Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
344 changes: 342 additions & 2 deletions crates/engine/src/game/effects/attach.rs
Original file line number Diff line number Diff line change
Expand Up @@ -601,13 +601,186 @@ pub(crate) fn attachment_illegality(
state.objects.get(&attachment_id),
) {
if crate::game::keywords::protection_prevents_from(host, attachment) {
// CR 702.16n / CR 702.16p: an ALREADY-attached Aura/Equipment is
// detached UNLESS every protection instance matching its quality was
// granted by an effect that ALSO exempts THIS attachment. Protection
// of the same quality from any other source still removes it.
if attachment_exempt_from_protection(
state,
host_id,
attachment_id,
attacher_is_aura,
attacher_is_equipment,
) {
return None;
}
return Some(AttachIllegality::Protection);
}
}

None
}

/// CR 702.16n / CR 702.16p: Decide whether an already-attached Aura/Equipment is
/// exempt from protection-based detachment.
///
/// The exemption is bound to the SPECIFIC protection-granting continuous effect.
/// A protection instance only fails to remove this attachment when the same
/// effect that granted the protection ALSO carries the matching exemption
/// modification and names this attachment (`ProtectionDoesntRemoveThisAura` for
/// the source Aura itself; `ProtectionDoesntRemoveControlledAttachments` for
/// attachments controlled by the granting effect's controller). Every other
/// protection instance of the same quality — from a different effect, from the
/// host's intrinsic (printed) keywords, or from a transient grant — still
/// detaches the attachment (CR 702.16n/p: other instances of protection from the
/// same quality affect the attached permanent normally).
///
/// Fail-closed: a protection quality present on the host that cannot be traced
/// back to an exempting granting effect (e.g. a transient "gains protection
/// until end of turn") counts as a non-exempt instance.
fn attachment_exempt_from_protection(
state: &GameState,
host_id: ObjectId,
attachment_id: ObjectId,
attacher_is_aura: bool,
attacher_is_equipment: bool,
) -> bool {
use crate::types::ability::ContinuousModification;
use crate::types::keywords::{Keyword, ProtectionTarget};

let (Some(host), Some(attachment)) = (
state.objects.get(&host_id),
state.objects.get(&attachment_id),
) else {
return false;
};

// CR 702.16n/p: this is a DETACHMENT exemption for objects that are ALREADY
// attached. A brand-new attach attempt is prevented normally (CR 702.16c/d).
if !host.attachments.contains(&attachment_id) {
return false;
}
if !attacher_is_aura && !attacher_is_equipment {
return false;
}

let matches_attachment = |pt: &ProtectionTarget| {
crate::game::keywords::source_matches_protection_target(pt, host, attachment)
};

// Protection targets granted by an effect that exempts THIS attachment.
let mut exempted: Vec<ProtectionTarget> = Vec::new();
// Set once any protection instance matching the attachment is NOT exempt.
let mut has_unexempted = false;

// CR 702.16n/p: intrinsic (printed) protection never carries an attachment
// exemption, so a matching printed keyword always detaches.
for kw in &host.base_keywords {
if let Keyword::Protection(pt) = kw {
if matches_attachment(pt) {
has_unexempted = true;
}
}
}

// Granted protection scanned WITH provenance: the granting `StaticDefinition`
// exposes both its `AddKeyword(Protection)` and any sibling exemption
// `AddStaticMode`, so the exemption binds to that one specific effect.
for (source_obj, def) in crate::game::functioning_abilities::battlefield_active_statics(state) {
let affected = def.affected.clone().unwrap_or(TargetFilter::Any);
let ctx = FilterContext::from_source(state, source_obj.id);
if !matches_target_filter(state, host_id, &affected, &ctx) {
continue;
}
let def_exempts = definition_exempts_attachment(
def,
source_obj,
attachment,
attacher_is_aura,
attacher_is_equipment,
);
for modification in &def.modifications {
let ContinuousModification::AddKeyword {
keyword: Keyword::Protection(pt),
} = modification
else {
continue;
};
let Some(resolved) = resolve_granted_protection_target(pt, source_obj) else {
continue;
};
if !matches_attachment(&resolved) {
continue;
}
if def_exempts {
exempted.push(resolved);
} else {
has_unexempted = true;
}
}
}

if has_unexempted {
return false;
}

// Fail-closed: every protection quality currently on the host that matches
// the attachment must be covered by an exempting granting effect. A baked
// quality with no such source (e.g. a transient grant) is non-exempt.
let matching_on_host = crate::game::keywords::protection_targets_matching(host, attachment);
let all_matching_covered = matching_on_host
.iter()
.all(|pt| exempted.iter().any(|e| e == pt));

all_matching_covered && !exempted.is_empty()
}

