feat(catalog): operator-owned cosmetic catalog + three-way unlock

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.
This commit is contained in:
jwinterm
2026-07-10 16:13:35 -04:00
parent b31a408789
commit bdf7f19dcb
6 changed files with 368 additions and 52 deletions
+77
View File
@@ -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;