Files
wownerogue/html/js/render/assetPacks.js
jwinterm ed24d43b56 chore: prepare the repository for public release
Removes operator-private material from the shipped tree, corrects documentation
that had drifted from the code, deletes dead code, and applies a consistent
prose style.

Private material moved out of the public tree (docs/private/, git-ignored):
  - the wallet keyfile recovery runbook and its helper scripts, which together
    published the mainnet wallet file path, the service account, backup snapshot
    filenames, and the wallet RPC listener state. The helper duplicated the same
    constants, so relocating the document alone would not have removed them.
  - the stagenet parallel-candidate runbook and its fixtures.
  - the dated production-state record, the internal remediation plan, and the
    TODO list. None are reference documentation for an outside reader.

Redacted or genericised:
  - operator home-directory paths in shipped scripts and units. The asset build
    script now takes KENNEY_ASSETS_DIR instead of a hard-coded path.
  - private LAN addresses in tests, env examples and deploy docs, replaced with
    RFC 5737 documentation addresses.
  - the default alert sender and the notification hub example.
  - the Monero wallet unit now matches the Wownero one: a dedicated service
    account and a config file, instead of the operator's login account with the
    wallet password in argv where any local user can read it from /proc.

Ignore rules now cover every .env variant rather than the bare name, plus local
agent state. The deployment docs instruct operators to create .env.mainnet and
.env.stagenet, which the exact-name rule would have allowed to be committed.

Documentation corrected against the code: per-chain dungeon level counts, the
premium unlock rule, Tavern chat scope, backup retention, the chain-profile
fallback, the environment template location, and several sections describing
shipped work as pending.

Dead code removed: configPersistence, rpccalls, a duplicate vendored ROT.js
bundle, the unused tile-picker overlay, the unreachable legacy game-size path
and its vestigial parameter, spectator stubs that shadowed the real
SpectatorManager, and commented-out debugging. nip98 now imports the shared
nostr loader rather than duplicating it.

Prose style: em-dashes removed throughout in favour of colons, semicolons and
commas; comments restated as present-tense descriptions of the code rather than
narration of past changes.

127 suites / 1015 tests pass.
2026-07-27 06:40:38 -04:00

470 lines
22 KiB
JavaScript

