From 9b9b795eb5c2952a3f9b5d451750bce4a543e533 Mon Sep 17 00:00:00 2001 From: alicewonderland-dev <300129165+alicewonderland-dev@users.noreply.github.com> Date: Thu, 20 Aug 2026 04:29:10 -0400 Subject: [PATCH 1/5] Refresh Cargo.lock after mtgish removal --- Cargo.lock | 253 ++--------------------------------------------------- 1 file changed, 9 insertions(+), 244 deletions(-) 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", From 3275bb9a0e87ff7c5f1a2a545c547133ab287239 Mon Sep 17 00:00:00 2001 From: alicewonderland-dev <300129165+alicewonderland-dev@users.noreply.github.com> Date: Thu, 20 Aug 2026 04:29:20 -0400 Subject: [PATCH 2/5] Add Princess Yue --- crates/engine/src/game/layers.rs | 15 +- crates/engine/src/parser/oracle_effect/mod.rs | 262 ++++++++-- .../src/parser/oracle_effect/subject.rs | 243 +++++++-- crates/engine/src/parser/oracle_trigger.rs | 110 +++++ .../engine/src/parser/oracle_trigger_tests.rs | 163 +++++- .../tests/integration/std_longtail_e.rs | 466 +++++++++++++++++- 6 files changed, 1180 insertions(+), 79 deletions(-) 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..4cafedac1d 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((), 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,91 @@ 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.", + "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..e802a94cac 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,98 @@ 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("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..29af20e5db 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -1910,6 +1910,159 @@ 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, + ), + ( + "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!(leading.condition.is_some(), "positive reach guard"); + 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.condition, None); + + let trailing = parse_trigger_line( + "When this creature dies, draw a card if she was a land.", + "Trailing Probe", + ); + assert_eq!(trailing.condition, None); + assert!( + trailing.execute.is_some(), + "trailing condition must remain in the effect pipeline" + ); +} + #[test] fn trigger_dies_if_it_was_enchanted_attaches_attachment_lookback() { let def = parse_trigger_line( @@ -15575,9 +15728,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..a4fafed113 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, + QuantityExpr, }; -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,461 @@ 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."; + +fn all_modifications(def: &AbilityDefinition) -> Vec<&ContinuousModification> { + let mut result = Vec::new(); + let mut cursor = Some(def); + while let Some(node) = cursor { + if let Effect::GenericEffect { + static_abilities, .. + } = node.effect.as_ref() + { + result.extend( + static_abilities + .iter() + .flat_map(|static_def| static_def.modifications.iter()), + ); + } + cursor = node.sub_ability.as_deref(); + } + result +} + +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 { .. } + )); +} + +/// 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" + ); +} From b8e73936c2d5254e99e8bf8d0f2c435b20a997cb Mon Sep 17 00:00:00 2001 From: alicewonderland-dev <300129165+alicewonderland-dev@users.noreply.github.com> Date: Thu, 20 Aug 2026 05:52:07 -0400 Subject: [PATCH 3/5] Test retained-type duration classes --- .../engine/src/parser/oracle_trigger_tests.rs | 94 ++++++++- .../tests/integration/std_longtail_e.rs | 193 +++++++++++++++++- 2 files changed, 283 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index 29af20e5db..dd3c69b0a8 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -2037,7 +2037,14 @@ fn gendered_past_type_condition_does_not_hoist_outside_leading_dies_position() { "When this creature dies, if she was a land, draw a card.", "Leading Probe", ); - assert!(leading.condition.is_some(), "positive reach guard"); + assert_dies_event_object_filter( + leading + .condition + .as_ref() + .expect("positive leading-dies reach guard"), + &[TypeFilter::Land], + false, + ); assert!(matches!( leading .execute @@ -2050,16 +2057,97 @@ fn gendered_past_type_condition_does_not_hoist_outside_leading_dies_position() { "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.execute.is_some(), - "trailing condition must remain in the effect pipeline" + 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 ); } diff --git a/crates/engine/tests/integration/std_longtail_e.rs b/crates/engine/tests/integration/std_longtail_e.rs index a4fafed113..40af1662c6 100644 --- a/crates/engine/tests/integration/std_longtail_e.rs +++ b/crates/engine/tests/integration/std_longtail_e.rs @@ -339,7 +339,7 @@ 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, ManaProduction, - QuantityExpr, + PlayerScope, QuantityExpr, StaticCondition, }; use engine::types::card_type::{CoreType, Supertype}; use engine::types::keywords::Keyword; @@ -1514,6 +1514,13 @@ const TENTH_DISTRICT_HERO_ORACLE: &str = "{1}{W}, Collect evidence 2: This creat 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(); @@ -1534,6 +1541,92 @@ fn all_modifications(def: &AbilityDefinition) -> Vec<&ContinuousModification> { 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, @@ -1899,6 +1992,104 @@ fn fang_dies_filter_adds_spirit_only_when_it_was_absent() { )); } +/// 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 From 03e0a0e4a948715dfa1359115b50e7471465da0b Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 20 Aug 2026 05:05:14 -0700 Subject: [PATCH 4/5] fix(PR-7566): cover apostrophe parser siblings --- crates/engine/src/parser/oracle_effect/mod.rs | 3 ++- crates/engine/src/parser/oracle_trigger.rs | 1 + crates/engine/src/parser/oracle_trigger_tests.rs | 8 ++++++++ crates/engine/tests/integration/std_longtail_e.rs | 7 ++++--- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index 4cafedac1d..ccb04116f9 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -11556,7 +11556,7 @@ fn parse_retained_type_clause(tp: TextPair) -> Option (false, format!("{article}{descriptor}")) } else { let (_, descriptor) = nom_on_lower(tp.original, tp.lower, |input| { - value((), tag("they're still ")).parse(input) + alt((tag("they're still "), tag("they’re still "))).parse(input) })?; (true, descriptor.to_string()) }; @@ -36550,6 +36550,7 @@ mod gendered_still_type_tests { "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())) diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index e802a94cac..f224fbcc5a 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -6182,6 +6182,7 @@ fn parse_gendered_dies_event_object_condition<'a>( .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 ")), )) diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index dd3c69b0a8..f59b7fb644 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -1981,6 +1981,14 @@ fn gendered_dies_condition_factors_pronoun_copula_article_and_descriptor() { vec![TypeFilter::Artifact], true, ), + ( + "he", + "wasn’t", + "an", + "Artifact", + vec![TypeFilter::Artifact], + true, + ), ( "she", "was not", diff --git a/crates/engine/tests/integration/std_longtail_e.rs b/crates/engine/tests/integration/std_longtail_e.rs index 40af1662c6..62113ba21f 100644 --- a/crates/engine/tests/integration/std_longtail_e.rs +++ b/crates/engine/tests/integration/std_longtail_e.rs @@ -1524,8 +1524,8 @@ const CAVERNOUS_MAW_ORACLE: &str = "{T}: Add {C}.\n{2}: This land becomes a 3/3 fn all_modifications(def: &AbilityDefinition) -> Vec<&ContinuousModification> { let mut result = Vec::new(); - let mut cursor = Some(def); - while let Some(node) = cursor { + let mut pending = vec![def]; + while let Some(node) = pending.pop() { if let Effect::GenericEffect { static_abilities, .. } = node.effect.as_ref() @@ -1536,7 +1536,8 @@ fn all_modifications(def: &AbilityDefinition) -> Vec<&ContinuousModification> { .flat_map(|static_def| static_def.modifications.iter()), ); } - cursor = node.sub_ability.as_deref(); + pending.extend(node.sub_ability.as_deref()); + pending.extend(node.else_ability.as_deref()); } result } From 5548de9b2c0704c44b7d971b9bc714f9cbfc2d9b Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 20 Aug 2026 06:03:16 -0700 Subject: [PATCH 5/5] fix(PR-7566): preserve original descriptor remainder --- crates/engine/src/parser/oracle_effect/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/mod.rs b/crates/engine/src/parser/oracle_effect/mod.rs index ccb04116f9..053a9885e4 100644 --- a/crates/engine/src/parser/oracle_effect/mod.rs +++ b/crates/engine/src/parser/oracle_effect/mod.rs @@ -11555,8 +11555,8 @@ fn parse_retained_type_clause(tp: TextPair) -> Option } (false, format!("{article}{descriptor}")) } else { - let (_, descriptor) = nom_on_lower(tp.original, tp.lower, |input| { - alt((tag("they're still "), tag("they’re still "))).parse(input) + 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()) };