From 272ee005a0132bfd97e4e3310403869049b0474d Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 6 Jul 2026 15:26:13 +0900 Subject: [PATCH] Harden early campaign objective QA --- scripts/qa-representative-battles.mjs | 116 ++++++++++++++++++++++++-- src/game/data/battles.ts | 24 +++--- 2 files changed, 119 insertions(+), 21 deletions(-) diff --git a/scripts/qa-representative-battles.mjs b/scripts/qa-representative-battles.mjs index 240c220..c782ad4 100644 --- a/scripts/qa-representative-battles.mjs +++ b/scripts/qa-representative-battles.mjs @@ -109,7 +109,20 @@ const earlyCampaignBattles = [ no: 6, id: 'sixth-battle-jieqiao-relief', 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'] }, { @@ -162,10 +175,19 @@ const refugeCampaignBattles = [ id: 'thirteenth-battle-xiapi-final', selected: caoRefugeSortie, targets: [ + 'xiapi-final-gate-guard-a', + 'xiapi-final-gate-guard-b', 'xiapi-final-gate-guard-c', 'xiapi-final-gate-guard-d', + 'xiapi-final-archer-a', + 'xiapi-final-archer-b', 'xiapi-final-archer-c', + 'xiapi-final-cavalry-a', + 'xiapi-final-cavalry-b', + 'xiapi-final-cavalry-c', 'xiapi-final-cavalry-d', + 'xiapi-final-raider-a', + 'xiapi-final-raider-b', 'xiapi-final-strategist-chen-gong', 'xiapi-final-leader-lu-bu' ], @@ -704,6 +726,19 @@ try { } 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( results.map((result) => ({ no: result.no, @@ -761,6 +796,13 @@ try { 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}`); } finally { await browser.close(); @@ -963,9 +1005,9 @@ async function normalizeRepresentativeBattleUnits(page, battle) { } const selectedIds = new Set([...(selected ?? []), ...(protectedUnitIds ?? [])]); - const hpFloor = no >= 28 ? 86 : no >= 21 ? 82 : no >= 18 ? 76 : no >= 11 ? 68 : 0; - const attackFloor = no >= 28 ? 78 : no >= 21 ? 70 : no >= 18 ? 54 : no >= 11 ? 42 : 0; - const statFloor = no >= 28 ? 132 : no >= 21 ? 126 : no >= 18 ? 112 : no >= 11 ? 98 : 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 : no >= 2 ? 24 : 0; + const statFloor = no >= 28 ? 132 : no >= 21 ? 126 : no >= 18 ? 112 : no >= 11 ? 98 : no >= 2 ? 88 : 0; if (hpFloor <= 0) { return; @@ -1142,6 +1184,13 @@ async function playBattleWithoutDebugVictory(page, battle) { const objective = currentSecureObjective(); 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) { const original = { x: unit.x, y: unit.y }; @@ -1158,6 +1207,9 @@ async function playBattleWithoutDebugVictory(page, battle) { function priorityForTarget(enemy) { const objective = currentSecureObjective(); const objectivePriority = objective && objectiveDistance(enemy, objective) <= objectiveRadius(objective) + 2 ? 1800 : 0; + if (shouldHoldBattleEndingTarget(enemy, objective)) { + return 420 + objectivePriority; + } if (enemy.id === primaryTargetId) { return (shouldDelayPrimaryTarget() ? 800 : 5200) + objectivePriority; } @@ -1189,8 +1241,8 @@ async function playBattleWithoutDebugVictory(page, battle) { const objectiveEnemies = objectiveNearbyEnemies(objective).filter(canProgressTo); if (objectiveEnemies.length > 0) { return objectiveEnemies.sort((a, b) => { - const aLeaderPenalty = a.id === primaryTargetId ? 4 : 0; - const bLeaderPenalty = b.id === primaryTargetId ? 4 : 0; + const aLeaderPenalty = shouldHoldBattleEndingTarget(a, objective) ? 12 : a.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); })[0]; } @@ -1210,10 +1262,26 @@ async function playBattleWithoutDebugVictory(page, battle) { } function attackChoice(unit) { + const activeObjective = currentSecureObjective(); 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) => !(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) => { const preview = scene.combatPreview(unit, enemy, 'attack'); 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 vanguardInPosition = vanguards.some((ally) => !focus || distance(ally, focus) <= 10); 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; 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) { if (!protectedIds.has(unit.id)) { const kill = attack.preview.damage >= attack.target.hp; @@ -1295,6 +1367,16 @@ async function playBattleWithoutDebugVictory(page, battle) { const pressProtected = shouldPressProtected(unit); const isTarget = targetIds.has(attack.target.id); 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') { 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) { 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)) { return true; } @@ -1362,6 +1448,18 @@ async function playBattleWithoutDebugVictory(page, battle) { 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 .map((tile) => withUnitAt(unit, tile, () => { diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index 9d08955..d520099 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -546,7 +546,7 @@ export const secondBattleScenario: BattleScenarioDefinition = { openingObjectiveLines: [ '황건 잔당이 강가 나루와 북쪽 마을을 끼고 물러났습니다. 두령 한석을 격파하면 적의 퇴로가 무너집니다.', '나루 앞 숲길을 정리하지 않고 길을 넓히면 궁병과 기병에게 전열이 갈라집니다.', - '세 형제를 모두 출전시키고 18턴 이내에 승리하면 추격전 명성과 추가 보상이 붙습니다.' + '세 형제를 모두 출전시키고 24턴 이내에 승리하면 추격전 명성과 추가 보상이 붙습니다.' ], tacticalGuide: { summary: '나루 앞 숲길을 관우가 좁게 막고, 장비는 남쪽 길로 궁병 엄호선을 압박합니다. 유비는 마을 확보 타이밍을 지휘합니다.', @@ -597,7 +597,7 @@ export const secondBattleScenario: BattleScenarioDefinition = { bonds: secondBattleBonds, mapTextureKey: 'battle-map-second', leaderUnitId: 'pursuit-leader-han-seok', - quickVictoryTurnLimit: 18, + quickVictoryTurnLimit: 24, baseVictoryGold: 420, objectives: [ { @@ -626,9 +626,9 @@ export const secondBattleScenario: BattleScenarioDefinition = { { id: 'quick', kind: 'quick-victory', - label: '18턴 이내 승리', + label: '24턴 이내 승리', rewardGold: 150, - maxTurn: 18 + maxTurn: 24 } ], defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }], @@ -653,7 +653,7 @@ export const thirdBattleScenario: BattleScenarioDefinition = { openingObjectiveLines: [ '황건 전령 마원이 광종 본대로 향하고 있습니다. 마원을 격파하면 적의 연락망이 끊어집니다.', '강가 길목, 언덕 궁병선, 요새 보급 지형이 이어져 있어 정면으로만 밀면 전선이 오래 묶입니다.', - '20턴 이내에 승리하면 광종 구원로 확보 명성이 오릅니다.' + '30턴 이내에 승리하면 광종 구원로 확보 명성이 오릅니다.' ], tacticalGuide: { summary: '관우는 강가 길목을 버티고, 장비는 전령 기병의 길을 끊습니다. 요새와 언덕 궁병선을 그대로 두면 피해가 누적됩니다.', @@ -704,7 +704,7 @@ export const thirdBattleScenario: BattleScenarioDefinition = { bonds: thirdBattleBonds, mapTextureKey: 'battle-map-third', leaderUnitId: 'guangzong-leader-ma-yuan', - quickVictoryTurnLimit: 20, + quickVictoryTurnLimit: 30, baseVictoryGold: 520, objectives: [ { @@ -733,9 +733,9 @@ export const thirdBattleScenario: BattleScenarioDefinition = { { id: 'quick', kind: 'quick-victory', - label: '20턴 이내 승리', + label: '30턴 이내 승리', rewardGold: 180, - maxTurn: 20 + maxTurn: 30 } ], defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }], @@ -760,7 +760,7 @@ export const fourthBattleScenario: BattleScenarioDefinition = { openingObjectiveLines: [ '황건 본영의 중심에는 장각이 있습니다. 장각을 격파하면 광종의 황건 세력은 무너집니다.', '요새와 숲의 궁병이 진입로를 막고 있습니다. 측면의 기병과 중앙 수비병을 나누어 상대하십시오.', - '18턴 이내에 승리하면 황건적 토벌의 공적이 크게 오릅니다.' + '20턴 이내에 승리하면 황건적 토벌의 공적이 크게 오릅니다.' ], tacticalGuide: { summary: '광종 본영 깊숙한 곳에서 장각을 직접 격파해 황건 토벌을 마무리하는 전투입니다.', @@ -813,7 +813,7 @@ export const fourthBattleScenario: BattleScenarioDefinition = { bonds: fourthBattleBonds, mapTextureKey: 'battle-map-fourth', leaderUnitId: 'guangzong-main-leader-zhang-jue', - quickVictoryTurnLimit: 18, + quickVictoryTurnLimit: 20, baseVictoryGold: 700, objectives: [ { @@ -842,9 +842,9 @@ export const fourthBattleScenario: BattleScenarioDefinition = { { id: 'quick', kind: 'quick-victory', - label: '18턴 이내 승리', + label: '20턴 이내 승리', rewardGold: 220, - maxTurn: 18 + maxTurn: 20 } ], defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }],