Skip to content

Lorcana — Phase 0 Feasibility & Identity

Date: 2026-08-30 Skill: add-new-game Phase 0 (no code) Status: exit bar met — cleared to start Phase 1

All values below were read from live TCGCSV on 2026-08-30 by sweeping all 20 groups (GET /tcgplayer/71/groups, then /products and /prices per group). Nothing here is assumed or carried over from another game (CLAUDE.md §5.4).

TCGCSV returns 401 Unauthorized to a default python-urllib User-Agent. Any script must send a browser/curl UA. This is not a rate limit and not an auth requirement — it is UA filtering, and it fails loudly.


1. Data source

SourceTCGCSV.com (same as Pokemon and Riftbound)
Category id71name: "Lorcana TCG", displayName: "Disney Lorcana"
Basehttps://tcgcsv.com/tcgplayer/71
Groups (sets)20
Products3,646 total
Singles (have a Rarity in extendedData)3,474
Sealed candidates (empty extendedData)172

Sets, cards, and prices are all available. Catalog and prices come from the same source, which drives the identity decision in §5.


2. Rarity vocabulary (verbatim, all 20 sets)

Rarity valueCount
Common957
Uncommon714
Rare635
Super Rare240
Enchanted222
Promo203
None165
Legendary161
Epic90
Quest77
Iconic10

Eleven values — nearly double Riftbound's six. Two hazards:

  • 'None' is a literal rarity string, not a missing field. 165 products carry Rarity: "None". Every one of them also has no Number (verified on set 24617: 10 of 10). These are puzzle inserts and similar non-cards ("Attack of the Vine! Puzzle Insert (Top Left)"). Phase 1 must decide whether they are cards at all; treating 'None' as "rarity unknown" and importing them will put non-cards in the singles catalog.
  • Multi-word rarities (Super Rare) mean the plugin's rarity ids need a normalization rule (super rare vs super_rare) that client/src/lib/rarityOptions.js must mirror exactly (Phase 6 depends on this — buylist rarityRates keys are plugin rarity ids).

3. Finish vocabulary (verbatim subTypeName, all 20 sets)

subTypeNamePrice rows
Normal3,083
Cold Foil2,723
Holofoil452

Lorcana has three finishes, and none of the other games' literals apply. MTG's catalog says Nonfoil/Foil; Riftbound's says lowercase normal/foil; Lorcana's foil is Cold Foil. A plugin copied from Riftbound with a two-entry {normal, foil} map silently drops every Cold Foil and Holofoil price.

Distribution on set 24617 (Wilds Unknown, 278 products, 471 price rows):

Finishes pricedProducts
2185
189
34

Confirms the Phase 3 rule: derive finish variants from PRICE DATA, never assume both/all exist.

nonFoilFinishes for this game is ['Normal'] (the value the catalog stores). The Shopify Finish option value written at sync time is decided in Phase 3 and must be re-cited for plugins/nonFoilFinishes.test.js and, if Phase 7 happens, for cardtrader/mapping.js GAME_MAPPERS — those two want different things (catalog value vs. Shopify option value).


4. Card-number format

extendedData.Number is not uniform:

Shape (# = digit)Count
###/###1,716
##/###1,185
##126
#/###121
#76
##/##52
#/##18
#a/####e/###1 each
##A, ##B1 each

Samples: 14/37, 21/207, 127/207, 16.

Two consequences for Phase 3:

  • Most numbers contain /. Handles and SKUs need an explicit normalization rule; / cannot go into a Shopify handle unescaped.
  • Letter suffixes (#a/###, ##A) exist but are rare (6 products total). They must survive normalization rather than being stripped — a stripped suffix collapses 12a/204 and 12b/204 onto one handle.

173 singles have a Rarity but no Number — the 'None'-rarity puzzle inserts from §2. Any number-derived key must handle their absence explicitly rather than defaulting.


5. Identity-key decision

Decision: key on TCGplayer productId. Same as Riftbound, NOT the Pokemon composite setSlug-number.

Justification against the skill's rule ("use the source's native product id when catalog and prices come from ONE source and share it"):

  • Catalog and prices are both TCGCSV category 71.
  • Both sides carry productId as the join field.
  • Measured across all 20 groups: 0 price rows reference a productId that does not exist in products. (33 products have no price row, which is the expected direction — unpriced products, not a broken join.)
  • The composite alternative is unavailable anyway: 173 singles have no Number, so setSlug-number is not total.

Exit bar — §5.3 slug invariant, real documents

Set 24617 (Wilds Unknown), product 678236:

Catalog document (GET /tcgplayer/71/24617/products):

json
{
  "productId": 678236,
  "name": "Buzz Lightyear - On the Way",
  "groupId": 24617,
  "categoryId": 71,
  "url": "https://www.tcgplayer.com/product/678236/lorcana-tcg-wilds-unknown-buzz-lightyear-on-the-way"
}

extendedData: Rarity = "Rare", Number = "85/204"

Price documents (GET /tcgplayer/71/24617/prices):

json
{"productId": 678236, "lowPrice": 0.19, "midPrice": 0.46, "highPrice": 8.0,   "marketPrice": 0.41, "directLowPrice": null, "subTypeName": "Normal"}
{"productId": 678236, "lowPrice": 1.25, "midPrice": 2.91, "highPrice": 11.03, "marketPrice": 2.77, "directLowPrice": null, "subTypeName": "Holofoil"}
{"productId": 678236, "lowPrice": 0.3,  "midPrice": 1.12, "highPrice": 13.2,  "marketPrice": 1.02, "directLowPrice": null, "subTypeName": "Cold Foil"}

Keys string-equal: str(catalog.productId) = '678236' === str(price.productId) = '678236'True.

This card also demonstrates the three-finish case in one document.


6. Set-name / abbreviation reliability

All 20 groups have a unique, non-empty abbreviation — unlike Pokemon, whose abbreviations are empty or duplicated. So set_code keys on abbreviation and smart collections key on set_code.

Decision: use the TCGCSV abbreviation verbatim, unprefixed. Lorcana's abbreviations are raw numbers (114) plus Q1/Q2/Q3 for Illumineer's Quest and D23/D100/DLPC for promo sets. That is the community-canonical scheme, independently confirmed against Lorcast (GET https://api.lorcast.com/v0/sets, 2026-08-31), whose code field is "1", "2", … "13" for the same sets. Prefixing to LOR14 was considered and rejected: it would diverge from what merchants and customers already type.

Full mapping, sorted by publishedOn:

groupIdabbreviationname
17690D23D23 Promos
23234DLPCDisney Lorcana Promo Cards
229371The First Chapter
233032Rise of the Floodborn
23305D100Disney100 Promos
233673Into the Inklands
23528Q1Illumineer's Quest: Deep Trouble
234744Ursula's Return
235365Shimmering Skies
237466Azurite Sea
240117Archazia's Island
242588Reign of Jafar
24257Q2Illumineer's Quest: Palace Heist
243489Fabled
2441410Whispers in the Well
2450011Winterspell
2461712Wilds Unknown
2466613Attack of the Vine!
24734Q3Illumineer's Quest: The Great Hunny Rescue
2474014Hyperia City

The numeric-code invariant

Riftbound's findSet (riftbound/index.js:291) resolves in the order abbreviation → exact name → parseInt(raw) as groupId → slugified name. A numeric Lorcana code like "14" reaches that third branch only if the abbreviation lookup misses, and it is safe today because groupIds occupy 17690–24740 while abbreviations occupy 1–14 — the ranges do not overlap, so findOne({ groupId: 14 }) matches nothing and falls through.

That is a property of the data, not of the code. Phase 1 must ship it as an asserted invariant (a test that no Lorcana abbreviation parses to an existing Lorcana groupId), because if TCGCSV ever issues a low groupId the failure is silent: findSet('14') would return the wrong set and sync it under the right name.


7. Sealed outlook (informs Phase 5, not required now)

172 products across all sets have empty extendedData — the same shape Riftbound's sealed detection uses. Set 24617 alone: Booster Box, Booster Box Case, Booster Pack, Illumineer's Trove, 2-Player Starter Set, Starter Set Display (11 products).

Lorcana has no sealed category taxonomy comparable to MTG's, so the plugin should declare hasSealedCategoryTaxonomy = false (matching Pokemon and Riftbound). Buylist sealed.categoryRates stays empty by design.

Caveat: the puzzle inserts from §2 have Rarity: "None" and an extendedData array, so they land on the singles side of an extendedData-emptiness test. They need their own rule.


8. Decisions for Phase 1 (resolved 2026-08-31)

  1. Puzzle inserts / Rarity: "None" (165 products) — EXCLUDE. They are not tradeable singles and carry no Number. The importer filters on Rarity !== 'None'; the count of excluded rows is logged each run so a vocabulary change upstream is visible rather than silent.

  2. Set code — raw TCGCSV abbreviation, unprefixed (114, Q1Q3, D23, D100, DLPC). Matches Lorcast's canonical scheme; see §6, including the numeric-code invariant that must ship as a test.

  3. Rarity id normalization — lowercase, spaces preserved. Super Raresuper rare, following Pokemon (rare holo ex, rare prism star) rather than inventing an underscore convention. Mirrored in client/src/lib/rarityOptions.js; 'None' never becomes a rarity id because those products are excluded by decision 1.

  4. Card-number normalization — drop the denominator, preserve letter suffixes. 85/20485, 012/20412, 12a/20412a, 1616.

    Revised during Phase 3. The original decision here was 85/20485-204, keeping the whole string. That was wrong: the denominator is Lorcana's set size, not part of the card's identity, and both sibling TCGCSV games already drop it from the same 036/219 shape (riftbound/index.js and Pokemon's normalizeCardNumber). Keeping it would have made Lorcana the only game whose collector_number metafield is not a bare number — and syncService's single-card filter matches on exactly that field across every game. Letter suffixes are still preserved, which is what keeps 12a/204 and 12b/204 on distinct handles.


8b. Phase 3 outcome (2026-08-31)

Phase 3 (Shopify-ready transform) shipped before Phase 2, not after — see the ordering note in the add-new-game skill. Phase 2 gates on isGameSupported(), which reads the plugin registry, and registering the plugin makes the registry-driven parity tests demand getProductModel() to resolve a real /^Shopify/ model. Measured: registering at Phase 1 produced 11 failures.

Importer results against local Mongo:

Shopify-ready docs written3,301 of 3,309 cards
Skipped by validateCard8
sourceCardId values with a matching LorcanaPrice.slug3,281 of 3,301

The 8 skipped are 7 "(Oversized)" Quest promos (Ursula - Ruler of Lorcana (Oversized), the Battleground cards) and one Disney Cruise Promos (Set of 5). All carry a real rarity but no Number, so there is no stable collector number to build a handle or a collector_number metafield from. Skipping them matches Riftbound's validateCard, which also requires a number. If they are ever wanted, they need a deliberate identity scheme, not a relaxed validator.

The 20 unmatched are cards TCGCSV lists with no price row yet. They get a normal-only variant and pick up their real finishes on the next daily run.

Hyperia City (set 14) transformed 0 cards — correct. It releases 2026-10-16 and TCGCSV currently lists only 6 preorder sealed products for it, no singles.

Finish distribution — why deriving from price data mattered

Variants per cardCards
1668
22,621
312

Per-finish presence: normal 2,772 · coldFoil 2,723 · holofoil 451.

529 of the 3,301 cards have no normal variant at all — they are foil-only. Defaulting every card to all three finishes, or even assuming normal always exists, would have produced thousands of variants that can never be priced. This is the Riftbound "658 of 1,332 were foil-only" pattern repeating, and it is the reason the finish list comes from loadLatestFinishKeys() rather than from the plugin's finishes map.

Phase 3 exit bar

ShopifyLorcanaProductVariant.sourceCardId '678236' === LorcanaPrice.slug'678236', asserted against real persisted documents. The transformed doc:

handle : lorcana-wilds-unknown-85-buzz-lightyear-on-the-way
title  : Buzz Lightyear - On the Way (85) - Wilds Unknown (12)
variants: normal / coldFoil / holofoil, each with a distinct barcode
metafields.collector_number: "85"      metafields.set_code: "12"
metafields.ink_type: "Emerald"         metafields.card_type: "Character"

8c. Phase 2 outcome (2026-09-01)

Catalog browse shipped after Phase 3, for the registration reason in §8b.

Riftbound and Lorcana now share one code path

Both games ingest identically (a {game}_sets collection plus a {game}_products collection discriminated by productType), so rather than hand-writing a second branch in /catalog/sets, /catalog/cards and set-details, all three are driven off TCGCSV_GAMES in server/routes/catalogQueries.js. Only names and one facet column differ — Riftbound's domain, Lorcana's inkType. A second copied branch would have been three more places to remember on every future change (§5.1).

RIFTBOUND_SYNCABLE_CARD_FILTER was renamed NUMBERED_CARD_FILTER: it only ever tested number, so it was already game-neutral and Lorcana needed exactly it — for the 8 "(Oversized)" quest promos from §8b (§5.8).

A latent bug this surfaced

GET /api/catalog/sealed validated isGameSupported(game) but notplugin.supportsSealed, and its game branches end in MTG's SetModel aggregation as an unguarded default. Any registered game without an explicit branch was therefore served MTG's sealed products under its own name — the §5.5 phantom-default shape, latent since the route was written and triggered the moment a fourth game registered. routes/sealedProducts.js already made this check on the write side; the read side now does too (§5.12).

Lorcana's sealed rows are ingested but supportsSealed is false until Phase 5, so /catalog/sealed?game=lorcana returns 400 and set-details lists no sealed products — while still counting them, since the count is a fact about the catalog rather than an offer to act on.

Set-code ordering, made non-load-bearing

setDetails.js resolved a set with a single $or over {abbreviation} and {groupId}. Mongo matches either clause and findOne returns an arbitrary winner, so for Lorcana — whose codes are raw numbers — code "12" could have resolved to whichever set had groupId 12. Now resolved as two ordered queries, abbreviation first. The §6 range invariant still holds and the importer still warns, but correctness no longer depends on it.

Exit bar — real imported data through the real routes

GET /api/catalog/sets?game=lorcana            200 | 20 sets
  12 | Wilds Unknown         | cards 257 | sealed 11 | 2026-05-08
  11 | Winterspell           | cards 246 | sealed  9 | 2026-02-13
  Q3 | Illumineer's Quest... | cards   5 | sealed  1 | 2026-10-02
  14 | Hyperia City          | cards   0 | sealed  6 | 2026-10-16

GET /api/catalog/cards?game=lorcana&sets=12   200 | 257 cards
  #1   Gosalyn Mallard - The Quiverwi | Common   | Amber   | $0.05 (normal) your=$0.59
  #10  Rex - Protective Dinosaur      | Rare     | Amber   | $1.39 (normal) your=$1.59
  #102 Pizza Planet - Spaceport       | Rare     | Emerald | $0.30 (normal) your=$0.59
  priced: 6/6 on the page; yourPrice computed for all

GET .../cards?inkType=Emerald                  42 results, all Emerald
GET .../cards?rarity=super%20rare               18 results, all Super Rare
GET /api/catalog/sealed?game=lorcana          400 "Sealed products are not supported for lorcana"

Sets, cards and prices render. MTG and Riftbound return 200 with 0 rows in this local DB only because those collections are empty in it; their regression coverage comes from the seeded suite (api.catalogSealed.test.js, catalog.lorcana.test.js).

Registry sweep — remaining Lorcana gaps

Deliberately not done, and why:

RegistryStatus
schemas/marketplaceConnections.js z.enum + .max(3)Phase 7. Lorcana is not pushed to CardTrader yet.
Store.js marketplace enabledGames validatorPhase 7, same reason.
cardtrader/constants.js, mapping.js, supportedGames()Phase 7 — needs a live-verified CardTrader game id and category id (§5.4).
buylistQuoteService.js priceFnsPhase 6. Quoting a Lorcana card throws until getPricesForLorcanaCard exists.
CatalogSealedPage.jsx sealed-game listCorrectly excludes Lorcana already; it is a hardcoded UX mirror of supportsSealed that should read the capability instead (§5.8 follow-up).

8d. Phase 4 outcome (2026-09-01) — sync enablement

What shipped

Seven SYNC INTERFACE methods on the plugin (findSet, getSetCollectionSpec, selectVariantByFinish, applyPricing, buildVariantProducts, findExistingProduct, findExistingProductLive) and four pricing functions in priceLookupService (getLorcanaPriceForFinish, findValidLorcanaPriceInTimeseries, getPricesForLorcanaCard, updateLorcanaVariantPrices).

getCollectionNames() and parentCollectionSpec use BaseGamePlugin's defaults — the former already yields shopify_lorcana_products_variants, the latter already keys on booster_game whose value is displayName, which is exactly what the transform writes. Neither needed an override.

Rule 5 holds: zero edits to syncService, syncProcessor, preview or collection creation. syncSetDirect is entirely plugin-driven; every call site it makes (findSet, getProductModel, getCardIdentityField, preloadPrices, applyPricing, buildVariantProducts, findExistingProduct) now resolves.

Two decisions worth recording:

  • Set collections key on set_name, not set_code — mirroring Pokemon and Riftbound. Lorcana's codes are unique, but they are bare numbers ("12"), which makes a poor Shopify collection-rule condition. This also has to match the joinField the catalog sets route uses for sync completion (routes/catalog.js sets 'name' for TCGCSV games) or the two disagree about what "fully synced" means (§5.3).
  • selectVariantByFinish maps the neutral 'foil' token to coldFoil before holofoil — coldFoil outnumbers holofoil 2,723 to 452 price rows, so it is what a merchant writing just "foil" almost always means. Asking for 'nonfoil' on one of the 529 foil-only cards returns null rather than silently substituting a foil.

⚠️ The mandated exit bar has NOT been run

The skill's Phase 4 exit bar is:

POST /api/sync { game:'lorcana', setCodes:['12'], testMode:true, testLimit:2 }
→ succeeds AND the set's smart collection CONTAINS the synced products

This requires a connected Shopify store, and this environment has none — no store row in the local DB, no Shopify credentials in env, and the Shopify MCP connector cannot complete OAuth in a non-interactive session. Phase 4 is therefore not signed off. It must be run against the dev store (ufkes-dev-2.myshopify.com, with ngrok http 3000) before this is considered done.

What was verified instead, as the closest available substitute:

ClaimHow
The request contract accepts LorcanasyncRequestSchema / syncStartRequestSchema parse game:'lorcana' with numeric set codes preserved as strings; a missing game is still rejected (§5.5)
The metafield ⇄ collection-rule match — the mechanism the exit bar exists to validateproduct.metafields[spec.metafieldKey] === spec.condition asserted for both the set and parent collection, and for every finish variant after buildVariantProducts overlays finish
sourceSet agreementfindSet().id and the transform's sourceSet both come from slugifySetName, asserted equal on a real awkward name (Illumineer's Quest: The Great Hunny Rescue)
Set resolution cannot pick the wrong setfindSet('77') tested against a fixture holding both a set abbreviated 77 and a different set whose groupId is 77

The residual risk the local tests cannot cover is that Shopify's own smart-collection engine agrees with the rule we generate. That is precisely what the live run proves, and why it is still required.

A bug lint caught that tests did not

getPricesForLorcanaCard referenced LorcanaPrice without importing it — a ReferenceError on the first cache miss in production. Every pricing test supplied a preloaded priceCache, so none reached the branch that queries the model. ESLint's no-undef found it. Fixed, and pinned by a test that exercises the uncached path; verified by mutation (deleting the import makes it fail).

§5.1 parity verdict

GameStatusEvidence
mtgexemptNo MTG code path changed. FINISH_FALLBACK gained two keys MTG's retail objects never contain; a test asserts MTG's etched still outranks them.
pokemonexemptSame. Pokemon already writes holofoil, which was already in FINISH_FALLBACK — its position is unchanged.
riftboundmirroredMoved onto the shared TCGCSV_GAMES path in Phase 2, so its browse behaviour is re-asserted directly: domain still projected (not inkType), 036/219 still normalizes to 36, domain filter still works, no Lorcana rows leak in.
lorcanaaddedThis phase.

Registry-driven guard: the parent-collection invariant test is it.each'd over getSupportedGames(), so a fifth game cannot skip it.

Known gap: existing stores will not see Lorcana

Store.enabledCatalogs gained lorcana in its default, and Mongoose persists a default at document creation. Stores that already have an explicit ['mtg','pokemon','riftbound'] array therefore keep it — Lorcana stays hidden in their sidebar until they toggle it on. Only docs where the field is absent inherit the new default. There is no backfill script (none exists for Riftbound either), so this follows the established pattern, but it means shipping Lorcana does not by itself make it visible to any existing merchant. Product decision for Brent: leave opt-in, or add a backfill migration. Not done unilaterally — stores is shared state and §2 warns a mistake there is visible to every store.

9. Phase 0 checklist

  • [x] Data source identified with sets, cards, and prices available — TCGCSV category 71
  • [x] Real upstream rows queried; rarity vocabulary (11 values), finish vocabulary (3 values), card-number format (14 shapes), and set-abbreviation reliability (unique, non-empty, numeric) recorded
  • [x] Identity-key decision written down — TCGplayer productId, with the 0-orphan measurement that justifies it
  • [x] Exit bar: one real catalog document and one real price document, keys shown string-equal (§5.3)

Cleared for Phase 1 (catalog + price ingest).