diff --git a/Mage.Client/src/main/java/org/mage/plugins/card/dl/sources/ScryfallImageSupportTokens.java b/Mage.Client/src/main/java/org/mage/plugins/card/dl/sources/ScryfallImageSupportTokens.java index bb20b138e70..280e8b8f582 100644 --- a/Mage.Client/src/main/java/org/mage/plugins/card/dl/sources/ScryfallImageSupportTokens.java +++ b/Mage.Client/src/main/java/org/mage/plugins/card/dl/sources/ScryfallImageSupportTokens.java @@ -1,5 +1,7 @@ package org.mage.plugins.card.dl.sources; +import mage.cards.repository.TokenRepository; + import java.util.HashMap; import java.util.Map; @@ -13,11 +15,13 @@ public class ScryfallImageSupportTokens { private static final Map supportedCards = new HashMap() { { // xmage token -> direct or api link: + // // examples: - // direct example: https://img.scryfall.com/cards/large/en/trix/6.jpg + // direct example: https://cards.scryfall.io/large/back/d/c/dc26e13b-7a0f-4e7f-8593-4f22234f4517.jpg // api example: https://api.scryfall.com/cards/trix/6/en?format=image // api example: https://api.scryfall.com/cards/trix/6?format=image - // api format is primary + // api example: https://api.scryfall.com/cards/tvow/21/en?format=image&face=back + // api format is primary (direct images links can be changed by scryfall) // // code form for one token: // set/token_name @@ -25,6 +29,14 @@ public class ScryfallImageSupportTokens { // code form for same name tokens (alternative images): // set/token_name/1 // set/token_name/2 + // + // double faced cards: + // front face image: format=image&face=front + // back face image: format=image&face=back + + // XMAGE + // additional tokens for reminder/helper images + putAll(TokenRepository.instance.prepareScryfallDownloadList()); // RIX put("RIX/City's Blessing", "https://api.scryfall.com/cards/trix/6/en?format=image"); // TODO: missing from tokens data diff --git a/Mage.Tests/src/test/java/org/mage/test/serverside/TokenImagesTest.java b/Mage.Tests/src/test/java/org/mage/test/serverside/TokenImagesTest.java index 7e315eca0c5..84513322e1f 100644 --- a/Mage.Tests/src/test/java/org/mage/test/serverside/TokenImagesTest.java +++ b/Mage.Tests/src/test/java/org/mage/test/serverside/TokenImagesTest.java @@ -1,9 +1,17 @@ package org.mage.test.serverside; +import mage.abilities.Ability; +import mage.abilities.common.SimpleActivatedAbility; +import mage.abilities.costs.mana.ManaCostsImpl; +import mage.abilities.effects.common.CreateTokenEffect; import mage.cards.Card; +import mage.cards.repository.TokenRepository; import mage.constants.PhaseStep; import mage.constants.Zone; import mage.game.permanent.PermanentToken; +import mage.game.permanent.token.Token; +import mage.game.permanent.token.TokenImpl; +import mage.game.permanent.token.custom.CreatureToken; import mage.util.CardUtil; import mage.view.CardView; import mage.view.GameView; @@ -31,6 +39,22 @@ public class TokenImagesTest extends CardTestPlayerBase { private static final Pattern checkPattern = Pattern.compile("(\\w+)([<=>])(\\d+)"); // code=12, code>0 + static class TestToken extends TokenImpl { + + TestToken(String name, String description) { + super(name, description); + } + + TestToken(final TestToken token) { + super(token); + } + + @Override + public Token copy() { + return new TestToken(this); + } + } + private void prepareCards_MemorialToGlory(String... cardsList) { // {3}{W}, {T}, Sacrifice Memorial to Glory: Create two 1/1 white Soldier creature tokens. prepareCards_Inner(Zone.BATTLEFIELD, "Memorial to Glory", 4, cardsList); @@ -315,8 +339,52 @@ public class TokenImagesTest extends CardTestPlayerBase { } @Test - @Ignore // TODO: implement + @Ignore + // TODO: implement auto-generate creature token images from public tokens (by name, type, color, PT, abilities) + public void test_CreatureToken_MustGetDefaultImage() { + Ability ability = new SimpleActivatedAbility( + Zone.ALL, + new CreateTokenEffect(new CreatureToken(2, 2), 10), + new ManaCostsImpl<>("") + ); + addCustomCardWithAbility("test", playerA, ability); + + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "create ten"); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, 1 + 10); // 1 test card + 10 tokens + + assert_Inner("test", 0, 0, 1, + "", 10, false, "XXX=10"); + } + + @Test public void test_UnknownToken_MustGetDefaultImage() { + // all unknown tokens must put in XMAGE set + String xmageSetCode = TokenRepository.XMAGE_TOKENS_SET_CODE; + TestToken token = new TestToken("Unknown Token", "xxx"); + Ability ability = new SimpleActivatedAbility( + Zone.ALL, + new CreateTokenEffect(token, 10), + new ManaCostsImpl<>("") + ); + addCustomCardWithAbility("test", playerA, ability); + + activateAbility(1, PhaseStep.PRECOMBAT_MAIN, playerA, "create ten"); + waitStackResolved(1, PhaseStep.PRECOMBAT_MAIN); + + setStrictChooseMode(true); + setStopAt(1, PhaseStep.END_TURN); + execute(); + + assertPermanentCount(playerA, 1 + 10); // 1 test card + 10 tokens + + assert_Inner("test", 0, 0, 1, + "Unknown Token", 10, false, xmageSetCode + "=10"); } @Test diff --git a/Mage.Verify/src/test/java/mage/verify/VerifyCardDataTest.java b/Mage.Verify/src/test/java/mage/verify/VerifyCardDataTest.java index 37cb86fa8e8..24482253aba 100644 --- a/Mage.Verify/src/test/java/mage/verify/VerifyCardDataTest.java +++ b/Mage.Verify/src/test/java/mage/verify/VerifyCardDataTest.java @@ -1203,7 +1203,7 @@ public class VerifyCardDataTest { Collection errorsList = new ArrayList<>(); Collection warningsList = new ArrayList<>(); - // all tokens must be stores in card-pictures-tok.txt (if not then viewer and image downloader are missing token images) + // all tokens must be stores in tokens-database.txt (if not then viewer and image downloader are missing token images) // https://github.com/ronmamo/reflections Reflections reflections = new Reflections("mage."); Set> tokenClassesList = reflections.getSubTypesOf(TokenImpl.class); @@ -1220,6 +1220,7 @@ public class VerifyCardDataTest { } // xmage sets Set allSetCodes = Sets.getInstance().values().stream().map(ExpansionSet::getCode).collect(Collectors.toSet()); + allSetCodes.add(TokenRepository.XMAGE_TOKENS_SET_CODE); // reminder tokens // tok file's data @@ -1290,14 +1291,14 @@ public class VerifyCardDataTest { } else if (tokDataNamesIndex.getOrDefault(token.getName().replace(" Token", ""), "").isEmpty()) { // 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 card-pictures-tok.txt for token: " + tokenClass.getName() + " -> " + token.getName()); + errorsList.add("Error: can't find data in tokens-database.txt for token: " + tokenClass.getName() + " -> " + token.getName()); } } // CHECK: wrong set codes in tok-data tokDataTokensBySetIndex.forEach((setCode, setTokens) -> { if (!allSetCodes.contains(setCode)) { - errorsList.add("error, card-pictures-tok.txt contains unknown set code: " + errorsList.add("error, tokens-database.txt contains unknown set code: " + setCode + " - " + setTokens.stream().map(TokenInfo::getName).collect(Collectors.joining(", "))); } }); @@ -1353,6 +1354,10 @@ public class VerifyCardDataTest { }); // 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) @@ -1364,13 +1369,37 @@ public class VerifyCardDataTest { // CHECK: token and class names must be same in all sets TokenRepository.instance.getAllByClassName().forEach((className, list) -> { - Set names = list.stream().map(TokenInfo::getName).collect(Collectors.toSet()); + // ignore reminder tokens + Set names = list.stream() + .filter(token -> !token.getTokenType().equals(TokenType.XMAGE)) + .map(TokenInfo::getName) + .collect(Collectors.toSet()); if (names.size() > 1) { - errorsList.add("error, card-pictures-tok.txt contains different names for same class: " + errorsList.add("error, tokens-database.txt contains different names for same class: " + className + " - " + String.join(", ", names)); } }); + Set usedNames = new HashSet<>(); + TokenRepository.instance.getByType(TokenType.XMAGE).forEach(token -> { + // CHECK: xmage's tokens must be unique + // how-to fix: edit TokenRepository->loadXmageTokens + String needName = String.format("%s.%d", token.getName(), token.getImageNumber()); + if (usedNames.contains(needName)) { + errorsList.add("error, xmage token's name and image number must be unique: " + + token.getName() + " - " + token.getImageNumber()); + } else { + usedNames.add(needName); + } + + // CHECK: xmage's tokens must be downloadable + // how-to fix: edit TokenRepository->loadXmageTokens + if (token.getDownloadUrl().isEmpty()) { + errorsList.add("error, xmage token's must have download url: " + + token.getName() + " - " + token.getImageNumber()); + } + }); + // TODO: all sets must have full tokens data in tok file (token in every set) // 1. Download scryfall tokens list: https://api.scryfall.com/cards/search?q=t:token // 2. Proccess each token with all prints: read "prints_search_uri" field from token data and go to link like diff --git a/Mage/src/main/java/mage/abilities/effects/ContinuousEffects.java b/Mage/src/main/java/mage/abilities/effects/ContinuousEffects.java index d1f5ca01e0a..786c0f4d851 100644 --- a/Mage/src/main/java/mage/abilities/effects/ContinuousEffects.java +++ b/Mage/src/main/java/mage/abilities/effects/ContinuousEffects.java @@ -1273,12 +1273,12 @@ public class ContinuousEffects implements Serializable { } public synchronized void addEffect(ContinuousEffect effect, Ability source) { - if (effect == null) { - logger.error("Effect is null: " + source.toString()); - return; - } else if (source == null) { - logger.warn("Adding effect without ability : " + effect); + if (effect == null || source == null) { + // addEffect(effect, source) need a non-null source + throw new IllegalArgumentException("Wrong code usage. Effect and source can't be null here: " + + source + "; " + effect); } + switch (effect.getEffectType()) { case REPLACEMENT: case REDIRECTION: @@ -1313,9 +1313,12 @@ public class ContinuousEffects implements Serializable { case CONTINUOUS_RULE_MODIFICATION: continuousRuleModifyingEffects.addEffect((ContinuousRuleModifyingEffect) effect, source); break; - default: + case CONTINUOUS: + case ONESHOT: layeredEffects.addEffect(effect, source); break; + default: + throw new IllegalArgumentException("Unknown effect type: " + effect.getEffectType()); } } diff --git a/Mage/src/main/java/mage/cards/repository/TokenInfo.java b/Mage/src/main/java/mage/cards/repository/TokenInfo.java index fb5c636cd64..4488bbedda7 100644 --- a/Mage/src/main/java/mage/cards/repository/TokenInfo.java +++ b/Mage/src/main/java/mage/cards/repository/TokenInfo.java @@ -7,14 +7,16 @@ package mage.cards.repository; */ public class TokenInfo { - private TokenType tokenType; - private String name; - private String setCode; - private Integer imageNumber = 1; // if one set contains diff images with same name + private final TokenType tokenType; + private final String name; + private final String setCode; + private final Integer imageNumber; // if one set contains diff images with same name - private String classFileName; + private final String classFileName; - private String imageFileName; + private final String imageFileName; + + private String downloadUrl = ""; public TokenInfo(TokenType tokenType, String name, String setCode, Integer imageNumber) { this(tokenType, name, setCode, imageNumber, "", ""); @@ -31,7 +33,7 @@ public class TokenInfo { @Override public String toString() { - return String.format("%s - %s - %d (%s)", this.setCode, this.name, this.imageNumber, this.classFileName); + return String.format("%s - %s - %s - %d (%s)", this.tokenType, this.setCode, this.name, this.imageNumber, this.classFileName); } public TokenType getTokenType() { @@ -50,14 +52,19 @@ public class TokenInfo { return setCode; } - public String getClassFileName() { - return classFileName; - } - public Integer getImageNumber() { return imageNumber; } + public String getDownloadUrl() { + return downloadUrl; + } + + public TokenInfo withDownloadUrl(String downloadUrl) { + this.downloadUrl = downloadUrl; + return this; + } + public String getFullClassFileName() { String simpleName = classFileName.isEmpty() ? name.replaceAll("[^a-zA-Z0-9]", "") : classFileName; switch (this.tokenType) { @@ -69,6 +76,8 @@ public class TokenInfo { return "mage.game.command.planes." + simpleName; case DUNGEON: return "mage.game.command.dungeons." + simpleName; + case XMAGE: + return classFileName; default: throw new IllegalStateException("Unknown token type: " + this.tokenType); } diff --git a/Mage/src/main/java/mage/cards/repository/TokenRepository.java b/Mage/src/main/java/mage/cards/repository/TokenRepository.java index c40daafc5bf..8315e22cb9c 100644 --- a/Mage/src/main/java/mage/cards/repository/TokenRepository.java +++ b/Mage/src/main/java/mage/cards/repository/TokenRepository.java @@ -15,11 +15,13 @@ public enum TokenRepository { instance; + public static final String XMAGE_TOKENS_SET_CODE = "XMAGE"; + private static final Logger logger = Logger.getLogger(TokenRepository.class); private ArrayList allTokens = new ArrayList<>(); - private Map> indexByClassName = new HashMap<>(); - private Map> indexByType = new HashMap<>(); + private final Map> indexByClassName = new HashMap<>(); + private final Map> indexByType = new HashMap<>(); TokenRepository() { } @@ -29,7 +31,9 @@ public enum TokenRepository { return; } - allTokens = loadAllTokens(); + // tokens + allTokens = loadMtgTokens(); + allTokens.addAll(loadXmageTokens()); // index allTokens.forEach(token -> { @@ -72,7 +76,7 @@ public enum TokenRepository { return indexByClassName.getOrDefault(fullClassName, new ArrayList<>()); } - private static ArrayList loadAllTokens() throws RuntimeException { + private static ArrayList loadMtgTokens() throws RuntimeException { // Must load tokens data in strict mode (throw exception on any error) // Try to put verify checks here instead verify tests String dbSource = "tokens-database.txt"; @@ -203,4 +207,72 @@ public enum TokenRepository { return list; } + + public Map prepareScryfallDownloadList() { + init(); + + Map res = new LinkedHashMap<>(); + + // format example: + // put("ONC/Angel/1", "https://api.scryfall.com/cards/tonc/2/en?format=image"); + allTokens.stream() + .filter(token -> token.getTokenType().equals(TokenType.XMAGE)) + .forEach(token -> { + String code = String.format("%s/%s/%d", token.getSetCode(), token.getName(), token.getImageNumber()); + res.put(code, token.getDownloadUrl()); + }); + return res; + } + + private static TokenInfo createXmageToken(String name, Integer imageNumber, String scryfallDownloadUrl) { + return new TokenInfo(TokenType.XMAGE, name, XMAGE_TOKENS_SET_CODE, imageNumber) + .withDownloadUrl(scryfallDownloadUrl); + } + + private static ArrayList loadXmageTokens() { + // Create reminder/helper tokens (special images like Copy, Morph, Manifest, etc) + // Search by + // - https://tagger.scryfall.com/tags/card/assistant-cards + // - https://scryfall.com/search?q=otag%3Aassistant-cards&unique=cards&as=grid&order=name + // Must add only unique prints + // TODO: add custom set in download window to download a custom tokens only + // TODO: add custom set in card viewer to view a custom tokens only + ArrayList res = new ArrayList<>(); + + // Copy + // https://scryfall.com/search?q=include%3Aextras+unique%3Aprints+type%3Atoken+copy&unique=cards&as=grid&order=name + res.add(createXmageToken("Copy", 1, "https://api.scryfall.com/cards/tclb/19/en?format=image")); + res.add(createXmageToken("Copy", 2, "https://api.scryfall.com/cards/tsnc/1/en?format=image")); + res.add(createXmageToken("Copy", 3, "https://api.scryfall.com/cards/tvow/19/en?format=image")); + res.add(createXmageToken("Copy", 4, "https://api.scryfall.com/cards/tznr/12/en?format=image")); + + // City's Blessing + // https://scryfall.com/search?q=type%3Atoken+include%3Aextras+unique%3Aprints+City%27s+Blessing+&unique=cards&as=grid&order=name + res.add(createXmageToken("City's Blessing", 1, "https://api.scryfall.com/cards/f18/2/en?format=image")); + + // Day // Night + // https://scryfall.com/search?q=include%3Aextras+unique%3Aprints+%22Day+%2F%2F+Night%22&unique=cards&as=grid&order=name + res.add(createXmageToken("Day", 1, "https://api.scryfall.com/cards/tvow/21/en?format=image&face=front")); + res.add(createXmageToken("Night", 1, "https://api.scryfall.com/cards/tvow/21/en?format=image&face=back")); + + // Manifest + // https://scryfall.com/search?q=Manifest+include%3Aextras+unique%3Aprints&unique=cards&as=grid&order=name + res.add(createXmageToken("Manifest", 1, "https://api.scryfall.com/cards/tc19/28/en?format=image")); + res.add(createXmageToken("Manifest", 2, "https://api.scryfall.com/cards/tc18/1/en?format=image")); + res.add(createXmageToken("Manifest", 3, "https://api.scryfall.com/cards/tfrf/4/en?format=image")); + res.add(createXmageToken("Manifest", 4, "https://api.scryfall.com/cards/tncc/3/en?format=image")); + + // Morph + // https://scryfall.com/search?q=Morph+unique%3Aprints+otag%3Aassistant-cards&unique=cards&as=grid&order=name + res.add(createXmageToken("Morph", 1, "https://api.scryfall.com/cards/tktk/11/en?format=image")); + res.add(createXmageToken("Morph", 2, "https://api.scryfall.com/cards/ta25/15/en?format=image")); + res.add(createXmageToken("Morph", 3, "https://api.scryfall.com/cards/tc19/27/en?format=image")); + + // The Monarch + // https://scryfall.com/search?q=Monarch+unique%3Aprints+otag%3Aassistant-cards&unique=cards&as=grid&order=name + res.add(createXmageToken("The Monarch", 1, "https://api.scryfall.com/cards/tonc/22/en?format=image")); + res.add(createXmageToken("The Monarch", 2, "https://api.scryfall.com/cards/tcn2/1/en?format=image")); + + return res; + } } diff --git a/Mage/src/main/java/mage/cards/repository/TokenType.java b/Mage/src/main/java/mage/cards/repository/TokenType.java index 59afb3ee368..3fee2741212 100644 --- a/Mage/src/main/java/mage/cards/repository/TokenType.java +++ b/Mage/src/main/java/mage/cards/repository/TokenType.java @@ -1,6 +1,7 @@ package mage.cards.repository; /** + * GUI related * XMage's token types for images * * @author JayDi85 @@ -10,6 +11,7 @@ public enum TokenType { TOKEN, EMBLEM, PLANE, - DUNGEON + DUNGEON, + XMAGE // custom images for reminder cards like Copy, Manifest, etc } diff --git a/Mage/src/main/java/mage/game/permanent/token/EmptyToken.java b/Mage/src/main/java/mage/game/permanent/token/EmptyToken.java index 9b9ff133fe4..49d6fdf81fe 100644 --- a/Mage/src/main/java/mage/game/permanent/token/EmptyToken.java +++ b/Mage/src/main/java/mage/game/permanent/token/EmptyToken.java @@ -1,6 +1,8 @@ package mage.game.permanent.token; /** + * Token container for copyable characteristics, don't put it to battlefield + * * @author nantuko */ public final class EmptyToken extends TokenImpl { diff --git a/Mage/src/main/java/mage/game/permanent/token/TokenImpl.java b/Mage/src/main/java/mage/game/permanent/token/TokenImpl.java index 925953efd05..7fb1e35e8dd 100644 --- a/Mage/src/main/java/mage/game/permanent/token/TokenImpl.java +++ b/Mage/src/main/java/mage/game/permanent/token/TokenImpl.java @@ -167,14 +167,13 @@ public abstract class TokenImpl extends MageObjectImpl implements Token { } } - - // by set code + // search by set code List possibleInfo = TokenRepository.instance.getByClassName(token.getClass().getName()) .stream() .filter(info -> info.getSetCode().equals(setCode)) .collect(Collectors.toList()); - // by random set + // search by random set if (possibleInfo.isEmpty()) { possibleInfo = new ArrayList<>(TokenRepository.instance.getByClassName(token.getClass().getName())); } @@ -183,11 +182,13 @@ public abstract class TokenImpl extends MageObjectImpl implements Token { return RandomUtil.randomFromCollection(possibleInfo); } - // unknown token - // TODO: download default tokens for xmage's set and use random images from it - // example: TOK.zip/Creature.1.full.jpg - // example: TOK.zip/Creature.2.full.jpg - return new TokenInfo(TokenType.TOKEN, "Unknown", "XMAGE", 0); + // TODO: implement auto-generate images for CreatureToken (search public tokens for same characteristics) + // TODO: implement Copy image + // TODO: implement Manifest image + // TODO: implement Morph image + + // unknown tokens + return new TokenInfo(TokenType.TOKEN, "Unknown", TokenRepository.XMAGE_TOKENS_SET_CODE, 0); } @Override @@ -406,7 +407,7 @@ public abstract class TokenImpl extends MageObjectImpl implements Token { } /** - * Set token index to search in card-pictures-tok.txt (if set have multiple + * Set token index to search in tokens-database.txt (if set have multiple * tokens with same name) Default is 1 */ @Override diff --git a/Mage/src/main/java/mage/game/permanent/token/custom/CreatureToken.java b/Mage/src/main/java/mage/game/permanent/token/custom/CreatureToken.java index 691a448cb2f..764297fe47e 100644 --- a/Mage/src/main/java/mage/game/permanent/token/custom/CreatureToken.java +++ b/Mage/src/main/java/mage/game/permanent/token/custom/CreatureToken.java @@ -10,6 +10,10 @@ import mage.game.permanent.token.TokenImpl; import java.util.Arrays; /** + * Token builder for token effects + * + * Use it for custom tokens (tokens without public class and image) + * * @author JayDi85 */ public final class CreatureToken extends TokenImpl { diff --git a/Mage/src/main/java/mage/util/CardUtil.java b/Mage/src/main/java/mage/util/CardUtil.java index ec89547e4a6..fa2451f07b2 100644 --- a/Mage/src/main/java/mage/util/CardUtil.java +++ b/Mage/src/main/java/mage/util/CardUtil.java @@ -976,7 +976,7 @@ public final class CardUtil { || text.startsWith("any ")) { return text; } - return vowels.contains(text.substring(0, 1)) ? "an " + text : "a " + text; + return (!text.isEmpty() && vowels.contains(text.substring(0, 1))) ? "an " + text : "a " + text; } public static String italicizeWithEmDash(String text) {