From 4ee03f24a61ea54fd16d97b3f54ac9533533e2af Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 9 Jul 2026 06:15:00 -0400 Subject: [PATCH] fix: char customizer flakiness + wire real render-mode entitlement gating - Equipment option tiles register for async redraw (were stuck on '...') - Render mode switching no longer silently mutates avatar identity - Dungeon renderer falls back to @ tile when overlay draw fails - Main-game customizer locked to topdown (no confusing render-mode picker) - equipRedrawers prevents unbounded redraw array growth - RENDER_MODE_TEST_UNLOCKS flipped to false; ASCII made free (accessible fallback) - Documented COSMETIC_PRODUCTS in .env.example - 5 new tests (239 total, 36 suites, all passing) --- html/js/core/singlePlayerAvatar.js | 2 +- html/js/display/renderEngine.js | 9 +++++- html/js/render/assetPacks.js | 3 +- html/js/render/charCustomize.js | 45 ++++++++++++++++++++++-------- html/js/render/renderModes.js | 8 +++--- src/.env.example | 8 ++++++ test/renderPackResolver.test.js | 27 ++++++++++++++++-- test/singlePlayerAvatar.test.js | 19 +++++++++++++ 8 files changed, 100 insertions(+), 21 deletions(-) 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)'); + }); });