Files
heros_web/scripts/verify-first-pursuit-camp-exploration-browser.mjs

1284 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdirSync } from 'node:fs';
import { chromium } from 'playwright';
import {
desktopBrowserContextOptions,
desktopBrowserDeviceScaleFactor,
desktopBrowserViewport
} from './desktop-browser-viewport.mjs';
const sceneKey = 'CampVisitExplorationScene';
const sourceBattleId = 'first-battle-zhuo-commandery';
const visitId = 'first-pursuit-scout-tent';
const choiceId = 'trace-river-ambush';
const choiceLabel = '강가 매복 흔적을 쫓는다';
const rewardItemLabel = '상처약';
const rewardItemText = '상처약 1';
const bondId = 'liu-bei__jian-yong';
const expectedActors = {
'jian-yong': {
x: 510,
y: 492,
textureKey: 'exploration-jian-yong'
},
'guan-yu': {
x: 1015,
y: 565,
textureKey: 'exploration-guan-yu'
},
'zhang-fei': {
x: 1270,
y: 595,
textureKey: 'exploration-zhang-fei'
}
};
const targetUrl = withDebugOptions(
process.env.VERIFY_FIRST_PURSUIT_CAMP_EXPLORATION_URL ??
'http://127.0.0.1:41798/'
);
let serverProcess;
let browser;
try {
mkdirSync('dist', { recursive: true });
serverProcess = await ensureLocalServer(targetUrl);
browser = await chromium.launch({
headless:
process.env.VERIFY_FIRST_PURSUIT_CAMP_EXPLORATION_HEADLESS !== '0'
});
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 page.evaluate(() => window.localStorage.clear());
await page.reload({
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForDebugApi(page);
await assertDesktopViewport(page);
const seeded = await seedFirstVictoryCamp(page);
assert.equal(seeded.step, 'first-camp');
assert.equal(seeded.latestBattleId, sourceBattleId);
assert.equal(seeded.reportBattleId, sourceBattleId);
assert.equal(seeded.reportOutcome, 'victory');
assert.equal(seeded.settlementOutcome, 'victory');
assert.equal(seeded.pendingAftermathBattleId, null);
assert.equal(seeded.completed, false);
assert.equal(seeded.choiceId, null);
assert.equal(seeded.rewardItemAmount, 0);
assert(
seeded.bond,
`The first-victory seed must include ${bondId}: ${JSON.stringify(seeded)}`
);
await page.evaluate(async () => {
await window.__HEROS_DEBUG__.goToCamp();
});
await page.waitForFunction(
({ expectedVisitId }) => {
const debug = window.__HEROS_DEBUG__;
const camp = debug?.camp?.();
const reward =
camp?.victoryRewardAcknowledgement;
const guided = reward?.actions?.find(
(action) => action.id === 'guided'
);
return (
debug?.activeScenes?.().includes('CampScene') &&
camp?.campaign?.step === 'first-camp' &&
reward?.visible === true &&
reward.actions
.map((action) => action.id)
.join(',') ===
'equipment,supplies,guided,sortie,close' &&
guided?.primary === true &&
guided?.label === '북문 정찰막 둘러보기' &&
guided?.interactive === true &&
camp?.sortieCommand?.guidedKind === 'visit' &&
camp.sortieCommand.targetId === expectedVisitId &&
camp.sortieCommand.label === '정찰막 둘러보기' &&
camp.sortieCommand.bypass?.label === '바로 출진' &&
camp.sortieCommand.bypass?.interactive === true
);
},
{ expectedVisitId: visitId },
{ timeout: 90000 }
);
const guidedCamp = await page.evaluate(() =>
window.__HEROS_DEBUG__?.camp?.()
);
const guidedReward =
guidedCamp.victoryRewardAcknowledgement;
const guidedAction = guidedReward.actions.find(
(action) => action.id === 'guided'
);
assert.equal(
guidedReward.actions.filter((action) => action.primary)
.length,
1
);
assert.equal(
guidedReward.actions.find(
(action) => action.id === 'sortie'
)?.label,
'바로 출진'
);
assertBoundsInsideViewport(
guidedAction.bounds,
'first-camp guided reward action'
);
await captureStableScreenshot(
page,
'dist/verification-first-pursuit-camp-guided-reward.png'
);
await clickNamedSceneBounds(
page,
'CampScene',
guidedAction.bounds
);
await waitForExplorationReady(page);
await page.waitForTimeout(380);
let exploration = await readExploration(page);
assert.equal(exploration.scene, sceneKey);
assert.equal(exploration.locationId, visitId);
assert.equal(exploration.campaignStep, 'first-camp');
assert.deepEqual(exploration.viewport, desktopBrowserViewport);
assert.equal(exploration.background.key, 'first-pursuit-camp-background');
assert.equal(exploration.background.ready, true);
assert.equal(exploration.background.fallback, false);
assert.equal(
exploration.background.sourceWidth,
desktopBrowserViewport.width
);
assert.equal(
exploration.background.sourceHeight,
desktopBrowserViewport.height
);
assert.deepEqual(exploration.background.bounds, {
x: 0,
y: 0,
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height
});
assert.equal(exploration.requiredTexturesReady, true);
assert.deepEqual(
exploration.requiredTextures,
[
{ key: 'exploration-liu-bei', ready: true },
{ key: 'exploration-jian-yong', ready: true },
{ key: 'exploration-guan-yu', ready: true },
{ key: 'exploration-zhang-fei', ready: true }
]
);
assert.equal(exploration.player.textureKey, 'exploration-liu-bei');
assert.deepEqual(
{
x: exploration.player.x,
y: exploration.player.y
},
{ x: 780, y: 720 }
);
assert.equal(exploration.movement.speed, 300);
assert.equal(exploration.interaction.radius, 122);
assert.equal(exploration.interaction.promptRadius, 164);
assert.equal(exploration.visit.completed, false);
assert.equal(exploration.visit.choiceId, null);
assertActorsFixed(exploration, 'initial camp');
assertBoundsInsideViewport(
exploration.background.bounds,
'exploration background'
);
assertBoundsInsideViewport(exploration.player.bounds, 'initial Liu Bei');
exploration.actors.forEach((actor) =>
assertBoundsInsideViewport(actor.bounds, `initial ${actor.name}`)
);
const jianYongSource = await readTextureSource(
page,
'exploration-jian-yong'
);
assert.equal(jianYongSource.exists, true);
assert.match(
decodeURIComponent(jianYongSource.url),
/\/exploration-jian-yong\.webp(?:$|[?#])/,
`Jian Yong must use his dedicated SD sheet instead of a generic fallback: ${JSON.stringify(jianYongSource)}`
);
assert.equal(jianYongSource.width, 3072);
assert.equal(jianYongSource.height, 768);
await captureStableScreenshot(
page,
'dist/verification-first-pursuit-camp-exploration-initial.png'
);
const beforeKeyboardMove = exploration.player;
await page.keyboard.down('ArrowLeft');
try {
await page.waitForFunction(
({ key, startX }) =>
window.__HEROS_DEBUG__
?.scene(key)
?.getDebugState?.()?.player?.x <= startX - 65,
{ key: sceneKey, startX: beforeKeyboardMove.x },
{ timeout: 3000 }
);
} finally {
await page.keyboard.up('ArrowLeft');
}
await page.waitForTimeout(100);
exploration = await readExploration(page);
assert(
beforeKeyboardMove.x - exploration.player.x >= 65,
`Expected real keyboard movement to move Liu Bei left: ${JSON.stringify({
before: beforeKeyboardMove,
after: exploration.player
})}`
);
assert(
Math.abs(beforeKeyboardMove.y - exploration.player.y) <= 2,
`Horizontal keyboard movement must retain Y: ${JSON.stringify({
before: beforeKeyboardMove,
after: exploration.player
})}`
);
assert.equal(exploration.player.moving, false);
assertActorsFixed(exploration, 'after keyboard movement');
assert(
nearestActorDistance(exploration) > exploration.interaction.radius &&
exploration.exit.distance > exploration.interaction.radius,
`The distant-interaction probe must start outside every interaction radius: ${JSON.stringify({
player: exploration.player,
actors: exploration.actors.map(({ id, distance }) => ({
id,
distance
})),
exit: exploration.exit
})}`
);
const savesBeforeDistantInteraction = await readCampaignSaves(page);
await page.keyboard.press('e');
await page.waitForTimeout(150);
exploration = await readExploration(page);
assert.equal(
exploration.dialogue.active,
false,
'Pressing E outside the interaction radius must not open dialogue.'
);
assert.equal(exploration.choice.open, false);
assert.equal(exploration.visit.completed, false);
assert.equal(exploration.lastNotice, '조금 더 가까이 다가가세요.');
assertRelevantProgressEqual(
savesBeforeDistantInteraction,
await readCampaignSaves(page),
'distant E interaction'
);
const jianYong = actorById(exploration, 'jian-yong');
const clickStart = { ...exploration.player };
const autoInteractionCountBefore =
exploration.movement.autoInteraction.triggeredCount;
await clickScenePoint(page, jianYong.x, jianYong.y);
await page.waitForFunction(
({ key, actorId, startX, startY }) => {
const state = window.__HEROS_DEBUG__
?.scene(key)
?.getDebugState?.();
return (
state?.movement?.targetId === actorId &&
Math.hypot(
state.player.x - startX,
state.player.y - startY
) >= 10
);
},
{
key: sceneKey,
actorId: 'jian-yong',
startX: clickStart.x,
startY: clickStart.y
},
{ timeout: 5000 }
);
await page.waitForFunction(
({ key, actorId }) => {
const state = window.__HEROS_DEBUG__
?.scene(key)
?.getDebugState?.();
return (
state?.movement?.target === null &&
state?.movement?.lastTargetId === actorId &&
state?.interaction?.targetId === actorId &&
state?.interaction?.canInteract === true
);
},
{ key: sceneKey, actorId: 'jian-yong' },
{ timeout: 30000 }
);
await page.waitForFunction(
(key) => (
window.__HEROS_DEBUG__
?.scene(key)
?.getDebugState?.()?.dialogue?.sourceNpcId === 'jian-yong'
),
sceneKey
);
exploration = await readExploration(page);
assert(
actorById(exploration, 'jian-yong').distance <=
exploration.interaction.radius,
`Click movement must stop inside Jian Yong's interaction radius: ${JSON.stringify(exploration.interaction)}`
);
assertActorsFixed(exploration, 'after click navigation');
assert.equal(
exploration.movement.autoInteraction.triggeredCount,
autoInteractionCountBefore + 1,
'Arriving at Jian Yong must open dialogue exactly once.'
);
const protectedArrivalLineIndex = exploration.dialogue.lineIndex;
await page.mouse.click(960, 850);
await page.waitForTimeout(80);
exploration = await readExploration(page);
assert.equal(
exploration.dialogue.lineIndex,
protectedArrivalLineIndex,
'A follow-up click inside the arrival guard must not skip Jian Yongs first line.'
);
assert.equal(
exploration.dialogue.sourceNpcId,
'jian-yong',
'The arrival guard must keep Jian Yongs newly opened dialogue active.'
);
await page.waitForTimeout(160);
assert.equal(
(await readExploration(page)).movement.autoInteraction.triggeredCount,
autoInteractionCountBefore + 1,
'Jian Yong dialogue must not retrigger on a later update.'
);
exploration = await readExploration(page);
assert.equal(exploration.dialogue.speaker, '간옹');
assert.equal(exploration.dialogue.portraitVisible, true);
assert.equal(exploration.dialogue.portraitFrameVisible, true);
assert.equal(
exploration.dialogue.portraitTextureKey,
'portrait-jian-yong-campaign'
);
assert.equal(exploration.dialogue.totalLines, 2);
assertBoundsInsideViewport(
exploration.dialogue.bounds,
'Jian Yong dialogue'
);
await captureStableScreenshot(
page,
'dist/verification-first-pursuit-camp-exploration-dialogue.png'
);
await advanceDialogueUntilChoice(page);
await page.waitForFunction(
(key) =>
window.__HEROS_DEBUG__
?.scene(key)
?.getDebugState?.()?.choice?.ready === true,
sceneKey
);
exploration = await readExploration(page);
assert.equal(exploration.dialogue.active, false);
assert.equal(exploration.choice.open, true);
assert.deepEqual(
exploration.choice.choices.map(
({ id, label, rewardText, interactive }) => ({
id,
label,
rewardText,
interactive
})
),
[
{
id: choiceId,
label: choiceLabel,
rewardText: `공명 +10 · ${rewardItemText}`,
interactive: true
},
{
id: 'mark-village-relief',
label: '마을 구호 길을 표시한다',
rewardText: '공명 +10 · 콩 1',
interactive: true
}
]
);
assertBoundsInsideViewport(
exploration.choice.panelBounds,
'scout choice panel'
);
exploration.choice.choices.forEach((choice) =>
assertBoundsInside(
choice.bounds,
exploration.choice.panelBounds,
`choice ${choice.id}`
)
);
assertNoOverlappingBounds(
exploration.choice.choices.map(({ id, bounds }) => ({
id,
bounds
})),
'scout choices'
);
await captureStableScreenshot(
page,
'dist/verification-first-pursuit-camp-exploration-choice.png'
);
const savesBeforeChoice = await readCampaignSaves(page);
const bondBeforeChoice = campaignBond(
savesBeforeChoice.current,
bondId
);
assert(
bondBeforeChoice,
`Missing ${bondId} before the scout choice: ${JSON.stringify(savesBeforeChoice.current?.bonds)}`
);
const selectedChoice = exploration.choice.choices.find(
(choice) => choice.id === choiceId
);
assert(selectedChoice, `Missing choice ${choiceId}.`);
await clickSceneBounds(page, selectedChoice.bounds);
await page.waitForFunction(
({ key, expectedVisitId, expectedChoiceId }) => {
const state = window.__HEROS_DEBUG__
?.scene(key)
?.getDebugState?.();
return (
state?.visit?.completed === true &&
state.visit.id === expectedVisitId &&
state.visit.choiceId === expectedChoiceId &&
state.dialogue.active === true
);
},
{
key: sceneKey,
expectedVisitId: visitId,
expectedChoiceId: choiceId
}
);
exploration = await readExploration(page);
assert.equal(exploration.visit.choiceLabel, choiceLabel);
assert.deepEqual(exploration.visit.itemRewards, [rewardItemText]);
assert.equal(exploration.visit.bondExp, 10);
assert.equal(exploration.dialogue.speaker, '간옹');
assert.equal(exploration.dialogue.portraitVisible, true);
assert.equal(
exploration.dialogue.portraitTextureKey,
'portrait-jian-yong-campaign'
);
assert.equal(actorById(exploration, 'jian-yong').completed, true);
assertActorsFixed(exploration, 'after scout completion');
const savesAfterChoice = await readCampaignSaves(page);
assertScoutRewardSaved(
savesBeforeChoice,
savesAfterChoice,
bondBeforeChoice
);
await advanceDialogueUntilClosed(page);
exploration = await readExploration(page);
assert.equal(exploration.dialogue.active, false);
assert.equal(exploration.choice.open, false);
assert.equal(exploration.visit.completed, true);
assertActorsFixed(exploration, 'completed camp');
await captureStableScreenshot(
page,
'dist/verification-first-pursuit-camp-exploration-completed.png'
);
const duplicateDebugResult = await page.evaluate(
({ key, expectedChoiceId }) =>
window.__HEROS_DEBUG__
?.scene(key)
?.debugChooseScout?.(expectedChoiceId) ?? null,
{ key: sceneKey, expectedChoiceId: choiceId }
);
assert.equal(
duplicateDebugResult,
false,
'The scene debug choice hook must reject a completed visit.'
);
await page.keyboard.press('e');
await page.waitForFunction(
(key) =>
window.__HEROS_DEBUG__
?.scene(key)
?.getDebugState?.()?.dialogue?.sourceNpcId === 'jian-yong',
sceneKey
);
await advanceDialogueUntilClosed(page);
exploration = await readExploration(page);
assert.equal(
exploration.choice.open,
false,
'Revisiting Jian Yong after completion must replay the result without reopening choices.'
);
assertRelevantProgressEqual(
savesAfterChoice,
await readCampaignSaves(page),
'completed Jian Yong revisit'
);
const exitStart = { ...exploration.player };
await clickScenePoint(page, exploration.exit.x, exploration.exit.y);
await page.waitForFunction(
({ key, exitId, startX, startY }) => {
const state = window.__HEROS_DEBUG__
?.scene(key)
?.getDebugState?.();
return (
state?.movement?.targetId === exitId &&
Math.hypot(
state.player.x - startX,
state.player.y - startY
) >= 10
);
},
{
key: sceneKey,
exitId: 'camp-exit',
startX: exitStart.x,
startY: exitStart.y
},
{ timeout: 5000 }
);
await page.waitForFunction(
({ key, exitId }) => {
const state = window.__HEROS_DEBUG__
?.scene(key)
?.getDebugState?.();
return (
state?.movement?.target === null &&
state?.movement?.lastTargetId === exitId &&
state?.interaction?.targetId === exitId &&
state?.interaction?.canInteract === true
);
},
{ key: sceneKey, exitId: 'camp-exit' },
{ timeout: 30000 }
);
await page.keyboard.press('e');
await waitForCampReturn(page);
const camp = await page.evaluate(() =>
window.__HEROS_DEBUG__?.camp?.()
);
assert.equal(camp.campaign.step, 'first-camp');
assert.equal(camp.campBattleId, sourceBattleId);
assert.equal(camp.firstPursuitScoutMemory.completed, true);
assert.equal(camp.firstPursuitScoutMemory.choiceId, choiceId);
assert.equal(
camp.firstPursuitScoutMemory.activeForNextSortie,
true
);
assert.notEqual(
camp.sortieCommand.guidedKind,
'visit',
'Completing the guided scout visit must advance the primary camp action.'
);
assert.equal(
camp.sortieCommand.bypass,
null,
'The direct-sortie bypass must collapse once the seed has no pending guided step.'
);
assertRelevantProgressEqual(
savesAfterChoice,
await readCampaignSaves(page),
'CampScene return'
);
assert.deepEqual(
pageErrors,
[],
`Expected no browser page errors: ${JSON.stringify(pageErrors)}`
);
assert.deepEqual(
consoleErrors.filter(
(message) =>
!message.includes('The AudioContext was not allowed to start')
),
[],
`Expected no browser console errors: ${JSON.stringify(consoleErrors)}`
);
console.log(
`Verified the first-pursuit camp exploration at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` +
`DPR ${desktopBrowserDeviceScaleFactor}: dedicated Jian Yong SD art, fixed NPC positions, keyboard and pointer movement, ` +
'first-victory guided reward routing, distance-gated interaction, face portraits, two real choices, one-time resonance/item rewards, duplicate protection, ' +
'CampScene return, and current/slot-1 save persistence.'
);
} finally {
await browser?.close();
if (serverProcess && !serverProcess.killed) {
serverProcess.kill();
}
}
function withDebugOptions(url) {
const parsed = new URL(url);
parsed.searchParams.set('debug', '1');
parsed.searchParams.set(
'renderer',
process.env.VERIFY_FIRST_PURSUIT_CAMP_EXPLORATION_RENDERER ??
'canvas'
);
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, expectedVisitId, expectedBondId, itemLabel }) => {
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-27T00:00:00.000Z'
});
campaignModule.completeCampaignAftermath(scenario.id);
const campaign = campaignModule.getCampaignState();
const bond = campaign.bonds.find(
(candidate) => candidate.id === expectedBondId
);
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,
completed:
campaign.completedCampVisits.includes(expectedVisitId),
choiceId:
campaign.campVisitChoiceIds[expectedVisitId] ?? null,
rewardItemAmount: campaign.inventory[itemLabel] ?? 0,
bond: bond
? {
level: bond.level,
exp: bond.exp,
battleExp: bond.battleExp
}
: null
};
},
{
battleId: sourceBattleId,
expectedVisitId: visitId,
expectedBondId: bondId,
itemLabel: rewardItemLabel
}
);
}
async function waitForExplorationReady(page) {
try {
await page.waitForFunction(
(key) => {
const debug = window.__HEROS_DEBUG__;
const state = debug?.scene(key)?.getDebugState?.();
return (
debug?.activeScenes?.().includes(key) &&
state?.scene === key &&
state?.ready === true
);
},
sceneKey,
{ timeout: 90000 }
);
} catch (error) {
const diagnostic = await page.evaluate((key) => ({
activeScenes:
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
exploration:
window.__HEROS_DEBUG__?.scene(key)?.getDebugState?.() ??
null,
camp: window.__HEROS_DEBUG__?.camp?.() ?? null
}), sceneKey);
throw new Error(
`Camp visit exploration did not become ready: ${JSON.stringify(diagnostic)}`,
{ cause: error }
);
}
}
async function waitForCampReturn(page) {
await page.waitForFunction(
() => {
const debug = window.__HEROS_DEBUG__;
const camp = debug?.camp?.();
return (
debug?.activeScenes?.().includes('CampScene') &&
!debug?.activeScenes?.().includes('CampVisitExplorationScene') &&
camp?.scene === 'CampScene' &&
camp?.campaign?.step === 'first-camp'
);
},
undefined,
{ timeout: 90000 }
);
}
async function readExploration(page) {
return page.evaluate((key) =>
window.__HEROS_DEBUG__?.scene(key)?.getDebugState?.()
, sceneKey);
}
async function readTextureSource(page, textureKey) {
return page.evaluate(
({ key, requestedTextureKey }) => {
const scene = window.__HEROS_DEBUG__?.scene(key);
const texture = scene?.textures?.get(requestedTextureKey);
const source = texture?.source?.[0];
const image = source?.image;
return {
exists:
Boolean(scene?.textures?.exists(requestedTextureKey)),
url:
image?.currentSrc ??
image?.src ??
'',
width:
source?.width ??
image?.naturalWidth ??
image?.width ??
null,
height:
source?.height ??
image?.naturalHeight ??
image?.height ??
null
};
},
{ key: sceneKey, requestedTextureKey: textureKey }
);
}
async function advanceDialogueUntilChoice(page) {
for (let attempt = 0; attempt < 8; attempt += 1) {
const state = await readExploration(page);
if (state?.choice?.open) {
return;
}
if (!state?.dialogue?.active) {
throw new Error(
`Dialogue closed without opening the scout choice: ${JSON.stringify(state)}`
);
}
await page.keyboard.press('e');
await page.waitForTimeout(140);
}
throw new Error(
`Scout choice did not open: ${JSON.stringify(await readExploration(page))}`
);
}
async function advanceDialogueUntilClosed(page) {
for (let attempt = 0; attempt < 8; attempt += 1) {
const state = await readExploration(page);
if (!state?.dialogue?.active) {
return;
}
await page.keyboard.press('e');
await page.waitForTimeout(140);
}
throw new Error(
`Camp exploration dialogue did not close: ${JSON.stringify(await readExploration(page))}`
);
}
async function clickScenePoint(page, x, y) {
const point = await page.evaluate(
({ key, sceneX, sceneY }) => {
const scene = window.__HEROS_DEBUG__?.scene(key);
const canvas = document.querySelector('canvas');
const canvasBounds = canvas?.getBoundingClientRect();
if (!scene || !canvasBounds) {
return null;
}
return {
x:
canvasBounds.left +
(sceneX * canvasBounds.width) / scene.scale.width,
y:
canvasBounds.top +
(sceneY * canvasBounds.height) / scene.scale.height
};
},
{ key: sceneKey, sceneX: x, sceneY: y }
);
assert(
point && Number.isFinite(point.x) && Number.isFinite(point.y),
`Unable to map scene point ${JSON.stringify({ x, y })}.`
);
await page.mouse.click(point.x, point.y);
}
async function clickSceneBounds(page, bounds) {
await clickScenePoint(
page,
bounds.x + bounds.width / 2,
bounds.y + bounds.height / 2
);
}
async function clickNamedSceneBounds(page, requestedSceneKey, bounds) {
const point = await page.evaluate(
({ key, requestedBounds }) => {
const scene = window.__HEROS_DEBUG__?.scene(key);
const canvas = document.querySelector('canvas');
const canvasBounds = 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: requestedSceneKey, requestedBounds: bounds }
);
assert(
point && Number.isFinite(point.x) && Number.isFinite(point.y),
`Unable to map ${requestedSceneKey} bounds ${JSON.stringify(bounds)}.`
);
await page.mouse.click(point.x, point.y);
}
async function readCampaignSaves(page) {
return page.evaluate(() => {
const parse = (key) => {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
};
return {
current: parse('heros-web:campaign-state'),
slot1: parse('heros-web:campaign-state:slot-1')
};
});
}
function actorById(exploration, actorId) {
const actor = exploration?.actors?.find(
(candidate) => candidate.id === actorId
);
assert(
actor,
`Missing exploration actor ${actorId}: ${JSON.stringify(exploration?.actors)}`
);
return actor;
}
function assertActorsFixed(exploration, label) {
assert.equal(
exploration.actors.length,
Object.keys(expectedActors).length,
`${label}: unexpected actor count.`
);
Object.entries(expectedActors).forEach(
([actorId, expected]) => {
const actor = actorById(exploration, actorId);
assert.deepEqual(
{
x: actor.x,
y: actor.y,
initialX: actor.initialX,
initialY: actor.initialY,
moved: actor.moved,
textureKey: actor.textureKey
},
{
x: expected.x,
y: expected.y,
initialX: expected.x,
initialY: expected.y,
moved: false,
textureKey: expected.textureKey
},
`${label}: ${actorId} must remain at the authored position.`
);
}
);
}
function nearestActorDistance(exploration) {
return Math.min(
...exploration.actors.map(({ distance }) => distance)
);
}
function campaignBond(campaign, requestedBondId) {
return campaign?.bonds?.find(
(bond) => bond.id === requestedBondId
);
}
function bondProgress(bond) {
return bond.level * 100 + bond.exp;
}
function assertScoutRewardSaved(before, after, bondBefore) {
[after.current, after.slot1].forEach((campaign, index) => {
const saveLabel = index === 0 ? 'current save' : 'slot-1 save';
assert(campaign, `${saveLabel} is missing.`);
assert.equal(campaign.step, 'first-camp');
assert.equal(campaign.latestBattleId, sourceBattleId);
assert.equal(
campaign.completedCampVisits.filter(
(candidate) => candidate === visitId
).length,
1,
`${saveLabel} must contain one completion flag.`
);
assert.equal(
campaign.firstBattleReport.completedCampVisits.filter(
(candidate) => candidate === visitId
).length,
1,
`${saveLabel} report must contain one completion flag.`
);
assert.equal(
campaign.campVisitChoiceIds[visitId],
choiceId,
`${saveLabel} must retain the canonical choice.`
);
assert.equal(
campaign.inventory[rewardItemLabel] ??
0,
(before.current?.inventory?.[rewardItemLabel] ?? 0) + 1,
`${saveLabel} must grant exactly one ${rewardItemLabel}.`
);
const bondAfter = campaignBond(campaign, bondId);
assert(
bondAfter,
`${saveLabel} is missing ${bondId}.`
);
assert.equal(
bondProgress(bondAfter) - bondProgress(bondBefore),
10,
`${saveLabel} must grant exactly 10 resonance EXP.`
);
assert.equal(
bondAfter.battleExp - bondBefore.battleExp,
10,
`${saveLabel} must retain exactly 10 earned resonance EXP.`
);
});
}
function relevantProgress(saves) {
const project = (campaign) => {
const bond = campaignBond(campaign, bondId);
return {
completedCampVisits:
campaign?.completedCampVisits?.filter(
(candidate) => candidate === visitId
) ?? [],
reportCompletedCampVisits:
campaign?.firstBattleReport?.completedCampVisits?.filter(
(candidate) => candidate === visitId
) ?? [],
choiceId:
campaign?.campVisitChoiceIds?.[visitId] ?? null,
rewardItemAmount:
campaign?.inventory?.[rewardItemLabel] ?? 0,
bond: bond
? {
level: bond.level,
exp: bond.exp,
battleExp: bond.battleExp
}
: null
};
};
return {
current: project(saves.current),
slot1: project(saves.slot1)
};
}
function assertRelevantProgressEqual(before, after, label) {
assert.deepEqual(
relevantProgress(after),
relevantProgress(before),
`${label} must not change scout completion, choice, reward, or resonance.`
);
}
function assertNoOverlappingBounds(entries, label) {
for (
let leftIndex = 0;
leftIndex < entries.length;
leftIndex += 1
) {
for (
let rightIndex = leftIndex + 1;
rightIndex < entries.length;
rightIndex += 1
) {
const left = entries[leftIndex];
const right = entries[rightIndex];
const overlapWidth =
Math.min(
left.bounds.x + left.bounds.width,
right.bounds.x + right.bounds.width
) - Math.max(left.bounds.x, right.bounds.x);
const overlapHeight =
Math.min(
left.bounds.y + left.bounds.height,
right.bounds.y + right.bounds.height
) - Math.max(left.bounds.y, right.bounds.y);
assert(
overlapWidth <= 0 || overlapHeight <= 0,
`${label} ${left.id} and ${right.id} overlap: ${JSON.stringify({ left, right })}`
);
}
}
}
function assertBoundsInsideViewport(bounds, label) {
assertBoundsInside(
bounds,
{
x: 0,
y: 0,
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height
},
label
);
}
function assertBoundsInside(bounds, container, label) {
assert(bounds, `${label}: bounds are required.`);
const epsilon = 0.01;
assert(
bounds.x >= container.x - epsilon &&
bounds.y >= container.y - epsilon &&
bounds.x + bounds.width <=
container.x + container.width + epsilon &&
bounds.y + bounds.height <=
container.y + container.height + epsilon,
`${label}: expected bounds inside container, received ${JSON.stringify({ bounds, container })}`
);
}
async function assertDesktopViewport(page) {
const viewport = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
const bounds = canvas?.getBoundingClientRect();
return {
width: window.innerWidth,
height: window.innerHeight,
dpr: window.devicePixelRatio,
visualScale: window.visualViewport?.scale ?? 1,
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(viewport.width, desktopBrowserViewport.width);
assert.equal(viewport.height, desktopBrowserViewport.height);
assert.equal(viewport.dpr, desktopBrowserDeviceScaleFactor);
assert.equal(viewport.visualScale, 1);
assert.equal(viewport.canvas?.width, desktopBrowserViewport.width);
assert.equal(viewport.canvas?.height, desktopBrowserViewport.height);
assert.equal(
viewport.canvas?.clientWidth,
desktopBrowserViewport.width
);
assert.equal(
viewport.canvas?.clientHeight,
desktopBrowserViewport.height
);
assert.equal(
viewport.canvas?.bounds?.width,
desktopBrowserViewport.width
);
assert.equal(
viewport.canvas?.bounds?.height,
desktopBrowserViewport.height
);
}
async function captureStableScreenshot(page, path) {
await page.waitForTimeout(300);
const loopSlept = await page.evaluate(() => {
const loop = window.__HEROS_GAME__?.loop;
if (!loop || typeof loop.sleep !== 'function') {
return false;
}
loop.sleep();
return true;
});
await page.waitForTimeout(40);
try {
await page.screenshot({ path, fullPage: true });
} finally {
if (loopSlept) {
await page.evaluate(() =>
window.__HEROS_GAME__?.loop.wake()
);
await page.waitForTimeout(40);
}
}
}
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 || '41798',
'--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);
}
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;
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}