1683 lines
56 KiB
JavaScript
1683 lines
56 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 rendererTypes = {
|
|
canvas: 1,
|
|
webgl: 2
|
|
};
|
|
const baseTargetUrl =
|
|
process.env.VERIFY_CITY_STAY_URL ?? 'http://127.0.0.1:41795/';
|
|
const targetUrl = withDebugOptions(
|
|
baseTargetUrl,
|
|
'canvas'
|
|
);
|
|
|
|
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');
|
|
|
|
const movementProbe = await verifyKeyboardMovement(page);
|
|
await verifyPointerMovement(page, movementProbe);
|
|
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))}`
|
|
);
|
|
|
|
await verifyRendererVisualInteractionCoverage(
|
|
browser,
|
|
baseTargetUrl,
|
|
'webgl'
|
|
);
|
|
|
|
console.log(
|
|
`Verified direct inter-battle city exploration at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` +
|
|
`DPR ${desktopBrowserDeviceScaleFactor} in Canvas and WebGL: Camp gateway entry, high-resolution city backgrounds, ` +
|
|
'SD exploration characters, portrait dialogue, real pointer navigation and automatic interaction, 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, renderer) {
|
|
const parsed = new URL(url);
|
|
parsed.searchParams.set('debug', '1');
|
|
parsed.searchParams.set('renderer', renderer);
|
|
return parsed.toString();
|
|
}
|
|
|
|
async function verifyRendererVisualInteractionCoverage(
|
|
browserInstance,
|
|
url,
|
|
renderer
|
|
) {
|
|
const context = await browserInstance.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 {
|
|
await openCleanCamp(page, withDebugOptions(url, renderer));
|
|
await assertDesktopViewport(page);
|
|
|
|
for (const [index, cityCase] of cityCases.entries()) {
|
|
if (index > 0) {
|
|
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);
|
|
await captureStableScreenshot(
|
|
page,
|
|
`dist/verification-city-${cityCase.cityStayId}-${renderer}-map.png`
|
|
);
|
|
|
|
await pointerInteractWith(page, 'information', {
|
|
expectTravel: true
|
|
});
|
|
city = await readCity(page);
|
|
verifyActiveDialoguePortrait(
|
|
city,
|
|
`${renderer} ${cityCase.cityStayId} information dialogue`
|
|
);
|
|
await captureStableScreenshot(
|
|
page,
|
|
`dist/verification-city-${cityCase.cityStayId}-${renderer}-information-pointer.png`
|
|
);
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForFunction(
|
|
() =>
|
|
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active ===
|
|
false
|
|
);
|
|
|
|
await pointerInteractWith(page, 'market', {
|
|
expectTravel: true
|
|
});
|
|
city = await readCity(page);
|
|
verifyShopLayout(city);
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForFunction(
|
|
() => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === false
|
|
);
|
|
|
|
await pointerInteractWith(page, 'dialogue', {
|
|
expectTravel: true
|
|
});
|
|
city = await readCity(page);
|
|
verifyActiveDialoguePortrait(
|
|
city,
|
|
`${renderer} ${cityCase.cityStayId} companion dialogue`
|
|
);
|
|
await captureStableScreenshot(
|
|
page,
|
|
`dist/verification-city-${cityCase.cityStayId}-${renderer}-companion-pointer.png`
|
|
);
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForFunction(
|
|
() =>
|
|
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active ===
|
|
false
|
|
);
|
|
assert.equal(
|
|
(await readCity(page)).progress.completed,
|
|
0,
|
|
`${renderer} visual interaction coverage must not complete optional city activities.`
|
|
);
|
|
}
|
|
|
|
assert.deepEqual(
|
|
pageErrors,
|
|
[],
|
|
`${renderer}: expected no browser page errors: ${JSON.stringify(pageErrors.slice(-8))}`
|
|
);
|
|
assert.deepEqual(
|
|
consoleErrors.filter(
|
|
(message) =>
|
|
!message.includes('The AudioContext was not allowed to start')
|
|
),
|
|
[],
|
|
`${renderer}: expected no browser console errors: ${JSON.stringify(consoleErrors.slice(-8))}`
|
|
);
|
|
} finally {
|
|
await context.close();
|
|
}
|
|
}
|
|
|
|
async function openCleanCamp(page, url = targetUrl) {
|
|
await page.goto(url, { 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'
|
|
);
|
|
const rewardLedger = camp.victoryRewardAcknowledgement;
|
|
assert.equal(
|
|
camp.victorySettlementArrival,
|
|
null,
|
|
'An already acknowledged and dismissed victory must not repeat its settlement arrival notice.'
|
|
);
|
|
assert.equal(rewardLedger.role, 'on-demand-ledger');
|
|
assert.equal(rewardLedger.available, true);
|
|
assert.equal(
|
|
rewardLedger.visible,
|
|
false,
|
|
'The on-demand reward ledger must not obscure the city gateway.'
|
|
);
|
|
assert.equal(rewardLedger.toggleInteractive, true);
|
|
assertBoundsInside(
|
|
rewardLedger.toggleButtonBounds,
|
|
viewportBounds,
|
|
'City-camp reward ledger toggle'
|
|
);
|
|
assert.deepEqual(rewardLedger.cards, []);
|
|
assert.deepEqual(rewardLedger.actions, []);
|
|
}
|
|
|
|
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 assertDesktopViewport(page);
|
|
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);
|
|
const companion = city.actors.find((actor) => actor.kind === 'dialogue');
|
|
assert(companion, `${expectedCityStayId} must expose its companion actor.`);
|
|
assert.equal(
|
|
city.objective?.dialogueUnitNames,
|
|
`유비 · ${companion.name}`,
|
|
`${expectedCityStayId} objective card must use authored Korean character names instead of roster or unit IDs.`
|
|
);
|
|
assert.deepEqual(
|
|
[...city.actors.map((actor) => actor.kind)].sort(),
|
|
['dialogue', 'information', 'market']
|
|
);
|
|
assert.equal(city.requiredTexturesReady, true);
|
|
assert.deepEqual(
|
|
{
|
|
textureKey: city.background?.textureKey,
|
|
ready: city.background?.ready,
|
|
sourceWidth: city.background?.sourceWidth,
|
|
sourceHeight: city.background?.sourceHeight,
|
|
fallback: city.background?.fallback
|
|
},
|
|
{
|
|
textureKey: `city-${expectedCityStayId}-stay-background`,
|
|
ready: true,
|
|
sourceWidth: desktopBrowserViewport.width,
|
|
sourceHeight: desktopBrowserViewport.height,
|
|
fallback: false
|
|
},
|
|
`${expectedCityStayId} must render its authored 1920x1080 city background texture.`
|
|
);
|
|
assertBoundsCoverViewport(
|
|
city.background?.bounds,
|
|
`${expectedCityStayId} background`
|
|
);
|
|
assert(
|
|
city.player?.textureKey?.startsWith('exploration-'),
|
|
`${expectedCityStayId} player must use an exploration-* SD texture: ${JSON.stringify(city.player)}`
|
|
);
|
|
assert(
|
|
city.player?.animationKey?.startsWith(`${city.player.textureKey}-`),
|
|
`${expectedCityStayId} player animation must use its SD texture: ${JSON.stringify(city.player)}`
|
|
);
|
|
|
|
assertBoundsInsideViewport(city.player.bounds, `${expectedCityStayId} player`);
|
|
assertBoundsInsideViewport(city.movement.bounds, `${expectedCityStayId} movement area`);
|
|
city.actors.forEach((actor) => {
|
|
assert(
|
|
actor.textureKey?.startsWith('exploration-'),
|
|
`${expectedCityStayId} actor ${actor.id} must use an exploration-* SD texture: ${JSON.stringify(actor)}`
|
|
);
|
|
assert(
|
|
actor.animationKey?.startsWith(`${actor.textureKey}-`),
|
|
`${expectedCityStayId} actor ${actor.id} animation must use its SD texture: ${JSON.stringify(actor)}`
|
|
);
|
|
assert.equal(
|
|
actor.moved,
|
|
false,
|
|
`${expectedCityStayId} actor ${actor.id} must remain at its authored position.`
|
|
);
|
|
assert.equal(
|
|
actor.x,
|
|
actor.initialX,
|
|
`${expectedCityStayId} actor ${actor.id} X must match its authored position.`
|
|
);
|
|
assert.equal(
|
|
actor.y,
|
|
actor.initialY,
|
|
`${expectedCityStayId} actor ${actor.id} Y must match its authored position.`
|
|
);
|
|
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);
|
|
return {
|
|
start: { x: before.player.x, y: before.player.y },
|
|
end: { x: after.player.x, y: after.player.y }
|
|
};
|
|
}
|
|
|
|
async function verifyPointerMovement(page, movementProbe) {
|
|
await clickScenePoint(page, 'CityStayScene', movementProbe.start);
|
|
await waitForPlayerAt(page, movementProbe.start);
|
|
const returned = await readCity(page);
|
|
assert(
|
|
returned.player.x - movementProbe.end.x >= 85,
|
|
`Expected world-pointer movement to return Liu Bei right: ${JSON.stringify({
|
|
movementProbe,
|
|
returned: returned.player
|
|
})}`
|
|
);
|
|
assert(
|
|
Math.abs(returned.player.y - movementProbe.start.y) <= 3,
|
|
`Expected world-pointer movement to retain Y: ${JSON.stringify({
|
|
movementProbe,
|
|
returned: returned.player
|
|
})}`
|
|
);
|
|
|
|
await clickScenePoint(page, 'CityStayScene', movementProbe.end);
|
|
await waitForPlayerAt(page, movementProbe.end);
|
|
const restored = await readCity(page);
|
|
assert(
|
|
Math.abs(restored.player.x - movementProbe.end.x) <= 9 &&
|
|
Math.abs(restored.player.y - movementProbe.end.y) <= 9,
|
|
`Expected the pointer probe to restore the distant interaction position: ${JSON.stringify({
|
|
expected: movementProbe.end,
|
|
restored: restored.player
|
|
})}`
|
|
);
|
|
}
|
|
|
|
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 pointerInteractWith(page, 'information', { expectTravel: true });
|
|
await page.waitForFunction(() => (
|
|
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true
|
|
));
|
|
verifyActiveDialoguePortrait(
|
|
await readCity(page),
|
|
`${cityCase.cityStayId} information dialogue`
|
|
);
|
|
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 pointerInteractWith(page, 'information');
|
|
await page.waitForFunction(() => (
|
|
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true
|
|
));
|
|
verifyActiveDialoguePortrait(
|
|
await readCity(page),
|
|
`${cityCase.cityStayId} repeated information dialogue`
|
|
);
|
|
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 pointerInteractWith(page, 'market', { expectTravel: true });
|
|
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 pointerInteractWith(page, 'dialogue', { expectTravel: true });
|
|
await page.waitForFunction(() => (
|
|
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true
|
|
));
|
|
const initialDialogueState = await readCity(page);
|
|
verifyActiveDialoguePortrait(
|
|
initialDialogueState,
|
|
`${cityCase.cityStayId} companion dialogue`
|
|
);
|
|
const initialCompanionFacing = initialDialogueState.actors.find(
|
|
(actor) => actor.kind === 'dialogue'
|
|
)?.animationKey;
|
|
assert(
|
|
initialCompanionFacing,
|
|
`${cityCase.cityStayId} companion must expose its facing animation during dialogue.`
|
|
);
|
|
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.dialogue.active,
|
|
true,
|
|
'Selecting a resonance response must open its acknowledgement dialogue.'
|
|
);
|
|
assert.equal(
|
|
afterChoice.dialogue.lineIndex,
|
|
0,
|
|
'The pointer event that selects a response must not also skip the first acknowledgement line.'
|
|
);
|
|
assert.equal(
|
|
afterChoice.actors.find((actor) => actor.kind === 'dialogue')?.animationKey,
|
|
initialCompanionFacing,
|
|
'The companion must turn back toward the player for the acknowledgement dialogue.'
|
|
);
|
|
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`
|
|
);
|
|
}
|
|
|
|
function verifyActiveDialoguePortrait(city, label) {
|
|
assert.equal(
|
|
city.dialogue?.active,
|
|
true,
|
|
`${label}: dialogue must be active.`
|
|
);
|
|
assert.equal(
|
|
city.dialogue.portraitVisible,
|
|
true,
|
|
`${label}: the face portrait must be visible.`
|
|
);
|
|
assert(
|
|
city.dialogue.portraitTextureKey?.startsWith('portrait-'),
|
|
`${label}: expected a portrait-* face texture, received ${JSON.stringify(city.dialogue.portraitTextureKey)}.`
|
|
);
|
|
assertBoundsInsideViewport(
|
|
city.dialogue.portraitBounds,
|
|
`${label} face portrait`
|
|
);
|
|
assertBoundsInsideViewport(city.dialogue.bounds, `${label} panel`);
|
|
}
|
|
|
|
function verifyDialoguePortraitPolicy(city, label) {
|
|
const portraitlessSpeakers = new Set([
|
|
'공명',
|
|
'체류 기록',
|
|
'길잡이'
|
|
]);
|
|
if (!portraitlessSpeakers.has(city.dialogue?.speaker)) {
|
|
verifyActiveDialoguePortrait(city, label);
|
|
return;
|
|
}
|
|
assert.equal(
|
|
city.dialogue?.active,
|
|
true,
|
|
`${label}: narrator dialogue must be active.`
|
|
);
|
|
assert.equal(
|
|
city.dialogue.portraitVisible,
|
|
false,
|
|
`${label}: narrator line must leave the face portrait hidden.`
|
|
);
|
|
assert.equal(
|
|
city.dialogue.portraitTextureKey,
|
|
null,
|
|
`${label}: narrator line must not expose a stale portrait texture.`
|
|
);
|
|
assertBoundsInsideViewport(
|
|
city.dialogue.bounds,
|
|
`${label} panel`
|
|
);
|
|
}
|
|
|
|
function assertActorsRemainAuthored(expectedActors, city, label) {
|
|
assert.equal(
|
|
city.actors.length,
|
|
expectedActors.length,
|
|
`${label}: actor count changed while the player moved.`
|
|
);
|
|
expectedActors.forEach((expected) => {
|
|
const actual = city.actors.find((actor) => actor.id === expected.id);
|
|
assert(actual, `${label}: actor ${expected.id} disappeared.`);
|
|
assert.deepEqual(
|
|
{
|
|
id: actual.id,
|
|
x: actual.x,
|
|
y: actual.y,
|
|
initialX: actual.initialX,
|
|
initialY: actual.initialY,
|
|
moved: actual.moved
|
|
},
|
|
expected,
|
|
`${label}: actor ${expected.id} must not teleport or drift.`
|
|
);
|
|
});
|
|
}
|
|
|
|
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 pointerInteractWith(page, 'information', { expectTravel: true });
|
|
await page.waitForFunction(() => (
|
|
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true
|
|
));
|
|
city = await readCity(page);
|
|
verifyActiveDialoguePortrait(
|
|
city,
|
|
`${cityCase.cityStayId} information dialogue`
|
|
);
|
|
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 pointerInteractWith(page, 'market', { expectTravel: true });
|
|
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 pointerInteractWith(page, 'dialogue', { expectTravel: true });
|
|
await page.waitForFunction(() => (
|
|
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true
|
|
));
|
|
verifyActiveDialoguePortrait(
|
|
await readCity(page),
|
|
`${cityCase.cityStayId} companion dialogue`
|
|
);
|
|
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 pointerInteractWith(page, 'exit', { expectTravel: true });
|
|
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 pointerInteractWith(
|
|
page,
|
|
targetIdOrKind,
|
|
{ expectTravel = false } = {}
|
|
) {
|
|
const before = await readCity(page);
|
|
assert.equal(before.dialogue.active, false);
|
|
assert.equal(before.shop.open, false);
|
|
assert.equal(before.choice.open, false);
|
|
const actor = before.actors.find(
|
|
(candidate) =>
|
|
candidate.id === targetIdOrKind || candidate.kind === targetIdOrKind
|
|
);
|
|
const target = actor ?? (
|
|
targetIdOrKind === 'exit' || targetIdOrKind === before.exit?.id
|
|
? {
|
|
...before.exit,
|
|
id: before.exit.id,
|
|
kind: 'exit',
|
|
bounds: before.exit.bounds ?? null
|
|
}
|
|
: null
|
|
);
|
|
assert(
|
|
target,
|
|
`Expected a pointer target for ${targetIdOrKind}: ${JSON.stringify({
|
|
actors: before.actors.map(({ id, kind }) => ({ id, kind })),
|
|
exit: before.exit
|
|
})}`
|
|
);
|
|
assert(
|
|
before.movement?.autoInteraction,
|
|
`CityStayScene must expose automatic pointer interaction state: ${JSON.stringify(before.movement)}`
|
|
);
|
|
const actorPositions = before.actors.map(
|
|
({ id, x, y, initialX, initialY, moved }) => ({
|
|
id,
|
|
x,
|
|
y,
|
|
initialX,
|
|
initialY,
|
|
moved
|
|
})
|
|
);
|
|
assertActorsRemainAuthored(actorPositions, before, `${before.cityStayId} before ${target.id}`);
|
|
|
|
const beforeTriggerCount =
|
|
before.movement.autoInteraction.triggeredCount;
|
|
const playerStart = { x: before.player.x, y: before.player.y };
|
|
if (target.bounds) {
|
|
await clickSceneBounds(page, 'CityStayScene', target.bounds);
|
|
} else {
|
|
await clickScenePoint(page, 'CityStayScene', {
|
|
x: target.x,
|
|
y: target.y
|
|
});
|
|
}
|
|
|
|
let routeObserved = false;
|
|
let maxPlayerDisplacement = 0;
|
|
let after;
|
|
for (let attempt = 0; attempt < 600; attempt += 1) {
|
|
after = await readCity(page);
|
|
assertActorsRemainAuthored(
|
|
actorPositions,
|
|
after,
|
|
`${before.cityStayId} while approaching ${target.id}`
|
|
);
|
|
const autoInteraction = after.movement?.autoInteraction;
|
|
if (
|
|
autoInteraction?.pending === true &&
|
|
autoInteraction.targetId === target.id
|
|
) {
|
|
routeObserved = true;
|
|
}
|
|
if (
|
|
after.movement?.targetId === target.id ||
|
|
after.movement?.waypoints?.length > 0
|
|
) {
|
|
routeObserved = true;
|
|
}
|
|
maxPlayerDisplacement = Math.max(
|
|
maxPlayerDisplacement,
|
|
Math.hypot(
|
|
after.player.x - playerStart.x,
|
|
after.player.y - playerStart.y
|
|
)
|
|
);
|
|
const interactionOpened =
|
|
target.kind === 'market'
|
|
? after.shop.open === true
|
|
: target.kind === 'exit'
|
|
? after.dialogue.active === true &&
|
|
after.dialogue.sourceTargetId === target.id
|
|
: after.dialogue.active === true &&
|
|
after.dialogue.sourceTargetId === target.id;
|
|
if (interactionOpened) {
|
|
break;
|
|
}
|
|
await page.waitForTimeout(50);
|
|
}
|
|
|
|
assert(
|
|
after,
|
|
`Expected CityStayScene state after clicking ${target.id}.`
|
|
);
|
|
const interactionOpened =
|
|
target.kind === 'market'
|
|
? after.shop.open === true
|
|
: after.dialogue.active === true &&
|
|
after.dialogue.sourceTargetId === target.id;
|
|
assert(
|
|
interactionOpened,
|
|
`Pointer click did not automatically interact with ${target.id}: ${JSON.stringify(after)}`
|
|
);
|
|
if (expectTravel) {
|
|
assert(
|
|
routeObserved,
|
|
`Expected pointer navigation state while approaching ${target.id}: ${JSON.stringify(after.movement)}`
|
|
);
|
|
assert(
|
|
maxPlayerDisplacement >= 40,
|
|
`Expected Liu Bei to visibly walk toward ${target.id}: ${JSON.stringify({
|
|
playerStart,
|
|
playerEnd: after.player,
|
|
maxPlayerDisplacement
|
|
})}`
|
|
);
|
|
}
|
|
assert.equal(
|
|
after.movement.autoInteraction.triggeredCount,
|
|
beforeTriggerCount + 1,
|
|
`Pointer interaction with ${target.id} must trigger exactly once.`
|
|
);
|
|
assert.equal(
|
|
after.movement.autoInteraction.lastTriggeredTargetId,
|
|
target.id,
|
|
`Pointer interaction must report ${target.id} as its final automatic target.`
|
|
);
|
|
if (actor) {
|
|
const finalActor = after.actors.find(
|
|
(candidate) => candidate.id === actor.id
|
|
);
|
|
assert(
|
|
finalActor?.distance <= after.interaction.radius + 2,
|
|
`Automatic movement must finish inside ${actor.id}'s interaction radius: ${JSON.stringify(finalActor)}`
|
|
);
|
|
}
|
|
assertActorsRemainAuthored(
|
|
actorPositions,
|
|
after,
|
|
`${before.cityStayId} after ${target.id}`
|
|
);
|
|
}
|
|
|
|
async function advanceCityDialogueUntilClosed(page) {
|
|
for (let attempt = 0; attempt < 16; attempt += 1) {
|
|
const city = await readCity(page);
|
|
if (!city?.dialogue?.active) {
|
|
return;
|
|
}
|
|
verifyDialoguePortraitPolicy(
|
|
city,
|
|
`${city.cityStayId} dialogue line ${city.dialogue.lineIndex + 1}`
|
|
);
|
|
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)}`);
|
|
}
|
|
verifyDialoguePortraitPolicy(
|
|
city,
|
|
`${city.cityStayId} resonance line ${city.dialogue.lineIndex + 1}`
|
|
);
|
|
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 assertBoundsCoverViewport(bounds, label) {
|
|
assert(bounds, `${label}: bounds are required.`);
|
|
const epsilon = 0.01;
|
|
assert(
|
|
Math.abs(bounds.x) <= epsilon &&
|
|
Math.abs(bounds.y) <= epsilon &&
|
|
Math.abs(bounds.width - desktopBrowserViewport.width) <= epsilon &&
|
|
Math.abs(bounds.height - desktopBrowserViewport.height) <= epsilon,
|
|
`${label}: expected a full 1920x1080 display, received ${JSON.stringify(bounds)}`
|
|
);
|
|
}
|
|
|
|
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,
|
|
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.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);
|
|
const requestedRenderer =
|
|
new URL(page.url()).searchParams.get('renderer') ?? 'canvas';
|
|
assert(
|
|
requestedRenderer in rendererTypes,
|
|
`Unknown requested renderer "${requestedRenderer}".`
|
|
);
|
|
assert.equal(
|
|
viewport.rendererType,
|
|
rendererTypes[requestedRenderer],
|
|
`${requestedRenderer}: Phaser must use the requested renderer.`
|
|
);
|
|
}
|
|
|
|
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 clickScenePoint(page, sceneKey, scenePoint) {
|
|
const point = await page.evaluate(({ requestedSceneKey, requestedPoint }) => {
|
|
const scene = window.__HEROS_GAME__?.scene.getScene(requestedSceneKey);
|
|
const canvas = document.querySelector('canvas');
|
|
const canvasBounds = canvas?.getBoundingClientRect();
|
|
if (!scene || !canvasBounds || !requestedPoint) {
|
|
return null;
|
|
}
|
|
return {
|
|
x: canvasBounds.left +
|
|
requestedPoint.x * canvasBounds.width / scene.scale.width,
|
|
y: canvasBounds.top +
|
|
requestedPoint.y * canvasBounds.height / scene.scale.height
|
|
};
|
|
}, {
|
|
requestedSceneKey: sceneKey,
|
|
requestedPoint: scenePoint
|
|
});
|
|
assert(point && Number.isFinite(point.x) && Number.isFinite(point.y));
|
|
await page.mouse.click(point.x, point.y);
|
|
}
|
|
|
|
async function waitForPlayerAt(page, target) {
|
|
await page.waitForFunction(({ x, y }) => {
|
|
const player = window.__HEROS_DEBUG__?.cityStay?.()?.player;
|
|
return Boolean(
|
|
player &&
|
|
Math.abs(player.x - x) <= 9 &&
|
|
Math.abs(player.y - y) <= 9 &&
|
|
player.moving === false
|
|
);
|
|
}, target);
|
|
}
|
|
|
|
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));
|
|
}
|