import { spawn } from 'node:child_process'; import { chromium } from 'playwright'; import { desktopBrowserContextOptions, desktopBrowserViewport } from './desktop-browser-viewport.mjs'; const targetUrl = withDebugOptions( process.env.VERIFY_INTERACTION_UX_URL ?? 'http://127.0.0.1:41783/' ); const expectedBattleId = 'second-battle-yellow-turban-pursuit'; let serverProcess; let browser; try { serverProcess = await ensureLocalServer(targetUrl); browser = await chromium.launch({ headless: process.env.VERIFY_INTERACTION_UX_HEADLESS !== '0' }); const context = await browser.newContext(desktopBrowserContextOptions); const page = await context.newPage(); page.setDefaultTimeout(30000); const pageErrors = []; page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message)); let delayedBattleModuleRequests = 0; await page.route('**/src/game/scenes/BattleScene.ts*', async (route) => { delayedBattleModuleRequests += 1; await delay(1200); await route.continue(); }); await verifyCampModalAndNavigation(page); await verifyBattlePointerFlow(page); await verifyWolongNarrativeVictoryGate(page); await verifyCampTimelineRowLayout(page); if (pageErrors.length > 0) { throw new Error(`Unexpected browser errors: ${JSON.stringify(pageErrors.slice(-5))}`); } if (delayedBattleModuleRequests !== 1) { throw new Error( `Expected one delayed BattleScene module request during single-flight navigation, received ${delayedBattleModuleRequests}.` ); } console.log( `Verified pointer-based interaction UX at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}: ` + 'camp and battle save modals block click-through, slow camp navigation is single-flight and commits after loading, ' + 'delayed combat assets rebuild deployment controls before battle start, ' + 'battle event overlays block edge-scroll and hover feedback, prioritized same-action notices collapse into one disclosed modal and battle log, ' + 'tactical reactions exclude undeployed or defeated officers, the Wolong narrative objectives gate victory, ' + 'long camp timeline titles and victory conditions stay in separate fixed-width columns, ' + 'movement commands stay anchored to the destination, the final ally prompt waits for the command, ' + 'the all-acted prompt keeps Enter-to-end, early manual turn end discloses unacted allies and overflow threats, ' + 'safe Enter/Esc cancellation requires explicit keyboard focus before abandoning actions, ' + 'the persistent turn-end action reopens it, and the right-click menu follows the pointer.' ); } finally { await browser?.close(); if (serverProcess && !serverProcess.killed) { serverProcess.kill(); } } function withDebugOptions(url) { const parsed = new URL(url); parsed.searchParams.set('debug', '1'); parsed.searchParams.set('renderer', 'canvas'); parsed.searchParams.set('debugCombatAssetWatchdogMs', '250'); return parsed.toString(); } async function verifyCampModalAndNavigation(page) { const campUrl = new URL(targetUrl); campUrl.searchParams.set('debugSortiePrep', '1'); await page.goto(campUrl.toString(), { waitUntil: 'domcontentloaded', timeout: 90000 }); await page.evaluate(() => window.localStorage.clear()); await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 }); await waitForDebugApi(page); await page.waitForFunction(() => { const scenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; const camp = window.__HEROS_DEBUG__?.camp(); return scenes.includes('CampScene') && camp?.scene === 'CampScene' && camp?.sortieVisible === true; }, undefined, { timeout: 90000 }); await assertDesktopViewport(page); const saveButtonBounds = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const candidates = (scene?.children.list ?? []) .filter((object) => ( object.type === 'Text' && object.text === '저장' && object.active && object.visible && object.input?.enabled )) .map((object) => ({ depth: object.depth, bounds: object.getBounds() })) .sort((left, right) => right.depth - left.depth || right.bounds.y - left.bounds.y); const bounds = candidates[0]?.bounds; return bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null; }); assert(saveButtonBounds, 'Expected a visible camp save button in sortie preparation.'); const campBeforeModal = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); await clickSceneBounds(page, 'CampScene', saveButtonBounds); await page.waitForFunction(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); return (scene?.saveSlotObjects?.length ?? 0) > 0; }); const campSaveModal = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const rectangles = (scene?.saveSlotObjects ?? []) .filter((object) => object.type === 'Rectangle' && object.active && object.input?.enabled) .map((object) => { const bounds = object.getBounds(); return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, depth: object.depth }; }); const blocker = rectangles.find((bounds) => ( bounds.width >= scene.scale.width * 0.98 && bounds.height >= scene.scale.height * 0.98 )); return { blocker: blocker ?? null, objectCount: scene?.saveSlotObjects?.length ?? 0 }; }); assert( campSaveModal.blocker, `Expected the camp save panel to include a full-screen interactive blocker: ${JSON.stringify(campSaveModal)}` ); const coveredCampAction = campBeforeModal?.sortiePrimaryAction; assert(coveredCampAction?.bounds, 'Expected the covered sortie primary action for camp click-through QA.'); await clickSceneBounds(page, 'CampScene', coveredCampAction.bounds); await page.waitForTimeout(120); const campAfterCoveredClick = await page.evaluate(() => ({ state: window.__HEROS_DEBUG__?.camp(), savePanelObjectCount: window.__HEROS_GAME__?.scene.getScene('CampScene')?.saveSlotObjects?.length ?? 0 })); assert( campAfterCoveredClick.state?.sortiePrepStep === campBeforeModal?.sortiePrepStep, `Camp save modal click-through changed the underlying sortie step: ${JSON.stringify({ before: campBeforeModal?.sortiePrepStep, after: campAfterCoveredClick.state?.sortiePrepStep, savePanelObjectCount: campAfterCoveredClick.savePanelObjectCount })}` ); const launchAction = await prepareCampLaunch(page); const navigationBaseline = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const camp = window.__HEROS_DEBUG__?.camp(); window.__HEROS_INTERACTION_UX__ = { battleSceneStarts: 0 }; const originalStart = scene.scene.start; scene.scene.start = function (key, data) { if (key === 'BattleScene') { window.__HEROS_INTERACTION_UX__.battleSceneStarts += 1; } return originalStart.call(this, key, data); }; return { campaignStep: camp?.campaign?.step ?? null, sortiePrepStep: camp?.sortiePrepStep ?? null, selectedSortieUnitIds: [...(camp?.selectedSortieUnitIds ?? [])] }; }); const launchPoint = await sceneBoundsCenter(page, 'CampScene', launchAction.bounds); await page.mouse.click(launchPoint.x, launchPoint.y); await page.waitForFunction(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); return scene?.navigationPending === true && (scene?.navigationBlockerObjects?.length ?? 0) > 0; }, undefined, { timeout: 3000 }); const navigationPendingState = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const camp = window.__HEROS_DEBUG__?.camp(); const blocker = (scene?.navigationBlockerObjects ?? []) .filter((object) => object.type === 'Rectangle' && object.input?.enabled) .map((object) => object.getBounds()) .find((bounds) => bounds.width >= scene.scale.width * 0.98 && bounds.height >= scene.scale.height * 0.98); return { pending: scene?.navigationPending ?? false, blocker: blocker ? { x: blocker.x, y: blocker.y, width: blocker.width, height: blocker.height } : null, campaignStep: camp?.campaign?.step ?? null, sortieVisible: camp?.sortieVisible ?? false, selectedSortieUnitIds: [...(camp?.selectedSortieUnitIds ?? [])] }; }); assert( navigationPendingState.blocker && navigationPendingState.campaignStep === navigationBaseline.campaignStep, `Camp navigation must block input and defer the campaign-step commit until loading succeeds: ${JSON.stringify({ baseline: navigationBaseline, pending: navigationPendingState })}` ); await page.keyboard.press('Escape'); await page.mouse.click(launchPoint.x, launchPoint.y); const coveredTabBounds = (await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.campTabs?.find((tab) => tab.id === 'dialogue')?.bounds)) ?? null; if (coveredTabBounds) { await clickSceneBounds(page, 'CampScene', coveredTabBounds); } await page.waitForTimeout(120); const navigationAfterRepeatedInput = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const camp = window.__HEROS_DEBUG__?.camp(); return { activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [], pending: scene?.navigationPending ?? false, blockerCount: scene?.navigationBlockerObjects?.length ?? 0, campaignStep: camp?.campaign?.step ?? null, sortiePrepStep: camp?.sortiePrepStep ?? null, selectedSortieUnitIds: [...(camp?.selectedSortieUnitIds ?? [])], battleSceneStarts: window.__HEROS_INTERACTION_UX__?.battleSceneStarts ?? 0 }; }); assert( navigationAfterRepeatedInput.activeScenes.includes('CampScene') && navigationAfterRepeatedInput.pending && navigationAfterRepeatedInput.blockerCount > 0 && navigationAfterRepeatedInput.campaignStep === navigationBaseline.campaignStep && navigationAfterRepeatedInput.sortiePrepStep === navigationBaseline.sortiePrepStep && sameStrings(navigationAfterRepeatedInput.selectedSortieUnitIds, navigationBaseline.selectedSortieUnitIds) && navigationAfterRepeatedInput.battleSceneStarts === 0, `Repeated input changed camp state during lazy navigation: ${JSON.stringify({ baseline: navigationBaseline, after: navigationAfterRepeatedInput })}` ); await page.waitForFunction(() => { const scenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; const battle = window.__HEROS_DEBUG__?.battle(); return scenes.includes('BattleScene') && battle?.scene === 'BattleScene'; }, undefined, { timeout: 90000 }); const navigationCompleteState = await page.evaluate(() => ({ battleSceneStarts: window.__HEROS_INTERACTION_UX__?.battleSceneStarts ?? 0, activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [] })); assert( navigationCompleteState.battleSceneStarts === 1 && !navigationCompleteState.activeScenes.includes('CampScene'), `Camp navigation must start BattleScene exactly once: ${JSON.stringify(navigationCompleteState)}` ); } async function prepareCampLaunch(page) { for (let attempt = 0; attempt < 8; attempt += 1) { const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); assert(state?.sortieVisible, `Expected sortie preparation while finding launch action: ${JSON.stringify(state)}`); if (state.sortiePrepStep === 'formation' && state.sortieOperationOrder?.required && !state.sortieOperationOrder.complete) { if (!state.sortieOperationOrder.open) { assert(state.sortieOperationOrder.toggleButtonBounds, 'Expected the operation-order picker button.'); await clickSceneBounds(page, 'CampScene', state.sortieOperationOrder.toggleButtonBounds); await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder?.open === true); } const orderState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder); const order = orderState?.cards?.find((card) => card.cardBounds); assert(order?.cardBounds, `Expected a selectable operation order: ${JSON.stringify(orderState)}`); await clickSceneBounds(page, 'CampScene', order.cardBounds); await page.waitForFunction((orderId) => ( window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder?.selectedId === orderId ), order.id); const selectedOrderState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder); if (selectedOrderState?.open && selectedOrderState.closeButtonBounds) { await clickSceneBounds(page, 'CampScene', selectedOrderState.closeButtonBounds); await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder?.open === false); } continue; } const action = state.sortiePrimaryAction; assert(action?.bounds && action.interactive, `Expected an interactive sortie primary action: ${JSON.stringify(action)}`); if (action.kind === 'launch') { return action; } const previousStep = state.sortiePrepStep; await clickSceneBounds(page, 'CampScene', action.bounds); await page.waitForFunction((step) => window.__HEROS_DEBUG__?.camp()?.sortiePrepStep !== step, previousStep); } throw new Error('Could not reach the sortie launch action within the expected three-step flow.'); } async function verifyBattlePointerFlow(page) { await page.evaluate((battleId) => window.__HEROS_DEBUG__?.goToBattle(battleId), expectedBattleId); await page.waitForFunction((battleId) => { const battle = window.__HEROS_DEBUG__?.battle(); return ( battle?.battleId === battleId && ['deployment', 'idle'].includes(battle.phase) && battle.mapBackgroundReady === true && (battle.phase === 'idle' || ['ready', 'degraded'].includes(battle.combatAssets?.status)) ); }, expectedBattleId, { timeout: 90000 }); const deploymentReadyState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); if (deploymentReadyState?.phase === 'deployment') { assert( deploymentReadyState.battleHud?.deploymentPanel?.combatAssetsReady === true && deploymentReadyState.battleHud.deploymentPanel.startButtonLabel === '전투 시작', `Deployment controls must rebuild after delayed combat assets settle: ${JSON.stringify({ combatAssets: deploymentReadyState.combatAssets, deploymentPanel: deploymentReadyState.battleHud?.deploymentPanel })}` ); } await startDeploymentIfNeeded(page, expectedBattleId); await verifyBattleEventOverlayInputBlock(page); await verifyBattleEventQueueBehavior(page); const lastAllySetup = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const units = scene?.debugBattleUnits?.() ?? []; const allies = units.filter((unit) => unit.faction === 'ally' && unit.hp > 0); const lastAlly = allies.find((unit) => scene.reachableTiles(unit).some((tile) => tile.x !== unit.x || tile.y !== unit.y)); if (!scene || !lastAlly) { return null; } scene.hideBattleEventBanner(); scene.hideTurnEndPrompt(); scene.hideSaveSlotPanel(); scene.hideMapMenu(); scene.hideCommandMenu(); scene.clearMarkers(); scene.activeFaction = 'ally'; scene.phase = 'idle'; scene.selectedUnit = undefined; scene.pendingMove = undefined; scene.targetingAction = undefined; scene.selectedUsable = undefined; scene.actedUnitIds.clear(); allies.filter((unit) => unit.id !== lastAlly.id).forEach((unit) => scene.actedUnitIds.add(unit.id)); scene.centerCameraOnTile(lastAlly.x, lastAlly.y); scene.renderSituationPanel(); const hitZone = scene.unitViews.get(lastAlly.id)?.hitZone; const bounds = hitZone?.getBounds(); return bounds ? { unitId: lastAlly.id, unitTile: { x: lastAlly.x, y: lastAlly.y }, bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } } : null; }); assert(lastAllySetup?.bounds, 'Expected a visible final allied unit hit area.'); await page.waitForTimeout(80); await clickSceneBounds(page, 'BattleScene', lastAllySetup.bounds); await page.waitForFunction((unitId) => { const battle = window.__HEROS_DEBUG__?.battle(); return battle?.selectedUnitId === unitId && battle?.phase === 'moving'; }, lastAllySetup.unitId); const selectedBeforeMove = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); assert( !selectedBeforeMove.turnPromptVisible, `Selecting the final ally must not show the turn-end prompt: ${JSON.stringify(selectedBeforeMove)}` ); const movementTarget = await page.evaluate((unitId) => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const unit = scene?.debugUnitById?.(unitId); const destination = unit ? scene.reachableTiles(unit).find((tile) => tile.x !== unit.x || tile.y !== unit.y) : undefined; if (!scene || !destination) { return null; } return { tile: { x: destination.x, y: destination.y }, point: { x: scene.tileCenterX(destination.x), y: scene.tileCenterY(destination.y) }, farPoint: { x: destination.x - scene.cameraTileX < scene.layout.visibleColumns / 2 ? scene.layout.gridX + scene.layout.gridWidth - 12 : scene.layout.gridX + 12, y: destination.y - scene.cameraTileY < scene.layout.visibleRows / 2 ? scene.layout.gridY + scene.layout.gridHeight - 12 : scene.layout.gridY + 12 } }; }, lastAllySetup.unitId); assert(movementTarget?.point, 'Expected a reachable non-origin tile for the final ally.'); const movementScreenPoint = await sceneLogicalPoint(page, 'BattleScene', movementTarget.point); const farScreenPoint = await sceneLogicalPoint(page, 'BattleScene', movementTarget.farPoint); await page.mouse.click(movementScreenPoint.x, movementScreenPoint.y); await page.mouse.move(farScreenPoint.x, farScreenPoint.y); const duringMovement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); assert( !duringMovement?.turnPromptVisible && !duringMovement?.actedUnitIds?.includes(lastAllySetup.unitId), `Movement of the final ally must not complete the action or show the prompt: ${JSON.stringify(duringMovement)}` ); await page.waitForFunction((unitId) => { const battle = window.__HEROS_DEBUG__?.battle(); return battle?.phase === 'command' && battle?.pendingMove?.unitId === unitId && battle?.commandMenuVisible === true; }, lastAllySetup.unitId); const postMovement = await page.evaluate(({ unitId, destination }) => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const state = window.__HEROS_DEBUG__?.battle(); const activeObjects = (scene?.commandMenuObjects ?? []).filter((object) => object.active); const objectBounds = activeObjects.map((object) => object.getBounds()); const left = Math.min(...objectBounds.map((bounds) => bounds.x)); const top = Math.min(...objectBounds.map((bounds) => bounds.y)); const right = Math.max(...objectBounds.map((bounds) => bounds.right)); const bottom = Math.max(...objectBounds.map((bounds) => bounds.bottom)); const destinationPoint = { x: scene.tileCenterX(destination.x), y: scene.tileCenterY(destination.y) }; const waitButton = scene.commandButtons.find((button) => button.command === 'wait')?.background; const waitBounds = waitButton?.getBounds(); return { state, unitId, menuBounds: { x: left, y: top, width: right - left, height: bottom - top }, destinationPoint, waitBounds: waitBounds ? { x: waitBounds.x, y: waitBounds.y, width: waitBounds.width, height: waitBounds.height } : null }; }, { unitId: lastAllySetup.unitId, destination: movementTarget.tile }); assert( !postMovement.state?.turnPromptVisible && postMovement.state?.phase === 'command' && !postMovement.state?.actedUnitIds?.includes(lastAllySetup.unitId), `Arrival must keep command selection open before showing the turn-end prompt: ${JSON.stringify(postMovement.state)}` ); const destinationMenuDistance = pointToBoundsDistance(postMovement.destinationPoint, postMovement.menuBounds); assert( destinationMenuDistance <= 48, `The movement command menu drifted away from the clicked destination (${destinationMenuDistance.toFixed(1)}px): ${JSON.stringify(postMovement)}` ); assert(postMovement.waitBounds, 'Expected the wait command button after movement.'); await clickSceneBounds(page, 'BattleScene', postMovement.waitBounds); await page.waitForFunction((unitId) => { const battle = window.__HEROS_DEBUG__?.battle(); return ( battle?.turnPromptVisible === true && battle?.turnPromptMode === 'turn-end' && battle?.actedUnitIds?.includes(unitId) ); }, lastAllySetup.unitId); const automaticTurnPrompt = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.turnPrompt); assert( automaticTurnPrompt?.title === '모든 아군의 행동이 종료되었습니다.' && automaticTurnPrompt.focusedAction === 'primary' && automaticTurnPrompt.cancelAction === 'secondary' && automaticTurnPrompt.buttons?.find((button) => button.id === 'primary')?.meaning === 'end-turn' && automaticTurnPrompt.buttons?.find((button) => button.id === 'secondary')?.meaning === 'review-battlefield', `The all-acted automatic prompt must retain Enter-to-end and Esc-to-review semantics: ${JSON.stringify(automaticTurnPrompt)}` ); await clickTurnPromptSecondary(page); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false); const persistentTurnEndBounds = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const text = (scene?.sidePanelObjects ?? []).find((object) => ( object.type === 'Text' && object.active && object.visible && object.input?.enabled && object.text === '턴 종료' )); const bounds = text?.getBounds(); return bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null; }); assert(persistentTurnEndBounds, 'Expected a persistent turn-end action after choosing battlefield review.'); await clickSceneBounds(page, 'BattleScene', persistentTurnEndBounds); await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === true && window.__HEROS_DEBUG__?.battle()?.turnPromptMode === 'turn-end' )); await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); window.__HEROS_INTERACTION_UX__ ??= {}; window.__HEROS_INTERACTION_UX__.originalAutomaticRunEnemyTurn = scene.runEnemyTurn; window.__HEROS_INTERACTION_UX__.automaticTurnEnemyRunCount = 0; scene.runEnemyTurn = async () => { window.__HEROS_INTERACTION_UX__.automaticTurnEnemyRunCount += 1; }; }); await page.keyboard.press('Enter'); await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.battle()?.activeFaction === 'enemy' && window.__HEROS_INTERACTION_UX__?.automaticTurnEnemyRunCount === 1 )); const automaticEnterResult = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const interaction = window.__HEROS_INTERACTION_UX__; scene.runEnemyTurn = interaction.originalAutomaticRunEnemyTurn; delete interaction.originalAutomaticRunEnemyTurn; scene.activeFaction = 'ally'; scene.phase = 'idle'; scene.suppressNextLeftClick = false; scene.refreshEnemyIntentForecast(); scene.refreshUnitLegibilityStyles(); scene.updateTurnText(); scene.renderSituationPanel('자동 턴 종료 Enter 검증 완료'); return { enemyRunCount: interaction.automaticTurnEnemyRunCount, battle: window.__HEROS_DEBUG__?.battle() }; }); assert( automaticEnterResult.enemyRunCount === 1 && automaticEnterResult.battle?.activeFaction === 'ally' && automaticEnterResult.battle.turnPromptVisible === false, `Enter on the all-acted automatic prompt must still end the allied turn: ${JSON.stringify(automaticEnterResult)}` ); const mapMenuPointer = await findRightmostEmptyBattleTile(page); await page.mouse.click(mapMenuPointer.x, mapMenuPointer.y, { button: 'right' }); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.mapMenuVisible === true); const mapMenu = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); return scene?.mapMenuLayout ? { ...scene.mapMenuLayout, actions: [...scene.mapMenuLayout.actions] } : null; }); assert(mapMenu, 'Expected the right-click battlefield menu.'); const horizontalPointerGap = mapMenuPointer.x < mapMenu.left ? mapMenu.left - mapMenuPointer.x : mapMenuPointer.x > mapMenu.left + mapMenu.width ? mapMenuPointer.x - (mapMenu.left + mapMenu.width) : 0; assert( horizontalPointerGap <= 24, `The right-click menu did not open next to the pointer (${horizontalPointerGap.toFixed(1)}px): ${JSON.stringify({ mapMenuPointer, mapMenu })}` ); const saveActionIndex = mapMenu.actions.indexOf('save'); assert(saveActionIndex >= 0, `Expected a save action in the map menu: ${JSON.stringify(mapMenu)}`); await clickSceneBounds(page, 'BattleScene', { x: mapMenu.left, y: mapMenu.top + mapMenu.padding + saveActionIndex * mapMenu.buttonHeight, width: mapMenu.width, height: mapMenu.buttonHeight }); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.saveSlotPanelMode === 'save'); const battleModalProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const rectangles = (scene?.saveSlotPanelObjects ?? []) .filter((object) => object.type === 'Rectangle' && object.active && object.input?.enabled) .map((object) => { const bounds = object.getBounds(); return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, depth: object.depth }; }); const blocker = rectangles.find((bounds) => ( bounds.width >= scene.scale.width * 0.98 && bounds.height >= scene.scale.height * 0.98 )); const panel = rectangles .filter((bounds) => bounds.width < scene.scale.width * 0.9 && bounds.height > 200) .sort((left, right) => right.width * right.height - left.width * left.height)[0]; const tryCamera = (x, y) => { scene.setCameraTilePosition(x, y); return (scene.debugBattleUnits?.() ?? []) .filter((unit) => unit.faction === 'ally' && unit.hp > 0) .map((unit) => ({ unit, zone: scene.unitViews.get(unit.id)?.hitZone })) .find(({ zone }) => { if (!zone?.visible || !zone.input?.enabled) { return false; } const bounds = zone.getBounds(); const point = { x: bounds.centerX, y: bounds.centerY }; return !panel || !pointInsideBounds(point, panel); }); }; const candidate = tryCamera(0, 0) ?? tryCamera(scene.maxCameraTileX(), scene.maxCameraTileY()); const candidateBounds = candidate?.zone?.getBounds(); const persistentTurnEndAction = (scene?.sidePanelObjects ?? []).find((object) => ( object.type === 'Text' && object.active && object.visible && object.input?.enabled && object.text === '턴 종료' )); const persistentActionBounds = persistentTurnEndAction?.getBounds(); const coveredBounds = candidateBounds ?? persistentActionBounds; return { blocker: blocker ?? null, panel: panel ?? null, unitId: candidate?.unit.id ?? null, targetKind: candidateBounds ? 'unit' : persistentActionBounds ? 'turn-end-action' : null, coveredBounds: coveredBounds ? { x: coveredBounds.x, y: coveredBounds.y, width: coveredBounds.width, height: coveredBounds.height } : null, selectedUnitId: scene.selectedUnit?.id ?? null }; function pointInsideBounds(point, bounds) { return ( point.x >= bounds.x && point.x <= bounds.x + bounds.width && point.y >= bounds.y && point.y <= bounds.y + bounds.height ); } }); assert( battleModalProbe.blocker && battleModalProbe.coveredBounds, `Expected a full-screen battle save blocker and an interactive control behind it: ${JSON.stringify(battleModalProbe)}` ); await clickSceneBounds(page, 'BattleScene', battleModalProbe.coveredBounds); await page.waitForTimeout(120); const battleAfterCoveredClick = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); assert( battleAfterCoveredClick?.saveSlotPanelMode === 'save' && battleAfterCoveredClick?.selectedUnitId === battleModalProbe.selectedUnitId && battleAfterCoveredClick?.turnPromptVisible === false, `Battle save modal allowed an underlying control click: ${JSON.stringify({ before: battleModalProbe, after: { saveSlotPanelMode: battleAfterCoveredClick?.saveSlotPanelMode, selectedUnitId: battleAfterCoveredClick?.selectedUnitId, turnPromptVisible: battleAfterCoveredClick?.turnPromptVisible } })}` ); await verifyEarlyTurnEndSafety(page); } async function verifyEarlyTurnEndSafety(page) { const setup = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const units = scene?.debugBattleUnits?.() ?? []; const allies = units.filter((unit) => unit.faction === 'ally' && unit.hp > 0); const enemies = units.filter((unit) => unit.faction === 'enemy' && unit.hp > 0); if (!scene || allies.length < 1 || enemies.length < 3) { return null; } scene.hideBattleEventBanner(); scene.hideTurnEndPrompt(); scene.hideSaveSlotPanel(); scene.hideMapMenu(); scene.hideCommandMenu(); scene.clearMarkers(); scene.activeFaction = 'ally'; scene.phase = 'idle'; scene.selectedUnit = undefined; scene.pendingMove = undefined; scene.targetingAction = undefined; scene.selectedUsable = undefined; scene.actedUnitIds.clear(); scene.clearEnemyIntentForecast(); const threatTargets = Array.from({ length: 3 }, (_, index) => { const ally = allies[index]; return ally ?? { ...allies[0], id: `qa-overflow-threat-target-${index + 1}`, name: `추가 위협 대상 ${index + 1}` }; }); threatTargets.forEach((ally, index) => { const enemy = enemies[index]; const plan = scene.buildEnemyActionPlan(enemy); scene.enemyIntentForecasts.set(enemy.id, { ...plan, kind: 'attack', target: ally, destination: { x: enemy.x, y: enemy.y }, preview: { ...(plan.preview ?? {}), damage: Math.max(1, plan.preview?.damage ?? 1), hitRate: plan.preview?.hitRate ?? 100, criticalRate: plan.preview?.criticalRate ?? 0 } }); }); scene.renderSituationPanel('조기 턴 종료 안전성 검증'); window.__HEROS_INTERACTION_UX__ ??= {}; window.__HEROS_INTERACTION_UX__.originalRunEnemyTurn = scene.runEnemyTurn; window.__HEROS_INTERACTION_UX__.earlyTurnEnemyRunCount = 0; scene.runEnemyTurn = async () => { window.__HEROS_INTERACTION_UX__.earlyTurnEnemyRunCount += 1; }; return { allyIds: allies.map((unit) => unit.id), allyNames: allies.map((unit) => unit.name), forcedThreatTargetCount: new Set( Array.from(scene.enemyIntentForecasts.values()).map((plan) => plan.target?.id).filter(Boolean) ).size }; }); assert( setup?.allyIds.length >= 1 && setup.forcedThreatTargetCount === 3, `Expected unacted allies with three distinct forced threats: ${JSON.stringify(setup)}` ); const initialPrompt = await openManualTurnEndPrompt(page); assertEarlyTurnPrompt(initialPrompt, setup, 'initial'); await page.screenshot({ path: 'dist/verification-interaction-ux-early-turn-end.png', fullPage: true }); await page.keyboard.press('Enter'); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false); const afterSafeEnter = await page.evaluate(() => ({ battle: window.__HEROS_DEBUG__?.battle(), enemyRunCount: window.__HEROS_INTERACTION_UX__?.earlyTurnEnemyRunCount ?? -1 })); assert( afterSafeEnter.battle?.activeFaction === 'ally' && afterSafeEnter.battle.actedUnitIds?.length === 0 && afterSafeEnter.enemyRunCount === 0, `Enter on the safe default must only close the early turn-end prompt: ${JSON.stringify(afterSafeEnter)}` ); await openManualTurnEndPrompt(page); await page.keyboard.press('Escape'); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false); const afterEscape = await page.evaluate(() => ({ battle: window.__HEROS_DEBUG__?.battle(), enemyRunCount: window.__HEROS_INTERACTION_UX__?.earlyTurnEnemyRunCount ?? -1 })); assert( afterEscape.battle?.activeFaction === 'ally' && afterEscape.enemyRunCount === 0, `Escape must always cancel early turn end: ${JSON.stringify(afterEscape)}` ); await openManualTurnEndPrompt(page); await page.keyboard.press('ArrowRight'); const afterRight = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.turnPrompt); assert( afterRight?.focusedAction === 'secondary' && afterRight.buttons?.find((button) => button.id === 'secondary')?.dangerous === true, `ArrowRight must explicitly focus the dangerous early turn-end action: ${JSON.stringify(afterRight)}` ); await page.keyboard.press('ArrowLeft'); const afterLeft = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.turnPrompt); assert( afterLeft?.focusedAction === 'primary', `ArrowLeft must restore the safe early turn-end action: ${JSON.stringify(afterLeft)}` ); await page.keyboard.press('Tab'); const afterTab = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.turnPrompt); assert( afterTab?.focusedAction === 'secondary', `Tab must explicitly focus the dangerous early turn-end action: ${JSON.stringify(afterTab)}` ); // Let Phaser observe the keyup and render the new focus before the next synthetic key press. // Real keyboard input naturally has this spacing; without it Chromium can rarely coalesce the sequence. await page.waitForTimeout(80); await page.keyboard.press('Enter'); try { await page.waitForFunction(() => ( window.__HEROS_DEBUG__?.battle()?.activeFaction === 'enemy' && window.__HEROS_INTERACTION_UX__?.earlyTurnEnemyRunCount === 1 ), undefined, { timeout: 5000 }); } catch (error) { const stalledState = await page.evaluate(() => { const battle = window.__HEROS_DEBUG__?.battle(); return { activeElement: document.activeElement?.tagName ?? null, documentHasFocus: document.hasFocus(), activeFaction: battle?.activeFaction ?? null, phase: battle?.phase ?? null, battleOutcome: battle?.battleOutcome ?? null, turnPromptVisible: battle?.turnPromptVisible ?? null, turnPrompt: battle?.turnPrompt ?? null, battleEventVisible: battle?.battleEvent?.visible ?? null, saveSlotPanelMode: battle?.saveSlotPanelMode ?? null, tacticalCommandCutInVisible: battle?.tacticalCommandCutIn?.visible ?? null, enemyRunCount: window.__HEROS_INTERACTION_UX__?.earlyTurnEnemyRunCount ?? -1 }; }); throw new Error( `The focused dangerous early turn-end action stalled after Enter: ${JSON.stringify(stalledState)}`, { cause: error } ); } const confirmed = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const interaction = window.__HEROS_INTERACTION_UX__; if (scene && interaction?.originalRunEnemyTurn) { scene.runEnemyTurn = interaction.originalRunEnemyTurn; delete interaction.originalRunEnemyTurn; scene.activeFaction = 'ally'; scene.phase = 'idle'; scene.suppressNextLeftClick = false; scene.hideTurnEndPrompt(); scene.refreshEnemyIntentForecast(); scene.refreshUnitLegibilityStyles(); scene.updateTurnText(); scene.renderSituationPanel('조기 턴 종료 안전성 검증 완료'); } return { enemyRunCount: interaction?.earlyTurnEnemyRunCount ?? -1, battle: window.__HEROS_DEBUG__?.battle() }; }); assert( confirmed.enemyRunCount === 1 && confirmed.battle?.activeFaction === 'ally' && confirmed.battle.turnPromptVisible === false, `The explicitly selected dangerous action did not enter and cleanly restore the enemy turn: ${JSON.stringify(confirmed)}` ); } async function openManualTurnEndPrompt(page) { const mapMenuPointer = await findRightmostEmptyBattleTile(page); await page.mouse.click(mapMenuPointer.x, mapMenuPointer.y, { button: 'right' }); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.mapMenuVisible === true); const mapMenu = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); return scene?.mapMenuLayout ? { ...scene.mapMenuLayout, actions: [...scene.mapMenuLayout.actions] } : null; }); const endTurnActionIndex = mapMenu?.actions.indexOf('endTurn') ?? -1; assert(mapMenu && endTurnActionIndex >= 0, `Expected an enabled manual turn-end action: ${JSON.stringify(mapMenu)}`); await clickSceneBounds(page, 'BattleScene', { x: mapMenu.left, y: mapMenu.top + mapMenu.padding + endTurnActionIndex * mapMenu.buttonHeight, width: mapMenu.width, height: mapMenu.buttonHeight }); await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === true); return page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.turnPrompt); } function assertEarlyTurnPrompt(prompt, setup, stage) { const primary = prompt?.buttons?.find((button) => button.id === 'primary'); const secondary = prompt?.buttons?.find((button) => button.id === 'secondary'); assert( prompt?.title.includes(`미행동 ${setup.allyIds.length}명`) && setup.allyNames.every((name) => prompt.body.includes(name)) && prompt.body.includes('외 1명') && prompt.focusedAction === 'primary' && prompt.cancelAction === 'primary' && primary?.label === '계속 지휘' && primary.meaning === 'continue-command' && primary.dangerous === false && primary.focused === true && primary.escapeCancels === true && secondary?.label === '미행동 포기 후 종료' && secondary.meaning === 'abandon-unacted-and-end' && secondary.dangerous === true && secondary.focused === false && secondary.escapeCancels === false, `Early turn-end prompt did not disclose unacted allies, overflow threats, or safe focus (${stage}): ${JSON.stringify({ setup, prompt })}` ); } async function verifyBattleEventOverlayInputBlock(page) { const before = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); if (!scene || (scene.battleEventObjects?.length ?? 0) === 0) { return null; } const maxX = scene.maxCameraTileX(); const maxY = scene.maxCameraTileY(); const edge = 2; const point = scene.cameraTileX < maxX ? { x: scene.layout.gridX + scene.layout.gridWidth - edge, y: scene.layout.gridY + scene.layout.gridHeight / 2 } : scene.cameraTileX > 0 ? { x: scene.layout.gridX + edge, y: scene.layout.gridY + scene.layout.gridHeight / 2 } : scene.cameraTileY < maxY ? { x: scene.layout.gridX + scene.layout.gridWidth / 2, y: scene.layout.gridY + scene.layout.gridHeight - edge } : scene.cameraTileY > 0 ? { x: scene.layout.gridX + scene.layout.gridWidth / 2, y: scene.layout.gridY + edge } : null; return { eventObjectCount: scene.battleEventObjects.length, cameraTileX: scene.cameraTileX, cameraTileY: scene.cameraTileY, point }; }); assert(before?.point, `Expected a visible battle event overlay and a scrollable map edge: ${JSON.stringify(before)}`); const edgePoint = await sceneLogicalPoint(page, 'BattleScene', before.point); await page.mouse.move(edgePoint.x, edgePoint.y); await page.waitForTimeout(360); const after = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); return { eventObjectCount: scene?.battleEventObjects?.length ?? 0, cameraTileX: scene?.cameraTileX ?? null, cameraTileY: scene?.cameraTileY ?? null, pointerFeedbackKey: scene?.lastPointerFeedbackKey ?? null, pointerFeedbackVisible: scene?.pointerFeedbackMarker?.visible ?? false }; }); assert( after.eventObjectCount > 0 && after.cameraTileX === before.cameraTileX && after.cameraTileY === before.cameraTileY && after.pointerFeedbackKey === null && after.pointerFeedbackVisible === false, `Battle event overlay allowed edge-scroll or pointer feedback through: ${JSON.stringify({ before, after })}` ); const safePoint = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); return scene ? { x: scene.layout.gridX + scene.layout.gridWidth / 2, y: scene.layout.gridY + scene.layout.gridHeight / 2 } : null; }); assert(safePoint, 'Expected a safe battlefield pointer position after overlay QA.'); const safeScreenPoint = await sceneLogicalPoint(page, 'BattleScene', safePoint); await page.mouse.move(safeScreenPoint.x, safeScreenPoint.y); } async function verifyBattleEventQueueBehavior(page) { const initial = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); if (!scene) { return null; } scene.hideBattleEventBanner(); scene.clearBattleEvents(); ['qa-event-a', 'qa-event-b', 'qa-event-c', 'qa-event-d', 'qa-event-interrupted'].forEach((key) => scene.triggeredBattleEvents.delete(key)); scene.deferBattleEventPresentation = true; scene.triggerBattleEvent('qa-event-a', '먼저 감지된 사건', ['같은 전투 흐름에서 먼저 표시됩니다.'], { playCue: false }); scene.triggerBattleEvent('qa-event-b', '낮은 우선순위', ['마지막에 표시되어야 합니다.'], { playCue: false, priority: 'low' }); scene.triggerBattleEvent('qa-event-c', '긴급 사건 하나', ['가장 높은 우선순위로 표시되어야 합니다.'], { playCue: false, priority: 'critical' }); scene.triggerBattleEvent('qa-event-d', '긴급 사건 둘', ['같은 우선순위의 발생 순서를 지켜야 합니다.'], { playCue: false, priority: 'critical' }); scene.deferBattleEventPresentation = false; scene.showNextBattleEvent(); return { active: scene.activeBattleEvent?.key ?? null, queued: scene.battleEventQueue.map((event) => event.key), completed: ['qa-event-a', 'qa-event-b', 'qa-event-c', 'qa-event-d'].filter((key) => scene.triggeredBattleEvents.has(key)), objectCount: scene.battleEventObjects.length, bannerText: scene.battleEventObjects .map((object) => typeof object.text === 'string' ? object.text : '') .filter(Boolean) }; }); assert( initial?.active === 'qa-event-c' && JSON.stringify(initial.queued) === JSON.stringify(['qa-event-d', 'qa-event-a', 'qa-event-b']) && initial.completed.length === 0 && initial.objectCount > 0 && initial.bannerText.some((text) => text.includes('외 3건')), `Battle event grouping did not preserve priority/FIFO or disclose grouped notices: ${JSON.stringify(initial)}` ); await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); scene?.hideBattleEventBanner(); }); await page.waitForFunction(() => ( window.__HEROS_GAME__?.scene.getScene('BattleScene')?.activeBattleEvent === undefined )); const afterFirstDismissal = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); return scene ? { active: scene.activeBattleEvent?.key ?? null, queued: scene.battleEventQueue.map((event) => event.key), completed: ['qa-event-a', 'qa-event-b', 'qa-event-c', 'qa-event-d'].filter((key) => scene.triggeredBattleEvents.has(key)), objectCount: scene.battleEventObjects.length } : null; }); assert( afterFirstDismissal?.active === null && afterFirstDismissal.queued.length === 0 && afterFirstDismissal.completed.length === 4 && afterFirstDismissal.objectCount === 0, `Battle event dismissal did not complete the grouped one-modal batch: ${JSON.stringify(afterFirstDismissal)}` ); const cleanup = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); scene?.triggerBattleEvent('qa-event-interrupted', '중단될 사건', ['정리 시 완료 처리되면 안 됩니다.'], { playCue: false }); scene?.clearBattleEvents(); return scene ? { active: scene.activeBattleEvent?.key ?? null, queued: scene.battleEventQueue.map((event) => event.key), objectCount: scene.battleEventObjects.length, interruptedCompleted: scene.triggeredBattleEvents.has('qa-event-interrupted') } : null; }); assert( cleanup?.active === null && cleanup.queued.length === 0 && cleanup.objectCount === 0 && cleanup.interruptedCompleted === false, `Battle event cleanup retained or falsely completed interrupted events: ${JSON.stringify(cleanup)}` ); const reactionProbe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const units = scene?.debugBattleUnits?.() ?? []; const liuBei = units.find((unit) => unit.id === 'liu-bei'); if (!scene || !liuBei) { return null; } const livingReaction = scene.tacticalEventReaction('ally-danger'); const previousHp = liuBei.hp; liuBei.hp = 0; const defeatedReaction = scene.tacticalEventReaction('ally-danger'); liuBei.hp = previousHp; return { deployedIds: units.filter((unit) => unit.faction === 'ally').map((unit) => unit.id), livingSpeaker: livingReaction?.unitId ?? null, defeatedSpeaker: defeatedReaction?.unitId ?? null }; }); assert( reactionProbe?.livingSpeaker === 'liu-bei' && reactionProbe.defeatedSpeaker === null && !reactionProbe.deployedIds.includes('zhao-yun'), `Tactical event reaction used an undeployed or defeated officer: ${JSON.stringify(reactionProbe)}` ); } async function verifyWolongNarrativeVictoryGate(page) { const battleId = 'seventeenth-battle-wolong-visit-road'; await page.evaluate((nextBattleId) => window.__HEROS_DEBUG__?.goToBattle(nextBattleId), battleId); await page.waitForFunction((expectedId) => { const battle = window.__HEROS_DEBUG__?.battle(); return battle?.battleId === expectedId && ['deployment', 'idle'].includes(battle.phase); }, battleId, { timeout: 90000 }); await startDeploymentIfNeeded(page, battleId); const probe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const units = scene?.debugBattleUnits?.() ?? []; const allies = units.filter((unit) => unit.faction === 'ally' && unit.hp > 0); const enemies = units.filter((unit) => unit.faction === 'enemy'); const visitor = allies.find((unit) => unit.id === 'liu-bei') ?? allies[0]; if (!scene || !visitor || enemies.length === 0) { return null; } scene.clearBattleEvents(); enemies.forEach((enemy) => { enemy.hp = 0; }); const blockedOutcome = scene.pendingBattleOutcome() ?? null; const before = scene.requiredVictoryObjectiveStates().map((objective) => ({ id: objective.id, category: objective.category, achieved: objective.achieved })); const gateEventKnown = scene.isBattleEventKnown('victory-gate-pending'); visitor.x = 29; visitor.y = 16; const after = scene.requiredVictoryObjectiveStates().map((objective) => ({ id: objective.id, category: objective.category, achieved: objective.achieved })); const completedOutcome = scene.pendingBattleOutcome() ?? null; return { blockedOutcome, completedOutcome, before, after, gateEventKnown }; }); assert( probe?.blockedOutcome === null && probe.completedOutcome === 'victory' && probe.gateEventKnown === true && JSON.stringify(probe.before.map((objective) => objective.id)) === JSON.stringify(['scholar-rendezvous', 'longzhong-cottage']) && probe.before.every((objective) => objective.category === 'required' && objective.achieved === false) && probe.after.every((objective) => objective.category === 'required' && objective.achieved === true), `Wolong battle ignored or misreported its narrative victory gate: ${JSON.stringify(probe)}` ); } async function verifyCampTimelineRowLayout(page) { await page.evaluate(() => window.__HEROS_DEBUG__?.goToCamp()); await page.waitForFunction(() => { const scenes = window.__HEROS_DEBUG__?.activeScenes() ?? []; return scenes.includes('CampScene') && window.__HEROS_GAME__?.scene.getScene('CampScene')?.scene?.isActive(); }, undefined, { timeout: 90000 }); const rows = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); if (!scene) { return null; } scene.hideSortiePrep(false); scene.clearContent(); scene.renderProgressChapterDetail( { id: 'qa-long-timeline-fields', title: '긴 연표 필드 검증', period: '1920×1080', description: '긴 전투명과 승리 조건이 서로 침범하지 않는지 확인합니다.', battleIds: [ 'seventeenth-battle-wolong-visit-road', 'fifty-fifth-battle-northern-qishan-road', 'fifty-seventh-battle-jieting-crisis', 'fifty-eighth-battle-qishan-retreat' ], nextHints: [] }, 0, 804, 262, 390, 310, new Set() ); const fields = scene.children.list .filter((object) => object.type === 'Text' && object.active && object.getData('timelineField')) .map((object) => { const bounds = object.getBounds(); return { field: object.getData('timelineField'), text: object.text, x: bounds.x, y: bounds.y, right: bounds.right, width: bounds.width }; }); const titles = fields.filter((field) => field.field === 'battle-title').sort((left, right) => left.y - right.y); const objectives = fields.filter((field) => field.field === 'victory-condition').sort((left, right) => left.y - right.y); return titles.map((title, index) => ({ title, objective: objectives[index] ?? null })); }); assert( rows?.length === 4 && rows.every(({ title, objective }) => ( objective && title.right < objective.x && title.width > 0 && objective.width > 0 && objective.text.endsWith('…') )), `Camp timeline detail fields overlap or fail to truncate at 1920x1080: ${JSON.stringify(rows)}` ); } async function startDeploymentIfNeeded(page, battleId) { const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); if (state?.phase === 'idle') { return; } assert( state?.battleId === battleId && state?.phase === 'deployment' && state?.battleHud?.deploymentPanel?.startButtonBounds, `Expected ${battleId} deployment controls: ${JSON.stringify(state)}` ); await clickSceneBounds(page, 'BattleScene', state.battleHud.deploymentPanel.startButtonBounds); await page.waitForFunction((expectedId) => { const battle = window.__HEROS_DEBUG__?.battle(); return battle?.battleId === expectedId && battle?.phase === 'idle'; }, battleId, { timeout: 30000 }); await page.waitForTimeout(100); } async function clickTurnPromptSecondary(page) { const bounds = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); const buttons = (scene?.turnPromptObjects ?? []) .filter((object) => ( object.type === 'Rectangle' && object.active && object.input?.enabled && object.displayWidth < 500 && object.displayHeight < 100 )) .map((object) => object.getBounds()) .sort((left, right) => right.centerX - left.centerX); const button = buttons[0]; return button ? { x: button.x, y: button.y, width: button.width, height: button.height } : null; }); assert(bounds, 'Expected the battlefield-review button in the turn-end prompt.'); await clickSceneBounds(page, 'BattleScene', bounds); } async function findRightmostEmptyBattleTile(page) { const logicalPoint = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); if (!scene) { return null; } for (let column = scene.layout.visibleColumns - 1; column >= 0; column -= 1) { for (let row = 0; row < scene.layout.visibleRows; row += 1) { const x = scene.cameraTileX + column; const y = scene.cameraTileY + row; if (scene.isInBounds(x, y) && !scene.isOccupied(x, y)) { return { x: scene.tileCenterX(x), y: scene.tileCenterY(y) }; } } } return null; }); assert(logicalPoint, 'Expected a visible empty battlefield tile for the right-click menu.'); return sceneLogicalPoint(page, 'BattleScene', logicalPoint); } async function waitForDebugApi(page) { await page.waitForFunction(() => ( document.querySelector('canvas') !== null && window.__HEROS_GAME__ !== undefined && window.__HEROS_DEBUG__ !== undefined ), undefined, { timeout: 90000 }); } async function assertDesktopViewport(page) { const viewport = await page.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight, dpr: window.devicePixelRatio, canvas: (() => { const canvas = document.querySelector('canvas'); return canvas ? { width: canvas.width, height: canvas.height } : null; })() })); assert( viewport.width === desktopBrowserViewport.width && viewport.height === desktopBrowserViewport.height && viewport.dpr === 1 && viewport.canvas?.width === desktopBrowserViewport.width && viewport.canvas?.height === desktopBrowserViewport.height, `Expected the required 1920x1080 CSS viewport at 100% zoom: ${JSON.stringify(viewport)}` ); } async function clickSceneBounds(page, sceneKey, bounds, options) { const point = await sceneBoundsCenter(page, sceneKey, bounds); await page.mouse.click(point.x, point.y, options); return point; } async function sceneBoundsCenter(page, sceneKey, bounds) { return sceneLogicalPoint(page, sceneKey, { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }); } async function sceneLogicalPoint(page, sceneKey, logicalPoint) { const point = await page.evaluate(({ requestedSceneKey, requestedPoint }) => { const scene = window.__HEROS_GAME__?.scene.getScene(requestedSceneKey); const canvas = document.querySelector('canvas'); const canvasBounds = canvas?.getBoundingClientRect(); if (!scene || !canvasBounds || !requestedPoint) { return null; } return { x: canvasBounds.left + requestedPoint.x * canvasBounds.width / scene.scale.width, y: canvasBounds.top + requestedPoint.y * canvasBounds.height / scene.scale.height }; }, { requestedSceneKey: sceneKey, requestedPoint: logicalPoint }); assert(point && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected a valid ${sceneKey} pointer coordinate.`); return point; } function pointToBoundsDistance(point, bounds) { const dx = Math.max(bounds.x - point.x, 0, point.x - (bounds.x + bounds.width)); const dy = Math.max(bounds.y - point.y, 0, point.y - (bounds.y + bounds.height)); return Math.hypot(dx, dy); } function sameStrings(left, right) { return ( Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => value === right[index]) ); } function assert(condition, message) { if (!condition) { throw new Error(message); } } 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 || '41783'], { 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 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)); }