mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-11-14 11:03:53 +01:00
fix(tavern): repaint permitted avatars and prioritize the live arena (#3)
Permitted Tavern character sprites could remain placeholders after their atlas loaded, and the active dungeon appeared below chat and action controls. Repaint the latest scene when assets settle, coalesce callbacks, and bound pending callback registration. Preserve failed-skin fallback and renderer destruction behavior.
Place the labelled live arena before chat in DOM and visual order. Focus the arena rather than the Forfeit button, then restore a visible Tavern control on return. Cosmetic unlock rules are preserved. The browser fixture now derives full source-owned catalog snapshots and distinguishes explicit synthetic Interior/Iso grants from an original-only zero-grant client.
Validation at 87e688b4ac:
- Linux Node22.22.1: 134 suites and 1,115 tests passed.
- Actual two-browser application lifecycle passed through free queue, scheduler/countdown, keyboard Escape Race, persisted result and automatic Tavern return, with zero financial rows and successful cleanup.
- Updated browser smoke passed Tiled/ASCII/Iso, actual permitted character drawing, zero-grant denial, mobile layout, arena placement, safe initial focus and return focus; no unexpected HTTP or external requests.
- Fresh runtime artifact, sidecar, runtime manifest, locked production install and dependency graph verified with npm10.9.9.
- Independent source review, final credential scan, syntax and diff checks passed.
No production deployment, purchase, or installed-wallet payment acceptance is claimed.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] : [];
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user