feat: guide early stays and remember player choices
This commit is contained in:
@@ -49,6 +49,8 @@ try {
|
||||
'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 all-acted prompt keeps Enter-to-end, early manual turn end discloses unacted allies and overflow threats, ' +
|
||||
'safe Enter/Esc cancellation requires explicit keyboard focus before abandoning actions, ' +
|
||||
'the persistent turn-end action reopens it, and the right-click menu follows the pointer.'
|
||||
);
|
||||
} finally {
|
||||
@@ -440,6 +442,15 @@ async function verifyBattlePointerFlow(page) {
|
||||
battle?.actedUnitIds?.includes(unitId)
|
||||
);
|
||||
}, lastAllySetup.unitId);
|
||||
const automaticTurnPrompt = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.turnPrompt);
|
||||
assert(
|
||||
automaticTurnPrompt?.title === '모든 아군의 행동이 종료되었습니다.' &&
|
||||
automaticTurnPrompt.focusedAction === 'primary' &&
|
||||
automaticTurnPrompt.cancelAction === 'secondary' &&
|
||||
automaticTurnPrompt.buttons?.find((button) => button.id === 'primary')?.meaning === 'end-turn' &&
|
||||
automaticTurnPrompt.buttons?.find((button) => button.id === 'secondary')?.meaning === 'review-battlefield',
|
||||
`The all-acted automatic prompt must retain Enter-to-end and Esc-to-review semantics: ${JSON.stringify(automaticTurnPrompt)}`
|
||||
);
|
||||
|
||||
await clickTurnPromptSecondary(page);
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false);
|
||||
@@ -457,8 +468,43 @@ async function verifyBattlePointerFlow(page) {
|
||||
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);
|
||||
await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
window.__HEROS_INTERACTION_UX__ ??= {};
|
||||
window.__HEROS_INTERACTION_UX__.originalAutomaticRunEnemyTurn = scene.runEnemyTurn;
|
||||
window.__HEROS_INTERACTION_UX__.automaticTurnEnemyRunCount = 0;
|
||||
scene.runEnemyTurn = async () => {
|
||||
window.__HEROS_INTERACTION_UX__.automaticTurnEnemyRunCount += 1;
|
||||
};
|
||||
});
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => (
|
||||
window.__HEROS_DEBUG__?.battle()?.activeFaction === 'enemy' &&
|
||||
window.__HEROS_INTERACTION_UX__?.automaticTurnEnemyRunCount === 1
|
||||
));
|
||||
const automaticEnterResult = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
const interaction = window.__HEROS_INTERACTION_UX__;
|
||||
scene.runEnemyTurn = interaction.originalAutomaticRunEnemyTurn;
|
||||
delete interaction.originalAutomaticRunEnemyTurn;
|
||||
scene.activeFaction = 'ally';
|
||||
scene.phase = 'idle';
|
||||
scene.suppressNextLeftClick = false;
|
||||
scene.refreshEnemyIntentForecast();
|
||||
scene.refreshUnitLegibilityStyles();
|
||||
scene.updateTurnText();
|
||||
scene.renderSituationPanel('자동 턴 종료 Enter 검증 완료');
|
||||
return {
|
||||
enemyRunCount: interaction.automaticTurnEnemyRunCount,
|
||||
battle: window.__HEROS_DEBUG__?.battle()
|
||||
};
|
||||
});
|
||||
assert(
|
||||
automaticEnterResult.enemyRunCount === 1 &&
|
||||
automaticEnterResult.battle?.activeFaction === 'ally' &&
|
||||
automaticEnterResult.battle.turnPromptVisible === false,
|
||||
`Enter on the all-acted automatic prompt must still end the allied turn: ${JSON.stringify(automaticEnterResult)}`
|
||||
);
|
||||
|
||||
const mapMenuPointer = await findRightmostEmptyBattleTile(page);
|
||||
await page.mouse.click(mapMenuPointer.x, mapMenuPointer.y, { button: 'right' });
|
||||
@@ -567,6 +613,233 @@ async function verifyBattlePointerFlow(page) {
|
||||
}
|
||||
})}`
|
||||
);
|
||||
await verifyEarlyTurnEndSafety(page);
|
||||
}
|
||||
|
||||
async function verifyEarlyTurnEndSafety(page) {
|
||||
const setup = 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' && unit.hp > 0);
|
||||
if (!scene || allies.length < 1 || enemies.length < 3) {
|
||||
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();
|
||||
scene.clearEnemyIntentForecast();
|
||||
|
||||
const threatTargets = Array.from({ length: 3 }, (_, index) => {
|
||||
const ally = allies[index];
|
||||
return ally ?? {
|
||||
...allies[0],
|
||||
id: `qa-overflow-threat-target-${index + 1}`,
|
||||
name: `추가 위협 대상 ${index + 1}`
|
||||
};
|
||||
});
|
||||
threatTargets.forEach((ally, index) => {
|
||||
const enemy = enemies[index];
|
||||
const plan = scene.buildEnemyActionPlan(enemy);
|
||||
scene.enemyIntentForecasts.set(enemy.id, {
|
||||
...plan,
|
||||
kind: 'attack',
|
||||
target: ally,
|
||||
destination: { x: enemy.x, y: enemy.y },
|
||||
preview: {
|
||||
...(plan.preview ?? {}),
|
||||
damage: Math.max(1, plan.preview?.damage ?? 1),
|
||||
hitRate: plan.preview?.hitRate ?? 100,
|
||||
criticalRate: plan.preview?.criticalRate ?? 0
|
||||
}
|
||||
});
|
||||
});
|
||||
scene.renderSituationPanel('조기 턴 종료 안전성 검증');
|
||||
|
||||
window.__HEROS_INTERACTION_UX__ ??= {};
|
||||
window.__HEROS_INTERACTION_UX__.originalRunEnemyTurn = scene.runEnemyTurn;
|
||||
window.__HEROS_INTERACTION_UX__.earlyTurnEnemyRunCount = 0;
|
||||
scene.runEnemyTurn = async () => {
|
||||
window.__HEROS_INTERACTION_UX__.earlyTurnEnemyRunCount += 1;
|
||||
};
|
||||
|
||||
return {
|
||||
allyIds: allies.map((unit) => unit.id),
|
||||
allyNames: allies.map((unit) => unit.name),
|
||||
forcedThreatTargetCount: new Set(
|
||||
Array.from(scene.enemyIntentForecasts.values()).map((plan) => plan.target?.id).filter(Boolean)
|
||||
).size
|
||||
};
|
||||
});
|
||||
assert(
|
||||
setup?.allyIds.length >= 1 && setup.forcedThreatTargetCount === 3,
|
||||
`Expected unacted allies with three distinct forced threats: ${JSON.stringify(setup)}`
|
||||
);
|
||||
|
||||
const initialPrompt = await openManualTurnEndPrompt(page);
|
||||
assertEarlyTurnPrompt(initialPrompt, setup, 'initial');
|
||||
await page.screenshot({ path: 'dist/verification-interaction-ux-early-turn-end.png', fullPage: true });
|
||||
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false);
|
||||
const afterSafeEnter = await page.evaluate(() => ({
|
||||
battle: window.__HEROS_DEBUG__?.battle(),
|
||||
enemyRunCount: window.__HEROS_INTERACTION_UX__?.earlyTurnEnemyRunCount ?? -1
|
||||
}));
|
||||
assert(
|
||||
afterSafeEnter.battle?.activeFaction === 'ally' &&
|
||||
afterSafeEnter.battle.actedUnitIds?.length === 0 &&
|
||||
afterSafeEnter.enemyRunCount === 0,
|
||||
`Enter on the safe default must only close the early turn-end prompt: ${JSON.stringify(afterSafeEnter)}`
|
||||
);
|
||||
|
||||
await openManualTurnEndPrompt(page);
|
||||
await page.keyboard.press('Escape');
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === false);
|
||||
const afterEscape = await page.evaluate(() => ({
|
||||
battle: window.__HEROS_DEBUG__?.battle(),
|
||||
enemyRunCount: window.__HEROS_INTERACTION_UX__?.earlyTurnEnemyRunCount ?? -1
|
||||
}));
|
||||
assert(
|
||||
afterEscape.battle?.activeFaction === 'ally' && afterEscape.enemyRunCount === 0,
|
||||
`Escape must always cancel early turn end: ${JSON.stringify(afterEscape)}`
|
||||
);
|
||||
|
||||
await openManualTurnEndPrompt(page);
|
||||
await page.keyboard.press('ArrowRight');
|
||||
const afterRight = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.turnPrompt);
|
||||
assert(
|
||||
afterRight?.focusedAction === 'secondary' &&
|
||||
afterRight.buttons?.find((button) => button.id === 'secondary')?.dangerous === true,
|
||||
`ArrowRight must explicitly focus the dangerous early turn-end action: ${JSON.stringify(afterRight)}`
|
||||
);
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
const afterLeft = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.turnPrompt);
|
||||
assert(
|
||||
afterLeft?.focusedAction === 'primary',
|
||||
`ArrowLeft must restore the safe early turn-end action: ${JSON.stringify(afterLeft)}`
|
||||
);
|
||||
await page.keyboard.press('Tab');
|
||||
const afterTab = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.turnPrompt);
|
||||
assert(
|
||||
afterTab?.focusedAction === 'secondary',
|
||||
`Tab must explicitly focus the dangerous early turn-end action: ${JSON.stringify(afterTab)}`
|
||||
);
|
||||
// Let Phaser observe the keyup and render the new focus before the next synthetic key press.
|
||||
// Real keyboard input naturally has this spacing; without it Chromium can rarely coalesce the sequence.
|
||||
await page.waitForTimeout(80);
|
||||
await page.keyboard.press('Enter');
|
||||
try {
|
||||
await page.waitForFunction(() => (
|
||||
window.__HEROS_DEBUG__?.battle()?.activeFaction === 'enemy' &&
|
||||
window.__HEROS_INTERACTION_UX__?.earlyTurnEnemyRunCount === 1
|
||||
), undefined, { timeout: 5000 });
|
||||
} catch (error) {
|
||||
const stalledState = await page.evaluate(() => {
|
||||
const battle = window.__HEROS_DEBUG__?.battle();
|
||||
return {
|
||||
activeElement: document.activeElement?.tagName ?? null,
|
||||
documentHasFocus: document.hasFocus(),
|
||||
activeFaction: battle?.activeFaction ?? null,
|
||||
phase: battle?.phase ?? null,
|
||||
battleOutcome: battle?.battleOutcome ?? null,
|
||||
turnPromptVisible: battle?.turnPromptVisible ?? null,
|
||||
turnPrompt: battle?.turnPrompt ?? null,
|
||||
battleEventVisible: battle?.battleEvent?.visible ?? null,
|
||||
saveSlotPanelMode: battle?.saveSlotPanelMode ?? null,
|
||||
tacticalCommandCutInVisible: battle?.tacticalCommandCutIn?.visible ?? null,
|
||||
enemyRunCount: window.__HEROS_INTERACTION_UX__?.earlyTurnEnemyRunCount ?? -1
|
||||
};
|
||||
});
|
||||
throw new Error(
|
||||
`The focused dangerous early turn-end action stalled after Enter: ${JSON.stringify(stalledState)}`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
|
||||
const confirmed = await page.evaluate(() => {
|
||||
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
|
||||
const interaction = window.__HEROS_INTERACTION_UX__;
|
||||
if (scene && interaction?.originalRunEnemyTurn) {
|
||||
scene.runEnemyTurn = interaction.originalRunEnemyTurn;
|
||||
delete interaction.originalRunEnemyTurn;
|
||||
scene.activeFaction = 'ally';
|
||||
scene.phase = 'idle';
|
||||
scene.suppressNextLeftClick = false;
|
||||
scene.hideTurnEndPrompt();
|
||||
scene.refreshEnemyIntentForecast();
|
||||
scene.refreshUnitLegibilityStyles();
|
||||
scene.updateTurnText();
|
||||
scene.renderSituationPanel('조기 턴 종료 안전성 검증 완료');
|
||||
}
|
||||
return {
|
||||
enemyRunCount: interaction?.earlyTurnEnemyRunCount ?? -1,
|
||||
battle: window.__HEROS_DEBUG__?.battle()
|
||||
};
|
||||
});
|
||||
assert(
|
||||
confirmed.enemyRunCount === 1 &&
|
||||
confirmed.battle?.activeFaction === 'ally' &&
|
||||
confirmed.battle.turnPromptVisible === false,
|
||||
`The explicitly selected dangerous action did not enter and cleanly restore the enemy turn: ${JSON.stringify(confirmed)}`
|
||||
);
|
||||
}
|
||||
|
||||
async function openManualTurnEndPrompt(page) {
|
||||
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;
|
||||
});
|
||||
const endTurnActionIndex = mapMenu?.actions.indexOf('endTurn') ?? -1;
|
||||
assert(mapMenu && endTurnActionIndex >= 0, `Expected an enabled manual turn-end action: ${JSON.stringify(mapMenu)}`);
|
||||
await clickSceneBounds(page, 'BattleScene', {
|
||||
x: mapMenu.left,
|
||||
y: mapMenu.top + mapMenu.padding + endTurnActionIndex * mapMenu.buttonHeight,
|
||||
width: mapMenu.width,
|
||||
height: mapMenu.buttonHeight
|
||||
});
|
||||
await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === true);
|
||||
return page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.turnPrompt);
|
||||
}
|
||||
|
||||
function assertEarlyTurnPrompt(prompt, setup, stage) {
|
||||
const primary = prompt?.buttons?.find((button) => button.id === 'primary');
|
||||
const secondary = prompt?.buttons?.find((button) => button.id === 'secondary');
|
||||
assert(
|
||||
prompt?.title.includes(`미행동 ${setup.allyIds.length}명`) &&
|
||||
setup.allyNames.every((name) => prompt.body.includes(name)) &&
|
||||
prompt.body.includes('외 1명') &&
|
||||
prompt.focusedAction === 'primary' &&
|
||||
prompt.cancelAction === 'primary' &&
|
||||
primary?.label === '계속 지휘' &&
|
||||
primary.meaning === 'continue-command' &&
|
||||
primary.dangerous === false &&
|
||||
primary.focused === true &&
|
||||
primary.escapeCancels === true &&
|
||||
secondary?.label === '미행동 포기 후 종료' &&
|
||||
secondary.meaning === 'abandon-unacted-and-end' &&
|
||||
secondary.dangerous === true &&
|
||||
secondary.focused === false &&
|
||||
secondary.escapeCancels === false,
|
||||
`Early turn-end prompt did not disclose unacted allies, overflow threats, or safe focus (${stage}): ${JSON.stringify({
|
||||
setup,
|
||||
prompt
|
||||
})}`
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyBattleEventOverlayInputBlock(page) {
|
||||
|
||||
Reference in New Issue
Block a user