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

1106 lines
38 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 seedGold = 5000;
const cityCases = [
{
cityStayId: 'xuzhou',
afterBattleId: 'seventh-battle-xuzhou-rescue',
nextBattleId: 'eighth-battle-xiaopei-supply-road',
campStep: 'seventh-camp',
partnerId: 'mi-zhu',
bondId: 'liu-bei__mi-zhu',
informationId: 'city-xuzhou-xiaopei-ledger',
dialogueId: 'city-xuzhou-liu-mi-supply-trust'
},
{
cityStayId: 'xinye',
afterBattleId: 'seventeenth-battle-wolong-visit-road',
nextBattleId: 'eighteenth-battle-bowang-ambush',
campStep: 'seventeenth-camp',
partnerId: 'zhuge-liang',
bondId: 'liu-bei__zhuge-liang',
informationId: 'city-xinye-bowang-scout-map',
dialogueId: 'city-xinye-liu-zhuge-first-command'
},
{
cityStayId: 'chengdu',
afterBattleId: 'thirty-third-battle-chengdu-surrender',
nextBattleId: 'thirty-fourth-battle-jiameng-pass',
campStep: 'thirty-third-camp',
partnerId: 'huang-quan',
bondId: 'liu-bei__huang-quan',
informationId: 'city-chengdu-jiameng-route-ledger',
dialogueId: 'city-chengdu-liu-huang-quan-order'
}
];
const [xuzhouCase, xinyeCase, chengduCase] = cityCases;
const targetUrl = withDebugOptions(
process.env.VERIFY_CITY_STAY_URL ?? 'http://127.0.0.1:41795/'
);
let serverProcess;
let browser;
try {
mkdirSync('dist', { recursive: true });
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 = [];
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 openCleanCamp(page);
await assertDesktopViewport(page);
await seedCityStay(page, xuzhouCase);
await restartCamp(page, xuzhouCase.cityStayId);
const xuzhouGateway = await waitForCityGateway(page, xuzhouCase.cityStayId);
verifyGatewayLayout(xuzhouGateway);
await captureStableScreenshot(page, 'dist/verification-city-xuzhou-gateway.png');
let city = await enterCityFromGateway(page, xuzhouGateway, xuzhouCase.cityStayId);
verifyExplorationLayout(city, xuzhouCase.cityStayId);
await captureStableScreenshot(page, 'dist/verification-city-xuzhou-map.png');
await verifyKeyboardMovement(page);
await verifyDistantInteractionIsBlocked(page);
const informationResult = await verifyInformationReward(
page,
xuzhouCase,
xuzhouGateway.cityStay.information
);
const marketResult = await verifyMarketPurchase(
page,
'dist/verification-city-xuzhou-market.png'
);
const resonanceResult = await verifyCompanionResonance(
page,
xuzhouCase,
'dist/verification-city-xuzhou-dialogue.png'
);
city = await reloadAndContinueCityStay(page, xuzhouCase.cityStayId);
verifyRestoredOutcomes(city, xuzhouCase, informationResult, marketResult, resonanceResult);
await verifyNextBattleBriefing(
page,
xuzhouCase,
xuzhouGateway.cityStay.information.briefingLines
);
await verifyAdditionalCityLayout(page, xinyeCase, {
verifyIncompleteExit: true
});
await verifyAdditionalCityLayout(page, chengduCase, {
verifyIncompleteExit: false
});
assert.deepEqual(
pageErrors,
[],
`Expected no browser page errors: ${JSON.stringify(pageErrors.slice(-8))}`
);
assert.deepEqual(
consoleErrors.filter((message) => !message.includes('The AudioContext was not allowed to start')),
[],
`Expected no browser console errors: ${JSON.stringify(consoleErrors.slice(-8))}`
);
console.log(
`Verified direct inter-battle city exploration at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` +
`DPR ${desktopBrowserDeviceScaleFactor}: Camp gateway entry, real keyboard movement, distance-gated interaction, ` +
'one-time information rewards, exact market purchases, saved resonance choices, reload/Continue restoration, ' +
'optional-objective exit, next-battle briefing integration, and Xuzhou/Xinye/Chengdu map and modal layouts.'
);
} 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 openCampScene(page);
}
async function openCampScene(page) {
await page.evaluate(async () => {
const game = window.__HEROS_GAME__;
const debug = window.__HEROS_DEBUG__;
if (!game || !debug) {
throw new Error('The game debug API is unavailable while opening CampScene.');
}
for (const scene of game.scene.getScenes(true)) {
if (scene.scene.key !== 'CampScene') {
game.scene.stop(scene.scene.key);
}
}
await debug.goToCamp();
game.scene.bringToTop('CampScene');
});
await page.waitForFunction(() => {
const camp = window.__HEROS_DEBUG__?.camp?.();
return (
window.__HEROS_DEBUG__?.activeScenes?.().includes('CampScene') &&
camp?.scene === 'CampScene' &&
camp?.report?.battleId &&
camp?.campaign
);
}, undefined, { timeout: 90000 });
}
async function seedCityStay(page, cityCase) {
const result = await 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 templateUnit = scene.campaign.roster[0] ?? scene.report.units[0];
if (!templateUnit) {
return { saved: false, reason: 'No unit template is available for the city-stay seed.' };
}
const now = new Date().toISOString();
const liuBei = {
...structuredClone(templateUnit),
id: 'liu-bei',
name: 'Liu Bei'
};
const partner = {
...structuredClone(templateUnit),
id: config.partnerId,
name: config.partnerId
};
const bond = {
id: config.bondId,
unitIds: ['liu-bei', config.partnerId],
title: `${config.cityStayId}-resonance`,
level: 42,
exp: 0,
battleExp: 0,
description: `Verification bond for ${config.cityStayId}.`
};
const report = {
...scene.report,
battleId: config.afterBattleId,
battleTitle: config.afterBattleId,
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.inventory = {};
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.afterBattleId,
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;
delete scene.campaign.activeCityStayId;
scene.report = report;
scene.persistSortieSelection();
const currentCityStayId = scene.currentCityStay?.()?.id ?? null;
return {
saved: true,
latestBattleId: scene.campaign.latestBattleId,
reportBattleId: scene.campaign.firstBattleReport?.battleId,
cityStayId: currentCityStayId,
gold: scene.campaign.gold
};
}, {
...cityCase,
gold: seedGold
});
assert.deepEqual(
result,
{
saved: true,
latestBattleId: cityCase.afterBattleId,
reportBattleId: cityCase.afterBattleId,
cityStayId: cityCase.cityStayId,
gold: seedGold
},
`Failed to seed ${cityCase.cityStayId}: ${JSON.stringify(result)}`
);
}
async function restartCamp(page, expectedCityStayId) {
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
await waitForDebugApi(page);
await openCampScene(page);
await waitForCityGateway(page, expectedCityStayId);
}
async function waitForCityGateway(page, expectedCityStayId) {
await page.waitForFunction((cityStayId) => {
const camp = window.__HEROS_DEBUG__?.camp?.();
return (
window.__HEROS_DEBUG__?.activeScenes?.().includes('CampScene') &&
camp?.scene === 'CampScene' &&
camp?.activeTab === 'city' &&
camp?.cityStay?.id === cityStayId &&
camp?.cityStay?.active === true &&
camp?.cityStay?.panelBounds &&
camp?.cityStay?.explorationButtonBounds &&
(
camp?.cityStay?.explorationButtonInteractive === true ||
camp?.cityStay?.explorationInteractive === true
)
);
}, expectedCityStayId, { timeout: 90000 });
return page.evaluate(() => window.__HEROS_DEBUG__?.camp?.());
}
function verifyGatewayLayout(camp) {
assert.equal(camp.activeTab, 'city');
assert.equal(camp.campTabs.length, 7, 'A city stay must retain the seven CampScene tabs.');
assert(
camp.campTabs.every((tab) => tab.bounds && tab.interactive),
`Every Camp tab must remain visible and interactive: ${JSON.stringify(camp.campTabs)}`
);
assertNoOverlappingBounds(
camp.campTabs.map((tab) => ({ id: tab.id, bounds: tab.bounds })),
'Camp tabs'
);
const viewportBounds = {
x: 0,
y: 0,
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height
};
assertBoundsInside(camp.cityStay.panelBounds, viewportBounds, 'City gateway panel');
assertBoundsInside(
camp.cityStay.explorationButtonBounds,
camp.cityStay.panelBounds,
'City exploration gateway button'
);
assert.equal(
camp.victoryRewardAcknowledgement.visible,
false,
'The acknowledged battle reward must not obscure the city gateway.'
);
}
async function enterCityFromGateway(page, gatewayState, expectedCityStayId) {
assert.equal(
gatewayState.cityStay.explorationButtonInteractive ??
gatewayState.cityStay.explorationInteractive,
true
);
await clickSceneBounds(
page,
'CampScene',
gatewayState.cityStay.explorationButtonBounds
);
return waitForCityReady(page, expectedCityStayId);
}
async function waitForCityReady(page, expectedCityStayId) {
try {
await page.waitForFunction((cityStayId) => {
const city = window.__HEROS_DEBUG__?.cityStay?.();
return (
window.__HEROS_DEBUG__?.activeScenes?.().includes('CityStayScene') &&
city?.scene === 'CityStayScene' &&
city?.ready === true &&
city?.cityStayId === cityStayId &&
city?.requiredTexturesReady === true
);
}, expectedCityStayId, { timeout: 90000 });
} catch (error) {
const diagnostic = await page.evaluate(() => ({
activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
cityStay: window.__HEROS_DEBUG__?.cityStay?.() ?? null,
camp: window.__HEROS_DEBUG__?.camp?.() ?? null
}));
throw new Error(
`CityStayScene ${expectedCityStayId} did not become ready: ${JSON.stringify(diagnostic)}`,
{ cause: error }
);
}
await page.waitForTimeout(380);
return readCity(page);
}
function verifyExplorationLayout(city, expectedCityStayId) {
assert.equal(city.scene, 'CityStayScene');
assert.equal(city.cityStayId, expectedCityStayId);
assert.equal(city.activeCityStayId, expectedCityStayId);
assert.deepEqual(city.viewport, desktopBrowserViewport);
assert.equal(city.movement.speed, 300);
assert.equal(city.interaction.radius, 122);
assert.equal(city.progress.total, 2);
assert.equal(city.progress.marketOptional, true);
assert.equal(city.progress.exitUnlocked, true);
assert.equal(city.actors.length, 3);
assert.deepEqual(
[...city.actors.map((actor) => actor.kind)].sort(),
['dialogue', 'information', 'market']
);
assert.equal(city.requiredTexturesReady, true);
assertBoundsInsideViewport(city.player.bounds, `${expectedCityStayId} player`);
assertBoundsInsideViewport(city.movement.bounds, `${expectedCityStayId} movement area`);
city.actors.forEach((actor) => {
assertBoundsInsideViewport(actor.bounds, `${expectedCityStayId} actor ${actor.id}`);
});
city.blockers.forEach((blocker, index) => {
assertBoundsInsideViewport(blocker, `${expectedCityStayId} blocker ${index}`);
});
}
async function verifyKeyboardMovement(page) {
const before = await readCity(page);
assert.equal(before.dialogue.active, false);
assert.equal(before.shop.open, false);
assert.equal(before.choice.open, false);
await page.keyboard.down('a');
await page.waitForTimeout(430);
await page.keyboard.up('a');
await page.waitForTimeout(90);
const after = await readCity(page);
assert(
before.player.x - after.player.x >= 85,
`Expected real keyboard movement to move Liu Bei left: ${JSON.stringify({
before: before.player,
after: after.player
})}`
);
assert(
Math.abs(after.player.y - before.player.y) <= 3,
`Expected horizontal movement to retain Y: ${JSON.stringify({
before: before.player,
after: after.player
})}`
);
assert.equal(after.player.moving, false);
}
async function verifyDistantInteractionIsBlocked(page) {
const before = await readCity(page);
assert.equal(before.interaction.canInteract, false, 'The movement probe should end outside every interaction radius.');
await page.keyboard.press('e');
await page.waitForTimeout(160);
const after = await readCity(page);
assert.equal(after.dialogue.active, false, 'A distant interaction must not open dialogue.');
assert.equal(after.shop.open, false, 'A distant interaction must not open the market.');
assert.equal(after.choice.open, false, 'A distant interaction must not open a resonance choice.');
assert.deepEqual(after.progress, before.progress, 'A distant interaction must not change city progress.');
await page.keyboard.press('Escape');
await page.waitForTimeout(120);
const afterEscape = await readCity(page);
assert.equal(
afterEscape.navigationPending,
false,
'ESC on the open map must not bypass walking to the south exit.'
);
assert(
afterEscape.lastNotice.includes('남쪽 군영 출구'),
`ESC should direct the player to the physical exit: ${afterEscape.lastNotice}`
);
}
async function verifyInformationReward(page, cityCase, gatewayInformation) {
const before = await readCity(page);
assert.equal(before.progress.informationComplete, false);
assert(!before.campaign.completedCampVisits.includes(cityCase.informationId));
const inventoryBefore = { ...before.campaign.inventory };
await interactWith(page, 'information');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true
));
await captureStableScreenshot(
page,
`dist/verification-city-${cityCase.cityStayId}-information.png`
);
await advanceCityDialogueUntilClosed(page);
await page.waitForFunction((informationId) => {
const city = window.__HEROS_DEBUG__?.cityStay?.();
return (
city?.progress?.informationComplete === true &&
city?.campaign?.completedCampVisits?.includes(informationId)
);
}, cityCase.informationId);
const afterFirst = await readCity(page);
const rewardDelta = inventoryDelta(inventoryBefore, afterFirst.campaign.inventory);
assert.equal(
rewardDelta.length,
1,
`Information must grant exactly one inventory entry: ${JSON.stringify(rewardDelta)}`
);
assert.equal(rewardDelta[0].amount, 1, 'The information inventory reward must increase by exactly one.');
assert.equal(
afterFirst.campaign.completedCampVisits.filter((id) => id === cityCase.informationId).length,
1,
'The information completion ID must be unique.'
);
await interactWith(page, 'information');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true
));
await advanceCityDialogueUntilClosed(page);
const afterRepeated = await readCity(page);
assert.deepEqual(
afterRepeated.campaign.inventory,
afterFirst.campaign.inventory,
'Repeating completed information dialogue must not duplicate its reward.'
);
assert.equal(
afterRepeated.campaign.completedCampVisits.filter((id) => id === cityCase.informationId).length,
1,
'Repeating completed information dialogue must not duplicate its completion ID.'
);
assert(
Array.isArray(gatewayInformation?.briefingLines) && gatewayInformation.briefingLines.length > 0,
'The Camp gateway must expose the authored next-battle briefing lines.'
);
return {
inventoryLabel: rewardDelta[0].label,
inventoryCount: afterRepeated.campaign.inventory[rewardDelta[0].label]
};
}
async function verifyMarketPurchase(page, screenshotPath) {
await interactWith(page, 'market');
await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === true);
const before = await readCity(page);
verifyShopLayout(before);
await captureStableScreenshot(page, screenshotPath);
const offer = before.shop.offers[0];
assert(offer?.interactive && offer.buttonBounds && offer.canBuy);
const expectedGold = before.shop.gold - offer.price;
const expectedOwned = offer.owned + 1;
await clickSceneBounds(page, 'CityStayScene', offer.buttonBounds);
await page.waitForFunction(({ offerId, gold, owned }) => {
const shop = window.__HEROS_DEBUG__?.cityStay?.()?.shop;
const currentOffer = shop?.offers?.find((candidate) => candidate.id === offerId);
return shop?.gold === gold && currentOffer?.owned === owned;
}, {
offerId: offer.id,
gold: expectedGold,
owned: expectedOwned
});
const after = await readCity(page);
const purchased = after.shop.offers.find((candidate) => candidate.id === offer.id);
assert.equal(after.shop.gold, expectedGold, 'The market must deduct the exact displayed price.');
assert.equal(purchased?.owned, expectedOwned, 'The purchased item count must increase by exactly one.');
assert.equal(after.campaign.gold, expectedGold);
await page.keyboard.press('Escape');
await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === false);
return {
offerId: offer.id,
gold: expectedGold,
owned: expectedOwned
};
}
function verifyShopLayout(city) {
assert.equal(city.shop.open, true);
assertBoundsInsideViewport(city.shop.panelBounds, `${city.cityStayId} shop panel`);
assert.equal(city.shop.offers.length, 3);
city.shop.offers.forEach((offer) => {
assert(offer.buttonBounds, `${offer.id} must expose purchase button bounds.`);
assertBoundsInsideViewport(offer.buttonBounds, `${city.cityStayId} shop offer ${offer.id}`);
});
assertNoOverlappingBounds(
city.shop.offers.map((offer) => ({ id: offer.id, bounds: offer.buttonBounds })),
`${city.cityStayId} shop offer buttons`
);
}
async function verifyCompanionResonance(page, cityCase, screenshotPath) {
const campaignBefore = await readActiveCampaign(page);
const bondBefore = campaignBefore.bonds.find((bond) => bond.id === cityCase.bondId);
assert(bondBefore, `Expected seeded bond ${cityCase.bondId}.`);
await interactWith(page, 'dialogue');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true
));
await advanceDialogueUntilChoice(page);
const choiceState = await readCity(page);
verifyChoiceLayout(choiceState);
await captureStableScreenshot(page, screenshotPath);
const choice = choiceState.choice.choices[0];
assert(choice?.interactive && choice.bounds);
await clickSceneBounds(page, 'CityStayScene', choice.bounds);
await page.waitForFunction(({ dialogueId, choiceId }) => {
const city = window.__HEROS_DEBUG__?.cityStay?.();
return (
city?.progress?.dialogueComplete === true &&
city?.campaign?.completedCampDialogues?.includes(dialogueId) &&
city?.campaign?.campDialogueChoiceIds?.[dialogueId] === choiceId
);
}, {
dialogueId: cityCase.dialogueId,
choiceId: choice.id
});
const afterChoice = await readCity(page);
assert.equal(
afterChoice.campaign.completedCampDialogues.filter((id) => id === cityCase.dialogueId).length,
1,
'The companion dialogue completion ID must be unique.'
);
assert.equal(afterChoice.campaign.campDialogueChoiceIds[cityCase.dialogueId], choice.id);
const campaignAfter = await readActiveCampaign(page);
const bondAfter = campaignAfter.bonds.find((bond) => bond.id === cityCase.bondId);
assert(bondAfter, `Expected persisted bond ${cityCase.bondId}.`);
assert.equal(
bondAfter.battleExp,
bondBefore.battleExp + choice.rewardExp,
'The selected response must persist its exact resonance reward.'
);
assert.equal(
bondAfter.exp,
bondBefore.exp + choice.rewardExp,
'The seeded bond should retain the exact resonance EXP below the level threshold.'
);
await advanceCityDialogueUntilClosed(page);
return {
dialogueId: cityCase.dialogueId,
choiceId: choice.id,
rewardExp: choice.rewardExp,
bondExp: bondAfter.exp,
bondBattleExp: bondAfter.battleExp
};
}
function verifyChoiceLayout(city) {
assert.equal(city.choice.open, true);
assertBoundsInsideViewport(city.choice.panelBounds, `${city.cityStayId} resonance panel`);
assert.equal(city.choice.choices.length, 2);
city.choice.choices.forEach((choice) => {
assert(choice.interactive && choice.bounds, `${choice.id} must be visible and interactive.`);
assertBoundsInsideViewport(choice.bounds, `${city.cityStayId} resonance choice ${choice.id}`);
});
assertNoOverlappingBounds(
city.choice.choices.map((choice) => ({ id: choice.id, bounds: choice.bounds })),
`${city.cityStayId} resonance choices`
);
}
async function reloadAndContinueCityStay(page, expectedCityStayId) {
const beforeReload = await readCity(page);
assert.equal(beforeReload.activeCityStayId, expectedCityStayId);
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
await waitForDebugApi(page);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene')
), undefined, { timeout: 90000 });
await assertDesktopViewport(page);
await page.waitForTimeout(380);
await page.keyboard.press('Enter');
return waitForCityReady(page, expectedCityStayId);
}
function verifyRestoredOutcomes(city, cityCase, information, market, resonance) {
verifyExplorationLayout(city, cityCase.cityStayId);
assert.equal(city.progress.informationComplete, true);
assert.equal(city.progress.dialogueComplete, true);
assert.equal(
city.campaign.inventory[information.inventoryLabel],
information.inventoryCount,
'The one-time information reward must survive reload/Continue.'
);
assert.equal(city.shop.gold, market.gold, 'The exact post-purchase gold balance must survive reload/Continue.');
assert.equal(
city.shop.offers.find((offer) => offer.id === market.offerId)?.owned,
market.owned,
'The purchased equipment count must survive reload/Continue.'
);
assert.equal(
city.campaign.campDialogueChoiceIds[cityCase.dialogueId],
resonance.choiceId,
'The selected companion response must survive reload/Continue.'
);
}
async function verifyNextBattleBriefing(page, cityCase, expectedBriefingLines) {
await page.evaluate(async (battleId) => {
const game = window.__HEROS_GAME__;
if (!game || !window.__HEROS_DEBUG__) {
throw new Error('The game debug API is unavailable while opening BattleScene.');
}
for (const scene of game.scene.getScenes(true)) {
if (scene.scene.key !== 'BattleScene') {
game.scene.stop(scene.scene.key);
}
}
await window.__HEROS_DEBUG__.goToBattle(battleId);
game.scene.bringToTop('BattleScene');
}, cityCase.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
);
}, cityCase.nextBattleId, { timeout: 90000 });
let battle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle?.());
assert.equal(battle.cityInformation.available, true);
assert.equal(battle.cityInformation.completed, true);
assert.deepEqual(
battle.cityInformation.briefingLines,
expectedBriefingLines,
'The next battle must expose the exact authored city briefing.'
);
const openingBriefingLines = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
return scene?.completedCityInformationBriefingLines?.() ?? [];
});
assert.equal(
openingBriefingLines.length,
expectedBriefingLines.length + 1,
'The opening briefing payload must prepend one city-information heading.'
);
assert.deepEqual(
openingBriefingLines.slice(1),
expectedBriefingLines,
'Every gathered city-information line must feed the next battle opening briefing.'
);
}
async function verifyAdditionalCityLayout(page, cityCase, options) {
await openCampScene(page);
await seedCityStay(page, cityCase);
await restartCamp(page, cityCase.cityStayId);
const gateway = await waitForCityGateway(page, cityCase.cityStayId);
verifyGatewayLayout(gateway);
let city = await enterCityFromGateway(page, gateway, cityCase.cityStayId);
verifyExplorationLayout(city, cityCase.cityStayId);
assert.equal(city.progress.informationComplete, false);
assert.equal(city.progress.dialogueComplete, false);
await captureStableScreenshot(
page,
`dist/verification-city-${cityCase.cityStayId}-map.png`
);
await interactWith(page, 'information');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true
));
city = await readCity(page);
assertBoundsInsideViewport(city.dialogue.bounds, `${cityCase.cityStayId} information dialogue`);
await captureStableScreenshot(
page,
`dist/verification-city-${cityCase.cityStayId}-information.png`
);
await page.keyboard.press('Escape');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === false
));
assert.equal((await readCity(page)).progress.informationComplete, false);
await interactWith(page, 'market');
await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === true);
city = await readCity(page);
verifyShopLayout(city);
await captureStableScreenshot(
page,
`dist/verification-city-${cityCase.cityStayId}-market.png`
);
await page.keyboard.press('Escape');
await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === false);
await interactWith(page, 'dialogue');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true
));
await advanceDialogueUntilChoice(page);
city = await readCity(page);
verifyChoiceLayout(city);
await captureStableScreenshot(
page,
`dist/verification-city-${cityCase.cityStayId}-dialogue.png`
);
await page.keyboard.press('Escape');
await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.choice?.open === false);
city = await readCity(page);
assert.equal(city.progress.dialogueComplete, false);
if (options.verifyIncompleteExit) {
await verifyIncompleteCityExit(page, cityCase);
}
}
async function verifyIncompleteCityExit(page, cityCase) {
const before = await readCity(page);
assert.equal(before.progress.completed, 0);
assert.equal(before.progress.exitUnlocked, true);
await interactWith(page, 'exit');
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true
));
await captureStableScreenshot(
page,
`dist/verification-city-${cityCase.cityStayId}-exit-warning.png`
);
await advanceCityDialogueUntilSceneChanges(page);
await page.waitForFunction(() => {
const camp = window.__HEROS_DEBUG__?.camp?.();
return (
window.__HEROS_DEBUG__?.activeScenes?.().includes('CampScene') &&
camp?.scene === 'CampScene'
);
}, undefined, { timeout: 90000 });
const campaign = await readActiveCampaign(page);
assert.equal(campaign.activeCityStayId ?? null, null, 'Returning to Camp must clear activeCityStayId.');
assert(!campaign.completedCampVisits.includes(cityCase.informationId));
assert(!campaign.completedCampDialogues.includes(cityCase.dialogueId));
}
async function interactWith(page, targetIdOrKind) {
const result = await page.evaluate((target) => {
const scene = window.__HEROS_GAME__?.scene.getScene('CityStayScene');
return scene?.debugInteractWith?.(target) ?? false;
}, targetIdOrKind);
assert.equal(result, true, `Expected CityStayScene interaction with ${targetIdOrKind}.`);
await page.waitForTimeout(90);
}
async function advanceCityDialogueUntilClosed(page) {
for (let attempt = 0; attempt < 16; attempt += 1) {
const city = await readCity(page);
if (!city?.dialogue?.active) {
return;
}
await page.mouse.click(960, 850);
await page.waitForTimeout(130);
}
throw new Error(`City dialogue did not close: ${JSON.stringify(await readCity(page))}`);
}
async function advanceDialogueUntilChoice(page) {
for (let attempt = 0; attempt < 16; attempt += 1) {
const city = await readCity(page);
if (city?.choice?.open) {
return;
}
if (!city?.dialogue?.active) {
throw new Error(`Dialogue closed without opening a choice: ${JSON.stringify(city)}`);
}
await page.mouse.click(960, 850);
await page.waitForTimeout(130);
}
throw new Error(`Resonance choice did not open: ${JSON.stringify(await readCity(page))}`);
}
async function advanceCityDialogueUntilSceneChanges(page) {
for (let attempt = 0; attempt < 16; attempt += 1) {
const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes?.() ?? []);
if (!activeScenes.includes('CityStayScene')) {
return;
}
const city = await readCity(page);
if (city?.dialogue?.active) {
await page.mouse.click(960, 850);
await page.waitForTimeout(150);
continue;
}
if (city?.navigationPending) {
await page.waitForTimeout(150);
continue;
}
throw new Error(`City exit stopped before navigation: ${JSON.stringify(city)}`);
}
throw new Error(`City exit did not return to Camp: ${JSON.stringify(await readCity(page))}`);
}
async function readCity(page) {
return page.evaluate(() => window.__HEROS_DEBUG__?.cityStay?.());
}
async function readActiveCampaign(page) {
return page.evaluate(() => {
const debug = window.__HEROS_DEBUG__;
const activeKey = ['CityStayScene', 'CampScene', 'BattleScene']
.find((key) => window.__HEROS_GAME__?.scene.isActive(key));
const campaign = activeKey ? debug?.scene(activeKey)?.campaign : undefined;
if (!campaign) {
throw new Error(`No active scene campaign is available (scene: ${activeKey ?? 'none'}).`);
}
return structuredClone(campaign);
});
}
function inventoryDelta(before, after) {
return [...new Set([...Object.keys(before), ...Object.keys(after)])]
.map((label) => ({
label,
amount: (after[label] ?? 0) - (before[label] ?? 0)
}))
.filter((entry) => entry.amount !== 0);
}
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 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');
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 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));
await page.mouse.click(point.x, point.y);
}
async function captureStableScreenshot(page, path) {
await page.waitForTimeout(260);
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 || '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));
}