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:
jwinterm
2026-07-10 16:13:35 -04:00
parent b31a408789
commit bdf7f19dcb
6 changed files with 368 additions and 52 deletions
+48
View File
@@ -0,0 +1,48 @@
-- Migration 024: operator-owned cosmetic catalog (the 10× foundation stone).
--
-- Replaces the split-brain, hand-synced pack definitions that live in BOTH
-- src/multiplayer/entitlements.js (server PACKS) and
-- html/js/render/assetPacks.js (client RK.PACKS)
-- with a single server-authoritative table the operator owns. The server snapshot and the
-- client renderer both read from this (the client gets it in the entitlement/config payload),
-- so an operator can define a new skin/tile pack, its price, tier, and unlock rule without
-- editing any JS.
--
-- UNLOCK MODEL (fixes the current "any purchase unlocks EVERY premium pack" short-circuit in
-- entitlements.js snapshotForUser). A user owns a pack if ANY of:
-- 1. an explicit row in user_pack_entitlements (a grant or a direct cosmetic purchase), OR
-- 2. unlock_min_credits IS NOT NULL AND users.total_credits_purchased >= unlock_min_credits, OR
-- 3. the user's tier >= this pack's tier (ordered ladder; 0 = free).
-- This makes "buy 1 credit → unlock skin A, buy the 10-pack → unlock pack B" expressible:
-- skin A: unlock_min_credits = 1 | pack B: unlock_min_credits = 10 (or grant_only + a
-- per-product grant on the 10-pack). Tiers cover blanket "supporter unlocks everything at
-- tier ≤ N" if the operator wants it.
CREATE TABLE IF NOT EXISTS cosmetic_catalog (
pack_id VARCHAR(64) PRIMARY KEY, -- 'iso-dungeon', 'generated-skins', …
label VARCHAR(120) NOT NULL,
kind VARCHAR(24) NOT NULL DEFAULT 'render-pack', -- 'render-pack' | 'skin' | 'tile'
projection VARCHAR(24), -- 'topdown' | 'iso' | '3d' | NULL (any)
tier INTEGER NOT NULL DEFAULT 0, -- 0 = free; ordered ladder (1,2,…)
unlock_min_credits BIGINT, -- lifetime-spend threshold; NULL = not credit-unlockable
grant_only BOOLEAN NOT NULL DEFAULT FALSE,-- true = only via explicit grant/purchase
sort_order INTEGER NOT NULL DEFAULT 0,
active BOOLEAN NOT NULL DEFAULT TRUE,
metadata JSONB NOT NULL DEFAULT '{}', -- asset urls, render-mode binding, etc.
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT cosmetic_catalog_tier_nonneg CHECK (tier >= 0),
CONSTRAINT cosmetic_catalog_min_credits_nonneg CHECK (unlock_min_credits IS NULL OR unlock_min_credits >= 0)
);
CREATE INDEX IF NOT EXISTS idx_cosmetic_catalog_active ON cosmetic_catalog(active, sort_order);
-- Seed the four packs that exist today so nothing regresses on first migration. These mirror the
-- current hardcoded definitions (roguelike-interior free; the rest premium, credit-unlockable at
-- 1 lifetime credit — matching today's {kind:'credits_purchase', minTotalCreditsPurchased:1}).
INSERT INTO cosmetic_catalog (pack_id, label, kind, projection, tier, unlock_min_credits, grant_only, sort_order) VALUES
('roguelike-interior', 'Kenney Roguelike Interior', 'render-pack', 'topdown', 0, NULL, FALSE, 0),
('generated-skins', 'Premium Generated Skins', 'render-pack', 'topdown', 1, 1, FALSE, 1),
('iso-dungeon', 'Kenney Isometric Dungeon', 'render-pack', 'iso', 1, 1, FALSE, 2),
('kenney-3d-characters', 'Kenney Animated 3D Chars', 'render-pack', '3d', 1, 1, FALSE, 3)
ON CONFLICT (pack_id) DO NOTHING;
+97 -48
View File
@@ -1,35 +1,41 @@
const Appearance = require('./appearance');
const PACKS = Object.freeze({
// Built-in default catalog. This is the FALLBACK used when the DB `cosmetic_catalog` table
// (migration 024) isn't available (fresh/partly-migrated dev DBs, unit tests). In production the
// operator-owned DB catalog is loaded by CatalogService and passed into snapshotForUser(); this
// object just mirrors the four seed packs so nothing regresses without the table.
//
// Each entry: { id, label, kind, projection, tier, unlockMinCredits, grantOnly, premium }.
// tier — 0 = free tier; ordered ladder (1,2,3) for premium subscriptions.
// 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).
const DEFAULT_CATALOG = Object.freeze({
'roguelike-interior': Object.freeze({
id: 'roguelike-interior',
label: 'Kenney Roguelike Interior',
premium: false,
projection: 'topdown'
id: 'roguelike-interior', label: 'Kenney Roguelike Interior', kind: 'render-pack',
projection: 'topdown', tier: 0, unlockMinCredits: null, grantOnly: false, premium: false
}),
'generated-skins': Object.freeze({
id: 'generated-skins',
label: 'Premium Generated Skins',
premium: true,
projection: 'topdown',
unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 }
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',
premium: true,
projection: 'iso',
unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 }
id: 'iso-dungeon', label: 'Kenney Isometric Dungeon', kind: 'render-pack',
projection: 'iso', tier: 1, unlockMinCredits: 1, grantOnly: false, premium: true
}),
'kenney-3d-characters': Object.freeze({
id: 'kenney-3d-characters',
label: 'Kenney Animated 3D Characters',
premium: true,
projection: '3d',
unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 }
id: 'kenney-3d-characters', label: 'Kenney Animated 3D Characters', kind: 'render-pack',
projection: '3d', tier: 1, unlockMinCredits: 1, grantOnly: false, premium: true
})
});
// Ordered premium-tier ladder. Buying credits does NOT put you on this ladder (that was the old
// bug — any purchase → level 'credits' → every premium pack unlocked). 'credits' maps to tier 0.
const TIER_OF = Object.freeze({ free: 0, credits: 0, supporter: 1, premium: 2, operator: 3 });
// Backwards-compatible alias: productGrants.js and older callers validate pack ids against PACKS.
const PACKS = DEFAULT_CATALOG;
// Legacy: the set of premium level names (still referenced by a couple of callers).
const PREMIUM_LEVELS = new Set(['credits', 'supporter', 'premium', 'operator']);
function asNumber(v) {
@@ -37,68 +43,107 @@ function asNumber(v) {
return Number.isFinite(n) ? n : 0;
}
function normalizePackGrants(grants = []) {
function tierForLevel(level) {
const t = TIER_OF[String(level || '').toLowerCase()];
return t == null ? 0 : t;
}
function isFreePack(pack) {
return asNumber(pack.tier) === 0 && (pack.unlockMinCredits == null) && !pack.grantOnly;
}
function normalizePackGrants(grants = [], catalog = DEFAULT_CATALOG) {
const out = {};
if (Array.isArray(grants)) {
for (const g of grants) {
const id = g && (g.pack_id || g.packId || g.id);
if (id && PACKS[id]) out[id] = true;
if (id && catalog[id]) out[id] = true;
}
return out;
}
if (grants && typeof grants === 'object') {
for (const id of Object.keys(grants)) {
if (PACKS[id] && grants[id]) out[id] = true;
if (catalog[id] && grants[id]) out[id] = true;
}
}
return out;
}
// Only a REAL premium tier (supporter/premium/operator) counts as a level; buying credits does not.
function levelForUser(user = {}) {
const raw = String(user.premium_level || user.premiumLevel || '').trim().toLowerCase();
if (raw) return raw;
return asNumber(user.total_credits_purchased || user.totalCreditsPurchased) > 0 ? 'credits' : 'free';
if (raw && tierForLevel(raw) > 0) return raw;
return 'free';
}
function snapshotForUser(user = {}, packGrants = []) {
// A pack is unlocked for a user if ANY of: it's free, an explicit grant, the user's lifetime
// spend crosses the pack's threshold, or the user's premium tier is >= the pack's tier.
function snapshotForUser(user = {}, packGrants = [], catalog = DEFAULT_CATALOG) {
catalog = catalog && typeof catalog === 'object' ? catalog : DEFAULT_CATALOG;
const totalCreditsPurchased = asNumber(user.total_credits_purchased || user.totalCreditsPurchased);
const credits = asNumber(user.credits);
const level = levelForUser(user);
const explicit = normalizePackGrants(packGrants);
const hasCreditUnlock = totalCreditsPurchased > 0;
const hasPremiumLevel = PREMIUM_LEVELS.has(level);
const packs = {};
const userTier = tierForLevel(level);
const explicit = normalizePackGrants(packGrants, catalog);
for (const id of Object.keys(PACKS)) {
const pack = PACKS[id];
if (!pack.premium) {
packs[id] = true;
continue;
}
const unlock = pack.unlock || {};
const unlockedByCredits = unlock.kind === 'credits_purchase'
&& hasCreditUnlock
&& totalCreditsPurchased >= (unlock.minTotalCreditsPurchased || 1);
packs[id] = !!explicit[id] || unlockedByCredits || hasPremiumLevel;
const packs = {};
let anyPremiumUnlocked = false;
for (const id of Object.keys(catalog)) {
const pack = catalog[id];
const tier = asNumber(pack.tier);
const minCredits = pack.unlockMinCredits;
const free = isFreePack(pack);
const unlocked = free
|| !!explicit[id]
|| (minCredits != null && totalCreditsPurchased >= asNumber(minCredits))
|| (tier > 0 && userTier >= tier);
packs[id] = unlocked;
if (!free && unlocked) anyPremiumUnlocked = true;
}
const premiumPackUnlocked = Object.keys(packs).some(id => PACKS[id].premium && packs[id]);
return {
premium: hasCreditUnlock || hasPremiumLevel || premiumPackUnlocked,
level: level === 'free' && hasCreditUnlock ? 'credits' : level,
premium: userTier > 0 || anyPremiumUnlocked,
level,
tier: userTier,
credits,
totalCreditsPurchased,
packs
packs,
catalog: catalogSummary(catalog)
};
}
// Display-facing catalog the client renders (labels/projection/gating) — replaces the hardcoded
// client RK.PACKS so the operator owns the catalog from one place (the DB).
function catalogSummary(catalog = DEFAULT_CATALOG) {
const out = [];
for (const id of Object.keys(catalog)) {
const p = catalog[id];
out.push({
id: p.id || id,
label: p.label || id,
kind: p.kind || 'render-pack',
projection: p.projection || null,
tier: asNumber(p.tier),
premium: !isFreePack(p),
unlockMinCredits: p.unlockMinCredits == null ? null : asNumber(p.unlockMinCredits)
});
}
return out;
}
// Gating reads the user's computed snapshot (which already contains every catalog pack). Unknown
// packs default to the built-in catalog's free-check (deny premium, allow free).
function canUsePack(entitlements = {}, packId) {
const pack = PACKS[packId];
if (!pack) return false;
if (!pack.premium) return true;
if (!packId) return true;
// A full snapshot's packs map is authoritative (the three-way rule already ran).
if (entitlements.packs && Object.prototype.hasOwnProperty.call(entitlements.packs, packId)) {
return !!entitlements.packs[packId];
}
// No granular packs map (a coarse/legacy entitlement): free packs are always allowed; else
// fall back to the coarse premium flag. Production entitlements always carry the packs map,
// so this branch never weakens real gating.
const def = DEFAULT_CATALOG[packId];
if (def && isFreePack(def)) return true;
return !!entitlements.premium;
}
@@ -117,9 +162,13 @@ function normalizeAppearance(input = {}, entitlements = {}) {
}
module.exports = {
DEFAULT_CATALOG,
PACKS,
PREMIUM_LEVELS,
TIER_OF,
tierForLevel,
snapshotForUser,
catalogSummary,
canUsePack,
canUseAppearance,
normalizeAppearance,
+20 -3
View File
@@ -1,5 +1,6 @@
const Appearance = require('../multiplayer/appearance');
const Entitlements = require('../multiplayer/entitlements');
const CatalogService = require('../services/catalogService');
function parseAppearance(value) {
if (!value) return Appearance.normalizeAppearance('default');
@@ -14,11 +15,26 @@ function parseAppearance(value) {
}
class IdentityService {
constructor({ db = null, gameModeManager = null, sessionManager = null, debugManager = null } = {}) {
constructor({ db = null, gameModeManager = null, sessionManager = null, debugManager = null, catalogService = null } = {}) {
this.db = db || gameModeManager?.db || null;
this.gameModeManager = gameModeManager;
this.sessionManager = sessionManager;
this.debugManager = debugManager;
// Operator-owned cosmetic catalog (DB-backed, cached). Falls back to the built-in default
// when the table is absent, so this never breaks a fresh/partly-migrated DB.
this.catalogService = catalogService || (this.db ? new CatalogService({ db: this.db }) : null);
}
async _catalog() {
if (!this.catalogService) return undefined; // snapshotForUser falls back to DEFAULT_CATALOG
try {
return await this.catalogService.getCatalog();
} catch (err) {
if (this.debugManager?.CONSOLE_LOGGING) {
console.warn('[IdentityService] catalog load failed; using default:', err.message);
}
return undefined;
}
}
async userForSocket(socket) {
@@ -56,9 +72,10 @@ class IdentityService {
}
async entitlementsForUser(user) {
if (!user) return Entitlements.snapshotForUser({});
const catalog = await this._catalog();
if (!user) return Entitlements.snapshotForUser({}, [], catalog);
const grants = await this.packGrantsForUser(user.id);
return Entitlements.snapshotForUser(user, grants);
return Entitlements.snapshotForUser(user, grants, catalog);
}
async entitlementsForSocket(socket) {
+77
View File
@@ -0,0 +1,77 @@
const { DEFAULT_CATALOG } = require('../multiplayer/entitlements');
/**
* Loads the operator-owned cosmetic catalog (migration 024 `cosmetic_catalog`) — the single
* server-authoritative source for pack definitions + unlock rules, replacing the split-brain
* hardcoded PACKS in entitlements.js (server) and assetPacks.js (client, via the served snapshot).
*
* Cached with a short TTL. Falls back to the built-in DEFAULT_CATALOG when the table is absent
* (fresh/partly-migrated DBs, tests) so nothing regresses without it.
*/
class CatalogService {
constructor({ db = null, ttlMs = 60000 } = {}) {
this.db = db;
this.ttlMs = ttlMs;
this._cache = null;
this._cacheAt = 0;
}
async getCatalog() {
const now = Date.now();
if (this._cache && (now - this._cacheAt) < this.ttlMs) return this._cache;
const loaded = await this._load();
this._cache = loaded;
this._cacheAt = now;
return loaded;
}
invalidate() {
this._cache = null;
this._cacheAt = 0;
}
async _load() {
if (!this.db || typeof this.db.query !== 'function') return DEFAULT_CATALOG;
try {
const res = await this.db.query(`
SELECT pack_id, label, kind, projection, tier, unlock_min_credits, grant_only, active, metadata
FROM cosmetic_catalog
WHERE active = TRUE
ORDER BY sort_order, pack_id
`);
const rows = (res && res.rows) || [];
if (rows.length === 0) return DEFAULT_CATALOG;
const out = {};
for (const r of rows) {
// Defend against non-catalog rows (e.g. a mock/query returning grant rows, which
// have pack_id but no label). Real catalog rows have both NOT-NULL fields.
if (!r || typeof r.pack_id !== 'string' || !r.pack_id || typeof r.label !== 'string' || !r.label) continue;
const tier = Number(r.tier || 0);
const minCredits = r.unlock_min_credits == null ? null : Number(r.unlock_min_credits);
const grantOnly = !!r.grant_only;
const premium = !(tier === 0 && minCredits == null && !grantOnly);
out[r.pack_id] = {
id: r.pack_id,
label: r.label,
kind: r.kind || 'render-pack',
projection: r.projection || null,
tier,
unlockMinCredits: minCredits,
grantOnly,
premium,
metadata: r.metadata || {}
};
}
if (Object.keys(out).length === 0) return DEFAULT_CATALOG; // no valid catalog rows
return out;
} catch (err) {
// Table missing (fresh/partly-migrated DB) — degrade to the built-in default.
if (err && (err.code === '42P01' || /cosmetic_catalog/i.test(err.message || ''))) {
return DEFAULT_CATALOG;
}
throw err;
}
}
}
module.exports = CatalogService;
+125
View File
@@ -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);
});
});
+1 -1
View File
@@ -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);