foul-magics/Mage/src/main/java/mage/util/CardUtil.java
2025-10-29 15:59:43 -04:00

2510 lines
102 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.util;
import com.google.common.collect.ImmutableList;
import mage.*;
import mage.abilities.*;
import mage.abilities.condition.Condition;
import mage.abilities.costs.Cost;
import mage.abilities.costs.Costs;
import mage.abilities.costs.VariableCost;
import mage.abilities.costs.mana.*;
import mage.abilities.dynamicvalue.DynamicValue;
import mage.abilities.dynamicvalue.common.SavedDamageValue;
import mage.abilities.dynamicvalue.common.SavedDiscardValue;
import mage.abilities.dynamicvalue.common.SavedGainedLifeValue;
import mage.abilities.dynamicvalue.common.StaticValue;
import mage.abilities.effects.ContinuousEffect;
import mage.abilities.effects.Effect;
import mage.abilities.effects.common.InfoEffect;
import mage.abilities.effects.common.asthought.CanPlayCardControllerEffect;
import mage.abilities.effects.common.asthought.YouMaySpendManaAsAnyColorToCastTargetEffect;
import mage.abilities.effects.common.counter.AddCountersTargetEffect;
import mage.abilities.hint.Hint;
import mage.abilities.hint.HintUtils;
import mage.cards.*;
import mage.constants.*;
import mage.counters.Counter;
import mage.filter.Filter;
import mage.filter.FilterCard;
import mage.filter.StaticFilters;
import mage.filter.predicate.Predicate;
import mage.filter.predicate.Predicates;
import mage.filter.predicate.card.OwnerIdPredicate;
import mage.filter.predicate.mageobject.NamePredicate;
import mage.filter.predicate.permanent.ControllerIdPredicate;
import mage.game.CardState;
import mage.game.Game;
import mage.game.GameState;
import mage.game.command.Commander;
import mage.game.events.BatchEvent;
import mage.game.events.GameEvent;
import mage.game.permanent.Permanent;
import mage.game.permanent.PermanentCard;
import mage.game.permanent.PermanentMeld;
import mage.game.permanent.PermanentToken;
import mage.game.permanent.token.Token;
import mage.game.stack.Spell;
import mage.players.Player;
import mage.players.PlayerList;
import mage.target.Target;
import mage.target.TargetCard;
import mage.target.targetpointer.FixedTarget;
import mage.watchers.Watcher;
import org.apache.log4j.Logger;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.function.BiFunction;
import java.util.function.BiPredicate;
import java.util.function.Function;
import java.util.function.ToIntFunction;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* @author nantuko
*/
public final class CardUtil {
private static final Logger logger = Logger.getLogger(CardUtil.class);
public static final List<String> RULES_ERROR_INFO = ImmutableList.of("Exception occurred in rules generation");
public static final String SOURCE_EXILE_ZONE_TEXT = "SourceExileZone";
static final String[] numberStrings = {"zero", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine",
"ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen", "twenty"};
static final String[] ordinalStrings = {"first", "second", "third", "fourth", "fifth", "sixth", "seventh", "eightth", "ninth",
"tenth", "eleventh", "twelfth", "thirteenth", "fourteenth", "fifteenth", "sixteenth", "seventeenth", "eighteenth", "nineteenth", "twentieth"};
public static final SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss.SSS");
private static final List<String> costWords = Arrays.asList(
"put", "return", "exile", "discard", "mill", "sacrifice", "remove", "tap", "reveal", "pay", "have", "collect", "forage"
);
// search set code in commands like "set_code-card_name"
public static final int TESTS_SET_CODE_MIN_LOOKUP_LENGTH = 3;
public static final int TESTS_SET_CODE_MAX_LOOKUP_LENGTH = 6;
public static final String TESTS_SET_CODE_DELIMETER = "-"; // delimeter for cheats and tests command "set_code-card_name"
/**
* Increase spell or ability cost to be paid.
*
* @param ability
* @param increaseCount
*/
public static void increaseCost(Ability ability, int increaseCount) {
adjustAbilityCost(ability, -increaseCount);
}
/**
* calculates the maximal possible generic mana reduction for a given mana cost
*
* @param mana mana costs that should be reduced
* @param maxPossibleReduction max possible generic mana reduction
* @param notLessThan the complete costs may not be reduced more than this CMC mana costs
*/
public static int calculateActualPossibleGenericManaReduction(Mana mana, int maxPossibleReduction, int notLessThan) {
int nonGeneric = mana.count() - mana.getGeneric();
int notPossibleGenericReduction = Math.max(0, notLessThan - nonGeneric);
int actualPossibleGenericManaReduction = Math.max(0, mana.getGeneric() - notPossibleGenericReduction);
if (actualPossibleGenericManaReduction > maxPossibleReduction) {
actualPossibleGenericManaReduction = maxPossibleReduction;
}
return actualPossibleGenericManaReduction;
}
/**
* Reduces ability cost to be paid.
*
* @param ability
* @param reduceCount
*/
public static void reduceCost(Ability ability, int reduceCount) {
adjustAbilityCost(ability, reduceCount);
}
/**
* Adjusts spell or ability cost to be paid.
*
* @param spellAbility
* @param reduceCount
*/
public static void adjustCost(SpellAbility spellAbility, int reduceCount) {
CardUtil.adjustAbilityCost(spellAbility, reduceCount);
}
public static ManaCosts<ManaCost> increaseCost(ManaCosts<ManaCost> manaCosts, int increaseCount) {
return adjustCost(manaCosts, -increaseCount);
}
public static ManaCosts<ManaCost> reduceCost(ManaCosts<ManaCost> manaCosts, int reduceCount) {
return adjustCost(manaCosts, reduceCount);
}
/**
* Adjusts ability cost to be paid.
*
* @param ability
* @param reduceCount
*/
private static void adjustAbilityCost(Ability ability, int reduceCount) {
ManaCosts<ManaCost> adjustedCost = adjustCost(ability.getManaCostsToPay(), reduceCount);
ability.clearManaCostsToPay();
ability.addManaCostsToPay(adjustedCost);
}
public static ManaCosts<ManaCost> adjustCost(ManaCosts<ManaCost> manaCosts, int reduceCount) {
ManaCosts<ManaCost> newCost = new ManaCostsImpl<>();
// nothing to change
if (reduceCount == 0) {
for (ManaCost manaCost : manaCosts) {
newCost.add(manaCost.copy());
}
return newCost;
}
// keep same order for costs
Map<ManaCost, ManaCost> changedCost = new LinkedHashMap<>(); // must be ordered
List<ManaCost> addedCost = new ArrayList<>();
manaCosts.forEach(manaCost -> {
changedCost.put(manaCost, manaCost);
});
// remove or save cost
if (reduceCount > 0) {
int restToReduce = reduceCount;
// first run - priority for single option costs (generic)
for (ManaCost manaCost : manaCosts) {
// ignore snow mana
if (manaCost instanceof SnowManaCost) {
continue;
}
// ignore unknown mana
if (manaCost.getOptions().isEmpty()) {
continue;
}
// ignore monohybrid and other multi-option mana (for potential support)
if (manaCost.getOptions().size() > 1) {
continue;
}
// generic mana reduce
Mana mana = manaCost.getOptions().getAtIndex(0);
int colorless = mana != null ? mana.getGeneric() : 0;
if (restToReduce != 0 && colorless > 0) {
if ((colorless - restToReduce) > 0) {
// partly reduce
int newColorless = colorless - restToReduce;
changedCost.put(manaCost, new GenericManaCost(newColorless));
restToReduce = 0;
} else {
// full reduce - ignore cost
changedCost.put(manaCost, null);
restToReduce -= colorless;
}
} else {
// nothing to reduce
}
}
// second run - priority for multi option costs (monohybrid)
//
// from Reaper King:
// If an effect reduces the cost to cast a spell by an amount of generic mana, it applies to a monocolored hybrid
// spell only if youve chosen a method of paying for it that includes generic mana.
// (2008-05-01)
// TODO: xmage don't use announce for hybrid mana (instead it uses auto-pay), so that's workaround uses first hybrid to reduce (see https://github.com/magefree/mage/issues/6130 )
for (ManaCost manaCost : manaCosts) {
if (manaCost.getOptions().size() <= 1) {
continue;
}
if (manaCost instanceof MonoHybridManaCost) {
// current implemention supports reduce from left to right hybrid cost without cost parts announce
MonoHybridManaCost mono = (MonoHybridManaCost) manaCost;
int colorless = mono.getOptions().getAtIndex(1).getGeneric();
if (restToReduce != 0 && colorless > 0) {
if ((colorless - restToReduce) > 0) {
// partly reduce
int newColorless = colorless - restToReduce;
changedCost.put(manaCost, new MonoHybridManaCost(mono.getManaColor(), newColorless));
restToReduce = 0;
} else {
// full reduce
changedCost.put(manaCost, new MonoHybridManaCost(mono.getManaColor(), 0));
restToReduce -= colorless;
}
} else {
// nothing to reduce
}
continue;
}
// unsupported multi-option mana types for reduce (like HybridManaCost)
// nothing to do
}
}
// increase cost (add to first generic or add new)
if (reduceCount < 0) {
boolean added = false;
for (ManaCost manaCost : manaCosts) {
// ignore already reduced cost (add new cost to the start)
if (changedCost.get(manaCost) == null) {
continue;
}
// add to existing cost
if (reduceCount != 0 && manaCost instanceof GenericManaCost) {
GenericManaCost gen = (GenericManaCost) manaCost;
changedCost.put(manaCost, new GenericManaCost(gen.getOptions().getAtIndex(0).getGeneric() + -reduceCount));
reduceCount = 0;
added = true;
} else {
// non-generic mana
}
}
// add as new cost
if (!added) {
addedCost.add(new GenericManaCost(-reduceCount));
}
}
// collect final result
addedCost.forEach(cost -> {
newCost.add(cost.copy());
});
changedCost.forEach((key, value) -> {
// ignore fully reduced and add changed
if (value != null) {
newCost.add(value.copy());
}
});
// cost modifying effects requiring snow mana unnecessarily (fixes #6000)
Filter filter = manaCosts.stream()
.filter(manaCost -> !(manaCost instanceof SnowManaCost))
.map(ManaCost::getSourceFilter)
.filter(Objects::nonNull)
.findFirst()
.orElse(null);
if (filter != null) {
newCost.setSourceFilter(filter);
}
return newCost;
}
public static void reduceCost(SpellAbility spellAbility, ManaCosts<ManaCost> manaCostsToReduce) {
adjustCost(spellAbility, manaCostsToReduce, true);
}
public static void increaseCost(SpellAbility spellAbility, ManaCosts<ManaCost> manaCostsToIncrease) {
ManaCosts<ManaCost> increasedCost = spellAbility.getManaCostsToPay().copy();
for (ManaCost manaCost : manaCostsToIncrease) {
increasedCost.add(manaCost.copy());
}
spellAbility.clearManaCostsToPay();
spellAbility.addManaCostsToPay(increasedCost);
}
/**
* Adjusts spell or ability cost to be paid by colored and generic mana.
*
* @param ability spell or ability to reduce the cost of
* @param manaCostsToReduce reduces the spell or ability cost by that much
* @param convertToGeneric colored mana does reduce generic mana if no
* appropriate colored mana is in the costs
* included
*/
public static void adjustCost(Ability ability, ManaCosts<ManaCost> manaCostsToReduce, boolean convertToGeneric) {
ManaCosts<ManaCost> previousCost = ability.getManaCostsToPay();
ManaCosts<ManaCost> adjustedCost = new ManaCostsImpl<>();
// save X value (e.g. convoke ability)
for (VariableCost vCost : previousCost.getVariableCosts()) {
if (vCost instanceof VariableManaCost) {
adjustedCost.add((VariableManaCost) vCost);
}
}
Mana reduceMana = new Mana();
for (ManaCost manaCost : manaCostsToReduce) {
if (manaCost instanceof MonoHybridManaCost) {
reduceMana.add(Mana.GenericMana(2));
} else {
reduceMana.add(manaCost.getMana());
}
}
ManaCosts<ManaCost> manaCostToCheckForGeneric = new ManaCostsImpl<>();
// subtract non-generic mana
for (ManaCost newManaCost : previousCost) {
Mana mana = newManaCost.getMana();
if (!(newManaCost instanceof MonoHybridManaCost) && mana.getGeneric() > 0) {
manaCostToCheckForGeneric.add(newManaCost);
continue;
}
boolean hybridMana = newManaCost instanceof HybridManaCost;
if (mana.getBlack() > 0 && reduceMana.getBlack() > 0) {
if (reduceMana.getBlack() > mana.getBlack()) {
reduceMana.setBlack(reduceMana.getBlack() - mana.getBlack());
mana.setBlack(0);
} else {
mana.setBlack(mana.getBlack() - reduceMana.getBlack());
reduceMana.setBlack(0);
}
if (hybridMana) {
continue;
}
}
if (mana.getRed() > 0 && reduceMana.getRed() > 0) {
if (reduceMana.getRed() > mana.getRed()) {
reduceMana.setRed(reduceMana.getRed() - mana.getRed());
mana.setRed(0);
} else {
mana.setRed(mana.getRed() - reduceMana.getRed());
reduceMana.setRed(0);
}
if (hybridMana) {
continue;
}
}
if (mana.getBlue() > 0 && reduceMana.getBlue() > 0) {
if (reduceMana.getBlue() > mana.getBlue()) {
reduceMana.setBlue(reduceMana.getBlue() - mana.getBlue());
mana.setBlue(0);
} else {
mana.setBlue(mana.getBlue() - reduceMana.getBlue());
reduceMana.setBlue(0);
}
if (hybridMana) {
continue;
}
}
if (mana.getGreen() > 0 && reduceMana.getGreen() > 0) {
if (reduceMana.getGreen() > mana.getGreen()) {
reduceMana.setGreen(reduceMana.getGreen() - mana.getGreen());
mana.setGreen(0);
} else {
mana.setGreen(mana.getGreen() - reduceMana.getGreen());
reduceMana.setGreen(0);
}
if (hybridMana) {
continue;
}
}
if (mana.getWhite() > 0 && reduceMana.getWhite() > 0) {
if (reduceMana.getWhite() > mana.getWhite()) {
reduceMana.setWhite(reduceMana.getWhite() - mana.getWhite());
mana.setWhite(0);
} else {
mana.setWhite(mana.getWhite() - reduceMana.getWhite());
reduceMana.setWhite(0);
}
if (hybridMana) {
continue;
}
}
if (mana.getColorless() > 0 && reduceMana.getColorless() > 0) {
if (reduceMana.getColorless() > mana.getColorless()) {
reduceMana.setColorless(reduceMana.getColorless() - mana.getColorless());
mana.setColorless(0);
} else {
mana.setColorless(mana.getColorless() - reduceMana.getColorless());
reduceMana.setColorless(0);
}
}
if (mana.count() > 0) {
if (newManaCost instanceof MonoHybridManaCost) {
if (mana.count() == 2) {
reduceMana.setGeneric(reduceMana.getGeneric() - 2);
continue;
}
}
manaCostToCheckForGeneric.add(newManaCost);
}
}
// subtract colorless mana, use all mana that is left
int reduceAmount;
if (convertToGeneric) {
reduceAmount = reduceMana.count();
} else {
reduceAmount = reduceMana.getGeneric();
}
if (reduceAmount > 0) {
for (ManaCost newManaCost : manaCostToCheckForGeneric) {
Mana mana = newManaCost.getMana();
if (mana.getGeneric() == 0 || reduceAmount == 0) {
adjustedCost.add(newManaCost);
continue;
}
if (newManaCost instanceof MonoHybridManaCost) {
if (reduceAmount > 1) {
reduceAmount -= 2;
mana.clear();
}
continue;
}
if (mana.getGeneric() > 0) {
if (reduceAmount > mana.getGeneric()) {
reduceAmount -= mana.getGeneric();
mana.setGeneric(0);
} else {
mana.setGeneric(mana.getGeneric() - reduceAmount);
reduceAmount = 0;
}
}
if (mana.count() > 0) {
adjustedCost.add(0, new GenericManaCost(mana.count()));
}
}
} else {
adjustedCost.addAll(manaCostToCheckForGeneric);
}
if (adjustedCost.isEmpty()) {
adjustedCost.add(new GenericManaCost(0)); // neede to check if cost was reduced to 0
}
adjustedCost.setSourceFilter(previousCost.getSourceFilter()); // keep mana source restrictions
ability.clearManaCostsToPay();
ability.addManaCostsToPay(adjustedCost);
}
/**
* Converts an integer number to string Numbers > 20 will be returned as
* digits
*
* @param number
* @return
*/
public static String numberToText(int number) {
return numberToText(number, "one");
}
/**
* Converts an integer number to string like "one", "two", "three", ...
* Numbers > 20 will be returned as digits
*
* @param number number to convert to text
* @param forOne if the number is 1, this string will be returnedinstead of
* "one".
* @return
*/
public static String numberToText(int number, String forOne) {
if (number == 1 && forOne != null) {
return forOne;
}
if (number >= 0 && number < 21) {
return numberStrings[number];
}
if (number == Integer.MAX_VALUE) {
return "any number of";
}
return Integer.toString(number);
}
public static String numberToText(String number) {
return numberToText(number, "one");
}
public static String numberToText(String number, String forOne) {
if (checkNumeric(number)) {
return numberToText(Integer.parseInt(number), forOne);
}
return number;
}
public static String numberToOrdinalText(int number) {
if (number >= 1 && number < 21) {
return ordinalStrings[number - 1];
}
return number + "th";
}
public static String replaceSourceName(String message, String sourceName) {
return message != null ? message.replace("{this}", sourceName) : null;
}
public static String booleanToFlipName(boolean flip) {
if (flip) {
return "Heads";
}
return "Tails";
}
public static boolean checkNumeric(String s) {
return !s.isEmpty() && s.chars().allMatch(Character::isDigit);
}
/**
* Parse card number as int (support base [123] and alternative numbers
* [123b], [U123]).
*
* @param cardNumber origin card number
* @return int
*/
public static int parseCardNumberAsInt(String cardNumber) {
if (cardNumber == null || cardNumber.isEmpty()) {
throw new IllegalArgumentException("Card number is empty.");
}
try {
if (!Character.isDigit(cardNumber.charAt(0))) {
// U123
return Integer.parseInt(cardNumber.substring(1));
} else if (!Character.isDigit(cardNumber.charAt(cardNumber.length() - 1))) {
// 123b
return Integer.parseInt(cardNumber.substring(0, cardNumber.length() - 1));
} else {
// 123
return Integer.parseInt(cardNumber);
}
} catch (NumberFormatException e) {
// wrong numbers like RA5 and etc
return -1;
}
}
/**
* Creates and saves a (card + zoneChangeCounter) specific exileId.
*
* @param game the current game
* @param source source ability
* @return the specific UUID
*/
public static UUID getCardExileZoneId(Game game, Ability source) {
return getCardExileZoneId(game, source.getSourceId());
}
public static UUID getCardExileZoneId(Game game, UUID sourceId) {
return getCardExileZoneId(game, sourceId, false);
}
public static UUID getCardExileZoneId(Game game, UUID sourceId, boolean previous) {
return getExileZoneId(getCardZoneString(SOURCE_EXILE_ZONE_TEXT, sourceId, game, previous), game);
}
/**
* Find exiled zone due source object's zcc
* <p>
* Warning, carefully use it from static abilities, cause:
* - static abilities init from start of the game, e.g. zcc = 0
* - activated abilities init on usage, e.g. zcc = 123
* - if you need to share some data between diff type of effects then find actual object's zcc manually
*/
public static UUID getExileZoneId(Game game, Ability source) {
return getExileZoneId(game, source, 0);
}
public static UUID getExileZoneId(Game game, Ability source, int offset) {
return getExileZoneId(game, source.getSourceId(), source.getStackMomentSourceZCC() + offset);
}
public static UUID getExileZoneId(Game game, UUID objectId, int zoneChangeCounter) {
return getExileZoneId(getObjectZoneString(SOURCE_EXILE_ZONE_TEXT, objectId, game, zoneChangeCounter, false), game);
}
public static UUID getExileZoneId(String key, Game game) {
UUID exileId = (UUID) game.getState().getValue(key);
if (exileId == null) {
exileId = UUID.randomUUID();
game.getState().setValue(key, exileId);
}
return exileId;
}
/**
* Creates a string from text + cardId and the zoneChangeCounter of the card
* (from cardId). This string can be used to save and get values that must
* be specific to a permanent instance. So they won't match, if a permanent
* was e.g. exiled and came back immediately.
*
* @param text short value to describe the value
* @param cardId id of the card
* @param game the game
* @return
*/
public static String getCardZoneString(String text, UUID cardId, Game game) {
return getCardZoneString(text, cardId, game, false);
}
public static String getCardZoneString(String text, UUID cardId, Game game, boolean previous) {
int zoneChangeCounter = 0;
Card card = game.getCard(cardId); // if called for a token, the id is enough
if (card != null) {
zoneChangeCounter = card.getZoneChangeCounter(game);
}
return getObjectZoneString(text, cardId, game, zoneChangeCounter, previous);
}
public static String getObjectZoneString(String text, MageObject mageObject, Game game) {
int zoneChangeCounter = 0;
if (mageObject instanceof Permanent) {
zoneChangeCounter = mageObject.getZoneChangeCounter(game);
} else if (mageObject instanceof Card) {
zoneChangeCounter = mageObject.getZoneChangeCounter(game);
}
return getObjectZoneString(text, mageObject.getId(), game, zoneChangeCounter, false);
}
public static String getObjectZoneString(String text, UUID objectId, Game game, int zoneChangeCounter, boolean previous) {
StringBuilder uniqueString = new StringBuilder();
if (text != null) {
uniqueString.append(text);
}
uniqueString.append(objectId);
uniqueString.append(previous ? zoneChangeCounter - 1 : zoneChangeCounter);
return uniqueString.toString();
}
/**
* Adds tags to mark the additional info of a card (e.g. blue font color)
*
* @param text text body
* @return
*/
public static String addToolTipMarkTags(String text) {
return "<font color = 'blue'>" + text + "</font>";
}
/**
* Integer operation with overflow protection
*
* @param base
* @param increment
* @return
*/
public static int overflowInc(int base, int increment) {
return overflowResult((long) base + increment);
}
/**
* Integer operation with overflow protection
*
* @param base
* @param decrement
* @return
*/
public static int overflowDec(int base, int decrement) {
return overflowResult((long) base - decrement);
}
/**
* Integer operation with overflow protection
*
* @param base
* @param multiply
* @return
*/
public static int overflowMultiply(int base, int multiply) {
return overflowResult((long) base * multiply);
}
private static int overflowResult(long value) {
if (value >= Integer.MAX_VALUE) {
return Integer.MAX_VALUE;
} else if (value <= Integer.MIN_VALUE) {
return Integer.MIN_VALUE;
} else {
return (int) value;
}
}
/**
* Checks if a given integer is prime
*
* @param number
* @return
*/
public static boolean isPrime(int number) {
// if it's 1 or less it's not prime
if (number < 2) {
return false;
}
// easy to check 2 and 3 first
if (number == 2 || number == 3) {
return true;
}
// sieve of eratosthenes, only need to check up to sqrt(x) to find a divisor
for (int i = 2; i * i <= number; i++) {
if (number % i == 0) {
return false;
}
}
return true;
}
public static String createObjectRelatedWindowTitle(Ability source, Game game, String textSuffix) {
String title;
if (source != null) {
MageObject sourceObject = game.getObject(source);
if (sourceObject != null) {
title = sourceObject.getIdName()
+ " [" + source.getStackMomentSourceZCC() + "]"
+ (textSuffix == null ? "" : " " + textSuffix);
} else {
title = textSuffix == null ? "" : textSuffix;
}
} else {
title = textSuffix == null ? "" : textSuffix;
}
return title;
}
/**
* Face down cards and their copy tokens don't have names and that's "empty"
* names is not equals
*/
public static boolean haveSameNames(String name1, String name2, Boolean ignoreMtgRuleForEmptyNames) {
if (ignoreMtgRuleForEmptyNames) {
// simple compare for tests and engine
return name1 != null && name1.equals(name2);
} else {
// mtg logic compare for game (empty names can't be same)
return !haveEmptyName(name1) && !haveEmptyName(name2) && name1.equals(name2);
}
}
public static boolean haveSameNames(String name1, String name2) {
return haveSameNames(name1, name2, false);
}
public static boolean haveSameNames(MageObject object1, MageObject object2) {
return object1 != null && object2 != null && haveSameNames(object1.getName(), object2.getName());
}
public static boolean haveSameNames(MageObject object, String needName, Game game) {
return containsName(object, needName, game);
}
public static boolean containsName(MageObject object, String name, Game game) {
return new NamePredicate(name).apply(object, game);
}
public static boolean haveEmptyName(String name) {
return name == null
|| name.isEmpty()
|| EmptyNames.isEmptyName(name);
}
public static boolean haveEmptyName(MageObject object) {
return object == null || haveEmptyName(object.getName());
}
public static UUID getMainCardId(Game game, UUID objectId) {
Card card = game.getCard(objectId);
return card != null ? card.getMainCard().getId() : objectId;
}
public static String urlEncode(String data) {
if (data.isEmpty()) {
return "";
}
try {
return URLEncoder.encode(data, "UTF-8");
} catch (UnsupportedEncodingException e) {
return "";
}
}
public static String urlDecode(String encodedData) {
if (encodedData.isEmpty()) {
return "";
}
try {
return URLDecoder.decode(encodedData, "UTF-8");
} catch (UnsupportedEncodingException e) {
return "";
}
}
/**
* Checks if a card had a given ability depending their historic cardState
*
* @param ability the ability that is checked
* @param cardState the historic cardState (from LKI)
* @param cardId the id of the card
* @param game
* @return
*/
public static boolean cardHadAbility(Ability ability, CardState cardState, UUID cardId, Game game) {
Card card = game.getCard(cardId);
if (card != null) {
if (cardState != null) {
if (cardState.getAbilities().contains(ability)) { // Check other abilities (possibly given after lost of abilities)
return true;
}
if (cardState.hasLostAllAbilities()) {
return false; // Not allowed to check abilities of original card
}
}
return card.getAbilities().contains(ability); // check if the original card has the ability
}
return false;
}
public static List<String> concatManaSymbols(String delimeter, List<String> mana1, List<String> mana2) {
List<String> res = new ArrayList<>(mana1);
if (res.size() > 0 && mana2.size() > 0 && delimeter != null && !delimeter.isEmpty()) {
res.add(delimeter);
}
res.addAll(mana2);
return res;
}
public static String concatManaSymbols(String delimeter, String mana1, String mana2) {
String res = mana1;
if (!res.isEmpty() && !mana2.isEmpty() && delimeter != null && !delimeter.isEmpty()) {
res += delimeter;
}
res += mana2;
return res;
}
public static ColoredManaSymbol manaTypeToColoredManaSymbol(ManaType manaType) {
switch (manaType) {
case BLACK:
return ColoredManaSymbol.B;
case BLUE:
return ColoredManaSymbol.U;
case GREEN:
return ColoredManaSymbol.G;
case RED:
return ColoredManaSymbol.R;
case WHITE:
return ColoredManaSymbol.W;
case GENERIC:
case COLORLESS:
default:
throw new IllegalArgumentException("Wrong mana type " + manaType);
}
}
public static String getBoostCountAsStr(DynamicValue power, DynamicValue toughness) {
// sign fix for zero values
// -1/+0 must be -1/-0
// +0/-1 must be -0/-1
String p = power.toString();
String t = toughness.toString();
if (!p.startsWith("-")) {
p = t.startsWith("-") && p.equals("0") ? "-0" : "+" + p;
}
if (!t.startsWith("-")) {
t = p.startsWith("-") && t.equals("0") ? "-0" : "+" + t;
}
return p + "/" + t;
}
public static String getBoostCountAsStr(int power, int toughness) {
return getBoostCountAsStr(StaticValue.get(power), StaticValue.get(toughness));
}
public static String getBoostText(DynamicValue power, DynamicValue toughness, Duration duration) {
String boostCount = getBoostCountAsStr(power, toughness);
StringBuilder sb = new StringBuilder(boostCount);
// don't include "for the rest of the game" for emblems, etc.
if (duration != Duration.EndOfGame) {
String d = duration.toString();
if (!d.isEmpty()) {
sb.append(' ').append(d);
}
}
String message = power.getMessage();
if (message.isEmpty()) {
message = toughness.getMessage();
}
if (!message.isEmpty()) {
sb.append(boostCount.contains("X") ? ", where X is " : " for each ").append(message);
}
return sb.toString();
}
public static Outcome getBoostOutcome(DynamicValue power, DynamicValue toughness) {
if (toughness.getSign() < 0) {
return Outcome.Removal;
}
if (power.getSign() < 0) {
return Outcome.UnboostCreature;
}
return Outcome.BoostCreature;
}
public static String getSimpleCountersText(int amount, String forOne, String counterType) {
return numberToText(amount, forOne) + " " + counterType + " counter" + (amount == 1 ? "" : "s");
}
public static String getOneOneCountersText(int amount) {
return getSimpleCountersText(amount, "a", "+1/+1");
}
public static String getAddRemoveCountersText(DynamicValue amount, Counter counter, String description, boolean add) {
boolean targetPlayerGets = add && (description.endsWith("player") || description.endsWith("opponent"));
StringBuilder sb = new StringBuilder();
if (targetPlayerGets) {
sb.append(description);
sb.append(" gets ");
} else {
sb.append(add ? "put " : "remove ");
}
boolean xValue = amount.toString().equals("X");
if (xValue) {
sb.append("X ").append(counter.getName()).append(" counters");
} else if (amount == SavedDamageValue.MANY
|| amount == SavedGainedLifeValue.MANY
|| amount == SavedDiscardValue.MANY) {
sb.append("that many ").append(counter.getName()).append(" counters");
} else {
sb.append(counter.getDescription());
}
if (!targetPlayerGets) {
sb.append(add ? " on " : " from ");
if (description.contains("up to") && !description.contains("up to one")) {
sb.append("each of ");
}
sb.append(description);
}
if (!amount.getMessage().isEmpty()) {
sb.append(xValue ? ", where X is " : " for each ").append(amount.getMessage());
}
return sb.toString();
}
public static boolean isFusedPartAbility(Ability ability, Game game) {
// TODO: does it work fine with copies of spells on stack?
if (!(ability instanceof SpellAbility)) {
return false;
}
if (((SpellAbility) ability).getSpellAbilityType() == SpellAbilityType.SPLICE) {
return true;
}
Spell mainSpell = game.getSpell(ability.getId());
if (mainSpell == null) {
return true;
}
SpellAbility mainSpellAbility = mainSpell.getSpellAbility();
return mainSpellAbility.getSpellAbilityType() == SpellAbilityType.SPLIT_FUSED
&& !ability.equals(mainSpellAbility);
}
public static Abilities<Ability> getAbilities(MageObject object, Game game) {
if (object instanceof Card) {
return ((Card) object).getAbilities(game);
} else if (object instanceof Commander && Zone.COMMAND.equals(game.getState().getZone(object.getId()))) {
// Commanders in command zone must gain cost related abilities for playable
// calculation (affinity, convoke; example: Chief Engineer). So you must use card object here
Card card = game.getCard(object.getId());
if (card != null) {
return card.getAbilities(game);
} else {
return object.getAbilities();
}
} else {
return object.getAbilities();
}
}
public static String getTextWithFirstCharUpperCase(String text) {
if (text != null && !text.isEmpty()) {
return Character.toUpperCase(text.charAt(0)) + text.substring(1);
} else {
return text;
}
}
public static String getTextWithFirstCharLowerCase(String text) {
if (text != null && !text.isEmpty()) {
return Character.toLowerCase(text.charAt(0)) + text.substring(1);
} else {
return text;
}
}
private static final String vowels = "aeiouAEIOU8";
public static String addArticle(String text) {
if (text.startsWith("a ")
|| text.startsWith("an ")
|| text.startsWith("another ")
|| text.startsWith("any ")
|| text.startsWith("{this} ")
|| text.startsWith("your ")
|| text.startsWith("their ")
|| text.startsWith("one ")) {
return text;
}
return (!text.isEmpty() && vowels.contains(text.substring(0, 1))) ? "an " + text : "a " + text;
}
public static String italicizeWithEmDash(String text) {
return "<i>" + text + "</i> &mdash; ";
}
public static String stripReminderText(String text) {
return text.endsWith(")</i>") ? text.substring(0, text.indexOf(" <i>(")) : text;
}
public static Set<UUID> getAllSelectedTargets(Ability ability, Game game) {
return ability.getModes().getSelectedModes()
.stream()
.map(ability.getModes()::get)
.map(Mode::getTargets)
.flatMap(Collection::stream)
.map(Target::getTargets)
.flatMap(Collection::stream)
.collect(Collectors.toSet());
}
public static Set<UUID> getAllPossibleTargets(Ability ability, Game game) {
return ability.getModes().values()
.stream()
.map(Mode::getTargets)
.flatMap(Collection::stream)
.map(t -> t.possibleTargets(ability.getControllerId(), ability, game))
.flatMap(Collection::stream)
.collect(Collectors.toSet());
}
public static Set<UUID> getAllPossibleTargets(Cost cost, Game game, Ability source) {
return cost.getTargets()
.stream()
.map(t -> t.possibleTargets(source.getControllerId(), source, game))
.flatMap(Collection::stream)
.collect(Collectors.toSet());
}
/**
* Distribute values between min and max and make sure that the values will be evenly distributed
* Use it to limit possible values list like mana options
*/
public static List<Integer> distributeValues(int count, int min, int max) {
List<Integer> res = new ArrayList<>();
if (count <= 0 || min > max) {
return res;
}
if (min == max) {
res.add(min);
return res;
}
int range = max - min + 1;
// low possible amount
if (range <= count) {
for (int i = 0; i < range; i++) {
res.add(min + i);
}
return res;
}
// big possible amount, so skip some values
double step = (double) (max - min) / (count - 1);
for (int i = 0; i < count; i++) {
res.add(min + (int) Math.round(i * step));
}
// make sure first and last elements are good
res.set(0, min);
if (res.size() > 1) {
res.set(res.size() - 1, max);
}
return res;
}
/**
* For overriding `canTarget()` with usages such as "any number of target cards with total mana value X or less".
* Call this after super.canTarget() returns true.
*
* @param selectedTargets this.getTargets()
* @param checkTargetId id from canTarget
* @param valueMapper e.g. MageObject::getManaValue or m -> m.getPower().getValue()
* @param maxValue the maximum total value of the parameter
* @return true if the total value would not be exceeded by the target being checked.
*/
public static boolean checkCanTargetTotalValueLimit(Collection<UUID> selectedTargets, UUID checkTargetId,
ToIntFunction<MageObject> valueMapper, int maxValue,
Game game) {
MageObject checkTarget = game.getObject(checkTargetId);
if (checkTarget == null) {
return false;
}
return maxValue >= selectedTargets.stream()
.map(game::getObject)
.filter(Objects::nonNull)
.mapToInt(valueMapper)
.sum()
+ (selectedTargets.contains(checkTargetId) ? 0 : valueMapper.applyAsInt(checkTarget));
}
/**
* For overriding `possibleTargets()` with usages such as "any number of target cards with total mana value X or less".
*
* @param selectedTargets this.getTargets()
* @param possibleTargets super.possibleTargets()
* @param valueMapper e.g. MageObject::getManaValue or m -> m.getPower().getValue()
* @param maxValue the maximum total value of the parameter
* @return the set of possible targets that don't exceed the maximum total value.
*/
public static Set<UUID> checkPossibleTargetsTotalValueLimit(Collection<UUID> selectedTargets, Set<UUID> possibleTargets,
ToIntFunction<MageObject> valueMapper, int maxValue, Game game) {
int selectedValue = selectedTargets.stream()
.map(game::getObject)
.filter(Objects::nonNull)
.mapToInt(valueMapper)
.sum();
int remainingValue = maxValue - selectedValue;
Set<UUID> validTargets = new HashSet<>();
for (UUID id : possibleTargets) {
MageObject mageObject = game.getObject(id);
if (mageObject != null && valueMapper.applyAsInt(mageObject) <= remainingValue) {
validTargets.add(id);
}
}
return validTargets;
}
/**
* Put card to battlefield without resolve/ETB (for cheats and tests only)
*
* @param source must be non-null (if you need it empty then use fakeSourceAbility)
* @param game
* @param newCard
* @param player
*/
public static void putCardOntoBattlefieldWithEffects(Ability source, Game game, Card newCard, Player player, boolean tapped) {
// same logic as ZonesHandler->maybeRemoveFromSourceZone
// runtime check: must have source
if (source == null) {
throw new IllegalArgumentException("Wrong code usage: must use source ability or fakeSourceAbility");
}
// runtime check: must use only real cards
if (newCard instanceof PermanentCard) {
throw new IllegalArgumentException("Wrong code usage: must put to battlefield only real cards, not PermanentCard");
}
// workaround to put real permanent from one side (example: you call mdf card by cheats)
Card permCard = getDefaultCardSideForBattlefield(game, newCard);
// prepare card and permanent
permCard.setZone(Zone.BATTLEFIELD, game);
permCard.setOwnerId(player.getId());
PermanentCard permanent;
if (permCard instanceof MeldCard) {
permanent = new PermanentMeld(permCard, player.getId(), game);
} else {
permanent = new PermanentCard(permCard, player.getId(), game);
}
// put onto battlefield with possible counters without ETB
game.getPermanentsEntering().put(permanent.getId(), permanent);
permCard.applyEnterWithCounters(permanent, source, game);
permanent.entersBattlefield(source, game, Zone.OUTSIDE, false);
game.addPermanent(permanent, game.getState().getNextPermanentOrderNumber());
game.getPermanentsEntering().remove(permanent.getId());
// tapped status
// warning, "enters the battlefield tapped" abilities will be executed before, so don't set to false here
if (tapped) {
permanent.setTapped(true);
}
// remove sickness
permanent.removeSummoningSickness();
// init effects on static abilities (init continuous effects; warning, game state contains copy)
for (ContinuousEffect effect : game.getState().getContinuousEffects().getLayeredEffects(game)) {
Optional<Ability> ability = game.getState().getContinuousEffects().getLayeredEffectAbilities(effect).stream().findFirst();
if (ability.isPresent() && permanent.getId().equals(ability.get().getSourceId())) {
effect.init(ability.get(), game, player.getId());
}
}
}
/**
* Choose default card's part to put on battlefield (for cheats and tests only)
* or to find a default card side (for copy effect)
*
* @param card
* @return
*/
public static Card getDefaultCardSideForBattlefield(Game game, Card card) {
if (card instanceof PermanentCard) {
return card;
}
// must choose left side all time
Card permCard;
if (card instanceof SplitCard) {
permCard = card;
} else if (card instanceof CardWithSpellOption) {
permCard = card;
} else if (card instanceof ModalDoubleFacedCard) {
permCard = ((ModalDoubleFacedCard) card).getLeftHalfCard();
} else {
permCard = card;
}
// must be creature/planeswalker (if you catch this error then check targeting/copying code)
if (permCard.isInstantOrSorcery(game)) {
throw new IllegalArgumentException("Card side can't be put to battlefield: " + permCard.getName());
}
return permCard;
}
/**
* If a card object is moved to the battlefield, object id can be different (e.g. MDFC).
* Use this method to get the permanent object from the card object after move to battlefield.
* Can return null if not found on the battlefield.
*/
public static Permanent getPermanentFromCardPutToBattlefield(Card card, Game game) {
return game.getPermanent(CardUtil.getDefaultCardSideForBattlefield(game, card).getId());
}
/**
* Return card name for same name searching
*
* @param card
* @return
*/
public static String getCardNameForSameNameSearch(Card card) {
// it's ok to return one name only cause NamePredicate can find same card by first name
if (card instanceof SplitCard) {
return ((SplitCard) card).getLeftHalfCard().getName();
} else if (card instanceof ModalDoubleFacedCard) {
return ((ModalDoubleFacedCard) card).getLeftHalfCard().getName();
} else {
return card.getName();
}
}
public static List<String> getCardRulesWithAdditionalInfo(MageObject object,
Abilities<Ability> rulesSource, Abilities<Ability> hintAbilities) {
return getCardRulesWithAdditionalInfo(null, object, rulesSource, hintAbilities);
}
/**
* Prepare rules list from abilities
*
* @param rulesSource abilities list to show as rules
* @param hintsSource abilities list to show as card hints only (you can add additional hints here; example: from second or transformed side)
*/
public static List<String> getCardRulesWithAdditionalInfo(Game game, MageObject object,
Abilities<Ability> rulesSource, Abilities<Ability> hintsSource) {
try {
List<String> rules = rulesSource.getRules();
if (game == null || game.getPhase() == null) {
// dynamic hints for started game only
return rules;
}
// additional effect's info from card.addInfo methods
CardState cardState = game.getState().getCardState(object.getId());
if (cardState != null) {
rules.addAll(cardState.getInfo().values());
}
// ability hints
List<String> abilityHints = new ArrayList<>();
if (HintUtils.ABILITY_HINTS_ENABLE) {
for (Ability ability : hintsSource) {
for (Hint hint : ability.getHints()) {
String s = hint.getText(game, ability);
if (s != null && !s.isEmpty()) {
abilityHints.add(s);
}
}
}
}
// restrict hints only for permanents, not cards
// total hints
if (!abilityHints.isEmpty()) {
rules.add(HintUtils.HINT_START_MARK);
HintUtils.appendHints(rules, abilityHints);
}
return rules;
} catch (Exception e) {
logger.error("Exception in rules generation for object: " + object.getName(), e);
}
return RULES_ERROR_INFO;
}
/**
* Take control under another player, use it in inner effects like Word of Commands. Don't forget to end it in same code.
*
* @param game
* @param controller
* @param playerUnderControl
* @param givePauseForResponse if you want to give controller time to watch opponent's hand (if you remove control effect in the end of code)
*/
public static void takeControlUnderPlayerStart(Game game, Ability source, Player controller, Player playerUnderControl, boolean givePauseForResponse) {
// game logs added in child's call
if (!controller.controlPlayersTurn(game, playerUnderControl.getId(), CardUtil.getSourceLogName(game, source))) {
return;
}
// give pause, so new controller can look around battlefield and hands before finish controlling choose dialog
if (givePauseForResponse) {
while (controller.canRespond()) {
if (controller.chooseUse(Outcome.Benefit, "You got control of " + playerUnderControl.getLogName()
+ ". Use switch hands button to view opponent's hand.", null,
"Continue", "Wait", null, game)) {
break;
}
}
}
}
/**
* Return control under another player, use it in inner effects like Word of Commands.
*
* @param game
* @param controller
* @param playerUnderControl
*/
public static void takeControlUnderPlayerEnd(Game game, Ability source, Player controller, Player playerUnderControl) {
playerUnderControl.setGameUnderYourControl(game, true, false);
if (!playerUnderControl.getTurnControlledBy().equals(controller.getId())) {
game.informPlayers(controller.getLogName() + " return control of the turn to " + playerUnderControl.getLogName() + CardUtil.getSourceLogName(game, source));
controller.getPlayersUnderYourControl().remove(playerUnderControl.getId());
}
}
// TODO: use CastManaAdjustment instead of boolean anyColor
public static void makeCardPlayable(Game game, Ability source, Card card, boolean useCastSpellOnly, Duration duration, boolean anyColor) {
makeCardPlayable(game, source, card, useCastSpellOnly, duration, anyColor, null, null);
}
/**
* Add effects to game that allows to play/cast card from current zone and spend mana as any type for it.
* Effects will be discarded/ignored on any card movements or blinks (after ZCC change)
* <p>
* Affected to all card's parts
*
* @param game
* @param card
* @param duration
* @param anyColor
* @param condition can be null
*/
// TODO: use CastManaAdjustment instead of boolean anyColor
public static void makeCardPlayable(Game game, Ability source, Card card, boolean useCastSpellOnly, Duration duration, boolean anyColor, UUID playerId, Condition condition) {
// Effect can be used for cards in zones and permanents on battlefield
// PermanentCard's ZCC is static, but we need updated ZCC from the card (after moved to another zone)
// So there is a workaround to get actual card's ZCC
// Example: Hostage Taker
UUID objectId = card.getMainCard().getId();
int zcc = game.getState().getZoneChangeCounter(objectId);
game.addEffect(new CanPlayCardControllerEffect(game, objectId, zcc, useCastSpellOnly, duration, playerId, condition), source);
if (anyColor) {
game.addEffect(new YouMaySpendManaAsAnyColorToCastTargetEffect(duration, playerId, condition).setTargetPointer(new FixedTarget(objectId, zcc)), source);
}
}
public interface SpellCastTracker {
boolean checkCard(Card card, Game game);
void addCard(Card card, Ability source, Game game);
}
/**
* Retrieves a list of all castable components from a given card based on certain conditions.
* <p>
* Castable components are parts of a card that can be played or cast,
* such as the adventure and main side of adventure spells or both sides of a fuse card.
*
* @param cardToCast
* @param filter An optional filter to determine if a card is eligible for casting.
* @param source The ability or source responsible for the casting.
* @param player
* @param game
* @param spellCastTracker An optional tracker for spell casting.
* @param playLand A boolean flag indicating whether playing lands is allowed.
* @return A list of castable components from the input card, considering the provided conditions.
*/
public static List<Card> getCastableComponents(Card cardToCast, FilterCard filter, Ability source, Player player, Game game, SpellCastTracker spellCastTracker, boolean playLand) {
UUID playerId = player.getId();
List<Card> cards = new ArrayList<>();
if (cardToCast instanceof CardWithHalves) {
cards.add(((CardWithHalves) cardToCast).getLeftHalfCard());
cards.add(((CardWithHalves) cardToCast).getRightHalfCard());
} else if (cardToCast instanceof CardWithSpellOption) {
cards.add(cardToCast);
cards.add(((CardWithSpellOption) cardToCast).getSpellCard());
} else {
cards.add(cardToCast);
}
cards.removeIf(Objects::isNull);
if (!playLand || !player.canPlayLand() || !game.isActivePlayer(playerId)) {
cards.removeIf(card -> card.isLand(game));
}
if (filter != null) {
cards.removeIf(card -> !filter.match(card, playerId, source, game));
}
if (spellCastTracker != null) {
cards.removeIf(card -> !spellCastTracker.checkCard(card, game));
}
return cards;
}
private static final FilterCard defaultFilter = new FilterCard("card to cast");
public static boolean castSpellWithAttributesForFree(Player player, Ability source, Game game, Card card) {
return castSpellWithAttributesForFree(player, source, game, card, StaticFilters.FILTER_CARD);
}
public static boolean castSpellWithAttributesForFree(Player player, Ability source, Game game, Card card, FilterCard filter) {
return castSpellWithAttributesForFree(player, source, game, new CardsImpl(card), filter);
}
public static boolean castSpellWithAttributesForFree(Player player, Ability source, Game game, Cards cards, FilterCard filter) {
return castSpellWithAttributesForFree(player, source, game, cards, filter, null);
}
public static boolean castSpellWithAttributesForFree(Player player, Ability source, Game game, Cards cards, FilterCard filter, SpellCastTracker spellCastTracker) {
return castSpellWithAttributesForFree(player, source, game, cards, filter, spellCastTracker, false);
}
public static boolean castSpellWithAttributesForFree(Player player, Ability source, Game game, Cards cards, FilterCard filter, SpellCastTracker spellCastTracker, boolean playLand) {
Map<UUID, List<Card>> cardMap = new HashMap<>();
for (Card card : cards.getCards(game)) {
List<Card> castableComponents = getCastableComponents(card, filter, source, player, game, spellCastTracker, playLand);
if (!castableComponents.isEmpty()) {
cardMap.put(card.getId(), castableComponents);
}
}
Card cardToCast;
switch (cardMap.size()) {
case 0:
return false;
case 1:
cardToCast = cards.get(cardMap.keySet().stream().findFirst().orElse(null), game);
break;
default:
Cards castableCards = new CardsImpl(cardMap.keySet());
TargetCard target = new TargetCard(0, 1, Zone.ALL, defaultFilter);
target.withNotTarget(true);
player.choose(Outcome.PlayForFree, castableCards, target, source, game);
cardToCast = castableCards.get(target.getFirstTarget(), game);
}
if (cardToCast == null) {
return false;
}
List<Card> partsToCast = cardMap.get(cardToCast.getId());
String partsInfo = partsToCast
.stream()
.map(MageObject::getLogName)
.collect(Collectors.joining(" or "));
if (partsToCast.size() < 1
|| !player.chooseUse(
Outcome.PlayForFree, "Cast spell without paying its mana cost (" + partsInfo + ")?", source, game
)) {
return false;
}
partsToCast.forEach(card -> game.getState().setValue("PlayFromNotOwnHandZone" + card.getId(), Boolean.TRUE));
ActivatedAbility chosenAbility;
if (playLand) {
chosenAbility = player.chooseLandOrSpellAbility(cardToCast, game, true);
} else {
chosenAbility = player.chooseAbilityForCast(cardToCast, game, true);
}
boolean result;
if (chosenAbility instanceof SpellAbility) {
result = player.cast(
(SpellAbility) chosenAbility,
game, true, new ApprovingObject(source, game)
);
} else if (playLand && chosenAbility instanceof PlayLandAbility) {
Card land = game.getCard(chosenAbility.getSourceId());
result = player.playLand(land, game, true);
} else {
result = false;
}
partsToCast.forEach(card -> game.getState().setValue("PlayFromNotOwnHandZone" + card.getId(), null));
if (result && spellCastTracker != null) {
spellCastTracker.addCard(cardToCast, source, game);
}
if (player.isComputer() && !result) {
cards.remove(cardToCast);
}
return result;
}
private static boolean cardsHasCastableParts(Cards cards, FilterCard filter, Ability source, Player player, Game game, SpellCastTracker spellCastTracker, boolean playLand) {
return cards
.getCards(game)
.stream()
.anyMatch(card -> !getCastableComponents(card, filter, source, player, game, spellCastTracker, playLand).isEmpty());
}
public static void castMultipleWithAttributeForFree(Player player, Ability source, Game game, Cards cards, FilterCard filter) {
castMultipleWithAttributeForFree(player, source, game, cards, filter, Integer.MAX_VALUE);
}
public static void castMultipleWithAttributeForFree(Player player, Ability source, Game game, Cards cards, FilterCard filter, int maxSpells) {
castMultipleWithAttributeForFree(player, source, game, cards, filter, maxSpells, null);
}
public static void castMultipleWithAttributeForFree(Player player, Ability source, Game game, Cards cards, FilterCard filter, int maxSpells, SpellCastTracker spellCastTracker) {
castMultipleWithAttributeForFree(player, source, game, cards, filter, maxSpells, spellCastTracker, false);
}
public static void castMultipleWithAttributeForFree(Player player, Ability source, Game game, Cards cards, FilterCard filter, int maxSpells, SpellCastTracker spellCastTracker, boolean playLand) {
if (maxSpells == 1) {
CardUtil.castSpellWithAttributesForFree(player, source, game, cards, filter);
return;
}
int castCount = 0;
int maxCastCount = Integer.min(cards.size(), maxSpells);
cards.removeZone(Zone.STACK, game);
if (!cardsHasCastableParts(cards, filter, source, player, game, spellCastTracker, playLand)) {
return;
}
while (player.canRespond()) {
boolean wasCast = CardUtil.castSpellWithAttributesForFree(player, source, game, cards, filter, spellCastTracker, playLand);
// nothing to cast
cards.removeZone(Zone.STACK, game);
if (cards.isEmpty() || !cardsHasCastableParts(cards, filter, source, player, game, spellCastTracker, playLand)) {
break;
}
if (wasCast) {
// no more tries to cast
castCount++;
if (castCount >= maxCastCount) {
break;
}
} else {
// player want to cancel
if (player.isComputer()) {
// AI can't choose good spell, so stop
break;
} else {
// Human can choose wrong spell part, so allow to continue
if (!player.chooseUse(Outcome.PlayForFree, "Continue casting spells?", source, game)) {
break;
}
}
}
}
}
public static boolean castSingle(Player player, Ability source, Game game, Card card) {
return castSingle(player, source, game, card, null);
}
public static boolean castSingle(Player player, Ability source, Game game, Card card, ManaCostsImpl<ManaCost> manaCost) {
return castSingle(player, source, game, card, false, manaCost);
}
public static boolean castSingle(Player player, Ability source, Game game, Card card, boolean noMana, ManaCostsImpl<ManaCost> manaCost) {
// handle split-cards
if (card instanceof SplitCard) {
SplitCardHalf leftHalfCard = ((SplitCard) card).getLeftHalfCard();
SplitCardHalf rightHalfCard = ((SplitCard) card).getRightHalfCard();
if (manaCost != null) {
// get additional cost if any
Costs<Cost> additionalCostsLeft = leftHalfCard.getSpellAbility().getCosts();
Costs<Cost> additionalCostsRight = rightHalfCard.getSpellAbility().getCosts();
// set alternative cost and any additional cost
player.setCastSourceIdWithAlternateMana(leftHalfCard.getId(), manaCost, additionalCostsLeft, MageIdentifier.Default);
player.setCastSourceIdWithAlternateMana(rightHalfCard.getId(), manaCost, additionalCostsRight, MageIdentifier.Default);
}
// allow the card to be cast
game.getState().setValue("PlayFromNotOwnHandZone" + leftHalfCard.getId(), Boolean.TRUE);
game.getState().setValue("PlayFromNotOwnHandZone" + rightHalfCard.getId(), Boolean.TRUE);
}
// handle MDFC
if (card instanceof ModalDoubleFacedCard) {
ModalDoubleFacedCardHalf leftHalfCard = ((ModalDoubleFacedCard) card).getLeftHalfCard();
ModalDoubleFacedCardHalf rightHalfCard = ((ModalDoubleFacedCard) card).getRightHalfCard();
if (manaCost != null) {
// some MDFC cards are lands. IE: sea gate restoration
if (!leftHalfCard.isLand(game)) {
// get additional cost if any
Costs<Cost> additionalCostsMDFCLeft = leftHalfCard.getSpellAbility().getCosts();
// set alternative cost and any additional cost
player.setCastSourceIdWithAlternateMana(leftHalfCard.getId(), manaCost, additionalCostsMDFCLeft, MageIdentifier.Default);
}
if (!rightHalfCard.isLand(game)) {
// get additional cost if any
Costs<Cost> additionalCostsMDFCRight = rightHalfCard.getSpellAbility().getCosts();
// set alternative cost and any additional cost
player.setCastSourceIdWithAlternateMana(rightHalfCard.getId(), manaCost, additionalCostsMDFCRight, MageIdentifier.Default);
}
}
// allow the card to be cast
game.getState().setValue("PlayFromNotOwnHandZone" + leftHalfCard.getId(), Boolean.TRUE);
game.getState().setValue("PlayFromNotOwnHandZone" + rightHalfCard.getId(), Boolean.TRUE);
}
// handle adventure cards
if (card instanceof CardWithSpellOption) {
Card creatureCard = card.getMainCard();
Card spellCard = ((CardWithSpellOption) card).getSpellCard();
if (manaCost != null) {
// get additional cost if any
Costs<Cost> additionalCostsCreature = creatureCard.getSpellAbility().getCosts();
Costs<Cost> additionalCostsSpellCard = spellCard.getSpellAbility().getCosts();
// set alternative cost and any additional cost
player.setCastSourceIdWithAlternateMana(creatureCard.getId(), manaCost, additionalCostsCreature, MageIdentifier.Default);
player.setCastSourceIdWithAlternateMana(spellCard.getId(), manaCost, additionalCostsSpellCard, MageIdentifier.Default);
}
// allow the card to be cast
game.getState().setValue("PlayFromNotOwnHandZone" + creatureCard.getId(), Boolean.TRUE);
game.getState().setValue("PlayFromNotOwnHandZone" + spellCard.getId(), Boolean.TRUE);
}
// normal card
if (manaCost != null) {
// get additional cost if any
Costs<Cost> additionalCostsNormalCard = card.getSpellAbility().getCosts();
player.setCastSourceIdWithAlternateMana(card.getMainCard().getId(), manaCost, additionalCostsNormalCard, MageIdentifier.Default);
}
game.getState().setValue("PlayFromNotOwnHandZone" + card.getMainCard().getId(), Boolean.TRUE);
// cast it
boolean result = player.cast(player.chooseAbilityForCast(card.getMainCard(), game, noMana),
game, noMana, new ApprovingObject(source, game));
// turn off effect after cast on every possible card-face
if (card instanceof SplitCard) {
SplitCardHalf leftHalfCard = ((SplitCard) card).getLeftHalfCard();
SplitCardHalf rightHalfCard = ((SplitCard) card).getRightHalfCard();
game.getState().setValue("PlayFromNotOwnHandZone" + leftHalfCard.getId(), null);
game.getState().setValue("PlayFromNotOwnHandZone" + rightHalfCard.getId(), null);
}
if (card instanceof ModalDoubleFacedCard) {
ModalDoubleFacedCardHalf leftHalfCard = ((ModalDoubleFacedCard) card).getLeftHalfCard();
ModalDoubleFacedCardHalf rightHalfCard = ((ModalDoubleFacedCard) card).getRightHalfCard();
game.getState().setValue("PlayFromNotOwnHandZone" + leftHalfCard.getId(), null);
game.getState().setValue("PlayFromNotOwnHandZone" + rightHalfCard.getId(), null);
}
if (card instanceof CardWithSpellOption) {
Card creatureCard = card.getMainCard();
Card spellCard = ((CardWithSpellOption) card).getSpellCard();
game.getState().setValue("PlayFromNotOwnHandZone" + creatureCard.getId(), null);
game.getState().setValue("PlayFromNotOwnHandZone" + spellCard.getId(), null);
}
// turn off effect on a normal card
game.getState().setValue("PlayFromNotOwnHandZone" + card.getId(), null);
return result;
}
/**
* Pay life in effects
*
* @param lifeToPay
* @param player
* @param source
* @param game
* @return true on good pay
*/
public static boolean tryPayLife(int lifeToPay, Player player, Ability source, Game game) {
// rules:
// 119.4. If a cost or effect allows a player to pay an amount of life greater than 0, the player may do so
// only if their life total is greater than or equal to the amount of the payment. If a player pays life,
// the payment is subtracted from their life total; in other words, the player loses that much life.
// (Players can always pay 0 life.)
if (lifeToPay == 0) {
return true;
} else if (lifeToPay < 0) {
return false;
}
if (lifeToPay > player.getLife()) {
return false;
}
if (game.replaceEvent(GameEvent.getEvent(GameEvent.EventType.PAY_LIFE, player.getId(), source, player.getId(), lifeToPay))) {
// 2023-08-20: For now, Cost being replaced are paid.
// Waiting on actual ruling of Ashiok, Wicked Manipulator.
return true;
}
int lostLife = player.loseLife(lifeToPay, game, source, false);
if (lostLife > 0) {
game.fireEvent(GameEvent.getEvent(GameEvent.EventType.LIFE_PAID, player.getId(), source, player.getId(), lostLife));
return true;
}
return false;
}
public static String getSourceName(Game game, Ability source) {
MageObject sourceObject = source.getSourceObject(game);
return sourceObject != null ? sourceObject.getName() : "";
}
public static String getSourceIdName(Game game, Ability source) {
MageObject sourceObject = source.getSourceObject(game);
return sourceObject != null ? sourceObject.getIdName() : "";
}
/**
* Generates source log name to insert into log messages
*
* @param game
* @param sourceId
* @param namePrefix if source object exists then that will be added before name
* @param namePostfix if source object exists then that will be added after name
* @param nonFoundText if source object not exists then it will be used
* @return
*/
public static String getSourceLogName(Game game, String namePrefix, UUID sourceId, String namePostfix, String nonFoundText) {
MageObject sourceObject = game.getObject(sourceId);
return (sourceObject == null ? nonFoundText : namePrefix + sourceObject.getLogName() + namePostfix);
}
public static String getSourceLogName(Game game, String namePrefix, Ability source, String namePostfix, String nonFoundText) {
return getSourceLogName(game, namePrefix, source == null ? null : source.getSourceId(), namePostfix, nonFoundText);
}
public static String getSourceLogName(Game game, Ability source) {
return CardUtil.getSourceLogName(game, " (source: ", source, ")", "");
}
public static String getSourceLogName(Game game, Ability source, UUID ignoreSourceId) {
if (ignoreSourceId != null && source != null && ignoreSourceId.equals(source.getSourceId())) {
return "";
}
return CardUtil.getSourceLogName(game, " (source: ", source, ")", "");
}
/**
* Find actual ZCC of source object, works in any moment (even before source ability activated)
* <p>
* Use case for usage: if you want to get actual object ZCC before ability resolve
* (ability gets zcc after resolve/activate/trigger only -- ?wtf workaround to targets setup I think?)
*
* @param game
* @param source
* @return
*/
public static int getActualSourceObjectZoneChangeCounter(Game game, Ability source) {
// current object zcc, find from source object (it can be permanent or spell on stack)
int zcc = source.getStackMomentSourceZCC();
if (zcc == 0) {
// if ability is not activated yet then use current object's zcc (example: triggered etb ability checking the kicker conditional)
zcc = game.getState().getZoneChangeCounter(source.getSourceId());
}
return zcc;
}
/**
* Create a MageObjectReference of the ability's source
* Subtract 1 zcc if not on the stack, referencing when it was on the stack if it's a resolved permanent.
* works in any moment (even before source ability activated)
*
* @param game
* @param ability
* @return MageObjectReference to the ability's source stack moment
*/
public static MageObjectReference getSourceStackMomentReference(Game game, Ability ability) {
// Squad/Kicker activates in STACK zone so all zcc must be from "stack moment"
// Use cases:
// * resolving spell have same zcc (example: check kicker status in sorcery/instant);
// * copied spell have same zcc as source spell (see Spell.copySpell and zcc sync);
// * creature/token from resolved spell have +1 zcc after moved to battlefield (example: check kicker status in ETB triggers/effects);
// find object info from the source ability (it can be a permanent or a spell on stack, on the moment of trigger/resolve)
MageObject sourceObject = ability.getSourceObject(game);
Zone sourceObjectZone = game.getState().getZone(sourceObject.getId());
int zcc = CardUtil.getActualSourceObjectZoneChangeCounter(game, ability);
// find "stack moment" zcc:
// * permanent cards enters from STACK to BATTLEFIELD (+1 zcc)
// * permanent tokens enters from OUTSIDE to BATTLEFIELD (+1 zcc, see prepare code in TokenImpl.putOntoBattlefieldHelper)
// * spells and copied spells resolves on STACK (zcc not changes)
if (sourceObjectZone != Zone.STACK) {
--zcc;
}
return new MageObjectReference(ability.getSourceId(), zcc, game);
}
/**
* Returns the entire cost tags map of either the source ability, or the permanent source of the ability. May be null.
* Works in any moment (even before source ability activated)
* <p>
* Usually you should use one of the single tag functions instead: getSourceCostsTag() or checkSourceCostsTagExists().
* Use this function with caution, as it directly exposes the backing data structure.
*
* @param game
* @param source
* @return the tag map (or null)
*/
public static Map<String, Object> getSourceCostsTagsMap(Game game, Ability source) {
Map<String, Object> costTags;
if (game == null) {
return null;
}
// from spell ability - direct access
costTags = source.getCostsTagMap();
if (costTags != null) {
return costTags;
}
// from any ability after resolve - access by permanent
Permanent permanent = source.getSourcePermanentOrLKI(game);
if (permanent != null) {
costTags = game.getPermanentCostsTags().get(CardUtil.getSourceStackMomentReference(game, source));
return costTags;
}
// from any ability before resolve (on stack) - access by spell ability
Spell sourceObject = game.getSpellOrLKIStack(source.getSourceId());
if (sourceObject != null) {
return sourceObject.getSpellAbility().getCostsTagMap();
}
return null;
}
/**
* Check if a specific tag exists in the cost tags of either the source ability, or the permanent source of the ability.
* Works in any moment (even before source ability activated)
*
* @param game
* @param source
* @param tag The tag's string identifier to look up
* @return if the tag was found
*/
public static boolean checkSourceCostsTagExists(Game game, Ability source, String tag) {
Map<String, Object> costTags = getSourceCostsTagsMap(game, source);
return costTags != null && costTags.containsKey(tag);
}
/**
* Find a specific tag in the cost tags of either the source ability, or the permanent source of the ability.
* Works in any moment (even before source ability activated)
* Do not use with null values, use checkSourceCostsTagExists instead
*
* @param game
* @param source
* @param tag The tag's string identifier to look up
* @param defaultValue A default value to return if the tag is not found
* @return The object stored by the tag if found, the default if not
*/
public static <T> T getSourceCostsTag(Game game, Ability source, String tag, T defaultValue) {
Map<String, Object> costTags = getSourceCostsTagsMap(game, source);
if (costTags != null) {
Object value = costTags.getOrDefault(tag, defaultValue);
if (value == null) {
throw new IllegalStateException("Wrong code usage: Costs tag " + tag + " has value stored of type null but is trying to be read. Use checkSourceCostsTagExists");
}
if (defaultValue != null && value.getClass() != defaultValue.getClass()) {
throw new IllegalStateException("Wrong code usage: Costs tag " + tag + " has value stored of type " + value.getClass().getName() + " different from default of type " + defaultValue.getClass().getName());
}
return (T) value;
}
return defaultValue;
}
public static int getSourceCostsTagX(Game game, Ability source, int defaultValue) {
return getSourceCostsTag(game, source, "X", defaultValue);
}
public static String addCostVerb(String text) {
if (costWords.stream().anyMatch(text.toLowerCase(Locale.ENGLISH)::startsWith)) {
return text;
}
return "pay " + text;
}
private static boolean isImmutableObject(Object o) {
return o == null
|| o instanceof Number || o instanceof Boolean || o instanceof String
|| o instanceof MageObjectReference || o instanceof UUID
|| o instanceof Enum;
}
/**
* Make deep copy of any object (supported by xmage)
* <p>
* Warning, don't use self reference objects because it will raise StackOverflowError
*
* @param value
* @param <T>
* @return
*/
public static <T> T deepCopyObject(T value) {
if (isImmutableObject(value)) {
return value;
} else if (value instanceof Copyable) {
return (T) ((Copyable<T>) value).copy();
} else if (value instanceof Watcher) {
return (T) ((Watcher) value).copy();
} else if (value instanceof Ability) {
return (T) ((Ability) value).copy();
} else if (value instanceof PlayerList) {
return (T) ((PlayerList) value).copy();
} else if (value instanceof EnumSet) {
return (T) ((EnumSet) value).clone();
} else if (value instanceof EnumMap) {
return (T) deepCopyEnumMap((EnumMap) value);
} else if (value instanceof LinkedHashSet) {
return (T) deepCopyLinkedHashSet((LinkedHashSet) value);
} else if (value instanceof LinkedHashMap) {
return (T) deepCopyLinkedHashMap((LinkedHashMap) value);
} else if (value instanceof TreeSet) {
return (T) deepCopyTreeSet((TreeSet) value);
} else if (value instanceof HashSet) {
return (T) deepCopyHashSet((HashSet) value);
} else if (value instanceof HashMap) {
return (T) deepCopyHashMap((HashMap) value);
} else if (value instanceof List) {
return (T) deepCopyList((List) value);
} else if (value instanceof AbstractMap.SimpleImmutableEntry) { //Used by Leonin Arbiter, Vessel Of The All Consuming Wanderer as a generic Pair class
AbstractMap.SimpleImmutableEntry entryValue = (AbstractMap.SimpleImmutableEntry) value;
return (T) new AbstractMap.SimpleImmutableEntry(deepCopyObject(entryValue.getKey()), deepCopyObject(entryValue.getValue()));
} else {
// warning, do not add unnecessarily new data types and structures to game engine, try to use only standard types (see above)
throw new IllegalStateException("Unhandled object " + value.getClass().getSimpleName() + " during deep copy, must add explicit handling of all Object types");
}
}
private static <T extends Comparable<T>> TreeSet<T> deepCopyTreeSet(TreeSet<T> original) {
if (original.getClass() != TreeSet.class) {
throw new IllegalStateException("Unhandled TreeSet type " + original.getClass().getSimpleName() + " in deep copy");
}
TreeSet<T> newSet = new TreeSet<>();
for (T value : original) {
newSet.add((T) deepCopyObject(value));
}
return newSet;
}
private static <T> HashSet<T> deepCopyHashSet(Set<T> original) {
if (original.getClass() != HashSet.class) {
throw new IllegalStateException("Unhandled HashSet type " + original.getClass().getSimpleName() + " in deep copy");
}
HashSet<T> newSet = new HashSet<>(original.size());
for (T value : original) {
newSet.add((T) deepCopyObject(value));
}
return newSet;
}
private static <T> LinkedHashSet<T> deepCopyLinkedHashSet(LinkedHashSet<T> original) {
if (original.getClass() != LinkedHashSet.class) {
throw new IllegalStateException("Unhandled LinkedHashSet type " + original.getClass().getSimpleName() + " in deep copy");
}
LinkedHashSet<T> newSet = new LinkedHashSet<>(original.size());
for (T value : original) {
newSet.add((T) deepCopyObject(value));
}
return newSet;
}
private static <T> List<T> deepCopyList(List<T> original) { //always returns an ArrayList
if (original.getClass() != ArrayList.class) {
throw new IllegalStateException("Unhandled List type " + original.getClass().getSimpleName() + " in deep copy");
}
ArrayList<T> newList = new ArrayList<>(original.size());
for (T value : original) {
newList.add((T) deepCopyObject(value));
}
return newList;
}
private static <K, V> HashMap<K, V> deepCopyHashMap(Map<K, V> original) {
if (original.getClass() != HashMap.class) {
throw new IllegalStateException("Unhandled HashMap type " + original.getClass().getSimpleName() + " in deep copy");
}
HashMap<K, V> newMap = new HashMap<>(original.size());
for (Map.Entry<K, V> entry : original.entrySet()) {
newMap.put((K) deepCopyObject(entry.getKey()), (V) deepCopyObject(entry.getValue()));
}
return newMap;
}
private static <K, V> LinkedHashMap<K, V> deepCopyLinkedHashMap(Map<K, V> original) {
if (original.getClass() != LinkedHashMap.class) {
throw new IllegalStateException("Unhandled LinkedHashMap type " + original.getClass().getSimpleName() + " in deep copy");
}
LinkedHashMap<K, V> newMap = new LinkedHashMap<>(original.size());
for (Map.Entry<K, V> entry : original.entrySet()) {
newMap.put((K) deepCopyObject(entry.getKey()), (V) deepCopyObject(entry.getValue()));
}
return newMap;
}
private static <K extends Enum<K>, V> EnumMap<K, V> deepCopyEnumMap(Map<K, V> original) {
if (original.getClass() != EnumMap.class) {
throw new IllegalStateException("Unhandled EnumMap type " + original.getClass().getSimpleName() + " in deep copy");
}
EnumMap<K, V> newMap = new EnumMap<>(original);
for (Map.Entry<K, V> entry : newMap.entrySet()) {
entry.setValue((V) deepCopyObject(entry.getValue()));
}
return newMap;
}
/**
* Collect all possible object's parts (example: all sides in mdf/split cards)
* <p>
* Works with any objects, so commander object can return four ids: commander + main card + left card + right card
* If you pass Card object then it return main card + all parts
*
* @param object
* @return
*/
public static Set<UUID> getObjectParts(MageObject object) {
Set<UUID> res = new LinkedHashSet<>(); // set must be ordered
List<MageObject> allParts = getObjectPartsAsObjects(object);
allParts.forEach(part -> {
res.add(part.getId());
});
return res;
}
public static List<MageObject> getObjectPartsAsObjects(MageObject object) {
List<MageObject> res = new ArrayList<>();
if (object == null) {
return res;
}
if (object instanceof SplitCard || object instanceof SplitCardHalf) {
SplitCard mainCard = (SplitCard) ((Card) object).getMainCard();
res.add(mainCard);
res.add(mainCard.getLeftHalfCard());
res.add(mainCard.getRightHalfCard());
} else if (object instanceof ModalDoubleFacedCard || object instanceof ModalDoubleFacedCardHalf) {
ModalDoubleFacedCard mainCard = (ModalDoubleFacedCard) ((Card) object).getMainCard();
res.add(mainCard);
res.add(mainCard.getLeftHalfCard());
res.add(mainCard.getRightHalfCard());
} else if (object instanceof CardWithSpellOption || object instanceof SpellOptionCard) {
CardWithSpellOption mainCard = (CardWithSpellOption) ((Card) object).getMainCard();
res.add(mainCard);
res.add(mainCard.getSpellCard());
} else if (object instanceof Spell) {
// example: activate Lightning Storm's ability from the spell on the stack
res.add(object);
res.addAll(getObjectPartsAsObjects(((Spell) object).getCard()));
} else if (object instanceof Commander) {
// commander can contains double sides
res.add(object);
res.addAll(getObjectPartsAsObjects(((Commander) object).getSourceObject()));
} else {
res.add(object);
}
return res;
}
public static int parseIntWithDefault(String value, int defaultValue) {
int res;
try {
res = Integer.parseInt(value);
} catch (NumberFormatException ex) {
res = defaultValue;
}
return res;
}
/**
* Find mapping from original to copied card (e.g. map original left side with copied left side)
*
* @param originalCard
* @param copiedCard
* @return
*/
public static Map<UUID, MageObject> getOriginalToCopiedPartsMap(Card originalCard, Card copiedCard) {
List<UUID> oldIds = new ArrayList<>(CardUtil.getObjectParts(originalCard));
List<MageObject> newObjects = new ArrayList<>(CardUtil.getObjectPartsAsObjects(copiedCard));
if (oldIds.size() != newObjects.size()) {
throw new IllegalStateException("Found wrong card parts after copy: " + originalCard.getName() + " -> " + copiedCard.getName());
}
Map<UUID, MageObject> mapOldToNew = new HashMap<>();
for (int i = 0; i < oldIds.size(); i++) {
mapOldToNew.put(oldIds.get(i), newObjects.get(i));
}
return mapOldToNew;
}
/**
* Return turn info for game. Uses in game logs and debug.
*
* @param game
* @return
*/
public static String getTurnInfo(Game game) {
return getTurnInfo(game == null ? null : game.getState());
}
public static String getTurnInfo(GameState gameState) {
// no turn info
if (gameState == null) {
return null;
}
// T0 - for not started game
// T2 - for starting of the turn
if (gameState.getTurn().getStep() == null) {
return "T" + gameState.getTurnNum();
}
// normal game
return "T" + gameState.getTurnNum() + "." + gameState.getTurn().getStep().getType().getStepShortText();
}
public static String concatWithOr(List<String> strings) {
return concatWith(strings, "or");
}
public static String concatWithAnd(List<String> strings) {
return concatWith(strings, "and");
}
private static String concatWith(List<String> strings, String last) {
switch (strings.size()) {
case 0:
return "";
case 1:
return strings.get(0);
case 2:
return strings.get(0) + " " + last + " " + strings.get(1);
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < strings.size(); i++) {
sb.append(strings.get(i));
if (i == strings.size() - 1) {
break;
}
sb.append(", ");
if (i == strings.size() - 2) {
sb.append(last);
sb.append(' ');
}
}
return sb.toString();
}
public static <T> Optional<T> getEffectValueFromAbility(Ability ability, String key, Class<T> clazz) {
return castStream(
ability.getAllEffects()
.stream()
.map(effect -> effect.getValue(key)),
clazz
).findFirst();
}
public static <T> Stream<T> castStream(Collection<?> collection, Class<T> clazz) {
return castStream(collection.stream(), clazz);
}
public static <T> Stream<T> castStream(Stream<?> stream, Class<T> clazz) {
return stream.filter(clazz::isInstance).map(clazz::cast).filter(Objects::nonNull);
}
public static <T> boolean checkAnyPairs(Collection<T> collection, BiPredicate<T, T> predicate) {
return streamPairsWithMap(collection, (t1, t2) -> predicate.test(t1, t2)).anyMatch(x -> x);
}
public static <T> Stream<T> streamAllPairwiseMatches(Collection<T> collection, BiPredicate<T, T> predicate) {
return streamPairsWithMap(
collection,
(t1, t2) -> predicate.test(t1, t2)
? Stream.of(t1, t2)
: Stream.<T>empty()
).flatMap(Function.identity()).distinct();
}
private static class IntPairIterator implements Iterator<AbstractMap.SimpleImmutableEntry<Integer, Integer>> {
private final int amount;
private int firstCounter = 0;
private int secondCounter = 1;
IntPairIterator(int amount) {
this.amount = amount;
}
@Override
public boolean hasNext() {
return firstCounter + 1 < amount;
}
@Override
public AbstractMap.SimpleImmutableEntry<Integer, Integer> next() {
AbstractMap.SimpleImmutableEntry<Integer, Integer> value
= new AbstractMap.SimpleImmutableEntry(firstCounter, secondCounter);
secondCounter++;
if (secondCounter == amount) {
firstCounter++;
secondCounter = firstCounter + 1;
}
return value;
}
public int getMax() {
// amount choose 2
return (amount * amount - amount) / 2;
}
}
public static <T, U> Stream<U> streamPairsWithMap(Collection<T> collection, BiFunction<T, T, U> function) {
if (collection.size() < 2) {
return Stream.empty();
}
List<T> list;
if (collection instanceof List) {
list = (List<T>) collection;
} else {
list = new ArrayList<>(collection);
}
IntPairIterator it = new IntPairIterator(list.size());
return Stream
.generate(it::next)
.limit(it.getMax())
.map(pair -> function.apply(
list.get(pair.getKey()),
list.get(pair.getValue())
));
}
public static void AssertNoControllerOwnerPredicates(Target target) {
List<Predicate> list = new ArrayList<>();
Predicates.collectAllComponents(target.getFilter().getPredicates(), target.getFilter().getExtraPredicates(), list);
if (list.stream().anyMatch(p -> p instanceof TargetController.ControllerPredicate || p instanceof TargetController.OwnerPredicate
|| p instanceof OwnerIdPredicate || p instanceof ControllerIdPredicate)) {
throw new IllegalArgumentException("Wrong code usage: target adjuster will add controller/owner predicate, but target's filter already has one - " + target);
}
}
/**
* Move card or permanent to dest zone and add counter to it
*
* @param game
* @param source
* @param controller
* @param card can be card or permanent
* @param toZone
* @param counter
*/
public static boolean moveCardWithCounter(Game game, Ability source, Player controller, Card card, Zone toZone, Counter counter) {
if (toZone == Zone.BATTLEFIELD) {
throw new IllegalArgumentException("Wrong code usage - method doesn't support moving to battlefield zone");
}
// workaround:
// in ZONE_CHANGE replace events you must set new zone by event's setToZone,
// BUT for counter effect you need to complete zone change event first (so moveCards calls here)
// TODO: must be fixed someday by:
// * or by new event ZONE_CHANGED to apply counter effect on it
// * or by counter effects applier in ZONE_CHANGE event (see copy or token as example)
// move to zone
if (!controller.moveCards(card, toZone, source, game)) {
return false;
}
// add counter
// after move it's a new object (not a permanent), so must work with main card
Effect effect = new AddCountersTargetEffect(counter);
effect.setTargetPointer(new FixedTarget(card.getMainCard(), game));
effect.apply(game, source);
return true;
}
public static <T> int setOrIncrementValue(T u, Integer i) {
return i == null ? 1 : Integer.sum(i, 1);
}
public static String convertLoyaltyOrDefense(int value) {
switch (value) {
case -2:
return "X";
case -1:
return "";
default:
return "" + value;
}
}
public static int convertLoyaltyOrDefense(String value) {
switch (value) {
case "X":
return -2;
case "":
return -1;
default:
return Integer.parseInt(value);
}
}
public static void checkSetParamForSerializationCompatibility(Set<String> data) {
// HashMap uses inner class for Keys without serialization support,
// so you can't use it for client-server data
if (data != null && data.getClass().getName().endsWith("$KeySet")) {
throw new IllegalArgumentException("Can't use KeySet as param, use new LinkedHashSet<>(data.keySet()) instead");
}
}
public static String substring(String str, int maxLength) {
return substring(str, maxLength, "");
}
/**
* Don't raise exception, so must be used instead standard substring calls all the time
*
* @param str
* @param maxLength
* @param overflowEnding can add ... at the end
* @return
*/
public static String substring(String str, int maxLength, String overflowEnding) {
if (str == null || str.isEmpty()) {
return str;
}
// full
if (str.length() <= maxLength) {
return str;
}
// short
if (maxLength <= overflowEnding.length()) {
return overflowEnding.substring(0, maxLength);
} else {
return (str + overflowEnding).substring(0, maxLength - overflowEnding.length()) + overflowEnding;
}
}
/**
* Copy image related data from one object to another (set code, card number, image number, file name)
* Use it in copy/transform effects
*/
public static void copySetAndCardNumber(MageObject targetObject, MageObject copyFromObject) {
String needSetCode;
String needCardNumber;
String needImageFileName;
int needImageNumber;
boolean needUsesVariousArt = false;
needSetCode = copyFromObject.getExpansionSetCode();
needCardNumber = copyFromObject.getCardNumber();
needImageFileName = copyFromObject.getImageFileName();
needImageNumber = copyFromObject.getImageNumber();
needUsesVariousArt = copyFromObject.getUsesVariousArt();
if (targetObject instanceof Permanent) {
copySetAndCardNumber((Permanent) targetObject, needSetCode, needCardNumber, needImageFileName, needImageNumber, needUsesVariousArt);
} else if (targetObject instanceof Token) {
copySetAndCardNumber((Token) targetObject, needSetCode, needCardNumber, needImageFileName, needImageNumber, needUsesVariousArt);
} else if (targetObject instanceof Card) {
copySetAndCardNumber((Card) targetObject, needSetCode, needCardNumber, needImageFileName, needImageNumber, needUsesVariousArt);
} else {
throw new IllegalStateException("Unsupported target object class: " + targetObject.getClass().getSimpleName());
}
}
private static void copySetAndCardNumber(Permanent targetPermanent, String newSetCode, String newCardNumber, String newImageFileName, Integer newImageNumber, boolean usesVariousArt) {
if (targetPermanent instanceof PermanentCard
|| targetPermanent instanceof PermanentToken) {
targetPermanent.setExpansionSetCode(newSetCode);
targetPermanent.setUsesVariousArt(usesVariousArt);
targetPermanent.setCardNumber(newCardNumber);
targetPermanent.setImageFileName(newImageFileName);
targetPermanent.setImageNumber(newImageNumber);
} else {
throw new IllegalArgumentException("Wrong code usage: un-supported target permanent type: " + targetPermanent.getClass().getSimpleName());
}
}
private static void copySetAndCardNumber(Token targetToken, String newSetCode, String newCardNumber, String newImageFileName, Integer newImageNumber, boolean newUsesVariousArt) {
targetToken.setExpansionSetCode(newSetCode);
targetToken.setCardNumber(newCardNumber);
targetToken.setImageFileName(newImageFileName);
targetToken.setImageNumber(newImageNumber);
// runtime check
if (newUsesVariousArt && newCardNumber.isEmpty()) {
throw new IllegalArgumentException("Wrong code usage: usesVariousArt can be used for token from card only");
}
targetToken.setUsesVariousArt(newUsesVariousArt);
}
private static void copySetAndCardNumber(Card targetCard, String newSetCode, String newCardNumber, String newImageFileName, Integer newImageNumber, boolean usesVariousArt) {
targetCard.setExpansionSetCode(newSetCode);
targetCard.setUsesVariousArt(usesVariousArt);
targetCard.setCardNumber(newCardNumber);
targetCard.setImageFileName(newImageFileName);
targetCard.setImageNumber(newImageNumber);
}
/**
* One single event can be a batch (contain multiple events)
*/
public static Set<UUID> getEventTargets(GameEvent event) {
Set<UUID> res = new HashSet<>();
if (event instanceof BatchEvent) {
res.addAll(((BatchEvent<?>) event).getTargetIds());
} else if (event != null && event.getTargetId() != null) {
res.add(event.getTargetId());
}
return res;
}
/**
* Prepare card name for render in card panels, popups, etc. Can show face down status and real card name instead empty string
*
* @param imageFileName face down status or another inner image name like Morph, Copy, etc
*/
public static String getCardNameForGUI(String name, String imageFileName) {
if (imageFileName.isEmpty()) {
// normal name
return name;
} else {
// face down or inner name
return imageFileName + (name.isEmpty() ? "" : ": " + name);
}
}
/**
* GUI related: show real name and day/night button for face down card
*/
public static boolean canShowAsControlled(Card card, UUID createdForPlayer) {
return card.getControllerOrOwnerId().equals(createdForPlayer);
}
/**
* Ability used for information only, e.g. adds additional rule texts
*/
public static boolean isInformationAbility(Ability ability) {
return !ability.getEffects().isEmpty()
&& ability.getEffects().stream().allMatch(InfoEffect.class::isInstance);
}
}