feat: surface turn-end risk forecasts

This commit is contained in:
2026-07-28 07:27:21 +09:00
parent 7e06ffb9f5
commit a67cfbfbe3
5 changed files with 1429 additions and 68 deletions

View File

@@ -292,10 +292,35 @@ RPG로 다듬는다.
남겼다. 결과→후일담→군영→장부 역할은 정적 계약과 1920x1080 브라우저
회귀 검증으로 고정한다.
다음 묶음은 조기 턴 종료 확인창에 적의 대상별 공격 수뿐 아니라 예상 피해와
명중 위험을 함께 요약해 마지막 결정에 필요한 정보를 보존한다. 그 뒤에는
### 완료한 15차 묶음: 끝내기 전에 결과를 읽는 턴 종료 위험 예보
- 모든 적 의도와 표적·공격 유형을 행동 전에 공개해 대응 자체를 전술로 만든
[Into the Breach GDC 포스트모템](https://media.gdcvault.com/gdc2019/presentations/Into%20the%20Breach%20Postmortem%20Final.pdf)과,
HP·피해·명중·치명 정보를 분리해 선택 전에 비교하게 하는
[Nintendo의 Fire Emblem: Radiant Dawn 공식 전술 안내](https://www.nintendo.com/en-gb/Games/Wii/Fire-Emblem-Radiant-Dawn-281265.html)를
턴 종료 위험창의 정보 구조 기준으로 삼았다.
- 되돌릴 수 없는 행동 전에 검토·취소 기회를 주라는
[Xbox XAG 115](https://learn.microsoft.com/en-us/xbox/accessibility/xbox-accessibility-guidelines/115)와,
색상 신호를 텍스트·아이콘 같은 추가 단서로 반복하라는
[Xbox XAG 103](https://learn.microsoft.com/en-us/xbox/accessibility/xbox-accessibility-guidelines/103)을
확인 흐름과 위험 표현의 접근성 기준으로 적용했다.
- 턴 종료 위험창은 위험 대상별 카드를 최대 2개까지 보여 준다. 각 카드에는
공격 횟수·기대 피해·최대 피해·명중률 범위·현재 HP를 함께 배치하고,
`집중 공격``격파 가능`을 색상뿐 아니라 명시적인 텍스트로 표시한다.
나머지 대상은 `외 N명`으로 요약해 창의 정보 밀도를 제한한다.
- 미행동 장수가 남은 조기 종료에서는 `계속 지휘`에 안전 기본 포커스를 두고,
미행동 포기와 턴 종료는 별도의 위험 행동으로 남긴다. 마지막 장수의 이동만
끝났을 때는 확인창을 자동으로 열지 않는다. 모든 아군의 명령까지 끝난 정상
확인과 플레이어가 직접 요청한 조기 종료 모두 현재 위치를 기준으로 위험을
다시 계산해 보여 준다.
- 대상 카드·초과 요약·안전 기본 포커스·텍스트 위험 경고가 잘리지 않고 실제
전투 예보와 일치하는지를 Canvas와 WebGL 모두 1920x1080 CSS viewport,
100% 확대, DPR 1에서 자동 검증하는 계약으로 고정한다.
다음 묶음은
[Xbox 목표 명료성 지침](https://learn.microsoft.com/en-us/xbox/accessibility/xbox-accessibility-guidelines/109)에
맞춰 현재 목표완료 목표의 재확인 위치를 캠페인 전반에서 다시 감사한다.
맞춰 현재 목표·완료 목표·다음 목표를 어디에서 다시 확인할 수 있는지
캠페인 전반에서 감사한다.
## 장기 개선 순서

View File

@@ -81,11 +81,12 @@
"verify:flow:monolith": "node scripts/verify-flow.mjs",
"verify:flow:segment": "node scripts/verify-flow.mjs",
"verify:interaction-ux": "node scripts/verify-interaction-ux.mjs",
"verify:turn-end-risk:browser": "node scripts/verify-turn-end-risk-browser.mjs",
"verify:battle-keyboard:browser": "node scripts/verify-battle-keyboard-command-flow-browser.mjs",
"verify:title-accessibility:browser": "node scripts/verify-title-accessibility-browser.mjs",
"verify:save-flow": "node scripts/verify-save-retry-flow.mjs",
"verify:release": "node scripts/verify-release-candidate.mjs",
"verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:title-accessibility:browser && pnpm run verify:battle-keyboard:browser && pnpm run verify:city-stays:browser && pnpm run verify:xuzhou-equipment-link:browser && pnpm run verify:xuzhou-stay-payoff:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:third-camp-preparation:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance",
"verify:local-release": "pnpm run verify:static-data && pnpm run verify:unit-action-assets && pnpm run verify:enemy-intent && tsc --noEmit && pnpm run build && pnpm run verify:unit-action-assets:dist && pnpm run verify:loader-lifecycle && pnpm run verify:interaction-ux && pnpm run verify:turn-end-risk:browser && pnpm run verify:title-accessibility:browser && pnpm run verify:battle-keyboard:browser && pnpm run verify:city-stays:browser && pnpm run verify:xuzhou-equipment-link:browser && pnpm run verify:xuzhou-stay-payoff:browser && pnpm run verify:prologue-village:browser && pnpm run verify:first-pursuit-camp:browser && pnpm run verify:second-battle-relief:browser && pnpm run verify:third-battle-return:browser && pnpm run verify:third-camp-preparation:browser && pnpm run verify:av-feedback:browser && pnpm run verify:release && pnpm run verify:performance",
"verify:1.0": "pnpm run verify:local-release && pnpm run verify:flow && pnpm run qa:campaign-complete && pnpm run qa:battle-maps:webgl",
"verify:public-deploy": "node scripts/verify-public-deploy.mjs",
"qa:representative": "node scripts/qa-representative-battles.mjs",

View File

@@ -447,6 +447,11 @@ async function verifyBattlePointerFlow(page) {
automaticTurnPrompt?.title === '모든 아군의 행동이 종료되었습니다.' &&
automaticTurnPrompt.focusedAction === 'primary' &&
automaticTurnPrompt.cancelAction === 'secondary' &&
automaticTurnPrompt.riskForecast?.targetCount > 0 &&
automaticTurnPrompt.riskRows?.length === Math.min(
2,
automaticTurnPrompt.riskForecast.targetCount
) &&
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)}`
@@ -641,27 +646,45 @@ async function verifyEarlyTurnEndSafety(page) {
scene.actedUnitIds.clear();
scene.clearEnemyIntentForecast();
const threatTargets = Array.from({ length: 3 }, (_, index) => {
const ally = allies[index];
return ally ?? {
const threatTargets = Array.from({ length: 3 }, (_, index) => (
allies[index] ?? {
...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, {
}
));
const forcedAttacks = [
{ enemy: enemies[0], target: threatTargets[0], damage: 12, hitRate: 60, criticalRate: 0 },
{ enemy: enemies[1], target: threatTargets[0], damage: 18, hitRate: 90, criticalRate: 20 },
{ enemy: enemies[2], target: threatTargets[1], damage: 10, hitRate: 75, criticalRate: 0 },
{
enemy: {
...enemies[0],
id: `qa-overflow-attacker-${enemies[0].id}`,
name: '추가 위험 검증 적군'
},
planEnemy: enemies[0],
target: threatTargets[2],
damage: 8,
hitRate: 50,
criticalRate: 0
}
];
forcedAttacks.forEach((attack) => {
const plan = scene.buildEnemyActionPlan(attack.planEnemy ?? attack.enemy);
scene.enemyIntentForecasts.set(attack.enemy.id, {
...plan,
enemy: attack.enemy,
kind: 'attack',
target: ally,
destination: { x: enemy.x, y: enemy.y },
target: attack.target,
approachTarget: attack.target,
usable: undefined,
destination: { x: attack.enemy.x, y: attack.enemy.y },
preview: {
...(plan.preview ?? {}),
damage: Math.max(1, plan.preview?.damage ?? 1),
hitRate: plan.preview?.hitRate ?? 100,
criticalRate: plan.preview?.criticalRate ?? 0
damage: attack.damage,
hitRate: attack.hitRate,
criticalRate: attack.criticalRate
}
});
});
@@ -674,17 +697,61 @@ async function verifyEarlyTurnEndSafety(page) {
window.__HEROS_INTERACTION_UX__.earlyTurnEnemyRunCount += 1;
};
const expectedGroups = threatTargets.map((target) => {
const attacks = forcedAttacks.filter((attack) => attack.target.id === target.id);
const expectedDamage = Math.round(attacks.reduce(
(total, attack) => total +
attack.damage * attack.hitRate / 100 * (1 + attack.criticalRate / 200),
0
));
const rawDamage = attacks.reduce((total, attack) => total + attack.damage, 0);
const maximumDamage = attacks.reduce(
(total, attack) => total + (
attack.criticalRate > 0 ? Math.round(attack.damage * 1.5) : attack.damage
),
0
);
return {
targetId: target.id,
targetName: target.name,
hp: target.hp,
immediateCount: attacks.length,
approachCount: 0,
expectedDamage,
rawDamage,
maximumDamage,
lethal: maximumDamage >= target.hp,
hitRateMin: Math.min(...attacks.map((attack) => attack.hitRate)),
hitRateMax: Math.max(...attacks.map((attack) => attack.hitRate)),
concentrated: attacks.length > 1,
attackCount: attacks.length
};
}).sort((left, right) => (
Number(right.lethal) - Number(left.lethal) ||
right.expectedDamage / Math.max(1, right.hp) - left.expectedDamage / Math.max(1, left.hp) ||
right.expectedDamage - left.expectedDamage ||
right.immediateCount - left.immediateCount ||
right.approachCount - left.approachCount ||
left.targetName.localeCompare(right.targetName, 'ko')
));
return {
allyIds: allies.map((unit) => unit.id),
allyNames: allies.map((unit) => unit.name),
forcedAttackCount: forcedAttacks.length,
forcedThreatTargetCount: new Set(
Array.from(scene.enemyIntentForecasts.values()).map((plan) => plan.target?.id).filter(Boolean)
).size
).size,
expectedGroups,
expectedVisibleGroupIds: expectedGroups.slice(0, 2).map((group) => group.targetId),
expectedDamage: expectedGroups.reduce((total, group) => total + group.expectedDamage, 0),
expectedLethalTargetCount: expectedGroups.filter((group) => group.lethal).length
};
});
assert(
setup?.allyIds.length >= 1 && setup.forcedThreatTargetCount === 3,
`Expected unacted allies with three distinct forced threats: ${JSON.stringify(setup)}`
setup?.allyIds.length >= 1 &&
setup.forcedAttackCount === 4 &&
setup.forcedThreatTargetCount === 3,
`Expected four attacks concentrated across three distinct forced threats: ${JSON.stringify(setup)}`
);
const initialPrompt = await openManualTurnEndPrompt(page);
@@ -819,10 +886,36 @@ async function openManualTurnEndPrompt(page) {
function assertEarlyTurnPrompt(prompt, setup, stage) {
const primary = prompt?.buttons?.find((button) => button.id === 'primary');
const secondary = prompt?.buttons?.find((button) => button.id === 'secondary');
const forecast = prompt?.riskForecast;
const groupContractsValid = setup.expectedGroups.every((expected) => {
const actual = forecast?.groups?.find((group) => group.targetId === expected.targetId);
return (
actual?.targetName === expected.targetName &&
actual.hp === expected.hp &&
actual.immediateCount === expected.immediateCount &&
actual.approachCount === expected.approachCount &&
actual.expectedDamage === expected.expectedDamage &&
actual.rawDamage === expected.rawDamage &&
actual.maximumDamage === expected.maximumDamage &&
actual.lethal === expected.lethal &&
actual.hitRateMin === expected.hitRateMin &&
actual.hitRateMax === expected.hitRateMax &&
actual.concentrated === expected.concentrated &&
actual.attacks?.length === expected.attackCount
);
});
assert(
prompt?.title.includes(`미행동 ${setup.allyIds.length}`) &&
setup.allyNames.every((name) => prompt.body.includes(name)) &&
prompt.body.includes('외 1명') &&
forecast?.immediateAttackCount === setup.forcedAttackCount &&
forecast.approachCount === 0 &&
forecast.targetCount === setup.forcedThreatTargetCount &&
forecast.expectedDamage === setup.expectedDamage &&
forecast.lethalTargetCount === setup.expectedLethalTargetCount &&
forecast.omittedTargetCount === 1 &&
sameStrings(forecast.visibleGroupIds, setup.expectedVisibleGroupIds) &&
forecast.groups?.length === setup.expectedGroups.length &&
groupContractsValid &&
prompt.focusedAction === 'primary' &&
prompt.cancelAction === 'primary' &&
primary?.label === '계속 지휘' &&

View File

@@ -0,0 +1,855 @@
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { mkdirSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import {
desktopBrowserContextOptions,
desktopBrowserDeviceScaleFactor,
desktopBrowserViewport,
desktopBrowserViewportBounds
} from './desktop-browser-viewport.mjs';
const battleId = 'second-battle-yellow-turban-pursuit';
const renderer = process.env.VERIFY_TURN_END_RISK_RENDERER;
const rendererNames = ['canvas', 'webgl'];
if (!renderer) {
for (const requestedRenderer of rendererNames) {
const result = spawnSync(
process.execPath,
[fileURLToPath(import.meta.url)],
{
cwd: process.cwd(),
env: {
...process.env,
VERIFY_TURN_END_RISK_RENDERER: requestedRenderer
},
stdio: 'inherit'
}
);
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
process.exit(0);
}
assert(
rendererNames.includes(renderer),
`Unsupported turn-end risk renderer "${renderer}".`
);
const baseUrl =
process.env.VERIFY_TURN_END_RISK_URL ??
'http://127.0.0.1:41811/heros_web/';
const targetUrl = withDebugOptions(baseUrl, renderer);
const screenshotPath =
`dist/verification-turn-end-risk-${renderer}-1920x1080.png`;
const forcedAttacks = Object.freeze([
{ targetIndex: 0, damage: 16, hitRate: 60, criticalRate: 0 },
{ targetIndex: 0, damage: 24, hitRate: 90, criticalRate: 20 },
{ targetIndex: 1, damage: 10, hitRate: 75, criticalRate: 0 },
{ targetIndex: 2, damage: 8, hitRate: 50, criticalRate: 0 }
]);
let serverProcess;
let browser;
try {
mkdirSync('dist', { recursive: true });
serverProcess = await ensureLocalServer(targetUrl);
browser = await chromium.launch({
headless: process.env.VERIFY_TURN_END_RISK_HEADLESS !== '0',
args: renderer === 'webgl'
? ['--use-angle=swiftshader', '--enable-unsafe-swiftshader']
: []
});
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.addInitScript(() => window.localStorage.clear());
await page.goto(targetUrl, {
waitUntil: 'domcontentloaded',
timeout: 90000
});
await waitForBattleReady(page);
const runtime = await readRuntimeProbe(page);
assertRuntimeBaseline(runtime, renderer);
await startDeploymentIfNeeded(page);
const setup = await installTurnEndRiskFixture(page);
assert.equal(setup.allyIds.length >= 3, true);
assert.equal(setup.forcedAttackCount, 4);
assert.equal(setup.forcedThreatTargetCount, 3);
assert.deepEqual(
setup.expectedVisibleGroupIds,
setup.expectedGroups.slice(0, 2).map((group) => group.targetId)
);
const prompt = await openManualTurnEndPrompt(page);
assertRiskForecast(prompt, setup, renderer);
assertPromptLayout(prompt, renderer);
const screenshot = await page.screenshot({
path: screenshotPath,
fullPage: true
});
assert(
screenshot.byteLength >= 100_000,
`Expected a painted ${renderer} turn-end risk screenshot: ${screenshot.byteLength} bytes.`
);
await page.keyboard.press('Enter');
await page.waitForFunction(() => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
battle?.turnPromptVisible === false &&
battle.activeFaction === 'ally'
);
});
const safeReturn = await page.evaluate(() =>
window.__HEROS_DEBUG__?.battle()
);
assert.equal(
safeReturn?.actedUnitIds?.length,
0,
`${renderer}: safe default must return to command without abandoning unacted allies.`
);
assert.deepEqual(
pageErrors,
[],
`${renderer}: unexpected page errors: ${pageErrors.join(' | ')}`
);
assert.deepEqual(
consoleErrors,
[],
`${renderer}: unexpected console errors: ${consoleErrors.join(' | ')}`
);
console.log(
`Verified ${renderer} early turn-end risk forecast at ` +
`${desktopBrowserViewport.width}x${desktopBrowserViewport.height}, ` +
`100% zoom, DPR ${desktopBrowserDeviceScaleFactor}; captured ${screenshotPath}.`
);
} 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('debugBattle', battleId);
parsed.searchParams.set('debugCombatAssetWatchdogMs', '250');
return parsed.toString();
}
async function waitForBattleReady(page) {
await page.waitForFunction(
(expectedBattleId) => {
const battle = window.__HEROS_DEBUG__?.battle?.();
const activeScenes = window.__HEROS_DEBUG__?.activeScenes?.() ?? [];
return (
window.__HEROS_GAME__ !== undefined &&
battle?.battleId === expectedBattleId &&
['deployment', 'idle'].includes(battle.phase) &&
battle.mapBackgroundReady === true &&
(
battle.phase === 'idle' ||
['ready', 'degraded'].includes(battle.combatAssets?.status)
) &&
activeScenes.includes('BattleScene')
);
},
battleId,
{ timeout: 90000 }
);
await page.evaluate(() => new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(resolve));
}));
}
async function startDeploymentIfNeeded(page) {
const state = await page.evaluate(() => window.__HEROS_DEBUG__?.battle());
if (state?.phase === 'idle') {
return;
}
const startButtonBounds =
state?.battleHud?.deploymentPanel?.startButtonBounds;
assert(
state?.battleId === battleId &&
state.phase === 'deployment' &&
startButtonBounds,
`Expected ${battleId} deployment controls: ${JSON.stringify(state)}`
);
await clickSceneBounds(page, startButtonBounds);
await page.waitForFunction(
(expectedBattleId) => {
const battle = window.__HEROS_DEBUG__?.battle();
return (
battle?.battleId === expectedBattleId &&
battle.phase === 'idle' &&
battle.battleHud?.deploymentPanel === null
);
},
battleId,
{ timeout: 90000 }
);
}
async function installTurnEndRiskFixture(page) {
return page.evaluate((attackDefinitions) => {
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 < 3 || enemies.length < attackDefinitions.length) {
return {
allyIds: [],
forcedAttackCount: 0,
forcedThreatTargetCount: 0,
expectedGroups: [],
expectedVisibleGroupIds: [],
expectedDamage: 0,
expectedLethalTargetCount: 0
};
}
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 attacks = attackDefinitions.map((definition, index) => ({
...definition,
target: allies[definition.targetIndex],
enemy: enemies[index]
}));
attacks.forEach((attack) => {
const plan = scene.buildEnemyActionPlan(attack.enemy);
scene.enemyIntentForecasts.set(attack.enemy.id, {
...plan,
kind: 'attack',
target: attack.target,
approachTarget: attack.target,
usable: undefined,
destination: { x: attack.enemy.x, y: attack.enemy.y },
preview: {
...(plan.preview ?? {}),
damage: attack.damage,
hitRate: attack.hitRate,
criticalRate: attack.criticalRate
}
});
});
scene.renderSituationPanel('조기 턴 종료 위험 요약 검증');
const expectedGroups = allies.slice(0, 3).map((target) => {
const targeted = attacks.filter(
(attack) => attack.target.id === target.id
);
const expectedDamage = Math.round(targeted.reduce(
(total, attack) => total +
attack.damage *
attack.hitRate /
100 *
(1 + attack.criticalRate / 200),
0
));
const rawDamage = targeted.reduce(
(total, attack) => total + attack.damage,
0
);
const maximumDamage = targeted.reduce(
(total, attack) => total + (
attack.criticalRate > 0
? Math.round(attack.damage * 1.5)
: attack.damage
),
0
);
return {
targetId: target.id,
targetName: target.name,
hp: target.hp,
immediateCount: targeted.length,
approachCount: 0,
expectedDamage,
rawDamage,
maximumDamage,
lethal: maximumDamage >= target.hp,
hitRateMin: Math.min(
...targeted.map((attack) => attack.hitRate)
),
hitRateMax: Math.max(
...targeted.map((attack) => attack.hitRate)
),
concentrated: targeted.length > 1,
attackCount: targeted.length
};
});
return {
allyIds: allies.map((unit) => unit.id),
allyNames: allies.map((unit) => unit.name),
forcedAttackCount: attacks.length,
forcedThreatTargetCount: new Set(
attacks.map((attack) => attack.target.id)
).size,
expectedGroups,
expectedVisibleGroupIds:
expectedGroups.slice(0, 2).map((group) => group.targetId),
expectedDamage: expectedGroups.reduce(
(total, group) => total + group.expectedDamage,
0
),
expectedLethalTargetCount:
expectedGroups.filter((group) => group.lethal).length
};
}, forcedAttacks);
}
async function openManualTurnEndPrompt(page) {
const pointer = await findRightmostEmptyBattleTile(page);
await page.mouse.click(pointer.x, pointer.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 endTurnIndex = mapMenu?.actions.indexOf('endTurn') ?? -1;
assert(
mapMenu && endTurnIndex >= 0,
`Expected an enabled manual turn-end action: ${JSON.stringify(mapMenu)}`
);
const keyboardState = await page.evaluate(
() => window.__HEROS_DEBUG__?.battle()?.mapMenuKeyboard
);
assert.equal(
keyboardState?.focusedItem?.action,
'endTurn',
`Expected turn end to be the map menu's safe keyboard default: ${JSON.stringify(keyboardState)}`
);
assert.equal(keyboardState?.focusedItem?.available, true);
await page.keyboard.press('Enter');
await page.waitForFunction(
() => window.__HEROS_DEBUG__?.battle()?.turnPromptVisible === true
);
return page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.turnPrompt);
}
function assertRiskForecast(prompt, setup, requestedRenderer) {
const forecast = prompt?.riskForecast;
const primary = prompt?.buttons?.find((button) => button.id === 'primary');
const secondary =
prompt?.buttons?.find((button) => button.id === 'secondary');
assert.equal(
prompt?.title,
`미행동 ${setup.allyIds.length}명 · 턴 종료 확인`,
`${requestedRenderer}: early turn-end title must disclose every unacted ally.`
);
setup.allyNames.forEach((name) => {
assert(
prompt.body.includes(name),
`${requestedRenderer}: missing unacted ally ${name}: ${prompt.body}`
);
});
assert.equal(forecast?.immediateAttackCount, setup.forcedAttackCount);
assert.equal(forecast?.approachCount, 0);
assert.equal(forecast?.targetCount, setup.forcedThreatTargetCount);
assert.equal(forecast?.expectedDamage, setup.expectedDamage);
assert.equal(
forecast?.lethalTargetCount,
setup.expectedLethalTargetCount
);
assert.equal(forecast?.omittedTargetCount, 1);
assert.deepEqual(
forecast?.visibleGroupIds,
setup.expectedVisibleGroupIds
);
assert.equal(forecast?.groups?.length, setup.expectedGroups.length);
setup.expectedGroups.forEach((expected) => {
const actual = forecast.groups.find(
(group) => group.targetId === expected.targetId
);
const row = prompt.riskRows?.find(
(candidate) => candidate.targetId === expected.targetId
);
assert(actual, `${requestedRenderer}: missing risk group ${expected.targetId}.`);
assert.deepEqual(
{
targetId: actual.targetId,
targetName: actual.targetName,
hp: actual.hp,
immediateCount: actual.immediateCount,
approachCount: actual.approachCount,
expectedDamage: actual.expectedDamage,
rawDamage: actual.rawDamage,
maximumDamage: actual.maximumDamage,
lethal: actual.lethal,
hitRateMin: actual.hitRateMin,
hitRateMax: actual.hitRateMax,
concentrated: actual.concentrated,
attackCount: actual.attacks?.length
},
expected,
`${requestedRenderer}: risk group mismatch for ${expected.targetName}.`
);
if (forecast.visibleGroupIds.includes(expected.targetId)) {
const expectedBadge = expected.concentrated && expected.lethal
? '집중 공격 · 격파 가능'
: expected.lethal
? '격파 가능'
: expected.concentrated
? '집중 공격'
: expected.immediateCount > 0
? '피격 예상'
: '접근 예정';
assert(row, `${requestedRenderer}: missing visible risk row ${expected.targetId}.`);
assert.equal(row.badgeText, expectedBadge);
assert.equal(
row.titleText,
`${expected.targetName} · 현재 HP ${expected.hp}`
);
assert(
!row.titleText.includes('기대 후'),
`${requestedRenderer}: risk row must not imply a deterministic post-turn HP.`
);
assert(
row.metricText.includes(`공격 ${expected.immediateCount}`) &&
row.metricText.includes(`기대 피해 ${expected.expectedDamage}`) &&
row.metricText.includes(`최대 피해 ${expected.maximumDamage}`),
`${requestedRenderer}: incomplete risk metrics for ${expected.targetName}: ${row.metricText}`
);
}
});
assert.deepEqual(
{
focusedAction: prompt.focusedAction,
cancelAction: prompt.cancelAction,
primary: {
label: primary?.label,
meaning: primary?.meaning,
dangerous: primary?.dangerous,
focused: primary?.focused,
escapeCancels: primary?.escapeCancels
},
secondary: {
label: secondary?.label,
meaning: secondary?.meaning,
dangerous: secondary?.dangerous,
focused: secondary?.focused,
escapeCancels: secondary?.escapeCancels
}
},
{
focusedAction: 'primary',
cancelAction: 'primary',
primary: {
label: '계속 지휘',
meaning: 'continue-command',
dangerous: false,
focused: true,
escapeCancels: true
},
secondary: {
label: '미행동 포기 후 종료',
meaning: 'abandon-unacted-and-end',
dangerous: true,
focused: false,
escapeCancels: false
}
},
`${requestedRenderer}: safe focus or explicit danger semantics regressed.`
);
}
function assertPromptLayout(prompt, requestedRenderer) {
const panel = assertFiniteBounds(
prompt?.panelBounds,
`${requestedRenderer} prompt panel`
);
const summary = assertFiniteBounds(
prompt?.summaryBounds,
`${requestedRenderer} prompt summary`
);
const footnote = assertFiniteBounds(
prompt?.footnoteBounds,
`${requestedRenderer} prompt footnote`
);
const rows = prompt?.riskRows ?? [];
const buttons = prompt?.buttons ?? [];
assert.equal(
rows.length,
prompt?.riskForecast?.visibleGroupIds?.length,
`${requestedRenderer}: every visible risk group must have one row.`
);
assert.deepEqual(
rows.map((row) => row.targetId),
prompt.riskForecast.visibleGroupIds,
`${requestedRenderer}: row order must match visible risk priority.`
);
assert.equal(buttons.length, 2, `${requestedRenderer}: expected two prompt buttons.`);
const rowBounds = rows.map((row) => {
const bounds = assertFiniteBounds(
row.bounds,
`${requestedRenderer} risk row ${row.targetId}`
);
const badgeBounds = assertFiniteBounds(
row.badgeBounds,
`${requestedRenderer} risk badge ${row.targetId}`
);
const titleBounds = assertFiniteBounds(
row.titleBounds,
`${requestedRenderer} risk title ${row.targetId}`
);
const metricBounds = assertFiniteBounds(
row.metricBounds,
`${requestedRenderer} risk metrics ${row.targetId}`
);
[badgeBounds, titleBounds, metricBounds].forEach((textBounds, index) => {
assertBoundsContained(
textBounds,
bounds,
`${requestedRenderer} risk row ${row.targetId} text ${index + 1}`
);
});
assert(
!boundsOverlap(titleBounds, badgeBounds),
`${requestedRenderer}: title and warning badge overlap for ${row.targetId}.`
);
assert(
!boundsOverlap(metricBounds, badgeBounds),
`${requestedRenderer}: metrics and warning badge overlap for ${row.targetId}.`
);
assert(
!boundsOverlap(titleBounds, metricBounds),
`${requestedRenderer}: title and metrics overlap for ${row.targetId}.`
);
return bounds;
});
const buttonBounds = buttons.map((button) =>
assertFiniteBounds(
button.bounds,
`${requestedRenderer} prompt button ${button.id}`
)
);
const contentBounds = [summary, ...rowBounds, footnote, ...buttonBounds];
assertBoundsContained(
panel,
desktopBrowserViewportBounds,
`${requestedRenderer} prompt panel in viewport`
);
contentBounds.forEach((bounds, index) => {
assertBoundsContained(
bounds,
panel,
`${requestedRenderer} prompt content ${index + 1} in panel`
);
assertBoundsContained(
bounds,
desktopBrowserViewportBounds,
`${requestedRenderer} prompt content ${index + 1} in viewport`
);
});
for (let leftIndex = 0; leftIndex < contentBounds.length; leftIndex += 1) {
for (
let rightIndex = leftIndex + 1;
rightIndex < contentBounds.length;
rightIndex += 1
) {
assert(
!boundsOverlap(
contentBounds[leftIndex],
contentBounds[rightIndex]
),
`${requestedRenderer}: prompt regions ${leftIndex + 1} and ` +
`${rightIndex + 1} overlap: ${JSON.stringify({
left: contentBounds[leftIndex],
right: contentBounds[rightIndex]
})}`
);
}
}
}
async function readRuntimeProbe(page) {
return page.evaluate(() => {
const game = window.__HEROS_GAME__;
const scene = game?.scene.getScene('BattleScene');
const canvas = document.querySelector('canvas');
const canvasBounds = canvas?.getBoundingClientRect();
return {
viewport: {
width: window.innerWidth,
height: window.innerHeight,
dpr: window.devicePixelRatio,
zoom: window.visualViewport?.scale ?? 1
},
canvas: canvas
? {
width: canvas.width,
height: canvas.height,
bounds: canvasBounds
? {
x: canvasBounds.x,
y: canvasBounds.y,
width: canvasBounds.width,
height: canvasBounds.height
}
: null
}
: null,
renderer: {
requested: new URLSearchParams(window.location.search).get(
'renderer'
),
type: game?.renderer?.type ?? null,
name: game?.renderer?.constructor?.name ?? null
},
scene: scene
? { width: scene.scale.width, height: scene.scale.height }
: null
};
});
}
function assertRuntimeBaseline(runtime, requestedRenderer) {
assert.deepEqual(runtime.viewport, {
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height,
dpr: desktopBrowserDeviceScaleFactor,
zoom: 1
});
assert.deepEqual(runtime.canvas, {
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height,
bounds: {
x: 0,
y: 0,
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height
}
});
assert.deepEqual(runtime.scene, {
width: desktopBrowserViewport.width,
height: desktopBrowserViewport.height
});
assert.equal(runtime.renderer.requested, requestedRenderer);
assert.equal(
runtime.renderer.type,
requestedRenderer === 'canvas' ? 1 : 2,
`Expected ${requestedRenderer} renderer: ${JSON.stringify(runtime.renderer)}`
);
}
async function findRightmostEmptyBattleTile(page) {
const logicalPoint = await page.evaluate(() => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
if (!scene) {
return null;
}
for (
let column = scene.layout.visibleColumns - 1;
column >= 0;
column -= 1
) {
for (let row = 0; row < scene.layout.visibleRows; row += 1) {
const x = scene.cameraTileX + column;
const y = scene.cameraTileY + row;
if (scene.isInBounds(x, y) && !scene.isOccupied(x, y)) {
return {
x: scene.tileCenterX(x),
y: scene.tileCenterY(y)
};
}
}
}
return null;
});
assert(logicalPoint, 'Expected a visible empty battlefield tile.');
return sceneLogicalPoint(page, logicalPoint);
}
async function clickSceneBounds(page, bounds) {
const point = await sceneLogicalPoint(page, {
x: bounds.x + bounds.width / 2,
y: bounds.y + bounds.height / 2
});
await page.mouse.click(point.x, point.y);
}
async function sceneLogicalPoint(page, logicalPoint) {
const point = await page.evaluate((requestedPoint) => {
const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene');
const canvasBounds =
document.querySelector('canvas')?.getBoundingClientRect();
if (!scene || !canvasBounds || !requestedPoint) {
return null;
}
return {
x:
canvasBounds.left +
requestedPoint.x * canvasBounds.width / scene.scale.width,
y:
canvasBounds.top +
requestedPoint.y * canvasBounds.height / scene.scale.height
};
}, logicalPoint);
assert(
point && Number.isFinite(point.x) && Number.isFinite(point.y),
`Expected a finite BattleScene point: ${JSON.stringify({
logicalPoint,
point
})}`
);
return point;
}
function assertFiniteBounds(bounds, label) {
assert(
bounds &&
Number.isFinite(bounds.x) &&
Number.isFinite(bounds.y) &&
Number.isFinite(bounds.width) &&
Number.isFinite(bounds.height) &&
bounds.width > 0 &&
bounds.height > 0,
`Expected finite positive bounds for ${label}: ${JSON.stringify(bounds)}`
);
return bounds;
}
function assertBoundsContained(inner, outer, label) {
const tolerance = 1;
assert(
inner.x >= outer.x - tolerance &&
inner.y >= outer.y - tolerance &&
inner.x + inner.width <= outer.x + outer.width + tolerance &&
inner.y + inner.height <= outer.y + outer.height + tolerance,
`${label} escaped its container: ${JSON.stringify({ inner, outer })}`
);
}
function boundsOverlap(left, right) {
const tolerance = 1;
return (
left.x < right.x + right.width - tolerance &&
left.x + left.width > right.x + tolerance &&
left.y < right.y + right.height - tolerance &&
left.y + left.height > right.y + tolerance
);
}
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 || '41811',
'--strictPort'
],
{
cwd: process.cwd(),
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true
}
);
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)]);
}
}

