feat: surface turn-end risk forecasts
This commit is contained in:
@@ -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 === '계속 지휘' &&
|
||||
|
||||
Reference in New Issue
Block a user