mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-22 16:48:34 +02:00
fix(tavern): repaint permitted avatars and prioritize the live arena
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