View File

@@ -60,6 +60,7 @@ import {
import {
buildCombatExchangeProjection,
compareMoveIntentRisk,
summarizeIntentRisk,
type CombatExchangeProjection,
type MoveIntentRiskDelta
} from '../data/battleForecast';
@@ -1673,6 +1674,7 @@ type TurnEndPromptOptions = {
mode?: TurnPromptMode;
title?: string;
body?: string;
riskContext?: string;
primaryLabel?: string;
secondaryLabel?: string;
primaryAction?: () => void;
@@ -1706,6 +1708,49 @@ type TurnPromptButtonView = {
hint: Phaser.GameObjects.Text;
};
type TurnEndRiskAttack = {
enemyId: string;
enemyName: string;
damage: number;
hitRate: number;
criticalRate: number;
};
type TurnEndRiskGroup = {
targetId: string;
targetName: string;
hp: number;
immediateCount: number;
approachCount: number;
expectedDamage: number;
rawDamage: number;
maximumDamage: number;
lethal: boolean;
hitRateMin: number;
hitRateMax: number;
concentrated: boolean;
attacks: TurnEndRiskAttack[];
};
type TurnEndRiskForecast = {
immediateAttackCount: number;
approachCount: number;
targetCount: number;
expectedDamage: number;
lethalTargetCount: number;
omittedTargetCount: number;
visibleGroupIds: string[];
groups: TurnEndRiskGroup[];
};
type TurnEndRiskRowView = {
targetId: string;
background: Phaser.GameObjects.Rectangle;
badgeText: Phaser.GameObjects.Text;
titleText: Phaser.GameObjects.Text;
metricText: Phaser.GameObjects.Text;
};
type DeploymentSlot = {
x: number;
y: number;
@@ -2288,6 +2333,7 @@ type EnemyIntentSnapshot = {
usableId?: string;
damage: number;
hitRate: number;
criticalRate?: number;
};
type IntentCounterplayKind = 'defeat' | 'line-break' | 'guard';
@@ -3946,6 +3992,11 @@ export class BattleScene extends Phaser.Scene {
private turnPromptFocusedAction?: TurnPromptActionId;
private turnPromptCancelActionId?: TurnPromptActionId;
private turnPromptButtonViews: TurnPromptButtonView[] = [];
private turnPromptPanel?: Phaser.GameObjects.Rectangle;
private turnPromptRiskForecast?: TurnEndRiskForecast;
private turnPromptRiskSummaryText?: Phaser.GameObjects.Text;
private turnPromptRiskFootnoteText?: Phaser.GameObjects.Text;
private turnPromptRiskRows: TurnEndRiskRowView[] = [];
private mapResultPopupObjects: Phaser.GameObjects.Text[] = [];
private mapResultPopupPeakCount = 0;
private mapResultPopupLast?: {
@@ -4159,6 +4210,11 @@ export class BattleScene extends Phaser.Scene {
this.turnPromptFocusedAction = undefined;
this.turnPromptCancelActionId = undefined;
this.turnPromptButtonViews = [];
this.turnPromptPanel = undefined;
this.turnPromptRiskForecast = undefined;
this.turnPromptRiskSummaryText = undefined;
this.turnPromptRiskFootnoteText = undefined;
this.turnPromptRiskRows = [];
this.turnText = undefined;
this.battleTitleText = undefined;
this.objectiveTrackerText = undefined;
@@ -11525,43 +11581,129 @@ export class BattleScene extends Phaser.Scene {
});
}
private enemyIntentTurnEndBody() {
private enemyIntentTurnEndRiskForecast(): TurnEndRiskForecast | undefined {
if (!this.enemyIntentForecastEnabled()) {
return '아군 턴을 종료하시겠습니까?';
return undefined;
}
return `${this.enemyIntentTurnEndSummary()}\n이대로 턴을 종료할까요?`;
}
private enemyIntentTurnEndSummary() {
if (!this.enemyIntentForecastEnabled()) {
return '적 예상 정보 없음';
}
const groups = new Map<string, { target: UnitData; immediate: number; approach: number }>();
const plans = this.enemyIntentForecasts.size > 0
? Array.from(this.enemyIntentForecasts.values())
: this.currentEnemyActionPlans();
const snapshots = this.enemyIntentSnapshotsFromPlans(plans);
const plansByTarget = new Map<string, { target: UnitData; plans: EnemyActionPlan[] }>();
plans.forEach((plan) => {
if (!plan.target || plan.kind === 'wait') {
if (!plan.target || plan.target.faction !== 'ally' || plan.kind === 'wait') {
return;
}
const group = groups.get(plan.target.id) ?? { target: plan.target, immediate: 0, approach: 0 };
if (plan.kind === 'attack' || plan.kind === 'usable') {
group.immediate += 1;
} else {
group.approach += 1;
}
groups.set(plan.target.id, group);
const group = plansByTarget.get(plan.target.id) ?? { target: plan.target, plans: [] };
group.plans.push(plan);
plansByTarget.set(plan.target.id, group);
});
const sortedGroups = Array.from(groups.values())
.sort((left, right) => right.immediate - left.immediate || right.approach - left.approach);
const visibleGroups = sortedGroups.slice(0, 2);
const omittedCount = Math.max(0, sortedGroups.length - visibleGroups.length);
const summaries = visibleGroups.map(
(group) => `${group.target.name}${group.immediate}·추${group.approach}`
);
return summaries.length > 0
? `적 예상 · ${summaries.join(' · ')}${omittedCount > 0 ? ` · 외 ${omittedCount}` : ''}`
: '적 예상 · 즉시 공격 없음';
const groups = Array.from(plansByTarget.values())
.map(({ target, plans: targetPlans }): TurnEndRiskGroup => {
const risk = summarizeIntentRisk(snapshots, target.id, target.hp);
const immediatePlans = targetPlans.filter(
(plan) => plan.kind === 'attack' || plan.kind === 'usable'
);
const attacks = immediatePlans.map((plan) => ({
enemyId: plan.enemy.id,
enemyName: plan.enemy.name,
damage: Math.max(0, plan.preview?.damage ?? 0),
hitRate: Phaser.Math.Clamp(Math.round(plan.preview?.hitRate ?? 0), 0, 100),
criticalRate: Phaser.Math.Clamp(Math.round(plan.preview?.criticalRate ?? 0), 0, 100)
}));
const hitRates = attacks.map((attack) => attack.hitRate);
return {
targetId: target.id,
targetName: target.name,
hp: Math.max(0, target.hp),
immediateCount: risk.immediateCount,
approachCount: risk.approachCount,
expectedDamage: risk.expectedDamage,
rawDamage: risk.rawDamage,
maximumDamage: risk.criticalAdjustedMaxDamage,
lethal: risk.lethal,
hitRateMin: hitRates.length > 0 ? Math.min(...hitRates) : 0,
hitRateMax: hitRates.length > 0 ? Math.max(...hitRates) : 0,
concentrated: risk.immediateCount >= 2,
attacks
};
})
.sort((left, right) => (
Number(right.lethal) - Number(left.lethal) ||
right.expectedDamage / Math.max(1, right.hp) - left.expectedDamage / Math.max(1, left.hp) ||
right.expectedDamage - left.expectedDamage ||
right.immediateCount - left.immediateCount ||
right.approachCount - left.approachCount ||
left.targetName.localeCompare(right.targetName, 'ko')
));
const visibleGroups = groups.slice(0, 2);
return {
immediateAttackCount: groups.reduce((total, group) => total + group.immediateCount, 0),
approachCount: groups.reduce((total, group) => total + group.approachCount, 0),
targetCount: groups.length,
expectedDamage: groups.reduce((total, group) => total + group.expectedDamage, 0),
lethalTargetCount: groups.filter((group) => group.lethal).length,
omittedTargetCount: Math.max(0, groups.length - visibleGroups.length),
visibleGroupIds: visibleGroups.map((group) => group.targetId),
groups
};
}
private enemyIntentTurnEndSummary(forecast = this.enemyIntentTurnEndRiskForecast()) {
if (!forecast) {
return '적 예상 정보 없음';
}
if (forecast.targetCount === 0) {
return '적 예상 · 즉시 공격 없음';
}
return [
`적 예상 · 즉시 공격 ${forecast.immediateAttackCount}`,
`위험 대상 ${forecast.targetCount}`,
`기대 피해 ${forecast.expectedDamage}`,
forecast.lethalTargetCount > 0 ? `격파 가능 ${forecast.lethalTargetCount}` : ''
].filter(Boolean).join(' · ');
}
private enemyIntentTurnEndGroupLine(group: TurnEndRiskGroup) {
if (group.immediateCount === 0) {
return `${group.targetName} · HP ${group.hp} · 즉시 공격 없음 · 접근 ${group.approachCount}`;
}
const hitRate = group.hitRateMin === group.hitRateMax
? `${group.hitRateMax}%`
: `${group.hitRateMin}~${group.hitRateMax}%`;
return [
`${group.targetName} · HP ${group.hp}`,
`공격 ${group.immediateCount}`,
`기대 피해 ${group.expectedDamage}`,
`최대 피해 ${group.maximumDamage}`,
`명중 ${hitRate}`,
group.concentrated ? '집중 공격' : '',
group.lethal ? '격파 가능' : ''
].filter(Boolean).join(' · ');
}
private enemyIntentTurnEndBody(
forecast = this.enemyIntentTurnEndRiskForecast(),
context = '현재 위치에서 적 턴을 시작합니다.'
) {
if (!forecast) {
return '아군 턴을 종료하시겠습니까?';
}
if (forecast.targetCount === 0) {
return `${context}\n${this.enemyIntentTurnEndSummary(forecast)}\n이대로 턴을 종료할까요?`;
}
const visibleGroups = forecast.groups.slice(0, 2);
return [
context,
this.enemyIntentTurnEndSummary(forecast),
...visibleGroups.map((group) => this.enemyIntentTurnEndGroupLine(group)),
forecast.omittedTargetCount > 0 ? `${forecast.omittedTargetCount}명 위험` : '',
'현재 위치와 공개된 적 의도를 기준으로 한 예상입니다.',
'이대로 턴을 종료할까요?'
].filter(Boolean).join('\n');
}
private enemyIntentTargetCounts(unitId: string) {
@@ -22112,7 +22254,7 @@ export class BattleScene extends Phaser.Scene {
if (unactedAllies.length === 0) {
this.showTurnEndPrompt(undefined, {
title: '턴 종료 확인',
body: this.enemyIntentTurnEndBody(),
riskContext: '모든 장수의 행동을 마쳤습니다.',
primaryLabel: '턴 종료',
secondaryLabel: '계속 지휘',
primaryMeaning: 'end-turn',
@@ -22126,7 +22268,7 @@ export class BattleScene extends Phaser.Scene {
const unactedNames = `${visibleNames.join(' · ')}${omittedNames > 0 ? `${omittedNames}` : ''}`;
this.showTurnEndPrompt(undefined, {
title: `미행동 ${unactedAllies.length}명 · 턴 종료 확인`,
body: `미행동 장수 · ${unactedNames}\n${this.enemyIntentTurnEndSummary()}\n행동을 포기하고 턴을 종료할까요?`,
riskContext: `미행동 장수 · ${unactedNames}`,
primaryLabel: '계속 지휘',
secondaryLabel: '미행동 포기 후 종료',
primaryAction: () => this.hideTurnEndPrompt(),
@@ -29370,7 +29512,15 @@ export class BattleScene extends Phaser.Scene {
this.hideTurnEndPrompt();
this.turnPromptMode = options.mode ?? 'turn-end';
const promptTitle = options.title ?? '모든 아군의 행동이 종료되었습니다.';
const promptBody = options.body ?? this.enemyIntentTurnEndBody();
const riskForecast = this.turnPromptMode === 'turn-end'
? this.enemyIntentTurnEndRiskForecast()
: undefined;
const riskContext = options.riskContext ?? '모든 장수의 행동을 마쳤습니다.';
const promptBody = options.body ?? (
this.turnPromptMode === 'turn-end'
? this.enemyIntentTurnEndBody(riskForecast, riskContext)
: '계속 진행하시겠습니까?'
);
const primaryLabel = options.primaryLabel ?? '턴 종료';
const secondaryLabel = options.secondaryLabel ?? '전장 확인';
const primaryAction = options.primaryAction ?? (() => this.endAllyTurn());
@@ -29391,8 +29541,11 @@ export class BattleScene extends Phaser.Scene {
this.turnPromptSecondaryAction = secondaryAction;
this.turnPromptFocusedAction = options.initialFocus ?? 'primary';
this.turnPromptCancelActionId = options.cancelActionId ?? 'secondary';
this.turnPromptRiskForecast = riskForecast;
const riskLayout = Boolean(riskForecast && riskForecast.targetCount > 0);
const expandedPrompt = promptBody.split('\n').length >= 3;
const promptHeight = expandedPrompt ? 202 : 170;
const promptWidth = riskLayout ? 560 : 388;
const promptHeight = riskLayout ? 326 : expandedPrompt ? 202 : 170;
const buttonY = promptHeight - 46;
const hintY = promptHeight - 19;
@@ -29411,7 +29564,7 @@ export class BattleScene extends Phaser.Scene {
);
this.turnPromptObjects.push(shade);
const width = this.battleUiLength(388);
const width = this.battleUiLength(promptWidth);
const height = this.battleUiLength(promptHeight);
const left = this.layout.mapX + this.layout.mapWidth / 2 - width / 2;
const top = this.layout.mapY + this.layout.mapHeight / 2 - height / 2;
@@ -29420,6 +29573,7 @@ export class BattleScene extends Phaser.Scene {
panel.setDepth(40);
panel.setStrokeStyle(this.battleUiLength(2), palette.gold, 0.9);
this.turnPromptObjects.push(panel);
this.turnPromptPanel = panel;
const title = this.add.text(left + width / 2, top + this.battleUiLength(26), promptTitle, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
@@ -29431,32 +29585,39 @@ export class BattleScene extends Phaser.Scene {
title.setDepth(41);
this.turnPromptObjects.push(title);
const body = this.add.text(left + width / 2, top + this.battleUiLength(52), promptBody, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(17),
color: '#d4dce6',
align: 'center',
wordWrap: { width: width - this.battleUiLength(44), useAdvancedWrap: true }
});
body.setOrigin(0.5, 0);
body.setDepth(41);
this.turnPromptObjects.push(body);
if (riskForecast && riskLayout) {
this.renderTurnEndRiskPromptContents(left, top, width, riskContext, riskForecast);
} else {
const body = this.add.text(left + width / 2, top + this.battleUiLength(52), promptBody, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(17),
color: '#d4dce6',
align: 'center',
wordWrap: { width: width - this.battleUiLength(44), useAdvancedWrap: true }
});
body.setOrigin(0.5, 0);
body.setDepth(41);
this.turnPromptObjects.push(body);
}
const buttonWidth = this.battleUiLength(136);
const buttonWidth = this.battleUiLength(riskLayout ? 160 : 136);
const buttonGap = this.battleUiLength(riskLayout ? 16 : 14);
const buttonOffset = (buttonWidth + buttonGap) / 2;
const buttonCenter = left + width / 2;
const buttons = [
{
id: 'primary' as const,
label: primaryLabel,
meaning: primaryMeaning,
dangerous: options.primaryDangerous ?? false,
x: left + this.battleUiLength(119)
x: buttonCenter - buttonOffset
},
{
id: 'secondary' as const,
label: secondaryLabel,
meaning: secondaryMeaning,
dangerous: options.secondaryDangerous ?? false,
x: left + this.battleUiLength(269)
x: buttonCenter + buttonOffset
}
];
@@ -29497,6 +29658,198 @@ export class BattleScene extends Phaser.Scene {
void message;
}
private renderTurnEndRiskPromptContents(
left: number,
top: number,
width: number,
context: string,
forecast: TurnEndRiskForecast
) {
const depth = 41;
const contextText = this.add.text(
left + width / 2,
top + this.battleUiLength(50),
context,
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(13),
color: '#d4dce6',
fontStyle: '700',
align: 'center'
}
);
contextText.setOrigin(0.5, 0);
contextText.setDepth(depth);
this.fitTextToWidth(contextText, context, width - this.battleUiLength(56));
this.turnPromptObjects.push(contextText);
const summaryTop = top + this.battleUiLength(76);
const summaryHeight = this.battleUiLength(32);
const summaryWidth = width - this.battleUiLength(56);
const summaryBackground = this.add.rectangle(
left + this.battleUiLength(28),
summaryTop,
summaryWidth,
summaryHeight,
0x182431,
0.98
);
summaryBackground.setOrigin(0);
summaryBackground.setDepth(depth);
summaryBackground.setStrokeStyle(this.battleUiLength(1), palette.gold, 0.72);
this.turnPromptObjects.push(summaryBackground);
const summaryLabel = [
`즉시 공격 ${forecast.immediateAttackCount}`,
`위험 대상 ${forecast.targetCount}`,
`기대 피해 ${forecast.expectedDamage}`,
forecast.lethalTargetCount > 0 ? `격파 가능 ${forecast.lethalTargetCount}` : ''
].filter(Boolean).join(' · ');
const summaryText = this.add.text(
left + width / 2,
summaryTop + summaryHeight / 2,
summaryLabel,
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(12),
color: '#fff2c4',
fontStyle: '700'
}
);
summaryText.setOrigin(0.5);
summaryText.setDepth(depth + 1);
this.fitTextToWidth(summaryText, summaryLabel, summaryWidth - this.battleUiLength(20));
this.turnPromptObjects.push(summaryText);
this.turnPromptRiskSummaryText = summaryText;
const rowLeft = left + this.battleUiLength(28);
const rowWidth = width - this.battleUiLength(56);
const rowHeight = this.battleUiLength(52);
forecast.groups.slice(0, 2).forEach((group, index) => {
const rowTop = top + this.battleUiLength(116 + index * 60);
const tone = group.lethal
? 0xd46a54
: group.concentrated
? 0xd8732c
: group.immediateCount > 0
? palette.gold
: palette.blue;
const rowBackground = this.add.rectangle(
rowLeft,
rowTop,
rowWidth,
rowHeight,
group.lethal ? 0x281819 : 0x121d27,
0.98
);
rowBackground.setOrigin(0);
rowBackground.setDepth(depth);
rowBackground.setStrokeStyle(this.battleUiLength(group.lethal ? 2 : 1), tone, 0.9);
this.turnPromptObjects.push(rowBackground);
const targetTitle = `${group.targetName} · 현재 HP ${group.hp}`;
const simultaneousWarnings = [
group.concentrated ? '집중 공격' : '',
group.lethal ? '격파 가능' : ''
].filter(Boolean);
const badgeLabel = simultaneousWarnings.length > 0
? simultaneousWarnings.join(' · ')
: group.immediateCount > 0
? '피격 예상'
: '접근 예정';
const badgeWidth = this.battleUiLength(Math.max(68, badgeLabel.length * 12 + 20));
const badge = this.add.rectangle(
rowLeft + rowWidth - this.battleUiLength(10) - badgeWidth / 2,
rowTop + this.battleUiLength(14),
badgeWidth,
this.battleUiLength(22),
group.lethal ? 0x6a2923 : 0x1b2a36,
0.98
);
badge.setDepth(depth + 1);
badge.setStrokeStyle(this.battleUiLength(1), tone, 0.94);
this.turnPromptObjects.push(badge);
const badgeText = this.add.text(badge.x, badge.y, badgeLabel, {
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(10),
color: group.lethal ? '#ffd4c9' : '#fff0c8',
fontStyle: '700'
});
badgeText.setOrigin(0.5);
badgeText.setDepth(depth + 2);
this.turnPromptObjects.push(badgeText);
const titleText = this.add.text(
rowLeft + this.battleUiLength(14),
rowTop + this.battleUiLength(6),
targetTitle,
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(13),
color: '#f2e3bf',
fontStyle: '700'
}
);
titleText.setDepth(depth + 1);
this.fitTextToWidth(
titleText,
targetTitle,
rowWidth - badgeWidth - this.battleUiLength(46)
);
this.turnPromptObjects.push(titleText);
const hitRateLabel = group.hitRateMin === group.hitRateMax
? `${group.hitRateMax}%`
: `${group.hitRateMin}~${group.hitRateMax}%`;
const metricLabel = group.immediateCount > 0
? `공격 ${group.immediateCount}회 · 기대 피해 ${group.expectedDamage} · 최대 피해 ${group.maximumDamage} · 명중 ${hitRateLabel}${group.approachCount > 0 ? ` · 접근 ${group.approachCount}` : ''}`
: `즉시 공격 없음 · 접근 ${group.approachCount}명 · 다음 적 행동에서 거리 단축`;
const metricText = this.add.text(
rowLeft + this.battleUiLength(14),
rowTop + this.battleUiLength(30),
metricLabel,
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(11),
color: group.lethal ? '#ffc3b4' : '#b9c9d8',
fontStyle: '700'
}
);
metricText.setDepth(depth + 1);
this.fitTextToWidth(metricText, metricLabel, rowWidth - this.battleUiLength(28));
this.turnPromptObjects.push(metricText);
this.turnPromptRiskRows.push({
targetId: group.targetId,
background: rowBackground,
badgeText,
titleText,
metricText
});
});
const footnoteLabel = [
'현재 위치와 공개된 적 의도를 기준으로 한 예상입니다.',
forecast.omittedTargetCount > 0 ? `${forecast.omittedTargetCount}명 위험` : ''
].filter(Boolean).join(' · ');
const footnoteText = this.add.text(
left + width / 2,
top + this.battleUiLength(240),
footnoteLabel,
{
fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif',
fontSize: this.battleUiFontSize(11),
color: '#91a7bd',
fontStyle: '700'
}
);
footnoteText.setOrigin(0.5, 0);
footnoteText.setDepth(depth);
this.fitTextToWidth(footnoteText, footnoteLabel, width - this.battleUiLength(56));
this.turnPromptObjects.push(footnoteText);
this.turnPromptRiskFootnoteText = footnoteText;
}
private handleTurnPromptNavigationKey(event: KeyboardEvent) {
if (this.turnPromptObjects.length === 0) {
return false;
@@ -29587,6 +29940,11 @@ export class BattleScene extends Phaser.Scene {
this.turnPromptFocusedAction = undefined;
this.turnPromptCancelActionId = undefined;
this.turnPromptButtonViews = [];
this.turnPromptPanel = undefined;
this.turnPromptRiskForecast = undefined;
this.turnPromptRiskSummaryText = undefined;
this.turnPromptRiskFootnoteText = undefined;
this.turnPromptRiskRows = [];
}
private pushBattleLog(message: string) {
@@ -33722,13 +34080,42 @@ export class BattleScene extends Phaser.Scene {
body: this.turnPromptBody ?? '',
focusedAction: this.turnPromptFocusedAction ?? null,
cancelAction: this.turnPromptCancelActionId ?? null,
panelBounds: this.gameObjectBoundsDebug(this.turnPromptPanel),
summaryBounds: this.gameObjectBoundsDebug(this.turnPromptRiskSummaryText),
footnoteBounds: this.gameObjectBoundsDebug(this.turnPromptRiskFootnoteText),
riskForecast: this.turnPromptRiskForecast
? {
immediateAttackCount: this.turnPromptRiskForecast.immediateAttackCount,
approachCount: this.turnPromptRiskForecast.approachCount,
targetCount: this.turnPromptRiskForecast.targetCount,
expectedDamage: this.turnPromptRiskForecast.expectedDamage,
lethalTargetCount: this.turnPromptRiskForecast.lethalTargetCount,
omittedTargetCount: this.turnPromptRiskForecast.omittedTargetCount,
visibleGroupIds: [...this.turnPromptRiskForecast.visibleGroupIds],
groups: this.turnPromptRiskForecast.groups.map((group) => ({
...group,
attacks: group.attacks.map((attack) => ({ ...attack }))
}))
}
: null,
riskRows: this.turnPromptRiskRows.map((row) => ({
targetId: row.targetId,
bounds: this.gameObjectBoundsDebug(row.background),
badgeText: row.badgeText.text,
badgeBounds: this.gameObjectBoundsDebug(row.badgeText),
titleText: row.titleText.text,
titleBounds: this.gameObjectBoundsDebug(row.titleText),
metricText: row.metricText.text,
metricBounds: this.gameObjectBoundsDebug(row.metricText)
})),
buttons: this.turnPromptButtonViews.map((view) => ({
id: view.id,
label: view.label,
meaning: view.meaning,
dangerous: view.dangerous,
focused: this.turnPromptFocusedAction === view.id,
escapeCancels: this.turnPromptCancelActionId === view.id
escapeCancels: this.turnPromptCancelActionId === view.id,
bounds: this.gameObjectBoundsDebug(view.background)
}))
}
: null,