2354 lines
92 KiB
JavaScript
2354 lines
92 KiB
JavaScript
import { spawn, spawnSync } from 'node:child_process';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { chromium } from 'playwright';
|
||
import { desktopBrowserContextOptions, desktopBrowserViewport } from './desktop-browser-viewport.mjs';
|
||
|
||
const renderers = ['canvas', 'webgl'];
|
||
const renderer =
|
||
process.env.VERIFY_INTERACTION_UX_RENDERER;
|
||
|
||
if (!renderer) {
|
||
for (const requestedRenderer of renderers) {
|
||
const result = spawnSync(
|
||
process.execPath,
|
||
[fileURLToPath(import.meta.url)],
|
||
{
|
||
cwd: process.cwd(),
|
||
env: {
|
||
...process.env,
|
||
VERIFY_INTERACTION_UX_RENDERER:
|
||
requestedRenderer
|
||
},
|
||
stdio: 'inherit'
|
||
}
|
||
);
|
||
if (result.status !== 0) {
|
||
process.exit(result.status ?? 1);
|
||
}
|
||
}
|
||
process.exit(0);
|
||
}
|
||
|
||
assert(
|
||
renderers.includes(renderer),
|
||
`Unsupported interaction UX renderer "${renderer}".`
|
||
);
|
||
|
||
const targetUrl = withDebugOptions(
|
||
process.env.VERIFY_INTERACTION_UX_URL ??
|
||
`http://127.0.0.1:${renderer === 'canvas' ? 41783 : 41784}/`
|
||
);
|
||
const expectedBattleId = 'second-battle-yellow-turban-pursuit';
|
||
|
||
let serverProcess;
|
||
let browser;
|
||
const pageErrors = [];
|
||
let delayedBattleModuleRequests = 0;
|
||
|
||
try {
|
||
serverProcess = await ensureLocalServer(targetUrl);
|
||
await prewarmBattleSceneModule(targetUrl);
|
||
browser = await chromium.launch({
|
||
headless:
|
||
process.env.VERIFY_INTERACTION_UX_HEADLESS !== '0',
|
||
args:
|
||
renderer === 'webgl'
|
||
? [
|
||
'--use-angle=swiftshader',
|
||
'--enable-unsafe-swiftshader'
|
||
]
|
||
: []
|
||
});
|
||
const context = await browser.newContext(desktopBrowserContextOptions);
|
||
const page = await context.newPage();
|
||
page.setDefaultTimeout(30000);
|
||
|
||
page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message));
|
||
|
||
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);
|
||
await verifyBattleShutdownWaitCleanup(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 ${renderer} 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, ' +
|
||
'final attack and support result popups clear before first-engagement and victory-gate events, and all clear before the turn-end prompt appears, ' +
|
||
'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, the right-click menu follows the pointer, ' +
|
||
'and pending result waits resolve when the battle scene shuts down.'
|
||
);
|
||
} 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', renderer);
|
||
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
|
||
})}`
|
||
);
|
||
|
||
try {
|
||
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 });
|
||
} catch (error) {
|
||
const diagnostic = await page.evaluate(() => {
|
||
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
|
||
const camp = window.__HEROS_DEBUG__?.camp();
|
||
return {
|
||
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [],
|
||
camp: camp
|
||
? {
|
||
campaignStep: camp.campaign?.step ?? null,
|
||
sortiePrepStep: camp.sortiePrepStep ?? null,
|
||
sortiePrimaryAction: camp.sortiePrimaryAction?.kind ?? null,
|
||
selectedSortieUnitIds: camp.selectedSortieUnitIds ?? [],
|
||
nextSortieBattleId: camp.nextSortieBattleId ?? null,
|
||
skipIntroStoryForSortie:
|
||
camp.skipIntroStoryForSortie ?? null
|
||
}
|
||
: null,
|
||
navigationPending: scene?.navigationPending ?? null,
|
||
navigationBlockerCount: scene?.navigationBlockerObjects?.length ?? null,
|
||
saveSlotObjectCount: scene?.saveSlotObjects?.length ?? null,
|
||
saveConfirmObjectCount: scene?.saveSlotConfirmObjects?.length ?? null,
|
||
sceneKeys: Object.keys(window.__HEROS_GAME__?.scene.keys ?? {}),
|
||
resources: performance
|
||
.getEntriesByType('resource')
|
||
.map((entry) => entry.name)
|
||
.filter((name) => name.includes('BattleScene'))
|
||
};
|
||
});
|
||
throw new Error(
|
||
`BattleScene did not become active after camp navigation: ${JSON.stringify({
|
||
diagnostic,
|
||
delayedBattleModuleRequests,
|
||
pageErrors
|
||
})}`,
|
||
{ cause: error }
|
||
);
|
||
}
|
||
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)}`
|
||
);
|
||
// WebGL needs one rendered frame after selection before the new tile hit
|
||
// areas are guaranteed to accept synthetic pointer input.
|
||
await page.waitForTimeout(120);
|
||
|
||
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 page.waitForTimeout(120);
|
||
|
||
const waitCommandClick = await clickBattleCommand(
|
||
page,
|
||
'wait'
|
||
);
|
||
try {
|
||
await page.waitForFunction((unitId) => {
|
||
const battle = window.__HEROS_DEBUG__?.battle();
|
||
return (
|
||
battle?.turnPromptVisible === true &&
|
||
battle?.turnPromptMode === 'turn-end' &&
|
||
battle?.actedUnitIds?.includes(unitId)
|
||
);
|
||
}, lastAllySetup.unitId);
|
||
} catch (error) {
|
||
const diagnostic =
|
||
await battleCommandPointerDiagnostic(
|
||
page,
|
||
waitCommandClick
|
||
);
|
||
throw new Error(
|
||
`The wait-command pointer click did not finish the final ally action: ${JSON.stringify(
|
||
diagnostic
|
||
)}`,
|
||
{ cause: error }
|
||
);
|
||
}
|
||
const automaticTurnPrompt = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.turnPrompt);
|
||
assert(
|
||
automaticTurnPrompt?.title === '모든 아군의 행동이 종료되었습니다.' &&
|
||
automaticTurnPrompt.focusedAction === 'primary' &&
|
||
automaticTurnPrompt.cancelAction === 'secondary' &&
|
||
automaticTurnPrompt.riskForecast?.targetCount > 0 &&
|
||
automaticTurnPrompt.riskRows?.length === Math.min(
|
||
2,
|
||
automaticTurnPrompt.riskForecast.targetCount
|
||
) &&
|
||
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 page.waitForTimeout(120);
|
||
const automaticTurnPromptSecondaryClick = await clickTurnPromptSecondary(page);
|
||
try {
|
||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false);
|
||
} catch (error) {
|
||
const diagnostic = await turnPromptPointerDiagnostic(page, automaticTurnPromptSecondaryClick);
|
||
throw new Error(
|
||
`The battlefield-review pointer click did not close the turn-end prompt: ${JSON.stringify(diagnostic)}`,
|
||
{ cause: error }
|
||
);
|
||
}
|
||
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 page.waitForTimeout(120);
|
||
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');
|
||
await page.waitForTimeout(120);
|
||
|
||
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,
|
||
persistentActionBounds: persistentActionBounds
|
||
? {
|
||
x: persistentActionBounds.x,
|
||
y: persistentActionBounds.y,
|
||
width: persistentActionBounds.width,
|
||
height: persistentActionBounds.height
|
||
}
|
||
: null,
|
||
selectedUnitId: scene.selectedUnit?.id ?? null,
|
||
activeFaction: scene.activeFaction,
|
||
turnNumber: scene.turnNumber
|
||
};
|
||
|
||
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 &&
|
||
battleModalProbe.persistentActionBounds,
|
||
`Expected a full-screen battle save blocker, an interactive control, and the persistent turn-end action 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 clickSceneBounds(page, 'BattleScene', battleModalProbe.persistentActionBounds);
|
||
await page.waitForTimeout(120);
|
||
const battleAfterCoveredTurnEndClick = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
||
assert(
|
||
battleAfterCoveredTurnEndClick?.saveSlotPanelMode === 'save' &&
|
||
battleAfterCoveredTurnEndClick?.selectedUnitId === battleModalProbe.selectedUnitId &&
|
||
battleAfterCoveredTurnEndClick?.turnPromptVisible === false &&
|
||
battleAfterCoveredTurnEndClick?.activeFaction === battleModalProbe.activeFaction &&
|
||
battleAfterCoveredTurnEndClick?.turnNumber === battleModalProbe.turnNumber,
|
||
`Battle save modal allowed the persistent turn-end action behind it: ${JSON.stringify({
|
||
before: battleModalProbe,
|
||
after: {
|
||
saveSlotPanelMode: battleAfterCoveredTurnEndClick?.saveSlotPanelMode,
|
||
selectedUnitId: battleAfterCoveredTurnEndClick?.selectedUnitId,
|
||
turnPromptVisible: battleAfterCoveredTurnEndClick?.turnPromptVisible,
|
||
activeFaction: battleAfterCoveredTurnEndClick?.activeFaction,
|
||
turnNumber: battleAfterCoveredTurnEndClick?.turnNumber
|
||
}
|
||
})}`
|
||
);
|
||
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) => (
|
||
allies[index] ?? {
|
||
...allies[0],
|
||
id: `qa-overflow-threat-target-${index + 1}`,
|
||
name: `추가 위협 대상 ${index + 1}`
|
||
}
|
||
));
|
||
const forcedAttacks = [
|
||
{ enemy: enemies[0], target: threatTargets[0], damage: 12, hitRate: 60, criticalRate: 0 },
|
||
{ enemy: enemies[1], target: threatTargets[0], damage: 18, hitRate: 90, criticalRate: 20 },
|
||
{ enemy: enemies[2], target: threatTargets[1], damage: 10, hitRate: 75, criticalRate: 0 },
|
||
{
|
||
enemy: {
|
||
...enemies[0],
|
||
id: `qa-overflow-attacker-${enemies[0].id}`,
|
||
name: '추가 위험 검증 적군'
|
||
},
|
||
planEnemy: enemies[0],
|
||
target: threatTargets[2],
|
||
damage: 8,
|
||
hitRate: 50,
|
||
criticalRate: 0
|
||
}
|
||
];
|
||
forcedAttacks.forEach((attack) => {
|
||
const plan = scene.buildEnemyActionPlan(attack.planEnemy ?? attack.enemy);
|
||
scene.enemyIntentForecasts.set(attack.enemy.id, {
|
||
...plan,
|
||
enemy: attack.enemy,
|
||
kind: 'attack',
|
||
target: attack.target,
|
||
approachTarget: attack.target,
|
||
usable: undefined,
|
||
destination: { x: attack.enemy.x, y: attack.enemy.y },
|
||
preview: {
|
||
...(plan.preview ?? {}),
|
||
damage: attack.damage,
|
||
hitRate: attack.hitRate,
|
||
criticalRate: attack.criticalRate
|
||
}
|
||
});
|
||
});
|
||
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;
|
||
};
|
||
|
||
const expectedGroups = threatTargets.map((target) => {
|
||
const attacks = forcedAttacks.filter((attack) => attack.target.id === target.id);
|
||
const expectedDamage = Math.round(attacks.reduce(
|
||
(total, attack) => total +
|
||
attack.damage * attack.hitRate / 100 * (1 + attack.criticalRate / 200),
|
||
0
|
||
));
|
||
const rawDamage = attacks.reduce((total, attack) => total + attack.damage, 0);
|
||
const maximumDamage = attacks.reduce(
|
||
(total, attack) => total + (
|
||
attack.criticalRate > 0 ? Math.round(attack.damage * 1.5) : attack.damage
|
||
),
|
||
0
|
||
);
|
||
return {
|
||
targetId: target.id,
|
||
targetName: target.name,
|
||
hp: target.hp,
|
||
immediateCount: attacks.length,
|
||
approachCount: 0,
|
||
expectedDamage,
|
||
rawDamage,
|
||
maximumDamage,
|
||
lethal: maximumDamage >= target.hp,
|
||
hitRateMin: Math.min(...attacks.map((attack) => attack.hitRate)),
|
||
hitRateMax: Math.max(...attacks.map((attack) => attack.hitRate)),
|
||
concentrated: attacks.length > 1,
|
||
attackCount: attacks.length
|
||
};
|
||
}).sort((left, right) => (
|
||
Number(right.lethal) - Number(left.lethal) ||
|
||
right.expectedDamage / Math.max(1, right.hp) - left.expectedDamage / Math.max(1, left.hp) ||
|
||
right.expectedDamage - left.expectedDamage ||
|
||
right.immediateCount - left.immediateCount ||
|
||
right.approachCount - left.approachCount ||
|
||
left.targetName.localeCompare(right.targetName, 'ko')
|
||
));
|
||
return {
|
||
allyIds: allies.map((unit) => unit.id),
|
||
allyNames: allies.map((unit) => unit.name),
|
||
forcedAttackCount: forcedAttacks.length,
|
||
forcedThreatTargetCount: new Set(
|
||
Array.from(scene.enemyIntentForecasts.values()).map((plan) => plan.target?.id).filter(Boolean)
|
||
).size,
|
||
expectedGroups,
|
||
expectedVisibleGroupIds: expectedGroups.slice(0, 2).map((group) => group.targetId),
|
||
expectedDamage: expectedGroups.reduce((total, group) => total + group.expectedDamage, 0),
|
||
expectedLethalTargetCount: expectedGroups.filter((group) => group.lethal).length
|
||
};
|
||
});
|
||
assert(
|
||
setup?.allyIds.length >= 1 &&
|
||
setup.forcedAttackCount === 4 &&
|
||
setup.forcedThreatTargetCount === 3,
|
||
`Expected four attacks concentrated across 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-${renderer}.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)}`
|
||
);
|
||
await verifyFinalActionResultTiming(page);
|
||
}
|
||
|
||
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');
|
||
const forecast = prompt?.riskForecast;
|
||
const groupContractsValid = setup.expectedGroups.every((expected) => {
|
||
const actual = forecast?.groups?.find((group) => group.targetId === expected.targetId);
|
||
return (
|
||
actual?.targetName === expected.targetName &&
|
||
actual.hp === expected.hp &&
|
||
actual.immediateCount === expected.immediateCount &&
|
||
actual.approachCount === expected.approachCount &&
|
||
actual.expectedDamage === expected.expectedDamage &&
|
||
actual.rawDamage === expected.rawDamage &&
|
||
actual.maximumDamage === expected.maximumDamage &&
|
||
actual.lethal === expected.lethal &&
|
||
actual.hitRateMin === expected.hitRateMin &&
|
||
actual.hitRateMax === expected.hitRateMax &&
|
||
actual.concentrated === expected.concentrated &&
|
||
actual.attacks?.length === expected.attackCount
|
||
);
|
||
});
|
||
assert(
|
||
prompt?.title.includes(`미행동 ${setup.allyIds.length}명`) &&
|
||
setup.allyNames.every((name) => prompt.body.includes(name)) &&
|
||
forecast?.immediateAttackCount === setup.forcedAttackCount &&
|
||
forecast.approachCount === 0 &&
|
||
forecast.targetCount === setup.forcedThreatTargetCount &&
|
||
forecast.expectedDamage === setup.expectedDamage &&
|
||
forecast.lethalTargetCount === setup.expectedLethalTargetCount &&
|
||
forecast.omittedTargetCount === 1 &&
|
||
sameStrings(forecast.visibleGroupIds, setup.expectedVisibleGroupIds) &&
|
||
forecast.groups?.length === setup.expectedGroups.length &&
|
||
groupContractsValid &&
|
||
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 verifyFinalActionResultTiming(page) {
|
||
for (const actionKind of ['attack', 'support']) {
|
||
const result = await page.evaluate(async (requestedActionKind) => {
|
||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||
const units = scene?.debugBattleUnits?.() ?? [];
|
||
const allies = units.filter((unit) => unit.faction === 'ally' && unit.hp > 0);
|
||
if (!scene || allies.length === 0) {
|
||
return { error: 'missing active battle scene or allied units' };
|
||
}
|
||
|
||
const originalMethods = {
|
||
showBondMapEffect: scene.showBondMapEffect,
|
||
presentCombatResult: scene.presentCombatResult,
|
||
presentSupportResult: scene.presentSupportResult,
|
||
resolveCounterAttack: scene.resolveCounterAttack,
|
||
triggerFirstEngagementEvent: scene.triggerFirstEngagementEvent,
|
||
collectBattleEventsWithoutPresentation:
|
||
scene.collectBattleEventsWithoutPresentation,
|
||
showNextBattleEvent: scene.showNextBattleEvent,
|
||
resolveBattleOutcomeIfNeeded: scene.resolveBattleOutcomeIfNeeded
|
||
};
|
||
const originalBattleSpeed = scene.battleSpeed;
|
||
const originalUnitState = new Map(
|
||
units.map((unit) => [
|
||
unit.id,
|
||
{ x: unit.x, y: unit.y, hp: unit.hp, maxHp: unit.maxHp }
|
||
])
|
||
);
|
||
const sample = {
|
||
actionKind: requestedActionKind,
|
||
popupSeen: false,
|
||
promptSeen: false,
|
||
overlapSeen: false,
|
||
eventOverlaySeen: false,
|
||
resultEventOverlapSeen: false,
|
||
firstPopupFrame: null,
|
||
firstPromptFrame: null,
|
||
settledFrame: null,
|
||
frame: 0,
|
||
maxActivePopupCount: 0
|
||
};
|
||
let sampling = true;
|
||
let samplingError;
|
||
let settleSampling;
|
||
const samplingDone = new Promise((resolve) => {
|
||
settleSampling = resolve;
|
||
});
|
||
const sampleFrame = () => {
|
||
if (!sampling) {
|
||
settleSampling();
|
||
return;
|
||
}
|
||
|
||
sample.frame += 1;
|
||
const activePopupCount = (scene.mapResultPopupObjects ?? []).filter((object) => object.active).length;
|
||
const activeEventObjectCount = (
|
||
scene.battleEventObjects ?? []
|
||
).filter((object) => object.active).length;
|
||
const promptVisible = (scene.turnPromptObjects?.length ?? 0) > 0;
|
||
sample.maxActivePopupCount = Math.max(sample.maxActivePopupCount, activePopupCount);
|
||
if (activePopupCount > 0) {
|
||
sample.popupSeen = true;
|
||
sample.firstPopupFrame ??= sample.frame;
|
||
}
|
||
if (promptVisible) {
|
||
sample.promptSeen = true;
|
||
sample.firstPromptFrame ??= sample.frame;
|
||
}
|
||
if (activePopupCount > 0 && promptVisible) {
|
||
sample.overlapSeen = true;
|
||
}
|
||
if (activeEventObjectCount > 0) {
|
||
sample.eventOverlaySeen = true;
|
||
}
|
||
if (
|
||
activePopupCount > 0 &&
|
||
activeEventObjectCount > 0
|
||
) {
|
||
sample.resultEventOverlapSeen = true;
|
||
}
|
||
if (sample.popupSeen && promptVisible && activePopupCount === 0) {
|
||
sample.settledFrame = sample.frame;
|
||
sampling = false;
|
||
settleSampling();
|
||
return;
|
||
}
|
||
if (sample.frame >= 480) {
|
||
samplingError = `timed out after ${sample.frame} animation frames`;
|
||
sampling = false;
|
||
settleSampling();
|
||
return;
|
||
}
|
||
requestAnimationFrame(sampleFrame);
|
||
};
|
||
|
||
try {
|
||
scene.clearBattleEvents();
|
||
scene.triggeredBattleEvents.delete('first-engagement');
|
||
scene.hideTurnEndPrompt();
|
||
scene.hideSaveSlotPanel();
|
||
scene.hideMapMenu();
|
||
scene.hideCommandMenu();
|
||
scene.clearMarkers();
|
||
(scene.mapResultPopupObjects ?? []).forEach((object) => {
|
||
if (object.active) {
|
||
scene.tweens.killTweensOf(object);
|
||
object.destroy();
|
||
}
|
||
});
|
||
scene.mapResultPopupObjects = [];
|
||
scene.mapResultPopupLast = undefined;
|
||
scene.battleSpeed = 'normal';
|
||
scene.activeFaction = 'ally';
|
||
scene.battleOutcome = undefined;
|
||
scene.phase = 'targeting';
|
||
scene.pendingMove = undefined;
|
||
scene.targetingAction = requestedActionKind === 'attack' ? 'attack' : 'strategy';
|
||
scene.lockedTargetPreview = undefined;
|
||
scene.actedUnitIds.clear();
|
||
|
||
scene.showBondMapEffect = async () => {};
|
||
scene.presentCombatResult = async () => {};
|
||
scene.presentSupportResult = async () => {};
|
||
scene.resolveCounterAttack = () => undefined;
|
||
scene.collectBattleEventsWithoutPresentation = () => {};
|
||
scene.resolveBattleOutcomeIfNeeded = () => false;
|
||
|
||
let actingUnit;
|
||
let targetUnit;
|
||
let supportUsable;
|
||
if (requestedActionKind === 'attack') {
|
||
actingUnit = allies[0];
|
||
targetUnit = units.find((unit) => unit.faction === 'enemy' && unit.hp > 0);
|
||
if (!targetUnit) {
|
||
return { error: 'missing living enemy target' };
|
||
}
|
||
const adjacentTile = [
|
||
{ x: actingUnit.x + 1, y: actingUnit.y },
|
||
{ x: actingUnit.x - 1, y: actingUnit.y },
|
||
{ x: actingUnit.x, y: actingUnit.y + 1 },
|
||
{ x: actingUnit.x, y: actingUnit.y - 1 }
|
||
].find((tile) => scene.isInBounds(tile.x, tile.y) && !scene.isOccupied(tile.x, tile.y));
|
||
targetUnit.x = adjacentTile?.x ?? actingUnit.x;
|
||
targetUnit.y = adjacentTile?.y ?? actingUnit.y;
|
||
targetUnit.maxHp = Math.max(targetUnit.maxHp, 999);
|
||
targetUnit.hp = targetUnit.maxHp;
|
||
} else {
|
||
const supportChoice = allies
|
||
.map((unit) => ({
|
||
unit,
|
||
usable: scene.availableUsables(unit, 'strategy').find((usable) => usable.effect === 'focus')
|
||
}))
|
||
.find((choice) => choice.usable);
|
||
actingUnit = supportChoice?.unit;
|
||
targetUnit = actingUnit;
|
||
supportUsable = supportChoice?.usable;
|
||
if (!actingUnit || !targetUnit || !supportUsable) {
|
||
return { error: 'missing allied focus-support action' };
|
||
}
|
||
}
|
||
|
||
scene.selectedUnit = actingUnit;
|
||
scene.selectedUsable = supportUsable;
|
||
allies
|
||
.filter((unit) => unit.id !== actingUnit.id)
|
||
.forEach((unit) => scene.actedUnitIds.add(unit.id));
|
||
scene.centerCameraOnTile(targetUnit.x, targetUnit.y);
|
||
scene.restoreUnitView(targetUnit);
|
||
scene.renderSituationPanel();
|
||
|
||
requestAnimationFrame(sampleFrame);
|
||
if (requestedActionKind === 'attack') {
|
||
await scene.tryResolveDamageTarget(actingUnit, targetUnit, 'attack');
|
||
} else {
|
||
await scene.tryResolveSupportTarget(actingUnit, targetUnit, supportUsable);
|
||
}
|
||
await samplingDone;
|
||
|
||
return {
|
||
...sample,
|
||
samplingError: samplingError ?? null,
|
||
actionCompleted: scene.actedUnitIds.has(actingUnit.id),
|
||
promptVisibleAfterAction: scene.turnPromptObjects.length > 0,
|
||
promptModeAfterAction: scene.turnPromptMode ?? null,
|
||
popupActiveAfterAction: scene.mapResultPopupObjects.filter((object) => object.active).length
|
||
};
|
||
} finally {
|
||
sampling = false;
|
||
scene.showBondMapEffect = originalMethods.showBondMapEffect;
|
||
scene.presentCombatResult = originalMethods.presentCombatResult;
|
||
scene.presentSupportResult = originalMethods.presentSupportResult;
|
||
scene.resolveCounterAttack = originalMethods.resolveCounterAttack;
|
||
scene.triggerFirstEngagementEvent = originalMethods.triggerFirstEngagementEvent;
|
||
scene.collectBattleEventsWithoutPresentation =
|
||
originalMethods.collectBattleEventsWithoutPresentation;
|
||
scene.showNextBattleEvent =
|
||
originalMethods.showNextBattleEvent;
|
||
scene.resolveBattleOutcomeIfNeeded = originalMethods.resolveBattleOutcomeIfNeeded;
|
||
scene.battleSpeed = originalBattleSpeed;
|
||
scene.hideTurnEndPrompt();
|
||
(scene.mapResultPopupObjects ?? []).forEach((object) => {
|
||
if (object.active) {
|
||
scene.tweens.killTweensOf(object);
|
||
object.destroy();
|
||
}
|
||
});
|
||
scene.mapResultPopupObjects = [];
|
||
scene.clearBattleEvents();
|
||
scene.triggeredBattleEvents.delete('first-engagement');
|
||
units.forEach((unit) => {
|
||
const original = originalUnitState.get(unit.id);
|
||
if (!original) {
|
||
return;
|
||
}
|
||
unit.x = original.x;
|
||
unit.y = original.y;
|
||
unit.hp = original.hp;
|
||
unit.maxHp = original.maxHp;
|
||
scene.restoreUnitView(unit);
|
||
});
|
||
scene.phase = 'idle';
|
||
scene.selectedUnit = undefined;
|
||
scene.selectedUsable = undefined;
|
||
scene.targetingAction = undefined;
|
||
scene.lockedTargetPreview = undefined;
|
||
scene.actedUnitIds.clear();
|
||
}
|
||
}, actionKind);
|
||
|
||
assert(!result?.error, `Could not prepare the final ${actionKind} timing case: ${JSON.stringify(result)}`);
|
||
assert(
|
||
result.popupSeen &&
|
||
result.promptSeen &&
|
||
!result.overlapSeen &&
|
||
!result.resultEventOverlapSeen &&
|
||
(actionKind !== 'attack' || result.eventOverlaySeen) &&
|
||
!result.samplingError &&
|
||
result.actionCompleted &&
|
||
result.promptVisibleAfterAction &&
|
||
result.promptModeAfterAction === 'turn-end' &&
|
||
result.popupActiveAfterAction === 0,
|
||
`The final ${actionKind} result must clear before the turn-end prompt appears: ${JSON.stringify(result)}`
|
||
);
|
||
}
|
||
await verifyFinalFollowUpResultTiming(page);
|
||
await verifyOutcomeGateEventTiming(page);
|
||
}
|
||
|
||
async function verifyFinalFollowUpResultTiming(page) {
|
||
const result = await page.evaluate(async () => {
|
||
const scene =
|
||
window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||
const units = scene?.debugBattleUnits?.() ?? [];
|
||
const allies = units.filter(
|
||
(unit) => unit.faction === 'ally' && unit.hp > 0
|
||
);
|
||
const actor = allies[0];
|
||
if (!scene || !actor) {
|
||
return {
|
||
error:
|
||
'missing active battle scene or final allied actor'
|
||
};
|
||
}
|
||
|
||
const originalMethods = {
|
||
settleEnemyIntentCounterplay:
|
||
scene.settleEnemyIntentCounterplay,
|
||
collectBattleEventsWithoutPresentation:
|
||
scene.collectBattleEventsWithoutPresentation,
|
||
showNextBattleEvent: scene.showNextBattleEvent,
|
||
resolveBattleOutcomeIfNeeded:
|
||
scene.resolveBattleOutcomeIfNeeded,
|
||
persistBattleAutosave: scene.persistBattleAutosave,
|
||
showTurnEndPrompt: scene.showTurnEndPrompt
|
||
};
|
||
const originalState = {
|
||
phase: scene.phase,
|
||
activeFaction: scene.activeFaction,
|
||
selectedUnit: scene.selectedUnit,
|
||
pendingMove: scene.pendingMove,
|
||
targetingAction: scene.targetingAction,
|
||
selectedUsable: scene.selectedUsable,
|
||
actedUnitIds: [...scene.actedUnitIds],
|
||
battleLog: [...scene.battleLog],
|
||
feedbackReadableUntil:
|
||
scene.battleFeedbackReadableUntil
|
||
};
|
||
let popupDuration = 0;
|
||
let promptAt = null;
|
||
let activePopupsWhenPromptOpened = null;
|
||
|
||
try {
|
||
scene.hideBattleEventBanner();
|
||
scene.hideTurnEndPrompt();
|
||
scene.hideSaveSlotPanel();
|
||
scene.hideMapMenu();
|
||
scene.hideCommandMenu();
|
||
scene.clearMarkers();
|
||
(scene.mapResultPopupObjects ?? []).forEach(
|
||
(object) => {
|
||
if (object.active) {
|
||
scene.tweens.killTweensOf(object);
|
||
object.destroy();
|
||
}
|
||
}
|
||
);
|
||
scene.mapResultPopupObjects = [];
|
||
scene.battleFeedbackReadableUntil = scene.time.now;
|
||
scene.activeFaction = 'ally';
|
||
scene.phase = 'command';
|
||
scene.selectedUnit = actor;
|
||
scene.pendingMove = undefined;
|
||
scene.targetingAction = undefined;
|
||
scene.selectedUsable = undefined;
|
||
scene.actedUnitIds.clear();
|
||
allies
|
||
.filter((unit) => unit.id !== actor.id)
|
||
.forEach((unit) =>
|
||
scene.actedUnitIds.add(unit.id)
|
||
);
|
||
|
||
scene.settleEnemyIntentCounterplay = (
|
||
settlementActor
|
||
) => {
|
||
popupDuration = scene.showMapResultPopup(
|
||
settlementActor,
|
||
['의도 파훼 +1', '공격선 차단'],
|
||
'#ffdf7b',
|
||
'#2b1606',
|
||
18,
|
||
36
|
||
);
|
||
return {
|
||
message: `의도 파훼 +1 · ${settlementActor.name} · 차1`,
|
||
popupDuration
|
||
};
|
||
};
|
||
scene.collectBattleEventsWithoutPresentation =
|
||
() => {};
|
||
scene.showNextBattleEvent = () => {};
|
||
scene.resolveBattleOutcomeIfNeeded = () => false;
|
||
scene.persistBattleAutosave = () => true;
|
||
scene.showTurnEndPrompt = function (
|
||
...args
|
||
) {
|
||
promptAt = performance.now();
|
||
activePopupsWhenPromptOpened = (
|
||
scene.mapResultPopupObjects ?? []
|
||
).filter((object) => object.active).length;
|
||
return originalMethods.showTurnEndPrompt.apply(
|
||
this,
|
||
args
|
||
);
|
||
};
|
||
|
||
const startedAt = performance.now();
|
||
await scene.finishUnitAction(
|
||
actor,
|
||
`${actor.name} 행동 완료`
|
||
);
|
||
const elapsed = performance.now() - startedAt;
|
||
return {
|
||
elapsed,
|
||
popupDuration,
|
||
promptAt,
|
||
activePopupsWhenPromptOpened,
|
||
promptVisible:
|
||
scene.turnPromptObjects.length > 0,
|
||
promptMode: scene.turnPromptMode ?? null,
|
||
actionCompleted:
|
||
scene.actedUnitIds.has(actor.id)
|
||
};
|
||
} finally {
|
||
scene.settleEnemyIntentCounterplay =
|
||
originalMethods.settleEnemyIntentCounterplay;
|
||
scene.collectBattleEventsWithoutPresentation =
|
||
originalMethods.collectBattleEventsWithoutPresentation;
|
||
scene.showNextBattleEvent =
|
||
originalMethods.showNextBattleEvent;
|
||
scene.resolveBattleOutcomeIfNeeded =
|
||
originalMethods.resolveBattleOutcomeIfNeeded;
|
||
scene.persistBattleAutosave =
|
||
originalMethods.persistBattleAutosave;
|
||
scene.showTurnEndPrompt =
|
||
originalMethods.showTurnEndPrompt;
|
||
scene.hideTurnEndPrompt();
|
||
(scene.mapResultPopupObjects ?? []).forEach(
|
||
(object) => {
|
||
if (object.active) {
|
||
scene.tweens.killTweensOf(object);
|
||
object.destroy();
|
||
}
|
||
}
|
||
);
|
||
scene.mapResultPopupObjects = [];
|
||
scene.phase = originalState.phase;
|
||
scene.activeFaction = originalState.activeFaction;
|
||
scene.selectedUnit = originalState.selectedUnit;
|
||
scene.pendingMove = originalState.pendingMove;
|
||
scene.targetingAction =
|
||
originalState.targetingAction;
|
||
scene.selectedUsable =
|
||
originalState.selectedUsable;
|
||
scene.actedUnitIds = new Set(
|
||
originalState.actedUnitIds
|
||
);
|
||
scene.battleLog = originalState.battleLog;
|
||
scene.battleFeedbackReadableUntil =
|
||
originalState.feedbackReadableUntil;
|
||
scene.resetActedStyles();
|
||
}
|
||
});
|
||
|
||
assert(
|
||
!result?.error &&
|
||
result.popupDuration > 0 &&
|
||
result.elapsed >= result.popupDuration &&
|
||
result.promptAt !== null &&
|
||
result.activePopupsWhenPromptOpened === 0 &&
|
||
result.promptVisible &&
|
||
result.promptMode === 'turn-end' &&
|
||
result.actionCompleted,
|
||
`The final follow-up result must settle before the turn-end prompt appears: ${JSON.stringify(result)}`
|
||
);
|
||
}
|
||
|
||
async function verifyOutcomeGateEventTiming(page) {
|
||
const result = await page.evaluate(async () => {
|
||
const scene =
|
||
window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||
const units = scene?.debugBattleUnits?.() ?? [];
|
||
const allies = units.filter(
|
||
(unit) => unit.faction === 'ally' && unit.hp > 0
|
||
);
|
||
const actor = allies[0];
|
||
if (!scene || !actor) {
|
||
return {
|
||
error:
|
||
'missing active battle scene or final allied actor'
|
||
};
|
||
}
|
||
|
||
const eventKey = 'qa-victory-gate-pending';
|
||
const originalMethods = {
|
||
settleEnemyIntentCounterplay:
|
||
scene.settleEnemyIntentCounterplay,
|
||
collectBattleEventsWithoutPresentation:
|
||
scene.collectBattleEventsWithoutPresentation,
|
||
resolveBattleOutcomeIfNeeded:
|
||
scene.resolveBattleOutcomeIfNeeded,
|
||
persistBattleAutosave: scene.persistBattleAutosave,
|
||
showTurnEndPrompt: scene.showTurnEndPrompt
|
||
};
|
||
const originalState = {
|
||
phase: scene.phase,
|
||
activeFaction: scene.activeFaction,
|
||
selectedUnit: scene.selectedUnit,
|
||
actedUnitIds: [...scene.actedUnitIds],
|
||
battleLog: [...scene.battleLog]
|
||
};
|
||
let promptAt = null;
|
||
let activeEventObjectsWhenPromptOpened = null;
|
||
let outcomeResolutionCalls = 0;
|
||
|
||
try {
|
||
scene.clearBattleEvents();
|
||
scene.hideTurnEndPrompt();
|
||
scene.activeFaction = 'ally';
|
||
scene.phase = 'command';
|
||
scene.selectedUnit = actor;
|
||
scene.actedUnitIds.clear();
|
||
allies
|
||
.filter((unit) => unit.id !== actor.id)
|
||
.forEach((unit) =>
|
||
scene.actedUnitIds.add(unit.id)
|
||
);
|
||
scene.triggeredBattleEvents.delete(eventKey);
|
||
scene.settleEnemyIntentCounterplay = () => undefined;
|
||
scene.collectBattleEventsWithoutPresentation = () => {};
|
||
scene.resolveBattleOutcomeIfNeeded = function () {
|
||
outcomeResolutionCalls += 1;
|
||
this.triggerBattleEvent(
|
||
eventKey,
|
||
'Required objective pending',
|
||
['Secure the required objective before ending combat.'],
|
||
{ playCue: false, priority: 'critical' }
|
||
);
|
||
return false;
|
||
};
|
||
scene.persistBattleAutosave = () => true;
|
||
scene.showTurnEndPrompt = function (...args) {
|
||
promptAt = performance.now();
|
||
activeEventObjectsWhenPromptOpened =
|
||
this.battleEventObjects.filter(
|
||
(object) => object.active
|
||
).length;
|
||
return originalMethods.showTurnEndPrompt.apply(
|
||
this,
|
||
args
|
||
);
|
||
};
|
||
|
||
const startedAt = performance.now();
|
||
await scene.finishUnitAction(
|
||
actor,
|
||
`${actor.name} action complete`
|
||
);
|
||
return {
|
||
elapsed: performance.now() - startedAt,
|
||
promptAt,
|
||
activeEventObjectsWhenPromptOpened,
|
||
outcomeResolutionCalls,
|
||
eventTriggered:
|
||
scene.triggeredBattleEvents.has(eventKey),
|
||
promptVisible:
|
||
scene.turnPromptObjects.length > 0,
|
||
promptMode: scene.turnPromptMode ?? null
|
||
};
|
||
} finally {
|
||
scene.settleEnemyIntentCounterplay =
|
||
originalMethods.settleEnemyIntentCounterplay;
|
||
scene.collectBattleEventsWithoutPresentation =
|
||
originalMethods.collectBattleEventsWithoutPresentation;
|
||
scene.resolveBattleOutcomeIfNeeded =
|
||
originalMethods.resolveBattleOutcomeIfNeeded;
|
||
scene.persistBattleAutosave =
|
||
originalMethods.persistBattleAutosave;
|
||
scene.showTurnEndPrompt =
|
||
originalMethods.showTurnEndPrompt;
|
||
scene.clearBattleEvents();
|
||
scene.hideTurnEndPrompt();
|
||
scene.triggeredBattleEvents.delete(eventKey);
|
||
scene.phase = originalState.phase;
|
||
scene.activeFaction = originalState.activeFaction;
|
||
scene.selectedUnit = originalState.selectedUnit;
|
||
scene.actedUnitIds = new Set(
|
||
originalState.actedUnitIds
|
||
);
|
||
scene.battleLog = originalState.battleLog;
|
||
scene.resetActedStyles();
|
||
}
|
||
});
|
||
|
||
assert(
|
||
!result?.error &&
|
||
result.outcomeResolutionCalls === 1 &&
|
||
result.eventTriggered &&
|
||
result.promptAt !== null &&
|
||
result.activeEventObjectsWhenPromptOpened === 0 &&
|
||
result.promptVisible &&
|
||
result.promptMode === 'turn-end',
|
||
`A victory-gate event created during outcome resolution must clear before the turn-end prompt appears: ${JSON.stringify(result)}`
|
||
);
|
||
}
|
||
|
||
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 verifyBattleShutdownWaitCleanup(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
|
||
);
|
||
}, expectedBattleId, { timeout: 90000 });
|
||
|
||
const result = await page.evaluate(async () => {
|
||
const scene =
|
||
window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||
if (!scene) {
|
||
return { error: 'missing active battle scene' };
|
||
}
|
||
scene.battleFeedbackReadableUntil =
|
||
scene.time.now + 5_000;
|
||
const waits = [
|
||
scene.delay(5_000),
|
||
scene.waitSceneDuration(5_000),
|
||
scene.waitForBattleFeedbackReadability()
|
||
];
|
||
scene.scene.stop();
|
||
const resolved = await Promise.race([
|
||
Promise.all(waits).then(() => true),
|
||
new Promise((resolve) =>
|
||
window.setTimeout(() => resolve(false), 1_500)
|
||
)
|
||
]);
|
||
return {
|
||
resolved,
|
||
battleSceneActive:
|
||
window.__HEROS_DEBUG__?.activeScenes?.()
|
||
?.includes('BattleScene') ?? false
|
||
};
|
||
});
|
||
|
||
assert(
|
||
!result?.error &&
|
||
result.resolved === true &&
|
||
result.battleSceneActive === false,
|
||
`Battle result waits remained pending after scene shutdown: ${JSON.stringify(result)}`
|
||
);
|
||
}
|
||
|
||
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 button = await page.evaluate(() => {
|
||
const prompt = window.__HEROS_DEBUG__?.battle()?.turnPrompt;
|
||
return prompt?.buttons?.find((candidate) => candidate.id === 'secondary') ?? null;
|
||
});
|
||
assert(
|
||
button?.bounds && button.meaning === 'review-battlefield',
|
||
`Expected the battlefield-review button in the turn-end prompt: ${JSON.stringify(button)}`
|
||
);
|
||
const point = await clickSceneBounds(page, 'BattleScene', button.bounds);
|
||
return { button, point };
|
||
}
|
||
|
||
async function clickBattleCommand(page, command) {
|
||
await page.evaluate(
|
||
() =>
|
||
new Promise((resolve) =>
|
||
requestAnimationFrame(() =>
|
||
requestAnimationFrame(resolve)
|
||
)
|
||
)
|
||
);
|
||
const button = await page.evaluate(
|
||
(requestedCommand) => {
|
||
const scene =
|
||
window.__HEROS_GAME__?.scene.getScene(
|
||
'BattleScene'
|
||
);
|
||
const candidate = scene?.commandButtons?.find(
|
||
(entry) =>
|
||
entry.command === requestedCommand
|
||
);
|
||
const bounds =
|
||
candidate?.background?.getBounds?.();
|
||
return candidate && bounds
|
||
? {
|
||
command: candidate.command,
|
||
available: candidate.available,
|
||
active:
|
||
candidate.background.active,
|
||
visible:
|
||
candidate.background.visible,
|
||
inputEnabled:
|
||
candidate.background.input?.enabled ===
|
||
true,
|
||
bounds: {
|
||
x: bounds.x,
|
||
y: bounds.y,
|
||
width: bounds.width,
|
||
height: bounds.height
|
||
}
|
||
}
|
||
: null;
|
||
},
|
||
command
|
||
);
|
||
assert(
|
||
button?.command === command &&
|
||
button.available === true &&
|
||
button.active === true &&
|
||
button.visible === true &&
|
||
button.inputEnabled === true,
|
||
`Expected an active ${command} command button: ${JSON.stringify(
|
||
button
|
||
)}`
|
||
);
|
||
const point = await clickSceneBounds(
|
||
page,
|
||
'BattleScene',
|
||
button.bounds
|
||
);
|
||
return { button, point };
|
||
}
|
||
|
||
async function battleCommandPointerDiagnostic(
|
||
page,
|
||
click
|
||
) {
|
||
return page.evaluate((requestedClick) => {
|
||
const scene =
|
||
window.__HEROS_GAME__?.scene.getScene(
|
||
'BattleScene'
|
||
);
|
||
const canvas = document.querySelector('canvas');
|
||
const canvasBounds =
|
||
canvas?.getBoundingClientRect();
|
||
const pointer = scene?.input?.activePointer;
|
||
const buttons = (
|
||
scene?.commandButtons ?? []
|
||
).map((button) => {
|
||
const bounds =
|
||
button.background?.getBounds?.();
|
||
return {
|
||
command: button.command,
|
||
available: button.available,
|
||
active:
|
||
button.background?.active ?? false,
|
||
visible:
|
||
button.background?.visible ?? false,
|
||
inputEnabled:
|
||
button.background?.input?.enabled ===
|
||
true,
|
||
bounds: bounds
|
||
? {
|
||
x: bounds.x,
|
||
y: bounds.y,
|
||
width: bounds.width,
|
||
height: bounds.height
|
||
}
|
||
: null
|
||
};
|
||
});
|
||
return {
|
||
click: requestedClick,
|
||
battle:
|
||
window.__HEROS_DEBUG__?.battle?.() ??
|
||
null,
|
||
buttons,
|
||
commandMenuObjectCount:
|
||
scene?.commandMenuObjects?.filter(
|
||
(object) => object.active
|
||
).length ?? 0,
|
||
suppressNextLeftClick:
|
||
scene?.suppressNextLeftClick ?? null,
|
||
canvasBounds: canvasBounds
|
||
? {
|
||
x: canvasBounds.x,
|
||
y: canvasBounds.y,
|
||
width: canvasBounds.width,
|
||
height: canvasBounds.height
|
||
}
|
||
: null,
|
||
sceneSize: scene
|
||
? {
|
||
width: scene.scale.width,
|
||
height: scene.scale.height
|
||
}
|
||
: null,
|
||
activePointer: pointer
|
||
? {
|
||
x: pointer.x,
|
||
y: pointer.y,
|
||
downX: pointer.downX,
|
||
downY: pointer.downY,
|
||
upX: pointer.upX,
|
||
upY: pointer.upY,
|
||
isDown: pointer.isDown
|
||
}
|
||
: null
|
||
};
|
||
}, click);
|
||
}
|
||
|
||
async function turnPromptPointerDiagnostic(page, click) {
|
||
return page.evaluate((requestedClick) => {
|
||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||
const canvas = document.querySelector('canvas');
|
||
const canvasBounds = canvas?.getBoundingClientRect();
|
||
const pointer = scene?.input?.activePointer;
|
||
return {
|
||
click: requestedClick,
|
||
turnPrompt: window.__HEROS_DEBUG__?.battle()?.turnPrompt ?? null,
|
||
canvasBounds: canvasBounds
|
||
? {
|
||
x: canvasBounds.x,
|
||
y: canvasBounds.y,
|
||
width: canvasBounds.width,
|
||
height: canvasBounds.height
|
||
}
|
||
: null,
|
||
sceneSize: scene
|
||
? { width: scene.scale.width, height: scene.scale.height }
|
||
: null,
|
||
activePointer: pointer
|
||
? {
|
||
x: pointer.x,
|
||
y: pointer.y,
|
||
downX: pointer.downX,
|
||
downY: pointer.downY,
|
||
upX: pointer.upX,
|
||
upY: pointer.upY,
|
||
isDown: pointer.isDown
|
||
}
|
||
: null
|
||
};
|
||
}, click);
|
||
}
|
||
|
||
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,
|
||
scale: window.visualViewport?.scale ?? 1,
|
||
rendererType:
|
||
window.__HEROS_GAME__?.renderer?.type ?? null,
|
||
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.scale === 1 &&
|
||
viewport.rendererType ===
|
||
(renderer === 'webgl' ? 2 : 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;
|
||
}
|
||
}
|
||
|
||
async function prewarmBattleSceneModule(url) {
|
||
const parsed = new URL(url);
|
||
const moduleUrl = new URL(
|
||
'/heros_web/src/game/scenes/BattleScene.ts',
|
||
parsed.origin
|
||
);
|
||
const response = await fetch(moduleUrl, {
|
||
signal: AbortSignal.timeout(30000)
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(
|
||
`Could not prewarm the BattleScene module (${response.status}) at ${moduleUrl}.`
|
||
);
|
||
}
|
||
await response.arrayBuffer();
|
||
}
|
||
|
||
function delay(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|