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

1160 lines
33 KiB
JavaScript

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__.goToCampVisitExploration();
});
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 };
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: 12000 }
);
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');
await page.keyboard.press('e');
await page.waitForFunction(
(key) => {
const state = window.__HEROS_DEBUG__
?.scene(key)
?.getDebugState?.();
return (
state?.dialogue?.active === true &&
state.dialogue.sourceNpcId === 'jian-yong'
);
},
sceneKey
);
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: 12000 }
);
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
);
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, ` +
'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 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));
}