import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; import { chromium } from 'playwright'; import { desktopBrowserContextOptions, desktopBrowserDeviceScaleFactor, desktopBrowserViewport } from './desktop-browser-viewport.mjs'; const xuzhouBattleId = 'seventh-battle-xuzhou-rescue'; const xiaopeiBattleId = 'eighth-battle-xiaopei-supply-road'; const xuzhouCityStayId = 'xuzhou'; const seedGold = 5000; const additionalCityVisualCases = [ { cityStayId: 'xinye', cityName: '신야', afterBattleId: 'seventeenth-battle-wolong-visit-road', nextBattleId: 'eighteenth-battle-bowang-ambush', battleTitle: '와룡 방문로', campStep: 'seventeenth-camp', partnerId: 'zhuge-liang', partnerName: '제갈량', bondId: 'liu-bei__zhuge-liang', bondTitle: '삼고초려', screenshotPath: 'dist/verification-city-xinye-information.png' }, { cityStayId: 'chengdu', cityName: '성도', afterBattleId: 'thirty-third-battle-chengdu-surrender', nextBattleId: 'thirty-fourth-battle-jiameng-pass', battleTitle: '성도 항복', campStep: 'thirty-third-camp', partnerId: 'huang-quan', partnerName: '황권', bondId: 'liu-bei__huang-quan', bondTitle: '익주의 신뢰', screenshotPath: 'dist/verification-city-chengdu-information.png' } ]; const targetUrl = withDebugOptions( process.env.VERIFY_CITY_STAY_URL ?? 'http://127.0.0.1:41795/' ); let serverProcess; let browser; try { 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 = []; page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message)); await openCleanCamp(page); await assertDesktopViewport(page); await seedXuzhouCityStay(page); await restartCamp(page); const initialState = await waitForXuzhouCityStay(page); verifyCityStayLayout(initialState); await captureStableScreenshot(page, 'dist/verification-city-xuzhou-information.png'); assert.equal( initialState.victoryRewardAcknowledgement.visible, false, 'The seeded victory-reward notice must not obstruct the city-stay flow.' ); const informationResult = await verifyInformationReward(page, initialState); const marketResult = await verifyMarketPurchase(page); const dialogueResult = await verifyCompanionDialogue(page); await restartCamp(page); const restoredState = await waitForXuzhouCityStay(page); verifyRestoredOutcomes(restoredState, informationResult, marketResult, dialogueResult); await verifyNextBattleBriefing(page, restoredState.cityStay.information.briefingLines); await verifyAdditionalCityLayouts(page); assert.deepEqual( pageErrors, [], `Expected zero browser page errors, received: ${JSON.stringify(pageErrors.slice(-5))}` ); console.log( `Verified the Xuzhou, Xinye, and Chengdu city stays at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` + `DPR ${desktopBrowserDeviceScaleFactor}: seven non-overlapping tabs and an in-viewport city panel; ` + 'idempotent information rewards; exact market gold and inventory changes; saved companion choice; ' + 'all outcomes surviving a full browser reload; completed city information appearing in each next battle briefing; ' + 'and information, market, and resonance layouts remaining readable at the desktop baseline.' ); } 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 page.evaluate(() => window.__HEROS_DEBUG__?.goToCamp()); await page.waitForFunction(() => { const camp = window.__HEROS_DEBUG__?.camp(); return ( window.__HEROS_DEBUG__?.activeScenes().includes('CampScene') && camp?.scene === 'CampScene' && camp?.report?.battleId ); }, undefined, { timeout: 90000 }); } async function seedXuzhouCityStay(page) { const result = await page.evaluate(({ battleId, battleTitle, gold }) => { 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 now = new Date().toISOString(); const templateUnit = scene.campaign.roster[0] ?? scene.report.units[0]; const liuBei = templateUnit ? { ...structuredClone(templateUnit), id: 'liu-bei', name: '유비' } : null; const xuzhouBond = { id: 'liu-bei__mi-zhu', unitIds: ['liu-bei', 'mi-zhu'], title: '서주 후원', level: 42, exp: 0, battleExp: 0, description: '미축은 서주의 민심과 군량을 묶어 유비군이 오래 버틸 수 있게 돕는다.' }; const report = { ...scene.report, battleId, battleTitle, outcome: 'victory', turnNumber: Math.max(1, Number(scene.report.turnNumber) || 1), rewardGold: 0, itemRewards: [], objectives: [], units: liuBei ? [liuBei] : [], bonds: [xuzhouBond], completedCampDialogues: [], completedCampVisits: [], createdAt: now }; delete report.campaignRewards; scene.campaign.step = 'seventh-camp'; scene.campaign.gold = gold; scene.campaign.latestBattleId = battleId; scene.campaign.roster = liuBei ? [liuBei] : []; scene.campaign.bonds = [xuzhouBond]; scene.campaign.firstBattleReport = report; scene.campaign.battleHistory = { [battleId]: { battleId, battleTitle, 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 = [battleId]; scene.campaign.acknowledgedVictoryRewardCategories = {}; scene.campaign.dismissedVictoryRewardNoticeBattleIds = [battleId]; delete scene.campaign.pendingAftermathBattleId; scene.report = report; scene.persistSortieSelection(); return { saved: true, latestBattleId: scene.campaign.latestBattleId, reportBattleId: scene.campaign.firstBattleReport?.battleId, gold: scene.campaign.gold }; }, { battleId: xuzhouBattleId, battleTitle: '서주 구원전', gold: seedGold }); assert.deepEqual( result, { saved: true, latestBattleId: xuzhouBattleId, reportBattleId: xuzhouBattleId, gold: seedGold }, `Failed to persist the Xuzhou seed through CampScene.persistSortieSelection(): ${JSON.stringify(result)}` ); } async function restartCamp(page, expectedCityStayId = xuzhouCityStayId) { await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await page.evaluate(() => window.__HEROS_DEBUG__?.goToCamp()); await page.waitForFunction((expectedId) => { const camp = window.__HEROS_DEBUG__?.camp(); return ( window.__HEROS_DEBUG__?.activeScenes().includes('CampScene') && camp?.scene === 'CampScene' && camp?.cityStay?.id === expectedId && camp?.cityStay?.active === true ); }, expectedCityStayId, { timeout: 90000 }); } async function verifyAdditionalCityLayouts(page) { for (const visualCase of additionalCityVisualCases) { await openCampAboveBattle(page); const seedResult = await seedCityStayPreview(page, { ...visualCase, gold: seedGold }); assert.deepEqual( seedResult, { saved: true, latestBattleId: visualCase.afterBattleId, reportBattleId: visualCase.afterBattleId, cityStayId: visualCase.cityStayId }, `Failed to persist the ${visualCase.cityName} city-stay preview: ${JSON.stringify(seedResult)}` ); await restartCamp(page, visualCase.cityStayId); const state = await waitForCityStay(page, visualCase.cityStayId); verifyCityStayLayout(state); assert.equal(state.cityStay.name, visualCase.cityName); assert.equal(state.cityStay.information.completed, false); assert.equal(state.cityStay.dialogue.completed, false); await captureStableScreenshot(page, visualCase.screenshotPath); await selectCityLocation(page, 'market'); const marketState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert.equal(marketState.cityStay.market.offers.length, 3); marketState.cityStay.market.offers.forEach((offer) => { assert(offer.buyBounds && offer.interactive && offer.canBuy); assertBoundsInside( offer.buyBounds, marketState.cityStay.panelBounds, `${visualCase.cityName} market offer "${offer.id}" must remain inside the city panel.` ); }); assertNoOverlappingBounds( marketState.cityStay.market.offers.map((offer) => ({ id: offer.id, bounds: offer.buyBounds })), `${visualCase.cityName} market offer buttons` ); await captureStableScreenshot( page, visualCase.screenshotPath.replace('-information.png', '-market.png') ); await selectCityLocation(page, 'dialogue'); const dialogueState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert.equal(dialogueState.cityStay.dialogue.choices.length, 2); dialogueState.cityStay.dialogue.choices.forEach((choice) => { assert(choice.bounds && choice.interactive); assertBoundsInside( choice.bounds, dialogueState.cityStay.panelBounds, `${visualCase.cityName} resonance choice "${choice.id}" must remain inside the city panel.` ); }); assertNoOverlappingBounds( dialogueState.cityStay.dialogue.choices.map((choice) => ({ id: choice.id, bounds: choice.bounds })), `${visualCase.cityName} resonance choice buttons` ); await captureStableScreenshot( page, visualCase.screenshotPath.replace('-information.png', '-dialogue.png') ); await selectCityLocation(page, 'information'); const functionalInitialState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const informationResult = await verifyInformationReward(page, functionalInitialState); const marketResult = await verifyMarketPurchase(page, null); const dialogueResult = await verifyCompanionDialogue(page, null); await restartCamp(page, visualCase.cityStayId); const restoredState = await waitForCityStay(page, visualCase.cityStayId); verifyRestoredOutcomes(restoredState, informationResult, marketResult, dialogueResult); await verifyNextBattleBriefing( page, restoredState.cityStay.information.briefingLines, visualCase.nextBattleId, visualCase.cityName ); } } async function openCampAboveBattle(page) { await page.evaluate(async () => { const game = window.__HEROS_GAME__; if (!game || !window.__HEROS_DEBUG__) { throw new Error('The game debug API is unavailable while opening CampScene.'); } if (game.scene.isActive('BattleScene')) { game.scene.stop('BattleScene'); } await window.__HEROS_DEBUG__.goToCamp(); game.scene.bringToTop('CampScene'); }); await page.waitForFunction(() => ( !window.__HEROS_GAME__?.scene.isActive('BattleScene') && window.__HEROS_DEBUG__?.activeScenes().includes('CampScene') && window.__HEROS_DEBUG__?.camp()?.scene === 'CampScene' ), undefined, { timeout: 90000 }); } async function seedCityStayPreview(page, visualCase) { return 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 now = new Date().toISOString(); const templateUnit = scene.campaign.roster[0] ?? scene.report.units[0]; if (!templateUnit) { return { saved: false, reason: 'No unit template is available for the city preview.' }; } const liuBei = { ...structuredClone(templateUnit), id: 'liu-bei', name: '유비' }; const partner = { ...structuredClone(templateUnit), id: config.partnerId, name: config.partnerName }; const bond = { id: config.bondId, unitIds: ['liu-bei', config.partnerId], title: config.bondTitle, level: 42, exp: 0, battleExp: 0, description: `${config.cityName} 체류 중 함께 다음 전투를 준비하며 쌓은 공명입니다.` }; const report = { ...scene.report, battleId: config.afterBattleId, battleTitle: config.battleTitle, 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.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.battleTitle, 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; scene.report = report; scene.persistSortieSelection(); const cityStayId = scene.currentCityStay?.()?.id ?? null; return { saved: true, latestBattleId: scene.campaign.latestBattleId, reportBattleId: scene.campaign.firstBattleReport?.battleId, cityStayId }; }, visualCase); } async function waitForCityStay(page, expectedId) { await page.waitForFunction((cityStayId) => { const camp = window.__HEROS_DEBUG__?.camp(); return ( camp?.scene === 'CampScene' && camp?.cityStay?.id === cityStayId && camp?.cityStay?.active === true && camp?.cityStay?.panelBounds && camp?.campTabs?.length === 7 ); }, expectedId, { timeout: 90000 }); return page.evaluate(() => window.__HEROS_DEBUG__?.camp()); } async function waitForXuzhouCityStay(page) { return waitForCityStay(page, xuzhouCityStayId); } function verifyCityStayLayout(state) { assert.equal(state.activeTab, 'city', 'The city tab should be active when the Xuzhou stay opens.'); assert.equal(state.campTabs.length, 7, 'A city stay must expose exactly seven CampScene tabs.'); assert( state.campTabs.every((tab) => tab.bounds && tab.interactive), `Every city-stay tab must be visible and interactive: ${JSON.stringify(state.campTabs)}` ); assertNoOverlappingBounds( state.campTabs.map((tab) => ({ id: tab.id, bounds: tab.bounds })), 'CampScene tabs' ); const sceneBounds = state.campSkin?.sceneBounds ?? { x: 0, y: 0, width: desktopBrowserViewport.width, height: desktopBrowserViewport.height }; assertBoundsInside( state.cityStay.panelBounds, sceneBounds, `The Xuzhou city panel must remain inside the ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} scene.` ); state.campTabs.forEach((tab) => { assertBoundsInside(tab.bounds, sceneBounds, `CampScene tab "${tab.id}" must remain inside the scene.`); }); } async function verifyInformationReward(page, initialState) { const information = initialState.cityStay.information; assert.equal(information.completed, false, 'Xuzhou information should initially be unclaimed.'); assert(information.actionInteractive && information.actionBounds, 'The information action must be clickable.'); const reward = parseInventoryReward(information.itemReward); const beforeCount = initialState.campaign.inventory[reward.label] ?? 0; await clickSceneBounds(page, 'CampScene', information.actionBounds); await page.waitForFunction(({ visitId, label, expectedCount }) => { const camp = window.__HEROS_DEBUG__?.camp(); return ( camp?.cityStay?.information?.id === visitId && camp.cityStay.information.completed === true && (camp.campaign?.inventory?.[label] ?? 0) === expectedCount ); }, { visitId: information.id, label: reward.label, expectedCount: beforeCount + reward.amount }); const afterFirstClaim = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const cityStay = scene?.currentCityStay?.(); if (!scene || !cityStay) { throw new Error('Xuzhou city stay disappeared before the idempotency probe.'); } scene.gatherCityInformation(cityStay); }); await page.waitForTimeout(100); const afterRepeatedClaim = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert.equal( afterRepeatedClaim.campaign.inventory[reward.label] ?? 0, afterFirstClaim.campaign.inventory[reward.label] ?? 0, 'Calling the completed information action again must not duplicate its inventory reward.' ); assert.equal( afterRepeatedClaim.campaign.completedCampVisits.filter((id) => id === information.id).length, 1, 'The information completion record must remain unique.' ); return { id: information.id, inventoryLabel: reward.label, inventoryCount: afterRepeatedClaim.campaign.inventory[reward.label] ?? 0 }; } async function verifyMarketPurchase(page, screenshotPath = 'dist/verification-city-xuzhou-market.png') { await selectCityLocation(page, 'market'); const before = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (screenshotPath) { await captureStableScreenshot(page, screenshotPath); } const offer = before.cityStay.market.offers[0]; assert(offer?.buyBounds && offer.interactive && offer.canBuy, 'The first Xuzhou market offer must be affordable and clickable.'); const goldBefore = before.cityStay.market.gold; const ownedBefore = offer.owned; await clickSceneBounds(page, 'CampScene', offer.buyBounds); await page.waitForFunction(({ offerId, expectedGold, expectedOwned }) => { const market = window.__HEROS_DEBUG__?.camp()?.cityStay?.market; const currentOffer = market?.offers?.find((candidate) => candidate.id === offerId); return market?.gold === expectedGold && currentOffer?.owned === expectedOwned; }, { offerId: offer.id, expectedGold: goldBefore - offer.price, expectedOwned: ownedBefore + 1 }); const after = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const purchasedOffer = after.cityStay.market.offers.find((candidate) => candidate.id === offer.id); assert.equal(after.cityStay.market.gold, goldBefore - offer.price, 'The market must deduct the exact listed price.'); assert.equal(purchasedOffer.owned, ownedBefore + 1, 'The purchased equipment inventory must increase by exactly one.'); return { offerId: offer.id, itemId: offer.itemId, itemName: offer.itemName, gold: after.cityStay.market.gold, owned: purchasedOffer.owned }; } async function verifyCompanionDialogue(page, screenshotPath = 'dist/verification-city-xuzhou-dialogue.png') { await selectCityLocation(page, 'dialogue'); const before = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (screenshotPath) { await captureStableScreenshot(page, screenshotPath); } const choice = before.cityStay.dialogue.choices[0]; assert(choice?.bounds && choice.interactive, 'The first Xuzhou companion choice must be clickable.'); await clickSceneBounds(page, 'CampScene', choice.bounds); await page.waitForTimeout(150); const after = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert( after?.cityStay?.dialogue?.id === before.cityStay.dialogue.id && after.cityStay.dialogue.completed === true && after.cityStay.dialogue.choiceId === choice.id, `The companion choice did not complete synchronously: ${JSON.stringify({ before: before.cityStay.dialogue, after: after?.cityStay?.dialogue, campaignBonds: after?.report?.bonds })}` ); assert.equal( after.campaign.completedCampDialogues.filter((id) => id === before.cityStay.dialogue.id).length, 1, 'The companion dialogue completion record must be unique.' ); assert.equal( after.campaign.campDialogueChoiceIds[before.cityStay.dialogue.id], choice.id, 'The selected companion response must be saved by choice ID.' ); return { id: before.cityStay.dialogue.id, choiceId: choice.id }; } async function selectCityLocation(page, locationId) { const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const location = state.cityStay.locations.find((candidate) => candidate.id === locationId); assert(location?.bounds && location.interactive, `City location "${locationId}" must be clickable.`); await clickSceneBounds(page, 'CampScene', location.bounds); await page.waitForFunction((expectedId) => ( window.__HEROS_DEBUG__?.camp()?.cityStay?.selectedLocation === expectedId ), locationId); } function verifyRestoredOutcomes(state, information, market, dialogue) { assert.equal(state.cityStay.information.completed, true, 'Information completion must survive a CampScene restart.'); assert.equal( state.campaign.inventory[information.inventoryLabel] ?? 0, information.inventoryCount, 'The one-time information reward count must survive a CampScene restart.' ); const restoredOffer = state.cityStay.market.offers.find((offer) => offer.id === market.offerId); assert.equal(state.cityStay.market.gold, market.gold, 'The exact post-purchase gold balance must survive a CampScene restart.'); assert.equal(restoredOffer?.owned, market.owned, 'The purchased equipment count must survive a CampScene restart.'); assert.equal(state.cityStay.dialogue.completed, true, 'Companion dialogue completion must survive a CampScene restart.'); assert.equal(state.cityStay.dialogue.choiceId, dialogue.choiceId, 'The companion choice must survive a CampScene restart.'); assert.equal( state.campaign.campDialogueChoiceIds[dialogue.id], dialogue.choiceId, 'The persisted companion choice history must match the selected response.' ); } async function verifyNextBattleBriefing( page, expectedBriefingLines, nextBattleId = xiaopeiBattleId, cityName = '서주' ) { await page.evaluate((battleId) => window.__HEROS_DEBUG__?.goToBattle(battleId), 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 ); }, nextBattleId, { timeout: 90000 }); let battle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); assert.equal(battle.cityInformation.available, true, `${nextBattleId} must expose its preceding city information.`); assert.equal(battle.cityInformation.completed, true, `${nextBattleId} must see the saved ${cityName} information completion.`); assert.deepEqual( battle.cityInformation.briefingLines, expectedBriefingLines, `${nextBattleId} must expose the exact authored ${cityName} briefing lines.` ); await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); scene?.showOpeningBattleEvent?.(); }); await page.waitForFunction(() => { const battleState = window.__HEROS_DEBUG__?.battle(); return battleState?.activeBattleEvent?.key === 'opening' || battleState?.battleLog?.length > 0; }); battle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const openingEvent = battle.activeBattleEvent?.key === 'opening' ? battle.activeBattleEvent : battle.pendingBattleEvents?.find((event) => event.key === 'opening'); const briefingRecordedInLog = expectedBriefingLines.every((line) => ( battle.battleLog?.some((entry) => entry.includes(line)) )); assert( (openingEvent && expectedBriefingLines.every((line) => openingEvent.lines.includes(line))) || briefingRecordedInLog, `The opening battle briefing must record every completed ${cityName} information line: ${JSON.stringify({ openingEvent, battleLog: battle.battleLog?.slice(-4) })}` ); } 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 assertBoundsInside(bounds, container, message) { 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, `${message} ${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'); return { width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio, visualScale: window.visualViewport?.scale ?? 1, canvas: canvas ? { width: canvas.width, height: canvas.height } : null }; }); assert.deepEqual( viewport, { width: desktopBrowserViewport.width, height: desktopBrowserViewport.height, dpr: desktopBrowserDeviceScaleFactor, visualScale: 1, canvas: { width: desktopBrowserViewport.width, height: desktopBrowserViewport.height } }, 'The browser QA baseline must be an explicit 1920x1080 CSS viewport at DPR 1 and 100% zoom.' ); } 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), `Expected a valid ${sceneKey} pointer coordinate.`); await page.mouse.click(point.x, point.y); } async function captureStableScreenshot(page, path) { await page.waitForTimeout(300); 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(50); try { await page.screenshot({ path, fullPage: true }); } finally { if (loopSlept) { await page.evaluate(() => window.__HEROS_GAME__?.loop.wake()); await page.waitForTimeout(50); } } } 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)); } function parseInventoryReward(reward) { const match = /^(.*?)\s+([1-9]\d*)$/.exec(reward); assert(match, `Expected an inventory reward ending in a positive amount: ${reward}`); return { label: match[1], amount: Number(match[2]) }; }