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
52 changes: 52 additions & 0 deletions crates/engine/src/game/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2386,6 +2386,58 @@ pub fn matches_target_filter_in_owner_zone(
)
}

/// CR 400.3: the zones whose membership is keyed by OWNER rather than controller —
/// "If an object would go to any library, graveyard, or hand other than its owner's,
/// it goes to its owner's corresponding zone." The rule enumerates the partition
/// itself; this predicate is that enumeration and nothing more.
///
/// Why ownership is the correct scope for a `ControllerRef::You` filter there:
/// CR 108.4 + CR 108.4a — a card has a controller only when it represents a
/// permanent or spell; if it has no controller, use its owner instead. A card in a
/// hand, library, or graveyard is neither, so CR 109.5 routes "you"/"your" to its
/// owner. Thus "your graveyard" is an ownership claim even though the parser
/// represents its player scope as `ControllerRef::You`.
///
/// EXILE IS DELIBERATELY EXCLUDED, and CR 400.3 excludes it too — the rule names
/// library, graveyard, and hand, not exile. The engine matches exiled objects
/// against their AT-EXILE controller via `effective_controller`'s LKI fallback,
/// which the Oversimplify class depends on ("creatures they controlled that were
/// exiled this way" is keyed on who controlled the object when it left, not on who
/// owns it now). Substituting ownership there would break that class.
///
/// The single authority for this partition: `game::targeting::add_zone_targets`
/// (target enumeration) and `game::off_zone_characteristics` (off-zone keyword
/// grants) both route through it, so the two cannot drift on which zones are
/// owner-scoped.
pub fn is_owner_scoped_zone(zone: Zone) -> bool {
matches!(zone, Zone::Hand | Zone::Library | Zone::Graveyard)
}

/// CR 400.3 + CR 109.5 + CR 108.4a: match `object_id` against `filter` using the
/// ownership semantics of the zone it is being enumerated from.
///
/// The single entry point for "evaluate this filter against an object in zone Z".
/// In an owner-scoped zone (see [`is_owner_scoped_zone`]) this delegates to
/// [`matches_target_filter_in_owner_zone`], so a stale `obj.controller` left behind
/// by a control-change effect cannot exclude the object from its own owner's
/// player-scoped query — the state `effects::change_zone` documents for a stolen
/// creature that dies into its owner's graveyard, where `reset_for_battlefield_exit`
/// leaves `controller = thief`. Everywhere else it delegates to the ordinary
/// controller-scoped [`matches_target_filter`].
pub fn matches_target_filter_for_zone(
state: &GameState,
object_id: ObjectId,
zone: Zone,
filter: &TargetFilter,
ctx: &FilterContext<'_>,
) -> bool {
if is_owner_scoped_zone(zone) {
matches_target_filter_in_owner_zone(state, object_id, filter, ctx)
} else {
matches_target_filter(state, object_id, filter, ctx)
}
}

