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 () => {