import assert from 'node:assert/strict'; import { mkdirSync } from 'node:fs'; import { chromium } from 'playwright'; import { createServer } from 'vite'; import { desktopBrowserContextOptions, desktopBrowserDeviceScaleFactor, desktopBrowserViewport } from './desktop-browser-viewport.mjs'; const sourceBattleId = 'third-battle-guangzong-road'; const priorityBadgePattern = /우선/; const resonanceChecklistLabel = '공명 대화'; const fixtures = [ { id: 'canvas-fort-missed-quick-missed', renderer: 'canvas', expectedRendererType: 1, fortAchieved: false, quickAchieved: false, sourceTurn: 34, expectedVariant: 'fort-recovery', expectedTargetDialogueId: 'liu-guan-after-guangzong-road' }, { id: 'canvas-fort-missed-quick-achieved', renderer: 'canvas', expectedRendererType: 1, fortAchieved: false, quickAchieved: true, sourceTurn: 24, expectedVariant: 'fort-recovery', expectedTargetDialogueId: 'liu-guan-after-guangzong-road' }, { id: 'canvas-fort-achieved-quick-missed', renderer: 'canvas', expectedRendererType: 1, fortAchieved: true, quickAchieved: false, sourceTurn: 34, expectedVariant: 'tempo-recovery', expectedTargetDialogueId: 'liu-zhang-after-guangzong-road' }, { id: 'canvas-fort-achieved-quick-achieved', renderer: 'canvas', expectedRendererType: 1, fortAchieved: true, quickAchieved: true, sourceTurn: 24, expectedVariant: 'decisive-advance', expectedTargetDialogueId: 'guan-zhang-after-guangzong-road' }, { id: 'webgl-fort-achieved-quick-achieved', renderer: 'webgl', expectedRendererType: 2, fortAchieved: true, quickAchieved: true, sourceTurn: 24, expectedVariant: 'decisive-advance', expectedTargetDialogueId: 'guan-zhang-after-guangzong-road' } ]; const requestedRenderer = cliOption('renderer') ?? process.env.VERIFY_THIRD_BATTLE_PRIORITY_RETURN_RENDERER ?? 'both'; assert( ['canvas', 'webgl', 'both'].includes(requestedRenderer), `Unknown renderer "${requestedRenderer}". Use canvas, webgl, or both.` ); const selectedFixtures = fixtures.filter( ({ renderer }) => requestedRenderer === 'both' || renderer === requestedRenderer ); const server = await createServer({ logLevel: 'error', server: { host: '127.0.0.1', port: 0, hmr: false }, appType: 'spa' }); let browser; try { mkdirSync('dist', { recursive: true }); await server.listen(); const address = server.httpServer?.address(); assert( address && typeof address !== 'string', 'Expected a local Vite verification server.' ); const baseUrl = `http://127.0.0.1:${address.port}/heros_web/`; browser = await chromium.launch({ headless: process.env.VERIFY_THIRD_BATTLE_PRIORITY_RETURN_HEADLESS !== '0' }); for (const fixture of selectedFixtures) { await verifyFixture(browser, baseUrl, fixture); } console.log( `Third-battle priority-return browser verification passed for ${selectedFixtures .map(({ id }) => id) .join(', ')} at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} CSS pixels, DPR ${desktopBrowserDeviceScaleFactor}.` ); } finally { await browser?.close(); await server.close(); } async function verifyFixture(browser, baseUrl, fixture) { 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()); } }); try { const url = new URL(baseUrl); url.searchParams.set('debug', '1'); url.searchParams.set('renderer', fixture.renderer); await page.goto(url.toString(), { waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await assertFhdViewport(page, fixture); const seeded = await seedThirdVictoryCamp(page, fixture); assert.deepEqual( seeded, { step: 'third-camp', latestBattleId: sourceBattleId, reportBattleId: sourceBattleId, settlementOutcome: 'victory', sourceTurn: fixture.sourceTurn, fortAchieved: fixture.fortAchieved, quickAchieved: fixture.quickAchieved, dialogueCompleted: false, dialogueChoiceId: null }, `${fixture.id}: unexpected seeded campaign state.` ); await page.evaluate(async () => { await window.__HEROS_DEBUG__.goToCamp(); }); let camp = await waitForPriorityReturn( page, fixture, false ); assert.equal(camp.campaign.step, 'third-camp'); assert.equal(camp.campBattleId, sourceBattleId); assertPriorityReturn(camp.thirdBattlePriorityReturn, fixture, { completed: false, requireRowBounds: false }); const dialogueTab = camp.campTabs.find( ({ id }) => id === 'dialogue' ); assert(dialogueTab, `${fixture.id}: dialogue tab is missing.`); assert.equal(dialogueTab.interactive, true); assert.equal( dialogueTab.newBadgeVisible, true, `${fixture.id}: the dialogue tab must advertise the pending priority return.` ); assert.match( dialogueTab.newBadgeText ?? '', priorityBadgePattern, `${fixture.id}: the dialogue tab badge must identify the priority return.` ); assertBoundsInsideViewport( dialogueTab.bounds, `${fixture.id} dialogue tab` ); assert.equal( resonanceChecklist(camp).complete, false, `${fixture.id}: resonance dialogue must remain incomplete before the priority return.` ); await capture( page, `dist/verification-third-battle-priority-return-${fixture.id}-tab-badge.png` ); await clickSceneBounds(page, 'CampScene', dialogueTab.bounds); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp?.()?.activeTab === 'dialogue' ); camp = await waitForPriorityReturn(page, fixture, false, true); const priorityReturn = camp.thirdBattlePriorityReturn; assertPriorityReturn(priorityReturn, fixture, { completed: false, requireRowBounds: true }); assertBoundsInsideViewport( priorityReturn.rowBounds, `${fixture.id} priority dialogue row` ); let badgeProbe = await readPriorityRowBadge( page, priorityReturn.rowBounds ); assert.equal( badgeProbe.markers.length, 1, `${fixture.id}: expected exactly one visible priority marker in the target row: ${JSON.stringify(badgeProbe)}` ); assert.match( badgeProbe.markers[0].text, priorityBadgePattern ); const rowControl = await readDialogueControlPoint( page, 'row', fixture.expectedTargetDialogueId ); await page.mouse.click(rowControl.x, rowControl.y); await page.waitForFunction( (dialogueId) => window.__HEROS_DEBUG__?.camp?.()?.selectedDialogue ?.id === dialogueId, fixture.expectedTargetDialogueId ); camp = await readCamp(page); const selectedDialogue = camp.selectedDialogue; assert.equal( selectedDialogue.id, fixture.expectedTargetDialogueId ); assert.equal( selectedDialogue.title, priorityReturn.dialogueTitle ); assert.deepEqual( selectedDialogue.lines, priorityReturn.dialogueLines ); assert.equal(selectedDialogue.completed, false); assert( selectedDialogue.choices.length > 0, `${fixture.id}: the priority dialogue must expose at least one completion choice.` ); selectedDialogue.choices.forEach((choice) => { assertBoundsInsideViewport( choice.bounds, `${fixture.id} choice ${choice.id}` ); }); assertNoOverlappingBounds( selectedDialogue.choices.map(({ id, bounds }) => ({ id, bounds })), `${fixture.id} dialogue choices` ); assertVariantNarrative(priorityReturn, fixture); await capture( page, `dist/verification-third-battle-priority-return-${fixture.id}-pending-dialogue.png` ); const selectedChoice = selectedDialogue.choices[0]; const choiceControl = await readDialogueControlPoint( page, 'choice', fixture.expectedTargetDialogueId, selectedChoice.id ); await page.mouse.click(choiceControl.x, choiceControl.y); await page.waitForFunction( ({ dialogueId, choiceId }) => { const camp = window.__HEROS_DEBUG__?.camp?.(); return ( camp?.thirdBattlePriorityReturn?.completed === true && camp.thirdBattlePriorityReturn.pending === false && camp?.selectedDialogue?.id === dialogueId && camp.selectedDialogue.completed === true && camp?.campaign?.campDialogueChoiceIds?.[dialogueId] === choiceId ); }, { dialogueId: fixture.expectedTargetDialogueId, choiceId: selectedChoice.id } ); camp = await readCamp(page); assertPriorityReturn(camp.thirdBattlePriorityReturn, fixture, { completed: true, requireRowBounds: true }); assert.equal( camp.selectedDialogue.id, fixture.expectedTargetDialogueId ); assert.equal(camp.selectedDialogue.completed, true); assert.equal( camp.campaign.completedCampDialogues.filter( (id) => id === fixture.expectedTargetDialogueId ).length, 1, `${fixture.id}: priority dialogue completion must be recorded exactly once.` ); assert.equal( camp.campaign.campDialogueChoiceIds[ fixture.expectedTargetDialogueId ], selectedChoice.id ); assert.equal( camp.campTabs.find(({ id }) => id === 'dialogue') ?.newBadgeVisible, false, `${fixture.id}: the dialogue tab priority badge must clear after completion.` ); assert.equal( resonanceChecklist(camp).complete, true, `${fixture.id}: completing the one priority return must satisfy the resonance checklist.` ); badgeProbe = await readPriorityRowBadge( page, camp.thirdBattlePriorityReturn.rowBounds ); assert.equal( badgeProbe.markers.length, 0, `${fixture.id}: the row priority badge must disappear after completion: ${JSON.stringify(badgeProbe)}` ); assertPersistedDialogue( await readCampaignSave(page), fixture.expectedTargetDialogueId, selectedChoice.id, `${fixture.id} completed` ); await capture( page, `dist/verification-third-battle-priority-return-${fixture.id}-completed.png` ); await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await assertFhdViewport(page, fixture); await page.evaluate(async () => { await window.__HEROS_DEBUG__.goToCamp(); }); camp = await waitForPriorityReturn(page, fixture, true); assertPriorityReturn(camp.thirdBattlePriorityReturn, fixture, { completed: true, requireRowBounds: false }); assert.equal( camp.campTabs.find(({ id }) => id === 'dialogue') ?.newBadgeVisible, false, `${fixture.id}: the dialogue tab badge must remain cleared after Camp re-entry.` ); assert.equal( resonanceChecklist(camp).complete, true, `${fixture.id}: the resonance checklist must remain complete after Camp re-entry.` ); assertPersistedDialogue( await readCampaignSave(page), fixture.expectedTargetDialogueId, selectedChoice.id, `${fixture.id} re-entered` ); const reenteredDialogueTab = camp.campTabs.find( ({ id }) => id === 'dialogue' ); await clickSceneBounds( page, 'CampScene', reenteredDialogueTab.bounds ); camp = await waitForPriorityReturn(page, fixture, true, true); const reenteredRow = await readDialogueControlPoint( page, 'row', fixture.expectedTargetDialogueId ); await page.mouse.click(reenteredRow.x, reenteredRow.y); await page.waitForFunction( (dialogueId) => { const selected = window.__HEROS_DEBUG__?.camp?.()?.selectedDialogue; return ( selected?.id === dialogueId && selected.completed === true ); }, fixture.expectedTargetDialogueId ); camp = await readCamp(page); assert.equal(camp.selectedDialogue.completed, true); badgeProbe = await readPriorityRowBadge( page, camp.thirdBattlePriorityReturn.rowBounds ); assert.equal( badgeProbe.markers.length, 0, `${fixture.id}: the priority marker must stay absent after Camp re-entry.` ); await capture( page, `dist/verification-third-battle-priority-return-${fixture.id}-reentered.png` ); assert.deepEqual( pageErrors, [], `${fixture.id}: expected no browser page errors: ${JSON.stringify(pageErrors)}` ); assert.deepEqual( consoleErrors.filter( (message) => !message.includes( 'The AudioContext was not allowed to start' ) ), [], `${fixture.id}: expected no browser console errors: ${JSON.stringify(consoleErrors)}` ); } finally { await context.close(); } } async function seedThirdVictoryCamp(page, fixture) { return page.evaluate( async ({ battleId, expectedDialogueId, fortAchieved, quickAchieved, sourceTurn }) => { 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[battleId]; if (!scenario) { throw new Error(`Missing battle scenario ${battleId}.`); } const objectives = scenario.objectives.map( (objective) => { const achieved = objective.id === 'fort' ? fortAchieved : objective.id === 'quick' ? quickAchieved : true; return { id: objective.id, label: objective.label, achieved, status: achieved ? 'done' : 'failed', detail: achieved ? '달성' : '미달성', category: objective.id === 'fort' || objective.id === 'quick' ? 'bonus' : 'required', rewardGold: objective.rewardGold }; } ); campaignModule.resetCampaignState(); campaignModule.setFirstBattleReport({ battleId: scenario.id, battleTitle: scenario.title, outcome: 'victory', turnNumber: sourceTurn, rewardGold: scenario.baseVictoryGold, defeatedEnemies: scenario.units.filter( (unit) => unit.faction === 'enemy' ).length, totalEnemies: scenario.units.filter( (unit) => unit.faction === 'enemy' ).length, objectives, units: scenario.units, bonds: scenario.bonds.map((bond) => ({ ...bond, battleExp: 0 })), itemRewards: [], completedCampDialogues: [], completedCampVisits: [], createdAt: '2026-07-27T00:00:00.000Z' }); let campaign = campaignModule.getCampaignState(); campaign.dismissedVictoryRewardNoticeBattleIds = [ ...new Set([ ...campaign.dismissedVictoryRewardNoticeBattleIds, battleId ]) ]; campaignModule.setCampaignState(campaign); campaignModule.completeCampaignAftermath(scenario.id); campaign = campaignModule.getCampaignState(); const settlement = campaign.battleHistory[battleId]; return { step: campaign.step, latestBattleId: campaign.latestBattleId ?? null, reportBattleId: campaign.firstBattleReport?.battleId ?? null, settlementOutcome: settlement?.outcome ?? null, sourceTurn: settlement?.turnNumber ?? null, fortAchieved: settlement?.objectives.find( ({ id }) => id === 'fort' )?.achieved ?? null, quickAchieved: settlement?.objectives.find( ({ id }) => id === 'quick' )?.achieved ?? null, dialogueCompleted: campaign.completedCampDialogues.includes( expectedDialogueId ), dialogueChoiceId: campaign.campDialogueChoiceIds[ expectedDialogueId ] ?? null }; }, { battleId: sourceBattleId, expectedDialogueId: fixture.expectedTargetDialogueId, fortAchieved: fixture.fortAchieved, quickAchieved: fixture.quickAchieved, sourceTurn: fixture.sourceTurn } ); } async function waitForDebugApi(page) { await page.waitForFunction( () => document.querySelector('canvas') !== null && window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 } ); } async function waitForPriorityReturn( page, fixture, completed, requireRowBounds = false ) { try { await page.waitForFunction( ({ battleId, expectedVariant, expectedTargetDialogueId, fortAchieved, quickAchieved, sourceTurn, completed, requireRowBounds }) => { const debug = window.__HEROS_DEBUG__; const camp = debug?.camp?.(); const priority = camp?.thirdBattlePriorityReturn; return ( debug?.activeScenes?.().includes('CampScene') && camp?.scene === 'CampScene' && camp?.campaign?.step === 'third-camp' && camp?.campBattleId === battleId && camp?.campTabs?.some(({ id }) => id === 'dialogue') && priority?.available === true && priority.sourceBattleId === battleId && priority.sourceTurn === sourceTurn && priority.fortAchieved === fortAchieved && priority.quickAchieved === quickAchieved && priority.variant === expectedVariant && priority.targetDialogueId === expectedTargetDialogueId && priority.completed === completed && priority.pending === !completed && (!requireRowBounds || Boolean(priority.rowBounds)) ); }, { battleId: sourceBattleId, expectedVariant: fixture.expectedVariant, expectedTargetDialogueId: fixture.expectedTargetDialogueId, fortAchieved: fixture.fortAchieved, quickAchieved: fixture.quickAchieved, sourceTurn: fixture.sourceTurn, completed, requireRowBounds }, { timeout: 90000 } ); } catch (error) { const diagnostic = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], camp: window.__HEROS_DEBUG__?.camp?.() ?? null })); throw new Error( `${fixture.id}: CampScene did not expose the expected thirdBattlePriorityReturn contract: ${JSON.stringify(diagnostic)}`, { cause: error } ); } return readCamp(page); } function assertPriorityReturn( priority, fixture, { completed, requireRowBounds } ) { assert( priority, `${fixture.id}: thirdBattlePriorityReturn is missing.` ); assert.equal(priority.available, true); assert.equal(priority.sourceBattleId, sourceBattleId); assert.equal(priority.sourceTurn, fixture.sourceTurn); assert.equal( priority.fortAchieved, fixture.fortAchieved ); assert.equal( priority.quickAchieved, fixture.quickAchieved ); assert.equal(priority.variant, fixture.expectedVariant); assert.equal( priority.targetDialogueId, fixture.expectedTargetDialogueId ); assert.equal(typeof priority.dialogueTitle, 'string'); assert(priority.dialogueTitle.length > 0); assert.equal(priority.dialogueLines.length, 3); assert( priority.dialogueLines.every( (line) => typeof line === 'string' && line.length > 0 ) ); assert.equal(priority.completed, completed); assert.equal(priority.pending, !completed); if (requireRowBounds) { assertFiniteBounds( priority.rowBounds, `${fixture.id} priority row` ); } } function assertVariantNarrative(priority, fixture) { const text = [ priority.dialogueTitle, ...priority.dialogueLines ].join('\n'); if (!fixture.fortAchieved) { assert.match( text, /요새/, `${fixture.id}: fort-recovery dialogue must discuss the fort.` ); assert.match( text, /확보하지 못|비워 둔|남겼/, `${fixture.id}: fort-recovery dialogue must not praise a missed fort objective.` ); return; } if (!fixture.quickAchieved) { assert.match( text, /늦|때를 놓쳤|제한 초과/, `${fixture.id}: tempo-recovery dialogue must acknowledge the missed quick objective.` ); return; } assert.match( text, /확보|길이 열렸/, `${fixture.id}: decisive-advance dialogue must acknowledge the achieved fort objective.` ); assert.match( text, new RegExp(`${fixture.sourceTurn}턴`), `${fixture.id}: decisive-advance dialogue must retain the source turn.` ); } function resonanceChecklist(camp) { const item = camp?.sortieChecklist?.find( ({ label }) => label === resonanceChecklistLabel ); assert( item, `CampScene must expose a "${resonanceChecklistLabel}" checklist item: ${JSON.stringify(camp?.sortieChecklist)}` ); return item; } async function readPriorityRowBadge(page, rowBounds) { return page.evaluate((requestedRowBounds) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const objectBounds = (object) => { const bounds = object?.getBounds?.(); return bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null; }; const intersects = (left, right) => Boolean( left && right && left.x < right.x + right.width && left.x + left.width > right.x && left.y < right.y + right.height && left.y + left.height > right.y ); const markers = (scene?.contentObjects ?? []) .filter( (object) => object?.type === 'Text' && object.active && object.visible && typeof object.text === 'string' && object.text.includes('우선') ) .map((object) => ({ text: object.text, bounds: objectBounds(object) })) .filter(({ bounds }) => intersects(bounds, requestedRowBounds) ); return { rowBounds: requestedRowBounds, markers }; }, rowBounds); } async function readDialogueControlPoint( page, control, dialogueId, choiceId ) { const probe = await page.evaluate( ({ control, dialogueId, choiceId }) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const state = window.__HEROS_DEBUG__?.camp?.(); const canvas = document.querySelector('canvas'); const priority = state?.thirdBattlePriorityReturn; const dialogue = state?.selectedDialogue?.id === dialogueId ? state.selectedDialogue : null; const choice = dialogue?.choices?.find( ({ id }) => id === choiceId ); const bounds = control === 'row' ? priority?.targetDialogueId === dialogueId ? priority.rowBounds : null : choice?.bounds; const interactiveObject = control === 'row' ? scene?.campDialogueRowButtons?.[dialogueId] : scene?.campDialogueChoiceButtons?.[ `${dialogueId}:${choiceId}` ]; if ( !scene || !canvas || !bounds || !interactiveObject?.input?.enabled ) { return { ready: false, control, dialogueId, choiceId: choiceId ?? null, bounds: bounds ?? null, priority: priority ?? null, selectedDialogue: state?.selectedDialogue ?? null }; } const canvasBounds = canvas.getBoundingClientRect(); const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; return { ready: true, control, dialogueId, choiceId: choiceId ?? null, bounds, x: canvasBounds.left + (centerX * canvasBounds.width) / scene.scale.width, y: canvasBounds.top + (centerY * canvasBounds.height) / scene.scale.height }; }, { control, dialogueId, choiceId } ); assert( probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), `Expected an interactive ${control} for ${dialogueId}: ${JSON.stringify(probe)}` ); assertBoundsInsideViewport( probe.bounds, `${dialogueId} ${control}` ); return probe; } async function clickSceneBounds(page, sceneKey, bounds) { assertFiniteBounds(bounds, `${sceneKey} click target`); const point = await page.evaluate( ({ sceneKey, bounds }) => { const scene = window.__HEROS_GAME__?.scene.getScene(sceneKey); const canvas = document.querySelector('canvas'); const canvasBounds = canvas?.getBoundingClientRect(); if (!scene || !canvasBounds) { return null; } const centerX = bounds.x + bounds.width / 2; const centerY = bounds.y + bounds.height / 2; return { x: canvasBounds.left + (centerX * canvasBounds.width) / scene.scale.width, y: canvasBounds.top + (centerY * canvasBounds.height) / scene.scale.height }; }, { sceneKey, bounds } ); assert( point && Number.isFinite(point.x) && Number.isFinite(point.y), `Unable to map ${sceneKey} bounds to a browser point: ${JSON.stringify(bounds)}` ); await page.mouse.click(point.x, point.y); } async function readCamp(page) { return page.evaluate(() => window.__HEROS_DEBUG__?.camp?.() ); } async function readCampaignSave(page) { return page.evaluate(() => { const raw = window.localStorage.getItem( 'heros-web:campaign-state' ); return raw ? JSON.parse(raw) : null; }); } function assertPersistedDialogue( save, targetDialogueId, choiceId, label ) { assert(save, `${label}: current campaign save is missing.`); assert.equal(save.step, 'third-camp'); assert.equal(save.latestBattleId, sourceBattleId); assert.equal( save.completedCampDialogues.filter( (id) => id === targetDialogueId ).length, 1, `${label}: the priority dialogue completion must persist exactly once.` ); assert.equal( save.firstBattleReport?.completedCampDialogues?.filter( (id) => id === targetDialogueId ).length, 1, `${label}: the report completion must persist exactly once.` ); assert.equal( save.campDialogueChoiceIds[targetDialogueId], choiceId, `${label}: the selected priority response must persist.` ); } 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) { assertFiniteBounds(bounds, label); const epsilon = 0.01; assert( bounds.x >= -epsilon && bounds.y >= -epsilon && bounds.x + bounds.width <= desktopBrowserViewport.width + epsilon && bounds.y + bounds.height <= desktopBrowserViewport.height + epsilon, `${label}: expected bounds inside the 1920x1080 viewport, received ${JSON.stringify(bounds)}` ); } function assertFiniteBounds(bounds, label) { assert(bounds, `${label}: bounds are required.`); assert( ['x', 'y', 'width', 'height'].every((key) => Number.isFinite(bounds[key]) ) && bounds.width > 0 && bounds.height > 0, `${label}: expected positive finite bounds, received ${JSON.stringify(bounds)}` ); } async function assertFhdViewport(page, fixture) { 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, 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(viewport.width, desktopBrowserViewport.width); assert.equal( viewport.height, desktopBrowserViewport.height ); assert.equal( viewport.dpr, desktopBrowserDeviceScaleFactor ); assert.equal(viewport.visualScale, 1); assert.equal( viewport.rendererType, fixture.expectedRendererType, `${fixture.id}: Phaser did not use the requested renderer.` ); 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 capture(page, path) { await page.waitForTimeout(250); 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); } } } function cliOption(name) { const equalsPrefix = `--${name}=`; const equalsOption = process.argv.find((argument) => argument.startsWith(equalsPrefix) ); if (equalsOption) { return equalsOption.slice(equalsPrefix.length); } const index = process.argv.indexOf(`--${name}`); return index >= 0 ? process.argv[index + 1] : undefined; }