foul-magics/Mage.Verify/src/test/java/mage/verify/VerifyCardDataTest.java

3616 lines
174 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.verify;
import com.google.common.base.CharMatcher;
import com.google.gson.Gson;
import mage.MageObject;
import mage.Mana;
import mage.ObjectColor;
import mage.abilities.*;
import mage.abilities.common.*;
import mage.abilities.condition.Condition;
import mage.abilities.costs.Cost;
import mage.abilities.dynamicvalue.DynamicValue;
import mage.abilities.effects.Effect;
import mage.abilities.effects.common.ExileUntilSourceLeavesEffect;
import mage.abilities.effects.common.FightTargetsEffect;
import mage.abilities.effects.common.InfoEffect;
import mage.abilities.effects.common.counter.ProliferateEffect;
import mage.abilities.effects.keyword.ScryEffect;
import mage.abilities.hint.common.CitysBlessingHint;
import mage.abilities.hint.common.CurrentDungeonHint;
import mage.abilities.hint.common.InitiativeHint;
import mage.abilities.hint.common.MonarchHint;
import mage.abilities.keyword.*;
import mage.cards.*;
import mage.cards.decks.CardNameUtil;
import mage.cards.decks.Deck;
import mage.cards.decks.DeckCardInfo;
import mage.cards.decks.DeckCardLists;
import mage.cards.decks.importer.DeckImporter;
import mage.cards.repository.*;
import mage.choices.Choice;
import mage.client.remote.XmageURLConnection;
import mage.constants.*;
import mage.filter.Filter;
import mage.filter.predicate.Predicate;
import mage.filter.predicate.Predicates;
import mage.game.FakeGame;
import mage.game.Game;
import mage.game.command.Dungeon;
import mage.game.command.Emblem;
import mage.game.command.Plane;
import mage.game.command.emblems.EmblemOfCard;
import mage.game.draft.DraftCube;
import mage.game.permanent.token.Token;
import mage.game.permanent.token.TokenImpl;
import mage.game.permanent.token.custom.CreatureToken;
import mage.game.permanent.token.custom.XmageToken;
import mage.sets.TherosBeyondDeath;
import mage.target.Target;
import mage.target.targetpointer.TargetPointer;
import mage.tournament.cubes.CubeFromDeck;
import mage.util.CardUtil;
import mage.utils.SystemUtil;
import mage.verify.mtgjson.MtgJsonCard;
import mage.verify.mtgjson.MtgJsonService;
import mage.verify.mtgjson.MtgJsonSet;
import mage.verify.mtgjson.SpellBookCardsPage;
import mage.watchers.Watcher;
import net.java.truevfs.access.TFile;
import org.apache.log4j.Logger;
import org.junit.Assert;
import org.junit.Ignore;
import org.junit.Test;
import org.mage.plugins.card.dl.sources.ScryfallImageSupportCards;
import org.reflections.Reflections;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* @author JayDi85
*/
public class VerifyCardDataTest {
private static final Logger logger = Logger.getLogger(VerifyCardDataTest.class);
private static String FULL_ABILITIES_CHECK_SET_CODES = ""; // check ability text due mtgjson, can use multiple sets like MAT;CMD or * for all
private static boolean CHECK_ONLY_ABILITIES_TEXT = false; // use when checking text locally, suppresses unnecessary checks and output messages
private static final boolean CHECK_COPYABLE_FIELDS = true; // disable for better verify test performance
// for automated local testing support
static {
String val = System.getProperty("xmage.tests.verifyCheckSetCodes");
if (val != null) {
FULL_ABILITIES_CHECK_SET_CODES = val;
}
val = System.getProperty("xmage.tests.verifyCheckOnlyText");
if (val != null) {
CHECK_ONLY_ABILITIES_TEXT = Boolean.parseBoolean(val);
}
}
private static final boolean AUTO_FIX_SAMPLE_DECKS = false; // debug only: auto-fix sample decks by test_checkSampleDecks test run
private static final Set<String> checkedNames = new HashSet<>(); // skip already checked cards
private static final HashMap<String, Set<String>> skipCheckLists = new HashMap<>();
private static final Set<String> subtypesToIgnore = new HashSet<>();
private static final String SKIP_LIST_PT = "PT";
private static final String SKIP_LIST_LOYALTY = "LOYALTY";
private static final String SKIP_LIST_DEFENSE = "DEFENSE";
private static final String SKIP_LIST_COLOR = "COLOR";
private static final String SKIP_LIST_COST = "COST";
private static final String SKIP_LIST_SUPERTYPE = "SUPERTYPE";
private static final String SKIP_LIST_TYPE = "TYPE";
private static final String SKIP_LIST_SUBTYPE = "SUBTYPE";
private static final String SKIP_LIST_NUMBER = "NUMBER";
private static final String SKIP_LIST_RARITY = "RARITY";
private static final String SKIP_LIST_MISSING_ABILITIES = "MISSING_ABILITIES";
private static final String SKIP_LIST_DOUBLE_RARE = "DOUBLE_RARE";
private static final String SKIP_LIST_UNSUPPORTED_SETS = "UNSUPPORTED_SETS";
private static final String SKIP_LIST_SCRYFALL_DOWNLOAD_SETS = "SCRYFALL_DOWNLOAD_SETS";
private static final String SKIP_LIST_WRONG_CARD_NUMBERS = "WRONG_CARD_NUMBERS";
private static final String SKIP_LIST_SAMPLE_DECKS = "SAMPLE_DECKS";
private static final List<String> evergreenKeywords = Arrays.asList(
"flying", "lifelink", "menace", "trample", "haste", "first strike", "hexproof", "fear",
"deathtouch", "double strike", "indestructible", "reach", "flash", "defender", "vigilance",
"plainswalk", "islandwalk", "swampwalk", "mountainwalk", "forestwalk", "myriad", "prowess", "convoke",
"shroud", "banding", "flanking", "horsemanship", "legendary landwalk"
);
private static final List<String> doubleWords = new ArrayList<>(); // for inner calc
private static final List<String> etbTriggerPhrases = new ArrayList<>(); // for inner calc
static {
// numbers
for (int i = 1; i <= 9; i++) {
String s = CardUtil.numberToText(i).toLowerCase(Locale.ENGLISH);
doubleWords.add(s + " " + s);
}
// additional checks
doubleWords.add(" an an ");
doubleWords.add(" a a ");
}
static {
// skip lists for checks (example: unstable cards with same name may have different stats)
// can be full set ignore list or set + cardname
// power-toughness
// skipListAddName(SKIP_LIST_PT, set, cardName);
// loyalty
// skipListAddName(SKIP_LIST_LOYALTY, set, cardName);
// defense
// skipListAddName(SKIP_LIST_DEFENSE, set, cardName);
// color
// skipListAddName(SKIP_LIST_COLOR, set, cardName);
// cost
// skipListAddName(SKIP_LIST_COST, set, cardName);
// supertype
// skipListAddName(SKIP_LIST_SUPERTYPE, set, cardName);
// type
// skipListAddName(SKIP_LIST_TYPE, set, cardName);
skipListAddName(SKIP_LIST_TYPE, "UNH", "Old Fogey"); // uses summon word as a joke card
skipListAddName(SKIP_LIST_TYPE, "UND", "Old Fogey");
skipListAddName(SKIP_LIST_TYPE, "UST", "capital offense"); // uses "instant" instead "Instant" as a joke card
// subtype
// skipListAddName(SKIP_LIST_SUBTYPE, set, cardName);
skipListAddName(SKIP_LIST_SUBTYPE, "UGL", "Miss Demeanor"); // uses multiple types as a joke card: Lady, of, Proper, Etiquette
skipListAddName(SKIP_LIST_SUBTYPE, "UGL", "Elvish Impersonators"); // subtype is "Elves" pun
skipListAddName(SKIP_LIST_SUBTYPE, "UND", "Elvish Impersonators");
// number
// skipListAddName(SKIP_LIST_NUMBER, set, cardName);
// rarity
// skipListAddName(SKIP_LIST_RARITY, set, cardName);
skipListAddName(SKIP_LIST_RARITY, "CMR", "The Prismatic Piper"); // Collation is not yet set up for CMR https://www.lethe.xyz/mtg/collation/cmr.html
skipListAddName(SKIP_LIST_RARITY, "TLE", "Teferi's Protection"); // temporary
// missing abilities
// skipListAddName(SKIP_LIST_MISSING_ABILITIES, set, cardName);
// double rare cards
// skipListAddName(SKIP_LIST_DOUBLE_RARE, set, cardName);
// Un-supported sets (mtgjson/scryfall contains that set but xmage don't implement it)
// Example: Non-English sets, Token sets, Archenemy Schemes, Plane-Chase Planes, etc.
// Sets with no cards or with "Oversized" in the name are automatically skipped.
//
// Non-English-only sets should not be added. https://github.com/magefree/mage/pull/6190#issuecomment-582354790
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "4BB"); // 4th Edition Foreign black border.
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "FBB"); // Foreign Black Border. Not on Scryfall, but other sources use this to distinguish non-English Revised cards
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PHJ"); // Hobby Japan Promos
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PJJT"); // Japan Junior Tournament
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PRED"); // Redemption Program
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PSAL"); // Salvat 2005
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PS11"); // Salvat 2011
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PMPS"); // Magic Premiere Shop 2005, Japanese Basic lands
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PMPS06"); // Magic Premiere Shop 2006, Japanese Basic lands
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PMPS07"); // Magic Premiere Shop 2007, Japanese Basic lands
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PMPS08"); // Magic Premiere Shop 2008, Japanese Basic lands
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PMPS09"); // Magic Premiere Shop 2009, Japanese Basic lands
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PMPS10"); // Magic Premiere Shop 2010, Japanese Basic lands
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PMPS11"); // Magic Premiere Shop 2011, Japanese Basic lands
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "REN"); // Renaissance
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "RIN"); // Rinascimento
//
// Archenemy Schemes
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "OARC"); // Archenemy Schemes
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "OE01"); // Archenemy: Nicol Bolas Schemes
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PARC"); // Promotional Schemes
//
// Plane-chase Planes
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "OHOP"); // Planechase Planes
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "OPC2"); // Planechase 2012 Plane
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "OPCA"); // Planechase Anthology Planes
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PHOP"); // Promotional Planes
//
// Token sets TODO: implement tokens only sets
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "L12"); // League Tokens 2012
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "L13"); // League Tokens 2013
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "L14"); // League Tokens 2014
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "L15"); // League Tokens 2015
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "L16"); // League Tokens 2016
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "L17"); // League Tokens 2017
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PLNY"); // 2018 Lunar New Year
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "F18"); // Friday Night Magic 2018
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PR2"); // Magic Player Rewards 2002
//
// PvE sets containing non-traditional cards. These enable casual PvE battles against a "random AI"-driven opponent.
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PPC1"); // M15 Prerelease Challenge
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "TBTH"); // Battle the Horde
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "TDAG"); // Defeat a God
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "TFTH"); // Face the Hydra
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "THP1"); // Theros Hero's Path
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "THP2"); // Born of the Gods Hero's Path
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "THP3"); // Journey into Nyx Hero's Path
//
// Other
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PCEL"); // Celebration Cards
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PMOA"); // Magic Online Avatar
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PVAN"); // Vanguard Series
skipListAddName(SKIP_LIST_UNSUPPORTED_SETS, "PTG"); // Ponies: The Galloping
// wrong card numbers skip list
skipListAddName(SKIP_LIST_WRONG_CARD_NUMBERS, "SWS"); // Star Wars
skipListAddName(SKIP_LIST_WRONG_CARD_NUMBERS, "UND"); // un-sets don't have full implementation of card variations
skipListAddName(SKIP_LIST_WRONG_CARD_NUMBERS, "UST"); // un-sets don't have full implementation of card variations
skipListAddName(SKIP_LIST_WRONG_CARD_NUMBERS, "SOI", "Tamiyo's Journal"); // not all variations implemented
skipListAddName(SKIP_LIST_WRONG_CARD_NUMBERS, "SLD", "Zndrsplt, Eye of Wisdom"); // has alternative image as second side
skipListAddName(SKIP_LIST_WRONG_CARD_NUMBERS, "SLD", "Krark's Thumb"); // has alternative image as second side
skipListAddName(SKIP_LIST_WRONG_CARD_NUMBERS, "SLD", "Okaun, Eye of Chaos"); // has alternative image as second side
skipListAddName(SKIP_LIST_WRONG_CARD_NUMBERS, "SLD", "Propaganda"); // has alternative image as second side
skipListAddName(SKIP_LIST_WRONG_CARD_NUMBERS, "SLD", "Stitch in Time"); // has alternative image as second side
skipListAddName(SKIP_LIST_WRONG_CARD_NUMBERS, "SLD", "Zndrsplt, Eye of Wisdom"); // has alternative image as second side
// scryfall download sets (missing from scryfall website)
skipListAddName(SKIP_LIST_SCRYFALL_DOWNLOAD_SETS, "SWS"); // Star Wars
// sample decks checking - some decks can contains unimplemented cards, so ignore it
// file name must be related to sample-decks folder
// for linux/windows build system use paths constructor
skipListAddName(SKIP_LIST_SAMPLE_DECKS, Paths.get("Jumpstart", "jumpstart_custom.txt").toString()); // it's not a deck file
}
private final ArrayList<String> outputMessages = new ArrayList<>();
private int failed = 0;
private int wrongAbilityStatsTotal = 0;
private int wrongAbilityStatsGood = 0;
private int wrongAbilityStatsBad = 0;
private static Set<String> skipListGet(String listName) {
return skipCheckLists.computeIfAbsent(listName, x -> new LinkedHashSet<>());
}
private static void skipListAddName(String listName, String set, String cardName) {
skipListGet(listName).add(set + " - " + cardName);
}
private static void skipListAddName(String listName, String set) {
skipListGet(listName).add(set);
}
private static boolean skipListHaveName(String listName, String set, String cardName) {
return skipListGet(listName).contains(set + " - " + cardName)
|| skipListGet(listName).contains(set);
}
private static boolean skipListHaveName(String listName, String set) {
return skipListGet(listName).contains(set);
}
/**
* For splitting printed rules text with multiple keywords on one line
*/
private static boolean evergreenCheck(String s) {
return evergreenKeywords.contains(s) || s.startsWith("protection from") || s.startsWith("hexproof from")
|| s.startsWith("ward ") || s.startsWith("rampage ") || s.startsWith("annihilator")
|| s.matches("^firebending \\d");
}
private static <T> boolean eqSet(Collection<T> a, Collection<T> b) {
if (a == null || a.isEmpty()) {
return b == null || b.isEmpty();
}
return b != null && a.size() == b.size() && a.containsAll(b);
}
private void warn(Card card, String message) {
outputMessages.add("Warning: " + message + " for " + card.getExpansionSetCode() + " - " + card.getName() + " - " + card.getCardNumber());
}
private void fail(Card card, String category, String message) {
failed++;
outputMessages.add("Error: (" + category + ") " + message + " for " + card.getExpansionSetCode() + " - " + card.getName() + " - " + card.getCardNumber());
}
@Test
public void test_verifyCards() {
checkWrongAbilitiesTextStart();
int cardIndex = 0;
List<Card> allCards = CardScanner.getAllCards();
for (Card card : allCards) {
cardIndex++;
if (card instanceof CardWithHalves) {
check(((CardWithHalves) card).getLeftHalfCard(), cardIndex);
check(((CardWithHalves) card).getRightHalfCard(), cardIndex);
} else if (card instanceof CardWithSpellOption) {
check(card, cardIndex);
check(((CardWithSpellOption) card).getSpellCard(), cardIndex);
} else {
check(card, cardIndex);
}
}
checkWrongAbilitiesTextEnd();
printMessages(outputMessages);
if (failed > 0) {
Assert.fail(String.format("found %d errors in %d cards verify (see errors list above)", failed, allCards.size()));
}
}
@Test
public void test_checkDuplicateCardNumbersInDB() {
Collection<String> doubleErrors = new ArrayList<>();
Collection<ExpansionSet> sets = Sets.getInstance().values();
for (ExpansionSet set : sets) {
Map<String, ExpansionSet.SetCardInfo> cardsList = new HashMap<>();
for (ExpansionSet.SetCardInfo checkCard : set.getSetCardInfo()) {
String cardNumber = checkCard.getCardNumber();
// ignore double faced
Card realCard = CardImpl.createCard(checkCard.getCardClass(), new CardSetInfo(checkCard.getName(), set.getCode(),
checkCard.getCardNumber(), checkCard.getRarity(), checkCard.getGraphicInfo()));
if (realCard.isNightCard()) {
continue;
}
if (cardsList.containsKey(cardNumber)) {
ExpansionSet.SetCardInfo prevCard = cardsList.get(cardNumber);
String errorType;
if (checkCard.getName().equals(prevCard.getName())) {
errorType = " found DUPLICATED cards"
+ " set (" + set.getCode() + " - " + set.getName() + ")"
+ " (" + checkCard.getCardNumber() + " - " + checkCard.getName() + ")";
} else {
errorType = " found TYPOS in card numbers"
+ " set (" + set.getCode() + " - " + set.getName() + ")"
+ " (" + prevCard.getCardNumber() + " - " + prevCard.getName() + ")"
+ " and"
+ " (" + checkCard.getCardNumber() + " - " + checkCard.getName() + ")";
}
String error = "Error: " + errorType;
doubleErrors.add(error);
} else {
cardsList.put(cardNumber, checkCard);
}
}
}
for (String error : doubleErrors) {
System.out.println(error);
}
if (doubleErrors.size() > 0) {
Assert.fail("DB has duplicated card numbers, found errors: " + doubleErrors.size());
}
}
@Test
@Ignore // TODO: enable it after THB set will be completed
public void test_checkDoubleRareCardsInSets() {
// all basic sets after THB must have double rare cards (one normal, one bonus)
// ELD can have same rules, but xmage stores it as different sets (ELD and CELD)
Date startCheck = TherosBeyondDeath.getInstance().getReleaseDate();
Calendar cal = Calendar.getInstance();
cal.set(2050, Calendar.JANUARY, 1); // optimistic
Date endCheck = cal.getTime();
Collection<String> doubleErrors = new ArrayList<>();
Collection<ExpansionSet> sets = Sets.getInstance().values();
for (ExpansionSet set : sets) {
// only post THB sets must have double versions
if (set.getReleaseDate().before(startCheck)
|| set.getReleaseDate().after(endCheck)
|| !set.getSetType().isStandardLegal()) {
continue;
}
if (skipListHaveName(SKIP_LIST_DOUBLE_RARE, set.getCode())) {
continue;
}
Map<String, Integer> cardsList = new HashMap<>();
for (ExpansionSet.SetCardInfo checkCard : set.getSetCardInfo()) {
// only rare cards must have double versions
if (!Objects.equals(checkCard.getRarity(), Rarity.RARE) && !Objects.equals(checkCard.getRarity(), Rarity.MYTHIC)) {
continue;
}
if (skipListHaveName(SKIP_LIST_DOUBLE_RARE, set.getCode(), checkCard.getName())) {
continue;
}
String cardName = checkCard.getName();
cardsList.putIfAbsent(cardName, 0);
cardsList.compute(cardName, (k, v) -> v + 1);
}
cardsList.forEach((cardName, amount) -> {
if (amount != 2) {
String error = "Error: non duplicated rare card -"
+ " set (" + set.getCode() + " - " + set.getName() + ")"
+ " card (" + cardName + ")";
doubleErrors.add(error);
}
});
}
for (String error : doubleErrors) {
System.out.println(error);
}
if (doubleErrors.size() > 0) {
Assert.fail("DB has non duplicated rare cards, found errors: " + doubleErrors.size());
}
}
@Test
public void test_checkWrongCardClasses() {
Collection<String> errorsList = new ArrayList<>();
Map<String, String> classesIndex = new HashMap<>();
int totalCards = 0;
Collection<ExpansionSet> sets = Sets.getInstance().values();
for (ExpansionSet set : sets) {
for (ExpansionSet.SetCardInfo checkCard : set.getSetCardInfo()) {
totalCards = totalCards + 1;
String currentClass = checkCard.getCardClass().toString();
if (classesIndex.containsKey(checkCard.getName())) {
String needClass = classesIndex.get(checkCard.getName());
if (!needClass.equals(currentClass)) {
// workaround to star wars and unstable set with same card names
if (!checkCard.getName().equals("Syndicate Enforcer")
&& !checkCard.getName().equals("Everythingamajig")
&& !checkCard.getName().equals("Garbage Elemental")
&& !checkCard.getName().equals("Very Cryptic Command")) {
errorsList.add("Error: wrong class in set " + set.getCode() + " - " + checkCard.getName() + " (" + currentClass + " <> " + needClass + ")");
}
}
} else {
classesIndex.put(checkCard.getName(), currentClass);
}
}
}
printMessages(errorsList);
// unique cards stats
System.out.println("Total unique cards: " + classesIndex.size() + ", total non unique cards (reprints): " + totalCards);
if (errorsList.size() > 0) {
Assert.fail("DB has wrong card classes, found errors: " + errorsList.size());
}
}
@Test
public void test_checkMissingSets() {
// generate unimplemented sets list
Collection<String> info = new ArrayList<>();
int missingSets = 0;
int missingCards = 0;
int unsupportedSets = 0;
int unsupportedCards = 0;
int mtgCards = 0;
int mtgSets = 0;
int xmageCards = 0;
int xmageUnofficialSets = 0;
int xmageUnofficialCards = 0;
Collection<ExpansionSet> sets = Sets.getInstance().values();
Assert.assertFalse("XMage data must contains sets list", sets.isEmpty());
Assert.assertFalse("MtgJson data must contains sets list", MtgJsonService.sets().isEmpty());
// official sets
for (Map.Entry<String, MtgJsonSet> refEntry : MtgJsonService.sets().entrySet()) {
MtgJsonSet refSet = refEntry.getValue();
mtgCards += refSet.totalSetSize;
// replace codes for aliases
String searchSet = MtgJsonService.mtgJsonToXMageCodes.getOrDefault(refSet.code, refSet.code);
if (skipListHaveName(SKIP_LIST_UNSUPPORTED_SETS, searchSet)) {
unsupportedSets++;
unsupportedCards += refSet.totalSetSize;
continue;
}
ExpansionSet mageSet = Sets.findSet(searchSet.toUpperCase(Locale.ENGLISH));
if (mageSet == null) {
if (refSet.cards.isEmpty() || refSet.name.contains("Oversized")) {
unsupportedSets++;
unsupportedCards += refSet.totalSetSize;
} else {
missingSets++;
missingCards = missingCards + refSet.cards.size();
if (!CHECK_ONLY_ABILITIES_TEXT) {
info.add("Warning: missing set " + refSet.code + " - " + refSet.name + " (cards: " + refSet.cards.size() + ", date: " + refSet.releaseDate + ")");
}
}
continue;
}
mtgSets++;
xmageCards += mageSet.getSetCardInfo().size();
}
if (info.size() > 0) {
info.add("Warning: total missing sets: " + missingSets + ", with missing cards: " + missingCards);
}
// unofficial sets info
for (ExpansionSet set : sets) {
if (MtgJsonService.sets().containsKey(set.getCode())) {
continue;
}
xmageUnofficialSets++;
xmageUnofficialCards += set.getSetCardInfo().size();
info.add("Unofficial set: " + set.getCode() + " - " + set.getName() + ", cards: " + set.getSetCardInfo().size() + ", year: " + set.getReleaseYear());
}
printMessages(info);
System.out.println();
System.out.println("Official sets implementation stats:");
System.out.println("* MTG sets: " + MtgJsonService.sets().size() + ", cards: " + mtgCards);
System.out.println("* Implemented sets: " + mtgSets + ", cards: " + xmageCards);
System.out.println("* Unsupported sets: " + unsupportedSets + ", cards: " + unsupportedCards);
System.out.println("* TODO sets: " + (MtgJsonService.sets().size() - mtgSets - unsupportedSets) + ", cards: " + (mtgCards - xmageCards - unsupportedCards));
System.out.println();
System.out.println("Unofficial sets implementation stats:");
System.out.println("* Implemented sets: " + xmageUnofficialSets + ", cards: " + xmageUnofficialCards);
System.out.println();
}
@Test
public void test_checkSampleDecks() {
Collection<String> errorsList = new ArrayList<>();
// workaround to run verify test from IDE or from maven's project root folder
Path rootPath = Paths.get("Mage.Client", "release", "sample-decks");
if (!Files.exists(rootPath)) {
rootPath = Paths.get("..", "Mage.Client", "release", "sample-decks");
}
if (!Files.exists(rootPath)) {
Assert.fail("Sample decks: unknown root folder " + rootPath.toAbsolutePath());
}
// collect all files in all root's folders
Collection<Path> filesList = new ArrayList<>();
try {
Files.walkFileTree(rootPath, new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
filesList.add(file);
return FileVisitResult.CONTINUE;
}
});
} catch (IOException e) {
e.printStackTrace();
errorsList.add("Error: sample deck - can't get folder content - " + e.getMessage());
}
Assert.assertTrue("Sample decks: can't find any deck files in " + rootPath.toAbsolutePath(), filesList.size() > 0);
// try to open deck files
int totalErrorFiles = 0;
for (Path deckFile : filesList) {
String deckName = rootPath.relativize(deckFile).toString();
if (!deckName.endsWith(".dck") || skipListHaveName(SKIP_LIST_SAMPLE_DECKS, deckName)) {
continue;
}
StringBuilder deckErrors = new StringBuilder();
DeckCardLists deckCards = DeckImporter.importDeckFromFile(deckFile.toString(), deckErrors, AUTO_FIX_SAMPLE_DECKS);
if (!deckErrors.toString().isEmpty()) {
errorsList.add("Error: sample deck contains errors " + deckName);
System.out.println("Errors in sample deck " + deckName + ":\n" + deckErrors);
totalErrorFiles++;
continue;
}
if ((deckCards.getCards().size() + deckCards.getSideboard().size()) < 10) {
// note: does not check whether cards are extra deck cards (contraptions/attractions);
// may succeed for decks with such cards when it should fail
errorsList.add("Error: sample deck contains too little cards (" + deckCards.getSideboard().size() + ") " + deckName);
totalErrorFiles++;
continue;
}
}
printMessages(errorsList);
if (errorsList.size() > 0) {
Assert.fail("Found sample decks: " + filesList.size() + "; with errors: " + totalErrorFiles);
}
}
@Test
public void test_checkMissingSecondSideCardsInSets() {
Collection<String> errorsList = new ArrayList<>();
// CHECK: if card have second side (flip, transform) then it must have all sides in that set
for (ExpansionSet set : Sets.getInstance().values()) {
for (ExpansionSet.SetCardInfo info : set.getSetCardInfo()) {
CardInfo cardInfo = CardRepository.instance.findCardsByClass(info.getCardClass().getCanonicalName()).stream().findFirst().orElse(null);
Assert.assertNotNull(cardInfo);
if (cardInfo.isDoubleFacedCard()) {
break;
}
Card card = cardInfo.createCard();
Card secondCard = card.getSecondCardFace();
if (secondCard != null) {
if (set.findCardInfoByClass(secondCard.getClass()).isEmpty()) {
errorsList.add("Error: missing second face card from set: " + set.getCode() + " - " + set.getName() + " - main: " + card.getName() + "; second: " + secondCard.getName());
}
}
}
}
printMessages(errorsList);
if (errorsList.size() > 0) {
Assert.fail("Found missing second side cards in sets, errors: " + errorsList.size());
}
}
@Test
@Ignore // TODO: enable after all missing cards and settings fixes
public void test_checkWrongCardsDataInSets() {
Collection<String> errorsList = new ArrayList<>();
Collection<String> warningsList = new ArrayList<>();
Collection<ExpansionSet> xmageSets = Sets.getInstance().values();
Set<String> foundedJsonCards = new HashSet<>();
// fast check instead card's db search (only main side card)
Set<String> implementedIndex = new HashSet<>();
CardRepository.instance.findCards(new CardCriteria()).forEach(card -> {
implementedIndex.add(card.getName());
});
// CHECK: wrong card numbers
for (ExpansionSet set : xmageSets) {
if (skipListHaveName(SKIP_LIST_WRONG_CARD_NUMBERS, set.getCode())) {
continue;
}
for (ExpansionSet.SetCardInfo card : set.getSetCardInfo()) {
MtgJsonCard jsonCard = MtgJsonService.cardFromSet(set.getCode(), card.getName(), card.getCardNumber());
if (jsonCard == null) {
// see convertMtgJsonToXmageCardNumber for card number convert notation
errorsList.add("Error: unknown mtgjson's card number or set, use standard number notations: "
+ set.getCode() + " - " + set.getName() + " - " + card.getName() + " - " + card.getCardNumber());
continue;
}
// index for missing cards
// String code = MtgJsonService.xMageToMtgJsonCodes.getOrDefault(set.getCode(), set.getCode()) + " - " + jsonCard.getNameAsFull() + " - " + jsonCard.number;
// foundedJsonCards.add(code);
//
}
}
// CHECK: missing cards from set
for (MtgJsonSet jsonSet : MtgJsonService.sets().values()) {
if (skipListHaveName(SKIP_LIST_UNSUPPORTED_SETS, jsonSet.code)
|| skipListHaveName(SKIP_LIST_WRONG_CARD_NUMBERS, jsonSet.code)) {
continue;
}
ExpansionSet xmageSet = Sets.findSet(jsonSet.code);
if (xmageSet == null) {
ExpansionSet sameSet = Sets.findSetByName(jsonSet.name);
if (sameSet != null) {
warningsList.add("Warning: implemented set with different set code: "
+ jsonSet.code + " - " + jsonSet.name + " - " + jsonSet.releaseDate
+ " (xmage's set under code: " + sameSet.getCode() + ")"
);
} else {
warningsList.add("Warning: un-implemented set from mtgjson: " + jsonSet.code + " - " + jsonSet.name + " - " + jsonSet.releaseDate);
}
continue;
}
for (MtgJsonCard jsonCard : jsonSet.cards) {
String code = jsonSet.code + " - " + jsonCard.getNameAsFull() + " - " + jsonCard.number;
if (!foundedJsonCards.contains(code)) {
if (!implementedIndex.contains(jsonCard.getNameAsFull())) {
warningsList.add("Warning: un-implemented card from mtgjson: " + jsonSet.code + " - " + jsonCard.getNameAsFull() + " - " + jsonCard.number);
} else {
errorsList.add("Error: missing card from xmage's set: " + jsonSet.code + " - " + jsonCard.getNameAsFull() + " - " + jsonCard.number);
}
}
}
}
printMessages(warningsList);
printMessages(errorsList);
if (errorsList.size() > 0) {
Assert.fail("Found wrong cards data in sets, errors: " + errorsList.size());
}
}
@Test
@Ignore
public void test_checkWrongFullArtAndRetro() {
Collection<String> errorsList = new ArrayList<>();
Collection<ExpansionSet> xmageSets = Sets.getInstance().values();
// CHECK: wrong card numbers
for (ExpansionSet set : xmageSets) {
if (skipListHaveName(SKIP_LIST_WRONG_CARD_NUMBERS, set.getCode())) {
continue;
}
for (ExpansionSet.SetCardInfo card : set.getSetCardInfo()) {
MtgJsonCard jsonCard = MtgJsonService.cardFromSet(set.getCode(), card.getName(), card.getCardNumber());
if (jsonCard == null) {
continue;
}
// CHECK: full art lands must use full art setting
// CHECK: non-full art lands must not use full art setting
// CHECK: if full art land is using full art setting, don't perform retro or poster tests
boolean isLand = card.getRarity().equals(Rarity.LAND);
if (isLand && jsonCard.isFullArt && !card.isFullArt()) {
errorsList.add("Error: card must use full art lands setting: "
+ set.getCode() + " - " + set.getName() + " - " + card.getName() + " - " + card.getCardNumber());
continue;
} else if (isLand && !jsonCard.isFullArt && card.isFullArt()) {
errorsList.add("Error: card must NOT use full art lands setting: "
+ set.getCode() + " - " + set.getName() + " - " + card.getName() + " - " + card.getCardNumber());
continue;
} else if (isLand && jsonCard.isFullArt && card.isFullArt()) {
// Land full art is correct, skip other tests
continue;
}
// CHECK: poster promoType and/or textless must use full art setting
if (((jsonCard.promoTypes != null && jsonCard.promoTypes.contains("poster")) || jsonCard.isTextless) && !card.isFullArt()) {
errorsList.add("Error: card must use full art setting: "
+ set.getCode() + " - " + set.getName() + " - " + card.getName() + " - " + card.getCardNumber());
}
// CHECK: must use retro frame setting
if ((jsonCard.frameVersion.equals("1993") || jsonCard.frameVersion.equals("1997")) && !card.isRetroFrame()) {
errorsList.add("Error: card must use retro art setting: "
+ set.getCode() + " - " + set.getName() + " - " + card.getName() + " - " + card.getCardNumber());
}
// CHECK: must not use retro frame setting
if ((!(jsonCard.frameVersion.equals("1993") || jsonCard.frameVersion.equals("1997"))) && card.isRetroFrame()) {
errorsList.add("Error: card must NOT use retro art setting: "
+ set.getCode() + " - " + set.getName() + " - " + card.getName() + " - " + card.getCardNumber());
}
}
}
printMessages(errorsList);
if (errorsList.size() > 0) {
Assert.fail("Found wrong cards data in sets, errors: " + errorsList.size());
}
}
@Test
@Ignore // TODO: enable after all missing cards and settings fixes
public void test_checkMissingScryfallSettingsAndCardNumbers() {
Collection<String> errorsList = new ArrayList<>();
Collection<ExpansionSet> xmageSets = Sets.getInstance().values();
Set<String> scryfallSets = ScryfallImageSupportCards.getSupportedSets();
// CHECK: missing sets in supported list
for (ExpansionSet set : xmageSets) {
if (skipListHaveName(SKIP_LIST_SCRYFALL_DOWNLOAD_SETS, set.getCode())) {
continue;
}
if (!scryfallSets.contains(set.getCode())) {
errorsList.add("Error: scryfall download missing setting: " + set.getCode() + " - " + set.getName());
}
}
// CHECK: unknown sets in supported list
for (String scryfallCode : scryfallSets) {
if (xmageSets.stream().noneMatch(e -> e.getCode().equals(scryfallCode))) {
errorsList.add("Error: scryfall download unknown setting: " + scryfallCode);
}
}
// card numbers
// all cards with non-ascii numbers must be downloaded by direct links (api)
Set<String> foundedDirectDownloadKeys = new HashSet<>();
for (ExpansionSet set : xmageSets) {
if (skipListHaveName(SKIP_LIST_SCRYFALL_DOWNLOAD_SETS, set.getCode())
|| skipListHaveName(SKIP_LIST_WRONG_CARD_NUMBERS, set.getCode())) {
continue;
}
for (ExpansionSet.SetCardInfo card : set.getSetCardInfo()) {
if (skipListHaveName(SKIP_LIST_WRONG_CARD_NUMBERS, set.getCode(), card.getName())) {
continue;
}
MtgJsonCard jsonCard = MtgJsonService.cardFromSet(set.getCode(), card.getName(), card.getCardNumber());
if (jsonCard == null) {
// see convertMtgJsonToXmageCardNumber for card number convert notation
if (!skipListHaveName(SKIP_LIST_WRONG_CARD_NUMBERS, set.getCode(), card.getName())) {
errorsList.add("Error: scryfall download can't find card from mtgjson " + set.getCode() + " - " + set.getName() + " - " + card.getName() + " - " + card.getCardNumber());
}
continue;
}
// CHECK: non-ascii numbers and direct download list
if (!CharMatcher.ascii().matchesAllOf(jsonCard.number)) {
// non-ascii numbers
// xmage card numbers can't have non-ascii numbers (it checked by test_checkMissingCardData)
String key = ScryfallImageSupportCards.findDirectDownloadKey(set.getCode(), card.getName(), card.getCardNumber());
if (key != null) {
foundedDirectDownloadKeys.add(key);
} else {
// how-to fix: add miss images links in ScryfallImageSupportCards->directDownloadLinks
errorsList.add("Error: scryfall download can't find non-ascii card link in direct download list " + set.getCode() + " - " + set.getName() + " - " + card.getName() + " - " + jsonCard.number);
}
}
// CHECK: reversible_card must be in direct download list (xmage must have 2 cards with diff image face)
if (jsonCard.layout.equals("reversible_card")) {
String key = ScryfallImageSupportCards.findDirectDownloadKey(set.getCode(), card.getName(), card.getCardNumber());
if (key != null) {
foundedDirectDownloadKeys.add(key);
} else {
errorsList.add("Error: scryfall download can't find face image of reversible_card in direct download list " + set.getCode() + " - " + set.getName() + " - " + card.getName() + " - " + jsonCard.number);
}
}
}
}
// CHECK: unknown direct download links
// TODO: add same for tokens
for (Map.Entry<String, String> direct : ScryfallImageSupportCards.getDirectDownloadLinks().entrySet()) {
// skip custom sets
String setCode = ScryfallImageSupportCards.extractSetCodeFromDirectKey(direct.getKey());
String cardName = ScryfallImageSupportCards.extractCardNameFromDirectKey(direct.getKey());
if (skipListHaveName(SKIP_LIST_SCRYFALL_DOWNLOAD_SETS, setCode)
|| skipListHaveName(SKIP_LIST_WRONG_CARD_NUMBERS, setCode)) {
continue;
}
// skip non-implemented cards list
if (CardRepository.instance.findCard(cardName) == null) {
continue;
}
if (!foundedDirectDownloadKeys.contains(direct.getKey())) {
errorsList.add("Error: scryfall download found unknown direct download link " + direct.getKey() + " - " + direct.getValue());
}
}
printMessages(errorsList);
if (errorsList.size() > 0) {
Assert.fail("Found scryfall download errors: " + errorsList.size());
}
}
private Object createNewObject(Class<?> clazz) {
try {
Constructor<?> cons = clazz.getConstructor();
return cons.newInstance();
} catch (InvocationTargetException ex) {
Throwable e = ex.getTargetException();
e.printStackTrace();
return null;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
private void printMessages(Collection<String> list, boolean sorted) {
ArrayList<String> sortedList = new ArrayList<>(list);
if (sorted) {
sortedList.sort(new Comparator<String>() {
@Override
public int compare(String o1, String o2) {
// show errors in the end of the list (after warnings, near the final assert fail)
boolean e1 = o1.toLowerCase(Locale.ENGLISH).startsWith("error");
boolean e2 = o2.toLowerCase(Locale.ENGLISH).startsWith("error");
if (e1 != e2) {
return Boolean.compare(e1, e2);
} else {
return o1.compareTo(o2);
}
}
});
}
for (String mes : sortedList) {
System.out.println(mes);
}
}
private void printMessages(Collection<String> list) {
printMessages(list, true);
}
private String extractShortClass(Class<? extends Object> tokenClass) {
String origin = tokenClass.getName();
if (origin.contains("$")) {
// inner classes, example: mage.cards.f.FigureOfDestiny$FigureOfDestinyToken3
return origin.replaceAll(".+\\$(.+)", "$1");
} else {
// public classes, example: mage.game.permanent.token.FigureOfDestinyToken
return origin.replaceAll(".+\\.(.+)", "$1");
}
}
private static final Set<String> ignoreBoosterSets = new HashSet<>();
static {
// jumpstart, TODO: implement from JumpstartPoolGenerator, see #13264
ignoreBoosterSets.add("Jumpstart");
ignoreBoosterSets.add("Jumpstart 2022");
ignoreBoosterSets.add("Foundations Jumpstart");
ignoreBoosterSets.add("Ravnica: Clue Edition");
ignoreBoosterSets.add("Avatar: The Last Airbender Eternal");
// joke or un-sets, low implemented cards
ignoreBoosterSets.add("Unglued");
ignoreBoosterSets.add("Unhinged");
ignoreBoosterSets.add("Unstable");
ignoreBoosterSets.add("Unfinity");
// other
ignoreBoosterSets.add("Secret Lair Drop"); // cards shop
ignoreBoosterSets.add("Zendikar Rising Expeditions"); // box toppers
ignoreBoosterSets.add("March of the Machine: The Aftermath"); // epilogue boosters aren't for draft
}
@Test
public void test_checkMissingSetData() {
Collection<String> errorsList = new ArrayList<>();
Collection<String> warningsList = new ArrayList<>();
Collection<ExpansionSet> sets = Sets.getInstance().values();
// CHECK: wrong set class names
for (ExpansionSet set : sets) {
String className = extractShortClass(set.getClass());
String needClassName = Arrays.stream(
set.getName()
.replaceAll("&", "And")
.replaceAll("^(\\d+)", "The$1") // replace starting "2007 xxx" by "The2007"
.replace("-", " ")
.replaceAll("[.+-/:\"']", "")
.split(" ")
).map(CardUtil::getTextWithFirstCharUpperCase).reduce("", String::concat);
if (!className.equals(needClassName)) {
// if set name start with a numbers then add "The" at the start
errorsList.add("Error: set's class name must be equal to set name: "
+ className + " from " + set.getClass().getName() + ", caption: " + set.getName() + ", need name: " + needClassName);
}
}
// CHECK: wrong basic lands settings (it's for lands search, not booster construct)
for (ExpansionSet set : sets) {
boolean needLand = set.hasBasicLands();
boolean foundLand = false;
Map<String, Integer> foundLandsList = new HashMap<>();
for (ExpansionSet.SetCardInfo card : set.getSetCardInfo()) {
if (isBasicLandName(card.getName())) {
foundLand = true;
int count = foundLandsList.getOrDefault(card.getName(), 0);
foundLandsList.put(card.getName(), count + 1);
}
}
String landNames = foundLandsList.entrySet().stream()
.map(p -> (p.getKey() + " - " + p.getValue().toString()))
.sorted().collect(Collectors.joining(", "));
if (needLand && !foundLand) {
errorsList.add("Error: set with wrong hasBasicLands - it's true, but haven't land cards: " + set.getCode() + " in " + set.getClass().getName());
}
if (!needLand && foundLand) {
errorsList.add("Error: set with wrong hasBasicLands - it's false, but have land cards: " + set.getCode() + " in " + set.getClass().getName() + ", lands: " + landNames);
}
// TODO: add test to check num cards (hasBasicLands and numLand > 0)
}
// CHECK: wrong snow land info - set needs to have exclusively snow basics to qualify
for (ExpansionSet set : sets) {
boolean needSnow = CardRepository.haveSnowLands(set.getCode());
boolean haveSnow = false;
boolean haveNonSnow = false;
for (ExpansionSet.SetCardInfo card : set.getSetCardInfo()) {
if (card.getName().startsWith("Snow-Covered ")) {
haveSnow = true;
}
if (isNonSnowBasicLandName(card.getName())) {
haveNonSnow = true;
break;
}
}
if (needSnow != (haveSnow && !haveNonSnow)) {
errorsList.add("Error: incorrect snow land info in set " + set.getCode() + ": "
+ ((haveSnow && !haveNonSnow) ? "set has exclusively snow basics" : "set doesn't have exclusively snow basics")
+ ", but xmage thinks that it " + (needSnow ? "does" : "doesn't"));
}
}
// CHECK: unknown set or wrong name
for (ExpansionSet set : sets) {
if (set.getSetType().equals(SetType.CUSTOM_SET)) {
// skip unofficial sets like Star Wars
continue;
}
MtgJsonSet jsonSet = MtgJsonService.sets().getOrDefault(set.getCode().toUpperCase(Locale.ENGLISH), null);
if (jsonSet == null) {
errorsList.add(String.format("Error: unknown official set: %s - %s (make sure it use correct set code or mark it as SetType.CUSTOM_SET)",
set.getCode(),
set.getName()
));
} else {
if (!Objects.equals(set.getName(), jsonSet.name)) {
// how-to fix: rename xmage set to the json version or fix a set's code
// also don't forget to change names in mtg-cards-data.txt
errorsList.add(String.format("Error: wrong set name or set code: %s (mtgjson set for same code: %s)",
set.getCode() + " - " + set.getName(),
jsonSet.name
));
}
}
}
// CHECK: parent and block info
// TODO: it's UX problem, see https://github.com/magefree/mage/issues/10184
for (ExpansionSet set : sets) {
if (true) {
continue; // TODO: comments it and run to find a problems
}
MtgJsonSet jsonSet = MtgJsonService.sets().getOrDefault(set.getCode().toUpperCase(Locale.ENGLISH), null);
if (jsonSet == null) {
continue;
}
// parent set
MtgJsonSet jsonParentSet = jsonSet.parentCode == null ? null : MtgJsonService.sets().getOrDefault(jsonSet.parentCode, null);
ExpansionSet mageParentSet = set.getParentSet();
String jsonParentCode = jsonParentSet == null ? "null" : jsonParentSet.code;
String mageParentCode = mageParentSet == null ? "null" : mageParentSet.getCode();
String needMageClass = "";
if (!jsonParentCode.equals("null")) {
needMageClass = sets
.stream()
.filter(exp -> exp.getCode().equals(jsonParentCode))
.map(exp -> " - " + exp.getClass().getSimpleName() + ".getInstance()")
.findFirst()
.orElse("- error, can't find class");
}
if (!Objects.equals(jsonParentCode, mageParentCode)) {
errorsList.add(String.format("Error: set with wrong parentSet settings: %s (parentSet = %s, but must be %s%s)",
set.getCode() + " - " + set.getName(),
mageParentCode,
jsonParentCode,
needMageClass
));
}
// block info
if (!Objects.equals(set.getBlockName(), jsonSet.block)) {
if (true) {
continue; // TODO: comments it and run to find a problems
}
errorsList.add(String.format("Error: set with wrong blockName settings: %s (blockName = %s, but must be %s)",
set.getCode() + " - " + set.getName(),
set.getBlockName(),
jsonSet.block
));
}
}
// CHECK: miss booster settings
// make sure mtgjson has booster data
boolean hasBoostersInfo = MtgJsonService.sets().values().stream().anyMatch(s -> s.booster != null && !s.booster.isEmpty());
for (ExpansionSet set : sets) {
if (!hasBoostersInfo) {
System.out.println("Warning, mtgjson data lost boosters info");
break;
}
MtgJsonSet jsonSet = MtgJsonService.sets().getOrDefault(set.getCode().toUpperCase(Locale.ENGLISH), null);
if (jsonSet == null) {
continue;
}
boolean needBooster = jsonSet.booster != null && !jsonSet.booster.isEmpty();
if (set.hasBoosters() != needBooster) {
if (ignoreBoosterSets.contains(set.getName())) {
continue;
}
// error example: wrong booster settings (set MUST HAVE booster, but haven't) - 2020 - J22 - Jumpstart 2022 - boosters: [jumpstart]
errorsList.add(String.format("Error: wrong booster settings (set %s booster, but %s) - %s%s",
(needBooster ? "MUST HAVE" : "MUST HAVEN'T"),
(set.hasBoosters() ? "have" : "haven't"),
set.getReleaseYear() + " - " + set.getCode() + " - " + set.getName(),
(jsonSet.booster == null ? "" : " - boosters: " + jsonSet.booster.keySet())
));
}
}
// CHECK: missing important sets for draft format
Set<String> implementedSets = sets.stream().map(ExpansionSet::getCode).collect(Collectors.toSet());
MtgJsonService.sets().values().forEach(jsonSet -> {
if (jsonSet.booster != null && !jsonSet.booster.isEmpty() && !implementedSets.contains(jsonSet.code)) {
// how-to fix: it's miss promo sets with boosters, so just add/generate it in most use cases
errorsList.add(String.format("Error: missing set implementation (important for draft format) - %s - %s - boosters: %s",
jsonSet.code,
jsonSet.name,
jsonSet.booster.keySet()
));
}
});
// TODO: add test to check num cards for rarity (rarityStats > 0 and numRarity > 0)
printMessages(warningsList);
printMessages(errorsList);
if (errorsList.size() > 0) {
Assert.fail("Found set errors: " + errorsList.size());
}
}
@Test
public void test_checkMissingCardData() {
Collection<String> errorsList = new ArrayList<>();
Collection<String> warningsList = new ArrayList<>();
Collection<ExpansionSet> sets = Sets.getInstance().values();
// CHECK: wrong UsesVariousArt settings (set have duplicated card name without that setting -- e.g. cards will have same image)
for (ExpansionSet set : sets) {
Map<String, Integer> doubleNames = new HashMap<>();
for (ExpansionSet.SetCardInfo card : set.getSetCardInfo()) {
int count = doubleNames.getOrDefault(card.getName(), 0);
doubleNames.put(card.getName(), count + 1);
}
for (ExpansionSet.SetCardInfo card : set.getSetCardInfo()) {
boolean cardHaveDoubleName = (doubleNames.getOrDefault(card.getName(), 0) > 1);
boolean cardHaveVariousSetting = card.getGraphicInfo() != null && card.getGraphicInfo().getUsesVariousArt();
if (cardHaveDoubleName && !cardHaveVariousSetting) {
errorsList.add("Error: double faced card, but UsesVariousArt = false (missing NON_FULL_USE_VARIOUS, etc): "
+ set.getCode() + " - " + set.getName() + " - " + card.getName() + " - " + card.getCardNumber());
}
}
}
for (ExpansionSet set : sets) {
// additional info
Set<String> cardNames = new HashSet<>();
for (ExpansionSet.SetCardInfo cardInfo : set.getSetCardInfo()) {
cardNames.add(cardInfo.getName());
}
// CHECK: set code must be compatible with tests commands format like "SET-card"
// how-to fix: change min/max lookup length
if (set.getCode().length() < CardUtil.TESTS_SET_CODE_MIN_LOOKUP_LENGTH
|| set.getCode().length() > CardUtil.TESTS_SET_CODE_MAX_LOOKUP_LENGTH) {
errorsList.add("Error: set code un-supported by test commands lookup: " + set.getCode()
+ ", min length: " + CardUtil.TESTS_SET_CODE_MIN_LOOKUP_LENGTH
+ ", max length: " + CardUtil.TESTS_SET_CODE_MAX_LOOKUP_LENGTH);
}
boolean containsDoubleSideCards = false;
Map<String, String> cardNumbers = new HashMap<>();
for (ExpansionSet.SetCardInfo cardInfo : set.getSetCardInfo()) {
Card card = CardImpl.createCard(cardInfo.getCardClass(), new CardSetInfo(cardInfo.getName(), set.getCode(),
cardInfo.getCardNumber(), cardInfo.getRarity(), cardInfo.getGraphicInfo()));
Assert.assertNotNull(card);
//TODO: do we need this check after tdfc rework?
if (card.getSecondCardFace() != null && !(card instanceof DoubleFacedCard)) {
containsDoubleSideCards = true;
}
// CHECK: all planeswalkers must be legendary
if (card.isPlaneswalker() && !card.isLegendary()) {
errorsList.add("Error: planeswalker must have legendary type: " + set.getCode() + " - " + set.getName() + " - " + card.getName() + " - " + card.getCardNumber());
}
// CHECK: getMana must works without NPE errors (it uses getNetMana with empty game param for AI score calcs)
// https://github.com/magefree/mage/issues/6300
card.getMana();
// CHECK: non ascii symbols in card name and number
if (!CharMatcher.ascii().matchesAllOf(card.getName()) || !CharMatcher.ascii().matchesAllOf(card.getCardNumber())) {
errorsList.add("Error: card name or number contains non-ascii symbols: " + set.getCode() + " - " + set.getName() + " - " + card.getName() + " - " + card.getCardNumber());
}
// CHECK: set code and card name must be parseable for test and cheat commands XLN-Mountain
List<String> cardCommand = SystemUtil.parseSetAndCardNameCommand(set.getCode() + CardUtil.TESTS_SET_CODE_DELIMETER + card.getName());
if (!Objects.equals(set.getCode(), cardCommand.get(0))
|| !Objects.equals(card.getName(), cardCommand.get(1))) {
// if you catch it then parser logic must be changed to support problem set-card combination
errorsList.add("Error: card name can't be used in tests and cheats with set code: " + set.getCode() + " - " + set.getName() + " - " + card.getName() + " - " + card.getCardNumber());
}
// CHECK: card number must start with 09-aZ symbols (wrong symbol example: *123)
// if you found card with number like *123 then report it to scryfall to fix to 123*
if (!Character.isLetterOrDigit(card.getCardNumber().charAt(0))) {
errorsList.add("Error: card number must start with digit: " + set.getCode() + " - " + set.getName() + " - " + card.getName() + " - " + card.getCardNumber());
}
// CHECK: second side cards in one set
// https://github.com/magefree/mage/issues/8081
/*
if (card.getSecondCardFace() != null && cardNames.contains(card.getSecondCardFace().getName())) {
errorsList.add("Error: set contains second side cards: " + set.getCode() + " - " + set.getName()
+ " - " + card.getName() + " - " + card.getCardNumber()
+ " - " + card.getSecondCardFace().getName() + " - " + card.getSecondCardFace().getCardNumber());
}
//*/
// CHECK: set contains both card sides
// related to second side cards usage
/*
String existedCardName = cardNumbers.getOrDefault(card.getCardNumber(), null);
if (existedCardName != null && !existedCardName.equals(card.getName())) {
String info = card.isNightCard() ? existedCardName + " -> " + card.getName() : card.getName() + " -> " + existedCardName;
errorsList.add("Error: set contains both card sides instead main only: "
+ set.getCode() + " - " + set.getName() + " - " + info + " - " + card.getCardNumber());
} else {
cardNumbers.put(card.getCardNumber(), card.getName());
}
//*/
}
// CHECK: double side cards must be in boosters
boolean hasBoosterSettings = (set.getNumBoosterDoubleFaced() > 0);
if (set.hasBoosters()
&& (set.getNumBoosterDoubleFaced() != -1) // -1 must ignore double cards in booster
&& containsDoubleSideCards
&& !hasBoosterSettings) {
errorsList.add("Error: set with boosters contains second side cards, but numBoosterDoubleFaced is not set - "
+ set.getCode() + " - " + set.getName());
}
}
printMessages(warningsList);
printMessages(errorsList);
if (errorsList.size() > 0) {
Assert.fail("Found card errors: " + errorsList.size());
}
}
@Test
public void test_checkWatcherCopyMethods() {
Collection<String> errorsList = new ArrayList<>();
Collection<String> warningsList = new ArrayList<>();
Reflections reflections = new Reflections("mage.");
Set<Class<? extends Watcher>> watcherClassesList = reflections.getSubTypesOf(Watcher.class);
for (Class<? extends Watcher> watcherClass : watcherClassesList) {
// only watcher class can be extended (e.g. final)
if (!watcherClass.getSuperclass().equals(Watcher.class)) {
errorsList.add("Error: only Watcher class can be extended: " + watcherClass.getName());
}
// no copy methods
try {
Method m = watcherClass.getMethod("copy");
if (!m.getGenericReturnType().getTypeName().equals("T")) {
errorsList.add("Error: copy() method must be deleted from watcher class: " + watcherClass.getName());
}
} catch (NoSuchMethodException e) {
errorsList.add("Error: can't find copy() method in watcher class: " + watcherClass.getName());
}
// no constructor for copy
try {
Constructor<? extends Watcher> constructor = watcherClass.getDeclaredConstructor(watcherClass);
errorsList.add("Error: copy constructor is not allowed in watcher class: " + watcherClass.getName());
} catch (NoSuchMethodException e) {
// all fine, no needs in copy constructors
}
// errors on create
try {
List<?> constructors = Arrays.asList(watcherClass.getDeclaredConstructors());
Constructor<? extends Watcher> constructor = (Constructor<? extends Watcher>) constructors.get(0);
Object[] args = new Object[constructor.getParameterCount()];
for (int index = 0; index < constructor.getParameterTypes().length; index++) {
Class<?> parameterType = constructor.getParameterTypes()[index];
if (parameterType.getSimpleName().equalsIgnoreCase("boolean")) {
args[index] = false;
} else {
args[index] = null;
}
}
constructor.setAccessible(true);
Watcher w1 = constructor.newInstance(args);
// errors on copy
try {
Watcher w2 = w1.copy();
if (w2 == null) {
errorsList.add("Error: can't copy watcher with unknown error, look at error logs above: " + watcherClass.getName());
}
} catch (Exception e) {
errorsList.add("Error: can't copy watcher: " + watcherClass.getName() + " (" + e.getMessage() + ")");
}
} catch (Exception e) {
errorsList.add("Error: can't create watcher: " + watcherClass.getName() + " (" + e.getMessage() + ")");
}
}
printMessages(warningsList);
printMessages(errorsList);
if (errorsList.size() > 0) {
Assert.fail("Found watcher errors: " + errorsList.size());
}
}
@Test
@Ignore // TODO: enable test after massive token fixes
public void test_checkMissingTokenData() {
Collection<String> errorsList = new ArrayList<>();
Collection<String> warningsList = new ArrayList<>();
// all tokens must be stores in tokens-database.txt (if not then viewer and image downloader are missing token images)
// https://github.com/ronmamo/reflections
Reflections reflections = new Reflections("mage.");
Set<Class<? extends TokenImpl>> tokenClassesList = reflections.getSubTypesOf(TokenImpl.class);
// xmage tokens
Set<Class<? extends TokenImpl>> privateTokens = new HashSet<>();
Set<Class<? extends TokenImpl>> publicTokens = new HashSet<>();
for (Class<? extends TokenImpl> tokenClass : tokenClassesList) {
if (Modifier.isPublic(tokenClass.getModifiers())) {
publicTokens.add(tokenClass);
} else {
privateTokens.add(tokenClass);
}
}
// xmage sets
Set<String> allSetCodes = Sets.getInstance().values().stream().map(ExpansionSet::getCode).collect(Collectors.toSet());
allSetCodes.add(TokenRepository.XMAGE_TOKENS_SET_CODE); // reminder tokens
// tok file's data
List<TokenInfo> tokFileTokens = TokenRepository.instance.getAll();
LinkedHashMap<String, String> tokDataClassesIndex = new LinkedHashMap<>();
LinkedHashMap<String, String> tokDataNamesIndex = new LinkedHashMap<>();
LinkedHashMap<String, List<TokenInfo>> tokDataTokensBySetIndex = new LinkedHashMap<>();
for (TokenInfo tokData : tokFileTokens) {
String searchName;
String setsList;
// by set
List<TokenInfo> tokensInSet = tokDataTokensBySetIndex.getOrDefault(tokData.getSetCode(), null);
if (tokensInSet == null) {
tokensInSet = new ArrayList<>();
tokDataTokensBySetIndex.put(tokData.getSetCode(), tokensInSet);
}
tokensInSet.add(tokData);
// by class
searchName = tokData.getFullClassFileName();
setsList = tokDataClassesIndex.getOrDefault(searchName, "");
if (!setsList.isEmpty()) {
setsList += ",";
}
setsList += tokData.getSetCode();
tokDataClassesIndex.put(searchName, setsList);
// by name
searchName = tokData.getName();
setsList = tokDataNamesIndex.getOrDefault(searchName, "");
if (!setsList.isEmpty()) {
setsList += ",";
}
setsList += tokData.getSetCode();
tokDataNamesIndex.put(searchName, setsList);
}
// CHECK: token's class name convention
for (Class<? extends TokenImpl> tokenClass : tokenClassesList) {
if (!tokenClass.getName().endsWith("Token")) {
String className = extractShortClass(tokenClass);
warningsList.add("warning, token class must ends with Token: " + className + " from " + tokenClass.getName());
}
}
// CHECK: public class for downloadable tokens
for (Class<? extends TokenImpl> tokenClass : publicTokens) {
String fullClass = tokenClass.getName();
if (!fullClass.startsWith("mage.game.permanent.token.")) {
String className = extractShortClass(tokenClass);
errorsList.add("Error: public token must stores in mage.game.permanent.token package: " + className + " from " + tokenClass.getName());
}
}
// CHECK: private class for inner tokens (no needs at all -- all private tokens must be replaced by CreatureToken)
for (Class<? extends TokenImpl> tokenClass : privateTokens) {
String className = extractShortClass(tokenClass);
errorsList.add("Warning: no needs in private tokens, replace it with CreatureToken: " + className + " from " + tokenClass.getName());
}
// CHECK: all public tokens must have tok-data (private tokens uses for innner abilities -- no need images for it)
for (Class<? extends TokenImpl> tokenClass : publicTokens) {
Token token = (Token) createNewObject(tokenClass);
if (token == null) {
// how-to fix:
// - create empty param
// - fix error in token's constructor
errorsList.add("Error: token must have default constructor with zero params: " + tokenClass.getName());
} else if (tokDataNamesIndex.getOrDefault(token.getName().replace(" Token", ""), "").isEmpty()) {
if (token instanceof CreatureToken || token instanceof XmageToken) {
// ignore custom token builders
continue;
}
// how-to fix:
// - public token must be downloadable, so tok-data must contain miss set
// (also don't forget to add new set to scryfall download)
errorsList.add("Error: can't find data in tokens-database.txt for token: " + tokenClass.getName() + " -> " + token.getName());
}
}
// 111.4. A spell or ability that creates a token sets both its name and its subtype(s).
// If the spell or ability doesnt specify the name of the token, its name is the same
// as its subtype(s) plus the word “Token.” Once a token is on the battlefield, changing
// its name doesnt change its subtype(s), and vice versa.
for (Class<? extends TokenImpl> tokenClass : publicTokens) {
Token token = (Token) createNewObject(tokenClass);
if (token == null) {
// error in constructor, see checks above
continue;
}
// CHECK: tokens must have Token word in the name
if (token.getDescription().startsWith(token.getName() + ", ")
|| token.getDescription().contains("named " + token.getName())
|| (token instanceof CreatureToken)
|| (token instanceof XmageToken)) {
// ignore some names:
// - Boo, a legendary 1/1 red Hamster creature token with trample and haste
// - 1/1 green Insect creature token with flying named Butterfly
// - custom token builders
} else {
if (!token.getName().endsWith("Token")) {
errorsList.add("Error: token's name must ends with Token: "
+ tokenClass.getName() + " - " + token.getName());
}
}
// CHECK: named tokens must not have Token in the name
if (token.getDescription().contains("named") && token.getName().contains("Token")) {
// ignore ability text like Return a card named Deathpact Angel from
if (!token.getDescription().contains("card named")) {
errorsList.add("Error: named token must not have Token in the name: "
+ tokenClass.getName() + " - " + token.getName() + " - " + token.getDescription());
}
}
}
// CHECK: wrong set codes in tok-data
tokDataTokensBySetIndex.forEach((setCode, setTokens) -> {
if (!allSetCodes.contains(setCode)) {
errorsList.add("error, tokens-database.txt contains unknown set code: "
+ setCode + " - " + setTokens.stream().map(TokenInfo::getName).collect(Collectors.joining(", ")));
}
});
// CHECK: find possible sets without tokens
List<Card> cardsList = new ArrayList<>(CardScanner.getAllCards());
Map<String, List<Card>> setsWithTokens = new HashMap<>();
for (Card card : cardsList) {
// must check all card parts (example: Mila, Crafty Companion with Lukka Emblem)
String allRules = CardUtil.getObjectPartsAsObjects(card)
.stream()
.map(obj -> (Card) obj)
.map(Card::getRules)
.flatMap(Collection::stream)
.map(r -> r.toLowerCase(Locale.ENGLISH))
.collect(Collectors.joining("; "));
if ((allRules.contains("create") && allRules.contains("token"))
|| (allRules.contains("get") && allRules.contains("emblem"))) {
List<Card> sourceCards = setsWithTokens.getOrDefault(card.getExpansionSetCode(), null);
if (sourceCards == null) {
sourceCards = new ArrayList<>();
setsWithTokens.put(card.getExpansionSetCode(), sourceCards);
}
sourceCards.add(card);
}
}
// set uses tokens, but tok data miss it
setsWithTokens.forEach((setCode, sourceCards) -> {
List<TokenInfo> setTokens = tokDataTokensBySetIndex.getOrDefault(setCode, null);
if (setTokens == null) {
// it's not a problem -- just find set's cards without real tokens for image tests
// Possible reasons:
// - promo sets with cards without tokens (nothing to do with it)
// - miss set from tok-data (must add new set to tok-data and scryfall download)
// - wizards miss some paper printed token, see https://www.mtg.onl/mtg-missing-tokens/
warningsList.add("info, set's cards uses tokens but tok-data haven't it: "
+ setCode + " - " + sourceCards.stream().map(MageObject::getName).collect(Collectors.joining(", ")));
} else {
// Card can be checked on scryfall like "set:set_code oracle:token_name oracle:token"
// Possible reasons for un-used tokens:
// - normal use case: tok-data contains wrong token data and must be removed
// - normal use case: card uses wrong rules text (must contain "create" and "token" words)
// - rare use case: un-implemented card that uses a token
setTokens.forEach(token -> {
if (token.getName().contains("Plane - ")) {
// cards don't put it to the game, so no related cards
return;
}
String needTokenName = token.getName()
.replace(" Token", "")
.replace("Emblem ", "");
// cards with emblems don't use emblem's name, so check it in card name itself (example: Sarkhan, the Dragonspeaker)
// also must check all card parts (example: Mila, Crafty Companion with Lukka Emblem)
if (sourceCards.stream()
.map(CardUtil::getObjectPartsAsObjects)
.flatMap(Collection::stream)
.map(obj -> (Card) obj)
.map(card -> card.getName() + " - " + String.join(", ", card.getRules()))
.noneMatch(s -> s.contains(needTokenName))) {
warningsList.add("info, tok-data has un-used tokens: "
+ token.getSetCode() + " - " + token.getName());
}
});
}
});
// tok data have tokens, but cards from set are miss
tokDataTokensBySetIndex.forEach((setCode, setTokens) -> {
if (setCode.equals(TokenRepository.XMAGE_TOKENS_SET_CODE)) {
// ignore reminder tokens
return;
}
if (!setsWithTokens.containsKey(setCode)) {
// Possible reasons:
// - outdated set code in tokens database (must be fixed by new set code, another verify check it)
// - promo set contains additional tokens for main set (it's ok and must be ignored, example: Saproling in E02)
warningsList.add("warning, tok-data has tokens, but real set haven't cards with it: "
+ setCode + " - " + setTokens.stream().map(TokenInfo::getName).collect(Collectors.joining(", ")));
}
});
// CHECK: token and class names must be same in all sets
TokenRepository.instance.getAllByClassName().forEach((className, list) -> {
// ignore reminder tokens
Set<String> names = list.stream()
.filter(token -> !token.getTokenType().equals(TokenType.XMAGE))
.map(TokenInfo::getName)
.collect(Collectors.toSet());
if (names.size() > 1) {
errorsList.add("error, tokens-database.txt contains different names for same class: "
+ className + " - " + String.join(", ", names));
}
});
Set<String> usedNames = new HashSet<>();
TokenRepository.instance.getByType(TokenType.XMAGE).forEach(token -> {
// CHECK: xmage's tokens must be unique
// how-to fix: edit TokenRepository->loadXmageTokens
String needName = String.format("%s.%d", token.getName(), token.getImageNumber());
if (usedNames.contains(needName)) {
errorsList.add("error, xmage token's name and image number must be unique: "
+ token.getName() + " - " + token.getImageNumber());
} else {
usedNames.add(needName);
}
// CHECK: xmage's tokens must be downloadable
// how-to fix: edit TokenRepository->loadXmageTokens
if (token.getDownloadUrl().isEmpty()) {
errorsList.add("error, xmage token's must have download url: "
+ token.getName() + " - " + token.getImageNumber());
}
});
// TODO: all sets must have full tokens data in tok file (token in every set)
// 1. Download scryfall tokens list: https://api.scryfall.com/cards/search?q=t:token
// 2. Proccess each token with all prints: read "prints_search_uri" field from token data and go to link like
// https://api.scryfall.com/cards/search?order=set&q=%21%E2%80%9CAngel%E2%80%9D&unique=prints
// 3. Collect all strings in "set@name"
// 4. Proccess tokens data and find missing strings from "set@name" list
printMessages(warningsList);
printMessages(errorsList);
if (errorsList.size() > 0) {
Assert.fail("Found token errors: " + errorsList.size());
}
}
@Test
public void test_checkMissingPlanesData() {
Collection<String> errorsList = new ArrayList<>();
Reflections reflections = new Reflections("mage.");
Set<Class<? extends Plane>> planesClassesList = reflections.getSubTypesOf(Plane.class);
// 1. correct class name
for (Class<? extends Plane> planeClass : planesClassesList) {
if (!planeClass.getName().endsWith("Plane")) {
String className = extractShortClass(planeClass);
errorsList.add("Error: plane class must ends with Plane: " + className + " from " + planeClass.getName());
}
}
// 2. correct package
for (Class<? extends Plane> planeClass : planesClassesList) {
String fullClass = planeClass.getName();
if (!fullClass.startsWith("mage.game.command.planes.")) {
String className = extractShortClass(planeClass);
errorsList.add("Error: plane must be stored in mage.game.command.planes package: " + className + " from " + planeClass.getName());
}
}
// 3. correct constructor
for (Class<? extends Plane> planeClass : planesClassesList) {
String className = extractShortClass(planeClass);
Plane plane;
try {
plane = (Plane) createNewObject(planeClass);
// 4. must have type/name
if (plane.getPlaneType() == null) {
errorsList.add("Error: plane must have plane type: " + className + " from " + planeClass.getName());
}
} catch (Throwable e) {
errorsList.add("Error: can't create plane with default constructor: " + className + " from " + planeClass.getName());
}
}
printMessages(errorsList);
if (errorsList.size() > 0) {
Assert.fail("Found plane errors: " + errorsList.size());
}
}
@Test
public void test_checkMissingDungeonsData() {
Collection<String> errorsList = new ArrayList<>();
Reflections reflections = new Reflections("mage.");
Set<Class<? extends Dungeon>> dungeonClassesList = reflections.getSubTypesOf(Dungeon.class);
// 1. correct class name
for (Class<? extends Dungeon> dungeonClass : dungeonClassesList) {
if (!dungeonClass.getName().endsWith("Dungeon")) {
String className = extractShortClass(dungeonClass);
errorsList.add("Error: dungeon class must ends with Dungeon: " + className + " from " + dungeonClass.getName());
}
}
// 2. correct package
for (Class<? extends Dungeon> dungeonClass : dungeonClassesList) {
String fullClass = dungeonClass.getName();
if (!fullClass.startsWith("mage.game.command.dungeons.")) {
String className = extractShortClass(dungeonClass);
errorsList.add("Error: dungeon must be stored in mage.game.command.dungeons package: " + className + " from " + dungeonClass.getName());
}
}
// 3. correct constructor
for (Class<? extends Dungeon> dungeonClass : dungeonClassesList) {
String className = extractShortClass(dungeonClass);
Dungeon dungeon;
try {
dungeon = (Dungeon) createNewObject(dungeonClass);
} catch (Throwable e) {
errorsList.add("Error: can't create dungeon with default constructor: " + className + " from " + dungeonClass.getName());
}
}
printMessages(errorsList);
if (errorsList.size() > 0) {
Assert.fail("Found dungeon errors: " + errorsList.size());
}
}
@Test
// TODO: add same images verify for tokens/dungeons and other
// TODO: add same verify for Speed and other new command objects
public void test_checkMissingEmblemsData() {
Collection<String> errorsList = new ArrayList<>();
// prepare DBs
CardScanner.scan();
Reflections reflections = new Reflections("mage.");
Set<Class<? extends Emblem>> emblemClassesList = reflections.getSubTypesOf(Emblem.class).stream()
.filter(c -> !c.equals(EmblemOfCard.class)) // ignore emblem card
.collect(Collectors.toSet());
// 1. correct class name
for (Class<? extends Emblem> emblemClass : emblemClassesList) {
if (!emblemClass.getName().endsWith("Emblem")) {
String className = extractShortClass(emblemClass);
errorsList.add("Error: emblem class must ends with Emblem: " + className + " from " + emblemClass.getName());
}
}
// 2. correct package
for (Class<? extends Emblem> emblemClass : emblemClassesList) {
String fullClass = emblemClass.getName();
if (!fullClass.startsWith("mage.game.command.emblems.")) {
String className = extractShortClass(emblemClass);
errorsList.add("Error: emblem must be stored in mage.game.command.emblems package: " + className + " from " + emblemClass.getName());
}
}
// 3. correct constructor
MageObject fakeObject = CardRepository.instance.findCard("Forest").createCard();
for (Class<? extends Emblem> emblemClass : emblemClassesList) {
String className = extractShortClass(emblemClass);
Emblem emblem;
try {
emblem = (Emblem) createNewObject(emblemClass);
// 4. correct tokens-database (image)
try {
emblem.setSourceObjectAndInitImage(fakeObject);
} catch (Throwable e) {
e.printStackTrace();
errorsList.add("Error: can't setup emblem's image data - make sure tokens-database.txt has it: " + className + " from " + emblemClass.getName());
}
} catch (Throwable e) {
e.printStackTrace();
errorsList.add("Error: can't create emblem with default constructor: " + className + " from " + emblemClass.getName());
}
}
printMessages(errorsList);
if (errorsList.size() > 0) {
Assert.fail("Found emblem errors: " + errorsList.size());
}
}
@Test
@Ignore
// experimental test to find potentially fail conditions with NPE see https://github.com/magefree/mage/issues/13752
public void test_checkBadConditions() {
// all conditions in AsThoughEffect must be compatible with empty source param (e.g. must be able to use inside ConditionalAsThoughEffect)
// see AsThoughEffectType.needAffectedAbility ?
// 370+ failed conditions
Collection<String> errorsList = new ArrayList<>();
Game fakeGame = new FakeGame();
Ability fakeAbility = new SimpleStaticAbility(new InfoEffect("fake"));
// TODO: add classes support (see example with tokens and default constructor)?
Reflections reflections = new Reflections("mage.");
Set<Class<?>> conditionEnums = reflections.getSubTypesOf(Condition.class)
.stream()
.filter(Class::isEnum)
.sorted(Comparator.comparing(Class::toString))
.collect(Collectors.toCollection(LinkedHashSet::new));
for (Class<?> enumClass : conditionEnums) {
for (Object enumItem : enumClass.getEnumConstants()) {
// miss watcher will fail in both use cases, but miss ability only one
String errorOnAbility = "";
try {
((Condition) enumItem).apply(fakeGame, fakeAbility);
} catch (Exception e) {
errorOnAbility = Arrays.stream(e.getStackTrace())
.map(StackTraceElement::toString)
.limit(5)
.collect(Collectors.joining("\n"));
}
String errorOnEmptyAbility = "";
try {
((Condition) enumItem).apply(fakeGame, null);
} catch (Exception e) {
errorOnEmptyAbility = Arrays.stream(e.getStackTrace())
.map(StackTraceElement::toString)
.limit(5)
.collect(Collectors.joining("\n"));
}
if (errorOnAbility.isEmpty() && !errorOnEmptyAbility.isEmpty()) {
System.out.println();
System.out.println("bad condition " + enumClass.getName() + "\n" + errorOnEmptyAbility);
errorsList.add("Error: condition must support empty and non-empty source params: " + enumClass.getName());
}
}
}
printMessages(errorsList);
if (!errorsList.isEmpty()) {
Assert.fail("Found conditions errors: " + errorsList.size());
}
}
private void check(Card card, int cardIndex) {
MtgJsonCard ref = MtgJsonService.cardFromSet(card.getExpansionSetCode(), card.getName(), card.getCardNumber());
if (ref != null) {
if ((card instanceof CardWithSpellOption || card instanceof SpellOptionCard) && ref.layout.equals("reversible_card")) {
// TODO: Remove when MtgJson updated
// workaround for reversible omen cards e.g. Bloomvine Regent // Claim Territory // Bloomvine Regent
// both sides have main card info
return;
}
checkAll(card, ref, cardIndex);
} else if (!CHECK_ONLY_ABILITIES_TEXT) {
warn(card, "Can't find card in mtgjson to verify");
}
}
private boolean contains(Collection<String> options, String value) {
return options != null && options.contains(value);
}
private static boolean wasCheckedByAbilityText(MtgJsonCard ref) {
// ignore already checked cards, so no bloated logs from duplicated cards
if (checkedNames.contains(ref.getNameAsFace())) {
return true;
}
checkedNames.add(ref.getNameAsFace());
return false;
}
private void checkAll(Card card, MtgJsonCard ref, int cardIndex) {
if (!CHECK_ONLY_ABILITIES_TEXT) {
checkCost(card, ref);
checkPT(card, ref);
checkLoyalty(card, ref);
checkDefense(card, ref);
checkSubtypes(card, ref);
checkSupertypes(card, ref);
checkTypes(card, ref);
checkColors(card, ref);
checkRarityAndBasicLands(card, ref);
checkMissingAbilities(card, ref);
checkWrongSymbolsInRules(card);
if (CHECK_COPYABLE_FIELDS) {
checkCardCanBeCopied(card);
}
}
checkWrongAbilitiesText(card, ref, cardIndex, false);
}
private void checkColors(Card card, MtgJsonCard ref) {
if (skipListHaveName(SKIP_LIST_COLOR, card.getExpansionSetCode(), card.getName())) {
return;
}
// TODO: temporary fix - scryfall/mtgjson wrongly add [colors, mana cost] from spell part to main part/card,
// example: Ishgard, the Holy See // Faith & Grief
if (card instanceof CardWithSpellOption && card.isLand()
|| card instanceof SpellOptionCard && ((SpellOptionCard) card).getParentCard().isLand()) {
return;
}
Set<String> expected = new HashSet<>();
if (ref.colors != null) {
expected.addAll(ref.colors);
}
ObjectColor color = card.getColor(null);
if (expected.size() != color.getColorCount()
|| (color.isBlack() && !expected.contains("B"))
|| (color.isBlue() && !expected.contains("U"))
|| (color.isGreen() && !expected.contains("G"))
|| (color.isRed() && !expected.contains("R"))
|| (color.isWhite() && !expected.contains("W"))) {
fail(card, "colors", color + " != " + expected);
}
}
// "copy" fails means that the copy constructor are not correct inside a card.
// To fix those, try to find the class that did trigger the copy failure, and check
// that copy() exists, a copy constructor exists, and the copy constructor is right.
private void checkCardCanBeCopied(Card card1) {
Card card2;
try {
card2 = card1.copy();
} catch (Error err) {
fail(card1, "copy", "throws on copy : " + err.getClass() + " : " + err.getMessage() + " : ");
return;
}
compareClassRecursive(card1, card2, card1, "[Card", 10, new HashSet<>(), true);
}
/**
* @param obj1 first object to compare. Initially the original card.
* @param obj2 second object to compare. Initially the copy of the original card.
* @param originalCard the original card, used to print a nice message on fail.
* @param msg the recursively built message to explain what is different.
* @param maxDepth the maximum allowed recursion. A safety mesure for the test to end.
* @param alreadyChecked Map of all Cards obj1 already compared.
* @param useRecursive When false, do not recursively compare Cards.
*/
private void compareClassRecursive(Object obj1, Object obj2, Card originalCard, String msg, int maxDepth,
Set<Card> alreadyChecked, boolean useRecursive) {
if (obj1 == null && obj2 == null) {
return;
} else if (obj1 == null || obj2 == null) {
fail(originalCard, "copy", "not same class for " + msg + "]");
} else if (obj1.getClass() != obj2.getClass()) {
fail(originalCard, "copy", "not same class for " + msg + "<" + obj1.getClass() + ">" + "]");
} else if (obj1 == obj2) { // for instances mostly
return;
} else {
// Only recurse so much.
if (maxDepth == 0) {
return;
}
// Only recurse on those objects
if (obj1 instanceof MageObject || obj1 instanceof Filter || obj1 instanceof Condition || obj1 instanceof Effect
|| obj1 instanceof Ability || obj1 instanceof Mana || obj1 instanceof Cost || obj1 instanceof DynamicValue
|| obj1 instanceof Choice || obj1 instanceof TargetPointer) {
boolean doRecurse = useRecursive;
if (obj1 instanceof Card) {
if (alreadyChecked.contains(obj1)) {
if (!doRecurse) {
return; // we already checked that obj1 and do not want to recurse. stop there.
} else {
doRecurse = false;
}
} else {
alreadyChecked.add((Card) obj1);
}
}
//System.out.println(msg);
Class class1 = obj1.getClass();
Class class2 = obj2.getClass();
do {
if (class1 == null && class2 == null) {
return;
}
if (class1 == null || class2 == null) {
fail(originalCard, "copy", "not same class for " + msg + "<" + obj1.getClass() + ">" + "]");
return;
}
if (class1.equals(ArrayList.class)) {
List list1 = (ArrayList) obj1;
List list2 = (ArrayList) obj2;
Iterator it1 = list1.iterator();
Iterator it2 = list2.iterator();
int i = 0;
while (it1.hasNext() && it2.hasNext()) {
compareClassRecursive(it1.next(), it2.next(), originalCard, msg + "<" + obj1.getClass() + ">" + "[" + i++ + "]", maxDepth - 1, alreadyChecked, useRecursive);
}
if (it1.hasNext() || it2.hasNext()) {
fail(originalCard, "copy", "not same size for (ArrayList) " + msg + "]");
}
return;
}
List<Field> ability2Fields = Arrays.stream(class2.getDeclaredFields()).collect(Collectors.toList());
// Special fields for CardImpl.class
boolean hasSpellAbilityField = false;
boolean hasMeldField = false;
boolean hasSecondSideCardField = false;
// Special fields for AbilityImpl.class
boolean hasWatchersField = false;
boolean hasModesField = false;
int fieldIndex = 0;
for (Field field1 : class1.getDeclaredFields()) {
Field field2 = ability2Fields.get(fieldIndex);
field1.setAccessible(true);
field2.setAccessible(true);
try {
Object value1 = field1.get(obj1);
Object value2 = field2.get(obj2);
boolean doFieldRecurse = true;
if (class1 == CardImpl.class) {
if (field1.getName().equals("spellAbility")) {
compareClassRecursive(((CardImpl) obj1).getSpellAbility(), ((CardImpl) obj2).getSpellAbility(), originalCard, msg + "<" + obj1.getClass() + ">" + "::" + field1.getName(), maxDepth - 1, alreadyChecked, doRecurse);
doFieldRecurse = false;
hasSpellAbilityField = true;
} else if (field1.getName().equals("meldsToCard")) {
compareClassRecursive(((CardImpl) obj1).getMeldsToCard(), ((CardImpl) obj2).getMeldsToCard(), originalCard, msg + "::" + field1.getName(), maxDepth - 1, alreadyChecked, doRecurse);
doFieldRecurse = false;
hasMeldField = true;
} else if (field1.getName().equals("secondSideCard")) {
compareClassRecursive(((CardImpl) obj1).getSecondCardFace(), ((CardImpl) obj2).getSecondCardFace(), originalCard, msg + "::" + field1.getName(), maxDepth - 1, alreadyChecked, doRecurse);
doFieldRecurse = false;
hasSecondSideCardField = true;
}
}
if (class1 == AbilityImpl.class) {
if (field1.getName().equals("watchers")) {
// Watchers are only used on initialization, they are not copied.
doFieldRecurse = false;
hasWatchersField = true;
}
if (field1.getName().equals("modes")) {
//compareClassRecursive(((AbilityImpl) obj1).getModes(), ((AbilityImpl) obj2).getModes(), originalCard, msg + "<" + obj1.getClass() + ">" + "::" + field1.getName(), maxDepth - 1);
compareClassRecursive(((AbilityImpl) obj1).getEffects(), ((AbilityImpl) obj2).getEffects(), originalCard, msg + "<" + obj1.getClass() + ">" + "::" + field1.getName(), maxDepth - 1, alreadyChecked, doRecurse);
doFieldRecurse = false;
hasModesField = true;
}
}
if (doFieldRecurse) {
compareClassRecursive(value1, value2, originalCard, msg + "<" + obj1.getClass() + ">" + "::" + field1.getName(), maxDepth - 1, alreadyChecked, doRecurse);
}
} catch (IllegalArgumentException | IllegalAccessException e) {
}
fieldIndex++;
}
// Do check that the expected special fields were encountered.
// If those field are no relevant anymore, or were renamed, please modify the matching code
// block above on how to loop into those fields.
if (class1 == CardImpl.class) {
if (!hasSpellAbilityField) {
fail(originalCard, "copy", "was expecting a spellAbility field, but found none " + msg + "]");
}
if (!hasMeldField) {
fail(originalCard, "copy", "was expecting a meldsToCard field, but found none " + msg + "]");
}
if (!hasSecondSideCardField) {
fail(originalCard, "copy", "was expecting a secondSideCard field, but found none " + msg + "]");
}
} else if (class1 == AbilityImpl.class) {
if (!hasWatchersField) {
fail(originalCard, "copy", "was expecting a watchers field, but found none " + msg + "]");
}
if (!hasModesField) {
fail(originalCard, "copy", "was expecting a modes field, but found none " + msg + "]");
}
}
class1 = class1.getSuperclass();
class2 = class2.getSuperclass();
} while (class1 != Object.class && class1 != null);
} else if (obj1 instanceof Collection) {
Collection col1 = (Collection) obj1;
Collection col2 = (Collection) obj2;
Iterator it1 = col1.iterator();
Iterator it2 = col2.iterator();
int i = 0;
while (it1.hasNext() && it2.hasNext()) {
compareClassRecursive(it1.next(), it2.next(), originalCard, msg + "<" + obj1.getClass() + ">" + "[" + i++ + "]", maxDepth - 1, alreadyChecked, useRecursive);
}
if (it1.hasNext() || it2.hasNext()) {
fail(originalCard, "copy", "not same size for (Collection) " + msg + "]");
}
} else if (obj1 instanceof Map) {
Map map1 = (Map) obj1;
Map map2 = (Map) obj2;
map1.forEach((i, el1) -> {
compareClassRecursive(el1, ((Map<?, ?>) obj2).get(i), originalCard, msg + "<" + obj1.getClass() + ">" + ".(" + i + ")", maxDepth - 1, alreadyChecked, useRecursive);
});
if (map1.size() != map2.size()) {
fail(originalCard, "copy", "not same size for (Map) " + msg + "]");
}
}
}
}
private void checkSubtypes(Card card, MtgJsonCard ref) {
if (skipListHaveName(SKIP_LIST_SUBTYPE, card.getExpansionSetCode(), card.getName())) {
return;
}
List<String> expected = new ArrayList<>(ref.subtypes);
// fix names (e.g. Urzas to Urza's)
for (ListIterator<String> it = expected.listIterator(); it.hasNext(); ) {
switch (it.next()) {
case "Urzas":
it.set("Urza's");
break;
case "Ctan":
it.set("C'tan");
}
}
if (expected.contains("Time") && expected.contains("Lord")) {
expected.remove("Time");
expected.remove("Lord");
expected.add("Time Lord");
}
// Remove subtypes that need to be ignored
Collection<String> actual = card
.getSubtype()
.stream()
.map(SubType::toString)
.collect(Collectors.toSet());
actual.removeIf(subtypesToIgnore::contains);
expected.removeIf(subtypesToIgnore::contains);
for (SubType subType : card.getSubtype()) {
if (subType.isCustomSet()) {
if (!ref.isFunny) {
fail(card, "subtypes", "subtype " + subType + " is marked as \"custom\" but is in an official set");
}
continue;
}
if (!subType.canGain(card)) {
String cardTypeString = card
.getCardType()
.stream()
.map(CardType::toString)
.reduce((a, b) -> a + " " + b)
.orElse("");
fail(card, "subtypes", "card has subtype "
+ subType.getDescription() + " (" + subType.getSubTypeSet() + ')'
+ " that doesn't match its card type(s) (" + cardTypeString + ')');
}
}
if (!eqSet(actual, expected)) {
fail(card, "subtypes", actual + " != " + expected);
}
}
private void checkSupertypes(Card card, MtgJsonCard ref) {
if (skipListHaveName(SKIP_LIST_SUPERTYPE, card.getExpansionSetCode(), card.getName())) {
return;
}
Collection<String> expected = ref.supertypes;
if (!eqSet(card.getSuperType().stream().map(s -> s.toString()).collect(Collectors.toList()), expected)) {
fail(card, "supertypes", card.getSuperType() + " != " + expected);
}
}
// There are many cards that use the word "target" or "targets" in reference to spells/abilities that target rather than actually themselves having a target.
// Examples include Wall of Shadows, Psychic Battle, Coalition Honor Guard, Aboleth Spawn, Akroan Crusader, Grip of Chaos, and many more
Pattern singularTargetRegexPattern = Pattern.compile("\\b(?<!(can|the|that|could|each|single|its|new|spell's) )target\\b");
Pattern pluralTargetsRegexPattern = Pattern.compile("\\b(?<!(new|the|that|copy|choosing|it|more|spell|or|changing|legal|has) )targets\\b");
// Note that the check includes reminder text, so any keyword ability with reminder text always included in the card text doesn't need to be added
// FIN added equip abilities with flavor words, allow for those. There are also cards that affect equip costs or equip abilities, exclude those
// Technically Enchant should be in this list, but that's added to the SpellAbility in XMage
// Earthbend is an action word and thus can be anywhere, the rest are keywords that are always first in the line
Pattern targetKeywordRegexPattern = Pattern.compile("earthbend |^((.*— )?equip(?! cost| abilit)|bestow|partner with|modular|backup)\\b", Pattern.MULTILINE);
// Checks for targeted reflexive or delayed triggered abilities, ones that only can trigger as a result of another ability
// and thus have their "when" located after a previous statement (detected by a period or comma followed by a space) instead of the start.
// Some reflexive triggers (Cemetery Desecrator, Tranquil Frillback) have a modal decision before the word target, need to check across multiple lines of text for those (and . doesn't match newlines)
// Many delayed triggers only get caught by the recursiveTargetAbilityCheck, if we want to improve that check to be an "and" instead of the current "or", we'll need to add them here
Pattern indirectTriggerTargetRegexPattern = Pattern.compile("([.,] when)(.|—\\n)+target");
// Check if the word "target" is inside a quoted ability being granted or of a token (or is using GiveScavengeContinuousEffect)
// A quoted ability always has a space before the opening quote and never before the closing one, so we can use that to ensure we're only checking inside
Pattern quotedTargetRegexPattern = Pattern.compile(" \"[^\"]*target|has scavenge");
// This check looks inside the abilities' effects to try to find a target that's not part of the main ability
// Examples include reflexive triggers (Ahn-Crop Crasher), delayed triggers (Feral Encounter), ability granting (Acidic Sliver), or tokens with abilities (Dance with Devils)
// Ideally the indirect/quoted text check and this check would always return the same result, but that would require both better regexes for checking and a lot of card changes
boolean recursiveTargetObjectCheck(Object obj, int depth) {
if (depth < 0) {
return false;
}
if (obj instanceof Effect) {
return recursiveTargetEffectCheck((Effect) obj, depth - 1);
}
if (obj instanceof Ability) {
return recursiveTargetAbilityCheck((Ability) obj, depth - 1);
}
if (obj instanceof Token) {
return ((Token) obj).getAbilities().stream().anyMatch(ability -> recursiveTargetAbilityCheck(ability, depth - 1));
}
if (obj instanceof Collection) {
return ((Collection) obj).stream().anyMatch(x -> recursiveTargetObjectCheck(x, depth - 1));
}
return false;
}
boolean recursiveTargetEffectCheck(Effect effect, int depth) {
if (depth < 0) {
return false;
}
return Arrays.stream(effect.getClass().getDeclaredFields())
.anyMatch(f -> {
f.setAccessible(true);
try {
return recursiveTargetObjectCheck(f.get(effect), depth); // Intentionally not decreasing depth here
} catch (IllegalAccessException ex) {
throw new RuntimeException(ex); // Should never happen due to setAccessible
}
});
}
boolean recursiveTargetAbilityCheck(Ability ability, int depth) {
if (depth < 0) {
return false;
}
Collection<Mode> modes = ability.getModes().values();
return modes.stream().flatMap(mode -> mode.getTargets().stream()).anyMatch(target -> !target.isNotTarget())
|| ability.getTargetAdjuster() != null
|| modes.stream().flatMap(mode -> mode.getEffects().stream()).anyMatch(effect -> recursiveTargetEffectCheck(effect, depth - 1));
}
private void checkMissingAbilities(Card card, MtgJsonCard ref) {
if (skipListHaveName(SKIP_LIST_MISSING_ABILITIES, card.getExpansionSetCode(), card.getName())) {
return;
}
// search missing abilities from card source
if (ref.text == null || ref.text.isEmpty()) {
return;
}
String refLowerText = ref.text.toLowerCase(Locale.ENGLISH);
String cardLowerText = String.join("\n", card.getRules()).toLowerCase(Locale.ENGLISH);
// special check: kicker ability must be in rules
if (card.getAbilities().containsClass(MultikickerAbility.class) && card.getRules().stream().noneMatch(rule -> rule.contains("Multikicker"))) {
fail(card, "abilities", "card have Multikicker ability, but missing it in rules text");
}
// special check: Auras need to have enchant ability added
if (card.hasSubtype(SubType.AURA, null) && !card.getAbilities().containsClass(EnchantAbility.class)) {
fail(card, "abilities", "card is an Aura but is missing this.addAbility(EnchantAbility)");
}
// special check: Sagas need to have saga ability added
if (card.hasSubtype(SubType.SAGA, null) && !card.getAbilities().containsClass(SagaAbility.class)) {
fail(card, "abilities", "card is a Saga but is missing this.addAbility(SagaAbility)");
}
// special check: backup ability should be set up correctly
if (card.getAbilities().containsClass(BackupAbility.class) && CardUtil.castStream(card.getAbilities().stream(), BackupAbility.class).noneMatch(BackupAbility::hasAbilities)) {
fail(card, "abilities", "card has backup but is missing this.addAbility(backupAbility)");
}
// special check: DFC main card should not have abilities
if (card instanceof DoubleFacedCardHalf && !card.getMainCard().getInitAbilities().isEmpty()) {
fail(card, "abilities", "transforming double-faced card should not have abilities on the main card");
}
// TODO: remove after transform ability removed
// special check: new DFC implementation should not have transform ability
if (card instanceof DoubleFacedCardHalf && card.getAbilities().containsClass(TransformAbility.class)
&& !card.getAbilities().containsClass(DayboundAbility.class)
&& !card.getAbilities().containsClass(CraftAbility.class)
&& !card.getAbilities().containsClass(SiegeAbility.class)) {
fail(card, "abilities", "new transforming double-faced card should not have transform ability");
}
// special check: Werewolves front ability should only be on front and vice versa
if (card.getAbilities().containsClass(WerewolfFrontTriggeredAbility.class) && (card.isNightCard() || (card instanceof DoubleFacedCardHalf && ((DoubleFacedCardHalf) card).isBackSide()))) {
fail(card, "abilities", "card is a back face werewolf with a front face ability");
}
if (card.getAbilities().containsClass(WerewolfBackTriggeredAbility.class) && (!card.isNightCard() && (card instanceof DoubleFacedCardHalf && !((DoubleFacedCardHalf) card).isBackSide()))) {
fail(card, "abilities", "card is a front face werewolf with a back face ability");
}
// special check: transform ability in TDFC should only be on front and vice versa
if (card.getSecondCardFace() != null && !card.isNightCard() && !card.getAbilities().containsClass(TransformAbility.class)) {
fail(card, "abilities", "double-faced cards should have transform ability on the front");
}
if (card.getSecondCardFace() != null && card.isNightCard() && card.getAbilities().containsClass(TransformAbility.class)) {
fail(card, "abilities", "double-faced cards should not have transform ability on the back");
}
// special check: back side in TDFC must be only night card
if (card.getSecondCardFace() != null && !card.getSecondCardFace().isNightCard()) {
fail(card, "abilities", "the back face of a double-faced card should be nightCard = true");
}
// special check: siege ability must be used in double faced cards only
if (card.getAbilities().containsClass(SiegeAbility.class) && (card.getSecondCardFace() == null && (card instanceof DoubleFacedCardHalf && ((DoubleFacedCardHalf) card).getOtherSide() == null))) {
fail(card, "abilities", "miss second side settings in card with siege ability");
}
// special check: legendary spells need to have legendary spell ability
if (card.isLegendary() && !card.isPermanent() && !card.getAbilities().containsClass(LegendarySpellAbility.class)) {
fail(card, "abilities", "legendary nonpermanent cards need to have LegendarySpellAbility");
}
// special check: mutate is not supported yet, so must be removed from sets
if (card.getAbilities().containsClass(MutateAbility.class)) {
// how-to fix: add that code at the end of the set
// cards.removeIf(card -> HIDE_MUTATE_CARDS && MUTATE_CARD_NAMES.contains(card.getName()));
fail(card, "abilities", "mutate cards aren't implemented and shouldn't be available");
}
// special check: some new creature's ETB must use When this creature enters instead When {this} enters
if (EntersBattlefieldTriggeredAbility.ENABLE_TRIGGER_PHRASE_AUTO_FIX) {
if (etbTriggerPhrases.isEmpty()) {
etbTriggerPhrases.addAll(EntersBattlefieldTriggeredAbility.getPossibleTriggerPhrases());
Assert.assertTrue(etbTriggerPhrases.get(0).startsWith("when"));
}
if (refLowerText.contains("when")) {
for (String needTriggerPhrase : etbTriggerPhrases) {
if (refLowerText.contains(needTriggerPhrase) && !cardLowerText.contains(needTriggerPhrase)) {
fail(card, "abilities", "wrong creature's ETB trigger phrase, must use: " + needTriggerPhrase);
}
}
}
}
// special check: wrong dies triggers (there are also a runtime check on wrong usage, see isInUseableZoneDiesTrigger)
Set<String> ignoredCards = new HashSet<>();
ignoredCards.add("Caller of the Claw");
ignoredCards.add("Boneyard Scourge");
ignoredCards.add("Fell Shepherd");
ignoredCards.add("Massacre Girl");
ignoredCards.add("Infested Thrinax");
ignoredCards.add("Xira, the Golden Sting");
ignoredCards.add("Mawloc");
ignoredCards.add("Crack in Time");
ignoredCards.add("Mysterious Limousine");
ignoredCards.add("Graceful Antelope");
ignoredCards.add("Portcullis");
List<String> ignoredAbilities = new ArrayList<>();
ignoredAbilities.add("roll"); // roll die effects
ignoredAbilities.add("with \"When"); // token creating effects
ignoredAbilities.add("gains \"When"); // token creating effects
ignoredAbilities.add("and \"When"); // token creating effects
ignoredAbilities.add("it has \"When"); // token creating effects
ignoredAbilities.add("beginning of your end step"); // step triggers
ignoredAbilities.add("beginning of each end step"); // step triggers
ignoredAbilities.add("beginning of combat"); // step triggers
if (!ignoredCards.contains(card.getName())) {
for (Ability ability : card.getAbilities()) {
TriggeredAbility triggeredAbility = ability instanceof TriggeredAbility ? (TriggeredAbility) ability : null;
if (triggeredAbility == null) {
continue;
}
// ignore exile effects
// example 1: exile up to one other target nonland permanent until Constable of the Realm leaves the battlefield.
if (ability.getAllEffects().stream().anyMatch(e -> e instanceof ExileUntilSourceLeavesEffect)) {
continue;
}
// example 2: When Hostage Taker enters the battlefield, exile another target artifact or creature until Hostage Taker leaves the battlefield
if (ability instanceof EntersBattlefieldTriggeredAbility) {
continue;
}
// search and check dies related abilities
// remove reminder text
String rules = triggeredAbility
.getRule()
.replaceAll("(?i) <i>\\(.+\\)</i>", "")
.replaceAll("(?i) \\(.+\\)", "");
if (ignoredAbilities.stream().anyMatch(rules::contains)) {
continue;
}
boolean isDiesAbility = rules.contains("die ")
|| (rules.contains("dies ") && !rules.contains("dies this turn"))
|| rules.contains("die,")
|| rules.contains("dies,");
boolean isPutToGraveAbility = rules.contains("put into")
&& rules.contains("graveyard")
&& rules.contains("from the battlefield");
boolean isLeavesBattlefield = rules.contains("leaves the battlefield") && !rules.contains("until {this} leaves the battlefield");
if (triggeredAbility.isLeavesTheBattlefieldTrigger()) {
// TODO: add check for wrongly enabled settings too?
} else {
if (isDiesAbility || isPutToGraveAbility || isLeavesBattlefield) {
fail(card, "abilities", "dies related trigger must use setLeavesTheBattlefieldTrigger(true) and possibly override isInUseableZone - "
+ triggeredAbility.getClass().getSimpleName());
}
}
}
}
// special check: duplicated words in ability text (wrong target/filter usage)
// example: You may exile __two two__ blue cards
// possible fixes:
// - remove numbers from filter's text
// - use target.getDescription() in ability instead target.getTargetName()
for (String rule : card.getRules()) {
for (String doubleNumber : doubleWords) {
if (rule.toLowerCase(Locale.ENGLISH).contains(doubleNumber)) {
fail(card, "abilities", "duplicated numbers/words: " + rule);
}
}
}
// special check: wrong targeted ability
// Checks that no ability targets use withNotTarget (use OneShotNonTargetEffect if it's a choose effect)
// Checks that, if the text contains the word target, the ability does have a target.
// - In cases involving a target in a reflexive trigger or token or other complex situation, it assumes that it's fine
// - There are two versions of this complexity check, either can trigger: one on card text, one that uses Java reflection to inspect the ability's effects.
String[] excludedCards = {"Lodestone Bauble", // Needs to choose a player before targets are selected
"Blink", // Current XMage code does not correctly support non-consecutive chapter effects, duplicates effects as a workaround
"Artifact Ward"}; // This card is just implemented wrong, but would need significant work to fix
if (Arrays.stream(excludedCards).noneMatch(x -> x.equals(ref.name))) {
for (Ability ability : card.getAbilities()) {
boolean foundNotTarget = ability.getModes().values().stream()
.flatMap(mode -> mode.getTargets().stream()).anyMatch(Target::isNotTarget);
if (foundNotTarget) {
fail(card, "abilities", "notTarget should not be used as ability target, should be inside ability effect");
}
String abilityText = ability.getRule().toLowerCase(Locale.ENGLISH);
boolean needTargetedAbility = singularTargetRegexPattern.matcher(abilityText).find() || pluralTargetsRegexPattern.matcher(abilityText).find() || targetKeywordRegexPattern.matcher(abilityText).find();
boolean recursiveAbilityText = indirectTriggerTargetRegexPattern.matcher(abilityText).find() || quotedTargetRegexPattern.matcher(abilityText).find();
boolean foundTargetedAbility = recursiveTargetAbilityCheck(ability, 0);
boolean recursiveAbility = recursiveTargetAbilityCheck(ability, 4);
if (needTargetedAbility && !(foundTargetedAbility || recursiveAbilityText || recursiveAbility)
&& card.getAbilities().stream().noneMatch(x -> x instanceof LevelUpAbility)) { // Targeting Level Up abilities' text is put in the power-toughness setting effect
fail(card, "abilities", "wrong target settings (must be targeted, but is not):" + ability.getClass().getSimpleName());
}
if (!needTargetedAbility && foundTargetedAbility
&& !(ability instanceof SpellAbility && abilityText.equals("") && card.getSubtype().contains(SubType.AURA)) // Auras' SpellAbility targets, not the EnchantAbility
&& !(ability instanceof SpellAbility && (recursiveTargetAbilityCheck(card.getSpellAbility(), 0))) // SurgeAbility is a modified copy of the main SpellAbility, so it targets
&& !(ability instanceof SpellTransformedAbility)) { // DisturbAbility targets if the backside aura targets
fail(card, "abilities", "wrong target settings (targeted ability found but no target in text):" + ability.getClass().getSimpleName());
}
}
// Also check that the reference text and the final ability text have the same number of "target"
String preparedRefText = refLowerText.replaceAll("\\([^)]+\\)", ""); // Remove reminder text
int refTargetCount = (preparedRefText.length() - preparedRefText.replace("target", "").length());
String preparedRuleText = cardLowerText.replaceAll("\\([^)]+\\)", "");
if (!ref.subtypes.contains("Adventure") && !ref.subtypes.contains("Omen")) {
preparedRuleText = preparedRuleText.replaceAll("^(adventure|omen).*", "");
}
int cardTargetCount = (preparedRuleText.length() - preparedRuleText.replace("target", "").length());
if (refTargetCount != cardTargetCount) {
fail(card, "abilities", "target count text discrepancy: " + (refTargetCount / 6) + " in reference but " + (cardTargetCount / 6) + " in card.");
}
}
// special check: missing or wrong ability/effect rules hint
Map<Class, String> ruleHints = new HashMap<>();
ruleHints.put(FightTargetsEffect.class, "Each deals damage equal to its power to the other");
ruleHints.put(MenaceAbility.class, "can't be blocked except by two or more");
ruleHints.put(ScryEffect.class, "Look at the top card of your library. You may put that card on the bottom");
ruleHints.put(EquipAbility.class, "Equip only as a sorcery.");
ruleHints.put(WardAbility.class, "becomes the target of a spell or ability an opponent controls");
ruleHints.put(ProliferateEffect.class, "Choose any number of permanents and/or players, then give each another counter of each kind already there.");
for (Class objectClass : ruleHints.keySet()) {
String needText = ruleHints.get(objectClass);
// ability/effect must have description or not
boolean needHint = ref.text.contains(needText);
boolean haveHint = card.getRules().stream().anyMatch(rule -> rule.contains(needText));
if (needHint != haveHint) {
warn(card, "card have " + objectClass.getSimpleName() + " but hint is wrong (it must be " + (needHint ? "enabled" : "disabled") + ")");
}
}
// special check: missing card hints like designation
Map<Class, String> cardHints = new HashMap<>();
cardHints.put(CitysBlessingHint.class, "city's blessing");
cardHints.put(MonarchHint.class, "the monarch");
cardHints.put(InitiativeHint.class, "the initiative");
cardHints.put(CurrentDungeonHint.class, "venture into");
for (Class hintClass : cardHints.keySet()) {
String lookupText = cardHints.get(hintClass);
boolean needHint = ref.text.contains(lookupText);
if (needHint) {
boolean haveHint = card.getAbilities()
.stream()
.flatMap(ability -> ability.getHints().stream())
.anyMatch(h -> h.getClass().equals(hintClass));
if (!haveHint) {
fail(card, "abilities", "miss card hint: " + hintClass.getSimpleName());
}
}
}
// special check: equip abilities must have controlled predicate due rules
List<EquipAbility> equipAbilities = card.getAbilities()
.stream()
.filter(a -> a instanceof EquipAbility)
.map(a -> (EquipAbility) a)
.collect(Collectors.toList());
equipAbilities.forEach(a -> {
List<Predicate> allPredicates = new ArrayList<>();
a.getTargets().forEach(t -> Predicates.collectAllComponents(t.getFilter().getPredicates(), t.getFilter().getExtraPredicates(), allPredicates));
boolean hasControlledFilter = allPredicates
.stream()
.filter(p -> p instanceof TargetController.ControllerPredicate)
.map(p -> ((TargetController.ControllerPredicate) p).getController())
.anyMatch(tc -> tc.equals(TargetController.YOU));
if (!hasControlledFilter) {
fail(card, "abilities", "card has equip ability, but it doesn't use controllered filter - " + a.getRule());
}
});
// special check: target tags must be used for all targets and starts with 1
card.getAbilities().stream().filter(a -> a.getTargets().size() >= 2).forEach(a -> {
Set<Integer> tags = a.getTargets().stream().map(Target::getTargetTag).collect(Collectors.toSet());
if (tags.size() == 1 && tags.stream().findFirst().get().equals(0)) {
// no tags usage
return;
}
if (tags.size() != a.getTargets().size() || (tags.size() > 1 && tags.contains(0))) {
// how-to fix: make sure each target has it's own target tag like 1 and 2 (don't use 0 because it's default)
fail(card, "abilities", "wrong target tags: miss tag in one of the targets, current list: " + tags);
}
});
// spells have only 1 ability
if (card.isInstantOrSorcery()) {
return;
}
// additional cost go to 1 ability
if (refLowerText.startsWith("as an additional cost to cast")) {
return;
}
// must have 1+ abilities all the time (to cast)
if (card.getAbilities().toArray().length <= 1) { // all cards have 1 inner ability to cast
fail(card, "abilities", "card's abilities is empty, but ref have text");
}
}
private static final String[] wrongSymbols = {"", "", ""};
private void checkWrongSymbolsInRules(Card card) {
for (String s : wrongSymbols) {
if (card.getName().contains(s)) {
fail(card, "card name", "card's name contains restricted symbol " + s);
}
}
for (String rule : card.getRules()) {
for (String s : wrongSymbols) {
if (rule.contains(s)) {
fail(card, "rules", "card's rules contains restricted symbol " + s);
}
}
if (rule.contains("&mdash ")) {
fail(card, "rules", "card's rules contains restricted test [&mdash ] instead [&mdash;]");
}
}
}
private void checkLegalityFormats(Card card, MtgJsonCard ref) {
if (skipListHaveName("LEGALITY", card.getExpansionSetCode(), card.getName())) {
return;
}
// TODO: add legality checks (by sets and cards, by banned)
}
private static final List<SubType> selfRefNamedSubtypes = Arrays.asList(
SubType.EQUIPMENT,
SubType.VEHICLE,
SubType.SPACECRAFT,
SubType.AURA,
SubType.CLASS,
SubType.SAGA,
SubType.SIEGE
);
private static String applySelfReference(String rule, MageObject mageObject, Game game) {
return rule
.replace("{this}", getCardSelfReference(mageObject, game))
.replace(". this", ". This")
.replace("\nthis", "\nThis")
.replace("-this", "-This")
.replace(": this", ": This")
.replace("&bull this", "&bull This")
.replace("&mdash; this", "&mdash; This")
.replace("&mdash;this", "&mdash;This")
.replace("- this", "- This");
}
/**
* Returns the description of the card for rules text that references itself.
* Applies to abilities that function only on the battlefield.
* Does not account for every exception.
* <a href="https://scryfall.com/blog/errata-notice-aetherdrift-231">Details here</a>
*/
private static String getCardSelfReference(MageObject mageObject, Game game) {
if (!mageObject.isPermanent(game)) {
return mageObject.getName();
}
if (mageObject.isPlaneswalker(game)) {
// try to ref by planeswalker name which is subtype
return mageObject
.getSubtype(game)
.stream()
.filter(SubType.getPlaneswalkerTypes()::contains)
.findFirst()
.map(SubType::getDescription)
.orElse(mageObject.getName());
}
if (mageObject.isLegendary(game)) {
// Generate shortname for legendary permanent (other than planeswalker)
List<String> parts = Arrays.asList(mageObject.getName().split("(, | of the )"));
if (parts.size() > 1) {
return parts.get(0);
} else {
return mageObject.getName();
}
}
if (mageObject.isCreature(game)) {
return "this creature";
}
for (SubType subType : selfRefNamedSubtypes) {
if (mageObject.hasSubtype(subType, game)) {
return "this " + subType.getDescription();
}
}
if (mageObject.isLand(game)) {
return "this land";
}
if (mageObject.isBattle(game)) {
return "this battle";
}
if (mageObject.isEnchantment(game)) {
return "this enchantment";
}
if (mageObject.isArtifact(game)) {
return "this artifact";
}
return "this permanent";
}
private String prepareRule(Card card, String rule) {
// remove and optimize rule text for analyze
String newRule = rule;
// remove reminder text
newRule = newRule.replaceAll("(?i) <i>\\(.+\\)</i>", "");
newRule = newRule.replaceAll("(?i) \\(.+\\)", "");
// fix specifically for mana abilities
if (newRule.startsWith("({T}: Add")) {
newRule = newRule
.replace("(", "")
.replace(")", "");
}
// replace special text and symbols
newRule = applySelfReference(newRule, card, null)
.replace("", "-")
.replace("", "-")
.replace("&mdash;", "-");
// remove html marks
newRule = newRule
.replace("<i>", "")
.replace("</i>", "");
newRule = CardNameUtil.normalizeCardName(newRule);
return CardUtil.getTextWithFirstCharUpperCase(newRule.trim());
}
@Test
public void test_showCardInfo() {
// debug only: show direct card rules from a class file without db-recreate
// - search by card name: Spark Double
// - search by class name from set: SparkDouble
// - search by class name from non-set: SparkDoubleFixedV1;SparkDoubleFixedV2
// - multiple searches: name1;class2;name3
String cardSearches = "Spark Double;AbandonedSarcophagus";
// command line support, e.g. check currently opened file
// compatible IDEs: IntelliJ IDEA, Visual Studio Code - see instructions in https://github.com/magefree/mage/wiki/Setting-up-your-Development-Environment
// example cmd: mvn install test "-Dxmage.showCardInfo=${fileBasenameNoExtension}" "-Dsurefire.failIfNoSpecifiedTests=false" "-Dxmage.build.tests.treeViewRunnerShowAllLogs=true" -Dtest=VerifyCardDataTest#test_showCardInfo
if (System.getProperty("xmage.showCardInfo") != null) {
cardSearches = System.getProperty("xmage.showCardInfo");
}
// prepare DBs
CardScanner.scan();
MtgJsonService.cards();
Arrays.stream(cardSearches.split(";")).forEach(searchName -> {
// original card
searchName = searchName.trim();
String searchClass = String.format("mage.cards.%s.%s",
searchName.substring(0, 1).toLowerCase(Locale.ENGLISH),
searchName);
String foundCardName = "";
String foundClassName = "";
// search by name
CardInfo cardInfo = CardRepository.instance.findCard(searchName);
if (cardInfo != null) {
foundCardName = cardInfo.getName();
foundClassName = cardInfo.getClassName();
}
// search by class from set
if (foundClassName.isEmpty()) {
cardInfo = CardRepository.instance.findCardsByClass(searchClass)
.stream()
.findFirst()
.orElse(null);
if (cardInfo != null) {
foundCardName = cardInfo.getName();
foundClassName = cardInfo.getClassName();
}
}
// search by class from non-set (must store in mage.cards.* package)
if (foundClassName.isEmpty()) {
Reflections reflections = new Reflections(searchClass);
try {
Set<Class<? extends CardImpl>> founded = reflections.getSubTypesOf(CardImpl.class);
if (!founded.isEmpty()) {
foundClassName = founded.stream().findFirst().get().getName();
foundCardName = searchClass;
}
} catch (Exception e) {
if (!e.getMessage().contains("SubTypesScanner was not configured")) {
// unknown error
throw e;
}
}
}
if (foundClassName.isEmpty()) {
Assert.fail("Can't find card by name or class: " + searchName);
}
CardSetInfo testSet = new CardSetInfo(foundCardName, "test", "123", Rarity.COMMON);
Card card = CardImpl.createCard(foundClassName, testSet);
Assert.assertNotNull("Card can't be loaded: " + foundClassName, card);
System.out.println();
System.out.println(card.getName() + " " + card.getManaCost().getText());
if (card instanceof CardWithSpellOption) {
// format to print main card then spell card
card.getInitAbilities().getRules().forEach(this::printAbilityText);
((CardWithSpellOption) card).getSpellCard().getAbilities().getRules().forEach(r -> printAbilityText(r.replace("&mdash; ", "\n")));
} else if (card instanceof SplitCard || card instanceof DoubleFacedCard) {
// format to print each side separately
System.out.println("=== " + ((CardWithHalves) card).getLeftHalfCard().getName() + " ===");
((CardWithHalves) card).getLeftHalfCard().getAbilities().getRules().forEach(this::printAbilityText);
System.out.println("=== " + ((CardWithHalves) card).getRightHalfCard().getName() + " ===");
((CardWithHalves) card).getRightHalfCard().getAbilities().getRules().forEach(this::printAbilityText);
} else {
card.getRules().forEach(this::printAbilityText);
}
// ref card
System.out.println();
MtgJsonCard refMain = MtgJsonService.card(card.getName());
Card cardMain = card;
MtgJsonCard refTwo = null;
Card cardTwo = null;
if (card instanceof CardWithSpellOption) {
refTwo = MtgJsonService.card(((CardWithSpellOption) card).getSpellCard().getName());
cardTwo = ((CardWithSpellOption) card).getSpellCard();
} else if (card instanceof CardWithHalves) {
refMain = MtgJsonService.card(((CardWithHalves) card).getLeftHalfCard().getName());
cardMain = ((CardWithHalves) card).getLeftHalfCard();
refTwo = MtgJsonService.card(((CardWithHalves) card).getRightHalfCard().getName());
cardTwo = ((CardWithHalves) card).getRightHalfCard();
}
if (refMain == null) {
refMain = MtgJsonService.cardByClassName(foundClassName);
}
if (refMain != null) {
System.out.println("ref: " + refMain.getNameAsFace() + " " + refMain.manaCost);
System.out.println(refMain.text);
if (refTwo != null) {
System.out.println("ref: " + refTwo.getNameAsFace() + " " + refTwo.manaCost);
System.out.println(refTwo.text);
}
} else {
System.out.println("WARNING, can't find mtgjson ref for " + card.getName());
}
// additional check to simulate diff in rules
if (refMain != null) {
checkWrongAbilitiesText(cardMain, refMain, 0, true);
}
if (refTwo != null) {
checkWrongAbilitiesText(cardTwo, refTwo, 0, true);
}
});
}
private void printAbilityText(String text) {
text = text.replace("<br>", "\n");
System.out.println(text);
}
private static boolean compareText(String cardText, String refText, String name) {
return cardText.equals(refText)
|| cardText.replace(name, name.split(", ")[0])
.equals(refText.replace(name, name.split(", ")[0]))
|| cardText.replace(name, name.split(" ")[0])
.equals(refText.replace(name, name.split(" ")[0]));
}
private static boolean checkForEffect(Card card, Class<? extends Effect> effectClazz) {
return card.getAbilities()
.stream()
.map(Ability::getModes)
.map(LinkedHashMap::values)
.flatMap(Collection::stream)
.map(Mode::getEffects)
.flatMap(Collection::stream)
.anyMatch(effectClazz::isInstance);
}
private void checkWrongAbilitiesTextStart() {
if (FULL_ABILITIES_CHECK_SET_CODES.isEmpty()) {
return;
}
System.out.println("Ability text checks started for " + FULL_ABILITIES_CHECK_SET_CODES);
wrongAbilityStatsTotal = 0;
wrongAbilityStatsGood = 0;
wrongAbilityStatsBad = 0;
}
private void checkWrongAbilitiesTextEnd() {
if (FULL_ABILITIES_CHECK_SET_CODES.isEmpty()) {
return;
}
// TODO: implement tests result/stats by github actions to show in check message compared to prev version
System.out.println();
System.out.printf("Stats for %d cards checked for abilities text:%n", wrongAbilityStatsTotal);
System.out.printf(" - Cards with correct text: %5d (%.2f)%n", wrongAbilityStatsGood, wrongAbilityStatsGood * 100.0 / wrongAbilityStatsTotal);
System.out.printf(" - Cards with text errors: %5d (%.2f)%n", wrongAbilityStatsBad, wrongAbilityStatsBad * 100.0 / wrongAbilityStatsTotal);
System.out.println();
}
private void checkWrongAbilitiesText(Card card, MtgJsonCard ref, int cardIndex, boolean forceToCheck) {
// checks missing or wrong text
if (!forceToCheck && !FULL_ABILITIES_CHECK_SET_CODES.equals("*") && !FULL_ABILITIES_CHECK_SET_CODES.contains(card.getExpansionSetCode())) {
return;
}
if (wasCheckedByAbilityText(ref)) {
return;
}
if (ref.text == null || ref.text.isEmpty()) {
return;
}
wrongAbilityStatsTotal++;
String refText = ref.text;
// planeswalker fix [-7]: xxx
refText = refText.replaceAll("\\[([\\\\+]?\\d*)\\]\\: ", "$1: ").replaceAll("\\[\\X\\]\\: ", "-X: ");
// evergreen keyword fix
for (String s : refText.replaceAll(" \\(.+?\\)", "").split("[\\$\\\n]")) {
if (Arrays
.stream(s.split("[,;] "))
.map(String::toLowerCase)
.allMatch(VerifyCardDataTest::evergreenCheck)) {
String replacement = Arrays
.stream(s.split("[,;] "))
.map(CardUtil::getTextWithFirstCharUpperCase)
.collect(Collectors.joining("\n"));
refText = refText.replace(s, replacement);
}
}
// modal spell fix
if (refText.contains("")) {
refText = refText
.replace("\n•", "\n•")
.replace("", "")
.replace("\n•", "-<br>&bull ")
.replace("\n•", "<br>&bull ");
refText += "<br>";
refText = refText.replace("<br>", "\n");
}
// remove unnecessary reminder text
refText = refText.replaceAll("^\\(.+(can be paid with|ransforms from|represents).+\\)\n", "");
// mana ability fix
// Current implementation makes one Activated Ability per kind of color.
// We split such abilities in the reference text.
// For instance "{T}: Add {G} or {W}."
// becomes "{T}: Add {G}.\n{T}: Add {W}."
//
// The regex down handle more complex situations.
refText = splitManaAbilities(refText);
// cycling fix
// Current implementation makes one CyclingAbility per quality,
// We split such abilities in the reference text.
//
// For instance "Swampcycling {2}, mountaincycling {2}"
// becomes "Swampcycling {2}\nMountaincycling {2}"
refText = splitCyclingAbilities(refText);
// ref text can contain dirty spaces after some text remove/fix, example: anchor words in Struggle for Project Purity
refText = refText.replaceAll(" +", " ");
String[] refRules = refText.split("[\\$\\\n]"); // ref card's abilities can be splited by \n or $ chars
for (int i = 0; i < refRules.length; i++) {
refRules[i] = prepareRule(card, refRules[i]);
}
if (ref.subtypes.contains("Adventure")) {
for (int i = 0; i < refRules.length; i++) {
refRules[i] = "Adventure " +
ref.types.get(0) + " - " +
ref.faceName + ' ' +
ref.manaCost + " - " +
refRules[i];
}
}
if (ref.subtypes.contains("Omen")) {
for (int i = 0; i < refRules.length; i++) {
refRules[i] = "Omen " +
ref.types.get(0) + " - " +
ref.faceName + ' ' +
ref.manaCost + " - " +
refRules[i];
}
}
String[] cardRules = card
.getRules()
.stream()
.filter(s -> !(card instanceof CardWithSpellOption) || !(s.startsWith("Adventure ") || s.startsWith("Omen ")))
.collect(Collectors.joining("\n"))
.replace("<br>", "\n")
.replace("<br/>", "\n")
.replace("<b>", "")
.replace("</b>", "")
.split("[\\$\\\n]");
for (int i = 0; i < cardRules.length; i++) {
cardRules[i] = prepareRule(card, cardRules[i]);
}
boolean isFine = true;
for (int i = 0; i < cardRules.length; i++) {
if (cardRules[i].startsWith("Use the Special button")) {
// This is a rules text for GUI implication like Quenchable Fire
cardRules[i] = "+ " + cardRules[i];
continue;
}
boolean isAbilityFounded = false;
for (int j = 0; j < refRules.length; j++) {
String refRule = refRules[j];
if (compareText(cardRules[i], refRule, card.getName())) {
cardRules[i] = "+ " + cardRules[i];
refRules[j] = "+ " + refRules[j];
isAbilityFounded = true;
break;
}
}
if (!isAbilityFounded && cardRules[i].length() > 0) {
isFine = false;
if (!CHECK_ONLY_ABILITIES_TEXT) {
warn(card, "card ability can't be found in ref [" + card.getName() + ": " + cardRules[i] + "]");
}
cardRules[i] = "- " + cardRules[i];
}
}
// mark ref rules as unknown
for (int j = 0; j <= refRules.length - 1; j++) {
String refRule = refRules[j];
if (!refRule.startsWith("+ ")) {
isFine = false;
refRules[j] = "- " + refRules[j];
}
}
if (isFine) {
wrongAbilityStatsGood++;
} else {
wrongAbilityStatsBad++;
}
// extra message for easy checks
if (forceToCheck || !isFine) {
System.out.println();
System.out.println((isFine ? "Good" : "Wrong") + " card " + cardIndex + ": " + card.getName());
Arrays.sort(cardRules);
for (String s : cardRules) {
System.out.println(s);
}
System.out.println("ref:");
Arrays.sort(refRules);
for (String s : refRules) {
System.out.println(s);
}
System.out.println();
}
}
private String splitCyclingAbilities(String refText) {
for (String s : refText.split("[\\$\\\n]")) {
if (!Pattern.matches("^[a-zA-Z]*cycling .*, [a-zA-Z]*cycling.*", s)) {
continue;
}
String newStr = "";
Pattern p = Pattern.compile(", [a-zA-Z]*cycling");
Matcher m = p.matcher(s);
int start = 0;
while (m.find()) {
String group = m.group();
int newStart = m.start();
newStr += s.substring(start, newStart) + "\n" + group.substring(2, 3).toUpperCase() + group.substring(3);
start = newStart + group.length();
}
newStr += s.substring(start);
refText = refText.replace(s, newStr);
}
return refText;
}
@Test
public void checkSplitCyclingAbilities() {
// Test the function splitting cycling abilities is correct.
Assert.assertEquals(
"Swampcycling {2}\nMountaincycling {2}",
splitCyclingAbilities("Swampcycling {2}, mountaincycling {2}")
);
}
private String splitManaAbilities(String refText) {
for (String s : refText.split("[\\$\\\n]")) {
if (!Pattern.matches("[^\"']*: Add [^\\.]* or.*\\..*", s)) {
continue;
}
// Loyalty Abilities
if (s.startsWith("0") || s.startsWith("+") || s.startsWith("-")) {
continue;
}
// Leafkin Avenger
if (s.contains("} for")) {
continue;
}
// Splitting the ability into three segments:
//
// {G/W}, {T}: Add {G}{G}, {G}{W}, or {W}{W}. This mana can only be used to cast multicolor spells.
// ^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// before ^^^^^^^^^^^^^^^^^^^^^^^^^ after
// middle
int beforeLength = s.indexOf(": Add ");
String before = s.substring(0, beforeLength + 6);
int middleIndex = s.indexOf('.', beforeLength);
String middle = s.substring(beforeLength + 6, middleIndex);
String after = s.substring(middleIndex);
//making life easier on the split
middle = middle
.replace(", or ", "|")
.replace(" or ", "|")
.replace(", ", "|");
// This now looks like "{G}{G}|{G}{W}|{W}{W}".
// for each part, make a new line with 'before + part + end'
String newStr = "";
for (String part : middle.split("[|]")) {
newStr += before + part + after + "\n";
}
if (!newStr.isEmpty()) {
newStr = newStr.substring(0, newStr.length() - 1);
}
refText = refText.replace(s, newStr);
}
return refText;
}
@Test
public void checkSplitManaAbilities() {
// Test the function splitting mana abilities is correct.
Assert.assertEquals(
"{T}: Add {G}.\n{T}: Add {W}.",
splitManaAbilities("{T}: Add {G} or {W}.")
);
Assert.assertEquals(
"{T}: Add {G}.\n{T}: Add {W}.\n{T}: Add {R}.",
splitManaAbilities("{T}: Add {G}, {W}, or {R}.")
);
Assert.assertEquals(
"{G/W}, {T}: Add {G}{G}.\n{G/W}, {T}: Add {G}{W}.\n{G/W}, {T}: Add {W}{W}.",
splitManaAbilities("{G/W}, {T}: Add {G}{G}, {G}{W}, or {W}{W}.")
);
Assert.assertEquals(
"{T}: Add {R}.\n{T}: Add one mana of the chosen color.",
splitManaAbilities("{T}: Add {R} or one mana of the chosen color.")
);
Assert.assertEquals(
"{T}: Add {B}. Activate only if you control a swamp.\n{T}: Add {U}. Activate only if you control a swamp.",
splitManaAbilities("{T}: Add {B} or {U}. Activate only if you control a swamp.")
);
// Not splitting those:
Assert.assertEquals(
"{T}: Each player creates a colorless artifact token named Banana with \"{T}, Sacrifice this artifact: Add {R} or {G}. You gain 2 life.\"",
splitManaAbilities("{T}: Each player creates a colorless artifact token named Banana with \"{T}, Sacrifice this artifact: Add {R} or {G}. You gain 2 life.\"")
);
Assert.assertEquals(
"+1: Add {R} or {G}. Creature spells you cast this turn can't be countered.",
splitManaAbilities("+1: Add {R} or {G}. Creature spells you cast this turn can't be countered.")
);
Assert.assertEquals(
"0: Add {R} or {G}. Creature spells you cast this turn can't be countered.",
splitManaAbilities("0: Add {R} or {G}. Creature spells you cast this turn can't be countered.")
);
Assert.assertEquals(
"-1: Add {R} or {G}. Creature spells you cast this turn can't be countered.",
splitManaAbilities("-1: Add {R} or {G}. Creature spells you cast this turn can't be countered.")
);
Assert.assertEquals(
"{T}: Add {G} for each creature with power 4 or greater you control.",
splitManaAbilities("{T}: Add {G} for each creature with power 4 or greater you control.")
);
}
private void checkTypes(Card card, MtgJsonCard ref) {
if (skipListHaveName(SKIP_LIST_TYPE, card.getExpansionSetCode(), card.getName())) {
return;
}
Collection<String> expected = ref.types;
List<String> type = new ArrayList<>();
for (CardType cardType : card.getCardType()) {
type.add(cardType.toString());
}
if (!eqSet(type, expected)) {
fail(card, "types", type + " != " + expected);
}
}
private void checkPT(Card card, MtgJsonCard ref) {
if (skipListHaveName(SKIP_LIST_PT, card.getExpansionSetCode(), card.getName())) {
return;
}
// Spacecraft are ignored as they shouldn't have a printed power/toughness but they do in the data
if (ref.subtypes.contains("Spacecraft")) {
return;
}
if (!eqPT(card.getPower().toString(), ref.power) || !eqPT(card.getToughness().toString(), ref.toughness)) {
String pt = card.getPower() + "/" + card.getToughness();
String expected = ref.power + '/' + ref.toughness;
fail(card, "pt", pt + " != " + expected);
}
}
private boolean eqPT(String found, String expected) {
if (expected == null) {
return "0".equals(found);
} else {
return found.equals(expected) || expected.contains("*");
}
}
private void checkLoyalty(Card card, MtgJsonCard ref) {
if (skipListHaveName(SKIP_LIST_LOYALTY, card.getExpansionSetCode(), card.getName())) {
return;
}
if (ref.loyalty == null) {
if (card.getStartingLoyalty() == -1) {
return;
}
} else if (ref.loyalty.equals("X")) {
if (card.getStartingLoyalty() == -2) {
return;
}
} else if (ref.loyalty.equals("" + card.getStartingLoyalty())) {
return;
}
fail(card, "loyalty", card.getStartingLoyalty() + " != " + ref.loyalty);
}
private void checkDefense(Card card, MtgJsonCard ref) {
if (skipListHaveName(SKIP_LIST_DEFENSE, card.getExpansionSetCode(), card.getName())) {
return;
}
if (ref.defense == null) {
if (card.getStartingDefense() == -1) {
return;
}
} else if (ref.defense.equals("X")) {
if (card.getStartingDefense() == -2) {
return;
}
} else if (ref.defense.equals("" + card.getStartingDefense())) {
return;
}
fail(card, "defense", card.getStartingDefense() + " != " + ref.defense);
}
private void checkCost(Card card, MtgJsonCard ref) {
if (skipListHaveName(SKIP_LIST_COST, card.getExpansionSetCode(), card.getName())) {
return;
}
// TODO: temporary fix - scryfall/mtgjson wrongly add [colors, mana cost] from spell part to main part/card,
// example: Ishgard, the Holy See // Faith & Grief
if (card instanceof CardWithSpellOption && card.isLand()
|| card instanceof SpellOptionCard && ((SpellOptionCard) card).getParentCard().isLand()) {
return;
}
String expected = ref.manaCost;
String cost = String.join("", card.getManaCostSymbols());
if (cost.isEmpty()) {
cost = null;
}
if (cost != null) {
cost = cost.replaceAll("P\\}", "P}");
}
if (!Objects.equals(cost, expected)) {
fail(card, "cost", cost + " != " + expected);
}
}
private boolean isBasicLandName(String name) {
String checkName = name;
if (name.startsWith("Snow-Covered ")) {
// snow lands is basic lands too
checkName = name.replace("Snow-Covered ", "");
}
return checkName.equals("Island")
|| checkName.equals("Forest")
|| checkName.equals("Swamp")
|| checkName.equals("Plains")
|| checkName.equals("Mountain");
}
private boolean isNonSnowBasicLandName(String name) {
return name.equals("Island")
|| name.equals("Forest")
|| name.equals("Swamp")
|| name.equals("Plains")
|| name.equals("Mountain");
}
private void checkRarityAndBasicLands(Card card, MtgJsonCard ref) {
if (skipListHaveName(SKIP_LIST_RARITY, card.getExpansionSetCode(), card.getName())) {
return;
}
// basic lands must have Rarity.LAND and SuperType.BASIC
// other cards can't have that stats
String name = card.getName();
if (isBasicLandName(name)) {
// lands
if (card.getRarity() != Rarity.LAND) {
fail(card, "rarity", "basic land must be Rarity.LAND");
}
if (!card.isBasic()) {
fail(card, "supertype", "basic land must be SuperType.BASIC");
}
} else if (name.equals("Wastes") || name.equals("Snow-Covered Wastes")) {
// Wastes are SuperType.BASIC but not necessarily Rarity.LAND
if (!card.isBasic()) {
fail(card, "supertype", "Wastes must be SuperType.BASIC");
}
} else {
// non lands
if (card.getRarity() == Rarity.LAND) {
fail(card, "rarity", "only basic land can be Rarity.LAND");
} else if (!card.getRarity().equals(ref.getRarity())) {
fail(card, "rarity", "mismatched. MtgJson has " + ref.getRarity() + " while set file has " + card.getRarity());
}
if (card.isBasic()) {
fail(card, "supertype", "only basic land can be SuperType.BASIC");
}
}
}
@Test
public void test_checkCardRatingConsistency() {
// all cards with same name must have same rating (see RateCard.rateCard)
// cards rating must be consistency (same) for card sorting
List<Card> cardsList = new ArrayList<>(CardScanner.getAllCards());
Map<String, Integer> cardRates = new HashMap<>();
for (Card card : cardsList) {
int curRate = RateCard.rateCard(card, Collections.emptyList(), false);
int prevRate = cardRates.getOrDefault(card.getName(), 0);
if (prevRate == 0) {
cardRates.putIfAbsent(card.getName(), curRate);
} else {
if (curRate != prevRate) {
Assert.fail("Card with same name have different ratings: " + card.getName());
}
}
}
}
@Test
public void test_checkCardConstructors() {
// create all cards, can catch additional verify and runtime checks from abilities and effects
// example: wrong code usage errors
//
// warning, look at stack trace logs, not card names -- some error code can be hidden in static methods and can be called from un-related cards
// use test_showCardInfo for detailed errors
Collection<String> errorsList = new ArrayList<>();
Collection<ExpansionSet> sets = Sets.getInstance().values();
for (ExpansionSet set : sets) {
for (ExpansionSet.SetCardInfo setInfo : set.getSetCardInfo()) {
try {
Card card = CardImpl.createCard(setInfo.getCardClass(), new CardSetInfo(setInfo.getName(), set.getCode(),
setInfo.getCardNumber(), setInfo.getRarity(), setInfo.getGraphicInfo()));
if (card == null) {
errorsList.add("Error: can't create card - " + setInfo.getCardClass() + " - see logs for errors");
continue;
}
if (!card.getExpansionSetCode().equals(set.getCode())) {
errorsList.add("Error: card constructor have custom expansionSetCode, must be removed " + setInfo.getCardClass());
}
} catch (Throwable e) {
// CardImpl.createCard don't throw exceptions (only error logs), so that logs are useless here
errorsList.add("Error: can't create card - " + setInfo.getCardClass() + " - see logs for errors");
}
}
}
if (!errorsList.isEmpty()) {
printMessages(errorsList);
Assert.fail("Found " + errorsList.size() + " broken cards, look at logs above for more details");
}
}
@Test
public void test_checkCardsInCubes() throws Exception {
Reflections reflections = new Reflections("mage.tournament.cubes.");
Set<Class<? extends DraftCube>> cubesList = reflections.getSubTypesOf(DraftCube.class);
Assert.assertFalse("Can't find any cubes", cubesList.isEmpty());
CardScanner.scan();
Collection<String> errorsList = new ArrayList<>();
for (Class<? extends DraftCube> cubeClass : cubesList) {
// need drafts with fixed cards list (constructor with zero params)
if (Arrays.stream(cubeClass.getConstructors()).noneMatch(c -> c.getParameterCount() == 0)) {
continue;
}
// cube from deck must use real deck to check complete
DraftCube cube;
if (cubeClass.isAssignableFrom(CubeFromDeck.class)) {
DeckCardLists deckCardLists = new DeckCardLists();
deckCardLists.setCards(Arrays.asList(
new DeckCardInfo("Adanto Vanguard", "1", "XLN"),
new DeckCardInfo("Boneyard Parley", "94", "XLN"),
new DeckCardInfo("Forest", "276", "XLN")
));
Deck deck = Deck.load(deckCardLists, true);
Constructor<?> con = cubeClass.getConstructor(Deck.class);
cube = (DraftCube) con.newInstance(deck);
Assert.assertFalse(deckCardLists.getCards().isEmpty());
Assert.assertEquals("deck must be loaded to cube", cube.getCubeCards().size(), deckCardLists.getCards().size());
Assert.assertTrue("cube's name must contains cards count", cube.getName().contains(deckCardLists.getCards().size() + " cards"));
} else {
cube = (DraftCube) createNewObject(cubeClass);
}
if (cube.getCubeCards().isEmpty()) {
errorsList.add("Error: broken cube, empty cards list: " + cube.getClass().getCanonicalName());
}
for (DraftCube.CardIdentity cardId : cube.getCubeCards()) {
// same find code as original cube
CardInfo cardInfo;
if (!cardId.getExtension().isEmpty()) {
cardInfo = CardRepository.instance.findCardWithPreferredSetAndNumber(cardId.getName(), cardId.getExtension(), null);
} else {
cardInfo = CardRepository.instance.findPreferredCoreExpansionCard(cardId.getName());
}
if (cardInfo == null) {
errorsList.add("Error: broken cube, can't find card: " + cube.getClass().getCanonicalName() + " - " + cardId.getName());
}
}
}
if (!errorsList.isEmpty()) {
printMessages(errorsList);
Assert.fail("Found " + errorsList.size() + " errors in the cubes, look at logs above for more details");
}
}
@Test
public void test_checkUnicodeCardNamesForImport() {
// deck import can catch real card names with non-ascii symbols like Arwen Undómiel, so it must be able to process it
// check unicode card
MtgJsonCard card = MtgJsonService.cardFromSet("LTR", "Arwen Undomiel", "194");
Assert.assertNotNull("test card must exists", card);
Assert.assertTrue(card.isUseUnicodeName());
Assert.assertEquals("Arwen Undomiel", card.getNameAsASCII());
Assert.assertEquals("Arwen Undómiel", card.getNameAsUnicode());
Assert.assertEquals("Arwen Undomiel", card.getNameAsFull());
// mtga format can contain /// in the names, so check it too
// see https://github.com/magefree/mage/pull/9855
Assert.assertEquals("Dusk // Dawn", CardNameUtil.normalizeCardName("Dusk /// Dawn"));
// check all converters
Collection<String> errorsList = new ArrayList<>();
MtgJsonService.sets().values().forEach(jsonSet -> {
jsonSet.cards.forEach(jsonCard -> {
if (jsonCard.isUseUnicodeName()) {
String inName = jsonCard.getNameAsUnicode();
String outName = CardNameUtil.normalizeCardName(inName);
String needOutName = jsonCard.getNameAsFace();
if (!outName.equals(needOutName)) {
// how-to fix: add new unicode symbol in CardNameUtil.normalizeCardName
errorsList.add(String.format("error, found unsupported unicode symbol in %s - %s", inName, jsonSet.code));
}
}
});
});
printMessages(errorsList);
if (errorsList.size() > 0) {
Assert.fail(String.format("Card name converters contains unsupported unicode symbols in %d cards, see logs above", errorsList.size()));
}
}
/**
* Not really a test. Used to make the changelog diff list
* for sets that are heavily worked on.
*/
@Ignore
@Test
public void list_ChangelogHelper() {
String setCode = "LCC";
int maxCards = 100; // don't want to look above that card number, as those are alternate prints.
boolean doExclude = false; // use the excluded array or not.
// Excluded in that list, either already listed in a previous changelog, or exceptions.
Set<Integer> excluded = new HashSet<>(Arrays.asList(
8, 9, 49, 71, 76, 79, 80, 89, 90, 92, 96, 97
));
for (int i = 17; i <= 68; ++i) {
excluded.add(i); // alternates version of the new cards.
}
/*
String setCode = "LCI";
int maxCards = 285; // don't want to look above that card number, as those are alternate prints.
boolean doExclude = false; // use the excluded array or not.
// Excluded in that list, either already listed in a previous changelog, or exceptions.
Set<Integer> excluded = new HashSet<>(Arrays.asList(
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 40, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 127, 128, 129, 130, 131, 132, 133, 134, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191, 192, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 224, 225, 226, 227, 228, 229, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 242, 243, 244, 245, 246, 247, 248, 249, 250, 251, 252, 253, 254, 255, 256, 257, 258, 259, 260, 261, 262, 263, 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 274, 275, 276, 277, 278, 279, 280, 281, 282, 283, 284
));
*/
Set<Integer> listChangelog = new HashSet<>();
List<ExpansionSet.SetCardInfo> setInfo = Sets.getInstance().get(setCode).getSetCardInfo();
for (ExpansionSet.SetCardInfo sci : setInfo) {
int cn = sci.getCardNumberAsInt();
if (cn > maxCards) {
continue;
}
if (doExclude && excluded.contains(cn)) {
continue;
}
listChangelog.add(cn);
}
List<Integer> sorted = listChangelog.stream().sorted().collect(Collectors.toList());
// to manual update the excluded array
System.out.println(sorted.stream().map(cn -> cn + "").collect(Collectors.joining(", ")));
// Scryfall list of all the new cards for that set
System.out.println(
"https://scryfall.com/search?q=s%3A" + setCode + "+-is%3Areprint+%28"
+ sorted.stream().map(cn -> "cn%3A" + cn).collect(Collectors.joining("+or+"))
+ "%29&order=set&as=grid&unique=cards"
);
}
/**
* Helper test to download infinite combos data and prepare it to use in Commander Brackets score system
* <p>
* How-to use: run it before each main release and upload updated files to github
*/
@Ignore
@Test
public void downloadAndPrepareCommanderBracketsData() {
// download data
// load data by pages, max limit = 100, no needs to setup it, ~2000 combos or 20 pages
String nextUrl = "https://backend.commanderspellbook.com/variants?format=json&group_by_combo=true&ordering=created&q=cards%3D2+result%3Ainfinite";
SpellBookCardsPage page;
int pageNumber = 0;
List<String> allCombos = new ArrayList<>();
while (nextUrl != null && !nextUrl.isEmpty()) {
pageNumber++;
System.out.println("downloading page " + pageNumber);
String res = XmageURLConnection.downloadText(nextUrl);
page = new Gson().fromJson(res, SpellBookCardsPage.class);
if (page == null || page.results == null) {
System.out.println("ERROR, unknown data format: " + res.substring(0, 10) + "...");
return;
}
page.results.forEach(combo -> {
if (combo.uses.isEmpty()) {
System.out.println("wrong combo uses: " + combo.uses);
return;
}
// Stella Lee, Wild Card - can generate infinite combo by itself, so allow 1 card
String card1 = combo.uses.get(0).card.name;
String card2 = combo.uses.size() == 1 ? card1 : combo.uses.get(1).card.name;
if (card1 != null && card2 != null) {
allCombos.add(String.format("%s@%s", card1, card2));
}
});
nextUrl = page.next;
}
// save results (run from Mage.Verify)
File destFile = new TFile("..\\Mage\\src\\main\\resources\\brackets\\infinite-combos.txt");
if (!destFile.exists()) {
System.out.println("Can't find dest file " + destFile);
return;
}
List<String> infiniteCombosRes;
try {
infiniteCombosRes = Files.readAllLines(destFile.toPath(), StandardCharsets.UTF_8);
} catch (IOException e) {
System.out.println("Can't read file " + destFile + " " + e);
return;
}
// replace all cards data, but keep comments
infiniteCombosRes.removeIf(s -> !s.startsWith("#")
|| s.startsWith("# Source")
|| s.startsWith("# Updated")
|| s.startsWith("# Found")
);
infiniteCombosRes.add(String.format("# Source: %s", "https://commanderspellbook.com"));
infiniteCombosRes.add(String.format("# Updated: %s", LocalDate.now().format(DateTimeFormatter.ISO_DATE)));
infiniteCombosRes.add(String.format("# Found: %d", allCombos.size()));
infiniteCombosRes.addAll(allCombos); // do not sort, all new combos will be added to the end due spell book default order
try {
Files.write(destFile.toPath(), infiniteCombosRes, StandardCharsets.UTF_8);
} catch (IOException e) {
System.out.println("Can't write file " + destFile + " " + e);
return;
}
System.out.println(String.format("ALL DONE, found and write %d combos", allCombos.size()));
}
}