import { spawn } from 'node:child_process'; import { chromium } from 'playwright'; import { desktopBrowserContextOptions, desktopBrowserViewport } from './desktop-browser-viewport.mjs'; const targetUrl = withCanvasRenderer(process.env.VERIFY_URL ?? 'http://localhost:5173/'); const baselineViewport = desktopBrowserViewport; const legacyUiScale = 1.5; function withCanvasRenderer(url) { const parsed = new URL(url); parsed.searchParams.set('renderer', 'canvas'); return parsed.toString(); } let serverProcess; let browser; try { serverProcess = await ensureLocalServer(targetUrl); browser = await chromium.launch({ headless: true }); const page = await browser.newPage(desktopBrowserContextOptions); await page.goto(targetUrl, { waitUntil: 'domcontentloaded' }); await page.evaluate(() => window.localStorage.clear()); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); await page.screenshot({ path: 'dist/verification-save-title-empty.png', fullPage: true }); await startTitleDefaultAction(page); await advanceUntilBattle(page, 'first-battle-zhuo-commandery'); await page.screenshot({ path: 'dist/verification-save-new-battle.png', fullPage: true }); const autoSave = await readCampaignSave(page); if (autoSave.current?.step !== 'first-battle' || autoSave.slot1?.step !== 'first-battle') { throw new Error(`Expected first battle auto-save in current and slot 1 saves: ${JSON.stringify(autoSave)}`); } await page.evaluate(() => { const battleId = 'first-battle-zhuo-commandery'; const installRecommendation = (key) => { const raw = window.localStorage.getItem(key); if (!raw) { return; } const state = JSON.parse(raw); const orderId = state.sortieOrderSelection?.orderId ?? 'elite'; state.sortieOrderSelection = { battleId, orderId }; delete state.sortieResonanceSelection; const selectedUnitIds = state.selectedSortieUnitIds?.length ? [...state.selectedSortieUnitIds] : ['liu-bei', 'guan-yu', 'zhang-fei']; state.selectedSortieUnitIds = [...selectedUnitIds]; const fallbackRoles = ['front', 'flank', 'support', 'reserve']; const formationAssignments = Object.fromEntries(selectedUnitIds.map((unitId, index) => [ unitId, state.sortieFormationAssignments?.[unitId] ?? fallbackRoles[index] ?? 'reserve' ])); state.sortieFormationAssignments = { ...(state.sortieFormationAssignments ?? {}), ...formationAssignments }; state.sortieRecommendationSelection = { version: 1, battleId, planId: 'order', label: '재도전 검증안', summary: '패배 후 같은 전장 보완 동선을 검증합니다.', sortieOrderId: orderId, selectedUnitIds, formationAssignments, unitReasons: Object.fromEntries(selectedUnitIds.map((unitId) => [unitId, '추천 역할 수행을 검증합니다.'])) }; window.localStorage.setItem(key, JSON.stringify(state)); }; installRecommendation('heros-web:campaign-state'); installRecommendation('heros-web:campaign-state:slot-1'); }); await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); await page.screenshot({ path: 'dist/verification-save-title-continue.png', fullPage: true }); await startTitleDefaultAction(page); await waitForBattleReady(page, 'first-battle-zhuo-commandery'); const continuedBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); if ( continuedBattle?.battleOutcome !== null || continuedBattle?.resultVisible !== false || continuedBattle?.turnNumber !== 1 || continuedBattle?.sortieRecommendation?.battleId !== 'first-battle-zhuo-commandery' ) { throw new Error(`Expected continue to reopen a clean first battle: ${JSON.stringify(continuedBattle)}`); } await page.screenshot({ path: 'dist/verification-save-continued-battle.png', fullPage: true }); await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('defeat')); await waitForBattleOutcome(page, 'defeat'); await page.screenshot({ path: 'dist/verification-save-defeat-result.png', fullPage: true }); const defeatSave = await readCampaignSave(page); if (defeatSave.current?.step !== 'first-battle' || defeatSave.current?.firstBattleReport?.outcome !== 'defeat') { throw new Error(`Expected defeat to save retry-ready first battle state: ${JSON.stringify(defeatSave.current)}`); } const closedDefeatEvaluation = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation); if ( closedDefeatEvaluation?.improvementAction?.available !== true || closedDefeatEvaluation.improvementAction.label !== '재도전 보완' || closedDefeatEvaluation.improvementAction.sourceBattleId !== 'first-battle-zhuo-commandery' || closedDefeatEvaluation.improvementAction.targetBattleId !== 'first-battle-zhuo-commandery' ) { throw new Error(`Expected defeat to expose same-battle improvement semantics: ${JSON.stringify(closedDefeatEvaluation)}`); } await clickDebugBounds(page, 'BattleScene', closedDefeatEvaluation.toggleButtonBounds); await page.waitForFunction( () => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === true, undefined, { timeout: 30000 } ); const openedDefeatEvaluation = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation); if (!insideViewport(openedDefeatEvaluation?.improvementAction?.buttonBounds, baselineViewport)) { throw new Error(`Expected an in-FHD defeat improvement button: ${JSON.stringify(openedDefeatEvaluation)}`); } await clickDebugBounds(page, 'BattleScene', openedDefeatEvaluation.improvementAction.buttonBounds); try { await page.waitForFunction(() => { const camp = window.__HEROS_DEBUG__?.camp?.(); return camp?.sortieVisible === true && camp?.sortiePrepStep === 'formation' && camp?.nextSortieBattleId === 'first-battle-zhuo-commandery' && camp?.sortieComparisonAction?.kind === 'confirm-improvement'; }, undefined, { timeout: 30000 }); } catch { const transitionState = await page.evaluate(() => ({ activeScenes: window.__HEROS_GAME__?.scene.getScenes(true).map((scene) => scene.scene.key), battleOutcome: window.__HEROS_DEBUG__?.battle?.()?.battleOutcome, camp: (() => { const camp = window.__HEROS_DEBUG__?.camp?.(); return camp ? { sortieVisible: camp.sortieVisible, sortiePrepStep: camp.sortiePrepStep, nextSortieBattleId: camp.nextSortieBattleId, selectedSortieUnitIds: camp.campaign?.selectedSortieUnitIds, guidedImprovement: camp.sortieGuidedImprovement, comparisonAction: camp.sortieComparisonAction, feedback: camp.sortiePlanFeedback } : null; })() })); throw new Error(`Expected defeat improvement CTA to open same-battle Camp preview: ${JSON.stringify(transitionState)}`); } const defeatGuidedPreview = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( defeatGuidedPreview?.sortieGuidedImprovement?.sourceBattleId !== 'first-battle-zhuo-commandery' || defeatGuidedPreview.sortieGuidedImprovement.targetBattleId !== 'first-battle-zhuo-commandery' || defeatGuidedPreview.sortieComparisonPreview?.source !== 'guided-improvement' || !insideViewport(defeatGuidedPreview.sortieComparisonAction?.buttonBounds, baselineViewport) ) { throw new Error(`Expected same-battle guided preview after defeat: ${JSON.stringify(defeatGuidedPreview)}`); } await clickDebugBounds(page, 'CampScene', defeatGuidedPreview.sortieComparisonAction.buttonBounds); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp?.()?.sortieComparisonAction?.kind === 'undo-improvement', undefined, { timeout: 30000 } ); const appliedDefeatImprovement = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const appliedDefeatProposal = appliedDefeatImprovement?.sortieGuidedImprovement?.proposal; if ( !appliedDefeatProposal || appliedDefeatImprovement?.sortieRecommendationSelection?.battleId !== 'first-battle-zhuo-commandery' || appliedDefeatImprovement.sortieRecommendationSelection.planId !== appliedDefeatProposal.planId ) { throw new Error(`Expected defeat improvement to preserve same-battle recommendation provenance: ${JSON.stringify(appliedDefeatImprovement)}`); } await clickLegacyUi(page, 1116, 656); await page.waitForFunction( () => window.__HEROS_DEBUG__?.camp?.()?.sortiePrepStep === 'loadout', undefined, { timeout: 30000 } ); await clickLegacyUi(page, 1116, 656); await waitForBattleReady(page, 'first-battle-zhuo-commandery'); const retriedBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const retriedLiuBei = retriedBattle?.units?.find((unit) => unit.id === 'liu-bei'); const retriedEnemies = retriedBattle?.units?.filter((unit) => unit.faction === 'enemy' && unit.hp > 0) ?? []; const damagedAlliesAfterRetry = retriedBattle?.units?.filter((unit) => unit.faction === 'ally' && unit.hp !== unit.maxHp) ?? []; if ( retriedBattle?.battleOutcome !== null || retriedBattle?.resultVisible !== false || retriedBattle?.turnNumber !== 1 || retriedBattle?.sortieRecommendation?.battleId !== 'first-battle-zhuo-commandery' || retriedBattle?.sortieRecommendation?.planId !== appliedDefeatProposal.planId || JSON.stringify(retriedBattle?.selectedSortieUnitIds) !== JSON.stringify(appliedDefeatProposal.projectedSelectedUnitIds) || !retriedLiuBei || retriedLiuBei.hp !== retriedLiuBei.maxHp || damagedAlliesAfterRetry.length > 0 || retriedEnemies.length <= 0 ) { throw new Error(`Expected retry to restart battle without stale result or damage state: ${JSON.stringify(retriedBattle)}`); } await page.screenshot({ path: 'dist/verification-save-retry-battle.png', fullPage: true }); await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await waitForBattleOutcome(page, 'victory'); await page.screenshot({ path: 'dist/verification-save-victory-result.png', fullPage: true }); await clickLegacyUi(page, 738, 642); await advanceUntilCamp(page); await page.screenshot({ path: 'dist/verification-save-victory-camp.png', fullPage: true }); const campState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const victorySave = await readCampaignSave(page); if ( campState?.scene !== 'CampScene' || campState?.report?.rewardGold <= 0 || victorySave.current?.step !== 'first-camp' || victorySave.current?.firstBattleReport?.outcome !== 'victory' || victorySave.current?.gold <= 0 ) { throw new Error(`Expected victory to return to camp with persisted rewards: ${JSON.stringify({ campState, victorySave })}`); } console.log(`Verified new-game auto-save, browser reload continue, defeat retry, and victory camp return at ${targetUrl}`); } finally { await browser?.close(); if (serverProcess && !serverProcess.killed) { serverProcess.kill(); } } async function waitForTitle(page) { await page.waitForFunction(() => document.querySelector('canvas') !== null, undefined, { timeout: 90000 }); await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined, undefined, { timeout: 90000 }); await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined, undefined, { timeout: 90000 }); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('TitleScene'); }, undefined, { timeout: 90000 }); } async function startTitleDefaultAction(page) { await waitForTitle(page); await page.keyboard.press('Enter'); await page.waitForTimeout(300); } async function advanceUntilBattle(page, battleId) { for (let i = 0; i < 180; i += 1) { const battleReady = await page.evaluate((expectedBattleId) => { let state; try { state = window.__HEROS_DEBUG__?.battle(); } catch { state = undefined; } return ( state?.scene === 'BattleScene' && state?.battleId === expectedBattleId && state?.battleOutcome === null && ['deployment', 'idle'].includes(state?.phase) && state?.mapBackgroundReady === true && state?.resultVisible === false ); }, battleId); if (battleReady) { return; } const activeScenes = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []); if (activeScenes.includes('PrologueVillageScene')) { const advanced = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('PrologueVillageScene'); return scene?.getDebugState?.()?.ready === true ? scene?.debugCompleteVillage?.() ?? false : false; }); if (advanced) { await page.waitForTimeout(320); continue; } } if (activeScenes.includes('PrologueMilitiaCampScene')) { const advanced = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('PrologueMilitiaCampScene'); return scene?.getDebugState?.()?.ready === true ? scene?.debugCompleteCamp?.() ?? false : false; }); if (advanced) { await page.waitForTimeout(320); continue; } } await page.keyboard.press(activeScenes.includes('TitleScene') ? 'Enter' : 'Space'); await page.waitForTimeout(240); } const state = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], battle: window.__HEROS_DEBUG__?.battle(), camp: window.__HEROS_DEBUG__?.camp() })); throw new Error(`Expected to advance into ${battleId}: ${JSON.stringify(state)}`); } async function waitForBattleReady(page, battleId) { await page.waitForFunction((expectedBattleId) => { let state; try { state = window.__HEROS_DEBUG__?.battle(); } catch { state = undefined; } return ( state?.scene === 'BattleScene' && state?.battleId === expectedBattleId && state?.battleOutcome === null && ['deployment', 'idle'].includes(state?.phase) && state?.mapBackgroundReady === true && state?.resultVisible === false ); }, battleId, { timeout: 90000 }); } async function waitForBattleOutcome(page, outcome) { await page.waitForFunction((expectedOutcome) => { let state; try { state = window.__HEROS_DEBUG__?.battle(); } catch { state = undefined; } return state?.battleOutcome === expectedOutcome && state?.phase === 'resolved' && state?.resultVisible === true; }, outcome, { timeout: 90000 }); } async function advanceUntilCamp(page) { for (let i = 0; i < 240; i += 1) { const state = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], battle: window.__HEROS_DEBUG__?.battle(), camp: window.__HEROS_DEBUG__?.camp() })); if (state.activeScenes.includes('CampScene')) { return; } if (state.activeScenes.includes('BattleScene') && state.battle?.battleOutcome === 'victory' && state.battle?.resultVisible) { await clickLegacyUi(page, 738, 642); } else { await page.keyboard.press('Space'); } await page.waitForTimeout(250); } const state = await page.evaluate(() => ({ activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], battle: window.__HEROS_DEBUG__?.battle(), camp: window.__HEROS_DEBUG__?.camp() })); throw new Error(`Expected victory flow to reach CampScene: ${JSON.stringify(state)}`); } async function readCampaignSave(page) { return page.evaluate(() => { const read = (key) => { const raw = window.localStorage.getItem(key); return raw ? JSON.parse(raw) : undefined; }; return { current: read('heros-web:campaign-state'), slot1: read('heros-web:campaign-state:slot-1') }; }); } async function ensureLocalServer(url) { if (await canReach(url)) { return undefined; } const parsed = new URL(url); const isLocal = ['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname); if (!isLocal) { 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 || '5173'], { cwd: process.cwd(), env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }); child.stderr.on('data', (chunk) => stderr.push(chunk.toString())); child.stdout.on('data', () => {}); for (let i = 0; i < 80; i += 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)); } async function clickLegacyUi(page, x, y, options) { await page.mouse.click(x * legacyUiScale, y * legacyUiScale, options); } async function clickDebugBounds(page, sceneKey, bounds) { if (!bounds) { throw new Error(`Expected ${sceneKey} debug bounds before click.`); } const point = await page.evaluate(({ sceneKey, bounds }) => { const scene = window.__HEROS_GAME__?.scene.getScene(sceneKey); const canvas = document.querySelector('canvas'); if (!scene || !canvas) { return null; } const canvasBounds = canvas.getBoundingClientRect(); 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 }); if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { throw new Error(`Expected a canvas-scaled ${sceneKey} click point: ${JSON.stringify({ bounds, point })}`); } await page.mouse.click(point.x, point.y); } function insideViewport(bounds, viewport) { return Boolean( bounds && Number.isFinite(bounds.x) && Number.isFinite(bounds.y) && Number.isFinite(bounds.width) && Number.isFinite(bounds.height) && bounds.width > 0 && bounds.height > 0 && bounds.x >= 0 && bounds.y >= 0 && bounds.x + bounds.width <= viewport.width && bounds.y + bounds.height <= viewport.height ); }