mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 14:21:25 +02:00
feat(monetization): lock the cosmetic ladder (loyalty model) + free Original pack
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.
This commit is contained in:
+22
-1
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user