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;
import mage.cards.repository.TokenRepository;
import java.util.HashMap;
import java.util.Map;
@ -13,11 +15,13 @@ public class ScryfallImageSupportTokens {
private static final Map<String, String> supportedCards = new HashMap<String, String>() {
{
// 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

View file

@ -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

View file

@ -1203,7 +1203,7 @@ public class VerifyCardDataTest {
Collection<String> errorsList = 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
Reflections reflections = new Reflections("mage.");
Set<Class<? extends TokenImpl>> tokenClassesList = reflections.getSubTypesOf(TokenImpl.class);
@ -1220,6 +1220,7 @@ public class VerifyCardDataTest {
}
// 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
@ -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<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) {
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<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

View file

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

View file

@ -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);
}

View file

@ -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<TokenInfo> allTokens = new ArrayList<>();
private Map<String, List<TokenInfo>> indexByClassName = new HashMap<>();
private Map<TokenType, List<TokenInfo>> indexByType = new HashMap<>();
private final Map<String, List<TokenInfo>> indexByClassName = new HashMap<>();
private final Map<TokenType, List<TokenInfo>> 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<TokenInfo> loadAllTokens() throws RuntimeException {
private static ArrayList<TokenInfo> 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<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;
/**
* 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
}

View file

@ -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 {

View file

@ -167,14 +167,13 @@ public abstract class TokenImpl extends MageObjectImpl implements Token {
}
}
// by set code
// search by set code
List<TokenInfo> 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

View file

@ -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 {

View file

@ -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) {