From 9f6fad55e83282e8e0757df23ee8b04c7158ff8e Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Thu, 23 Jul 2026 09:39:20 +0000 Subject: [PATCH 1/7] fix(engine): honor CR 702.16n protection Aura exemption (Flickering Ward #6499) Stamp ProtectionDoesNotRemove on continuous protection grants (including post-normalization `~`) and skip SBA removal for matching exempted attachments. Co-authored-by: Cursor --- PR_6499.md | 26 +++ crates/engine/src/ai_support/mod.rs | 2 + crates/engine/src/game/casting_tests.rs | 4 + crates/engine/src/game/coverage.rs | 12 ++ crates/engine/src/game/dungeon.rs | 1 + crates/engine/src/game/effects/attach.rs | 177 +++++++++++++++- .../engine/src/game/effects/create_emblem.rs | 1 + crates/engine/src/parser/oracle_modal.rs | 1 + .../engine/src/parser/oracle_static/anthem.rs | 21 +- .../src/parser/oracle_static/keyword_grant.rs | 90 +++++++- crates/engine/src/parser/oracle_static/mod.rs | 2 +- .../engine/src/parser/oracle_static/tests.rs | 27 ++- crates/engine/src/types/ability.rs | 33 +++ ...99_flickering_ward_protection_exemption.rs | 198 ++++++++++++++++++ .../tests/integration/kaito_integration.rs | 1 + crates/engine/tests/integration/main.rs | 1 + .../najeela_extra_combat_grant_2898.rs | 1 + .../phase-ai/src/features/aggro_pressure.rs | 2 + .../src/policies/planeswalker_loyalty.rs | 1 + .../src/policies/reactive_self_protection.rs | 4 + 20 files changed, 582 insertions(+), 23 deletions(-) create mode 100644 PR_6499.md create mode 100644 crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs diff --git a/PR_6499.md b/PR_6499.md new file mode 100644 index 0000000000..2367a7470f --- /dev/null +++ b/PR_6499.md @@ -0,0 +1,26 @@ +Closes #6499 + +## Summary + +Discord report: **Flickering Ward** could not stay attached after choosing a color. + +Oracle: `Enchanted creature has protection from the chosen color. This effect doesn't remove this Aura.` + +Choosing white (the Aura's color) grants protection from white. Without the CR 702.16n rider, SBA CR 704.5m / CR 702.16c puts the Aura in the graveyard — which matches the report. + +## Root cause + +Coverage claimed the protection grant was supported, but the parser deliberately dropped `"This effect doesn't remove this Aura"` as inert prose so the keyword would parse. The exemption was never modeled, so runtime treated the grant like ordinary protection. + +## Changes + +- **`ProtectionDoesNotRemove`** on `StaticDefinition` — `Source` / `Auras` / `ControlledAttachmentsAlreadyAttached` (CR 702.16n / 702.16p class). +- **Parser** — recover the rider onto the continuous static (`parse_protection_does_not_remove`); still strip it from the keyword leg so `Protection(ChosenColor)` parses cleanly. Source matches both `"this Aura"` and post-normalization `~`. +- **Runtime** — `attachment_illegality` walks continuous protection grants and honors per-grant exemptions (other protection instances still apply). +- **Tests** — parse Source exemption; choose white → Aura stays attached; printed protection without rider still blocks. + +## Test Plan + +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo test -p engine --lib -- protection_chosen_color_drops_trailing_this_aura` +- [ ] `cargo test -p engine --test integration -- flickering_ward` 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 6012ec3751..2ed148e184 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 7ff4b55ebd..d085b3c507 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -12178,6 +12178,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], duration: Some(Duration::UntilEndOfTurn), target: None, @@ -12225,6 +12226,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }], duration: Some(Duration::UntilEndOfTurn), target: None, @@ -13360,6 +13362,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()); @@ -13394,6 +13397,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()); @@ -13426,6 +13430,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); let findings = audit_card_lines(oracle, &face); @@ -13576,6 +13581,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); assert!( @@ -13608,6 +13614,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); assert!( @@ -13650,6 +13657,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); let gaps = card_face_gaps(&face); @@ -13683,6 +13691,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); let gaps = card_face_gaps(&face); @@ -13718,6 +13727,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); let gaps = card_face_gaps(&face); @@ -13759,6 +13769,7 @@ mod tests { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, }); } @@ -13927,6 +13938,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..1791ac27d8 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,177 @@ 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 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 modification in &def.modifications { + 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, + host_id, + attachment_id, + source_id, + 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()), + } +} + +/// 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, + host_id: ObjectId, + attachment_id: ObjectId, + grant_source_id: ObjectId, + exemption: Option<&crate::types::ability::ProtectionDoesNotRemove>, +) -> bool { + use crate::types::ability::ProtectionDoesNotRemove; + + 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: already-attached Auras/Equipment you control + ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached => { + let Some(source) = state.objects.get(&grant_source_id) else { + return false; + }; + let is_aura_or_equipment = attachment + .card_types + .subtypes + .iter() + .any(|s| s.eq_ignore_ascii_case("Aura") || s.eq_ignore_ascii_case("Equipment")); + let controlled_by_source_controller = attachment.controller == source.controller; + let already_on_host = matches!( + attachment.attached_to, + Some(AttachTarget::Object(h)) if h == host_id + ); + is_aura_or_equipment && controlled_by_source_controller && already_on_host + } + } +} + /// 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`. /// 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/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 b55e8cf8c5..c1b6bb0ebf 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -19613,6 +19613,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 { @@ -19632,6 +19656,7 @@ impl StaticDefinition { source_controller: None, source_object: None, bypass_beneficiary: None, + protection_does_not_remove: None, } } @@ -19649,6 +19674,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 @@ -23173,6 +23204,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(); @@ -23466,6 +23498,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..13c510e361 --- /dev/null +++ b/crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs @@ -0,0 +1,198 @@ +//! 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)" + ); +} 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 228c5b68f9..6b6fdc38a7 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -576,6 +576,7 @@ mod issue_6006_aminatou_veil_piercer_own_turn_miracle; mod issue_6092_ability_block_reason; mod issue_6102_ragavan_exile_cast; mod issue_629_fractured_sanity_cycling; +mod issue_6499_flickering_ward_protection_exemption; mod issue_654_stridehangar_automaton; mod issue_680_shalai_and_hallar_forgotten_ancient; mod issue_680_shalai_upkeep_move; 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, From 3ffe952441720a7bdb689209e4d207789835240e Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Thu, 23 Jul 2026 13:10:22 +0000 Subject: [PATCH 2/7] fix(engine): snapshot CR 702.16p attachment exemptions at grant start 702.16p fixes exempt attachments when protection begins applying, not on every later legality check. Record per-grant attachment IDs on the source, refresh once from layer evaluation, and add regressions for start-time retention, late-matching removal, and a second un-ridered grant. Co-authored-by: Cursor --- crates/engine/src/game/effects/attach.rs | 381 +++++++++++++++++- crates/engine/src/game/game_object.rs | 9 + crates/engine/src/game/layers.rs | 1 + ...99_flickering_ward_protection_exemption.rs | 247 ++++++++++++ 4 files changed, 621 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/game/effects/attach.rs b/crates/engine/src/game/effects/attach.rs index 1791ac27d8..054dfb2ca5 100644 --- a/crates/engine/src/game/effects/attach.rs +++ b/crates/engine/src/game/effects/attach.rs @@ -841,6 +841,162 @@ fn resolve_protection_target_for_grant( } } +/// 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 { + 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 affected = def.affected.clone().unwrap_or(TargetFilter::Any); + let ctx = FilterContext::from_source(state, source_id); + for modification in &def.modifications { + 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 { + 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_hosts = active_by_source + .get(&source_id) + .map(|grants| grants.iter().map(|g| 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(|host_id, _| active_hosts.contains(host_id)); + } 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 already_snapshotted = state.objects.get(&source_id).is_some_and(|source| { + source + .protection_start_exempt_attachments + .contains_key(&grant.host_id) + }); + if already_snapshotted { + continue; + } + to_capture.push(( + source_id, + grant.host_id, + grant.resolved_pt, + grant.controller, + )); + } + } + + for (source_id, host_id, resolved_pt, controller) in to_capture { + let snapshot = + capture_protection_start_attachment_snapshot(state, host_id, controller, &resolved_pt); + state + .objects + .get_mut(&source_id) + .expect("grant source must exist") + .protection_start_exempt_attachments + .insert(host_id, 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( @@ -867,23 +1023,13 @@ fn protection_grant_exempts_attachment( .subtypes .iter() .any(|s| s.eq_ignore_ascii_case("Aura")), - // CR 702.16p: already-attached Auras/Equipment you control - ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached => { - let Some(source) = state.objects.get(&grant_source_id) else { - return false; - }; - let is_aura_or_equipment = attachment - .card_types - .subtypes - .iter() - .any(|s| s.eq_ignore_ascii_case("Aura") || s.eq_ignore_ascii_case("Equipment")); - let controlled_by_source_controller = attachment.controller == source.controller; - let already_on_host = matches!( - attachment.attached_to, - Some(AttachTarget::Object(h)) if h == host_id - ); - is_aura_or_equipment && controlled_by_source_controller && already_on_host - } + // CR 702.16p: only attachments that matched the grant's protection + // quality and were already attached when this grant started applying. + ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached => state + .objects + .get(&grant_source_id) + .and_then(|source| source.protection_start_exempt_attachments.get(&host_id)) + .is_some_and(|snapshot| snapshot.contains(&attachment_id)), } } @@ -2493,4 +2639,205 @@ 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); + } + + #[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 = state + .objects + .get(&grant_source) + .unwrap() + .protection_start_exempt_attachments + .get(&host) + .cloned() + .unwrap_or_default(); + 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!( + !state + .objects + .get(&grant_source) + .unwrap() + .protection_start_exempt_attachments + .get(&host) + .is_some_and(|snapshot| snapshot.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" + ); + } } diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index 010e9b33a4..11c625a6f1 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -381,6 +381,12 @@ pub struct GameObject { /// `None` if unattached. See `AttachTarget` for variants. pub attached_to: Option, pub attachments: Vec, + /// CR 702.16p: For each host this object grants protection to with a + /// `ControlledAttachmentsAlreadyAttached` rider, the attachment `ObjectId`s + /// that matched the grant's protection quality and were already attached + /// when that grant 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 +1255,7 @@ fn _gameobject_partition_is_total(o: &GameObject) { phyrexian_life_paid: _, mana_spent_source_snapshots: _, phase_status: _, + protection_start_exempt_attachments: _, } = o; } @@ -1981,6 +1988,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 +2363,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 6e7f427dbe..61859119fd 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -2243,6 +2243,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/tests/integration/issue_6499_flickering_ward_protection_exemption.rs b/crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs index 13c510e361..38dc31e0a6 100644 --- a/crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs +++ b/crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs @@ -196,3 +196,250 @@ fn printed_protection_still_removes_white_aura_without_rider() { "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); +} + +#[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(&host) + .is_some_and(|snapshot| snapshot.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" + ); +} From 9caefe3f2a050f1fefd6fa490aed1200f13f1eae Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 23 Jul 2026 07:09:31 -0700 Subject: [PATCH 3/7] fix(PR-6542): strip accidental review note --- PR_6499.md | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 PR_6499.md diff --git a/PR_6499.md b/PR_6499.md deleted file mode 100644 index 2367a7470f..0000000000 --- a/PR_6499.md +++ /dev/null @@ -1,26 +0,0 @@ -Closes #6499 - -## Summary - -Discord report: **Flickering Ward** could not stay attached after choosing a color. - -Oracle: `Enchanted creature has protection from the chosen color. This effect doesn't remove this Aura.` - -Choosing white (the Aura's color) grants protection from white. Without the CR 702.16n rider, SBA CR 704.5m / CR 702.16c puts the Aura in the graveyard — which matches the report. - -## Root cause - -Coverage claimed the protection grant was supported, but the parser deliberately dropped `"This effect doesn't remove this Aura"` as inert prose so the keyword would parse. The exemption was never modeled, so runtime treated the grant like ordinary protection. - -## Changes - -- **`ProtectionDoesNotRemove`** on `StaticDefinition` — `Source` / `Auras` / `ControlledAttachmentsAlreadyAttached` (CR 702.16n / 702.16p class). -- **Parser** — recover the rider onto the continuous static (`parse_protection_does_not_remove`); still strip it from the keyword leg so `Protection(ChosenColor)` parses cleanly. Source matches both `"this Aura"` and post-normalization `~`. -- **Runtime** — `attachment_illegality` walks continuous protection grants and honors per-grant exemptions (other protection instances still apply). -- **Tests** — parse Source exemption; choose white → Aura stays attached; printed protection without rider still blocks. - -## Test Plan - -- [ ] `cargo fmt --all -- --check` -- [ ] `cargo test -p engine --lib -- protection_chosen_color_drops_trailing_this_aura` -- [ ] `cargo test -p engine --test integration -- flickering_ward` From 156209be80e2f8817abe491e84449e3bd262a788 Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Thu, 23 Jul 2026 15:13:06 +0000 Subject: [PATCH 4/7] fix(engine): scope CR 702.16p snapshots per StaticGateKey effect Key attachment exemptions by (def_index, host) on the grant source rather than source/host alone, store resolved quality with each snapshot so a replaced effect at the same slot cannot inherit a prior capture, and add regressions for same-source effect replacement and opponent control. Co-authored-by: Cursor --- crates/engine/src/game/effects/attach.rs | 229 +++++++++++++++--- crates/engine/src/game/game_object.rs | 17 +- ...99_flickering_ward_protection_exemption.rs | 180 +++++++++++++- 3 files changed, 387 insertions(+), 39 deletions(-) diff --git a/crates/engine/src/game/effects/attach.rs b/crates/engine/src/game/effects/attach.rs index 054dfb2ca5..b769531153 100644 --- a/crates/engine/src/game/effects/attach.rs +++ b/crates/engine/src/game/effects/attach.rs @@ -748,6 +748,7 @@ fn protection_blocks_attachment( 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) { @@ -774,6 +775,8 @@ fn protection_blocks_attachment( host_id, attachment_id, source_id, + def_index, + &resolved, def.protection_does_not_remove.as_ref(), ) { return true; @@ -841,6 +844,23 @@ fn resolve_protection_target_for_grant( } } +/// Composite key for a protection effect (`StaticGateKey::def_index` on the +/// grant source) plus the host it applies to. +type ProtectionEffectHostKey = (usize, ObjectId); + +/// 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( @@ -889,6 +909,7 @@ pub(crate) fn refresh_protection_start_attachment_snapshots(state: &mut GameStat use std::collections::{HashMap, HashSet}; struct ActiveGrant { + def_index: usize, host_id: ObjectId, resolved_pt: crate::types::keywords::ProtectionTarget, controller: PlayerId, @@ -906,6 +927,7 @@ pub(crate) fn refresh_protection_start_attachment_snapshots(state: &mut GameStat 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 modification in &def.modifications { @@ -927,6 +949,7 @@ pub(crate) fn refresh_protection_start_attachment_snapshots(state: &mut GameStat .entry(source_id) .or_default() .push(ActiveGrant { + def_index, host_id, resolved_pt: resolved_pt.clone(), controller: source_obj.controller, @@ -944,9 +967,14 @@ pub(crate) fn refresh_protection_start_attachment_snapshots(state: &mut GameStat if source.protection_start_exempt_attachments.is_empty() { continue; } - let active_hosts = active_by_source + let active_keys = active_by_source .get(&source_id) - .map(|grants| grants.iter().map(|g| g.host_id).collect::>()) + .map(|grants| { + grants + .iter() + .map(|g| (g.def_index, g.host_id)) + .collect::>() + }) .unwrap_or_default(); if active_sources.contains(&source_id) { state @@ -954,7 +982,7 @@ pub(crate) fn refresh_protection_start_attachment_snapshots(state: &mut GameStat .get_mut(&source_id) .expect("battlefield object") .protection_start_exempt_attachments - .retain(|host_id, _| active_hosts.contains(host_id)); + .retain(|key, _| active_keys.contains(key)); } else { state .objects @@ -968,32 +996,35 @@ pub(crate) fn refresh_protection_start_attachment_snapshots(state: &mut GameStat let mut to_capture = Vec::new(); for (source_id, grants) in active_by_source { for grant in grants { + let key = (grant.def_index, grant.host_id); let already_snapshotted = state.objects.get(&source_id).is_some_and(|source| { source .protection_start_exempt_attachments - .contains_key(&grant.host_id) + .get(&key) + .is_some_and(|entry| entry.resolved_quality == grant.resolved_pt) }); if already_snapshotted { continue; } - to_capture.push(( - source_id, - grant.host_id, - grant.resolved_pt, - grant.controller, - )); + to_capture.push((source_id, key, grant.resolved_pt, grant.controller)); } } - for (source_id, host_id, resolved_pt, controller) in to_capture { + for (source_id, key, resolved_pt, controller) in to_capture { let snapshot = - capture_protection_start_attachment_snapshot(state, host_id, controller, &resolved_pt); + capture_protection_start_attachment_snapshot(state, key.1, controller, &resolved_pt); state .objects .get_mut(&source_id) .expect("grant source must exist") .protection_start_exempt_attachments - .insert(host_id, snapshot); + .insert( + key, + crate::game::game_object::ProtectionStartSnapshot { + resolved_quality: resolved_pt, + attachment_ids: snapshot, + }, + ); } } @@ -1004,6 +1035,8 @@ fn protection_grant_exempts_attachment( host_id: ObjectId, attachment_id: ObjectId, grant_source_id: ObjectId, + grant_def_index: usize, + resolved_pt: &crate::types::keywords::ProtectionTarget, exemption: Option<&crate::types::ability::ProtectionDoesNotRemove>, ) -> bool { use crate::types::ability::ProtectionDoesNotRemove; @@ -1023,13 +1056,20 @@ fn protection_grant_exempts_attachment( .subtypes .iter() .any(|s| s.eq_ignore_ascii_case("Aura")), - // CR 702.16p: only attachments that matched the grant's protection - // quality and were already attached when this grant started applying. + // CR 702.16p: only attachments snapshotted for this specific effect + // (`StaticGateKey::def_index`) and host when it started applying. ProtectionDoesNotRemove::ControlledAttachmentsAlreadyAttached => state .objects .get(&grant_source_id) - .and_then(|source| source.protection_start_exempt_attachments.get(&host_id)) - .is_some_and(|snapshot| snapshot.contains(&attachment_id)), + .and_then(|source| { + source + .protection_start_exempt_attachments + .get(&(grant_def_index, host_id)) + }) + .is_some_and(|entry| { + entry.resolved_quality == *resolved_pt + && entry.attachment_ids.contains(&attachment_id) + }), } } @@ -2681,6 +2721,33 @@ mod tests { crate::game::layers::evaluate_layers(state); } + fn protection_snapshot_ids( + state: &GameState, + source_id: ObjectId, + def_index: usize, + host_id: ObjectId, + ) -> Vec { + state + .objects + .get(&source_id) + .and_then(|source| { + source + .protection_start_exempt_attachments + .get(&(def_index, host_id)) + }) + .map(|entry| entry.attachment_ids.clone()) + .unwrap_or_default() + } + + fn reset_source_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; + obj.protection_start_exempt_attachments.clear(); + } + #[test] fn cr_702_16p_exempts_matching_controlled_attachment_at_grant_start() { use crate::types::ability::ProtectionDoesNotRemove; @@ -2706,14 +2773,7 @@ mod tests { ); evaluate_protection_layers(&mut state); - let snapshot = state - .objects - .get(&grant_source) - .unwrap() - .protection_start_exempt_attachments - .get(&host) - .cloned() - .unwrap_or_default(); + let snapshot = protection_snapshot_ids(&state, grant_source, 0, host); assert!( snapshot.contains(&equipment), "CR 702.16p: matching controlled attachment at grant start must be snapshotted" @@ -2755,13 +2815,7 @@ mod tests { evaluate_protection_layers(&mut state); assert!( - !state - .objects - .get(&grant_source) - .unwrap() - .protection_start_exempt_attachments - .get(&host) - .is_some_and(|snapshot| snapshot.contains(&equipment)), + !protection_snapshot_ids(&state, grant_source, 0, host).contains(&equipment), "colorless Equipment must not enter the start-time snapshot" ); @@ -2840,4 +2894,115 @@ mod tests { "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, host).contains(&equipment), + "blue rider must snapshot the blue Equipment at effect start" + ); + + reset_source_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, 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, 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/game_object.rs b/crates/engine/src/game/game_object.rs index 11c625a6f1..b2865c07bf 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -340,6 +340,14 @@ pub struct EmblemSource { pub printed_ref: Option, } +/// CR 702.16p: Start-time attachment exemption captured for one continuous +/// protection effect (`StaticGateKey::def_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, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct GameObject { pub id: ObjectId, @@ -381,12 +389,11 @@ pub struct GameObject { /// `None` if unattached. See `AttachTarget` for variants. pub attached_to: Option, pub attachments: Vec, - /// CR 702.16p: For each host this object grants protection to with a - /// `ControlledAttachmentsAlreadyAttached` rider, the attachment `ObjectId`s - /// that matched the grant's protection quality and were already attached - /// when that grant first started applying to that host. + /// 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>, + pub protection_start_exempt_attachments: HashMap<(usize, ObjectId), ProtectionStartSnapshot>, /// 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 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 index 38dc31e0a6..7b5d1d0b68 100644 --- a/crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs +++ b/crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs @@ -280,8 +280,8 @@ fn cr_702_16p_snapshots_matching_controlled_attachment_at_grant_start() { .get(&grant_source) .unwrap() .protection_start_exempt_attachments - .get(&host) - .is_some_and(|snapshot| snapshot.contains(&equipment)), + .get(&(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" ); @@ -443,3 +443,179 @@ fn cr_702_16p_second_unridered_protection_grant_still_removes_despite_snapshot() "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, 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; + obj.protection_start_exempt_attachments.clear(); + } + 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, 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, 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" + ); +} From abc0d9020d735d7cc1716eedcce194fdf5bc168b Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Thu, 23 Jul 2026 18:47:36 +0000 Subject: [PATCH 5/7] fix --- crates/engine/src/game/effects/attach.rs | 44 ++--- crates/engine/src/game/game_object.rs | 9 +- ...99_flickering_ward_protection_exemption.rs | 153 +++++++++++++++++- 3 files changed, 181 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/game/effects/attach.rs b/crates/engine/src/game/effects/attach.rs index b769531153..a7c04e8a19 100644 --- a/crates/engine/src/game/effects/attach.rs +++ b/crates/engine/src/game/effects/attach.rs @@ -754,7 +754,7 @@ fn protection_blocks_attachment( if !matches_target_filter(state, host_id, &affected, &ctx) { continue; } - for modification in &def.modifications { + for (mod_index, modification) in def.modifications.iter().enumerate() { let ContinuousModification::AddKeyword { keyword: Keyword::Protection(pt), } = modification @@ -776,6 +776,7 @@ fn protection_blocks_attachment( attachment_id, source_id, def_index, + mod_index, &resolved, def.protection_does_not_remove.as_ref(), ) { @@ -846,7 +847,7 @@ fn resolve_protection_target_for_grant( /// Composite key for a protection effect (`StaticGateKey::def_index` on the /// grant source) plus the host it applies to. -type ProtectionEffectHostKey = (usize, ObjectId); +use crate::game::game_object::ProtectionEffectHostKey; /// Live `static_definitions` index for an active static returned by /// `battlefield_active_statics`. @@ -910,6 +911,7 @@ pub(crate) fn refresh_protection_start_attachment_snapshots(state: &mut GameStat struct ActiveGrant { def_index: usize, + mod_index: usize, host_id: ObjectId, resolved_pt: crate::types::keywords::ProtectionTarget, controller: PlayerId, @@ -930,7 +932,7 @@ pub(crate) fn refresh_protection_start_attachment_snapshots(state: &mut GameStat 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 modification in &def.modifications { + for (mod_index, modification) in def.modifications.iter().enumerate() { let ContinuousModification::AddKeyword { keyword: Keyword::Protection(pt), } = modification @@ -950,6 +952,7 @@ pub(crate) fn refresh_protection_start_attachment_snapshots(state: &mut GameStat .or_default() .push(ActiveGrant { def_index, + mod_index, host_id, resolved_pt: resolved_pt.clone(), controller: source_obj.controller, @@ -972,7 +975,7 @@ pub(crate) fn refresh_protection_start_attachment_snapshots(state: &mut GameStat .map(|grants| { grants .iter() - .map(|g| (g.def_index, g.host_id)) + .map(|g| (g.def_index, g.mod_index, g.host_id)) .collect::>() }) .unwrap_or_default(); @@ -996,12 +999,11 @@ pub(crate) fn refresh_protection_start_attachment_snapshots(state: &mut GameStat let mut to_capture = Vec::new(); for (source_id, grants) in active_by_source { for grant in grants { - let key = (grant.def_index, grant.host_id); + 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) + .contains_key(&key) }); if already_snapshotted { continue; @@ -1012,7 +1014,7 @@ pub(crate) fn refresh_protection_start_attachment_snapshots(state: &mut GameStat for (source_id, key, resolved_pt, controller) in to_capture { let snapshot = - capture_protection_start_attachment_snapshot(state, key.1, controller, &resolved_pt); + capture_protection_start_attachment_snapshot(state, key.2, controller, &resolved_pt); state .objects .get_mut(&source_id) @@ -1036,6 +1038,7 @@ fn protection_grant_exempts_attachment( attachment_id: ObjectId, grant_source_id: ObjectId, grant_def_index: usize, + grant_mod_index: usize, resolved_pt: &crate::types::keywords::ProtectionTarget, exemption: Option<&crate::types::ability::ProtectionDoesNotRemove>, ) -> bool { @@ -1056,15 +1059,17 @@ fn protection_grant_exempts_attachment( .subtypes .iter() .any(|s| s.eq_ignore_ascii_case("Aura")), - // CR 702.16p: only attachments snapshotted for this specific effect - // (`StaticGateKey::def_index`) and host when it started applying. + // 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, host_id)) + source.protection_start_exempt_attachments.get(&( + grant_def_index, + grant_mod_index, + host_id, + )) }) .is_some_and(|entry| { entry.resolved_quality == *resolved_pt @@ -2725,6 +2730,7 @@ mod tests { state: &GameState, source_id: ObjectId, def_index: usize, + mod_index: usize, host_id: ObjectId, ) -> Vec { state @@ -2733,7 +2739,7 @@ mod tests { .and_then(|source| { source .protection_start_exempt_attachments - .get(&(def_index, host_id)) + .get(&(def_index, mod_index, host_id)) }) .map(|entry| entry.attachment_ids.clone()) .unwrap_or_default() @@ -2773,7 +2779,7 @@ mod tests { ); evaluate_protection_layers(&mut state); - let snapshot = protection_snapshot_ids(&state, grant_source, 0, host); + 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" @@ -2815,7 +2821,7 @@ mod tests { evaluate_protection_layers(&mut state); assert!( - !protection_snapshot_ids(&state, grant_source, 0, host).contains(&equipment), + !protection_snapshot_ids(&state, grant_source, 0, 0, host).contains(&equipment), "colorless Equipment must not enter the start-time snapshot" ); @@ -2920,7 +2926,7 @@ mod tests { ); evaluate_protection_layers(&mut state); assert!( - protection_snapshot_ids(&state, grant_source, 0, host).contains(&equipment), + protection_snapshot_ids(&state, grant_source, 0, 0, host).contains(&equipment), "blue rider must snapshot the blue Equipment at effect start" ); @@ -2934,7 +2940,7 @@ mod tests { ); evaluate_protection_layers(&mut state); assert!( - !protection_snapshot_ids(&state, grant_source, 0, host).contains(&equipment), + !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" ); @@ -2989,7 +2995,7 @@ mod tests { evaluate_protection_layers(&mut state); assert!( - !protection_snapshot_ids(&state, grant_source, 0, host).contains(&equipment), + !protection_snapshot_ids(&state, grant_source, 0, 0, host).contains(&equipment), "702.16p only exempts attachments you control at effect start" ); assert_eq!( diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs index b2865c07bf..a159c48880 100644 --- a/crates/engine/src/game/game_object.rs +++ b/crates/engine/src/game/game_object.rs @@ -341,13 +341,17 @@ pub struct EmblemSource { } /// CR 702.16p: Start-time attachment exemption captured for one continuous -/// protection effect (`StaticGateKey::def_index` on the grant source) and host. +/// 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, @@ -393,7 +397,8 @@ pub struct GameObject { /// 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<(usize, ObjectId), ProtectionStartSnapshot>, + 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 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 index 7b5d1d0b68..fe0270bb8d 100644 --- a/crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs +++ b/crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs @@ -223,6 +223,151 @@ fn apply_host_protection_grant( .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); @@ -280,7 +425,7 @@ fn cr_702_16p_snapshots_matching_controlled_attachment_at_grant_start() { .get(&grant_source) .unwrap() .protection_start_exempt_attachments - .get(&(0, host)) + .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" ); @@ -500,7 +645,7 @@ fn cr_702_16p_same_source_second_effect_does_not_inherit_first_snapshot() { .get(&grant_source) .unwrap() .protection_start_exempt_attachments - .get(&(0, host)) + .get(&(0, 0, host)) .is_some_and(|snapshot| snapshot.attachment_ids.contains(&equipment))); { @@ -525,7 +670,7 @@ fn cr_702_16p_same_source_second_effect_does_not_inherit_first_snapshot() { .get(&grant_source) .unwrap() .protection_start_exempt_attachments - .get(&(0, host)) + .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" ); @@ -606,7 +751,7 @@ fn cr_702_16p_opponent_controlled_matching_attachment_not_exempt() { .get(&grant_source) .unwrap() .protection_start_exempt_attachments - .get(&(0, host)) + .get(&(0, 0, host)) .is_some_and(|snapshot| snapshot.attachment_ids.contains(&equipment)), "702.16p only exempts controlled attachments at effect start" ); From d3ea81ca7f1a735ecd8638d33b585961d6accccf Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Thu, 23 Jul 2026 21:15:00 +0000 Subject: [PATCH 6/7] fix(engine): address CR 702.16p review blockers for PR #6542 Bundle grant identity into ProtectionEffectHostKey to satisfy clippy, re-snapshot when resolved protection quality changes at the same key, and stop clearing snapshot maps in the inherit-prevention regression. Co-authored-by: Cursor --- crates/engine/src/game/effects/attach.rs | 22 +++++++++---------- ...99_flickering_ward_protection_exemption.rs | 1 - 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/game/effects/attach.rs b/crates/engine/src/game/effects/attach.rs index a7c04e8a19..6c7a5f52f6 100644 --- a/crates/engine/src/game/effects/attach.rs +++ b/crates/engine/src/game/effects/attach.rs @@ -772,11 +772,9 @@ fn protection_blocks_attachment( any_matching_grant = true; if !protection_grant_exempts_attachment( state, - host_id, attachment_id, source_id, - def_index, - mod_index, + (def_index, mod_index, host_id), &resolved, def.protection_does_not_remove.as_ref(), ) { @@ -845,8 +843,8 @@ fn resolve_protection_target_for_grant( } } -/// Composite key for a protection effect (`StaticGateKey::def_index` on the -/// grant source) plus the host it applies to. +/// 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 @@ -1003,7 +1001,8 @@ pub(crate) fn refresh_protection_start_attachment_snapshots(state: &mut GameStat let already_snapshotted = state.objects.get(&source_id).is_some_and(|source| { source .protection_start_exempt_attachments - .contains_key(&key) + .get(&key) + .is_some_and(|entry| entry.resolved_quality == grant.resolved_pt) }); if already_snapshotted { continue; @@ -1034,16 +1033,16 @@ pub(crate) fn refresh_protection_start_attachment_snapshots(state: &mut GameStat /// `attachment_id` on `host_id`? fn protection_grant_exempts_attachment( state: &GameState, - host_id: ObjectId, attachment_id: ObjectId, grant_source_id: ObjectId, - grant_def_index: usize, - grant_mod_index: usize, + 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; }; @@ -2745,13 +2744,12 @@ mod tests { .unwrap_or_default() } - fn reset_source_statics(state: &mut GameState, source_id: ObjectId) { + 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; - obj.protection_start_exempt_attachments.clear(); } #[test] @@ -2930,7 +2928,7 @@ mod tests { "blue rider must snapshot the blue Equipment at effect start" ); - reset_source_statics(&mut state, grant_source); + replace_source_protection_statics(&mut state, grant_source); apply_protection_grant( &mut state, grant_source, 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 index fe0270bb8d..b73214aec8 100644 --- a/crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs +++ b/crates/engine/tests/integration/issue_6499_flickering_ward_protection_exemption.rs @@ -653,7 +653,6 @@ fn cr_702_16p_same_source_second_effect_does_not_inherit_first_snapshot() { obj.static_definitions.clear(); obj.base_static_definitions = Arc::new(Vec::new()); obj.base_characteristics_initialized = false; - obj.protection_start_exempt_attachments.clear(); } apply_host_protection_grant( &mut state, From b3448b1f2a358243cb05dc250273a21204198ecb Mon Sep 17 00:00:00 2001 From: Andriy Polanski Date: Thu, 23 Jul 2026 22:44:13 +0000 Subject: [PATCH 7/7] fix(tauri): sync Cargo.lock with phase-tauri 0.35.2 (#6542) The main merge bumped client/src-tauri/Cargo.toml to 0.35.2 but left the lockfile package entry at 0.35.1, breaking CI's --locked compile check. Co-authored-by: Cursor --- client/src-tauri/Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src-tauri/Cargo.lock b/client/src-tauri/Cargo.lock index 0e9d0e5f13..ce777b5dac 100644 --- a/client/src-tauri/Cargo.lock +++ b/client/src-tauri/Cargo.lock @@ -2616,7 +2616,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "phase-tauri" -version = "0.35.1" +version = "0.35.2" dependencies = [ "futures-util", "minisign-verify",