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