// Themes / packs / entitlement policy for the render kit. Packs are resolved by capability
// (top-down, isometric, 3D) so the same `appearance` identity can render through different
// visual projections without changing Room state or join flow.
(function (root) {
'use strict';
var RK = root.RK = root.RK || {};
RK.TILE_KINDS = ['floor', 'wall', 'bar', 'table'];
var FREE_EQUIPMENT = { body: 'none', head: 'none', shield: 'none', weapon: 'none' };
var FREE_COLORS = { base: 'none', skin: 'natural', hair: 'copper', body: 'none', head: 'none', shield: 'none', weapon: 'none' };
RK.PACKS = {
'roguelike-interior': {
id: 'roguelike-interior',
label: 'Kenney Roguelike Interior',
premium: false,
projection: 'topdown'
},
'iso-dungeon': {
id: 'iso-dungeon',
label: 'Kenney Isometric Dungeon',
premium: true,
projection: 'iso',
unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 }
},
'kenney-3d-characters': {
id: 'kenney-3d-characters',
label: 'Kenney Animated 3D Characters',
premium: true,
projection: '3d',
unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 }
},
'generated-skins': {
id: 'generated-skins',
label: 'Premium Generated Skins',
premium: true,
projection: 'topdown',
unlock: { kind: 'credits_purchase', minTotalCreditsPurchased: 1 }
}
};
RK.entitlements = RK.entitlements || { premium: false, level: 'free', packs: {}, credits: 0, totalCreditsPurchased: 0 };
RK.setEntitlementSnapshot = function (data) {
data = data || {};
var prev = RK.entitlements || {};
// The server catalog is authoritative over the client pack list: operator-added packs
// appear, and existing packs pick up server definitions (label/projection/tier). Merged
// rather than replaced so a client fallback a render mode relies on survives an operator
// catalog that omits it.
if (Array.isArray(data.catalog) && data.catalog.length) {
var merged = {};
for (var k in RK.PACKS) merged[k] = RK.PACKS[k];
for (var ci = 0; ci < data.catalog.length; ci++) {
var c = data.catalog[ci];
if (!c || !c.id) continue;
var ex = merged[c.id] || {};
merged[c.id] = {
id: c.id,
label: c.label || ex.label || c.id,
premium: c.premium != null ? !!c.premium : !!ex.premium,
projection: c.projection || ex.projection || null,
tier: c.tier != null ? c.tier : ex.tier,
unlockMinCredits: c.unlockMinCredits != null ? c.unlockMinCredits : ex.unlockMinCredits,
unlock: ex.unlock
};
}
RK.PACKS = merged;
}
var hasTotal = data.totalCreditsPurchased != null || data.total_credits_purchased != null;
var hasPremium = data.premium != null;
var hasPacks = !!data.packs;
var hasCredits = data.credits != null || data.balance != null;
var total = hasTotal
? Number(data.totalCreditsPurchased || data.total_credits_purchased || 0)
: Number(prev.totalCreditsPurchased || 0);
var credits = hasCredits
? Number(data.credits != null ? data.credits : data.balance)
: Number(prev.credits || 0);
var premium = hasPremium ? !!data.premium : (hasTotal ? total > 0 : !!prev.premium);
var packs = {};
for (var id in RK.PACKS) {
var p = RK.PACKS[id];
if (!p.premium) {
packs[id] = true;
} else if (hasPacks && Object.prototype.hasOwnProperty.call(data.packs, id)) {
packs[id] = !!data.packs[id];
} else if (!hasTotal && !hasPacks && prev.packs && Object.prototype.hasOwnProperty.call(prev.packs, id)) {
packs[id] = !!prev.packs[id];
} else {
packs[id] = premium;
}
}
for (var pid in packs) {
if (packs[pid] && RK.PACKS[pid] && RK.PACKS[pid].premium) premium = true;
}
RK.entitlements = {
premium: premium,
level: data.level || (premium ? ((prev.level && prev.level !== 'free') ? prev.level : 'credits') : 'free'),
packs: packs,
credits: credits,
totalCreditsPurchased: total
};
try { localStorage.setItem('rk_entitlements', JSON.stringify(RK.entitlements)); } catch (_) {}
return RK.entitlements;
};
RK.loadEntitlements = function () {
try {
var raw = JSON.parse(localStorage.getItem('rk_entitlements') || 'null');
if (raw) return RK.setEntitlementSnapshot(raw);
} catch (_) {}
return RK.entitlements;
};
RK.loadEntitlements();
RK.pack = function (id) { return RK.PACKS[id] || null; };
RK.canUsePack = function (id) {
var p = RK.pack(id);
if (!p) return false;
if (!p.premium) return true;
// Production QA switch: when enabled, all premium packs are available for testing.
if (RK.renderModeTestUnlocks && RK.renderModeTestUnlocks()) return true;
return !!(RK.entitlements && RK.entitlements.packs && RK.entitlements.packs[id]);
};
RK.canUseAppearance = function (a) {
if (!a || !a.premium) return true;
return a.pack ? RK.canUsePack(a.pack) : !!(RK.entitlements && RK.entitlements.premium);
};
RK.THEMES = {
// Free baseline pack: the game's 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, which is the reason to unlock.
'original': {
id: 'original',
label: 'Original Tiles',
// html/tiles.png, 32px, no spacing: the same tiles the ROT instructions and waiting
// screens use, so the free pack carries the classic look. Grid coords are the legacy
// pixel coords divided by 32 (see options.js tileMap).
tileset: { url: 'tiles.png', tile: 32, spacing: 0 },
tiles: {
floor: [0, 0],
floor2: [32, 0], // alt/dirt floor
wall: [2, 1], // stone brick wall
door: [1, 0], // wooden door
entrance: [0, 1],
exit: [1, 0],
treasure: [30, 0], // $W gold coin
torch: [32, 1],
// The tiled renderer prefers a pack's player/monster tile over the sprite when
// present: the blue-robe @ hero and the green ~ goblin.
player: [2, 0],
monster: [31, 1]
// No furniture kinds, so tavern furniture falls back to floor.
}
},
'roguelike-interior': {
id: 'roguelike-interior',
label: 'Kenney Roguelike Interior',
tileset: { url: 'assets/kenney/roguelikeSheet.png', tile: 16, spacing: 1 },
// Default tile picks from the sheet metadata (roguelikeSheet_meta.json).
// The tile-picker overlay can override these per-user via localStorage.
// Each kind maps to a single [col,row] on the sheet (cols = 57). One coherent tile per
// kind: mixing wood/stone/plank variants per cell reads as noise. TileRenderer still
// accepts an array here for a theme wanting subtle, same-material variation.
tiles: {
floor: [5, 2], // 119 wood floor
floor2: [6, 2], // 120 stone floor (accent)
wall: [13, 12], // 697 stone masonry
// Tavern furnishings (coords from roguelikeSheet_meta.json labels: index -> [i%57, i/57]).
window: [42, 2], // 156 window (in the wall)
bar: [29, 0], // 29 bar counter
keg: [23, 0], // 23 barrel / beer keg (behind the bar)
shelf: [30, 1], // 87 shelf of bottles (behind the bar)
table: [20, 3], // 191 table, reads wooden; 312 renders green
chair: [19, 3], // 190 chair
barrel: [23, 0], // 23 barrel (decor)
crate: [25, 0], // 25 crate (decor)
rug: [11, 16], // 923 rug
door: [37, 9], // 550 door
// Dungeon features drawn as real tiles instead of monospace glyphs.
entrance: [37, 9], // 550 door
exit: [37, 9], // 550 door
treasure: [42, 15], // 897 item
torch: [50, 10] // 620 rubble / prop
}
},
// Second topdown pack: same sheet, dungeon palette (stone floors, barrels/crates instead of
// the wood-interior furniture). A distinct look with zero new assets; the tiled renderer's
// lighting/shadows supply the FX.
'roguelike-dungeon': {
id: 'roguelike-dungeon',
label: 'Roguelike Dungeon',
tileset: { url: 'assets/kenney/roguelikeSheet.png', tile: 16, spacing: 1 },
fx: true,
tiles: {
floor: [6, 2], // 120 stone floor
floor2: [13, 17], // 982 plank accent
wall: [13, 12], // 697 masonry
window: [42, 2], // 156 window
bar: [23, 0], // barrels (a dungeon has no bar)
keg: [23, 0],
shelf: [25, 0], // crate
table: [25, 0], // crate as a table
chair: [23, 0], // barrel as a stool
barrel: [23, 0],
crate: [25, 0],
rug: [6, 2], // stone (no rug)
door: [37, 9],
entrance: [37, 9], exit: [37, 9], treasure: [42, 15], torch: [50, 10]
}
}
};
RK.activeThemeId = 'roguelike-interior';
RK.theme = function (id) {
// With no explicit id, use the active topdown pack (registry-selected + entitlement-gated).
if (!id && RK.activePackId) {
var ap = RK.activePackId('topdown');
if (ap && RK.THEMES[ap]) return RK.THEMES[ap];
}
return RK.THEMES[id || RK.activeThemeId] || null;
};
// Effective tile map = theme defaults merged with localStorage overrides from the picker.
RK.tileMap = function (id) {
var t = RK.theme(id); if (!t) return {};
var map = {};
for (var k in t.tiles) map[k] = t.tiles[k];
try {
var ov = JSON.parse(localStorage.getItem('rk_tilemap_' + t.id) || '{}');
for (var kk in ov) map[kk] = ov[kk];
} catch (_) { /* ignore */ }
return map;
};
RK.setTile = function (id, kind, col, row) {
var t = RK.theme(id); if (!t) return;
var key = 'rk_tilemap_' + t.id, ov = {};
try { ov = JSON.parse(localStorage.getItem(key) || '{}'); } catch (_) {}
ov[kind] = [col, row];
try { localStorage.setItem(key, JSON.stringify(ov)); } catch (_) {}
};
// Lazy tileset atlas per theme.
var atlases = {};
RK.themeAtlas = function (id) {
var t = RK.theme(id); if (!t || !t.tileset) return null;
if (!atlases[t.id]) {
atlases[t.id] = new RK.TilesetAtlas(t.tileset.url, { tile: t.tileset.tile, spacing: t.tileset.spacing });
}
return atlases[t.id];
};
RK.loadTheme = function (id, cb) {
var a = RK.themeAtlas(id);
if (!a) { if (cb) cb(null); return; }
a.onReady(function () { if (cb) cb(a); });
};
RK.EXTRA_APPEARANCES = {
'kenney-survivor-male': {
id: 'kenney-survivor-male',
label: 'Survivor',
kind: 'model3d',
premium: true,
pack: 'kenney-3d-characters'
},
'kenney-survivor-female': {
id: 'kenney-survivor-female',
label: 'Survivor Scout',
kind: 'model3d',
premium: true,
pack: 'kenney-3d-characters'
}
};
function normalizeEquipment(eq) {
eq = eq || {};
var out = {};
for (var k in FREE_EQUIPMENT) out[k] = eq[k] || 'none';
return out;
}
function normalizeColors(colors, tint) {
if (RK.normalizeCharColors) return RK.normalizeCharColors(colors, tint);
colors = colors || {};
var out = {};
for (var k in FREE_COLORS) out[k] = colors[k] || (k === 'base' ? (tint || FREE_COLORS[k]) : FREE_COLORS[k]);
return out;
}
RK.packAppearances = function () {
var out = [];
for (var id in RK.EXTRA_APPEARANCES) out.push(RK.EXTRA_APPEARANCES[id]);
return out;
};
function isoMale(n) {
var base = 'assets/kenney/iso-dungeon/characters/male' + n + '/Male_' + n + '_';
var run = [];
for (var i = 0; i < 10; i++) run.push(base + 'Run' + i + '.png');
return {
idle: base + 'Idle0.png',
run: run,
imageW: 48,
imageH: 92
};
}
RK.isoAssets = {
pack: 'iso-dungeon',
avatar: 'char-villager',
tile: { w: 84, h: 42, imageW: 92, imageH: 184 },
tiles: {
// Floors / ground
floor: 'assets/kenney/iso-dungeon/tiles/planks_S.png', // wood, tavern interior
floor2: 'assets/kenney/iso-dungeon/tiles/stone_S.png', // stone, dungeon
dirt: 'assets/kenney/iso-dungeon/tiles/dirt_S.png',
rug: 'assets/kenney/iso-dungeon/tiles/planks_S.png',
// Architecture (full-height tiles). Iso walls have a facing: a wall running along the
// grid-x axis needs the _S rotation, one running along grid-y needs the perpendicular
// _W rotation, and true corners get the corner tile. The renderer picks per-cell from
// neighbours, choosing `<kind>Y` for y-running segments; without that the top-left and
// bottom-right edges read as thin slabs.
wall: 'assets/kenney/iso-dungeon/tiles/stoneWall_S.png',
wallY: 'assets/kenney/iso-dungeon/tiles/stoneWall_W.png',
wallCorner: 'assets/kenney/iso-dungeon/tiles/stoneWallCorner_S.png',
window: 'assets/kenney/iso-dungeon/tiles/stoneWallWindow_S.png',
windowY: 'assets/kenney/iso-dungeon/tiles/stoneWallWindow_W.png',
door: 'assets/kenney/iso-dungeon/tiles/stoneWallDoorOpen_S.png',
doorY: 'assets/kenney/iso-dungeon/tiles/stoneWallDoorOpen_W.png',
archway: 'assets/kenney/iso-dungeon/tiles/stoneWallArchway_S.png',
// Floor variety: the renderer sprinkles these deterministically so the ground isn't a
// flat sea of identical planks.
floorAlt: 'assets/kenney/iso-dungeon/tiles/planksBroken_S.png',
floor2Alt: 'assets/kenney/iso-dungeon/tiles/stoneUneven_S.png',
stoneTile: 'assets/kenney/iso-dungeon/tiles/stoneTile_S.png',
column: 'assets/kenney/iso-dungeon/tiles/stoneColumnWood_S.png',
stairs: 'assets/kenney/iso-dungeon/tiles/stairs_S.png',
// Furniture props, drawn over a floor base; each kind has distinct art.
bar: 'assets/kenney/iso-dungeon/tiles/barrelsStacked_S.png', // stacked barrels as the bar counter
keg: 'assets/kenney/iso-dungeon/tiles/barrel_S.png',
barrel: 'assets/kenney/iso-dungeon/tiles/barrel_S.png',
table: 'assets/kenney/iso-dungeon/tiles/tableShortChairs_S.png', // real table + chairs
shelf: 'assets/kenney/iso-dungeon/tiles/woodenCrates_S.png',
crate: 'assets/kenney/iso-dungeon/tiles/woodenCrate_S.png',
chair: 'assets/kenney/iso-dungeon/tiles/chair_S.png',
chest: 'assets/kenney/iso-dungeon/tiles/chestClosed_S.png',
fallback: 'assets/kenney/iso-dungeon/tiles/planks_S.png'
},
// The Kenney files named Male_0..Male_7 are directional renders of the same body, not
// different character classes, so the customizer exposes one Iso body. The sprites are
// angled clockwise from NE: 0=NE, 2=SE, 4=SW, 6=NW. In this iso projection a grid step
// moves visually up->NE, down->SW, left->NW, right->SE, so each facing uses the sprite
// pointing that way and the character faces its direction of travel.
character: isoMale(3), // idle faces roughly toward the camera (S)
directions: {
up: isoMale(0), // NE (up-right)
down: isoMale(4), // SW (down-left)
left: isoMale(6), // NW (up-left)
right: isoMale(2) // SE (down-right)
},
characters: {
fallback: isoMale(3),
'char-villager': isoMale(3)
}
};
// Second iso pack: Kenney Isometric Medieval Town, a separate environment. Strong on
// architecture (floor/walls/door/window/banner); it has no medieval furniture at this scale, so
// furniture kinds fall back to floor. Reuses the dungeon-pack characters. Its tiles are shorter
// (210x244) than the dungeon set, hence its own tile geometry.
RK.isoMedievalAssets = {
pack: 'iso-medieval',
avatar: 'char-villager',
tile: { w: 84, h: 42, imageW: 84, imageH: 98 },
tiles: {
floor: 'assets/kenney/iso-medieval/floor.png',
floor2: 'assets/kenney/iso-medieval/floor2.png',
wall: 'assets/kenney/iso-medieval/wall.png',
window: 'assets/kenney/iso-medieval/window.png',
door: 'assets/kenney/iso-medieval/door.png',
banner: 'assets/kenney/iso-medieval/banner.png',
fallback: 'assets/kenney/iso-medieval/floor.png'
},
character: RK.isoAssets.character,
directions: RK.isoAssets.directions,
characters: RK.isoAssets.characters
};
RK.threeAssets = {
pack: 'kenney-3d-characters',
models: {
'kenney-survivor-male': {
url: 'assets/generated/3d/kenney-animated-characters/survivorMaleB.glb',
clips: { idle: 'idle', run: 'run', jump: 'jump' }
},
'kenney-survivor-female': {
url: 'assets/generated/3d/kenney-animated-characters/survivorFemaleA.glb',
clips: { idle: 'idle', run: 'run', jump: 'jump' }
},
fallback: {
url: 'assets/generated/3d/kenney-animated-characters/survivorMaleB.glb',
clips: { idle: 'idle', run: 'run', jump: 'jump' }
}
}
};
// Register the built-in render packs in the pack registry (packRegistry.js) so iso and 3D are
// multi-pack like topdown themes. Each maps to its catalog id; the active pack per projection
// is entitlement-gated and user-selectable. Adding a pack takes registerPack(...), assets, and
// a catalog row. With one pack per projection, resolution falls through 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 });
RK.registerPack({ id: 'iso-medieval', label: 'Medieval Town', projection: 'iso', kind: 'tiles', assets: RK.isoMedievalAssets });
RK.registerPack({ id: 'kenney-3d-characters', label: 'Animated 3D', projection: '3d', kind: 'skin', assets: RK.threeAssets });
}
// Active-pack resolvers the renderers read (fall back to the single default if the registry is absent).
RK.activeIsoAssets = function () { return (RK.activePackAssets && RK.activePackAssets('iso')) || RK.isoAssets; };
RK.activeThreeAssets = function () { return (RK.activePackAssets && RK.activePackAssets('3d')) || RK.threeAssets; };
RK.packForProjection = function (projection) {
for (var id in RK.PACKS) {
if (RK.PACKS[id].projection === projection) return RK.PACKS[id];
}
return null;
};
RK.resolveAppearance = function (entity, projection) {
entity = entity || {};
var baseAppearance = entity.appearance || { avatar: entity.avatar || 'default', tint: 'none', equipment: FREE_EQUIPMENT };
if (RK.avatarVisuals && RK.avatarVisuals.resolve) {
return RK.avatarVisuals.resolve(baseAppearance, {
projection: projection || 'topdown',
context: 'room-entity',
entity: entity
});
}
var appearance = entity.appearance || { avatar: entity.avatar || 'default', tint: 'none', equipment: FREE_EQUIPMENT };
var avatar = appearance.avatar || entity.avatar || 'default';
if (projection === 'iso') {
return {
projection: 'iso',
pack: 'iso-dungeon',
avatar: avatar,
tint: appearance.tint || 'none',
equipment: normalizeEquipment(appearance.equipment),
colors: normalizeColors(appearance.colors, appearance.tint),
character: RK.isoAssets.character
};
}
if (projection === '3d') {
return {
projection: '3d',
pack: 'kenney-3d-characters',
avatar: avatar,
model: RK.threeAssets.models[avatar] || RK.threeAssets.models.fallback,
tint: appearance.tint || 'none',
equipment: normalizeEquipment(appearance.equipment),
colors: normalizeColors(appearance.colors, appearance.tint)
};
}
return { projection: 'topdown', avatar: avatar, appearance: appearance };
};
})(window);