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
62 changes: 60 additions & 2 deletions crates/engine/src/game/effects/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2450,12 +2450,41 @@ fn apply_parent_chain_context(
/// NOT inherit an earlier instruction's already-chosen recipient, or every
/// replicated instruction in a chain collapses onto whichever single object
/// the first one picked (Kathril, Aspect Warper, issue #6321 / PR #6533).
///
/// CR 406.6 + CR 607.2a (issue #6437, Fight Rigging / Collector's Cage): a sub
/// whose own effect targets a BARE `TargetFilter::ExiledBySource` resolves
/// that reference itself, at its own resolution, against this source's
/// durable `exile_links` (`cast_from_zone::resolve`'s no-target
/// `ExiledBySource` branch) — it never needs a pre-chosen object. "Put a
/// +1/+1 counter on target creature you control. Then ... you may play the
/// exiled card" chains a targeted clause before the linked-exile clause in
/// the SAME resolution; without this guard the counter's targeted creature
/// propagates into the exiled-card sub as if IT were the card to play, and
/// `CastFromZone` proceeds to grant a cast permission (and, for a
/// battlefield object, an exile-delivery move) on the targeted creature
/// instead of the hidden card.
///
/// Gated on `!effect_refs_parent_target`: a COMPOSED filter like Jodah's
/// cleanup rider (`And { ExiledBySource, Typed(DistinctFrom { ParentTarget })
/// }`, sweeping the "misses" while excluding the cast hit) also references
/// `ExiledBySource`, but STILL needs the propagated parent target so
/// `ParentTarget` can resolve to the hit and exclude it from the sweep — the
/// declined-branch dispatcher (this function's `Chaos-Wand cleanup` caller)
/// already ANDs this predicate with its own `effect_refs_parent_target`
/// check for exactly that reason, so excluding the composed case here too
/// would strand the exclusion and sweep the hit itself onto the library
/// bottom alongside the misses.
/// Every other sub keeps today's behavior: parent targets propagate when the
/// sub declares none of its own.
fn should_propagate_parent_targets(ability: &ResolvedAbility, sub: &ResolvedAbility) -> bool {
sub.targets.is_empty()
&& !ability.targets.is_empty()
&& sub.target_choice_timing != TargetChoiceTiming::Resolution
&& !(sub
.effect
.target_filter()
.is_some_and(TargetFilter::references_exiled_by_source)
&& !effect_refs_parent_target(&sub.effect))
}

fn waits_for_resolution_choice(waiting_for: &WaitingFor) -> bool {
Expand Down Expand Up @@ -10425,10 +10454,39 @@ fn resolve_chain_body(
// gated subs ("When you discard a card this way, put a counter on target
// Faerie") keep inheriting their selected target through the parent
// chain; their condition decides whether the sub fires.
//
// CR 406.6 + CR 607.2a (issue #6437, Fight Rigging / Collector's
// Cage): a sub targeting a BARE `TargetFilter::ExiledBySource`
// ("the exiled card") is ALSO independent — it resolves its own
// object at its own resolution against this source's durable
// `exile_links` (`cast_from_zone::resolve`'s no-target
// `ExiledBySource` branch), never from an inherited object target.
// `extract_target_filter_from_effect` returns `None` for it (its
// `is_context_ref` guard), so without this arm it fell through to
// the default "no independent slot" case and inherited whatever
// object the PARENT clause targeted ("put a +1/+1 counter on
// target creature you control. Then ... you may play the exiled
// card") — treating the targeted creature as the card to license,
// which `grant_lingering_permissions` then routed through the
// exile-delivery batch instead of the hidden card.
//
// Gated on `!effect_refs_parent_target`: a COMPOSED filter like
// Jodah's cleanup rider (`And { ExiledBySource, DistinctFrom {
// ParentTarget } }`, sweeping the "misses" while excluding the
// cast hit) also references `ExiledBySource` but STILL needs the
// propagated parent target so `ParentTarget` can resolve to the
// hit and exclude it — treating it as independent here stranded
// that exclusion, sweeping the hit itself to the library bottom
// alongside the misses.
let has_independent_target_slot =
crate::game::triggers::extract_target_filter_from_effect(&sub.effect).is_some()
(crate::game::triggers::extract_target_filter_from_effect(&sub.effect).is_some()
&& !effect_refs_parent_target(&sub.effect)
&& !sub_ability_target_belongs_to_reflexive_context(sub);
&& !sub_ability_target_belongs_to_reflexive_context(sub))
|| (sub
.effect
.target_filter()
.is_some_and(TargetFilter::references_exiled_by_source)
&& !effect_refs_parent_target(&sub.effect));
sub_with_targets.targets = ability
.targets
.iter()
Expand Down
37 changes: 37 additions & 0 deletions crates/engine/src/game/scenario.rs
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,43 @@ impl GameScenario {
builder
}

/// Add a nonland, noncreature permanent (e.g. an enchantment) to the
/// battlefield with abilities parsed from Oracle text. Mirrors
/// `add_land_from_oracle`; needed for permanents whose own triggered/
/// static abilities (not a cast) are under test — e.g. a Hideaway
/// enchantment's beginning-of-combat trigger.
pub fn add_enchantment_from_oracle(
&mut self,
player: PlayerId,
name: &str,
oracle_text: &str,
) -> CardBuilder<'_> {
let card_id = CardId(self.state.next_object_id);
let id = create_object(
&mut self.state,
card_id,
player,
name.to_string(),
Zone::Battlefield,
);
let ts = self.state.next_timestamp();
let obj = self.state.objects.get_mut(&id).unwrap();
obj.card_types.core_types.push(CoreType::Enchantment);
obj.base_card_types = obj.card_types.clone();
obj.timestamp = ts;
// CR 302.6 note: summoning sickness only gates creatures, but the
// builder models a pre-existing permanent (entered on a prior turn),
// matching `add_land_from_oracle`'s override.
obj.summoning_sick = false;

let mut builder = CardBuilder {
state: &mut self.state,
id,
};
builder.from_oracle_text(oracle_text);
builder
}

/// Add a creature to hand with abilities parsed from Oracle text.
pub fn add_creature_to_hand_from_oracle(
&mut self,
Expand Down
20 changes: 13 additions & 7 deletions crates/engine/src/game/zones.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1421,6 +1421,18 @@ pub fn stamp_simultaneous_from_slice(state: &GameState, slice: &mut [GameEvent])
mark_simultaneous_departures(slice, &departed);
}

/// CR 406.6 + CR 607.2a (issue #6437): Snapshot `source_id`'s linked exiles at
/// the moment it leaves the battlefield, for a leaves-the-battlefield
/// trigger's later `ExiledBySource` lookup (`filter.rs`'s `trigger_source.
/// is_some()` branch). Every `ExileLinkKind` is kind-agnostically readable via
/// `ExiledBySource` (`HideawayLookable`'s and `CraftMaterial`'s own doc
/// comments say so explicitly) and the LIVE lookup
/// (`players::linked_exile_cards_for_source`) does not filter by kind either —
/// this snapshot must match that surface exactly, or a card whose "play the
/// exiled card" clause resolves via a TRIGGERED ability (Fight Rigging's
/// begin-of-combat trigger, as opposed to Windbrisk Heights' activated
/// ability) silently finds nothing: Hideaway's link is `HideawayLookable`, and
/// a `TrackedBySource`-only filter here dropped it before the previous fix.
pub(crate) fn capture_linked_exile_snapshot(
state: &GameState,
source_id: ObjectId,
Expand All @@ -1433,13 +1445,7 @@ pub(crate) fn capture_linked_exile_snapshot(
state
.exile_links
.iter()
.filter(|link| {
link.source_id == source_id
&& matches!(
link.kind,
crate::types::game_state::ExileLinkKind::TrackedBySource
)
})
.filter(|link| link.source_id == source_id)
.filter_map(|link| {
state.objects.get(&link.exiled_id).and_then(|obj| {
(obj.zone == Zone::Exile).then(|| crate::types::game_state::LinkedExileSnapshot {
Expand Down
Loading
Loading