982 lines
31 KiB
JavaScript
982 lines
31 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 battleId = 'first-battle-zhuo-commandery';
|
|
const renderer = process.env.VERIFY_BATTLE_KEYBOARD_RENDERER;
|
|
if (!renderer) {
|
|
for (const requestedRenderer of ['canvas', 'webgl']) {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[fileURLToPath(import.meta.url)],
|
|
{
|
|
cwd: process.cwd(),
|
|
env: {
|
|
...process.env,
|
|
VERIFY_BATTLE_KEYBOARD_RENDERER: requestedRenderer
|
|
},
|
|
stdio: 'inherit'
|
|
}
|
|
);
|
|
if (result.status !== 0) {
|
|
process.exit(result.status ?? 1);
|
|
}
|
|
}
|
|
process.exit(0);
|
|
}
|
|
assert(
|
|
renderer === 'canvas' || renderer === 'webgl',
|
|
`Unsupported renderer: ${renderer}`
|
|
);
|
|
const baseUrl =
|
|
process.env.VERIFY_BATTLE_KEYBOARD_URL ??
|
|
'http://127.0.0.1:41796/heros_web/';
|
|
const targetUrl = withDebugOptions(baseUrl, renderer);
|
|
|
|
let serverProcess;
|
|
let browser;
|
|
|
|
try {
|
|
serverProcess = await ensureLocalServer(targetUrl);
|
|
browser = await chromium.launch({
|
|
headless: process.env.VERIFY_BATTLE_KEYBOARD_HEADLESS !== '0'
|
|
});
|
|
const context = await browser.newContext(desktopBrowserContextOptions);
|
|
const page = await context.newPage();
|
|
page.setDefaultTimeout(30000);
|
|
|
|
const pageErrors = [];
|
|
const consoleErrors = [];
|
|
page.on('pageerror', (error) => pageErrors.push(error.stack ?? error.message));
|
|
page.on('console', (message) => {
|
|
if (message.type() === 'error') {
|
|
consoleErrors.push(message.text());
|
|
}
|
|
});
|
|
|
|
await page.goto(targetUrl, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90000
|
|
});
|
|
await page.evaluate(() => window.localStorage.clear());
|
|
await page.reload({
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 90000
|
|
});
|
|
await waitForDebugApi(page);
|
|
await assertDesktopViewport(page);
|
|
await assertRendererAndBasePath(page, renderer);
|
|
|
|
await page.evaluate((requestedBattleId) => {
|
|
window.__HEROS_DEBUG__?.goToBattle(requestedBattleId);
|
|
}, battleId);
|
|
await page.waitForFunction(
|
|
(requestedBattleId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
const activeScenes = window.__HEROS_DEBUG__?.activeScenes?.() ?? [];
|
|
return (
|
|
battle?.battleId === requestedBattleId &&
|
|
battle.phase === 'deployment' &&
|
|
battle.mapBackgroundReady === true &&
|
|
['ready', 'degraded'].includes(battle.combatAssets?.status) &&
|
|
activeScenes.length === 1 &&
|
|
activeScenes[0] === 'BattleScene'
|
|
);
|
|
},
|
|
battleId,
|
|
{ timeout: 90000 }
|
|
);
|
|
|
|
await installPointerInputRecorder(page);
|
|
let state = await readBattleState(page);
|
|
assertFirstBattleObjectiveDefinitions(state.objectiveDefinitions);
|
|
assertDeploymentKeyboardFocus(state.deploymentKeyboard, 'start');
|
|
assertDeploymentKeyboardOrder(state.deploymentKeyboard);
|
|
|
|
await page.keyboard.press('Tab');
|
|
await waitForDeploymentFocus(page, state.deploymentKeyboard.items[0].id);
|
|
state = await readBattleState(page);
|
|
assertDeploymentKeyboardFocus(
|
|
state.deploymentKeyboard,
|
|
state.deploymentKeyboard.items[0].id
|
|
);
|
|
|
|
await page.keyboard.press('Shift+Tab');
|
|
await waitForDeploymentFocus(page, 'start');
|
|
state = await readBattleState(page);
|
|
assertDeploymentKeyboardFocus(state.deploymentKeyboard, 'start');
|
|
|
|
await page.keyboard.press('ArrowRight');
|
|
await waitForDeploymentFocus(page, state.deploymentKeyboard.items[0].id);
|
|
state = await readBattleState(page);
|
|
assertDeploymentKeyboardFocus(
|
|
state.deploymentKeyboard,
|
|
state.deploymentKeyboard.items[0].id
|
|
);
|
|
|
|
await page.keyboard.press('ArrowLeft');
|
|
await waitForDeploymentFocus(page, 'start');
|
|
state = await readBattleState(page);
|
|
assertDeploymentKeyboardFocus(state.deploymentKeyboard, 'start');
|
|
|
|
const firstDeploymentItemId = state.deploymentKeyboard.items[0].id;
|
|
const secondDeploymentItemId = state.deploymentKeyboard.items[1].id;
|
|
await page.keyboard.down('ArrowRight');
|
|
await waitForDeploymentFocus(page, firstDeploymentItemId);
|
|
await page.keyboard.down('ArrowRight');
|
|
await waitForDeploymentFocus(page, secondDeploymentItemId);
|
|
await page.keyboard.up('ArrowRight');
|
|
await page.keyboard.press('ArrowLeft');
|
|
await waitForDeploymentFocus(page, firstDeploymentItemId);
|
|
await page.keyboard.press('ArrowLeft');
|
|
await waitForDeploymentFocus(page, 'start');
|
|
|
|
await dispatchKeyDownWithoutKeyUp(page, 'Tab', 'Tab');
|
|
await waitForDeploymentFocus(page, firstDeploymentItemId);
|
|
await page.evaluate(() => {
|
|
window.dispatchEvent(new Event('blur'));
|
|
});
|
|
await page.keyboard.press('Tab');
|
|
await waitForDeploymentFocus(page, secondDeploymentItemId);
|
|
await page.keyboard.press('Shift+Tab');
|
|
await waitForDeploymentFocus(page, firstDeploymentItemId);
|
|
await page.keyboard.press('Shift+Tab');
|
|
await waitForDeploymentFocus(page, 'start');
|
|
|
|
await page.screenshot({
|
|
path: `dist/verification-battle-keyboard-deployment-focus-${renderer}.png`,
|
|
fullPage: true
|
|
});
|
|
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForFunction(
|
|
(requestedBattleId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return battle?.battleId === requestedBattleId && battle.phase === 'idle';
|
|
},
|
|
battleId,
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
await page.waitForFunction(
|
|
() => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
battle?.phase === 'idle' &&
|
|
battle.activeBattleEvent === null &&
|
|
battle.firstBattleTutorial?.active === true &&
|
|
battle.firstBattleTutorial.step === 'select-unit'
|
|
);
|
|
},
|
|
undefined,
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
state = await readBattleState(page);
|
|
const tutorialPath = state.firstBattleTutorial?.path;
|
|
assert(
|
|
tutorialPath?.unitId &&
|
|
tutorialPath.from &&
|
|
tutorialPath.moveTile &&
|
|
tutorialPath.targetId &&
|
|
tutorialPath.targetTile,
|
|
`Expected a complete first-battle tutorial path: ${JSON.stringify(state.firstBattleTutorial)}`
|
|
);
|
|
|
|
state = await focusBattleCursorAt(page, tutorialPath.from, {
|
|
expectedMode: 'unit',
|
|
expectedUnitId: tutorialPath.unitId
|
|
});
|
|
assertIdleUnitKeyboardFocus(state, tutorialPath.unitId, tutorialPath.from);
|
|
|
|
await page.screenshot({
|
|
path: `dist/verification-battle-keyboard-idle-focus-${renderer}.png`,
|
|
fullPage: true
|
|
});
|
|
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForFunction(
|
|
(expectedUnitId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
battle?.phase === 'moving' &&
|
|
battle.selectedUnitId === expectedUnitId &&
|
|
battle.firstBattleTutorial?.step === 'move'
|
|
);
|
|
},
|
|
tutorialPath.unitId
|
|
);
|
|
|
|
state = await focusBattleCursorAt(page, tutorialPath.moveTile, {
|
|
expectedMode: 'move'
|
|
});
|
|
assertBattleCursor(state.keyboardBattleCursor, tutorialPath.moveTile, 'move');
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForFunction(
|
|
(expectedUnitId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
battle?.phase === 'command' &&
|
|
battle.selectedUnitId === expectedUnitId &&
|
|
battle.pendingMove?.unitId === expectedUnitId &&
|
|
battle.firstBattleTutorial?.step === 'attack-command' &&
|
|
battle.commandMenuKeyboard?.visible === true
|
|
);
|
|
},
|
|
tutorialPath.unitId,
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
await focusMenuItem(page, 'attack');
|
|
state = await readBattleState(page);
|
|
assertCommandMenuFocus(state.commandMenuKeyboard, 'command');
|
|
assert(
|
|
state.commandMenuKeyboard.focusedItem.id === 'attack',
|
|
`Expected attack to receive keyboard focus: ${JSON.stringify(state.commandMenuKeyboard)}`
|
|
);
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForFunction(
|
|
(expectedUnitId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
battle?.phase === 'targeting' &&
|
|
battle.selectedUnitId === expectedUnitId &&
|
|
battle.firstBattleTutorial?.step === 'target-preview'
|
|
);
|
|
},
|
|
tutorialPath.unitId
|
|
);
|
|
|
|
state = await focusBattleCursorAt(page, tutorialPath.targetTile, {
|
|
expectedMode: 'damage',
|
|
expectedUnitId: tutorialPath.targetId
|
|
});
|
|
assertBattleCursor(
|
|
state.keyboardBattleCursor,
|
|
tutorialPath.targetTile,
|
|
'damage',
|
|
tutorialPath.targetId
|
|
);
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForFunction(
|
|
(expectedTargetId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
battle?.firstBattleTutorial?.targetPreviewLocked === true &&
|
|
battle.combatExchangePreview?.defenderId === expectedTargetId
|
|
);
|
|
},
|
|
tutorialPath.targetId
|
|
);
|
|
|
|
state = await readBattleState(page);
|
|
assert(
|
|
state.combatExchangePreview?.attackerId === tutorialPath.unitId &&
|
|
state.combatExchangePreview.defenderId === tutorialPath.targetId &&
|
|
Number.isFinite(state.combatExchangePreview.attackDamage) &&
|
|
Number.isFinite(state.combatExchangePreview.attackHitRate),
|
|
`Expected a keyboard-selected attack forecast before confirmation: ${JSON.stringify(state.combatExchangePreview)}`
|
|
);
|
|
|
|
await page.screenshot({
|
|
path: `dist/verification-battle-keyboard-attack-preview-${renderer}.png`,
|
|
fullPage: true
|
|
});
|
|
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForFunction(
|
|
(unitId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
battle?.actedUnitIds?.includes(unitId) &&
|
|
battle.commandMenuKeyboard?.visible === false &&
|
|
battle.firstBattleTutorial?.active === false
|
|
);
|
|
},
|
|
tutorialPath.unitId,
|
|
{ timeout: 30000 }
|
|
);
|
|
|
|
const completed = await page.evaluate((unitId) => {
|
|
const state = window.__HEROS_DEBUG__?.battle();
|
|
const qa = window.__HEROS_KEYBOARD_COMMAND_QA__;
|
|
return {
|
|
selectedUnitId: state?.selectedUnitId ?? null,
|
|
pendingMove: state?.pendingMove ?? null,
|
|
acted: state?.actedUnitIds?.includes(unitId) ?? false,
|
|
commandMenu: state?.commandMenuKeyboard ?? null,
|
|
phase: state?.phase ?? null,
|
|
tutorialActive: state?.firstBattleTutorial?.active ?? null,
|
|
pointerInputCount: qa?.pointerInputCount ?? -1
|
|
};
|
|
}, tutorialPath.unitId);
|
|
assert(
|
|
completed.acted &&
|
|
completed.commandMenu?.visible === false &&
|
|
completed.tutorialActive === false,
|
|
`Keyboard attack confirmation must complete exactly one first action: ${JSON.stringify(completed)}`
|
|
);
|
|
assert(
|
|
completed.pointerInputCount === 0,
|
|
`Deployment-to-attack keyboard flow emitted mouse/pointer input: ${JSON.stringify(completed)}`
|
|
);
|
|
|
|
state = await waitForAutoFocusedUnitWithKeyboard(page);
|
|
const handoffUnit = state.units?.find(
|
|
(unit) => unit.id === state.selectedUnitId
|
|
);
|
|
assert(
|
|
handoffUnit?.id === 'zhang-fei' &&
|
|
handoffUnit.faction === 'ally' &&
|
|
handoffUnit.hp > 0 &&
|
|
handoffUnit.acted === false,
|
|
`Expected the first completed action to hand keyboard focus to Zhang Fei: ${JSON.stringify({
|
|
selectedUnitId: state.selectedUnitId,
|
|
units: state.units
|
|
})}`
|
|
);
|
|
|
|
await focusBattleCursorAt(
|
|
page,
|
|
{ x: handoffUnit.x, y: handoffUnit.y },
|
|
{ expectedMode: 'move' }
|
|
);
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForFunction(
|
|
(unitId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
battle?.selectedUnitId === unitId &&
|
|
battle.pendingMove?.unitId === unitId &&
|
|
battle.commandMenuKeyboard?.visible === true
|
|
);
|
|
},
|
|
handoffUnit.id
|
|
);
|
|
await focusMenuItem(page, 'wait');
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForFunction(
|
|
(unitId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
battle?.actedUnitIds?.includes(unitId) &&
|
|
battle.commandMenuKeyboard?.visible === false
|
|
);
|
|
},
|
|
handoffUnit.id
|
|
);
|
|
|
|
state = await waitForAutoFocusedUnitWithKeyboard(page);
|
|
const menuUnit = state.units?.find(
|
|
(unit) => unit.id === state.selectedUnitId
|
|
);
|
|
assert(
|
|
menuUnit?.id === 'liu-bei' &&
|
|
menuUnit.faction === 'ally' &&
|
|
menuUnit.hp > 0 &&
|
|
menuUnit.acted === false,
|
|
`Expected Zhang Fei's wait to hand keyboard focus to Liu Bei: ${JSON.stringify({
|
|
selectedUnitId: state.selectedUnitId,
|
|
units: state.units
|
|
})}`
|
|
);
|
|
|
|
await focusBattleCursorAt(
|
|
page,
|
|
{ x: menuUnit.x, y: menuUnit.y },
|
|
{ expectedMode: 'move' }
|
|
);
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForFunction(
|
|
(unitId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
battle?.selectedUnitId === unitId &&
|
|
battle.pendingMove?.unitId === unitId &&
|
|
battle.commandMenuKeyboard?.visible === true &&
|
|
battle.commandMenuKeyboard.mode === 'command'
|
|
);
|
|
},
|
|
menuUnit.id
|
|
);
|
|
|
|
state = await readBattleState(page);
|
|
assertCommandMenuFocus(state.commandMenuKeyboard, 'command');
|
|
const commandItems = state.commandMenuKeyboard.items;
|
|
const attackCommand = commandItems.find(({ id }) => id === 'attack');
|
|
const strategyCommand = commandItems.find(({ id }) => id === 'strategy');
|
|
assert(
|
|
attackCommand?.available === false &&
|
|
strategyCommand?.available === true &&
|
|
state.commandMenuKeyboard.focusedItem.id === 'strategy',
|
|
`Default command focus must skip unavailable attack and land on strategy: ${JSON.stringify(state.commandMenuKeyboard)}`
|
|
);
|
|
|
|
const availableCommandIds = commandItems
|
|
.filter(({ available }) => available)
|
|
.map(({ id }) => id);
|
|
const strategyIndex = availableCommandIds.indexOf('strategy');
|
|
const previousCommandId =
|
|
availableCommandIds[
|
|
(strategyIndex - 1 + availableCommandIds.length) %
|
|
availableCommandIds.length
|
|
];
|
|
await page.keyboard.press('Shift+Tab');
|
|
state = await readBattleState(page);
|
|
assert(
|
|
state.commandMenuKeyboard?.focusedItem?.id === previousCommandId,
|
|
`Shift+Tab must wrap to the previous available command: ${JSON.stringify(state.commandMenuKeyboard)}`
|
|
);
|
|
await page.keyboard.press('Tab');
|
|
state = await readBattleState(page);
|
|
assert(
|
|
state.commandMenuKeyboard?.focusedItem?.id === 'strategy',
|
|
`Tab must return to strategy without visiting disabled attack: ${JSON.stringify(state.commandMenuKeyboard)}`
|
|
);
|
|
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForFunction(() => {
|
|
const keyboard = window.__HEROS_DEBUG__?.battle()?.commandMenuKeyboard;
|
|
return keyboard?.visible === true && keyboard.mode === 'usable';
|
|
});
|
|
state = await readBattleState(page);
|
|
assertCommandMenuFocus(state.commandMenuKeyboard, 'usable');
|
|
assert(
|
|
state.commandMenuKeyboard.items.some(({ available }) => !available) &&
|
|
state.commandMenuKeyboard.focusedItem.available === true,
|
|
`Usable focus must skip unavailable choices: ${JSON.stringify(state.commandMenuKeyboard)}`
|
|
);
|
|
|
|
await page.keyboard.press('Shift+Tab');
|
|
state = await readBattleState(page);
|
|
assert(
|
|
state.commandMenuKeyboard?.focusedItem?.available === true,
|
|
`Usable Shift+Tab must keep focus on an available choice: ${JSON.stringify(state.commandMenuKeyboard)}`
|
|
);
|
|
await page.keyboard.press('Escape');
|
|
await page.waitForFunction(() => {
|
|
const keyboard = window.__HEROS_DEBUG__?.battle()?.commandMenuKeyboard;
|
|
return (
|
|
keyboard?.visible === true &&
|
|
keyboard.mode === 'command' &&
|
|
keyboard.focusedItem?.id === 'strategy'
|
|
);
|
|
});
|
|
state = await readBattleState(page);
|
|
assert(
|
|
state.selectedUnitId === menuUnit.id &&
|
|
state.pendingMove?.unitId === menuUnit.id,
|
|
`Esc must return one menu level without clearing the unit or pending move: ${JSON.stringify(state)}`
|
|
);
|
|
|
|
await focusMenuItem(page, 'wait');
|
|
await page.keyboard.press('Enter');
|
|
await page.waitForFunction(
|
|
(unitId) => {
|
|
const battle = window.__HEROS_DEBUG__?.battle();
|
|
return (
|
|
battle?.actedUnitIds?.includes(unitId) &&
|
|
battle.commandMenuKeyboard?.visible === false
|
|
);
|
|
},
|
|
menuUnit.id
|
|
);
|
|
|
|
const finalInputState = await page.evaluate(() => {
|
|
const qa = window.__HEROS_KEYBOARD_COMMAND_QA__;
|
|
if (qa?.recordPointerInput) {
|
|
window.removeEventListener(
|
|
'mousedown',
|
|
qa.recordPointerInput,
|
|
true
|
|
);
|
|
window.removeEventListener(
|
|
'pointerdown',
|
|
qa.recordPointerInput,
|
|
true
|
|
);
|
|
}
|
|
if (qa?.recordKeyboardInput) {
|
|
window.removeEventListener(
|
|
'keydown',
|
|
qa.recordKeyboardInput,
|
|
true
|
|
);
|
|
}
|
|
return {
|
|
pointerInputCount: qa?.pointerInputCount ?? -1,
|
|
keyboardInputCount: qa?.keyboardInputs?.length ?? 0
|
|
};
|
|
});
|
|
assert(
|
|
finalInputState.pointerInputCount === 0 &&
|
|
finalInputState.keyboardInputCount > 0,
|
|
`Full keyboard regression flow must complete with zero pointer input: ${JSON.stringify(finalInputState)}`
|
|
);
|
|
assert(
|
|
pageErrors.length === 0,
|
|
`Unexpected browser errors: ${JSON.stringify(pageErrors.slice(-5))}`
|
|
);
|
|
assert(
|
|
consoleErrors.length === 0,
|
|
`Unexpected console errors: ${JSON.stringify(consoleErrors.slice(-5))}`
|
|
);
|
|
|
|
console.log(
|
|
`Verified ${renderer} keyboard-only first-battle flow at ${desktopBrowserViewport.width}x${desktopBrowserViewport.height} DPR1 under /heros_web/: ` +
|
|
'deployment focus cycles with Tab/Shift+Tab and held arrows, focus-loss recovery works without keyup, Enter starts the recommended deployment, ' +
|
|
'idle ally selection, movement, attack forecast, and confirmation complete one action; disabled command and usable choices are skipped, ' +
|
|
'submenu Escape preserves the pending move, and a second action completes with zero pointer input.'
|
|
);
|
|
} finally {
|
|
await browser?.close();
|
|
await stopServerProcess(serverProcess);
|
|
}
|
|
|
|
function withDebugOptions(url, requestedRenderer) {
|
|
const parsed = new URL(url);
|
|
parsed.searchParams.set('debug', '1');
|
|
parsed.searchParams.set('renderer', requestedRenderer);
|
|
parsed.searchParams.set('debugCombatAssetWatchdogMs', '250');
|
|
return parsed.toString();
|
|
}
|
|
|
|
async function dispatchKeyDownWithoutKeyUp(page, key, code) {
|
|
await page.evaluate(
|
|
({ requestedKey, requestedCode }) => {
|
|
window.dispatchEvent(
|
|
new KeyboardEvent('keydown', {
|
|
key: requestedKey,
|
|
code: requestedCode,
|
|
bubbles: true,
|
|
cancelable: true
|
|
})
|
|
);
|
|
},
|
|
{ requestedKey: key, requestedCode: code }
|
|
);
|
|
}
|
|
|
|
async function installPointerInputRecorder(page) {
|
|
await page.evaluate(() => {
|
|
const recordPointerInput = () => {
|
|
window.__HEROS_KEYBOARD_COMMAND_QA__.pointerInputCount += 1;
|
|
};
|
|
const recordKeyboardInput = (event) => {
|
|
window.__HEROS_KEYBOARD_COMMAND_QA__.keyboardInputs.push({
|
|
code: event.code,
|
|
key: event.key,
|
|
shiftKey: event.shiftKey,
|
|
repeat: event.repeat
|
|
});
|
|
};
|
|
window.__HEROS_KEYBOARD_COMMAND_QA__ = {
|
|
pointerInputCount: 0,
|
|
recordPointerInput,
|
|
recordKeyboardInput,
|
|
keyboardInputs: []
|
|
};
|
|
window.addEventListener('mousedown', recordPointerInput, true);
|
|
window.addEventListener('pointerdown', recordPointerInput, true);
|
|
window.addEventListener('keydown', recordKeyboardInput, true);
|
|
});
|
|
}
|
|
|
|
function assertDeploymentKeyboardOrder(keyboard) {
|
|
const ids = keyboard?.items?.map(({ id }) => id) ?? [];
|
|
const firstSlotIndex = ids.findIndex((id) => id.startsWith('slot:'));
|
|
const lastUnitIndex = ids.findLastIndex((id) => id.startsWith('unit:'));
|
|
assert(
|
|
ids.length >= 4 &&
|
|
ids.at(-2) === 'restore' &&
|
|
ids.at(-1) === 'start' &&
|
|
lastUnitIndex >= 0 &&
|
|
firstSlotIndex > lastUnitIndex &&
|
|
ids.slice(0, firstSlotIndex).every((id) => id.startsWith('unit:')) &&
|
|
ids
|
|
.slice(firstSlotIndex, -2)
|
|
.every((id) => id.startsWith('slot:')),
|
|
`Expected deployment order roles -> slots -> restore -> start: ${JSON.stringify(keyboard)}`
|
|
);
|
|
}
|
|
|
|
function assertFirstBattleObjectiveDefinitions(objectives) {
|
|
assert(
|
|
Array.isArray(objectives) && objectives.length === 4,
|
|
`Expected exactly four first-battle objective definitions: ${JSON.stringify(objectives)}`
|
|
);
|
|
|
|
const byId = Object.fromEntries(
|
|
objectives.map((objective) => [objective.id, objective])
|
|
);
|
|
assert(
|
|
byId.leader?.kind === 'defeat-leader' &&
|
|
byId.leader.rewardGold === 200,
|
|
`Expected the commander objective to reward 200 gold: ${JSON.stringify(byId.leader)}`
|
|
);
|
|
assert(
|
|
byId['liu-bei']?.kind === 'keep-unit-alive' &&
|
|
byId['liu-bei'].rewardGold === 100,
|
|
`Expected the Liu Bei survival objective to reward 100 gold: ${JSON.stringify(byId['liu-bei'])}`
|
|
);
|
|
assert(
|
|
byId.village?.kind === 'secure-terrain' &&
|
|
byId.village.terrain === 'village' &&
|
|
byId.village.targetTile?.radius === 4 &&
|
|
byId.village.rewardGold === 210,
|
|
`Expected the village objective to expose radius 4 and reward 210 gold: ${JSON.stringify(byId.village)}`
|
|
);
|
|
assert(
|
|
byId.quick?.kind === 'quick-victory' &&
|
|
byId.quick.maxTurn === 15 &&
|
|
byId.quick.rewardGold === 120,
|
|
`Expected the quick-victory objective to expose 15 turns and reward 120 gold: ${JSON.stringify(byId.quick)}`
|
|
);
|
|
}
|
|
|
|
function assertDeploymentKeyboardFocus(keyboard, expectedId) {
|
|
const focusedItem = keyboard?.focusedItem;
|
|
const matchingItem = keyboard?.items?.find(({ id }) => id === expectedId);
|
|
const guidanceText = keyboard?.guidance?.text ?? '';
|
|
assert(
|
|
keyboard?.visible === true &&
|
|
focusedItem?.id === expectedId &&
|
|
focusedItem.available !== false &&
|
|
matchingItem?.focused === true &&
|
|
keyboard.items.filter(({ focused }) => focused).length === 1 &&
|
|
focusedItem.bounds &&
|
|
boundsInsideViewport(focusedItem.bounds) &&
|
|
(focusedItem.lineWidth ?? matchingItem.lineWidth ?? 0) >= 3 &&
|
|
guidanceText.includes('Tab') &&
|
|
guidanceText.includes('Enter') &&
|
|
guidanceText.includes('Esc') &&
|
|
boundsInsideViewport(keyboard.guidance?.bounds),
|
|
`Expected visible deployment keyboard focus and guidance on ${expectedId}: ${JSON.stringify(keyboard)}`
|
|
);
|
|
}
|
|
|
|
async function waitForDeploymentFocus(page, expectedId) {
|
|
try {
|
|
await page.waitForFunction(
|
|
(id) =>
|
|
window.__HEROS_DEBUG__?.battle()?.deploymentKeyboard?.focusedItem
|
|
?.id === id,
|
|
expectedId
|
|
);
|
|
} catch (error) {
|
|
const diagnostic = await page.evaluate(() => ({
|
|
deploymentKeyboard: window.__HEROS_DEBUG__?.battle()?.deploymentKeyboard,
|
|
keyboardInputs: window.__HEROS_KEYBOARD_COMMAND_QA__?.keyboardInputs ?? []
|
|
}));
|
|
throw new Error(
|
|
`Timed out waiting for deployment keyboard focus ${expectedId}: ${JSON.stringify(diagnostic)}`,
|
|
{ cause: error }
|
|
);
|
|
}
|
|
}
|
|
|
|
async function focusBattleCursorAt(
|
|
page,
|
|
expectedTile,
|
|
{ expectedMode, expectedUnitId } = {}
|
|
) {
|
|
for (let attempt = 0; attempt < 96; attempt += 1) {
|
|
const state = await readBattleState(page);
|
|
const cursor = state?.keyboardBattleCursor;
|
|
const cursorUnitId =
|
|
cursor?.unitId ??
|
|
cursor?.targetId ??
|
|
state?.units?.find(
|
|
(unit) =>
|
|
unit.hp > 0 &&
|
|
unit.x === cursor?.x &&
|
|
unit.y === cursor?.y
|
|
)?.id;
|
|
if (
|
|
cursor?.x === expectedTile.x &&
|
|
cursor?.y === expectedTile.y &&
|
|
(!expectedMode || cursor.mode === expectedMode) &&
|
|
(!expectedUnitId || cursorUnitId === expectedUnitId)
|
|
) {
|
|
return state;
|
|
}
|
|
await page.keyboard.press('Tab');
|
|
await page.waitForTimeout(20);
|
|
}
|
|
throw new Error(
|
|
`Could not focus ${expectedMode ?? 'battle'} cursor at ${expectedTile.x},${expectedTile.y} for ${expectedUnitId ?? 'any unit'}.`
|
|
);
|
|
}
|
|
|
|
function assertIdleUnitKeyboardFocus(state, expectedUnitId, expectedTile) {
|
|
const focusedUnit = state?.units?.find(
|
|
(unit) =>
|
|
unit.hp > 0 &&
|
|
unit.x === state.keyboardBattleCursor?.x &&
|
|
unit.y === state.keyboardBattleCursor?.y
|
|
);
|
|
assert(
|
|
state?.phase === 'idle' &&
|
|
state.selectedUnitId === null &&
|
|
state.keyboardBattleCursor?.mode === 'unit' &&
|
|
focusedUnit?.id === expectedUnitId &&
|
|
focusedUnit.faction === 'ally' &&
|
|
focusedUnit.acted === false &&
|
|
state.keyboardBattleCursor.x === expectedTile.x &&
|
|
state.keyboardBattleCursor.y === expectedTile.y,
|
|
`Expected one keyboard-focused actionable ally before selection: ${JSON.stringify({
|
|
cursor: state?.keyboardBattleCursor,
|
|
focusedUnit,
|
|
selectedUnitId: state?.selectedUnitId,
|
|
phase: state?.phase
|
|
})}`
|
|
);
|
|
}
|
|
|
|
function assertBattleCursor(cursor, expectedTile, expectedMode, expectedTargetId) {
|
|
assert(
|
|
cursor?.mode === expectedMode &&
|
|
cursor.x === expectedTile.x &&
|
|
cursor.y === expectedTile.y &&
|
|
(!expectedTargetId ||
|
|
cursor.targetId === expectedTargetId ||
|
|
cursor.unitId === expectedTargetId),
|
|
`Expected ${expectedMode} keyboard cursor at ${expectedTile.x},${expectedTile.y}: ${JSON.stringify(cursor)}`
|
|
);
|
|
}
|
|
|
|
async function waitForAutoFocusedUnitWithKeyboard(page) {
|
|
for (let attempt = 0; attempt < 300; attempt += 1) {
|
|
const state = await readBattleState(page);
|
|
const selected = state?.units?.find(
|
|
(unit) => unit.id === state.selectedUnitId
|
|
);
|
|
if (
|
|
state?.phase === 'moving' &&
|
|
state.activeFaction === 'ally' &&
|
|
!state.activeBattleEvent &&
|
|
selected?.faction === 'ally' &&
|
|
selected.hp > 0 &&
|
|
selected.acted === false
|
|
) {
|
|
return state;
|
|
}
|
|
if (state?.activeBattleEvent) {
|
|
await page.keyboard.press('Enter');
|
|
}
|
|
await page.waitForTimeout(100);
|
|
}
|
|
const state = await readBattleState(page);
|
|
throw new Error(
|
|
`Could not reach the next auto-focused ally after a keyboard action: ${JSON.stringify({
|
|
phase: state?.phase,
|
|
activeFaction: state?.activeFaction,
|
|
activeBattleEvent: state?.activeBattleEvent,
|
|
combatCutIn: state?.combatCutIn,
|
|
selectedUnitId: state?.selectedUnitId,
|
|
keyboardBattleCursor: state?.keyboardBattleCursor,
|
|
actedUnitIds: state?.actedUnitIds
|
|
})}`
|
|
);
|
|
}
|
|
|
|
async function focusMenuItem(page, expectedId) {
|
|
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
const keyboard = await page.evaluate(
|
|
() => window.__HEROS_DEBUG__?.battle()?.commandMenuKeyboard
|
|
);
|
|
assertCommandMenuFocus(keyboard, keyboard?.mode);
|
|
if (keyboard.focusedItem.id === expectedId) {
|
|
return;
|
|
}
|
|
const target = keyboard.items.find(({ id }) => id === expectedId);
|
|
assert(
|
|
target?.available,
|
|
`Expected ${expectedId} to be keyboard-selectable: ${JSON.stringify(keyboard)}`
|
|
);
|
|
await page.keyboard.press('Tab');
|
|
}
|
|
throw new Error(`Could not focus command menu item ${expectedId}.`);
|
|
}
|
|
|
|
function assertCommandMenuFocus(keyboard, expectedMode) {
|
|
assert(
|
|
keyboard?.visible === true &&
|
|
keyboard.mode === expectedMode &&
|
|
keyboard.focusedItem?.available === true &&
|
|
keyboard.focusedItem.bounds &&
|
|
keyboard.focusedItem.lineWidth >= 3 &&
|
|
keyboard.focusMarker?.visible === true &&
|
|
keyboard.focusMarker.text === '▶' &&
|
|
keyboard.focusMarker.bounds &&
|
|
boundsInside(keyboard.focusMarker.bounds, keyboard.focusedItem.bounds) &&
|
|
keyboard.guidance?.text.includes('↑↓/Tab') &&
|
|
keyboard.guidance.text.includes('Enter') &&
|
|
keyboard.guidance.text.includes('Esc') &&
|
|
boundsInsideViewport(keyboard.guidance.bounds),
|
|
`Expected one visible non-color keyboard focus and on-screen controls guidance: ${JSON.stringify(keyboard)}`
|
|
);
|
|
assert(
|
|
keyboard.items.filter(({ focused }) => focused).length === 1,
|
|
`Expected exactly one focused menu item: ${JSON.stringify(keyboard)}`
|
|
);
|
|
}
|
|
|
|
async function readBattleState(page) {
|
|
return page.evaluate(() => window.__HEROS_DEBUG__?.battle());
|
|
}
|
|
|
|
function boundsInside(inner, outer) {
|
|
return (
|
|
inner.x >= outer.x - 1 &&
|
|
inner.y >= outer.y - 1 &&
|
|
inner.x + inner.width <= outer.x + outer.width + 1 &&
|
|
inner.y + inner.height <= outer.y + outer.height + 1
|
|
);
|
|
}
|
|
|
|
function boundsInsideViewport(bounds) {
|
|
return (
|
|
bounds &&
|
|
bounds.x >= 0 &&
|
|
bounds.y >= 0 &&
|
|
bounds.x + bounds.width <= desktopBrowserViewport.width &&
|
|
bounds.y + bounds.height <= desktopBrowserViewport.height
|
|
);
|
|
}
|
|
|
|
async function waitForDebugApi(page) {
|
|
await page.waitForFunction(
|
|
() =>
|
|
Boolean(
|
|
window.__HEROS_DEBUG__?.activeScenes &&
|
|
window.__HEROS_DEBUG__?.goToBattle
|
|
),
|
|
undefined,
|
|
{ timeout: 90000 }
|
|
);
|
|
}
|
|
|
|
async function assertDesktopViewport(page) {
|
|
const viewport = await page.evaluate(() => {
|
|
const canvas = document.querySelector('canvas');
|
|
return {
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
dpr: window.devicePixelRatio,
|
|
zoom: window.visualViewport?.scale ?? 1,
|
|
canvas: canvas
|
|
? { width: canvas.width, height: canvas.height }
|
|
: null
|
|
};
|
|
});
|
|
assert(
|
|
viewport.width === desktopBrowserViewport.width &&
|
|
viewport.height === desktopBrowserViewport.height &&
|
|
viewport.dpr === 1 &&
|
|
viewport.zoom === 1 &&
|
|
viewport.canvas?.width === desktopBrowserViewport.width &&
|
|
viewport.canvas?.height === desktopBrowserViewport.height,
|
|
`Expected explicit 1920x1080 CSS viewport, 100% zoom, and DPR1: ${JSON.stringify(viewport)}`
|
|
);
|
|
}
|
|
|
|
async function assertRendererAndBasePath(page, requestedRenderer) {
|
|
const runtime = await page.evaluate(() => ({
|
|
pathname: window.location.pathname,
|
|
rendererType: window.__HEROS_GAME__?.renderer?.type ?? null,
|
|
rendererName:
|
|
window.__HEROS_GAME__?.renderer?.constructor?.name ?? null
|
|
}));
|
|
const expectedRendererType = requestedRenderer === 'canvas' ? 1 : 2;
|
|
assert(
|
|
runtime.pathname === '/heros_web/' &&
|
|
runtime.rendererType === expectedRendererType,
|
|
`Expected ${requestedRenderer} under /heros_web/: ${JSON.stringify(runtime)}`
|
|
);
|
|
}
|
|
|
|
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 || '41796',
|
|
'--strictPort'
|
|
],
|
|
{
|
|
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);
|
|
}
|
|
|
|
await stopServerProcess(child);
|
|
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));
|
|
}
|
|
|
|
async function stopServerProcess(child) {
|
|
if (!child || child.exitCode !== null) {
|
|
return;
|
|
}
|
|
|
|
const exited = new Promise((resolve) => {
|
|
child.once('exit', resolve);
|
|
});
|
|
child.kill();
|
|
await Promise.race([exited, delay(5000)]);
|
|
if (child.exitCode === null) {
|
|
child.kill('SIGKILL');
|
|
await Promise.race([exited, delay(2000)]);
|
|
}
|
|
}
|
|
|
|
function assert(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|