diff --git a/crates/engine/src/game/effects/add_target_replacement.rs b/crates/engine/src/game/effects/add_target_replacement.rs index d3cd9162bc..ccd3d5faff 100644 --- a/crates/engine/src/game/effects/add_target_replacement.rs +++ b/crates/engine/src/game/effects/add_target_replacement.rs @@ -954,7 +954,7 @@ mod tests { sacrifice_at: None, source_id: ObjectId(50), controller: PlayerId(1), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let proposed = ProposedEvent::CreateToken { owner: PlayerId(1), @@ -1048,7 +1048,7 @@ mod tests { sacrifice_at: None, source_id: ObjectId(70), controller: PlayerId(1), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let proposed = ProposedEvent::CreateToken { owner: PlayerId(1), @@ -1132,7 +1132,7 @@ mod tests { sacrifice_at: None, source_id: ObjectId(60), controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let proposed = ProposedEvent::CreateToken { owner: PlayerId(0), diff --git a/crates/engine/src/game/effects/amass.rs b/crates/engine/src/game/effects/amass.rs index c71b41871d..d020f07515 100644 --- a/crates/engine/src/game/effects/amass.rs +++ b/crates/engine/src/game/effects/amass.rs @@ -204,7 +204,7 @@ fn create_army_token( enter_with_counters: vec![], tapped: false, enters_attacking: false, - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, sacrifice_at: None, source_id: ability.source_id, controller: ability.controller, @@ -383,7 +383,7 @@ mod tests { enter_with_counters: Vec::new(), tapped: false, enters_attacking: false, - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, sacrifice_at: None, source_id: ObjectId(0), controller: P0, diff --git a/crates/engine/src/game/effects/attach.rs b/crates/engine/src/game/effects/attach.rs index f41e8b7162..1870cb427f 100644 --- a/crates/engine/src/game/effects/attach.rs +++ b/crates/engine/src/game/effects/attach.rs @@ -685,7 +685,7 @@ pub(crate) enum AttachmentAuthority<'a> { /// 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( +pub(crate) fn authority_is_aura( state: &GameState, attachment_id: ObjectId, authority: AttachmentAuthority<'_>, diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index 2098c4b082..8380be4bf5 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -27,7 +27,7 @@ use crate::types::keywords::{Keyword, WardCost}; use crate::types::mana::{ManaColor, ManaCost}; use crate::types::phase::Phase; use crate::types::player::PlayerId; -use crate::types::proposed_event::{CopyTokenSpec, ProposedEvent, TokenSpec}; +use crate::types::proposed_event::{CopyTokenSpec, ProposedEvent, TokenHostRequest, TokenSpec}; use crate::types::resolved_commands::{ ResolvedCopyBodyModifications, ResolvedTokenBody, ResolvedTokenCreationCommand, ResolvedTokenCreationReplayInvariantError, @@ -539,9 +539,16 @@ pub fn resolve( // CR 303.4 + CR 303.4i: Resolve the specified Aura/Role host once, at propose // time. ParentTarget reads the first Object target (the for-each loop's // per-iteration rebind binds it); Typed/event-context filters resolve via the - // shared target/event-context path. `None` for ordinary (unattached) tokens. - let attach_target: Option = - attach_to.and_then(|f| resolve_attach_host(state, ability, f)); + // shared target/event-context path. + // + // The result keeps "the instruction named no host" apart from "it named one + // and nothing bound it": CR 303.4i denies the entry of an Aura token in the + // second case and says nothing about the first, and the seam that applies + // that verdict runs after the CR 614 replacement pipeline, far from here. + let host_request = TokenHostRequest::from_binding( + attach_to.is_some(), + attach_to.and_then(|f| resolve_attach_host(state, ability, f)), + ); // CR 111.1 + CR 111.4: Resolve the token's characteristics into a // self-describing `TokenSpec`. Script-name parsing takes precedence; @@ -579,7 +586,7 @@ pub fn resolve( enters_attacking, token_statics, resolved_etb_counters, - attach_target, + host_request, ability, state, ); @@ -655,7 +662,7 @@ fn build_token_spec( enters_attacking: bool, static_abilities: Vec, enter_with_counters: Vec<(CounterType, u32)>, - attach_to: Option, + attach_to: TokenHostRequest, ability: &ResolvedAbility, state: &GameState, ) -> TokenSpec { @@ -757,6 +764,60 @@ fn intrinsic_equip_abilities_from_token_statics( /// creation, etc.) through the same code path. /// /// `event` must be a `ProposedEvent::CreateToken`; other variants are no-ops. +/// CR 303.4i: does the rule deny this entrant's battlefield entry? +/// +/// > If an effect attempts to put an Aura onto the battlefield attached to +/// > either an object or player it can't legally enchant or an object or player +/// > that is undefined, … If the Aura is a token, it isn't created. +/// +/// Asked per token and on the ACTUAL entrant, after the CR 614 replacement +/// pipeline has settled its characteristics: a replacement effect may create +/// something other than the Aura the instruction described, and CR 303.4i is a +/// question about what is entering rather than about what was announced. +/// +/// Both halves are answered by the authority that already owns them — +/// [`attach::authority_is_aura`] for Aura-ness (CR 205.1a: a copy exception can +/// add or remove the subtype, so it reads characteristics, not the effect's +/// `types`), and [`attach::can_attach_to_object`] / [`attach::can_attach_to_player`] +/// for "can't legally enchant". That second pair is the SAME verdict +/// `attach::attach_to` consumes a few lines later, so the gate and the +/// attachment can never disagree about one host. +/// +/// [`attach::authority_is_aura`]: super::attach::authority_is_aura +/// [`attach::can_attach_to_object`]: super::attach::can_attach_to_object +/// [`attach::can_attach_to_player`]: super::attach::can_attach_to_player +fn aura_token_entry_denied(state: &GameState, entrant: ObjectId, host: TokenHostRequest) -> bool { + // CR 303.4h: "If an effect attempts to put a permanent that isn't an Aura, + // Equipment, or Fortification onto the battlefield attached to an object or + // player, it enters the battlefield unattached." It is created either way, + // so CR 303.4i is not its rule. + if !super::attach::authority_is_aura(state, entrant, super::attach::AttachmentAuthority::Stored) + { + return false; + } + match host { + // CR 303.4f — an Aura entering with no effect-specified host has its + // controller choose one — is a different rule with a different + // disposition (a choice, not a denial). Its consult belongs to the entry + // pipeline (`zone_pipeline::entering_aura_hosts`), which the liminal and + // copy token seams reach and this one does not — a stated gap, not an + // oversight. Measured over the shipped pool: every Aura-typed token spec + // names a host, so no card reaches this arm today. + TokenHostRequest::NotRequested => false, + // CR 303.4i, "undefined": the instruction named a host and nothing bound + // it — the shape #7302 reported. + TokenHostRequest::Unbound => true, + // CR 303.4i, "can't legally enchant": the host is defined, so the + // question is legality, and legality is `attach`'s to answer. + TokenHostRequest::Bound(AttachTarget::Object(host_id)) => { + !super::attach::can_attach_to_object(state, entrant, host_id) + } + TokenHostRequest::Bound(AttachTarget::Player(host_player)) => { + !super::attach::can_attach_to_player(state, entrant, host_player) + } + } +} + pub fn apply_create_token_after_replacement( state: &mut GameState, event: ProposedEvent, @@ -846,6 +907,28 @@ pub(crate) fn apply_create_token_after_replacement_with_created_ids( ObjectIncarnationRef::from_object(obj) }); + // CR 303.4i: settle whether this entrant is created at all, BEFORE the + // CR 733 birth is journaled (the journal is append-only — a birth + // recorded here could not be retracted) and before anything the game can + // observe is emitted. Same decide/act split as the copy seam in + // `token_copy.rs` and the liminal seam in + // `commit_liminal_token_entry_with_post_actions`. + // + // Everything done so far for this token is silent: `zones::create_object` + // inserts and zones the object without an event, and + // `materialize_token_spec_body` only fills it in. `uncreate_unentered_ + // aura_token` undoes exactly that pair, so a denied entry leaves no + // `TokenCreated`, no `ZoneChanged`, no birth record, no `created_ids` + // row, and nothing in any graveyard. The loop goes on to the next token + // of the count; the tail's `last_created_token_ids = created_ids` then + // publishes only the tokens that were actually created, so a later + // `TargetFilter::LastCreated` cannot read a denied one — or, when the + // whole batch is denied, an earlier unrelated batch. + if aura_token_entry_denied(state, obj_id, spec.attach_to) { + uncreate_unentered_aura_token(state, obj_id, owner); + continue; + } + // CR 733: journal the settled creation, after the body borrow ends. // Counters, the attacking entry, and any later status change journal // through their OWN families, so this command covers the birth only. @@ -907,7 +990,7 @@ pub(crate) fn apply_create_token_after_replacement_with_created_ids( PendingCounterPostAction::FinalizeTokenEntry { object_id: obj_id, name: spec.characteristics.display_name.clone(), - attach_to: spec.attach_to, + attach_to: spec.attach_to.bound(), sacrifice_at: spec.sacrifice_at.clone(), source_id: spec.source_id, controller: spec.controller, @@ -954,21 +1037,21 @@ pub(crate) fn apply_create_token_after_replacement_with_created_ids( // `push_committed_token_entry_events` below — recording it here too double-counts. crate::game::restrictions::record_token_created(state, obj_id); - // CR 303.4 + CR 303.7: A Role/Aura token created "attached to" a host - // enters attached. If no legal host was bound, the token is created - // unattached and the SBA at CR 704.5m (an Aura not attached to an object - // or player is put into its owner's graveyard) removes it; for multiple - // same-controller Roles on one host, CR 704.5y keeps only the - // latest-timestamp Role. (CR 303.4i's strict "the token isn't created" - // outcome is approximated by this create-then-SBA path.) Single - // authority: effects::attach. - if let Some(host) = &spec.attach_to { + // CR 303.4: A Role/Aura token created "attached to" a host enters + // attached. The ACT half of the CR 303.4i split above: an Aura whose + // host is undefined or illegal never reaches this line, so this is an + // attachment that the gate has already found legal, not an attempt. + // CR 303.4h keeps the other class here — a non-Aura token named a host + // too, and if that host is illegal it simply enters unattached. For + // multiple same-controller Roles on one host, CR 704.5z keeps only the + // latest-timestamp Role. Single authority: effects::attach. + if let Some(host) = spec.attach_to.bound() { match host { AttachTarget::Object(id) => { - super::attach::attach_to(state, obj_id, *id); + super::attach::attach_to(state, obj_id, id); } AttachTarget::Player(pid) => { - super::attach::attach_to_player(state, obj_id, *pid); + super::attach::attach_to_player(state, obj_id, pid); } } } @@ -1521,7 +1604,8 @@ 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. +/// CR 303.4g / CR 303.4i: undo a token battlefield entry the Aura-entry rules +/// say was never created. /// /// The ONLY unhosted-entry disposition the liminal seam has, because the only /// entrant that seam can hold is a [`crate::types::game_state::TokenProjection`] @@ -1545,13 +1629,13 @@ pub(crate) fn uncreate_unentered_aura_token( // (`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`, + // This is the un-entry of a token that CR 303.4g or CR 303.4i 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. + // allow-raw-zone: undoes a CR 303.4g/CR 303.4i-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); } @@ -2336,7 +2420,8 @@ pub(crate) fn spec_emits_only_etb_pair(spec: &TokenSpec) -> bool { spec.enter_with_counters.is_empty() // no CounterAdded event / AddCounter replacement && !spec.enters_attacking // no combat-state mutation (CR 508.4) && spec.sacrifice_at.is_none() // no delayed trigger (CR 603.7) - && spec.attach_to.is_none() // no host attachment mutation (CR 303.4) + // no host attachment mutation and no CR 303.4i entry verdict to reach + && !spec.attach_to.is_requested() } /// CR 603.6a + CR 111.1: The set of event keys a single produced token EMITS as @@ -2843,7 +2928,7 @@ pub(crate) fn copy_probe_spec_for( sacrifice_at, source_id, controller, - attach_to: None, + attach_to: TokenHostRequest::NotRequested, } } @@ -2924,9 +3009,12 @@ pub(crate) fn resolve_token_spec( let count = resolve_quantity_with_targets(state, count, ability).max(0) as u32; let token_owner = resolve_token_owner(state, ability, owner); - let attach_target = attach_to - .as_ref() - .and_then(|f| resolve_attach_host(state, ability, f)); + let host_request = TokenHostRequest::from_binding( + attach_to.is_some(), + attach_to + .as_ref() + .and_then(|f| resolve_attach_host(state, ability, f)), + ); let parsed = parse_token_script(name).or_else(|| { build_token_attrs_from_effect( @@ -2951,7 +3039,7 @@ pub(crate) fn resolve_token_spec( *enters_attacking, static_abilities.clone(), resolved_etb_counters, - attach_target, + host_request, ability, state, ); @@ -6192,7 +6280,7 @@ mod tests { sacrifice_at: None, source_id: source, controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let event = ProposedEvent::CreateToken { owner: PlayerId(0), @@ -6271,7 +6359,7 @@ mod tests { sacrifice_at: None, source_id: source, controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let event = ProposedEvent::CreateToken { owner: PlayerId(0), @@ -6340,7 +6428,7 @@ mod tests { sacrifice_at: None, source_id: source, controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let event = ProposedEvent::CreateToken { owner: PlayerId(0), @@ -6418,7 +6506,7 @@ mod tests { sacrifice_at: None, source_id: source, controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let event = ProposedEvent::CreateToken { owner: PlayerId(0), @@ -6608,7 +6696,7 @@ mod tests { sacrifice_at: None, source_id: source, controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let event = ProposedEvent::CreateToken { owner: PlayerId(0), @@ -7169,7 +7257,7 @@ mod tests { sacrifice_at: None, source_id: ObjectId(100), controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let event = ProposedEvent::CreateToken { @@ -7234,7 +7322,7 @@ mod tests { sacrifice_at: None, source_id: ObjectId(100), controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let event = ProposedEvent::CreateToken { @@ -7302,7 +7390,7 @@ mod tests { sacrifice_at: None, source_id: ObjectId(101), controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let event = ProposedEvent::CreateToken { @@ -7377,7 +7465,7 @@ mod tests { sacrifice_at: None, source_id: ObjectId(102), controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let event = ProposedEvent::CreateToken { @@ -7431,7 +7519,7 @@ mod tests { sacrifice_at: None, source_id: ObjectId(100), controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let event = ProposedEvent::CreateToken { diff --git a/crates/engine/src/game/engine_debug.rs b/crates/engine/src/game/engine_debug.rs index cb7ed9bf29..dc2f6af54b 100644 --- a/crates/engine/src/game/engine_debug.rs +++ b/crates/engine/src/game/engine_debug.rs @@ -540,7 +540,7 @@ pub fn apply_debug_action( sacrifice_at: None, source_id: ObjectId(0), controller: owner, - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let proposed = ProposedEvent::CreateToken { owner, diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index b86c6b2309..62afb65cca 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -4458,7 +4458,7 @@ mod tests { sacrifice_at: None, source_id: ObjectId(1), controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let battlefield_before = state.battlefield.clone(); @@ -4583,7 +4583,7 @@ mod tests { sacrifice_at: None, source_id: replacement_source, controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let battlefield_before = state.battlefield.clone(); @@ -4741,7 +4741,7 @@ mod tests { sacrifice_at: None, source_id: replacement_source, controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let battlefield_before = state.battlefield.clone(); @@ -4916,7 +4916,7 @@ mod tests { sacrifice_at: None, source_id: replacement_source, controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let battlefield_before = state.battlefield.clone(); @@ -5070,7 +5070,7 @@ mod tests { sacrifice_at: None, source_id: jinnie_source, controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let applied = state .post_replacement_token_choice_applied @@ -5207,7 +5207,7 @@ mod tests { sacrifice_at: None, source_id: jinnie_source, controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let battlefield_before = state.battlefield.clone(); diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index b0ded3ff4c..738f6a3fda 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -14430,7 +14430,7 @@ mod tests { sacrifice_at: None, source_id: ObjectId(999), controller: owner_controller, - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, } } @@ -17708,7 +17708,7 @@ mod tests { sacrifice_at: None, source_id: ObjectId(0), controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let repl = ReplacementDefinition::new(ReplacementEvent::CreateToken) .token_owner_scope(ControllerRef::You) @@ -17735,7 +17735,7 @@ mod tests { sacrifice_at: None, source_id: chatterfang, controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let proposed = ProposedEvent::CreateToken { owner: PlayerId(0), @@ -17807,7 +17807,7 @@ mod tests { sacrifice_at: None, source_id: ObjectId(0), controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, } } @@ -17941,7 +17941,7 @@ mod tests { sacrifice_at: None, source_id: ObjectId(0), controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let proposed = ProposedEvent::CreateToken { @@ -18269,7 +18269,7 @@ mod tests { sacrifice_at: None, source_id: ObjectId(0), controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, } } @@ -18496,7 +18496,7 @@ mod tests { sacrifice_at: None, source_id: ObjectId(0), controller: PlayerId(1), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; let proposed = ProposedEvent::CreateToken { @@ -19024,7 +19024,7 @@ mod tests { sacrifice_at: None, source_id: ObjectId(0), controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, } } @@ -19142,7 +19142,7 @@ mod tests { sacrifice_at: None, source_id: ObjectId(0), controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, } } @@ -19322,7 +19322,7 @@ mod tests { sacrifice_at: None, source_id: ObjectId(0), controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }), copy: None, enter_tapped: EtbTapState::Unspecified, diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 0fba5db95a..78b24d0018 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -9007,7 +9007,7 @@ mod tests { sacrifice_at: None, source_id: ObjectId(1), controller: PlayerId(0), - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }; // Bare spec passes. assert!(super::super::effects::token::spec_emits_only_etb_pair( @@ -9033,7 +9033,9 @@ mod tests { )); let mut attached = base.clone(); - attached.attach_to = Some(crate::game::game_object::AttachTarget::Object(ObjectId(2))); + attached.attach_to = crate::types::proposed_event::TokenHostRequest::Bound( + crate::game::game_object::AttachTarget::Object(ObjectId(2)), + ); assert!(!super::super::effects::token::spec_emits_only_etb_pair( &attached )); diff --git a/crates/engine/src/parser/oracle_effect/token.rs b/crates/engine/src/parser/oracle_effect/token.rs index dbda946870..4681d6a3c4 100644 --- a/crates/engine/src/parser/oracle_effect/token.rs +++ b/crates/engine/src/parser/oracle_effect/token.rs @@ -453,6 +453,29 @@ fn tracked_set_count_is_type_restricted(qty: &QuantityRef) -> bool { .any(|type_filter| !matches!(type_filter, TypeFilter::Card)) } +/// CR 303.4: The printed surfaces that bind a created token to a host inside the +/// same create-token instruction — "an Aura enters the battlefield attached to +/// an object or player". `" attached to "` states the relation and +/// `" and attach it to "` states the action; the resulting permanent is +/// identical, so both feed one `attach_to` field rather than two code paths. +/// +/// Scanned at word boundaries with a single `alt`, so the connector that occurs +/// FIRST in the text wins regardless of which spelling it is — testing each +/// spelling over the whole string separately would let a later "attached to" +/// beat an earlier "and attach it to". +fn first_token_attachment_connector(lower: &str) -> Option<&'static str> { + nom_primitives::scan_at_word_boundaries(lower, |input| { + alt(( + value( + " and attach it to ", + tag::<_, _, OracleError<'_>>("and attach it to "), + ), + value(" attached to ", tag("attached to ")), + )) + .parse(input) + }) +} + fn parse_token_description_with_context( text: &str, ctx: &ParseContext, @@ -460,14 +483,21 @@ fn parse_token_description_with_context( let text = text.trim().trim_end_matches('.'); let lower = text.to_lowercase(); - // CR 303.7: Strip "attached to [target]" suffix and capture the attachment target. + // CR 303.4: Strip the attachment clause and capture its target. Oracle + // prints the same relation two ways in a create-token instruction — as a + // STATE ("create a Cursed Role token attached to target creature") and as an + // ACTION ("create a Questing Role token and attach it to target creature"). + // Both mean the token enters attached, in the same instruction, so both bind + // the same `attach_to` field; only the printed surface differs. Keying on the + // state form alone dropped the attachment entirely for the action form, and + // CR 303.4i then says a hostless Aura token is not created at all (#7302). let tp = TextPair::new(text, &lower); - let (text, attach_to) = if let Some((before, after)) = tp.split_around(" attached to ") { - let (target, _) = parse_target(after.original); - (before.original, Some(target)) - } else { - (text, None) - }; + let (text, attach_to) = first_token_attachment_connector(&lower) + .and_then(|connector| tp.split_around(connector)) + .map_or((text, None), |(before, after)| { + let (target, _) = parse_target(after.original); + (before.original, Some(target)) + }); // CR 508.4 + CR 506.3a: Strip inline "that's tapped and attacking" / // "that is tapped and attacking" / "thats tapped and attacking" / @@ -3491,3 +3521,44 @@ fn copy_token_non_saga_token_you_control_issue_3294() { assert!(tf.properties.contains(&FilterProp::Token)); assert_eq!(tf.controller, Some(ControllerRef::You)); } + +#[cfg(test)] +mod token_attachment_connector_tests { + use super::*; + + /// CR 303.4 + CR 303.4i: Oracle prints one relation two ways inside a + /// create-token instruction — as a STATE ("…token attached to target + /// creature") and as an ACTION ("…token and attach it to target creature"). + /// Both must bind `attach_to`; the action surface used to drop it, leaving a + /// hostless Aura token that CR 303.4i says is not created at all + /// (Questing Cosplayer, #7302). + /// + /// Table-driven over both surfaces plus the counter-direction: a token line + /// with no attachment clause must keep `attach_to` at `None`. + #[test] + fn both_printed_attachment_surfaces_bind_the_host() { + let cases: &[(&str, bool)] = &[ + ( + "create a Questing Role token and attach it to target creature", + true, + ), + ( + "create a Cursed Role token attached to target creature", + true, + ), + ("create a 1/1 white Soldier creature token", false), + ]; + for (text, expects_host) in cases { + let effect = try_parse_token(&text.to_lowercase(), text, &mut ParseContext::default()) + .unwrap_or_else(|| panic!("{text:?} must parse as a token line")); + let Effect::Token { attach_to, .. } = effect else { + panic!("{text:?} must lower to Effect::Token"); + }; + assert_eq!( + attach_to.is_some(), + *expects_host, + "{text:?} host binding mismatch, got {attach_to:?}" + ); + } + } +} diff --git a/crates/engine/src/parser/oracle_replacement.rs b/crates/engine/src/parser/oracle_replacement.rs index c297f902da..524265c6dc 100644 --- a/crates/engine/src/parser/oracle_replacement.rs +++ b/crates/engine/src/parser/oracle_replacement.rs @@ -9605,7 +9605,7 @@ fn token_description_to_spec( controller: crate::types::player::PlayerId(0), // Replacement-created tokens ("instead, create a token") are not the // "attached to" Aura/Role class; that path flows through `Effect::Token`. - attach_to: None, + attach_to: crate::types::proposed_event::TokenHostRequest::NotRequested, }) } diff --git a/crates/engine/src/types/proposed_event.rs b/crates/engine/src/types/proposed_event.rs index 902bda9937..9b9baa7286 100644 --- a/crates/engine/src/types/proposed_event.rs +++ b/crates/engine/src/types/proposed_event.rs @@ -357,12 +357,73 @@ pub struct TokenSpec { /// creating the token (distinct from `owner`, the player to whom the /// token belongs). pub controller: PlayerId, - /// CR 303.4 + CR 303.7: When the token is an Aura/Role created "attached to" a - /// host, the resolved host (object or player). `None` for ordinary tokens. - /// Resolved once at propose time so the replacement-safe apply path attaches - /// each created token without re-reading ability.targets. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub attach_to: Option, + /// CR 303.4 + CR 303.4i: The token instruction's "attached to …" clause and + /// its binding outcome, resolved once at propose time so the + /// replacement-safe apply path attaches each created token without + /// re-reading `ability.targets`. + #[serde(default, skip_serializing_if = "TokenHostRequest::is_not_requested")] + pub attach_to: TokenHostRequest, +} + +/// CR 303.4i: what the token instruction asked for as a host, and whether +/// anything bound it. +/// +/// The distinction is load-bearing, which is why it is a type rather than an +/// `Option`: CR 303.4i denies the entry of an Aura token whose +/// named host is *undefined*, while an ordinary token that never named a host +/// is created normally. Both were `None` before, so the seam that had to tell +/// them apart could not. [`TokenHostRequest::Unbound`] is the state that +/// `None` could not express. +/// +/// Carried through the CR 614 replacement pipeline rather than consumed before +/// it: a replacement effect may change the entering token's characteristics, +/// so whether CR 303.4i applies is a question about the ACTUAL entrant and can +/// only be answered per token, after replacements. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum TokenHostRequest { + /// The instruction named no host. An ordinary token. + #[default] + NotRequested, + /// The instruction named a host and it resolved to this object or player. + /// Whether that host can legally be enchanted is a separate question, + /// owned by `effects::attach`. + Bound(AttachTarget), + /// CR 303.4i: the instruction named a host and nothing bound it — the host + /// is undefined. + Unbound, +} + +impl TokenHostRequest { + /// Whether the instruction named no host. Keeping this default omitted + /// preserves the existing wire shape for ordinary token creation events. + pub fn is_not_requested(&self) -> bool { + matches!(self, Self::NotRequested) + } + + /// The resolved host, if one bound. `None` for both of the other states — + /// use the variant itself when the difference matters. + pub fn bound(self) -> Option { + match self { + Self::Bound(target) => Some(target), + Self::NotRequested | Self::Unbound => None, + } + } + + /// Did the instruction name a host at all? + pub fn is_requested(self) -> bool { + !matches!(self, Self::NotRequested) + } + + /// Build the request from a named-host flag and its binding outcome. The + /// single place the three states are derived, so no caller re-encodes the + /// mapping. + pub fn from_binding(named: bool, bound: Option) -> Self { + match (named, bound) { + (_, Some(target)) => Self::Bound(target), + (true, None) => Self::Unbound, + (false, None) => Self::NotRequested, + } + } } /// CR 707.2 + CR 707.5: Copy-token creation payload carried by the same diff --git a/crates/engine/tests/integration/aura_token_attach_guard.rs b/crates/engine/tests/integration/aura_token_attach_guard.rs new file mode 100644 index 0000000000..ac0d129253 --- /dev/null +++ b/crates/engine/tests/integration/aura_token_attach_guard.rs @@ -0,0 +1,422 @@ +//! CR 303.4i: an Aura token whose host is undefined or illegal is NOT created (#7302). +//! +//! > If an effect attempts to put an Aura onto the battlefield attached to +//! > either an object or player it can't legally enchant or an object or player +//! > that is undefined, the Aura remains in its current zone. … If the Aura is a +//! > token, it isn't created. +//! +//! The engine used to create the token hostless and let the CR 704.5m +//! state-based action sweep it to a graveyard. That is observably different: the +//! token existed for a beat, fired enters-the-battlefield triggers, and left a +//! graveyard entry. +//! +//! Questing Cosplayer is the card that surfaced it, and it needed the parser +//! half too — "create a Questing Role token **and attach it to** target +//! creature" is the ACTION surface of the same CR 303.4 relation Oracle +//! otherwise prints as "…token **attached to** target creature", and only the +//! state surface was recognised, so the token was created with no host at all. +//! +//! ## What each row proves +//! +//! The production rows cast the card and let its enters trigger resolve, so the +//! parser binding, target selection and the token seam are proven together. The +//! rows marked as building blocks call `token::resolve` directly: they reach +//! shapes a card's own targeting layer would never hand the resolver (an +//! `attach_to` filter with NO bound target) or that the replacement pipeline +//! would have to be taught to produce (an entrant that stopped being an Aura). +//! They are evidence about the seam, not about a card. +//! +//! The third discriminator the rule needs — an UNBOUND host reached through the +//! production pipeline — lives in `self_attached_aura_token_host.rs`, whose +//! `RoleChain` rows activate a real ability whose host authority names nothing +//! (`CostPaidObject`, `SourceOrPaired`, an unbound chosen player) and assert the +//! same CR 303.4i outcome behind a reach guard. It is not repeated here. +//! +//! There is deliberately NO replay of the shipped Questing Cosplayer here. The +//! curated fixture stores the export's PRE-BAKED parse, which the card-data +//! pipeline regenerates — so a fixture replay would assert the old parse until +//! that pipeline runs, and would prove nothing about a parser change either way. +//! The rows below build the same printed sentence from Oracle text, which is +//! parsed by the parser under test at run time; for this PR that is the stronger +//! evidence, not the weaker one. + +use std::collections::HashSet; + +use engine::game::game_object::AttachTarget; +use engine::game::scenario::{GameScenario, P0}; +use engine::types::ability::{ + Effect, PtValue, QuantityExpr, ResolvedAbility, TargetFilter, TargetRef, TypeFilter, + TypedFilter, +}; +use engine::types::card_type::CoreType; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::proposed_event::{ + ProposedEvent, TokenCharacteristics, TokenHostRequest, TokenSpec, +}; +use engine::types::zones::{EtbTapState, Zone}; + +/// Questing Cosplayer's printed sentence with the reminder text dropped. Built +/// from Oracle text so the row runs in CI, where only the curated fixture +/// exists; the shipped card is replayed separately below. +const COSPLAYER_ETB: &str = + "When this creature enters, create a Questing Role token and attach it to target creature."; + +/// A host that is perfectly legal to TARGET and illegal to ENCHANT — CR 303.4i's +/// "can't legally enchant" half. Protection cannot express it: protection would +/// stop the trigger from targeting the creature at all, the trigger would never +/// resolve, and the empty board would prove nothing about the token seam. +const CANT_BE_ENCHANTED: &str = "This creature can't be enchanted."; + +fn pool(generic: usize, colors: &[ManaType]) -> Vec { + (0..generic) + .map(|_| ManaType::Colorless) + .chain(colors.iter().copied()) + .map(|kind| ManaUnit::new(kind, ObjectId(0), false, vec![])) + .collect() +} + +/// Every Role token in the game, in EVERY zone on purpose: the defect's +/// signature is a token that reached the battlefield and was swept, which a +/// battlefield-only query cannot tell apart from one that was never created. +fn role_tokens( + runner: &engine::game::scenario::GameRunner, +) -> Vec<&engine::game::game_object::GameObject> { + runner + .state() + .objects + .values() + .filter(|object| object.is_token && object.card_types.subtypes.iter().any(|s| s == "Role")) + .collect() +} + +// ── Production pipeline ───────────────────────────────────────────────────── + +/// The positive case, end to end: cast the creature, let its enters trigger +/// choose a target, and the Role must enter attached to it. +/// +/// This is also the reach guard for the negative row below — the two share a +/// board, so a harness that could not create the Role at all would fail here +/// first rather than passing the negative vacuously. +#[test] +fn questing_cosplayers_role_enters_attached_to_the_chosen_target() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool(P0, pool(4, &[])); + let host = scenario.add_creature(P0, "Chosen Host", 2, 2).id(); + let cosplayer = scenario + .add_creature_to_hand_from_oracle(P0, "Cosplaying Bard", 1, 1, COSPLAYER_ETB) + .id(); + let mut runner = scenario.build(); + + runner.cast(cosplayer).target_object(host).resolve(); + runner.advance_until_stack_empty(); + + let tokens = role_tokens(&runner); + assert_eq!(tokens.len(), 1, "the Role token must be created"); + assert_eq!(tokens[0].zone, Zone::Battlefield); + assert_eq!( + tokens[0].attached_to, + Some(AttachTarget::Object(host)), + "the Role enters attached to the creature the trigger targeted" + ); +} + +/// CR 303.4i, the "can't legally enchant" half — the half the first head of this +/// PR left unfixed. +/// +/// The host is a legal TARGET (nothing here restricts targeting), so the trigger +/// goes on the stack and resolves; the Role is simply not created. The reach +/// guard is the row above, which runs the identical board with an enchantable +/// host and gets its token. +#[test] +fn a_defined_but_illegal_host_creates_no_token() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool(P0, pool(4, &[])); + let host = scenario + .add_creature_from_oracle(P0, "Unenchantable Host", 2, 2, CANT_BE_ENCHANTED) + .id(); + let cosplayer = scenario + .add_creature_to_hand_from_oracle(P0, "Cosplaying Bard", 1, 1, COSPLAYER_ETB) + .id(); + let mut runner = scenario.build(); + + runner.cast(cosplayer).target_object(host).resolve(); + runner.advance_until_stack_empty(); + + assert!( + runner.state().battlefield.contains(&cosplayer), + "reach guard: the creature spell resolved, so its enters trigger ran" + ); + assert!( + role_tokens(&runner).is_empty(), + "CR 303.4i: an Aura token whose host can't legally be enchanted is not created" + ); + assert!( + runner.state().objects[&host].attachments.is_empty(), + "and nothing was attached to the host on the way past" + ); + // The discriminating assertion. "No Role token in any zone" alone is + // VACUOUS here: without the gate the token is created, `attach::attach_to` + // refuses the illegal host, the CR 704.5m unattached-Aura state-based action + // moves it, and CR 111.7 ends it there — leaving the same empty board. What + // separates "never created" from "created and swept" is the trail: the + // anaphora slot a later `TargetFilter::LastCreated` reads. + assert!( + runner.state().last_created_token_ids.is_empty(), + "CR 303.4i: an entry the rule denies leaves no created-token row behind, \ + got {:?}", + runner.state().last_created_token_ids + ); +} + +// ── Building blocks at the token seam ─────────────────────────────────────── + +/// A "Cursed Role" Aura token created attached to whatever `attach_to` names. +fn aura_token_effect(attach_to: Option) -> Effect { + token_effect( + "Cursed Role", + vec![ + "Enchantment".to_string(), + "Aura".to_string(), + "Role".to_string(), + ], + attach_to, + ) +} + +fn token_effect(name: &str, types: Vec, attach_to: Option) -> Effect { + Effect::Token { + name: name.to_string(), + power: PtValue::Fixed(0), + toughness: PtValue::Fixed(0), + types, + colors: vec![], + keywords: vec![], + tapped: false, + count: QuantityExpr::Fixed { value: 1 }, + owner: TargetFilter::Controller, + attach_to, + enters_attacking: false, + supertypes: vec![], + static_abilities: vec![], + enter_with_counters: vec![], + } +} + +fn creature_filter() -> TargetFilter { + TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + controller: None, + properties: Vec::new(), + }) +} + +struct Board { + runner: engine::game::scenario::GameRunner, + source: ObjectId, + host: ObjectId, +} + +fn board() -> Board { + let mut scenario = GameScenario::new(); + let host = scenario.add_creature(P0, "Host", 2, 2).id(); + let mut runner = scenario.build(); + let source = ObjectId(9001); + runner.state_mut().objects.insert( + source, + engine::game::game_object::GameObject::new( + source, + CardId(9001), + P0, + "Token Source".to_string(), + Zone::Battlefield, + ), + ); + Board { + runner, + source, + host, + } +} + +fn resolve(board: &mut Board, effect: Effect, targets: Vec) { + let ability = ResolvedAbility::new(effect, targets, board.source, P0); + let mut events = Vec::new(); + engine::game::effects::token::resolve(board.runner.state_mut(), &ability, &mut events) + .expect("token effect resolves"); +} + +fn tokens_named(board: &Board, needle: &str) -> Vec { + board + .runner + .state() + .objects + .values() + .filter(|object| object.is_token && object.name.contains(needle)) + .map(|object| object.id) + .collect() +} + +/// CR 303.4i, the "undefined" half: the instruction names a host, nothing binds +/// it, so no token is created — and, unlike the create-then-sweep path, nothing +/// lands in a graveyard either. +/// +/// Reverting the gate turns this red: the token is created, the CR 704.5m SBA +/// moves it, and the graveyard half of the assertion fails. +#[test] +fn an_aura_token_with_an_unbound_host_is_not_created() { + let mut board = board(); + resolve( + &mut board, + aura_token_effect(Some(creature_filter())), + vec![], + ); + + assert!( + tokens_named(&board, "Role").is_empty(), + "CR 303.4i: an Aura token with an undefined host is not created, in any zone" + ); + assert!( + board.runner.state().last_created_token_ids.is_empty(), + "CR 303.4i: an entry the rule denies leaves no created-token row behind, got {:?}", + board.runner.state().last_created_token_ids + ); +} + +/// Positive counter-direction at the seam: a bound host still creates the token +/// and attaches it. +#[test] +fn a_bound_host_still_gets_its_aura_token() { + let mut board = board(); + let host = board.host; + resolve( + &mut board, + aura_token_effect(Some(creature_filter())), + vec![TargetRef::Object(host)], + ); + + let tokens = tokens_named(&board, "Role"); + assert_eq!(tokens.len(), 1, "the Role token must be created"); + assert_eq!( + board.runner.state().objects[&tokens[0]] + .attached_to + .as_ref() + .and_then(|attached| attached.as_object()), + Some(host), + "the Role token must enter attached to its host" + ); +} + +/// CR 303.4h: "If an effect attempts to put a permanent that isn't an Aura, +/// Equipment, or Fortification onto the battlefield attached to an object or +/// player, it enters the battlefield unattached." +/// +/// Same undefined host as the row above, on a token that is not an Aura. The +/// rule's disposition is different — it is created, just unattached — so this is +/// what keeps the gate from spreading past the class CR 303.4i names. +#[test] +fn a_non_aura_token_with_an_unbound_host_is_created_unattached() { + let mut board = board(); + resolve( + &mut board, + token_effect( + "Spirit", + vec!["Creature".to_string(), "Spirit".to_string()], + Some(creature_filter()), + ), + vec![], + ); + + let tokens = tokens_named(&board, "Spirit"); + assert_eq!(tokens.len(), 1, "CR 303.4h: the token is created anyway"); + let token = &board.runner.state().objects[&tokens[0]]; + assert_eq!(token.zone, Zone::Battlefield); + assert_eq!(token.attached_to, None, "and it enters unattached"); +} + +/// An ordinary token that names no host at all is untouched, so nothing about +/// the common create-a-token path changes. +#[test] +fn an_ordinary_token_without_a_host_is_still_created() { + let mut board = board(); + let before = board.runner.state().battlefield.len(); + resolve(&mut board, aura_token_effect(None), vec![]); + + assert_eq!( + board.runner.state().battlefield.len(), + before + 1, + "a token that names no host is created as before" + ); +} + +/// WHY the gate sits after the CR 614 replacement pipeline rather than before +/// the proposal. +/// +/// A replacement effect may change what is actually entering. The event below is +/// what the pipeline hands the apply path in that case: the instruction named a +/// host and nothing bound it (`TokenHostRequest::Unbound`), but the entrant is +/// no longer an Aura — so CR 303.4i is not its rule and it must be created. A +/// gate keyed on the pre-replacement announcement, which is what the first head +/// of this PR had, would have suppressed it. +/// +/// What this row does NOT prove: that any shipped card replaces an Aura token +/// with a non-Aura one. None does today. It proves that the seam reads the +/// ENTRANT rather than the announcement, which is the property that makes the +/// placement correct rather than incidental. +#[test] +fn a_replacement_that_leaves_a_non_aura_entrant_still_creates_the_token() { + let mut board = board(); + let spec = TokenSpec { + characteristics: TokenCharacteristics { + display_name: "Spirit".to_string(), + power: Some(1), + toughness: Some(1), + core_types: vec![CoreType::Creature], + // What the replacement left behind: no Aura subtype. + subtypes: vec!["Spirit".to_string()], + supertypes: vec![], + colors: vec![], + keywords: vec![], + }, + script_name: "Spirit".to_string(), + static_abilities: vec![], + enter_with_counters: vec![], + tapped: false, + enters_attacking: false, + sacrifice_at: None, + source_id: board.source, + controller: P0, + // The announcement is untouched by the replacement: the instruction + // named a host and nothing bound it. + attach_to: TokenHostRequest::Unbound, + }; + + let mut events = Vec::new(); + engine::game::effects::token::apply_create_token_after_replacement( + board.runner.state_mut(), + ProposedEvent::CreateToken { + owner: P0, + spec: Box::new(spec), + copy: None, + enter_tapped: EtbTapState::Unspecified, + count: 1, + applied: HashSet::new(), + }, + &mut events, + ); + + let tokens = tokens_named(&board, "Spirit"); + assert_eq!( + tokens.len(), + 1, + "the entrant is not an Aura, so CR 303.4i does not deny it" + ); + assert_eq!( + board.runner.state().objects[&tokens[0]].attached_to, + None, + "CR 303.4h: it enters unattached" + ); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index e1744a5dc5..cdd51114eb 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -46,6 +46,7 @@ mod attacks_while_saddled_trigger; mod auntie_ool_minus_one_counter_trigger; mod aura_graft_enchant_restriction; mod aura_on_player; +mod aura_token_attach_guard; mod aurification_gold_counter_defender_cant_attack; mod awaken_runtime; mod awe_strike_prevention; diff --git a/crates/engine/tests/integration/mauhur_swarming_of_moria.rs b/crates/engine/tests/integration/mauhur_swarming_of_moria.rs index 963084a31f..ced46e6828 100644 --- a/crates/engine/tests/integration/mauhur_swarming_of_moria.rs +++ b/crates/engine/tests/integration/mauhur_swarming_of_moria.rs @@ -132,7 +132,7 @@ fn squirrel_spec() -> TokenSpec { sacrifice_at: None, source_id: ObjectId(0), controller: P0, - attach_to: None, + attach_to: engine::types::proposed_event::TokenHostRequest::NotRequested, } } diff --git a/crates/engine/tests/integration/self_attached_aura_token_host.rs b/crates/engine/tests/integration/self_attached_aura_token_host.rs index 0625de31a1..ecfd2d2cda 100644 --- a/crates/engine/tests/integration/self_attached_aura_token_host.rs +++ b/crates/engine/tests/integration/self_attached_aura_token_host.rs @@ -464,6 +464,28 @@ impl RoleChain { } fn run_targeting(&mut self, player: Option) -> ObjectId { + let created = self.run_collecting(player); + assert_eq!( + created.len(), + 1, + "reach guard: exactly one Role token was created" + ); + created[0] + } + + /// CR 303.4i: the run where the declared host authority names nothing, so + /// the Aura token is NOT created ("If the Aura is a token, it isn't + /// created"). The tap reach guard inside `run_collecting` is what keeps this + /// from passing vacuously — resolution demonstrably reached the token clause. + fn run_expecting_no_token(&mut self) { + let created = self.run_collecting(None); + assert!( + created.is_empty(), + "CR 303.4i: an Aura token whose host is undefined is not created, got {created:?}" + ); + } + + fn run_collecting(&mut self, player: Option) -> Vec { let source = self.source; let selected = self.selected; let activation = self.runner.activate(source, 0).target_object(selected); @@ -485,12 +507,7 @@ impl RoleChain { self.runner.state().objects[&selected].tapped, "reach guard: the parent effect ran, so resolution reached the token clause" ); - assert_eq!( - created.len(), - 1, - "reach guard: exactly one Role token was created" - ); - created[0] + created } /// The host the created token ended up with, or `None` where the token was @@ -513,18 +530,17 @@ fn a_context_filter_host_never_falls_back_to_the_selected_target() { // CR 400.7j + CR 608.2k: "the object paid as a cost" names nothing here, so // the token gets no host — it must NOT inherit the chosen target. let mut chain = RoleChain::build(TargetFilter::CostPaidObject); - let token = chain.run(); + // CR 303.4i: the authority names no object, so the host is undefined and the + // Role token is not created at all. It used to be created hostless and swept + // by the CR 704.5m SBA (#7302); either way it must never inherit the chosen + // target, which the attachment count below is what proves. + chain.run_expecting_no_token(); assert_eq!( chain.attachments_of(chain.selected), 0, "a reference filter that resolves to nothing must leave the selected \ target unenchanted" ); - assert_eq!( - chain.host_of(token), - None, - "no Role may be attached at all when its declared authority named no host" - ); // The discriminating half: a context filter that DOES name an object // resolves through its own authority, and that object is the host even @@ -557,18 +573,15 @@ fn a_paired_source_filter_never_inherits_the_selected_target() { .expect("partner") .paired_with = Some(source); } - let token = chain.run(); + // CR 303.4i: no single host authority exists for the pair, so the host is + // undefined and the token is not created. + chain.run_expecting_no_token(); assert_eq!( chain.attachments_of(chain.selected), 0, "a paired-source reference must not enchant the object the ability selected" ); - assert_eq!( - chain.host_of(token), - None, - "with no single host authority for the pair, the Role gets no host at all" - ); } /// CR 115.1a + CR 303.4: "… attached to target opponent" exports as the @@ -708,13 +721,10 @@ fn selenias_curse_enchants_the_targeted_opponent() { #[test] fn an_unbound_chosen_player_yields_no_host_rather_than_the_controller() { let mut chain = RoleChain::build_curse(chosen_player_filter()); - let token = chain.run(); + // CR 303.4i: an unbound chosen-player slot names nobody, so the host is + // undefined and the Curse token is not created. + chain.run_expecting_no_token(); - assert_eq!( - chain.host_of(token), - None, - "an unbound chosen-player slot names nobody, so there is no host" - ); assert_eq!( chain.attachments_of(chain.selected), 0,