From 10c5dd8aa75f6498bbf6cac589ccda3f5b802800 Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:13:03 +0200 Subject: [PATCH 1/7] fix(engine): attach token copies of Auras to a host (CR 303.4f) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A token that is a copy of an Aura entered unattached and was swept by the CR 704.5m SBA before the player saw it — no host prompt, no copy. The CR 303.4f consult now runs on both copy-token entry paths, CR 303.4g un-creates a token with no legal host, and a `LastCreated`-gated rider no longer resolves eagerly mid-prompt. Fixes #5904. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/effects/counters.rs | 14 +- crates/engine/src/game/effects/mod.rs | 104 +- crates/engine/src/game/effects/token.rs | 173 ++- crates/engine/src/game/effects/token_copy.rs | 444 +++++-- crates/engine/src/game/engine.rs | 35 +- crates/engine/src/game/engine_replacement.rs | 20 +- crates/engine/src/game/filter.rs | 19 + crates/engine/src/game/sba.rs | 31 +- crates/engine/src/game/zone_pipeline.rs | 277 ++++- crates/engine/src/types/game_state.rs | 35 + .../battlefield_entry_authority_census.rs | 12 +- crates/engine/tests/integration/main.rs | 1 + .../integration/yenna_aura_token_copy.rs | 1090 +++++++++++++++++ 13 files changed, 2053 insertions(+), 202 deletions(-) create mode 100644 crates/engine/tests/integration/yenna_aura_token_copy.rs 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 b649049d30..2dfac3994b 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -2870,31 +2870,19 @@ 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) } -fn quantity_ref_depends_on_zone_change_this_way(qty: &QuantityRef) -> bool { +/// The object-population `TargetFilter` a `QuantityRef` counts over, if it +/// counts over one at all. +/// +/// 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 eleven variants and a new +/// population-counting `QuantityRef` had to be threaded once per predicate. +fn quantity_ref_population_filter(qty: &QuantityRef) -> Option<&TargetFilter> { match qty { QuantityRef::ObjectCount { filter } | QuantityRef::ObjectCountDistinct { filter, .. } @@ -2905,27 +2893,38 @@ 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::BattlefieldEntriesThisTurn { filter, .. } => Some(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, + } => Some(filter), + _ => None, } } +/// 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_population_filter`]. +fn quantity_expr_counts_population_matching( + expr: &QuantityExpr, + filter_pred: &dyn Fn(&TargetFilter) -> bool, +) -> bool { + quantity_expr_any_ref(expr, &mut |qty| { + quantity_ref_population_filter(qty).is_some_and(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 @@ -2935,6 +2934,38 @@ 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 { + match condition { + AbilityCondition::QuantityCheck { lhs, rhs, .. } => { + quantity_expr_counts_population_matching(lhs, &filter_contains_last_created) + || quantity_expr_counts_population_matching(rhs, &filter_contains_last_created) + } + AbilityCondition::Not { condition } => condition_depends_on_last_created(condition), + AbilityCondition::And { conditions } | AbilityCondition::Or { conditions } => { + conditions.iter().any(condition_depends_on_last_created) + } + _ => 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 @@ -10782,9 +10813,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)) diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index bd691211e0..b6d8b22a3a 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -1521,6 +1521,41 @@ pub(crate) fn continue_liminal_copy_token_batch_after_counter_pause( ) } +/// CR 303.4g: what "there is no legal object or player for it to enchant" means +/// for THIS entrant. +/// +/// The rule splits on exactly one property — "If the Aura is a token, it isn't +/// created" — so this is selected from the object's own CR 111.1 `is_token` +/// flag and nothing else. A typed pair rather than a `bool` so both dispositions +/// carry their rule at the use site. +enum UnhostedAuraEntry { + /// The entrant is a token: it isn't created at all. + NotCreated, + /// The entrant is card-backed: it is already on the battlefield and cannot + /// be un-entered, so it stays there unattached and CR 704.5m sweeps it. + EnterUnattached, +} + +/// CR 303.4g: undo a token battlefield entry that CR 303.4g says never happened. +/// +/// 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, +) { + // allow-raw-zone: 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 destination zone + // and no `ZoneChanged` to emit. + 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, @@ -1549,13 +1584,23 @@ pub(crate) fn commit_liminal_token_entry_with_post_actions( entry.object.tapped = enter_tapped.resolve(entry.object.tapped); let owner = entry.object.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,7 +1615,7 @@ pub(crate) fn commit_liminal_token_entry_with_post_actions( all_creature_types: state.all_creature_types.clone(), } }; - let command = ResolvedTokenCreationCommand { + ResolvedTokenCreationCommand { object: ObjectIncarnationRef::from_object(&entry.object), owner, entry_timestamp: entry.object.timestamp, @@ -1589,16 +1634,129 @@ pub(crate) fn commit_liminal_token_entry_with_post_actions( // 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(), - }; + } + }); + + // CR 303.4g: what "no legal host" means for THIS entrant. Selected from the + // object's own `is_token` (CR 111.1), which is exactly the discriminator the + // rule names — so `LiminalEntryKind::Meld`, a card-backed entrant, routes to + // `EnterUnattached` without a special case. + let unhosted_entry = if entry.object.is_token { + UnhostedAuraEntry::NotCreated + } else { + UnhostedAuraEntry::EnterUnattached + }; + + 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); + + // 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). + 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 … If the Aura is a token, it isn't + // created." Un-enter it before anything observes it: no CR 733 birth record, + // no `TokenCreated`, no battlefield `ZoneChanged`, no `last_created_token_ids` + // row, and — the observable difference from letting the CR 704.5m + // unattached-Aura SBA sweep it — nothing in the owner's graveyard. + if matches!( + &hosts, + crate::game::zone_pipeline::EnteringAuraHosts::Hosts { legal_targets, .. } + if legal_targets.is_empty() + ) && matches!(unhosted_entry, UnhostedAuraEntry::NotCreated) + { + 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` reaches here only for a non-token entrant (the token + // arm returned above): a card-backed Aura is already on the + // battlefield and cannot be un-entered, so CR 704.5m owns it. + 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 +1946,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 } diff --git a/crates/engine/src/game/effects/token_copy.rs b/crates/engine/src/game/effects/token_copy.rs index 2ced01f18a..3d4ad511e2 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::{ @@ -701,7 +701,28 @@ 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 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 = crate::game::zone_pipeline::entering_aura_hosts(state, token_id); + 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 +744,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 +800,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 +878,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 276f2590d7..38c46f2a70 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -16035,9 +16035,38 @@ 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. - "game/effects/mod.rs:6300".to_string(), - "game/effects/mod.rs:6377".to_string(), - "game/effects/mod.rs:9570".to_string(), + // + // Issue #5904 (CR 303.4f Aura token copies): `:6300/:6377/:9570 ⇒ + // `:6331/:6408/:9601`, a UNIFORM +31. LOCAL, not upstream, so the + // CI-vs-local diagnosis in the header does not apply. Accounted + // hunk by hunk from `git diff -U0` on `effects/mod.rs`: the seven + // hunks in the `2873..2969` predicate block sum to exactly +31 + // (−20 collapsing the hand-rolled + // `quantity_{expr,ref}_depends_on_zone_change_this_way` recursion + // into the shared `quantity_ref_population_filter` + + // `quantity_expr_counts_population_matching` pair, then +8/−2/+13 + // for those two new helpers and the + // `filter_contains_last_created` wrapper, then +32 for + // `condition_depends_on_last_created` and its doc), and they sit + // above ALL THREE producers. The unit's only other hunks in this + // file are at `:10816`/`:10831` (+13, the `last_created` + // deferral arm and its comment), i.e. BELOW all three; whole-file + // delta is +44, so nothing else moved them. Predicted + // `6300+31`/`6377+31`/`9570+31` equal the observed coordinates + // exactly. Identity re-established, not assumed: each producer at + // its new coordinate is sha256-identical to + // `HEAD:effects/mod.rs` at its old one (`a8512b40…`, `82c6c569…`, + // `eb2d5e19…`) and each is still inside the enclosing function + // this row NAMES — `drive_sequential_repeated_optional_payment`, + // `resolve_repeated_optional_payment_choice`, + // `resolve_chain_body`. Set preservation: the two asserts above + // ran GREEN on the run that caught this (total still 37, + // partition still 5/7/25), and the other two entries did not move + // at all. Nothing this unit adds mints an `OptionalEffect` prompt + // — the one prompt it does add is `ReturnAsAuraTarget`. + "game/effects/mod.rs:6331".to_string(), + "game/effects/mod.rs:6408".to_string(), + "game/effects/mod.rs:9601".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. diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 31f035a23f..7f85054412 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -1504,8 +1504,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" @@ -2101,8 +2110,15 @@ fn finish_copy_target_choice_entry( // multi-host Aura with a token continuation); if one ever does, thread the // continuation through the resume like the ETB-counter pause does. 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, diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index fa6597cb02..0205d287f7 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -1504,6 +1504,25 @@ pub(crate) fn filter_contains_last_zone_changed(filter: &TargetFilter) -> bool { } } +/// 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 { + match filter { + TargetFilter::LastCreated => true, + TargetFilter::And { filters } | TargetFilter::Or { filters } => { + filters.iter().any(filter_contains_last_created) + } + TargetFilter::Not { filter } => filter_contains_last_created(filter), + TargetFilter::TrackedSetFiltered { filter, .. } => filter_contains_last_created(filter), + _ => false, + } +} + /// Check if an object matches a typed TargetFilter against the given context. /// /// This is the unified entry point for filter evaluation. Build a diff --git a/crates/engine/src/game/sba.rs b/crates/engine/src/game/sba.rs index 5033e4b8c9..67858ebac4 100644 --- a/crates/engine/src/game/sba.rs +++ b/crates/engine/src/game/sba.rs @@ -44,8 +44,17 @@ 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. +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 +145,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 +265,7 @@ pub fn check_state_based_actions(state: &mut GameState, events: &mut Vec EnteringAuraAttachment { + 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, + }, +} + +/// 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 { let Some(enchant_filter) = aura_enchant_filter(state, object_id) else { - return EnteringAuraAttachment::NotApplicable; + return EnteringAuraHosts::NotApplicable; }; 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 @@ -1967,26 +2005,45 @@ 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); + EnteringAuraHosts::Hosts { + legal_targets: legal_aura_attachment_targets(state, object_id, controller, &enchant_filter), + controller, + } +} + +/// Act half of [`resolve_entering_aura_attachment`]: attach the sole legal host +/// (CR 303.4f), or report the disposition the caller must handle. +pub(crate) fn apply_entering_aura_hosts( + state: &mut GameState, + object_id: ObjectId, + hosts: EnteringAuraHosts, +) -> EnteringAuraAttachment { + let EnteringAuraHosts::Hosts { + controller, + legal_targets, + } = 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 + EnteringAuraAttachment::Attached } [TargetRef::Player(id)] => { crate::game::effects::attach::attach_to_player(state, object_id, *id); - EnteringAuraAttachment::Resolved + EnteringAuraAttachment::Attached } _ => EnteringAuraAttachment::NeedsChoice { controller, @@ -1995,6 +2052,192 @@ pub(crate) fn resolve_entering_aura_attachment( } } +#[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, + } = entering_aura_hosts(&state, id) + else { + panic!("an unattached Aura on the battlefield has a host verdict"); + }; + 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); + } +} + /// CR 708.3 + CR 708.2a: Turn an object face down as part of its battlefield /// entry — snapshot the real face into `back_face`, then overwrite the live /// characteristics with the face-down profile (the morph/manifest vanilla 2/2 diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 6804f91cab..6fc59b98fc 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -5308,6 +5308,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 { @@ -5372,6 +5394,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, 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/main.rs b/crates/engine/tests/integration/main.rs index 7095d0f5f6..bf7aea8487 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1037,6 +1037,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..abd0720b19 --- /dev/null +++ b/crates/engine/tests/integration/yenna_aura_token_copy.rs @@ -0,0 +1,1090 @@ +//! 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; +use engine::types::mana::{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) { + 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::new(), + }) + .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" + ); +} From 79b9e1a0b08b74ed1011548c56eb8e0b545e4f73 Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Wed, 12 Aug 2026 19:27:07 +0200 Subject: [PATCH 2/7] fix(engine): deny unhosted Aura entry pre-entry (CR 303.4g) Review round on #7303. The CR 303.4f/g consult read the entrant's pre-entry self, so an Aura meld result got no host choice at all; it now reads the liminal projection (CR 614.12). One authority decides the CR 303.4g disposition, and it denies the entry before insertion instead of leaving it to the CR 704.5m SBA. Both `LastCreated` traversals are now wildcard-free, and the Aura-host pause parks its caller's batch tail. --- crates/engine/src/game/effects/mod.rs | 226 +++++++++++++- crates/engine/src/game/effects/token.rs | 249 ++++++++++++--- crates/engine/src/game/engine.rs | 13 +- crates/engine/src/game/engine_replacement.rs | 183 ++++++++++- crates/engine/src/game/filter.rs | 145 +++++++-- crates/engine/src/game/meld_tests.rs | 159 ++++++++++ crates/engine/src/game/zone_pipeline.rs | 300 ++++++++++++++++++- 7 files changed, 1184 insertions(+), 91 deletions(-) diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index 3df3a8ce06..09f36a6b86 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -2874,16 +2874,50 @@ fn filter_contains_last_created(filter: &TargetFilter) -> bool { crate::game::filter::filter_contains_last_created(filter) } -/// The object-population `TargetFilter` a `QuantityRef` counts over, if it -/// counts over one at all. +/// 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, + } +} + +/// 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 eleven variants and a new +/// Without it each such predicate re-listed the same variants and a new /// population-counting `QuantityRef` had to be threaded once per predicate. -fn quantity_ref_population_filter(qty: &QuantityRef) -> Option<&TargetFilter> { +/// +/// 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, .. } @@ -2893,28 +2927,112 @@ fn quantity_ref_population_filter(qty: &QuantityRef) -> Option<&TargetFilter> { | QuantityRef::DistinctColorsAmongPermanents { filter } | QuantityRef::DistinctCounterKindsAmong { filter } | QuantityRef::EnteredThisTurn { filter } - | QuantityRef::BattlefieldEntriesThisTurn { filter, .. } => Some(filter), - QuantityRef::DistinctCardTypes { - source: crate::types::ability::CardTypeSetSource::Objects { filter }, - } - | QuantityRef::DistinctSubtypes { - source: crate::types::ability::CardTypeSetSource::Objects { filter }, - .. - } => Some(filter), - _ => None, + | 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_population_filter`]. +/// [`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_population_filter(qty).is_some_and(filter_pred) + quantity_ref_counts_population_matching(qty, filter_pred) }) } @@ -29381,4 +29499,82 @@ 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, + })); + } } diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index b6d8b22a3a..8b170cfecf 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -1521,21 +1521,6 @@ pub(crate) fn continue_liminal_copy_token_batch_after_counter_pause( ) } -/// CR 303.4g: what "there is no legal object or player for it to enchant" means -/// for THIS entrant. -/// -/// The rule splits on exactly one property — "If the Aura is a token, it isn't -/// created" — so this is selected from the object's own CR 111.1 `is_token` -/// flag and nothing else. A typed pair rather than a `bool` so both dispositions -/// carry their rule at the use site. -enum UnhostedAuraEntry { - /// The entrant is a token: it isn't created at all. - NotCreated, - /// The entrant is card-backed: it is already on the battlefield and cannot - /// be un-entered, so it stays there unattached and CR 704.5m sweeps it. - EnterUnattached, -} - /// CR 303.4g: undo a token battlefield entry that CR 303.4g says never happened. /// /// The inverse of the `state.objects.insert` + `zones::add_to_zone` pair @@ -1549,13 +1534,43 @@ pub(crate) fn uncreate_unentered_aura_token( object_id: ObjectId, owner: PlayerId, ) { - // allow-raw-zone: 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 destination zone - // and no `ZoneChanged` to emit. + // 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); } +/// CR 303.4g: deny a CARD-BACKED entrant's battlefield entry and put it into its +/// owner's graveyard. +/// +/// Sibling of [`uncreate_unentered_aura_token`] for the entrant the token clause +/// does not cover. See `zone_pipeline::unhosted_aura_entry` for why the liminal +/// seam's card-backed arm takes the graveyard disposition: the entrant has no +/// prior zone to "remain" in, and the card must not simply cease to exist. +fn place_unentered_aura_in_owners_graveyard( + state: &mut GameState, + object_id: ObjectId, + owner: PlayerId, +) { + // allow-raw-zone: rewinds a CR 303.4g-denied battlefield entry; the entry never happened, so no ZoneChanged may be emitted. + zones::remove_from_zone(state, object_id, Zone::Battlefield, owner); + if let Some(object) = state.objects.get_mut(&object_id) { + // allow-raw-zone: places a CR 303.4g-denied entrant in its owner's graveyard; the denied entry is not a replaceable CR 400.7 event. + object.zone = Zone::Graveyard; + } + // allow-raw-zone: pairs with the zone assignment directly above for the same CR 303.4g-denied entry. + zones::add_to_zone(state, object_id, Zone::Graveyard, owner); +} + pub(crate) fn commit_liminal_token_entry_with_post_actions( state: &mut GameState, event: ProposedEvent, @@ -1637,15 +1652,17 @@ pub(crate) fn commit_liminal_token_entry_with_post_actions( } }); - // CR 303.4g: what "no legal host" means for THIS entrant. Selected from the - // object's own `is_token` (CR 111.1), which is exactly the discriminator the - // rule names — so `LiminalEntryKind::Meld`, a card-backed entrant, routes to - // `EnterUnattached` without a special case. - let unhosted_entry = if entry.object.is_token { - UnhostedAuraEntry::NotCreated - } else { - UnhostedAuraEntry::EnterUnattached - }; + // CR 303.4g: what "no legal host" means for THIS entrant, decided from the + // liminal projection BEFORE it is installed. `ProposedEvent::TokenEntry` + // carries no from-zone — the entrant is a set of characteristics no zone list + // holds — so the origin is `NoPriorZone` and the shared authority in + // `zone_pipeline` maps it: a token isn't created, a card-backed entrant is put + // into its owner's graveyard. Neither may enter unattached and be left to the + // CR 704.5m state-based action; the rule denies the entry itself. + let unhosted_entry = crate::game::zone_pipeline::unhosted_aura_entry( + &entry.object, + crate::game::zone_pipeline::UnhostedAuraOrigin::NoPriorZone, + ); 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). @@ -1668,18 +1685,42 @@ pub(crate) fn commit_liminal_token_entry_with_post_actions( }; // 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." Un-enter it before anything observes it: no CR 733 birth record, - // no `TokenCreated`, no battlefield `ZoneChanged`, no `last_created_token_ids` - // row, and — the observable difference from letting the CR 704.5m - // unattached-Aura SBA sweep it — nothing in the owner's graveyard. + // 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." + // + // EVERY disposition denies the entry — 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() - ) && matches!(unhosted_entry, UnhostedAuraEntry::NotCreated) - { - uncreate_unentered_aura_token(state, entry_ref, owner); + ) { + match unhosted_entry { + // CR 303.4g + CR 111.1: "it isn't created" — and, unlike the CR 704.5m + // sweep it replaces, nothing lands in the owner's graveyard either. + crate::game::zone_pipeline::UnhostedAuraEntry::NotCreated => { + uncreate_unentered_aura_token(state, entry_ref, owner); + } + // Card-backed. Not reachable from any production caller today — the + // only production producer of `ProposedEvent::TokenEntry` is the + // liminal copy-token seam in `token_copy.rs`, whose + // `materialize_token_copy_body` sets `is_token` — but handled rather + // than assumed away, because the seam's contract is a `GameObject` + // and nothing in the type forbids a card-backed one. `NoPriorZone` + // cannot yield `RemainInCurrentZone`, so both remaining arms are the + // graveyard; they are listed exhaustively so a future origin that + // does yield it forces a decision here. + crate::game::zone_pipeline::UnhostedAuraEntry::OwnersGraveyard + | crate::game::zone_pipeline::UnhostedAuraEntry::RemainInCurrentZone => { + place_unentered_aura_in_owners_graveyard(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 @@ -1700,9 +1741,8 @@ pub(crate) fn commit_liminal_token_entry_with_post_actions( } match crate::game::zone_pipeline::apply_entering_aura_hosts(state, entry_ref, hosts) { - // `NoLegalHost` reaches here only for a non-token entrant (the token - // arm returned above): a card-backed Aura is already on the - // battlefield and cannot be un-entered, so CR 704.5m owns it. + // `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 => {} @@ -7229,6 +7269,139 @@ mod tests { ); } + /// CR 303.4g on the liminal seam, for BOTH dispositions: an unhosted entrant + /// never enters, and what happens instead is selected by CR 111.1 token-ness. + /// + /// The card-backed half is not reachable from a production caller today (the + /// only production producer of `ProposedEvent::TokenEntry` is the liminal + /// copy-token seam in `token_copy.rs`, whose `materialize_token_copy_body` + /// sets `is_token`), which is precisely why it is pinned here: the seam takes + /// a `GameObject`, nothing in that type forbids a card-backed one, and the + /// arm it used to take — enter unattached and wait for the CR 704.5m + /// state-based action — is an entry CR 303.4g says never happens. + #[test] + fn an_unhosted_liminal_aura_entrant_never_enters_whatever_its_token_ness() { + use crate::types::game_state::LiminalEntry; + use crate::types::keywords::Keyword; + + // `is_token` selects the disposition; everything else is identical. + for is_token in [true, false] { + let mut state = GameState::new_two_player(42); + let source_id = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Maker".to_string(), + Zone::Battlefield, + ); + + // The entrant: an Aura with `Enchant creature` in a game with no + // creature anywhere, so the CR 303.4f consult finds no legal host. + let (entry_ref, mut entrant) = + reserve_liminal_token_object(&mut state, PlayerId(0), "Unhosted Aura".to_string()); + entrant.is_token = is_token; + 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); + + state.liminal_entries.insert( + entry_ref, + LiminalEntry { + object: entrant, + 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(), + }, + ); + + 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" + ); + + // Shared by both dispositions: the entry never happened, and nothing + // observed one. + assert!( + !state.battlefield.iter().any(|&id| id == entry_ref), + "CR 303.4g: the unhosted Aura must not be on the battlefield \ + (is_token = {is_token})" + ); + 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, + to: Zone::Battlefield, + .. + } if *object_id == entry_ref + )), + "CR 303.4g: no battlefield ZoneChanged for an entry the rule denies" + ); + + if is_token { + // CR 303.4g + CR 111.1: "it isn't created" — and, unlike the + // CR 704.5m sweep it replaces, nothing reaches any graveyard. + assert!( + !state.objects.contains_key(&entry_ref), + "a token CR 303.4g denies is not created at all" + ); + assert!(state.players[0].graveyard.is_empty()); + } else { + // CR 303.4g: a card-backed entrant is not destroyed — it is put + // into its owner's graveyard, the rule's own non-battlefield + // outcome for an entrant with nowhere to remain. + assert_eq!( + state.objects[&entry_ref].zone, + Zone::Graveyard, + "a card-backed entrant CR 303.4g denies goes to its owner's graveyard" + ); + assert!( + state.players[0].graveyard.iter().any(|&id| id == entry_ref), + "the owner's graveyard actually holds it" + ); + } + } + } + #[test] fn paused_liminal_copy_token_counter_finalizes_entry_after_choice() { use std::sync::Arc; diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index d7386055e7..ab60f0c789 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -16093,9 +16093,16 @@ mod stage2_injector_tests { // #5904 adds the Aura-token host prompt and shifts these existing // producers. Re-pinned against the merged source; this remains // coordinate evidence only, not a sixth prompt producer. - "game/effects/mod.rs:6331".to_string(), - "game/effects/mod.rs:6408".to_string(), - "game/effects/mod.rs:9607".to_string(), + // + // #5904's review round shifts them again, a uniform +118: every + // hunk of the wildcard-free `quantity_ref_counts_population_matching` + // rewrite sits above all three producers, and the whole-file delta + // is the same +118. Each producer is sha256-identical at its new + // coordinate and still inside the function its row names. That + // round removes two `_` arms and adds no prompt of any kind. + "game/effects/mod.rs:6449".to_string(), + "game/effects/mod.rs:6526".to_string(), + "game/effects/mod.rs:9725".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. diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 7f85054412..9be51e04ec 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -2100,15 +2100,16 @@ 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) @@ -2123,6 +2124,29 @@ fn finish_copy_target_choice_entry( 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, @@ -6509,6 +6533,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 diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index 0205d287f7..7deb923426 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -1488,20 +1488,93 @@ 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 nesting set here +/// is the same five `normalize_contextual_filter` recurses through, which is the +/// other end of the same shape. +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), + // 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::Typed(_) + | 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, + } +} + /// 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` @@ -1512,15 +1585,7 @@ pub(crate) fn filter_contains_last_zone_changed(filter: &TargetFilter) -> bool { /// 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 { - match filter { - TargetFilter::LastCreated => true, - TargetFilter::And { filters } | TargetFilter::Or { filters } => { - filters.iter().any(filter_contains_last_created) - } - TargetFilter::Not { filter } => filter_contains_last_created(filter), - TargetFilter::TrackedSetFiltered { filter, .. } => filter_contains_last_created(filter), - _ => false, - } + filter_contains(filter, &|inner| matches!(inner, TargetFilter::LastCreated)) } /// Check if an object matches a typed TargetFilter against the given context. @@ -10471,6 +10536,46 @@ mod tests { assert!(matches_target_filter(&state, veteran, &filter, attacker)); } + /// The nesting set `filter_contains` recurses through must match the one + /// `normalize_contextual_filter` rewrites through, or a filter that + /// normalization reaches is one the anaphor predicates cannot see. + /// `ChosenDamageSource`'s optional inner filter was exactly that gap: reached + /// by normalization, invisible to both `filter_contains_*` predicates, so a + /// nested `LastCreated` read as absent and the CR 608.2c deferral gate it + /// feeds let a prompt-suspended sub-ability evaluate against a stale ledger. + #[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 } + )); + } + #[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_tests.rs b/crates/engine/src/game/meld_tests.rs index 5f48524ab5..d0e45fe004 100644 --- a/crates/engine/src/game/meld_tests.rs +++ b/crates/engine/src/game/meld_tests.rs @@ -2216,3 +2216,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/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index 8076ab3222..27137c8de0 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -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) @@ -1828,8 +1828,28 @@ 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) + .or_else(|| state.objects.get(&object_id)) +} + fn aura_enchant_filter(state: &GameState, object_id: ObjectId) -> Option { - let obj = state.objects.get(&object_id)?; + let obj = entering_object_projection(state, object_id)?; if !obj.card_types.subtypes.iter().any(|s| s == "Aura") { return None; } @@ -1889,6 +1909,18 @@ 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)) + // CR 701.3a + CR 702.16c: host-side prohibitions and protection. + // + // NARROWING, deliberate and named: `attachment_illegality` reads the + // ATTACHMENT from `state.objects`. For a liminal entrant that id still + // holds the pre-entry object (a meld's exiled component card), so the + // attachment-side halves of that check — "is the attacher an Aura", and + // the protection quality match — read pre-entry characteristics. The + // effect is only ever permissive (a non-Aura attacher skips both), never + // restrictive, and it is strictly better than the prior behaviour on this + // path, which ran no CR 303.4f/g consult at all. Closing it needs the + // entrant projection threaded through `attach.rs`, which is a wider + // single-authority change than this seam should make. .filter(|id| crate::game::effects::attach::can_attach_to_object(state, aura_id, *id)) .map(TargetRef::Object) .collect(); @@ -1916,6 +1948,65 @@ fn legal_aura_attachment_targets( targets } +/// Where a CR 303.4g entrant would "remain", for a seam that is deciding an +/// Aura's fate BEFORE the entry happens. +/// +/// The rule's non-token dispositions are both phrased against the zone the Aura +/// is entering from, so a caller must state which it has. A liminal projection +/// has none: the entrant is a set of characteristics that no zone list holds yet. +pub(crate) enum UnhostedAuraOrigin { + /// A CR 400.7 zone change: the `from` zone of the approved event. + Zone(Zone), + /// A liminal entry (`ProposedEvent::TokenEntry`): the entrant exists in no + /// zone, so there is nothing for it to "remain" in. + NoPriorZone, +} + +/// 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 where it is entering from. +/// +/// [`UnhostedAuraOrigin::NoPriorZone`] takes the graveyard arm for a card-backed +/// entrant: "remains in its current zone" has no referent for an entrant that is +/// in none, which is the same shape as the stack case the rule already answers +/// (an object whose pre-entry location cannot hold a permanent card). It is +/// never the destructive reading — the card is never simply dropped. +pub(crate) fn unhosted_aura_entry( + entrant: &GameObject, + origin: UnhostedAuraOrigin, +) -> 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 + // where the effect was putting it onto the battlefield from. + if entrant.is_token { + return UnhostedAuraEntry::NotCreated; + } + match origin { + UnhostedAuraOrigin::Zone(Zone::Stack) | UnhostedAuraOrigin::NoPriorZone => { + UnhostedAuraEntry::OwnersGraveyard + } + UnhostedAuraOrigin::Zone(_) => 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`]). /// @@ -2236,6 +2327,156 @@ mod entering_aura_attachment_tests { 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 + } + + /// 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 where 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 origin in [ + UnhostedAuraOrigin::NoPriorZone, + UnhostedAuraOrigin::Zone(Zone::Stack), + UnhostedAuraOrigin::Zone(Zone::Graveyard), + ] { + assert!(matches!( + unhosted_aura_entry(&entrant, origin), + 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." A liminal projection has + /// no current zone at all, so it takes the same non-destructive arm. + #[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, UnhostedAuraOrigin::Zone(Zone::Graveyard)), + UnhostedAuraEntry::RemainInCurrentZone + )); + assert!(matches!( + unhosted_aura_entry(&entrant, UnhostedAuraOrigin::Zone(Zone::Exile)), + UnhostedAuraEntry::RemainInCurrentZone + )); + assert!(matches!( + unhosted_aura_entry(&entrant, UnhostedAuraOrigin::Zone(Zone::Stack)), + UnhostedAuraEntry::OwnersGraveyard + )); + assert!(matches!( + unhosted_aura_entry(&entrant, UnhostedAuraOrigin::NoPriorZone), + 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" + ); + } + + /// 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" + ); + } } /// CR 708.3 + CR 708.2a: Turn an object face down as part of its battlefield @@ -3250,8 +3491,15 @@ 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. Applied + // after the borrow of `event` ends, by rewriting the approved event's + // destination — the entry is denied, and the graveyard placement IS + // the rule's substitute for it. + let mut unhosted_to_owners_graveyard = false; if let ProposedEvent::ZoneChange { object_id, + from, to: Zone::Battlefield, attach_to, controller_override, @@ -3260,8 +3508,15 @@ 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, @@ -3270,10 +3525,32 @@ fn execute_zone_move_with_applied_terminal( &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, UnhostedAuraOrigin::Zone(*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 = @@ -3293,6 +3570,17 @@ 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." Retarget the already-approved + // event rather than re-entering the replacement pipeline: the + // graveyard placement is not a new, separately replaceable event — + // it is the rules-mandated substitute outcome of this one. + if let ProposedEvent::ZoneChange { to, attach_to, .. } = &mut event { + *to = Zone::Graveyard; + *attach_to = None; + } + } if let Some((controller, aura_id, legal_targets)) = pending_aura_choice { let delivery_start = events.len(); match deliver_replaced_zone_change( From 6dba1454347a73a9bd31e4cc230c0f20a074c828 Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:56:34 +0200 Subject: [PATCH 3/7] test(engine): re-pin the CR 603.5 prompt census after the main merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only the third producer moved (`:9725 ⇒ :9721`); the other two did not, which locates the deletion between them. Re-read at the new coordinate and sha256-identical to its old one. Coordinate evidence only — this round adds no prompt. --- crates/engine/src/game/engine.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 80651f24cc..a78ecddce1 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -16118,9 +16118,14 @@ mod stage2_injector_tests { // is the same +118. Each producer is sha256-identical at its new // coordinate and still inside the function its row names. That // round removes two `_` arms and adds no prompt of any kind. + // + // Merging `origin/main` (#7304 and neighbours) then moved the + // third producer alone by -4, `:9725 ⇒ :9721`; the other two did + // not move, which locates the deletion between them. Re-read at + // the new coordinate and sha256-identical to its old one. "game/effects/mod.rs:6449".to_string(), "game/effects/mod.rs:6526".to_string(), - "game/effects/mod.rs:9725".to_string(), + "game/effects/mod.rs:9721".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. From a0bca51970def88b15d9c80406d6796a82dcf1cc Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:12:04 +0200 Subject: [PATCH 4/7] fix(engine): route the CR 303.4g graveyard placement through replacement Review round 2 on #7303. The stack-origin denial rewrote the approved event's destination, so a board-wide Moved graveyard redirect (Rest in Peace) never saw it; it is now a fresh consulted move carrying the applied set. The entrant projection also reaches attach legality and the non-liminal consult, so both copy seams judge the same object, and the `LastCreated` traversals lose their last wildcards. --- crates/engine/src/game/effects/attach.rs | 107 ++++- crates/engine/src/game/effects/mod.rs | 209 ++++++++- crates/engine/src/game/effects/token.rs | 41 ++ crates/engine/src/game/effects/token_copy.rs | 39 +- crates/engine/src/game/engine.rs | 29 +- crates/engine/src/game/filter.rs | 284 +++++++++++- crates/engine/src/game/sba.rs | 9 + crates/engine/src/game/static_abilities.rs | 54 ++- crates/engine/src/game/zone_pipeline.rs | 420 ++++++++++++++++-- .../integration/yenna_aura_token_copy.rs | 144 +++++- 10 files changed, 1238 insertions(+), 98 deletions(-) diff --git a/crates/engine/src/game/effects/attach.rs b/crates/engine/src/game/effects/attach.rs index 5aacc01d7c..e71a317a46 100644 --- a/crates/engine/src/game/effects/attach.rs +++ b/crates/engine/src/game/effects/attach.rs @@ -787,6 +787,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 +840,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 +865,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 +877,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 +1259,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 +1284,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 +1305,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 +1351,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). @@ -1616,7 +1679,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/mod.rs b/crates/engine/src/game/effects/mod.rs index 226cf36a6b..f916f89f7d 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -3069,16 +3069,109 @@ fn condition_depends_on_zone_change_this_way(condition: &AbilityCondition) -> bo /// 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 { - AbilityCondition::QuantityCheck { lhs, rhs, .. } => { - quantity_expr_counts_population_matching(lhs, &filter_contains_last_created) - || quantity_expr_counts_population_matching(rhs, &filter_contains_last_created) - } - AbilityCondition::Not { condition } => condition_depends_on_last_created(condition), + // Compound / wrapping conditions. + AbilityCondition::Not { condition } => recurse(condition), + AbilityCondition::ConditionInstead { inner } => recurse(inner), AbilityCondition::And { conditions } | AbilityCondition::Or { conditions } => { - conditions.iter().any(condition_depends_on_last_created) + conditions.iter().any(recurse) } - _ => false, + // 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, } } @@ -29564,4 +29657,106 @@ mod tests { 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 8b170cfecf..041db35c30 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -1556,6 +1556,27 @@ pub(crate) fn uncreate_unentered_aura_token( /// does not cover. See `zone_pipeline::unhosted_aura_entry` for why the liminal /// seam's card-backed arm takes the graveyard disposition: the entrant has no /// prior zone to "remain" in, and the card must not simply cease to exist. +/// +/// WHY THIS ONE IS NOT PIPELINE-ROUTED, unlike its ZoneChange twin. The +/// stack-origin arm in `zone_pipeline` proposes a fresh, replacement-aware +/// Stack → Graveyard move, so a `Moved` graveyard→exile redirect (Rest in Peace / +/// Leyline of the Void) fires on it. That is only expressible because that +/// entrant HAS a from-zone. This one does not: `ProposedEvent::TokenEntry` +/// carries no origin, and the object is on the battlefield here solely because +/// the seam raw-inserted it a few lines above to run the consult. Routing +/// Battlefield → Graveyard through `move_object` would emit +/// `ZoneChanged { from: Some(Battlefield) }` and make the game observe a CR 700.4 +/// death for an entry CR 303.4g says never happened — strictly worse than the +/// missing `ZoneChanged` it would buy. There is no `from: None` graveyard +/// proposal in the pipeline to route instead (`record_and_emit_entry_from_no_zone` +/// is battlefield-only). +/// +/// This is reachable only defensively: `materialize_token_copy_body` sets +/// `is_token` on every entrant the one production `TokenEntry` producer builds +/// (`token_copy.rs`), so `unhosted_aura_entry` takes the `NotCreated` arm for all +/// of them. Closing it properly means giving the pipeline a from-nothing, +/// replacement-consulting placement — worth doing when a production card-backed +/// liminal entrant exists, not before. fn place_unentered_aura_in_owners_graveyard( state: &mut GameState, object_id: ObjectId, @@ -1678,6 +1699,26 @@ pub(crate) fn commit_liminal_token_entry_with_post_actions( // // 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 { diff --git a/crates/engine/src/game/effects/token_copy.rs b/crates/engine/src/game/effects/token_copy.rs index 3d4ad511e2..0c2504ac10 100644 --- a/crates/engine/src/game/effects/token_copy.rs +++ b/crates/engine/src/game/effects/token_copy.rs @@ -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 { @@ -701,13 +705,46 @@ pub(crate) fn apply_copy_token_after_replacement_with_created_ids( ObjectIncarnationRef::from_object(token) }); + // 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 = crate::game::zone_pipeline::entering_aura_hosts(state, token_id); + 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, .. } diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index b1e7bead43..01bdf74de6 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -16110,9 +16110,32 @@ mod stage2_injector_tests { // evidence only, not a sixth prompt producer. The existing // producers are at these current `WaitingFor::OptionalEffectChoice` // construction sites. - "game/effects/mod.rs:6447".to_string(), - "game/effects/mod.rs:6524".to_string(), - "game/effects/mod.rs:9719".to_string(), + // #7303 fix round 2 (base e8db46ca7): `:6447/:6524/:9719 ⇒ + // `:6540/:6617/:9812`, uniform +93 above all three. Re-derived, not + // assumed. `git diff -U0` on this file has exactly five hunks: four at + // `:3071–:3081` (the `_ => false` wildcard in + // `condition_depends_on_last_created` replaced by the exhaustive + // `condition_reads_filter_population`; net `+28 −2 +0 +67` = `+93`) and + // one at `:29566` (`mod tests`, i.e. BELOW all three). Whole-file delta + // is `+93` plus the test block, so nothing was inserted between the + // producers. Predicted `6447+93`, `6524+93`, `9719+93` equal the observed + // coordinates exactly. Identity re-established: each producer at its new + // coordinate is byte-identical to `e8db46ca7:effects/mod.rs` at its old + // one, and so is its ±6-line window (md5 `bc0baefa…`, `6448e086…`, + // `4831945d…` on both sides). The window is what establishes identity + // here: all three producers are the SAME one-line mint text, so a bare + // line comparison could not tell them apart. (Spelling that text out in + // this comment would make the row's own source a 38th census hit — the + // needle is assembled at run time precisely to stop that, but only for + // the needle itself.) Set preservation: the two asserts above ran FIRST and + // both fired GREEN on the run that caught this (total **37**, partition + // **5/7/25**), and the other two entries + // (`scoped_library_search.rs:452`, `engine.rs:12004`) did not move at all. + // The change is a filter-traversal classifier; it constructs no + // `WaitingFor` of any kind. + "game/effects/mod.rs:6540".to_string(), + "game/effects/mod.rs:6617".to_string(), + "game/effects/mod.rs:9812".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. diff --git a/crates/engine/src/game/filter.rs b/crates/engine/src/game/filter.rs index 7deb923426..069a9bd632 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}; @@ -1500,9 +1501,18 @@ pub(crate) fn controller_ref_player( /// 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 nesting set here -/// is the same five `normalize_contextual_filter` recurses through, which is the -/// other end of the same shape. +/// 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; @@ -1517,6 +1527,15 @@ pub(crate) fn filter_contains(filter: &TargetFilter, leaf: &dyn Fn(&TargetFilter // 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 @@ -1527,7 +1546,6 @@ pub(crate) fn filter_contains(filter: &TargetFilter, leaf: &dyn Fn(&TargetFilter | TargetFilter::SelfRef | TargetFilter::GrantingObject | TargetFilter::SourceOrPaired - | TargetFilter::Typed(_) | TargetFilter::StackAbility { .. } | TargetFilter::StackSpell | TargetFilter::SpecificObject { .. } @@ -1569,6 +1587,167 @@ pub(crate) fn filter_contains(filter: &TargetFilter, leaf: &dyn Fn(&TargetFilter } } +/// [`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 { @@ -10536,13 +10715,21 @@ mod tests { assert!(matches_target_filter(&state, veteran, &filter, attacker)); } - /// The nesting set `filter_contains` recurses through must match the one - /// `normalize_contextual_filter` rewrites through, or a filter that - /// normalization reaches is one the anaphor predicates cannot see. - /// `ChosenDamageSource`'s optional inner filter was exactly that gap: reached - /// by normalization, invisible to both `filter_contains_*` predicates, so a - /// nested `LastCreated` read as absent and the CR 608.2c deferral gate it - /// feeds let a prompt-suspended sub-ability evaluate against a stale ledger. + /// `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 { @@ -10576,6 +10763,77 @@ mod tests { )); } + /// `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/sba.rs b/crates/engine/src/game/sba.rs index 67858ebac4..c17895c3da 100644 --- a/crates/engine/src/game/sba.rs +++ b/crates/engine/src/game/sba.rs @@ -49,6 +49,15 @@ fn live_battlefield_object_mut<'a>( /// 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!( diff --git a/crates/engine/src/game/static_abilities.rs b/crates/engine/src/game/static_abilities.rs index a1af2ff4eb..137bbbe370 100644 --- a/crates/engine/src/game/static_abilities.rs +++ b/crates/engine/src/game/static_abilities.rs @@ -1578,6 +1578,27 @@ pub fn player_protection_from( state: &GameState, player_id: PlayerId, source: Option, +) -> 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/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index ade22690fe..6c33403198 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -1837,7 +1837,19 @@ fn entering_object_projection(state: &GameState, object_id: ObjectId) -> Option< } fn aura_enchant_filter(state: &GameState, object_id: ObjectId) -> Option { - let obj = entering_object_projection(state, 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; } @@ -1864,9 +1876,19 @@ fn aura_enchant_filter(state: &GameState, object_id: ObjectId) -> Option, controller: PlayerId, enchant_filter: &TargetFilter, ) -> Vec { @@ -1897,19 +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)) - // CR 701.3a + CR 702.16c: host-side prohibitions and protection. - // - // NARROWING, deliberate and named: `attachment_illegality` reads the - // ATTACHMENT from `state.objects`. For a liminal entrant that id still - // holds the pre-entry object (a meld's exiled component card), so the - // attachment-side halves of that check — "is the attacher an Aura", and - // the protection quality match — read pre-entry characteristics. The - // effect is only ever permissive (a non-Aura attacher skips both), never - // restrictive, and it is strictly better than the prior behaviour on this - // path, which ran no CR 303.4f/g consult at all. Closing it needs the - // entrant projection threaded through `attach.rs`, which is a wider - // single-authority change than this seam should make. - .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(); @@ -1921,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, @@ -2071,9 +2099,34 @@ pub(crate) enum EnteringAuraHosts { /// 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 { - let Some(enchant_filter) = aura_enchant_filter(state, object_id) else { + let Some(entrant) = entering_object_projection(state, object_id) else { + return EnteringAuraHosts::NotApplicable; + }; + entering_aura_hosts_projected(state, object_id, entrant) +} + +/// 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 { + 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 EnteringAuraHosts::NotApplicable; }; @@ -2091,9 +2144,17 @@ pub(crate) fn entering_aura_hosts(state: &GameState, object_id: ObjectId) -> Ent if obj.attached_to.is_some() { return EnteringAuraHosts::NotApplicable; } - let controller = obj.controller; + // 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, controller, &enchant_filter), + legal_targets: legal_aura_attachment_targets( + state, + object_id, + Some(entrant), + controller, + &enchant_filter, + ), controller, } } @@ -2334,6 +2395,21 @@ mod entering_aura_attachment_tests { 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 where the effect was /// putting it onto the battlefield from. @@ -2441,6 +2517,276 @@ mod entering_aura_attachment_tests { ); } + /// 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 of the kind the liminal card-backed sibling still + /// uses, which emits nothing at all and so fires no "put into a graveyard from + /// anywhere" trigger. 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, + ); + } + + 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. @@ -3480,10 +3826,9 @@ fn execute_zone_move_with_applied_terminal( 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. Applied - // after the borrow of `event` ends, by rewriting the approved event's - // destination — the entry is denied, and the graveyard placement IS - // the rule's substitute for it. + // 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, @@ -3509,6 +3854,7 @@ fn execute_zone_move_with_applied_terminal( let legal_targets = legal_aura_attachment_targets( state, *object_id, + entering_object_projection(state, *object_id), controller, &enchant_filter, ); @@ -3560,14 +3906,30 @@ fn execute_zone_move_with_applied_terminal( } if unhosted_to_owners_graveyard { // CR 303.4g: "…the Aura is put into its owner's graveyard instead - // of entering the battlefield." Retarget the already-approved - // event rather than re-entering the replacement pipeline: the - // graveyard placement is not a new, separately replaceable event — - // it is the rules-mandated substitute outcome of this one. - if let ProposedEvent::ZoneChange { to, attach_to, .. } = &mut event { - *to = Zone::Graveyard; - *attach_to = None; - } + // 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(); diff --git a/crates/engine/tests/integration/yenna_aura_token_copy.rs b/crates/engine/tests/integration/yenna_aura_token_copy.rs index abd0720b19..cd45568bfb 100644 --- a/crates/engine/tests/integration/yenna_aura_token_copy.rs +++ b/crates/engine/tests/integration/yenna_aura_token_copy.rs @@ -959,6 +959,18 @@ enchanted creature this turn.\n\ /// 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 @@ -969,9 +981,9 @@ fn fylgja_copy_scenario(hosts: usize) -> (GameRunner, ObjectId, ObjectId, Vec = (0..hosts) @@ -1088,3 +1100,131 @@ fn non_liminal_copy_with_no_legal_host_is_not_created() { 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" + ); + } +} From 3743919ab40e9869a5f24116b6aa780866870539 Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Thu, 13 Aug 2026 05:02:26 +0200 Subject: [PATCH 5/7] fix(engine): attach the entrant the host choice was made against Review round 3 on #7303. The decide half read the post-exception copy while the act half re-derived legality from the stored pre-exception object, so a legally chosen host could reject the attachment and leave the token for SBA. `AttachmentAuthority` names the object instead, and the multi-host resume carries it across the pause. --- crates/engine/src/game/effects/attach.rs | 118 +++++- crates/engine/src/game/engine.rs | 34 +- crates/engine/src/game/zone_pipeline.rs | 340 +++++++++++++++++- crates/engine/src/types/game_state.rs | 55 +++ .../fixtures/cr733/authority_matrix.json.gz | Bin 41666 -> 41520 bytes .../integration/yenna_aura_token_copy.rs | 317 +++++++++++++++- 6 files changed, 833 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/game/effects/attach.rs b/crates/engine/src/game/effects/attach.rs index e71a317a46..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; } @@ -1370,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 @@ -1379,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; } diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 01bdf74de6..7e813e790d 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, @@ -16453,7 +16452,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/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index 6c33403198..e598bf2cc1 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; @@ -2093,16 +2093,75 @@ pub(crate) enum EnteringAuraHosts { 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 { - let Some(entrant) = entering_object_projection(state, object_id) else { + // 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.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_projected(state, object_id, entrant) + 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 @@ -2121,6 +2180,25 @@ 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; @@ -2156,19 +2234,34 @@ pub(crate) fn entering_aura_hosts_projected( &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; @@ -2178,17 +2271,84 @@ pub(crate) fn apply_entering_aura_hosts( // caller decide the entrant's fate. [] => EnteringAuraAttachment::NoLegalHost, [TargetRef::Object(id)] => { - crate::game::effects::attach::attach_to(state, object_id, *id); + 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); + crate::game::effects::attach::attach_to_player_with_authority( + state, + object_id, + *id, + entrant.authority(), + ); EnteringAuraAttachment::Attached } - _ => EnteringAuraAttachment::NeedsChoice { - controller, - legal_targets, - }, + _ => { + // 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(), + ) + } } } @@ -2352,10 +2512,16 @@ mod entering_aura_attachment_tests { 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" @@ -2811,6 +2977,154 @@ mod entering_aura_attachment_tests { "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" + ); + } } /// CR 708.3 + CR 708.2a: Turn an object face down as part of its battlefield diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 574769cf10..b335466115 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -13713,6 +13713,33 @@ 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, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LiminalEntry { pub object: GameObject, @@ -14061,6 +14088,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 @@ -19655,6 +19700,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, @@ -21586,6 +21632,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 diff --git a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz index 39b14bf14d6f088b5671212caaee27926527048e..8fd48cb658a9709d02cde92ccba1ac1f16f92189 100644 GIT binary patch literal 41520 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 z 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 literal 41666 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 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" + ); +} From db291749813c362cb1fa47b4759bea63c0dc403e Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Thu, 13 Aug 2026 06:07:05 +0200 Subject: [PATCH 6/7] refactor(engine): make a card-backed TokenEntry entrant unrepresentable A `TokenEntry` entrant is a CR 111.1 marker in no zone, so CR 303.4g's card dispositions cannot apply to it. `LiminalEntrant` carries that as a witness instead of an expected flag, which deletes the raw graveyard placement rather than teaching it about replacements. Wire form is unchanged; the reachable card-backed class keeps its consulted move. --- crates/engine/src/game/effects/token.rs | 522 +++++++++++------- crates/engine/src/game/effects/token_copy.rs | 7 +- crates/engine/src/game/engine_replacement.rs | 9 +- crates/engine/src/game/filter.rs | 6 +- crates/engine/src/game/meld.rs | 5 +- crates/engine/src/game/meld_tests.rs | 3 +- crates/engine/src/game/replacement.rs | 14 +- crates/engine/src/game/visibility.rs | 16 +- crates/engine/src/game/zone_pipeline.rs | 98 ++-- crates/engine/src/game/zones.rs | 2 +- crates/engine/src/types/game_state.rs | 161 +++++- crates/engine/src/types/proposed_event.rs | 2 +- .../deterministic_game_state_serde.rs | 9 +- 13 files changed, 561 insertions(+), 293 deletions(-) diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index 041db35c30..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(), @@ -1523,6 +1523,13 @@ 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 @@ -1549,49 +1556,6 @@ pub(crate) fn uncreate_unentered_aura_token( state.objects.remove(&object_id); } -/// CR 303.4g: deny a CARD-BACKED entrant's battlefield entry and put it into its -/// owner's graveyard. -/// -/// Sibling of [`uncreate_unentered_aura_token`] for the entrant the token clause -/// does not cover. See `zone_pipeline::unhosted_aura_entry` for why the liminal -/// seam's card-backed arm takes the graveyard disposition: the entrant has no -/// prior zone to "remain" in, and the card must not simply cease to exist. -/// -/// WHY THIS ONE IS NOT PIPELINE-ROUTED, unlike its ZoneChange twin. The -/// stack-origin arm in `zone_pipeline` proposes a fresh, replacement-aware -/// Stack → Graveyard move, so a `Moved` graveyard→exile redirect (Rest in Peace / -/// Leyline of the Void) fires on it. That is only expressible because that -/// entrant HAS a from-zone. This one does not: `ProposedEvent::TokenEntry` -/// carries no origin, and the object is on the battlefield here solely because -/// the seam raw-inserted it a few lines above to run the consult. Routing -/// Battlefield → Graveyard through `move_object` would emit -/// `ZoneChanged { from: Some(Battlefield) }` and make the game observe a CR 700.4 -/// death for an entry CR 303.4g says never happened — strictly worse than the -/// missing `ZoneChanged` it would buy. There is no `from: None` graveyard -/// proposal in the pipeline to route instead (`record_and_emit_entry_from_no_zone` -/// is battlefield-only). -/// -/// This is reachable only defensively: `materialize_token_copy_body` sets -/// `is_token` on every entrant the one production `TokenEntry` producer builds -/// (`token_copy.rs`), so `unhosted_aura_entry` takes the `NotCreated` arm for all -/// of them. Closing it properly means giving the pipeline a from-nothing, -/// replacement-consulting placement — worth doing when a production card-backed -/// liminal entrant exists, not before. -fn place_unentered_aura_in_owners_graveyard( - state: &mut GameState, - object_id: ObjectId, - owner: PlayerId, -) { - // allow-raw-zone: rewinds a CR 303.4g-denied battlefield entry; the entry never happened, so no ZoneChanged may be emitted. - zones::remove_from_zone(state, object_id, Zone::Battlefield, owner); - if let Some(object) = state.objects.get_mut(&object_id) { - // allow-raw-zone: places a CR 303.4g-denied entrant in its owner's graveyard; the denied entry is not a replaceable CR 400.7 event. - object.zone = Zone::Graveyard; - } - // allow-raw-zone: pairs with the zone assignment directly above for the same CR 303.4g-denied entry. - zones::add_to_zone(state, object_id, Zone::Graveyard, owner); -} - pub(crate) fn commit_liminal_token_entry_with_post_actions( state: &mut GameState, event: ProposedEvent, @@ -1608,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; }; @@ -1617,8 +1598,10 @@ 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: the settled copy-token birth journals at the single liminal insert // seam. `copy_resume` is `Some` for every production liminal entry of kind @@ -1652,20 +1635,21 @@ pub(crate) fn commit_liminal_token_entry_with_post_actions( } }; ResolvedTokenCreationCommand { - object: ObjectIncarnationRef::from_object(&entry.object), + 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, @@ -1673,19 +1657,9 @@ pub(crate) fn commit_liminal_token_entry_with_post_actions( } }); - // CR 303.4g: what "no legal host" means for THIS entrant, decided from the - // liminal projection BEFORE it is installed. `ProposedEvent::TokenEntry` - // carries no from-zone — the entrant is a set of characteristics no zone list - // holds — so the origin is `NoPriorZone` and the shared authority in - // `zone_pipeline` maps it: a token isn't created, a card-backed entrant is put - // into its owner's graveyard. Neither may enter unattached and be left to the - // CR 704.5m state-based action; the rule denies the entry itself. - let unhosted_entry = crate::game::zone_pipeline::unhosted_aura_entry( - &entry.object, - crate::game::zone_pipeline::UnhostedAuraOrigin::NoPriorZone, - ); - - state.objects.insert(entry_ref, entry.object); + 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); @@ -1731,37 +1705,29 @@ pub(crate) fn commit_liminal_token_entry_with_post_actions( // owner's graveyard instead of entering the battlefield. If the Aura is a // token, it isn't created." // - // EVERY disposition denies the entry — 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 + // 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() ) { - match unhosted_entry { - // CR 303.4g + CR 111.1: "it isn't created" — and, unlike the CR 704.5m - // sweep it replaces, nothing lands in the owner's graveyard either. - crate::game::zone_pipeline::UnhostedAuraEntry::NotCreated => { - uncreate_unentered_aura_token(state, entry_ref, owner); - } - // Card-backed. Not reachable from any production caller today — the - // only production producer of `ProposedEvent::TokenEntry` is the - // liminal copy-token seam in `token_copy.rs`, whose - // `materialize_token_copy_body` sets `is_token` — but handled rather - // than assumed away, because the seam's contract is a `GameObject` - // and nothing in the type forbids a card-backed one. `NoPriorZone` - // cannot yield `RemainInCurrentZone`, so both remaining arms are the - // graveyard; they are listed exhaustively so a future origin that - // does yield it forces a decision here. - crate::game::zone_pipeline::UnhostedAuraEntry::OwnersGraveyard - | crate::game::zone_pipeline::UnhostedAuraEntry::RemainInCurrentZone => { - place_unentered_aura_in_owners_graveyard(state, entry_ref, owner); - } - } + // 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 @@ -7310,137 +7276,274 @@ mod tests { ); } - /// CR 303.4g on the liminal seam, for BOTH dispositions: an unhosted entrant - /// never enters, and what happens instead is selected by CR 111.1 token-ness. - /// - /// The card-backed half is not reachable from a production caller today (the - /// only production producer of `ProposedEvent::TokenEntry` is the liminal - /// copy-token seam in `token_copy.rs`, whose `materialize_token_copy_body` - /// sets `is_token`), which is precisely why it is pinned here: the seam takes - /// a `GameObject`, nothing in that type forbids a card-backed one, and the - /// arm it used to take — enter unattached and wait for the CR 704.5m - /// state-based action — is an entry CR 303.4g says never happens. - #[test] - fn an_unhosted_liminal_aura_entrant_never_enters_whatever_its_token_ness() { - use crate::types::game_state::LiminalEntry; - use crate::types::keywords::Keyword; + /// 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; - // `is_token` selects the disposition; everything else is identical. - for is_token in [true, false] { - 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 = 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) + } - // The entrant: an Aura with `Enchant creature` in a game with no - // creature anywhere, so the CR 303.4f consult finds no legal host. - let (entry_ref, mut entrant) = - reserve_liminal_token_object(&mut state, PlayerId(0), "Unhosted Aura".to_string()); - entrant.is_token = is_token; - 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, + 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), ), - )); - entrant.keywords = vec![enchant.clone()]; - entrant.base_keywords = vec![enchant]; - let timestamp = state.next_timestamp(); - entrant.reset_for_battlefield_entry(state.turn_number, timestamp); + source_id, + ), + ); - state.liminal_entries.insert( - entry_ref, - LiminalEntry { - object: entrant, - 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, + 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(), - kind: crate::types::game_state::LiminalEntryKind::Token, - replacement_applied: std::collections::HashSet::new(), + applied: std::collections::HashSet::new(), }, - ); - - 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" - ); + &mut events, + TokenEntryEventEmission::Emit, + Vec::new(), + ), + "a denied entry is not a pause — the batch loop must continue" + ); - // Shared by both dispositions: the entry never happened, and nothing - // observed one. - assert!( - !state.battlefield.iter().any(|&id| id == entry_ref), - "CR 303.4g: the unhosted Aura must not be on the battlefield \ - (is_token = {is_token})" - ); - 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, - to: Zone::Battlefield, - .. - } if *object_id == entry_ref + // 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 303.4g: no battlefield ZoneChanged for an entry the rule denies" - ); + "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()); + } - if is_token { - // CR 303.4g + CR 111.1: "it isn't created" — and, unlike the - // CR 704.5m sweep it replaces, nothing reaches any graveyard. - assert!( - !state.objects.contains_key(&entry_ref), - "a token CR 303.4g denies is not created at all" - ); - assert!(state.players[0].graveyard.is_empty()); - } else { - // CR 303.4g: a card-backed entrant is not destroyed — it is put - // into its owner's graveyard, the rule's own non-battlefield - // outcome for an entrant with nowhere to remain. - assert_eq!( - state.objects[&entry_ref].zone, - Zone::Graveyard, - "a card-backed entrant CR 303.4g denies goes to its owner's graveyard" - ); - assert!( - state.players[0].graveyard.iter().any(|&id| id == entry_ref), - "the owner's graveyard actually holds it" - ); - } - } + /// 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] @@ -7493,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, @@ -7506,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 0c2504ac10..8afd4c4072 100644 --- a/crates/engine/src/game/effects/token_copy.rs +++ b/crates/engine/src/game/effects/token_copy.rs @@ -575,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, diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 9be51e04ec..109ac1e652 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -1782,7 +1782,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(), @@ -2252,6 +2252,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()) @@ -2304,7 +2305,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| { ( @@ -7255,7 +7256,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 069a9bd632..0f074d186f 100644 --- a/crates/engine/src/game/filter.rs +++ b/crates/engine/src/game/filter.rs @@ -2392,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; @@ -2420,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, @@ -2439,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, 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 d0e45fe004..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" ); diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index ff08ad6c75..018715ff2e 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -218,7 +218,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)) } @@ -6253,7 +6253,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; }; @@ -6796,6 +6796,7 @@ fn legacy_object_replacement_candidates( candidates.extend( entry .object + .projected() .replacement_definitions .iter_all() .enumerate() @@ -6845,6 +6846,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/visibility.rs b/crates/engine/src/game/visibility.rs index eb87e158a3..5eb9601d26 100644 --- a/crates/engine/src/game/visibility.rs +++ b/crates/engine/src/game/visibility.rs @@ -2246,12 +2246,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 e598bf2cc1..b11d736e43 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -1832,7 +1832,7 @@ fn entering_object_projection(state: &GameState, object_id: ObjectId) -> Option< state .liminal_entries .get(&object_id) - .map(|entry| &entry.object) + .map(|entry| entry.object.projected()) .or_else(|| state.objects.get(&object_id)) } @@ -1964,20 +1964,6 @@ fn legal_aura_attachment_targets( targets } -/// Where a CR 303.4g entrant would "remain", for a seam that is deciding an -/// Aura's fate BEFORE the entry happens. -/// -/// The rule's non-token dispositions are both phrased against the zone the Aura -/// is entering from, so a caller must state which it has. A liminal projection -/// has none: the entrant is a set of characteristics that no zone list holds yet. -pub(crate) enum UnhostedAuraOrigin { - /// A CR 400.7 zone change: the `from` zone of the approved event. - Zone(Zone), - /// A liminal entry (`ProposedEvent::TokenEntry`): the entrant exists in no - /// zone, so there is nothing for it to "remain" in. - NoPriorZone, -} - /// 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". /// @@ -1998,28 +1984,25 @@ pub(crate) enum UnhostedAuraEntry { } /// CR 303.4g: select the disposition from the two facts the rule keys on — the -/// entrant's CR 111.1 token-ness, and where it is entering from. +/// entrant's CR 111.1 token-ness, and the zone it is entering from. /// -/// [`UnhostedAuraOrigin::NoPriorZone`] takes the graveyard arm for a card-backed -/// entrant: "remains in its current zone" has no referent for an entrant that is -/// in none, which is the same shape as the stack case the rule already answers -/// (an object whose pre-entry location cannot hold a permanent card). It is -/// never the destructive reading — the card is never simply dropped. -pub(crate) fn unhosted_aura_entry( - entrant: &GameObject, - origin: UnhostedAuraOrigin, -) -> UnhostedAuraEntry { +/// 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 - // where the effect was putting it onto the battlefield from. + // which zone the effect was putting it onto the battlefield from. if entrant.is_token { return UnhostedAuraEntry::NotCreated; } - match origin { - UnhostedAuraOrigin::Zone(Zone::Stack) | UnhostedAuraOrigin::NoPriorZone => { - UnhostedAuraEntry::OwnersGraveyard - } - UnhostedAuraOrigin::Zone(_) => UnhostedAuraEntry::RemainInCurrentZone, + match from { + Zone::Stack => UnhostedAuraEntry::OwnersGraveyard, + _ => UnhostedAuraEntry::RemainInCurrentZone, } } @@ -2148,7 +2131,7 @@ pub(crate) fn entering_aura_hosts(state: &GameState, object_id: ObjectId) -> Ent // `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.clone(); + let entrant = entry.object.projected().clone(); return entering_aura_hosts_projected(state, object_id, &entrant); } let Some(entrant) = state.objects.get(&object_id) else { @@ -2577,21 +2560,17 @@ mod entering_aura_attachment_tests { } /// 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 where the effect was - /// putting it onto the battlefield from. + /// 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 origin in [ - UnhostedAuraOrigin::NoPriorZone, - UnhostedAuraOrigin::Zone(Zone::Stack), - UnhostedAuraOrigin::Zone(Zone::Graveyard), - ] { + for from in [Zone::Stack, Zone::Graveyard, Zone::Exile] { assert!(matches!( - unhosted_aura_entry(&entrant, origin), + unhosted_aura_entry(&entrant, from), UnhostedAuraEntry::NotCreated )); } @@ -2599,8 +2578,14 @@ mod entering_aura_attachment_tests { /// 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." A liminal projection has - /// no current zone at all, so it takes the same non-destructive arm. + /// 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); @@ -2608,19 +2593,15 @@ mod entering_aura_attachment_tests { let entrant = state.objects[&id].clone(); assert!(matches!( - unhosted_aura_entry(&entrant, UnhostedAuraOrigin::Zone(Zone::Graveyard)), + unhosted_aura_entry(&entrant, Zone::Graveyard), UnhostedAuraEntry::RemainInCurrentZone )); assert!(matches!( - unhosted_aura_entry(&entrant, UnhostedAuraOrigin::Zone(Zone::Exile)), + unhosted_aura_entry(&entrant, Zone::Exile), UnhostedAuraEntry::RemainInCurrentZone )); assert!(matches!( - unhosted_aura_entry(&entrant, UnhostedAuraOrigin::Zone(Zone::Stack)), - UnhostedAuraEntry::OwnersGraveyard - )); - assert!(matches!( - unhosted_aura_entry(&entrant, UnhostedAuraOrigin::NoPriorZone), + unhosted_aura_entry(&entrant, Zone::Stack), UnhostedAuraEntry::OwnersGraveyard )); } @@ -2690,10 +2671,13 @@ mod entering_aura_attachment_tests { /// 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 of the kind the liminal card-backed sibling still - /// uses, which emits nothing at all and so fires no "put into a graveyard from - /// anywhere" trigger. The revert-discriminating assertion for the routing - /// change itself is in the redirect test below. + /// 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); @@ -4055,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 @@ -4181,9 +4165,9 @@ fn execute_zone_move_with_applied_terminal( // event — the rule denies the entry, so nothing may // observe one. [] => { - match entering_object_projection(state, *object_id).map(|entrant| { - unhosted_aura_entry(entrant, UnhostedAuraOrigin::Zone(*from)) - }) { + match entering_object_projection(state, *object_id) + .map(|entrant| unhosted_aura_entry(entrant, *from)) + { Some(UnhostedAuraEntry::OwnersGraveyard) => { unhosted_to_owners_graveyard = true; } 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 b335466115..d09c895b8f 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -13740,9 +13740,129 @@ pub struct EnteringAuraAuthority { 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, @@ -22751,6 +22871,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/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), From d9912081213d568c1ec91fb15c05afc0004955e3 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 13 Aug 2026 00:05:07 -0700 Subject: [PATCH 7/7] fix(PR-7303): re-pin optional-effect census --- crates/engine/src/game/engine.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 4ae47b8ed3..05b7557e0c 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -16105,9 +16105,9 @@ 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. - "game/effects/mod.rs:6306".to_string(), - "game/effects/mod.rs:6383".to_string(), - "game/effects/mod.rs:9578".to_string(), + "game/effects/mod.rs:6548".to_string(), + "game/effects/mod.rs:6625".to_string(), + "game/effects/mod.rs:9820".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.