mirror of
https://github.com/magefree/mage.git
synced 2025-12-29 15:02:13 -08:00
1800 lines
70 KiB
Java
1800 lines
70 KiB
Java
package mage.abilities;
|
|
|
|
import mage.MageIdentifier;
|
|
import mage.MageObject;
|
|
import mage.Mana;
|
|
import mage.abilities.common.EntersBattlefieldAbility;
|
|
import mage.abilities.condition.Condition;
|
|
import mage.abilities.costs.*;
|
|
import mage.abilities.costs.common.PayLifeCost;
|
|
import mage.abilities.costs.mana.ManaCost;
|
|
import mage.abilities.costs.mana.ManaCosts;
|
|
import mage.abilities.costs.mana.ManaCostsImpl;
|
|
import mage.abilities.costs.mana.VariableManaCost;
|
|
import mage.abilities.effects.ContinuousEffect;
|
|
import mage.abilities.effects.Effect;
|
|
import mage.abilities.effects.Effects;
|
|
import mage.abilities.effects.OneShotEffect;
|
|
import mage.abilities.effects.mana.ManaEffect;
|
|
import mage.abilities.hint.Hint;
|
|
import mage.abilities.icon.CardIcon;
|
|
import mage.abilities.mana.ActivatedManaAbilityImpl;
|
|
import mage.cards.Card;
|
|
import mage.choices.Choice;
|
|
import mage.choices.ChoiceHintType;
|
|
import mage.choices.ChoiceImpl;
|
|
import mage.constants.*;
|
|
import mage.filter.FilterMana;
|
|
import mage.game.Game;
|
|
import mage.game.command.Dungeon;
|
|
import mage.game.command.Emblem;
|
|
import mage.game.command.Plane;
|
|
import mage.game.events.BatchEvent;
|
|
import mage.game.events.GameEvent;
|
|
import mage.game.events.ZoneChangeEvent;
|
|
import mage.game.permanent.Permanent;
|
|
import mage.game.stack.Spell;
|
|
import mage.game.stack.StackAbility;
|
|
import mage.players.Player;
|
|
import mage.target.Target;
|
|
import mage.target.TargetCard;
|
|
import mage.target.Targets;
|
|
import mage.target.common.TargetCardInLibrary;
|
|
import mage.target.targetadjustment.GenericTargetAdjuster;
|
|
import mage.target.targetadjustment.TargetAdjuster;
|
|
import mage.util.CardUtil;
|
|
import mage.util.GameLog;
|
|
import mage.util.ThreadLocalStringBuilder;
|
|
import mage.watchers.Watcher;
|
|
import org.apache.log4j.Logger;
|
|
|
|
import java.util.*;
|
|
|
|
/**
|
|
* @author BetaSteward_at_googlemail.com
|
|
*/
|
|
public abstract class AbilityImpl implements Ability {
|
|
|
|
private static final Logger logger = Logger.getLogger(AbilityImpl.class);
|
|
private static final ThreadLocalStringBuilder threadLocalBuilder = new ThreadLocalStringBuilder(100);
|
|
private static final List<Ability> emptyAbilities = new ArrayList<>();
|
|
|
|
protected UUID id;
|
|
private UUID originalId; // TODO: delete originalId???
|
|
protected AbilityType abilityType;
|
|
protected UUID controllerId;
|
|
protected UUID sourceId;
|
|
private final ManaCosts<ManaCost> manaCosts;
|
|
private final ManaCosts<ManaCost> manaCostsToPay;
|
|
private final Costs<Cost> costs;
|
|
private final Modes modes; // access to it by GetModes only (it can be overridden by some abilities)
|
|
protected Zone zone;
|
|
protected String name;
|
|
protected AbilityWord abilityWord;
|
|
protected String flavorWord;
|
|
protected boolean usesStack = true;
|
|
private boolean ruleAtTheTop = false;
|
|
private boolean ruleVisible = true;
|
|
private boolean ruleAdditionalCostsVisible = true;
|
|
protected boolean activated = false;
|
|
private boolean worksFaceDown = false;
|
|
private boolean worksPhasedOut = false;
|
|
private int sourceObjectZoneChangeCounter;
|
|
private List<Watcher> watchers = new ArrayList<>(); // access to it by GetWatchers only (it can be overridden by some abilities)
|
|
private List<Ability> subAbilities = null;
|
|
private boolean canFizzle = true; // for Gilded Drake
|
|
private TargetAdjuster targetAdjuster = null;
|
|
private CostAdjuster costAdjuster = null;
|
|
private List<Hint> hints = new ArrayList<>();
|
|
protected List<CardIcon> icons = new ArrayList<>();
|
|
private Outcome customOutcome = null; // uses for AI decisions instead effects
|
|
private MageIdentifier identifier = MageIdentifier.Default; // used to identify specific ability (e.g. to match with corresponding watcher)
|
|
private String appendToRule = null;
|
|
private int sourcePermanentTransformCount = 0;
|
|
private Map<String, Object> costsTagMap = null;
|
|
|
|
protected AbilityImpl(AbilityType abilityType, Zone zone) {
|
|
this.id = UUID.randomUUID();
|
|
this.originalId = id;
|
|
this.abilityType = abilityType;
|
|
this.zone = zone;
|
|
this.manaCosts = new ManaCostsImpl<>();
|
|
this.manaCostsToPay = new ManaCostsImpl<>();
|
|
this.costs = new CostsImpl<>();
|
|
this.modes = new Modes();
|
|
}
|
|
|
|
protected AbilityImpl(final AbilityImpl ability) {
|
|
this.id = ability.id;
|
|
this.originalId = ability.originalId;
|
|
this.abilityType = ability.abilityType;
|
|
this.controllerId = ability.controllerId;
|
|
this.sourceId = ability.sourceId;
|
|
this.zone = ability.zone;
|
|
this.name = ability.name;
|
|
this.usesStack = ability.usesStack;
|
|
this.manaCosts = ability.manaCosts.copy();
|
|
this.manaCostsToPay = ability.manaCostsToPay.copy();
|
|
this.costs = ability.costs.copy();
|
|
this.watchers = CardUtil.deepCopyObject(ability.getWatchers());
|
|
|
|
this.subAbilities = CardUtil.deepCopyObject(ability.subAbilities);
|
|
this.modes = ability.getModes().copy();
|
|
this.ruleAtTheTop = ability.ruleAtTheTop;
|
|
this.ruleVisible = ability.ruleVisible;
|
|
this.ruleAdditionalCostsVisible = ability.ruleAdditionalCostsVisible;
|
|
this.worksFaceDown = ability.worksFaceDown;
|
|
this.worksPhasedOut = ability.worksPhasedOut;
|
|
this.abilityWord = ability.abilityWord;
|
|
this.flavorWord = ability.flavorWord;
|
|
this.sourceObjectZoneChangeCounter = ability.sourceObjectZoneChangeCounter;
|
|
this.canFizzle = ability.canFizzle;
|
|
this.targetAdjuster = ability.targetAdjuster;
|
|
this.costAdjuster = ability.costAdjuster;
|
|
this.hints = CardUtil.deepCopyObject(ability.getHints());
|
|
this.icons = CardUtil.deepCopyObject(ability.getIcons());
|
|
this.customOutcome = ability.customOutcome;
|
|
this.identifier = ability.identifier;
|
|
this.activated = ability.activated;
|
|
this.appendToRule = ability.appendToRule;
|
|
this.sourcePermanentTransformCount = ability.sourcePermanentTransformCount;
|
|
this.costsTagMap = CardUtil.deepCopyObject(ability.costsTagMap);
|
|
}
|
|
|
|
@Override
|
|
public UUID getId() {
|
|
return id;
|
|
}
|
|
|
|
@Override
|
|
public void newId() {
|
|
if (!(this instanceof MageSingleton)) {
|
|
this.id = UUID.randomUUID();
|
|
}
|
|
getEffects().newId();
|
|
|
|
for (Ability sub : getSubAbilities()) {
|
|
sub.newId();
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void newOriginalId() {
|
|
this.id = UUID.randomUUID();
|
|
this.originalId = id;
|
|
getEffects().newId();
|
|
}
|
|
|
|
@Override
|
|
public AbilityType getAbilityType() {
|
|
return this.abilityType;
|
|
}
|
|
|
|
@Override
|
|
public boolean isActivatedAbility() {
|
|
return this.abilityType.isActivatedAbility();
|
|
}
|
|
|
|
@Override
|
|
public boolean isTriggeredAbility() {
|
|
return this.abilityType.isTriggeredAbility();
|
|
}
|
|
|
|
@Override
|
|
public boolean isNonManaActivatedAbility() {
|
|
return this.abilityType.isNonManaActivatedAbility();
|
|
}
|
|
|
|
@Override
|
|
public boolean isManaActivatedAbility() {
|
|
return this.abilityType.isManaActivatedAbility();
|
|
}
|
|
|
|
@Override
|
|
public boolean isManaAbility() {
|
|
return this.abilityType.isManaAbility();
|
|
}
|
|
|
|
@Override
|
|
public boolean resolve(Game game) {
|
|
boolean result = true;
|
|
//20100716 - 117.12
|
|
if (checkIfClause(game)) {
|
|
// Ability has started resolving. Fire event.
|
|
// Used for abilities counting the number of resolutions like Ashling the Pilgrim.
|
|
// TODO: called for mana abilities too, must be removed to safe place someday (see old place like StackAbility::resolve)
|
|
game.fireEvent(new GameEvent(GameEvent.EventType.RESOLVING_ABILITY, this.getOriginalId(), this, this.getControllerId()));
|
|
if (this instanceof TriggeredAbility) {
|
|
for (UUID modeId : this.getModes().getSelectedModes()) {
|
|
this.getModes().setActiveMode(modeId);
|
|
result = resolveMode(game);
|
|
}
|
|
} else {
|
|
result = resolveMode(game);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private boolean resolveMode(Game game) {
|
|
boolean result = true;
|
|
for (Effect effect : getEffects()) {
|
|
if (game.inCheckPlayableState() && !(effect instanceof ManaEffect)) {
|
|
continue; // Ignored non mana effects - see GameEvent.TAPPED_FOR_MANA
|
|
}
|
|
if (effect instanceof OneShotEffect) {
|
|
boolean effectResult = effect.apply(game, this);
|
|
result &= effectResult;
|
|
if (logger.isDebugEnabled()) {
|
|
if (!this.isManaAbility()) {
|
|
if (!effectResult) {
|
|
if (this.getSourceId() != null) {
|
|
MageObject mageObject = game.getObject(this.getSourceId());
|
|
if (mageObject != null) {
|
|
logger.debug("AbilityImpl.resolve: object: " + mageObject.getName());
|
|
}
|
|
}
|
|
logger.debug("AbilityImpl.resolve: effect returned false -" + effect.getText(this.getModes().getMode()));
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
game.addEffect((ContinuousEffect) effect, this);
|
|
}
|
|
|
|
/**
|
|
* All restrained trigger events are fired now. To restrain the
|
|
* events is mainly neccessary because of the movement of multiple
|
|
* object at once. If the event is fired directly as one object
|
|
* moved, other objects are not already in the correct zone to check
|
|
* for their effects. (e.g. Valakut, the Molten Pinnacle)
|
|
* <p>
|
|
* game.applyEffects() has to be done at least for every effect that
|
|
* moves cards/permanent between zones, or changes control of
|
|
* objects so Static effects work as intended if dependant from the
|
|
* moved objects zone it is in Otherwise for example were static
|
|
* abilities with replacement effects deactivated too late Example:
|
|
* {@link org.mage.test.cards.replacement.DryadMilitantTest#testDiesByDestroy testDiesByDestroy}
|
|
*/
|
|
game.processAction();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
@Override
|
|
public boolean activate(Game game, Set<MageIdentifier> allowedIdentifiers, boolean noMana) {
|
|
Player controller = game.getPlayer(this.getControllerId());
|
|
if (controller == null) {
|
|
return false;
|
|
}
|
|
game.applyEffects();
|
|
|
|
MageObject sourceObject = getSourceObject(game);
|
|
if (getSourceObjectZoneChangeCounter() == 0) {
|
|
setSourceObjectZoneChangeCounter(game.getState().getZoneChangeCounter(getSourceId()));
|
|
}
|
|
setSourcePermanentTransformCount(game);
|
|
|
|
// if ability can be cast for no mana, clear the mana costs now, because additional mana costs must be paid.
|
|
// For Flashback ability can be set X before, so the X costs have to be restored for the flashbacked ability
|
|
if (noMana) {
|
|
if (!this.getManaCostsToPay().getVariableCosts().isEmpty()) {
|
|
int xValue = CardUtil.getSourceCostsTag(game, this, "X", 0);
|
|
this.clearManaCostsToPay();
|
|
VariableManaCost xCosts = new VariableManaCost(VariableCostType.ADDITIONAL);
|
|
xCosts.setAmount(xValue, xValue, false);
|
|
addManaCostsToPay(xCosts);
|
|
} else {
|
|
this.clearManaCostsToPay();
|
|
}
|
|
}
|
|
|
|
// fused or spliced spells contain multiple abilities (e.g. fused, left, right)
|
|
// optional costs and cost modification must be applied only to the first/main ability
|
|
boolean isMainPartAbility = !CardUtil.isFusedPartAbility(this, game);
|
|
|
|
/* 20220908 - 601.2b
|
|
* If the player wishes to splice any cards onto the spell (see rule 702.45), they
|
|
* reveal those cards in their hand.
|
|
*/
|
|
if (isMainPartAbility && this.abilityType == AbilityType.SPELL) {
|
|
game.getContinuousEffects().applySpliceEffects(this, game);
|
|
}
|
|
|
|
// 20130201 - 601.2b
|
|
// If the spell has alternative or additional costs that will be paid as it's being cast such
|
|
// as buyback, kicker, or convoke costs (see rules 117.8 and 117.9), the player announces his
|
|
// or her intentions to pay any or all of those costs (see rule 601.2e).
|
|
// A player can't apply two alternative methods of casting or two alternative costs to a single spell.
|
|
if (isMainPartAbility) {
|
|
if (!activateAlternateOrAdditionalCosts(sourceObject, allowedIdentifiers, noMana, controller, game)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// 117.6. Some mana costs contain no mana symbols. This represents an unpayable cost. An ability can
|
|
// also have an unpayable cost if its cost is based on the mana cost of an object with no mana cost.
|
|
// Attempting to cast a spell or activate an ability that has an unpayable cost is a legal action.
|
|
// However, attempting to pay an unpayable cost is an illegal action.
|
|
//
|
|
// We apply this now, *AFTER* the user has made the choice to pay an alternative cost for the
|
|
// spell. You can also still cast a spell with an unplayable cost by... not paying it's mana cost.
|
|
//if (getAbilityType() == AbilityType.SPELL && getManaCostsToPay().isEmpty() && !noMana) {
|
|
// return false;
|
|
//}
|
|
if (getAbilityType() == AbilityType.SPELL && (getManaCostsToPay().isEmpty() && getCosts().isEmpty()) && !noMana) {
|
|
return false;
|
|
}
|
|
|
|
// 20121001 - 601.2b
|
|
// If the spell has a variable cost that will be paid as it's being cast (such as an {X} in
|
|
// its mana cost; see rule 107.3), the player announces the value of that variable.
|
|
VariableManaCost variableManaCost = handleManaXCosts(game, noMana, controller);
|
|
String announceString = handleOtherXCosts(game, controller);
|
|
|
|
// 601.2b If a cost that will be paid as the spell is being cast includes
|
|
// Phyrexian mana symbols, the player announces whether they intend to pay 2
|
|
// life or the corresponding colored mana cost for each of those symbols.
|
|
AbilityImpl.handlePhyrexianCosts(game, this, this, this.getManaCostsToPay());
|
|
|
|
// 20241022 - 601.2b
|
|
// Not yet included in 601.2b but this is where it will be
|
|
handleChooseCostTargets(game, controller);
|
|
|
|
/* 20130201 - 601.2b
|
|
* If the spell is modal the player announces the mode choice (see rule 700.2).
|
|
*/
|
|
// rules:
|
|
// You kick a spell as you cast it. You declare whether you're going to pay a kicker cost at the same
|
|
// time you'd choose a spell's mode, and then you actually pay it at the same time you pay the spell's mana cost.
|
|
// Kicking a spell is always optional.
|
|
if (!getModes().choose(game, this)) {
|
|
return false;
|
|
}
|
|
|
|
// apply mode costs if they have them
|
|
for (UUID modeId : this.getModes().getSelectedModes()) {
|
|
Cost cost = this.getModes().get(modeId).getCost();
|
|
if (cost instanceof ManaCost) {
|
|
this.addManaCostsToPay((ManaCost) cost.copy());
|
|
} else if (cost != null) {
|
|
this.costs.add(cost.copy());
|
|
}
|
|
}
|
|
|
|
// unit tests only: it allows to add targets/choices by two ways:
|
|
// 1. From cast/activate command params (process it here)
|
|
// 2. From single addTarget/setChoice, it's a preffered method for tests (process it in normal choose dialogs like human player)
|
|
if (controller.isTestsMode()) {
|
|
if (!controller.addTargets(this, game)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
for (UUID modeId : this.getModes().getSelectedModes()) {
|
|
this.getModes().setActiveMode(modeId);
|
|
|
|
// 20121001 - 601.2c
|
|
// 601.2c The player announces their choice of an appropriate player, object, or zone for
|
|
// each target the spell requires. A spell may require some targets only if an alternative or
|
|
// additional cost (such as a buyback or kicker cost), or a particular mode, was chosen for it;
|
|
// otherwise, the spell is cast as though it did not require those targets. If the spell has a
|
|
// variable number of targets, the player announces how many targets they will choose before
|
|
// they announce those targets. The same target can't be chosen multiple times for any one
|
|
// instance of the word "target" on the spell. However, if the spell uses the word "target" in
|
|
// multiple places, the same object, player, or zone can be chosen once for each instance of the
|
|
// word "target" (as long as it fits the targeting criteria). If any effects say that an object
|
|
// or player must be chosen as a target, the player chooses targets so that they obey the
|
|
// maximum possible number of such effects without violating any rules or effects that say that
|
|
// an object or player can't be chosen as a target. The chosen players, objects, and/or zones
|
|
// each become a target of that spell. (Any abilities that trigger when those players, objects,
|
|
// and/or zones become the target of a spell trigger at this point; they'll wait to be put on
|
|
// the stack until the spell has finished being cast.)
|
|
|
|
if (!this.getAbilityType().isTriggeredAbility()) { // triggered abilities check this already in playerImpl.triggerAbility
|
|
adjustTargets(game);
|
|
}
|
|
|
|
if (!getTargets().isEmpty()) {
|
|
Outcome outcome = getEffects().getOutcome(this);
|
|
|
|
// only activated abilities can be canceled by human user (not triggered)
|
|
// Note: ActivatedAbility does include SpellAbility & PlayLandAbility, but those should be able to be canceled too.
|
|
boolean canCancel = this instanceof ActivatedAbility && controller.isHuman();
|
|
if (!getTargets().chooseTargets(outcome, this.controllerId, this, noMana, game, canCancel)) {
|
|
// was canceled during targer selection
|
|
return false;
|
|
}
|
|
}
|
|
} // end modes
|
|
|
|
// 20220908 - 601.2e
|
|
// 601.2e The game checks to see if the proposed spell can legally be cast. If the proposed spell
|
|
// is illegal, the game returns to the moment before the casting of that spell was proposed
|
|
// (see rule 727, "Handling Illegal Actions").
|
|
if (this.getAbilityType() == AbilityType.SPELL) {
|
|
GameEvent castEvent = GameEvent.getEvent(GameEvent.EventType.CAST_SPELL_LATE, this.getId(), this, getControllerId());
|
|
castEvent.setZone(game.getState().getZone(CardUtil.getMainCardId(game, sourceId)));
|
|
if (game.replaceEvent(castEvent, this)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// this is a hack to prevent mana abilities with mana costs from causing endless loops - pay other costs first
|
|
if (this instanceof ActivatedManaAbilityImpl && !getCosts().pay(this, game, this, controllerId, noMana, null)) {
|
|
logger.debug("activate mana ability failed - non mana costs");
|
|
return false;
|
|
}
|
|
|
|
//20101001 - 601.2e
|
|
if (isMainPartAbility) {
|
|
adjustCosts(game); // still needed for CostAdjuster objects (to handle some types of dynamic costs)
|
|
game.getContinuousEffects().costModification(this, game);
|
|
}
|
|
|
|
UUID activatorId = controllerId;
|
|
if ((this instanceof ActivatedAbilityImpl) && ((ActivatedAbilityImpl) this).getActivatorId() != null) {
|
|
activatorId = ((ActivatedAbilityImpl) this).getActivatorId();
|
|
}
|
|
|
|
//20100716 - 601.2f (noMana is not used here, because mana costs were cleared for this ability before adding additional costs and applying cost modification effects)
|
|
if (!getManaCostsToPay().pay(this, game, this, activatorId, false, null)) {
|
|
return false; // cancel during mana payment
|
|
}
|
|
|
|
//20100716 - 601.2g
|
|
if (!getCosts().pay(this, game, this, activatorId, noMana, null)) {
|
|
logger.debug("activate failed - non mana costs");
|
|
return false;
|
|
}
|
|
// inform about x costs now, so canceled announcements are not shown in the log
|
|
if ((announceString != null) && (!announceString.equals(""))) {
|
|
game.informPlayers(announceString);
|
|
}
|
|
if (variableManaCost != null) {
|
|
int xValue = CardUtil.getSourceCostsTag(game, this, "X", 0);
|
|
game.informPlayers(controller.getLogName() + " announces a value of " + xValue + " for " + variableManaCost.getText()
|
|
+ CardUtil.getSourceLogName(game, this));
|
|
}
|
|
activated = true;
|
|
return true;
|
|
}
|
|
|
|
@Override
|
|
public boolean isActivated() {
|
|
return activated;
|
|
}
|
|
|
|
/**
|
|
* @return true if choices for the activation were made (can be to activate with the regular cost)
|
|
*/
|
|
@Override
|
|
public boolean activateAlternateOrAdditionalCosts(MageObject sourceObject, Set<MageIdentifier> allowedIdentifiers, boolean noMana, Player controller, Game game) {
|
|
boolean canUseAlternativeCost = true;
|
|
boolean canUseAdditionalCost = true;
|
|
|
|
if (this instanceof SpellAbility) {
|
|
// A player can't apply two alternative methods of casting or two alternative costs to a single spell.
|
|
switch (((SpellAbility) this).getSpellAbilityCastMode()) {
|
|
case FLASHBACK:
|
|
case MADNESS:
|
|
case TRANSFORMED:
|
|
case DISTURB:
|
|
case MORE_THAN_MEETS_THE_EYE:
|
|
case BESTOW:
|
|
case MORPH:
|
|
case DISGUISE:
|
|
case PLOT:
|
|
// from Snapcaster Mage:
|
|
// If you cast a spell from a graveyard using its flashback ability, you can't pay other alternative costs
|
|
// (such as that of Foil). (2018-12-07)
|
|
canUseAlternativeCost = false;
|
|
// You may pay any optional additional costs the spell has, such as kicker costs. You must pay any
|
|
// mandatory additional costs the spell has, such as that of Tormenting Voice. (2018-12-07)
|
|
canUseAdditionalCost = true;
|
|
break;
|
|
case PROTOTYPE:
|
|
// Notably, casting a spell as a prototype does not count as paying an alternative cost.
|
|
// https://magic.wizards.com/en/news/feature/comprehensive-rules-changes
|
|
case NORMAL:
|
|
canUseAlternativeCost = true;
|
|
canUseAdditionalCost = true;
|
|
break;
|
|
default:
|
|
throw new IllegalArgumentException("Unknown ability cast mode: " + ((SpellAbility) this).getSpellAbilityCastMode());
|
|
}
|
|
}
|
|
if (this.getAbilityType() == AbilityType.SPELL && this instanceof SpellAbility
|
|
// 117.9a Only one alternative cost can be applied to any one spell as it's being cast.
|
|
// So an alternate spell ability can't be paid with Omniscience
|
|
&& ((SpellAbility) this).getSpellAbilityType() == SpellAbilityType.BASE_ALTERNATE) {
|
|
canUseAlternativeCost = false;
|
|
}
|
|
|
|
// TODO: Why the check for permanent?
|
|
if (sourceObject == null || sourceObject instanceof Permanent) {
|
|
return true;
|
|
}
|
|
|
|
// it's important to apply alternative cost first
|
|
// example: Omniscience gives free mana as alternative, but Entwine ability adds {2} as additional
|
|
Abilities<Ability> abilities = CardUtil.getAbilities(sourceObject, game);
|
|
|
|
// 1. ALTERNATIVE COSTS
|
|
// Collect all possible alternatives costs:
|
|
List<AlternativeSourceCosts> possibleAlternatives = new ArrayList<>();
|
|
for (Ability ability : abilities) {
|
|
// if cast for noMana no Alternative costs are allowed
|
|
if (canUseAlternativeCost && !noMana && ability instanceof AlternativeSourceCosts) {
|
|
AlternativeSourceCosts alternativeSpellCosts = (AlternativeSourceCosts) ability;
|
|
if (alternativeSpellCosts.isAvailable(this, game)
|
|
&& alternativeSpellCosts.canActivateAlternativeCostsNow(this, game)
|
|
&& (allowedIdentifiers.contains(MageIdentifier.Default) || allowedIdentifiers.contains(ability.getIdentifier()))) {
|
|
possibleAlternatives.add(alternativeSpellCosts);
|
|
}
|
|
}
|
|
}
|
|
// controller specific alternate spell costs
|
|
if (canUseAlternativeCost && !noMana) {
|
|
for (AlternativeSourceCosts alternativeSourceCosts : controller.getAlternativeSourceCosts()) {
|
|
if (alternativeSourceCosts.isAvailable(this, game)
|
|
&& alternativeSourceCosts.canActivateAlternativeCostsNow(this, game)
|
|
&& (allowedIdentifiers.contains(MageIdentifier.Default) || allowedIdentifiers.contains(alternativeSourceCosts.getIdentifier()))) {
|
|
possibleAlternatives.add(alternativeSourceCosts);
|
|
}
|
|
}
|
|
}
|
|
Player player = game.getPlayer(getControllerId());
|
|
if (player == null) {
|
|
// No controller to activate.
|
|
return false;
|
|
}
|
|
Choice choice = new ChoiceImpl(false); // not required, cancelling will cancel the cast (as you could do once in the pay mana mode).
|
|
choice.setSubMessage("for casting " + CardUtil.getSourceLogName(game, "", this, "", ""));
|
|
AlternativeSourceCosts alternativeChosen = null;
|
|
if (!possibleAlternatives.isEmpty()) {
|
|
// At least one alternative cost is available.
|
|
// We open a menu for the player to choose up to one.
|
|
boolean mustChooseAlternative = !(allowedIdentifiers.contains(MageIdentifier.Default) || allowedIdentifiers.contains(getIdentifier()));
|
|
choice.setMessage(
|
|
mustChooseAlternative
|
|
? "Choose an alternative cost"
|
|
: "You may choose an alternative cost"
|
|
);
|
|
Map<String, Integer> sort = new LinkedHashMap<>();
|
|
int i;
|
|
for (i = 0; i < possibleAlternatives.size(); i++) {
|
|
String key = Integer.toString(i + 1);
|
|
sort.put(key, i);
|
|
AlternativeSourceCosts alternative = possibleAlternatives.get(i);
|
|
MageObject object = alternative.getSourceObject(game);
|
|
choice.withItem(
|
|
key,
|
|
possibleAlternatives.get(i).getAlternativeCostText(this, game),
|
|
i,
|
|
object != null ? ChoiceHintType.GAME_OBJECT : null,
|
|
object != null ? object.getId().toString() : null
|
|
);
|
|
}
|
|
if (!mustChooseAlternative) {
|
|
// add the non-alternative cast as the last option.
|
|
String key = Integer.toString(i + 1);
|
|
sort.put(key, i);
|
|
choice.withItem(
|
|
key,
|
|
"Cast with no alternative cost: " + this.getManaCosts().getText(),
|
|
i,
|
|
ChoiceHintType.GAME_OBJECT,
|
|
sourceObject.getId().toString()
|
|
);
|
|
}
|
|
if (!player.choose(Outcome.Benefit, choice, game)) {
|
|
return false;
|
|
}
|
|
String choiceKey = choice.getChoiceKey();
|
|
if (sort.containsKey(choiceKey)) {
|
|
int choiceNumber = sort.get(choiceKey);
|
|
if (choiceNumber < possibleAlternatives.size()) {
|
|
alternativeChosen = possibleAlternatives.get(choiceNumber);
|
|
}
|
|
}
|
|
}
|
|
if (alternativeChosen != null) {
|
|
alternativeChosen.activateAlternativeCosts(this, game);
|
|
}
|
|
// 2. ADDITIONAL COST
|
|
for (Ability ability : abilities) {
|
|
if (canUseAdditionalCost && ability instanceof OptionalAdditionalSourceCosts) {
|
|
((OptionalAdditionalSourceCosts) ability).addOptionalAdditionalCosts(this, game);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Handles the setting of non mana X costs
|
|
*
|
|
* @param controller
|
|
* @param game
|
|
* @return announce message
|
|
*/
|
|
protected String handleOtherXCosts(Game game, Player controller) {
|
|
StringBuilder announceString = new StringBuilder();
|
|
for (VariableCost variableCost : this.getCosts().getVariableCosts()) {
|
|
if (!(variableCost instanceof VariableManaCost) && !((Cost) variableCost).isPaid()) {
|
|
int xValue = variableCost.announceXValue(this, game);
|
|
Cost fixedCost = variableCost.getFixedCostsFromAnnouncedValue(xValue);
|
|
addCost(fixedCost);
|
|
// set the xcosts to paid
|
|
variableCost.setAmount(xValue, xValue, false);
|
|
((Cost) variableCost).setPaid();
|
|
String message = controller.getLogName() + " announces a value of " + xValue + " (" + variableCost.getActionText() + ')'
|
|
+ CardUtil.getSourceLogName(game, this);
|
|
announceString.append(message);
|
|
setCostsTag("X", xValue);
|
|
}
|
|
}
|
|
return announceString.toString();
|
|
}
|
|
|
|
/**
|
|
* Prepare Phyrexian costs (choose life to pay instead mana)
|
|
* Must be called on cast announce before any cost modifications
|
|
*
|
|
* @param abilityToPay paying ability (will receive life cost)
|
|
* @param manaCostsToPay paying cost (will remove P and replace it by mana or nothing)
|
|
*/
|
|
public static void handlePhyrexianCosts(Game game, Ability source, Ability abilityToPay, ManaCosts manaCostsToPay) {
|
|
Player controller = game.getPlayer(source.getControllerId());
|
|
if (controller == null) {
|
|
return;
|
|
}
|
|
|
|
Iterator<ManaCost> costIterator = manaCostsToPay.iterator();
|
|
while (costIterator.hasNext()) {
|
|
ManaCost cost = costIterator.next();
|
|
if (!cost.isPhyrexian()) {
|
|
continue;
|
|
}
|
|
PayLifeCost payLifeCost = new PayLifeCost(2);
|
|
if (payLifeCost.canPay(abilityToPay, source, controller.getId(), game)
|
|
&& controller.chooseUse(Outcome.LoseLife, "Pay 2 life instead of " + cost.getText().replace("/P", "")
|
|
+ " (phyrexian cost)?", source, game)) {
|
|
costIterator.remove();
|
|
abilityToPay.addCost(payLifeCost);
|
|
manaCostsToPay.incrPhyrexianPaid(); // mark it as real phyrexian pay, e.g. for planeswalkers with Compleated ability
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Prepare and pay Phyrexian style effects like replace mana by life
|
|
* Must be called after original Phyrexian mana processing and after cost modifications, e.g. on payment
|
|
*
|
|
* @param abilityToPay paying ability (will receive life cost)
|
|
* @param manaCostsToPay paying cost (will replace mana by nothing)
|
|
*/
|
|
public static void handlePhyrexianLikeEffects(Game game, Ability source, Ability abilityToPay, ManaCosts manaCostsToPay) {
|
|
Player controller = game.getPlayer(source.getControllerId());
|
|
if (controller == null) {
|
|
return;
|
|
}
|
|
|
|
// If a cost contains a mana symbol that may be paid in multiple ways, such as {B/R}, {B/P}, or {2/B},
|
|
// you choose how you'll pay it before you do so. If you choose to pay {B} this way, K'rrik's ability allows
|
|
// you to pay life rather than pay that mana.
|
|
// (2019-08-23)
|
|
FilterMana phyrexianColors = controller.getPhyrexianColors();
|
|
if (controller.getPhyrexianColors() == null) {
|
|
return;
|
|
}
|
|
Iterator<ManaCost> costIterator = manaCostsToPay.iterator();
|
|
while (costIterator.hasNext()) {
|
|
ManaCost cost = costIterator.next();
|
|
Mana mana = cost.getMana();
|
|
if ((!phyrexianColors.isWhite() || mana.getWhite() <= 0)
|
|
&& (!phyrexianColors.isBlue() || mana.getBlue() <= 0)
|
|
&& (!phyrexianColors.isBlack() || mana.getBlack() <= 0)
|
|
&& (!phyrexianColors.isRed() || mana.getRed() <= 0)
|
|
&& (!phyrexianColors.isGreen() || mana.getGreen() <= 0)) {
|
|
continue;
|
|
}
|
|
PayLifeCost payLifeCost = new PayLifeCost(2);
|
|
if (payLifeCost.canPay(abilityToPay, source, controller.getId(), game)
|
|
&& controller.chooseUse(Outcome.LoseLife, "Pay 2 life instead of " + cost.getText().replace("/P", "")
|
|
+ " (pay life cost)?", source, game)) {
|
|
if (payLifeCost.pay(abilityToPay, game, source, controller.getId(), false, null)) {
|
|
costIterator.remove();
|
|
abilityToPay.addCost(payLifeCost);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 601.2b Choose targets for costs that have to be chosen early.
|
|
*/
|
|
private void handleChooseCostTargets(Game game, Player controller) {
|
|
for (Cost cost : getCosts()) {
|
|
if (cost instanceof EarlyTargetCost && cost.getTargets().isEmpty()) {
|
|
((EarlyTargetCost) cost).chooseTarget(game, this, controller);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handles X mana costs and sets manaCostsToPay.
|
|
*
|
|
* @param game
|
|
* @param noMana
|
|
* @param controller
|
|
* @return variableManaCost for posting to log later
|
|
*/
|
|
protected VariableManaCost handleManaXCosts(Game game, boolean noMana, Player controller) {
|
|
// 20210723 - 601.2b
|
|
// If the spell has alternative or additional costs that will
|
|
// be paid as it's being cast such as buyback or kicker costs (see rules 118.8 and 118.9),
|
|
// the player announces their intentions to pay any or all of those costs (see rule 601.2f).
|
|
// A player can't apply two alternative methods of casting or two alternative costs to a
|
|
// single spell. If the spell has a variable cost that will be paid as it's being cast
|
|
// (such as an {X} in its mana cost; see rule 107.3), the player announces the value of that
|
|
// variable. If the value of that variable is defined in the text of the spell by a choice
|
|
// that player would make later in the announcement or resolution of the spell, that player
|
|
// makes that choice at this time instead of that later time.
|
|
|
|
// TODO: Handle announcing other variable costs here like: RemoveVariableCountersSourceCost
|
|
VariableManaCost variableManaCost = null;
|
|
for (ManaCost cost : getManaCostsToPay()) {
|
|
if (cost instanceof VariableManaCost) {
|
|
if (variableManaCost == null) {
|
|
variableManaCost = (VariableManaCost) cost;
|
|
} else {
|
|
// only one VariableManCost per spell (or is it possible to have more?)
|
|
logger.error("Variable mana cost allowes only in one instance per ability: " + this);
|
|
}
|
|
}
|
|
}
|
|
if (variableManaCost != null) {
|
|
if (!variableManaCost.isPaid()) { // should only happen for human players
|
|
int xValue;
|
|
if (!noMana || variableManaCost.getCostType().canUseAnnounceOnFreeCast()) {
|
|
xValue = controller.announceXMana(variableManaCost.getMinX(), variableManaCost.getMaxX(),
|
|
"Announce the value for " + variableManaCost.getText(), game, this);
|
|
int amountMana = xValue * variableManaCost.getXInstancesCount();
|
|
StringBuilder manaString = threadLocalBuilder.get();
|
|
if (variableManaCost.getFilter() == null || variableManaCost.getFilter().isGeneric()) {
|
|
manaString.append('{').append(amountMana).append('}');
|
|
} else {
|
|
String manaSymbol = null;
|
|
if (variableManaCost.getFilter().isBlack()) {
|
|
if (variableManaCost.getFilter().isRed()) {
|
|
manaSymbol = "B/R";
|
|
} else {
|
|
manaSymbol = "B";
|
|
}
|
|
} else if (variableManaCost.getFilter().isRed()) {
|
|
manaSymbol = "R";
|
|
} else if (variableManaCost.getFilter().isBlue()) {
|
|
manaSymbol = "U";
|
|
} else if (variableManaCost.getFilter().isGreen()) {
|
|
manaSymbol = "G";
|
|
} else if (variableManaCost.getFilter().isWhite()) {
|
|
manaSymbol = "W";
|
|
}
|
|
if (manaSymbol == null) {
|
|
throw new UnsupportedOperationException("ManaFilter is not supported: " + this);
|
|
}
|
|
for (int i = 0; i < amountMana; i++) {
|
|
manaString.append('{').append(manaSymbol).append('}');
|
|
}
|
|
}
|
|
addManaCostsToPay(new ManaCostsImpl<>(manaString.toString()));
|
|
getManaCostsToPay().setX(xValue, amountMana);
|
|
setCostsTag("X", xValue);
|
|
}
|
|
variableManaCost.setPaid();
|
|
}
|
|
}
|
|
|
|
return variableManaCost;
|
|
}
|
|
|
|
// called at end of turn for each Permanent
|
|
@Override
|
|
public void reset(Game game) {
|
|
}
|
|
|
|
@Override
|
|
public boolean checkIfClause(Game game) {
|
|
return true;
|
|
}
|
|
|
|
@Override
|
|
public UUID getControllerId() {
|
|
return controllerId;
|
|
}
|
|
|
|
@Override
|
|
public UUID getControllerOrOwnerId() {
|
|
return getControllerId();
|
|
}
|
|
|
|
@Override
|
|
public void setControllerId(UUID controllerId) {
|
|
this.controllerId = controllerId;
|
|
for (Watcher watcher : getWatchers()) {
|
|
watcher.setControllerId(controllerId);
|
|
}
|
|
|
|
if (subAbilities != null) {
|
|
for (Ability subAbility : subAbilities) {
|
|
subAbility.setControllerId(controllerId);
|
|
}
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public UUID getSourceId() {
|
|
return sourceId;
|
|
}
|
|
|
|
@Override
|
|
public void setSourceId(UUID sourceId) {
|
|
if (this.sourceId == null) {
|
|
this.sourceId = sourceId;
|
|
} else if (!(this instanceof MageSingleton)) {
|
|
this.sourceId = sourceId;
|
|
}
|
|
if (subAbilities != null) {
|
|
for (Ability subAbility : subAbilities) {
|
|
subAbility.setSourceId(sourceId);
|
|
}
|
|
}
|
|
for (Watcher watcher : getWatchers()) {
|
|
watcher.setSourceId(sourceId);
|
|
}
|
|
|
|
}
|
|
|
|
@Override
|
|
public Costs<Cost> getCosts() {
|
|
return costs;
|
|
}
|
|
|
|
@Override
|
|
public ManaCosts<ManaCost> getManaCosts() {
|
|
return manaCosts;
|
|
}
|
|
|
|
/**
|
|
* Should be used by
|
|
* {@link mage.abilities.effects.CostModificationEffect cost modification effects}
|
|
* to manipulate what is actually paid before resolution.
|
|
*
|
|
* @return
|
|
*/
|
|
@Override
|
|
public ManaCosts<ManaCost> getManaCostsToPay() {
|
|
return manaCostsToPay;
|
|
}
|
|
|
|
@Override
|
|
public Map<String, Object> getCostsTagMap() {
|
|
return costsTagMap;
|
|
}
|
|
|
|
public void setCostsTag(String tag, Object value) {
|
|
if (costsTagMap == null) {
|
|
costsTagMap = new HashMap<>();
|
|
}
|
|
costsTagMap.put(tag, value);
|
|
}
|
|
|
|
@Override
|
|
public Effects getEffects() {
|
|
return getModes().getMode().getEffects();
|
|
}
|
|
|
|
@Override
|
|
public Effects getAllEffects() {
|
|
Effects allEffects = new Effects();
|
|
for (Mode mode : getModes().values()) {
|
|
allEffects.addAll(mode.getEffects());
|
|
}
|
|
return allEffects;
|
|
}
|
|
|
|
@Override
|
|
public Effects getEffects(Game game, EffectType effectType) {
|
|
Effects typedEffects = new Effects();
|
|
for (Effect effect : getEffects()) {
|
|
if (effect.getEffectType() == effectType) {
|
|
typedEffects.add(effect);
|
|
}
|
|
}
|
|
return typedEffects;
|
|
}
|
|
|
|
@Override
|
|
public Zone getZone() {
|
|
return zone;
|
|
}
|
|
|
|
@Override
|
|
public List<Watcher> getWatchers() {
|
|
return watchers;
|
|
}
|
|
|
|
@Override
|
|
public void addWatcher(Watcher watcher) {
|
|
watcher.setSourceId(this.sourceId);
|
|
watcher.setControllerId(this.controllerId);
|
|
getWatchers().add(watcher);
|
|
}
|
|
|
|
@Override
|
|
public List<Ability> getSubAbilities() {
|
|
if (subAbilities != null) {
|
|
return subAbilities;
|
|
} else {
|
|
return emptyAbilities;
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void addSubAbility(Ability ability) {
|
|
if (subAbilities == null) {
|
|
subAbilities = new ArrayList<>();
|
|
}
|
|
ability.setSourceId(this.sourceId);
|
|
ability.setControllerId(this.controllerId);
|
|
subAbilities.add(ability);
|
|
}
|
|
|
|
@Override
|
|
public boolean isUsesStack() {
|
|
return usesStack;
|
|
}
|
|
|
|
public void appendToRule(String appendToRule) {
|
|
this.appendToRule = appendToRule;
|
|
}
|
|
|
|
@Override
|
|
public String getRule() {
|
|
return getRule(false);
|
|
}
|
|
|
|
@Override
|
|
public String getRule(boolean all) {
|
|
StringBuilder sbRule = threadLocalBuilder.get();
|
|
if (all || this.abilityType != AbilityType.SPELL) { // TODO: Why the override for non-spells?
|
|
if (!getManaCosts().isEmpty()) {
|
|
sbRule.append(getManaCosts().getText());
|
|
}
|
|
if (!getCosts().isEmpty()) {
|
|
if (sbRule.length() > 0) {
|
|
sbRule.append(", ");
|
|
}
|
|
sbRule.append(getCosts().getText());
|
|
}
|
|
if (sbRule.length() > 0) {
|
|
sbRule.append(": ");
|
|
}
|
|
}
|
|
|
|
String ruleStart = sbRule.toString();
|
|
String text = getModes().getText();
|
|
String rule;
|
|
if (!text.isEmpty()) {
|
|
if (ruleStart.length() > 1) {
|
|
String end = ruleStart.substring(ruleStart.length() - 2).trim();
|
|
if (end.isEmpty() || end.equals(":") || end.equals(".")) {
|
|
rule = ruleStart + CardUtil.getTextWithFirstCharUpperCase(text);
|
|
} else {
|
|
rule = ruleStart + text;
|
|
}
|
|
} else {
|
|
rule = ruleStart + text;
|
|
}
|
|
} else {
|
|
rule = ruleStart;
|
|
}
|
|
String prefix;
|
|
if (this instanceof TriggeredAbility || this instanceof EntersBattlefieldAbility) {
|
|
prefix = null;
|
|
} else if (abilityWord != null) {
|
|
prefix = abilityWord.formatWord();
|
|
} else if (flavorWord != null) {
|
|
prefix = CardUtil.italicizeWithEmDash(flavorWord);
|
|
} else {
|
|
prefix = null;
|
|
}
|
|
if (prefix != null) {
|
|
rule = prefix + CardUtil.getTextWithFirstCharUpperCase(rule);
|
|
}
|
|
if (appendToRule != null) {
|
|
rule = rule.concat(appendToRule);
|
|
}
|
|
return rule;
|
|
}
|
|
|
|
@Override
|
|
public String getRule(String source) {
|
|
return formatRule(getRule(), source);
|
|
}
|
|
|
|
protected String formatRule(String rule, String source) {
|
|
String replace = rule;
|
|
if (rule != null && source != null && !source.isEmpty()) {
|
|
replace = rule.replace("{this}", source);
|
|
}
|
|
return replace;
|
|
}
|
|
|
|
@Override
|
|
public void addCost(Cost cost) {
|
|
if (cost == null) {
|
|
return;
|
|
}
|
|
if (cost instanceof Costs) {
|
|
// as list of costs
|
|
Costs<Cost> list = (Costs<Cost>) cost;
|
|
for (Cost single : list) {
|
|
addCost(single);
|
|
}
|
|
} else {
|
|
// as single cost
|
|
if (cost instanceof ManaCost) {
|
|
manaCosts.add((ManaCost) cost);
|
|
manaCostsToPay.add((ManaCost) cost);
|
|
} else {
|
|
costs.add(cost);
|
|
}
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void addManaCostsToPay(ManaCost manaCost) {
|
|
if (manaCost == null) {
|
|
return;
|
|
}
|
|
if (manaCost instanceof ManaCosts) {
|
|
manaCostsToPay.addAll((ManaCosts) manaCost);
|
|
} else {
|
|
manaCostsToPay.add(manaCost);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void addEffect(Effect effect) {
|
|
if (effect != null) {
|
|
getEffects().add(effect);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void addTarget(Target target) {
|
|
// verify check
|
|
if (target instanceof TargetCardInLibrary
|
|
|| (target instanceof TargetCard && target.getZone().equals(Zone.LIBRARY))) {
|
|
throw new IllegalArgumentException("Wrong usage of TargetCardInLibrary - you must use it with SearchLibrary only");
|
|
}
|
|
|
|
if (target != null) {
|
|
getTargets().add(target);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public Targets getTargets() {
|
|
if (getModes().getMode() != null) {
|
|
return getModes().getMode().getTargets();
|
|
}
|
|
return new Targets().withReadOnly();
|
|
}
|
|
|
|
@Override
|
|
public Targets getAllSelectedTargets() {
|
|
Targets res = new Targets();
|
|
for (UUID modeId : this.getModes().getSelectedModes()) {
|
|
Mode mode = this.getModes().get(modeId);
|
|
if (mode != null) {
|
|
res.addAll(mode.getTargets());
|
|
}
|
|
}
|
|
return res.withReadOnly();
|
|
}
|
|
|
|
@Override
|
|
public UUID getFirstTarget() {
|
|
return getTargets().getFirstTarget();
|
|
}
|
|
|
|
@Override
|
|
public boolean isModal() {
|
|
return getModes().size() > 1;
|
|
}
|
|
|
|
@Override
|
|
public void addMode(Mode mode) {
|
|
getModes().addMode(mode);
|
|
|
|
// runtime check: modes must have good settings
|
|
int currentMin = getModes().getMinModes();
|
|
int currentMax = getModes().getMaxModes(null, null);
|
|
boolean isFine = true;
|
|
if (currentMin < 0 || currentMax < 0) {
|
|
isFine = false;
|
|
}
|
|
if (currentMin > 0 && currentMin > currentMax) {
|
|
isFine = false;
|
|
}
|
|
if (!isFine) {
|
|
throw new IllegalArgumentException(String.format("Wrong code usage: you must setup correct min and max modes (%d, %d) for %s",
|
|
currentMin, currentMax, this));
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public Modes getModes() {
|
|
return modes;
|
|
}
|
|
|
|
@Override
|
|
public boolean canChooseTarget(Game game, UUID playerId) {
|
|
return canChooseTargetAbility(this, getModes(), game, playerId);
|
|
}
|
|
|
|
protected static boolean canChooseTargetAbility(Ability ability, Modes modes, Game game, UUID controllerId) {
|
|
int found = 0;
|
|
for (Mode mode : modes.values()) {
|
|
boolean validTargets = true;
|
|
for (Target target : mode.getTargets()) {
|
|
UUID abilityControllerId = controllerId;
|
|
if (target.getTargetController() != null) {
|
|
abilityControllerId = target.getTargetController();
|
|
}
|
|
if (!target.canChoose(abilityControllerId, ability, game)) {
|
|
validTargets = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (validTargets) {
|
|
found++;
|
|
if (modes.isMayChooseSameModeMoreThanOnce()) {
|
|
return true;
|
|
}
|
|
if (found >= modes.getMinModes()) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
@Override
|
|
public boolean isInUseableZone(Game game, MageObject sourceObject, GameEvent event) {
|
|
if (!this.hasSourceObjectAbility(game, sourceObject, event)) {
|
|
return false;
|
|
}
|
|
|
|
// workaround for singleton abilities like Flying
|
|
UUID affectedSourceId = getRealSourceObjectId(this, sourceObject);
|
|
MageObject affectedSourceObject = game.getObject(affectedSourceId);
|
|
|
|
// global game effects (works all the time and don't have sourceId, example: FinalityCounterEffect)
|
|
if (affectedSourceId == null) {
|
|
return true;
|
|
}
|
|
|
|
// emblems/dungeons/planes effects (works all the time, store in command zone)
|
|
if (zone == Zone.COMMAND) {
|
|
if (affectedSourceObject instanceof Emblem || affectedSourceObject instanceof Dungeon || affectedSourceObject instanceof Plane) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// on entering permanents must use static abilities like it already on battlefield
|
|
// example: Tatterkite enters without counters from Mikaeus, the Unhallowed
|
|
if (game.getPermanentEntering(affectedSourceId) != null && zone == Zone.BATTLEFIELD) {
|
|
return true;
|
|
}
|
|
|
|
// 603.10.
|
|
// Normally, objects that exist immediately after an event are checked to see if the event matched
|
|
// any trigger conditions, and continuous effects that exist at that time are used to determine what the
|
|
// trigger conditions are and what the objects involved in the event look like.
|
|
// ...
|
|
Zone affectedObjectZone = game.getState().getZone(affectedSourceId);
|
|
|
|
// 603.10.
|
|
// ...
|
|
// However, some triggered abilities are exceptions to this rule; the game “looks back in time” to determine
|
|
// if those abilities trigger, using the existence of those abilities and the appearance of objects
|
|
// immediately prior to the event. The list of exceptions is as follows:
|
|
|
|
// 603.10a
|
|
// Some zone-change triggers look back in time. These are leaves-the-battlefield abilities,
|
|
// abilities that trigger when a card leaves a graveyard, and abilities that trigger when an object that all
|
|
// players can see is put into a hand or library.
|
|
|
|
// TODO: research use cases and implement shared logic with "looking zone" instead LKI only
|
|
// in most use cases it's already supported by event (example: saved permanent object in event's target)
|
|
// [x] 603.10a leaves-the-battlefield abilities and other
|
|
// [ ] 603.10b Abilities that trigger when a permanent phases out look back in time.
|
|
// [ ] 603.10c Abilities that trigger specifically when an object becomes unattached look back in time.
|
|
// [ ] 603.10d Abilities that trigger when a player loses control of an object look back in time.
|
|
// [ ] 603.10e Abilities that trigger when a spell is countered look back in time.
|
|
// [ ] 603.10f Abilities that trigger when a player loses the game look back in time.
|
|
// [ ] 603.10g Abilities that trigger when a player planeswalks away from a plane look back in time.
|
|
|
|
if (event == null) {
|
|
// state base triggers - use only actual state
|
|
} else {
|
|
// event triggers and continues effects - can look back in time
|
|
if (isAbilityCanLookBackInTime(this) && isEventCanLookBackInTime(event)) {
|
|
// 603.10a leaves-the-battlefield
|
|
if (game.checkShortLivingLKI(affectedSourceId, Zone.BATTLEFIELD)) {
|
|
affectedObjectZone = Zone.BATTLEFIELD;
|
|
}
|
|
// 603.10a leaves a graveyard
|
|
// TODO: need tests
|
|
if (game.checkShortLivingLKI(affectedSourceId, Zone.GRAVEYARD)) {
|
|
affectedObjectZone = Zone.GRAVEYARD;
|
|
}
|
|
// 603.10a put into a hand or library
|
|
// TODO: need tests and implementation?
|
|
}
|
|
}
|
|
|
|
return zone.match(affectedObjectZone);
|
|
}
|
|
|
|
public static boolean isAbilityCanLookBackInTime(Ability ability) {
|
|
if (ability instanceof StaticAbility) {
|
|
return true;
|
|
}
|
|
if (ability instanceof TriggeredAbility) {
|
|
return ((TriggeredAbility) ability).isLeavesTheBattlefieldTrigger();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
public static boolean isEventCanLookBackInTime(GameEvent event) {
|
|
if (event == null) {
|
|
return false;
|
|
}
|
|
|
|
List<GameEvent> allEvents = new ArrayList<>();
|
|
if (event instanceof BatchEvent) {
|
|
allEvents.addAll(((BatchEvent) event).getEvents());
|
|
} else {
|
|
allEvents.add(event);
|
|
}
|
|
|
|
return allEvents.stream().anyMatch(e -> {
|
|
// TODO: need sync code with TriggeredAbilityImpl.isInUseableZone
|
|
// TODO: add more events with zone change logic (or make it event's param)?
|
|
// need research: is it ability's or event's task?
|
|
// - ability's task: code like ability.setLookBackInTime
|
|
// - event's task: code like current switch
|
|
// TODO: alternative solution: replace check by source.isLeavesTheBattlefieldTrigger?
|
|
switch (e.getType()) {
|
|
case DESTROYED_PERMANENT:
|
|
case EXPLOITED_CREATURE:
|
|
case SACRIFICED_PERMANENT:
|
|
return true;
|
|
case ZONE_CHANGE:
|
|
return ((ZoneChangeEvent) e).getFromZone() == Zone.BATTLEFIELD;
|
|
default:
|
|
return false;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Find real source object id from any ability (real and singleton)
|
|
*/
|
|
protected static UUID getRealSourceObjectId(Ability sourceAbility, MageObject sourceObject) {
|
|
// In singleton abilities like Flying we can't rely on ability's source because it's init only once in continuous effects
|
|
// so will use the sourceId of the object itself that came as a parameter if it is not null
|
|
if (sourceAbility instanceof MageSingleton && sourceObject != null) {
|
|
return sourceObject.getId();
|
|
} else {
|
|
return sourceAbility.getSourceId();
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public final boolean hasSourceObjectAbility(Game game, MageObject sourceObject, GameEvent event) {
|
|
MageObject object = sourceObject;
|
|
if (object == null) {
|
|
object = game.getPermanentEntering(getSourceId());
|
|
if (object == null) {
|
|
object = game.getObject(getSourceId());
|
|
}
|
|
}
|
|
|
|
if (object == null) {
|
|
// global replacement and other continues effects can be without source, but active (must return true all time)
|
|
return true;
|
|
}
|
|
|
|
if (!object.hasAbility(this, game)) {
|
|
return false;
|
|
}
|
|
|
|
// phase in/out support
|
|
if (object instanceof Permanent) {
|
|
return ((Permanent) object).isPhasedIn() || this.getWorksPhasedOut();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
@Override
|
|
public String toString() {
|
|
return getRule();
|
|
}
|
|
|
|
@Override
|
|
public boolean getRuleAtTheTop() {
|
|
return ruleAtTheTop;
|
|
}
|
|
|
|
@Override
|
|
public Ability setRuleAtTheTop(boolean ruleAtTheTop) {
|
|
if (!(this instanceof MageSingleton)) {
|
|
this.ruleAtTheTop = ruleAtTheTop;
|
|
}
|
|
return this;
|
|
}
|
|
|
|
@Override
|
|
public boolean getRuleVisible() {
|
|
return ruleVisible;
|
|
}
|
|
|
|
@Override
|
|
public Ability setRuleVisible(boolean ruleVisible) {
|
|
if (!(this instanceof MageSingleton)) { // prevent to change singletons
|
|
this.ruleVisible = ruleVisible;
|
|
}
|
|
return this;
|
|
}
|
|
|
|
@Override
|
|
public boolean getAdditionalCostsRuleVisible() {
|
|
return ruleAdditionalCostsVisible;
|
|
}
|
|
|
|
@Override
|
|
public void setAdditionalCostsRuleVisible(boolean ruleAdditionalCostsVisible) {
|
|
this.ruleAdditionalCostsVisible = ruleAdditionalCostsVisible;
|
|
}
|
|
|
|
@Override
|
|
public UUID getOriginalId() {
|
|
return this.originalId;
|
|
}
|
|
|
|
@Override
|
|
public Ability setAbilityWord(AbilityWord abilityWord) {
|
|
this.abilityWord = abilityWord;
|
|
return this;
|
|
}
|
|
|
|
@Override
|
|
public Ability withFlavorWord(String flavorWord) {
|
|
this.flavorWord = flavorWord;
|
|
return this;
|
|
}
|
|
|
|
@Override
|
|
public Ability withFirstModeFlavorWord(String flavorWord) {
|
|
this.modes.getMode().withFlavorWord(flavorWord);
|
|
return this;
|
|
}
|
|
|
|
@Override
|
|
public Ability withFirstModeCost(Cost cost) {
|
|
this.modes.getMode().withCost(cost);
|
|
return this;
|
|
}
|
|
|
|
@Override
|
|
public String getGameLogMessage(Game game) {
|
|
if (game.isSimulation()) {
|
|
return "";
|
|
}
|
|
MageObject object = game.getObject(this.sourceId);
|
|
if (object == null) { // e.g. sacrificed token
|
|
logger.warn("Could get no object: " + this);
|
|
}
|
|
return " activates: " +
|
|
(object != null ? this.formatRule(getModes().getText(), object.getLogName()) : getModes().getText()) +
|
|
" from " +
|
|
getMessageText(game);
|
|
}
|
|
|
|
protected String getMessageText(Game game) {
|
|
StringBuilder sb = threadLocalBuilder.get();
|
|
MageObject object = game.getObject(this.sourceId);
|
|
if (object != null) {
|
|
if (object instanceof StackAbility) {
|
|
Card card = game.getCard(((StackAbility) object).getSourceId());
|
|
if (card != null) {
|
|
sb.append(GameLog.getColoredObjectIdName(card));
|
|
} else {
|
|
sb.append(GameLog.getColoredObjectIdName(object));
|
|
}
|
|
} else if (object instanceof Spell) {
|
|
Spell spell = (Spell) object;
|
|
String castText = spell.getSpellCastText(game);
|
|
sb.append((castText.startsWith("Cast ") ? castText.substring(5) : castText));
|
|
if (spell.getFromZone() == Zone.GRAVEYARD) {
|
|
sb.append(" from graveyard");
|
|
}
|
|
sb.append(getOptionalTextSuffix(game, spell));
|
|
} else {
|
|
sb.append(GameLog.getColoredObjectIdName(object));
|
|
}
|
|
} else {
|
|
sb.append("unknown");
|
|
}
|
|
if (object instanceof Spell && ((Spell) object).getSpellAbilities().size() > 1) {
|
|
if (((Spell) object).getSpellAbility().getSpellAbilityType() == SpellAbilityType.SPLIT_FUSED) {
|
|
Spell spell = (Spell) object;
|
|
int i = 0;
|
|
for (SpellAbility spellAbility : spell.getSpellAbilities()) {
|
|
i++;
|
|
String half;
|
|
if (i == 1) {
|
|
half = " left";
|
|
} else {
|
|
half = " right";
|
|
}
|
|
if (!spellAbility.getTargets().isEmpty()) {
|
|
sb.append(half).append(" half targeting ");
|
|
for (Target target : spellAbility.getTargets()) {
|
|
sb.append(target.getTargetedName(game));
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
Spell spell = (Spell) object;
|
|
int i = 0;
|
|
for (SpellAbility spellAbility : spell.getSpellAbilities()) {
|
|
i++;
|
|
if (i > 1) {
|
|
sb.append(" splicing ");
|
|
if (spellAbility.name.length() > 5 && spellAbility.name.startsWith("Cast ")) {
|
|
sb.append(spellAbility.name.substring(5));
|
|
} else {
|
|
sb.append(spellAbility.name);
|
|
}
|
|
}
|
|
sb.append(getTargetDescriptionForLog(spellAbility.getTargets(), game));
|
|
}
|
|
}
|
|
} else if (object instanceof Spell && ((Spell) object).getSpellAbility().getModes().size() > 1) {
|
|
Modes spellModes = ((Spell) object).getSpellAbility().getModes();
|
|
for (UUID selectedModeId : spellModes.getSelectedModes()) {
|
|
Mode selectedMode = spellModes.get(selectedModeId);
|
|
int item = 0;
|
|
for (Mode mode : spellModes.values()) {
|
|
item++;
|
|
if (mode.getId().equals(selectedMode.getId())) {
|
|
sb.append(" (mode ").append(item).append(')');
|
|
sb.append(getTargetDescriptionForLog(selectedMode.getTargets(), game));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
sb.append(getTargetDescriptionForLog(getTargets(), game));
|
|
}
|
|
return sb.toString();
|
|
}
|
|
|
|
@Override
|
|
public String getTargetDescription(Targets targets, Game game) {
|
|
return getTargetDescriptionForLog(targets, game);
|
|
}
|
|
|
|
protected String getTargetDescriptionForLog(Targets targets, Game game) {
|
|
StringBuilder sb = new StringBuilder(); // threadLocal StringBuilder can't be used because calling method already uses it
|
|
if (!targets.isEmpty()) {
|
|
String usedVerb = null;
|
|
boolean isFirstTarget = true;
|
|
for (Target target : targets) {
|
|
if (!target.getTargets().isEmpty()) {
|
|
String targetHintInfo = target.getChooseHint() == null ? "" : " (" + target.getChooseHint() + ")";
|
|
if (!target.isNotTarget()) {
|
|
if (usedVerb == null || usedVerb.equals(" choosing ")) {
|
|
usedVerb = " targeting ";
|
|
sb.append(usedVerb);
|
|
}
|
|
} else if (target.isNotTarget() && (usedVerb == null || usedVerb.equals(" targeting "))) {
|
|
usedVerb = " choosing ";
|
|
sb.append(usedVerb);
|
|
}
|
|
|
|
if (!isFirstTarget) {
|
|
sb.append(", ");
|
|
}
|
|
isFirstTarget = false;
|
|
|
|
sb.append(target.getTargetedName(game));
|
|
sb.append(targetHintInfo);
|
|
}
|
|
}
|
|
}
|
|
return sb.toString();
|
|
}
|
|
|
|
private String getOptionalTextSuffix(Game game, Spell spell) {
|
|
StringBuilder sb = new StringBuilder();
|
|
for (Ability ability : spell.getAbilities()) {
|
|
if (ability instanceof OptionalAdditionalSourceCosts) {
|
|
sb.append(((OptionalAdditionalSourceCosts) ability).getCastMessageSuffix());
|
|
}
|
|
if (ability instanceof AlternativeSourceCosts && ((AlternativeSourceCosts) ability).isActivated(this, game)) {
|
|
sb.append(((AlternativeSourceCosts) ability).getCastMessageSuffix(game));
|
|
}
|
|
}
|
|
return sb.toString();
|
|
}
|
|
|
|
@Override
|
|
public boolean getWorksFaceDown() {
|
|
return worksFaceDown;
|
|
}
|
|
|
|
@Override
|
|
public void setWorksFaceDown(boolean worksFaceDown) {
|
|
this.worksFaceDown = worksFaceDown;
|
|
}
|
|
|
|
@Override
|
|
public boolean getWorksPhasedOut() {
|
|
return worksPhasedOut;
|
|
}
|
|
|
|
@Override
|
|
public void setWorksPhasedOut(boolean worksPhasedOut) {
|
|
this.worksPhasedOut = worksPhasedOut;
|
|
}
|
|
|
|
@Override
|
|
public MageObject getSourceObject(Game game) {
|
|
return game.getObject(getSourceId());
|
|
}
|
|
|
|
@Override
|
|
public MageObject getSourceObjectIfItStillExists(Game game) {
|
|
if (getSourceObjectZoneChangeCounter() == 0
|
|
|| getSourceObjectZoneChangeCounter() == game.getState().getZoneChangeCounter(getSourceId())) {
|
|
// exists or lki from battlefield
|
|
return game.getObject(getSourceId());
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public Card getSourceCardIfItStillExists(Game game) {
|
|
MageObject mageObject = getSourceObjectIfItStillExists(game);
|
|
if (mageObject instanceof Card) {
|
|
return (Card) mageObject;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public Permanent getSourcePermanentIfItStillExists(Game game) {
|
|
MageObject mageObject = getSourceObjectIfItStillExists(game);
|
|
if (mageObject instanceof Permanent) {
|
|
return (Permanent) mageObject;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
@Override
|
|
public Permanent getSourcePermanentOrLKI(Game game) {
|
|
Permanent permanent = getSourcePermanentIfItStillExists(game);
|
|
if (permanent == null) {
|
|
permanent = (Permanent) game.getLastKnownInformation(getSourceId(), Zone.BATTLEFIELD, getSourceObjectZoneChangeCounter());
|
|
}
|
|
return permanent;
|
|
}
|
|
|
|
@Override
|
|
public void setSourceObjectZoneChangeCounter(int sourceObjectZoneChangeCounter) {
|
|
this.sourceObjectZoneChangeCounter = sourceObjectZoneChangeCounter;
|
|
}
|
|
|
|
@Override
|
|
public int getSourceObjectZoneChangeCounter() {
|
|
return sourceObjectZoneChangeCounter;
|
|
}
|
|
|
|
@Override
|
|
public void setSourcePermanentTransformCount(Game game) {
|
|
Permanent permanent = getSourcePermanentOrLKI(game);
|
|
if (permanent != null) {
|
|
this.sourcePermanentTransformCount = permanent.getTransformCount();
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public boolean checkTransformCount(Permanent permanent, Game game) {
|
|
if (permanent == null
|
|
|| !permanent.getId().equals(sourceId)
|
|
|| permanent.getZoneChangeCounter(game) != sourceObjectZoneChangeCounter) {
|
|
return true;
|
|
}
|
|
return permanent.getTransformCount() == sourcePermanentTransformCount;
|
|
}
|
|
|
|
@Override
|
|
public boolean canFizzle() {
|
|
return canFizzle;
|
|
}
|
|
|
|
@Override
|
|
public void setCanFizzle(boolean canFizzle) {
|
|
this.canFizzle = canFizzle;
|
|
}
|
|
|
|
@Override
|
|
public AbilityImpl setTargetAdjuster(TargetAdjuster targetAdjuster) {
|
|
if (targetAdjuster instanceof GenericTargetAdjuster && this.getTargets().isEmpty()) {
|
|
throw new IllegalStateException("Target adjuster being added but no targets are set!");
|
|
}
|
|
this.targetAdjuster = targetAdjuster;
|
|
this.targetAdjuster.addDefaultTargets(this);
|
|
return this;
|
|
}
|
|
|
|
@Override
|
|
public TargetAdjuster getTargetAdjuster() {
|
|
return targetAdjuster;
|
|
}
|
|
|
|
@Override
|
|
public void adjustTargets(Game game) {
|
|
if (targetAdjuster != null) {
|
|
targetAdjuster.adjustTargets(this, game);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Dynamic cost modification for ability.<br>
|
|
* Example: if it need stack related info (like real targets) then must
|
|
* check two states (game.inCheckPlayableState): <br>
|
|
* 1. In playable state it must check all possible use cases (e.g. allow to
|
|
* reduce on any available target and modes) <br>
|
|
* 2. In real cast state it must check current use case (e.g. real selected
|
|
* targets and modes)
|
|
*
|
|
* @param costAdjuster
|
|
*/
|
|
@Override
|
|
public AbilityImpl setCostAdjuster(CostAdjuster costAdjuster) {
|
|
this.costAdjuster = costAdjuster;
|
|
return this;
|
|
}
|
|
|
|
@Override
|
|
public CostAdjuster getCostAdjuster() {
|
|
return costAdjuster;
|
|
}
|
|
|
|
@Override
|
|
public void adjustCosts(Game game) {
|
|
if (costAdjuster != null) {
|
|
costAdjuster.adjustCosts(this, game);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public List<Hint> getHints() {
|
|
return this.hints;
|
|
}
|
|
|
|
@Override
|
|
public Ability addHint(Hint hint) {
|
|
this.hints.add(hint);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* sets the mode tag for the current mode.
|
|
*/
|
|
@Override
|
|
public void setModeTag(String tag) {
|
|
if (getModes().getMode() != null) {
|
|
getModes().getMode().setModeTag(tag);
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public final List<CardIcon> getIcons() {
|
|
return getIcons(null);
|
|
}
|
|
|
|
@Override
|
|
public List<CardIcon> getIcons(Game game) {
|
|
return this.icons;
|
|
}
|
|
|
|
@Override
|
|
public Ability addIcon(CardIcon cardIcon) {
|
|
this.icons.add(cardIcon);
|
|
return this;
|
|
}
|
|
|
|
@Override
|
|
public Ability addCustomOutcome(Outcome customOutcome) {
|
|
this.customOutcome = customOutcome;
|
|
return this;
|
|
}
|
|
|
|
@Override
|
|
public Outcome getCustomOutcome() {
|
|
return this.customOutcome;
|
|
}
|
|
|
|
@Override
|
|
public boolean isSameInstance(Ability ability) {
|
|
// same instance (by mtg rules) = same object, ID or class+text (you can't check class only cause it can be different by params/text)
|
|
if (ability == null) {
|
|
return false;
|
|
}
|
|
|
|
return (this == ability)
|
|
|| (this.getId().equals(ability.getId()))
|
|
|| (this.getOriginalId().equals(ability.getOriginalId()))
|
|
|| (this.getClass() == ability.getClass() && this.getRule(true).equals(ability.getRule(true)));
|
|
}
|
|
|
|
@Override
|
|
public MageIdentifier getIdentifier() {
|
|
return identifier;
|
|
}
|
|
|
|
@Override
|
|
public AbilityImpl setIdentifier(MageIdentifier identifier) {
|
|
this.identifier = identifier;
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Needed for disabling auto-mana payments for things like Sunburst.
|
|
*
|
|
* @return true if the ability is impacted by the color of the mana used to pay for it.
|
|
*/
|
|
public boolean caresAboutManaColor() {
|
|
return this.getEffects().stream()
|
|
.filter(Objects::nonNull)
|
|
.map(Effect::getCondition)
|
|
.filter(Objects::nonNull)
|
|
.anyMatch(Condition::caresAboutManaColor);
|
|
}
|
|
|
|
public AbilityImpl copyWithZone(Zone zone) {
|
|
if (this instanceof MageSingleton) {
|
|
// not safe to change zone for singletons
|
|
// in theory there could be some sort of wrapper to effectively change
|
|
// the zone here, but currently no use of copyWithZone actually needs
|
|
// to change the zone of any existing singleton abilities
|
|
return this;
|
|
}
|
|
AbilityImpl copy = ((AbilityImpl) this.copy());
|
|
copy.zone = zone;
|
|
copy.newId();
|
|
return copy;
|
|
}
|
|
}
|