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 checkedNames = new HashSet<>(); // skip already checked cards private static final HashMap> skipCheckLists = new HashMap<>(); private static final Set 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 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 doubleWords = new ArrayList<>(); // for inner calc private static final List 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 outputMessages = new ArrayList<>(); private int failed = 0; private int wrongAbilityStatsTotal = 0; private int wrongAbilityStatsGood = 0; private int wrongAbilityStatsBad = 0; private static Set 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 boolean eqSet(Collection a, Collection 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 allCards = CardScanner.getAllCards(true, true, false); 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 doubleErrors = new ArrayList<>(); Collection sets = Sets.getInstance().values(); for (ExpansionSet set : sets) { Map 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 doubleErrors = new ArrayList<>(); Collection 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 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 errorsList = new ArrayList<>(); Map classesIndex = new HashMap<>(); int totalCards = 0; Collection 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 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 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 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 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 filesList = new ArrayList<>(); try { Files.walkFileTree(rootPath, new SimpleFileVisitor() { @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 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 errorsList = new ArrayList<>(); Collection warningsList = new ArrayList<>(); Collection xmageSets = Sets.getInstance().values(); Set foundedJsonCards = new HashSet<>(); // fast check instead card's db search (only main side card) Set 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 errorsList = new ArrayList<>(); Collection 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 errorsList = new ArrayList<>(); Collection xmageSets = Sets.getInstance().values(); Set 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 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 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 list, boolean sorted) { ArrayList sortedList = new ArrayList<>(list); if (sorted) { sortedList.sort(new Comparator() { @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 list) { printMessages(list, true); } private String extractShortClass(Class 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 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 errorsList = new ArrayList<>(); Collection warningsList = new ArrayList<>(); Collection 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 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 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 errorsList = new ArrayList<>(); Collection warningsList = new ArrayList<>(); Collection 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 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 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 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 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 errorsList = new ArrayList<>(); Collection warningsList = new ArrayList<>(); Reflections reflections = new Reflections("mage."); Set> watcherClassesList = reflections.getSubTypesOf(Watcher.class); for (Class 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 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 constructor = (Constructor) 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 errorsList = new ArrayList<>(); Collection 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> tokenClassesList = reflections.getSubTypesOf(TokenImpl.class); // xmage tokens Set> privateTokens = new HashSet<>(); Set> publicTokens = new HashSet<>(); for (Class tokenClass : tokenClassesList) { if (Modifier.isPublic(tokenClass.getModifiers())) { publicTokens.add(tokenClass); } else { privateTokens.add(tokenClass); } } // xmage sets Set 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 tokFileTokens = TokenRepository.instance.getAll(); LinkedHashMap tokDataClassesIndex = new LinkedHashMap<>(); LinkedHashMap tokDataNamesIndex = new LinkedHashMap<>(); LinkedHashMap> tokDataTokensBySetIndex = new LinkedHashMap<>(); for (TokenInfo tokData : tokFileTokens) { String searchName; String setsList; // by set List 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 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 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 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 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 doesn’t 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 doesn’t change its subtype(s), and vice versa. for (Class 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 cardsList = new ArrayList<>(CardScanner.getAllCards()); Map> 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 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 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 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 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 errorsList = new ArrayList<>(); Reflections reflections = new Reflections("mage."); Set> planesClassesList = reflections.getSubTypesOf(Plane.class); // 1. correct class name for (Class 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 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 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 errorsList = new ArrayList<>(); Reflections reflections = new Reflections("mage."); Set> dungeonClassesList = reflections.getSubTypesOf(Dungeon.class); // 1. correct class name for (Class 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 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 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 errorsList = new ArrayList<>(); // prepare DBs CardScanner.scan(); Reflections reflections = new Reflections("mage."); Set> emblemClassesList = reflections.getSubTypesOf(Emblem.class).stream() .filter(c -> !c.equals(EmblemOfCard.class)) // ignore emblem card .collect(Collectors.toSet()); // 1. correct class name for (Class 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 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 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 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> 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 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 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 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 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 expected = new ArrayList<>(ref.subtypes); // fix names (e.g. Urza’s to Urza's) for (ListIterator it = expected.listIterator(); it.hasNext(); ) { switch (it.next()) { case "Urza’s": it.set("Urza's"); break; case "C’tan": 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 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 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(? 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 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 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 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) \\(.+\\)", "") .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 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 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 equipAbilities = card.getAbilities() .stream() .filter(a -> a instanceof EquipAbility) .map(a -> (EquipAbility) a) .collect(Collectors.toList()); equipAbilities.forEach(a -> { List 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 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 [—]"); } } } 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 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("— this", "— This") .replace("—this", "—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. * Details here */ 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 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) \\(.+\\)", ""); 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("—", "-"); // remove html marks newRule = newRule .replace("", "") .replace("", ""); 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> 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("— ", "\n"))); } else if (card instanceof SplitCard || card instanceof ModalDoubleFacedCard) { card.getAbilities().getRules().forEach(this::printAbilityText); } else { card.getRules().forEach(this::printAbilityText); } // ref card System.out.println(); MtgJsonCard refMain = MtgJsonService.card(card.getName()); MtgJsonCard refSpell = null; if (card instanceof CardWithSpellOption) { refSpell = MtgJsonService.card(((CardWithSpellOption) card).getSpellCard().getName()); } if (refMain == null) { refMain = MtgJsonService.cardByClassName(foundClassName); } if (refMain != null) { System.out.println("ref: " + refMain.getNameAsFace() + " " + refMain.manaCost); System.out.println(refMain.text); if (refSpell != null) { System.out.println(refSpell.getNameAsFace() + " " + refSpell.manaCost); System.out.println(refSpell.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(card, refMain, 0, true); } else if (refSpell != null) { checkWrongAbilitiesText(((CardWithSpellOption) card).getSpellCard(), refSpell, 0, true); } }); } private void printAbilityText(String text) { text = text.replace("
", "\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 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•", "-
&bull ") .replace("\n•", "
&bull "); refText += "
"; refText = refText.replace("
", "\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("
", "\n") .replace("
", "\n") .replace("", "") .replace("", "") .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 expected = ref.types; List 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 cardsList = new ArrayList<>(CardScanner.getAllCards()); Map 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 errorsList = new ArrayList<>(); Collection 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> cubesList = reflections.getSubTypesOf(DraftCube.class); Assert.assertFalse("Can't find any cubes", cubesList.isEmpty()); CardScanner.scan(); Collection errorsList = new ArrayList<>(); for (Class 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 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 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 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 listChangelog = new HashSet<>(); List 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 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 *

* 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 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 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())); } }