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)); }