From 66f9c026f0e80fcd45bec554e9d82a793818ddca Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:31:23 +0200 Subject: [PATCH 1/7] fix(engine): bind an untargeted Aura token's host from the event context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "When this creature enters, create a Monster Role token attached to it" (Faunsbane Troll) put no Role on the board at all. The token was created, entered attached to nothing, and the CR 704.5m unattached-Aura state-based action moved it to the graveyard, where CR 111.7 ended it — all inside one resolution, so the card simply appeared to do nothing. The host comes from `attach_to`, which the parser binds to `TargetFilter::ParentTarget` for the bare pronoun. `resolve_attach_host` reads that as "the first object target this ability chose", and these abilities choose none: `ability.targets` is empty, so the host resolved to `None`. CR 608.2k covers exactly this sentence — an effect referring to a specific untargeted object that the ability's trigger condition already named. When no target was chosen there is nothing for the pronoun to refer back to, so the host now falls back to the triggering object through `targeting::resolve_event_context_target`, the same authority the neighbouring `TriggeringSource` arm uses. Routing through the event context rather than `source_id` is what makes it general: Gylwain, Casting Director creates the Role for ANOTHER creature that entered, and the event's subject is the object its sentence refers to. The fallback is gated on the ability having chosen no target at all, so a real target always outranks it and the for-each rebind (Asinine Antics, Twisted Sewer-Witch) keeps binding each Role to its own iteration host. Class, measured over the shipped pool: 8 token specs on 6 cards had an unresolvable host (Faunsbane Troll, Cursed Courtier, Unassuming Sage, Twisted Sewer-Witch, Asinine Antics, Gylwain's three modes); 5 cards whose same phrase sits behind a target were already correct. No parser change, so no card data moves. Counter-measured: dropping the fallback turns `untargeted_role_token_enchants_the_creature_that_entered` red — the row builds its card from Oracle text, so it discriminates in CI, where only the curated fixture exists — and the shipped-card replay red beside it; the targeted and for-each rows stay green, which is what attributes the red to this seam. The `targets.is_empty()` guard has no red row on the always side — no card in the pool creates an Aura token from an ability that targets only a player — so it is a rules guard, not a measured behaviour. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/effects/token.rs | 35 ++- crates/engine/tests/integration/main.rs | 1 + .../self_attached_aura_token_host.rs | 228 ++++++++++++++++++ 3 files changed, 260 insertions(+), 4 deletions(-) create mode 100644 crates/engine/tests/integration/self_attached_aura_token_host.rs diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index a235519a88..f2ccd0443f 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -2997,10 +2997,37 @@ fn resolve_attach_host( // `TargetRef::Object` in `ability.targets`. Player-host Auras (CR 303.4 // permits a player host) are not yet implemented — no current card creates // a token attached to a player, so a Player slot yields `None` here. - _ => ability.targets.iter().find_map(|target| match target { - TargetRef::Object(id) => Some(AttachTarget::Object(*id)), - TargetRef::Player(_) => None, - }), + _ => ability + .targets + .iter() + .find_map(|target| match target { + TargetRef::Object(id) => Some(AttachTarget::Object(*id)), + TargetRef::Player(_) => None, + }) + // CR 608.2k: when the ability chose no target at all, the host + // pronoun cannot be a back-reference to one — its antecedent is the + // untargeted object the trigger condition already named ("When this + // creature enters, create a Monster Role token attached to IT"). + // The event context is the single authority for that object, so the + // fallback routes through the same resolver the `TriggeringSource` + // arm above uses rather than reading `source_id` directly: for a + // token copy created by a resolving chain the two differ, and the + // event's subject is the one the sentence refers to. + // + // Without this the host stays unbound, the Aura token enters + // attached to nothing, and the CR 704.5m SBA moves it to the + // graveyard where CR 111.7 ends it — a Role that flickers in and out + // of existence within one resolution and never reaches the board. + .or_else(|| { + ability.targets.is_empty().then(|| { + crate::game::targeting::resolve_event_context_target( + state, + &TargetFilter::TriggeringSource, + ability.source_id, + ) + .map(target_ref_to_attach_target) + })? + }), } } diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 4139d4259f..773467c335 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -954,6 +954,7 @@ mod search_delivery_observer_dedup; mod season_points_budget_modal; mod seasoned_dungeoneer_initiative_room_trigger; mod selenia_vigilance_grant; +mod self_attached_aura_token_host; mod self_destruct_target_power; mod sensei_golden_tail_5950; mod sentinel_sliver_vigilance_grant; diff --git a/crates/engine/tests/integration/self_attached_aura_token_host.rs b/crates/engine/tests/integration/self_attached_aura_token_host.rs new file mode 100644 index 0000000000..40f4909e2d --- /dev/null +++ b/crates/engine/tests/integration/self_attached_aura_token_host.rs @@ -0,0 +1,228 @@ +//! CR 608.2k + CR 303.4: an Aura token created "attached to it" by an ability +//! that chose no target must enchant the object its trigger condition named. +//! +//! The class is every `Effect::Token` whose `attach_to` is `ParentTarget` inside +//! an ability with no target slot. Measured over the shipped pool: 8 such token +//! specs on 6 cards (Faunsbane Troll, Cursed Courtier, Unassuming Sage, Twisted +//! Sewer-Witch, Asinine Antics, and Gylwain's three modes), against 5 cards whose +//! identical phrase sits behind a target and always worked (Monstrous Rage, +//! Croaking Curse, Royal Treatment, Return Triumphant, Not Dead After All). +//! +//! `ParentTarget` reads the first object in `ability.targets`, and these +//! abilities put none there: the host resolved to `None`, the Role entered +//! enchanting nothing, the CR 704.5m unattached-Aura state-based action moved it +//! to the graveyard, and CR 111.7 ended it there — all inside one resolution, so +//! the card looked like it did nothing at all. +//! +//! The rows are built from Oracle text rather than the card export so they run +//! in CI, where only the curated fixture exists; the two shipped cards are then +//! replayed end to end behind the fixture guard this suite uses elsewhere. + +use engine::game::game_object::AttachTarget; +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::game::scenario_db::GameScenarioDbExt; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +use crate::support::shared_card_db as load_db; + +/// Faunsbane Troll's and Cursed Courtier's shared shape, with the reminder text +/// dropped: the enters trigger, the Role, and the bare pronoun host. +const SELF_ATTACHED_ETB: &str = + "When this creature enters, create a Monster Role token attached to it."; + +/// The same sentence with a target in front of it — the control for the +/// discriminator, and the shape Monstrous Rage and Royal Treatment print. +const TARGETED_ETB: &str = "When this creature enters, create a Monster Role token attached to \ + target creature you control."; + +fn colorless(count: usize) -> Vec { + pool(count, &[]) +} + +/// `generic` colorless units plus one unit of each named colour, which is what +/// the shipped cards' costs need ({2}{B}{G}, {2}{W}, {2}{U}{U}). +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() +} + +fn role_tokens( + runner: &engine::game::scenario::GameRunner, +) -> Vec<&engine::game::game_object::GameObject> { + runner + .state() + .objects + .values() + .filter(|object| object.card_types.subtypes.iter().any(|s| s == "Role")) + .collect() +} + +/// Searched across 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 only_role_token( + runner: &engine::game::scenario::GameRunner, +) -> &engine::game::game_object::GameObject { + let tokens = role_tokens(runner); + assert_eq!(tokens.len(), 1, "exactly one Role token per resolution"); + tokens[0] +} + +#[test] +fn untargeted_role_token_enchants_the_creature_that_entered() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool(P0, colorless(4)); + let subject = scenario + .add_creature_to_hand_from_oracle(P0, "Self Role Host", 4, 4, SELF_ATTACHED_ETB) + .id(); + let mut runner = scenario.build(); + + runner.cast(subject).resolve(); + runner.advance_until_stack_empty(); + + let token = only_role_token(&runner); + assert_eq!( + token.zone, + Zone::Battlefield, + "the Role must survive the CR 704.5m unattached-Aura check" + ); + assert_eq!( + token.attached_to, + Some(AttachTarget::Object(subject)), + "CR 608.2k: the pronoun names the object the trigger condition named" + ); + assert!( + runner.state().objects[&subject] + .attachments + .contains(&token.id), + "both directions of the attachment relationship are written (CR 301.5)" + ); +} + +/// The other side of the discriminator: with a target chosen, the host is the +/// target and the untargeted fallback must not outrank it. +#[test] +fn targeted_role_token_still_enchants_the_chosen_target() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool(P0, colorless(4)); + let bystander = scenario.add_creature(P0, "Role Bystander", 2, 2).id(); + let subject = scenario + .add_creature_to_hand_from_oracle(P0, "Targeted Role Host", 4, 4, TARGETED_ETB) + .id(); + let mut runner = scenario.build(); + + runner.cast(subject).target_object(bystander).resolve(); + runner.advance_until_stack_empty(); + + let token = only_role_token(&runner); + assert_eq!(token.zone, Zone::Battlefield); + assert_eq!( + token.attached_to, + Some(AttachTarget::Object(bystander)), + "the chosen target stays the host, not the creature that entered" + ); +} + +/// End-to-end replay on the two shipped cards that reported the bug, so the fix +/// is pinned against real printed text and two different Roles rather than one +/// synthetic sentence. Skips where only the curated fixture is available; the +/// rows above carry the same claim in CI. +#[test] +fn shipped_self_attached_role_cards_keep_their_role() { + let Some(db) = load_db() else { + return; + }; + + for (card, cost, role) in [ + ( + "Faunsbane Troll", + pool(2, &[ManaType::Black, ManaType::Green]), + "Monster Role", + ), + ( + "Cursed Courtier", + pool(2, &[ManaType::White]), + "Cursed Role", + ), + ] { + if db.get_face_by_name(card).is_none() { + eprintln!("skipping: {card} is not in integration_cards.json.gz"); + continue; + } + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool(P0, cost); + let subject = scenario.add_real_card(P0, card, Zone::Hand, db); + let mut runner = scenario.build(); + engine::game::rehydrate_game_from_card_db(runner.state_mut(), db); + + runner.cast(subject).resolve(); + runner.advance_until_stack_empty(); + + let token = only_role_token(&runner); + assert_eq!(token.name, role, "{card} creates its own printed Role"); + assert_eq!(token.zone, Zone::Battlefield, "{card}'s Role stays in play"); + assert_eq!( + token.attached_to, + Some(AttachTarget::Object(subject)), + "{card}'s Role enchants the creature that entered" + ); + } +} + +/// The loop case, which shares the same `ParentTarget` host but binds it per +/// iteration: Asinine Antics enchants every creature its opponents control. Each +/// Role must land on its own iteration host — a fallback that outranked the +/// loop's rebind would pile them all onto one permanent. +#[test] +fn for_each_role_tokens_enchant_their_own_iteration_host() { + let Some(db) = load_db() else { + return; + }; + if db.get_face_by_name("Asinine Antics").is_none() { + eprintln!("skipping: Asinine Antics is not in integration_cards.json.gz"); + return; + } + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool(P0, pool(2, &[ManaType::Blue, ManaType::Blue])); + let first = scenario.add_creature(P1, "Antic Victim One", 2, 2).id(); + let second = scenario.add_creature(P1, "Antic Victim Two", 3, 3).id(); + let antics = scenario.add_real_card(P0, "Asinine Antics", Zone::Hand, db); + let mut runner = scenario.build(); + engine::game::rehydrate_game_from_card_db(runner.state_mut(), db); + + runner.cast(antics).resolve(); + runner.advance_until_stack_empty(); + + let mut hosts: Vec = role_tokens(&runner) + .iter() + .map(|token| { + assert_eq!( + token.zone, + Zone::Battlefield, + "every Role of the loop survives the unattached-Aura check" + ); + match token.attached_to { + Some(AttachTarget::Object(id)) => id.0, + other => panic!("a loop Role must have an object host, got {other:?}"), + } + }) + .collect(); + hosts.sort_unstable(); + let mut expected = vec![first.0, second.0]; + expected.sort_unstable(); + assert_eq!( + hosts, expected, + "one Role per opponent creature, each on its own iteration host" + ); +} From fe2e25e52fae5409d38c2deb13e9a1ca22efeaa1 Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:39:56 +0200 Subject: [PATCH 2/7] fix(engine): confine the untargeted host fallback to the pronoun arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the previous commit. The fallback sat in the `match`'s wildcard, so it also covered typed targeting filters: an "attached to target creature you control" that legally selected zero targets would have picked up the triggering object instead of keeping its own no-host outcome. Nothing in such a filter's text names an untargeted object, so CR 608.2k does not reach it. `TargetFilter::ParentTarget` is now its own arm and carries the fallback alone; both arms read the chosen target through a shared `first_object_host`. The doc comment described the engine's observed path (create, enter unattached, CR 704.5m to the graveyard, CR 111.7) as though it were the rule. CR 303.4i says the opposite — such a token is not created at all — and that divergence is the open second defect on the `apply_create_token_after_replacement` path (#7302). The comment now says which is which and points there. The for-each row built its board from the card export, so it self-skipped in CI, where only `integration_cards.json.gz` exists — the one row covering per-iteration rebinding proved nothing there. It now builds the loop from Oracle text and keeps the Asinine Antics replay as its second half, behind the fixture guard. Re-measured: dropping the fallback still turns only `untargeted_role_token_enchants_the_creature_that_entered` red on the fixture DB, with the targeted, for-each and shipped rows green. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/effects/token.rs | 88 +++++++++++-------- .../self_attached_aura_token_host.rs | 86 +++++++++++++----- 2 files changed, 111 insertions(+), 63 deletions(-) diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index f2ccd0443f..5e9f96689a 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -2973,7 +2973,10 @@ pub(crate) fn resolve_token_spec( /// the chosen target out of `ability.targets`. Returns `None` when no legal /// host has been bound — the apply path then leaves the token unattached and /// the CR 704.5m SBA (an unattached Aura) moves the orphaned Aura to the -/// graveyard. +/// graveyard. That observed path is itself a divergence from CR 303.4i, which +/// says an Aura token whose host is undefined is not created at all; the missing +/// guard is tracked separately (#7302). Binding the host correctly is what keeps +/// a card off that path, not a substitute for the guard. /// /// This does NOT duplicate attach legality: the actual attach is performed by /// `attach::attach_to` / `attach::attach_to_player`, the single authority for @@ -2990,47 +2993,54 @@ fn resolve_attach_host( crate::game::targeting::resolve_event_context_target(state, filter, ability.source_id) .map(target_ref_to_attach_target) } - // ParentTarget and any targeting filter resolve to the chosen target - // carried in `ability.targets`. ParentTarget is bound per-iteration by the - // for-each rebind; a `Typed` targeting filter is the single-target - // "attached to target creature" case (CR 115.1a). Both read the first - // `TargetRef::Object` in `ability.targets`. Player-host Auras (CR 303.4 - // permits a player host) are not yet implemented — no current card creates - // a token attached to a player, so a Player slot yields `None` here. - _ => ability - .targets - .iter() - .find_map(|target| match target { - TargetRef::Object(id) => Some(AttachTarget::Object(*id)), - TargetRef::Player(_) => None, - }) - // CR 608.2k: when the ability chose no target at all, the host - // pronoun cannot be a back-reference to one — its antecedent is the - // untargeted object the trigger condition already named ("When this - // creature enters, create a Monster Role token attached to IT"). - // The event context is the single authority for that object, so the - // fallback routes through the same resolver the `TriggeringSource` - // arm above uses rather than reading `source_id` directly: for a - // token copy created by a resolving chain the two differ, and the - // event's subject is the one the sentence refers to. - // - // Without this the host stays unbound, the Aura token enters - // attached to nothing, and the CR 704.5m SBA moves it to the - // graveyard where CR 111.7 ends it — a Role that flickers in and out - // of existence within one resolution and never reaches the board. - .or_else(|| { - ability.targets.is_empty().then(|| { - crate::game::targeting::resolve_event_context_target( - state, - &TargetFilter::TriggeringSource, - ability.source_id, - ) - .map(target_ref_to_attach_target) - })? - }), + // The bare-pronoun host ("attached to it"). It normally reads the chosen + // target out of `ability.targets` — the for-each rebind binds it per + // iteration — but the pronoun also appears in abilities that choose no + // target at all, where there is no back-reference for it to make. + // + // CR 608.2k: such a pronoun names the specific untargeted object the + // ability's trigger condition already referred to ("When this creature + // enters, create a Monster Role token attached to IT"). The event context + // is the single authority for that object, so the fallback routes through + // the same resolver the `TriggeringSource` arm above uses rather than + // reading `source_id`: Gylwain, Casting Director creates the Role for + // ANOTHER creature that entered, and the event's subject — not the source + // — is the one its sentence refers to. + // + // The fallback is confined to this arm and to an ability that chose + // NOTHING. A typed targeting filter that legally selected zero targets + // ("attached to target creature you control" with no legal target) keeps + // its own no-host outcome: nothing in its text names an untargeted + // object, so CR 608.2k does not reach it. + TargetFilter::ParentTarget => first_object_host(ability).or_else(|| { + ability.targets.is_empty().then(|| { + crate::game::targeting::resolve_event_context_target( + state, + &TargetFilter::TriggeringSource, + ability.source_id, + ) + .map(target_ref_to_attach_target) + })? + }), + // Any targeting filter resolves to the chosen target carried in + // `ability.targets` — the single-target "attached to target creature" + // case (CR 115.1a). Player-host Auras (CR 303.4 permits a player host) + // are not yet implemented — no current card creates a token attached to + // a player, so a Player slot yields `None` here. + _ => first_object_host(ability), } } +/// The first object target the ability chose, which is the host every targeting +/// attachment filter reads. Mirrors `attach::resolve_object_filter`'s +/// ParentTarget arm. +fn first_object_host(ability: &ResolvedAbility) -> Option { + ability.targets.iter().find_map(|target| match target { + TargetRef::Object(id) => Some(AttachTarget::Object(*id)), + TargetRef::Player(_) => None, + }) +} + /// Convert a resolved `TargetRef` into an `AttachTarget` host. Player and Object /// hosts both reach the apply path (CR 303.4 allows player-host Auras). fn target_ref_to_attach_target(target: TargetRef) -> AttachTarget { 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 40f4909e2d..23949c24cc 100644 --- a/crates/engine/tests/integration/self_attached_aura_token_host.rs +++ b/crates/engine/tests/integration/self_attached_aura_token_host.rs @@ -52,6 +52,38 @@ fn pool(generic: usize, colors: &[ManaType]) -> Vec { .collect() } +/// Twisted Sewer-Witch's and Asinine Antics' shape: the same host filter, bound +/// once per iteration instead of once per ability. +const FOR_EACH_ETB: &str = "When this creature enters, for each creature you control, create a \ + Wicked Role token attached to that creature."; + +/// The hosts of every Role in play, sorted, so a row can compare sets without +/// depending on object-id order. +fn sorted_hosts(runner: &engine::game::scenario::GameRunner) -> Vec { + let mut hosts: Vec = role_tokens(runner) + .iter() + .map(|token| { + assert_eq!( + token.zone, + Zone::Battlefield, + "every Role must survive the unattached-Aura check" + ); + match token.attached_to { + Some(AttachTarget::Object(id)) => id.0, + other => panic!("a Role must have an object host, got {other:?}"), + } + }) + .collect(); + hosts.sort_unstable(); + hosts +} + +fn sorted_ids(ids: &[ObjectId]) -> Vec { + let mut ids: Vec = ids.iter().map(|id| id.0).collect(); + ids.sort_unstable(); + ids +} + fn role_tokens( runner: &engine::game::scenario::GameRunner, ) -> Vec<&engine::game::game_object::GameObject> { @@ -179,19 +211,41 @@ fn shipped_self_attached_role_cards_keep_their_role() { } /// The loop case, which shares the same `ParentTarget` host but binds it per -/// iteration: Asinine Antics enchants every creature its opponents control. Each -/// Role must land on its own iteration host — a fallback that outranked the -/// loop's rebind would pile them all onto one permanent. +/// iteration. Each Role must land on its own iteration host — a fallback that +/// outranked the loop's rebind would pile them all onto one permanent, or onto +/// the creature that entered. +/// +/// Built from Oracle text so the claim is exercised in CI, then replayed on +/// Asinine Antics where the full export is available. #[test] fn for_each_role_tokens_enchant_their_own_iteration_host() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool(P0, colorless(4)); + let buddy = scenario.add_creature(P0, "Loop Buddy", 1, 1).id(); + let subject = scenario + .add_creature_to_hand_from_oracle(P0, "Loop Role Host", 3, 3, FOR_EACH_ETB) + .id(); + let mut runner = scenario.build(); + + runner.cast(subject).resolve(); + runner.advance_until_stack_empty(); + + assert_eq!( + sorted_hosts(&runner), + sorted_ids(&[buddy, subject]), + "one Role per creature, each on its own iteration host" + ); + let Some(db) = load_db() else { return; }; if db.get_face_by_name("Asinine Antics").is_none() { - eprintln!("skipping: Asinine Antics is not in integration_cards.json.gz"); + eprintln!( + "skipping the shipped replay: Asinine Antics is not in integration_cards.json.gz" + ); return; } - let mut scenario = GameScenario::new(); scenario.at_phase(Phase::PreCombatMain); scenario.with_mana_pool(P0, pool(2, &[ManaType::Blue, ManaType::Blue])); @@ -204,25 +258,9 @@ fn for_each_role_tokens_enchant_their_own_iteration_host() { runner.cast(antics).resolve(); runner.advance_until_stack_empty(); - let mut hosts: Vec = role_tokens(&runner) - .iter() - .map(|token| { - assert_eq!( - token.zone, - Zone::Battlefield, - "every Role of the loop survives the unattached-Aura check" - ); - match token.attached_to { - Some(AttachTarget::Object(id)) => id.0, - other => panic!("a loop Role must have an object host, got {other:?}"), - } - }) - .collect(); - hosts.sort_unstable(); - let mut expected = vec![first.0, second.0]; - expected.sort_unstable(); assert_eq!( - hosts, expected, - "one Role per opponent creature, each on its own iteration host" + sorted_hosts(&runner), + sorted_ids(&[first, second]), + "Asinine Antics enchants each opponent creature separately" ); } From cb627bdafa2e7ae634580919572d389d3e5c0ec4 Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:55:49 +0200 Subject: [PATCH 3/7] fix(engine): classify the attach host authority instead of guessing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolve_attach_host` routed every filter but three through the ability's chosen targets, so a context or reference filter — SelfRef, LastCreated, CostPaidObject, TrackedSet and the rest — could attach a token to an unrelated object the enclosing ability happened to select, and a new `TargetFilter` variant would have inherited that behaviour silently. The host authority is now classified explicitly and exhaustively over `TargetFilter`: only predicates that describe a target slot (CR 115.1a) read `ability.targets`; event and resolution contexts resolve through their own authority; player-valued and unresolved reference families yield no host. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/effects/token.rs | 142 ++++++++++++++++-- .../self_attached_aura_token_host.rs | 86 +++++++++++ 2 files changed, 215 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index 5e9f96689a..a76dd54a41 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -2986,10 +2986,15 @@ fn resolve_attach_host( ability: &ResolvedAbility, filter: &TargetFilter, ) -> Option { - match filter { + match classify_attach_host_authority(filter) { + // CR 115.1a: the chosen target carried in `ability.targets` — the + // single-target "attached to target creature" case. Player-host Auras + // (CR 303.4 permits a player host) are not yet implemented, so a Player + // slot yields `None` here. + AttachHostAuthority::SelectedTarget => first_object_host(ability), // Event-context hosts ("attached to the triggering creature") resolve the // triggering event's subject via the shared event-context resolver. - TargetFilter::TriggeringSource | TargetFilter::AttachedTo => { + AttachHostAuthority::EventContext => { crate::game::targeting::resolve_event_context_target(state, filter, ability.source_id) .map(target_ref_to_attach_target) } @@ -3002,17 +3007,17 @@ fn resolve_attach_host( // ability's trigger condition already referred to ("When this creature // enters, create a Monster Role token attached to IT"). The event context // is the single authority for that object, so the fallback routes through - // the same resolver the `TriggeringSource` arm above uses rather than - // reading `source_id`: Gylwain, Casting Director creates the Role for - // ANOTHER creature that entered, and the event's subject — not the source - // — is the one its sentence refers to. + // the same resolver the `EventContext` arm above uses rather than reading + // `source_id`: Gylwain, Casting Director creates the Role for ANOTHER + // creature that entered, and the event's subject — not the source — is + // the one its sentence refers to. // // The fallback is confined to this arm and to an ability that chose // NOTHING. A typed targeting filter that legally selected zero targets // ("attached to target creature you control" with no legal target) keeps // its own no-host outcome: nothing in its text names an untargeted // object, so CR 608.2k does not reach it. - TargetFilter::ParentTarget => first_object_host(ability).or_else(|| { + AttachHostAuthority::Pronoun => first_object_host(ability).or_else(|| { ability.targets.is_empty().then(|| { crate::game::targeting::resolve_event_context_target( state, @@ -3022,12 +3027,123 @@ fn resolve_attach_host( .map(target_ref_to_attach_target) })? }), - // Any targeting filter resolves to the chosen target carried in - // `ability.targets` — the single-target "attached to target creature" - // case (CR 115.1a). Player-host Auras (CR 303.4 permits a player host) - // are not yet implemented — no current card creates a token attached to - // a player, so a Player slot yields `None` here. - _ => first_object_host(ability), + // CR 608.2c: a numbered anaphor resolves against the whole resolving + // chain's targets, which is why it routes through the same authority + // `attach::resolve_object_filter` uses rather than reading this clause's + // nearest target. + AttachHostAuthority::ParentSlot(index) => { + crate::game::targeting::resolve_parent_slot_from_root(state, ability, index) + .map(target_ref_to_attach_target) + } + AttachHostAuthority::Source => Some(AttachTarget::Object(ability.source_id)), + AttachHostAuthority::SpecificObject(id) => Some(AttachTarget::Object(id)), + AttachHostAuthority::NoHost => None, + } +} + +/// CR 303.4 + CR 608.2c: which authority names the host of a token created +/// "attached to" something. +/// +/// Reading the enclosing ability's chosen targets is correct only for a filter +/// that describes a target slot (CR 115.1a). Every other family names its object +/// through its own authority, and a filter that names no object must leave the +/// token hostless rather than inherit whatever the ability happened to select. +enum AttachHostAuthority { + /// A predicate over objects, which the targeting layer used to choose a + /// target. The host is that chosen target. + SelectedTarget, + /// An object the triggering event or the resolution context names. + EventContext, + /// The bare anaphoric pronoun, which reads the chosen target and otherwise + /// falls back to the untargeted object the trigger condition named. + Pronoun, + /// One numbered slot of the resolving chain's accumulated targets. + ParentSlot(usize), + /// The ability's own source object. + Source, + /// An object the ability definition names outright. + SpecificObject(ObjectId), + /// No host from this path: a player-valued filter, a filter that names no + /// object at all, or a reference family whose authority this path does not + /// resolve. The token is then left unattached (see the `resolve_attach_host` + /// doc comment for what happens to it). + NoHost, +} + +/// The classification is exhaustive over [`TargetFilter`] on purpose: a new +/// variant has to be triaged here rather than inheriting selected-target +/// semantics from a wildcard. +fn classify_attach_host_authority(filter: &TargetFilter) -> AttachHostAuthority { + match filter { + // Predicates over objects — what a target slot is chosen with. + TargetFilter::Any + | TargetFilter::Typed(_) + | TargetFilter::Not { .. } + | TargetFilter::Or { .. } + | TargetFilter::And { .. } + | TargetFilter::Named { .. } + | TargetFilter::HasChosenName + | TargetFilter::SourceOrPaired + | TargetFilter::StackSpell + | TargetFilter::StackAbility { .. } => AttachHostAuthority::SelectedTarget, + + // CR 603.7c + CR 608.2c: event- and resolution-context references. + TargetFilter::EventTarget + | TargetFilter::LastCreated + | TargetFilter::LastRevealed + | TargetFilter::LastZoneChanged + | TargetFilter::PostReplacementDamageSource + | TargetFilter::TriggeringSource + | TargetFilter::AttachedTo => AttachHostAuthority::EventContext, + + TargetFilter::ParentTarget => AttachHostAuthority::Pronoun, + TargetFilter::ParentTargetSlot { index } => AttachHostAuthority::ParentSlot(*index), + TargetFilter::SelfRef => AttachHostAuthority::Source, + TargetFilter::SpecificObject { id } => AttachHostAuthority::SpecificObject(*id), + + // Player-valued filters. CR 303.4 permits a player host, but no card in + // the corpus creates a token attached to a player and this path has no + // player-host support to route them to. + TargetFilter::Player + | TargetFilter::Controller + | TargetFilter::SourceController + | TargetFilter::ControllerAndControlledPermanents { .. } + | TargetFilter::Opponent + | TargetFilter::SpecificPlayer { .. } + | TargetFilter::PlayerWhoChoseLabel { .. } + | TargetFilter::Neighbor { .. } + | TargetFilter::ScopedPlayer + | TargetFilter::TriggeringSpellController + | TargetFilter::TriggeringSpellOwner + | TargetFilter::TriggeringPlayer + | TargetFilter::TriggeringSourceController + | TargetFilter::ParentTargetController + | TargetFilter::ParentTargetOwner + | TargetFilter::SourceChosenPlayer + | TargetFilter::OriginalController + | TargetFilter::PostReplacementSourceController + | TargetFilter::PostReplacementDamageTarget + | TargetFilter::PostReplacementDamageTargetOwner + | TargetFilter::DefendingPlayer + | TargetFilter::Owner + | TargetFilter::AllPlayers => AttachHostAuthority::NoHost, + + // Object references this path does not resolve. Each names its object + // through an authority of its own (an exile link, a tracked set, a + // recorded choice, a paid cost); none of them is the enclosing ability's + // selected target, so an unsupported one yields no host instead. + // `OriginalSource` never survives to resolution — it is concretized to + // `SpecificObject` beforehand. + TargetFilter::None + | TargetFilter::GrantingObject + | TargetFilter::CostPaidObject + | TargetFilter::ChosenCard + | TargetFilter::ChosenDamageSource { .. } + | TargetFilter::TrackedSet { .. } + | TargetFilter::TrackedSetFiltered { .. } + | TargetFilter::ExiledBySource + | TargetFilter::ExiledCardByIndex { .. } + | TargetFilter::OriginalSource => AttachHostAuthority::NoHost, } } 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 23949c24cc..a87a347800 100644 --- a/crates/engine/tests/integration/self_attached_aura_token_host.rs +++ b/crates/engine/tests/integration/self_attached_aura_token_host.rs @@ -18,9 +18,13 @@ //! in CI, where only the curated fixture exists; the two shipped cards are then //! replayed end to end behind the fixture guard this suite uses elsewhere. +use engine::game::effects::resolve_ability_chain; use engine::game::game_object::AttachTarget; use engine::game::scenario::{GameScenario, P0, P1}; use engine::game::scenario_db::GameScenarioDbExt; +use engine::types::ability::{ + Effect, PtValue, QuantityExpr, ResolvedAbility, TargetFilter, TargetRef, +}; use engine::types::identifiers::ObjectId; use engine::types::mana::{ManaType, ManaUnit}; use engine::types::phase::Phase; @@ -264,3 +268,85 @@ fn for_each_role_tokens_enchant_their_own_iteration_host() { "Asinine Antics enchants each opponent creature separately" ); } + +/// A Role token created "attached to" a context or reference filter, by an +/// ability that ALSO selected an object target. +/// +/// The host filter and the ability's target slots answer different questions: +/// only a filter that describes a target slot (CR 115.1a) may read what the +/// ability chose. A context filter names its object through its own authority, +/// so the selected object must not become the host by default — that would let +/// "attached to the exiled card" silently enchant whatever else the ability +/// happened to target. +fn resolve_role_token_attached_to( + attach_to: TargetFilter, +) -> (engine::game::scenario::GameRunner, ObjectId, ObjectId) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let source = scenario.add_creature(P0, "Context Host Source", 2, 2).id(); + let selected = scenario.add_creature(P0, "Selected Bystander", 2, 2).id(); + let mut runner = scenario.build(); + + let ability = ResolvedAbility::new( + Effect::Token { + name: "Monster Role".to_string(), + power: PtValue::Fixed(0), + toughness: PtValue::Fixed(0), + types: vec![ + "Enchantment".to_string(), + "Aura".to_string(), + "Role".to_string(), + ], + colors: Vec::new(), + keywords: Vec::new(), + tapped: false, + count: QuantityExpr::Fixed { value: 1 }, + owner: TargetFilter::Controller, + attach_to: Some(attach_to), + enters_attacking: false, + supertypes: Vec::new(), + static_abilities: Vec::new(), + enter_with_counters: Vec::new(), + }, + vec![TargetRef::Object(selected)], + source, + P0, + ); + let mut events = Vec::new(); + resolve_ability_chain(runner.state_mut(), &ability, &mut events, 0) + .expect("the token effect resolves"); + + (runner, source, selected) +} + +#[test] +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 (runner, _source, selected) = resolve_role_token_attached_to(TargetFilter::CostPaidObject); + assert!( + runner.state().objects[&selected].attachments.is_empty(), + "a reference filter that resolves to nothing must leave the selected \ + target unenchanted" + ); + assert!( + role_tokens(&runner) + .iter() + .all(|token| token.attached_to.is_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 + // though a different object sits in the ability's target slot. + let (runner, source, selected) = resolve_role_token_attached_to(TargetFilter::SelfRef); + assert_eq!( + only_role_token(&runner).attached_to, + Some(AttachTarget::Object(source)), + "the filter's own authority names the host, not the selected target" + ); + assert!( + runner.state().objects[&selected].attachments.is_empty(), + "the selected target stays unenchanted" + ); +} From 071de5760ea701378cdbc82a65f9b160d3129cd2 Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:08:19 +0200 Subject: [PATCH 4/7] fix(engine): keep the paired-source reference out of the target slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SourceOrPaired` was classified as a selected target, but the engine's own authority on that question — TargetFilter::is_context_ref — calls it an automatic context reference: it matches the source and the creature it is paired with (CR 702.95b), never an object a player chose. An Aura-token effect naming it could therefore attach to an unrelated selection. It now fails closed alongside the other unresolved reference families, and two unit guards pin the classification against `is_context_ref` from both sides so the next misfiling is caught where it is written. The context regression drives activation, targeting, resolution and state-based actions through the production stack, and reads token creation from the resolution's own events, so a swept Role cannot be mistaken for one never created. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/effects/token.rs | 97 +++++++- .../self_attached_aura_token_host.rs | 226 +++++++++++++----- 2 files changed, 259 insertions(+), 64 deletions(-) diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index a76dd54a41..c6f3f92592 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -3083,7 +3083,6 @@ fn classify_attach_host_authority(filter: &TargetFilter) -> AttachHostAuthority | TargetFilter::And { .. } | TargetFilter::Named { .. } | TargetFilter::HasChosenName - | TargetFilter::SourceOrPaired | TargetFilter::StackSpell | TargetFilter::StackAbility { .. } => AttachHostAuthority::SelectedTarget, @@ -3134,7 +3133,12 @@ fn classify_attach_host_authority(filter: &TargetFilter) -> AttachHostAuthority // selected target, so an unsupported one yields no host instead. // `OriginalSource` never survives to resolution — it is concretized to // `SpecificObject` beforehand. - TargetFilter::None + // CR 702.95b: `SourceOrPaired` names the source AND the creature it is + // paired with — two objects, not one host — and `is_context_ref` already + // classifies it as an automatic context reference rather than a target + // slot. It fails closed here until a host authority for the pair exists. + TargetFilter::SourceOrPaired + | TargetFilter::None | TargetFilter::GrantingObject | TargetFilter::CostPaidObject | TargetFilter::ChosenCard @@ -8738,3 +8742,92 @@ mod tests { ); } } + +#[cfg(test)] +mod attach_host_authority_tests { + use super::*; + use crate::types::ability::{SeatDirection, TypedFilter}; + + /// The selected-target class must stay disjoint from + /// [`TargetFilter::is_context_ref`], the engine's existing authority on which + /// filters never surface a chosen target slot. Anything that authority calls + /// a context reference has to resolve through its own authority here, or + /// yield no host — it may never read `ability.targets`. + #[test] + fn selected_target_filters_are_never_context_refs() { + for filter in [ + TargetFilter::Any, + TargetFilter::Typed(TypedFilter::creature()), + TargetFilter::Not { + filter: Box::new(TargetFilter::Any), + }, + TargetFilter::Or { + filters: vec![TargetFilter::Any], + }, + TargetFilter::And { + filters: vec![TargetFilter::Any], + }, + TargetFilter::Named { + name: "Grizzly Bears".to_string(), + }, + TargetFilter::HasChosenName, + TargetFilter::StackSpell, + TargetFilter::StackAbility { + controller: None, + tag: None, + kind: None, + }, + ] { + assert!( + matches!( + classify_attach_host_authority(&filter), + AttachHostAuthority::SelectedTarget + ), + "fixture guard: {filter:?} is meant to be a selected-target filter" + ); + assert!( + !filter.is_context_ref(), + "{filter:?} is an automatic context reference and must not read the \ + ability's chosen targets" + ); + } + } + + /// The same disjointness read from the other side, which is the direction + /// that catches a misfiling: every filter the engine calls a context + /// reference must resolve through an authority of its own or yield no host. + #[test] + fn context_references_never_classify_as_a_selected_target() { + for filter in [ + TargetFilter::SourceOrPaired, + TargetFilter::SelfRef, + TargetFilter::CostPaidObject, + TargetFilter::LastCreated, + TargetFilter::AttachedTo, + TargetFilter::EventTarget, + TargetFilter::ParentTarget, + TargetFilter::ParentTargetSlot { index: 0 }, + TargetFilter::OriginalSource, + TargetFilter::TrackedSet { + id: TrackedSetId(0), + }, + TargetFilter::PostReplacementDamageSource, + TargetFilter::Neighbor { + direction: SeatDirection::Left, + }, + ] { + assert!( + filter.is_context_ref(), + "fixture guard: {filter:?} is meant to be a context reference" + ); + assert!( + !matches!( + classify_attach_host_authority(&filter), + AttachHostAuthority::SelectedTarget + ), + "{filter:?} is a context reference and must not inherit the ability's \ + chosen targets as its attachment host" + ); + } + } +} 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 a87a347800..2c3a2972cf 100644 --- a/crates/engine/tests/integration/self_attached_aura_token_host.rs +++ b/crates/engine/tests/integration/self_attached_aura_token_host.rs @@ -18,13 +18,14 @@ //! in CI, where only the curated fixture exists; the two shipped cards are then //! replayed end to end behind the fixture guard this suite uses elsewhere. -use engine::game::effects::resolve_ability_chain; use engine::game::game_object::AttachTarget; use engine::game::scenario::{GameScenario, P0, P1}; use engine::game::scenario_db::GameScenarioDbExt; use engine::types::ability::{ - Effect, PtValue, QuantityExpr, ResolvedAbility, TargetFilter, TargetRef, + AbilityDefinition, AbilityKind, Effect, EffectScope, PtValue, QuantityExpr, TapStateChange, + TargetFilter, TypedFilter, }; +use engine::types::events::GameEvent; use engine::types::identifiers::ObjectId; use engine::types::mana::{ManaType, ManaUnit}; use engine::types::phase::Phase; @@ -138,7 +139,8 @@ fn untargeted_role_token_enchants_the_creature_that_entered() { runner.state().objects[&subject] .attachments .contains(&token.id), - "both directions of the attachment relationship are written (CR 301.5)" + "CR 701.3a: the Role is attached to that creature; the reverse list is \ + the engine invariant that says so from the host's side" ); } @@ -269,84 +271,184 @@ fn for_each_role_tokens_enchant_their_own_iteration_host() { ); } -/// A Role token created "attached to" a context or reference filter, by an -/// ability that ALSO selected an object target. -/// -/// The host filter and the ability's target slots answer different questions: -/// only a filter that describes a target slot (CR 115.1a) may read what the -/// ability chose. A context filter names its object through its own authority, -/// so the selected object must not become the host by default — that would let -/// "attached to the exiled card" silently enchant whatever else the ability -/// happened to target. -fn resolve_role_token_attached_to( - attach_to: TargetFilter, -) -> (engine::game::scenario::GameRunner, ObjectId, ObjectId) { - let mut scenario = GameScenario::new(); - scenario.at_phase(Phase::PreCombatMain); - let source = scenario.add_creature(P0, "Context Host Source", 2, 2).id(); - let selected = scenario.add_creature(P0, "Selected Bystander", 2, 2).id(); - let mut runner = scenario.build(); +fn role_token_effect(attach_to: TargetFilter) -> Effect { + Effect::Token { + name: "Monster Role".to_string(), + power: PtValue::Fixed(0), + toughness: PtValue::Fixed(0), + types: vec![ + "Enchantment".to_string(), + "Aura".to_string(), + "Role".to_string(), + ], + colors: Vec::new(), + keywords: Vec::new(), + tapped: false, + count: QuantityExpr::Fixed { value: 1 }, + owner: TargetFilter::Controller, + attach_to: Some(attach_to), + enters_attacking: false, + supertypes: Vec::new(), + static_abilities: Vec::new(), + enter_with_counters: Vec::new(), + } +} - let ability = ResolvedAbility::new( - Effect::Token { - name: "Monster Role".to_string(), - power: PtValue::Fixed(0), - toughness: PtValue::Fixed(0), - types: vec![ - "Enchantment".to_string(), - "Aura".to_string(), - "Role".to_string(), - ], - colors: Vec::new(), - keywords: Vec::new(), - tapped: false, - count: QuantityExpr::Fixed { value: 1 }, - owner: TargetFilter::Controller, - attach_to: Some(attach_to), - enters_attacking: false, - supertypes: Vec::new(), - static_abilities: Vec::new(), - enter_with_counters: Vec::new(), - }, - vec![TargetRef::Object(selected)], - source, - P0, - ); - let mut events = Vec::new(); - resolve_ability_chain(runner.state_mut(), &ability, &mut events, 0) - .expect("the token effect resolves"); +/// A permanent whose activated ability taps a chosen creature and then creates +/// a Role token "attached to" `attach_to` — one ability holding both a selected +/// object target and a host filter, which is the shape where the two can be +/// confused. Everything runs through the production path: activation, target +/// selection, stack resolution and state-based actions. +struct RoleChain { + runner: engine::game::scenario::GameRunner, + source: ObjectId, + selected: ObjectId, + partner: ObjectId, +} + +impl RoleChain { + fn build(attach_to: TargetFilter) -> Self { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let selected = scenario.add_creature(P0, "Selected Bystander", 2, 2).id(); + let partner = scenario.add_creature(P0, "Paired Partner", 2, 2).id(); + let source = scenario + .add_creature(P0, "Chain Source", 2, 2) + .with_ability_definition(AbilityDefinition { + sub_ability: Some(Box::new(AbilityDefinition::new( + AbilityKind::Activated, + role_token_effect(attach_to), + ))), + ..AbilityDefinition::new( + AbilityKind::Activated, + Effect::SetTapState { + target: TargetFilter::Typed(TypedFilter::creature()), + scope: EffectScope::Single, + state: TapStateChange::Tap, + }, + ) + }) + .id(); + Self { + runner: scenario.build(), + source, + selected, + partner, + } + } + + /// Activates the ability on `selected`, settles the stack, and returns the + /// Role token the resolution created. + /// + /// The creation is read from the resolution's own events, not from the + /// final board: a Role that ends up with no host is unattached, the + /// CR 704.5m state-based action moves it to the graveyard and CR 111.7 ends + /// it there — so a board-only check could not tell "created and swept" from + /// "never created", and the negative rows below would pass on a resolution + /// that never reached the token clause at all. + fn run(&mut self) -> ObjectId { + let source = self.source; + let selected = self.selected; + let outcome = self + .runner + .activate(source, 0) + .target_object(selected) + .resolve(); + let created: Vec = outcome + .events() + .iter() + .filter_map(|event| match event { + GameEvent::TokenCreated { object_id, .. } => Some(*object_id), + _ => None, + }) + .collect(); + self.runner.advance_until_stack_empty(); + assert!( + 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] + } + + /// The host the created token ended up with, or `None` where the token was + /// left unattached (and therefore no longer exists). + fn host_of(&self, token: ObjectId) -> Option { + self.runner + .state() + .objects + .get(&token) + .and_then(|object| object.attached_to) + } - (runner, source, selected) + fn attachments_of(&self, object: ObjectId) -> usize { + self.runner.state().objects[&object].attachments.len() + } } #[test] 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 (runner, _source, selected) = resolve_role_token_attached_to(TargetFilter::CostPaidObject); - assert!( - runner.state().objects[&selected].attachments.is_empty(), + let mut chain = RoleChain::build(TargetFilter::CostPaidObject); + let token = chain.run(); + assert_eq!( + chain.attachments_of(chain.selected), + 0, "a reference filter that resolves to nothing must leave the selected \ target unenchanted" ); - assert!( - role_tokens(&runner) - .iter() - .all(|token| token.attached_to.is_none()), + 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 // though a different object sits in the ability's target slot. - let (runner, source, selected) = resolve_role_token_attached_to(TargetFilter::SelfRef); + let mut chain = RoleChain::build(TargetFilter::SelfRef); + let token = chain.run(); assert_eq!( - only_role_token(&runner).attached_to, - Some(AttachTarget::Object(source)), + chain.host_of(token), + Some(AttachTarget::Object(chain.source)), "the filter's own authority names the host, not the selected target" ); - assert!( - runner.state().objects[&selected].attachments.is_empty(), - "the selected target stays unenchanted" + assert_eq!(chain.attachments_of(chain.selected), 0); +} + +/// CR 702.95b: `SourceOrPaired` matches the source and the creature it is +/// paired with. `TargetFilter::is_context_ref` classifies it as an automatic +/// context reference — it is never a chosen target slot — so it must not read +/// `ability.targets`. It names two objects rather than one host, and no host +/// authority exists for that here, so it fails closed until one does. +#[test] +fn a_paired_source_filter_never_inherits_the_selected_target() { + let mut chain = RoleChain::build(TargetFilter::SourceOrPaired); + let (source, partner) = (chain.source, chain.partner); + { + let state = chain.runner.state_mut(); + state.objects.get_mut(&source).expect("source").paired_with = Some(partner); + state + .objects + .get_mut(&partner) + .expect("partner") + .paired_with = Some(source); + } + let token = chain.run(); + + 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" ); } From ea758d7eafb27198a9440c5e564a942b750b1876 Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:03:38 +0200 Subject: [PATCH 5/7] fix(engine): route a player-valued attach host to the player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `classify_attach_host_authority` sent every `TargetFilter::Typed` through the selected-target arm, which reads the ability's chosen OBJECT targets. Two `Typed` shapes are not object target slots at all: - the property-free filter with a controller scope ("target opponent"), which `game::targeting::legal_targets` enumerates as PLAYERS, and - the same shape scoped to a resolution-chosen player, which `TargetFilter::is_context_ref` already reports through `chosen_player_index` as a context reference with no target slot. Selenia, the Cursed Heart is the shipped card in the first class — "create a legendary black Aura Curse enchantment token named Selenia's Curse attached to target opponent". Its host resolved to `None` (the player slot was skipped while scanning for an object), the Curse entered enchanting nothing, CR 704.5m swept it and CR 111.7 ended it: the card did nothing. Where the same ability also holds an object target, the token attached to that unrelated object instead. The players-only shape test moves onto `TargetFilter` as `denotes_player_target`, so `legal_targets` and the host resolver read one authority instead of each deriving the shape. The classification asks that authority and `chosen_player_index` before matching on shape, and a `debug_assert` inside the classification now checks the result against both — every filter the engine classifies anywhere is covered, in place of a hand-kept list that could only guard the shapes someone remembered. CR 115.1a / CR 303.4 / CR 608.2c / CR 109.4. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/effects/token.rs | 96 ++++++- crates/engine/src/game/targeting.rs | 6 +- crates/engine/src/types/ability.rs | 32 +++ .../self_attached_aura_token_host.rs | 247 +++++++++++++++++- 4 files changed, 357 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index c6f3f92592..f7bb8465a0 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -2987,11 +2987,23 @@ fn resolve_attach_host( filter: &TargetFilter, ) -> Option { match classify_attach_host_authority(filter) { - // CR 115.1a: the chosen target carried in `ability.targets` — the - // single-target "attached to target creature" case. Player-host Auras - // (CR 303.4 permits a player host) are not yet implemented, so a Player - // slot yields `None` here. + // CR 115.1a: the chosen OBJECT target carried in `ability.targets` — the + // single-target "attached to target creature" case. A player-valued + // slot never reaches this arm; `denotes_player_target` routes it to + // `SelectedPlayerTarget` below. AttachHostAuthority::SelectedTarget => first_object_host(ability), + // CR 115.1a + CR 303.4: the chosen PLAYER target is the host. Curse + // Auras (Selenia's Curse) are the shipped shape; `attach_to_player` + // downstream carries the CR 303.4i legality gate, exactly as + // `attach_to` does for an object host. + AttachHostAuthority::SelectedPlayerTarget => first_player_host(ability), + // CR 608.2c + CR 109.4: the resolution-chosen player, read from the + // resolution's own chosen-player list through the shared context-ref + // player resolver — the same authority every other "the chosen player + // s" sub-effect uses. + AttachHostAuthority::ChosenPlayer => Some(AttachTarget::Player( + super::resolve_player_for_context_ref(state, ability, filter), + )), // Event-context hosts ("attached to the triggering creature") resolve the // triggering event's subject via the shared event-context resolver. AttachHostAuthority::EventContext => { @@ -3052,6 +3064,13 @@ enum AttachHostAuthority { /// A predicate over objects, which the targeting layer used to choose a /// target. The host is that chosen target. SelectedTarget, + /// CR 115.1a + CR 303.4: a target slot that holds a PLAYER, not an object + /// ("… attached to target opponent"). The host is that chosen player. + SelectedPlayerTarget, + /// CR 608.2c + CR 109.4: the Nth resolution-chosen player. Fixed while the + /// ability resolves, never declared as a target — so it names its player + /// through the chosen-player list, not through `ability.targets`. + ChosenPlayer, /// An object the triggering event or the resolution context names. EventContext, /// The bare anaphoric pronoun, which reads the chosen target and otherwise @@ -3074,7 +3093,26 @@ enum AttachHostAuthority { /// variant has to be triaged here rather than inheriting selected-target /// semantics from a wildcard. fn classify_attach_host_authority(filter: &TargetFilter) -> AttachHostAuthority { - match filter { + let authority = match filter { + // CR 608.2c + CR 109.4: a reference to the resolution-chosen player is a + // `Typed` filter BY SHAPE, but it is a context reference — the engine + // says so through `chosen_player_index`, which is what + // `is_context_ref` itself consults. Asked ahead of the generic `Typed` + // arm below, which would otherwise read the ability's chosen targets and + // attach the token to an unrelated object. + TargetFilter::Typed(_) if filter.chosen_player_index().is_some() => { + AttachHostAuthority::ChosenPlayer + } + // CR 115.1a: whether a target slot holds a player or an object is the + // targeting layer's question, and `denotes_player_target` is the single + // authority both it and this classification read. "… attached to target + // opponent" (Selenia, the Cursed Heart) parses to the property-free + // `Typed` shape, so without this arm its Curse would look for an object + // target, find none, and enter unattached. + TargetFilter::Typed(_) if filter.denotes_player_target() => { + AttachHostAuthority::SelectedPlayerTarget + } + // Predicates over objects — what a target slot is chosen with. TargetFilter::Any | TargetFilter::Typed(_) @@ -3100,15 +3138,22 @@ fn classify_attach_host_authority(filter: &TargetFilter) -> AttachHostAuthority TargetFilter::SelfRef => AttachHostAuthority::Source, TargetFilter::SpecificObject { id } => AttachHostAuthority::SpecificObject(*id), - // Player-valued filters. CR 303.4 permits a player host, but no card in - // the corpus creates a token attached to a player and this path has no - // player-host support to route them to. - TargetFilter::Player - | TargetFilter::Controller + // CR 115.1a: the remaining player-valued TARGET SLOTS, which + // `denotes_player_target` also claims. Kept as their own arm rather than + // folded into a guard so the variant list stays readable, and asserted + // to agree with that authority in `attach_host_authority_tests`. + TargetFilter::Player | TargetFilter::SpecificPlayer { .. } => { + AttachHostAuthority::SelectedPlayerTarget + } + + // Player-valued filters that are NOT target slots. CR 303.4 permits a + // player host, but each of these names its player through a context + // authority this path does not resolve, so they fail closed rather than + // guess one. + TargetFilter::Controller | TargetFilter::SourceController | TargetFilter::ControllerAndControlledPermanents { .. } | TargetFilter::Opponent - | TargetFilter::SpecificPlayer { .. } | TargetFilter::PlayerWhoChoseLabel { .. } | TargetFilter::Neighbor { .. } | TargetFilter::ScopedPlayer @@ -3148,7 +3193,22 @@ fn classify_attach_host_authority(filter: &TargetFilter) -> AttachHostAuthority | TargetFilter::ExiledBySource | TargetFilter::ExiledCardByIndex { .. } | TargetFilter::OriginalSource => AttachHostAuthority::NoHost, - } + }; + + // CR 115.1a: the two authorities above are the engine's, not this function's, + // so the classification is checked against them rather than against a + // hand-kept list of filters — every filter the engine classifies anywhere is + // covered, including shapes nobody thought to write down. `is_context_ref` + // says a filter surfaces no target slot; `denotes_player_target` says the + // slot holds a player. Either one rules out reading the ability's chosen + // OBJECT targets. + debug_assert!( + !(matches!(authority, AttachHostAuthority::SelectedTarget) + && (filter.is_context_ref() || filter.denotes_player_target())), + "{filter:?} is not an object target slot, so it must not inherit the ability's \ + chosen object targets as its attachment host" + ); + authority } /// The first object target the ability chose, which is the host every targeting @@ -3161,6 +3221,18 @@ fn first_object_host(ability: &ResolvedAbility) -> Option { }) } +/// The first player target the ability chose — the mirror of +/// [`first_object_host`] for a host filter whose slot holds a player +/// (CR 115.1a). Object slots are skipped rather than converted: an ability can +/// carry both ("tap target creature, then create a Curse attached to target +/// opponent"), and the object slot is the other clause's, not this one's. +fn first_player_host(ability: &ResolvedAbility) -> Option { + ability.targets.iter().find_map(|target| match target { + TargetRef::Player(id) => Some(AttachTarget::Player(*id)), + TargetRef::Object(_) => None, + }) +} + /// Convert a resolved `TargetRef` into an `AttachTarget` host. Player and Object /// hosts both reach the apply path (CR 303.4 allows player-host Auras). fn target_ref_to_attach_target(target: TargetRef) -> AttachTarget { diff --git a/crates/engine/src/game/targeting.rs b/crates/engine/src/game/targeting.rs index c05a4d9a50..0fe5a09435 100644 --- a/crates/engine/src/game/targeting.rs +++ b/crates/engine/src/game/targeting.rs @@ -220,8 +220,12 @@ fn find_legal_targets_with_context( // (handled above as `is_any_other_target`) is the sole property-bearing // exception: it adds players above and falls through to the object // enumeration below instead of collapsing to players-only here. + // + // The players-only shape test itself lives on `TargetFilter` as + // `denotes_player_target`, so the Aura-token host resolver reads the same + // authority rather than re-deriving it (CR 115.1a). if let TargetFilter::Typed(ref tf) = filter { - if tf.type_filters.is_empty() && tf.properties.is_empty() && !is_any_other_target { + if filter.denotes_player_target() && !is_any_other_target { let controller = &tf.controller; for player in &state.players { // CR 115.1: one authority for player-target legality — existence diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 00c0572e3f..8ecb143b48 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -15813,6 +15813,38 @@ impl TargetFilter { ) } + /// CR 115.1a + CR 109.5: Returns true when this filter's TARGET SLOT holds a + /// player rather than an object — "target player", "target opponent", a + /// snapshotted specific player. + /// + /// This is the same rule `game::targeting::legal_targets` enumerates + /// players-only with, kept here as the single authority so any consumer that + /// must know whether a slot is player-valued asks it instead of re-deriving + /// the shape. The Aura-token host resolver is the second consumer: a token + /// created "attached to target opponent" (Selenia, the Cursed Heart) has to + /// reach the chosen PLAYER, and reading the ability's object targets for it + /// would attach the token to an unrelated permanent. + /// + /// The property-free requirement is load-bearing in both directions: + /// `Typed { properties: [Token] }` ("target token you control") names an + /// object characteristic that has no meaning for a player, and + /// `properties: [Another]` is the CR 115.4 "any other target" shape — both + /// denote objects and must fall through to object enumeration. + /// + /// Distinct from [`Self::is_context_ref`], which answers whether the filter + /// has a target slot at all: a resolution-chosen player is a context ref and + /// is NOT a player target, so [`Self::chosen_player_index`] must be consulted + /// first by callers that handle both. + pub fn denotes_player_target(&self) -> bool { + matches!( + self, + TargetFilter::Player | TargetFilter::SpecificPlayer { .. } + ) || matches!( + self, + TargetFilter::Typed(tf) if tf.type_filters.is_empty() && tf.properties.is_empty() + ) + } + /// CR 608.2c + CR 109.4: If this filter is a player-only reference to the /// Nth resolution-chosen player (a type-filter-free `Typed` whose only /// distinguishing property is `controller: ChosenPlayer { index }`), return 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 2c3a2972cf..5bbe76d8c0 100644 --- a/crates/engine/tests/integration/self_attached_aura_token_host.rs +++ b/crates/engine/tests/integration/self_attached_aura_token_host.rs @@ -17,19 +17,27 @@ //! The rows are built from Oracle text rather than the card export so they run //! in CI, where only the curated fixture exists; the two shipped cards are then //! replayed end to end behind the fixture guard this suite uses elsewhere. +//! +//! The suite has since grown into the whole question the host resolver answers: +//! WHICH authority names a token's host. Beside the pronoun above it now covers +//! the context references that must never read the ability's chosen targets, and +//! the filters whose target slot holds a PLAYER rather than an object — the +//! class Selenia, the Cursed Heart prints (CR 115.1a + CR 303.4). use engine::game::game_object::AttachTarget; use engine::game::scenario::{GameScenario, P0, P1}; use engine::game::scenario_db::GameScenarioDbExt; use engine::types::ability::{ - AbilityDefinition, AbilityKind, Effect, EffectScope, PtValue, QuantityExpr, TapStateChange, - TargetFilter, TypedFilter, + AbilityDefinition, AbilityKind, ChoiceType, ControllerRef, Effect, EffectScope, + PlayerChoiceDistinctness, PtValue, QuantityExpr, TapStateChange, TargetFilter, + TargetSelectionMode, TypedFilter, }; use engine::types::events::GameEvent; use engine::types::identifiers::ObjectId; use engine::types::mana::{ManaType, ManaUnit}; use engine::types::phase::Phase; use engine::types::zones::Zone; +use engine::types::PlayerId; use crate::support::shared_card_db as load_db; @@ -294,6 +302,57 @@ fn role_token_effect(attach_to: TargetFilter) -> Effect { } } +/// Selenia's Curse, with the same shape the shipped card exports: an Aura +/// enchantment token whose host filter names a PLAYER. Used where the row has to +/// distinguish a player host from an object host, so the token is a Curse rather +/// than a Role (CR 303.4: a Curse is the Aura subclass that enchants players). +fn curse_token_effect(attach_to: TargetFilter) -> Effect { + Effect::Token { + name: "Selenia's Curse".to_string(), + power: PtValue::Fixed(0), + toughness: PtValue::Fixed(0), + types: vec![ + "Enchantment".to_string(), + "Aura".to_string(), + "Curse".to_string(), + ], + colors: Vec::new(), + keywords: Vec::new(), + tapped: false, + count: QuantityExpr::Fixed { value: 1 }, + owner: TargetFilter::Controller, + attach_to: Some(attach_to), + enters_attacking: false, + supertypes: Vec::new(), + static_abilities: Vec::new(), + enter_with_counters: Vec::new(), + } +} + +/// "target opponent" as the parser exports it (Selenia, the Cursed Heart): a +/// `Typed` filter with no type filters and no properties, whose only content is +/// the controller scope. `legal_targets` enumerates PLAYERS for this shape — +/// that is what makes it a player target slot rather than an object predicate. +fn target_opponent_filter() -> TargetFilter { + TargetFilter::Typed(TypedFilter { + type_filters: Vec::new(), + controller: Some(ControllerRef::Opponent), + properties: Vec::new(), + }) +} + +/// "the chosen player" — the same property-free `Typed` shape, but scoped to a +/// player fixed during resolution instead of one declared as a target +/// (CR 608.2c + CR 109.4). `is_context_ref` reports it through +/// `chosen_player_index`. +fn chosen_player_filter() -> TargetFilter { + TargetFilter::Typed(TypedFilter { + type_filters: Vec::new(), + controller: Some(ControllerRef::ChosenPlayer { index: 0 }), + properties: Vec::new(), + }) +} + /// A permanent whose activated ability taps a chosen creature and then creates /// a Role token "attached to" `attach_to` — one ability holding both a selected /// object target and a host filter, which is the shape where the two can be @@ -308,6 +367,48 @@ struct RoleChain { impl RoleChain { fn build(attach_to: TargetFilter) -> Self { + Self::build_with(AbilityDefinition::new( + AbilityKind::Activated, + role_token_effect(attach_to), + )) + } + + /// The same fixture creating a Curse instead of a Role, for the rows whose + /// host filter names a player. + fn build_curse(attach_to: TargetFilter) -> Self { + Self::build_with(AbilityDefinition::new( + AbilityKind::Activated, + curse_token_effect(attach_to), + )) + } + + /// The Curse fixture with a "choose an opponent" step between the tap clause + /// and the token clause, so the resolution has bound a chosen player by the + /// time the host filter is read (CR 608.2c). The choice is made by the game + /// (CR 608.2d override) to keep the row free of a round-trip, and + /// `ChoiceType::Opponent` leaves exactly one legal answer in a two-player + /// game, so the chosen player is deterministic without pinning the RNG. + fn build_curse_after_choosing_an_opponent(attach_to: TargetFilter) -> Self { + Self::build_with(AbilityDefinition { + sub_ability: Some(Box::new(AbilityDefinition::new( + AbilityKind::Activated, + curse_token_effect(attach_to), + ))), + ..AbilityDefinition::new( + AbilityKind::Activated, + Effect::Choose { + choice_type: ChoiceType::Opponent { + restriction: None, + distinctness: PlayerChoiceDistinctness::default(), + }, + persist: false, + selection: TargetSelectionMode::Random, + }, + ) + }) + } + + fn build_with(token_ability: AbilityDefinition) -> Self { let mut scenario = GameScenario::new(); scenario.at_phase(Phase::PreCombatMain); let selected = scenario.add_creature(P0, "Selected Bystander", 2, 2).id(); @@ -315,10 +416,7 @@ impl RoleChain { let source = scenario .add_creature(P0, "Chain Source", 2, 2) .with_ability_definition(AbilityDefinition { - sub_ability: Some(Box::new(AbilityDefinition::new( - AbilityKind::Activated, - role_token_effect(attach_to), - ))), + sub_ability: Some(Box::new(token_ability)), ..AbilityDefinition::new( AbilityKind::Activated, Effect::SetTapState { @@ -347,13 +445,27 @@ impl RoleChain { /// "never created", and the negative rows below would pass on a resolution /// that never reached the token clause at all. fn run(&mut self) -> ObjectId { + self.run_targeting(None) + } + + /// The same run with a player target declared alongside the object one. + /// Both slots are live at once, which is the condition that makes the row + /// discriminating: a host resolver that scans `ability.targets` for the + /// first object would pick the tap clause's creature instead of the player + /// the host filter names. + fn run_with_player_target(&mut self, player: PlayerId) -> ObjectId { + self.run_targeting(Some(player)) + } + + fn run_targeting(&mut self, player: Option) -> ObjectId { let source = self.source; let selected = self.selected; - let outcome = self - .runner - .activate(source, 0) - .target_object(selected) - .resolve(); + let activation = self.runner.activate(source, 0).target_object(selected); + let activation = match player { + Some(player) => activation.target_player(player), + None => activation, + }; + let outcome = activation.resolve(); let created: Vec = outcome .events() .iter() @@ -452,3 +564,116 @@ fn a_paired_source_filter_never_inherits_the_selected_target() { "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 +/// property-free `Typed` shape, which `legal_targets` enumerates as PLAYERS, not +/// objects — `TargetFilter::denotes_player_target` is the authority both it and +/// the host resolver read. The host is therefore the chosen player, and the +/// unrelated object slot in the same ability must not be mistaken for it. +/// +/// Selenia, the Cursed Heart is the shipped card in this class, and the only one +/// in the pool whose `attach_to` is player-valued: measured over the 35 795-card +/// export, the 47 `attach_to` filters are 34 `Typed` and 13 `ParentTarget`, and +/// exactly one of the 34 is this shape. +#[test] +fn a_player_host_filter_enchants_the_targeted_player_not_the_object_target() { + let mut chain = RoleChain::build_curse(target_opponent_filter()); + let token = chain.run_with_player_target(P1); + + assert_eq!( + chain.host_of(token), + Some(AttachTarget::Player(P1)), + "a player-valued host filter must reach the chosen player" + ); + assert_eq!( + chain.attachments_of(chain.selected), + 0, + "the tap clause's creature is another clause's target and must never \ + become the Curse's host" + ); +} + +/// CR 608.2c + CR 109.4: the resolution-chosen player is the same property-free +/// `Typed` shape, but it is a context reference — `is_context_ref` says so +/// through `chosen_player_index` — so its player comes from the resolution's +/// chosen-player list, never from a declared target slot. +/// +/// The discriminating half is the object target: the ability taps a creature in +/// the same resolution, so a host resolver that read `ability.targets` would +/// attach the Curse to that creature. +#[test] +fn a_chosen_player_host_filter_never_inherits_the_object_target() { + let mut chain = RoleChain::build_curse_after_choosing_an_opponent(chosen_player_filter()); + let token = chain.run(); + + assert_eq!( + chain.host_of(token), + Some(AttachTarget::Player(P1)), + "the chosen player is the host; P1 is the only opponent that could be chosen" + ); + assert_eq!( + chain.attachments_of(chain.selected), + 0, + "a resolution-chosen player reference must not enchant the object the \ + ability selected" + ); +} + +/// The shipped card in the player-host class, replayed end to end: Selenia, the +/// Cursed Heart — "When Selenia dies, create a legendary black Aura Curse +/// enchantment token named Selenia's Curse attached to target opponent." +/// +/// Her Curse is the only `attach_to` filter in the export whose slot holds a +/// player. Before this fix the host resolver scanned the ability's targets for +/// an OBJECT, found the player slot and skipped it, and the Curse entered +/// enchanting nothing — CR 704.5m moved it to the graveyard and CR 111.7 ended +/// it there, inside the same resolution. The card read as doing nothing at all. +#[test] +fn selenias_curse_enchants_the_targeted_opponent() { + let Some(db) = load_db() else { + return; + }; + let card = "Selenia, the Cursed Heart"; + if db.get_face_by_name(card).is_none() { + eprintln!("skipping: {card} is not in integration_cards.json.gz"); + return; + } + + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let selenia = scenario.add_real_card(P0, card, Zone::Battlefield, db); + let mut runner = scenario.build(); + engine::game::rehydrate_game_from_card_db(runner.state_mut(), db); + + // Killed as a game event so the dies trigger observes the death, rather than + // by editing the zone behind the engine's back. + let mut events = Vec::new(); + engine::game::zones::move_to_zone(runner.state_mut(), selenia, Zone::Graveyard, &mut events); + engine::game::process_triggers(runner.state_mut(), &events); + assert_eq!( + runner.state().stack.len(), + 1, + "Selenia's dies trigger must be on the stack" + ); + runner.advance_until_stack_empty(); + + // Found by type rather than by name: the export's token carries the source's + // name ("Selenia") instead of the printed "Selenia's Curse", which is a + // separate card-data question and not what this row is about. + let curse = runner + .state() + .objects + .values() + .find(|object| object.is_token && object.card_types.subtypes.iter().any(|s| s == "Curse")) + .expect("the Curse token must still exist; an unattached Aura is swept"); + assert_eq!( + curse.zone, + Zone::Battlefield, + "the Curse survives the CR 704.5m unattached-Aura check" + ); + assert_eq!( + curse.attached_to, + Some(AttachTarget::Player(P1)), + "CR 303.4: the Curse enchants the opponent its trigger targeted" + ); +} From e6c44e07d9043e3c347e0cae22416a1e162ce8f6 Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:07:51 +0200 Subject: [PATCH 6/7] fix(engine): never invent an attachment host for an unnamed player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways the host resolver could still reach a host nobody named. `AttachHostAuthority::ChosenPlayer` routed through `resolve_player_for_context_ref`, which falls back to `ability.controller` when the chosen-player slot is unbound. That fallback is right for a sub-effect that must still act ("the chosen player draws a card") and wrong for a host: an unbound slot means the sentence names nobody, and attaching to the controller invents the host this resolver exists to prevent. The arm carries the index and reads `ability.chosen_players` directly, yielding no host instead. CR 608.2c + CR 109.4. `TargetFilter::And`/`Or`/`Not` were classified by their outer shape, but a composite can CONTAIN a context reference: the parser builds `And { ExiledBySource, Typed }` for "an exiled card that is a creature", and `is_context_ref` reports the whole filter as one because `references_exiled_by_source` recurses. The object comes from the exile link, not from a target slot, so the composites now ask that authority ahead of the object-predicate arm. CR 601.3. Selenia's regression drives her death through lethal damage and the CR 704.5g state-based action rather than moving her zone by hand, so the dies trigger observes a real death. Tests: `an_unbound_chosen_player_yields_no_host_rather_than_the_controller` (counter-measured: the old resolver puts the Curse on `Player(PlayerId(0))`), `a_composite_carrying_the_exile_anaphor_is_not_a_selected_target`, and a direct unit test for `denotes_player_target` covering both player slots, the empty `Typed` across controller scopes, and the two negatives that matter — `properties: [Token]` and the CR 115.4 `[Another]` shape. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/effects/token.rs | 69 ++++++++++++++++--- crates/engine/src/types/ability.rs | 68 ++++++++++++++++++ .../self_attached_aura_token_host.rs | 46 +++++++++++-- 3 files changed, 171 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index f7bb8465a0..b410f66826 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -2998,12 +2998,20 @@ fn resolve_attach_host( // `attach_to` does for an object host. AttachHostAuthority::SelectedPlayerTarget => first_player_host(ability), // CR 608.2c + CR 109.4: the resolution-chosen player, read from the - // resolution's own chosen-player list through the shared context-ref - // player resolver — the same authority every other "the chosen player - // s" sub-effect uses. - AttachHostAuthority::ChosenPlayer => Some(AttachTarget::Player( - super::resolve_player_for_context_ref(state, ability, filter), - )), + // resolution's own chosen-player list. + // + // Read from the slot directly rather than through + // `resolve_player_for_context_ref`: that helper falls back to + // `ability.controller` when the index is unbound, which is right for a + // sub-effect that must still act ("the chosen player draws a card") and + // wrong here. An unbound slot means the sentence names nobody, and this + // path may not invent a host — inventing one is the whole defect this + // resolver exists to prevent. No host, and CR 704.5m takes it from there. + AttachHostAuthority::ChosenPlayer(index) => ability + .chosen_players + .get(index as usize) + .copied() + .map(AttachTarget::Player), // Event-context hosts ("attached to the triggering creature") resolve the // triggering event's subject via the shared event-context resolver. AttachHostAuthority::EventContext => { @@ -3070,7 +3078,7 @@ enum AttachHostAuthority { /// CR 608.2c + CR 109.4: the Nth resolution-chosen player. Fixed while the /// ability resolves, never declared as a target — so it names its player /// through the chosen-player list, not through `ability.targets`. - ChosenPlayer, + ChosenPlayer(u8), /// An object the triggering event or the resolution context names. EventContext, /// The bare anaphoric pronoun, which reads the chosen target and otherwise @@ -3101,7 +3109,11 @@ fn classify_attach_host_authority(filter: &TargetFilter) -> AttachHostAuthority // arm below, which would otherwise read the ability's chosen targets and // attach the token to an unrelated object. TargetFilter::Typed(_) if filter.chosen_player_index().is_some() => { - AttachHostAuthority::ChosenPlayer + AttachHostAuthority::ChosenPlayer( + filter + .chosen_player_index() + .expect("guarded by the arm above"), + ) } // CR 115.1a: whether a target slot holds a player or an object is the // targeting layer's question, and `denotes_player_target` is the single @@ -3113,6 +3125,19 @@ fn classify_attach_host_authority(filter: &TargetFilter) -> AttachHostAuthority AttachHostAuthority::SelectedPlayerTarget } + // CR 601.3 + CR 608.2c: a composite can CONTAIN a context reference — + // the parser builds `And { ExiledBySource, Typed }` for "an exiled card + // that is a creature" — and `is_context_ref` reports the whole filter as + // one. Its object comes from the exile link, not from a target slot, so + // it fails closed here rather than reading `ability.targets`. Asked + // before the object-predicate arm below, which would otherwise claim the + // composite by its outer shape. + TargetFilter::And { .. } | TargetFilter::Or { .. } | TargetFilter::Not { .. } + if filter.is_context_ref() => + { + AttachHostAuthority::NoHost + } + // Predicates over objects — what a target slot is chosen with. TargetFilter::Any | TargetFilter::Typed(_) @@ -8902,4 +8927,32 @@ mod attach_host_authority_tests { ); } } + + /// CR 601.3: the case where the two ways of deciding disagree. A composite + /// that CONTAINS the exile anaphor is a context reference as a whole — + /// `is_context_ref` says so through `references_exiled_by_source`, which + /// recurses — while its OUTER shape is `And`, which is otherwise an object + /// predicate. The parser builds exactly this for "an exiled card that is a + /// creature", so classifying by shape would read the enclosing ability's + /// chosen targets for an object the exile link already names. + #[test] + fn a_composite_carrying_the_exile_anaphor_is_not_a_selected_target() { + let filter = TargetFilter::And { + filters: vec![ + TargetFilter::ExiledBySource, + TargetFilter::Typed(TypedFilter::creature()), + ], + }; + assert!( + filter.is_context_ref(), + "fixture guard: the composite must be a context reference" + ); + assert!( + matches!( + classify_attach_host_authority(&filter), + AttachHostAuthority::NoHost + ), + "a composite naming an exile-linked object has no host authority here" + ); + } } diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 8ecb143b48..28359de384 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -30728,3 +30728,71 @@ mod mana_target_role_tests { ); } } + +/// CR 115.1a: `denotes_player_target` is read by both +/// `game::targeting::legal_targets` and the Aura-token host resolver, so its +/// contract is pinned here rather than only through either consumer. +#[cfg(test)] +mod player_target_slot_tests { + use super::*; + + /// CR 115.1a: `denotes_player_target` is read by both + /// `game::targeting::legal_targets` and the Aura-token host resolver, so its + /// contract is pinned here rather than only through either consumer. + /// + /// The property-free requirement is the load-bearing half: a `Typed` filter + /// carrying an object characteristic denotes objects however player-shaped + /// the rest of it looks. + #[test] + fn denotes_player_target_covers_the_player_slots_and_nothing_else() { + let empty_typed = |controller: Option| { + TargetFilter::Typed(TypedFilter { + type_filters: Vec::new(), + controller, + properties: Vec::new(), + }) + }; + + for filter in [ + TargetFilter::Player, + TargetFilter::SpecificPlayer { id: PlayerId(1) }, + empty_typed(Some(ControllerRef::Opponent)), + empty_typed(Some(ControllerRef::You)), + // A resolution-chosen player is a player slot by shape; callers that + // must tell it apart ask `chosen_player_index` first. + empty_typed(Some(ControllerRef::ChosenPlayer { index: 0 })), + empty_typed(None), + ] { + assert!( + filter.denotes_player_target(), + "{filter:?} names a player, not an object" + ); + } + + for filter in [ + TargetFilter::Any, + TargetFilter::Typed(TypedFilter::creature()), + TargetFilter::Opponent, + TargetFilter::Controller, + TargetFilter::SelfRef, + // "target token you control" — a characteristic no player has. + TargetFilter::Typed(TypedFilter { + type_filters: Vec::new(), + controller: Some(ControllerRef::You), + properties: vec![FilterProp::Token], + }), + // CR 115.4 "any other target": players AND objects, so not a + // player-only slot. + TargetFilter::Typed(TypedFilter { + type_filters: Vec::new(), + controller: None, + properties: vec![FilterProp::Another], + }), + ] { + assert!( + !filter.denotes_player_target(), + "{filter:?} does not name a player-only target slot" + ); + } + } +} 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 5bbe76d8c0..c8ba2de876 100644 --- a/crates/engine/tests/integration/self_attached_aura_token_host.rs +++ b/crates/engine/tests/integration/self_attached_aura_token_host.rs @@ -645,11 +645,24 @@ fn selenias_curse_enchants_the_targeted_opponent() { let mut runner = scenario.build(); engine::game::rehydrate_game_from_card_db(runner.state_mut(), db); - // Killed as a game event so the dies trigger observes the death, rather than - // by editing the zone behind the engine's back. + // Killed the way the game kills: lethal damage, then the CR 704.5g + // state-based action moves her to the graveyard and the dies trigger + // observes THAT death. Moving the zone by hand would skip both the + // replacement pipeline and the state-based pass. + runner + .state_mut() + .objects + .get_mut(&selenia) + .expect("Selenia is on the battlefield") + .damage_marked = 99; let mut events = Vec::new(); - engine::game::zones::move_to_zone(runner.state_mut(), selenia, Zone::Graveyard, &mut events); - engine::game::process_triggers(runner.state_mut(), &events); + engine::game::sba::check_state_based_actions(runner.state_mut(), &mut events); + assert_eq!( + runner.state().objects[&selenia].zone, + Zone::Graveyard, + "the state-based action must be what kills her" + ); + engine::game::triggers::process_triggers(runner.state_mut(), &events); assert_eq!( runner.state().stack.len(), 1, @@ -677,3 +690,28 @@ fn selenias_curse_enchants_the_targeted_opponent() { "CR 303.4: the Curse enchants the opponent its trigger targeted" ); } + +/// CR 608.2c: with no player bound to the chosen-player slot, the sentence names +/// nobody, so the token gets no host. +/// +/// The shared context-ref player resolver falls back to `ability.controller` for +/// an unbound slot, which is right for a sub-effect that must still act ("the +/// chosen player draws a card") and wrong for a host: the controller was never +/// named, and inventing a host is the defect this resolver exists to prevent. +/// The same fixture as the row above, minus the choosing step. +#[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(); + + 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, + "and certainly not the object the ability selected" + ); +} From 986ca4d8f2d94f44888104768af8f22d34069f41 Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:37:42 +0200 Subject: [PATCH 7/7] fix(engine): resolve the pronoun host through the ParentTarget authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The untargeted fallback resolved `TargetFilter::TriggeringSource` while implementing the `ParentTarget` anaphor. It agreed with that authority on the zone-change shapes the shipped cards print and on nothing else: `targeting::resolved_targets` carries referents this clause has no business re-deriving — the attack batch, the cast spell, the blocked attacker, and the Stationed / VehicleCrewed / Saddled subjects (CR 702.184a, CR 702.122, CR 702.171). On a zone change that authority yields the ENTERING object only when it is not the ability's source (Gylwain, Casting Director creates the Role for another creature that entered) and otherwise falls back to the source, which is what "When THIS creature enters … attached to it" needs. So the shipped class keeps its behaviour and every other event kind gains the right one. The non-empty chosen-target path is untouched. Test `the_pronoun_names_the_creature_the_trigger_watched_not_the_source` builds the shape where referent and source differ, from Oracle text so it runs in CI. Counter-measured: with the fallback reading `source_id` it fails `left: Some(Object(ObjectId(1))) right: Some(Object(ObjectId(2)))`. It does NOT discriminate `TriggeringSource` from `ParentTarget` — those agree on a zone change, which is the point above — so it pins the referent-is-not-the-source property rather than the resolver swap. CR 608.2c + CR 608.2k. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/effects/token.rs | 30 +++++++++---- .../self_attached_aura_token_host.rs | 45 +++++++++++++++++++ 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/game/effects/token.rs b/crates/engine/src/game/effects/token.rs index b410f66826..2098c4b082 100644 --- a/crates/engine/src/game/effects/token.rs +++ b/crates/engine/src/game/effects/token.rs @@ -3025,25 +3025,37 @@ fn resolve_attach_host( // // CR 608.2k: such a pronoun names the specific untargeted object the // ability's trigger condition already referred to ("When this creature - // enters, create a Monster Role token attached to IT"). The event context - // is the single authority for that object, so the fallback routes through - // the same resolver the `EventContext` arm above uses rather than reading - // `source_id`: Gylwain, Casting Director creates the Role for ANOTHER - // creature that entered, and the event's subject — not the source — is - // the one its sentence refers to. + // enters, create a Monster Role token attached to IT"). + // + // `ParentTarget` IS that anaphor, and `targeting::resolved_targets` is + // its authority — so the fallback asks it rather than substituting a + // neighbouring one. It carries referents this clause has no business + // re-deriving: the attack batch, the cast spell, the blocked attacker, + // and the Stationed / VehicleCrewed / Saddled subjects (CR 702.184a, + // CR 702.122, CR 702.171). On a zone change it hands back the ENTERING + // object only when that is not the source — Gylwain, Casting Director + // creates the Role for another creature that entered — and otherwise + // falls back to the source, which is what "When THIS creature enters … + // attached to it" needs. Resolving `TriggeringSource` here happened to + // agree on both zone-change shapes and on nothing else. // // The fallback is confined to this arm and to an ability that chose // NOTHING. A typed targeting filter that legally selected zero targets // ("attached to target creature you control" with no legal target) keeps // its own no-host outcome: nothing in its text names an untargeted // object, so CR 608.2k does not reach it. + // + // One host is taken from what may be a batch: the clause creates one + // token and its pronoun names one thing. AttachHostAuthority::Pronoun => first_object_host(ability).or_else(|| { ability.targets.is_empty().then(|| { - crate::game::targeting::resolve_event_context_target( + crate::game::targeting::resolved_targets( + ability, + &TargetFilter::ParentTarget, state, - &TargetFilter::TriggeringSource, - ability.source_id, ) + .into_iter() + .next() .map(target_ref_to_attach_target) })? }), 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 c8ba2de876..0625de31a1 100644 --- a/crates/engine/tests/integration/self_attached_aura_token_host.rs +++ b/crates/engine/tests/integration/self_attached_aura_token_host.rs @@ -51,6 +51,12 @@ const SELF_ATTACHED_ETB: &str = const TARGETED_ETB: &str = "When this creature enters, create a Monster Role token attached to \ target creature you control."; +/// Gylwain's shape: the trigger watches ANOTHER creature, so the pronoun's +/// referent and the ability's source are different objects. This is the row that +/// separates "the parent-target anaphor" from "the source". +const OTHER_CREATURE_ETB: &str = "Whenever another creature you control enters, create a Monster \ + Role token attached to it."; + fn colorless(count: usize) -> Vec { pool(count, &[]) } @@ -715,3 +721,42 @@ fn an_unbound_chosen_player_yields_no_host_rather_than_the_controller() { "and certainly not the object the ability selected" ); } + +/// CR 608.2c + CR 608.2k: the bare pronoun resolves through the `ParentTarget` +/// authority (`targeting::resolved_targets`), which is the anaphor it is +/// implementing. On a zone change that authority hands back the ENTERING object +/// whenever it is not the ability's source, and only otherwise falls back to the +/// source. +/// +/// The discriminating shape is Gylwain's: the trigger watches another creature, +/// so referent and source are different objects and a resolver that reads +/// `source_id` would enchant the wrong permanent. +#[test] +fn the_pronoun_names_the_creature_the_trigger_watched_not_the_source() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool(P0, colorless(4)); + let watcher = scenario + .add_creature_from_oracle(P0, "Role Patron", 2, 2, OTHER_CREATURE_ETB) + .id(); + let newcomer = scenario + .add_creature_to_hand_from_oracle(P0, "Role Recipient", 3, 3, "") + .id(); + let mut runner = scenario.build(); + + runner.cast(newcomer).resolve(); + runner.advance_until_stack_empty(); + + let token = only_role_token(&runner); + assert_eq!( + token.attached_to, + Some(AttachTarget::Object(newcomer)), + "the pronoun names the creature that entered, not the permanent whose \ + ability watched it" + ); + assert_eq!( + runner.state().objects[&watcher].attachments.len(), + 0, + "the source must not collect the Role its own trigger created for another" + ); +}