feat: make guidance recoverable across camp and battle

This commit is contained in:
2026-07-28 05:57:42 +09:00
parent 495f98372c
commit 5f6490b2ae
7 changed files with 1300 additions and 44 deletions

View File

@@ -94,6 +94,7 @@ try {
await installPointerInputRecorder(page);
let state = await readBattleState(page);
assertFirstBattleObjectiveDefinitions(state.objectiveDefinitions);
assertBattleMenuDiscovery(state.battleHud?.header);
assertDeploymentKeyboardFocus(state.deploymentKeyboard, 'start');
assertDeploymentKeyboardOrder(state.deploymentKeyboard);
@@ -162,12 +163,84 @@ try {
{ timeout: 30000 }
);
await page.waitForFunction(
() => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
battle?.phase === 'idle' &&
battle.activeBattleEvent?.key === 'opening' &&
battle.firstBattleTutorial?.active === false
);
},
undefined,
{ timeout: 30000 }
);
const automaticTutorialMenuGuardSetup = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
if (
!scene ||
typeof scene.hideBattleEventBanner !== 'function'
) {
return { ready: false };
}
scene.hideBattleEventBanner();
window.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'm',
code: 'KeyM',
bubbles: true,
cancelable: true
})
);
window.dispatchEvent(
new KeyboardEvent('keyup', {
key: 'm',
code: 'KeyM',
bubbles: true,
cancelable: true
})
);
const battle = window.__HEROS_DEBUG__?.battle();
return {
ready: true,
activeBattleEvent: battle?.activeBattleEvent ?? null,
mapMenuKeyboard: battle?.mapMenuKeyboard ?? null,
firstBattleTutorial: battle?.firstBattleTutorial ?? null
};
});
assert(
automaticTutorialMenuGuardSetup.ready === true &&
automaticTutorialMenuGuardSetup.activeBattleEvent === null &&
automaticTutorialMenuGuardSetup.mapMenuKeyboard?.visible === true &&
automaticTutorialMenuGuardSetup.firstBattleTutorial?.active === false,
`Expected the opening event to close and the M menu to open before the automatic tutorial retry: ${JSON.stringify(automaticTutorialMenuGuardSetup)}`
);
assertMapMenuFocus(automaticTutorialMenuGuardSetup.mapMenuKeyboard);
await page.waitForTimeout(540);
state = await readBattleState(page);
assert(
state.mapMenuKeyboard?.visible === true &&
state.activeBattleEvent === null &&
state.firstBattleTutorial?.active === false &&
state.firstBattleTutorial.step === null,
`Automatic tutorial must remain inactive across multiple retry intervals while the M menu is open: ${JSON.stringify({
mapMenuKeyboard: state.mapMenuKeyboard,
activeBattleEvent: state.activeBattleEvent,
firstBattleTutorial: state.firstBattleTutorial
})}`
);
await page.keyboard.press('KeyM');
await waitForMapMenuVisibility(page, false);
await page.waitForFunction(
() => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
battle?.phase === 'idle' &&
battle.activeBattleEvent === null &&
battle.mapMenuKeyboard?.visible === false &&
battle.firstBattleTutorial?.active === true &&
battle.firstBattleTutorial.step === 'select-unit'
);
@@ -177,6 +250,121 @@ try {
);
state = await readBattleState(page);
assertFirstBattleTutorialStep(
state.firstBattleTutorial,
'select-unit',
1,
false
);
assert(
boundsInsideViewport(state.firstBattleTutorial.skipBounds),
`Expected the first-battle tutorial skip control inside the desktop viewport: ${JSON.stringify(state.firstBattleTutorial)}`
);
await page.keyboard.press('KeyK');
await page.waitForFunction(() => {
const tutorial = window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial;
return (
tutorial?.active === false &&
tutorial.completion === 'skipped' &&
tutorial.step === null
);
});
state = await readBattleState(page);
assert(
state.firstBattleTutorial?.completed === true &&
state.firstBattleTutorial.cardBounds === null &&
state.firstBattleTutorial.skipBounds === null,
`K must dismiss and persist the initially active first-battle tutorial: ${JSON.stringify(state.firstBattleTutorial)}`
);
await page.keyboard.press('KeyM');
await waitForMapMenuVisibility(page, true);
state = await readBattleState(page);
assertMapMenuFocus(state.mapMenuKeyboard);
await page.waitForTimeout(80);
await page.keyboard.press('KeyM');
await waitForMapMenuVisibility(page, false);
await page.waitForTimeout(80);
await page.keyboard.press('KeyM');
await waitForMapMenuVisibility(page, true);
await page.waitForTimeout(80);
await page.keyboard.press('Escape');
await waitForMapMenuVisibility(page, false);
await page.waitForTimeout(80);
await page.keyboard.press('KeyM');
await waitForMapMenuVisibility(page, true);
state = await readBattleState(page);
const mapMenuItems = state.mapMenuKeyboard.items;
const disabledMapMenuIndex = mapMenuItems.findIndex(
(item, index) =>
item.available === false &&
mapMenuItems[index - 1]?.available === true &&
mapMenuItems[index + 1]?.available === true
);
assert(
disabledMapMenuIndex > 0 &&
mapMenuItemId(mapMenuItems[disabledMapMenuIndex]) === 'load',
`Expected an unavailable load action between enabled map-menu actions after clearing saves: ${JSON.stringify(state.mapMenuKeyboard)}`
);
const beforeDisabledMapMenuId =
mapMenuItemId(mapMenuItems[disabledMapMenuIndex - 1]);
const afterDisabledMapMenuId =
mapMenuItemId(mapMenuItems[disabledMapMenuIndex + 1]);
await focusMapMenuItem(page, beforeDisabledMapMenuId);
await page.keyboard.press('ArrowDown');
await waitForMapMenuFocus(page, afterDisabledMapMenuId);
state = await readBattleState(page);
assertMapMenuFocus(state.mapMenuKeyboard, afterDisabledMapMenuId);
await page.keyboard.press('ArrowUp');
await waitForMapMenuFocus(page, beforeDisabledMapMenuId);
state = await readBattleState(page);
assertMapMenuFocus(state.mapMenuKeyboard, beforeDisabledMapMenuId);
await page.keyboard.press('Tab');
await waitForMapMenuFocus(page, afterDisabledMapMenuId);
state = await readBattleState(page);
assertMapMenuFocus(state.mapMenuKeyboard, afterDisabledMapMenuId);
await page.keyboard.press('Shift+Tab');
await waitForMapMenuFocus(page, beforeDisabledMapMenuId);
await focusMapMenuItem(page, 'tutorial');
state = await readBattleState(page);
assertMapMenuFocus(state.mapMenuKeyboard, 'tutorial');
await page.screenshot({
path: `dist/verification-battle-keyboard-map-menu-${renderer}.png`,
fullPage: true
});
await page.keyboard.press('Enter');
await page.waitForFunction(() => {
const battle = window.__HEROS_DEBUG__?.battle();
const tutorial = battle?.firstBattleTutorial;
return (
battle?.mapMenuKeyboard?.visible === false &&
tutorial?.active === true &&
tutorial.step === 'select-unit' &&
tutorial.stepNumber === 1 &&
tutorial.totalSteps === 4
);
});
state = await readBattleState(page);
assertFirstBattleTutorialStep(
state.firstBattleTutorial,
'select-unit',
1,
true
);
assert(
state.firstBattleTutorial.completion === null,
`On-demand tutorial replay must begin as a fresh run: ${JSON.stringify(state.firstBattleTutorial)}`
);
const tutorialPath = state.firstBattleTutorial?.path;
assert(
tutorialPath?.unitId &&
@@ -205,7 +393,8 @@ try {
return (
battle?.phase === 'moving' &&
battle.selectedUnitId === expectedUnitId &&
battle.firstBattleTutorial?.step === 'move'
battle.firstBattleTutorial?.step === 'move' &&
battle.firstBattleTutorial.stepNumber === 2
);
},
tutorialPath.unitId
@@ -224,6 +413,7 @@ try {
battle.selectedUnitId === expectedUnitId &&
battle.pendingMove?.unitId === expectedUnitId &&
battle.firstBattleTutorial?.step === 'attack-command' &&
battle.firstBattleTutorial.stepNumber === 3 &&
battle.commandMenuKeyboard?.visible === true
);
},
@@ -245,7 +435,8 @@ try {
return (
battle?.phase === 'targeting' &&
battle.selectedUnitId === expectedUnitId &&
battle.firstBattleTutorial?.step === 'target-preview'
battle.firstBattleTutorial?.step === 'target-preview' &&
battle.firstBattleTutorial.stepNumber === 4
);
},
tutorialPath.unitId
@@ -294,7 +485,8 @@ try {
return (
battle?.actedUnitIds?.includes(unitId) &&
battle.commandMenuKeyboard?.visible === false &&
battle.firstBattleTutorial?.active === false
battle.firstBattleTutorial?.active === false &&
battle.firstBattleTutorial.completion === 'completed'
);
},
tutorialPath.unitId,
@@ -311,13 +503,16 @@ try {
commandMenu: state?.commandMenuKeyboard ?? null,
phase: state?.phase ?? null,
tutorialActive: state?.firstBattleTutorial?.active ?? null,
tutorialCompletion:
state?.firstBattleTutorial?.completion ?? null,
pointerInputCount: qa?.pointerInputCount ?? -1
};
}, tutorialPath.unitId);
assert(
completed.acted &&
completed.commandMenu?.visible === false &&
completed.tutorialActive === false,
completed.tutorialActive === false &&
completed.tutorialCompletion === 'completed',
`Keyboard attack confirmation must complete exactly one first action: ${JSON.stringify(completed)}`
);
assert(
@@ -529,6 +724,7 @@ try {
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, ' +
'the automatic tutorial stays paused while the M menu is open and resumes after it closes; K skips it, M toggles the map menu, Esc closes it, arrows/Tab skip its disabled load action, and Enter replays the four-step tutorial; ' +
'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.'
);
@@ -638,6 +834,38 @@ function assertFirstBattleObjectiveDefinitions(objectives) {
);
}
function assertFirstBattleTutorialStep(
tutorial,
expectedStep,
stepNumber,
expectedCompleted
) {
assert(
tutorial?.active === true &&
tutorial.completed === expectedCompleted &&
tutorial.onDemandAvailable === true &&
tutorial.step === expectedStep &&
tutorial.stepNumber === stepNumber &&
tutorial.totalSteps === 4 &&
tutorial.cardBounds &&
tutorial.highlightBounds &&
tutorial.skipBounds &&
boundsInsideViewport(tutorial.cardBounds) &&
boundsInsideViewport(tutorial.highlightBounds) &&
boundsInsideViewport(tutorial.skipBounds),
`Expected visible first-battle tutorial step ${stepNumber}/4 (${expectedStep}) inside the desktop viewport: ${JSON.stringify(tutorial)}`
);
}
function assertBattleMenuDiscovery(header) {
assert(
header?.menuLabel === '메뉴 M' &&
header.menuBounds &&
boundsInsideViewport(header.menuBounds),
`Expected a visible 메뉴 M discovery control inside the desktop HUD: ${JSON.stringify(header)}`
);
}
function assertDeploymentKeyboardFocus(keyboard, expectedId) {
const focusedItem = keyboard?.focusedItem;
const matchingItem = keyboard?.items?.find(({ id }) => id === expectedId);
@@ -679,6 +907,118 @@ async function waitForDeploymentFocus(page, expectedId) {
}
}
async function waitForMapMenuVisibility(page, expectedVisible) {
try {
await page.waitForFunction(
(visible) =>
window.__HEROS_DEBUG__?.battle()?.mapMenuKeyboard?.visible === visible,
expectedVisible
);
} catch (error) {
const diagnostic = await page.evaluate(() => ({
phase: window.__HEROS_DEBUG__?.battle()?.phase,
mapMenuVisible: window.__HEROS_DEBUG__?.battle()?.mapMenuVisible,
mapMenuKeyboard:
window.__HEROS_DEBUG__?.battle()?.mapMenuKeyboard ?? null,
firstBattleTutorial:
window.__HEROS_DEBUG__?.battle()?.firstBattleTutorial ?? null,
keyboardInputs:
window.__HEROS_KEYBOARD_COMMAND_QA__?.keyboardInputs ?? []
}));
throw new Error(
`Timed out waiting for map-menu visibility ${expectedVisible}: ${JSON.stringify(diagnostic)}`,
{ cause: error }
);
}
}
async function waitForMapMenuFocus(page, expectedId) {
try {
await page.waitForFunction(
(id) => {
const focused =
window.__HEROS_DEBUG__?.battle()?.mapMenuKeyboard?.focusedItem;
return (focused?.id ?? focused?.action) === id;
},
expectedId
);
} catch (error) {
const diagnostic = await page.evaluate(() => ({
mapMenuKeyboard:
window.__HEROS_DEBUG__?.battle()?.mapMenuKeyboard ?? null,
keyboardInputs:
window.__HEROS_KEYBOARD_COMMAND_QA__?.keyboardInputs ?? []
}));
throw new Error(
`Timed out waiting for map-menu focus ${expectedId}: ${JSON.stringify(diagnostic)}`,
{ cause: error }
);
}
}
async function focusMapMenuItem(page, expectedId) {
for (let attempt = 0; attempt < 24; attempt += 1) {
const keyboard = await page.evaluate(
() => window.__HEROS_DEBUG__?.battle()?.mapMenuKeyboard
);
assertMapMenuFocus(keyboard);
if (mapMenuItemId(keyboard.focusedItem) === expectedId) {
return;
}
const target = keyboard.items.find(
(item) => mapMenuItemId(item) === expectedId
);
assert(
target?.available === true,
`Expected ${expectedId} to be keyboard-selectable in the map menu: ${JSON.stringify(keyboard)}`
);
await page.keyboard.press('Tab');
await page.waitForTimeout(20);
}
throw new Error(`Could not focus map-menu item ${expectedId}.`);
}
function assertMapMenuFocus(keyboard, expectedId) {
const focusedId = mapMenuItemId(keyboard?.focusedItem);
const matchingItem = keyboard?.items?.find(
(item) => mapMenuItemId(item) === focusedId
);
const guidanceText = keyboard?.guidance?.text ?? '';
assert(
keyboard?.visible === true &&
keyboard.bounds &&
boundsInsideViewport(keyboard.bounds) &&
Array.isArray(keyboard.items) &&
keyboard.items.length >= 4 &&
keyboard.items.every(
(item) =>
typeof mapMenuItemId(item) === 'string' &&
typeof item.available === 'boolean' &&
item.bounds &&
boundsInside(item.bounds, keyboard.bounds) &&
boundsInsideViewport(item.bounds)
) &&
keyboard.items.some(({ available }) => available === false) &&
keyboard.focusedItem?.available === true &&
matchingItem?.focused === true &&
keyboard.items.filter(({ focused }) => focused).length === 1 &&
keyboard.focusedItem.bounds &&
boundsInsideViewport(keyboard.focusedItem.bounds) &&
(!expectedId || focusedId === expectedId) &&
guidanceText.includes('M') &&
guidanceText.includes('Tab') &&
guidanceText.includes('Enter') &&
guidanceText.includes('Esc') &&
keyboard.guidance?.bounds &&
boundsInsideViewport(keyboard.guidance.bounds),
`Expected a visible map menu with one enabled keyboard focus, desktop-safe bounds, and M/Tab/Enter/Esc guidance${expectedId ? ` on ${expectedId}` : ''}: ${JSON.stringify(keyboard)}`
);
}
function mapMenuItemId(item) {
return item?.id ?? item?.action ?? null;
}
async function focusBattleCursorAt(
page,
expectedTile,