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
1 change: 1 addition & 0 deletions crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3726,6 +3726,7 @@ fn walk_ability(
trigger_source: _, // exact triggered-source authority, no read/write effect
trigger_definition_ref: _, // exact trigger occurrence, no read/write effect
force_block_attacker: _, // exact force-block referent, no read/write effect
target_incarnations: _, // CR 400.7 pins on the referents, no read/write effect
Comment thread
matthewevans marked this conversation as resolved.
controller: _,
original_controller: _,
scoped_player: _,
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ fn resolved_ability_axes(a: &ResolvedAbility, mode: ScanMode) -> Axes {
trigger_source: _, // exact triggered-source authority, no dynamic read
trigger_definition_ref: _, // exact trigger occurrence, no dynamic read
force_block_attacker: _, // exact force-block referent, no dynamic read
target_incarnations: _, // CR 400.7 referent pins, no dynamic read
controller: _, // player id
original_controller: _, // player id
scoped_player: _, // player id (iteration binding)
Expand Down
1 change: 1 addition & 0 deletions crates/engine/src/game/effects/additional_phase.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ mod tests {
trigger_source: None,
trigger_definition_ref: None,
force_block_attacker: None,
target_incarnations: Vec::new(),
targets: vec![],
kind: AbilityKind::Spell,
sub_ability: None,
Expand Down
62 changes: 57 additions & 5 deletions crates/engine/src/game/effects/attach.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,28 @@ pub fn resolve(
return Ok(());
}

// CR 400.7 + CR 603.7c: a delayed attach whose pinned referent became a new
// object attaches nothing. The trigger fired and resolved (CR 603.7b); it
// affected nothing.
//
// PLACEMENT IS LOAD-BEARING — this MUST sit above the two `ok_or_else(..)?`
// conversions below. Letting the substitution in `resolve_object_filter`
// empty the list instead would surface `EffectError::MissingParam` and emit
// NO EffectResolved. Mirrors the CR 303.4j no-op guard further down, which
// is this function's proof that an events sink is in scope here.
//
// Reached by `gift of immortality` and `next of kin`: their ROOT is
// `ChangeZone{SelfRef}`, but `ability_pins_object_anaphor` walks the whole
// chain, so the `Attach` sub-ability's `ParentTarget` earns the pins.
if ability.pinned_object_targets_all_stale(state) {
events.push(GameEvent::EffectResolved {
kind: EffectKind::from(&ability.effect),
source_id,
subject: None,
});
return Ok(());
}

// CR 608.2h + CR 608.2k: Typed attachment operands resolve from the
// battlefield/LKI unless they are explicit player-chosen targets.
// Typed/scan-based attachment filters (e.g. "Equipment attached to ~") resolve
Expand Down Expand Up @@ -227,7 +249,28 @@ pub fn resolve_unattach_all(
attachment_filter,
TargetFilter::ParentTarget | TargetFilter::ParentTargetSlot { .. }
)
.then(|| super::effect_object_targets(attachment_filter, &ability.targets));
.then(|| {
// CR 400.7 + CR 603.7c: substitution only. Population inside the class
// is 0 (zero `UnattachAll` nodes), and the one card that reaches this
// (`stolen uniform`) is denied a pin by
// `condition_names_referent_zone_change`, so `live_object_targets` is
// the identity here today. Applied for uniformity and defence in depth;
// deliberately NO early return, since a population of 0 admits no
// non-vacuous test.
//
// Slot carve-out DOES apply: `attachment_filter` may be
// `ParentTargetSlot`, and `effect_object_targets` indexes it
// positionally. This is the second call site of the standing
// 22-call-site constraint.
let live_targets = ability.live_object_targets(state);
let pool: &[TargetRef] =
if matches!(attachment_filter, TargetFilter::ParentTargetSlot { .. }) {
&ability.targets
} else {
&live_targets
};
super::effect_object_targets(attachment_filter, pool)
});
for target_id in target_ids {
let attachments = state
.objects
Expand Down Expand Up @@ -494,10 +537,19 @@ fn resolve_object_filter<'a>(
TargetRef::Player(_) => None,
})
}
TargetFilter::ParentTarget => ability.targets.iter().find_map(|target| match target {
TargetRef::Object(id) => Some(*id),
TargetRef::Player(_) => None,
}),
// CR 400.7 + CR 603.7c: a pinned referent that became a new object is
// dropped. The all-stale case is caught by the early return at the top
// of `resolve` (which can emit `EffectResolved`); this substitution
// handles the PARTIAL-stale case.
TargetFilter::ParentTarget => {
ability
.live_object_targets(state)
.into_iter()
.find_map(|target| match target {
TargetRef::Object(id) => Some(id),
TargetRef::Player(_) => None,
})
}
// CR 608.2c: a precise slot anaphor ("Attach it to the chosen creature"
// → attachment slot 1, target slot 0) resolves against the whole
// resolving chain's accumulated targets. The per-clause `ability.targets`
Expand Down
37 changes: 36 additions & 1 deletion crates/engine/src/game/effects/cast_from_zone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,8 +402,22 @@ pub fn resolve(
TargetFilter::TrackedSet { .. } | TargetFilter::TrackedSetFiltered { .. } => {
tracked_set_cast_candidates(state, ability, target_filter)
}
// CR 400.7 + CR 603.7c: a delayed cast-from-zone whose pinned referent
// became a new object casts nothing. This `_` arm is THE single read
// through which a pinned referent flows into this resolver.
//
// The plan pre-flagged this file as a possible STOP because of its three
// `scoped_ability.targets = …` assignments (`:112`, `:444`, `:517`) and
// `fallback.targets = ability.targets.clone()` (`:257`). Re-read at the
// source, none of those is a read of the pinned referent: all three
// `scoped_ability` sites WRITE a freshly-derived id list onto a throwaway
// clone purely to scope a `FilterContext`, and their inputs
// (`deduped`, `candidate_ids`, `target_ids`) are already downstream of
// this arm. `:257` is chain-context propagation onto a declined-optional
// fallback ability, not a target resolution. So the flat substitution
// does apply here, at exactly one site.
_ => ability
.targets
.live_object_targets(state)
.iter()
.filter_map(|t| {
if let TargetRef::Object(id) = t {
Expand All @@ -415,6 +429,27 @@ pub fn resolve(
.collect(),
};

// CR 400.7 + CR 603.7c + CR 603.7b: the trigger fired and resolved; it cast
// nothing. EARLY RETURN IS MANDATORY, and its placement immediately below
// the read is load-bearing: EVERY branch between here and the end of this
// function keys on `target_ids.is_empty()` and re-binds to a DIFFERENT set
// of objects — the linked-exile scan (`:432`), the `last_revealed` library
// scan (`:467`), and the `SelfRef` source fallback (`:570`). Letting the
// substitution above empty the list without returning would hand the cast to
// one of those pools instead of doing nothing.
//
// Mirrors the existing "No targets resolved — nothing to cast" exit below,
// including its `EffectKind::CastFromZone` literal, so both no-op paths emit
// the same event.
if ability.pinned_object_targets_all_stale(state) {
events.push(GameEvent::EffectResolved {
kind: EffectKind::CastFromZone,
source_id: ability.source_id,
subject: None,
});
return Ok(());
}

// CR 701.20e + CR 608.2c: Look-then-cast chains (Kiora) inject the legal
// looked-at library cards as targets at the chain seam
// (`inject_last_revealed_targets`), already filtered through this cast
Expand Down
21 changes: 21 additions & 0 deletions crates/engine/src/game/effects/change_zone.rs
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,27 @@ pub fn resolve(
return Ok(());
}

// CR 400.7 + CR 603.7c: a delayed ability whose pinned referent became a
// new object affects nothing. Return before the untargeted zone-scan
// below, which must not rediscover a same-id return — the same reason
// the SelfRef guard above exists. `filter.rs`'s never-match arm makes
// that scan inert for ParentTarget today; this guard does not rely on
// that distant `_ => false` arm.
//
// Emits EffectResolved first, exactly as the three sibling guards in
// this block do (CR 115.6 optional targeting, CR 400.7 SelfRef,
// CR 701.23b fail-to-find): the trigger DID fire and DID resolve
// (CR 603.7b) — it simply affected nothing, and the game log / event
// observers / chain machinery must see that.
if ability.pinned_object_targets_all_stale(state) {
events.push(GameEvent::EffectResolved {
kind: EffectKind::from(&ability.effect),
source_id: ability.source_id,
subject: None,
});
return Ok(());
}

// CR 701.23b + CR 401.2: Interactive library-step fail-to-find guard.
// The parser emits `origin=Library, target=Any` for the put-step of a
// chain where an earlier interactive step selects the card from the
Expand Down
56 changes: 48 additions & 8 deletions crates/engine/src/game/effects/copy_spell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,33 @@ pub fn resolve(
ability: &ResolvedAbility,
events: &mut Vec<GameEvent>,
) -> Result<(), EffectError> {
// CR 400.7 + CR 603.7c: a delayed copy whose pinned referent became a new
// object copies nothing. The trigger DID fire and DID resolve (CR 603.7b) —
// it simply affected nothing — so the game log, event observers and the
// chain machinery must see an EffectResolved, exactly as the sibling
// `stack_entry_cant_be_copied` guard below does.
//
// PLACEMENT IS LOAD-BEARING: this MUST sit ABOVE the `ok_or_else(..)?`
// below. Returning `None` from `copy_source_entry` instead converts a
// deliberate no-op into `EffectError::MissingParam` and emits NO
// EffectResolved, because the `?` short-circuits before any events.push.
// The guard belongs at a function that can say "resolved, did nothing", not
// one that can only say "absent".
//
// Inert for every non-pinned caller: `pinned_object_targets_all_stale`
// requires a non-empty `target_incarnations`, which only a pinned delayed
// ParentTarget trigger has. Measured: all 14 in-class CopySpell pairs carry
// `target: ParentTarget`, so Saruman (ExiledBySource) and Isochron Scepter
// (TrackedSet) can never satisfy it and their branches run untouched.
if ability.pinned_object_targets_all_stale(state) {
events.push(GameEvent::EffectResolved {
kind: EffectKind::from(&ability.effect),
source_id: ability.source_id,
subject: None,
});
return Ok(());
}

// CR 707.10 / CR 702.153a (Casualty): resolve which stack entry to copy.
// The helper handles explicit object targets (Twincast / Gogo), SelfRef
// (Casualty triggers whose intermediate stack pushes would make stack.last()
Expand Down Expand Up @@ -170,10 +197,16 @@ pub fn resolve(
..
}
) {
if let Some(member) = ability.targets.iter().find_map(|target| match target {
TargetRef::Object(id) => Some(*id),
TargetRef::Player(_) => None,
}) {
// CR 400.7 + CR 603.7c: sits below the all-stale guard above, so this
// substitution handles the PARTIAL-stale case the guard does not.
if let Some(member) = ability
.live_object_targets(state)
.iter()
.find_map(|target| match target {
TargetRef::Object(id) => Some(*id),
TargetRef::Player(_) => None,
})
{
if let Some(copy_ability) = state.stack.back_mut().and_then(|e| e.ability_mut()) {
rewrite_copy_spell_object_targets(copy_ability, member);
}
Expand Down Expand Up @@ -560,10 +593,17 @@ fn copy_source_entry(state: &GameState, ability: &ResolvedAbility) -> Option<Sta
return copy_source_from_tracked_set(state, ability, target);
}
}
let target_id = ability.targets.iter().find_map(|target| match target {
TargetRef::Object(id) => Some(*id),
TargetRef::Player(_) => None,
});
// CR 400.7 + CR 603.7c: covers the partial-stale case, and is defence in
// depth for any future caller of `copy_source_entry` that does not pass
// through the guarded `resolve`.
let target_id =
ability
.live_object_targets(state)
.into_iter()
.find_map(|target| match target {
TargetRef::Object(id) => Some(id),
TargetRef::Player(_) => None,
});
if let Some(target_id) = target_id {
return state
.stack
Expand Down
24 changes: 21 additions & 3 deletions crates/engine/src/game/effects/counters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1845,6 +1845,12 @@ fn resolve_defined_or_targets(
// local `ability.targets` may have been replaced with the most-recent parent
// slot by chain propagation, so resolve against the flattened chain root
// (single authority in `targeting`), then keep only the object at `index`.
//
// CR 400.7 + CR 603.7c: deliberately NOT pin-filtered — see the standing
// constraint recorded at the `ParentTargetSlot` arm in
// `targeting::resolved_object_ids_for_filter_with_context`. Slot numbering
// is declared, not live, so filtering here would renumber later slots. Do
// not "complete the pattern" by adding a pin check.
if let Some(TargetFilter::ParentTargetSlot { index }) = target_spec {
return crate::game::targeting::resolve_parent_slot_from_root(state, ability, *index)
.into_iter()
Expand Down Expand Up @@ -1901,12 +1907,24 @@ fn resolve_defined_or_targets(
}
}

// CR 400.7 + CR 603.7c: a delayed counter effect's pinned referent that
// became a new object is dropped. Substitution only: the source-fallback
// arms and the attack-batch arm above all key off `has_object_target` /
// `has_choice_bookkeeping_player`, computed from the RAW `ability.targets`,
// so those preconditions stay intact and an emptied list here cannot
// re-bind to a different object. No early return, hence no EffectResolved
// question.
//
// This is `lagrella, the magpie`'s route: her delayed `PutCounter` reaches
// here with the returned card in `targets`. She is correct only because the
// pin is never STAMPED for her (her condition names the referent's own
// entry) — not because this read is exempted.
ability
.targets
.iter()
.live_object_targets(state)
.into_iter()
.filter_map(|t| {
if let TargetRef::Object(id) = t {
Some(*id)
Some(id)
} else {
None
}
Expand Down
30 changes: 28 additions & 2 deletions crates/engine/src/game/effects/deal_damage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,37 @@ fn resolve_effect_recipients(
.into_iter()
.collect();
}
// CR 400.7 + CR 603.7c: a delayed damage effect whose pinned referent became
// a new object deals it no damage. This is a RAW read that never reaches
// `resolved_targets`, so the targeting chokepoint cannot see this pin.
//
// THE `is_empty()` GATE STAYS RAW, AND THAT IS LOAD-BEARING — it is the
// reason no early return is needed here. "Targets were declared" and
// "declared targets are still live" are different questions. Gating on the
// raw list means an all-stale ability still ENTERS this branch and returns
// an empty recipient list, an inert no-op. Substituting the gate itself
// would make it fall through to the `Controller` fallback below and deal
// the damage to a PLAYER instead — precisely the `searing blood` shape the
// Tier C exclusion list warns about (its 14 Controller/Owner-only cards are
// additionally denied pins upstream, so this can only ever fail safe).
if !ability.targets.is_empty() {
if skip_first_target && ability.targets.len() > 1 {
return ability.targets[1..].to_vec();
// The positional split runs on the RAW list and the pin filter is
// applied AFTER it — never the other way round. `[1..]` encodes slot
// identity (`[source_0, …, recipient]`), so filtering first could
// renumber a live recipient into the source position. This is the
// same constraint as §5.4(b)'s `ParentTargetSlot` carve-out, applied
// to this file's own positional convention.
return ability.targets[1..]
.iter()
.filter(|target| match target {
TargetRef::Object(id) => ability.target_pin_is_current(*id, state),
TargetRef::Player(_) => true,
})
.cloned()
.collect();
}
return ability.targets.clone();
return ability.live_object_targets(state);
}
match target_filter {
TargetFilter::Controller => vec![TargetRef::Player(ability.controller)],
Expand Down
Loading
Loading