Files
heros_web/scripts/verify-prologue-exploration-checkpoint-browser.mjs

1197 lines
31 KiB
JavaScript

import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import {
desktopBrowserContextOptions,
desktopBrowserDeviceScaleFactor,
desktopBrowserViewport
} from './desktop-browser-viewport.mjs';
const rendererNames = ['canvas', 'webgl'];
const renderer =
process.env.VERIFY_PROLOGUE_CHECKPOINT_RENDERER;
const selectedOrderId = 'mobile';
if (!renderer) {
for (const requestedRenderer of rendererNames) {
const result = spawnSync(
process.execPath,
[fileURLToPath(import.meta.url)],
{
cwd: process.cwd(),
env: {
...process.env,
VERIFY_PROLOGUE_CHECKPOINT_RENDERER:
requestedRenderer
},
stdio: 'inherit'
}
);
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
process.exit(0);
}
assert(
rendererNames.includes(renderer),
`Unsupported prologue checkpoint renderer "${renderer}".`
);
const baseUrl =
process.env.VERIFY_PROLOGUE_CHECKPOINT_URL ??
'http://127.0.0.1:41811/heros_web/';
const targetUrl = withDebugOptions(baseUrl, renderer);
let serverProcess;
let browser;
try {
serverProcess = await ensureLocalServer(targetUrl);
browser = await chromium.launch({
headless:
process.env.VERIFY_PROLOGUE_CHECKPOINT_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.goto(targetUrl, {
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForDebugApi(page);
await assertDesktopRuntime(page, renderer);
const villageSeed = await seedVillage(page);
assert.equal(villageSeed.step, 'prologue-town');
assert.equal(villageSeed.checkpoint, null);
assert.equal(villageSeed.entered, true);
await page.evaluate(
async () => window.__HEROS_DEBUG__?.goToVillage()
);
await waitForVillageReady(page);
await assertDesktopRuntime(page, renderer);
const villagePlayer = await createMovementCheckpoint(
page,
'village'
);
const villagePositionResume =
await reloadAndContinueToVillage(page);
assertExactPlayer(
villagePositionResume.player,
villagePlayer,
`${renderer}: village position resume`
);
assertCheckpoint(
villagePositionResume.explorationCheckpoint,
'prologue-village',
'prologue-village',
villagePlayer,
`${renderer}: village position checkpoint`
);
const villageDialogueBeforeReload =
await openVillageDialogueLine(page);
const villageDialogueResume =
await reloadAndContinueToVillage(page);
assertExactPlayer(
villageDialogueResume.player,
villageDialogueBeforeReload.player,
`${renderer}: village dialogue position resume`
);
assertExplorationDialogue(
villageDialogueResume,
{
key: 'prologue-village-interaction',
source: 'zhang-fei',
lineIndex:
villageDialogueBeforeReload.dialogue.lineIndex
},
`${renderer}: village dialogue line resume`
);
const completedVillage = await page.evaluate(() =>
window.__HEROS_DEBUG__
?.scene('PrologueVillageScene')
?.debugCompleteVillage?.() ?? false
);
assert.equal(
completedVillage,
true,
`${renderer}: could not complete the village transition.`
);
await waitForStoryReady(page, 'PrologueMilitiaCampScene');
const villageExitState = await readCampaignCheckpoint(page);
assert.equal(villageExitState.step, 'prologue-town');
assert.equal(villageExitState.checkpoint, null);
assert.equal(villageExitState.villageComplete, true);
await page.keyboard.press('Space');
const campAfterVillage = await waitForMilitiaCampReady(page);
assertCheckpoint(
campAfterVillage.explorationCheckpoint,
'prologue-militia-camp',
'prologue-militia-camp',
campAfterVillage.player,
`${renderer}: village transition changed checkpoint context`
);
assert.equal(
campAfterVillage.dialogue.dialogueKey,
'prologue-camp-interaction'
);
assert.equal(
campAfterVillage.dialogue.sourceNpcId,
'camp-intro'
);
await finishMilitiaDialogue(page);
const campPlayer = await createMovementCheckpoint(
page,
'militia-camp'
);
const campPositionResume =
await reloadAndContinueToMilitiaCamp(page);
assertExactPlayer(
campPositionResume.player,
campPlayer,
`${renderer}: militia camp position resume`
);
assertCheckpoint(
campPositionResume.explorationCheckpoint,
'prologue-militia-camp',
'prologue-militia-camp',
campPlayer,
`${renderer}: militia camp position checkpoint`
);
const campDialogueBeforeReload =
await openMilitiaDialogueLine(page);
const campDialogueResume =
await reloadAndContinueToMilitiaCamp(page);
assertExactPlayer(
campDialogueResume.player,
campDialogueBeforeReload.player,
`${renderer}: militia dialogue position resume`
);
assertExplorationDialogue(
campDialogueResume,
{
key: 'prologue-camp-interaction',
source: 'guan-yu',
lineIndex:
campDialogueBeforeReload.dialogue.lineIndex
},
`${renderer}: militia dialogue line resume`
);
await finishMilitiaDialogue(page);
const completedObjectives = await page.evaluate(() =>
window.__HEROS_DEBUG__
?.scene('PrologueMilitiaCampScene')
?.debugCompleteRequiredObjectives?.() ?? []
);
assert.deepEqual(
[...completedObjectives].sort(),
[
'inspect-arms',
'ready-vanguard',
'review-scout-report'
].sort()
);
try {
await page.waitForFunction(
() => {
const camp =
window.__HEROS_DEBUG__?.militiaCamp?.();
return (
camp?.ready === true &&
camp?.navigationPending === false &&
camp?.movement?.npcMovementPending === false &&
camp?.dialogue?.active === false &&
camp?.commandChoice?.open === false
);
},
undefined,
{ timeout: 15000 }
);
} catch (error) {
throw new Error(
`${renderer}: militia camp did not settle before command interaction: ` +
JSON.stringify(await readMilitiaCamp(page)),
{ cause: error }
);
}
const commanderInteracted = await page.evaluate(() =>
window.__HEROS_DEBUG__
?.scene('PrologueMilitiaCampScene')
?.debugInteractWith?.('zou-jing') ?? false
);
assert.equal(
commanderInteracted,
true,
`${renderer}: could not start the departure command dialogue.`
);
await waitForMilitiaDialogue(page, {
key: 'prologue-camp-departure',
source: 'zou-jing',
lineIndex: 0
});
await page.waitForTimeout(320);
await page.keyboard.press('e');
await waitForCommandChoice(page);
const choiceOpened = await readMilitiaCamp(page);
const mobileCard = choiceOpened.commandChoice.cards.find(
({ orderId }) => orderId === selectedOrderId
);
assert(
mobileCard?.bounds,
`${renderer}: mobile command card is missing.`
);
await clickBounds(page, mobileCard.bounds);
await page.waitForFunction(
(orderId) => {
const camp =
window.__HEROS_DEBUG__?.militiaCamp?.();
return (
camp?.commandChoice?.open === true &&
camp.commandChoice.pendingId === orderId &&
camp.commandChoice.confirmEnabled === true &&
camp.explorationCheckpoint?.overlay?.type ===
'choice' &&
camp.explorationCheckpoint.overlay.mode ===
'prologue-command' &&
camp.explorationCheckpoint.overlay.choiceId ===
orderId
);
},
selectedOrderId
);
const pendingChoiceBeforeReload =
await readMilitiaCamp(page);
const pendingChoiceResume =
await reloadAndContinueToMilitiaCamp(page);
await waitForCommandChoice(page, selectedOrderId);
const readyPendingChoiceResume =
await readMilitiaCamp(page);
assertExactPlayer(
readyPendingChoiceResume.player,
pendingChoiceBeforeReload.player,
`${renderer}: command choice position resume`
);
assert.equal(
pendingChoiceResume.commandChoice.pendingId,
selectedOrderId
);
assert.equal(
readyPendingChoiceResume.commandChoice.confirmEnabled,
true
);
assert.equal(
readyPendingChoiceResume.commandChoice.cards.find(
({ orderId }) => orderId === selectedOrderId
)?.pending,
true
);
assert.deepEqual(
readyPendingChoiceResume.explorationCheckpoint?.overlay,
{
type: 'choice',
mode: 'prologue-command',
sourceNpcId: 'zou-jing',
choiceId: selectedOrderId
},
`${renderer}: pending command selection checkpoint`
);
const commandChosen = await page.evaluate((orderId) =>
window.__HEROS_DEBUG__
?.scene('PrologueMilitiaCampScene')
?.debugChooseFirstCommand?.(orderId) ?? false,
selectedOrderId
);
assert.equal(
commandChosen,
true,
`${renderer}: could not confirm the command choice.`
);
await waitForMilitiaDialogue(page, {
key: 'prologue-camp-command-response',
source: `first-command-${selectedOrderId}`,
lineIndex: 0
});
await page.waitForTimeout(320);
await page.keyboard.press('e');
await waitForMilitiaDialogue(page, {
key: 'prologue-camp-command-response',
source: `first-command-${selectedOrderId}`,
lineIndex: 1
});
const responseBeforeReload = await readMilitiaCamp(page);
const responseResume =
await reloadAndContinueToMilitiaCamp(page);
assertExactPlayer(
responseResume.player,
responseBeforeReload.player,
`${renderer}: command response position resume`
);
assertExplorationDialogue(
responseResume,
{
key: 'prologue-camp-command-response',
source: `first-command-${selectedOrderId}`,
lineIndex: 1
},
`${renderer}: command response line resume`
);
assert.deepEqual(
(await readCampaignCheckpoint(page))
.sortieOrderSelection,
{
battleId: 'first-battle-zhuo-commandery',
orderId: selectedOrderId
}
);
await page.waitForTimeout(320);
await page.keyboard.press('e');
await waitForStoryReady(page, 'BattleScene');
const campExitState = await readCampaignCheckpoint(page);
assert.equal(campExitState.step, 'prologue-camp');
assert.equal(campExitState.checkpoint, null);
assert.equal(campExitState.campComplete, true);
assert.deepEqual(campExitState.sortieOrderSelection, {
battleId: 'first-battle-zhuo-commandery',
orderId: selectedOrderId
});
await reloadToTitle(page);
await clickContinue(page);
await waitForStoryReady(page, 'BattleScene');
assert(
!(await page.evaluate(() =>
window.__HEROS_DEBUG__
?.activeScenes?.()
.includes('PrologueMilitiaCampScene')
)),
`${renderer}: Continue reopened a completed militia camp.`
);
assert.equal(
(await readCampaignCheckpoint(page)).checkpoint,
null
);
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%'
},
checkpoints: {
villageExactPlayerResume: villagePlayer,
villageDialogueLineResume: true,
villageExitCleared: true,
villageToCampContextChangedSafely: true,
militiaExactPlayerResume: campPlayer,
militiaDialogueLineResume: true,
commandChoiceResume: true,
pendingCommandSelectionResume: selectedOrderId,
commandResponseLineResume: true,
militiaExitCleared: true
},
pageErrors: pageErrors.length,
consoleErrors: actionableConsoleErrors.length
},
null,
2
)
);
} 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 assertDesktopRuntime(page, expectedRenderer) {
const runtime = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
return {
innerWidth: window.innerWidth,
innerHeight: window.innerHeight,
devicePixelRatio: window.devicePixelRatio,
visualViewportScale:
window.visualViewport?.scale ?? 1,
rendererType:
window.__HEROS_GAME__?.renderer?.type ?? null,
canvas: canvas
? {
width: canvas.width,
height: canvas.height,
clientWidth: canvas.clientWidth,
clientHeight: canvas.clientHeight,
bounds: bounds
? {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height
}
: null
}
: null
};
});
assert.equal(runtime.innerWidth, desktopBrowserViewport.width);
assert.equal(runtime.innerHeight, desktopBrowserViewport.height);
assert.equal(
runtime.devicePixelRatio,
desktopBrowserDeviceScaleFactor
);
assert.equal(runtime.visualViewportScale, 1);
assert.equal(
runtime.rendererType,
expectedRenderer === 'canvas' ? 1 : 2
);
assert.equal(
runtime.canvas?.width,
desktopBrowserViewport.width
);
assert.equal(
runtime.canvas?.height,
desktopBrowserViewport.height
);
assert.equal(
runtime.canvas?.clientWidth,
desktopBrowserViewport.width
);
assert.equal(
runtime.canvas?.clientHeight,
desktopBrowserViewport.height
);
assert.equal(
runtime.canvas?.bounds?.width,
desktopBrowserViewport.width
);
assert.equal(
runtime.canvas?.bounds?.height,
desktopBrowserViewport.height
);
}
async function seedVillage(page) {
return page.evaluate(async () => {
const campaignModule = await import(
'/heros_web/src/game/state/campaignState.ts'
);
campaignModule.resetCampaignState();
const state =
campaignModule.createInitialCampaignState();
state.step = 'prologue-town';
state.completedTutorialIds = [
campaignModule.prologueVillageCampaignTutorialIds
.entered
];
const saved = campaignModule.setCampaignState(state);
return {
step: saved.step,
checkpoint:
saved.explorationCheckpoint ?? null,
entered: saved.completedTutorialIds.includes(
campaignModule.prologueVillageCampaignTutorialIds
.entered
)
};
});
}
async function waitForVillageReady(page) {
try {
await page.waitForFunction(
() => {
const village =
window.__HEROS_DEBUG__?.village?.();
return (
window.__HEROS_DEBUG__
?.activeScenes?.()
.includes('PrologueVillageScene') &&
village?.scene === 'PrologueVillageScene' &&
village?.ready === true &&
village?.requiredTexturesReady === true
);
},
undefined,
{ timeout: 90000 }
);
} catch (error) {
const diagnostic = await page.evaluate(() => ({
activeScenes:
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
village:
window.__HEROS_DEBUG__?.village?.() ?? null,
title:
window.__HEROS_DEBUG__?.title?.() ?? null
}));
throw new Error(
`${renderer}: village did not become ready: ` +
JSON.stringify(diagnostic),
{ cause: error }
);
}
await afterTwoFrames(page);
return readVillage(page);
}
async function waitForMilitiaCampReady(page) {
try {
await page.waitForFunction(
() => {
const camp =
window.__HEROS_DEBUG__?.militiaCamp?.();
return (
window.__HEROS_DEBUG__
?.activeScenes?.()
.includes('PrologueMilitiaCampScene') &&
camp?.scene ===
'PrologueMilitiaCampScene' &&
camp?.ready === true &&
camp?.requiredTexturesReady === true
);
},
undefined,
{ timeout: 90000 }
);
} catch (error) {
const diagnostic = await page.evaluate(() => ({
activeScenes:
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
militiaCamp:
window.__HEROS_DEBUG__?.militiaCamp?.() ?? null,
story:
window.__HEROS_DEBUG__
?.scene('StoryScene')
?.getDebugState?.() ?? null
}));
throw new Error(
`${renderer}: militia camp did not become ready: ` +
JSON.stringify(diagnostic),
{ cause: error }
);
}
await afterTwoFrames(page);
return readMilitiaCamp(page);
}
async function readVillage(page) {
return page.evaluate(
() => window.__HEROS_DEBUG__?.village?.() ?? null
);
}
async function readMilitiaCamp(page) {
return page.evaluate(
() =>
window.__HEROS_DEBUG__?.militiaCamp?.() ?? null
);
}
async function createMovementCheckpoint(page, sceneKind) {
const reader =
sceneKind === 'village' ? readVillage : readMilitiaCamp;
await page.waitForTimeout(450);
const before = await reader(page);
const teleported = await page.evaluate(
({ sceneKey, targetId }) =>
window.__HEROS_DEBUG__
?.scene(sceneKey)
?.debugTeleportTo?.(targetId) ?? false,
sceneKind === 'village'
? {
sceneKey: 'PrologueVillageScene',
targetId: 'zhang-fei'
}
: {
sceneKey: 'PrologueMilitiaCampScene',
targetId: 'quartermaster'
}
);
assert.equal(
teleported,
true,
`${renderer}: ${sceneKind} checkpoint teleport failed.`
);
await page.waitForTimeout(100);
await page.keyboard.down('ArrowRight');
await page.waitForTimeout(240);
await page.keyboard.up('ArrowRight');
await page.waitForFunction(
(kind) => {
const state =
kind === 'village'
? window.__HEROS_DEBUG__?.village?.()
: window.__HEROS_DEBUG__?.militiaCamp?.();
const player = state?.player;
const checkpoint = state?.explorationCheckpoint;
return (
player?.moving === false &&
player?.direction === 'east' &&
checkpoint?.overlay === undefined &&
Math.abs(checkpoint?.player?.x - player.x) <= 0.05 &&
Math.abs(checkpoint?.player?.y - player.y) <= 0.05 &&
checkpoint?.player?.direction === player.direction
);
},
sceneKind,
{ timeout: 10000 }
);
const state = await reader(page);
assert(
Math.hypot(
state.player.x - before.player.x,
state.player.y - before.player.y
) > 20,
`${renderer}: ${sceneKind} movement checkpoint was not distinct.`
);
return exactPlayer(state.player);
}
async function openVillageDialogueLine(page) {
const interacted = await page.evaluate(() =>
window.__HEROS_DEBUG__
?.scene('PrologueVillageScene')
?.debugInteractWith?.('zhang-fei') ?? false
);
assert.equal(
interacted,
true,
`${renderer}: could not interact with Zhang Fei.`
);
await waitForVillageDialogue(page, {
key: 'prologue-village-interaction',
source: 'zhang-fei',
lineIndex: 0
});
await page.waitForTimeout(320);
await page.keyboard.press('e');
await waitForVillageDialogue(page, {
key: 'prologue-village-interaction',
source: 'zhang-fei',
lineIndex: 1
});
const village = await readVillage(page);
return {
...village,
player: exactPlayer(village.player)
};
}
async function openMilitiaDialogueLine(page) {
const interacted = await page.evaluate(() =>
window.__HEROS_DEBUG__
?.scene('PrologueMilitiaCampScene')
?.debugInteractWith?.('guan-yu') ?? false
);
assert.equal(
interacted,
true,
`${renderer}: could not interact with Guan Yu.`
);
await waitForMilitiaDialogue(page, {
key: 'prologue-camp-interaction',
source: 'guan-yu',
lineIndex: 0
});
await page.waitForTimeout(320);
await page.keyboard.press('e');
await waitForMilitiaDialogue(page, {
key: 'prologue-camp-interaction',
source: 'guan-yu',
lineIndex: 1
});
const camp = await readMilitiaCamp(page);
return {
...camp,
player: exactPlayer(camp.player)
};
}
async function waitForVillageDialogue(
page,
expected
) {
await page.waitForFunction(
({ key, source, lineIndex }) => {
const village =
window.__HEROS_DEBUG__?.village?.();
return (
village?.dialogue?.active === true &&
village.dialogue.dialogueKey === key &&
village.dialogue.sourceNpcId === source &&
village.dialogue.lineIndex === lineIndex &&
village.explorationCheckpoint?.overlay?.type ===
'dialogue' &&
village.explorationCheckpoint.overlay
.dialogueKey === key &&
village.explorationCheckpoint.overlay
.sourceNpcId === source &&
village.explorationCheckpoint.overlay
.lineIndex === lineIndex
);
},
expected
);
}
async function waitForMilitiaDialogue(
page,
expected
) {
await page.waitForFunction(
({ key, source, lineIndex }) => {
const camp =
window.__HEROS_DEBUG__?.militiaCamp?.();
return (
camp?.dialogue?.active === true &&
camp.dialogue.dialogueKey === key &&
camp.dialogue.sourceNpcId === source &&
camp.dialogue.lineIndex === lineIndex &&
camp.explorationCheckpoint?.overlay?.type ===
'dialogue' &&
camp.explorationCheckpoint.overlay.dialogueKey ===
key &&
camp.explorationCheckpoint.overlay.sourceNpcId ===
source &&
camp.explorationCheckpoint.overlay.lineIndex ===
lineIndex
);
},
expected,
{ timeout: 15000 }
);
}
async function finishMilitiaDialogue(page) {
for (let attempt = 0; attempt < 12; attempt += 1) {
const camp = await readMilitiaCamp(page);
if (!camp?.dialogue?.active) {
return;
}
const advanced = await page.evaluate(() => {
const scene =
window.__HEROS_DEBUG__?.scene(
'PrologueMilitiaCampScene'
);
if (!scene || typeof scene.advanceDialogue !== 'function') {
return false;
}
scene.advanceDialogue();
return true;
});
assert.equal(
advanced,
true,
`${renderer}: militia dialogue advance helper is unavailable.`
);
await page.waitForTimeout(100);
}
throw new Error(
`${renderer}: militia camp dialogue did not finish.`
);
}
async function waitForCommandChoice(
page,
pendingId = null
) {
await page.waitForFunction(
(expectedPendingId) => {
const camp =
window.__HEROS_DEBUG__?.militiaCamp?.();
return (
camp?.commandChoice?.open === true &&
camp.commandChoice.ready === true &&
camp.explorationCheckpoint?.overlay?.type ===
'choice' &&
camp.explorationCheckpoint.overlay.mode ===
'prologue-command' &&
(expectedPendingId === null ||
(camp.commandChoice.pendingId ===
expectedPendingId &&
camp.explorationCheckpoint.overlay.choiceId ===
expectedPendingId))
);
},
pendingId,
{ timeout: 30000 }
);
}
function assertExplorationDialogue(
state,
{ key, source, lineIndex },
label
) {
assert.equal(state?.dialogue?.active, true, label);
assert.equal(state.dialogue.dialogueKey, key, label);
assert.equal(state.dialogue.sourceNpcId, source, label);
assert.equal(state.dialogue.lineIndex, lineIndex, label);
assert.deepEqual(
state.explorationCheckpoint?.overlay,
{
type: 'dialogue',
dialogueKey: key,
sourceNpcId: source,
lineIndex
},
label
);
}
async function waitForStoryReady(page, nextScene) {
try {
await page.waitForFunction(
(expectedNextScene) => {
const debug = window.__HEROS_DEBUG__;
const storyScene = debug?.scene('StoryScene');
const story = storyScene?.getDebugState?.();
return (
debug?.activeScenes?.().includes('StoryScene') &&
story?.ready === true &&
storyScene?.transitioning === false &&
story?.nextScene === expectedNextScene
);
},
nextScene,
{ timeout: 90000 }
);
} catch (error) {
const diagnostic = await page.evaluate(() => ({
activeScenes:
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
story:
window.__HEROS_DEBUG__
?.scene('StoryScene')
?.getDebugState?.() ?? null,
village:
window.__HEROS_DEBUG__?.village?.() ?? null,
militiaCamp:
window.__HEROS_DEBUG__?.militiaCamp?.() ?? null
}));
throw new Error(
`${renderer}: StoryScene did not become ready: ` +
JSON.stringify(diagnostic),
{ cause: error }
);
}
await afterTwoFrames(page);
}
async function readCampaignCheckpoint(page) {
return page.evaluate(async () => {
const campaignModule = await import(
'/heros_web/src/game/state/campaignState.ts'
);
const campaign = campaignModule.getCampaignState();
return {
step: campaign.step,
checkpoint:
campaign.explorationCheckpoint ?? null,
villageComplete:
campaign.completedTutorialIds.includes(
campaignModule.prologueVillageCampaignTutorialIds
.complete
),
campComplete:
campaign.completedTutorialIds.includes(
campaignModule
.prologueMilitiaCampCampaignTutorialIds.complete
),
sortieOrderSelection:
campaign.sortieOrderSelection ?? null
};
});
}
async function reloadAndContinueToVillage(page) {
await reloadToTitle(page);
await clickContinue(page);
return waitForVillageReady(page);
}
async function reloadAndContinueToMilitiaCamp(page) {
await reloadToTitle(page);
await clickContinue(page);
return waitForMilitiaCampReady(page);
}
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(
({ id }) => 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 clickContinue(page) {
const item = await page.evaluate(
() =>
window.__HEROS_DEBUG__
?.title?.()
?.focus?.items?.find(
({ id }) => id === 'continue'
) ?? null
);
assert.equal(
item?.enabled,
true,
`${renderer}: Continue is unavailable.`
);
await clickBounds(page, item.bounds);
}
async function clickBounds(page, bounds) {
assertFiniteBounds(bounds);
assert(
bounds.x >= 0 &&
bounds.y >= 0 &&
bounds.x + bounds.width <=
desktopBrowserViewport.width &&
bounds.y + bounds.height <=
desktopBrowserViewport.height,
`Bounds are outside the FHD viewport: ${JSON.stringify(
bounds
)}`
);
await page.mouse.click(
bounds.x + bounds.width / 2,
bounds.y + bounds.height / 2
);
}
function exactPlayer(player) {
assert(player, 'Player debug state is required.');
assert(Number.isFinite(player.x));
assert(Number.isFinite(player.y));
assert(
['north', 'south', 'east', 'west'].includes(
player.direction
)
);
return {
x: player.x,
y: player.y,
direction: player.direction
};
}
function assertExactPlayer(actual, expected, label) {
assert(actual, label);
assert(
Math.abs(actual.x - expected.x) <= 0.05,
`${label}: x ${actual.x} !== ${expected.x}`
);
assert(
Math.abs(actual.y - expected.y) <= 0.05,
`${label}: y ${actual.y} !== ${expected.y}`
);
assert.equal(actual.direction, expected.direction, label);
}
function assertCheckpoint(
checkpoint,
scene,
contextId,
expectedPlayer,
label
) {
assert.equal(checkpoint?.version, 1, label);
assert.equal(checkpoint?.scene, scene, label);
assert.equal(checkpoint?.contextId, contextId, label);
assertExactPlayer(
checkpoint?.player,
exactPlayer(expectedPlayer),
label
);
}
function assertFiniteBounds(bounds) {
assert(bounds, 'Interactive bounds are required.');
for (const value of [
bounds.x,
bounds.y,
bounds.width,
bounds.height
]) {
assert(Number.isFinite(value));
}
assert(bounds.width > 0);
assert(bounds.height > 0);
}
async function afterTwoFrames(page) {
await page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() =>
requestAnimationFrame(resolve)
)
)
);
}
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 || '41811',
'--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 < 160; attempt += 1) {
if (await canReach(url)) {
return child;
}
await delay(250);
}
child.kill();
throw new Error(
`Vite server did not start at ${url}\n${stderr.join('')}`
);
}
async function canReach(url) {
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(1000)
});
return response.ok;
} catch {
return false;
}
}
async function stopServerProcess(child) {
if (!child || child.killed) {
return;
}
child.kill();
await delay(80);
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}