Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
225 changes: 196 additions & 29 deletions crates/engine/src/game/effects/attach.rs
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,79 @@ fn resolve_attached_to_source_lki_attachment(
})
}

/// CR 614.12 + CR 701.3a/b: whose characteristics the ATTACHMENT side of an
/// attachment-legality gate is read from.
///
/// CR 701.3b makes an attach attempt at an illegal host a silent no-op, so the
/// gate below is the ONLY thing standing between a decided attachment and a
/// permanent left dangling for the CR 704.5m sweep. A seam that decided the host
/// was legal must therefore be able to make the gate read the SAME object it
/// decided against: an entrant whose CR 707.9 copy exceptions have not yet been
/// stamped onto the object stored under its id is a different object for
/// protection (CR 702.16c) and attachment-restriction (CR 301.5) purposes than
/// the one the decision saw.
///
/// Borrowed rather than owned: the projection is owned by the deciding seam (or
/// by the state slot that parks it across a player-choice pause), and the gate
/// only reads it.
#[derive(Debug, Clone, Copy)]
pub(crate) enum AttachmentAuthority<'a> {
/// The object stored under the attachment's id already IS the attachment.
/// Read it live, so a change to the permanent between a decision and this
/// gate is never masked by a stale snapshot.
Stored,
/// CR 614.12: the attachment as it will exist on the battlefield, supplied
/// by the seam that holds it because the stored object does not match it yet.
Projected(&'a crate::game::game_object::GameObject),
}

/// CR 303.4 + CR 301.5: is the attachment an Aura, per the supplied authority?
///
/// Player hosts are Auras-only (see [`attach_to_player`]); a copy exception that
/// adds or removes the `Aura` subtype (CR 205.1a) changes the answer, so this
/// reads the authority rather than the stored object.
fn authority_is_aura(
state: &GameState,
attachment_id: ObjectId,
authority: AttachmentAuthority<'_>,
) -> bool {
let attachment = match authority {
AttachmentAuthority::Stored => state.objects.get(&attachment_id),
AttachmentAuthority::Projected(projection) => Some(projection),
};
attachment.is_some_and(|obj| obj.card_types.subtypes.iter().any(|s| s == "Aura"))
}

/// [`can_attach_to_object`] against an explicit [`AttachmentAuthority`].
pub(crate) fn can_attach_to_object_with_authority(
state: &GameState,
attachment_id: ObjectId,
target_id: ObjectId,
authority: AttachmentAuthority<'_>,
) -> bool {
match authority {
AttachmentAuthority::Stored => can_attach_to_object(state, attachment_id, target_id),
AttachmentAuthority::Projected(projection) => {
can_attach_to_object_projected(state, attachment_id, Some(projection), target_id)
}
}
}

/// [`can_attach_to_player`] against an explicit [`AttachmentAuthority`].
pub(crate) fn can_attach_to_player_with_authority(
state: &GameState,
attachment_id: ObjectId,
target_player: PlayerId,
authority: AttachmentAuthority<'_>,
) -> bool {
match authority {
AttachmentAuthority::Stored => can_attach_to_player(state, attachment_id, target_player),
AttachmentAuthority::Projected(projection) => {
can_attach_to_player_projected(state, Some(projection), target_player)
}
}
}

