Files
heros_web/scripts/verify-interaction-ux.mjs

1025 lines
44 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { spawn } from 'node:child_process';
import { chromium } from 'playwright';
import { desktopBrowserContextOptions, desktopBrowserViewport } from './desktop-browser-viewport.mjs';
const targetUrl = withDebugOptions(
process.env.VERIFY_INTERACTION_UX_URL ?? 'http://127.0.0.1:41783/'
);
const expectedBattleId = 'second-battle-yellow-turban-pursuit';
let serverProcess;
let browser;
try {
serverProcess = await ensureLocalServer(targetUrl);
browser = await chromium.launch({ headless: process.env.VERIFY_INTERACTION_UX_HEADLESS !== '0' });
const context = await browser.newContext(desktopBrowserContextOptions);
const page = await context.newPage();
page.setDefaultTimeout(30000);
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message));
let delayedBattleModuleRequests = 0;
await page.route('**/src/game/scenes/BattleScene.ts*', async (route) => {
delayedBattleModuleRequests += 1;
await delay(1200);
await route.continue();
});
await verifyCampModalAndNavigation(page);
await verifyBattlePointerFlow(page);
await verifyWolongNarrativeVictoryGate(page);
await verifyCampTimelineRowLayout(page);
if (pageErrors.length > 0) {
throw new Error(`Unexpected browser errors: ${JSON.stringify(pageErrors.slice(-5))}`);
}
if (delayedBattleModuleRequests !== 1) {
throw new Error(
`Expected one delayed BattleScene module request during single-flight navigation, received ${delayedBattleModuleRequests}.`
);
}
console.log(
`Verified pointer-based interaction UX at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height}: ` +
'camp and battle save modals block click-through, slow camp navigation is single-flight and commits after loading, ' +
'battle event overlays block edge-scroll and hover feedback, prioritized same-action notices collapse into one disclosed modal and battle log, ' +
'tactical reactions exclude undeployed or defeated officers, the Wolong narrative objectives gate victory, ' +
'long camp timeline titles and victory conditions stay in separate fixed-width columns, ' +
'movement commands stay anchored to the destination, the final ally prompt waits for the command, ' +
'the 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);
await verifyBattleEventQueueBehavior(page);
const lastAllySetup = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const units = scene?.debugBattleUnits?.() ?? [];
const allies = units.filter((unit) => unit.faction === 'ally' && unit.hp > 0);
const lastAlly = allies.find((unit) => scene.reachableTiles(unit).some((tile) => tile.x !== unit.x || tile.y !== unit.y));
if (!scene || !lastAlly) {
return null;
}
scene.hideBattleEventBanner();
scene.hideTurnEndPrompt();
scene.hideSaveSlotPanel();
scene.hideMapMenu();
scene.hideCommandMenu();
scene.clearMarkers();
scene.activeFaction = 'ally';
scene.phase = 'idle';
scene.selectedUnit = undefined;
scene.pendingMove = undefined;
scene.targetingAction = undefined;
scene.selectedUsable = undefined;
scene.actedUnitIds.clear();
allies.filter((unit) => unit.id !== lastAlly.id).forEach((unit) => scene.actedUnitIds.add(unit.id));
scene.centerCameraOnTile(lastAlly.x, lastAlly.y);
scene.renderSituationPanel();
const hitZone = scene.unitViews.get(lastAlly.id)?.hitZone;
const bounds = hitZone?.getBounds();
return bounds
? {
unitId: lastAlly.id,
unitTile: { x: lastAlly.x, y: lastAlly.y },
bounds: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
}
: null;
});
assert(lastAllySetup?.bounds, 'Expected a visible final allied unit hit area.');
await page.waitForTimeout(80);
await clickSceneBounds(page, 'BattleScene', lastAllySetup.bounds);
await page.waitForFunction((unitId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return battle?.selectedUnitId === unitId && battle?.phase === 'moving';
}, lastAllySetup.unitId);
const selectedBeforeMove = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
assert(
!selectedBeforeMove.turnPromptVisible,
`Selecting the final ally must not show the turn-end prompt: ${JSON.stringify(selectedBeforeMove)}`
);
const movementTarget = await page.evaluate((unitId) => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const unit = scene?.debugUnitById?.(unitId);
const destination = unit
? scene.reachableTiles(unit).find((tile) => tile.x !== unit.x || tile.y !== unit.y)
: undefined;
if (!scene || !destination) {
return null;
}
return {
tile: { x: destination.x, y: destination.y },
point: { x: scene.tileCenterX(destination.x), y: scene.tileCenterY(destination.y) },
farPoint: {
x: destination.x - scene.cameraTileX < scene.layout.visibleColumns / 2
? scene.layout.gridX + scene.layout.gridWidth - 12
: scene.layout.gridX + 12,
y: destination.y - scene.cameraTileY < scene.layout.visibleRows / 2
? scene.layout.gridY + scene.layout.gridHeight - 12
: scene.layout.gridY + 12
}
};
}, lastAllySetup.unitId);
assert(movementTarget?.point, 'Expected a reachable non-origin tile for the final ally.');
const movementScreenPoint = await sceneLogicalPoint(page, 'BattleScene', movementTarget.point);
const farScreenPoint = await sceneLogicalPoint(page, 'BattleScene', movementTarget.farPoint);
await page.mouse.click(movementScreenPoint.x, movementScreenPoint.y);
await page.mouse.move(farScreenPoint.x, farScreenPoint.y);
const duringMovement = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
assert(
!duringMovement?.turnPromptVisible && !duringMovement?.actedUnitIds?.includes(lastAllySetup.unitId),
`Movement of the final ally must not complete the action or show the prompt: ${JSON.stringify(duringMovement)}`
);
await page.waitForFunction((unitId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return battle?.phase === 'command' && battle?.pendingMove?.unitId === unitId && battle?.commandMenuVisible === true;
}, lastAllySetup.unitId);
const postMovement = await page.evaluate(({ unitId, destination }) => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const state = window.__HEROS_DEBUG__?.battle();
const activeObjects = (scene?.commandMenuObjects ?? []).filter((object) => object.active);
const objectBounds = activeObjects.map((object) => object.getBounds());
const left = Math.min(...objectBounds.map((bounds) => bounds.x));
const top = Math.min(...objectBounds.map((bounds) => bounds.y));
const right = Math.max(...objectBounds.map((bounds) => bounds.right));
const bottom = Math.max(...objectBounds.map((bounds) => bounds.bottom));
const destinationPoint = { x: scene.tileCenterX(destination.x), y: scene.tileCenterY(destination.y) };
const waitButton = scene.commandButtons.find((button) => button.command === 'wait')?.background;
const waitBounds = waitButton?.getBounds();
return {
state,
unitId,
menuBounds: { x: left, y: top, width: right - left, height: bottom - top },
destinationPoint,
waitBounds: waitBounds
? { x: waitBounds.x, y: waitBounds.y, width: waitBounds.width, height: waitBounds.height }
: null
};
}, { unitId: lastAllySetup.unitId, destination: movementTarget.tile });
assert(
!postMovement.state?.turnPromptVisible &&
postMovement.state?.phase === 'command' &&
!postMovement.state?.actedUnitIds?.includes(lastAllySetup.unitId),
`Arrival must keep command selection open before showing the turn-end prompt: ${JSON.stringify(postMovement.state)}`
);
const destinationMenuDistance = pointToBoundsDistance(postMovement.destinationPoint, postMovement.menuBounds);
assert(
destinationMenuDistance <= 48,
`The movement command menu drifted away from the clicked destination (${destinationMenuDistance.toFixed(1)}px): ${JSON.stringify(postMovement)}`
);
assert(postMovement.waitBounds, 'Expected the wait command button after movement.');
await clickSceneBounds(page, 'BattleScene', postMovement.waitBounds);
await page.waitForFunction((unitId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
battle?.turnPromptVisible === true &&
battle?.turnPromptMode === 'turn-end' &&
battle?.actedUnitIds?.includes(unitId)
);
}, lastAllySetup.unitId);
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 verifyBattleEventQueueBehavior(page) {
const initial = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
if (!scene) {
return null;
}
scene.hideBattleEventBanner();
scene.clearBattleEvents();
['qa-event-a', 'qa-event-b', 'qa-event-c', 'qa-event-d', 'qa-event-interrupted'].forEach((key) => scene.triggeredBattleEvents.delete(key));
scene.deferBattleEventPresentation = true;
scene.triggerBattleEvent('qa-event-a', '먼저 감지된 사건', ['같은 전투 흐름에서 먼저 표시됩니다.'], { playCue: false });
scene.triggerBattleEvent('qa-event-b', '낮은 우선순위', ['마지막에 표시되어야 합니다.'], { playCue: false, priority: 'low' });
scene.triggerBattleEvent('qa-event-c', '긴급 사건 하나', ['가장 높은 우선순위로 표시되어야 합니다.'], { playCue: false, priority: 'critical' });
scene.triggerBattleEvent('qa-event-d', '긴급 사건 둘', ['같은 우선순위의 발생 순서를 지켜야 합니다.'], { playCue: false, priority: 'critical' });
scene.deferBattleEventPresentation = false;
scene.showNextBattleEvent();
return {
active: scene.activeBattleEvent?.key ?? null,
queued: scene.battleEventQueue.map((event) => event.key),
completed: ['qa-event-a', 'qa-event-b', 'qa-event-c', 'qa-event-d'].filter((key) => scene.triggeredBattleEvents.has(key)),
objectCount: scene.battleEventObjects.length,
bannerText: scene.battleEventObjects
.map((object) => typeof object.text === 'string' ? object.text : '')
.filter(Boolean)
};
});
assert(
initial?.active === 'qa-event-c' &&
JSON.stringify(initial.queued) === JSON.stringify(['qa-event-d', 'qa-event-a', 'qa-event-b']) &&
initial.completed.length === 0 &&
initial.objectCount > 0 &&
initial.bannerText.some((text) => text.includes('외 3건')),
`Battle event grouping did not preserve priority/FIFO or disclose grouped notices: ${JSON.stringify(initial)}`
);
await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
scene?.hideBattleEventBanner();
});
await page.waitForFunction(() => (
window.__HEROS_GAME__?.scene.getScene('BattleScene')?.activeBattleEvent === undefined
));
const afterFirstDismissal = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
return scene
? {
active: scene.activeBattleEvent?.key ?? null,
queued: scene.battleEventQueue.map((event) => event.key),
completed: ['qa-event-a', 'qa-event-b', 'qa-event-c', 'qa-event-d'].filter((key) => scene.triggeredBattleEvents.has(key)),
objectCount: scene.battleEventObjects.length
}
: null;
});
assert(
afterFirstDismissal?.active === null &&
afterFirstDismissal.queued.length === 0 &&
afterFirstDismissal.completed.length === 4 &&
afterFirstDismissal.objectCount === 0,
`Battle event dismissal did not complete the grouped one-modal batch: ${JSON.stringify(afterFirstDismissal)}`
);
const cleanup = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
scene?.triggerBattleEvent('qa-event-interrupted', '중단될 사건', ['정리 시 완료 처리되면 안 됩니다.'], { playCue: false });
scene?.clearBattleEvents();
return scene
? {
active: scene.activeBattleEvent?.key ?? null,
queued: scene.battleEventQueue.map((event) => event.key),
objectCount: scene.battleEventObjects.length,
interruptedCompleted: scene.triggeredBattleEvents.has('qa-event-interrupted')
}
: null;
});
assert(
cleanup?.active === null &&
cleanup.queued.length === 0 &&
cleanup.objectCount === 0 &&
cleanup.interruptedCompleted === false,
`Battle event cleanup retained or falsely completed interrupted events: ${JSON.stringify(cleanup)}`
);
const reactionProbe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const units = scene?.debugBattleUnits?.() ?? [];
const liuBei = units.find((unit) => unit.id === 'liu-bei');
if (!scene || !liuBei) {
return null;
}
const livingReaction = scene.tacticalEventReaction('ally-danger');
const previousHp = liuBei.hp;
liuBei.hp = 0;
const defeatedReaction = scene.tacticalEventReaction('ally-danger');
liuBei.hp = previousHp;
return {
deployedIds: units.filter((unit) => unit.faction === 'ally').map((unit) => unit.id),
livingSpeaker: livingReaction?.unitId ?? null,
defeatedSpeaker: defeatedReaction?.unitId ?? null
};
});
assert(
reactionProbe?.livingSpeaker === 'liu-bei' &&
reactionProbe.defeatedSpeaker === null &&
!reactionProbe.deployedIds.includes('zhao-yun'),
`Tactical event reaction used an undeployed or defeated officer: ${JSON.stringify(reactionProbe)}`
);
}
async function verifyWolongNarrativeVictoryGate(page) {
const battleId = 'seventeenth-battle-wolong-visit-road';
await page.evaluate((nextBattleId) => window.__HEROS_DEBUG__?.goToBattle(nextBattleId), battleId);
await page.waitForFunction((expectedId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return battle?.battleId === expectedId && ['deployment', 'idle'].includes(battle.phase);
}, battleId, { timeout: 90000 });
await startDeploymentIfNeeded(page, battleId);
const probe = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const units = scene?.debugBattleUnits?.() ?? [];
const allies = units.filter((unit) => unit.faction === 'ally' && unit.hp > 0);
const enemies = units.filter((unit) => unit.faction === 'enemy');
const visitor = allies.find((unit) => unit.id === 'liu-bei') ?? allies[0];
if (!scene || !visitor || enemies.length === 0) {
return null;
}
scene.clearBattleEvents();
enemies.forEach((enemy) => {
enemy.hp = 0;
});
const blockedOutcome = scene.pendingBattleOutcome() ?? null;
const before = scene.requiredVictoryObjectiveStates().map((objective) => ({
id: objective.id,
category: objective.category,
achieved: objective.achieved
}));
const gateEventKnown = scene.isBattleEventKnown('victory-gate-pending');
visitor.x = 29;
visitor.y = 16;
const after = scene.requiredVictoryObjectiveStates().map((objective) => ({
id: objective.id,
category: objective.category,
achieved: objective.achieved
}));
const completedOutcome = scene.pendingBattleOutcome() ?? null;
return { blockedOutcome, completedOutcome, before, after, gateEventKnown };
});
assert(
probe?.blockedOutcome === null &&
probe.completedOutcome === 'victory' &&
probe.gateEventKnown === true &&
JSON.stringify(probe.before.map((objective) => objective.id)) === JSON.stringify(['scholar-rendezvous', 'longzhong-cottage']) &&
probe.before.every((objective) => objective.category === 'required' && objective.achieved === false) &&
probe.after.every((objective) => objective.category === 'required' && objective.achieved === true),
`Wolong battle ignored or misreported its narrative victory gate: ${JSON.stringify(probe)}`
);
}
async function verifyCampTimelineRowLayout(page) {
await page.evaluate(() => window.__HEROS_DEBUG__?.goToCamp());
await page.waitForFunction(() => {
const scenes = window.__HEROS_DEBUG__?.activeScenes() ?? [];
return scenes.includes('CampScene') && window.__HEROS_GAME__?.scene.getScene('CampScene')?.scene?.isActive();
}, undefined, { timeout: 90000 });
const rows = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('CampScene');
if (!scene) {
return null;
}
scene.hideSortiePrep(false);
scene.clearContent();
scene.renderProgressChapterDetail(
{
id: 'qa-long-timeline-fields',
title: '긴 연표 필드 검증',
period: '1920×1080',
description: '긴 전투명과 승리 조건이 서로 침범하지 않는지 확인합니다.',
battleIds: [
'seventeenth-battle-wolong-visit-road',
'fifty-fifth-battle-northern-qishan-road',
'fifty-seventh-battle-jieting-crisis',
'fifty-eighth-battle-qishan-retreat'
],
nextHints: []
},
0,
804,
262,
390,
310,
new Set()
);
const fields = scene.children.list
.filter((object) => object.type === 'Text' && object.active && object.getData('timelineField'))
.map((object) => {
const bounds = object.getBounds();
return {
field: object.getData('timelineField'),
text: object.text,
x: bounds.x,
y: bounds.y,
right: bounds.right,
width: bounds.width
};
});
const titles = fields.filter((field) => field.field === 'battle-title').sort((left, right) => left.y - right.y);
const objectives = fields.filter((field) => field.field === 'victory-condition').sort((left, right) => left.y - right.y);
return titles.map((title, index) => ({ title, objective: objectives[index] ?? null }));
});
assert(
rows?.length === 4 &&
rows.every(({ title, objective }) => (
objective &&
title.right < objective.x &&
title.width > 0 &&
objective.width > 0 &&
objective.text.endsWith('…')
)),
`Camp timeline detail fields overlap or fail to truncate at 1920x1080: ${JSON.stringify(rows)}`
);
}
async function startDeploymentIfNeeded(page, battleId) {
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
if (state?.phase === 'idle') {
return;
}
assert(
state?.battleId === battleId && state?.phase === 'deployment' && state?.battleHud?.deploymentPanel?.startButtonBounds,
`Expected ${battleId} deployment controls: ${JSON.stringify(state)}`
);
await clickSceneBounds(page, 'BattleScene', state.battleHud.deploymentPanel.startButtonBounds);
await page.waitForFunction((expectedId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return battle?.battleId === expectedId && battle?.phase === 'idle';
}, battleId, { timeout: 30000 });
await page.waitForTimeout(100);
}
async function clickTurnPromptSecondary(page) {
const bounds = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const buttons = (scene?.turnPromptObjects ?? [])
.filter((object) => (
object.type === 'Rectangle' &&
object.active &&
object.input?.enabled &&
object.displayWidth < 500 &&
object.displayHeight < 100
))
.map((object) => object.getBounds())
.sort((left, right) => right.centerX - left.centerX);
const button = buttons[0];
return button ? { x: button.x, y: button.y, width: button.width, height: button.height } : null;
});
assert(bounds, 'Expected the battlefield-review button in the turn-end prompt.');
await clickSceneBounds(page, 'BattleScene', bounds);
}
async function findRightmostEmptyBattleTile(page) {
const logicalPoint = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
if (!scene) {
return null;
}
for (let column = scene.layout.visibleColumns - 1; column >= 0; column -= 1) {
for (let row = 0; row < scene.layout.visibleRows; row += 1) {
const x = scene.cameraTileX + column;
const y = scene.cameraTileY + row;
if (scene.isInBounds(x, y) && !scene.isOccupied(x, y)) {
return { x: scene.tileCenterX(x), y: scene.tileCenterY(y) };
}
}
}
return null;
});
assert(logicalPoint, 'Expected a visible empty battlefield tile for the right-click menu.');
return sceneLogicalPoint(page, 'BattleScene', logicalPoint);
}
async function waitForDebugApi(page) {
await page.waitForFunction(() => (
document.querySelector('canvas') !== null &&
window.__HEROS_GAME__ !== undefined &&
window.__HEROS_DEBUG__ !== undefined
), undefined, { timeout: 90000 });
}
async function assertDesktopViewport(page) {
const viewport = await page.evaluate(() => ({
width: window.innerWidth,
height: window.innerHeight,
dpr: window.devicePixelRatio,
canvas: (() => {
const canvas = document.querySelector('canvas');
return canvas ? { width: canvas.width, height: canvas.height } : null;
})()
}));
assert(
viewport.width === desktopBrowserViewport.width &&
viewport.height === desktopBrowserViewport.height &&
viewport.dpr === 1 &&
viewport.canvas?.width === desktopBrowserViewport.width &&
viewport.canvas?.height === desktopBrowserViewport.height,
`Expected the required 1920x1080 CSS viewport at 100% zoom: ${JSON.stringify(viewport)}`
);
}
async function clickSceneBounds(page, sceneKey, bounds, options) {
const point = await sceneBoundsCenter(page, sceneKey, bounds);
await page.mouse.click(point.x, point.y, options);
return point;
}
async function sceneBoundsCenter(page, sceneKey, bounds) {
return sceneLogicalPoint(page, sceneKey, {
x: bounds.x + bounds.width / 2,
y: bounds.y + bounds.height / 2
});
}
async function sceneLogicalPoint(page, sceneKey, logicalPoint) {
const point = await page.evaluate(({ requestedSceneKey, requestedPoint }) => {
const scene = window.__HEROS_GAME__?.scene.getScene(requestedSceneKey);
const canvas = document.querySelector('canvas');
const canvasBounds = canvas?.getBoundingClientRect();
if (!scene || !canvasBounds || !requestedPoint) {
return null;
}
return {
x: canvasBounds.left + requestedPoint.x * canvasBounds.width / scene.scale.width,
y: canvasBounds.top + requestedPoint.y * canvasBounds.height / scene.scale.height
};
}, { requestedSceneKey: sceneKey, requestedPoint: logicalPoint });
assert(point && Number.isFinite(point.x) && Number.isFinite(point.y), `Expected a valid ${sceneKey} pointer coordinate.`);
return point;
}
function pointToBoundsDistance(point, bounds) {
const dx = Math.max(bounds.x - point.x, 0, point.x - (bounds.x + bounds.width));
const dy = Math.max(bounds.y - point.y, 0, point.y - (bounds.y + bounds.height));
return Math.hypot(dx, dy);
}
function sameStrings(left, right) {
return (
Array.isArray(left) &&
Array.isArray(right) &&
left.length === right.length &&
left.every((value, index) => value === right[index])
);
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
async function ensureLocalServer(url) {
if (await canReach(url)) {
return undefined;
}
const parsed = new URL(url);
if (!['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname)) {
throw new Error(`No server responded at ${url}`);
}
const stderr = [];
const child = spawn(
process.execPath,
['node_modules/vite/bin/vite.js', '--host', '127.0.0.1', '--port', parsed.port || '41783'],
{ cwd: process.cwd(), env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }
);
child.stderr.on('data', (chunk) => stderr.push(chunk.toString()));
child.stdout.on('data', () => {});
for (let attempt = 0; attempt < 120; attempt += 1) {
if (await canReach(url)) {
return child;
}
await delay(250);
}
child.kill();
throw new Error(`Vite server did not start at ${url}\n${stderr.join('')}`);
}
async function canReach(url) {
try {
const response = await fetch(url, { signal: AbortSignal.timeout(1000) });
return response.ok;
} catch {
return false;
}
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}