Skip to content

fix(scripts): index token printings Scryfall does not label by layout - #7335

Merged
matthewevans merged 3 commits into
phase-rs:mainfrom
cuinhellcat:fix/role-token-flip-layout-images
Aug 13, 2026
Merged

fix(scripts): index token printings Scryfall does not label by layout#7335
matthewevans merged 3 commits into
phase-rs:mainfrom
cuinhellcat:fix/role-token-flip-layout-images

Conversation

@cuinhellcat

@cuinhellcat cuinhellcat commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

Summary

Role tokens (Wilds of Eldraine — Wicked, Cursed, Royal, Young Hero, Monster, Virtuous, Sorcerer) render with no art. Scryfall prints the two Roles on one card with the second upside down, so it classifies all 6 Role printings as layout: "flip" — not "token" or "double_faced_token". scripts/gen-scryfall-token-images.sh selects on layout alone, so every Role printing fell through the filter and client/public/scryfall-token-images.json carried zero Role entries.

The chain is fully determined, not inferred:

step evidence
12 Role presets carry an image ref crates/engine/data/known-tokens.toml, [token.token_image_ref]
the client resolves scryfall:<id>, then oracle:<oracle_id>:<face_name> client/src/services/scryfall.ts:887 fetchTokenImageByRef
neither key existed 0 of 4768 keys matched any of the 6 Role printing ids
all 12 resolve after the fix measured, on the exact scryfall: key

#5232 (merged) fixed preset↔token matching one layer up and claimed "resolve face art"; that claim died at the empty table. This is the next link in the same chain, not a second attempt at the same seam.

The fix is for the class, not the Roles

is_token_printing keeps the two token layout values as a fast path and adds a layout-agnostic clause: a printing whose type line marks it as a token is a token, whatever Scryfall calls its layout.

The first draft of this PR conjoined that clause with layout == "flip". Review caught it as instance-shaped, and the corpus proved it: Roles are not alone out there. The Secret Lair Mechtitan // Mechtitan (5d02dee4-…) is layout: "reversible_card" with a null top-level type_line and token-typed faces. Two real presets point at it, and both were silently degrading through the oracle: fallback to the tneo printing's art instead of the Secret Lair art they name. Gating on "flip" would have fixed the Roles and left the class — and would have left the one card already broken by it still broken.

Today the clause admits exactly 7 printings the layout values do not already carry: the 6 flip Roles and that Mechtitan. (The type-line test itself is true for 2797 printings; the other 2790 arrive on layout anyway.) Nothing else in the corpus carries a Token -prefixed type line outside the two token layouts.

Three details are load-bearing, all measured:

  • type_line, not set_type. Two of the twelve Role presets point at the Wicked // Cursed printing in The List, whose set_type is "masters". A set_type == "token" filter strands exactly those two.
  • type_line, not layout. Bare layout == "flip" would pull the genuine flip cards — 21 cards, 39 printings (Budoka Gardener, Erayo, Akki Lavarunner, …) — into a table of tokens.
  • Top-level and per-face type lines. 81 of 120 double_faced_token entries have no Token -prefixed top-level type_line (62 read "Card // Card"), and the Mechtitan printing has none at all. Those survive on layout today, but a top-level-only test would silently drop the next multi-face token that has to enter through this clause.

Deliberately not copying #2682's mechanism. That PR fixed the same reversible_card blind spot in the two sibling generators by falling back to card_faces[0].oracle_id. Doing that here would emit an oracle:a4fecf0a-…:mechtitan key from the sld printing, which sits at corpus index 42383 against tneo's 1160 — from_entries is last-wins, so it would overwrite an existing entry and silently repoint every other Mechtitan consumer at Secret Lair art. The layout-agnostic clause reaches the same printing through its scryfall: key and changes nothing that already worked.

One health check, three gates

validate_token_images is the single authority for "is this table healthy" — the count of printings the token layout values do not carry. Three gates now use it:

  1. The skip-guard. Skipping on existence alone is a large part of why this table stayed wrong: any checkout, cache or dev machine already holding a Role-less file would never regenerate it, so the fix would have reached nobody who had run the script before. It now skips only a table that still passes the check. Verified four ways — fresh build works; a healthy table is skipped; a 0-byte table is rebuilt rather than certified; a Role-less table is rebuilt automatically, so local checkouts pick this up with nothing to delete. Cost when the file exists: 0.01–0.02s; a forced rebuild is 4.3s. In CI the file never exists (it is gitignored, absent from preview-inputs, and not covered by the data/scryfall cache), so CI behaviour is byte-identical to base.
  2. A pre-install gate, in the shape gen-scryfall-locale-images.sh uses for its own empty classes. The message separates the benign cause (Scryfall reclassified these into a token layout — confirm they still arrive, then drop the clause) from the real one (the type-line prefix changed and the class is about to go artless). It is a presence check, not a completeness one; the comment says so and says why.
  3. The recovery branch inside scryfall_finalize_download, where the generic "is this JSON" would accept an empty table that beat us into place.

