feat: guide early stays and remember player choices

This commit is contained in:
2026-07-27 19:41:10 +09:00
parent eec4896002
commit c22f77b4e1
11 changed files with 2121 additions and 93 deletions

View File

@@ -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) {