diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 175535c939..84947991fd 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..765463b55a 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..10e44a712c 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -890,6 +890,13 @@ 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). + Effect::ChoosePermanent { filter } => { + scan_target_filter(filter, target_ctx, mode).or(Axes::CONSERVATIVE) + } Effect::GainActivatedAbilitiesOfTarget { target, recipient, @@ -5002,6 +5009,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 +5185,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 @@ -5485,7 +5500,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 { .. } @@ -5527,6 +5542,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 +5938,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 +6209,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 { .. } @@ -6874,7 +6898,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 @@ -6910,6 +6934,7 @@ mod tests { "ChangeZoneAll", "ChooseAndSacrificeRest", "ChooseObjectsIntoTrackedSet", + "ChoosePermanent", "CounterAll", "DamageAll", "DamageEachPlayer", @@ -6949,7 +6974,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 @@ -7060,7 +7085,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 @@ -7075,7 +7100,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]; @@ -7104,7 +7129,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/coverage.rs b/crates/engine/src/game/coverage.rs index 7ff4b55ebd..ff7139a21d 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 b448407267..71ccfd5249 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -3886,6 +3886,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..534752c98f 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,202 @@ 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 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 + // 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) + { + 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, + }); + apply_pending_spell_resolution(state, &ctx, events); + } + + // 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. + // 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 + | 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 + .objects + .get(&source_id) + .is_some_and(|aura| aura.attached_to.is_some()), + "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`. + // + // 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. + // + // 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, + }; + 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 + // (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); + } + 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 +1532,7 @@ pub(super) fn handle_copy_target_choice( player, source_id, valid_targets, + purpose, .. } = waiting_for else { @@ -1353,6 +1550,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 +2144,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 e307bc7787..a87ee65061 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -6104,6 +6104,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 d23bfcd28f..ff7c12eac9 100644 --- a/crates/engine/src/game/printed_cards.rs +++ b/crates/engine/src/game/printed_cards.rs @@ -863,6 +863,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 2daf8b6fd7..50484e8ba7 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/game/replacement.rs b/crates/engine/src/game/replacement.rs index 33137c879d..8328aedc2e 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -8507,11 +8507,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/scenario.rs b/crates/engine/src/game/scenario.rs index 97f5ccfae3..36d2b7ed50 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 } @@ -3320,6 +3328,9 @@ fn drive_resolution( )?; } WaitingFor::CopyTargetChoice { 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; }; diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 2cd0131e57..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 @@ -440,10 +492,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 @@ -970,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 @@ -1005,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 { @@ -1066,12 +1117,26 @@ 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(_) => { + 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, }); @@ -1162,22 +1227,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 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. - 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, - ); - } + // 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 — @@ -1217,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 @@ -1435,6 +1447,10 @@ 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, @@ -1461,6 +1477,53 @@ 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 + // 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( + state, + Some(entry.id), + None, + Some(crate::types::replacements::ReplacementEvent::Moved), + events, + ) { + match wf { + // 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 { .. } => { + 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 { .. } => {} + other => { + // Tribute / Siege NamedChoice — surface the prompt + // and continue the caller epilogue. Do not push + // SpellResolution on top of an AbilityContinuation. + state.waiting_for = other; + } + } + } + } + // 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/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 diff --git a/crates/engine/src/parser/oracle.rs b/crates/engine/src/parser/oracle.rs index 5dfc0cbb8f..7cac640b52 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; @@ -1192,6 +1192,7 @@ fn detect_document_relations(items: &[OracleItemIr], types: &[String]) -> Vec, +) { + 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 + .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: Replace the proven chooser gap ability +/// 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], + ability_ids: &mut Vec, + replacement_ids: &mut Vec, +) { + for relation in relations { + let DocumentRelationIr::LinkedChoice(LinkedChoiceKind::CopyChosenHost { chooser, .. }) = + relation + else { + continue; + }; + let Some(ability_pos) = position_of(ability_ids, *chooser) else { + continue; + }; + let Some(description) = result.abilities[ability_pos] + .effect + .unimplemented_description() + .map(str::to_owned) + else { + continue; + }; + let Some(filter) = filter_from_as_enters_choose_permanent_text(&description) else { + continue; + }; + result.abilities.remove(ability_pos); + ability_ids.remove(ability_pos); + let execute = + AbilityDefinition::new(AbilityKind::Spell, Effect::ChoosePermanent { filter }); + 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); + } +} + +/// 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 /// player/opponent choice. fn ability_chain_has_player_choice(def: &AbilityDefinition) -> bool { @@ -2848,6 +2951,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, + &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 @@ -2863,11 +2972,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 c568568cfc..a3bb4f2f7d 100644 --- a/crates/engine/src/parser/oracle_classifier.rs +++ b/crates/engine/src/parser/oracle_classifier.rs @@ -977,6 +977,13 @@ fn is_as_enters_choose_pattern(lower: &str) -> bool { tag::<_, _, OracleError<'_>>("enters").parse(i) }) .is_some(); + // 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() 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 9ed248baca..06bd63584c 100644 --- a/crates/engine/src/parser/oracle_effect/sequence.rs +++ b/crates/engine/src/parser/oracle_effect/sequence.rs @@ -6049,6 +6049,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_ir/relation.rs b/crates/engine/src/parser/oracle_ir/relation.rs index 3a3a24981f..f4c8211e69 100644 --- a/crates/engine/src/parser/oracle_ir/relation.rs +++ b/crates/engine/src/parser/oracle_ir/relation.rs @@ -130,4 +130,17 @@ 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 + /// 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` — + /// 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, + copy_static: OracleItemId, + }, } diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index 34c152300e..206db3d9e7 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; @@ -26,7 +26,7 @@ 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, @@ -2120,11 +2120,22 @@ fn parse_as_enters_choose(norm_lower: &str, original_text: &str) -> Option>("choose ").parse(i) })?; - let choice_type = try_parse_named_choice(choose_text)?; + // 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(); + // Named-attribute choices only. Object choices ("choose a creature") are + // deliberately NOT claimed here — see `LinkedChoiceKind::CopyChosenHost`. + // Claiming them as Moved+unsupported would reshape every as-enters + // permanent-choose card (Dauntless Bodyguard, Scheming Fence, …) even when + // no CopyChosen consumer exists. + let choice_type = parse_named_choice_object(choose_object)?; let choose = AbilityDefinition::new( AbilityKind::Spell, @@ -2273,6 +2284,39 @@ fn parse_as_enters_choose(norm_lower: &str, original_text: &str) -> Option 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) +} + /// 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 @@ -14631,6 +14675,22 @@ mod tests { )); } + /// 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_not_a_line_local_replacement() { + assert!( + 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" + ); + } + #[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/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 66f2a7fac3..d156d27c5d 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` 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(), + }, } } @@ -10860,6 +10877,22 @@ 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: 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, + }, /// 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` @@ -14397,6 +14430,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 { .. } @@ -14702,6 +14739,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 { .. } @@ -14955,6 +14994,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 { .. } @@ -15165,6 +15206,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", @@ -15384,6 +15426,7 @@ pub enum EffectKind { Tribute, TimeTravel, BecomeMonarch, + ChoosePermanent, NoOp, Proliferate, ProliferateTarget, @@ -15666,6 +15709,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 } @@ -20524,6 +20568,47 @@ pub struct CopiableValues { pub static_definitions: Arc>, } +/// CR 707.2b + CR 707.2c + CR 111.1: A copiable-values snapshot latched when an +/// `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 +/// 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; @@ -20582,6 +20667,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 3f53126b8c..0370508cc8 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, @@ -7552,6 +7552,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 9df3268382..792dacb02d 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -684,6 +684,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..67ac53a4d2 --- /dev/null +++ b/crates/engine/tests/integration/metamorphic_alteration.rs @@ -0,0 +1,795 @@ +//! 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::{ + 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. +/// +/// 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 + // Enchantment — Aura spell (not a Sorcery+Enchantment hybrid). + 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_with_keywords(&["enchant"], METAMORPHIC_ALTERATION) + .id() +} + +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 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 +/// 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(); + 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) + .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(); + 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. Mutate via `GameRunner::state_mut` (GameScenario has no + // mutable state escape hatch). + { + 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 { + scryfall_id: "tok-angel-1".to_string(), + scryfall_oracle_id: None, + face_name: None, + preset_id: "angel_4_4".to_string(), + }); + } + 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" + ); +} + +/// 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). Stage + // 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) + .as_enchantment() + .with_subtypes(vec!["Aura"]) + .with_mana_cost(ManaCost::Cost { + generic: 2, + shards: vec![ManaCostShard::Blue], + }) + .from_oracle_text_with_keywords(&["enchant"], METAMORPHIC_ALTERATION) + .id(); + + let mut runner = scenario.build(); + + // Narrow Enchant so only `host` is a legal attach target (auto-attach). + { + 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 mut you_control_creature = TypedFilter::creature(); + you_control_creature.controller = Some(ControllerRef::You); + let narrowed = Keyword::Enchant(TargetFilter::Typed(you_control_creature)); + obj.keywords.push(narrowed.clone()); + obj.base_keywords.push(narrowed); + } + assert!( + runner.state().objects[&aura] + .replacement_definitions + .iter_unchecked() + .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 full Metamorphic Oracle (choose line + CopyChosen static) lowers +/// the choose gap via `CopyChosenHost` to an as-enters +/// `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] +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 } => { + 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:?}"), + } +} + +/// 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" + ); + } +} 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 { .. }