diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8a101b9ac7..98dc15d135 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -340,6 +340,60 @@ jobs: ./scripts/gen-scryfall-sets.sh ./scripts/gen-scryfall-printings.sh + - name: Derive MTGJSON cache key + # Re-derived in this job rather than plumbed out of `preview-inputs`: + # `steps.` references cannot cross a job boundary, and a dangling one + # does not fail — it expands to the empty string, which would freeze the + # locale-map key at a constant and serve the same maps forever. + # + # Re-running is safe and stays single-authority: the action probes + # MTGJSON's published version, so both jobs derive the same suffix from + # the same source rather than forking the formula. + id: mtgjson-key + uses: ./.github/actions/mtgjson-cache-key + + - name: Restore locale card-art maps + # Caches the OUTPUT (five small JSON maps), not the ~169 MB AllSetFiles + # input the generator downloads to build them. Folding that input into + # the `data/mtgjson` entry is not an option: that cache is saved far + # earlier (it is complete before this step runs) and GitHub caches are + # immutable, so a later hit could never contain it — and the extracted + # set files would add ~1 GB to an entry shared with release.yml. + # + # Same `mtgjson-` prefix as the draft-pools pair (so /clear-caches + # mtgjson sweeps it) and the same published-data suffix. The generator's + # script hash joins the key because LOCALE_MAP lives in the script: a + # locale added there must rebuild, and the data-version suffix alone + # cannot see a source change. + # + # NO restore-keys: every input to the output is in the key, so an + # inexact hit is by construction a stale map. + id: locale-images-cache + uses: actions/cache/restore@v4 + with: + path: client/public/scryfall-images.*.json + key: mtgjson-locale-images-${{ steps.mtgjson-key.outputs.suffix }}-${{ hashFiles('scripts/gen-scryfall-locale-images.sh') }} + + # Separate from the Scryfall step: this reads MTGJSON set files, not the + # Scryfall bulk exports, so it is not covered by the data/scryfall cache. + # + # The generator no-ops when all five maps are already present, so a cache + # hit skips the AllSetFiles download entirely. It gates on the files + # themselves rather than `cache-hit` (same reasoning as "Generate draft + # pools"): a poisoned or partial entry reports a hit with nothing on disk, + # and regenerating on an absent file lets the cache self-heal. + - name: Generate locale card-art maps + run: ./scripts/gen-scryfall-locale-images.sh + + - name: Save locale card-art maps + # Banked the moment the maps exist, so the AllSetFiles download is never + # paid twice for the same inputs even if a later step fails. + if: ${{ steps.locale-images-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: client/public/scryfall-images.*.json + key: mtgjson-locale-images-${{ steps.mtgjson-key.outputs.suffix }}-${{ hashFiles('scripts/gen-scryfall-locale-images.sh') }} + - name: Save Scryfall bulk data # data/scryfall is complete once the four generators have run. Banking # it here keeps a later failure from re-hitting the Scryfall API on the diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 270319ed77..e129b37773 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -314,6 +314,48 @@ jobs: ./scripts/gen-scryfall-sets.sh ./scripts/gen-scryfall-printings.sh + - name: Restore locale card-art maps + # Caches the OUTPUT (five small JSON maps), not the ~169 MB AllSetFiles + # input the generator downloads to build them. Folding that input into + # the `data/mtgjson` entry is not an option: that cache is saved far + # earlier (it is complete before this step runs) and GitHub caches are + # immutable, so a later hit could never contain it — and the extracted + # set files would add ~1 GB to an entry shared with deploy.yml. + # + # Same `mtgjson-` prefix as the draft-pools pair (so /clear-caches + # mtgjson sweeps it) and the same published-data suffix. The generator's + # script hash joins the key because LOCALE_MAP lives in the script: a + # locale added there must rebuild, and the data-version suffix alone + # cannot see a source change. + # + # NO restore-keys: every input to the output is in the key, so an + # inexact hit is by construction a stale map. + id: locale-images-cache + uses: actions/cache/restore@v4 + with: + path: client/public/scryfall-images.*.json + key: mtgjson-locale-images-${{ steps.mtgjson-key.outputs.suffix }}-${{ hashFiles('scripts/gen-scryfall-locale-images.sh') }} + + # Separate from the Scryfall step: this reads MTGJSON set files, not the + # Scryfall bulk exports, so it is not covered by the data/scryfall cache. + # + # The generator no-ops when all five maps are already present, so a cache + # hit skips the AllSetFiles download entirely. It gates on the files + # themselves rather than `cache-hit` (same reasoning as "Generate draft + # pools"): a poisoned or partial entry reports a hit with nothing on disk, + # and regenerating on an absent file lets the cache self-heal. + - name: Generate locale card-art maps + run: ./scripts/gen-scryfall-locale-images.sh + + - name: Save locale card-art maps + # Banked the moment the maps exist, so the AllSetFiles download is never + # paid twice for the same inputs even if a later step fails. + if: ${{ steps.locale-images-cache.outputs.cache-hit != 'true' }} + uses: actions/cache/save@v4 + with: + path: client/public/scryfall-images.*.json + key: mtgjson-locale-images-${{ steps.mtgjson-key.outputs.suffix }}-${{ hashFiles('scripts/gen-scryfall-locale-images.sh') }} + - name: Save Scryfall bulk data # data/scryfall is complete once the four generators have run. Banking # it here keeps a later failure from re-hitting the Scryfall API on the diff --git a/.gitignore b/.gitignore index 95ff364070..8188a1e52e 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,7 @@ client/public/card-names.json client/public/coverage-data.json client/public/coverage-summary.json client/public/scryfall-data.json +client/public/scryfall-images.*.json client/public/scryfall-printings.json client/public/scryfall-sets.json client/public/scryfall-token-images.json @@ -71,6 +72,8 @@ data/mtgjson/SetList.json data/mtgjson/.refresh-week data/mtgjson/decks/ data/mtgjson/sets/ +data/mtgjson/AllSetFiles.tar +data/mtgjson/allsets/ !data/abilities/ !data/precons/ !data/learned-weights.json diff --git a/client/src/components/deck-builder/PrintingPickerModal.tsx b/client/src/components/deck-builder/PrintingPickerModal.tsx index 848000b359..f4178e14df 100644 --- a/client/src/components/deck-builder/PrintingPickerModal.tsx +++ b/client/src/components/deck-builder/PrintingPickerModal.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { getCardPrintings } from "../../services/scryfall.ts"; +import { useLocaleArt } from "../../hooks/useCardImage.ts"; +import { getCardPrintings, resolvePrintingImageUrl } from "../../services/scryfall.ts"; import type { PrintingEntry } from "../../services/scryfall.ts"; import { usePreferencesStore } from "../../stores/preferencesStore.ts"; import { ModalPanelShell } from "../ui/ModalPanelShell"; @@ -28,6 +29,15 @@ export function PrintingPickerModal({ const [visibleCount, setVisibleCount] = useState(INITIAL_PAGE_SIZE); const [query, setQuery] = useState(""); + // Tile URLs come from `resolvePrintingImageUrl` during render, which reads the + // installed locale-art map. Without this the picker would render whatever + // vocabulary happened to be loaded when it mounted: open it while the map is + // still in flight and every tile shows English art with no re-render when the + // map lands. The hook loads the active language's map and ticks this + // component when it arrives; the tile URLs are recomputed inline, so a + // re-render is all that is needed to pick up the swap. + useLocaleArt(); + const currentOverride = usePreferencesStore((s) => s.artOverrides[oracleId]); const setArtOverride = usePreferencesStore((s) => s.setArtOverride); const clearArtOverride = usePreferencesStore((s) => s.clearArtOverride); @@ -132,7 +142,12 @@ export function PrintingPickerModal({
{visiblePrintings.map((printing) => { const isSelected = currentOverride?.scryfallId === printing.id; - const imgUrl = printing.faces[0]?.normal; + // Go through the shared resolver rather than reading the face URL + // directly: it applies the active locale's art, so the picker + // previews each printing in the same language the board renders. + // It also maps Scryfall's "image coming soon" placeholder to null, + // which this tile already renders as a proper "no image" cell. + const imgUrl = resolvePrintingImageUrl(printing, 0, "normal"); const isBorderless = printing.border_color === "borderless"; const isExtended = printing.frame_effects.includes("extendedart"); diff --git a/client/src/components/deck-builder/__tests__/PrintingPickerModal.test.tsx b/client/src/components/deck-builder/__tests__/PrintingPickerModal.test.tsx new file mode 100644 index 0000000000..3d439b7168 --- /dev/null +++ b/client/src/components/deck-builder/__tests__/PrintingPickerModal.test.tsx @@ -0,0 +1,91 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { PrintingPickerModal } from "../PrintingPickerModal"; +import { usePreferencesStore } from "../../../stores/preferencesStore.ts"; + +// `vi.mock` is hoisted above the imports, so the fixture its factory closes over +// has to be hoisted too — a plain `const` would be in the temporal dead zone. +const { EN_ID, DE_ID, cardUrl } = vi.hoisted(() => { + const EN_ID = "0dbac7ce-a6fa-466e-b6ba-173cf2dec98e"; + const DE_ID = "345a1cf0-e4de-42a9-9c72-ed16826b9067"; + // Real five-segment `cards.scryfall.io` shape: a shorter URL is not + // localizable at all, so every assertion below would pass vacuously. + const cardUrl = (id: string) => + `https://cards.scryfall.io/normal/front/${id[0]}/${id[1]}/${id}.jpg`; + return { EN_ID, DE_ID, cardUrl }; +}); + +// Only `getCardPrintings` is stubbed. The localization path under test — +// `resolvePrintingImageUrl`, `loadLocaleArt`, `isLocaleArtReady` — stays real and +// shares one module closure, so the map a load installs is the map a tile reads. +vi.mock("../../../services/scryfall.ts", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getCardPrintings: vi.fn().mockResolvedValue([ + { + id: EN_ID, + set: "mid", + set_name: "Innistrad: Midnight Hunt", + collector_number: "7", + released_at: "2021-09-24", + border_color: "black", + frame_effects: [], + full_art: false, + faces: [{ normal: cardUrl(EN_ID), art_crop: cardUrl(EN_ID) }], + }, + ]), + }; +}); + +describe("PrintingPickerModal localized art", () => { + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + usePreferencesStore.getState().setLanguage("en"); + }); + + it("swaps tile art when the locale map arrives after the modal mounts", async () => { + usePreferencesStore.getState().setLanguage("de"); + + // Hold the locale map in flight so the modal is forced through the state + // this test exists for: mounted, localized language, no map yet. + let settle: ((r: Response) => void) | undefined; + vi.stubGlobal( + "fetch", + vi.fn( + () => + new Promise((resolve) => { + settle = resolve; + }), + ), + ); + + render( + {}} />, + ); + + // Pending map: the tile renders English art rather than blocking on a + // fetch that may 404. This is also the reach guard for the swap below — + // without it, a tile that never rendered at all would satisfy the final + // assertion by never having been English in the first place. + const img = await screen.findByRole("img"); + expect(img).toHaveAttribute("src", cardUrl(EN_ID)); + + settle!( + new Response(JSON.stringify({ [EN_ID]: DE_ID }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + // The picker resolves tile URLs during render, so arrival of the map is + // only visible if the component subscribed to it. Drop `useLocaleArt()` + // from the modal and this assertion fails on stale English art. + await waitFor(() => { + expect(screen.getByRole("img")).toHaveAttribute("src", cardUrl(DE_ID)); + }); + }); +}); diff --git a/client/src/hooks/__tests__/useCardImage.test.tsx b/client/src/hooks/__tests__/useCardImage.test.tsx index 1b5bd2aa53..c2d35fcc4c 100644 --- a/client/src/hooks/__tests__/useCardImage.test.tsx +++ b/client/src/hooks/__tests__/useCardImage.test.tsx @@ -119,6 +119,11 @@ describe("useCardImage", () => { findPrintingById: vi.fn(), getCardPrintings: vi.fn().mockResolvedValue([]), isCardImageRotatedSync: vi.fn().mockReturnValue(false), + // Report the art locale as already resolved so the hook's background + // loader short-circuits — this test is about token fallback, not + // localization. + isLocaleArtReady: vi.fn().mockReturnValue(true), + loadLocaleArt: vi.fn().mockResolvedValue(new Map()), resolveFaceIndexSync: vi.fn().mockReturnValue(null), resolveOracleIdSync: vi.fn().mockReturnValue(null), resolvePrintingImageUrl: vi.fn(), @@ -166,6 +171,10 @@ describe("useCardImage", () => { getCardPrintings: vi.fn().mockResolvedValue([]), isCardImageFlipLayoutSync: vi.fn().mockReturnValue(false), isCardImageRotatedSync: vi.fn().mockReturnValue(false), + // See the note on the token-fallback mock above: the art locale is + // reported ready so the background loader never runs here. + isLocaleArtReady: vi.fn().mockReturnValue(true), + loadLocaleArt: vi.fn().mockResolvedValue(new Map()), pickOldestPrinting: vi.fn(), resolveFaceIndexSync: vi.fn().mockReturnValue(null), resolveOracleIdSync: vi.fn().mockReturnValue(null), diff --git a/client/src/hooks/useCardImage.ts b/client/src/hooks/useCardImage.ts index 8cebceddaa..33b816b153 100644 --- a/client/src/hooks/useCardImage.ts +++ b/client/src/hooks/useCardImage.ts @@ -9,6 +9,8 @@ import { getCardPrintings, isCardImageFlipLayoutSync, isCardImageRotatedSync, + isLocaleArtReady, + loadLocaleArt, pickOldestPrinting, resolveFaceIndexSync, resolveOracleIdSync, @@ -106,6 +108,81 @@ registerStrategyCacheClearFn(() => { strategyNoWinnerCache.clear(); }); +/** + * Locales whose card-art map is currently being fetched. Same anti-spin-loop + * discipline as `strategyInflight`: without it, every render before the map + * lands would start another fetch. + * + * A failed fetch cannot loop either — `loadLocaleArt` swallows errors and + * resolves an empty map, which still installs, so `isLocaleArtReady` flips true + * and every card simply keeps its English art. + */ +const localeArtInflight = new Set(); + +/** + * Fetch the active locale's card-art map, then invalidate every mounted tile. + * + * The dispatch deliberately carries no `detail`: unlike a printings fetch (which + * concerns one oracleId), a language change re-resolves the URL of every card on + * screen, and the listener treats a detail-less event as a global invalidation. + */ +function loadLocaleArtInBackground(lang: string): void { + if (isLocaleArtReady(lang) || localeArtInflight.has(lang)) return; + localeArtInflight.add(lang); + loadLocaleArt(lang) + .then(() => { + localeArtInflight.delete(lang); + artCacheEvents.dispatchEvent(new Event("update")); + }) + .catch(() => { + localeArtInflight.delete(lang); + }); +} + +/** + * Cache-key component for a resolved image URL. It encodes the *art vocabulary* + * the URL was produced with, not merely the language: before the locale map + * arrives every card legitimately resolves to English art, and caching that + * under a bare `"de"` key would pin it there forever — the background load + * dispatches an invalidation, but the request key would be unchanged, so the + * resolution effect would never re-run. Distinguishing pending from ready makes + * the map's arrival a genuine key change. + */ +function localeArtCacheKey(lang: string): string { + return isLocaleArtReady(lang) ? lang : `${lang}:pending`; +} + +/** + * Load the active language's card-art map and re-render the caller when it + * lands, returning the art-locale key its URLs were resolved with. + * + * For components that resolve art through `resolvePrintingImageUrl` directly + * instead of through `useCardImage` — they otherwise render whatever vocabulary + * happened to be installed at mount and never hear about the map arriving. + * + * `useCardImage` deliberately does NOT call this: it already owns an + * `artCacheEvents` subscription filtered by oracleId, and an unfiltered second + * one per tile would resurrect the unscoped re-render storm that filter exists + * to prevent (see the subscription comment in the hook body). Callers of this + * hook subscribe once per component, not once per rendered card. + */ +export function useLocaleArt(): string { + const language = usePreferencesStore((s) => s.language); + const [, setLocaleArtTick] = useState(0); + + useEffect(() => { + const handler = () => setLocaleArtTick((t) => t + 1); + artCacheEvents.addEventListener("update", handler); + return () => artCacheEvents.removeEventListener("update", handler); + }, []); + + useEffect(() => { + loadLocaleArtInBackground(language); + }, [language]); + + return localeArtCacheKey(language); +} + function applyChainEntry( entry: ArtChainEntry, printings: PrintingEntry[], @@ -241,6 +318,12 @@ function imageRequestKey( tokenImageRefKey: string, oracleId: string, faceName: string, + // `imageRequestCache` stores the FINAL resolved URL, which differs per + // language once localized art is applied — so the art locale belongs in the + // key. The printing-selection caches (`strategyCacheMap`, `printingsCacheMap`) + // stay language-neutral on purpose: which printing wins is a function of the + // user's art preferences, not of their language. + artLocaleKey: string, ): string { return [ oracleId || cardName, @@ -253,6 +336,7 @@ function imageRequestKey( filterSubtypes, String(filterHasAbilities), tokenImageRefKey, + artLocaleKey, ].join("|"); } @@ -370,6 +454,11 @@ export function useCardImage( const artOverrides = usePreferencesStore((s) => s.artOverrides); const artChain = usePreferencesStore((s) => s.artChain); + // Card art follows the UI language: the printing the user chose is kept, and + // only its image is swapped for the same printing in their language. Cards + // with no localized sibling keep their English art. + const language = usePreferencesStore((s) => s.language); + const artLocaleKey = localeArtCacheKey(language); const [src, setSrc] = useState(null); const [isRotated, setIsRotated] = useState(false); @@ -405,6 +494,20 @@ export function useCardImage( return () => artCacheEvents.removeEventListener("update", handler); }, []); + // Kick the locale-art load from an effect, not from render. It writes + // module-global state (`desiredArtLang`, which decides whose fetch may + // install itself), and under concurrent rendering React may start a render + // and discard it — so a render-phase call can let a language that was never + // committed win that race. Running after commit means only the committed + // language is ever requested. + // + // Deliberately placed after the subscription effect above: effects run in + // source order, so the `update` listener is registered before any dispatch + // this load can trigger, even when `loadLocaleArt` resolves from cache. + useEffect(() => { + loadLocaleArtInBackground(language); + }, [language]); + // The printings/art-strategy path indexes faces numerically, but for a // DFC/MDFC the reliable signal is the engine's `faceName` (an MDFC cast as its // back face reports `transformed: false`, so the caller's `faceIndex` is 0 — @@ -456,6 +559,7 @@ export function useCardImage( tokenImageRefKey, oracleId, faceName, + artLocaleKey, ); useEffect(() => { diff --git a/client/src/services/__tests__/scryfall.test.ts b/client/src/services/__tests__/scryfall.test.ts index 802e6e8551..95d51e065b 100644 --- a/client/src/services/__tests__/scryfall.test.ts +++ b/client/src/services/__tests__/scryfall.test.ts @@ -13,6 +13,8 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PrintingEntry } from "../scryfall.ts"; + const REPO_ROOT = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../../../..", @@ -1332,3 +1334,171 @@ esac }, ); }); + +describe("localized card art", () => { + // Real five-segment `cards.scryfall.io` shape. As with the size-derivation + // suite above, the `makeLocalDataMap` fixture is deliberately NOT reused: its + // one-segment `https://img.example/.jpg` URLs are not localizable, so + // every assertion here would pass vacuously against them. + const EN_ID = "0dbac7ce-a6fa-466e-b6ba-173cf2dec98e"; + const DE_ID = "345a1cf0-e4de-42a9-9c72-ed16826b9067"; + const UNMAPPED_ID = "11111111-2222-3333-4444-555555555555"; + + const cardUrl = (id: string, size = "normal", face = "front", query = "") => + `https://cards.scryfall.io/${size}/${face}/${id[0]}/${id[1]}/${id}.jpg${query}`; + + const printing = (id: string, query = ""): PrintingEntry => ({ + id, + set: "mid", + set_name: "Innistrad: Midnight Hunt", + collector_number: "7", + released_at: "2021-09-24", + border_color: "black", + frame_effects: [], + full_art: false, + faces: [ + { normal: cardUrl(id, "normal", "front", query), art_crop: cardUrl(id, "art_crop") }, + { normal: cardUrl(id, "normal", "back", query), art_crop: cardUrl(id, "art_crop", "back") }, + ], + }); + + function stubLocaleArt(map: Record) { + global.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify(map), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + } + + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("swaps the image to the localized printing, keeping the chosen printing", async () => { + const mod = await loadScryfallModule(); + stubLocaleArt({ [EN_ID]: DE_ID }); + await mod.loadLocaleArt("de"); + + const resolved = mod.resolvePrintingImageUrl(printing(EN_ID), 0, "normal"); + + expect(resolved).toBe(cardUrl(DE_ID)); + // Non-vacuity: a no-op `localizeImageUrl` would return the English URL. + expect(resolved).not.toBe(cardUrl(EN_ID)); + }); + + it("keeps English art when the printing has no localized sibling", async () => { + const mod = await loadScryfallModule(); + stubLocaleArt({ [EN_ID]: DE_ID }); + await mod.loadLocaleArt("de"); + + // Reach guard FIRST: prove the map actually loaded and the lookup runs. + // Without this, a failed fetch (empty map) would make the real assertion + // below pass for entirely the wrong reason. + expect(mod.resolvePrintingImageUrl(printing(EN_ID), 0, "normal")).toBe(cardUrl(DE_ID)); + + expect(mod.resolvePrintingImageUrl(printing(UNMAPPED_ID), 0, "normal")).toBe( + cardUrl(UNMAPPED_ID), + ); + }); + + it("localizes the back face and the art crop", async () => { + const mod = await loadScryfallModule(); + stubLocaleArt({ [EN_ID]: DE_ID }); + await mod.loadLocaleArt("de"); + + // A localized Scryfall id addresses the whole printing; front/back is a path + // segment, so one mapping covers both faces of a DFC. + expect(mod.resolvePrintingImageUrl(printing(EN_ID), 1, "normal")).toBe( + cardUrl(DE_ID, "normal", "back"), + ); + expect(mod.resolvePrintingImageUrl(printing(EN_ID), 0, "art_crop")).toBe( + cardUrl(DE_ID, "art_crop"), + ); + }); + + it("is a no-op for English", async () => { + const mod = await loadScryfallModule(); + stubLocaleArt({ [EN_ID]: DE_ID }); + await mod.loadLocaleArt("de"); + expect(mod.resolvePrintingImageUrl(printing(EN_ID), 0, "normal")).toBe(cardUrl(DE_ID)); + + // Assert the *gate*, not just the reset. `useCardImage` skips the load + // entirely when this reports ready, so an unconditionally-ready English + // would strand the German map installed and keep serving German art — + // `loadLocaleArt("en")` below would never run in production. + expect(mod.isLocaleArtReady("en")).toBe(false); + + // Switching back to English must drop the map, not keep serving German art. + await mod.loadLocaleArt("en"); + expect(mod.isLocaleArtReady("en")).toBe(true); + expect(mod.resolvePrintingImageUrl(printing(EN_ID), 0, "normal")).toBe(cardUrl(EN_ID)); + }); + + it("never rewrites the card back or the placeholder", async () => { + const mod = await loadScryfallModule(); + // Map the placeholder's and card back's own ids too, so the guard is doing + // the work rather than a lookup simply missing. + stubLocaleArt({ [EN_ID]: DE_ID, soon: DE_ID, "0aeebaf5-8c7d-4636-9e82-8c27447861f7": DE_ID }); + await mod.loadLocaleArt("de"); + expect(mod.resolvePrintingImageUrl(printing(EN_ID), 0, "normal")).toBe(cardUrl(DE_ID)); + + // `deriveImageUrl` is the exported probe for the same `splitSizedImageUrl` + // guard `localizeImageUrl` relies on; a URL it rejects is one localization + // also leaves alone. The placeholder must stay byte-identical or + // `isPlaceholderImageUrl`'s `===` stops gating the printing fallback. + for (const input of [mod.CARD_BACK_URL, "https://errors.scryfall.com/soon.jpg"]) { + expect(mod.imageUrlSize(input)).toBeNull(); + expect(mod.deriveImageUrl(input, "small")).toBe(input); + } + }); + + it("reports readiness and tolerates a missing locale file", async () => { + const mod = await loadScryfallModule(); + expect(mod.isLocaleArtReady("en")).toBe(true); + expect(mod.isLocaleArtReady("de")).toBe(false); + + global.fetch = vi.fn().mockResolvedValue(new Response("", { status: 404 })); + await mod.loadLocaleArt("de"); + + // A 404 still counts as resolved — otherwise `useCardImage` would refetch on + // every render. Every card simply keeps its English art. + expect(mod.isLocaleArtReady("de")).toBe(true); + expect(mod.resolvePrintingImageUrl(printing(EN_ID), 0, "normal")).toBe(cardUrl(EN_ID)); + }); + + it("dedupes concurrent loads of one locale into a single fetch", async () => { + const mod = await loadScryfallModule(); + let settle: ((r: Response) => void) | undefined; + const fetchMock = vi.fn( + () => new Promise((resolve) => { + settle = resolve; + }), + ); + global.fetch = fetchMock as unknown as typeof global.fetch; + + // Both callers start while the request is still in flight. Several tiles + // mounting at once is the normal case, so the second must join the pending + // promise rather than opening its own request. + const first = mod.loadLocaleArt("de"); + const second = mod.loadLocaleArt("de"); + expect(fetchMock).toHaveBeenCalledTimes(1); + + settle!( + new Response(JSON.stringify({ [EN_ID]: DE_ID }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const [mapA, mapB] = await Promise.all([first, second]); + + // Same Map instance, so the body was parsed once and both callers observe + // one shared map rather than two equal copies. + expect(mapA).toBe(mapB); + expect(mapA.get(EN_ID)).toBe(DE_ID); + expect(fetchMock).toHaveBeenCalledTimes(1); + // Reach guard: the deduped map is the one that actually got installed, so + // the identity assertions above are not describing a map nobody uses. + expect(mod.resolvePrintingImageUrl(printing(EN_ID), 0, "normal")).toBe(cardUrl(DE_ID)); + }); +}); diff --git a/client/src/services/scryfall.ts b/client/src/services/scryfall.ts index ade25cc394..a2264ef5d1 100644 --- a/client/src/services/scryfall.ts +++ b/client/src/services/scryfall.ts @@ -95,6 +95,71 @@ function loadTokenImagesData(): Promise { return tokenImagesDataPromise; } +/** + * Per-locale card-art map: English Scryfall printing id → the same printing's + * localized sibling id (`scryfall-images..json`, generated by + * `scripts/gen-scryfall-locale-images.sh` from MTGJSON `foreignData`). + * + * Only one locale is resolved at a time — the UI renders in exactly one + * language — mirroring how `scryfallDataResolved` holds a single module-global + * map rather than threading data through every call site. + */ +let localeArtResolved: { lang: string; map: Map } | null = null; +const localeArtPromises = new Map>>(); +/** + * The locale the app currently wants. Set synchronously on every request so a + * slow fetch for a language the user has already switched away from cannot + * install itself over the newer one (de → fr → de resolves out of order). + */ +let desiredArtLang = "en"; + +/** + * True when `localizeImageUrl` already resolves in `lang` — i.e. the installed + * map matches it. English is ready only when *no* map is installed, because + * English is defined by the absence of one: reporting it ready unconditionally + * would let a de → en switch skip the load that clears the German map, leaving + * `localizeImageUrl` serving German art under an English key. + * + * This doubles as the caller's "do I need to load?" gate, so the two meanings + * must not diverge. + */ +export function isLocaleArtReady(lang: string): boolean { + return lang === "en" ? localeArtResolved === null : localeArtResolved?.lang === lang; +} + +/** + * Load the card-art map for `lang`. English clears any resolved map and resolves + * immediately. A missing file (404 for a locale not yet published) resolves to an + * empty map, so every card falls back to English art — localized art is + * best-effort display data, never a hard dependency. + */ +export function loadLocaleArt(lang: string): Promise> { + desiredArtLang = lang; + if (lang === "en") { + localeArtResolved = null; + return Promise.resolve(new Map()); + } + let promise = localeArtPromises.get(lang); + if (!promise) { + // Same shape as `ensureCardLocale` (engineRuntime.ts), the content-sidecar + // sibling of this loader: an async IIFE with an early return on !ok, so the + // "missing file" path yields an empty map without widening the value type. + promise = (async () => { + const resp = await fetch( + __SCRYFALL_IMAGES_LOCALE_URL_TEMPLATE__.replace("{lng}", lang), + ); + if (!resp.ok) return new Map(); + const obj = (await resp.json()) as Record; + return new Map(Object.entries(obj)); + })().catch(() => new Map()); + localeArtPromises.set(lang, promise); + } + return promise.then((map) => { + if (desiredArtLang === lang) localeArtResolved = { lang, map }; + return map; + }); +} + export function hasAlternatePrintingsSync(oracleId: string): boolean { if (!printingsDataResolved) return false; const printings = printingsDataResolved[oracleId]; @@ -259,6 +324,40 @@ export function deriveImageUrl(url: string, size: ImageSize): string { return `${parsed.scheme}://${segments.join("/")}`; } +/** + * Rewrite a Scryfall image URL to the active locale's printing of the same card, + * returning the input unchanged when there is no locale loaded, the URL is not a + * sized Scryfall URL, or that printing has no localized sibling. + * + * Reusing `splitSizedImageUrl` is load-bearing, not stylistic. It rejects + * `CARD_BACK_URL` (four path segments) and the `errors.scryfall.com/soon.jpg` + * placeholder (one) — and the placeholder MUST come back byte-identical or + * `isPlaceholderImageUrl`'s `===` stops gating the printing-fallback chain, + * silently disabling art fallback for every card with missing art. A regex that + * merely found a UUID in the path would rewrite both. + * + * The trailing `?` is dropped: it is the *English* printing's + * cache-buster and means nothing for a different Scryfall object. Omitting it + * costs only the ability to notice a re-scan of that art. + */ +function localizeImageUrl(url: string): string { + if (!localeArtResolved) return url; + const parsed = splitSizedImageUrl(url); + if (!parsed) return url; + // segments: [host, size, face, id[0], id[1], ".jpg?"] + const filename = parsed.segments[5]; + // A UUID contains no `.`, so the first dot always ends the id. + const dot = filename.indexOf("."); + if (dot < 0) return url; + const localized = localeArtResolved.map.get(filename.slice(0, dot)); + if (!localized) return url; + const segments = [...parsed.segments]; + segments[3] = localized[0]; + segments[4] = localized[1]; + segments[5] = `${localized}.jpg`; + return `${parsed.scheme}://${segments.join("/")}`; +} + /** * Resolve one stored local face to a URL for the requested size. * @@ -274,8 +373,15 @@ function localFaceImageUrl( size: ImageSize, ): string | undefined { if (!face) return undefined; - if (size === "art_crop") return face.art_crop; - return size === "small" ? deriveImageUrl(face.normal, "small") : face.normal; + // Localization is applied here, at the single funnel every stored-URL path + // reaches (`resolveImageUrl`, `resolvePrintingImageUrl`, and the local token + // lookup), rather than in each caller. Size derivation and localization + // commute — `deriveImageUrl` rewrites segment 1, `localizeImageUrl` rewrites + // segments 3-5 — so the order below is immaterial. + if (size === "art_crop") return localizeImageUrl(face.art_crop); + return localizeImageUrl( + size === "small" ? deriveImageUrl(face.normal, "small") : face.normal, + ); } export interface CardImageAsset { @@ -528,6 +634,12 @@ export function buildLocalSearchCard(overrides: LocalSearchCardOverrides): Scryf ? scryfallDataResolved?.[overrides.oracleId.toLowerCase()] : undefined) ?? scryfallDataResolved?.[overrides.name.toLowerCase()]; const face = entry?.faces[0]; + // Copies stored face URLs straight into `image_uris` rather than going through + // `localFaceImageUrl`, so localization has to be applied explicitly here or + // deck-builder search results would stay English while the board is localized. + // The size mapping below (small/large both reusing `normal`) is preserved + // as-is — deriving a real `small` here would change which asset search + // results download. return { name: entry?.name ?? overrides.name, mana_cost: entry?.mana_cost ?? "", @@ -538,7 +650,12 @@ export function buildLocalSearchCard(overrides: LocalSearchCardOverrides): Scryf keywords: entry?.keywords ?? [], legalities: overrides.legalities, image_uris: face - ? { art_crop: face.art_crop, normal: face.normal, small: face.normal, large: face.normal } + ? { + art_crop: localizeImageUrl(face.art_crop), + normal: localizeImageUrl(face.normal), + small: localizeImageUrl(face.normal), + large: localizeImageUrl(face.normal), + } : undefined, }; } diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts index a9e6f1206d..28e27b124f 100644 --- a/client/src/vite-env.d.ts +++ b/client/src/vite-env.d.ts @@ -8,6 +8,7 @@ declare const __ENGINE_WASM_URL__: string | undefined; declare const __DEFAULT_MULTIPLAYER_SERVER_URL__: string; declare const __CARD_DATA_URL__: string; declare const __CARD_DATA_LOCALE_URL_TEMPLATE__: string; +declare const __SCRYFALL_IMAGES_LOCALE_URL_TEMPLATE__: string; declare const __CARD_NAMES_URL__: string; declare const __CHANGELOG_URL__: string; declare const __CHANGELOG_META_URL__: string; diff --git a/client/vite.config.ts b/client/vite.config.ts index 7f1ab001e8..37a5c3b39e 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -180,6 +180,16 @@ function dataFileDefines(mode: string): Record { process.env.CARD_DATA_LOCALE_URL_TEMPLATE || (base ? `${base}/card-data.{lng}.json` : "/card-data.{lng}.json"), ), + // Per-locale card-ART map URL template ({lng} replaced at runtime). Maps an + // English Scryfall printing id to the same printing's localized sibling id, + // so the art the player already chose is re-rendered in their language. Same + // manifest/upload/strip lifecycle as the content sidecar above; a 404 + // degrades to English art, which is also the per-card fallback whenever a + // printing has no localized sibling. An explicit env override still wins. + __SCRYFALL_IMAGES_LOCALE_URL_TEMPLATE__: JSON.stringify( + process.env.SCRYFALL_IMAGES_LOCALE_URL_TEMPLATE || + (base ? `${base}/scryfall-images.{lng}.json` : "/scryfall-images.{lng}.json"), + ), }; for (const filename of manifest) { // "card-names.json" → "__CARD_NAMES_URL__"; "card-data.de.json" → @@ -310,6 +320,31 @@ export default defineConfig(({ mode }) => ({ expiration: { maxEntries: 6, maxAgeSeconds: 2592000 }, }, }, + { + // Per-locale card-ART maps (`scryfall-images..json`), the image + // counterpart to the content sidecars above. The data-manifest rule + // below is an exact-name alternation that does not list these, and + // the precache glob covers only js/css/html — so without this rule a + // non-English PWA user would fall back to English art offline while + // their card *text* stayed localized. Same mutability and reasoning + // as card-locale-sidecars: regenerated each deploy, so + // StaleWhileRevalidate. + // + // Two anchored branches, mirroring the engine-WASM rule above. + // Workbox's RegExpRoute refuses a cross-origin match that does not + // begin at index 0 of the href, and in production these are served + // from R2 at DATA_BASE_URL — so a bare `…\.json$` suffix pattern + // silently never routes the very requests this rule exists for. The + // second branch keeps the same-origin path working in dev/Tauri, + // where the files are served from the site root. + urlPattern: + /(?:^https:\/\/data\.phase-rs\.dev\/scryfall-images\.[a-z]{2}\.json$|\/scryfall-images\.[a-z]{2}\.json$)/, + handler: "StaleWhileRevalidate", + options: { + cacheName: "card-art-locale-maps", + expiration: { maxEntries: 6, maxAgeSeconds: 2592000 }, + }, + }, { urlPattern: /^https:\/\/data\.phase-rs\.dev\/audio\//, handler: "CacheFirst", diff --git a/client/vitest.config.ts b/client/vitest.config.ts index f0fd470974..a523a79e9b 100644 --- a/client/vitest.config.ts +++ b/client/vitest.config.ts @@ -45,6 +45,7 @@ export default defineConfig({ __DECKS_URL__: JSON.stringify("/decks.json"), __CARD_DATA_URL__: JSON.stringify("/card-data.json"), __CARD_DATA_LOCALE_URL_TEMPLATE__: JSON.stringify("/card-data.{lng}.json"), + __SCRYFALL_IMAGES_LOCALE_URL_TEMPLATE__: JSON.stringify("/scryfall-images.{lng}.json"), __CHANGELOG_URL__: JSON.stringify("/changelog.json"), __CHANGELOG_META_URL__: JSON.stringify("/changelog-meta.json"), __APP_VERSION__: JSON.stringify("0.0.0-test"), diff --git a/data-files.json b/data-files.json index a66a16a982..36202eb760 100644 --- a/data-files.json +++ b/data-files.json @@ -13,6 +13,11 @@ "decks.json", "draft-pools.json", "scryfall-data.json", + "scryfall-images.de.json", + "scryfall-images.es.json", + "scryfall-images.fr.json", + "scryfall-images.it.json", + "scryfall-images.pt.json", "scryfall-token-images.json", "scryfall-printings.json", "scryfall-sets.json", diff --git a/scripts/deploy-cf.sh b/scripts/deploy-cf.sh index 4b821b662c..4751d0c10a 100755 --- a/scripts/deploy-cf.sh +++ b/scripts/deploy-cf.sh @@ -19,6 +19,8 @@ export AUDIO_BASE_URL="${AUDIO_BASE_URL:-$R2_PUBLIC/audio}" # Per-locale content-i18n sidecars are offloaded to R2 like card-data.json; # the {lng} template resolves to where the upload loop below PUTs them. export CARD_DATA_LOCALE_URL_TEMPLATE="${CARD_DATA_LOCALE_URL_TEMPLATE:-$R2_PUBLIC/card-data.{lng}.json}" +# Per-locale card-art maps, same lifecycle as the content sidecars above. +export SCRYFALL_IMAGES_LOCALE_URL_TEMPLATE="${SCRYFALL_IMAGES_LOCALE_URL_TEMPLATE:-$R2_PUBLIC/scryfall-images.{lng}.json}" DEPLOY_CACHE=".deploy-cache" touch "$DEPLOY_CACHE" @@ -44,6 +46,11 @@ upload_to_r2() { "card-data.fr.json:public/card-data.fr.json" \ "card-data.it.json:public/card-data.it.json" \ "card-data.pt.json:public/card-data.pt.json" \ + "scryfall-images.de.json:public/scryfall-images.de.json" \ + "scryfall-images.es.json:public/scryfall-images.es.json" \ + "scryfall-images.fr.json:public/scryfall-images.fr.json" \ + "scryfall-images.it.json:public/scryfall-images.it.json" \ + "scryfall-images.pt.json:public/scryfall-images.pt.json" \ "coverage-data.json:public/coverage-data.json" \ "coverage-summary.json:public/coverage-summary.json"; do key="${entry%%:*}" @@ -61,10 +68,13 @@ upload_to_r2() { brotli -q 9 -c "client/$file" > "$BRDIR/$key.br" (cd client && pnpm wrangler r2 object put "$R2_BUCKET/$key" \ --file "$BRDIR/$key.br" --content-type application/json --content-encoding br --remote) - # Update cache atomically - grep -v "^$key:" "$DEPLOY_CACHE" > "$DEPLOY_CACHE.tmp" 2>/dev/null || true - echo "$key:$local_tag" >> "$DEPLOY_CACHE.tmp" - mv "$DEPLOY_CACHE.tmp" "$DEPLOY_CACHE" + # Record the tag in a private per-key file instead of editing + # $DEPLOY_CACHE here. Every entry in this loop runs in its own + # background subshell, so a read-modify-write through one shared + # "$DEPLOY_CACHE.tmp" path drops entries whenever two workers interleave: + # both read the old cache, both write the same temp name, and the last + # `mv` wins. The merge after the wait loop is the single writer. + echo "$key:$local_tag" > "$BRDIR/$key.cachetag" fi ) & json_pids+=($!) @@ -93,6 +103,17 @@ upload_to_r2() { done echo "R2 uploads complete." + # Fold the workers' recorded tags into $DEPLOY_CACHE. Every worker has exited + # by now, so this is the only process touching the file — the read-modify-write + # below is safe here in a way it was not inside the loop. + for tagfile in "$BRDIR"/*.cachetag; do + [ -e "$tagfile" ] || continue # no glob match => nothing was uploaded + tag_entry=$(cat "$tagfile") + grep -v "^${tag_entry%%:*}:" "$DEPLOY_CACHE" > "$DEPLOY_CACHE.tmp" 2>/dev/null || true + echo "$tag_entry" >> "$DEPLOY_CACHE.tmp" + mv "$DEPLOY_CACHE.tmp" "$DEPLOY_CACHE" + done + # Verify uploads actually reached remote R2 (not local emulator) echo "Verifying R2 uploads are accessible..." if ! curl -sf --head "$R2_PUBLIC/coverage-summary.json" >/dev/null 2>&1; then @@ -126,6 +147,8 @@ echo " AUDIO_BASE_URL=$AUDIO_BASE_URL" rm -f client/dist/card-data.json client/dist/card-data.json.br # Locale sidecars (card-data..json) — served from R2, strip from bundle. rm -f client/dist/card-data.??.json client/dist/card-data.??.json.br +# Locale card-art maps (scryfall-images..json) — same, served from R2. +rm -f client/dist/scryfall-images.??.json client/dist/scryfall-images.??.json.br rm -f client/dist/coverage-data.json client/dist/coverage-data.json.br rm -f client/dist/coverage-summary.json client/dist/coverage-summary.json.br rm -f client/dist/audio/music/planeswalker-*.m4a diff --git a/scripts/gen-scryfall-locale-images.sh b/scripts/gen-scryfall-locale-images.sh new file mode 100755 index 0000000000..7fc5dd90a2 --- /dev/null +++ b/scripts/gen-scryfall-locale-images.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/mtgjson-fetch.sh" + +DATA_DIR="data/mtgjson" +SETS_TAR="${MTGJSON_ALL_SET_FILES:-$DATA_DIR/AllSetFiles.tar}" +SETS_DIR="${MTGJSON_ALL_SETS_DIR:-$DATA_DIR/allsets}" +OUTPUT_DIR="${SCRYFALL_LOCALE_IMAGES_OUTPUT_DIR:-client/public}" + +# MTGJSON `foreignData.language` (full English language name) -> UI locale code. +# MUST stay in lockstep with `locale_code` in crates/engine/src/bin/oracle_gen.rs, +# which keys the sibling text sidecars (card-data..json). A code present +# here but not there (or vice versa) ships localized art with English text or +# the reverse. +# +# Polish is deliberately absent: MTGJSON has zero Polish foreignData records and +# Scryfall rejects `lang:pl` outright ("Unknown language `pl`"), so `pl` — which +# IS in the frontend's SUPPORTED_LNGS — can never have localized card data from +# either source. Its chrome is translated; its cards stay English. +LOCALE_MAP='{ + "German": "de", + "Spanish": "es", + "French": "fr", + "Italian": "it", + "Portuguese (Brazil)": "pt" +}' + +echo "=== Scryfall Locale Image Map Generation ===" + +CODES=$(jq -r '.[]' <<< "$LOCALE_MAP" | sort) + +# Skip only when every locale output already exists — a partial set must +# regenerate, or a locale added to LOCALE_MAP would never be built. +ALL_PRESENT=1 +for code in $CODES; do + [ -f "$OUTPUT_DIR/scryfall-images.$code.json" ] || ALL_PRESENT=0 +done +if [ "$ALL_PRESENT" = 1 ]; then + echo "Skipping generation — all locale maps already exist in $OUTPUT_DIR (delete to regenerate)." + exit 0 +fi + +# Per-set files, not AllPrintings.json. Both artifacts are the same ~169 MB +# download, but a whole-file `jq` parse of AllPrintings peaks at ~3.5 GB RSS +# (measured, 623 MB of JSON) while parsing one set at a time peaks at ~95 MB — +# a 36x reduction that keeps this runnable on a standard CI runner. +if [ ! -d "$SETS_DIR" ]; then + if [ ! -f "$SETS_TAR" ]; then + echo "Downloading MTGJSON AllSetFiles..." + mkdir -p "$DATA_DIR" + # mtgjson_download appends `.gz`, so "AllSetFiles.tar" resolves to the + # published AllSetFiles.tar.gz and is decompressed to the bare tar. + mtgjson_download "AllSetFiles.tar" "$SETS_TAR" + echo "Downloaded $SETS_TAR." + fi + echo "Extracting set files..." + # Own a private directory: data/mtgjson/sets/ belongs to fetch-draft-sets.sh + # and fetch-token-sets.sh, whose skip-if-exists logic would treat our files + # as their own cache hits. + mkdir -p "$SETS_DIR" + tar -xf "$SETS_TAR" -C "$SETS_DIR" --strip-components=1 +fi + +SET_COUNT=$(find "$SETS_DIR" -name '*.json' | wc -l | tr -d ' ') +if [ "$SET_COUNT" = 0 ]; then + echo "ERROR: no set files found in $SETS_DIR" >&2 + exit 1 +fi +echo "Scanning $SET_COUNT set files..." + +WORK_DIR=$(mktemp -d) +trap 'rm -rf "$WORK_DIR"' EXIT +PAIRS="$WORK_DIR/pairs.tsv" + +# One pass per set file, streaming `\t\t`. +# +# `identifiers.scryfallId` on a foreignData record is the Scryfall id of the +# LOCALIZED printing (verified: MID Brutal Cathar's German id resolves to +# lang "de", printed_name "Brutaler Katharer"). The card's own +# `identifiers.scryfallId` is the English printing the frontend already +# resolved, which is what the runtime looks up. +: > "$PAIRS" +for set_file in "$SETS_DIR"/*.json; do + jq -r --argjson locales "$LOCALE_MAP" ' + .data.cards[]? + | select(.identifiers.scryfallId != null) + | . as $card + | .foreignData[]? + | select(.identifiers.scryfallId != null) + | ($locales[.language] // empty) as $code + | "\($code)\t\($card.identifiers.scryfallId)\t\(.identifiers.scryfallId)" + ' "$set_file" >> "$PAIRS" +done + +# Split by locale code, then build each map. Keeping the split in awk means the +# large intermediate is never held in a jq value. +awk -F'\t' -v dir="$WORK_DIR" '{ print $2 "\t" $3 > (dir "/pairs-" $1 ".tsv") }' "$PAIRS" + +mkdir -p "$OUTPUT_DIR" +for code in $CODES; do + locale_pairs="$WORK_DIR/pairs-$code.tsv" + if [ ! -f "$locale_pairs" ]; then + echo "ERROR: no localized printings found for '$code' — is LOCALE_MAP's language name still correct for this MTGJSON version?" >&2 + exit 1 + fi + out="$OUTPUT_DIR/scryfall-images.$code.json" + jq -R -s -c ' + split("\n") + | map(select(length > 0) | split("\t") | {key: .[0], value: .[1]}) + | from_entries + ' "$locale_pairs" > "$out" + printf " %-8s %7d entries %s\n" "$code" "$(jq 'length' "$out")" "$(du -h "$out" | cut -f1)" +done + +# The runtime builds image URLs by substituting the localized id into the same +# CDN path shape every stored Scryfall URL already uses. That shape is not a +# documented API contract, so verify a sample here: if Scryfall reorganizes its +# CDN, this fails at generation instead of blanking every localized card image +# in production. +echo "Validating constructed image URLs..." +SAMPLE_CODE=$(echo "$CODES" | head -1) +SAMPLE_IDS=$(jq -r '[.[]] | .[0:5][]' "$OUTPUT_DIR/scryfall-images.$SAMPLE_CODE.json") +for id in $SAMPLE_IDS; do + url="https://cards.scryfall.io/normal/front/${id:0:1}/${id:1:1}/${id}.jpg" + status=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 30 --retry 3 \ + -H 'User-Agent: phase-rs-card-data/1.0 (+https://github.com/phase-rs/phase)' "$url") + if [ "$status" != "200" ]; then + echo "ERROR: constructed image URL returned HTTP $status — $url" >&2 + echo " The Scryfall CDN path shape may have changed. Localized art would 404 for every card." >&2 + exit 1 + fi +done +echo " ${SAMPLE_CODE}: $(echo "$SAMPLE_IDS" | wc -l | tr -d ' ') sampled URLs OK" + +echo "Generated locale image maps in $OUTPUT_DIR" diff --git a/scripts/setup.sh b/scripts/setup.sh index cef0250779..16136e93de 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -121,10 +121,15 @@ else ./scripts/gen-scryfall-images.sh & PID_IMAGES=$! ./scripts/gen-scryfall-token-images.sh & PID_TOKEN_IMAGES=$! ./scripts/gen-scryfall-printings.sh & PID_PRINTINGS=$! + # Locale card-art maps. Sourced from MTGJSON rather than Scryfall bulk, but + # the same category of artifact: runtime-only frontend image data, needed + # only when a non-English UI language is selected. + ./scripts/gen-scryfall-locale-images.sh & PID_LOCALE_IMAGES=$! wait $PID_IMAGES || FAIL=1 wait $PID_TOKEN_IMAGES || FAIL=1 wait $PID_PRINTINGS || FAIL=1 + wait $PID_LOCALE_IMAGES || FAIL=1 if [ $FAIL -ne 0 ]; then echo "ERROR: Scryfall sidecar fetch failed." >&2 exit 1