The check is arithmetic, not a string compare against "0". jq exits 0 with empty stdout on a 0-byte or whitespace-only input, so || echo 0 never fires and [ "" = 0 ] reads as healthy — both gates in this file were written that way once and caught in review.

Atomic install. Generation builds into a .tmp staging file (the suffix .gitignore already covers in this directory) under a trap, and installs it with the shared scryfall_finalize_download. Writing straight to $OUTPUT truncated it before jq ran, so an interrupt or an OOM on the 600 MB+ input left a 0-byte file for the skip-guard to certify.

Files changed

  • scripts/gen-scryfall-token-images.sh — the is_token_printing predicate and its rationale, the empty-class guard, the temp+rename install and its validator.
  • .gitignore — cover scryfall_finalize_download's rename diagnostic; this script is its first caller to stage into a tracked directory.

Track

Developer

LLM

Model: claude-opus-5
Tier: Frontier
Thinking: high

Implementation method (required)

Method: not-applicable — no change under crates/engine/. This is a data-generation script plus a .gitignore line, paths CONTRIBUTING.md names as directly editable. No game logic, parser, effect, trigger, targeting or state-machine behavior is touched; every TokenImageRef the engine emits is unchanged, and only the lookup table they resolve against gains rows. Six /review-impl passes ran regardless, per CONTRIBUTING; rounds 1–5 each produced findings addressed with code — two factual errors in the comments, an instance-shaped predicate, a non-atomic write, a file-mode regression, and the same empty-stdout hole in both health checks. Round 6 returned no findings.

CR references

None — no rules logic.

Verification

  • Required checks ran clean, or the exact CI-owned alternative is stated below.

  • Gate A output below is for the current committed head.

  • Final review-impl below is clean for the current committed head.

  • Both anchors cite existing analogous code at the same seam.

  • bash -n scripts/gen-scryfall-token-images.sh — clean

  • ./scripts/gen-scryfall-token-images.sh4785 entries, 7 via type line, output mode 644, no staging file left behind; 4.3s cold, 0.02s when skipping

  • Diff against the shipped table (same default-cards.json input) — 17 keys added, 0 removed, 0 values changed. 7 scryfall: keys (6 Roles + Mechtitan) and 10 oracle: keys; Mechtitan's null oracle_id suppresses its oracle keys, which is what keeps the existing oracle:a4fecf0a-…:mechtitan value intact.

  • End-to-end, over the whole catalog — of the 2869 presets in known-tokens.toml carrying a token_image_ref, all 2869 now resolve on their exact scryfall: key. Before: 2855 exact, 12 missed entirely (the Roles), 2 fell back to another printing's art (Mechtitan SLD).

  • Two-way control — with the type-line clause deleted from is_token_printing, the script prints its ERROR block, exits 1, writes no output file and leaves no staging file. With it present, 7 printings.

  • Validator against its real inputs — accepts the generated table; rejects 0-byte, whitespace-only, malformed, a bare scalar, [], {}, and a layout-only table.

  • Skip-guard, four ways — absent → builds; healthy → skips; 0-byte → rebuilds; Role-less shipped table installed as the output → rebuilds to 4785.

  • ./scripts/check-parser-combinators.sh — Gate G PASS, Gate A PASS

  • pre-commit — Gate P PASS

Tilt is not installed in this environment; scripts/tilt-wait.sh would return 3 (cannot answer), never a build result. No Rust or TypeScript source changed, so no Tilt resource is implicated.

Gate A

Gate A PASS head=a4a8f22a5bd045602a16c75b8ebd397ccc60302c base=1f612b55d8897f3734efef381a150a03392b589b

