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

@@ -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 === '계속 지휘' &&