From 42ed51af694679fac2ddfdd56f69db2e9db789bc Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 12 Jul 2026 09:52:13 -0400 Subject: [PATCH] feat(monetization): lock the cosmetic ladder (loyalty model) + free Original pack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nails down the premium unlock system per the design pass: - FREE `original` topdown pack (NEW): the game's bare tiles — floor/wall/door/ torch/features only, no furniture, so the free tavern reads plain (the intended "reason to unlock"). Registered in assetPacks (RK.THEMES.original) as the free default topdown pack; plain ASCII stays free (accessibility). - Loyalty ladder (lifetime credits PURCHASED, cumulative, NOT deducted → buying to play also unlocks cosmetics): Interior 1, skins 5, iso 10, +tilepack 20, +iso 40, 3D 50. Each row is data (tilepack OR skin) → operator appends more at any rung. DEFAULT_CATALOG (entitlements.js) + migration 028 + MONETIZATION.md ladder table. - Tests updated to the new thresholds (catalogEntitlements_ultra, directEntryCredit_ultra). Note: this reprices roguelike-interior from free → 1cr, so once deployed the free tavern becomes bare (by design). Deploy is bundled with the single-player render-kit migration so the free-tier change lands with its payoff. Suite 61/432 green. --- docs/MONETIZATION.md | 23 +++++++++++++++- html/js/render/assetPacks.js | 20 ++++++++++++++ src/migrations/028_cosmetic_ladder.sql | 16 +++++++++++ src/multiplayer/entitlements.js | 37 ++++++++++++++++++-------- test/catalogEntitlements_ultra.test.js | 21 +++++++++------ test/directEntryCredit_ultra.test.js | 7 ++--- 6 files changed, 101 insertions(+), 23 deletions(-) create mode 100644 src/migrations/028_cosmetic_ladder.sql diff --git a/docs/MONETIZATION.md b/docs/MONETIZATION.md index e1fda88..12d691b 100644 --- a/docs/MONETIZATION.md +++ b/docs/MONETIZATION.md @@ -27,7 +27,7 @@ owns a parallel catalog. ``` 1. it's free (tier 0, no gate) 2. an explicit grant (products → grants) -3. lifetime spend ≥ the pack's unlock_min_credits threshold +3. lifetime credits PURCHASED ≥ the pack's unlock_min_credits threshold 4. the user's premium TIER ≥ the pack's tier ``` @@ -35,6 +35,27 @@ owns a parallel catalog. **Buying credits does NOT put you on the premium ladder** (`credits` → tier 0) — that was the old bug where any purchase unlocked every premium pack. +### The ladder (loyalty model) + +Clause 3 keys off **lifetime credits _purchased_**, cumulatively — it is **not deducted**, so buying credits +to *play* also walks you up the cosmetic ladder (cosmetics never compete with the core loop). Each catalog +row is just data — a **tilepack OR a char-skin**, at any credit rung — so the operator adds more of either by +appending a row (+ assets + `registerPack` for a client pack). This IS the "infinite packs" system. + +| Rung (lifetime credits) | Unlocks | +|---|---| +| **free** | `original` bare tiles (no furniture → the "ugly tavern", by design) · plain ASCII | +| 1 | `roguelike-interior` (first premium tilepack) | +| 5 | `generated-skins` (character skins) | +| 10 | `iso-dungeon` — unlocks the **Iso** technique | +| 20 | `roguelike-dungeon` (another tilepack) | +| 40 | `iso-medieval` (second iso pack) | +| 50 | `kenney-3d-characters` — unlocks **3D** | + +A render **technique** (Iso/3D) is available once the user has *any* unlocked pack for its projection, so the +cheapest pack in a projection is effectively that technique's gate. Plain ASCII stays free (accessibility). +Source of truth: `cosmetic_catalog` table (migration 028) mirrored by `DEFAULT_CATALOG` in `entitlements.js`. + ## Premium subscription → cosmetics `levelForUser(user)` returns the **higher** of the stored `premium_level` and an active diff --git a/html/js/render/assetPacks.js b/html/js/render/assetPacks.js index 67a2555..e2116eb 100644 --- a/html/js/render/assetPacks.js +++ b/html/js/render/assetPacks.js @@ -130,6 +130,25 @@ }; RK.THEMES = { + // FREE baseline pack — the game's original bare tiles. Only the essentials are mapped + // (floor/wall/door/torch/features); furniture kinds (bar/keg/table/chair/…) are absent, so + // they fall back to floor and the free tavern reads plain — the intended "reason to unlock". + 'original': { + id: 'original', + label: 'Original Tiles', + tileset: { url: 'assets/kenney/roguelikeSheet.png', tile: 16, spacing: 1 }, + tiles: { + floor: [5, 2], // wood floor + floor2: [6, 2], // stone accent + wall: [13, 12], // masonry + door: [37, 9], + entrance: [37, 9], + exit: [37, 9], + treasure: [42, 15], + torch: [50, 10] + // No furniture kinds → bar/keg/table/chair/shelf/barrel/crate/rug fall back to floor. + } + }, 'roguelike-interior': { id: 'roguelike-interior', label: 'Kenney Roguelike Interior', @@ -387,6 +406,7 @@ // projection is entitlement-gated + user-selectable. Adding a pack later = registerPack(...) + // assets + a catalog row. Backward-compatible: one pack per projection resolves to these. if (RK.registerPack) { + RK.registerPack({ id: 'original', label: 'Original Tiles', projection: 'topdown', kind: 'tiles', assets: RK.THEMES['original'] }); RK.registerPack({ id: 'roguelike-interior', label: 'Roguelike Interior', projection: 'topdown', kind: 'tiles', assets: RK.THEMES['roguelike-interior'] }); RK.registerPack({ id: 'roguelike-dungeon', label: 'Roguelike Dungeon', projection: 'topdown', kind: 'tiles', assets: RK.THEMES['roguelike-dungeon'] }); RK.registerPack({ id: 'iso-dungeon', label: 'Isometric Dungeon', projection: 'iso', kind: 'tiles', assets: RK.isoAssets }); diff --git a/src/migrations/028_cosmetic_ladder.sql b/src/migrations/028_cosmetic_ladder.sql new file mode 100644 index 0000000..058c611 --- /dev/null +++ b/src/migrations/028_cosmetic_ladder.sql @@ -0,0 +1,16 @@ +-- 028: lock the cosmetic ladder (loyalty model — unlock by lifetime credits purchased, cumulative). +-- Adds the FREE `original` bare-tiles baseline and reprices the ladder: Interior 1, skins 5, iso 10, +-- +tilepack 20, +iso 40, 3D 50. Mirrors DEFAULT_CATALOG in src/multiplayer/entitlements.js. +-- Idempotent. See docs/MONETIZATION.md. +INSERT INTO cosmetic_catalog (pack_id, label, kind, projection, tier, unlock_min_credits, grant_only, active, sort_order, metadata) VALUES + ('original', 'Original Tiles', 'render-pack', 'topdown', 0, NULL, false, true, 0, '{}'::jsonb), + ('roguelike-interior', 'Kenney Roguelike Interior', 'render-pack', 'topdown', 1, 1, false, true, 10, '{}'::jsonb), + ('generated-skins', 'Premium Character Skins', 'render-pack', 'topdown', 1, 5, false, true, 15, '{}'::jsonb), + ('iso-dungeon', 'Kenney Isometric Dungeon', 'render-pack', 'iso', 2, 10, false, true, 20, '{}'::jsonb), + ('roguelike-dungeon', 'Roguelike Dungeon (Tiles)', 'render-pack', 'topdown', 2, 20, false, true, 25, '{}'::jsonb), + ('iso-medieval', 'Medieval Town (Isometric)', 'render-pack', 'iso', 3, 40, false, true, 30, '{}'::jsonb), + ('kenney-3d-characters', 'Kenney Animated 3D Characters', 'render-pack', '3d', 3, 50, false, true, 40, '{}'::jsonb) +ON CONFLICT (pack_id) DO UPDATE + SET label = EXCLUDED.label, kind = EXCLUDED.kind, projection = EXCLUDED.projection, tier = EXCLUDED.tier, + unlock_min_credits = EXCLUDED.unlock_min_credits, grant_only = EXCLUDED.grant_only, + active = true, sort_order = EXCLUDED.sort_order; diff --git a/src/multiplayer/entitlements.js b/src/multiplayer/entitlements.js index 656afff..f7a59c0 100644 --- a/src/multiplayer/entitlements.js +++ b/src/multiplayer/entitlements.js @@ -10,32 +10,47 @@ const Appearance = require('./appearance'); // 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). +// Cosmetic ladder. Unlock by LIFETIME CREDITS PURCHASED (cumulative — spend more, unlock more; NOT +// deducted, so buying credits to play also walks you up the ladder) OR by subscription tier. Each +// row is just data — a tilepack OR a char-skin, at any credit rung — so the operator adds more of +// either by appending a row (+ assets + registerPack for a client pack). See docs/MONETIZATION.md. +// +// free original tiles (bare — no furniture, the "ugly tavern") + plain ASCII (in renderModes) +// 1 Roguelike Interior (first premium tilepack) +// 5 Character skins +// 10 Iso technique (first iso pack) 20 another tilepack +// 40 second iso pack 50 3D technique const DEFAULT_CATALOG = Object.freeze({ - 'roguelike-interior': Object.freeze({ - id: 'roguelike-interior', label: 'Kenney Roguelike Interior', kind: 'render-pack', + // FREE pleb baseline — the game's original bare tiles. Furniture kinds fall back to floor, so + // the free tavern is pointedly plain (just floor + walls + players/monsters). By design: it's + // the reason to unlock the richer packs. + 'original': Object.freeze({ + id: 'original', label: 'Original Tiles', 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', + 'roguelike-interior': Object.freeze({ + id: 'roguelike-interior', label: 'Kenney Roguelike Interior', kind: 'render-pack', projection: 'topdown', tier: 1, unlockMinCredits: 1, grantOnly: false, premium: true }), - 'roguelike-dungeon': Object.freeze({ - id: 'roguelike-dungeon', label: 'Roguelike Dungeon (Tiles)', kind: 'render-pack', - projection: 'topdown', tier: 1, unlockMinCredits: 1, grantOnly: false, premium: true + 'generated-skins': Object.freeze({ + id: 'generated-skins', label: 'Premium Character Skins', kind: 'render-pack', + projection: 'topdown', tier: 1, unlockMinCredits: 5, grantOnly: false, premium: true }), 'iso-dungeon': Object.freeze({ id: 'iso-dungeon', label: 'Kenney Isometric Dungeon', kind: 'render-pack', projection: 'iso', tier: 2, unlockMinCredits: 10, grantOnly: false, premium: true }), + 'roguelike-dungeon': Object.freeze({ + id: 'roguelike-dungeon', label: 'Roguelike Dungeon (Tiles)', kind: 'render-pack', + projection: 'topdown', tier: 2, unlockMinCredits: 20, grantOnly: false, premium: true + }), 'iso-medieval': Object.freeze({ id: 'iso-medieval', label: 'Medieval Town (Isometric)', kind: 'render-pack', - projection: 'iso', tier: 2, unlockMinCredits: 15, grantOnly: false, premium: true + projection: 'iso', tier: 3, unlockMinCredits: 40, grantOnly: false, premium: true }), 'kenney-3d-characters': Object.freeze({ id: 'kenney-3d-characters', label: 'Kenney Animated 3D Characters', kind: 'render-pack', - projection: '3d', tier: 3, unlockMinCredits: 25, grantOnly: false, premium: true + projection: '3d', tier: 3, unlockMinCredits: 50, grantOnly: false, premium: true }) }); diff --git a/test/catalogEntitlements_ultra.test.js b/test/catalogEntitlements_ultra.test.js index 92d6421..e44c909 100644 --- a/test/catalogEntitlements_ultra.test.js +++ b/test/catalogEntitlements_ultra.test.js @@ -13,8 +13,9 @@ 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['original']).toBe(true); // free baseline + expect(s.packs['roguelike-interior']).toBe(false); // now 1-credit premium + expect(s.packs['generated-skins']).toBe(false); // threshold 5, no spend expect(s.packs['iso-dungeon']).toBe(false); expect(s.packs['kenney-3d-characters']).toBe(false); expect(s.premium).toBe(false); @@ -22,18 +23,22 @@ describe('three-way unlock — default (seed) catalog', () => { expect(s.tier).toBe(0); }); - test('graduated ladder: 1 credit unlocks Fancy (skins) only, not Iso or 3D', () => { + test('graduated ladder: 1 credit unlocks the first tilepack only, not skins/iso/3D', () => { const s = snapshotForUser({ credits: 5, total_credits_purchased: 1 }); - expect(s.packs['generated-skins']).toBe(true); // threshold 1 + expect(s.packs['roguelike-interior']).toBe(true); // threshold 1 + expect(s.packs['generated-skins']).toBe(false); // threshold 5 expect(s.packs['iso-dungeon']).toBe(false); // threshold 10 - expect(s.packs['kenney-3d-characters']).toBe(false); // threshold 25 + expect(s.packs['kenney-3d-characters']).toBe(false); // threshold 50 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 }); + test('graduated ladder: 50 credits unlocks the whole ladder', () => { + const s = snapshotForUser({ credits: 0, total_credits_purchased: 50 }); + expect(s.packs['roguelike-interior']).toBe(true); expect(s.packs['generated-skins']).toBe(true); expect(s.packs['iso-dungeon']).toBe(true); + expect(s.packs['roguelike-dungeon']).toBe(true); + expect(s.packs['iso-medieval']).toBe(true); expect(s.packs['kenney-3d-characters']).toBe(true); }); @@ -96,7 +101,7 @@ describe('grant-only packs', () => { 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, 'original')).toBe(true); // free expect(Entitlements.canUsePack(locked, 'iso-dungeon')).toBe(false); // locked 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 b7001bc..6448240 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 - // 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); + // A direct entry = 1 credit, which unlocks the first tilepack (roguelike-interior, threshold + // 1) but not skins (5) / Iso (10) / 3D (50). + expect(rec.entitlements.packs['roguelike-interior']).toBe(true); + expect(rec.entitlements.packs['generated-skins']).toBe(false); expect(rec.entitlements.packs['iso-dungeon']).toBe(false); expect(rec.entitlements.premium).toBe(true); // ...but a direct buyer is still NOT a premium tier.