Skip to content

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 as x.test.js.
  • Never default game โ€” no || 'mtg' / ?? 'mtg' anywhere (CLAUDE.md ยง5.5).
  • Identity invariant (ยง5.3): sourceCardId = String(RiftboundProduct.tcgplayerProductId), string-equal to RiftboundPrice.slug (updateRiftboundPrices.js:276 writes slug: String(productId)).
  • Finish vocabulary (ยง5.4): 'normal', 'foil' โ€” from SUBTYPE_TO_FINISH in updateRiftboundPrices.js:126-127. Rarity vocabulary observed in shopify_test.riftbound_products on 2026-07-15: Common, Uncommon, Rare, Epic, Showcase, Promo, and None (12 promo cards). Card number format 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/$match before $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 test and npm run lint exit 0 before each commit. Husky pre-commit runs ESLint โ€” never --no-verify.
  • Zero if (game === 'riftbound') added to syncService, 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 main and merge origin/main if ahead (ยง5.10).
  • The game-parity skill 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 (collection shopify_riftbound_products_variants) with variants[] sub-docs (finish/price/sku/inventoryQty/inventoryPolicy/barcode/imageUrl/shopifyVariantId) and metafields sub-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_VALUE from server/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|null where cardDoc is a lean RiftboundProduct doc, setData = { id, groupId, name, abbreviation, publishedOn }, finishKeys is an array of 'normal'|'foil', and the return value matches the ShopifyRiftboundProductVariant shape 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:

  1. 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 };
  1. Replace the entire // --- Sync-only methods... block (the four throwing stubs getMetafieldDefinitions / 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 _setDeps DI seam.

  • Produces: exported loadLatestFinishKeys() and transformSetsToShopifyProducts(groups); updateRiftboundCatalog() now runs the transform after upsertProducts. Raw riftbound_sets/riftbound_products writes 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:

  1. 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();
  1. Extend getDeps()'s default object with the two new models:
javascript
function getDeps() {
    return _deps || {
        RiftboundSet,
        RiftboundProduct,
        RiftboundPrice,
        ShopifyRiftboundProductVariant,
        httpsGetJson
    };
}
  1. 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;
}
  1. Wire into updateRiftboundCatalog() after the upsertProducts summary 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}`);
  1. Add loadLatestFinishKeys and transformSetsToShopifyProducts to module.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: RiftboundPrice model; existing calculatePriceWithCondition, getPreSalePlaceholderPrice, priceCacheService, DEFAULT_CONFIG (all already in the file).

  • Produces (Task 6 consumes): updateRiftboundVariantPrices(variants, cardId, rarity, config, options) โ†’ Promise<{variants, isPreSale}>; also exports getPricesForRiftboundCard, 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:

  1. Add const RiftboundPrice = require('../models/RiftboundPrice'); next to the existing PokemonPrice require.

  2. 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.

  1. Add to module.exports under 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/RiftboundPrice models, 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/syncProcessor consume these via getPlugin('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 -i

For 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 test

Expected 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 lint

Expected: 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:

  1. POST /api/sync with body { "game": "riftbound", "setCodes": ["OGN"], "testMode": true, "testLimit": 2 }
  2. Poll GET /api/sync/:jobId until completed.
  3. 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_name metafield โ‡„ collection-rule match).
  4. 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; findSet returns { id, groupId, name, code } and id equals the importer's sourceSet (both via slugifySetName).
  • 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.