download: reworked scryfall images support:

- download: fixed unmount zip errors on cancel download in some use cases (closes #12536);
- download: significant download speed improvements (now it depends on user's network speed, not api limitations);
- download: added additional error dialogs on bad use cases;
- scryfall: added cards and bulk data api support;
- scryfall: added bulk data download (updates once per week, contains all scryfall cards and store in images\downloading folder, 2 GB size);
- scryfall: added optimized images download without api usage (use direct images links from bulk data, closes #11576);
- scryfall: improved image source searching for some use cases (miss or wrong images problems, closes #12511);
- scryfall: tokens don't use bulk data;
- scryfall: 75k small images downloads 40 minutes and takes 1 GB and 2100 api calls (most of it from tokens);
- scryfall: how-to disable bulk data, e.g. for api testing: -Dxmage.scryfallEnableBulkData=false
This commit is contained in:
Oleg Agafonov 2024-08-03 19:41:14 +04:00
parent 46f7304692
commit 0a55e37c8c
19 changed files with 884 additions and 216 deletions

View file

@ -1355,7 +1355,7 @@ public class MageFrame extends javax.swing.JFrame implements MageClient {
userRequestDialog.showDialog(userRequestMessage);
}
public void showErrorDialog(String errorType, Exception e) {
public void showErrorDialog(String errorType, Throwable e) {
String errorMessage = e.getMessage();
if (errorMessage == null || errorMessage.isEmpty() || errorMessage.equals("Null")) {
errorMessage = e.getClass().getSimpleName() + " - look at server or client logs for more details";

View file

@ -387,7 +387,7 @@
<Property name="icon" type="javax.swing.Icon" editor="org.netbeans.modules.form.editors2.IconEditor">
<Image iconType="3" name="/buttons/search_24.png"/>
</Property>
<Property name="toolTipText" type="java.lang.String" value="Fast search your flag"/>
<Property name="toolTipText" type="java.lang.String" value="Search set to download"/>
<Property name="alignmentX" type="float" value="1.0"/>
<Property name="preferredSize" type="java.awt.Dimension" editor="org.netbeans.beaninfo.editors.DimensionEditor">
<Dimension value="[25, 25]"/>
@ -419,7 +419,7 @@
<SubComponents>
<Component class="javax.swing.JCheckBox" name="checkboxRedownload">
<Properties>
<Property name="text" type="java.lang.String" value="&lt;html&gt;Re-download all images"/>
<Property name="text" type="java.lang.String" value="&lt;html&gt;Re-download all selected images"/>
<Property name="verticalAlignment" type="int" value="3"/>
</Properties>
<Constraints>

View file

@ -358,7 +358,7 @@ public class DownloadImagesDialog extends MageDialog {
panelModeSelect.add(fillerMode1);
buttonSearchSet.setIcon(new javax.swing.ImageIcon(getClass().getResource("/buttons/search_24.png"))); // NOI18N
buttonSearchSet.setToolTipText("Fast search your flag");
buttonSearchSet.setToolTipText("Search set to download");
buttonSearchSet.setAlignmentX(1.0F);
buttonSearchSet.setPreferredSize(new java.awt.Dimension(25, 25));
buttonSearchSet.addActionListener(new java.awt.event.ActionListener() {
@ -378,7 +378,7 @@ public class DownloadImagesDialog extends MageDialog {
panelRedownload.setPreferredSize(new java.awt.Dimension(280, 100));
panelRedownload.setLayout(new java.awt.BorderLayout());
checkboxRedownload.setText("<html>Re-download all images");
checkboxRedownload.setText("<html>Re-download all selected images");
checkboxRedownload.setVerticalAlignment(javax.swing.SwingConstants.BOTTOM);
panelRedownload.add(checkboxRedownload, java.awt.BorderLayout.CENTER);
panelRedownload.add(filler1, java.awt.BorderLayout.PAGE_END);
@ -444,6 +444,7 @@ public class DownloadImagesDialog extends MageDialog {
}//GEN-LAST:event_buttonStopActionPerformed
private void doClose(int retStatus) {
returnStatus = retStatus;
setVisible(false);
dispose();

View file

@ -52,6 +52,7 @@ public class XmageURLConnection {
Proxy proxy = null;
HttpURLConnection connection = null;
HttpLoggingType loggingType = HttpLoggingType.ERRORS;
boolean forceGZipEncoding = false;
public XmageURLConnection(String url) {
this.url = url;
@ -75,6 +76,10 @@ public class XmageURLConnection {
}
}
public void setForceGZipEncoding(boolean enable) {
this.forceGZipEncoding = enable;
}
/**
* Connect to server
*/
@ -130,7 +135,11 @@ public class XmageURLConnection {
}
private void initDefaultHeaders() {
// warning, do not add Accept-Encoding - it processing inside URLConnection for http/https links (trying to use gzip by default)
// warning, Accept-Encoding processing inside URLConnection for http/https links (trying to use gzip by default)
// use force encoding for special use cases (example: download big text file as zip file)
if (forceGZipEncoding) {
this.connection.setRequestProperty("Accept-Encoding", "gzip");
}
// user agent due standard notation User-Agent: <product> / <product-version> <comment>
// warning, dot not add os, language and other details
@ -290,13 +299,18 @@ public class XmageURLConnection {
return "";
}
public static InputStream downloadBinary(String resourceUrl) {
return downloadBinary(resourceUrl, false);
}
/**
* Fast download of binary data
*
* @return stream on OK 200 response or null on any other errors
*/
public static InputStream downloadBinary(String resourceUrl) {
public static InputStream downloadBinary(String resourceUrl, boolean downloadAsGZip) {
XmageURLConnection con = new XmageURLConnection(resourceUrl);
con.setForceGZipEncoding(downloadAsGZip);
con.startConnection();
if (con.isConnected()) {
try {

View file

@ -301,10 +301,9 @@ public final class ManaSymbols {
if (SvgUtils.haveSvgSupport()) {
file = getSymbolFileNameAsSVG(symbol);
if (file.exists()) {
try {
InputStream fileStream = new FileInputStream(file);
try(InputStream fileStream = Files.newInputStream(file.toPath())) {
image = loadSymbolAsSVG(fileStream, file.getPath(), size, size);
} catch (FileNotFoundException ignore) {
} catch (IOException ignore) {
}
}
}

View file

@ -4,7 +4,10 @@ import mage.client.util.CardLanguage;
import org.mage.plugins.card.dl.DownloadServiceInfo;
import org.mage.plugins.card.images.CardDownloadData;
import java.util.*;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* @author North, JayDi85
@ -70,4 +73,8 @@ public interface CardImageSource {
default boolean isTokenImageProvided(String setCode, String cardName, Integer tokenNumber) {
return false;
}
default void onFinished() {
// cleanup temp resources
}
}

View file

@ -0,0 +1,19 @@
package org.mage.plugins.card.dl.sources;
import java.util.Date;
/**
* Scryfall API: bulk file links to download
* <p>
* API docs: <a href="https://scryfall.com/docs/api/bulk-data">here</a>
*
* @author JayDi85
*/
public class ScryfallApiBulkData {
public String object;
public String type;
public Date updated_at;
public long size;
public String download_uri;
}

View file

@ -0,0 +1,156 @@
package org.mage.plugins.card.dl.sources;
import mage.cards.decks.CardNameUtil;
import java.util.List;
import java.util.Map;
/**
* Scryfall API: card info
* <p>
* API docs: <a href="https://scryfall.com/docs/api/cards">here</a>
* <p>
* Field values depends on data source, check it on null (as example: bulk data from oracle cards and bulk data from all cards)
*
* @author JayDi85
*/
public class ScryfallApiCard {
public String name;
public String set; // set code
public String collector_number;
public String lang;
public String layout; // card type like adventure, etc, see https://scryfall.com/docs/api/layouts
public List<ScryfallApiCardFace> card_faces;
public String image_status; // missing, placeholder, lowres, or highres_scan
public Map<String, String> image_uris;
// fast access fields, fills on loading
transient public String imageSmall = "";
transient public String imageNormal = "";
transient public String imageLarge = "";
// potentially interesting fields, can be used in other places
//public UUID oracle_id; // TODO: implement card hint with oracle/cr ruling texts (see Rulings bulk data)
//public Integer edhrec_rank; // TODO: use it to rating cards for AI and draft bots
//public Object legalities; // TODO: add verify check for bans list
//public Boolean full_art; // TODO: add verify check for full art usage in sets
//public Object prices; // TODO: add total deck price and table limit by deck's price
//public Date released_at; // the date this card was first released.
//public String watermark; // background watermark image for some cards
public void prepareCompatibleData() {
if (this.image_uris != null) {
this.imageSmall = this.image_uris.getOrDefault("small", "");
this.imageNormal = this.image_uris.getOrDefault("normal", "");
this.imageLarge = this.image_uris.getOrDefault("large", "");
this.image_uris = null;
}
if (this.card_faces != null) {
this.card_faces.forEach(ScryfallApiCardFace::prepareCompatibleData);
}
// workaround for adventure card name fix:
// - scryfall: Ondu Knotmaster // Throw a Line
// - xmage: Ondu Knotmaster
if (this.layout.equals("adventure")) {
this.name = this.card_faces.get(0).name;
}
// workaround for flip card name and image fixes:
// - scryfall: Budoka Pupil // Ichiga, Who Topples Oaks
// - xmage: Budoka Pupil
if (this.layout.equals("flip")) {
if (!this.card_faces.get(0).imageNormal.isEmpty()
|| !this.card_faces.get(1).imageNormal.isEmpty()) {
throw new IllegalArgumentException("Scryfall: unsupported data type, flip parts must not have images data in scryfall "
+ this.set + " - " + this.collector_number + " - " + this.name);
}
// fix name
this.name = this.card_faces.get(0).name;
// fix image (xmage uses diff cards for flips, but it's same image)
// example: https://scryfall.com/card/sok/103/homura-human-ascendant-homuras-essence
this.card_faces.get(0).image_uris = this.image_uris;
this.card_faces.get(0).imageSmall = this.imageSmall;
this.card_faces.get(0).imageNormal = this.imageNormal;
this.card_faces.get(0).imageLarge = this.imageLarge;
this.card_faces.get(1).image_uris = this.image_uris;
this.card_faces.get(1).imageSmall = this.imageSmall;
this.card_faces.get(1).imageNormal = this.imageNormal;
this.card_faces.get(1).imageLarge = this.imageLarge;
}
// workaround for reversed cards:
// - scryfall: Command Tower // Command Tower
// - xmage: Command Tower (second side as diff card and direct link image), example: https://scryfall.com/card/rex/26/command-tower-command-tower
if (this.layout.equals("reversible_card")) {
if (!this.card_faces.get(0).name.equals(this.card_faces.get(1).name)) {
throw new IllegalArgumentException("Scryfall: unsupported data type, reversible_card has diff faces "
+ this.set + " - " + this.collector_number + " - " + this.name);
}
this.name = this.card_faces.get(0).name;
}
// workaround for non ascii names
// - scryfall uses original names like Arna Kennerüd, Skycaptain
// - xmage need ascii only names like Arna Kennerud, Skycaptain
this.name = CardNameUtil.normalizeCardName(this.name);
// workaround for non scii card numbers
// - scryfall uses unicode numbers for reprints like Chandra Nalaar - dd2 - 34 https://scryfall.com/card/dd2/34%E2%98%85/
// - xmage uses ascii alternative Chandra Nalaar - dd2 - 34*
this.collector_number = transformCardNumberFromScryfallToXmage(this.collector_number);
}
public static String transformCardNumberFromXmageToScryfall(String cardNumber) {
String res = cardNumber;
if (res.endsWith("*")) {
res = res.substring(0, res.length() - 1) + "";
}
if (res.endsWith("+")) {
res = res.substring(0, res.length() - 1) + "";
}
if (res.endsWith("Ph")) {
res = res.substring(0, res.length() - 2) + "Φ";
}
return res;
}
public static String transformCardNumberFromScryfallToXmage(String cardNumber) {
String res = cardNumber;
if (res.endsWith("")) {
res = res.substring(0, res.length() - 1) + "*";
}
if (res.endsWith("")) {
res = res.substring(0, res.length() - 1) + "+";
}
if (res.endsWith("Φ")) {
res = res.substring(0, res.length() - 1) + "Ph";
}
return res;
}
public String findImage(String imageSize) {
// api possible values:
// - small
// - normal
// - large
// - png
// - art_crop
// - border_crop
switch (imageSize) {
case "small":
return this.imageSmall;
case "normal":
return this.imageNormal;
case "large":
return this.imageLarge;
default:
throw new IllegalArgumentException("Unsupported image size: " + imageSize);
}
}
}

View file

@ -0,0 +1,50 @@
package org.mage.plugins.card.dl.sources;
import java.util.Map;
/**
* Scryfall API: card face info (used for double faced cards)
* <p>
* API docs: <a href="https://scryfall.com/docs/api/cards">here</a>
* <p>
*
* @author JayDi85
*/
public class ScryfallApiCardFace {
public String name;
public Map<String, String> image_uris;
// fast access fields, fills on loading
transient public String imageSmall = "";
transient public String imageNormal = "";
transient public String imageLarge = "";
public void prepareCompatibleData() {
if (this.image_uris != null) {
this.imageSmall = this.image_uris.getOrDefault("small", "");
this.imageNormal = this.image_uris.getOrDefault("normal", "");
this.imageLarge = this.image_uris.getOrDefault("large", "");
this.image_uris = null;
}
}
public String findImage(String imageSize) {
// api possible values:
// - small
// - normal
// - large
// - png
// - art_crop
// - border_crop
switch (imageSize) {
case "small":
return this.imageSmall;
case "normal":
return this.imageNormal;
case "large":
return this.imageLarge;
default:
throw new IllegalArgumentException("Unsupported image size: " + imageSize);
}
}
}

View file

@ -1,22 +1,36 @@
package org.mage.plugins.card.dl.sources;
import com.google.gson.Gson;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.stream.JsonReader;
import mage.MageException;
import mage.client.remote.XmageURLConnection;
import mage.client.util.CardLanguage;
import mage.util.JsonUtil;
import net.java.truevfs.access.TFile;
import net.java.truevfs.access.TFileInputStream;
import net.java.truevfs.access.TFileOutputStream;
import net.java.truevfs.access.TVFS;
import org.apache.log4j.Logger;
import org.mage.plugins.card.dl.DownloadServiceInfo;
import org.mage.plugins.card.images.CardDownloadData;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;
import java.util.zip.GZIPInputStream;
import static org.mage.plugins.card.utils.CardImageUtils.getImagesDir;
/**
* Download: scryfall API support to download images and other data
*
* @author JayDi85
*/
public class ScryfallImageSource implements CardImageSource {
@ -32,6 +46,21 @@ public class ScryfallImageSource implements CardImageSource {
private static final ReentrantLock waitBeforeRequestLock = new ReentrantLock();
private static final int DOWNLOAD_TIMEOUT_MS = 300;
// bulk images optimization, how it works
// - scryfall limit all calls to API endpoints, but allow direct calls to CDN (scryfall.io) without limitation
// - so download and prepare whole scryfall database
// - for each downloading card try to find it in bulk database and use direct link from it
// - only card images supported (without tokens, emblems, etc)
private static final boolean SCRYFALL_BULK_FILES_ENABLED = true; // use bulk data to find direct links instead API calls
private static final long SCRYFALL_BULK_FILES_OUTDATE_IN_MILLIS = 7 * 24 * 3600 * 1000; // after 1 week need to download again
private static final String SCRYFALL_BULK_FILES_DATABASE_SOURCE_API = "https://api.scryfall.com/bulk-data/all-cards"; // 300 MB in zip
private static final boolean SCRYFALL_BULK_FILES_DEBUG_READ_ONLY_MODE = false; // default: false - for faster debug only, ignore write operations
// run app with -Dxmage.scryfallEnableBulkData=false to disable bulk data (e.g. for testing api links generation)
private static final String SCRYFALL_BULK_FILES_PROPERTY = "xmage.scryfallEnableBulkData";
private static final List<ScryfallApiCard> bulkCardsDatabaseAll = new ArrayList<>(); // 100MB memory footprint, cleaning on download stop
private static final Map<String, ScryfallApiCard> bulkCardsDatabaseDefault = new HashMap<>(); // card/set/number
public static ScryfallImageSource getInstance() {
return instance;
}
@ -117,7 +146,7 @@ public class ScryfallImageSource implements CardImageSource {
// basic cards by api call (redirect to img link)
// example: https://api.scryfall.com/cards/xln/121/en?format=image
if (baseUrl == null) {
String cn = ScryfallImageSupportCards.prepareCardNumber(card.getCollectorId());
String cn = ScryfallApiCard.transformCardNumberFromXmageToScryfall(card.getCollectorId());
baseUrl = String.format("https://api.scryfall.com/cards/%s/%s/%s?format=image",
formatSetName(card.getSet(), isToken),
cn,
@ -132,12 +161,12 @@ public class ScryfallImageSource implements CardImageSource {
// include_variations=true added to deal with the cards that scryfall has marked as variations that seem to sometimes fail
// eg https://api.scryfall.com/cards/4ed/134?format=image fails
// eg https://api.scryfall.com/cards/4ed/134?format=image&include_variations=true succeeds
}
return new CardImageUrls(baseUrl, alternativeUrl);
}
// TODO: delete face code after bulk data implemented?
private String getFaceImageUrl(CardDownloadData card, boolean isToken) throws Exception {
final String defaultCode = CardLanguage.ENGLISH.getCode();
final String localizedCode = languageAliases.getOrDefault(this.getCurrentLanguage(), defaultCode);
@ -147,14 +176,8 @@ public class ScryfallImageSource implements CardImageSource {
String apiUrl = ScryfallImageSupportCards.findDirectDownloadLink(card.getSet(), card.getName(), card.getCollectorId());
if (apiUrl != null) {
if (apiUrl.endsWith("*/")) {
apiUrl = apiUrl.substring(0, apiUrl.length() - 2) + "★/";
} else if (apiUrl.endsWith("+/")) {
apiUrl = apiUrl.substring(0, apiUrl.length() - 2) + "†/";
} else if (apiUrl.endsWith("Ph/")) {
apiUrl = apiUrl.substring(0, apiUrl.length() - 3) + "Φ/";
}
// BY DIRECT URL
// direct url already contains scryfall compatible card numbers (with unicode chars)
// direct links via hardcoded API path. Used for cards with non-ASCII collector numbers
if (localizedCode.equals(defaultCode)) {
// english only, so can use workaround without loc param (scryfall download first available card)
@ -168,7 +191,7 @@ public class ScryfallImageSource implements CardImageSource {
} else {
// BY CARD NUMBER
// localized and default
String cn = ScryfallImageSupportCards.prepareCardNumber(card.getCollectorId());
String cn = ScryfallApiCard.transformCardNumberFromXmageToScryfall(card.getCollectorId());
needUrls.add(String.format("https://api.scryfall.com/cards/%s/%s/%s",
formatSetName(card.getSet(), isToken),
cn,
@ -221,9 +244,23 @@ public class ScryfallImageSource implements CardImageSource {
@Override
public boolean prepareDownloadList(DownloadServiceInfo downloadServiceInfo, List<CardDownloadData> downloadList) {
// prepare download list example (
preparedUrls.clear();
// direct images without api calls
if (!prepareBulkData(downloadServiceInfo, downloadList)) {
return false;
}
analyseBulkData(downloadServiceInfo, downloadList);
// second side images need lookup for
if (!prepareSecondSideImages(downloadServiceInfo, downloadList)) {
return false;
}
return true;
}
private boolean prepareSecondSideImages(DownloadServiceInfo downloadServiceInfo, List<CardDownloadData> downloadList) {
// prepare stats
int needPrepareCount = 0;
int currentPrepareCount = 0;
@ -235,11 +272,16 @@ public class ScryfallImageSource implements CardImageSource {
updatePrepareStats(downloadServiceInfo, needPrepareCount, currentPrepareCount);
for (CardDownloadData card : downloadList) {
// need cancel
// fast cancel
if (downloadServiceInfo.isNeedCancel()) {
return false;
}
// already prepare, e.g. on bulk data
if (preparedUrls.containsKey(card)) {
continue;
}
// prepare the back face URL
if (card.isSecondSide()) {
currentPrepareCount++;
@ -259,10 +301,410 @@ public class ScryfallImageSource implements CardImageSource {
return true;
}
private boolean prepareBulkData(DownloadServiceInfo downloadServiceInfo, List<CardDownloadData> downloadList) {
boolean isEnabled = SCRYFALL_BULK_FILES_ENABLED;
if (System.getProperty(SCRYFALL_BULK_FILES_PROPERTY) != null) {
isEnabled = Boolean.parseBoolean(System.getProperty(SCRYFALL_BULK_FILES_PROPERTY));
}
if (!isEnabled) {
return true;
}
// if up to date
if (isBulkDataPrepared()) {
return true;
}
// NEED TO DOWNLOAD
// clean
TFile bulkTempFile = prepareTempFileForBulkData();
if (bulkTempFile.exists()) {
try {
if (!SCRYFALL_BULK_FILES_DEBUG_READ_ONLY_MODE) {
bulkTempFile.rm();
TVFS.umount();
}
} catch (IOException e) {
return false;
}
}
// find actual download link
String s = XmageURLConnection.downloadText(SCRYFALL_BULK_FILES_DATABASE_SOURCE_API);
if (s.isEmpty()) {
logger.error("Can't get bulk info from scryfall api " + SCRYFALL_BULK_FILES_DATABASE_SOURCE_API);
return false;
}
ScryfallApiBulkData bulkData = new Gson().fromJson(s, ScryfallApiBulkData.class);
if (bulkData == null
|| bulkData.download_uri == null
|| bulkData.download_uri.isEmpty()
|| bulkData.size == 0) {
logger.error("Unknown bulk info format from scryfall api " + SCRYFALL_BULK_FILES_DATABASE_SOURCE_API);
return false;
}
// download
if (!SCRYFALL_BULK_FILES_DEBUG_READ_ONLY_MODE) {
logger.info("Scryfall: downloading additional data files, size " + bulkData.size / (1024 * 1024) + " MB");
String url = bulkData.download_uri;
InputStream inputStream = XmageURLConnection.downloadBinary(url, true);
OutputStream outputStream = null;
try {
try {
if (inputStream == null) {
logger.error("Can't get bulk data from scryfall api " + url);
return false;
}
outputStream = new TFileOutputStream(bulkTempFile);
byte[] buf = new byte[5 * 1024 * 1024]; // 5 MB buffer
int len;
long needDownload = bulkData.size / 7; // text -> zip size multiplier
long doneDownload = 0;
while ((len = inputStream.read(buf)) != -1) {
// fast cancel
if (downloadServiceInfo.isNeedCancel()) {
// stop download and delete current file
inputStream.close();
outputStream.close();
if (bulkTempFile.exists()) {
bulkTempFile.rm();
}
return false;
}
// all fine, can save data part
outputStream.write(buf, 0, len);
doneDownload += len;
updateBulkDownloadStats(downloadServiceInfo, doneDownload, needDownload);
}
outputStream.flush();
// unpack
logger.info("Scryfall: unpacking files...");
Path zippedBulkPath = Paths.get(getBulkTempFileName());
Path textBulkPath = Paths.get(getBulkStaticFileName());
Files.deleteIfExists(textBulkPath);
try (GZIPInputStream in = new GZIPInputStream(Files.newInputStream(zippedBulkPath));
FileOutputStream out = new FileOutputStream(textBulkPath.toString())) {
byte[] buffer = new byte[5 * 1024 * 1024];
long needUnpack = bulkData.size; // zip -> text
long doneUnpack = 0;
while ((len = in.read(buffer)) > 0) {
// fast cancel
if (downloadServiceInfo.isNeedCancel()) {
out.flush();
Files.deleteIfExists(textBulkPath);
return false;
}
out.write(buffer, 0, len);
doneUnpack += len;
updateBulkUnpackingStats(downloadServiceInfo, doneUnpack, needUnpack);
}
}
logger.info("Scryfall: unpacking done");
// all fine
} finally {
if (inputStream != null) {
inputStream.close();
}
if (outputStream != null) {
outputStream.close();
}
}
} catch (Exception e) {
logger.error("Catch unknown error while download bulk data from scryfall api " + url, e);
return false;
}
}
return isBulkDataPrepared();
}
private boolean isBulkDataPrepared() {
// already loaded
if (bulkCardsDatabaseAll.size() > 0) {
return true;
}
// file not exists
Path textBulkPath = Paths.get(getBulkStaticFileName());
if (!Files.exists(textBulkPath)) {
return false;
}
// file outdated
try {
if (System.currentTimeMillis() - Files.getLastModifiedTime(textBulkPath).toMillis() > SCRYFALL_BULK_FILES_OUTDATE_IN_MILLIS) {
logger.info("Scryfall: bulk files outdated, need to download new");
return false;
}
} catch (IOException ignore) {
}
// bulk files are too big, so must read and parse it in stream mode only
// 500k reprints in diff languages
Gson gson = new Gson();
try (TFileInputStream inputStream = new TFileInputStream(textBulkPath.toFile());
InputStreamReader inputReader = new InputStreamReader(inputStream);
JsonReader jsonReader = new JsonReader(inputReader)) {
bulkCardsDatabaseAll.clear();
bulkCardsDatabaseDefault.clear();
jsonReader.beginArray();
while (jsonReader.hasNext()) {
ScryfallApiCard card = gson.fromJson(jsonReader, ScryfallApiCard.class);
// prepare data
// memory optimization: fewer data, from 1145 MB to 470 MB
card.prepareCompatibleData();
// keep only usefully languages
// memory optimization: fewer items, from 470 MB to 96 MB
// make sure final list will have one version
// example: japan only card must be in final list for any selected languages https://scryfall.com/card/war/180/
String key = String.format("%s/%s/%s", card.name, card.set, card.collector_number);
if (!card.lang.equals(this.currentLanguage.getCode())
&& !card.lang.equals(CardLanguage.ENGLISH.getCode())
&& bulkCardsDatabaseDefault.containsKey(key)) {
continue;
}
// default versions list depends on scryfall bulk data order (en first)
bulkCardsDatabaseDefault.put(key, card);
bulkCardsDatabaseAll.add(card);
}
jsonReader.close();
return bulkCardsDatabaseAll.size() > 0;
} catch (Exception e) {
logger.error("Can't read bulk file (possible reason: broken format)");
try {
// clean up
if (!SCRYFALL_BULK_FILES_DEBUG_READ_ONLY_MODE) {
Files.deleteIfExists(textBulkPath);
}
} catch (IOException ignore) {
}
}
return false;
}
private void analyseBulkData(DownloadServiceInfo downloadServiceInfo, List<CardDownloadData> downloadList) {
// prepare card indexes
// name/set/number/lang - normal cards
Map<String, String> bulkImagesIndexAll = createBulkImagesIndex(downloadServiceInfo, bulkCardsDatabaseAll, true);
// name/set/number - default cards
Map<String, String> bulkImagesIndexDefault = createBulkImagesIndex(downloadServiceInfo, bulkCardsDatabaseDefault.values(), false);
// find good images
AtomicInteger statsOldPrepared = new AtomicInteger();
AtomicInteger statsDirectLinks = new AtomicInteger();
AtomicInteger statsNewPrepared = new AtomicInteger();
AtomicInteger statsNotFound = new AtomicInteger();
for (CardDownloadData card : downloadList) {
// fast cancel
if (downloadServiceInfo.isNeedCancel()) {
return;
}
// already prepared, e.g. from direct download links or other sources
if (preparedUrls.containsKey(card)) {
statsOldPrepared.incrementAndGet();
continue;
}
// only cards supported
if (card.isToken()) {
continue;
}
// ignore direct links
String directLink = ScryfallImageSupportCards.findDirectDownloadLink(card.getSet(), card.getName(), card.getCollectorId());
if (directLink != null) {
statsDirectLinks.incrementAndGet();
continue;
}
String searchingName = card.getName();
String searchingSet = card.getSet().toLowerCase(Locale.ENGLISH);
String searchingNumber = card.getCollectorId();
// current language
String key = String.format("%s/%s/%s/%s",
searchingName,
searchingSet,
searchingNumber,
this.currentLanguage
);
String link = bulkImagesIndexAll.getOrDefault(key, "");
// default en language
if (link.isEmpty()) {
key = String.format("%s/%s/%s/%s",
searchingName,
searchingSet,
searchingNumber,
CardLanguage.ENGLISH.getCode()
);
link = bulkImagesIndexAll.getOrDefault(key, "");
}
// default non en-language
if (link.isEmpty()) {
key = String.format("%s/%s/%s/",
searchingName,
searchingSet,
searchingNumber);
link = bulkImagesIndexDefault.getOrDefault(key, "");
}
if (link.isEmpty()) {
// how-to fix:
// - make sure name notation compatible, see workarounds in ScryfallApiCard.prepareCompatibleData
// - makue sure it's not fake card (xmage can use fake cards instead doubled-images) -- add it to ScryfallImageSupportCards.directDownloadLinks
logger.info("Scryfall: bulk image not found (outdated cards data in xmage?): " + key + " ");
statsNotFound.incrementAndGet();
} else {
preparedUrls.put(card, link);
statsNewPrepared.incrementAndGet();
}
}
logger.info(String.format("Scryfall: bulk optimization result - need cards: %d, already prepared: %d, direct links: %d, new prepared: %d, NOT FOUND: %d",
downloadList.size(),
statsOldPrepared.get(),
statsDirectLinks.get(),
statsNewPrepared.get(),
statsNotFound.get() // all not founded cards must be fixed
));
}
private Map<String, String> createBulkImagesIndex(DownloadServiceInfo downloadServiceInfo, Collection<ScryfallApiCard> sourceList, boolean useLocalization) {
Map<String, String> res = new HashMap<>();
for (ScryfallApiCard card : sourceList) {
// fast cancel
if (downloadServiceInfo.isNeedCancel()) {
return res;
}
// main card
String image = card.findImage(getImageQuality());
if (!image.isEmpty()) {
String mainKey = String.format("%s/%s/%s/%s",
card.name,
card.set,
card.collector_number,
useLocalization ? card.lang : ""
);
if (res.containsKey(mainKey)) {
// how-to fix: rework whole card number logic in xmage and scryfall
throw new IllegalArgumentException("Wrong code usage: scryfall used unique card numbers, but found duplicated: " + mainKey);
}
res.put(mainKey, image);
}
// faces
if (card.card_faces != null) {
// hints:
// 1. Not all faces has images - example: adventure cards
// 2. Some faces contain fake data in second face, search it on scryfall by "set_type:memorabilia"
// example: https://scryfall.com/card/aznr/25/clearwater-pathway-clearwater-pathway
// 3. Some faces contain diff images of the same card, layout = reversible_card
// example: https://scryfall.com/card/rex/26/command-tower-command-tower
Set<String> usedNames = new HashSet<>();
card.card_faces.forEach(face -> {
// workaround to ignore fake data, see above about memorabilia
if (usedNames.contains(face.name)) {
return;
}
usedNames.add(face.name);
String faceImage = face.findImage(getImageQuality());
if (!faceImage.isEmpty()) {
String faceKey = String.format("%s/%s/%s/%s",
face.name,
card.set,
card.collector_number,
useLocalization ? card.lang : ""
);
// workaround for flip cards - ignore first face - it already in the index
// example: https://scryfall.com/card/sok/103/homura-human-ascendant-homuras-essence
if (card.layout.equals("flip")
&& card.name.equals(face.name)
&& res.containsKey(faceKey)) {
return;
}
if (res.containsKey(faceKey)) {
// how-to fix: rework whole card number logic in xmage and scryfall
throw new IllegalArgumentException("Wrong code usage: scryfall used unique card numbers, but found duplicated: " + faceKey);
}
res.put(faceKey, faceImage);
}
});
}
}
return res;
}
private String getBulkTempFileName() {
return getImagesDir() + File.separator + "downloading" + File.separator + "scryfall_bulk_cards.json.gz";
}
private String getBulkStaticFileName() {
return getImagesDir() + File.separator + "downloading" + File.separator + "scryfall_bulk_cards.json";
}
private TFile prepareTempFileForBulkData() {
TFile file = new TFile(getBulkTempFileName());
TFile parent = file.getParentFile();
if (parent != null) {
if (!parent.exists()) {
parent.mkdirs();
}
}
return file;
}
private void updateBulkDownloadStats(DownloadServiceInfo service, long done, long need) {
int doneMb = (int) (done / (1024 * 1024));
int needMb = (int) (need / (1024 * 1024));
synchronized (service.getSync()) {
service.updateProgressMessage(
String.format("Step 1 of 3. Downloading additional data... %d of %d MB", doneMb, needMb),
doneMb,
needMb
);
}
}
private void updateBulkUnpackingStats(DownloadServiceInfo service, long done, long need) {
int doneMb = (int) (done / (1024 * 1024));
int needMb = (int) (need / (1024 * 1024));
synchronized (service.getSync()) {
service.updateProgressMessage(
String.format("Step 2 of 3. Unpacking additional files... %d of %d MB", doneMb, needMb),
doneMb,
needMb
);
}
}
private void updatePrepareStats(DownloadServiceInfo service, int need, int current) {
synchronized (service.getSync()) {
service.updateProgressMessage(
String.format("Preparing download list... %d of %d", current, need),
String.format("Step 3 of 3. Preparing download list... %d of %d", current, need),
current,
need
);
@ -334,7 +776,12 @@ public class ScryfallImageSource implements CardImageSource {
public void doPause(String fullUrl) {
// scryfall recommends 300 ms timeout per each request to API to work under a rate limit
// possible error: 429 Too Many Requests
// TODO: add diff endpoint supports (api calls with timeout, cdn/direct calls without timeout)
// cdn source can be safe and called without pause
if (fullUrl.contains("scryfall.io")) {
return;
}
waitBeforeRequest();
}
@ -387,4 +834,18 @@ public class ScryfallImageSource implements CardImageSource {
// only direct tokens from set
return ScryfallImageSupportTokens.findTokenLink(setCode, cardName, tokenNumber) != null;
}
/**
* Image quality
*/
public String getImageQuality() {
return "large";
}
@Override
public void onFinished() {
// cleanup resources
bulkCardsDatabaseAll.clear();
bulkCardsDatabaseDefault.clear();
}
}

View file

@ -48,4 +48,8 @@ public class ScryfallImageSourceNormal extends ScryfallImageSource {
return 92f;
}
@Override
public String getImageQuality() {
return "normal";
}
}

View file

@ -48,4 +48,8 @@ public class ScryfallImageSourceSmall extends ScryfallImageSource {
return 14f;
}
@Override
public String getImageQuality() {
return "small";
}
}

View file

@ -681,19 +681,6 @@ public class ScryfallImageSupportCards {
return null;
}
public static String prepareCardNumber(String xmageCardNumber) {
if (xmageCardNumber.endsWith("*")) {
return xmageCardNumber.substring(0, xmageCardNumber.length() - 1) + "";
}
if (xmageCardNumber.endsWith("+")) {
return xmageCardNumber.substring(0, xmageCardNumber.length() - 1) + "";
}
if (xmageCardNumber.endsWith("Ph")) {
return xmageCardNumber.substring(0, xmageCardNumber.length() - 2) + "Φ";
}
return xmageCardNumber;
}
public static String findDirectDownloadLink(String setCode, String cardName, String cardNumber) {
String key = findDirectDownloadKey(setCode, cardName, cardNumber);
return directDownloadLinks.get(key);

View file

@ -66,8 +66,8 @@ public class DownloadPicturesService extends DefaultBoundedRangeModel implements
// protect from wrong data save
// there are possible land images with small sizes, so must research content in check
private static final int MIN_FILE_SIZE_OF_GOOD_IMAGE = 1024 * 6; // broken
private static final int MIN_FILE_SIZE_OF_POSSIBLE_BAD_IMAGE = 1024 * 15; // possible broken (need content check)
private static final int MIN_FILE_SIZE_OF_GOOD_IMAGE = 1024 * 6; // smaller files will be mark as broken
private static final int MIN_FILE_SIZE_OF_POSSIBLE_BAD_IMAGE = 1024 * 8; // smaller files will be checked for possible broken mark (slow)
private final DownloadImagesDialog uiDialog;
private boolean needCancel;
@ -77,8 +77,6 @@ public class DownloadPicturesService extends DefaultBoundedRangeModel implements
private List<CardInfo> cardsAll;
private List<CardDownloadData> cardsMissing;
private final List<CardDownloadData> cardsDownloadQueue;
private int missingCardsCount = 0;
private int missingTokensCount = 0;
private final List<String> selectedSets = new ArrayList<>();
private static CardImageSource selectedSource;
@ -429,13 +427,13 @@ public class DownloadPicturesService extends DefaultBoundedRangeModel implements
}
private void updateProgressText(int cardCount, int tokenCount) {
missingTokensCount = 0;
int missingTokensCount = 0;
for (CardDownloadData card : cardsMissing) {
if (card.isToken()) {
missingTokensCount++;
}
}
missingCardsCount = cardsMissing.size() - missingTokensCount;
int missingCardsCount = cardsMissing.size() - missingTokensCount;
uiDialog.setCurrentInfo("Missing: " + missingCardsCount + " card images / " + missingTokensCount + " token images");
int imageSum = cardCount + tokenCount;
@ -446,7 +444,7 @@ public class DownloadPicturesService extends DefaultBoundedRangeModel implements
} else if (cardIndex == 0) {
statusEnd = "image download NOT STARTED. Please start.";
} else {
statusEnd = String.format("image downloading... Please wait [%.1f Mb left].", mb);
statusEnd = String.format("image downloading... Please wait [%.1f MB left].", mb);
}
updateProgressMessage(String.format("%d of %d (%d cards and %d tokens) %s",
0, imageSum, cardCount, tokenCount, statusEnd
@ -618,7 +616,8 @@ public class DownloadPicturesService extends DefaultBoundedRangeModel implements
}
});
if (badContentChecks.get() > 10000) {
if (badContentChecks.get() > 1000) {
// how-to fix: download full small images and run checks -- make sure it finds fewer files to check
logger.warn("Wrong code usage: too many file content checks (" + badContentChecks.get() + ") - try to decrease min file size");
}
@ -630,6 +629,7 @@ public class DownloadPicturesService extends DefaultBoundedRangeModel implements
this.cardIndex = 0;
this.resetErrorCount();
try {
File base = new File(getImagesDir());
if (!base.exists()) {
base.mkdir();
@ -637,7 +637,6 @@ public class DownloadPicturesService extends DefaultBoundedRangeModel implements
int downloadThreadsAmount = Math.max(1, Integer.parseInt((String) uiDialog.getDownloadThreadsCombo().getSelectedItem()));
logger.info("Started download of " + cardsDownloadQueue.size() + " images"
+ " from source: " + selectedSource.getSourceName()
+ ", language: " + selectedSource.getCurrentLanguage().getCode()
@ -699,16 +698,23 @@ public class DownloadPicturesService extends DefaultBoundedRangeModel implements
}
}
}
} catch (Throwable e) {
logger.error("Catch unknown error while downloading: " + e, e);
MageFrame.getInstance().showErrorDialog("Catch unknown error while downloading " + e, e);
} finally {
// must close all active archives anyway
try {
TVFS.umount();
} catch (FsSyncException e) {
logger.fatal("Couldn't unmount zip files", e);
JOptionPane.showMessageDialog(null, "Couldn't unmount zip files", "Error", JOptionPane.ERROR_MESSAGE);
} finally {
//
logger.fatal("Couldn't unmount zip files " + e, e);
MageFrame.getInstance().showErrorDialog("Couldn't unmount zip files " + e, e);
}
}
// cleanup resources
Arrays.stream(DownloadSources.values()).forEach(resource -> {
resource.source.onFinished();
});
// stop
reloadCardsToDownload(uiDialog.getSetsCombo().getSelectedItem().toString());
@ -882,16 +888,7 @@ public class DownloadPicturesService extends DefaultBoundedRangeModel implements
// user cancelled
if (DownloadPicturesService.getInstance().isNeedCancel()) {
// stop download, save current state and exit
TFile archive = destFile.getTopLevelArchive();
///* not need to unmout/close - it's auto action
if (archive != null && archive.exists()) {
logger.info("User canceled download. Closing archive file: " + destFile.toString());
try {
TVFS.umount(archive);
} catch (Exception e) {
logger.error("Can't close archive file: " + e.getMessage(), e);
}
}
// real archives save will be done on download service finish
try {
TFile.rm(fileTempImage);
} catch (Exception e) {
@ -946,11 +943,11 @@ public class DownloadPicturesService extends DefaultBoundedRangeModel implements
if (cardIndex < needDownloadCount) {
// downloading
float mb = ((needDownloadCount - lastCardIndex) * selectedSource.getAverageSizeKb()) / 1024;
updateProgressMessage(String.format("%d of %d image downloading... Please wait [%.1f Mb left].",
updateProgressMessage(String.format("%d of %d image downloading... Please wait [%.1f MB left].",
lastCardIndex, needDownloadCount, mb), lastCardIndex, needDownloadCount);
} else {
// finished
updateProgressMessage("Image download DONE, refreshing stats... Please wait.");
updateProgressMessage("Image download DONE, saving last files and refreshing stats... Please wait.");
List<CardDownloadData> downloadedCards = Collections.synchronizedList(new ArrayList<>());
DownloadPicturesService.this.cardsMissing.parallelStream().forEach(cardDownloadData -> {
TFile file = new TFile(CardImageUtils.buildImagePathToCardOrToken(cardDownloadData));

View file

@ -1,28 +1,16 @@
package org.mage.plugins.card.utils;
import mage.cards.repository.CardInfo;
import mage.cards.repository.CardRepository;
import mage.cards.repository.TokenRepository;
import mage.client.MageFrame;
import mage.client.constants.Constants;
import mage.client.dialog.PreferencesDialog;
import mage.client.remote.XmageURLConnection;
import mage.remote.Connection;
import mage.remote.Connection.ProxyType;
import mage.view.CardView;
import net.java.truevfs.access.TVFS;
import net.java.truevfs.kernel.spec.FsSyncException;
import org.apache.log4j.Logger;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.mage.plugins.card.images.CardDownloadData;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.URL;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
@ -31,7 +19,6 @@ import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Locale;
import java.util.prefs.Preferences;
public final class CardImageUtils {
@ -191,19 +178,16 @@ public final class CardImageUtils {
return getImagesDir() + File.separator + "FACE" + File.separator + fixSetNameForWindows(setCode) + File.separator + prepareCardNameForFile(cardName) + ".jpg";
}
public static Proxy getProxyFromPreferences() {
Preferences prefs = MageFrame.getPreferences();
Connection.ProxyType proxyType = Connection.ProxyType.valueByText(prefs.get("proxyType", "None"));
if (proxyType != ProxyType.NONE) {
String proxyServer = prefs.get("proxyAddress", "");
int proxyPort = Integer.parseInt(prefs.get("proxyPort", "0"));
return new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyServer, proxyPort));
}
return null;
}
public static void checkAndFixImageFiles() {
// search broken, temp or outdated files and delete it
// make sure all archives was closed (e.g. on second call of download dialog)
try {
TVFS.umount();
} catch (FsSyncException e) {
LOGGER.fatal("Couldn't unmount zip files on searching broken images " + e, e);
}
// real images check is slow, so it used on images download only (not here)
Path rootPath = new File(CardImageUtils.getImagesDir()).toPath();
if (!Files.exists(rootPath)) {

View file

@ -9,13 +9,14 @@ import net.xeoh.plugins.base.annotations.meta.Author;
import org.apache.log4j.Logger;
import java.io.*;
import java.nio.file.Files;
/**
* Implementation of {@link CounterPlugin}.<br/>
* Stores data in data folder.
*
* @version 0.1 14.11.2010 Initial Version
* @author nantuko
* @version 0.1 14.11.2010 Initial Version
*/
@PluginImplementation
@Author(name = "nantuko")
@ -69,7 +70,7 @@ public class CounterPluginImpl implements CounterPlugin {
if (data.exists()) {
int prev = 0;
try(ObjectInputStream ois = new ObjectInputStream(new FileInputStream(data))) {
try (ObjectInputStream ois = new ObjectInputStream(Files.newInputStream(data.toPath()))) {
Object o = ois.readObject();
CounterBean c;
if (o instanceof CounterBean) {
@ -107,7 +108,7 @@ public class CounterPluginImpl implements CounterPlugin {
return 0;
}
if (data.exists()) {
try(ObjectInputStream ois = new ObjectInputStream(new FileInputStream(data))) {
try (ObjectInputStream ois = new ObjectInputStream(Files.newInputStream(data.toPath()))) {
synchronized (this) {
Object o = ois.readObject();
CounterBean c = null;

View file

@ -1,25 +1,23 @@
package mage.server.game;
import java.io.BufferedInputStream;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInput;
import java.util.UUID;
import java.util.zip.GZIPInputStream;
import mage.game.Game;
import mage.game.GameState;
import mage.game.GameStates;
import mage.server.Main;
import mage.util.CopierObjectInputStream;
import mage.utils.StreamUtils;
import org.apache.log4j.Logger;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInput;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.UUID;
import java.util.zip.GZIPInputStream;
/**
* Replay system, outdated and not used. TODO: delete
*
* @author BetaSteward_at_googlemail.com
*/
@ -59,33 +57,19 @@ public class GameReplay {
}
private Game loadGame(UUID gameId) {
InputStream file = null;
InputStream buffer = null;
InputStream gzip = null;
ObjectInput input = null;
try{
file = new FileInputStream("saved/" + gameId.toString() + ".game");
buffer = new BufferedInputStream(file);
gzip = new GZIPInputStream(buffer);
input = new CopierObjectInputStream(Main.classLoader, gzip);
try (InputStream file = Files.newInputStream(Paths.get("saved/" + gameId.toString() + ".game"));
InputStream buffer = new BufferedInputStream(file);
InputStream gzip = new GZIPInputStream(buffer);
ObjectInput input = new CopierObjectInputStream(Main.classLoader, gzip)) {
Game loadGame = (Game) input.readObject();
GameStates states = (GameStates) input.readObject();
loadGame.loadGameStates(states);
return loadGame;
}
catch(ClassNotFoundException ex) {
logger.fatal("Cannot load game. Class not found.", ex);
}
catch(IOException ex) {
logger.fatal("Cannot load game:" + gameId, ex);
} finally {
StreamUtils.closeQuietly(file);
StreamUtils.closeQuietly(buffer);
StreamUtils.closeQuietly(input);
StreamUtils.closeQuietly(gzip);
} catch (ClassNotFoundException e) {
logger.fatal("Cannot load game. Class not found.", e);
} catch (IOException e) {
logger.fatal("Cannot load game:" + gameId, e);
}
return null;
}
}

View file

@ -756,7 +756,7 @@ public class SecretLairDrop extends ExpansionSet {
cards.add(new SetCardInfo("Elvish Mystic", 805, Rarity.RARE, mage.cards.e.ElvishMystic.class, NON_FULL_USE_VARIOUS));
cards.add(new SetCardInfo("Snapcaster Mage", 808, Rarity.MYTHIC, mage.cards.s.SnapcasterMage.class));
cards.add(new SetCardInfo("Thrun, the Last Troll", 810, Rarity.MYTHIC, mage.cards.t.ThrunTheLastTroll.class));
//cards.add(new SetCardInfo("Elesh Norn, Grand Cenobite", 811, Rarity.MYTHIC, mage.cards.e.EleshNornGrandCenobite.class, NON_FULL_USE_VARIOUS)); // BUG: breaks download of card 209
cards.add(new SetCardInfo("Elesh Norn, Grand Cenobite", 811, Rarity.MYTHIC, mage.cards.e.EleshNornGrandCenobite.class, NON_FULL_USE_VARIOUS)); // BUG: breaks download of card 209
cards.add(new SetCardInfo("Arcane Signet", 820, Rarity.RARE, mage.cards.a.ArcaneSignet.class, NON_FULL_USE_VARIOUS));
cards.add(new SetCardInfo("Norin the Wary", 827, Rarity.RARE, mage.cards.n.NorinTheWary.class));
cards.add(new SetCardInfo("The Scarab God", 900, Rarity.MYTHIC, mage.cards.t.TheScarabGod.class));

View file

@ -26,7 +26,7 @@ public class UserData implements Serializable {
protected int autoTargetLevel;
protected boolean useSameSettingsForReplacementEffects;
protected boolean useFirstManaAbility = false;
private String userIdStr;
private String userIdStr; // TODO: delete as un-used or use for hardware id instead?
protected Map<UUID, Set<UUID>> requestedHandPlayersList; // game -> players list
protected String matchHistory;