Files
heros_web/scripts/verify-campaign-objective-journal-browser.mjs

1541 lines
41 KiB
JavaScript

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 screenshotPath =
`dist/verification-campaign-objective-journal-${renderer}-1920x1080.png`;
let serverProcess;
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(() => window.localStorage.clear());
await page.goto(targetUrl, {
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForDebugApi(page);
const seeded = await seedFirstVictoryCamp(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);
}
function withDebugOptions(url, requestedRenderer) {
const parsed = new URL(url);
parsed.searchParams.set('debug', '1');
parsed.searchParams.set('renderer', requestedRenderer);
return parsed.toString();
}
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 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)]);
}
}