import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { mkdirSync } from 'node:fs'; import { chromium } from 'playwright'; import { desktopBrowserContextOptions, desktopBrowserDeviceScaleFactor, desktopBrowserViewport } from './desktop-browser-viewport.mjs'; const seedGold = 5000; const cityCases = [ { cityStayId: 'xuzhou', afterBattleId: 'seventh-battle-xuzhou-rescue', nextBattleId: 'eighth-battle-xiaopei-supply-road', campStep: 'seventh-camp', partnerId: 'mi-zhu', bondId: 'liu-bei__mi-zhu', informationId: 'city-xuzhou-xiaopei-ledger', dialogueId: 'city-xuzhou-liu-mi-supply-trust' }, { cityStayId: 'xinye', afterBattleId: 'seventeenth-battle-wolong-visit-road', nextBattleId: 'eighteenth-battle-bowang-ambush', campStep: 'seventeenth-camp', partnerId: 'zhuge-liang', bondId: 'liu-bei__zhuge-liang', informationId: 'city-xinye-bowang-scout-map', dialogueId: 'city-xinye-liu-zhuge-first-command' }, { cityStayId: 'chengdu', afterBattleId: 'thirty-third-battle-chengdu-surrender', nextBattleId: 'thirty-fourth-battle-jiameng-pass', campStep: 'thirty-third-camp', partnerId: 'huang-quan', bondId: 'liu-bei__huang-quan', informationId: 'city-chengdu-jiameng-route-ledger', dialogueId: 'city-chengdu-liu-huang-quan-order' } ]; const [xuzhouCase, xinyeCase, chengduCase] = cityCases; const targetUrl = withDebugOptions( process.env.VERIFY_CITY_STAY_URL ?? 'http://127.0.0.1:41795/' ); let serverProcess; let browser; try { mkdirSync('dist', { recursive: true }); serverProcess = await ensureLocalServer(targetUrl); browser = await chromium.launch({ headless: process.env.VERIFY_CITY_STAY_HEADLESS !== '0' }); const context = await browser.newContext(desktopBrowserContextOptions); const page = await context.newPage(); page.setDefaultTimeout(30000); const pageErrors = []; const consoleErrors = []; page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message)); page.on('console', (message) => { if (message.type() === 'error') { consoleErrors.push(message.text()); } }); await openCleanCamp(page); await assertDesktopViewport(page); await seedCityStay(page, xuzhouCase); await restartCamp(page, xuzhouCase.cityStayId); const xuzhouGateway = await waitForCityGateway(page, xuzhouCase.cityStayId); verifyGatewayLayout(xuzhouGateway); await captureStableScreenshot(page, 'dist/verification-city-xuzhou-gateway.png'); let city = await enterCityFromGateway(page, xuzhouGateway, xuzhouCase.cityStayId); verifyExplorationLayout(city, xuzhouCase.cityStayId); await captureStableScreenshot(page, 'dist/verification-city-xuzhou-map.png'); await verifyKeyboardMovement(page); await verifyDistantInteractionIsBlocked(page); const informationResult = await verifyInformationReward( page, xuzhouCase, xuzhouGateway.cityStay.information ); const marketResult = await verifyMarketPurchase( page, 'dist/verification-city-xuzhou-market.png' ); const resonanceResult = await verifyCompanionResonance( page, xuzhouCase, 'dist/verification-city-xuzhou-dialogue.png' ); city = await reloadAndContinueCityStay(page, xuzhouCase.cityStayId); verifyRestoredOutcomes(city, xuzhouCase, informationResult, marketResult, resonanceResult); await verifyNextBattleBriefing( page, xuzhouCase, xuzhouGateway.cityStay.information.briefingLines ); await verifyAdditionalCityLayout(page, xinyeCase, { verifyIncompleteExit: true }); await verifyAdditionalCityLayout(page, chengduCase, { verifyIncompleteExit: false }); assert.deepEqual( pageErrors, [], `Expected no browser page errors: ${JSON.stringify(pageErrors.slice(-8))}` ); assert.deepEqual( consoleErrors.filter((message) => !message.includes('The AudioContext was not allowed to start')), [], `Expected no browser console errors: ${JSON.stringify(consoleErrors.slice(-8))}` ); console.log( `Verified direct inter-battle city exploration at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` + `DPR ${desktopBrowserDeviceScaleFactor}: Camp gateway entry, real keyboard movement, distance-gated interaction, ` + 'one-time information rewards, exact market purchases, saved resonance choices, reload/Continue restoration, ' + 'optional-objective exit, next-battle briefing integration, and Xuzhou/Xinye/Chengdu map and modal layouts.' ); } finally { await browser?.close(); if (serverProcess && !serverProcess.killed) { serverProcess.kill(); } } function withDebugOptions(url) { const parsed = new URL(url); parsed.searchParams.set('debug', '1'); parsed.searchParams.set('renderer', 'canvas'); return parsed.toString(); } async function openCleanCamp(page) { await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 }); await page.evaluate(() => window.localStorage.clear()); await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await openCampScene(page); } async function openCampScene(page) { await page.evaluate(async () => { const game = window.__HEROS_GAME__; const debug = window.__HEROS_DEBUG__; if (!game || !debug) { throw new Error('The game debug API is unavailable while opening CampScene.'); } for (const scene of game.scene.getScenes(true)) { if (scene.scene.key !== 'CampScene') { game.scene.stop(scene.scene.key); } } await debug.goToCamp(); game.scene.bringToTop('CampScene'); }); await page.waitForFunction(() => { const camp = window.__HEROS_DEBUG__?.camp?.(); return ( window.__HEROS_DEBUG__?.activeScenes?.().includes('CampScene') && camp?.scene === 'CampScene' && camp?.report?.battleId && camp?.campaign ); }, undefined, { timeout: 90000 }); } async function seedCityStay(page, cityCase) { const result = await page.evaluate((config) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); if (!scene?.campaign || !scene.report || typeof scene.persistSortieSelection !== 'function') { return { saved: false, reason: 'CampScene campaign/report/persistSortieSelection is unavailable.' }; } const templateUnit = scene.campaign.roster[0] ?? scene.report.units[0]; if (!templateUnit) { return { saved: false, reason: 'No unit template is available for the city-stay seed.' }; } const now = new Date().toISOString(); const liuBei = { ...structuredClone(templateUnit), id: 'liu-bei', name: 'Liu Bei' }; const partner = { ...structuredClone(templateUnit), id: config.partnerId, name: config.partnerId }; const bond = { id: config.bondId, unitIds: ['liu-bei', config.partnerId], title: `${config.cityStayId}-resonance`, level: 42, exp: 0, battleExp: 0, description: `Verification bond for ${config.cityStayId}.` }; const report = { ...scene.report, battleId: config.afterBattleId, battleTitle: config.afterBattleId, outcome: 'victory', turnNumber: Math.max(1, Number(scene.report.turnNumber) || 1), rewardGold: 0, itemRewards: [], objectives: [], units: [liuBei, partner], bonds: [bond], completedCampDialogues: [], completedCampVisits: [], createdAt: now }; delete report.campaignRewards; scene.campaign.step = config.campStep; scene.campaign.gold = config.gold; scene.campaign.inventory = {}; scene.campaign.latestBattleId = config.afterBattleId; scene.campaign.roster = [liuBei, partner]; scene.campaign.bonds = [bond]; scene.campaign.firstBattleReport = report; scene.campaign.battleHistory = { [config.afterBattleId]: { battleId: config.afterBattleId, battleTitle: config.afterBattleId, outcome: 'victory', turnNumber: report.turnNumber, rewardGold: 0, itemRewards: [], objectives: [], units: [], bonds: [], completedAt: now } }; scene.campaign.completedCampDialogues = []; scene.campaign.completedCampVisits = []; scene.campaign.campDialogueChoiceIds = {}; scene.campaign.campVisitChoiceIds = {}; scene.campaign.acknowledgedVictoryRewardBattleIds = [config.afterBattleId]; scene.campaign.acknowledgedVictoryRewardCategories = {}; scene.campaign.dismissedVictoryRewardNoticeBattleIds = [config.afterBattleId]; delete scene.campaign.pendingAftermathBattleId; delete scene.campaign.activeCityStayId; scene.report = report; scene.persistSortieSelection(); const currentCityStayId = scene.currentCityStay?.()?.id ?? null; return { saved: true, latestBattleId: scene.campaign.latestBattleId, reportBattleId: scene.campaign.firstBattleReport?.battleId, cityStayId: currentCityStayId, gold: scene.campaign.gold }; }, { ...cityCase, gold: seedGold }); assert.deepEqual( result, { saved: true, latestBattleId: cityCase.afterBattleId, reportBattleId: cityCase.afterBattleId, cityStayId: cityCase.cityStayId, gold: seedGold }, `Failed to seed ${cityCase.cityStayId}: ${JSON.stringify(result)}` ); } async function restartCamp(page, expectedCityStayId) { await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await openCampScene(page); await waitForCityGateway(page, expectedCityStayId); } async function waitForCityGateway(page, expectedCityStayId) { await page.waitForFunction((cityStayId) => { const camp = window.__HEROS_DEBUG__?.camp?.(); return ( window.__HEROS_DEBUG__?.activeScenes?.().includes('CampScene') && camp?.scene === 'CampScene' && camp?.activeTab === 'city' && camp?.cityStay?.id === cityStayId && camp?.cityStay?.active === true && camp?.cityStay?.panelBounds && camp?.cityStay?.explorationButtonBounds && ( camp?.cityStay?.explorationButtonInteractive === true || camp?.cityStay?.explorationInteractive === true ) ); }, expectedCityStayId, { timeout: 90000 }); return page.evaluate(() => window.__HEROS_DEBUG__?.camp?.()); } function verifyGatewayLayout(camp) { assert.equal(camp.activeTab, 'city'); assert.equal(camp.campTabs.length, 7, 'A city stay must retain the seven CampScene tabs.'); assert( camp.campTabs.every((tab) => tab.bounds && tab.interactive), `Every Camp tab must remain visible and interactive: ${JSON.stringify(camp.campTabs)}` ); assertNoOverlappingBounds( camp.campTabs.map((tab) => ({ id: tab.id, bounds: tab.bounds })), 'Camp tabs' ); const viewportBounds = { x: 0, y: 0, width: desktopBrowserViewport.width, height: desktopBrowserViewport.height }; assertBoundsInside(camp.cityStay.panelBounds, viewportBounds, 'City gateway panel'); assertBoundsInside( camp.cityStay.explorationButtonBounds, camp.cityStay.panelBounds, 'City exploration gateway button' ); assert.equal( camp.victoryRewardAcknowledgement.visible, false, 'The acknowledged battle reward must not obscure the city gateway.' ); } async function enterCityFromGateway(page, gatewayState, expectedCityStayId) { assert.equal( gatewayState.cityStay.explorationButtonInteractive ?? gatewayState.cityStay.explorationInteractive, true ); await clickSceneBounds( page, 'CampScene', gatewayState.cityStay.explorationButtonBounds ); return waitForCityReady(page, expectedCityStayId); } async function waitForCityReady(page, expectedCityStayId) { try { await page.waitForFunction((cityStayId) => { const city = window.__HEROS_DEBUG__?.cityStay?.(); return ( window.__HEROS_DEBUG__?.activeScenes?.().includes('CityStayScene') && city?.scene === 'CityStayScene' && city?.ready === true && city?.cityStayId === cityStayId && city?.requiredTexturesReady === true ); }, expectedCityStayId, { timeout: 90000 }); } catch (error) { const diagnostic = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], cityStay: window.__HEROS_DEBUG__?.cityStay?.() ?? null, camp: window.__HEROS_DEBUG__?.camp?.() ?? null })); throw new Error( `CityStayScene ${expectedCityStayId} did not become ready: ${JSON.stringify(diagnostic)}`, { cause: error } ); } await page.waitForTimeout(380); return readCity(page); } function verifyExplorationLayout(city, expectedCityStayId) { assert.equal(city.scene, 'CityStayScene'); assert.equal(city.cityStayId, expectedCityStayId); assert.equal(city.activeCityStayId, expectedCityStayId); assert.deepEqual(city.viewport, desktopBrowserViewport); assert.equal(city.movement.speed, 300); assert.equal(city.interaction.radius, 122); assert.equal(city.progress.total, 2); assert.equal(city.progress.marketOptional, true); assert.equal(city.progress.exitUnlocked, true); assert.equal(city.actors.length, 3); assert.deepEqual( [...city.actors.map((actor) => actor.kind)].sort(), ['dialogue', 'information', 'market'] ); assert.equal(city.requiredTexturesReady, true); assertBoundsInsideViewport(city.player.bounds, `${expectedCityStayId} player`); assertBoundsInsideViewport(city.movement.bounds, `${expectedCityStayId} movement area`); city.actors.forEach((actor) => { assertBoundsInsideViewport(actor.bounds, `${expectedCityStayId} actor ${actor.id}`); }); city.blockers.forEach((blocker, index) => { assertBoundsInsideViewport(blocker, `${expectedCityStayId} blocker ${index}`); }); } async function verifyKeyboardMovement(page) { const before = await readCity(page); assert.equal(before.dialogue.active, false); assert.equal(before.shop.open, false); assert.equal(before.choice.open, false); await page.keyboard.down('a'); await page.waitForTimeout(430); await page.keyboard.up('a'); await page.waitForTimeout(90); const after = await readCity(page); assert( before.player.x - after.player.x >= 85, `Expected real keyboard movement to move Liu Bei left: ${JSON.stringify({ before: before.player, after: after.player })}` ); assert( Math.abs(after.player.y - before.player.y) <= 3, `Expected horizontal movement to retain Y: ${JSON.stringify({ before: before.player, after: after.player })}` ); assert.equal(after.player.moving, false); } async function verifyDistantInteractionIsBlocked(page) { const before = await readCity(page); assert.equal(before.interaction.canInteract, false, 'The movement probe should end outside every interaction radius.'); await page.keyboard.press('e'); await page.waitForTimeout(160); const after = await readCity(page); assert.equal(after.dialogue.active, false, 'A distant interaction must not open dialogue.'); assert.equal(after.shop.open, false, 'A distant interaction must not open the market.'); assert.equal(after.choice.open, false, 'A distant interaction must not open a resonance choice.'); assert.deepEqual(after.progress, before.progress, 'A distant interaction must not change city progress.'); await page.keyboard.press('Escape'); await page.waitForTimeout(120); const afterEscape = await readCity(page); assert.equal( afterEscape.navigationPending, false, 'ESC on the open map must not bypass walking to the south exit.' ); assert( afterEscape.lastNotice.includes('남쪽 군영 출구'), `ESC should direct the player to the physical exit: ${afterEscape.lastNotice}` ); } async function verifyInformationReward(page, cityCase, gatewayInformation) { const before = await readCity(page); assert.equal(before.progress.informationComplete, false); assert(!before.campaign.completedCampVisits.includes(cityCase.informationId)); const inventoryBefore = { ...before.campaign.inventory }; await interactWith(page, 'information'); await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true )); await captureStableScreenshot( page, `dist/verification-city-${cityCase.cityStayId}-information.png` ); await advanceCityDialogueUntilClosed(page); await page.waitForFunction((informationId) => { const city = window.__HEROS_DEBUG__?.cityStay?.(); return ( city?.progress?.informationComplete === true && city?.campaign?.completedCampVisits?.includes(informationId) ); }, cityCase.informationId); const afterFirst = await readCity(page); const rewardDelta = inventoryDelta(inventoryBefore, afterFirst.campaign.inventory); assert.equal( rewardDelta.length, 1, `Information must grant exactly one inventory entry: ${JSON.stringify(rewardDelta)}` ); assert.equal(rewardDelta[0].amount, 1, 'The information inventory reward must increase by exactly one.'); assert.equal( afterFirst.campaign.completedCampVisits.filter((id) => id === cityCase.informationId).length, 1, 'The information completion ID must be unique.' ); await interactWith(page, 'information'); await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true )); await advanceCityDialogueUntilClosed(page); const afterRepeated = await readCity(page); assert.deepEqual( afterRepeated.campaign.inventory, afterFirst.campaign.inventory, 'Repeating completed information dialogue must not duplicate its reward.' ); assert.equal( afterRepeated.campaign.completedCampVisits.filter((id) => id === cityCase.informationId).length, 1, 'Repeating completed information dialogue must not duplicate its completion ID.' ); assert( Array.isArray(gatewayInformation?.briefingLines) && gatewayInformation.briefingLines.length > 0, 'The Camp gateway must expose the authored next-battle briefing lines.' ); return { inventoryLabel: rewardDelta[0].label, inventoryCount: afterRepeated.campaign.inventory[rewardDelta[0].label] }; } async function verifyMarketPurchase(page, screenshotPath) { await interactWith(page, 'market'); await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === true); const before = await readCity(page); verifyShopLayout(before); await captureStableScreenshot(page, screenshotPath); const offer = before.shop.offers[0]; assert(offer?.interactive && offer.buttonBounds && offer.canBuy); const expectedGold = before.shop.gold - offer.price; const expectedOwned = offer.owned + 1; await clickSceneBounds(page, 'CityStayScene', offer.buttonBounds); await page.waitForFunction(({ offerId, gold, owned }) => { const shop = window.__HEROS_DEBUG__?.cityStay?.()?.shop; const currentOffer = shop?.offers?.find((candidate) => candidate.id === offerId); return shop?.gold === gold && currentOffer?.owned === owned; }, { offerId: offer.id, gold: expectedGold, owned: expectedOwned }); const after = await readCity(page); const purchased = after.shop.offers.find((candidate) => candidate.id === offer.id); assert.equal(after.shop.gold, expectedGold, 'The market must deduct the exact displayed price.'); assert.equal(purchased?.owned, expectedOwned, 'The purchased item count must increase by exactly one.'); assert.equal(after.campaign.gold, expectedGold); await page.keyboard.press('Escape'); await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === false); return { offerId: offer.id, gold: expectedGold, owned: expectedOwned }; } function verifyShopLayout(city) { assert.equal(city.shop.open, true); assertBoundsInsideViewport(city.shop.panelBounds, `${city.cityStayId} shop panel`); assert.equal(city.shop.offers.length, 3); city.shop.offers.forEach((offer) => { assert(offer.buttonBounds, `${offer.id} must expose purchase button bounds.`); assertBoundsInsideViewport(offer.buttonBounds, `${city.cityStayId} shop offer ${offer.id}`); }); assertNoOverlappingBounds( city.shop.offers.map((offer) => ({ id: offer.id, bounds: offer.buttonBounds })), `${city.cityStayId} shop offer buttons` ); } async function verifyCompanionResonance(page, cityCase, screenshotPath) { const campaignBefore = await readActiveCampaign(page); const bondBefore = campaignBefore.bonds.find((bond) => bond.id === cityCase.bondId); assert(bondBefore, `Expected seeded bond ${cityCase.bondId}.`); await interactWith(page, 'dialogue'); await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true )); await advanceDialogueUntilChoice(page); const choiceState = await readCity(page); verifyChoiceLayout(choiceState); await captureStableScreenshot(page, screenshotPath); const choice = choiceState.choice.choices[0]; assert(choice?.interactive && choice.bounds); await clickSceneBounds(page, 'CityStayScene', choice.bounds); await page.waitForFunction(({ dialogueId, choiceId }) => { const city = window.__HEROS_DEBUG__?.cityStay?.(); return ( city?.progress?.dialogueComplete === true && city?.campaign?.completedCampDialogues?.includes(dialogueId) && city?.campaign?.campDialogueChoiceIds?.[dialogueId] === choiceId ); }, { dialogueId: cityCase.dialogueId, choiceId: choice.id }); const afterChoice = await readCity(page); assert.equal( afterChoice.campaign.completedCampDialogues.filter((id) => id === cityCase.dialogueId).length, 1, 'The companion dialogue completion ID must be unique.' ); assert.equal(afterChoice.campaign.campDialogueChoiceIds[cityCase.dialogueId], choice.id); const campaignAfter = await readActiveCampaign(page); const bondAfter = campaignAfter.bonds.find((bond) => bond.id === cityCase.bondId); assert(bondAfter, `Expected persisted bond ${cityCase.bondId}.`); assert.equal( bondAfter.battleExp, bondBefore.battleExp + choice.rewardExp, 'The selected response must persist its exact resonance reward.' ); assert.equal( bondAfter.exp, bondBefore.exp + choice.rewardExp, 'The seeded bond should retain the exact resonance EXP below the level threshold.' ); await advanceCityDialogueUntilClosed(page); return { dialogueId: cityCase.dialogueId, choiceId: choice.id, rewardExp: choice.rewardExp, bondExp: bondAfter.exp, bondBattleExp: bondAfter.battleExp }; } function verifyChoiceLayout(city) { assert.equal(city.choice.open, true); assertBoundsInsideViewport(city.choice.panelBounds, `${city.cityStayId} resonance panel`); assert.equal(city.choice.choices.length, 2); city.choice.choices.forEach((choice) => { assert(choice.interactive && choice.bounds, `${choice.id} must be visible and interactive.`); assertBoundsInsideViewport(choice.bounds, `${city.cityStayId} resonance choice ${choice.id}`); }); assertNoOverlappingBounds( city.choice.choices.map((choice) => ({ id: choice.id, bounds: choice.bounds })), `${city.cityStayId} resonance choices` ); } async function reloadAndContinueCityStay(page, expectedCityStayId) { const beforeReload = await readCity(page); assert.equal(beforeReload.activeCityStayId, expectedCityStayId); await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.activeScenes?.().includes('TitleScene') ), undefined, { timeout: 90000 }); await assertDesktopViewport(page); await page.waitForTimeout(380); await page.keyboard.press('Enter'); return waitForCityReady(page, expectedCityStayId); } function verifyRestoredOutcomes(city, cityCase, information, market, resonance) { verifyExplorationLayout(city, cityCase.cityStayId); assert.equal(city.progress.informationComplete, true); assert.equal(city.progress.dialogueComplete, true); assert.equal( city.campaign.inventory[information.inventoryLabel], information.inventoryCount, 'The one-time information reward must survive reload/Continue.' ); assert.equal(city.shop.gold, market.gold, 'The exact post-purchase gold balance must survive reload/Continue.'); assert.equal( city.shop.offers.find((offer) => offer.id === market.offerId)?.owned, market.owned, 'The purchased equipment count must survive reload/Continue.' ); assert.equal( city.campaign.campDialogueChoiceIds[cityCase.dialogueId], resonance.choiceId, 'The selected companion response must survive reload/Continue.' ); } async function verifyNextBattleBriefing(page, cityCase, expectedBriefingLines) { await page.evaluate(async (battleId) => { const game = window.__HEROS_GAME__; if (!game || !window.__HEROS_DEBUG__) { throw new Error('The game debug API is unavailable while opening BattleScene.'); } for (const scene of game.scene.getScenes(true)) { if (scene.scene.key !== 'BattleScene') { game.scene.stop(scene.scene.key); } } await window.__HEROS_DEBUG__.goToBattle(battleId); game.scene.bringToTop('BattleScene'); }, cityCase.nextBattleId); await page.waitForFunction((battleId) => { const battle = window.__HEROS_DEBUG__?.battle?.(); return ( window.__HEROS_DEBUG__?.activeScenes?.().includes('BattleScene') && battle?.scene === 'BattleScene' && battle?.battleId === battleId && battle?.cityInformation ); }, cityCase.nextBattleId, { timeout: 90000 }); let battle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle?.()); assert.equal(battle.cityInformation.available, true); assert.equal(battle.cityInformation.completed, true); assert.deepEqual( battle.cityInformation.briefingLines, expectedBriefingLines, 'The next battle must expose the exact authored city briefing.' ); const openingBriefingLines = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); return scene?.completedCityInformationBriefingLines?.() ?? []; }); assert.equal( openingBriefingLines.length, expectedBriefingLines.length + 1, 'The opening briefing payload must prepend one city-information heading.' ); assert.deepEqual( openingBriefingLines.slice(1), expectedBriefingLines, 'Every gathered city-information line must feed the next battle opening briefing.' ); } async function verifyAdditionalCityLayout(page, cityCase, options) { await openCampScene(page); await seedCityStay(page, cityCase); await restartCamp(page, cityCase.cityStayId); const gateway = await waitForCityGateway(page, cityCase.cityStayId); verifyGatewayLayout(gateway); let city = await enterCityFromGateway(page, gateway, cityCase.cityStayId); verifyExplorationLayout(city, cityCase.cityStayId); assert.equal(city.progress.informationComplete, false); assert.equal(city.progress.dialogueComplete, false); await captureStableScreenshot( page, `dist/verification-city-${cityCase.cityStayId}-map.png` ); await interactWith(page, 'information'); await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true )); city = await readCity(page); assertBoundsInsideViewport(city.dialogue.bounds, `${cityCase.cityStayId} information dialogue`); await captureStableScreenshot( page, `dist/verification-city-${cityCase.cityStayId}-information.png` ); await page.keyboard.press('Escape'); await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === false )); assert.equal((await readCity(page)).progress.informationComplete, false); await interactWith(page, 'market'); await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === true); city = await readCity(page); verifyShopLayout(city); await captureStableScreenshot( page, `dist/verification-city-${cityCase.cityStayId}-market.png` ); await page.keyboard.press('Escape'); await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.shop?.open === false); await interactWith(page, 'dialogue'); await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true )); await advanceDialogueUntilChoice(page); city = await readCity(page); verifyChoiceLayout(city); await captureStableScreenshot( page, `dist/verification-city-${cityCase.cityStayId}-dialogue.png` ); await page.keyboard.press('Escape'); await page.waitForFunction(() => window.__HEROS_DEBUG__?.cityStay?.()?.choice?.open === false); city = await readCity(page); assert.equal(city.progress.dialogueComplete, false); if (options.verifyIncompleteExit) { await verifyIncompleteCityExit(page, cityCase); } } async function verifyIncompleteCityExit(page, cityCase) { const before = await readCity(page); assert.equal(before.progress.completed, 0); assert.equal(before.progress.exitUnlocked, true); await interactWith(page, 'exit'); await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.cityStay?.()?.dialogue?.active === true )); await captureStableScreenshot( page, `dist/verification-city-${cityCase.cityStayId}-exit-warning.png` ); await advanceCityDialogueUntilSceneChanges(page); await page.waitForFunction(() => { const camp = window.__HEROS_DEBUG__?.camp?.(); return ( window.__HEROS_DEBUG__?.activeScenes?.().includes('CampScene') && camp?.scene === 'CampScene' ); }, undefined, { timeout: 90000 }); const campaign = await readActiveCampaign(page); assert.equal(campaign.activeCityStayId ?? null, null, 'Returning to Camp must clear activeCityStayId.'); assert(!campaign.completedCampVisits.includes(cityCase.informationId)); assert(!campaign.completedCampDialogues.includes(cityCase.dialogueId)); } async function interactWith(page, targetIdOrKind) { const result = await page.evaluate((target) => { const scene = window.__HEROS_GAME__?.scene.getScene('CityStayScene'); return scene?.debugInteractWith?.(target) ?? false; }, targetIdOrKind); assert.equal(result, true, `Expected CityStayScene interaction with ${targetIdOrKind}.`); await page.waitForTimeout(90); } async function advanceCityDialogueUntilClosed(page) { for (let attempt = 0; attempt < 16; attempt += 1) { const city = await readCity(page); if (!city?.dialogue?.active) { return; } await page.mouse.click(960, 850); await page.waitForTimeout(130); } throw new Error(`City dialogue did not close: ${JSON.stringify(await readCity(page))}`); } async function advanceDialogueUntilChoice(page) { for (let attempt = 0; attempt < 16; attempt += 1) { const city = await readCity(page); if (city?.choice?.open) { return; } if (!city?.dialogue?.active) { throw new Error(`Dialogue closed without opening a choice: ${JSON.stringify(city)}`); } await page.mouse.click(960, 850); await page.waitForTimeout(130); } throw new Error(`Resonance choice did not open: ${JSON.stringify(await readCity(page))}`); } async function advanceCityDialogueUntilSceneChanges(page) { for (let attempt = 0; attempt < 16; attempt += 1) { const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes?.() ?? []); if (!activeScenes.includes('CityStayScene')) { return; } const city = await readCity(page); if (city?.dialogue?.active) { await page.mouse.click(960, 850); await page.waitForTimeout(150); continue; } if (city?.navigationPending) { await page.waitForTimeout(150); continue; } throw new Error(`City exit stopped before navigation: ${JSON.stringify(city)}`); } throw new Error(`City exit did not return to Camp: ${JSON.stringify(await readCity(page))}`); } async function readCity(page) { return page.evaluate(() => window.__HEROS_DEBUG__?.cityStay?.()); } async function readActiveCampaign(page) { return page.evaluate(() => { const debug = window.__HEROS_DEBUG__; const activeKey = ['CityStayScene', 'CampScene', 'BattleScene'] .find((key) => window.__HEROS_GAME__?.scene.isActive(key)); const campaign = activeKey ? debug?.scene(activeKey)?.campaign : undefined; if (!campaign) { throw new Error(`No active scene campaign is available (scene: ${activeKey ?? 'none'}).`); } return structuredClone(campaign); }); } function inventoryDelta(before, after) { return [...new Set([...Object.keys(before), ...Object.keys(after)])] .map((label) => ({ label, amount: (after[label] ?? 0) - (before[label] ?? 0) })) .filter((entry) => entry.amount !== 0); } function assertNoOverlappingBounds(entries, label) { for (let leftIndex = 0; leftIndex < entries.length; leftIndex += 1) { for (let rightIndex = leftIndex + 1; rightIndex < entries.length; rightIndex += 1) { const left = entries[leftIndex]; const right = entries[rightIndex]; const overlapWidth = Math.min(left.bounds.x + left.bounds.width, right.bounds.x + right.bounds.width) - Math.max(left.bounds.x, right.bounds.x); const overlapHeight = Math.min(left.bounds.y + left.bounds.height, right.bounds.y + right.bounds.height) - Math.max(left.bounds.y, right.bounds.y); assert( overlapWidth <= 0 || overlapHeight <= 0, `${label} "${left.id}" and "${right.id}" overlap: ${JSON.stringify({ left, right })}` ); } } } function assertBoundsInsideViewport(bounds, label) { assertBoundsInside( bounds, { x: 0, y: 0, width: desktopBrowserViewport.width, height: desktopBrowserViewport.height }, label ); } function assertBoundsInside(bounds, container, label) { assert(bounds, `${label}: bounds are required.`); const epsilon = 0.01; assert( bounds.x >= container.x - epsilon && bounds.y >= container.y - epsilon && bounds.x + bounds.width <= container.x + container.width + epsilon && bounds.y + bounds.height <= container.y + container.height + epsilon, `${label}: expected bounds inside container, received ${JSON.stringify({ bounds, container })}` ); } async function waitForDebugApi(page) { await page.waitForFunction(() => ( document.querySelector('canvas') !== null && window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined ), undefined, { timeout: 90000 }); } async function assertDesktopViewport(page) { const viewport = await page.evaluate(() => { const canvas = document.querySelector('canvas'); const bounds = canvas?.getBoundingClientRect(); return { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio, visualScale: window.visualViewport?.scale ?? 1, canvas: canvas ? { width: canvas.width, height: canvas.height, clientWidth: canvas.clientWidth, clientHeight: canvas.clientHeight, bounds: bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null } : null }; }); assert.equal(viewport.width, desktopBrowserViewport.width); assert.equal(viewport.height, desktopBrowserViewport.height); assert.equal(viewport.dpr, desktopBrowserDeviceScaleFactor); assert.equal(viewport.visualScale, 1); assert.equal(viewport.canvas?.width, desktopBrowserViewport.width); assert.equal(viewport.canvas?.height, desktopBrowserViewport.height); assert.equal(viewport.canvas?.clientWidth, desktopBrowserViewport.width); assert.equal(viewport.canvas?.clientHeight, desktopBrowserViewport.height); assert.equal(viewport.canvas?.bounds?.width, desktopBrowserViewport.width); assert.equal(viewport.canvas?.bounds?.height, desktopBrowserViewport.height); } async function clickSceneBounds(page, sceneKey, bounds) { const point = await page.evaluate(({ requestedSceneKey, requestedBounds }) => { const scene = window.__HEROS_GAME__?.scene.getScene(requestedSceneKey); const canvas = document.querySelector('canvas'); const canvasBounds = canvas?.getBoundingClientRect(); if (!scene || !canvasBounds || !requestedBounds) { return null; } return { x: canvasBounds.left + (requestedBounds.x + requestedBounds.width / 2) * canvasBounds.width / scene.scale.width, y: canvasBounds.top + (requestedBounds.y + requestedBounds.height / 2) * canvasBounds.height / scene.scale.height }; }, { requestedSceneKey: sceneKey, requestedBounds: bounds }); assert(point && Number.isFinite(point.x) && Number.isFinite(point.y)); await page.mouse.click(point.x, point.y); } async function captureStableScreenshot(page, path) { await page.waitForTimeout(260); const loopSlept = await page.evaluate(() => { const loop = window.__HEROS_GAME__?.loop; if (!loop || typeof loop.sleep !== 'function') { return false; } loop.sleep(); return true; }); await page.waitForTimeout(40); try { await page.screenshot({ path, fullPage: true }); } finally { if (loopSlept) { await page.evaluate(() => window.__HEROS_GAME__?.loop.wake()); await page.waitForTimeout(40); } } } async function ensureLocalServer(url) { if (await canReach(url)) { return undefined; } const parsed = new URL(url); if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname)) { throw new Error(`No server responded at ${url}`); } const stderr = []; const child = spawn( process.execPath, [ 'node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', parsed.port || '41795', '--strictPort' ], { cwd: process.cwd(), env: process.env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true } ); child.stderr.on('data', (chunk) => stderr.push(chunk.toString())); child.stdout.on('data', () => {}); for (let attempt = 0; attempt < 120; attempt += 1) { if (await canReach(url)) { return child; } await delay(250); } child.kill(); throw new Error(`Vite server did not start at ${url}\n${stderr.join('')}`); } async function canReach(url) { try { const response = await fetch(url, { signal: AbortSignal.timeout(1000) }); return response.ok; } catch { return false; } } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }