fix(tavern): repaint permitted avatars and prioritize the live arena

This commit is contained in:
2026-10-21 13:55:00 -04:00
parent c387d64067
commit 87e688b4ac
8 changed files with 385 additions and 53 deletions
+6 -3
View File
@@ -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
+4 -2
View File
@@ -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 <directory>` 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,
+1 -1
View File
@@ -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] : [];
+42 -4
View File
@@ -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;
+27 -14
View File
@@ -127,7 +127,7 @@
<div id="modebar" class="hidden" role="group" aria-label="Render style"></div>
<div id="packbar" class="hidden" role="group" aria-label="Visual pack"></div>
<div id="stage" class="hidden" aria-label="Shared tavern room"></div>
<div id="stage" class="hidden" aria-label="Shared tavern room" tabindex="-1"></div>
<div id="tavernDpad" class="touch-pad hidden" role="group" aria-label="Tavern movement controls">
<button type="button" data-dir="up" aria-label="Move up">▲</button>
<button type="button" data-dir="left" aria-label="Move left">◀</button>
@@ -137,6 +137,21 @@
<p id="status" class="muted" role="status" aria-live="polite"></p>
<p id="movementHint" class="muted hidden">Move with arrow keys or WASD · drag/pinch to explore · ★ may require credits · ⛔ is unavailable on this server.</p>
<!-- Keep the live arena ahead of chat and controls in the reading and visual order. -->
<section id="spectatorStage" class="hidden" aria-label="Live arena" tabindex="-1">
<div class="spectator-head">
<span id="spectatorLabel" class="muted"></span>
<button id="leaveSpectateBtn" class="secondary">← Back to tavern</button>
</div>
<div id="spectatorRender" class="spectator-render"></div>
<div id="spectatorDpad" class="touch-pad hidden" role="group" aria-label="Match movement controls">
<button type="button" data-dir="up" aria-label="Move up">▲</button>
<button type="button" data-dir="left" aria-label="Move left">◀</button>
<button type="button" data-dir="down" aria-label="Move down">▼</button>
<button type="button" data-dir="right" aria-label="Move right">▶</button>
</div>
</section>
<div id="chatPanel" class="panel hidden">
<div id="chatLog" aria-live="polite"></div>
<form id="chatForm" autocomplete="off">
@@ -171,19 +186,6 @@
<div id="activeMatchList" style="max-height:160px;overflow-y:auto;font-size:13px;"></div>
<button id="closeMatchPanelBtn" class="secondary" style="margin-top:8px;">Close</button>
</div>
<div id="spectatorStage" class="hidden">
<div class="spectator-head">
<span id="spectatorLabel" class="muted"></span>
<button id="leaveSpectateBtn" class="secondary">← Back to tavern</button>
</div>
<div id="spectatorRender" class="spectator-render"></div>
<div id="spectatorDpad" class="touch-pad hidden" role="group" aria-label="Match movement controls">
<button type="button" data-dir="up" aria-label="Move up">▲</button>
<button type="button" data-dir="left" aria-label="Move left">◀</button>
<button type="button" data-dir="down" aria-label="Move down">▼</button>
<button type="button" data-dir="right" aria-label="Move right">▶</button>
</div>
</div>
</div>
<script>
@@ -219,6 +221,7 @@
var spectatorRender = document.getElementById('spectatorRender');
var spectatorDpad = document.getElementById('spectatorDpad');
var leaveSpectateBtn = document.getElementById('leaveSpectateBtn');
var spectatorReturnFocus = null;
// Character customization: persisted appearance identity, chosen before entering.
var customizeBtn = document.getElementById('customizeBtn');
@@ -699,6 +702,7 @@
if (spectatorCamera) spectatorCamera.update(spectatorRenderer);
}
function openSpectatorView(kind, label, initialState) {
spectatorReturnFocus = document.activeElement;
spectatorKind = kind;
spectatorPendingState = initialState || null;
joinPanel.classList.add('hidden');
@@ -713,6 +717,7 @@
spectatorDpad.classList.toggle('hidden', kind !== 'player-match');
spectatorLabel.textContent = label || 'Live dungeon';
leaveSpectateBtn.textContent = kind === 'player-match' ? 'Forfeit match' : '← Back to tavern';
spectatorStage.focus();
destroySpectatorRenderer();
var request = ++spectatorRequest;
var renderMode = currentMode || (RK.loadMode && RK.loadMode('tiles')) || 'tiles';
@@ -746,6 +751,14 @@
joinPanel.classList.remove('hidden');
}
leaveSpectateBtn.textContent = '← Back to tavern';
var returnFocus = spectatorReturnFocus;
spectatorReturnFocus = null;
window.requestAnimationFrame(function () {
if (spectatorKind) return;
if (!returnFocus || !document.contains(returnFocus) || !returnFocus.getClientRects().length
|| returnFocus.disabled) returnFocus = state.you ? stageEl : joinBtn;
if (returnFocus && typeof returnFocus.focus === 'function') returnFocus.focus();
});
}
function populateSoloGames(data) {
+148 -27
View File
@@ -1,7 +1,8 @@
'use strict';
// Gameplay-only browser check. This deliberately avoids the application entrypoint, database,
// wallet, payment handlers, and queue/race transitions. The Iso grant below is a visible fixture.
// wallet, payment handlers, and queue/race transitions. Mica receives synthetic Interior and Iso
// grants; Moth receives the source-owned zero-grant catalog fixture and must remain original-only.
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
@@ -9,23 +10,22 @@ const express = require('express');
const { Server } = require('socket.io');
const { chromium } = require('playwright-core');
const TavernManager = require('../network/tavernManager');
const Entitlements = require('../multiplayer/entitlements');
const ROOT = path.resolve(__dirname, '../..');
const HTML_ROOT = path.join(ROOT, 'html');
const OUTPUT_BASE = path.join(os.homedir(), 'Build', 'wowngeon', 'showcase-browser');
const FIXTURE_ENTITLEMENTS = Object.freeze({
premium: true,
level: 'free',
credits: 0,
totalCreditsPurchased: 0,
packs: Object.freeze({
'iso-dungeon': true,
'generated-skins': false,
'kenney-3d-characters': false,
'roguelike-dungeon': false,
'iso-medieval': false
})
});
const ZERO_FUNDS_USER = Object.freeze({ credits: 0, total_credits_purchased: 0 });
const FIXTURE_PACK_GRANTS = Object.freeze([
Object.freeze({ pack_id: 'roguelike-interior' }),
Object.freeze({ pack_id: 'iso-dungeon' })
]);
const FIXTURE_ENTITLEMENTS = Object.freeze(Entitlements.snapshotForUser(ZERO_FUNDS_USER, FIXTURE_PACK_GRANTS));
const FREE_ENTITLEMENTS = Object.freeze(Entitlements.snapshotForUser(ZERO_FUNDS_USER, []));
function entitlementsForSocket(socket) {
return socket.data.showcaseFixture === true ? FIXTURE_ENTITLEMENTS : FREE_ENTITLEMENTS;
}
function assert(condition, message) {
if (!condition) throw new Error(message);
@@ -65,9 +65,7 @@ async function main() {
const manager = new TavernManager({
io,
tickMs: 50,
entitlementProvider: socket => socket.data.showcaseFixture === true
? FIXTURE_ENTITLEMENTS
: { premium: false, totalCreditsPurchased: 0, packs: {} },
entitlementProvider: entitlementsForSocket,
globalChatProvider: {
publish(message) {
io.emit('chat_broadcast', {
@@ -107,15 +105,11 @@ async function main() {
});
socket.on('identity:get', () => socket.emit('identity_update', {
appearance: { avatar: 'default' },
entitlements: socket.data.showcaseFixture ? FIXTURE_ENTITLEMENTS : {
premium: false, level: 'free', credits: 0, totalCreditsPurchased: 0, packs: {}
}
entitlements: entitlementsForSocket(socket)
}));
socket.on('identity:update', data => socket.emit('identity_update', {
appearance: data?.appearance || { avatar: 'default' },
entitlements: socket.data.showcaseFixture ? FIXTURE_ENTITLEMENTS : {
premium: false, level: 'free', credits: 0, totalCreditsPurchased: 0, packs: {}
}
entitlements: entitlementsForSocket(socket)
}));
socket.on('tavern_join', data => manager.join(socket, data));
socket.on('tavern_move', data => manager.move(socket, data));
@@ -186,12 +180,73 @@ async function main() {
for (const { page } of Object.values(views)) {
await page.locator('#joinBtn').waitFor({ state: 'visible' });
await page.waitForFunction(() => document.getElementById('joinBtn') && !document.getElementById('joinBtn').disabled);
await page.waitForFunction(() => window.RK && window.RK.entitlements
&& Object.prototype.hasOwnProperty.call(window.RK.entitlements.packs, 'roguelike-interior'));
}
const packPolicy = {};
for (const role of ['mica', 'moth']) {
packPolicy[role] = await views[role].page.evaluate(() => {
const ids = ['original', 'roguelike-interior', 'generated-skins', 'iso-dungeon',
'roguelike-dungeon', 'iso-medieval', 'kenney-3d-characters'];
const appearance = RK.avatarVisuals.resolve({ avatar: 'default' }, { projection: 'topdown' });
return {
canUse: Object.fromEntries(ids.map(id => [id, RK.canUsePack(id)])),
resolvedDefaultPack: appearance.pack,
resolvedDefaultAllowed: appearance.allowed,
activeTopdownPack: RK.activePackId('topdown'),
credits: RK.entitlements.credits,
totalCreditsPurchased: RK.entitlements.totalCreditsPurchased
};
});
}
assert(packPolicy.moth.canUse.original && !packPolicy.moth.canUse['roguelike-interior']
&& !packPolicy.moth.canUse['generated-skins'] && !packPolicy.moth.canUse['iso-dungeon']
&& !packPolicy.moth.canUse['roguelike-dungeon'] && !packPolicy.moth.canUse['iso-medieval']
&& !packPolicy.moth.canUse['kenney-3d-characters'], 'Moth did not retain original-only zero-grant policy');
assert(packPolicy.moth.resolvedDefaultPack === 'roguelike-interior'
&& !packPolicy.moth.resolvedDefaultAllowed && packPolicy.moth.activeTopdownPack === 'original'
&& packPolicy.moth.credits === 0 && packPolicy.moth.totalCreditsPurchased === 0,
'Moth default character or active tileset bypassed the real pack policy');
assert(packPolicy.mica.canUse.original && packPolicy.mica.canUse['roguelike-interior']
&& packPolicy.mica.canUse['iso-dungeon'] && !packPolicy.mica.canUse['generated-skins']
&& !packPolicy.mica.canUse['roguelike-dungeon'] && !packPolicy.mica.canUse['iso-medieval']
&& !packPolicy.mica.canUse['kenney-3d-characters'], 'Mica fixture did not match its two explicit pack grants');
assert(packPolicy.mica.resolvedDefaultPack === 'roguelike-interior'
&& packPolicy.mica.resolvedDefaultAllowed && packPolicy.mica.credits === 0
&& packPolicy.mica.totalCreditsPurchased === 0,
'Mica synthetic character grant changed the no-funds balance or failed to permit the asset');
for (const { page } of Object.values(views)) {
await page.evaluate(() => {
const original = RK.drawCharTileCanvas;
window.__showcaseCharTileDraws = 0;
RK.drawCharTileCanvas = function () {
window.__showcaseCharTileDraws += 1;
return original.apply(this, arguments);
};
});
}
await views.mica.page.locator('#name').fill('Mica');
await views.mica.page.locator('#joinBtn').click();
await views.moth.page.locator('#name').fill('Moth');
await views.moth.page.locator('#joinBtn').click();
await Promise.all(Object.values(views).map(({ page }) => page.locator('#stage canvas.rk-canvas').waitFor({ state: 'visible' })));
await views.mica.page.waitForFunction(() => RK.charAtlas() && window.__showcaseCharTileDraws > 0,
undefined, { timeout: 5000 });
const characterRenderProof = {};
for (const role of ['mica', 'moth']) {
characterRenderProof[role] = await views[role].page.evaluate(() => ({
atlasReady: !!RK.charAtlas(),
drawCalls: window.__showcaseCharTileDraws,
defaultAllowed: RK.avatarVisuals.resolve({ avatar: 'default' }, { projection: 'topdown' }).allowed
}));
}
assert(characterRenderProof.mica.atlasReady && characterRenderProof.mica.drawCalls > 0
&& characterRenderProof.mica.defaultAllowed, 'Permitted synthetic Interior character was not actually drawn');
assert(characterRenderProof.moth.drawCalls === 0 && !characterRenderProof.moth.defaultAllowed,
'Original-only free client unexpectedly drew a gated character');
await views.mica.page.screenshot({
path: path.join(outputDir, 'tavern-synthetic-interior-character.png'), fullPage: true
});
await pageWaitFor(() => manager.room.size === 2, 3000, 'Two browser clients joined TavernManager');
assert(clientsByRole.size === 2, 'Both page-owned client ids were not registered');
const ids = { mica: clientsByRole.get('mica'), moth: clientsByRole.get('moth') };
@@ -290,6 +345,63 @@ async function main() {
return current.x !== mothStart.x || current.y !== mothStart.y;
}, 1500, 'Mobile movement control moves the other player');
const layoutScene = {
visibleTiles: Array.from({ length: 12 }, () => Array(12).fill("'1")),
exploredTiles: Array.from({ length: 12 }, () => Array(12).fill("'1")),
players: [{ id: ids.mica, name: 'Layout fixture', x: 5, y: 5, appearance: { avatar: 'default' } }],
entrance: [0, 0], exit: [11, 11], monster: null, items: {}, isSpectating: true
};
const desktopPage = views.mica.page;
await desktopPage.setViewportSize({ width: 1280, height: 900 });
await desktopPage.locator('#chatInput').focus();
io.to(ids.mica).emit('spectate_start', { playerId: 'layout fixture', initialState: layoutScene });
await desktopPage.locator('#spectatorStage').waitFor({ state: 'visible' });
await desktopPage.waitForFunction(() => document.activeElement === document.getElementById('spectatorStage'));
await desktopPage.keyboard.press('Enter');
await desktopPage.keyboard.press('Space');
assert(await desktopPage.locator('#spectatorStage').isVisible(),
'Enter or Space activated a control when the active arena received focus');
const desktopArena = await desktopPage.evaluate(() => {
const arena = document.getElementById('spectatorStage').getBoundingClientRect();
const chat = document.getElementById('chatPanel').getBoundingClientRect();
return {
arenaTop: arena.top, arenaBottom: arena.bottom, arenaHeight: arena.height,
chatTop: chat.top, viewportHeight: document.documentElement.clientHeight,
orderedBeforeChat: !!(document.getElementById('spectatorStage').compareDocumentPosition(
document.getElementById('chatPanel')) & Node.DOCUMENT_POSITION_FOLLOWING)
};
});
assert(desktopArena.orderedBeforeChat && desktopArena.arenaBottom <= desktopArena.chatTop,
'Active arena does not precede chat in reading and viewport order');
assert(desktopArena.arenaHeight >= 300 && desktopArena.arenaTop < desktopArena.viewportHeight,
'Desktop active arena is not meaningfully visible in the initial viewport');
await desktopPage.locator('#leaveSpectateBtn').click();
await desktopPage.waitForFunction(() => document.activeElement === document.getElementById('chatInput'));
const mobilePage = views.moth.page;
await mobilePage.locator('#tavernDpad [data-dir="up"]').focus();
io.to(ids.moth).emit('spectate_start', { playerId: 'layout fixture', initialState: layoutScene });
await mobilePage.locator('#spectatorStage').waitFor({ state: 'visible' });
await mobilePage.waitForFunction(() => document.activeElement === document.getElementById('spectatorStage'));
const mobileArena = await mobilePage.evaluate(() => {
const arena = document.getElementById('spectatorStage').getBoundingClientRect();
const chat = document.getElementById('chatPanel').getBoundingClientRect();
return {
viewport: document.documentElement.clientWidth,
document: document.documentElement.scrollWidth,
arenaTop: arena.top, arenaBottom: arena.bottom, arenaHeight: arena.height,
chatTop: chat.top,
orderedBeforeChat: !!(document.getElementById('spectatorStage').compareDocumentPosition(
document.getElementById('chatPanel')) & Node.DOCUMENT_POSITION_FOLLOWING)
};
});
assert(mobileArena.orderedBeforeChat && mobileArena.arenaBottom <= mobileArena.chatTop,
'Mobile active arena does not precede chat in reading and viewport order');
assert(mobileArena.document <= mobileArena.viewport && mobileArena.arenaHeight >= 300
&& mobileArena.arenaTop < 844, 'Mobile active arena overflows or falls below the initial viewport');
await mobilePage.locator('#leaveSpectateBtn').click();
await mobilePage.waitForFunction(() => document.activeElement === document.querySelector('#tavernDpad [data-dir="up"]'));
const unexpectedHttpFailures = observedHttpFailures.filter(f => !(f.status === 404 && f.path === expectedMissingSkin));
assert(observedHttpFailures.some(f => f.status === 404 && f.path === expectedMissingSkin), 'Expected missing generated skin was not requested');
assert(unexpectedHttpFailures.length === 0, `Unexpected HTTP failures: ${JSON.stringify(unexpectedHttpFailures)}`);
@@ -298,22 +410,31 @@ async function main() {
const screenshotPaths = {};
for (const role of ['mica', 'moth']) {
const filename = `tavern-mobile-${role}.png`;
const filename = role === 'moth'
? 'tavern-mobile-free-original-baseline.png'
: `tavern-mobile-${role}.png`;
await views[role].page.screenshot({ path: path.join(outputDir, filename), fullPage: true });
screenshotPaths[role] = filename;
}
const result = {
ok: true,
scope: 'gameplay-only Tavern browser smoke; no queue/race transitions',
scope: 'gameplay-only Tavern browser smoke with isolated spectator layout fixture; no queue/race transitions',
origin: '127.0.0.1 loopback only',
fixture: 'synthetic Iso entitlement fixture; zero credits, no purchase, no generated skins or 3D grant',
fixture: 'Mica receives synthetic roguelike-interior and iso-dungeon grants; Moth receives the authoritative zero-grant, original-only snapshot; both have zero balances and no purchase, skin or 3D grant',
clients: { count: clientsByRole.size, mappedByPageOwnedSocketId: true },
packPolicy,
characterRenderProof,
modes: modeResults,
chat: 'broadcast to both clients',
keyboardFocusGuards: ['input', 'select', 'contenteditable'],
mobile,
activeArena: { desktop: desktopArena, mobile: mobileArena, arenaReceivesSafeInitialFocus: true, tavernFocusRestored: true },
missingGeneratedSkin: skinFailure,
screenshots: { desktopIso: 'tavern-desktop-iso.png', mobile: screenshotPaths },
screenshots: {
syntheticInteriorCharacter: 'tavern-synthetic-interior-character.png',
freeOriginalBaseline: screenshotPaths.moth,
desktopIso: 'tavern-desktop-iso.png', mobile: screenshotPaths
},
unexpectedHttpFailures,
externalRequestsBlocked: blockedExternal.length,
outputDir
+6 -2
View File
@@ -195,13 +195,14 @@ describe('renderer controls and failed skin fallback', () => {
test('a normal character still waits for its atlas and resolves the render callback', () => {
let atlasReady;
let atlasLoaded = false;
const loadAtlas = jest.fn(callback => { atlasReady = callback; });
const RK = {
CHARS: { 'char-villager': { frame: 1 } },
normalizeAppearance: appearance => ({ avatar: typeof appearance === 'string' ? appearance : appearance.avatar || 'default' }),
appearance: appearance => ({ label: appearance.avatar }),
isChar: id => id === 'char-villager',
charAtlas: () => atlasLoaded ? {} : null,
loadCharAtlas: callback => { atlasReady = callback; },
loadCharAtlas: loadAtlas,
charAppearance: appearance => ({ avatar: appearance.avatar, tint: 'none', colors: {}, equipment: {} }),
charFrame: () => ({ squash: 1, bob: 0 }),
drawCharTileCanvas: jest.fn(),
@@ -218,7 +219,10 @@ describe('renderer controls and failed skin fallback', () => {
const ready = jest.fn();
const canvas = { beginPath() {}, ellipse() {}, stroke() {} };
const entity = { id: 'me', x: 1, y: 1, facing: 'down', you: true };
expect(RK.avatarVisuals.drawTopdownWorld(canvas, visual, entity, { screenX: 1, screenY: 1, cell: 24 }, { onReady: ready })).toBe(false);
for (let i = 0; i < 12; i++) {
expect(RK.avatarVisuals.drawTopdownWorld(canvas, visual, entity, { screenX: 1, screenY: 1, cell: 24 }, { onReady: ready })).toBe(false);
}
expect(loadAtlas).toHaveBeenCalledTimes(1);
atlasLoaded = true;
atlasReady();
+151
View File
@@ -0,0 +1,151 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
function loadTileRenderer(options) {
options = options || {};
const frames = [];
const callbacks = [];
let atlasReady = false;
let skinReady = false;
let skinFailed = false;
const sprites = [];
let renderPasses = 0;
const ctx = {
fillRect() {}, strokeRect() {}, save() {}, restore() {}, beginPath() {}, ellipse() {},
arc() {}, fill() {}, stroke() {}, clearRect() {}, drawImage() {}, fillText() {}
};
const canvas = { className: '', width: 0, height: 0, parentNode: null, getContext: () => ctx };
const window = {
requestAnimationFrame(callback) { frames.push(callback); return frames.length; },
cancelAnimationFrame() {},
RK: {
themeAtlas: () => null,
tileMap: () => ({}),
roomReady: () => false,
charAtlas: () => atlasReady ? {} : null,
skinSheet: () => skinReady ? {} : null,
skinSheetFailed: () => skinFailed,
avatarVisuals: {
resolve: appearance => ({
kind: appearance.avatar === 'generated-test-skin' ? 'skin' : 'char',
allowed: true,
appearance
}),
drawTopdownWorld(_ctx, visual, entity, _viewport, opts) {
if (visual.kind === 'skin' && skinReady) { sprites.push(entity.id); return true; }
if (visual.kind === 'skin' && skinFailed && atlasReady) { sprites.push(entity.id); return true; }
if (visual.kind === 'char' && atlasReady) { sprites.push(entity.id); return true; }
if (visual.kind === 'skin' && options.transitionToChar) {
callbacks.push(opts.onReady);
return false;
}
if (options.completeWithoutReady) { opts.onReady(); return false; }
if (options.completeWithReady) { atlasReady = true; opts.onReady(); return false; }
callbacks.push(opts.onReady);
return false;
}
}
}
};
const document = { createElement: () => canvas };
const context = { window, document, RK: window.RK, Date };
vm.createContext(context);
vm.runInContext(fs.readFileSync(path.join(__dirname, '../html/js/render/tileRenderer.js'), 'utf8'), context);
const host = { appendChild(child) { child.parentNode = this; }, removeChild(child) { child.parentNode = null; } };
const renderer = new window.RK.TileRenderer(host);
renderer._collectEmitters = () => {};
renderer._snapshot = () => { renderPasses += 1; };
renderer._ensureEmbers = () => {};
renderer._composite = () => {};
renderer._startLoop = () => {};
function scene(id) {
return {
cols: 1, rows: 1, grid: [['floor']], legend: { floor: { color: '#654321' } },
entities: [{ id, kind: 'avatar', x: 0, y: 0, appearance: { avatar: 'default' } }]
};
}
return {
callbacks, frames, renderer, scene, sprites,
ready() { atlasReady = true; },
failSkin() { skinFailed = true; },
get renderPasses() { return renderPasses; }
};
}
describe('TileRenderer character atlas repaint', () => {
test('repaints the latest retained scene once when the free character atlas becomes ready', () => {
const kit = loadTileRenderer();
const first = kit.scene('first');
first.entities.push({ id: 'first-other', kind: 'avatar', x: 0, y: 0, appearance: { avatar: 'default' } });
const latest = kit.scene('latest');
latest.entities.push({ id: 'latest-other', kind: 'avatar', x: 0, y: 0, appearance: { avatar: 'default' } });
kit.renderer.render(first);
kit.renderer.render(latest);
expect(new Set(kit.callbacks).size).toBe(1);
expect(kit.sprites).toEqual([]);
kit.ready();
kit.callbacks.forEach(callback => callback());
expect(kit.frames).toHaveLength(1);
kit.frames.shift()();
expect(kit.sprites).toEqual(['latest', 'latest-other']);
expect(kit.renderPasses).toBe(3);
expect(kit.frames).toHaveLength(0);
});
test('does not repaint after destruction, including when readiness was already queued', () => {
const kit = loadTileRenderer();
kit.renderer.render(kit.scene('removed'));
kit.ready();
kit.callbacks[0]();
expect(kit.frames).toHaveLength(1);
kit.renderer.destroy();
kit.frames.shift()();
expect(kit.renderPasses).toBe(1);
expect(kit.sprites).toEqual([]);
});
test('does not retry a loader callback that completes without a drawable atlas', () => {
const kit = loadTileRenderer({ completeWithoutReady: true });
kit.renderer.render(kit.scene('unavailable'));
expect(kit.frames).toHaveLength(0);
expect(kit.renderPasses).toBe(1);
});
test('retains the current scene before a loader completion callback can repaint it', () => {
const kit = loadTileRenderer({ completeWithReady: true });
kit.renderer.render(kit.scene('synchronous-ready'));
expect(kit.frames).toHaveLength(1);
kit.frames.shift()();
expect(kit.sprites).toEqual(['synchronous-ready']);
expect(kit.renderPasses).toBe(2);
expect(kit.frames).toHaveLength(0);
});
test('repaints after a failed skin so the free character fallback can load and draw', () => {
const kit = loadTileRenderer({ transitionToChar: true });
const skinScene = kit.scene('free-fallback');
skinScene.entities[0].appearance = { avatar: 'generated-test-skin' };
kit.renderer.render(skinScene);
expect(kit.callbacks).toHaveLength(1);
expect(kit.frames).toHaveLength(0);
kit.failSkin();
kit.callbacks[0]();
expect(kit.frames).toHaveLength(1);
kit.frames.shift()();
expect(kit.callbacks).toHaveLength(2);
expect(kit.sprites).toEqual([]);
kit.ready();
kit.callbacks[1]();
expect(kit.frames).toHaveLength(1);
kit.frames.shift()();
expect(kit.sprites).toEqual(['free-fallback']);
expect(kit.renderPasses).toBe(3);
expect(kit.frames).toHaveLength(0);
});
});