diff --git a/forge-gui-desktop/src/main/java/forge/util/SwingImageFetcher.java b/forge-gui-desktop/src/main/java/forge/util/SwingImageFetcher.java
index cccdb4736f91..08bf5f9df120 100644
--- a/forge-gui-desktop/src/main/java/forge/util/SwingImageFetcher.java
+++ b/forge-gui-desktop/src/main/java/forge/util/SwingImageFetcher.java
@@ -34,9 +34,11 @@ private boolean doFetch(String urlToDownload) throws IOException {
return false;
}
- String newdespath = urlToDownload.contains(".fullborder.jpg") || urlToDownload.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD) ?
+ boolean isScryfallUrl = urlToDownload.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD)
+ || urlToDownload.startsWith(ForgeConstants.URL_SCRYFALL_CDN);
+ String newdespath = urlToDownload.contains(".fullborder.jpg") || isScryfallUrl ?
TextUtil.fastReplace(destPath, ".full.jpg", ".fullborder.jpg") : destPath;
- if (!newdespath.contains(".full") && !newdespath.contains(".artcrop") && urlToDownload.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD) && !destPath.startsWith(ForgeConstants.CACHE_TOKEN_PICS_DIR))
+ if (!newdespath.contains(".full") && !newdespath.contains(".artcrop") && isScryfallUrl && !destPath.startsWith(ForgeConstants.CACHE_TOKEN_PICS_DIR))
newdespath = newdespath.replace(".jpg", ".fullborder.jpg"); //fix planes/phenomenon for round border options
URL url = new URL(urlToDownload);
System.out.println("Attempting to fetch: " + url);
diff --git a/forge-gui-desktop/src/test/java/forge/download/ScryfallBulkDataTest.java b/forge-gui-desktop/src/test/java/forge/download/ScryfallBulkDataTest.java
new file mode 100644
index 000000000000..08bd2a8dacdb
--- /dev/null
+++ b/forge-gui-desktop/src/test/java/forge/download/ScryfallBulkDataTest.java
@@ -0,0 +1,26 @@
+package forge.download;
+
+import forge.gui.download.ScryfallBulkData;
+import org.testng.Assert;
+import org.testng.annotations.Test;
+
+/**
+ * Unit tests for {@link ScryfallBulkData}.
+ *
+ * Card UUIDs are stored in {@code res/cdn_uuid/{setCode}/{collectorNumber}.json} files
+ * (part of the assets zip) and loaded at runtime by {@link forge.gui.download.CdnUuidCache}.
+ */
+@Test(groups = {"UnitTest"})
+public class ScryfallBulkDataTest {
+
+ @Test
+ public void testCdnUrlFormula() {
+ String uuid = "4e7a547f-d1b0-4f4e-9a99-3c44fc89c048";
+ Assert.assertEquals(
+ ScryfallBulkData.cdnUrl(uuid, "front", "normal"),
+ "https://cards.scryfall.io/normal/front/4/e/" + uuid + ".jpg");
+ Assert.assertEquals(
+ ScryfallBulkData.cdnUrl(uuid, "back", "art_crop"),
+ "https://cards.scryfall.io/art_crop/back/4/e/" + uuid + ".jpg");
+ }
+}
diff --git a/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java b/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java
new file mode 100644
index 000000000000..ec30cfbd951c
--- /dev/null
+++ b/forge-gui-desktop/src/test/java/forge/gui/download/CdnUuidCacheTest.java
@@ -0,0 +1,209 @@
+package forge.gui.download;
+
+import org.testng.Assert;
+import org.testng.annotations.AfterClass;
+import org.testng.annotations.BeforeClass;
+import org.testng.annotations.Test;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+
+@Test(groups = {"UnitTest"})
+public class CdnUuidCacheTest {
+
+ private static final String SET = "tst";
+ private static final String SET_DFC = "dfc";
+ private static final String SET_ABSENT = "xyz";
+
+ private static final String UUID_EN = "aaaaaaaa-bbbb-cccc-dddd-000000000001";
+ private static final String UUID_JA = "aaaaaaaa-bbbb-cccc-dddd-000000000002";
+ private static final String UUID_FRONT = "aaaaaaaa-bbbb-cccc-dddd-000000000003";
+ private static final String UUID_BACK = "aaaaaaaa-bbbb-cccc-dddd-000000000004";
+
+ /** Local cache dir — starts empty; populated by the cache on first remote fetch. */
+ private File localCacheDir;
+ /** Remote "server" dir — pre-populated with set JSON files, served via file:// URL. */
+ private File remoteDir;
+
+ @BeforeClass
+ public void setUp() throws IOException {
+ localCacheDir = Files.createTempDirectory("cdn_local").toFile();
+ remoteDir = Files.createTempDirectory("cdn_remote").toFile();
+
+ // tst.json — single-faced cards, multiple languages
+ write(new File(remoteDir, SET + ".json"),
+ "{"
+ + "\"1\":{\"en\":\"" + UUID_EN + "\",\"ja\":\"" + UUID_JA + "\"},"
+ + "\"2\":{\"en\":\"" + UUID_EN + "\"}"
+ + "}");
+
+ // dfc.json — double-faced cards
+ write(new File(remoteDir, SET_DFC + ".json"),
+ "{"
+ + "\"1\":{\"en\":[\"" + UUID_FRONT + "\",\"" + UUID_BACK + "\"]},"
+ + "\"2\":{\"en\":[\"" + UUID_FRONT + "\",\"" + UUID_FRONT + "\"]}"
+ + "}");
+
+ // SET_ABSENT has no file in remoteDir — lookups must return null
+
+ CdnUuidCache.localCacheDirOverride = localCacheDir.getAbsolutePath() + File.separator;
+ CdnUuidCache.remoteBaseUrlOverride = remoteDir.toURI().toURL().toString();
+ CdnUuidCache.clearCacheForTesting();
+ }
+
+ @AfterClass
+ public void tearDown() {
+ CdnUuidCache.localCacheDirOverride = null;
+ CdnUuidCache.remoteBaseUrlOverride = null;
+ CdnUuidCache.clearCacheForTesting();
+ deleteDir(localCacheDir);
+ deleteDir(remoteDir);
+ }
+
+ // --- happy path ---
+
+ @Test
+ public void englishFront_returnsCorrectCdnUrl() {
+ String url = CdnUuidCache.getCdnUrl(SET, "1", "en", "front", "normal");
+ Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_EN, "front", "normal"));
+ }
+
+ @Test
+ public void artCropSize_reflectedInUrl() {
+ String url = CdnUuidCache.getCdnUrl(SET, "1", "en", "front", "art_crop");
+ Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_EN, "front", "art_crop"));
+ }
+
+ @Test
+ public void japaneseLang_returnsJaUuid() {
+ String url = CdnUuidCache.getCdnUrl(SET, "1", "ja", "front", "normal");
+ Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_JA, "front", "normal"));
+ }
+
+ // --- language fallback ---
+
+ @Test
+ public void unknownLang_fallsBackToEnglish() {
+ String url = CdnUuidCache.getCdnUrl(SET, "1", "zz", "front", "normal");
+ Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_EN, "front", "normal"));
+ }
+
+ @Test
+ public void cardWithOnlyEn_jaRequestFallsBack() {
+ String url = CdnUuidCache.getCdnUrl(SET, "2", "ja", "front", "normal");
+ Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_EN, "front", "normal"));
+ }
+
+ // --- DFC (double-faced cards) ---
+
+ @Test
+ public void dfcDistinctFaces_frontUuid() {
+ String url = CdnUuidCache.getCdnUrl(SET_DFC, "1", "en", "front", "normal");
+ Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_FRONT, "front", "normal"));
+ }
+
+ @Test
+ public void dfcDistinctFaces_backUuid() {
+ String url = CdnUuidCache.getCdnUrl(SET_DFC, "1", "en", "back", "normal");
+ Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_BACK, "back", "normal"));
+ }
+
+ @Test
+ public void dfcSameUuid_backRequestStillUsesSharedUuid() {
+ // When both faces share the same UUID, back is stored as null internally.
+ String url = CdnUuidCache.getCdnUrl(SET_DFC, "2", "en", "back", "normal");
+ Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_FRONT, "back", "normal"));
+ }
+
+ @Test
+ public void dfcEmptyFaceString_treatedAsFront() {
+ // ImageFetcher passes "" for the front face.
+ String urlEmpty = CdnUuidCache.getCdnUrl(SET_DFC, "1", "en", "", "normal");
+ String urlFront = CdnUuidCache.getCdnUrl(SET_DFC, "1", "en", "front", "normal");
+ Assert.assertEquals(urlEmpty, urlFront);
+ }
+
+ // --- set code normalisation ---
+
+ @Test
+ public void uppercaseSetCode_lowercasedBeforeLookup() {
+ String url = CdnUuidCache.getCdnUrl(SET.toUpperCase(), "1", "en", "front", "normal");
+ Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_EN, "front", "normal"));
+ }
+
+ // --- remote fetch writes to local cache ---
+
+ @Test
+ public void remoteFetch_writesLocalCacheFile() {
+ // After the first successful lookup, the set JSON should be present in localCacheDir.
+ CdnUuidCache.getCdnUrl(SET, "1", "en", "front", "normal");
+ Assert.assertTrue(new File(localCacheDir, SET + ".json").exists(),
+ "local cache file should be written after remote fetch");
+ }
+
+ @Test(dependsOnMethods = "remoteFetch_writesLocalCacheFile")
+ public void localCache_usedOnSubsequentLookup() throws IOException {
+ // Corrupt the in-memory cache but keep the local file; clear remote.
+ CdnUuidCache.clearCacheForTesting();
+ CdnUuidCache.remoteBaseUrlOverride = "file:///nonexistent-dir/";
+ try {
+ String url = CdnUuidCache.getCdnUrl(SET, "1", "en", "front", "normal");
+ Assert.assertEquals(url, ScryfallBulkData.cdnUrl(UUID_EN, "front", "normal"),
+ "should resolve from local cache even when remote is unavailable");
+ } finally {
+ CdnUuidCache.remoteBaseUrlOverride = remoteDir.toURI().toURL().toString();
+ CdnUuidCache.clearCacheForTesting();
+ }
+ }
+
+ // --- null / missing inputs ---
+
+ @Test
+ public void nullScryfallCode_returnsNull() {
+ Assert.assertNull(CdnUuidCache.getCdnUrl(null, "1", "en", "front", "normal"));
+ }
+
+ @Test
+ public void nullCollectorNumber_returnsNull() {
+ Assert.assertNull(CdnUuidCache.getCdnUrl(SET, null, "en", "front", "normal"));
+ }
+
+ @Test
+ public void absentSet_returnsNull() {
+ // No local file and no remote file for SET_ABSENT.
+ Assert.assertNull(CdnUuidCache.getCdnUrl(SET_ABSENT, "1", "en", "front", "normal"));
+ }
+
+ @Test(dependsOnMethods = "absentSet_returnsNull")
+ public void absentSetCachedAsMissing_secondCallAlsoNull() {
+ // MISSING_SET sentinel must be in cache; second lookup must not retry remote.
+ Assert.assertNull(CdnUuidCache.getCdnUrl(SET_ABSENT, "99", "en", "front", "normal"));
+ }
+
+ @Test
+ public void unknownCollectorNumber_returnsNull() {
+ Assert.assertNull(CdnUuidCache.getCdnUrl(SET, "9999", "en", "front", "normal"));
+ }
+
+ // --- helpers ---
+
+ private static void write(File f, String content) throws IOException {
+ Files.write(f.toPath(), content.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private static void deleteDir(File dir) {
+ if (dir == null) return;
+ File[] children = dir.listFiles();
+ if (children != null) {
+ for (File child : children) {
+ if (child.isDirectory()) deleteDir(child);
+ else //noinspection ResultOfMethodCallIgnored
+ child.delete();
+ }
+ }
+ //noinspection ResultOfMethodCallIgnored
+ dir.delete();
+ }
+}
diff --git a/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java b/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java
index 54a993b2fbe9..75acb50dccb3 100644
--- a/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java
+++ b/forge-gui-mobile/src/forge/util/LibGDXImageFetcher.java
@@ -67,9 +67,11 @@ private boolean doFetch(String urlToDownload) throws IOException {
}
}
- String newdespath = urlToDownload.contains(".fullborder.") || urlToDownload.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD) ?
+ boolean isScryfallUrl = urlToDownload.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD)
+ || urlToDownload.startsWith(ForgeConstants.URL_SCRYFALL_CDN);
+ String newdespath = urlToDownload.contains(".fullborder.") || isScryfallUrl ?
TextUtil.fastReplace(destPath, ".full.", ".fullborder.") : destPath;
- if (!newdespath.contains(".full") && urlToDownload.startsWith(ForgeConstants.URL_PIC_SCRYFALL_DOWNLOAD) &&
+ if (!newdespath.contains(".full") && isScryfallUrl &&
!destPath.startsWith(ForgeConstants.CACHE_TOKEN_PICS_DIR) && !destPath.startsWith(ForgeConstants.CACHE_PLANECHASE_PICS_DIR))
newdespath = newdespath.replace(".jpg", ".fullborder.jpg"); //fix planes/phenomenon for round border options
URL url = new URL(urlToDownload);
diff --git a/forge-gui/pom.xml b/forge-gui/pom.xml
index 44b7e3e9ee76..327a6846afe3 100644
--- a/forge-gui/pom.xml
+++ b/forge-gui/pom.xml
@@ -82,6 +82,11 @@
UUID data lives in per-set JSON files hosted in the forge-extras repository. + * On the first lookup for a set, the cache checks for a local copy under + * {@code {cacheDir}/cdn_uuid/{setCode}.json}. If absent it fetches the file from + * forge-extras and writes it locally so subsequent lookups are instant. + * Returns {@code null} on any failure so callers fall back to the rate-limited + * Scryfall API or the cardforge server. + * + *
Set JSON format: + *
+ * {
+ * "1": {"en": "uuid"},
+ * "2": {"en": "uuid", "ja": "ja-uuid"},
+ * "A-40":{"en": ["frontUuid", "backUuid"]}
+ * }
+ *
+ */
+public final class CdnUuidCache {
+
+ private static final String FALLBACK_LANG = "en";
+ private static final int FETCH_TIMEOUT_MS = 10_000;
+
+ /** Sentinel: set was looked up and no data exists (locally or remotely). */
+ private static final MapThe CDN ({@code cards.scryfall.io}) is not rate-limited. Given a UUID and image + * size, the URL is fully deterministic: + *
+ * https://cards.scryfall.io/{size}/{front|back}/{uuid[0]}/{uuid[1]}/{uuid}.jpg
+ *
+ * where {@code size} is {@code "normal"} or {@code "art_crop"}.
+ *
+ * UUIDs are loaded from {@code res/cdn_uuid/{setCode}/{collectorNumber}.json} asset files
+ * by {@link CdnUuidCache}.
+ */
+public final class ScryfallBulkData {
+
+ private ScryfallBulkData() {}
+
+ /**
+ * Builds a Scryfall CDN image URL.
+ *
+ * @param uuid the Scryfall card UUID (e.g. {@code "4e7a547f-..."})
+ * @param side {@code "front"} or {@code "back"}
+ * @param size {@code "normal"} or {@code "art_crop"}
+ */
+ public static String cdnUrl(String uuid, String side, String size) {
+ return "https://cards.scryfall.io/" + size + "/" + side
+ + "/" + uuid.charAt(0) + "/" + uuid.charAt(1) + "/" + uuid + ".jpg";
+ }
+}
diff --git a/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java b/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java
index 8854c312ab03..71d18bafea5f 100644
--- a/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java
+++ b/forge-gui/src/main/java/forge/localinstance/properties/ForgeConstants.java
@@ -240,6 +240,7 @@ public final class ForgeConstants {
}
// data that is only in the profile dirs
+ public static final String CACHE_CDN_UUID_DIR = CACHE_DIR + "cdn_uuid" + PATH_SEPARATOR;
public static final String USER_QUEST_DIR = USER_DIR + "quest" + PATH_SEPARATOR;
public static final String USER_QUEST_WORLD_DIR = USER_QUEST_DIR + "world" + PATH_SEPARATOR;
public static final String USER_CONQUEST_DIR = USER_DIR + "conquest" + PATH_SEPARATOR;
@@ -345,6 +346,8 @@ public final class ForgeConstants {
public static final String URL_PRICE_DOWNLOAD = GITHUB_ASSETS_BASE + "all-prices.txt";
private static final String URL_SCRYFALL = "https://api.scryfall.com";
public static final String URL_PIC_SCRYFALL_DOWNLOAD = URL_SCRYFALL + "/cards/";
+ public static final String URL_SCRYFALL_CDN = "https://cards.scryfall.io/";
+ public static final String FORGE_EXTRAS_CDN_UUID_URL = GITHUB_ASSETS_BASE + "cdn_uuid/";
// Constants for Display Card Identity game setting
public static final String DISP_CURRENT_COLORS_ALWAYS = "Always";
diff --git a/forge-gui/src/main/java/forge/util/ImageFetcher.java b/forge-gui/src/main/java/forge/util/ImageFetcher.java
index c23a0500735a..4f97eda5cd51 100644
--- a/forge-gui/src/main/java/forge/util/ImageFetcher.java
+++ b/forge-gui/src/main/java/forge/util/ImageFetcher.java
@@ -9,6 +9,7 @@
import forge.localinstance.properties.ForgeConstants;
import forge.localinstance.properties.ForgePreferences;
import forge.model.FModel;
+import org.apache.commons.lang3.StringUtils;
import java.io.File;
import java.util.*;
@@ -71,16 +72,21 @@ private String getScryfallDownloadURL(PaperCard c, String face, boolean useArtCr
private void addScryfallUrl(PaperCard card, String face, boolean useArtCrop, ArrayList