From 006e1442a16efa1279dd3f18046fa5eb11a102d7 Mon Sep 17 00:00:00 2001 From: traemyn Date: Tue, 11 Aug 2026 08:11:06 -0500 Subject: [PATCH] fix(engine): preserve target incarnation identity --- crates/engine/src/game/ability_rw.rs | 1 + crates/engine/src/game/ability_scan.rs | 1 + crates/engine/src/game/ability_utils.rs | 110 +++++--- .../src/game/effects/additional_phase.rs | 1 + .../engine/src/game/effects/change_targets.rs | 16 ++ crates/engine/src/game/effects/copy_spell.rs | 200 +++++++++++++- crates/engine/src/game/effects/double.rs | 1 + crates/engine/src/game/effects/extra_turn.rs | 1 + .../grant_extra_loyalty_activations.rs | 1 + .../engine/src/game/effects/player_counter.rs | 2 + .../src/game/effects/reverse_turn_order.rs | 1 + .../engine/src/game/effects/skip_next_step.rs | 1 + .../engine/src/game/effects/skip_next_turn.rs | 1 + crates/engine/src/game/effects/vote.rs | 7 + crates/engine/src/game/engine.rs | 50 +++- .../game/engine_keyword_action_stack_tests.rs | 98 ++++++- crates/engine/src/game/resolution_prompt.rs | 1 + crates/engine/src/game/stack.rs | 6 + crates/engine/src/types/ability.rs | 74 +++++ crates/engine/tests/integration/main.rs | 1 + .../target_incarnation_revalidation.rs | 259 ++++++++++++++++++ .../the_chain_veil_loyalty_grants.rs | 1 + 22 files changed, 778 insertions(+), 56 deletions(-) create mode 100644 crates/engine/tests/integration/target_incarnation_revalidation.rs diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index ea45c01ce8..6bb03e2504 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -3732,6 +3732,7 @@ fn walk_ability( 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 + selected_target_incarnations: _, // CR 400.7 selected-target pins, no read/write effect controller: _, original_controller: _, scoped_player: _, diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 48fb363b59..356f451c28 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -234,6 +234,7 @@ fn resolved_ability_axes(a: &ResolvedAbility, mode: ScanMode) -> Axes { 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 + selected_target_incarnations: _, // CR 400.7 selected-target pins, no dynamic read controller: _, // player id original_controller: _, // player id scoped_player: _, // player id (iteration binding) diff --git a/crates/engine/src/game/ability_utils.rs b/crates/engine/src/game/ability_utils.rs index 4db37cb296..2810ef6c6c 100644 --- a/crates/engine/src/game/ability_utils.rs +++ b/crates/engine/src/game/ability_utils.rs @@ -1777,10 +1777,12 @@ pub fn assign_targets_in_chain( ) -> Result<(), EngineError> { if is_per_opponent_target_fanout(ability) { ability.targets = targets.to_vec(); + ability.capture_target_incarnations_recursive(state); return Ok(()); } if !chain_has_target_sink(ability) { ability.targets = targets.to_vec(); + ability.capture_target_incarnations_recursive(state); return Ok(()); } let mut next_target = 0usize; @@ -1790,6 +1792,7 @@ pub fn assign_targets_in_chain( "Unused selected targets".to_string(), )); } + ability.capture_target_incarnations_recursive(state); Ok(()) } @@ -1800,10 +1803,12 @@ pub fn assign_selected_slots_in_chain( ) -> Result<(), EngineError> { if is_per_opponent_target_fanout(ability) { ability.targets = selected_slots.iter().flatten().cloned().collect(); + ability.capture_target_incarnations_recursive(state); return Ok(()); } if !chain_has_target_sink(ability) { ability.targets = selected_slots.iter().flatten().cloned().collect(); + ability.capture_target_incarnations_recursive(state); return Ok(()); } let mut next_slot = 0usize; @@ -1813,6 +1818,7 @@ pub fn assign_selected_slots_in_chain( "Unused selected target slots".to_string(), )); } + ability.capture_target_incarnations_recursive(state); Ok(()) } @@ -1860,6 +1866,28 @@ pub fn distribution_targets(ability: &ResolvedAbility) -> Vec { } /// CR 608.2b: Re-validate targets on resolution — remove any that are no longer legal. +fn target_is_current(ability: &ResolvedAbility, target: &TargetRef, state: &GameState) -> bool { + match target { + TargetRef::Object(id) => { + ability.target_pin_is_current(*id, state) + && ability.selected_target_pin_is_current(*id, state) + } + TargetRef::Player(_) => true, + } +} + +fn validate_pinned_targets( + state: &GameState, + targets: &[TargetRef], + filter: &TargetFilter, + ability: &ResolvedAbility, +) -> Vec { + targeting::validate_targets_for_ability(state, targets, filter, ability) + .into_iter() + .filter(|target| target_is_current(ability, target, state)) + .collect() +} + pub fn validate_targets_in_chain(state: &GameState, ability: &ResolvedAbility) -> ResolvedAbility { let mut validated = ability.clone(); validated.targets = if is_per_opponent_target_fanout(&validated) { @@ -1876,7 +1904,7 @@ pub fn validate_targets_in_chain(state: &GameState, ability: &ResolvedAbility) - .filter(|filter| !filter.is_context_ref()) .zip(validated.targets.iter()) .filter_map(|(filter, target_ref)| { - let legal = targeting::validate_targets_for_ability( + let legal = validate_pinned_targets( state, std::slice::from_ref(target_ref), filter, @@ -1905,14 +1933,10 @@ pub fn validate_targets_in_chain(state: &GameState, ability: &ResolvedAbility) - let Some(target_ref) = target_iter.next() else { continue; }; - if let Some(legal) = targeting::validate_targets_for_ability( - state, - std::slice::from_ref(target_ref), - filter, - &validated, - ) - .into_iter() - .next() + if let Some(legal) = + validate_pinned_targets(state, std::slice::from_ref(target_ref), filter, &validated) + .into_iter() + .next() { kept.push(legal); } @@ -1967,13 +1991,8 @@ pub fn validate_targets_in_chain(state: &GameState, ability: &ResolvedAbility) - let Some(target_ref) = target_iter.next() else { break; }; - if !targeting::validate_targets_for_ability( - state, - std::slice::from_ref(target_ref), - filter, - &validated, - ) - .is_empty() + if !validate_pinned_targets(state, std::slice::from_ref(target_ref), filter, &validated) + .is_empty() { any_legal = true; } @@ -1998,7 +2017,7 @@ pub fn validate_targets_in_chain(state: &GameState, ability: &ResolvedAbility) - let Some(target_ref) = target_iter.next() else { continue; }; - if let Some(legal) = targeting::validate_targets_for_ability( + if let Some(legal) = validate_pinned_targets( state, std::slice::from_ref(target_ref), filter, @@ -2043,15 +2062,10 @@ pub fn validate_targets_in_chain(state: &GameState, ability: &ResolvedAbility) - let explicit: Vec = candidate_targets .iter() .filter(|t| { - targeting::validate_targets_for_ability( - state, - std::slice::from_ref(t), - target, - &validated, - ) - .into_iter() - .next() - .is_some() + validate_pinned_targets(state, std::slice::from_ref(t), target, &validated) + .into_iter() + .next() + .is_some() }) .cloned() .collect(); @@ -2062,7 +2076,9 @@ pub fn validate_targets_in_chain(state: &GameState, ability: &ResolvedAbility) - let TargetRef::Object(id) = t else { return false; }; - !explicit.contains(t) && fight_creature_on_battlefield(state, *id) + !explicit.contains(t) + && fight_creature_on_battlefield(state, *id) + && target_is_current(&validated, t, state) }) { kept.push(ally.clone()); } @@ -2077,7 +2093,7 @@ pub fn validate_targets_in_chain(state: &GameState, ability: &ResolvedAbility) - // would fizzle-filter the spell to battlefield presence and drop it // (the spell lives on the STACK). Re-validate against the source leaf // (`InZone Stack`-aware) instead, preserving the spell target. - targeting::validate_targets_for_ability(state, &validated.targets, &src_leaf, &validated) + validate_pinned_targets(state, &validated.targets, &src_leaf, &validated) } else { match triggers::extract_target_filter_from_effect(&validated.effect) { Some(filter) if matches!(validated.effect, Effect::PairWith { .. }) => { @@ -2085,7 +2101,10 @@ pub fn validate_targets_in_chain(state: &GameState, ability: &ResolvedAbility) - validated .targets .iter() - .filter(|target| legal_choices.contains(target)) + .filter(|target| { + legal_choices.contains(target) + && target_is_current(&validated, target, state) + }) .cloned() .collect() } @@ -2102,7 +2121,12 @@ pub fn validate_targets_in_chain(state: &GameState, ability: &ResolvedAbility) - Some((_, rest)) => rest, None => &[], }; - kept.extend(targeting::validate_targets_for_ability( + if let Some(companion) = kept.first() { + if !target_is_current(&validated, companion, state) { + kept.clear(); + } + } + kept.extend(validate_pinned_targets( state, primary_targets, filter, @@ -2110,12 +2134,7 @@ pub fn validate_targets_in_chain(state: &GameState, ability: &ResolvedAbility) - )); kept } - Some(filter) => targeting::validate_targets_for_ability( - state, - &validated.targets, - filter, - &validated, - ), + Some(filter) => validate_pinned_targets(state, &validated.targets, filter, &validated), // CR 608.2b: A context-ref filter (`ParentTarget`, // `TriggeringSource`, etc.) carries a resolution-time *snapshot*, // not a player-chosen target. `extract_target_filter_from_effect` @@ -2160,17 +2179,17 @@ pub fn validate_targets_in_chain(state: &GameState, ability: &ResolvedAbility) - state, validated.source_id, ) { - Some(filter) => targeting::validate_targets_for_ability( - state, - &validated.targets, - &filter, - &validated, - ), + Some(filter) => { + validate_pinned_targets(state, &validated.targets, &filter, &validated) + } None => validated .targets .iter() .filter(|target| match target { - TargetRef::Object(object_id) => state.battlefield.contains(object_id), + TargetRef::Object(object_id) => { + state.battlefield.contains(object_id) + && target_is_current(&validated, target, state) + } TargetRef::Player(_) => true, }) .cloned() @@ -2181,7 +2200,10 @@ pub fn validate_targets_in_chain(state: &GameState, ability: &ResolvedAbility) - .targets .iter() .filter(|target| match target { - TargetRef::Object(object_id) => state.battlefield.contains(object_id), + TargetRef::Object(object_id) => { + state.battlefield.contains(object_id) + && target_is_current(&validated, target, state) + } TargetRef::Player(_) => true, }) .cloned() diff --git a/crates/engine/src/game/effects/additional_phase.rs b/crates/engine/src/game/effects/additional_phase.rs index d11ce6b811..db6c404301 100644 --- a/crates/engine/src/game/effects/additional_phase.rs +++ b/crates/engine/src/game/effects/additional_phase.rs @@ -272,6 +272,7 @@ mod tests { trigger_definition_ref: None, force_block_attacker: None, target_incarnations: Vec::new(), + selected_target_incarnations: Vec::new(), targets: vec![], kind: AbilityKind::Spell, sub_ability: None, diff --git a/crates/engine/src/game/effects/change_targets.rs b/crates/engine/src/game/effects/change_targets.rs index 4518902cab..83ad129089 100644 --- a/crates/engine/src/game/effects/change_targets.rs +++ b/crates/engine/src/game/effects/change_targets.rs @@ -85,8 +85,24 @@ pub fn resolve( // `vec![new_target]`, which would delete the untouched slot. let updated = forced_retarget_targets(state, &stack_ability, ¤t_targets, new_target); + let changed = updated + .iter() + .zip(current_targets.iter()) + .find(|(updated, current)| { + stack_ability.retarget_target_requires_pin_refresh(current, updated, state) + }) + .and_then(|(target, _)| match target { + TargetRef::Object(id) => state + .objects + .get(id) + .map(crate::types::identifiers::ObjectIncarnationRef::from_object), + TargetRef::Player(_) => None, + }); if let Some(stack_ability_mut) = state.stack[stack_entry_index].ability_mut() { stack_ability_mut.targets = updated; + if let Some(pin) = changed { + stack_ability_mut.update_selected_target_incarnation(pin); + } } } events.push(GameEvent::EffectResolved { diff --git a/crates/engine/src/game/effects/copy_spell.rs b/crates/engine/src/game/effects/copy_spell.rs index 532b0dd2e3..13451d6f9c 100644 --- a/crates/engine/src/game/effects/copy_spell.rs +++ b/crates/engine/src/game/effects/copy_spell.rs @@ -8,7 +8,7 @@ use crate::types::events::GameEvent; use crate::types::game_state::{ CastingVariant, CopyTargetSlot, GameState, StackEntry, StackEntryKind, WaitingFor, }; -use crate::types::identifiers::{ObjectId, TrackedSetId}; +use crate::types::identifiers::{ObjectId, ObjectIncarnationRef, TrackedSetId}; use crate::types::player::PlayerId; use crate::types::statics::StaticMode; use crate::types::zones::Zone; @@ -207,8 +207,12 @@ pub fn resolve( TargetRef::Player(_) => None, }) { + let member_pin = state + .objects + .get(&member) + .map(ObjectIncarnationRef::from_object); if let Some(copy_ability) = state.stack.back_mut().and_then(|e| e.ability_mut()) { - rewrite_copy_spell_object_targets(copy_ability, member); + rewrite_copy_spell_object_targets(copy_ability, member, member_pin); } } } @@ -883,18 +887,32 @@ fn preserve_ability_copy_source_recursive(ability: &mut ResolvedAbility) { ability.clear_noted_mana_payment_recursive(); } -/// CR 707.10d: Replace every object target on a copied spell with `new_target`. -fn rewrite_copy_spell_object_targets(ability: &mut ResolvedAbility, new_target: ObjectId) { - for target in &mut ability.targets { - if matches!(target, TargetRef::Object(_)) { +/// CR 707.10d: Replace every object target on a copied spell with `new_target` +/// and capture the target's current incarnation for ordinary resolution pins. +fn rewrite_copy_spell_object_targets( + ability: &mut ResolvedAbility, + new_target: ObjectId, + new_target_pin: Option, +) { + let replaced_object_target = ability + .targets + .iter_mut() + .filter(|target| matches!(target, TargetRef::Object(_))) + .map(|target| { *target = TargetRef::Object(new_target); + }) + .count() + > 0; + if replaced_object_target { + if let Some(pin) = new_target_pin { + ability.update_selected_target_incarnation(pin); } } if let Some(sub) = ability.sub_ability.as_mut() { - rewrite_copy_spell_object_targets(sub, new_target); + rewrite_copy_spell_object_targets(sub, new_target, new_target_pin); } if let Some(else_ab) = ability.else_ability.as_mut() { - rewrite_copy_spell_object_targets(else_ab, new_target); + rewrite_copy_spell_object_targets(else_ab, new_target, new_target_pin); } } @@ -929,7 +947,7 @@ mod tests { use crate::types::card_type::CoreType; use crate::types::counter::CounterType; use crate::types::game_state::{CastingVariant, StackEntry, StackEntryKind}; - use crate::types::identifiers::{CardId, ObjectId, TrackedSetId}; + use crate::types::identifiers::{CardId, ObjectId, ObjectIncarnationRef, TrackedSetId}; use crate::types::keywords::Keyword; use crate::types::player::PlayerId; @@ -3234,6 +3252,170 @@ mod tests { ); } + #[test] + fn automatic_copy_retarget_captures_iteration_member_incarnation() { + fn resolve_zone_change( + state: &mut GameState, + object_id: ObjectId, + origin: Zone, + destination: Zone, + events: &mut Vec, + ) { + let ability = ResolvedAbility::new( + Effect::ChangeZone { + origin: Some(origin), + destination, + target: TargetFilter::Any, + owner_library: false, + enter_transformed: false, + enters_under: None, + enter_tapped: crate::types::zones::EtbTapState::Unspecified, + enters_attacking: false, + up_to: false, + enter_with_counters: vec![], + conditional_enter_with_counters: vec![], + face_down_profile: None, + enters_modified_if: None, + }, + vec![TargetRef::Object(object_id)], + ObjectId(20), + PlayerId(0), + ); + crate::game::effects::resolve_ability_chain(state, &ability, events, 0) + .expect("production ChangeZone must resolve"); + } + + let mut state = GameState::new_two_player(42); + let original_target = ObjectId(60); + let iteration_member = ObjectId(61); + let mut original_target_object = GameObject::new( + original_target, + CardId(5), + PlayerId(1), + "Original Target".to_string(), + Zone::Battlefield, + ); + original_target_object + .card_types + .core_types + .push(CoreType::Creature); + state + .objects + .insert(original_target, original_target_object); + let mut iteration_member_object = GameObject::new( + iteration_member, + CardId(6), + PlayerId(1), + "Iteration Member".to_string(), + Zone::Battlefield, + ); + iteration_member_object + .card_types + .core_types + .push(CoreType::Creature); + state + .objects + .insert(iteration_member, iteration_member_object); + + let mut original_spell = ResolvedAbility::new( + Effect::Destroy { + target: TargetFilter::Typed(crate::types::ability::TypedFilter { + type_filters: vec![crate::types::ability::TypeFilter::Creature], + controller: None, + properties: vec![], + }), + cant_regenerate: false, + }, + vec![TargetRef::Object(original_target)], + ObjectId(10), + PlayerId(0), + ); + original_spell.capture_target_incarnations_recursive(&state); + push_spell( + &mut state, + ObjectId(10), + CardId(1), + PlayerId(0), + "Destroy Spell", + original_spell, + CastingVariant::Normal, + ); + + let mut copy = ResolvedAbility::new( + Effect::CopySpell { + target: TargetFilter::Any, + retarget: CopyRetargetPermission::RetargetEachCopyToIterationMember, + copier: None, + additional_modifications: Vec::new(), + starting_loyalty_from_casualty_sacrifice: false, + }, + vec![TargetRef::Object(iteration_member)], + ObjectId(20), + PlayerId(0), + ); + copy.target_incarnations = vec![ObjectIncarnationRef::from_object( + state.objects.get(&iteration_member).unwrap(), + )]; + state.current_trigger_event = Some(GameEvent::SpellCast { + card_id: CardId(1), + object_id: ObjectId(10), + controller: PlayerId(0), + }); + let mut events = Vec::new(); + resolve(&mut state, ©, &mut events).expect("automatic copy must resolve"); + + let copied_ability = state + .stack + .back() + .and_then(|entry| entry.ability()) + .expect("automatic copy must be on the stack"); + assert_eq!( + copied_ability.targets, + vec![TargetRef::Object(iteration_member)] + ); + assert!( + copied_ability.selected_target_pin_is_current(iteration_member, &state), + "automatic retarget must capture the iteration member incarnation" + ); + + resolve_zone_change( + &mut state, + iteration_member, + Zone::Battlefield, + Zone::Graveyard, + &mut events, + ); + resolve_zone_change( + &mut state, + iteration_member, + Zone::Graveyard, + Zone::Battlefield, + &mut events, + ); + assert_eq!( + state.objects.get(&iteration_member).unwrap().zone, + Zone::Battlefield, + "iteration member must return to the battlefield before copy resolution" + ); + let copied_ability = state + .stack + .back() + .and_then(|entry| entry.ability()) + .cloned() + .expect("automatic copy ability must remain on the stack"); + assert!( + !copied_ability.selected_target_pin_is_current(iteration_member, &state), + "returned iteration member must no longer match the captured copy target pin" + ); + crate::game::stack::resolve_top(&mut state, &mut events); + + assert_eq!( + state.objects.get(&iteration_member).unwrap().zone, + Zone::Battlefield, + "automatic copy must not affect a returned iteration-member incarnation" + ); + } + /// Twinning Staff's ruling grants new-target permission for the replacement- /// added copy even if the original copy effect keeps targets unchanged. #[test] diff --git a/crates/engine/src/game/effects/double.rs b/crates/engine/src/game/effects/double.rs index cca9045288..149061ef06 100644 --- a/crates/engine/src/game/effects/double.rs +++ b/crates/engine/src/game/effects/double.rs @@ -332,6 +332,7 @@ mod tests { trigger_definition_ref: None, force_block_attacker: None, target_incarnations: Vec::new(), + selected_target_incarnations: Vec::new(), targets, kind: AbilityKind::Spell, sub_ability: None, diff --git a/crates/engine/src/game/effects/extra_turn.rs b/crates/engine/src/game/effects/extra_turn.rs index 179fa1aa90..be995ef991 100644 --- a/crates/engine/src/game/effects/extra_turn.rs +++ b/crates/engine/src/game/effects/extra_turn.rs @@ -77,6 +77,7 @@ mod tests { trigger_definition_ref: None, force_block_attacker: None, target_incarnations: Vec::new(), + selected_target_incarnations: Vec::new(), targets: vec![], kind: AbilityKind::Spell, sub_ability: None, diff --git a/crates/engine/src/game/effects/grant_extra_loyalty_activations.rs b/crates/engine/src/game/effects/grant_extra_loyalty_activations.rs index 0ef26f735a..1dc37343fd 100644 --- a/crates/engine/src/game/effects/grant_extra_loyalty_activations.rs +++ b/crates/engine/src/game/effects/grant_extra_loyalty_activations.rs @@ -95,6 +95,7 @@ mod tests { trigger_definition_ref: None, force_block_attacker: None, target_incarnations: Vec::new(), + selected_target_incarnations: Vec::new(), targets: vec![], kind: AbilityKind::Activated, sub_ability: None, diff --git a/crates/engine/src/game/effects/player_counter.rs b/crates/engine/src/game/effects/player_counter.rs index f65bc5d61f..b564934717 100644 --- a/crates/engine/src/game/effects/player_counter.rs +++ b/crates/engine/src/game/effects/player_counter.rs @@ -445,6 +445,7 @@ mod tests { trigger_definition_ref: None, force_block_attacker: None, target_incarnations: Vec::new(), + selected_target_incarnations: Vec::new(), targets: vec![], kind: AbilityKind::Spell, sub_ability: None, @@ -641,6 +642,7 @@ mod tests { trigger_definition_ref: None, force_block_attacker: None, target_incarnations: Vec::new(), + selected_target_incarnations: Vec::new(), targets: vec![], kind: AbilityKind::Spell, sub_ability: None, diff --git a/crates/engine/src/game/effects/reverse_turn_order.rs b/crates/engine/src/game/effects/reverse_turn_order.rs index 4b90202ebe..b851afa6d9 100644 --- a/crates/engine/src/game/effects/reverse_turn_order.rs +++ b/crates/engine/src/game/effects/reverse_turn_order.rs @@ -50,6 +50,7 @@ mod tests { trigger_definition_ref: None, force_block_attacker: None, target_incarnations: Vec::new(), + selected_target_incarnations: Vec::new(), targets: vec![], kind: AbilityKind::Spell, sub_ability: None, diff --git a/crates/engine/src/game/effects/skip_next_step.rs b/crates/engine/src/game/effects/skip_next_step.rs index 57d063f26c..37bfb8c4ac 100644 --- a/crates/engine/src/game/effects/skip_next_step.rs +++ b/crates/engine/src/game/effects/skip_next_step.rs @@ -113,6 +113,7 @@ mod tests { trigger_definition_ref: None, force_block_attacker: None, target_incarnations: Vec::new(), + selected_target_incarnations: Vec::new(), targets: vec![], kind: AbilityKind::Spell, sub_ability: None, diff --git a/crates/engine/src/game/effects/skip_next_turn.rs b/crates/engine/src/game/effects/skip_next_turn.rs index 9fcb84f597..0f6d74987a 100644 --- a/crates/engine/src/game/effects/skip_next_turn.rs +++ b/crates/engine/src/game/effects/skip_next_turn.rs @@ -91,6 +91,7 @@ mod tests { trigger_definition_ref: None, force_block_attacker: None, target_incarnations: Vec::new(), + selected_target_incarnations: Vec::new(), targets: vec![], kind: AbilityKind::Spell, sub_ability: None, diff --git a/crates/engine/src/game/effects/vote.rs b/crates/engine/src/game/effects/vote.rs index bb550b6d6e..78d00004d8 100644 --- a/crates/engine/src/game/effects/vote.rs +++ b/crates/engine/src/game/effects/vote.rs @@ -357,6 +357,7 @@ pub fn resolve_tally( trigger_definition_ref: None, force_block_attacker: None, target_incarnations: Vec::new(), + selected_target_incarnations: Vec::new(), controller, original_controller: None, scoped_player: None, @@ -426,6 +427,7 @@ pub fn resolve_tally( trigger_definition_ref: None, force_block_attacker: None, target_incarnations: Vec::new(), + selected_target_incarnations: Vec::new(), controller, original_controller: None, scoped_player: None, @@ -669,6 +671,7 @@ fn resolved_from_def( trigger_definition_ref: None, force_block_attacker: None, target_incarnations: Vec::new(), + selected_target_incarnations: Vec::new(), controller, original_controller: None, scoped_player: None, @@ -933,6 +936,7 @@ mod tests { trigger_definition_ref: None, force_block_attacker: None, target_incarnations: Vec::new(), + selected_target_incarnations: Vec::new(), controller, original_controller: None, scoped_player: None, @@ -1043,6 +1047,7 @@ mod tests { trigger_definition_ref: None, force_block_attacker: None, target_incarnations: Vec::new(), + selected_target_incarnations: Vec::new(), controller, original_controller: None, scoped_player: None, @@ -1478,6 +1483,7 @@ mod tests { trigger_definition_ref: None, force_block_attacker: None, target_incarnations: Vec::new(), + selected_target_incarnations: Vec::new(), controller, original_controller: None, scoped_player: None, @@ -1645,6 +1651,7 @@ mod tests { trigger_definition_ref: None, force_block_attacker: None, target_incarnations: Vec::new(), + selected_target_incarnations: Vec::new(), controller, original_controller: None, scoped_player: None, diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index b7ec95079d..fc9504eed6 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -15,7 +15,7 @@ use crate::types::game_state::{ MayTriggerAutoChoiceKey, PayCostKind, PendingCostMoveResume, RetargetScope, StackEntry, StackEntryKind, WaitingFor, }; -use crate::types::identifiers::{CardId, DelayedTriggerOrigin, ObjectId}; +use crate::types::identifiers::{CardId, DelayedTriggerOrigin, ObjectId, ObjectIncarnationRef}; use crate::types::match_config::MatchType; use crate::types::phase::Phase; use crate::types::player::PlayerId; @@ -6742,9 +6742,32 @@ fn finalize_copy_retarget( }) }) .collect::, _>>()?; + let changed_pins = state + .stack + .iter() + .find(|entry| entry.id == copy_id) + .and_then(|entry| entry.ability()) + .map(|ability| { + ability + .targets + .iter() + .zip(targets.iter()) + .filter(|(old, new)| ability.retarget_target_requires_pin_refresh(old, new, state)) + .filter_map(|(_, target)| match target { + TargetRef::Object(id) => { + state.objects.get(id).map(ObjectIncarnationRef::from_object) + } + TargetRef::Player(_) => None, + }) + .collect::>() + }) + .unwrap_or_default(); if let Some(entry) = state.stack.iter_mut().find(|e| e.id == copy_id) { if let Some(ability) = entry.ability_mut() { ability.targets = targets; + for pin in changed_pins { + ability.update_selected_target_incarnation(pin); + } } } events.push(GameEvent::EffectResolved { @@ -11729,8 +11752,31 @@ fn apply_retarget( } if stack_entry_index < state.stack.len() { + let target_pins: Vec<_> = state + .stack + .get(stack_entry_index) + .and_then(|entry| entry.ability()) + .map(|ability| { + current_targets + .iter() + .zip(new_targets.iter()) + .filter(|(old, new)| { + ability.retarget_target_requires_pin_refresh(old, new, state) + }) + .filter_map(|(_, target)| match target { + TargetRef::Object(id) => { + state.objects.get(id).map(ObjectIncarnationRef::from_object) + } + TargetRef::Player(_) => None, + }) + .collect() + }) + .unwrap_or_default(); if let Some(ability) = state.stack[stack_entry_index].ability_mut() { ability.targets = new_targets; + for pin in target_pins { + ability.update_selected_target_incarnation(pin); + } } } else { return Err(EngineError::InvalidAction( @@ -16355,7 +16401,7 @@ mod stage2_injector_tests { // // SET PRESERVATION: unchanged. Upstream adds no line matching the needle to this file and // neither does this branch — total still 37, partition still 5/7/25. - "game/engine.rs:11942".to_string(), + "game/engine.rs:11988".to_string(), ], "the five production producers, NAMED: the CR 603.5 gate in `resolve_chain_body` \ plus the two repeated-optional-payment drivers, the per-player acceptance cursor \ diff --git a/crates/engine/src/game/engine_keyword_action_stack_tests.rs b/crates/engine/src/game/engine_keyword_action_stack_tests.rs index eaa9391b88..98fa7e27ae 100644 --- a/crates/engine/src/game/engine_keyword_action_stack_tests.rs +++ b/crates/engine/src/game/engine_keyword_action_stack_tests.rs @@ -17,7 +17,7 @@ //! power) persist. use super::*; -use crate::game::zones::create_object; +use crate::game::zones::{create_object, move_to_zone}; use crate::types::card_type::CoreType; use crate::types::counter::CounterType; use crate::types::identifiers::{CardId, ObjectId}; @@ -976,6 +976,102 @@ fn issue_3660_finalize_copy_retarget_stashes_offers_on_deferred_pause() { ); } +#[test] +fn finalize_copy_retarget_refreshes_stale_same_id_object_pin() { + use crate::types::ability::{Effect, EffectKind, ResolvedAbility, TargetFilter, TargetRef}; + use crate::types::actions::GameAction; + use crate::types::game_state::{CastingVariant, StackEntry, StackEntryKind}; + use crate::types::{TypeFilter, TypedFilter}; + + let mut state = setup_main_phase(); + let target = create_object( + &mut state, + CardId(1501), + PlayerId(1), + "Copy Target".to_string(), + Zone::Battlefield, + ); + state + .objects + .get_mut(&target) + .unwrap() + .card_types + .core_types = vec![CoreType::Creature]; + let copy_id = create_object( + &mut state, + CardId(1502), + PlayerId(0), + "Copy Probe".to_string(), + Zone::Stack, + ); + let mut copy_ability = ResolvedAbility::new( + Effect::Destroy { + target: TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + controller: None, + properties: vec![], + }), + cant_regenerate: false, + }, + vec![TargetRef::Object(target)], + copy_id, + PlayerId(0), + ); + copy_ability.capture_target_incarnations_recursive(&state); + state.stack.push_back(StackEntry { + id: copy_id, + source_id: copy_id, + controller: PlayerId(0), + kind: StackEntryKind::Spell { + card_id: CardId(1502), + ability: Some(Box::new(copy_ability)), + casting_variant: CastingVariant::Normal, + actual_mana_spent: 0, + }, + }); + let mut zone_events = Vec::new(); + move_to_zone(&mut state, target, Zone::Graveyard, &mut zone_events); + move_to_zone(&mut state, target, Zone::Battlefield, &mut zone_events); + + let copy_ability_for_retarget = state.stack[0] + .ability() + .expect("copy ability on stack") + .clone(); + crate::game::effects::copy_spell::open_copy_retarget_choice( + &mut state, + PlayerId(0), + copy_id, + &[TargetRef::Object(target)], + ©_ability_for_retarget, + EffectKind::Destroy, + copy_id, + ); + let WaitingFor::CopyRetarget { target_slots, .. } = &state.waiting_for else { + panic!("copy retarget choice must be opened"); + }; + assert!( + target_slots[0] + .legal_alternatives + .contains(&TargetRef::Object(target)), + "production target-slot builder must admit the creature target" + ); + apply_action( + &mut state, + PlayerId(0), + GameAction::ChooseTarget { + target: Some(TargetRef::Object(target)), + }, + None, + ) + .expect("copy retarget choice must finalize through the action path"); + + let ability = state.stack[0].ability().expect("copy ability on stack"); + assert!( + ability.selected_target_pin_is_current(target, &state), + "copy same-ID retarget must refresh the selected-target pin" + ); +} + /// CR 702.6: An Equip keyword granted at runtime by a static ability (Bram, /// Bludgeon Brawl: "… is an Equipment with equip {N} …") must produce a real, /// cost-bearing equip activated ability — offered and charged through the normal diff --git a/crates/engine/src/game/resolution_prompt.rs b/crates/engine/src/game/resolution_prompt.rs index 2ec3f3200f..0df0fa0bef 100644 --- a/crates/engine/src/game/resolution_prompt.rs +++ b/crates/engine/src/game/resolution_prompt.rs @@ -555,6 +555,7 @@ pub(crate) fn chain_offers_choice(a: &ResolvedAbility) -> bool { trigger_definition_ref: _, // exact trigger occurrence, no choice force_block_attacker: _, // exact force-block referent, no choice target_incarnations: _, // CR 400.7 referent pins, no choice + selected_target_incarnations: _, // CR 400.7 selected-target pins, no choice controller: _, // player id original_controller: _, // player id scoped_player: _, // player id (iteration binding) diff --git a/crates/engine/src/game/stack.rs b/crates/engine/src/game/stack.rs index 8ce1bd9436..dd372bbd93 100644 --- a/crates/engine/src/game/stack.rs +++ b/crates/engine/src/game/stack.rs @@ -2995,6 +2995,7 @@ fn self_counter_ability_is_batch_candidate(ability: &ResolvedAbility) -> bool { trigger_definition_ref, force_block_attacker: _, target_incarnations: _, // CR 400.7 referent pins; batch candidacy is shape-only + selected_target_incarnations: _, // CR 400.7 selected-target pins; batch candidacy is shape-only controller: _, original_controller, scoped_player, @@ -3207,6 +3208,7 @@ fn fixed_controller_gain_life_ability_is_batch_candidate(ability: &ResolvedAbili trigger_definition_ref: _, force_block_attacker: _, target_incarnations: _, // CR 400.7 referent pins; batch candidacy is shape-only + selected_target_incarnations: _, // CR 400.7 selected-target pins; batch candidacy is shape-only controller: _, original_controller: _, scoped_player, @@ -3398,6 +3400,7 @@ fn fixed_opponent_lose_life_ability_is_batch_candidate(ability: &ResolvedAbility trigger_definition_ref: _, force_block_attacker: _, target_incarnations: _, // CR 400.7 referent pins; batch candidacy is shape-only + selected_target_incarnations: _, // CR 400.7 selected-target pins; batch candidacy is shape-only controller: _, original_controller: _, scoped_player, @@ -4082,6 +4085,7 @@ fn inert_trigger_abilities_eq_ignoring_provenance( modal: a_modal, mode_abilities: a_mode_abilities, parent_target_missing_reason: a_parent_target_missing_reason, + selected_target_incarnations: a_selected_target_incarnations, } = a; let ResolvedAbility { effect: b_effect, @@ -4137,6 +4141,7 @@ fn inert_trigger_abilities_eq_ignoring_provenance( modal: b_modal, mode_abilities: b_mode_abilities, parent_target_missing_reason: b_parent_target_missing_reason, + selected_target_incarnations: b_selected_target_incarnations, } = b; a_effect == b_effect @@ -4147,6 +4152,7 @@ fn inert_trigger_abilities_eq_ignoring_provenance( // keeps this manual comparison in agreement with the type's derived // `PartialEq`; disagreeing with the derive would be the actual defect. && a_target_incarnations == b_target_incarnations + && a_selected_target_incarnations == b_selected_target_incarnations && a_controller == b_controller && a_scoped_player == b_scoped_player && a_kind == b_kind diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 9257c2629f..0d967f8d77 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -24184,6 +24184,11 @@ pub struct ResolvedAbility { /// `source_incarnation`'s `is_none_or` fail-open at `source_is_current`). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub target_incarnations: Vec, + /// CR 400.7 + CR 601.2c: Incarnations captured for ordinary player- or + /// controller-selected object targets. Separate from `target_incarnations`, + /// whose keyed pins are reserved for delayed-trigger referents. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub selected_target_incarnations: Vec, pub controller: PlayerId, /// CR 109.5: The controller of the spell or ability before any /// resolution-time player-scope iteration rebinds the acting player. @@ -24495,6 +24500,7 @@ impl ResolvedAbility { trigger_definition_ref: None, force_block_attacker: None, target_incarnations: Vec::new(), + selected_target_incarnations: Vec::new(), modal: None, mode_abilities: Vec::new(), parent_target_missing_reason: None, @@ -24658,6 +24664,7 @@ impl ResolvedAbility { // whether two abilities would resolve identically, and two pins at // different epochs would not. Same field, different questions. self.target_incarnations.clear(); + self.selected_target_incarnations.clear(); if let Some(sub) = self.sub_ability.as_mut() { sub.clear_trigger_identity_recursive(); } @@ -24703,6 +24710,31 @@ impl ResolvedAbility { } } + /// CR 115.1a/c/d + CR 400.7 + CR 601.2c + CR 602.2b + CR 603.3d: Capture ordinary object targets at the shared + /// announcement/selection seam. Existing pins belong to delayed-trigger + /// referents and must not be replaced by a later assignment. + pub fn capture_target_incarnations_recursive( + &mut self, + state: &crate::types::game_state::GameState, + ) { + self.selected_target_incarnations = self + .targets + .iter() + .filter_map(|target| match target { + TargetRef::Object(id) => { + state.objects.get(id).map(ObjectIncarnationRef::from_object) + } + TargetRef::Player(_) => None, + }) + .collect(); + if let Some(sub) = self.sub_ability.as_mut() { + sub.capture_target_incarnations_recursive(state); + } + if let Some(else_branch) = self.else_ability.as_mut() { + else_branch.capture_target_incarnations_recursive(state); + } + } + /// CR 400.7 + CR 603.7c: True when `id` may still be affected by this /// ability. /// @@ -24729,6 +24761,48 @@ impl ResolvedAbility { .is_none_or(|pin| pin.is_current(state)) } + /// CR 400.7: True when an ordinary selected target still names the + /// incarnation captured at announcement/selection time. + pub fn selected_target_pin_is_current( + &self, + id: ObjectId, + state: &crate::types::game_state::GameState, + ) -> bool { + self.selected_target_incarnations + .iter() + .find(|pin| pin.object_id == id) + .is_none_or(|pin| pin.is_current(state)) + } + + /// CR 115.7: A retarget refreshes the pin when the target changes, including + /// a same-ID target that left and returned as a new object. + pub fn retarget_target_requires_pin_refresh( + &self, + old: &TargetRef, + new: &TargetRef, + state: &crate::types::game_state::GameState, + ) -> bool { + old != new + || matches!( + new, + TargetRef::Object(id) if !self.selected_target_pin_is_current(*id, state) + ) + } + + /// CR 115.7: Refresh the selected-target pin for one target changed by a + /// retargeting effect, leaving every unchanged slot's original pin intact. + pub fn update_selected_target_incarnation(&mut self, pin: ObjectIncarnationRef) { + if let Some(existing) = self + .selected_target_incarnations + .iter_mut() + .find(|existing| existing.object_id == pin.object_id) + { + *existing = pin; + } else { + self.selected_target_incarnations.push(pin); + } + } + /// CR 603.7c + CR 400.7: The subset of `targets` this ability may still /// affect. An object target whose creation-time pin no longer matches the /// live object is dropped: it left its zone (and possibly returned), so it diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 7fe6c1febd..35e2f24c77 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -1260,6 +1260,7 @@ mod swords_life_equals_power_counters_2895; mod taigam_master_opportunist_exiles_cast_spell_749; mod taii_wakeen; mod tamiyo_inquisitive_student_flip; +mod target_incarnation_revalidation; mod teamwork_keyword; mod teferis_puzzle_box_4241; mod temporal_anchor; diff --git a/crates/engine/tests/integration/target_incarnation_revalidation.rs b/crates/engine/tests/integration/target_incarnation_revalidation.rs new file mode 100644 index 0000000000..539d5cd447 --- /dev/null +++ b/crates/engine/tests/integration/target_incarnation_revalidation.rs @@ -0,0 +1,259 @@ +//! CR 400.7 + CR 608.2b regression coverage for selected object targets. + +use engine::game::effects::change_targets; +use engine::game::scenario::{GameScenario, P0, P1}; +use engine::game::zones::{create_object, move_to_zone}; +use engine::types::ability::{ + Effect, ResolvedAbility, TargetFilter, TargetRef, TypeFilter, TypedFilter, +}; +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +use engine::types::game_state::{ + CastingVariant, RetargetScope, StackEntry, StackEntryKind, WaitingFor, +}; +use engine::types::identifiers::CardId; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +#[test] +fn selected_target_does_not_follow_object_id_after_zone_change_and_return() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let source = scenario + .add_creature_from_oracle( + P0, + "Strip Mine Probe", + 1, + 1, + "{T}: Destroy target creature.", + ) + .id(); + let target = scenario.add_creature(P0, "Target Permanent", 2, 2).id(); + let blink = scenario + .add_creature_from_oracle( + P0, + "Blink Probe", + 1, + 1, + "{T}: Exile target creature you control, then return it to the battlefield under its owner's control.", + ) + .id(); + let mut runner = scenario.build(); + + runner + .act(GameAction::ActivateAbility { + source_id: source, + ability_index: 0, + }) + .expect("activation announcement must be accepted"); + runner + .act(GameAction::ChooseTarget { + target: Some(engine::types::ability::TargetRef::Object(target)), + }) + .expect("target selection must be accepted"); + assert_eq!( + runner + .state() + .stack + .back() + .and_then(|entry| entry.ability()) + .map(|ability| ability.selected_target_incarnations.len()), + Some(1), + "the announced target must be pinned on the stack entry" + ); + let announced_incarnation = runner.state().objects[&target].incarnation; + runner + .act(GameAction::ActivateAbility { + source_id: blink, + ability_index: 0, + }) + .expect("the intervening blink activation must be accepted"); + runner + .act(GameAction::ChooseTarget { + target: Some(engine::types::ability::TargetRef::Object(target)), + }) + .expect("the intervening blink target must be accepted"); + runner + .act(GameAction::PassPriority) + .expect("controller priority pass must be accepted for the blink"); + runner + .act(GameAction::PassPriority) + .expect("opponent priority pass must resolve the blink"); + assert_ne!( + runner.state().objects[&target].incarnation, + announced_incarnation, + "the production zone-change pipeline must create a new object on return" + ); + runner + .act(GameAction::PassPriority) + .expect("controller priority pass must be accepted"); + runner + .act(GameAction::PassPriority) + .expect("opponent priority pass must resolve the ability"); + assert!( + runner.state().battlefield.contains(&target), + "CR 608.2b must not destroy the returned new object" + ); +} + +fn stale_target_stack() -> ( + engine::game::scenario::GameRunner, + engine::types::identifiers::ObjectId, +) { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let target = scenario.add_creature(P1, "Retarget Target", 2, 2).id(); + let mut runner = scenario.build(); + let source = create_object( + runner.state_mut(), + CardId(88), + P0, + "Retarget Probe".to_string(), + Zone::Stack, + ); + runner + .state_mut() + .objects + .get_mut(&source) + .expect("retarget source") + .card_types + .core_types = vec![CoreType::Instant]; + let creature_target_filter = TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + controller: None, + properties: vec![], + }); + let mut target_ability = ResolvedAbility::new( + Effect::Destroy { + target: creature_target_filter.clone(), + cant_regenerate: false, + }, + vec![TargetRef::Object(target)], + source, + P0, + ); + target_ability.capture_target_incarnations_recursive(runner.state()); + runner.state_mut().stack.push_back(StackEntry { + id: source, + source_id: source, + controller: P0, + kind: StackEntryKind::Spell { + card_id: CardId(88), + ability: Some(Box::new(target_ability)), + casting_variant: CastingVariant::Normal, + actual_mana_spent: 0, + }, + }); + let mut events = Vec::new(); + move_to_zone(runner.state_mut(), target, Zone::Graveyard, &mut events); + move_to_zone(runner.state_mut(), target, Zone::Battlefield, &mut events); + assert!( + !runner + .state() + .stack + .back() + .and_then(|entry| entry.ability()) + .is_some_and(|ability| ability.selected_target_pin_is_current(target, runner.state())), + "the round-trip must stale the original selected-target pin" + ); + (runner, target) +} + +#[test] +fn interactive_same_object_id_retarget_refreshes_selected_target_pin() { + let (mut runner, target) = stale_target_stack(); + runner.state_mut().waiting_for = WaitingFor::RetargetChoice { + player: P0, + stack_entry_index: 0, + scope: RetargetScope::Single, + current_targets: vec![TargetRef::Object(target)], + legal_new_targets: vec![TargetRef::Object(target)], + }; + + runner + .act(GameAction::RetargetSpell { + new_targets: vec![TargetRef::Object(target)], + }) + .expect("same-ID retarget must be accepted"); + + let ability = runner.state().stack[0].ability().expect("ability on stack"); + assert!( + ability.selected_target_pin_is_current(target, runner.state()), + "interactive same-ID retarget must refresh the selected-target pin" + ); +} + +#[test] +fn forced_same_object_id_retarget_refreshes_selected_target_pin() { + let (mut runner, target) = stale_target_stack(); + let creature_target_filter = TargetFilter::Typed(TypedFilter { + type_filters: vec![TypeFilter::Creature], + controller: None, + properties: vec![], + }); + let stack_entry_id = runner.state().stack[0].id; + let retarget_id = create_object( + runner.state_mut(), + CardId(99), + P0, + "Forced Retarget Probe".to_string(), + Zone::Stack, + ); + runner + .state_mut() + .objects + .get_mut(&retarget_id) + .expect("retarget source") + .card_types + .core_types = vec![CoreType::Instant]; + let retarget_ability = ResolvedAbility::new( + Effect::ChangeTargets { + target: TargetFilter::StackSpell, + scope: RetargetScope::Single, + forced_to: Some(creature_target_filter.clone()), + }, + vec![TargetRef::Object(stack_entry_id)], + retarget_id, + P0, + ); + runner.state_mut().stack.push_back(StackEntry { + id: retarget_id, + source_id: retarget_id, + controller: P0, + kind: StackEntryKind::Spell { + card_id: CardId(99), + ability: Some(Box::new(retarget_ability.clone())), + casting_variant: CastingVariant::Normal, + actual_mana_spent: 0, + }, + }); + + assert!( + engine::game::effects::change_targets::legal_new_targets_for_stack_entry( + runner.state(), + 0, + ) + .contains(&TargetRef::Object(target)), + "the original target must remain legal for the forced retarget" + ); + let forced_candidates = engine::game::targeting::find_legal_targets( + runner.state(), + &creature_target_filter, + P0, + retarget_id, + ); + assert_eq!( + forced_candidates, + vec![TargetRef::Object(target)], + "the forced candidate filter must select only the returned object" + ); + let mut events = Vec::new(); + change_targets::resolve(runner.state_mut(), &retarget_ability, &mut events) + .expect("forced retarget must resolve"); + + let ability = runner.state().stack[0].ability().expect("ability on stack"); + assert!( + ability.selected_target_pin_is_current(target, runner.state()), + "forced same-ID retarget must refresh the selected-target pin" + ); +} diff --git a/crates/engine/tests/integration/the_chain_veil_loyalty_grants.rs b/crates/engine/tests/integration/the_chain_veil_loyalty_grants.rs index 64cbc42577..5f118cea0d 100644 --- a/crates/engine/tests/integration/the_chain_veil_loyalty_grants.rs +++ b/crates/engine/tests/integration/the_chain_veil_loyalty_grants.rs @@ -157,6 +157,7 @@ fn make_grant_ability(controller: PlayerId, source: ObjectId) -> ResolvedAbility trigger_definition_ref: None, force_block_attacker: None, target_incarnations: Vec::new(), + selected_target_incarnations: Vec::new(), targets: vec![], kind: AbilityKind::Activated, sub_ability: None,