Anchored on

  • scripts/gen-scryfall-images.sh + scripts/gen-scryfall-printings.sh via fix missing art for reversible cards #2682 (Fixes #2031) — the existing authority that Scryfall's non-token layouts need explicit handling in these generators. That PR fixed reversible_card in the two sibling scripts and left this one out; the same class of printing is why the clause here is layout-agnostic rather than flip-specific.
  • scripts/lib/scryfall-fetch.sh:41 scryfall_finalize_download — the existing temp+rename authority, already used by both bulk download paths in the same library. The generator now installs its output the same way instead of truncating in place.
  • scripts/gen-scryfall-locale-images.sh:67, :105 — the house guard idiom the empty-class check copies: an expected class coming back empty is an upstream shape change to look at, so it exit 1s naming the suspected cause rather than shipping a table quietly missing a class.

Final review-impl

Final review-impl PASS head=a4a8f22a5bd045602a16c75b8ebd397ccc60302c

Claimed parse impact

None. No engine input changes; known-tokens.toml, card-data.json and every AbilityDefinition are untouched. The only artifact that changes is the gitignored client/public/scryfall-token-images.json, regenerated from scratch by deploy.yml:339 / release.yml:319 — it is never cached across runs, only data/scryfall is. Local checkouts need no manual step: the skip-guard now rejects the Role-less table and rebuilds it.

Scope Expansion

None. Two adjacent defects were found while measuring this one. Both are deliberately not in this PR — recorded here so they are not mistaken for oversights, and offered as follow-ups.

  1. Four of the seven Roles cannot be spun in the preview. isFlip reaches the token path from isCardImageFlipLayoutSync(resolvedOracleId, cardName) (client/src/hooks/useCardImage.ts:570, :592, :618), which resolves through scryfall-data.json. That file is keyed by $card.name and card_faces[0].name only, so wicked, royal and monster are present but cursed, young hero, virtuous and sorcerer are not — verified by direct lookup. The four Roles whose printed image reads the other Role's name upright are exactly the four with no Ctrl-spin. The clean fix is a tokenImagesResolved module global beside printingsDataResolved (client/src/services/scryfall.ts:74), deriving isFlip in the isToken branch from the token entry's own layout — which this table already carries. Note this is a display affordance, not the art: isSidewaysLayout is split-only, so this codebase never auto-orients any flip card, and every Role now shows the printed token exactly as a Budoka Gardener does.
  2. The three layout-classifying scripts disagree, and the obvious cleanup would regress (1). gen-scryfall-images.sh:70 NON_PLAYABLE omits "flip", so the 5 Role oracle entries are emitted into scryfall-data.json as playable cards. That leak is the only reason three Roles have a spin today. Adding "flip" to NON_PLAYABLE — shared with gen-scryfall-printings.sh — silently removes the spin from those three unless (1) lands first or together. Hoisting a shared token predicate into scripts/lib/ belongs with that change, where it would have more than one consumer; a def with a single consumer belongs in its own script.

Validation Failures

None.

CI Failures

None.

Summary by CodeRabbit

  • Improvements

    • Improved token image generation to recognize a broader range of token layouts, including Role and Mechtitan cards.
    • Added validation to ensure generated token-image data is complete and accurate before use.
    • Existing valid image data is now preserved, while updates are applied safely and atomically.
    • Generation now reports the number of token images produced.
  • Chores

    • Diagnostic files created during downloads are excluded from version control.

Role tokens (Wilds of Eldraine: Wicked, Cursed, Royal, Young Hero,
Monster, Virtuous, Sorcerer) rendered with no art. Scryfall prints the
two Roles on one card with the second upside down, so it classifies all
6 printings as `layout: "flip"` — not "token" or "double_faced_token".
The generator selected on `layout` alone, so `scryfall-token-images.json`
carried zero Role entries and all 12 Role presets in known-tokens.toml
had a `token_image_ref` that resolved to nothing in
`fetchTokenImageByRef`.

`is_token_printing` keeps the two layout values as a fast path and adds a
layout-agnostic clause: a printing whose type line marks it as a token is
a token, whatever Scryfall calls its layout. Roles are not alone out
there — the Secret Lair `Mechtitan // Mechtitan` is
`layout: "reversible_card"` with a null top-level type line and
token-typed faces, and the two presets pointing at it were silently
degrading to the `tneo` printing's art through the `oracle:` fallback.
Gating the clause on "flip" would have fixed the Role instance and left
the class.

`type_line` is the discriminator rather than `set_type` because two of
the twelve Role presets point at the Wicked // Cursed printing in The
List (`set_type: "masters"`), and rather than `layout` because that would
pull the genuine flip cards (21 cards, 39 printings — Budoka Gardener,
Erayo, ...) into a table of tokens. Both the top-level and per-face type
lines are tested: 81 of 120 `double_faced_token` entries have no
"Token "-prefixed top-level `type_line` (62 read "Card // Card") and the
Mechtitan printing has none at all.

