Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5998,6 +5998,44 @@ fn filter_uses_relative_controller_scoped(filter: &TargetFilter) -> bool {
}
}

/// CR 109.4 + CR 115.10a (issue #6505): True when this ability's effect — or any
/// effect in its sub-ability / else-ability chain — carries a `ScopedPlayer`-
/// scoped move-object filter ("a creature they control" / "their graveyard").
/// Used by `stack::resolve_top` to bind the resolution-time scoped player to the
/// ability's single player target, so a "target opponent exiles a creature they
/// control" chooser is the target opponent, not the caster.
pub(crate) fn ability_uses_relative_controller_scoped(ability: &ResolvedAbility) -> bool {
fn effect_moves_scoped_filter(effect: &Effect) -> bool {
if effect
.target_filter()
.is_some_and(filter_uses_relative_controller_scoped)
{
return true;
}
// `Effect::target_filter()` does not surface `ChangeZoneAll`'s mass
// filter, so check it explicitly (the "their graveyard" leg).
matches!(
effect,
Effect::ChangeZoneAll { target, .. } if filter_uses_relative_controller_scoped(target)
)
}
let mut node = Some(ability);
while let Some(current) = node {
if effect_moves_scoped_filter(&current.effect) {
return true;
}
if current
.else_ability
.as_deref()
.is_some_and(ability_uses_relative_controller_scoped)
{
return true;
}
node = current.sub_ability.as_deref();
}
false
}

pub(crate) fn controller_for_relative_filter(
ability: &ResolvedAbility,
target_filter: &TargetFilter,
Expand Down
25 changes: 25 additions & 0 deletions crates/engine/src/game/stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,31 @@ pub fn resolve_top(state: &mut GameState, events: &mut Vec<GameEvent>) {
}
}

// CR 109.4 + CR 115.10a/b (issue #6505): "Target opponent exiles a creature
// they control and their graveyard" (Strategic Betrayal). The spell targets
// ONLY the opponent (CR 115.1a); that opponent then CHOOSES a creature they
// control and exiles their graveyard — so a `ScopedPlayer`-scoped move-object
// filter must resolve its acting/choosing player against the resolved single
// player target, not the caster. Sibling of the DamageDealt/AttackersDeclared
// scoped-player stamp above: bind `scoped_player` from the ability's lone
// `TargetRef::Player` before the change_zone choosers run at resolution.
if let Some(ability) = ability.as_mut() {
if ability.scoped_player.is_none() {
let single_player_target = ability
.targets
.iter()
.filter(|target| matches!(target, TargetRef::Player(_)))
.count()
== 1;
if single_player_target
&& crate::game::effects::ability_uses_relative_controller_scoped(ability)
{
let actor = ability.target_player();
ability.set_scoped_player_recursive(actor);
}
}
}

// CR 608.2c: Re-stamp ParentTarget anaphora from the stack entry's trigger
// event at resolution time (Stationed/VehicleCrewed/Saddled/attack batches).
// Push-time seeding in `push_pending_trigger_to_stack_with_event_batch` can
Expand Down
7 changes: 6 additions & 1 deletion crates/engine/src/parser/oracle_effect/imperative.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8378,7 +8378,12 @@ pub(super) fn parse_exile_ast(
} else {
parsed_target
};
let origin = super::infer_origin_zone(rest_lower);
// CR 400.7 (issue #6505): infer the origin zone excluding ONLY a trailing
// compound conjunct ("and their graveyard", Strategic Betrayal) so its
// sibling graveyard leg cannot leak Zone::Graveyard onto the battlefield
// creature leg — while a non-"and" qualifier ("instead of putting it into
// its owner's graveyard") still defines this leg's origin.
let origin = super::infer_origin_zone(&super::compound_exile_origin_scan(rest_text, rem));
Some(ZoneCounterImperativeAst::Exile {
origin,
target,
Expand Down
5 changes: 4 additions & 1 deletion crates/engine/src/parser/oracle_effect/lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1611,7 +1611,10 @@ fn filter_mentions_exiled_by_source(filter: &TargetFilter) -> bool {

/// CR 115.1: True when a `ChangeZone` clause selects from the battlefield
/// (explicitly or by permanent-type default) rather than a private/off-BF zone.
fn change_zone_selects_battlefield_permanent(origin: Option<Zone>, target: &TargetFilter) -> bool {
pub(super) fn change_zone_selects_battlefield_permanent(
origin: Option<Zone>,
target: &TargetFilter,
) -> bool {
if target.is_context_ref() {
return false;
}
Expand Down
Loading
Loading