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) { await waitForCityInteractionIdle( page, fixture.marketActorId ); let interacted = false; for (let attempt = 0; attempt < 8; attempt += 1) { interacted = await page.evaluate((actorId) => window.__HEROS_DEBUG__ ?.scene('CityStayScene') ?.debugInteractWith?.(actorId) ?? false, fixture.marketActorId ); if (interacted) { break; } await page.waitForTimeout(120); } const diagnostic = interacted ? null : await readCityInteractionDiagnostic( page, fixture.marketActorId ); assert.equal( interacted, true, `${renderer}: could not open the city shop: ${JSON.stringify( diagnostic )}` ); 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?.ready === true && city.dialogue?.active === false ) { await waitForCityInteractionIdle(page); return; } if (city?.dialogue?.active === true) { await page.waitForTimeout(150); await page.keyboard.press('e'); } else { await page.waitForTimeout(150); } } throw new Error( `${renderer}: city dialogue did not finish.` ); } async function waitForCityInteractionIdle( page, targetActorId ) { const expected = { targetActorId }; const waitForIdle = () => page.waitForFunction( ({ targetActorId: actorId }) => { const debug = window.__HEROS_DEBUG__; const city = debug?.cityStay?.(); return ( debug ?.activeScenes?.() .includes('CityStayScene') === true && city?.scene === 'CityStayScene' && city.ready === true && city.navigationPending === false && city.dialogue?.active === false && city.shop?.open === false && city.choice?.open === false && city.campaignObjectiveJournal?.view?.visible === false && (!actorId || city.actors?.some( ({ id }) => id === actorId )) ); }, expected, { timeout: 30000 } ); await waitForIdle(); await afterTwoFrames(page); const stable = await readCityInteractionDiagnostic( page, targetActorId ); assert.equal( stable.idle, true, `${renderer}: city interaction became blocked after reaching idle; the closing key may have reopened a modal: ${JSON.stringify( stable )}` ); } async function readCityInteractionDiagnostic( page, targetActorId ) { return page.evaluate((actorId) => { const debug = window.__HEROS_DEBUG__; const scene = debug?.scene('CityStayScene'); const city = debug?.cityStay?.() ?? null; const activeScenes = debug?.activeScenes?.() ?? []; const actor = city?.actors?.find(({ id }) => id === actorId) ?? null; const objectiveJournalOpen = city?.campaignObjectiveJournal?.view?.visible ?? null; const idle = activeScenes.includes('CityStayScene') && city?.scene === 'CityStayScene' && city.ready === true && city.navigationPending === false && city.dialogue?.active === false && city.shop?.open === false && city.choice?.open === false && objectiveJournalOpen === false && (!actorId || Boolean(actor)); return { idle, activeScenes, sceneActive: scene?.scene?.isActive?.() ?? false, scenePaused: scene?.scene?.isPaused?.() ?? false, ready: city?.ready ?? null, navigationPending: city?.navigationPending ?? null, dialogue: city?.dialogue ?? null, shopOpen: city?.shop?.open ?? null, choiceOpen: city?.choice?.open ?? null, objectiveJournalOpen, actor }; }, targetActorId); } 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); } await stopServerProcess(child); 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.exitCode !== null || child.signalCode !== null ) { return; } const exited = new Promise((resolve) => child.once('exit', resolve) ); child.kill(); await Promise.race([exited, delay(5000)]); if ( child.exitCode === null && child.signalCode === null ) { child.kill('SIGKILL'); await Promise.race([exited, delay(2000)]); } } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }