Appearance
Riftbound Full Sync Support + New-Game Playbook Implementation Plan โ
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Enable end-to-end Shopify sync for Riftbound singles (Pokemon-pattern), then codify the process as the reusable add-new-game playbook with sealed requirements.
Architecture: A Phase-2 transform inside updateRiftboundCatalog.js writes Shopify-ready ShopifyRiftboundProductVariant docs (one product per card, Finish variants derived from price data); the Riftbound plugin implements the full SYNC INTERFACE modeled on the Pokemon plugin; the sync core is untouched. Spec: docs/superpowers/specs/2026-07-15-riftbound-sync-support-design.md.
Tech Stack: Node 24 CommonJS (server), Vitest + ESM (tests), Mongoose, existing plugin registry (server/plugins/index.js).
Global Constraints โ
- Server code is CommonJS (
require); test files are ESM (import). Test files co-located asx.test.js. - Never default
gameโ no|| 'mtg'/?? 'mtg'anywhere (CLAUDE.md ยง5.5). - Identity invariant (ยง5.3):
sourceCardId = String(RiftboundProduct.tcgplayerProductId), string-equal toRiftboundPrice.slug(updateRiftboundPrices.js:276writesslug: String(productId)). - Finish vocabulary (ยง5.4):
'normal','foil'โ fromSUBTYPE_TO_FINISHinupdateRiftboundPrices.js:126-127. Rarity vocabulary observed inshopify_test.riftbound_productson 2026-07-15:Common,Uncommon,Rare,Epic,Showcase,Promo, andNone(12 promo cards). Cardnumberformat observed: zero-padded slash form, e.g."036/219","147/219". - Finish availability is price-derived: observed 677 foil-only, 410 normal+foil, 109 normal-only cards โ never assume both finishes exist.
- Aggregations:
$project/$matchbefore$sort(ยง5.6). No mutation of caller-owned arrays in sync paths (ยง5.11). - New sub-document fields declared field-by-field in schemas + round-trip test (ยง5.2).
- โฅ70% coverage on all four metrics for every modified file;
npm testandnpm run lintexit 0 before each commit. Husky pre-commit runs ESLint โ never--no-verify. - Zero
if (game === 'riftbound')added tosyncService,syncProcessor, preview, or collection creation (rule 5). - Commit messages: one imperative sentence, merchant-visible outcome, sentence case, no trailing period, ending with the
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>trailer. - Before starting:
git fetch origin mainand mergeorigin/mainif ahead (ยง5.10). - The
game-parityskill runs before the PR 1 and PR 2 commits land; the summary names mtg/pokemon/riftbound as mirrored/exempt.
PR 1 โ Data layer (inert; sync stays off) โ
Task 1: ShopifyRiftboundProductVariant model + round-trip test โ
Files:
- Create:
server/models/ShopifyRiftboundProductVariant.js - Test:
server/models/ShopifyRiftboundProductVariant.test.js
Interfaces:
Produces: Mongoose model
ShopifyRiftboundProductVariant(collectionshopify_riftbound_products_variants) withvariants[]sub-docs (finish/price/sku/inventoryQty/inventoryPolicy/barcode/imageUrl/shopifyVariantId) andmetafieldssub-doc (booster_game/card_name/card_number/collector_number/set_name/set_code/year/rarity/managed_by/card_type/domain/tcg_identifier). Tasks 3, 6 consume it.[ ] Step 1: Write the failing round-trip test
Create server/models/ShopifyRiftboundProductVariant.test.js:
javascript
/**
* Unit tests for ShopifyRiftboundProductVariant model.
*
* Mirrors ShopifyPokemonProductVariant.test.js: exists specifically to catch
* schema drift that plain-object mocks in plugin/service tests can't. If a
* field is dropped from the schema, Mongoose strict mode silently discards it
* on write (CLAUDE.md ยง5.2) โ these round-trips fail loudly instead.
*/
import { describe, it, expect, beforeEach } from 'vitest';
let ShopifyRiftboundProductVariant;
beforeEach(async () => {
const module = await import('./ShopifyRiftboundProductVariant.js');
ShopifyRiftboundProductVariant = module.default;
});
describe('ShopifyRiftboundProductVariant Model', () => {
const validDoc = {
handle: 'riftbound-origins-147-baron-nashor',
sku: 'riftbound-origins-147-baron-nashor',
title: 'Baron Nashor (147) - Origins (OGN)',
published: true,
productOptions: [{ name: 'Finish', values: ['normal', 'foil'] }],
variants: [
{ finish: 'normal', price: 0, sku: 'riftbound-origins-147-baron-nashor', inventoryQty: 0, inventoryPolicy: 'deny' },
{ finish: 'foil', price: 0, sku: 'riftbound-origins-147-baron-nashor', inventoryQty: 0, inventoryPolicy: 'deny' }
],
metafields: {
booster_game: 'Riftbound TCG',
card_name: 'Baron Nashor',
card_number: '147',
collector_number: '147',
set_name: 'Origins',
set_code: 'OGN',
year: '2025-10-31',
rarity: 'Epic',
managed_by: 'lgs_forge',
card_type: 'Unit',
domain: 'Chaos',
tcg_identifier: 'https://www.tcgplayer.com/product/683790'
},
sourceSet: 'origins',
sourceCardId: '683790',
syncStatus: 'pending'
};
it('accepts a valid multi-finish document', () => {
const doc = new ShopifyRiftboundProductVariant(validDoc);
const error = doc.validateSync();
expect(error).toBeUndefined();
});
it('round-trips variants[] as a real array (schema-drift guard)', () => {
const doc = new ShopifyRiftboundProductVariant(validDoc);
expect(Array.isArray(doc.variants)).toBe(true);
expect(doc.variants).toHaveLength(2);
expect(doc.variants[1].finish).toBe('foil');
expect(doc.variants[1].inventoryPolicy).toBe('deny');
});
it('round-trips every metafield field-by-field (schema-drift guard)', () => {
const doc = new ShopifyRiftboundProductVariant(validDoc);
// Assert each declared key individually โ a deleted schema line makes
// exactly one of these come back undefined.
expect(doc.metafields.booster_game).toBe('Riftbound TCG');
expect(doc.metafields.card_name).toBe('Baron Nashor');
expect(doc.metafields.card_number).toBe('147');
expect(doc.metafields.collector_number).toBe('147');
expect(doc.metafields.set_name).toBe('Origins');
expect(doc.metafields.set_code).toBe('OGN');
expect(doc.metafields.year).toBe('2025-10-31');
expect(doc.metafields.rarity).toBe('Epic');
expect(doc.metafields.managed_by).toBe('lgs_forge');
expect(doc.metafields.card_type).toBe('Unit');
expect(doc.metafields.domain).toBe('Chaos');
expect(doc.metafields.tcg_identifier).toBe('https://www.tcgplayer.com/product/683790');
});
it('requires handle and sku', () => {
const doc = new ShopifyRiftboundProductVariant({ title: 'No identifiers' });
const error = doc.validateSync();
expect(error.errors.handle).toBeDefined();
expect(error.errors.sku).toBeDefined();
});
it('round-trips sourceCardId as the stringified TCGplayer productId (slug invariant, ยง5.3)', () => {
const doc = new ShopifyRiftboundProductVariant(validDoc);
expect(doc.sourceCardId).toBe('683790');
});
});- [ ] Step 2: Run the test to verify it fails
Run: npx vitest run server/models/ShopifyRiftboundProductVariant.test.js Expected: FAIL โ Cannot find module './ShopifyRiftboundProductVariant.js'
- [ ] Step 3: Create the model
Create server/models/ShopifyRiftboundProductVariant.js:
javascript
/**
* Shopify Riftbound Product Variant Model
* One document per card, covering every finish (normal/foil) as a Finish
* variant โ mirrors ShopifyPokemonProductVariant.js.
*
* Identity: sourceCardId is the stringified TCGplayer productId, which is
* string-equal to RiftboundPrice.slug (updateRiftboundPrices.js writes
* slug: String(productId)) โ the ยง5.3 slug invariant for Riftbound.
*/
const mongoose = require('mongoose');
// Variant sub-schema
const variantSchema = new mongoose.Schema({
finish: {
type: String,
default: ''
},
price: {
type: Number,
default: 0
},
sku: String, // Base SKU (shared across finishes, same convention as MTG/Pokemon)
inventoryQty: {
type: Number,
default: 0
},
inventoryPolicy: {
type: String,
enum: ['continue', 'deny'],
default: 'deny'
},
barcode: String,
imageUrl: String, // Variant-specific image URL
shopifyVariantId: String, // Track Shopify variant ID after sync
}, { _id: false });
// Main product schema with variants
const shopifyRiftboundProductVariantSchema = new mongoose.Schema({
// Primary identifiers
handle: {
type: String,
required: true,
index: true,
unique: true // One product per card
},
sku: {
type: String,
required: true,
index: true,
unique: true // Base SKU (shared across finishes)
},
// Shopify product fields
title: String,
body: String,
vendor: {
type: String,
default: 'Riot Games'
},
type: {
type: String,
default: 'Trading Card'
},
tags: String,
published: Boolean,
// Product options
productOptions: [{
name: String,
values: [String]
}],
// Variants array - one per finish type
variants: [variantSchema],
// Common variant settings
variantgrams: Number,
variantrequiresshipping: Boolean,
varianttaxable: Boolean,
// Images
imagesrc: String,
imageposition: Number,
imagealttext: String,
// Multiple images support
images: [{
src: String,
position: Number,
alt: String
}],
// Metafields - Riftbound specific (shared across all variants)
metafields: {
booster_game: String,
card_name: String,
card_number: String,
collector_number: String, // kept in sync with MTG's field name so syncService's single-card filter (metafields.collector_number) works for all games
set_name: String,
set_code: String,
year: String,
rarity: String,
managed_by: String,
// Riftbound-specific fields
card_type: String, // Unit, Legend, Spell, ...
domain: String, // semicolon-separated in source, e.g. "Mind;Chaos"
tcg_identifier: String
// finish is deliberately ABSENT here - it's per-variant, same as MTG/Pokemon
},
// Source data for reference
sourceSet: String, // slugified group name, e.g. 'origins'
sourceCardId: String, // String(tcgplayerProductId) โ equals RiftboundPrice.slug
// Sync status
lastSyncedToShopify: Date,
shopifyProductId: String,
syncStatus: {
type: String,
enum: ['pending', 'synced', 'failed', 'not_found'],
default: 'pending'
},
syncError: String,
// Timestamps
createdAt: {
type: Date,
default: Date.now
},
updatedAt: {
type: Date,
default: Date.now
}
});
// Update timestamp on save
shopifyRiftboundProductVariantSchema.pre('save', function(next) {
this.updatedAt = Date.now();
next();
});
// Indexes for efficient queries
shopifyRiftboundProductVariantSchema.index({ sourceSet: 1 });
shopifyRiftboundProductVariantSchema.index({ syncStatus: 1 });
shopifyRiftboundProductVariantSchema.index({ 'metafields.set_name': 1, 'metafields.card_number': 1 });
shopifyRiftboundProductVariantSchema.index({ 'metafields.rarity': 1 });
module.exports = mongoose.model('ShopifyRiftboundProductVariant', shopifyRiftboundProductVariantSchema, 'shopify_riftbound_products_variants');- [ ] Step 4: Run the test to verify it passes
Run: npx vitest run server/models/ShopifyRiftboundProductVariant.test.js Expected: PASS (5 tests)
- [ ] Step 5: Commit
bash
git add server/models/ShopifyRiftboundProductVariant.js server/models/ShopifyRiftboundProductVariant.test.js
git commit -m "Add Shopify-ready Riftbound product model with schema round-trip tests"Task 2: Riftbound plugin โ card transform methods โ
Files:
- Modify:
server/plugins/riftbound/index.js - Test:
server/plugins/riftbound/index.test.js(extend existing)
Interfaces:
Consumes:
BaseGamePlugin.normalizeCardName(name)(inherited),MANAGED_BY_VALUEfromserver/config/constants.js(value'lgs_forge').Produces (Task 3 consumes):
slugifySetName(name) โ string;normalizeCardNumber(number) โ string("036/219"โ"36");validateCard(cardDoc) โ { valid, errors };transformCardToProduct(cardDoc, setData, finishKeys) โ object|nullwherecardDocis a leanRiftboundProductdoc,setData = { id, groupId, name, abbreviation, publishedOn },finishKeysis an array of'normal'|'foil', and the return value matches theShopifyRiftboundProductVariantshape from Task 1;generateHandle(cardName, setData, number) โ string;generateSKU(setData, number, cardName) โ string.[ ] Step 1: Write the failing tests
Append to server/plugins/riftbound/index.test.js (inside or alongside the existing describe blocks โ keep existing tests untouched):
javascript
describe('RiftboundPlugin card transform', () => {
const plugin = new RiftboundPlugin();
const setData = {
id: 'origins',
groupId: 24344,
name: 'Origins',
abbreviation: 'OGN',
publishedOn: '2025-10-31'
};
// Field shapes match real riftbound_products documents (verified against
// shopify_test on 2026-07-15): number "147/219", rarity "Epic", domain "Chaos".
const cardDoc = {
tcgplayerProductId: 683790,
groupId: 24344,
name: 'Baron Nashor',
cleanName: 'Baron Nashor',
imageUrl: 'https://product-images.tcgplayer.com/683790.jpg',
url: 'https://www.tcgplayer.com/product/683790',
rarity: 'Epic',
number: '147/219',
cardType: 'Unit',
domain: 'Chaos',
productType: 'card'
};
describe('normalizeCardNumber', () => {
it('strips the set-total suffix and leading zeros ("036/219" -> "36")', () => {
expect(plugin.normalizeCardNumber('036/219')).toBe('36');
expect(plugin.normalizeCardNumber('147/219')).toBe('147');
});
it('returns empty string for missing input', () => {
expect(plugin.normalizeCardNumber('')).toBe('');
expect(plugin.normalizeCardNumber(null)).toBe('');
});
it('passes through non-numeric bases unchanged', () => {
expect(plugin.normalizeCardNumber('PR-01/10')).toBe('PR-01');
});
});
describe('slugifySetName', () => {
it('slugifies group names the same way Pokemon set ids are built', () => {
expect(plugin.slugifySetName('Origins: Proving Grounds')).toBe('origins-proving-grounds');
expect(plugin.slugifySetName('Origins')).toBe('origins');
});
});
describe('generateHandle / generateSKU', () => {
it('builds a card-level handle with no finish suffix', () => {
expect(plugin.generateHandle('Baron Nashor', setData, '147'))
.toBe('riftbound-origins-147-baron-nashor');
});
it('SKU mirrors the handle', () => {
expect(plugin.generateSKU(setData, '147', 'Baron Nashor'))
.toBe('riftbound-origins-147-baron-nashor');
});
});
describe('validateCard', () => {
it('accepts a real card doc', () => {
expect(plugin.validateCard(cardDoc).valid).toBe(true);
});
it('rejects docs missing productId, number, or name', () => {
expect(plugin.validateCard({ ...cardDoc, tcgplayerProductId: undefined }).valid).toBe(false);
expect(plugin.validateCard({ ...cardDoc, number: '' }).valid).toBe(false);
expect(plugin.validateCard({ ...cardDoc, name: '', cleanName: '' }).valid).toBe(false);
});
});
describe('transformCardToProduct', () => {
it('produces one Shopify-ready doc per card with a variant per finish', () => {
const product = plugin.transformCardToProduct(cardDoc, setData, ['foil', 'normal']);
expect(product.handle).toBe('riftbound-origins-147-baron-nashor');
expect(product.sku).toBe(product.handle);
expect(product.title).toBe('Baron Nashor (147) - Origins (OGN)');
// normal sorts before foil regardless of input order
expect(product.variants.map(v => v.finish)).toEqual(['normal', 'foil']);
expect(product.productOptions).toEqual([{ name: 'Finish', values: ['normal', 'foil'] }]);
expect(product.variants[0].sku).toBe(product.handle);
expect(product.variants[0].price).toBe(0);
});
it('keys sourceCardId on the stringified TCGplayer productId (slug invariant, ยง5.3)', () => {
const product = plugin.transformCardToProduct(cardDoc, setData, ['normal']);
expect(product.sourceCardId).toBe('683790');
expect(product.sourceSet).toBe('origins');
});
it('writes the metafields the collection spec and single-card sync key on', () => {
const product = plugin.transformCardToProduct(cardDoc, setData, ['normal']);
expect(product.metafields.set_name).toBe('Origins');
expect(product.metafields.card_number).toBe('147');
expect(product.metafields.collector_number).toBe('147');
expect(product.metafields.booster_game).toBe('Riftbound TCG');
expect(product.metafields.rarity).toBe('Epic');
expect(product.metafields.domain).toBe('Chaos');
expect(product.metafields.managed_by).toBe('lgs_forge');
// finish is per-variant, never card-level
expect(product.metafields.finish).toBeUndefined();
});
it('returns null for cards with no usable number', () => {
expect(plugin.transformCardToProduct({ ...cardDoc, number: '' }, setData, ['normal'])).toBeNull();
});
});
});- [ ] Step 2: Run tests to verify the new ones fail
Run: npx vitest run server/plugins/riftbound/index.test.js Expected: existing tests PASS; new tests FAIL (plugin.normalizeCardNumber is not a function, and transformCardToProduct / generateHandle throw the "not yet implemented" stub errors)
- [ ] Step 3: Implement the transform methods
In server/plugins/riftbound/index.js:
- Add at the top, below the existing requires:
javascript
const { MANAGED_BY_VALUE } = require('../../config/constants');
// normal sorts before foil so the base product (title/tags/image source) is
// stable across runs โ mirrors updatePokemonData.js's FINISH_SORT_ORDER.
const FINISH_SORT_ORDER = { normal: 0, foil: 1 };- Replace the entire
// --- Sync-only methods...block (the four throwing stubsgetMetafieldDefinitions/transformToProduct/generateSKU/generateHandle) with:
javascript
// --- Card transform (consumed by updateRiftboundCatalog.js Phase 2) ---
/**
* Slugify a TCGCSV group name into the set id used for sourceSet, handles,
* and findSet resolution. Same convention as Pokemon's slugifyName
* (updatePokemonData.js) โ RiftboundSet has no stored id field, so this
* is THE single definition both the importer and findSet share.
*/
slugifySetName(name) {
return String(name || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
/**
* Normalize the TCGCSV extendedData Number ("036/219") -> "36".
* Same normalization as Pokemon's normalizeCardNumber so card_number
* metafields stay consistent across games.
*/
normalizeCardNumber(number) {
if (!number) return '';
const base = String(number).split('/')[0].trim();
if (!base) return '';
if (/^\d+$/.test(base)) return String(parseInt(base, 10));
return base;
}
/**
* SKU mirrors the handle for consistency โ same convention as Pokemon.
*/
generateSKU(setData, number, cardName) {
return this.generateHandle(cardName, setData, number);
}
/**
* Card-level handle โ no finish suffix, because Riftbound products are
* built one-doc-per-card from the start (no per-finish rows to merge,
* unlike Pokemon).
*/
generateHandle(cardName, setData, number) {
const nameSlug = this.normalizeCardName(cardName || 'unknown')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const cardNumber = String(number || '0').replace('/', '-');
return `riftbound-${setData.id}-${cardNumber}-${nameSlug}`;
}
/**
* Validate a RiftboundProduct card doc before transforming.
*/
validateCard(cardDoc) {
const errors = [];
if (!cardDoc.tcgplayerProductId) errors.push('Missing tcgplayerProductId');
if (!cardDoc.number) errors.push('Missing number');
if (!cardDoc.cleanName && !cardDoc.name) errors.push('Missing name');
return { valid: errors.length === 0, errors };
}
/**
* Transform one RiftboundProduct card doc into one Shopify-ready product
* with a Finish variant per available finish.
*
* @param {Object} cardDoc - lean RiftboundProduct document
* @param {Object} setData - { id, groupId, name, abbreviation, publishedOn }
* @param {string[]} finishKeys - available finishes ('normal'/'foil'),
* derived by the caller from the card's latest RiftboundPrice retail keys
* @returns {Object|null} ShopifyRiftboundProductVariant-shaped doc, or null
*/
transformCardToProduct(cardDoc, setData, finishKeys = ['normal']) {
const number = this.normalizeCardNumber(cardDoc.number);
if (!number) return null;
const slugName = this.normalizeCardName(cardDoc.cleanName || cardDoc.name || '');
if (!slugName) return null;
const handle = this.generateHandle(slugName, setData, number);
const displayName = this.normalizeCardName(cardDoc.name || slugName);
const setCode = (setData.abbreviation || '').toUpperCase();
const setLabel = setCode ? `${setData.name} (${setCode})` : setData.name;
const title = `${displayName} (${number}) - ${setLabel}`;
const rarity = cardDoc.rarity || 'Unknown';
// domain is semicolon-separated in TCGCSV extendedData ("Mind;Chaos")
const domains = String(cardDoc.domain || '').split(';').map(d => d.trim()).filter(Boolean);
const tags = ['Riftbound', setData.name, rarity, cardDoc.cardType, ...domains];
const sortedFinishes = [...finishKeys].sort(
(a, b) => (FINISH_SORT_ORDER[a] ?? 99) - (FINISH_SORT_ORDER[b] ?? 99)
);
const variants = sortedFinishes.map(finish => ({
finish,
price: 0, // set dynamically during sync
sku: handle, // shared card-level SKU, matching MTG/Pokemon convention
inventoryQty: 0,
inventoryPolicy: 'deny',
barcode: '',
imageUrl: cardDoc.imageUrl || null
}));
return {
handle,
sku: handle,
title,
body: '',
vendor: 'Riot Games',
type: 'Trading Card',
tags: [...new Set(tags.filter(Boolean))].join(', '),
published: true,
productOptions: [{ name: 'Finish', values: sortedFinishes }],
variants,
variantgrams: 2,
variantrequiresshipping: true,
varianttaxable: true,
imagesrc: cardDoc.imageUrl || '',
imageposition: 1,
imagealttext: title,
metafields: {
booster_game: this.displayName,
card_name: displayName,
card_number: number,
// syncService's single-card filter matches on collector_number โ
// MTG and Pokemon both populate it (PR #283); Riftbound must too.
collector_number: number,
set_name: setData.name,
set_code: setCode,
// publishedOn is a full YYYY-MM-DD string โ the year metafield's
// Shopify definition is type date and rejects bare years.
year: setData.publishedOn || '',
rarity,
card_type: cardDoc.cardType || '',
domain: cardDoc.domain || '',
tcg_identifier: cardDoc.url || '',
managed_by: MANAGED_BY_VALUE
},
sourceSet: setData.id,
// ยง5.3 slug invariant: String(tcgplayerProductId) === RiftboundPrice.slug
sourceCardId: String(cardDoc.tcgplayerProductId),
syncStatus: 'pending'
};
}
/**
* Base-interface alias. Sync-era callers use transformCardToProduct with
* explicit finishKeys; this satisfies BaseGamePlugin's abstract method.
*/
transformToProduct(rawCard, setData) {
return this.transformCardToProduct(rawCard, setData, ['normal']);
}
getMetafieldDefinitions() {
throw new Error('Riftbound: getMetafieldDefinitions not yet implemented (sync enablement follow-up)');
}(Keep getMetafieldDefinitions throwing for now โ it lands in Task 6 with the sync interface. Update the file's header comment to say catalog + transform are implemented and sync follows.)
- [ ] Step 4: Run tests to verify they pass
Run: npx vitest run server/plugins/riftbound/index.test.js Expected: PASS (all existing + all new tests)
- [ ] Step 5: Commit
bash
git add server/plugins/riftbound/index.js server/plugins/riftbound/index.test.js
git commit -m "Add Riftbound card-to-product transform to the game plugin"Task 3: Importer Phase 2 โ write Shopify-ready docs โ
Files:
- Modify:
server/scripts/data-loading/updateRiftboundCatalog.js - Test:
server/scripts/data-loading/updateRiftboundCatalog.test.js(extend existing)
Interfaces:
Consumes:
plugin.transformCardToProduct / validateCard / slugifySetName(Task 2),ShopifyRiftboundProductVariant(Task 1), existing_setDepsDI seam.Produces: exported
loadLatestFinishKeys()andtransformSetsToShopifyProducts(groups);updateRiftboundCatalog()now runs the transform afterupsertProducts. Rawriftbound_sets/riftbound_productswrites are unchanged (catalog browse keeps working).[ ] Step 1: Write the failing tests
Append to server/scripts/data-loading/updateRiftboundCatalog.test.js (match the existing file's _setDeps mocking style; add mocks for the two new dep keys):
javascript
describe('transformSetsToShopifyProducts (Phase 2)', () => {
const groups = [{ groupId: 24344, name: 'Origins', abbreviation: 'OGN', publishedOn: '2025-10-31' }];
const cardDocs = [
{
tcgplayerProductId: 683790, groupId: 24344, name: 'Baron Nashor',
cleanName: 'Baron Nashor', imageUrl: 'https://img/683790.jpg',
url: 'https://tcgplayer.com/product/683790', rarity: 'Epic',
number: '147/219', cardType: 'Unit', domain: 'Chaos', productType: 'card'
},
{
tcgplayerProductId: 684124, groupId: 24344, name: 'Mutated Mouser',
cleanName: 'Mutated Mouser', imageUrl: 'https://img/684124.jpg',
url: 'https://tcgplayer.com/product/684124', rarity: 'Common',
number: '036/219', cardType: 'Unit', domain: 'Calm', productType: 'card'
}
];
function buildDeps({ priceRows }) {
const inserted = [];
return {
deps: {
RiftboundSet: {},
RiftboundProduct: {
find: vi.fn(() => ({ lean: vi.fn(async () => cardDocs) }))
},
RiftboundPrice: {
aggregate: vi.fn(async () => priceRows)
},
ShopifyRiftboundProductVariant: {
find: vi.fn(() => ({ distinct: vi.fn(async () => []) })),
deleteMany: vi.fn(async () => ({})),
insertMany: vi.fn(async (docs) => { inserted.push(...docs); return docs; })
},
httpsGetJson: vi.fn()
},
inserted
};
}
afterEach(() => {
updater._resetDeps();
});
it('derives finish variants from the latest price retail keys', async () => {
// 683790 is foil-only in price data; 684124 has both finishes.
const { deps, inserted } = buildDeps({
priceRows: [
{ _id: '683790', retail: { foil: 16.42 } },
{ _id: '684124', retail: { normal: 0.05, foil: 0.25 } }
]
});
updater._setDeps(deps);
await updater.transformSetsToShopifyProducts(groups);
expect(inserted).toHaveLength(2);
const baron = inserted.find(p => p.sourceCardId === '683790');
const mouser = inserted.find(p => p.sourceCardId === '684124');
expect(baron.variants.map(v => v.finish)).toEqual(['foil']);
expect(mouser.variants.map(v => v.finish)).toEqual(['normal', 'foil']);
});
it('defaults cards with no price doc to a normal-only variant (self-healing)', async () => {
const { deps, inserted } = buildDeps({ priceRows: [] });
updater._setDeps(deps);
await updater.transformSetsToShopifyProducts(groups);
expect(inserted).toHaveLength(2);
for (const p of inserted) {
expect(p.variants.map(v => v.finish)).toEqual(['normal']);
}
});
it('replaces per set keyed on the slugified group name', async () => {
const { deps } = buildDeps({ priceRows: [] });
updater._setDeps(deps);
await updater.transformSetsToShopifyProducts(groups);
expect(deps.ShopifyRiftboundProductVariant.deleteMany)
.toHaveBeenCalledWith({ sourceSet: 'origins' });
expect(deps.RiftboundProduct.find)
.toHaveBeenCalledWith({ groupId: 24344, productType: 'card' });
});
});(updater is however the existing test file imports the module under test โ reuse its import name; add afterEach/vi imports only if not already present.)
- [ ] Step 2: Run tests to verify the new ones fail
Run: npx vitest run server/scripts/data-loading/updateRiftboundCatalog.test.js Expected: existing tests PASS; new tests FAIL (transformSetsToShopifyProducts is not a function)
- [ ] Step 3: Implement Phase 2 in the importer
In server/scripts/data-loading/updateRiftboundCatalog.js:
- Add requires below the existing model requires:
javascript
const RiftboundPrice = require('../../models/RiftboundPrice');
const ShopifyRiftboundProductVariant = require('../../models/ShopifyRiftboundProductVariant');
const RiftboundPlugin = require('../../plugins/riftbound');
const riftboundPlugin = new RiftboundPlugin();- Extend
getDeps()'s default object with the two new models:
javascript
function getDeps() {
return _deps || {
RiftboundSet,
RiftboundProduct,
RiftboundPrice,
ShopifyRiftboundProductVariant,
httpsGetJson
};
}- Add the Phase-2 functions above
updateRiftboundCatalog():
javascript
/**
* Load each card's available finishes from its LATEST price snapshot.
* Returns Map<slug, string[]> where slug = String(tcgplayerProductId).
* $project precedes $sort (Atlas 32MB sort limit โ CLAUDE.md ยง5.6).
*/
async function loadLatestFinishKeys() {
const deps = getDeps();
const rows = await deps.RiftboundPrice.aggregate([
{ $match: { productType: 'card' } },
{ $project: { slug: 1, timestamp: 1, retail: '$paper.tcgplayer.retail' } },
{ $sort: { slug: 1, timestamp: -1 } },
{ $group: { _id: '$slug', retail: { $first: '$retail' } } }
]);
const map = new Map();
for (const row of rows) {
const keys = [];
if (row.retail && typeof row.retail.normal === 'number') keys.push('normal');
if (row.retail && typeof row.retail.foil === 'number') keys.push('foil');
if (keys.length > 0) map.set(row._id, keys);
}
return map;
}
/**
* Phase 2: transform raw card rows into Shopify-ready
* ShopifyRiftboundProductVariant docs โ one per card, a Finish variant per
* available finish. Mirrors updatePokemonData.js Phase 2, minus the
* group-by step (Riftbound is already one row per card).
*
* ยง5.3 invariant: sourceCardId = String(tcgplayerProductId) which equals
* RiftboundPrice.slug (updateRiftboundPrices.js writes slug: String(productId)).
*/
async function transformSetsToShopifyProducts(groups) {
const deps = getDeps();
const finishKeysBySlug = await loadLatestFinishKeys();
const stats = { created: 0, updated: 0, errors: 0, setsProcessed: 0 };
for (const group of groups) {
const setData = {
id: riftboundPlugin.slugifySetName(group.name),
groupId: group.groupId,
name: group.name,
abbreviation: group.abbreviation || '',
publishedOn: group.publishedOn ? String(group.publishedOn).substring(0, 10) : ''
};
if (!setData.id) continue;
const cards = await deps.RiftboundProduct
.find({ groupId: group.groupId, productType: 'card' })
.lean();
const finalProducts = [];
for (const card of cards) {
const validation = riftboundPlugin.validateCard(card);
if (!validation.valid) {
stats.errors++;
continue;
}
// Cards with no price doc yet default to normal-only; the daily
// run adds the foil variant once a foil price appears.
const finishKeys = finishKeysBySlug.get(String(card.tcgplayerProductId)) || ['normal'];
const product = riftboundPlugin.transformCardToProduct(card, setData, finishKeys);
if (!product) {
stats.errors++;
continue;
}
finalProducts.push(product);
}
// Full replace per set, matching updatePokemonData.js's semantics.
const existingHandles = new Set(
await deps.ShopifyRiftboundProductVariant.find({ sourceSet: setData.id }).distinct('handle')
);
try {
await deps.ShopifyRiftboundProductVariant.deleteMany({ sourceSet: setData.id });
if (finalProducts.length > 0) {
const inserted = await deps.ShopifyRiftboundProductVariant.insertMany(finalProducts, { ordered: false });
for (const p of inserted) {
if (existingHandles.has(p.handle)) stats.updated++;
else stats.created++;
}
}
} catch (e) {
log(` โ ${setData.id} replace: ${e.message}`);
stats.errors++;
}
stats.setsProcessed++;
log(` โ
${setData.id.padEnd(32)} ${String(finalProducts.length).padStart(4)} cards`);
}
return stats;
}- Wire into
updateRiftboundCatalog()after theupsertProductssummary lines:
javascript
log(`\n๐ Phase 2: transforming cards into Shopify-ready products...`);
const transformStats = await transformSetsToShopifyProducts(groups);
log(`โ
Shopify-ready products: +${transformStats.created} / ~${transformStats.updated} / !${transformStats.errors}`);- Add
loadLatestFinishKeysandtransformSetsToShopifyProductstomodule.exports.
- [ ] Step 4: Run the full importer test file
Run: npx vitest run server/scripts/data-loading/updateRiftboundCatalog.test.js Expected: PASS (all existing + 3 new tests)
- [ ] Step 5: Run the importer against local data as a smoke test
Run: docker compose up -d then npm run data:update-riftbound-catalog Expected: existing set/product upsert output, then Phase 2 lines per set, exit 0. Spot-check one doc:
bash
node -e "const m=require('mongoose');m.connect('mongodb://localhost:27017/shopify_test').then(async()=>{const M=require('./server/models/ShopifyRiftboundProductVariant');const d=await M.findOne({sourceSet:'origins'}).lean();console.log(JSON.stringify({handle:d.handle,sourceCardId:d.sourceCardId,finishes:d.variants.map(v=>v.finish),set_name:d.metafields.set_name},null,2));await m.disconnect();})"Expected: a real handle like riftbound-origins-<n>-<name>, sourceCardId all digits, finishes drawn from ['normal','foil'].
- [ ] Step 6: Commit
bash
git add server/scripts/data-loading/updateRiftboundCatalog.js server/scripts/data-loading/updateRiftboundCatalog.test.js
git commit -m "Build Shopify-ready Riftbound products during the daily catalog import"Task 4: PR 1 wrap-up โ quality bars + PR โ
Files: none new (verification only)
- [ ] Step 1: Full quality bars
bash
npm test # exit 0
npm run test:coverage # every modified file โฅ70% on all four metrics
npm run lint # exit 0, no new warnings- [ ] Step 2: Game-parity audit
Invoke the game-parity skill on the diff. Expected verdicts: riftbound = the change; mtg = exempt (no MTG field/vocabulary touched); pokemon = exempt (patterns copied FROM pokemon, nothing changed in it). Confirm with: git diff origin/main... --stat shows no server/plugins/mtg/, server/plugins/pokemon/, or MTG/Pokemon model/importer files.
- [ ] Step 3: Grep guards
bash
git diff origin/main... | grep -E "\|\| ?'mtg'|\?\? ?'mtg'" # expect: no matches
git diff origin/main... | grep -i "myshopify" # expect: no matches- [ ] Step 4: Push and open PR 1
bash
git push -u origin claude/riftbound-script-support-1d2eab
gh pr create --title "Build Shopify-ready Riftbound product data during catalog import" --body "$(cat <<'EOF'
## Summary
- New `ShopifyRiftboundProductVariant` model (one product per card, Finish variants) with schema round-trip tests
- Riftbound plugin gains the card transform (`transformCardToProduct`, SKU/handle/number normalization)
- `updateRiftboundCatalog.js` Phase 2 derives each card's finishes from its latest price snapshot and writes Shopify-ready docs
Inert by design: the plugin's sync interface still throws, so sync stays off until the follow-up PR.
Spec: `docs/superpowers/specs/2026-07-15-riftbound-sync-support-design.md` (PR 1 of 3).
## Game parity
- riftbound: the change. mtg/pokemon: exempt โ no shared field, vocabulary, or model touched (patterns copied from pokemon, zero edits to it).
## Identity invariant (ยง5.3)
`sourceCardId = String(tcgplayerProductId)` === `RiftboundPrice.slug` (`updateRiftboundPrices.js:276`).
๐ค Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"PR 2 โ Sync enablement โ
Task 5: Riftbound pricing functions in priceLookupService โ
Files:
- Modify:
server/services/priceLookupService.js - Test:
server/services/priceLookupService.test.js(extend existing)
Interfaces:
Consumes:
RiftboundPricemodel; existingcalculatePriceWithCondition,getPreSalePlaceholderPrice,priceCacheService,DEFAULT_CONFIG(all already in the file).Produces (Task 6 consumes):
updateRiftboundVariantPrices(variants, cardId, rarity, config, options) โ Promise<{variants, isPreSale}>; also exportsgetPricesForRiftboundCard,RIFTBOUND_FINISH_MAP.[ ] Step 1: Write the failing tests
Append to server/services/priceLookupService.test.js (reuse its existing import of the service; check how it imports and mocks โ it uses the module's exports directly):
javascript
describe('Riftbound pricing', () => {
const priceDoc = (retail, ts = '2026-06-19') => ({
productType: 'card',
slug: '683790',
timestamp: new Date(ts),
paper: { tcgplayer: { retail } }
});
describe('updateRiftboundVariantPrices', () => {
it('prices each variant from its finish key via the pre-loaded cache', async () => {
const priceCache = new Map([
['683790', [priceDoc({ normal: 2.00, foil: 16.42 })]]
]);
const variants = [
{ finish: 'normal', price: 0 },
{ finish: 'foil', price: 0 }
];
const { isPreSale } = await priceLookupService.updateRiftboundVariantPrices(
variants, '683790', 'Epic', undefined, { priceCache }
);
expect(isPreSale).toBe(false);
expect(variants[0].price).toBeGreaterThan(0);
expect(variants[1].price).toBeGreaterThan(variants[0].price);
expect(variants[1].marketPrice).toBe(16.42);
});
it('marks cards with no price docs as pre-sale with placeholder prices', async () => {
const priceCache = new Map([['999999', []]]);
const variants = [{ finish: 'normal', price: 0 }];
const { isPreSale } = await priceLookupService.updateRiftboundVariantPrices(
variants, '999999', 'Common', undefined, { priceCache }
);
expect(isPreSale).toBe(true);
expect(variants[0].price).toBeGreaterThan(0); // placeholder, never 0
});
it('falls back through the timeseries to the most recent non-zero price', async () => {
const priceCache = new Map([
['683790', [
priceDoc({ foil: 0 }, '2026-06-19'),
priceDoc({ foil: 12.00 }, '2026-06-18')
]]
]);
const variants = [{ finish: 'foil', price: 0 }];
await priceLookupService.updateRiftboundVariantPrices(
variants, '683790', 'Epic', undefined, { priceCache }
);
expect(variants[0].marketPrice).toBe(12.00);
});
});
describe('RIFTBOUND_FINISH_MAP', () => {
it('maps exactly the finish keys the price importer writes (updateRiftboundPrices.js SUBTYPE_TO_FINISH)', () => {
expect(priceLookupService.RIFTBOUND_FINISH_MAP).toEqual({ normal: 'normal', foil: 'foil' });
});
});
});- [ ] Step 2: Run tests to verify the new ones fail
Run: npx vitest run server/services/priceLookupService.test.js Expected: existing tests PASS; new tests FAIL (updateRiftboundVariantPrices is not a function)
- [ ] Step 3: Implement, mirroring the Pokemon family
In server/services/priceLookupService.js:
Add
const RiftboundPrice = require('../models/RiftboundPrice');next to the existingPokemonPricerequire.Directly below the Pokemon pricing block (after
updatePokemonVariantPrices), add:
javascript
/**
* Riftbound finish type mapping.
* Keys/values match SUBTYPE_TO_FINISH in updateRiftboundPrices.js โ the
* only finishes the importer writes into paper.tcgplayer.retail.
*/
const RIFTBOUND_FINISH_MAP = {
normal: 'normal',
foil: 'foil'
};
/**
* Extract raw price from Riftbound price data for a specific finish.
* Mirrors getRawPokemonPriceForFinish.
*/
function getRawRiftboundPriceForFinish(priceData, finishType) {
if (!priceData || !priceData.paper || !priceData.paper.tcgplayer || !priceData.paper.tcgplayer.retail) {
return { rawPrice: null, priceType: 'normal' };
}
const normalizedFinish = String(finishType || '').toLowerCase().replace(/\s+/g, '');
const priceKey = RIFTBOUND_FINISH_MAP[normalizedFinish] || 'normal';
const rawPrice = priceData.paper.tcgplayer.retail[priceKey];
return { rawPrice, priceType: priceKey };
}
/**
* Full-pipeline price for one finish from one Riftbound price doc.
*/
function getRiftboundPriceForFinish(priceData, finishType, rarity, config = DEFAULT_CONFIG, condition = 'nm') {
const { rawPrice, priceType } = getRawRiftboundPriceForFinish(priceData, finishType);
if (rawPrice === undefined || rawPrice === null) {
return null;
}
return calculatePriceWithCondition(rawPrice, priceType, rarity, condition, config);
}
/**
* Most recent non-null, non-zero price for a finish in Riftbound timeseries.
*/
function findValidRiftboundPriceInTimeseries(priceDocuments, finishType, rarity, config = DEFAULT_CONFIG, condition = 'nm') {
for (const priceData of priceDocuments) {
const price = getRiftboundPriceForFinish(priceData, finishType, rarity, config, condition);
if (price !== null && price > 0) {
return price;
}
}
return null;
}
/**
* Raw-only Riftbound timeseries lookup, mirroring findRawPokemonPriceInTimeseries.
*/
function findRawRiftboundPriceInTimeseries(priceDocuments, finishKey, config = DEFAULT_CONFIG) {
for (const priceData of priceDocuments) {
const { rawPrice, priceType } = getRawRiftboundPriceForFinish(priceData, finishKey);
if (rawPrice !== null && rawPrice > 0) {
return { rawPrice, priceType };
}
}
return { rawPrice: null, priceType: 'normal' };
}
/**
* Get prices for a Riftbound card across finish types.
* cardId is the stringified TCGplayer productId (=== RiftboundPrice.slug).
* Mirrors getPricesForPokemonCard.
*/
async function getPricesForRiftboundCard(cardId, rarity, finishTypes = ['normal', 'foil'], config = DEFAULT_CONFIG, options = {}) {
try {
let priceDocuments;
if (options.priceCache && options.priceCache.has(cardId)) {
priceDocuments = options.priceCache.get(cardId);
} else {
const cacheKey = `riftbound:${cardId}`;
priceDocuments = await priceCacheService.get(cacheKey);
if (!priceDocuments) {
priceDocuments = await RiftboundPrice.find({
slug: cardId,
productType: 'card'
})
.sort({ timestamp: -1 })
.limit(30)
.lean();
await priceCacheService.set(cacheKey, priceDocuments || []);
}
}
if (!priceDocuments || priceDocuments.length === 0) {
logger.warn(`No Riftbound price data found for card ${cardId} - marking as pre-sale with placeholder price`);
const defaultPrices = {};
const placeholderPrice = getPreSalePlaceholderPrice(config);
finishTypes.forEach(finish => {
defaultPrices[finish] = placeholderPrice;
});
defaultPrices._isPreSale = true;
return defaultPrices;
}
const prices = {};
const rawPrices = {};
const cardRarity = rarity ? rarity.toLowerCase() : '';
let hasAnyValidPrice = false;
finishTypes.forEach(finishType => {
const price = findValidRiftboundPriceInTimeseries(priceDocuments, finishType, cardRarity, config);
const { rawPrice } = findRawRiftboundPriceInTimeseries(priceDocuments, finishType, config);
rawPrices[finishType] = rawPrice;
if (price) {
hasAnyValidPrice = true;
prices[finishType] = price;
} else {
prices[finishType] = getPreSalePlaceholderPrice(config);
}
});
prices._rawPrices = rawPrices;
if (!hasAnyValidPrice) {
prices._isPreSale = true;
}
return prices;
} catch (error) {
logger.error(`Error getting Riftbound prices for card ${cardId}`, { error: error.message });
const fallback = { _isPreSale: true };
const placeholderPrice = getPreSalePlaceholderPrice(config);
finishTypes.forEach(finish => {
fallback[finish] = placeholderPrice;
});
return fallback;
}
}
/**
* Update Riftbound variant prices in-place. Mirrors updatePokemonVariantPrices.
*/
async function updateRiftboundVariantPrices(variants, cardId, rarity, config = DEFAULT_CONFIG, options = {}) {
if (!variants || variants.length === 0) {
return { variants, isPreSale: false };
}
const globalMin = (config.minimumPrices && config.minimumPrices.global) || DEFAULT_CONFIG.minimumPrices.global;
const finishTypes = [...new Set(variants.map(v => v.finish || 'normal'))];
const prices = await getPricesForRiftboundCard(cardId, rarity, finishTypes, config, options);
const isPreSale = prices._isPreSale || false;
variants.forEach(variant => {
const finishType = variant.finish || 'normal';
variant.price = prices[finishType] || globalMin;
variant.marketPrice = (prices._rawPrices && prices._rawPrices[finishType]) ?? null;
});
return { variants, isPreSale };
}Before finishing this step: read how getPricesForPokemonCard handles its catch block and getPreSalePlaceholderPrice in the actual file and match that error-path style exactly if it differs from the above.
- Add to
module.exportsunder the Pokemon entries:
javascript
// Riftbound pricing API
getPricesForRiftboundCard,
updateRiftboundVariantPrices,and next to POKEMON_FINISH_MAP:
javascript
RIFTBOUND_FINISH_MAP, // Riftbound finish type to price key mapping- [ ] Step 4: Run tests to verify they pass
Run: npx vitest run server/services/priceLookupService.test.js Expected: PASS (all existing + 4 new tests)
- [ ] Step 5: Commit
bash
git add server/services/priceLookupService.js server/services/priceLookupService.test.js
git commit -m "Price Riftbound card variants through the standard pricing pipeline"Task 6: Riftbound plugin โ full sync interface โ
Files:
- Modify:
server/plugins/riftbound/index.js - Test:
server/plugins/riftbound/index.test.js(extend)
Interfaces:
Consumes:
ShopifyRiftboundProductVariant(Task 1),updateRiftboundVariantPrices(Task 5),RiftboundSet/RiftboundPricemodels,slugifySetName(Task 2).Produces: the complete SYNC INTERFACE โ
getMetafieldDefinitions(),getProductModel(),findSet(setCode) โ { id, groupId, name, code }|null,getSetCollectionSpec(set) โ { title, metafieldKey, condition },getSourceCardId(product),preloadPrices(cardIds) โ Map,applyPricing(product, pricingConfig, priceCache) โ { isPreSale },buildVariantProducts(product) โ array,findExistingProduct(batchService, product),findExistingProductLive(shopifyAPI, product).syncService/syncProcessorconsume these viagetPlugin('riftbound')with zero core edits.[ ] Step 1: Write the failing tests
Append to server/plugins/riftbound/index.test.js (mirror server/plugins/pokemon/index.test.js's structure โ pure/injectable knobs only; findSet/preloadPrices/applyPricing are DB/service-backed and covered by the e2e bar):
javascript
describe('RiftboundPlugin sync interface', () => {
const plugin = new RiftboundPlugin();
describe('parentCollectionSpec', () => {
it('keys the parent collection on booster_game = displayName', () => {
const spec = plugin.parentCollectionSpec;
expect(spec.metafieldKey).toBe('booster_game');
expect(spec.condition).toBe('Riftbound TCG');
});
});
describe('getSetCollectionSpec', () => {
it('keys the set collection on set_name (mirrors Pokemon: name is the guaranteed-unique key)', () => {
const set = { id: 'origins-proving-grounds', name: 'Origins: Proving Grounds', code: 'OGS' };
const spec = plugin.getSetCollectionSpec(set);
expect(spec.metafieldKey).toBe('set_name');
expect(spec.condition).toBe('Origins: Proving Grounds');
expect(spec.title).toBe('Origins: Proving Grounds');
});
});
describe('getMetafieldDefinitions', () => {
it('defines every metafield the transform writes, plus per-variant finish', () => {
const defs = plugin.getMetafieldDefinitions();
const keys = defs.map(d => d.key);
for (const key of ['booster_game', 'card_name', 'card_number', 'collector_number',
'set_name', 'set_code', 'year', 'rarity', 'finish', 'card_type',
'domain', 'tcg_identifier', 'managed_by']) {
expect(keys).toContain(key);
}
expect(defs.every(d => d.namespace === 'custom')).toBe(true);
});
});
describe('getSourceCardId / getCardIdentifiers', () => {
it('joins products to prices via sourceCardId', () => {
expect(plugin.getSourceCardId({ sourceCardId: '683790' })).toBe('683790');
});
});
describe('getProductModel', () => {
it('returns the ShopifyRiftboundProductVariant model', () => {
const Model = plugin.getProductModel();
expect(Model.collection.name).toBe('shopify_riftbound_products_variants');
});
});
describe('buildVariantProducts', () => {
it('maps one entry per finish with display finish labels', () => {
const product = {
sku: 'riftbound-origins-147-baron-nashor',
metafields: { card_name: 'Baron Nashor' },
variants: [
{ finish: 'normal', sku: 'riftbound-origins-147-baron-nashor', price: 2.0, imageUrl: 'https://img/a.png' },
{ finish: 'foil', sku: 'riftbound-origins-147-baron-nashor', price: 16.42, imageUrl: 'https://img/b.png' }
]
};
const variants = plugin.buildVariantProducts(product);
expect(variants).toHaveLength(2);
expect(variants[0].metafields.finish).toBe('Normal');
expect(variants[1].metafields.finish).toBe('Foil');
expect(variants[1].variantprice).toBe(16.42);
});
it('throws when the product has no variants array', () => {
expect(() => plugin.buildVariantProducts({ sku: 's', metafields: {} }))
.toThrow('Product has no variants - variants mode requires variants array');
});
});
describe('findExistingProduct', () => {
it('falls through id -> handle -> sourceCardId SKU -> handle SKU', () => {
const calls = [];
const batchService = {
findProductById: vi.fn(() => null),
findProductByHandleWithVariations: vi.fn(() => null),
findProductBySKU: vi.fn((sku) => { calls.push(sku); return null; })
};
const product = { shopifyProductId: 'gid', handle: 'h', sourceCardId: '683790' };
expect(plugin.findExistingProduct(batchService, product)).toBeNull();
expect(calls).toEqual(['683790', 'h']);
});
});
});- [ ] Step 2: Run tests to verify the new ones fail
Run: npx vitest run server/plugins/riftbound/index.test.js Expected: new tests FAIL (getMetafieldDefinitions throws; getProductModel/buildVariantProducts/findExistingProduct throw the BaseGamePlugin "must be implemented" errors)
- [ ] Step 3: Implement the sync interface
In server/plugins/riftbound/index.js, replace the throwing getMetafieldDefinitions and add the sync section (modeled line-for-line on server/plugins/pokemon/index.js; update the file header comment โ the plugin is no longer "pricing-only scope"):
javascript
getMetafieldDefinitions() {
return [
{ namespace: 'custom', key: 'booster_game', name: 'Game', type: 'single_line_text_field', required: false },
{ namespace: 'custom', key: 'card_name', name: 'Card Name', type: 'single_line_text_field', required: true },
{ namespace: 'custom', key: 'card_number', name: 'Card Number', type: 'single_line_text_field', required: true },
{ namespace: 'custom', key: 'collector_number', name: 'Collector Number', type: 'single_line_text_field', required: false },
{ namespace: 'custom', key: 'set_name', name: 'Set Name', type: 'single_line_text_field', required: true },
{ namespace: 'custom', key: 'set_code', name: 'Set Code', type: 'single_line_text_field', required: true },
{ namespace: 'custom', key: 'year', name: 'Year', type: 'single_line_text_field', required: false },
{ namespace: 'custom', key: 'rarity', name: 'Rarity', type: 'single_line_text_field', required: true },
{ namespace: 'custom', key: 'finish', name: 'Finish', type: 'single_line_text_field', required: true },
{ namespace: 'custom', key: 'card_type', name: 'Card Type', type: 'single_line_text_field', required: false },
{ namespace: 'custom', key: 'domain', name: 'Domain', type: 'single_line_text_field', required: false },
{ namespace: 'custom', key: 'tcg_identifier', name: 'TCG Identifier', type: 'single_line_text_field', required: false },
{ namespace: 'custom', key: 'managed_by', name: 'Managed By', type: 'single_line_text_field', required: false }
];
}
// ===== Sync interface =====
getProductModel() {
return require('../../models/ShopifyRiftboundProductVariant');
}
async findSet(setCode) {
const RiftboundSet = require('../../models/RiftboundSet');
const raw = String(setCode || '').trim();
if (!raw) return null;
// RiftboundSet has no stored slug id โ findSet computes it with the
// same slugifySetName the importer uses, so the returned `id` always
// equals the sourceSet key on products.
const toResult = (doc) => ({
id: this.slugifySetName(doc.name),
groupId: doc.groupId,
name: doc.name,
code: doc.abbreviation || String(doc.groupId)
});
// The catalog API surfaces `code` = abbreviation || String(groupId)
// (see routes/api.js riftbound sets branch) โ resolve both, plus
// exact/slugified names for robustness.
const byAbbrev = await RiftboundSet.find({ abbreviation: { $in: [raw.toUpperCase(), raw] } }).lean();
if (byAbbrev.length === 1) return toResult(byAbbrev[0]);
const byName = await RiftboundSet.findOne({ name: raw }).lean();
if (byName) return toResult(byName);
if (/^\d+$/.test(raw)) {
const byGroupId = await RiftboundSet.findOne({ groupId: parseInt(raw, 10) }).lean();
if (byGroupId) return toResult(byGroupId);
}
// Slugified-name fallback (small collection: 9 sets as of 2026-07)
const normalized = raw.toLowerCase();
const all = await RiftboundSet.find({}).lean();
const bySlug = all.find(doc => this.slugifySetName(doc.name) === normalized);
if (bySlug) return toResult(bySlug);
return null;
}
getSetCollectionSpec(set) {
// Key on set_name, mirroring Pokemon: abbreviations exist for all 9
// current Riftbound sets but nothing guarantees future uniqueness,
// and set_name is stored verbatim on every product's metafields.
return {
title: set.name,
metafieldKey: 'set_name',
condition: set.name
};
}
getSourceCardId(product) {
return product.sourceCardId;
}
async preloadPrices(cardIds) {
if (!cardIds || cardIds.length === 0) return new Map();
const RiftboundPrice = require('../../models/RiftboundPrice');
// $project precedes $sort (CLAUDE.md ยง5.6).
const priceDocs = await RiftboundPrice.aggregate([
{ $match: { slug: { $in: cardIds }, productType: 'card' } },
{ $project: { slug: 1, timestamp: 1, paper: 1, productType: 1 } },
{ $sort: { slug: 1, timestamp: -1 } },
{ $group: { _id: '$slug', prices: { $push: '$$ROOT' } } }
]);
const priceMap = new Map();
priceDocs.forEach(doc => priceMap.set(doc._id, doc.prices));
cardIds.forEach(cardId => {
if (!priceMap.has(cardId)) priceMap.set(cardId, []);
});
return priceMap;
}
async applyPricing(product, pricingConfig, priceCache) {
const { updateRiftboundVariantPrices } = require('../../services/priceLookupService');
if (!product.sourceCardId || !product.variants || product.variants.length === 0) {
return { isPreSale: false };
}
const rarity = product.metafields?.rarity || 'common';
const { isPreSale } = await updateRiftboundVariantPrices(
product.variants, product.sourceCardId, rarity, pricingConfig, { priceCache }
);
return { isPreSale };
}
buildVariantProducts(product) {
if (!product.variants || product.variants.length === 0) {
throw new Error('Product has no variants - variants mode requires variants array');
}
return product.variants.map(v => {
const finishDisplay = this.finishes[v.finish]?.name || v.finish || 'Normal';
return {
metafields: { ...product.metafields, finish: finishDisplay },
variantsku: v.sku,
variantprice: v.price,
variantcompareatprice: v.compareAtPrice,
variantinventorypolicy: v.inventoryPolicy || 'deny',
imageUrl: v.imageUrl || null
};
});
}
findExistingProduct(batchService, product) {
let existing = batchService.findProductById(product.shopifyProductId);
if (!existing) existing = batchService.findProductByHandleWithVariations(product.handle);
if (!existing && product.sourceCardId) {
existing = batchService.findProductBySKU(product.sourceCardId);
}
if (!existing) {
existing = batchService.findProductBySKU(product.handle);
}
return existing || null;
}
async findExistingProductLive(shopifyAPI, product) {
if (product.shopifyProductId) {
const existing = await shopifyAPI.getProductWithVariantsGraphQL(product.shopifyProductId);
if (existing) return existing;
}
let existing = await shopifyAPI.findProductByHandleWithVariations(product.handle);
if (!existing && product.sourceCardId) {
existing = await shopifyAPI.findProductBySKU(product.sourceCardId);
}
if (!existing) {
existing = await shopifyAPI.findProductBySKU(product.handle);
}
return existing || null;
}- [ ] Step 4: Run the plugin tests + full suite
Run: npx vitest run server/plugins/riftbound/index.test.js then npm test Expected: PASS. Specifically confirm no MTG/Pokemon sync test regressed.
- [ ] Step 5: Commit
bash
git add server/plugins/riftbound/index.js server/plugins/riftbound/index.test.js
git commit -m "Enable Shopify sync for Riftbound sets through the plugin sync interface"Task 7: API/client audit for lingering catalog-only gating โ
Files: determined by audit (expected small or empty diff)
- [ ] Step 1: Server audit greps
bash
grep -rn "riftbound" server/services/planService.js server/services/syncService.js server/queues/processors/syncProcessor.js
grep -rn "catalog.only\|catalog-only\|sync methods throw\|not yet implemented" server/ client/src --include=*.js --include=*.jsx -i | grep -vi test | grep -vi node_modules
grep -rn "mtg\|pokemon\|riftbound" server/scripts/setupMetafieldDefinitions.js -iFor each hit: if it blocks or special-cases riftbound sync, fix it (thread game explicitly โ never default); if it's stale copy ("Riftbound is catalog-only"), update the text. setupMetafieldDefinitions.js: if it enumerates games/plugins, confirm riftbound is included via the registry (it should come for free from getAllPlugins() โ if it hardcodes a game list, add riftbound).
- [ ] Step 2: Client audit greps
bash
grep -rn "riftbound" client/src --include=*.jsx --include=*.js -i | grep -vi testExpected from the spec audit: CatalogSinglesPage/CatalogSetBrowser are game-generic already. Fix only: stale tooltips/copy claiming riftbound can't sync. Do NOT touch sealed gating (CatalogSealedPage stays MTG-import-only per spec).
- [ ] Step 3: Run client tests + lint if client touched
Run: npm run test:client && npm run build (skip if zero client files changed) Expected: exit 0
- [ ] Step 4: Commit (only if the audit produced changes)
bash
git add -A
git commit -m "Remove stale catalog-only gating so Riftbound sync is reachable end to end"Task 8: End-to-end sync bar + PR 2 โ
Files: none (verification)
- [ ] Step 1: Quality bars
bash
npm test && npm run test:coverage && npm run lintExpected: exit 0; modified files โฅ70%.
- [ ] Step 2: Game-parity audit
Invoke game-parity on the PR 2 diff. Verdicts to record in the PR: riftbound = the change; pokemon = exempt (mirrored-from, not modified โ confirm git diff origin/main... -- server/plugins/pokemon/ is empty); mtg = exempt (same check).
- [ ] Step 3: End-to-end sync bar (ยง7 / MULTI_GAME_ARCHITECTURE ยง7)
With docker compose up -d, data loaded (npm run data:update-riftbound-catalog, npm run data:update-riftbound-prices), npm run dev, ngrok, and the dev store (ufkes-dev-2.myshopify.com) connected:
POST /api/syncwith body{ "game": "riftbound", "setCodes": ["OGN"], "testMode": true, "testLimit": 2 }- Poll
GET /api/sync/:jobIduntilcompleted. - In Shopify admin: confirm 2 Riftbound products exist, each with Finish variants and non-zero prices (or DRAFT + placeholder if pre-sale), and the "Origins" smart collection contains them (validates the
set_namemetafield โ collection-rule match). - Paste the job result JSON and a screenshot/description of the collection contents into the PR body.
- [ ] Step 4: Push and open PR 2
bash
git push
gh pr create --title "Let merchants sync Riftbound sets to their Shopify stores" --body "$(cat <<'EOF'
## Summary
- Riftbound plugin implements the full SYNC INTERFACE (modeled on Pokemon's) โ sync core untouched, zero game conditionals added
- Riftbound pricing runs through the standard pipeline (`updateRiftboundVariantPrices` mirroring Pokemon's)
- Audit fixes for stale catalog-only gating/copy
Spec: `docs/superpowers/specs/2026-07-15-riftbound-sync-support-design.md` (PR 2 of 3).
## End-to-end evidence
[paste sync job JSON + smart-collection confirmation here]
## Game parity
riftbound: the change. mtg/pokemon: exempt โ `git diff` shows no edits under their plugins/models/importers.
๐ค Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"PR 3 โ The add-new-game playbook โ
Task 9: add-new-game skill โ
Files:
- Create:
.claude/skills/add-new-game/SKILL.md
Interfaces:
Consumes: the refreshed
MULTI_GAME_ARCHITECTURE.md(Task 10 โ write both in the same PR; the skill references doc sections by name).Produces: a phased checklist skill; thin, pointing at the doc (same pattern as
.claude/skills/game-parity/SKILL.md).[ ] Step 1: Write the skill
Create .claude/skills/add-new-game/SKILL.md with exactly this content:
markdown
---
name: add-new-game
description: Use when adding support for a new collectible game (a new TCG, a new server/plugins/ directory, a new TCGCSV category) at ANY stage โ feasibility, catalog ingest, browse, Shopify-ready transform, sync enablement, or sealed. Also use when someone asks "what would it take to add <game>".
---
# Adding a New Collectible Game
## Overview
Games are added in six phases, each independently shippable and each with a
checkable exit bar. Riftbound (PRs from the 2026-07 sync-enablement effort) is
the canonical TCGCSV reference implementation; Pokemon is the reference for
per-finish-row sources. Full architectural detail lives in
`MULTI_GAME_ARCHITECTURE.md` โ this skill is the phase gate sequence.
**Core principle: never advance a phase whose exit bar you haven't run.** Every
literal (finish, rarity, number format, slug) is copied from real upstream
data, never assumed (CLAUDE.md ยง5.4).
## Phase 0 โ Feasibility & identity (no code)
- [ ] Data source identified (TCGCSV category id, or equivalent API) with sets,
cards, and prices available
- [ ] Query REAL upstream rows and record: rarity vocabulary, finish vocabulary,
card-number format, set-name/abbreviation reliability
- [ ] **Identity-key decision, written down**: use the source's native product
id when catalog and prices come from ONE source and share it (Riftbound:
TCGplayer productId); use a composite `setSlug-number` ONLY when the two
sides disagree (Pokemon โ see PR #260 for what misalignment costs)
- [ ] Exit bar: one real catalog document and one real price document, keys
shown string-equal (the ยง5.3 slug invariant), pasted into the design doc
## Phase 1 โ Catalog + price ingest
- [ ] `{Game}Set`, `{Game}Product` (or Shopify-ready direct, see Phase 3),
`{Game}Price` Mongoose models โ every sub-document field declared
field-by-field (ยง5.2)
- [ ] Importers in `server/scripts/data-loading/` with `_setDeps` DI seams and
co-located tests (reference: `updateRiftboundCatalog.js` / `updateRiftboundPrices.js`)
- [ ] npm scripts (`data:update-{game}-catalog`, `data:update-{game}-prices`)
- [ ] Daily GitHub Actions workflows (reference: `update-riftbound-catalog.yml`)
- [ ] Plugin skeleton registered in `server/plugins/index.js`: gameId,
displayName, rarities, finishes, dataSource, priceSource,
getCollectionNames โ sync methods may throw AT THIS PHASE ONLY
- [ ] Exit bar: both importers run clean against local Mongo; collections
populated; `npm test` green
## Phase 2 โ Catalog browse
- [ ] Game branches in `/api/catalog/sets`, `/api/catalog/cards`,
`/api/catalog/sealed` (routes/api.js) โ `$project` before `$sort` in
every aggregation (ยง5.6)
- [ ] `attachCatalogPricing` support (catalogPricingService)
- [ ] Client game registration (StoreContext enabledCatalogs, nav, rarity/type
option lists)
- [ ] Exit bar: sets and cards render in the catalog UI with prices
## Phase 3 โ Shopify-ready transform
- [ ] `Shopify{Game}ProductVariant` model mirroring ShopifyRiftboundProductVariant:
one doc per card, `variants[]` per finish, metafields field-by-field +
schema-shape round-trip test (ยง5.2)
- [ ] Plugin transform: `transformCardToProduct` (or `transformRowToProduct`
for per-finish-row sources), SKU/handle/number normalization
- [ ] Importer Phase 2 writes the Shopify-ready docs; finish variants derived
from PRICE DATA, not assumed (Riftbound: 677 of 1196 priced cards were
foil-only when this was built)
- [ ] Exit bar: importer produces docs whose `sourceCardId` string-equals the
price slug โ assert with a real query
- [ ] Ship it: this phase is INERT (sync still off) and merges safely alone
## Phase 4 โ Sync enablement
- [ ] Pricing functions in priceLookupService (`update{Game}VariantPrices`
family, mirroring Riftbound's)
- [ ] Full SYNC INTERFACE on the plugin (see BaseGamePlugin.js for the method
list; copy Riftbound's implementations, adjust vocabularies)
- [ ] ZERO edits to syncService/syncProcessor/preview/collection creation
(rule 5) โ if you think you need one, the plugin interface is missing a
method; raise that instead
- [ ] Run the game-parity skill; name every plugin mirrored/exempt
- [ ] Exit bar (mandatory, from MULTI_GAME_ARCHITECTURE ยง7):
`POST /api/sync { game, setCodes:[<set>], testMode:true, testLimit:2 }`
succeeds AND the set's smart collection CONTAINS the synced products
(proves the metafield โ collection-rule match)
## Phase 5 โ Sealed (optional)
Requirements live in `MULTI_GAME_ARCHITECTURE.md` ยง "Sealed Products โ
Requirements for game-generic support". Summary gates:
- [ ] Plugin declares `supportsSealed` capability; server route enforces it
(never client-only, ยง5.12)
- [ ] Plugin SEALED INTERFACE: `findSealedProducts`, `transformSealedToProduct`,
`preloadSealedPrices`
- [ ] Sealed identity invariant verified (catalog uuid === sealed price key)
- [ ] `game` field threaded through SealedProduct/SealedProductMSRP (no
defaults, ยง5.5) with backfill migration
- [ ] Exit bar: import one sealed product in testMode โ DRAFT in Shopify with
correct price
## Red flags
| Thought | Reality |
|---|---|
| "Both finishes surely exist" | Riftbound: 677 cards were foil-only. Derive from price data. |
| "I'll key on productId like Riftbound" | Only if catalog AND prices share it. Pokemon couldn't. Check first. |
| "set abbreviation is fine as the collection key" | Pokemon's are empty/duplicated. Key on set_name unless proven unique. |
| "I'll wire sync core to handle this game's quirk" | Rule 5. The quirk belongs in the plugin. |
| "The importer can default game to X" | ยง5.5. No identity defaults, ever. |- [ ] Step 2: Verify the skill loads
Run: ls .claude/skills/add-new-game/SKILL.md and confirm the frontmatter parses (name + description present, valid YAML). Expected: file exists; frontmatter has name and description keys.
- [ ] Step 3: Commit
bash
git add .claude/skills/add-new-game/SKILL.md
git commit -m "Add the add-new-game skill codifying the phased playbook for new games"Task 10: MULTI_GAME_ARCHITECTURE.md refresh โ
Files:
- Modify:
MULTI_GAME_ARCHITECTURE.md
Interfaces:
Consumes: outcomes of PRs 1โ2 (cite real file names); the skill from Task 9 references this doc's section titles โ keep them exactly as written below.
[ ] Step 1: Update the "Checklist: Adding a New Game (Catalog โ Sync)" section
Replace the section's 7 sub-checklists with the Phase 0โ5 structure (same content as the skill's phases, expanded with the doc-level detail the existing checklist already carries โ keep the existing per-item file references, add: the Phase 0 identity-key decision guidance, the "finish variants derived from price data" rule with the Riftbound foil-only numbers, and Riftbound named as the canonical TCGCSV reference). Keep the existing "Checklist: Adding/Changing a Per-Game Field" section untouched.
- [ ] Step 2: Update the Game Comparison table
Replace the Yu-Gi-Oh (future) column with Riftbound (real values, all verified this effort):
markdown
| Feature | MTG | Pokemon | Riftbound |
|---------|-----|---------|-----------|
| **Rarities** | 4 (common โ mythic) | 25 (common โ ace spec rare) | 6 (common โ promo) + literal `None` on some promos |
| **Finishes** | normal/foil/etched | normal/holofoil/reverse holofoil/1st ed. | normal/foil (many cards foil-only) |
| **Data Source** | MTGJSON | TCGCSV (TCGPlayer) | TCGCSV category 89 |
| **Price Source** | TCGPlayer (via MTGJSON) | TCGCSV (TCGPlayer) | TCGCSV (same feed) |
| **Product shape** | one product, `variants[]` per finish | one product per card, `variants[]` per finish | one product per card, `variants[]` per finish |
| **Identity key** | MTGJSON uuid | composite `setId-number` (PR #260) | TCGplayer `productId` (shared by catalog + prices) |
| **Set-collection key** | `set_code` (unique) | `set_name` (abbreviation unreliable) | `set_name` (abbreviations present but uniqueness unguaranteed) |- [ ] Step 3: Replace the "Sealed Products" section
Delete the current 4-line sketch and replace with (title must match what the skill references):
markdown
## Sealed Products โ Requirements for game-generic support
> Status: requirements. MTG has the full lifecycle today; Riftbound has
> browse-only. Implementing another game's sealed support is its own effort โ
> these are the gates it must clear. (Spec: docs/superpowers/specs/2026-07-15-riftbound-sync-support-design.md ยง6.)
**How sealed differs from singles**: singles are a global Shopify-ready
catalog synced per set; sealed is per-store curated inventory
(draft โ review โ approve โ active, the `/sealed-products/*` endpoints) with
MSRP-vs-market pricing.
**Current state**: MTG-only end to end โ `mtg_sets` embedded sealed data โ
`updateSealedProductPrices.js` (hardcoded MTG_CATEGORY_ID = 1) โ per-store
`SealedProduct` workflow โ `SealedProductMSRP` fallback โ per-store
`sealedPricingConfig`. Riftbound sealed rows exist in `riftbound_products`
(`productType: 'sealed'`) and render on CatalogSealedPage browse, but quick-add
is disabled because the endpoints read `mtg_sets`. Neither `SealedProduct` nor
`SealedProductMSRP` has a `game` field.
**Requirements** (each maps to a named failure mode in CLAUDE.md ยง5):
1. **Capability declaration** โ plugin declares `supportsSealed`; client
gating becomes capability-driven AND the server enforces the same check in
the route/Zod layer (ยง5.12).
2. **Plugin SEALED INTERFACE** โ `findSealedProducts(setCode)` abstracting
where the global sealed catalog lives (MTG: `mtg_sets.sealedProduct[]`;
TCGCSV games: `{game}_products` with `productType: 'sealed'`), returning
`{ uuid, name, setCode, category, subtype, imageUrl }`;
`transformSealedToProduct(entry, setData)` (single variant, no Finish
option); `preloadSealedPrices(uuids)`.
3. **Sealed identity invariant (ยง5.3)** โ sealed catalog uuid string-equals
the sealed price key. Riftbound already satisfies it
(`String(tcgplayerProductId)` โ `RiftboundPrice.slug`); document MTG's
mapping from `updateSealedProductPrices.js` before touching it.
4. **Price source per game** โ MTG keeps its dedicated sealed importer;
TCGCSV games already get sealed prices in their existing price importer
(`productType: 'sealed'` docs) โ no new importer.
5. **`game` field threading (ยง5.5)** โ `SealedProduct` and `SealedProductMSRP`
gain a REQUIRED `game` field with no default; unique index becomes
`shop+game+uuid`; explicit backfill migration sets `'mtg'` on existing
docs (a migration script, never a schema default).
6. **MSRP is optional per game** โ fallback chain market โ MSRP โ skip;
`getMSRP(game, productType, setCode)`.
7. **Per-game sealed pricing config** โ `Store.sealedPricingConfig` moves
under per-game keying, following `migratePerGamePricingConfig.js`.
8. **Endpoints game-threaded** โ every `/sealed-products/*` endpoint takes an
explicit Zod-validated `game` and routes through the plugin interface
instead of reading `mtg_sets`.
9. **Verification bar** โ schema round-trip tests for new fields; e2e =
import one sealed product in testMode โ DRAFT in Shopify with correct price.- [ ] Step 4: Build docs check + commit
Run: npm test (doc-only change; confirms nothing imported the doc) and visually confirm the three section titles the skill references exist verbatim.
bash
git add MULTI_GAME_ARCHITECTURE.md
git commit -m "Refresh the new-game checklist with the phased playbook and real sealed requirements"- [ ] Step 5: Push and open PR 3
bash
git push
gh pr create --title "Codify the add-new-game playbook and sealed requirements" --body "$(cat <<'EOF'
## Summary
- New `add-new-game` skill: six phase gates with exit bars, Riftbound as the canonical TCGCSV reference
- MULTI_GAME_ARCHITECTURE.md: phased new-game checklist, Riftbound in the comparison table, and the stale sealed sketch replaced with real game-generic sealed requirements
Spec: `docs/superpowers/specs/2026-07-15-riftbound-sync-support-design.md` (PR 3 of 3).
๐ค Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"Plan Self-Review (completed) โ
- Spec coverage: Section 1 โ Tasks 1โ3; Section 2 โ Tasks 5โ6; Section 3 โ Task 7; Section 4 โ Tasks 4, 8; Section 5 โ Task 9; Section 6 โ Task 10. PR chain matches spec (1: Tasks 1โ4, 2: Tasks 5โ8, 3: Tasks 9โ10).
- Type consistency:
transformCardToProduct(cardDoc, setData, finishKeys)consistent across Tasks 2/3;updateRiftboundVariantPrices(variants, cardId, rarity, config, options)consistent across Tasks 5/6;findSetreturns{ id, groupId, name, code }andidequals the importer'ssourceSet(both viaslugifySetName). - Known judgment points for the implementer: (a) exact import/mock style in the two existing test files โ match what's there; (b)
getPricesForPokemonCard's real catch-block style โ mirror it exactly in the Riftbound copy; (c) Task 7 is an audit โ its diff may legitimately be empty.