/// CR 701.3c: Attaching to a different object gives the attachment a new timestamp.
/// Core attachment logic: attach `attachment_id` to `target_id`.
/// Handles detaching from a previous target if already attached.
Expand All @@ -662,7 +735,24 @@ pub fn attach_to(
attachment_id: ObjectId,
target_id: ObjectId,
) -> Option<TargetRef> {
if !can_attach_to_object(state, attachment_id, target_id) {
attach_to_with_authority(state, attachment_id, target_id, AttachmentAuthority::Stored)
}

/// CR 614.12 + CR 701.3a: [`attach_to`] whose CR 701.3b legality gate reads the
/// supplied [`AttachmentAuthority`] instead of the stored object.
///
/// Only the GATE is projected. The edit itself — `attached_to`, the host's
/// `attachments` list, the CR 613.7e timestamp, the resolved-commands journal
/// row — is
/// applied to the object actually stored under `attachment_id`, because that is
/// the object that will carry the attachment.
pub(crate) fn attach_to_with_authority(
state: &mut GameState,
attachment_id: ObjectId,
target_id: ObjectId,
authority: AttachmentAuthority<'_>,
) -> Option<TargetRef> {
if !can_attach_to_object_with_authority(state, attachment_id, target_id, authority) {
return None;
}

Expand Down Expand Up @@ -787,6 +877,40 @@ pub(crate) fn attachment_illegality(
state: &GameState,
attachment_id: ObjectId,
host_id: ObjectId,
) -> Option<AttachIllegality> {
attachment_illegality_projected(
state,
attachment_id,
state.objects.get(&attachment_id),
host_id,
)
}

/// CR 614.12 + CR 701.3a: [`attachment_illegality`] against an explicitly
/// supplied projection of the ATTACHMENT.
///
/// Every attachment-side half of this resolver — is the attacher an Aura or an
/// Equipment (CR 303.4 / CR 301.5), its own `AttachmentRestriction` statics
/// (CR 301.5b / CR 303.4j), and the CR 702.16c/d protection quality match — reads
/// the attachment's characteristics. For an entrant that is not yet the object
/// stored under its id, `state.objects` holds the WRONG characteristics: a meld
/// entrant's id still holds the exiled front-face component card, which has a
/// different typeline, colors and controller than the permanent that is entering.
/// CR 614.12 requires "the characteristics of the permanent as it would exist on
/// the battlefield", so the pre-entry CR 303.4f/g host consult passes the
/// entrant projection here and this resolver reads it instead.
///
/// `attachment` is `None` only when no object and no projection exists under the
/// id, in which case the attachment-side halves are skipped exactly as before.
///
/// `attachment_id` is still used for the two id-keyed structural reads that are
/// not characteristic lookups: the CR 301.5c self-attach guard and the CR 701.3b
/// attachment-graph cycle guard.
pub(crate) fn attachment_illegality_projected(
state: &GameState,
attachment_id: ObjectId,
attachment: Option<&crate::game::game_object::GameObject>,
host_id: ObjectId,
) -> Option<AttachIllegality> {
// CR 301.5c: "An Equipment can't equip itself." (And no permanent can be
// attached to itself.) Single-authority self-attach guard protecting both
Expand All @@ -806,16 +930,12 @@ pub(crate) fn attachment_illegality(
if crate::game::static_abilities::object_has_static_other(state, host_id, "CantBeAttached") {
return Some(AttachIllegality::Prohibited);
}
let (attacher_is_aura, attacher_is_equipment) =
state
.objects
.get(&attachment_id)
.map_or((false, false), |obj| {
(
obj.card_types.subtypes.iter().any(|s| s == "Aura"),
obj.card_types.subtypes.iter().any(|s| s == "Equipment"),
)
});
let (attacher_is_aura, attacher_is_equipment) = attachment.map_or((false, false), |obj| {
(
obj.card_types.subtypes.iter().any(|s| s == "Aura"),
obj.card_types.subtypes.iter().any(|s| s == "Equipment"),
)
});
// CR 303.4c: Other applicable effects can make an Aura's host illegal.
if attacher_is_aura
&& crate::game::static_abilities::object_has_static_other(state, host_id, "CantBeEnchanted")
Expand All @@ -835,7 +955,7 @@ pub(crate) fn attachment_illegality(
// (read from the HOST's statics), this restriction is carried by the
// ATTACHMENT itself, so a candidate host failing the filter makes the attach
// illegal (CR 301.5b / CR 303.4j: the attachment doesn't move).
if !attachment_satisfies_restrictions(state, attachment_id, host_id) {
if !attachment_satisfies_restrictions(state, attachment_id, attachment, host_id) {
return Some(AttachIllegality::Prohibited);
}

Expand All @@ -847,10 +967,7 @@ pub(crate) fn attachment_illegality(
// 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 let (Some(host), Some(attachment)) = (state.objects.get(&host_id), attachment) {
if protection_blocks_attachment(state, host_id, attachment_id, host, attachment) {
return Some(AttachIllegality::Protection);
}
Expand Down Expand Up @@ -1232,12 +1349,18 @@ fn protection_grant_exempts_attachment(
fn attachment_satisfies_restrictions(
state: &GameState,
attachment_id: ObjectId,
attachment: Option<&crate::game::game_object::GameObject>,
host_id: ObjectId,
) -> bool {
let Some(attachment) = state.objects.get(&attachment_id) else {
let Some(attachment) = attachment else {
return true;
};
let ctx = FilterContext::from_source(state, attachment_id);
// CR 614.12: source-relative predicates inside the restriction filter bind to
// the ENTRANT's controller. Identical to `FilterContext::from_source` for an
// attachment that is already the object stored under this id (that
// constructor reads exactly `state.objects[id].controller`); it differs only
// for a pre-entry projection, which is the case this parameter exists for.
let ctx = FilterContext::from_source_with_controller(attachment_id, attachment.controller);
crate::game::functioning_abilities::active_static_definitions(state, attachment).all(|def| {
match &def.mode {
crate::types::statics::StaticMode::AttachmentRestriction { filter } => {
Expand All @@ -1251,14 +1374,18 @@ fn attachment_satisfies_restrictions(

/// Returns `Some(reason)` when a player host forbids `attachment` via
/// player-scoped protection, else `None`.
/// The attachment is supplied as a projection rather than looked up by id:
/// CR 614.12 requires an ENTRANT to be read as it will exist on the battlefield,
/// and its id may still hold the pre-entry object. Object-host sibling of
/// [`attachment_illegality_projected`], which documents the same reasoning.
pub(crate) fn player_attachment_illegality(
state: &GameState,
attachment_id: ObjectId,
attachment: Option<&crate::game::game_object::GameObject>,
host: PlayerId,
) -> Option<AttachIllegality> {
// CR 702.16c: A player with protection can't be enchanted by an Aura of the
// protected quality.
if crate::game::static_abilities::player_protection_from(state, host, Some(attachment_id)) {
if crate::game::static_abilities::player_protection_from_object(state, host, attachment) {
return Some(AttachIllegality::Protection);
}
None
Expand All @@ -1268,15 +1395,41 @@ pub(crate) fn can_attach_to_object(
state: &GameState,
attachment_id: ObjectId,
target_id: ObjectId,
) -> bool {
can_attach_to_object_projected(
state,
attachment_id,
state.objects.get(&attachment_id),
target_id,
)
}

/// CR 614.12: [`can_attach_to_object`] against an explicitly supplied projection
/// of the attachment. See [`attachment_illegality_projected`].
pub(crate) fn can_attach_to_object_projected(
state: &GameState,
attachment_id: ObjectId,
attachment: Option<&crate::game::game_object::GameObject>,
target_id: ObjectId,
) -> bool {
// CR 701.3a: A blocked attachment is not a legal host for an attach effect.
attachment_illegality(state, attachment_id, target_id).is_none()
attachment_illegality_projected(state, attachment_id, attachment, target_id).is_none()
}

pub(crate) fn can_attach_to_player(
state: &GameState,
attachment_id: ObjectId,
target_player: PlayerId,
) -> bool {
can_attach_to_player_projected(state, state.objects.get(&attachment_id), target_player)
}

/// CR 614.12: [`can_attach_to_player`] against an explicitly supplied projection
/// of the attachment. See [`attachment_illegality_projected`].
pub(crate) fn can_attach_to_player_projected(
state: &GameState,
attachment: Option<&crate::game::game_object::GameObject>,
target_player: PlayerId,
) -> bool {
// CR 303.4c: A player who has left the game is an illegal Aura host.
if !state
Expand All @@ -1288,7 +1441,7 @@ pub(crate) fn can_attach_to_player(
}
// CR 702.16c: Protection from a quality prevents Auras of that quality from
// being attached to the protected player.
player_attachment_illegality(state, attachment_id, target_player).is_none()
player_attachment_illegality(state, attachment, target_player).is_none()
}

/// CR 303.4: Attach an Aura to a player (Curse cycle, Faith's Fetters-class).
Expand All @@ -1307,6 +1460,24 @@ pub fn attach_to_player(
state: &mut GameState,
attachment_id: ObjectId,
target_player: PlayerId,
) -> Option<TargetRef> {
attach_to_player_with_authority(
state,
attachment_id,
target_player,
AttachmentAuthority::Stored,
)
}

/// CR 614.12 + CR 303.4i: [`attach_to_player`] whose CR 701.3b legality gate
/// reads the supplied [`AttachmentAuthority`] instead of the stored object.
/// Player-host sibling of [`attach_to_with_authority`]; the same "gate is
/// projected, edit is not" split applies.
pub(crate) fn attach_to_player_with_authority(
state: &mut GameState,
attachment_id: ObjectId,
target_player: PlayerId,
authority: AttachmentAuthority<'_>,
) -> Option<TargetRef> {
// CR 301.5: Equipment or Fortification cannot attach to a player.
// CR 303.4: Only Auras may have a player host. Any non-Aura attachment is
Expand All @@ -1316,14 +1487,10 @@ pub fn attach_to_player(
// attachment subtypes cannot slip through by
// accident — the contract is "Auras only", not "anything that isn't
// currently equipment".
let is_aura = state
.objects
.get(&attachment_id)
.is_some_and(|obj| obj.card_types.subtypes.iter().any(|s| s == "Aura"));
if !is_aura {
if !authority_is_aura(state, attachment_id, authority) {
return None;
}
if !can_attach_to_player(state, attachment_id, target_player) {
if !can_attach_to_player_with_authority(state, attachment_id, target_player, authority) {
return None;
}

Expand Down Expand Up @@ -1616,7 +1783,7 @@ mod tests {
);

assert_eq!(
player_attachment_illegality(&state, aura, PlayerId(1)),
player_attachment_illegality(&state, state.objects.get(&aura), PlayerId(1)),
Some(AttachIllegality::Protection)
);
assert!(!can_attach_to_player(&state, aura, PlayerId(1)));
Expand Down
14 changes: 9 additions & 5 deletions crates/engine/src/game/effects/counters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -686,16 +686,20 @@ fn apply_pending_counter_post_action(
events,
);
let completion = status.completion;
if let Some(pending) = state.active_copy_token_mut() {
pending.created_ids.extend(status.created_ids);
} else {
state.last_created_token_ids.extend(status.created_ids);
}
super::token_copy::extend_copy_batch_created_ids(state, status.created_ids);
match completion {
super::token_copy::CopyTokenApplyCompletion::Completed => true,
super::token_copy::CopyTokenApplyCompletion::Paused => false,
}
}
PendingCounterPostAction::ContinueCopyTokenEntryAfterAuraHost { object_id, tail } => {
// CR 303.4f: the host choice is answered and the attach is applied;
// run the rest of this token's entry (copy exceptions, entry counters,
// entry events) plus the rest of the batch.
super::token_copy::continue_copy_token_entry_after_aura_host(
state, object_id, *tail, events,
)
}
PendingCounterPostAction::ApplyCopyTokenModificationsAndFinalize {
object_id,
name,
Expand Down
Loading
Loading