pub fn matches_target_filter_on_battlefield_entry(
state: &GameState,
event: &ProposedEvent,
Expand Down
21 changes: 7 additions & 14 deletions crates/engine/src/game/off_zone_characteristics.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
use crate::game::filter::{
matches_target_filter, matches_target_filter_in_owner_zone, FilterContext,
};
use crate::game::filter::FilterContext;
use crate::game::layers::{
active_continuous_effects_from_base_static_source, active_effect_condition_controller,
collect_shared_active_continuous_effects, evaluate_condition_with_recipient,
Expand Down Expand Up @@ -198,24 +196,19 @@ pub(crate) fn collect_applicable_off_zone_keyword_effects(
.collect()
}

/// CR 109.5 + CR 400.3: "your" cards in hand/library/graveyard are scoped by owner,
/// not by a stale object controller/LKI. Delegates to
/// `filter::matches_target_filter_for_zone`, the single authority for that
/// partition, so this path and target enumeration in `game::targeting` cannot drift
/// on which zones are owner-scoped.
fn matches_off_zone_keyword_recipient(
state: &GameState,
object_id: ObjectId,
zone: Zone,
filter: &TargetFilter,
ctx: &FilterContext<'_>,
) -> bool {
if is_owner_scoped_zone(zone) {
matches_target_filter_in_owner_zone(state, object_id, filter, ctx)
} else {
matches_target_filter(state, object_id, filter, ctx)
}
}

fn is_owner_scoped_zone(zone: Zone) -> bool {
// CR 109.5 + CR 400.3: "your" cards in hand/library/graveyard are scoped
// by owner, not stale object controller/LKI.
matches!(zone, Zone::Hand | Zone::Library | Zone::Graveyard)
crate::game::filter::matches_target_filter_for_zone(state, object_id, zone, filter, ctx)
}

fn supports_off_zone_keyword_query(modification: &ContinuousModification) -> bool {
Expand Down
154 changes: 150 additions & 4 deletions crates/engine/src/game/targeting.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ fn find_legal_targets_with_context(
}
Zone::Exile => add_zone_targets(
state,
Zone::Exile,
state.exile.iter().copied(),
filter,
target_ctx,
Expand All @@ -315,6 +316,7 @@ fn find_legal_targets_with_context(
for player in &state.players {
add_zone_targets(
state,
Zone::Graveyard,
player.graveyard.iter().copied(),
filter,
target_ctx,
Expand All @@ -327,6 +329,7 @@ fn find_legal_targets_with_context(
for player in &state.players {
add_zone_targets(
state,
Zone::Hand,
player.hand.iter().copied(),
filter,
target_ctx,
Expand All @@ -339,6 +342,7 @@ fn find_legal_targets_with_context(
for player in &state.players {
add_zone_targets(
state,
Zone::Library,
player.library.iter().copied(),
filter,
target_ctx,
Expand Down Expand Up @@ -1009,9 +1013,26 @@ fn target_ref_matches_resolved_filter_with_context(
TargetRef::Object(id) if state.stack.iter().any(|entry| entry.id == *id) => {
super::filter::matches_stack_target_filter(state, *id, target_filter, ctx)
}
TargetRef::Object(id) => {
super::filter::matches_target_filter(state, *id, target_filter, ctx)
}
// CR 109.5 + CR 108.4 + CR 108.4a + CR 400.3: RE-VALIDATION must use the same
// ownership semantics as enumeration, or a target that was legal when chosen
// becomes illegal when the spell resolves. Unlike the battlefield scans in
// this file, an explicit target can live in ANY zone, so the zone is read off
// the object rather than assumed — `matches_target_filter_for_zone` then
// owner-scopes hand/library/graveyard and leaves battlefield and exile on
// controller matching, exactly as `add_zone_targets` does at selection time.
// Keeping the two seams on one authority is the point: while enumeration was
// owner-scoped and this check was not, a card in its owner's graveyard with a
// stale controller could be selected and then fizzle on resolution.
TargetRef::Object(id) => match state.objects.get(id) {
Some(obj) => super::filter::matches_target_filter_for_zone(
state,
*id,
obj.zone,
target_filter,
ctx,
),
None => false,
},
TargetRef::Player(player) => super::filter::player_matches_target_filter_in_state(
state,
target_filter,
Expand Down Expand Up @@ -2030,8 +2051,28 @@ fn stack_entry_controller_matches(
}
}

/// Enumerate legal targets among `object_ids`, all of which are being read out of
/// `zone`.
///
/// CR 109.5 + CR 108.4 + CR 108.4a + CR 400.3: `zone` is not bookkeeping — it selects
/// the ownership semantics the filter is evaluated under, via
/// `filter::matches_target_filter_for_zone`. A player-scoped query on a hand,
/// library, or graveyard ("target creature card from YOUR graveyard") is an
/// ownership claim as a matter of rule: a card has a controller only when it
/// represents a permanent or spell, and CR 108.4a uses the owner when it has none.
/// Cards in those zones are neither, so CR 109.5 resolves "your" to the owner.
/// CR 400.3 fixes which zones those are.
///
/// Matching them against `obj.controller` excluded a card from its OWN owner's
/// query whenever a control-change effect left a stale controller behind — the
/// state `effects::change_zone` documents for a creature stolen via Mind Control
/// that dies into its owner's graveyard, where `reset_for_battlefield_exit` does
/// not reset controller and the layer pass that would skips objects off the
/// battlefield. Exile keeps controller matching deliberately; see
/// `filter::is_owner_scoped_zone` for why.
fn add_zone_targets(
state: &GameState,
zone: Zone,
object_ids: impl IntoIterator<Item = ObjectId>,
filter: &TargetFilter,
target_ctx: &super::filter::FilterContext,
Expand All @@ -2049,7 +2090,7 @@ fn add_zone_targets(
let source_ignores_hexproof = require_full_targeting
&& crate::game::static_abilities::player_ignores_hexproof(state, source_controller);
for obj_id in object_ids {
if super::filter::matches_target_filter(state, obj_id, filter, target_ctx) {
if super::filter::matches_target_filter_for_zone(state, obj_id, zone, filter, target_ctx) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
let obj = match state.objects.get(&obj_id) {
Some(o) => o,
None => continue,
Expand Down Expand Up @@ -5189,6 +5230,111 @@ mod tests {
);
}

/// CR 109.5 + CR 108.4 + CR 108.4a + CR 400.3: a player-scoped query on an
/// owner-scoped zone follows OWNERSHIP, and — the point of this test — it does so
/// identically at both seams.
///
/// Selection (`find_legal_targets`) and resolution-time re-validation
/// (`resolved_object_ids_for_filter`, via
/// `target_ref_matches_resolved_filter_with_context`) are separate code paths that
/// must agree, or a target legally chosen on announcement becomes illegal on
/// resolution and the spell fizzles. Fixing only enumeration would leave exactly
/// that split, so both are asserted here on one state.
///
/// The fixture stages the divergence CR 400.3 makes reachable: a card goes to its
/// OWNER's graveyard, while `reset_for_battlefield_exit` leaves a stale
/// `controller` behind from a control-change effect. So `mine` (owner P0,
/// controller P1) is in P0's graveyard and must match "creature card in YOUR
/// graveyard"; `theirs` (owner P1, controller P0) is in P1's graveyard and must
/// not — under controller matching the two verdicts invert exactly.
#[test]
fn owner_scoped_zone_query_agrees_across_selection_and_resolution() {
let mut state = GameState::new_two_player(42);

let mut graveyard_creature =
|card: u64, owner: PlayerId, controller: PlayerId, name: &str| {
let id = create_object(
&mut state,
CardId(card),
owner,
name.to_string(),
Zone::Graveyard,
);
let obj = state.objects.get_mut(&id).expect("fixture present");
obj.card_types.core_types.push(CoreType::Creature);
obj.controller = controller;
id
};
let mine = graveyard_creature(1, PlayerId(0), PlayerId(1), "My Stolen Bear");
let theirs = graveyard_creature(2, PlayerId(1), PlayerId(0), "Their Stolen Bear");

// Premise: owner and controller really do diverge on both fixtures, so
// neither verdict below can be produced by a state where they coincide.
for (id, owner, controller) in [
(mine, PlayerId(0), PlayerId(1)),
(theirs, PlayerId(1), PlayerId(0)),
] {
let obj = &state.objects[&id];
assert_eq!(obj.owner, owner);
assert_eq!(obj.controller, controller);
}

// "target creature card in your graveyard", as the parser represents it:
// the player scope rides `ControllerRef::You`, and the ZONE decides that it
// is read as ownership.
let filter = TargetFilter::Typed(
TypedFilter::creature()
.controller(ControllerRef::You)
.properties(vec![FilterProp::InZone {
zone: Zone::Graveyard,
}]),
);

let source = create_object(
&mut state,
CardId(3),
PlayerId(0),
"Reanimation Spell".to_string(),
Zone::Stack,
);

// Seam 1 — selection.
let selectable = find_legal_targets(&state, &filter, PlayerId(0), source);
assert!(
selectable.contains(&TargetRef::Object(mine)),
"a card YOU OWN in your graveyard must be selectable despite a stale \
opponent controller: {selectable:?}"
);
assert!(
!selectable.contains(&TargetRef::Object(theirs)),
"a card an OPPONENT OWNS must not be selectable however it is \
controlled: {selectable:?}"
);

// Seam 2 — resolution-time re-validation of an already-chosen target.
let resolved = resolved_object_ids_for_filter(
&state,
&make_resolved_with_targets(vec![TargetRef::Object(mine)], source),
&filter,
);
assert!(
resolved.contains(&mine),
"the selected owner-scoped target must survive re-validation rather than \
fizzling: {resolved:?}"
);

let resolved_foreign = resolved_object_ids_for_filter(
&state,
&make_resolved_with_targets(vec![TargetRef::Object(theirs)], source),
&filter,
);
assert!(
!resolved_foreign.contains(&theirs),
"re-validation must not admit an opponent-owned card that selection \
refused: {resolved_foreign:?}"
);
}

fn make_resolved_with_targets(
targets: Vec<TargetRef>,
source: ObjectId,
Expand Down
Loading
Loading