From 1b59d2f927b0be0802fc4aef3c4b3babcf2d716f Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sat, 11 Jul 2026 07:24:26 -0400 Subject: [PATCH] feat(catalog): graduated cosmetic ladder + per-session test-unlock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 — real tiered unlocks (was flat: any credit unlocked skins+iso+3D): - entitlements DEFAULT_CATALOG + migration 025 graduate the ladder — unlock by lifetime credit spend OR subscription tier, whichever first: Fancy (generated-skins) 1 credit / supporter (tier 1) Iso (iso-dungeon) 10 credits / premium (tier 2) 3D (kenney-3d-characters) 25 credits / operator (tier 3) - Migration 025 UPDATEs the already-seeded 024 rows (idempotent). - Tests updated to assert the ladder (1cr -> Fancy only; 10 -> +Iso; 25 -> all). Step 2 — test the premium modes without paying: - renderModes: ?unlock=1 (sticky via localStorage) flips RENDER_MODE_TEST_UNLOCKS for THAT browser only, so Fancy/Iso/3D are all clickable; ?unlock=0 turns it off. Suite 57 suites / 414 tests green. --- html/js/render/renderModes.js | 11 +++++++++-- src/migrations/025_graduate_cosmetic_tiers.sql | 13 +++++++++++++ src/multiplayer/entitlements.js | 6 ++++-- test/catalogEntitlements_ultra.test.js | 13 ++++++++++--- test/directEntryCredit_ultra.test.js | 5 +++-- test/entitlements.test.js | 8 ++++---- test/identityService.test.js | 3 ++- 7 files changed, 45 insertions(+), 14 deletions(-) create mode 100644 src/migrations/025_graduate_cosmetic_tiers.sql diff --git a/html/js/render/renderModes.js b/html/js/render/renderModes.js index 003ed8e..fdc34c2 100644 --- a/html/js/render/renderModes.js +++ b/html/js/render/renderModes.js @@ -16,9 +16,16 @@ ]; RK.entitlements = RK.entitlements || { premium: false, level: 'free', packs: {} }; - // Production QA switch: set to true to temporarily bypass premium render-mode gating for - // testing. Default false — premium modes (Fancy/Iso/3D) gate behind pack entitlements. + // Per-session QA bypass for premium render-mode gating (Fancy/Iso/3D). Default OFF. A tester + // opts IN for their own browser only via ?unlock=1 (persisted) — nobody else is affected: + // ?unlock=1 -> on (sticky) ?unlock=0 -> off RK.RENDER_MODE_TEST_UNLOCKS = false; + try { + var q = (root.location && root.location.search) || ''; + var m = /[?&]unlock=([01])/.exec(q); + if (m) { try { root.localStorage.setItem('rk_unlock_all', m[1]); } catch (_) {} } + RK.RENDER_MODE_TEST_UNLOCKS = (root.localStorage && root.localStorage.getItem('rk_unlock_all') === '1'); + } catch (_) { /* no location/localStorage */ } RK.renderModeTestUnlocks = function () { return RK.RENDER_MODE_TEST_UNLOCKS === true; }; RK.modeMeta = function (id) { diff --git a/src/migrations/025_graduate_cosmetic_tiers.sql b/src/migrations/025_graduate_cosmetic_tiers.sql new file mode 100644 index 0000000..2a94038 --- /dev/null +++ b/src/migrations/025_graduate_cosmetic_tiers.sql @@ -0,0 +1,13 @@ +-- 025: graduate the cosmetic ladder. +-- +-- Migration 024 seeded every premium pack at tier 1 / unlock_min_credits 1, so buying ANY credit +-- unlocked skins + iso + 3D at once (flat, not tiered). Turn it into a real ladder — unlock by +-- lifetime credit spend OR by subscription tier, whichever comes first: +-- Fancy (generated-skins) 1 credit / supporter (tier 1) +-- Iso (iso-dungeon) 10 credits / premium (tier 2) +-- 3D (kenney-3d-characters) 25 credits / operator (tier 3) +-- Idempotent (plain UPDATEs); the free roguelike-interior pack is untouched. + +UPDATE cosmetic_catalog SET tier = 1, unlock_min_credits = 1 WHERE pack_id = 'generated-skins'; +UPDATE cosmetic_catalog SET tier = 2, unlock_min_credits = 10 WHERE pack_id = 'iso-dungeon'; +UPDATE cosmetic_catalog SET tier = 3, unlock_min_credits = 25 WHERE pack_id = 'kenney-3d-characters'; diff --git a/src/multiplayer/entitlements.js b/src/multiplayer/entitlements.js index 6d48f80..f356f73 100644 --- a/src/multiplayer/entitlements.js +++ b/src/multiplayer/entitlements.js @@ -15,17 +15,19 @@ const DEFAULT_CATALOG = Object.freeze({ id: 'roguelike-interior', label: 'Kenney Roguelike Interior', kind: 'render-pack', projection: 'topdown', tier: 0, unlockMinCredits: null, grantOnly: false, premium: false }), + // Graduated ladder — unlock by lifetime credit spend OR by subscription tier, whichever comes + // first: Fancy(skins) at 1 credit / supporter, Iso at 10 / premium, 3D at 25 / operator. 'generated-skins': Object.freeze({ 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', kind: 'render-pack', - projection: 'iso', tier: 1, unlockMinCredits: 1, grantOnly: false, premium: true + projection: 'iso', tier: 2, unlockMinCredits: 10, grantOnly: false, premium: true }), 'kenney-3d-characters': Object.freeze({ id: 'kenney-3d-characters', label: 'Kenney Animated 3D Characters', kind: 'render-pack', - projection: '3d', tier: 1, unlockMinCredits: 1, grantOnly: false, premium: true + projection: '3d', tier: 3, unlockMinCredits: 25, grantOnly: false, premium: true }) }); diff --git a/test/catalogEntitlements_ultra.test.js b/test/catalogEntitlements_ultra.test.js index 2af103e..92d6421 100644 --- a/test/catalogEntitlements_ultra.test.js +++ b/test/catalogEntitlements_ultra.test.js @@ -22,12 +22,19 @@ describe('three-way unlock — default (seed) catalog', () => { expect(s.tier).toBe(0); }); - test('backward-compat: buying any credit unlocks the seed packs (all threshold 1)', () => { + test('graduated ladder: 1 credit unlocks Fancy (skins) only, not Iso or 3D', () => { const s = snapshotForUser({ credits: 5, total_credits_purchased: 1 }); + expect(s.packs['generated-skins']).toBe(true); // threshold 1 + expect(s.packs['iso-dungeon']).toBe(false); // threshold 10 + expect(s.packs['kenney-3d-characters']).toBe(false); // threshold 25 + expect(s.premium).toBe(true); // a premium pack IS unlocked + }); + + test('graduated ladder: 25 credits unlocks the whole ladder', () => { + const s = snapshotForUser({ credits: 0, total_credits_purchased: 25 }); 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', () => { @@ -91,7 +98,7 @@ describe('canUsePack reads the snapshot', () => { 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 }); + const unlocked = snapshotForUser({ total_credits_purchased: 10 }); // iso threshold is 10 expect(Entitlements.canUsePack(unlocked, 'iso-dungeon')).toBe(true); }); }); diff --git a/test/directEntryCredit_ultra.test.js b/test/directEntryCredit_ultra.test.js index 82a2dc6..b7001bc 100644 --- a/test/directEntryCredit_ultra.test.js +++ b/test/directEntryCredit_ultra.test.js @@ -47,9 +47,10 @@ describe('recordDirectEntryPurchase', () => { expect(insert[0]).toMatch(/'direct_entry'/); // audits the purchase leg expect(insert[0]).toMatch(/'game_entry'/); // audits the spend leg - // The seed premium packs (threshold 1) are now unlocked, exactly like a credit purchase. - expect(rec.entitlements.packs['iso-dungeon']).toBe(true); + // A direct entry = 1 credit, which unlocks Fancy (generated-skins, threshold 1) but not the + // pricier Iso (10) / 3D (25) tiers. expect(rec.entitlements.packs['generated-skins']).toBe(true); + expect(rec.entitlements.packs['iso-dungeon']).toBe(false); expect(rec.entitlements.premium).toBe(true); // ...but a direct buyer is still NOT a premium tier. expect(rec.entitlements.level).toBe('free'); diff --git a/test/entitlements.test.js b/test/entitlements.test.js index 72a5d8f..73f8e11 100644 --- a/test/entitlements.test.js +++ b/test/entitlements.test.js @@ -11,14 +11,14 @@ describe('cosmetic entitlements', () => { expect(Entitlements.normalizeAppearance({ avatar: 'kenney-survivor-male' }, entitlements).avatar).toBe('default'); }); - test('any credits purchase unlocks the preview premium packs', () => { + test('graduated ladder: credit spend unlocks packs by threshold', () => { const entitlements = Entitlements.snapshotForUser({ credits: 7, total_credits_purchased: 10 }); expect(entitlements.premium).toBe(true); 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); + expect(entitlements.packs['generated-skins']).toBe(true); // threshold 1 + expect(entitlements.packs['iso-dungeon']).toBe(true); // threshold 10 — just reached + expect(entitlements.packs['kenney-3d-characters']).toBe(false); // threshold 25 — not yet expect(Entitlements.normalizeAppearance({ avatar: 'monero-knight' }, entitlements).avatar).toBe('monero-knight'); }); diff --git a/test/identityService.test.js b/test/identityService.test.js index a681545..9fee30e 100644 --- a/test/identityService.test.js +++ b/test/identityService.test.js @@ -47,7 +47,8 @@ describe('IdentityService', () => { expect(snapshot.appearance).toEqual(db.state.user.appearance); expect(snapshot.entitlements.premium).toBe(true); - expect(snapshot.entitlements.packs['iso-dungeon']).toBe(true); + expect(snapshot.entitlements.packs['generated-skins']).toBe(true); // 5 credits >= threshold 1 + expect(snapshot.entitlements.packs['iso-dungeon']).toBe(false); // < threshold 10 }); test('saves normalized appearance and refreshes the session cache', async () => {