diff --git a/crates/engine/src/ai_support/mod.rs b/crates/engine/src/ai_support/mod.rs index 3e9e0a3fb4..d4b05819c9 100644 --- a/crates/engine/src/ai_support/mod.rs +++ b/crates/engine/src/ai_support/mod.rs @@ -4809,6 +4809,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }; obj.static_definitions = vec![def].into(); } @@ -4931,6 +4932,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }; obj.static_definitions = vec![def].into(); } diff --git a/crates/engine/src/game/casting_tests.rs b/crates/engine/src/game/casting_tests.rs index f1dda13f6f..3f5da456bf 100644 --- a/crates/engine/src/game/casting_tests.rs +++ b/crates/engine/src/game/casting_tests.rs @@ -3467,6 +3467,7 @@ fn granted_freerunning_static_surfaces_freerunning_variant() { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }; obj.static_definitions = vec![def].into(); } @@ -11960,6 +11961,7 @@ fn x_cost_max_accounts_for_granted_affinity_exceeding_fixed_generic() { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }] .into(); } @@ -14737,6 +14739,7 @@ fn witherbloom_grants_affinity_to_instant_and_sorcery_spells() { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }; obj.static_definitions = vec![def].into(); } @@ -14854,6 +14857,7 @@ fn add_witherbloom_affinity_source(state: &mut GameState, player: PlayerId) -> O source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }] .into(); } diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index ff7139a21d..26da090b1c 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -12185,6 +12185,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], duration: Some(Duration::UntilEndOfTurn), target: None, @@ -12232,6 +12233,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], duration: Some(Duration::UntilEndOfTurn), target: None, @@ -13367,6 +13369,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); assert!(audit_card_lines(oracle, &face).is_empty()); @@ -13401,6 +13404,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); assert!(audit_card_lines(oracle, &face).is_empty()); @@ -13433,6 +13437,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); let findings = audit_card_lines(oracle, &face); @@ -13583,6 +13588,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); assert!( @@ -13615,6 +13621,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); assert!( @@ -13657,6 +13664,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); let gaps = card_face_gaps(&face); @@ -13690,6 +13698,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); let gaps = card_face_gaps(&face); @@ -13725,6 +13734,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); let gaps = card_face_gaps(&face); @@ -13766,6 +13776,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); } @@ -13934,6 +13945,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); assert!( diff --git a/crates/engine/src/game/dungeon.rs b/crates/engine/src/game/dungeon.rs index 6fc83fa7c3..5b850faaec 100644 --- a/crates/engine/src/game/dungeon.rs +++ b/crates/engine/src/game/dungeon.rs @@ -864,6 +864,7 @@ pub fn room_effects( source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], triggers: Vec::new(), }, diff --git a/crates/engine/src/game/effects/attach.rs b/crates/engine/src/game/effects/attach.rs index baed3ea8fc..6c7a5f52f6 100644 --- a/crates/engine/src/game/effects/attach.rs +++ b/crates/engine/src/game/effects/attach.rs @@ -700,11 +700,15 @@ 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.16n / CR 702.16p: A protection grant that says "this effect doesn't + // remove …" does not make matching attachments illegal via *that* instance + // (Flickering Ward / Ward cycle / Benevolent Blessing). Other instances of + // protection from the same quality still apply normally. 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_blocks_attachment(state, host_id, attachment_id, host, attachment) { return Some(AttachIllegality::Protection); } } @@ -712,6 +716,367 @@ pub(crate) fn attachment_illegality( None } +/// CR 702.16c/d + CR 702.16n/p: True when some protection instance on `host` +/// matches `attachment` and is not exempted for that attachment. +fn protection_blocks_attachment( + state: &GameState, + host_id: ObjectId, + attachment_id: ObjectId, + host: &crate::game::game_object::GameObject, + attachment: &crate::game::game_object::GameObject, +) -> bool { + use crate::types::ability::ContinuousModification; + use crate::types::keywords::Keyword; + use crate::types::statics::StaticMode; + + // CR 702.16: Printed / base protection on the host has no 702.16n rider — + // it always blocks matching attachments. + for kw in &host.base_keywords { + if let Keyword::Protection(ref pt) = kw { + if crate::game::keywords::source_matches_protection_target(pt, host, attachment) { + return true; + } + } + } + + // Continuous grants: each matching protection instance blocks unless its + // StaticDefinition/TCE carries a CR 702.16n/p exemption covering this + // attachment. + let mut any_matching_grant = false; + for (source_obj, def) in crate::game::functioning_abilities::battlefield_active_statics(state) { + if !matches!(def.mode, StaticMode::Continuous) { + continue; + } + let source_id = source_obj.id; + let def_index = live_static_def_index(source_obj, def); + let affected = def.affected.clone().unwrap_or(TargetFilter::Any); + let ctx = FilterContext::from_source(state, source_id); + if !matches_target_filter(state, host_id, &affected, &ctx) { + continue; + } + for (mod_index, modification) in def.modifications.iter().enumerate() { + let ContinuousModification::AddKeyword { + keyword: Keyword::Protection(pt), + } = modification + else { + continue; + }; + let resolved = resolve_protection_target_for_grant(state, source_id, pt); + let Some(resolved) = resolved else { + continue; + }; + if !crate::game::keywords::source_matches_protection_target(&resolved, host, attachment) + { + continue; + } + any_matching_grant = true; + if !protection_grant_exempts_attachment( + state, + attachment_id, + source_id, + (def_index, mod_index, host_id), + &resolved, + def.protection_does_not_remove.as_ref(), + ) { + return true; + } + } + } + + // Transient continuous protection grants (e.g. Mother of Runes) — no + // StaticDefinition rider today; treat as always-blocking when they match. + for tce in &state.transient_continuous_effects { + let ctx = FilterContext::from_source(state, tce.source_id); + if !matches_target_filter(state, host_id, &tce.affected, &ctx) { + continue; + } + for modification in &tce.modifications { + let ContinuousModification::AddKeyword { + keyword: Keyword::Protection(pt), + } = modification + else { + continue; + }; + let resolved = resolve_protection_target_for_grant(state, tce.source_id, pt); + let Some(resolved) = resolved else { + continue; + }; + if crate::game::keywords::source_matches_protection_target(&resolved, host, attachment) + { + // Transients currently carry no 702.16n rider field. + return true; + } + } + } + + // If host.keywords still match (granted protection present) but we found no + // continuous grant — fall back to the pre-exemption query so we never open + // a hole when grant discovery misses a path. + if !any_matching_grant && crate::game::keywords::protection_prevents_from(host, attachment) { + return true; + } + + false +} + +/// CR 702.16 + CR 105.4: Resolve `ChosenColor` / `ChosenCardType` against the +/// granting source before matching the attachment (mirrors layer bake-in). +fn resolve_protection_target_for_grant( + state: &GameState, + source_id: ObjectId, + pt: &crate::types::keywords::ProtectionTarget, +) -> Option { + use crate::types::keywords::ProtectionTarget; + match pt { + ProtectionTarget::ChosenColor => state + .objects + .get(&source_id) + .and_then(|src| src.chosen_color()) + .map(ProtectionTarget::Color), + ProtectionTarget::ChosenCardType => state + .objects + .get(&source_id) + .and_then(|src| src.chosen_card_type()) + .and_then(|ct| ct.protection_quality_str()) + .map(|quality| ProtectionTarget::CardType(quality.to_string())), + other => Some(other.clone()), + } +} + +/// Composite key for one protection modification on a host: +/// `(static_definitions index, modifications index, host object id)`. +use crate::game::game_object::ProtectionEffectHostKey; + +/// Live `static_definitions` index for an active static returned by +/// `battlefield_active_statics`. +fn live_static_def_index( + source: &crate::game::game_object::GameObject, + def: &crate::types::ability::StaticDefinition, +) -> usize { + source + .static_definitions + .iter_all() + .position(|d| std::ptr::eq(d, def)) + .expect("active static definition must index live static_definitions") +} + +/// CR 702.16p: Capture attachment IDs matching `resolved_pt` that are already +/// on `host_id` and controlled by `grant_controller` at protection-start time. +fn capture_protection_start_attachment_snapshot( + state: &GameState, + host_id: ObjectId, + grant_controller: PlayerId, + resolved_pt: &crate::types::keywords::ProtectionTarget, +) -> Vec { + let Some(host) = state.objects.get(&host_id) else { + return Vec::new(); + }; + host.attachments + .iter() + .filter_map(|&attachment_id| { + let attachment = state.objects.get(&attachment_id)?; + let is_aura_or_equipment = attachment + .card_types + .subtypes + .iter() + .any(|s| s.eq_ignore_ascii_case("Aura") || s.eq_ignore_ascii_case("Equipment")); + if !is_aura_or_equipment || attachment.controller != grant_controller { + return None; + } + if !crate::game::keywords::source_matches_protection_target( + resolved_pt, + host, + attachment, + ) { + return None; + } + Some(attachment_id) + }) + .collect() +} + +/// CR 702.16p: When a continuous protection grant with the already-attached +/// rider starts applying to a host, snapshot the matching controlled +/// attachments once; consult that per-grant map in +/// [`protection_grant_exempts_attachment`]. Prune entries when the grant stops +/// applying to a host or the source leaves the battlefield. +pub(crate) fn refresh_protection_start_attachment_snapshots(state: &mut GameState) { + use crate::types::ability::ContinuousModification; + use crate::types::ability::ProtectionDoesNotRemove; + use crate::types::keywords::Keyword; + use crate::types::statics::StaticMode; + use std::collections::{HashMap, HashSet}; + + struct ActiveGrant { + def_index: usize, + mod_index: usize, + host_id: ObjectId, + resolved_pt: crate::types::keywords::ProtectionTarget, + controller: PlayerId, + } + + let mut active_by_source: HashMap> = HashMap::new(); + + for (source_obj, def) in crate::game::functioning_abilities::battlefield_active_statics(state) { + if !matches!(def.mode, StaticMode::Continuous) { + continue; + } + if def.protection_does_not_remove + != Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached) + { + continue; + } + let source_id = source_obj.id; + let def_index = live_static_def_index(source_obj, def); + let affected = def.affected.clone().unwrap_or(TargetFilter::Any); + let ctx = FilterContext::from_source(state, source_id); + for (mod_index, modification) in def.modifications.iter().enumerate() { + let ContinuousModification::AddKeyword { + keyword: Keyword::Protection(pt), + } = modification + else { + continue; + }; + let Some(resolved_pt) = resolve_protection_target_for_grant(state, source_id, pt) + else { + continue; + }; + for &host_id in &state.battlefield { + if !matches_target_filter(state, host_id, &affected, &ctx) { + continue; + } + active_by_source + .entry(source_id) + .or_default() + .push(ActiveGrant { + def_index, + mod_index, + host_id, + resolved_pt: resolved_pt.clone(), + controller: source_obj.controller, + }); + } + } + } + + let active_sources: HashSet = active_by_source.keys().copied().collect(); + + for &source_id in &state.battlefield { + let Some(source) = state.objects.get(&source_id) else { + continue; + }; + if source.protection_start_exempt_attachments.is_empty() { + continue; + } + let active_keys = active_by_source + .get(&source_id) + .map(|grants| { + grants + .iter() + .map(|g| (g.def_index, g.mod_index, g.host_id)) + .collect::>() + }) + .unwrap_or_default(); + if active_sources.contains(&source_id) { + state + .objects + .get_mut(&source_id) + .expect("battlefield object") + .protection_start_exempt_attachments + .retain(|key, _| active_keys.contains(key)); + } else { + state + .objects + .get_mut(&source_id) + .expect("battlefield object") + .protection_start_exempt_attachments + .clear(); + } + } + + let mut to_capture = Vec::new(); + for (source_id, grants) in active_by_source { + for grant in grants { + let key = (grant.def_index, grant.mod_index, grant.host_id); + let already_snapshotted = state.objects.get(&source_id).is_some_and(|source| { + source + .protection_start_exempt_attachments + .get(&key) + .is_some_and(|entry| entry.resolved_quality == grant.resolved_pt) + }); + if already_snapshotted { + continue; + } + to_capture.push((source_id, key, grant.resolved_pt, grant.controller)); + } + } + + for (source_id, key, resolved_pt, controller) in to_capture { + let snapshot = + capture_protection_start_attachment_snapshot(state, key.2, controller, &resolved_pt); + state + .objects + .get_mut(&source_id) + .expect("grant source must exist") + .protection_start_exempt_attachments + .insert( + key, + crate::game::game_object::ProtectionStartSnapshot { + resolved_quality: resolved_pt, + attachment_ids: snapshot, + }, + ); + } +} + +/// CR 702.16n / CR 702.16p: Does this protection grant's exemption rider cover +/// `attachment_id` on `host_id`? +fn protection_grant_exempts_attachment( + state: &GameState, + attachment_id: ObjectId, + grant_source_id: ObjectId, + grant_key: ProtectionEffectHostKey, + resolved_pt: &crate::types::keywords::ProtectionTarget, + exemption: Option<&crate::types::ability::ProtectionDoesNotRemove>, +) -> bool { + use crate::types::ability::ProtectionDoesNotRemove; + + let (grant_def_index, grant_mod_index, host_id) = grant_key; + + let Some(exemption) = exemption else { + return false; + }; + let Some(attachment) = state.objects.get(&attachment_id) else { + return false; + }; + match exemption { + // CR 702.16n: "this effect doesn't remove this Aura" + ProtectionDoesNotRemove::Source => attachment_id == grant_source_id, + // CR 702.16n: "this effect doesn't remove Auras" + ProtectionDoesNotRemove::Auras => attachment + .card_types + .subtypes + .iter() + .any(|s| s.eq_ignore_ascii_case("Aura")), + // CR 702.16p: only attachments snapshotted for this specific protection + // modification (`def_index`, `mod_index`) and host when it started applying. + ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached => state + .objects + .get(&grant_source_id) + .and_then(|source| { + source.protection_start_exempt_attachments.get(&( + grant_def_index, + grant_mod_index, + host_id, + )) + }) + .is_some_and(|entry| { + entry.resolved_quality == *resolved_pt + && entry.attachment_ids.contains(&attachment_id) + }), + } +} + /// 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`. /// @@ -2318,4 +2683,330 @@ mod tests { ); assert_eq!(state.objects.get(&old_equipment).unwrap().attached_to, None); } + + fn spawn_grant_source(state: &mut GameState, name: &str, card_id: u64) -> ObjectId { + create_object( + state, + CardId(card_id), + PlayerId(0), + name.to_string(), + Zone::Battlefield, + ) + } + + fn apply_protection_grant( + state: &mut GameState, + source_id: ObjectId, + host_id: ObjectId, + pt: crate::types::keywords::ProtectionTarget, + exemption: Option, + ) { + use crate::types::ability::ContinuousModification; + use crate::types::keywords::Keyword; + + let mut def = StaticDefinition::continuous() + .affected(TargetFilter::SpecificObject { id: host_id }) + .modifications(vec![ContinuousModification::AddKeyword { + keyword: Keyword::Protection(pt), + }]); + if let Some(exemption) = exemption { + def = def.protection_does_not_remove(exemption); + } + state + .objects + .get_mut(&source_id) + .unwrap() + .static_definitions + .push(def); + } + + fn evaluate_protection_layers(state: &mut GameState) { + crate::game::layers::mark_layers_full(state); + crate::game::layers::evaluate_layers(state); + } + + fn protection_snapshot_ids( + state: &GameState, + source_id: ObjectId, + def_index: usize, + mod_index: usize, + host_id: ObjectId, + ) -> Vec { + state + .objects + .get(&source_id) + .and_then(|source| { + source + .protection_start_exempt_attachments + .get(&(def_index, mod_index, host_id)) + }) + .map(|entry| entry.attachment_ids.clone()) + .unwrap_or_default() + } + + fn replace_source_protection_statics(state: &mut GameState, source_id: ObjectId) { + use std::sync::Arc; + let obj = state.objects.get_mut(&source_id).unwrap(); + obj.static_definitions.clear(); + obj.base_static_definitions = Arc::new(Vec::new()); + obj.base_characteristics_initialized = false; + } + + #[test] + fn cr_702_16p_exempts_matching_controlled_attachment_at_grant_start() { + use crate::types::ability::ProtectionDoesNotRemove; + use crate::types::keywords::ProtectionTarget; + use crate::types::mana::ManaColor; + + let mut state = setup(); + let host = spawn_creature(&mut state, "Bear"); + let equipment = spawn_equipment(&mut state, "Sword", 10); + { + let obj = state.objects.get_mut(&equipment).unwrap(); + obj.color.push(ManaColor::White); + } + attach_to(&mut state, equipment, host); + + let grant_source = spawn_grant_source(&mut state, "Blessing", 11); + apply_protection_grant( + &mut state, + grant_source, + host, + ProtectionTarget::Color(ManaColor::White), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + evaluate_protection_layers(&mut state); + + let snapshot = protection_snapshot_ids(&state, grant_source, 0, 0, host); + assert!( + snapshot.contains(&equipment), + "CR 702.16p: matching controlled attachment at grant start must be snapshotted" + ); + assert_eq!( + attachment_illegality(&state, equipment, host), + None, + "snapshotted attachment must remain legal" + ); + + let mut events = Vec::new(); + crate::game::sba::check_state_based_actions(&mut state, &mut events); + assert_eq!( + state.objects.get(&equipment).unwrap().attached_to, + Some(AttachTarget::Object(host)), + "CR 702.16p: exempt Equipment must stay attached through SBA" + ); + } + + #[test] + fn cr_702_16p_does_not_exempt_attachment_that_becomes_matching_after_grant_start() { + use crate::types::ability::ProtectionDoesNotRemove; + use crate::types::keywords::ProtectionTarget; + use crate::types::mana::ManaColor; + + let mut state = setup(); + let host = spawn_creature(&mut state, "Bear"); + let equipment = spawn_equipment(&mut state, "Sword", 20); + attach_to(&mut state, equipment, host); + + let grant_source = spawn_grant_source(&mut state, "Blessing", 21); + apply_protection_grant( + &mut state, + grant_source, + host, + ProtectionTarget::Color(ManaColor::White), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + evaluate_protection_layers(&mut state); + + assert!( + !protection_snapshot_ids(&state, grant_source, 0, 0, host).contains(&equipment), + "colorless Equipment must not enter the start-time snapshot" + ); + + state + .objects + .get_mut(&equipment) + .unwrap() + .base_color + .push(ManaColor::White); + evaluate_protection_layers(&mut state); + + assert_eq!( + attachment_illegality(&state, equipment, host), + Some(AttachIllegality::Protection), + "attachment that becomes matching only after grant start must be illegal (live-check bug)" + ); + + let mut events = Vec::new(); + crate::game::sba::check_state_based_actions(&mut state, &mut events); + assert_eq!( + state.objects.get(&equipment).unwrap().attached_to, + None, + "CR 704.5n: Equipment that was not in the 702.16p snapshot must unattach" + ); + assert!( + state.battlefield.contains(&equipment), + "Equipment stays on the battlefield after illegal attachment SBA" + ); + } + + #[test] + fn cr_702_16p_second_protection_grant_without_rider_still_blocks() { + use crate::types::keywords::ProtectionTarget; + use crate::types::mana::ManaColor; + + let mut state = setup(); + let host = spawn_creature(&mut state, "Bear"); + let equipment = spawn_equipment(&mut state, "Sword", 30); + { + let obj = state.objects.get_mut(&equipment).unwrap(); + obj.color.push(ManaColor::White); + } + attach_to(&mut state, equipment, host); + + let rider_source = spawn_grant_source(&mut state, "Blessing", 31); + apply_protection_grant( + &mut state, + rider_source, + host, + ProtectionTarget::Color(ManaColor::White), + Some(crate::types::ability::ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + evaluate_protection_layers(&mut state); + + let plain_source = spawn_grant_source(&mut state, "Mother of Runes", 32); + apply_protection_grant( + &mut state, + plain_source, + host, + ProtectionTarget::Color(ManaColor::White), + None, + ); + evaluate_protection_layers(&mut state); + + assert_eq!( + attachment_illegality(&state, equipment, host), + Some(AttachIllegality::Protection), + "a second protection instance without a 702.16n/p rider must still block" + ); + + let mut events = Vec::new(); + crate::game::sba::check_state_based_actions(&mut state, &mut events); + assert_eq!( + state.objects.get(&equipment).unwrap().attached_to, + None, + "second un-ridered grant must remove despite the first grant's snapshot" + ); + } + + #[test] + fn cr_702_16p_same_source_second_effect_does_not_inherit_first_snapshot() { + use crate::types::ability::ProtectionDoesNotRemove; + use crate::types::keywords::ProtectionTarget; + use crate::types::mana::ManaColor; + + let mut state = setup(); + let host = spawn_creature(&mut state, "Bear"); + let equipment = spawn_equipment(&mut state, "Sword", 40); + { + let obj = state.objects.get_mut(&equipment).unwrap(); + obj.color.push(ManaColor::Blue); + } + attach_to(&mut state, equipment, host); + + let grant_source = spawn_grant_source(&mut state, "Dual Blessing", 41); + apply_protection_grant( + &mut state, + grant_source, + host, + ProtectionTarget::Color(ManaColor::Blue), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + evaluate_protection_layers(&mut state); + assert!( + protection_snapshot_ids(&state, grant_source, 0, 0, host).contains(&equipment), + "blue rider must snapshot the blue Equipment at effect start" + ); + + replace_source_protection_statics(&mut state, grant_source); + apply_protection_grant( + &mut state, + grant_source, + host, + ProtectionTarget::Color(ManaColor::White), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + evaluate_protection_layers(&mut state); + assert!( + !protection_snapshot_ids(&state, grant_source, 0, 0, host).contains(&equipment), + "white rider must not inherit the prior blue snapshot at the same def_index" + ); + + state + .objects + .get_mut(&equipment) + .unwrap() + .base_color + .push(ManaColor::White); + evaluate_protection_layers(&mut state); + + assert_eq!( + attachment_illegality(&state, equipment, host), + Some(AttachIllegality::Protection), + "white Equipment must be illegal once it matches the white rider" + ); + + let mut events = Vec::new(); + crate::game::sba::check_state_based_actions(&mut state, &mut events); + assert_eq!( + state.objects.get(&equipment).unwrap().attached_to, + None, + "Equipment that became white after the white rider started must unattach" + ); + } + + #[test] + fn cr_702_16p_opponent_controlled_matching_attachment_not_exempt() { + use crate::types::ability::ProtectionDoesNotRemove; + use crate::types::keywords::ProtectionTarget; + use crate::types::mana::ManaColor; + + let mut state = setup(); + let host = spawn_creature(&mut state, "Bear"); + let equipment = spawn_equipment(&mut state, "Sword", 50); + { + let obj = state.objects.get_mut(&equipment).unwrap(); + obj.color.push(ManaColor::White); + obj.controller = PlayerId(1); + obj.base_controller = Some(PlayerId(1)); + } + attach_to(&mut state, equipment, host); + + let grant_source = spawn_grant_source(&mut state, "Blessing", 51); + apply_protection_grant( + &mut state, + grant_source, + host, + ProtectionTarget::Color(ManaColor::White), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + evaluate_protection_layers(&mut state); + + assert!( + !protection_snapshot_ids(&state, grant_source, 0, 0, host).contains(&equipment), + "702.16p only exempts attachments you control at effect start" + ); + assert_eq!( + attachment_illegality(&state, equipment, host), + Some(AttachIllegality::Protection) + ); + + let mut events = Vec::new(); + crate::game::sba::check_state_based_actions(&mut state, &mut events); + assert_eq!( + state.objects.get(&equipment).unwrap().attached_to, + None, + "opponent-controlled matching Equipment must unattach" + ); + } } diff --git a/crates/engine/src/game/effects/create_emblem.rs b/crates/engine/src/game/effects/create_emblem.rs index 1ca175aaa9..250958218f 100644 --- a/crates/engine/src/game/effects/create_emblem.rs +++ b/crates/engine/src/game/effects/create_emblem.rs @@ -149,6 +149,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, } } diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index 010e9b33a4..a159c48880 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -340,6 +340,18 @@ pub struct EmblemSource { pub printed_ref: Option, } +/// CR 702.16p: Start-time attachment exemption captured for one continuous +/// protection modification (`static_definitions` index + `modifications` index +/// on the grant source) and host. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProtectionStartSnapshot { + pub resolved_quality: crate::types::keywords::ProtectionTarget, + pub attachment_ids: Vec, +} + +/// `(static_definitions index, modifications index, host object id)`. +pub type ProtectionEffectHostKey = (usize, usize, ObjectId); + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GameObject { pub id: ObjectId, @@ -381,6 +393,12 @@ pub struct GameObject { /// `None` if unattached. See `AttachTarget` for variants. pub attached_to: Option, pub attachments: Vec, + /// CR 702.16p: Per [`StaticGateKey::def_index`] on this source and enchanted + /// host, the controlled attachments matching that effect's resolved protection + /// quality when it first started applying to that host. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub protection_start_exempt_attachments: + HashMap, /// CR 702.95b-d: Soulbond pair relationship. Pairing is symmetric: /// if `A.paired_with == Some(B)`, then `B.paired_with == Some(A)`. /// This is independent from attachments; paired creatures are not @@ -1249,6 +1267,7 @@ fn _gameobject_partition_is_total(o: &GameObject) { phyrexian_life_paid: _, mana_spent_source_snapshots: _, phase_status: _, + protection_start_exempt_attachments: _, } = o; } @@ -1981,6 +2000,7 @@ impl GameObject { dealt_deathtouch_damage: false, attached_to: None, attachments: Vec::new(), + protection_start_exempt_attachments: HashMap::new(), paired_with: None, pair_controller: None, counters: HashMap::new(), @@ -2355,6 +2375,7 @@ impl GameObject { // CR 305.1 + CR 603.4: Land-play provenance is likewise battlefield- // entry scoped and must not survive a later zone change. self.played_from_zone = None; + self.protection_start_exempt_attachments.clear(); self.convoked_creatures.clear(); // CR 702.103f: `bestow_form` is intentionally NOT cleared here. // The zone-exit cleanup in `apply_zone_exit_cleanup` (zones.rs) reads diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index a87ee65061..623c9a11c4 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -2252,6 +2252,7 @@ pub fn evaluate_layers(state: &mut GameState) { // Step 5: Clear dirty flag. A full evaluation satisfies any pending request // (Clean / EnteredObjects / Full). + crate::game::effects::attach::refresh_protection_start_attachment_snapshots(state); state.layers_dirty = LayersDirty::Clean; } diff --git a/crates/engine/src/parser/oracle_modal.rs b/crates/engine/src/parser/oracle_modal.rs index 61da730105..6f578cf506 100644 --- a/crates/engine/src/parser/oracle_modal.rs +++ b/crates/engine/src/parser/oracle_modal.rs @@ -1234,6 +1234,7 @@ fn lower_as_enters_anchor_word_modal( source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }; result.statics.push(placeholder); } diff --git a/crates/engine/src/parser/oracle_static/anthem.rs b/crates/engine/src/parser/oracle_static/anthem.rs index 7be6b661ad..59b4aca0cc 100644 --- a/crates/engine/src/parser/oracle_static/anthem.rs +++ b/crates/engine/src/parser/oracle_static/anthem.rs @@ -440,10 +440,13 @@ pub(crate) fn parse_subject_continuous_static(text: &str) -> Option first, @@ -1959,3 +1961,87 @@ pub(crate) fn split_keyword_list(text: &str) -> Vec> { // comma-continuation, and Oxford comma protection patterns. super::oracle_keyword::expand_protection_parts(&parts) } + +/// CR 702.16n / CR 702.16p: Parse the trailing "This effect doesn't remove …" +/// rider that accompanies a protection grant (Flickering Ward, Ward cycle, +/// Spectra Ward, Benevolent Blessing). Returns `None` when the rider is absent. +/// +/// Combinator-based word-boundary scan (parser-combinator gate): tries the +/// fixed rider prefix at each word start so the sentence may follow any +/// protection grant phrasing. +pub(crate) fn parse_protection_does_not_remove( + text: &str, +) -> Option { + use nom::branch::alt; + use nom::bytes::complete::tag; + use nom::combinator::value; + use nom::Parser; + + let lower = text.to_lowercase(); + let mut remaining = lower.as_str(); + while !remaining.is_empty() { + if let Ok((rest, ())) = value( + (), + alt(( + tag::<_, _, nom::error::Error<&str>>("this effect doesn't remove "), + tag("this effect does not remove "), + )), + ) + .parse(remaining) + { + let rest = rest.trim().trim_end_matches('.').trim(); + return parse_does_not_remove_object(rest); + } + remaining = remaining + .find(' ') + .map_or("", |i| remaining[i + 1..].trim_start()); + } + None +} + +/// CR 702.16n / CR 702.16p: Object phrase after "doesn't remove ". +fn parse_does_not_remove_object( + rest: &str, +) -> Option { + use crate::types::ability::ProtectionDoesNotRemove; + use nom::branch::alt; + use nom::bytes::complete::tag; + use nom::combinator::value; + use nom::Parser; + + // Longest matches first so Benevolent Blessing doesn't collapse to Auras. + // `~` is the post-normalization form of "this Aura" (SELF_REF_TYPE_PHRASES): + // `parse_oracle_text` rewrites self-refs before static dispatch, so Source + // must match both the raw Oracle phrase and the tilde form. + alt(( + value( + ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached, + alt(( + tag::<_, _, nom::error::Error<&str>>( + "auras and equipment you control that are already attached to it", + ), + tag("auras and equipment you control that are already attached to them"), + )), + ), + value( + ProtectionDoesNotRemove::Source, + alt((tag("this aura"), tag("~"))), + ), + value(ProtectionDoesNotRemove::Auras, tag("auras")), + )) + .parse(rest) + .ok() + .and_then(|(leftover, exemption)| leftover.trim().is_empty().then_some(exemption)) +} + +/// CR 702.16n / CR 702.16p: Stamp a parsed protection SBA-exemption rider onto +/// a continuous static when the Oracle text carries one. +pub(crate) fn with_protection_does_not_remove( + def: crate::types::ability::StaticDefinition, + text: &str, +) -> crate::types::ability::StaticDefinition { + match parse_protection_does_not_remove(text) { + Some(exemption) => def.protection_does_not_remove(exemption), + None => def, + } +} diff --git a/crates/engine/src/parser/oracle_static/mod.rs b/crates/engine/src/parser/oracle_static/mod.rs index 05f52138f8..736f91ecea 100644 --- a/crates/engine/src/parser/oracle_static/mod.rs +++ b/crates/engine/src/parser/oracle_static/mod.rs @@ -127,7 +127,7 @@ mod support { apply_spell_keyword_subject_constraints, fold_grant_cap_rider, parse_chosen_qualifier_subject, parse_continuous_modifications, parse_quoted_ability_modifications, push_grant_clause_modifications, split_keyword_list, - RuleStaticPredicate, + with_protection_does_not_remove, RuleStaticPredicate, }; pub(super) use super::restriction::{ parse_cant_be_activated_exemption_in_text, parse_cast_and_activate_only_during, diff --git a/crates/engine/src/parser/oracle_static/tests.rs b/crates/engine/src/parser/oracle_static/tests.rs index 0cf7b069ce..1d826092c0 100644 --- a/crates/engine/src/parser/oracle_static/tests.rs +++ b/crates/engine/src/parser/oracle_static/tests.rs @@ -28669,25 +28669,30 @@ fn protection_chosen_color_drops_trailing_sba_exemption_benevolent_blessing() { /// yield `Protection(ChosenColor)`. (fail-if-reverted) #[test] fn protection_chosen_color_drops_trailing_this_aura_exemption() { + use crate::types::ability::ProtectionDoesNotRemove; use crate::types::keywords::{Keyword, ProtectionTarget}; - let mods = parse_continuous_modifications( - "Enchanted creature has protection from the chosen color. This effect doesn't remove this Aura.", - ); + let text = + "Enchanted creature has protection from the chosen color. This effect doesn't remove this Aura."; + let mods = parse_continuous_modifications(text); assert!( mods.contains(&ContinuousModification::AddKeyword { keyword: Keyword::Protection(ProtectionTarget::ChosenColor), }), "expected Protection(ChosenColor), got {mods:?}" ); - assert!( - !mods.iter().any(|m| matches!( - m, - ContinuousModification::AddKeyword { - keyword: Keyword::Protection(ProtectionTarget::CardType(_)), - } - )), - "trailing prose must not be swallowed into Protection(CardType(_)), got {mods:?}" + assert_eq!( + parse_protection_does_not_remove(text), + Some(ProtectionDoesNotRemove::Source), + "CR 702.16n rider must parse as Source exemption" + ); + // `parse_oracle_text` rewrites "this Aura" → `~` before static dispatch. + assert_eq!( + parse_protection_does_not_remove( + "Enchanted creature has protection from the chosen color. This effect doesn't remove ~." + ), + Some(ProtectionDoesNotRemove::Source), + "normalized self-ref `~` must still stamp Source" ); } diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index d156d27c5d..e14863e5f5 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -19682,6 +19682,30 @@ pub struct StaticDefinition { /// serialized statics (all unrestricted) round-trip unchanged. #[serde(default, skip_serializing_if = "Option::is_none")] pub bypass_beneficiary: Option, + /// CR 702.16n / CR 702.16p: When this continuous static grants protection, + /// attachments matching this exemption are not put into their owners' + /// graveyards as a state-based action by *this* protection instance + /// (Flickering Ward / Pentarch Ward / Ward cycle / Benevolent Blessing). + /// Other protection instances from the same quality still apply normally. + /// `None` = no exemption (ordinary protection). Serde-defaulted so + /// pre-existing card-data round-trips unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub protection_does_not_remove: Option, +} + +/// CR 702.16n / CR 702.16p: Which attachments a protection-granting continuous +/// effect does not remove via SBA (and, for the already-attached form, which +/// may remain attached when the effect starts). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ProtectionDoesNotRemove { + /// CR 702.16n: "This effect doesn't remove this Aura." — the grant source. + Source, + /// CR 702.16n: "This effect doesn't remove Auras." (Spectra Ward). + Auras, + /// CR 702.16p: "doesn't remove Auras and Equipment you control that are + /// already attached to it" (Benevolent Blessing). New same-quality + /// attachments remain illegal; only already-attached controlled ones stay. + ControlledAttachmentsAlreadyAttached, } impl StaticDefinition { @@ -19701,6 +19725,7 @@ impl StaticDefinition { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, } } @@ -19718,6 +19743,12 @@ impl StaticDefinition { self } + /// CR 702.16n / CR 702.16p: Attach the protection SBA exemption rider. + pub fn protection_does_not_remove(mut self, exemption: ProtectionDoesNotRemove) -> Self { + self.protection_does_not_remove = Some(exemption); + self + } + pub fn condition(mut self, cond: StaticCondition) -> Self { self.condition = Some(cond); self @@ -23295,6 +23326,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }; let json = serde_json::to_string(&static_def).unwrap(); let deserialized: StaticDefinition = serde_json::from_str(&json).unwrap(); @@ -23588,6 +23620,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], duration: Some(Duration::UntilEndOfTurn), target: None, diff --git a/crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs b/crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs new file mode 100644 index 0000000000..b73214aec8 --- /dev/null +++ b/crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs @@ -0,0 +1,765 @@ +//! Issue #6499 — Flickering Ward cannot stay attached after choosing a color. +//! +//! Oracle: `Enchant creature` / `As this Aura enters, choose a color.` / +//! `Enchanted creature has protection from the chosen color. This effect +//! doesn't remove this Aura.` / `{W}: Return this Aura to its owner's hand.` +//! +//! Discord report: after picking a color, the Aura would not stay attached. +//! Choosing white (the Aura's color) grants protection from white; without +//! CR 702.16n's "doesn't remove this Aura" rider, SBA CR 704.5m / CR 702.16c +//! puts the white Aura into the graveyard. +//! +//! Root cause: the parser dropped the rider as "inert prose" so coverage +//! claimed the protection grant was supported while the exemption was never +//! modeled. Fix stamps `ProtectionDoesNotRemove::Source` on the continuous +//! static and honors it in `attachment_illegality`. +//! +//! DISCRIMINATING: with chosen color = white, Flickering Ward stays attached +//! and on the battlefield. A revert (no rider / no exemption check) sends it +//! to the graveyard. + +use engine::game::layers::evaluate_layers; +use engine::game::sba::check_state_based_actions; +use engine::game::zones::create_object; +use engine::parser::oracle::parse_oracle_text; +use engine::types::ability::{ChosenAttribute, ContinuousModification, ProtectionDoesNotRemove}; +use engine::types::card_type::CoreType; +use engine::types::game_state::GameState; +use engine::types::identifiers::CardId; +use engine::types::keywords::{Keyword, ProtectionTarget}; +use engine::types::mana::ManaColor; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; + +const FLICKERING_WARD: &str = "Enchant creature\n\ +As this Aura enters, choose a color.\n\ +Enchanted creature has protection from the chosen color. This effect doesn't remove this Aura.\n\ +{W}: Return this Aura to its owner's hand."; + +#[test] +fn flickering_ward_parses_protection_source_exemption() { + let parsed = parse_oracle_text( + FLICKERING_WARD, + "Flickering Ward", + &[], + &["Enchantment".to_string()], + &["Aura".to_string()], + ); + let prot = parsed + .statics + .iter() + .find(|s| { + s.protection_does_not_remove == Some(ProtectionDoesNotRemove::Source) + && s.modifications.iter().any(|m| { + matches!( + m, + ContinuousModification::AddKeyword { + keyword: Keyword::Protection(ProtectionTarget::ChosenColor), + } + ) + }) + }) + .expect("Flickering Ward must carry Protection(ChosenColor) + Source exemption"); + assert_eq!( + prot.protection_does_not_remove, + Some(ProtectionDoesNotRemove::Source) + ); +} + +#[test] +fn flickering_ward_stays_attached_after_choosing_own_color() { + let parsed = parse_oracle_text( + FLICKERING_WARD, + "Flickering Ward", + &[], + &["Enchantment".to_string()], + &["Aura".to_string()], + ); + let prot_static = parsed + .statics + .iter() + .find(|s| s.protection_does_not_remove.is_some()) + .cloned() + .expect("protection static with exemption"); + + let mut state = GameState::new_two_player(42); + let creature = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Bear".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&creature) + .unwrap() + .card_types + .core_types = vec![CoreType::Creature]; + + let aura = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Flickering Ward".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&aura).unwrap(); + obj.card_types.core_types = vec![CoreType::Enchantment]; + obj.base_card_types = obj.card_types.clone(); + obj.card_types.subtypes.push("Aura".to_string()); + obj.base_card_types.subtypes.push("Aura".to_string()); + obj.color.push(ManaColor::White); + obj.attached_to = Some(creature.into()); + obj.chosen_attributes + .push(ChosenAttribute::Color(ManaColor::White)); + obj.static_definitions.push(prot_static.clone()); + let base = std::sync::Arc::make_mut(&mut obj.base_static_definitions); + base.push(prot_static); + } + state + .objects + .get_mut(&creature) + .unwrap() + .attachments + .push(aura); + + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + + let mut events = Vec::new(); + check_state_based_actions(&mut state, &mut events); + + assert!( + state.battlefield.contains(&aura), + "CR 702.16n: Aura must stay on the battlefield after choosing white (Discord #6499)" + ); + assert_eq!( + state.objects.get(&aura).and_then(|o| o.attached_to), + Some(creature.into()), + "Aura must remain attached to the enchanted creature" + ); +} + +#[test] +fn printed_protection_still_removes_white_aura_without_rider() { + // Sanity: ordinary Pacifism on a host with printed protection from white + // is still removed — exemptions are per-grant, not global. + let mut state = GameState::new_two_player(42); + let creature = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Bear".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&creature).unwrap(); + obj.card_types.core_types = vec![CoreType::Creature]; + obj.base_keywords + .push(Keyword::Protection(ProtectionTarget::Color( + ManaColor::White, + ))); + obj.keywords = obj.base_keywords.clone(); + } + let aura = create_object( + &mut state, + CardId(2), + PlayerId(1), + "Pacifism".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&aura).unwrap(); + obj.card_types.core_types = vec![CoreType::Enchantment]; + obj.card_types.subtypes.push("Aura".to_string()); + obj.color.push(ManaColor::White); + obj.attached_to = Some(creature.into()); + } + state + .objects + .get_mut(&creature) + .unwrap() + .attachments + .push(aura); + + let mut events = Vec::new(); + check_state_based_actions(&mut state, &mut events); + + assert!( + !state.battlefield.contains(&aura), + "printed protection without a 702.16n rider still removes white Auras" + ); + assert!( + state.players[1].graveyard.contains(&aura), + "illegal Aura must go to its owner's graveyard (CR 704.5m)" + ); +} + +fn apply_host_protection_grant( + state: &mut GameState, + source_id: engine::types::identifiers::ObjectId, + host_id: engine::types::identifiers::ObjectId, + pt: ProtectionTarget, + exemption: Option, +) { + use engine::types::ability::{ContinuousModification, StaticDefinition}; + use engine::types::keywords::Keyword; + + let mut def = StaticDefinition::continuous() + .affected(engine::types::ability::TargetFilter::SpecificObject { id: host_id }) + .modifications(vec![ContinuousModification::AddKeyword { + keyword: Keyword::Protection(pt), + }]); + if let Some(exemption) = exemption { + def = def.protection_does_not_remove(exemption); + } + state + .objects + .get_mut(&source_id) + .unwrap() + .static_definitions + .push(def); +} + +fn apply_host_dual_protection_grant( + state: &mut GameState, + source_id: engine::types::identifiers::ObjectId, + host_id: engine::types::identifiers::ObjectId, + qualities: [ProtectionTarget; 2], + exemption: Option, +) { + use engine::types::ability::{ContinuousModification, StaticDefinition}; + use engine::types::keywords::Keyword; + + let mut def = StaticDefinition::continuous() + .affected(engine::types::ability::TargetFilter::SpecificObject { id: host_id }) + .modifications(vec![ + ContinuousModification::AddKeyword { + keyword: Keyword::Protection(qualities[0].clone()), + }, + ContinuousModification::AddKeyword { + keyword: Keyword::Protection(qualities[1].clone()), + }, + ]); + if let Some(exemption) = exemption { + def = def.protection_does_not_remove(exemption); + } + state + .objects + .get_mut(&source_id) + .unwrap() + .static_definitions + .push(def); +} + +#[test] +fn cr_702_16p_two_qualities_in_one_static_each_modification_snapshots_independently() { + let mut state = GameState::new_two_player(42); + let host = create_object( + &mut state, + CardId(50), + PlayerId(0), + "Bear".to_string(), + Zone::Battlefield, + ); + state.objects.get_mut(&host).unwrap().card_types.core_types = vec![CoreType::Creature]; + + let red_equipment = create_object( + &mut state, + CardId(51), + PlayerId(0), + "Red Sword".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&red_equipment).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.card_types.subtypes.push("Equipment".to_string()); + obj.color.push(ManaColor::Red); + obj.attached_to = Some(host.into()); + } + + let blue_equipment = create_object( + &mut state, + CardId(52), + PlayerId(0), + "Blue Shield".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&blue_equipment).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.card_types.subtypes.push("Equipment".to_string()); + obj.color.push(ManaColor::Blue); + obj.attached_to = Some(host.into()); + } + + state + .objects + .get_mut(&host) + .unwrap() + .attachments + .extend([red_equipment, blue_equipment]); + + let grant_source = create_object( + &mut state, + CardId(53), + PlayerId(0), + "Dual Ward".to_string(), + Zone::Battlefield, + ); + apply_host_dual_protection_grant( + &mut state, + grant_source, + host, + [ + ProtectionTarget::Color(ManaColor::Red), + ProtectionTarget::Color(ManaColor::Blue), + ], + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + + let snapshots = &state + .objects + .get(&grant_source) + .unwrap() + .protection_start_exempt_attachments; + assert!( + snapshots + .get(&(0, 0, host)) + .is_some_and(|snapshot| snapshot.attachment_ids.contains(&red_equipment)), + "red protection modification must snapshot the red Equipment at effect start" + ); + assert!( + snapshots + .get(&(0, 1, host)) + .is_some_and(|snapshot| snapshot.attachment_ids.contains(&blue_equipment)), + "blue protection modification must snapshot the blue Equipment at effect start" + ); + assert!( + !snapshots + .get(&(0, 0, host)) + .is_some_and(|snapshot| snapshot.attachment_ids.contains(&blue_equipment)), + "red snapshot must not absorb the blue Equipment" + ); + + let mut events = Vec::new(); + check_state_based_actions(&mut state, &mut events); + assert_eq!( + state + .objects + .get(&red_equipment) + .and_then(|o| o.attached_to), + Some(host.into()), + "red Equipment exempted by the red protection instance must stay attached" + ); + assert_eq!( + state + .objects + .get(&blue_equipment) + .and_then(|o| o.attached_to), + Some(host.into()), + "blue Equipment exempted by the blue protection instance must stay attached" + ); +} + +#[test] +fn cr_702_16p_snapshots_matching_controlled_attachment_at_grant_start() { + let mut state = GameState::new_two_player(42); + let host = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Bear".to_string(), + Zone::Battlefield, + ); + state.objects.get_mut(&host).unwrap().card_types.core_types = vec![CoreType::Creature]; + + let equipment = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Sword".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&equipment).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.card_types.subtypes.push("Equipment".to_string()); + obj.color.push(ManaColor::White); + obj.attached_to = Some(host.into()); + } + state + .objects + .get_mut(&host) + .unwrap() + .attachments + .push(equipment); + + let grant_source = create_object( + &mut state, + CardId(3), + PlayerId(0), + "Blessing".to_string(), + Zone::Battlefield, + ); + apply_host_protection_grant( + &mut state, + grant_source, + host, + ProtectionTarget::Color(ManaColor::White), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + + assert!( + state + .objects + .get(&grant_source) + .unwrap() + .protection_start_exempt_attachments + .get(&(0, 0, host)) + .is_some_and(|snapshot| snapshot.attachment_ids.contains(&equipment)), + "CR 702.16p: white Equipment already attached when the grant starts must be snapshotted" + ); + + let mut events = Vec::new(); + check_state_based_actions(&mut state, &mut events); + assert_eq!( + state.objects.get(&equipment).and_then(|o| o.attached_to), + Some(host.into()), + "snapshotted Equipment must survive SBA" + ); +} + +#[test] +fn cr_702_16p_does_not_retain_attachment_that_becomes_matching_later() { + let mut state = GameState::new_two_player(42); + let host = create_object( + &mut state, + CardId(10), + PlayerId(0), + "Bear".to_string(), + Zone::Battlefield, + ); + state.objects.get_mut(&host).unwrap().card_types.core_types = vec![CoreType::Creature]; + + let equipment = create_object( + &mut state, + CardId(11), + PlayerId(0), + "Sword".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&equipment).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.card_types.subtypes.push("Equipment".to_string()); + obj.attached_to = Some(host.into()); + } + state + .objects + .get_mut(&host) + .unwrap() + .attachments + .push(equipment); + + let grant_source = create_object( + &mut state, + CardId(12), + PlayerId(0), + "Blessing".to_string(), + Zone::Battlefield, + ); + apply_host_protection_grant( + &mut state, + grant_source, + host, + ProtectionTarget::Color(ManaColor::White), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + + state + .objects + .get_mut(&equipment) + .unwrap() + .base_color + .push(ManaColor::White); + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + + let mut events = Vec::new(); + check_state_based_actions(&mut state, &mut events); + + assert_eq!( + state.objects.get(&equipment).and_then(|o| o.attached_to), + None, + "Equipment that became matching only after grant start must unattach (CR 702.16p snapshot, not live check)" + ); + assert!( + state.battlefield.contains(&equipment), + "Equipment remains on the battlefield after CR 704.5n" + ); +} + +#[test] +fn cr_702_16p_second_unridered_protection_grant_still_removes_despite_snapshot() { + let mut state = GameState::new_two_player(42); + let host = create_object( + &mut state, + CardId(20), + PlayerId(0), + "Bear".to_string(), + Zone::Battlefield, + ); + state.objects.get_mut(&host).unwrap().card_types.core_types = vec![CoreType::Creature]; + + let equipment = create_object( + &mut state, + CardId(21), + PlayerId(0), + "Sword".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&equipment).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.card_types.subtypes.push("Equipment".to_string()); + obj.color.push(ManaColor::White); + obj.attached_to = Some(host.into()); + } + state + .objects + .get_mut(&host) + .unwrap() + .attachments + .push(equipment); + + let rider_source = create_object( + &mut state, + CardId(22), + PlayerId(0), + "Blessing".to_string(), + Zone::Battlefield, + ); + apply_host_protection_grant( + &mut state, + rider_source, + host, + ProtectionTarget::Color(ManaColor::White), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + + let plain_source = create_object( + &mut state, + CardId(23), + PlayerId(0), + "Extra Ward".to_string(), + Zone::Battlefield, + ); + apply_host_protection_grant( + &mut state, + plain_source, + host, + ProtectionTarget::Color(ManaColor::White), + None, + ); + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + + let mut events = Vec::new(); + check_state_based_actions(&mut state, &mut events); + + assert_eq!( + state.objects.get(&equipment).and_then(|o| o.attached_to), + None, + "second protection from white without a rider must remove despite the first grant's 702.16p snapshot" + ); +} + +#[test] +fn cr_702_16p_same_source_second_effect_does_not_inherit_first_snapshot() { + use std::sync::Arc; + + let mut state = GameState::new_two_player(42); + let host = create_object( + &mut state, + CardId(30), + PlayerId(0), + "Bear".to_string(), + Zone::Battlefield, + ); + state.objects.get_mut(&host).unwrap().card_types.core_types = vec![CoreType::Creature]; + + let equipment = create_object( + &mut state, + CardId(31), + PlayerId(0), + "Sword".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&equipment).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.card_types.subtypes.push("Equipment".to_string()); + obj.color.push(ManaColor::Blue); + obj.attached_to = Some(host.into()); + } + state + .objects + .get_mut(&host) + .unwrap() + .attachments + .push(equipment); + + let grant_source = create_object( + &mut state, + CardId(32), + PlayerId(0), + "Dual Blessing".to_string(), + Zone::Battlefield, + ); + apply_host_protection_grant( + &mut state, + grant_source, + host, + ProtectionTarget::Color(ManaColor::Blue), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + assert!(state + .objects + .get(&grant_source) + .unwrap() + .protection_start_exempt_attachments + .get(&(0, 0, host)) + .is_some_and(|snapshot| snapshot.attachment_ids.contains(&equipment))); + + { + let obj = state.objects.get_mut(&grant_source).unwrap(); + obj.static_definitions.clear(); + obj.base_static_definitions = Arc::new(Vec::new()); + obj.base_characteristics_initialized = false; + } + apply_host_protection_grant( + &mut state, + grant_source, + host, + ProtectionTarget::Color(ManaColor::White), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + assert!( + !state + .objects + .get(&grant_source) + .unwrap() + .protection_start_exempt_attachments + .get(&(0, 0, host)) + .is_some_and(|snapshot| snapshot.attachment_ids.contains(&equipment)), + "white rider must not inherit blue-rider snapshot at the same effect slot" + ); + + state + .objects + .get_mut(&equipment) + .unwrap() + .base_color + .push(ManaColor::White); + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + + let mut events = Vec::new(); + check_state_based_actions(&mut state, &mut events); + assert_eq!( + state.objects.get(&equipment).and_then(|o| o.attached_to), + None, + "Equipment that became white after the white rider started must unattach" + ); +} + +#[test] +fn cr_702_16p_opponent_controlled_matching_attachment_not_exempt() { + let mut state = GameState::new_two_player(42); + let host = create_object( + &mut state, + CardId(40), + PlayerId(0), + "Bear".to_string(), + Zone::Battlefield, + ); + state.objects.get_mut(&host).unwrap().card_types.core_types = vec![CoreType::Creature]; + + let equipment = create_object( + &mut state, + CardId(41), + PlayerId(1), + "Sword".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&equipment).unwrap(); + obj.card_types.core_types = vec![CoreType::Artifact]; + obj.card_types.subtypes.push("Equipment".to_string()); + obj.color.push(ManaColor::White); + obj.controller = PlayerId(1); + obj.base_controller = Some(PlayerId(1)); + obj.attached_to = Some(host.into()); + } + state + .objects + .get_mut(&host) + .unwrap() + .attachments + .push(equipment); + + let grant_source = create_object( + &mut state, + CardId(42), + PlayerId(0), + "Blessing".to_string(), + Zone::Battlefield, + ); + apply_host_protection_grant( + &mut state, + grant_source, + host, + ProtectionTarget::Color(ManaColor::White), + Some(ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached), + ); + state.layers_dirty.mark_full(); + evaluate_layers(&mut state); + + assert!( + !state + .objects + .get(&grant_source) + .unwrap() + .protection_start_exempt_attachments + .get(&(0, 0, host)) + .is_some_and(|snapshot| snapshot.attachment_ids.contains(&equipment)), + "702.16p only exempts controlled attachments at effect start" + ); + + let mut events = Vec::new(); + check_state_based_actions(&mut state, &mut events); + assert_eq!( + state.objects.get(&equipment).and_then(|o| o.attached_to), + None, + "opponent-controlled matching Equipment must unattach" + ); +} diff --git a/crates/engine/tests/integration/kaito_integration.rs b/crates/engine/tests/integration/kaito_integration.rs index 9da46cca80..4a37fa84e6 100644 --- a/crates/engine/tests/integration/kaito_integration.rs +++ b/crates/engine/tests/integration/kaito_integration.rs @@ -67,6 +67,7 @@ fn ninja_pump_static() -> StaticDefinition { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, } } diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 4b97a3e1d5..9b4772a2a2 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -584,6 +584,7 @@ mod issue_6102_ragavan_exile_cast; mod issue_6157_gold_token_auto_mana_payment; mod issue_629_fractured_sanity_cycling; mod issue_6498_portent_of_calamity; +mod issue_6499_flickering_ward_protection_exemption; mod issue_6500_loreseekers_stone_hand_cost; mod issue_654_stridehangar_automaton; mod issue_680_shalai_and_hallar_forgotten_ancient; diff --git a/crates/engine/tests/integration/najeela_extra_combat_grant_2898.rs b/crates/engine/tests/integration/najeela_extra_combat_grant_2898.rs index c6d970d9ea..5d5efb0d59 100644 --- a/crates/engine/tests/integration/najeela_extra_combat_grant_2898.rs +++ b/crates/engine/tests/integration/najeela_extra_combat_grant_2898.rs @@ -83,6 +83,7 @@ fn najeela_chain(source: ObjectId, controller: PlayerId) -> ResolvedAbility { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], duration: Some(Duration::UntilEndOfTurn), target: None, diff --git a/crates/phase-ai/src/features/aggro_pressure.rs b/crates/phase-ai/src/features/aggro_pressure.rs index 1351480d2f..680ff16e80 100644 --- a/crates/phase-ai/src/features/aggro_pressure.rs +++ b/crates/phase-ai/src/features/aggro_pressure.rs @@ -505,6 +505,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }; let mut face = creature_face(2); face.static_abilities.push(static_haste); @@ -598,6 +599,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }; let face = CardFace { mana_cost: ManaCost::generic(3), diff --git a/crates/phase-ai/src/policies/planeswalker_loyalty.rs b/crates/phase-ai/src/policies/planeswalker_loyalty.rs index 3cb03c1775..4feafbab8a 100644 --- a/crates/phase-ai/src/policies/planeswalker_loyalty.rs +++ b/crates/phase-ai/src/policies/planeswalker_loyalty.rs @@ -206,6 +206,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], target: None, duration: None, diff --git a/crates/phase-ai/src/policies/reactive_self_protection.rs b/crates/phase-ai/src/policies/reactive_self_protection.rs index ab4b60de65..f4f0c69134 100644 --- a/crates/phase-ai/src/policies/reactive_self_protection.rs +++ b/crates/phase-ai/src/policies/reactive_self_protection.rs @@ -169,6 +169,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], target, duration: None, @@ -275,6 +276,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], target: None, duration: None, @@ -425,6 +427,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], target: None, duration: None, @@ -510,6 +513,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], target: None, duration: None,