diff --git a/crates/engine/src/game/effects/attach.rs b/crates/engine/src/game/effects/attach.rs index 5aacc01d7c..f41e8b7162 100644 --- a/crates/engine/src/game/effects/attach.rs +++ b/crates/engine/src/game/effects/attach.rs @@ -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. @@ -662,7 +735,24 @@ pub fn attach_to( attachment_id: ObjectId, target_id: ObjectId, ) -> Option { - 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 { + if !can_attach_to_object_with_authority(state, attachment_id, target_id, authority) { return None; } @@ -787,6 +877,40 @@ pub(crate) fn attachment_illegality( state: &GameState, attachment_id: ObjectId, host_id: ObjectId, +) -> Option { + 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 { // CR 301.5c: "An Equipment can't equip itself." (And no permanent can be // attached to itself.) Single-authority self-attach guard protecting both @@ -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") @@ -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); } @@ -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); } @@ -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 } => { @@ -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 { // 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 @@ -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 @@ -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). @@ -1307,6 +1460,24 @@ pub fn attach_to_player( state: &mut GameState, attachment_id: ObjectId, target_player: PlayerId, +) -> Option { + 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 { // 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 @@ -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; } @@ -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))); diff --git a/crates/engine/src/game/effects/counters.rs b/crates/engine/src/game/effects/counters.rs index 243b45768d..be48042372 100644 --- a/crates/engine/src/game/effects/counters.rs +++ b/crates/engine/src/game/effects/counters.rs @@ -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, diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 9fefc57359..ccb76c172c 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -2939,32 +2939,54 @@ fn filter_contains_last_zone_changed(filter: &TargetFilter) -> bool { crate::game::filter::filter_contains_last_zone_changed(filter) } -fn quantity_expr_depends_on_zone_change_this_way(expr: &QuantityExpr) -> bool { - match expr { - QuantityExpr::Ref { qty } => quantity_ref_depends_on_zone_change_this_way(qty), - QuantityExpr::DivideRounded { inner, .. } - | QuantityExpr::Multiply { inner, .. } - | QuantityExpr::ClampMin { inner, .. } - | QuantityExpr::Offset { inner, .. } => { - quantity_expr_depends_on_zone_change_this_way(inner) - } - QuantityExpr::Sum { exprs } | QuantityExpr::Max { exprs } => exprs - .iter() - .any(quantity_expr_depends_on_zone_change_this_way), - QuantityExpr::UpTo { max } => quantity_expr_depends_on_zone_change_this_way(max), - QuantityExpr::Power { exponent, .. } => { - quantity_expr_depends_on_zone_change_this_way(exponent) - } - QuantityExpr::Difference { left, right } => { - quantity_expr_depends_on_zone_change_this_way(left) - || quantity_expr_depends_on_zone_change_this_way(right) - } - QuantityExpr::Fixed { .. } => false, +fn filter_contains_last_created(filter: &TargetFilter) -> bool { + crate::game::filter::filter_contains_last_created(filter) +} + +/// Whether the object population a `CardTypeSetSource` reads is selected by a +/// filter satisfying `filter_pred`. +/// +/// Exhaustive: the three non-`Objects` sources name a zone, the source's exile +/// set, or a tracked set, none of which carries a `TargetFilter`. +fn card_type_set_source_counts_population_matching( + source: &crate::types::ability::CardTypeSetSource, + filter_pred: &dyn Fn(&TargetFilter) -> bool, +) -> bool { + use crate::types::ability::CardTypeSetSource; + match source { + CardTypeSetSource::Objects { filter } => filter_pred(filter), + CardTypeSetSource::Zone { .. } + | CardTypeSetSource::ExiledBySource + | CardTypeSetSource::TrackedSet { .. } => false, } } -fn quantity_ref_depends_on_zone_change_this_way(qty: &QuantityRef) -> bool { +/// Whether the population `qty` counts over is selected by a `TargetFilter` +/// satisfying `filter_pred`. +/// +/// Single enumeration of the "this ref reads a population" variant set, shared +/// by every predicate that asks whether a magnitude depends on a particular +/// resolution-local anaphor ledger (`LastZoneChanged`, `LastCreated`, …). +/// Without it each such predicate re-listed the same variants and a new +/// population-counting `QuantityRef` had to be threaded once per predicate. +/// +/// EXHAUSTIVE, no `_` arm. The wildcard this replaced classified every unlisted +/// variant as filter-free, which was wrong for thirteen variants that already +/// carried one (`SacrificedThisTurn`, `ZoneChangeCountThisTurn`, +/// `DamageDealtThisTurn`, `TokensCreatedThisTurn`, …) and would have been wrong +/// by default for every future one. A new `QuantityRef` now has to be classified +/// here before it compiles, which is the point: "does this magnitude read a +/// population?" is a decision, not a default. +/// +/// Returning a predicate rather than an `Option<&TargetFilter>` is what lets +/// `DamageDealtThisTurn` report BOTH of its filters; a single-filter accessor +/// structurally could not. +fn quantity_ref_counts_population_matching( + qty: &QuantityRef, + filter_pred: &dyn Fn(&TargetFilter) -> bool, +) -> bool { match qty { + // Owned `TargetFilter` naming the counted population. QuantityRef::ObjectCount { filter } | QuantityRef::ObjectCountDistinct { filter, .. } | QuantityRef::ObjectCountBySharedQuality { filter, .. } @@ -2974,27 +2996,122 @@ fn quantity_ref_depends_on_zone_change_this_way(qty: &QuantityRef) -> bool { | QuantityRef::DistinctColorsAmongPermanents { filter } | QuantityRef::DistinctCounterKindsAmong { filter } | QuantityRef::EnteredThisTurn { filter } - | QuantityRef::BattlefieldEntriesThisTurn { filter, .. } => { - filter_contains_last_zone_changed(filter) - } - QuantityRef::DistinctCardTypes { - source: crate::types::ability::CardTypeSetSource::Objects { filter }, - } - | QuantityRef::DistinctSubtypes { - source: crate::types::ability::CardTypeSetSource::Objects { filter }, - .. - } => filter_contains_last_zone_changed(filter), - _ => false, + | QuantityRef::SacrificedThisTurn { filter, .. } + | QuantityRef::BattlefieldEntriesThisTurn { filter, .. } + | QuantityRef::ZoneChangeCountThisTurn { filter, .. } + | QuantityRef::ZoneChangeAggregateThisTurn { filter, .. } + | QuantityRef::TokensCreatedThisTurn { filter, .. } => filter_pred(filter), + // Same, under a field named for what it selects rather than `filter`. + QuantityRef::CounterAddedThisTurn { target, .. } => filter_pred(target), + // Boxed. + QuantityRef::TargetObjectManaValue { filter } + | QuantityRef::FilteredTrackedSetSize { filter, .. } => filter_pred(filter), + // Optional narrowing on an otherwise unfiltered count. + QuantityRef::ZoneCardCount { filter, .. } + | QuantityRef::SpellsCastThisTurn { filter, .. } + | QuantityRef::SpellsCastBeforeTriggeringSpell { filter, .. } + | QuantityRef::AttackedThisTurn { filter, .. } + | QuantityRef::SpellsCastThisGame { filter, .. } => { + filter.as_ref().is_some_and(filter_pred) + } + // CR 120.1 + CR 120.2: two independent populations — what dealt the damage + // and what received it. Either one can carry the anaphor. + QuantityRef::DamageDealtThisTurn { source, target, .. } => { + filter_pred(source) || filter_pred(target) + } + QuantityRef::DistinctCardTypes { source } + | QuantityRef::DistinctSubtypes { source, .. } => { + card_type_set_source_counts_population_matching(source, filter_pred) + } + // No `TargetFilter` anywhere: player-scoped totals, per-object scopes, + // resolution/turn counters, and cost bookkeeping. + QuantityRef::HandSize { .. } + | QuantityRef::LifeTotal { .. } + | QuantityRef::GraveyardSize { .. } + | QuantityRef::LifeAboveStarting + | QuantityRef::StartingLifeTotal + | QuantityRef::TriggeringDiscoverValue + | QuantityRef::TriggeringScryLookCount + | QuantityRef::TriggeringScryBottomCount + | QuantityRef::PlayerCount { .. } + | QuantityRef::CountersOn { .. } + | QuantityRef::PlayerCounter { .. } + | QuantityRef::TargetControllerCounter { .. } + | QuantityRef::Variable { .. } + | QuantityRef::Power { .. } + | QuantityRef::Intensity { .. } + | QuantityRef::Toughness { .. } + | QuantityRef::ObjectManaValue { .. } + | QuantityRef::ObjectColorCount { .. } + | QuantityRef::ObjectNameWordCount { .. } + | QuantityRef::ObjectTypelineComponentCount { .. } + | QuantityRef::ManaSymbolsInManaCost { .. } + | QuantityRef::SelfManaValue + | QuantityRef::TargetZoneCardCount { .. } + | QuantityRef::Devotion { .. } + | QuantityRef::CardsExiledBySource + | QuantityRef::ExiledCardPower { .. } + | QuantityRef::BasicLandTypeCount { .. } + | QuantityRef::TrackedSetSize + | QuantityRef::TrackedSetAggregate { .. } + | QuantityRef::ExiledFromHandThisResolution + | QuantityRef::PreviousEffectAmount { .. } + | QuantityRef::LifeLostThisTurn { .. } + | QuantityRef::PartySize { .. } + | QuantityRef::UnspentMana { .. } + | QuantityRef::Speed { .. } + | QuantityRef::EventContextAmount + | QuantityRef::EventContextPlayerCount { .. } + | QuantityRef::AttachmentsOnLeavingObject { .. } + | QuantityRef::EventContextSourceCostX + | QuantityRef::EventContextSourceModesChosen + | QuantityRef::CrimesCommittedThisTurn + | QuantityRef::BendTypesThisTurn + | QuantityRef::LifeGainedThisTurn { .. } + | QuantityRef::CardsDrawnThisTurn { .. } + | QuantityRef::LandsPlayedThisTurn { .. } + | QuantityRef::TurnsTaken + | QuantityRef::ChosenNumber + | QuantityRef::DescendedThisTurn + | QuantityRef::LoyaltyAbilitiesActivatedThisTurn { .. } + | QuantityRef::SpellsCastLastTurn + | QuantityRef::CardsDiscardedThisTurn { .. } + | QuantityRef::PlayerActionsThisTurn { .. } + | QuantityRef::DungeonsCompleted + | QuantityRef::CostXPaid + | QuantityRef::KickerCount + | QuantityRef::AdditionalCostPaymentCount + | QuantityRef::AdditionalCostPaymentCountFor { .. } + | QuantityRef::ConvokedCreatureCount + | QuantityRef::TimesCostPaidThisResolution + | QuantityRef::ManaSpentToCast { .. } + | QuantityRef::ColorsInCommandersColorIdentity + | QuantityRef::CommanderCastFromCommandZoneCount + | QuantityRef::CommanderManaValue { .. } + | QuantityRef::VoteCount { .. } => false, } } +/// Whether any magnitude in `expr` counts a population selected by a filter +/// satisfying `filter_pred`. Traversal delegates to `QuantityExpr::any_ref` +/// (the single composition-form authority) and the variant set to +/// [`quantity_ref_counts_population_matching`]. +fn quantity_expr_counts_population_matching( + expr: &QuantityExpr, + filter_pred: &dyn Fn(&TargetFilter) -> bool, +) -> bool { + quantity_expr_any_ref(expr, &mut |qty| { + quantity_ref_counts_population_matching(qty, filter_pred) + }) +} + fn condition_depends_on_zone_change_this_way(condition: &AbilityCondition) -> bool { match condition { AbilityCondition::ZoneChangedThisWay { .. } | AbilityCondition::DiscardedCardMatchesFilter { .. } => true, AbilityCondition::QuantityCheck { lhs, rhs, .. } => { - quantity_expr_depends_on_zone_change_this_way(lhs) - || quantity_expr_depends_on_zone_change_this_way(rhs) + quantity_expr_counts_population_matching(lhs, &filter_contains_last_zone_changed) + || quantity_expr_counts_population_matching(rhs, &filter_contains_last_zone_changed) } AbilityCondition::Not { condition } => condition_depends_on_zone_change_this_way(condition), AbilityCondition::And { conditions } | AbilityCondition::Or { conditions } => conditions @@ -3004,6 +3121,131 @@ fn condition_depends_on_zone_change_this_way(condition: &AbilityCondition) -> bo } } +/// CR 608.2c + CR 111.1: Whether a condition reads the resolution-local +/// `last_created_token_ids` ledger — the "the token created this way" / "it" +/// anaphor a token-creating instruction publishes when it COMPLETES +/// (`record_last_created_token`, the tail of the liminal entry finalization). +/// +/// Structural twin of [`condition_depends_on_zone_change_this_way`], and used at +/// the same gate for the same reason: while the parent instruction is suspended +/// on a player choice mid-entry (a CR 616.1 replacement ordering prompt, or the +/// CR 303.4f Aura-host prompt raised when a token copy of an Aura enters), the +/// ledger does not yet name the token being created. Evaluating "If the token is +/// an Aura, …" against it there reads a stale population and silently drops the +/// gated sub-ability (Yenna, Redtooth Regent). Deferring the sub WITH its +/// condition re-evaluates it at chain top once the entry completes, which is +/// also the CR 608.2c order: the gated sentence is the NEXT instruction and +/// cannot begin before this one finishes. +/// +/// Predicate helper, not rule-implementing code — the CR annotation lives at the +/// gate. +fn condition_depends_on_last_created(condition: &AbilityCondition) -> bool { + condition_reads_filter_population(condition, &filter_contains_last_created) +} + +/// Whether any filter or counted population anywhere inside `condition` +/// satisfies `leaf`. +/// +/// EXHAUSTIVE on `AbilityCondition`, no `_` arm, for the same reason +/// `filter::filter_contains` is exhaustive on `TargetFilter`. The wildcard this +/// replaced classified EVERY filter-bearing condition as anaphor-free: +/// `TargetMatchesFilter`, `ControllerControlsMatching`, `SourceMatchesFilter`, +/// `ZoneChangeObjectMatchesFilter` and `DiscardedCardMatchesFilter` all read as +/// mentioning nothing, so a sub-ability gated on "if the token is an Aura" was +/// not deferred and evaluated against a ledger the suspended parent had not +/// published yet — the exact CR 608.2c defect this predicate family exists to +/// prevent. +/// +/// Both carriers of a filter are covered: a `TargetFilter` mentioned directly +/// (via `filter_contains`, which also descends `FilterProp` and `PlayerFilter`), +/// and a population COUNTED by a `QuantityExpr` (via +/// `quantity_expr_counts_population_matching`). +fn condition_reads_filter_population( + condition: &AbilityCondition, + leaf: &dyn Fn(&TargetFilter) -> bool, +) -> bool { + let has_filter = |filter: &TargetFilter| crate::game::filter::filter_contains(filter, leaf); + let has_quantity = + |quantity: &QuantityExpr| quantity_expr_counts_population_matching(quantity, leaf); + let recurse = |inner: &AbilityCondition| condition_reads_filter_population(inner, leaf); + match condition { + // Compound / wrapping conditions. + AbilityCondition::Not { condition } => recurse(condition), + AbilityCondition::ConditionInstead { inner } => recurse(inner), + AbilityCondition::And { conditions } | AbilityCondition::Or { conditions } => { + conditions.iter().any(recurse) + } + // Conditions that name a population by filter. + AbilityCondition::ObjectsShareQuality { + subject, reference, .. + } => has_filter(subject) || has_filter(reference), + AbilityCondition::TargetSharesNameWithOtherExiledThisWay { target } => has_filter(target), + AbilityCondition::DiscardedCardMatchesFilter { filter } + | AbilityCondition::TargetMatchesFilter { filter, .. } + | AbilityCondition::TriggeringSpellTargetsFilter { filter } + | AbilityCondition::SourceMatchesFilter { filter } + | AbilityCondition::PostReplacementDamageSourceMatchesFilter { filter } + | AbilityCondition::ZoneChangeObjectMatchesFilter { filter, .. } + | AbilityCondition::ControllerControlsMatching { filter } + | AbilityCondition::ControllerControlledMatchingAsCast { filter } + | AbilityCondition::ZoneChangedThisWay { filter } + | AbilityCondition::CostPaidObjectMatchesFilter { filter } => has_filter(filter), + AbilityCondition::RevealedHasCardType { + additional_filter, + subtype_filter, + .. + } => { + subtype_filter.as_deref().is_some_and(has_filter) + || additional_filter + .as_ref() + .is_some_and(|prop| crate::game::filter::filter_prop_contains(prop, leaf)) + } + AbilityCondition::ScopedPlayerMatches { filter } => { + crate::game::filter::player_filter_contains(filter, leaf) + } + // Conditions that COUNT a population. + AbilityCondition::QuantityCheck { lhs, rhs, .. } => has_quantity(lhs) || has_quantity(rhs), + AbilityCondition::PreviousEffectAmount { rhs, .. } => has_quantity(rhs), + // Leaves: nothing filter- or quantity-shaped to read. + AbilityCondition::TriggerEventTargetDamagedBySourceThisTurn + | AbilityCondition::AdditionalCostPaid { .. } + | AbilityCondition::AdditionalCostPaidInstead + | AbilityCondition::AlternativeManaCostPaid + | AbilityCondition::EffectOutcome { .. } + | AbilityCondition::EventOutcomeWon + | AbilityCondition::CoinFlipOutcome { .. } + | AbilityCondition::WhenYouDo + | AbilityCondition::WasCast { .. } + | AbilityCondition::CastDuringPhase { .. } + | AbilityCondition::CurrentPhaseIs { .. } + | AbilityCondition::CastTimingPermission { .. } + | AbilityCondition::ManaColorSpent { .. } + | AbilityCondition::SourceEnteredThisTurn + | AbilityCondition::CastVariantPaid { .. } + | AbilityCondition::CastVariantPaidInstead { .. } + | AbilityCondition::HasMaxSpeed + | AbilityCondition::IsMonarch + | AbilityCondition::IsInitiative + | AbilityCondition::HasCityBlessing + | AbilityCondition::HasEnduringStory + | AbilityCondition::IsRingBearer + | AbilityCondition::CompletedDungeon { .. } + | AbilityCondition::TargetHasKeywordInstead { .. } + | AbilityCondition::HasObjectTarget + | AbilityCondition::IsYourTurn + | AbilityCondition::WasStartingPlayer { .. } + | AbilityCondition::SpellCastWithVariantThisTurn { .. } + | AbilityCondition::FirstCombatPhaseOfTurn + | AbilityCondition::FirstEndStepOfTurn + | AbilityCondition::SourceIsTapped + | AbilityCondition::SourceAttachedToCreature + | AbilityCondition::DayNightIsNeither + | AbilityCondition::DayNightIs { .. } + | AbilityCondition::NthResolutionThisTurn { .. } + | AbilityCondition::SourceLacksKeyword { .. } => false, + } +} + /// CR 608.2c + CR 400.7j: Whether a condition reads the parent's *suspended- /// selection result object* — the found/revealed card that a `SearchLibrary` /// injects as the continuation target only after the player responds @@ -10929,9 +11171,22 @@ fn resolve_chain_body( // resolution choice would mis-defer e.g. a `Sacrifice`→`EffectZoneChoice` // →`TargetMatchesFilter`-gated sibling, whose completion leaves // `cont.chain.targets` empty (it uses the tracked-set/ParentTarget path). + // CR 608.2c + CR 111.1 + CR 303.4f: The `last_created_token_ids` + // twin of the `last_zone_changed_ids` deferral above. A token entry + // publishes "the token created this way" only when the entry + // FINISHES; while it is suspended mid-entry — on a CR 616.1 + // replacement-ordering prompt, or on the CR 303.4f host prompt a + // token copy of an Aura raises — the ledger still names the previous + // instruction's tokens (or nothing at all). Evaluating a gate like + // Yenna, Redtooth Regent's "If the token is an Aura, untap Yenna, + // then scry 2" there reads that stale population, so the whole + // trailing sentence was silently dropped. Defer it WITH its + // condition; the drain re-evaluates it at chain top after the entry + // completes, which is the order CR 608.2c requires anyway. if waits_for_resolution_choice(&state.waiting_for) && (condition_depends_on_effect_performed(condition) || condition_depends_on_zone_change_this_way(condition) + || condition_depends_on_last_created(condition) || matches!(condition, AbilityCondition::WhenYouDo) || (matches!(state.waiting_for, WaitingFor::SearchChoice { .. }) && condition_depends_on_result_object(condition)) @@ -29473,4 +29728,184 @@ mod tests { read the 2 actually removed — not the 7 the relay asked for" ); } + /// CR 608.2c: the deferral gate asks whether a gated sub-ability's CONDITION + /// reads a resolution-local anaphor ledger. That question is answered by + /// walking every `TargetFilter` a `QuantityRef` counts over — and the + /// wildcard this replaced answered "no filter" for thirteen variants that + /// already carried one, so a magnitude like "damage dealt this turn by the + /// token created this way" silently read as anaphor-free and the trailing + /// sentence evaluated against a stale ledger mid-prompt. + /// + /// One representative per shape the exhaustive match had to classify: a + /// two-filter variant, an optional filter, a boxed filter, and a differently + /// named field. + #[test] + fn population_predicate_sees_filters_the_wildcard_arm_used_to_swallow() { + let counts = |qty: QuantityRef| { + condition_depends_on_last_created(&AbilityCondition::QuantityCheck { + lhs: QuantityExpr::Ref { qty }, + comparator: Comparator::GE, + rhs: QuantityExpr::Fixed { value: 1 }, + }) + }; + let anaphor = || TargetFilter::LastCreated; + let plain = || TargetFilter::Typed(TypedFilter::creature()); + + // Two independent populations: either side carries the anaphor. + assert!(counts(QuantityRef::DamageDealtThisTurn { + source: Box::new(anaphor()), + target: Box::new(plain()), + aggregate: AggregateFunction::Sum, + group_by: None, + damage_kind: Default::default(), + channel: DamageChannel::Total, + })); + assert!(counts(QuantityRef::DamageDealtThisTurn { + source: Box::new(plain()), + target: Box::new(anaphor()), + aggregate: AggregateFunction::Sum, + group_by: None, + damage_kind: Default::default(), + channel: DamageChannel::Total, + })); + // Optional narrowing filter. + assert!(counts(QuantityRef::SpellsCastThisTurn { + scope: crate::types::ability::CountScope::Controller, + filter: Some(anaphor()), + })); + // Boxed filter. + assert!(counts(QuantityRef::TargetObjectManaValue { + filter: Box::new(anaphor()), + })); + // Field named for what it selects rather than `filter`. + assert!(counts(QuantityRef::CounterAddedThisTurn { + actor: crate::types::ability::CountScope::Controller, + counters: CounterMatch::Any, + target: anaphor(), + })); + // Nested one level down through a compound, and through the + // `ChosenDamageSource` inner filter the traversal used to skip. + assert!(counts(QuantityRef::TokensCreatedThisTurn { + player: PlayerScope::Controller, + filter: TargetFilter::ChosenDamageSource { + filter: Some(Box::new(anaphor())), + }, + })); + + // Negatives: the same variants without the anaphor, and a genuinely + // filter-free ref. + assert!(!counts(QuantityRef::SpellsCastThisTurn { + scope: crate::types::ability::CountScope::Controller, + filter: Some(plain()), + })); + assert!(!counts(QuantityRef::SpellsCastThisTurn { + scope: crate::types::ability::CountScope::Controller, + filter: None, + })); + assert!(!counts(QuantityRef::HandSize { + player: PlayerScope::Controller, + })); + } + + /// CR 608.2c + CR 111.1: every FILTER-bearing `AbilityCondition` must be + /// classified, not just the counting one. + /// + /// The `_ => false` this replaced read every one of these as anaphor-free, so + /// a sub-ability gated on "the token created this way" was evaluated against a + /// ledger the suspended parent had not published yet and silently dropped — + /// the defect this whole predicate family exists to prevent. Each assertion + /// below flips to `false` if its arm is removed from + /// `condition_reads_filter_population`. + #[test] + fn every_filter_bearing_condition_sees_the_last_created_anaphor() { + let anaphor = || TargetFilter::LastCreated; + let cases: Vec = vec![ + AbilityCondition::TargetMatchesFilter { + filter: anaphor(), + use_lki: false, + subject_slot: None, + }, + AbilityCondition::ControllerControlsMatching { filter: anaphor() }, + AbilityCondition::SourceMatchesFilter { filter: anaphor() }, + AbilityCondition::ZoneChangeObjectMatchesFilter { + origin: None, + destination: Zone::Battlefield, + filter: anaphor(), + }, + AbilityCondition::DiscardedCardMatchesFilter { filter: anaphor() }, + AbilityCondition::CostPaidObjectMatchesFilter { filter: anaphor() }, + AbilityCondition::TriggeringSpellTargetsFilter { filter: anaphor() }, + AbilityCondition::ControllerControlledMatchingAsCast { filter: anaphor() }, + AbilityCondition::ZoneChangedThisWay { filter: anaphor() }, + AbilityCondition::PostReplacementDamageSourceMatchesFilter { filter: anaphor() }, + AbilityCondition::TargetSharesNameWithOtherExiledThisWay { target: anaphor() }, + AbilityCondition::ObjectsShareQuality { + subject: anaphor(), + reference: TargetFilter::Any, + quality: crate::types::ability::SharedQuality::Color, + }, + AbilityCondition::ScopedPlayerMatches { + filter: PlayerFilter::ControlsCount { + relation: crate::types::ability::PlayerRelation::Controller, + filter: anaphor(), + comparator: Comparator::GE, + count: Box::new(QuantityExpr::Fixed { value: 1 }), + }, + }, + ]; + for condition in cases { + assert!( + condition_depends_on_last_created(&condition), + "CR 608.2c: {condition:?} names the last-created population" + ); + } + } + + /// The same predicate must still say NO to a condition that mentions no + /// anaphor, or the deferral gate would defer every gated sub-ability. + #[test] + fn a_condition_without_the_anaphor_is_not_deferred() { + assert!(!condition_depends_on_last_created( + &AbilityCondition::TargetMatchesFilter { + filter: TargetFilter::Typed(TypedFilter::creature()), + use_lki: false, + subject_slot: None, + } + )); + assert!(!condition_depends_on_last_created( + &AbilityCondition::IsMonarch + )); + assert!(!condition_depends_on_last_created( + &AbilityCondition::ZoneChangedThisWay { + filter: TargetFilter::LastZoneChanged, + } + )); + } + + /// Compound and prop-nested carriers reach the anaphor too: `Not`/`And`/`Or`, + /// `ConditionInstead`, and a filter buried inside a `Typed` filter's + /// properties — the `TargetFilter::Typed(_)` leaf arm this pass removed. + #[test] + fn nested_carriers_still_reach_the_last_created_anaphor() { + let nested_in_props = TargetFilter::Typed(TypedFilter::creature().properties(vec![ + FilterProp::DistinctFrom { + reference: Box::new(TargetFilter::LastCreated), + }, + ])); + let leaf = AbilityCondition::SourceMatchesFilter { + filter: nested_in_props, + }; + assert!(condition_depends_on_last_created(&leaf)); + assert!(condition_depends_on_last_created(&AbilityCondition::Not { + condition: Box::new(leaf.clone()), + })); + assert!(condition_depends_on_last_created(&AbilityCondition::And { + conditions: vec![AbilityCondition::IsMonarch, leaf.clone()], + })); + assert!(condition_depends_on_last_created( + &AbilityCondition::ConditionInstead { + inner: Box::new(leaf), + } + )); + } } diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index bd691211e0..a235519a88 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -1430,7 +1430,7 @@ fn liminal_copy_token_continuation_for_event( let entry = state.liminal_entries.get(entry_ref)?; let copy = entry.copy_resume.clone()?; Some(LiminalCopyTokenContinuation { - owner: entry.object.owner, + owner: entry.object.projected().owner, copy, enter_tapped: entry.enter_tapped, enter_with_counters: entry.enter_with_counters.clone(), @@ -1521,6 +1521,41 @@ pub(crate) fn continue_liminal_copy_token_batch_after_counter_pause( ) } +/// CR 303.4g: undo a token battlefield entry that CR 303.4g says never happened. +/// +/// The ONLY unhosted-entry disposition the liminal seam has, because the only +/// entrant that seam can hold is a [`crate::types::game_state::TokenProjection`] +/// (CR 111.1). The rule's card-backed dispositions are phrased against a +/// from-zone, so they live where a from-zone exists: the +/// `ProposedEvent::ZoneChange` path in `zone_pipeline`, which re-proposes the +/// owner's-graveyard placement as a fresh, replacement-consulted event. +/// +/// The inverse of the `state.objects.insert` + `zones::add_to_zone` pair +/// immediately above the CR 303.4f/g consult, and nothing more. In particular it +/// does NOT roll back `state.next_object_id`: the id was drawn by +/// `reserve_liminal_token_object` and is recorded as a high-water mark on every +/// sibling token's CR 733 birth command (`resulting_next_object_id`), so +/// rewinding the allocator would make a later replay reuse a burnt id. +pub(crate) fn uncreate_unentered_aura_token( + state: &mut GameState, + object_id: ObjectId, + owner: PlayerId, +) { + // The annotation has to sit on the mover line or the line directly above it + // (`scripts/zone_authority_census.py::census_file`), so the rationale is + // stated once here and the annotation itself is the one-liner below. + // + // This is the un-entry of a token that CR 303.4g says was never created, not + // a CR 400.7 zone change: there is no from-zone, no destination zone, and + // nothing may observe it — the whole point is that no `ZoneChanged`, + // `TokenCreated`, or CR 733 birth record is produced for an entry the rules + // deny. Routing it through `zone_pipeline` would manufacture exactly the + // observable event this arm exists to suppress. + // allow-raw-zone: undoes a CR 303.4g-denied token entry; not a CR 400.7 zone change, so no ZoneChanged may be emitted. + zones::remove_from_zone(state, object_id, Zone::Battlefield, owner); + state.objects.remove(&object_id); +} + pub(crate) fn commit_liminal_token_entry_with_post_actions( state: &mut GameState, event: ProposedEvent, @@ -1537,6 +1572,23 @@ pub(crate) fn commit_liminal_token_entry_with_post_actions( else { return true; }; + // CR 111.1: the entrant of a `ProposedEvent::TokenEntry` is a TOKEN + // projection — the marker that represents a permanent no card represents, + // and that therefore sits in no zone until this entry commits. + // `state.liminal_entries` also holds the card-backed CR 701.42 meld + // projection, whose components are real cards in exile and which enters + // through `ProposedEvent::ZoneChange` from that real prior zone. A + // `TokenEntry` naming one would name nothing this seam may act on, so the + // entry is left exactly where it is — the same no-op as an entry that has + // already been taken, and, unlike a raw graveyard placement, an outcome + // that puts no object anywhere. + if !state + .liminal_entries + .get(&entry_ref) + .is_some_and(|entry| entry.object.is_token_projection()) + { + return true; + } let Some(mut entry) = state.liminal_entries.remove(&entry_ref) else { return true; }; @@ -1546,16 +1598,28 @@ pub(crate) fn commit_liminal_token_entry_with_post_actions( .chain(entry.enter_with_counters.iter()) .cloned() .collect(); - entry.object.tapped = enter_tapped.resolve(entry.object.tapped); - let owner = entry.object.owner; + entry + .object + .set_tapped(enter_tapped.resolve(entry.object.projected().tapped)); + let owner = entry.object.projected().owner; - // CR 733: journal the settled copy-token birth at the single liminal insert + // CR 733: the settled copy-token birth journals at the single liminal insert // seam. `copy_resume` is `Some` for every production liminal entry of kind // Token (`token_copy.rs` is the only production constructor), so this covers // the whole liminal copy path. Counters, the attacking entry, and later // status changes journal through their OWN families — this command is the // birth only, exactly as the ordinary CR 111.1 birth is. - if let Some(copy) = entry.copy_resume.clone() { + // + // The command is BUILT here, before `state.objects.insert` consumes + // `entry.object`, but RECORDED below, after the CR 303.4f/g consult has + // settled whether this token is created at all. `ResolvedRulesJournal` is + // append-only — `record_token_creation` has no retraction anywhere in the + // tree, verified against `append_command`, which only pushes — so a birth + // recorded for a token CR 303.4g says "isn't created" could never be taken + // back. Recording after the consult but BEFORE the attach is also what keeps + // the journal replayable: `apply_resolved_attachment` rejects an attachment + // whose object does not exist yet, so the birth must own the lower ordinal. + let birth_command = entry.copy_resume.clone().map(|copy| { // CR 707.9b/9c: the liminal seam folds its immediate exceptions into the // copiable values BEFORE entry, so the body is complete here and replay // can reapply them from this record. @@ -1570,35 +1634,176 @@ pub(crate) fn commit_liminal_token_entry_with_post_actions( all_creature_types: state.all_creature_types.clone(), } }; - let command = ResolvedTokenCreationCommand { - object: ObjectIncarnationRef::from_object(&entry.object), + ResolvedTokenCreationCommand { + object: ObjectIncarnationRef::from_object(entry.object.projected()), owner, - entry_timestamp: entry.object.timestamp, + entry_timestamp: entry.object.projected().timestamp, // CR 302.6: the entered-turn the liminal build already stamped, read // back off the object rather than re-read from the live turn. entry_turn: entry .object + .projected() .entered_battlefield_turn .unwrap_or(state.turn_number), body: ResolvedTokenBody::Copy { copy, modifications, }, - resulting_tapped: entry.object.tapped, + resulting_tapped: entry.object.projected().tapped, // `reserve_liminal_token_object` advanced the allocator to exactly // one past this id when it drew it, however many were drawn since. resulting_next_object_id: entry_ref.0 + 1, cause: state.current_or_begin_rules_execution_node(), - }; + } + }); + + state + .objects + .insert(entry_ref, entry.object.into_projected()); + // allow-raw-zone: liminal token birth has no from-zone move; TokenEntry already consults entry replacements (CR 111.2 + CR 614.12). + zones::add_to_zone(state, entry_ref, Zone::Battlefield, owner); + + // CR 303.4f: an Aura entering the battlefield by any means other than + // resolving as an Aura spell, where the effect doesn't specify a host, has + // its controller choose what it enchants as it enters. A token that is a + // copy of an Aura (Yenna, Redtooth Regent; Court of Vantress copying a + // Curse) carries no effect-specified `attach_to` — `entry.attach_to.is_some()` + // means the effect DID name a host (Role tokens), so CR 303.4f doesn't apply. + // Mirrors the `attach_to.is_none()` gate on the ZoneChange entry path. + // + // Decided before the birth is journaled and applied after, so the CR 303.4g + // arm can withhold the birth entirely (see `birth_command` above). + // + // WHY THE CONSULT RUNS AFTER THE INSERT, and what that costs. The insert + + // `add_to_zone` pair above emits nothing — no `ZoneChanged`, no + // `TokenCreated`, no journal command, no `last_created_token_ids` row — and + // the `NotCreated` arm below rewinds both, so nothing the game can observe + // escapes on the denied path. It is a prerequisite, not a shortcut: + // `entering_aura_hosts` reads the entrant's zone off `state.objects` and + // reports `NotApplicable` for anything not on the battlefield. It also puts + // this seam in agreement with the OTHER token seam + // (`token_copy.rs`'s non-liminal loop, which likewise consults a token it has + // already created on the battlefield). + // + // The residual, stated rather than papered over: an enchant filter that + // COUNTS a population the entrant belongs to ("enchant creature you control if + // you control two or more enchantments") observes the entrant as present here + // and as absent at the pre-entry ZoneChange seam in `zone_pipeline`. Only + // counting predicates diverge — CR 303.4d self-exclusion is applied + // explicitly by `legal_aura_attachment_targets`, and `FilterProp::Another` is + // source-relative to the Aura itself, so both already exclude the entrant on + // either side. No card in the pool carries a counting enchant ability. + let hosts = if entry.attach_to.is_none() { + crate::game::zone_pipeline::entering_aura_hosts(state, entry_ref) + } else { + crate::game::zone_pipeline::EnteringAuraHosts::NotApplicable + }; + + // CR 303.4g: "If an Aura is entering the battlefield and there is no legal + // object or player for it to enchant, the Aura remains in its current zone, + // unless that zone is the stack. In that case, the Aura is put into its + // owner's graveyard instead of entering the battlefield. If the Aura is a + // token, it isn't created." + // + // The entry is denied — there is no arm that lets the entrant stay on the + // battlefield unattached for the CR 704.5m state-based action to sweep, + // because the rule says this entry never happens. Rewound before anything + // observes it: no CR 733 birth record (`birth_command` is still unrecorded + // here), no `TokenCreated`, no battlefield `ZoneChanged`, and no + // `last_created_token_ids` row. + if matches!( + &hosts, + crate::game::zone_pipeline::EnteringAuraHosts::Hosts { legal_targets, .. } + if legal_targets.is_empty() + ) { + // CR 303.4g + CR 111.1: "If the Aura is a token, it isn't created" is the + // ONLY disposition available here, and it is available by construction: + // this seam's entrant is a `LiminalEntrant::Token`, whose CR 111.1 + // token-ness is a property of the type rather than an expectation about + // a flag. The rule's two card-backed dispositions are phrased against + // the zone the Aura is entering FROM, which is why they belong to — and + // only exist on — the `ProposedEvent::ZoneChange` path in + // `zone_pipeline`, where the owner's-graveyard placement is re-proposed + // as a fresh event so CR 614.6 graveyard→exile redirects (Rest in Peace, + // Leyline of the Void) still apply to it. Nothing is placed anywhere + // here, so there is no placement for a replacement to miss. + uncreate_unentered_aura_token(state, entry_ref, owner); + // CR 111.1 + CR 603.7: the anaphora slot still has to be republished, or + // the batch continuation (which reads it back to seed the next token's + // `created_ids`) would carry whatever an EARLIER, unrelated effect left + // there. `entry.created_ids` is this batch's list up to but excluding the + // token that was not created — exactly what `finalize_committed_liminal_ + // token_entry_from_action` would have assigned before appending, minus + // the append. + state.last_created_token_ids = entry.created_ids.clone(); + // Not a pause: the batch loop must go on to the next token in the count. + return true; + } + + if let Some(command) = birth_command { state .resolved_rules_journal .record_token_creation(command) .expect("resolved copy-token creation must have a live journal cause"); } - state.objects.insert(entry_ref, entry.object); - // allow-raw-zone: liminal token birth has no from-zone move; TokenEntry already consults entry replacements (CR 111.2 + CR 614.12). - zones::add_to_zone(state, entry_ref, Zone::Battlefield, owner); + match crate::game::zone_pipeline::apply_entering_aura_hosts(state, entry_ref, hosts) { + // `NoLegalHost` is unreachable here: the empty-`legal_targets` arm above + // returned for every entrant, card-backed included. + crate::game::zone_pipeline::EnteringAuraAttachment::NotApplicable + | crate::game::zone_pipeline::EnteringAuraAttachment::Attached + | crate::game::zone_pipeline::EnteringAuraAttachment::NoLegalHost => {} + crate::game::zone_pipeline::EnteringAuraAttachment::NeedsChoice { + controller: chooser, + legal_targets, + } => { + // CR 616.1 carrier: park the entry tail exactly like the + // enter-with-counters pause below, so the finalize step and any + // remaining batch continuation run after the host is chosen. + state.last_created_token_ids = entry.created_ids.clone(); + let remaining_counters = counters_to_apply + .iter() + .filter(|(_, count)| *count > 0) + .map(|(counter_type, count)| PendingCounterAddition::Object { + actor: owner, + object_id: entry_ref, + counter_type: counter_type.clone(), + count: *count, + }) + .collect(); + let mut post_actions = vec![finalization]; + post_actions.extend(post_actions_after_finalize); + super::counters::stash_pending_counter_additions( + state, + remaining_counters, + crate::types::game_state::PendingEffectResolved::with_post_actions_without_effect( + if entry.copy_resume.is_some() { + EffectKind::CopyTokenOf + } else { + EffectKind::Token + }, + entry.source_id, + post_actions, + ), + ); + state.waiting_for = WaitingFor::ReturnAsAuraTarget { + player: chooser, + source_id: entry.source_id, + returned_id: entry_ref, + legal_targets, + pending_effect: Box::new(ResolvedAbility::new( + Effect::Attach { + attachment: TargetFilter::SelfRef, + target: TargetFilter::Any, + }, + Vec::new(), + entry.source_id, + chooser, + )), + }; + return false; + } + } for (counter_index, (counter_type, counter_count)) in counters_to_apply.iter().enumerate() { if *counter_count > 0 @@ -1788,6 +1993,7 @@ pub(crate) fn finalize_committed_liminal_token_entry_from_action( // withheld its `TokenCreated` and wrote no `created_tokens_this_turn` row. Appending after the // assignment is the same position `created_ids.push(object_id)` produced. record_last_created_token(state, object_id); + true } @@ -7070,6 +7276,276 @@ mod tests { ); } + /// A Rest in Peace-class board-wide `Moved` graveyard→exile redirect, + /// deliberately NOT a creature: a creature would be a legal host for + /// `enchant creature` and CR 303.4g would never be reached. + fn add_graveyard_to_exile_redirect(state: &mut GameState) -> ObjectId { + use crate::types::ability::{AbilityDefinition, AbilityKind, ReplacementDefinition}; + use crate::types::replacements::ReplacementEvent; + + let rip = create_object( + state, + CardId(90_400), + PlayerId(1), + "Rest in Peace".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&rip) + .expect("just created") + .replacement_definitions + .push( + ReplacementDefinition::new(ReplacementEvent::Moved) + .destination_zone(Zone::Graveyard) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChangeZone { + origin: None, + destination: Zone::Exile, + target: TargetFilter::SelfRef, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + )), + ); + rip + } + + /// Build the unhosted liminal Aura fixture: an Aura with `Enchant creature` + /// in a game with no creature anywhere, so the CR 303.4f consult finds no + /// legal host and CR 303.4g decides the entry. + fn unhosted_liminal_aura_entrant(state: &mut GameState) -> (ObjectId, GameObject) { + use crate::types::keywords::Keyword; + + let (entry_ref, mut entrant) = + reserve_liminal_token_object(state, PlayerId(0), "Unhosted Aura".to_string()); + entrant.card_types.core_types = vec![CoreType::Enchantment]; + entrant.card_types.subtypes = vec!["Aura".to_string()]; + entrant.base_card_types = entrant.card_types.clone(); + let enchant = Keyword::Enchant(TargetFilter::Typed( + crate::types::ability::TypedFilter::new(crate::types::ability::TypeFilter::Creature), + )); + entrant.keywords = vec![enchant.clone()]; + entrant.base_keywords = vec![enchant]; + let timestamp = state.next_timestamp(); + entrant.reset_for_battlefield_entry(state.turn_number, timestamp); + (entry_ref, entrant) + } + + fn liminal_entry_for( + object: crate::types::game_state::LiminalEntrant, + source_id: ObjectId, + ) -> crate::types::game_state::LiminalEntry { + crate::types::game_state::LiminalEntry { + object, + name: "Unhosted Aura".to_string(), + source_id, + controller: PlayerId(0), + enters_attacking: false, + attach_to: None, + sacrifice_at: None, + remaining_count: 0, + created_ids: Vec::new(), + copy_resume: None, + spec_resume: None, + enter_tapped: EtbTapState::Unspecified, + enter_with_counters: Vec::new(), + kind: crate::types::game_state::LiminalEntryKind::Token, + replacement_applied: std::collections::HashSet::new(), + } + } + + /// CR 303.4g + CR 111.1 on the liminal seam: an unhosted entrant never + /// enters, and because this seam's entrant is a `LiminalEntrant::Token`, "if + /// the Aura is a token, it isn't created" is the whole disposition — nothing + /// is placed in any zone. + /// + /// A Rest in Peace-class graveyard→exile redirect is on the battlefield + /// throughout. Its only job is to be available: the seam performs no + /// placement for it to redirect, so both the graveyard and exile stay empty. + /// That is the point of the narrowing — the placement that used to bypass + /// this redirect does not exist any more, rather than existing and being + /// routed correctly. + #[test] + fn an_unhosted_liminal_aura_token_is_not_created_and_reaches_no_zone() { + let mut state = GameState::new_two_player(42); + let source_id = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Maker".to_string(), + Zone::Battlefield, + ); + let rip = add_graveyard_to_exile_redirect(&mut state); + // Reach guard: the anaphora slot starts non-empty, so the republish + // below is observable rather than vacuously equal. + state.last_created_token_ids = vec![ObjectId(4_242)]; + + let (entry_ref, entrant) = unhosted_liminal_aura_entrant(&mut state); + state.liminal_entries.insert( + entry_ref, + liminal_entry_for( + crate::types::game_state::LiminalEntrant::Token( + crate::types::game_state::TokenProjection::materialize(entrant), + ), + source_id, + ), + ); + + let mut events = Vec::new(); + assert!( + commit_liminal_token_entry_with_post_actions( + &mut state, + ProposedEvent::TokenEntry { + entry_ref, + enter_tapped: EtbTapState::Unspecified, + enter_with_counters: Vec::new(), + applied: std::collections::HashSet::new(), + }, + &mut events, + TokenEntryEventEmission::Emit, + Vec::new(), + ), + "a denied entry is not a pause — the batch loop must continue" + ); + + // CR 303.4g + CR 111.1: "it isn't created" — the object does not exist. + assert!( + !state.objects.contains_key(&entry_ref), + "a token CR 303.4g denies is not created at all" + ); + assert!(!state.battlefield.iter().any(|&id| id == entry_ref)); + // Nothing observed the entry: no birth, no battlefield ZoneChanged. + assert!( + !events.iter().any(|event| matches!( + event, + GameEvent::TokenCreated { object_id, .. } if *object_id == entry_ref + )), + "CR 303.4g: no TokenCreated for an entry the rule denies" + ); + assert!( + !events.iter().any(|event| matches!( + event, + GameEvent::ZoneChanged { object_id, .. } if *object_id == entry_ref + )), + "CR 303.4g: no ZoneChanged at all for an entry the rule denies" + ); + assert!( + state + .resolved_rules_journal + .entries() + .iter() + .all(|entry| !matches!( + entry.command, + Some(crate::types::resolved_commands::ResolvedRulesCommand::TokenCreation(_)) + )), + "CR 733: no birth is journaled for a token that isn't created" + ); + // CR 111.1: the anaphora slot names the tokens THIS effect created, so + // it is republished as this batch's list (empty here) rather than left + // holding the earlier, unrelated effect's tokens. + assert!(state.last_created_token_ids.is_empty()); + // Nothing reached a graveyard, so the redirect that was standing by had + // nothing to redirect either. + assert!(state + .players + .iter() + .all(|player| player.graveyard.is_empty())); + assert!( + !state.exile.iter().any(|&id| id == entry_ref), + "the redirect must not have anything to redirect" + ); + assert!( + state.objects.contains_key(&rip), + "reach guard: the redirect was on the battlefield the whole time" + ); + assert!(state.liminal_entries.is_empty()); + } + + /// The card-backed half of the old dual-disposition test, kept as the + /// regression for what replaced it: a card-backed projection reaching this + /// seam is now inert instead of being raw-placed into a graveyard. + /// + /// `LiminalEntrant::Card` is the CR 701.42a meld result — a permanent + /// "represented by two cards" — which enters through + /// `ProposedEvent::ZoneChange` from the exile its components sit in, and + /// whose CR 303.4g dispositions are decided there (see + /// `zone_pipeline::the_stack_origin_graveyard_placement_consults_moved_redirects` + /// for the replacement-consulted graveyard placement on that path). A + /// `TokenEntry` naming one names nothing this seam may act on. + /// + /// Revert-failing assertion: `graveyard.is_empty()`. The deleted + /// `place_unentered_aura_in_owners_graveyard` put this entrant into its + /// owner's graveyard with raw `zones::` calls, past the Rest in Peace-class + /// redirect that is on the battlefield here — so before the change this + /// assertion failed, and the exile assertion below failed too. + #[test] + fn a_card_backed_liminal_projection_is_never_placed_by_the_token_entry_seam() { + let mut state = GameState::new_two_player(42); + let source_id = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Maker".to_string(), + Zone::Battlefield, + ); + add_graveyard_to_exile_redirect(&mut state); + + let (entry_ref, entrant) = unhosted_liminal_aura_entrant(&mut state); + state.liminal_entries.insert( + entry_ref, + liminal_entry_for( + crate::types::game_state::LiminalEntrant::Card(entrant), + source_id, + ), + ); + + let mut events = Vec::new(); + assert!( + commit_liminal_token_entry_with_post_actions( + &mut state, + ProposedEvent::TokenEntry { + entry_ref, + enter_tapped: EtbTapState::Unspecified, + enter_with_counters: Vec::new(), + applied: std::collections::HashSet::new(), + }, + &mut events, + TokenEntryEventEmission::Emit, + Vec::new(), + ), + "declining an entrant that is not this seam's is not a pause" + ); + + assert!( + state + .players + .iter() + .all(|player| player.graveyard.is_empty()), + "no raw graveyard placement may happen on this path" + ); + assert!( + state.exile.is_empty(), + "and nothing was routed to the redirect's destination either" + ); + assert!(!state.objects.contains_key(&entry_ref)); + assert!(!state.battlefield.iter().any(|&id| id == entry_ref)); + assert!(events.is_empty(), "nothing observable happened: {events:?}"); + assert!( + state.liminal_entries.contains_key(&entry_ref), + "the projection is left exactly where it was, not consumed" + ); + } + #[test] fn paused_liminal_copy_token_counter_finalizes_entry_after_choice() { use std::sync::Arc; @@ -7120,7 +7596,6 @@ mod tests { let source_id = ObjectId(100); let (entry_ref, mut token) = reserve_liminal_token_object(&mut state, PlayerId(0), values.name.clone()); - token.is_token = true; apply_copiable_values_to_liminal_object( &mut token, &values, @@ -7133,7 +7608,9 @@ mod tests { state.liminal_entries.insert( entry_ref, LiminalEntry { - object: token, + object: crate::types::game_state::LiminalEntrant::Token( + crate::types::game_state::TokenProjection::materialize(token), + ), name: values.name.clone(), source_id, controller: PlayerId(0), diff --git a/crates/engine/src/game/effects/token_copy.rs b/crates/engine/src/game/effects/token_copy.rs index 2ced01f18a..8afd4c4072 100644 --- a/crates/engine/src/game/effects/token_copy.rs +++ b/crates/engine/src/game/effects/token_copy.rs @@ -14,8 +14,8 @@ use crate::types::card_type::SubtypeSet; use crate::types::counter::{CounterMatch, CounterType}; use crate::types::events::GameEvent; use crate::types::game_state::{ - GameState, LiminalEntry, PendingCopyTokenBatch, PendingCopyTokenResolution, - PendingCounterPostAction, PendingLiminalEntryResume, + CopyTokenEntryTail, GameState, LiminalEntry, PendingCopyTokenBatch, PendingCopyTokenResolution, + PendingCounterPostAction, PendingLiminalEntryResume, WaitingFor, }; use crate::types::identifiers::{CardId, ObjectId, ObjectIncarnationRef}; use crate::types::proposed_event::{ @@ -529,6 +529,10 @@ pub(crate) fn apply_copy_token_after_replacement_with_created_ids( let liminal_immediate = copy_token_modifications_are_liminal_immediate(&additional_modifications) && etb_counters.is_empty(); + // CR 205.3m: the live creature-type list the CR 707.9 subtype exceptions + // resolve against. Loop-invariant, and cloned once so the per-token CR + // 303.4f/g projection below can be built while `state` is borrowed. + let all_creature_types = state.all_creature_types.clone(); for index in 0..final_count { if liminal_immediate { @@ -571,7 +575,12 @@ pub(crate) fn apply_copy_token_after_replacement_with_created_ids( state.liminal_entries.insert( token_id, LiminalEntry { - object: token, + // CR 111.1: the projection this entry will create is a + // token, carried as the witness the `TokenEntry` seam acts + // on rather than as a flag it has to trust. + object: crate::types::game_state::LiminalEntrant::Token( + crate::types::game_state::TokenProjection::materialize(token), + ), name: name.clone(), source_id, controller, @@ -701,7 +710,61 @@ pub(crate) fn apply_copy_token_after_replacement_with_created_ids( ObjectIncarnationRef::from_object(token) }); - // CR 733: journal the settled copy birth, after the body borrow ends. + // CR 707.9b/9c + CR 614.12: the consult below must see the entrant as it + // will exist AFTER the copy exceptions, not the bare copied body. + // + // `materialize_token_copy_body` is a documented no-op for + // `DeferredToUnjournaledSeam` — on this path the exceptions land later, at + // `apply_token_modifications` — so at this point the stored object still + // carries the UNMODIFIED copiable values. The liminal seam folds its + // exceptions before its own consult, so without this projection the two + // copy seams disagree about what the entrant is, and an exception that + // adds or removes `Creature` (CR 303.4d), adds or removes the `Aura` + // subtype, or changes color (CR 702.16c) flips the CR 303.4f/g verdict. + // The failure mode is silent: a token that is never created. + // + // A PROJECTION rather than an early mutation of the stored object: the + // exceptions must still be applied exactly once, by the seam that owns + // them and can pause (`AddCounterOnEnter` reaches + // `add_counter_with_replacement`), and several arms — `AddPower`, + // `GrantAbility`, `GrantTrigger` — are not idempotent under a second pass. + let entrant_projection = state.objects.get(&token_id).map(|token| { + let mut projection = token.clone(); + apply_immediate_copy_token_modifications_to_object( + &mut projection, + &additional_modifications, + &all_creature_types, + ); + projection + }); + + // CR 303.4f + CR 303.4g: decide the entering Aura's host BEFORE the CR 733 + // birth is journaled (append-only, no retraction) and BEFORE the attach is + // applied, so the CR 303.4g "if the Aura is a token, it isn't created" arm + // can withhold the birth and the attach can never take a lower journal + // ordinal than the birth it depends on. Same decide/act split the liminal + // seam uses in `token::commit_liminal_token_entry_with_post_actions`. + let hosts = match entrant_projection.as_ref() { + Some(entrant) => { + crate::game::zone_pipeline::entering_aura_hosts_projected(state, token_id, entrant) + } + None => crate::game::zone_pipeline::EnteringAuraHosts::NotApplicable, + }; + if matches!( + &hosts, + crate::game::zone_pipeline::EnteringAuraHosts::Hosts { legal_targets, .. } + if legal_targets.is_empty() + ) { + // CR 303.4g: this entrant is always a token on this path + // (`materialize_token_copy_body` set `is_token`), so "it isn't created": + // un-enter it with no birth record, no `TokenCreated`, no battlefield + // `ZoneChanged`, no `created_ids` row, and nothing in any graveyard. + super::token::uncreate_unentered_aura_token(state, token_id, token_owner); + continue; + } + + // CR 733: journal the settled copy birth, after the body borrow ends and + // after CR 303.4g has settled that there IS a birth to journal. if let Some(object) = created_reference { let cause = state.current_or_begin_rules_execution_node(); let command = ResolvedTokenCreationCommand { @@ -723,105 +786,55 @@ pub(crate) fn apply_copy_token_after_replacement_with_created_ids( .expect("resolved copy-token creation must have a live journal cause"); } - let finalization = CopyTokenFinalization { - name: name.clone(), - enters_attacking, - source_id, - controller, + let tail = CopyTokenEntryTail { + owner: token_owner, + copy: copy_spec.clone(), + enter_tapped, + enter_with_counters: enter_with_counters.clone(), + etb_counters: etb_counters.clone(), + remaining_count: final_count.saturating_sub(index + 1), }; - if !apply_token_modifications( - state, - token_id, - &finalization, - &additional_modifications, - events, - ) { - let remaining_count = final_count.saturating_sub(index + 1); - if remaining_count > 0 { - super::counters::append_pending_counter_post_actions( - state, - vec![PendingCounterPostAction::ContinueCopyTokenCreation { - owner: token_owner, - copy: copy_spec.clone(), - enter_tapped, - enter_with_counters: enter_with_counters.clone(), - remaining_count, - }], - ); - } - state.last_created_token_ids = created_ids.clone(); - return CopyTokenApplyStatus { - created_ids, - completion: CopyTokenApplyCompletion::Paused, - }; - } - - finalize_copied_token(state, source_id, token_id); - // CR 614.1c + CR 122.6a: ETB-counter replacement mutations are carried - // on the accepted CreateToken spec, even for copy tokens whose full - // CR 707 payload lives in `CopyTokenSpec`. - for (counter_index, (counter_type, counter_count)) in etb_counters.iter().enumerate() { - if *counter_count > 0 - && !super::counters::add_counter_with_replacement( - state, - token_owner, - token_id, - counter_type.clone(), - *counter_count, - events, - ) - { + match crate::game::zone_pipeline::apply_entering_aura_hosts(state, token_id, hosts) { + // `NoLegalHost` is unreachable here — the empty-host arm above `continue`d. + crate::game::zone_pipeline::EnteringAuraAttachment::NotApplicable + | crate::game::zone_pipeline::EnteringAuraAttachment::Attached + | crate::game::zone_pipeline::EnteringAuraAttachment::NoLegalHost => {} + crate::game::zone_pipeline::EnteringAuraAttachment::NeedsChoice { + controller: chooser, + legal_targets, + } => { + // CR 616.1 carrier: park the WHOLE remaining entry tail — copy + // exceptions, entry counters, entry events, and the rest of the + // batch — behind the host choice. state.last_created_token_ids = created_ids.clone(); - let remaining_counters = etb_counters[counter_index + 1..] - .iter() - .filter(|(_, count)| *count > 0) - .map(|(counter_type, count)| { - crate::types::game_state::PendingCounterAddition::Object { - actor: token_owner, - object_id: token_id, - counter_type: counter_type.clone(), - count: *count, - } - }) - .collect(); - let remaining_count = final_count.saturating_sub(index + 1); super::counters::stash_pending_counter_additions( state, - remaining_counters, + Vec::new(), crate::types::game_state::PendingEffectResolved::with_post_actions_without_effect( EffectKind::CopyTokenOf, source_id, - vec![ - PendingCounterPostAction::FinalizeCopyTokenEntry { - object_id: token_id, - name: name.clone(), - enters_attacking, - source_id, - controller, - }, - PendingCounterPostAction::ContinueCopyTokenCreation { - owner: token_owner, - copy: Box::new(CopyTokenSpec { - values: values.clone(), - display_source, - printed_ref: printed_ref.clone(), - token_image_ref: token_image_ref.clone(), - extra_keywords: extra_keywords.clone(), - additional_modifications: additional_modifications.clone(), - tapped, - enters_attacking, - sacrifice_at: sacrifice_at.clone(), - source_id, - controller, - }), - enter_tapped, - enter_with_counters: enter_with_counters.clone(), - remaining_count, - }, - ], + vec![PendingCounterPostAction::ContinueCopyTokenEntryAfterAuraHost { + object_id: token_id, + tail: Box::new(tail), + }], ), ); + state.waiting_for = WaitingFor::ReturnAsAuraTarget { + player: chooser, + source_id, + returned_id: token_id, + legal_targets, + pending_effect: Box::new(crate::types::ability::ResolvedAbility::new( + crate::types::ability::Effect::Attach { + attachment: crate::types::ability::TargetFilter::SelfRef, + target: crate::types::ability::TargetFilter::Any, + }, + Vec::new(), + source_id, + chooser, + )), + }; return CopyTokenApplyStatus { created_ids, completion: CopyTokenApplyCompletion::Paused, @@ -829,42 +842,20 @@ pub(crate) fn apply_copy_token_after_replacement_with_created_ids( } } - // CR 508.4: Uses shared helper for defending player resolution. - if enters_attacking { - crate::game::combat::enter_attacking(state, token_id, source_id, controller); - } - - // CR 111.10: Predefined token abilities for known subtypes (Treasure, Food, etc.). - // - // PAIRED WITH THE REPLAY ARM at `token::apply_resolved_token_creation`'s - // `ResolvedTokenBody::Copy` match arm, which must call the same - // predefined-only injector. Unlike the liminal path — where one - // `copy_resume.is_some()` predicate drives both the live and journaled - // matches, so a divergence fails to compile — this branch is coupled to - // replay by convention only. Switching it to the catalog-wide - // `inject_resolved_token_abilities` would silently desync replay from live. - super::token::inject_predefined_token_abilities(state, token_id); - // Battlefield entry of a copy token: request an incremental re-derive - // for just this token. `flush_layers` escalates to a full pass when - // the copied object sources a continuous effect, carries a CDA, etc. - crate::game::layers::mark_layers_entered(state, token_id); - crate::game::restrictions::record_token_created(state, token_id); - - // CR 400.7 + CR 608.2i + CR 603.2c: route the record and the entry pair through the single - // `from: None → Battlefield` authority so the emitted `ZoneChanged` carries this turn's - // real zone-change index instead of the `0` placeholder. The authority performs the - // CR 608.2i battlefield-entry bookkeeping itself, so the co-located - // `record_battlefield_entry` call is deleted — keeping it would double-count - // `battlefield_entries_this_turn`. - super::token::push_committed_token_entry_events( + if !finish_non_liminal_copy_token_entry( state, token_id, - name.clone(), - source_id, + &tail, + &mut CopyBatchIdSink::BatchLocal(&mut created_ids), events, - ) - .expect("token just created"); - created_ids.push(token_id); + ) { + let created_ids_snapshot = created_ids.clone(); + state.last_created_token_ids = created_ids_snapshot; + return CopyTokenApplyStatus { + created_ids, + completion: CopyTokenApplyCompletion::Paused, + }; + } } if liminal_immediate { @@ -929,6 +920,251 @@ struct CopyTokenFinalization { controller: crate::types::player::PlayerId, } +/// CR 111.1 + CR 707.2: where a finished non-liminal copy-token entry publishes +/// the id it just created. +/// +/// The two live routes into [`finish_non_liminal_copy_token_entry`] differ in +/// exactly this one respect, so the difference is a named parameter rather than +/// two copies of the tail. Inline in the batch loop the running list is a local +/// the loop owns and later returns; resumed from a parked post-action that local +/// is gone, and the id must go through the guarded ledger-3 + in-flight-buffer +/// authority instead (see `token::record_last_created_copy_batch_token` for why +/// that has to be one call and not two statements). +enum CopyBatchIdSink<'a> { + BatchLocal(&'a mut Vec), + ResumedBatch, +} + +impl CopyBatchIdSink<'_> { + fn publish(&mut self, state: &mut GameState, token_id: ObjectId) { + match self { + CopyBatchIdSink::BatchLocal(ids) => ids.push(token_id), + CopyBatchIdSink::ResumedBatch => { + super::token::record_last_created_copy_batch_token(state, token_id); + } + } + } + + /// The batch's created-id list as it stands, for the `last_created_token_ids` + /// publication every pause inside the tail performs. On the resumed route the + /// ledger already IS that list, so this reads it back rather than inventing one. + fn snapshot(&self, state: &GameState) -> Vec { + match self { + CopyBatchIdSink::BatchLocal(ids) => (**ids).clone(), + CopyBatchIdSink::ResumedBatch => state.last_created_token_ids.clone(), + } + } +} + +/// CR 707.2 + CR 614.1c + CR 400.7: finish ONE non-liminal copy-token entry whose +/// body is already materialized and whose CR 733 birth is already journaled. +/// +/// Applies the copy exceptions (CR 707.9), the entry counters (CR 306.5b copied +/// loyalty + CR 614.1c self-replacements + the creating effect's own), the +/// attacking placement (CR 508.4), the predefined-token abilities (CR 111.10), +/// and the CR 400.7 entry pair, then publishes the id through `sink`. +/// +/// Returns `false` when a sub-step paused for a player choice, having parked its +/// own remainder plus the rest of the batch; the caller must report `Paused`. +fn finish_non_liminal_copy_token_entry( + state: &mut GameState, + token_id: ObjectId, + tail: &CopyTokenEntryTail, + sink: &mut CopyBatchIdSink<'_>, + events: &mut Vec, +) -> bool { + let token_owner = tail.owner; + let copy_spec = &tail.copy; + let enter_tapped = tail.enter_tapped; + let enter_with_counters = &tail.enter_with_counters; + let etb_counters = &tail.etb_counters; + let remaining_count = tail.remaining_count; + let enters_attacking = copy_spec.enters_attacking; + let source_id = copy_spec.source_id; + let controller = copy_spec.controller; + let additional_modifications = copy_spec.additional_modifications.clone(); + let name = copy_spec.values.name.clone(); + + let finalization = CopyTokenFinalization { + name: name.clone(), + enters_attacking, + source_id, + controller, + }; + if !apply_token_modifications( + state, + token_id, + &finalization, + &additional_modifications, + events, + ) { + if remaining_count > 0 { + super::counters::append_pending_counter_post_actions( + state, + vec![PendingCounterPostAction::ContinueCopyTokenCreation { + owner: token_owner, + copy: copy_spec.clone(), + enter_tapped, + enter_with_counters: enter_with_counters.clone(), + remaining_count, + }], + ); + } + state.last_created_token_ids = sink.snapshot(state); + return false; + } + + finalize_copied_token(state, source_id, token_id); + + // CR 614.1c + CR 122.6a: ETB-counter replacement mutations are carried + // on the accepted CreateToken spec, even for copy tokens whose full + // CR 707 payload lives in `CopyTokenSpec`. + for (counter_index, (counter_type, counter_count)) in etb_counters.iter().enumerate() { + if *counter_count > 0 + && !super::counters::add_counter_with_replacement( + state, + token_owner, + token_id, + counter_type.clone(), + *counter_count, + events, + ) + { + state.last_created_token_ids = sink.snapshot(state); + let remaining_counters = etb_counters[counter_index + 1..] + .iter() + .filter(|(_, count)| *count > 0) + .map(|(counter_type, count)| { + crate::types::game_state::PendingCounterAddition::Object { + actor: token_owner, + object_id: token_id, + counter_type: counter_type.clone(), + count: *count, + } + }) + .collect(); + super::counters::stash_pending_counter_additions( + state, + remaining_counters, + crate::types::game_state::PendingEffectResolved::with_post_actions_without_effect( + EffectKind::CopyTokenOf, + source_id, + vec![ + PendingCounterPostAction::FinalizeCopyTokenEntry { + object_id: token_id, + name: name.clone(), + enters_attacking, + source_id, + controller, + }, + PendingCounterPostAction::ContinueCopyTokenCreation { + owner: token_owner, + copy: copy_spec.clone(), + enter_tapped, + enter_with_counters: enter_with_counters.clone(), + remaining_count, + }, + ], + ), + ); + return false; + } + } + + // CR 508.4: Uses shared helper for defending player resolution. + if enters_attacking { + crate::game::combat::enter_attacking(state, token_id, source_id, controller); + } + + // CR 111.10: Predefined token abilities for known subtypes (Treasure, Food, etc.). + // + // PAIRED WITH THE REPLAY ARM at `token::apply_resolved_token_creation`'s + // `ResolvedTokenBody::Copy` match arm, which must call the same + // predefined-only injector. Unlike the liminal path — where one + // `copy_resume.is_some()` predicate drives both the live and journaled + // matches, so a divergence fails to compile — this branch is coupled to + // replay by convention only. Switching it to the catalog-wide + // `inject_resolved_token_abilities` would silently desync replay from live. + super::token::inject_predefined_token_abilities(state, token_id); + // Battlefield entry of a copy token: request an incremental re-derive + // for just this token. `flush_layers` escalates to a full pass when + // the copied object sources a continuous effect, carries a CDA, etc. + crate::game::layers::mark_layers_entered(state, token_id); + crate::game::restrictions::record_token_created(state, token_id); + + // CR 400.7 + CR 608.2i + CR 603.2c: route the record and the entry pair through the single + // `from: None → Battlefield` authority so the emitted `ZoneChanged` carries this turn's + // real zone-change index instead of the `0` placeholder. The authority performs the + // CR 608.2i battlefield-entry bookkeeping itself, so the co-located + // `record_battlefield_entry` call is deleted — keeping it would double-count + // `battlefield_entries_this_turn`. + super::token::push_committed_token_entry_events(state, token_id, name, source_id, events) + .expect("token just created"); + sink.publish(state, token_id); + true +} + +/// CR 303.4f: resume a non-liminal copy-token entry that paused on its Aura-host +/// choice. The host is already attached by the `ReturnAsAuraTarget` answer +/// handler; everything after the attach is the shared tail. +pub(crate) fn continue_copy_token_entry_after_aura_host( + state: &mut GameState, + token_id: ObjectId, + tail: CopyTokenEntryTail, + events: &mut Vec, +) -> bool { + if !finish_non_liminal_copy_token_entry( + state, + token_id, + &tail, + &mut CopyBatchIdSink::ResumedBatch, + events, + ) { + return false; + } + // CR 707.2: the paused token is done; drive the rest of the batch. Publication + // mirrors the sibling `ContinueCopyTokenCreation` resume arm exactly — a fresh + // `created_ids` from the continuation, EXTENDED into whichever ledger owns the + // batch — because `finish_non_liminal_copy_token_entry`'s `ResumedBatch` sink + // has already published THIS token into both of them, and assigning the + // continuation's list wholesale would drop it. + if tail.remaining_count == 0 { + return true; + } + let status = apply_copy_token_after_replacement( + state, + tail.owner, + *tail.copy, + tail.enter_tapped, + tail.enter_with_counters, + tail.remaining_count, + events, + ); + let completion = status.completion; + extend_copy_batch_created_ids(state, status.created_ids); + matches!(completion, CopyTokenApplyCompletion::Completed) +} + +/// CR 111.1 + CR 707.2: fold a RESUMED copy-batch continuation's created ids into +/// whichever ledger owns the batch. +/// +/// Single authority for that bulk republish, shared by every post-action arm that +/// restarts a paused batch. The two destinations are not interchangeable: while a +/// `CopyToken` frame is live its `created_ids` buffer is assigned WHOLESALE onto +/// ledger 3 at the drain, so extending ledger 3 directly would be overwritten; +/// with no frame, ledger 3 is the only destination there is. +/// +/// `extend`, never assign: the id of the token whose own entry just finished has +/// already been published by `token::record_last_created_copy_batch_token`, and +/// assigning the continuation's list wholesale would drop it. +pub(crate) fn extend_copy_batch_created_ids(state: &mut GameState, created_ids: Vec) { + if let Some(pending) = state.active_copy_token_mut() { + pending.created_ids.extend(created_ids); + } else { + state.last_created_token_ids.extend(created_ids); + } +} + /// CR 707.2 / CR 707.9: Complete copy-token entry and apply remaining copy /// modifications after resuming from a counter-placement replacement pause. #[allow(clippy::too_many_arguments)] diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 41eb5b0b26..2f3ec0abe6 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -9940,14 +9940,13 @@ fn apply_action( grants, } => (enchant_filter.clone(), grants.clone()), _ => { - let old_target = match chosen { - TargetRef::Object(chosen_id) => { - super::effects::attach::attach_to(state, returned, chosen_id) - } - TargetRef::Player(chosen_player) => { - super::effects::attach::attach_to_player(state, returned, chosen_player) - } - }; + // CR 303.4f + CR 701.3b: attach through the entering-Aura + // authority, so the CR 701.3a gate judges the same entrant + // the host list was offered for. Seams that park none get + // the stored object, i.e. their prior behaviour exactly. + let old_target = super::zone_pipeline::attach_chosen_entering_aura_host( + state, returned, &chosen, + ); if let Some(old_target) = old_target { events.push(crate::types::events::GameEvent::Unattached { attachment_id: returned, @@ -16106,14 +16105,12 @@ mod stage2_injector_tests { // shifts combine with #6958's paid-cast outcome exclusion and // #6976's conditional-branch exclusions. None creates an // `OptionalEffect` prompt. Re-pinned against the merged source. - // #7221 adds the typed player-action completion seam above all three - // producers: `:6306/:6383/:9578 => :6390/:6467/:9672`. The first - // two move by +84; the third also includes ten lines added inside - // `resolve_chain_body`. The census and partition assertions above - // remain unchanged, and each producer remains in its named function. - "game/effects/mod.rs:6390".to_string(), - "game/effects/mod.rs:6467".to_string(), - "game/effects/mod.rs:9672".to_string(), + // Current-main port: #7221's typed player-action completion seam and the + // contemporaneous upstream changes moved these three producers. Re-derived + // in the merged source, still in their named production functions. + "game/effects/mod.rs:6632".to_string(), + "game/effects/mod.rs:6709".to_string(), + "game/effects/mod.rs:9914".to_string(), // UNMOVED across the rebase, and that is itself evidence the SET did not // move: a census that had gained or lost a producer would not leave this // entry both byte-identical AND at the same coordinate. @@ -16431,7 +16428,24 @@ mod stage2_injector_tests { // // SET PRESERVATION: unchanged. Upstream adds no line matching the needle to this file and // neither does this branch — total still 37, partition still 5/7/25. - "game/engine.rs:12004".to_string(), + // + // #7303 fix round 3: `:12004 ⇒ :12003`, −1, and ONLY this entry moved. + // Re-derived, not assumed. `git diff -U0` on this file has exactly ONE hunk, + // `@@ -9943,8 +9943,7 @@` inside `apply_action` — the `ReturnAsAuraTarget` + // resume arm's two raw attach calls replaced by one call to the entering-Aura + // attachment authority plus its four-line rationale (`-8 +7`). It sits ABOVE + // this producer, and the whole-file delta is also `-1`, so nothing was + // inserted or removed below it. Predicted `12004-1` equals the observed + // coordinate exactly. IDENTITY re-established rather than assumed: the + // producer at its new coordinate is md5-identical to `a0bca5197:engine.rs` + // at its old one, and so is its ±6-line window (`4f7522fc…`) — the window is + // what discriminates here, since the same one-line mint text appears at + // several coordinates in the crate. The other four entries did not move and + // were re-read in place. SET PRESERVATION: the two asserts above this one ran + // FIRST and both fired GREEN on the run that caught this — total still 37, + // partition still 5/7/25. The change constructs no `WaitingFor` of any kind; + // it threads an attachment-legality authority through an existing call. + "game/engine.rs:12003".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 7c74bad679..2c342c0dae 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -1520,8 +1520,17 @@ fn handle_persist_chosen_attribute_choice( .is_some_and(|aura| aura.attached_to.is_none()) { match crate::game::zone_pipeline::resolve_entering_aura_attachment(state, source_id) { + // CR 303.4g does NOT apply on this route: `NoLegalHost` here means the + // entrant already ENTERED the battlefield as a non-Aura and only became + // an Aura when its `BecomeCopy` replacement realized post-entry. There is + // no un-entering it — CR 303.4g's "isn't created" / "remains in its + // current zone" clause governs an object still in the act of entering — + // so the CR 704.5m unattached-Aura state-based action owns it from here, + // exactly as it did before the `Resolved` split. Same disposition as + // `Attached`: nothing further to do at this seam. crate::game::zone_pipeline::EnteringAuraAttachment::NotApplicable - | crate::game::zone_pipeline::EnteringAuraAttachment::Resolved => {} + | crate::game::zone_pipeline::EnteringAuraAttachment::Attached + | crate::game::zone_pipeline::EnteringAuraAttachment::NoLegalHost => {} crate::game::zone_pipeline::EnteringAuraAttachment::NeedsChoice { .. } => { return Err(EngineError::InvalidAction( "PersistChosenAttribute requires a resolved Aura host before the copy installs" @@ -1789,7 +1798,7 @@ pub(super) fn handle_copy_target_choice( entry.copy_resume.as_ref().and_then(|copy| { (entry.remaining_count > 0).then(|| { ( - entry.object.owner, + entry.object.projected().owner, copy.clone(), entry.enter_tapped, entry.enter_with_counters.clone(), @@ -2107,22 +2116,53 @@ fn finish_copy_target_choice_entry( // to priority, and any entry trigger already placed on the stack resolves with // the host chosen (CR 303.4f). The auto-attach (0/1 host) branch never pauses. // - // Liminal-path caveat: the copy-token caller (`handle_copy_target_choice`, - // ~1236) has a `copy_continuation` / committed-token-entry tail that runs only - // if this function returns `Ok(None)`. A multi-host `NeedsChoice` pause here - // would return `Ok(Some(..))` and skip that tail — the same drop the existing - // `replay_deferred_entry_events` pause above already causes. No card reaches - // that intersection today (the liminal accept path requires the COPIED card to - // carry its own enter-as-a-copy replacement, and none of those realize into a - // multi-host Aura with a token continuation); if one ever does, thread the - // continuation through the resume like the ETB-counter pause does. + // CR 616.1: the `NeedsChoice` arm below is a PAUSE, so it owes the same + // carrier the ETB-counter pause above owes — `counter_pause_post_actions` + // holds the liminal copy-token caller's committed-entry emit and its + // remaining-batch continuation, and returning `Ok(Some(..))` without parking + // them would drop both (`handle_copy_target_choice` runs that tail only on + // `Ok(None)`). Parked rather than reasoned about: the current card pool does + // not reach the intersection (the liminal accept path requires the COPIED card + // to carry its own enter-as-a-copy replacement, and none of those realize into + // a multi-host Aura with a token continuation), but "no card does this today" + // is not a property the engine should depend on. match crate::game::zone_pipeline::resolve_entering_aura_attachment(state, source_id) { + // CR 303.4g does NOT apply on this route: this entrant already ENTERED the + // battlefield as a non-Aura (Copy Enchantment enters as a plain enchantment) + // and only became an Aura when `BecomeCopy` realized post-entry. It cannot be + // un-entered, and CR 303.4g's "isn't created" clause governs only an object + // still in the act of entering — so the CR 704.5m unattached-Aura state-based + // action owns it. Same disposition as `Attached`: nothing to do here. crate::game::zone_pipeline::EnteringAuraAttachment::NotApplicable - | crate::game::zone_pipeline::EnteringAuraAttachment::Resolved => {} + | crate::game::zone_pipeline::EnteringAuraAttachment::Attached + | crate::game::zone_pipeline::EnteringAuraAttachment::NoLegalHost => {} crate::game::zone_pipeline::EnteringAuraAttachment::NeedsChoice { controller, legal_targets, } => { + // CR 616.1 carrier for the host pause. Same vehicle the non-liminal + // copy-token path uses for its own `ReturnAsAuraTarget` pause + // (`token_copy::apply_copy_token_after_replacement_with_created_ids`, + // the `ContinueCopyTokenEntryAfterAuraHost` stash): an empty counter + // queue whose completion carries the post-actions, drained by + // `drain_pending_counter_additions` when the `ReturnAsAuraTarget` + // handler in `engine.rs` returns to priority and calls + // `resume_pending_continuation_if_priority`. + // + // Only stashed when there is something to carry — the non-liminal + // caller (real Copy Enchantment) passes an empty list and must not + // acquire a spurious pending-counter frame. `EmitCommittedCopyTokenEntry` + // in the list is idempotent (`flush_pending_token_battlefield_entry` + // above already realized the parked entry), so re-running it after the + // host choice is a no-op rather than a double emit. + if !counter_pause_post_actions.is_empty() { + super::effects::counters::stash_pending_counter_post_actions( + state, + crate::types::ability::EffectKind::CopyTokenOf, + source_id, + counter_pause_post_actions, + ); + } state.waiting_for = WaitingFor::ReturnAsAuraTarget { player: controller, source_id, @@ -2228,6 +2268,7 @@ fn copy_effect_for_source(state: &GameState, source_id: ObjectId) -> Option<&Abi if let Some(entry) = state.liminal_entries.get(&source_id) { return entry .object + .projected() .replacement_definitions .iter_all() .filter_map(|replacement| replacement.execute.as_deref()) @@ -2280,7 +2321,7 @@ pub(super) fn apply_post_replacement_effect( state .liminal_entries .get(&obj_id) - .map(|entry| &entry.object) + .map(|entry| entry.object.projected()) }) .map(|obj| { ( @@ -6509,6 +6550,147 @@ mod tests { ); } + /// CR 616.1: the CR 303.4f host pause must CARRY the caller's entry tail, not + /// drop it. + /// + /// `finish_copy_target_choice_entry` returns `Ok(Some(..))` on that pause, and + /// its liminal copy-token caller runs the committed-entry emit and the + /// remaining-batch continuation only on `Ok(None)`. Before the carrier, a + /// multi-host Aura realized by a liminal copy token would therefore have + /// abandoned the rest of the batch and never published its created ids. + /// + /// Driven at the seam rather than through a card, because no card in the pool + /// reaches the intersection today (see the comment at the `NeedsChoice` arm) — + /// which is exactly why the behaviour needs a test that does not depend on + /// one. The post-action chosen is the real batch continuation, and the + /// prompt is answered through the production `apply` path, so the assertion + /// is "the second token actually got created", not "a struct was stored". + #[test] + fn the_aura_host_pause_carries_the_liminal_copy_token_continuation() { + use crate::game::printed_cards::intrinsic_copiable_values; + use crate::types::game_state::PendingCounterPostAction; + use crate::types::keywords::Keyword; + use crate::types::proposed_event::{CopyTokenSpec, EtbTapState}; + + let mut state = GameState::new_two_player(42); + let hosts: Vec = (0..2) + .map(|i| make_creature(&mut state, PlayerId(0), &format!("Grizzly Bears {i}"))) + .collect(); + + // An unattached Aura on the battlefield with two legal hosts: the shape + // `resolve_entering_aura_attachment` answers with `NeedsChoice`. + let entering_aura = create_object( + &mut state, + CardId(400), + PlayerId(0), + "Entering Aura".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&entering_aura).unwrap(); + obj.base_card_types.core_types = vec![CoreType::Enchantment]; + obj.card_types.core_types = vec![CoreType::Enchantment]; + obj.base_card_types.subtypes = vec!["Aura".to_string()]; + obj.card_types.subtypes = vec!["Aura".to_string()]; + let enchant = Keyword::Enchant(TargetFilter::Typed( + crate::types::ability::TypedFilter::new( + crate::types::ability::TypeFilter::Creature, + ), + )); + obj.base_keywords = vec![enchant.clone()]; + obj.keywords = vec![enchant]; + } + + // The tail the caller would otherwise have run itself: one more token in + // this batch, a copy of a Treasure. + let copied = create_object( + &mut state, + CardId(401), + PlayerId(0), + "Treasure".to_string(), + Zone::Battlefield, + ); + { + let obj = state.objects.get_mut(&copied).unwrap(); + obj.base_card_types.core_types = vec![CoreType::Artifact]; + obj.card_types = obj.base_card_types.clone(); + } + let values = intrinsic_copiable_values(state.objects.get(&copied).unwrap()); + let continuation = PendingCounterPostAction::ContinueCopyTokenCreation { + owner: PlayerId(0), + copy: Box::new(CopyTokenSpec { + values: Box::new(values), + display_source: crate::game::game_object::DisplaySource::Token, + printed_ref: None, + token_image_ref: None, + extra_keywords: Vec::new(), + additional_modifications: Vec::new(), + tapped: false, + enters_attacking: false, + sacrifice_at: None, + source_id: entering_aura, + controller: PlayerId(0), + }), + enter_tapped: EtbTapState::Unspecified, + enter_with_counters: Vec::new(), + remaining_count: 1, + }; + + let tokens_before = state + .battlefield + .iter() + .filter(|id| state.objects[id].is_token) + .count(); + + let mut events = Vec::new(); + let paused = finish_copy_target_choice_entry( + &mut state, + entering_aura, + &mut events, + vec![continuation], + false, + ) + .expect("the host pause is not an error"); + + assert!( + matches!(paused, Some(WaitingFor::ReturnAsAuraTarget { .. })), + "two legal hosts must pause on the CR 303.4f host choice, got {paused:?}" + ); + assert!( + state.active_counter_additions().is_some(), + "CR 616.1: the pause must park the caller's tail, not drop it" + ); + + apply_as_current( + &mut state, + GameAction::ChooseTarget { + target: Some(TargetRef::Object(hosts[1])), + }, + ) + .expect("answer the CR 303.4f host choice"); + + assert_eq!( + state.objects[&entering_aura].attached_to, + Some(crate::game::game_object::AttachTarget::Object(hosts[1])), + "the host choice still applies" + ); + let tokens_after = state + .battlefield + .iter() + .filter(|id| state.objects[id].is_token) + .count(); + assert_eq!( + tokens_after, + tokens_before + 1, + "CR 616.1: the parked batch continuation must run once the host is chosen \ + — this is the assertion that fails if the carrier is removed" + ); + assert!( + state.active_counter_additions().is_none(), + "the parked frame is consumed, not left resident" + ); + } + /// CR 303.4f: With MULTIPLE legal hosts, the copied Aura must PAUSE on /// `ReturnAsAuraTarget` for the controller's choice, then attach to the /// chosen host and survive SBA. Exercises the interactive branch of the @@ -7091,7 +7273,9 @@ mod tests { state.liminal_entries.insert( liminal_id, LiminalEntry { - object: liminal, + object: crate::types::game_state::LiminalEntrant::Token( + crate::types::game_state::TokenProjection::materialize(liminal), + ), name: "Liminal Mockingbird".to_string(), source_id: ObjectId(999), controller: PlayerId(0), diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index 5d67c3ab9e..7e06ab73dd 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -13,8 +13,9 @@ use crate::game::quantity::{ }; use crate::types::ability::{ ChoiceValue, ChosenAttribute, CombatRelation, CombatRelationSubject, ControllerRef, CountScope, - FilterProp, Parity, ParitySource, PtStat, PtValueScope, QuantityExpr, ResolvedAbility, - SharedQuality, SharedQualityRelation, TargetFilter, TargetRef, TypeFilter, TypedFilter, + FilterProp, Parity, ParitySource, PlayerFilter, PtStat, PtValueScope, QuantityExpr, + ResolvedAbility, SharedQuality, SharedQualityRelation, TargetFilter, TargetRef, TypeFilter, + TypedFilter, }; use crate::types::card::CardFace; use crate::types::card_type::{CoreType, Supertype}; @@ -1488,20 +1489,282 @@ pub(crate) fn controller_ref_player( ControllerRef::ActivePlayer => Some(state.active_player), } } +/// Whether `filter`, or any filter nested anywhere inside it, satisfies `leaf`. +/// +/// The single authority for `TargetFilter`'s recursive shape. Every predicate +/// that asks "does this filter mention X anywhere" routes here instead of +/// re-listing the nesting variants, because a predicate that lists them itself +/// lists them from memory: `filter_contains_last_zone_changed` and its +/// `last_created` twin both omitted `ChosenDamageSource`'s optional inner filter, +/// so a `LastCreated` nested one level inside it read as absent. +/// +/// The match is EXHAUSTIVE on purpose — no `_` arm. A `_ => false` silently +/// classifies every future variant as a leaf, which is how that omission +/// survived; with the wildcard gone, a new nesting variant does not compile until +/// someone decides which side of this match it belongs on. The same discipline +/// applies to the two enums this traversal descends into, +/// [`filter_prop_contains`] and [`player_filter_contains`]. +/// +/// SCOPE, stated rather than left implicit: this traverses every nested +/// `TargetFilter`. It deliberately does NOT descend into the `QuantityExpr` +/// magnitudes some `FilterProp`s carry (`Cmc { value }`, `Counters { count }`, +/// `PtComparison { value }`). A filter reached through a quantity is a +/// *population being counted*, not a quality this filter mentions, and it has its +/// own authority with its own semantics — +/// `effects::quantity_ref_counts_population_matching`, which the anaphor gates +/// call alongside this function rather than through it. +pub(crate) fn filter_contains(filter: &TargetFilter, leaf: &dyn Fn(&TargetFilter) -> bool) -> bool { + if leaf(filter) { + return true; + } + let recurse = |inner: &TargetFilter| filter_contains(inner, leaf); + match filter { + TargetFilter::And { filters } | TargetFilter::Or { filters } => filters.iter().any(recurse), + TargetFilter::Not { filter } => recurse(filter), + TargetFilter::TrackedSetFiltered { filter, .. } => recurse(filter), + // CR 609.7a: the source a "source of your choice" effect chose. CR 609.7b: + // the optional inner filter is the "red source"-style quality the shield + // rechecks, so it is a real nested filter. The `None` case ("a source of + // your choice", unqualified) is a leaf. + TargetFilter::ChosenDamageSource { filter } => filter.as_deref().is_some_and(recurse), + // `Typed` is NOT a leaf: six of its `FilterProp`s box a `TargetFilter` + // (`CanEnchant`, `DifferentNameFrom`, `DistinctFrom`, `SharesQuality`, + // `Targets`, `TargetsOnly`), `Not`/`AnyOf` recurse through more props, and + // `ControllerMatches` crosses into `PlayerFilter`, which boxes filters of + // its own. + TargetFilter::Typed(typed) => typed + .properties + .iter() + .any(|prop| filter_prop_contains(prop, leaf)), + // Leaves: no nested `TargetFilter` to descend into. + TargetFilter::None + | TargetFilter::Any + | TargetFilter::Player + | TargetFilter::Controller + | TargetFilter::ControllerAndControlledPermanents { .. } + | TargetFilter::Opponent + | TargetFilter::SelfRef + | TargetFilter::GrantingObject + | TargetFilter::SourceOrPaired + | TargetFilter::StackAbility { .. } + | TargetFilter::StackSpell + | TargetFilter::SpecificObject { .. } + | TargetFilter::SpecificPlayer { .. } + | TargetFilter::PlayerWhoChoseLabel { .. } + | TargetFilter::Neighbor { .. } + | TargetFilter::ScopedPlayer + | TargetFilter::AttachedTo + | TargetFilter::LastCreated + | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged + | TargetFilter::CostPaidObject + | TargetFilter::ChosenCard + | TargetFilter::TrackedSet { .. } + | TargetFilter::ExiledBySource + | TargetFilter::ExiledCardByIndex { .. } + | TargetFilter::TriggeringSpellController + | TargetFilter::TriggeringSpellOwner + | TargetFilter::TriggeringPlayer + | TargetFilter::TriggeringSource + | TargetFilter::EventTarget + | TargetFilter::TriggeringSourceController + | TargetFilter::ParentTarget + | TargetFilter::ParentTargetSlot { .. } + | TargetFilter::ParentTargetController + | TargetFilter::ParentTargetOwner + | TargetFilter::SourceChosenPlayer + | TargetFilter::OriginalController + | TargetFilter::OriginalSource + | TargetFilter::PostReplacementSourceController + | TargetFilter::PostReplacementDamageSource + | TargetFilter::PostReplacementDamageTarget + | TargetFilter::PostReplacementDamageTargetOwner + | TargetFilter::DefendingPlayer + | TargetFilter::HasChosenName + | TargetFilter::Named { .. } + | TargetFilter::Owner + | TargetFilter::AllPlayers => false, + } +} + +/// [`filter_contains`] across the `TargetFilter`s a single `FilterProp` nests. +/// +/// Exhaustive for the same reason `filter_contains` is: a `_ => false` here would +/// silently reclassify every future prop as anaphor-free, which is the defect +/// class this pair exists to make uncompilable. +pub(crate) fn filter_prop_contains( + prop: &FilterProp, + leaf: &dyn Fn(&TargetFilter) -> bool, +) -> bool { + let recurse = |inner: &TargetFilter| filter_contains(inner, leaf); + match prop { + // CR 303.4 + CR 702.5: the referenced host an Aura "could enchant". + FilterProp::CanEnchant { target } => recurse(target), + FilterProp::DifferentNameFrom { filter } => recurse(filter), + // CR 109.1 + CR 120.3: the object-identity reference. + FilterProp::DistinctFrom { reference } => recurse(reference), + FilterProp::SharesQuality { reference, .. } => reference.as_deref().is_some_and(recurse), + // CR 115.9b/9c: the stack entry's target-side filters. + FilterProp::Targets { filter } | FilterProp::TargetsOnly { filter } => recurse(filter), + // CR 608.2c: prop-level combinators. + FilterProp::Not { prop } => filter_prop_contains(prop, leaf), + FilterProp::AnyOf { props } => props.iter().any(|p| filter_prop_contains(p, leaf)), + // CR 109.4: the object-axis crossing into the player axis. + FilterProp::ControllerMatches { player } => player_filter_contains(player, leaf), + // Leaves: no nested `TargetFilter`. (Props carrying only a `QuantityExpr` + // magnitude are leaves HERE by the scope rule documented on + // `filter_contains` — the quantity authority classifies those.) + FilterProp::Token + | FilterProp::NonToken + | FilterProp::RepresentedByCard + | FilterProp::ControllerChoseLabel { .. } + | FilterProp::WasPlayed + | FilterProp::Attacking { .. } + | FilterProp::Blocking + | FilterProp::BlockingSource + | FilterProp::CombatRelation { .. } + | FilterProp::Unblocked + | FilterProp::AttackingAlone + | FilterProp::BlockingAlone + | FilterProp::Tapped + | FilterProp::Untapped + | FilterProp::IsSaddled + | FilterProp::SaddledSource + | FilterProp::ConvokedSource + | FilterProp::ProtectorMatches { .. } + | FilterProp::HasHasteOrControlledSinceTurnBegan + | FilterProp::WithKeyword { .. } + | FilterProp::HasKeywordKind { .. } + | FilterProp::WithoutKeyword { .. } + | FilterProp::WithoutKeywordKind { .. } + | FilterProp::Counters { .. } + | FilterProp::Cmc { .. } + | FilterProp::ManaValueParity { .. } + | FilterProp::ManaCostIn { .. } + | FilterProp::InZone { .. } + | FilterProp::Owned { .. } + | FilterProp::Foretold + | FilterProp::HasAdventure + | FilterProp::EnchantedBy + | FilterProp::EquippedBy + | FilterProp::AttachedToSource + | FilterProp::AttachedToRecipient + | FilterProp::HasAttachment { .. } + | FilterProp::HasAnyAttachmentOf { .. } + | FilterProp::Another + | FilterProp::Unpaired + | FilterProp::OtherThanTriggerObject + | FilterProp::HasColor { .. } + | FilterProp::PtComparison { .. } + | FilterProp::PowerGTSource + | FilterProp::ColorCount { .. } + | FilterProp::ManaSymbolCount { .. } + | FilterProp::HasSupertype { .. } + | FilterProp::IsChosenCreatureType + | FilterProp::MostPrevalentCreatureTypeIn { .. } + | FilterProp::IsChosenColor + | FilterProp::IsChosenCardType + | FilterProp::MatchesLastChosenCardPredicate + | FilterProp::HasSingleTarget + | FilterProp::Modal + | FilterProp::NotColor { .. } + | FilterProp::NotSupertype { .. } + | FilterProp::Suspected + | FilterProp::Renowned + | FilterProp::Goaded + | FilterProp::ToughnessGTPower + | FilterProp::PowerExceedsBase + | FilterProp::InTrackedSet { .. } + | FilterProp::Modified + | FilterProp::Historic + | FilterProp::NotHistoric + | FilterProp::InAnyZone { .. } + | FilterProp::WasDealtDamageThisTurn + | FilterProp::DealtDamageThisTurn + | FilterProp::EnteredThisTurn + | FilterProp::ControlledContinuouslySinceTurnBegan + | FilterProp::ZoneChangedThisTurn { .. } + | FilterProp::AttackedThisTurn { .. } + | FilterProp::BlockedThisTurn + | FilterProp::AttackedOrBlockedThisTurn + | FilterProp::CountersPutOnThisTurn { .. } + | FilterProp::FaceDown + | FilterProp::Transformed + | FilterProp::CouldBeTargetedByTriggeringSpell + | FilterProp::HasXInManaCost + | FilterProp::HasXInActivationCost + | FilterProp::WasKicked + | FilterProp::HasManaAbility + | FilterProp::HasNoAbilities + | FilterProp::Named { .. } + | FilterProp::SameName + | FilterProp::SameNameAsParentTarget + | FilterProp::SameNameAsExiledBySource + | FilterProp::NameMatchesAnyPermanent { .. } + | FilterProp::IsCommander + | FilterProp::SharesCreatureTypeWithCommander + | FilterProp::Other { .. } => false, + } +} + +/// [`filter_contains`] across the `TargetFilter`s a single `PlayerFilter` nests. +/// Player-axis third of the same exhaustive traversal; see [`filter_contains`]. +pub(crate) fn player_filter_contains( + filter: &PlayerFilter, + leaf: &dyn Fn(&TargetFilter) -> bool, +) -> bool { + let recurse = |inner: &TargetFilter| filter_contains(inner, leaf); + match filter { + // CR 120.3: the "dealt damage by a " quality. + PlayerFilter::OpponentDealtDamage { source, .. } => source.as_deref().is_some_and(recurse), + PlayerFilter::ControlsCount { filter, .. } => recurse(filter), + PlayerFilter::TrackedSetPossessor { filter, .. } => recurse(filter), + // Leaves: no nested `TargetFilter`. `PlayerAttribute`'s `QuantityRef` / + // `QuantityExpr` are quantities, which the scope rule on + // `filter_contains` assigns to the quantity authority. + PlayerFilter::Controller + | PlayerFilter::Opponent + | PlayerFilter::DefendingPlayer + | PlayerFilter::OpponentLostLife + | PlayerFilter::OpponentGainedLife + | PlayerFilter::HasLostTheGame + | PlayerFilter::OpponentAttacked { .. } + | PlayerFilter::OpponentAttackingEnchantedPlayer + | PlayerFilter::All + | PlayerFilter::AllExcept { .. } + | PlayerFilter::HighestSpeed + | PlayerFilter::ZoneChangedThisWay + | PlayerFilter::PerformedActionThisWay { .. } + | PlayerFilter::OwnersOfCardsExiledBySource + | PlayerFilter::TriggeringPlayer + | PlayerFilter::OpponentOtherThanTriggering + | PlayerFilter::OpponentOfTriggeringPlayer + | PlayerFilter::OpponentOfTriggeringPlayerNotAttacked + | PlayerFilter::VotedFor { .. } + | PlayerFilter::ParentObjectTargetController + | PlayerFilter::PlayerAttribute { .. } + | PlayerFilter::ChosenPlayer { .. } + | PlayerFilter::ParentObjectTargetOwner => false, + } +} + /// Whether `filter` references the resolution-local `last_zone_changed_ids` /// ledger population (bare or nested inside compound filters). pub(crate) fn filter_contains_last_zone_changed(filter: &TargetFilter) -> bool { - match filter { - TargetFilter::LastZoneChanged => true, - TargetFilter::And { filters } | TargetFilter::Or { filters } => { - filters.iter().any(filter_contains_last_zone_changed) - } - TargetFilter::Not { filter } => filter_contains_last_zone_changed(filter), - TargetFilter::TrackedSetFiltered { filter, .. } => { - filter_contains_last_zone_changed(filter) - } - _ => false, - } + filter_contains(filter, &|inner| { + matches!(inner, TargetFilter::LastZoneChanged) + }) +} + +/// Whether `filter` references the resolution-local `last_created_token_ids` +/// ledger population — "the token created this way" / "it" (bare or nested +/// inside compound filters). +/// +/// Structural twin of [`filter_contains_last_zone_changed`]: same anaphor class, +/// same recursion set, different published ledger. Kept beside it so the two +/// resolution-local anaphors stay discoverable as one pair. +pub(crate) fn filter_contains_last_created(filter: &TargetFilter) -> bool { + filter_contains(filter, &|inner| matches!(inner, TargetFilter::LastCreated)) } /// Check if an object matches a typed TargetFilter against the given context. @@ -2129,7 +2392,7 @@ pub fn matches_target_filter_on_battlefield_entry( let Some(mut obj) = state .liminal_entries .get(object_id) - .map(|entry| entry.object.clone()) + .map(|entry| entry.object.projected().clone()) .or_else(|| state.objects.get(object_id).cloned()) else { return false; @@ -2157,7 +2420,7 @@ pub fn matches_target_filter_on_battlefield_entry( } else if let Some(entry) = state.liminal_entries.get(object_id) { filter_inner_for_object( state, - &entry.object, + entry.object.projected(), *object_id, filter, ctx.source_id, @@ -2176,7 +2439,7 @@ pub fn matches_target_filter_on_battlefield_entry( state.liminal_entries.get(entry_ref).is_some_and(|entry| { filter_inner_for_object( state, - &entry.object, + entry.object.projected(), *entry_ref, filter, ctx.source_id, @@ -10468,6 +10731,125 @@ mod tests { assert!(matches_target_filter(&state, veteran, &filter, attacker)); } + /// `filter_contains` must reach every `TargetFilter` nested anywhere inside a + /// filter, or a nested anaphor reads as absent and the CR 608.2c deferral + /// gate it feeds lets a prompt-suspended sub-ability evaluate against a stale + /// ledger. `ChosenDamageSource`'s optional inner filter was exactly that gap: + /// invisible to both `filter_contains_*` predicates, so a `LastCreated` one + /// level inside it read as absent. + /// + /// The recursion set stands on the shape of `TargetFilter` alone. An earlier + /// revision of this comment justified it as "the same five + /// `normalize_contextual_filter` recurses through"; that premise was simply + /// false — that function (CR 608.2c parent-target exclusion) recurses through + /// `Not`, `Or` and `And` only and ends in a `_ => filter.clone()` wildcard, so + /// it reaches neither `TrackedSetFiltered` nor `ChosenDamageSource`. The two + /// functions answer different questions and their sets are not required to + /// agree. + #[test] + fn filter_contains_recurses_through_a_chosen_damage_sources_inner_filter() { + let nested = |inner: TargetFilter| TargetFilter::ChosenDamageSource { + filter: Some(Box::new(inner)), + }; + + assert!( + filter_contains_last_created(&nested(TargetFilter::LastCreated)), + "a LastCreated nested inside ChosenDamageSource must be seen" + ); + assert!( + filter_contains_last_zone_changed(&nested(TargetFilter::LastZoneChanged)), + "the LastZoneChanged twin has the same nesting set" + ); + // Two levels down, through an intervening compound. + assert!(filter_contains_last_created(&nested(TargetFilter::And { + filters: vec![ + TargetFilter::Typed(TypedFilter::creature()), + TargetFilter::Not { + filter: Box::new(TargetFilter::LastCreated), + }, + ], + }))); + // Negative: the same shape without the anaphor, and the bare `None` form + // (which is a leaf, not a missed recursion). + assert!(!filter_contains_last_created(&nested(TargetFilter::Typed( + TypedFilter::creature() + )))); + assert!(!filter_contains_last_created( + &TargetFilter::ChosenDamageSource { filter: None } + )); + } + + /// `TargetFilter::Typed` is not a leaf: six `FilterProp`s box a + /// `TargetFilter`, two more recurse through further props, and + /// `ControllerMatches` crosses into `PlayerFilter`, which boxes filters of its + /// own. Treating `Typed` as a leaf hid every one of those from the anaphor + /// predicates — the same gap `ChosenDamageSource` had, one level down. + /// + /// Each assertion here flips to `false` if its arm is removed from + /// `filter_prop_contains` / `player_filter_contains`. + #[test] + fn filter_contains_recurses_into_a_typed_filters_properties() { + let typed = + |props: Vec| TargetFilter::Typed(TypedFilter::creature().properties(props)); + let anaphor = || Box::new(TargetFilter::LastCreated); + + for props in [ + vec![FilterProp::CanEnchant { target: anaphor() }], + vec![FilterProp::DifferentNameFrom { filter: anaphor() }], + vec![FilterProp::DistinctFrom { + reference: anaphor(), + }], + vec![FilterProp::SharesQuality { + quality: SharedQuality::Color, + reference: Some(anaphor()), + relation: SharedQualityRelation::default(), + }], + vec![FilterProp::Targets { filter: anaphor() }], + vec![FilterProp::TargetsOnly { filter: anaphor() }], + // Prop-level combinators. + vec![FilterProp::Not { + prop: Box::new(FilterProp::Targets { filter: anaphor() }), + }], + vec![FilterProp::AnyOf { + props: vec![FilterProp::Token, FilterProp::Targets { filter: anaphor() }], + }], + // Object axis -> player axis -> back to a filter. + vec![FilterProp::ControllerMatches { + player: Box::new(PlayerFilter::ControlsCount { + relation: crate::types::ability::PlayerRelation::Controller, + filter: TargetFilter::LastCreated, + comparator: crate::types::ability::Comparator::GE, + count: Box::new(QuantityExpr::Fixed { value: 1 }), + }), + }], + ] { + assert!( + filter_contains_last_created(&typed(props.clone())), + "a LastCreated nested in {props:?} must be seen" + ); + } + + // Negative: the same shapes without the anaphor, and a prop-free typed + // filter, so the positives above are not passing vacuously. + assert!(!filter_contains_last_created(&typed(vec![ + FilterProp::Targets { + filter: Box::new(TargetFilter::Any), + }, + FilterProp::Token, + ]))); + assert!(!filter_contains_last_created(&typed(Vec::new()))); + // The `LastZoneChanged` twin shares the traversal, so it must see the + // same nesting and not confuse the two ledgers. + assert!(filter_contains_last_zone_changed(&typed(vec![ + FilterProp::Targets { + filter: Box::new(TargetFilter::LastZoneChanged), + }, + ]))); + assert!(!filter_contains_last_zone_changed(&typed(vec![ + FilterProp::Targets { filter: anaphor() }, + ]))); + } + #[test] fn normalize_contextual_filter_without_parent_targets_rewrites_not_parent_to_any() { let filter = TargetFilter::Not { diff --git a/crates/engine/src/game/meld.rs b/crates/engine/src/game/meld.rs index 9135534fc0..8e30a3c250 100644 --- a/crates/engine/src/game/meld.rs +++ b/crates/engine/src/game/meld.rs @@ -248,7 +248,10 @@ pub(crate) fn finish_meld_entry( state.liminal_entries.insert( context.source_id, LiminalEntry { - object: projected, + // CR 701.42a: the meld result is card-backed — its two component + // cards are real objects in exile — so it is not a CR 111.1 token + // projection and never enters through `ProposedEvent::TokenEntry`. + object: crate::types::game_state::LiminalEntrant::Card(projected), name: context.result.clone(), source_id: context.source_id, controller: context.controller, diff --git a/crates/engine/src/game/meld_tests.rs b/crates/engine/src/game/meld_tests.rs index 5f48524ab5..d33ac14d28 100644 --- a/crates/engine/src/game/meld_tests.rs +++ b/crates/engine/src/game/meld_tests.rs @@ -1204,7 +1204,8 @@ fn meld_replacement_pause_keeps_result_projection_detached() { assert_eq!(state.objects[&source].name, "Gisela, the Broken Blade"); assert_eq!(state.objects[&partner].name, "Bruna, the Fading Light"); assert_eq!( - state.liminal_entries[&source].object.name, RESULT_NAME, + state.liminal_entries[&source].object.projected().name, + RESULT_NAME, "replacement matching sees the detached result projection" ); @@ -2216,3 +2217,162 @@ fn mishra_final_controller_owns_attack_destination_choice() { assert!(record.combat_status.attacking); assert_eq!(record.combat_status.defending_player, Some(PlayerId(3))); } + +// --------------------------------------------------------------------------- +// CR 303.4f + CR 303.4g for a CARD-BACKED liminal Aura entrant. +// +// A meld result is projected into `state.liminal_entries` and only becomes the +// live object once its approved battlefield delivery commits, so at the moment +// the entry consult runs, `state.objects[source_id]` is still the exiled +// front-face component card. An Aura meld result therefore reached the +// battlefield without ever being asked what it enchants: no CR 303.4f host +// choice, and — the part CR 303.4g forbids outright — no way to deny an entry +// that has no legal host. These drive `perform_meld` end to end. +// --------------------------------------------------------------------------- + +const AURA_RESULT_NAME: &str = "Melded Shackles"; + +/// Seed an AURA meld result face (`Enchant creature`) plus its pair record. +fn seed_aura_result_face(state: &mut crate::types::game_state::GameState) { + let mut face = CardFace { + name: AURA_RESULT_NAME.to_string(), + ..CardFace::default() + }; + face.card_type.core_types.push(CoreType::Enchantment); + face.card_type.subtypes.push("Aura".to_string()); + // CR 702.5a: the enchant ability is what defines a legal host. + face.keywords.push(crate::types::keywords::Keyword::Enchant( + crate::types::ability::TargetFilter::Typed(crate::types::ability::TypedFilter::creature()), + )); + Arc::make_mut(&mut state.card_face_registry).insert(AURA_RESULT_NAME.to_lowercase(), face); + seed_meld_pair( + state, + "Gisela, the Broken Blade", + "Bruna, the Fading Light", + AURA_RESULT_NAME, + ); +} + +/// A meld ability whose result is the Aura face above. +fn aura_meld_ability(source: ObjectId, controller: PlayerId) -> ResolvedAbility { + ResolvedAbility::new( + Effect::Meld { + source: "Gisela, the Broken Blade".to_string(), + partner: "Bruna, the Fading Light".to_string(), + result: AURA_RESULT_NAME.to_string(), + source_filter: crate::types::ability::TargetFilter::SelfRef, + partner_filter: crate::types::ability::TargetFilter::Any, + entry: crate::types::ability::PermanentEntryMode::Normal, + }, + Vec::new(), + source, + controller, + ) +} + +/// Both halves plus the Aura result face. `extra_host` adds an unrelated +/// creature that survives the meld exile and is therefore a legal CR 303.4f +/// host. +fn aura_meld_setup(extra_host: bool) -> (crate::types::game_state::GameState, ObjectId, ObjectId) { + let mut sc = GameScenario::new(); + let source = sc.add_creature(P0, "Gisela, the Broken Blade", 4, 3).id(); + let partner = sc.add_creature(P0, "Bruna, the Fading Light", 5, 4).id(); + if extra_host { + sc.add_creature(P1, "Grizzly Bears", 2, 2); + } + seed_aura_result_face(&mut sc.state); + (sc.state, source, partner) +} + +/// CR 303.4g: "If an Aura is entering the battlefield and there is no legal +/// object or player for it to enchant, the Aura remains in its current zone." +/// +/// The meld halves are the only creatures in the game and the exile instruction +/// has already moved both of them, so when the melded Aura's entry is consulted +/// there is no legal host anywhere. The entry must be denied BEFORE it happens — +/// not taken and then swept by the CR 704.5m unattached-Aura state-based action, +/// which is an entry the rules say never occurred. +#[test] +fn unhosted_aura_meld_result_does_not_enter_and_both_halves_remain_in_exile() { + let (mut state, source, partner) = aura_meld_setup(false); + let mut events = Vec::new(); + + perform_meld(&mut state, &aura_meld_ability(source, P0), &mut events).unwrap(); + + // CR 303.4g: the entry did not happen. This is the assertion that flips when + // the fix is reverted — pre-fix the consult read the exiled Gisela card (not + // an Aura), skipped CR 303.4f/g entirely, and the melded Aura entered. + let survivor = state.objects.get(&source).expect("source card persists"); + assert_eq!( + survivor.zone, + Zone::Exile, + "CR 303.4g: with no legal host the Aura remains in its current zone (exile)" + ); + assert!( + !state.battlefield.iter().any(|&id| id == source), + "CR 303.4g: the melded Aura must not be on the battlefield" + ); + // CR 701.42c: a denied entry leaves BOTH physical cards in exile. + let partner_obj = state.objects.get(&partner).expect("partner card persists"); + assert_eq!(partner_obj.zone, Zone::Exile); + assert!(state.exile.iter().any(|&id| id == source)); + assert!(state.exile.iter().any(|&id| id == partner)); + // The denied entry is not observable: no battlefield `ZoneChanged` for it. + assert!( + !events.iter().any(|event| matches!( + event, + GameEvent::ZoneChanged { + object_id, + to: Zone::Battlefield, + .. + } if *object_id == source + )), + "CR 303.4g: nothing may observe an entry the rule denies" + ); + // Not absorbed either — the meld never completed. + assert!( + survivor.merged_components.is_empty(), + "a denied entry must not leave a half-built melded permanent" + ); +} + +/// CR 303.4f positive reach-guard for the test above. +/// +/// Identical fixture except that one creature survives the meld exile. The same +/// consult now finds exactly one legal host and auto-attaches to it, which +/// proves the negative test above reaches the CR 303.4f/g arm rather than +/// short-circuiting somewhere upstream (a non-Aura entrant, an absent enchant +/// ability, or a projection the consult never looked at). +#[test] +fn hosted_aura_meld_result_enters_attached_to_its_only_legal_host() { + let (mut state, source, partner) = aura_meld_setup(true); + let host = state + .battlefield + .iter() + .copied() + .find(|id| state.objects[id].name == "Grizzly Bears") + .expect("the extra host is on the battlefield"); + let mut events = Vec::new(); + + perform_meld(&mut state, &aura_meld_ability(source, P0), &mut events).unwrap(); + + let survivor = state.objects.get(&source).expect("survivor exists"); + assert_eq!( + survivor.zone, + Zone::Battlefield, + "with a legal host the melded Aura does enter" + ); + assert_eq!(survivor.name, AURA_RESULT_NAME); + // CR 303.4f: "that player chooses what it will enchant as the Aura enters" — + // with exactly one legal host there is no prompt, it is simply attached. + assert_eq!( + survivor.attached_to, + Some(crate::game::game_object::AttachTarget::Object(host)), + "CR 303.4f: the entering Aura is attached to its only legal host" + ); + assert_eq!( + survivor.merged_components, + vec![source, partner], + "the meld itself still completes normally" + ); +} diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index 8c7de5ec8b..3b14c8b5e5 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -215,7 +215,7 @@ fn commander_hand_or_library_return_object( state .liminal_entries .get(&object_id) - .map(|entry| &entry.object) + .map(|entry| entry.object.projected()) .or_else(|| state.objects.get(&object_id)) } @@ -6251,7 +6251,7 @@ fn object_replacement_candidate_applies( let liminal_obj = liminal_entry_ref(event) .filter(|entry_ref| *entry_ref == rid.source) .and_then(|entry_ref| state.liminal_entries.get(&entry_ref)) - .map(|entry| &entry.object); + .map(|entry| entry.object.projected()); let Some(obj) = liminal_obj.or_else(|| state.objects.get(&rid.source)) else { return false; }; @@ -6794,6 +6794,7 @@ fn legacy_object_replacement_candidates( candidates.extend( entry .object + .projected() .replacement_definitions .iter_all() .enumerate() @@ -6843,6 +6844,7 @@ fn indexed_object_replacement_candidates_from_index( candidates.extend( entry .object + .projected() .replacement_definitions .iter_all() .enumerate() @@ -8138,7 +8140,7 @@ fn apply_single_replacement( state .liminal_entries .get(&rid.source) - .map(|entry| &entry.object) + .map(|entry| entry.object.projected()) .or_else(|| state.objects.get(&rid.source)) .and_then(|obj| obj.replacement_definitions.get(rid.index)) }; @@ -9172,7 +9174,7 @@ fn replacement_definition_for_id( state .liminal_entries .get(&rid.source) - .map(|entry| &entry.object) + .map(|entry| entry.object.projected()) .or_else(|| state.objects.get(&rid.source)) .and_then(|obj| obj.replacement_definitions.get(rid.index)) // CR 121.2: an instruction to draw multiple cards is performed as that many @@ -10574,7 +10576,9 @@ mod tests { state.liminal_entries.insert( entry_ref, LiminalEntry { - object: liminal, + object: crate::types::game_state::LiminalEntrant::Token( + crate::types::game_state::TokenProjection::materialize(liminal), + ), name: "Liminal Copy".to_string(), source_id: ObjectId(999), controller: PlayerId(0), diff --git a/crates/engine/src/game/sba.rs b/crates/engine/src/game/sba.rs index 5033e4b8c9..c17895c3da 100644 --- a/crates/engine/src/game/sba.rs +++ b/crates/engine/src/game/sba.rs @@ -44,8 +44,26 @@ fn live_battlefield_object_mut<'a>( }) } -fn pending_replacement_pauses_sba(state: &GameState) -> bool { +/// CR 704.4: state-based actions pay no attention to what happens during the +/// resolution of a spell or ability. An entry that is mid-resolution — parked on +/// a CR 616.1 replacement-ordering choice, or on a CR 303.4f Aura-host choice — +/// is not yet the thing that entered, so the object-destroying SBAs (notably the +/// CR 704.5m unattached-Aura sweep) must not see it. +/// +/// NOT `effects::waits_for_resolution_choice`, though the two overlap on +/// `ReturnAsAuraTarget`. That predicate answers a different question — "must a +/// chained sub-ability be stashed as a CR 608.2c continuation across this +/// window?" — and answers it for some sixty prompt variants (Scry, Discard, +/// Search, …). Reusing it here would suppress the CR 704.5 SBAs across every one +/// of them, a behavior change with nothing to do with an in-flight ENTRY. It +/// also cannot express the other half of this gate: `pending_replacement`, which +/// is a parked event rather than a `WaitingFor` variant at all. +fn mid_resolution_entry_pauses_sba(state: &GameState) -> bool { state.pending_replacement.is_some() + || matches!( + state.waiting_for, + crate::types::game_state::WaitingFor::ReturnAsAuraTarget { .. } + ) } /// CR 704.3: Run state-based actions in a fixpoint loop until no more actions are performed, @@ -136,7 +154,7 @@ pub fn check_state_based_actions(state: &mut GameState, events: &mut Vec= its final chapter number, // and no chapter ability has triggered but not yet left the stack, sacrifice it. check_saga_sacrifice(state, events, &mut any_performed, &battlefield_snapshot); - if pending_replacement_pauses_sba(state) { + if mid_resolution_entry_pauses_sba(state) { return; } @@ -256,7 +274,7 @@ pub fn check_state_based_actions(state: &mut GameState, events: &mut Vec, +) -> bool { + player_protection_from_object( + state, + player_id, + source.and_then(|id| state.objects.get(&id)), + ) +} + +/// CR 702.16 + CR 614.12: [`player_protection_from`] against an explicitly +/// supplied source object. +/// +/// Every source-side read in this authority is a characteristic read (card type +/// for CR 702.16 + CR 205.2, controller for CR 702.16k), so a source that is not +/// yet the object stored under its id — a meld or liminal ENTRANT, whose id still +/// holds the pre-entry component — must be read from its projection instead of +/// from `state.objects`. `None` means "no concrete source object", for which only +/// the CR 702.16j `Everything` short-circuit can fire. +pub fn player_protection_from_object( + state: &GameState, + player_id: PlayerId, + source: Option<&crate::game::game_object::GameObject>, ) -> bool { use crate::game::keywords::source_matches_card_type; use crate::types::ability::ControllerRef; @@ -1587,7 +1608,7 @@ pub fn player_protection_from( if player_has_protection_from_everything(state, player_id) { return true; } - let Some(source_id) = source else { + let Some(source_obj) = source else { return false; }; let context = StaticCheckContext { @@ -1624,32 +1645,23 @@ pub fn player_protection_from( ProtectionTarget::Everything => false, // CR 702.16 + CR 205.2: protection from the card type // chosen as the granting permanent (e.g. Serra's Emissary) entered. - ProtectionTarget::ChosenCardType => { - state.objects.get(&source_id).is_some_and(|src| { - src_obj - .chosen_card_type() - .and_then(|ct| ct.protection_quality_str()) - .is_some_and(|quality| source_matches_card_type(src, quality)) - }) - } + ProtectionTarget::ChosenCardType => src_obj + .chosen_card_type() + .and_then(|ct| ct.protection_quality_str()) + .is_some_and(|quality| source_matches_card_type(source_obj, quality)), // CR 702.16k: "Protection from [a player]" at the player level — the // protected player has protection from each object the specified // player(s) control. "Each of your opponents" (CR 702.16i) → the // `Opponent` scope: any source NOT controlled by the protected // player is an opponent's object in 1v1 and free-for-all. Mirrors the // object-level arm in `game/keywords.rs::source_matches_protection_target`. - ProtectionTarget::FromPlayer(scope) => { - state - .objects - .get(&source_id) - .is_some_and(|src| match scope { - ControllerRef::Opponent => src.controller != player_id, - ControllerRef::You => src.controller == player_id, - // Target/chosen player refs have no static context here — - // fail closed (the parser never emits them for protection). - _ => false, - }) - } + ProtectionTarget::FromPlayer(scope) => match scope { + ControllerRef::Opponent => source_obj.controller != player_id, + ControllerRef::You => source_obj.controller == player_id, + // Target/chosen player refs have no static context here — + // fail closed (the parser never emits them for protection). + _ => false, + }, // Truly inert at the player level — no card grants these qualities to // a player; object-level grants of these qualities flow through the // `AddKeyword(Protection)` continuous path, not `PlayerProtection`. diff --git a/crates/engine/src/game/visibility.rs b/crates/engine/src/game/visibility.rs index f055b488e1..b2220a4d64 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -2302,12 +2302,16 @@ mod tests { state.liminal_entries.insert( entry_ref, crate::types::game_state::LiminalEntry { - object: crate::game::game_object::GameObject::new( - entry_ref, - CardId(99), - PlayerId(0), - "Liminal Token".to_string(), - Zone::Battlefield, + object: crate::types::game_state::LiminalEntrant::Token( + crate::types::game_state::TokenProjection::materialize( + crate::game::game_object::GameObject::new( + entry_ref, + CardId(99), + PlayerId(0), + "Liminal Token".to_string(), + Zone::Battlefield, + ), + ), ), name: "Liminal Token".to_string(), source_id: ObjectId(1), diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index 6f0ed11c78..b11d736e43 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -16,11 +16,11 @@ use crate::types::ability::{ use crate::types::counter::CounterType; use crate::types::events::GameEvent; use crate::types::game_state::{ - BatchCompletion, ExileLinkKind, GameState, LiminalEntryKind, LogicalZoneChangeGroup, - MergedCardComponentRoute, PendingBatchDeliveries, PendingBatchZoneChangeCause, - PendingBatchZoneMoveRequest, PendingCounterPostAction, PendingLiminalEntryResume, - PendingZoneChangeDelivery, PostReplacementDrainOwner, WaitingFor, ZoneDeliveryExileTracking, - ZoneMoveCompletion, + BatchCompletion, EnteringAuraAuthority, ExileLinkKind, GameState, LiminalEntryKind, + LogicalZoneChangeGroup, MergedCardComponentRoute, PendingBatchDeliveries, + PendingBatchZoneChangeCause, PendingBatchZoneMoveRequest, PendingCounterPostAction, + PendingLiminalEntryResume, PendingZoneChangeDelivery, PostReplacementDrainOwner, WaitingFor, + ZoneDeliveryExileTracking, ZoneMoveCompletion, }; use std::collections::HashSet; @@ -31,7 +31,7 @@ use crate::types::proposed_event::{AppliedReplacementKey, ProposedEvent}; use crate::types::zones::{EtbTapState, Zone}; use crate::game::effects::change_zone::shuffle_library; -use crate::game::game_object::AttachTarget; +use crate::game::game_object::{AttachTarget, GameObject}; use crate::types::ability::FaceDownProfile; /// Why this zone change is happening. Determines pipeline engagement (PLAN §3) @@ -1816,8 +1816,40 @@ pub(crate) fn apply_zone_delivery_tail( ZoneDeliveryResult::Done } +/// CR 614.12 + CR 303.4f: the characteristics the CR 303.4f/g consult must read +/// for `object_id` — "the characteristics of the permanent as it would exist on +/// the battlefield". +/// +/// A liminal entry IS that projection, and while it is pending the object still +/// stored under the same id is the entrant's PRE-entry self: for a meld +/// (`LiminalEntryKind::Meld`) that is the exiled front-face component card, which +/// is not an Aura and carries none of the result face's `Enchant` abilities. +/// Reading `state.objects` alone therefore made the consult blind to every +/// card-backed liminal Aura entrant. Same dual lookup, in the same precedence, +/// that the intrinsic enter-with-counters seeding in +/// `consult_and_deliver_zone_change` and `copy_effect_for_source` already use. +fn entering_object_projection(state: &GameState, object_id: ObjectId) -> Option<&GameObject> { + state + .liminal_entries + .get(&object_id) + .map(|entry| entry.object.projected()) + .or_else(|| state.objects.get(&object_id)) +} + fn aura_enchant_filter(state: &GameState, object_id: ObjectId) -> Option { - let obj = state.objects.get(&object_id)?; + aura_enchant_filter_of(entering_object_projection(state, object_id)?) +} + +/// CR 303.4 + CR 702.5: the Enchant ability an ENTRANT will have on the +/// battlefield, read from an explicitly supplied projection. +/// +/// Split from [`aura_enchant_filter`] so a seam holding a projection that is not +/// (yet) the object stored under its id can consult it — CR 614.12's "the +/// characteristics of the permanent as it would exist on the battlefield". Two +/// seams need that: a liminal entrant, whose id still holds the pre-entry +/// component card, and a non-liminal copy token whose CR 707.9 exceptions are +/// applied by a later, unjournaled seam. +pub(crate) fn aura_enchant_filter_of(obj: &GameObject) -> Option { if !obj.card_types.subtypes.iter().any(|s| s == "Aura") { return None; } @@ -1844,9 +1876,19 @@ fn aura_enchant_filter(state: &GameState, object_id: ObjectId) -> Option, controller: PlayerId, enchant_filter: &TargetFilter, ) -> Vec { @@ -1877,7 +1919,15 @@ fn legal_aura_attachment_targets( // `matches_target_filter`, never the `find_legal_targets` enumerator, so // hexproof (CR 702.11) / shroud (CR 702.18) never remove a legal host. .filter(|id| crate::game::filter::matches_target_filter(state, *id, enchant_filter, &ctx)) - .filter(|id| crate::game::effects::attach::can_attach_to_object(state, aura_id, *id)) + // CR 701.3a + CR 702.16c: host-side prohibitions and protection, read + // against the CR 614.12 entrant projection so a protection or + // attachment-restriction match is computed from the characteristics the + // permanent will have on the battlefield. + .filter(|id| { + crate::game::effects::attach::can_attach_to_object_projected( + state, aura_id, entrant, *id, + ) + }) .map(TargetRef::Object) .collect(); @@ -1889,6 +1939,16 @@ fn legal_aura_attachment_targets( if !crate::game::players::player_exists_for_choice(state, player.id) { return None; } + // CR 303.4c + CR 702.16c: the player-host mirror of the object-host + // legality filter above. Without it an illegal player counts as a legal + // host, which suppresses the CR 303.4g denial: the Curse token copy is + // created, `attach_to_player` no-ops on the illegality, and CR 704.5m + // sweeps it — exactly the entered-then-died bug this seam exists to + // prevent, on the player axis. + if !crate::game::effects::attach::can_attach_to_player_projected(state, entrant, player.id) + { + return None; + } if crate::game::filter::player_matches_target_filter_in_state( state, enchant_filter, @@ -1904,16 +1964,67 @@ fn legal_aura_attachment_targets( targets } +/// CR 303.4g: the fate of an Aura that is entering the battlefield when "there +/// is no legal object or player for it to enchant". +/// +/// Three outcomes, because the rule states three — and NONE of them is "enter +/// unattached and let the CR 704.5m state-based action sweep it". The rule +/// denies the entry itself, so a seam that can still decide must decide here; +/// anything the game could observe of that entry is an event the rules say never +/// happened. +pub(crate) enum UnhostedAuraEntry { + /// CR 303.4g: "If the Aura is a token, it isn't created." + NotCreated, + /// CR 303.4g: "the Aura remains in its current zone" — the entry does not + /// happen and the card stays exactly where it was. + RemainInCurrentZone, + /// CR 303.4g: "…unless that zone is the stack. In that case, the Aura is put + /// into its owner's graveyard instead of entering the battlefield." + OwnersGraveyard, +} + +/// CR 303.4g: select the disposition from the two facts the rule keys on — the +/// entrant's CR 111.1 token-ness, and the zone it is entering from. +/// +/// Every entrant this authority answers for HAS a from-zone, because both of the +/// rule's non-token dispositions are phrased against one ("remains in its +/// current zone", "unless that zone is the stack"). That is the whole population +/// of the `ProposedEvent::ZoneChange` entry path. The other entry path, +/// `ProposedEvent::TokenEntry`, carries a `LiminalEntrant::Token` — a CR 111.1 +/// token, in no zone at all — for which the rule's token clause is the only +/// applicable disposition, so that seam never asks this question. +pub(crate) fn unhosted_aura_entry(entrant: &GameObject, from: Zone) -> UnhostedAuraEntry { + // CR 111.1: token-ness is the ONLY discriminator the rule's token clause + // names, and it outranks the origin — a token is not created regardless of + // which zone the effect was putting it onto the battlefield from. + if entrant.is_token { + return UnhostedAuraEntry::NotCreated; + } + match from { + Zone::Stack => UnhostedAuraEntry::OwnersGraveyard, + _ => UnhostedAuraEntry::RemainInCurrentZone, + } +} + /// Disposition of an object that has just become an Aura while already on the /// battlefield (the copy path — see [`resolve_entering_aura_attachment`]). +/// +/// `Attached` and `NoLegalHost` are deliberately distinct even though neither +/// raises a prompt: CR 303.4g gives the no-host case its OWN rule ("the Aura +/// remains in its current zone … If the Aura is a token, it isn't created"), +/// which a caller that can still decline to create the entrant must be able to +/// act on. Collapsing them loses exactly that information. pub(crate) enum EnteringAuraAttachment { /// The object is not an Aura needing attachment (not an Aura, an Aura that's /// also a creature per CR 303.4d, or already attached). NotApplicable, - /// Attachment resolved without a player choice — either auto-attached to the - /// sole legal host, or deliberately left unattached because there is no legal - /// host (CR 303.4g; the CR 704.5m unattached-Aura SBA will handle it). - Resolved, + /// CR 303.4f: attachment resolved without a player choice — the sole legal + /// host was auto-attached. + Attached, + /// CR 303.4g: there is no legal object or player for the Aura to enchant. + /// The Aura was left unattached; what that means is the caller's decision + /// (see the callers' own CR 303.4g/CR 704.5m rationale). + NoLegalHost, /// CR 303.4f: multiple legal hosts, so the controller must choose one. NeedsChoice { controller: PlayerId, @@ -1935,18 +2046,150 @@ pub(crate) enum EnteringAuraAttachment { /// /// CR 303.4f: because the Aura is entering by a means other than resolving as an /// Aura spell and the effect doesn't specify a host, its controller chooses what -/// it enchants. CR 303.4g: with no legal host the Aura would not enter at all; -/// the engine's post-entry equivalent is to leave it unattached so the -/// unattached-Aura SBA (CR 704.5m) moves it to the graveyard on the next check. +/// it enchants. CR 303.4g: with no legal host the Aura can't enter — this +/// function reports that as [`EnteringAuraAttachment::NoLegalHost`] and leaves +/// the object untouched, because only the CALLER knows whether the entrant can +/// still be withheld (a token that isn't created) or has already entered and is +/// therefore the CR 704.5m unattached-Aura SBA's problem. +/// +/// Composed from [`entering_aura_hosts`] (decide) and +/// [`apply_entering_aura_hosts`] (act), which a caller that must interpose an +/// irreversible step between the two — the liminal token path, whose CR 733 +/// birth journal append is append-only and must not be written for a token CR +/// 303.4g says isn't created — invokes separately. pub(crate) fn resolve_entering_aura_attachment( state: &mut GameState, object_id: ObjectId, ) -> EnteringAuraAttachment { - let Some(enchant_filter) = aura_enchant_filter(state, object_id) else { - return EnteringAuraAttachment::NotApplicable; + let hosts = entering_aura_hosts(state, object_id); + apply_entering_aura_hosts(state, object_id, hosts) +} + +/// The legal hosts an entering Aura may be attached to, decided but NOT applied. +/// +/// `Hosts::legal_targets` may be empty — that IS the CR 303.4g case, and it is +/// reported rather than acted on so a caller can answer CR 303.4g's "if the Aura +/// is a token, it isn't created" BEFORE taking any step it cannot take back. +pub(crate) enum EnteringAuraHosts { + /// Same disposition as [`EnteringAuraAttachment::NotApplicable`]. + NotApplicable, + Hosts { + controller: PlayerId, + legal_targets: Vec, + /// CR 614.12: the object whose characteristics `legal_targets` was + /// decided against, carried so the act half can judge CR 701.3a legality + /// against the SAME object rather than re-deriving it from the id. + entrant: EnteringAuraEntrant, + }, +} + +/// CR 614.12: which object an entering Aura's attachment legality is judged +/// against — the decide half's finding, carried to the act half. +/// +/// The two halves are separated by at least a function boundary and, on the +/// multi-host route, by a player-choice pause. Deriving the attachment side +/// twice is what let them disagree: CR 303.4f offers a host that is legal for +/// the Aura AS IT ENTERS, and CR 701.3b silently no-ops an attach at a host the +/// gate then judges illegal. Naming the authority instead of re-deriving it +/// makes that disagreement unrepresentable. +#[derive(Debug, Clone)] +pub(crate) enum EnteringAuraEntrant { + /// The object stored under the Aura's id already IS the entrant, so the act + /// half reads it LIVE. Deliberately not a snapshot: for these seams the + /// permanent is already on the battlefield with its final characteristics, + /// and a stale clone could only mask a legitimate mid-flight change. + Stored, + /// The stored object is not the entrant yet — the deciding seam supplied a + /// projection (a CR 707.9 copy exception applied by a later seam, or a + /// liminal entry whose id still holds the pre-entry component). The act half + /// must use it, or it will judge a different object than the chooser was + /// offered. + Projected(Box), +} + +impl EnteringAuraEntrant { + /// The borrowed view the CR 701.3a legality gate consumes. + fn authority(&self) -> crate::game::effects::attach::AttachmentAuthority<'_> { + match self { + Self::Stored => crate::game::effects::attach::AttachmentAuthority::Stored, + Self::Projected(entrant) => { + crate::game::effects::attach::AttachmentAuthority::Projected(entrant) + } + } + } +} + +/// Decide half of [`resolve_entering_aura_attachment`] — pure with respect to +/// the game state. +pub(crate) fn entering_aura_hosts(state: &GameState, object_id: ObjectId) -> EnteringAuraHosts { + // CR 614.12: a LIVE liminal entry means the id's stored object is not the + // entrant (a meld's exiled front face, a token whose body is still parked), + // so the projection has to travel to the act half as well — the same class + // of decide/act disagreement the copy-token seam hits. No production caller + // of this function reaches it with a live entry today (the liminal token + // seam removes its entry before consulting, and every + // `resolve_entering_aura_attachment` caller runs on a realized battlefield + // permanent), so this arm closes the class rather than fixing a live bug. + if let Some(entry) = state.liminal_entries.get(&object_id) { + let entrant = entry.object.projected().clone(); + return entering_aura_hosts_projected(state, object_id, &entrant); + } + let Some(entrant) = state.objects.get(&object_id) else { + return EnteringAuraHosts::NotApplicable; + }; + entering_aura_hosts_with( + state, + object_id, + entrant, + // Read live by the act half: for these seams the stored object IS the + // entrant, and this preserves their exact pre-existing behaviour. + EnteringAuraEntrant::Stored, + ) +} + +/// CR 614.12: [`entering_aura_hosts`] against an explicitly supplied projection +/// of the ENTRANT. +/// +/// The non-liminal copy-token seam owns a projection its stored object does not +/// match yet: on that path the CR 707.9b/9c "except …" exceptions are applied by +/// a later, unjournaled seam (`apply_token_modifications`), so the object under +/// this id still carries the UNMODIFIED copied body. An exception that adds or +/// removes `Creature` (CR 303.4d), adds or removes the `Aura` subtype, or changes +/// the entrant's colors (CR 702.16c protection) flips this verdict — and a wrong +/// verdict here is a token silently never created (CR 303.4g). The liminal seam +/// folds the same exceptions BEFORE its own consult, so passing the projection is +/// what makes the two seams agree on what the entrant is. +pub(crate) fn entering_aura_hosts_projected( + state: &GameState, + object_id: ObjectId, + entrant: &GameObject, +) -> EnteringAuraHosts { + entering_aura_hosts_with( + state, + object_id, + entrant, + // The whole point of the projected entry point: the act half must judge + // CR 701.3a legality against this object, not against the id's stored + // one, or CR 303.4f can offer a host CR 701.3b then refuses to attach to. + EnteringAuraEntrant::Projected(Box::new(entrant.clone())), + ) +} + +/// Shared body of [`entering_aura_hosts`] and [`entering_aura_hosts_projected`], +/// parameterized by which object the act half must judge legality against. +fn entering_aura_hosts_with( + state: &GameState, + object_id: ObjectId, + entrant: &GameObject, + authority: EnteringAuraEntrant, +) -> EnteringAuraHosts { + let Some(enchant_filter) = aura_enchant_filter_of(entrant) else { + return EnteringAuraHosts::NotApplicable; }; + // Existence and battlefield residency are read from the STORED object: they + // are facts about where the entrant is, which no projection may override. let Some(obj) = state.objects.get(&object_id) else { - return EnteringAuraAttachment::NotApplicable; + return EnteringAuraHosts::NotApplicable; }; // CR 303.4 + CR 704.5m: entry-time attachment only applies to an Aura that is // actually on the battlefield. Defensive guard — if an intermediate entry @@ -1955,31 +2198,916 @@ pub(crate) fn resolve_entering_aura_attachment( // attaching it or prompting for a host of a non-battlefield Aura would be // invalid state; do nothing and let it resolve wherever it now lives. if obj.zone != Zone::Battlefield { - return EnteringAuraAttachment::NotApplicable; + return EnteringAuraHosts::NotApplicable; } // Only resolve entry attachment for an as-yet-unattached Aura; a copy that // was already attached by some other effect must not be re-homed here. if obj.attached_to.is_some() { - return EnteringAuraAttachment::NotApplicable; + return EnteringAuraHosts::NotApplicable; } - let controller = obj.controller; - let legal_targets = - legal_aura_attachment_targets(state, object_id, controller, &enchant_filter); + // CR 303.4f: "that player" is the entrant's controller — read from the + // projection, which is where a controller-changing entry effect lands first. + let controller = entrant.controller; + EnteringAuraHosts::Hosts { + legal_targets: legal_aura_attachment_targets( + state, + object_id, + Some(entrant), + controller, + &enchant_filter, + ), + controller, + entrant: authority, + } +} + +/// Act half of [`resolve_entering_aura_attachment`]: attach the sole legal host +/// (CR 303.4f), or report the disposition the caller must handle. +/// +/// CR 303.4f + CR 701.3b: every attach below goes through the decide half's +/// [`EnteringAuraEntrant`]. The act half must not re-derive the attachment's +/// characteristics from `object_id` — on the non-liminal copy-token seam that id +/// still holds the pre-exception body, so a re-derived CR 701.3a gate can reject +/// a host CR 303.4f legally offered and (CR 701.3b) no-op the attach, leaving the +/// Aura for the CR 704.5m sweep. +pub(crate) fn apply_entering_aura_hosts( + state: &mut GameState, + object_id: ObjectId, + hosts: EnteringAuraHosts, +) -> EnteringAuraAttachment { + // Any authority parked by an earlier entering-Aura decision is spent or + // stale by the time another one is being ACTED on: the only way to reach + // this function with one parked is to have resumed past that pause (which + // takes it) or to have abandoned it. Cleared here rather than at the pause + // so no path can leave one behind. + state.entering_aura_authority = None; + let EnteringAuraHosts::Hosts { + controller, + legal_targets, + entrant, + } = hosts + else { + return EnteringAuraAttachment::NotApplicable; + }; match legal_targets.as_slice() { - // CR 303.4g: no legal host — leave unattached for the CR 704.5m SBA. - [] => EnteringAuraAttachment::Resolved, + // CR 303.4g: no legal object or player to enchant — report it and let the + // caller decide the entrant's fate. + [] => EnteringAuraAttachment::NoLegalHost, [TargetRef::Object(id)] => { - crate::game::effects::attach::attach_to(state, object_id, *id); - EnteringAuraAttachment::Resolved + crate::game::effects::attach::attach_to_with_authority( + state, + object_id, + *id, + entrant.authority(), + ); + EnteringAuraAttachment::Attached } [TargetRef::Player(id)] => { - crate::game::effects::attach::attach_to_player(state, object_id, *id); - EnteringAuraAttachment::Resolved + crate::game::effects::attach::attach_to_player_with_authority( + state, + object_id, + *id, + entrant.authority(), + ); + EnteringAuraAttachment::Attached + } + _ => { + // CR 303.4f: the choice returns to the event loop, so the entrant has + // to outlive this stack frame for the resume path's gate. Only a + // genuine projection is parked — a `Stored` authority would freeze a + // snapshot the resume path is better off reading live, and parking + // nothing is exactly the pre-existing behaviour every non-projected + // seam (Copy Enchantment's `BecomeCopy`, `ReturnAsAura`, the plain + // ZoneChange entry) already has. + if let EnteringAuraEntrant::Projected(entrant) = entrant { + state.entering_aura_authority = Some(EnteringAuraAuthority { + aura_id: object_id, + entrant, + }); + } + EnteringAuraAttachment::NeedsChoice { + controller, + legal_targets, + } + } + } +} + +/// CR 303.4f + CR 701.3b: attach an entering Aura to the host its controller +/// chose, judged against the entrant the choice was offered for. +/// +/// The single authority behind the `WaitingFor::ReturnAsAuraTarget` resume arm's +/// attach. That arm is shared by seams that park no +/// [`EnteringAuraAuthority`] — `ReturnAsAura` (Old-Growth Troll), the plain +/// non-spell Aura ZoneChange entry, and the on-battlefield `BecomeCopy` +/// realization — and for all of those the absent authority selects +/// [`EnteringAuraEntrant::Stored`], i.e. byte-for-byte the `attach_to` / +/// `attach_to_player` behaviour they had before. +pub(crate) fn attach_chosen_entering_aura_host( + state: &mut GameState, + aura_id: ObjectId, + chosen: &TargetRef, +) -> Option { + // Taken unconditionally: a parked authority belongs to exactly one pause, so + // whichever pause is resuming, it must not survive into a later one. It is + // then honoured only for the Aura it was parked for. + let parked = state + .entering_aura_authority + .take() + .filter(|authority| authority.aura_id == aura_id) + .map(|authority| EnteringAuraEntrant::Projected(authority.entrant)); + let entrant = parked.unwrap_or(EnteringAuraEntrant::Stored); + match chosen { + TargetRef::Object(host_id) => crate::game::effects::attach::attach_to_with_authority( + state, + aura_id, + *host_id, + entrant.authority(), + ), + TargetRef::Player(host_player) => { + crate::game::effects::attach::attach_to_player_with_authority( + state, + aura_id, + *host_player, + entrant.authority(), + ) + } + } +} + +#[cfg(test)] +mod entering_aura_attachment_tests { + use super::*; + use crate::game::zones::create_object; + use crate::types::ability::{ControllerRef, TypeFilter, TypedFilter}; + use crate::types::card_type::CoreType; + use crate::types::identifiers::CardId; + use crate::types::keywords::Keyword; + + const P0: PlayerId = PlayerId(0); + const P1: PlayerId = PlayerId(1); + + fn creature(state: &mut GameState, controller: PlayerId, name: &str) -> ObjectId { + let id = create_object( + state, + CardId(90_100), + controller, + name.to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).expect("just created"); + obj.card_types.core_types.push(CoreType::Creature); + obj.base_card_types = obj.card_types.clone(); + obj.power = Some(1); + obj.toughness = Some(1); + id + } + + /// An unattached Aura token on the battlefield with `enchant creature`, + /// controlled by `controller`. + fn aura(state: &mut GameState, controller: PlayerId, enchant: TargetFilter) -> ObjectId { + let id = create_object( + state, + CardId(90_200), + controller, + "Test Aura".to_string(), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).expect("just created"); + obj.card_types.core_types.push(CoreType::Enchantment); + obj.card_types.subtypes.push("Aura".to_string()); + obj.base_card_types = obj.card_types.clone(); + obj.is_token = true; + obj.keywords.push(Keyword::Enchant(enchant)); + obj.base_keywords = obj.keywords.clone(); + id + } + + fn enchant_creature() -> TargetFilter { + TargetFilter::Typed(TypedFilter::new(TypeFilter::Creature)) + } + + /// CR 303.4d: an Aura that's also a creature can't enchant anything, so + /// entry-time attachment does not apply to it at all. This is the arm that + /// must NOT be folded into CR 303.4g — the entrant is still created. + #[test] + fn an_aura_creature_is_not_applicable() { + let mut state = GameState::new_two_player(1); + creature(&mut state, P0, "Host"); + let id = aura(&mut state, P0, enchant_creature()); + state + .objects + .get_mut(&id) + .expect("aura") + .card_types + .core_types + .push(CoreType::Creature); + + assert!(matches!( + resolve_entering_aura_attachment(&mut state, id), + EnteringAuraAttachment::NotApplicable + )); + } + + /// CR 303.4g: zero legal hosts is its OWN verdict, distinct from `Attached`. + /// Reported, not acted on — the object is left exactly as it was so the + /// caller can still decline to create it. + #[test] + fn no_legal_host_is_reported_and_nothing_is_attached() { + let mut state = GameState::new_two_player(1); + let id = aura(&mut state, P0, enchant_creature()); + + assert!(matches!( + resolve_entering_aura_attachment(&mut state, id), + EnteringAuraAttachment::NoLegalHost + )); + assert!( + state.objects[&id].attached_to.is_none(), + "CR 303.4g: the no-host verdict must not attach anything" + ); + assert!( + state.objects.contains_key(&id), + "the decision seam does not itself un-create the entrant — that is the caller's call" + ); + } + + /// CR 303.4f: one legal host is not a choice — attach it, and say so with a + /// verdict distinct from "there was nothing to attach to". + #[test] + fn a_sole_legal_host_is_attached() { + let mut state = GameState::new_two_player(1); + let host = creature(&mut state, P0, "Host"); + let id = aura(&mut state, P0, enchant_creature()); + + assert!(matches!( + resolve_entering_aura_attachment(&mut state, id), + EnteringAuraAttachment::Attached + )); + assert_eq!( + state.objects[&id].attached_to, + Some(crate::game::game_object::AttachTarget::Object(host)) + ); + } + + /// CR 303.4f: more than one legal host IS a choice. + #[test] + fn multiple_legal_hosts_need_a_choice() { + let mut state = GameState::new_two_player(1); + let host_a = creature(&mut state, P0, "Host A"); + let host_b = creature(&mut state, P0, "Host B"); + let id = aura(&mut state, P0, enchant_creature()); + + let EnteringAuraAttachment::NeedsChoice { + controller, + legal_targets, + } = resolve_entering_aura_attachment(&mut state, id) + else { + panic!("two legal hosts must produce a choice"); + }; + assert_eq!(controller, P0); + assert_eq!( + legal_targets, + vec![TargetRef::Object(host_a), TargetRef::Object(host_b)] + ); + assert!( + state.objects[&id].attached_to.is_none(), + "an unanswered choice attaches nothing" + ); + } + + /// CR 303.4f: "that player" is the player the Aura is entering under the + /// control of. It is read off the OBJECT, never off the active player — which + /// is what makes an opponent-controlled copy token prompt its own controller. + #[test] + fn entering_aura_hosts_reports_the_objects_own_controller() { + let mut state = GameState::new_two_player(1); + state.active_player = P0; + creature(&mut state, P1, "Their A"); + creature(&mut state, P1, "Their B"); + // The Aura is P1's; a controller-scoped enchant ability therefore binds + // to P1's creatures even though P0 is the active player. + let id = aura( + &mut state, + P1, + TargetFilter::Typed(TypedFilter::creature().controller(ControllerRef::You)), + ); + + let EnteringAuraHosts::Hosts { + controller, + legal_targets, + entrant, + } = entering_aura_hosts(&state, id) + else { + panic!("an unattached Aura on the battlefield has a host verdict"); + }; + assert!( + matches!(entrant, EnteringAuraEntrant::Stored), + "no liminal entry and no supplied projection: the act half must read \ + the stored object live, not a snapshot" + ); + assert_eq!( + controller, P1, + "CR 303.4f: the chooser is the Aura's controller, not the active player" + ); + assert_eq!(legal_targets.len(), 2); + } + + /// The decide half is pure: asking twice does not attach anything. + #[test] + fn entering_aura_hosts_does_not_mutate() { + let mut state = GameState::new_two_player(1); + creature(&mut state, P0, "Host"); + let id = aura(&mut state, P0, enchant_creature()); + + let before = state.objects[&id].clone(); + let _ = entering_aura_hosts(&state, id); + let _ = entering_aura_hosts(&state, id); + assert_eq!(state.objects[&id].attached_to, before.attached_to); + assert_eq!(state.objects[&id].timestamp, before.timestamp); + } + + /// An unattached card-backed Aura in `zone`, with `enchant creature`. + fn card_aura(state: &mut GameState, controller: PlayerId, zone: Zone) -> ObjectId { + let id = create_object( + state, + CardId(90_300), + controller, + "Card Aura".to_string(), + zone, + ); + let obj = state.objects.get_mut(&id).expect("just created"); + obj.card_types.core_types.push(CoreType::Enchantment); + obj.card_types.subtypes.push("Aura".to_string()); + obj.base_card_types = obj.card_types.clone(); + obj.keywords.push(Keyword::Enchant(enchant_creature())); + obj.base_keywords = obj.keywords.clone(); + id + } + + /// A Curse-shaped card-backed Aura in `zone`: `enchant player`, so its only + /// candidate hosts are players. + fn card_aura_enchanting_players( + state: &mut GameState, + controller: PlayerId, + zone: Zone, + ) -> ObjectId { + let id = card_aura(state, controller, zone); + let obj = state.objects.get_mut(&id).expect("just created"); + obj.name = "Card Curse".to_string(); + obj.keywords = vec![Keyword::Enchant(TargetFilter::Player)]; + obj.base_keywords = obj.keywords.clone(); + id + } + + /// CR 303.4g: "If the Aura is a token, it isn't created." Token-ness outranks + /// the origin — a token is never created regardless of which zone the effect + /// was putting it onto the battlefield from. + #[test] + fn a_token_entrant_is_never_created_whatever_its_origin() { + let mut state = GameState::new_two_player(1); + let id = aura(&mut state, P0, enchant_creature()); + let entrant = state.objects[&id].clone(); + + for from in [Zone::Stack, Zone::Graveyard, Zone::Exile] { + assert!(matches!( + unhosted_aura_entry(&entrant, from), + UnhostedAuraEntry::NotCreated + )); + } + } + + /// CR 303.4g: a card-backed Aura "remains in its current zone, unless that + /// zone is the stack. In that case, the Aura is put into its owner's + /// graveyard instead of entering the battlefield." + /// + /// Every entrant this authority answers for has a from-zone: it is asked + /// only from the `ProposedEvent::ZoneChange` entry path. The other entry + /// path carries a CR 111.1 `LiminalEntrant::Token`, for which the rule's + /// token clause is the only applicable disposition, so a from-nothing + /// card-backed entrant is not a state this authority (or the type it is + /// asked about) can be in. + #[test] + fn a_card_backed_entrants_disposition_is_selected_by_its_origin() { + let mut state = GameState::new_two_player(1); + let id = card_aura(&mut state, P0, Zone::Graveyard); + let entrant = state.objects[&id].clone(); + + assert!(matches!( + unhosted_aura_entry(&entrant, Zone::Graveyard), + UnhostedAuraEntry::RemainInCurrentZone + )); + assert!(matches!( + unhosted_aura_entry(&entrant, Zone::Exile), + UnhostedAuraEntry::RemainInCurrentZone + )); + assert!(matches!( + unhosted_aura_entry(&entrant, Zone::Stack), + UnhostedAuraEntry::OwnersGraveyard + )); + } + + /// CR 303.4g through the real pipeline: an Aura card put onto the battlefield + /// from a NON-stack zone with no legal host remains where it was. + #[test] + fn unhosted_card_aura_from_a_non_stack_zone_remains_in_that_zone() { + let mut state = GameState::new_two_player(1); + let id = card_aura(&mut state, P0, Zone::Graveyard); + let mut events = Vec::new(); + + let result = move_object( + &mut state, + ZoneMoveRequest::effect(id, Zone::Battlefield, id), + &mut events, + ); + + assert!(matches!(result, ZoneMoveResult::Done)); + assert_eq!( + state.objects[&id].zone, + Zone::Graveyard, + "CR 303.4g: the Aura remains in its current zone" + ); + assert!(!state.battlefield.iter().any(|&bid| bid == id)); + } + + /// CR 303.4g's stack exception, through the real pipeline: an Aura put onto + /// the battlefield FROM THE STACK with no legal host cannot remain there — it + /// goes to its owner's graveyard instead of entering. + /// + /// This is the assertion that flips when the exception is removed: before the + /// fix this path took the same unconditional `Remained` arm as the graveyard + /// case above and left the Aura sitting on the stack forever. + #[test] + fn unhosted_card_aura_from_the_stack_goes_to_its_owners_graveyard() { + let mut state = GameState::new_two_player(1); + let id = card_aura(&mut state, P0, Zone::Stack); + let mut events = Vec::new(); + + let result = move_object( + &mut state, + ZoneMoveRequest::effect(id, Zone::Battlefield, id), + &mut events, + ); + + assert!(matches!(result, ZoneMoveResult::Done)); + assert_eq!( + state.objects[&id].zone, + Zone::Graveyard, + "CR 303.4g: a stack-origin unhosted Aura is put into its owner's graveyard" + ); + assert!( + state.players[0].graveyard.iter().any(|&gid| gid == id), + "the owner's graveyard actually holds it" + ); + assert!( + !state.battlefield.iter().any(|&bid| bid == id), + "it is put into the graveyard INSTEAD OF entering the battlefield" + ); + } + + /// CR 400.7 + CR 603.6a: the CR 303.4g graveyard placement is a real zone + /// change, so it EMITS a `ZoneChanged` that "whenever a card is put into a + /// graveyard from anywhere" triggers can see. + /// + /// Honest scope: this does NOT discriminate against the destination-rewrite + /// form this arm replaced — that delivered the same event and emitted the same + /// pair. It pins the property against the OTHER regression available here, a + /// raw `zones::` placement, which emits nothing at all and so fires no "put + /// into a graveyard from anywhere" trigger. (The liminal seam's card-backed + /// sibling was exactly such a raw placement; it no longer exists — a + /// `ProposedEvent::TokenEntry` entrant is a CR 111.1 token by construction, + /// so this path is the only place a CR 303.4g graveyard placement happens.) + /// The revert-discriminating assertion for the routing change itself is in + /// the redirect test below. + #[test] + fn the_stack_origin_graveyard_placement_emits_a_zone_changed() { + let mut state = GameState::new_two_player(1); + let id = card_aura(&mut state, P0, Zone::Stack); + let mut events = Vec::new(); + + let result = move_object( + &mut state, + ZoneMoveRequest::effect(id, Zone::Battlefield, id), + &mut events, + ); + + assert!(matches!(result, ZoneMoveResult::Done)); + assert!( + events.iter().any(|event| matches!( + event, + crate::types::events::GameEvent::ZoneChanged { + object_id, + from: Some(Zone::Stack), + to: Zone::Graveyard, + .. + } if *object_id == id + )), + "CR 400.7: the graveyard placement must be observable (got {events:?})" + ); + assert!( + !events.iter().any(|event| matches!( + event, + crate::types::events::GameEvent::ZoneChanged { + object_id, + to: Zone::Battlefield, + .. + } if *object_id == id + )), + "CR 303.4g: nothing may observe the denied battlefield entry" + ); + } + + /// CR 614.6 discriminating regression: the CR 303.4g graveyard placement is a + /// FRESH event, so a board-wide `Moved` graveyard→exile redirect (Rest in + /// Peace / Leyline of the Void) fires on it and the Aura ends in EXILE. + /// + /// The revert-failing assertion is `zone == Exile`. Rewriting the approved + /// entry event's destination — the shape this replaced — skipped a second + /// consult entirely, so the Aura landed in the graveyard with Rest in Peace on + /// the battlefield. Structural twin of + /// `engine_replacement::prevented_etb_graveyard_fallback_consults_moved_redirects`. + #[test] + fn the_stack_origin_graveyard_placement_consults_moved_redirects() { + use crate::types::ability::{AbilityDefinition, AbilityKind, ReplacementDefinition}; + use crate::types::replacements::ReplacementEvent; + + let mut state = GameState::new_two_player(1); + // Rest in Peace-class redirect. Deliberately NOT a creature: a creature + // would be a legal host for `enchant creature` and CR 303.4g would never + // be reached. + let rip = create_object( + &mut state, + CardId(90_400), + P1, + "Rest in Peace".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&rip) + .expect("just created") + .replacement_definitions + .push( + ReplacementDefinition::new(ReplacementEvent::Moved) + .destination_zone(Zone::Graveyard) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChangeZone { + origin: None, + destination: Zone::Exile, + target: TargetFilter::SelfRef, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + )), + ); + + let id = card_aura(&mut state, P0, Zone::Stack); + let mut events = Vec::new(); + + let result = move_object( + &mut state, + ZoneMoveRequest::effect(id, Zone::Battlefield, id), + &mut events, + ); + + assert!(matches!(result, ZoneMoveResult::Done)); + assert_eq!( + state.objects[&id].zone, + Zone::Exile, + "CR 614.6: the CR 303.4g graveyard placement is a fresh, replaceable \ + event — a graveyard→exile redirect must fire on it" + ); + assert!( + !state.players[0].graveyard.iter().any(|&gid| gid == id), + "the Aura must not reach the graveyard with Rest in Peace out" + ); + assert!( + !state.battlefield.iter().any(|&bid| bid == id), + "CR 303.4g: it still never enters the battlefield" + ); + } + + /// Reach-guard for the redirect regression: with Rest in Peace out but a + /// legal host present, the Aura enters normally. Without this, the exile + /// assertion above could pass for the wrong reason (an entry blocked upstream + /// and swept by some other rule). + #[test] + fn a_moved_redirect_does_not_disturb_a_hosted_entry() { + use crate::types::ability::{AbilityDefinition, AbilityKind, ReplacementDefinition}; + use crate::types::replacements::ReplacementEvent; + + let mut state = GameState::new_two_player(1); + let rip = create_object( + &mut state, + CardId(90_400), + P1, + "Rest in Peace".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&rip) + .expect("just created") + .replacement_definitions + .push( + ReplacementDefinition::new(ReplacementEvent::Moved) + .destination_zone(Zone::Graveyard) + .execute(AbilityDefinition::new( + AbilityKind::Spell, + Effect::ChangeZone { + origin: None, + destination: Zone::Exile, + target: TargetFilter::SelfRef, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + )), + ); + let host = creature(&mut state, P0, "Host"); + let id = card_aura(&mut state, P0, Zone::Stack); + let mut events = Vec::new(); + + let result = move_object( + &mut state, + ZoneMoveRequest::effect(id, Zone::Battlefield, id), + &mut events, + ); + + assert!(matches!(result, ZoneMoveResult::Done)); + assert_eq!(state.objects[&id].zone, Zone::Battlefield); + assert_eq!( + state.objects[&id].attached_to, + Some(crate::game::game_object::AttachTarget::Object(host)) + ); + } + + /// CR 303.4c + CR 702.16c: a player who cannot legally be enchanted is not a + /// legal host, so an Aura whose only candidate is that player takes the + /// CR 303.4g arm rather than entering and being swept by CR 704.5m. + /// + /// Revert-failing assertion: `zone == Zone::Graveyard`. Without the + /// `can_attach_to_player` filter the protected player counted as legal, the + /// entry was allowed, and `attach_to_player` silently no-opped on the + /// illegality. + #[test] + fn a_player_host_that_cannot_be_enchanted_is_not_a_legal_host() { + let mut state = GameState::new_two_player(1); + let id = card_aura_enchanting_players(&mut state, P0, Zone::Stack); + // CR 702.16j: protection from everything on BOTH players, so no player is + // a legal host and the enchant-player filter's population is empty. + for player in [P0, P1] { + state.add_transient_continuous_effect( + id, + P0, + crate::types::ability::Duration::UntilEndOfTurn, + TargetFilter::SpecificPlayer { id: player }, + vec![crate::types::ability::ContinuousModification::AddKeyword { + keyword: Keyword::Protection( + crate::types::keywords::ProtectionTarget::Everything, + ), + }], + None, + ); } - _ => EnteringAuraAttachment::NeedsChoice { + + let mut events = Vec::new(); + let result = move_object( + &mut state, + ZoneMoveRequest::effect(id, Zone::Battlefield, id), + &mut events, + ); + + assert!(matches!(result, ZoneMoveResult::Done)); + assert_eq!( + state.objects[&id].zone, + Zone::Graveyard, + "CR 303.4g: no legal player host, so the stack-origin Aura is put into \ + its owner's graveyard instead of entering" + ); + assert!(!state.battlefield.iter().any(|&bid| bid == id)); + } + + /// Reach-guard twin of the test above: the same Aura with an unprotected + /// player present enters and attaches to that player, so the negative there + /// is not passing because enchant-player hosts are never offered at all. + #[test] + fn an_unprotected_player_is_still_a_legal_host() { + let mut state = GameState::new_two_player(1); + let id = card_aura_enchanting_players(&mut state, P0, Zone::Stack); + state.add_transient_continuous_effect( + id, + P0, + crate::types::ability::Duration::UntilEndOfTurn, + TargetFilter::SpecificPlayer { id: P0 }, + vec![crate::types::ability::ContinuousModification::AddKeyword { + keyword: Keyword::Protection(crate::types::keywords::ProtectionTarget::Everything), + }], + None, + ); + + let mut events = Vec::new(); + let result = move_object( + &mut state, + ZoneMoveRequest::effect(id, Zone::Battlefield, id), + &mut events, + ); + + assert!(matches!(result, ZoneMoveResult::Done)); + assert_eq!(state.objects[&id].zone, Zone::Battlefield); + assert_eq!( + state.objects[&id].attached_to, + Some(crate::game::game_object::AttachTarget::Player(P1)), + "CR 303.4f: the sole legal player host is attached as the Aura enters" + ); + } + + /// Reach-guard for the two pipeline tests above: the same move with a legal + /// host on the battlefield DOES enter and attach, so their negatives are not + /// passing because the entry was blocked somewhere upstream of CR 303.4f/g. + #[test] + fn a_hosted_card_aura_from_the_stack_enters_and_attaches() { + let mut state = GameState::new_two_player(1); + let host = creature(&mut state, P0, "Host"); + let id = card_aura(&mut state, P0, Zone::Stack); + let mut events = Vec::new(); + + let result = move_object( + &mut state, + ZoneMoveRequest::effect(id, Zone::Battlefield, id), + &mut events, + ); + + assert!(matches!(result, ZoneMoveResult::Done)); + assert_eq!(state.objects[&id].zone, Zone::Battlefield); + assert_eq!( + state.objects[&id].attached_to, + Some(crate::game::game_object::AttachTarget::Object(host)), + "CR 303.4f: the sole legal host is attached as the Aura enters" + ); + } + + /// A Serra's Emissary-shaped permanent: its controller has CR 702.16c + /// protection from the card type it chose as it entered (CR 205.2). + fn chosen_card_type_protection( + state: &mut GameState, + controller: PlayerId, + chosen: CoreType, + ) -> ObjectId { + use crate::types::ability::ChosenAttribute; + use crate::types::keywords::ProtectionTarget; + use crate::types::statics::StaticMode; + + let id = create_object( + state, + CardId(90_400), controller, + format!("Emissary vs {chosen:?}"), + Zone::Battlefield, + ); + let obj = state.objects.get_mut(&id).expect("just created"); + obj.card_types.core_types.push(CoreType::Creature); + obj.base_card_types = obj.card_types.clone(); + obj.chosen_attributes + .push(ChosenAttribute::CardType(chosen)); + let protection = crate::types::ability::StaticDefinition::new( + StaticMode::PlayerProtection(ProtectionTarget::ChosenCardType), + ) + .affected(TargetFilter::Typed( + TypedFilter::default().controller(ControllerRef::You), + )); + obj.static_definitions.push(protection.clone()); + obj.base_static_definitions = std::sync::Arc::new(vec![protection]); + crate::game::layers::mark_layers_full(state); + crate::game::layers::flush_layers(state); + id + } + + /// CR 303.4f + CR 701.3b + CR 303.4i on the PLAYER half of the act half: the + /// attach must be judged against the entrant the decide half was given, not + /// against the object stored under the Aura's id. + /// + /// `attach_to_player` carries its own CR 303.4i legality gate, so the object + /// half's fix does not cover it. This is a SEAM test rather than a + /// production-pipeline one, and deliberately so: `player_protection_from_object` + /// is the only projection-sensitive input to that gate, and of the qualities it + /// implements at the player level only `ChosenCardType` reads the attachment's + /// characteristics — while every copy exception the parser produces moves the + /// card-type set in the RESTRICTIVE direction. No production input can + /// therefore reach this arm today; the fixture states the seam contract + /// directly instead of inventing a card. See the note on + /// `yenna_aura_token_copy::chosen_player_host_resume_survives_the_color_exception`. + /// + /// P1 is protected from artifacts, P0 from enchantments. The STORED body is an + /// artifact enchantment (illegal for both); the ENTRANT is a plain enchantment + /// (illegal for P0, legal for P1) — the shape a `SetCardTypes` copy exception + /// yields. The revert-failing assertion is `attached_to == Some(Player(P1))`: + /// with the act half reading the stored body, P1's protection from artifacts + /// rejects the attach and CR 701.3b leaves the Aura unattached. + #[test] + fn player_host_attach_uses_the_supplied_entrant() { + let mut state = GameState::new_two_player(1); + chosen_card_type_protection(&mut state, P0, CoreType::Enchantment); + chosen_card_type_protection(&mut state, P1, CoreType::Artifact); + let id = card_aura_enchanting_players(&mut state, P0, Zone::Battlefield); + { + let obj = state.objects.get_mut(&id).expect("aura"); + obj.card_types.core_types.push(CoreType::Artifact); + obj.base_card_types = obj.card_types.clone(); + } + // The CR 614.12 entrant: the same object without the artifact type. + let mut entrant = state.objects[&id].clone(); + entrant + .card_types + .core_types + .retain(|t| *t != CoreType::Artifact); + entrant.base_card_types = entrant.card_types.clone(); + + let hosts = entering_aura_hosts_projected(&state, id, &entrant); + let EnteringAuraHosts::Hosts { legal_targets, .. } = &hosts else { + panic!("an unattached Curse on the battlefield has a host verdict"); + }; + assert_eq!( legal_targets, - }, + &vec![TargetRef::Player(P1)], + "reach-guard: judged against the ENTRANT, P1 is the sole legal player \ + host (P0 is protected from enchantments either way)" + ); + + assert!(matches!( + apply_entering_aura_hosts(&mut state, id, hosts), + EnteringAuraAttachment::Attached + )); + assert_eq!( + state.objects[&id].attached_to, + Some(crate::game::game_object::AttachTarget::Player(P1)), + "CR 303.4i: the player gate must read the ENTRANT — the stored body's \ + artifact type is not the Aura that is entering" + ); + } + + /// CR 303.4f: the multi-host pause parks the entrant, and only a real + /// projection — never a `Stored` authority — is parked. + /// + /// Parking a snapshot for a seam whose stored object already IS the entrant + /// would freeze characteristics the resume is better off reading live, and + /// would change behaviour for the three pre-existing `ReturnAsAuraTarget` + /// producers that share the resume arm. + #[test] + fn only_a_projected_entrant_is_parked_across_the_host_choice() { + let mut state = GameState::new_two_player(1); + creature(&mut state, P0, "Host A"); + creature(&mut state, P0, "Host B"); + let id = aura(&mut state, P0, enchant_creature()); + + let stored_hosts = entering_aura_hosts(&state, id); + assert!(matches!( + apply_entering_aura_hosts(&mut state, id, stored_hosts), + EnteringAuraAttachment::NeedsChoice { .. } + )); + assert!( + state.entering_aura_authority.is_none(), + "a `Stored` authority is never parked — the resume reads the object live" + ); + + let entrant = state.objects[&id].clone(); + let projected_hosts = entering_aura_hosts_projected(&state, id, &entrant); + assert!(matches!( + apply_entering_aura_hosts(&mut state, id, projected_hosts), + EnteringAuraAttachment::NeedsChoice { .. } + )); + let parked = state + .entering_aura_authority + .as_ref() + .expect("a projected entrant is parked for the resume"); + assert_eq!(parked.aura_id, id); + + // Spent by the resume, and honoured only for its own Aura. + let other = aura(&mut state, P0, enchant_creature()); + assert!( + attach_chosen_entering_aura_host(&mut state, other, &TargetRef::Object(id)).is_none() + || state.entering_aura_authority.is_none(), + "a resume for a different Aura must not consume the parked entrant as its own" + ); + assert!( + state.entering_aura_authority.is_none(), + "the parked authority never survives a `ReturnAsAuraTarget` resume" + ); } } @@ -2911,7 +4039,7 @@ fn execute_zone_move_with_applied_terminal( if let Some(obj) = state .liminal_entries .get(&obj_id) - .map(|entry| &entry.object) + .map(|entry| entry.object.projected()) .or_else(|| state.objects.get(&obj_id)) { // CR 712.14a + CR 712.18: A permanent entering transformed (e.g. a @@ -2995,8 +4123,14 @@ fn execute_zone_move_with_applied_terminal( match replacement::replace_event(state, proposed, events) { ReplacementResult::Execute(mut event) => { let mut pending_aura_choice: Option<(PlayerId, ObjectId, Vec)> = None; + // CR 303.4g: set when the unhosted entrant came from the stack and so + // must be put into its owner's graveyard rather than remain. Acted on + // after the borrow of `event` ends — the battlefield entry is denied + // and a FRESH graveyard move is proposed in its place. + let mut unhosted_to_owners_graveyard = false; if let ProposedEvent::ZoneChange { object_id, + from, to: Zone::Battlefield, attach_to, controller_override, @@ -3005,20 +4139,50 @@ fn execute_zone_move_with_applied_terminal( { if attach_to.is_none() { if let Some(enchant_filter) = aura_enchant_filter(state, *object_id) { + // CR 614.12: read the entrant's projected characteristics, + // not the pre-entry object still stored under this id — for + // a meld the latter is the exiled component card and has + // the wrong controller as well as the wrong typeline. let controller = (*controller_override) - .or_else(|| state.objects.get(object_id).map(|obj| obj.controller)) + .or_else(|| { + entering_object_projection(state, *object_id) + .map(|obj| obj.controller) + }) .unwrap_or(PlayerId(0)); let legal_targets = legal_aura_attachment_targets( state, *object_id, + entering_object_projection(state, *object_id), controller, &enchant_filter, ); match legal_targets.as_slice() { + // CR 303.4g: no legal object or player to enchant, so + // this entry does not happen. Decided BEFORE + // `deliver_replaced_zone_change`, i.e. before the + // object is inserted into the battlefield, before the + // meld commit journals anything, and before any entry + // event — the rule denies the entry, so nothing may + // observe one. [] => { - return ZoneMoveTerminalResult::Completed( - ZoneMoveCompletion::Remained, - ); + match entering_object_projection(state, *object_id) + .map(|entrant| unhosted_aura_entry(entrant, *from)) + { + Some(UnhostedAuraEntry::OwnersGraveyard) => { + unhosted_to_owners_graveyard = true; + } + // "The Aura remains in its current zone" — + // and, for a token already in a zone, "isn't + // created" has nothing left to withhold, so + // both leave the object exactly where it is. + Some(UnhostedAuraEntry::NotCreated) + | Some(UnhostedAuraEntry::RemainInCurrentZone) + | None => { + return ZoneMoveTerminalResult::Completed( + ZoneMoveCompletion::Remained, + ); + } + } } [TargetRef::Object(id)] => { *attach_to = @@ -3038,6 +4202,33 @@ fn execute_zone_move_with_applied_terminal( // `attach_to` fails / SBA (CR 704.5m). Pre-move filter checks while // the Aura is still in GY falsely Remained legal Gift/Lynde hosts. } + if unhosted_to_owners_graveyard { + // CR 303.4g: "…the Aura is put into its owner's graveyard instead + // of entering the battlefield." + // + // CR 614.6: the approved battlefield entry never happens, and the + // graveyard placement that replaces it is a FRESH, never-consulted + // event — so it routes through the pipeline rather than being + // written as a destination rewrite of the already-approved event. + // A board-wide `Moved` graveyard→exile redirect (Rest in Peace / + // Leyline of the Void) therefore fires on it. Same house decision + // as `engine_replacement.rs`'s CR 608.3e prevented-permanent + // graveyard fallback, which is the structural twin of this arm. + // + // The already-applied set rides along on the fresh request so no + // replacement can be spent twice: every def that applied to this + // entry was consulted against `to: Battlefield`, and the new + // proposal is `to: Graveyard`, so a battlefield-scoped entry + // replacement cannot re-match it either way — carrying `applied` + // makes that structural fact explicit instead of implicit. + let applied = event.applied_set().clone(); + return move_object_with_terminal( + state, + ZoneMoveRequest::effect(obj_id, Zone::Graveyard, source_id) + .with_replacement_applied(applied), + events, + ); + } if let Some((controller, aura_id, legal_targets)) = pending_aura_choice { let delivery_start = events.len(); match deliver_replaced_zone_change( diff --git a/crates/engine/src/game/zones.rs b/crates/engine/src/game/zones.rs index bf2112a9a5..a44247f525 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -996,7 +996,7 @@ pub fn move_to_zone( state .liminal_entries .get(&object_id) - .map(|entry| entry.object.clone()) + .map(|entry| entry.object.projected().clone()) }) .flatten(); let liminal_attack_target = (to == Zone::Battlefield) diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 7ef98a75db..f70fa9b39c 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -5361,6 +5361,28 @@ pub struct PendingTokenBattlefieldEntry { pub source_id: ObjectId, } +/// CR 707.2 + CR 614.1c: everything the non-liminal copy-token entry tail still +/// has to do for ONE token after its body is materialized — the copy exceptions, +/// its entry counters, its entry events, and the rest of the batch. +/// +/// Split out of the batch loop so the tail has exactly one implementation +/// (`token_copy::finish_non_liminal_copy_token_entry`), reachable both inline and +/// from the [`PendingCounterPostAction::ContinueCopyTokenEntryAfterAuraHost`] +/// resume. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CopyTokenEntryTail { + pub owner: PlayerId, + pub copy: Box, + pub enter_tapped: EtbTapState, + pub enter_with_counters: Vec<(CounterType, u32)>, + /// CR 306.5b + CR 614.1c: the entry counters the copy path seeds itself + /// (copied loyalty, "enters with N counters" self-replacements, and the + /// creating effect's own additions), already merged in application order. + pub etb_counters: Vec<(CounterType, u32)>, + /// How many tokens of this batch are still unstarted after this one. + pub remaining_count: u32, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum PendingCounterPostAction { EmitEffectResolved { @@ -5425,6 +5447,19 @@ pub enum PendingCounterPostAction { controller: PlayerId, remaining_modifications: Vec, }, + /// CR 303.4f: finish a NON-liminal copy-token entry whose CR 303.4f Aura-host + /// choice paused it between the body's materialization and the rest of its + /// entry. + /// + /// Deliberately NOT `ApplyCopyTokenModificationsAndFinalize`: that action's + /// handler resumes a copy whose ETB counters have ALREADY been applied and + /// therefore skips `etb_counters` entirely, while this pause happens BEFORE + /// them — reusing it would silently drop a copied planeswalker's CR 306.5b + /// loyalty and every CR 614.1c "enters with counters" self-replacement. + ContinueCopyTokenEntryAfterAuraHost { + object_id: ObjectId, + tail: Box, + }, FinalizeCommittedLiminalTokenEntry { object_id: ObjectId, name: String, @@ -13697,9 +13732,156 @@ impl ResolvingTriggerContext { } } +/// CR 303.4f + CR 614.12: the entrant an entering Aura's host choice was decided +/// against, parked across the [`WaitingFor::ReturnAsAuraTarget`] pause that asks +/// its controller which of several legal hosts to enchant. +/// +/// CR 303.4f requires the chooser to pick "a legal object or player according to +/// the Aura's enchant ability and any other applicable effects" — i.e. according +/// to the Aura AS IT ENTERS. When the choice pauses, the act half runs in a later +/// `apply` call and can no longer see the deciding seam's stack frame, so the +/// entrant travels here. Without it the act half re-derives CR 701.3a legality +/// from `state.objects[aura_id]`, which on the non-liminal copy-token path still +/// holds the PRE-exception body: a copy exception that changes the Aura's color +/// (CR 707.9b) would make the chosen host's protection (CR 702.16c) reject an +/// attachment CR 303.4f had legally offered, and CR 701.3b would turn the attach +/// into a silent no-op, leaving the token for the CR 704.5m sweep. +/// +/// `None` for every seam whose stored object already IS the entrant, so the +/// shared resume path is unchanged for them. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnteringAuraAuthority { + /// The Aura the parked entrant belongs to. The `ReturnAsAuraTarget` resume + /// path is shared with seams that park nothing, so the id is matched before + /// the entrant is honoured. + pub aura_id: ObjectId, + /// CR 614.12: the characteristics the Aura will have on the battlefield. + pub entrant: Box, +} + +/// CR 111.1: a token projection — "a marker used to represent any permanent +/// that isn't represented by a card". +/// +/// A witness type, not a convenience wrapper. CR 303.4g ends with "If the Aura +/// is a token, it isn't created", so the seam that denies an unhosted entry has +/// to know whether its entrant is a token — and a plain [`GameObject`] can only +/// be *expected* to have `is_token` set, never proven to. That left the seam +/// carrying a disposition for a card-backed entrant the type still admitted. +/// The single constructor sets the flag and no accessor hands out a +/// `&mut GameObject`, so "this entrant is a token" holds by construction +/// wherever this type appears. +#[derive(Debug, Clone)] +pub struct TokenProjection(GameObject); + +impl TokenProjection { + /// CR 111.1: project characteristics as a token. Marking is part of the + /// construction, so the invariant cannot be missed by a caller that forgot. + pub fn materialize(mut object: GameObject) -> Self { + object.is_token = true; + Self(object) + } + + pub fn projected(&self) -> &GameObject { + &self.0 + } + + pub fn into_projected(self) -> GameObject { + self.0 + } + + /// CR 614.1c: an entry replacement can still settle the entrant's tapped + /// state before it enters. Deliberately narrow — a general + /// `&mut GameObject` would let a caller clear the CR 111.1 flag this type + /// exists to carry. + pub fn set_tapped(&mut self, tapped: bool) { + self.0.tapped = tapped; + } +} + +/// CR 614.12: the entrant of a liminal (decided-but-not-yet-entered) projection. +/// +/// Two kinds of entrant reach `GameState::liminal_entries`, and CR 303.4g makes +/// the difference load-bearing rather than cosmetic: +/// +/// * a token, which exists in no zone at all until its entry commits, and +/// * the CR 701.42 meld result, whose components are real cards sitting in +/// exile. +/// +/// Storing both as a bare `GameObject` erased that distinction at exactly the +/// seam that has to act on it. +#[derive(Debug, Clone)] +pub enum LiminalEntrant { + /// CR 111.1: the entrant of a `ProposedEvent::TokenEntry`. It is in no zone, + /// so CR 303.4g's zone-phrased dispositions cannot apply to it — only the + /// rule's token clause can. + Token(TokenProjection), + /// CR 701.42: a card-backed projection. It always enters from a real prior + /// zone through `ProposedEvent::ZoneChange`, so CR 303.4g's card + /// dispositions ("remains in its current zone", or the stack's + /// owner's-graveyard placement) are decided on that path — which + /// re-proposes the graveyard placement as a fresh, replacement-consulted + /// event (CR 614.6). + Card(GameObject), +} + +impl LiminalEntrant { + /// CR 614.12: the characteristics the entrant will have on the battlefield, + /// whichever kind of entrant it is. + pub fn projected(&self) -> &GameObject { + match self { + Self::Token(token) => token.projected(), + Self::Card(object) => object, + } + } + + pub fn into_projected(self) -> GameObject { + match self { + Self::Token(token) => token.into_projected(), + Self::Card(object) => object, + } + } + + /// CR 111.1: whether this projection is a token, answered by the stored + /// witness rather than by trusting a flag on the projected object. + pub fn is_token_projection(&self) -> bool { + matches!(self, Self::Token(_)) + } + + /// CR 614.1c: settle the entrant's tapped state before it enters. + pub fn set_tapped(&mut self, tapped: bool) { + match self { + Self::Token(token) => token.set_tapped(tapped), + Self::Card(object) => object.tapped = tapped, + } + } +} + +/// The serialized form is the projected object itself, unchanged from when this +/// field was a bare `GameObject`: the witness is a compile-time distinction, and +/// CR 111.1 token-ness is already a persisted characteristic of the projection. +impl Serialize for LiminalEntrant { + fn serialize(&self, serializer: S) -> Result { + self.projected().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for LiminalEntrant { + fn deserialize>(deserializer: D) -> Result { + let object = GameObject::deserialize(deserializer)?; + // CR 111.1: reading the witness back off the projection is exact — the + // two constructors are the two sides of this test, and nothing else can + // produce a `LiminalEntrant`. + Ok(if object.is_token { + Self::Token(TokenProjection(object)) + } else { + Self::Card(object) + }) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LiminalEntry { - pub object: GameObject, + pub object: LiminalEntrant, pub name: String, pub source_id: ObjectId, pub controller: PlayerId, @@ -14045,6 +14227,24 @@ declare_game_state! { pub liminal_entries: HashMap, #[serde(default, skip_serializing_if = "Option::is_none")] pub pending_liminal_entry_resume: Option, + /// CR 303.4f + CR 614.12: see [`EnteringAuraAuthority`]. Written only by + /// `zone_pipeline::apply_entering_aura_hosts` when it hands an entering + /// Aura's host choice to a player and the deciding seam judged legality + /// against a projection the stored object does not match yet; taken by the + /// `WaitingFor::ReturnAsAuraTarget` resume arm. + /// + /// Pause-scoped, and therefore INTENTIONALLY omitted from + /// `impl PartialEq for GameState` (same treatment as + /// `resolving_continuation_attach_host`): the pause it belongs to is already + /// compared through `waiting_for`, and a projection snapshot is not board + /// state a CR 104.4b loop can accumulate in. + /// + /// Not redacted by `visibility::filter_state_for_viewer` (unlike + /// `liminal_entries`, which can hold a face-down/meld projection no viewer + /// may see): this entrant is a token that is already on the battlefield, so + /// its characteristics are public information. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entering_aura_authority: Option, /// CR 614.12a: set by `continue_replacement` when an optional `MayCost` /// accept's payment paused for an interactive sub-choice (e.g. Mox Diamond's /// "discard a land card" with multiple eligible lands). It re-parks the @@ -19639,6 +19839,7 @@ impl GameState { pending_replacement: None, liminal_entries: HashMap::new(), pending_liminal_entry_resume: None, + entering_aura_authority: None, replacement_may_cost_paused: false, post_replacement_token_choice_applied: None, post_replacement_token_substitution_count: None, @@ -21570,6 +21771,15 @@ fn _gamestate_partition_is_total(s: &GameState) { // never involve these, so no certification-death. liminal_entries: _, pending_liminal_entry_resume: _, + // `entering_aura_authority` (CR 303.4f entering-Aura host authority): + // EXCLUDED from `impl PartialEq for GameState`, and that is the SAFE + // direction here rather than the dangerous one. It is `Some` only while + // `waiting_for == ReturnAsAuraTarget` — a state the loop sampler never + // records (samples are taken at the Priority window) — and it is taken + // by the resume arm, so it is `None` at every sample beat. It is a + // read-only snapshot of an object `objects` already carries, never an + // accumulator: no value can grow across iterations in it. + entering_aura_authority: _, last_discover_value: _, // Post-rebase upstream additions (rebased onto d1a1e995e), classified by ONE-SIDED-SAFETY // (COMPARED is fail-safe; EXCLUSION is the fail-DANGEROUS direction — a field is excluded @@ -22680,6 +22890,45 @@ mod tests { }; use crate::types::resolved_commands::ResolvedDelayedTriggerCommand; + /// The `LiminalEntrant` witness is a compile-time distinction with no wire + /// footprint: it serializes as the bare projected object, exactly as this + /// field did before the witness existed, and CR 111.1 token-ness — already a + /// persisted characteristic — selects the variant on the way back in. + #[test] + fn a_liminal_entrant_round_trips_as_its_projected_object() { + for is_token in [true, false] { + let mut object = GameObject::new( + ObjectId(7), + CardId(3), + PlayerId(0), + "Projection".to_string(), + Zone::Battlefield, + ); + object.is_token = is_token; + let entrant = if is_token { + LiminalEntrant::Token(TokenProjection::materialize(object.clone())) + } else { + LiminalEntrant::Card(object.clone()) + }; + + let entrant_json = serde_json::to_string(&entrant).expect("entrant serializes"); + assert_eq!( + entrant_json, + serde_json::to_string(&object).expect("object serializes"), + "the wire form is the projected object itself" + ); + + let restored: LiminalEntrant = + serde_json::from_str(&entrant_json).expect("entrant restores"); + assert_eq!( + restored.is_token_projection(), + is_token, + "CR 111.1: the witness survives the round trip" + ); + assert_eq!(restored.projected().name, object.name); + } + } + #[derive(Serialize)] struct TupleKeyFixture<'a> { #[serde(serialize_with = "tuple_key_map::serialize")] diff --git a/crates/engine/src/types/proposed_event.rs b/crates/engine/src/types/proposed_event.rs index 57ed504e08..83ed4aa728 100644 --- a/crates/engine/src/types/proposed_event.rs +++ b/crates/engine/src/types/proposed_event.rs @@ -1027,7 +1027,7 @@ impl ProposedEvent { ProposedEvent::TokenEntry { entry_ref, .. } => state .liminal_entries .get(entry_ref) - .map(|entry| entry.object.controller) + .map(|entry| entry.object.projected().controller) .unwrap_or(PlayerId(0)), // CR 701.3a: The attaching Aura/Equipment's controller is the // affected player — they are the one who would choose a diff --git a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz index 39b14bf14d..8fd48cb658 100644 Binary files a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz and b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz differ diff --git a/crates/engine/tests/integration/battlefield_entry_authority_census.rs b/crates/engine/tests/integration/battlefield_entry_authority_census.rs index 702c488aef..316d606c14 100644 --- a/crates/engine/tests/integration/battlefield_entry_authority_census.rs +++ b/crates/engine/tests/integration/battlefield_entry_authority_census.rs @@ -1254,12 +1254,20 @@ fn every_single_id_anaphora_publish_lives_in_an_authority() { Ambiguous hits = {ambiguous_production:#?}" ); + // Issue #5904 moved TWO of these from `counters.rs` to `token_copy.rs` without changing + // the total, which is exactly the shape this pin's own instructions ask for. The + // `ContinueCopyTokenCreation` resume arm's `if let Some(pending) = active_copy_token_mut() + // { pending.created_ids.extend(..) } else { last_created_token_ids.extend(..) }` pair was + // about to be COPIED verbatim into the new `ContinueCopyTokenEntryAfterAuraHost` resume, + // so it was lifted into `token_copy::extend_copy_batch_created_ids` instead and both arms + // now call it. Same five calls, same bulk arguments (`status.created_ids`, a `Vec`), one + // fewer place to write the destination-selection rule wrong. `counters.rs` therefore drops + // out of this multiset entirely; its absence is now a claim, not an omission. assert_eq!( production_multiset(&ambiguous_production), vec![ - ("engine/src/game/effects/counters.rs".to_string(), 2), ("engine/src/game/effects/token.rs".to_string(), 1), - ("engine/src/game/effects/token_copy.rs".to_string(), 2), + ("engine/src/game/effects/token_copy.rs".to_string(), 4), ], "per-file argument-ambiguous mutator multiset moved. All five of these are bulk \ republishes today — `.extend(status.created_ids)` and `.extend(state.\ diff --git a/crates/engine/tests/integration/deterministic_game_state_serde.rs b/crates/engine/tests/integration/deterministic_game_state_serde.rs index 9cd729f0a5..4558fad799 100644 --- a/crates/engine/tests/integration/deterministic_game_state_serde.rs +++ b/crates/engine/tests/integration/deterministic_game_state_serde.rs @@ -12,8 +12,9 @@ use engine::types::card_type::CardType; use engine::types::definitions::Definitions; use engine::types::events::{GameEvent, PlayerActionKind}; use engine::types::game_state::{ - AutoPassMode, LandPlayRecord, LiminalEntry, LinkedExileSnapshot, PendingConniveReentry, - PersistedGameState, PriorityPassingMode, SpellCastRecord, StackPaidSnapshot, WaitingFor, + AutoPassMode, LandPlayRecord, LiminalEntrant, LiminalEntry, LinkedExileSnapshot, + PendingConniveReentry, PersistedGameState, PriorityPassingMode, SpellCastRecord, + StackPaidSnapshot, TokenProjection, WaitingFor, }; use engine::types::identifiers::{CardId, ObjectId, TrackedSetId}; use engine::types::keywords::ProtectionTarget; @@ -1419,7 +1420,7 @@ fn build_all_direct_numeric_maps_state() -> GameState { ( ObjectId(1), LiminalEntry { - object: first.clone(), + object: LiminalEntrant::Token(TokenProjection::materialize(first.clone())), name: "First liminal".to_string(), source_id: ObjectId(11), controller: PlayerId(0), @@ -1439,7 +1440,7 @@ fn build_all_direct_numeric_maps_state() -> GameState { ( ObjectId(2), LiminalEntry { - object: second.clone(), + object: LiminalEntrant::Token(TokenProjection::materialize(second.clone())), name: "Second liminal".to_string(), source_id: ObjectId(22), controller: PlayerId(1), diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 08895c900c..28e3da46b7 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1049,6 +1049,7 @@ mod wolverine_fierce_fighter_heal; mod wrenn_and_six_up_to_one_optout; mod yare_extra_blockers; mod yavimaya_enchantress_dynamic_pump; +mod yenna_aura_token_copy; mod yuriko_combat_damage; // Folded in from former top-level tests/*.rs files (each was its own ~130MB diff --git a/crates/engine/tests/integration/yenna_aura_token_copy.rs b/crates/engine/tests/integration/yenna_aura_token_copy.rs new file mode 100644 index 0000000000..2f490f3132 --- /dev/null +++ b/crates/engine/tests/integration/yenna_aura_token_copy.rs @@ -0,0 +1,1543 @@ +//! Issue #5904 — CR 303.4f + CR 303.4g for a token that is a copy of an Aura. +//! +//! A token copy of an Aura (Yenna, Redtooth Regent; Court of Vantress copying a +//! Curse) never passed through the entry-time `attach_to` slot, so it entered +//! unattached and died to the CR 704.5m unattached-Aura state-based action. It +//! must instead choose a host as it enters (CR 303.4f) — or, when there is no +//! legal host at all, not be created at all (CR 303.4g). + +use engine::game::game_object::AttachTarget; +use engine::types::ability::{ + ContinuousModification, ControllerRef, Effect, PtValue, QuantityExpr, TargetFilter, TargetRef, + TypeFilter, TypedFilter, +}; +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +use engine::types::events::GameEvent; +use engine::types::game_state::WaitingFor; +use engine::types::keywords::{Keyword, ProtectionTarget}; +use engine::types::mana::{ManaColor, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::player::PlayerId; +use engine::types::zones::Zone; +use engine::types::ObjectId; + +use engine::game::engine::{apply, EngineError}; +use engine::game::scenario::{GameRunner, GameScenario}; + +const P0: PlayerId = PlayerId(0); +const P1: PlayerId = PlayerId(1); + +// Oracle text taken verbatim from the shipped card data (`client/public/card-data.json`). +const YENNA: &str = + "{2}, {T}: Choose target enchantment you control that doesn't have the same name \ +as another permanent you control. Create a token that's a copy of it, except it isn't legendary. \ +If the token is an Aura, untap Yenna, then scry 2. Activate only as a sorcery."; + +const COOPED_UP: &str = "Enchant creature\n\ +Enchanted creature can't attack or block.\n\ +{2}{W}: Exile enchanted creature."; + +const CURSE_OF_BLOODLETTING: &str = "Enchant player\n\ +If a source would deal damage to enchanted player, it deals double that damage to that player \ +instead."; + +const SIGIL_OF_THE_EMPTY_THRONE: &str = + "Whenever you cast an enchantment spell, create a 4/4 white Angel creature token with flying."; + +/// Every battlefield permanent with `name`, id-sorted. +fn battlefield_named(runner: &GameRunner, name: &str) -> Vec { + let mut ids: Vec = runner + .state() + .objects + .iter() + .filter(|(_, o)| o.zone == Zone::Battlefield && o.name == name) + .map(|(id, _)| *id) + .collect(); + ids.sort(); + ids +} + +/// What a driven activation observed. Recorded rather than asserted inline so +/// each test names the specific claim it is making. +#[derive(Default)] +struct Observed { + host_prompts: Vec<(PlayerId, Vec)>, + saw_scry: bool, + events: Vec, +} + +impl Observed { + fn token_created_ids(&self) -> Vec { + self.events + .iter() + .filter_map(|event| match event { + GameEvent::TokenCreated { object_id, .. } => Some(*object_id), + _ => None, + }) + .collect() + } + + fn entered_battlefield_ids(&self) -> Vec { + self.events + .iter() + .filter_map(|event| match event { + GameEvent::ZoneChanged { + object_id, + to: Zone::Battlefield, + .. + } => Some(*object_id), + _ => None, + }) + .collect() + } +} + +/// What [`drive`] does when a CR 303.4f host prompt opens. +enum HostAnswers<'a> { + /// Answer each prompt from this sequence, in prompt order. + Answer(&'a [TargetRef]), + /// Return with the prompt still open, for tests that inspect or submit to + /// the live window themselves. + HaltAtPrompt, +} + +/// Drive an activation to its next priority window. +/// +/// `targets` is a POOL, not a sequence: each target-selection window is answered +/// with the first pool entry the engine accepts, which keeps a test from +/// depending on the order the engine happens to declare its slots in. `hosts` IS +/// a sequence — CR 303.4f prompt order is part of what several tests assert. +fn drive(runner: &mut GameRunner, targets: &[TargetRef], hosts: HostAnswers<'_>) -> Observed { + let mut observed = Observed::default(); + let mut pool: Vec = targets.to_vec(); + let mut next_host = 0usize; + for _ in 0..60 { + match &runner.state().waiting_for { + WaitingFor::ManaPayment { .. } => { + observed + .events + .extend(runner.act(GameAction::PassPriority).expect("mana").events); + } + WaitingFor::TargetSelection { .. } => { + let accepted = pool.iter().position(|candidate| { + match runner.act(GameAction::ChooseTarget { + target: Some(candidate.clone()), + }) { + Ok(result) => { + observed.events.extend(result.events); + true + } + Err(_) => false, + } + }); + let Some(index) = accepted else { + panic!( + "no supplied target was legal for {:?}", + runner.state().waiting_for + ); + }; + pool.remove(index); + } + WaitingFor::ReturnAsAuraTarget { + player, + legal_targets, + .. + } => { + observed.host_prompts.push((*player, legal_targets.clone())); + let HostAnswers::Answer(hosts) = hosts else { + return observed; + }; + let host = hosts + .get(next_host) + .unwrap_or_else(|| panic!("unexpected host prompt #{next_host}")) + .clone(); + next_host += 1; + observed.events.extend( + runner + .act(GameAction::ChooseTarget { target: Some(host) }) + .expect("choose the token Aura's host") + .events, + ); + } + WaitingFor::ScryChoice { cards, .. } => { + observed.saw_scry = true; + let cards = cards.clone(); + observed.events.extend( + runner + .act(GameAction::SelectCards { cards }) + .expect("scry keep") + .events, + ); + } + WaitingFor::Priority { .. } => { + if runner.state().stack.is_empty() { + break; + } + observed.events.extend( + runner + .act(GameAction::PassPriority) + .expect("resolve") + .events, + ); + } + other => panic!("unexpected window: {other:?}"), + } + } + observed +} + +/// Yenna plus two untapped colorless mana, ready to activate. +fn yenna_scenario() -> (GameScenario, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool( + P0, + vec![ + ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![]), + ], + ); + // Scry needs cards to look at; without them Yenna's rider resolves silently + // and `saw_scry` could never discriminate. + scenario.with_library_top(P0, &["Scry A", "Scry B", "Scry C"]); + let yenna = scenario + .add_creature_from_oracle(P0, "Yenna, Redtooth Regent", 4, 4, YENNA) + .id(); + (scenario, yenna) +} + +fn enchant_creature() -> Keyword { + Keyword::Enchant(TargetFilter::Typed(TypedFilter::new(TypeFilter::Creature))) +} + +fn activate(runner: &mut GameRunner, source: ObjectId) { + runner + .act(GameAction::ActivateAbility { + source_id: source, + ability_index: 0, + }) + .expect("activate"); +} + +/// CR 303.4f: with more than one legal host the token Aura's controller chooses +/// which one it enchants, and the rest of the creating ability still runs. +#[test] +fn multi_host_copy_prompts_and_attaches() { + let (mut scenario, yenna) = yenna_scenario(); + let host_a = scenario.add_creature(P0, "Host A", 2, 2).id(); + let host_b = scenario.add_creature(P0, "Host B", 2, 2).id(); + let aura = scenario + .add_enchantment_from_oracle(P0, "Cooped Up", COOPED_UP) + .with_subtypes(vec!["Aura"]) + .with_keyword(enchant_creature()) + .id(); + + let mut runner = scenario.build(); + { + let s = runner.state_mut(); + s.objects.get_mut(¥na).unwrap().tapped = false; + // CR 704.5m: the copy source must itself be legally attached. + s.objects.get_mut(&aura).unwrap().attached_to = Some(AttachTarget::Object(host_a)); + } + assert_eq!(battlefield_named(&runner, "Cooped Up").len(), 1); + + activate(&mut runner, yenna); + let observed = drive( + &mut runner, + &[TargetRef::Object(aura)], + HostAnswers::Answer(&[TargetRef::Object(host_b)]), + ); + + assert_eq!( + observed.host_prompts.len(), + 1, + "CR 303.4f: with two legal hosts the controller must be asked exactly once" + ); + assert_eq!( + observed.host_prompts[0].0, P0, + "the token's controller asks" + ); + let on_battlefield = battlefield_named(&runner, "Cooped Up"); + assert_eq!( + on_battlefield.len(), + 2, + "CR 303.4f + CR 704.5m: the token copy must survive on the battlefield \ + (got {on_battlefield:?})" + ); + let token = on_battlefield + .iter() + .copied() + .find(|id| *id != aura) + .expect("token copy exists"); + assert_eq!( + runner.state().objects[&token].attached_to, + Some(AttachTarget::Object(host_b)), + "CR 303.4f: attached to the CHOSEN host, not merely to some host" + ); + // CR 608.2c: the host choice must not swallow the rest of Yenna's ability. + // These two are the assertions that flip if the deferral of the + // `LastCreated`-gated rider is reverted. + assert!( + !runner.state().objects[¥na].tapped, + "the Aura-gated untap rider must still resolve after the host choice" + ); + assert!( + observed.saw_scry, + "the Aura-gated scry rider must still resolve after the host choice" + ); +} + +/// CR 303.4f: exactly one legal host means no choice to make — auto-attach, no +/// prompt, and (the reach-guard for the negative) the rider still runs. +#[test] +fn single_host_auto_attaches_without_prompt() { + let (mut scenario, yenna) = yenna_scenario(); + // Yenna is the ONLY creature, so she is the only legal host. + let aura = scenario + .add_enchantment_from_oracle(P0, "Cooped Up", COOPED_UP) + .with_subtypes(vec!["Aura"]) + .with_keyword(enchant_creature()) + .id(); + + let mut runner = scenario.build(); + { + let s = runner.state_mut(); + s.objects.get_mut(¥na).unwrap().tapped = false; + s.objects.get_mut(&aura).unwrap().attached_to = Some(AttachTarget::Object(yenna)); + } + + activate(&mut runner, yenna); + let observed = drive( + &mut runner, + &[TargetRef::Object(aura)], + HostAnswers::Answer(&[]), + ); + + assert!( + observed.host_prompts.is_empty(), + "CR 303.4f: one legal host is not a choice" + ); + let on_battlefield = battlefield_named(&runner, "Cooped Up"); + assert_eq!( + on_battlefield.len(), + 2, + "CR 303.4f: the token copy must auto-attach to the only legal host \ + (got {on_battlefield:?})" + ); + let token = on_battlefield + .iter() + .copied() + .find(|id| *id != aura) + .expect("token copy exists"); + assert_eq!( + runner.state().objects[&token].attached_to, + Some(AttachTarget::Object(yenna)), + "CR 303.4f: attached to the sole legal host" + ); + assert!(observed.saw_scry, "the Aura-gated rider still runs"); +} + +/// A copy driver whose token is controlled by a DIFFERENT player than the copy +/// source's controller, so a controller-scoped enchant ability can be legal for +/// the source and illegal for the token. +/// +/// `p0_creature` is the reach-guard dial: `Some` gives the token exactly one +/// legal host (the positive control), `None` gives it none (CR 303.4g). +fn cross_controller_copy_scenario(p0_creature: Option<&str>) -> (GameRunner, ObjectId, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + + let engine_id = scenario + .add_enchantment_from_oracle(P0, "Copy Engine", "") + .with_ability(Effect::CopyTokenOf { + target: TargetFilter::Typed(TypedFilter::new(TypeFilter::Enchantment)), + owner: TargetFilter::Controller, + source_filter: None, + enters_attacking: false, + tapped: false, + count: QuantityExpr::Fixed { value: 1 }, + extra_keywords: Vec::new(), + additional_modifications: Vec::new(), + }) + .id(); + + let p0_host = p0_creature.map(|name| scenario.add_creature(P0, name, 2, 2).id()); + let p1_host = scenario.add_creature(P1, "Their Ally", 2, 2).id(); + // "Enchant creature you control" — legal for P1's Aura on P1's creature, and + // (with no P0 creature) illegal for every object once the copy is P0's. + let aura = scenario + .add_enchantment_from_oracle(P1, "Loyal Leash", "Enchant creature you control") + .with_subtypes(vec!["Aura"]) + .with_keyword(Keyword::Enchant(TargetFilter::Typed( + TypedFilter::creature().controller(ControllerRef::You), + ))) + .id(); + + let mut runner = scenario.build(); + { + let s = runner.state_mut(); + s.objects.get_mut(&aura).unwrap().attached_to = Some(AttachTarget::Object(p1_host)); + } + (runner, engine_id, p0_host.unwrap_or(aura)) +} + +/// CR 303.4g: "If an Aura is entering the battlefield and there is no legal +/// object or player for it to enchant … If the Aura is a token, it isn't +/// created." Not "created and then swept" — the graveyard assertion is what +/// separates the two. +#[test] +fn no_legal_host_means_the_token_is_not_created() { + let (mut runner, engine_id, _) = cross_controller_copy_scenario(None); + let aura = battlefield_named(&runner, "Loyal Leash")[0]; + let battlefield_before = runner.state().objects.len(); + let next_id_before = runner.state().next_object_id; + // Seed the CR 111.1 anaphora slot with an unrelated earlier effect's token so + // "no token was created" cannot pass merely because the slot was never + // touched. + let stale = ObjectId(9_999); + runner.state_mut().last_created_token_ids = vec![stale]; + + activate(&mut runner, engine_id); + let observed = drive( + &mut runner, + &[TargetRef::Object(aura)], + HostAnswers::Answer(&[]), + ); + + assert!( + observed.host_prompts.is_empty(), + "CR 303.4g: no legal host is not a choice" + ); + assert_eq!( + battlefield_named(&runner, "Loyal Leash"), + vec![aura], + "CR 303.4g: no token copy on the battlefield" + ); + assert_eq!( + runner.state().objects.len(), + battlefield_before, + "CR 303.4g: the token must not remain in `state.objects` at all" + ); + assert!( + observed.token_created_ids().is_empty(), + "CR 303.4g: a token that isn't created emits no `TokenCreated` \ + (got {:?})", + observed.token_created_ids() + ); + let uncreated = ObjectId(next_id_before); + assert!( + !observed.entered_battlefield_ids().contains(&uncreated), + "CR 303.4g: a token that isn't created emits no battlefield `ZoneChanged`" + ); + assert!( + !runner.state().last_created_token_ids.contains(&uncreated), + "CR 303.4g: a token that isn't created is not \"the token created this way\"" + ); + assert!( + !runner.state().last_created_token_ids.contains(&stale), + "CR 603.7: the uncreated entry still republishes THIS batch's (empty) list, \ + so a prior effect's token cannot leak into \"the token created this way\" \ + (got {:?})", + runner.state().last_created_token_ids + ); + // THE DISCRIMINATOR. A naive "create it and let CR 704.5m sweep it" + // implementation passes every assertion above and fails this one. + assert!( + runner.state().players[0].graveyard.is_empty(), + "CR 303.4g: the token is not created, so nothing reaches a graveyard \ + (got {:?})", + runner.state().players[0].graveyard + ); +} + +/// Reach-guard for [`no_legal_host_means_the_token_is_not_created`]: the SAME +/// driver, one legal host added, must create and attach the token. Without this +/// the negative above would also pass if the copy effect never ran at all. +#[test] +fn cross_controller_copy_with_a_legal_host_is_created() { + let (mut runner, engine_id, p0_host) = cross_controller_copy_scenario(Some("Our Ally")); + let aura = battlefield_named(&runner, "Loyal Leash")[0]; + + activate(&mut runner, engine_id); + drive( + &mut runner, + &[TargetRef::Object(aura)], + HostAnswers::Answer(&[]), + ); + + let on_battlefield = battlefield_named(&runner, "Loyal Leash"); + assert_eq!( + on_battlefield.len(), + 2, + "reach-guard: the copy effect really does produce an Aura token here" + ); + let token = on_battlefield + .iter() + .copied() + .find(|id| *id != aura) + .expect("token copy exists"); + assert_eq!( + runner.state().objects[&token].attached_to, + Some(AttachTarget::Object(p0_host)), + "CR 303.4f: the copy's controller-scoped enchant ability binds to ITS controller's creature" + ); +} + +/// CR 303.4f: "the effect … doesn't specify the object **or player** the Aura +/// will enchant". A Curse (enchant player) must offer player hosts. +/// +/// Driven with Yenna rather than Court of Vantress — Court's clause is an upkeep +/// trigger gated on the monarch, and none of that is the behaviour under test. +#[test] +fn player_hosts_are_offered_and_attach() { + let (mut scenario, yenna) = yenna_scenario(); + let curse = scenario + .add_enchantment_from_oracle(P0, "Curse of Bloodletting", CURSE_OF_BLOODLETTING) + .with_subtypes(vec!["Aura", "Curse"]) + .with_keyword(Keyword::Enchant(TargetFilter::Player)) + .id(); + + let mut runner = scenario.build(); + { + let s = runner.state_mut(); + s.objects.get_mut(¥na).unwrap().tapped = false; + s.objects.get_mut(&curse).unwrap().attached_to = Some(AttachTarget::Player(P1)); + } + + activate(&mut runner, yenna); + let observed = drive( + &mut runner, + &[TargetRef::Object(curse)], + HostAnswers::Answer(&[TargetRef::Player(P1)]), + ); + + assert_eq!( + observed.host_prompts.len(), + 1, + "CR 303.4f: two legal players is a choice" + ); + let (chooser, legal) = &observed.host_prompts[0]; + assert_eq!(*chooser, P0); + assert!( + legal.iter().any(|t| matches!(t, TargetRef::Player(_))), + "CR 303.4f: a Curse's legal hosts are PLAYERS (got {legal:?})" + ); + let token = battlefield_named(&runner, "Curse of Bloodletting") + .into_iter() + .find(|id| *id != curse) + .expect("token copy exists"); + assert_eq!( + runner.state().objects[&token].attached_to, + Some(AttachTarget::Player(P1)), + "CR 303.4f: attached to the chosen PLAYER" + ); +} + +/// A copy of a non-Aura enchantment is untouched by CR 303.4f: no prompt, no +/// attachment, and it survives a full state-based-action pass. +#[test] +fn non_aura_enchantment_copy_is_unchanged() { + let (mut scenario, yenna) = yenna_scenario(); + let sigil = scenario + .add_enchantment_from_oracle(P0, "Sigil of the Empty Throne", SIGIL_OF_THE_EMPTY_THRONE) + .id(); + + let mut runner = scenario.build(); + runner.state_mut().objects.get_mut(¥na).unwrap().tapped = false; + + activate(&mut runner, yenna); + let observed = drive( + &mut runner, + &[TargetRef::Object(sigil)], + HostAnswers::Answer(&[]), + ); + + assert!( + observed.host_prompts.is_empty(), + "a non-Aura enchantment copy raises no CR 303.4f prompt" + ); + let on_battlefield = battlefield_named(&runner, "Sigil of the Empty Throne"); + assert_eq!(on_battlefield.len(), 2, "the copy token exists"); + let token = on_battlefield + .into_iter() + .find(|id| *id != sigil) + .expect("token copy exists"); + assert!( + runner.state().objects[&token].attached_to.is_none(), + "a non-Aura is attached to nothing" + ); + + // A full SBA pass must not touch it (CR 704.5m applies to Auras only). + let mut events = Vec::new(); + engine::game::sba::check_state_based_actions(runner.state_mut(), &mut events); + assert_eq!( + runner.state().objects[&token].zone, + Zone::Battlefield, + "CR 704.5m does not sweep a non-Aura" + ); + assert!( + !observed.saw_scry, + "reach-guard: Yenna's Aura-gated rider correctly did NOT run for a non-Aura copy" + ); +} + +/// CR 303.4f applies only when "the effect putting it onto the battlefield +/// doesn't specify the object … the Aura will enchant". A Role token names its +/// host, so it must not be prompted for one. +/// +/// Scope, stated precisely so this is not read as more than it is: this drives +/// `apply_create_token`'s `spec.attach_to` route, NOT the liminal +/// `entry.attach_to.is_none()` gate this change added. That gate cannot be +/// reached today — both production `LiminalEntry` constructors (`meld.rs`, +/// `token_copy.rs`) pass `attach_to: None` — so it is kept as CR 303.4f's own +/// precondition rather than as covered code. What this test does guarantee is +/// the regression that matters: the new consult must not start prompting for +/// tokens whose host the effect already names. +#[test] +fn effect_specified_host_raises_no_prompt() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + // Two creatures, so a CR 303.4f prompt WOULD have something to ask about. + let host_a = scenario.add_creature(P0, "Host A", 2, 2).id(); + let _host_b = scenario.add_creature(P0, "Host B", 2, 2).id(); + // Lord Skitter's Blessing's shipped `Effect::Token`, as an activated ability. + let maker = scenario + .add_enchantment_from_oracle(P0, "Role Maker", "") + .with_ability(Effect::Token { + name: "Wicked Role".to_string(), + power: PtValue::Fixed(0), + toughness: PtValue::Fixed(0), + types: vec![ + "Enchantment".to_string(), + "Aura".to_string(), + "Role".to_string(), + ], + colors: Vec::new(), + keywords: Vec::new(), + tapped: false, + count: QuantityExpr::Fixed { value: 1 }, + owner: TargetFilter::Controller, + attach_to: Some(TargetFilter::Typed( + TypedFilter::creature().controller(ControllerRef::You), + )), + enters_attacking: false, + supertypes: Vec::new(), + static_abilities: Vec::new(), + enter_with_counters: Vec::new(), + }) + .id(); + + let mut runner = scenario.build(); + activate(&mut runner, maker); + let observed = drive( + &mut runner, + &[TargetRef::Object(host_a)], + HostAnswers::Answer(&[]), + ); + + assert!( + observed.host_prompts.is_empty(), + "CR 303.4f does not apply when the effect specifies the host" + ); + let token = battlefield_named(&runner, "Wicked Role"); + assert_eq!(token.len(), 1, "reach-guard: the Role token was created"); + assert!( + runner.state().objects[&token[0]].attached_to.is_some(), + "the effect-specified host is bound" + ); +} + +/// CR 303.4d: an Aura that's also a creature can't enchant anything — that is a +/// state-based action (CR 704.5m via CR 303.4d), NOT CR 303.4g's "isn't +/// created". The token IS created, `TokenCreated` fires, and it then dies. +/// +/// The *source* stays a plain, legally attached Aura (an Aura-creature on the +/// battlefield would be swept by that same SBA before the copy could resolve); +/// the CR 707.9 "except" exception is what makes the TOKEN the Aura-creature. +#[test] +fn aura_creature_copy_is_created_then_swept() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let host = scenario.add_creature(P0, "Host", 2, 2).id(); + let maker = scenario + .add_enchantment_from_oracle(P0, "Animating Copier", "") + .with_ability(Effect::CopyTokenOf { + target: TargetFilter::Typed(TypedFilter::new(TypeFilter::Enchantment)), + owner: TargetFilter::Controller, + source_filter: None, + enters_attacking: false, + tapped: false, + count: QuantityExpr::Fixed { value: 1 }, + extra_keywords: Vec::new(), + additional_modifications: vec![ContinuousModification::AddType { + core_type: CoreType::Creature, + }], + }) + .id(); + let aura = scenario + .add_enchantment_from_oracle(P0, "Cooped Up", COOPED_UP) + .with_subtypes(vec!["Aura"]) + .with_keyword(enchant_creature()) + .id(); + + let mut runner = scenario.build(); + runner + .state_mut() + .objects + .get_mut(&aura) + .unwrap() + .attached_to = Some(AttachTarget::Object(host)); + + activate(&mut runner, maker); + let observed = drive( + &mut runner, + &[TargetRef::Object(aura)], + HostAnswers::Answer(&[]), + ); + + assert!( + observed.host_prompts.is_empty(), + "CR 303.4d: an Aura that's also a creature enchants nothing, so no host is chosen" + ); + let created = observed.token_created_ids(); + assert_eq!( + created.len(), + 1, + "CR 303.4d is not CR 303.4g: the token IS created and `TokenCreated` fires \ + (got {created:?})" + ); + let token = created[0]; + assert_eq!( + battlefield_named(&runner, "Cooped Up"), + vec![aura], + "CR 704.5m via CR 303.4d: the created Aura-creature is then swept off the battlefield" + ); + assert!( + runner.state().objects.get(&token).is_none(), + "CR 111.7: a token that leaves the battlefield ceases to exist" + ); +} + +/// CR 303.4f binds the host choice to ONE player — the one the Aura is entering +/// under the control of. No other player may answer it. +/// +/// That the bound player is the TOKEN's controller (rather than the active or +/// activating player) is asserted directly against the decision seam by +/// `zone_pipeline::tests::entering_aura_hosts_reports_the_objects_own_controller`; +/// this test covers the submission gate the prompt then enforces. +#[test] +fn only_the_bound_chooser_may_answer_the_host_prompt() { + let (mut scenario, yenna) = yenna_scenario(); + let host_a = scenario.add_creature(P0, "Host A", 2, 2).id(); + let _host_b = scenario.add_creature(P0, "Host B", 2, 2).id(); + let aura = scenario + .add_enchantment_from_oracle(P0, "Cooped Up", COOPED_UP) + .with_subtypes(vec!["Aura"]) + .with_keyword(enchant_creature()) + .id(); + + let mut runner = scenario.build(); + { + let s = runner.state_mut(); + s.objects.get_mut(¥na).unwrap().tapped = false; + s.objects.get_mut(&aura).unwrap().attached_to = Some(AttachTarget::Object(host_a)); + } + + activate(&mut runner, yenna); + let observed = drive( + &mut runner, + &[TargetRef::Object(aura)], + HostAnswers::HaltAtPrompt, + ); + assert_eq!(observed.host_prompts.len(), 1, "the host prompt opened"); + assert_eq!( + observed.host_prompts[0].0, P0, + "CR 303.4f: bound to the token's controller" + ); + + let rejected = apply( + runner.state_mut(), + P1, + GameAction::ChooseTarget { + target: Some(TargetRef::Object(host_a)), + }, + ); + assert!( + matches!(rejected, Err(EngineError::WrongPlayer)), + "a player who is not the bound chooser must not be able to answer \ + (got {rejected:?})" + ); + assert!( + matches!( + runner.state().waiting_for, + WaitingFor::ReturnAsAuraTarget { .. } + ), + "reach-guard: the rejected submission left the window open rather than \ + being swallowed" + ); + + apply( + runner.state_mut(), + P0, + GameAction::ChooseTarget { + target: Some(TargetRef::Object(host_a)), + }, + ) + .expect("the bound chooser may answer"); + let token = battlefield_named(&runner, "Cooped Up") + .into_iter() + .find(|id| *id != aura) + .expect("token copy exists"); + assert_eq!( + runner.state().objects[&token].attached_to, + Some(AttachTarget::Object(host_a)) + ); +} + +/// CR 303.4f: an effect that creates two Aura tokens asks once per ENTERING +/// token — the rule is written per-Aura, not per-effect — and each answer binds +/// its OWN token. Also the proof that `remaining_count` survives the pause and +/// that the second entry is not overwritten by the first. +#[test] +fn two_copies_each_choose_their_own_host() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let engine_id = scenario + .add_enchantment_from_oracle(P0, "Copy Engine", "") + .with_ability(Effect::CopyTokenOf { + target: TargetFilter::Typed(TypedFilter::new(TypeFilter::Enchantment)), + owner: TargetFilter::Controller, + source_filter: None, + enters_attacking: false, + tapped: false, + count: QuantityExpr::Fixed { value: 2 }, + extra_keywords: Vec::new(), + additional_modifications: Vec::new(), + }) + .id(); + let host_a = scenario.add_creature(P0, "Host A", 2, 2).id(); + let host_b = scenario.add_creature(P0, "Host B", 2, 2).id(); + let aura = scenario + .add_enchantment_from_oracle(P0, "Cooped Up", COOPED_UP) + .with_subtypes(vec!["Aura"]) + .with_keyword(enchant_creature()) + .id(); + + let mut runner = scenario.build(); + runner + .state_mut() + .objects + .get_mut(&aura) + .unwrap() + .attached_to = Some(AttachTarget::Object(host_a)); + + activate(&mut runner, engine_id); + let observed = drive( + &mut runner, + &[TargetRef::Object(aura)], + HostAnswers::Answer(&[TargetRef::Object(host_a), TargetRef::Object(host_b)]), + ); + + assert_eq!( + observed.host_prompts.len(), + 2, + "CR 303.4f is asked once per entering Aura token" + ); + let tokens: Vec = battlefield_named(&runner, "Cooped Up") + .into_iter() + .filter(|id| *id != aura) + .collect(); + assert_eq!(tokens.len(), 2, "both tokens were created (got {tokens:?})"); + let hosts: Vec> = tokens + .iter() + .map(|id| runner.state().objects[id].attached_to) + .collect(); + assert_eq!( + hosts, + vec![ + Some(AttachTarget::Object(host_a)), + Some(AttachTarget::Object(host_b)) + ], + "each token attaches to the host chosen for IT" + ); + assert_eq!( + runner.state().last_created_token_ids, + tokens, + "CR 111.1: both tokens are \"the tokens created this way\"" + ); +} + +/// CR 704.4: state-based actions pay no attention to what happens during the +/// resolution of a spell or ability. An open CR 303.4f host prompt is +/// mid-resolution, so the token must not be swept while it is open — and an +/// unrelated pending SBA (a player at 0 life) must still process once it closes. +#[test] +fn an_open_host_prompt_does_not_expose_the_token_to_sbas() { + let (mut scenario, yenna) = yenna_scenario(); + let host_a = scenario.add_creature(P0, "Host A", 2, 2).id(); + let _host_b = scenario.add_creature(P0, "Host B", 2, 2).id(); + let aura = scenario + .add_enchantment_from_oracle(P0, "Cooped Up", COOPED_UP) + .with_subtypes(vec!["Aura"]) + .with_keyword(enchant_creature()) + .id(); + + let mut runner = scenario.build(); + { + let s = runner.state_mut(); + s.objects.get_mut(¥na).unwrap().tapped = false; + s.objects.get_mut(&aura).unwrap().attached_to = Some(AttachTarget::Object(host_a)); + } + + activate(&mut runner, yenna); + let observed = drive( + &mut runner, + &[TargetRef::Object(aura)], + HostAnswers::HaltAtPrompt, + ); + assert_eq!(observed.host_prompts.len(), 1, "the host prompt opened"); + let WaitingFor::ReturnAsAuraTarget { returned_id, .. } = runner.state().waiting_for.clone() + else { + panic!( + "expected the CR 303.4f host prompt to still be open, got {:?}", + runner.state().waiting_for + ); + }; + + // NON-VACUITY: while the prompt is open the token is on the battlefield and + // unattached — i.e. it is right now a live CR 704.5m candidate, which is what + // makes the survival assertion below a claim rather than a tautology. + assert_eq!(runner.state().objects[&returned_id].zone, Zone::Battlefield); + assert!(runner.state().objects[&returned_id].attached_to.is_none()); + + let mut events = Vec::new(); + engine::game::sba::check_state_based_actions(runner.state_mut(), &mut events); + assert_eq!( + runner.state().objects[&returned_id].zone, + Zone::Battlefield, + "CR 704.4 + CR 704.5m: an unattached Aura that is mid-entry is not swept" + ); + + // An unrelated pending loss must still process once resolution finishes. + // Set here rather than at scenario build time: a player at 0 life on the + // first priority check ends the game before the ability ever resolves. + runner.state_mut().players[1].life = 0; + runner + .act(GameAction::ChooseTarget { + target: Some(TargetRef::Object(host_a)), + }) + .expect("answer the host prompt"); + assert_eq!( + runner.state().objects[&returned_id].attached_to, + Some(AttachTarget::Object(host_a)), + "the token was still on the battlefield to be attached when the prompt closed" + ); + assert!( + runner.state().players[1].is_eliminated, + "CR 704.5a: the 0-life loss still processes once resolution finishes" + ); +} + +// ── The NON-LIMINAL copy path ──────────────────────────────────────────────── +// +// `token_copy.rs` takes a second, separate route whenever the copy has entry +// counters to seed (CR 306.5b copied loyalty, or a CR 614.1c "enters with N +// counters" self-replacement on the copied card) or a modification that cannot +// be folded before entry. Fylgja is an Aura with exactly such a +// self-replacement, so copying it drives that route rather than the liminal one. + +// Oracle text taken verbatim from the shipped card data. +const FYLGJA: &str = "Enchant creature\n\ +This Aura enters with four healing counters on it.\n\ +Remove a healing counter from this Aura: Prevent the next 1 damage that would be dealt to \ +enchanted creature this turn.\n\ +{2}{W}: Put a healing counter on this Aura."; + +/// P0 copies P1's Fylgja. The source's `enchant creature you control` binds to +/// P1's own creature (so the source stays legally attached and alive), while the +/// COPY — P0's — sees only P0's `hosts` creatures. `hosts == 0` is therefore a +/// genuine CR 303.4g case with an untouched P0 graveyard. +fn fylgja_copy_scenario(hosts: usize) -> (GameRunner, ObjectId, ObjectId, Vec) { + fylgja_copy_scenario_with(hosts, 1, Vec::new()) +} + +/// [`fylgja_copy_scenario`] with an explicit copy `count` and CR 707.9 "except" +/// body, for the two seam properties the 1×/no-exception shape cannot reach: a +/// multi-token non-liminal batch, and an exception that changes what the entrant +/// IS before the CR 303.4f/g consult reads it. +fn fylgja_copy_scenario_with( + hosts: usize, + count: i32, + additional_modifications: Vec, +) -> (GameRunner, ObjectId, ObjectId, Vec) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let maker = scenario + .add_enchantment_from_oracle(P0, "Copy Engine", "") + .with_ability(Effect::CopyTokenOf { + target: TargetFilter::Typed(TypedFilter::new(TypeFilter::Enchantment)), + owner: TargetFilter::Controller, + source_filter: None, + enters_attacking: false, + tapped: false, + count: QuantityExpr::Fixed { value: count }, + extra_keywords: Vec::new(), + additional_modifications, + }) + .id(); + let host_ids: Vec = (0..hosts) + .map(|i| scenario.add_creature(P0, &format!("Host {i}"), 2, 2).id()) + .collect(); + let their_host = scenario.add_creature(P1, "Their Ally", 2, 2).id(); + let aura = scenario + .add_enchantment_from_oracle(P1, "Fylgja", FYLGJA) + .with_subtypes(vec!["Aura"]) + .with_keyword(Keyword::Enchant(TargetFilter::Typed( + TypedFilter::creature().controller(ControllerRef::You), + ))) + .id(); + + let mut runner = scenario.build(); + runner + .state_mut() + .objects + .get_mut(&aura) + .unwrap() + .attached_to = Some(AttachTarget::Object(their_host)); + (runner, maker, aura, host_ids) +} + +/// CR 303.4f + CR 614.1c on the non-liminal copy path: the host choice pauses the +/// entry BEFORE its entry counters are seeded, so the resume must still seed +/// them. +/// +/// The counter assertion is the discriminating one: it is exactly what fails if +/// this pause is resumed through `ApplyCopyTokenModificationsAndFinalize` +/// (whose handler skips `etb_counters`) instead of through +/// `ContinueCopyTokenEntryAfterAuraHost`. +#[test] +fn non_liminal_copy_prompts_and_still_seeds_entry_counters() { + let (mut runner, maker, aura, hosts) = fylgja_copy_scenario(2); + // Reach-guard for the whole fixture: the copy SOURCE really does carry the + // CR 614.1c self-replacement that forces the non-liminal route. + assert_eq!( + runner.state().objects[&aura].counters.values().sum::(), + 0, + "fixture: the pre-existing source was placed directly and never entered, \ + so only the TOKEN's entry can produce counters" + ); + + activate(&mut runner, maker); + let observed = drive( + &mut runner, + &[TargetRef::Object(aura)], + HostAnswers::Answer(&[TargetRef::Object(hosts[1])]), + ); + + assert_eq!( + observed.host_prompts.len(), + 1, + "CR 303.4f applies on the non-liminal copy path too" + ); + let token = battlefield_named(&runner, "Fylgja") + .into_iter() + .find(|id| *id != aura) + .expect("token copy exists"); + assert_eq!( + runner.state().objects[&token].attached_to, + Some(AttachTarget::Object(hosts[1])), + "CR 303.4f: attached to the chosen host" + ); + assert_eq!( + runner.state().objects[&token] + .counters + .values() + .sum::(), + 4, + "CR 614.1c: the entry counters parked behind the host choice must still be \ + seeded on resume (got {:?})", + runner.state().objects[&token].counters + ); + assert!( + !observed.token_created_ids().is_empty(), + "CR 111.1: the token's entry events fire after the pause" + ); +} + +/// CR 303.4g on the non-liminal copy path: same verdict, same discriminator. +#[test] +fn non_liminal_copy_with_no_legal_host_is_not_created() { + let (mut runner, maker, aura, _) = fylgja_copy_scenario(0); + let objects_before = runner.state().objects.len(); + + activate(&mut runner, maker); + let observed = drive( + &mut runner, + &[TargetRef::Object(aura)], + HostAnswers::Answer(&[]), + ); + + assert!(observed.host_prompts.is_empty()); + assert!( + observed.token_created_ids().is_empty(), + "CR 303.4g: no `TokenCreated` for a token that isn't created" + ); + assert_eq!( + runner.state().objects.len(), + objects_before, + "CR 303.4g: the token must not remain in `state.objects`" + ); + assert!( + runner.state().players[0].graveyard.is_empty(), + "CR 303.4g: not created, so nothing reaches a graveyard (got {:?})", + runner.state().players[0].graveyard + ); + assert_eq!( + battlefield_named(&runner, "Fylgja"), + vec![aura], + "reach-guard: the copy SOURCE is still legally attached, so the copy effect \ + really had something to copy" + ); +} + +/// CR 707.9b + CR 303.4d on the NON-LIMINAL copy path: the CR 303.4f/g consult +/// must read the entrant as it will exist after the copy exceptions. +/// +/// The two copy seams apply CR 707.9 exceptions at different points — the liminal +/// one folds them into the copiable values before entry, the non-liminal one +/// defers them to `apply_token_modifications` after the birth — so only a +/// projection makes them agree about what the entrant is. +/// +/// Fixture reachability, stated because the sibling `aura_creature_copy_is_ +/// created_then_swept` looks identical and is NOT this test: that one copies +/// Cooped Up, whose only modification (`AddType`) is liminal-immediate and which +/// has no entry counters, so it takes the LIMINAL seam. Fylgja carries a +/// CR 614.1c "enters with four healing counters" self-replacement, which forces +/// the non-liminal route regardless of the modification. +/// +/// The revert-failing assertion is `created.len() == 1`. Reading the stored +/// object instead of the projection sees a plain Aura with `enchant creature you +/// control`, finds zero P0 creatures, and takes CR 303.4g — the token is silently +/// never created. +#[test] +fn non_liminal_copy_reads_the_entrant_after_its_copy_exceptions() { + let (mut runner, maker, aura, _) = fylgja_copy_scenario_with( + 0, + 1, + vec![ContinuousModification::AddType { + core_type: CoreType::Creature, + }], + ); + // Reach-guard: the copy SOURCE really carries the CR 614.1c self-replacement + // that forces the non-liminal route (it was placed directly, so it has no + // counters of its own — only an ENTRY can produce them). + assert_eq!( + runner.state().objects[&aura].counters.values().sum::(), + 0, + "fixture: only the token's entry can seed counters" + ); + + activate(&mut runner, maker); + let observed = drive( + &mut runner, + &[TargetRef::Object(aura)], + HostAnswers::Answer(&[]), + ); + + assert!( + observed.host_prompts.is_empty(), + "CR 303.4d: an Aura that's also a creature enchants nothing, so no host is chosen" + ); + let created = observed.token_created_ids(); + assert_eq!( + created.len(), + 1, + "CR 303.4d is not CR 303.4g: with the `AddType` exception read, the token IS \ + created even though no legal host exists for the unmodified body (got {created:?})" + ); + assert_eq!( + battlefield_named(&runner, "Fylgja"), + vec![aura], + "CR 704.5m via CR 303.4d: the created Aura-creature is then swept, exactly as \ + on the liminal seam" + ); +} + +/// CR 111.1 + CR 608.2c on the NON-LIMINAL copy path: a multi-token batch whose +/// first token pauses on a CR 303.4f host prompt must still publish BOTH tokens +/// as "the tokens created this way". +/// +/// `two_copies_each_choose_their_own_host` pins the same property on the liminal +/// seam. This is its non-liminal twin: the nested pause assigns +/// `state.last_created_token_ids = []` and the first token survives only inside +/// `pending.created_ids`, one frame-lifetime assumption away from being dropped +/// from the anaphor. +/// +/// The revert-failing assertion is the two-element `created` list together with +/// both tokens being attached to distinct hosts. +#[test] +fn two_non_liminal_copies_each_choose_their_own_host() { + let (mut runner, maker, aura, hosts) = fylgja_copy_scenario_with(2, 2, Vec::new()); + + activate(&mut runner, maker); + let observed = drive( + &mut runner, + &[TargetRef::Object(aura)], + HostAnswers::Answer(&[TargetRef::Object(hosts[0]), TargetRef::Object(hosts[1])]), + ); + + assert_eq!( + observed.host_prompts.len(), + 2, + "CR 303.4f: each token in the batch chooses its own host" + ); + let created = observed.token_created_ids(); + assert_eq!( + created.len(), + 2, + "CR 111.1: both tokens of the batch are created across the nested pause \ + (got {created:?})" + ); + let tokens: Vec = battlefield_named(&runner, "Fylgja") + .into_iter() + .filter(|id| *id != aura) + .collect(); + assert_eq!(tokens.len(), 2, "both token copies are on the battlefield"); + let mut attached: Vec> = tokens + .iter() + .map(|id| runner.state().objects[id].attached_to) + .collect(); + attached.sort_by_key(|target| match target { + Some(AttachTarget::Object(id)) => id.0, + _ => u64::MAX, + }); + assert_eq!( + attached, + vec![ + Some(AttachTarget::Object(hosts[0])), + Some(AttachTarget::Object(hosts[1])), + ], + "CR 303.4f: each token is attached to the host its own prompt chose" + ); + for token in &tokens { + assert_eq!( + runner.state().objects[token].counters.values().sum::(), + 4, + "CR 614.1c: every token in the batch still seeds its entry counters" + ); + } +} + +// ── The entering-Aura ATTACHMENT AUTHORITY ─────────────────────────────────── +// +// The decide half (`entering_aura_hosts_projected`) and the act half +// (`apply_entering_aura_hosts` / the `ReturnAsAuraTarget` resume) must judge the +// SAME object. On the non-liminal copy path they were derived separately: the +// decide half read the CR 707.9 projection, the act half re-derived CR 701.3a +// legality from `state.objects[token]`, which on that path still holds the +// PRE-exception body until `apply_token_modifications` runs. +// +// A colour exception is the sharpest instrument for that split. The fixtures +// below copy a WHITE Aura with an "except it's blue" exception, over hosts whose +// CR 702.16c protection is keyed to the colour on ONE side of the exception: +// +// * protection from WHITE → illegal for the source, LEGAL for the entrant +// * protection from BLUE → legal for the source, ILLEGAL for the entrant +// +// so the fixture discriminates in both directions at once. It is not enough for +// the act half to skip its legality check — a "trust the offered list" fix would +// pass the first arm and still be wrong; it has to check against the entrant. + +/// CR 707.9b: "except it's blue" — the exception under test. +fn recolored_blue() -> ContinuousModification { + ContinuousModification::SetColor { + colors: vec![ManaColor::Blue], + } +} + +/// [`fylgja_copy_scenario_with`]'s colour-exception sibling: P0 copies P1's +/// **white** Fylgja with an "except it's blue" exception, and each of P0's +/// candidate hosts carries protection from the colour named in +/// `host_protections`. +/// +/// Fylgja is retained deliberately — its verbatim CR 614.1c "enters with four +/// healing counters" self-replacement is what forces the NON-LIMINAL copy seam, +/// which is the seam whose two halves disagreed. The `Enchant` keyword is +/// overridden the same way [`fylgja_copy_scenario_with`] overrides it, so the +/// copy's hosts are P0's creatures rather than P1's. +fn recolored_fylgja_scenario( + host_protections: &[ManaColor], +) -> (GameRunner, ObjectId, ObjectId, Vec) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let maker = scenario + .add_enchantment_from_oracle(P0, "Copy Engine", "") + .with_ability(Effect::CopyTokenOf { + target: TargetFilter::Typed(TypedFilter::new(TypeFilter::Enchantment)), + owner: TargetFilter::Controller, + source_filter: None, + enters_attacking: false, + tapped: false, + count: QuantityExpr::Fixed { value: 1 }, + extra_keywords: Vec::new(), + additional_modifications: vec![recolored_blue()], + }) + .id(); + let host_ids: Vec = host_protections + .iter() + .enumerate() + .map(|(i, color)| { + scenario + .add_creature(P0, &format!("Host {i}"), 2, 2) + .with_keyword(Keyword::Protection(ProtectionTarget::Color(*color))) + .id() + }) + .collect(); + let their_host = scenario.add_creature(P1, "Their Ally", 2, 2).id(); + let aura = scenario + .add_enchantment_from_oracle(P1, "Fylgja", FYLGJA) + .with_subtypes(vec!["Aura"]) + .with_keyword(Keyword::Enchant(TargetFilter::Typed( + TypedFilter::creature().controller(ControllerRef::You), + ))) + .id(); + + let mut runner = scenario.build(); + { + let source = runner.state_mut().objects.get_mut(&aura).unwrap(); + source.attached_to = Some(AttachTarget::Object(their_host)); + // CR 707.2 + CR 105.2a: `intrinsic_copiable_values` reads `base_color`, + // so this is the colour the copy is made from. `color` is set alongside + // it because that is what the CR 702.16c gate reads on the STORED token + // body — i.e. the value the act half wrongly judged against. + source.base_color = vec![ManaColor::White]; + source.color = vec![ManaColor::White]; + } + (runner, maker, aura, host_ids) +} + +/// The token copy of Fylgja on the battlefield, and its host. +fn fylgja_token(runner: &GameRunner, source: ObjectId) -> Option<(ObjectId, Option)> { + battlefield_named(runner, "Fylgja") + .into_iter() + .find(|id| *id != source) + .map(|id| (id, runner.state().objects[&id].attached_to)) +} + +/// Reach-guard shared by the colour-exception tests: the fixture really is a +/// WHITE source, and the entrant really did come out BLUE. Without both, a green +/// assertion below would prove nothing about which body the act half read. +fn assert_color_exception_landed(runner: &GameRunner, source: ObjectId, token: ObjectId) { + assert_eq!( + runner.state().objects[&source].color, + vec![ManaColor::White], + "fixture: the copy SOURCE is white, so protection-from-white is what the \ + pre-exception body would trip" + ); + assert_eq!( + runner.state().objects[&token].color, + vec![ManaColor::Blue], + "CR 707.9b: the copy exception really recoloured the entrant (got {:?})", + runner.state().objects[&token].color + ); +} + +/// CR 303.4f + CR 701.3b + CR 702.16c on the AUTO-ATTACH half: with exactly one +/// host legal for the entrant, the token must end up attached to it. +/// +/// `Host 0` has protection from BLUE (legal for the white source, illegal for the +/// blue entrant); `Host 1` has protection from WHITE (illegal for the source, +/// legal for the entrant). CR 303.4f's "legal … according to the Aura" makes +/// `Host 1` the sole legal host. +/// +/// The revert-failing assertion is `attached == Some(Object(hosts[1]))`. With the +/// act half re-deriving legality from the stored PRE-exception (white) body, +/// `attach_to` judges `Host 1` protected and CR 701.3b makes the attach a silent +/// no-op — the token enters unattached and CR 704.5m sweeps it, so the assertion +/// fails on `None` (or on the token being absent entirely). +#[test] +fn auto_attached_copy_uses_the_entrant_after_its_color_exception() { + let (mut runner, maker, aura, hosts) = + recolored_fylgja_scenario(&[ManaColor::Blue, ManaColor::White]); + + activate(&mut runner, maker); + let observed = drive( + &mut runner, + &[TargetRef::Object(aura)], + HostAnswers::Answer(&[]), + ); + + assert!( + observed.host_prompts.is_empty(), + "CR 303.4f: exactly one host is legal for the entrant, so there is nothing \ + to ask — this test is pinned to the AUTO-attach half" + ); + let (token, attached) = fylgja_token(&runner, aura).unwrap_or_else(|| { + panic!( + "CR 303.4f + CR 704.5m: the token copy must survive on the battlefield, \ + attached to the host chosen for the post-exception entrant" + ) + }); + assert_color_exception_landed(&runner, aura, token); + assert_eq!( + attached, + Some(AttachTarget::Object(hosts[1])), + "CR 701.3b: the act half must judge the ENTRANT (blue), so the \ + protection-from-white host is legal and the protection-from-blue host is not" + ); +} + +/// The PLAYER-host mirror, driven through the same production pipeline: a Curse +/// -class copy whose colour exception lands, chosen host answered through +/// `GameAction::ChooseTarget`, and the token attached to the chosen player. +/// +/// HONEST SCOPE — this one is a routing / non-regression test, not a +/// revert-discriminating one, and the reason is a property of the card pool +/// rather than of the fix. `attach_to_player`'s CR 303.4i gate reads exactly one +/// projection-sensitive input: `player_protection_from_object`. At the PLAYER +/// level that resolver implements `Everything`, `FromPlayer` and `ChosenCardType` +/// and is deliberately inert for `Color` — no card in the pool grants a player +/// protection from a colour (the seven `PlayerProtection` cards are Absolute +/// Virtue, Noble Heritage, Perch Protection, Runed Halo, Teferi's Protection, +/// The One Ring, The Stasis Coffin), so a colour exception cannot flip a player +/// host's legality. The type-keyed quality that IS live can only get MORE +/// restrictive under the copy exceptions the parser produces. The player half of +/// the act-half gate is therefore pinned at the seam instead, by +/// `zone_pipeline::entering_aura_attachment_tests:: +/// player_host_attach_uses_the_supplied_entrant`. +#[test] +fn chosen_player_host_resume_survives_the_color_exception() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let maker = scenario + .add_enchantment_from_oracle(P0, "Copy Engine", "") + .with_ability(Effect::CopyTokenOf { + target: TargetFilter::Typed(TypedFilter::new(TypeFilter::Enchantment)), + owner: TargetFilter::Controller, + source_filter: None, + enters_attacking: false, + tapped: false, + count: QuantityExpr::Fixed { value: 1 }, + extra_keywords: Vec::new(), + additional_modifications: vec![recolored_blue()], + }) + .id(); + // Fylgja's verbatim CR 614.1c "enters with four healing counters" + // self-replacement is what selects the NON-LIMINAL copy seam — the seam under + // test. Only the `Enchant` keyword is overridden, to the player-scoped filter + // a Curse carries, exactly as `fylgja_copy_scenario_with` overrides it to a + // controller-scoped creature filter. + let aura = scenario + .add_enchantment_from_oracle(P1, "Fylgja", FYLGJA) + .with_subtypes(vec!["Aura", "Curse"]) + .with_keyword(Keyword::Enchant(TargetFilter::Player)) + .id(); + + let mut runner = scenario.build(); + { + let source = runner.state_mut().objects.get_mut(&aura).unwrap(); + source.attached_to = Some(AttachTarget::Player(P0)); + source.base_color = vec![ManaColor::White]; + source.color = vec![ManaColor::White]; + } + + activate(&mut runner, maker); + let observed = drive( + &mut runner, + &[TargetRef::Object(aura)], + HostAnswers::Answer(&[TargetRef::Player(P1)]), + ); + + assert_eq!( + observed.host_prompts.len(), + 1, + "CR 303.4f: two legal players is a choice" + ); + assert!( + observed.host_prompts[0] + .1 + .iter() + .all(|target| matches!(target, TargetRef::Player(_))), + "CR 303.4f: a Curse's legal hosts are PLAYERS (got {:?})", + observed.host_prompts[0].1 + ); + let (token, attached) = fylgja_token(&runner, aura).unwrap_or_else(|| { + panic!("CR 303.4f + CR 704.5m: the Curse token copy must survive its host choice") + }); + assert_color_exception_landed(&runner, aura, token); + assert_eq!( + attached, + Some(AttachTarget::Player(P1)), + "CR 303.4f: the player-host resume attaches to the CHOSEN player" + ); + assert!( + runner.state().entering_aura_authority.is_none(), + "the parked entering-Aura authority is spent by the resume, never left behind" + ); +} + +/// CR 303.4f on the CHOICE half: the `WaitingFor::ReturnAsAuraTarget` resume path +/// attaches through the same authority. +/// +/// Both hosts carry protection from WHITE, so both are legal for the blue entrant +/// and neither is legal for the white source — the controller is asked, and the +/// answer must actually attach. +/// +/// This is the arm the auto-attach tests cannot reach: the choice returns to the +/// event loop, so the entrant has to survive a pause. The revert-failing +/// assertion is `attached == Some(Object(hosts[1]))`; with the resume arm calling +/// the unprojected `attach_to`, the stored white body is protected against by +/// BOTH offered hosts, the attach no-ops (CR 701.3b) and CR 704.5m sweeps the +/// token the player just chose a host for. +#[test] +fn chosen_host_resume_uses_the_entrant_after_its_color_exception() { + let (mut runner, maker, aura, hosts) = + recolored_fylgja_scenario(&[ManaColor::White, ManaColor::White]); + + activate(&mut runner, maker); + let observed = drive( + &mut runner, + &[TargetRef::Object(aura)], + HostAnswers::Answer(&[TargetRef::Object(hosts[1])]), + ); + + assert_eq!( + observed.host_prompts.len(), + 1, + "CR 303.4f: two hosts are legal for the entrant, so this test is pinned to \ + the CHOICE half" + ); + assert_eq!( + observed.host_prompts[0].1.len(), + 2, + "CR 702.16c: both protection-from-white hosts are legal for the BLUE entrant \ + (got {:?})", + observed.host_prompts[0].1 + ); + let (token, attached) = fylgja_token(&runner, aura).unwrap_or_else(|| { + panic!( + "CR 303.4f + CR 704.5m: the token must survive the host choice it was \ + legally offered" + ) + }); + assert_color_exception_landed(&runner, aura, token); + assert_eq!( + attached, + Some(AttachTarget::Object(hosts[1])), + "CR 303.4f: the resume must attach to the CHOSEN host, judged against the \ + entrant the choice was offered for" + ); + assert_eq!( + runner.state().objects[&token] + .counters + .values() + .sum::(), + 4, + "CR 614.1c: the rest of the parked entry tail still runs after the resume" + ); + assert!( + runner.state().entering_aura_authority.is_none(), + "the parked entering-Aura authority is spent by the resume, never left behind" + ); +}