mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-20 15:48:33 +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,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