Feature ID: F6.16
Section: F6 — Card Data & Validation
Priority: Medium
Depends on: F6.2 (TCGdex enrichment), F6.10 (Card identity & printing model), F6.13 (Incremental TCGdex sync)
Context
Players who want to register a Japanese deck list today (e.g. cards from SV10 / Mega Brave / Mega Symphonia) can't, because the TCGdex sync, deck-list parser, and card enrichment are hardwired to English (/v2/en). The goal is to allow JP cards as previews in deck lists while they wait for their international release, then auto-promote them to first-class cards (with a CardIdentity and full Expanded legality) when the EN counterpart syncs in.
This is feasibility-confirmed: TCGdex's Japanese coverage is broad enough (172 ja sets vs. 208 en sets, full card data including attacks/abilities/regulation marks/images) and the local schema already has the right slots (TcgdexCard.name is a multilingual JSON, DeckCard.cardLocale exists, Deck.languages exists).
TCGdex Reality Check (verified against live API)
| Aspect |
Finding |
Source |
| Language code for Japanese |
ja (ISO 639-1), not jp |
API error from /v2/jp/sets lists supported langs: en, fr, es, es-mx, it, pt, pt-br, pt-pt, de, nl, pl, ru, ja, ko, zh-tw, id, th, zh-cn |
| Number of JA sets |
172 |
GET /v2/ja/sets |
| Card payload shape |
Single-locale flat strings (e.g. name: "クヌギダマ"), not a {en, ja} object |
GET /v2/ja/cards/SV10-001 |
| Available JP fields |
name, hp, types, stage, attacks[{cost,name,damage}], weaknesses, retreat, regulationMark, legal.{standard,expanded}, image, illustrator, rarity, dexId, description |
same |
legal.expanded on fresh JP releases |
false (e.g. SV10-001) |
same |
releaseDate on JP sets |
often missing |
same |
| Pricing on JP cards |
usually null |
same |
Implication for our TcgdexCard.name = {"en": "...", "fr": "..."} JSON column: we must convert flat JA responses into the same shape ourselves ({"ja": "クヌギダマ"}), since the API doesn't deliver multilingual objects.
Approach — Preview Now, Auto-Promote on EN Release
Two-phase lifecycle for a JP-released card:
Phase A — Preview (international release does not exist yet)
- Sync
/v2/ja/... weekly. Upsert TcgdexCard rows keyed by their JP ID (e.g. SV10-001).
- Store JA fields in the existing JSON columns:
name['ja'], abilities[*].name['ja'], attacks[*].name['ja'], effect['ja'], etc.
- Set
isExpandedLegal = true ("anticipated Expanded" — overrides TCGdex's legal.expanded: false).
- Do not create
CardIdentity / CardPrinting for these rows. A new boolean TcgdexCard.isPreview distinguishes them.
- Deck-list parser accepts JP set codes; deck-list validator allows preview cards but flags the deck as
hasPreviewCards = true (so we can render a banner: "this deck contains cards awaiting international release").
Phase B — Promotion (EN sync sees the card)
When SyncTcgdexCardHandler ingests the EN payload for a card whose TCGdex ID already exists with isPreview = true:
- Merge
name['en'] (and fr, etc.) into the existing JSON columns alongside name['ja'].
- Refresh
isExpandedLegal from legal.expanded (TCGdex's official value now becomes the source of truth).
- Flip
isPreview = false and run the normal CardEnricher flow to create CardIdentity + CardPrinting.
- Re-enrich every
DeckCard that referenced this card (they previously had no identity link); existing decks now have full validation and Cardmarket export.
Promotion fires from a TcgdexCardPromotedEvent so deck enrichment and notifications stay decoupled.
Critical Files to Modify
1. API client — make locale a parameter
-
src/Service/Tcgdex/TcgdexApiClient.php (line 32, line 224, 299, 330, 464): remove const BASE_URL = 'https://api.tcgdex.net/v2/en', replace with private const string BASE_URL_TEMPLATE = 'https://api.tcgdex.net/v2/%s', and pass a string $locale = 'en' argument through findCard(), findCardByName*, fetchSet(). Default stays 'en' so existing call sites don't change.
-
Mirror same change in every sync handler with a hardcoded URL:
src/MessageHandler/SyncTcgdexSeriesHandler.php:39
src/MessageHandler/SyncTcgdexSerieHandler.php
src/MessageHandler/SyncTcgdexSetHandler.php
src/MessageHandler/SyncTcgdexCardHandler.php:40
src/MessageHandler/BuildSetMappingsHandler.php (if any)
(grep -rn "api.tcgdex.net/v2/en" src/ finds them all)
2. Sync messages — add locale
src/Message/SyncTcgdexSeriesMessage.php, SyncTcgdexSerieMessage.php, SyncTcgdexSetMessage.php, SyncTcgdexCardMessage.php — add an optional string $locale = 'en' constructor argument. Handlers route to the right /v2/{locale}/....
src/Command/SyncTcgdexCommand.php — accept --locale=ja (repeatable) and dispatch the cascade once per locale.
src/Message/SyncTcgdexCompleteMessage.php — orchestrate the cascade per locale.
3. TcgdexCard — additive changes
src/Entity/TcgdexCard.php:
- New generated column
nameJa (mirror lines 58–61 for nameFr). Migration: ALTER TABLE tcgdex_card ADD COLUMN name_ja VARCHAR(100) GENERATED ALWAYS AS (name->>'$.ja') STORED, ADD INDEX idx_tcgdex_card_name_ja (name_ja);
- New column
bool $isPreview = false with index. Stores whether this row has been seen in EN yet.
src/Service/Tcgdex/TcgdexCardHydrator.php: the hydrator currently assumes flat multilingual objects from EN. When the source payload is a flat string (because we queried /v2/ja/), wrap it as [$locale => $value] and merge with any existing JSON to preserve other locales already stored.
4. Promotion logic — new service
src/Service/Tcgdex/CardPromotionService.php (new):
promote(TcgdexCard $card): void — flips isPreview to false, calls CardEnricher::enrich() to create the CardIdentity + CardPrinting, then dispatches TcgdexCardPromotedEvent.
src/EventListener/ReEnrichDeckCardsOnPromotionListener.php (new): subscribed via #[AsEventListener], finds every DeckCard where cardLocale = 'ja' and tcgdexCardId = <promoted id>, points them at the now-existing CardPrinting, recomputes deck validation flags.
SyncTcgdexCardHandler — at the end of an EN sync, if the just-upserted TcgdexCard has isPreview = true, call CardPromotionService::promote().
5. Deck list parser & validator
src/Service/DeckListParser.php:
- Set-code parsing is already locale-agnostic (it grabs
SV10 001 regardless of language). No change to the regex, but add JA name lookups to the basic-energy table around lines 105–113 (already partially there) and Japanese set-code prefix recognition if PTCG's JP export uses different codes.
- When a line resolves only via the JA
TcgdexCard, set DeckCard.cardLocale = 'ja'.
src/Service/DeckListValidator.php:
- Skip the strict format-legality check (
isExpandedLegal) when DeckCard.cardLocale = 'ja' and the matched TcgdexCard.isPreview = true — the "anticipated Expanded" override.
- Emit a new validation info message (not error):
app.deck.validation.contains_preview_cards.
src/Entity/Deck.php: derived flag hasPreviewCards (computed at save time, like existing aggregates) so the UI can render a banner without re-scanning DeckCards.
6. UI surfacing
- Add a Twig partial
templates/deck/_preview_banner.html.twig that renders when deck.hasPreviewCards is true — explains the deck contains JP-only cards awaiting international release.
- Card-tile component (likely under
assets/components/): when cardLocale = 'ja', badge with a small "JP" chip and the image from getImageUrl(...) automatically picks the ja/ CDN path (already supported by imageBaseUrl).
- Translations: add
app.deck.preview.banner.title/body and app.card.locale.ja to translations/messages.en.xlf and messages.fr.xlf (no card-name translation — card names stay in their native locale).
7. Format-legality nuance
src/Repository/CardPrintingRepository.php — the EXPANDED_ERA_START filter still applies, but JA-only previews never reach this code path (no CardPrinting exists for them). No change needed.
TcgdexCard.isExpandedLegal for JA previews: set to true on import (override the API). On promotion, the EN payload's legal.expanded becomes authoritative — if it turns out the card was banned in Expanded, the deck banner switches to "no longer Expanded-legal" via the existing banned-card pipeline.
8. Reused functions / utilities — do not reinvent
TcgdexCard::getLocalizedName(string $locale) (line 181) already handles fallback chains — reuse it everywhere instead of new locale-specific helpers.
Archetype::getLocalizedName(), MenuCategory::getLocalizedName() follow the same pattern — they're the reference style for locale-aware accessors.
TcgdexSetAlias (already in repo) is the right home for any JP-specific set-code translation if PTCGL exports use codes that differ from TCGdex's SV10. Don't create a new alias mechanism.
CardEnricher::enrich() should be called by CardPromotionService rather than duplicating its logic.
Verification
End-to-end manual test (after make migrations and make assets):
- Sync a JP-only set:
symfony console app:tcgdex:sync --locale=ja --set=SV10. Confirm rows appear in tcgdex_card with name->>'$.ja' populated, is_preview = 1, and is_expanded_legal = 1.
- Confirm no identity created:
SELECT COUNT(*) FROM card_identity ci JOIN card_printing cp ON cp.card_identity_id = ci.id WHERE cp.tcgdex_card_id LIKE 'SV10-%'; → 0.
- Paste a JP deck: register a new deck, paste a list referencing
SV10 001 (using its JP card name from the API). Expect: deck saves; banner "contains preview cards" appears; deck export disabled or marked "preview".
- Simulate EN release:
symfony console app:tcgdex:sync --locale=en --set=SV10 (or whatever EN code shadows it, once mapped via TcgdexSetAlias). Expect: same TCGdex IDs get name['en'] merged in, is_preview flips to 0, CardIdentity rows are created, and the preview banner disappears from the deck view.
- Re-run the deck-list validator on the existing deck — should now report full Expanded legality and Cardmarket export should work.
Tests to add
tests/Service/Tcgdex/TcgdexCardHydratorTest.php — flat JA payload is wrapped into {"ja": ...} and merged with existing {"en": ...} without overwriting.
tests/Service/Tcgdex/CardPromotionServiceTest.php — isPreview flip + identity creation + event dispatch.
tests/Service/DeckListValidatorTest.php — JP preview card bypasses Expanded check; deck flagged with hasPreviewCards.
tests/Functional/SyncTcgdexCardHandlerTest.php — EN sync over an existing JA preview triggers promotion.
What This Plan Deliberately Does Not Do
- No new "Japanese" format: JP cards live in "anticipated Expanded" rather than getting their own format.
- No JP card-name translation: card names stay in their native locale. UI shows
getLocalizedName($userLocale) with a fallback chain.
- No
TcgdexSet releaseDate patching: TCGdex's JA releaseDate is sparse but we don't need it — JP-only cards are identified by their absence from the EN dataset, not by date.
- No fresh JA pricing scraping: Cardmarket has no JP listings;
cardmarketProductId stays null on previews and Cardmarket export is disabled for them.
Open Questions for Follow-up (do not block this feature)
- Some recent JP sets use codes that differ from the eventual EN code (e.g. JP "SV6a" → EN "TWM"). Promotion-on-sync needs a robust mapping; the existing
TcgdexSetAlias table is the right home, but the seed data may need expanding. Worth a small spike after Phase A ships, since previews work without it (each card is keyed by its full TCGdex ID).
- TCGdex's coverage of brand new JP sets (within days of release) varies. Status of any given set should be checked at https://tcgdex.dev/status before relying on it.
Acceptance Criteria
Feature ID: F6.16
Section: F6 — Card Data & Validation
Priority: Medium
Depends on: F6.2 (TCGdex enrichment), F6.10 (Card identity & printing model), F6.13 (Incremental TCGdex sync)
Context
Players who want to register a Japanese deck list today (e.g. cards from
SV10/ Mega Brave / Mega Symphonia) can't, because the TCGdex sync, deck-list parser, and card enrichment are hardwired to English (/v2/en). The goal is to allow JP cards as previews in deck lists while they wait for their international release, then auto-promote them to first-class cards (with aCardIdentityand full Expanded legality) when the EN counterpart syncs in.This is feasibility-confirmed: TCGdex's Japanese coverage is broad enough (172
jasets vs. 208ensets, full card data including attacks/abilities/regulation marks/images) and the local schema already has the right slots (TcgdexCard.nameis a multilingual JSON,DeckCard.cardLocaleexists,Deck.languagesexists).TCGdex Reality Check (verified against live API)
ja(ISO 639-1), notjp/v2/jp/setslists supported langs:en, fr, es, es-mx, it, pt, pt-br, pt-pt, de, nl, pl, ru, ja, ko, zh-tw, id, th, zh-cnGET /v2/ja/setsname: "クヌギダマ"), not a{en, ja}objectGET /v2/ja/cards/SV10-001name,hp,types,stage,attacks[{cost,name,damage}],weaknesses,retreat,regulationMark,legal.{standard,expanded},image,illustrator,rarity,dexId,descriptionlegal.expandedon fresh JP releasesfalse(e.g. SV10-001)releaseDateon JP setsnullImplication for our
TcgdexCard.name = {"en": "...", "fr": "..."}JSON column: we must convert flat JA responses into the same shape ourselves ({"ja": "クヌギダマ"}), since the API doesn't deliver multilingual objects.Approach — Preview Now, Auto-Promote on EN Release
Two-phase lifecycle for a JP-released card:
Phase A — Preview (international release does not exist yet)
/v2/ja/...weekly. UpsertTcgdexCardrows keyed by their JP ID (e.g.SV10-001).name['ja'],abilities[*].name['ja'],attacks[*].name['ja'],effect['ja'], etc.isExpandedLegal = true("anticipated Expanded" — overrides TCGdex'slegal.expanded: false).CardIdentity/CardPrintingfor these rows. A new booleanTcgdexCard.isPreviewdistinguishes them.hasPreviewCards = true(so we can render a banner: "this deck contains cards awaiting international release").Phase B — Promotion (EN sync sees the card)
When
SyncTcgdexCardHandleringests the EN payload for a card whose TCGdex ID already exists withisPreview = true:name['en'](andfr, etc.) into the existing JSON columns alongsidename['ja'].isExpandedLegalfromlegal.expanded(TCGdex's official value now becomes the source of truth).isPreview = falseand run the normalCardEnricherflow to createCardIdentity+CardPrinting.DeckCardthat referenced this card (they previously had no identity link); existing decks now have full validation and Cardmarket export.Promotion fires from a
TcgdexCardPromotedEventso deck enrichment and notifications stay decoupled.Critical Files to Modify
1. API client — make locale a parameter
src/Service/Tcgdex/TcgdexApiClient.php(line 32, line 224, 299, 330, 464): removeconst BASE_URL = 'https://api.tcgdex.net/v2/en', replace withprivate const string BASE_URL_TEMPLATE = 'https://api.tcgdex.net/v2/%s', and pass astring $locale = 'en'argument throughfindCard(),findCardByName*,fetchSet(). Default stays'en'so existing call sites don't change.Mirror same change in every sync handler with a hardcoded URL:
src/MessageHandler/SyncTcgdexSeriesHandler.php:39src/MessageHandler/SyncTcgdexSerieHandler.phpsrc/MessageHandler/SyncTcgdexSetHandler.phpsrc/MessageHandler/SyncTcgdexCardHandler.php:40src/MessageHandler/BuildSetMappingsHandler.php(if any)(
grep -rn "api.tcgdex.net/v2/en" src/finds them all)2. Sync messages — add locale
src/Message/SyncTcgdexSeriesMessage.php,SyncTcgdexSerieMessage.php,SyncTcgdexSetMessage.php,SyncTcgdexCardMessage.php— add an optionalstring $locale = 'en'constructor argument. Handlers route to the right/v2/{locale}/....src/Command/SyncTcgdexCommand.php— accept--locale=ja(repeatable) and dispatch the cascade once per locale.src/Message/SyncTcgdexCompleteMessage.php— orchestrate the cascade per locale.3. TcgdexCard — additive changes
src/Entity/TcgdexCard.php:nameJa(mirror lines 58–61 fornameFr). Migration:ALTER TABLE tcgdex_card ADD COLUMN name_ja VARCHAR(100) GENERATED ALWAYS AS (name->>'$.ja') STORED, ADD INDEX idx_tcgdex_card_name_ja (name_ja);bool $isPreview = falsewith index. Stores whether this row has been seen in EN yet.src/Service/Tcgdex/TcgdexCardHydrator.php: the hydrator currently assumes flat multilingual objects from EN. When the source payload is a flat string (because we queried/v2/ja/), wrap it as[$locale => $value]and merge with any existing JSON to preserve other locales already stored.4. Promotion logic — new service
src/Service/Tcgdex/CardPromotionService.php(new):promote(TcgdexCard $card): void— flipsisPreviewtofalse, callsCardEnricher::enrich()to create theCardIdentity+CardPrinting, then dispatchesTcgdexCardPromotedEvent.src/EventListener/ReEnrichDeckCardsOnPromotionListener.php(new): subscribed via#[AsEventListener], finds everyDeckCardwherecardLocale = 'ja'andtcgdexCardId = <promoted id>, points them at the now-existingCardPrinting, recomputes deck validation flags.SyncTcgdexCardHandler— at the end of an EN sync, if the just-upsertedTcgdexCardhasisPreview = true, callCardPromotionService::promote().5. Deck list parser & validator
src/Service/DeckListParser.php:SV10 001regardless of language). No change to the regex, but add JA name lookups to the basic-energy table around lines 105–113 (already partially there) and Japanese set-code prefix recognition if PTCG's JP export uses different codes.TcgdexCard, setDeckCard.cardLocale = 'ja'.src/Service/DeckListValidator.php:isExpandedLegal) whenDeckCard.cardLocale = 'ja'and the matchedTcgdexCard.isPreview = true— the "anticipated Expanded" override.app.deck.validation.contains_preview_cards.src/Entity/Deck.php: derived flaghasPreviewCards(computed at save time, like existing aggregates) so the UI can render a banner without re-scanning DeckCards.6. UI surfacing
templates/deck/_preview_banner.html.twigthat renders whendeck.hasPreviewCardsis true — explains the deck contains JP-only cards awaiting international release.assets/components/): whencardLocale = 'ja', badge with a small "JP" chip and the image fromgetImageUrl(...)automatically picks theja/CDN path (already supported byimageBaseUrl).app.deck.preview.banner.title/bodyandapp.card.locale.jatotranslations/messages.en.xlfandmessages.fr.xlf(no card-name translation — card names stay in their native locale).7. Format-legality nuance
src/Repository/CardPrintingRepository.php— theEXPANDED_ERA_STARTfilter still applies, but JA-only previews never reach this code path (noCardPrintingexists for them). No change needed.TcgdexCard.isExpandedLegalfor JA previews: set totrueon import (override the API). On promotion, the EN payload'slegal.expandedbecomes authoritative — if it turns out the card was banned in Expanded, the deck banner switches to "no longer Expanded-legal" via the existing banned-card pipeline.8. Reused functions / utilities — do not reinvent
TcgdexCard::getLocalizedName(string $locale)(line 181) already handles fallback chains — reuse it everywhere instead of new locale-specific helpers.Archetype::getLocalizedName(),MenuCategory::getLocalizedName()follow the same pattern — they're the reference style for locale-aware accessors.TcgdexSetAlias(already in repo) is the right home for any JP-specific set-code translation if PTCGL exports use codes that differ from TCGdex'sSV10. Don't create a new alias mechanism.CardEnricher::enrich()should be called byCardPromotionServicerather than duplicating its logic.Verification
End-to-end manual test (after
make migrationsandmake assets):symfony console app:tcgdex:sync --locale=ja --set=SV10. Confirm rows appear intcgdex_cardwithname->>'$.ja'populated,is_preview = 1, andis_expanded_legal = 1.SELECT COUNT(*) FROM card_identity ci JOIN card_printing cp ON cp.card_identity_id = ci.id WHERE cp.tcgdex_card_id LIKE 'SV10-%';→ 0.SV10 001(using its JP card name from the API). Expect: deck saves; banner "contains preview cards" appears; deck export disabled or marked "preview".symfony console app:tcgdex:sync --locale=en --set=SV10(or whatever EN code shadows it, once mapped viaTcgdexSetAlias). Expect: same TCGdex IDs getname['en']merged in,is_previewflips to 0,CardIdentityrows are created, and the preview banner disappears from the deck view.Tests to add
tests/Service/Tcgdex/TcgdexCardHydratorTest.php— flat JA payload is wrapped into{"ja": ...}and merged with existing{"en": ...}without overwriting.tests/Service/Tcgdex/CardPromotionServiceTest.php—isPreviewflip + identity creation + event dispatch.tests/Service/DeckListValidatorTest.php— JP preview card bypasses Expanded check; deck flagged withhasPreviewCards.tests/Functional/SyncTcgdexCardHandlerTest.php— EN sync over an existing JA preview triggers promotion.What This Plan Deliberately Does Not Do
getLocalizedName($userLocale)with a fallback chain.TcgdexSetreleaseDatepatching: TCGdex's JAreleaseDateis sparse but we don't need it — JP-only cards are identified by their absence from the EN dataset, not by date.cardmarketProductIdstays null on previews and Cardmarket export is disabled for them.Open Questions for Follow-up (do not block this feature)
TcgdexSetAliastable is the right home, but the seed data may need expanding. Worth a small spike after Phase A ships, since previews work without it (each card is keyed by its full TCGdex ID).Acceptance Criteria
TcgdexApiClientaccepts alocaleparameter; default behaviour unchanged for existingencallers.app:tcgdex:sync --locale=ja --set=<id>syncs a JP set intotcgdex_cardwithis_preview = 1and noCardIdentityrows created.4 クヌギダマ SV10 001to the JATcgdexCard, storingDeckCard.cardLocale = 'ja'.Deck.hasPreviewCards = true, and surfaces an info-level banner.is_previewflips to 0,CardIdentity+CardPrintingare created, and existingDeckCardrows are re-linked automatically (banner disappears, Cardmarket export becomes available).docs/technicalities/tcgdex_sync.mdanddocs/technicalities/enrichment.mdupdated; new doc entry in features.md for F6.16.