1253 lines
33 KiB
JavaScript
1253 lines
33 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { chromium } from 'playwright';
|
|
import {
|
|
desktopBrowserContextOptions,
|
|
desktopBrowserDeviceScaleFactor,
|
|
desktopBrowserViewport
|
|
} from './desktop-browser-viewport.mjs';
|
|
|
|
const fixture = Object.freeze({
|
|
cityStayId: 'xuzhou',
|
|
afterBattleId: 'seventh-battle-xuzhou-rescue',
|
|
campaignStep: 'seventh-camp',
|
|
partnerId: 'mi-zhu',
|
|
bondId: 'liu-bei__mi-zhu',
|
|
dialogueId: 'city-xuzhou-liu-mi-supply-trust',
|
|
choiceId: 'city-xuzhou-share-storehouse-duty',
|
|
informationActorId: 'xuzhou-information-clerk',
|
|
marketActorId: 'xuzhou-market-smith',
|
|
companionActorId: 'xuzhou-companion-mi-zhu',
|
|
offerId: 'city-xuzhou-iron-spear',
|
|
seedGold: 5000
|
|
});
|
|
const rendererNames = ['canvas', 'webgl'];
|
|
const renderer = process.env.VERIFY_CITY_STAY_CHECKPOINT_RENDERER;
|
|
|
|
if (!renderer) {
|
|
for (const requestedRenderer of rendererNames) {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[fileURLToPath(import.meta.url)],
|
|
{
|
|
cwd: process.cwd(),
|
|
env: {
|
|
...process.env,
|
|
VERIFY_CITY_STAY_CHECKPOINT_RENDERER:
|
|
requestedRenderer
|
|
},
|
|
stdio: 'inherit'
|
|
}
|
|
);
|
|
if (result.status !== 0) {
|
|
process.exit(result.status ?? 1);
|
|
}
|
|
}
|
|
process.exit(0);
|
|
}
|
|
|
|
assert(
|
|
rendererNames.includes(renderer),
|
|
`Unsupported city checkpoint renderer "${renderer}".`
|
|
);
|
|
|
|
const baseUrl =
|
|
process.env.VERIFY_CITY_STAY_CHECKPOINT_URL ??
|
|
'http://127.0.0.1:41810/heros_web/';
|
|
const targetUrl = withDebugOptions(baseUrl, renderer);
|
|
let serverProcess;
|
|
let browser;
|
|
|
|
try {
|
|
serverProcess = await ensureLocalServer(targetUrl);
|
|
browser = await chromium.launch({
|
|
headless:
|
|
process.env.VERIFY_CITY_STAY_CHECKPOINT_HEADLESS !== '0',
|
|
args:
|
|
renderer === 'webgl'
|
|
? [
|
|
'--use-angle=swiftshader',
|
|
'--enable-unsafe-swiftshader'
|
|
]
|
|
: []
|
|
});
|
|
const context = await browser.newContext(
|
|
desktopBrowserContextOptions
|
|
);
|
|
const page = await context.newPage();
|
|
page.setDefaultTimeout(30000);
|
|
|
|
const pageErrors = [];
|
|
const consoleErrors = [];
|
|
page.on('pageerror', (error) =>
|
|
pageErrors.push(error.stack ?? error.message)
|
|
);
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error') {
|
|
consoleErrors.push(message.text());
|
|
}
|
|
});
|
|
|
|
await page.goto(targetUrl, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90000
|
|
});
|
|
await waitForDebugApi(page);
|
|
await assertDesktopRuntime(page, renderer);
|
|
|
|
const seeded = await seedCityStay(page, fixture);
|
|
assert.equal(seeded.step, fixture.campaignStep);
|
|
assert.equal(seeded.latestBattleId, fixture.afterBattleId);
|
|
assert.equal(seeded.activeCityStayId, fixture.cityStayId);
|
|
assert.equal(seeded.gold, fixture.seedGold);
|
|
assert.equal(seeded.checkpoint, null);
|
|
assert.equal(seeded.bondId, fixture.bondId);
|
|
|
|
await page.evaluate(
|
|
async (cityStayId) =>
|
|
window.__HEROS_DEBUG__?.goToCityStay(cityStayId),
|
|
fixture.cityStayId
|
|
);
|
|
await waitForCityReady(page);
|
|
await assertDesktopRuntime(page, renderer);
|
|
|
|
const savedPlayer = await createPlayerCheckpoint(page);
|
|
const playerResume = await reloadAndContinueToCity(page);
|
|
assertExactPlayer(
|
|
playerResume.player,
|
|
savedPlayer,
|
|
`${renderer}: city position resume`
|
|
);
|
|
assertCheckpointPlayer(
|
|
playerResume.explorationCheckpoint,
|
|
savedPlayer,
|
|
`${renderer}: persisted city position`
|
|
);
|
|
|
|
const dialogueBeforeReload =
|
|
await openAndAdvanceInformationDialogue(page);
|
|
const dialogueResume = await reloadAndContinueToCity(page);
|
|
assertExactPlayer(
|
|
dialogueResume.player,
|
|
dialogueBeforeReload.player,
|
|
`${renderer}: city dialogue position resume`
|
|
);
|
|
assertDialogue(
|
|
dialogueResume,
|
|
{
|
|
key: 'city-interaction',
|
|
source: fixture.informationActorId,
|
|
lineIndex: dialogueBeforeReload.dialogue.lineIndex
|
|
},
|
|
`${renderer}: information dialogue resume`
|
|
);
|
|
await page.waitForTimeout(450);
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForFunction(
|
|
() =>
|
|
window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active ===
|
|
false
|
|
);
|
|
|
|
const choiceBeforeReload = await openResonanceChoice(page);
|
|
const choiceResume = await reloadAndContinueToCity(page);
|
|
await waitForChoiceOpen(page);
|
|
const readyChoiceResume = await readCity(page);
|
|
assertExactPlayer(
|
|
readyChoiceResume.player,
|
|
choiceBeforeReload.player,
|
|
`${renderer}: city resonance choice position resume`
|
|
);
|
|
assert.equal(readyChoiceResume.choice.open, true);
|
|
assert.deepEqual(
|
|
readyChoiceResume.explorationCheckpoint?.overlay,
|
|
{
|
|
type: 'choice',
|
|
mode: 'city-resonance',
|
|
sourceNpcId: fixture.companionActorId
|
|
},
|
|
`${renderer}: city resonance choice checkpoint`
|
|
);
|
|
assert.equal(choiceResume.cityStayId, fixture.cityStayId);
|
|
|
|
const rewardBefore = await readCityActionSnapshot(page);
|
|
const selectedChoice =
|
|
readyChoiceResume.choice.choices.find(
|
|
({ id }) => id === fixture.choiceId
|
|
);
|
|
assert(
|
|
selectedChoice?.interactive,
|
|
`${renderer}: resonance choice is not interactive.`
|
|
);
|
|
await clickBounds(page, selectedChoice.bounds);
|
|
await page.waitForFunction(
|
|
({ key, source }) => {
|
|
const city = window.__HEROS_DEBUG__?.cityStay?.();
|
|
return (
|
|
city?.dialogue?.active === true &&
|
|
city.dialogue.dialogueKey === key &&
|
|
city.dialogue.sourceTargetId === source
|
|
);
|
|
},
|
|
{
|
|
key: 'city-resonance-response',
|
|
source: fixture.companionActorId
|
|
}
|
|
);
|
|
const rewardAfterChoice = await readCityActionSnapshot(page);
|
|
assert.equal(
|
|
rewardAfterChoice.completedDialogueCount,
|
|
rewardBefore.completedDialogueCount + 1,
|
|
`${renderer}: resonance completion must be recorded once.`
|
|
);
|
|
assert.equal(
|
|
rewardAfterChoice.choiceId,
|
|
fixture.choiceId,
|
|
`${renderer}: resonance choice id was not saved.`
|
|
);
|
|
assert.notDeepEqual(
|
|
rewardAfterChoice.campaignBond,
|
|
rewardBefore.campaignBond,
|
|
`${renderer}: resonance reward did not change the campaign bond.`
|
|
);
|
|
assert.deepEqual(
|
|
rewardAfterChoice.campaignBond,
|
|
rewardAfterChoice.reportBond,
|
|
`${renderer}: campaign and report bond ledgers diverged.`
|
|
);
|
|
|
|
const responseBeforeReload = await readCity(page);
|
|
const responseResume = await reloadAndContinueToCity(page);
|
|
assertDialogue(
|
|
responseResume,
|
|
{
|
|
key: 'city-resonance-response',
|
|
source: fixture.companionActorId,
|
|
lineIndex: responseBeforeReload.dialogue.lineIndex
|
|
},
|
|
`${renderer}: resonance response resume`
|
|
);
|
|
assert.deepEqual(
|
|
await readCityActionSnapshot(page),
|
|
rewardAfterChoice,
|
|
`${renderer}: reloading the resonance response duplicated its reward.`
|
|
);
|
|
await finishCityDialogue(page);
|
|
|
|
const shopBeforeReload = await openShop(page);
|
|
const shopResume = await reloadAndContinueToCity(page);
|
|
await waitForShopOpen(page);
|
|
const readyShopResume = await readCity(page);
|
|
assertExactPlayer(
|
|
readyShopResume.player,
|
|
shopBeforeReload.player,
|
|
`${renderer}: city shop position resume`
|
|
);
|
|
assert.equal(shopResume.cityStayId, fixture.cityStayId);
|
|
assert.equal(readyShopResume.shop.open, true);
|
|
assert.deepEqual(
|
|
readyShopResume.explorationCheckpoint?.overlay,
|
|
{
|
|
type: 'shop',
|
|
sourceNpcId: fixture.marketActorId
|
|
},
|
|
`${renderer}: city shop checkpoint`
|
|
);
|
|
|
|
const purchase = await replayPurchaseIntent(page, fixture);
|
|
assert.equal(purchase.first.ok, true);
|
|
assert.equal(purchase.first.replayed, false);
|
|
assert.equal(purchase.second.ok, true);
|
|
assert.equal(purchase.second.replayed, true);
|
|
assert.equal(
|
|
purchase.after.gold,
|
|
purchase.before.gold - purchase.first.price,
|
|
`${renderer}: a replayed purchase charged gold more than once.`
|
|
);
|
|
assert.equal(
|
|
purchase.after.inventoryTotal,
|
|
purchase.before.inventoryTotal + 1,
|
|
`${renderer}: a replayed purchase granted more than one item.`
|
|
);
|
|
assert.equal(
|
|
purchase.after.purchaseCount,
|
|
purchase.before.purchaseCount + 1,
|
|
`${renderer}: a replayed purchase incremented its count more than once.`
|
|
);
|
|
assert.equal(purchase.after.receiptCount, 1);
|
|
|
|
await reloadAndContinueToCity(page);
|
|
await waitForShopOpen(page);
|
|
assert.deepEqual(
|
|
await readPurchaseSnapshot(page, fixture),
|
|
purchase.after,
|
|
`${renderer}: shop reload changed the committed purchase.`
|
|
);
|
|
|
|
await page.waitForTimeout(450);
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForFunction(
|
|
() =>
|
|
window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open ===
|
|
false
|
|
);
|
|
const exitOpened = await page.evaluate((targetId) =>
|
|
window.__HEROS_DEBUG__
|
|
?.scene('CityStayScene')
|
|
?.debugInteractWith?.(targetId) ?? false,
|
|
'camp-exit'
|
|
);
|
|
assert.equal(
|
|
exitOpened,
|
|
true,
|
|
`${renderer}: could not open the city exit.`
|
|
);
|
|
await page.waitForFunction(
|
|
() => {
|
|
const city = window.__HEROS_DEBUG__?.cityStay?.();
|
|
return (
|
|
city?.dialogue?.active === true &&
|
|
city.dialogue.dialogueKey === 'city-exit'
|
|
);
|
|
}
|
|
);
|
|
await finishCityDialogueToCamp(page);
|
|
|
|
const exited = await readCampaignCheckpoint(page);
|
|
assert.equal(exited.activeCityStayId, null);
|
|
assert.equal(
|
|
exited.checkpoint,
|
|
null,
|
|
`${renderer}: normal city exit left a stale checkpoint.`
|
|
);
|
|
|
|
await reloadToTitle(page);
|
|
await clickContinue(page);
|
|
await page.waitForFunction(
|
|
() =>
|
|
window.__HEROS_DEBUG__
|
|
?.activeScenes?.()
|
|
.includes('CampScene'),
|
|
undefined,
|
|
{ timeout: 90000 }
|
|
);
|
|
assert(
|
|
!(await page.evaluate(() =>
|
|
window.__HEROS_DEBUG__
|
|
?.activeScenes?.()
|
|
.includes('CityStayScene')
|
|
)),
|
|
`${renderer}: Continue reopened a normally exited city stay.`
|
|
);
|
|
assert.equal(
|
|
(await readCampaignCheckpoint(page)).checkpoint,
|
|
null
|
|
);
|
|
|
|
const actionableConsoleErrors = consoleErrors.filter(
|
|
(message) =>
|
|
!message.includes(
|
|
'The AudioContext was not allowed to start'
|
|
)
|
|
);
|
|
assert.deepEqual(
|
|
pageErrors,
|
|
[],
|
|
`${renderer}: page errors: ${pageErrors.join('\n')}`
|
|
);
|
|
assert.deepEqual(
|
|
actionableConsoleErrors,
|
|
[],
|
|
`${renderer}: console errors: ${actionableConsoleErrors.join(
|
|
'\n'
|
|
)}`
|
|
);
|
|
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
renderer,
|
|
viewport: {
|
|
...desktopBrowserViewport,
|
|
deviceScaleFactor:
|
|
desktopBrowserDeviceScaleFactor,
|
|
browserZoom: '100%'
|
|
},
|
|
cityStayId: fixture.cityStayId,
|
|
checkpoint: {
|
|
exactPlayerResume: savedPlayer,
|
|
dialogueLineResume: true,
|
|
resonanceChoiceResume: true,
|
|
resonanceRewardCommittedOnce: true,
|
|
responseReloadStable: true,
|
|
shopResume: true,
|
|
purchaseIntentReplayedSafely: true,
|
|
normalExitCleared: true
|
|
},
|
|
pageErrors: pageErrors.length,
|
|
consoleErrors: actionableConsoleErrors.length
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
);
|
|
} finally {
|
|
await browser?.close();
|
|
await stopServerProcess(serverProcess);
|
|
}
|
|
|
|
function withDebugOptions(url, requestedRenderer) {
|
|
const parsed = new URL(url);
|
|
parsed.searchParams.set('debug', '1');
|
|
parsed.searchParams.set('renderer', requestedRenderer);
|
|
return parsed.toString();
|
|
}
|
|
|
|
async function waitForDebugApi(page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
document.querySelector('canvas') !== null &&
|
|
window.__HEROS_GAME__ !== undefined &&
|
|
window.__HEROS_DEBUG__ !== undefined,
|
|
undefined,
|
|
{ timeout: 90000 }
|
|
);
|
|
}
|
|
|
|
async function assertDesktopRuntime(page, expectedRenderer) {
|
|
const runtime = await page.evaluate(() => {
|
|
const canvas = document.querySelector('canvas');
|
|
const bounds = canvas?.getBoundingClientRect();
|
|
return {
|
|
innerWidth: window.innerWidth,
|
|
innerHeight: window.innerHeight,
|
|
devicePixelRatio: window.devicePixelRatio,
|
|
visualViewportScale:
|
|
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(runtime.innerWidth, desktopBrowserViewport.width);
|
|
assert.equal(runtime.innerHeight, desktopBrowserViewport.height);
|
|
assert.equal(
|
|
runtime.devicePixelRatio,
|
|
desktopBrowserDeviceScaleFactor
|
|
);
|
|
assert.equal(runtime.visualViewportScale, 1);
|
|
assert.equal(
|
|
runtime.rendererType,
|
|
expectedRenderer === 'canvas' ? 1 : 2
|
|
);
|
|
assert.equal(
|
|
runtime.canvas?.width,
|
|
desktopBrowserViewport.width
|
|
);
|
|
assert.equal(
|
|
runtime.canvas?.height,
|
|
desktopBrowserViewport.height
|
|
);
|
|
assert.equal(
|
|
runtime.canvas?.clientWidth,
|
|
desktopBrowserViewport.width
|
|
);
|
|
assert.equal(
|
|
runtime.canvas?.clientHeight,
|
|
desktopBrowserViewport.height
|
|
);
|
|
assert.equal(
|
|
runtime.canvas?.bounds?.width,
|
|
desktopBrowserViewport.width
|
|
);
|
|
assert.equal(
|
|
runtime.canvas?.bounds?.height,
|
|
desktopBrowserViewport.height
|
|
);
|
|
}
|
|
|
|
async function seedCityStay(page, seed) {
|
|
return page.evaluate(async (requested) => {
|
|
const campaignModule = await import(
|
|
'/heros_web/src/game/state/campaignState.ts'
|
|
);
|
|
const { battleScenarios } = await import(
|
|
'/heros_web/src/game/data/battles.ts'
|
|
);
|
|
const scenario = battleScenarios[requested.afterBattleId];
|
|
if (!scenario) {
|
|
throw new Error(
|
|
`Missing battle scenario ${requested.afterBattleId}.`
|
|
);
|
|
}
|
|
const template =
|
|
scenario.units.find(({ id }) => id === 'liu-bei') ??
|
|
scenario.units[0];
|
|
if (!template) {
|
|
throw new Error('The city seed has no unit template.');
|
|
}
|
|
|
|
campaignModule.resetCampaignState();
|
|
const state = campaignModule.createInitialCampaignState();
|
|
const liuBei = {
|
|
...structuredClone(template),
|
|
id: 'liu-bei',
|
|
name: 'Liu Bei'
|
|
};
|
|
const partner = {
|
|
...structuredClone(template),
|
|
id: requested.partnerId,
|
|
name: requested.partnerId
|
|
};
|
|
const bond = {
|
|
id: requested.bondId,
|
|
unitIds: ['liu-bei', requested.partnerId],
|
|
title: `${requested.cityStayId}-verification`,
|
|
level: 1,
|
|
exp: 0,
|
|
battleExp: 0,
|
|
description: 'City checkpoint verification bond.'
|
|
};
|
|
const report = {
|
|
battleId: scenario.id,
|
|
battleTitle: scenario.title,
|
|
outcome: 'victory',
|
|
turnNumber: 6,
|
|
rewardGold: 0,
|
|
defeatedEnemies: scenario.units.filter(
|
|
({ faction }) => faction === 'enemy'
|
|
).length,
|
|
totalEnemies: scenario.units.filter(
|
|
({ faction }) => faction === 'enemy'
|
|
).length,
|
|
itemRewards: [],
|
|
objectives: [],
|
|
units: [liuBei, partner],
|
|
bonds: [bond],
|
|
completedCampDialogues: [],
|
|
completedCampVisits: [],
|
|
createdAt: '2026-07-28T00:00:00.000Z'
|
|
};
|
|
Object.assign(state, {
|
|
step: requested.campaignStep,
|
|
gold: requested.seedGold,
|
|
roster: [liuBei, partner],
|
|
bonds: [bond],
|
|
latestBattleId: requested.afterBattleId,
|
|
firstBattleReport: report,
|
|
battleHistory: {
|
|
[requested.afterBattleId]: {
|
|
battleId: requested.afterBattleId,
|
|
battleTitle: scenario.title,
|
|
outcome: 'victory',
|
|
turnNumber: report.turnNumber,
|
|
rewardGold: 0,
|
|
itemRewards: [],
|
|
objectives: [],
|
|
units: [],
|
|
bonds: [],
|
|
completedAt: report.createdAt
|
|
}
|
|
},
|
|
activeCityStayId: requested.cityStayId,
|
|
acknowledgedVictoryRewardBattleIds: [
|
|
requested.afterBattleId
|
|
],
|
|
dismissedVictoryRewardNoticeBattleIds: [
|
|
requested.afterBattleId
|
|
]
|
|
});
|
|
const saved = campaignModule.setCampaignState(state);
|
|
return {
|
|
step: saved.step,
|
|
latestBattleId: saved.latestBattleId ?? null,
|
|
activeCityStayId:
|
|
saved.activeCityStayId ?? null,
|
|
gold: saved.gold,
|
|
checkpoint:
|
|
saved.explorationCheckpoint ?? null,
|
|
bondId:
|
|
saved.bonds.find(
|
|
({ id }) => id === requested.bondId
|
|
)?.id ?? null
|
|
};
|
|
}, seed);
|
|
}
|
|
|
|
async function waitForCityReady(page) {
|
|
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
|
|
);
|
|
},
|
|
fixture.cityStayId,
|
|
{ timeout: 90000 }
|
|
);
|
|
} catch (error) {
|
|
const diagnostic = await page.evaluate(() => ({
|
|
activeScenes:
|
|
window.__HEROS_DEBUG__?.activeScenes?.() ?? [],
|
|
city:
|
|
window.__HEROS_DEBUG__?.cityStay?.() ?? null,
|
|
title:
|
|
window.__HEROS_DEBUG__?.title?.() ?? null
|
|
}));
|
|
throw new Error(
|
|
`${renderer}: city stay did not become ready: ` +
|
|
JSON.stringify(diagnostic),
|
|
{ cause: error }
|
|
);
|
|
}
|
|
await afterTwoFrames(page);
|
|
return readCity(page);
|
|
}
|
|
|
|
async function readCity(page) {
|
|
return page.evaluate(
|
|
() => window.__HEROS_DEBUG__?.cityStay?.() ?? null
|
|
);
|
|
}
|
|
|
|
async function createPlayerCheckpoint(page) {
|
|
await page.waitForTimeout(450);
|
|
const before = await readCity(page);
|
|
await page.keyboard.down('ArrowRight');
|
|
await page.waitForTimeout(260);
|
|
await page.keyboard.up('ArrowRight');
|
|
await page.waitForFunction(
|
|
() => {
|
|
const city = window.__HEROS_DEBUG__?.cityStay?.();
|
|
const player = city?.player;
|
|
const checkpoint = city?.explorationCheckpoint;
|
|
return (
|
|
player?.moving === false &&
|
|
player?.direction === 'east' &&
|
|
checkpoint?.scene === 'city-stay' &&
|
|
checkpoint?.contextId === 'xuzhou' &&
|
|
checkpoint?.overlay === undefined &&
|
|
Math.abs(checkpoint.player.x - player.x) <= 0.05 &&
|
|
Math.abs(checkpoint.player.y - player.y) <= 0.05 &&
|
|
checkpoint.player.direction === player.direction
|
|
);
|
|
},
|
|
undefined,
|
|
{ timeout: 10000 }
|
|
);
|
|
const city = await readCity(page);
|
|
assert(
|
|
city.player.x > before.player.x + 20,
|
|
`${renderer}: keyboard movement did not create a distinct checkpoint.`
|
|
);
|
|
return exactPlayer(city.player);
|
|
}
|
|
|
|
async function openAndAdvanceInformationDialogue(page) {
|
|
const interacted = await page.evaluate((actorId) =>
|
|
window.__HEROS_DEBUG__
|
|
?.scene('CityStayScene')
|
|
?.debugInteractWith?.(actorId) ?? false,
|
|
fixture.informationActorId
|
|
);
|
|
assert.equal(
|
|
interacted,
|
|
true,
|
|
`${renderer}: could not open information dialogue.`
|
|
);
|
|
await waitForDialogue(page, {
|
|
key: 'city-interaction',
|
|
source: fixture.informationActorId,
|
|
lineIndex: 0
|
|
});
|
|
await page.waitForTimeout(320);
|
|
await page.keyboard.press('e');
|
|
await waitForDialogue(page, {
|
|
key: 'city-interaction',
|
|
source: fixture.informationActorId,
|
|
lineIndex: 1
|
|
});
|
|
const city = await readCity(page);
|
|
assertDialogue(
|
|
city,
|
|
{
|
|
key: 'city-interaction',
|
|
source: fixture.informationActorId,
|
|
lineIndex: 1
|
|
},
|
|
`${renderer}: saved information dialogue`
|
|
);
|
|
return {
|
|
...city,
|
|
player: exactPlayer(city.player)
|
|
};
|
|
}
|
|
|
|
async function openResonanceChoice(page) {
|
|
const interacted = await page.evaluate((actorId) =>
|
|
window.__HEROS_DEBUG__
|
|
?.scene('CityStayScene')
|
|
?.debugInteractWith?.(actorId) ?? false,
|
|
fixture.companionActorId
|
|
);
|
|
assert.equal(
|
|
interacted,
|
|
true,
|
|
`${renderer}: could not open companion dialogue.`
|
|
);
|
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
const city = await readCity(page);
|
|
if (city?.choice?.open) {
|
|
assert.deepEqual(
|
|
city.explorationCheckpoint?.overlay,
|
|
{
|
|
type: 'choice',
|
|
mode: 'city-resonance',
|
|
sourceNpcId: fixture.companionActorId
|
|
}
|
|
);
|
|
return {
|
|
...city,
|
|
player: exactPlayer(city.player)
|
|
};
|
|
}
|
|
assert.equal(
|
|
city?.dialogue?.active,
|
|
true,
|
|
`${renderer}: companion dialogue closed before choice.`
|
|
);
|
|
await page.waitForTimeout(170);
|
|
await page.keyboard.press('e');
|
|
}
|
|
throw new Error(
|
|
`${renderer}: resonance choice did not open.`
|
|
);
|
|
}
|
|
|
|
async function openShop(page) {
|
|
const interacted = await page.evaluate((actorId) =>
|
|
window.__HEROS_DEBUG__
|
|
?.scene('CityStayScene')
|
|
?.debugInteractWith?.(actorId) ?? false,
|
|
fixture.marketActorId
|
|
);
|
|
assert.equal(
|
|
interacted,
|
|
true,
|
|
`${renderer}: could not open the city shop.`
|
|
);
|
|
await waitForShopOpen(page);
|
|
const city = await readCity(page);
|
|
assert.deepEqual(city.explorationCheckpoint?.overlay, {
|
|
type: 'shop',
|
|
sourceNpcId: fixture.marketActorId
|
|
});
|
|
return {
|
|
...city,
|
|
player: exactPlayer(city.player)
|
|
};
|
|
}
|
|
|
|
async function waitForDialogue(
|
|
page,
|
|
{ key, source, lineIndex }
|
|
) {
|
|
await page.waitForFunction(
|
|
(expected) => {
|
|
const city = window.__HEROS_DEBUG__?.cityStay?.();
|
|
return (
|
|
city?.dialogue?.active === true &&
|
|
city.dialogue.dialogueKey === expected.key &&
|
|
city.dialogue.sourceTargetId === expected.source &&
|
|
city.dialogue.lineIndex === expected.lineIndex &&
|
|
city.explorationCheckpoint?.overlay?.type ===
|
|
'dialogue' &&
|
|
city.explorationCheckpoint.overlay.dialogueKey ===
|
|
expected.key &&
|
|
city.explorationCheckpoint.overlay.sourceNpcId ===
|
|
expected.source &&
|
|
city.explorationCheckpoint.overlay.lineIndex ===
|
|
expected.lineIndex
|
|
);
|
|
},
|
|
{ key, source, lineIndex }
|
|
);
|
|
}
|
|
|
|
async function waitForChoiceOpen(page) {
|
|
await page.waitForFunction(
|
|
() => {
|
|
const city = window.__HEROS_DEBUG__?.cityStay?.();
|
|
return (
|
|
city?.choice?.open === true &&
|
|
city.explorationCheckpoint?.overlay?.type ===
|
|
'choice' &&
|
|
city.explorationCheckpoint.overlay.mode ===
|
|
'city-resonance'
|
|
);
|
|
},
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
}
|
|
|
|
async function waitForShopOpen(page) {
|
|
await page.waitForFunction(
|
|
() => {
|
|
const city = window.__HEROS_DEBUG__?.cityStay?.();
|
|
return (
|
|
city?.shop?.open === true &&
|
|
city.explorationCheckpoint?.overlay?.type ===
|
|
'shop'
|
|
);
|
|
},
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
}
|
|
|
|
function assertDialogue(
|
|
city,
|
|
{ key, source, lineIndex },
|
|
label
|
|
) {
|
|
assert.equal(city?.dialogue?.active, true, label);
|
|
assert.equal(city.dialogue.dialogueKey, key, label);
|
|
assert.equal(
|
|
city.dialogue.sourceTargetId,
|
|
source,
|
|
label
|
|
);
|
|
assert.equal(city.dialogue.lineIndex, lineIndex, label);
|
|
assert.deepEqual(
|
|
city.explorationCheckpoint?.overlay,
|
|
{
|
|
type: 'dialogue',
|
|
dialogueKey: key,
|
|
sourceNpcId: source,
|
|
lineIndex
|
|
},
|
|
label
|
|
);
|
|
}
|
|
|
|
async function finishCityDialogue(page) {
|
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
const city = await readCity(page);
|
|
if (!city?.dialogue?.active) {
|
|
return;
|
|
}
|
|
await page.waitForTimeout(150);
|
|
await page.keyboard.press('e');
|
|
}
|
|
throw new Error(
|
|
`${renderer}: city dialogue did not finish.`
|
|
);
|
|
}
|
|
|
|
async function finishCityDialogueToCamp(page) {
|
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
|
const activeScenes = await page.evaluate(
|
|
() => window.__HEROS_DEBUG__?.activeScenes?.() ?? []
|
|
);
|
|
if (activeScenes.includes('CampScene')) {
|
|
return;
|
|
}
|
|
const city = await readCity(page);
|
|
if (!city?.dialogue?.active) {
|
|
assert.equal(
|
|
city?.navigationPending,
|
|
true,
|
|
`${renderer}: city exit dialogue disappeared without starting its transition.`
|
|
);
|
|
await page.waitForTimeout(180);
|
|
continue;
|
|
}
|
|
await page.waitForTimeout(170);
|
|
await page.keyboard.press('e');
|
|
}
|
|
await page.waitForFunction(
|
|
() =>
|
|
window.__HEROS_DEBUG__
|
|
?.activeScenes?.()
|
|
.includes('CampScene'),
|
|
undefined,
|
|
{ timeout: 90000 }
|
|
);
|
|
}
|
|
|
|
async function readCityActionSnapshot(page) {
|
|
return page.evaluate(async ({ bondId, dialogueId }) => {
|
|
const { getCampaignState } = await import(
|
|
'/heros_web/src/game/state/campaignState.ts'
|
|
);
|
|
const campaign = getCampaignState();
|
|
const campaignBond = campaign.bonds.find(
|
|
({ id }) => id === bondId
|
|
);
|
|
const reportBond = campaign.firstBattleReport?.bonds.find(
|
|
({ id }) => id === bondId
|
|
);
|
|
const simplifyBond = (bond) =>
|
|
bond
|
|
? {
|
|
id: bond.id,
|
|
level: bond.level,
|
|
exp: bond.exp,
|
|
battleExp: bond.battleExp
|
|
}
|
|
: null;
|
|
return {
|
|
completedDialogueCount:
|
|
campaign.completedCampDialogues.filter(
|
|
(id) => id === dialogueId
|
|
).length,
|
|
choiceId:
|
|
campaign.campDialogueChoiceIds[dialogueId] ?? null,
|
|
campaignBond: simplifyBond(campaignBond),
|
|
reportBond: simplifyBond(reportBond)
|
|
};
|
|
}, {
|
|
bondId: fixture.bondId,
|
|
dialogueId: fixture.dialogueId
|
|
});
|
|
}
|
|
|
|
async function replayPurchaseIntent(page, seed) {
|
|
return page.evaluate(async (requested) => {
|
|
const actions = await import(
|
|
'/heros_web/src/game/state/cityStayActions.ts'
|
|
);
|
|
const campaignModule = await import(
|
|
'/heros_web/src/game/state/campaignState.ts'
|
|
);
|
|
const intentId =
|
|
`qa-${requested.cityStayId}-${requested.offerId}-intent`;
|
|
const snapshot = (campaign) => ({
|
|
gold: campaign.gold,
|
|
inventoryTotal: Object.values(
|
|
campaign.inventory
|
|
).reduce((sum, amount) => sum + amount, 0),
|
|
purchaseCount:
|
|
campaign.cityEquipmentPurchaseCounts[
|
|
requested.offerId
|
|
] ?? 0,
|
|
receiptCount: Object.values(
|
|
campaign.cityEquipmentPurchaseReceipts
|
|
).filter(
|
|
(receipt) => receipt.intentId === intentId
|
|
).length
|
|
});
|
|
const before = snapshot(
|
|
campaignModule.getCampaignState()
|
|
);
|
|
const first = actions.purchaseCityStayEquipment(
|
|
requested.cityStayId,
|
|
requested.offerId,
|
|
intentId
|
|
);
|
|
const second = actions.purchaseCityStayEquipment(
|
|
requested.cityStayId,
|
|
requested.offerId,
|
|
intentId
|
|
);
|
|
const after = snapshot(
|
|
campaignModule.getCampaignState()
|
|
);
|
|
return {
|
|
before,
|
|
first: first.ok
|
|
? {
|
|
ok: true,
|
|
replayed: first.replayed,
|
|
price: first.offer.price,
|
|
purchaseNumber: first.purchaseNumber
|
|
}
|
|
: first,
|
|
second: second.ok
|
|
? {
|
|
ok: true,
|
|
replayed: second.replayed,
|
|
price: second.offer.price,
|
|
purchaseNumber: second.purchaseNumber
|
|
}
|
|
: second,
|
|
after
|
|
};
|
|
}, seed);
|
|
}
|
|
|
|
async function readPurchaseSnapshot(page, seed) {
|
|
return page.evaluate(
|
|
async ({ cityStayId, offerId }) => {
|
|
const { getCampaignState } = await import(
|
|
'/heros_web/src/game/state/campaignState.ts'
|
|
);
|
|
const intentId =
|
|
`qa-${cityStayId}-${offerId}-intent`;
|
|
const campaign = getCampaignState();
|
|
return {
|
|
gold: campaign.gold,
|
|
inventoryTotal: Object.values(
|
|
campaign.inventory
|
|
).reduce((sum, amount) => sum + amount, 0),
|
|
purchaseCount:
|
|
campaign.cityEquipmentPurchaseCounts[offerId] ??
|
|
0,
|
|
receiptCount: Object.values(
|
|
campaign.cityEquipmentPurchaseReceipts
|
|
).filter(
|
|
(receipt) => receipt.intentId === intentId
|
|
).length
|
|
};
|
|
},
|
|
{
|
|
cityStayId: seed.cityStayId,
|
|
offerId: seed.offerId
|
|
}
|
|
);
|
|
}
|
|
|
|
async function readCampaignCheckpoint(page) {
|
|
return page.evaluate(async () => {
|
|
const { getCampaignState } = await import(
|
|
'/heros_web/src/game/state/campaignState.ts'
|
|
);
|
|
const campaign = getCampaignState();
|
|
return {
|
|
step: campaign.step,
|
|
activeCityStayId:
|
|
campaign.activeCityStayId ?? null,
|
|
checkpoint:
|
|
campaign.explorationCheckpoint ?? null
|
|
};
|
|
});
|
|
}
|
|
|
|
async function reloadAndContinueToCity(page) {
|
|
await reloadToTitle(page);
|
|
await clickContinue(page);
|
|
return waitForCityReady(page);
|
|
}
|
|
|
|
async function reloadToTitle(page) {
|
|
await page.reload({
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90000
|
|
});
|
|
await waitForDebugApi(page);
|
|
await page.waitForFunction(
|
|
() => {
|
|
const debug = window.__HEROS_DEBUG__;
|
|
const title = debug?.title?.();
|
|
const continueItem = title?.focus?.items?.find(
|
|
({ id }) => id === 'continue'
|
|
);
|
|
return (
|
|
debug?.activeScenes?.().includes('TitleScene') &&
|
|
title?.scene === 'TitleScene' &&
|
|
title?.navigating === false &&
|
|
title?.focus?.scope === 'main' &&
|
|
continueItem?.enabled === true &&
|
|
continueItem?.bounds
|
|
);
|
|
},
|
|
undefined,
|
|
{ timeout: 90000 }
|
|
);
|
|
await afterTwoFrames(page);
|
|
}
|
|
|
|
async function clickContinue(page) {
|
|
const item = await page.evaluate(
|
|
() =>
|
|
window.__HEROS_DEBUG__
|
|
?.title?.()
|
|
?.focus?.items?.find(
|
|
({ id }) => id === 'continue'
|
|
) ?? null
|
|
);
|
|
assert.equal(
|
|
item?.enabled,
|
|
true,
|
|
`${renderer}: Continue is unavailable.`
|
|
);
|
|
await clickBounds(page, item.bounds);
|
|
}
|
|
|
|
async function clickBounds(page, bounds) {
|
|
assertFiniteBounds(bounds);
|
|
assert(
|
|
bounds.x >= 0 &&
|
|
bounds.y >= 0 &&
|
|
bounds.x + bounds.width <=
|
|
desktopBrowserViewport.width &&
|
|
bounds.y + bounds.height <=
|
|
desktopBrowserViewport.height,
|
|
`Bounds are outside the FHD viewport: ${JSON.stringify(
|
|
bounds
|
|
)}`
|
|
);
|
|
await page.mouse.click(
|
|
bounds.x + bounds.width / 2,
|
|
bounds.y + bounds.height / 2
|
|
);
|
|
}
|
|
|
|
function exactPlayer(player) {
|
|
assert(player, 'Player debug state is required.');
|
|
assert(Number.isFinite(player.x));
|
|
assert(Number.isFinite(player.y));
|
|
assert(
|
|
['north', 'south', 'east', 'west'].includes(
|
|
player.direction
|
|
)
|
|
);
|
|
return {
|
|
x: player.x,
|
|
y: player.y,
|
|
direction: player.direction
|
|
};
|
|
}
|
|
|
|
function assertExactPlayer(actual, expected, label) {
|
|
assert(actual, label);
|
|
assert(
|
|
Math.abs(actual.x - expected.x) <= 0.05,
|
|
`${label}: x ${actual.x} !== ${expected.x}`
|
|
);
|
|
assert(
|
|
Math.abs(actual.y - expected.y) <= 0.05,
|
|
`${label}: y ${actual.y} !== ${expected.y}`
|
|
);
|
|
assert.equal(actual.direction, expected.direction, label);
|
|
}
|
|
|
|
function assertCheckpointPlayer(checkpoint, expected, label) {
|
|
assert.equal(checkpoint?.version, 1, label);
|
|
assert.equal(checkpoint?.scene, 'city-stay', label);
|
|
assert.equal(
|
|
checkpoint?.contextId,
|
|
fixture.cityStayId,
|
|
label
|
|
);
|
|
assertExactPlayer(checkpoint?.player, expected, label);
|
|
}
|
|
|
|
function assertFiniteBounds(bounds) {
|
|
assert(bounds, 'Interactive bounds are required.');
|
|
for (const value of [
|
|
bounds.x,
|
|
bounds.y,
|
|
bounds.width,
|
|
bounds.height
|
|
]) {
|
|
assert(Number.isFinite(value));
|
|
}
|
|
assert(bounds.width > 0);
|
|
assert(bounds.height > 0);
|
|
}
|
|
|
|
async function afterTwoFrames(page) {
|
|
await page.evaluate(
|
|
() =>
|
|
new Promise((resolve) =>
|
|
requestAnimationFrame(() =>
|
|
requestAnimationFrame(resolve)
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
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 || '41810',
|
|
'--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 < 160; 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;
|
|
}
|
|
}
|
|
|
|
async function stopServerProcess(child) {
|
|
if (!child || child.killed) {
|
|
return;
|
|
}
|
|
child.kill();
|
|
await delay(80);
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|