feat: add playable Zhuo prologue village
This commit is contained in:
415
scripts/verify-prologue-village-browser.mjs
Normal file
415
scripts/verify-prologue-village-browser.mjs
Normal file
@@ -0,0 +1,415 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { chromium } from 'playwright';
|
||||
import {
|
||||
desktopBrowserContextOptions,
|
||||
desktopBrowserDeviceScaleFactor,
|
||||
desktopBrowserViewport
|
||||
} from './desktop-browser-viewport.mjs';
|
||||
|
||||
const targetUrl = withDebugOptions(
|
||||
process.env.VERIFY_PROLOGUE_VILLAGE_URL ?? 'http://127.0.0.1:41796/'
|
||||
);
|
||||
|
||||
let serverProcess;
|
||||
let browser;
|
||||
|
||||
try {
|
||||
mkdirSync('dist', { recursive: true });
|
||||
serverProcess = await ensureLocalServer(targetUrl);
|
||||
browser = await chromium.launch({
|
||||
headless: process.env.VERIFY_PROLOGUE_VILLAGE_HEADLESS !== '0'
|
||||
});
|
||||
const context = await browser.newContext(desktopBrowserContextOptions);
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(30000);
|
||||
|
||||
const pageErrors = [];
|
||||
const consoleErrors = [];
|
||||
page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message));
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') {
|
||||
consoleErrors.push(message.text());
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||
await page.evaluate(() => window.localStorage.clear());
|
||||
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||
await waitForDebugApi(page);
|
||||
await waitForTitle(page);
|
||||
await assertDesktopViewport(page);
|
||||
|
||||
await clickLegacyTitleControl(page, 962, 240);
|
||||
await waitForStoryReady(page);
|
||||
const opening = await readStory(page);
|
||||
assert.equal(opening.totalPages, 2, 'The playable prologue should retain only the two setup pages before control is handed over.');
|
||||
assert.equal(opening.nextScene, 'PrologueVillageScene');
|
||||
assert.equal(opening.currentPageId, 'yellow-turban-chaos');
|
||||
await page.mouse.click(960, 850);
|
||||
await page.waitForFunction(() => {
|
||||
const scene = window.__HEROS_DEBUG__?.scene('StoryScene');
|
||||
return scene?.getDebugState?.()?.currentPageId === 'liu-bei-resolve' && scene?.transitioning === false;
|
||||
});
|
||||
await page.keyboard.press('Space');
|
||||
await waitForVillageReady(page);
|
||||
|
||||
await page.waitForTimeout(380);
|
||||
let village = await readVillage(page);
|
||||
assert.equal(village.campaignStep, 'prologue-town');
|
||||
assert.equal(village.requiredTexturesReady, true);
|
||||
assert.equal(village.viewport.width, desktopBrowserViewport.width);
|
||||
assert.equal(village.viewport.height, desktopBrowserViewport.height);
|
||||
assert.equal(village.movement.speed, 300);
|
||||
assert.equal(village.interaction.radius, 122);
|
||||
assert.equal(village.npcs.length, 4);
|
||||
assert.equal(village.objectives.length, 4);
|
||||
assert.equal(village.completedObjectiveIds.length, 0);
|
||||
assert.equal(village.exitUnlocked, false);
|
||||
assert.equal(village.dialogue.active, true, 'The first visit should open one short Liu Bei orientation line.');
|
||||
assertBoundsInsideViewport(village.player.bounds, 'initial player');
|
||||
village.npcs.forEach((npc) => assertBoundsInsideViewport(npc.bounds, `NPC ${npc.id}`));
|
||||
|
||||
await page.mouse.click(960, 850);
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.village?.()?.dialogue?.active === false);
|
||||
await captureStableScreenshot(page, 'dist/verification-prologue-village-initial.png');
|
||||
|
||||
const beforeMove = await readVillage(page);
|
||||
await page.keyboard.down('d');
|
||||
await page.waitForTimeout(430);
|
||||
await page.keyboard.up('d');
|
||||
await page.waitForTimeout(80);
|
||||
const afterMove = await readVillage(page);
|
||||
assert(
|
||||
afterMove.player.x - beforeMove.player.x >= 85,
|
||||
`Expected direct keyboard movement to advance Liu Bei: ${JSON.stringify({ before: beforeMove.player, after: afterMove.player })}`
|
||||
);
|
||||
assert(
|
||||
Math.abs(afterMove.player.y - beforeMove.player.y) <= 3,
|
||||
`Expected horizontal movement to retain Y: ${JSON.stringify({ before: beforeMove.player, after: afterMove.player })}`
|
||||
);
|
||||
assert.equal(afterMove.player.moving, false);
|
||||
|
||||
await page.keyboard.press('e');
|
||||
await page.waitForTimeout(120);
|
||||
assert.equal((await readVillage(page)).dialogue.active, false, 'Interaction outside the radius must not open dialogue.');
|
||||
|
||||
await teleportTo(page, 'zhang-fei');
|
||||
await page.keyboard.press('e');
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'zhang-fei');
|
||||
await captureStableScreenshot(page, 'dist/verification-prologue-village-active-dialogue.png');
|
||||
const dialoguePlayerBefore = (await readVillage(page)).player;
|
||||
await page.keyboard.down('ArrowRight');
|
||||
await page.waitForTimeout(360);
|
||||
await page.keyboard.up('ArrowRight');
|
||||
const dialoguePlayerAfter = (await readVillage(page)).player;
|
||||
assert(
|
||||
Math.abs(dialoguePlayerAfter.x - dialoguePlayerBefore.x) < 1 &&
|
||||
Math.abs(dialoguePlayerAfter.y - dialoguePlayerBefore.y) < 1,
|
||||
'Movement must remain blocked while dialogue is open.'
|
||||
);
|
||||
await advanceDialogueUntilClosed(page);
|
||||
village = await readVillage(page);
|
||||
assert(village.completedObjectiveIds.includes('meet-zhang-fei'));
|
||||
assert.equal(village.exitUnlocked, false);
|
||||
await captureStableScreenshot(page, 'dist/verification-prologue-village-dialogue.png');
|
||||
|
||||
await teleportTo(page, 'make-oath');
|
||||
await page.keyboard.press('e');
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.village?.()?.dialogue?.active === true);
|
||||
const lockedOath = await readVillage(page);
|
||||
assert.equal(lockedOath.navigationPending, false);
|
||||
assert.equal(lockedOath.dialogue.totalLines, 1);
|
||||
await advanceDialogueUntilClosed(page);
|
||||
assert.equal((await readVillage(page)).exitUnlocked, false, 'The oath must stay locked before all preparation goals are complete.');
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||
await waitForDebugApi(page);
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene'));
|
||||
await clickLegacyTitleControl(page, 962, 310);
|
||||
await waitForVillageReady(page);
|
||||
await page.waitForTimeout(380);
|
||||
village = await readVillage(page);
|
||||
assert(village.completedObjectiveIds.includes('meet-zhang-fei'), 'A completed NPC goal must survive reload and Continue.');
|
||||
assert.equal(village.dialogue.active, false, 'The one-time orientation line must not replay on Continue.');
|
||||
|
||||
for (const npcId of ['guan-yu', 'quartermaster']) {
|
||||
await teleportTo(page, npcId);
|
||||
await page.keyboard.press('e');
|
||||
await page.waitForFunction((expectedNpcId) => (
|
||||
window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === expectedNpcId
|
||||
), npcId);
|
||||
await advanceDialogueUntilClosed(page);
|
||||
}
|
||||
|
||||
village = await readVillage(page);
|
||||
assert.deepEqual(
|
||||
[...village.completedObjectiveIds].sort(),
|
||||
['check-supplies', 'meet-guan-yu', 'meet-zhang-fei'].sort()
|
||||
);
|
||||
assert.equal(village.exitUnlocked, true);
|
||||
assert.equal(village.oath.visible, true);
|
||||
await captureStableScreenshot(page, 'dist/verification-prologue-village-ready.png');
|
||||
|
||||
await teleportTo(page, 'make-oath');
|
||||
await page.keyboard.press('e');
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.village?.()?.dialogue?.sourceNpcId === 'make-oath');
|
||||
assert.equal((await readVillage(page)).dialogue.totalLines, 6);
|
||||
await advanceDialogueUntilClosed(page);
|
||||
await waitForStoryReady(page);
|
||||
|
||||
const departure = await readStory(page);
|
||||
assert.equal(departure.totalPages, 4);
|
||||
assert.equal(departure.currentPageId, 'first-sortie');
|
||||
assert.equal(departure.nextScene, 'BattleScene');
|
||||
const savedDeparture = await readCampaignSave(page);
|
||||
assert.equal(savedDeparture.step, 'prologue-town', 'The campaign should remain resumable in departure story until BattleScene starts.');
|
||||
assert(savedDeparture.completedTutorialIds.includes('prologue-village-complete'));
|
||||
assert(!savedDeparture.completedTutorialIds.includes('first-battle-basic-controls'));
|
||||
|
||||
for (let pageIndex = 1; pageIndex < 4; pageIndex += 1) {
|
||||
await page.mouse.click(960, 850);
|
||||
await page.waitForFunction((expectedPageIndex) => {
|
||||
const scene = window.__HEROS_DEBUG__?.scene('StoryScene');
|
||||
return scene?.getDebugState?.()?.pageIndex === expectedPageIndex && scene?.transitioning === false;
|
||||
}, pageIndex);
|
||||
}
|
||||
await page.keyboard.press('Space');
|
||||
await page.waitForFunction(() => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle?.();
|
||||
return (
|
||||
window.__HEROS_DEBUG__?.activeScenes?.().includes('BattleScene') &&
|
||||
battle?.battleId === 'first-battle-zhuo-commandery' &&
|
||||
battle?.phase === 'deployment' &&
|
||||
battle?.mapBackgroundReady === true
|
||||
);
|
||||
}, undefined, { timeout: 90000 });
|
||||
const battleSave = await readCampaignSave(page);
|
||||
assert.equal(battleSave.step, 'first-battle');
|
||||
assert(!battleSave.completedTutorialIds.includes('first-battle-basic-controls'));
|
||||
|
||||
assert.deepEqual(pageErrors, [], `Expected no browser page errors: ${JSON.stringify(pageErrors)}`);
|
||||
assert.deepEqual(
|
||||
consoleErrors.filter((message) => !message.includes('The AudioContext was not allowed to start')),
|
||||
[],
|
||||
`Expected no browser console errors: ${JSON.stringify(consoleErrors)}`
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Verified the playable prologue village at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` +
|
||||
`DPR ${desktopBrowserDeviceScaleFactor}: opening handoff, direct movement, distance-gated dialogue, ` +
|
||||
'dialogue movement lock, locked oath, objective autosave/reload, final oath, departure story, and first deployment.'
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
if (serverProcess && !serverProcess.killed) {
|
||||
serverProcess.kill();
|
||||
}
|
||||
}
|
||||
|
||||
function withDebugOptions(url) {
|
||||
const parsed = new URL(url);
|
||||
parsed.searchParams.set('debug', '1');
|
||||
parsed.searchParams.set('renderer', 'canvas');
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function waitForDebugApi(page) {
|
||||
await page.waitForFunction(() => (
|
||||
document.querySelector('canvas') !== null &&
|
||||
window.__HEROS_DEBUG__ !== undefined
|
||||
), undefined, { timeout: 90000 });
|
||||
}
|
||||
|
||||
async function waitForTitle(page) {
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene'), undefined, {
|
||||
timeout: 90000
|
||||
});
|
||||
}
|
||||
|
||||
async function clickLegacyTitleControl(page, x, y) {
|
||||
await page.mouse.click(x * 1.5, y * 1.5);
|
||||
}
|
||||
|
||||
async function waitForStoryReady(page) {
|
||||
await page.waitForFunction(() => {
|
||||
const story = window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.();
|
||||
return window.__HEROS_DEBUG__?.activeScenes?.().includes('StoryScene') && story?.ready === true;
|
||||
}, undefined, { timeout: 90000 });
|
||||
}
|
||||
|
||||
async function waitForVillageReady(page) {
|
||||
try {
|
||||
await page.waitForFunction(() => {
|
||||
const village = window.__HEROS_DEBUG__?.village?.();
|
||||
return (
|
||||
window.__HEROS_DEBUG__?.activeScenes?.().includes('PrologueVillageScene') &&
|
||||
village?.scene === 'PrologueVillageScene' &&
|
||||
village?.ready === true
|
||||
);
|
||||
}, undefined, { timeout: 30000 });
|
||||
} catch (error) {
|
||||
const diagnostic = await page.evaluate(() => ({
|
||||
activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
|
||||
village: window.__HEROS_DEBUG__?.village?.() ?? null,
|
||||
story: window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.() ?? null
|
||||
}));
|
||||
throw new Error(`Playable village did not become ready: ${JSON.stringify(diagnostic)}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async function readStory(page) {
|
||||
return page.evaluate(() => window.__HEROS_DEBUG__?.scene('StoryScene')?.getDebugState?.());
|
||||
}
|
||||
|
||||
async function readVillage(page) {
|
||||
return page.evaluate(() => window.__HEROS_DEBUG__?.village?.());
|
||||
}
|
||||
|
||||
async function teleportTo(page, targetId) {
|
||||
const teleported = await page.evaluate((id) => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('PrologueVillageScene');
|
||||
return scene?.debugTeleportTo?.(id) ?? false;
|
||||
}, targetId);
|
||||
assert.equal(teleported, true, `Expected debug teleport to ${targetId}.`);
|
||||
await page.waitForTimeout(80);
|
||||
}
|
||||
|
||||
async function advanceDialogueUntilClosed(page) {
|
||||
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||
const active = (await readVillage(page))?.dialogue?.active;
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
await page.mouse.click(960, 850);
|
||||
await page.waitForTimeout(130);
|
||||
}
|
||||
throw new Error(`Dialogue did not close: ${JSON.stringify(await readVillage(page))}`);
|
||||
}
|
||||
|
||||
async function readCampaignSave(page) {
|
||||
return page.evaluate(() => {
|
||||
const raw = window.localStorage.getItem('heros-web:campaign-state');
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
});
|
||||
}
|
||||
|
||||
async function assertDesktopViewport(page) {
|
||||
const viewport = await page.evaluate(() => {
|
||||
const canvas = document.querySelector('canvas');
|
||||
const bounds = canvas?.getBoundingClientRect();
|
||||
return {
|
||||
innerWidth: window.innerWidth,
|
||||
innerHeight: window.innerHeight,
|
||||
devicePixelRatio: window.devicePixelRatio,
|
||||
canvas: canvas
|
||||
? {
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
clientWidth: canvas.clientWidth,
|
||||
clientHeight: canvas.clientHeight,
|
||||
bounds: bounds
|
||||
? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
|
||||
: null
|
||||
}
|
||||
: null
|
||||
};
|
||||
});
|
||||
assert.equal(viewport.innerWidth, desktopBrowserViewport.width);
|
||||
assert.equal(viewport.innerHeight, desktopBrowserViewport.height);
|
||||
assert.equal(viewport.devicePixelRatio, desktopBrowserDeviceScaleFactor);
|
||||
assert.equal(viewport.canvas?.width, desktopBrowserViewport.width);
|
||||
assert.equal(viewport.canvas?.height, desktopBrowserViewport.height);
|
||||
assert.equal(viewport.canvas?.clientWidth, desktopBrowserViewport.width);
|
||||
assert.equal(viewport.canvas?.clientHeight, desktopBrowserViewport.height);
|
||||
assert.equal(viewport.canvas?.bounds?.width, desktopBrowserViewport.width);
|
||||
assert.equal(viewport.canvas?.bounds?.height, desktopBrowserViewport.height);
|
||||
}
|
||||
|
||||
function assertBoundsInsideViewport(bounds, label) {
|
||||
assert(bounds, `${label}: bounds are required`);
|
||||
assert(
|
||||
bounds.x >= 0 &&
|
||||
bounds.y >= 0 &&
|
||||
bounds.x + bounds.width <= desktopBrowserViewport.width &&
|
||||
bounds.y + bounds.height <= desktopBrowserViewport.height,
|
||||
`${label}: expected in-viewport bounds, received ${JSON.stringify(bounds)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function captureStableScreenshot(page, path) {
|
||||
await page.waitForTimeout(220);
|
||||
const loopSlept = await page.evaluate(() => {
|
||||
const loop = window.__HEROS_GAME__?.loop;
|
||||
if (!loop || typeof loop.sleep !== 'function') {
|
||||
return false;
|
||||
}
|
||||
loop.sleep();
|
||||
return true;
|
||||
});
|
||||
await page.waitForTimeout(40);
|
||||
try {
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
} finally {
|
||||
if (loopSlept) {
|
||||
await page.evaluate(() => window.__HEROS_GAME__?.loop.wake());
|
||||
await page.waitForTimeout(40);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureLocalServer(url) {
|
||||
if (await canReach(url)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = new URL(url);
|
||||
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname)) {
|
||||
throw new Error(`No server responded at ${url}`);
|
||||
}
|
||||
|
||||
const stderr = [];
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
'node_modules/vite/bin/vite.js',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
parsed.port || '41796',
|
||||
'--strictPort'
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
}
|
||||
);
|
||||
child.stderr.on('data', (chunk) => stderr.push(chunk.toString()));
|
||||
child.stdout.on('data', () => {});
|
||||
|
||||
for (let attempt = 0; attempt < 120; attempt += 1) {
|
||||
if (await canReach(url)) {
|
||||
return child;
|
||||
}
|
||||
await delay(250);
|
||||
}
|
||||
|
||||
child.kill();
|
||||
throw new Error(`Vite server did not start at ${url}\n${stderr.join('')}`);
|
||||
}
|
||||
|
||||
async function canReach(url) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
Reference in New Issue
Block a user