From 2ebd5fc6c33b50f567cd682bfec534b86f11801a Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sat, 27 Jun 2026 10:03:18 +0900 Subject: [PATCH] QA and tune early campaign battles --- scripts/qa-representative-battles.mjs | 126 ++++++++++++++++-- .../images/battle/second-battle-map.svg | 3 + src/game/data/battles.ts | 16 +-- src/game/data/campaignFlow.ts | 2 +- src/game/data/scenario.ts | 60 ++++----- src/game/scenes/BattleScene.ts | 96 ++++++++++++- src/game/scenes/CampScene.ts | 8 +- 7 files changed, 255 insertions(+), 56 deletions(-) diff --git a/scripts/qa-representative-battles.mjs b/scripts/qa-representative-battles.mjs index 1641d64..80c4c31 100644 --- a/scripts/qa-representative-battles.mjs +++ b/scripts/qa-representative-battles.mjs @@ -6,6 +6,76 @@ const headless = process.env.QA_HEADLESS !== '0'; const maxRounds = Number(process.env.QA_MAX_ROUNDS ?? 80); const pnpmCommand = process.env.PNPM_CMD ?? 'pnpm'; +const coreBrothers = ['liu-bei', 'guan-yu', 'zhang-fei']; +const xuzhouFullSortie = ['liu-bei', 'guan-yu', 'zhang-fei', 'jian-yong', 'mi-zhu']; + +const earlyCampaignBattles = [ + { no: 1, id: 'first-battle-zhuo-commandery', selected: [], targets: ['rebel-leader'], protected: ['liu-bei'] }, + { + no: 2, + id: 'second-battle-yellow-turban-pursuit', + selected: coreBrothers, + targets: ['pursuit-leader-han-seok'], + protected: ['liu-bei'] + }, + { + no: 3, + id: 'third-battle-guangzong-road', + selected: coreBrothers, + targets: ['guangzong-leader-ma-yuan'], + protected: ['liu-bei'] + }, + { + no: 4, + id: 'fourth-battle-guangzong-camp', + selected: coreBrothers, + targets: ['guangzong-main-leader-zhang-jue'], + protected: ['liu-bei'] + }, + { + no: 5, + id: 'fifth-battle-sishui-vanguard', + selected: coreBrothers, + targets: ['sishui-leader-hu-zhen'], + protected: ['liu-bei'] + }, + { + no: 6, + id: 'sixth-battle-jieqiao-relief', + selected: coreBrothers, + targets: ['jieqiao-leader-qu-yi'], + protected: ['liu-bei'] + }, + { + no: 7, + id: 'seventh-battle-xuzhou-rescue', + selected: coreBrothers, + targets: ['xuzhou-leader-xiahou-dun'], + protected: ['liu-bei'] + }, + { + no: 8, + id: 'eighth-battle-xiaopei-supply-road', + selected: xuzhouFullSortie, + targets: ['xiaopei-leader-gao-shun'], + protected: ['liu-bei'] + }, + { + no: 9, + id: 'ninth-battle-xuzhou-gate-night-raid', + selected: xuzhouFullSortie, + targets: ['xuzhou-leader-cao-bao'], + protected: ['liu-bei'] + }, + { + no: 10, + id: 'tenth-battle-xuzhou-breakout', + selected: xuzhouFullSortie, + targets: ['xuzhou-escape-leader-song-xian'], + protected: ['liu-bei'] + } +]; + const allRepresentativeBattles = [ { no: 1, id: 'first-battle-zhuo-commandery', selected: [], targets: ['rebel-leader'], protected: ['liu-bei'] }, { @@ -66,6 +136,16 @@ const allRepresentativeBattles = [ } ]; +const qaBattleSets = { + representative: allRepresentativeBattles, + early: earlyCampaignBattles +}; +const qaSetName = process.env.QA_SET ?? 'representative'; +const qaBattleSet = qaBattleSets[qaSetName]; +if (!qaBattleSet) { + throw new Error(`Unknown QA_SET "${qaSetName}". Use one of: ${Object.keys(qaBattleSets).join(', ')}`); +} + const requestedBattles = new Set( (process.env.QA_BATTLES ?? '') .split(',') @@ -74,8 +154,8 @@ const requestedBattles = new Set( ); const representativeBattles = requestedBattles.size > 0 - ? allRepresentativeBattles.filter((battle) => requestedBattles.has(String(battle.no)) || requestedBattles.has(battle.id)) - : allRepresentativeBattles; + ? qaBattleSet.filter((battle) => requestedBattles.has(String(battle.no)) || requestedBattles.has(battle.id)) + : qaBattleSet; let serverProcess; @@ -242,11 +322,28 @@ async function playBattleWithoutDebugVictory(page, battle) { } function focusTarget(unit) { + const enemies = liveUnits('enemy'); + const canProgressTo = (enemy) => { + if (scene.canAttack(unit, enemy)) { + return true; + } + const approachTile = scene.chooseApproachTile?.(unit, enemy); + return Boolean(approachTile && (approachTile.x !== unit.x || approachTile.y !== unit.y)); + }; + const progressEnemies = enemies.filter(canProgressTo); + const nearestEnemy = progressEnemies.sort((a, b) => distance(unit, a) - distance(unit, b) || priorityForTarget(b) - priorityForTarget(a))[0]; const aliveTargets = targets.map((targetId) => unitRef(targetId)).filter((target) => target && target.hp > 0); if (aliveTargets.length > 0) { - return aliveTargets.sort((a, b) => distance(unit, a) - distance(unit, b))[0]; + const reachableTargets = aliveTargets.filter(canProgressTo); + const nearestTarget = (reachableTargets.length > 0 ? reachableTargets : aliveTargets).sort((a, b) => distance(unit, a) - distance(unit, b))[0]; + if (nearestEnemy && nearestEnemy.id !== nearestTarget.id && distance(unit, nearestEnemy) + 5 < distance(unit, nearestTarget)) { + return nearestEnemy; + } + return nearestTarget; } - return liveUnits('enemy').sort((a, b) => priorityForTarget(b) - priorityForTarget(a) || distance(unit, a) - distance(unit, b))[0]; + return (progressEnemies.length > 0 ? progressEnemies : enemies).sort( + (a, b) => priorityForTarget(b) - priorityForTarget(a) || distance(unit, a) - distance(unit, b) + )[0]; } function attackChoice(unit) { @@ -300,13 +397,12 @@ async function playBattleWithoutDebugVictory(page, battle) { } const focus = focusTarget(unit); const enemies = liveUnits('enemy'); - const vanguardInPosition = liveUnits('ally').some( - (ally) => ally.id !== unit.id && !protectedIds.has(ally.id) && (!focus || distance(ally, focus) <= 10) - ); - return ( - !vanguardInPosition || - (focus && targetIds.has(focus.id) && focus.hp <= Math.ceil(focus.maxHp * 0.3)) - ); + 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 Boolean(vanguardInPosition && focus && targetIds.has(focus.id) && focus.hp <= Math.ceil(focus.maxHp * 0.3)); } function canTakeAttack(unit, attack, threat = protectedIds.has(unit.id) ? incomingThreat(unit, unit) : 0) { @@ -367,6 +463,14 @@ async function playBattleWithoutDebugVictory(page, battle) { const focus = focusTarget(unit); if (focus) { const currentDistance = distance(unit, focus); + const approachTile = scene.chooseApproachTile?.(unit, focus); + if ( + approachTile && + (approachTile.x !== unit.x || approachTile.y !== unit.y) && + (!pressProtected || canPressProtectedTile(unit, approachTile, focus, currentDistance)) + ) { + return approachTile; + } const pressureTile = tiles .filter((tile) => distance(tile, focus) < currentDistance) .filter((tile) => !pressProtected || canPressProtectedTile(unit, tile, focus, currentDistance)) diff --git a/src/assets/images/battle/second-battle-map.svg b/src/assets/images/battle/second-battle-map.svg index 5e9399b..5b551c9 100644 --- a/src/assets/images/battle/second-battle-map.svg +++ b/src/assets/images/battle/second-battle-map.svg @@ -46,6 +46,9 @@ + + + diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index f5bb87f..31dd40f 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -382,7 +382,7 @@ export const firstBattleScenario: BattleScenarioDefinition = { openingObjectiveLines: [ '두령 한석을 격파하면 황건적의 전열이 무너집니다.', '유비가 퇴각하면 의용군은 전투를 지속할 수 없습니다.', - '마을을 확보하고 8턴 안에 승리하면 추가 보상이 붙습니다.' + '마을 지형을 밟아 보급을 확보하고, 다친 장수는 콩과 탁주로 회복하십시오.' ], map: firstBattleMap, units: firstBattleUnits, @@ -434,15 +434,15 @@ export const secondBattleScenario: BattleScenarioDefinition = { defeatConditionLabel: '유비 퇴각', openingObjectiveLines: [ '황건 잔당이 북쪽 마을로 달아났습니다. 두령 한석을 격파하면 적의 퇴로가 무너집니다.', - '마을 주변에 적 위협이 남아 있으면 보상이 줄어듭니다. 길목의 습격조를 먼저 정리하십시오.', - '12턴 이내에 승리하면 의용군의 추격전 명성이 오릅니다.' + '중앙 나루를 건너기 전에 길목의 습격조를 먼저 정리하면 북쪽 마을을 안전하게 확보할 수 있습니다.', + '세 형제를 모두 출전시키고 14턴 이내에 승리하면 추격전 명성과 추가 보상이 붙습니다.' ], map: secondBattleMap, units: secondBattleUnits, bonds: secondBattleBonds, mapTextureKey: 'battle-map-second', leaderUnitId: 'pursuit-leader-han-seok', - quickVictoryTurnLimit: 12, + quickVictoryTurnLimit: 14, baseVictoryGold: 420, objectives: [ { @@ -469,13 +469,13 @@ export const secondBattleScenario: BattleScenarioDefinition = { { id: 'quick', kind: 'quick-victory', - label: '12턴 이내 승리', + label: '14턴 이내 승리', rewardGold: 150, - maxTurn: 12 + maxTurn: 14 } ], defeatConditions: [{ kind: 'unit-defeated', unitId: 'liu-bei' }], - itemRewards: ['콩 2', '탁주 1', '의용군 명성 +2'], + itemRewards: ['콩 2', '상처약 1', '탁주 1', '의용군 명성 +2'], victoryPages: secondBattleVictoryPages, nextCampScene: 'CampScene' }; @@ -646,7 +646,7 @@ export const sixthBattleScenario: BattleScenarioDefinition = { defeatConditionLabel: '유비 퇴각', openingObjectiveLines: [ '공손찬의 보급로를 원소군 교위 곡의가 끊으려 합니다. 곡의를 격파하면 계교로 향하는 길을 다시 열 수 있습니다.', - '강가의 궁병과 측면 기병이 동시에 압박합니다. 숲길 척후를 먼저 밀어내고 중앙 길을 확보하십시오.', + '강가의 궁병과 측면 기병이 동시에 압박합니다. 유비는 뒤에 두고 관우와 장비로 중앙 길을 확보하십시오.', '15턴 이내에 승리하면 공손찬 진영에서 세 형제의 신뢰가 커집니다.' ], map: sixthBattleMap, diff --git a/src/game/data/campaignFlow.ts b/src/game/data/campaignFlow.ts index 3d1a903..9d9c9f3 100644 --- a/src/game/data/campaignFlow.ts +++ b/src/game/data/campaignFlow.ts @@ -223,7 +223,7 @@ const sortieFlows: Record = { afterBattleId: defaultBattleScenario.id, eyebrow: '다음 전장', title: secondBattleScenario.title, - description: '탁현을 물러난 황건 잔당이 인근 마을을 위협하고 있습니다. 의용군은 첫 승리의 기세를 몰아 추격에 나섭니다.', + description: '탁현을 물러난 황건 잔당이 인근 마을을 위협하고 있습니다. 의용군은 중앙 나루를 확보하며 첫 승리의 기세를 몰아 추격에 나섭니다.', rewardHint: '예상 보상: 군자금, 소모품, 의용군 명성', nextBattleId: secondBattleScenario.id, campaignStep: 'second-battle', diff --git a/src/game/data/scenario.ts b/src/game/data/scenario.ts index b8ce9c1..d8e63d3 100644 --- a/src/game/data/scenario.ts +++ b/src/game/data/scenario.ts @@ -4244,8 +4244,8 @@ export const secondBattleMap: BattleMap = { ['plain', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'hill', 'plain', 'plain', 'plain', 'river', 'river', 'road', 'plain', 'plain', 'hill', 'plain', 'village', 'plain', 'forest', 'forest', 'plain'], ['plain', 'plain', 'forest', 'plain', 'road', 'plain', 'plain', 'hill', 'hill', 'plain', 'plain', 'plain', 'river', 'river', 'road', 'road', 'plain', 'plain', 'plain', 'village', 'plain', 'plain', 'forest', 'plain'], ['plain', 'forest', 'forest', 'plain', 'road', 'plain', 'plain', 'plain', 'hill', 'plain', 'road', 'road', 'plain', 'river', 'river', 'road', 'plain', 'plain', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain'], - ['plain', 'forest', 'plain', 'road', 'road', 'plain', 'hill', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'hill', 'hill', 'plain'], - ['plain', 'plain', 'plain', 'road', 'plain', 'plain', 'hill', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain'], + ['plain', 'forest', 'plain', 'road', 'road', 'plain', 'hill', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'hill', 'hill', 'plain'], + ['plain', 'plain', 'plain', 'road', 'plain', 'plain', 'hill', 'plain', 'road', 'road', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain', 'hill', 'plain'], ['hill', 'plain', 'plain', 'road', 'plain', 'plain', 'plain', 'road', 'road', 'plain', 'plain', 'village', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain', 'plain'], ['hill', 'hill', 'plain', 'road', 'plain', 'forest', 'plain', 'road', 'plain', 'plain', 'village', 'village', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'forest', 'plain', 'plain', 'plain', 'plain'], ['cliff', 'hill', 'plain', 'road', 'road', 'plain', 'plain', 'road', 'plain', 'plain', 'plain', 'village', 'plain', 'plain', 'plain', 'river', 'river', 'plain', 'plain', 'forest', 'plain', 'plain', 'plain', 'plain'], @@ -6119,9 +6119,9 @@ export const sixthBattleUnits: UnitData[] = [ classKey: 'archer', level: 6, exp: 38, - hp: 29, - maxHp: 29, - attack: 12, + hp: 27, + maxHp: 27, + attack: 10, move: 3, stats: { might: 66, intelligence: 54, leadership: 54, agility: 66, luck: 50 }, equipment: { @@ -6140,9 +6140,9 @@ export const sixthBattleUnits: UnitData[] = [ classKey: 'cavalry', level: 7, exp: 40, - hp: 39, - maxHp: 39, - attack: 13, + hp: 37, + maxHp: 37, + attack: 12, move: 5, stats: { might: 78, intelligence: 42, leadership: 60, agility: 78, luck: 52 }, equipment: { @@ -6163,7 +6163,7 @@ export const sixthBattleUnits: UnitData[] = [ exp: 38, hp: 33, maxHp: 33, - attack: 12, + attack: 10, move: 4, stats: { might: 74, intelligence: 44, leadership: 51, agility: 69, luck: 51 }, equipment: { @@ -6182,9 +6182,9 @@ export const sixthBattleUnits: UnitData[] = [ classKey: 'archer', level: 7, exp: 40, - hp: 30, - maxHp: 30, - attack: 13, + hp: 27, + maxHp: 27, + attack: 9, move: 3, stats: { might: 68, intelligence: 56, leadership: 56, agility: 67, luck: 51 }, equipment: { @@ -6203,9 +6203,9 @@ export const sixthBattleUnits: UnitData[] = [ classKey: 'cavalry', level: 7, exp: 42, - hp: 40, - maxHp: 40, - attack: 13, + hp: 38, + maxHp: 38, + attack: 12, move: 5, stats: { might: 79, intelligence: 42, leadership: 61, agility: 79, luck: 52 }, equipment: { @@ -6224,9 +6224,9 @@ export const sixthBattleUnits: UnitData[] = [ classKey: 'yellowTurban', level: 7, exp: 42, - hp: 38, - maxHp: 38, - attack: 12, + hp: 26, + maxHp: 26, + attack: 8, move: 3, stats: { might: 76, intelligence: 45, leadership: 62, agility: 58, luck: 51 }, equipment: { @@ -6245,9 +6245,9 @@ export const sixthBattleUnits: UnitData[] = [ classKey: 'cavalry', level: 7, exp: 44, - hp: 41, - maxHp: 41, - attack: 13, + hp: 34, + maxHp: 34, + attack: 10, move: 5, stats: { might: 80, intelligence: 43, leadership: 62, agility: 80, luck: 53 }, equipment: { @@ -6266,9 +6266,9 @@ export const sixthBattleUnits: UnitData[] = [ classKey: 'yellowTurban', level: 7, exp: 44, - hp: 39, - maxHp: 39, - attack: 12, + hp: 26, + maxHp: 26, + attack: 8, move: 3, stats: { might: 77, intelligence: 45, leadership: 63, agility: 58, luck: 52 }, equipment: { @@ -6287,9 +6287,9 @@ export const sixthBattleUnits: UnitData[] = [ classKey: 'archer', level: 7, exp: 44, - hp: 31, - maxHp: 31, - attack: 13, + hp: 20, + maxHp: 20, + attack: 7, move: 3, stats: { might: 69, intelligence: 57, leadership: 57, agility: 68, luck: 52 }, equipment: { @@ -6308,9 +6308,9 @@ export const sixthBattleUnits: UnitData[] = [ classKey: 'rebelLeader', level: 8, exp: 48, - hp: 54, - maxHp: 54, - attack: 15, + hp: 38, + maxHp: 38, + attack: 10, move: 3, stats: { might: 84, intelligence: 62, leadership: 78, agility: 62, luck: 56 }, equipment: { diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index b935ebe..fcf6685 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -2853,6 +2853,7 @@ export class BattleScene extends Phaser.Scene { private cameraTileY = 0; private edgeScrollElapsed = 0; private suppressNextLeftClick = false; + private approachPathCostCache = new Map>(); constructor() { super('BattleScene'); @@ -2886,6 +2887,7 @@ export class BattleScene extends Phaser.Scene { this.battleLog = []; this.actedUnitIds.clear(); this.attackIntents = []; + this.approachPathCostCache.clear(); this.selectedUnit = undefined; this.pendingMove = undefined; this.targetingAction = undefined; @@ -6714,17 +6716,107 @@ export class BattleScene extends Phaser.Scene { private chooseApproachTile(enemy: UnitData, target: UnitData) { const candidates = [{ x: enemy.x, y: enemy.y, cost: 0 }, ...this.reachableTiles(enemy)]; + const pathCosts = this.cachedPathCostsToAttackRange(enemy, target); return candidates .filter((tile) => !this.isOccupied(tile.x, tile.y, enemy.id)) .sort((a, b) => { - const aDistance = Math.abs(a.x - target.x) + Math.abs(a.y - target.y); - const bDistance = Math.abs(b.x - target.x) + Math.abs(b.y - target.y); + const aPathCost = pathCosts.get(this.tileKey(a.x, a.y)) ?? Number.POSITIVE_INFINITY; + const bPathCost = pathCosts.get(this.tileKey(b.x, b.y)) ?? Number.POSITIVE_INFINITY; + const aHasPath = Number.isFinite(aPathCost); + const bHasPath = Number.isFinite(bPathCost); + if (aHasPath !== bHasPath) { + return aHasPath ? -1 : 1; + } + const aDistance = aHasPath ? aPathCost : Math.abs(a.x - target.x) + Math.abs(a.y - target.y); + const bDistance = bHasPath ? bPathCost : Math.abs(b.x - target.x) + Math.abs(b.y - target.y); const aTerrain = getTerrainRule(battleMap.terrain[a.y][a.x]).defenseBonus; const bTerrain = getTerrainRule(battleMap.terrain[b.y][b.x]).defenseBonus; return aDistance - bDistance || bTerrain - aTerrain || a.cost - b.cost; })[0]; } + private cachedPathCostsToAttackRange(unit: UnitData, target: UnitData) { + const key = this.approachPathCacheKey(unit, target); + const cached = this.approachPathCostCache.get(key); + if (cached) { + return cached; + } + + if (this.approachPathCostCache.size > 180) { + this.approachPathCostCache.clear(); + } + const costs = this.pathCostsToAttackRange(unit, target); + this.approachPathCostCache.set(key, costs); + return costs; + } + + private approachPathCacheKey(unit: UnitData, target: UnitData) { + const blockers = battleUnits + .filter((candidate) => candidate.id !== unit.id && candidate.hp > 0) + .map((candidate) => `${candidate.id}:${candidate.x},${candidate.y}`) + .join('|'); + return `${unit.id}:${unit.classKey}:${unit.faction}:${target.id}:${target.x},${target.y}:${blockers}`; + } + + private pathCostsToAttackRange(unit: UnitData, target: UnitData) { + const range = this.attackRange(unit); + const directions = [ + { x: 1, y: 0 }, + { x: -1, y: 0 }, + { x: 0, y: 1 }, + { x: 0, y: -1 } + ]; + const costs = new Map(); + const queue: Array<{ x: number; y: number; cost: number }> = []; + + this.tilesWithinDistance(target.x, target.y, range).forEach((tile) => { + if (this.isMovementBlocked(unit, tile.x, tile.y) || this.isOccupied(tile.x, tile.y, unit.id)) { + return; + } + + const stepCost = this.movementCost(unit, battleMap.terrain[tile.y][tile.x]); + if (!Number.isFinite(stepCost)) { + return; + } + + const key = this.tileKey(tile.x, tile.y); + costs.set(key, 0); + queue.push({ ...tile, cost: 0 }); + }); + + while (queue.length > 0) { + queue.sort((a, b) => a.cost - b.cost); + const current = queue.shift(); + if (!current) { + break; + } + + for (const direction of directions) { + const nextX = current.x + direction.x; + const nextY = current.y + direction.y; + if (!this.isInBounds(nextX, nextY) || this.isMovementBlocked(unit, nextX, nextY)) { + continue; + } + + const stepCost = this.movementCost(unit, battleMap.terrain[current.y][current.x]); + if (!Number.isFinite(stepCost)) { + continue; + } + + const nextCost = current.cost + stepCost; + const key = this.tileKey(nextX, nextY); + if (nextCost >= (costs.get(key) ?? Number.POSITIVE_INFINITY)) { + continue; + } + + costs.set(key, nextCost); + queue.push({ x: nextX, y: nextY, cost: nextCost }); + } + } + + return costs; + } + private nearestAliveUnit(unit: UnitData, faction: UnitData['faction']) { return battleUnits .filter((candidate) => candidate.faction === faction && candidate.hp > 0) diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 9d96fa8..3c7367d 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -559,7 +559,7 @@ const sortieRulesByBattleId: Partial