From 82de74ca2b42704c251780c56886e25b25e9e471 Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 23 Jul 2026 02:18:53 -0500 Subject: [PATCH 01/20] fix(engine): Metamorphic Alteration as-enters choose + host copy (#6013) Latch the chosen creature's copiable values on the Aura and install a Layer-1 CopyValues TCE on the enchanted host so the second choice works without BecomeCopying the Aura. Co-authored-by: Cursor --- client/src/adapter/types.ts | 2 +- crates/engine/src/analysis/ability_graph.rs | 3 + crates/engine/src/game/ability_rw.rs | 11 + crates/engine/src/game/ability_scan.rs | 25 + crates/engine/src/game/coverage.rs | 7 + crates/engine/src/game/effects/mod.rs | 14 + .../engine_phase_trigger_regression_tests.rs | 8 + crates/engine/src/game/engine_replacement.rs | 166 +++++- crates/engine/src/game/layers.rs | 7 + crates/engine/src/game/printed_cards.rs | 6 + crates/engine/src/game/quantity.rs | 2 + .../engine/src/parser/oracle_effect/lower.rs | 4 + .../src/parser/oracle_effect/sequence.rs | 3 + crates/engine/src/parser/oracle_ir/doc.rs | 3 + .../engine/src/parser/oracle_replacement.rs | 85 ++- .../src/parser/oracle_static/dispatch.rs | 48 ++ .../engine/src/parser/oracle_static/shared.rs | 2 + crates/engine/src/types/ability.rs | 115 ++++ crates/engine/src/types/game_state.rs | 13 +- crates/engine/src/types/layers.rs | 5 + crates/engine/tests/integration/main.rs | 1 + .../integration/metamorphic_alteration.rs | 550 ++++++++++++++++++ crates/phase-ai/src/policies/copy_value.rs | 1 + .../phase-ai/src/policies/effect_classify.rs | 1 + .../src/policies/redundancy_avoidance.rs | 3 + crates/phase-ai/src/policies/x_reference.rs | 4 +- 26 files changed, 1071 insertions(+), 18 deletions(-) create mode 100644 crates/engine/tests/integration/metamorphic_alteration.rs diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 684e75f090..78d34b3698 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1639,7 +1639,7 @@ export type WaitingFor = | { type: "GameOver"; data: { winner: PlayerId | null } } | { type: "ReplacementChoice"; data: { player: PlayerId; candidate_count: number; candidates?: ReplacementCandidateSummary[] } } | { type: "OrderTriggers"; data: { player: PlayerId; triggers: PendingTriggerSummary[] } } - | { type: "CopyTargetChoice"; data: { player: PlayerId; source_id: ObjectId; valid_targets: ObjectId[]; max_mana_value?: number | null } } + | { type: "CopyTargetChoice"; data: { player: PlayerId; source_id: ObjectId; valid_targets: ObjectId[]; max_mana_value?: number | null; purpose?: { type: "BecomeCopy" | "PersistChosenAttribute" } } } | { type: "ExploreChoice"; data: { player: PlayerId; source_id: ObjectId; choosable: ObjectId[]; remaining: ObjectId[]; pending_effect: unknown } } | { type: "ReturnAsAuraTarget"; data: { player: PlayerId; source_id: ObjectId; returned_id: ObjectId; legal_targets: TargetRef[]; pending_effect: unknown } } | { type: "EquipTarget"; data: { player: PlayerId; equipment_id: ObjectId; valid_targets: ObjectId[] } } diff --git a/crates/engine/src/analysis/ability_graph.rs b/crates/engine/src/analysis/ability_graph.rs index 3eea0c026b..a5f93033a1 100644 --- a/crates/engine/src/analysis/ability_graph.rs +++ b/crates/engine/src/analysis/ability_graph.rs @@ -884,6 +884,9 @@ fn effect_projection(effect: &Effect) -> Projection { | Effect::HideawayConceal { .. } | Effect::CopyTokenBlockingAttacker { .. } | Effect::BecomeCopy { .. } + // CR 707.2c (Metamorphic Alteration): as-enters copy choice — no repeatable + // modeled axis at the candidate stage. + | Effect::ChoosePermanent { .. } | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::ChooseCard { .. } | Effect::DoublePT { .. } diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index e6127d76fe..971494a908 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -2710,6 +2710,9 @@ fn legacy_continuous_modification(m: &ContinuousModification) -> bool { legacy_quantity_expr(value) } ContinuousModification::CopyValues { .. } + // CR 707.2c (Metamorphic Alteration): inert parse-time copy marker — no + // frozen event-context tag. + | ContinuousModification::CopyChosen | ContinuousModification::SetName { .. } | ContinuousModification::SetTextName { .. } | ContinuousModification::AddPower { .. } @@ -3159,6 +3162,9 @@ fn legacy_effect(x: &Effect) -> bool { | Effect::CastFromZone { target, duration, .. } => legacy_target_filter(target) || odur(duration), + // CR 707.2c (Metamorphic Alteration): the copy-source choice pool is a + // target filter; walk it for legacy event-refs like every other filter. + Effect::ChoosePermanent { filter, .. } => legacy_target_filter(filter), Effect::GenericEffect { duration, target, @@ -5482,6 +5488,11 @@ fn rw_effect( // and may add counters from a live property read. Fail closed until the // copy/counter sub-steps have a precise profile. | Effect::EachPlayerCopyChosen { .. } + // CR 707.2c (Metamorphic Alteration): raised only from the Aura-ETB + // replacement path; on answer it installs a Layer-1 copy on the Aura's + // host. Never resolves through the intra-ability chain — fail-closed + // conservative keeps the exhaustive match honest. + | Effect::ChoosePermanent { .. } | Effect::Myriad | Effect::Encore | Effect::CombineHost { .. } diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index e7b79a6f74..07307d648e 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -890,6 +890,14 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes { acc } Effect::BecomeCopy { .. } => Axes::CONSERVATIVE, + // CR 707.2c: the chosen creature's copiable values are latched onto the + // Aura's host at the answer — a copy-family continuous effect, same + // conservative classification as `BecomeCopy`. `filter` scans no + // per-source projected resource (it just bounds the choice pool); + // `persist` is a compile-time selector. + Effect::ChoosePermanent { filter, persist: _ } => { + scan_target_filter(filter, target_ctx, mode).or(Axes::CONSERVATIVE) + } Effect::GainActivatedAbilitiesOfTarget { target, recipient, @@ -5002,6 +5010,10 @@ fn scan_continuous_modification(m: &ContinuousModification, mode: ScanMode) -> A // documented fail-safe (over-veto = missed offer). A full `TriggerDefinition` // walker is a follow-up. ContinuousModification::CopyValues { .. } + // CR 707.2c (Metamorphic Alteration): the copy-marker stands in for a + // copy that grants the donor's whole ability set — fail-closed alongside + // its `CopyValues` sibling (the real grant is the installed TCE). + | ContinuousModification::CopyChosen | ContinuousModification::GrantTrigger { .. } | ContinuousModification::GrantAllActivatedAbilitiesOf { .. } | ContinuousModification::GrantAllTriggeredAbilitiesOf { .. } @@ -5174,6 +5186,10 @@ fn effect_target_ctx(e: &Effect, mode: ScanMode) -> FilterReadContext { // recipient set ("Shards you control", CR 707.2). | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::BecomeCopy { .. } + // CR 707.2c (Metamorphic Alteration): the copy target is chosen from a + // battlefield-reading filter pool; defense-in-depth parity with + // BecomeCopy (fail-closed census — over-vetoes the single-host shortcut). + | Effect::ChoosePermanent { .. } // CR 708.2 / CR 708.2a (face-down permanents): `resolved_battlefield_object_ // ids` (effects/mod.rs) falls through to a battlefield mass scan for a // non-targeted "turn each matching creature face up/down" (Illithid @@ -5527,6 +5543,9 @@ fn effect_census_role(e: &Effect) -> CensusRole { | Effect::PhaseIn { .. } | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::BecomeCopy { .. } + // CR 707.2c (Metamorphic Alteration): parity with the `effect_target_ctx` + // LiveBoardCensus member added above. + | Effect::ChoosePermanent { .. } | Effect::TurnFaceUp { .. } | Effect::TurnFaceDown { .. } | Effect::MultiplyCounter { .. } @@ -5920,6 +5939,10 @@ fn effect_resolution_choice_freedom(e: &Effect) -> ResolutionChoiceFreedom { | Effect::HideawayConceal { .. } | Effect::CopyTokenBlockingAttacker { .. } | Effect::BecomeCopy { .. } + // CR 707.2c: raises `WaitingFor::CopyTargetChoice` — prompts, fail-closed + // MayPrompt (never resolved through the normal chain, but classified here + // to keep the match exhaustive). + | Effect::ChoosePermanent { .. } | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::ChooseCard { .. } | Effect::PutCounter { .. } @@ -6187,6 +6210,8 @@ pub(crate) fn effect_is_randomness_bearing(e: &Effect) -> bool { | Effect::HideawayConceal { .. } | Effect::CopyTokenBlockingAttacker { .. } | Effect::BecomeCopy { .. } + // CR 707.2c: choosing a permanent draws on no game randomness. + | Effect::ChoosePermanent { .. } | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::ChooseCard { .. } | Effect::PutCounter { .. } diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 7ff4b55ebd..09d7dcdc7f 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -2333,6 +2333,10 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> { } => { d.push(("target".into(), fmt_target(target))); } + // CR 707.2c (Metamorphic Alteration): report the copy-source choice pool. + Effect::ChoosePermanent { filter, .. } => { + d.push(("choose".into(), fmt_target(filter))); + } Effect::Destroy { target, .. } | Effect::Sacrifice { target, .. } | Effect::GainControl { target } @@ -4165,6 +4169,9 @@ fn fmt_modification(m: &crate::types::ability::ContinuousModification) -> String use crate::types::ability::ContinuousModification; match m { ContinuousModification::CopyValues { .. } => "copy values".into(), + // CR 707.2c (Metamorphic Alteration): parse-time marker for the enchanted + // host's copy — the runtime copy is the latched `CopyValues` TCE. + ContinuousModification::CopyChosen => "copy chosen".into(), ContinuousModification::SetName { name } => format!("set name {name}"), ContinuousModification::SetTextName { name } => format!("set text name {name}"), ContinuousModification::AddPower { value } => format!("power {:+}", value), diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 1917ec18e7..c46b9231a9 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -3827,6 +3827,20 @@ pub fn resolve_effect( copy_token_blocking::resolve(state, ability, events) } Effect::BecomeCopy { .. } => become_copy::resolve(state, ability, events), + // CR 614.12a + CR 707.2c (Metamorphic Alteration): `ChoosePermanent` is an + // as-enters replacement-effect choice, NOT a stack-resolution effect. It is + // raised by `apply_post_replacement_effect` and answered by + // `handle_copy_target_choice` (PersistChosenAttribute). It never enters the + // normal resolution chain, so reaching this dispatch arm is a bug. + Effect::ChoosePermanent { .. } => { + debug_assert!( + false, + "Effect::ChoosePermanent must be handled via the Aura-ETB \ + replacement path (apply_post_replacement_effect), never resolved \ + as a stack effect" + ); + Ok(()) + } Effect::GainActivatedAbilitiesOfTarget { .. } => { gain_activated_abilities::resolve(state, ability, events) } diff --git a/crates/engine/src/game/engine_phase_trigger_regression_tests.rs b/crates/engine/src/game/engine_phase_trigger_regression_tests.rs index b953bd9160..3a81004797 100644 --- a/crates/engine/src/game/engine_phase_trigger_regression_tests.rs +++ b/crates/engine/src/game/engine_phase_trigger_regression_tests.rs @@ -3448,6 +3448,7 @@ fn copy_target_choice_resolves_become_copy() { source_id: clone_id, valid_targets: vec![target_id], max_mana_value: None, + purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy, }; // Player chooses to copy Grizzly Bears @@ -3542,6 +3543,7 @@ fn copy_target_choice_applies_copied_enter_with_counters_replacement_before_sba( source_id: assassin, valid_targets: vec![ghave], max_mana_value: None, + purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy, }; apply_as_current( @@ -3615,6 +3617,7 @@ fn echoing_deeps_copying_sunken_citadel_prompts_for_the_copied_color_choice() { source_id: deeps, valid_targets: vec![citadel], max_mana_value: None, + purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy, }; let result = apply_as_current( @@ -3922,6 +3925,7 @@ fn copy_target_choice_fires_granted_etb_trigger_against_deferred_entry_event() { source_id: assassin, valid_targets: vec![bear], max_mana_value: None, + purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy, }; apply_as_current( @@ -4103,6 +4107,7 @@ fn copy_target_choice_surfaces_interactive_trigger_prompt_for_deferred_entry() { source_id: assassin, valid_targets: vec![bear], max_mana_value: None, + purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy, }; let _waiting = apply_as_current( @@ -4182,6 +4187,7 @@ fn copy_target_choice_rejects_invalid_target() { source_id: clone_id, valid_targets: vec![valid_id], // Bird is NOT in valid targets max_mana_value: None, + purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy, }; // Try to choose invalid target @@ -4332,6 +4338,7 @@ fn superior_spider_man_full_copy_flow_copies_graveyard_card_and_exiles_it() { source_id: spidey, valid_targets: vec![elesh], max_mana_value: None, + purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy, }; let result = apply_as_current( @@ -4508,6 +4515,7 @@ fn reflexive_when_you_do_fires_after_become_copy_replacement() { source_id: cloner, valid_targets: vec![source_card], max_mana_value: None, + purpose: crate::types::ability::CopyTargetPurpose::BecomeCopy, }; // Accumulate events across the full resolution so we can count diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 317f008749..2ac02e3eab 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -2,8 +2,8 @@ use std::collections::HashSet; use crate::ai_support::copy_target_mana_value_ceiling; use crate::types::ability::{ - AbilityDefinition, Effect, PostReplacementContinuation, ResolvedAbility, TargetFilter, - TargetRef, + AbilityDefinition, CopyTargetPurpose, Effect, PostReplacementContinuation, ResolvedAbility, + TargetFilter, TargetRef, }; #[cfg(test)] use crate::types::ability::{EffectScope, TapStateChange}; @@ -1326,6 +1326,135 @@ pub(super) fn handle_replacement_choice( } } +/// CR 707.2c + CR 614.12a + CR 613.1a: Answer path for Metamorphic Alteration's +/// "As this Aura enters, choose a creature." Latches the chosen creature's +/// copiable values (fixed here, per CR 707.2c, as the copy effect first starts +/// to apply — later changes to the chosen creature never propagate, CR 707.2b) +/// and installs them as a Layer-1 `CopyValues` transient continuous effect +/// SOURCED FROM the Aura and applied to the Aura's enchanted host. The Aura +/// itself is left untouched (it does not become a copy). The install reuses the +/// single copy-values authority (`become_copy::apply_precomputed_copy_values`), +/// which strips exceptions / flushes layers / emits `EffectResolved`. +fn handle_persist_chosen_attribute_choice( + state: &mut GameState, + source_id: ObjectId, + controller: PlayerId, + donor_id: ObjectId, + events: &mut Vec, +) -> Result { + // CR 707.2c: copiable values are determined at the moment the effect first + // starts to apply — snapshot the donor now. + let values = crate::game::layers::compute_current_copiable_values(state, donor_id).ok_or( + EngineError::InvalidAction("Chosen copy source no longer exists".to_string()), + )?; + // CR 111.1 + CR 707.2: art routing follows the copy (token vs printed + // source), captured alongside the values — NOT a copiable value itself. + let (display_source, printed_ref, token_image_ref) = state + .objects + .get(&donor_id) + .map(|o| { + ( + o.display_source, + o.printed_ref.clone(), + o.token_image_ref.clone(), + ) + }) + .unwrap_or_default(); + + // CR 400.7: persist the frozen snapshot on the Aura so it is cleared + // automatically when the Aura changes zones (exactly the copy's lifetime). + let snapshot = crate::types::ability::LatchedCopiableSnapshot { + values: values.clone(), + display_source, + printed_ref: printed_ref.clone(), + token_image_ref: token_image_ref.clone(), + }; + if let Some(aura) = state.objects.get_mut(&source_id) { + aura.chosen_attributes + .push(crate::types::ability::ChosenAttribute::CopiableSnapshot( + Box::new(snapshot), + )); + } + + // CR 303.4 + CR 702.5: the Aura enchants a creature — its host is the + // recipient of the copy effect. On the spell-cast path the Aura is put onto + // the battlefield ALREADY attached to its enchant target (CR 608.3c — NOT + // CR 303.4f, which governs only Auras entering "by any means other than by + // resolving as an Aura spell"). That attach runs in the Aura-spell + // resolution tail in `stack.rs` (the `is_aura` block at the CR 608.3c + // comment) BEFORE this `CopyTargetChoice` answer is processed: on the + // Execute path the copy choice is raised while draining post-replacement + // effects, execution then falls through to the CR 608.3c attach, and only + // then is priority (carrying the pending choice) returned — so `attached_to` + // is already `Some` here. CR 608.2b guarantees it further: an Aura spell + // whose sole enchant target is illegal at resolution is countered and never + // enters, so the "as this Aura enters, choose a creature" replacement never + // fires with a hostless Aura. No defensive attach is performed here. + let host = state + .objects + .get(&source_id) + .and_then(|aura| aura.attached_to.as_ref()) + .and_then(|attach| attach.as_object()); + + if let Some(host_id) = host { + // CR 707.2c + CR 613.1a + CR 611.2a: install the copy as a transient + // continuous effect sourced from the Aura, applied to the host, ending + // when the Aura leaves the battlefield (`UntilHostLeavesPlay` prunes on + // `tce.source_id` leaving — the Aura). `duration_subject_id` tracks the + // host for any recipient-relative duration reads (harmless here). + let copy = super::effects::become_copy::PrecomputedCopyValues { + source_id, + controller, + duration_subject_id: host_id, + duration: crate::types::ability::Duration::UntilHostLeavesPlay, + values, + display_source, + printed_ref, + token_image_ref, + additional_modifications: Vec::new(), + effect_kind: crate::types::ability::EffectKind::ChoosePermanent, + }; + super::effects::become_copy::apply_precomputed_copy_values(state, host_id, copy, events) + .map_err(|e| EngineError::InvalidAction(format!("{e:?}")))?; + } else { + // Unreachable on the spell-cast path (see the CR 608.3c / CR 608.2b note + // above). If it were ever reached, the copy simply does not apply and the + // unattached Aura is cleaned up by SBA (CR 704.5m) — never invent an + // attach here. + debug_assert!( + false, + "Metamorphic Alteration's Aura must be attached to a creature host when \ + the copy choice is answered (CR 608.3c: the spell-cast attach runs \ + before the CopyTargetChoice answer)" + ); + } + + // CR 707.4: changing the host's copiable identity is NOT a zone change and + // must not disturb the Aura's own identity. + debug_assert!( + state + .objects + .get(&source_id) + .is_some_and(|aura| aura.attached_to.is_some()), + "the Aura's own identity/attachment must be unchanged by installing the host copy" + ); + + // CR 614.12a + CR 603.2: replay the Aura's deferred battlefield-entry event + // now that the host copy is realized, so ETB observers see the final state, + // then retire this paused post-replacement dispatch (mirrors the BecomeCopy + // completion tail). + if let Some(waiting_for) = replay_deferred_entry_events(state, source_id, events)? { + return Ok(waiting_for); + } + state.finish_active_paused_post_replacement_dispatch(); + if !matches!(state.waiting_for, WaitingFor::Priority { .. }) { + return Ok(state.waiting_for.clone()); + } + Ok(WaitingFor::Priority { + player: state.active_player, + }) +} + pub(super) fn handle_copy_target_choice( state: &mut GameState, waiting_for: WaitingFor, @@ -1336,6 +1465,7 @@ pub(super) fn handle_copy_target_choice( player, source_id, valid_targets, + purpose, .. } = waiting_for else { @@ -1353,6 +1483,15 @@ pub(super) fn handle_copy_target_choice( } }; + // CR 707.2c + CR 614.12a: Metamorphic Alteration — the chosen creature's + // copiable values are latched onto the source Aura and installed as a + // Layer-1 copy effect on the Aura's enchanted host. The entering Aura is + // NEVER turned into a copy (never `BecomeCopy`), and this path never runs + // the enter-as-a-copy / copy-token completion tail below. + if matches!(purpose, CopyTargetPurpose::PersistChosenAttribute) { + return handle_persist_chosen_attribute_choice(state, source_id, player, target_id, events); + } + if state.liminal_entries.contains_key(&source_id) { let Some(resume) = state.pending_liminal_entry_resume.take() else { return Err(EngineError::InvalidAction( @@ -1938,10 +2077,33 @@ pub(super) fn apply_post_replacement_effect( source_id, valid_targets, max_mana_value, + purpose: CopyTargetPurpose::BecomeCopy, }); } } + // CR 614.12a + CR 707.2c: "As this Aura enters, choose a creature." The + // chosen permanent's copiable values are latched onto the source Aura and + // installed as a copy effect on the Aura's enchanted host at the answer + // (`PersistChosenAttribute`) — never onto the entering Aura itself. This + // reuses the same `CopyTargetChoice` machinery as `BecomeCopy` (it is a + // choice, not targeting — hexproof/shroud don't apply, CR 115.10a) but is + // discriminated by `purpose`. Empty legal-choice set → no prompt (CR 609.3: + // an effect does only as much as possible). + if let Effect::ChoosePermanent { ref filter, .. } = *real_work.effect { + let valid_targets = find_copy_targets(state, filter, source_id, controller, None); + if valid_targets.is_empty() { + return None; + } + return Some(WaitingFor::CopyTargetChoice { + player: controller, + source_id, + valid_targets, + max_mana_value: None, + purpose: CopyTargetPurpose::PersistChosenAttribute, + }); + } + // CR 614.1c: The injected `Object(source)` target is the source-as-SelfRef // hook for replacement post-effects that consume their source (BecomeCopy, // PutCounter, Choose). For an interactive chooser-driven `Effect::Sacrifice` diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 6e7f427dbe..3aeaacf95f 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -6095,6 +6095,13 @@ fn apply_continuous_effect_filtered( }; match &effect.modification { + // CR 707.2c + CR 613.1a: `CopyChosen` is a parse-time marker for + // Metamorphic Alteration's "enchanted creature is a copy of the + // chosen creature" static. The copy is materialized exactly once — + // as a latched `CopyValues` TCE installed at the + // `Effect::ChoosePermanent` answer (values fixed per CR 707.2c) — + // so applying anything here would double-install. Explicit no-op. + ContinuousModification::CopyChosen => {} ContinuousModification::CopyValues { values, display_source, diff --git a/crates/engine/src/game/printed_cards.rs b/crates/engine/src/game/printed_cards.rs index 8fe5d9d215..9bda5a233f 100644 --- a/crates/engine/src/game/printed_cards.rs +++ b/crates/engine/src/game/printed_cards.rs @@ -849,6 +849,9 @@ fn walk_continuous_mod(modification: &ContinuousModification, out: &mut Vec) { | Effect::HideawayConceal { .. } | Effect::CopyTokenBlockingAttacker { .. } | Effect::BecomeCopy { .. } + // CR 707.2c (Metamorphic Alteration): filter-only copy choice; no nested + // ability carrier to walk — a leaf for printed-card collection. + | Effect::ChoosePermanent { .. } | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::ChooseCard { .. } | Effect::PutCounter { .. } diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index f7560a8c69..a2aa46a2cf 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -734,6 +734,8 @@ pub(crate) fn continuous_modification_dynamic_quantity( // magnitude. Enumerated explicitly (no wildcard) so a future // QuantityExpr-carrying variant forces a decision here. ContinuousModification::CopyValues { .. } + // CR 707.2c (Metamorphic Alteration): inert copy marker — no dynamic magnitude. + | ContinuousModification::CopyChosen | ContinuousModification::SetName { .. } | ContinuousModification::SetTextName { .. } | ContinuousModification::AddPower { .. } diff --git a/crates/engine/src/parser/oracle_effect/lower.rs b/crates/engine/src/parser/oracle_effect/lower.rs index b7e2581a49..a10d65ea9b 100644 --- a/crates/engine/src/parser/oracle_effect/lower.rs +++ b/crates/engine/src/parser/oracle_effect/lower.rs @@ -9025,6 +9025,8 @@ fn apply_where_x_continuous_modification( // Keep this wildcard-free so a future QuantityExpr-carrying variant // forces a deliberate where-X decision. ContinuousModification::CopyValues { .. } + // CR 707.2c (Metamorphic Alteration): inert copy marker — no where-X carrier. + | ContinuousModification::CopyChosen | ContinuousModification::SetName { .. } | ContinuousModification::SetTextName { .. } | ContinuousModification::AddPower { .. } @@ -9123,6 +9125,8 @@ fn rebind_target_anaphor_continuous_modification(modification: &mut ContinuousMo ContinuousModification::AddCounterOnEnter { .. } | ContinuousModification::SetStartingLoyalty { .. } => {} ContinuousModification::CopyValues { .. } + // CR 707.2c (Metamorphic Alteration): inert copy marker — no where-X carrier. + | ContinuousModification::CopyChosen | ContinuousModification::SetName { .. } | ContinuousModification::SetTextName { .. } | ContinuousModification::AddPower { .. } diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs index 465c44a762..cbfc10b991 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -5954,6 +5954,9 @@ pub(super) fn clause_is_dig_lookback_transparent(effect: &Effect) -> bool { | Effect::HideawayConceal { .. } | Effect::CopyTokenBlockingAttacker { .. } | Effect::BecomeCopy { .. } + // CR 707.2c (Metamorphic Alteration): the as-enters copy choice is its + // own resolving effect, not a Dig-lookback-transparent clause. + | Effect::ChoosePermanent { .. } | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::ChooseCard { .. } | Effect::PutCounter { .. } diff --git a/crates/engine/src/parser/oracle_ir/doc.rs b/crates/engine/src/parser/oracle_ir/doc.rs index e8cec36a5e..1b2a94638e 100644 --- a/crates/engine/src/parser/oracle_ir/doc.rs +++ b/crates/engine/src/parser/oracle_ir/doc.rs @@ -1414,6 +1414,9 @@ fn stamp_effect_printed_slot(effect: &mut Effect, slot: usize, kind: PrintedItem Effect::ApplyPerpetual { .. } => {} Effect::Intensify { .. } => {} Effect::DraftFromSpellbook { .. } => {} + // CR 707.2c (Metamorphic Alteration): no nested printed-slot carrier — + // the copy is materialized from the chosen donor at resolution. + Effect::ChoosePermanent { .. } => {} Effect::Unimplemented { .. } => {} } } diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 34c152300e..11d4667d39 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -26,22 +26,22 @@ use super::oracle_nom::primitives as nom_primitives; use super::oracle_nom::quantity as nom_quantity; use super::oracle_nom::target::parse_type_filter_word; use super::oracle_quantity::capitalize_first; -use super::oracle_target::parse_type_phrase; +use super::oracle_target::{parse_target, parse_type_phrase}; use super::oracle_util::{ normalize_card_name_refs, parse_count_expr, parse_number, parse_ordinal, strip_after, strip_reminder_text, TextPair, }; use crate::types::ability::CastingPermission; use crate::types::ability::{ - AbilityCost, AbilityDefinition, AbilityKind, CastVariantPaid, ChoiceType, CombatDamageScope, - Comparator, ContinuousModification, ControllerRef, CopyManaValueLimit, CountScope, - CounterReplacementSubject, DamageModification, DamageRedirectTarget, DamageTargetFilter, - DamageTargetPlayerScope, DrawReplacementScope, Duration, Effect, EffectScope, FilterProp, - LibraryPosition, ManaModification, ManaReplacementScope, ManaSpendPermission, - PermissionGrantee, PlayerFilter, PreventionAmount, QuantityExpr, QuantityModification, - QuantityRef, ReplacementCondition, ReplacementDefinition, ReplacementMode, - ReplacementPlayerScope, StaticCondition, StaticDefinition, TapStateChange, TargetFilter, - TypeFilter, TypedFilter, + AbilityCost, AbilityDefinition, AbilityKind, CastVariantPaid, ChoiceType, + ChoosePermanentPersist, CombatDamageScope, Comparator, ContinuousModification, ControllerRef, + CopyManaValueLimit, CountScope, CounterReplacementSubject, DamageModification, + DamageRedirectTarget, DamageTargetFilter, DamageTargetPlayerScope, DrawReplacementScope, + Duration, Effect, EffectScope, FilterProp, LibraryPosition, ManaModification, + ManaReplacementScope, ManaSpendPermission, PermissionGrantee, PlayerFilter, PreventionAmount, + QuantityExpr, QuantityModification, QuantityRef, ReplacementCondition, ReplacementDefinition, + ReplacementMode, ReplacementPlayerScope, StaticCondition, StaticDefinition, TapStateChange, + TargetFilter, TypeFilter, TypedFilter, }; use crate::types::card_type::Supertype; use crate::types::counter::{CounterMatch, CounterType}; @@ -2124,7 +2124,14 @@ fn parse_as_enters_choose(norm_lower: &str, original_text: &str) -> Option>("choose ").parse(i) })?; - let choice_type = try_parse_named_choice(choose_text)?; + // CR 614.12a + CR 707.2c: "As this Aura enters, choose a creature." is an + // as-enters OBJECT choice, not a named-attribute choice, so + // `try_parse_named_choice` (which only recognizes color/type/name/etc.) + // fails. Fall back to the object-choice combinator that lowers to + // `Effect::ChoosePermanent` (Metamorphic Alteration) before giving up. + let Some(choice_type) = try_parse_named_choice(choose_text) else { + return parse_as_enters_choose_permanent(choose_text, original_text); + }; let choose = AbilityDefinition::new( AbilityKind::Spell, @@ -2273,6 +2280,62 @@ fn parse_as_enters_choose(norm_lower: &str, original_text: &str) -> Option Option { + let (object_phrase, _) = tag::<_, _, OracleError<'_>>("choose ") + .parse(choose_text) + .ok()?; + // Structural trailing-period/whitespace cleanup on the extracted object + // phrase (not parsing dispatch) before the descriptor grammar runs. + let object_phrase = object_phrase + .trim_end() + .strip_suffix('.') // allow-noncombinator: structural trailing-period cleanup, not dispatch + .unwrap_or(object_phrase) + .trim(); + let (filter, remainder) = parse_target(object_phrase); + if !remainder.trim().is_empty() { + return None; + } + // CR 707.2c: the copy source is a permanent — only a concrete object filter + // (never a player/zone/`Any` descriptor) is a valid copy-source pool. + if !matches!(filter, TargetFilter::Typed(_)) { + return None; + } + + let execute = AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChoosePermanent { + filter, + persist: ChoosePermanentPersist::CopiableSnapshot, + }, + ); + + Some( + ReplacementDefinition::new(ReplacementEvent::Moved) + .execute(execute) + .valid_card(TargetFilter::SelfRef) + // CR 614.1c: battlefield-entry-scoped, mirroring the named-choice sibling. + .destination_zone(Zone::Battlefield) + .description(original_text.to_string()), + ) +} + /// Parse "As ~ becomes attached [to a creature/permanent], choose …" into an /// `Attached`-event replacement with a persisted `Choose` (Psychic Paper: "As /// this Equipment becomes attached to a creature, choose a creature card name diff --git a/crates/engine/src/parser/oracle_static/dispatch.rs b/crates/engine/src/parser/oracle_static/dispatch.rs index 57930efb7e..1a291e2ccf 100644 --- a/crates/engine/src/parser/oracle_static/dispatch.rs +++ b/crates/engine/src/parser/oracle_static/dispatch.rs @@ -27,6 +27,46 @@ fn parse_self_reference_subject(input: &str) -> OracleResult<'_, ()> { Err(oracle_err(input)) } +/// CR 707.2c + CR 613.1a + CR 303.4: "Enchanted is a copy of the +/// chosen ." — Metamorphic Alteration's companion static. Emits the +/// parse-time `ContinuousModification::CopyChosen` MARKER affecting the +/// enchanted host. The marker is a Layer-1 no-op at apply time: the real copy +/// is installed as a `CopyValues` transient continuous effect when the +/// `Effect::ChoosePermanent` as-enters choice is answered (per CR 707.2c the +/// copiable values are fixed only when the effect first starts to apply). +/// Emitting the static keeps the printed line "supported" and documents the +/// copy layer on the card. The donor type after "the chosen" is informational +/// (the concrete copy source is picked at entry), so it is required to parse +/// but is not otherwise threaded. +fn parse_enchanted_is_copy_of_chosen(tp: &TextPair, text: &str) -> Option { + let rest = nom_tag_tp(tp, "enchanted ")?; + // Subject type of the enchanted host (creature / permanent). Trim the + // trailing period first so the donor phrase parses to an empty remainder. + let subject_lower = rest.lower.trim_end_matches('.').trim(); + let (subject_filter, after_subject) = parse_type_phrase(subject_lower); + let TargetFilter::Typed(mut subject) = subject_filter else { + return None; + }; + let (donor_lower, _) = tag::<_, _, OracleError<'_>>(" is a copy of the chosen ") + .parse(after_subject) + .ok()?; + // The donor type ("creature" / "permanent") must parse and fully consume the + // remainder — a trailing rider (e.g. "except it has flying") is not modeled + // by the marker, so bail rather than silently drop it. + let (_donor_filter, donor_rest) = parse_type_phrase(donor_lower); + if !donor_rest.trim().is_empty() { + return None; + } + // CR 303.4 + CR 613.1: the static affects the enchanted host. + subject.properties.push(FilterProp::EnchantedBy); + Some( + StaticDefinition::continuous() + .affected(TargetFilter::Typed(subject)) + .modifications(vec![ContinuousModification::CopyChosen]) + .description(text.to_string()), + ) +} + /// CR 208.1 + CR 113.7: Parse the dynamic referent of a "{X} … less to activate, /// where X is [source]'s {power|toughness|mana value}" activated-ability cost /// reduction (Agatha of the Vile Cauldron — "where X is Agatha's power", which @@ -1232,6 +1272,14 @@ pub(crate) fn parse_static_line_inner( if let Some(def) = parse_becomes_equipment_with_ability(&tp, &text) { return Some(def); } + // CR 707.2c + CR 613.1a + CR 303.4: "Enchanted is a copy of the + // chosen " — Metamorphic Alteration's companion static. Must precede + // `parse_enchanted_is_type`, whose "is a " copula would otherwise try + // (and fail) to read "copy" as a card type. + if let Some(def) = parse_enchanted_is_copy_of_chosen(&tp, &text) { + return Some(def); + } + // CR 613.1d + CR 205.1a: "Enchanted [permanent-type] is a [type] [with base P/T N/N] // [in addition to its other types]" — type-changing aura effects. // Must come before the basic-land-type handler which is a subset of this pattern. diff --git a/crates/engine/src/parser/oracle_static/shared.rs b/crates/engine/src/parser/oracle_static/shared.rs index 3796bf5057..0243865e74 100644 --- a/crates/engine/src/parser/oracle_static/shared.rs +++ b/crates/engine/src/parser/oracle_static/shared.rs @@ -883,6 +883,8 @@ fn continuous_modification_dynamic_quantity_mut( ContinuousModification::AddCounterOnEnter { .. } | ContinuousModification::SetStartingLoyalty { .. } | ContinuousModification::CopyValues { .. } + // CR 707.2c (Metamorphic Alteration): inert copy marker — no dynamic quantity. + | ContinuousModification::CopyChosen | ContinuousModification::SetName { .. } | ContinuousModification::SetTextName { .. } | ContinuousModification::AddPower { .. } diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index b55e8cf8c5..e420d51490 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -1334,6 +1334,16 @@ pub enum ChosenAttribute { /// Replace-on-rechoose: the {Left,Right} hijack in `choose.rs` removes any /// prior `Direction` before pushing, so only the "last chosen" survives. Direction(SeatDirection), + /// CR 707.2c + CR 614.12a + CR 400.7: The copiable-values snapshot chosen as + /// an `Effect::ChoosePermanent { persist: CopiableSnapshot }` Aura entered + /// (Metamorphic Alteration: "choose a creature"). Like `Card` and + /// `TributeOutcome`, this is ENGINE-SET (written directly at the + /// `CopyTargetChoice` answer, not produced through `ChoiceType`/`from_choice`) + /// — it records the frozen snapshot the companion copy effect was installed + /// from, and, being stored in `chosen_attributes`, is cleared automatically + /// when the Aura changes zones (CR 400.7), which is exactly the copy's + /// lifetime. Boxed to keep the enum small (mirrors the copy-value box idiom). + CopiableSnapshot(Box), } impl ChosenAttribute { @@ -1396,6 +1406,13 @@ impl ChosenAttribute { Self::Direction(_) => ChoiceType::Labeled { options: vec!["Left".into(), "Right".into()], }, + // Engine-set, never player-prompted (written directly at the + // `CopyTargetChoice` answer, not via `from_choice`). Mirrors the + // `Card` placeholder: an empty `Labeled` template rather than + // inventing a spurious copy-snapshot choice category. + Self::CopiableSnapshot(_) => ChoiceType::Labeled { + options: Vec::new(), + }, } } @@ -10847,6 +10864,23 @@ pub enum Effect { #[serde(default, skip_serializing_if = "Vec::is_empty")] additional_modifications: Vec, }, + /// CR 614.12a + CR 707.2c: "As this Aura enters, choose a creature." The + /// controller chooses a permanent matching `filter` as the Aura enters + /// (CR 614.12a: the choice is made before the permanent enters). Unlike + /// `BecomeCopy` — where the *entering* object becomes the copy — this effect + /// latches the chosen permanent's copiable values and applies them to a + /// *separate* recipient determined by `persist`. For + /// `ChoosePermanentPersist::CopiableSnapshot` (Metamorphic Alteration) the + /// recipient is the Aura's enchanted host; the companion static + /// (`ContinuousModification::CopyChosen`) is a parse-time marker only — the + /// copy is materialized once, at the choice answer, as a Layer-1 + /// `CopyValues` transient continuous effect whose values are fixed per + /// CR 707.2c (determined when the copy effect first starts to apply). + ChoosePermanent { + #[serde(default = "default_target_filter_any")] + filter: TargetFilter, + persist: ChoosePermanentPersist, + }, /// CR 113.1a + CR 113.10 + CR 611.2 + CR 611.2c + CR 613.1f: Grant the /// recipient(s) all activated abilities of a chosen target object, for a /// duration. Unlike the static-side `ContinuousModification::GrantAllActivatedAbilitiesOf` @@ -14384,6 +14418,10 @@ impl Effect { // target) picked at resolution time via // `WaitingFor::ReturnAsAuraTarget`. No stack-push target slot. | Effect::ReturnAsAura { .. } + // CR 614.12a + CR 707.2c: Metamorphic Alteration's "As this Aura + // enters, choose a creature" is an as-enters replacement CHOICE picked + // via `WaitingFor::CopyTargetChoice`, never a stack-declared target. + | Effect::ChoosePermanent { .. } | Effect::ChooseFromZone { .. } | Effect::ForEachCategory { .. } | Effect::ChooseAndSacrificeRest { .. } @@ -14689,6 +14727,8 @@ impl Effect { | Effect::HideawayConceal { .. } | Effect::CopyTokenBlockingAttacker { .. } | Effect::BecomeCopy { .. } + // CR 707.2c: the copy-source choice carries no magnitude. + | Effect::ChoosePermanent { .. } | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::ChooseCard { .. } | Effect::MultiplyCounter { .. } @@ -14942,6 +14982,8 @@ impl Effect { | Effect::HideawayConceal { .. } | Effect::CopyTokenBlockingAttacker { .. } | Effect::BecomeCopy { .. } + // CR 707.2c: the copy-source choice carries no magnitude. + | Effect::ChoosePermanent { .. } | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::ChooseCard { .. } | Effect::MultiplyCounter { .. } @@ -15152,6 +15194,7 @@ pub fn effect_variant_name(effect: &Effect) -> &str { Effect::HideawayConceal { .. } => "HideawayConceal", Effect::CopyTokenBlockingAttacker { .. } => "CopyTokenBlockingAttacker", Effect::BecomeCopy { .. } => "BecomeCopy", + Effect::ChoosePermanent { .. } => "ChoosePermanent", Effect::GainActivatedAbilitiesOfTarget { .. } => "GainActivatedAbilitiesOfTarget", Effect::ChooseCard { .. } => "ChooseCard", Effect::PutCounter { .. } => "PutCounter", @@ -15371,6 +15414,7 @@ pub enum EffectKind { Tribute, TimeTravel, BecomeMonarch, + ChoosePermanent, NoOp, Proliferate, ProliferateTarget, @@ -15653,6 +15697,7 @@ impl From<&Effect> for EffectKind { // is bookkeeping layered on top of the same token-copy creation. Effect::CopyTokenBlockingAttacker { .. } => EffectKind::CopyTokenOf, Effect::BecomeCopy { .. } => EffectKind::BecomeCopy, + Effect::ChoosePermanent { .. } => EffectKind::ChoosePermanent, Effect::GainActivatedAbilitiesOfTarget { .. } => { EffectKind::GainActivatedAbilitiesOfTarget } @@ -20499,6 +20544,64 @@ pub struct CopiableValues { pub static_definitions: Arc>, } +/// CR 707.2c: How the permanent chosen by `Effect::ChoosePermanent` is +/// consumed. Extensible axis (build-for-the-class): today only the +/// copiable-snapshot form is printed (Metamorphic Alteration). A future +/// as-enters "choose a permanent" clause with a different persisted +/// consequence adds a sibling leaf here, never a new `Effect` variant. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ChoosePermanentPersist { + /// CR 707.2c + CR 613.1a + CR 707.2b: Latch the chosen permanent's copiable + /// values (fixed at the moment the copy effect first starts to apply per + /// CR 707.2c; unaffected by later changes to the chosen object per + /// CR 707.2b) and install them as a Layer-1 copy effect on the source + /// Aura's enchanted host. Persisted as `ChosenAttribute::CopiableSnapshot` + /// on the Aura. + CopiableSnapshot, +} + +/// CR 707.2b + CR 707.2c + CR 111.1: A copiable-values snapshot latched when an +/// `Effect::ChoosePermanent { persist: CopiableSnapshot }` choice is answered. +/// Per CR 707.2c the copiable values a static copy effect grants are determined +/// only when the effect first starts to apply, and per CR 707.2b later changes +/// to the chosen object never propagate — so the values are frozen here rather +/// than re-read live. The display trio (`display_source` / `printed_ref` / +/// `token_image_ref`) is art routing only (NOT a CR 707.2 copiable value), +/// carried so the enchanted host renders the chosen creature's art; it mirrors +/// the identical trio on `ContinuousModification::CopyValues`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LatchedCopiableSnapshot { + pub values: CopiableValues, + #[serde(default)] + pub display_source: DisplaySource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub printed_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_image_ref: Option, +} + +/// CR 707.2 + CR 707.2c: Why a `WaitingFor::CopyTargetChoice` was raised, which +/// determines how the answer is consumed in `handle_copy_target_choice`. +/// `BecomeCopy` (default for serde back-compat with pre-existing serialized +/// enter-as-a-copy waits) resolves the entering object into a copy of the +/// chosen permanent. `PersistChosenAttribute` (Metamorphic Alteration) latches +/// the chosen permanent's copiable values onto the source Aura and installs a +/// copy effect on the Aura's enchanted host instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(tag = "type")] +pub enum CopyTargetPurpose { + /// CR 707.2: the entering object (the clone) becomes a copy of the chosen + /// permanent, acquiring its copiable values. Every classic enter-as-a-copy + /// card (Clone, Phantasmal Image, Vesuva, meld/embalm copy tokens). + #[default] + BecomeCopy, + /// CR 707.2c + CR 614.12a: the chosen permanent's copiable values are + /// latched and applied to a *separate* recipient (the source Aura's host) + /// rather than to the entering object. Metamorphic Alteration. + PersistChosenAttribute, +} + /// CR 702.143d + CR 702 (alternative-cost cast-from-off-zone family): how a /// granted cost-bearing keyword's cost is DERIVED from each recipient. Extensible /// axis (build-for-the-class): today only the reduced-mana-cost form is printed; @@ -20557,6 +20660,18 @@ pub enum ContinuousModification { #[serde(default)] token_image_ref: Option, }, + /// CR 707.2c + CR 613.1a: Parse-time MARKER for the static ability + /// "enchanted creature is a copy of the chosen creature" (Metamorphic + /// Alteration). It exists so the card's static parse claims copy support at + /// Layer 1, but it carries NO layered behavior: the actual copy is + /// materialized once — at the `Effect::ChoosePermanent` answer — as a + /// `CopyValues` transient continuous effect whose values are latched per + /// CR 707.2c (fixed when the copy effect first starts to apply). Applying it + /// in the layer pipeline would double-install the copy, so its + /// `apply_continuous_effect` arm is an explicit no-op. Layered at + /// `Layer::Copy` purely for correct copy-layer ordering/dependency + /// bookkeeping alongside the real `CopyValues` TCE. + CopyChosen, /// CR 707.9 + CR 707.2: Override the copy's name after `CopyValues` applies. /// Used by "enter as a copy, except its name is X" (e.g., Superior Spider-Man's /// Mind Swap). Applied in Layer 1 so the override is part of the copy's diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 00154dd7b0..87b8801811 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -10,9 +10,9 @@ use super::ability::{ AdditionalCostInstance, AdditionalCostInstancePayment, AttackSubject, BeholdCostAction, CastTimingPermission, CastVariantPaid, CategoryChooserScope, ChoiceType, ChoiceValue, ChooseFromZoneConstraint, ChosenAttribute, CoinFlipResult, Comparator, ContinuousModification, - ControlWindow, CopiableValues, CopyChooseScope, CopyScale, CostPaidObjectSnapshot, - CounterCostSelection, DelayedTriggerCondition, Duration, EffectKind, FaceDownProfile, - GameRestriction, KeywordAction, KickerVariant, LibraryPosition, ModalChoice, + ControlWindow, CopiableValues, CopyChooseScope, CopyScale, CopyTargetPurpose, + CostPaidObjectSnapshot, CounterCostSelection, DelayedTriggerCondition, Duration, EffectKind, + FaceDownProfile, GameRestriction, KeywordAction, KickerVariant, LibraryPosition, ModalChoice, PermanentEntryMode, PileSource, QuantityExpr, ResolvedAbility, SearchDestinationSplit, SearchSelectionConstraint, StaticCondition, TapCreaturesAggregate, TargetFilter, TargetRef, ThisWayCause, TriggerCondition, TriggerDefinition, TriggerDefinitionRef, TriggerEntry, @@ -7519,6 +7519,13 @@ pub enum WaitingFor { valid_targets: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] max_mana_value: Option, + /// CR 707.2 + CR 707.2c: Why this choice was raised — whether the + /// entering object becomes the copy (`BecomeCopy`, default for + /// serde back-compat) or the chosen permanent's values are latched onto + /// a source Aura's host (`PersistChosenAttribute`, Metamorphic + /// Alteration). Read at the answer in `handle_copy_target_choice`. + #[serde(default)] + purpose: CopyTargetPurpose, }, /// CR 701.44d: Player chooses which of their remaining permanents explores next. ExploreChoice { diff --git a/crates/engine/src/types/layers.rs b/crates/engine/src/types/layers.rs index f63dd78875..b8b7a1fb37 100644 --- a/crates/engine/src/types/layers.rs +++ b/crates/engine/src/types/layers.rs @@ -81,6 +81,11 @@ impl ContinuousModification { pub fn layer(&self) -> Layer { match self { ContinuousModification::CopyValues { .. } => Layer::Copy, + // CR 707.2c + CR 613.1a: parse-time marker for Metamorphic + // Alteration's static copy. Layered at Copy purely for ordering; its + // `apply_continuous_effect` arm is an explicit no-op (the real copy + // is the latched `CopyValues` TCE installed at the choice answer). + ContinuousModification::CopyChosen => Layer::Copy, // CR 707.9b + CR 613.1a: Copy-effect name override applies in Layer 1 // after CopyValues, per timestamp order within the layer. ContinuousModification::SetName { .. } => Layer::Copy, diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 8e41fba730..2c3ab42d90 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -670,6 +670,7 @@ mod mechtitan_core_return_exiled; mod memory_plunder_free_cast_2884; mod mercenaries_any_player_activate_prevention_scope; mod merieke_ri_berit_cant_regenerate; +mod metamorphic_alteration; mod militant_angel_attacked_opponents; mod mill_double_redirect_choice_continuation; mod mill_rest_in_peace_redirect; diff --git a/crates/engine/tests/integration/metamorphic_alteration.rs b/crates/engine/tests/integration/metamorphic_alteration.rs new file mode 100644 index 0000000000..973cbee3da --- /dev/null +++ b/crates/engine/tests/integration/metamorphic_alteration.rs @@ -0,0 +1,550 @@ +//! GitHub #6013 — Metamorphic Alteration. +//! +//! ```text +//! Enchant creature +//! As this Aura enters, choose a creature. +//! Enchanted creature is a copy of the chosen creature. +//! ``` +//! +//! The Aura latches the CHOSEN creature's copiable values (fixed as the copy +//! effect first starts to apply, CR 707.2c; unaffected by later changes to the +//! chosen object, CR 707.2b) and installs them as a Layer-1 copy on its +//! ENCHANTED HOST — the entering Aura itself never becomes a copy. The install +//! reuses the single copy-values authority (`apply_precomputed_copy_values`), +//! and ends when the Aura leaves the battlefield (CR 400.7 / CR 611.2a). + +use engine::game::game_object::{AttachTarget, DisplaySource}; +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::parser::oracle::parse_oracle_text; +use engine::types::ability::{ + ChoosePermanentPersist, ChosenAttribute, ContinuousModification, Effect, FilterProp, + TargetFilter, TypedFilter, +}; +use engine::types::card::TokenImageRef; +use engine::types::card_type::CoreType; +use engine::types::identifiers::ObjectId; +use engine::types::keywords::Keyword; +use engine::types::mana::{ManaCost, ManaCostShard, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const METAMORPHIC_ALTERATION: &str = "Enchant creature\nAs this Aura enters, choose a creature.\nEnchanted creature is a copy of the chosen creature."; + +/// Stage Metamorphic Alteration in P0's hand as a {2}{U} Aura carrying its +/// parsed as-enters choice + copy static. Identity (Enchantment / Aura / +/// mana cost) is set BEFORE `from_oracle_text`, which preserves identity fields +/// while installing the parsed abilities/keywords/replacements/statics. +fn stage_metamorphic(scenario: &mut GameScenario) -> ObjectId { + let aura = scenario + .add_spell_to_hand(P0, "Metamorphic Alteration", false) + .as_enchantment() + .with_subtypes(vec!["Aura"]) + .with_mana_cost(ManaCost::Cost { + generic: 2, + shards: vec![ManaCostShard::Blue], + }) + .from_oracle_text(METAMORPHIC_ALTERATION) + .id(); + // `add_spell_to_hand(_, is_instant = false)` seeds `CoreType::Sorcery`, and + // `as_enchantment` only strips `Creature`. CR 205.3h: Aura is an enchantment + // subtype — drop the stray non-permanent type so the staged card is a clean + // enchantment spell rather than a Sorcery+Enchantment hybrid. + let obj = scenario.state_mut().objects.get_mut(&aura).unwrap(); + obj.card_types + .core_types + .retain(|t| !matches!(t, CoreType::Instant | CoreType::Sorcery)); + obj.base_card_types = obj.card_types.clone(); + aura +} + +fn blue_pool(scenario: &mut GameScenario) { + scenario.with_mana_pool( + P0, + vec![ + ManaUnit::new(ManaType::Blue, ObjectId(9_990), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(9_991), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(9_992), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(9_993), false, vec![]), + ], + ); +} + +/// CR 707.2c + CR 614.12a + CR 613.1a: casting the Aura on a host, then choosing +/// a donor, turns the ENCHANTED HOST into a copy of the donor — while the Aura +/// itself stays a Metamorphic Alteration Aura attached to that host (it never +/// `BecomeCopy`s). Reverting the install leaves the host as Grizzly Bears 2/2, +/// so every copied-value assertion below flips. +#[test] +fn enchanted_creature_becomes_copy_of_chosen_and_aura_is_unchanged() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let donor = scenario + .add_creature_from_oracle(P0, "Serra Angel", 4, 4, "Flying") + .id(); + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let aura = stage_metamorphic(&mut scenario); + blue_pool(&mut scenario); + + let mut runner = scenario.build(); + + runner + .cast(aura) + .target_object(host) + .copy_target(donor) + .resolve(); + + let host_obj = &runner.state().objects[&host]; + assert_eq!( + host_obj.name, "Serra Angel", + "CR 707.2c: enchanted host takes on the chosen creature's copiable name" + ); + assert_eq!(host_obj.power, Some(4), "host copies the donor's power"); + assert_eq!( + host_obj.toughness, + Some(4), + "host copies the donor's toughness" + ); + assert!( + host_obj.keywords.contains(&Keyword::Flying), + "host copies the donor's copiable keywords (CR 707.2)" + ); + + let aura_obj = &runner.state().objects[&aura]; + assert_eq!( + aura_obj.name, "Metamorphic Alteration", + "CR 707.4: the Aura's own identity must be unchanged — it never becomes a copy" + ); + assert!( + aura_obj + .card_types + .core_types + .contains(&CoreType::Enchantment) + && !aura_obj.card_types.core_types.contains(&CoreType::Creature), + "the Aura stays an Enchantment and is never turned into a creature copy" + ); + assert_eq!( + aura_obj.attached_to, + Some(AttachTarget::Object(host)), + "CR 608.3c: a spell-cast Aura is put onto the battlefield attached to its \ + enchant target, and the copy choice never disturbs that attachment" + ); +} + +/// CR 707.2b: the copy is a FROZEN snapshot taken when the effect first started +/// to apply — later changes to the chosen creature never propagate to the host. +/// Mutating the donor's base P/T and re-running the layer engine must leave the +/// host at the originally-copied 4/4. A live re-read of the donor would flip the +/// host to 9/9. +#[test] +fn copied_values_are_a_frozen_snapshot_of_the_chosen_creature() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let donor = scenario + .add_creature_from_oracle(P0, "Serra Angel", 4, 4, "Flying") + .id(); + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let aura = stage_metamorphic(&mut scenario); + blue_pool(&mut scenario); + + let mut runner = scenario.build(); + runner + .cast(aura) + .target_object(host) + .copy_target(donor) + .resolve(); + assert_eq!(runner.state().objects[&host].power, Some(4)); + + { + let donor_obj = runner.state_mut().objects.get_mut(&donor).unwrap(); + donor_obj.base_power = Some(9); + donor_obj.base_toughness = Some(9); + } + engine::game::layers::evaluate_layers(runner.state_mut()); + + let host_obj = &runner.state().objects[&host]; + assert_eq!( + host_obj.power, + Some(4), + "CR 707.2b: the snapshot is frozen — the host keeps the donor's power at choice time" + ); + assert_eq!( + host_obj.toughness, + Some(4), + "CR 707.2b: later changes to the chosen creature must not propagate to the host" + ); +} + +/// CR 400.7 + CR 611.2a: the copy ends when the Aura leaves the battlefield +/// (`Duration::UntilHostLeavesPlay`, pruned on the Aura as the effect's source). +/// After the Aura is gone the host reverts to its own printed identity. +#[test] +fn host_reverts_when_the_aura_leaves_play() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let donor = scenario + .add_creature_from_oracle(P0, "Serra Angel", 4, 4, "Flying") + .id(); + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let aura = stage_metamorphic(&mut scenario); + blue_pool(&mut scenario); + + let mut runner = scenario.build(); + runner + .cast(aura) + .target_object(host) + .copy_target(donor) + .resolve(); + assert_eq!(runner.state().objects[&host].name, "Serra Angel"); + + let mut events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), aura, Zone::Graveyard, &mut events); + engine::game::layers::prune_host_left_effects(runner.state_mut(), aura); + engine::game::layers::evaluate_layers(runner.state_mut()); + + let host_obj = &runner.state().objects[&host]; + assert_eq!( + host_obj.name, "Grizzly Bears", + "CR 400.7: the copy ends when the source Aura leaves — the host reverts to its own name" + ); + assert_eq!(host_obj.power, Some(2), "host reverts to its printed power"); + assert_eq!( + host_obj.toughness, + Some(2), + "host reverts to its printed toughness" + ); + assert!( + !host_obj.keywords.contains(&Keyword::Flying), + "the copied Flying keyword ends with the Aura" + ); +} + +/// CR 115.10a: "choose a creature" is a CHOICE, not targeting — hexproof (which +/// only protects against opponents' *targeted* spells and abilities) never +/// removes a creature from the copy-choice pool. An opponent's hexproof creature +/// must be a legal donor, and the host becomes a copy of it. Reverting the copy +/// install leaves the host as Grizzly Bears 2/2, so every assertion below flips. +#[test] +fn hexproof_opponent_creature_is_a_legal_copy_donor() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // Opponent-controlled donor with Hexproof: a *targeted* effect could never + // pick it, but this choice can (CR 115.10a). + let donor = scenario + .add_creature_from_oracle(P1, "Serra Angel", 4, 4, "Flying") + .hexproof() + .id(); + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let aura = stage_metamorphic(&mut scenario); + blue_pool(&mut scenario); + + let mut runner = scenario.build(); + runner + .cast(aura) + .target_object(host) + .copy_target(donor) + .resolve(); + + let host_obj = &runner.state().objects[&host]; + assert_eq!( + host_obj.name, "Serra Angel", + "CR 115.10a: an opponent's hexproof creature is still a legal copy CHOICE — the host copies it" + ); + assert_eq!( + host_obj.power, + Some(4), + "host copies the hexproof donor's power" + ); + assert!( + host_obj.keywords.contains(&Keyword::Flying), + "host copies the donor's copiable keywords across the choice" + ); + assert!( + host_obj.keywords.contains(&Keyword::Hexproof), + "the copied identity includes the donor's Hexproof (a copiable keyword)" + ); +} + +/// CR 609.3 + CR 303.4: the copy-choice pool is every creature matching "a +/// creature", and an Enchant creature Aura guarantees a creature host — so the +/// host itself is always in the pool. Even when the host is the ONLY creature on +/// the battlefield the pool is non-empty and the choice is raised (choosing the +/// host copies its own values — a legal, if inert, pick). +/// +/// This is why the empty-pool early-skip in +/// `apply_pending_post_replacement_effect` (the `Effect::ChoosePermanent` arm's +/// `valid_targets.is_empty()` guard, CR 609.3 "do only as much as possible") is +/// UNREACHABLE on the spell-cast Enchant-creature path: `find_copy_targets` over +/// "a creature" always contains the host. A genuinely empty pool (zero creatures +/// anywhere) would also leave the Aura spell with no legal enchant target, so +/// CR 608.2b counters it and it never enters — the "as this Aura enters" choose +/// replacement never fires with an empty pool. We therefore prove pool +/// composition here rather than fabricating an unreachable empty-pool fixture. +#[test] +fn host_is_the_only_creature_and_remains_a_legal_copy_choice() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let aura = stage_metamorphic(&mut scenario); + blue_pool(&mut scenario); + + let mut runner = scenario.build(); + runner + .cast(aura) + .target_object(host) + .copy_target(host) + .resolve(); + + // Reach-guard: inert self-copy leaves printed P/T unchanged, so the + // host-stays-Grizzly assertions below would also pass if CopyTargetChoice + // were skipped entirely. The snapshot + Layer-1 CopyValues TCE are written + // only on the answer path — either missing flips if that path is bypassed. + assert!( + runner.state().objects[&aura] + .chosen_attributes + .iter() + .any(|a| matches!(a, ChosenAttribute::CopiableSnapshot(_))), + "CopyTargetChoice must latch ChosenAttribute::CopiableSnapshot on the Aura \ + (absent if the answer path was skipped)" + ); + assert!( + runner + .state() + .transient_continuous_effects + .iter() + .any(|tce| { + tce.source_id == aura + && tce + .modifications + .iter() + .any(|m| matches!(m, ContinuousModification::CopyValues { .. })) + && matches!( + &tce.affected, + TargetFilter::SpecificObject { id } if *id == host + ) + }), + "CopyTargetChoice must install a Layer-1 CopyValues TCE sourced from the Aura \ + on the enchanted host (absent if the answer path was skipped)" + ); + + let host_obj = &runner.state().objects[&host]; + assert_eq!( + host_obj.name, "Grizzly Bears", + "choosing the host itself copies its own values — the host stays Grizzly Bears" + ); + assert_eq!( + host_obj.power, + Some(2), + "self-copy keeps the host's printed power" + ); + assert_eq!( + runner.state().objects[&aura].attached_to, + Some(AttachTarget::Object(host)), + "CR 608.3c: the Aura remains attached to the host it enchanted" + ); +} + +/// Two Metamorphic Alterations on two different hosts, each choosing a DIFFERENT +/// donor, resolve independently: each host copies its own Aura's chosen donor. +/// The frozen snapshot is latched per-Aura (CR 707.2c) and installed on that +/// Aura's own enchanted host (CR 303.4 + CR 613.1a), so the two copy effects +/// never bleed. If host B picked up host A's donor's Flying, the independence +/// assertion below flips. +#[test] +fn two_auras_install_independent_copies_on_their_own_hosts() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let donor_a = scenario + .add_creature_from_oracle(P0, "Serra Angel", 4, 4, "Flying") + .id(); + let donor_b = scenario.add_creature(P0, "Hill Giant", 3, 3).id(); + let host_a = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let host_b = scenario.add_creature(P0, "Runeclaw Bear", 2, 2).id(); + let aura_a = stage_metamorphic(&mut scenario); + let aura_b = stage_metamorphic(&mut scenario); + + // Two casts of {2}{U}: 2 blue + 4 colorless. + scenario.with_mana_pool( + P0, + vec![ + ManaUnit::new(ManaType::Blue, ObjectId(9_990), false, vec![]), + ManaUnit::new(ManaType::Blue, ObjectId(9_991), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(9_992), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(9_993), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(9_994), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(9_995), false, vec![]), + ], + ); + + let mut runner = scenario.build(); + runner + .cast(aura_a) + .target_object(host_a) + .copy_target(donor_a) + .resolve(); + runner + .cast(aura_b) + .target_object(host_b) + .copy_target(donor_b) + .resolve(); + + let ha = &runner.state().objects[&host_a]; + assert_eq!( + ha.name, "Serra Angel", + "host A copies Aura A's donor (Serra Angel)" + ); + assert_eq!(ha.power, Some(4)); + assert!(ha.keywords.contains(&Keyword::Flying)); + + let hb = &runner.state().objects[&host_b]; + assert_eq!( + hb.name, "Hill Giant", + "host B copies Aura B's donor (Hill Giant)" + ); + assert_eq!(hb.power, Some(3)); + assert!( + !hb.keywords.contains(&Keyword::Flying), + "host B must NOT pick up host A's donor's Flying — the two copies are independent" + ); +} + +/// CR 111.1 + CR 707.2: art routing follows the copy. When the chosen donor is a +/// true token, the host's display routing switches to the token art database +/// (`DisplaySource::Token` + the source token's `token_image_ref`) — carried +/// alongside the copiable values, NOT as a copiable value itself. The host is a +/// nontoken; reverting the copy install would reset its display routing to +/// `Card`/`None`, flipping both display assertions. +#[test] +fn host_copying_a_token_donor_routes_token_display() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let donor = scenario.add_creature(P0, "Angel", 4, 4).id(); + // Make the donor a TRUE token so its display routes to the token art db + // (CR 111.1): `is_token` with no `base_printed_ref` derives + // `DisplaySource::Token` in the layer engine; its `token_image_ref` is its + // own art pointer. + { + let d = scenario.state_mut().objects.get_mut(&donor).unwrap(); + d.is_token = true; + d.base_printed_ref = None; + d.token_image_ref = Some(TokenImageRef { + scryfall_id: "tok-angel-1".to_string(), + scryfall_oracle_id: None, + face_name: None, + preset_id: "angel_4_4".to_string(), + }); + } + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let aura = stage_metamorphic(&mut scenario); + blue_pool(&mut scenario); + + let mut runner = scenario.build(); + runner + .cast(aura) + .target_object(host) + .copy_target(donor) + .resolve(); + + let host_obj = &runner.state().objects[&host]; + assert_eq!(host_obj.name, "Angel", "host copies the token donor's name"); + assert_eq!( + host_obj.power, + Some(4), + "host copies the token donor's power" + ); + assert_eq!( + host_obj.display_source, + DisplaySource::Token, + "CR 111.1 + CR 707.2: copying a true token routes the host's art to the token db" + ); + assert_eq!( + host_obj + .token_image_ref + .as_ref() + .map(|r| r.preset_id.as_str()), + Some("angel_4_4"), + "the source token's token_image_ref rides along with the copy" + ); +} + +/// SHAPE: the "As this Aura enters, choose a creature." line parses to an +/// as-enters `Effect::ChoosePermanent { persist: CopiableSnapshot }` over a +/// creature copy-source pool — never a `BecomeCopy` on the entering Aura. +#[test] +fn as_enters_choose_a_creature_parses_to_choose_permanent() { + let parsed = parse_oracle_text( + METAMORPHIC_ALTERATION, + "Metamorphic Alteration", + &[], + &["Enchantment".to_string()], + &["Aura".to_string()], + ); + + let replacement = parsed + .replacements + .iter() + .find(|r| { + matches!( + r.execute.as_ref().map(|e| e.effect.as_ref()), + Some(Effect::ChoosePermanent { .. }) + ) + }) + .expect("as-enters choose-a-creature must lower to an Effect::ChoosePermanent replacement"); + + match replacement.execute.as_ref().unwrap().effect.as_ref() { + Effect::ChoosePermanent { filter, persist } => { + assert!( + matches!(persist, ChoosePermanentPersist::CopiableSnapshot), + "the choice must persist as a copiable snapshot latched onto the Aura" + ); + assert_eq!( + filter, + &TargetFilter::Typed(TypedFilter::creature()), + "the copy-source pool is 'a creature'" + ); + } + other => panic!("expected Effect::ChoosePermanent, got {other:?}"), + } +} + +/// SHAPE: the "Enchanted creature is a copy of the chosen creature." line parses +/// to the `ContinuousModification::CopyChosen` marker affecting the enchanted +/// host (an `EnchantedBy` creature filter). The marker is a Layer-1 no-op; the +/// copy is materialized by the `ChoosePermanent` answer, not this static. +#[test] +fn enchanted_is_a_copy_of_chosen_parses_to_copy_chosen_static() { + let parsed = parse_oracle_text( + METAMORPHIC_ALTERATION, + "Metamorphic Alteration", + &[], + &["Enchantment".to_string()], + &["Aura".to_string()], + ); + + let static_def = parsed + .statics + .iter() + .find(|s| { + s.modifications + .contains(&ContinuousModification::CopyChosen) + }) + .expect("the copy static must lower to ContinuousModification::CopyChosen"); + + match static_def + .affected + .as_ref() + .expect("static must be scoped to a host") + { + TargetFilter::Typed(tf) => assert!( + tf.properties.contains(&FilterProp::EnchantedBy), + "the copy static must affect the enchanted host (CR 303.4 + CR 613.1a)" + ), + other => panic!("expected a Typed enchanted-host filter, got {other:?}"), + } +} diff --git a/crates/phase-ai/src/policies/copy_value.rs b/crates/phase-ai/src/policies/copy_value.rs index c99678b1de..9bec353139 100644 --- a/crates/phase-ai/src/policies/copy_value.rs +++ b/crates/phase-ai/src/policies/copy_value.rs @@ -644,6 +644,7 @@ mod tests { source_id: mockingbird_id, valid_targets: vec![small, large], max_mana_value: Some(4), + purpose: engine::types::ability::CopyTargetPurpose::BecomeCopy, }, candidates: Vec::new(), }; diff --git a/crates/phase-ai/src/policies/effect_classify.rs b/crates/phase-ai/src/policies/effect_classify.rs index a2801396b3..58f749c627 100644 --- a/crates/phase-ai/src/policies/effect_classify.rs +++ b/crates/phase-ai/src/policies/effect_classify.rs @@ -244,6 +244,7 @@ pub(crate) fn effect_polarity(effect: &Effect) -> EffectPolarity { | Effect::ChooseFromZone { .. } | Effect::ChooseObjectsIntoTrackedSet { .. } | Effect::ChooseOneOf { .. } + | Effect::ChoosePermanent { .. } | Effect::Clash | Effect::Cleanup { .. } | Effect::Cloak { .. } diff --git a/crates/phase-ai/src/policies/redundancy_avoidance.rs b/crates/phase-ai/src/policies/redundancy_avoidance.rs index b87847ef36..edc9fd2d57 100644 --- a/crates/phase-ai/src/policies/redundancy_avoidance.rs +++ b/crates/phase-ai/src/policies/redundancy_avoidance.rs @@ -446,6 +446,9 @@ fn redundancy_delta( | Effect::ExileResolvingSpellInsteadOfGraveyard { .. } | Effect::CopyTokenBlockingAttacker { .. } | Effect::BecomeCopy { .. } + // CR 707.2c (Metamorphic Alteration): as-enters copy choice carries no + // "redundant if already controlled" signal for this policy. + | Effect::ChoosePermanent { .. } | Effect::GainActivatedAbilitiesOfTarget { .. } | Effect::ChooseCard { .. } | Effect::PutCounterAll { .. } diff --git a/crates/phase-ai/src/policies/x_reference.rs b/crates/phase-ai/src/policies/x_reference.rs index 9c215857f7..4b63ad44a3 100644 --- a/crates/phase-ai/src/policies/x_reference.rs +++ b/crates/phase-ai/src/policies/x_reference.rs @@ -182,7 +182,9 @@ fn continuous_modification_references_x(modification: &ContinuousModification) - | ContinuousModification::AddCounterOnEnter { count: value, .. } => { expr_references_chosen_x(value) } - ContinuousModification::SetName { .. } + // CR 707.2c (Metamorphic Alteration): inert copy marker references no X. + ContinuousModification::CopyChosen + | ContinuousModification::SetName { .. } | ContinuousModification::SetTextName { .. } | ContinuousModification::AddPower { .. } | ContinuousModification::AddToughness { .. } From c9d49fa0f0c26ba2213355c10e2a9da99bc1c1ab Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 23 Jul 2026 03:05:19 -0500 Subject: [PATCH 02/20] fix(engine): cover EffectKind::ChoosePermanent in trigger index Exhaustive match in keys_from_effect_kind must classify the new Metamorphic Alteration kind as a no-op EffectResolved key (same as Choose/BecomeCopy). Co-authored-by: Cursor --- crates/engine/src/game/trigger_index.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/engine/src/game/trigger_index.rs b/crates/engine/src/game/trigger_index.rs index da611f19f8..3f814a117e 100644 --- a/crates/engine/src/game/trigger_index.rs +++ b/crates/engine/src/game/trigger_index.rs @@ -805,6 +805,7 @@ fn keys_from_effect_kind(kind: EffectKind, push: &mut impl FnMut(TriggerEventKey | EffectKind::ExileTop | EffectKind::TargetOnly | EffectKind::Choose + | EffectKind::ChoosePermanent | EffectKind::OpponentGuess | EffectKind::ChooseDamageSource | EffectKind::Suspect From a6cf3ce6cfa84c2419ed86efb0a951aaf14b623c Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 23 Jul 2026 04:43:18 -0500 Subject: [PATCH 03/20] fix(engine): stop using GameScenario::state_mut in Metamorphic tests as_enchantment now strips Instant/Sorcery like other permanent converters, and token-donor setup mutates via GameRunner after build. Co-authored-by: Cursor --- crates/engine/src/game/scenario.rs | 16 +++++++--- .../integration/metamorphic_alteration.rs | 32 ++++++++----------- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index 0cd300b29a..712d1ddab5 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -989,10 +989,18 @@ impl<'a> CardBuilder<'a> { pub fn as_enchantment(&mut self) -> &mut Self { let obj = self.obj(); - obj.card_types - .core_types - .retain(|t| *t != CoreType::Creature); - obj.card_types.core_types.push(CoreType::Enchantment); + // Permanent enchantment spells staged from `add_spell_to_hand` keep the + // Instant/Sorcery seed until stripped here — same shape as + // `as_creature` / `as_planeswalker_with_loyalty`. + obj.card_types.core_types.retain(|t| { + !matches!( + t, + CoreType::Creature | CoreType::Instant | CoreType::Sorcery + ) + }); + if !obj.card_types.core_types.contains(&CoreType::Enchantment) { + obj.card_types.core_types.push(CoreType::Enchantment); + } self.sync_base_card_types(); self } diff --git a/crates/engine/tests/integration/metamorphic_alteration.rs b/crates/engine/tests/integration/metamorphic_alteration.rs index 973cbee3da..8ddb18b0dc 100644 --- a/crates/engine/tests/integration/metamorphic_alteration.rs +++ b/crates/engine/tests/integration/metamorphic_alteration.rs @@ -35,7 +35,10 @@ const METAMORPHIC_ALTERATION: &str = "Enchant creature\nAs this Aura enters, cho /// mana cost) is set BEFORE `from_oracle_text`, which preserves identity fields /// while installing the parsed abilities/keywords/replacements/statics. fn stage_metamorphic(scenario: &mut GameScenario) -> ObjectId { - let aura = scenario + // CR 205.3h: Aura is an enchantment subtype. `as_enchantment` strips the + // Instant/Sorcery seed from `add_spell_to_hand` so this is a clean + // Enchantment — Aura spell (not a Sorcery+Enchantment hybrid). + scenario .add_spell_to_hand(P0, "Metamorphic Alteration", false) .as_enchantment() .with_subtypes(vec!["Aura"]) @@ -44,17 +47,7 @@ fn stage_metamorphic(scenario: &mut GameScenario) -> ObjectId { shards: vec![ManaCostShard::Blue], }) .from_oracle_text(METAMORPHIC_ALTERATION) - .id(); - // `add_spell_to_hand(_, is_instant = false)` seeds `CoreType::Sorcery`, and - // `as_enchantment` only strips `Creature`. CR 205.3h: Aura is an enchantment - // subtype — drop the stray non-permanent type so the staged card is a clean - // enchantment spell rather than a Sorcery+Enchantment hybrid. - let obj = scenario.state_mut().objects.get_mut(&aura).unwrap(); - obj.card_types - .core_types - .retain(|t| !matches!(t, CoreType::Instant | CoreType::Sorcery)); - obj.base_card_types = obj.card_types.clone(); - aura + .id() } fn blue_pool(scenario: &mut GameScenario) { @@ -425,12 +418,18 @@ fn host_copying_a_token_donor_routes_token_display() { scenario.at_phase(Phase::PreCombatMain); let donor = scenario.add_creature(P0, "Angel", 4, 4).id(); + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let aura = stage_metamorphic(&mut scenario); + blue_pool(&mut scenario); + + let mut runner = scenario.build(); // Make the donor a TRUE token so its display routes to the token art db // (CR 111.1): `is_token` with no `base_printed_ref` derives // `DisplaySource::Token` in the layer engine; its `token_image_ref` is its - // own art pointer. + // own art pointer. Mutate via `GameRunner::state_mut` (GameScenario has no + // mutable state escape hatch). { - let d = scenario.state_mut().objects.get_mut(&donor).unwrap(); + let d = runner.state_mut().objects.get_mut(&donor).unwrap(); d.is_token = true; d.base_printed_ref = None; d.token_image_ref = Some(TokenImageRef { @@ -440,11 +439,6 @@ fn host_copying_a_token_donor_routes_token_display() { preset_id: "angel_4_4".to_string(), }); } - let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); - let aura = stage_metamorphic(&mut scenario); - blue_pool(&mut scenario); - - let mut runner = scenario.build(); runner .cast(aura) .target_object(host) From c93e9596f988f68b8e255f1de9a1bde53d146367 Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 23 Jul 2026 05:05:51 -0500 Subject: [PATCH 04/20] fix(engine): attach Aura before Metamorphic copy choice applies Mid-entry CopyTargetChoice pauses delivery before CR 608.3c attach; stash PendingSpellResolution on that pause and apply it when PersistChosenAttribute answers. Also bump the ability-scan census count for ChoosePermanent. Co-authored-by: Cursor --- crates/engine/src/game/ability_scan.rs | 13 ++-- crates/engine/src/game/engine_replacement.rs | 49 +++++++------- crates/engine/src/game/stack.rs | 71 ++++++++++++++++++-- 3 files changed, 99 insertions(+), 34 deletions(-) diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 07307d648e..63adf70c1f 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -5501,7 +5501,7 @@ enum CensusRole { #[cfg(test)] fn effect_census_role(e: &Effect) -> CensusRole { match e { - // -- CENSUS (28): verbatim mirror of `effect_target_ctx`'s LiveBoardCensus + // -- CENSUS (29): verbatim mirror of `effect_target_ctx`'s LiveBoardCensus // arm - mass battlefield population reads that scale with growth. Effect::EachSourceDealsDamage { .. } | Effect::EachDealsDamageEqualToPower { .. } @@ -6899,7 +6899,7 @@ mod tests { } /// guard#3 (mitigation #3): the `LiveBoardCensus` tag set of `effect_target_ctx` - /// == EXACTLY the enumeration-derived MASS-POPULATION set (28). Source-scanned, not + /// == EXACTLY the enumeration-derived MASS-POPULATION set (29). Source-scanned, not /// hand-counted (the hand-count is what produced the earlier "relax=4" miss). Under /// B's SnapshotOrEvent default this is the primary false-certificate gate: only a /// census tag vetoes a mass read that ESCALATES over inert token growth (which @@ -6935,6 +6935,7 @@ mod tests { "ChangeZoneAll", "ChooseAndSacrificeRest", "ChooseObjectsIntoTrackedSet", + "ChoosePermanent", "CounterAll", "DamageAll", "DamageEachPlayer", @@ -6974,7 +6975,7 @@ mod tests { got, want, "census tag set drifted from the enumeration-derived mass-population set" ); - assert_eq!(got.len(), 28, "exactly 28 mass-population census tags"); + assert_eq!(got.len(), 29, "exactly 29 mass-population census tags"); } /// A7' (mitigation #4, replaces the void census-default A7): with SnapshotOrEvent the @@ -7085,7 +7086,7 @@ mod tests { /// with `effect_target_ctx` on the Census/Relax boundary, closing the F1 gap where a /// census-ROLE slot silently in the generic relax `|`-chain (exactly R1's Suspect{All}) /// is invisible to the census-arm-only guards. Structural: both functions' `Census` - /// name-sets are source-scanned and asserted IDENTICAL (== the 28). Behavioral: the + /// name-sets are source-scanned and asserted IDENTICAL (== the 29). Behavioral: the /// two oracles agree on every discriminator, incl. BOTH Suspect/Unsuspect scopes. /// /// REVERT-PROBE (discrimination proof): moving `Suspect{All}` out of the census arm of @@ -7100,7 +7101,7 @@ mod tests { use crate::types::ability::{EffectScope, TapStateChange}; use ScanMode::LoopFirewall; - // -- Structural: the two census name-sets are byte-identical (and == 28). + // -- Structural: the two census name-sets are byte-identical (and == 29). fn census_names(fnsrc: &str, terminator: &str) -> Vec { let end = fnsrc.find(terminator).expect("census terminator"); let block = &fnsrc[..end]; @@ -7129,7 +7130,7 @@ mod tests { etc_census, ecr_census, "effect_census_role Census set diverged from effect_target_ctx" ); - assert_eq!(ecr_census.len(), 28, "exactly 28 census members"); + assert_eq!(ecr_census.len(), 29, "exactly 29 census members"); // -- Behavioral: the two oracles agree on the Census/Relax boundary for every // discriminator. `census(e, true)` requires BOTH `effect_census_role == Census` diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 2ac02e3eab..e6d03683ee 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -1376,20 +1376,25 @@ fn handle_persist_chosen_attribute_choice( )); } + // CR 608.3c + CR 614.12a: Mid-entry `CopyTargetChoice` is raised from the + // zone-delivery post-replacement drain, which pauses BEFORE the spell- + // resolution epilogue (Aura attach, cast-link stamps) in `stack.rs`. The + // stash pushed there (`PendingSpellResolution`) carries the Aura spell's + // enchant target — apply it now so `attached_to` is set before the host + // copy installs. Not CR 303.4f (non-spell Aura entry). `attach_to` is + // idempotent if attachment already ran. + if state + .active_spell_resolution() + .is_some_and(|ctx| ctx.object_id == source_id) + { + let ctx = state + .take_active_spell_resolution() + .expect("active spell-resolution frame checked above"); + apply_pending_spell_resolution(state, &ctx, events); + } + // CR 303.4 + CR 702.5: the Aura enchants a creature — its host is the - // recipient of the copy effect. On the spell-cast path the Aura is put onto - // the battlefield ALREADY attached to its enchant target (CR 608.3c — NOT - // CR 303.4f, which governs only Auras entering "by any means other than by - // resolving as an Aura spell"). That attach runs in the Aura-spell - // resolution tail in `stack.rs` (the `is_aura` block at the CR 608.3c - // comment) BEFORE this `CopyTargetChoice` answer is processed: on the - // Execute path the copy choice is raised while draining post-replacement - // effects, execution then falls through to the CR 608.3c attach, and only - // then is priority (carrying the pending choice) returned — so `attached_to` - // is already `Some` here. CR 608.2b guarantees it further: an Aura spell - // whose sole enchant target is illegal at resolution is countered and never - // enters, so the "as this Aura enters, choose a creature" replacement never - // fires with a hostless Aura. No defensive attach is performed here. + // recipient of the copy effect. let host = state .objects .get(&source_id) @@ -1417,26 +1422,24 @@ fn handle_persist_chosen_attribute_choice( super::effects::become_copy::apply_precomputed_copy_values(state, host_id, copy, events) .map_err(|e| EngineError::InvalidAction(format!("{e:?}")))?; } else { - // Unreachable on the spell-cast path (see the CR 608.3c / CR 608.2b note - // above). If it were ever reached, the copy simply does not apply and the - // unattached Aura is cleaned up by SBA (CR 704.5m) — never invent an - // attach here. + // CR 608.2b: an Aura spell whose enchant target is illegal at resolution + // is countered and never enters — so this choose-a-creature replacement + // should not fire hostless on the spell-cast path. If reached anyway, + // skip the copy; SBA CR 704.5m cleans up the unattached Aura. debug_assert!( false, - "Metamorphic Alteration's Aura must be attached to a creature host when \ - the copy choice is answered (CR 608.3c: the spell-cast attach runs \ - before the CopyTargetChoice answer)" + "Metamorphic Alteration's Aura must be attached (CR 608.3c via \ + PendingSpellResolution) when the copy choice is answered" ); } - // CR 707.4: changing the host's copiable identity is NOT a zone change and - // must not disturb the Aura's own identity. + // CR 707.4: installing the host copy must not disturb the Aura's attachment. debug_assert!( state .objects .get(&source_id) .is_some_and(|aura| aura.attached_to.is_some()), - "the Aura's own identity/attachment must be unchanged by installing the host copy" + "the Aura must remain attached after installing the host copy" ); // CR 614.12a + CR 603.2: replay the Aura's deferred battlefield-entry event diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 2cd0131e57..d90836c6df 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -1066,12 +1066,73 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { events, ) { zone_pipeline::ZoneDeliveryResult::Done => {} - // CR 614.1c / CR 616.1: the delivery tail parked a - // counter-replacement pause and stashed the - // remaining tail; surface it without running the - // caller epilogue (the parked tail carries - // `CallerEpilogue` and the resume path owns it). + // CR 614.1c / CR 616.1 / CR 614.12a: the delivery + // tail parked a mid-entry choice (CopyTargetChoice, + // NamedChoice, counter branch, …) and stashed the + // remaining tail. Surface it without running the + // caller epilogue — including CR 608.3c Aura attach, + // which has not run yet. + // + // CR 608.3c + CR 400.7d: stash PendingSpellResolution + // so the choice-answer resume can complete Aura + // attachment / cast-link stamps — mirrors the + // ReplacementResult::NeedsChoice arm below. zone_pipeline::ZoneDeliveryResult::NeedsChoice(_) => { + let cast_from_zone = ability + .as_ref() + .and_then(|a| a.context.cast_from_zone) + .or_else(|| { + state + .objects + .get(&entry.id) + .and_then(|o| o.cast_from_zone) + }); + let kickers_paid = ability + .as_ref() + .map(|a| a.context.kickers_paid.clone()) + .unwrap_or_else(|| { + state + .objects + .get(&entry.id) + .map(|o| o.kickers_paid.clone()) + .unwrap_or_default() + }); + let additional_cost_payment_count = ability + .as_ref() + .map(|a| a.context.additional_cost_payment_count) + .unwrap_or_else(|| { + state + .objects + .get(&entry.id) + .map(|o| o.additional_cost_payment_count) + .unwrap_or_default() + }); + let additional_cost_payments = ability + .as_ref() + .map(|a| a.context.additional_cost_payments.clone()) + .unwrap_or_else(|| { + state + .objects + .get(&entry.id) + .map(|o| o.additional_cost_payments.clone()) + .unwrap_or_default() + }); + state.push_spell_resolution( + crate::types::game_state::PendingSpellResolution { + object_id: entry.id, + controller: entry.controller, + casting_variant, + cast_from_zone, + cast_controller: Some(entry.controller), + cast_timing_permission, + spell_targets: spell_targets.clone(), + actual_mana_spent, + kickers_paid, + additional_cost_payment_count, + additional_cost_payments, + convoked_creatures: convoked_creatures.clone(), + }, + ); events.push(GameEvent::StackResolved { object_id: entry.id, }); From 0efb9a399207ad1d0117dc799bea07e883cef5b8 Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 23 Jul 2026 05:53:30 -0500 Subject: [PATCH 05/20] fix(engine): route Metamorphic as-enters choose through replacement parse is_as_enters_choose_pattern only accepted named choices, so choose-a-creature never reached ChoosePermanent. Parse the choose suffix with the shared target descriptor, establish the Aura host from spell targets or CR 303.4f attach, and cover non-spell entry. Co-authored-by: Cursor --- crates/engine/src/game/engine_replacement.rs | 105 ++++++++++------ crates/engine/src/parser/oracle_classifier.rs | 6 + .../engine/src/parser/oracle_replacement.rs | 110 +++++++++++----- .../integration/metamorphic_alteration.rs | 118 ++++++++++++++++++ 4 files changed, 267 insertions(+), 72 deletions(-) diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index e6d03683ee..8300aab82f 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -1380,9 +1380,13 @@ fn handle_persist_chosen_attribute_choice( // zone-delivery post-replacement drain, which pauses BEFORE the spell- // resolution epilogue (Aura attach, cast-link stamps) in `stack.rs`. The // stash pushed there (`PendingSpellResolution`) carries the Aura spell's - // enchant target — apply it now so `attached_to` is set before the host - // copy installs. Not CR 303.4f (non-spell Aura entry). `attach_to` is - // idempotent if attachment already ran. + // enchant target — establish the host from those spell targets AND apply + // the attach now so `attached_to` is consistent before the copy installs. + // + // CR 303.4f: Non-spell Aura entry attaches during delivery (before this + // choice), so `attached_to` is already set and there is no spell-resolution + // frame — fall through to the attached host / enter-time attach consult. + let mut host_from_spell: Option = None; if state .active_spell_resolution() .is_some_and(|ctx| ctx.object_id == source_id) @@ -1390,49 +1394,72 @@ fn handle_persist_chosen_attribute_choice( let ctx = state .take_active_spell_resolution() .expect("active spell-resolution frame checked above"); + host_from_spell = ctx.spell_targets.first().and_then(|t| match t { + crate::types::ability::TargetRef::Object(id) => Some(*id), + _ => None, + }); apply_pending_spell_resolution(state, &ctx, events); } - // CR 303.4 + CR 702.5: the Aura enchants a creature — its host is the - // recipient of the copy effect. - let host = state + // CR 303.4f: Non-spell path may still be unattached if entry skipped the + // auto-attach consult (e.g. liminal copy → Aura). Resolve it now. + if state .objects .get(&source_id) - .and_then(|aura| aura.attached_to.as_ref()) - .and_then(|attach| attach.as_object()); - - if let Some(host_id) = host { - // CR 707.2c + CR 613.1a + CR 611.2a: install the copy as a transient - // continuous effect sourced from the Aura, applied to the host, ending - // when the Aura leaves the battlefield (`UntilHostLeavesPlay` prunes on - // `tce.source_id` leaving — the Aura). `duration_subject_id` tracks the - // host for any recipient-relative duration reads (harmless here). - let copy = super::effects::become_copy::PrecomputedCopyValues { - source_id, - controller, - duration_subject_id: host_id, - duration: crate::types::ability::Duration::UntilHostLeavesPlay, - values, - display_source, - printed_ref, - token_image_ref, - additional_modifications: Vec::new(), - effect_kind: crate::types::ability::EffectKind::ChoosePermanent, - }; - super::effects::become_copy::apply_precomputed_copy_values(state, host_id, copy, events) - .map_err(|e| EngineError::InvalidAction(format!("{e:?}")))?; - } else { - // CR 608.2b: an Aura spell whose enchant target is illegal at resolution - // is countered and never enters — so this choose-a-creature replacement - // should not fire hostless on the spell-cast path. If reached anyway, - // skip the copy; SBA CR 704.5m cleans up the unattached Aura. - debug_assert!( - false, - "Metamorphic Alteration's Aura must be attached (CR 608.3c via \ - PendingSpellResolution) when the copy choice is answered" - ); + .is_some_and(|aura| aura.attached_to.is_none()) + { + match crate::game::zone_pipeline::resolve_entering_aura_attachment(state, source_id) { + crate::game::zone_pipeline::EnteringAuraAttachment::NotApplicable + | crate::game::zone_pipeline::EnteringAuraAttachment::Resolved => {} + crate::game::zone_pipeline::EnteringAuraAttachment::NeedsChoice { .. } => { + return Err(EngineError::InvalidAction( + "PersistChosenAttribute requires a resolved Aura host before the copy installs" + .to_string(), + )); + } + } } + // CR 303.4 + CR 702.5: the Aura enchants a creature — its host is the + // recipient of the copy effect. Prefer the spell-target host established + // above (spell-cast path), then the live attachment (non-spell / after + // attach). Never silently no-op if the host is missing. + let host_id = host_from_spell + .or_else(|| { + state + .objects + .get(&source_id) + .and_then(|aura| aura.attached_to.as_ref()) + .and_then(|attach| attach.as_object()) + }) + .ok_or_else(|| { + EngineError::InvalidAction( + "Metamorphic Alteration copy choice answered without a resolved Aura host \ + (CR 608.3c spell target / CR 303.4f attach)" + .to_string(), + ) + })?; + + // CR 707.2c + CR 613.1a + CR 611.2a: install the copy as a transient + // continuous effect sourced from the Aura, applied to the host, ending + // when the Aura leaves the battlefield (`UntilHostLeavesPlay` prunes on + // `tce.source_id` leaving — the Aura). `duration_subject_id` tracks the + // host for any recipient-relative duration reads (harmless here). + let copy = super::effects::become_copy::PrecomputedCopyValues { + source_id, + controller, + duration_subject_id: host_id, + duration: crate::types::ability::Duration::UntilHostLeavesPlay, + values, + display_source, + printed_ref, + token_image_ref, + additional_modifications: Vec::new(), + effect_kind: crate::types::ability::EffectKind::ChoosePermanent, + }; + super::effects::become_copy::apply_precomputed_copy_values(state, host_id, copy, events) + .map_err(|e| EngineError::InvalidAction(format!("{e:?}")))?; + // CR 707.4: installing the host copy must not disturb the Aura's attachment. debug_assert!( state diff --git a/crates/engine/src/parser/oracle_classifier.rs b/crates/engine/src/parser/oracle_classifier.rs index c568568cfc..ae2378f9d2 100644 --- a/crates/engine/src/parser/oracle_classifier.rs +++ b/crates/engine/src/parser/oracle_classifier.rs @@ -977,9 +977,15 @@ fn is_as_enters_choose_pattern(lower: &str) -> bool { tag::<_, _, OracleError<'_>>("enters").parse(i) }) .is_some(); + // CR 614.12a + CR 707.2c: named-attribute choices ("choose a creature type") + // AND object choices ("choose a creature" — Metamorphic Alteration) both + // route through `parse_as_enters_choose`. Gating only on + // `try_parse_named_choice` dropped the object-choice class before Priority-8 + // replacement dispatch, so the permanent combinator never ran. let has_choose = nom_primitives::scan_at_word_boundaries(lower, |i| { verify(tag::<_, _, OracleError<'_>>("choose "), |_: &&str| { try_parse_named_choice(i).is_some() + || super::oracle_replacement::is_as_enters_choose_permanent_phrase(i) }) .parse(i) }) diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 11d4667d39..56b09f89e2 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -12,7 +12,7 @@ use nom::Parser; use super::oracle_effect::become_copy_except::parse_except_clause; use super::oracle_effect::{ parse_effect_chain, parse_effect_chain_with_context, parse_effect_clause, - try_parse_named_choice, try_parse_named_choice_conjunction, + parse_named_choice_object, try_parse_named_choice, try_parse_named_choice_conjunction, }; use super::oracle_ir::context::ParseContext; use super::oracle_ir::replacement::ReplacementIr; @@ -2120,17 +2120,23 @@ fn parse_as_enters_choose(norm_lower: &str, original_text: &str) -> Option>("choose ").parse(i) })?; + // Structural trailing-period cleanup before the named-choice object table + // (not parsing dispatch). `parse_named_choice_object` expects the phrase + // with "choose " already stripped. + let choose_object = choose_suffix.trim_end().trim_end_matches('.').trim(); // CR 614.12a + CR 707.2c: "As this Aura enters, choose a creature." is an // as-enters OBJECT choice, not a named-attribute choice, so - // `try_parse_named_choice` (which only recognizes color/type/name/etc.) - // fails. Fall back to the object-choice combinator that lowers to - // `Effect::ChoosePermanent` (Metamorphic Alteration) before giving up. - let Some(choice_type) = try_parse_named_choice(choose_text) else { - return parse_as_enters_choose_permanent(choose_text, original_text); + // `parse_named_choice_object` (color/type/name/etc.) fails. Fall back to + // the object-choice combinator that lowers to `Effect::ChoosePermanent` + // (Metamorphic Alteration) before giving up. + let Some(choice_type) = parse_named_choice_object(choose_object) else { + return parse_as_enters_choose_permanent(choose_suffix, original_text); }; let choose = AbilityDefinition::new( @@ -2280,6 +2286,42 @@ fn parse_as_enters_choose(norm_lower: &str, original_text: &str) -> Option bool { + as_enters_choose_permanent_filter(choose_text).is_some() +} + +/// Parse the object-filter of an as-enters permanent choice. Accepts either the +/// suffix after `choose ` (`"a creature."`) or a full `"choose a creature."` +/// clause. Requires full consumption and a concrete `Typed` filter. +fn as_enters_choose_permanent_filter(choose_text: &str) -> Option { + // Structural optional-prefix + trailing-period cleanup (not dispatch): + // callers may pass the match-at-start slice or the post-`choose ` suffix. + let object_phrase = choose_text.trim_start(); + let object_phrase = object_phrase + .strip_prefix("choose ") // allow-noncombinator: optional prefix tolerance + .unwrap_or(object_phrase); + let trimmed_end = object_phrase.trim_end(); + let object_phrase = trimmed_end + .strip_suffix('.') // allow-noncombinator: structural trailing-period cleanup + .unwrap_or(trimmed_end) + .trim(); + if object_phrase.is_empty() { + return None; + } + let (filter, remainder) = parse_target(object_phrase); + if !remainder.trim().is_empty() { + return None; + } + // CR 707.2c: the copy source is a permanent — only a concrete object filter + // (never a player/zone/`Any` descriptor) is a valid copy-source pool. + matches!(filter, TargetFilter::Typed(_)).then_some(filter) +} + /// CR 614.12a + CR 707.2c: "As this Aura enters, choose a creature." — the /// as-enters OBJECT-choice analogue of `parse_as_enters_choose` (which only /// handles named-attribute choices: color, creature type, card name, etc.). @@ -2288,35 +2330,14 @@ fn parse_as_enters_choose(norm_lower: &str, original_text: &str) -> Option Option { - let (object_phrase, _) = tag::<_, _, OracleError<'_>>("choose ") - .parse(choose_text) - .ok()?; - // Structural trailing-period/whitespace cleanup on the extracted object - // phrase (not parsing dispatch) before the descriptor grammar runs. - let object_phrase = object_phrase - .trim_end() - .strip_suffix('.') // allow-noncombinator: structural trailing-period cleanup, not dispatch - .unwrap_or(object_phrase) - .trim(); - let (filter, remainder) = parse_target(object_phrase); - if !remainder.trim().is_empty() { - return None; - } - // CR 707.2c: the copy source is a permanent — only a concrete object filter - // (never a player/zone/`Any` descriptor) is a valid copy-source pool. - if !matches!(filter, TargetFilter::Typed(_)) { - return None; - } + let filter = as_enters_choose_permanent_filter(choose_text)?; let execute = AbilityDefinition::new( AbilityKind::Spell, @@ -14694,6 +14715,29 @@ mod tests { )); } + /// CR 614.12a + CR 707.2c: object as-enters choice (Metamorphic Alteration) + /// must lower to `Effect::ChoosePermanent`, not a named `ChoiceType`. + #[test] + fn as_enters_choose_a_creature_is_choose_permanent() { + let def = parse_replacement_line( + "As this Aura enters, choose a creature.", + "Metamorphic Alteration", + ) + .expect("as-enters choose-a-creature must parse as a Moved replacement"); + let execute = def.execute.as_ref().unwrap(); + assert!( + matches!( + *execute.effect, + Effect::ChoosePermanent { + persist: ChoosePermanentPersist::CopiableSnapshot, + .. + } + ), + "expected ChoosePermanent {{ CopiableSnapshot }}, got {:?}", + execute.effect + ); + } + #[test] fn as_enters_choose_does_not_match_shock_land() { // Shock lands with "choose a basic land type" should be handled by parse_shock_land, diff --git a/crates/engine/tests/integration/metamorphic_alteration.rs b/crates/engine/tests/integration/metamorphic_alteration.rs index 8ddb18b0dc..0e6dc5f4b3 100644 --- a/crates/engine/tests/integration/metamorphic_alteration.rs +++ b/crates/engine/tests/integration/metamorphic_alteration.rs @@ -467,6 +467,124 @@ fn host_copying_a_token_donor_routes_token_display() { ); } +/// CR 303.4f + CR 614.12a + CR 707.2c: Non-spell Aura entry (dig → battlefield) +/// attaches via the enter-time host consult, then raises the as-enters copy +/// choice. Discriminates from the spell-cast path (CR 608.3c / +/// `PendingSpellResolution`): no spell-resolution frame is involved. +/// +/// Enchant is narrowed to "creature you control" so the opponent's donor is NOT +/// a legal attach host (single legal host → auto-attach, CR 303.4f) while still +/// remaining a legal copy CHOICE ("choose a creature", CR 115.10a). +#[test] +fn non_spell_aura_entry_copies_chosen_creature() { + use engine::types::ability::{ControllerRef, CopyTargetPurpose, TargetRef}; + use engine::types::actions::GameAction; + use engine::types::game_state::WaitingFor; + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + // Opponent-controlled donor: legal copy choice, illegal attach host under + // the narrowed Enchant(you-control) keyword installed below. + let donor = scenario + .add_creature_from_oracle(P1, "Serra Angel", 4, 4, "Flying") + .id(); + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + // Library dig → battlefield is a non-spell Aura entry (CR 303.4f). + let aura = scenario.add_card_to_library_top(P0, "Metamorphic Alteration"); + + let mut runner = scenario.build(); + + // Install Aura identity + parsed Metamorphic replacements/statics/keywords. + let parsed = parse_oracle_text( + METAMORPHIC_ALTERATION, + "Metamorphic Alteration", + &[], + &["Enchantment".to_string()], + &["Aura".to_string()], + ); + { + let obj = runner.state_mut().objects.get_mut(&aura).unwrap(); + obj.card_types.core_types = vec![CoreType::Enchantment]; + obj.card_types.subtypes = vec!["Aura".to_string()]; + obj.base_card_types = obj.card_types.clone(); + obj.keywords = parsed.extracted_keywords; + obj.replacements = parsed.replacements; + obj.statics = parsed.statics; + // Narrow Enchant so only `host` is a legal attach target (auto-attach). + obj.keywords.retain(|k| !matches!(k, Keyword::Enchant(_))); + let mut you_control_creature = TypedFilter::creature(); + you_control_creature.controller = Some(ControllerRef::You); + obj.keywords + .push(Keyword::Enchant(TargetFilter::Typed(you_control_creature))); + } + assert!( + runner.state().objects[&aura] + .replacements + .iter() + .any(|r| matches!( + r.execute.as_ref().map(|e| e.effect.as_ref()), + Some(Effect::ChoosePermanent { .. }) + )), + "non-spell fixture must carry the ChoosePermanent as-enters replacement" + ); + + runner.state_mut().waiting_for = WaitingFor::DigChoice { + player: P0, + library_owner: P0, + cards: vec![aura], + keep_count: 1, + up_to: false, + selectable_cards: vec![aura], + kept_destination: Some(Zone::Battlefield), + rest_destination: Some(Zone::Graveyard), + source_id: None, + enter_tapped: false, + }; + + runner + .act(GameAction::SelectCards { cards: vec![aura] }) + .expect("dig keep must put the Aura onto the battlefield"); + + // Single legal host → auto-attach (no ReturnAsAuraTarget); copy choice next. + match &runner.state().waiting_for { + WaitingFor::CopyTargetChoice { purpose, .. } => { + assert!( + matches!(purpose, CopyTargetPurpose::PersistChosenAttribute), + "non-spell path must raise PersistChosenAttribute, got {purpose:?}" + ); + } + other => panic!("expected CopyTargetChoice after non-spell Aura entry, got {other:?}"), + } + assert_eq!( + runner.state().objects[&aura].attached_to, + Some(AttachTarget::Object(host)), + "CR 303.4f: non-spell Aura auto-attaches to the sole legal host before the copy choice" + ); + + runner + .act(GameAction::ChooseTarget { + target: Some(TargetRef::Object(donor)), + }) + .expect("answer the copy-source choice"); + + let host_obj = &runner.state().objects[&host]; + assert_eq!( + host_obj.name, "Serra Angel", + "CR 707.2c: non-spell Aura entry still installs the host copy" + ); + assert_eq!(host_obj.power, Some(4)); + assert!(host_obj.keywords.contains(&Keyword::Flying)); + + let aura_obj = &runner.state().objects[&aura]; + assert_eq!(aura_obj.name, "Metamorphic Alteration"); + assert_eq!( + aura_obj.attached_to, + Some(AttachTarget::Object(host)), + "CR 303.4f: Aura remains attached to the chosen host" + ); +} + /// SHAPE: the "As this Aura enters, choose a creature." line parses to an /// as-enters `Effect::ChoosePermanent { persist: CopiableSnapshot }` over a /// creature copy-source pool — never a `BecomeCopy` on the entering Aura. From 9173e16349f6a1b326d6c071431f5ed375403bca Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 23 Jul 2026 10:12:33 -0500 Subject: [PATCH 06/20] fix(engine): gate Metamorphic ChoosePermanent on CopyChosen link Bare as-enters permanent choices stay unsupported until a CopyChosenHost document relation proves the companion static; stage the non-spell fixture via from_oracle_text so live and base definitions compile. Co-authored-by: Cursor --- crates/engine/src/parser/oracle.rs | 97 ++++++++++++++++++- .../engine/src/parser/oracle_ir/relation.rs | 11 +++ .../engine/src/parser/oracle_replacement.rs | 70 +++++++------ .../integration/metamorphic_alteration.rs | 48 ++++----- 4 files changed, 168 insertions(+), 58 deletions(-) diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index d7aabc9dff..8bfb4efa07 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -11,11 +11,11 @@ use serde::{Deserialize, Serialize}; use crate::types::ability::{ AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, AbilityTag, ActivationRestriction, AdditionalCost, CastTimingPermission, CastingRestriction, ChoiceType, - ChosenSubtypeKind, ContinuousModification, ControllerRef, CostReduction, - DelayedTriggerCondition, Duration, Effect, EffectScope, FilterProp, ManaProduction, - ModalChoice, ParsedCondition, PlayerFilter, QuantityExpr, QuantityRef, ReplacementDefinition, - SolveCondition, SpellCastingOption, StaticCondition, StaticDefinition, TapStateChange, - TargetFilter, TriggerCondition, TriggerDefinition, TypedFilter, + ChoosePermanentPersist, ChosenSubtypeKind, ContinuousModification, ControllerRef, + CostReduction, DelayedTriggerCondition, Duration, Effect, EffectScope, FilterProp, + ManaProduction, ModalChoice, ParsedCondition, PlayerFilter, QuantityExpr, QuantityRef, + ReplacementDefinition, SolveCondition, SpellCastingOption, StaticCondition, StaticDefinition, + TapStateChange, TargetFilter, TriggerCondition, TriggerDefinition, TypedFilter, }; use crate::types::format::DeckCopyLimit; use crate::types::keywords::{EscapeCost, FlashbackCost, Keyword, KeywordKind}; @@ -1192,6 +1192,7 @@ fn detect_document_relations(items: &[OracleItemIr], types: &[String]) -> Vec, +) { + let chooser = items.iter().find(|item| { + item_replacement(item) + .and_then(|replacement| replacement.execute.as_ref()) + .is_some_and(is_as_enters_choose_permanent_unsupported) + }); + let copy_static = items.iter().find(|item| { + item_static(item).is_some_and(|s| { + s.modifications + .contains(&ContinuousModification::CopyChosen) + }) + }); + if let (Some(chooser), Some(copy_static)) = (chooser, copy_static) { + if chooser.id != copy_static.id { + relations.push(DocumentRelationIr::LinkedChoice( + LinkedChoiceKind::CopyChosenHost { + chooser: chooser.id, + copy_static: copy_static.id, + }, + )); + } + } +} + +/// CR 607.2d + CR 707.2c + CR 614.12a: Upgrade a proven chooser from the +/// unsupported marker to `Effect::ChoosePermanent { CopiableSnapshot }`. The +/// filter is re-derived from the marker's Oracle description so line-local +/// lowering never assigns copy-host semantics without this relation. +fn apply_linked_choice_copy_chosen_host( + result: &mut ParsedAbilities, + relations: &[DocumentRelationIr], + replacement_ids: &[OracleItemId], +) { + for relation in relations { + let DocumentRelationIr::LinkedChoice(LinkedChoiceKind::CopyChosenHost { chooser, .. }) = + relation + else { + continue; + }; + let Some(pos) = position_of(replacement_ids, *chooser) else { + continue; + }; + let Some(execute) = result.replacements[pos].execute.as_mut() else { + continue; + }; + let description = match execute.effect.as_ref() { + Effect::Unimplemented { description, .. } => description.clone(), + _ => continue, + }; + let lower = description.to_lowercase(); + let Some((_, _, choose_suffix)) = + scan_preceded(&lower, |i| tag::<_, _, OracleError<'_>>("choose ").parse(i)) + else { + continue; + }; + let Some(filter) = + super::oracle_replacement::as_enters_choose_permanent_filter(choose_suffix) + else { + continue; + }; + *execute = AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChoosePermanent { + filter, + persist: ChoosePermanentPersist::CopiableSnapshot, + }, + ); + } +} + +fn is_as_enters_choose_permanent_unsupported(def: &AbilityDefinition) -> bool { + matches!( + def.effect.as_ref(), + Effect::Unimplemented { name, .. } if name == "as-enters-choose-permanent" + ) +} + /// Whether an ability's effect chain (recursing sub-abilities) makes a /// player/opponent choice. fn ability_chain_has_player_choice(def: &AbilityDefinition) -> bool { @@ -2848,6 +2934,7 @@ pub(crate) fn lower_oracle_ir(ir: &mut OracleDocIr) -> ParsedAbilities { ); reconcile_host_bound_phase_outs(&mut result); apply_linked_choice_persisted_player(&mut result, &ir.relations, &ability_ids, &trigger_ids); + apply_linked_choice_copy_chosen_host(&mut result, &ir.relations, &replacement_ids); // Architectural rule: the parser must never silently discard Oracle text. Run // the swallow audit against the parsed result so any unrepresented clause diff --git a/crates/engine/src/parser/oracle_ir/relation.rs b/crates/engine/src/parser/oracle_ir/relation.rs index 3a3a24981f..ae4f07d655 100644 --- a/crates/engine/src/parser/oracle_ir/relation.rs +++ b/crates/engine/src/parser/oracle_ir/relation.rs @@ -130,4 +130,15 @@ pub(crate) enum LinkedChoiceKind { /// exists, so a resolution-scoped choice with no durable reader stays /// non-persisted. PersistedPlayer { choosers: Vec }, + /// CR 607.2d + CR 707.2c + CR 614.12a: An as-enters permanent-object choice + /// (`chooser`) linked to a `ContinuousModification::CopyChosen` static + /// (`copy_static`). Applying rewrites the chooser's unsupported marker into + /// `Effect::ChoosePermanent { persist: CopiableSnapshot }` — Metamorphic + /// Alteration's Aura-host copy. Without this consumer relation the choose + /// line stays loud-unsupported rather than silently gaining copy-host + /// semantics. + CopyChosenHost { + chooser: OracleItemId, + copy_static: OracleItemId, + }, } diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 56b09f89e2..8da50dff08 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -33,15 +33,15 @@ use super::oracle_util::{ }; use crate::types::ability::CastingPermission; use crate::types::ability::{ - AbilityCost, AbilityDefinition, AbilityKind, CastVariantPaid, ChoiceType, - ChoosePermanentPersist, CombatDamageScope, Comparator, ContinuousModification, ControllerRef, - CopyManaValueLimit, CountScope, CounterReplacementSubject, DamageModification, - DamageRedirectTarget, DamageTargetFilter, DamageTargetPlayerScope, DrawReplacementScope, - Duration, Effect, EffectScope, FilterProp, LibraryPosition, ManaModification, - ManaReplacementScope, ManaSpendPermission, PermissionGrantee, PlayerFilter, PreventionAmount, - QuantityExpr, QuantityModification, QuantityRef, ReplacementCondition, ReplacementDefinition, - ReplacementMode, ReplacementPlayerScope, StaticCondition, StaticDefinition, TapStateChange, - TargetFilter, TypeFilter, TypedFilter, + AbilityCost, AbilityDefinition, AbilityKind, CastVariantPaid, ChoiceType, CombatDamageScope, + Comparator, ContinuousModification, ControllerRef, CopyManaValueLimit, CountScope, + CounterReplacementSubject, DamageModification, DamageRedirectTarget, DamageTargetFilter, + DamageTargetPlayerScope, DrawReplacementScope, Duration, Effect, EffectScope, FilterProp, + LibraryPosition, ManaModification, ManaReplacementScope, ManaSpendPermission, + PermissionGrantee, PlayerFilter, PreventionAmount, QuantityExpr, QuantityModification, + QuantityRef, ReplacementCondition, ReplacementDefinition, ReplacementMode, + ReplacementPlayerScope, StaticCondition, StaticDefinition, TapStateChange, TargetFilter, + TypeFilter, TypedFilter, }; use crate::types::card_type::Supertype; use crate::types::counter::{CounterMatch, CounterType}; @@ -2133,8 +2133,9 @@ fn parse_as_enters_choose(norm_lower: &str, original_text: &str) -> Option bool { /// Parse the object-filter of an as-enters permanent choice. Accepts either the /// suffix after `choose ` (`"a creature."`) or a full `"choose a creature."` /// clause. Requires full consumption and a concrete `Typed` filter. -fn as_enters_choose_permanent_filter(choose_text: &str) -> Option { +/// +/// `pub(crate)` so the `CopyChosenHost` document-relation apply step can +/// re-derive the filter from an unsupported chooser's description when upgrading +/// to `ChoosePermanent { CopiableSnapshot }`. +pub(crate) fn as_enters_choose_permanent_filter(choose_text: &str) -> Option { // Structural optional-prefix + trailing-period cleanup (not dispatch): // callers may pass the match-at-start slice or the post-`choose ` suffix. let object_phrase = choose_text.trim_start(); @@ -2325,26 +2330,29 @@ fn as_enters_choose_permanent_filter(choose_text: &str) -> Option /// CR 614.12a + CR 707.2c: "As this Aura enters, choose a creature." — the /// as-enters OBJECT-choice analogue of `parse_as_enters_choose` (which only /// handles named-attribute choices: color, creature type, card name, etc.). -/// The chosen permanent's copiable values are latched at the choice answer and -/// installed as a Layer-1 copy on the Aura's enchanted host -/// (`ChoosePermanentPersist::CopiableSnapshot`) — the entering Aura itself is -/// never turned into a copy. Metamorphic Alteration. +/// +/// Line-local lowering does **not** emit `ChoosePermanentPersist::CopiableSnapshot`: +/// that persist purpose installs a Layer-1 copy on an Aura's enchanted host and +/// is only rules-correct when a companion `ContinuousModification::CopyChosen` +/// consumer is also present (Metamorphic Alteration). Emit a loud unsupported +/// marker instead; `LinkedChoiceKind::CopyChosenHost` upgrades it at the +/// document-relation seam when the consumer is proven. /// /// `choose_text` is the object phrase (optionally still carrying a leading /// `"choose "`). Parsed with the shared `parse_target` descriptor grammar; the -/// remainder must be empty and the filter must be `Typed`. +/// remainder must be empty and the filter must be `Typed` (proves the phrase is +/// an object choice before we claim the line). fn parse_as_enters_choose_permanent( choose_text: &str, original_text: &str, ) -> Option { - let filter = as_enters_choose_permanent_filter(choose_text)?; + // Validate the object phrase is a concrete Typed filter — then leave the + // CopiableSnapshot lowering to the CopyChosenHost document relation. + let _filter = as_enters_choose_permanent_filter(choose_text)?; let execute = AbilityDefinition::new( AbilityKind::Spell, - Effect::ChoosePermanent { - filter, - persist: ChoosePermanentPersist::CopiableSnapshot, - }, + Effect::unimplemented("as-enters-choose-permanent", original_text), ); Some( @@ -14715,25 +14723,27 @@ mod tests { )); } - /// CR 614.12a + CR 707.2c: object as-enters choice (Metamorphic Alteration) - /// must lower to `Effect::ChoosePermanent`, not a named `ChoiceType`. + /// CR 614.12a + CR 707.2c: object as-enters choice alone is loud-unsupported + /// until a companion `CopyChosen` static proves the host-copy relation + /// (`LinkedChoiceKind::CopyChosenHost`). Line-local parse must not emit + /// `ChoosePermanent { CopiableSnapshot }`. #[test] - fn as_enters_choose_a_creature_is_choose_permanent() { + fn as_enters_choose_a_creature_is_unsupported_without_copy_chosen() { let def = parse_replacement_line( "As this Aura enters, choose a creature.", "Metamorphic Alteration", ) - .expect("as-enters choose-a-creature must parse as a Moved replacement"); + .expect("as-enters choose-a-creature must claim a Moved replacement"); let execute = def.execute.as_ref().unwrap(); assert!( matches!( *execute.effect, - Effect::ChoosePermanent { - persist: ChoosePermanentPersist::CopiableSnapshot, + Effect::Unimplemented { + name: ref n, .. - } + } if n == "as-enters-choose-permanent" ), - "expected ChoosePermanent {{ CopiableSnapshot }}, got {:?}", + "expected unsupported as-enters-choose-permanent marker, got {:?}", execute.effect ); } diff --git a/crates/engine/tests/integration/metamorphic_alteration.rs b/crates/engine/tests/integration/metamorphic_alteration.rs index 0e6dc5f4b3..82c75c8a28 100644 --- a/crates/engine/tests/integration/metamorphic_alteration.rs +++ b/crates/engine/tests/integration/metamorphic_alteration.rs @@ -490,37 +490,37 @@ fn non_spell_aura_entry_copies_chosen_creature() { .add_creature_from_oracle(P1, "Serra Angel", 4, 4, "Flying") .id(); let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); - // Library dig → battlefield is a non-spell Aura entry (CR 303.4f). - let aura = scenario.add_card_to_library_top(P0, "Metamorphic Alteration"); + // Library dig → battlefield is a non-spell Aura entry (CR 303.4f). Stage + // through the same `from_oracle_text` path as the spell fixture so live + + // base replacement/static definitions are both populated. + let aura = scenario + .add_spell_to_library_top(P0, "Metamorphic Alteration", false) + .as_enchantment() + .with_subtypes(vec!["Aura"]) + .with_mana_cost(ManaCost::Cost { + generic: 2, + shards: vec![ManaCostShard::Blue], + }) + .from_oracle_text(METAMORPHIC_ALTERATION) + .id(); let mut runner = scenario.build(); - // Install Aura identity + parsed Metamorphic replacements/statics/keywords. - let parsed = parse_oracle_text( - METAMORPHIC_ALTERATION, - "Metamorphic Alteration", - &[], - &["Enchantment".to_string()], - &["Aura".to_string()], - ); + // Narrow Enchant so only `host` is a legal attach target (auto-attach). { let obj = runner.state_mut().objects.get_mut(&aura).unwrap(); - obj.card_types.core_types = vec![CoreType::Enchantment]; - obj.card_types.subtypes = vec!["Aura".to_string()]; - obj.base_card_types = obj.card_types.clone(); - obj.keywords = parsed.extracted_keywords; - obj.replacements = parsed.replacements; - obj.statics = parsed.statics; - // Narrow Enchant so only `host` is a legal attach target (auto-attach). obj.keywords.retain(|k| !matches!(k, Keyword::Enchant(_))); + obj.base_keywords + .retain(|k| !matches!(k, Keyword::Enchant(_))); let mut you_control_creature = TypedFilter::creature(); you_control_creature.controller = Some(ControllerRef::You); - obj.keywords - .push(Keyword::Enchant(TargetFilter::Typed(you_control_creature))); + let narrowed = Keyword::Enchant(TargetFilter::Typed(you_control_creature)); + obj.keywords.push(narrowed.clone()); + obj.base_keywords.push(narrowed); } assert!( runner.state().objects[&aura] - .replacements + .replacement_definitions .iter() .any(|r| matches!( r.execute.as_ref().map(|e| e.effect.as_ref()), @@ -585,9 +585,11 @@ fn non_spell_aura_entry_copies_chosen_creature() { ); } -/// SHAPE: the "As this Aura enters, choose a creature." line parses to an -/// as-enters `Effect::ChoosePermanent { persist: CopiableSnapshot }` over a -/// creature copy-source pool — never a `BecomeCopy` on the entering Aura. +/// SHAPE: the "As this Aura enters, choose a creature." line, paired with the +/// companion CopyChosen static on the same card, lowers to an as-enters +/// `Effect::ChoosePermanent { persist: CopiableSnapshot }` over a creature +/// copy-source pool — never a `BecomeCopy` on the entering Aura. A bare choose +/// line without CopyChosen stays unsupported (see unit test). #[test] fn as_enters_choose_a_creature_parses_to_choose_permanent() { let parsed = parse_oracle_text( From b503d99ea58e9df74d8a97b143ab7eee2dd57e1b Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 23 Jul 2026 10:17:43 -0500 Subject: [PATCH 07/20] fix(engine): compile CopyChosenHost apply against Box execute is_some_and needs a closure over &Box, description is Option, and assignment targets the boxed AbilityDefinition. Co-authored-by: Cursor --- crates/engine/src/parser/oracle.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index 8bfb4efa07..57d32f9573 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -1688,7 +1688,7 @@ fn detect_linked_choice_copy_chosen_host( let chooser = items.iter().find(|item| { item_replacement(item) .and_then(|replacement| replacement.execute.as_ref()) - .is_some_and(is_as_enters_choose_permanent_unsupported) + .is_some_and(|exec| is_as_enters_choose_permanent_unsupported(exec)) }); let copy_static = items.iter().find(|item| { item_static(item).is_some_and(|s| { @@ -1729,9 +1729,11 @@ fn apply_linked_choice_copy_chosen_host( let Some(execute) = result.replacements[pos].execute.as_mut() else { continue; }; - let description = match execute.effect.as_ref() { - Effect::Unimplemented { description, .. } => description.clone(), - _ => continue, + if !is_as_enters_choose_permanent_unsupported(execute) { + continue; + } + let Some(description) = execute.effect.unimplemented_description().map(str::to_owned) else { + continue; }; let lower = description.to_lowercase(); let Some((_, _, choose_suffix)) = @@ -1744,7 +1746,7 @@ fn apply_linked_choice_copy_chosen_host( else { continue; }; - *execute = AbilityDefinition::new( + **execute = AbilityDefinition::new( AbilityKind::Spell, Effect::ChoosePermanent { filter, From ce5cf1d32195c756e450d51e74a73143a4b8d364 Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 23 Jul 2026 10:32:33 -0500 Subject: [PATCH 08/20] fix(engine): narrow Metamorphic choose to CopyChosenHost only Stop claiming Moved for bare as-enters permanent choices so Dauntless Bodyguard and Scheming Fence keep unsupported ability shape; inject ChoosePermanent only when a CopyChosen companion proves the relation. Co-authored-by: Cursor --- crates/engine/src/parser/oracle.rs | 110 +++++++++++------- crates/engine/src/parser/oracle_classifier.rs | 13 ++- .../engine/src/parser/oracle_ir/relation.rs | 11 +- .../engine/src/parser/oracle_replacement.rs | 99 ++++------------ .../integration/metamorphic_alteration.rs | 63 +++++++++- 5 files changed, 167 insertions(+), 129 deletions(-) diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index 57d32f9573..c875953ee7 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -30,7 +30,7 @@ use crate::types::zones::Zone; use super::oracle_nom::bridge::{nom_on_lower, split_once_on_lower}; use super::oracle_nom::condition::parse_graveyard_keyword_grant_sentence; use super::oracle_nom::primitives::{ - parse_number as nom_parse_number, scan_contains, scan_preceded, + parse_number as nom_parse_number, scan_at_word_boundaries, scan_contains, scan_preceded, }; use super::oracle_attraction::parse_attraction_visit_triggers; @@ -1678,18 +1678,18 @@ fn apply_linked_choice_persisted_player( // --- CR 607.2d + CR 707.2c: as-enters permanent choice → CopyChosen host copy -- -/// Pair an as-enters permanent-object choice (loud-unsupported until proven) -/// with a `ContinuousModification::CopyChosen` consumer. First-match of each -/// mirrors the other linked-choice detectors. +/// Pair an as-enters permanent-object choice gap (Unimplemented ability) with a +/// `ContinuousModification::CopyChosen` consumer. First-match of each mirrors +/// the other linked-choice detectors. The chooser is deliberately NOT claimed +/// as a Moved replacement at line-local parse — only this relation injects +/// `ChoosePermanent`, so non-CopyChosen cards keep their prior unsupported shape. fn detect_linked_choice_copy_chosen_host( items: &[OracleItemIr], relations: &mut Vec, ) { - let chooser = items.iter().find(|item| { - item_replacement(item) - .and_then(|replacement| replacement.execute.as_ref()) - .is_some_and(|exec| is_as_enters_choose_permanent_unsupported(exec)) - }); + let chooser = items + .iter() + .find(|item| item_ability(item).is_some_and(ability_is_as_enters_choose_permanent_gap)); let copy_static = items.iter().find(|item| { item_static(item).is_some_and(|s| { s.modifications @@ -1708,14 +1708,15 @@ fn detect_linked_choice_copy_chosen_host( } } -/// CR 607.2d + CR 707.2c + CR 614.12a: Upgrade a proven chooser from the -/// unsupported marker to `Effect::ChoosePermanent { CopiableSnapshot }`. The -/// filter is re-derived from the marker's Oracle description so line-local -/// lowering never assigns copy-host semantics without this relation. +/// CR 607.2d + CR 707.2c + CR 614.12a: Replace the proven chooser gap ability +/// with a Moved `ChoosePermanent { CopiableSnapshot }` replacement. Filter is +/// re-derived from the Unimplemented description so line-local parse never +/// assigns copy-host semantics without this relation. fn apply_linked_choice_copy_chosen_host( result: &mut ParsedAbilities, relations: &[DocumentRelationIr], - replacement_ids: &[OracleItemId], + ability_ids: &mut Vec, + replacement_ids: &mut Vec, ) { for relation in relations { let DocumentRelationIr::LinkedChoice(LinkedChoiceKind::CopyChosenHost { chooser, .. }) = @@ -1723,44 +1724,63 @@ fn apply_linked_choice_copy_chosen_host( else { continue; }; - let Some(pos) = position_of(replacement_ids, *chooser) else { + let Some(ability_pos) = position_of(ability_ids, *chooser) else { continue; }; - let Some(execute) = result.replacements[pos].execute.as_mut() else { - continue; - }; - if !is_as_enters_choose_permanent_unsupported(execute) { - continue; - } - let Some(description) = execute.effect.unimplemented_description().map(str::to_owned) else { - continue; - }; - let lower = description.to_lowercase(); - let Some((_, _, choose_suffix)) = - scan_preceded(&lower, |i| tag::<_, _, OracleError<'_>>("choose ").parse(i)) + let Some(description) = result.abilities[ability_pos] + .effect + .unimplemented_description() + .map(str::to_owned) else { continue; }; - let Some(filter) = - super::oracle_replacement::as_enters_choose_permanent_filter(choose_suffix) - else { + let Some(filter) = filter_from_as_enters_choose_permanent_text(&description) else { continue; }; - **execute = AbilityDefinition::new( + result.abilities.remove(ability_pos); + ability_ids.remove(ability_pos); + let execute = AbilityDefinition::new( AbilityKind::Spell, Effect::ChoosePermanent { filter, persist: ChoosePermanentPersist::CopiableSnapshot, }, ); + result.replacements.push( + ReplacementDefinition::new(ReplacementEvent::Moved) + .execute(execute) + .valid_card(TargetFilter::SelfRef) + // CR 614.1c: battlefield-entry-scoped. + .destination_zone(Zone::Battlefield) + .description(description), + ); + replacement_ids.push(*chooser); } } -fn is_as_enters_choose_permanent_unsupported(def: &AbilityDefinition) -> bool { - matches!( - def.effect.as_ref(), - Effect::Unimplemented { name, .. } if name == "as-enters-choose-permanent" - ) +/// An Unimplemented ability whose fragment is an as-enters permanent-object +/// choice ("As … enters, choose a creature/permanent…"). Framing + Typed filter +/// must both match — same grammar as `as_enters_choose_permanent_filter`. +fn ability_is_as_enters_choose_permanent_gap(def: &AbilityDefinition) -> bool { + let Some(description) = def.effect.unimplemented_description() else { + return false; + }; + filter_from_as_enters_choose_permanent_text(description).is_some() +} + +fn filter_from_as_enters_choose_permanent_text(description: &str) -> Option { + let lower = description.to_lowercase(); + let has_as = + scan_at_word_boundaries(&lower, |i| tag::<_, _, OracleError<'_>>("as ").parse(i)).is_some(); + let has_enters = + scan_at_word_boundaries(&lower, |i| tag::<_, _, OracleError<'_>>("enters").parse(i)) + .is_some(); + if !has_as || !has_enters { + return None; + } + let (_, _, choose_suffix) = + scan_preceded(&lower, |i| tag::<_, _, OracleError<'_>>("choose ").parse(i))?; + super::oracle_replacement::as_enters_choose_permanent_filter(choose_suffix) } /// Whether an ability's effect chain (recursing sub-abilities) makes a @@ -2936,7 +2956,12 @@ pub(crate) fn lower_oracle_ir(ir: &mut OracleDocIr) -> ParsedAbilities { ); reconcile_host_bound_phase_outs(&mut result); apply_linked_choice_persisted_player(&mut result, &ir.relations, &ability_ids, &trigger_ids); - apply_linked_choice_copy_chosen_host(&mut result, &ir.relations, &replacement_ids); + apply_linked_choice_copy_chosen_host( + &mut result, + &ir.relations, + &mut ability_ids, + &mut replacement_ids, + ); // Architectural rule: the parser must never silently discard Oracle text. Run // the swallow audit against the parsed result so any unrepresented clause @@ -2952,11 +2977,12 @@ pub(crate) fn lower_oracle_ir(ir: &mut OracleDocIr) -> ParsedAbilities { // rather than the whole card's text. The draft-matters (CR 905) filter that used // to strip lines from the whole-card text moves inside as a per-item skip. // - // The tracks are sound to zip here: of the four relation passes above, three are - // length-preserving and `apply_linked_choice_etb_counter` removes from - // `result.replacements` and `replacement_ids` at the same index. This is also - // exactly why the audit stays HERE, post-relation: a pre-lowering audit is blind - // to relation-synthesized semantics (that pass *synthesizes a replacement*), so + // The tracks are sound to zip here: of the relation passes above, + // `apply_linked_choice_etb_counter` removes from `result.replacements` and + // `replacement_ids` at the same index, and `apply_linked_choice_copy_chosen_host` + // moves an ability id onto the replacement track. This is also exactly why + // the audit stays HERE, post-relation: a pre-lowering audit is blind to + // relation-synthesized semantics (that pass *synthesizes a replacement*), so // the false-positive wave U1 bounded to 31 faces would be caused, not avoided. // // Emitted into a local vec and appended, rather than passing `&mut diff --git a/crates/engine/src/parser/oracle_classifier.rs b/crates/engine/src/parser/oracle_classifier.rs index ae2378f9d2..a3bb4f2f7d 100644 --- a/crates/engine/src/parser/oracle_classifier.rs +++ b/crates/engine/src/parser/oracle_classifier.rs @@ -977,15 +977,16 @@ fn is_as_enters_choose_pattern(lower: &str) -> bool { tag::<_, _, OracleError<'_>>("enters").parse(i) }) .is_some(); - // CR 614.12a + CR 707.2c: named-attribute choices ("choose a creature type") - // AND object choices ("choose a creature" — Metamorphic Alteration) both - // route through `parse_as_enters_choose`. Gating only on - // `try_parse_named_choice` dropped the object-choice class before Priority-8 - // replacement dispatch, so the permanent combinator never ran. + // Named-attribute choices only ("choose a creature type", "choose a color"). + // Object choices ("choose a creature" — Metamorphic Alteration, Dauntless + // Bodyguard, Scheming Fence) are NOT replacement-classified here: claiming + // them as Moved without a proven CopyChosen consumer changes unsupported + // card shape for the whole class. Metamorphic's ChoosePermanent is injected + // only by `LinkedChoiceKind::CopyChosenHost` after the companion static + // parses. let has_choose = nom_primitives::scan_at_word_boundaries(lower, |i| { verify(tag::<_, _, OracleError<'_>>("choose "), |_: &&str| { try_parse_named_choice(i).is_some() - || super::oracle_replacement::is_as_enters_choose_permanent_phrase(i) }) .parse(i) }) diff --git a/crates/engine/src/parser/oracle_ir/relation.rs b/crates/engine/src/parser/oracle_ir/relation.rs index ae4f07d655..eb3f40f29d 100644 --- a/crates/engine/src/parser/oracle_ir/relation.rs +++ b/crates/engine/src/parser/oracle_ir/relation.rs @@ -131,12 +131,15 @@ pub(crate) enum LinkedChoiceKind { /// non-persisted. PersistedPlayer { choosers: Vec }, /// CR 607.2d + CR 707.2c + CR 614.12a: An as-enters permanent-object choice - /// (`chooser`) linked to a `ContinuousModification::CopyChosen` static - /// (`copy_static`). Applying rewrites the chooser's unsupported marker into + /// gap (`chooser` — an Unimplemented ability whose Oracle text is + /// "As … enters, choose ") linked to a + /// `ContinuousModification::CopyChosen` static (`copy_static`). Applying + /// removes the gap ability and injects /// `Effect::ChoosePermanent { persist: CopiableSnapshot }` — Metamorphic /// Alteration's Aura-host copy. Without this consumer relation the choose - /// line stays loud-unsupported rather than silently gaining copy-host - /// semantics. + /// line stays an ordinary Unimplemented ability (no Moved claim), so + /// non-CopyChosen cards (Dauntless Bodyguard, Scheming Fence) keep their + /// pre-existing unsupported shape. CopyChosenHost { chooser: OracleItemId, copy_static: OracleItemId, diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 8da50dff08..2621ed511a 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -2121,8 +2121,8 @@ fn parse_as_enters_choose(norm_lower: &str, original_text: &str) -> Option>("choose ").parse(i) })?; @@ -2130,15 +2130,12 @@ fn parse_as_enters_choose(norm_lower: &str, original_text: &str) -> Option Option bool { as_enters_choose_permanent_filter(choose_text).is_some() } @@ -2301,8 +2299,8 @@ pub(crate) fn is_as_enters_choose_permanent_phrase(choose_text: &str) -> bool { /// clause. Requires full consumption and a concrete `Typed` filter. /// /// `pub(crate)` so the `CopyChosenHost` document-relation apply step can -/// re-derive the filter from an unsupported chooser's description when upgrading -/// to `ChoosePermanent { CopiableSnapshot }`. +/// derive the filter from an Unimplemented ability's description when injecting +/// `ChoosePermanent { CopiableSnapshot }`. pub(crate) fn as_enters_choose_permanent_filter(choose_text: &str) -> Option { // Structural optional-prefix + trailing-period cleanup (not dispatch): // callers may pass the match-at-start slice or the post-`choose ` suffix. @@ -2327,44 +2325,6 @@ pub(crate) fn as_enters_choose_permanent_filter(choose_text: &str) -> Option Option { - // Validate the object phrase is a concrete Typed filter — then leave the - // CopiableSnapshot lowering to the CopyChosenHost document relation. - let _filter = as_enters_choose_permanent_filter(choose_text)?; - - let execute = AbilityDefinition::new( - AbilityKind::Spell, - Effect::unimplemented("as-enters-choose-permanent", original_text), - ); - - Some( - ReplacementDefinition::new(ReplacementEvent::Moved) - .execute(execute) - .valid_card(TargetFilter::SelfRef) - // CR 614.1c: battlefield-entry-scoped, mirroring the named-choice sibling. - .destination_zone(Zone::Battlefield) - .description(original_text.to_string()), - ) -} - /// Parse "As ~ becomes attached [to a creature/permanent], choose …" into an /// `Attached`-event replacement with a persisted `Choose` (Psychic Paper: "As /// this Equipment becomes attached to a creature, choose a creature card name @@ -14723,28 +14683,19 @@ mod tests { )); } - /// CR 614.12a + CR 707.2c: object as-enters choice alone is loud-unsupported - /// until a companion `CopyChosen` static proves the host-copy relation - /// (`LinkedChoiceKind::CopyChosenHost`). Line-local parse must not emit - /// `ChoosePermanent { CopiableSnapshot }`. + /// CR 614.12a + CR 707.2c: object as-enters choice alone is NOT a + /// replacement line — line-local parse must leave it alone so non-CopyChosen + /// cards keep their unsupported ability shape. `CopyChosenHost` injects + /// ChoosePermanent only when the companion static is present. #[test] - fn as_enters_choose_a_creature_is_unsupported_without_copy_chosen() { - let def = parse_replacement_line( - "As this Aura enters, choose a creature.", - "Metamorphic Alteration", - ) - .expect("as-enters choose-a-creature must claim a Moved replacement"); - let execute = def.execute.as_ref().unwrap(); + fn as_enters_choose_a_creature_is_not_a_line_local_replacement() { assert!( - matches!( - *execute.effect, - Effect::Unimplemented { - name: ref n, - .. - } if n == "as-enters-choose-permanent" - ), - "expected unsupported as-enters-choose-permanent marker, got {:?}", - execute.effect + parse_replacement_line( + "As this Aura enters, choose a creature.", + "Metamorphic Alteration", + ) + .is_none(), + "bare object as-enters choose must not claim Moved without CopyChosen" ); } diff --git a/crates/engine/tests/integration/metamorphic_alteration.rs b/crates/engine/tests/integration/metamorphic_alteration.rs index 82c75c8a28..8e9c9c2766 100644 --- a/crates/engine/tests/integration/metamorphic_alteration.rs +++ b/crates/engine/tests/integration/metamorphic_alteration.rs @@ -585,11 +585,11 @@ fn non_spell_aura_entry_copies_chosen_creature() { ); } -/// SHAPE: the "As this Aura enters, choose a creature." line, paired with the -/// companion CopyChosen static on the same card, lowers to an as-enters +/// SHAPE: the full Metamorphic Oracle (choose line + CopyChosen static) lowers +/// the choose gap via `CopyChosenHost` to an as-enters /// `Effect::ChoosePermanent { persist: CopiableSnapshot }` over a creature /// copy-source pool — never a `BecomeCopy` on the entering Aura. A bare choose -/// line without CopyChosen stays unsupported (see unit test). +/// line without CopyChosen stays an Unimplemented ability (see honesty test). #[test] fn as_enters_choose_a_creature_parses_to_choose_permanent() { let parsed = parse_oracle_text( @@ -662,3 +662,60 @@ fn enchanted_is_a_copy_of_chosen_parses_to_copy_chosen_static() { other => panic!("expected a Typed enchanted-host filter, got {other:?}"), } } + +/// COVERAGE HONESTY: as-enters permanent-object choices WITHOUT a CopyChosen +/// companion must not gain a Moved / ChoosePermanent shape. Dauntless Bodyguard +/// and Scheming Fence are the parse-diff blast-radius representatives — their +/// choose lines stay explicit Unimplemented abilities. +#[test] +fn non_copy_chosen_as_enters_permanent_choose_stays_unsupported() { + const CASES: &[(&str, &str, &[&str], &[&str])] = &[ + ( + "Dauntless Bodyguard", + "As this creature enters, choose another creature you control.\n\ + Sacrifice this creature: The chosen creature gains indestructible until end of turn.", + &["Creature"], + &["Human", "Knight"], + ), + ( + "Scheming Fence", + "As this creature enters, you may choose a nonland permanent.\n\ + Activated abilities of the chosen permanent can't be activated.\n\ + This creature has all activated abilities of the chosen permanent except for loyalty abilities. \ + You may spend mana as though it were mana of any color to activate those abilities.", + &["Creature"], + &["Human", "Citizen"], + ), + ]; + + for &(name, oracle, types, subtypes) in CASES { + let type_strings: Vec = types.iter().map(|s| (*s).to_string()).collect(); + let subtype_strings: Vec = subtypes.iter().map(|s| (*s).to_string()).collect(); + let parsed = parse_oracle_text(oracle, name, &[], &type_strings, &subtype_strings); + + assert!( + parsed.replacements.iter().all(|r| { + !matches!( + r.execute.as_ref().map(|e| e.effect.as_ref()), + Some(Effect::ChoosePermanent { .. }) + ) + }), + "{name}: must not emit ChoosePermanent without CopyChosen" + ); + assert!( + !parsed.statics.iter().any(|s| { + s.modifications + .contains(&ContinuousModification::CopyChosen) + }), + "{name}: sanity — no CopyChosen static on this class" + ); + assert!( + parsed.abilities.iter().any(|a| { + a.effect + .unimplemented_description() + .is_some_and(|d| d.to_lowercase().contains("choose")) + }), + "{name}: as-enters permanent choose must remain an explicit Unimplemented ability gap" + ); + } +} From 3a0e901261b0e2a61290bcb7aa9be5ec562ca6de Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 23 Jul 2026 10:36:18 -0500 Subject: [PATCH 09/20] fix(engine): drop unused as-enters permanent phrase helper Classifier no longer routes object chooses through replacement parse; only as_enters_choose_permanent_filter remains for CopyChosenHost. Co-authored-by: Cursor --- crates/engine/src/parser/oracle_replacement.rs | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 2621ed511a..a82792fe96 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -2284,23 +2284,15 @@ fn parse_as_enters_choose(norm_lower: &str, original_text: &str) -> Option bool { - as_enters_choose_permanent_filter(choose_text).is_some() -} - /// Parse the object-filter of an as-enters permanent choice. Accepts either the /// suffix after `choose ` (`"a creature."`) or a full `"choose a creature."` /// clause. Requires full consumption and a concrete `Typed` filter. /// -/// `pub(crate)` so the `CopyChosenHost` document-relation apply step can -/// derive the filter from an Unimplemented ability's description when injecting -/// `ChoosePermanent { CopiableSnapshot }`. +/// Used by `LinkedChoiceKind::CopyChosenHost` to identify Unimplemented ability +/// gaps that pair with a CopyChosen static and to derive the injected +/// `ChoosePermanent { CopiableSnapshot }` filter — never by line-local +/// replacement classification (that would claim Moved for every +/// permanent-choose card). pub(crate) fn as_enters_choose_permanent_filter(choose_text: &str) -> Option { // Structural optional-prefix + trailing-period cleanup (not dispatch): // callers may pass the match-at-start slice or the post-`choose ` suffix. From c7d81ead697ee502b2927df4f0780c8ef47f6966 Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 23 Jul 2026 10:45:55 -0500 Subject: [PATCH 10/20] fix(engine): use Definitions::iter_all in Metamorphic fixture assert Live replacement_definitions is Definitions, not a slice. Co-authored-by: Cursor --- crates/engine/tests/integration/metamorphic_alteration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/tests/integration/metamorphic_alteration.rs b/crates/engine/tests/integration/metamorphic_alteration.rs index 8e9c9c2766..ae3a9e94d3 100644 --- a/crates/engine/tests/integration/metamorphic_alteration.rs +++ b/crates/engine/tests/integration/metamorphic_alteration.rs @@ -521,7 +521,7 @@ fn non_spell_aura_entry_copies_chosen_creature() { assert!( runner.state().objects[&aura] .replacement_definitions - .iter() + .iter_all() .any(|r| matches!( r.execute.as_ref().map(|e| e.effect.as_ref()), Some(Effect::ChoosePermanent { .. }) From e42fc8313f6590f18501beda288e14d3c9301bd3 Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 23 Jul 2026 10:53:24 -0500 Subject: [PATCH 11/20] fix(engine): use public iter_unchecked in Metamorphic fixture iter_all is crate-private; integration tests need the public API. Co-authored-by: Cursor --- crates/engine/tests/integration/metamorphic_alteration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/tests/integration/metamorphic_alteration.rs b/crates/engine/tests/integration/metamorphic_alteration.rs index ae3a9e94d3..560409a0d8 100644 --- a/crates/engine/tests/integration/metamorphic_alteration.rs +++ b/crates/engine/tests/integration/metamorphic_alteration.rs @@ -521,7 +521,7 @@ fn non_spell_aura_entry_copies_chosen_creature() { assert!( runner.state().objects[&aura] .replacement_definitions - .iter_all() + .iter_unchecked() .any(|r| matches!( r.execute.as_ref().map(|e| e.effect.as_ref()), Some(Effect::ChoosePermanent { .. }) From 90f4179fbce57621c581ad77f0e91169f9d172d8 Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 23 Jul 2026 11:28:05 -0500 Subject: [PATCH 12/20] fix(engine): honor mid-entry choice from spell CallerEpilogue drain CallerEpilogue discarded apply_pending_post_replacement_effect's CopyTargetChoice, so Metamorphic spell casts attached without copying. Surface the prompt, stash PendingSpellResolution, and return like the delivery NeedsChoice arm. Mark ChoosePermanent order-independent. Co-authored-by: Cursor --- crates/engine/src/game/replacement.rs | 11 +-- crates/engine/src/game/stack.rs | 96 +++++++++++++++++++++++---- 2 files changed, 90 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 7f53b1e119..5eb636d6fa 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -8074,11 +8074,12 @@ fn candidate_materiality( } // ETB-counter replacements (`PutCounter`) only *append* to // `enter_with_counters`, so they never conflict. `Effect::Choose` - // (the as-enters color choice) runs after the ZoneChange and - // touches no shared event field. Both are explicitly recognized as - // order-independent so they do NOT fall through to the conservative - // material default below. - Effect::PutCounter { .. } | Effect::Choose { .. } => {} + // (the as-enters color choice) and `Effect::ChoosePermanent` (the + // as-enters object choice — Metamorphic Alteration) run after the + // ZoneChange and touch no shared event field. Both are explicitly + // recognized as order-independent so they do NOT fall through to + // the conservative material default below. + Effect::PutCounter { .. } | Effect::Choose { .. } | Effect::ChoosePermanent { .. } => {} // CR 614.1a + CR 111.1: Full token substitution on a CreateToken // event rewrites `CreateToken::spec` in the applier. Two different // substitutions on one event are last-applied-wins and stay diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index d90836c6df..e338325ba5 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -1224,20 +1224,92 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { ); } // CR 614.12a: Drain mandatory replacement post-effects (e.g., the - // Siege protector / Tribute opponent-choice prompt that was stashed - // by `apply_single_replacement` while resolving this ZoneChange). - // Sets `state.waiting_for` to the resulting prompt, if any — the - // caller's post-stack resolution checks waiting_for before returning - // priority. Without this drain the choice would be silently dropped. + // Siege protector / Tribute opponent-choice prompt, or Metamorphic + // Alteration's ChoosePermanent CopyTargetChoice) that were stashed + // by `apply_single_replacement` while resolving this ZoneChange. + // `CallerEpilogue` skipped the DeliveryTail drain above, so this + // site owns the prompt. The waiting_for MUST be applied — discarding + // it silently drops mid-entry choices and lets Aura attach proceed + // without the copy (spell-path Metamorphic regression). if state.has_post_replacement_drain() { state.clear_post_replacement_source(); - let _ = super::engine_replacement::apply_pending_post_replacement_effect( - state, - Some(entry.id), - None, - Some(crate::types::replacements::ReplacementEvent::Moved), - events, - ); + if let Some(wf) = + super::engine_replacement::apply_pending_post_replacement_effect( + state, + Some(entry.id), + None, + Some(crate::types::replacements::ReplacementEvent::Moved), + events, + ) + { + if !matches!(wf, WaitingFor::Priority { .. }) { + // CR 608.3c + CR 400.7d: stash PendingSpellResolution so + // the choice-answer resume can complete Aura attachment / + // cast-link stamps — mirrors the delivery NeedsChoice arm + // and ReplacementResult::NeedsChoice arm above. + let cast_from_zone = ability + .as_ref() + .and_then(|a| a.context.cast_from_zone) + .or_else(|| { + state.objects.get(&entry.id).and_then(|o| o.cast_from_zone) + }); + let kickers_paid = ability + .as_ref() + .map(|a| a.context.kickers_paid.clone()) + .unwrap_or_else(|| { + state + .objects + .get(&entry.id) + .map(|o| o.kickers_paid.clone()) + .unwrap_or_default() + }); + let additional_cost_payment_count = ability + .as_ref() + .map(|a| a.context.additional_cost_payment_count) + .unwrap_or_else(|| { + state + .objects + .get(&entry.id) + .map(|o| o.additional_cost_payment_count) + .unwrap_or_default() + }); + let additional_cost_payments = ability + .as_ref() + .map(|a| a.context.additional_cost_payments.clone()) + .unwrap_or_else(|| { + state + .objects + .get(&entry.id) + .map(|o| o.additional_cost_payments.clone()) + .unwrap_or_default() + }); + state.push_spell_resolution( + crate::types::game_state::PendingSpellResolution { + object_id: entry.id, + controller: entry.controller, + casting_variant, + cast_from_zone, + cast_controller: Some(entry.controller), + cast_timing_permission, + spell_targets: spell_targets.clone(), + actual_mana_spent, + kickers_paid, + additional_cost_payment_count, + additional_cost_payments, + convoked_creatures: convoked_creatures.clone(), + }, + ); + state.waiting_for = wf; + events.push(GameEvent::StackResolved { + object_id: entry.id, + }); + state.current_trigger_event = None; + state.current_trigger_events.clear(); + state.current_trigger_match_count = None; + state.die_result_this_resolution = None; + return; + } + } } } super::replacement::ReplacementResult::Prevented => { From e5daa5996b5a3d58d018120dbac31de21cd18b53 Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 23 Jul 2026 12:03:41 -0500 Subject: [PATCH 13/20] fix(engine): apply CallerEpilogue WaitingFor without burying continuations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Honor mid-entry prompts from the spell drain (Metamorphic CopyTargetChoice) but continue the Aura-attach epilogue instead of early-returning with PendingSpellResolution on top — that buried Tribute/Siege AbilityContinuations and broke resume. Host comes from attached_to after epilogue, matching dig. Co-authored-by: Cursor --- crates/engine/src/game/stack.rs | 78 +++++---------------------------- 1 file changed, 11 insertions(+), 67 deletions(-) diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index e338325ba5..d33ca3bef5 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -1228,9 +1228,17 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { // Alteration's ChoosePermanent CopyTargetChoice) that were stashed // by `apply_single_replacement` while resolving this ZoneChange. // `CallerEpilogue` skipped the DeliveryTail drain above, so this - // site owns the prompt. The waiting_for MUST be applied — discarding - // it silently drops mid-entry choices and lets Aura attach proceed - // without the copy (spell-path Metamorphic regression). + // site owns the prompt. + // + // Honor the returned `WaitingFor` — discarding it silently drops + // mid-entry choices (spell-path Metamorphic: Aura attached with + // no copy). Continue the epilogue afterward so CR 608.3c Aura + // attach / cast stamps still run; the answer path then finds the + // host via `attached_to` (PersistChosenAttribute) the same way + // the dig/delivery path does. Do NOT early-return + push + // `PendingSpellResolution` here: that buries a Tribute/Siege + // AbilityContinuation under SpellResolution (`active_*` is + // top-only) and breaks TributeChoice / card-name resume. if state.has_post_replacement_drain() { state.clear_post_replacement_source(); if let Some(wf) = @@ -1243,71 +1251,7 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { ) { if !matches!(wf, WaitingFor::Priority { .. }) { - // CR 608.3c + CR 400.7d: stash PendingSpellResolution so - // the choice-answer resume can complete Aura attachment / - // cast-link stamps — mirrors the delivery NeedsChoice arm - // and ReplacementResult::NeedsChoice arm above. - let cast_from_zone = ability - .as_ref() - .and_then(|a| a.context.cast_from_zone) - .or_else(|| { - state.objects.get(&entry.id).and_then(|o| o.cast_from_zone) - }); - let kickers_paid = ability - .as_ref() - .map(|a| a.context.kickers_paid.clone()) - .unwrap_or_else(|| { - state - .objects - .get(&entry.id) - .map(|o| o.kickers_paid.clone()) - .unwrap_or_default() - }); - let additional_cost_payment_count = ability - .as_ref() - .map(|a| a.context.additional_cost_payment_count) - .unwrap_or_else(|| { - state - .objects - .get(&entry.id) - .map(|o| o.additional_cost_payment_count) - .unwrap_or_default() - }); - let additional_cost_payments = ability - .as_ref() - .map(|a| a.context.additional_cost_payments.clone()) - .unwrap_or_else(|| { - state - .objects - .get(&entry.id) - .map(|o| o.additional_cost_payments.clone()) - .unwrap_or_default() - }); - state.push_spell_resolution( - crate::types::game_state::PendingSpellResolution { - object_id: entry.id, - controller: entry.controller, - casting_variant, - cast_from_zone, - cast_controller: Some(entry.controller), - cast_timing_permission, - spell_targets: spell_targets.clone(), - actual_mana_spent, - kickers_paid, - additional_cost_payment_count, - additional_cost_payments, - convoked_creatures: convoked_creatures.clone(), - }, - ); state.waiting_for = wf; - events.push(GameEvent::StackResolved { - object_id: entry.id, - }); - state.current_trigger_event = None; - state.current_trigger_events.clear(); - state.current_trigger_match_count = None; - state.die_result_this_resolution = None; - return; } } } From 942cbcbfb1ed3d8922ef9c90b2b451171526f2d1 Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 23 Jul 2026 12:25:35 -0500 Subject: [PATCH 14/20] fix(engine): drain CallerEpilogue post-effects after Aura attach PersistChosenAttribute needs attached_to before CopyTargetChoice is answered. Move the honor-WaitingFor drain to after CR 608.3c attach so spell-path Metamorphic matches dig order without burying Tribute AbilityContinuations under SpellResolution. Co-authored-by: Cursor --- crates/engine/src/game/stack.rs | 59 +++++++++++++++------------------ 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index d33ca3bef5..7aa19cd55b 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -1223,38 +1223,9 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { events, ); } - // CR 614.12a: Drain mandatory replacement post-effects (e.g., the - // Siege protector / Tribute opponent-choice prompt, or Metamorphic - // Alteration's ChoosePermanent CopyTargetChoice) that were stashed - // by `apply_single_replacement` while resolving this ZoneChange. - // `CallerEpilogue` skipped the DeliveryTail drain above, so this - // site owns the prompt. - // - // Honor the returned `WaitingFor` — discarding it silently drops - // mid-entry choices (spell-path Metamorphic: Aura attached with - // no copy). Continue the epilogue afterward so CR 608.3c Aura - // attach / cast stamps still run; the answer path then finds the - // host via `attached_to` (PersistChosenAttribute) the same way - // the dig/delivery path does. Do NOT early-return + push - // `PendingSpellResolution` here: that buries a Tribute/Siege - // AbilityContinuation under SpellResolution (`active_*` is - // top-only) and breaks TributeChoice / card-name resume. - if state.has_post_replacement_drain() { - state.clear_post_replacement_source(); - if let Some(wf) = - super::engine_replacement::apply_pending_post_replacement_effect( - state, - Some(entry.id), - None, - Some(crate::types::replacements::ReplacementEvent::Moved), - events, - ) - { - if !matches!(wf, WaitingFor::Priority { .. }) { - state.waiting_for = wf; - } - } - } + // CR 614.12a post-replacement drain runs AFTER CR 608.3c Aura + // attach below — PersistChosenAttribute needs `attached_to` + // before the choice is answered (mirrors dig/CR 303.4f). } super::replacement::ReplacementResult::Prevented => { // CR 608.3e: Permanent spell's ETB was fully prevented — @@ -1538,6 +1509,30 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { } } + // CR 614.12a: Drain mandatory replacement post-effects (Siege / + // Tribute opponent-choice, Metamorphic ChoosePermanent + // CopyTargetChoice, …) stashed while resolving this permanent's + // ZoneChange. `CallerEpilogue` skipped the DeliveryTail drain, so + // this site owns the prompt — AFTER CR 608.3c Aura attach above so + // PersistChosenAttribute can read `attached_to` (dig/CR 303.4f + // order). Honor the returned WaitingFor; do not push + // PendingSpellResolution on top of an AbilityContinuation + // (Tribute/Siege resume is top-only). + if state.has_post_replacement_drain() { + state.clear_post_replacement_source(); + if let Some(wf) = super::engine_replacement::apply_pending_post_replacement_effect( + state, + Some(entry.id), + None, + Some(crate::types::replacements::ReplacementEvent::Moved), + events, + ) { + if !matches!(wf, WaitingFor::Priority { .. }) { + state.waiting_for = wf; + } + } + } + // CR 702.185a: Warp — when a permanent cast via Warp resolves to the battlefield, // create a delayed trigger to exile it at end step with WarpExile permission. // Only triggers on the initial Warp cast (CastingVariant::Warp), NOT on re-casts From 7c9276ed11cceba256280b3977482c4e0df146bf Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 23 Jul 2026 12:44:16 -0500 Subject: [PATCH 15/20] fix(engine): recover Aura host attach for Metamorphic spell path Flatten stack enchant targets, fall back to Enchant-filter attach when cast targets are missing, and pin spell fixtures' Enchant to the host so PersistChosenAttribute always sees attached_to before the copy installs. Co-authored-by: Cursor --- crates/engine/src/game/engine_replacement.rs | 2 ++ crates/engine/src/game/stack.rs | 25 +++++++++++++++++-- .../integration/metamorphic_alteration.rs | 23 ++++++++++++++++- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 8300aab82f..e20c47b9a7 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -1403,6 +1403,8 @@ fn handle_persist_chosen_attribute_choice( // CR 303.4f: Non-spell path may still be unattached if entry skipped the // auto-attach consult (e.g. liminal copy → Aura). Resolve it now. + // CR 608.3c recovery: the same consult covers spell-path Auras whose stack + // ability lost enchant targets before the CallerEpilogue attach site ran. if state .objects .get(&source_id) diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 7aa19cd55b..4c771b46ea 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -440,10 +440,19 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { return; } - // Capture targets for Aura attachment after resolution + // Capture targets for Aura attachment after resolution. Prefer the full + // chain flatten so Enchant targets assigned onto an Aura placeholder are + // not missed when only a nested sink holds them. let spell_targets = ability .as_ref() - .map(|a| a.targets.clone()) + .map(|a| { + let flat = flatten_targets_in_chain(a); + if flat.is_empty() { + a.targets.clone() + } else { + flat + } + }) .unwrap_or_default(); // CR 702.103e: As a bestowed Aura spell begins resolving, if its target is @@ -1473,6 +1482,7 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { .map(|obj| obj.card_types.subtypes.iter().any(|s| s == "Aura")) .unwrap_or(false); if is_aura { + let mut attached = false; match spell_targets.first() { // CR 608.3c + CR 608.2b: Object Aura — verify the target is // still a legal host per the Aura's own zone-scoped enchant @@ -1489,6 +1499,7 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { ) => { effects::attach::attach_to(state, entry.id, *target_id); + attached = true; } Some(crate::types::ability::TargetRef::Object(_)) => { // Target is no longer a legal host — SBA cleanup follows. @@ -1500,6 +1511,7 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { // a Curse whose enchanted player has left the game. Some(crate::types::ability::TargetRef::Player(player_id)) => { effects::attach::attach_to_player(state, entry.id, *player_id); + attached = true; } None => { // CR 303.4g: An Aura entering the battlefield with no @@ -1507,6 +1519,15 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { // path catches this on the next pass. } } + // CR 608.3c recovery: when the stack ability lost its enchant + // targets (placeholder Unimplemented stripped with empty + // targets), fall back to the Enchant-filter attach authority + // used for non-spell Aura entry (CR 303.4f). A unique legal + // host auto-attaches; multiple hosts raise NeedsChoice (rare + // for a correctly targeted Aura spell). + if !attached { + let _ = zone_pipeline::resolve_entering_aura_attachment(state, entry.id); + } } // CR 614.12a: Drain mandatory replacement post-effects (Siege / diff --git a/crates/engine/tests/integration/metamorphic_alteration.rs b/crates/engine/tests/integration/metamorphic_alteration.rs index 560409a0d8..639c4d4583 100644 --- a/crates/engine/tests/integration/metamorphic_alteration.rs +++ b/crates/engine/tests/integration/metamorphic_alteration.rs @@ -14,7 +14,7 @@ //! and ends when the Aura leaves the battlefield (CR 400.7 / CR 611.2a). use engine::game::game_object::{AttachTarget, DisplaySource}; -use engine::game::scenario::{GameScenario, P0, P1}; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; use engine::parser::oracle::parse_oracle_text; use engine::types::ability::{ ChoosePermanentPersist, ChosenAttribute, ContinuousModification, Effect, FilterProp, @@ -50,6 +50,19 @@ fn stage_metamorphic(scenario: &mut GameScenario) -> ObjectId { .id() } +/// CR 608.3c: pin Enchant to a single host so spell-path attach cannot become +/// an ambiguous multi-host consult when stack enchant targets are absent. +/// Opponent donors remain legal *copy* choices (ChoosePermanent pool ≠ Enchant). +fn pin_enchant_host(runner: &mut GameRunner, aura: ObjectId, host: ObjectId) { + let obj = runner.state_mut().objects.get_mut(&aura).unwrap(); + obj.keywords.retain(|k| !matches!(k, Keyword::Enchant(_))); + obj.base_keywords + .retain(|k| !matches!(k, Keyword::Enchant(_))); + let pinned = Keyword::Enchant(TargetFilter::SpecificObject { id: host }); + obj.keywords.push(pinned.clone()); + obj.base_keywords.push(pinned); +} + fn blue_pool(scenario: &mut GameScenario) { scenario.with_mana_pool( P0, @@ -80,6 +93,7 @@ fn enchanted_creature_becomes_copy_of_chosen_and_aura_is_unchanged() { blue_pool(&mut scenario); let mut runner = scenario.build(); + pin_enchant_host(&mut runner, aura, host); runner .cast(aura) @@ -142,6 +156,7 @@ fn copied_values_are_a_frozen_snapshot_of_the_chosen_creature() { blue_pool(&mut scenario); let mut runner = scenario.build(); + pin_enchant_host(&mut runner, aura, host); runner .cast(aura) .target_object(host) @@ -185,6 +200,7 @@ fn host_reverts_when_the_aura_leaves_play() { blue_pool(&mut scenario); let mut runner = scenario.build(); + pin_enchant_host(&mut runner, aura, host); runner .cast(aura) .target_object(host) @@ -235,6 +251,7 @@ fn hexproof_opponent_creature_is_a_legal_copy_donor() { blue_pool(&mut scenario); let mut runner = scenario.build(); + pin_enchant_host(&mut runner, aura, host); runner .cast(aura) .target_object(host) @@ -286,6 +303,7 @@ fn host_is_the_only_creature_and_remains_a_legal_copy_choice() { blue_pool(&mut scenario); let mut runner = scenario.build(); + pin_enchant_host(&mut runner, aura, host); runner .cast(aura) .target_object(host) @@ -375,6 +393,8 @@ fn two_auras_install_independent_copies_on_their_own_hosts() { ); let mut runner = scenario.build(); + pin_enchant_host(&mut runner, aura_a, host_a); + pin_enchant_host(&mut runner, aura_b, host_b); runner .cast(aura_a) .target_object(host_a) @@ -423,6 +443,7 @@ fn host_copying_a_token_donor_routes_token_display() { blue_pool(&mut scenario); let mut runner = scenario.build(); + pin_enchant_host(&mut runner, aura, host); // Make the donor a TRUE token so its display routes to the token art db // (CR 111.1): `is_token` with no `base_printed_ref` derives // `DisplaySource::Token` in the layer engine; its `token_image_ref` is its From 5fb54980c4dbcc871d85886189ea25458f98746c Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 23 Jul 2026 13:10:42 -0500 Subject: [PATCH 16/20] fix(engine): carry Aura spell target through Metamorphic CopyTargetChoice CR 608.3c / CR 303.4a: stash PendingSpellResolution.spell_targets on the CallerEpilogue CopyTargetChoice pause instead of recovering the host via an ambiguous Enchant-filter consult. Fixture installs Enchant via MTGJSON hint; add a multi-host regression without SpecificObject pinning. Co-authored-by: Cursor --- crates/engine/src/game/engine_replacement.rs | 20 ++- crates/engine/src/game/stack.rs | 141 +++++++++++++----- .../integration/metamorphic_alteration.rs | 102 +++++++++---- 3 files changed, 193 insertions(+), 70 deletions(-) diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index e20c47b9a7..051c132bff 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -1377,8 +1377,8 @@ fn handle_persist_chosen_attribute_choice( } // CR 608.3c + CR 614.12a: Mid-entry `CopyTargetChoice` is raised from the - // zone-delivery post-replacement drain, which pauses BEFORE the spell- - // resolution epilogue (Aura attach, cast-link stamps) in `stack.rs`. The + // zone-delivery / CallerEpilogue post-replacement drain, which pauses + // BEFORE the spell-resolution Aura-attach epilogue in `stack.rs`. The // stash pushed there (`PendingSpellResolution`) carries the Aura spell's // enchant target — establish the host from those spell targets AND apply // the attach now so `attached_to` is consistent before the copy installs. @@ -1387,6 +1387,7 @@ fn handle_persist_chosen_attribute_choice( // choice), so `attached_to` is already set and there is no spell-resolution // frame — fall through to the attached host / enter-time attach consult. let mut host_from_spell: Option = None; + let mut took_spell_resolution = false; if state .active_spell_resolution() .is_some_and(|ctx| ctx.object_id == source_id) @@ -1394,6 +1395,7 @@ fn handle_persist_chosen_attribute_choice( let ctx = state .take_active_spell_resolution() .expect("active spell-resolution frame checked above"); + took_spell_resolution = true; host_from_spell = ctx.spell_targets.first().and_then(|t| match t { crate::types::ability::TargetRef::Object(id) => Some(*id), _ => None, @@ -1403,12 +1405,14 @@ fn handle_persist_chosen_attribute_choice( // CR 303.4f: Non-spell path may still be unattached if entry skipped the // auto-attach consult (e.g. liminal copy → Aura). Resolve it now. - // CR 608.3c recovery: the same consult covers spell-path Auras whose stack - // ability lost enchant targets before the CallerEpilogue attach site ran. - if state - .objects - .get(&source_id) - .is_some_and(|aura| aura.attached_to.is_none()) + // Never use this Enchant-filter consult as a CR 608.3c spell-path fallback + // — a resolving Aura spell's host is the target chosen at cast (CR 303.4a), + // carried on `PendingSpellResolution.spell_targets`. + if !took_spell_resolution + && state + .objects + .get(&source_id) + .is_some_and(|aura| aura.attached_to.is_none()) { match crate::game::zone_pipeline::resolve_entering_aura_attachment(state, source_id) { crate::game::zone_pipeline::EnteringAuraAttachment::NotApplicable diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 4c771b46ea..356e101097 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -1476,13 +1476,113 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { // (NOT CR 303.4f, which explicitly governs Auras entering "by any means // other than by resolving as an Aura spell.") if spell_in_zone(state, entry.id, Zone::Battlefield) { + // CR 614.12a: Drain mandatory replacement post-effects (Siege / + // Tribute opponent-choice, Metamorphic ChoosePermanent + // CopyTargetChoice, …) stashed while resolving this permanent's + // ZoneChange. `CallerEpilogue` skipped the DeliveryTail drain, so + // this site owns the prompt — BEFORE CR 608.3c Aura attach so a + // mid-entry `CopyTargetChoice` can pause with the cast target + // carried on `PendingSpellResolution` (CR 608.3c / CR 303.4a), + // matching the DeliveryTail `NeedsChoice` arm above. Do not push + // `PendingSpellResolution` on top of an AbilityContinuation + // (Tribute/Siege resume is top-only). + if state.has_post_replacement_drain() { + state.clear_post_replacement_source(); + if let Some(wf) = super::engine_replacement::apply_pending_post_replacement_effect( + state, + Some(entry.id), + None, + Some(crate::types::replacements::ReplacementEvent::Moved), + events, + ) { + match wf { + // CR 608.3c + CR 614.12a: Metamorphic-class mid-entry + // copy choice — stash the Aura spell's chosen host and + // pause before attach / cast-link stamps. The answer + // path (`handle_persist_chosen_attribute_choice`) + // applies `PendingSpellResolution` then installs the + // host copy. + WaitingFor::CopyTargetChoice { .. } => { + let cast_from_zone = ability + .as_ref() + .and_then(|a| a.context.cast_from_zone) + .or_else(|| { + state.objects.get(&entry.id).and_then(|o| o.cast_from_zone) + }); + let kickers_paid = ability + .as_ref() + .map(|a| a.context.kickers_paid.clone()) + .unwrap_or_else(|| { + state + .objects + .get(&entry.id) + .map(|o| o.kickers_paid.clone()) + .unwrap_or_default() + }); + let additional_cost_payment_count = ability + .as_ref() + .map(|a| a.context.additional_cost_payment_count) + .unwrap_or_else(|| { + state + .objects + .get(&entry.id) + .map(|o| o.additional_cost_payment_count) + .unwrap_or_default() + }); + let additional_cost_payments = ability + .as_ref() + .map(|a| a.context.additional_cost_payments.clone()) + .unwrap_or_else(|| { + state + .objects + .get(&entry.id) + .map(|o| o.additional_cost_payments.clone()) + .unwrap_or_default() + }); + state.push_spell_resolution( + crate::types::game_state::PendingSpellResolution { + object_id: entry.id, + controller: entry.controller, + casting_variant, + cast_from_zone, + cast_controller: Some(entry.controller), + cast_timing_permission, + spell_targets: spell_targets.clone(), + actual_mana_spent, + kickers_paid, + additional_cost_payment_count, + additional_cost_payments, + convoked_creatures: convoked_creatures.clone(), + }, + ); + state.waiting_for = wf; + events.push(GameEvent::StackResolved { + object_id: entry.id, + }); + state.current_trigger_event = None; + state.current_trigger_events.clear(); + state.current_trigger_match_count = None; + state.die_result_this_resolution = None; + return; + } + WaitingFor::Priority { .. } => {} + other => { + // Tribute / Siege NamedChoice — surface the prompt + // and continue the caller epilogue (Aura attach + + // cast-variant stamps). Do not push SpellResolution + // on top of an AbilityContinuation. + state.waiting_for = other; + } + } + } + } + let is_aura = state .objects .get(&entry.id) .map(|obj| obj.card_types.subtypes.iter().any(|s| s == "Aura")) .unwrap_or(false); if is_aura { - let mut attached = false; match spell_targets.first() { // CR 608.3c + CR 608.2b: Object Aura — verify the target is // still a legal host per the Aura's own zone-scoped enchant @@ -1493,13 +1593,16 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { // Dead) legally accepts a graveyard host. A now-illegal target // leaves the Aura unattached and SBA (CR 704.5m) cleans it up // at the next checkpoint. + // + // CR 608.3c / CR 303.4a: the host is the spell's chosen + // target — never re-consult the Enchant filter (CR 303.4f + // non-spell entry) when that target is missing. Some(crate::types::ability::TargetRef::Object(target_id)) if crate::game::sba::is_valid_attachment_target( state, entry.id, *target_id, ) => { effects::attach::attach_to(state, entry.id, *target_id); - attached = true; } Some(crate::types::ability::TargetRef::Object(_)) => { // Target is no longer a legal host — SBA cleanup follows. @@ -1511,7 +1614,6 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { // a Curse whose enchanted player has left the game. Some(crate::types::ability::TargetRef::Player(player_id)) => { effects::attach::attach_to_player(state, entry.id, *player_id); - attached = true; } None => { // CR 303.4g: An Aura entering the battlefield with no @@ -1519,39 +1621,6 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { // path catches this on the next pass. } } - // CR 608.3c recovery: when the stack ability lost its enchant - // targets (placeholder Unimplemented stripped with empty - // targets), fall back to the Enchant-filter attach authority - // used for non-spell Aura entry (CR 303.4f). A unique legal - // host auto-attaches; multiple hosts raise NeedsChoice (rare - // for a correctly targeted Aura spell). - if !attached { - let _ = zone_pipeline::resolve_entering_aura_attachment(state, entry.id); - } - } - - // CR 614.12a: Drain mandatory replacement post-effects (Siege / - // Tribute opponent-choice, Metamorphic ChoosePermanent - // CopyTargetChoice, …) stashed while resolving this permanent's - // ZoneChange. `CallerEpilogue` skipped the DeliveryTail drain, so - // this site owns the prompt — AFTER CR 608.3c Aura attach above so - // PersistChosenAttribute can read `attached_to` (dig/CR 303.4f - // order). Honor the returned WaitingFor; do not push - // PendingSpellResolution on top of an AbilityContinuation - // (Tribute/Siege resume is top-only). - if state.has_post_replacement_drain() { - state.clear_post_replacement_source(); - if let Some(wf) = super::engine_replacement::apply_pending_post_replacement_effect( - state, - Some(entry.id), - None, - Some(crate::types::replacements::ReplacementEvent::Moved), - events, - ) { - if !matches!(wf, WaitingFor::Priority { .. }) { - state.waiting_for = wf; - } - } } // CR 702.185a: Warp — when a permanent cast via Warp resolves to the battlefield, diff --git a/crates/engine/tests/integration/metamorphic_alteration.rs b/crates/engine/tests/integration/metamorphic_alteration.rs index 639c4d4583..3801abae5c 100644 --- a/crates/engine/tests/integration/metamorphic_alteration.rs +++ b/crates/engine/tests/integration/metamorphic_alteration.rs @@ -14,7 +14,7 @@ //! and ends when the Aura leaves the battlefield (CR 400.7 / CR 611.2a). use engine::game::game_object::{AttachTarget, DisplaySource}; -use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::game::scenario::{GameScenario, P0, P1}; use engine::parser::oracle::parse_oracle_text; use engine::types::ability::{ ChoosePermanentPersist, ChosenAttribute, ContinuousModification, Effect, FilterProp, @@ -34,6 +34,10 @@ const METAMORPHIC_ALTERATION: &str = "Enchant creature\nAs this Aura enters, cho /// parsed as-enters choice + copy static. Identity (Enchantment / Aura / /// mana cost) is set BEFORE `from_oracle_text`, which preserves identity fields /// while installing the parsed abilities/keywords/replacements/statics. +/// +/// Pass the MTGJSON-style `"enchant"` keyword hint so `"Enchant creature"` is +/// extracted as `Keyword::Enchant` (scenario bare-FromStr inference cannot +/// parse the space-form line into `Enchant:creature`). fn stage_metamorphic(scenario: &mut GameScenario) -> ObjectId { // CR 205.3h: Aura is an enchantment subtype. `as_enchantment` strips the // Instant/Sorcery seed from `add_spell_to_hand` so this is a clean @@ -46,23 +50,10 @@ fn stage_metamorphic(scenario: &mut GameScenario) -> ObjectId { generic: 2, shards: vec![ManaCostShard::Blue], }) - .from_oracle_text(METAMORPHIC_ALTERATION) + .from_oracle_text_with_keywords(&["enchant"], METAMORPHIC_ALTERATION) .id() } -/// CR 608.3c: pin Enchant to a single host so spell-path attach cannot become -/// an ambiguous multi-host consult when stack enchant targets are absent. -/// Opponent donors remain legal *copy* choices (ChoosePermanent pool ≠ Enchant). -fn pin_enchant_host(runner: &mut GameRunner, aura: ObjectId, host: ObjectId) { - let obj = runner.state_mut().objects.get_mut(&aura).unwrap(); - obj.keywords.retain(|k| !matches!(k, Keyword::Enchant(_))); - obj.base_keywords - .retain(|k| !matches!(k, Keyword::Enchant(_))); - let pinned = Keyword::Enchant(TargetFilter::SpecificObject { id: host }); - obj.keywords.push(pinned.clone()); - obj.base_keywords.push(pinned); -} - fn blue_pool(scenario: &mut GameScenario) { scenario.with_mana_pool( P0, @@ -93,8 +84,6 @@ fn enchanted_creature_becomes_copy_of_chosen_and_aura_is_unchanged() { blue_pool(&mut scenario); let mut runner = scenario.build(); - pin_enchant_host(&mut runner, aura, host); - runner .cast(aura) .target_object(host) @@ -138,6 +127,74 @@ fn enchanted_creature_becomes_copy_of_chosen_and_aura_is_unchanged() { ); } +/// CR 608.3c / CR 303.4a: with the normal `"Enchant creature"` filter and at +/// least two legal hosts, a resolving Aura spell must attach to the creature +/// it targeted at cast — never re-consult the Enchant filter (CR 303.4f) for an +/// ambiguous host. The copy donor is a third creature so attach-host ≠ +/// copy-source cannot be confused. +#[test] +fn spell_path_retains_chosen_enchant_target_among_multiple_legal_hosts() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let donor = scenario + .add_creature_from_oracle(P0, "Serra Angel", 4, 4, "Flying") + .id(); + let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); + let other_legal = scenario.add_creature(P0, "Runeclaw Bear", 2, 2).id(); + let aura = stage_metamorphic(&mut scenario); + blue_pool(&mut scenario); + + let mut runner = scenario.build(); + // Sanity: fixture must carry the real Enchant creature filter (not a + // SpecificObject pin that would collapse the multi-host consult). + assert!( + runner.state().objects[&aura].keywords.iter().any(|k| { + matches!( + k, + Keyword::Enchant(TargetFilter::Typed(tf)) + if tf.type_filters.contains(&engine::types::ability::TypeFilter::Creature) + ) + }), + "fixture must use normal Enchant creature, not a pinned SpecificObject host" + ); + + runner + .cast(aura) + .target_object(host) + .copy_target(donor) + .resolve(); + + let aura_obj = &runner.state().objects[&aura]; + assert_eq!( + aura_obj.attached_to, + Some(AttachTarget::Object(host)), + "CR 608.3c: Aura must remain attached to the cast target, not the other \ + legal Enchant creature ({other_legal:?})" + ); + assert_ne!( + aura_obj.attached_to, + Some(AttachTarget::Object(other_legal)), + "must not attach to the non-targeted legal creature via Enchant-filter consult" + ); + assert_ne!( + aura_obj.attached_to, + Some(AttachTarget::Object(donor)), + "must not attach to the copy donor" + ); + + let host_obj = &runner.state().objects[&host]; + assert_eq!( + host_obj.name, "Serra Angel", + "targeted host still receives the chosen creature's copy" + ); + assert_eq!( + runner.state().objects[&other_legal].name, + "Runeclaw Bear", + "non-targeted creature must not receive the host copy" + ); +} + /// CR 707.2b: the copy is a FROZEN snapshot taken when the effect first started /// to apply — later changes to the chosen creature never propagate to the host. /// Mutating the donor's base P/T and re-running the layer engine must leave the @@ -156,7 +213,6 @@ fn copied_values_are_a_frozen_snapshot_of_the_chosen_creature() { blue_pool(&mut scenario); let mut runner = scenario.build(); - pin_enchant_host(&mut runner, aura, host); runner .cast(aura) .target_object(host) @@ -200,7 +256,6 @@ fn host_reverts_when_the_aura_leaves_play() { blue_pool(&mut scenario); let mut runner = scenario.build(); - pin_enchant_host(&mut runner, aura, host); runner .cast(aura) .target_object(host) @@ -251,7 +306,6 @@ fn hexproof_opponent_creature_is_a_legal_copy_donor() { blue_pool(&mut scenario); let mut runner = scenario.build(); - pin_enchant_host(&mut runner, aura, host); runner .cast(aura) .target_object(host) @@ -303,7 +357,6 @@ fn host_is_the_only_creature_and_remains_a_legal_copy_choice() { blue_pool(&mut scenario); let mut runner = scenario.build(); - pin_enchant_host(&mut runner, aura, host); runner .cast(aura) .target_object(host) @@ -393,8 +446,6 @@ fn two_auras_install_independent_copies_on_their_own_hosts() { ); let mut runner = scenario.build(); - pin_enchant_host(&mut runner, aura_a, host_a); - pin_enchant_host(&mut runner, aura_b, host_b); runner .cast(aura_a) .target_object(host_a) @@ -443,7 +494,6 @@ fn host_copying_a_token_donor_routes_token_display() { blue_pool(&mut scenario); let mut runner = scenario.build(); - pin_enchant_host(&mut runner, aura, host); // Make the donor a TRUE token so its display routes to the token art db // (CR 111.1): `is_token` with no `base_printed_ref` derives // `DisplaySource::Token` in the layer engine; its `token_image_ref` is its @@ -512,7 +562,7 @@ fn non_spell_aura_entry_copies_chosen_creature() { .id(); let host = scenario.add_creature(P0, "Grizzly Bears", 2, 2).id(); // Library dig → battlefield is a non-spell Aura entry (CR 303.4f). Stage - // through the same `from_oracle_text` path as the spell fixture so live + + // through the same oracle+enchant-hint path as the spell fixture so live + // base replacement/static definitions are both populated. let aura = scenario .add_spell_to_library_top(P0, "Metamorphic Alteration", false) @@ -522,7 +572,7 @@ fn non_spell_aura_entry_copies_chosen_creature() { generic: 2, shards: vec![ManaCostShard::Blue], }) - .from_oracle_text(METAMORPHIC_ALTERATION) + .from_oracle_text_with_keywords(&["enchant"], METAMORPHIC_ALTERATION) .id(); let mut runner = scenario.build(); From 4d35c00da3c120be7a2646be8bd23aa898e9ee09 Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 23 Jul 2026 13:22:02 -0500 Subject: [PATCH 17/20] fix(engine): resolve cast-link locals in CopyTargetChoice stash CallerEpilogue CopyTargetChoice sits outside the permanent replace_event scope, so re-read cast_timing_permission and convoked_creatures from the battlefield object when building PendingSpellResolution. Co-authored-by: Cursor --- crates/engine/src/game/stack.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 356e101097..ada49e1453 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -1509,6 +1509,10 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { .or_else(|| { state.objects.get(&entry.id).and_then(|o| o.cast_from_zone) }); + let cast_timing_permission = state + .objects + .get(&entry.id) + .and_then(|o| o.cast_timing_permission.map(|(p, _)| p)); let kickers_paid = ability .as_ref() .map(|a| a.context.kickers_paid.clone()) @@ -1539,6 +1543,11 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { .map(|o| o.additional_cost_payments.clone()) .unwrap_or_default() }); + let convoked_creatures = state + .objects + .get(&entry.id) + .map(|o| o.convoked_creatures.clone()) + .unwrap_or_default(); state.push_spell_resolution( crate::types::game_state::PendingSpellResolution { object_id: entry.id, @@ -1552,7 +1561,7 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { kickers_paid, additional_cost_payment_count, additional_cost_payments, - convoked_creatures: convoked_creatures.clone(), + convoked_creatures, }, ); state.waiting_for = wf; From 46f4cd5398194a17096b7419bd6e0dfcd7eec192 Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 23 Jul 2026 14:55:00 -0500 Subject: [PATCH 18/20] fix(engine): clear Metamorphic CopyTargetChoice before next cast Attach the Aura before the CallerEpilogue ChoosePermanent drain, keep PendingSpellResolution for spell_targets without early-returning, and retire the paused drain before deferred-entry replay so a second cast is not blocked on a leftover CopyTargetChoice. Co-authored-by: Cursor --- crates/engine/src/game/engine_replacement.rs | 29 ++-- crates/engine/src/game/scenario.rs | 14 +- crates/engine/src/game/stack.rs | 128 ++++++++---------- .../integration/metamorphic_alteration.rs | 8 ++ 4 files changed, 97 insertions(+), 82 deletions(-) diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 051c132bff..e585015055 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -1376,12 +1376,13 @@ fn handle_persist_chosen_attribute_choice( )); } - // CR 608.3c + CR 614.12a: Mid-entry `CopyTargetChoice` is raised from the - // zone-delivery / CallerEpilogue post-replacement drain, which pauses - // BEFORE the spell-resolution Aura-attach epilogue in `stack.rs`. The - // stash pushed there (`PendingSpellResolution`) carries the Aura spell's - // enchant target — establish the host from those spell targets AND apply - // the attach now so `attached_to` is consistent before the copy installs. + // CR 608.3c + CR 614.12a: Spell-path mid-entry `CopyTargetChoice` is raised + // from the CallerEpilogue post-replacement drain AFTER CR 608.3c Aura + // attach — `attached_to` is already the cast host. The stash pushed there + // (`PendingSpellResolution`) still carries `spell_targets` so this answer + // path can apply cast-link stamps and prefer the CR 303.4a spell target as + // the copy recipient (re-attach via `apply_pending_spell_resolution` is + // idempotent). // // CR 303.4f: Non-spell Aura entry attaches during delivery (before this // choice), so `attached_to` is already set and there is no spell-resolution @@ -1475,14 +1476,22 @@ fn handle_persist_chosen_attribute_choice( "the Aura must remain attached after installing the host copy" ); + // CR 615.5 + CR 614.12a: PersistChosenAttribute's ChoosePermanent work is + // complete once the host copy is installed. Retire the paused post- + // replacement drain BEFORE replaying deferred entry events — otherwise a + // nested OrderTriggers / trigger-target pause can return while the same + // ChoosePermanent drain is still resident, and a later post-action pass + // re-drains it into a second `CopyTargetChoice` that the cast driver + // never clears (breaks sequential Metamorphic casts). + state.finish_active_paused_post_replacement_dispatch(); + // CR 614.12a + CR 603.2: replay the Aura's deferred battlefield-entry event - // now that the host copy is realized, so ETB observers see the final state, - // then retire this paused post-replacement dispatch (mirrors the BecomeCopy - // completion tail). + // now that the host copy is realized, so ETB observers see the final state + // (mirrors the BecomeCopy completion tail, but with the drain already + // retired — see above). if let Some(waiting_for) = replay_deferred_entry_events(state, source_id, events)? { return Ok(waiting_for); } - state.finish_active_paused_post_replacement_dispatch(); if !matches!(state.waiting_for, WaitingFor::Priority { .. }) { return Ok(state.waiting_for.clone()); } diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index 712d1ddab5..ad568a0160 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -3298,9 +3298,17 @@ fn drive_resolution( )?; } WaitingFor::CopyTargetChoice { valid_targets, .. } => { - let Some(target) = policy.copy_target else { - break; - }; + // Fail loud: a silent `break` left Metamorphic-class casts paused + // on `CopyTargetChoice`, so the next `CastSpell` panicked with a + // misleading "ActionNotAllowed while waiting" instead of pointing + // at the missing `.copy_target(...)` declaration. + let target = policy.copy_target.unwrap_or_else(|| { + panic!( + "CopyTargetChoice requires ResolutionPolicy.copy_target \ + (call .copy_target(id) on SpellCast / AbilityActivation). \ + legal targets: {valid_targets:?}" + ) + }); assert!( valid_targets.contains(&target), "CopyTargetChoice target {target:?} is not in legal set {valid_targets:?}" diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index ada49e1453..640fb61c9e 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -1476,16 +1476,62 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { // (NOT CR 303.4f, which explicitly governs Auras entering "by any means // other than by resolving as an Aura spell.") if spell_in_zone(state, entry.id, Zone::Battlefield) { + let is_aura = state + .objects + .get(&entry.id) + .map(|obj| obj.card_types.subtypes.iter().any(|s| s == "Aura")) + .unwrap_or(false); + if is_aura { + match spell_targets.first() { + // CR 608.3c + CR 608.2b: Object Aura — verify the target is + // still a legal host per the Aura's own zone-scoped enchant + // ability (`is_valid_attachment_target`, the single legality + // authority shared with `attach::resolve` and the SBA + // re-check). A battlefield-only Enchant filter still requires + // battlefield presence; a graveyard-scoped filter (Animate + // Dead) legally accepts a graveyard host. A now-illegal target + // leaves the Aura unattached and SBA (CR 704.5m) cleans it up + // at the next checkpoint. + // + // CR 608.3c / CR 303.4a: the host is the spell's chosen + // target — never re-consult the Enchant filter (CR 303.4f + // non-spell entry) when that target is missing. + Some(crate::types::ability::TargetRef::Object(target_id)) + if crate::game::sba::is_valid_attachment_target( + state, entry.id, *target_id, + ) => + { + effects::attach::attach_to(state, entry.id, *target_id); + } + Some(crate::types::ability::TargetRef::Object(_)) => { + // Target is no longer a legal host — SBA cleanup follows. + } + // CR 608.3c + CR 702.5d: Player Aura (Curse cycle, Faith's + // Fetters-class). Validity check is "player still in game" + // — `attach_to_player` makes no liveness check itself, but + // `check_unattached_auras` (CR 303.4c) will detach + grave + // a Curse whose enchanted player has left the game. + Some(crate::types::ability::TargetRef::Player(player_id)) => { + effects::attach::attach_to_player(state, entry.id, *player_id); + } + None => { + // CR 303.4g: An Aura entering the battlefield with no + // legal target goes to its owner's graveyard. The SBA + // path catches this on the next pass. + } + } + } + // CR 614.12a: Drain mandatory replacement post-effects (Siege / // Tribute opponent-choice, Metamorphic ChoosePermanent // CopyTargetChoice, …) stashed while resolving this permanent's // ZoneChange. `CallerEpilogue` skipped the DeliveryTail drain, so - // this site owns the prompt — BEFORE CR 608.3c Aura attach so a - // mid-entry `CopyTargetChoice` can pause with the cast target - // carried on `PendingSpellResolution` (CR 608.3c / CR 303.4a), - // matching the DeliveryTail `NeedsChoice` arm above. Do not push - // `PendingSpellResolution` on top of an AbilityContinuation - // (Tribute/Siege resume is top-only). + // this site owns the prompt — AFTER CR 608.3c Aura attach above so + // the Aura is hosted before SBAs / the copy-choice answer, while + // `PendingSpellResolution.spell_targets` still carries the cast + // target for the PersistChosenAttribute resume (CR 608.3c / + // CR 303.4a). Do not push SpellResolution on top of an + // AbilityContinuation (Tribute/Siege resume is top-only). if state.has_post_replacement_drain() { state.clear_post_replacement_source(); if let Some(wf) = super::engine_replacement::apply_pending_post_replacement_effect( @@ -1496,12 +1542,11 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { events, ) { match wf { - // CR 608.3c + CR 614.12a: Metamorphic-class mid-entry - // copy choice — stash the Aura spell's chosen host and - // pause before attach / cast-link stamps. The answer - // path (`handle_persist_chosen_attribute_choice`) - // applies `PendingSpellResolution` then installs the - // host copy. + // CR 608.3c + CR 614.12a: stash the Aura spell's chosen + // host for the copy-choice answer path, then surface the + // prompt. Continue the cast-variant epilogue (same as + // Tribute NamedChoice) so resolve_top settles normally; + // the answer path still prefers spell_targets. WaitingFor::CopyTargetChoice { .. } => { let cast_from_zone = ability .as_ref() @@ -1565,73 +1610,18 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { }, ); state.waiting_for = wf; - events.push(GameEvent::StackResolved { - object_id: entry.id, - }); - state.current_trigger_event = None; - state.current_trigger_events.clear(); - state.current_trigger_match_count = None; - state.die_result_this_resolution = None; - return; } WaitingFor::Priority { .. } => {} other => { // Tribute / Siege NamedChoice — surface the prompt - // and continue the caller epilogue (Aura attach + - // cast-variant stamps). Do not push SpellResolution - // on top of an AbilityContinuation. + // and continue the caller epilogue. Do not push + // SpellResolution on top of an AbilityContinuation. state.waiting_for = other; } } } } - let is_aura = state - .objects - .get(&entry.id) - .map(|obj| obj.card_types.subtypes.iter().any(|s| s == "Aura")) - .unwrap_or(false); - if is_aura { - match spell_targets.first() { - // CR 608.3c + CR 608.2b: Object Aura — verify the target is - // still a legal host per the Aura's own zone-scoped enchant - // ability (`is_valid_attachment_target`, the single legality - // authority shared with `attach::resolve` and the SBA - // re-check). A battlefield-only Enchant filter still requires - // battlefield presence; a graveyard-scoped filter (Animate - // Dead) legally accepts a graveyard host. A now-illegal target - // leaves the Aura unattached and SBA (CR 704.5m) cleans it up - // at the next checkpoint. - // - // CR 608.3c / CR 303.4a: the host is the spell's chosen - // target — never re-consult the Enchant filter (CR 303.4f - // non-spell entry) when that target is missing. - Some(crate::types::ability::TargetRef::Object(target_id)) - if crate::game::sba::is_valid_attachment_target( - state, entry.id, *target_id, - ) => - { - effects::attach::attach_to(state, entry.id, *target_id); - } - Some(crate::types::ability::TargetRef::Object(_)) => { - // Target is no longer a legal host — SBA cleanup follows. - } - // CR 608.3c + CR 702.5d: Player Aura (Curse cycle, Faith's - // Fetters-class). Validity check is "player still in game" - // — `attach_to_player` makes no liveness check itself, but - // `check_unattached_auras` (CR 303.4c) will detach + grave - // a Curse whose enchanted player has left the game. - Some(crate::types::ability::TargetRef::Player(player_id)) => { - effects::attach::attach_to_player(state, entry.id, *player_id); - } - None => { - // CR 303.4g: An Aura entering the battlefield with no - // legal target goes to its owner's graveyard. The SBA - // path catches this on the next pass. - } - } - } - // CR 702.185a: Warp — when a permanent cast via Warp resolves to the battlefield, // create a delayed trigger to exile it at end step with WarpExile permission. // Only triggers on the initial Warp cast (CastingVariant::Warp), NOT on re-casts diff --git a/crates/engine/tests/integration/metamorphic_alteration.rs b/crates/engine/tests/integration/metamorphic_alteration.rs index 3801abae5c..8c74e21bba 100644 --- a/crates/engine/tests/integration/metamorphic_alteration.rs +++ b/crates/engine/tests/integration/metamorphic_alteration.rs @@ -451,6 +451,14 @@ fn two_auras_install_independent_copies_on_their_own_hosts() { .target_object(host_a) .copy_target(donor_a) .resolve(); + assert!( + matches!( + runner.state().waiting_for, + engine::types::game_state::WaitingFor::Priority { .. } + ), + "first Metamorphic must fully clear CopyTargetChoice before the second cast, got {:?}", + runner.state().waiting_for + ); runner .cast(aura_b) .target_object(host_b) From b71d2c706dc5b3267f8a6dc65c2c933d3fa6e251 Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 23 Jul 2026 15:15:33 -0500 Subject: [PATCH 19/20] fix(engine): stop echoing answered Metamorphic CopyTargetChoice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PersistChosenAttribute left state.waiting_for as the inbound CopyTargetChoice, so the completion tail re-returned it and the cast driver re-answered until its loop cap — sequential casts stayed blocked while single-cast tests still passed. Co-authored-by: Cursor --- crates/engine/src/game/engine_replacement.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index e585015055..6f48beb0e2 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -1481,8 +1481,17 @@ fn handle_persist_chosen_attribute_choice( // replacement drain BEFORE replaying deferred entry events — otherwise a // nested OrderTriggers / trigger-target pause can return while the same // ChoosePermanent drain is still resident, and a later post-action pass - // re-drains it into a second `CopyTargetChoice` that the cast driver - // never clears (breaks sequential Metamorphic casts). + // re-drains it into a second `CopyTargetChoice`. + // + // Clear the prompt we just answered first: `state.waiting_for` is still the + // inbound `CopyTargetChoice`, and the completion tail below used to echo it + // via `if !Priority { return waiting_for }` — the cast driver then re-answered + // the same prompt until its 64-iteration cap, leaving sequential Metamorphic + // casts blocked (two_auras). Nested prompts raised during copy install / + // entry replay still overwrite `waiting_for` and propagate below. + state.waiting_for = WaitingFor::Priority { + player: state.active_player, + }; state.finish_active_paused_post_replacement_dispatch(); // CR 614.12a + CR 603.2: replay the Aura's deferred battlefield-entry event From a08ffb0ad7095ddb589f17a0b8b126d3e24e0d15 Mon Sep 17 00:00:00 2001 From: Clayton Date: Thu, 23 Jul 2026 15:43:12 -0500 Subject: [PATCH 20/20] =?UTF-8?q?refactor(engine):=20Metamorphic=20review?= =?UTF-8?q?=20fixups=20=E2=80=94=20drop=20persist,=20share=20spell=20stash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the unread ChoosePermanentPersist discriminator (CopyTargetPurpose + CopyChosenHost already gate behavior), extract pending_spell_resolution_snapshot for the three stack stash sites, document why the PersistChosenAttribute completion tail omits BecomeCopy batch-capture steps, and restore CopyTargetChoice harness break uniformity. Co-authored-by: Cursor --- crates/engine/src/game/ability_rw.rs | 2 +- crates/engine/src/game/ability_scan.rs | 5 +- crates/engine/src/game/coverage.rs | 2 +- crates/engine/src/game/engine_replacement.rs | 15 +- crates/engine/src/game/scenario.rs | 17 +- crates/engine/src/game/stack.rs | 250 ++++++------------ crates/engine/src/parser/oracle.rs | 25 +- .../engine/src/parser/oracle_ir/relation.rs | 9 +- .../engine/src/parser/oracle_replacement.rs | 2 +- crates/engine/src/types/ability.rs | 44 +-- .../integration/metamorphic_alteration.rs | 11 +- 11 files changed, 131 insertions(+), 251 deletions(-) diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index 971494a908..765463b55a 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -3164,7 +3164,7 @@ fn legacy_effect(x: &Effect) -> bool { } => legacy_target_filter(target) || odur(duration), // CR 707.2c (Metamorphic Alteration): the copy-source choice pool is a // target filter; walk it for legacy event-refs like every other filter. - Effect::ChoosePermanent { filter, .. } => legacy_target_filter(filter), + Effect::ChoosePermanent { filter } => legacy_target_filter(filter), Effect::GenericEffect { duration, target, diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 63adf70c1f..10e44a712c 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -893,9 +893,8 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes { // CR 707.2c: the chosen creature's copiable values are latched onto the // Aura's host at the answer — a copy-family continuous effect, same // conservative classification as `BecomeCopy`. `filter` scans no - // per-source projected resource (it just bounds the choice pool); - // `persist` is a compile-time selector. - Effect::ChoosePermanent { filter, persist: _ } => { + // per-source projected resource (it just bounds the choice pool). + Effect::ChoosePermanent { filter } => { scan_target_filter(filter, target_ctx, mode).or(Axes::CONSERVATIVE) } Effect::GainActivatedAbilitiesOfTarget { diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 09d7dcdc7f..ff7139a21d 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -2334,7 +2334,7 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> { d.push(("target".into(), fmt_target(target))); } // CR 707.2c (Metamorphic Alteration): report the copy-source choice pool. - Effect::ChoosePermanent { filter, .. } => { + Effect::ChoosePermanent { filter } => { d.push(("choose".into(), fmt_target(filter))); } Effect::Destroy { target, .. } diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 6f48beb0e2..534752c98f 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -1489,6 +1489,19 @@ fn handle_persist_chosen_attribute_choice( // the same prompt until its 64-iteration cap, leaving sequential Metamorphic // casts blocked (two_auras). Nested prompts raised during copy install / // entry replay still overwrite `waiting_for` and propagate below. + // + // Divergence from the BecomeCopy completion tail (`:1878+`): that sibling + // retires the drain *after* replay and brackets replay with + // `capture_paused_zone_change_delivery_for_member` + + // `drain_pending_batch_deliveries`. Those steps are for liminal / multi- + // member batch deliveries (meld, ninjutsu copy tokens) that can park further + // co-arrivers behind the mid-entry choice. An Aura *spell* entry is a + // single-object `CallerEpilogue` delivery with no active batch frame — there + // is nothing for delivery-capture or batch-drain to preserve — so this path + // intentionally omits them. Retiring before replay is required here because + // `Effect::ChoosePermanent` (unlike `BecomeCopy`) is itself the post- + // replacement continuation being answered; leaving it paused through replay + // lets a nested prompt return and then re-surface the same ChoosePermanent. state.waiting_for = WaitingFor::Priority { player: state.active_player, }; @@ -2144,7 +2157,7 @@ pub(super) fn apply_post_replacement_effect( // choice, not targeting — hexproof/shroud don't apply, CR 115.10a) but is // discriminated by `purpose`. Empty legal-choice set → no prompt (CR 609.3: // an effect does only as much as possible). - if let Effect::ChoosePermanent { ref filter, .. } = *real_work.effect { + if let Effect::ChoosePermanent { ref filter } = *real_work.effect { let valid_targets = find_copy_targets(state, filter, source_id, controller, None); if valid_targets.is_empty() { return None; diff --git a/crates/engine/src/game/scenario.rs b/crates/engine/src/game/scenario.rs index ad568a0160..0723e7d521 100644 --- a/crates/engine/src/game/scenario.rs +++ b/crates/engine/src/game/scenario.rs @@ -3298,17 +3298,12 @@ fn drive_resolution( )?; } WaitingFor::CopyTargetChoice { valid_targets, .. } => { - // Fail loud: a silent `break` left Metamorphic-class casts paused - // on `CopyTargetChoice`, so the next `CastSpell` panicked with a - // misleading "ActionNotAllowed while waiting" instead of pointing - // at the missing `.copy_target(...)` declaration. - let target = policy.copy_target.unwrap_or_else(|| { - panic!( - "CopyTargetChoice requires ResolutionPolicy.copy_target \ - (call .copy_target(id) on SpellCast / AbilityActivation). \ - legal targets: {valid_targets:?}" - ) - }); + // No pick declared → halt so the caller can assert the offered + // options and the prompt boundary via `final_waiting_for()` + // (mirrors SpellbookDraft / NamedChoice / ReplacementChoice). + let Some(target) = policy.copy_target else { + break; + }; assert!( valid_targets.contains(&target), "CopyTargetChoice target {target:?} is not in legal set {valid_targets:?}" diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 640fb61c9e..791718071d 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -9,8 +9,8 @@ use crate::types::counter::CounterType; use crate::types::events::GameEvent; use crate::types::game_state::{ AutoMayChoice, CastOfferKind, CastingVariant, ExileLink, ExileLinkKind, GameState, - MayTriggerAutoChoiceKey, MayTriggerOrigin, PendingCounterPostAction, StackEntry, - StackEntryKind, StackPaidSnapshot, WaitingFor, + MayTriggerAutoChoiceKey, MayTriggerOrigin, PendingCounterPostAction, PendingSpellResolution, + StackEntry, StackEntryKind, StackPaidSnapshot, WaitingFor, }; use crate::types::identifiers::ObjectId; use crate::types::player::PlayerId; @@ -239,6 +239,58 @@ fn move_prevented_permanent_spell_to_graveyard_if_still_on_stack( } } +/// CR 608.3 + CR 400.7d: Snapshot cast-link / target facts for a permanent spell +/// paused mid-resolution (delivery-tail `NeedsChoice`, replacement-choice +/// `NeedsChoice`, or CallerEpilogue `CopyTargetChoice`). Single authority so a +/// new cast-metadata field cannot be threaded into only two of three stash sites. +fn pending_spell_resolution_snapshot( + state: &GameState, + entry: &StackEntry, + ability: Option<&ResolvedAbility>, + casting_variant: CastingVariant, + actual_mana_spent: u32, + spell_targets: &[TargetRef], +) -> PendingSpellResolution { + let obj = state.objects.get(&entry.id); + let cast_from_zone = ability + .and_then(|a| a.context.cast_from_zone) + .or_else(|| obj.and_then(|o| o.cast_from_zone)); + let cast_timing_permission = + obj.and_then(|o| o.cast_timing_permission.map(|(permission, _)| permission)); + let kickers_paid = ability + .map(|a| a.context.kickers_paid.clone()) + .unwrap_or_else(|| obj.map(|o| o.kickers_paid.clone()).unwrap_or_default()); + let additional_cost_payment_count = ability + .map(|a| a.context.additional_cost_payment_count) + .unwrap_or_else(|| { + obj.map(|o| o.additional_cost_payment_count) + .unwrap_or_default() + }); + let additional_cost_payments = ability + .map(|a| a.context.additional_cost_payments.clone()) + .unwrap_or_else(|| { + obj.map(|o| o.additional_cost_payments.clone()) + .unwrap_or_default() + }); + let convoked_creatures = obj + .map(|o| o.convoked_creatures.clone()) + .unwrap_or_default(); + PendingSpellResolution { + object_id: entry.id, + controller: entry.controller, + casting_variant, + cast_from_zone, + cast_controller: Some(entry.controller), + cast_timing_permission, + spell_targets: spell_targets.to_vec(), + actual_mana_spent, + kickers_paid, + additional_cost_payment_count, + additional_cost_payments, + convoked_creatures, + } +} + /// CR 608.2: Resolve the top object on the stack. pub fn resolve_top(state: &mut GameState, events: &mut Vec) { // CR 603.3c + CR 603.3d: The top of the stack may be a trigger entry that @@ -979,11 +1031,6 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { } } - let convoked_creatures = state - .objects - .get(&entry.id) - .map(|obj| obj.convoked_creatures.clone()) - .unwrap_or_default(); // CR 702.33d + CR 400.7d + CR 603.4: Normalize the authoritative // cast-link provenance onto the stack object BEFORE `replace_event`, // so the pipeline's `CastLinkSnapshot` (captured inside @@ -1014,11 +1061,6 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { ability.context.cast_controller.or(Some(entry.controller)); } } - let cast_timing_permission = state - .objects - .get(&entry.id) - .and_then(|obj| obj.cast_timing_permission.map(|(permission, _)| permission)); - match super::replacement::replace_event(state, proposed, events) { super::replacement::ReplacementResult::Execute(event) => { if let crate::types::proposed_event::ProposedEvent::ZoneChange { @@ -1087,61 +1129,14 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { // attachment / cast-link stamps — mirrors the // ReplacementResult::NeedsChoice arm below. zone_pipeline::ZoneDeliveryResult::NeedsChoice(_) => { - let cast_from_zone = ability - .as_ref() - .and_then(|a| a.context.cast_from_zone) - .or_else(|| { - state - .objects - .get(&entry.id) - .and_then(|o| o.cast_from_zone) - }); - let kickers_paid = ability - .as_ref() - .map(|a| a.context.kickers_paid.clone()) - .unwrap_or_else(|| { - state - .objects - .get(&entry.id) - .map(|o| o.kickers_paid.clone()) - .unwrap_or_default() - }); - let additional_cost_payment_count = ability - .as_ref() - .map(|a| a.context.additional_cost_payment_count) - .unwrap_or_else(|| { - state - .objects - .get(&entry.id) - .map(|o| o.additional_cost_payment_count) - .unwrap_or_default() - }); - let additional_cost_payments = ability - .as_ref() - .map(|a| a.context.additional_cost_payments.clone()) - .unwrap_or_else(|| { - state - .objects - .get(&entry.id) - .map(|o| o.additional_cost_payments.clone()) - .unwrap_or_default() - }); - state.push_spell_resolution( - crate::types::game_state::PendingSpellResolution { - object_id: entry.id, - controller: entry.controller, - casting_variant, - cast_from_zone, - cast_controller: Some(entry.controller), - cast_timing_permission, - spell_targets: spell_targets.clone(), - actual_mana_spent, - kickers_paid, - additional_cost_payment_count, - additional_cost_payments, - convoked_creatures: convoked_creatures.clone(), - }, - ); + state.push_spell_resolution(pending_spell_resolution_snapshot( + state, + &entry, + ability.as_ref(), + casting_variant, + actual_mana_spent, + &spell_targets, + )); events.push(GameEvent::StackResolved { object_id: entry.id, }); @@ -1274,60 +1269,20 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { super::replacement::ReplacementResult::NeedsChoice(player) => { // A replacement needs player choice (e.g., Clone "enter as a copy"). // Store context so handle_replacement_choice can complete post-resolution. - let cast_from_zone = ability - .as_ref() - .and_then(|a| a.context.cast_from_zone) - .or_else(|| state.objects.get(&entry.id).and_then(|o| o.cast_from_zone)); // CR 702.33d + CR 400.7d: Use the authoritative kicker payments // (resolving spell's `SpellContext` when present, else the stack // object's stamped value) so placeholder permanent spells with // `ability == None` are not silently de-kicked when a replacement // needs a player choice. `engine_replacement` restores this onto // the permanent unconditionally after the choice resolves. - let kickers_paid = ability - .as_ref() - .map(|a| a.context.kickers_paid.clone()) - .unwrap_or_else(|| { - state - .objects - .get(&entry.id) - .map(|o| o.kickers_paid.clone()) - .unwrap_or_default() - }); - let additional_cost_payment_count = ability - .as_ref() - .map(|a| a.context.additional_cost_payment_count) - .unwrap_or_else(|| { - state - .objects - .get(&entry.id) - .map(|o| o.additional_cost_payment_count) - .unwrap_or_default() - }); - let additional_cost_payments = ability - .as_ref() - .map(|a| a.context.additional_cost_payments.clone()) - .unwrap_or_else(|| { - state - .objects - .get(&entry.id) - .map(|o| o.additional_cost_payments.clone()) - .unwrap_or_default() - }); - state.push_spell_resolution(crate::types::game_state::PendingSpellResolution { - object_id: entry.id, - controller: entry.controller, + state.push_spell_resolution(pending_spell_resolution_snapshot( + state, + &entry, + ability.as_ref(), casting_variant, - cast_from_zone, - cast_controller: Some(entry.controller), - cast_timing_permission, - spell_targets: spell_targets.clone(), actual_mana_spent, - kickers_paid, - additional_cost_payment_count, - additional_cost_payments, - convoked_creatures, - }); + &spell_targets, + )); state.waiting_for = super::replacement::replacement_choice_waiting_for(player, state); // Emit StackResolved now — the spell has left the stack even though @@ -1548,67 +1503,14 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec) { // Tribute NamedChoice) so resolve_top settles normally; // the answer path still prefers spell_targets. WaitingFor::CopyTargetChoice { .. } => { - let cast_from_zone = ability - .as_ref() - .and_then(|a| a.context.cast_from_zone) - .or_else(|| { - state.objects.get(&entry.id).and_then(|o| o.cast_from_zone) - }); - let cast_timing_permission = state - .objects - .get(&entry.id) - .and_then(|o| o.cast_timing_permission.map(|(p, _)| p)); - let kickers_paid = ability - .as_ref() - .map(|a| a.context.kickers_paid.clone()) - .unwrap_or_else(|| { - state - .objects - .get(&entry.id) - .map(|o| o.kickers_paid.clone()) - .unwrap_or_default() - }); - let additional_cost_payment_count = ability - .as_ref() - .map(|a| a.context.additional_cost_payment_count) - .unwrap_or_else(|| { - state - .objects - .get(&entry.id) - .map(|o| o.additional_cost_payment_count) - .unwrap_or_default() - }); - let additional_cost_payments = ability - .as_ref() - .map(|a| a.context.additional_cost_payments.clone()) - .unwrap_or_else(|| { - state - .objects - .get(&entry.id) - .map(|o| o.additional_cost_payments.clone()) - .unwrap_or_default() - }); - let convoked_creatures = state - .objects - .get(&entry.id) - .map(|o| o.convoked_creatures.clone()) - .unwrap_or_default(); - state.push_spell_resolution( - crate::types::game_state::PendingSpellResolution { - object_id: entry.id, - controller: entry.controller, - casting_variant, - cast_from_zone, - cast_controller: Some(entry.controller), - cast_timing_permission, - spell_targets: spell_targets.clone(), - actual_mana_spent, - kickers_paid, - additional_cost_payment_count, - additional_cost_payments, - convoked_creatures, - }, - ); + state.push_spell_resolution(pending_spell_resolution_snapshot( + state, + &entry, + ability.as_ref(), + casting_variant, + actual_mana_spent, + &spell_targets, + )); state.waiting_for = wf; } WaitingFor::Priority { .. } => {} diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index d6501947c8..7cac640b52 100644 --- a/crates/engine/src/parser/oracle.rs +++ b/crates/engine/src/parser/oracle.rs @@ -11,11 +11,11 @@ use serde::{Deserialize, Serialize}; use crate::types::ability::{ AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, AbilityTag, ActivationRestriction, AdditionalCost, CastTimingPermission, CastingRestriction, ChoiceType, - ChoosePermanentPersist, ChosenSubtypeKind, ContinuousModification, ControllerRef, - CostReduction, DelayedTriggerCondition, Duration, Effect, EffectScope, FilterProp, - ManaProduction, ModalChoice, ParsedCondition, PlayerFilter, QuantityExpr, QuantityRef, - ReplacementDefinition, SolveCondition, SpellCastingOption, StaticCondition, StaticDefinition, - TapStateChange, TargetFilter, TriggerCondition, TriggerDefinition, TypedFilter, + ChosenSubtypeKind, ContinuousModification, ControllerRef, CostReduction, + DelayedTriggerCondition, Duration, Effect, EffectScope, FilterProp, ManaProduction, + ModalChoice, ParsedCondition, PlayerFilter, QuantityExpr, QuantityRef, ReplacementDefinition, + SolveCondition, SpellCastingOption, StaticCondition, StaticDefinition, TapStateChange, + TargetFilter, TriggerCondition, TriggerDefinition, TypedFilter, }; use crate::types::format::DeckCopyLimit; use crate::types::keywords::{EscapeCost, FlashbackCost, Keyword, KeywordKind}; @@ -1709,9 +1709,9 @@ fn detect_linked_choice_copy_chosen_host( } /// CR 607.2d + CR 707.2c + CR 614.12a: Replace the proven chooser gap ability -/// with a Moved `ChoosePermanent { CopiableSnapshot }` replacement. Filter is -/// re-derived from the Unimplemented description so line-local parse never -/// assigns copy-host semantics without this relation. +/// with a Moved `ChoosePermanent` replacement. Filter is re-derived from the +/// Unimplemented description so line-local parse never assigns copy-host +/// semantics without this relation. fn apply_linked_choice_copy_chosen_host( result: &mut ParsedAbilities, relations: &[DocumentRelationIr], @@ -1739,13 +1739,8 @@ fn apply_linked_choice_copy_chosen_host( }; result.abilities.remove(ability_pos); ability_ids.remove(ability_pos); - let execute = AbilityDefinition::new( - AbilityKind::Spell, - Effect::ChoosePermanent { - filter, - persist: ChoosePermanentPersist::CopiableSnapshot, - }, - ); + let execute = + AbilityDefinition::new(AbilityKind::Spell, Effect::ChoosePermanent { filter }); result.replacements.push( ReplacementDefinition::new(ReplacementEvent::Moved) .execute(execute) diff --git a/crates/engine/src/parser/oracle_ir/relation.rs b/crates/engine/src/parser/oracle_ir/relation.rs index eb3f40f29d..f4c8211e69 100644 --- a/crates/engine/src/parser/oracle_ir/relation.rs +++ b/crates/engine/src/parser/oracle_ir/relation.rs @@ -134,11 +134,10 @@ pub(crate) enum LinkedChoiceKind { /// gap (`chooser` — an Unimplemented ability whose Oracle text is /// "As … enters, choose ") linked to a /// `ContinuousModification::CopyChosen` static (`copy_static`). Applying - /// removes the gap ability and injects - /// `Effect::ChoosePermanent { persist: CopiableSnapshot }` — Metamorphic - /// Alteration's Aura-host copy. Without this consumer relation the choose - /// line stays an ordinary Unimplemented ability (no Moved claim), so - /// non-CopyChosen cards (Dauntless Bodyguard, Scheming Fence) keep their + /// removes the gap ability and injects `Effect::ChoosePermanent` — + /// Metamorphic Alteration's Aura-host copy. Without this consumer relation + /// the choose line stays an ordinary Unimplemented ability (no Moved claim), + /// so non-CopyChosen cards (Dauntless Bodyguard, Scheming Fence) keep their /// pre-existing unsupported shape. CopyChosenHost { chooser: OracleItemId, diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index a82792fe96..206db3d9e7 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -2290,7 +2290,7 @@ fn parse_as_enters_choose(norm_lower: &str, original_text: &str) -> Option Option { diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 3216ae691a..90d843ec4f 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -1335,14 +1335,14 @@ pub enum ChosenAttribute { /// prior `Direction` before pushing, so only the "last chosen" survives. Direction(SeatDirection), /// CR 707.2c + CR 614.12a + CR 400.7: The copiable-values snapshot chosen as - /// an `Effect::ChoosePermanent { persist: CopiableSnapshot }` Aura entered - /// (Metamorphic Alteration: "choose a creature"). Like `Card` and - /// `TributeOutcome`, this is ENGINE-SET (written directly at the - /// `CopyTargetChoice` answer, not produced through `ChoiceType`/`from_choice`) - /// — it records the frozen snapshot the companion copy effect was installed - /// from, and, being stored in `chosen_attributes`, is cleared automatically - /// when the Aura changes zones (CR 400.7), which is exactly the copy's - /// lifetime. Boxed to keep the enum small (mirrors the copy-value box idiom). + /// an `Effect::ChoosePermanent` Aura entered (Metamorphic Alteration: + /// "choose a creature"). Like `Card` and `TributeOutcome`, this is + /// ENGINE-SET (written directly at the `CopyTargetChoice` answer, not + /// produced through `ChoiceType`/`from_choice`) — it records the frozen + /// snapshot the companion copy effect was installed from, and, being stored + /// in `chosen_attributes`, is cleared automatically when the Aura changes + /// zones (CR 400.7), which is exactly the copy's lifetime. Boxed to keep + /// the enum small (mirrors the copy-value box idiom). CopiableSnapshot(Box), } @@ -10869,17 +10869,16 @@ pub enum Effect { /// (CR 614.12a: the choice is made before the permanent enters). Unlike /// `BecomeCopy` — where the *entering* object becomes the copy — this effect /// latches the chosen permanent's copiable values and applies them to a - /// *separate* recipient determined by `persist`. For - /// `ChoosePermanentPersist::CopiableSnapshot` (Metamorphic Alteration) the - /// recipient is the Aura's enchanted host; the companion static - /// (`ContinuousModification::CopyChosen`) is a parse-time marker only — the + /// *separate* recipient: the Aura's enchanted host (Metamorphic Alteration). + /// Runtime discrimination is `CopyTargetPurpose::PersistChosenAttribute`; + /// emission is gated on `LinkedChoiceKind::CopyChosenHost` (companion + /// `ContinuousModification::CopyChosen` is a parse-time marker only). The /// copy is materialized once, at the choice answer, as a Layer-1 /// `CopyValues` transient continuous effect whose values are fixed per /// CR 707.2c (determined when the copy effect first starts to apply). ChoosePermanent { #[serde(default = "default_target_filter_any")] filter: TargetFilter, - persist: ChoosePermanentPersist, }, /// CR 113.1a + CR 113.10 + CR 611.2 + CR 611.2c + CR 613.1f: Grant the /// recipient(s) all activated abilities of a chosen target object, for a @@ -20556,25 +20555,8 @@ pub struct CopiableValues { pub static_definitions: Arc>, } -/// CR 707.2c: How the permanent chosen by `Effect::ChoosePermanent` is -/// consumed. Extensible axis (build-for-the-class): today only the -/// copiable-snapshot form is printed (Metamorphic Alteration). A future -/// as-enters "choose a permanent" clause with a different persisted -/// consequence adds a sibling leaf here, never a new `Effect` variant. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum ChoosePermanentPersist { - /// CR 707.2c + CR 613.1a + CR 707.2b: Latch the chosen permanent's copiable - /// values (fixed at the moment the copy effect first starts to apply per - /// CR 707.2c; unaffected by later changes to the chosen object per - /// CR 707.2b) and install them as a Layer-1 copy effect on the source - /// Aura's enchanted host. Persisted as `ChosenAttribute::CopiableSnapshot` - /// on the Aura. - CopiableSnapshot, -} - /// CR 707.2b + CR 707.2c + CR 111.1: A copiable-values snapshot latched when an -/// `Effect::ChoosePermanent { persist: CopiableSnapshot }` choice is answered. +/// `Effect::ChoosePermanent` choice is answered (`CopyTargetPurpose::PersistChosenAttribute`). /// Per CR 707.2c the copiable values a static copy effect grants are determined /// only when the effect first starts to apply, and per CR 707.2b later changes /// to the chosen object never propagate — so the values are frozen here rather diff --git a/crates/engine/tests/integration/metamorphic_alteration.rs b/crates/engine/tests/integration/metamorphic_alteration.rs index 8c74e21bba..67ac53a4d2 100644 --- a/crates/engine/tests/integration/metamorphic_alteration.rs +++ b/crates/engine/tests/integration/metamorphic_alteration.rs @@ -17,8 +17,7 @@ use engine::game::game_object::{AttachTarget, DisplaySource}; use engine::game::scenario::{GameScenario, P0, P1}; use engine::parser::oracle::parse_oracle_text; use engine::types::ability::{ - ChoosePermanentPersist, ChosenAttribute, ContinuousModification, Effect, FilterProp, - TargetFilter, TypedFilter, + ChosenAttribute, ContinuousModification, Effect, FilterProp, TargetFilter, TypedFilter, }; use engine::types::card::TokenImageRef; use engine::types::card_type::CoreType; @@ -666,7 +665,7 @@ fn non_spell_aura_entry_copies_chosen_creature() { /// SHAPE: the full Metamorphic Oracle (choose line + CopyChosen static) lowers /// the choose gap via `CopyChosenHost` to an as-enters -/// `Effect::ChoosePermanent { persist: CopiableSnapshot }` over a creature +/// `Effect::ChoosePermanent` over a creature /// copy-source pool — never a `BecomeCopy` on the entering Aura. A bare choose /// line without CopyChosen stays an Unimplemented ability (see honesty test). #[test] @@ -691,11 +690,7 @@ fn as_enters_choose_a_creature_parses_to_choose_permanent() { .expect("as-enters choose-a-creature must lower to an Effect::ChoosePermanent replacement"); match replacement.execute.as_ref().unwrap().effect.as_ref() { - Effect::ChoosePermanent { filter, persist } => { - assert!( - matches!(persist, ChoosePermanentPersist::CopiableSnapshot), - "the choice must persist as a copiable snapshot latched onto the Aura" - ); + Effect::ChoosePermanent { filter } => { assert_eq!( filter, &TargetFilter::Typed(TypedFilter::creature()),