`validate_token_images` is the single authority for "is this table
healthy", and all three gates now use it:

* **The skip-guard.** Skipping on existence alone is what let this table
  stay wrong — any checkout, cache or dev machine already holding a
  Role-less file would never regenerate it, so the fix would reach nobody
  who had run the script before. It now skips only a table that still
  passes the check; a stale or truncated one costs 4s to rebuild.
* **A pre-install gate**, in the shape gen-scryfall-locale-images.sh uses
  for its own empty classes, with a message separating the benign cause
  from the real one. Nothing ever noticed the class was empty; the table
  stayed plausible and the art simply never appeared.
* **The recovery branch** inside `scryfall_finalize_download`, where the
  generic "is this JSON" would accept an empty table that beat us into
  place.

The check is arithmetic, not a string compare against "0": jq exits 0
with EMPTY stdout on a 0-byte or whitespace-only input, so `|| echo 0`
never fires and `[ "" = 0 ]` reads as healthy. Both gates in this file
got that wrong once during review.

Generation also builds into a `.tmp` staging file (the suffix `.gitignore`
already covers here) under a trap and installs it with the shared
`scryfall_finalize_download`, instead of truncating the output before jq
runs. Also gitignores the finalizer's rename diagnostic: this script is
its first caller to stage into a tracked directory.

Measured against the shipped table: 17 keys added, 0 removed, 0 changed.
Of the 2869 presets carrying a `token_image_ref`, all 2869 now resolve on
their exact `scryfall:` key — previously 12 missed entirely and 2 fell
back to another printing's art. Both faces of a flip pair share one
`image_uris` — the printed card is a single image — so both Roles resolve
to it, unrotated, exactly as this codebase already renders every other
flip card.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4723f0bc-2219-448b-bfab-9619569cd12e

📥 Commits

Reviewing files that changed from the base of the PR and between a4a8f22 and 7ab197b.

📒 Files selected for processing (2)
  • .gitignore
  • scripts/gen-scryfall-token-images.sh
🚧 Files skipped from review as they are similar to previous changes (2)
  • .gitignore
  • scripts/gen-scryfall-token-images.sh

📝 Walkthrough

Walkthrough

The token image generator now validates existing and newly generated data, detects nonstandard token printings, and installs validated output atomically. Git ignores diagnostic files created during Scryfall downloads.

Changes

Token image generation

