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 sourceBattleId = 'first-battle-zhuo-commandery'; const visitId = 'first-pursuit-scout-tent'; const renderer = process.env.VERIFY_CAMPAIGN_OBJECTIVE_JOURNAL_RENDERER; const rendererNames = ['canvas', 'webgl']; const forbiddenFutureTerms = [ '제갈량', '형주 붕괴와 이릉', '백제성', '맹획', '오장원' ]; if (!renderer) { for (const requestedRenderer of rendererNames) { const result = spawnSync( process.execPath, [fileURLToPath(import.meta.url)], { cwd: process.cwd(), env: { ...process.env, VERIFY_CAMPAIGN_OBJECTIVE_JOURNAL_RENDERER: requestedRenderer }, stdio: 'inherit' } ); if (result.status !== 0) { process.exit(result.status ?? 1); } } process.exit(0); } assert( rendererNames.includes(renderer), `Unsupported campaign objective journal renderer "${renderer}".` ); const baseUrl = process.env.VERIFY_CAMPAIGN_OBJECTIVE_JOURNAL_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 ); const screenshotPath = `dist/verification-campaign-objective-journal-${renderer}-1920x1080.png`; let serverProcess; let seedServerProcess; let browser; try { mkdirSync('dist', { recursive: true }); serverProcess = await ensureLocalServer(targetUrl); browser = await chromium.launch({ headless: process.env.VERIFY_CAMPAIGN_OBJECTIVE_JOURNAL_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:campaign-objective-journal-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); let seeded; if (isLocalUrl(targetUrl)) { seeded = await seedFirstVictoryCamp(page); } else { const seedBridge = await createFirstVictorySeedBridge( browser, localSeedUrl ); seedServerProcess = seedBridge.serverProcess; seeded = seedBridge.seeded; await page.evaluate((campaignSaves) => { for (const [key, value] of Object.entries(campaignSaves)) { window.localStorage.setItem(key, value); } }, seedBridge.campaignSaves); await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); } assert.deepEqual( seeded, { step: 'first-camp', latestBattleId: sourceBattleId, reportBattleId: sourceBattleId, reportOutcome: 'victory', settlementOutcome: 'victory', pendingAftermathBattleId: null }, `${renderer}: failed to seed the first-victory camp.` ); await page.evaluate(async () => { await window.__HEROS_DEBUG__.goToCamp(); }); await waitForCampJournalReady(page); const runtime = await readRuntimeProbe(page, 'CampScene'); assertRuntimeBaseline(runtime, renderer); const savesBeforeCampJournal = await readCampaignSaves(page); const openedBy = await openCampJournalWithPointer(page); const firstOpen = await readSceneJournal( page, 'CampScene' ); const firstContract = assertJournalContract( firstOpen, `${renderer} first-victory camp` ); assertFirstVictoryMeaning(firstOpen.snapshot, firstOpen.view); const screenshot = await page.screenshot({ path: screenshotPath, fullPage: true }); assert( screenshot.byteLength >= 100_000, `Expected a painted ${renderer} campaign objective journal ` + `screenshot: ${screenshot.byteLength} bytes.` ); await page.keyboard.press('Escape'); await waitForJournalVisibility(page, 'CampScene', false); await page.keyboard.press('j'); await waitForJournalVisibility(page, 'CampScene', true); const keyboardOpen = await readSceneJournal( page, 'CampScene' ); const keyboardContract = assertJournalContract( keyboardOpen, `${renderer} keyboard-opened camp` ); assert.deepEqual( stableJournalContent(keyboardOpen), stableJournalContent(firstOpen), `${renderer}: objective journal content changed after Escape and J.` ); assert.equal( keyboardContract.objectCount, firstContract.objectCount, `${renderer}: objective journal object count changed after reopen.` ); await page.keyboard.press('j'); await waitForJournalVisibility(page, 'CampScene', false); await page.keyboard.press('j'); await waitForJournalVisibility(page, 'CampScene', true); const keyboardReopen = await readSceneJournal( page, 'CampScene' ); const reopenedContract = assertJournalContract( keyboardReopen, `${renderer} keyboard-reopened camp` ); assert.deepEqual( stableJournalContent(keyboardReopen), stableJournalContent(firstOpen), `${renderer}: objective journal content was not stable across ` + 'repeated J toggles.' ); assert.equal( reopenedContract.objectCount, firstContract.objectCount, `${renderer}: repeated J toggles created duplicate display objects.` ); assert.deepEqual( reopenedContract.cardKeys, firstContract.cardKeys, `${renderer}: repeated J toggles duplicated or reordered cards.` ); await page.keyboard.press('Escape'); await waitForJournalVisibility(page, 'CampScene', false); assert.deepEqual( await readCampaignSaves(page), savesBeforeCampJournal, `${renderer}: merely viewing the objective journal changed a save.` ); const explorationResult = await verifyExplorationJournalIfAvailable(page); const modalIsolationResult = await verifyModalInputIsolation(page); assert.deepEqual( pageErrors, [], `${renderer}: unexpected page errors: ${pageErrors.join(' | ')}` ); assert.deepEqual( consoleErrors, [], `${renderer}: unexpected console errors: ` + consoleErrors.join(' | ') ); console.log( `Verified ${renderer} campaign objective journal from the ` + `${openedBy} at ${desktopBrowserViewport.width}x` + `${desktopBrowserViewport.height}, 100% zoom, DPR ` + `${desktopBrowserDeviceScaleFactor}; exploration ` + `${explorationResult}; modal input isolation ` + `${modalIsolationResult}; captured ${screenshotPath}.` ); } 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 seedFirstVictoryCamp(page) { return page.evaluate(async (battleId) => { 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[battleId]; if (!scenario) { throw new Error(`Missing battle scenario ${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); const campaign = campaignModule.getCampaignState(); return { step: campaign.step, latestBattleId: campaign.latestBattleId ?? null, reportBattleId: campaign.firstBattleReport?.battleId ?? null, reportOutcome: campaign.firstBattleReport?.outcome ?? null, settlementOutcome: campaign.battleHistory[battleId]?.outcome ?? null, pendingAftermathBattleId: campaign.pendingAftermathBattleId ?? null }; }, sourceBattleId); } async function createFirstVictorySeedBridge(activeBrowser, seedUrl) { 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 seedFirstVictoryCamp(page); const campaignSaves = await readCampaignSaves(page); assert( Object.keys(campaignSaves).length > 0, 'Local seed bridge did not create a campaign save.' ); return { campaignSaves, seeded, serverProcess: localServerProcess }; } catch (error) { await stopServerProcess(localServerProcess); throw error; } finally { await context.close(); } } async function waitForCampJournalReady(page) { try { await page.waitForFunction( () => { const debug = window.__HEROS_DEBUG__; const camp = debug?.camp?.(); const journal = camp?.campaignObjectiveJournal; const progressTab = camp?.campTabs?.find( (tab) => tab.id === 'progress' ); return ( debug?.activeScenes?.().includes('CampScene') && camp?.campaign?.step === 'first-camp' && journal?.snapshot?.current && journal?.snapshot?.recentCompleted && journal?.snapshot?.nextClue && journal?.view && progressTab?.interactive === true && progressTab?.bounds ); }, undefined, { timeout: 90000 } ); } catch (error) { const diagnostic = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], camp: window.__HEROS_DEBUG__?.camp?.() ?? null })); throw new Error( `Campaign objective journal did not become ready in ` + `CampScene: ${JSON.stringify(diagnostic)}`, { cause: error } ); } await afterTwoFrames(page); } async function openCampJournalWithPointer(page) { const candidates = await page.evaluate(() => { const camp = window.__HEROS_DEBUG__?.camp?.(); const view = camp?.campaignObjectiveJournal?.view; const progressTab = camp?.campTabs?.find( (tab) => tab.id === 'progress' ); const bounds = (candidate) => candidate?.bounds ?? candidate ?? null; const proposed = [ { id: 'progress tab', bounds: progressTab?.interactive === false ? null : bounds(progressTab) }, { id: 'journal trigger', bounds: bounds(view?.triggerBounds) ?? bounds(view?.toggleButtonBounds) ?? bounds(view?.openButtonBounds) ?? bounds(view?.buttonBounds) ?? bounds(view?.trigger) ?? bounds(view?.toggleButton) ?? bounds(view?.openButton) } ].filter((candidate) => candidate.bounds); return proposed.filter( (candidate, index, entries) => entries.findIndex( (entry) => JSON.stringify(entry.bounds) === JSON.stringify(candidate.bounds) ) === index ); }); assert( candidates.length > 0, `${renderer}: no interactive progress tab or journal trigger ` + 'was exposed.' ); for (const candidate of candidates) { assertBoundsContained( assertFiniteBounds( candidate.bounds, `${renderer} ${candidate.id}` ), desktopBrowserViewportBounds, `${renderer} ${candidate.id} in viewport` ); await clickNamedSceneBounds( page, 'CampScene', candidate.bounds ); try { await waitForJournalVisibility( page, 'CampScene', true, 3500 ); return candidate.id; } catch { // Some campaign builds keep the legacy progress tab and add a // separate trigger. Try the next real pointer target. } } const diagnostic = await page.evaluate(() => window.__HEROS_DEBUG__?.camp?.() ); throw new Error( `${renderer}: pointer targets did not open the objective ` + `journal: ${JSON.stringify({ candidates, diagnostic })}` ); } async function waitForJournalVisibility( page, sceneKey, expected, timeout = 30000 ) { await page.waitForFunction( ({ key, visible }) => { const debug = window.__HEROS_DEBUG__; const sceneState = key === 'CampScene' ? debug?.camp?.() : debug?.scene(key)?.getDebugState?.(); const view = sceneState?.campaignObjectiveJournal?.view; const actual = typeof view?.visible === 'boolean' ? view.visible : typeof view?.open === 'boolean' ? view.open : typeof view?.isOpen === 'boolean' ? view.isOpen : typeof view?.panelVisible === 'boolean' ? view.panelVisible : typeof view?.panel?.visible === 'boolean' ? view.panel.visible : null; return actual === visible; }, { key: sceneKey, visible: expected }, { timeout } ); await afterTwoFrames(page); } async function readSceneJournal(page, sceneKey) { const journal = await page.evaluate((key) => { const debug = window.__HEROS_DEBUG__; const sceneState = key === 'CampScene' ? debug?.camp?.() : debug?.scene(key)?.getDebugState?.(); return sceneState?.campaignObjectiveJournal ?? null; }, sceneKey); assert( journal?.snapshot && journal?.view, `${renderer}: missing ${sceneKey} campaignObjectiveJournal ` + `debug contract: ${JSON.stringify(journal)}` ); return journal; } function assertJournalContract(journal, label) { const { snapshot, view } = journal; assert( isJournalOpen(view), `${label}: debug view did not report an open journal.` ); ['current', 'recentCompleted', 'nextClue'].forEach((key) => { assertMeaningfulEntry(snapshot?.[key], `${label} ${key}`); }); const panel = assertFiniteBounds( findBounds(view, [ 'panelBounds', 'overlayBounds', 'dialogBounds', 'bounds' ]) ?? findNestedBounds(view.panel), `${label} panel` ); assertBoundsContained( panel, desktopBrowserViewportBounds, `${label} panel in viewport` ); const buttons = collectButtons(view); assert( buttons.length > 0, `${label}: the view must expose at least one button bound.` ); buttons.forEach((button, index) => { const bounds = assertFiniteBounds( button.bounds, `${label} button ${button.id ?? index + 1}` ); assertBoundsContained( bounds, desktopBrowserViewportBounds, `${label} button ${button.id ?? index + 1} in viewport` ); if ( button.id !== 'trigger' && button.id !== 'toggle' && boundsCenterInside(bounds, panel) ) { assertBoundsContained( bounds, panel, `${label} button ${button.id ?? index + 1} in panel` ); } }); const cards = collectCards(view); assert.equal( cards.length, 3, `${label}: expected current, recent-completed, and next-clue ` + `cards: ${JSON.stringify(cards)}` ); const cardBounds = cards.map((card, index) => { const bounds = assertFiniteBounds( card.bounds, `${label} card ${card.id ?? index + 1}` ); assertBoundsContained( bounds, panel, `${label} card ${card.id ?? index + 1} in panel` ); assertBoundsContained( bounds, desktopBrowserViewportBounds, `${label} card ${card.id ?? index + 1} in viewport` ); const titleBounds = assertFiniteBounds( findBounds(card.source, [ 'titleBounds', 'headingBounds', 'labelBounds' ]), `${label} card ${card.id ?? index + 1} title` ); const bodyBounds = assertFiniteBounds( findBounds(card.source, [ 'bodyBounds', 'detailBounds', 'descriptionBounds', 'textBounds' ]), `${label} card ${card.id ?? index + 1} body` ); [titleBounds, bodyBounds].forEach((textBounds, textIndex) => { assertBoundsContained( textBounds, bounds, `${label} card ${card.id ?? index + 1} ` + `${textIndex === 0 ? 'title' : 'body'} in card` ); assertBoundsContained( textBounds, panel, `${label} card ${card.id ?? index + 1} text in panel` ); }); return bounds; }); assertPairwiseNonOverlapping(cardBounds, `${label} cards`); const visibleText = collectVisibleText(view); const joinedVisibleText = visibleText.join('\n'); assert( /◆\s*진행/.test(joinedVisibleText), `${label}: visible text is missing "◆ 진행": ` + JSON.stringify(visibleText) ); assert( /✓\s*완료/.test(joinedVisibleText), `${label}: visible text is missing "✓ 완료": ` + JSON.stringify(visibleText) ); assert( /\?\s*실마리/.test(joinedVisibleText), `${label}: visible text is missing "? 실마리": ` + JSON.stringify(visibleText) ); const publicText = [ joinedVisibleText, ...['current', 'recentCompleted', 'nextClue'].map((key) => collectStrings(snapshot[key]).join('\n') ) ].join('\n'); forbiddenFutureTerms.forEach((term) => { assert( !publicText.includes(term), `${label}: unrevealed future term "${term}" leaked into ` + `the visible journal: ${publicText}` ); }); const cardKeys = cards.map( (card, index) => card.id ?? `card-${index + 1}` ); assert.equal( new Set(cardKeys).size, cardKeys.length, `${label}: duplicate card identities were exposed: ` + JSON.stringify(cardKeys) ); const objectCount = readObjectCount(view, cards, buttons); assert( Number.isInteger(objectCount) && objectCount > 0, `${label}: expected a finite positive display-object count.` ); return { cardKeys, objectCount }; } function assertFirstVictoryMeaning(snapshot, view) { const currentText = collectStrings(snapshot.current).join(' '); const recentText = collectStrings( snapshot.recentCompleted ).join(' '); const nextText = collectStrings(snapshot.nextClue).join(' '); const visibleText = collectVisibleText(view).join(' '); assert( /정찰막|둘러보기/.test(currentText), `${renderer}: first-camp current objective should direct the ` + `player to the scout tent: ${currentText}` ); assert( /탁현의 전투|한석 선봉/.test(recentText), `${renderer}: first-camp recent completion should recount the ` + `first victory: ${recentText}` ); assert( nextText.trim().length >= 2, `${renderer}: first-camp next clue must be meaningful.` ); assert.notEqual( normalizeWhitespace(nextText), normalizeWhitespace(currentText), `${renderer}: next clue must not duplicate the current action.` ); assert.notEqual( normalizeWhitespace(nextText), normalizeWhitespace(recentText), `${renderer}: next clue must not duplicate the recent result.` ); assert( /정찰막|둘러보기/.test(visibleText) && /탁현의 전투|한석 선봉/.test(visibleText), `${renderer}: meaningful first-camp journal content was not ` + `rendered visibly: ${visibleText}` ); } async function verifyExplorationJournalIfAvailable(page) { const available = await page.evaluate( () => typeof window.__HEROS_DEBUG__ ?.goToCampVisitExploration === 'function' ); if (!available) { return 'skipped (debug navigation unavailable)'; } await page.evaluate(async (requestedVisitId) => { await window.__HEROS_DEBUG__.goToCampVisitExploration( requestedVisitId ); }, visitId); await page.waitForFunction( () => { const debug = window.__HEROS_DEBUG__; const state = debug ?.scene('CampVisitExplorationScene') ?.getDebugState?.(); return ( debug ?.activeScenes?.() .includes('CampVisitExplorationScene') && state?.ready === true ); }, undefined, { timeout: 90000 } ); await afterTwoFrames(page); const state = await page.evaluate(() => window.__HEROS_DEBUG__ ?.scene('CampVisitExplorationScene') ?.getDebugState?.() ); if (!state?.campaignObjectiveJournal?.view) { return 'skipped (exploration journal contract unavailable)'; } const savesBefore = await readCampaignSaves(page); await page.keyboard.press('j'); await waitForJournalVisibility( page, 'CampVisitExplorationScene', true ); const openJournal = await readSceneJournal( page, 'CampVisitExplorationScene' ); assertJournalContract( openJournal, `${renderer} scout-tent exploration` ); await page.keyboard.press('Escape'); await waitForJournalVisibility( page, 'CampVisitExplorationScene', false ); assert.deepEqual( await readCampaignSaves(page), savesBefore, `${renderer}: viewing the exploration journal changed a save.` ); return 'verified'; } async function verifyModalInputIsolation(page) { if (renderer !== 'canvas') { return 'covered in canvas'; } const verifiedScenes = []; const visitScene = await page.evaluate(() => { const scene = window.__HEROS_DEBUG__ ?.scene('CampVisitExplorationScene'); if (!scene || typeof scene.openChoicePanel !== 'function') { return false; } scene.openChoicePanel(); return true; }); if (visitScene) { await page.waitForFunction( () => { const state = window.__HEROS_DEBUG__ ?.scene('CampVisitExplorationScene') ?.getDebugState?.(); return state?.choice?.open === true && state?.choice?.ready === true; } ); const before = await page.evaluate(() => { const state = window.__HEROS_DEBUG__ ?.scene('CampVisitExplorationScene') ?.getDebugState?.(); return { choiceOpen: state?.choice?.open ?? false, choiceId: state?.visit?.choiceId ?? null, completed: state?.visit?.completed ?? false, dialogueActive: state?.dialogue?.active ?? false }; }); const savesBefore = await readCampaignSaves(page); await page.keyboard.press('j'); await waitForJournalVisibility( page, 'CampVisitExplorationScene', true ); await page.keyboard.press('2'); await afterTwoFrames(page); const after = await page.evaluate(() => { const state = window.__HEROS_DEBUG__ ?.scene('CampVisitExplorationScene') ?.getDebugState?.(); return { choiceOpen: state?.choice?.open ?? false, choiceId: state?.visit?.choiceId ?? null, completed: state?.visit?.completed ?? false, dialogueActive: state?.dialogue?.active ?? false, journalVisible: state?.campaignObjectiveJournal?.view?.visible ?? false }; }); assert.deepEqual( { choiceOpen: after.choiceOpen, choiceId: after.choiceId, completed: after.completed, dialogueActive: after.dialogueActive }, before, 'canvas: journal leaked numeric input into the exploration choice.' ); assert.equal( after.journalVisible, true, 'canvas: exploration journal closed while blocking a choice.' ); assert.deepEqual( await readCampaignSaves(page), savesBefore, 'canvas: blocked exploration choice changed a save.' ); await page.keyboard.press('Escape'); await waitForJournalVisibility( page, 'CampVisitExplorationScene', false ); verifiedScenes.push('exploration'); } const militiaAvailable = await page.evaluate( () => typeof window.__HEROS_DEBUG__?.goToMilitiaCamp === 'function' ); if (militiaAvailable) { await page.evaluate(async () => { await window.__HEROS_DEBUG__.goToMilitiaCamp(); }); await page.waitForFunction( () => { const debug = window.__HEROS_DEBUG__; return debug?.activeScenes?.().includes( 'PrologueMilitiaCampScene' ) && debug?.militiaCamp?.()?.ready === true; }, undefined, { timeout: 90000 } ); await page.evaluate(() => { const scene = window.__HEROS_DEBUG__ ?.scene('PrologueMilitiaCampScene'); for (let index = 0; index < 20 && scene?.dialogueState; index += 1) { scene.advanceDialogue(); } scene?.openFirstCommandChoice?.(); }); await page.waitForFunction( () => { const choice = window.__HEROS_DEBUG__ ?.militiaCamp?.()?.commandChoice; return choice?.open === true && choice?.ready === true; } ); const before = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.militiaCamp?.(); return { open: state?.commandChoice?.open ?? false, pendingId: state?.commandChoice?.pendingId ?? null, selectedId: state?.commandChoice?.selectedId ?? null, dialogueActive: state?.dialogue?.active ?? false }; }); const savesBefore = await readCampaignSaves(page); await page.keyboard.press('j'); await waitForJournalVisibility( page, 'PrologueMilitiaCampScene', true ); await page.keyboard.press('2'); await page.keyboard.press('Enter'); await afterTwoFrames(page); const after = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.militiaCamp?.(); return { open: state?.commandChoice?.open ?? false, pendingId: state?.commandChoice?.pendingId ?? null, selectedId: state?.commandChoice?.selectedId ?? null, dialogueActive: state?.dialogue?.active ?? false, journalVisible: state?.campaignObjectiveJournal?.view?.visible ?? false }; }); assert.deepEqual( { open: after.open, pendingId: after.pendingId, selectedId: after.selectedId, dialogueActive: after.dialogueActive }, before, 'canvas: journal leaked 2/Enter into the militia command choice.' ); assert.equal( after.journalVisible, true, 'canvas: militia journal closed while blocking a command choice.' ); assert.deepEqual( await readCampaignSaves(page), savesBefore, 'canvas: blocked militia command input changed a save.' ); await page.keyboard.press('Escape'); await waitForJournalVisibility( page, 'PrologueMilitiaCampScene', false ); verifiedScenes.push('militia command'); } const cityAvailable = await page.evaluate( () => typeof window.__HEROS_DEBUG__?.goToCityStay === 'function' ); if (cityAvailable) { await page.evaluate(async () => { await window.__HEROS_DEBUG__.goToCityStay('xuzhou'); }); await page.waitForFunction( () => { const debug = window.__HEROS_DEBUG__; return debug?.activeScenes?.().includes('CityStayScene') && debug?.cityStay?.()?.ready === true; }, undefined, { timeout: 90000 } ); await page.evaluate(() => { const scene = window.__HEROS_DEBUG__?.scene('CityStayScene'); for (let index = 0; index < 20 && scene?.dialogueState; index += 1) { scene.advanceDialogue(); } scene?.openChoicePanel?.(); }); await page.waitForFunction( () => window.__HEROS_DEBUG__?.cityStay?.()?.choice?.open === true ); const before = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.cityStay?.(); return { choiceOpen: state?.choice?.open ?? false, dialogueActive: state?.dialogue?.active ?? false, campaign: state?.campaign ?? null }; }); const savesBefore = await readCampaignSaves(page); await page.keyboard.press('j'); await waitForJournalVisibility(page, 'CityStayScene', true); await page.keyboard.press('2'); await afterTwoFrames(page); const after = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.cityStay?.(); return { choiceOpen: state?.choice?.open ?? false, dialogueActive: state?.dialogue?.active ?? false, campaign: state?.campaign ?? null, journalVisible: state?.campaignObjectiveJournal?.view?.visible ?? false }; }); assert.deepEqual( { choiceOpen: after.choiceOpen, dialogueActive: after.dialogueActive, campaign: after.campaign }, before, 'canvas: journal leaked numeric input into the city choice.' ); assert.equal( after.journalVisible, true, 'canvas: city journal closed while blocking a dialogue choice.' ); assert.deepEqual( await readCampaignSaves(page), savesBefore, 'canvas: blocked city choice changed a save.' ); await page.keyboard.press('Escape'); await waitForJournalVisibility(page, 'CityStayScene', false); verifiedScenes.push('city dialogue'); } return verifiedScenes.length > 0 ? `verified (${verifiedScenes.join(', ')})` : 'skipped (debug navigation unavailable)'; } function isJournalOpen(view) { if (typeof view?.visible === 'boolean') { return view.visible; } if (typeof view?.open === 'boolean') { return view.open; } if (typeof view?.isOpen === 'boolean') { return view.isOpen; } if (typeof view?.panelVisible === 'boolean') { return view.panelVisible; } return view?.panel?.visible === true; } function findBounds(source, keys) { for (const key of keys) { const value = source?.[key]; const bounds = findNestedBounds(value); if (bounds) { return bounds; } } return null; } function findNestedBounds(value) { if (!value || typeof value !== 'object') { return null; } if ( Number.isFinite(value.x) && Number.isFinite(value.y) && Number.isFinite(value.width) && Number.isFinite(value.height) ) { return { x: value.x, y: value.y, width: value.width, height: value.height }; } return findNestedBounds(value.bounds); } function collectButtons(view) { const buttons = []; const append = (id, candidate) => { const bounds = findNestedBounds(candidate); if (!bounds) { return; } const signature = JSON.stringify(bounds); if ( buttons.some( (button) => JSON.stringify(button.bounds) === signature ) ) { return; } buttons.push({ id, bounds }); }; [ ['trigger', view.triggerBounds], ['toggle', view.toggleButtonBounds], ['open', view.openButtonBounds], ['button', view.buttonBounds], ['close', view.closeButtonBounds], ['trigger', view.trigger], ['toggle', view.toggleButton], ['open', view.openButton], ['close', view.closeButton] ].forEach(([id, candidate]) => append(id, candidate)); for (const collectionKey of [ 'buttons', 'actions', 'controls' ]) { (view?.[collectionKey] ?? []).forEach( (button, index) => append( button?.id ?? button?.role ?? `${collectionKey}-${index + 1}`, button ) ); } return buttons; } function collectCards(view) { const cards = []; const append = (id, source) => { const bounds = findNestedBounds(source); if (!bounds) { return; } const signature = JSON.stringify(bounds); if ( cards.some( (card) => JSON.stringify(card.bounds) === signature ) ) { return; } cards.push({ id: source?.id ?? source?.role ?? id, bounds, source }); }; for (const collectionKey of ['cards', 'sections', 'entries']) { (view?.[collectionKey] ?? []).forEach((card, index) => append( card?.id ?? card?.role ?? `${collectionKey}-${index + 1}`, card ) ); } [ ['current', view.currentCard ?? view.current], [ 'recentCompleted', view.recentCompletedCard ?? view.recentCompleted ], ['nextClue', view.nextClueCard ?? view.nextClue] ].forEach(([id, card]) => append(id, card)); return cards; } function collectVisibleText(view) { if (Array.isArray(view?.visibleText)) { return view.visibleText .flatMap((value) => typeof value === 'string' ? [value] : collectStrings(value) ) .filter(Boolean); } if (typeof view?.visibleText === 'string') { return [view.visibleText]; } return [ ...collectStrings(view?.title), ...collectStrings(view?.cards), ...collectStrings(view?.sections) ]; } function collectStrings(value, seen = new Set()) { if (typeof value === 'string') { return value.trim() ? [value.trim()] : []; } if ( value === null || value === undefined || typeof value !== 'object' || seen.has(value) ) { return []; } seen.add(value); if (Array.isArray(value)) { return value.flatMap((entry) => collectStrings(entry, seen) ); } return Object.entries(value).flatMap(([key, entry]) => /bounds|texture|color|font|id$/i.test(key) ? [] : collectStrings(entry, seen) ); } function assertMeaningfulEntry(entry, label) { const strings = collectStrings(entry); assert( entry && strings.some( (value) => normalizeWhitespace(value).length >= 2 && !['current', 'completed', 'clue'].includes( value.toLowerCase() ) ), `${label} must contain meaningful player-facing text: ` + JSON.stringify(entry) ); } function readObjectCount(view, cards, buttons) { const explicit = [ view?.objectCount, view?.displayObjectCount, view?.childCount, view?.objects?.length, view?.displayObjects?.length ].find( (value) => Number.isInteger(value) && value > 0 ); return explicit ?? cards.length + buttons.length + 1; } function stableJournalContent(journal) { return { snapshot: stripPresentationState(journal.snapshot), visibleText: collectVisibleText(journal.view).map( normalizeWhitespace ), cards: collectCards(journal.view).map((card) => ({ id: card.id, content: stripPresentationState(card.source) })) }; } function stripPresentationState(value) { if (Array.isArray(value)) { return value.map(stripPresentationState); } if (!value || typeof value !== 'object') { return value; } return Object.fromEntries( Object.entries(value) .filter( ([key]) => !/bounds|^(x|y|width|height|visible|open|isOpen|panelVisible|interactive|hovered|focused|objectCount|displayObjectCount|childCount)$/i.test( key ) ) .map(([key, entry]) => [ key, stripPresentationState(entry) ]) ); } function normalizeWhitespace(value) { return String(value).replace(/\s+/g, ' ').trim(); } 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, `Expected finite positive bounds for ${label}: ` + JSON.stringify(bounds) ); return bounds; } function assertBoundsContained(inner, outer, label) { const tolerance = 1; assert( inner.x >= outer.x - tolerance && inner.y >= outer.y - tolerance && inner.x + inner.width <= outer.x + outer.width + tolerance && inner.y + inner.height <= outer.y + outer.height + tolerance, `${label} escaped its container: ` + JSON.stringify({ inner, outer }) ); } function boundsCenterInside(inner, outer) { const x = inner.x + inner.width / 2; const y = inner.y + inner.height / 2; return ( x >= outer.x && y >= outer.y && x <= outer.x + outer.width && y <= outer.y + outer.height ); } function boundsOverlap(left, right) { const tolerance = 1; return ( left.x < right.x + right.width - tolerance && left.x + left.width > right.x + tolerance && left.y < right.y + right.height - tolerance && left.y + left.height > right.y + tolerance ); } function assertPairwiseNonOverlapping(bounds, label) { for (let leftIndex = 0; leftIndex < bounds.length; leftIndex += 1) { for ( let rightIndex = leftIndex + 1; rightIndex < bounds.length; rightIndex += 1 ) { assert( !boundsOverlap(bounds[leftIndex], bounds[rightIndex]), `${label} ${leftIndex + 1} and ${rightIndex + 1} ` + `overlap: ${JSON.stringify({ left: bounds[leftIndex], right: bounds[rightIndex] })}` ); } } } async function clickNamedSceneBounds( 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 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 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) { assert.deepEqual(runtime.viewport, { width: desktopBrowserViewport.width, height: desktopBrowserViewport.height, dpr: desktopBrowserDeviceScaleFactor, zoom: 1 }); assert.deepEqual(runtime.canvas, { width: desktopBrowserViewport.width, height: desktopBrowserViewport.height, bounds: { x: 0, y: 0, width: desktopBrowserViewport.width, height: desktopBrowserViewport.height } }); assert.deepEqual(runtime.scene, { width: desktopBrowserViewport.width, height: desktopBrowserViewport.height }); assert.equal(runtime.renderer.requested, requestedRenderer); assert.equal( runtime.renderer.type, requestedRenderer === 'canvas' ? 1 : 2, `Expected ${requestedRenderer} renderer: ` + 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)]); } }