From 96c68dffe6c31e25494cfc3a57de5dd8fa19485d Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:58:42 +0200 Subject: [PATCH 1/4] fix(engine,parser): do not create an Aura token whose host is undefined (#7302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR 303.4i: "If an effect attempts to put an Aura onto the battlefield attached to … 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 did the opposite: it created 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 a parser fix too. "create a Questing Role token AND ATTACH IT TO target creature" is the action surface of the same CR 303.7 relation Oracle otherwise prints as a state ("…token ATTACHED TO target creature"); only the state surface was recognised, so the token was built with no host at all and then swept. Two parts: * `parser/oracle_effect/token.rs` recognises both printed surfaces through one connector list, so both bind the same `attach_to` field rather than becoming two code paths. * `game/effects/token.rs` gates the propose step: an Aura token whose instruction NAMED a host that nothing bound is not created — no propose, no replacement pipeline, no ETB triggers. Keyed on the resolved spec's subtypes, so a script-named token ("Cursed Role") is covered like a typed `Effect::Token`. Measured over all 35,399 distinct cards in `client/public/card-data.json`: * the parser half changes exactly ONE card's parse — Questing Cosplayer, `attach_to: None` -> `Typed(Creature)`; * the shipped pool holds 48 Aura-typed token specs, of which Questing Cosplayer was the only one with no `attach_to`. After the parser half that count is 0. The engine guard is therefore the durable half rather than a second fix for the same card: it is what makes any FUTURE unbound Aura token follow CR 303.4i, and it also covers the runtime case no parse measurement can show — a host that is named but fails to bind at resolution. Counter-probe: with the guard disabled, `an_aura_token_with_an_unbound_host_is_not_created` fails on the battlefield census while the bound-host and ordinary-token rows stay green. Behaviour change to existing coverage: three rows in `self_attached_aura_token_host.rs` asserted the old create-then-sweep path ("the token gets no host"). Their claim — that an unresolvable host authority must never inherit the ability's chosen target — is unchanged and still asserted through the attachment census; what they now assert on the token itself is the stronger CR 303.4i outcome, that it is not created. The tap reach guard in the shared runner keeps those negatives from passing vacuously. Not covered: CR 303.4i's other half, a host that is DEFINED but cannot legally be enchanted. Host legality is owned by `attach::attach_to` / `attach::attach_to_player`, and routing that verdict back to this pre-propose gate is a separate change. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/effects/token.rs | 42 +++++ .../engine/src/parser/oracle_effect/token.rs | 70 ++++++- .../integration/aura_token_attach_guard.rs | 172 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + .../self_attached_aura_token_host.rs | 58 +++--- 5 files changed, 312 insertions(+), 31 deletions(-) create mode 100644 crates/engine/tests/integration/aura_token_attach_guard.rs diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index 2098c4b082..d56f3974d8 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -570,6 +570,9 @@ pub fn resolve( }) .collect(); + // CR 303.4i: read the host-binding outcome before the spec consumes it. + let host_named_but_unbound = attach_to.is_some() && attach_target.is_none(); + let spec = build_token_spec( &script_name, parsed.as_ref(), @@ -584,6 +587,35 @@ pub fn resolve( state, ); + // CR 303.4i: "If an effect attempts to put an Aura onto the battlefield + // attached to … 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 instruction NAMED a host (`attach_to` is set) and nothing bound it, so + // the host is undefined and the token is not created — no propose, no + // replacement pipeline, no enters-the-battlefield triggers. Until now the + // token was created hostless and the CR 704.5m state-based action swept it + // into a graveyard, which is observably different: the token existed for a + // beat, fired ETB triggers, and left a graveyard entry (#7302). + // + // Gated on the RESOLVED spec's subtypes rather than the effect's `types`, so + // a named token script ("Cursed Role") is covered the same as a typed + // `Effect::Token`. A token with no `attach_to` at all is an ordinary token + // and is untouched; so is an Aura token whose host bound successfully. + // + // Not covered here: a host that is DEFINED but cannot legally be enchanted + // (CR 303.4i's other half). Host legality is owned by `attach::attach_to` / + // `attach::attach_to_player`, and routing that verdict back to this + // pre-propose gate is a separate change. + if host_named_but_unbound && spec_is_aura(&spec) { + events.push(GameEvent::EffectResolved { + kind: EffectKind::from(&ability.effect), + source_id: ability.source_id, + subject: None, + }); + return Ok(()); + } + // CR 614.1a: Propose entire token batch for replacement pipeline. // Replacement effects (Doubling Season, Primal Vigor) modify count. let proposed = ProposedEvent::CreateToken { @@ -641,6 +673,16 @@ pub fn resolve( Ok(()) } +/// CR 205.3h: Is this token an Aura? Aura is an enchantment SUBTYPE, so the +/// question is answered off the resolved characteristics — the one place a +/// script-named token ("Cursed Role") and a typed `Effect::Token` agree. +fn spec_is_aura(spec: &crate::types::proposed_event::TokenSpec) -> bool { + spec.characteristics + .subtypes + .iter() + .any(|subtype| subtype.eq_ignore_ascii_case("aura")) +} + /// CR 111.1 + CR 111.4 + CR 111.10: Build the resolved `TokenSpec` for a /// token creation event, combining parsed script attributes with typed /// `Effect::Token` fallback fields and ability context (source/controller/ diff --git a/crates/engine/src/parser/oracle_effect/token.rs b/crates/engine/src/parser/oracle_effect/token.rs index dbda946870..6054186996 100644 --- a/crates/engine/src/parser/oracle_effect/token.rs +++ b/crates/engine/src/parser/oracle_effect/token.rs @@ -453,6 +453,12 @@ fn tracked_set_count_is_type_restricted(qty: &QuantityRef) -> bool { .any(|type_filter| !matches!(type_filter, TypeFilter::Card)) } +/// CR 303.7: The printed surfaces that bind a created token to a host inside the +/// same create-token instruction. `" attached to "` states the relation, +/// `" and attach it to "` states the action; the resulting permanent is +/// identical, so both feed one `attach_to` field rather than two code paths. +const TOKEN_ATTACHMENT_CONNECTORS: [&str; 2] = [" and attach it to ", " attached to "]; + fn parse_token_description_with_context( text: &str, ctx: &ParseContext, @@ -460,14 +466,23 @@ 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.7: 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 + // a hostless Aura token is not created at all under CR 303.4i (#7302). + // Longest connector first so " and attach it to " is not shadowed. 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) = TOKEN_ATTACHMENT_CONNECTORS + .iter() + .find_map(|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 +3506,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.7 + 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/tests/integration/aura_token_attach_guard.rs b/crates/engine/tests/integration/aura_token_attach_guard.rs new file mode 100644 index 0000000000..edd8c78fcf --- /dev/null +++ b/crates/engine/tests/integration/aura_token_attach_guard.rs @@ -0,0 +1,172 @@ +//! CR 303.4i: an Aura token whose host is undefined 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.7 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. +//! +//! The effect is built directly here rather than from a card: the negative case +//! needs an `attach_to` filter with NO bound target, which a card's own +//! targeting layer would never hand to the resolver. + +use engine::game::scenario::{GameScenario, P0}; +use engine::types::ability::{ + Effect, PtValue, QuantityExpr, ResolvedAbility, TargetFilter, TargetRef, TypeFilter, + TypedFilter, +}; +use engine::types::identifiers::{CardId, ObjectId}; +use engine::types::zones::Zone; + +/// A "Cursed Role" Aura token created attached to whatever `attach_to` names. +fn aura_token_effect(attach_to: Option) -> Effect { + Effect::Token { + name: "Cursed Role".to_string(), + power: PtValue::Fixed(0), + toughness: PtValue::Fixed(0), + types: vec![ + "Enchantment".to_string(), + "Aura".to_string(), + "Role".to_string(), + ], + 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 role_tokens(board: &Board, zone: Zone) -> Vec { + board + .runner + .state() + .objects + .values() + .filter(|object| object.zone == zone && object.is_token && object.name.contains("Role")) + .map(|object| object.id) + .collect() +} + +/// CR 303.4i: 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 guard turns this red: the token is created, the CR 704.5m SBA +/// moves it, and the graveyard 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!( + role_tokens(&board, Zone::Battlefield).is_empty(), + "CR 303.4i: an Aura token with an undefined host is not created" + ); + assert!( + role_tokens(&board, Zone::Graveyard).is_empty(), + "not created means it never reached a graveyard either" + ); +} + +/// Positive counter-direction: a bound host still creates the token and attaches +/// it. This is also the reach guard — if this harness could not create an Aura +/// token at all, the negative assertion above would be vacuous. +#[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 = role_tokens(&board, Zone::Battlefield); + 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" + ); +} + +/// The guard is scoped to Auras: an ordinary token that names no host 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" + ); +} 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/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, From 895fc5651ba6ea6b0b62353c1cfe664e955ded21 Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:02:21 +0200 Subject: [PATCH 2/4] fix(PR-7534): nom-scan the attachment connector, cite CR 303.4 not CR 303.7 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of the five review findings, both parser-side and independent of the engine rework: * CR 303.7 is the Role SUBTYPE rule ("Some Aura enchantments also have the subtype Role", `docs/MagicCompRules.txt:1673`). The relation this clause binds is CR 303.4 — "An Aura enters the battlefield attached to an object or player" — and the not-created outcome is CR 303.4i. Corrected at both the helper and the use site. * The connector dispatch is a nom scan (`alt((tag(…), tag(…)))` tried at each word boundary) instead of testing two literals over the whole string. Besides matching the parser's combinator mandate this fixes an ordering hazard the literal list had: it now returns the connector that occurs FIRST in the text, where the list form would let a later " attached to " beat an earlier " and attach it to ". The three engine findings — moving the CR 303.4i gate to the post-replacement per-token authority, covering a defined-but-illegal host, and the production-pipeline regression for Questing Cosplayer — are the rework and are not in this commit. Co-Authored-By: Claude Opus 5 --- .../engine/src/parser/oracle_effect/token.rs | 41 ++++++++++++++----- .../integration/aura_token_attach_guard.rs | 2 +- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/parser/oracle_effect/token.rs b/crates/engine/src/parser/oracle_effect/token.rs index 6054186996..72bcdf7fad 100644 --- a/crates/engine/src/parser/oracle_effect/token.rs +++ b/crates/engine/src/parser/oracle_effect/token.rs @@ -453,11 +453,34 @@ fn tracked_set_count_is_type_restricted(qty: &QuantityRef) -> bool { .any(|type_filter| !matches!(type_filter, TypeFilter::Card)) } -/// CR 303.7: The printed surfaces that bind a created token to a host inside the -/// same create-token instruction. `" attached to "` states the relation, +/// 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. -const TOKEN_ATTACHMENT_CONNECTORS: [&str; 2] = [" and attach it to ", " attached to "]; +/// +/// 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> { + let mut rest = lower; + while !rest.is_empty() { + if let Ok((_, connector)) = alt(( + value( + " and attach it to ", + tag::<_, _, OracleError<'_>>(" and attach it to "), + ), + value(" attached to ", tag(" attached to ")), + )) + .parse(rest) + { + return Some(connector); + } + rest = &rest[rest.chars().next().map_or(1, char::len_utf8)..]; + } + None +} fn parse_token_description_with_context( text: &str, @@ -466,19 +489,17 @@ fn parse_token_description_with_context( let text = text.trim().trim_end_matches('.'); let lower = text.to_lowercase(); - // CR 303.7: Strip the attachment clause and capture its target. Oracle + // 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 - // a hostless Aura token is not created at all under CR 303.4i (#7302). - // Longest connector first so " and attach it to " is not shadowed. + // 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) = TOKEN_ATTACHMENT_CONNECTORS - .iter() - .find_map(|connector| tp.split_around(connector)) + 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)) @@ -3511,7 +3532,7 @@ fn copy_token_non_saga_token_you_control_issue_3294() { mod token_attachment_connector_tests { use super::*; - /// CR 303.7 + CR 303.4i: Oracle prints one relation two ways inside a + /// 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 diff --git a/crates/engine/tests/integration/aura_token_attach_guard.rs b/crates/engine/tests/integration/aura_token_attach_guard.rs index edd8c78fcf..ecccca5554 100644 --- a/crates/engine/tests/integration/aura_token_attach_guard.rs +++ b/crates/engine/tests/integration/aura_token_attach_guard.rs @@ -12,7 +12,7 @@ //! //! 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.7 relation Oracle +//! 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. //! From e88b4d966fd1a9eabd180424299f52bd2f260ce3 Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:01:11 +0200 Subject: [PATCH 3/4] fix(engine): decide CR 303.4i per token, after replacements (#7302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework of the review's two blockers. The pre-propose guard is gone; the verdict now sits at the plain-token apply seam, per token, on the object that is actually entering. Why the placement changed. CR 303.4i is a question about the ENTRANT, and a CR 614 replacement effect may create something other than the Aura the instruction described. A gate keyed on the pre-replacement spec answers about the announcement instead, and suppresses a token the rule never denied. Why not `entering_aura_hosts_projected`, which the review named. That resolver returns `NotApplicable` as soon as `aura_enchant_filter_of` finds no enchant ability (zone_pipeline.rs), and a Role token has none — its host lives in the effect, not in an ability. It is the right authority for CR 303.4f, where the controller must CHOOSE a host, and it cannot reach the class this issue is about. The gate reuses the authority that does own the question instead: `attach::authority_is_aura` for Aura-ness (CR 205.1a — a copy exception can add or remove the subtype, so it reads characteristics), and `attach::can_attach_to_object` / `can_attach_to_player` for "can't legally enchant" — the same verdict `attach::attach_to` consumes when the attachment is applied a few lines later, so gate and attachment cannot disagree about one host. `TokenSpec.attach_to` becomes a three-state `TokenHostRequest` (NotRequested / Bound / Unbound). `Option` conflated "named no host" with "named one and nothing bound it", which is exactly the distinction CR 303.4i turns on; the seam that had to tell them apart could not. Decide/act split, as in `token_copy.rs` and the liminal seam: the verdict is settled before the CR 733 birth is journaled (append-only) and before any observable event. A denied entry is rewound through the existing `uncreate_unentered_aura_token` — no `TokenCreated`, no `ZoneChanged`, no birth record, no `created_ids` row, nothing in a graveyard. The loop then goes on to the next token of the count, so the normal tail runs and publishes `last_created_token_ids = created_ids`: the stale-anaphora finding is closed by construction rather than by a second cleanup path. Behaviour change beyond the reported card: an Aura token whose defined host cannot legally be enchanted is no longer created and swept. Its enters-the-battlefield triggers and its "put into a graveyard from the battlefield" trigger (Wicked Role, CR 111.10q) no longer fire, which is what CR 303.4i says. Coverage. Two rows cast a creature whose printed enters trigger creates the Role and let it resolve: the chosen host gets its Role, and a host that can't be enchanted gets no token. The negative row asserts the empty `last_created_token_ids` slot, not just an empty board — without the gate the token is created, refused by `attach_to`, swept by CR 704.5m and ended by CR 111.7, leaving the same board. Building-block rows at the seam cover the undefined host, CR 303.4h (a non-Aura token that names a host is created unattached), and a post-replacement non-Aura entrant. Counter-probe: with the gate disabled, `an_aura_token_with_an_unbound_host_is_not_created` and `a_defined_but_illegal_host_creates_no_token` both fail, the latter on `got [ObjectId(4)]`; the three unbound rows in `self_attached_aura_token_host.rs` fail with them. Every positive row stays green. Not covered: * CR 303.4f — an Aura token that names NO host, where the controller would choose. That consult belongs to the entry pipeline, which this seam does not reach. Every Aura-typed token spec in the shipped pool names a host, so no card reaches the arm. * A bound host that has ceased to exist by resolution. `attachment_illegality` does not evaluate the host's zone, so it reads as legal here exactly as it did before. Unchanged, pre-existing. * No shipped-card fixture replay of Questing Cosplayer. The fixture stores the export's pre-baked parse, which the card-data pipeline regenerates, so a replay would assert the OLD parse. The Oracle-text rows parse the same printed sentence with the parser under test. Co-Authored-By: Claude Opus 5 --- .../game/effects/add_target_replacement.rs | 6 +- crates/engine/src/game/effects/amass.rs | 4 +- crates/engine/src/game/effects/attach.rs | 2 +- crates/engine/src/game/effects/token.rs | 197 +++++++----- crates/engine/src/game/engine_debug.rs | 2 +- crates/engine/src/game/engine_replacement.rs | 12 +- crates/engine/src/game/replacement.rs | 20 +- crates/engine/src/game/stack.rs | 6 +- .../engine/src/parser/oracle_replacement.rs | 2 +- crates/engine/src/types/proposed_event.rs | 67 +++- .../integration/aura_token_attach_guard.rs | 303 ++++++++++++++++-- .../integration/mauhur_swarming_of_moria.rs | 2 +- 12 files changed, 485 insertions(+), 138 deletions(-) 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 d56f3974d8..23676aafd0 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; @@ -570,9 +577,6 @@ pub fn resolve( }) .collect(); - // CR 303.4i: read the host-binding outcome before the spec consumes it. - let host_named_but_unbound = attach_to.is_some() && attach_target.is_none(); - let spec = build_token_spec( &script_name, parsed.as_ref(), @@ -582,40 +586,11 @@ pub fn resolve( enters_attacking, token_statics, resolved_etb_counters, - attach_target, + host_request, ability, state, ); - // CR 303.4i: "If an effect attempts to put an Aura onto the battlefield - // attached to … 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 instruction NAMED a host (`attach_to` is set) and nothing bound it, so - // the host is undefined and the token is not created — no propose, no - // replacement pipeline, no enters-the-battlefield triggers. Until now the - // token was created hostless and the CR 704.5m state-based action swept it - // into a graveyard, which is observably different: the token existed for a - // beat, fired ETB triggers, and left a graveyard entry (#7302). - // - // Gated on the RESOLVED spec's subtypes rather than the effect's `types`, so - // a named token script ("Cursed Role") is covered the same as a typed - // `Effect::Token`. A token with no `attach_to` at all is an ordinary token - // and is untouched; so is an Aura token whose host bound successfully. - // - // Not covered here: a host that is DEFINED but cannot legally be enchanted - // (CR 303.4i's other half). Host legality is owned by `attach::attach_to` / - // `attach::attach_to_player`, and routing that verdict back to this - // pre-propose gate is a separate change. - if host_named_but_unbound && spec_is_aura(&spec) { - events.push(GameEvent::EffectResolved { - kind: EffectKind::from(&ability.effect), - source_id: ability.source_id, - subject: None, - }); - return Ok(()); - } - // CR 614.1a: Propose entire token batch for replacement pipeline. // Replacement effects (Doubling Season, Primal Vigor) modify count. let proposed = ProposedEvent::CreateToken { @@ -673,16 +648,6 @@ pub fn resolve( Ok(()) } -/// CR 205.3h: Is this token an Aura? Aura is an enchantment SUBTYPE, so the -/// question is answered off the resolved characteristics — the one place a -/// script-named token ("Cursed Role") and a typed `Effect::Token` agree. -fn spec_is_aura(spec: &crate::types::proposed_event::TokenSpec) -> bool { - spec.characteristics - .subtypes - .iter() - .any(|subtype| subtype.eq_ignore_ascii_case("aura")) -} - /// CR 111.1 + CR 111.4 + CR 111.10: Build the resolved `TokenSpec` for a /// token creation event, combining parsed script attributes with typed /// `Effect::Token` fallback fields and ability context (source/controller/ @@ -697,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 { @@ -799,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, @@ -888,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. @@ -949,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, @@ -996,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); } } } @@ -2378,7 +2419,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 @@ -2885,7 +2927,7 @@ pub(crate) fn copy_probe_spec_for( sacrifice_at, source_id, controller, - attach_to: None, + attach_to: TokenHostRequest::NotRequested, } } @@ -2966,9 +3008,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( @@ -2993,7 +3038,7 @@ pub(crate) fn resolve_token_spec( *enters_attacking, static_abilities.clone(), resolved_etb_counters, - attach_target, + host_request, ability, state, ); @@ -6234,7 +6279,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), @@ -6313,7 +6358,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), @@ -6382,7 +6427,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), @@ -6460,7 +6505,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), @@ -6650,7 +6695,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), @@ -7211,7 +7256,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 { @@ -7276,7 +7321,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 { @@ -7344,7 +7389,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 { @@ -7419,7 +7464,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 { @@ -7473,7 +7518,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_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..a35a3bac00 100644 --- a/crates/engine/src/types/proposed_event.rs +++ b/crates/engine/src/types/proposed_event.rs @@ -357,12 +357,67 @@ 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)] + 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 { + /// 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 index ecccca5554..afb3e1b688 100644 --- a/crates/engine/tests/integration/aura_token_attach_guard.rs +++ b/crates/engine/tests/integration/aura_token_attach_guard.rs @@ -1,4 +1,4 @@ -//! CR 303.4i: an Aura token whose host is undefined is NOT created (#7302). +//! 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 @@ -16,29 +16,183 @@ //! 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. //! -//! The effect is built directly here rather than from a card: the negative case -//! needs an `attach_to` filter with NO bound target, which a card's own -//! targeting layer would never hand to the resolver. +//! ## 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::zones::Zone; +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 { - Effect::Token { - name: "Cursed Role".to_string(), - power: PtValue::Fixed(0), - toughness: PtValue::Fixed(0), - types: vec![ + 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, @@ -95,23 +249,23 @@ fn resolve(board: &mut Board, effect: Effect, targets: Vec) { .expect("token effect resolves"); } -fn role_tokens(board: &Board, zone: Zone) -> Vec { +fn tokens_named(board: &Board, needle: &str) -> Vec { board .runner .state() .objects .values() - .filter(|object| object.zone == zone && object.is_token && object.name.contains("Role")) + .filter(|object| object.is_token && object.name.contains(needle)) .map(|object| object.id) .collect() } -/// CR 303.4i: 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. +/// 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 guard turns this red: the token is created, the CR 704.5m SBA -/// moves it, and the graveyard assertion fails. +/// 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(); @@ -122,18 +276,13 @@ fn an_aura_token_with_an_unbound_host_is_not_created() { ); assert!( - role_tokens(&board, Zone::Battlefield).is_empty(), - "CR 303.4i: an Aura token with an undefined host is not created" - ); - assert!( - role_tokens(&board, Zone::Graveyard).is_empty(), - "not created means it never reached a graveyard either" + tokens_named(&board, "Role").is_empty(), + "CR 303.4i: an Aura token with an undefined host is not created, in any zone" ); } -/// Positive counter-direction: a bound host still creates the token and attaches -/// it. This is also the reach guard — if this harness could not create an Aura -/// token at all, the negative assertion above would be vacuous. +/// 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(); @@ -144,7 +293,7 @@ fn a_bound_host_still_gets_its_aura_token() { vec![TargetRef::Object(host)], ); - let tokens = role_tokens(&board, Zone::Battlefield); + 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]] @@ -156,8 +305,35 @@ fn a_bound_host_still_gets_its_aura_token() { ); } -/// The guard is scoped to Auras: an ordinary token that names no host is -/// untouched, so nothing about the common create-a-token path changes. +/// 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(); @@ -170,3 +346,72 @@ fn an_ordinary_token_without_a_host_is_still_created() { "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/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, } } From 0e076a8c65749615156766cad8a4b89edffbc259 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 18 Aug 2026 13:08:19 -0700 Subject: [PATCH 4/4] fix(PR-7534): harden aura token entry guard --- crates/engine/src/game/effects/token.rs | 11 ++++++----- .../engine/src/parser/oracle_effect/token.rs | 18 ++++++------------ crates/engine/src/types/proposed_event.rs | 8 +++++++- .../integration/aura_token_attach_guard.rs | 5 +++++ 4 files changed, 24 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index 23676aafd0..8380be4bf5 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -1604,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`] @@ -1628,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); } diff --git a/crates/engine/src/parser/oracle_effect/token.rs b/crates/engine/src/parser/oracle_effect/token.rs index 72bcdf7fad..4681d6a3c4 100644 --- a/crates/engine/src/parser/oracle_effect/token.rs +++ b/crates/engine/src/parser/oracle_effect/token.rs @@ -464,22 +464,16 @@ fn tracked_set_count_is_type_restricted(qty: &QuantityRef) -> bool { /// 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> { - let mut rest = lower; - while !rest.is_empty() { - if let Ok((_, connector)) = alt(( + nom_primitives::scan_at_word_boundaries(lower, |input| { + alt(( value( " and attach it to ", - tag::<_, _, OracleError<'_>>(" and attach it to "), + tag::<_, _, OracleError<'_>>("and attach it to "), ), - value(" attached to ", tag(" attached to ")), + value(" attached to ", tag("attached to ")), )) - .parse(rest) - { - return Some(connector); - } - rest = &rest[rest.chars().next().map_or(1, char::len_utf8)..]; - } - None + .parse(input) + }) } fn parse_token_description_with_context( diff --git a/crates/engine/src/types/proposed_event.rs b/crates/engine/src/types/proposed_event.rs index a35a3bac00..9b9baa7286 100644 --- a/crates/engine/src/types/proposed_event.rs +++ b/crates/engine/src/types/proposed_event.rs @@ -361,7 +361,7 @@ pub struct TokenSpec { /// 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)] + #[serde(default, skip_serializing_if = "TokenHostRequest::is_not_requested")] pub attach_to: TokenHostRequest, } @@ -394,6 +394,12 @@ pub enum TokenHostRequest { } 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 { diff --git a/crates/engine/tests/integration/aura_token_attach_guard.rs b/crates/engine/tests/integration/aura_token_attach_guard.rs index afb3e1b688..ac0d129253 100644 --- a/crates/engine/tests/integration/aura_token_attach_guard.rs +++ b/crates/engine/tests/integration/aura_token_attach_guard.rs @@ -279,6 +279,11 @@ fn an_aura_token_with_an_unbound_host_is_not_created() { 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