foul-magics/Mage/src/main/java/mage/abilities/TriggeredAbilityImpl.java
2025-10-31 12:29:05 -04:00

582 lines
25 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package mage.abilities;
import mage.MageObject;
import mage.abilities.condition.Condition;
import mage.abilities.effects.Effect;
import mage.abilities.effects.common.*;
import mage.constants.AbilityType;
import mage.constants.AbilityWord;
import mage.constants.Zone;
import mage.game.Game;
import mage.game.events.BatchEvent;
import mage.game.events.GameEvent;
import mage.game.events.ZoneChangeEvent;
import mage.game.permanent.PermanentToken;
import mage.players.Player;
import mage.util.CardUtil;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
/**
* @author BetaSteward_at_googlemail.com
*/
public abstract class TriggeredAbilityImpl extends AbilityImpl implements TriggeredAbility {
private boolean optional;
private Condition interveningIfCondition;
private Condition triggerCondition;
private boolean leavesTheBattlefieldTrigger;
private int triggerLimitEachTurn = Integer.MAX_VALUE; // for "triggers only once|twice each turn"
private int triggerLimitEachGame = Integer.MAX_VALUE; // for "triggers only once|twice"
private boolean doOnlyOnceEachTurn = false;
private boolean replaceRuleText = false; // if true, replace "{this}" with "it" in effect text
private GameEvent triggerEvent = null;
private String triggerPhrase = null;
protected TriggeredAbilityImpl(Zone zone, Effect effect) {
this(zone, effect, false);
}
protected TriggeredAbilityImpl(Zone zone, Effect effect, boolean optional) {
super(AbilityType.TRIGGERED_NONMANA, zone);
setLeavesTheBattlefieldTrigger(false);
if (effect != null) {
addEffect(effect);
}
this.optional = optional;
// verify check: DoIfCostPaid effect already asks about action (optional), so no needs to ask it again in triggered ability
if (effect instanceof DoIfCostPaid && (this.optional && ((DoIfCostPaid) effect).isOptional())) {
throw new IllegalArgumentException("DoIfCostPaid effect must have only one optional settings, but it have two (trigger + DoIfCostPaid): " + this.getClass().getSimpleName());
}
}
protected TriggeredAbilityImpl(final TriggeredAbilityImpl ability) {
super(ability);
this.optional = ability.optional;
this.interveningIfCondition = ability.interveningIfCondition;
this.triggerCondition = ability.triggerCondition;
this.leavesTheBattlefieldTrigger = ability.leavesTheBattlefieldTrigger;
this.triggerLimitEachTurn = ability.triggerLimitEachTurn;
this.triggerLimitEachGame = ability.triggerLimitEachGame;
this.doOnlyOnceEachTurn = ability.doOnlyOnceEachTurn;
this.replaceRuleText = ability.replaceRuleText;
this.triggerEvent = ability.triggerEvent;
this.triggerPhrase = ability.triggerPhrase;
}
@Override
public void trigger(Game game, UUID controllerId, GameEvent triggeringEvent) {
//20091005 - 603.4
if (checkInterveningIfClause(game) && checkTriggerCondition(game)) {
updateTurnCount(game);
updateGameCount(game);
game.addTriggeredAbility(this, triggeringEvent);
}
}
// Used for triggers with a per-turn limit.
private String getKeyLastTurnTriggered(Game game) {
return CardUtil.getCardZoneString(
"lastTurnTriggered|" + getOriginalId(), getSourceId(), game
);
}
// Used for triggers with a per-turn limit.
private String getKeyLastTurnTriggeredCount(Game game) {
return CardUtil.getCardZoneString(
"lastTurnTriggeredCount|" + getOriginalId(), getSourceId(), game
);
}
// Used for triggers with a per-game limit.
private String getKeyGameTriggeredCount(Game game) {
return CardUtil.getCardZoneString(
"gameTriggeredCount|" + getOriginalId(), getSourceId(), game
);
}
private void updateTurnCount(Game game) {
if (triggerLimitEachTurn == Integer.MAX_VALUE) {
return;
}
String keyLastTurnTriggered = getKeyLastTurnTriggered(game);
String keyLastTurnTriggeredCount = getKeyLastTurnTriggeredCount(game);
Integer lastTurn = (Integer) game.getState().getValue(keyLastTurnTriggered);
int currentTurn = game.getTurnNum();
if (lastTurn != null && lastTurn == currentTurn) {
// Ability already triggered this turn, incrementing the count.
int lastCount = Optional.ofNullable((Integer) game.getState().getValue(keyLastTurnTriggeredCount)).orElse(0);
game.getState().setValue(keyLastTurnTriggeredCount, lastCount + 1);
} else {
// first trigger for Ability this turn.
game.getState().setValue(keyLastTurnTriggered, currentTurn);
game.getState().setValue(keyLastTurnTriggeredCount, 1);
}
}
private void updateGameCount(Game game) {
if (triggerLimitEachGame == Integer.MAX_VALUE) {
return;
}
String keyGameTriggeredCount = getKeyGameTriggeredCount(game);
int lastCount = Optional.ofNullable((Integer) game.getState().getValue(keyGameTriggeredCount)).orElse(0);
// Incrementing the count.
game.getState().setValue(keyGameTriggeredCount, lastCount + 1);
}
@Override
public TriggeredAbilityImpl setTriggerPhrase(String triggerPhrase) {
this.triggerPhrase = triggerPhrase;
return this;
}
@Override
public String getTriggerPhrase() {
return this.triggerPhrase;
}
@Override
public void setTriggerEvent(GameEvent triggerEvent) {
this.triggerEvent = triggerEvent;
}
@Override
public GameEvent getTriggerEvent() {
return triggerEvent;
}
@Override
public boolean checkTriggeredLimit(Game game) {
return getRemainingTriggersLimitEachGame(game) > 0 && getRemainingTriggersLimitEachTurn(game) > 0;
}
@Override
public boolean checkUsedAlready(Game game) {
return doOnlyOnceEachTurn && TriggeredAbility.checkDidThisTurn(this, game);
}
@Override
public TriggeredAbility setTriggersLimitEachTurn(int limit) {
this.triggerLimitEachTurn = limit;
return this;
}
@Override
public TriggeredAbility setTriggersLimitEachGame(int limit) {
this.triggerLimitEachGame = limit;
return this;
}
@Override
public int getRemainingTriggersLimitEachTurn(Game game) {
if (triggerLimitEachTurn == Integer.MAX_VALUE) {
return Integer.MAX_VALUE;
}
String keyLastTurnTriggered = getKeyLastTurnTriggered(game);
Integer lastTurn = (Integer) game.getState().getValue(keyLastTurnTriggered);
int currentTurn = game.getTurnNum();
if (lastTurn != null && lastTurn == currentTurn) {
// Ability already triggered this turn, so returning the limit minus the count this turn
String keyLastTurnTriggeredCount = getKeyLastTurnTriggeredCount(game);
int count = Optional.ofNullable((Integer) game.getState().getValue(keyLastTurnTriggeredCount)).orElse(0);
return Math.max(0, triggerLimitEachTurn - count);
} else {
// Ability did not trigger this turn, so returning the limit
return triggerLimitEachTurn;
}
}
@Override
public int getRemainingTriggersLimitEachGame(Game game) {
if (triggerLimitEachGame == Integer.MAX_VALUE) {
return Integer.MAX_VALUE;
}
String keyGameTriggeredCount = getKeyGameTriggeredCount(game);
int count = Optional.ofNullable((Integer) game.getState().getValue(keyGameTriggeredCount)).orElse(0);
return Math.max(0, triggerLimitEachGame - count);
}
@Override
public TriggeredAbility setDoOnlyOnceEachTurn(boolean doOnlyOnce) {
this.doOnlyOnceEachTurn = doOnlyOnce;
if (CardUtil.castStream(this.getAllEffects(), DoIfCostPaid.class).noneMatch(DoIfCostPaid::isOptional)) {
this.optional = true;
}
return this;
}
@Override
public TriggeredAbility setOptional(boolean optional) {
this.optional = optional;
return this;
}
@Override
public TriggeredAbility withRuleTextReplacement(boolean replaceRuleText) {
this.replaceRuleText = replaceRuleText;
return this;
}
@Override
public TriggeredAbility withInterveningIf(Condition interveningIfCondition) {
this.interveningIfCondition = interveningIfCondition;
this.replaceRuleText = false;
return this;
}
@Override
public boolean checkInterveningIfClause(Game game) {
return interveningIfCondition == null || interveningIfCondition.apply(game, this);
}
@Override
public TriggeredAbility withTriggerCondition(Condition condition) {
this.triggerCondition = condition;
if (this.triggerPhrase != null && !condition.toString().isEmpty()) {
this.setTriggerPhrase(
this.triggerPhrase.substring(0, this.triggerPhrase.length() - 2) + ' ' +
(condition.toString().startsWith("during") ? "" : "while ") + condition + ", "
);
}
return this;
}
@Override
public Condition getTriggerCondition() {
return triggerCondition;
}
@Override
public boolean checkTriggerCondition(Game game) {
return triggerCondition == null || triggerCondition.apply(game, this);
}
@Override
public boolean resolve(Game game) {
if (!checkInterveningIfClause(game)) {
return false;
}
if (isOptional()) {
MageObject object = game.getObject(getSourceId());
Player player = game.getPlayer(this.getControllerId());
if (player == null || object == null
|| (doOnlyOnceEachTurn && checkUsedAlready(game))
|| !player.chooseUse(
getEffects().getOutcome(this),
this.getRule(object.getLogName()), this, game
)) {
return false;
}
if (doOnlyOnceEachTurn) {
TriggeredAbility.setDidThisTurn(this, game);
}
}
//20091005 - 603.4
if (!super.resolve(game)) {
return false;
}
return true;
}
@Override
public String getGameLogMessage(Game game) {
MageObject object = game.getObject(sourceId);
StringBuilder sb = new StringBuilder();
if (object != null) {
sb.append("Ability triggers: ").append(object.getLogName()).append(" - ").append(this.getRule(object.getLogName()));
} else {
sb.append("Ability triggers: ").append(this.getRule());
}
String targetText = getTargetDescriptionForLog(getTargets(), game);
if (!targetText.isEmpty()) {
sb.append(" - ").append(targetText);
}
return sb.toString();
}
@Override
public String getRule() {
StringBuilder sb = new StringBuilder();
sb.append(triggerPhrase == null ? "" : triggerPhrase);
if (interveningIfCondition != null) {
String conditionText = interveningIfCondition.toString();
if (!conditionText.isEmpty()) { // e.g. CaseSolveAbility
if (replaceRuleText && triggerPhrase != null && triggerPhrase.contains("{this}")) {
conditionText = conditionText.replace("{this}", "it");
if (conditionText.startsWith("it is ")) {
conditionText = conditionText.replace("it is ", "it's ");
}
}
if (!conditionText.startsWith("if ")) {
sb.append("if ");
}
sb.append(conditionText).append(", ");
}
}
String superRule = super.getRule(true);
if (!superRule.isEmpty()) {
String ruleLow = superRule.toLowerCase(Locale.ENGLISH);
if (isOptional()) {
if (ruleLow.startsWith("you ")) {
if (!ruleLow.startsWith("you may")) {
StringBuilder newRule = new StringBuilder(superRule);
newRule.insert(4, "may ");
superRule = newRule.toString();
}
} else if (!ruleLow.startsWith("{this}")
&& (this.getTargets().isEmpty()
|| startsWithVerb(ruleLow))) {
sb.append("you may ");
} else if (!ruleLow.startsWith("its controller may")) {
sb.append("you may have ");
superRule = superRule.replaceFirst(" (become|block|deal|discard|gain|get|lose|mill|sacrifice)s? ", " $1 ");
}
}
if (replaceRuleText && triggerPhrase != null) {
superRule = superRule.replaceFirst("^((?:you may )?sacrifice |(put|remove) [^ ]+ [^ ]+ counters? (on|from) |return |transform |untap |regenerate |attach |exile )?\\{this\\}", "$1it");
}
sb.append(superRule);
if (triggerLimitEachTurn != Integer.MAX_VALUE) {
sb.append(" This ability triggers only ");
switch (triggerLimitEachTurn) {
case 1:
sb.append("once");
break;
case 2:
sb.append("twice");
break;
default:
// No card with that behavior yet, so feel free to change the text once one exist
sb.append(CardUtil.numberToText(triggerLimitEachTurn)).append(" times");
}
sb.append(" each turn.");
}
if (triggerLimitEachGame != Integer.MAX_VALUE) {
sb.append(" This ability triggers only ");
switch (triggerLimitEachGame) {
case 1:
sb.append("once.");
break;
case 2:
// No card with that behavior yet, so feel free to change the text once one exist
sb.append("twice.");
break;
default:
// No card with that behavior yet, so feel free to change the text once one exist
sb.append(CardUtil.numberToText(triggerLimitEachGame)).append(" times.");
}
}
if (doOnlyOnceEachTurn) {
sb.append(" Do this only once each turn.");
}
}
return addRulePrefix(sb.toString());
}
private static boolean startsWithVerb(String ruleLow) {
return ruleLow.startsWith("attach")
|| ruleLow.startsWith("cast")
|| ruleLow.startsWith("change")
|| ruleLow.startsWith("counter")
|| ruleLow.startsWith("create")
|| ruleLow.startsWith("destroy")
|| ruleLow.startsWith("distribute")
|| ruleLow.startsWith("sacrifice")
|| ruleLow.startsWith("exchange")
|| ruleLow.startsWith("exile")
|| ruleLow.startsWith("gain")
|| ruleLow.startsWith("goad")
|| ruleLow.startsWith("have")
|| ruleLow.startsWith("move")
|| ruleLow.startsWith("prevent")
|| ruleLow.startsWith("put")
|| ruleLow.startsWith("remove")
|| ruleLow.startsWith("return")
|| ruleLow.startsWith("shuffle")
|| ruleLow.startsWith("turn")
|| ruleLow.startsWith("tap")
|| ruleLow.startsWith("untap");
}
/**
* For use in generating trigger phrases with correct text
*
* @return "When " for an effect that always removes the source from the battlefield, otherwise "Whenever "
*/
protected final String getWhen() {
return (!optional && getAllEffects().stream().anyMatch(
effect -> effect instanceof SacrificeSourceEffect
|| effect instanceof ReturnToHandSourceEffect
|| effect instanceof ShuffleIntoLibrarySourceEffect
|| effect instanceof ExileSourceEffect
|| effect instanceof FlipSourceEffect
|| effect instanceof DestroySourceEffect
) ? "When " : "Whenever ");
}
@Override
public boolean isInUseableZone(Game game, MageObject sourceObject, GameEvent event) {
// workaround for singleton abilities like Flying
UUID affectedSourceId = getRealSourceObjectId(this, sourceObject);
// 603.6
// Trigger events that involve objects changing zones are called "zone-change triggers." Many abilities with
// zone-change triggers attempt to do something to that object after it changes zones. During resolution,
// these abilities look for the object in the zone that it moved to. If the object is unable to be found
// in the zone it went to, the part of the ability attempting to do something to the object will fail to
// do anything. The ability could be unable to find the object because the object never entered the
// specified zone, because it left the zone before the ability resolved, or because it is in a zone that
// is hidden from a player, such as a library or an opponents hand. (This rule applies even if the
// object leaves the zone and returns again before the ability resolves.) The most common zone-change
// triggers are enters-the-battlefield triggers and leaves-the-battlefield triggers.
// There are possible two different use cases:
// * look in current game state (normal events):
// * look back in time (leaves battlefield, dies, etc);
// TODO: need sync or shared code with AbilityImpl.isInUseableZone
MageObject affectedSourceObject = sourceObject;
if (event == null) {
// state base triggers - use only actual state
} else {
// event triggers - can look back in time for some use cases
switch (event.getType()) {
case ZONE_CHANGE:
ZoneChangeEvent zce = (ZoneChangeEvent) event;
Set<UUID> eventTargets = CardUtil.getEventTargets(event);
if (eventTargets.contains(getSourceId()) && !zce.getToZone().isPublicZone()) {
// TODO: need research and share with AbilityImpl
// If an ability triggers when the object that has it is put into a hidden zone from a graveyard,
// that ability triggers from the graveyard, (such as Golgari Brownscale),
// Yixlid Jailer will prevent that ability from triggering.
if (zce.getFromZone().match(Zone.GRAVEYARD)) {
if (!CardUtil.cardHadAbility(this, game.getLastKnownInformationCard(getSourceId(), zce.getFromZone()), getSourceId(), game)) {
return false;
}
}
}
if (isLeavesTheBattlefieldTrigger() && game.checkShortLivingLKI(affectedSourceId, Zone.BATTLEFIELD)) {
affectedSourceObject = game.getLastKnownInformation(affectedSourceId, Zone.BATTLEFIELD);
}
break;
case DESTROYED_PERMANENT:
case EXPLOITED_CREATURE:
case SACRIFICED_PERMANENT:
if (isLeavesTheBattlefieldTrigger() && game.checkShortLivingLKI(affectedSourceId, Zone.BATTLEFIELD)) {
affectedSourceObject = game.getPermanentOrLKIBattlefield(affectedSourceId);
}
break;
}
}
return super.isInUseableZone(game, affectedSourceObject, event);
}
@Override
public boolean isLeavesTheBattlefieldTrigger() {
return leavesTheBattlefieldTrigger;
}
@Override
public final void setLeavesTheBattlefieldTrigger(boolean leavesTheBattlefieldTrigger) {
this.leavesTheBattlefieldTrigger = leavesTheBattlefieldTrigger;
// TODO: replace override of isInUseableZone in dies only triggers by like "isDiesOnlyTrigger" here
}
@Override
public boolean isOptional() {
return optional;
}
@Override
public TriggeredAbilityImpl setAbilityWord(AbilityWord abilityWord) {
super.setAbilityWord(abilityWord);
return this;
}
/**
* Looking object in GRAVEYARD zone only. If you need multi zone then use default isInUseableZone
* - good example: Whenever another creature you control dies
* - bad example: When {this} dies or is put into exile from the battlefield
* <p>
* For triggered abilities that function from the battlefield that must trigger when the source permanent dies
* and/or for any other events that happen simultaneously to the source permanent dying.
* (Similar logic must be used for any leaves-the-battlefield, but this method assumes to graveyard only.)
* NOTE: If your ability functions from another zone (not battlefield) then must use standard logic, not this.
*/
public static boolean isInUseableZoneDiesTrigger(TriggeredAbility sourceAbility, MageObject sourceObject, GameEvent event, Game game) {
// runtime check: wrong trigger settings
if (!sourceAbility.isLeavesTheBattlefieldTrigger()) {
throw new IllegalArgumentException("Wrong code usage: all dies triggers must use setLeavesTheBattlefieldTrigger(true) and override isInUseableZone - "
+ sourceAbility.getSourceObject(game) + " - " + sourceAbility);
}
// runtime check: wrong isInUseableZone for batch related triggers
if (event instanceof BatchEvent) {
throw new IllegalArgumentException("Wrong code usage: batch events unsupported here, possible miss of override isInUseableZone - "
+ sourceAbility.getSourceObject(game) + " - " + sourceAbility);
}
// workaround for singleton abilities like Flying
UUID affectedSourceId = getRealSourceObjectId(sourceAbility, sourceObject);
// on permanent - can use actual or look back in time
MageObject affectedObject = null;
if (game.getState().getZone(affectedSourceId) == Zone.BATTLEFIELD) {
affectedObject = game.getPermanent(affectedSourceId);
} else {
// The idea: short living LKI must help to find a moment in the inner of resolve
// -
// Example:
// --!---------------!-------------!-----!-----------!
// - ! steps ! perm zone ! LKI ! short LKI !
// --!---------------!-------------!-----!-----------!
// - ! resolve start ! battlefield ! no ! no !
// - ! step 1 ! battlefield ! no ! no ! permanent moving to graveyard by step's command
// - ! step 2 ! graveyard ! yes ! yes ! other commands
// - ! step 3 ! graveyard ! yes ! yes ! other commands
// - ! raise triggers! graveyard ! yes ! yes ! handle and put triggers that was raised in resolve steps
// - ! resolve end ! graveyard ! yes ! no !
// - ! resolve next ! graveyard ! yes ! no ! resolve next spell
// - ! empty stack ! graveyard ! no ! no ! no more to resolve
// --!---------------!-------------!-----!-----------!
// -
if (game.checkShortLivingLKI(affectedSourceId, Zone.BATTLEFIELD)) {
affectedObject = game.getLastKnownInformation(affectedSourceId, Zone.BATTLEFIELD);
}
}
if (affectedObject == null) {
affectedObject = game.getObject(sourceAbility);
if (affectedObject == null || affectedObject.isPermanent(game)) {
// if it was a permanent, but now removed then ignore
return false;
}
}
if (!sourceAbility.hasSourceObjectAbility(game, affectedObject, event)) {
return false; // the permanent does currently not have or before it dies the ability so no trigger
}
// check now it is in graveyard (only if it is no token and was the target itself)
// TODO: need research
if (affectedSourceId.equals(event.getTargetId()) // source is also the target
&& !(affectedObject instanceof PermanentToken) // it's no token
&& affectedObject.getZoneChangeCounter(game) + 1 == game.getState().getZoneChangeCounter(affectedSourceId)) { // It's in the next zone
Zone after = game.getState().getZone(affectedSourceId);
if (!Zone.GRAVEYARD.match(after)) { // Zone is not the graveyard
return false; // Moving to graveyard was replaced so no trigger
}
}
return true;
}
}