diff --git a/Cargo.lock b/Cargo.lock
index 1ca3defbb9..ce0346bc94 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -44,15 +44,6 @@ dependencies = [
"alloc-no-stdlib",
]
-[[package]]
-name = "android_system_properties"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
-dependencies = [
- "libc",
-]
-
[[package]]
name = "anstream"
version = "1.0.0"
@@ -346,15 +337,6 @@ dependencies = [
"alloc-stdlib",
]
-[[package]]
-name = "bs58"
-version = "0.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
-dependencies = [
- "tinyvec",
-]
-
[[package]]
name = "bumpalo"
version = "3.20.2"
@@ -408,18 +390,6 @@ dependencies = [
"rand_core 0.10.1",
]
-[[package]]
-name = "chrono"
-version = "0.4.44"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
-dependencies = [
- "iana-time-zone",
- "num-traits",
- "serde",
- "windows-link",
-]
-
[[package]]
name = "clap"
version = "4.6.1"
@@ -632,40 +602,6 @@ dependencies = [
"syn 2.0.117",
]
-[[package]]
-name = "darling"
-version = "0.23.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
-dependencies = [
- "darling_core",
- "darling_macro",
-]
-
-[[package]]
-name = "darling_core"
-version = "0.23.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
-dependencies = [
- "ident_case",
- "proc-macro2",
- "quote",
- "strsim",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "darling_macro"
-version = "0.23.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
-dependencies = [
- "darling_core",
- "quote",
- "syn 2.0.117",
-]
-
[[package]]
name = "data-encoding"
version = "2.11.0"
@@ -679,7 +615,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
- "serde_core",
]
[[package]]
@@ -777,12 +712,6 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
-[[package]]
-name = "dyn-clone"
-version = "1.0.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
-
[[package]]
name = "ego-tree"
version = "0.10.0"
@@ -1151,19 +1080,13 @@ dependencies = [
"futures-core",
"futures-sink",
"http",
- "indexmap 2.14.0",
+ "indexmap",
"slab",
"tokio",
"tokio-util",
"tracing",
]
-[[package]]
-name = "hashbrown"
-version = "0.12.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
-
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -1227,12 +1150,6 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
-[[package]]
-name = "hex"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
-
[[package]]
name = "hostname"
version = "0.3.1"
@@ -1401,30 +1318,6 @@ dependencies = [
"windows-registry",
]
-[[package]]
-name = "iana-time-zone"
-version = "0.1.65"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
-dependencies = [
- "android_system_properties",
- "core-foundation-sys",
- "iana-time-zone-haiku",
- "js-sys",
- "log",
- "wasm-bindgen",
- "windows-core",
-]
-
-[[package]]
-name = "iana-time-zone-haiku"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
-dependencies = [
- "cc",
-]
-
[[package]]
name = "icu_collections"
version = "2.2.0"
@@ -1513,12 +1406,6 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
-[[package]]
-name = "ident_case"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
-
[[package]]
name = "idna"
version = "1.1.0"
@@ -1555,17 +1442,6 @@ dependencies = [
"version_check",
]
-[[package]]
-name = "indexmap"
-version = "1.9.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
-dependencies = [
- "autocfg",
- "hashbrown 0.12.3",
- "serde",
-]
-
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -2047,7 +1923,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db"
dependencies = [
"fixedbitset",
- "indexmap 2.14.0",
+ "indexmap",
]
[[package]]
@@ -2075,7 +1951,7 @@ dependencies = [
"assert_matches",
"flate2",
"im",
- "indexmap 2.14.0",
+ "indexmap",
"insta",
"nom",
"petgraph",
@@ -2534,26 +2410,6 @@ dependencies = [
"bitflags 2.11.1",
]
-[[package]]
-name = "ref-cast"
-version = "1.0.25"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d"
-dependencies = [
- "ref-cast-impl",
-]
-
-[[package]]
-name = "ref-cast-impl"
-version = "1.0.25"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
[[package]]
name = "regex"
version = "1.12.3"
@@ -2790,30 +2646,6 @@ dependencies = [
"windows-sys 0.61.2",
]
-[[package]]
-name = "schemars"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
-dependencies = [
- "dyn-clone",
- "ref-cast",
- "serde",
- "serde_json",
-]
-
-[[package]]
-name = "schemars"
-version = "1.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
-dependencies = [
- "dyn-clone",
- "ref-cast",
- "serde",
- "serde_json",
-]
-
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -2992,38 +2824,6 @@ dependencies = [
"serde",
]
-[[package]]
-name = "serde_with"
-version = "3.21.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
-dependencies = [
- "base64 0.22.1",
- "bs58",
- "chrono",
- "hex",
- "indexmap 1.9.3",
- "indexmap 2.14.0",
- "schemars 0.9.0",
- "schemars 1.2.1",
- "serde_core",
- "serde_json",
- "serde_with_macros",
- "time",
-]
-
-[[package]]
-name = "serde_with_macros"
-version = "3.21.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
-dependencies = [
- "darling",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
[[package]]
name = "server-core"
version = "0.59.0"
@@ -3589,7 +3389,7 @@ version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
- "indexmap 2.14.0",
+ "indexmap",
"serde",
"serde_spanned",
"toml_datetime",
@@ -4017,7 +3817,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
- "indexmap 2.14.0",
+ "indexmap",
"wasm-encoder",
"wasmparser",
]
@@ -4030,7 +3830,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.11.1",
"hashbrown 0.15.5",
- "indexmap 2.14.0",
+ "indexmap",
"semver",
]
@@ -4094,41 +3894,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
-[[package]]
-name = "windows-core"
-version = "0.62.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
-dependencies = [
- "windows-implement",
- "windows-interface",
- "windows-link",
- "windows-result",
- "windows-strings",
-]
-
-[[package]]
-name = "windows-implement"
-version = "0.60.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
-[[package]]
-name = "windows-interface"
-version = "0.59.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -4355,7 +4120,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
- "indexmap 2.14.0",
+ "indexmap",
"prettyplease",
"syn 2.0.117",
"wasm-metadata",
@@ -4386,7 +4151,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.11.1",
- "indexmap 2.14.0",
+ "indexmap",
"log",
"serde",
"serde_derive",
@@ -4405,7 +4170,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
- "indexmap 2.14.0",
+ "indexmap",
"log",
"semver",
"serde",
diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs
index e1a1ddfdd4..458c4569f7 100644
--- a/crates/engine/src/game/layers.rs
+++ b/crates/engine/src/game/layers.rs
@@ -5043,14 +5043,13 @@ fn apply_copy_sublayer_to_fixed_point(
/// `add_transient_continuous_effect`, so the effect is `Transient`-keyed.
/// - `expand_granted_static_effects` sets `def_index: None`, so a granted
/// copy-layer static is `GrantedStatic`-keyed.
-/// - Card data is the only other producer of `StaticDefinition`s, and in the
-/// generated pool every copy-layer modification that sits inside one at all sits
-/// inside a `GenericEffect` payload — Awakening of Vitu-Ghazi, Tenth District
-/// Hero, The Curse of Fenric, The Irencrag, all `SetName` — which
-/// `effects::effect` resolves through `register_transient_effect`. No card
-/// carries a copy-layer modification in its printed `static_abilities`, so no
-/// route into an object's `static_definitions` can carry one either: the copy
-/// payload ([`apply_copiable_values`]), the `GrantStaticAbility` graft, and the
+/// - Card data is the only other producer of `StaticDefinition`s. The resolving
+/// name changes on Awakening of Vitu-Ghazi, Tenth District Hero, The Curse of
+/// Fenric, and The Irencrag are `SetTextName` modifications in Layer 3 inside
+/// `GenericEffect` payloads, not copy-layer producers. No card carries a
+/// copy-layer modification in its printed `static_abilities`, so no route into
+/// an object's `static_definitions` can carry one either: the copy payload
+/// ([`apply_copiable_values`]), the `GrantStaticAbility` graft, and the
/// `RetainPrintedAbilityFromSource` graft all replay card-data statics.
///
/// The first card to print a copy-layer static ability directly — rather than
diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs
index a3d0d92570..053a9885e4 100644
--- a/crates/engine/src/parser/oracle_effect/mod.rs
+++ b/crates/engine/src/parser/oracle_effect/mod.rs
@@ -661,6 +661,7 @@ fn if_you_do_object_anchor(
clauses
.iter()
.rev()
+ // allow-noncombinator: typed ClauseIr adjacency lookup, not Oracle-text dispatch.
.find(|clause| !matches!(clause.disposition, ClauseDisposition::Continue { .. }))
.and_then(|clause| match &clause.parsed.effect {
Effect::GenericEffect {
@@ -11441,8 +11442,9 @@ fn rebind_controller_to_triggering_source(mut clause: ParsedEffectClause) -> Par
/// Parse "it's still a/an [type]" and "that's still a/an [type]" type-retention clauses.
///
/// These appear as separate sentences after animation effects (e.g., "This land becomes
-/// a 3/3 creature with vigilance. It's still a land."). The clause ensures the original
-/// type is retained as a permanent continuous effect.
+/// a 3/3 creature with vigilance. It's still a land."). The retained type shares the
+/// governing animation's stated duration; a standalone clause has no governing duration
+/// and therefore lasts indefinitely (CR 611.2a).
///
/// CR 205.1a: An object retains types explicitly stated by the effect.
/// CR 509.1c: "All creatures able to block [target/~] [this turn] do so."
@@ -11523,28 +11525,48 @@ fn try_parse_mass_forced_block(tp: TextPair, ctx: &mut ParseContext) -> Option
Option {
+fn parse_retained_type_clause(tp: TextPair) -> Option {
// Match singular "it's still a/an [type]" / "that's still a/an [type]"
// or plural "they're still [type]s" — CR 205.1a type retention after
// animation. The descriptor is purely additive: a permanent animated into
// a creature retains its prior types/subtypes (CR 613.1d ordering), so the
// "still a …" clause is confirmatory and emits the same `AddType`/
// `AddSubtype` Layer-4 modifications the animation already implies.
- let (is_plural, descriptor_orig) = nom_on_lower(tp.original, tp.lower, |input| {
- alt((
- value(false, tag("it's still ")),
- value(false, tag("that's still ")),
- value(true, tag("they're still ")),
- ))
- .parse(input)
- })?;
+ let (is_plural, descriptor_owned) = if let Some(((pronoun, article), descriptor)) =
+ nom_on_lower(tp.original, tp.lower, |input| {
+ let (input, pronoun) = alt((
+ value(StillTypePronoun::It, tag("it")),
+ value(StillTypePronoun::He, tag("he")),
+ value(StillTypePronoun::She, tag("she")),
+ value(StillTypePronoun::That, tag("that")),
+ ))
+ .parse(input)?;
+ let (input, _) = alt((tag("'"), tag("’"))).parse(input)?;
+ let (input, _) = tag("s still ").parse(input)?;
+ let (input, article) =
+ alt((value("an ", tag("an ")), value("a ", tag("a ")))).parse(input)?;
+ Ok((input, (pronoun, article)))
+ }) {
+ match pronoun {
+ StillTypePronoun::It
+ | StillTypePronoun::He
+ | StillTypePronoun::She
+ | StillTypePronoun::That => {}
+ }
+ (false, format!("{article}{descriptor}"))
+ } else {
+ let (descriptor, ()) = nom_on_lower(tp.original, tp.lower, |input| {
+ value((), alt((tag("they're still "), tag("they’re still ")))).parse(input)
+ })?;
+ (true, descriptor.to_string())
+ };
// CR 205.1b + CR 305.7: parse the type descriptor ("a Cave land", "lands",
// "a planeswalker") through the shared animation building block so a subtype
// *and* core type are both retained ("It's still a Cave land" → AddType{Land}
// + AddSubtype{Cave}, Cavernous Maw), not just a bare core type. Strip a
// trailing period so the descriptor parses cleanly.
- let descriptor = descriptor_orig.trim().trim_end_matches('.');
+ let descriptor = descriptor_owned.trim().trim_end_matches('.');
let descriptor = if is_plural {
// allow-noncombinator: structural singularization after nom parsed the plural prefix.
descriptor.strip_suffix('s').unwrap_or(descriptor)
@@ -11557,26 +11579,114 @@ fn try_parse_still_a_type(tp: TextPair) -> Option {
return None;
}
- Some(ParsedEffectClause {
- effect: Effect::GenericEffect {
- static_abilities: vec![StaticDefinition::continuous()
- .affected(TargetFilter::SelfRef)
- .modifications(modifications)
- .description(tp.original.to_string())],
- duration: Some(Duration::Permanent),
- target: None,
- end_cost: None,
- },
- duration: Some(Duration::Permanent),
- sub_ability: None,
- distribute: None,
- multi_target: None,
- condition: None,
- optional: false,
- unless_pay: None,
+ Some(ParsedRetainedTypeClause {
+ modifications,
+ description: tp.original.to_string(),
})
}
+fn try_parse_still_a_type(tp: TextPair) -> Option {
+ parse_retained_type_clause(tp)
+ .map(|clause| clause.lower(RetainedTypeDurationBinding::Standalone))
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum RetainedTypeDurationBinding {
+ Standalone,
+ GoverningAnimation(Duration),
+}
+
+impl RetainedTypeDurationBinding {
+ fn duration(self) -> Duration {
+ match self {
+ Self::Standalone => Duration::Permanent,
+ Self::GoverningAnimation(duration) => duration,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq)]
+struct ParsedRetainedTypeClause {
+ modifications: Vec,
+ description: String,
+}
+
+impl ParsedRetainedTypeClause {
+ fn lower(self, duration_binding: RetainedTypeDurationBinding) -> ParsedEffectClause {
+ let duration = duration_binding.duration();
+ ParsedEffectClause {
+ effect: Effect::GenericEffect {
+ static_abilities: vec![StaticDefinition::continuous()
+ .affected(TargetFilter::SelfRef)
+ .modifications(self.modifications)
+ .description(self.description)],
+ duration: Some(duration.clone()),
+ target: None,
+ end_cost: None,
+ },
+ duration: Some(duration),
+ sub_ability: None,
+ distribute: None,
+ multi_target: None,
+ condition: None,
+ optional: false,
+ unless_pay: None,
+ }
+ }
+}
+
+/// CR 205.1b + CR 608.2c + CR 611.2a: a separate retained-type sentence
+/// modifies the immediately preceding animation. Bind it to that animation's
+/// duration in the typed clause stream, before lowering constructs sibling
+/// continuous effects. If there is no adjacent type-changing animation, the
+/// retained-type clause is standalone and keeps its indefinite duration.
+fn retained_type_duration_binding(clauses: &[ClauseIr]) -> RetainedTypeDurationBinding {
+ clauses
+ .iter()
+ .rev()
+ .find(|clause| !matches!(clause.disposition, ClauseDisposition::Continue { .. }))
+ .and_then(type_changing_clause_duration)
+ .map_or(
+ RetainedTypeDurationBinding::Standalone,
+ RetainedTypeDurationBinding::GoverningAnimation,
+ )
+}
+
+fn type_changing_clause_duration(clause: &ClauseIr) -> Option {
+ match &clause.parsed.effect {
+ Effect::GenericEffect {
+ static_abilities,
+ duration,
+ ..
+ } if static_abilities.iter().any(|definition| {
+ definition.modifications.iter().any(|modification| {
+ matches!(
+ modification,
+ ContinuousModification::SetCardTypes { .. }
+ | ContinuousModification::AddType { .. }
+ | ContinuousModification::AddSubtype { .. }
+ )
+ })
+ }) =>
+ {
+ duration
+ .clone()
+ .or_else(|| clause.parsed.duration.clone())
+ .or(Some(Duration::Permanent))
+ }
+ Effect::Animate { .. } => clause.parsed.duration.clone().or(Some(Duration::Permanent)),
+ _ => None,
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum StillTypePronoun {
+ It,
+ He,
+ She,
+ That,
+}
+
/// CR 614.10a: Parse "[subject] skip[s] [their|your] next [step] step[s]" —
/// one-shot step skips. Handles controller and target-player forms.
fn try_parse_skip_next_step(tp: TextPair, ctx: &ParseContext) -> Option {
@@ -33156,6 +33266,10 @@ pub(crate) fn parse_effect_chain_ir(
let (text_no_temporal, delayed_condition) = strip_temporal_suffix(&text);
let (text_no_qty, mut multi_target) = strip_any_number_quantifier(text_no_temporal);
+ let retained_type_clause = {
+ let lower = text_no_qty.to_lowercase();
+ parse_retained_type_clause(TextPair::new(&text_no_qty, &lower))
+ };
// CR 121.1 + CR 608.2c: "draw cards equal to the difference" — anaphoric draw
// count. When a leading QuantityCheck condition establishes two operands (e.g.
// "if you have fewer than seven cards in hand"), "the difference" draws the
@@ -33200,7 +33314,10 @@ pub(crate) fn parse_effect_chain_ir(
None
}
});
- let (clause, repeat_for) = if let Some(draw) = difference_draw {
+ let (clause, repeat_for) = if let Some(retained_type_clause) = retained_type_clause {
+ let duration_binding = retained_type_duration_binding(builder.clauses());
+ (retained_type_clause.lower(duration_binding), repeat_for)
+ } else if let Some(draw) = difference_draw {
(draw, repeat_for)
} else if let Some(lose) = difference_lose {
(lose, repeat_for)
@@ -36364,6 +36481,92 @@ fn extract_effect_verb(effect: &Effect) -> Option<&'static str> {
#[cfg(test)]
mod tests;
+#[cfg(test)]
+mod gendered_still_type_tests {
+ use super::*;
+
+ #[test]
+ fn gideon_gendered_still_type_retains_planeswalker() {
+ for text in ["He's still a planeswalker.", "He’s still a planeswalker."] {
+ let clause = try_parse_still_a_type(TextPair::new(text, &text.to_lowercase()))
+ .expect("gendered still-a clause parses");
+ let Effect::GenericEffect {
+ static_abilities, ..
+ } = clause.effect
+ else {
+ panic!("expected GenericEffect");
+ };
+ assert_eq!(static_abilities[0].affected, Some(TargetFilter::SelfRef));
+ assert!(static_abilities[0]
+ .modifications
+ .iter()
+ .any(|modification| {
+ matches!(
+ modification,
+ ContinuousModification::AddType {
+ core_type: CoreType::Planeswalker
+ }
+ )
+ }));
+ assert!(!static_abilities[0]
+ .modifications
+ .iter()
+ .any(|modification| {
+ matches!(modification, ContinuousModification::SetCardTypes { .. })
+ }));
+ }
+
+ assert!(try_parse_still_a_type(TextPair::new(
+ "He's still maybe a planeswalker.",
+ "he's still maybe a planeswalker."
+ ))
+ .is_none());
+ }
+
+ #[test]
+ fn gideon_separate_retention_clause_inherits_animation_duration() {
+ let definition = parse_effect_chain(
+ "Until end of turn, Gideon becomes a Human Soldier creature with indestructible. He's still a planeswalker.",
+ AbilityKind::Activated,
+ );
+ assert_eq!(definition.duration, Some(Duration::UntilEndOfTurn));
+ let retained = definition
+ .sub_ability
+ .as_deref()
+ .expect("separate retained-type clause");
+ assert_eq!(retained.duration, Some(Duration::UntilEndOfTurn));
+ assert!(matches!(
+ retained.effect.as_ref(),
+ Effect::GenericEffect {
+ duration: Some(Duration::UntilEndOfTurn),
+ ..
+ }
+ ));
+ }
+
+ #[test]
+ fn standalone_retention_pronoun_siblings_remain_permanent() {
+ for text in [
+ "It's still a land.",
+ "That's still an artifact.",
+ "They're still lands.",
+ "They’re still lands.",
+ "She's still a creature.",
+ ] {
+ let clause = try_parse_still_a_type(TextPair::new(text, &text.to_lowercase()))
+ .expect("standalone retained-type clause parses");
+ assert_eq!(clause.duration, Some(Duration::Permanent), "{text}");
+ assert!(matches!(
+ clause.effect,
+ Effect::GenericEffect {
+ duration: Some(Duration::Permanent),
+ ..
+ }
+ ));
+ }
+ }
+}
+
/// Snapshot tests locking current `parse_effect_chain` behavior before the
/// IR/lowering split in Phase 48 Plan 02. Per D-05/D-06, these test 4 groups:
/// continuation patching, condition lifting, delayed-trigger wrapping, and
diff --git a/crates/engine/src/parser/oracle_effect/subject.rs b/crates/engine/src/parser/oracle_effect/subject.rs
index 7151f675bb..83e3240890 100644
--- a/crates/engine/src/parser/oracle_effect/subject.rs
+++ b/crates/engine/src/parser/oracle_effect/subject.rs
@@ -409,16 +409,26 @@ fn try_parse_contracted_subject_additive_type_clause(
text: &str,
ctx: &mut ParseContext,
) -> Option {
- type VE<'a> = OracleError<'a>;
-
let lower = text.to_lowercase();
- let (_, (subject_text, prefix_len)) = alt((
- value(("it", "it's ".len()), tag::<_, _, VE>("it's ")),
- value(("it", "it’s ".len()), tag::<_, _, VE>("it’s ")),
- ))
- .parse(lower.as_str())
- .ok()?;
- let rest_original = &text[prefix_len..];
+ let ((pronoun, article), descriptor) = nom_on_lower(text, &lower, |input| {
+ let (input, pronoun) = alt((
+ value(ContractedSubjectPronoun::It, tag("it")),
+ value(ContractedSubjectPronoun::He, tag("he")),
+ value(ContractedSubjectPronoun::She, tag("she")),
+ ))
+ .parse(input)?;
+ let (input, _) = alt((tag("'"), tag("’"))).parse(input)?;
+ let (input, _) = tag("s ").parse(input)?;
+ let (input, article) =
+ alt((value("an ", tag("an ")), value("a ", tag("a ")))).parse(input)?;
+ Ok((input, (pronoun, article)))
+ })?;
+ let subject_text = match pronoun {
+ ContractedSubjectPronoun::It => "it",
+ ContractedSubjectPronoun::He => "he",
+ ContractedSubjectPronoun::She => "she",
+ };
+ let rest_original = format!("{article}{descriptor}");
let predicate = format!("is {rest_original}");
let application = additive_type_subject_application(subject_text, ctx)?;
@@ -473,21 +483,23 @@ fn try_parse_contracted_subject_additive_type_clause(
// CR 205.1b: additive form first — "it's a [type] in addition to its other
// types" retains prior types (AddType/AddSubtype only).
- if let Some(clause) = build_additive_type_continuous_clause(&application, &predicate) {
- return Some(ClauseAst::SubjectPredicate {
- subject: Box::new(SubjectPhraseAst {
- affected: Some(application.affected),
- target: application.target,
- multi_target: application.multi_target,
- inherits_parent: application.inherits_parent,
- is_optional: application.is_optional,
- }),
- predicate: Box::new(PredicateAst::Continuous {
- effect: clause.effect,
- duration: clause.duration,
- sub_ability: clause.sub_ability,
- }),
- });
+ if has_in_addition_to_other_types(&predicate) {
+ if let Some(clause) = build_additive_type_continuous_clause(&application, &predicate) {
+ return Some(ClauseAst::SubjectPredicate {
+ subject: Box::new(SubjectPhraseAst {
+ affected: Some(application.affected),
+ target: application.target,
+ multi_target: application.multi_target,
+ inherits_parent: application.inherits_parent,
+ is_optional: application.is_optional,
+ }),
+ predicate: Box::new(PredicateAst::Continuous {
+ effect: clause.effect,
+ duration: clause.duration,
+ sub_ability: clause.sub_ability,
+ }),
+ });
+ }
}
// CR 205.1a + CR 613.1d: non-additive animation — "it's a 3/3 Robot artifact
@@ -509,14 +521,53 @@ fn try_parse_contracted_subject_additive_type_clause(
// animating the wrong object. The additive "… in addition to its other
// types" form above is unaffected (it is a type *addition* and stays on the
// referenced subject regardless).
- if !matches!(
- static_affected_for_application(&application),
- TargetFilter::ParentTarget
- ) {
+ let affected = static_affected_for_application(&application);
+ let binds_honestly = match pronoun {
+ ContractedSubjectPronoun::It => matches!(affected, TargetFilter::ParentTarget),
+ ContractedSubjectPronoun::He | ContractedSubjectPronoun::She => {
+ matches!(affected, TargetFilter::SelfRef)
+ }
+ };
+ if !binds_honestly {
return None;
}
let become_predicate = format!("becomes {rest_original}");
- let clause = build_become_clause(application.clone(), &become_predicate, ctx)?;
+ let mut clause = build_become_clause(application.clone(), &become_predicate, ctx)?;
+ // CR 205.1a: an explicit gendered contracted copula is a type-setting
+ // instruction, not an additive animation shorthand. Preserve supertypes
+ // such as Legendary, but replace the core card-type set ("She's a land" ->
+ // Land, not Creature Land). The context-sensitive `it's` branch keeps the
+ // established antecedent-bound animation semantics (Sauron, Dino Devotee).
+ // Explicit "in addition" returned above for every pronoun.
+ if matches!(
+ pronoun,
+ ContractedSubjectPronoun::He | ContractedSubjectPronoun::She
+ ) {
+ if let Effect::GenericEffect {
+ static_abilities, ..
+ } = &mut clause.effect
+ {
+ for definition in static_abilities {
+ let mut core_types = Vec::new();
+ let mut first_core_type_index = None;
+ for (index, modification) in definition.modifications.iter().enumerate() {
+ if let ContinuousModification::AddType { core_type } = modification {
+ first_core_type_index.get_or_insert(index);
+ core_types.push(*core_type);
+ }
+ }
+ if let Some(index) = first_core_type_index {
+ definition.modifications.retain(|modification| {
+ !matches!(modification, ContinuousModification::AddType { .. })
+ });
+ definition.modifications.insert(
+ index.min(definition.modifications.len()),
+ ContinuousModification::SetCardTypes { core_types },
+ );
+ }
+ }
+ }
+ }
Some(ClauseAst::SubjectPredicate {
subject: Box::new(SubjectPhraseAst {
affected: Some(application.affected),
@@ -533,6 +584,13 @@ fn try_parse_contracted_subject_additive_type_clause(
})
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ContractedSubjectPronoun {
+ It,
+ He,
+ She,
+}
+
fn try_parse_subject_continuous_clause(
text: &str,
ctx: &mut ParseContext,
@@ -4887,7 +4945,10 @@ fn build_become_clause(
}
let modifications = if let Some(name) = name_override {
let mut with_name = Vec::with_capacity(modifications.len() + 1);
- with_name.push(ContinuousModification::SetName { name });
+ // CR 612.8 + CR 613.1c: a resolving non-copy effect that assigns a
+ // name is a text-changing effect in Layer 3. Copy exceptions continue
+ // to use `SetName` in the copy-effect payload.
+ with_name.push(ContinuousModification::SetTextName { name });
with_name.extend(modifications);
with_name
} else {
@@ -4923,11 +4984,17 @@ fn build_become_clause(
/// abilities" yields the name `"Fenric"` (not `"Fenric and loses all
/// abilities"`); the residual `"and loses all abilities"` is recovered
/// independently by `parse_continuous_modifications` on the full predicate.
-/// CR 201.4: an effect-assigned name is a single token-or-phrase, not the rest
-/// of the clause.
fn strip_become_name_override(text: &str) -> (String, Option) {
let lower = text.to_lowercase();
- let tp = TextPair::new(text, &lower);
+ let masked_lower = nom_primitives::mask_double_quoted_spans_preserving_len(&lower);
+ // The masked view is deliberately not byte-for-byte lowercase text, but it
+ // preserves byte length. Construct the lockstep slices directly so quoted
+ // `named` tokens stay invisible while all original-text slicing remains
+ // aligned.
+ let tp = TextPair {
+ original: text,
+ lower: masked_lower.as_ref(),
+ };
let Some((before, after)) = tp.split_around(" named ") else {
return (text.to_string(), None);
};
@@ -6910,7 +6977,7 @@ mod tests {
use crate::types::ability::{
AbilityKind, BasicLandType, ContinuousModification, ControllerRef, Effect, TypeFilter,
};
- use crate::types::card_type::Supertype;
+ use crate::types::card_type::{CoreType, Supertype};
use crate::types::statics::BlockExceptionKind;
/// CR 105.3 + CR 106.1a: "becomes that color" (Foraging Wickermaw) maps to the
@@ -7766,7 +7833,7 @@ mod tests {
);
}
- // CR 201.4: a "named X" effect-assigned name terminates at the first
+ // A "named X" outer assigned name terminates at the first
// conjunction — "becomes … named Fenric and loses all abilities" yields
// name "Fenric", not "Fenric and loses all abilities". The residual "loses
// all abilities" is recovered independently as RemoveAllAbilities. Building
@@ -7791,6 +7858,104 @@ mod tests {
assert_eq!(name.as_deref(), Some("Serra Angel"));
}
+ fn clause_modifications(text: &str, ctx: &mut ParseContext) -> Vec {
+ let ability = crate::parser::oracle_effect::parse_effect_chain_with_context(
+ text,
+ AbilityKind::Spell,
+ ctx,
+ );
+ let Effect::GenericEffect {
+ static_abilities, ..
+ } = ability.effect.as_ref()
+ else {
+ panic!(
+ "expected GenericEffect for {text:?}, got {:?}",
+ ability.effect
+ );
+ };
+ static_abilities[0].modifications.clone()
+ }
+
+ #[test]
+ fn gendered_contracted_copulas_bind_self_and_preserve_original_name_case() {
+ for text in ["She's a land named Moon", "She’s a land named Moon"] {
+ let modifications = clause_modifications(text, &mut ParseContext::default());
+ assert!(
+ modifications.iter().any(|modification| matches!(
+ modification,
+ ContinuousModification::SetCardTypes { core_types }
+ if core_types == &vec![CoreType::Land]
+ )),
+ "missing land replacement in {modifications:?}"
+ );
+ assert!(modifications.iter().any(|modification| matches!(
+ modification,
+ ContinuousModification::SetTextName { name } if name == "Moon"
+ )));
+ assert!(!modifications.iter().any(|modification| matches!(
+ modification,
+ ContinuousModification::SetName { .. }
+ )));
+ }
+
+ let fang = clause_modifications(
+ "He's a Spirit in addition to his other types",
+ &mut ParseContext::default(),
+ );
+ assert!(fang.iter().any(|modification| matches!(
+ modification,
+ ContinuousModification::AddSubtype { subtype } if subtype == "Spirit"
+ )));
+ assert!(!fang.iter().any(|modification| matches!(
+ modification,
+ ContinuousModification::SetCardTypes { .. }
+ )));
+ }
+
+ #[test]
+ fn outer_assigned_names_are_text_changes_but_quoted_named_is_opaque() {
+ for (text, expected_name) in [
+ (
+ "It becomes a legendary 0/0 Elemental creature with haste named Vitu-Ghazi",
+ "Vitu-Ghazi",
+ ),
+ (
+ "it becomes a legendary creature named Mileva, the Stalwart, it has base power and toughness 5/5",
+ "Mileva, the Stalwart",
+ ),
+ (
+ "Target nontoken creature becomes a 6/6 legendary Horror creature named Fenric and loses all abilities",
+ "Fenric",
+ ),
+ (
+ "have The Irencrag become a legendary Equipment artifact named Everflame, Heroes' Legacy",
+ "Everflame, Heroes' Legacy",
+ ),
+ ] {
+ let mut ctx = ParseContext {
+ card_name: Some("The Irencrag".to_string()),
+ ..Default::default()
+ };
+ let modifications = clause_modifications(text, &mut ctx);
+ assert!(modifications.iter().any(|modification| matches!(
+ modification,
+ ContinuousModification::SetTextName { name } if name == expected_name
+ )), "missing SetTextName({expected_name:?}) in {modifications:?}");
+ assert!(!modifications.iter().any(|modification| matches!(
+ modification,
+ ContinuousModification::SetName { .. }
+ )), "non-copy outer name must not use SetName: {modifications:?}");
+ }
+
+ let (_, name) = strip_become_name_override(
+ "become 0/0 Elemental creatures with reach, haste, and \"When this creature leaves the battlefield, conjure a card named Forest onto the battlefield tapped.\" They're still lands",
+ );
+ assert_eq!(
+ name, None,
+ "quoted named token is not an outer assigned name"
+ );
+ }
+
/// CR 608.2c: the additive-"also" strip is a building block — it removes the
/// trailing connector for any filter subject, is case-insensitive, leaves
/// non-additive subjects untouched, and refuses to strip a bare "also" that
@@ -8143,10 +8308,14 @@ mod tests {
assert!(
modifications.iter().any(|modification| matches!(
modification,
- ContinuousModification::SetName { name } if name == "Everflame, Heroes' Legacy"
+ // allow-noncombinator: semantic test assertion on the exact parsed assigned name, not parser dispatch
+ ContinuousModification::SetTextName { name } if name == "Everflame, Heroes' Legacy"
)),
- "expected SetName in {modifications:?}",
+ "expected SetTextName in {modifications:?}",
);
+ assert!(!modifications
+ .iter()
+ .any(|modification| matches!(modification, ContinuousModification::SetName { .. })));
assert!(
modifications.iter().any(|modification| matches!(
modification,
diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs
index c4ea8b9985..f224fbcc5a 100644
--- a/crates/engine/src/parser/oracle_trigger.rs
+++ b/crates/engine/src/parser/oracle_trigger.rs
@@ -5903,6 +5903,24 @@ fn extract_if_condition_with_card_name(
return result;
}
+ // CR 603.4 + CR 603.10a + CR 700.4: A leading past-tense pronoun predicate
+ // after a proven dies head binds to the battlefield-to-graveyard event
+ // object's last-known characteristics. Keep this ahead of the legacy
+ // source-only `WasType` arm so gendered pronouns and composite descriptors
+ // use the event snapshot. Negation wraps only the coherent snapshot
+ // predicate, so a non-dies event can never fail open through `Not`.
+ if let Some((before, condition, rest)) = scan_preceded(&lower, |input| {
+ parse_gendered_dies_event_object_condition(input, trigger_zone_change)
+ })
+ .filter(|(before, _, _)| before.trim().is_empty())
+ {
+ let clause_len = lower.len() - before.len() - rest.len();
+ return (
+ strip_condition_clause(text, before.len(), clause_len),
+ Some(condition),
+ );
+ }
+
// CR 400.7 + CR 603.10: "if it was a [type]" / "if it was an [type]"
// Nom combinator: prefix dispatch + typed core type extraction.
{
@@ -6134,6 +6152,99 @@ fn extract_if_condition_with_card_name(
(text.to_string(), None)
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum DiesEventObjectPronoun {
+ It,
+ He,
+ She,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum PastCopulaPolarity {
+ Positive,
+ Negative,
+}
+
+fn parse_gendered_dies_event_object_condition<'a>(
+ input: &'a str,
+ trigger_zone_change: Option<(Zone, Zone)>,
+) -> OracleResult<'a, TriggerCondition> {
+ if trigger_zone_change != Some((Zone::Battlefield, Zone::Graveyard)) {
+ return Err(oracle_err(input));
+ }
+
+ let (rest, _) = tag("if ").parse(input)?;
+ let (rest, pronoun) = alt((
+ value(DiesEventObjectPronoun::It, tag("it ")),
+ value(DiesEventObjectPronoun::He, tag("he ")),
+ value(DiesEventObjectPronoun::She, tag("she ")),
+ ))
+ .parse(rest)?;
+ let (rest, polarity) = alt((
+ value(PastCopulaPolarity::Negative, tag("wasn't ")),
+ value(PastCopulaPolarity::Negative, tag("wasn’t ")),
+ value(PastCopulaPolarity::Negative, tag("was not ")),
+ value(PastCopulaPolarity::Positive, tag("was ")),
+ ))
+ .parse(rest)?;
+ let (descriptor, _) = alt((tag("an "), tag("a "))).parse(rest)?;
+
+ // All three pronouns name the same grammatical role here: the object whose
+ // death produced the zone-change event. Keeping the axis typed and matched
+ // exhaustively prevents a future pronoun from silently inheriting it.
+ match pronoun {
+ DiesEventObjectPronoun::It | DiesEventObjectPronoun::He | DiesEventObjectPronoun::She => {}
+ }
+
+ let (filter, rest) = parse_type_phrase(descriptor);
+ if matches!(filter, TargetFilter::Any) {
+ return Err(oracle_err(input));
+ }
+ // Preserve the legacy positive bare-core `if it was a ` shape.
+ // Existing exported cards encode that narrow grammar as `WasType`; the new
+ // event-snapshot condition owns gendered pronouns, negative copulas,
+ // subtypes, and composite descriptors without rewriting that stable wire
+ // representation.
+ if pronoun == DiesEventObjectPronoun::It
+ && polarity == PastCopulaPolarity::Positive
+ && matches!(&filter,
+ TargetFilter::Typed(typed)
+ if typed.controller.is_none()
+ && typed.properties.is_empty()
+ && matches!(typed.type_filters.as_slice(),
+ [TypeFilter::Creature]
+ | [TypeFilter::Land]
+ | [TypeFilter::Instant]
+ | [TypeFilter::Sorcery]
+ | [TypeFilter::Artifact]
+ | [TypeFilter::Enchantment]
+ | [TypeFilter::Planeswalker]
+ | [TypeFilter::Battle]))
+ {
+ return Err(oracle_err(input));
+ }
+ alt((
+ value((), eof),
+ value((), peek(one_of::<_, _, OracleError<'_>>(",."))),
+ ))
+ .parse(rest)?;
+
+ let condition = TriggerCondition::ZoneChangeObjectMatchesFilter {
+ origin: Some(Zone::Battlefield),
+ destination: Zone::Graveyard,
+ filter,
+ };
+ Ok((
+ rest,
+ match polarity {
+ PastCopulaPolarity::Positive => condition,
+ PastCopulaPolarity::Negative => TriggerCondition::Not {
+ condition: Box::new(condition),
+ },
+ },
+ ))
+}
+
/// CR 603.4 + CR 700.4 + CR 120.1: dies-trigger intervening-if
/// "if [~ | this creature | ] dealt damage to it this
/// turn" (Hawkeye, Avenging Archer).
diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs
index ecc00adb23..f59b7fb644 100644
--- a/crates/engine/src/parser/oracle_trigger_tests.rs
+++ b/crates/engine/src/parser/oracle_trigger_tests.rs
@@ -1910,6 +1910,255 @@ fn zone_change_token_predicate_parses_present_and_past_negation_forms() {
}
}
+fn assert_dies_event_object_filter(
+ condition: &TriggerCondition,
+ expected_types: &[TypeFilter],
+ negated: bool,
+) {
+ let condition = match (condition, negated) {
+ (TriggerCondition::Not { condition }, true) => condition.as_ref(),
+ (condition, false) => condition,
+ (other, expected) => panic!("wrong polarity (negated={expected}): {other:?}"),
+ };
+ let TriggerCondition::ZoneChangeObjectMatchesFilter {
+ origin: Some(Zone::Battlefield),
+ destination: Zone::Graveyard,
+ filter: TargetFilter::Typed(filter),
+ } = condition
+ else {
+ panic!("expected battlefield-to-graveyard event-object filter, got {condition:?}");
+ };
+ assert_eq!(filter.type_filters, expected_types);
+}
+
+#[test]
+fn princess_yue_gendered_dies_condition_keeps_composite_lki_filter() {
+ let def = parse_trigger_line(
+ "When Princess Yue dies, if she was a nonland creature, return this card to the battlefield tapped under your control. She's a land named Moon. She gains \"{T}: Add {C}.\" (She's still legendary.)",
+ "Princess Yue",
+ );
+ assert_eq!(def.constraint, None);
+ assert_dies_event_object_filter(
+ def.condition.as_ref().expect("Princess intervening-if"),
+ &[
+ TypeFilter::Creature,
+ TypeFilter::Non(Box::new(TypeFilter::Land)),
+ ],
+ false,
+ );
+}
+
+#[test]
+fn fang_gendered_dies_condition_keeps_negative_subtype_lki_filter() {
+ let def = parse_trigger_line(
+ "When Fang dies, if he wasn't a Spirit, return this card to the battlefield under your control. He's a Spirit in addition to his other types.",
+ "Fang, Roku's Companion",
+ );
+ assert_eq!(def.constraint, None);
+ assert_dies_event_object_filter(
+ def.condition.as_ref().expect("Fang intervening-if"),
+ &[TypeFilter::Subtype("Spirit".to_string())],
+ true,
+ );
+}
+
+#[test]
+fn gendered_dies_condition_factors_pronoun_copula_article_and_descriptor() {
+ for (pronoun, copula, article, descriptor, expected_types, negated) in [
+ (
+ "it",
+ "was",
+ "a",
+ "Spirit",
+ vec![TypeFilter::Subtype("Spirit".to_string())],
+ false,
+ ),
+ (
+ "he",
+ "wasn't",
+ "an",
+ "Artifact",
+ vec![TypeFilter::Artifact],
+ true,
+ ),
+ (
+ "he",
+ "wasn’t",
+ "an",
+ "Artifact",
+ vec![TypeFilter::Artifact],
+ true,
+ ),
+ (
+ "she",
+ "was not",
+ "a",
+ "Spirit",
+ vec![TypeFilter::Subtype("Spirit".to_string())],
+ true,
+ ),
+ (
+ "she",
+ "was",
+ "a",
+ "nonland creature",
+ vec![
+ TypeFilter::Creature,
+ TypeFilter::Non(Box::new(TypeFilter::Land)),
+ ],
+ false,
+ ),
+ ] {
+ let line = format!(
+ "When this creature dies, if {pronoun} {copula} {article} {descriptor}, draw a card."
+ );
+ let def = parse_trigger_line(&line, "Grammar Probe");
+ assert_dies_event_object_filter(
+ def.condition.as_ref().expect("leading dies condition"),
+ &expected_types,
+ negated,
+ );
+ assert!(matches!(
+ def.execute
+ .as_deref()
+ .map(|ability| ability.effect.as_ref()),
+ Some(Effect::Draw { .. })
+ ));
+ }
+
+ let legacy = parse_trigger_line(
+ "When this creature dies, if it was a creature, draw a card.",
+ "Legacy Bare Core Probe",
+ );
+ assert_eq!(
+ legacy.condition,
+ Some(TriggerCondition::WasType {
+ card_type: CoreType::Creature,
+ }),
+ "positive bare-core `it was` keeps the stable WasType representation"
+ );
+}
+
+#[test]
+fn gendered_past_type_condition_does_not_hoist_outside_leading_dies_position() {
+ let leading = parse_trigger_line(
+ "When this creature dies, if she was a land, draw a card.",
+ "Leading Probe",
+ );
+ assert_dies_event_object_filter(
+ leading
+ .condition
+ .as_ref()
+ .expect("positive leading-dies reach guard"),
+ &[TypeFilter::Land],
+ false,
+ );
+ assert!(matches!(
+ leading
+ .execute
+ .as_deref()
+ .map(|ability| ability.effect.as_ref()),
+ Some(Effect::Draw { .. })
+ ));
+
+ let non_dies = parse_trigger_line(
+ "When this creature enters, if she was a land, draw a card.",
+ "Non-Dies Probe",
+ );
+ assert_eq!(non_dies.mode, TriggerMode::ChangesZone);
+ assert_eq!(non_dies.destination, Some(Zone::Battlefield));
+ assert_eq!(non_dies.condition, None);
+ assert!(matches!(
+ non_dies
+ .execute
+ .as_deref()
+ .map(|ability| ability.effect.as_ref()),
+ Some(Effect::Draw { .. })
+ ));
+ let non_dies_card = parse_oracle_text(
+ "When this creature enters, if she was a land, draw a card.",
+ "Non-Dies Probe",
+ &[],
+ &["Creature".to_string()],
+ &[],
+ );
+ assert!(
+ non_dies_card.parse_warnings.iter().any(|warning| matches!(
+ warning,
+ OracleDiagnostic::SwallowedClause {
+ detector,
+ description,
+ line_index: 0,
+ ..
+ } if detector == "Condition_If"
+ && description == "When this creature enters, if she was a land, draw a card."
+ )),
+ "the non-dies clause must remain an exact honest deferral: {:?}",
+ non_dies_card.parse_warnings
+ );
+
+ let trailing = parse_trigger_line(
+ "When this creature dies, draw a card if she was a land.",
+ "Trailing Probe",
+ );
+ assert_eq!(trailing.mode, TriggerMode::ChangesZone);
+ assert_eq!(trailing.origin, Some(Zone::Battlefield));
+ assert_eq!(trailing.destination, Some(Zone::Graveyard));
+ assert_eq!(trailing.condition, None);
+ assert!(matches!(
+ trailing
+ .execute
+ .as_deref()
+ .map(|ability| ability.effect.as_ref()),
+ Some(Effect::Draw { .. })
+ ));
+ assert_eq!(
+ trailing
+ .execute
+ .as_deref()
+ .and_then(|ability| ability.condition.clone()),
+ None,
+ "unsupported gendered trailing predicate must not fabricate a resolution condition"
+ );
+ let trailing_card = parse_oracle_text(
+ "When this creature dies, draw a card if she was a land.",
+ "Trailing Probe",
+ &[],
+ &["Creature".to_string()],
+ &[],
+ );
+ assert!(
+ trailing_card.parse_warnings.iter().any(|warning| matches!(
+ warning,
+ OracleDiagnostic::SwallowedClause {
+ detector,
+ description,
+ line_index: 0,
+ ..
+ } if detector == "Condition_If"
+ && description == "When this creature dies, draw a card if she was a land."
+ )),
+ "the trailing predicate must remain an exact honest deferral: {:?}",
+ trailing_card.parse_warnings
+ );
+
+ let leading_card = parse_oracle_text(
+ "When this creature dies, if she was a land, draw a card.",
+ "Leading Probe",
+ &[],
+ &["Creature".to_string()],
+ &[],
+ );
+ assert!(
+ leading_card.parse_warnings.iter().all(|warning| !matches!(
+ warning,
+ OracleDiagnostic::SwallowedClause { detector, .. } if detector == "Condition_If"
+ )),
+ "the paired positive must represent its condition: {:?}",
+ leading_card.parse_warnings
+ );
+}
+
#[test]
fn trigger_dies_if_it_was_enchanted_attaches_attachment_lookback() {
let def = parse_trigger_line(
@@ -15575,9 +15824,15 @@ fn trigger_may_have_self_become_named_equipment_if_you_do() {
assert!(
modifications.iter().any(|modification| matches!(
modification,
- ContinuousModification::SetName { name } if name == "Everflame, Heroes' Legacy"
+ ContinuousModification::SetTextName { name } if name == "Everflame, Heroes' Legacy"
)),
- "expected SetName in {modifications:?}",
+ "expected SetTextName in {modifications:?}",
+ );
+ assert!(
+ !modifications
+ .iter()
+ .any(|modification| matches!(modification, ContinuousModification::SetName { .. })),
+ "resolving non-copy name changes must not use copy-layer SetName: {modifications:?}",
);
assert!(
modifications.iter().any(|modification| matches!(
diff --git a/crates/engine/tests/integration/std_longtail_e.rs b/crates/engine/tests/integration/std_longtail_e.rs
index 59890e6024..62113ba21f 100644
--- a/crates/engine/tests/integration/std_longtail_e.rs
+++ b/crates/engine/tests/integration/std_longtail_e.rs
@@ -46,6 +46,7 @@ use engine::game::ability_utils::build_resolved_from_def;
use engine::game::effects::resolve_ability_chain;
use engine::game::game_object::{AttachTarget, GameObject};
use engine::game::scenario::{GameRunner, GameScenario, P0, P1};
+use engine::game::turns::execute_cleanup;
use engine::game::zones::create_object;
use engine::parser::oracle::parse_oracle_text;
use engine::types::ability::TargetFilter;
@@ -54,6 +55,7 @@ use engine::types::actions::GameAction;
use engine::types::events::GameEvent;
use engine::types::game_state::{GameState, WaitingFor};
use engine::types::identifiers::{CardId, ObjectId};
+use engine::types::mana::ManaColor;
use engine::types::phase::Phase;
use engine::types::player::PlayerId;
use engine::types::replacements::ReplacementEvent;
@@ -336,9 +338,11 @@ fn ojer_taq_token_triplication_full_card_parses() {
use engine::game::ability_utils::build_resolved_from_def_with_targets;
use engine::game::layers::evaluate_layers;
use engine::types::ability::{
- AbilityCost, AbilityDefinition, ContinuousModification, Duration, Effect,
+ AbilityCost, AbilityDefinition, ContinuousModification, Duration, Effect, ManaProduction,
+ PlayerScope, QuantityExpr, StaticCondition,
};
-use engine::types::card_type::CoreType;
+use engine::types::card_type::{CoreType, Supertype};
+use engine::types::keywords::Keyword;
use engine::types::zones::Zone;
const VRASKA_ORACLE: &str = "Deathtouch\nWhenever a nontoken creature an opponent controls dies, you may pay {1}. If you do, return that card to the battlefield tapped under your control. It's a Treasure artifact with \"{T}, Sacrifice this artifact: Add one mana of any color,\" and it loses all other card types.";
@@ -1496,3 +1500,653 @@ fn moonlit_parses_to_copy_of_host_replacement() {
"Jinnie remains a ChooseOneOf substitution, not stolen by Moonlit's arm"
);
}
+
+// ---------------------------------------------------------------------------
+// Princess Yue / Fang / Gideon / quote-scoped assigned names
+// ---------------------------------------------------------------------------
+
+const PRINCESS_YUE_ORACLE: &str = "When Princess Yue dies, if she was a nonland creature, return this card to the battlefield tapped under your control. She's a land named Moon. She gains \"{T}: Add {C}.\" (She's still legendary.)\n{T}: Scry 2.";
+const FANG_ORACLE: &str = "Flying\nWhenever Fang attacks, another target legendary creature you control gets +X/+0 until end of turn, where X is Fang's power.\nWhen Fang dies, if he wasn't a Spirit, return this card to the battlefield under your control. He's a Spirit in addition to his other types.";
+const GIDEON_CHAMPION_ORACLE: &str = "[+1]: Put a loyalty counter on Gideon for each creature target opponent controls.\n[0]: Until end of turn, Gideon becomes a Human Soldier creature with power and toughness each equal to the number of loyalty counters on him and gains indestructible. He's still a planeswalker. Prevent all damage that would be dealt to him this turn.\n[−15]: Exile all other permanents.";
+const ARGOTHIAN_ORACLE: &str = "Put two +1/+1 counters on each of X target lands you control. They each become 0/0 Elemental creatures with reach, haste, and \"When this creature leaves the battlefield, conjure a card named Forest onto the battlefield tapped.\" They're still lands.";
+const AWAKENING_ORACLE: &str = "Put nine +1/+1 counters on target land you control. It becomes a legendary 0/0 Elemental creature with haste named Vitu-Ghazi. It's still a land.";
+const TENTH_DISTRICT_HERO_ORACLE: &str = "{1}{W}, Collect evidence 2: This creature becomes a Human Detective with base power and toughness 4/4 and gains vigilance.\n{2}{W}, Collect evidence 4: If this creature is a Detective, it becomes a legendary creature named Mileva, the Stalwart, it has base power and toughness 5/5, and it gains \"Other creatures you control have indestructible.\"";
+const CURSE_OF_FENRIC_ORACLE: &str = "(As this Saga enters and after your draw step, add a lore counter. Sacrifice after III.)\nI — For each player, destroy up to one target creature that player controls. For each creature destroyed this way, its controller creates a 3/3 green Mutant creature token with deathtouch.\nII — Target nontoken creature becomes a 6/6 legendary Horror creature named Fenric and loses all abilities.\nIII — Target Mutant fights another target creature named Fenric.";
+const IRENCRAG_ORACLE: &str = "{T}: Add {C}.\nWhenever a legendary creature you control enters, you may have The Irencrag become a legendary Equipment artifact named Everflame, Heroes' Legacy. If you do, it gains equip {3} and \"Equipped creature gets +3/+3\" and loses all other abilities.";
+const DISTURBED_SLUMBER_ORACLE: &str = "Until end of turn, target land you control becomes a 4/4 Dinosaur creature with reach and haste. It's still a land. It must be blocked this turn if able.";
+const NISSA_VITAL_FORCE_ORACLE: &str = "[+1]: Untap target land you control. Until your next turn, it becomes a 5/5 Elemental creature with haste. It's still a land.\n[−3]: Return target permanent card from your graveyard to your hand.\n[−6]: You get an emblem with \"Whenever a land you control enters, you may draw a card.\"";
+const SYLVAN_AWAKENING_ORACLE: &str = "Until your next turn, all lands you control become 2/2 Elemental creatures with reach, indestructible, and haste. They're still lands.";
+const WRENN_REALMBREAKER_ORACLE: &str = "Lands you control have \"{T}: Add one mana of any color.\"\n[+1]: Up to one target land you control becomes a 3/3 Elemental creature with vigilance, hexproof, and haste until your next turn. It's still a land.\n[−2]: Mill three cards. You may put a permanent card from among the milled cards into your hand.\n[−7]: You get an emblem with \"You may play lands and cast permanent spells from your graveyard.\"";
+const AWAKENER_DRUID_ORACLE: &str = "When this creature enters, target Forest becomes a 4/5 green Treefolk creature for as long as this creature remains on the battlefield. It's still a land.";
+const HEDGE_WHISPERER_ORACLE: &str = "You may choose not to untap this creature during your untap step.\n{3}{G}, {T}, Collect evidence 4: Target land you control becomes a 5/5 green Plant Boar creature with haste for as long as this creature remains tapped. It's still a land. Activate only as a sorcery. (To collect evidence 4, exile cards with total mana value 4 or greater from your graveyard.)";
+const CACOPHONY_UNLEASHED_ORACLE: &str = "When this enchantment enters, if you cast it, destroy all nonenchantment creatures.\nWhenever this enchantment or another enchantment you control enters, until end of turn, this enchantment becomes a legendary 6/6 Nightmare God creature with menace and deathtouch. It's still an enchantment.";
+const CAVERNOUS_MAW_ORACLE: &str = "{T}: Add {C}.\n{2}: This land becomes a 3/3 Elemental creature until end of turn. It's still a Cave land. Activate only if the number of other Caves you control plus the number of Cave cards in your graveyard is three or greater.";
+
+fn all_modifications(def: &AbilityDefinition) -> Vec<&ContinuousModification> {
+ let mut result = Vec::new();
+ let mut pending = vec![def];
+ while let Some(node) = pending.pop() {
+ if let Effect::GenericEffect {
+ static_abilities, ..
+ } = node.effect.as_ref()
+ {
+ result.extend(
+ static_abilities
+ .iter()
+ .flat_map(|static_def| static_def.modifications.iter()),
+ );
+ }
+ pending.extend(node.sub_ability.as_deref());
+ pending.extend(node.else_ability.as_deref());
+ }
+ result
+}
+
+fn retained_type_definition<'a>(
+ definition: &'a AbilityDefinition,
+ core_type: &CoreType,
+ subtype: Option<&str>,
+) -> Option<&'a AbilityDefinition> {
+ let carries_retained_type = match definition.effect.as_ref() {
+ Effect::GenericEffect {
+ static_abilities, ..
+ } => static_abilities.iter().any(|static_definition| {
+ let has_core_type = static_definition.modifications.iter().any(|modification| {
+ matches!(
+ modification,
+ ContinuousModification::AddType {
+ core_type: actual
+ } if actual == core_type
+ )
+ });
+ let has_subtype = subtype.is_none_or(|expected| {
+ static_definition.modifications.iter().any(|modification| {
+ matches!(
+ modification,
+ ContinuousModification::AddSubtype { subtype }
+ if subtype == expected
+ )
+ })
+ });
+ has_core_type && has_subtype
+ }),
+ _ => false,
+ };
+ if carries_retained_type {
+ return Some(definition);
+ }
+ definition
+ .sub_ability
+ .as_deref()
+ .and_then(|sub| retained_type_definition(sub, core_type, subtype))
+ .or_else(|| {
+ definition
+ .else_ability
+ .as_deref()
+ .and_then(|otherwise| retained_type_definition(otherwise, core_type, subtype))
+ })
+}
+
+fn parsed_retained_type_definition<'a>(
+ parsed: &'a engine::parser::oracle::ParsedAbilities,
+ core_type: CoreType,
+ subtype: Option<&str>,
+) -> &'a AbilityDefinition {
+ parsed
+ .abilities
+ .iter()
+ .find_map(|definition| retained_type_definition(definition, &core_type, subtype))
+ .or_else(|| {
+ parsed.triggers.iter().find_map(|trigger| {
+ trigger.execute.as_deref().and_then(|definition| {
+ retained_type_definition(definition, &core_type, subtype)
+ })
+ })
+ })
+ .expect("retained-type production definition")
+}
+
+fn assert_retained_type_duration(
+ parsed: &engine::parser::oracle::ParsedAbilities,
+ name: &str,
+ core_type: CoreType,
+ subtype: Option<&str>,
+ expected_duration: Duration,
+) {
+ assert_zero_unimplemented(parsed, name);
+ let retained = parsed_retained_type_definition(parsed, core_type, subtype);
+ assert_eq!(retained.duration, Some(expected_duration.clone()), "{name}");
+ assert!(
+ matches!(
+ retained.effect.as_ref(),
+ Effect::GenericEffect {
+ duration: Some(actual),
+ ..
+ } if actual == &expected_duration
+ ),
+ "{name}: retained-type effect duration must match the governing animation: {retained:#?}"
+ );
+}
+
+fn assert_exact_text_name(
+ definitions: impl IntoIterator- ,
+ expected_name: &str,
+) {
+ let definitions: Vec<_> = definitions.into_iter().collect();
+ let modifications: Vec<_> = definitions.iter().flat_map(all_modifications).collect();
+ assert!(
+ modifications.iter().any(|modification| matches!(
+ modification,
+ ContinuousModification::SetTextName { name } if name == expected_name
+ )),
+ "missing SetTextName({expected_name:?}) in {modifications:#?}"
+ );
+ assert!(
+ !modifications
+ .iter()
+ .any(|modification| matches!(modification, ContinuousModification::SetName { .. })),
+ "non-copy assigned name must not use SetName: {modifications:#?}"
+ );
+}
+
+#[test]
+fn resolving_outer_assigned_names_are_layer_three_in_all_full_cards() {
+ let awakening = parse(
+ AWAKENING_ORACLE,
+ "Awakening of Vitu-Ghazi",
+ &[],
+ &["Instant"],
+ &[],
+ );
+ assert_zero_unimplemented(&awakening, "Awakening of Vitu-Ghazi");
+ assert_exact_text_name(awakening.abilities, "Vitu-Ghazi");
+
+ let tenth = parse(
+ TENTH_DISTRICT_HERO_ORACLE,
+ "Tenth District Hero",
+ &[],
+ &["Creature"],
+ &["Human"],
+ );
+ assert_zero_unimplemented(&tenth, "Tenth District Hero");
+ assert_exact_text_name(tenth.abilities, "Mileva, the Stalwart");
+
+ let fenric = parse(
+ CURSE_OF_FENRIC_ORACLE,
+ "The Curse of Fenric",
+ &[],
+ &["Enchantment"],
+ &["Saga"],
+ );
+ assert_zero_unimplemented(&fenric, "The Curse of Fenric");
+ let fenric_chapters = fenric
+ .triggers
+ .iter()
+ .filter_map(|trigger| trigger.execute.as_deref().cloned());
+ assert_exact_text_name(fenric_chapters, "Fenric");
+
+ let irencrag = parse(IRENCRAG_ORACLE, "The Irencrag", &[], &["Artifact"], &[]);
+ assert_zero_unimplemented(&irencrag, "The Irencrag");
+ let execute = irencrag
+ .triggers
+ .iter()
+ .filter_map(|trigger| trigger.execute.as_deref().cloned());
+ assert_exact_text_name(execute, "Everflame, Heroes' Legacy");
+}
+
+#[test]
+fn princess_fang_gideon_and_argothian_full_cards_parse_semantically() {
+ let princess = parse(
+ PRINCESS_YUE_ORACLE,
+ "Princess Yue",
+ &[],
+ &["Legendary", "Creature"],
+ &["Human", "Noble"],
+ );
+ assert_zero_unimplemented(&princess, "Princess Yue");
+ let princess_trigger = princess.triggers.first().expect("Princess dies trigger");
+ assert!(matches!(
+ princess_trigger.condition,
+ Some(engine::types::ability::TriggerCondition::ZoneChangeObjectMatchesFilter { .. })
+ ));
+ let princess_mods = all_modifications(
+ princess_trigger
+ .execute
+ .as_deref()
+ .expect("Princess trigger execute"),
+ );
+ assert!(princess_mods.iter().any(|modification| matches!(
+ modification,
+ ContinuousModification::SetTextName { name } if name == "Moon"
+ )));
+ assert!(princess_mods.iter().any(|modification| matches!(
+ modification,
+ ContinuousModification::SetCardTypes { core_types }
+ if core_types == &vec![CoreType::Land]
+ )));
+ assert!(princess_mods.iter().any(|modification| matches!(
+ modification,
+ ContinuousModification::GrantAbility { definition }
+ if matches!(definition.cost, Some(AbilityCost::Tap))
+ && matches!(definition.effect.as_ref(), Effect::Mana {
+ produced: ManaProduction::Colorless {
+ count: QuantityExpr::Fixed { value: 1 }
+ },
+ ..
+ })
+ )));
+ assert!(
+ princess.abilities.iter().any(|definition| {
+ matches!(definition.cost, Some(AbilityCost::Tap))
+ && matches!(
+ definition.effect.as_ref(),
+ Effect::Scry {
+ count: QuantityExpr::Fixed { value: 2 },
+ ..
+ }
+ )
+ }),
+ "Princess's printed tap/Scry ability must remain distinct from the granted mana ability"
+ );
+
+ let fang = parse(
+ FANG_ORACLE,
+ "Fang, Roku's Companion",
+ &["Flying"],
+ &["Legendary", "Creature"],
+ &["Wolf", "Dog"],
+ );
+ assert_zero_unimplemented(&fang, "Fang, Roku's Companion");
+ let fang_trigger = fang
+ .triggers
+ .iter()
+ .find(|trigger| {
+ matches!(
+ trigger.condition,
+ Some(engine::types::ability::TriggerCondition::Not { .. })
+ )
+ })
+ .expect("Fang dies trigger");
+ assert!(all_modifications(fang_trigger.execute.as_deref().unwrap())
+ .iter()
+ .any(|modification| matches!(
+ modification,
+ ContinuousModification::AddSubtype { subtype } if subtype == "Spirit"
+ )));
+
+ let gideon = parse(
+ GIDEON_CHAMPION_ORACLE,
+ "Gideon, Champion of Justice",
+ &[],
+ &["Legendary", "Planeswalker"],
+ &["Gideon"],
+ );
+ assert_zero_unimplemented(&gideon, "Gideon, Champion of Justice");
+ assert!(gideon
+ .abilities
+ .iter()
+ .flat_map(all_modifications)
+ .any(|modification| matches!(
+ modification,
+ ContinuousModification::AddType {
+ core_type: CoreType::Planeswalker
+ }
+ )));
+
+ let argothian = parse(
+ ARGOTHIAN_ORACLE,
+ "Argothian Uprooting",
+ &[],
+ &["Sorcery"],
+ &[],
+ );
+ assert_zero_unimplemented(&argothian, "Argothian Uprooting");
+ let argothian_mods: Vec<_> = argothian
+ .abilities
+ .iter()
+ .flat_map(all_modifications)
+ .collect();
+ assert!(!argothian_mods.iter().any(|modification| matches!(
+ modification,
+ ContinuousModification::SetName { .. } | ContinuousModification::SetTextName { .. }
+ )));
+ assert!(argothian_mods
+ .iter()
+ .any(|modification| matches!(modification, ContinuousModification::SetPower { value: 0 })));
+ assert!(argothian_mods.iter().any(|modification| matches!(
+ modification,
+ ContinuousModification::SetToughness { value: 0 }
+ )));
+ assert!(argothian_mods.iter().any(|modification| matches!(
+ modification,
+ ContinuousModification::AddSubtype { subtype } if subtype == "Elemental"
+ )));
+ assert!(argothian_mods.iter().any(|modification| matches!(
+ modification,
+ ContinuousModification::AddType {
+ core_type: CoreType::Creature
+ }
+ )));
+ assert!(argothian_mods.iter().any(|modification| matches!(
+ modification,
+ ContinuousModification::AddKeyword {
+ keyword: Keyword::Reach
+ }
+ )));
+ assert!(argothian_mods.iter().any(|modification| matches!(
+ modification,
+ ContinuousModification::AddKeyword {
+ keyword: Keyword::Haste
+ }
+ )));
+ assert!(argothian_mods.iter().any(|modification| matches!(
+ modification,
+ ContinuousModification::GrantTrigger { trigger }
+ if matches!(trigger.execute.as_deref().map(|ability| ability.effect.as_ref()),
+ Some(Effect::Conjure {
+ cards,
+ destination: Zone::Battlefield,
+ tapped: true,
+ ..
+ }) if cards.len() == 1 && cards[0].named_name() == Some("Forest"))
+ )));
+}
+
+fn run_dies_return_case(
+ oracle: &str,
+ name: &str,
+ subtypes: Vec<&str>,
+ starts_as_land: bool,
+) -> (ObjectId, ObjectId, engine::game::scenario::CastOutcome) {
+ let mut scenario = GameScenario::new();
+ scenario.at_phase(Phase::PreCombatMain);
+ let mut subject = scenario.add_creature_from_oracle(P0, name, 2, 2, oracle);
+ subject
+ .as_legendary()
+ .with_subtypes(subtypes)
+ .controlled_by(P1);
+ if starts_as_land {
+ subject.as_land().as_creature();
+ }
+ let subject = subject.id();
+ let sentinel = scenario.add_creature(P1, "Sentinel", 3, 3).id();
+ let murder = scenario
+ .add_spell_to_hand_from_oracle(P0, "Murder", true, "Destroy target creature.")
+ .id();
+ let mut runner = scenario.build();
+ let outcome = runner.cast(murder).target_object(subject).resolve();
+ (subject, sentinel, outcome)
+}
+
+#[test]
+fn princess_yue_dies_filter_and_returned_object_transformation_execute() {
+ let (yue, sentinel, outcome) =
+ run_dies_return_case(PRINCESS_YUE_ORACLE, "Princess Yue", vec!["Human"], false);
+ outcome.assert_zone(&[yue], Zone::Battlefield);
+ outcome.assert_zone(&[sentinel], Zone::Battlefield);
+ let object = &outcome.state().objects[&yue];
+ assert!(object.tapped);
+ assert_eq!(object.controller, P1);
+ assert_eq!(object.name, "Moon");
+ assert!(object.card_types.supertypes.contains(&Supertype::Legendary));
+ assert!(object.card_types.core_types.contains(&CoreType::Land));
+ assert!(!object.card_types.core_types.contains(&CoreType::Creature));
+ assert!(object.abilities.iter().any(|definition| {
+ matches!(definition.cost, Some(AbilityCost::Tap))
+ && matches!(
+ definition.effect.as_ref(),
+ Effect::Mana {
+ produced: ManaProduction::Colorless {
+ count: QuantityExpr::Fixed { value: 1 }
+ },
+ ..
+ }
+ )
+ }));
+ assert!(outcome
+ .state()
+ .transient_continuous_effects
+ .iter()
+ .any(|effect| {
+ matches!(effect.affected, TargetFilter::SpecificObject { id } if id == yue)
+ }));
+ assert!(!outcome
+ .state()
+ .transient_continuous_effects
+ .iter()
+ .any(|effect| {
+ matches!(effect.affected, TargetFilter::SpecificObject { id } if id == sentinel)
+ }));
+ assert!(!outcome.state().objects[&sentinel]
+ .abilities
+ .iter()
+ .any(|definition| matches!(
+ definition.effect.as_ref(),
+ Effect::Mana {
+ produced: ManaProduction::Colorless { .. },
+ ..
+ }
+ )));
+
+ let (land_yue, _, negative) =
+ run_dies_return_case(PRINCESS_YUE_ORACLE, "Princess Yue", vec!["Human"], true);
+ negative.assert_zone(&[land_yue], Zone::Graveyard);
+ assert!(!negative
+ .state()
+ .transient_continuous_effects
+ .iter()
+ .any(|effect| {
+ matches!(effect.affected, TargetFilter::SpecificObject { id } if id == land_yue)
+ }));
+ assert!(matches!(
+ negative.final_waiting_for(),
+ WaitingFor::Priority { .. }
+ ));
+}
+
+#[test]
+fn fang_dies_filter_adds_spirit_only_when_it_was_absent() {
+ let (fang, _, outcome) = run_dies_return_case(
+ FANG_ORACLE,
+ "Fang, Roku's Companion",
+ vec!["Wolf", "Dog"],
+ false,
+ );
+ outcome.assert_zone(&[fang], Zone::Battlefield);
+ let object = &outcome.state().objects[&fang];
+ assert!(object.card_types.core_types.contains(&CoreType::Creature));
+ assert!(object
+ .card_types
+ .subtypes
+ .iter()
+ .any(|subtype| subtype == "Wolf"));
+ assert!(object
+ .card_types
+ .subtypes
+ .iter()
+ .any(|subtype| subtype == "Spirit"));
+ assert!(outcome
+ .state()
+ .transient_continuous_effects
+ .iter()
+ .any(|effect| {
+ matches!(effect.affected, TargetFilter::SpecificObject { id } if id == fang)
+ }));
+
+ let (spirit_fang, _, negative) = run_dies_return_case(
+ FANG_ORACLE,
+ "Fang, Roku's Companion",
+ vec!["Wolf", "Spirit"],
+ false,
+ );
+ negative.assert_zone(&[spirit_fang], Zone::Graveyard);
+ assert!(!negative
+ .state()
+ .transient_continuous_effects
+ .iter()
+ .any(|effect| {
+ matches!(effect.affected, TargetFilter::SpecificObject { id } if id == spirit_fang)
+ }));
+ assert!(matches!(
+ negative.final_waiting_for(),
+ WaitingFor::Priority { .. }
+ ));
+}
+
+/// Candidate-bound production coverage for every retained-type duration class in
+/// the 79-card projected comparator slice. The 72-card add-Land EOT class keeps
+/// its runtime discriminator below (Disturbed Slumber); these shipped cards pin
+/// all three non-EOT authorities plus the two distinct one-card EOT payloads.
+/// Reverting the preceding-animation binding changes every retained definition
+/// asserted here back to `Permanent`.
+#[test]
+fn shipped_retained_type_duration_classes_follow_the_governing_animation() {
+ let until_next_turn = Duration::UntilNextTurnOf {
+ player: PlayerScope::Controller,
+ };
+ for (name, oracle, types, subtypes) in [
+ (
+ "Nissa, Vital Force",
+ NISSA_VITAL_FORCE_ORACLE,
+ &["Legendary", "Planeswalker"][..],
+ &["Nissa"][..],
+ ),
+ (
+ "Sylvan Awakening",
+ SYLVAN_AWAKENING_ORACLE,
+ &["Sorcery"][..],
+ &[][..],
+ ),
+ (
+ "Wrenn and Realmbreaker",
+ WRENN_REALMBREAKER_ORACLE,
+ &["Legendary", "Planeswalker"][..],
+ &["Wrenn"][..],
+ ),
+ ] {
+ let parsed = parse(oracle, name, &[], types, subtypes);
+ assert_retained_type_duration(&parsed, name, CoreType::Land, None, until_next_turn.clone());
+ }
+
+ let awakener = parse(
+ AWAKENER_DRUID_ORACLE,
+ "Awakener Druid",
+ &[],
+ &["Creature"],
+ &["Human", "Druid"],
+ );
+ assert_retained_type_duration(
+ &awakener,
+ "Awakener Druid",
+ CoreType::Land,
+ None,
+ Duration::UntilHostLeavesPlay,
+ );
+
+ let hedge = parse(
+ HEDGE_WHISPERER_ORACLE,
+ "Hedge Whisperer",
+ &[],
+ &["Creature"],
+ &["Elf", "Druid"],
+ );
+ assert_retained_type_duration(
+ &hedge,
+ "Hedge Whisperer",
+ CoreType::Land,
+ None,
+ Duration::ForAsLongAs {
+ condition: StaticCondition::SourceIsTapped,
+ },
+ );
+
+ let cacophony = parse(
+ CACOPHONY_UNLEASHED_ORACLE,
+ "Cacophony Unleashed",
+ &[],
+ &["Legendary", "Enchantment"],
+ &[],
+ );
+ assert_retained_type_duration(
+ &cacophony,
+ "Cacophony Unleashed",
+ CoreType::Enchantment,
+ None,
+ Duration::UntilEndOfTurn,
+ );
+
+ let cavernous_maw = parse(
+ CAVERNOUS_MAW_ORACLE,
+ "Cavernous Maw",
+ &[],
+ &["Land"],
+ &["Cave"],
+ );
+ assert_retained_type_duration(
+ &cavernous_maw,
+ "Cavernous Maw",
+ CoreType::Land,
+ Some("Cave"),
+ Duration::UntilEndOfTurn,
+ );
+}
+
+/// CR 205.1b + CR 514.2 + CR 611.2a: the separate "It's still a land"
+/// sentence modifies the preceding animation; it does not create an independent
+/// permanent continuous effect. This exact shipped-card cast drives the parsed
+/// chain through resolution and cleanup. Reverting the duration binding leaves
+/// the retained-Land transient at `Permanent`, so the final assertion fails.
+#[test]
+fn retained_type_clause_expires_with_its_governing_animation() {
+ let mut scenario = GameScenario::new();
+ scenario.at_phase(Phase::PreCombatMain);
+ let land = scenario.add_basic_land(P0, ManaColor::Green);
+ let spell = scenario
+ .add_spell_to_hand_from_oracle(P0, "Disturbed Slumber", true, DISTURBED_SLUMBER_ORACLE)
+ .id();
+ let mut runner = scenario.build();
+
+ let outcome = runner.cast(spell).target_object(land).resolve();
+ assert!(
+ outcome
+ .state()
+ .transient_continuous_effects
+ .iter()
+ .any(|effect| {
+ effect.duration == Duration::UntilEndOfTurn
+ && matches!(effect.affected, TargetFilter::SpecificObject { id } if id == land)
+ && effect.modifications.iter().any(|modification| {
+ matches!(
+ modification,
+ ContinuousModification::AddType {
+ core_type: CoreType::Land
+ }
+ )
+ })
+ }),
+ "reach guard: the retained-Land clause must install an UntilEndOfTurn transient"
+ );
+
+ let mut events = Vec::new();
+ execute_cleanup(runner.state_mut(), &mut events);
+ evaluate_layers(runner.state_mut());
+
+ assert!(
+ !runner
+ .state()
+ .transient_continuous_effects
+ .iter()
+ .any(|effect| {
+ matches!(effect.affected, TargetFilter::SpecificObject { id } if id == land)
+ && effect.modifications.iter().any(|modification| {
+ matches!(
+ modification,
+ ContinuousModification::AddType {
+ core_type: CoreType::Land
+ }
+ )
+ })
+ }),
+ "CR 514.2: the retained-type transient must expire with the governing animation"
+ );
+}