From 87e688b4ac85dc49f43c485a24733091d8a39266 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Wed, 21 Oct 2026 13:55:00 -0400 Subject: [PATCH] fix(tavern): repaint permitted avatars and prioritize the live arena --- docs/SHOWCASE_QUALIFICATION.md | 9 +- docs/TAVERN_AND_MULTIPLAYER.md | 6 +- html/js/render/avatarVisuals.js | 2 +- html/js/render/tileRenderer.js | 46 ++++++- html/tavern.html | 41 +++--- src/scripts/showcase-browser-smoke.js | 175 ++++++++++++++++++++++---- test/renderUiFallback.test.js | 8 +- test/tileRendererAvatarReady.test.js | 151 ++++++++++++++++++++++ 8 files changed, 385 insertions(+), 53 deletions(-) create mode 100644 test/tileRendererAvatarReady.test.js diff --git a/docs/SHOWCASE_QUALIFICATION.md b/docs/SHOWCASE_QUALIFICATION.md index 83da56e..870f47b 100644 --- a/docs/SHOWCASE_QUALIFICATION.md +++ b/docs/SHOWCASE_QUALIFICATION.md @@ -29,9 +29,12 @@ wallet works. Run `npm run test:showcase-browser` from `src` with the cached Playwright Chromium available. The harness uses the real Tavern manager and two fresh browser clients on a loopback-only server. It verifies movement, chat, keyboard focus, mobile controls, missing-sheet failure, and Tiled/ASCII/Iso drawing. -Its synthetic Iso grant is a fixture, not evidence of purchased or production entitlements. Browser -output belongs under `~/Build/wowngeon`. No wallet extension, application database, or chain node is -used. See the script's usage for the output location. +Mica receives synthetic `roguelike-interior` and `iso-dungeon` grants from the source-owned default +catalog; the harness proves the permitted character sprite draws. Moth receives the catalog's +zero-grant snapshot and must retain original-only access. Both balances remain zero. These grants +are test fixtures, not evidence of purchases or production entitlements. Browser output belongs +under `~/Build/wowngeon`. No wallet extension, application database, or chain node is used. See the +script's usage for the output location. For authoritative multiplayer Escape Race captures, follow [PVP_CAPTURE.md](PVP_CAPTURE.md). Run `race` with each of `tiles`, `ascii`, and `iso`; require a decisive first exit and matching final diff --git a/docs/TAVERN_AND_MULTIPLAYER.md b/docs/TAVERN_AND_MULTIPLAYER.md index 861f3fe..e1e4836 100644 --- a/docs/TAVERN_AND_MULTIPLAYER.md +++ b/docs/TAVERN_AND_MULTIPLAYER.md @@ -339,8 +339,10 @@ the Tavern. For the isolated, gameplay-only Tavern browser smoke, run `npm run test:showcase-browser` from `src`. It starts a fresh loopback server and two anonymous browser contexts, with no database, -wallet, paid entitlement, or queue/race flow. Its synthetic Iso entitlement is a visual fixture, -not a purchase. Screenshots and a JSON result go to a unique directory under +wallet, purchase, or queue/race flow. Mica receives synthetic `roguelike-interior` and `iso-dungeon` +pack grants from the source-owned default catalog to prove permitted character drawing and Iso rendering. +Moth receives the catalog's zero-grant snapshot and must retain original-only access. Both balances +remain zero; neither fixture represents a purchase or production entitlement. Screenshots and a JSON result go to a unique directory under `~/Build/wowngeon/showcase-browser`; pass `-- --output-root ` to choose another output root. The browser blocks requests outside its loopback server. A local browser route injects a 404 for the generated Wownero skin sheet so its readable failed-asset state is checked consistently, diff --git a/html/js/render/avatarVisuals.js b/html/js/render/avatarVisuals.js index 56ee083..0ba3058 100644 --- a/html/js/render/avatarVisuals.js +++ b/html/js/render/avatarVisuals.js @@ -128,7 +128,7 @@ function rememberLoad(key, start, onReady) { if (loading[key]) { - if (onReady) loading[key].push(onReady); + if (onReady && loading[key].indexOf(onReady) === -1) loading[key].push(onReady); return; } loading[key] = onReady ? [onReady] : []; diff --git a/html/js/render/tileRenderer.js b/html/js/render/tileRenderer.js index 3a9d589..24f34b2 100644 --- a/html/js/render/tileRenderer.js +++ b/html/js/render/tileRenderer.js @@ -72,13 +72,20 @@ host.appendChild(this.canvas); this._snap = null; // offscreen snapshot of the base scene (tiles + entities) this._lastScene = null; + this._avatarReadyRepaintScheduled = false; + this._destroyed = false; + var self = this; + this._onAvatarReady = function () { + if (self._avatarAssetsReady(self._lastScene)) self._scheduleAvatarReadyRepaint(); + }; this._embers = []; this._raf = null; this._t = 0; } TileRenderer.prototype.render = function (scene) { - if (!scene || !this.ctx) return; + if (!scene || !this.ctx || this._destroyed) return; + this._lastScene = scene; var cell = this.cell, ctx = this.ctx; this.canvas.width = scene.cols * cell; this.canvas.height = scene.rows * cell; @@ -171,6 +178,7 @@ } var now = Date.now(); + var onAvatarReady = this._onAvatarReady; for (var i = 0; i < scene.entities.length; i++) { var e = scene.entities[i]; // Dungeon features (entrance / exit / treasure): real tiles, glyph fallback. @@ -216,7 +224,7 @@ var mAvatar = e.avatar || 'char-goblin'; if (window.RK && RK.avatarVisuals && RK.avatarVisuals.drawTopdownWorld) { var mv = RK.avatarVisuals.resolve({ avatar: mAvatar }, { projection: 'topdown', context: 'dungeon', entity: e }); - if (RK.avatarVisuals.drawTopdownWorld(ctx, mv, e, { screenX: e.x, screenY: e.y, cell: cell }, { now: now })) continue; + if (RK.avatarVisuals.drawTopdownWorld(ctx, mv, e, { screenX: e.x, screenY: e.y, cell: cell }, { now: now, onReady: onAvatarReady })) continue; } if (window.RK && RK.drawCharCanvas && RK.drawCharCanvas(ctx, { x: e.x, y: e.y, avatar: mAvatar }, cell, now)) continue; var mx = e.x * cell + cell / 2, my = e.y * cell + cell / 2, mr = cell * 0.34; @@ -253,7 +261,7 @@ screenX: e.x, screenY: e.y, cell: cell - }, { now: now })) continue; + }, { now: now, onReady: onAvatarReady })) continue; } else { if (window.RK && RK.isChar && RK.isChar(e.avatar) && RK.drawCharCanvas(ctx, e, cell, now)) continue; if (window.RK && RK.isSkin && RK.isSkin(e.avatar) && RK.drawSkinCanvas(ctx, e, cell, now)) continue; @@ -292,7 +300,6 @@ var _fp = findPlayer(scene); if (_fp) this.focusPoint = { x: (_fp.x + 0.5) * cell, y: (_fp.y + 0.5) * cell }; - this._lastScene = scene; this._collectEmitters(scene); this._snapshot(); this._ensureEmbers(scene); @@ -300,6 +307,36 @@ this._startLoop(); }; + // Character atlases load asynchronously. Coalesce callbacks from multiple entities and repaint + // the latest retained scene once the ready assets can be drawn. + TileRenderer.prototype._scheduleAvatarReadyRepaint = function () { + if (this._destroyed || !this.ctx || this._avatarReadyRepaintScheduled) return; + this._avatarReadyRepaintScheduled = true; + var self = this; + root.requestAnimationFrame(function () { + self._avatarReadyRepaintScheduled = false; + if (self._destroyed || !self.ctx || !self._lastScene) return; + self.render(self._lastScene); + }); + }; + + TileRenderer.prototype._avatarAssetsReady = function (scene) { + var rk = window.RK; + if (!scene || !Array.isArray(scene.entities) || !rk || !rk.avatarVisuals || !rk.avatarVisuals.resolve) return false; + for (var i = 0; i < scene.entities.length; i++) { + var entity = scene.entities[i]; + var appearance = entity.appearance || { avatar: entity.avatar || (entity.kind === 'monster' ? 'char-goblin' : 'default') }; + var visual = rk.avatarVisuals.resolve(appearance, { projection: 'topdown', context: 'tavern', entity: entity }); + if (!visual.allowed) continue; + if (visual.kind === 'char' && rk.charAtlas && rk.charAtlas()) return true; + if (visual.kind === 'skin' && rk.skinSheet && rk.skinSheet(visual.appearance.avatar)) return true; + // A failed generated sheet changes the next draw into the permitted Villager fallback path. + // Repaint once for that state change so the fallback atlas can start loading. + if (visual.kind === 'skin' && rk.skinSheetFailed && rk.skinSheetFailed(visual.appearance.avatar)) return true; + } + return false; + }; + // Cache the fire/hazard tile positions once per scene so the flicker loop can animate them // without rescanning the grid every frame. TileRenderer.prototype._collectEmitters = function (scene) { @@ -493,6 +530,7 @@ }; TileRenderer.prototype.destroy = function () { + this._destroyed = true; if (this._raf != null) { root.cancelAnimationFrame(this._raf); this._raf = null; } if (this.canvas && this.canvas.parentNode) this.canvas.parentNode.removeChild(this.canvas); this.canvas = null; diff --git a/html/tavern.html b/html/tavern.html index b68b5a1..c3163c3 100644 --- a/html/tavern.html +++ b/html/tavern.html @@ -127,7 +127,7 @@ - +