diff --git a/forge-scryfall-uuid-map/pom.xml b/forge-scryfall-uuid-map/pom.xml new file mode 100644 index 000000000000..3728303468a2 --- /dev/null +++ b/forge-scryfall-uuid-map/pom.xml @@ -0,0 +1,78 @@ + + 4.0.0 + + + forge + forge + ${revision} + + + forge-scryfall-uuid-map + jar + Forge Scryfall UUID Map Builder + + Standalone tool that parses a Scryfall bulk JSON file and writes one JSON file per + card print: {outputDir}/{setCode}/{collectorNumber}.json mapping language codes to + Scryfall UUIDs. Used to generate the res/cdn_uuid assets shipped with Forge. + + + + + + com.google.code.gson + gson + 2.11.0 + + + + + forge-scryfall-uuid-map-${revision} + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + forge.scryfall.uuidmap.Main + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + package + + shade + + + false + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + forge.scryfall.uuidmap.Main + + + + + + + + + + diff --git a/forge-scryfall-uuid-map/src/main/java/forge/scryfall/uuidmap/BulkDataFetcher.java b/forge-scryfall-uuid-map/src/main/java/forge/scryfall/uuidmap/BulkDataFetcher.java new file mode 100644 index 000000000000..30b2bc762b20 --- /dev/null +++ b/forge-scryfall-uuid-map/src/main/java/forge/scryfall/uuidmap/BulkDataFetcher.java @@ -0,0 +1,169 @@ +package forge.scryfall.uuidmap; + +import com.google.gson.stream.JsonReader; + +import java.io.BufferedOutputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.StringReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +/** + * Fetches the Scryfall bulk-data index and downloads the {@code all_cards} dataset. + * + *

The bulk-data index at {@code api.scryfall.com/bulk-data} is a small JSON file + * (~3 KB) listing available datasets and their CDN download URIs. Once we have the + * download URI, the actual data file is served from {@code data.scryfall.io} (CDN, + * no rate limit). + */ +public final class BulkDataFetcher { + + private static final String BULK_INDEX_URL = "https://api.scryfall.com/bulk-data"; + private static final int CONNECT_TIMEOUT = 10_000; + private static final int READ_TIMEOUT = 300_000; // 5 min for large downloads + + private BulkDataFetcher() {} + + /** + * Fetches the bulk-data index and returns the download URI for the + * {@code default_cards} dataset (one English entry per print, ~100 MB). + * Sufficient for patching edition files; prefer this over {@link #fetchAllCardsUri} + * for faster downloads. + */ + public static String fetchDefaultCardsUri() throws IOException { + System.err.println("Fetching Scryfall bulk-data index..."); + String json = fetchText(BULK_INDEX_URL); + String uri = parseDownloadUri(json, "default_cards"); + if (uri == null) { + throw new IOException("'default_cards' entry not found in Scryfall bulk-data index"); + } + System.err.println(" Found: " + uri); + return uri; + } + + /** + * Fetches the bulk-data index and returns the download URI for the + * {@code all_cards} dataset (every language, every art variant, ~2.5 GB). + */ + public static String fetchAllCardsUri() throws IOException { + System.err.println("Fetching Scryfall bulk-data index..."); + String json = fetchText(BULK_INDEX_URL); + String uri = parseDownloadUri(json, "all_cards"); + if (uri == null) { + throw new IOException("'all_cards' entry not found in Scryfall bulk-data index"); + } + System.err.println(" Found: " + uri); + return uri; + } + + /** + * Downloads {@code sourceUrl} to {@code dest}, printing progress every 50 MB. + */ + public static void downloadToFile(String sourceUrl, Path dest) throws IOException { + System.err.println("Downloading: " + sourceUrl); + System.err.println(" to: " + dest.toAbsolutePath()); + + URL url = new URL(sourceUrl); + HttpURLConnection conn = openConnection(url); + long total = conn.getContentLengthLong(); + long bytesRead = 0L; + long lastReport = 0L; + byte[] buf = new byte[65_536]; + + try (InputStream in = conn.getInputStream(); + OutputStream out = new BufferedOutputStream(new FileOutputStream(dest.toFile()))) { + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + bytesRead += n; + if (bytesRead - lastReport >= 50L << 20) { + lastReport = bytesRead; + String progress = total > 0 + ? String.format("%.0f%%", 100.0 * bytesRead / total) + : String.format("%.0f MB received", bytesRead / 1e6); + System.err.printf(" %.1f MB [%s]%n", bytesRead / 1e6, progress); + } + } + } finally { + conn.disconnect(); + } + System.err.printf(" Download complete: %.1f MB%n", bytesRead / 1e6); + } + + /** + * Parses the bulk-data index JSON and returns the {@code download_uri} for + * the entry whose {@code type} matches {@code targetType}. + */ + static String parseDownloadUri(String json, String targetType) throws IOException { + try (JsonReader reader = new JsonReader(new StringReader(json))) { + reader.beginObject(); + while (reader.hasNext()) { + if ("data".equals(reader.nextName())) { + reader.beginArray(); + while (reader.hasNext()) { + String uri = readEntry(reader, targetType); + if (uri != null) { + return uri; + } + } + reader.endArray(); + } else { + reader.skipValue(); + } + } + reader.endObject(); + } catch (IOException e) { + return null; + } + return null; + } + + private static String readEntry(JsonReader reader, String targetType) throws IOException { + String type = null; + String downloadUri = null; + reader.beginObject(); + while (reader.hasNext()) { + String name = reader.nextName(); + if ("type".equals(name)) { + type = reader.nextString(); + } else if ("download_uri".equals(name)) { + downloadUri = reader.nextString(); + } else { + reader.skipValue(); + } + } + reader.endObject(); + return targetType.equals(type) ? downloadUri : null; + } + + private static String fetchText(String urlStr) throws IOException { + URL url = new URL(urlStr); + HttpURLConnection conn = openConnection(url); + try { + byte[] data = conn.getInputStream().readAllBytes(); + return new String(data, StandardCharsets.UTF_8); + } finally { + conn.disconnect(); + } + } + + private static HttpURLConnection openConnection(URL url) throws IOException { + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestProperty("User-Agent", "forge-scryfall-uuid-map/1.0"); + conn.setConnectTimeout(CONNECT_TIMEOUT); + conn.setReadTimeout(READ_TIMEOUT); + conn.setInstanceFollowRedirects(true); + conn.connect(); + int code = conn.getResponseCode(); + if (code != HttpURLConnection.HTTP_OK) { + conn.disconnect(); + throw new IOException("HTTP " + code + " fetching " + url); + } + return conn; + } +} diff --git a/forge-scryfall-uuid-map/src/main/java/forge/scryfall/uuidmap/CardRecord.java b/forge-scryfall-uuid-map/src/main/java/forge/scryfall/uuidmap/CardRecord.java new file mode 100644 index 000000000000..58f2befc13ee --- /dev/null +++ b/forge-scryfall-uuid-map/src/main/java/forge/scryfall/uuidmap/CardRecord.java @@ -0,0 +1,9 @@ +package forge.scryfall.uuidmap; + +public record CardRecord( + String setCode, + String collectorNumber, + String lang, + String frontUuid, + String backUuid +) {} diff --git a/forge-scryfall-uuid-map/src/main/java/forge/scryfall/uuidmap/CardStreamParser.java b/forge-scryfall-uuid-map/src/main/java/forge/scryfall/uuidmap/CardStreamParser.java new file mode 100644 index 000000000000..611998f9a80f --- /dev/null +++ b/forge-scryfall-uuid-map/src/main/java/forge/scryfall/uuidmap/CardStreamParser.java @@ -0,0 +1,156 @@ +package forge.scryfall.uuidmap; + +import com.google.gson.stream.JsonReader; + +import java.io.BufferedReader; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.function.Consumer; + +/** + * Streams a Scryfall bulk JSON file (all_cards or default_cards) and emits + * a {@link CardRecord} for every card entry that carries image data. + * + *

Uses Gson's streaming {@link JsonReader} so the 2.5 GB file is never + * fully loaded into memory — only one object at a time is in heap. + */ +public final class CardStreamParser { + + private CardStreamParser() {} + + /** + * Parses {@code bulkFile} and calls {@code consumer} for every record with image data. + * + * @return number of records emitted to the consumer + */ + public static long parse(Path bulkFile, Consumer consumer) throws IOException { + long written = 0L; + long skipped = 0L; + try (JsonReader reader = new JsonReader(new BufferedReader( + new InputStreamReader(new FileInputStream(bulkFile.toFile()), StandardCharsets.UTF_8), + 1 << 20 /* 1 MB read buffer */))) { + reader.beginArray(); + while (reader.hasNext()) { + CardRecord record = readCard(reader); + if (record != null) { + consumer.accept(record); + written++; + } else { + skipped++; + } + long total = written + skipped; + if (total % 50_000 == 0) { + System.err.printf(" %,d processed (%,d written, %,d skipped)%n", + total, written, skipped); + } + } + reader.endArray(); + } + System.err.printf(" Done: %,d written, %,d skipped (no image)%n", written, skipped); + return written; + } + + private static CardRecord readCard(JsonReader reader) throws IOException { + String id = null; + String set = null; + String cn = null; + String lang = null; + String frontUrl = null; + String backUrl = null; + + reader.beginObject(); + while (reader.hasNext()) { + String field = reader.nextName(); + switch (field) { + case "id": id = reader.nextString(); break; + case "set": set = reader.nextString(); break; + case "collector_number": cn = reader.nextString(); break; + case "lang": lang = reader.nextString(); break; + case "image_uris": + frontUrl = readNormalUrl(reader); + break; + case "card_faces": { + String[] urls = readFaceUrls(reader); + frontUrl = urls[0]; + backUrl = urls[1]; + break; + } + default: reader.skipValue(); break; + } + } + reader.endObject(); + + if (id == null || set == null || cn == null || lang == null || frontUrl == null) { + return null; + } + + return new CardRecord( + set, cn, lang, + uuidFromUrl(frontUrl, id), + backUrl != null ? uuidFromUrl(backUrl, id) : null); + } + + /** Reads an {@code image_uris} object and returns the value of the {@code normal} key. */ + private static String readNormalUrl(JsonReader reader) throws IOException { + String normal = null; + reader.beginObject(); + while (reader.hasNext()) { + if ("normal".equals(reader.nextName())) { + normal = reader.nextString(); + } else { + reader.skipValue(); + } + } + reader.endObject(); + return normal; + } + + /** Reads a {@code card_faces} array and returns {@code [front_normal_url, back_normal_url]}. */ + private static String[] readFaceUrls(JsonReader reader) throws IOException { + String[] urls = new String[2]; + int idx = 0; + reader.beginArray(); + while (reader.hasNext()) { + reader.beginObject(); + while (reader.hasNext()) { + String field = reader.nextName(); + if ("image_uris".equals(field) && idx < 2) { + urls[idx] = readNormalUrl(reader); + } else { + reader.skipValue(); + } + } + reader.endObject(); + idx++; + } + reader.endArray(); + return urls; + } + + /** + * Extracts the UUID segment from a Scryfall CDN image URL. + * + *

URL format: {@code https://cards.scryfall.io/normal/front/4/e/{uuid}.jpg?timestamp} + * + *

Parsing the UUID from the URL (rather than using the card's {@code id} field directly) + * correctly handles the two Secret Lair DFC cards where both faces share an artwork UUID + * that differs from the card's own {@code id}. + * + *

Falls back to {@code cardId} for non-CDN URLs such as + * {@code errors.scryfall.com/soon.jpg} (placeholder for missing images). + */ + static String uuidFromUrl(String url, String cardId) { + if (url == null || !url.contains("cards.scryfall.io")) { + return cardId; + } + int qmark = url.indexOf('?'); + String path = qmark >= 0 ? url.substring(0, qmark) : url; + int slash = path.lastIndexOf('/'); + String filename = slash >= 0 ? path.substring(slash + 1) : path; + int dot = filename.lastIndexOf('.'); + return dot >= 0 ? filename.substring(0, dot) : filename; + } +} diff --git a/forge-scryfall-uuid-map/src/main/java/forge/scryfall/uuidmap/CdnUuidJsonWriter.java b/forge-scryfall-uuid-map/src/main/java/forge/scryfall/uuidmap/CdnUuidJsonWriter.java new file mode 100644 index 000000000000..530a1d0a9744 --- /dev/null +++ b/forge-scryfall-uuid-map/src/main/java/forge/scryfall/uuidmap/CdnUuidJsonWriter.java @@ -0,0 +1,137 @@ +package forge.scryfall.uuidmap; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.Map; +import java.util.TreeMap; + +/** + * Reads a Scryfall bulk JSON export and writes one UUID JSON file per set to + * {@code outputDir/{setCode}.json}. + * + *

Each file maps collector number to a per-language UUID map: + *

+ *   {
+ *     "1":   {"en": "uuid"}
+ *     "2":   {"en": "uuid", "ja": "ja-uuid"}
+ *     "A-40":{"en": ["front-uuid", "back-uuid"]}
+ *   }
+ * 
+ * + *

This set-per-file layout lets the runtime fetch exactly one file per set on + * demand and cache it locally, rather than shipping ~115k individual files with + * the game distribution. + * + *

Collector-number and language keys are written in sorted order rather than + * bulk-file insertion order, and a file is only touched on disk if its content + * actually changed. The Scryfall bulk export doesn't guarantee stable card + * ordering between snapshots, so without this, re-running against a newer + * snapshot would rewrite nearly every set file with the same data in a + * different key order — a huge, meaningless diff every time this tool runs. + * With it, a re-run against unchanged sets touches nothing. + */ +public final class CdnUuidJsonWriter { + + private static final Comparator COLLECTOR_NUMBER_ORDER = CdnUuidJsonWriter::compareCollectorNumbers; + + private CdnUuidJsonWriter() {} + + /** + * Parses {@code bulkFile} and writes per-set UUID JSON files under {@code outputDir}. + * Existing files whose content is unchanged are left untouched. + * + * @return number of set files created or updated (excludes unchanged files) + */ + public static long write(Path bulkFile, Path outputDir) throws IOException { + System.err.println("Parsing UUIDs from " + bulkFile.toAbsolutePath()); + + // setCode -> cn -> lang -> [frontUuid, backUuidOrNull] + // TreeMaps at every level so serialized key order is deterministic + // regardless of card order in the bulk export. + Map>> bySet = new TreeMap<>(); + + CardStreamParser.parse(bulkFile, record -> { + String setCode = record.setCode().toLowerCase(); + bySet.computeIfAbsent(setCode, k -> new TreeMap<>(COLLECTOR_NUMBER_ORDER)) + .computeIfAbsent(record.collectorNumber(), k -> new TreeMap<>()) + .put(record.lang(), new String[]{record.frontUuid(), record.backUuid()}); + }); + + System.err.printf(" Collected %,d unique sets.%n", bySet.size()); + + Files.createDirectories(outputDir); + long created = 0, updated = 0, unchanged = 0; + for (Map.Entry>> setEntry : bySet.entrySet()) { + Path out = outputDir.resolve(setEntry.getKey() + ".json"); + String json = buildSetJson(setEntry.getValue()); + boolean existed = Files.exists(out); + if (existed && json.equals(Files.readString(out, StandardCharsets.UTF_8))) { + unchanged++; + continue; + } + Files.writeString(out, json, StandardCharsets.UTF_8); + if (existed) updated++; else created++; + } + + System.err.printf("Done: %,d new, %,d updated, %,d unchanged set files under %s%n", + created, updated, unchanged, outputDir.toAbsolutePath()); + return created + updated; + } + + // ------------------------------------------------------------------------- + + /** Natural-ish order: numeric prefix compared as a number, then the remainder as text. */ + private static int compareCollectorNumbers(String a, String b) { + int ai = 0, bi = 0; + while (ai < a.length() && Character.isDigit(a.charAt(ai))) ai++; + while (bi < b.length() && Character.isDigit(b.charAt(bi))) bi++; + if (ai > 0 && bi > 0) { + int cmp = Long.compare(Long.parseLong(a.substring(0, ai)), Long.parseLong(b.substring(0, bi))); + if (cmp != 0) return cmp; + } + return a.compareTo(b); + } + + /** Builds {@code {cn: {lang: uuid|[front,back]}, ...}} followed by a trailing newline. */ + private static String buildSetJson(Map> cards) { + StringBuilder sb = new StringBuilder(cards.size() * 80); + sb.append('{'); + boolean firstCn = true; + for (Map.Entry> cnEntry : cards.entrySet()) { + if (!firstCn) sb.append(','); + firstCn = false; + appendQuoted(sb, cnEntry.getKey()); + sb.append(":{"); + boolean firstLang = true; + for (Map.Entry langEntry : cnEntry.getValue().entrySet()) { + if (!firstLang) sb.append(','); + firstLang = false; + String lang = langEntry.getKey(); + String front = langEntry.getValue()[0]; + String back = langEntry.getValue()[1]; + appendQuoted(sb, lang); + sb.append(':'); + if (back != null && !back.equals(front)) { + sb.append('['); + appendQuoted(sb, front); + sb.append(','); + appendQuoted(sb, back); + sb.append(']'); + } else { + appendQuoted(sb, front); + } + } + sb.append('}'); + } + sb.append('}'); + sb.append('\n'); + return sb.toString(); + } + + private static void appendQuoted(StringBuilder sb, String s) { + sb.append('"').append(s).append('"'); + } +} diff --git a/forge-scryfall-uuid-map/src/main/java/forge/scryfall/uuidmap/Main.java b/forge-scryfall-uuid-map/src/main/java/forge/scryfall/uuidmap/Main.java new file mode 100644 index 000000000000..04ed0687abda --- /dev/null +++ b/forge-scryfall-uuid-map/src/main/java/forge/scryfall/uuidmap/Main.java @@ -0,0 +1,136 @@ +package forge.scryfall.uuidmap; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; + +/** + * Entry point for the Scryfall CDN UUID map generator. + * + *

Reads a Scryfall bulk JSON file and writes one JSON file per set: + *

+ *   {outputDir}/{setCode}.json  →  {"cn":{"en":"uuid","ja":"uuid",...}, ...}
+ * 
+ * + *

Uses the {@code all_cards} Scryfall dataset by default (every language, every + * art variant, ~2.5 GB) so that per-language UUID entries are fully populated. + * Pass {@code --default-cards} to instead fetch {@code default_cards} (~100 MB, + * one entry per card in English or nearest language only). + * + *

CDN URL formula (for reference): + * {@code https://cards.scryfall.io/{size}/{front|back}/{uuid[0]}/{uuid[1]}/{uuid}.jpg} + * + *

Usage: + *

+ *   # Download all_cards from Scryfall and write to cdn_uuid (full multi-language)
+ *   java -jar forge-scryfall-uuid-map.jar --output-dir path/to/res/cdn_uuid
+ *
+ *   # Provide a pre-downloaded bulk file
+ *   java -jar forge-scryfall-uuid-map.jar --bulk-file all-cards-20260608.json \
+ *                                          --output-dir path/to/res/cdn_uuid
+ *
+ *   # English-only, smaller download (~100 MB)
+ *   java -jar forge-scryfall-uuid-map.jar --output-dir path/to/res/cdn_uuid --default-cards
+ * 
+ */ +public final class Main { + + public static void main(String[] args) throws Exception { + Path bulkFile = null; + Path outputDir = null; + boolean defaultCards = false; + + for (int i = 0; i < args.length; i++) { + switch (args[i]) { + case "--bulk-file": bulkFile = Path.of(args[++i]); break; + case "--output-dir": outputDir = Path.of(args[++i]); break; + case "--default-cards": defaultCards = true; break; + case "--help": printUsage(); return; + default: + System.err.println("Unknown argument: " + args[i]); + printUsage(); + System.exit(1); + } + } + + if (outputDir == null) { + System.err.println("Error: --output-dir is required."); + printUsage(); + System.exit(1); + } + Files.createDirectories(outputDir); + + if (bulkFile == null) { + bulkFile = findLocalBulkFile(); + if (bulkFile != null) { + System.err.println("Auto-detected: " + bulkFile); + } + } + + if (bulkFile == null || !Files.exists(bulkFile)) { + if (defaultCards) { + System.err.println("Fetching 'default_cards' from Scryfall (~100 MB, English only)."); + String uri = BulkDataFetcher.fetchDefaultCardsUri(); + bulkFile = Path.of(uriFilename(uri)); + BulkDataFetcher.downloadToFile(uri, bulkFile); + } else { + System.err.println("Fetching 'all_cards' from Scryfall (~2.5 GB, all languages)."); + System.err.println("Use --default-cards for a smaller English-only download."); + String uri = BulkDataFetcher.fetchAllCardsUri(); + bulkFile = Path.of(uriFilename(uri)); + BulkDataFetcher.downloadToFile(uri, bulkFile); + } + } + + System.err.println("Input: " + bulkFile.toAbsolutePath()); + System.err.println("Output dir: " + outputDir.toAbsolutePath()); + + long startMs = System.currentTimeMillis(); + long written = CdnUuidJsonWriter.write(bulkFile, outputDir); + long elapsedMs = System.currentTimeMillis() - startMs; + + System.err.printf("%nDone: %,d card entries written in %.1f s%n", written, elapsedMs / 1000.0); + } + + private static String uriFilename(String uri) { + String path = uri.contains("?") ? uri.substring(0, uri.indexOf('?')) : uri; + return path.substring(path.lastIndexOf('/') + 1); + } + + private static Path findLocalBulkFile() { + try (Stream entries = Files.list(Path.of("."))) { + return entries + .filter(p -> { + String name = p.getFileName().toString(); + return (name.startsWith("all-cards-") || name.startsWith("default-cards-")) + && name.endsWith(".json"); + }) + .findFirst() + .orElse(null); + } catch (IOException e) { + return null; + } + } + + private static void printUsage() { + System.err.println("Usage: java -jar forge-scryfall-uuid-map.jar --output-dir DIR [options]"); + System.err.println(); + System.err.println("Options:"); + System.err.println(" --output-dir DIR Output directory for JSON files (required)"); + System.err.println(" e.g. forge-gui/res/cdn_uuid"); + System.err.println(" --bulk-file FILE Pre-downloaded Scryfall all_cards or default_cards JSON."); + System.err.println(" Auto-detected if a matching file exists locally."); + System.err.println(" If absent, all_cards is downloaded from Scryfall."); + System.err.println(" --default-cards Download default_cards (~100 MB) instead of all_cards"); + System.err.println(" (~2.5 GB). Produces English-only output."); + System.err.println(); + System.err.println("Output: {outputDir}/{setCode}/{collectorNumber}.json"); + System.err.println(" Each file maps language code -> UUID string, or [front, back] for DFCs"); + System.err.println(" with distinct face UUIDs (rare). Example:"); + System.err.println(" {\"en\":\"4e7a547f-...\",\"ja\":\"9b2c1234-...\"}"); + System.err.println(); + System.err.println("CDN URL formula:"); + System.err.println(" https://cards.scryfall.io/{size}/{front|back}/{uuid[0]}/{uuid[1]}/{uuid}.jpg"); + } +} diff --git a/pom.xml b/pom.xml index 64fde6e6a127..f7d4424d695c 100644 --- a/pom.xml +++ b/pom.xml @@ -73,6 +73,7 @@ adventure-editor forge-gui-android forge-installer + forge-scryfall-uuid-map