1197 lines
31 KiB
JavaScript
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));
|
|
}
|