Tokens rework:

- added reminder / helper tokens support (example: Copy, Morph, Day // Night, related to #10139);
 - added verify checks for reminder tokens;
 - added images download for reminder tokens;
This commit is contained in:
Oleg Agafonov 2023-04-27 18:45:50 +04:00
parent 0e1e6a0f21
commit f86cf176d7
11 changed files with 242 additions and 40 deletions

View file

@ -1,5 +1,7 @@
package org.mage.plugins.card.dl.sources; package org.mage.plugins.card.dl.sources;
import mage.cards.repository.TokenRepository;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
@ -13,11 +15,13 @@ public class ScryfallImageSupportTokens {
private static final Map<String, String> supportedCards = new HashMap<String, String>() { private static final Map<String, String> supportedCards = new HashMap<String, String>() {
{ {
// xmage token -> direct or api link: // xmage token -> direct or api link:
//
// examples: // 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/en?format=image
// api example: https://api.scryfall.com/cards/trix/6?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: // code form for one token:
// set/token_name // set/token_name
@ -25,6 +29,14 @@ public class ScryfallImageSupportTokens {
// code form for same name tokens (alternative images): // code form for same name tokens (alternative images):
// set/token_name/1 // set/token_name/1
// set/token_name/2 // 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 // RIX
put("RIX/City's Blessing", "https://api.scryfall.com/cards/trix/6/en?format=image"); // TODO: missing from tokens data put("RIX/City's Blessing", "https://api.scryfall.com/cards/trix/6/en?format=image"); // TODO: missing from tokens data

View file

@ -1,9 +1,17 @@
package org.mage.test.serverside; 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.Card;
import mage.cards.repository.TokenRepository;
import mage.constants.PhaseStep; import mage.constants.PhaseStep;
import mage.constants.Zone; import mage.constants.Zone;
import mage.game.permanent.PermanentToken; 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.util.CardUtil;
import mage.view.CardView; import mage.view.CardView;
import mage.view.GameView; 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 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) { private void prepareCards_MemorialToGlory(String... cardsList) {
// {3}{W}, {T}, Sacrifice Memorial to Glory: Create two 1/1 white Soldier creature tokens. // {3}{W}, {T}, Sacrifice Memorial to Glory: Create two 1/1 white Soldier creature tokens.
prepareCards_Inner(Zone.BATTLEFIELD, "Memorial to Glory", 4, cardsList); prepareCards_Inner(Zone.BATTLEFIELD, "Memorial to Glory", 4, cardsList);
@ -315,8 +339,52 @@ public class TokenImagesTest extends CardTestPlayerBase {
} }
@Test @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() { 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 @Test

View file

@ -1203,7 +1203,7 @@ public class VerifyCardDataTest {
Collection<String> errorsList = new ArrayList<>(); Collection<String> errorsList = new ArrayList<>();
Collection<String> warningsList = new ArrayList<>(); Collection<String> 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 // https://github.com/ronmamo/reflections
Reflections reflections = new Reflections("mage."); Reflections reflections = new Reflections("mage.");
Set<Class<? extends TokenImpl>> tokenClassesList = reflections.getSubTypesOf(TokenImpl.class); Set<Class<? extends TokenImpl>> tokenClassesList = reflections.getSubTypesOf(TokenImpl.class);
@ -1220,6 +1220,7 @@ public class VerifyCardDataTest {
} }
// xmage sets // xmage sets
Set<String> allSetCodes = Sets.getInstance().values().stream().map(ExpansionSet::getCode).collect(Collectors.toSet()); 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 // tok file's data
@ -1290,14 +1291,14 @@ public class VerifyCardDataTest {
} else if (tokDataNamesIndex.getOrDefault(token.getName().replace(" Token", ""), "").isEmpty()) { } else if (tokDataNamesIndex.getOrDefault(token.getName().replace(" Token", ""), "").isEmpty()) {
// how-to fix: public token must be downloadable, so tok-data must contain miss set // 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) // (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 // CHECK: wrong set codes in tok-data
tokDataTokensBySetIndex.forEach((setCode, setTokens) -> { tokDataTokensBySetIndex.forEach((setCode, setTokens) -> {
if (!allSetCodes.contains(setCode)) { 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(", "))); + 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 // tok data have tokens, but cards from set are miss
tokDataTokensBySetIndex.forEach((setCode, setTokens) -> { tokDataTokensBySetIndex.forEach((setCode, setTokens) -> {
if (setCode.equals(TokenRepository.XMAGE_TOKENS_SET_CODE)) {
// ignore reminder tokens
return;
}
if (!setsWithTokens.containsKey(setCode)) { if (!setsWithTokens.containsKey(setCode)) {
// Possible reasons: // Possible reasons:
// - outdated set code in tokens database (must be fixed by new set code, another verify check it) // - 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 // CHECK: token and class names must be same in all sets
TokenRepository.instance.getAllByClassName().forEach((className, list) -> { TokenRepository.instance.getAllByClassName().forEach((className, list) -> {
Set<String> names = list.stream().map(TokenInfo::getName).collect(Collectors.toSet()); // 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) { 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)); + 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) // 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 // 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 // 2. Proccess each token with all prints: read "prints_search_uri" field from token data and go to link like

View file

@ -1273,12 +1273,12 @@ public class ContinuousEffects implements Serializable {
} }
public synchronized void addEffect(ContinuousEffect effect, Ability source) { public synchronized void addEffect(ContinuousEffect effect, Ability source) {
if (effect == null) { if (effect == null || source == null) {
logger.error("Effect is null: " + source.toString()); // addEffect(effect, source) need a non-null source
return; throw new IllegalArgumentException("Wrong code usage. Effect and source can't be null here: "
} else if (source == null) { + source + "; " + effect);
logger.warn("Adding effect without ability : " + effect);
} }
switch (effect.getEffectType()) { switch (effect.getEffectType()) {
case REPLACEMENT: case REPLACEMENT:
case REDIRECTION: case REDIRECTION:
@ -1313,9 +1313,12 @@ public class ContinuousEffects implements Serializable {
case CONTINUOUS_RULE_MODIFICATION: case CONTINUOUS_RULE_MODIFICATION:
continuousRuleModifyingEffects.addEffect((ContinuousRuleModifyingEffect) effect, source); continuousRuleModifyingEffects.addEffect((ContinuousRuleModifyingEffect) effect, source);
break; break;
default: case CONTINUOUS:
case ONESHOT:
layeredEffects.addEffect(effect, source); layeredEffects.addEffect(effect, source);
break; break;
default:
throw new IllegalArgumentException("Unknown effect type: " + effect.getEffectType());
} }
} }

View file

@ -7,14 +7,16 @@ package mage.cards.repository;
*/ */
public class TokenInfo { public class TokenInfo {
private TokenType tokenType; private final TokenType tokenType;
private String name; private final String name;
private String setCode; private final String setCode;
private Integer imageNumber = 1; // if one set contains diff images with same name 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) { public TokenInfo(TokenType tokenType, String name, String setCode, Integer imageNumber) {
this(tokenType, name, setCode, imageNumber, "", ""); this(tokenType, name, setCode, imageNumber, "", "");
@ -31,7 +33,7 @@ public class TokenInfo {
@Override @Override
public String toString() { 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() { public TokenType getTokenType() {
@ -50,14 +52,19 @@ public class TokenInfo {
return setCode; return setCode;
} }
public String getClassFileName() {
return classFileName;
}
public Integer getImageNumber() { public Integer getImageNumber() {
return imageNumber; return imageNumber;
} }
public String getDownloadUrl() {
return downloadUrl;
}
public TokenInfo withDownloadUrl(String downloadUrl) {
this.downloadUrl = downloadUrl;
return this;
}
public String getFullClassFileName() { public String getFullClassFileName() {
String simpleName = classFileName.isEmpty() ? name.replaceAll("[^a-zA-Z0-9]", "") : classFileName; String simpleName = classFileName.isEmpty() ? name.replaceAll("[^a-zA-Z0-9]", "") : classFileName;
switch (this.tokenType) { switch (this.tokenType) {
@ -69,6 +76,8 @@ public class TokenInfo {
return "mage.game.command.planes." + simpleName; return "mage.game.command.planes." + simpleName;
case DUNGEON: case DUNGEON:
return "mage.game.command.dungeons." + simpleName; return "mage.game.command.dungeons." + simpleName;
case XMAGE:
return classFileName;
default: default:
throw new IllegalStateException("Unknown token type: " + this.tokenType); throw new IllegalStateException("Unknown token type: " + this.tokenType);
} }

View file

@ -15,11 +15,13 @@ public enum TokenRepository {
instance; instance;
public static final String XMAGE_TOKENS_SET_CODE = "XMAGE";
private static final Logger logger = Logger.getLogger(TokenRepository.class); private static final Logger logger = Logger.getLogger(TokenRepository.class);
private ArrayList<TokenInfo> allTokens = new ArrayList<>(); private ArrayList<TokenInfo> allTokens = new ArrayList<>();
private Map<String, List<TokenInfo>> indexByClassName = new HashMap<>(); private final Map<String, List<TokenInfo>> indexByClassName = new HashMap<>();
private Map<TokenType, List<TokenInfo>> indexByType = new HashMap<>(); private final Map<TokenType, List<TokenInfo>> indexByType = new HashMap<>();
TokenRepository() { TokenRepository() {
} }
@ -29,7 +31,9 @@ public enum TokenRepository {
return; return;
} }
allTokens = loadAllTokens(); // tokens
allTokens = loadMtgTokens();
allTokens.addAll(loadXmageTokens());
// index // index
allTokens.forEach(token -> { allTokens.forEach(token -> {
@ -72,7 +76,7 @@ public enum TokenRepository {
return indexByClassName.getOrDefault(fullClassName, new ArrayList<>()); return indexByClassName.getOrDefault(fullClassName, new ArrayList<>());
} }
private static ArrayList<TokenInfo> loadAllTokens() throws RuntimeException { private static ArrayList<TokenInfo> loadMtgTokens() throws RuntimeException {
// Must load tokens data in strict mode (throw exception on any error) // Must load tokens data in strict mode (throw exception on any error)
// Try to put verify checks here instead verify tests // Try to put verify checks here instead verify tests
String dbSource = "tokens-database.txt"; String dbSource = "tokens-database.txt";
@ -203,4 +207,72 @@ public enum TokenRepository {
return list; return list;
} }
public Map<String, String> prepareScryfallDownloadList() {
init();
Map<String, String> 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<TokenInfo> 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<TokenInfo> 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;
}
} }

View file

@ -1,6 +1,7 @@
package mage.cards.repository; package mage.cards.repository;
/** /**
* GUI related
* XMage's token types for images * XMage's token types for images
* *
* @author JayDi85 * @author JayDi85
@ -10,6 +11,7 @@ public enum TokenType {
TOKEN, TOKEN,
EMBLEM, EMBLEM,
PLANE, PLANE,
DUNGEON DUNGEON,
XMAGE // custom images for reminder cards like Copy, Manifest, etc
} }

View file

@ -1,6 +1,8 @@
package mage.game.permanent.token; package mage.game.permanent.token;
/** /**
* Token container for copyable characteristics, don't put it to battlefield
*
* @author nantuko * @author nantuko
*/ */
public final class EmptyToken extends TokenImpl { public final class EmptyToken extends TokenImpl {

View file

@ -167,14 +167,13 @@ public abstract class TokenImpl extends MageObjectImpl implements Token {
} }
} }
// search by set code
// by set code
List<TokenInfo> possibleInfo = TokenRepository.instance.getByClassName(token.getClass().getName()) List<TokenInfo> possibleInfo = TokenRepository.instance.getByClassName(token.getClass().getName())
.stream() .stream()
.filter(info -> info.getSetCode().equals(setCode)) .filter(info -> info.getSetCode().equals(setCode))
.collect(Collectors.toList()); .collect(Collectors.toList());
// by random set // search by random set
if (possibleInfo.isEmpty()) { if (possibleInfo.isEmpty()) {
possibleInfo = new ArrayList<>(TokenRepository.instance.getByClassName(token.getClass().getName())); 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); return RandomUtil.randomFromCollection(possibleInfo);
} }
// unknown token // TODO: implement auto-generate images for CreatureToken (search public tokens for same characteristics)
// TODO: download default tokens for xmage's set and use random images from it // TODO: implement Copy image
// example: TOK.zip/Creature.1.full.jpg // TODO: implement Manifest image
// example: TOK.zip/Creature.2.full.jpg // TODO: implement Morph image
return new TokenInfo(TokenType.TOKEN, "Unknown", "XMAGE", 0);
// unknown tokens
return new TokenInfo(TokenType.TOKEN, "Unknown", TokenRepository.XMAGE_TOKENS_SET_CODE, 0);
} }
@Override @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 * tokens with same name) Default is 1
*/ */
@Override @Override

View file

@ -10,6 +10,10 @@ import mage.game.permanent.token.TokenImpl;
import java.util.Arrays; import java.util.Arrays;
/** /**
* Token builder for token effects
*
* Use it for custom tokens (tokens without public class and image)
*
* @author JayDi85 * @author JayDi85
*/ */
public final class CreatureToken extends TokenImpl { public final class CreatureToken extends TokenImpl {

View file

@ -976,7 +976,7 @@ public final class CardUtil {
|| text.startsWith("any ")) { || text.startsWith("any ")) {
return text; 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) { public static String italicizeWithEmDash(String text) {