/// CR 702.16n / CR 702.16p: True if the continuous effect `def` (functioning from
/// `source_obj`) carries the attachment-exemption static that names `attachment`.
fn definition_exempts_attachment(
def: &crate::types::ability::StaticDefinition,
source_obj: &crate::game::game_object::GameObject,
attachment: &crate::game::game_object::GameObject,
attacher_is_aura: bool,
attacher_is_equipment: bool,
) -> bool {
use crate::types::ability::ContinuousModification;
use crate::types::statics::StaticMode;
def.modifications
.iter()
.any(|modification| match modification {
ContinuousModification::AddStaticMode { mode } => match mode {
// CR 702.16n: "This effect doesn't remove this Aura" — only the Aura
// that IS the protection source is exempt from its own protection.
StaticMode::ProtectionDoesntRemoveThisAura => {
attacher_is_aura && attachment.id == source_obj.id
}
// CR 702.16p: "...doesn't remove Auras and Equipment you control" —
// attachments controlled by the controller of the granting effect.
StaticMode::ProtectionDoesntRemoveControlledAttachments => {
(attacher_is_aura || attacher_is_equipment)
&& attachment.controller == source_obj.controller
}
_ => false,
},
_ => false,
})
}

/// Resolve a granted protection target against its SOURCE (e.g. `ChosenColor` →
/// the source's chosen color), matching how the layer pipeline bakes the keyword
/// onto the host. Returns `None` when the grant is not yet concrete.
fn resolve_granted_protection_target(
pt: &crate::types::keywords::ProtectionTarget,
source_obj: &crate::game::game_object::GameObject,
) -> Option<crate::types::keywords::ProtectionTarget> {
use crate::types::keywords::ProtectionTarget;
match pt {
ProtectionTarget::ChosenColor => source_obj.chosen_color().map(ProtectionTarget::Color),
other => Some(other.clone()),
}
}

