diff --git a/crates/engine/src/game/effects/attach.rs b/crates/engine/src/game/effects/attach.rs index 0a0129722c..b22815489a 100644 --- a/crates/engine/src/game/effects/attach.rs +++ b/crates/engine/src/game/effects/attach.rs @@ -633,6 +633,19 @@ pub(crate) fn attachment_illegality( state.objects.get(&attachment_id), ) { if crate::game::keywords::protection_prevents_from(host, attachment) { + // 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, + attacher_is_aura, + attacher_is_equipment, + ) { + return None; + } return Some(AttachIllegality::Protection); } } @@ -640,6 +653,223 @@ pub(crate) fn attachment_illegality( None } +/// 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; +/// `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 +/// 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. 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, + attachment_id: ObjectId, + attacher_is_aura: bool, + attacher_is_equipment: bool, +) -> bool { + 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 scan = ProtectionExemptionScan { + host, + attachment, + attacher_is_aura, + attacher_is_equipment, + }; + + // 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 scan.matches(pt) { + has_unexempted = true; + } + } + } + + // 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; + } + scan.classify_effect( + source_obj, + &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; + } + + // 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 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() + .all(|pt| exempted.iter().any(|e| e == pt)); + + all_matching_covered && !exempted.is_empty() +} + +/// 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, +} + +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 => { + 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 => { + (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, + }) + } + + /// 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` → +/// 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 /// positive "can be attached only to {filter}" restriction on `attachment_id`. /// @@ -818,12 +1048,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, Duration, 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; @@ -832,6 +1064,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( @@ -905,6 +1174,264 @@ 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 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(ManaColor::White); + } + attach_protection_grant( + &mut state, + aura, + creature, + ManaColor::White, + vec![ContinuousModification::AddStaticMode { + mode: StaticMode::ProtectionDoesntRemoveControlledAttachments, + }], + ); + + assert_eq!(attachment_illegality(&state, aura, creature), None); + 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(); + let creature = spawn_creature(&mut state, "Bear"); + + // 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(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 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(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(&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![]); + + // The exempting effect neutralizes only its own protection; `other`'s + // protection-from-white still detaches the (white) Benevolent Blessing. + assert_eq!( + attachment_illegality(&state, blessing, creature), + Some(AttachIllegality::Protection), + "a same-quality protection instance from another source must still \ + detach the otherwise-exempt Aura (CR 702.16n/p)" + ); + } + + /// 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)" + ); + } + + /// 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/keywords.rs b/crates/engine/src/game/keywords.rs index 1e8cbe6977..eab8fa8502 100644 --- a/crates/engine/src/game/keywords.rs +++ b/crates/engine/src/game/keywords.rs @@ -472,6 +472,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, diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index 7164bdbece..fdf1d715a7 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -3683,6 +3683,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)>, diff --git a/crates/engine/src/game/static_abilities.rs b/crates/engine/src/game/static_abilities.rs index b5bd85fe77..93917b385a 100644 --- a/crates/engine/src/game/static_abilities.rs +++ b/crates/engine/src/game/static_abilities.rs @@ -225,6 +225,14 @@ 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, + ); + 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 709c908ec9..6ae1c27049 100644 --- a/crates/engine/src/parser/oracle_static/keyword_grant.rs +++ b/crates/engine/src/parser/oracle_static/keyword_grant.rs @@ -1564,13 +1564,43 @@ 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 @@ -1658,11 +1688,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('.'); @@ -1752,6 +1783,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; } @@ -1779,6 +1813,64 @@ pub(crate) fn push_grant_clause_modifications( } } +#[cfg(test)] +pub(crate) fn parse_protection_attachment_exemption_trailing_for_test( + text: &str, +) -> 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(( + 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); + } + // CR 702.16n (all-Auras form): "This effect doesn't remove Auras" (Spectra + // Ward) — the granted protection keeps EVERY already-attached Aura of the + // quality attached, not just the source Aura or controlled ones. This tag is + // a PREFIX of the controlled form above ("... Auras and Equipment you + // control") and of Guardian Beast's non-protection "... Auras already + // attached to those artifacts", so it is matched LAST and ONLY when it is the + // complete trailing sentence (nothing but end punctuation follows) — that + // end-anchor is what keeps Guardian Beast (and the controlled form) out. + let mut all_auras = alt(( + tag::<_, _, OracleError<'_>>("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 +} + +fn push_protection_attachment_exemption_modifications( + modifications: &mut Vec, + trailing: &str, +) { + if let Some(mode) = parse_protection_attachment_exemption_trailing(trailing) { + modifications.push(ContinuousModification::AddStaticMode { mode }); + } +} + /// 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 1d47b47db3..6f931d3e5a 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -25509,6 +25509,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, @@ -25549,7 +25558,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}; @@ -25557,15 +25567,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:?}" ); } @@ -25602,6 +25620,210 @@ fn protection_chosen_color_duration_form_glory() { ); } +/// 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() +} + +fn count_protection_grants(mods: &[ContinuousModification]) -> usize { + use crate::types::keywords::Keyword; + mods.iter() + .filter(|m| { + matches!( + m, + ContinuousModification::AddKeyword { + keyword: Keyword::Protection(_) + } + ) + }) + .count() +} + +/// 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!( + 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!( + m, + ContinuousModification::AddStaticMode { + mode: StaticMode::ProtectionDoesntRemoveAuras, + } + )), + "Spectra Ward's \"This effect doesn't remove Auras\" must emit the \ + all-Auras 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!( + 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!( + m, + ContinuousModification::AddStaticMode { + mode: StaticMode::ProtectionDoesntRemoveControlledAttachments, + } + )), + "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 / +/// 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!( + m, + ContinuousModification::AddStaticMode { + mode: StaticMode::ProtectionDoesntRemoveThisAura, + } + )), + "expected ProtectionDoesntRemoveThisAura static, got {mods:?}" + ); +} + /// No-regression: single-color protection still parses to Color(Red). #[test] fn protection_single_color_unchanged() { diff --git a/crates/engine/src/types/statics.rs b/crates/engine/src/types/statics.rs index 70dac35c40..b639cceb34 100644 --- a/crates/engine/src/types/statics.rs +++ b/crates/engine/src/types/statics.rs @@ -1693,6 +1693,20 @@ 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, + /// 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 @@ -2000,6 +2014,9 @@ pub enum StaticModeKind { UntapsDuringEachOtherPlayersUntapStep, MaxUntapPerType, EntersWithAdditionalCounters, + ProtectionDoesntRemoveThisAura, + ProtectionDoesntRemoveControlledAttachments, + ProtectionDoesntRemoveAuras, Other, } @@ -2144,6 +2161,13 @@ impl StaticMode { StaticMode::EntersWithAdditionalCounters { .. } => { StaticModeKind::EntersWithAdditionalCounters } + StaticMode::ProtectionDoesntRemoveThisAura => { + StaticModeKind::ProtectionDoesntRemoveThisAura + } + StaticMode::ProtectionDoesntRemoveControlledAttachments => { + StaticModeKind::ProtectionDoesntRemoveControlledAttachments + } + StaticMode::ProtectionDoesntRemoveAuras => StaticModeKind::ProtectionDoesntRemoveAuras, StaticMode::Other(..) => StaticModeKind::Other, } } @@ -2457,6 +2481,9 @@ impl StaticMode { | StaticMode::CantWinTheGame | StaticMode::CantLoseTheGame | StaticMode::LegendRuleDoesntApply + | StaticMode::ProtectionDoesntRemoveThisAura + | StaticMode::ProtectionDoesntRemoveControlledAttachments + | StaticMode::ProtectionDoesntRemoveAuras | StaticMode::SpeedCanIncreaseBeyondFour | StaticMode::DefilerCostReduction { .. } | StaticMode::SkipStep { .. } @@ -2832,6 +2859,15 @@ 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::ProtectionDoesntRemoveAuras => { + write!(f, "ProtectionDoesntRemoveAuras") + } StaticMode::SpeedCanIncreaseBeyondFour => write!(f, "SpeedCanIncreaseBeyondFour"), StaticMode::DefilerCostReduction { color, .. } => { write!(f, "DefilerCostReduction({color:?})") @@ -3279,6 +3315,11 @@ impl FromStr for StaticMode { "CantWinTheGame" => StaticMode::CantWinTheGame, "CantLoseTheGame" => StaticMode::CantLoseTheGame, "LegendRuleDoesntApply" => StaticMode::LegendRuleDoesntApply, + "ProtectionDoesntRemoveThisAura" => StaticMode::ProtectionDoesntRemoveThisAura, + "ProtectionDoesntRemoveControlledAttachments" => { + StaticMode::ProtectionDoesntRemoveControlledAttachments + } + "ProtectionDoesntRemoveAuras" => StaticMode::ProtectionDoesntRemoveAuras, "CanAttackWithDefender" => StaticMode::CanAttackWithDefender, // CR 509.1b + CR 609.4 + CR 702.14c: bare form = all-landwalk canceller. "IgnoreLandwalkForBlocking" => {