fix #13523 (trigger on becomes the target of recast spell) (#13740)

move findTargetingStackObject from CardUtil to Game, so saved data can be cleared with short living lki

add test cases
This commit is contained in:
xenohedron 2025-06-14 00:09:40 -04:00 committed by GitHub
parent 030e8ae5d3
commit 24f030fa71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 181 additions and 73 deletions

View file

@ -30,6 +30,7 @@ import mage.game.permanent.Battlefield;
import mage.game.permanent.Permanent;
import mage.game.stack.Spell;
import mage.game.stack.SpellStack;
import mage.game.stack.StackObject;
import mage.game.turn.Phase;
import mage.game.turn.Step;
import mage.game.turn.Turn;
@ -310,6 +311,19 @@ public interface Game extends MageItem, Serializable, Copyable<Game> {
void resetShortLivingLKI();
/**
* For finding the spell or ability on the stack for "becomes the target" triggers.
* Also ensures that spells/abilities that target the same object twice only trigger each "becomes the target" ability once.
* If this is the first attempt at triggering for a given ability targeting a given object,
* this method temporarily records that in the game state for later checks by this same method,
* to not return the same object again (until short living LKI is cleared)
*
* @param checkingReference must be unique for each usage (this.getId().toString() of the TriggeredAbility, or this.getKey() of the watcher)
* @param event the GameEvent.EventType.TARGETED from checkTrigger() or watch()
* @return the StackObject which targeted the source, or null if already used or not found
*/
StackObject findTargetingStackObject(String checkingReference, GameEvent event);
void setLosingPlayer(Player player);
Player getLosingPlayer();

View file

@ -121,6 +121,8 @@ public abstract class GameImpl implements Game {
protected Map<UUID, Map<Integer, MageObject>> lkiExtended = new HashMap<>();
// Used to check if an object was moved by the current effect in resolution (so Wrath like effect can be handled correctly)
protected Map<Zone, Set<UUID>> lkiShortLiving = new EnumMap<>(Zone.class);
// For checking "becomes the target" triggers accurately. Cleared on short living LKI reset
protected Map<String, Map<UUID, Set<UUID>>> targetedMap = new HashMap<>();
// Permanents entering the Battlefield while handling replacement effects before they are added to the battlefield
protected Map<UUID, Permanent> permanentsEntering = new HashMap<>();
@ -202,6 +204,7 @@ public abstract class GameImpl implements Game {
this.lkiCardState = CardUtil.deepCopyObject(game.lkiCardState);
this.lkiExtended = CardUtil.deepCopyObject(game.lkiExtended);
this.lkiShortLiving = CardUtil.deepCopyObject(game.lkiShortLiving);
this.targetedMap = CardUtil.deepCopyObject(game.targetedMap);
this.permanentsEntering = CardUtil.deepCopyObject(game.permanentsEntering);
this.enterWithCounters = CardUtil.deepCopyObject(game.enterWithCounters);
@ -3687,6 +3690,39 @@ public abstract class GameImpl implements Game {
@Override
public void resetShortLivingLKI() {
lkiShortLiving.clear();
targetedMap.clear();
}
@Override
public StackObject findTargetingStackObject(String checkingReference, GameEvent event) {
// In case of multiple simultaneous triggered abilities from the same source,
// need to get the actual one that targeted, see #8026, #8378, rulings for Battle Mammoth
// In case of copied triggered abilities, need to trigger on each independently, see #13498
// Also avoids triggering on cancelled selections, see #8802
Map<UUID, Set<UUID>> targetMap = targetedMap.getOrDefault(checkingReference, null);
// targetMap: key - targetId; value - Set of stackObject Ids
if (targetMap == null) {
targetMap = new HashMap<>();
} else {
targetMap = new HashMap<>(targetMap); // must have new object reference if saved back
}
Set<UUID> targetingObjects = targetMap.computeIfAbsent(event.getTargetId(), k -> new HashSet<>());
for (StackObject stackObject : getStack()) {
Ability stackAbility = stackObject.getStackAbility();
if (stackAbility == null || !stackAbility.getSourceId().equals(event.getSourceId())) {
continue;
}
if (CardUtil.getAllSelectedTargets(stackAbility, this).contains(event.getTargetId())) {
if (!targetingObjects.add(stackObject.getId())) {
continue; // The trigger/watcher already recorded that target of the stack object, check for another
}
// Otherwise, store this combination of trigger/watcher + target + stack object
targetMap.put(event.getTargetId(), targetingObjects);
targetedMap.put(checkingReference, targetMap);
return stackObject;
}
}
return null;
}
@Override