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 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 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 increaseCost(ManaCosts manaCosts, int increaseCount) { return adjustCost(manaCosts, -increaseCount); } public static ManaCosts reduceCost(ManaCosts 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 adjustedCost = adjustCost(ability.getManaCostsToPay(), reduceCount); ability.clearManaCostsToPay(); ability.addManaCostsToPay(adjustedCost); } public static ManaCosts adjustCost(ManaCosts manaCosts, int reduceCount) { ManaCosts 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 changedCost = new LinkedHashMap<>(); // must be ordered List 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 you’ve 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 manaCostsToReduce) { adjustCost(spellAbility, manaCostsToReduce, true); } public static void increaseCost(SpellAbility spellAbility, ManaCosts manaCostsToIncrease) { ManaCosts 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 manaCostsToReduce, boolean convertToGeneric) { ManaCosts previousCost = ability.getManaCostsToPay(); ManaCosts 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 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 *

* 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 "" + text + ""; } /** * 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 concatManaSymbols(String delimeter, List mana1, List mana2) { List 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 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 "" + text + " — "; } public static String stripReminderText(String text) { return text.endsWith(")") ? text.substring(0, text.indexOf(" (")) : text; } public static Set 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 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 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 distributeValues(int count, int min, int max) { List 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 selectedTargets, UUID checkTargetId, ToIntFunction 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 checkPossibleTargetsTotalValueLimit(Collection selectedTargets, Set possibleTargets, ToIntFunction valueMapper, int maxValue, Game game) { int selectedValue = selectedTargets.stream() .map(game::getObject) .filter(Objects::nonNull) .mapToInt(valueMapper) .sum(); int remainingValue = maxValue - selectedValue; Set 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 = 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 getCardRulesWithAdditionalInfo(MageObject object, Abilities rulesSource, Abilities 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 getCardRulesWithAdditionalInfo(Game game, MageObject object, Abilities rulesSource, Abilities hintsSource) { try { List 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 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) *

* 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. *

* 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 getCastableComponents(Card cardToCast, FilterCard filter, Ability source, Player player, Game game, SpellCastTracker spellCastTracker, boolean playLand) { UUID playerId = player.getId(); List 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> cardMap = new HashMap<>(); for (Card card : cards.getCards(game)) { List 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 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) { return castSingle(player, source, game, card, false, manaCost); } public static boolean castSingle(Player player, Ability source, Game game, Card card, boolean noMana, ManaCostsImpl 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 additionalCostsLeft = leftHalfCard.getSpellAbility().getCosts(); Costs 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 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 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 additionalCostsCreature = creatureCard.getSpellAbility().getCosts(); Costs 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 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) *

* 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) *

* 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 getSourceCostsTagsMap(Game game, Ability source) { Map 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 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 getSourceCostsTag(Game game, Ability source, String tag, T defaultValue) { Map 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) *

* Warning, don't use self reference objects because it will raise StackOverflowError * * @param value * @param * @return */ public static T deepCopyObject(T value) { if (isImmutableObject(value)) { return value; } else if (value instanceof Copyable) { return (T) ((Copyable) 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 > TreeSet deepCopyTreeSet(TreeSet original) { if (original.getClass() != TreeSet.class) { throw new IllegalStateException("Unhandled TreeSet type " + original.getClass().getSimpleName() + " in deep copy"); } TreeSet newSet = new TreeSet<>(); for (T value : original) { newSet.add((T) deepCopyObject(value)); } return newSet; } private static HashSet deepCopyHashSet(Set original) { if (original.getClass() != HashSet.class) { throw new IllegalStateException("Unhandled HashSet type " + original.getClass().getSimpleName() + " in deep copy"); } HashSet newSet = new HashSet<>(original.size()); for (T value : original) { newSet.add((T) deepCopyObject(value)); } return newSet; } private static LinkedHashSet deepCopyLinkedHashSet(LinkedHashSet original) { if (original.getClass() != LinkedHashSet.class) { throw new IllegalStateException("Unhandled LinkedHashSet type " + original.getClass().getSimpleName() + " in deep copy"); } LinkedHashSet newSet = new LinkedHashSet<>(original.size()); for (T value : original) { newSet.add((T) deepCopyObject(value)); } return newSet; } private static List deepCopyList(List original) { //always returns an ArrayList if (original.getClass() != ArrayList.class) { throw new IllegalStateException("Unhandled List type " + original.getClass().getSimpleName() + " in deep copy"); } ArrayList newList = new ArrayList<>(original.size()); for (T value : original) { newList.add((T) deepCopyObject(value)); } return newList; } private static HashMap deepCopyHashMap(Map original) { if (original.getClass() != HashMap.class) { throw new IllegalStateException("Unhandled HashMap type " + original.getClass().getSimpleName() + " in deep copy"); } HashMap newMap = new HashMap<>(original.size()); for (Map.Entry entry : original.entrySet()) { newMap.put((K) deepCopyObject(entry.getKey()), (V) deepCopyObject(entry.getValue())); } return newMap; } private static LinkedHashMap deepCopyLinkedHashMap(Map original) { if (original.getClass() != LinkedHashMap.class) { throw new IllegalStateException("Unhandled LinkedHashMap type " + original.getClass().getSimpleName() + " in deep copy"); } LinkedHashMap newMap = new LinkedHashMap<>(original.size()); for (Map.Entry entry : original.entrySet()) { newMap.put((K) deepCopyObject(entry.getKey()), (V) deepCopyObject(entry.getValue())); } return newMap; } private static , V> EnumMap deepCopyEnumMap(Map original) { if (original.getClass() != EnumMap.class) { throw new IllegalStateException("Unhandled EnumMap type " + original.getClass().getSimpleName() + " in deep copy"); } EnumMap newMap = new EnumMap<>(original); for (Map.Entry entry : newMap.entrySet()) { entry.setValue((V) deepCopyObject(entry.getValue())); } return newMap; } /** * Collect all possible object's parts (example: all sides in mdf/split cards) *

* 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 getObjectParts(MageObject object) { Set res = new LinkedHashSet<>(); // set must be ordered List allParts = getObjectPartsAsObjects(object); allParts.forEach(part -> { res.add(part.getId()); }); return res; } public static List getObjectPartsAsObjects(MageObject object) { List 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 getOriginalToCopiedPartsMap(Card originalCard, Card copiedCard) { List oldIds = new ArrayList<>(CardUtil.getObjectParts(originalCard)); List 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 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 strings) { return concatWith(strings, "or"); } public static String concatWithAnd(List strings) { return concatWith(strings, "and"); } private static String concatWith(List 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 Optional getEffectValueFromAbility(Ability ability, String key, Class clazz) { return castStream( ability.getAllEffects() .stream() .map(effect -> effect.getValue(key)), clazz ).findFirst(); } public static Stream castStream(Collection collection, Class clazz) { return castStream(collection.stream(), clazz); } public static Stream castStream(Stream stream, Class clazz) { return stream.filter(clazz::isInstance).map(clazz::cast).filter(Objects::nonNull); } public static boolean checkAnyPairs(Collection collection, BiPredicate predicate) { return streamPairsWithMap(collection, (t1, t2) -> predicate.test(t1, t2)).anyMatch(x -> x); } public static Stream streamAllPairwiseMatches(Collection collection, BiPredicate predicate) { return streamPairsWithMap( collection, (t1, t2) -> predicate.test(t1, t2) ? Stream.of(t1, t2) : Stream.empty() ).flatMap(Function.identity()).distinct(); } private static class IntPairIterator implements Iterator> { 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 next() { AbstractMap.SimpleImmutableEntry 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 Stream streamPairsWithMap(Collection collection, BiFunction function) { if (collection.size() < 2) { return Stream.empty(); } List list; if (collection instanceof List) { list = (List) 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 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 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 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 getEventTargets(GameEvent event) { Set 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); } }