/// CR 301.5 + CR 303.4 + CR 701.3a: True unless `host_id` is forbidden by a
/// positive "can be attached only to {filter}" restriction on `attachment_id`.
///
Expand Down Expand Up @@ -786,12 +959,14 @@ mod tests {
use super::*;
use crate::game::zones::create_object;
use crate::types::ability::{
AttachmentKind, ControllerRef, FilterProp, StaticDefinition, TargetFilter, TargetRef,
TypedFilter,
AttachmentKind, ContinuousModification, ControllerRef, FilterProp, StaticDefinition,
TargetFilter, TargetRef, TypedFilter,
};
use crate::types::card_type::CoreType;
use crate::types::game_state::{AttachmentSnapshot, ZoneChangeRecord};
use crate::types::identifiers::CardId;
use crate::types::keywords::{Keyword, ProtectionTarget};
use crate::types::mana::ManaColor;
use crate::types::player::PlayerId;
use crate::types::statics::StaticMode;
use crate::types::zones::Zone;
Expand All @@ -800,6 +975,43 @@ mod tests {
GameState::new_two_player(42)
}

/// Mirror the production "Enchanted creature has protection ..." affected
/// filter so a granting Aura's continuous effect resolves to the creature it
/// is attached to (via `FilterProp::EnchantedBy`).
fn enchanted_creature_filter() -> TargetFilter {
TargetFilter::Typed(TypedFilter::creature().properties(vec![FilterProp::EnchantedBy]))
}

/// Push a continuous effect that grants `Protection(color)` to the enchanted
/// creature, plus any additional modifications (e.g. an attachment-exemption
/// static), onto `aura`, and attach it to `host`.
fn attach_protection_grant(
state: &mut GameState,
aura: ObjectId,
host: ObjectId,
color: ManaColor,
extra: Vec<ContinuousModification>,
) {
let mut modifications = vec![ContinuousModification::AddKeyword {
keyword: Keyword::Protection(ProtectionTarget::Color(color)),
}];
modifications.extend(extra);
let obj = state.objects.get_mut(&aura).unwrap();
obj.attached_to = Some(AttachTarget::Object(host));
obj.static_definitions.push(
StaticDefinition::continuous()
.affected(enchanted_creature_filter())
.modifications(modifications),
);
let host_obj = state.objects.get_mut(&host).unwrap();
host_obj.attachments.push(aura);
// Layer-baked result of the grant above (what the host would carry after
// an `evaluate_layers` pass).
host_obj
.keywords
.push(Keyword::Protection(ProtectionTarget::Color(color)));
}

/// Build Equipment on the battlefield (Artifact + Equipment subtype).
fn spawn_equipment(state: &mut GameState, name: &str, card_id: u64) -> ObjectId {
let id = create_object(
Expand Down Expand Up @@ -873,6 +1085,134 @@ mod tests {
assert!(!can_attach_to_object(&state, aura, creature));
}

#[test]
fn attachment_illegality_protection_exemption_keeps_attached_controlled_aura() {
// CR 702.16p (issue #4964): Benevolent Blessing grants the host protection
// from the chosen color AND carries the "doesn't remove Auras and
// Equipment you control" exemption on the SAME continuous effect.
// Choosing that color must not detach the Aura that granted it.
let mut state = setup();
let creature = spawn_creature(&mut state, "Bear");
let aura = spawn_with_subtype(&mut state, "Benevolent Blessing", "Aura");
{
let obj = state.objects.get_mut(&aura).unwrap();
obj.card_types.core_types.push(CoreType::Enchantment);
obj.color.push(ManaColor::White);
}
attach_protection_grant(
&mut state,
aura,
creature,
ManaColor::White,
vec![ContinuousModification::AddStaticMode {
mode: StaticMode::ProtectionDoesntRemoveControlledAttachments,
}],
);

assert_eq!(attachment_illegality(&state, aura, creature), None);
assert!(can_attach_to_object(&state, aura, creature));
}

/// CR 702.16n (issue #4964 review): the "this Aura" exemption
/// (`ProtectionDoesntRemoveThisAura`, Pentarch Ward class) is
/// source-specific — it protects ONLY the Aura that granted it. A second
/// controlled Aura attached to the same protected host is still detached by
/// protection. Two controlled Auras on one protected host: the granting Aura
/// is exempt, the sibling Aura is not.
#[test]
fn attachment_illegality_this_aura_exemption_is_source_specific() {
let mut state = setup();
let creature = spawn_creature(&mut state, "Bear");

// Granting Aura: grants protection-from-white to the host AND carries the
// "doesn't remove this Aura" exemption on that same effect.
let granting_aura = spawn_with_subtype(&mut state, "Pentarch Ward", "Aura");
{
let obj = state.objects.get_mut(&granting_aura).unwrap();
obj.card_types.core_types.push(CoreType::Enchantment);
obj.color.push(ManaColor::White);
}
attach_protection_grant(
&mut state,
granting_aura,
creature,
ManaColor::White,
vec![ContinuousModification::AddStaticMode {
mode: StaticMode::ProtectionDoesntRemoveThisAura,
}],
);

// Sibling Aura: same controller, plain white Aura, NO grant/exemption.
let sibling_aura = spawn_with_subtype(&mut state, "Pacifism", "Aura");
{
let obj = state.objects.get_mut(&sibling_aura).unwrap();
obj.card_types.core_types.push(CoreType::Enchantment);
obj.color.push(ManaColor::White);
obj.attached_to = Some(AttachTarget::Object(creature));
}
state
.objects
.get_mut(&creature)
.unwrap()
.attachments
.push(sibling_aura);

// Granting Aura is exempt from its OWN protection; the sibling Aura is
// still removed by that protection (CR 702.16n is source-specific).
assert_eq!(attachment_illegality(&state, granting_aura, creature), None);
assert_eq!(
attachment_illegality(&state, sibling_aura, creature),
Some(AttachIllegality::Protection),
"a sibling controlled Aura must NOT share the source Aura's CR 702.16n exemption"
);
}

/// CR 702.16n / CR 702.16p (issue #4964 review, [HIGH]): the exemption is
/// bound to the specific protection-granting effect. Benevolent Blessing's
/// exemption only neutralizes ITS OWN protection grant — a second, unrelated
/// protection-from-white instance on the same host (no exemption) still
/// detaches Benevolent Blessing.
#[test]
fn attachment_illegality_other_protection_instance_still_detaches_exempt_aura() {
let mut state = setup();
let creature = spawn_creature(&mut state, "Bear");

// Benevolent Blessing: white Aura that grants protection-from-white and
// the controlled-attachment exemption on one effect.
let blessing = spawn_with_subtype(&mut state, "Benevolent Blessing", "Aura");
{
let obj = state.objects.get_mut(&blessing).unwrap();
obj.card_types.core_types.push(CoreType::Enchantment);
obj.color.push(ManaColor::White);
}
attach_protection_grant(
&mut state,
blessing,
creature,
ManaColor::White,
vec![ContinuousModification::AddStaticMode {
mode: StaticMode::ProtectionDoesntRemoveControlledAttachments,
}],
);

// A second, unrelated protection-from-white source with NO exemption.
let other = spawn_with_subtype(&mut state, "Gift of Sanctuary", "Aura");
{
let obj = state.objects.get_mut(&other).unwrap();
obj.card_types.core_types.push(CoreType::Enchantment);
}
attach_protection_grant(&mut state, other, creature, ManaColor::White, vec![]);

// The exempting effect neutralizes only its own protection; `other`'s
// protection-from-white still detaches the (white) Benevolent Blessing.
assert_eq!(
attachment_illegality(&state, blessing, creature),
Some(AttachIllegality::Protection),
"a same-quality protection instance from another source must still \
detach the otherwise-exempt Aura (CR 702.16n/p)"
);
}

#[test]
fn attachment_illegality_cant_be_enchanted_blocks_aura() {
// CR 303.4c: other applicable effects can make an Aura's host illegal.
Expand Down
Loading
Loading