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

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));
}