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

1500 lines
38 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 renderer =
process.env.VERIFY_CAMP_EXPLORATION_CHECKPOINT_RENDERER;
const rendererNames = ['canvas', 'webgl'];
const fixture = Object.freeze({
visitId: 'first-pursuit-scout-tent',
battleId: 'first-battle-zhuo-commandery',
campaignStep: 'first-camp',
actorId: 'jian-yong',
movementAnchorId: 'zhang-fei',
exitId: 'camp-exit',
choiceId: 'trace-river-ambush',
itemId: '상처약',
itemAmount: 1,
bondId: 'liu-bei__jian-yong',
bondExp: 10
});
if (!renderer) {
for (const requestedRenderer of rendererNames) {
const result = spawnSync(
process.execPath,
[fileURLToPath(import.meta.url)],
{
cwd: process.cwd(),
env: {
...process.env,
VERIFY_CAMP_EXPLORATION_CHECKPOINT_RENDERER:
requestedRenderer
},
stdio: 'inherit'
}
);
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
process.exit(0);
}
assert(
rendererNames.includes(renderer),
`Unsupported camp exploration checkpoint renderer "${renderer}".`
);
const baseUrl =
process.env.VERIFY_CAMP_EXPLORATION_CHECKPOINT_URL ??
'http://127.0.0.1:41808/heros_web/';
const targetUrl = withDebugOptions(baseUrl, renderer);
const localSeedUrl = withDebugOptions(
'http://127.0.0.1:41808/heros_web/',
renderer
);
let serverProcess;
let seedServerProcess;
let browser;
try {
mkdirSync('dist', { recursive: true });
serverProcess = await ensureLocalServer(targetUrl);
browser = await chromium.launch({
headless:
process.env.VERIFY_CAMP_EXPLORATION_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.addInitScript(() => {
const marker =
'heros-web:qa:camp-exploration-checkpoint-storage-cleared';
if (window.sessionStorage.getItem(marker) === '1') {
return;
}
window.localStorage.clear();
window.sessionStorage.setItem(marker, '1');
});
await page.goto(targetUrl, {
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForDebugApi(page);
let seeded;
if (isLocalUrl(targetUrl)) {
seeded = await seedFirstCampVisit(page, fixture);
} else {
const bridge = await createSeedBridge(
browser,
localSeedUrl,
fixture
);
seedServerProcess = bridge.serverProcess;
seeded = bridge.seeded;
await installCampaignSaves(page, bridge.campaignSaves);
}
assertSeededVisit(seeded);
await reloadToTitle(page);
assertRuntimeBaseline(
await readRuntimeProbe(page, 'TitleScene'),
renderer,
'seeded first-camp title'
);
await continueToExploration(page);
assertRuntimeBaseline(
await readRuntimeProbe(
page,
'CampVisitExplorationScene'
),
renderer,
'first-camp exploration'
);
const savedPlayer = await createPlayerCheckpoint(page);
const playerResume = await reloadAndContinueToExploration(page);
assertExactPlayer(
playerResume.player,
savedPlayer,
`${renderer}: position checkpoint resume`
);
assertExactCheckpointPlayer(
playerResume.campaign.explorationCheckpoint,
savedPlayer,
`${renderer}: persisted position checkpoint`
);
const dialogueBeforeReload =
await openAndAdvanceActorDialogue(page);
const dialogueResume =
await reloadAndContinueToExploration(page);
assertExactPlayer(
dialogueResume.player,
dialogueBeforeReload.player,
`${renderer}: dialogue player resume`
);
assertDialogueResume(
dialogueResume,
dialogueBeforeReload,
`${renderer}: actor dialogue resume`
);
const choiceBeforeReload = await advanceToChoicePanel(page);
const choiceResume =
await reloadAndContinueToExploration(page);
await waitForChoiceReady(page);
const readyChoiceResume = await readExploration(page);
assertChoiceResume(
readyChoiceResume,
choiceBeforeReload,
`${renderer}: choice panel resume`
);
assertRuntimeBaseline(
await readRuntimeProbe(
page,
'CampVisitExplorationScene'
),
renderer,
'resumed choice panel'
);
await page.screenshot({
path:
`dist/camp-exploration-checkpoint-choice-${renderer}.png`
});
const rewardBefore = rewardSnapshot(
await readCampaignStates(page)
);
assertRewardNotYetApplied(
rewardBefore,
`${renderer}: pre-choice reward state`
);
const selectedChoice = readyChoiceResume.choice.choices.find(
({ id }) => id === fixture.choiceId
);
assert(
selectedChoice,
`${renderer}: choice ${fixture.choiceId} is unavailable.`
);
const choiceBounds = assertFiniteBounds(
selectedChoice.bounds,
`${renderer}: ${fixture.choiceId} choice`
);
assertBoundsContained(
choiceBounds,
desktopBrowserViewportBounds,
`${renderer}: ${fixture.choiceId} choice`
);
await clickSceneBounds(
page,
'CampVisitExplorationScene',
choiceBounds
);
await waitForChoiceResponse(page, 0);
const rewardAfterChoice = rewardSnapshot(
await readCampaignStates(page)
);
assertRewardAppliedExactlyOnce(
rewardBefore,
rewardAfterChoice,
`${renderer}: first choice commit`
);
const advancedResponse = await page.evaluate(() =>
window.__HEROS_DEBUG__
?.scene('CampVisitExplorationScene')
?.debugAdvanceDialogue?.() ?? false
);
assert.equal(
advancedResponse,
true,
`${renderer}: could not advance the choice response.`
);
await waitForChoiceResponse(page, 1);
const responseBeforeReload = await readExploration(page);
assert.equal(
responseBeforeReload.dialogue.sourceNpcId,
fixture.actorId
);
assert.equal(
responseBeforeReload.campaign.explorationCheckpoint?.overlay
?.dialogueKey,
'first-choice-response'
);
const responseResume =
await reloadAndContinueToExploration(page);
assertChoiceResponseResume(
responseResume,
responseBeforeReload,
`${renderer}: choice response resume`
);
const rewardAfterReload = rewardSnapshot(
await readCampaignStates(page)
);
assert.deepEqual(
rewardAfterReload,
rewardAfterChoice,
`${renderer}: reloading the response duplicated or changed rewards.`
);
assertRewardAppliedExactlyOnce(
rewardBefore,
rewardAfterReload,
`${renderer}: post-reload reward state`
);
await finishActiveDialogue(page);
await exitExplorationNormally(page);
await waitForCampReady(page, fixture.campaignStep);
assertRuntimeBaseline(
await readRuntimeProbe(page, 'CampScene'),
renderer,
'normal checkpoint-clearing exit'
);
assertCheckpointCleared(
await readCampaignStates(page),
`${renderer}: normal exit`
);
await reloadToTitle(page);
await clickContinue(page);
await waitForCampReady(page, fixture.campaignStep);
assertCheckpointCleared(
await readCampaignStates(page),
`${renderer}: continue after normal exit`
);
assert(
!(await page.evaluate(() =>
window.__HEROS_DEBUG__
?.activeScenes?.()
.includes('CampVisitExplorationScene')
)),
`${renderer}: a cleared checkpoint reopened exploration.`
);
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%'
},
checkpoint: {
exactPlayerResume: savedPlayer,
dialogue: {
sourceNpcId:
dialogueBeforeReload.dialogue.sourceNpcId,
lineIndex:
dialogueBeforeReload.dialogue.lineIndex
},
choicePanelResumed:
choiceResume.choice.open === true,
response: {
sourceNpcId:
responseBeforeReload.dialogue.sourceNpcId,
lineIndex:
responseBeforeReload.dialogue.lineIndex
},
rewardCommittedOnce: true,
normalExitCleared: true
},
pageErrors: pageErrors.length,
consoleErrors: actionableConsoleErrors.length
},
null,
2
)
);
} finally {
await browser?.close();
await stopServerProcess(serverProcess);
await stopServerProcess(seedServerProcess);
}
function withDebugOptions(url, requestedRenderer) {
const parsed = new URL(url);
parsed.searchParams.set('debug', '1');
parsed.searchParams.set('renderer', requestedRenderer);
return parsed.toString();
}
function isLocalUrl(url) {
return ['localhost', '127.0.0.1', '0.0.0.0'].includes(
new URL(url).hostname
);
}
async function waitForDebugApi(page) {
await page.waitForFunction(
() =>
document.querySelector('canvas') !== null &&
window.__HEROS_GAME__ !== undefined &&
window.__HEROS_DEBUG__ !== undefined,
undefined,
{ timeout: 90000 }
);
}
async function seedFirstCampVisit(page, requested) {
return page.evaluate(async (seed) => {
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[seed.battleId];
if (!scenario) {
throw new Error(
`Missing battle scenario ${seed.battleId}.`
);
}
campaignModule.resetCampaignState();
campaignModule.setFirstBattleReport({
battleId: scenario.id,
battleTitle: scenario.title,
outcome: 'victory',
turnNumber: 6,
rewardGold: scenario.baseVictoryGold,
defeatedEnemies: scenario.units.filter(
(unit) => unit.faction === 'enemy'
).length,
totalEnemies: scenario.units.filter(
(unit) => unit.faction === 'enemy'
).length,
objectives: [],
units: scenario.units,
bonds: (scenario.bonds ?? []).map((bond) => ({
...bond,
battleExp: 0
})),
itemRewards: [],
completedCampDialogues: [],
completedCampVisits: [],
createdAt: '2026-07-28T00:00:00.000Z'
});
campaignModule.completeCampaignAftermath(scenario.id);
campaignModule.setActiveCampVisitId(seed.visitId);
const campaign = campaignModule.getCampaignState();
const slot = campaignModule.readCampaignSaveState(1);
const bond = campaign.bonds.find(
({ id }) => id === seed.bondId
);
return {
step: campaign.step,
latestBattleId: campaign.latestBattleId ?? null,
activeCampVisitId:
campaign.activeCampVisitId ?? null,
slotActiveCampVisitId:
slot?.activeCampVisitId ?? null,
checkpoint:
campaign.explorationCheckpoint ?? null,
completedVisitCount:
campaign.completedCampVisits.filter(
(id) => id === seed.visitId
).length,
inventoryItem:
campaign.inventory[seed.itemId] ?? 0,
bond: bond
? {
level: bond.level,
exp: bond.exp,
battleExp: bond.battleExp
}
: null
};
}, requested);
}
function assertSeededVisit(seeded) {
assert.equal(seeded.step, fixture.campaignStep);
assert.equal(seeded.latestBattleId, fixture.battleId);
assert.equal(
seeded.activeCampVisitId,
fixture.visitId
);
assert.equal(
seeded.slotActiveCampVisitId,
fixture.visitId
);
assert.equal(seeded.checkpoint, null);
assert.equal(seeded.completedVisitCount, 0);
assert(
seeded.bond,
`${renderer}: seed is missing ${fixture.bondId}.`
);
}
async function createSeedBridge(
activeBrowser,
seedUrl,
requested
) {
const localServerProcess = await ensureLocalServer(seedUrl);
const context = await activeBrowser.newContext(
desktopBrowserContextOptions
);
try {
const page = await context.newPage();
page.setDefaultTimeout(30000);
await page.addInitScript(() => window.localStorage.clear());
await page.goto(seedUrl, {
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForDebugApi(page);
const seeded = await seedFirstCampVisit(page, requested);
const campaignSaves = await readCampaignSaves(page);
assert(
Object.keys(campaignSaves).length >= 2,
'Local seed bridge did not create current and slot saves.'
);
return {
seeded,
campaignSaves,
serverProcess: localServerProcess
};
} catch (error) {
await stopServerProcess(localServerProcess);
throw error;
} finally {
await context.close();
}
}
async function installCampaignSaves(page, campaignSaves) {
await page.evaluate((saves) => {
Object.keys(window.localStorage)
.filter((key) =>
key.startsWith('heros-web:campaign-state')
)
.forEach((key) => window.localStorage.removeItem(key));
for (const [key, value] of Object.entries(saves)) {
window.localStorage.setItem(key, value);
}
}, campaignSaves);
}
async function reloadToTitle(page) {
await page.reload({
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForDebugApi(page);
await page.waitForFunction(
() => {
const debug = window.__HEROS_DEBUG__;
const title = debug?.title?.();
const continueItem = title?.focus?.items?.find(
({ 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}: title continue button is unavailable.`
);
const bounds = assertFiniteBounds(
item.bounds,
`${renderer}: title continue button`
);
assertBoundsContained(
bounds,
desktopBrowserViewportBounds,
`${renderer}: title continue button`
);
await clickSceneBounds(page, 'TitleScene', bounds);
}
async function continueToExploration(page) {
await clickContinue(page);
return waitForExplorationReady(page);
}
async function reloadAndContinueToExploration(page) {
await reloadToTitle(page);
return continueToExploration(page);
}
async function waitForExplorationReady(page) {
try {
await page.waitForFunction(
(visitId) => {
const debug = window.__HEROS_DEBUG__;
const exploration =
debug?.campVisitExploration?.();
return (
debug
?.activeScenes?.()
.includes('CampVisitExplorationScene') &&
exploration?.ready === true &&
exploration?.locationId === visitId &&
exploration?.visit?.id === visitId &&
exploration?.requiredTexturesReady === true
);
},
fixture.visitId,
{ timeout: 90000 }
);
} catch (error) {
const diagnostic = await page.evaluate(() => ({
activeScenes:
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
title: window.__HEROS_DEBUG__?.title?.() ?? null,
exploration:
window.__HEROS_DEBUG__?.campVisitExploration?.() ??
null
}));
throw new Error(
`${renderer}: exploration did not become ready: ` +
JSON.stringify(diagnostic),
{ cause: error }
);
}
await afterTwoFrames(page);
const exploration = await readExploration(page);
assert.equal(
exploration.campaignStep,
fixture.campaignStep
);
return exploration;
}
async function readExploration(page) {
return page.evaluate(
() =>
window.__HEROS_DEBUG__?.campVisitExploration?.() ??
null
);
}
async function createPlayerCheckpoint(page) {
await page.waitForTimeout(450);
const teleported = await page.evaluate((targetId) =>
window.__HEROS_DEBUG__
?.scene('CampVisitExplorationScene')
?.debugTeleportTo?.(targetId) ?? false,
fixture.movementAnchorId
);
assert.equal(
teleported,
true,
`${renderer}: could not create the movement checkpoint.`
);
await page.keyboard.down('ArrowRight');
await page.waitForTimeout(260);
await page.keyboard.up('ArrowRight');
try {
await page.waitForFunction(
() => {
const state =
window.__HEROS_DEBUG__?.campVisitExploration?.();
const player = state?.player;
const checkpoint =
state?.campaign?.explorationCheckpoint;
return (
player &&
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
);
},
undefined,
{ timeout: 10000 }
);
} catch (error) {
const diagnostic = await readExploration(page);
throw new Error(
`${renderer}: movement checkpoint did not settle: ` +
JSON.stringify({
activeScenes:
await page.evaluate(
() =>
window.__HEROS_DEBUG__?.activeScenes?.() ??
[]
),
player: diagnostic?.player ?? null,
checkpoint:
diagnostic?.campaign?.explorationCheckpoint ??
null,
movement: diagnostic?.movement ?? null
}),
{ cause: error }
);
}
const exploration = await readExploration(page);
const player = exactPlayer(
exploration.campaign.explorationCheckpoint?.player
);
assertExactCheckpointPlayer(
exploration.campaign.explorationCheckpoint,
player,
`${renderer}: newly saved player checkpoint`
);
return player;
}
async function openAndAdvanceActorDialogue(page) {
const interacted = await page.evaluate((actorId) =>
window.__HEROS_DEBUG__
?.scene('CampVisitExplorationScene')
?.debugInteractWith?.(actorId) ?? false,
fixture.actorId
);
assert.equal(
interacted,
true,
`${renderer}: could not interact with ${fixture.actorId}.`
);
await waitForDialogue(page, {
sourceNpcId: fixture.actorId,
lineIndex: 0,
dialogueKey: 'visit-interaction'
});
const advanced = await page.evaluate(() =>
window.__HEROS_DEBUG__
?.scene('CampVisitExplorationScene')
?.debugAdvanceDialogue?.() ?? false
);
assert.equal(
advanced,
true,
`${renderer}: could not advance the actor dialogue.`
);
await waitForDialogue(page, {
sourceNpcId: fixture.actorId,
lineIndex: 1,
dialogueKey: 'visit-interaction'
});
return readExploration(page);
}
async function waitForDialogue(
page,
{ sourceNpcId, lineIndex, dialogueKey }
) {
await page.waitForFunction(
(expected) => {
const state =
window.__HEROS_DEBUG__?.campVisitExploration?.();
const overlay =
state?.campaign?.explorationCheckpoint?.overlay;
return (
state?.dialogue?.active === true &&
state.dialogue.sourceNpcId ===
expected.sourceNpcId &&
state.dialogue.lineIndex === expected.lineIndex &&
overlay?.type === 'dialogue' &&
overlay.dialogueKey === expected.dialogueKey &&
overlay.sourceNpcId === expected.sourceNpcId &&
overlay.lineIndex === expected.lineIndex
);
},
{ sourceNpcId, lineIndex, dialogueKey },
{ timeout: 10000 }
);
await afterTwoFrames(page);
}
function assertDialogueResume(actual, expected, label) {
assert.equal(actual.dialogue.active, true, label);
assert.equal(
actual.dialogue.sourceNpcId,
expected.dialogue.sourceNpcId,
`${label}: source changed.`
);
assert.equal(
actual.dialogue.lineIndex,
expected.dialogue.lineIndex,
`${label}: line changed.`
);
assert.equal(
actual.dialogue.totalLines,
expected.dialogue.totalLines,
`${label}: dialogue content changed.`
);
const actualOverlay =
actual.campaign.explorationCheckpoint?.overlay;
const expectedOverlay =
expected.campaign.explorationCheckpoint?.overlay;
assert.deepEqual(
actualOverlay,
expectedOverlay,
`${label}: checkpoint overlay changed.`
);
}
async function advanceToChoicePanel(page) {
for (let attempt = 0; attempt < 8; attempt += 1) {
const state = await readExploration(page);
if (state?.choice?.open) {
break;
}
assert.equal(
state?.dialogue?.active,
true,
`${renderer}: dialogue closed before the choice panel opened.`
);
const advanced = await page.evaluate(() =>
window.__HEROS_DEBUG__
?.scene('CampVisitExplorationScene')
?.debugAdvanceDialogue?.() ?? false
);
assert.equal(
advanced,
true,
`${renderer}: failed to advance toward the choice panel.`
);
await page.waitForTimeout(80);
}
await page.waitForFunction(
() => {
const state =
window.__HEROS_DEBUG__?.campVisitExploration?.();
const overlay =
state?.campaign?.explorationCheckpoint?.overlay;
return (
state?.dialogue?.active === false &&
state?.choice?.open === true &&
state.choice.mode === 'visit' &&
state.choice.choices.length === 2 &&
overlay?.type === 'choice' &&
overlay.mode === 'visit'
);
},
undefined,
{ timeout: 10000 }
);
await afterTwoFrames(page);
return readExploration(page);
}
async function waitForChoiceReady(page) {
await page.waitForFunction(
() => {
const choice =
window.__HEROS_DEBUG__
?.campVisitExploration?.()
?.choice;
return (
choice?.open === true &&
choice?.ready === true &&
choice.choices.length === 2 &&
choice.choices.every(
({ interactive, bounds }) =>
interactive === true &&
bounds &&
Number.isFinite(bounds.x) &&
Number.isFinite(bounds.y)
)
);
},
undefined,
{ timeout: 10000 }
);
await afterTwoFrames(page);
}
function assertChoiceResume(actual, expected, label) {
assert.equal(actual.choice.open, true, label);
assert.equal(actual.choice.mode, expected.choice.mode);
assert.equal(
actual.choice.sourceNpcId,
expected.choice.sourceNpcId
);
assert.deepEqual(
actual.choice.choices.map(({ id }) => id),
expected.choice.choices.map(({ id }) => id),
`${label}: choice IDs changed.`
);
assert.deepEqual(
actual.campaign.explorationCheckpoint?.overlay,
expected.campaign.explorationCheckpoint?.overlay,
`${label}: choice checkpoint changed.`
);
for (const choice of actual.choice.choices) {
const bounds = assertFiniteBounds(
choice.bounds,
`${label}: ${choice.id}`
);
assertBoundsContained(
bounds,
desktopBrowserViewportBounds,
`${label}: ${choice.id}`
);
}
}
async function waitForChoiceResponse(page, lineIndex) {
await waitForDialogue(page, {
sourceNpcId: fixture.actorId,
lineIndex,
dialogueKey: 'first-choice-response'
});
const state = await readExploration(page);
assert.equal(state.choice.open, false);
assert.equal(state.visit.completed, true);
assert.equal(state.visit.choiceId, fixture.choiceId);
}
function assertChoiceResponseResume(actual, expected, label) {
assertDialogueResume(actual, expected, label);
assert.equal(actual.visit.completed, true, label);
assert.equal(
actual.visit.choiceId,
fixture.choiceId,
`${label}: selected choice changed.`
);
assert.equal(
actual.choice.open,
false,
`${label}: response reopened the choice panel.`
);
}
async function finishActiveDialogue(page) {
for (let attempt = 0; attempt < 8; attempt += 1) {
const state = await readExploration(page);
if (!state?.dialogue?.active) {
break;
}
const advanced = await page.evaluate(() =>
window.__HEROS_DEBUG__
?.scene('CampVisitExplorationScene')
?.debugAdvanceDialogue?.() ?? false
);
assert.equal(
advanced,
true,
`${renderer}: could not finish the response dialogue.`
);
await page.waitForTimeout(80);
}
await page.waitForFunction(
() => {
const state =
window.__HEROS_DEBUG__?.campVisitExploration?.();
return (
state?.dialogue?.active === false &&
state?.choice?.open === false &&
state?.campaign?.explorationCheckpoint &&
state.campaign.explorationCheckpoint.overlay ===
undefined
);
},
undefined,
{ timeout: 10000 }
);
}
async function exitExplorationNormally(page) {
const startedExit = await page.evaluate((exitId) =>
window.__HEROS_DEBUG__
?.scene('CampVisitExplorationScene')
?.debugInteractWith?.(exitId) ?? false,
fixture.exitId
);
assert.equal(
startedExit,
true,
`${renderer}: normal camp exit did not start.`
);
}
async function waitForCampReady(page, expectedStep) {
try {
await page.waitForFunction(
(step) => {
const debug = window.__HEROS_DEBUG__;
const camp = debug?.camp?.();
return (
debug?.activeScenes?.().includes('CampScene') &&
camp?.scene === 'CampScene' &&
camp?.campaignObjectiveJournal?.snapshot &&
camp?.campaign?.step === step
);
},
expectedStep,
{ timeout: 90000 }
);
} catch (error) {
const diagnostic = await page.evaluate(() => ({
activeScenes:
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
camp: window.__HEROS_DEBUG__?.camp?.() ?? null,
exploration:
window.__HEROS_DEBUG__?.campVisitExploration?.() ??
null
}));
throw new Error(
`${renderer}: CampScene did not become ready: ` +
JSON.stringify(diagnostic),
{ cause: error }
);
}
await afterTwoFrames(page);
}
function exactPlayer(player) {
assert(
player &&
Number.isFinite(player.x) &&
Number.isFinite(player.y) &&
['north', 'south', 'east', 'west'].includes(
player.direction
),
`${renderer}: invalid player snapshot ${JSON.stringify(
player
)}.`
);
return {
x: player.x,
y: player.y,
direction: player.direction
};
}
function assertExactPlayer(actual, expected, label) {
assert.deepEqual(
exactPlayer(actual),
exactPlayer(expected),
`${label}: exact x/y/direction changed.`
);
}
function assertExactCheckpointPlayer(
checkpoint,
expected,
label
) {
assert(
checkpoint,
`${label}: exploration checkpoint is missing.`
);
assert.equal(checkpoint.version, 1, label);
assert.equal(
checkpoint.scene,
'camp-visit-exploration',
label
);
assert.equal(checkpoint.contextId, fixture.visitId, label);
assert.deepEqual(
checkpoint.player,
expected,
`${label}: checkpoint player differs.`
);
}
async function readCampaignSaves(page) {
return page.evaluate(() =>
Object.fromEntries(
Object.keys(window.localStorage)
.filter((key) =>
key.startsWith('heros-web:campaign-state')
)
.sort()
.map((key) => [
key,
window.localStorage.getItem(key)
])
)
);
}
async function readCampaignStates(page) {
const saves = await readCampaignSaves(page);
return Object.fromEntries(
Object.entries(saves).map(([key, value]) => [
key,
JSON.parse(value)
])
);
}
function rewardSnapshot(states) {
const entries = Object.entries(states);
assert(
entries.length >= 2,
`${renderer}: expected current and slot campaign saves.`
);
return Object.fromEntries(
entries.map(([key, state]) => {
const bond = state.bonds.find(
({ id }) => id === fixture.bondId
);
const reportBond = state.firstBattleReport?.bonds.find(
({ id }) => id === fixture.bondId
);
assert(
bond && reportBond,
`${renderer}: ${key} is missing ${fixture.bondId}.`
);
return [
key,
{
completedVisitCount:
state.completedCampVisits.filter(
(id) => id === fixture.visitId
).length,
reportCompletedVisitCount:
state.firstBattleReport.completedCampVisits.filter(
(id) => id === fixture.visitId
).length,
choiceId:
state.campVisitChoiceIds[fixture.visitId] ??
null,
inventory: { ...state.inventory },
bond: {
level: bond.level,
exp: bond.exp,
battleExp: bond.battleExp
},
reportBond: {
level: reportBond.level,
exp: reportBond.exp,
battleExp: reportBond.battleExp
}
}
];
})
);
}
function assertRewardNotYetApplied(snapshot, label) {
for (const [key, state] of Object.entries(snapshot)) {
assert.equal(
state.completedVisitCount,
0,
`${label}: ${key} already completed the visit.`
);
assert.equal(
state.reportCompletedVisitCount,
0,
`${label}: ${key} report already completed the visit.`
);
assert.equal(
state.choiceId,
null,
`${label}: ${key} already remembers a choice.`
);
}
}
function assertRewardAppliedExactlyOnce(
before,
after,
label
) {
assert.deepEqual(
Object.keys(after),
Object.keys(before),
`${label}: campaign save keys changed.`
);
for (const key of Object.keys(before)) {
const previous = before[key];
const current = after[key];
assert.equal(
current.completedVisitCount,
1,
`${label}: ${key} visit completion count mismatch.`
);
assert.equal(
current.reportCompletedVisitCount,
1,
`${label}: ${key} report completion count mismatch.`
);
assert.equal(
current.choiceId,
fixture.choiceId,
`${label}: ${key} choice mismatch.`
);
assert.equal(
current.inventory[fixture.itemId] ?? 0,
(previous.inventory[fixture.itemId] ?? 0) +
fixture.itemAmount,
`${label}: ${key} inventory reward mismatch.`
);
for (const [itemId, amount] of Object.entries(
current.inventory
)) {
if (itemId !== fixture.itemId) {
assert.equal(
amount,
previous.inventory[itemId],
`${label}: ${key} changed unrelated item ${itemId}.`
);
}
}
assert.deepEqual(
current.bond,
{
level: previous.bond.level,
exp: previous.bond.exp + fixture.bondExp,
battleExp:
previous.bond.battleExp + fixture.bondExp
},
`${label}: ${key} campaign bond reward mismatch.`
);
assert.deepEqual(
current.reportBond,
current.bond,
`${label}: ${key} report bond diverged.`
);
}
}
function assertCheckpointCleared(states, label) {
const entries = Object.entries(states);
assert(
entries.length >= 2,
`${label}: expected current and slot saves.`
);
for (const [key, state] of entries) {
assert.equal(
state.activeCampVisitId ?? null,
null,
`${label}: ${key} retained the active visit.`
);
assert.equal(
state.explorationCheckpoint ?? null,
null,
`${label}: ${key} retained the exploration checkpoint.`
);
}
}
async function clickSceneBounds(page, sceneKey, bounds) {
const point = await page.evaluate(
({ key, requestedBounds }) => {
const scene = window.__HEROS_DEBUG__?.scene(key);
const canvasBounds =
document.querySelector('canvas')?.getBoundingClientRect();
if (!scene || !canvasBounds) {
return null;
}
return {
x:
canvasBounds.left +
(requestedBounds.x +
requestedBounds.width / 2) *
canvasBounds.width /
scene.scale.width,
y:
canvasBounds.top +
(requestedBounds.y +
requestedBounds.height / 2) *
canvasBounds.height /
scene.scale.height
};
},
{ key: sceneKey, requestedBounds: bounds }
);
assert(
point &&
Number.isFinite(point.x) &&
Number.isFinite(point.y),
`Unable to map ${sceneKey} bounds ${JSON.stringify(
bounds
)}.`
);
await page.mouse.click(point.x, point.y);
}
function assertFiniteBounds(bounds, label) {
assert(
bounds &&
Number.isFinite(bounds.x) &&
Number.isFinite(bounds.y) &&
Number.isFinite(bounds.width) &&
Number.isFinite(bounds.height) &&
bounds.width > 0 &&
bounds.height > 0,
`${label}: invalid bounds ${JSON.stringify(bounds)}.`
);
return bounds;
}
function assertBoundsContained(bounds, container, label) {
assert(
bounds.x >= container.x &&
bounds.y >= container.y &&
bounds.x + bounds.width <=
container.x + container.width &&
bounds.y + bounds.height <=
container.y + container.height,
`${label}: ${JSON.stringify(bounds)} is outside ` +
JSON.stringify(container)
);
}
async function afterTwoFrames(page) {
await page.evaluate(
() =>
new Promise((resolve) => {
requestAnimationFrame(() =>
requestAnimationFrame(resolve)
);
})
);
}
async function readRuntimeProbe(page, sceneKey) {
return page.evaluate((key) => {
const game = window.__HEROS_GAME__;
const scene = game?.scene.getScene(key);
const canvas = document.querySelector('canvas');
const canvasBounds = canvas?.getBoundingClientRect();
return {
viewport: {
width: window.innerWidth,
height: window.innerHeight,
dpr: window.devicePixelRatio,
zoom: window.visualViewport?.scale ?? 1
},
canvas: canvas
? {
width: canvas.width,
height: canvas.height,
bounds: canvasBounds
? {
x: canvasBounds.x,
y: canvasBounds.y,
width: canvasBounds.width,
height: canvasBounds.height
}
: null
}
: null,
renderer: {
requested: new URLSearchParams(
window.location.search
).get('renderer'),
type: game?.renderer?.type ?? null,
name: game?.renderer?.constructor?.name ?? null
},
scene: scene
? {
width: scene.scale.width,
height: scene.scale.height
}
: null
};
}, sceneKey);
}
function assertRuntimeBaseline(
runtime,
requestedRenderer,
label
) {
assert.deepEqual(
runtime.viewport,
{
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height,
dpr: desktopBrowserDeviceScaleFactor,
zoom: 1
},
`${label}: viewport, DPR, or browser zoom mismatch.`
);
assert.deepEqual(
runtime.canvas,
{
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height,
bounds: {
x: 0,
y: 0,
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height
}
},
`${label}: canvas did not fill the CSS viewport.`
);
assert.deepEqual(
runtime.scene,
{
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height
},
`${label}: Phaser scene size mismatch.`
);
assert.equal(
runtime.renderer.requested,
requestedRenderer,
`${label}: renderer query mismatch.`
);
assert.equal(
runtime.renderer.type,
requestedRenderer === 'canvas' ? 1 : 2,
`${label}: expected ${requestedRenderer}: ` +
JSON.stringify(runtime.renderer)
);
}
async function ensureLocalServer(url) {
if (await canReach(url)) {
return undefined;
}
const parsed = new URL(url);
if (
!['localhost', '127.0.0.1', '0.0.0.0'].includes(
parsed.hostname
)
) {
throw new Error(`No server responded at ${url}`);
}
const stderr = [];
const child = spawn(
process.execPath,
[
'node_modules/vite/bin/vite.js',
'--host',
'127.0.0.1',
'--port',
parsed.port || '41808',
'--strictPort'
],
{
cwd: process.cwd(),
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
}
);
child.stderr.on('data', (chunk) =>
stderr.push(chunk.toString())
);
child.stdout.on('data', () => {});
for (let attempt = 0; attempt < 120; attempt += 1) {
if (await canReach(url)) {
return child;
}
await delay(250);
}
await stopServerProcess(child);
throw new Error(
`Vite server did not start at ${url}\n${stderr.join('')}`
);
}
async function canReach(url) {
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(1000)
});
return response.ok;
} catch {
return false;
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function stopServerProcess(child) {
if (!child || child.exitCode !== null) {
return;
}
const exited = new Promise((resolve) =>
child.once('exit', resolve)
);
child.kill();
await Promise.race([exited, delay(5000)]);
if (child.exitCode === null) {
child.kill('SIGKILL');
await Promise.race([exited, delay(2000)]);
}
}