feat: make guidance recoverable across camp and battle
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user