Files
heros_web/scripts/verify-city-stay-browser.mjs

850 lines
30 KiB
JavaScript

import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { chromium } from 'playwright';
import {
desktopBrowserContextOptions,
desktopBrowserDeviceScaleFactor,
desktopBrowserViewport
} from './desktop-browser-viewport.mjs';
const xuzhouBattleId = 'seventh-battle-xuzhou-rescue';
const xiaopeiBattleId = 'eighth-battle-xiaopei-supply-road';
const xuzhouCityStayId = 'xuzhou';
const seedGold = 5000;
const additionalCityVisualCases = [
{
cityStayId: 'xinye',
cityName: '신야',
afterBattleId: 'seventeenth-battle-wolong-visit-road',
nextBattleId: 'eighteenth-battle-bowang-ambush',
battleTitle: '와룡 방문로',
campStep: 'seventeenth-camp',
partnerId: 'zhuge-liang',
partnerName: '제갈량',
bondId: 'liu-bei__zhuge-liang',
bondTitle: '삼고초려',
screenshotPath: 'dist/verification-city-xinye-information.png'
},
{
cityStayId: 'chengdu',
cityName: '성도',
afterBattleId: 'thirty-third-battle-chengdu-surrender',
nextBattleId: 'thirty-fourth-battle-jiameng-pass',
battleTitle: '성도 항복',
campStep: 'thirty-third-camp',
partnerId: 'huang-quan',
partnerName: '황권',
bondId: 'liu-bei__huang-quan',
bondTitle: '익주의 신뢰',
screenshotPath: 'dist/verification-city-chengdu-information.png'
}
];
const targetUrl = withDebugOptions(
process.env.VERIFY_CITY_STAY_URL ?? 'http://127.0.0.1:41795/'
);
let serverProcess;
let browser;
try {
serverProcess = await ensureLocalServer(targetUrl);
browser = await chromium.launch({
headless: process.env.VERIFY_CITY_STAY_HEADLESS !== '0'
});
const context = await browser.newContext(desktopBrowserContextOptions);
const page = await context.newPage();
page.setDefaultTimeout(30000);
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message));
await openCleanCamp(page);
await assertDesktopViewport(page);
await seedXuzhouCityStay(page);
await restartCamp(page);
const initialState = await waitForXuzhouCityStay(page);
verifyCityStayLayout(initialState);
await captureStableScreenshot(page, 'dist/verification-city-xuzhou-information.png');
assert.equal(
initialState.victoryRewardAcknowledgement.visible,
false,
'The seeded victory-reward notice must not obstruct the city-stay flow.'
);
const informationResult = await verifyInformationReward(page, initialState);
const marketResult = await verifyMarketPurchase(page);
const dialogueResult = await verifyCompanionDialogue(page);
await restartCamp(page);
const restoredState = await waitForXuzhouCityStay(page);
verifyRestoredOutcomes(restoredState, informationResult, marketResult, dialogueResult);
await verifyNextBattleBriefing(page, restoredState.cityStay.information.briefingLines);
await verifyAdditionalCityLayouts(page);
assert.deepEqual(
pageErrors,
[],
`Expected zero browser page errors, received: ${JSON.stringify(pageErrors.slice(-5))}`
);
console.log(
`Verified the Xuzhou, Xinye, and Chengdu city stays at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` +
`DPR ${desktopBrowserDeviceScaleFactor}: seven non-overlapping tabs and an in-viewport city panel; ` +
'idempotent information rewards; exact market gold and inventory changes; saved companion choice; ' +
'all outcomes surviving a full browser reload; completed city information appearing in each next battle briefing; ' +
'and information, market, and resonance layouts remaining readable at the desktop baseline.'
);
} 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', 'canvas');
return parsed.toString();
}
async function openCleanCamp(page) {
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 page.evaluate(() => window.__HEROS_DEBUG__?.goToCamp());
await page.waitForFunction(() => {
const camp = window.__HEROS_DEBUG__?.camp();
return (
window.__HEROS_DEBUG__?.activeScenes().includes('CampScene') &&
camp?.scene === 'CampScene' &&
camp?.report?.battleId
);
}, undefined, { timeout: 90000 });
}
async function seedXuzhouCityStay(page) {
const result = await page.evaluate(({ battleId, battleTitle, gold }) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
if (!scene?.campaign || !scene.report || typeof scene.persistSortieSelection !== 'function') {
return { saved: false, reason: 'CampScene campaign/report/persistSortieSelection is unavailable.' };
}
const now = new Date().toISOString();
const templateUnit = scene.campaign.roster[0] ?? scene.report.units[0];
const liuBei = templateUnit
? {
...structuredClone(templateUnit),
id: 'liu-bei',
name: '유비'
}
: null;
const xuzhouBond = {
id: 'liu-bei__mi-zhu',
unitIds: ['liu-bei', 'mi-zhu'],
title: '서주 후원',
level: 42,
exp: 0,
battleExp: 0,
description: '미축은 서주의 민심과 군량을 묶어 유비군이 오래 버틸 수 있게 돕는다.'
};
const report = {
...scene.report,
battleId,
battleTitle,
outcome: 'victory',
turnNumber: Math.max(1, Number(scene.report.turnNumber) || 1),
rewardGold: 0,
itemRewards: [],
objectives: [],
units: liuBei ? [liuBei] : [],
bonds: [xuzhouBond],
completedCampDialogues: [],
completedCampVisits: [],
createdAt: now
};
delete report.campaignRewards;
scene.campaign.step = 'seventh-camp';
scene.campaign.gold = gold;
scene.campaign.latestBattleId = battleId;
scene.campaign.roster = liuBei ? [liuBei] : [];
scene.campaign.bonds = [xuzhouBond];
scene.campaign.firstBattleReport = report;
scene.campaign.battleHistory = {
[battleId]: {
battleId,
battleTitle,
outcome: 'victory',
turnNumber: report.turnNumber,
rewardGold: 0,
itemRewards: [],
objectives: [],
units: [],
bonds: [],
completedAt: now
}
};
scene.campaign.completedCampDialogues = [];
scene.campaign.completedCampVisits = [];
scene.campaign.campDialogueChoiceIds = {};
scene.campaign.campVisitChoiceIds = {};
scene.campaign.acknowledgedVictoryRewardBattleIds = [battleId];
scene.campaign.acknowledgedVictoryRewardCategories = {};
scene.campaign.dismissedVictoryRewardNoticeBattleIds = [battleId];
delete scene.campaign.pendingAftermathBattleId;
scene.report = report;
scene.persistSortieSelection();
return {
saved: true,
latestBattleId: scene.campaign.latestBattleId,
reportBattleId: scene.campaign.firstBattleReport?.battleId,
gold: scene.campaign.gold
};
}, {
battleId: xuzhouBattleId,
battleTitle: '서주 구원전',
gold: seedGold
});
assert.deepEqual(
result,
{
saved: true,
latestBattleId: xuzhouBattleId,
reportBattleId: xuzhouBattleId,
gold: seedGold
},
`Failed to persist the Xuzhou seed through CampScene.persistSortieSelection(): ${JSON.stringify(result)}`
);
}
async function restartCamp(page, expectedCityStayId = xuzhouCityStayId) {
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
await waitForDebugApi(page);
await page.evaluate(() => window.__HEROS_DEBUG__?.goToCamp());
await page.waitForFunction((expectedId) => {
const camp = window.__HEROS_DEBUG__?.camp();
return (
window.__HEROS_DEBUG__?.activeScenes().includes('CampScene') &&
camp?.scene === 'CampScene' &&
camp?.cityStay?.id === expectedId &&
camp?.cityStay?.active === true
);
}, expectedCityStayId, { timeout: 90000 });
}
async function verifyAdditionalCityLayouts(page) {
for (const visualCase of additionalCityVisualCases) {
await openCampAboveBattle(page);
const seedResult = await seedCityStayPreview(page, { ...visualCase, gold: seedGold });
assert.deepEqual(
seedResult,
{
saved: true,
latestBattleId: visualCase.afterBattleId,
reportBattleId: visualCase.afterBattleId,
cityStayId: visualCase.cityStayId
},
`Failed to persist the ${visualCase.cityName} city-stay preview: ${JSON.stringify(seedResult)}`
);
await restartCamp(page, visualCase.cityStayId);
const state = await waitForCityStay(page, visualCase.cityStayId);
verifyCityStayLayout(state);
assert.equal(state.cityStay.name, visualCase.cityName);
assert.equal(state.cityStay.information.completed, false);
assert.equal(state.cityStay.dialogue.completed, false);
await captureStableScreenshot(page, visualCase.screenshotPath);
await selectCityLocation(page, 'market');
const marketState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
assert.equal(marketState.cityStay.market.offers.length, 3);
marketState.cityStay.market.offers.forEach((offer) => {
assert(offer.buyBounds && offer.interactive && offer.canBuy);
assertBoundsInside(
offer.buyBounds,
marketState.cityStay.panelBounds,
`${visualCase.cityName} market offer "${offer.id}" must remain inside the city panel.`
);
});
assertNoOverlappingBounds(
marketState.cityStay.market.offers.map((offer) => ({ id: offer.id, bounds: offer.buyBounds })),
`${visualCase.cityName} market offer buttons`
);
await captureStableScreenshot(
page,
visualCase.screenshotPath.replace('-information.png', '-market.png')
);
await selectCityLocation(page, 'dialogue');
const dialogueState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
assert.equal(dialogueState.cityStay.dialogue.choices.length, 2);
dialogueState.cityStay.dialogue.choices.forEach((choice) => {
assert(choice.bounds && choice.interactive);
assertBoundsInside(
choice.bounds,
dialogueState.cityStay.panelBounds,
`${visualCase.cityName} resonance choice "${choice.id}" must remain inside the city panel.`
);
});
assertNoOverlappingBounds(
dialogueState.cityStay.dialogue.choices.map((choice) => ({ id: choice.id, bounds: choice.bounds })),
`${visualCase.cityName} resonance choice buttons`
);
await captureStableScreenshot(
page,
visualCase.screenshotPath.replace('-information.png', '-dialogue.png')
);
await selectCityLocation(page, 'information');
const functionalInitialState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const informationResult = await verifyInformationReward(page, functionalInitialState);
const marketResult = await verifyMarketPurchase(page, null);
const dialogueResult = await verifyCompanionDialogue(page, null);
await restartCamp(page, visualCase.cityStayId);
const restoredState = await waitForCityStay(page, visualCase.cityStayId);
verifyRestoredOutcomes(restoredState, informationResult, marketResult, dialogueResult);
await verifyNextBattleBriefing(
page,
restoredState.cityStay.information.briefingLines,
visualCase.nextBattleId,
visualCase.cityName
);
}
}
async function openCampAboveBattle(page) {
await page.evaluate(async () => {
const game = window.__HEROS_GAME__;
if (!game || !window.__HEROS_DEBUG__) {
throw new Error('The game debug API is unavailable while opening CampScene.');
}
if (game.scene.isActive('BattleScene')) {
game.scene.stop('BattleScene');
}
await window.__HEROS_DEBUG__.goToCamp();
game.scene.bringToTop('CampScene');
});
await page.waitForFunction(() => (
!window.__HEROS_GAME__?.scene.isActive('BattleScene') &&
window.__HEROS_DEBUG__?.activeScenes().includes('CampScene') &&
window.__HEROS_DEBUG__?.camp()?.scene === 'CampScene'
), undefined, { timeout: 90000 });
}
async function seedCityStayPreview(page, visualCase) {
return page.evaluate((config) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
if (!scene?.campaign || !scene.report || typeof scene.persistSortieSelection !== 'function') {
return { saved: false, reason: 'CampScene campaign/report/persistSortieSelection is unavailable.' };
}
const now = new Date().toISOString();
const templateUnit = scene.campaign.roster[0] ?? scene.report.units[0];
if (!templateUnit) {
return { saved: false, reason: 'No unit template is available for the city preview.' };
}
const liuBei = {
...structuredClone(templateUnit),
id: 'liu-bei',
name: '유비'
};
const partner = {
...structuredClone(templateUnit),
id: config.partnerId,
name: config.partnerName
};
const bond = {
id: config.bondId,
unitIds: ['liu-bei', config.partnerId],
title: config.bondTitle,
level: 42,
exp: 0,
battleExp: 0,
description: `${config.cityName} 체류 중 함께 다음 전투를 준비하며 쌓은 공명입니다.`
};
const report = {
...scene.report,
battleId: config.afterBattleId,
battleTitle: config.battleTitle,
outcome: 'victory',
turnNumber: Math.max(1, Number(scene.report.turnNumber) || 1),
rewardGold: 0,
itemRewards: [],
objectives: [],
units: [liuBei, partner],
bonds: [bond],
completedCampDialogues: [],
completedCampVisits: [],
createdAt: now
};
delete report.campaignRewards;
scene.campaign.step = config.campStep;
scene.campaign.gold = config.gold;
scene.campaign.latestBattleId = config.afterBattleId;
scene.campaign.roster = [liuBei, partner];
scene.campaign.bonds = [bond];
scene.campaign.firstBattleReport = report;
scene.campaign.battleHistory = {
[config.afterBattleId]: {
battleId: config.afterBattleId,
battleTitle: config.battleTitle,
outcome: 'victory',
turnNumber: report.turnNumber,
rewardGold: 0,
itemRewards: [],
objectives: [],
units: [],
bonds: [],
completedAt: now
}
};
scene.campaign.completedCampDialogues = [];
scene.campaign.completedCampVisits = [];
scene.campaign.campDialogueChoiceIds = {};
scene.campaign.campVisitChoiceIds = {};
scene.campaign.acknowledgedVictoryRewardBattleIds = [config.afterBattleId];
scene.campaign.acknowledgedVictoryRewardCategories = {};
scene.campaign.dismissedVictoryRewardNoticeBattleIds = [config.afterBattleId];
delete scene.campaign.pendingAftermathBattleId;
scene.report = report;
scene.persistSortieSelection();
const cityStayId = scene.currentCityStay?.()?.id ?? null;
return {
saved: true,
latestBattleId: scene.campaign.latestBattleId,
reportBattleId: scene.campaign.firstBattleReport?.battleId,
cityStayId
};
}, visualCase);
}
async function waitForCityStay(page, expectedId) {
await page.waitForFunction((cityStayId) => {
const camp = window.__HEROS_DEBUG__?.camp();
return (
camp?.scene === 'CampScene' &&
camp?.cityStay?.id === cityStayId &&
camp?.cityStay?.active === true &&
camp?.cityStay?.panelBounds &&
camp?.campTabs?.length === 7
);
}, expectedId, { timeout: 90000 });
return page.evaluate(() => window.__HEROS_DEBUG__?.camp());
}
async function waitForXuzhouCityStay(page) {
return waitForCityStay(page, xuzhouCityStayId);
}
function verifyCityStayLayout(state) {
assert.equal(state.activeTab, 'city', 'The city tab should be active when the Xuzhou stay opens.');
assert.equal(state.campTabs.length, 7, 'A city stay must expose exactly seven CampScene tabs.');
assert(
state.campTabs.every((tab) => tab.bounds && tab.interactive),
`Every city-stay tab must be visible and interactive: ${JSON.stringify(state.campTabs)}`
);
assertNoOverlappingBounds(
state.campTabs.map((tab) => ({ id: tab.id, bounds: tab.bounds })),
'CampScene tabs'
);
const sceneBounds = state.campSkin?.sceneBounds ?? {
x: 0,
y: 0,
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height
};
assertBoundsInside(
state.cityStay.panelBounds,
sceneBounds,
`The Xuzhou city panel must remain inside the ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} scene.`
);
state.campTabs.forEach((tab) => {
assertBoundsInside(tab.bounds, sceneBounds, `CampScene tab "${tab.id}" must remain inside the scene.`);
});
}
async function verifyInformationReward(page, initialState) {
const information = initialState.cityStay.information;
assert.equal(information.completed, false, 'Xuzhou information should initially be unclaimed.');
assert(information.actionInteractive && information.actionBounds, 'The information action must be clickable.');
const reward = parseInventoryReward(information.itemReward);
const beforeCount = initialState.campaign.inventory[reward.label] ?? 0;
await clickSceneBounds(page, 'CampScene', information.actionBounds);
await page.waitForFunction(({ visitId, label, expectedCount }) => {
const camp = window.__HEROS_DEBUG__?.camp();
return (
camp?.cityStay?.information?.id === visitId &&
camp.cityStay.information.completed === true &&
(camp.campaign?.inventory?.[label] ?? 0) === expectedCount
);
}, {
visitId: information.id,
label: reward.label,
expectedCount: beforeCount + reward.amount
});
const afterFirstClaim = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const cityStay = scene?.currentCityStay?.();
if (!scene || !cityStay) {
throw new Error('Xuzhou city stay disappeared before the idempotency probe.');
}
scene.gatherCityInformation(cityStay);
});
await page.waitForTimeout(100);
const afterRepeatedClaim = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
assert.equal(
afterRepeatedClaim.campaign.inventory[reward.label] ?? 0,
afterFirstClaim.campaign.inventory[reward.label] ?? 0,
'Calling the completed information action again must not duplicate its inventory reward.'
);
assert.equal(
afterRepeatedClaim.campaign.completedCampVisits.filter((id) => id === information.id).length,
1,
'The information completion record must remain unique.'
);
return {
id: information.id,
inventoryLabel: reward.label,
inventoryCount: afterRepeatedClaim.campaign.inventory[reward.label] ?? 0
};
}
async function verifyMarketPurchase(page, screenshotPath = 'dist/verification-city-xuzhou-market.png') {
await selectCityLocation(page, 'market');
const before = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (screenshotPath) {
await captureStableScreenshot(page, screenshotPath);
}
const offer = before.cityStay.market.offers[0];
assert(offer?.buyBounds && offer.interactive && offer.canBuy, 'The first Xuzhou market offer must be affordable and clickable.');
const goldBefore = before.cityStay.market.gold;
const ownedBefore = offer.owned;
await clickSceneBounds(page, 'CampScene', offer.buyBounds);
await page.waitForFunction(({ offerId, expectedGold, expectedOwned }) => {
const market = window.__HEROS_DEBUG__?.camp()?.cityStay?.market;
const currentOffer = market?.offers?.find((candidate) => candidate.id === offerId);
return market?.gold === expectedGold && currentOffer?.owned === expectedOwned;
}, {
offerId: offer.id,
expectedGold: goldBefore - offer.price,
expectedOwned: ownedBefore + 1
});
const after = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const purchasedOffer = after.cityStay.market.offers.find((candidate) => candidate.id === offer.id);
assert.equal(after.cityStay.market.gold, goldBefore - offer.price, 'The market must deduct the exact listed price.');
assert.equal(purchasedOffer.owned, ownedBefore + 1, 'The purchased equipment inventory must increase by exactly one.');
return {
offerId: offer.id,
itemId: offer.itemId,
itemName: offer.itemName,
gold: after.cityStay.market.gold,
owned: purchasedOffer.owned
};
}
async function verifyCompanionDialogue(page, screenshotPath = 'dist/verification-city-xuzhou-dialogue.png') {
await selectCityLocation(page, 'dialogue');
const before = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
if (screenshotPath) {
await captureStableScreenshot(page, screenshotPath);
}
const choice = before.cityStay.dialogue.choices[0];
assert(choice?.bounds && choice.interactive, 'The first Xuzhou companion choice must be clickable.');
await clickSceneBounds(page, 'CampScene', choice.bounds);
await page.waitForTimeout(150);
const after = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
assert(
after?.cityStay?.dialogue?.id === before.cityStay.dialogue.id &&
after.cityStay.dialogue.completed === true &&
after.cityStay.dialogue.choiceId === choice.id,
`The companion choice did not complete synchronously: ${JSON.stringify({
before: before.cityStay.dialogue,
after: after?.cityStay?.dialogue,
campaignBonds: after?.report?.bonds
})}`
);
assert.equal(
after.campaign.completedCampDialogues.filter((id) => id === before.cityStay.dialogue.id).length,
1,
'The companion dialogue completion record must be unique.'
);
assert.equal(
after.campaign.campDialogueChoiceIds[before.cityStay.dialogue.id],
choice.id,
'The selected companion response must be saved by choice ID.'
);
return {
id: before.cityStay.dialogue.id,
choiceId: choice.id
};
}
async function selectCityLocation(page, locationId) {
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
const location = state.cityStay.locations.find((candidate) => candidate.id === locationId);
assert(location?.bounds && location.interactive, `City location "${locationId}" must be clickable.`);
await clickSceneBounds(page, 'CampScene', location.bounds);
await page.waitForFunction((expectedId) => (
window.__HEROS_DEBUG__?.camp()?.cityStay?.selectedLocation === expectedId
), locationId);
}
function verifyRestoredOutcomes(state, information, market, dialogue) {
assert.equal(state.cityStay.information.completed, true, 'Information completion must survive a CampScene restart.');
assert.equal(
state.campaign.inventory[information.inventoryLabel] ?? 0,
information.inventoryCount,
'The one-time information reward count must survive a CampScene restart.'
);
const restoredOffer = state.cityStay.market.offers.find((offer) => offer.id === market.offerId);
assert.equal(state.cityStay.market.gold, market.gold, 'The exact post-purchase gold balance must survive a CampScene restart.');
assert.equal(restoredOffer?.owned, market.owned, 'The purchased equipment count must survive a CampScene restart.');
assert.equal(state.cityStay.dialogue.completed, true, 'Companion dialogue completion must survive a CampScene restart.');
assert.equal(state.cityStay.dialogue.choiceId, dialogue.choiceId, 'The companion choice must survive a CampScene restart.');
assert.equal(
state.campaign.campDialogueChoiceIds[dialogue.id],
dialogue.choiceId,
'The persisted companion choice history must match the selected response.'
);
}
async function verifyNextBattleBriefing(
page,
expectedBriefingLines,
nextBattleId = xiaopeiBattleId,
cityName = '서주'
) {
await page.evaluate((battleId) => window.__HEROS_DEBUG__?.goToBattle(battleId), nextBattleId);
await page.waitForFunction((battleId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
window.__HEROS_DEBUG__?.activeScenes().includes('BattleScene') &&
battle?.scene === 'BattleScene' &&
battle?.battleId === battleId &&
battle?.cityInformation
);
}, nextBattleId, { timeout: 90000 });
let battle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
assert.equal(battle.cityInformation.available, true, `${nextBattleId} must expose its preceding city information.`);
assert.equal(battle.cityInformation.completed, true, `${nextBattleId} must see the saved ${cityName} information completion.`);
assert.deepEqual(
battle.cityInformation.briefingLines,
expectedBriefingLines,
`${nextBattleId} must expose the exact authored ${cityName} briefing lines.`
);
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
scene?.showOpeningBattleEvent?.();
});
await page.waitForFunction(() => {
const battleState = window.__HEROS_DEBUG__?.battle();
return battleState?.activeBattleEvent?.key === 'opening' || battleState?.battleLog?.length > 0;
});
battle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
const openingEvent = battle.activeBattleEvent?.key === 'opening'
? battle.activeBattleEvent
: battle.pendingBattleEvents?.find((event) => event.key === 'opening');
const briefingRecordedInLog = expectedBriefingLines.every((line) => (
battle.battleLog?.some((entry) => entry.includes(line))
));
assert(
(openingEvent && expectedBriefingLines.every((line) => openingEvent.lines.includes(line))) || briefingRecordedInLog,
`The opening battle briefing must record every completed ${cityName} information line: ${JSON.stringify({
openingEvent,
battleLog: battle.battleLog?.slice(-4)
})}`
);
}
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 assertBoundsInside(bounds, container, message) {
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,
`${message} ${JSON.stringify({ bounds, container })}`
);
}
async function waitForDebugApi(page) {
await page.waitForFunction(() => (
document.querySelector('canvas') !== null &&
window.__HEROS_GAME__ !== undefined &&
window.__HEROS_DEBUG__ !== undefined
), undefined, { timeout: 90000 });
}
async function assertDesktopViewport(page) {
const viewport = await page.evaluate(() => {
const canvas = document.querySelector('canvas');
return {
width: window.innerWidth,
height: window.innerHeight,
dpr: window.devicePixelRatio,
visualScale: window.visualViewport?.scale ?? 1,
canvas: canvas ? { width: canvas.width, height: canvas.height } : null
};
});
assert.deepEqual(
viewport,
{
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height,
dpr: desktopBrowserDeviceScaleFactor,
visualScale: 1,
canvas: {
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height
}
},
'The browser QA baseline must be an explicit 1920x1080 CSS viewport at DPR 1 and 100% zoom.'
);
}
async function clickSceneBounds(page, sceneKey, bounds) {
const point = await page.evaluate(({ requestedSceneKey, requestedBounds }) => {
const scene = window.__HEROS_GAME__?.scene.getScene(requestedSceneKey);
const canvas = document.querySelector('canvas');
const canvasBounds = canvas?.getBoundingClientRect();
if (!scene || !canvasBounds || !requestedBounds) {
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
};
}, {
requestedSceneKey: sceneKey,
requestedBounds: bounds
});
assert(point && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected a valid ${sceneKey} pointer coordinate.`);
await page.mouse.click(point.x, point.y);
}
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(50);
try {
await page.screenshot({ path, fullPage: true });
} finally {
if (loopSlept) {
await page.evaluate(() => window.__HEROS_GAME__?.loop.wake());
await page.waitForTimeout(50);
}
}
}
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 || '41795',
'--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));
}
function parseInventoryReward(reward) {
const match = /^(.*?)\s+([1-9]\d*)$/.exec(reward);
assert(match, `Expected an inventory reward ending in a positive amount: ${reward}`);
return { label: match[1], amount: Number(match[2]) };
}