Layer / File(s) Summary
Token detection and validation
scripts/gen-scryfall-token-images.sh
The script counts token printings from top-level and face-level Token type lines. Existing output is reused only after validation.
Validated output installation
scripts/gen-scryfall-token-images.sh, .gitignore
Generated data is written to a temporary file, validated, and atomically installed. Git ignores client/public/*.mv-error.* diagnostic files.

Estimated code review effort: 4 (Complex) | ~45 minutes

Mergeability Score: ⚪ Minimal · up to 7ab19

This localized script change improves token image generation and validation, with normal checks reported clean; no actionable merge-blocking risk remains.

Sequence Diagram(s)

sequenceDiagram
  participant Generator as gen-scryfall-token-images.sh
  participant Scryfall as Scryfall download
  participant Temporary as Temporary output file
  participant Destination as Final token image output
  Generator->>Scryfall: Download token data
  Scryfall-->>Temporary: Write generated data
  Generator->>Temporary: Validate generated data
  Generator->>Destination: Atomically install validated data
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the script change to index token printings that Scryfall does not classify by layout.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@matthewevans matthewevans self-assigned this Aug 13, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

[HIGH] The new staging-file template is invalid on macOS/Darwin, so this generator never reaches the token-indexing change on that supported platform. Evidence: scripts/gen-scryfall-token-images.sh:104 calls mktemp "$OUTPUT.XXXXXX.tmp"; Darwin mktemp requires the X run to be the template suffix, while this path has .tmp after it. Why it matters: the command exits during staging-file creation, so builds and local recovery cannot generate any token-image map, including the Role entries this PR is intended to restore. Suggested fix: use a Darwin-valid suffix such as "$OUTPUT.tmp.XXXXXX" and ignore the corresponding interrupted staging-file pattern (or use an API that explicitly supports a suffix), then rerun the script on macOS.

@matthewevans matthewevans added the bug Bug fix label Aug 13, 2026
@matthewevans matthewevans removed their assignment Aug 13, 2026
mkstemp(3) is EINVAL unless the template ends in XXXXXX; accepting a
suffix after the X run is a GNU extension, documented as such under GNU
mktemp(1) --suffix. `$OUTPUT.XXXXXX.tmp` therefore aborts this generator
under `set -e` on BSD and macOS, before it can index anything at all —
including the Role printings this branch exists to restore.

Use `$OUTPUT.tmp.XXXXXX`, the form gen-card-data.sh:248 and
lib/mtgjson-fetch.sh:70 already stage with, and add the
`client/public/*.tmp.*` ignore rule that name needs: the existing
`client/public/*.tmp` covers fixed-name staging files and does not match
a randomised tail, so without it an interrupted run would leave an
untracked 2 MB file in a tracked tree.

No tracked path in client/public matches the new rule.

Reported by @matthewevans in review of phase-rs#7335.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@matthewevans matthewevans self-assigned this Aug 13, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved on 7ab197b156e218a78b7792433060ceda35048345: the portable trailing-XXXXXX mktemp template resolves the prior Darwin blocker, and the current two-file diff has no remaining review finding. The cancelled lint job is a workflow cancellation; the merge queue will rerun required checks.

@matthewevans

Copy link
Copy Markdown
Member

Maintainer hold — current head 7ab197b156e218a78b7792433060ceda35048345.

The rerun replaced the earlier cancelled lint job, but its current card-data coverage check has failed while the Rust jobs are still settling. Approval, the bug label, and merge-when-ready remain armed; I will inspect the completed run’s failure evidence and resume queue admission if it is unrelated infrastructure or resolve a verified PR-caused blocker if not.

No contributor action is requested solely for this hold.

@cuinhellcat

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Thanks for the approval — and on the hold, here is the failure evidence, since it is cheaper for me to hand over than for you to dig out.

It is branch staleness, not this PR.

The complete diff against this PR's base 1f612b5 is two files:

 .gitignore                           |   7 ++
 scripts/gen-scryfall-token-images.sh | 139 ++++++++++++++++++++++++-

No .rs, nothing under crates/, nothing under data/. Parse output on 7ab197b is therefore identical to 1f612b5, and this diff cannot move a swallowed-clause count.

The baseline is main's last-good coverage-data.json (ci.yml:401) and 1f612b5 is now 7 commits behind main, so the reported delta lives in 1f612b5..origin/main, not in the diff under review:

DIAGNOSTIC REGRESSION: swallowed-clause increased from 932 to 933
REGRESSED (coverage honesty) — Pulse of the Forge [Swallow:Condition_If]

The likely origin is #7327 (Forage player-action completion timing) — the only one of the seven that reworks trigger parsing (oracle_trigger.rs +118/-56, plus oracle_effect/sequence.rs). I have not built main to confirm that attribution, so treat the specific commit as a lead rather than a conclusion; what I can stand behind is that it does not originate here.

The Rust (fmt, clippy, test, coverage-gate) failure is the aggregator reporting the same job — CARD_DATA_RESULT: failure, One or more split Rust jobs failed: success success failure skipped success success. One root cause, not two.

I am happy to rebase onto current main so the comparison is apples-to-apples; I have confirmed it is conflict-free. Flagging the cost so the choice is yours rather than mine: that moves the head off 7ab197b and would drop your approval and the armed merge-when-ready. If you would rather admit it as-is, nothing here needs to change.

@matthewevans matthewevans self-assigned this Aug 13, 2026
@matthewevans

Copy link
Copy Markdown
Member

Maintainer update hold — current head 12a8bc41c69f2cd1f9cb73ec0560de1510e1d362.

I brought this branch through current main because its previous card-data failure was the maintainer-side Pulse of the Forge parser change, not this PR's two-file token-image-generator diff. The merge preserved that diff unchanged; the new current-head CI run is in progress. The former approval applied to 7ab197b, so I will complete a fresh current-head review and approval/queue check once these gates settle.

@matthewevans matthewevans removed their assignment Aug 13, 2026
@matthewevans
matthewevans disabled auto-merge August 13, 2026 16:06
@matthewevans matthewevans self-assigned this Aug 13, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approved on 12a8bc41c69f2cd1f9cb73ec0560de1510e1d362: the current-main merge preserves the reviewed portable staging and token-printing classification change. Current required CI is green and this current-head recheck found no remaining implementation finding.

@matthewevans
matthewevans added this pull request to the merge queue Aug 13, 2026
@matthewevans matthewevans removed their assignment Aug 13, 2026
Merged via the queue into phase-rs:main with commit 2808f06 Aug 13, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants