diff --git a/crates/engine/src/game/effects/cast_from_zone.rs b/crates/engine/src/game/effects/cast_from_zone.rs index 0acd362b5b..ca87e5be31 100644 --- a/crates/engine/src/game/effects/cast_from_zone.rs +++ b/crates/engine/src/game/effects/cast_from_zone.rs @@ -93,12 +93,26 @@ fn tracked_set_cast_candidates( let Some(members) = state.tracked_object_sets.get(&id) else { return Vec::new(); }; - let ctx = crate::game::filter::FilterContext::from_ability(ability); let mut seen = HashSet::new(); - members + let deduped: Vec = members .iter() .copied() .filter(|obj_id| seen.insert(*obj_id)) + .collect(); + // CR 607.2a + CR 608.2c: bind the filter's object-scope reads to exactly the + // published set, mirroring the two `ExiledBySource` sites below. Building the + // context from `ability` directly would carry `ability.targets` — for Sanar, + // the whole reveal window the chain seam injected — so a residual leg that + // reads object scope (a `ParentTarget`-relative comparison, a same-name or + // shares-a-type leg) would evaluate against the injected window rather than + // the members actually published. Latent today (all 51 cards bind + // `filter: Any`, which reads no object scope) and closed here so it stays that + // way. + let mut scoped_ability = ability.clone(); + scoped_ability.targets = deduped.iter().copied().map(TargetRef::Object).collect(); + let ctx = crate::game::filter::FilterContext::from_ability(&scoped_ability); + deduped + .into_iter() .filter(|obj_id| crate::game::filter::matches_target_filter(state, *obj_id, &bound, &ctx)) .collect() } diff --git a/crates/engine/src/parser/oracle_effect/assembly.rs b/crates/engine/src/parser/oracle_effect/assembly.rs index 0670a7d455..78cacfd87f 100644 --- a/crates/engine/src/parser/oracle_effect/assembly.rs +++ b/crates/engine/src/parser/oracle_effect/assembly.rs @@ -2431,7 +2431,7 @@ pub(crate) fn assemble_effect_chain(ir: &EffectChainIr) -> AbilityDefinition { let has_tracked_ref = contains_explicit_tracked_set_pronoun(&source_text_lower) || contains_implicit_tracked_set_pronoun(&source_text_lower); if has_tracked_ref { - // CR 608.2c + CR 614.6: same walk, narrower predicate — + // CR 608.2c + CR 607.2a: same walk, narrower predicate — // does any prior clause publish members stamped `Exiled`? // Only then may a cast anaphor narrow to // `caused_by: Exiled`. diff --git a/crates/engine/src/parser/oracle_effect/tests.rs b/crates/engine/src/parser/oracle_effect/tests.rs index 5c22f141a8..a1a48ff53b 100644 --- a/crates/engine/src/parser/oracle_effect/tests.rs +++ b/crates/engine/src/parser/oracle_effect/tests.rs @@ -22561,6 +22561,23 @@ fn exiled_cause_publishers_all_stamp_exiled_at_runtime() { // accepted by `chain_clause_is_exile_producer` and must stay rejected here. let uncaused_exilers = [ Effect::HeistExile, + Effect::Dig { + player: TargetFilter::Controller, + count: QuantityExpr::Fixed { value: 1 }, + destination: Some(Zone::Exile), + keep_count: Some(1), + keep_count_expr: None, + up_to: false, + filter: TargetFilter::Any, + rest_destination: None, + reveal: false, + enter_tapped: false, + source: crate::types::ability::DigSource::default(), + }, + Effect::ExileHaunting { + target: TargetFilter::Any, + }, + Effect::ExileResolvingSpellInsteadOfGraveyard { on_exile: None }, Effect::RevealUntil { player: TargetFilter::Controller, filter: TargetFilter::Any,