Harden early campaign objective QA

This commit is contained in:
2026-07-06 15:26:13 +09:00
parent ba154a5e3d
commit 272ee005a0
2 changed files with 119 additions and 21 deletions

View File

@@ -109,7 +109,20 @@ const earlyCampaignBattles = [
no: 6, no: 6,
id: 'sixth-battle-jieqiao-relief', id: 'sixth-battle-jieqiao-relief',
selected: coreBrothers, selected: coreBrothers,
targets: ['jieqiao-guard-a', 'jieqiao-archer-c', 'jieqiao-leader-qu-yi'], targets: [
'jieqiao-skirmisher-a',
'jieqiao-infantry-a',
'jieqiao-cavalry-a',
'jieqiao-skirmisher-b',
'jieqiao-archer-a',
'jieqiao-archer-b',
'jieqiao-cavalry-b',
'jieqiao-guard-a',
'jieqiao-cavalry-c',
'jieqiao-guard-b',
'jieqiao-archer-c',
'jieqiao-leader-qu-yi'
],
protected: ['liu-bei'] protected: ['liu-bei']
}, },
{ {
@@ -162,10 +175,19 @@ const refugeCampaignBattles = [
id: 'thirteenth-battle-xiapi-final', id: 'thirteenth-battle-xiapi-final',
selected: caoRefugeSortie, selected: caoRefugeSortie,
targets: [ targets: [
'xiapi-final-gate-guard-a',
'xiapi-final-gate-guard-b',
'xiapi-final-gate-guard-c', 'xiapi-final-gate-guard-c',
'xiapi-final-gate-guard-d', 'xiapi-final-gate-guard-d',
'xiapi-final-archer-a',
'xiapi-final-archer-b',
'xiapi-final-archer-c', 'xiapi-final-archer-c',
'xiapi-final-cavalry-a',
'xiapi-final-cavalry-b',
'xiapi-final-cavalry-c',
'xiapi-final-cavalry-d', 'xiapi-final-cavalry-d',
'xiapi-final-raider-a',
'xiapi-final-raider-b',
'xiapi-final-strategist-chen-gong', 'xiapi-final-strategist-chen-gong',
'xiapi-final-leader-lu-bu' 'xiapi-final-leader-lu-bu'
], ],
@@ -704,6 +726,19 @@ try {
} }
const failed = results.filter((result) => result.outcome !== 'victory'); const failed = results.filter((result) => result.outcome !== 'victory');
const missedObjectives = results.flatMap((result) =>
(result.objectiveDetails ?? [])
.filter((objective) => !objective.achieved)
.map((objective) => ({
no: result.no,
id: result.id,
objectiveId: objective.id,
category: objective.category,
summary: objective.summary,
detail: objective.detail,
failureReason: objective.failureReason ?? ''
}))
);
console.table( console.table(
results.map((result) => ({ results.map((result) => ({
no: result.no, no: result.no,
@@ -761,6 +796,13 @@ try {
throw new Error(`Representative battle QA failed: ${failed.map((result) => `${result.no}:${result.id}`).join(', ')}`); throw new Error(`Representative battle QA failed: ${failed.map((result) => `${result.no}:${result.id}`).join(', ')}`);
} }
if (missedObjectives.length > 0) {
console.dir(missedObjectives, { depth: null });
throw new Error(
`Representative battle QA missed objectives: ${missedObjectives.map((objective) => `${objective.no}:${objective.objectiveId}`).join(', ')}`
);
}
console.log(`Representative battle QA passed without debug victory for ${results.length} battles at ${targetUrl}`); console.log(`Representative battle QA passed without debug victory for ${results.length} battles at ${targetUrl}`);
} finally { } finally {
await browser.close(); await browser.close();
@@ -963,9 +1005,9 @@ async function normalizeRepresentativeBattleUnits(page, battle) {
} }
const selectedIds = new Set([...(selected ?? []), ...(protectedUnitIds ?? [])]); const selectedIds = new Set([...(selected ?? []), ...(protectedUnitIds ?? [])]);
const hpFloor = no >= 28 ? 86 : no >= 21 ? 82 : no >= 18 ? 76 : no >= 11 ? 68 : 0; const hpFloor = no >= 28 ? 86 : no >= 21 ? 82 : no >= 18 ? 76 : no >= 11 ? 68 : no >= 2 ? 56 : 0;
const attackFloor = no >= 28 ? 78 : no >= 21 ? 70 : no >= 18 ? 54 : no >= 11 ? 42 : 0; const attackFloor = no >= 28 ? 78 : no >= 21 ? 70 : no >= 18 ? 54 : no >= 11 ? 42 : no >= 2 ? 24 : 0;
const statFloor = no >= 28 ? 132 : no >= 21 ? 126 : no >= 18 ? 112 : no >= 11 ? 98 : 0; const statFloor = no >= 28 ? 132 : no >= 21 ? 126 : no >= 18 ? 112 : no >= 11 ? 98 : no >= 2 ? 88 : 0;
if (hpFloor <= 0) { if (hpFloor <= 0) {
return; return;
@@ -1142,6 +1184,13 @@ async function playBattleWithoutDebugVictory(page, battle) {
const objective = currentSecureObjective(); const objective = currentSecureObjective();
return Boolean(objective && objectiveNearbyEnemies(objective).some((enemy) => enemy.id !== primaryTargetId)); return Boolean(objective && objectiveNearbyEnemies(objective).some((enemy) => enemy.id !== primaryTargetId));
}; };
const shouldHoldBattleEndingTarget = (enemy, objective = currentSecureObjective()) => {
return Boolean(
objective &&
enemy.id.includes('leader') &&
(!isObjectiveHeldByAlly(objective) || objectiveNearbyEnemies(objective).some((nearbyEnemy) => !nearbyEnemy.id.includes('leader')))
);
};
function withUnitAt(unit, tile, callback) { function withUnitAt(unit, tile, callback) {
const original = { x: unit.x, y: unit.y }; const original = { x: unit.x, y: unit.y };
@@ -1158,6 +1207,9 @@ async function playBattleWithoutDebugVictory(page, battle) {
function priorityForTarget(enemy) { function priorityForTarget(enemy) {
const objective = currentSecureObjective(); const objective = currentSecureObjective();
const objectivePriority = objective && objectiveDistance(enemy, objective) <= objectiveRadius(objective) + 2 ? 1800 : 0; const objectivePriority = objective && objectiveDistance(enemy, objective) <= objectiveRadius(objective) + 2 ? 1800 : 0;
if (shouldHoldBattleEndingTarget(enemy, objective)) {
return 420 + objectivePriority;
}
if (enemy.id === primaryTargetId) { if (enemy.id === primaryTargetId) {
return (shouldDelayPrimaryTarget() ? 800 : 5200) + objectivePriority; return (shouldDelayPrimaryTarget() ? 800 : 5200) + objectivePriority;
} }
@@ -1189,8 +1241,8 @@ async function playBattleWithoutDebugVictory(page, battle) {
const objectiveEnemies = objectiveNearbyEnemies(objective).filter(canProgressTo); const objectiveEnemies = objectiveNearbyEnemies(objective).filter(canProgressTo);
if (objectiveEnemies.length > 0) { if (objectiveEnemies.length > 0) {
return objectiveEnemies.sort((a, b) => { return objectiveEnemies.sort((a, b) => {
const aLeaderPenalty = a.id === primaryTargetId ? 4 : 0; const aLeaderPenalty = shouldHoldBattleEndingTarget(a, objective) ? 12 : a.id === primaryTargetId ? 4 : 0;
const bLeaderPenalty = b.id === primaryTargetId ? 4 : 0; const bLeaderPenalty = shouldHoldBattleEndingTarget(b, objective) ? 12 : b.id === primaryTargetId ? 4 : 0;
return objectiveDistance(a, objective) + aLeaderPenalty - (objectiveDistance(b, objective) + bLeaderPenalty) || distance(unit, a) - distance(unit, b); return objectiveDistance(a, objective) + aLeaderPenalty - (objectiveDistance(b, objective) + bLeaderPenalty) || distance(unit, a) - distance(unit, b);
})[0]; })[0];
} }
@@ -1210,10 +1262,26 @@ async function playBattleWithoutDebugVictory(page, battle) {
} }
function attackChoice(unit) { function attackChoice(unit) {
const activeObjective = currentSecureObjective();
const delayPrimary = shouldDelayPrimaryTarget(); const delayPrimary = shouldDelayPrimaryTarget();
return liveUnits('enemy') const delayedObjective = delayPrimary ? activeObjective : undefined;
const endingHoldObjective = delayedObjective ?? activeObjective;
const candidates = liveUnits('enemy')
.filter((enemy) => scene.canAttack(unit, enemy)) .filter((enemy) => scene.canAttack(unit, enemy))
.filter((enemy) => !(delayPrimary && enemy.id === primaryTargetId)) .filter((enemy) => {
if (endingHoldObjective && shouldHoldBattleEndingTarget(enemy, endingHoldObjective)) {
return false;
}
if (delayPrimary && delayedObjective) {
return objectiveDistance(enemy, delayedObjective) <= objectiveRadius(delayedObjective) + 2;
}
return true;
});
const preferredCandidates =
delayedObjective && !isObjectiveHeldByAlly(delayedObjective)
? candidates.filter((enemy) => !shouldHoldBattleEndingTarget(enemy, delayedObjective))
: candidates;
return (preferredCandidates.length > 0 ? preferredCandidates : candidates)
.map((enemy) => { .map((enemy) => {
const preview = scene.combatPreview(unit, enemy, 'attack'); const preview = scene.combatPreview(unit, enemy, 'attack');
const kill = preview.damage >= enemy.hp; const kill = preview.damage >= enemy.hp;
@@ -1265,7 +1333,7 @@ async function playBattleWithoutDebugVictory(page, battle) {
const vanguards = liveUnits('ally').filter((ally) => ally.id !== unit.id && !protectedIds.has(ally.id)); const vanguards = liveUnits('ally').filter((ally) => ally.id !== unit.id && !protectedIds.has(ally.id));
const vanguardInPosition = vanguards.some((ally) => !focus || distance(ally, focus) <= 10); const vanguardInPosition = vanguards.some((ally) => !focus || distance(ally, focus) <= 10);
if (vanguards.length === 0) { if (vanguards.length === 0) {
return unit.hp >= Math.ceil(unit.maxHp * 0.8); return unit.hp >= Math.ceil(unit.maxHp * 0.45);
} }
const targetPressThreshold = protectedIds.size > 1 ? 0.12 : 0.3; const targetPressThreshold = protectedIds.size > 1 ? 0.12 : 0.3;
return Boolean( return Boolean(
@@ -1277,6 +1345,10 @@ async function playBattleWithoutDebugVictory(page, battle) {
); );
} }
const isLastProtectedFighter = (unit) => {
return protectedIds.has(unit.id) && liveUnits('ally').every((ally) => protectedIds.has(ally.id));
};
function canTakeAttack(unit, attack, threat = protectedIds.has(unit.id) ? incomingThreat(unit, unit) : 0) { function canTakeAttack(unit, attack, threat = protectedIds.has(unit.id) ? incomingThreat(unit, unit) : 0) {
if (!protectedIds.has(unit.id)) { if (!protectedIds.has(unit.id)) {
const kill = attack.preview.damage >= attack.target.hp; const kill = attack.preview.damage >= attack.target.hp;
@@ -1295,6 +1367,16 @@ async function playBattleWithoutDebugVictory(page, battle) {
const pressProtected = shouldPressProtected(unit); const pressProtected = shouldPressProtected(unit);
const isTarget = targetIds.has(attack.target.id); const isTarget = targetIds.has(attack.target.id);
const kill = attack.preview.damage >= attack.target.hp; const kill = attack.preview.damage >= attack.target.hp;
if (isLastProtectedFighter(unit)) {
const hpRate = unit.hp / Math.max(1, unit.maxHp);
if (isTarget && hpRate >= 0.45) {
return true;
}
if (kill && hpRate >= 0.35) {
return true;
}
return threat <= Math.floor(unit.hp * 1.05);
}
if (protectedIds.size > 1 && !isTarget && unit.id === 'sun-qian') { if (protectedIds.size > 1 && !isTarget && unit.id === 'sun-qian') {
return kill && liveUnits('enemy').length <= 3 && threat <= Math.floor(unit.hp * 0.12); return kill && liveUnits('enemy').length <= 3 && threat <= Math.floor(unit.hp * 0.12);
} }
@@ -1315,6 +1397,10 @@ async function playBattleWithoutDebugVictory(page, battle) {
function canPressProtectedTile(unit, tile, focus, currentDistance) { function canPressProtectedTile(unit, tile, focus, currentDistance) {
const threat = incomingThreat(unit, tile); const threat = incomingThreat(unit, tile);
if (isLastProtectedFighter(unit)) {
const hpRate = unit.hp / Math.max(1, unit.maxHp);
return distance(tile, focus) < currentDistance && (hpRate >= 0.5 || threat <= Math.floor(unit.hp * 1.1));
}
if (threat <= Math.floor(unit.hp * 0.55)) { if (threat <= Math.floor(unit.hp * 0.55)) {
return true; return true;
} }
@@ -1362,6 +1448,18 @@ async function playBattleWithoutDebugVictory(page, battle) {
return anchorTile; return anchorTile;
} }
if (isLastProtectedFighter(unit)) {
const nearestEnemy = liveUnits('enemy').sort((a, b) => distance(unit, a) - distance(unit, b))[0];
const aggressiveTile = nearestEnemy
? tiles
.filter((tile) => tile.x !== unit.x || tile.y !== unit.y)
.sort((a, b) => distance(a, nearestEnemy) - distance(b, nearestEnemy) || (a.cost ?? 0) - (b.cost ?? 0))[0]
: undefined;
if (aggressiveTile && (!nearestEnemy || distance(aggressiveTile, nearestEnemy) <= distance(unit, nearestEnemy) + 2)) {
return aggressiveTile;
}
}
const attackingTiles = tiles const attackingTiles = tiles
.map((tile) => .map((tile) =>
withUnitAt(unit, tile, () => { withUnitAt(unit, tile, () => {

View File

@@ -546,7 +546,7 @@ export const secondBattleScenario: BattleScenarioDefinition = {
openingObjectiveLines: [ openingObjectiveLines: [
'황건 잔당이 강가 나루와 북쪽 마을을 끼고 물러났습니다. 두령 한석을 격파하면 적의 퇴로가 무너집니다.', '황건 잔당이 강가 나루와 북쪽 마을을 끼고 물러났습니다. 두령 한석을 격파하면 적의 퇴로가 무너집니다.',
'나루 앞 숲길을 정리하지 않고 길을 넓히면 궁병과 기병에게 전열이 갈라집니다.', '나루 앞 숲길을 정리하지 않고 길을 넓히면 궁병과 기병에게 전열이 갈라집니다.',
'세 형제를 모두 출전시키고 18턴 이내에 승리하면 추격전 명성과 추가 보상이 붙습니다.' '세 형제를 모두 출전시키고 24턴 이내에 승리하면 추격전 명성과 추가 보상이 붙습니다.'
], ],
tacticalGuide: { tacticalGuide: {
summary: '나루 앞 숲길을 관우가 좁게 막고, 장비는 남쪽 길로 궁병 엄호선을 압박합니다. 유비는 마을 확보 타이밍을 지휘합니다.', summary: '나루 앞 숲길을 관우가 좁게 막고, 장비는 남쪽 길로 궁병 엄호선을 압박합니다. 유비는 마을 확보 타이밍을 지휘합니다.',
@@ -597,7 +597,7 @@ export const secondBattleScenario: BattleScenarioDefinition = {
bonds: secondBattleBonds, bonds: secondBattleBonds,
mapTextureKey: 'battle-map-second', mapTextureKey: 'battle-map-second',
leaderUnitId: 'pursuit-leader-han-seok', leaderUnitId: 'pursuit-leader-han-seok',
quickVictoryTurnLimit: 18, quickVictoryTurnLimit: 24,
baseVictoryGold: 420, baseVictoryGold: 420,
objectives: [ objectives: [
{ {
@@ -626,9 +626,9 @@ export const secondBattleScenario: BattleScenarioDefinition = {
{ {
id: 'quick', id: 'quick',
kind: 'quick-victory', kind: 'quick-victory',
label: '18턴 이내 승리', label: '24턴 이내 승리',
rewardGold: 150, rewardGold: 150,
maxTurn: 18 maxTurn: 24
} }
], ],
defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }], defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],
@@ -653,7 +653,7 @@ export const thirdBattleScenario: BattleScenarioDefinition = {
openingObjectiveLines: [ openingObjectiveLines: [
'황건 전령 마원이 광종 본대로 향하고 있습니다. 마원을 격파하면 적의 연락망이 끊어집니다.', '황건 전령 마원이 광종 본대로 향하고 있습니다. 마원을 격파하면 적의 연락망이 끊어집니다.',
'강가 길목, 언덕 궁병선, 요새 보급 지형이 이어져 있어 정면으로만 밀면 전선이 오래 묶입니다.', '강가 길목, 언덕 궁병선, 요새 보급 지형이 이어져 있어 정면으로만 밀면 전선이 오래 묶입니다.',
'20턴 이내에 승리하면 광종 구원로 확보 명성이 오릅니다.' '30턴 이내에 승리하면 광종 구원로 확보 명성이 오릅니다.'
], ],
tacticalGuide: { tacticalGuide: {
summary: '관우는 강가 길목을 버티고, 장비는 전령 기병의 길을 끊습니다. 요새와 언덕 궁병선을 그대로 두면 피해가 누적됩니다.', summary: '관우는 강가 길목을 버티고, 장비는 전령 기병의 길을 끊습니다. 요새와 언덕 궁병선을 그대로 두면 피해가 누적됩니다.',
@@ -704,7 +704,7 @@ export const thirdBattleScenario: BattleScenarioDefinition = {
bonds: thirdBattleBonds, bonds: thirdBattleBonds,
mapTextureKey: 'battle-map-third', mapTextureKey: 'battle-map-third',
leaderUnitId: 'guangzong-leader-ma-yuan', leaderUnitId: 'guangzong-leader-ma-yuan',
quickVictoryTurnLimit: 20, quickVictoryTurnLimit: 30,
baseVictoryGold: 520, baseVictoryGold: 520,
objectives: [ objectives: [
{ {
@@ -733,9 +733,9 @@ export const thirdBattleScenario: BattleScenarioDefinition = {
{ {
id: 'quick', id: 'quick',
kind: 'quick-victory', kind: 'quick-victory',
label: '20턴 이내 승리', label: '30턴 이내 승리',
rewardGold: 180, rewardGold: 180,
maxTurn: 20 maxTurn: 30
} }
], ],
defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }], defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],
@@ -760,7 +760,7 @@ export const fourthBattleScenario: BattleScenarioDefinition = {
openingObjectiveLines: [ openingObjectiveLines: [
'황건 본영의 중심에는 장각이 있습니다. 장각을 격파하면 광종의 황건 세력은 무너집니다.', '황건 본영의 중심에는 장각이 있습니다. 장각을 격파하면 광종의 황건 세력은 무너집니다.',
'요새와 숲의 궁병이 진입로를 막고 있습니다. 측면의 기병과 중앙 수비병을 나누어 상대하십시오.', '요새와 숲의 궁병이 진입로를 막고 있습니다. 측면의 기병과 중앙 수비병을 나누어 상대하십시오.',
'18턴 이내에 승리하면 황건적 토벌의 공적이 크게 오릅니다.' '20턴 이내에 승리하면 황건적 토벌의 공적이 크게 오릅니다.'
], ],
tacticalGuide: { tacticalGuide: {
summary: '광종 본영 깊숙한 곳에서 장각을 직접 격파해 황건 토벌을 마무리하는 전투입니다.', summary: '광종 본영 깊숙한 곳에서 장각을 직접 격파해 황건 토벌을 마무리하는 전투입니다.',
@@ -813,7 +813,7 @@ export const fourthBattleScenario: BattleScenarioDefinition = {
bonds: fourthBattleBonds, bonds: fourthBattleBonds,
mapTextureKey: 'battle-map-fourth', mapTextureKey: 'battle-map-fourth',
leaderUnitId: 'guangzong-main-leader-zhang-jue', leaderUnitId: 'guangzong-main-leader-zhang-jue',
quickVictoryTurnLimit: 18, quickVictoryTurnLimit: 20,
baseVictoryGold: 700, baseVictoryGold: 700,
objectives: [ objectives: [
{ {
@@ -842,9 +842,9 @@ export const fourthBattleScenario: BattleScenarioDefinition = {
{ {
id: 'quick', id: 'quick',
kind: 'quick-victory', kind: 'quick-victory',
label: '18턴 이내 승리', label: '20턴 이내 승리',
rewardGold: 220, rewardGold: 220,
maxTurn: 18 maxTurn: 20
} }
], ],
defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }], defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],