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 sceneKey = 'CampVisitExplorationScene'; const sourceBattleId = 'first-battle-zhuo-commandery'; const visitId = 'first-pursuit-scout-tent'; const choiceId = 'trace-river-ambush'; const choiceLabel = '강가 매복 흔적을 쫓는다'; const rewardItemLabel = '상처약'; const rewardItemText = '상처약 1'; const bondId = 'liu-bei__jian-yong'; const expectedActors = { 'jian-yong': { x: 510, y: 492, textureKey: 'exploration-jian-yong' }, 'guan-yu': { x: 1015, y: 565, textureKey: 'exploration-guan-yu' }, 'zhang-fei': { x: 1270, y: 595, textureKey: 'exploration-zhang-fei' } }; const targetUrl = withDebugOptions( process.env.VERIFY_FIRST_PURSUIT_CAMP_EXPLORATION_URL ?? 'http://127.0.0.1:41798/' ); let serverProcess; let browser; try { mkdirSync('dist', { recursive: true }); serverProcess = await ensureLocalServer(targetUrl); browser = await chromium.launch({ headless: process.env.VERIFY_FIRST_PURSUIT_CAMP_EXPLORATION_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 page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 90000 }); await page.evaluate(() => window.localStorage.clear()); await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await assertDesktopViewport(page); const seeded = await seedFirstVictoryCamp(page); assert.equal(seeded.step, 'first-camp'); assert.equal(seeded.latestBattleId, sourceBattleId); assert.equal(seeded.reportBattleId, sourceBattleId); assert.equal(seeded.reportOutcome, 'victory'); assert.equal(seeded.settlementOutcome, 'victory'); assert.equal(seeded.pendingAftermathBattleId, null); assert.equal(seeded.completed, false); assert.equal(seeded.choiceId, null); assert.equal(seeded.rewardItemAmount, 0); assert( seeded.bond, `The first-victory seed must include ${bondId}: ${JSON.stringify(seeded)}` ); await page.evaluate(async () => { await window.__HEROS_DEBUG__.goToCampVisitExploration(); }); await waitForExplorationReady(page); await page.waitForTimeout(380); let exploration = await readExploration(page); assert.equal(exploration.scene, sceneKey); assert.equal(exploration.locationId, visitId); assert.equal(exploration.campaignStep, 'first-camp'); assert.deepEqual(exploration.viewport, desktopBrowserViewport); assert.equal(exploration.background.key, 'first-pursuit-camp-background'); assert.equal(exploration.background.ready, true); assert.equal(exploration.background.fallback, false); assert.equal( exploration.background.sourceWidth, desktopBrowserViewport.width ); assert.equal( exploration.background.sourceHeight, desktopBrowserViewport.height ); assert.deepEqual(exploration.background.bounds, { x: 0, y: 0, width: desktopBrowserViewport.width, height: desktopBrowserViewport.height }); assert.equal(exploration.requiredTexturesReady, true); assert.deepEqual( exploration.requiredTextures, [ { key: 'exploration-liu-bei', ready: true }, { key: 'exploration-jian-yong', ready: true }, { key: 'exploration-guan-yu', ready: true }, { key: 'exploration-zhang-fei', ready: true } ] ); assert.equal(exploration.player.textureKey, 'exploration-liu-bei'); assert.deepEqual( { x: exploration.player.x, y: exploration.player.y }, { x: 780, y: 720 } ); assert.equal(exploration.movement.speed, 300); assert.equal(exploration.interaction.radius, 122); assert.equal(exploration.interaction.promptRadius, 164); assert.equal(exploration.visit.completed, false); assert.equal(exploration.visit.choiceId, null); assertActorsFixed(exploration, 'initial camp'); assertBoundsInsideViewport( exploration.background.bounds, 'exploration background' ); assertBoundsInsideViewport(exploration.player.bounds, 'initial Liu Bei'); exploration.actors.forEach((actor) => assertBoundsInsideViewport(actor.bounds, `initial ${actor.name}`) ); const jianYongSource = await readTextureSource( page, 'exploration-jian-yong' ); assert.equal(jianYongSource.exists, true); assert.match( decodeURIComponent(jianYongSource.url), /\/exploration-jian-yong\.webp(?:$|[?#])/, `Jian Yong must use his dedicated SD sheet instead of a generic fallback: ${JSON.stringify(jianYongSource)}` ); assert.equal(jianYongSource.width, 3072); assert.equal(jianYongSource.height, 768); await captureStableScreenshot( page, 'dist/verification-first-pursuit-camp-exploration-initial.png' ); const beforeKeyboardMove = exploration.player; await page.keyboard.down('ArrowLeft'); try { await page.waitForFunction( ({ key, startX }) => window.__HEROS_DEBUG__ ?.scene(key) ?.getDebugState?.()?.player?.x <= startX - 65, { key: sceneKey, startX: beforeKeyboardMove.x }, { timeout: 3000 } ); } finally { await page.keyboard.up('ArrowLeft'); } await page.waitForTimeout(100); exploration = await readExploration(page); assert( beforeKeyboardMove.x - exploration.player.x >= 65, `Expected real keyboard movement to move Liu Bei left: ${JSON.stringify({ before: beforeKeyboardMove, after: exploration.player })}` ); assert( Math.abs(beforeKeyboardMove.y - exploration.player.y) <= 2, `Horizontal keyboard movement must retain Y: ${JSON.stringify({ before: beforeKeyboardMove, after: exploration.player })}` ); assert.equal(exploration.player.moving, false); assertActorsFixed(exploration, 'after keyboard movement'); assert( nearestActorDistance(exploration) > exploration.interaction.radius && exploration.exit.distance > exploration.interaction.radius, `The distant-interaction probe must start outside every interaction radius: ${JSON.stringify({ player: exploration.player, actors: exploration.actors.map(({ id, distance }) => ({ id, distance })), exit: exploration.exit })}` ); const savesBeforeDistantInteraction = await readCampaignSaves(page); await page.keyboard.press('e'); await page.waitForTimeout(150); exploration = await readExploration(page); assert.equal( exploration.dialogue.active, false, 'Pressing E outside the interaction radius must not open dialogue.' ); assert.equal(exploration.choice.open, false); assert.equal(exploration.visit.completed, false); assert.equal(exploration.lastNotice, '조금 더 가까이 다가가세요.'); assertRelevantProgressEqual( savesBeforeDistantInteraction, await readCampaignSaves(page), 'distant E interaction' ); const jianYong = actorById(exploration, 'jian-yong'); const clickStart = { ...exploration.player }; await clickScenePoint(page, jianYong.x, jianYong.y); await page.waitForFunction( ({ key, actorId, startX, startY }) => { const state = window.__HEROS_DEBUG__ ?.scene(key) ?.getDebugState?.(); return ( state?.movement?.targetId === actorId && Math.hypot( state.player.x - startX, state.player.y - startY ) >= 10 ); }, { key: sceneKey, actorId: 'jian-yong', startX: clickStart.x, startY: clickStart.y }, { timeout: 5000 } ); await page.waitForFunction( ({ key, actorId }) => { const state = window.__HEROS_DEBUG__ ?.scene(key) ?.getDebugState?.(); return ( state?.movement?.target === null && state?.movement?.lastTargetId === actorId && state?.interaction?.targetId === actorId && state?.interaction?.canInteract === true ); }, { key: sceneKey, actorId: 'jian-yong' }, { timeout: 12000 } ); exploration = await readExploration(page); assert( actorById(exploration, 'jian-yong').distance <= exploration.interaction.radius, `Click movement must stop inside Jian Yong's interaction radius: ${JSON.stringify(exploration.interaction)}` ); assertActorsFixed(exploration, 'after click navigation'); await page.keyboard.press('e'); await page.waitForFunction( (key) => { const state = window.__HEROS_DEBUG__ ?.scene(key) ?.getDebugState?.(); return ( state?.dialogue?.active === true && state.dialogue.sourceNpcId === 'jian-yong' ); }, sceneKey ); exploration = await readExploration(page); assert.equal(exploration.dialogue.speaker, '간옹'); assert.equal(exploration.dialogue.portraitVisible, true); assert.equal(exploration.dialogue.portraitFrameVisible, true); assert.equal( exploration.dialogue.portraitTextureKey, 'portrait-jian-yong-campaign' ); assert.equal(exploration.dialogue.totalLines, 2); assertBoundsInsideViewport( exploration.dialogue.bounds, 'Jian Yong dialogue' ); await captureStableScreenshot( page, 'dist/verification-first-pursuit-camp-exploration-dialogue.png' ); await advanceDialogueUntilChoice(page); await page.waitForFunction( (key) => window.__HEROS_DEBUG__ ?.scene(key) ?.getDebugState?.()?.choice?.ready === true, sceneKey ); exploration = await readExploration(page); assert.equal(exploration.dialogue.active, false); assert.equal(exploration.choice.open, true); assert.deepEqual( exploration.choice.choices.map( ({ id, label, rewardText, interactive }) => ({ id, label, rewardText, interactive }) ), [ { id: choiceId, label: choiceLabel, rewardText: `공명 +10 · ${rewardItemText}`, interactive: true }, { id: 'mark-village-relief', label: '마을 구호 길을 표시한다', rewardText: '공명 +10 · 콩 1', interactive: true } ] ); assertBoundsInsideViewport( exploration.choice.panelBounds, 'scout choice panel' ); exploration.choice.choices.forEach((choice) => assertBoundsInside( choice.bounds, exploration.choice.panelBounds, `choice ${choice.id}` ) ); assertNoOverlappingBounds( exploration.choice.choices.map(({ id, bounds }) => ({ id, bounds })), 'scout choices' ); await captureStableScreenshot( page, 'dist/verification-first-pursuit-camp-exploration-choice.png' ); const savesBeforeChoice = await readCampaignSaves(page); const bondBeforeChoice = campaignBond( savesBeforeChoice.current, bondId ); assert( bondBeforeChoice, `Missing ${bondId} before the scout choice: ${JSON.stringify(savesBeforeChoice.current?.bonds)}` ); const selectedChoice = exploration.choice.choices.find( (choice) => choice.id === choiceId ); assert(selectedChoice, `Missing choice ${choiceId}.`); await clickSceneBounds(page, selectedChoice.bounds); await page.waitForFunction( ({ key, expectedVisitId, expectedChoiceId }) => { const state = window.__HEROS_DEBUG__ ?.scene(key) ?.getDebugState?.(); return ( state?.visit?.completed === true && state.visit.id === expectedVisitId && state.visit.choiceId === expectedChoiceId && state.dialogue.active === true ); }, { key: sceneKey, expectedVisitId: visitId, expectedChoiceId: choiceId } ); exploration = await readExploration(page); assert.equal(exploration.visit.choiceLabel, choiceLabel); assert.deepEqual(exploration.visit.itemRewards, [rewardItemText]); assert.equal(exploration.visit.bondExp, 10); assert.equal(exploration.dialogue.speaker, '간옹'); assert.equal(exploration.dialogue.portraitVisible, true); assert.equal( exploration.dialogue.portraitTextureKey, 'portrait-jian-yong-campaign' ); assert.equal(actorById(exploration, 'jian-yong').completed, true); assertActorsFixed(exploration, 'after scout completion'); const savesAfterChoice = await readCampaignSaves(page); assertScoutRewardSaved( savesBeforeChoice, savesAfterChoice, bondBeforeChoice ); await advanceDialogueUntilClosed(page); exploration = await readExploration(page); assert.equal(exploration.dialogue.active, false); assert.equal(exploration.choice.open, false); assert.equal(exploration.visit.completed, true); assertActorsFixed(exploration, 'completed camp'); await captureStableScreenshot( page, 'dist/verification-first-pursuit-camp-exploration-completed.png' ); const duplicateDebugResult = await page.evaluate( ({ key, expectedChoiceId }) => window.__HEROS_DEBUG__ ?.scene(key) ?.debugChooseScout?.(expectedChoiceId) ?? null, { key: sceneKey, expectedChoiceId: choiceId } ); assert.equal( duplicateDebugResult, false, 'The scene debug choice hook must reject a completed visit.' ); await page.keyboard.press('e'); await page.waitForFunction( (key) => window.__HEROS_DEBUG__ ?.scene(key) ?.getDebugState?.()?.dialogue?.sourceNpcId === 'jian-yong', sceneKey ); await advanceDialogueUntilClosed(page); exploration = await readExploration(page); assert.equal( exploration.choice.open, false, 'Revisiting Jian Yong after completion must replay the result without reopening choices.' ); assertRelevantProgressEqual( savesAfterChoice, await readCampaignSaves(page), 'completed Jian Yong revisit' ); const exitStart = { ...exploration.player }; await clickScenePoint(page, exploration.exit.x, exploration.exit.y); await page.waitForFunction( ({ key, exitId, startX, startY }) => { const state = window.__HEROS_DEBUG__ ?.scene(key) ?.getDebugState?.(); return ( state?.movement?.targetId === exitId && Math.hypot( state.player.x - startX, state.player.y - startY ) >= 10 ); }, { key: sceneKey, exitId: 'camp-exit', startX: exitStart.x, startY: exitStart.y }, { timeout: 5000 } ); await page.waitForFunction( ({ key, exitId }) => { const state = window.__HEROS_DEBUG__ ?.scene(key) ?.getDebugState?.(); return ( state?.movement?.target === null && state?.movement?.lastTargetId === exitId && state?.interaction?.targetId === exitId && state?.interaction?.canInteract === true ); }, { key: sceneKey, exitId: 'camp-exit' }, { timeout: 12000 } ); await page.keyboard.press('e'); await waitForCampReturn(page); const camp = await page.evaluate(() => window.__HEROS_DEBUG__?.camp?.() ); assert.equal(camp.campaign.step, 'first-camp'); assert.equal(camp.campBattleId, sourceBattleId); assert.equal(camp.firstPursuitScoutMemory.completed, true); assert.equal(camp.firstPursuitScoutMemory.choiceId, choiceId); assert.equal( camp.firstPursuitScoutMemory.activeForNextSortie, true ); assertRelevantProgressEqual( savesAfterChoice, await readCampaignSaves(page), 'CampScene return' ); assert.deepEqual( pageErrors, [], `Expected no browser page errors: ${JSON.stringify(pageErrors)}` ); assert.deepEqual( consoleErrors.filter( (message) => !message.includes('The AudioContext was not allowed to start') ), [], `Expected no browser console errors: ${JSON.stringify(consoleErrors)}` ); console.log( `Verified the first-pursuit camp exploration at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` + `DPR ${desktopBrowserDeviceScaleFactor}: dedicated Jian Yong SD art, fixed NPC positions, keyboard and pointer movement, ` + 'distance-gated interaction, face portraits, two real choices, one-time resonance/item rewards, duplicate protection, ' + 'CampScene return, and current/slot-1 save persistence.' ); } 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', process.env.VERIFY_FIRST_PURSUIT_CAMP_EXPLORATION_RENDERER ?? 'canvas' ); 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 seedFirstVictoryCamp(page) { return page.evaluate( async ({ battleId, expectedVisitId, expectedBondId, itemLabel }) => { 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}.`); } campaignModule.resetCampaignState(); campaignModule.setFirstBattleReport({ battleId: scenario.id, battleTitle: scenario.title, outcome: 'victory', turnNumber: 6, 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' }); campaignModule.completeCampaignAftermath(scenario.id); const campaign = campaignModule.getCampaignState(); const bond = campaign.bonds.find( (candidate) => candidate.id === expectedBondId ); return { step: campaign.step, latestBattleId: campaign.latestBattleId ?? null, reportBattleId: campaign.firstBattleReport?.battleId ?? null, reportOutcome: campaign.firstBattleReport?.outcome ?? null, settlementOutcome: campaign.battleHistory[battleId]?.outcome ?? null, pendingAftermathBattleId: campaign.pendingAftermathBattleId ?? null, completed: campaign.completedCampVisits.includes(expectedVisitId), choiceId: campaign.campVisitChoiceIds[expectedVisitId] ?? null, rewardItemAmount: campaign.inventory[itemLabel] ?? 0, bond: bond ? { level: bond.level, exp: bond.exp, battleExp: bond.battleExp } : null }; }, { battleId: sourceBattleId, expectedVisitId: visitId, expectedBondId: bondId, itemLabel: rewardItemLabel } ); } async function waitForExplorationReady(page) { try { await page.waitForFunction( (key) => { const debug = window.__HEROS_DEBUG__; const state = debug?.scene(key)?.getDebugState?.(); return ( debug?.activeScenes?.().includes(key) && state?.scene === key && state?.ready === true ); }, sceneKey, { timeout: 90000 } ); } catch (error) { const diagnostic = await page.evaluate((key) => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes?.() ?? [], exploration: window.__HEROS_DEBUG__?.scene(key)?.getDebugState?.() ?? null, camp: window.__HEROS_DEBUG__?.camp?.() ?? null }), sceneKey); throw new Error( `Camp visit exploration did not become ready: ${JSON.stringify(diagnostic)}`, { cause: error } ); } } async function waitForCampReturn(page) { await page.waitForFunction( () => { const debug = window.__HEROS_DEBUG__; const camp = debug?.camp?.(); return ( debug?.activeScenes?.().includes('CampScene') && !debug?.activeScenes?.().includes('CampVisitExplorationScene') && camp?.scene === 'CampScene' && camp?.campaign?.step === 'first-camp' ); }, undefined, { timeout: 90000 } ); } async function readExploration(page) { return page.evaluate((key) => window.__HEROS_DEBUG__?.scene(key)?.getDebugState?.() , sceneKey); } async function readTextureSource(page, textureKey) { return page.evaluate( ({ key, requestedTextureKey }) => { const scene = window.__HEROS_DEBUG__?.scene(key); const texture = scene?.textures?.get(requestedTextureKey); const source = texture?.source?.[0]; const image = source?.image; return { exists: Boolean(scene?.textures?.exists(requestedTextureKey)), url: image?.currentSrc ?? image?.src ?? '', width: source?.width ?? image?.naturalWidth ?? image?.width ?? null, height: source?.height ?? image?.naturalHeight ?? image?.height ?? null }; }, { key: sceneKey, requestedTextureKey: textureKey } ); } async function advanceDialogueUntilChoice(page) { for (let attempt = 0; attempt < 8; attempt += 1) { const state = await readExploration(page); if (state?.choice?.open) { return; } if (!state?.dialogue?.active) { throw new Error( `Dialogue closed without opening the scout choice: ${JSON.stringify(state)}` ); } await page.keyboard.press('e'); await page.waitForTimeout(140); } throw new Error( `Scout choice did not open: ${JSON.stringify(await readExploration(page))}` ); } async function advanceDialogueUntilClosed(page) { for (let attempt = 0; attempt < 8; attempt += 1) { const state = await readExploration(page); if (!state?.dialogue?.active) { return; } await page.keyboard.press('e'); await page.waitForTimeout(140); } throw new Error( `Camp exploration dialogue did not close: ${JSON.stringify(await readExploration(page))}` ); } async function clickScenePoint(page, x, y) { const point = await page.evaluate( ({ key, sceneX, sceneY }) => { const scene = window.__HEROS_DEBUG__?.scene(key); const canvas = document.querySelector('canvas'); const canvasBounds = canvas?.getBoundingClientRect(); if (!scene || !canvasBounds) { return null; } return { x: canvasBounds.left + (sceneX * canvasBounds.width) / scene.scale.width, y: canvasBounds.top + (sceneY * canvasBounds.height) / scene.scale.height }; }, { key: sceneKey, sceneX: x, sceneY: y } ); assert( point && Number.isFinite(point.x) && Number.isFinite(point.y), `Unable to map scene point ${JSON.stringify({ x, y })}.` ); await page.mouse.click(point.x, point.y); } async function clickSceneBounds(page, bounds) { await clickScenePoint( page, bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 ); } async function readCampaignSaves(page) { return page.evaluate(() => { const parse = (key) => { const raw = window.localStorage.getItem(key); return raw ? JSON.parse(raw) : null; }; return { current: parse('heros-web:campaign-state'), slot1: parse('heros-web:campaign-state:slot-1') }; }); } function actorById(exploration, actorId) { const actor = exploration?.actors?.find( (candidate) => candidate.id === actorId ); assert( actor, `Missing exploration actor ${actorId}: ${JSON.stringify(exploration?.actors)}` ); return actor; } function assertActorsFixed(exploration, label) { assert.equal( exploration.actors.length, Object.keys(expectedActors).length, `${label}: unexpected actor count.` ); Object.entries(expectedActors).forEach( ([actorId, expected]) => { const actor = actorById(exploration, actorId); assert.deepEqual( { x: actor.x, y: actor.y, initialX: actor.initialX, initialY: actor.initialY, moved: actor.moved, textureKey: actor.textureKey }, { x: expected.x, y: expected.y, initialX: expected.x, initialY: expected.y, moved: false, textureKey: expected.textureKey }, `${label}: ${actorId} must remain at the authored position.` ); } ); } function nearestActorDistance(exploration) { return Math.min( ...exploration.actors.map(({ distance }) => distance) ); } function campaignBond(campaign, requestedBondId) { return campaign?.bonds?.find( (bond) => bond.id === requestedBondId ); } function bondProgress(bond) { return bond.level * 100 + bond.exp; } function assertScoutRewardSaved(before, after, bondBefore) { [after.current, after.slot1].forEach((campaign, index) => { const saveLabel = index === 0 ? 'current save' : 'slot-1 save'; assert(campaign, `${saveLabel} is missing.`); assert.equal(campaign.step, 'first-camp'); assert.equal(campaign.latestBattleId, sourceBattleId); assert.equal( campaign.completedCampVisits.filter( (candidate) => candidate === visitId ).length, 1, `${saveLabel} must contain one completion flag.` ); assert.equal( campaign.firstBattleReport.completedCampVisits.filter( (candidate) => candidate === visitId ).length, 1, `${saveLabel} report must contain one completion flag.` ); assert.equal( campaign.campVisitChoiceIds[visitId], choiceId, `${saveLabel} must retain the canonical choice.` ); assert.equal( campaign.inventory[rewardItemLabel] ?? 0, (before.current?.inventory?.[rewardItemLabel] ?? 0) + 1, `${saveLabel} must grant exactly one ${rewardItemLabel}.` ); const bondAfter = campaignBond(campaign, bondId); assert( bondAfter, `${saveLabel} is missing ${bondId}.` ); assert.equal( bondProgress(bondAfter) - bondProgress(bondBefore), 10, `${saveLabel} must grant exactly 10 resonance EXP.` ); assert.equal( bondAfter.battleExp - bondBefore.battleExp, 10, `${saveLabel} must retain exactly 10 earned resonance EXP.` ); }); } function relevantProgress(saves) { const project = (campaign) => { const bond = campaignBond(campaign, bondId); return { completedCampVisits: campaign?.completedCampVisits?.filter( (candidate) => candidate === visitId ) ?? [], reportCompletedCampVisits: campaign?.firstBattleReport?.completedCampVisits?.filter( (candidate) => candidate === visitId ) ?? [], choiceId: campaign?.campVisitChoiceIds?.[visitId] ?? null, rewardItemAmount: campaign?.inventory?.[rewardItemLabel] ?? 0, bond: bond ? { level: bond.level, exp: bond.exp, battleExp: bond.battleExp } : null }; }; return { current: project(saves.current), slot1: project(saves.slot1) }; } function assertRelevantProgressEqual(before, after, label) { assert.deepEqual( relevantProgress(after), relevantProgress(before), `${label} must not change scout completion, choice, reward, or resonance.` ); } 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 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 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(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 || '41798', '--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)); }