fix: harden turn flow interaction UX

This commit is contained in:
2026-07-20 23:10:52 +09:00
parent 3961282d82
commit be5fe3e880
7 changed files with 1187 additions and 90 deletions

View File

@@ -0,0 +1,795 @@
import { spawn } from 'node:child_process';
import { chromium } from 'playwright';
import { desktopBrowserContextOptions, desktopBrowserViewport } from './desktop-browser-viewport.mjs';
const targetUrl = withDebugOptions(
process.env.VERIFY_INTERACTION_UX_URL ?? 'http://127.0.0.1:41783/'
);
const expectedBattleId = 'second-battle-yellow-turban-pursuit';
let serverProcess;
let browser;
try {
serverProcess = await ensureLocalServer(targetUrl);
browser = await chromium.launch({ headless: process.env.VERIFY_INTERACTION_UX_HEADLESS !== '0' });
const context = await browser.newContext(desktopBrowserContextOptions);
const page = await context.newPage();
page.setDefaultTimeout(30000);
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message));
let delayedBattleModuleRequests = 0;
await page.route('**/src/game/scenes/BattleScene.ts*', async (route) => {
delayedBattleModuleRequests += 1;
await delay(1200);
await route.continue();
});
await verifyCampModalAndNavigation(page);
await verifyBattlePointerFlow(page);
if (pageErrors.length > 0) {
throw new Error(`Unexpected browser errors: ${JSON.stringify(pageErrors.slice(-5))}`);
}
if (delayedBattleModuleRequests !== 1) {
throw new Error(
`Expected one delayed BattleScene module request during single-flight navigation, received ${delayedBattleModuleRequests}.`
);
}
console.log(
`Verified pointer-based interaction UX at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}: ` +
'camp and battle save modals block click-through, slow camp navigation is single-flight and commits after loading, ' +
'battle event overlays block edge-scroll and hover feedback, ' +
'movement commands stay anchored to the destination, the final ally prompt waits for the command, ' +
'the persistent turn-end action reopens it, and the right-click menu follows the pointer.'
);
} finally {
await browser?.close();
if (serverProcess && !serverProcess.killed) {
serverProcess.kill();
}
}
function withDebugOptions(url) {
const parsed = new URL(url);
parsed.searchParams.set('debug', '1');
parsed.searchParams.set('renderer', 'canvas');
parsed.searchParams.set('debugCombatAssetWatchdogMs', '250');
return parsed.toString();
}
async function verifyCampModalAndNavigation(page) {
const campUrl = new URL(targetUrl);
campUrl.searchParams.set('debugSortiePrep', '1');
await page.goto(campUrl.toString(), { waitUntil: 'domcontentloaded', timeout: 90000 });
await page.evaluate(() => window.localStorage.clear());
await page.reload({ waitUntil: 'domcontentloaded', timeout: 90000 });
await waitForDebugApi(page);
await page.waitForFunction(() => {
const scenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
const camp = window.__HEROS_DEBUG__?.camp();
return scenes.includes('CampScene') && camp?.scene === 'CampScene' && camp?.sortieVisible === true;
}, undefined, { timeout: 90000 });
await assertDesktopViewport(page);
const saveButtonBounds = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const candidates = (scene?.children.list ?? [])
.filter((object) => (
object.type === 'Text' &&
object.text === '저장' &&
object.active &&
object.visible &&
object.input?.enabled
))
.map((object) => ({ depth: object.depth, bounds: object.getBounds() }))
.sort((left, right) => right.depth - left.depth || right.bounds.y - left.bounds.y);
const bounds = candidates[0]?.bounds;
return bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null;
});
assert(saveButtonBounds, 'Expected a visible camp save button in sortie preparation.');
const campBeforeModal = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
await clickSceneBounds(page, 'CampScene', saveButtonBounds);
await page.waitForFunction(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
return (scene?.saveSlotObjects?.length ?? 0) > 0;
});
const campSaveModal = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const rectangles = (scene?.saveSlotObjects ?? [])
.filter((object) => object.type === 'Rectangle' && object.active && object.input?.enabled)
.map((object) => {
const bounds = object.getBounds();
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, depth: object.depth };
});
const blocker = rectangles.find((bounds) => (
bounds.width >= scene.scale.width * 0.98 && bounds.height >= scene.scale.height * 0.98
));
return {
blocker: blocker ?? null,
objectCount: scene?.saveSlotObjects?.length ?? 0
};
});
assert(
campSaveModal.blocker,
`Expected the camp save panel to include a full-screen interactive blocker: ${JSON.stringify(campSaveModal)}`
);
const coveredCampAction = campBeforeModal?.sortiePrimaryAction;
assert(coveredCampAction?.bounds, 'Expected the covered sortie primary action for camp click-through QA.');
await clickSceneBounds(page, 'CampScene', coveredCampAction.bounds);
await page.waitForTimeout(120);
const campAfterCoveredClick = await page.evaluate(() => ({
state: window.__HEROS_DEBUG__?.camp(),
savePanelObjectCount: window.__HEROS_GAME__?.scene.getScene('CampScene')?.saveSlotObjects?.length ?? 0
}));
assert(
campAfterCoveredClick.state?.sortiePrepStep === campBeforeModal?.sortiePrepStep,
`Camp save modal click-through changed the underlying sortie step: ${JSON.stringify({
before: campBeforeModal?.sortiePrepStep,
after: campAfterCoveredClick.state?.sortiePrepStep,
savePanelObjectCount: campAfterCoveredClick.savePanelObjectCount
})}`
);
const launchAction = await prepareCampLaunch(page);
const navigationBaseline = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const camp = window.__HEROS_DEBUG__?.camp();
window.__HEROS_INTERACTION_UX__ = { battleSceneStarts: 0 };
const originalStart = scene.scene.start;
scene.scene.start = function (key, data) {
if (key === 'BattleScene') {
window.__HEROS_INTERACTION_UX__.battleSceneStarts += 1;
}
return originalStart.call(this, key, data);
};
return {
campaignStep: camp?.campaign?.step ?? null,
sortiePrepStep: camp?.sortiePrepStep ?? null,
selectedSortieUnitIds: [...(camp?.selectedSortieUnitIds ?? [])]
};
});
const launchPoint = await sceneBoundsCenter(page, 'CampScene', launchAction.bounds);
await page.mouse.click(launchPoint.x, launchPoint.y);
await page.waitForFunction(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
return scene?.navigationPending === true && (scene?.navigationBlockerObjects?.length ?? 0) > 0;
}, undefined, { timeout: 3000 });
const navigationPendingState = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const camp = window.__HEROS_DEBUG__?.camp();
const blocker = (scene?.navigationBlockerObjects ?? [])
.filter((object) => object.type === 'Rectangle' && object.input?.enabled)
.map((object) => object.getBounds())
.find((bounds) => bounds.width >= scene.scale.width * 0.98 && bounds.height >= scene.scale.height * 0.98);
return {
pending: scene?.navigationPending ?? false,
blocker: blocker ? { x: blocker.x, y: blocker.y, width: blocker.width, height: blocker.height } : null,
campaignStep: camp?.campaign?.step ?? null,
sortieVisible: camp?.sortieVisible ?? false,
selectedSortieUnitIds: [...(camp?.selectedSortieUnitIds ?? [])]
};
});
assert(
navigationPendingState.blocker && navigationPendingState.campaignStep === navigationBaseline.campaignStep,
`Camp navigation must block input and defer the campaign-step commit until loading succeeds: ${JSON.stringify({
baseline: navigationBaseline,
pending: navigationPendingState
})}`
);
await page.keyboard.press('Escape');
await page.mouse.click(launchPoint.x, launchPoint.y);
const coveredTabBounds = (await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.campTabs?.find((tab) => tab.id === 'dialogue')?.bounds)) ?? null;
if (coveredTabBounds) {
await clickSceneBounds(page, 'CampScene', coveredTabBounds);
}
await page.waitForTimeout(120);
const navigationAfterRepeatedInput = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
const camp = window.__HEROS_DEBUG__?.camp();
return {
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? [],
pending: scene?.navigationPending ?? false,
blockerCount: scene?.navigationBlockerObjects?.length ?? 0,
campaignStep: camp?.campaign?.step ?? null,
sortiePrepStep: camp?.sortiePrepStep ?? null,
selectedSortieUnitIds: [...(camp?.selectedSortieUnitIds ?? [])],
battleSceneStarts: window.__HEROS_INTERACTION_UX__?.battleSceneStarts ?? 0
};
});
assert(
navigationAfterRepeatedInput.activeScenes.includes('CampScene') &&
navigationAfterRepeatedInput.pending &&
navigationAfterRepeatedInput.blockerCount > 0 &&
navigationAfterRepeatedInput.campaignStep === navigationBaseline.campaignStep &&
navigationAfterRepeatedInput.sortiePrepStep === navigationBaseline.sortiePrepStep &&
sameStrings(navigationAfterRepeatedInput.selectedSortieUnitIds, navigationBaseline.selectedSortieUnitIds) &&
navigationAfterRepeatedInput.battleSceneStarts === 0,
`Repeated input changed camp state during lazy navigation: ${JSON.stringify({
baseline: navigationBaseline,
after: navigationAfterRepeatedInput
})}`
);
await page.waitForFunction(() => {
const scenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
const battle = window.__HEROS_DEBUG__?.battle();
return scenes.includes('BattleScene') && battle?.scene === 'BattleScene';
}, undefined, { timeout: 90000 });
const navigationCompleteState = await page.evaluate(() => ({
battleSceneStarts: window.__HEROS_INTERACTION_UX__?.battleSceneStarts ?? 0,
activeScenes: window.__HEROS_DEBUG__?.activeScenes() ?? []
}));
assert(
navigationCompleteState.battleSceneStarts === 1 && !navigationCompleteState.activeScenes.includes('CampScene'),
`Camp navigation must start BattleScene exactly once: ${JSON.stringify(navigationCompleteState)}`
);
}
async function prepareCampLaunch(page) {
for (let attempt = 0; attempt < 8; attempt += 1) {
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
assert(state?.sortieVisible, `Expected sortie preparation while finding launch action: ${JSON.stringify(state)}`);
if (state.sortiePrepStep === 'formation' && state.sortieOperationOrder?.required && !state.sortieOperationOrder.complete) {
if (!state.sortieOperationOrder.open) {
assert(state.sortieOperationOrder.toggleButtonBounds, 'Expected the operation-order picker button.');
await clickSceneBounds(page, 'CampScene', state.sortieOperationOrder.toggleButtonBounds);
await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder?.open === true);
}
const orderState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder);
const order = orderState?.cards?.find((card) => card.cardBounds);
assert(order?.cardBounds, `Expected a selectable operation order: ${JSON.stringify(orderState)}`);
await clickSceneBounds(page, 'CampScene', order.cardBounds);
await page.waitForFunction((orderId) => (
window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder?.selectedId === orderId
), order.id);
const selectedOrderState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder);
if (selectedOrderState?.open && selectedOrderState.closeButtonBounds) {
await clickSceneBounds(page, 'CampScene', selectedOrderState.closeButtonBounds);
await page.waitForFunction(() => window.__HEROS_DEBUG__?.camp()?.sortieOperationOrder?.open === false);
}
continue;
}
const action = state.sortiePrimaryAction;
assert(action?.bounds && action.interactive, `Expected an interactive sortie primary action: ${JSON.stringify(action)}`);
if (action.kind === 'launch') {
return action;
}
const previousStep = state.sortiePrepStep;
await clickSceneBounds(page, 'CampScene', action.bounds);
await page.waitForFunction((step) => window.__HEROS_DEBUG__?.camp()?.sortiePrepStep !== step, previousStep);
}
throw new Error('Could not reach the sortie launch action within the expected three-step flow.');
}
async function verifyBattlePointerFlow(page) {
await page.evaluate((battleId) => window.__HEROS_DEBUG__?.goToBattle(battleId), expectedBattleId);
await page.waitForFunction((battleId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
battle?.battleId === battleId &&
['deployment', 'idle'].includes(battle.phase) &&
battle.mapBackgroundReady === true &&
battle.combatAssets?.status !== 'loading'
);
}, expectedBattleId, { timeout: 90000 });
await startDeploymentIfNeeded(page, expectedBattleId);
await verifyBattleEventOverlayInputBlock(page);
const lastAllySetup = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const units = scene?.debugBattleUnits?.() ?? [];
const allies = units.filter((unit) => unit.faction === 'ally' && unit.hp > 0);
const lastAlly = allies.find((unit) => scene.reachableTiles(unit).some((tile) => tile.x !== unit.x || tile.y !== unit.y));
if (!scene || !lastAlly) {
return null;
}
scene.hideBattleEventBanner();
scene.hideTurnEndPrompt();
scene.hideSaveSlotPanel();
scene.hideMapMenu();
scene.hideCommandMenu();
scene.clearMarkers();
scene.activeFaction = 'ally';
scene.phase = 'idle';
scene.selectedUnit = undefined;
scene.pendingMove = undefined;
scene.targetingAction = undefined;
scene.selectedUsable = undefined;
scene.actedUnitIds.clear();
allies.filter((unit) => unit.id !== lastAlly.id).forEach((unit) => scene.actedUnitIds.add(unit.id));
scene.centerCameraOnTile(lastAlly.x, lastAlly.y);
scene.renderSituationPanel();
const hitZone = scene.unitViews.get(lastAlly.id)?.hitZone;
const bounds = hitZone?.getBounds();
return bounds
? {
unitId: lastAlly.id,
unitTile: { x: lastAlly.x, y: lastAlly.y },
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
}
: null;
});
assert(lastAllySetup?.bounds, 'Expected a visible final allied unit hit area.');
await page.waitForTimeout(80);
await clickSceneBounds(page, 'BattleScene', lastAllySetup.bounds);
await page.waitForFunction((unitId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return battle?.selectedUnitId === unitId && battle?.phase === 'moving';
}, lastAllySetup.unitId);
const selectedBeforeMove = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
assert(
!selectedBeforeMove.turnPromptVisible,
`Selecting the final ally must not show the turn-end prompt: ${JSON.stringify(selectedBeforeMove)}`
);
const movementTarget = await page.evaluate((unitId) => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const unit = scene?.debugUnitById?.(unitId);
const destination = unit
? scene.reachableTiles(unit).find((tile) => tile.x !== unit.x || tile.y !== unit.y)
: undefined;
if (!scene || !destination) {
return null;
}
return {
tile: { x: destination.x, y: destination.y },
point: { x: scene.tileCenterX(destination.x), y: scene.tileCenterY(destination.y) },
farPoint: {
x: destination.x - scene.cameraTileX < scene.layout.visibleColumns / 2
? scene.layout.gridX + scene.layout.gridWidth - 12
: scene.layout.gridX + 12,
y: destination.y - scene.cameraTileY < scene.layout.visibleRows / 2
? scene.layout.gridY + scene.layout.gridHeight - 12
: scene.layout.gridY + 12
}
};
}, lastAllySetup.unitId);
assert(movementTarget?.point, 'Expected a reachable non-origin tile for the final ally.');
const movementScreenPoint = await sceneLogicalPoint(page, 'BattleScene', movementTarget.point);
const farScreenPoint = await sceneLogicalPoint(page, 'BattleScene', movementTarget.farPoint);
await page.mouse.click(movementScreenPoint.x, movementScreenPoint.y);
await page.mouse.move(farScreenPoint.x, farScreenPoint.y);
const duringMovement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
assert(
!duringMovement?.turnPromptVisible && !duringMovement?.actedUnitIds?.includes(lastAllySetup.unitId),
`Movement of the final ally must not complete the action or show the prompt: ${JSON.stringify(duringMovement)}`
);
await page.waitForFunction((unitId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return battle?.phase === 'command' && battle?.pendingMove?.unitId === unitId && battle?.commandMenuVisible === true;
}, lastAllySetup.unitId);
const postMovement = await page.evaluate(({ unitId, destination }) => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const state = window.__HEROS_DEBUG__?.battle();
const activeObjects = (scene?.commandMenuObjects ?? []).filter((object) => object.active);
const objectBounds = activeObjects.map((object) => object.getBounds());
const left = Math.min(...objectBounds.map((bounds) => bounds.x));
const top = Math.min(...objectBounds.map((bounds) => bounds.y));
const right = Math.max(...objectBounds.map((bounds) => bounds.right));
const bottom = Math.max(...objectBounds.map((bounds) => bounds.bottom));
const destinationPoint = { x: scene.tileCenterX(destination.x), y: scene.tileCenterY(destination.y) };
const waitButton = scene.commandButtons.find((button) => button.command === 'wait')?.background;
const waitBounds = waitButton?.getBounds();
return {
state,
unitId,
menuBounds: { x: left, y: top, width: right - left, height: bottom - top },
destinationPoint,
waitBounds: waitBounds
? { x: waitBounds.x, y: waitBounds.y, width: waitBounds.width, height: waitBounds.height }
: null
};
}, { unitId: lastAllySetup.unitId, destination: movementTarget.tile });
assert(
!postMovement.state?.turnPromptVisible &&
postMovement.state?.phase === 'command' &&
!postMovement.state?.actedUnitIds?.includes(lastAllySetup.unitId),
`Arrival must keep command selection open before showing the turn-end prompt: ${JSON.stringify(postMovement.state)}`
);
const destinationMenuDistance = pointToBoundsDistance(postMovement.destinationPoint, postMovement.menuBounds);
assert(
destinationMenuDistance <= 48,
`The movement command menu drifted away from the clicked destination (${destinationMenuDistance.toFixed(1)}px): ${JSON.stringify(postMovement)}`
);
assert(postMovement.waitBounds, 'Expected the wait command button after movement.');
await clickSceneBounds(page, 'BattleScene', postMovement.waitBounds);
await page.waitForFunction((unitId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
battle?.turnPromptVisible === true &&
battle?.turnPromptMode === 'turn-end' &&
battle?.actedUnitIds?.includes(unitId)
);
}, lastAllySetup.unitId);
await clickTurnPromptSecondary(page);
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false);
const persistentTurnEndBounds = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const text = (scene?.sidePanelObjects ?? []).find((object) => (
object.type === 'Text' && object.active && object.visible && object.input?.enabled && object.text === '턴 종료'
));
const bounds = text?.getBounds();
return bounds ? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height } : null;
});
assert(persistentTurnEndBounds, 'Expected a persistent turn-end action after choosing battlefield review.');
await clickSceneBounds(page, 'BattleScene', persistentTurnEndBounds);
await page.waitForFunction(() => (
window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === true &&
window.__HEROS_DEBUG__?.battle()?.turnPromptMode === 'turn-end'
));
await clickTurnPromptSecondary(page);
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false);
const mapMenuPointer = await findRightmostEmptyBattleTile(page);
await page.mouse.click(mapMenuPointer.x, mapMenuPointer.y, { button: 'right' });
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.mapMenuVisible === true);
const mapMenu = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
return scene?.mapMenuLayout ? { ...scene.mapMenuLayout, actions: [...scene.mapMenuLayout.actions] } : null;
});
assert(mapMenu, 'Expected the right-click battlefield menu.');
const horizontalPointerGap = mapMenuPointer.x < mapMenu.left
? mapMenu.left - mapMenuPointer.x
: mapMenuPointer.x > mapMenu.left + mapMenu.width
? mapMenuPointer.x - (mapMenu.left + mapMenu.width)
: 0;
assert(
horizontalPointerGap <= 24,
`The right-click menu did not open next to the pointer (${horizontalPointerGap.toFixed(1)}px): ${JSON.stringify({ mapMenuPointer, mapMenu })}`
);
const saveActionIndex = mapMenu.actions.indexOf('save');
assert(saveActionIndex >= 0, `Expected a save action in the map menu: ${JSON.stringify(mapMenu)}`);
await clickSceneBounds(page, 'BattleScene', {
x: mapMenu.left,
y: mapMenu.top + mapMenu.padding + saveActionIndex * mapMenu.buttonHeight,
width: mapMenu.width,
height: mapMenu.buttonHeight
});
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.saveSlotPanelMode === 'save');
const battleModalProbe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const rectangles = (scene?.saveSlotPanelObjects ?? [])
.filter((object) => object.type === 'Rectangle' && object.active && object.input?.enabled)
.map((object) => {
const bounds = object.getBounds();
return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, depth: object.depth };
});
const blocker = rectangles.find((bounds) => (
bounds.width >= scene.scale.width * 0.98 && bounds.height >= scene.scale.height * 0.98
));
const panel = rectangles
.filter((bounds) => bounds.width < scene.scale.width * 0.9 && bounds.height > 200)
.sort((left, right) => right.width * right.height - left.width * left.height)[0];
const tryCamera = (x, y) => {
scene.setCameraTilePosition(x, y);
return (scene.debugBattleUnits?.() ?? [])
.filter((unit) => unit.faction === 'ally' && unit.hp > 0)
.map((unit) => ({ unit, zone: scene.unitViews.get(unit.id)?.hitZone }))
.find(({ zone }) => {
if (!zone?.visible || !zone.input?.enabled) {
return false;
}
const bounds = zone.getBounds();
const point = { x: bounds.centerX, y: bounds.centerY };
return !panel || !pointInsideBounds(point, panel);
});
};
const candidate = tryCamera(0, 0) ?? tryCamera(scene.maxCameraTileX(), scene.maxCameraTileY());
const candidateBounds = candidate?.zone?.getBounds();
const persistentTurnEndAction = (scene?.sidePanelObjects ?? []).find((object) => (
object.type === 'Text' &&
object.active &&
object.visible &&
object.input?.enabled &&
object.text === '턴 종료'
));
const persistentActionBounds = persistentTurnEndAction?.getBounds();
const coveredBounds = candidateBounds ?? persistentActionBounds;
return {
blocker: blocker ?? null,
panel: panel ?? null,
unitId: candidate?.unit.id ?? null,
targetKind: candidateBounds ? 'unit' : persistentActionBounds ? 'turn-end-action' : null,
coveredBounds: coveredBounds
? { x: coveredBounds.x, y: coveredBounds.y, width: coveredBounds.width, height: coveredBounds.height }
: null,
selectedUnitId: scene.selectedUnit?.id ?? null
};
function pointInsideBounds(point, bounds) {
return (
point.x >= bounds.x && point.x <= bounds.x + bounds.width &&
point.y >= bounds.y && point.y <= bounds.y + bounds.height
);
}
});
assert(
battleModalProbe.blocker && battleModalProbe.coveredBounds,
`Expected a full-screen battle save blocker and an interactive control behind it: ${JSON.stringify(battleModalProbe)}`
);
await clickSceneBounds(page, 'BattleScene', battleModalProbe.coveredBounds);
await page.waitForTimeout(120);
const battleAfterCoveredClick = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
assert(
battleAfterCoveredClick?.saveSlotPanelMode === 'save' &&
battleAfterCoveredClick?.selectedUnitId === battleModalProbe.selectedUnitId &&
battleAfterCoveredClick?.turnPromptVisible === false,
`Battle save modal allowed an underlying control click: ${JSON.stringify({
before: battleModalProbe,
after: {
saveSlotPanelMode: battleAfterCoveredClick?.saveSlotPanelMode,
selectedUnitId: battleAfterCoveredClick?.selectedUnitId,
turnPromptVisible: battleAfterCoveredClick?.turnPromptVisible
}
})}`
);
}
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 startDeploymentIfNeeded(page, battleId) {
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
if (state?.phase === 'idle') {
return;
}
assert(
state?.battleId === battleId && state?.phase === 'deployment' && state?.battleHud?.deploymentPanel?.startButtonBounds,
`Expected ${battleId} deployment controls: ${JSON.stringify(state)}`
);
await clickSceneBounds(page, 'BattleScene', state.battleHud.deploymentPanel.startButtonBounds);
await page.waitForFunction((expectedId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return battle?.battleId === expectedId && battle?.phase === 'idle';
}, battleId, { timeout: 30000 });
await page.waitForTimeout(100);
}
async function clickTurnPromptSecondary(page) {
const bounds = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const buttons = (scene?.turnPromptObjects ?? [])
.filter((object) => (
object.type === 'Rectangle' &&
object.active &&
object.input?.enabled &&
object.displayWidth < 500 &&
object.displayHeight < 100
))
.map((object) => object.getBounds())
.sort((left, right) => right.centerX - left.centerX);
const button = buttons[0];
return button ? { x: button.x, y: button.y, width: button.width, height: button.height } : null;
});
assert(bounds, 'Expected the battlefield-review button in the turn-end prompt.');
await clickSceneBounds(page, 'BattleScene', bounds);
}
async function findRightmostEmptyBattleTile(page) {
const logicalPoint = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
if (!scene) {
return null;
}
for (let column = scene.layout.visibleColumns - 1; column >= 0; column -= 1) {
for (let row = 0; row < scene.layout.visibleRows; row += 1) {
const x = scene.cameraTileX + column;
const y = scene.cameraTileY + row;
if (scene.isInBounds(x, y) && !scene.isOccupied(x, y)) {
return { x: scene.tileCenterX(x), y: scene.tileCenterY(y) };
}
}
}
return null;
});
assert(logicalPoint, 'Expected a visible empty battlefield tile for the right-click menu.');
return sceneLogicalPoint(page, 'BattleScene', logicalPoint);
}
async function waitForDebugApi(page) {
await page.waitForFunction(() => (
document.querySelector('canvas') !== null &&
window.__HEROS_GAME__ !== undefined &&
window.__HEROS_DEBUG__ !== undefined
), undefined, { timeout: 90000 });
}
async function assertDesktopViewport(page) {
const viewport = await page.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight,
dpr: window.devicePixelRatio,
canvas: (() => {
const canvas = document.querySelector('canvas');
return canvas ? { width: canvas.width, height: canvas.height } : null;
})()
}));
assert(
viewport.width === desktopBrowserViewport.width &&
viewport.height === desktopBrowserViewport.height &&
viewport.dpr === 1 &&
viewport.canvas?.width === desktopBrowserViewport.width &&
viewport.canvas?.height === desktopBrowserViewport.height,
`Expected the required 1920x1080 CSS viewport at 100% zoom: ${JSON.stringify(viewport)}`
);
}
async function clickSceneBounds(page, sceneKey, bounds, options) {
const point = await sceneBoundsCenter(page, sceneKey, bounds);
await page.mouse.click(point.x, point.y, options);
return point;
}
async function sceneBoundsCenter(page, sceneKey, bounds) {
return sceneLogicalPoint(page, sceneKey, {
x: bounds.x + bounds.width / 2,
y: bounds.y + bounds.height / 2
});
}
async function sceneLogicalPoint(page, sceneKey, logicalPoint) {
const point = await page.evaluate(({ requestedSceneKey, requestedPoint }) => {
const scene = window.__HEROS_GAME__?.scene.getScene(requestedSceneKey);
const canvas = document.querySelector('canvas');
const canvasBounds = canvas?.getBoundingClientRect();
if (!scene || !canvasBounds || !requestedPoint) {
return null;
}
return {
x: canvasBounds.left + requestedPoint.x * canvasBounds.width / scene.scale.width,
y: canvasBounds.top + requestedPoint.y * canvasBounds.height / scene.scale.height
};
}, { requestedSceneKey: sceneKey, requestedPoint: logicalPoint });
assert(point && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected a valid ${sceneKey} pointer coordinate.`);
return point;
}
function pointToBoundsDistance(point, bounds) {
const dx = Math.max(bounds.x - point.x, 0, point.x - (bounds.x + bounds.width));
const dy = Math.max(bounds.y - point.y, 0, point.y - (bounds.y + bounds.height));
return Math.hypot(dx, dy);
}
function sameStrings(left, right) {
return (
Array.isArray(left) &&
Array.isArray(right) &&
left.length === right.length &&
left.every((value, index) => value === right[index])
);
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function ensureLocalServer(url) {
if (await canReach(url)) {
return undefined;
}
const parsed = new URL(url);
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname)) {
throw new Error(`No server responded at ${url}`);
}
const stderr = [];
const child = spawn(
process.execPath,
['node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', parsed.port || '41783'],
{ cwd: process.cwd(), env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }
);
child.stderr.on('data', (chunk) => stderr.push(chunk.toString()));
child.stdout.on('data', () => {});
for (let attempt = 0; attempt < 120; attempt += 1) {
if (await canReach(url)) {
return child;
}
await delay(250);
}
child.kill();
throw new Error(`Vite server did not start at ${url}\n${stderr.join('')}`);
}
async function canReach(url) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
return response.ok;
} catch {
return false;
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}