Files
heros_web/scripts/verify-third-battle-priority-return-browser.mjs

1112 lines
31 KiB
JavaScript

import assert from 'node:assert/strict';
import { mkdirSync } from 'node:fs';
import { chromium } from 'playwright';
import { createServer } from 'vite';
import {
desktopBrowserContextOptions,
desktopBrowserDeviceScaleFactor,
desktopBrowserViewport
} from './desktop-browser-viewport.mjs';
const sourceBattleId = 'third-battle-guangzong-road';
const priorityBadgePattern = /우선/;
const resonanceChecklistLabel = '공명 대화';
const fixtures = [
{
id: 'canvas-fort-missed-quick-missed',
renderer: 'canvas',
expectedRendererType: 1,
fortAchieved: false,
quickAchieved: false,
sourceTurn: 34,
expectedVariant: 'fort-recovery',
expectedTargetDialogueId: 'liu-guan-after-guangzong-road'
},
{
id: 'canvas-fort-missed-quick-achieved',
renderer: 'canvas',
expectedRendererType: 1,
fortAchieved: false,
quickAchieved: true,
sourceTurn: 24,
expectedVariant: 'fort-recovery',
expectedTargetDialogueId: 'liu-guan-after-guangzong-road'
},
{
id: 'canvas-fort-achieved-quick-missed',
renderer: 'canvas',
expectedRendererType: 1,
fortAchieved: true,
quickAchieved: false,
sourceTurn: 34,
expectedVariant: 'tempo-recovery',
expectedTargetDialogueId: 'liu-zhang-after-guangzong-road'
},
{
id: 'canvas-fort-achieved-quick-achieved',
renderer: 'canvas',
expectedRendererType: 1,
fortAchieved: true,
quickAchieved: true,
sourceTurn: 24,
expectedVariant: 'decisive-advance',
expectedTargetDialogueId: 'guan-zhang-after-guangzong-road'
},
{
id: 'webgl-fort-achieved-quick-achieved',
renderer: 'webgl',
expectedRendererType: 2,
fortAchieved: true,
quickAchieved: true,
sourceTurn: 24,
expectedVariant: 'decisive-advance',
expectedTargetDialogueId: 'guan-zhang-after-guangzong-road'
}
];
const requestedRenderer =
cliOption('renderer') ??
process.env.VERIFY_THIRD_BATTLE_PRIORITY_RETURN_RENDERER ??
'both';
assert(
['canvas', 'webgl', 'both'].includes(requestedRenderer),
`Unknown renderer "${requestedRenderer}". Use canvas, webgl, or both.`
);
const selectedFixtures = fixtures.filter(
({ renderer }) =>
requestedRenderer === 'both' || renderer === requestedRenderer
);
const server = await createServer({
logLevel: 'error',
server: { host: '127.0.0.1', port: 0, hmr: false },
appType: 'spa'
});
let browser;
try {
mkdirSync('dist', { recursive: true });
await server.listen();
const address = server.httpServer?.address();
assert(
address && typeof address !== 'string',
'Expected a local Vite verification server.'
);
const baseUrl = `http://127.0.0.1:${address.port}/heros_web/`;
browser = await chromium.launch({
headless:
process.env.VERIFY_THIRD_BATTLE_PRIORITY_RETURN_HEADLESS !==
'0'
});
for (const fixture of selectedFixtures) {
await verifyFixture(browser, baseUrl, fixture);
}
console.log(
`Third-battle priority-return browser verification passed for ${selectedFixtures
.map(({ id }) => id)
.join(', ')} at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} CSS pixels, DPR ${desktopBrowserDeviceScaleFactor}.`
);
} finally {
await browser?.close();
await server.close();
}
async function verifyFixture(browser, baseUrl, fixture) {
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());
}
});
try {
const url = new URL(baseUrl);
url.searchParams.set('debug', '1');
url.searchParams.set('renderer', fixture.renderer);
await page.goto(url.toString(), {
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForDebugApi(page);
await assertFhdViewport(page, fixture);
const seeded = await seedThirdVictoryCamp(page, fixture);
assert.deepEqual(
seeded,
{
step: 'third-camp',
latestBattleId: sourceBattleId,
reportBattleId: sourceBattleId,
settlementOutcome: 'victory',
sourceTurn: fixture.sourceTurn,
fortAchieved: fixture.fortAchieved,
quickAchieved: fixture.quickAchieved,
dialogueCompleted: false,
dialogueChoiceId: null
},
`${fixture.id}: unexpected seeded campaign state.`
);
await page.evaluate(async () => {
await window.__HEROS_DEBUG__.goToCamp();
});
let camp = await waitForPriorityReturn(
page,
fixture,
false
);
assert.equal(camp.campaign.step, 'third-camp');
assert.equal(camp.campBattleId, sourceBattleId);
assertPriorityReturn(camp.thirdBattlePriorityReturn, fixture, {
completed: false,
requireRowBounds: false
});
const dialogueTab = camp.campTabs.find(
({ id }) => id === 'dialogue'
);
assert(dialogueTab, `${fixture.id}: dialogue tab is missing.`);
assert.equal(dialogueTab.interactive, true);
assert.equal(
dialogueTab.newBadgeVisible,
true,
`${fixture.id}: the dialogue tab must advertise the pending priority return.`
);
assert.match(
dialogueTab.newBadgeText ?? '',
priorityBadgePattern,
`${fixture.id}: the dialogue tab badge must identify the priority return.`
);
assertBoundsInsideViewport(
dialogueTab.bounds,
`${fixture.id} dialogue tab`
);
assert.equal(
resonanceChecklist(camp).complete,
false,
`${fixture.id}: resonance dialogue must remain incomplete before the priority return.`
);
await capture(
page,
`dist/verification-third-battle-priority-return-${fixture.id}-tab-badge.png`
);
await clickSceneBounds(page, 'CampScene', dialogueTab.bounds);
await page.waitForFunction(
() =>
window.__HEROS_DEBUG__?.camp?.()?.activeTab ===
'dialogue'
);
camp = await waitForPriorityReturn(page, fixture, false, true);
const priorityReturn = camp.thirdBattlePriorityReturn;
assertPriorityReturn(priorityReturn, fixture, {
completed: false,
requireRowBounds: true
});
assertBoundsInsideViewport(
priorityReturn.rowBounds,
`${fixture.id} priority dialogue row`
);
let badgeProbe = await readPriorityRowBadge(
page,
priorityReturn.rowBounds
);
assert.equal(
badgeProbe.markers.length,
1,
`${fixture.id}: expected exactly one visible priority marker in the target row: ${JSON.stringify(badgeProbe)}`
);
assert.match(
badgeProbe.markers[0].text,
priorityBadgePattern
);
const rowControl = await readDialogueControlPoint(
page,
'row',
fixture.expectedTargetDialogueId
);
await page.mouse.click(rowControl.x, rowControl.y);
await page.waitForFunction(
(dialogueId) =>
window.__HEROS_DEBUG__?.camp?.()?.selectedDialogue
?.id === dialogueId,
fixture.expectedTargetDialogueId
);
camp = await readCamp(page);
const selectedDialogue = camp.selectedDialogue;
assert.equal(
selectedDialogue.id,
fixture.expectedTargetDialogueId
);
assert.equal(
selectedDialogue.title,
priorityReturn.dialogueTitle
);
assert.deepEqual(
selectedDialogue.lines,
priorityReturn.dialogueLines
);
assert.equal(selectedDialogue.completed, false);
assert(
selectedDialogue.choices.length > 0,
`${fixture.id}: the priority dialogue must expose at least one completion choice.`
);
selectedDialogue.choices.forEach((choice) => {
assertBoundsInsideViewport(
choice.bounds,
`${fixture.id} choice ${choice.id}`
);
});
assertNoOverlappingBounds(
selectedDialogue.choices.map(({ id, bounds }) => ({
id,
bounds
})),
`${fixture.id} dialogue choices`
);
assertVariantNarrative(priorityReturn, fixture);
await capture(
page,
`dist/verification-third-battle-priority-return-${fixture.id}-pending-dialogue.png`
);
const selectedChoice = selectedDialogue.choices[0];
const choiceControl = await readDialogueControlPoint(
page,
'choice',
fixture.expectedTargetDialogueId,
selectedChoice.id
);
await page.mouse.click(choiceControl.x, choiceControl.y);
await page.waitForFunction(
({ dialogueId, choiceId }) => {
const camp = window.__HEROS_DEBUG__?.camp?.();
return (
camp?.thirdBattlePriorityReturn?.completed === true &&
camp.thirdBattlePriorityReturn.pending === false &&
camp?.selectedDialogue?.id === dialogueId &&
camp.selectedDialogue.completed === true &&
camp?.campaign?.campDialogueChoiceIds?.[dialogueId] ===
choiceId
);
},
{
dialogueId: fixture.expectedTargetDialogueId,
choiceId: selectedChoice.id
}
);
camp = await readCamp(page);
assertPriorityReturn(camp.thirdBattlePriorityReturn, fixture, {
completed: true,
requireRowBounds: true
});
assert.equal(
camp.selectedDialogue.id,
fixture.expectedTargetDialogueId
);
assert.equal(camp.selectedDialogue.completed, true);
assert.equal(
camp.campaign.completedCampDialogues.filter(
(id) => id === fixture.expectedTargetDialogueId
).length,
1,
`${fixture.id}: priority dialogue completion must be recorded exactly once.`
);
assert.equal(
camp.campaign.campDialogueChoiceIds[
fixture.expectedTargetDialogueId
],
selectedChoice.id
);
assert.equal(
camp.campTabs.find(({ id }) => id === 'dialogue')
?.newBadgeVisible,
false,
`${fixture.id}: the dialogue tab priority badge must clear after completion.`
);
assert.equal(
resonanceChecklist(camp).complete,
true,
`${fixture.id}: completing the one priority return must satisfy the resonance checklist.`
);
badgeProbe = await readPriorityRowBadge(
page,
camp.thirdBattlePriorityReturn.rowBounds
);
assert.equal(
badgeProbe.markers.length,
0,
`${fixture.id}: the row priority badge must disappear after completion: ${JSON.stringify(badgeProbe)}`
);
assertPersistedDialogue(
await readCampaignSave(page),
fixture.expectedTargetDialogueId,
selectedChoice.id,
`${fixture.id} completed`
);
await capture(
page,
`dist/verification-third-battle-priority-return-${fixture.id}-completed.png`
);
await page.reload({
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForDebugApi(page);
await assertFhdViewport(page, fixture);
await page.evaluate(async () => {
await window.__HEROS_DEBUG__.goToCamp();
});
camp = await waitForPriorityReturn(page, fixture, true);
assertPriorityReturn(camp.thirdBattlePriorityReturn, fixture, {
completed: true,
requireRowBounds: false
});
assert.equal(
camp.campTabs.find(({ id }) => id === 'dialogue')
?.newBadgeVisible,
false,
`${fixture.id}: the dialogue tab badge must remain cleared after Camp re-entry.`
);
assert.equal(
resonanceChecklist(camp).complete,
true,
`${fixture.id}: the resonance checklist must remain complete after Camp re-entry.`
);
assertPersistedDialogue(
await readCampaignSave(page),
fixture.expectedTargetDialogueId,
selectedChoice.id,
`${fixture.id} re-entered`
);
const reenteredDialogueTab = camp.campTabs.find(
({ id }) => id === 'dialogue'
);
await clickSceneBounds(
page,
'CampScene',
reenteredDialogueTab.bounds
);
camp = await waitForPriorityReturn(page, fixture, true, true);
const reenteredRow = await readDialogueControlPoint(
page,
'row',
fixture.expectedTargetDialogueId
);
await page.mouse.click(reenteredRow.x, reenteredRow.y);
await page.waitForFunction(
(dialogueId) => {
const selected =
window.__HEROS_DEBUG__?.camp?.()?.selectedDialogue;
return (
selected?.id === dialogueId &&
selected.completed === true
);
},
fixture.expectedTargetDialogueId
);
camp = await readCamp(page);
assert.equal(camp.selectedDialogue.completed, true);
badgeProbe = await readPriorityRowBadge(
page,
camp.thirdBattlePriorityReturn.rowBounds
);
assert.equal(
badgeProbe.markers.length,
0,
`${fixture.id}: the priority marker must stay absent after Camp re-entry.`
);
await capture(
page,
`dist/verification-third-battle-priority-return-${fixture.id}-reentered.png`
);
assert.deepEqual(
pageErrors,
[],
`${fixture.id}: expected no browser page errors: ${JSON.stringify(pageErrors)}`
);
assert.deepEqual(
consoleErrors.filter(
(message) =>
!message.includes(
'The AudioContext was not allowed to start'
)
),
[],
`${fixture.id}: expected no browser console errors: ${JSON.stringify(consoleErrors)}`
);
} finally {
await context.close();
}
}
async function seedThirdVictoryCamp(page, fixture) {
return page.evaluate(
async ({
battleId,
expectedDialogueId,
fortAchieved,
quickAchieved,
sourceTurn
}) => {
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}.`);
}
const objectives = scenario.objectives.map(
(objective) => {
const achieved =
objective.id === 'fort'
? fortAchieved
: objective.id === 'quick'
? quickAchieved
: true;
return {
id: objective.id,
label: objective.label,
achieved,
status: achieved ? 'done' : 'failed',
detail: achieved ? '달성' : '미달성',
category:
objective.id === 'fort' ||
objective.id === 'quick'
? 'bonus'
: 'required',
rewardGold: objective.rewardGold
};
}
);
campaignModule.resetCampaignState();
campaignModule.setFirstBattleReport({
battleId: scenario.id,
battleTitle: scenario.title,
outcome: 'victory',
turnNumber: sourceTurn,
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'
});
let campaign = campaignModule.getCampaignState();
campaign.dismissedVictoryRewardNoticeBattleIds = [
...new Set([
...campaign.dismissedVictoryRewardNoticeBattleIds,
battleId
])
];
campaignModule.setCampaignState(campaign);
campaignModule.completeCampaignAftermath(scenario.id);
campaign = campaignModule.getCampaignState();
const settlement =
campaign.battleHistory[battleId];
return {
step: campaign.step,
latestBattleId: campaign.latestBattleId ?? null,
reportBattleId:
campaign.firstBattleReport?.battleId ?? null,
settlementOutcome: settlement?.outcome ?? null,
sourceTurn: settlement?.turnNumber ?? null,
fortAchieved:
settlement?.objectives.find(
({ id }) => id === 'fort'
)?.achieved ?? null,
quickAchieved:
settlement?.objectives.find(
({ id }) => id === 'quick'
)?.achieved ?? null,
dialogueCompleted:
campaign.completedCampDialogues.includes(
expectedDialogueId
),
dialogueChoiceId:
campaign.campDialogueChoiceIds[
expectedDialogueId
] ?? null
};
},
{
battleId: sourceBattleId,
expectedDialogueId: fixture.expectedTargetDialogueId,
fortAchieved: fixture.fortAchieved,
quickAchieved: fixture.quickAchieved,
sourceTurn: fixture.sourceTurn
}
);
}
async function waitForDebugApi(page) {
await page.waitForFunction(
() =>
document.querySelector('canvas') !== null &&
window.__HEROS_GAME__ !== undefined &&
window.__HEROS_DEBUG__ !== undefined,
undefined,
{ timeout: 90000 }
);
}
async function waitForPriorityReturn(
page,
fixture,
completed,
requireRowBounds = false
) {
try {
await page.waitForFunction(
({
battleId,
expectedVariant,
expectedTargetDialogueId,
fortAchieved,
quickAchieved,
sourceTurn,
completed,
requireRowBounds
}) => {
const debug = window.__HEROS_DEBUG__;
const camp = debug?.camp?.();
const priority = camp?.thirdBattlePriorityReturn;
return (
debug?.activeScenes?.().includes('CampScene') &&
camp?.scene === 'CampScene' &&
camp?.campaign?.step === 'third-camp' &&
camp?.campBattleId === battleId &&
camp?.campTabs?.some(({ id }) => id === 'dialogue') &&
priority?.available === true &&
priority.sourceBattleId === battleId &&
priority.sourceTurn === sourceTurn &&
priority.fortAchieved === fortAchieved &&
priority.quickAchieved === quickAchieved &&
priority.variant === expectedVariant &&
priority.targetDialogueId === expectedTargetDialogueId &&
priority.completed === completed &&
priority.pending === !completed &&
(!requireRowBounds || Boolean(priority.rowBounds))
);
},
{
battleId: sourceBattleId,
expectedVariant: fixture.expectedVariant,
expectedTargetDialogueId:
fixture.expectedTargetDialogueId,
fortAchieved: fixture.fortAchieved,
quickAchieved: fixture.quickAchieved,
sourceTurn: fixture.sourceTurn,
completed,
requireRowBounds
},
{ timeout: 90000 }
);
} catch (error) {
const diagnostic = await page.evaluate(() => ({
activeScenes:
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
camp: window.__HEROS_DEBUG__?.camp?.() ?? null
}));
throw new Error(
`${fixture.id}: CampScene did not expose the expected thirdBattlePriorityReturn contract: ${JSON.stringify(diagnostic)}`,
{ cause: error }
);
}
return readCamp(page);
}
function assertPriorityReturn(
priority,
fixture,
{ completed, requireRowBounds }
) {
assert(
priority,
`${fixture.id}: thirdBattlePriorityReturn is missing.`
);
assert.equal(priority.available, true);
assert.equal(priority.sourceBattleId, sourceBattleId);
assert.equal(priority.sourceTurn, fixture.sourceTurn);
assert.equal(
priority.fortAchieved,
fixture.fortAchieved
);
assert.equal(
priority.quickAchieved,
fixture.quickAchieved
);
assert.equal(priority.variant, fixture.expectedVariant);
assert.equal(
priority.targetDialogueId,
fixture.expectedTargetDialogueId
);
assert.equal(typeof priority.dialogueTitle, 'string');
assert(priority.dialogueTitle.length > 0);
assert.equal(priority.dialogueLines.length, 3);
assert(
priority.dialogueLines.every(
(line) => typeof line === 'string' && line.length > 0
)
);
assert.equal(priority.completed, completed);
assert.equal(priority.pending, !completed);
if (requireRowBounds) {
assertFiniteBounds(
priority.rowBounds,
`${fixture.id} priority row`
);
}
}
function assertVariantNarrative(priority, fixture) {
const text = [
priority.dialogueTitle,
...priority.dialogueLines
].join('\n');
if (!fixture.fortAchieved) {
assert.match(
text,
/요새/,
`${fixture.id}: fort-recovery dialogue must discuss the fort.`
);
assert.match(
text,
/확보하지 못|비워 둔|남겼/,
`${fixture.id}: fort-recovery dialogue must not praise a missed fort objective.`
);
return;
}
if (!fixture.quickAchieved) {
assert.match(
text,
/늦|때를 놓쳤|제한 초과/,
`${fixture.id}: tempo-recovery dialogue must acknowledge the missed quick objective.`
);
return;
}
assert.match(
text,
/확보|길이 열렸/,
`${fixture.id}: decisive-advance dialogue must acknowledge the achieved fort objective.`
);
assert.match(
text,
new RegExp(`${fixture.sourceTurn}`),
`${fixture.id}: decisive-advance dialogue must retain the source turn.`
);
}
function resonanceChecklist(camp) {
const item = camp?.sortieChecklist?.find(
({ label }) => label === resonanceChecklistLabel
);
assert(
item,
`CampScene must expose a "${resonanceChecklistLabel}" checklist item: ${JSON.stringify(camp?.sortieChecklist)}`
);
return item;
}
async function readPriorityRowBadge(page, rowBounds) {
return page.evaluate((requestedRowBounds) => {
const scene =
window.__HEROS_GAME__?.scene.getScene('CampScene');
const objectBounds = (object) => {
const bounds = object?.getBounds?.();
return bounds
? {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height
}
: null;
};
const intersects = (left, right) =>
Boolean(
left &&
right &&
left.x < right.x + right.width &&
left.x + left.width > right.x &&
left.y < right.y + right.height &&
left.y + left.height > right.y
);
const markers = (scene?.contentObjects ?? [])
.filter(
(object) =>
object?.type === 'Text' &&
object.active &&
object.visible &&
typeof object.text === 'string' &&
object.text.includes('우선')
)
.map((object) => ({
text: object.text,
bounds: objectBounds(object)
}))
.filter(({ bounds }) =>
intersects(bounds, requestedRowBounds)
);
return { rowBounds: requestedRowBounds, markers };
}, rowBounds);
}
async function readDialogueControlPoint(
page,
control,
dialogueId,
choiceId
) {
const probe = await page.evaluate(
({ control, dialogueId, choiceId }) => {
const scene =
window.__HEROS_GAME__?.scene.getScene('CampScene');
const state = window.__HEROS_DEBUG__?.camp?.();
const canvas = document.querySelector('canvas');
const priority = state?.thirdBattlePriorityReturn;
const dialogue =
state?.selectedDialogue?.id === dialogueId
? state.selectedDialogue
: null;
const choice = dialogue?.choices?.find(
({ id }) => id === choiceId
);
const bounds =
control === 'row'
? priority?.targetDialogueId === dialogueId
? priority.rowBounds
: null
: choice?.bounds;
const interactiveObject =
control === 'row'
? scene?.campDialogueRowButtons?.[dialogueId]
: scene?.campDialogueChoiceButtons?.[
`${dialogueId}:${choiceId}`
];
if (
!scene ||
!canvas ||
!bounds ||
!interactiveObject?.input?.enabled
) {
return {
ready: false,
control,
dialogueId,
choiceId: choiceId ?? null,
bounds: bounds ?? null,
priority: priority ?? null,
selectedDialogue: state?.selectedDialogue ?? null
};
}
const canvasBounds = canvas.getBoundingClientRect();
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
return {
ready: true,
control,
dialogueId,
choiceId: choiceId ?? null,
bounds,
x:
canvasBounds.left +
(centerX * canvasBounds.width) /
scene.scale.width,
y:
canvasBounds.top +
(centerY * canvasBounds.height) /
scene.scale.height
};
},
{ control, dialogueId, choiceId }
);
assert(
probe.ready === true &&
Number.isFinite(probe.x) &&
Number.isFinite(probe.y),
`Expected an interactive ${control} for ${dialogueId}: ${JSON.stringify(probe)}`
);
assertBoundsInsideViewport(
probe.bounds,
`${dialogueId} ${control}`
);
return probe;
}
async function clickSceneBounds(page, sceneKey, bounds) {
assertFiniteBounds(bounds, `${sceneKey} click target`);
const point = await page.evaluate(
({ sceneKey, bounds }) => {
const scene =
window.__HEROS_GAME__?.scene.getScene(sceneKey);
const canvas = document.querySelector('canvas');
const canvasBounds = canvas?.getBoundingClientRect();
if (!scene || !canvasBounds) {
return null;
}
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
return {
x:
canvasBounds.left +
(centerX * canvasBounds.width) /
scene.scale.width,
y:
canvasBounds.top +
(centerY * canvasBounds.height) /
scene.scale.height
};
},
{ sceneKey, bounds }
);
assert(
point &&
Number.isFinite(point.x) &&
Number.isFinite(point.y),
`Unable to map ${sceneKey} bounds to a browser point: ${JSON.stringify(bounds)}`
);
await page.mouse.click(point.x, point.y);
}
async function readCamp(page) {
return page.evaluate(() =>
window.__HEROS_DEBUG__?.camp?.()
);
}
async function readCampaignSave(page) {
return page.evaluate(() => {
const raw = window.localStorage.getItem(
'heros-web:campaign-state'
);
return raw ? JSON.parse(raw) : null;
});
}
function assertPersistedDialogue(
save,
targetDialogueId,
choiceId,
label
) {
assert(save, `${label}: current campaign save is missing.`);
assert.equal(save.step, 'third-camp');
assert.equal(save.latestBattleId, sourceBattleId);
assert.equal(
save.completedCampDialogues.filter(
(id) => id === targetDialogueId
).length,
1,
`${label}: the priority dialogue completion must persist exactly once.`
);
assert.equal(
save.firstBattleReport?.completedCampDialogues?.filter(
(id) => id === targetDialogueId
).length,
1,
`${label}: the report completion must persist exactly once.`
);
assert.equal(
save.campDialogueChoiceIds[targetDialogueId],
choiceId,
`${label}: the selected priority response must persist.`
);
}
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) {
assertFiniteBounds(bounds, label);
const epsilon = 0.01;
assert(
bounds.x >= -epsilon &&
bounds.y >= -epsilon &&
bounds.x + bounds.width <=
desktopBrowserViewport.width + epsilon &&
bounds.y + bounds.height <=
desktopBrowserViewport.height + epsilon,
`${label}: expected bounds inside the 1920x1080 viewport, received ${JSON.stringify(bounds)}`
);
}
function assertFiniteBounds(bounds, label) {
assert(bounds, `${label}: bounds are required.`);
assert(
['x', 'y', 'width', 'height'].every((key) =>
Number.isFinite(bounds[key])
) &&
bounds.width > 0 &&
bounds.height > 0,
`${label}: expected positive finite bounds, received ${JSON.stringify(bounds)}`
);
}
async function assertFhdViewport(page, fixture) {
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,
rendererType:
window.__HEROS_GAME__?.renderer?.type ?? null,
canvas: canvas
? {
width: canvas.width,
height: canvas.height,
clientWidth: canvas.clientWidth,
clientHeight: canvas.clientHeight,
bounds: bounds
? {
x: bounds.x,
y: bounds.y,
width: bounds.width,
height: bounds.height
}
: null
}
: null
};
});
assert.equal(viewport.width, desktopBrowserViewport.width);
assert.equal(
viewport.height,
desktopBrowserViewport.height
);
assert.equal(
viewport.dpr,
desktopBrowserDeviceScaleFactor
);
assert.equal(viewport.visualScale, 1);
assert.equal(
viewport.rendererType,
fixture.expectedRendererType,
`${fixture.id}: Phaser did not use the requested renderer.`
);
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 capture(page, path) {
await page.waitForTimeout(250);
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);
}
}
}
function cliOption(name) {
const equalsPrefix = `--${name}=`;
const equalsOption = process.argv.find((argument) =>
argument.startsWith(equalsPrefix)
);
if (equalsOption) {
return equalsOption.slice(equalsPrefix.length);
}
const index = process.argv.indexOf(`--${name}`);
return index >= 0 ? process.argv[index + 1] : undefined;
}