From 05fdc3dff4984686316a2bf51b5d0fa4500bdd7d Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sat, 4 Jul 2026 12:03:35 +0900 Subject: [PATCH] Tune early battle objective QA --- scripts/qa-representative-battles.mjs | 57 +++++++++++++++++++++++++-- src/game/data/battles.ts | 10 ++++- src/game/scenes/BattleScene.ts | 46 ++++++++++++++++++--- 3 files changed, 102 insertions(+), 11 deletions(-) diff --git a/scripts/qa-representative-battles.mjs b/scripts/qa-representative-battles.mjs index 5bbffd6..a9ac3b8 100644 --- a/scripts/qa-representative-battles.mjs +++ b/scripts/qa-representative-battles.mjs @@ -674,9 +674,17 @@ try { const stockLine = result.campaign ? ` stock=${result.campaign.supplies.bean}/${result.campaign.supplies.salve}/${result.campaign.supplies.wine} gold=${result.campaign.gold} avgLv=${result.campaign.levelAvg}` : ''; + const missedObjectiveLine = result.objectiveDetails?.some((objective) => !objective.achieved) + ? ` missed=${result.objectiveDetails.filter((objective) => !objective.achieved).map((objective) => objective.id).join(',')}` + : ''; console.log( - `QA result ${result.no}: ${result.outcome} turn=${result.finalTurn} allies=${result.alliesAlive} enemies=${result.enemiesAlive} items=${result.itemsUsed} objectives=${result.objectivesAchieved}/${result.objectiveCount}${stockLine}` + `QA result ${result.no}: ${result.outcome} turn=${result.finalTurn} allies=${result.alliesAlive} enemies=${result.enemiesAlive} items=${result.itemsUsed} objectives=${result.objectivesAchieved}/${result.objectiveCount}${missedObjectiveLine}${stockLine}` ); + if (missedObjectiveLine) { + console.log( + `QA missed detail ${result.no}: objectives=${JSON.stringify(result.objectiveDetails)} allies=${result.allyStatus.join(' | ')} enemies=${result.enemyStatus.join(' | ')}` + ); + } if (result.outcome !== 'victory') { break; } @@ -698,7 +706,8 @@ try { level: result.campaign ? `${result.campaign.levelMin}-${result.campaign.levelMax} (${result.campaign.levelAvg})` : '', lowHp: result.campaign?.lowHp ?? '', supports: result.supports, - objectives: `${result.objectivesAchieved}/${result.objectiveCount}` + objectives: `${result.objectivesAchieved}/${result.objectiveCount}`, + missed: result.objectiveDetails?.filter((objective) => !objective.achieved).map((objective) => objective.id).join(',') ?? '' })) ); @@ -777,11 +786,17 @@ async function setupBattle(page, battle) { await page.waitForFunction( (battleId) => { const state = window.__HEROS_DEBUG__?.battle(); - return state?.scene === 'BattleScene' && state?.battleId === battleId && state?.phase === 'idle' && state?.mapBackgroundReady === true; + return ( + state?.scene === 'BattleScene' && + state?.battleId === battleId && + (state?.phase === 'deployment' || state?.phase === 'idle') && + state?.mapBackgroundReady === true + ); }, battle.id, { timeout: 90000 } ); + await startDeploymentIfNeeded(page, battle.id); } async function setupCumulativeBattle(page, battle, firstBattle) { @@ -907,14 +922,41 @@ async function setupCumulativeBattle(page, battle, firstBattle) { await page.waitForFunction( (battleId) => { const state = window.__HEROS_DEBUG__?.battle(); - return state?.scene === 'BattleScene' && state?.battleId === battleId && state?.phase === 'idle' && state?.mapBackgroundReady === true; + return ( + state?.scene === 'BattleScene' && + state?.battleId === battleId && + (state?.phase === 'deployment' || state?.phase === 'idle') && + state?.mapBackgroundReady === true + ); }, battle.id, { timeout: 90000 } ); + await startDeploymentIfNeeded(page, battle.id); return preparation; } +async function startDeploymentIfNeeded(page, battleId) { + const state = await page.evaluate((expectedBattleId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return battle?.battleId === expectedBattleId ? battle : undefined; + }, battleId); + + if (state?.phase !== 'deployment') { + return; + } + + await page.mouse.click(1085, 637); + await page.waitForFunction( + (expectedBattleId) => { + const battle = window.__HEROS_DEBUG__?.battle(); + return battle?.battleId === expectedBattleId && battle?.phase === 'idle'; + }, + battleId, + { timeout: 30000 } + ); +} + async function writeCampaignSnapshot(page, snapshotPath) { const snapshot = await page.evaluate( ({ slotKey, storageKey }) => window.localStorage.getItem(slotKey) ?? window.localStorage.getItem(storageKey), @@ -988,6 +1030,7 @@ async function playBattleWithoutDebugVictory(page, battle) { enemyAttacks: 0, objectivesAchieved: 0, objectiveCount: 0, + objectiveDetails: [], protectedStatus: [], targetStatus: [], allyStatus: [], @@ -1399,6 +1442,12 @@ async function playBattleWithoutDebugVictory(page, battle) { metrics.enemiesAlive = finalState.units.filter((unit) => unit.faction === 'enemy' && unit.hp > 0).length; metrics.objectiveCount = finalState.objectives.length; metrics.objectivesAchieved = finalState.objectives.filter((objective) => objective.achieved).length; + metrics.objectiveDetails = finalState.objectives.map((objective) => ({ + id: objective.id, + achieved: objective.achieved, + status: objective.status, + detail: objective.detail + })); metrics.protectedStatus = protectedUnitIds.map((unitId) => { const unit = finalState.units.find((candidate) => candidate.id === unitId); return unit ? `${unit.id}:${unit.hp}/${unit.maxHp}@${unit.x},${unit.y}` : `${unitId}:missing`; diff --git a/src/game/data/battles.ts b/src/game/data/battles.ts index e000551..d730698 100644 --- a/src/game/data/battles.ts +++ b/src/game/data/battles.ts @@ -348,6 +348,8 @@ export type BattleObjectiveDefinition = { rewardGold: number; unitId?: string; terrain?: string; + targetTile?: { x: number; y: number; radius?: number }; + threatRadius?: number; maxTurn?: number; }; @@ -509,7 +511,9 @@ export const firstBattleScenario: BattleScenarioDefinition = { kind: 'secure-terrain', label: '마을 확보', rewardGold: 150, - terrain: 'village' + terrain: 'village', + targetTile: { x: 15, y: 5, radius: 4 }, + threatRadius: 1 }, { id: 'quick', @@ -720,7 +724,9 @@ export const thirdBattleScenario: BattleScenarioDefinition = { kind: 'secure-terrain', label: '강가 요새 확보', rewardGold: 220, - terrain: 'fort' + terrain: 'fort', + targetTile: { x: 19, y: 2, radius: 2 }, + threatRadius: 0 }, { id: 'quick', diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 5efe088..d936bd9 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -8598,8 +8598,9 @@ export class BattleScene extends Phaser.Scene { if (objective.kind === 'secure-terrain') { const terrain = (objective.terrain ?? 'village') as TerrainType; - const allyHolding = this.isAnyAllyNearTerrain(terrain, 1); - const secured = allyHolding && !this.hasEnemyThreateningTerrain(terrain, 2); + const allyHolding = this.isObjectiveTileHeld(objective, terrain, 1); + const enemiesAlive = battleUnits.some((unit) => unit.faction === 'enemy' && unit.hp > 0); + const secured = (allyHolding || !enemiesAlive) && !this.hasEnemyThreateningObjective(objective, terrain, 2); const terrainLabel = terrain === 'village' ? '마을 주변' : terrain === 'camp' ? '군영/수용소' : terrain === 'fort' ? '요새' : '목표 지형'; const achieved = outcome ? outcome === 'victory' && secured : secured; return { @@ -10267,16 +10268,17 @@ export class BattleScene extends Phaser.Scene { ]); } + const secureObjective = this.tacticalSecureObjective(); const objectiveTerrain = this.tacticalObjectiveTerrain(); const objectiveTerrainLabel = this.tacticalTerrainLabel(objectiveTerrain); - if (this.isAnyAllyNearTerrain(objectiveTerrain, 2)) { + if (this.isObjectiveTileHeld(secureObjective, objectiveTerrain, 2)) { this.triggerTacticalEvent('village-approach', `${objectiveTerrainLabel} 접근`, [ `${objectiveTerrainLabel} 주변에 적이 버티고 있습니다.`, '주변의 적을 몰아내면 승리 보상이 추가됩니다.' ]); } - if (this.triggeredBattleEvents.has('village-approach') && !this.hasEnemyThreateningTerrain(objectiveTerrain, 2)) { + if (this.triggeredBattleEvents.has('village-approach') && !this.hasEnemyThreateningObjective(secureObjective, objectiveTerrain, 2)) { this.triggerTacticalEvent('village-secured', `${objectiveTerrainLabel} 확보`, [`${objectiveTerrainLabel} 주변의 적을 몰아냈습니다.`, '승리 시 목표 확보 보상이 추가됩니다.']); } @@ -10311,13 +10313,17 @@ export class BattleScene extends Phaser.Scene { } private tacticalObjectiveTerrain(): TerrainType { - const terrain = battleScenario.objectives.find((objective) => objective.kind === 'secure-terrain')?.terrain; + const terrain = this.tacticalSecureObjective()?.terrain; if (terrain === 'camp' || terrain === 'fort' || terrain === 'village') { return terrain; } return 'village'; } + private tacticalSecureObjective() { + return battleScenario.objectives.find((objective) => objective.kind === 'secure-terrain'); + } + private tacticalTerrainLabel(terrain: TerrainType) { if (terrain === 'fort') { return '요새'; @@ -10334,12 +10340,42 @@ export class BattleScene extends Phaser.Scene { }); } + private hasEnemyThreateningObjective(objective: BattleObjectiveDefinition | undefined, terrain: TerrainType, distance: number) { + const target = objective?.targetTile; + if (target) { + return this.isEnemyNearTile(target.x, target.y, objective.threatRadius ?? distance); + } + + return this.hasEnemyThreateningTerrain(terrain, objective?.threatRadius ?? distance); + } + private isAnyAllyNearTerrain(terrain: TerrainType, distance: number) { return battleUnits.some((unit) => { return unit.faction === 'ally' && unit.hp > 0 && this.isUnitNearTerrain(unit, terrain, distance); }); } + private isObjectiveTileHeld(objective: BattleObjectiveDefinition | undefined, terrain: TerrainType, distance: number) { + const target = objective?.targetTile; + if (target) { + return this.isAnyAllyNearTile(target.x, target.y, target.radius ?? distance); + } + + return this.isAnyAllyNearTerrain(terrain, distance); + } + + private isAnyAllyNearTile(x: number, y: number, distance: number) { + return battleUnits.some((unit) => { + return unit.faction === 'ally' && unit.hp > 0 && this.tileDistanceTo(unit.x, unit.y, x, y) <= distance; + }); + } + + private isEnemyNearTile(x: number, y: number, distance: number) { + return battleUnits.some((unit) => { + return unit.faction === 'enemy' && unit.hp > 0 && this.tileDistanceTo(unit.x, unit.y, x, y) <= distance; + }); + } + private isUnitNearTerrain(unit: UnitData, terrain: TerrainType, distance: number) { return battleMap.terrain.some((row, y) => { return row.some((tile, x) => tile === terrain && this.tileDistanceTo(unit.x, unit.y, x, y) <= distance);