import { spawn } from 'node:child_process'; import { chromium } from 'playwright'; const targetUrl = process.env.VERIFY_URL ?? 'http://localhost:5173/'; let serverProcess; let browser; try { serverProcess = await ensureLocalServer(targetUrl); browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); await page.goto(targetUrl, { waitUntil: 'networkidle' }); await page.evaluate(() => window.localStorage.clear()); await page.reload({ waitUntil: 'networkidle' }); await page.waitForSelector('canvas'); await page.waitForFunction(() => window.__HEROS_GAME__ !== undefined); await page.waitForFunction(() => window.__HEROS_DEBUG__ !== undefined); await page.waitForTimeout(500); const debugBeforeBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.activeScenes() ?? []); if (!debugBeforeBattle.includes('TitleScene')) { throw new Error(`Expected TitleScene before starting. Active scenes: ${debugBeforeBattle.join(', ')}`); } await page.mouse.click(962, 240); await page.waitForTimeout(250); for (let i = 0; i < 20; i += 1) { const isBattleActive = await page.evaluate(() => { const activeScenes = window.__HEROS_GAME__?.scene.getScenes(true) ?? []; return activeScenes.some((scene) => scene.scene.key === 'BattleScene'); }); if (isBattleActive) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(220); } await page.waitForFunction(() => { const activeScenes = window.__HEROS_GAME__?.scene.getScenes(true) ?? []; return activeScenes.some((scene) => scene.scene.key === 'BattleScene'); }); await page.keyboard.press('F9'); await page.waitForTimeout(100); await page.screenshot({ path: 'dist/verification-battle.png', fullPage: true }); const result = await page.evaluate(() => { const canvas = document.querySelector('canvas'); const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; const battleState = window.__HEROS_DEBUG__?.battle(); return { activeScenes, battleState, canvasWidth: canvas?.width ?? 0, canvasHeight: canvas?.height ?? 0 }; }); if (result.canvasWidth !== 1280 || result.canvasHeight !== 720) { throw new Error(`Unexpected canvas size: ${result.canvasWidth}x${result.canvasHeight}`); } if (!result.activeScenes.includes('BattleScene')) { throw new Error(`BattleScene was not active. Active scenes: ${result.activeScenes.join(', ')}`); } if (!result.battleState || result.battleState.scene !== 'BattleScene') { throw new Error(`Debug battle state was not available: ${JSON.stringify(result.battleState)}`); } if (result.battleState.battleId !== 'first-battle-zhuo-commandery') { throw new Error(`Expected first battle scenario id: ${JSON.stringify(result.battleState)}`); } if (result.battleState.victoryConditionLabel !== '두령 한석 격파' || result.battleState.defeatConditionLabel !== '유비 퇴각') { throw new Error(`Expected first battle objective labels: ${JSON.stringify(result.battleState)}`); } const openingLeaderObjective = result.battleState.objectives.find((objective) => objective.id === 'leader'); if (!openingLeaderObjective || openingLeaderObjective.status !== 'active' || openingLeaderObjective.achieved) { throw new Error(`Expected live leader objective to start active: ${JSON.stringify(result.battleState.objectives)}`); } const actionTexturesLoaded = await page.evaluate(() => { const textures = window.__HEROS_GAME__?.textures; return [ 'unit-liu-bei-actions', 'unit-guan-yu-actions', 'unit-zhang-fei-actions', 'unit-rebel-actions', 'unit-rebel-archer-actions', 'unit-rebel-cavalry-actions', 'unit-rebel-leader-actions' ].every((key) => textures?.exists(key)); }); if (!actionTexturesLoaded) { throw new Error('Expected all unit action textures to be loaded.'); } const movementBlockingState = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const state = window.__HEROS_DEBUG__?.battle(); const liuBei = state?.units?.find((unit) => unit.id === 'liu-bei'); const guanYu = state?.units?.find((unit) => unit.id === 'guan-yu'); const enemy = state?.units?.find((unit) => unit.faction === 'enemy'); if (!scene || !liuBei || !guanYu || !enemy) { throw new Error('Expected battle scene and movement test units.'); } return { allyBlocksPath: scene.isMovementBlocked(liuBei, guanYu.x, guanYu.y), enemyBlocksPath: scene.isMovementBlocked(liuBei, enemy.x, enemy.y), allyStopTileReachable: scene.reachableTiles(liuBei).some((tile) => tile.x === guanYu.x && tile.y === guanYu.y) }; }); if ( movementBlockingState.allyBlocksPath || !movementBlockingState.enemyBlocksPath || movementBlockingState.allyStopTileReachable ) { throw new Error(`Expected allies to be passable path blockers only as stop tiles, and enemies to block movement: ${JSON.stringify(movementBlockingState)}`); } const liuBeiScreen = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const state = window.__HEROS_DEBUG__?.battle(); const liuBei = state?.units?.find((unit) => unit.id === 'liu-bei'); if (!scene || !liuBei) { throw new Error('Expected Liu Bei and battle scene for stand-still command test.'); } return { x: scene.tileCenterX(liuBei.x), y: scene.tileCenterY(liuBei.y) }; }); await page.mouse.click(liuBeiScreen.x, liuBeiScreen.y); await page.waitForTimeout(140); await page.mouse.click(liuBeiScreen.x, liuBeiScreen.y); await page.waitForTimeout(180); const standStillCommandState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); if ( standStillCommandState?.phase !== 'command' || standStillCommandState.selectedUnitId !== 'liu-bei' || standStillCommandState.pendingMove?.unitId !== 'liu-bei' || standStillCommandState.pendingMove.from.x !== standStillCommandState.pendingMove.to.x || standStillCommandState.pendingMove.from.y !== standStillCommandState.pendingMove.to.y ) { throw new Error(`Expected Liu Bei to open commands without moving: ${JSON.stringify(standStillCommandState)}`); } await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); if (!scene) { return; } scene.selectedUnit = undefined; scene.pendingMove = undefined; scene.targetingAction = undefined; scene.selectedUsable = undefined; scene.phase = 'idle'; scene.clearMarkers(); scene.hideCommandMenu(); scene.hideMapMenu(); }); await page.waitForTimeout(700); await page.mouse.click(260, 300, { button: 'right' }); await page.waitForTimeout(120); await page.mouse.click(182, 196); await page.waitForTimeout(160); const threatState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); if (!threatState?.threatMarkerCount || threatState.threatMarkerCount <= 0) { throw new Error(`Expected enemy threat markers from map menu: ${JSON.stringify(threatState)}`); } await page.mouse.click(260, 300, { button: 'right' }); await page.waitForTimeout(120); await page.mouse.click(182, 230); await page.waitForTimeout(120); await page.mouse.click(400, 213); await page.waitForTimeout(180); const battleSlotSave = await page.evaluate(() => { const raw = window.localStorage.getItem('heros-web:battle:first-battle-zhuo-commandery:slot-1'); return raw ? JSON.parse(raw) : undefined; }); if (!battleSlotSave || battleSlotSave.battleId !== 'first-battle-zhuo-commandery') { throw new Error(`Expected slot 1 battle save from save UI: ${JSON.stringify(battleSlotSave)}`); } await page.mouse.click(260, 300, { button: 'right' }); await page.waitForTimeout(120); await page.mouse.click(182, 264); await page.waitForTimeout(120); await page.mouse.click(400, 213); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.scene === 'BattleScene'); const cameraBeforeScroll = result.battleState.camera; if ( !cameraBeforeScroll || cameraBeforeScroll.mapWidth <= cameraBeforeScroll.visibleColumns || cameraBeforeScroll.mapHeight <= cameraBeforeScroll.visibleRows ) { throw new Error(`Expected a scrollable battle map: ${JSON.stringify(cameraBeforeScroll)}`); } await page.mouse.move(872, 360); await page.waitForTimeout(520); const cameraAfterEdgeScroll = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.camera); if (!cameraAfterEdgeScroll || cameraAfterEdgeScroll.x <= cameraBeforeScroll.x) { throw new Error(`Expected edge scrolling to move camera right: ${JSON.stringify({ cameraBeforeScroll, cameraAfterEdgeScroll })}`); } await page.mouse.click(1138, 553); await page.waitForTimeout(160); const cameraAfterMiniMapClick = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.camera); if ( !cameraAfterMiniMapClick || cameraAfterMiniMapClick.x < cameraAfterMiniMapClick.mapWidth - cameraAfterMiniMapClick.visibleColumns || cameraAfterMiniMapClick.y !== 0 ) { throw new Error(`Expected minimap click to move camera to the northeast: ${JSON.stringify(cameraAfterMiniMapClick)}`); } const readyUnits = result.battleState.units.filter((unit) => !unit.acted); if (!readyUnits.some((unit) => unit.animating && unit.animationKey?.includes('-idle-'))) { throw new Error(`Expected ready units to loop idle frames: ${JSON.stringify(readyUnits)}`); } await page.keyboard.press('F9'); await page.waitForTimeout(100); await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); const victoryState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const livingEnemiesAfterVictory = victoryState.units.filter((unit) => unit.faction === 'enemy' && unit.hp > 0); if (livingEnemiesAfterVictory.length > 0) { throw new Error(`Expected no living enemies after forced victory: ${JSON.stringify(livingEnemiesAfterVictory)}`); } const victoryLeaderObjective = victoryState.objectives.find((objective) => objective.id === 'leader'); if (!victoryLeaderObjective || victoryLeaderObjective.status !== 'done' || !victoryLeaderObjective.achieved) { throw new Error(`Expected leader objective to resolve after victory: ${JSON.stringify(victoryState.objectives)}`); } await page.screenshot({ path: 'dist/verification-battle-result-victory.png', fullPage: true }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); await page.screenshot({ path: 'dist/verification-camp.png', fullPage: true }); const campState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!campState || campState.scene !== 'CampScene' || campState.report?.rewardGold <= 0) { throw new Error(`Expected CampScene report after victory: ${JSON.stringify(campState)}`); } await page.evaluate(() => { const campScene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const liuBei = campScene?.campaign?.roster?.find((unit) => unit.id === 'liu-bei'); if (!liuBei) { throw new Error('Expected Liu Bei in camp roster before supply verification.'); } liuBei.hp = Math.max(1, liuBei.maxHp - 10); campScene.report?.units?.forEach((unit) => { if (unit.id === 'liu-bei') { unit.hp = liuBei.hp; } }); campScene.render(); }); await page.mouse.click(918, 38); await page.waitForTimeout(160); await page.screenshot({ path: 'dist/verification-camp-supplies.png', fullPage: true }); await page.mouse.click(1120, 245); await page.waitForTimeout(260); const campStateAfterSupply = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const liuBeiAfterSupply = campStateAfterSupply?.campaign?.roster?.find((unit) => unit.id === 'liu-bei'); if (!liuBeiAfterSupply || liuBeiAfterSupply.hp !== liuBeiAfterSupply.maxHp) { throw new Error(`Expected bean supply to recover Liu Bei: ${JSON.stringify(campStateAfterSupply)}`); } if ((campStateAfterSupply.campaign?.inventory?.['콩'] ?? 0) !== 0) { throw new Error(`Expected bean supply to be consumed: ${JSON.stringify(campStateAfterSupply.campaign?.inventory)}`); } const goldBeforeMerchant = campStateAfterSupply.campaign?.gold ?? 0; await page.mouse.click(702, 386); await page.waitForTimeout(220); const campStateAfterMerchant = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if ( (campStateAfterMerchant.campaign?.inventory?.['콩'] ?? 0) !== 1 || campStateAfterMerchant.campaign?.gold !== goldBeforeMerchant - 40 ) { throw new Error(`Expected merchant purchase to add bean and spend gold: ${JSON.stringify({ goldBeforeMerchant, campStateAfterMerchant })}`); } await page.mouse.click(830, 38); await page.waitForTimeout(120); await page.mouse.click(974, 482); await page.waitForTimeout(260); const campStateAfterDialogue = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!campStateAfterDialogue.report?.completedCampDialogues?.length) { throw new Error(`Expected camp dialogue to award bond exp: ${JSON.stringify(campStateAfterDialogue)}`); } const campaignSaveAfterDialogue = await page.evaluate(() => { const raw = window.localStorage.getItem('heros-web:campaign-state'); return raw ? JSON.parse(raw) : undefined; }); if ( !campaignSaveAfterDialogue || campaignSaveAfterDialogue.step !== 'first-camp' || campaignSaveAfterDialogue.latestBattleId !== 'first-battle-zhuo-commandery' || !campaignSaveAfterDialogue.battleHistory?.['first-battle-zhuo-commandery'] || !campaignSaveAfterDialogue.completedCampDialogues?.length || Object.keys(campaignSaveAfterDialogue.inventory ?? {}).length === 0 ) { throw new Error(`Expected campaign save to persist camp progress: ${JSON.stringify(campaignSaveAfterDialogue)}`); } const campaignSlotAfterDialogue = await page.evaluate(() => { const raw = window.localStorage.getItem('heros-web:campaign-state:slot-1'); return raw ? JSON.parse(raw) : undefined; }); if (!campaignSlotAfterDialogue || campaignSlotAfterDialogue.latestBattleId !== 'first-battle-zhuo-commandery') { throw new Error(`Expected campaign slot 1 to persist progress: ${JSON.stringify(campaignSlotAfterDialogue)}`); } await page.mouse.click(1120, 38); await page.waitForTimeout(180); const campStateWithSortiePrep = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!campStateWithSortiePrep?.sortieVisible) { throw new Error(`Expected sortie preparation overlay from next story button: ${JSON.stringify(campStateWithSortiePrep)}`); } await page.screenshot({ path: 'dist/verification-camp-sortie.png', fullPage: true }); await page.mouse.click(792, 596); await page.waitForTimeout(140); const campStateAfterSortieClose = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (campStateAfterSortieClose?.sortieVisible) { throw new Error(`Expected sortie preparation overlay to close: ${JSON.stringify(campStateAfterSortieClose)}`); } await page.mouse.click(1120, 38); await page.waitForTimeout(120); await page.mouse.click(938, 596); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); for (let i = 0; i < 24; i += 1) { const isSecondBattleActive = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'second-battle-yellow-turban-pursuit'; }); if (isSecondBattleActive) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'second-battle-yellow-turban-pursuit' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-second-battle.png', fullPage: true }); const secondBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const secondEnemies = secondBattleState.units.filter((unit) => unit.faction === 'enemy'); const secondEnemyBehaviors = new Set(secondEnemies.map((unit) => unit.ai)); if ( secondBattleState.camera?.mapWidth !== 24 || secondBattleState.camera?.mapHeight !== 20 || secondEnemies.length < 10 || !secondEnemyBehaviors.has('aggressive') || !secondEnemyBehaviors.has('guard') || !secondEnemyBehaviors.has('hold') ) { throw new Error(`Expected expanded second battle map and mixed enemy AI: ${JSON.stringify(secondBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const campaignSaveAfterSecondBattle = await page.evaluate(() => { const raw = window.localStorage.getItem('heros-web:campaign-state'); return raw ? JSON.parse(raw) : undefined; }); if ( !campaignSaveAfterSecondBattle || campaignSaveAfterSecondBattle.step !== 'second-camp' || campaignSaveAfterSecondBattle.latestBattleId !== 'second-battle-yellow-turban-pursuit' || !campaignSaveAfterSecondBattle.battleHistory?.['second-battle-yellow-turban-pursuit'] || !campaignSaveAfterSecondBattle.completedCampDialogues?.length ) { throw new Error(`Expected campaign save to persist second battle victory: ${JSON.stringify(campaignSaveAfterSecondBattle)}`); } await page.mouse.click(1120, 38); await page.waitForTimeout(160); const secondCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!secondCampSortieState?.sortieVisible) { throw new Error(`Expected second camp sortie preparation overlay: ${JSON.stringify(secondCampSortieState)}`); } await page.mouse.click(938, 596); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); for (let i = 0; i < 24; i += 1) { const isThirdBattleActive = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'third-battle-guangzong-road'; }); if (isThirdBattleActive) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'third-battle-guangzong-road' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-third-battle.png', fullPage: true }); const thirdBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const thirdEnemies = thirdBattleState.units.filter((unit) => unit.faction === 'enemy'); const thirdEnemyBehaviors = new Set(thirdEnemies.map((unit) => unit.ai)); if ( thirdBattleState.camera?.mapWidth !== 24 || thirdBattleState.camera?.mapHeight !== 20 || thirdBattleState.victoryConditionLabel !== '전령 마원 격파' || thirdEnemies.length < 10 || !thirdEnemyBehaviors.has('aggressive') || !thirdEnemyBehaviors.has('guard') || !thirdEnemyBehaviors.has('hold') ) { throw new Error(`Expected third battle map, objective, and mixed enemy AI: ${JSON.stringify(thirdBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const campaignSaveAfterThirdBattle = await page.evaluate(() => { const raw = window.localStorage.getItem('heros-web:campaign-state'); return raw ? JSON.parse(raw) : undefined; }); if ( !campaignSaveAfterThirdBattle || campaignSaveAfterThirdBattle.step !== 'third-camp' || campaignSaveAfterThirdBattle.latestBattleId !== 'third-battle-guangzong-road' || !campaignSaveAfterThirdBattle.battleHistory?.['third-battle-guangzong-road'] ) { throw new Error(`Expected campaign save to persist third battle victory: ${JSON.stringify(campaignSaveAfterThirdBattle)}`); } await page.mouse.click(1120, 38); await page.waitForTimeout(160); const thirdCampSortieState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); if (!thirdCampSortieState?.sortieVisible) { throw new Error(`Expected third camp sortie preparation overlay: ${JSON.stringify(thirdCampSortieState)}`); } await page.mouse.click(938, 596); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('StoryScene'); }); for (let i = 0; i < 26; i += 1) { const isFourthBattleActive = await page.evaluate(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'fourth-battle-guangzong-camp'; }); if (isFourthBattleActive) { break; } await page.keyboard.press('Space'); await page.waitForTimeout(320); } await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.scene === 'BattleScene' && state?.battleId === 'fourth-battle-guangzong-camp' && state?.battleOutcome === null && state?.phase === 'idle'; }); await page.screenshot({ path: 'dist/verification-fourth-battle.png', fullPage: true }); const fourthBattleState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const fourthEnemies = fourthBattleState.units.filter((unit) => unit.faction === 'enemy'); const fourthEnemyBehaviors = new Set(fourthEnemies.map((unit) => unit.ai)); if ( fourthBattleState.camera?.mapWidth !== 24 || fourthBattleState.camera?.mapHeight !== 20 || fourthBattleState.victoryConditionLabel !== '장각 격파' || fourthEnemies.length < 12 || !fourthEnemyBehaviors.has('aggressive') || !fourthEnemyBehaviors.has('guard') || !fourthEnemyBehaviors.has('hold') ) { throw new Error(`Expected fourth battle map, objective, and mixed enemy AI: ${JSON.stringify(fourthBattleState)}`); } await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); await page.waitForFunction(() => { const state = window.__HEROS_DEBUG__?.battle(); return state?.battleOutcome === 'victory' && state?.phase === 'resolved' && state?.resultVisible === true; }); await page.mouse.click(738, 642); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); const campaignSaveAfterFourthBattle = await page.evaluate(() => { const raw = window.localStorage.getItem('heros-web:campaign-state'); return raw ? JSON.parse(raw) : undefined; }); if ( !campaignSaveAfterFourthBattle || campaignSaveAfterFourthBattle.step !== 'fourth-camp' || campaignSaveAfterFourthBattle.latestBattleId !== 'fourth-battle-guangzong-camp' || !campaignSaveAfterFourthBattle.battleHistory?.['fourth-battle-guangzong-camp'] ) { throw new Error(`Expected campaign save to persist fourth battle victory: ${JSON.stringify(campaignSaveAfterFourthBattle)}`); } await page.evaluate(() => window.__HEROS_GAME__?.scene.start('TitleScene')); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('TitleScene'); }); await page.mouse.click(962, 310); await page.waitForFunction(() => { const activeScenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return activeScenes.includes('CampScene'); }); console.log(`Verified title-to-fourth-battle flow, result states, and debug API at ${targetUrl}`); } finally { await browser?.close(); if (serverProcess && !serverProcess.killed) { serverProcess.kill(); } } 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)); }