import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; import { mkdirSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright'; import { desktopBrowserContextOptions, desktopBrowserDeviceScaleFactor, desktopBrowserViewport, desktopBrowserViewportBounds } from './desktop-browser-viewport.mjs'; const renderer = process.env.VERIFY_CAMP_VISIT_RESUME_RENDERER; const rendererNames = ['canvas', 'webgl']; const visitCases = [ { visitId: 'first-pursuit-scout-tent', battleId: 'first-battle-zhuo-commandery', step: 'first-camp', verifyNormalReturn: true }, { visitId: 'second-pursuit-aftermath-relief', battleId: 'second-battle-yellow-turban-pursuit', step: 'second-camp', verifyNormalReturn: false } ]; if (!renderer) { for (const requestedRenderer of rendererNames) { const result = spawnSync( process.execPath, [fileURLToPath(import.meta.url)], { cwd: process.cwd(), env: { ...process.env, VERIFY_CAMP_VISIT_RESUME_RENDERER: requestedRenderer }, stdio: 'inherit' } ); if (result.status !== 0) { process.exit(result.status ?? 1); } } process.exit(0); } assert( rendererNames.includes(renderer), `Unsupported camp visit resume renderer "${renderer}".` ); const baseUrl = process.env.VERIFY_CAMP_VISIT_RESUME_URL ?? 'http://127.0.0.1:41808/heros_web/'; const targetUrl = withDebugOptions(baseUrl, renderer); const localSeedUrl = withDebugOptions( 'http://127.0.0.1:41808/heros_web/', renderer ); let serverProcess; let seedServerProcess; let browser; try { mkdirSync('dist', { recursive: true }); serverProcess = await ensureLocalServer(targetUrl); browser = await chromium.launch({ headless: process.env.VERIFY_CAMP_VISIT_RESUME_HEADLESS !== '0', args: renderer === 'webgl' ? [ '--use-angle=swiftshader', '--enable-unsafe-swiftshader' ] : [] }); 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.addInitScript(() => { const marker = 'heros-web:qa:camp-visit-resume-storage-cleared'; if (window.sessionStorage.getItem(marker) === '1') { return; } window.localStorage.clear(); window.sessionStorage.setItem(marker, '1'); }); await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); const results = []; for (const [visitIndex, visitCase] of visitCases.entries()) { let seeded; if (isLocalUrl(targetUrl)) { seeded = await seedCampVisitResume(page, visitCase); } else { const bridge = await createSeedBridge( browser, localSeedUrl, visitCase ); seedServerProcess ??= bridge.serverProcess; seeded = bridge.seeded; await installCampaignSaves(page, bridge.campaignSaves); } assertSeededVisit(seeded, visitCase); await reloadToTitle(page); assertRuntimeBaseline( await readRuntimeProbe(page, 'TitleScene'), renderer, `${visitCase.visitId} title` ); if (visitIndex === 0) { await page.screenshot({ path: `dist/camp-visit-resume-title-${renderer}.png` }); } const continueBounds = await readEnabledContinueBounds(page); assertBoundsContained( continueBounds, desktopBrowserViewportBounds, `${renderer} ${visitCase.visitId} continue button` ); await clickSceneBounds( page, 'TitleScene', continueBounds ); const exploration = await waitForExplorationReady( page, visitCase.visitId ); assert.equal(exploration.locationId, visitCase.visitId); assert.equal(exploration.visit.id, visitCase.visitId); assert.equal(exploration.campaignStep, visitCase.step); assertRuntimeBaseline( await readRuntimeProbe( page, 'CampVisitExplorationScene' ), renderer, `${visitCase.visitId} resumed exploration` ); if (visitIndex === 0) { await page.screenshot({ path: `dist/camp-visit-resume-exploration-${renderer}.png` }); } assertActiveVisitMarker( await readCampaignStates(page), visitCase.visitId, `${renderer} ${visitCase.visitId} after continue` ); const result = { visitId: visitCase.visitId, titleContinueClicked: true, resumedScene: exploration.scene, markerRestored: true }; if (visitCase.verifyNormalReturn) { const returnResult = await returnIncompleteVisitThroughExit(page, visitCase); Object.assign(result, returnResult); } results.push(result); } const actionableConsoleErrors = consoleErrors.filter( (message) => !message.includes( 'The AudioContext was not allowed to start' ) ); assert.deepEqual( pageErrors, [], `${renderer}: page errors: ${pageErrors.join('\n')}` ); assert.deepEqual( actionableConsoleErrors, [], `${renderer}: console errors: ${actionableConsoleErrors.join( '\n' )}` ); console.log( JSON.stringify( { renderer, viewport: { ...desktopBrowserViewport, deviceScaleFactor: desktopBrowserDeviceScaleFactor, browserZoom: '100%' }, visits: results, pageErrors: pageErrors.length, consoleErrors: actionableConsoleErrors.length }, null, 2 ) ); } finally { await browser?.close(); await stopServerProcess(serverProcess); await stopServerProcess(seedServerProcess); } function withDebugOptions(url, requestedRenderer) { const parsed = new URL(url); parsed.searchParams.set('debug', '1'); parsed.searchParams.set('renderer', requestedRenderer); return parsed.toString(); } function isLocalUrl(url) { return ['localhost', '127.0.0.1', '0.0.0.0'].includes( new URL(url).hostname ); } async function waitForDebugApi(page) { await page.waitForFunction( () => document.querySelector('canvas') !== null && window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 } ); } async function seedCampVisitResume(page, visitCase) { return page.evaluate(async (requested) => { const campaignModule = await import( '/heros_web/src/game/state/campaignState.ts' ); const { battleScenarios } = await import( '/heros_web/src/game/data/battles.ts' ); const scenario = battleScenarios[requested.battleId]; if (!scenario) { throw new Error( `Missing battle scenario ${requested.battleId}.` ); } campaignModule.resetCampaignState(); campaignModule.setFirstBattleReport({ battleId: scenario.id, battleTitle: scenario.title, outcome: 'victory', turnNumber: 6, rewardGold: scenario.baseVictoryGold, defeatedEnemies: scenario.units.filter( (unit) => unit.faction === 'enemy' ).length, totalEnemies: scenario.units.filter( (unit) => unit.faction === 'enemy' ).length, objectives: [], units: scenario.units, bonds: (scenario.bonds ?? []).map((bond) => ({ ...bond, battleExp: 0 })), itemRewards: [], completedCampDialogues: [], completedCampVisits: [], createdAt: '2026-07-28T00:00:00.000Z' }); campaignModule.completeCampaignAftermath(scenario.id); campaignModule.setActiveCampVisitId(requested.visitId); const campaign = campaignModule.getCampaignState(); const slot = campaignModule.readCampaignSaveState(1); return { step: campaign.step, latestBattleId: campaign.latestBattleId ?? null, reportBattleId: campaign.firstBattleReport?.battleId ?? null, reportOutcome: campaign.firstBattleReport?.outcome ?? null, settlementOutcome: campaign.battleHistory[requested.battleId]?.outcome ?? null, pendingAftermathBattleId: campaign.pendingAftermathBattleId ?? null, activeCampVisitId: campaign.activeCampVisitId ?? null, slotActiveCampVisitId: slot?.activeCampVisitId ?? null }; }, visitCase); } function assertSeededVisit(seeded, visitCase) { assert.deepEqual( seeded, { step: visitCase.step, latestBattleId: visitCase.battleId, reportBattleId: visitCase.battleId, reportOutcome: 'victory', settlementOutcome: 'victory', pendingAftermathBattleId: null, activeCampVisitId: visitCase.visitId, slotActiveCampVisitId: visitCase.visitId }, `${renderer}: failed to seed ${visitCase.visitId}.` ); } async function createSeedBridge( activeBrowser, seedUrl, visitCase ) { const localServerProcess = await ensureLocalServer(seedUrl); const context = await activeBrowser.newContext(desktopBrowserContextOptions); try { const page = await context.newPage(); page.setDefaultTimeout(30000); await page.addInitScript(() => window.localStorage.clear()); await page.goto(seedUrl, { waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); const seeded = await seedCampVisitResume(page, visitCase); const campaignSaves = await readCampaignSaves(page); assert( Object.keys(campaignSaves).length > 0, `Local seed bridge did not create ${visitCase.visitId}.` ); return { campaignSaves, seeded, serverProcess: localServerProcess }; } catch (error) { await stopServerProcess(localServerProcess); throw error; } finally { await context.close(); } } async function installCampaignSaves(page, campaignSaves) { await page.evaluate((saves) => { Object.keys(window.localStorage) .filter((key) => key.startsWith('heros-web:campaign-state') ) .forEach((key) => window.localStorage.removeItem(key)); for (const [key, value] of Object.entries(saves)) { window.localStorage.setItem(key, value); } }, campaignSaves); } async function reloadToTitle(page) { await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await page.waitForFunction( () => { const debug = window.__HEROS_DEBUG__; const title = debug?.title?.(); const continueItem = title?.focus?.items?.find( (item) => item.id === 'continue' ); return ( debug?.activeScenes?.().includes('TitleScene') && title?.scene === 'TitleScene' && title?.navigating === false && title?.focus?.scope === 'main' && continueItem?.enabled === true && continueItem?.bounds ); }, undefined, { timeout: 90000 } ); await afterTwoFrames(page); } async function readEnabledContinueBounds(page) { const item = await page.evaluate(() => window.__HEROS_DEBUG__ ?.title?.() ?.focus?.items?.find((candidate) => candidate.id === 'continue' ) ?? null ); assert.equal( item?.enabled, true, `${renderer}: title continue button is not enabled.` ); return assertFiniteBounds( item.bounds, `${renderer} title continue button` ); } async function clickSceneBounds(page, sceneKey, bounds) { const point = await page.evaluate( ({ key, requestedBounds }) => { const scene = window.__HEROS_DEBUG__?.scene(key); const canvasBounds = document.querySelector('canvas')?.getBoundingClientRect(); if (!scene || !canvasBounds) { return null; } return { x: canvasBounds.left + (requestedBounds.x + requestedBounds.width / 2) * canvasBounds.width / scene.scale.width, y: canvasBounds.top + (requestedBounds.y + requestedBounds.height / 2) * canvasBounds.height / scene.scale.height }; }, { key: sceneKey, requestedBounds: bounds } ); assert( point && Number.isFinite(point.x) && Number.isFinite(point.y), `Unable to map ${sceneKey} bounds ${JSON.stringify( bounds )}.` ); await page.mouse.click(point.x, point.y); } async function waitForExplorationReady(page, visitId) { try { await page.waitForFunction( (expectedVisitId) => { const debug = window.__HEROS_DEBUG__; const exploration = debug?.campVisitExploration?.(); return ( debug ?.activeScenes?.() .includes('CampVisitExplorationScene') && exploration?.ready === true && exploration?.locationId === expectedVisitId && exploration?.visit?.id === expectedVisitId && exploration?.requiredTexturesReady === true ); }, visitId, { timeout: 90000 } ); } catch (error) { const diagnostic = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], title: window.__HEROS_DEBUG__?.title?.() ?? null, exploration: window.__HEROS_DEBUG__ ?.campVisitExploration?.() ?? null })); throw new Error( `${renderer}: ${visitId} did not resume: ` + JSON.stringify(diagnostic), { cause: error } ); } await afterTwoFrames(page); return page.evaluate(() => window.__HEROS_DEBUG__?.campVisitExploration?.() ); } async function returnIncompleteVisitThroughExit( page, visitCase ) { assert.equal( visitCase.visitId, 'first-pursuit-scout-tent' ); const before = await page.evaluate(() => window.__HEROS_DEBUG__?.campVisitExploration?.() ); assert.equal( before?.visit?.completed, false, `${renderer}: first scout visit seed must be incomplete.` ); await page.waitForTimeout(400); const teleported = await page.evaluate(() => window.__HEROS_DEBUG__ ?.scene('CampVisitExplorationScene') ?.debugTeleportTo?.('camp-exit') ?? false ); assert.equal( teleported, true, `${renderer}: could not reach the camp exit.` ); await page.waitForFunction( () => { const state = window.__HEROS_DEBUG__?.campVisitExploration?.(); return ( state?.interaction?.targetId === 'camp-exit' && state?.interaction?.canInteract === true ); } ); await page.keyboard.press('e'); await page.waitForFunction( () => { const state = window.__HEROS_DEBUG__?.campVisitExploration?.(); return ( state?.dialogue?.active === true && state.dialogue.sourceNpcId === 'camp-exit' ); }, undefined, { timeout: 5000 } ); for (let attempt = 0; attempt < 8; attempt += 1) { const activeScenes = await page.evaluate( () => window.__HEROS_DEBUG__?.activeScenes?.() ?? [] ); if (activeScenes.includes('CampScene')) { break; } const dialogueActive = await page.evaluate( () => window.__HEROS_DEBUG__ ?.campVisitExploration?.() ?.dialogue?.active === true ); if (dialogueActive) { await page.keyboard.press('e'); } await page.waitForTimeout(180); } await waitForCampReady(page, visitCase.step); assertRuntimeBaseline( await readRuntimeProbe(page, 'CampScene'), renderer, 'first visit normal return' ); assertClearedVisitMarker( await readCampaignStates(page), `${renderer} first visit after normal return` ); await page.evaluate(() => { const camp = window.__HEROS_DEBUG__?.scene('CampScene'); if (!camp) { throw new Error('CampScene is unavailable.'); } camp.scene.start('TitleScene'); }); await waitForTitleReadyWithoutReload(page); const continueBounds = await readEnabledContinueBounds(page); await clickSceneBounds( page, 'TitleScene', continueBounds ); await waitForCampReady(page, visitCase.step); assert( !(await page.evaluate(() => window.__HEROS_DEBUG__ ?.activeScenes?.() .includes('CampVisitExplorationScene') )), `${renderer}: cleared visit marker reopened exploration.` ); assertClearedVisitMarker( await readCampaignStates(page), `${renderer} first visit after second continue` ); return { normalExitReturnedTo: 'CampScene', markerCleared: true, secondTitleContinueReturnedTo: 'CampScene' }; } async function waitForCampReady(page, expectedStep) { try { await page.waitForFunction( (step) => { const debug = window.__HEROS_DEBUG__; const camp = debug?.camp?.(); return ( debug?.activeScenes?.().includes('CampScene') && camp?.scene === 'CampScene' && camp?.campaignObjectiveJournal?.snapshot && camp?.campaign?.step === step ); }, expectedStep, { timeout: 90000 } ); } catch (error) { const diagnostic = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], camp: window.__HEROS_DEBUG__?.camp?.() ?? null })); throw new Error( `${renderer}: CampScene did not become ready: ` + JSON.stringify(diagnostic), { cause: error } ); } await afterTwoFrames(page); } async function waitForTitleReadyWithoutReload(page) { await page.waitForFunction( () => { const debug = window.__HEROS_DEBUG__; const title = debug?.title?.(); return ( debug?.activeScenes?.().includes('TitleScene') && title?.scene === 'TitleScene' && title?.navigating === false && title?.focus?.items?.some( (item) => item.id === 'continue' && item.enabled === true && item.bounds ) ); }, undefined, { timeout: 90000 } ); await afterTwoFrames(page); } async function readCampaignSaves(page) { return page.evaluate(() => Object.fromEntries( Object.keys(window.localStorage) .filter((key) => key.startsWith('heros-web:campaign-state') ) .sort() .map((key) => [ key, window.localStorage.getItem(key) ]) ) ); } async function readCampaignStates(page) { const saves = await readCampaignSaves(page); return Object.fromEntries( Object.entries(saves).map(([key, value]) => [ key, JSON.parse(value) ]) ); } function assertActiveVisitMarker(states, visitId, label) { const entries = Object.entries(states); assert( entries.length >= 2, `${label}: expected current and slot campaign saves.` ); entries.forEach(([key, state]) => { assert.equal( state.activeCampVisitId ?? null, visitId, `${label}: ${key} marker mismatch.` ); }); } function assertClearedVisitMarker(states, label) { const entries = Object.entries(states); assert( entries.length >= 2, `${label}: expected current and slot campaign saves.` ); entries.forEach(([key, state]) => { assert.equal( state.activeCampVisitId ?? null, null, `${label}: ${key} retained an active visit marker.` ); }); } function assertFiniteBounds(bounds, label) { assert( bounds && Number.isFinite(bounds.x) && Number.isFinite(bounds.y) && Number.isFinite(bounds.width) && Number.isFinite(bounds.height) && bounds.width > 0 && bounds.height > 0, `${label}: invalid bounds ${JSON.stringify(bounds)}.` ); return bounds; } function assertBoundsContained(bounds, container, label) { assert( bounds.x >= container.x && bounds.y >= container.y && bounds.x + bounds.width <= container.x + container.width && bounds.y + bounds.height <= container.y + container.height, `${label}: ${JSON.stringify(bounds)} is outside ` + JSON.stringify(container) ); } async function afterTwoFrames(page) { await page.evaluate( () => new Promise((resolve) => { requestAnimationFrame(() => requestAnimationFrame(resolve) ); }) ); } async function readRuntimeProbe(page, sceneKey) { return page.evaluate((key) => { const game = window.__HEROS_GAME__; const scene = game?.scene.getScene(key); const canvas = document.querySelector('canvas'); const canvasBounds = canvas?.getBoundingClientRect(); return { viewport: { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio, zoom: window.visualViewport?.scale ?? 1 }, canvas: canvas ? { width: canvas.width, height: canvas.height, bounds: canvasBounds ? { x: canvasBounds.x, y: canvasBounds.y, width: canvasBounds.width, height: canvasBounds.height } : null } : null, renderer: { requested: new URLSearchParams( window.location.search ).get('renderer'), type: game?.renderer?.type ?? null, name: game?.renderer?.constructor?.name ?? null }, scene: scene ? { width: scene.scale.width, height: scene.scale.height } : null }; }, sceneKey); } function assertRuntimeBaseline( runtime, requestedRenderer, label ) { assert.deepEqual( runtime.viewport, { width: desktopBrowserViewport.width, height: desktopBrowserViewport.height, dpr: desktopBrowserDeviceScaleFactor, zoom: 1 }, `${label}: viewport, DPR, or browser zoom mismatch.` ); assert.deepEqual( runtime.canvas, { width: desktopBrowserViewport.width, height: desktopBrowserViewport.height, bounds: { x: 0, y: 0, width: desktopBrowserViewport.width, height: desktopBrowserViewport.height } }, `${label}: canvas did not fill the CSS viewport.` ); assert.deepEqual( runtime.scene, { width: desktopBrowserViewport.width, height: desktopBrowserViewport.height }, `${label}: Phaser scene size mismatch.` ); assert.equal( runtime.renderer.requested, requestedRenderer, `${label}: renderer query mismatch.` ); assert.equal( runtime.renderer.type, requestedRenderer === 'canvas' ? 1 : 2, `${label}: expected ${requestedRenderer}: ` + JSON.stringify(runtime.renderer) ); } 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 || '41808', '--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); } await stopServerProcess(child); 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)); } async function stopServerProcess(child) { if (!child || child.exitCode !== null) { return; } const exited = new Promise((resolve) => child.once('exit', resolve) ); child.kill(); await Promise.race([exited, delay(5000)]); if (child.exitCode === null) { child.kill('SIGKILL'); await Promise.race([exited, delay(2000)]); } }