From 63b8efbee36ee5075d172647cdd39f55fd624059 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 3 Jul 2026 16:40:38 +0200 Subject: [PATCH 1/9] fix(attach): honor protection attachment exemptions (#4964) Emit and enforce CR 702.16p/n statics for Benevolent Blessing-style protection grants so choosing a matching color does not detach your already-attached Auras and Equipment. Co-authored-by: Cursor --- crates/engine/src/game/effects/attach.rs | 80 +++++++++++++++++++ crates/engine/src/game/static_abilities.rs | 7 ++ .../src/parser/oracle_static/keyword_grant.rs | 49 ++++++++++-- .../engine/src/parser/oracle_static/tests.rs | 32 ++++++-- crates/engine/src/types/statics.rs | 20 +++++ 5 files changed, 176 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/game/effects/attach.rs b/crates/engine/src/game/effects/attach.rs index 41d89ee2ba..97ecf54cd0 100644 --- a/crates/engine/src/game/effects/attach.rs +++ b/crates/engine/src/game/effects/attach.rs @@ -601,6 +601,15 @@ pub(crate) fn attachment_illegality( state.objects.get(&attachment_id), ) { if crate::game::keywords::protection_prevents_from(host, attachment) { + if protection_doesnt_remove_attached_exemption( + state, + host_id, + attachment_id, + attacher_is_aura, + attacher_is_equipment, + ) { + return None; + } return Some(AttachIllegality::Protection); } } @@ -608,6 +617,39 @@ pub(crate) fn attachment_illegality( None } +/// CR 702.16n / CR 702.16p: already-attached Auras/Equipment exempt from +/// protection-based detachment when the host carries the matching exemption +/// static from the protection-granting effect. +fn protection_doesnt_remove_attached_exemption( + state: &GameState, + host_id: ObjectId, + attachment_id: ObjectId, + attacher_is_aura: bool, + attacher_is_equipment: bool, +) -> bool { + let Some(host) = state.objects.get(&host_id) else { + return false; + }; + let Some(attachment) = state.objects.get(&attachment_id) else { + return false; + }; + if !host.attachments.contains(&attachment_id) { + return false; + } + + crate::game::functioning_abilities::active_static_definitions(state, host).any(|def| match def + .mode + { + crate::types::statics::StaticMode::ProtectionDoesntRemoveThisAura => { + attacher_is_aura && attachment.controller == host.controller + } + crate::types::statics::StaticMode::ProtectionDoesntRemoveControlledAttachments => { + (attacher_is_aura || attacher_is_equipment) && attachment.controller == host.controller + } + _ => false, + }) +} + /// CR 301.5 + CR 303.4 + CR 701.3a: True unless `host_id` is forbidden by a /// positive "can be attached only to {filter}" restriction on `attachment_id`. /// @@ -873,6 +915,44 @@ mod tests { assert!(!can_attach_to_object(&state, aura, creature)); } + #[test] + fn attachment_illegality_protection_exemption_keeps_attached_controlled_aura() { + // CR 702.16p (issue #4964): Benevolent Blessing — protection from the + // chosen color must not detach your already-attached Auras/Equipment. + let mut state = setup(); + let aura = spawn_with_subtype(&mut state, "Benevolent Blessing", "Aura"); + { + let obj = state.objects.get_mut(&aura).unwrap(); + obj.card_types.core_types.push(CoreType::Enchantment); + obj.color.push(crate::types::mana::ManaColor::White); + } + let creature = spawn_creature(&mut state, "Bear"); + state + .objects + .get_mut(&creature) + .unwrap() + .attachments + .push(aura); + state.objects.get_mut(&creature).unwrap().keywords.push( + crate::types::keywords::Keyword::Protection( + crate::types::keywords::ProtectionTarget::Color( + crate::types::mana::ManaColor::White, + ), + ), + ); + state + .objects + .get_mut(&creature) + .unwrap() + .static_definitions + .push(StaticDefinition::new( + StaticMode::ProtectionDoesntRemoveControlledAttachments, + )); + + assert_eq!(attachment_illegality(&state, aura, creature), None); + assert!(can_attach_to_object(&state, aura, creature)); + } + #[test] fn attachment_illegality_cant_be_enchanted_blocks_aura() { // CR 303.4c: other applicable effects can make an Aura's host illegal. diff --git a/crates/engine/src/game/static_abilities.rs b/crates/engine/src/game/static_abilities.rs index 77f2b912d0..7ea230966d 100644 --- a/crates/engine/src/game/static_abilities.rs +++ b/crates/engine/src/game/static_abilities.rs @@ -214,6 +214,13 @@ pub fn build_static_registry() -> HashMap { // CR 704.5j: LegendRuleDoesntApply — affected permanents are excluded from // the legend-rule SBA. Runtime enforcement is in sba.rs::legend_rule_exempt(). registry.insert(StaticMode::LegendRuleDoesntApply, handle_rule_mod); + // CR 702.16n / CR 702.16p: protection attachment exemptions — enforced in + // effects/attach.rs::attachment_illegality via active static scan. + registry.insert(StaticMode::ProtectionDoesntRemoveThisAura, handle_rule_mod); + registry.insert( + StaticMode::ProtectionDoesntRemoveControlledAttachments, + handle_rule_mod, + ); // CR 702.179e: Card-specific rule modification allowing speed to exceed 4. registry.insert(StaticMode::SpeedCanIncreaseBeyondFour, handle_rule_mod); // CR 609.4b: "You may spend mana as though it were mana of any color." diff --git a/crates/engine/src/parser/oracle_static/keyword_grant.rs b/crates/engine/src/parser/oracle_static/keyword_grant.rs index 641cbeab99..0e91d5165c 100644 --- a/crates/engine/src/parser/oracle_static/keyword_grant.rs +++ b/crates/engine/src/parser/oracle_static/keyword_grant.rs @@ -1400,13 +1400,30 @@ pub(crate) fn parse_continuous_modifications(text: &str) -> Vec + { + (first, Some(rest.trim())) + } + _ => (keyword_text, None), + }; + for part in split_keyword_list(keyword_only.trim().trim_end_matches('.')) { push_grant_clause_modifications( &mut modifications, part.as_ref(), where_x_expression.as_deref(), ); } + if let Some(trailing) = trailing_exemption { + push_protection_attachment_exemption_modifications(&mut modifications, trailing); + } } // CR 613.1f: Pre-quote keyword recovery for compound lines like Swashbuckler's @@ -1461,11 +1478,12 @@ pub(crate) fn push_grant_clause_modifications( // parse_quoted_ability_modifications at :798) before extract_keyword_clause // runs, so any ". " here can only introduce a trailing inert prose sentence // (e.g. Benevolent Blessing's SBA-exemption "This effect doesn't remove ..."). - // Drop it so the keyword sentence reaches map_keyword clean. - let part = + // Strip it from the keyword token, but emit the matching protection-attachment + // exemption static when recognized. + let (part, trailing_exemption) = match super::oracle_nom::bridge::split_once_on_lower(part, &part.to_lowercase(), ". ") { - Some((first, _)) => first, - None => part, + Some((first, rest)) => (first, Some(rest.trim())), + None => (part, None), }; let part_trimmed = part.trim().trim_end_matches('.'); @@ -1538,6 +1556,9 @@ pub(crate) fn push_grant_clause_modifications( if let Some(kw) = map_keyword(part_trimmed) { modifications.push(ContinuousModification::AddKeyword { keyword: kw }); + if let Some(trailing) = trailing_exemption { + push_protection_attachment_exemption_modifications(modifications, trailing); + } return; } @@ -1565,6 +1586,24 @@ pub(crate) fn push_grant_clause_modifications( } } +fn push_protection_attachment_exemption_modifications( + modifications: &mut Vec, + trailing: &str, +) { + let lower = trailing.to_lowercase(); + if lower.contains("doesn't remove this aura") || lower.contains("does not remove this aura") { + modifications.push(ContinuousModification::AddStaticMode { + mode: StaticMode::ProtectionDoesntRemoveThisAura, + }); + } else if lower.contains("doesn't remove auras and equipment you control") + || lower.contains("does not remove auras and equipment you control") + { + modifications.push(ContinuousModification::AddStaticMode { + mode: StaticMode::ProtectionDoesntRemoveControlledAttachments, + }); + } +} + /// Extract quoted ability text from Oracle text and parse each into a typed AbilityDefinition. /// /// Quoted abilities like `"{T}: Add two mana of any one color."` are parsed by splitting diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index 777234e9a5..99752cbb83 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -21928,6 +21928,15 @@ fn protection_chosen_color_drops_trailing_sba_exemption_benevolent_blessing() { }), "expected Protection(ChosenColor), got {mods:?}" ); + assert!( + mods.iter().any(|m| matches!( + m, + ContinuousModification::AddStaticMode { + mode: StaticMode::ProtectionDoesntRemoveControlledAttachments, + } + )), + "expected ProtectionDoesntRemoveControlledAttachments, got {mods:?}" + ); assert!( !mods.iter().any(|m| matches!( m, @@ -21968,7 +21977,8 @@ fn protection_chosen_color_drops_trailing_this_aura_exemption() { /// Building-block: `push_grant_clause_modifications` must drop the trailing prose /// sentence directly on the bare keyword leg and emit one -/// `Protection(ChosenColor)`. (fail-if-reverted) +/// `Protection(ChosenColor)` plus the CR 702.16p attachment exemption static. +/// (fail-if-reverted) #[test] fn push_grant_clause_drops_trailing_sentence_chosen_color() { use crate::types::keywords::{Keyword, ProtectionTarget}; @@ -21976,15 +21986,23 @@ fn push_grant_clause_drops_trailing_sentence_chosen_color() { let mut mods = Vec::new(); push_grant_clause_modifications( &mut mods, - "protection from the chosen color. this effect doesn't remove auras", + "protection from the chosen color. This effect doesn't remove Auras and Equipment you control that are already attached to it.", None, ); - assert_eq!( - mods, - vec![ContinuousModification::AddKeyword { + assert!( + mods.contains(&ContinuousModification::AddKeyword { keyword: Keyword::Protection(ProtectionTarget::ChosenColor), - }], - "expected exactly one Protection(ChosenColor), got {mods:?}" + }), + "expected Protection(ChosenColor), got {mods:?}" + ); + assert!( + mods.iter().any(|m| matches!( + m, + ContinuousModification::AddStaticMode { + mode: StaticMode::ProtectionDoesntRemoveControlledAttachments, + } + )), + "expected exemption static, got {mods:?}" ); } diff --git a/crates/engine/src/types/statics.rs b/crates/engine/src/types/statics.rs index 48b8903868..5c14746dd7 100644 --- a/crates/engine/src/types/statics.rs +++ b/crates/engine/src/types/statics.rs @@ -1627,6 +1627,14 @@ pub enum StaticMode { /// Try-My-Deck Elemental, ...). Enforced per-permanent in `sba.rs` via /// `check_static_ability` with the candidate as the target object. LegendRuleDoesntApply, + /// CR 702.16n: A protection-granting Aura whose printed text includes + /// "This effect doesn't remove this Aura" does not detach itself when the + /// chosen/host color matches (Cho-Manno's Blessing, Pentarch Ward). + ProtectionDoesntRemoveThisAura, + /// CR 702.16p: A protection grant whose printed text includes "doesn't remove + /// Auras and Equipment you control that are already attached" keeps those + /// attachments when protection would otherwise detach them (Benevolent Blessing). + ProtectionDoesntRemoveControlledAttachments, /// Speed may increase beyond 4, and 4+ still counts as max speed for that player. SpeedCanIncreaseBeyondFour, /// CR 118.12a: Defiler cycle — "As an additional cost to cast [color] permanent @@ -2082,6 +2090,8 @@ impl StaticMode { | StaticMode::CantWinTheGame | StaticMode::CantLoseTheGame | StaticMode::LegendRuleDoesntApply + | StaticMode::ProtectionDoesntRemoveThisAura + | StaticMode::ProtectionDoesntRemoveControlledAttachments | StaticMode::SpeedCanIncreaseBeyondFour | StaticMode::DefilerCostReduction { .. } | StaticMode::SkipStep { .. } @@ -2442,6 +2452,12 @@ impl fmt::Display for StaticMode { StaticMode::CantWinTheGame => write!(f, "CantWinTheGame"), StaticMode::CantLoseTheGame => write!(f, "CantLoseTheGame"), StaticMode::LegendRuleDoesntApply => write!(f, "LegendRuleDoesntApply"), + StaticMode::ProtectionDoesntRemoveThisAura => { + write!(f, "ProtectionDoesntRemoveThisAura") + } + StaticMode::ProtectionDoesntRemoveControlledAttachments => { + write!(f, "ProtectionDoesntRemoveControlledAttachments") + } StaticMode::SpeedCanIncreaseBeyondFour => write!(f, "SpeedCanIncreaseBeyondFour"), StaticMode::DefilerCostReduction { color, .. } => { write!(f, "DefilerCostReduction({color:?})") @@ -2883,6 +2899,10 @@ impl FromStr for StaticMode { "CantWinTheGame" => StaticMode::CantWinTheGame, "CantLoseTheGame" => StaticMode::CantLoseTheGame, "LegendRuleDoesntApply" => StaticMode::LegendRuleDoesntApply, + "ProtectionDoesntRemoveThisAura" => StaticMode::ProtectionDoesntRemoveThisAura, + "ProtectionDoesntRemoveControlledAttachments" => { + StaticMode::ProtectionDoesntRemoveControlledAttachments + } "CanAttackWithDefender" => StaticMode::CanAttackWithDefender, // CR 509.1b + CR 609.4 + CR 702.14c: bare form = all-landwalk canceller. "IgnoreLandwalkForBlocking" => { From 1b476f31ee076a1ab67f4ced8b4d2650acbef51b Mon Sep 17 00:00:00 2001 From: root Date: Fri, 3 Jul 2026 18:09:00 +0200 Subject: [PATCH 2/9] fix(parser): nom exemption parsers and CR 702.16n/p attach (#4964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace forbidden .contains() dispatch with nom/scan_contains helpers, correct exemption controller checks against the granting aura, and add CR 702.16c–702.16p commentary on the protection attach gate. Co-authored-by: Cursor --- crates/engine/src/game/effects/attach.rs | 85 ++++++++++++++++--- .../src/parser/oracle_static/keyword_grant.rs | 46 ++++++---- 2 files changed, 104 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/game/effects/attach.rs b/crates/engine/src/game/effects/attach.rs index 97ecf54cd0..3a1a46c298 100644 --- a/crates/engine/src/game/effects/attach.rs +++ b/crates/engine/src/game/effects/attach.rs @@ -596,6 +596,9 @@ pub(crate) fn attachment_illegality( // being attached to the protected permanent. // CR 702.16d: Protection from a quality prevents Equipment or Fortifications // of that quality from being attached to the protected permanent. + // CR 702.16c–702.16d: Protection from a quality prevents new attachments of + // that quality, but CR 702.16n/702.16p exempt already-attached Auras and + // Equipment named by the protection-granting effect. if let (Some(host), Some(attachment)) = ( state.objects.get(&host_id), state.objects.get(&attachment_id), @@ -630,23 +633,71 @@ fn protection_doesnt_remove_attached_exemption( let Some(host) = state.objects.get(&host_id) else { return false; }; - let Some(attachment) = state.objects.get(&attachment_id) else { - return false; - }; if !host.attachments.contains(&attachment_id) { return false; } - crate::game::functioning_abilities::active_static_definitions(state, host).any(|def| match def - .mode + let host_has_this_aura = + crate::game::functioning_abilities::active_static_definitions(state, host).any(|def| { + matches!( + def.mode, + crate::types::statics::StaticMode::ProtectionDoesntRemoveThisAura + ) + }); + let host_has_controlled = + crate::game::functioning_abilities::active_static_definitions(state, host).any(|def| { + matches!( + def.mode, + crate::types::statics::StaticMode::ProtectionDoesntRemoveControlledAttachments + ) + }); + + if host_has_this_aura + && attacher_is_aura + && aura_grants_protection_attachment_exemption( + state, + attachment_id, + crate::types::statics::StaticMode::ProtectionDoesntRemoveThisAura, + ) { - crate::types::statics::StaticMode::ProtectionDoesntRemoveThisAura => { - attacher_is_aura && attachment.controller == host.controller - } - crate::types::statics::StaticMode::ProtectionDoesntRemoveControlledAttachments => { - (attacher_is_aura || attacher_is_equipment) && attachment.controller == host.controller - } - _ => false, + return true; + } + + if host_has_controlled && (attacher_is_aura || attacher_is_equipment) { + let Some(attachment) = state.objects.get(&attachment_id) else { + return false; + }; + return host.attachments.iter().any(|&grantor_id| { + aura_grants_protection_attachment_exemption( + state, + grantor_id, + crate::types::statics::StaticMode::ProtectionDoesntRemoveControlledAttachments, + ) && state + .objects + .get(&grantor_id) + .is_some_and(|grantor| grantor.controller == attachment.controller) + }); + } + + false +} + +fn aura_grants_protection_attachment_exemption( + state: &GameState, + aura_id: ObjectId, + mode: crate::types::statics::StaticMode, +) -> bool { + let Some(aura) = state.objects.get(&aura_id) else { + return false; + }; + crate::game::functioning_abilities::active_static_definitions(state, aura).any(|def| { + def.modifications.iter().any(|m| { + matches!( + m, + crate::types::ability::ContinuousModification::AddStaticMode { mode: m } + if *m == mode + ) + }) }) } @@ -940,6 +991,16 @@ mod tests { ), ), ); + state + .objects + .get_mut(&aura) + .unwrap() + .static_definitions + .push(StaticDefinition::continuous().modifications(vec![ + crate::types::ability::ContinuousModification::AddStaticMode { + mode: StaticMode::ProtectionDoesntRemoveControlledAttachments, + }, + ])); state .objects .get_mut(&creature) diff --git a/crates/engine/src/parser/oracle_static/keyword_grant.rs b/crates/engine/src/parser/oracle_static/keyword_grant.rs index 0e91d5165c..4d6c83704b 100644 --- a/crates/engine/src/parser/oracle_static/keyword_grant.rs +++ b/crates/engine/src/parser/oracle_static/keyword_grant.rs @@ -1406,10 +1406,7 @@ pub(crate) fn parse_continuous_modifications(text: &str) -> Vec - { + Some((first, rest)) if trailing_mentions_protection_attachment_exemption(rest) => { (first, Some(rest.trim())) } _ => (keyword_text, None), @@ -1586,21 +1583,40 @@ pub(crate) fn push_grant_clause_modifications( } } +fn trailing_mentions_protection_attachment_exemption(text: &str) -> bool { + nom_primitives::scan_contains(text, "doesn't remove") + || nom_primitives::scan_contains(text, "does not remove") +} + +fn parse_protection_attachment_exemption_trailing(text: &str) -> Option { + let lower = text.trim().to_ascii_lowercase(); + let mut this_aura = alt(( + tag::<_, _, OracleError<'_>>("this effect doesn't remove this aura"), + tag("this effect does not remove this aura"), + tag("doesn't remove this aura"), + tag("does not remove this aura"), + )); + if this_aura.parse(lower.as_str()).is_ok() { + return Some(StaticMode::ProtectionDoesntRemoveThisAura); + } + let mut controlled = alt(( + tag::<_, _, OracleError<'_>>("this effect doesn't remove auras and equipment you control"), + tag("this effect does not remove auras and equipment you control"), + tag("doesn't remove auras and equipment you control"), + tag("does not remove auras and equipment you control"), + )); + if controlled.parse(lower.as_str()).is_ok() { + return Some(StaticMode::ProtectionDoesntRemoveControlledAttachments); + } + None +} + fn push_protection_attachment_exemption_modifications( modifications: &mut Vec, trailing: &str, ) { - let lower = trailing.to_lowercase(); - if lower.contains("doesn't remove this aura") || lower.contains("does not remove this aura") { - modifications.push(ContinuousModification::AddStaticMode { - mode: StaticMode::ProtectionDoesntRemoveThisAura, - }); - } else if lower.contains("doesn't remove auras and equipment you control") - || lower.contains("does not remove auras and equipment you control") - { - modifications.push(ContinuousModification::AddStaticMode { - mode: StaticMode::ProtectionDoesntRemoveControlledAttachments, - }); + if let Some(mode) = parse_protection_attachment_exemption_trailing(trailing) { + modifications.push(ContinuousModification::AddStaticMode { mode }); } } From 57d3f1e02d7a2505f1057fba00b68563b2bab639 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 3 Jul 2026 18:54:01 +0200 Subject: [PATCH 3/9] test(parser/attach): lock Spectra Ward + source-specific aura exemption (#4964) Add a Spectra Ward regression proving the exemption splitter never duplicates sibling protection grants, and a two-Aura attach regression proving CR 702.16n's "this Aura" exemption protects only the granting Aura. Co-authored-by: Cursor --- crates/engine/src/game/effects/attach.rs | 59 +++++++++++++++++++ .../engine/src/parser/oracle_static/tests.rs | 49 +++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/crates/engine/src/game/effects/attach.rs b/crates/engine/src/game/effects/attach.rs index 3a1a46c298..01c7009922 100644 --- a/crates/engine/src/game/effects/attach.rs +++ b/crates/engine/src/game/effects/attach.rs @@ -1014,6 +1014,65 @@ mod tests { assert!(can_attach_to_object(&state, aura, creature)); } + /// CR 702.16n (issue #4964 review): the "this Aura" exemption + /// (`ProtectionDoesntRemoveThisAura`, Pentarch Ward class) is + /// source-specific — it protects ONLY the Aura that granted it. A second + /// controlled Aura attached to the same protected host is still detached by + /// protection. Two controlled Auras on one protected host: the granting Aura + /// is exempt, the sibling Aura is not. + #[test] + fn attachment_illegality_this_aura_exemption_is_source_specific() { + let mut state = setup(); + + // Granting Aura: carries the "doesn't remove this Aura" grant AND is the + // aura that supplies the host's protection static. + let granting_aura = spawn_with_subtype(&mut state, "Pentarch Ward", "Aura"); + { + let obj = state.objects.get_mut(&granting_aura).unwrap(); + obj.card_types.core_types.push(CoreType::Enchantment); + obj.color.push(crate::types::mana::ManaColor::White); + obj.static_definitions + .push(StaticDefinition::continuous().modifications(vec![ + crate::types::ability::ContinuousModification::AddStaticMode { + mode: StaticMode::ProtectionDoesntRemoveThisAura, + }, + ])); + } + + // Sibling Aura: same controller, plain white Aura, NO exemption grant. + let sibling_aura = spawn_with_subtype(&mut state, "Pacifism", "Aura"); + { + let obj = state.objects.get_mut(&sibling_aura).unwrap(); + obj.card_types.core_types.push(CoreType::Enchantment); + obj.color.push(crate::types::mana::ManaColor::White); + } + + let creature = spawn_creature(&mut state, "Bear"); + { + let obj = state.objects.get_mut(&creature).unwrap(); + obj.attachments.push(granting_aura); + obj.attachments.push(sibling_aura); + obj.keywords + .push(crate::types::keywords::Keyword::Protection( + crate::types::keywords::ProtectionTarget::Color( + crate::types::mana::ManaColor::White, + ), + )); + // Host carries the applied "this Aura" static from the granting Aura. + obj.static_definitions.push(StaticDefinition::new( + StaticMode::ProtectionDoesntRemoveThisAura, + )); + } + + // Granting Aura is exempt; sibling Aura is still removed by protection. + assert_eq!(attachment_illegality(&state, granting_aura, creature), None); + assert_eq!( + attachment_illegality(&state, sibling_aura, creature), + Some(AttachIllegality::Protection), + "a sibling controlled Aura must NOT share the source Aura's CR 702.16n exemption" + ); + } + #[test] fn attachment_illegality_cant_be_enchanted_blocks_aura() { // CR 303.4c: other applicable effects can make an Aura's host illegal. diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index 99752cbb83..c5e35756d3 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -22039,6 +22039,55 @@ fn protection_chosen_color_duration_form_glory() { ); } +/// CR 702.16 (issue #4964 review): the Benevolent Blessing trailing-prose +/// exemption splitter must NOT touch sibling protection grants that have no +/// SBA-exemption sentence. Spectra Ward ("Enchanted creature gets +2/+2 and has +/// protection from the color of your choice.") must still lower to exactly ONE +/// `Protection(ChosenColor)` — not a duplicated/expanded set. (fail-if-reverted) +#[test] +fn spectra_ward_chosen_color_not_duplicated_by_exemption_splitter() { + use crate::types::keywords::{Keyword, ProtectionTarget}; + + let mods = parse_continuous_modifications( + "Enchanted creature gets +2/+2 and has protection from the color of your choice.", + ); + let protection_mods: Vec<_> = mods + .iter() + .filter(|m| { + matches!( + m, + ContinuousModification::AddKeyword { + keyword: Keyword::Protection(_) + } + ) + }) + .collect(); + assert_eq!( + protection_mods.len(), + 1, + "Spectra Ward must grant exactly one protection, got {mods:?}" + ); + assert!( + matches!( + protection_mods[0], + ContinuousModification::AddKeyword { + keyword: Keyword::Protection(ProtectionTarget::ChosenColor), + } + ), + "expected Protection(ChosenColor), got {mods:?}" + ); + assert!( + !mods.iter().any(|m| matches!( + m, + ContinuousModification::AddStaticMode { + mode: StaticMode::ProtectionDoesntRemoveThisAura + | StaticMode::ProtectionDoesntRemoveControlledAttachments, + } + )), + "Spectra Ward has no attachment exemption, got {mods:?}" + ); +} + /// No-regression: single-color protection still parses to Color(Red). #[test] fn protection_single_color_unchanged() { From cbff90bc942dd675474a312542f70b5308674c78 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 3 Jul 2026 20:11:28 +0200 Subject: [PATCH 4/9] fix(parser): gate exemption peel on recognized exemption; keep Spectra Ward parse (#4964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-split exemption peel fired on any trailing "doesn't remove" sentence, which cleaned Spectra Ward's "protection from each color. This effect doesn't remove Auras." and let split_keyword_list fan the leg out into multiple protection grants — an unrelated change from origin/main. Gate the peel on a recognized attachment exemption (parse_protection_attachment_exemption_trailing) so unrecognized tails stay glued and the protection parse is unchanged. Replace the fragment-based unit test with production-line regressions (Spectra Ward, Benevolent Blessing, Ward of Lights) parsed through parse_static_line_multi, mirroring the coverage-parse-diff card-data path. Co-authored-by: Cursor --- .../src/parser/oracle_static/keyword_grant.rs | 20 ++- .../engine/src/parser/oracle_static/tests.rs | 133 ++++++++++++++---- 2 files changed, 122 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/parser/oracle_static/keyword_grant.rs b/crates/engine/src/parser/oracle_static/keyword_grant.rs index 4d6c83704b..69bb6fbd56 100644 --- a/crates/engine/src/parser/oracle_static/keyword_grant.rs +++ b/crates/engine/src/parser/oracle_static/keyword_grant.rs @@ -1400,13 +1400,26 @@ pub(crate) fn parse_continuous_modifications(text: &str) -> Vec { + Some((first, rest)) + if parse_protection_attachment_exemption_trailing(rest).is_some() => + { (first, Some(rest.trim())) } _ => (keyword_text, None), @@ -1583,11 +1596,6 @@ pub(crate) fn push_grant_clause_modifications( } } -fn trailing_mentions_protection_attachment_exemption(text: &str) -> bool { - nom_primitives::scan_contains(text, "doesn't remove") - || nom_primitives::scan_contains(text, "does not remove") -} - fn parse_protection_attachment_exemption_trailing(text: &str) -> Option { let lower = text.trim().to_ascii_lowercase(); let mut this_aura = alt(( diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index c5e35756d3..e30caf2345 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -22039,20 +22039,22 @@ fn protection_chosen_color_duration_form_glory() { ); } -/// CR 702.16 (issue #4964 review): the Benevolent Blessing trailing-prose -/// exemption splitter must NOT touch sibling protection grants that have no -/// SBA-exemption sentence. Spectra Ward ("Enchanted creature gets +2/+2 and has -/// protection from the color of your choice.") must still lower to exactly ONE -/// `Protection(ChosenColor)` — not a duplicated/expanded set. (fail-if-reverted) -#[test] -fn spectra_ward_chosen_color_not_duplicated_by_exemption_splitter() { - use crate::types::keywords::{Keyword, ProtectionTarget}; +/// Aggregate every `ContinuousModification` from every `StaticDefinition` the +/// production static-line parser (`parse_static_line_multi`, which strips +/// reminder text exactly as the card-data export does) produces for `line`. +/// This mirrors the parse-tree the `coverage-parse-diff` sticky is built from, +/// so these tests would fail on the sticky's reported regressions — unlike a +/// bare `parse_continuous_modifications` fragment which skips reminder-stripping. +fn production_line_modifications(line: &str) -> Vec { + parse_static_line_multi(line) + .into_iter() + .flat_map(|def| def.modifications) + .collect() +} - let mods = parse_continuous_modifications( - "Enchanted creature gets +2/+2 and has protection from the color of your choice.", - ); - let protection_mods: Vec<_> = mods - .iter() +fn count_protection_grants(mods: &[ContinuousModification]) -> usize { + use crate::types::keywords::Keyword; + mods.iter() .filter(|m| { matches!( m, @@ -22061,30 +22063,111 @@ fn spectra_ward_chosen_color_not_duplicated_by_exemption_splitter() { } ) }) - .collect(); + .count() +} + +/// CR 702.16 (issue #4964 review): the Benevolent Blessing trailing-prose +/// exemption splitter must NOT change the parse of a sibling protection grant +/// whose trailing sentence is NOT a recognized attachment exemption. This uses +/// Spectra Ward's *production* Oracle line verbatim (MTGJSON `AtomicCards`: +/// "gets +2/+2 and has protection from each color. This effect doesn't remove +/// Auras." + reminder) through the production static-line entry — the earlier +/// fragment test hid the regression because it never exercised the "each color" +/// fan-out that the exemption peel was inadvertently unblocking. The unrecognized +/// "... doesn't remove Auras." tail must stay glued so this still lowers to the +/// same single protection grant as `origin/main` (never the five-color WUBRG +/// fan-out), and emits NO attachment-exemption static. (fail-if-reverted: +/// peeling on mere "doesn't remove" reverts this to the fanned-out set.) +#[test] +fn spectra_ward_production_line_protection_not_duplicated_by_exemption_splitter() { + let mods = production_line_modifications( + "Enchanted creature gets +2/+2 and has protection from each color. This effect doesn't remove Auras. (It can't be blocked, targeted, or dealt damage by anything that's white, blue, black, red, or green.)", + ); + assert_eq!( + count_protection_grants(&mods), + 1, + "Spectra Ward's production line must keep its single protection grant \ + (the exemption peel must not unblock the each-color fan-out), got {mods:?}" + ); + assert!( + !mods.iter().any(|m| matches!( + m, + ContinuousModification::AddStaticMode { + mode: StaticMode::ProtectionDoesntRemoveThisAura + | StaticMode::ProtectionDoesntRemoveControlledAttachments, + } + )), + "Spectra Ward's \"doesn't remove Auras\" is not a modeled attachment \ + exemption and must emit no exemption static, got {mods:?}" + ); +} + +/// CR 702.16p (issue #4964): the *production* Benevolent Blessing line — whose +/// exemption sentence "This effect doesn't remove Auras and Equipment you +/// control that are already attached to it." carries an inner " and " — must +/// peel cleanly to a single `Protection(ChosenColor)` grant plus the +/// `ProtectionDoesntRemoveControlledAttachments` static. Without the pre-split +/// peel, `split_keyword_list` fractures the sentence on "Auras and Equipment" +/// into bogus legs. (This is the card the whole PR exists for.) +#[test] +fn benevolent_blessing_production_line_emits_controlled_attachment_exemption() { + use crate::types::keywords::{Keyword, ProtectionTarget}; + + let mods = production_line_modifications( + "Enchanted creature has protection from the chosen color. This effect doesn't remove Auras and Equipment you control that are already attached to it.", + ); assert_eq!( - protection_mods.len(), + count_protection_grants(&mods), 1, - "Spectra Ward must grant exactly one protection, got {mods:?}" + "expected exactly one protection grant, got {mods:?}" ); assert!( - matches!( - protection_mods[0], - ContinuousModification::AddKeyword { - keyword: Keyword::Protection(ProtectionTarget::ChosenColor), + mods.contains(&ContinuousModification::AddKeyword { + keyword: Keyword::Protection(ProtectionTarget::ChosenColor), + }), + "expected Protection(ChosenColor), got {mods:?}" + ); + assert!( + mods.iter().any(|m| matches!( + m, + ContinuousModification::AddStaticMode { + mode: StaticMode::ProtectionDoesntRemoveControlledAttachments, } - ), + )), + "expected ProtectionDoesntRemoveControlledAttachments static, got {mods:?}" + ); +} + +/// CR 702.16n (issue #4964): the *production* Ward of Lights / Floating Shield / +/// Pentarch Ward line ("... protection from the chosen color. This effect +/// doesn't remove this Aura.") must lower to a single `Protection(ChosenColor)` +/// grant plus the source-specific `ProtectionDoesntRemoveThisAura` static. +#[test] +fn ward_of_lights_production_line_emits_this_aura_exemption() { + use crate::types::keywords::{Keyword, ProtectionTarget}; + + let mods = production_line_modifications( + "Enchanted creature has protection from the chosen color. This effect doesn't remove this Aura.", + ); + assert_eq!( + count_protection_grants(&mods), + 1, + "expected exactly one protection grant, got {mods:?}" + ); + assert!( + mods.contains(&ContinuousModification::AddKeyword { + keyword: Keyword::Protection(ProtectionTarget::ChosenColor), + }), "expected Protection(ChosenColor), got {mods:?}" ); assert!( - !mods.iter().any(|m| matches!( + mods.iter().any(|m| matches!( m, ContinuousModification::AddStaticMode { - mode: StaticMode::ProtectionDoesntRemoveThisAura - | StaticMode::ProtectionDoesntRemoveControlledAttachments, + mode: StaticMode::ProtectionDoesntRemoveThisAura, } )), - "Spectra Ward has no attachment exemption, got {mods:?}" + "expected ProtectionDoesntRemoveThisAura static, got {mods:?}" ); } From 37bec17bc4d4c2061045bfa8bb2b11481bca2f7c Mon Sep 17 00:00:00 2001 From: root Date: Fri, 3 Jul 2026 20:46:32 +0200 Subject: [PATCH 5/9] fix(engine): map new protection-exemption static modes to StaticModeKind (#4964) Merging main brought in the StaticModePresence index (#5022), whose wildcard-free StaticMode::kind() match is exhaustive over StaticMode. The two exemption variants added by this PR (ProtectionDoesntRemoveThisAura / ProtectionDoesntRemoveControlledAttachments) were not covered, breaking the PR-merge build (E0004). Add matching StaticModeKind discriminants and their kind() arms. Co-authored-by: Cursor --- crates/engine/src/types/statics.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/engine/src/types/statics.rs b/crates/engine/src/types/statics.rs index 650dfd2808..52f6718e8c 100644 --- a/crates/engine/src/types/statics.rs +++ b/crates/engine/src/types/statics.rs @@ -1971,6 +1971,8 @@ pub enum StaticModeKind { UntapsDuringEachOtherPlayersUntapStep, MaxUntapPerType, EntersWithAdditionalCounters, + ProtectionDoesntRemoveThisAura, + ProtectionDoesntRemoveControlledAttachments, Other, } @@ -2115,6 +2117,12 @@ impl StaticMode { StaticMode::EntersWithAdditionalCounters { .. } => { StaticModeKind::EntersWithAdditionalCounters } + StaticMode::ProtectionDoesntRemoveThisAura => { + StaticModeKind::ProtectionDoesntRemoveThisAura + } + StaticMode::ProtectionDoesntRemoveControlledAttachments => { + StaticModeKind::ProtectionDoesntRemoveControlledAttachments + } StaticMode::Other(..) => StaticModeKind::Other, } } From 52b0e30e9acee5e8bb64c964f8a7793a846382aa Mon Sep 17 00:00:00 2001 From: root Date: Fri, 3 Jul 2026 23:36:53 +0200 Subject: [PATCH 6/9] fix(engine): bind protection attachment exemption to the granting effect (#4964) CR 702.16n/p: the "doesn't remove this Aura / Auras and Equipment you control" exemption only neutralizes the protection granted by that SAME continuous effect. The prior check asked separately whether the host had ANY protection (baked keyword, no provenance) and whether the host had ANY exemption static, so an unrelated same-quality protection instance was wrongly exempted too. Scan the protection-granting static definitions WITH provenance (each def exposes both its AddKeyword(Protection) and any sibling AddStaticMode(ProtectionDoesntRemove*)): an attachment stays only if every protection instance matching its quality comes from an effect that also exempts THIS attachment. Protection from another effect, the host's intrinsic keywords, or a transient grant still detaches it (fail-closed on unattributed protection). Rework the exemption unit tests to model protection as a granting effect (not a bare baked keyword), and add a regression proving a second, unrelated protection-from-white instance still detaches an otherwise exempt Benevolent Blessing. Co-authored-by: Cursor --- crates/engine/src/game/effects/attach.rs | 392 +++++++++++++++-------- 1 file changed, 266 insertions(+), 126 deletions(-) diff --git a/crates/engine/src/game/effects/attach.rs b/crates/engine/src/game/effects/attach.rs index 01c7009922..bfb3a3da80 100644 --- a/crates/engine/src/game/effects/attach.rs +++ b/crates/engine/src/game/effects/attach.rs @@ -596,15 +596,16 @@ pub(crate) fn attachment_illegality( // being attached to the protected permanent. // CR 702.16d: Protection from a quality prevents Equipment or Fortifications // of that quality from being attached to the protected permanent. - // CR 702.16c–702.16d: Protection from a quality prevents new attachments of - // that quality, but CR 702.16n/702.16p exempt already-attached Auras and - // Equipment named by the protection-granting effect. if let (Some(host), Some(attachment)) = ( state.objects.get(&host_id), state.objects.get(&attachment_id), ) { if crate::game::keywords::protection_prevents_from(host, attachment) { - if protection_doesnt_remove_attached_exemption( + // CR 702.16n / CR 702.16p: an ALREADY-attached Aura/Equipment is + // detached UNLESS every protection instance matching its quality was + // granted by an effect that ALSO exempts THIS attachment. Protection + // of the same quality from any other source still removes it. + if attachment_exempt_from_protection( state, host_id, attachment_id, @@ -620,85 +621,164 @@ pub(crate) fn attachment_illegality( None } -/// CR 702.16n / CR 702.16p: already-attached Auras/Equipment exempt from -/// protection-based detachment when the host carries the matching exemption -/// static from the protection-granting effect. -fn protection_doesnt_remove_attached_exemption( +/// CR 702.16n / CR 702.16p: Decide whether an already-attached Aura/Equipment is +/// exempt from protection-based detachment. +/// +/// The exemption is bound to the SPECIFIC protection-granting continuous effect. +/// A protection instance only fails to remove this attachment when the same +/// effect that granted the protection ALSO carries the matching exemption +/// modification and names this attachment (`ProtectionDoesntRemoveThisAura` for +/// the source Aura itself; `ProtectionDoesntRemoveControlledAttachments` for +/// attachments controlled by the granting effect's controller). Every other +/// protection instance of the same quality — from a different effect, from the +/// host's intrinsic (printed) keywords, or from a transient grant — still +/// detaches the attachment (CR 702.16n/p: other instances of protection from the +/// same quality affect the attached permanent normally). +/// +/// Fail-closed: a protection quality present on the host that cannot be traced +/// back to an exempting granting effect (e.g. a transient "gains protection +/// until end of turn") counts as a non-exempt instance. +fn attachment_exempt_from_protection( state: &GameState, host_id: ObjectId, attachment_id: ObjectId, attacher_is_aura: bool, attacher_is_equipment: bool, ) -> bool { - let Some(host) = state.objects.get(&host_id) else { + use crate::types::ability::ContinuousModification; + use crate::types::keywords::{Keyword, ProtectionTarget}; + + let (Some(host), Some(attachment)) = ( + state.objects.get(&host_id), + state.objects.get(&attachment_id), + ) else { return false; }; + + // CR 702.16n/p: this is a DETACHMENT exemption for objects that are ALREADY + // attached. A brand-new attach attempt is prevented normally (CR 702.16c/d). if !host.attachments.contains(&attachment_id) { return false; } + if !attacher_is_aura && !attacher_is_equipment { + return false; + } - let host_has_this_aura = - crate::game::functioning_abilities::active_static_definitions(state, host).any(|def| { - matches!( - def.mode, - crate::types::statics::StaticMode::ProtectionDoesntRemoveThisAura - ) - }); - let host_has_controlled = - crate::game::functioning_abilities::active_static_definitions(state, host).any(|def| { - matches!( - def.mode, - crate::types::statics::StaticMode::ProtectionDoesntRemoveControlledAttachments - ) - }); + let matches_attachment = |pt: &ProtectionTarget| { + crate::game::keywords::source_matches_protection_target(pt, host, attachment) + }; - if host_has_this_aura - && attacher_is_aura - && aura_grants_protection_attachment_exemption( - state, - attachment_id, - crate::types::statics::StaticMode::ProtectionDoesntRemoveThisAura, - ) - { - return true; + // Protection targets granted by an effect that exempts THIS attachment. + let mut exempted: Vec = Vec::new(); + // Set once any protection instance matching the attachment is NOT exempt. + let mut has_unexempted = false; + + // CR 702.16n/p: intrinsic (printed) protection never carries an attachment + // exemption, so a matching printed keyword always detaches. + for kw in &host.base_keywords { + if let Keyword::Protection(pt) = kw { + if matches_attachment(pt) { + has_unexempted = true; + } + } } - if host_has_controlled && (attacher_is_aura || attacher_is_equipment) { - let Some(attachment) = state.objects.get(&attachment_id) else { - return false; - }; - return host.attachments.iter().any(|&grantor_id| { - aura_grants_protection_attachment_exemption( - state, - grantor_id, - crate::types::statics::StaticMode::ProtectionDoesntRemoveControlledAttachments, - ) && state - .objects - .get(&grantor_id) - .is_some_and(|grantor| grantor.controller == attachment.controller) - }); + // Granted protection scanned WITH provenance: the granting `StaticDefinition` + // exposes both its `AddKeyword(Protection)` and any sibling exemption + // `AddStaticMode`, so the exemption binds to that one specific effect. + for (source_obj, def) in crate::game::functioning_abilities::battlefield_active_statics(state) { + let affected = def.affected.clone().unwrap_or(TargetFilter::Any); + let ctx = FilterContext::from_source(state, source_obj.id); + if !matches_target_filter(state, host_id, &affected, &ctx) { + continue; + } + let def_exempts = definition_exempts_attachment( + def, + source_obj, + attachment, + attacher_is_aura, + attacher_is_equipment, + ); + for modification in &def.modifications { + let ContinuousModification::AddKeyword { + keyword: Keyword::Protection(pt), + } = modification + else { + continue; + }; + let Some(resolved) = resolve_granted_protection_target(pt, source_obj) else { + continue; + }; + if !matches_attachment(&resolved) { + continue; + } + if def_exempts { + exempted.push(resolved); + } else { + has_unexempted = true; + } + } } - false + if has_unexempted { + return false; + } + + // Fail-closed: every protection quality currently on the host that matches + // the attachment must be covered by an exempting granting effect. A baked + // quality with no such source (e.g. a transient grant) is non-exempt. + let all_matching_covered = host.keywords.iter().all(|kw| match kw { + Keyword::Protection(pt) if matches_attachment(pt) => exempted.iter().any(|e| e == pt), + _ => true, + }); + + all_matching_covered && !exempted.is_empty() } -fn aura_grants_protection_attachment_exemption( - state: &GameState, - aura_id: ObjectId, - mode: crate::types::statics::StaticMode, +/// CR 702.16n / CR 702.16p: True if the continuous effect `def` (functioning from +/// `source_obj`) carries the attachment-exemption static that names `attachment`. +fn definition_exempts_attachment( + def: &crate::types::ability::StaticDefinition, + source_obj: &crate::game::game_object::GameObject, + attachment: &crate::game::game_object::GameObject, + attacher_is_aura: bool, + attacher_is_equipment: bool, ) -> bool { - let Some(aura) = state.objects.get(&aura_id) else { - return false; - }; - crate::game::functioning_abilities::active_static_definitions(state, aura).any(|def| { - def.modifications.iter().any(|m| { - matches!( - m, - crate::types::ability::ContinuousModification::AddStaticMode { mode: m } - if *m == mode - ) + use crate::types::ability::ContinuousModification; + use crate::types::statics::StaticMode; + def.modifications + .iter() + .any(|modification| match modification { + ContinuousModification::AddStaticMode { mode } => match mode { + // CR 702.16n: "This effect doesn't remove this Aura" — only the Aura + // that IS the protection source is exempt from its own protection. + StaticMode::ProtectionDoesntRemoveThisAura => { + attacher_is_aura && attachment.id == source_obj.id + } + // CR 702.16p: "...doesn't remove Auras and Equipment you control" — + // attachments controlled by the controller of the granting effect. + StaticMode::ProtectionDoesntRemoveControlledAttachments => { + (attacher_is_aura || attacher_is_equipment) + && attachment.controller == source_obj.controller + } + _ => false, + }, + _ => false, }) - }) +} + +/// Resolve a granted protection target against its SOURCE (e.g. `ChosenColor` → +/// the source's chosen color), matching how the layer pipeline bakes the keyword +/// onto the host. Returns `None` when the grant is not yet concrete. +fn resolve_granted_protection_target( + pt: &crate::types::keywords::ProtectionTarget, + source_obj: &crate::game::game_object::GameObject, +) -> Option { + use crate::types::keywords::ProtectionTarget; + match pt { + ProtectionTarget::ChosenColor => source_obj.chosen_color().map(ProtectionTarget::Color), + other => Some(other.clone()), + } } /// CR 301.5 + CR 303.4 + CR 701.3a: True unless `host_id` is forbidden by a @@ -879,12 +959,14 @@ mod tests { use super::*; use crate::game::zones::create_object; use crate::types::ability::{ - AttachmentKind, ControllerRef, FilterProp, StaticDefinition, TargetFilter, TargetRef, - TypedFilter, + AttachmentKind, ContinuousModification, ControllerRef, FilterProp, StaticDefinition, + TargetFilter, TargetRef, TypedFilter, }; use crate::types::card_type::CoreType; use crate::types::game_state::{AttachmentSnapshot, ZoneChangeRecord}; use crate::types::identifiers::CardId; + use crate::types::keywords::{Keyword, ProtectionTarget}; + use crate::types::mana::ManaColor; use crate::types::player::PlayerId; use crate::types::statics::StaticMode; use crate::types::zones::Zone; @@ -893,6 +975,43 @@ mod tests { GameState::new_two_player(42) } + /// Mirror the production "Enchanted creature has protection ..." affected + /// filter so a granting Aura's continuous effect resolves to the creature it + /// is attached to (via `FilterProp::EnchantedBy`). + fn enchanted_creature_filter() -> TargetFilter { + TargetFilter::Typed(TypedFilter::creature().properties(vec![FilterProp::EnchantedBy])) + } + + /// Push a continuous effect that grants `Protection(color)` to the enchanted + /// creature, plus any additional modifications (e.g. an attachment-exemption + /// static), onto `aura`, and attach it to `host`. + fn attach_protection_grant( + state: &mut GameState, + aura: ObjectId, + host: ObjectId, + color: ManaColor, + extra: Vec, + ) { + let mut modifications = vec![ContinuousModification::AddKeyword { + keyword: Keyword::Protection(ProtectionTarget::Color(color)), + }]; + modifications.extend(extra); + let obj = state.objects.get_mut(&aura).unwrap(); + obj.attached_to = Some(AttachTarget::Object(host)); + obj.static_definitions.push( + StaticDefinition::continuous() + .affected(enchanted_creature_filter()) + .modifications(modifications), + ); + let host_obj = state.objects.get_mut(&host).unwrap(); + host_obj.attachments.push(aura); + // Layer-baked result of the grant above (what the host would carry after + // an `evaluate_layers` pass). + host_obj + .keywords + .push(Keyword::Protection(ProtectionTarget::Color(color))); + } + /// Build Equipment on the battlefield (Artifact + Equipment subtype). fn spawn_equipment(state: &mut GameState, name: &str, card_id: u64) -> ObjectId { let id = create_object( @@ -968,47 +1087,27 @@ mod tests { #[test] fn attachment_illegality_protection_exemption_keeps_attached_controlled_aura() { - // CR 702.16p (issue #4964): Benevolent Blessing — protection from the - // chosen color must not detach your already-attached Auras/Equipment. + // CR 702.16p (issue #4964): Benevolent Blessing grants the host protection + // from the chosen color AND carries the "doesn't remove Auras and + // Equipment you control" exemption on the SAME continuous effect. + // Choosing that color must not detach the Aura that granted it. let mut state = setup(); + let creature = spawn_creature(&mut state, "Bear"); let aura = spawn_with_subtype(&mut state, "Benevolent Blessing", "Aura"); { let obj = state.objects.get_mut(&aura).unwrap(); obj.card_types.core_types.push(CoreType::Enchantment); - obj.color.push(crate::types::mana::ManaColor::White); + obj.color.push(ManaColor::White); } - let creature = spawn_creature(&mut state, "Bear"); - state - .objects - .get_mut(&creature) - .unwrap() - .attachments - .push(aura); - state.objects.get_mut(&creature).unwrap().keywords.push( - crate::types::keywords::Keyword::Protection( - crate::types::keywords::ProtectionTarget::Color( - crate::types::mana::ManaColor::White, - ), - ), + attach_protection_grant( + &mut state, + aura, + creature, + ManaColor::White, + vec![ContinuousModification::AddStaticMode { + mode: StaticMode::ProtectionDoesntRemoveControlledAttachments, + }], ); - state - .objects - .get_mut(&aura) - .unwrap() - .static_definitions - .push(StaticDefinition::continuous().modifications(vec![ - crate::types::ability::ContinuousModification::AddStaticMode { - mode: StaticMode::ProtectionDoesntRemoveControlledAttachments, - }, - ])); - state - .objects - .get_mut(&creature) - .unwrap() - .static_definitions - .push(StaticDefinition::new( - StaticMode::ProtectionDoesntRemoveControlledAttachments, - )); assert_eq!(attachment_illegality(&state, aura, creature), None); assert!(can_attach_to_object(&state, aura, creature)); @@ -1023,53 +1122,94 @@ mod tests { #[test] fn attachment_illegality_this_aura_exemption_is_source_specific() { let mut state = setup(); + let creature = spawn_creature(&mut state, "Bear"); - // Granting Aura: carries the "doesn't remove this Aura" grant AND is the - // aura that supplies the host's protection static. + // Granting Aura: grants protection-from-white to the host AND carries the + // "doesn't remove this Aura" exemption on that same effect. let granting_aura = spawn_with_subtype(&mut state, "Pentarch Ward", "Aura"); { let obj = state.objects.get_mut(&granting_aura).unwrap(); obj.card_types.core_types.push(CoreType::Enchantment); - obj.color.push(crate::types::mana::ManaColor::White); - obj.static_definitions - .push(StaticDefinition::continuous().modifications(vec![ - crate::types::ability::ContinuousModification::AddStaticMode { - mode: StaticMode::ProtectionDoesntRemoveThisAura, - }, - ])); + obj.color.push(ManaColor::White); } + attach_protection_grant( + &mut state, + granting_aura, + creature, + ManaColor::White, + vec![ContinuousModification::AddStaticMode { + mode: StaticMode::ProtectionDoesntRemoveThisAura, + }], + ); - // Sibling Aura: same controller, plain white Aura, NO exemption grant. + // Sibling Aura: same controller, plain white Aura, NO grant/exemption. let sibling_aura = spawn_with_subtype(&mut state, "Pacifism", "Aura"); { let obj = state.objects.get_mut(&sibling_aura).unwrap(); obj.card_types.core_types.push(CoreType::Enchantment); - obj.color.push(crate::types::mana::ManaColor::White); + obj.color.push(ManaColor::White); + obj.attached_to = Some(AttachTarget::Object(creature)); } + state + .objects + .get_mut(&creature) + .unwrap() + .attachments + .push(sibling_aura); + // Granting Aura is exempt from its OWN protection; the sibling Aura is + // still removed by that protection (CR 702.16n is source-specific). + assert_eq!(attachment_illegality(&state, granting_aura, creature), None); + assert_eq!( + attachment_illegality(&state, sibling_aura, creature), + Some(AttachIllegality::Protection), + "a sibling controlled Aura must NOT share the source Aura's CR 702.16n exemption" + ); + } + + /// CR 702.16n / CR 702.16p (issue #4964 review, [HIGH]): the exemption is + /// bound to the specific protection-granting effect. Benevolent Blessing's + /// exemption only neutralizes ITS OWN protection grant — a second, unrelated + /// protection-from-white instance on the same host (no exemption) still + /// detaches Benevolent Blessing. + #[test] + fn attachment_illegality_other_protection_instance_still_detaches_exempt_aura() { + let mut state = setup(); let creature = spawn_creature(&mut state, "Bear"); + + // Benevolent Blessing: white Aura that grants protection-from-white and + // the controlled-attachment exemption on one effect. + let blessing = spawn_with_subtype(&mut state, "Benevolent Blessing", "Aura"); { - let obj = state.objects.get_mut(&creature).unwrap(); - obj.attachments.push(granting_aura); - obj.attachments.push(sibling_aura); - obj.keywords - .push(crate::types::keywords::Keyword::Protection( - crate::types::keywords::ProtectionTarget::Color( - crate::types::mana::ManaColor::White, - ), - )); - // Host carries the applied "this Aura" static from the granting Aura. - obj.static_definitions.push(StaticDefinition::new( - StaticMode::ProtectionDoesntRemoveThisAura, - )); + let obj = state.objects.get_mut(&blessing).unwrap(); + obj.card_types.core_types.push(CoreType::Enchantment); + obj.color.push(ManaColor::White); + } + attach_protection_grant( + &mut state, + blessing, + creature, + ManaColor::White, + vec![ContinuousModification::AddStaticMode { + mode: StaticMode::ProtectionDoesntRemoveControlledAttachments, + }], + ); + + // A second, unrelated protection-from-white source with NO exemption. + let other = spawn_with_subtype(&mut state, "Gift of Sanctuary", "Aura"); + { + let obj = state.objects.get_mut(&other).unwrap(); + obj.card_types.core_types.push(CoreType::Enchantment); } + attach_protection_grant(&mut state, other, creature, ManaColor::White, vec![]); - // Granting Aura is exempt; sibling Aura is still removed by protection. - assert_eq!(attachment_illegality(&state, granting_aura, creature), None); + // The exempting effect neutralizes only its own protection; `other`'s + // protection-from-white still detaches the (white) Benevolent Blessing. assert_eq!( - attachment_illegality(&state, sibling_aura, creature), + attachment_illegality(&state, blessing, creature), Some(AttachIllegality::Protection), - "a sibling controlled Aura must NOT share the source Aura's CR 702.16n exemption" + "a same-quality protection instance from another source must still \ + detach the otherwise-exempt Aura (CR 702.16n/p)" ); } From 025ece35357dae61246a4d2e60b899e10ad60289 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 4 Jul 2026 00:00:33 +0200 Subject: [PATCH 7/9] fix(engine): route protection-instance read through keywords authority (#4964) The exemption fail-closed check iterated `host.keywords` directly, which the engine-authority gate (check-engine-authorities.sh) rejects: raw keyword queries silently miss off-zone grants. Add `keywords::protection_targets_matching` as the object-scoped authority for enumerating an object's matching protection instances, and consume it from the attach exemption path instead of poking `host.keywords`. No behavior change; keeps the CR 702.16n/p exemption fix intact. Co-authored-by: Cursor --- crates/engine/src/game/effects/attach.rs | 8 ++++---- crates/engine/src/game/keywords.rs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/game/effects/attach.rs b/crates/engine/src/game/effects/attach.rs index bfb3a3da80..79c2753d26 100644 --- a/crates/engine/src/game/effects/attach.rs +++ b/crates/engine/src/game/effects/attach.rs @@ -727,10 +727,10 @@ fn attachment_exempt_from_protection( // Fail-closed: every protection quality currently on the host that matches // the attachment must be covered by an exempting granting effect. A baked // quality with no such source (e.g. a transient grant) is non-exempt. - let all_matching_covered = host.keywords.iter().all(|kw| match kw { - Keyword::Protection(pt) if matches_attachment(pt) => exempted.iter().any(|e| e == pt), - _ => true, - }); + let matching_on_host = crate::game::keywords::protection_targets_matching(host, attachment); + let all_matching_covered = matching_on_host + .iter() + .all(|pt| exempted.iter().any(|e| e == pt)); all_matching_covered && !exempted.is_empty() } diff --git a/crates/engine/src/game/keywords.rs b/crates/engine/src/game/keywords.rs index d98fab6678..19de9c8eca 100644 --- a/crates/engine/src/game/keywords.rs +++ b/crates/engine/src/game/keywords.rs @@ -392,6 +392,29 @@ pub fn protection_prevents_from(target: &GameObject, source: &GameObject) -> boo false } +/// CR 702.16: Collect every protection target on `protected` (its post-layer +/// keyword set) whose quality matches `source` — i.e. the protection instances +/// that would prevent `source` from being attached to / interacting with it. +/// +/// This is the object-scoped authority for reading an object's protection +/// instances; callers must not iterate `protected.keywords` themselves. +pub fn protection_targets_matching( + protected: &GameObject, + source: &GameObject, +) -> Vec { + // allow-raw-authority: this IS the object-scoped protection-instance authority + protected + .keywords + .iter() + .filter_map(|kw| match kw { + Keyword::Protection(pt) if source_matches_protection_target(pt, protected, source) => { + Some(pt.clone()) + } + _ => None, + }) + .collect() +} + pub fn source_matches_protection_target( protection: &ProtectionTarget, protected: &GameObject, From 751ecaf28b9d775cfc74ee721daa61f4e6cfc303 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 4 Jul 2026 01:20:05 +0200 Subject: [PATCH 8/9] fix(engine): enumerate transient protection grants in attachment exemption (#4964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR 702.16n/p: identical Protection(Color(_)) grants from different effects deduplicate into one baked keyword, so an exempting static Aura (Benevolent Blessing) could mask a later same-quality transient grant ("gains protection from white until end of turn") — leaving the Aura attached, contrary to the "other instances" rule. Scan active TRANSIENT continuous grants alongside the static grants, preserving per-effect provenance, so an unexempted same-quality transient instance still detaches the otherwise-exempt attachment. Adds a `for_each_transient_protection_grant` authority in layers.rs (same duration/condition/affected gating as `gather_transient_continuous_effects`) and a regression test. Co-authored-by: Cursor --- crates/engine/src/game/effects/attach.rs | 210 +++++++++++++++++------ crates/engine/src/game/layers.rs | 66 +++++++ 2 files changed, 223 insertions(+), 53 deletions(-) diff --git a/crates/engine/src/game/effects/attach.rs b/crates/engine/src/game/effects/attach.rs index 79c2753d26..2445a2ee00 100644 --- a/crates/engine/src/game/effects/attach.rs +++ b/crates/engine/src/game/effects/attach.rs @@ -636,8 +636,11 @@ pub(crate) fn attachment_illegality( /// same quality affect the attached permanent normally). /// /// Fail-closed: a protection quality present on the host that cannot be traced -/// back to an exempting granting effect (e.g. a transient "gains protection -/// until end of turn") counts as a non-exempt instance. +/// back to an exempting granting effect (e.g. protection granted by a copy +/// effect) counts as a non-exempt instance. Both static AND transient continuous +/// grants are enumerated, because identical `Protection(Color(_))` values from +/// different effects are deduplicated into a single baked keyword — so a +/// same-quality transient grant would otherwise be masked by an exempting static. fn attachment_exempt_from_protection( state: &GameState, host_id: ObjectId, @@ -645,7 +648,6 @@ fn attachment_exempt_from_protection( attacher_is_aura: bool, attacher_is_equipment: bool, ) -> bool { - use crate::types::ability::ContinuousModification; use crate::types::keywords::{Keyword, ProtectionTarget}; let (Some(host), Some(attachment)) = ( @@ -664,8 +666,11 @@ fn attachment_exempt_from_protection( return false; } - let matches_attachment = |pt: &ProtectionTarget| { - crate::game::keywords::source_matches_protection_target(pt, host, attachment) + let scan = ProtectionExemptionScan { + host, + attachment, + attacher_is_aura, + attacher_is_equipment, }; // Protection targets granted by an effect that exempts THIS attachment. @@ -677,48 +682,45 @@ fn attachment_exempt_from_protection( // exemption, so a matching printed keyword always detaches. for kw in &host.base_keywords { if let Keyword::Protection(pt) = kw { - if matches_attachment(pt) { + if scan.matches(pt) { has_unexempted = true; } } } - // Granted protection scanned WITH provenance: the granting `StaticDefinition` - // exposes both its `AddKeyword(Protection)` and any sibling exemption - // `AddStaticMode`, so the exemption binds to that one specific effect. + // Static continuous effects, scanned WITH provenance: the granting + // `StaticDefinition` exposes both its `AddKeyword(Protection)` and any sibling + // exemption `AddStaticMode`, so the exemption binds to that one effect. for (source_obj, def) in crate::game::functioning_abilities::battlefield_active_statics(state) { let affected = def.affected.clone().unwrap_or(TargetFilter::Any); let ctx = FilterContext::from_source(state, source_obj.id); if !matches_target_filter(state, host_id, &affected, &ctx) { continue; } - let def_exempts = definition_exempts_attachment( - def, + scan.classify_effect( source_obj, - attachment, - attacher_is_aura, - attacher_is_equipment, - ); - for modification in &def.modifications { - let ContinuousModification::AddKeyword { - keyword: Keyword::Protection(pt), - } = modification - else { - continue; - }; - let Some(resolved) = resolve_granted_protection_target(pt, source_obj) else { - continue; - }; - if !matches_attachment(&resolved) { - continue; - } - if def_exempts { - exempted.push(resolved); - } else { - has_unexempted = true; - } - } - } + &def.modifications, + &mut exempted, + &mut has_unexempted, + ); + } + + // CR 702.16n/p: transient continuous grants ("gains protection from white + // until end of turn") also grant protection but collapse into the deduped + // baked keyword set, so enumerate them separately to keep their provenance — + // a same-quality transient grant carries no exemption and still detaches. + crate::game::layers::for_each_transient_protection_grant( + state, + host_id, + |source_obj, modifications| { + scan.classify_effect( + source_obj, + modifications, + &mut exempted, + &mut has_unexempted, + ); + }, + ); if has_unexempted { return false; @@ -726,7 +728,8 @@ fn attachment_exempt_from_protection( // Fail-closed: every protection quality currently on the host that matches // the attachment must be covered by an exempting granting effect. A baked - // quality with no such source (e.g. a transient grant) is non-exempt. + // quality with no traceable exempting source (e.g. a copy effect) is + // non-exempt. let matching_on_host = crate::game::keywords::protection_targets_matching(host, attachment); let all_matching_covered = matching_on_host .iter() @@ -735,36 +738,83 @@ fn attachment_exempt_from_protection( all_matching_covered && !exempted.is_empty() } -/// CR 702.16n / CR 702.16p: True if the continuous effect `def` (functioning from -/// `source_obj`) carries the attachment-exemption static that names `attachment`. -fn definition_exempts_attachment( - def: &crate::types::ability::StaticDefinition, - source_obj: &crate::game::game_object::GameObject, - attachment: &crate::game::game_object::GameObject, +/// Immutable context for one CR 702.16n/p attachment-exemption scan: the host, +/// the candidate attachment, and its attach kind. +struct ProtectionExemptionScan<'a> { + host: &'a crate::game::game_object::GameObject, + attachment: &'a crate::game::game_object::GameObject, attacher_is_aura: bool, attacher_is_equipment: bool, -) -> bool { - use crate::types::ability::ContinuousModification; - use crate::types::statics::StaticMode; - def.modifications - .iter() - .any(|modification| match modification { +} + +impl ProtectionExemptionScan<'_> { + /// True if `pt` (a protection quality on the host) would remove the attachment. + fn matches(&self, pt: &crate::types::keywords::ProtectionTarget) -> bool { + crate::game::keywords::source_matches_protection_target(pt, self.host, self.attachment) + } + + /// CR 702.16n / CR 702.16p: True if the continuous effect described by + /// `modifications` (granted from `source_obj`) carries the attachment + /// exemption that names this attachment. + fn effect_exempts( + &self, + modifications: &[crate::types::ability::ContinuousModification], + source_obj: &crate::game::game_object::GameObject, + ) -> bool { + use crate::types::ability::ContinuousModification; + use crate::types::statics::StaticMode; + modifications.iter().any(|modification| match modification { ContinuousModification::AddStaticMode { mode } => match mode { // CR 702.16n: "This effect doesn't remove this Aura" — only the Aura // that IS the protection source is exempt from its own protection. StaticMode::ProtectionDoesntRemoveThisAura => { - attacher_is_aura && attachment.id == source_obj.id + self.attacher_is_aura && self.attachment.id == source_obj.id } // CR 702.16p: "...doesn't remove Auras and Equipment you control" — // attachments controlled by the controller of the granting effect. StaticMode::ProtectionDoesntRemoveControlledAttachments => { - (attacher_is_aura || attacher_is_equipment) - && attachment.controller == source_obj.controller + (self.attacher_is_aura || self.attacher_is_equipment) + && self.attachment.controller == source_obj.controller } _ => false, }, _ => false, }) + } + + /// Classify every `Protection` grant carried by one continuous effect. A grant + /// matching the attachment is recorded in `exempted` when the SAME effect + /// exempts this attachment, otherwise it flips `has_unexempted`. + fn classify_effect( + &self, + source_obj: &crate::game::game_object::GameObject, + modifications: &[crate::types::ability::ContinuousModification], + exempted: &mut Vec, + has_unexempted: &mut bool, + ) { + use crate::types::ability::ContinuousModification; + use crate::types::keywords::Keyword; + let effect_exempts = self.effect_exempts(modifications, source_obj); + for modification in modifications { + let ContinuousModification::AddKeyword { + keyword: Keyword::Protection(pt), + } = modification + else { + continue; + }; + let Some(resolved) = resolve_granted_protection_target(pt, source_obj) else { + continue; + }; + if !self.matches(&resolved) { + continue; + } + if effect_exempts { + exempted.push(resolved); + } else { + *has_unexempted = true; + } + } + } } /// Resolve a granted protection target against its SOURCE (e.g. `ChosenColor` → @@ -959,8 +1009,8 @@ mod tests { use super::*; use crate::game::zones::create_object; use crate::types::ability::{ - AttachmentKind, ContinuousModification, ControllerRef, FilterProp, StaticDefinition, - TargetFilter, TargetRef, TypedFilter, + AttachmentKind, ContinuousModification, ControllerRef, Duration, FilterProp, + StaticDefinition, TargetFilter, TargetRef, TypedFilter, }; use crate::types::card_type::CoreType; use crate::types::game_state::{AttachmentSnapshot, ZoneChangeRecord}; @@ -1213,6 +1263,60 @@ mod tests { ); } + /// CR 702.16n/p (issue #4964 review, [HIGH] round 2): a same-quality + /// protection grant from a TRANSIENT continuous effect ("gains protection + /// from white until end of turn") deduplicates into the host's single baked + /// `Protection(White)` keyword, so it must be enumerated from + /// `transient_continuous_effects` to preserve its provenance. Benevolent + /// Blessing's exemption neutralizes only its OWN static grant; the unexempted + /// transient grant still detaches the Aura. + #[test] + fn attachment_illegality_transient_protection_still_detaches_exempt_aura() { + let mut state = setup(); + let creature = spawn_creature(&mut state, "Bear"); + + // Benevolent Blessing: white Aura granting protection-from-white plus the + // controlled-attachment exemption on ONE static effect. + let blessing = spawn_with_subtype(&mut state, "Benevolent Blessing", "Aura"); + { + let obj = state.objects.get_mut(&blessing).unwrap(); + obj.card_types.core_types.push(CoreType::Enchantment); + obj.color.push(ManaColor::White); + } + attach_protection_grant( + &mut state, + blessing, + creature, + ManaColor::White, + vec![ContinuousModification::AddStaticMode { + mode: StaticMode::ProtectionDoesntRemoveControlledAttachments, + }], + ); + + // A later "gains protection from white until end of turn" TRANSIENT grant + // on the same creature, from an unrelated source, carrying NO exemption. + // Its baked keyword collapses into the single deduped Protection(White) + // already on the host, so only its transient provenance distinguishes it. + let caster = spawn_creature(&mut state, "Gods Willing caster"); + state.add_transient_continuous_effect( + caster, + PlayerId(0), + Duration::UntilEndOfTurn, + TargetFilter::SpecificObject { id: creature }, + vec![ContinuousModification::AddKeyword { + keyword: Keyword::Protection(ProtectionTarget::Color(ManaColor::White)), + }], + None, + ); + + assert_eq!( + attachment_illegality(&state, blessing, creature), + Some(AttachIllegality::Protection), + "an unexempted same-quality transient protection grant must still \ + detach the otherwise-exempt Aura (CR 702.16n/p)" + ); + } + #[test] fn attachment_illegality_cant_be_enchanted_blocks_aura() { // CR 303.4c: other applicable effects can make an Aura's host illegal. diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 4d9102c9ae..595a98c944 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -3609,6 +3609,72 @@ fn transient_duration_holds(state: &GameState, tce: &TransientContinuousEffect) } } +/// CR 702.16n/p provenance helper: visit every currently-active TRANSIENT +/// continuous effect that grants a `Protection` keyword to `affected_id`. The +/// visitor receives the granting source object plus that effect's FULL sibling +/// modification list, so callers can reconstruct per-effect protection +/// provenance that the deduplicated baked `keywords` set discards — identical +/// `Protection(Color(_))` grants from different effects collapse to one keyword, +/// so a same-quality transient grant would otherwise be masked by an exempting +/// static grant. +/// +/// Applies the same duration / source-condition / recipient-condition / affected +/// gating as `gather_transient_continuous_effects`, so only grants that actually +/// bake onto `affected_id` are surfaced. +pub(crate) fn for_each_transient_protection_grant( + state: &GameState, + affected_id: ObjectId, + mut visit: impl FnMut(&crate::game::game_object::GameObject, &[ContinuousModification]), +) { + for tce in &state.transient_continuous_effects { + if tce.duration == Duration::UntilHostLeavesPlay + && !state + .objects + .get(&tce.source_id) + .is_some_and(|obj| obj.zone == crate::types::zones::Zone::Battlefield) + { + continue; + } + if !transient_duration_holds(state, tce) { + continue; + } + if let Some(condition) = &tce.condition { + if !source_condition_gate_passes(state, condition, tce.controller, tce.source_id) { + continue; + } + if condition_uses_recipient_context(condition) + && !evaluate_condition_with_recipient( + state, + condition, + tce.controller, + tce.source_id, + affected_id, + ) + { + continue; + } + } + let ctx = FilterContext::from_source(state, tce.source_id); + if !matches_target_filter(state, affected_id, &tce.affected, &ctx) { + continue; + } + let grants_protection = tce.modifications.iter().any(|modification| { + matches!( + modification, + ContinuousModification::AddKeyword { + keyword: crate::types::keywords::Keyword::Protection(_) + } + ) + }); + if !grants_protection { + continue; + } + if let Some(source_obj) = state.objects.get(&tce.source_id) { + visit(source_obj, &tce.modifications); + } + } +} + #[allow(clippy::ptr_arg)] fn push_effect( effects: &mut Vec<(Layer, Vec)>, From 659fd4bfc556ab943df2065a1b4dfcf08234d4e9 Mon Sep 17 00:00:00 2001 From: root Date: Sat, 4 Jul 2026 11:05:13 +0200 Subject: [PATCH 9/9] fix(engine): model CR 702.16n all-Auras protection attachment exemption (#4964) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spectra Ward's "This effect doesn't remove Auras" was the one modeled-gap in the CR 702.16n/p attachment-exemption family: the trailing sentence was left unrecognized, so the line lowered to a single inert Protection(CardType("each color")) grant with no exemption at all — meaning Spectra Ward granted no functional color protection and still detached every matching Aura. Add the all-Auras exemption scope explicitly: - New StaticMode::ProtectionDoesntRemoveAuras (+ StaticModeKind, Display, FromStr, as_keyword, static-abilities registry). - Recognize the terminal "This effect doesn't remove Auras" sentence in parse_protection_attachment_exemption_trailing. It is matched LAST (it is a prefix of the controlled-attachments form) and ONLY as a complete trailing sentence, so Guardian Beast's "...Auras already attached to those artifacts" and the controlled form are excluded. Recognizing it lets the peel fire, so the recovered "protection from each color" correctly fans into the five WUBRG grants (CR 105.2) instead of the inert CardType grant. - Enforce it in attach.rs: the all-Auras form exempts ANY already-attached Aura of the granted quality (not just the source Aura or controlled ones), while Equipment of that quality is still detached (the printed form names Auras). Regressions: Spectra Ward production-line parse (5 grants + exemption static), a direct trailing-form parser test incl. the Guardian Beast guard, and a runtime test proving a foreign-controlled Aura stays attached while Equipment is still removed. Co-authored-by: Cursor --- crates/engine/src/game/effects/attach.rs | 85 ++++++++++++- crates/engine/src/game/static_abilities.rs | 1 + .../src/parser/oracle_static/keyword_grant.rs | 39 +++++- .../engine/src/parser/oracle_static/tests.rs | 118 ++++++++++++++---- crates/engine/src/types/statics.rs | 13 ++ 5 files changed, 227 insertions(+), 29 deletions(-) diff --git a/crates/engine/src/game/effects/attach.rs b/crates/engine/src/game/effects/attach.rs index 2445a2ee00..cea6b38099 100644 --- a/crates/engine/src/game/effects/attach.rs +++ b/crates/engine/src/game/effects/attach.rs @@ -629,7 +629,9 @@ pub(crate) fn attachment_illegality( /// effect that granted the protection ALSO carries the matching exemption /// modification and names this attachment (`ProtectionDoesntRemoveThisAura` for /// the source Aura itself; `ProtectionDoesntRemoveControlledAttachments` for -/// attachments controlled by the granting effect's controller). Every other +/// attachments controlled by the granting effect's controller; +/// `ProtectionDoesntRemoveAuras` for ANY already-attached Aura, CR 702.16n's +/// all-Auras form). Every other /// protection instance of the same quality — from a different effect, from the /// host's intrinsic (printed) keywords, or from a transient grant — still /// detaches the attachment (CR 702.16n/p: other instances of protection from the @@ -776,6 +778,11 @@ impl ProtectionExemptionScan<'_> { (self.attacher_is_aura || self.attacher_is_equipment) && self.attachment.controller == source_obj.controller } + // CR 702.16n (all-Auras form): "This effect doesn't remove Auras" + // (Spectra Ward) — this grant keeps EVERY already-attached Aura of + // the quality attached, regardless of who controls it. Equipment is + // NOT named by this form, so it is still detached. + StaticMode::ProtectionDoesntRemoveAuras => self.attacher_is_aura, _ => false, }, _ => false, @@ -1317,6 +1324,82 @@ mod tests { ); } + /// CR 702.16n (issue #4964 review): the all-Auras exemption form (Spectra + /// Ward: "This effect doesn't remove Auras") keeps EVERY already-attached Aura + /// of the granted quality attached — regardless of who controls it (unlike the + /// controlled-attachments form) — while still detaching Equipment of that + /// quality, since the printed form names only Auras. + #[test] + fn attachment_illegality_all_auras_exemption_keeps_any_aura() { + let mut state = setup(); + let creature = spawn_creature(&mut state, "Bear"); + + // Spectra Ward: grants protection-from-white to the host AND carries the + // all-Auras exemption on that same continuous effect. + let spectra = spawn_with_subtype(&mut state, "Spectra Ward", "Aura"); + { + let obj = state.objects.get_mut(&spectra).unwrap(); + obj.card_types.core_types.push(CoreType::Enchantment); + obj.color.push(ManaColor::White); + } + attach_protection_grant( + &mut state, + spectra, + creature, + ManaColor::White, + vec![ContinuousModification::AddStaticMode { + mode: StaticMode::ProtectionDoesntRemoveAuras, + }], + ); + + // A white Aura controlled by the OPPONENT attached to the same host. Under + // the controlled-attachments form this would be detached (wrong + // controller); under the all-Auras form it stays. + let foreign_aura = spawn_with_subtype(&mut state, "Pacifism", "Aura"); + { + let obj = state.objects.get_mut(&foreign_aura).unwrap(); + obj.card_types.core_types.push(CoreType::Enchantment); + obj.color.push(ManaColor::White); + obj.controller = PlayerId(1); + obj.attached_to = Some(AttachTarget::Object(creature)); + } + state + .objects + .get_mut(&creature) + .unwrap() + .attachments + .push(foreign_aura); + + assert_eq!( + attachment_illegality(&state, foreign_aura, creature), + None, + "the all-Auras exemption keeps ANY already-attached Aura of the \ + quality attached, even one controlled by another player (CR 702.16n)" + ); + + // Equipment of the same quality is NOT named by the all-Auras form, so it + // is still detached. + let white_equip = spawn_equipment(&mut state, "White Equip", 99); + { + let obj = state.objects.get_mut(&white_equip).unwrap(); + obj.color.push(ManaColor::White); + obj.attached_to = Some(AttachTarget::Object(creature)); + } + state + .objects + .get_mut(&creature) + .unwrap() + .attachments + .push(white_equip); + + assert_eq!( + attachment_illegality(&state, white_equip, creature), + Some(AttachIllegality::Protection), + "the all-Auras form names only Auras; Equipment of the quality is \ + still detached (CR 702.16n)" + ); + } + #[test] fn attachment_illegality_cant_be_enchanted_blocks_aura() { // CR 303.4c: other applicable effects can make an Aura's host illegal. diff --git a/crates/engine/src/game/static_abilities.rs b/crates/engine/src/game/static_abilities.rs index 9c2ca15c78..00e358b4df 100644 --- a/crates/engine/src/game/static_abilities.rs +++ b/crates/engine/src/game/static_abilities.rs @@ -224,6 +224,7 @@ pub fn build_static_registry() -> HashMap { StaticMode::ProtectionDoesntRemoveControlledAttachments, handle_rule_mod, ); + registry.insert(StaticMode::ProtectionDoesntRemoveAuras, handle_rule_mod); // CR 702.179e: Card-specific rule modification allowing speed to exceed 4. registry.insert(StaticMode::SpeedCanIncreaseBeyondFour, handle_rule_mod); // CR 609.4b: "You may spend mana as though it were mana of any color." diff --git a/crates/engine/src/parser/oracle_static/keyword_grant.rs b/crates/engine/src/parser/oracle_static/keyword_grant.rs index 69bb6fbd56..2f228e02ea 100644 --- a/crates/engine/src/parser/oracle_static/keyword_grant.rs +++ b/crates/engine/src/parser/oracle_static/keyword_grant.rs @@ -1406,11 +1406,14 @@ pub(crate) fn parse_continuous_modifications(text: &str) -> Vec Option { + parse_protection_attachment_exemption_trailing(text) +} + fn parse_protection_attachment_exemption_trailing(text: &str) -> Option { let lower = text.trim().to_ascii_lowercase(); let mut this_aura = alt(( @@ -1616,6 +1626,25 @@ fn parse_protection_attachment_exemption_trailing(text: &str) -> Option>("this effect doesn't remove auras"), + tag("this effect does not remove auras"), + tag("doesn't remove auras"), + tag("does not remove auras"), + )); + if let Ok((rest, _)) = all_auras.parse(lower.as_str()) { + if rest.trim().trim_end_matches('.').trim().is_empty() { + return Some(StaticMode::ProtectionDoesntRemoveAuras); + } + } None } diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index 843adf07db..258122a46d 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -22213,39 +22213,59 @@ fn count_protection_grants(mods: &[ContinuousModification]) -> usize { .count() } -/// CR 702.16 (issue #4964 review): the Benevolent Blessing trailing-prose -/// exemption splitter must NOT change the parse of a sibling protection grant -/// whose trailing sentence is NOT a recognized attachment exemption. This uses -/// Spectra Ward's *production* Oracle line verbatim (MTGJSON `AtomicCards`: -/// "gets +2/+2 and has protection from each color. This effect doesn't remove -/// Auras." + reminder) through the production static-line entry — the earlier -/// fragment test hid the regression because it never exercised the "each color" -/// fan-out that the exemption peel was inadvertently unblocking. The unrecognized -/// "... doesn't remove Auras." tail must stay glued so this still lowers to the -/// same single protection grant as `origin/main` (never the five-color WUBRG -/// fan-out), and emits NO attachment-exemption static. (fail-if-reverted: -/// peeling on mere "doesn't remove" reverts this to the fanned-out set.) -#[test] -fn spectra_ward_production_line_protection_not_duplicated_by_exemption_splitter() { +/// CR 702.16n (issue #4964 review): Spectra Ward's "This effect doesn't remove +/// Auras" is the all-Auras exemption form — it must lower to the +/// `ProtectionDoesntRemoveAuras` static AND recover the correct protection parse. +/// This uses Spectra Ward's *production* Oracle line verbatim (MTGJSON +/// `AtomicCards`: "gets +2/+2 and has protection from each color. This effect +/// doesn't remove Auras." + reminder) through the production static-line entry. +/// +/// Before this fix the glued trailing sentence blocked the "each color" fan-out +/// and the line lowered to a single inert `Protection(CardType("each color"))` +/// grant (no real color protection, no exemption). Recognizing the all-Auras +/// exemption peels the tail, so the recovered "protection from each color" +/// correctly fans into the five WUBRG grants (CR 105.2) and the exemption static +/// is emitted. (fail-if-reverted: dropping the all-Auras form reverts this to the +/// single inert CardType grant with no exemption.) +#[test] +fn spectra_ward_production_line_emits_all_auras_exemption() { + use crate::types::keywords::{Keyword, ProtectionTarget}; + use crate::types::mana::ManaColor; + let mods = production_line_modifications( "Enchanted creature gets +2/+2 and has protection from each color. This effect doesn't remove Auras. (It can't be blocked, targeted, or dealt damage by anything that's white, blue, black, red, or green.)", ); + let prot: Vec<_> = mods + .iter() + .filter_map(|m| match m { + ContinuousModification::AddKeyword { + keyword: Keyword::Protection(pt), + } => Some(pt.clone()), + _ => None, + }) + .collect(); assert_eq!( - count_protection_grants(&mods), - 1, - "Spectra Ward's production line must keep its single protection grant \ - (the exemption peel must not unblock the each-color fan-out), got {mods:?}" + prot, + vec![ + ProtectionTarget::Color(ManaColor::White), + ProtectionTarget::Color(ManaColor::Blue), + ProtectionTarget::Color(ManaColor::Black), + ProtectionTarget::Color(ManaColor::Red), + ProtectionTarget::Color(ManaColor::Green), + ], + "Spectra Ward's 'protection from each color' must fan into five WUBRG \ + grants once the all-Auras tail is peeled (never the inert \ + CardType(\"each color\") grant), got {mods:?}" ); assert!( - !mods.iter().any(|m| matches!( + mods.iter().any(|m| matches!( m, ContinuousModification::AddStaticMode { - mode: StaticMode::ProtectionDoesntRemoveThisAura - | StaticMode::ProtectionDoesntRemoveControlledAttachments, + mode: StaticMode::ProtectionDoesntRemoveAuras, } )), - "Spectra Ward's \"doesn't remove Auras\" is not a modeled attachment \ - exemption and must emit no exemption static, got {mods:?}" + "Spectra Ward's \"This effect doesn't remove Auras\" must emit the \ + all-Auras exemption static, got {mods:?}" ); } @@ -22283,6 +22303,58 @@ fn benevolent_blessing_production_line_emits_controlled_attachment_exemption() { )), "expected ProtectionDoesntRemoveControlledAttachments static, got {mods:?}" ); + // The controlled form ("... Auras and Equipment you control ...") must be + // matched before the all-Auras prefix — it must NOT also emit the broader + // all-Auras exemption. + assert!( + !mods.iter().any(|m| matches!( + m, + ContinuousModification::AddStaticMode { + mode: StaticMode::ProtectionDoesntRemoveAuras, + } + )), + "the controlled-attachment form must not also emit the all-Auras \ + exemption (ordering/prefix guard), got {mods:?}" + ); +} + +/// CR 702.16n / CR 702.16p (issue #4964 review): direct coverage of the trailing +/// SBA-exemption recognizer for all three modeled forms, plus the two negative +/// guards that keep the all-Auras prefix from over-matching. Exercises the +/// private `parse_protection_attachment_exemption_trailing` so the end-anchor +/// (which distinguishes Spectra Ward's terminal "doesn't remove Auras" from +/// Guardian Beast's "doesn't remove Auras already attached to those artifacts" +/// and from the controlled-attachment form) is locked in independently of the +/// larger keyword-grant pipeline. +#[test] +fn protection_attachment_exemption_trailing_forms() { + use crate::parser::oracle_static::keyword_grant::parse_protection_attachment_exemption_trailing_for_test as parse; + + assert_eq!( + parse("This effect doesn't remove this Aura."), + Some(StaticMode::ProtectionDoesntRemoveThisAura), + ); + assert_eq!( + parse("This effect doesn't remove Auras and Equipment you control that are already attached to it."), + Some(StaticMode::ProtectionDoesntRemoveControlledAttachments), + ); + assert_eq!( + parse("This effect doesn't remove Auras."), + Some(StaticMode::ProtectionDoesntRemoveAuras), + ); + assert_eq!( + parse("This effect does not remove Auras"), + Some(StaticMode::ProtectionDoesntRemoveAuras), + ); + // Guardian Beast: "doesn't remove Auras already attached to those artifacts" + // is a different (non-protection) clause; the end-anchor must reject it so it + // is NOT read as the all-Auras protection exemption. + assert_eq!( + parse("This effect doesn't remove Auras already attached to those artifacts."), + None, + ); + // Unrelated trailing prose never matches. + assert_eq!(parse("Draw a card."), None); } /// CR 702.16n (issue #4964): the *production* Ward of Lights / Floating Shield / diff --git a/crates/engine/src/types/statics.rs b/crates/engine/src/types/statics.rs index 52f6718e8c..b99edac811 100644 --- a/crates/engine/src/types/statics.rs +++ b/crates/engine/src/types/statics.rs @@ -1663,6 +1663,12 @@ pub enum StaticMode { /// Auras and Equipment you control that are already attached" keeps those /// attachments when protection would otherwise detach them (Benevolent Blessing). ProtectionDoesntRemoveControlledAttachments, + /// CR 702.16n (all-Auras form): A protection grant whose printed text includes + /// "This effect doesn't remove Auras" keeps EVERY already-attached Aura of the + /// granted quality attached — not just the source Aura and not just controlled + /// ones (Spectra Ward). Equipment is not covered by this form. Other protection + /// instances of the same quality still detach normally. + ProtectionDoesntRemoveAuras, /// Speed may increase beyond 4, and 4+ still counts as max speed for that player. SpeedCanIncreaseBeyondFour, /// CR 118.12a: Defiler cycle — "As an additional cost to cast [color] permanent @@ -1973,6 +1979,7 @@ pub enum StaticModeKind { EntersWithAdditionalCounters, ProtectionDoesntRemoveThisAura, ProtectionDoesntRemoveControlledAttachments, + ProtectionDoesntRemoveAuras, Other, } @@ -2123,6 +2130,7 @@ impl StaticMode { StaticMode::ProtectionDoesntRemoveControlledAttachments => { StaticModeKind::ProtectionDoesntRemoveControlledAttachments } + StaticMode::ProtectionDoesntRemoveAuras => StaticModeKind::ProtectionDoesntRemoveAuras, StaticMode::Other(..) => StaticModeKind::Other, } } @@ -2436,6 +2444,7 @@ impl StaticMode { | StaticMode::LegendRuleDoesntApply | StaticMode::ProtectionDoesntRemoveThisAura | StaticMode::ProtectionDoesntRemoveControlledAttachments + | StaticMode::ProtectionDoesntRemoveAuras | StaticMode::SpeedCanIncreaseBeyondFour | StaticMode::DefilerCostReduction { .. } | StaticMode::SkipStep { .. } @@ -2803,6 +2812,9 @@ impl fmt::Display for StaticMode { StaticMode::ProtectionDoesntRemoveControlledAttachments => { write!(f, "ProtectionDoesntRemoveControlledAttachments") } + StaticMode::ProtectionDoesntRemoveAuras => { + write!(f, "ProtectionDoesntRemoveAuras") + } StaticMode::SpeedCanIncreaseBeyondFour => write!(f, "SpeedCanIncreaseBeyondFour"), StaticMode::DefilerCostReduction { color, .. } => { write!(f, "DefilerCostReduction({color:?})") @@ -3254,6 +3266,7 @@ impl FromStr for StaticMode { "ProtectionDoesntRemoveControlledAttachments" => { StaticMode::ProtectionDoesntRemoveControlledAttachments } + "ProtectionDoesntRemoveAuras" => StaticMode::ProtectionDoesntRemoveAuras, "CanAttackWithDefender" => StaticMode::CanAttackWithDefender, // CR 509.1b + CR 609.4 + CR 702.14c: bare form = all-landwalk canceller. "IgnoreLandwalkForBlocking" => {