Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +339 to +348

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Cache the AllSetFiles input after locale-map generation.

The mtgjson-full-* cache is saved before this step. It cannot contain AllSetFiles.tar or allsets/. Because GitHub Actions caches are immutable, later cache hits cannot add them. Each release then downloads and extracts the full MTGJSON archive again.

Generate the maps before saving the MTGJSON cache and roll the cache key, or add a separate versioned cache for the AllSetFiles inputs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 317 - 320, Move the Generate
locale card-art maps step before the MTGJSON cache save so AllSetFiles.tar and
allsets/ are included, and roll the mtgjson-full-* cache key to invalidate
existing incomplete entries. Alternatively, add a separate versioned cache
covering those inputs while preserving the existing generation flow.


- 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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions client/src/components/deck-builder/PrintingPickerModal.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -132,7 +142,12 @@ export function PrintingPickerModal({
<div className="grid gap-3 grid-cols-[repeat(auto-fill,minmax(140px,1fr))]">
{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");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const isBorderless = printing.border_color === "borderless";
const isExtended = printing.frame_effects.includes("extendedart");

Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof import("../../../services/scryfall.ts")>();
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<Response>((resolve) => {
settle = resolve;
}),
),
);

render(
<PrintingPickerModal cardName="Card" oracleId="oracle-1" onClose={() => {}} />,
);

// 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));
});
});
});
9 changes: 9 additions & 0 deletions client/src/hooks/__tests__/useCardImage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading