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
16 changes: 15 additions & 1 deletion crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1325,7 +1325,11 @@ enum WriteScope {
/// Exhaustive & wildcard-free: a future `TargetFilter` variant must be classified.
fn scope_of(target: &TargetFilter, chain_root: Option<WriteScope>) -> WriteScope {
match target {
TargetFilter::SelfRef | TargetFilter::SourceOrPaired => WriteScope::SelfSource,
// CR 608.2c: `OriginalSource` denotes the ability's own (pre-rebind) source
// object — a write to it lands on the source, exactly like `SelfRef`.
TargetFilter::SelfRef | TargetFilter::SourceOrPaired | TargetFilter::OriginalSource => {
WriteScope::SelfSource
}
TargetFilter::TriggeringSource => WriteScope::EventObject,
TargetFilter::ParentTarget | TargetFilter::ParentTargetSlot { .. } => {
chain_root.unwrap_or(WriteScope::EventObject)
Expand Down Expand Up @@ -2234,6 +2238,10 @@ fn legacy_target_filter(f: &TargetFilter) -> bool {
| TargetFilter::ExiledCardByIndex { .. }
| TargetFilter::SourceChosenPlayer
| TargetFilter::OriginalController
// CR 201.5a: `OriginalSource` is not one of the 12 frozen event-context
// tags — it is concretized to `SpecificObject` at resolution (mirrors
// `SpecificObject`, its concretized form).
| TargetFilter::OriginalSource
| TargetFilter::DefendingPlayer
| TargetFilter::HasChosenName
| TargetFilter::Named { .. }
Expand Down Expand Up @@ -2434,6 +2442,9 @@ fn member_bound_target_filter(f: &TargetFilter) -> bool {
// legacy-12 tags (`legacy_batch_prompt`), resolution-local refs, and
// uniformity-/owner-partition-invariant refs — all documented above.
TargetFilter::SelfRef
// CR 608.2c: source carrier (writes_self/reads_src) — source-invariant,
// not per-member-bound; concretized to SpecificObject before this walk.
| TargetFilter::OriginalSource
| TargetFilter::SourceOrPaired
| TargetFilter::TriggeringSource
| TargetFilter::ParentTarget
Expand Down Expand Up @@ -6116,6 +6127,9 @@ fn rw_target_filter(x: &TargetFilter) -> RwProfile {
| TargetFilter::ExiledCardByIndex { .. }
| TargetFilter::SourceChosenPlayer
| TargetFilter::OriginalController
// CR 201.5a: `OriginalSource` is a read-free object selector (concretized
// to `SpecificObject` at resolution).
| TargetFilter::OriginalSource
| TargetFilter::DefendingPlayer
| TargetFilter::HasChosenName
| TargetFilter::Named { .. }
Expand Down
3 changes: 3 additions & 0 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2384,6 +2384,9 @@ fn scan_target_filter(x: &TargetFilter) -> Axes {
// CR 201.5a: a source-relative object ref (the granting object), like
// SelfRef — no event/sibling/projected resource axis.
TargetFilter::GrantingObject => Axes::NONE,
// CR 608.2c: source-relative object ref (concretized to SpecificObject),
// like SelfRef — no event/sibling/projected resource axis.
TargetFilter::OriginalSource => Axes::NONE,
TargetFilter::SourceOrPaired => Axes::NONE,
TargetFilter::Typed(..) => Axes::CONSERVATIVE,
TargetFilter::Not { filter } => {
Expand Down
89 changes: 89 additions & 0 deletions crates/engine/src/game/ability_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1472,6 +1472,16 @@ pub fn validate_targets_in_chain(state: &GameState, ability: &ResolvedAbility) -
})
.collect()
} else if let Effect::Attach { attachment, target } = &validated.effect {
// CR 608.2b (phase#4767 review): `attachment`/`target` context-refs
// (SelfRef, ParentTarget, ...) don't need their own target slot and
// are skipped below — but `validated.targets` can carry MORE entries
// than this Attach node's own two operands consume, propagated
// through for a downstream sibling in the chain (e.g. a
// CreateDelayedTrigger sub-ability reading the same ParentTarget).
// Only the entries this node's own filters actually claim get
// re-validated here; any remaining, un-claimed entries must pass
// through UNCHANGED rather than being silently dropped, or a
// sibling relying on them downstream loses its target.
let mut kept = Vec::new();
let mut target_iter = validated.targets.iter();
for (is_attachment, filter) in [(true, attachment), (false, target)] {
Expand All @@ -1493,6 +1503,7 @@ pub fn validate_targets_in_chain(state: &GameState, ability: &ResolvedAbility) -
kept.push(legal);
}
}
kept.extend(target_iter.cloned());
kept
} else if let Effect::Fight { subject, target } = &validated.effect {
// CR 608.2b + CR 701.14a: Dual-fighter fights validate each chosen
Expand Down Expand Up @@ -1653,6 +1664,41 @@ pub fn validate_targets_in_chain(state: &GameState, ability: &ResolvedAbility) -
{
validated.targets.clone()
}
// CR 303.4a + CR 608.2b: A plain Aura spell has no separate on-cast
// effect — its resolving `Effect` is the `Effect::Unimplemented`
// placeholder built in `casting.rs`, so `extract_target_filter_from_effect`
// returns `None` and lands here. Its legal targets are defined by its
// enchant ability (`Keyword::Enchant`), NOT by the placeholder effect,
// and that filter may be zone-scoped (e.g. Animate Dead's "creature
// card in a graveyard"). Re-validate against the Enchant filter with
// the SAME machinery cast-time targeting uses, so a graveyard-zone host
// is not fizzle-filtered by the hardcoded battlefield check below.
// Gated on `Effect::Unimplemented` specifically (not on Aura-ness): the
// `None` arm also legitimately serves `Effect::Sacrifice`/`UnattachAll`/
// `Bounce { selection: AtResolution }`, which must keep the plain
// battlefield fizzle-check.
None if matches!(validated.effect, Effect::Unimplemented { .. }) => {
match crate::game::effects::change_targets::aura_enchant_filter(
state,
validated.source_id,
) {
Some(filter) => targeting::validate_targets_for_ability(
state,
&validated.targets,
&filter,
&validated,
),
None => validated
.targets
.iter()
.filter(|target| match target {
TargetRef::Object(object_id) => state.battlefield.contains(object_id),
TargetRef::Player(_) => true,
})
.cloned()
.collect(),
}
}
None => validated
.targets
.iter()
Expand Down Expand Up @@ -7092,6 +7138,49 @@ mod tests {
);
}

/// CR 608.2b (phase-rs/phase#5449 review): an `Effect::Attach` node whose
/// `attachment`/`target` are both context-refs (SelfRef/ParentTarget —
/// neither needs its own target slot) must not have its `.targets` wiped
/// to `[]` when the node carries MORE entries than its own two operands
/// consume — those extra entries are propagated through for a downstream
/// sibling (e.g. a chained `CreateDelayedTrigger` reading the same
/// `ParentTarget`), not this node's own operands, and must survive
/// re-validation unchanged.
#[test]
fn validate_targets_in_chain_attach_preserves_unclaimed_propagated_targets() {
let format = FormatConfig::duel_commander();
let mut state = GameState::new(format, 2, 2);
let creature = create_object(
&mut state,
CardId(0),
PlayerId(1),
"Grizzly Bears".to_string(),
Zone::Battlefield,
);

// Attach{SelfRef, ParentTarget} — neither operand needs a slot — but
// `.targets` carries the propagated creature id for a downstream
// sibling, not for this node's own attachment/target resolution.
let ability = ResolvedAbility::new(
Effect::Attach {
attachment: TargetFilter::SelfRef,
target: TargetFilter::ParentTarget,
},
vec![TargetRef::Object(creature)],
ObjectId(99),
PlayerId(0),
);

let validated = validate_targets_in_chain(&state, &ability);
assert_eq!(
validated.targets,
vec![TargetRef::Object(creature)],
"an Attach node's un-claimed propagated targets must pass through \
re-validation unchanged, not be dropped just because neither of \
this node's own operands needed a target slot"
);
}

/// CR 608.2c + CR 608.2h + CR 704.5d (issue #1582): Recoil reads "Return
/// target permanent to its owner's hand. Then that player discards a card."
/// When the bounced permanent is a token, it ceases to exist as a
Expand Down
Loading
Loading