diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index a7e0495aa9..d348a44c90 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -2825,7 +2825,8 @@ fn legacy_effect(x: &Effect) -> bool { | Effect::ExileHaunting { target } | Effect::HideawayConceal { target } | Effect::ChooseCard { target, .. } - | Effect::Transform { target } + // CR 701.27a: both scopes write ObjectPt on the target/population filter. + | Effect::Transform { target, .. } // CR 710.4: same single-target-slot shape as `Transform`. | Effect::FlipPermanent { target } | Effect::Shuffle { target } @@ -4949,7 +4950,7 @@ fn rw_effect( factor: _, } => obj(StateKind::ObjectPt, target), Effect::SwitchPT { target } => obj(StateKind::ObjectPt, target), - Effect::Transform { target } => obj(StateKind::ObjectPt, target), + Effect::Transform { target, .. } => obj(StateKind::ObjectPt, target), // CR 710.1b: flipping replaces the permanent's power and toughness // (along with its name, type line, and text box) — the same // `ObjectPt` write axis `Transform` records. @@ -7106,6 +7107,7 @@ mod tests { let docent = cond( ra(token(&["Creature", "Wizard"], qfix(1))).sub_ability(ra(Effect::Transform { target: TargetFilter::SelfRef, + scope: crate::types::ability::EffectScope::Single, })), qcheck(obj_count(sub("Wizard")), 1), ); diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 964afc3884..e9a4af942e 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -1044,7 +1044,7 @@ fn scan_effect(x: &Effect, mode: ScanMode) -> Axes { acc = acc.or(scan_target_filter(target, target_ctx, mode)); acc } - Effect::Transform { target } => { + Effect::Transform { target, .. } => { let mut acc = Axes::NONE; acc = acc.or(scan_target_filter(target, target_ctx, mode)); acc @@ -5198,6 +5198,16 @@ fn effect_target_ctx(e: &Effect, mode: ScanMode) -> FilterReadContext { // (missed offer, never a false certificate). | Effect::Suspect { scope: EffectScope::All, .. } | Effect::Unsuspect { scope: EffectScope::All, .. } + // CR 701.27a + CR 115.10a: mass Transform ("Transform all Humans", scope:All) + // is a non-targeting battlefield-population read (`target_filter()`==None; + // `transform_effect::resolve_all` enumerates `state.battlefield`, like + // DestroyAll) ⇒ census — its read SCALES with the growing class. Unlike the + // state-convergent SetTapState exception below, Transform WRITES ObjectPt and + // swaps the object's abilities, so a grown token is NOT inert and the read can + // escalate: `LiveBoardCensus`, never the Snapshot exception. scope:Single is a + // single announced/anaphoric target (a2), relaxed in the single-object group + // below. Exhaustive over EffectScope = {Single, All}. + | Effect::Transform { scope: EffectScope::All, .. } // ── F1-CLASS DUAL-MODE MASS-BATTLEFIELD RESOLVERS (P3-B round-2): each has a // resolver mode that, when the ability carries NO explicit object target, // enumerates the battlefield (or all phased-in/-out permanents) and applies the @@ -5337,8 +5347,11 @@ fn effect_target_ctx(e: &Effect, mode: ScanMode) -> FilterReadContext { | Effect::Mana { .. } | Effect::Discard { .. } | Effect::Shuffle { .. } - | Effect::Transform { .. } - // CR 710.4: same single-target read context as `Transform`. + // CR 701.27a: only the scope:Single Transform relaxes — a single announced or + // anaphoric target (a2). scope:All is the mass battlefield read, census-tagged + // above with the DestroyAll/Suspect{All} group. + | Effect::Transform { scope: EffectScope::Single, .. } + // CR 710.4: same single-target read context as `Transform` (always self-ref). | Effect::FlipPermanent { .. } | Effect::SearchLibrary { .. } | Effect::SearchOutsideGame { .. } @@ -5540,7 +5553,7 @@ enum CensusRole { #[cfg(test)] fn effect_census_role(e: &Effect) -> CensusRole { match e { - // -- CENSUS (29): verbatim mirror of `effect_target_ctx`'s LiveBoardCensus + // -- CENSUS (30): verbatim mirror of `effect_target_ctx`'s LiveBoardCensus // arm - mass battlefield population reads that scale with growth. Effect::EachSourceDealsDamage { .. } | Effect::EachDealsDamageEqualToPower { .. } @@ -5592,7 +5605,16 @@ fn effect_census_role(e: &Effect) -> CensusRole { // token copy per matching attacker over an UNCONDITIONAL battlefield scan (grows // the board); unsound across CR 508.1 multi-combat loops. Mirror of the new // effect_target_ctx census member. - | Effect::CopyTokenBlockingAttacker { .. } => CensusRole::Census, + | Effect::CopyTokenBlockingAttacker { .. } + // CR 701.27a + CR 115.10a: mass Transform (scope:All) enumerates + // `state.battlefield` (`transform_effect::resolve_all`) — a census read that + // GROWS with the class. It WRITES ObjectPt + swaps abilities (NOT state- + // convergent like SetTapState), so it is a true `Census`, never the SetTapState + // relax exception. Parity with the effect_target_ctx LiveBoardCensus member. + | Effect::Transform { + scope: EffectScope::All, + .. + } => CensusRole::Census, // -- SetTapState (scope-DESTRUCTURED, exhaustive over EffectScope): scope:All is // the census-ROLE proven exception (TapAll/UntapAll - state-convergent/idempotent, @@ -5725,9 +5747,14 @@ fn effect_census_role(e: &Effect) -> CensusRole { | Effect::Mana { .. } | Effect::Discard { .. } | Effect::Shuffle { .. } - | Effect::Transform { .. } + // CR 701.27a: scope:Single Transform reads only its single announced/anaphoric + // target — not a board census. scope:All is census-tagged above. + | Effect::Transform { + scope: EffectScope::Single, + .. + } // CR 710.4: a flip reads only its own self-referential target — not a - // board census, mirroring `Transform`. + // board census, mirroring `Transform`'s single scope. | Effect::FlipPermanent { .. } | Effect::TargetOnly { .. } | Effect::Choose { .. } @@ -6951,7 +6978,7 @@ mod tests { } /// guard#3 (mitigation #3): the `LiveBoardCensus` tag set of `effect_target_ctx` - /// == EXACTLY the enumeration-derived MASS-POPULATION set (29). Source-scanned, not + /// == EXACTLY the enumeration-derived MASS-POPULATION set (30). 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 @@ -7007,6 +7034,11 @@ mod tests { // `EffectScope::All`; the scope:Single arms live in the relax group below and // are NOT scanned here (they sit past the census terminator). "Suspect", + // CR 701.27a + CR 115.10a: mass Transform (scope:All) enumerates + // `state.battlefield` (`transform_effect::resolve_all`). Scope-gated on + // `EffectScope::All` in the census `|`-chain; the scope:Single arm sits past + // the census terminator in the relax group and is NOT scanned here. + "Transform", "UnattachAll", "Unsuspect", // P3-B round-2: F1-class dual-mode mass-battlefield resolvers (a resolver @@ -7027,7 +7059,7 @@ mod tests { got, want, "census tag set drifted from the enumeration-derived mass-population set" ); - assert_eq!(got.len(), 29, "exactly 29 mass-population census tags"); + assert_eq!(got.len(), 30, "exactly 30 mass-population census tags"); } /// A7' (mitigation #4, replaces the void census-default A7): with SnapshotOrEvent the @@ -7138,7 +7170,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 29). Behavioral: the + /// name-sets are source-scanned and asserted IDENTICAL (== the 30). 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 @@ -7153,7 +7185,7 @@ mod tests { use crate::types::ability::{EffectScope, TapStateChange}; use ScanMode::LoopFirewall; - // -- Structural: the two census name-sets are byte-identical (and == 29). + // -- Structural: the two census name-sets are byte-identical (and == 30). fn census_names(fnsrc: &str, terminator: &str) -> Vec { let end = fnsrc.find(terminator).expect("census terminator"); let block = &fnsrc[..end]; @@ -7182,7 +7214,7 @@ mod tests { etc_census, ecr_census, "effect_census_role Census set diverged from effect_target_ctx" ); - assert_eq!(ecr_census.len(), 29, "exactly 29 census members"); + assert_eq!(ecr_census.len(), 30, "exactly 30 census members"); // -- Behavioral: the two oracles agree on the Census/Relax boundary for every // discriminator. `census(e, true)` requires BOTH `effect_census_role == Census` @@ -7237,6 +7269,31 @@ mod tests { census(&settap, false); census(&Effect::HeistExile, false); census(&Effect::NoOp, false); + // CR 701.27a + CR 115.10a: mass Transform is a battlefield census in BOTH oracles + // (scope:All), and a bounded single-target read (scope:Single) that relaxes. It is + // a true Census, NOT the SetTapState relax exception (ObjectPt/ability write). + census( + &Effect::Transform { + target: f(), + scope: EffectScope::All, + }, + true, + ); + census( + &Effect::Transform { + target: f(), + scope: EffectScope::Single, + }, + false, + ); + assert_eq!( + effect_census_role(&Effect::Transform { + target: f(), + scope: EffectScope::All, + }), + CensusRole::Census, + "mass Transform must be a true Census, not the SetTapState relax exception" + ); // -- Reason sub-tags reachable and correct (documentation-grade, unenforced by the // Census/Relax boundary but proving each `RelaxReason` arm is live). diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index 99e9086eb0..72ac21e955 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -2995,6 +2995,13 @@ fn mass_all_target_filter(effect: &Effect) -> Option<&TargetFilter> { | Effect::BounceAll { target, .. } | Effect::CounterAll { target, .. } | Effect::ChangeZoneAll { target, .. } + // CR 701.27a + CR 115.10a: mass Transform's `target` is a resolution-time + // population scan (`target_filter()`==None), exactly like `TapAll`/`DestroyAll`. + | Effect::Transform { + scope: EffectScope::All, + target, + .. + } | Effect::DoublePTAll { target, .. } => Some(target), _ => None, } @@ -3703,6 +3710,13 @@ fn effect_references_parent_target_combat_relation(effect: &Effect) -> bool { | Effect::ChangeZoneAll { target, .. } | Effect::DoublePTAll { target, .. } | Effect::DamageAll { target, .. } + // CR 701.27a + CR 115.10a: parity with the other mass-population `target` + // filters — mass Transform's population filter is walked here too. + | Effect::Transform { + scope: EffectScope::All, + target, + .. + } | Effect::PutCounterAll { target, .. } => { filter_references_parent_target_combat_relation(target) } @@ -3779,6 +3793,13 @@ fn effect_target_slot_filter(effect: &Effect) -> Option { | Effect::BounceAll { target, .. } | Effect::CounterAll { target, .. } | Effect::ChangeZoneAll { target, .. } + // CR 701.27a + CR 115.10a: mass Transform's population filter is a + // resolution-time scan, walked here like the other mass-`All` effects. + | Effect::Transform { + scope: EffectScope::All, + target, + .. + } | Effect::DoublePTAll { target, .. } => filter_target_slot_filter(target), _ => None, } @@ -4857,8 +4878,15 @@ fn concretize_granting_object_in_effect(effect: &mut Effect, granter: ObjectId) | Effect::DealDamage { target, .. } | Effect::Pump { target, .. } | Effect::Counter { target, .. } - | Effect::Transform { target, .. } - // CR 710.4: same single-target-slot shape as `Transform`. + // CR 701.27a: only single-scope Transform carries a targetable slot that + // can bind a GrantingObject anaphor; the mass (`All`) scope's `target` is a + // population filter (mirrors the SetTapState Single-gate above). + | Effect::Transform { + scope: EffectScope::Single, + target, + .. + } + // CR 710.4: same single-target-slot shape as `Transform`'s single scope. | Effect::FlipPermanent { target, .. } | Effect::Connive { target, .. } | Effect::PhaseOut { target } diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 46c3ad8ae5..af718c7d16 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -2367,7 +2367,13 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> { | Effect::PhaseOut { target } | Effect::PhaseIn { target } | Effect::ForceAttack { target, .. } - | Effect::Transform { target } + // CR 701.27a: single-scope Transform reports its `target` like other + // single-target effects; mass Transform (scope:All) reports a `filter` below. + | Effect::Transform { + scope: EffectScope::Single, + target, + .. + } // CR 710.4: the flipping permanent is the effect's single reported target. | Effect::FlipPermanent { target } | Effect::Shuffle { target } @@ -2406,6 +2412,13 @@ fn effect_details(effect: &Effect) -> Vec<(String, String)> { target, .. } + // CR 701.27a + CR 115.10a: mass Transform ("Transform all Humans") reports its + // non-targeting population `filter`, like the other mass effects. + | Effect::Transform { + scope: EffectScope::All, + target, + .. + } | Effect::BounceAll { target, .. } | Effect::CounterAll { target, .. } | Effect::DamageAll { diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index f5bc37a741..a8656e61df 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -6432,8 +6432,7 @@ fn extract_event_context_filter(effect: &Effect) -> Option<&TargetFilter> { | Effect::Fight { target, .. } | Effect::Attach { target, .. } | Effect::UnattachAll { target, .. } - | Effect::Transform { target, .. } - // CR 710.4: same single-target-slot shape as `Transform`. + // CR 710.4: same single-target-slot shape as `Transform`'s single scope. | Effect::FlipPermanent { target, .. } | Effect::CopySpell { target, .. } | Effect::CastCopyOfCard { target, .. } @@ -6492,6 +6491,15 @@ fn extract_event_context_filter(effect: &Effect) -> Option<&TargetFilter> { target, .. } => target, + // CR 701.27a + CR 603.7c: only the single-scope Transform exposes an + // event-context target (e.g. an anaphoric trigger subject). The mass + // (`All`) scope's `target` is a population filter, not a per-event ref — + // it falls through to `None`, mirroring the SetTapState split above. + Effect::Transform { + scope: EffectScope::Single, + target, + .. + } => target, // CR 603.7c + CR 608.2c: `GenericEffect` carries an optional `target` that may // be an event-context ref (e.g., `TriggeringSource` for "that land doesn't untap // during its controller's next untap step" on a TapsForMana trigger). Routing it diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index e875640f7b..4dbd8b3099 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -2714,6 +2714,7 @@ fn incubator_ability() -> AbilityDefinition { AbilityKind::Activated, Effect::Transform { target: TargetFilter::SelfRef, + scope: crate::types::ability::EffectScope::Single, }, ) .cost(AbilityCost::Mana { @@ -3996,7 +3997,8 @@ mod tests { assert!(matches!( *abilities[0].effect, Effect::Transform { - target: TargetFilter::SelfRef + target: TargetFilter::SelfRef, + .. } )); assert!(matches!( diff --git a/crates/engine/src/game/effects/transform_effect.rs b/crates/engine/src/game/effects/transform_effect.rs index 450e42ed27..e65d3ecbe7 100644 --- a/crates/engine/src/game/effects/transform_effect.rs +++ b/crates/engine/src/game/effects/transform_effect.rs @@ -1,15 +1,33 @@ -use crate::game::transform::transform_permanent; -use crate::types::ability::{Effect, EffectError, EffectKind, ResolvedAbility, TargetRef}; +use crate::game::transform::{is_double_faced_permanent, transform_permanent}; +use crate::types::ability::{ + Effect, EffectError, EffectKind, EffectScope, ResolvedAbility, TargetRef, +}; use crate::types::events::GameEvent; use crate::types::game_state::GameState; /// CR 701.27a: Transform — turn a double-faced card to its other face. +/// +/// `scope` is load-bearing and genuinely divergent (mirrors +/// `tap_untap::resolve_set_tap_state`): +/// - `EffectScope::Single` (legacy targeted/anaphoric transform) acts on the +/// single chosen or source permanent (`resolve_single`). +/// - `EffectScope::All` ("Transform all Humans" — Moonmist) is a non-targeting +/// mass transform that enumerates the population filter over the battlefield +/// (`resolve_all`). pub fn resolve( state: &mut GameState, ability: &ResolvedAbility, events: &mut Vec, ) -> Result<(), EffectError> { match &ability.effect { + Effect::Transform { + scope: EffectScope::All, + target, + .. + } => { + let target = target.clone(); + return resolve_all(state, ability, &target, events); + } Effect::Transform { .. } => {} _ => { return Err(EffectError::InvalidParam( @@ -57,11 +75,62 @@ pub fn resolve( Ok(()) } +/// CR 701.27a + CR 115.10 / CR 115.10a: Mass transform of every permanent +/// matching the (non-targeting) population filter — "Transform all Humans" +/// (Moonmist). Unlike the single scope this never declares a target: it +/// enumerates the resolved population filter over the battlefield and turns each +/// matching permanent over, mirroring `tap_untap::resolve_all`. +/// +/// CR 701.27a + CR 701.27c: "all X" matches mostly SINGLE-FACED permanents, but +/// only permanents represented by double-faced tokens/cards can transform, and a +/// permanent that can't transform does nothing. The matched population is +/// therefore PRE-FILTERED to double-faced permanents (the authoritative +/// `is_double_faced_permanent`) before `transform_permanent`, and any residual +/// per-object error is caught as a no-op rather than propagated — a single +/// non-DFC in the population must never abort the whole mass transform. +fn resolve_all( + state: &mut GameState, + ability: &ResolvedAbility, + target: &crate::types::ability::TargetFilter, + events: &mut Vec, +) -> Result<(), EffectError> { + let effective_filter = crate::game::effects::resolved_object_filter(ability, target); + + // CR 107.3a + CR 601.2b: ability-context filter evaluation. + let ctx = crate::game::filter::FilterContext::from_ability(ability); + let matching: Vec<_> = state + .battlefield + .iter() + .copied() + .filter(|id| { + crate::game::filter::matches_target_filter(state, *id, &effective_filter, &ctx) + }) + // CR 701.27a + CR 701.27c: only double-faced permanents can transform; + // every other match does nothing (never an error). + .filter(|id| state.objects.get(id).is_some_and(is_double_faced_permanent)) + .collect(); + + for obj_id in matching { + // CR 701.27c: never `?`-propagate — a permanent that can't transform + // (CantTransform static, meld, or a filtered-in edge) is a per-object + // no-op, so a single failure must not abort the remaining population. + let _ = transform_permanent(state, obj_id, events); + } + + events.push(GameEvent::EffectResolved { + kind: EffectKind::Transform, + source_id: ability.source_id, + subject: None, + }); + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; use crate::game::zones::create_object; - use crate::types::ability::{AbilityDefinition, AbilityKind, TargetFilter}; + use crate::types::ability::{AbilityDefinition, AbilityKind, EffectScope, TargetFilter}; use crate::types::card_type::{CardType, CoreType}; use crate::types::identifiers::{CardId, ObjectId}; use crate::types::keywords::Keyword; @@ -94,6 +163,7 @@ mod tests { AbilityKind::Spell, Effect::Transform { target: TargetFilter::SelfRef, + scope: EffectScope::Single, }, )]); obj.base_abilities = Arc::clone(&obj.abilities); @@ -124,7 +194,10 @@ mod tests { strive_cost: None, casting_restrictions: vec![], casting_options: vec![], - layout_kind: None, + // CR 712.16: a transform DFC records the Transform layout on its back + // face so `is_double_faced_permanent` recognizes it (the mass-transform + // resolver pre-filters on that authority). + layout_kind: Some(crate::types::card::LayoutKind::Transform), }); id } @@ -136,6 +209,7 @@ mod tests { let ability = ResolvedAbility::new( Effect::Transform { target: TargetFilter::SelfRef, + scope: EffectScope::Single, }, vec![], source_id, @@ -171,6 +245,7 @@ mod tests { let ability = ResolvedAbility::new( Effect::Transform { target: TargetFilter::Any, + scope: EffectScope::Single, }, vec![TargetRef::Object(target_id)], source_id, @@ -352,6 +427,7 @@ mod tests { AbilityKind::Spell, Effect::Transform { target: TargetFilter::SelfRef, + scope: EffectScope::Single, }, )), uses_tracked_set: false, @@ -419,6 +495,7 @@ mod tests { AbilityKind::Spell, Effect::Transform { target: TargetFilter::SelfRef, + scope: EffectScope::Single, }, )), uses_tracked_set: false, @@ -467,4 +544,185 @@ mod tests { ); assert_eq!(state.objects[&source_id].transformation_count, 0); } + + /// A single-faced (non-DFC) creature with an arbitrary subtype, on the + /// battlefield. `back_face` is `None`, so `transform_permanent` would return + /// the "Card has no back face" error if it were ever called on it. + fn make_single_faced(state: &mut GameState, name: &str, subtype: &str) -> ObjectId { + let id = create_object( + state, + CardId(7), + PlayerId(0), + name.to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).unwrap(); + obj.card_types = CardType { + supertypes: vec![], + core_types: vec![CoreType::Creature], + subtypes: vec![subtype.to_string()], + }; + obj.base_card_types = obj.card_types.clone(); + id + } + + fn human_all_filter() -> TargetFilter { + use crate::types::ability::{TypeFilter, TypedFilter}; + TargetFilter::Typed(TypedFilter::new(TypeFilter::Creature).subtype("Human".to_string())) + } + + /// B1 (issue #6403, the bug-fix linchpin, CR 115.10a): the mass (`All`) scope + /// exposes NO target slot — so the cast/trigger pipeline builds no + /// one-target prompt — while the `Single` scope still surfaces its target. + /// Reverting the `Effect::target_filter()` scope-split (leaving Transform in + /// the unconditional `Some(target)` group) makes the `All` arm return `Some` + /// ⇒ a prompt ⇒ the first assertion flips red. + #[test] + fn mass_transform_exposes_no_target_slot() { + let mass = Effect::Transform { + target: human_all_filter(), + scope: EffectScope::All, + }; + assert!( + mass.target_filter().is_none(), + "mass Transform must expose no target slot (CR 115.10a)" + ); + let single = Effect::Transform { + target: human_all_filter(), + scope: EffectScope::Single, + }; + assert!( + single.target_filter().is_some(), + "single Transform must surface its target (CR 115.1)" + ); + } + + /// PRIMARY revert-guard (issue #6403, production path): Moonmist's verbatim + /// Oracle text parses to a mass Transform and resolves over a battlefield of + /// two transformable Humans (DFC) plus a Goblin and a Werewolf — BOTH Humans + /// transform, the non-Humans are untouched, and NO prompt is installed. + /// Reverting the parser mass branch (parses `scope: Single`) or `resolve_all` + /// flips this red. + #[test] + fn moonmist_transforms_all_humans_without_a_prompt() { + let parsed = crate::parser::parse_oracle_text( + "Transform all Humans. Prevent all combat damage that would be dealt this turn by creatures other than Werewolves and Wolves.", + "Moonmist", + &[], + &["Instant".to_string()], + &[], + ); + let def = parsed + .abilities + .first() + .expect("Moonmist parses a spell ability"); + // Production-path parser shape: the head is a mass Transform. + assert!( + matches!( + *def.effect, + Effect::Transform { + scope: EffectScope::All, + .. + } + ), + "Moonmist must parse to a mass Transform, got {:?}", + def.effect + ); + assert!( + def.effect.target_filter().is_none(), + "mass Transform must build no target slot (CR 115.10a)" + ); + // Sibling intact: the prevent-combat-damage clause is preserved as the + // sub-ability (the mass branch must not swallow the rest of the card). + let sibling = def + .sub_ability + .as_deref() + .expect("Moonmist's prevent-combat-damage sibling must be preserved"); + assert!( + matches!(*sibling.effect, Effect::PreventDamage { .. }), + "the second sentence must parse to PreventDamage, got {:?}", + sibling.effect + ); + + let mut state = GameState::new_two_player(42); + let human_a = setup_dfc(&mut state); + let human_b = setup_dfc(&mut state); + let goblin = make_single_faced(&mut state, "Goblin Raider", "Goblin"); + let werewolf = make_single_faced(&mut state, "Lone Wolf", "Werewolf"); + let source = create_object( + &mut state, + CardId(9), + PlayerId(0), + "Moonmist".to_string(), + Zone::Stack, + ); + let ability = ResolvedAbility::new((*def.effect).clone(), vec![], source, PlayerId(0)); + + let waiting_before = std::mem::discriminant(&state.waiting_for); + let mut events = Vec::new(); + resolve(&mut state, &ability, &mut events).expect("mass transform resolves"); + + assert!( + state.objects[&human_a].transformed, + "first Human transforms" + ); + assert!( + state.objects[&human_b].transformed, + "second Human transforms" + ); + assert!( + !state.objects[&goblin].transformed, + "the Goblin is not a Human — untouched" + ); + assert!( + !state.objects[&werewolf].transformed, + "the Werewolf is not a Human — untouched" + ); + assert_eq!( + std::mem::discriminant(&state.waiting_for), + waiting_before, + "mass transform must not install any WaitingFor prompt" + ); + } + + /// B2 (issue #6403, CR 701.27c): "all X" matches mostly SINGLE-FACED + /// permanents. A single-faced Human in the population must NOT abort + /// resolution — the DFC transforms, the non-DFC does nothing. Reverting the + /// `resolve_all` DFC pre-filter (letting `transform_permanent`'s "no back + /// face" error `?`-propagate) makes `resolve` return `Err` ⇒ this fails. + #[test] + fn mass_transform_skips_single_faced_human_without_error() { + let mut state = GameState::new_two_player(42); + let dfc_human = setup_dfc(&mut state); + let single_human = make_single_faced(&mut state, "Village Ironsmith", "Human"); + let source = create_object( + &mut state, + CardId(9), + PlayerId(0), + "Src".to_string(), + Zone::Stack, + ); + let ability = ResolvedAbility::new( + Effect::Transform { + target: human_all_filter(), + scope: EffectScope::All, + }, + vec![], + source, + PlayerId(0), + ); + let mut events = Vec::new(); + + resolve(&mut state, &ability, &mut events) + .expect("a non-DFC Human in the population must not error (CR 701.27c)"); + + assert!( + state.objects[&dfc_human].transformed, + "the Human-faced DFC transforms" + ); + assert!( + !state.objects[&single_human].transformed, + "the single-faced Human is untouched (CR 701.27c)" + ); + } } diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index e62685ae94..8a1e1a4319 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -5295,6 +5295,7 @@ pub(super) fn parse_utility_imperative_ast( ) { return Some(UtilityImperativeAst::Transform { target: TargetFilter::SelfRef, + scope: EffectScope::Single, }); } if matches!( @@ -5309,8 +5310,54 @@ pub(super) fn parse_utility_imperative_ast( // bare object pronoun family per `is_bare_object_pronoun`. return Some(UtilityImperativeAst::Transform { target: resolve_pronoun_target(ctx, "it"), + scope: EffectScope::Single, }); } + // CR 701.27 + CR 115.10 / CR 115.10a: "transform/convert all|each ..." is a + // NON-targeting mass instruction (Moonmist: "Transform all Humans"). It must + // be matched BEFORE the single "transform " branch below, because + // `parse_target_with_ctx("all Humans")` yields the same `Typed` population + // filter and the single branch would otherwise mis-emit it as a targeted + // (scope Single) transform, forcing a one-target prompt. `peek` leaves the + // "all|each ..." text intact so `parse_target_with_ctx` parses the full + // population filter. + if let Some((_, rest)) = nom_on_lower(text, lower, |input| { + value( + (), + preceded( + alt((tag("transform "), tag("convert "))), + peek(alt((tag("all "), tag("each ")))), + ), + ) + .parse(input) + }) { + let (target, remainder) = parse_target_with_ctx(rest, ctx); + if !matches!(target, TargetFilter::Any) { + // CR 712.2 (DFC front/back face): the population phrase may carry a + // trailing face-state restriction ("... on their front face" — That's + // No Moonmist: "Transform all artifacts and Phyrexian creatures on + // their front face.") that `parse_target_with_ctx` does NOT model as a + // typed predicate, leaving it in `remainder`. Emitting `scope: All` + // here would silently drop that restriction and transform matching + // back-face permanents that CR 712.2 leaves untouched. Only a + // fully-consumed population phrase (remainder empty after trimming + // whitespace and a trailing period — Moonmist's "all Humans", where + // the Human typing already implies front face) is an honest mass + // transform; when a rules-bearing suffix survives, strict-fail by + // returning `None` so the caller surfaces `Effect::unimplemented` + // (coverage-honest) instead of falling through to the single + // "transform " branch below, which would mis-emit a targeted + // `scope: Single` transform that also drops the suffix. + let suffix = remainder.trim().trim_end_matches('.').trim(); + if suffix.is_empty() { + return Some(UtilityImperativeAst::Transform { + target, + scope: EffectScope::All, + }); + } + return None; + } + } if let Some((_, rest)) = nom_on_lower(text, lower, |input| { value((), alt((tag("transform "), tag("convert ")))).parse(input) }) { @@ -5319,7 +5366,10 @@ pub(super) fn parse_utility_imperative_ast( // same trigger-subject machinery. let (target, _) = parse_target_with_ctx(rest, ctx); if !matches!(target, TargetFilter::Any) { - return Some(UtilityImperativeAst::Transform { target }); + return Some(UtilityImperativeAst::Transform { + target, + scope: EffectScope::Single, + }); } } // CR 710.4: the Kamigawa flip-card instruction. See @@ -5748,7 +5798,7 @@ pub(super) fn lower_utility_imperative_ast(ast: UtilityImperativeAst) -> Effect additional_modifications: Vec::new(), starting_loyalty_from_casualty_sacrifice: false, }, - UtilityImperativeAst::Transform { target } => Effect::Transform { target }, + UtilityImperativeAst::Transform { target, scope } => Effect::Transform { target, scope }, // CR 710.4: Kamigawa flip cards. UtilityImperativeAst::FlipPermanent { target } => Effect::FlipPermanent { target }, UtilityImperativeAst::Attach { @@ -14011,7 +14061,7 @@ mod tests { "convert itself", ] { let result = parse_utility_imperative_ast(input, input, &mut ParseContext::default()); - let Some(UtilityImperativeAst::Transform { target }) = result else { + let Some(UtilityImperativeAst::Transform { target, .. }) = result else { panic!("{input}: expected Transform, got {result:?}"); }; assert!( @@ -14021,6 +14071,69 @@ mod tests { } } + /// CR 701.27 + CR 115.10 / CR 115.10a (issue #6403, Moonmist): "transform all + /// Humans" / "convert each creature" is a NON-targeting mass transform ⇒ + /// `scope: All` with a `Typed` population filter. This is the parser half of + /// the bug fix — reverting the mass branch makes these lower to a single + /// targeted `scope: Single` transform (the reported one-target-prompt bug), + /// flipping the `scope` assertion. + #[test] + fn parse_transform_all_is_mass_scope() { + for input in ["transform all Humans", "convert each creature"] { + let result = parse_utility_imperative_ast(input, input, &mut ParseContext::default()); + let Some(UtilityImperativeAst::Transform { target, scope }) = result else { + panic!("{input}: expected Transform, got {result:?}"); + }; + assert_eq!( + scope, + EffectScope::All, + "{input}: 'all/each' must be a mass (non-targeting) transform" + ); + assert!( + matches!(target, TargetFilter::Typed(_)), + "{input}: mass transform target must be a Typed population filter, got {target:?}" + ); + } + } + + /// CR 712.2 (issue #6403, That's No Moonmist): a mass "transform all ..." + /// whose population phrase carries a face-state restriction the target parser + /// cannot model ("... on their front face") must NOT be silently marked + /// supported. `parse_target_with_ctx` consumes "all artifacts and Phyrexian + /// creatures" and leaves " on their front face" unparsed; the mass branch + /// strict-fails (returns `None`) so the line surfaces as + /// `Effect::unimplemented` rather than dropping the suffix. Critically it must + /// NOT fall through to the single "transform " branch, which would + /// emit a targeted `scope: Single` transform that also loses the restriction. + #[test] + fn parse_transform_all_with_unparsed_face_suffix_is_unsupported() { + let input = "transform all artifacts and Phyrexian creatures on their front face."; + let result = parse_utility_imperative_ast(input, input, &mut ParseContext::default()); + assert!( + result.is_none(), + "{input}: a mass transform with an unparsed face-state restriction must \ + strict-fail (None ⇒ Effect::unimplemented), not silently drop the \ + 'on their front face' suffix; got {result:?}" + ); + } + + /// Control (issue #6403): the single targeted transform must NOT regress to + /// mass. "transform target creature" stays `scope: Single` with a real + /// targetable filter, preserving the single-target path/prompt. + #[test] + fn parse_transform_target_is_single_scope() { + let input = "transform target creature"; + let result = parse_utility_imperative_ast(input, input, &mut ParseContext::default()); + let Some(UtilityImperativeAst::Transform { scope, .. }) = result else { + panic!("{input}: expected Transform, got {result:?}"); + }; + assert_eq!( + scope, + EffectScope::Single, + "{input}: 'target creature' must stay a single targeted transform" + ); + } + #[test] fn parse_attach_triggering_object_to_last_created_token() { let input = "attach it to the token"; diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index d6f3a2d9de..8723e7fc31 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -6429,7 +6429,8 @@ fn then_if_control_count_conditions_followup_transform() { assert!(matches!( transform.effect.as_ref(), Effect::Transform { - target: TargetFilter::SelfRef + target: TargetFilter::SelfRef, + .. } )); match transform.condition.as_ref() { diff --git a/crates/engine/src/parser/oracle_ir/ast.rs b/crates/engine/src/parser/oracle_ir/ast.rs index 4a051add21..35a7e265ed 100644 --- a/crates/engine/src/parser/oracle_ir/ast.rs +++ b/crates/engine/src/parser/oracle_ir/ast.rs @@ -4,11 +4,11 @@ use crate::types::ability::MultiTargetSpec; use crate::types::ability::{ AbilityCondition, AbilityCost, AbilityDefinition, ActivationRestriction, BounceSelection, CastingPermission, ControlWindow, ControllerRef, CopyRetargetPermission, CounterAdjustment, - CounterSourceRider, DoorLockOp, Duration, Effect, FaceDownProfile, ForceBlockAttackerRef, - LibraryPosition, ManaProduction, ManaSpendRestriction, ModalSelectionConstraint, - OutsideGameSourcePool, PlayerFilter, PtStat, PtValue, QuantityExpr, SearchDestinationSplit, - SearchSelectionConstraint, SpellStackToGraveyardReplacement, StaticCondition, StaticDefinition, - SubAbilityLink, TargetFilter, + CounterSourceRider, DoorLockOp, Duration, Effect, EffectScope, FaceDownProfile, + ForceBlockAttackerRef, LibraryPosition, ManaProduction, ManaSpendRestriction, + ModalSelectionConstraint, OutsideGameSourcePool, PlayerFilter, PtStat, PtValue, QuantityExpr, + SearchDestinationSplit, SearchSelectionConstraint, SpellStackToGraveyardReplacement, + StaticCondition, StaticDefinition, SubAbilityLink, TargetFilter, }; use crate::types::card_type::Supertype; use crate::types::counter::CounterType; @@ -1211,6 +1211,10 @@ pub(crate) enum UtilityImperativeAst { }, Transform { target: TargetFilter, + /// CR 701.27a vs CR 115.10a: `Single` is the legacy targeted/anaphoric + /// transform; `All` is the non-targeting mass transform ("transform all + /// Humans"). Mirrors `Effect::Transform`'s `scope` axis. + scope: EffectScope, }, /// CR 710.4: the Kamigawa flip-card instruction ("flip this creature" / /// "flip it" / "flip "). A sibling of [`UtilityImperativeAst::Transform`] diff --git a/crates/engine/src/parser/oracle_tests.rs b/crates/engine/src/parser/oracle_tests.rs index cdd9da7537..4ada55392b 100644 --- a/crates/engine/src/parser/oracle_tests.rs +++ b/crates/engine/src/parser/oracle_tests.rs @@ -22538,7 +22538,8 @@ fn azors_gateway_transform_condition_parses_with_zero_swallowed_clauses() { assert!(matches!( transform.effect.as_ref(), Effect::Transform { - target: TargetFilter::ParentTarget + target: TargetFilter::ParentTarget, + .. } )); assert_eq!(transform.condition, expected_ability_condition); diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index 57cadbb3cb..1e716289af 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -5388,7 +5388,11 @@ fn parse_cecil_dark_knight_then_if_life_threshold_gate_structure() { transform_sub.condition, ); match &*transform_sub.effect { - Effect::Transform { target } => { + Effect::Transform { + target, + scope: EffectScope::Single, + .. + } => { // The parser today emits `ParentTarget` here — "transform it" // refers back to the Untap target (the trigger source). assert_eq!( @@ -5397,7 +5401,7 @@ fn parse_cecil_dark_knight_then_if_life_threshold_gate_structure() { "Transform.target must be ParentTarget — 'transform it' inherits the Untap target", ); } - other => panic!("nested sub_ability effect must be Transform, got {other:?}"), + other => panic!("nested sub_ability effect must be a single Transform, got {other:?}"), } // The Transform clause is the inner-most sub_ability and inherits the // default `ContinuationStep` link — the chain shape is diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index b5612f09de..98b5edbb4a 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -11362,9 +11362,27 @@ pub enum Effect { #[serde(default = "default_target_filter_controller")] target: TargetFilter, }, + /// CR 701.27a: Transform — turn a double-faced permanent to its other face. + /// + /// `scope` mirrors `Effect::SetTapState`'s single-vs-mass parameterization + /// (CR 701.26a/b) instead of proliferating a `TransformAll` sibling: + /// - `Single` (default) == the legacy targeted/anaphoric transform + /// ("transform target creature" / "transform it" / self-ref "transform + /// ~"). `target` is a selectable target filter; `target_filter()` + /// exposes it so a target slot is built (CR 115.1). + /// - `All` == a non-targeting mass transform ("Transform all Humans" — + /// Moonmist). Per CR 115.10/115.10a "all X" is NOT a target; `target` + /// is a population filter enumerated over the battlefield at resolution + /// and `target_filter()` returns `None` (no prompt). + /// + /// Keeps Transform's legacy `SelfRef` serde default (NOT SetTapState's + /// `Any`) so old serialized single Transforms stay byte-compatible, and the + /// `Single` scope serde default so they deserialize as targeted. Transform { #[serde(default = "default_target_filter_self_ref")] target: TargetFilter, + #[serde(default = "default_effect_scope_single")] + scope: EffectScope, }, /// CR 710.4: Flip a Kamigawa flip permanent — a one-way status change /// (CR 110.5) after which the card's alternative name, text box, type line, @@ -14335,9 +14353,8 @@ impl Effect { | Effect::Animate { target, .. } | Effect::Discard { target, .. } | Effect::Shuffle { target, .. } - | Effect::Transform { target, .. } // CR 710.4: the flipping permanent is named by the effect's target - // slot exactly like `Transform`'s. + // slot exactly like `Transform`'s single scope (below). | Effect::FlipPermanent { target, .. } | Effect::RevealHand { target, .. } | Effect::Reveal { target, .. } @@ -14542,6 +14559,23 @@ impl Effect { .. } => None, + // CR 701.27a + CR 115.1 / CR 115.10a: `Transform` exposes its target + // only for the single-permanent scope (legacy "transform target + // creature" / "transform it" / self-ref "transform ~"). The `All` + // scope ("Transform all Humans" — Moonmist) is a non-targeting + // population filter enumerated at resolution, so — like `SetTapState` + // above and `DestroyAll` — its `target_filter()` is `None` and no + // target slot / prompt is built. + Effect::Transform { + scope: EffectScope::Single, + target, + .. + } => Some(target), + Effect::Transform { + scope: EffectScope::All, + .. + } => None, + // CR 701.60a: `Suspect`/`Unsuspect` expose a target slot only for the // single-permanent scope (targeted/anaphoric "suspect target // creature" / "it's no longer suspected"). The `All` scope ("all diff --git a/crates/engine/tests/integration/cr733_resolved_transform.rs b/crates/engine/tests/integration/cr733_resolved_transform.rs index 6efd3f6cfc..8f5eaf6a47 100644 --- a/crates/engine/tests/integration/cr733_resolved_transform.rs +++ b/crates/engine/tests/integration/cr733_resolved_transform.rs @@ -37,6 +37,7 @@ fn transform_journals_an_exact_resolved_transform() { let mut spell = scenario.add_spell_to_hand(P0, "Rouse", true); spell.with_ability(Effect::Transform { target: TargetFilter::Typed(TypedFilter::creature()), + scope: engine::types::ability::EffectScope::Single, }); let spell_id = spell.id(); diff --git a/crates/engine/tests/integration/issue_6403_moonmist_mass_transform.rs b/crates/engine/tests/integration/issue_6403_moonmist_mass_transform.rs new file mode 100644 index 0000000000..7c7083ccfb --- /dev/null +++ b/crates/engine/tests/integration/issue_6403_moonmist_mass_transform.rs @@ -0,0 +1,162 @@ +//! Runtime regression for issue #6403: Moonmist's "Transform all Humans" is a +//! non-targeting mass instruction, not a request to choose one Human. + +use engine::game::game_object::BackFaceData; +use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; +use engine::parser::oracle_effect::parse_effect; +use engine::types::ability::{Effect, EffectScope}; +use engine::types::card::LayoutKind; +use engine::types::card_type::{CardType, CoreType}; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaColor, ManaCost}; +use engine::types::phase::Phase; + +const MOONMIST: &str = "Transform all Humans. Prevent all combat damage that would be dealt this turn by creatures other than Werewolves and Wolves."; + +fn attach_transform_back_face(runner: &mut GameRunner, object_id: ObjectId) { + runner + .state_mut() + .objects + .get_mut(&object_id) + .unwrap() + .back_face = Some(BackFaceData { + name: "Back Face".to_string(), + power: Some(3), + toughness: Some(3), + loyalty: None, + printed_loyalty: None, + defense: None, + card_types: CardType { + supertypes: vec![], + core_types: vec![CoreType::Creature], + subtypes: vec!["Werewolf".to_string()], + }, + mana_cost: ManaCost::default(), + keywords: vec![], + abilities: vec![], + trigger_definitions: Default::default(), + replacement_definitions: Default::default(), + static_definitions: Default::default(), + color: vec![ManaColor::Green], + printed_ref: None, + modal: None, + additional_cost: None, + strive_cost: None, + casting_restrictions: vec![], + casting_options: vec![], + // CR 712.16: this is a transforming DFC, so the mass resolver may + // transform it rather than applying CR 701.27c's no-op. + layout_kind: Some(LayoutKind::Transform), + }); +} + +/// CR 115.10a: Moonmist's "all Humans" identifies a population; it does not +/// use the word "target". The production cast driver receives no target intent +/// here, so a regression back to `EffectScope::Single` stops at TargetSelection +/// instead of resolving. Both Human DFCs then prove the population resolver ran. +#[test] +fn moonmist_cast_transforms_all_humans_without_target_selection() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let human_a = scenario + .add_creature(P0, "Human A", 2, 2) + .with_subtypes(vec!["Human"]) + .id(); + let human_b = scenario + .add_creature(P1, "Human B", 2, 2) + .with_subtypes(vec!["Human"]) + .id(); + let non_human = scenario + .add_creature(P1, "Goblin", 2, 2) + .with_subtypes(vec!["Goblin"]) + .id(); + let single_faced_human = scenario + .add_creature(P1, "Human C", 2, 2) + .with_subtypes(vec!["Human"]) + .id(); + let moonmist = scenario + .add_spell_to_hand_from_oracle(P0, "Moonmist", true, MOONMIST) + .with_mana_cost(ManaCost::zero()) + .id(); + + let mut runner = scenario.build(); + attach_transform_back_face(&mut runner, human_a); + attach_transform_back_face(&mut runner, human_b); + + let outcome = runner.cast(moonmist).resolve(); + let state = outcome.state(); + + assert!( + state.objects[&human_a].transformed, + "the controller's Human DFC must transform" + ); + assert!( + state.objects[&human_b].transformed, + "an opponent's Human DFC must also transform" + ); + assert!( + !state.objects[&non_human].transformed, + "a non-Human must stay unchanged" + ); + assert!( + !state.objects[&single_faced_human].transformed, + "a single-faced Human must be a no-op under CR 701.27c" + ); + assert!( + matches!(outcome.final_waiting_for(), WaitingFor::Priority { .. }), + "Moonmist must resolve without a target-selection prompt" + ); +} + +/// CR 712.2 (issue #6403, That's No Moonmist): the sibling card's Oracle text +/// "Transform all artifacts and Phyrexian creatures on their front face." adds a +/// face-state restriction the target parser cannot model. The mass-transform +/// branch consumes "all artifacts and Phyrexian creatures" but leaves " on their +/// front face" unparsed; emitting a supported `scope: All` transform there would +/// silently ignore that restriction and turn over matching back-face permanents +/// CR 712.2 leaves untouched. The parser must strict-fail so the card stays +/// coverage-honestly unsupported (`Effect::Unimplemented`) rather than shipping a +/// silent Transform (whether mass `scope: All` or single `scope: Single`). +/// +/// Contrast Moonmist below: "Transform all Humans" fully consumes its population +/// phrase, so it remains a supported mass `scope: All` Transform — the Human +/// typing already implies front face (a transformed back-face Werewolf is no +/// longer a Human and won't match), which is why the fully-parsed phrase needs no +/// separate face guard. +#[test] +fn thats_no_moonmist_front_face_restriction_stays_unimplemented() { + let supported_population = parse_effect("Transform all artifacts and Phyrexian creatures."); + assert!( + matches!( + supported_population, + Effect::Transform { + scope: EffectScope::All, + .. + } + ), + "the population without the face-state suffix must reach the mass-transform parser; \ + got {supported_population:?}" + ); + + let effect = + parse_effect("Transform all artifacts and Phyrexian creatures on their front face."); + assert!( + matches!(effect, Effect::Unimplemented { .. }), + "That's No Moonmist's front-face restriction must surface as Unimplemented, \ + not a silent Transform; got {effect:?}" + ); + + let moonmist = parse_effect("Transform all Humans."); + assert!( + matches!( + moonmist, + Effect::Transform { + scope: EffectScope::All, + .. + } + ), + "Moonmist's fully-parsed 'all Humans' population must stay a supported mass \ + transform; got {moonmist:?}" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 37dfcb07e5..6a5f7b07d1 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -598,6 +598,7 @@ mod issue_6092_ability_block_reason; mod issue_6102_ragavan_exile_cast; mod issue_6157_gold_token_auto_mana_payment; mod issue_629_fractured_sanity_cycling; +mod issue_6403_moonmist_mass_transform; mod issue_6498_portent_of_calamity; mod issue_6499_flickering_ward_protection_exemption; mod issue_6500_loreseekers_stone_hand_cost; diff --git a/crates/mtgish-import/src/convert/action.rs b/crates/mtgish-import/src/convert/action.rs index e54b7bc6cb..5388416708 100644 --- a/crates/mtgish-import/src/convert/action.rs +++ b/crates/mtgish-import/src/convert/action.rs @@ -3818,6 +3818,8 @@ pub fn convert(a: &Action) -> ConvResult { // ordering) at resolution time. Action::TransformPermanent(p) => Effect::Transform { target: convert_permanent(p)?, + // CR 701.27a: mtgish `TransformPermanent` is a single targeted transform. + scope: EffectScope::Single, }, // CR 400.7 + CR 611.2c: "Return a [filter] to its owner's hand" — single-