mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 06:41:27 +02:00
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:
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user