diff --git a/html/js/core/singlePlayerAvatar.js b/html/js/core/singlePlayerAvatar.js
index ee7d4e8..e1780cd 100644
--- a/html/js/core/singlePlayerAvatar.js
+++ b/html/js/core/singlePlayerAvatar.js
@@ -53,7 +53,7 @@
if (root.socket && root.socket.emit) {
root.socket.emit('identity:update', { appearance: normalized });
}
- });
+ }, { projection: 'topdown' });
});
},
diff --git a/html/js/display/renderEngine.js b/html/js/display/renderEngine.js
index 5ebe86e..17ffaac 100644
--- a/html/js/display/renderEngine.js
+++ b/html/js/display/renderEngine.js
@@ -258,11 +258,18 @@ var RenderEngine = {
}
if (avatarOverlayReady && window.SinglePlayerAvatar.drawPlayer) {
- window.SinglePlayerAvatar.drawPlayer(gameState, {
+ var drew = window.SinglePlayerAvatar.drawPlayer(gameState, {
screenX: centerX,
screenY: centerY,
cell: window.options ? window.options.tileWidth : 32
});
+ // Fallback: if the overlay couldn't render (display not ready, atlas stale, etc.),
+ // draw the @ tile so the player is never invisible. The @ was suppressed above
+ // because canDrawPlayer returned true, but the overlay draw may still fail.
+ if (!drew) {
+ try { display.draw(centerX, centerY, playerTile, "#FFF", "transparent"); }
+ catch (_) { /* ignore โ next frame will retry */ }
+ }
}
// Render message at bottom if present
diff --git a/html/js/render/assetPacks.js b/html/js/render/assetPacks.js
index ef429bf..b126117 100644
--- a/html/js/render/assetPacks.js
+++ b/html/js/render/assetPacks.js
@@ -96,7 +96,8 @@
var p = RK.pack(id);
if (!p) return false;
if (!p.premium) return true;
- if (RK.renderModeTestUnlocks && RK.renderModeTestUnlocks() && (p.projection === 'iso' || p.projection === '3d')) 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) {
diff --git a/html/js/render/charCustomize.js b/html/js/render/charCustomize.js
index 1834d7c..151e447 100644
--- a/html/js/render/charCustomize.js
+++ b/html/js/render/charCustomize.js
@@ -112,6 +112,13 @@
} catch (_) { return null; }
};
+ function projectionToMode(projection) {
+ if (projection === 'iso') return 'iso';
+ if (projection === '3d') return '3d';
+ if (projection === 'ascii') return 'ascii';
+ return 'tiles';
+ }
+
function projectionForMode(mode) {
if (mode === 'iso' || mode === '3d' || mode === 'ascii') return mode;
return 'topdown';
@@ -424,11 +431,13 @@
document.head.appendChild(style);
}
- RK.openCustomize = function (current, onSave) {
+ RK.openCustomize = function (current, onSave, opts) {
+ opts = opts || {};
ensureCustomizeStyle();
var draft = RK.normalizeAppearance(current);
- var redrawers = [];
+ var redrawers = []; // avatar thumbnail draws (reset in buildAvatarGrid)
+ var equipRedrawers = []; // equipment option tile draws (reset in renderEditor)
var cards = {};
function isCharDraft() {
@@ -519,10 +528,14 @@
content.className = 'rk-customize-content';
body.appendChild(content);
- var selectedMode = RK.loadMode ? RK.loadMode('tiles') : 'tiles';
+ // The main game page only uses top-down rendering, so the customizer there should
+ // default to top-down and not offer the tavern's render-mode picker. The tavern passes
+ // no opts, so it gets the full picker and the user's saved mode.
+ var selectedMode = opts.projection ? projectionToMode(opts.projection) : (RK.loadMode ? RK.loadMode('tiles') : 'tiles');
+ var showRenderModes = !opts.projection && RK.RENDER_MODES && RK.RENDER_MODES.length;
var modeCards = {};
var renderModeGrid = null;
- if (RK.RENDER_MODES && RK.RENDER_MODES.length) {
+ if (showRenderModes) {
var renderSection = document.createElement('section');
renderSection.className = 'rk-customize-section';
content.appendChild(renderSection);
@@ -690,6 +703,7 @@
function renderEditor() {
editSection.innerHTML = '';
+ equipRedrawers = [];
if (!supportsColorDraft()) return;
if (isCharDraft()) ensureCharDraft();
else ensureColorDraft();
@@ -725,7 +739,7 @@
draft.equipment[slot] = item.id;
renderEditor();
redrawAll();
- }, optionTileCanvas(slot, item, draft, null)));
+ }, optionTileCanvas(slot, item, draft, equipRedrawers)));
})(catalog[id]);
}
if (equipmentColorable(selectedItem)) {
@@ -781,7 +795,10 @@
}
function buildAvatarGrid() {
- ensureVisibleAvatar();
+ // Don't auto-switch the avatar when the render mode changes โ only an explicit
+ // click on a different avatar card should change the saved identity. If the current
+ // avatar isn't native to this projection, it stays selected (the resolver handles
+ // fallback rendering) so browsing render modes never silently mutates the identity.
grid.innerHTML = '';
cards = {};
redrawers = [];
@@ -799,7 +816,7 @@
}, redrawers));
var lab = document.createElement('div');
lab.className = 'rk-option-label';
- lab.textContent = (activeProjection() === 'iso' ? 'Adventurer' : a.label) + (a.premium ? ' *' : '');
+ lab.textContent = a.label + (a.premium ? ' *' : '');
card.appendChild(lab);
card.onclick = function () { selectAvatar(a); };
grid.appendChild(card);
@@ -845,15 +862,21 @@
} else {
drawBaseThumb(pctx, base, draft, 'topdown', redrawAll);
}
- previewName.textContent = projection === 'iso' ? 'Adventurer' : (base.label || base.id);
- previewMeta.textContent = projection !== 'topdown'
- ? (projection.toUpperCase() + ' identity')
- : (base.premium ? 'Premium pack' : (base.kind === 'char' ? 'Customizable' : 'Free'));
+ var visible = appearanceVisibleInProjection(base, projection);
+ previewName.textContent = base.label || base.id;
+ var metaParts = [];
+ if (projection !== 'topdown') metaParts.push(projection.toUpperCase() + ' view');
+ if (!visible && projection !== 'topdown') metaParts.push('fallback render');
+ if (base.premium) metaParts.push('Premium pack');
+ else if (base.kind === 'char') metaParts.push('Customizable');
+ else if (base.kind === 'color') metaParts.push('Free');
+ previewMeta.textContent = metaParts.join(' ยท ') || 'Free';
}
function redrawAll() {
drawPreview();
redrawers.forEach(function (fn) { try { fn(); } catch (_) { /* ignore */ } });
+ equipRedrawers.forEach(function (fn) { try { fn(); } catch (_) { /* ignore */ } });
}
function mark() {
diff --git a/html/js/render/renderModes.js b/html/js/render/renderModes.js
index 384f2a1..003ed8e 100644
--- a/html/js/render/renderModes.js
+++ b/html/js/render/renderModes.js
@@ -8,7 +8,7 @@
// is client-side, so this is a soft/honour gate for cosmetics, not a hard security boundary.
RK.RENDER_MODES = [
{ id: 'tiles', label: 'Tiled', premium: false },
- { id: 'ascii', label: 'ASCII', premium: true },
+ { id: 'ascii', label: 'ASCII', premium: false },
{ id: 'fancy', label: 'Fancy', premium: true, pack: 'generated-skins' },
{ id: 'iso', label: 'Iso', premium: true, pack: 'iso-dungeon' },
{ id: '3d', label: '3D', premium: true, pack: 'kenney-3d-characters' }
@@ -16,9 +16,9 @@
];
RK.entitlements = RK.entitlements || { premium: false, level: 'free', packs: {} };
- // Temporary production QA switch while Iso/3D/Fancy are being proven out. Flip this back to
- // false when render modes return behind credits/pack entitlements.
- RK.RENDER_MODE_TEST_UNLOCKS = true;
+ // 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.
+ RK.RENDER_MODE_TEST_UNLOCKS = false;
RK.renderModeTestUnlocks = function () { return RK.RENDER_MODE_TEST_UNLOCKS === true; };
RK.modeMeta = function (id) {
diff --git a/src/.env.example b/src/.env.example
index 8b2fbdc..118466e 100644
--- a/src/.env.example
+++ b/src/.env.example
@@ -241,6 +241,14 @@ CREDITS_PAYOUT_TREASURE=3.0 # Same as direct mode
#
CREDITS_PACKAGES='[{"id":"small","credits":10,"price":"900000000000","bonus":0},{"id":"medium","credits":20,"price":"1700000000000","bonus":0},{"id":"large","credits":50,"price":"4000000000000","bonus":0}]'
+# Credit packages can bundle cosmetic/render pack grants and a premium tier via
+# a "grants" field. Example:
+# CREDITS_PACKAGES='[{"id":"supporter_25","credits":20,"bonus":5,"price":"1000000000000","grants":{"packs":["generated-skins","kenney-3d-characters"],"premiumLevel":"supporter"}}]'
+
+# Standalone cosmetic products (no game entry or credits required). Each product
+# may grant packs and/or a premium tier via "grants". JSON array; empty by default.
+# COSMETIC_PRODUCTS='[{"id":"pack_3d","label":"3D Character Pack","price":"250000000000","grants":{"packs":["kenney-3d-characters"]}}]'
+
# =============================================================================
# SECTION 6b: EARLY ENTRY
# =============================================================================
diff --git a/test/renderPackResolver.test.js b/test/renderPackResolver.test.js
index cecf8ea..ad16209 100644
--- a/test/renderPackResolver.test.js
+++ b/test/renderPackResolver.test.js
@@ -132,21 +132,27 @@ describe('render pack visual resolver', () => {
expect(RK.createRenderer('iso', {}, {}).name).toBe('iso');
});
- test('temporary render test unlocks bypass mode gates only for render packs', () => {
+ test('temporary render test unlocks bypass all premium gates for QA', () => {
const RK = loadRenderKit();
RK.setEntitlementSnapshot({ totalCreditsPurchased: 0, premium: false, packs: {} });
+ // With test unlocks off, premium modes and packs are gated by entitlements
expect(RK.canUseMode('iso')).toBe(false);
expect(RK.canUsePack('iso-dungeon')).toBe(false);
+ // With test unlocks on, ALL premium modes and packs are available for QA
RK.RENDER_MODE_TEST_UNLOCKS = true;
- expect(RK.canUseMode('ascii')).toBe(true);
expect(RK.canUseMode('fancy')).toBe(true);
expect(RK.canUseMode('iso')).toBe(true);
expect(RK.canUseMode('3d')).toBe(true);
expect(RK.canUsePack('iso-dungeon')).toBe(true);
expect(RK.canUsePack('kenney-3d-characters')).toBe(true);
- expect(RK.canUsePack('generated-skins')).toBe(false);
+ expect(RK.canUsePack('generated-skins')).toBe(true);
+
+ // Free modes and packs are always available regardless
+ RK.RENDER_MODE_TEST_UNLOCKS = false;
+ expect(RK.canUseMode('tiles')).toBe(true);
+ expect(RK.canUseMode('ascii')).toBe(true); // ASCII is the free accessible fallback
});
test('render pack code stays out of payment and account APIs', () => {
@@ -173,3 +179,18 @@ describe('render pack visual resolver', () => {
});
});
});
+
+ test('ASCII is the free accessible fallback, not premium-gated', () => {
+ const RK = loadRenderKit();
+ RK.setEntitlementSnapshot({ totalCreditsPurchased: 0, premium: false, packs: {} });
+ RK.RENDER_MODE_TEST_UNLOCKS = false;
+
+ const asciiMeta = RK.modeMeta('ascii');
+ expect(asciiMeta.premium).toBe(false);
+ expect(RK.canUseMode('ascii')).toBe(true);
+ });
+
+ test('render mode test unlocks default to false in production', () => {
+ const RK = loadRenderKit();
+ expect(RK.RENDER_MODE_TEST_UNLOCKS).toBe(false);
+ });
diff --git a/test/singlePlayerAvatar.test.js b/test/singlePlayerAvatar.test.js
index d205e59..0ad4e85 100644
--- a/test/singlePlayerAvatar.test.js
+++ b/test/singlePlayerAvatar.test.js
@@ -49,4 +49,23 @@ describe('single-player character identity wiring', () => {
expect(singlePlayerAvatar).toContain('scale: 0.78');
expect(avatarVisuals).toContain("visual.context === 'legend'");
});
+
+ test('dungeon renderer falls back to @ tile when overlay draw fails', () => {
+ expect(renderEngine).toContain('var drew = window.SinglePlayerAvatar.drawPlayer');
+ expect(renderEngine).toContain('if (!drew)');
+ expect(renderEngine).toContain('display.draw(centerX, centerY, playerTile');
+ });
+
+ test('customizer does not auto-switch avatar identity on render mode change', () => {
+ const charCustomize = fs.readFileSync(path.join(__dirname, '../html/js/render/charCustomize.js'), 'utf8');
+ const buildAvatarGridMatch = charCustomize.match(/function buildAvatarGrid\(\)[\s\S]*?grid\.innerHTML/);
+ expect(buildAvatarGridMatch).toBeTruthy();
+ expect(buildAvatarGridMatch[0]).not.toContain('ensureVisibleAvatar()');
+ });
+
+ test('equipment option tiles register for async redraw', () => {
+ const charCustomize = fs.readFileSync(path.join(__dirname, '../html/js/render/charCustomize.js'), 'utf8');
+ expect(charCustomize).toContain('optionTileCanvas(slot, item, draft, equipRedrawers)');
+ expect(charCustomize).not.toContain('optionTileCanvas(slot, item, draft, null)');
+ });
});