From bdf7f19dcbe19cb8139737c65ac615a5d24eb3cf Mon Sep 17 00:00:00 2001 From: jwinterm Date: Fri, 10 Jul 2026 16:13:35 -0400 Subject: [PATCH] feat(catalog): operator-owned cosmetic catalog + three-way unlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 024 adds a server-authoritative cosmetic_catalog table (seeds today's 4 packs). New CatalogService loads it (cached, falls back to a built-in default when the table is absent). entitlements.snapshotForUser is rewritten to the three-way unlock rule — a pack unlocks via: it's free, an explicit grant, lifetime spend >= its threshold, OR premium tier >= its tier. This kills the old bug where ANY purchase (total_credits_purchased>0 -> level 'credits') unlocked EVERY premium pack, and makes '1 credit -> skin A, 10-pack -> pack B' expressible. Credit-buying no longer auto-promotes to a premium tier. Snapshot now ships a catalog summary to the client (the seed for killing the client-side split-brain next). identityService loads + passes the catalog. +13 tests (catalogEntitlements_ultra), suite 48/343 green. First stone of the P1 foundation (branch feat/credits-catalog-foundation). Next: client consumes the served catalog (retires RK.PACKS), direct payment -> mint 1 credit, then the buy modal reflects the unified model. --- src/migrations/024_cosmetic_catalog.sql | 48 ++++++++ src/multiplayer/entitlements.js | 145 ++++++++++++++++-------- src/network/identityService.js | 23 +++- src/services/catalogService.js | 77 +++++++++++++ test/catalogEntitlements_ultra.test.js | 125 ++++++++++++++++++++ test/entitlements.test.js | 2 +- 6 files changed, 368 insertions(+), 52 deletions(-) create mode 100644 src/migrations/024_cosmetic_catalog.sql create mode 100644 src/services/catalogService.js create mode 100644 test/catalogEntitlements_ultra.test.js diff --git a/src/migrations/024_cosmetic_catalog.sql b/src/migrations/024_cosmetic_catalog.sql new file mode 100644 index 0000000..9be47b6 --- /dev/null +++ b/src/migrations/024_cosmetic_catalog.sql @@ -0,0 +1,48 @@ +-- Migration 024: operator-owned cosmetic catalog (the 10× foundation stone). +-- +-- Replaces the split-brain, hand-synced pack definitions that live in BOTH +-- src/multiplayer/entitlements.js (server PACKS) and +-- html/js/render/assetPacks.js (client RK.PACKS) +-- with a single server-authoritative table the operator owns. The server snapshot and the +-- client renderer both read from this (the client gets it in the entitlement/config payload), +-- so an operator can define a new skin/tile pack, its price, tier, and unlock rule without +-- editing any JS. +-- +-- UNLOCK MODEL (fixes the current "any purchase unlocks EVERY premium pack" short-circuit in +-- entitlements.js snapshotForUser). A user owns a pack if ANY of: +-- 1. an explicit row in user_pack_entitlements (a grant or a direct cosmetic purchase), OR +-- 2. unlock_min_credits IS NOT NULL AND users.total_credits_purchased >= unlock_min_credits, OR +-- 3. the user's tier >= this pack's tier (ordered ladder; 0 = free). +-- This makes "buy 1 credit → unlock skin A, buy the 10-pack → unlock pack B" expressible: +-- skin A: unlock_min_credits = 1 | pack B: unlock_min_credits = 10 (or grant_only + a +-- per-product grant on the 10-pack). Tiers cover blanket "supporter unlocks everything at +-- tier ≤ N" if the operator wants it. + +CREATE TABLE IF NOT EXISTS cosmetic_catalog ( + pack_id VARCHAR(64) PRIMARY KEY, -- 'iso-dungeon', 'generated-skins', … + label VARCHAR(120) NOT NULL, + kind VARCHAR(24) NOT NULL DEFAULT 'render-pack', -- 'render-pack' | 'skin' | 'tile' + projection VARCHAR(24), -- 'topdown' | 'iso' | '3d' | NULL (any) + tier INTEGER NOT NULL DEFAULT 0, -- 0 = free; ordered ladder (1,2,…) + unlock_min_credits BIGINT, -- lifetime-spend threshold; NULL = not credit-unlockable + grant_only BOOLEAN NOT NULL DEFAULT FALSE,-- true = only via explicit grant/purchase + sort_order INTEGER NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT TRUE, + metadata JSONB NOT NULL DEFAULT '{}', -- asset urls, render-mode binding, etc. + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT cosmetic_catalog_tier_nonneg CHECK (tier >= 0), + CONSTRAINT cosmetic_catalog_min_credits_nonneg CHECK (unlock_min_credits IS NULL OR unlock_min_credits >= 0) +); + +CREATE INDEX IF NOT EXISTS idx_cosmetic_catalog_active ON cosmetic_catalog(active, sort_order); + +-- Seed the four packs that exist today so nothing regresses on first migration. These mirror the +-- current hardcoded definitions (roguelike-interior free; the rest premium, credit-unlockable at +-- 1 lifetime credit — matching today's {kind:'credits_purchase', minTotalCreditsPurchased:1}). +INSERT INTO cosmetic_catalog (pack_id, label, kind, projection, tier, unlock_min_credits, grant_only, sort_order) VALUES + ('roguelike-interior', 'Kenney Roguelike Interior', 'render-pack', 'topdown', 0, NULL, FALSE, 0), + ('generated-skins', 'Premium Generated Skins', 'render-pack', 'topdown', 1, 1, FALSE, 1), + ('iso-dungeon', 'Kenney Isometric Dungeon', 'render-pack', 'iso', 1, 1, FALSE, 2), + ('kenney-3d-characters', 'Kenney Animated 3D Chars', 'render-pack', '3d', 1, 1, FALSE, 3) +ON CONFLICT (pack_id) DO NOTHING; diff --git a/src/multiplayer/entitlements.js b/src/multiplayer/entitlements.js index b334041..1efcffc 100644 --- a/src/multiplayer/entitlements.js +++ b/src/multiplayer/entitlements.js @@ -1,35 +1,41 @@ const Appearance = require('./appearance'); -const PACKS = Object.freeze({ +// Built-in default catalog. This is the FALLBACK used when the DB `cosmetic_catalog` table +// (migration 024) isn't available (fresh/partly-migrated dev DBs, unit tests). In production the +// operator-owned DB catalog is loaded by CatalogService and passed into snapshotForUser(); this +// object just mirrors the four seed packs so nothing regresses without the table. +// +// Each entry: { id, label, kind, projection, tier, unlockMinCredits, grantOnly, premium }. +// tier — 0 = free tier; ordered ladder (1,2,3) for premium subscriptions. +// unlockMinCredits — lifetime-spend threshold to unlock, or null (not credit-unlockable). +// grantOnly — only obtainable via an explicit grant/purchase. +// premium — derived convenience flag: true unless the pack is free (no gate at all). +const DEFAULT_CATALOG = Object.freeze({ 'roguelike-interior': Object.freeze({ - id: 'roguelike-interior', - label: 'Kenney Roguelike Interior', - premium: false, - projection: 'topdown' + id: 'roguelike-interior', label: 'Kenney Roguelike Interior', kind: 'render-pack', + projection: 'topdown', tier: 0, unlockMinCredits: null, grantOnly: false, premium: false }), 'generated-skins': Object.freeze({ - id: 'generated-skins', - label: 'Premium Generated Skins', - premium: true, - projection: 'topdown', - unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 } + id: 'generated-skins', label: 'Premium Generated Skins', kind: 'render-pack', + projection: 'topdown', tier: 1, unlockMinCredits: 1, grantOnly: false, premium: true }), 'iso-dungeon': Object.freeze({ - id: 'iso-dungeon', - label: 'Kenney Isometric Dungeon', - premium: true, - projection: 'iso', - unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 } + id: 'iso-dungeon', label: 'Kenney Isometric Dungeon', kind: 'render-pack', + projection: 'iso', tier: 1, unlockMinCredits: 1, grantOnly: false, premium: true }), 'kenney-3d-characters': Object.freeze({ - id: 'kenney-3d-characters', - label: 'Kenney Animated 3D Characters', - premium: true, - projection: '3d', - unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 } + id: 'kenney-3d-characters', label: 'Kenney Animated 3D Characters', kind: 'render-pack', + projection: '3d', tier: 1, unlockMinCredits: 1, grantOnly: false, premium: true }) }); +// Ordered premium-tier ladder. Buying credits does NOT put you on this ladder (that was the old +// bug — any purchase → level 'credits' → every premium pack unlocked). 'credits' maps to tier 0. +const TIER_OF = Object.freeze({ free: 0, credits: 0, supporter: 1, premium: 2, operator: 3 }); + +// Backwards-compatible alias: productGrants.js and older callers validate pack ids against PACKS. +const PACKS = DEFAULT_CATALOG; +// Legacy: the set of premium level names (still referenced by a couple of callers). const PREMIUM_LEVELS = new Set(['credits', 'supporter', 'premium', 'operator']); function asNumber(v) { @@ -37,68 +43,107 @@ function asNumber(v) { return Number.isFinite(n) ? n : 0; } -function normalizePackGrants(grants = []) { +function tierForLevel(level) { + const t = TIER_OF[String(level || '').toLowerCase()]; + return t == null ? 0 : t; +} + +function isFreePack(pack) { + return asNumber(pack.tier) === 0 && (pack.unlockMinCredits == null) && !pack.grantOnly; +} + +function normalizePackGrants(grants = [], catalog = DEFAULT_CATALOG) { const out = {}; if (Array.isArray(grants)) { for (const g of grants) { const id = g && (g.pack_id || g.packId || g.id); - if (id && PACKS[id]) out[id] = true; + if (id && catalog[id]) out[id] = true; } return out; } if (grants && typeof grants === 'object') { for (const id of Object.keys(grants)) { - if (PACKS[id] && grants[id]) out[id] = true; + if (catalog[id] && grants[id]) out[id] = true; } } return out; } +// Only a REAL premium tier (supporter/premium/operator) counts as a level; buying credits does not. function levelForUser(user = {}) { const raw = String(user.premium_level || user.premiumLevel || '').trim().toLowerCase(); - if (raw) return raw; - return asNumber(user.total_credits_purchased || user.totalCreditsPurchased) > 0 ? 'credits' : 'free'; + if (raw && tierForLevel(raw) > 0) return raw; + return 'free'; } -function snapshotForUser(user = {}, packGrants = []) { +// A pack is unlocked for a user if ANY of: it's free, an explicit grant, the user's lifetime +// spend crosses the pack's threshold, or the user's premium tier is >= the pack's tier. +function snapshotForUser(user = {}, packGrants = [], catalog = DEFAULT_CATALOG) { + catalog = catalog && typeof catalog === 'object' ? catalog : DEFAULT_CATALOG; const totalCreditsPurchased = asNumber(user.total_credits_purchased || user.totalCreditsPurchased); const credits = asNumber(user.credits); const level = levelForUser(user); - const explicit = normalizePackGrants(packGrants); - const hasCreditUnlock = totalCreditsPurchased > 0; - const hasPremiumLevel = PREMIUM_LEVELS.has(level); - const packs = {}; + const userTier = tierForLevel(level); + const explicit = normalizePackGrants(packGrants, catalog); - for (const id of Object.keys(PACKS)) { - const pack = PACKS[id]; - if (!pack.premium) { - packs[id] = true; - continue; - } - const unlock = pack.unlock || {}; - const unlockedByCredits = unlock.kind === 'credits_purchase' - && hasCreditUnlock - && totalCreditsPurchased >= (unlock.minTotalCreditsPurchased || 1); - packs[id] = !!explicit[id] || unlockedByCredits || hasPremiumLevel; + const packs = {}; + let anyPremiumUnlocked = false; + for (const id of Object.keys(catalog)) { + const pack = catalog[id]; + const tier = asNumber(pack.tier); + const minCredits = pack.unlockMinCredits; + const free = isFreePack(pack); + const unlocked = free + || !!explicit[id] + || (minCredits != null && totalCreditsPurchased >= asNumber(minCredits)) + || (tier > 0 && userTier >= tier); + packs[id] = unlocked; + if (!free && unlocked) anyPremiumUnlocked = true; } - const premiumPackUnlocked = Object.keys(packs).some(id => PACKS[id].premium && packs[id]); return { - premium: hasCreditUnlock || hasPremiumLevel || premiumPackUnlocked, - level: level === 'free' && hasCreditUnlock ? 'credits' : level, + premium: userTier > 0 || anyPremiumUnlocked, + level, + tier: userTier, credits, totalCreditsPurchased, - packs + packs, + catalog: catalogSummary(catalog) }; } +// Display-facing catalog the client renders (labels/projection/gating) — replaces the hardcoded +// client RK.PACKS so the operator owns the catalog from one place (the DB). +function catalogSummary(catalog = DEFAULT_CATALOG) { + const out = []; + for (const id of Object.keys(catalog)) { + const p = catalog[id]; + out.push({ + id: p.id || id, + label: p.label || id, + kind: p.kind || 'render-pack', + projection: p.projection || null, + tier: asNumber(p.tier), + premium: !isFreePack(p), + unlockMinCredits: p.unlockMinCredits == null ? null : asNumber(p.unlockMinCredits) + }); + } + return out; +} + +// Gating reads the user's computed snapshot (which already contains every catalog pack). Unknown +// packs default to the built-in catalog's free-check (deny premium, allow free). function canUsePack(entitlements = {}, packId) { - const pack = PACKS[packId]; - if (!pack) return false; - if (!pack.premium) return true; + if (!packId) return true; + // A full snapshot's packs map is authoritative (the three-way rule already ran). if (entitlements.packs && Object.prototype.hasOwnProperty.call(entitlements.packs, packId)) { return !!entitlements.packs[packId]; } + // No granular packs map (a coarse/legacy entitlement): free packs are always allowed; else + // fall back to the coarse premium flag. Production entitlements always carry the packs map, + // so this branch never weakens real gating. + const def = DEFAULT_CATALOG[packId]; + if (def && isFreePack(def)) return true; return !!entitlements.premium; } @@ -117,9 +162,13 @@ function normalizeAppearance(input = {}, entitlements = {}) { } module.exports = { + DEFAULT_CATALOG, PACKS, PREMIUM_LEVELS, + TIER_OF, + tierForLevel, snapshotForUser, + catalogSummary, canUsePack, canUseAppearance, normalizeAppearance, diff --git a/src/network/identityService.js b/src/network/identityService.js index 5dd2794..bdb093b 100644 --- a/src/network/identityService.js +++ b/src/network/identityService.js @@ -1,5 +1,6 @@ const Appearance = require('../multiplayer/appearance'); const Entitlements = require('../multiplayer/entitlements'); +const CatalogService = require('../services/catalogService'); function parseAppearance(value) { if (!value) return Appearance.normalizeAppearance('default'); @@ -14,11 +15,26 @@ function parseAppearance(value) { } class IdentityService { - constructor({ db = null, gameModeManager = null, sessionManager = null, debugManager = null } = {}) { + constructor({ db = null, gameModeManager = null, sessionManager = null, debugManager = null, catalogService = null } = {}) { this.db = db || gameModeManager?.db || null; this.gameModeManager = gameModeManager; this.sessionManager = sessionManager; this.debugManager = debugManager; + // Operator-owned cosmetic catalog (DB-backed, cached). Falls back to the built-in default + // when the table is absent, so this never breaks a fresh/partly-migrated DB. + this.catalogService = catalogService || (this.db ? new CatalogService({ db: this.db }) : null); + } + + async _catalog() { + if (!this.catalogService) return undefined; // snapshotForUser falls back to DEFAULT_CATALOG + try { + return await this.catalogService.getCatalog(); + } catch (err) { + if (this.debugManager?.CONSOLE_LOGGING) { + console.warn('[IdentityService] catalog load failed; using default:', err.message); + } + return undefined; + } } async userForSocket(socket) { @@ -56,9 +72,10 @@ class IdentityService { } async entitlementsForUser(user) { - if (!user) return Entitlements.snapshotForUser({}); + const catalog = await this._catalog(); + if (!user) return Entitlements.snapshotForUser({}, [], catalog); const grants = await this.packGrantsForUser(user.id); - return Entitlements.snapshotForUser(user, grants); + return Entitlements.snapshotForUser(user, grants, catalog); } async entitlementsForSocket(socket) { diff --git a/src/services/catalogService.js b/src/services/catalogService.js new file mode 100644 index 0000000..8120e29 --- /dev/null +++ b/src/services/catalogService.js @@ -0,0 +1,77 @@ +const { DEFAULT_CATALOG } = require('../multiplayer/entitlements'); + +/** + * Loads the operator-owned cosmetic catalog (migration 024 `cosmetic_catalog`) — the single + * server-authoritative source for pack definitions + unlock rules, replacing the split-brain + * hardcoded PACKS in entitlements.js (server) and assetPacks.js (client, via the served snapshot). + * + * Cached with a short TTL. Falls back to the built-in DEFAULT_CATALOG when the table is absent + * (fresh/partly-migrated DBs, tests) so nothing regresses without it. + */ +class CatalogService { + constructor({ db = null, ttlMs = 60000 } = {}) { + this.db = db; + this.ttlMs = ttlMs; + this._cache = null; + this._cacheAt = 0; + } + + async getCatalog() { + const now = Date.now(); + if (this._cache && (now - this._cacheAt) < this.ttlMs) return this._cache; + const loaded = await this._load(); + this._cache = loaded; + this._cacheAt = now; + return loaded; + } + + invalidate() { + this._cache = null; + this._cacheAt = 0; + } + + async _load() { + if (!this.db || typeof this.db.query !== 'function') return DEFAULT_CATALOG; + try { + const res = await this.db.query(` + SELECT pack_id, label, kind, projection, tier, unlock_min_credits, grant_only, active, metadata + FROM cosmetic_catalog + WHERE active = TRUE + ORDER BY sort_order, pack_id + `); + const rows = (res && res.rows) || []; + if (rows.length === 0) return DEFAULT_CATALOG; + const out = {}; + for (const r of rows) { + // Defend against non-catalog rows (e.g. a mock/query returning grant rows, which + // have pack_id but no label). Real catalog rows have both NOT-NULL fields. + if (!r || typeof r.pack_id !== 'string' || !r.pack_id || typeof r.label !== 'string' || !r.label) continue; + const tier = Number(r.tier || 0); + const minCredits = r.unlock_min_credits == null ? null : Number(r.unlock_min_credits); + const grantOnly = !!r.grant_only; + const premium = !(tier === 0 && minCredits == null && !grantOnly); + out[r.pack_id] = { + id: r.pack_id, + label: r.label, + kind: r.kind || 'render-pack', + projection: r.projection || null, + tier, + unlockMinCredits: minCredits, + grantOnly, + premium, + metadata: r.metadata || {} + }; + } + if (Object.keys(out).length === 0) return DEFAULT_CATALOG; // no valid catalog rows + return out; + } catch (err) { + // Table missing (fresh/partly-migrated DB) — degrade to the built-in default. + if (err && (err.code === '42P01' || /cosmetic_catalog/i.test(err.message || ''))) { + return DEFAULT_CATALOG; + } + throw err; + } + } +} + +module.exports = CatalogService; diff --git a/test/catalogEntitlements_ultra.test.js b/test/catalogEntitlements_ultra.test.js new file mode 100644 index 0000000..2af103e --- /dev/null +++ b/test/catalogEntitlements_ultra.test.js @@ -0,0 +1,125 @@ +/** + * Operator-owned catalog + three-way unlock model (migration 024). + * + * The old snapshotForUser unlocked EVERY premium pack the moment total_credits_purchased > 0 + * (any purchase → level 'credits' → hasPremiumLevel → all packs). The new rule unlocks a pack via + * exactly one of: it's free · an explicit grant · lifetime spend >= its threshold · tier >= its tier. + */ + +const Entitlements = require('../src/multiplayer/entitlements'); +const CatalogService = require('../src/services/catalogService'); +const { snapshotForUser, DEFAULT_CATALOG } = Entitlements; + +describe('three-way unlock — default (seed) catalog', () => { + test('free pack is always unlocked; premium packs are locked with no spend', () => { + const s = snapshotForUser({ credits: 0, total_credits_purchased: 0 }); + expect(s.packs['roguelike-interior']).toBe(true); // free + expect(s.packs['generated-skins']).toBe(false); // threshold 1, no spend + expect(s.packs['iso-dungeon']).toBe(false); + expect(s.packs['kenney-3d-characters']).toBe(false); + expect(s.premium).toBe(false); + expect(s.level).toBe('free'); + expect(s.tier).toBe(0); + }); + + test('backward-compat: buying any credit unlocks the seed packs (all threshold 1)', () => { + const s = snapshotForUser({ credits: 5, total_credits_purchased: 1 }); + expect(s.packs['generated-skins']).toBe(true); + expect(s.packs['iso-dungeon']).toBe(true); + expect(s.packs['kenney-3d-characters']).toBe(true); + expect(s.premium).toBe(true); + }); + + test('a credit buyer is NOT auto-promoted to a premium tier', () => { + const s = snapshotForUser({ total_credits_purchased: 100 }); + expect(s.level).toBe('free'); // spend gives unlocks, not a tier + expect(s.tier).toBe(0); + }); + + test('explicit grant unlocks a pack with zero spend', () => { + const s = snapshotForUser({ total_credits_purchased: 0 }, [{ pack_id: 'iso-dungeon' }]); + expect(s.packs['iso-dungeon']).toBe(true); + expect(s.packs['generated-skins']).toBe(false); // not granted, not bought + }); +}); + +describe('THE bug fix — differentiated thresholds', () => { + const catalog = { + 'skin-a': { id: 'skin-a', label: 'A', tier: 0, unlockMinCredits: 1, grantOnly: false }, + 'pack-b': { id: 'pack-b', label: 'B', tier: 0, unlockMinCredits: 10, grantOnly: false } + }; + + test('buying below the higher threshold unlocks only the cheaper pack', () => { + const s = snapshotForUser({ total_credits_purchased: 5 }, [], catalog); + expect(s.packs['skin-a']).toBe(true); // spent 5 >= 1 + expect(s.packs['pack-b']).toBe(false); // spent 5 < 10 (old code wrongly unlocked this) + }); + + test('crossing the higher threshold unlocks the bigger pack', () => { + const s = snapshotForUser({ total_credits_purchased: 10 }, [], catalog); + expect(s.packs['skin-a']).toBe(true); + expect(s.packs['pack-b']).toBe(true); + }); +}); + +describe('tier ladder', () => { + const catalog = { + 't2-pack': { id: 't2-pack', label: 'T2', tier: 2, unlockMinCredits: null, grantOnly: false } + }; + test('supporter (tier 1) cannot use a tier-2 pack; premium (tier 2) can', () => { + expect(snapshotForUser({ premium_level: 'supporter' }, [], catalog).packs['t2-pack']).toBe(false); + expect(snapshotForUser({ premium_level: 'premium' }, [], catalog).packs['t2-pack']).toBe(true); + expect(snapshotForUser({ premium_level: 'operator' }, [], catalog).packs['t2-pack']).toBe(true); + }); + test('spend never crosses a tier-only pack', () => { + expect(snapshotForUser({ total_credits_purchased: 9999 }, [], catalog).packs['t2-pack']).toBe(false); + }); +}); + +describe('grant-only packs', () => { + const catalog = { + 'exclusive': { id: 'exclusive', label: 'X', tier: 0, unlockMinCredits: null, grantOnly: true } + }; + test('grant-only is not unlocked by spend or tier — only an explicit grant', () => { + expect(snapshotForUser({ total_credits_purchased: 9999, premium_level: 'operator' }, [], catalog).packs['exclusive']).toBe(false); + expect(snapshotForUser({}, [{ pack_id: 'exclusive' }], catalog).packs['exclusive']).toBe(true); + }); +}); + +describe('canUsePack reads the snapshot', () => { + test('gates on the computed packs map', () => { + const locked = snapshotForUser({ total_credits_purchased: 0 }); + expect(Entitlements.canUsePack(locked, 'roguelike-interior')).toBe(true); // free + expect(Entitlements.canUsePack(locked, 'iso-dungeon')).toBe(false); // locked + const unlocked = snapshotForUser({ total_credits_purchased: 1 }); + expect(Entitlements.canUsePack(unlocked, 'iso-dungeon')).toBe(true); + }); +}); + +describe('CatalogService', () => { + test('falls back to the built-in default when the table is missing', async () => { + const db = { query: async () => { const e = new Error('relation "cosmetic_catalog" does not exist'); e.code = '42P01'; throw e; } }; + const svc = new CatalogService({ db }); + expect(await svc.getCatalog()).toBe(DEFAULT_CATALOG); + }); + + test('maps DB rows into the catalog shape', async () => { + const db = { query: async () => ({ rows: [ + { pack_id: 'skin-a', label: 'A', kind: 'skin', projection: 'topdown', tier: 0, unlock_min_credits: '3', grant_only: false, active: true, metadata: {} }, + { pack_id: 'free-x', label: 'Free', kind: 'render-pack', projection: null, tier: 0, unlock_min_credits: null, grant_only: false, active: true, metadata: {} } + ] }) }; + const cat = await new CatalogService({ db }).getCatalog(); + expect(cat['skin-a'].unlockMinCredits).toBe(3); + expect(cat['skin-a'].premium).toBe(true); + expect(cat['free-x'].premium).toBe(false); + }); + + test('caches within the TTL', async () => { + let calls = 0; + const db = { query: async () => { calls++; return { rows: [] }; } }; + const svc = new CatalogService({ db, ttlMs: 10000 }); + await svc.getCatalog(); + await svc.getCatalog(); + expect(calls).toBe(1); + }); +}); diff --git a/test/entitlements.test.js b/test/entitlements.test.js index 9061b4f..72a5d8f 100644 --- a/test/entitlements.test.js +++ b/test/entitlements.test.js @@ -15,7 +15,7 @@ describe('cosmetic entitlements', () => { const entitlements = Entitlements.snapshotForUser({ credits: 7, total_credits_purchased: 10 }); expect(entitlements.premium).toBe(true); - expect(entitlements.level).toBe('credits'); + expect(entitlements.level).toBe('free'); // spend unlocks packs by threshold, but isn't a premium tier expect(entitlements.packs['generated-skins']).toBe(true); expect(entitlements.packs['iso-dungeon']).toBe(true); expect(entitlements.packs['kenney-3d-characters']).toBe(true);