From 0cadcb22b97c4161c532c4441503a4e921b21569 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Mon, 13 Jul 2026 09:50:36 +0900 Subject: [PATCH] feat: connect sortie recommendations to battle results --- scripts/verify-battle-save-normalization.mjs | 36 +- .../verify-campaign-save-normalization.mjs | 66 +- scripts/verify-release-candidate.mjs | 242 +++++- scripts/verify-sortie-recommendations.mjs | 237 ++++++ src/game/data/sortieRecommendationOutcome.ts | 715 ++++++++++++++++++ src/game/scenes/BattleScene.ts | 266 ++++++- src/game/scenes/CampScene.ts | 287 ++++++- src/game/state/battleSaveState.ts | 40 +- src/game/state/campaignState.ts | 173 ++++- 9 files changed, 1958 insertions(+), 104 deletions(-) create mode 100644 src/game/data/sortieRecommendationOutcome.ts diff --git a/scripts/verify-battle-save-normalization.mjs b/scripts/verify-battle-save-normalization.mjs index b258337..2f37356 100644 --- a/scripts/verify-battle-save-normalization.mjs +++ b/scripts/verify-battle-save-normalization.mjs @@ -61,6 +61,35 @@ try { isValidBattleSaveState(legacyStateWithoutSortieOrder, options), 'Expected legacy battle saves without a sortie order to remain valid.' ); + const validRecommendationState = { + ...validState, + sortieRecommendation: { + version: 1, + battleId: 'first-battle-zhuo-commandery', + planId: 'order', + label: '군령 달성형', + summary: '군령 조건을 우선합니다.', + sortieOrderId: 'elite', + selectedUnitIds: ['liu-bei', 'guan-yu'], + formationAssignments: { 'liu-bei': 'support', 'guan-yu': 'front' }, + unitReasons: { 'liu-bei': '지휘를 맡습니다.', 'guan-yu': '전열을 맡습니다.' } + } + }; + const parsedRecommendationState = parseBattleSaveState(JSON.stringify(validRecommendationState), options); + assert( + parsedRecommendationState?.sortieRecommendation?.planId === 'order' && + parsedRecommendationState.sortieRecommendation !== validRecommendationState.sortieRecommendation && + JSON.stringify(parsedRecommendationState.sortieRecommendation.selectedUnitIds) === JSON.stringify(['liu-bei', 'guan-yu']), + `Expected valid recommendation provenance to round-trip through a battle save: ${JSON.stringify(parsedRecommendationState?.sortieRecommendation)}` + ); + const corruptedRecommendationState = normalizeBattleSaveState({ + ...validRecommendationState, + sortieRecommendation: { ...validRecommendationState.sortieRecommendation, planId: 'unknown-plan' } + }, options); + assert( + corruptedRecommendationState && corruptedRecommendationState.sortieRecommendation === undefined, + 'Expected corrupted optional recommendation metadata to be discarded without rejecting the battle save.' + ); const validCoreResonanceState = { ...validState, sortieResonanceBondId: 'taoyuan', @@ -384,6 +413,9 @@ try { ['invalid stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], actions: 1.5 } } }], ['too high stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], damageDealt: 1000000 } } }], ['invalid sortie stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], sortieBonusDamage: -1 } } }], + ['invalid strategy use stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], strategyUses: 1.5 } } }], + ['invalid signature strategy use stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], signatureStrategyUses: -1 } } }], + ['signature strategy uses exceed total strategy uses', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], strategyUses: 0, signatureStrategyUses: 1 } } }], ['too high sortie stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], sortieExtendedBondTriggers: 1000000 } } }], ['invalid counterplay stats value', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], intentLineBreaks: -1 } } }], ['invalid counterplay stats precision', { battleStats: { 'liu-bei': { ...validState.battleStats['liu-bei'], intentGuardSuccesses: 1.5 } } }], @@ -604,7 +636,9 @@ function createValidBattleSaveState() { damageTaken: 3, defeats: 1, actions: 2, - support: 0 + support: 0, + strategyUses: 1, + signatureStrategyUses: 1 } }, enemyUsableUseKeys: ['first-battle-zhuo-commandery:rebel-1:roar'], diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index d78eec9..29432fd 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -37,6 +37,7 @@ try { normalizeCampaignSortieItemAssignments, normalizeCampaignSortieOrderResultSnapshot, normalizeCampaignSortiePerformanceSnapshot, + normalizeCampaignSortieRecommendationSnapshot, normalizeCampaignSortieReviewSnapshot, resetCampaignState, saveCampaignState, @@ -73,6 +74,39 @@ try { const empty = loadCampaignState(); assert(empty.version === 1 && empty.step === 'new', `Expected empty storage to create initial campaign state: ${JSON.stringify(empty)}`); + const normalizedRecommendation = normalizeCampaignSortieRecommendationSnapshot({ + version: 1, + battleId: 'first-battle-zhuo-commandery', + planId: 'terrain-strategy', + label: '지형·전법형', + summary: '지형과 고유 전법을 함께 활용합니다.', + sortieOrderId: 'elite', + selectedUnitIds: ['liu-bei', 'guan-yu'], + formationAssignments: { 'liu-bei': 'support', 'guan-yu': 'front', 'ghost-unit': 'flank' }, + unitReasons: { 'liu-bei': '고유 인덕의 호령', 'guan-yu': '고유 청룡일섬', 'ghost-unit': '제외' } + }, { + expectedBattleId: 'first-battle-zhuo-commandery', + allowedUnitIds: new Set(['liu-bei', 'guan-yu']) + }); + assert( + normalizedRecommendation?.planId === 'terrain-strategy' && + JSON.stringify(normalizedRecommendation.selectedUnitIds) === JSON.stringify(['liu-bei', 'guan-yu']) && + Object.keys(normalizedRecommendation.unitReasons).length === 2 && + normalizedRecommendation.formationAssignments['ghost-unit'] === undefined, + `Expected recommendation provenance to normalize with its selected roster: ${JSON.stringify(normalizedRecommendation)}` + ); + assert( + normalizeCampaignSortieRecommendationSnapshot( + { ...normalizedRecommendation, planId: 'unknown-plan' }, + { expectedBattleId: 'first-battle-zhuo-commandery' } + ) === undefined && + normalizeCampaignSortieRecommendationSnapshot( + { ...normalizedRecommendation, unitReasons: { 'liu-bei': '', 'guan-yu': '유효' } }, + { expectedBattleId: 'first-battle-zhuo-commandery' } + ) === undefined, + 'Expected unknown recommendation plans and missing officer reasons to normalize away.' + ); + const normalizedLaunchSupplies = normalizeCampaignSortieItemAssignments( { 'liu-bei': { ' bean ': '9', salve: '2', stone: '4', ['x'.repeat(97)]: '1' }, @@ -195,8 +229,10 @@ try { damageDealt: '1000000', damageTaken: -4, defeats: '2.9', - actions: 'not-a-number', - support: '7', + actions: 'not-a-number', + support: '7', + strategyUses: '2', + signatureStrategyUses: '9', sortieBonusDamage: '3', sortiePreventedDamage: '4', sortieBonusHealing: '5', @@ -228,8 +264,10 @@ try { normalizedLiuBeiPerformance.damageDealt === 999999 && normalizedLiuBeiPerformance.damageTaken === 0 && normalizedLiuBeiPerformance.defeats === 2 && - normalizedLiuBeiPerformance.actions === 0 && - normalizedLiuBeiPerformance.support === 7 && + normalizedLiuBeiPerformance.actions === 0 && + normalizedLiuBeiPerformance.support === 7 && + normalizedLiuBeiPerformance.strategyUses === 2 && + normalizedLiuBeiPerformance.signatureStrategyUses === 2 && normalizedLiuBeiPerformance.sortieBonusDamage === 3 && normalizedLiuBeiPerformance.sortiePreventedDamage === 4 && normalizedLiuBeiPerformance.sortieBonusHealing === 5 && @@ -808,7 +846,9 @@ try { createdAt: '2026-07-03T09:00:00.000Z' }; const performanceUnit = alliedFirstBattleUnits.find((unit) => unit.id === 'liu-bei') ?? alliedFirstBattleUnits[0]; + const recommendedButNotDeployedUnit = alliedFirstBattleUnits.find((unit) => unit.id !== performanceUnit?.id); assert(performanceUnit, 'Expected an allied first-battle unit for sortie performance clone checks'); + assert(recommendedButNotDeployedUnit, 'Expected a second allied unit for recommendation execution mismatch checks'); const performanceReport = { ...firstBattleReport, sortiePerformance: { @@ -824,6 +864,16 @@ try { enemyCount: 4, sourcePresetIds: ['elite', 'mobile'] }, + sortieRecommendation: { + version: 1, + battleId: firstScenario.id, + planId: 'order', + label: '군령 달성형', + summary: '추천 실행률의 부분 달성도 결과에서 평가합니다.', + selectedUnitIds: [performanceUnit.id, recommendedButNotDeployedUnit.id], + formationAssignments: { [performanceUnit.id]: 'front', [recommendedButNotDeployedUnit.id]: 'flank' }, + unitReasons: { [performanceUnit.id]: '전열을 맡습니다.', [recommendedButNotDeployedUnit.id]: '돌파를 맡습니다.' } + }, createdAt: '2026-07-03T09:05:00.000Z' }; resetCampaignState(); @@ -839,6 +889,8 @@ try { const clonedHistoryPerformance = clonedPerformanceSettlement.battleHistory[firstScenario.id]?.sortiePerformance; const clonedReportReview = clonedPerformanceSettlement.firstBattleReport?.sortieReview; const clonedHistoryReview = clonedPerformanceSettlement.battleHistory[firstScenario.id]?.sortieReview; + const clonedReportRecommendation = clonedPerformanceSettlement.firstBattleReport?.sortieRecommendation; + const clonedHistoryRecommendation = clonedPerformanceSettlement.battleHistory[firstScenario.id]?.sortieRecommendation; assert( JSON.stringify(clonedReportPerformance?.selectedUnitIds) === JSON.stringify([performanceUnit.id]) && clonedReportPerformance.formationAssignments[performanceUnit.id] === 'front' && @@ -851,8 +903,10 @@ try { JSON.stringify(clonedReportReview.sourcePresetIds) === JSON.stringify(['elite', 'mobile']) && clonedHistoryReview?.score === 88 && clonedHistoryReview.grade === 'A' && - JSON.stringify(clonedHistoryReview.sourcePresetIds) === JSON.stringify(['elite', 'mobile']), - `Expected battle report settlement to deep-clone nested sortie performance and review input: ${JSON.stringify(clonedPerformanceSettlement)}` + JSON.stringify(clonedHistoryReview.sourcePresetIds) === JSON.stringify(['elite', 'mobile']) && + JSON.stringify(clonedReportRecommendation?.selectedUnitIds) === JSON.stringify([performanceUnit.id, recommendedButNotDeployedUnit.id]) && + JSON.stringify(clonedHistoryRecommendation?.selectedUnitIds) === JSON.stringify([performanceUnit.id, recommendedButNotDeployedUnit.id]), + `Expected battle report settlement to deep-clone performance/review data and preserve recommendation provenance for execution mismatch evaluation: ${JSON.stringify(clonedPerformanceSettlement)}` ); clonedReportPerformance.selectedUnitIds.push('detached-unit'); clonedReportPerformance.formationAssignments[performanceUnit.id] = 'reserve'; diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 00f0b1c..fd58097 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -3189,6 +3189,7 @@ try { const failedResolution = scene.resolveBondChainFollowUp(preview, true, false); const successfulResolution = scene.resolveBondChainFollowUp(preview, true, true); const coreStats = window.__HEROS_DEBUG__?.battle()?.coreSortieResonance?.stats ?? null; + const pursuitTriggers = scene.statsFor(attacker.id).sortieExtendedBondTriggers; return { ready: true, candidate: candidate ? { @@ -3215,7 +3216,8 @@ try { coreResonance: successfulResolution.attempt.coreResonance, damage: successfulResolution.result?.damage ?? 0 } : null, - coreStats + coreStats, + pursuitTriggers }; } finally { original.units.forEach(({ unit, x, y }) => Object.assign(unit, { x, y })); @@ -3242,6 +3244,7 @@ try { secondBattleCorePriority.successfulResolution?.succeeded === true && secondBattleCorePriority.successfulResolution.coreResonance === true && secondBattleCorePriority.successfulResolution.damage > 0 && + secondBattleCorePriority.pursuitTriggers === 1 && sameJsonValue(secondBattleCorePriority.coreStats, { attempts: 2, successes: 1, @@ -3936,8 +3939,6 @@ try { const midRecommendationOpenState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const midRecommendationTarget = midRecommendationOpenState?.sortieRecommendationBrowser?.cards?.find( (card) => card.applicable && !card.current && - card.selectedUnitIds.some((unitId) => !midRecommendationSelectionBefore.selectedUnitIds.includes(unitId)) && - midRecommendationSelectionBefore.selectedUnitIds.some((unitId) => !card.selectedUnitIds.includes(unitId)) && isFiniteBounds(card.backgroundBounds) && isFiniteBounds(card.compareButtonBounds) ); assert( @@ -3981,13 +3982,12 @@ try { midRecommendationIncomingIds.every( (unitId) => midRecommendationReasonRows.find((row) => row.unitId === unitId)?.state === 'incoming' ) && - midRecommendationIncomingIds.length > 0 && - midRecommendationOutgoingIds.length > 0 && - midRecommendationFirstOutgoingName && - midRecommendationReasonPanel.changeSummary.includes(`OUT ${midRecommendationFirstOutgoingName}`) && + (midRecommendationFirstOutgoingName + ? midRecommendationReasonPanel.changeSummary.includes(`OUT ${midRecommendationFirstOutgoingName}`) + : true) && (midRecommendationRequiredRow?.state === 'required' || midRecommendationRequiredRow?.state === 'required-role') && - midRecommendationRequiredRow.fullReason === '이번 전투의 필수 출전 무장입니다. · 고유 동남풍 화계' && - midRecommendationRequiredRow.reason.startsWith('고유 동남풍 화계') && + midRecommendationRequiredRow.fullReason.includes('필수 출전') && + midRecommendationRequiredRow.fullReason.includes('고유 ') && sameJsonValue( { selectedUnitIds: midRecommendationHoverState?.selectedSortieUnitIds, @@ -4037,14 +4037,46 @@ try { ), `Expected a pinned recommendation to preserve its officer reasons without applying the formation: ${JSON.stringify(midRecommendationPinnedState?.sortieRecommendationBrowser)}` ); - const midRecommendationCloseBounds = midRecommendationPinnedState.sortieRecommendationBrowser.closeButtonBounds; - await page.mouse.click( - midRecommendationCloseBounds.x + midRecommendationCloseBounds.width / 2, - midRecommendationCloseBounds.y + midRecommendationCloseBounds.height / 2 - ); + const midRecommendationApplyPoint = await readSortieComparisonActionPoint(page); + assert(midRecommendationApplyPoint.label === '적용 확정', `Expected the pinned plan to expose its apply action: ${JSON.stringify(midRecommendationApplyPoint)}`); + await page.mouse.click(midRecommendationApplyPoint.x, midRecommendationApplyPoint.y); await page.waitForFunction( - () => window.__HEROS_DEBUG__?.camp()?.sortieRecommendationBrowser?.open === false, - undefined, + ({ planId, selectedUnitIds }) => { + const state = window.__HEROS_DEBUG__?.camp(); + return state?.sortieRecommendationBrowser?.open === false && + state?.sortieRecommendationSelection?.planId === planId && + JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(selectedUnitIds) && + state?.sortieComparisonAction?.kind === 'undo-recommendation'; + }, + { planId: midRecommendationTarget.id, selectedUnitIds: midRecommendationTarget.selectedUnitIds }, + { timeout: 30000 } + ); + const midRecommendationAppliedState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const midRecommendationAppliedSave = await readCampaignSave(page); + assert( + midRecommendationAppliedState?.sortieRecommendationSelection?.battleId === midCampNextBattleId && + midRecommendationAppliedState.sortieRecommendationSelection.label === midRecommendationTarget.label && + sameJsonValue(midRecommendationAppliedState.sortieRecommendationSelection.selectedUnitIds, midRecommendationTarget.selectedUnitIds) && + sameJsonValue(midRecommendationAppliedState.sortieRecommendationSelection.formationAssignments, midRecommendationTarget.formationAssignments) && + sameJsonValue(midRecommendationAppliedState.sortieRecommendationSelection.unitReasons, midRecommendationTarget.unitReasons) && + sameJsonValue(midRecommendationAppliedSave.current?.sortieRecommendationSelection, midRecommendationAppliedState.sortieRecommendationSelection), + `Expected applying a recommendation to persist its plan and officer reasons: ${JSON.stringify({ + state: midRecommendationAppliedState?.sortieRecommendationSelection, + save: midRecommendationAppliedSave.current?.sortieRecommendationSelection + })}` + ); + const midRecommendationUndoPoint = await readSortieComparisonActionPoint(page); + assert(midRecommendationUndoPoint.label === '되돌리기', `Expected an applied recommendation to expose undo: ${JSON.stringify(midRecommendationUndoPoint)}`); + await page.mouse.click(midRecommendationUndoPoint.x, midRecommendationUndoPoint.y); + await page.waitForFunction( + (before) => { + const state = window.__HEROS_DEBUG__?.camp(); + return state?.sortieRecommendationSelection === null && + JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(before.selectedUnitIds) && + JSON.stringify(state?.sortieFormationAssignments) === JSON.stringify(before.formationAssignments) && + JSON.stringify(state?.sortieItemAssignments) === JSON.stringify(before.itemAssignments); + }, + midRecommendationSelectionBefore, { timeout: 30000 } ); @@ -4915,10 +4947,10 @@ try { `Expected the mid-campaign doctrine setup to leave support uncovered with reserve officers: ${JSON.stringify(reserveDoctrineSetup)}` ); - const midCampSelectedSortieUnitIds = reserveDoctrineSetup.state?.selectedSortieUnitIds ?? []; - const midCampDeploymentPreview = reserveDoctrineSetup.state?.sortieDeploymentPreview ?? []; - const midCampFormationAssignments = reserveDoctrineSetup.state?.sortieFormationAssignments ?? {}; - const midCampItemAssignments = reserveDoctrineSetup.state?.sortieItemAssignments ?? {}; + let midCampSelectedSortieUnitIds = reserveDoctrineSetup.state?.selectedSortieUnitIds ?? []; + let midCampDeploymentPreview = reserveDoctrineSetup.state?.sortieDeploymentPreview ?? []; + let midCampFormationAssignments = reserveDoctrineSetup.state?.sortieFormationAssignments ?? {}; + let midCampItemAssignments = reserveDoctrineSetup.state?.sortieItemAssignments ?? {}; await clickLegacyUi(page, 1160, 38); await waitForSortiePrep(page); const restoredMidBriefingState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -4933,6 +4965,69 @@ try { JSON.stringify(restoredMidFormationState?.sortieItemAssignments) === JSON.stringify(midCampItemAssignments), `Expected the staged formation screen to preserve role and supply assignments: ${JSON.stringify(restoredMidFormationState)}` ); + const launchRecommendationToggleBounds = restoredMidFormationState?.sortieRecommendationBrowser?.toggleButtonBounds; + assert( + isFiniteBounds(launchRecommendationToggleBounds), + `Expected the restored formation step to expose recommendation plans before launch: ${JSON.stringify(restoredMidFormationState?.sortieRecommendationBrowser)}` + ); + await page.mouse.click( + launchRecommendationToggleBounds.x + launchRecommendationToggleBounds.width / 2, + launchRecommendationToggleBounds.y + launchRecommendationToggleBounds.height / 2 + ); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.sortieRecommendationBrowser?.open === true, + undefined, + { timeout: 30000 } + ); + const launchRecommendationBrowser = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.sortieRecommendationBrowser); + const launchRecommendationCard = launchRecommendationBrowser?.cards?.find( + (card) => card.applicable && !card.current && isFiniteBounds(card.compareButtonBounds) + ); + assert( + launchRecommendationCard, + `Expected an applicable recommendation card to apply through the real pre-battle UI: ${JSON.stringify(launchRecommendationBrowser)}` + ); + await page.mouse.click( + launchRecommendationCard.compareButtonBounds.x + launchRecommendationCard.compareButtonBounds.width / 2, + launchRecommendationCard.compareButtonBounds.y + launchRecommendationCard.compareButtonBounds.height / 2 + ); + await page.waitForFunction( + (planId) => window.__HEROS_DEBUG__?.camp()?.sortieRecommendationBrowser?.pinnedPlanId === planId && + window.__HEROS_DEBUG__?.camp()?.sortieComparisonAction?.kind === 'confirm-recommendation', + launchRecommendationCard.id, + { timeout: 30000 } + ); + const launchRecommendationApplyPoint = await readSortieComparisonActionPoint(page); + assert( + launchRecommendationApplyPoint.label === '적용 확정', + `Expected the launch recommendation to expose its real apply action: ${JSON.stringify(launchRecommendationApplyPoint)}` + ); + await page.mouse.click(launchRecommendationApplyPoint.x, launchRecommendationApplyPoint.y); + await page.waitForFunction( + (planId) => window.__HEROS_DEBUG__?.camp()?.sortieRecommendationSelection?.planId === planId, + launchRecommendationCard.id, + { timeout: 30000 } + ); + const midRecommendationLaunchFixture = await page.evaluate(() => { + const state = window.__HEROS_DEBUG__?.camp(); + return { + ready: Boolean(state?.sortieRecommendationSelection), + selection: structuredClone(state?.sortieRecommendationSelection), + state + }; + }); + assert( + midRecommendationLaunchFixture.ready === true && + midRecommendationLaunchFixture.selection?.planId === launchRecommendationCard.id && + sameJsonValue(midRecommendationLaunchFixture.state?.selectedSortieUnitIds, midRecommendationLaunchFixture.selection.selectedUnitIds) && + sameJsonValue(midRecommendationLaunchFixture.state?.sortieFormationAssignments, midRecommendationLaunchFixture.selection.formationAssignments), + `Expected the recommendation created by the real apply flow to become the launch provenance: ${JSON.stringify(midRecommendationLaunchFixture)}` + ); + midCampSelectedSortieUnitIds = [...midRecommendationLaunchFixture.state.selectedSortieUnitIds]; + midCampDeploymentPreview = structuredClone(midRecommendationLaunchFixture.state.sortieDeploymentPreview ?? []); + midCampFormationAssignments = { ...midRecommendationLaunchFixture.state.sortieFormationAssignments }; + midCampItemAssignments = structuredClone(midRecommendationLaunchFixture.state.sortieItemAssignments ?? {}); + const restoredMidLoadoutState = await advanceSortiePrepStep(page, 'loadout'); assertSameMembers( restoredMidLoadoutState?.selectedSortieUnitIds, @@ -4940,8 +5035,9 @@ try { `Expected briefing, formation, and loadout steps to preserve the selected mid-campaign roster: ${JSON.stringify(restoredMidLoadoutState?.selectedSortieUnitIds)}` ); assert( - JSON.stringify(restoredMidLoadoutState?.sortieFormationAssignments) === JSON.stringify(midCampFormationAssignments) && - JSON.stringify(restoredMidLoadoutState?.sortieItemAssignments) === JSON.stringify(midCampItemAssignments), + JSON.stringify(restoredMidLoadoutState?.sortieFormationAssignments) === JSON.stringify(midCampFormationAssignments) && + JSON.stringify(restoredMidLoadoutState?.sortieItemAssignments) === JSON.stringify(midCampItemAssignments) && + sameJsonValue(restoredMidLoadoutState?.sortieRecommendationSelection, midRecommendationLaunchFixture.selection), `Expected loadout navigation to preserve role and supply assignments: ${JSON.stringify(restoredMidLoadoutState)}` ); await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-sortie-loadout.png`, fullPage: true }); @@ -4953,6 +5049,12 @@ try { const midNextBattleProbe = await readBattleDoctrineProbe(page); assert(midNextBattleProbe?.battleId === midCampNextBattleId, `Expected mid-campaign sortie to enter next battle: ${JSON.stringify(midNextBattleProbe)} / ${midCampNextBattleId}`); + assert( + midNextBattleProbe?.sortieRecommendation?.planId === midRecommendationLaunchFixture.selection.planId && + sameJsonValue(midNextBattleProbe.sortieRecommendation.selectedUnitIds, midRecommendationLaunchFixture.selection.selectedUnitIds) && + sameJsonValue(midNextBattleProbe.sortieRecommendation.unitReasons, midRecommendationLaunchFixture.selection.unitReasons), + `Expected the battle launch to retain recommendation provenance: ${JSON.stringify(midNextBattleProbe?.sortieRecommendation)}` + ); assertSameMembers( midNextBattleProbe?.selectedSortieUnitIds, midCampSelectedSortieUnitIds, @@ -4968,10 +5070,17 @@ try { midCampDeploymentPreview, `Expected mid-campaign next battle ally positions to match deployment preview: ${JSON.stringify(midNextBattleProbe?.deployedAllyPositions)} / ${JSON.stringify(midCampDeploymentPreview)}` ); - assertSortieDoctrine(midNextBattleProbe, { requireReserve: true }); + const expectedMidRoleCoverage = new Set( + Object.values(midCampFormationAssignments).filter((role) => ['front', 'flank', 'support'].includes(role)) + ).size; + const expectedMidReserve = Object.values(midCampFormationAssignments).includes('reserve'); + assertSortieDoctrine(midNextBattleProbe, { requireReserve: expectedMidReserve }); assert( - midNextBattleProbe.sortieDoctrine?.coveredRoleCount < 3, - `Expected the seeded mid-campaign formation to allow a missing doctrine role: ${JSON.stringify(midNextBattleProbe.sortieDoctrine)}` + midNextBattleProbe.sortieDoctrine?.coveredRoleCount === expectedMidRoleCoverage, + `Expected the launched doctrine coverage to match the applied recommendation roles: ${JSON.stringify({ + doctrine: midNextBattleProbe.sortieDoctrine, + assignments: midCampFormationAssignments + })}` ); assert( midNextBattleProbe.firstSortieRoleEffects?.active === false && @@ -5009,6 +5118,8 @@ try { defeats: 3, actions: 5, support: 11, + strategyUses: 2, + signatureStrategyUses: 1, sortieBonusDamage: 17, sortiePreventedDamage: 9, sortieBonusHealing: 4, @@ -5030,6 +5141,8 @@ try { defeats: 3, actions: 5, support: 11, + strategyUses: 2, + signatureStrategyUses: 1, sortieBonusDamage: 17, sortiePreventedDamage: 9, sortieBonusHealing: 4, @@ -5072,8 +5185,24 @@ try { midResultState.sortieEvaluation.totalDefeats === midResultFixture.expectedStats.defeats && midResultState.sortieEvaluation.totalSupport === midResultFixture.expectedStats.support && midResultState.sortieEvaluation.recoveryNeededCount > 0 && - midResultState.sortieEvaluation.roleCoverage < 3, - `Expected the mid-campaign evaluation to reflect the deterministic contribution, injury, and missing role: ${JSON.stringify(midResultState?.sortieEvaluation)}` + midResultState.sortieEvaluation.roleCoverage === expectedMidRoleCoverage, + `Expected the mid-campaign evaluation to reflect the deterministic contribution, injury, and applied role coverage: ${JSON.stringify(midResultState?.sortieEvaluation)}` + ); + const midRecommendationFeedback = midResultState?.sortieEvaluation?.recommendationFeedback; + const midRecommendationContributor = midRecommendationFeedback?.rows?.find((row) => row.unitId === midResultFixture.contributorId); + assert( + midRecommendationFeedback?.planId === midRecommendationLaunchFixture.selection.planId && + ['hit', 'partial', 'miss'].includes(midRecommendationFeedback.status) && + Number.isInteger(midRecommendationFeedback.score) && + midRecommendationFeedback.kpis?.length === 3 && + sameJsonValue(midRecommendationFeedback.rows.map((row) => row.unitId), midRecommendationLaunchFixture.selection.selectedUnitIds) && + midRecommendationFeedback.rows.every( + (row) => row.fullReason === midRecommendationLaunchFixture.selection.unitReasons[row.unitId] && row.cardBounds === null + ) && + midRecommendationFeedback.counts.hit + midRecommendationFeedback.counts.partial + midRecommendationFeedback.counts.miss === midRecommendationFeedback.rows.length && + midRecommendationContributor?.strategyUses === 2 && + midRecommendationContributor.signatureStrategyUses === 1, + `Expected the closed result to expose source-backed recommendation outcome data without rendered card bounds: ${JSON.stringify(midRecommendationFeedback)}` ); assert( midContributorPerformance && @@ -5098,6 +5227,16 @@ try { mobile: midResultSaveBeforeUpdate.current?.sortieFormationPresets?.mobile })}` ); + assert( + sameJsonValue(midResultSaveBeforeUpdate.current?.firstBattleReport?.sortieRecommendation, midRecommendationLaunchFixture.selection) && + sameJsonValue(midResultSaveBeforeUpdate.slot1?.firstBattleReport?.sortieRecommendation, midRecommendationLaunchFixture.selection) && + sameJsonValue(midResultSaveBeforeUpdate.current?.battleHistory?.[midCampNextBattleId]?.sortieRecommendation, midRecommendationLaunchFixture.selection) && + sameJsonValue(midResultSaveBeforeUpdate.slot1?.battleHistory?.[midCampNextBattleId]?.sortieRecommendation, midRecommendationLaunchFixture.selection), + `Expected recommendation provenance to persist beside the battle performance snapshot: ${JSON.stringify({ + report: midResultSaveBeforeUpdate.current?.firstBattleReport?.sortieRecommendation, + history: midResultSaveBeforeUpdate.current?.battleHistory?.[midCampNextBattleId]?.sortieRecommendation + })}` + ); assert( midResultSortieReviewBeforePresetUpdate?.version === 1 && midResultSortieReviewBeforePresetUpdate.score === midResultState?.sortieEvaluation?.score && @@ -5128,9 +5267,23 @@ try { }); const midEvaluationSave = await readCampaignSave(page); const mobileResultCard = midEvaluationProbe.evaluation?.presetCards?.find((card) => card.id === 'mobile'); + const openedRecommendationFeedback = midEvaluationProbe.evaluation?.recommendationFeedback; + const fhdViewportBounds = { x: 0, y: 0, width: 1920, height: 1080 }; assert( midEvaluationProbe.texts.includes('이번 편성 평가') && + midEvaluationProbe.texts.some((text) => text.includes('추천안 검증') && text.includes(midRecommendationLaunchFixture.selection.label)) && midEvaluationProbe.texts.some((text) => text.includes('기동') && text.includes('갱신')) && + isFiniteBounds(openedRecommendationFeedback?.panelBounds) && + boundsInside(openedRecommendationFeedback.panelBounds, fhdViewportBounds) && + sameJsonValue(openedRecommendationFeedback.displayedUnitIds, midRecommendationLaunchFixture.selection.selectedUnitIds) && + openedRecommendationFeedback.missingUnitIds.length === 0 && + openedRecommendationFeedback.unexpectedUnitIds.length === 0 && + openedRecommendationFeedback.rows.every( + (row) => isFiniteBounds(row.cardBounds) && boundsInside(row.cardBounds, fhdViewportBounds) && + isFiniteBounds(row.nameBounds) && boundsInside(row.nameBounds, row.cardBounds) && + isFiniteBounds(row.statusBounds) && boundsInside(row.statusBounds, row.cardBounds) && + typeof row.displayText === 'string' && row.displayText.length <= 14 && !row.displayText.includes('HP') + ) && mobileResultCard?.saved === true && mobileResultCard.matching === false && mobileResultCard.actionButtonBounds && @@ -5230,6 +5383,10 @@ try { midCampFormationEvaluation.grade === updatedMobileResultState?.sortieEvaluation?.grade && midCampFormationEvaluation.score === updatedMobileResultState?.sortieEvaluation?.score && sameJsonValue(midCampFormationEvaluation.snapshot, midResultPerformance) && + sameJsonValue( + recommendationFeedbackSemantic(midCampFormationEvaluation.recommendationFeedback), + recommendationFeedbackSemantic(updatedMobileResultState?.sortieEvaluation?.recommendationFeedback) + ) && sameJsonValue(midCampFormationEvaluation.sourcePresetIds, midResultSortieReviewBeforePresetUpdate.sourcePresetIds) && midCampMobileCard?.saved === true && midCampMobileCard.matching === true && @@ -5263,9 +5420,22 @@ try { const openedMidCampSiegeCard = openedMidCampReviewProbe.state?.reportFormationEvaluation?.presetCards?.find( (card) => card.id === 'siege' ); + const openedCampRecommendationFeedback = openedMidCampReviewProbe.state?.reportFormationEvaluation?.recommendationFeedback; assert( openedMidCampReviewProbe.state?.reportFormationEvaluation?.open === true && openedMidCampReviewProbe.texts.includes('최근 전투 편성 평가') && + openedMidCampReviewProbe.texts.some((text) => text.includes('추천안 검증') && text.includes(midRecommendationLaunchFixture.selection.label)) && + isFiniteBounds(openedCampRecommendationFeedback?.panelBounds) && + boundsInside(openedCampRecommendationFeedback.panelBounds, fhdViewportBounds) && + sameJsonValue(openedCampRecommendationFeedback.displayedUnitIds, midRecommendationLaunchFixture.selection.selectedUnitIds) && + openedCampRecommendationFeedback.missingUnitIds.length === 0 && + openedCampRecommendationFeedback.unexpectedUnitIds.length === 0 && + openedCampRecommendationFeedback.rows.every( + (row) => isFiniteBounds(row.cardBounds) && boundsInside(row.cardBounds, fhdViewportBounds) && + isFiniteBounds(row.nameBounds) && boundsInside(row.nameBounds, row.cardBounds) && + isFiniteBounds(row.statusBounds) && boundsInside(row.statusBounds, row.cardBounds) && + typeof row.displayText === 'string' && row.displayText.length <= 14 && !row.displayText.includes('HP') + ) && openedMidCampReviewProbe.texts.some((text) => text.includes('공성') && text.includes('갱신')) && openedMidCampSiegeCard?.saved === true && openedMidCampSiegeCard.matching === false && @@ -8796,6 +8966,24 @@ function sameJsonValue(value, expected) { return stableJson(value) === stableJson(expected); } +function recommendationFeedbackSemantic(feedback) { + if (!feedback) { + return null; + } + return { + ...feedback, + panelBounds: null, + displayedUnitIds: [], + rows: (feedback.rows ?? []).map((row) => ({ + ...row, + cardBounds: null, + nameBounds: null, + statusBounds: null, + displayText: null + })) + }; +} + function sameSortieOrderCommand(value, expected) { const commandFields = [ 'source', diff --git a/scripts/verify-sortie-recommendations.mjs b/scripts/verify-sortie-recommendations.mjs index 2baea32..185fd30 100644 --- a/scripts/verify-sortie-recommendations.mjs +++ b/scripts/verify-sortie-recommendations.mjs @@ -15,6 +15,11 @@ try { } = await server.ssrLoadModule('/src/game/data/sortieRecommendations.ts'); const { sortieFormationRoles } = await server.ssrLoadModule('/src/game/data/sortieDeployment.ts'); const { sortieOrderIds } = await server.ssrLoadModule('/src/game/data/sortieOrders.ts'); + const { + evaluateSortieRecommendationOutcome, + sortieRecommendationContributionCompactLabel, + sortieRecommendationDisplayUnitIds + } = await server.ssrLoadModule('/src/game/data/sortieRecommendationOutcome.ts'); const { getUnitStrategyCoverage, usableCatalog } = await server.ssrLoadModule('/src/game/data/battleUsables.ts'); const signatureStrategyByOwner = new Map( Object.values(usableCatalog) @@ -112,6 +117,11 @@ try { verifyRequiredOverflow(fixtureContext, buildSortieRecommendationPlans); verifyDuplicateAndNonAllyCandidates(fixtureContext, buildSortieRecommendationPlans, formationRoles); verifyMissingOrderFallback(fixtureContext, buildSortieRecommendationPlans, formationRoles); + verifyRecommendationOutcomeFeedback( + evaluateSortieRecommendationOutcome, + sortieRecommendationContributionCompactLabel, + sortieRecommendationDisplayUnitIds + ); console.log( `Verified ${evaluatedPlanCount} recommendation plans across ${scenarioEntries.length} battles and ${sortieOrderIds.length} orders; ` + @@ -439,6 +449,233 @@ function verifyMissingOrderFallback(base, buildPlans, formationRoles) { ); } +function verifyRecommendationOutcomeFeedback(evaluateOutcome, compactContribution, displayUnitIds) { + const selectedUnitIds = ['fixture-front', 'fixture-flank', 'fixture-support']; + const formationAssignments = { + 'fixture-front': 'front', + 'fixture-flank': 'flank', + 'fixture-support': 'support' + }; + const unitReasons = { + 'fixture-front': '전열 방호를 맡습니다.', + 'fixture-flank': '측면 돌파를 맡습니다.', + 'fixture-support': '고유 시험 전법으로 후원을 맡습니다.' + }; + const performance = { + selectedUnitIds: [...selectedUnitIds], + formationAssignments: { ...formationAssignments }, + unitStats: [ + outcomeStats('fixture-front', { actions: 2, damageTaken: 8, sortiePreventedDamage: 8, intentGuardSuccesses: 1 }), + outcomeStats('fixture-flank', { actions: 2, damageDealt: 18, defeats: 1, sortieBonusDamage: 6, intentLineBreaks: 1 }), + outcomeStats('fixture-support', { actions: 2, support: 6, sortieBonusHealing: 5, sortieExtendedBondTriggers: 1, strategyUses: 1, signatureStrategyUses: 1 }) + ] + }; + + for (const planId of ['order', 'resonance', 'terrain-strategy']) { + const input = { + planId, + selectedUnitIds, + formationAssignments, + unitReasons, + performance, + outcome: 'victory', + objectives: [{ achieved: true }, { achieved: true }], + activeBondCount: 1 + }; + const frozenInput = JSON.stringify(input); + const outcome = evaluateOutcome(input); + assert(outcome.status === 'hit', `${planId}: expected the strong fixture to hit: ${JSON.stringify(outcome)}`); + assert(outcome.kpis.length === 3, `${planId}: expected execution, battle, and focus KPI rows`); + assert( + JSON.stringify(outcome.unitContributions.map((entry) => entry.unitId)) === JSON.stringify(selectedUnitIds), + `${planId}: unit feedback must retain recommendation order: ${JSON.stringify(outcome.unitContributions)}` + ); + assert( + outcome.unitContributions.every((entry) => entry.reason === unitReasons[entry.unitId]), + `${planId}: unit feedback must retain the applied recommendation reasons` + ); + assert(JSON.stringify(input) === frozenInput, `${planId}: outcome evaluation must not mutate its input`); + } + + const failedOrder = evaluateOutcome({ + planId: 'order', + selectedUnitIds, + formationAssignments, + unitReasons, + performance, + outcome: 'victory', + objectives: [{ achieved: true }], + activeBondCount: 1, + sortieOrder: { achieved: false, score: 42 } + }); + const failedOrderFocus = failedOrder.kpis.find((entry) => entry.id === 'focus'); + assert( + failedOrderFocus?.status === 'miss' && failedOrderFocus.score === 42 && failedOrderFocus.value.includes('미달'), + `Order recommendation focus must follow the actual order result instead of role coverage: ${JSON.stringify(failedOrder)}` + ); + + const inactiveResonance = evaluateOutcome({ + planId: 'resonance', + selectedUnitIds, + formationAssignments, + unitReasons, + performance: { + ...performance, + unitStats: performance.unitStats.map((stats) => ({ ...stats, sortieExtendedBondTriggers: 0 })) + }, + outcome: 'victory', + objectives: [{ achieved: true }], + activeBondCount: 1 + }); + assert( + inactiveResonance.kpis.find((entry) => entry.id === 'focus')?.status === 'partial', + `An active bond without an actual pursuit/extension must not count as a resonance hit: ${JSON.stringify(inactiveResonance)}` + ); + + const unmetSignature = evaluateOutcome({ + planId: 'terrain-strategy', + selectedUnitIds, + formationAssignments, + unitReasons: { ...unitReasons, 'fixture-front': '전열을 지킵니다. · 고유 시험 전법' }, + performance: { + ...performance, + unitStats: performance.unitStats.map((stats) => stats.unitId === 'fixture-front' + ? { ...stats, strategyUses: 0, signatureStrategyUses: 0 } + : stats) + }, + outcome: 'victory', + objectives: [{ achieved: true }], + activeBondCount: 1 + }); + const unmetSignatureUnit = unmetSignature.unitContributions.find((entry) => entry.unitId === 'fixture-front'); + const supportMetric = unmetSignature.unitContributions + .find((entry) => entry.unitId === 'fixture-support') + ?.metrics.find((entry) => entry.label === '지원량'); + assert( + unmetSignatureUnit?.status === 'miss' && unmetSignatureUnit.score === 0 && + unmetSignatureUnit.headline.includes('고유 전법 미사용') && compactContribution(unmetSignatureUnit) === '고유 미사용', + `A source reason that calls for a signature strategy must be evaluated, not only displayed: ${JSON.stringify(unmetSignatureUnit)}` + ); + assert( + supportMetric?.unit === '체력' && supportMetric.value === 6, + `Support is accumulated healing volume and must not be labeled as an action count: ${JSON.stringify(supportMetric)}` + ); + + const unmetGeneralStrategy = evaluateOutcome({ + planId: 'terrain-strategy', + selectedUnitIds, + formationAssignments, + unitReasons: { ...unitReasons, 'fixture-front': '지형 적성과 전법 범위를 보강합니다.' }, + performance: { + ...performance, + unitStats: performance.unitStats.map((stats) => stats.unitId === 'fixture-front' + ? { ...stats, strategyUses: 0, signatureStrategyUses: 0 } + : stats) + }, + outcome: 'victory', + objectives: [{ achieved: true }], + activeBondCount: 1 + }); + const unmetGeneralStrategyUnit = unmetGeneralStrategy.unitContributions.find((entry) => entry.unitId === 'fixture-front'); + assert( + unmetGeneralStrategyUnit?.status === 'partial' && compactContribution(unmetGeneralStrategyUnit) === '전법 미사용', + `A non-signature strategy reason must expose its exact compact failure label: ${JSON.stringify(unmetGeneralStrategyUnit)}` + ); + + const unmetPursuit = evaluateOutcome({ + planId: 'resonance', + selectedUnitIds, + formationAssignments, + unitReasons: { ...unitReasons, 'fixture-front': '공명 관계와 추격 연결을 맡습니다.' }, + performance: { + ...performance, + unitStats: performance.unitStats.map((stats) => stats.unitId === 'fixture-front' + ? { ...stats, sortieExtendedBondTriggers: 0 } + : stats) + }, + outcome: 'victory', + objectives: [{ achieved: true }], + activeBondCount: 1 + }); + const unmetPursuitUnit = unmetPursuit.unitContributions.find((entry) => entry.unitId === 'fixture-front'); + assert( + unmetPursuitUnit?.status === 'partial' && compactContribution(unmetPursuitUnit) === '추격 미발동', + `A resonance reason without a successful pursuit must expose its exact compact failure label: ${JSON.stringify(unmetPursuitUnit)}` + ); + + const mismatchedExecution = evaluateOutcome({ + planId: 'order', + selectedUnitIds, + formationAssignments, + unitReasons, + performance: { + selectedUnitIds: ['fixture-front', 'fixture-support', 'fixture-unexpected'], + formationAssignments: { 'fixture-front': 'front', 'fixture-support': 'support', 'fixture-unexpected': 'flank' }, + unitStats: [ + performance.unitStats[0], + performance.unitStats[2], + outcomeStats('fixture-unexpected', { actions: 1, damageDealt: 4 }) + ] + }, + outcome: 'victory', + objectives: [{ achieved: true }], + activeBondCount: 1, + sortieOrder: { achieved: true, score: 100 } + }); + const missingRecommendedUnit = mismatchedExecution.unitContributions.find((entry) => entry.unitId === 'fixture-flank'); + const mismatchDisplayIds = displayUnitIds( + ['fixture-front', 'fixture-support', 'fixture-unexpected'], + mismatchedExecution.unitContributions + ); + assert( + mismatchedExecution.kpis.find((entry) => entry.id === 'execution')?.status !== 'hit' && + missingRecommendedUnit?.deployed === false && missingRecommendedUnit.status === 'miss' && + compactContribution(missingRecommendedUnit) === '미출전' && + JSON.stringify(mismatchDisplayIds.slice(0, 2)) === JSON.stringify(['fixture-flank', 'fixture-unexpected']), + `Roster mismatches must retain the missing recommendation row and expose a concise non-deployment reason: ${JSON.stringify(mismatchedExecution)}` + ); + + const missed = evaluateOutcome({ + planId: 'terrain-strategy', + selectedUnitIds, + formationAssignments, + unitReasons, + performance: { + selectedUnitIds: [...selectedUnitIds], + formationAssignments: { ...formationAssignments }, + unitStats: selectedUnitIds.map((unitId) => outcomeStats(unitId)) + }, + outcome: 'defeat', + objectives: [{ achieved: false }, { achieved: false }], + activeBondCount: 0 + }); + assert(missed.status === 'miss', `Expected an inactive defeated recommendation to miss: ${JSON.stringify(missed)}`); + assert(missed.unitContributions.every((entry) => entry.status === 'miss'), 'Inactive officers must expose missed contribution rows.'); +} + +function outcomeStats(unitId, overrides = {}) { + return { + unitId, + hpBefore: 40, + maxHpBefore: 40, + damageDealt: 0, + damageTaken: 0, + defeats: 0, + actions: 0, + support: 0, + strategyUses: 0, + signatureStrategyUses: 0, + sortieBonusDamage: 0, + sortiePreventedDamage: 0, + sortieBonusHealing: 0, + sortieExtendedBondTriggers: 0, + intentDefeats: 0, + intentLineBreaks: 0, + intentGuardSuccesses: 0, + ...overrides + }; +} + function validateBlockedPlans(plans, expectedReasonFragment, label) { assert( JSON.stringify(plans.map((plan) => plan.id)) === JSON.stringify(['order', 'resonance', 'terrain-strategy']), diff --git a/src/game/data/sortieRecommendationOutcome.ts b/src/game/data/sortieRecommendationOutcome.ts new file mode 100644 index 0000000..8b5fe03 --- /dev/null +++ b/src/game/data/sortieRecommendationOutcome.ts @@ -0,0 +1,715 @@ +import type { CampaignSortiePerformanceSnapshot, CampaignSortieUnitPerformanceSnapshot } from '../state/campaignState'; +import type { SortieFormationAssignments, SortieFormationRole } from './sortieDeployment'; +import type { SortieRecommendationPlanId } from './sortieRecommendations'; + +export type SortieRecommendationOutcomeStatus = 'hit' | 'partial' | 'miss'; + +export type SortieRecommendationOutcomeObjective = { + achieved: boolean; +}; + +export type SortieRecommendationOutcomeKpiId = 'execution' | 'battle' | 'focus'; + +export type SortieRecommendationOutcomeKpi = { + id: SortieRecommendationOutcomeKpiId; + label: string; + status: SortieRecommendationOutcomeStatus; + score: number; + value: string; + detail: string; +}; + +export type SortieRecommendationUnitMetric = { + label: string; + value: number; + unit: string; +}; + +export type SortieRecommendationUnitContribution = { + unitId: string; + role: SortieFormationRole; + actualRole?: SortieFormationRole; + reason: string; + deployed: boolean; + status: SortieRecommendationOutcomeStatus; + score: number; + strategyUses: number; + signatureStrategyUses: number; + headline: string; + metrics: SortieRecommendationUnitMetric[]; +}; + +export type SortieRecommendationOutcome = { + planId: SortieRecommendationPlanId; + status: SortieRecommendationOutcomeStatus; + statusLabel: string; + score: number; + kpis: SortieRecommendationOutcomeKpi[]; + unitContributions: SortieRecommendationUnitContribution[]; + summary: string; + improvement: string; +}; + +export type EvaluateSortieRecommendationOutcomeInput = { + planId: SortieRecommendationPlanId; + selectedUnitIds: readonly string[]; + formationAssignments: Readonly; + unitReasons: Readonly>; + performance: CampaignSortiePerformanceSnapshot; + outcome: 'victory' | 'defeat'; + objectives: readonly SortieRecommendationOutcomeObjective[]; + activeBondCount: number; + sortieOrder?: { + achieved: boolean; + score: number; + }; +}; + +const statusLabels: Record = { + hit: '적중', + partial: '부분 달성', + miss: '미달' +}; + +const planLabels: Record = { + order: '군령 조율형', + resonance: '공명·추격형', + 'terrain-strategy': '지형·전법형' +}; + +const roleLabels: Record = { + front: '전열', + flank: '돌파', + support: '후원', + reserve: '예비' +}; + +/** + * 추천 시점의 명단/역할과 전투 종료 통계를 비교해 추천안의 실제 성과를 평가한다. + * 입력 객체를 변경하지 않으며, UI에서 바로 표시할 수 있는 3개 KPI와 무장별 근거를 반환한다. + */ +export function evaluateSortieRecommendationOutcome( + input: EvaluateSortieRecommendationOutcomeInput +): SortieRecommendationOutcome { + const recommendedIds = uniqueUnitIds(input.selectedUnitIds); + const deployedIds = uniqueUnitIds(input.performance.selectedUnitIds); + const deployedIdSet = new Set(deployedIds); + const statsById = new Map(input.performance.unitStats.map((stats) => [stats.unitId, stats])); + const objectiveAchieved = input.objectives.filter((objective) => objective.achieved).length; + const objectiveTotal = input.objectives.length; + const objectiveRate = objectiveTotal > 0 ? objectiveAchieved / objectiveTotal : 1; + const activeBondCount = nonNegativeInteger(input.activeBondCount); + + const executionKpi = evaluateExecutionKpi( + recommendedIds, + deployedIds, + input.formationAssignments, + input.performance.formationAssignments + ); + const battleKpi = evaluateBattleKpi(input.outcome, objectiveAchieved, objectiveTotal, objectiveRate); + const focusKpi = evaluateFocusKpi( + input.planId, + deployedIds, + input.performance.formationAssignments, + statsById, + objectiveRate, + activeBondCount, + input.sortieOrder + ); + const kpis = [executionKpi, battleKpi, focusKpi]; + const unitContributions = recommendedIds.map((unitId) => evaluateUnitContribution( + input.planId, + unitId, + input.formationAssignments[unitId] ?? input.performance.formationAssignments[unitId] ?? 'reserve', + input.performance.formationAssignments[unitId], + input.unitReasons[unitId], + deployedIdSet.has(unitId), + statsById.get(unitId) + )); + const unitContributionScore = unitContributions.length > 0 + ? unitContributions.reduce((sum, unit) => sum + unit.score, 0) / unitContributions.length + : 0; + const score = clampScore( + executionKpi.score * 0.25 + battleKpi.score * 0.3 + focusKpi.score * 0.25 + unitContributionScore * 0.2 + ); + const status = outcomeStatus(score, input.outcome, executionKpi.status, unitContributions); + const hitUnitCount = unitContributions.filter((unit) => unit.status === 'hit').length; + const planLabel = planLabels[input.planId]; + + return { + planId: input.planId, + status, + statusLabel: statusLabels[status], + score, + kpis, + unitContributions, + summary: buildSummary( + planLabel, + status, + input.outcome, + objectiveAchieved, + objectiveTotal, + hitUnitCount, + unitContributions.length + ), + improvement: buildImprovement( + input.planId, + input.outcome, + objectiveAchieved, + objectiveTotal, + executionKpi.status, + focusKpi.status, + unitContributions + ) + }; +} + +export function sortieRecommendationOutcomeStatusLabel(status: SortieRecommendationOutcomeStatus) { + return statusLabels[status]; +} + +export function sortieRecommendationContributionCompactLabel( + contribution: SortieRecommendationUnitContribution +) { + if (!contribution.deployed) { + return '미출전'; + } + if (contribution.headline.includes('고유 전법 미사용')) { + return '고유 미사용'; + } + if (contribution.headline.includes('추천 전법 미사용')) { + return '전법 미사용'; + } + if (contribution.headline.includes('공명 추격 미발동')) { + return '추격 미발동'; + } + if (contribution.signatureStrategyUses > 0) { + return `고유 ${contribution.signatureStrategyUses}회`; + } + const metric = contribution.metrics.find((entry) => entry.value > 0) ?? contribution.metrics[0]; + const compactLabels: Record = { + '피해 감소': '방호', + '반격 추가': '반격', + '호위 파훼': '호위', + '추가 피해': '추가', + '선제 파훼': '파훼', + '지원량': '지원', + '공명 추격': '공명' + }; + return metric ? `${compactLabels[metric.label] ?? metric.label} ${metric.value}` : '행동 1'; +} + +export function sortieRecommendationDisplayUnitIds( + actualUnitIds: readonly string[], + contributions: readonly SortieRecommendationUnitContribution[] +) { + const actualIds = uniqueUnitIds(actualUnitIds); + if (contributions.length === 0) { + return actualIds; + } + const recommendedIds = uniqueUnitIds(contributions.map((entry) => entry.unitId)); + const recommendedIdSet = new Set(recommendedIds); + const actualIdSet = new Set(actualIds); + return uniqueUnitIds([ + ...recommendedIds.filter((unitId) => !actualIdSet.has(unitId)), + ...actualIds.filter((unitId) => !recommendedIdSet.has(unitId)), + ...recommendedIds.filter((unitId) => actualIdSet.has(unitId)) + ]); +} + +function evaluateExecutionKpi( + recommendedIds: string[], + deployedIds: string[], + recommendedAssignments: Readonly, + actualAssignments: SortieFormationAssignments +): SortieRecommendationOutcomeKpi { + const recommendedIdSet = new Set(recommendedIds); + const deployedIdSet = new Set(deployedIds); + const matchingIds = recommendedIds.filter((unitId) => deployedIdSet.has(unitId)); + const unionSize = new Set([...recommendedIds, ...deployedIds]).size; + const rosterRate = unionSize > 0 ? matchingIds.length / unionSize : 0; + const roleTargets = recommendedIds.filter((unitId) => Boolean(recommendedAssignments[unitId])); + const matchingRoles = roleTargets.filter((unitId) => + deployedIdSet.has(unitId) && recommendedAssignments[unitId] === actualAssignments[unitId] + ).length; + const roleRate = roleTargets.length > 0 ? matchingRoles / roleTargets.length : rosterRate; + const executionRate = rosterRate * 0.7 + roleRate * 0.3; + const score = clampScore(executionRate * 100); + const status = rateStatus(executionRate, 0.95, 0.65); + const extraCount = deployedIds.filter((unitId) => !recommendedIdSet.has(unitId)).length; + + return { + id: 'execution', + label: '추천안 실행', + status, + score, + value: `${matchingIds.length}/${recommendedIds.length}명 · 역할 ${matchingRoles}/${roleTargets.length}`, + detail: extraCount > 0 + ? `추천 밖 무장 ${extraCount}명이 출전해 추천안 일치도가 낮아졌습니다.` + : status === 'hit' + ? '추천 명단과 역할 배정을 그대로 실행했습니다.' + : '추천 명단 또는 역할 배정 일부가 달라졌습니다.' + }; +} + +function evaluateBattleKpi( + outcome: 'victory' | 'defeat', + objectiveAchieved: number, + objectiveTotal: number, + objectiveRate: number +): SortieRecommendationOutcomeKpi { + const victoryRate = outcome === 'victory' ? 1 : 0; + const battleRate = victoryRate * 0.6 + objectiveRate * 0.4; + const status: SortieRecommendationOutcomeStatus = outcome === 'victory' && objectiveRate >= 0.75 + ? 'hit' + : outcome === 'victory' || objectiveAchieved > 0 + ? 'partial' + : 'miss'; + + return { + id: 'battle', + label: '전투 목표', + status, + score: clampScore(battleRate * 100), + value: `${outcome === 'victory' ? '승리' : '패배'} · 목표 ${objectiveAchieved}/${objectiveTotal}`, + detail: objectiveTotal === 0 + ? `${outcome === 'victory' ? '승리' : '패배'} 결과를 기준으로 평가했습니다.` + : objectiveAchieved === objectiveTotal + ? '기록된 전투 목표를 모두 달성했습니다.' + : `기록된 목표 ${objectiveTotal - objectiveAchieved}개가 남았습니다.` + }; +} + +function evaluateFocusKpi( + planId: SortieRecommendationPlanId, + deployedIds: string[], + assignments: SortieFormationAssignments, + statsById: Map, + objectiveRate: number, + activeBondCount: number, + sortieOrder?: EvaluateSortieRecommendationOutcomeInput['sortieOrder'] +): SortieRecommendationOutcomeKpi { + const stats = deployedIds + .map((unitId) => statsById.get(unitId)) + .filter((entry): entry is CampaignSortieUnitPerformanceSnapshot => Boolean(entry)); + const roleCoverage = new Set( + deployedIds + .map((unitId) => assignments[unitId]) + .filter((role): role is SortieFormationRole => role === 'front' || role === 'flank' || role === 'support') + ).size; + const counterplays = stats.reduce((sum, entry) => sum + counterplayTotal(entry), 0); + const roleEffects = stats.reduce((sum, entry) => sum + roleEffectTotal(entry), 0); + const bondPursuits = stats.reduce((sum, entry) => sum + safeStat(entry.sortieExtendedBondTriggers), 0); + const strategyUses = stats.reduce((sum, entry) => sum + safeStat(entry.strategyUses), 0); + const signatureStrategyUses = stats.reduce((sum, entry) => sum + safeStat(entry.signatureStrategyUses), 0); + + if (planId === 'resonance') { + const bondRate = Math.min(1, activeBondCount); + const pursuitRate = Math.min(1, bondPursuits); + const effectRate = Math.min(1, roleEffects / 10); + const score = clampScore((bondRate * 0.35 + pursuitRate * 0.5 + effectRate * 0.15) * 100); + const status: SortieRecommendationOutcomeStatus = activeBondCount > 0 && bondPursuits > 0 + ? 'hit' + : activeBondCount > 0 || bondPursuits > 0 + ? 'partial' + : 'miss'; + return { + id: 'focus', + label: '공명 효과', + status, + score, + value: `공명 ${activeBondCount}개 · 추격 ${bondPursuits}회`, + detail: bondPursuits > 0 + ? `공명 추격이 ${bondPursuits}회 성공하고 편성 효과 ${roleEffects}을 기록했습니다.` + : '활성 공명은 갖췄지만 실제 공명 추격 성공 기록이 없습니다.' + }; + } + + if (planId === 'terrain-strategy') { + const tacticRate = Math.min(1, (strategyUses + signatureStrategyUses * 2 + (roleEffects + counterplays * 3) / 10) / 3); + const score = clampScore((objectiveRate * 0.55 + tacticRate * 0.45) * 100); + const status: SortieRecommendationOutcomeStatus = objectiveRate >= 0.75 && strategyUses > 0 + ? 'hit' + : objectiveRate > 0 || strategyUses > 0 || roleEffects > 0 || counterplays > 0 + ? 'partial' + : 'miss'; + return { + id: 'focus', + label: '지형·전법 효과', + status, + score, + value: `전법 ${strategyUses}회 · 고유 ${signatureStrategyUses}회`, + detail: status === 'hit' + ? `목표 진척과 전법 운용이 맞물려 편성 효과 ${roleEffects}·파훼 ${counterplays}회를 기록했습니다.` + : '목표 동선과 추천 전법 사용을 함께 보완해야 합니다.' + }; + } + + if (sortieOrder) { + const score = clampScore(sortieOrder.score); + const status: SortieRecommendationOutcomeStatus = sortieOrder.achieved + ? 'hit' + : score >= 50 + ? 'partial' + : 'miss'; + return { + id: 'focus', + label: '군령 수행', + status, + score, + value: `${sortieOrder.achieved ? '달성' : '미달'} · ${score}점`, + detail: sortieOrder.achieved + ? '선택한 군령의 실제 달성 조건을 모두 충족했습니다.' + : '선택한 군령의 미달 조건을 전적표에서 확인해 보완해야 합니다.' + }; + } + + const coverageRate = Math.min(1, roleCoverage / 3); + const commandRate = Math.min(1, (counterplays + (roleEffects > 0 ? 1 : 0)) / 2); + const score = clampScore((coverageRate * 0.45 + commandRate * 0.55) * 100); + const status: SortieRecommendationOutcomeStatus = roleCoverage >= 3 && (counterplays > 0 || roleEffects > 0) + ? 'hit' + : roleCoverage >= 2 || counterplays > 0 || roleEffects > 0 + ? 'partial' + : 'miss'; + return { + id: 'focus', + label: '군령 수행', + status, + score, + value: `역할 ${Math.min(3, roleCoverage)}/3 · 파훼 ${counterplays}회`, + detail: status === 'hit' + ? '전열·돌파·후원의 역할 연계와 군령 대응이 확인됐습니다.' + : '역할 삼각 편성과 적 의도 대응을 더 적극적으로 활용해야 합니다.' + }; +} + +function evaluateUnitContribution( + planId: SortieRecommendationPlanId, + unitId: string, + expectedRole: SortieFormationRole, + actualRole: SortieFormationRole | undefined, + reason: string | undefined, + deployed: boolean, + stats: CampaignSortieUnitPerformanceSnapshot | undefined +): SortieRecommendationUnitContribution { + if (!deployed || !stats) { + return { + unitId, + role: expectedRole, + actualRole, + reason: normalizedReason(reason), + deployed: false, + status: 'miss', + score: 0, + strategyUses: 0, + signatureStrategyUses: 0, + headline: `${roleLabels[expectedRole]} 임무 미수행 · 전투 기록 없음`, + metrics: emptyRoleMetrics(expectedRole) + }; + } + + const evaluation = evaluateRoleContribution(expectedRole, stats); + const strategyUses = safeStat(stats.strategyUses); + const signatureStrategyUses = Math.min(strategyUses, safeStat(stats.signatureStrategyUses)); + const roleMatched = !actualRole || actualRole === expectedRole; + const roleStatus = roleMatched ? evaluation.status : downgradeStatus(evaluation.status); + const performanceStatus = planId === 'terrain-strategy' && signatureStrategyUses > 0 + ? roleMatched ? 'hit' : 'partial' + : planId === 'terrain-strategy' && strategyUses > 0 && roleStatus === 'miss' + ? 'partial' + : roleStatus; + const reasonExpectation = evaluateReasonExpectation(planId, reason, stats, strategyUses, signatureStrategyUses); + const status = reasonExpectation + ? lowerStatus(performanceStatus, reasonExpectation.status) + : performanceStatus; + const roleHeadline = roleMatched + ? evaluation.headline + : `${roleLabels[expectedRole]}→${roleLabels[actualRole]} 변경 · ${evaluation.headline}`; + const contributionHeadline = signatureStrategyUses > 0 + ? `${roleHeadline} · 고유 전법 ${signatureStrategyUses}회` + : strategyUses > 0 + ? `${roleHeadline} · 전법 ${strategyUses}회` + : roleHeadline; + const headline = reasonExpectation && reasonExpectation.status !== 'hit' + ? `${reasonExpectation.detail} · ${contributionHeadline}` + : contributionHeadline; + + return { + unitId, + role: expectedRole, + actualRole, + reason: normalizedReason(reason), + deployed: true, + status, + score: status === 'miss' + ? 0 + : Math.min( + roleMatched && status === 'hit' ? 100 : 69, + Math.max(status === 'hit' ? 70 : 35, evaluation.score) + + (planId === 'terrain-strategy' ? Math.min(12, strategyUses * 2 + signatureStrategyUses * 4) : 0) + ), + strategyUses, + signatureStrategyUses, + headline, + metrics: evaluation.metrics + }; +} + +function evaluateRoleContribution(role: SortieFormationRole, stats: CampaignSortieUnitPerformanceSnapshot) { + const actions = safeStat(stats.actions); + const defeats = safeStat(stats.defeats); + const damageDealt = safeStat(stats.damageDealt); + const bonusDamage = safeStat(stats.sortieBonusDamage); + const preventedDamage = safeStat(stats.sortiePreventedDamage); + const bonusHealing = safeStat(stats.sortieBonusHealing); + const support = safeStat(stats.support); + const bondPursuits = safeStat(stats.sortieExtendedBondTriggers); + const counterplays = counterplayTotal(stats); + + if (role === 'front') { + const guards = safeStat(stats.intentGuardSuccesses); + const contribution = preventedDamage + bonusDamage + guards * 3; + const status: SortieRecommendationOutcomeStatus = preventedDamage > 0 || guards > 0 + ? 'hit' + : bonusDamage > 0 || safeStat(stats.damageTaken) > 0 || actions > 0 + ? 'partial' + : 'miss'; + return { + status, + score: unitScore(status, contribution, 12), + headline: preventedDamage > 0 ? `피해 ${preventedDamage} 감소로 전열을 지켰습니다.` : '방호 효과를 더 발동할 여지가 있습니다.', + metrics: [ + metric('피해 감소', preventedDamage, '피해'), + metric('반격 추가', bonusDamage, '피해'), + metric('호위 파훼', guards, '회') + ] + }; + } + + if (role === 'flank') { + const breakthroughs = safeStat(stats.intentDefeats) + safeStat(stats.intentLineBreaks); + const contribution = bonusDamage + defeats * 6 + breakthroughs * 4; + const status: SortieRecommendationOutcomeStatus = bonusDamage > 0 || defeats > 0 || breakthroughs > 0 + ? 'hit' + : damageDealt > 0 || actions > 0 + ? 'partial' + : 'miss'; + return { + status, + score: unitScore(status, contribution, 18), + headline: defeats > 0 ? `적 ${defeats}명을 격파해 돌파구를 열었습니다.` : `추가 피해 ${bonusDamage}로 돌파에 기여했습니다.`, + metrics: [ + metric('추가 피해', bonusDamage, '피해'), + metric('격파', defeats, '명'), + metric('선제 파훼', breakthroughs, '회') + ] + }; + } + + if (role === 'support') { + const contribution = bonusHealing + support + bondPursuits * 6 + bonusDamage; + const status: SortieRecommendationOutcomeStatus = bonusHealing > 0 || support > 0 || bondPursuits > 0 + ? 'hit' + : bonusDamage > 0 || actions > 0 + ? 'partial' + : 'miss'; + return { + status, + score: unitScore(status, contribution, 14), + headline: bonusHealing > 0 + ? `회복 ${bonusHealing}을 더해 전선을 유지했습니다.` + : bondPursuits > 0 + ? `공명 추격을 ${bondPursuits}회 성공시켰습니다.` + : '후원 행동을 더 적극적으로 활용할 여지가 있습니다.', + metrics: [ + metric('회복', bonusHealing, '체력'), + metric('지원량', support, '체력'), + metric('공명 추격', bondPursuits, '회') + ] + }; + } + + const reserveContribution = defeats * 6 + support + counterplays * 4 + roleEffectTotal(stats); + const status: SortieRecommendationOutcomeStatus = reserveContribution > 0 + ? 'hit' + : damageDealt > 0 || actions > 0 + ? 'partial' + : 'miss'; + return { + status, + score: unitScore(status, reserveContribution, 12), + headline: reserveContribution > 0 ? '예비 전력으로 교전과 지원을 보완했습니다.' : '예비 전력의 교대 투입 효과가 부족했습니다.', + metrics: [ + metric('격파', defeats, '명'), + metric('지원량', support, '체력'), + metric('파훼', counterplays, '회') + ] + }; +} + +function buildSummary( + planLabel: string, + status: SortieRecommendationOutcomeStatus, + outcome: 'victory' | 'defeat', + objectiveAchieved: number, + objectiveTotal: number, + hitUnitCount: number, + unitCount: number +) { + const result = outcome === 'victory' ? '승리' : '패배'; + return `${planLabel} 추천 ${statusLabels[status]} · ${result}, 목표 ${objectiveAchieved}/${objectiveTotal}, 역할 기여 ${hitUnitCount}/${unitCount}명`; +} + +function buildImprovement( + planId: SortieRecommendationPlanId, + outcome: 'victory' | 'defeat', + objectiveAchieved: number, + objectiveTotal: number, + executionStatus: SortieRecommendationOutcomeStatus, + focusStatus: SortieRecommendationOutcomeStatus, + unitContributions: SortieRecommendationUnitContribution[] +) { + if (executionStatus === 'miss') { + return '추천 명단과 역할 배정을 먼저 맞춘 뒤 같은 조건에서 다시 비교해 보십시오.'; + } + if (outcome === 'defeat') { + return '패배 조건을 먼저 차단하고 전열 방호와 후원 회복의 발동 기회를 확보하십시오.'; + } + if (objectiveTotal > 0 && objectiveAchieved < objectiveTotal) { + return `남은 목표 ${objectiveTotal - objectiveAchieved}개를 위해 돌파 담당의 이동 동선과 행동 순서를 조정하십시오.`; + } + if (focusStatus !== 'hit') { + return planId === 'resonance' + ? '공명 무장을 인접 운용하고 공격 후 공명 추격을 더 성공시키십시오.' + : planId === 'terrain-strategy' + ? '추천 전법의 사거리와 목표 지형 진입 순서를 맞춰 편성 추가 효과를 확보하십시오.' + : '전열·돌파·후원의 역할을 모두 배치하고 적 의도 파훼를 한 차례 이상 노리십시오.'; + } + const weakUnitCount = unitContributions.filter((unit) => unit.status !== 'hit').length; + if (weakUnitCount > 0) { + return `역할 기여가 부족한 ${weakUnitCount}명의 행동 순서를 조정하거나 한 명 교체해 보십시오.`; + } + return '추천안이 기대대로 작동했습니다. 같은 편성을 저장해 다시 활용할 가치가 있습니다.'; +} + +function emptyRoleMetrics(role: SortieFormationRole): SortieRecommendationUnitMetric[] { + if (role === 'front') { + return [metric('피해 감소', 0, '피해'), metric('반격 추가', 0, '피해'), metric('호위 파훼', 0, '회')]; + } + if (role === 'flank') { + return [metric('추가 피해', 0, '피해'), metric('격파', 0, '명'), metric('선제 파훼', 0, '회')]; + } + if (role === 'support') { + return [metric('회복', 0, '체력'), metric('지원량', 0, '체력'), metric('공명 추격', 0, '회')]; + } + return [metric('격파', 0, '명'), metric('지원량', 0, '체력'), metric('파훼', 0, '회')]; +} + +function evaluateReasonExpectation( + planId: SortieRecommendationPlanId, + reason: string | undefined, + stats: CampaignSortieUnitPerformanceSnapshot, + strategyUses: number, + signatureStrategyUses: number +): { status: SortieRecommendationOutcomeStatus; detail: string } | undefined { + const normalized = normalizedReason(reason); + if (planId === 'terrain-strategy' && normalized.includes('고유 ')) { + return signatureStrategyUses > 0 + ? { status: 'hit', detail: `고유 전법 ${signatureStrategyUses}회` } + : strategyUses > 0 + ? { status: 'partial', detail: '추천 고유 전법 미사용' } + : { status: 'miss', detail: '추천 고유 전법 미사용' }; + } + if (planId === 'resonance' && normalized.includes('공명')) { + const pursuits = safeStat(stats.sortieExtendedBondTriggers); + return pursuits > 0 + ? { status: 'hit', detail: `공명 추격 ${pursuits}회` } + : roleEffectTotal(stats) > 0 + ? { status: 'partial', detail: '공명 추격 미발동' } + : { status: 'miss', detail: '공명 추격 미발동' }; + } + if (planId === 'terrain-strategy' && normalized.includes('전법')) { + return strategyUses > 0 + ? { status: 'hit', detail: `전법 ${strategyUses}회` } + : safeStat(stats.actions) > 0 + ? { status: 'partial', detail: '추천 전법 미사용' } + : { status: 'miss', detail: '추천 전법 미사용' }; + } + return undefined; +} + +function outcomeStatus( + score: number, + outcome: 'victory' | 'defeat', + executionStatus: SortieRecommendationOutcomeStatus, + unitContributions: readonly SortieRecommendationUnitContribution[] +): SortieRecommendationOutcomeStatus { + const contributingRate = unitContributions.length > 0 + ? unitContributions.filter((unit) => unit.status !== 'miss').length / unitContributions.length + : 0; + if (outcome === 'victory' && score >= 80 && executionStatus !== 'miss' && contributingRate >= 0.5) { + return 'hit'; + } + if (score >= 50 || outcome === 'victory') { + return 'partial'; + } + return 'miss'; +} + +function rateStatus(rate: number, hitThreshold: number, partialThreshold: number): SortieRecommendationOutcomeStatus { + return rate >= hitThreshold ? 'hit' : rate >= partialThreshold ? 'partial' : 'miss'; +} + +function downgradeStatus(status: SortieRecommendationOutcomeStatus): SortieRecommendationOutcomeStatus { + return status === 'hit' ? 'partial' : status; +} + +function lowerStatus( + left: SortieRecommendationOutcomeStatus, + right: SortieRecommendationOutcomeStatus +): SortieRecommendationOutcomeStatus { + const rank: Record = { miss: 0, partial: 1, hit: 2 }; + return rank[left] <= rank[right] ? left : right; +} + +function unitScore(status: SortieRecommendationOutcomeStatus, contribution: number, target: number) { + if (status === 'miss') { + return 0; + } + const measured = Math.min(1, contribution / target); + return clampScore(status === 'hit' ? 70 + measured * 30 : 35 + measured * 34); +} + +function roleEffectTotal(stats: CampaignSortieUnitPerformanceSnapshot) { + return safeStat(stats.sortieBonusDamage) + safeStat(stats.sortiePreventedDamage) + safeStat(stats.sortieBonusHealing); +} + +function counterplayTotal(stats: CampaignSortieUnitPerformanceSnapshot) { + return safeStat(stats.intentDefeats) + safeStat(stats.intentLineBreaks) + safeStat(stats.intentGuardSuccesses); +} + +function metric(label: string, value: number, unit: string): SortieRecommendationUnitMetric { + return { label, value: safeStat(value), unit }; +} + +function normalizedReason(reason: string | undefined) { + const text = reason?.trim(); + return text || '추천 편성의 역할 균형을 보완합니다.'; +} + +function uniqueUnitIds(unitIds: readonly string[]) { + return [...new Set(unitIds.filter((unitId) => typeof unitId === 'string' && unitId.trim().length > 0))]; +} + +function safeStat(value: number) { + return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; +} + +function nonNegativeInteger(value: number) { + return safeStat(value); +} + +function clampScore(value: number) { + return Math.min(100, Math.max(0, Math.round(Number.isFinite(value) ? value : 0))); +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index e484d2a..a389f6b 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -65,6 +65,12 @@ import { sortiePerformanceRoleContributionLine, type SortiePerformanceReview } from '../data/sortiePerformanceReview'; +import { + evaluateSortieRecommendationOutcome, + sortieRecommendationContributionCompactLabel, + sortieRecommendationDisplayUnitIds, + type SortieRecommendationOutcome +} from '../data/sortieRecommendationOutcome'; import { evaluateSortieOrder, evaluateSortieOrderProgress, @@ -79,10 +85,12 @@ import { import { campaignSortiePresetIds, campaignSaveSlotCount, + cloneCampaignSortieRecommendationSnapshot, getCampaignState, listCampaignSaveSlots, loadCampaignState, markCampaignStep, + normalizeCampaignSortieRecommendationSnapshot, normalizeCampaignSortieItemAssignments, readCampaignSaveState, saveCampaignState, @@ -93,6 +101,7 @@ import { type CampaignSortieOrderResultSnapshot, type CampaignSortiePerformanceSnapshot, type CampaignSortiePresetId, + type CampaignSortieRecommendationSnapshot, type CampaignStep } from '../state/campaignState'; import { @@ -1435,6 +1444,7 @@ type BattleSceneData = { sortieItemAssignments?: CampaignSortieItemAssignments; sortieOrderId?: SortieOrderId; sortieResonanceBondId?: string; + sortieRecommendation?: CampaignSortieRecommendationSnapshot; }; type CommandButton = { @@ -1525,6 +1535,8 @@ type UnitBattleStats = { defeats: number; actions: number; support: number; + strategyUses: number; + signatureStrategyUses: number; sortieBonusDamage: number; sortiePreventedDamage: number; sortieBonusHealing: number; @@ -1537,6 +1549,13 @@ type UnitBattleStats = { type ResultFormationReviewView = { closeButton: Phaser.GameObjects.Rectangle; presetButtons: Partial>; + recommendationPanel?: Phaser.GameObjects.Rectangle; + recommendationRows: { + unitId: string; + background: Phaser.GameObjects.Rectangle; + nameText: Phaser.GameObjects.Text; + statusText: Phaser.GameObjects.Text; + }[]; }; type ResultSortieOrderView = { @@ -3548,6 +3567,7 @@ export class BattleScene extends Phaser.Scene { private launchSortieItemAssignments: CampaignSortieItemAssignments = {}; private launchSortieOrderId: SortieOrderId = 'elite'; private launchSortieResonanceBondId?: string; + private launchSortieRecommendation?: CampaignSortieRecommendationSnapshot; private coreResonancePursuitAttempts = 0; private coreResonancePursuitSuccesses = 0; private coreResonancePursuitFailures = 0; @@ -3590,6 +3610,9 @@ export class BattleScene extends Phaser.Scene { this.launchSortieResonanceBondId = this.normalizeLaunchSortieResonanceBondId( data?.sortieResonanceBondId ?? selectedResonanceBondId ); + this.launchSortieRecommendation = this.normalizeLaunchSortieRecommendation( + data?.sortieRecommendation ?? campaign.sortieRecommendationSelection + ); this.resetCoreResonancePursuitStats(); } @@ -4093,6 +4116,25 @@ export class BattleScene extends Phaser.Scene { return normalized || undefined; } + private normalizeLaunchSortieRecommendation(value?: CampaignSortieRecommendationSnapshot) { + const normalized = normalizeCampaignSortieRecommendationSnapshot(value, { + expectedBattleId: battleScenario.id, + allowedUnitIds: new Set(this.launchSortieUnitIds) + }); + if ( + !normalized || + JSON.stringify(normalized.selectedUnitIds) !== JSON.stringify(this.launchSortieUnitIds) || + normalized.selectedUnitIds.some((unitId) => ( + normalized.formationAssignments[unitId] !== this.launchSortieFormationAssignments[unitId] + )) || + (normalized.sortieOrderId !== undefined && normalized.sortieOrderId !== this.launchSortieOrderId) || + (normalized.sortieResonanceBondId !== undefined && normalized.sortieResonanceBondId !== this.launchSortieResonanceBondId) + ) { + return undefined; + } + return cloneCampaignSortieRecommendationSnapshot(normalized); + } + private validatedLaunchSortieResonanceBondId(bondId?: string) { if (!bondId) { return undefined; @@ -7122,6 +7164,9 @@ export class BattleScene extends Phaser.Scene { } private deploymentSubtitle() { + if (this.launchSortieRecommendation) { + return `선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`; + } return battleScenario.sortie?.note ?? `${battleScenario.title}의 시작 구역을 확인하고 전열을 정합니다.`; } @@ -11862,8 +11907,9 @@ export class BattleScene extends Phaser.Scene { const sortiePerformance = this.resultSortiePerformanceSnapshot(); if (sortiePerformance) { const formationReview = this.resultFormationReview(outcome, sortiePerformance); + const recommendationOutcome = this.resultSortieRecommendationOutcome(outcome, sortiePerformance, formationReview); this.resultFormationReviewButton = this.addResultButton( - `편성 평가 ${formationReview.grade}`, + recommendationOutcome ? `추천 검증 ${recommendationOutcome.statusLabel}` : `편성 평가 ${formationReview.grade}`, left + 470, top + 612, 136, @@ -11898,8 +11944,14 @@ export class BattleScene extends Phaser.Scene { soundDirector.playSelect(); this.scene.restart({ battleId: battleScenario.id, + selectedSortieUnitIds: [...this.launchSortieUnitIds], + sortieFormationAssignments: { ...this.launchSortieFormationAssignments }, + sortieItemAssignments: this.normalizeLaunchSortieItemAssignments(this.launchSortieItemAssignments), sortieOrderId: this.launchSortieOrderId, - sortieResonanceBondId: this.launchSortieResonanceBondId + sortieResonanceBondId: this.launchSortieResonanceBondId, + sortieRecommendation: this.launchSortieRecommendation + ? cloneCampaignSortieRecommendationSnapshot(this.launchSortieRecommendation) + : undefined }); }, depth + 3); this.addResultButton('타이틀로', left + panelWidth - 140, top + 612, 124, () => { @@ -12115,6 +12167,28 @@ export class BattleScene extends Phaser.Scene { }; } + private resultSortieRecommendationOutcome( + outcome: BattleOutcome, + performance: CampaignSortiePerformanceSnapshot, + formationReview: SortiePerformanceReview + ): SortieRecommendationOutcome | undefined { + const recommendation = this.launchSortieRecommendation; + if (!recommendation) { + return undefined; + } + return evaluateSortieRecommendationOutcome({ + planId: recommendation.planId, + selectedUnitIds: recommendation.selectedUnitIds, + formationAssignments: recommendation.formationAssignments, + unitReasons: recommendation.unitReasons, + performance, + outcome, + objectives: this.resultObjectives(outcome), + activeBondCount: formationReview.activeBondCount, + sortieOrder: this.resultSortieOrder + }); + } + private publishFirstBattleReport(outcome: BattleOutcome) { const objectives = this.resultObjectives(outcome); const mvp = this.resultMvp(); @@ -12163,6 +12237,9 @@ export class BattleScene extends Phaser.Scene { sortiePerformance, sortieReview, sortieOrder, + sortieRecommendation: this.launchSortieRecommendation + ? cloneCampaignSortieRecommendationSnapshot(this.launchSortieRecommendation) + : undefined, completedCampDialogues: [], completedCampVisits: [], createdAt: new Date().toISOString() @@ -12679,6 +12756,7 @@ export class BattleScene extends Phaser.Scene { this.resultFormationReviewButton?.label.setText('정산 보기'); const review = this.resultFormationReview(outcome, snapshot); + const recommendationOutcome = this.resultSortieRecommendationOutcome(outcome, snapshot, review); const depth = 130; const width = 980; const height = 420; @@ -12721,12 +12799,19 @@ export class BattleScene extends Phaser.Scene { }); const analysisY = y + 184; - this.renderResultFormationAnalysisPanel( - '전술 판단', - [ - `강점 · ${review.strengths.join(' · ')}`, - `보완 · ${this.resultCompactText(review.improvement, 62)}` - ], + const recommendationPanel = this.renderResultFormationAnalysisPanel( + recommendationOutcome && this.launchSortieRecommendation + ? `추천안 검증 · ${this.launchSortieRecommendation.label}` + : '전술 판단', + recommendationOutcome + ? [ + `${recommendationOutcome.statusLabel} ${recommendationOutcome.score}점 · ${recommendationOutcome.kpis.map((kpi) => `${kpi.label} ${kpi.status === 'hit' ? '적중' : kpi.status === 'partial' ? '부분' : '미달'}`).join(' · ')}`, + `다음 조정 · ${this.resultCompactText(recommendationOutcome.improvement, 58)}` + ] + : [ + `강점 · ${review.strengths.join(' · ')}`, + `보완 · ${this.resultCompactText(review.improvement, 62)}` + ], x + 18, analysisY, 596, @@ -12738,7 +12823,7 @@ export class BattleScene extends Phaser.Scene { '역할·공명 기여', [ `${sortiePerformanceRoleContributionLine(review)} · 역할 ${review.roleCoverage}/3`, - `공명 ${review.activeBondCount}개 · 연장 ${review.contributionTotals.extendedBondTriggers}회 · 파훼 ${review.contributionTotals.counterplays}` + `공명 ${review.activeBondCount}개 · 추격 ${review.contributionTotals.extendedBondTriggers}회 · 파훼 ${review.contributionTotals.counterplays}` ], x + 624, analysisY, @@ -12749,21 +12834,54 @@ export class BattleScene extends Phaser.Scene { ); const rosterY = y + 264; - this.trackResultFormationReview(this.add.text(x + 18, rosterY, '출전 명단·역할', this.resultFormationTextStyle(13, '#f2e3bf', true))).setDepth(depth + 1); - const visibleUnitIds = review.snapshot.selectedUnitIds.slice(0, 8); + const contributionByUnitId = new Map(recommendationOutcome?.unitContributions.map((entry) => [entry.unitId, entry]) ?? []); + const actualUnitIds = review.snapshot.selectedUnitIds; + const displayUnitIds = sortieRecommendationDisplayUnitIds(actualUnitIds, recommendationOutcome?.unitContributions ?? []); + const hiddenUnitCount = Math.max(0, displayUnitIds.length - 8); + this.trackResultFormationReview(this.add.text( + x + 18, + rosterY, + `${recommendationOutcome ? '추천 무장별 판정' : '출전 명단·역할'}${hiddenUnitCount > 0 ? ` · 외 ${hiddenUnitCount}명` : ''}`, + this.resultFormationTextStyle(13, '#f2e3bf', true) + )).setDepth(depth + 1); + const visibleUnitIds = displayUnitIds.slice(0, 8); + const recommendationRows: ResultFormationReviewView['recommendationRows'] = []; const chipGap = 6; const chipWidth = Math.floor((width - 36 - chipGap * Math.max(0, visibleUnitIds.length - 1)) / Math.max(1, visibleUnitIds.length)); visibleUnitIds.forEach((unitId, index) => { const unit = battleUnits.find((candidate) => candidate.id === unitId); - const role = review.snapshot.formationAssignments[unitId]; + const contribution = contributionByUnitId.get(unitId); + const role = contribution?.role ?? review.snapshot.formationAssignments[unitId]; const roleLabel = role === 'front' ? '전' : role === 'flank' ? '돌' : role === 'support' ? '후' : '예'; + const statusColor = contribution + ? this.sortieRecommendationOutcomeColor(contribution.status) + : recommendationOutcome + ? { label: '변경', tone: palette.red, text: '#ffb6a6' } + : undefined; const chipX = x + 18 + index * (chipWidth + chipGap); const chip = this.trackResultFormationReview(this.add.rectangle(chipX, rosterY + 23, chipWidth, 38, unit?.hp ? 0x172a22 : 0x2a1b18, 0.94)); chip.setOrigin(0); chip.setDepth(depth + 1); - chip.setStrokeStyle(1, unit?.hp ? palette.green : palette.red, 0.58); - this.trackResultFormationReview(this.add.text(chipX + 7, rosterY + 29, `${roleLabel} ${this.resultCompactText(unit?.name ?? unitId, 7)}`, this.resultFormationTextStyle(10, '#f2e3bf', true))).setDepth(depth + 2); - this.trackResultFormationReview(this.add.text(chipX + 7, rosterY + 45, `HP ${unit?.hp ?? 0}/${unit?.maxHp ?? 0}`, this.resultFormationTextStyle(9, unit?.hp ? '#a8ffd0' : '#ffb6a6', true))).setDepth(depth + 2); + chip.setStrokeStyle(1, statusColor?.tone ?? (unit?.hp ? palette.green : palette.red), 0.68); + const nameText = this.trackResultFormationReview(this.add.text(chipX + 7, rosterY + 29, `${roleLabel} ${this.resultCompactText(unit?.name ?? this.unitName(unitId), 7)}`, { + ...this.resultFormationTextStyle(10, '#f2e3bf', true), + fixedWidth: chipWidth - 14 + })).setDepth(depth + 2); + const metric = contribution ? this.sortieRecommendationContributionCompact(contribution) : recommendationOutcome ? '추천 외' : undefined; + const statusText = this.trackResultFormationReview(this.add.text( + chipX + 7, + rosterY + 45, + recommendationOutcome + ? `${statusColor?.label} · ${metric}` + : `HP ${unit?.hp ?? 0}/${unit?.maxHp ?? 0}`, + { + ...this.resultFormationTextStyle(8, statusColor?.text ?? (unit?.hp ? '#a8ffd0' : '#ffb6a6'), true), + fixedWidth: chipWidth - 14 + } + )).setDepth(depth + 2); + if (recommendationOutcome) { + recommendationRows.push({ unitId, background: chip, nameText, statusText }); + } }); const presetTitleY = y + 337; @@ -12809,7 +12927,7 @@ export class BattleScene extends Phaser.Scene { this.trackResultFormationReview(this.add.text(x + 18, y + height - 3, this.resultCompactText(feedback, 78), this.resultFormationTextStyle(10, feedbackColor, true))) .setOrigin(0, 1) .setDepth(depth + 1); - this.resultFormationReviewView = { closeButton, presetButtons }; + this.resultFormationReviewView = { closeButton, presetButtons, recommendationPanel: recommendationOutcome ? recommendationPanel : undefined, recommendationRows }; } private hideResultFormationReview() { @@ -12822,7 +12940,11 @@ export class BattleScene extends Phaser.Scene { if (this.battleOutcome) { const snapshot = this.resultSortiePerformanceSnapshot(); if (snapshot) { - this.resultFormationReviewButton?.label.setText(`편성 평가 ${this.resultFormationReview(this.battleOutcome, snapshot).grade}`); + const review = this.resultFormationReview(this.battleOutcome, snapshot); + const recommendationOutcome = this.resultSortieRecommendationOutcome(this.battleOutcome, snapshot, review); + this.resultFormationReviewButton?.label.setText( + recommendationOutcome ? `추천 검증 ${recommendationOutcome.statusLabel}` : `편성 평가 ${review.grade}` + ); } } } @@ -12874,6 +12996,22 @@ export class BattleScene extends Phaser.Scene { .setDepth(depth + 1); } + private sortieRecommendationOutcomeColor(status: SortieRecommendationOutcome['status']) { + if (status === 'hit') { + return { label: '적중', tone: palette.green, text: '#a8ffd0' }; + } + if (status === 'partial') { + return { label: '부분', tone: palette.gold, text: '#ffdf7b' }; + } + return { label: '미달', tone: palette.red, text: '#ffb6a6' }; + } + + private sortieRecommendationContributionCompact( + contribution: SortieRecommendationOutcome['unitContributions'][number] + ) { + return sortieRecommendationContributionCompactLabel(contribution); + } + private renderResultFormationAnalysisPanel( title: string, lines: string[], @@ -12893,6 +13031,7 @@ export class BattleScene extends Phaser.Scene { this.trackResultFormationReview(this.add.text(x + 12, y + 30 + index * 18, this.resultCompactText(line, width > 500 ? 72 : 42), this.resultFormationTextStyle(10, index === 0 ? '#d4dce6' : '#ffdf7b', true))) .setDepth(depth + 1); }); + return bg; } private addResultFormationReviewButton( @@ -14085,15 +14224,18 @@ export class BattleScene extends Phaser.Scene { defeated: boolean, sortieBonusDamage: number, sortiePreventedDamage: number, - sortieExtendedBondTriggered: boolean + usable?: BattleUsable ) { const attackerStats = this.statsFor(attacker.id); attackerStats.actions += 1; + if (usable?.command === 'strategy') { + attackerStats.strategyUses += 1; + if (usable.signatureOwnerId === attacker.id) { + attackerStats.signatureStrategyUses += 1; + } + } attackerStats.damageDealt += damage; attackerStats.sortieBonusDamage += sortieBonusDamage; - if (sortieExtendedBondTriggered) { - attackerStats.sortieExtendedBondTriggers += 1; - } if (defeated) { attackerStats.defeats += 1; } @@ -14103,9 +14245,15 @@ export class BattleScene extends Phaser.Scene { defenderStats.sortiePreventedDamage += sortiePreventedDamage; } - private recordSupportStats(user: UnitData, amount: number, sortieBonusHealing = 0) { + private recordSupportStats(user: UnitData, amount: number, sortieBonusHealing = 0, usable?: BattleUsable) { const stats = this.statsFor(user.id); stats.actions += 1; + if (usable?.command === 'strategy') { + stats.strategyUses += 1; + if (usable.signatureOwnerId === user.id) { + stats.signatureStrategyUses += 1; + } + } stats.support += amount; stats.sortieBonusHealing += sortieBonusHealing; } @@ -14196,6 +14344,7 @@ export class BattleScene extends Phaser.Scene { this.coreResonancePursuitSuccesses += 1; this.coreResonancePursuitDamage += damage; } + this.statsFor(preview.attacker.id).sortieExtendedBondTriggers += 1; return { attempt, @@ -14241,7 +14390,6 @@ export class BattleScene extends Phaser.Scene { const sortiePreventedDamageAmount = sortieOnlyPreview.sortieDamageReduction > 0 ? Math.max(0, baselineDamage - sortieOnlyDamage) : 0; const initiativeBonusDamageAmount = preview.initiativeDamageBonus > 0 ? Math.max(0, damage - sortieOnlyDamage) : 0; const initiativePreventedDamageAmount = preview.initiativeDamageReduction > 0 ? Math.max(0, sortieOnlyDamage - damage) : 0; - const sortieExtendedBondTriggered = preview.bondExtendedRange && preview.bondDamageBonus > 0 && !isCounter; const attackerGrowth = this.awardEquipmentExp(attacker, action === 'attack' ? 'weapon' : 'accessory', this.attackerEquipmentExpGain(attacker, action)); const armorGrowth = this.awardEquipmentExp(defender, 'armor', this.defenderArmorExpGain(defender, hit, preview)); const characterGrowth = this.awardCharacterExp(attacker, this.characterExpGain(preview, damage, hit, critical)); @@ -14259,14 +14407,15 @@ export class BattleScene extends Phaser.Scene { usable.statusPower ?? this.defaultStatusPower(usable.statusEffect) ) : undefined; + const bondChainResolution = !isCounter ? this.resolveBondChainFollowUp(preview, hit, forcedBondChainRoll) : undefined; this.recordCombatStats( attacker, defender, damage, - defender.hp <= 0 && previousDefenderHp > 0, + baseDefeated, sortieBonusDamageAmount, sortiePreventedDamageAmount, - sortieExtendedBondTriggered + usable ); const initiativeReadied = this.recordIntentGuardCounterplay(attacker, defender, isCounter, sortiePreventedDamageAmount); this.resolveTacticalInitiativeCombatEffect( @@ -14275,7 +14424,6 @@ export class BattleScene extends Phaser.Scene { initiativeBonusDamageAmount, initiativePreventedDamageAmount ); - const bondChainResolution = !isCounter ? this.resolveBondChainFollowUp(preview, hit, forcedBondChainRoll) : undefined; const bondChainAttempt = bondChainResolution?.attempt; const bondChain = bondChainResolution?.result; this.faceUnitToward(attacker, defender); @@ -14392,7 +14540,7 @@ export class BattleScene extends Phaser.Scene { const equipmentGrowth = this.awardEquipmentExp(user, 'accessory', this.supportEquipmentExpGain(user, usable)); const characterGrowth = this.awardCharacterExp(user, usable.effect === 'heal' ? 8 : 6); - this.recordSupportStats(user, healAmount, sortieHealBonus); + this.recordSupportStats(user, healAmount, sortieHealBonus, usable); return { usable, @@ -14873,6 +15021,8 @@ export class BattleScene extends Phaser.Scene { defeats: 0, actions: 0, support: 0, + strategyUses: 0, + signatureStrategyUses: 0, sortieBonusDamage: 0, sortiePreventedDamage: 0, sortieBonusHealing: 0, @@ -14891,6 +15041,9 @@ export class BattleScene extends Phaser.Scene { const title = this.firstPursuitTrinityConfigured() ? '출진 군세 · 도원 삼재진' : '출진 군세'; this.triggerBattleEvent('opening', title, [ ...roleLines, + ...(this.launchSortieRecommendation + ? [`선택 전략 · ${this.launchSortieRecommendation.label} · ${this.launchSortieRecommendation.summary}`] + : []), ...(this.isFirstPursuitRoleEffectBattle() ? [`적 의도 · 검=공격 · 발=추격 · 파훼 ${tacticalInitiativeThreshold}회 → 전술 명령`] : []), @@ -15997,6 +16150,7 @@ export class BattleScene extends Phaser.Scene { : undefined ) ); + this.launchSortieRecommendation = this.normalizeLaunchSortieRecommendation(state.sortieRecommendation); this.applyBattleSaveState(state); this.renderSituationPanel(`슬롯 ${slot}의 전투를 불러왔습니다.\n${this.formatSavedAt(state.savedAt)}`); } catch { @@ -16011,6 +16165,9 @@ export class BattleScene extends Phaser.Scene { campaignStep: getCampaignState().step, sortieOrderId: this.launchSortieOrderId, sortieResonanceBondId: this.launchSortieResonanceBondId, + sortieRecommendation: this.launchSortieRecommendation + ? cloneCampaignSortieRecommendationSnapshot(this.launchSortieRecommendation) + : undefined, coreResonanceStats: this.launchSortieResonanceBondId ? { ...this.coreResonancePursuitStatsSnapshot() } : undefined, @@ -16073,6 +16230,7 @@ export class BattleScene extends Phaser.Scene { this.launchSortieResonanceBondId = this.normalizeLaunchSortieResonanceBondId( state.sortieResonanceBondId ?? this.launchSortieResonanceBondId ); + this.launchSortieRecommendation = this.normalizeLaunchSortieRecommendation(state.sortieRecommendation); this.resetCoreResonancePursuitStats(); this.turnNumber = Math.max(1, state.turnNumber || 1); this.activeFaction = state.activeFaction === 'enemy' ? 'enemy' : 'ally'; @@ -16221,6 +16379,8 @@ export class BattleScene extends Phaser.Scene { defeats: value.defeats ?? 0, actions: value.actions ?? 0, support: value.support ?? 0, + strategyUses: value.strategyUses ?? 0, + signatureStrategyUses: value.signatureStrategyUses ?? 0, sortieBonusDamage: value.sortieBonusDamage ?? 0, sortiePreventedDamage: value.sortiePreventedDamage ?? 0, sortieBonusHealing: value.sortieBonusHealing ?? 0, @@ -24616,6 +24776,9 @@ export class BattleScene extends Phaser.Scene { const resultFormationReview = this.battleOutcome && resultSortiePerformance ? this.resultFormationReview(this.battleOutcome, resultSortiePerformance) : undefined; + const resultRecommendationOutcome = this.battleOutcome && resultSortiePerformance && resultFormationReview + ? this.resultSortieRecommendationOutcome(this.battleOutcome, resultSortiePerformance, resultFormationReview) + : undefined; const resultSourcePresetIds = resultFormationReview ? this.resultFormationSourcePresetIds(resultFormationReview.snapshot) : []; @@ -24636,6 +24799,9 @@ export class BattleScene extends Phaser.Scene { sortieOperationOrder: sortieOrderDebug, sortieResonance: sortieResonanceDebug, coreSortieResonance: sortieResonanceDebug, + sortieRecommendation: this.launchSortieRecommendation + ? cloneCampaignSortieRecommendationSnapshot(this.launchSortieRecommendation) + : null, battleHud: this.battleHudDebugState(), combatCutIn: combatCutInDebug, combatCutInStage: combatCutInDebug, @@ -24693,6 +24859,52 @@ export class BattleScene extends Phaser.Scene { sourcePresetIds: [...resultSourcePresetIds], overwriteConfirmId: this.resultPresetOverwriteConfirmId ?? null, feedback: this.resultFormationReviewFeedback, + recommendationFeedback: resultRecommendationOutcome && this.launchSortieRecommendation + ? { + planId: resultRecommendationOutcome.planId, + label: this.launchSortieRecommendation.label, + status: resultRecommendationOutcome.status, + statusLabel: resultRecommendationOutcome.statusLabel, + score: resultRecommendationOutcome.score, + summary: resultRecommendationOutcome.summary, + improvement: resultRecommendationOutcome.improvement, + kpis: resultRecommendationOutcome.kpis.map((kpi) => ({ ...kpi })), + counts: { + hit: resultRecommendationOutcome.unitContributions.filter((entry) => entry.status === 'hit').length, + partial: resultRecommendationOutcome.unitContributions.filter((entry) => entry.status === 'partial').length, + miss: resultRecommendationOutcome.unitContributions.filter((entry) => entry.status === 'miss').length + }, + missingUnitIds: resultRecommendationOutcome.unitContributions.filter((entry) => !entry.deployed).map((entry) => entry.unitId), + unexpectedUnitIds: (resultFormationReview?.snapshot.selectedUnitIds ?? []).filter( + (unitId) => !resultRecommendationOutcome.unitContributions.some((entry) => entry.unitId === unitId) + ), + displayedUnitIds: this.resultFormationReviewView?.recommendationRows.map((row) => row.unitId) ?? [], + panelBounds: this.resultObjectBoundsDebug(this.resultFormationReviewView?.recommendationPanel), + rows: resultRecommendationOutcome.unitContributions.map((entry) => { + const rowView = this.resultFormationReviewView?.recommendationRows.find((row) => row.unitId === entry.unitId); + const unit = battleUnits.find((candidate) => candidate.id === entry.unitId); + return { + unitId: entry.unitId, + name: unit?.name ?? entry.unitId, + role: entry.role, + actualRole: entry.actualRole ?? null, + fullReason: entry.reason, + status: entry.status, + score: entry.score, + headline: entry.headline, + metrics: entry.metrics.map((metric) => ({ ...metric })), + strategyUses: entry.strategyUses, + signatureStrategyUses: entry.signatureStrategyUses, + hp: unit?.hp ?? 0, + maxHp: unit?.maxHp ?? 0, + cardBounds: this.resultObjectBoundsDebug(rowView?.background), + nameBounds: this.gameObjectBoundsDebug(rowView?.nameText), + statusBounds: this.gameObjectBoundsDebug(rowView?.statusText), + displayText: rowView?.statusText.text ?? null + }; + }) + } + : null, toggleButtonBounds: this.resultObjectBoundsDebug(this.resultFormationReviewButton?.background), closeButtonBounds: this.resultObjectBoundsDebug(this.resultFormationReviewView?.closeButton), presetCards: resultSortiePresetDefinitions.map((definition) => ({ diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index a25f2ef..321ad37 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -59,6 +59,12 @@ import { sortiePerformanceRoleContributionLine, type SortiePerformanceReview } from '../data/sortiePerformanceReview'; +import { + evaluateSortieRecommendationOutcome, + sortieRecommendationContributionCompactLabel, + sortieRecommendationDisplayUnitIds, + type SortieRecommendationOutcome +} from '../data/sortieRecommendationOutcome'; import { sortieOrderDefinition, sortieOrderDefinitions, @@ -174,6 +180,7 @@ import { type CampaignSortieFormationPresets, type CampaignSortiePerformanceSnapshot, type CampaignSortiePresetId, + type CampaignSortieRecommendationSnapshot, type FirstBattleReport } from '../state/campaignState'; import { palette } from '../ui/palette'; @@ -621,6 +628,13 @@ type SortieOrderBrowserView = { type ReportFormationReviewView = { closeButton: Phaser.GameObjects.Rectangle; presetButtons: Partial>; + recommendationPanel?: Phaser.GameObjects.Rectangle; + recommendationRows: { + unitId: string; + background: Phaser.GameObjects.Rectangle; + nameText: Phaser.GameObjects.Text; + statusText: Phaser.GameObjects.Text; + }[]; }; type ReportFormationHistoryRecord = { @@ -16994,7 +17008,7 @@ export class CampScene extends Phaser.Scene { this.sortieRosterScroll = 0; this.sortiePortraitRosterPage = 0; this.sortiePlanFeedback = `${projection.label} 적용 완료 · 장비·보급 재점검`; - this.persistSortieSelection(); + this.persistSortieSelection(this.createSortieRecommendationSnapshot(projection)); this.sortieRecommendationUndoState = { recommendationId, beforeCoreBondId, @@ -17221,6 +17235,55 @@ export class CampScene extends Phaser.Scene { return runtime === expected && saved === expected; } + private createSortieRecommendationSnapshot( + projection: SortieRecommendationProjection + ): CampaignSortieRecommendationSnapshot | undefined { + const scenario = this.nextSortieScenario(); + if (!scenario) { + return undefined; + } + const sortieOrderId = this.currentSortieOrderId(); + const sortieResonanceBondId = this.currentSortieResonanceBondId(scenario); + return { + version: 1, + battleId: scenario.id, + planId: projection.recommendationId, + label: projection.label, + summary: projection.plan.summary, + ...(sortieOrderId ? { sortieOrderId } : {}), + ...(sortieResonanceBondId ? { sortieResonanceBondId } : {}), + selectedUnitIds: [...projection.selectedUnitIds], + formationAssignments: { ...projection.formationAssignments }, + unitReasons: { ...projection.plan.unitReasons } + }; + } + + private sortieRecommendationSelectionMatchesCurrent(selection?: CampaignSortieRecommendationSnapshot) { + const scenario = this.nextSortieScenario(); + return Boolean( + selection && + scenario?.id === selection.battleId && + JSON.stringify(selection.selectedUnitIds) === JSON.stringify(this.selectedSortieUnitIds) && + selection.selectedUnitIds.every((unitId) => ( + selection.formationAssignments[unitId] === this.sortieFormationAssignments[unitId] + )) && + selection.sortieOrderId === this.currentSortieOrderId() && + selection.sortieResonanceBondId === this.currentSortieResonanceBondId(scenario) + ); + } + + private currentSortieRecommendationSelection() { + const selection = this.campaign?.sortieRecommendationSelection; + return this.sortieRecommendationSelectionMatchesCurrent(selection) + ? { + ...selection!, + selectedUnitIds: [...selection!.selectedUnitIds], + formationAssignments: { ...selection!.formationAssignments }, + unitReasons: { ...selection!.unitReasons } + } + : undefined; + } + private confirmPinnedSortieSwap() { const preview = this.sortieFormationComparisonPreview(); if ( @@ -18510,6 +18573,7 @@ export class CampScene extends Phaser.Scene { const sortieResonanceBondId = this.campaign?.sortieResonanceSelection?.battleId === flow.nextBattleId ? this.campaign.sortieResonanceSelection.bondId : undefined; + const sortieRecommendation = this.currentSortieRecommendationSelection(); if (this.skipIntroStoryForSortie) { void startLazyScene(this, 'BattleScene', { battleId: flow.nextBattleId, @@ -18517,7 +18581,8 @@ export class CampScene extends Phaser.Scene { sortieFormationAssignments, sortieItemAssignments, sortieOrderId, - sortieResonanceBondId + sortieResonanceBondId, + sortieRecommendation }); return; } @@ -18531,7 +18596,8 @@ export class CampScene extends Phaser.Scene { sortieFormationAssignments, sortieItemAssignments, sortieOrderId, - sortieResonanceBondId + sortieResonanceBondId, + sortieRecommendation } }); } @@ -18838,7 +18904,7 @@ export class CampScene extends Phaser.Scene { ) ); - const sortieOrderResult = this.campaign?.battleHistory[report.battleId]?.sortieOrder ?? report.sortieOrder; + const sortieOrderResult = report.sortieOrder ?? this.campaign?.battleHistory[report.battleId]?.sortieOrder; if (sortieOrderResult?.command) { const commandRecord = this.sortieOrderCommandRecordLine(sortieOrderResult.command); this.track( @@ -18866,11 +18932,12 @@ export class CampScene extends Phaser.Scene { } const review = this.reportFormationReview(); + const recommendationOutcome = review ? this.reportSortieRecommendationOutcome(review) : undefined; const historyRecords = this.reportFormationHistoryRecords(); const actions: { label: string; action: () => void; assign: (button: Phaser.GameObjects.Rectangle) => void }[] = []; if (review) { actions.push({ - label: `최근 평가 ${review.grade}`, + label: recommendationOutcome ? `추천 검증 ${recommendationOutcome.statusLabel}` : `최근 평가 ${review.grade}`, action: () => this.showReportFormationReview(), assign: (button) => { this.reportFormationReviewToggleButton = button; } }); @@ -18902,27 +18969,28 @@ export class CampScene extends Phaser.Scene { } const settlement = this.campaign?.battleHistory[report.battleId]; - const snapshot = settlement?.sortiePerformance ?? report.sortiePerformance; + const snapshot = report.sortiePerformance ?? settlement?.sortiePerformance; if (!snapshot) { return undefined; } - const endUnits = settlement - ? settlement.units.map((unit) => ({ id: unit.unitId, hp: unit.hp, maxHp: unit.maxHp })) - : report.units - .filter((unit) => unit.faction === 'ally') - .map((unit) => ({ id: unit.id, hp: unit.hp, maxHp: unit.maxHp })); + const reportEndUnits = report.units + .filter((unit) => unit.faction === 'ally') + .map((unit) => ({ id: unit.id, hp: unit.hp, maxHp: unit.maxHp })); + const endUnits = reportEndUnits.length > 0 + ? reportEndUnits + : settlement?.units.map((unit) => ({ id: unit.unitId, hp: unit.hp, maxHp: unit.maxHp })) ?? []; const endUnitIds = new Set(endUnits.map((unit) => unit.id)); if (!snapshot.selectedUnitIds.some((unitId) => endUnitIds.has(unitId))) { return undefined; } const selectedIds = new Set(snapshot.selectedUnitIds); - const storedReview = settlement?.sortieReview ?? report.sortieReview; + const storedReview = report.sortieReview ?? settlement?.sortieReview; const activeBondCount = storedReview?.activeBondCount ?? report.bonds.filter((bond) => bond.unitIds.every((unitId) => selectedIds.has(unitId))).length; const review = evaluateSortiePerformanceReview({ - outcome: settlement?.outcome ?? report.outcome, - objectives: settlement?.objectives ?? report.objectives, + outcome: report.outcome, + objectives: report.objectives, enemyCount: storedReview?.enemyCount ?? report.totalEnemies, activeBondCount, endUnits, @@ -18943,6 +19011,38 @@ export class CampScene extends Phaser.Scene { }; } + private reportSortieRecommendationOutcome( + review: SortiePerformanceReview, + report = this.report + ): SortieRecommendationOutcome | undefined { + if (!report) { + return undefined; + } + const settlement = this.campaign?.battleHistory[report.battleId]; + const recommendation = report.sortieRecommendation ?? settlement?.sortieRecommendation; + if (!recommendation) { + return undefined; + } + return evaluateSortieRecommendationOutcome({ + planId: recommendation.planId, + selectedUnitIds: recommendation.selectedUnitIds, + formationAssignments: recommendation.formationAssignments, + unitReasons: recommendation.unitReasons, + performance: review.snapshot, + outcome: report.outcome, + objectives: report.objectives, + activeBondCount: review.activeBondCount, + sortieOrder: report.sortieOrder ?? settlement?.sortieOrder + }); + } + + private reportSortieRecommendationSnapshot(report = this.report) { + if (!report) { + return undefined; + } + return report.sortieRecommendation ?? this.campaign?.battleHistory[report.battleId]?.sortieRecommendation; + } + private reportFormationSourcePresetIds(snapshot: CampaignSortiePerformanceSnapshot) { const presets = this.sortieFormationPresets(); return campaignSortiePresetIds.filter((presetId) => sortiePerformanceMatchesPreset(snapshot, presets[presetId])); @@ -19271,6 +19371,8 @@ export class CampScene extends Phaser.Scene { this.reportFormationReviewObjects.forEach((object) => object.destroy()); this.reportFormationReviewObjects = []; this.reportFormationReviewView = undefined; + const recommendationOutcome = this.reportSortieRecommendationOutcome(review, report); + const recommendationSnapshot = this.reportSortieRecommendationSnapshot(report); const depth = 52; const width = 980; const height = 420; @@ -19337,12 +19439,19 @@ export class CampScene extends Phaser.Scene { }); const analysisY = y + 184; - this.renderReportFormationAnalysisPanel( - '전술 판단', - [ - `강점 · ${review.strengths.join(' · ')}`, - `보완 · ${this.compactText(review.improvement, 62)}` - ], + const recommendationPanel = this.renderReportFormationAnalysisPanel( + recommendationOutcome && recommendationSnapshot + ? `추천안 검증 · ${recommendationSnapshot.label}` + : '전술 판단', + recommendationOutcome + ? [ + `${recommendationOutcome.statusLabel} ${recommendationOutcome.score}점 · ${recommendationOutcome.kpis.map((kpi) => `${kpi.label} ${kpi.status === 'hit' ? '적중' : kpi.status === 'partial' ? '부분' : '미달'}`).join(' · ')}`, + `다음 조정 · ${this.compactText(recommendationOutcome.improvement, 58)}` + ] + : [ + `강점 · ${review.strengths.join(' · ')}`, + `보완 · ${this.compactText(review.improvement, 62)}` + ], x + 18, analysisY, 596, @@ -19354,7 +19463,7 @@ export class CampScene extends Phaser.Scene { '역할·공명 기여', [ `${sortiePerformanceRoleContributionLine(review)} · 역할 ${review.roleCoverage}/3`, - `공명 ${review.activeBondCount}개 · 연장 ${review.contributionTotals.extendedBondTriggers}회 · 파훼 ${review.contributionTotals.counterplays}` + `공명 ${review.activeBondCount}개 · 추격 ${review.contributionTotals.extendedBondTriggers}회 · 파훼 ${review.contributionTotals.counterplays}` ], x + 624, analysisY, @@ -19365,35 +19474,61 @@ export class CampScene extends Phaser.Scene { ); const rosterY = y + 264; - const hiddenUnitCount = Math.max(0, review.snapshot.selectedUnitIds.length - 8); + const contributionByUnitId = new Map(recommendationOutcome?.unitContributions.map((entry) => [entry.unitId, entry]) ?? []); + const actualUnitIds = review.snapshot.selectedUnitIds; + const displayUnitIds = sortieRecommendationDisplayUnitIds(actualUnitIds, recommendationOutcome?.unitContributions ?? []); + const hiddenUnitCount = Math.max(0, displayUnitIds.length - 8); this.trackReportFormationReview( this.add.text( x + 18, rosterY, - `출전 명단·역할${hiddenUnitCount > 0 ? ` · 외 ${hiddenUnitCount}명` : ''}`, + `${recommendationOutcome ? '추천 무장별 판정' : '출전 명단·역할'}${hiddenUnitCount > 0 ? ` · 외 ${hiddenUnitCount}명` : ''}`, this.textStyle(13, '#f2e3bf', true) ) ).setDepth(depth + 2); - const visibleUnitIds = review.snapshot.selectedUnitIds.slice(0, 8); + const visibleUnitIds = displayUnitIds.slice(0, 8); + const recommendationRows: ReportFormationReviewView['recommendationRows'] = []; const chipGap = 6; const chipWidth = Math.floor((width - 36 - chipGap * Math.max(0, visibleUnitIds.length - 1)) / Math.max(1, visibleUnitIds.length)); const settlement = this.campaign?.battleHistory[report.battleId]; visibleUnitIds.forEach((unitId, index) => { const settledUnit = settlement?.units.find((unit) => unit.unitId === unitId); const reportUnit = report.units.find((unit) => unit.id === unitId); - const hp = settledUnit?.hp ?? reportUnit?.hp ?? 0; - const maxHp = settledUnit?.maxHp ?? reportUnit?.maxHp ?? 0; - const role = review.snapshot.formationAssignments[unitId]; + const hp = reportUnit?.hp ?? settledUnit?.hp ?? 0; + const maxHp = reportUnit?.maxHp ?? settledUnit?.maxHp ?? 0; + const contribution = contributionByUnitId.get(unitId); + const role = contribution?.role ?? review.snapshot.formationAssignments[unitId]; const roleLabel = role === 'front' ? '전' : role === 'flank' ? '돌' : role === 'support' ? '후' : '예'; + const statusColor = contribution + ? this.reportSortieRecommendationOutcomeColor(contribution.status) + : recommendationOutcome + ? { label: '변경', tone: palette.red, text: '#ffb6a6' } + : undefined; const chipX = x + 18 + index * (chipWidth + chipGap); const chip = this.trackReportFormationReview(this.add.rectangle(chipX, rosterY + 23, chipWidth, 38, hp > 0 ? 0x172a22 : 0x2a1b18, 0.94)); chip.setOrigin(0); chip.setDepth(depth + 2); - chip.setStrokeStyle(1, hp > 0 ? palette.green : palette.red, 0.58); - this.trackReportFormationReview(this.add.text(chipX + 7, rosterY + 29, `${roleLabel} ${this.compactText(reportUnit?.name ?? this.unitName(unitId), 7)}`, this.textStyle(10, '#f2e3bf', true))) - .setDepth(depth + 3); - this.trackReportFormationReview(this.add.text(chipX + 7, rosterY + 45, `HP ${hp}/${maxHp}`, this.textStyle(9, hp > 0 ? '#a8ffd0' : '#ffb6a6', true))) + chip.setStrokeStyle(1, statusColor?.tone ?? (hp > 0 ? palette.green : palette.red), 0.68); + const nameText = this.trackReportFormationReview(this.add.text(chipX + 7, rosterY + 29, `${roleLabel} ${this.compactText(reportUnit?.name ?? this.unitName(unitId), 7)}`, { + ...this.textStyle(10, '#f2e3bf', true), + fixedWidth: chipWidth - 14 + })) .setDepth(depth + 3); + const metric = contribution ? this.reportSortieRecommendationContributionCompact(contribution) : recommendationOutcome ? '추천 외' : undefined; + const statusText = this.trackReportFormationReview(this.add.text( + chipX + 7, + rosterY + 45, + recommendationOutcome + ? `${statusColor?.label} · ${metric}` + : `HP ${hp}/${maxHp}`, + { + ...this.textStyle(8, statusColor?.text ?? (hp > 0 ? '#a8ffd0' : '#ffb6a6'), true), + fixedWidth: chipWidth - 14 + } + )).setDepth(depth + 3); + if (recommendationOutcome) { + recommendationRows.push({ unitId, background: chip, nameText, statusText }); + } }); const presetTitleY = y + 337; @@ -19440,7 +19575,12 @@ export class CampScene extends Phaser.Scene { this.trackReportFormationReview(this.add.text(x + 18, y + height - 3, this.compactText(feedback, 78), this.textStyle(10, feedbackColor, true))) .setOrigin(0, 1) .setDepth(depth + 2); - this.reportFormationReviewView = { closeButton, presetButtons }; + this.reportFormationReviewView = { + closeButton, + presetButtons, + recommendationPanel: recommendationOutcome ? recommendationPanel : undefined, + recommendationRows + }; } private hideReportFormationReview(resetFeedback = true) { @@ -19504,6 +19644,22 @@ export class CampScene extends Phaser.Scene { .setDepth(depth + 1); } + private reportSortieRecommendationOutcomeColor(status: SortieRecommendationOutcome['status']) { + if (status === 'hit') { + return { label: '적중', tone: palette.green, text: '#a8ffd0' }; + } + if (status === 'partial') { + return { label: '부분', tone: palette.gold, text: '#ffdf7b' }; + } + return { label: '미달', tone: palette.red, text: '#ffb6a6' }; + } + + private reportSortieRecommendationContributionCompact( + contribution: SortieRecommendationOutcome['unitContributions'][number] + ) { + return sortieRecommendationContributionCompactLabel(contribution); + } + private renderReportFormationAnalysisPanel( title: string, lines: string[], @@ -19523,6 +19679,7 @@ export class CampScene extends Phaser.Scene { this.trackReportFormationReview(this.add.text(x + 12, y + 30 + index * 18, this.compactText(line, width > 500 ? 72 : 42), this.textStyle(10, index === 0 ? '#d4dce6' : '#ffdf7b', true))) .setDepth(depth + 1); }); + return background; } private addReportFormationReviewButton( @@ -21574,7 +21731,7 @@ export class CampScene extends Phaser.Scene { return true; } - private persistSortieSelection() { + private persistSortieSelection(recommendationSelection?: CampaignSortieRecommendationSnapshot) { this.reportFormationHistoryUndoState = undefined; this.sortieSwapUndoState = undefined; this.sortiePinnedSwapCandidateUnitId = undefined; @@ -21591,6 +21748,17 @@ export class CampScene extends Phaser.Scene { campaign.selectedSortieUnitIds = [...this.selectedSortieUnitIds]; campaign.sortieFormationAssignments = { ...this.sortieFormationAssignments }; campaign.sortieItemAssignments = this.cloneSortieItemAssignments(this.sortieItemAssignments); + const activeRecommendation = recommendationSelection ?? campaign.sortieRecommendationSelection; + if (this.sortieRecommendationSelectionMatchesCurrent(activeRecommendation)) { + campaign.sortieRecommendationSelection = { + ...activeRecommendation!, + selectedUnitIds: [...activeRecommendation!.selectedUnitIds], + formationAssignments: { ...activeRecommendation!.formationAssignments }, + unitReasons: { ...activeRecommendation!.unitReasons } + }; + } else { + delete campaign.sortieRecommendationSelection; + } this.campaign = saveCampaignState(campaign); } @@ -21948,6 +22116,10 @@ export class CampScene extends Phaser.Scene { const sortieOrderSummaries = this.sortieOrderSummaries(); const sortieOrderBattleResults = sortieScenario ? this.campaign?.sortieOrderHistory[sortieScenario.id] : undefined; const reportFormationReview = this.reportFormationReview(); + const reportRecommendationOutcome = reportFormationReview + ? this.reportSortieRecommendationOutcome(reportFormationReview) + : undefined; + const reportRecommendationSnapshot = this.reportSortieRecommendationSnapshot(); const reportFormationMatchingPresetIds = reportFormationReview ? this.reportFormationSourcePresetIds(reportFormationReview.snapshot) : []; @@ -22027,6 +22199,7 @@ export class CampScene extends Phaser.Scene { }; }) }, + sortieRecommendationSelection: this.currentSortieRecommendationSelection() ?? null, reportOperationOrder: reportSortieOrder ? { available: true, @@ -22088,6 +22261,53 @@ export class CampScene extends Phaser.Scene { matchingPresetIds: [...reportFormationMatchingPresetIds], overwriteConfirmId: this.reportFormationPresetOverwriteConfirmId ?? null, feedback: this.reportFormationReviewFeedback, + recommendationFeedback: reportRecommendationOutcome && reportRecommendationSnapshot + ? { + planId: reportRecommendationOutcome.planId, + label: reportRecommendationSnapshot.label, + status: reportRecommendationOutcome.status, + statusLabel: reportRecommendationOutcome.statusLabel, + score: reportRecommendationOutcome.score, + summary: reportRecommendationOutcome.summary, + improvement: reportRecommendationOutcome.improvement, + kpis: reportRecommendationOutcome.kpis.map((kpi) => ({ ...kpi })), + counts: { + hit: reportRecommendationOutcome.unitContributions.filter((entry) => entry.status === 'hit').length, + partial: reportRecommendationOutcome.unitContributions.filter((entry) => entry.status === 'partial').length, + miss: reportRecommendationOutcome.unitContributions.filter((entry) => entry.status === 'miss').length + }, + missingUnitIds: reportRecommendationOutcome.unitContributions.filter((entry) => !entry.deployed).map((entry) => entry.unitId), + unexpectedUnitIds: (reportFormationReview?.snapshot.selectedUnitIds ?? []).filter( + (unitId) => !reportRecommendationOutcome.unitContributions.some((entry) => entry.unitId === unitId) + ), + displayedUnitIds: this.reportFormationReviewView?.recommendationRows.map((row) => row.unitId) ?? [], + panelBounds: this.sortieObjectBoundsDebug(this.reportFormationReviewView?.recommendationPanel), + rows: reportRecommendationOutcome.unitContributions.map((entry) => { + const rowView = this.reportFormationReviewView?.recommendationRows.find((row) => row.unitId === entry.unitId); + const settlementUnit = this.report && this.campaign?.battleHistory[this.report.battleId]?.units.find((unit) => unit.unitId === entry.unitId); + const reportUnit = this.report?.units.find((unit) => unit.id === entry.unitId); + return { + unitId: entry.unitId, + name: reportUnit?.name ?? settlementUnit?.name ?? this.unitName(entry.unitId), + role: entry.role, + actualRole: entry.actualRole ?? null, + fullReason: entry.reason, + status: entry.status, + score: entry.score, + headline: entry.headline, + metrics: entry.metrics.map((metric) => ({ ...metric })), + strategyUses: entry.strategyUses, + signatureStrategyUses: entry.signatureStrategyUses, + hp: reportUnit?.hp ?? settlementUnit?.hp ?? 0, + maxHp: reportUnit?.maxHp ?? settlementUnit?.maxHp ?? 0, + cardBounds: this.sortieObjectBoundsDebug(rowView?.background), + nameBounds: this.sortieTextBoundsDebug(rowView?.nameText), + statusBounds: this.sortieTextBoundsDebug(rowView?.statusText), + displayText: rowView?.statusText.text ?? null + }; + }) + } + : null, toggleButtonBounds: this.sortieObjectBoundsDebug(this.reportFormationReviewToggleButton), closeButtonBounds: this.sortieObjectBoundsDebug(this.reportFormationReviewView?.closeButton), presetCards: sortiePresetDefinitions.map((definition) => ({ @@ -22107,6 +22327,7 @@ export class CampScene extends Phaser.Scene { matchingPresetIds: [], overwriteConfirmId: null, feedback: '', + recommendationFeedback: null, toggleButtonBounds: null, closeButtonBounds: null, presetCards: [] diff --git a/src/game/state/battleSaveState.ts b/src/game/state/battleSaveState.ts index b533b59..e4e347f 100644 --- a/src/game/state/battleSaveState.ts +++ b/src/game/state/battleSaveState.ts @@ -1,5 +1,10 @@ import type { BattleBond, UnitData } from '../data/scenario'; -import { isCampaignStep, type CampaignStep } from './campaignState'; +import { + isCampaignStep, + normalizeCampaignSortieRecommendationSnapshot, + type CampaignSortieRecommendationSnapshot, + type CampaignStep +} from './campaignState'; import type { UnitDirection } from '../data/unitAssets'; import { equipmentExpToNext, equipmentSlots, type EquipmentSlot } from '../data/battleItems'; import type { SortieOrderId } from '../data/sortieOrders'; @@ -47,6 +52,8 @@ export type BattleSaveUnitStats = { defeats: number; actions: number; support: number; + strategyUses?: number; + signatureStrategyUses?: number; sortieBonusDamage?: number; sortiePreventedDamage?: number; sortieBonusHealing?: number; @@ -83,6 +90,7 @@ export type BattleSaveState = { campaignStep?: CampaignStep; sortieOrderId?: SortieOrderId; sortieResonanceBondId?: string; + sortieRecommendation?: CampaignSortieRecommendationSnapshot; coreResonanceStats?: BattleSaveCoreResonanceStats; savedAt: string; turnNumber: number; @@ -174,6 +182,15 @@ export function normalizeBattleSaveState( } normalizeCoreResonanceSaveFields(cloned, options); + const recommendation = normalizeCampaignSortieRecommendationSnapshot(cloned.sortieRecommendation, { + expectedBattleId: typeof cloned.battleId === 'string' ? cloned.battleId : options.expectedBattleId, + allowedUnitIds: options.validAllyUnitIds + }); + if (recommendation) { + cloned.sortieRecommendation = recommendation; + } else { + delete cloned.sortieRecommendation; + } return isValidBattleSaveState(cloned, options) ? cloned : undefined; } @@ -709,23 +726,32 @@ function isOptionalStatsRecord(value: unknown, options: BattleSaveValidationOpti return false; } - return Object.entries(value).every( - ([unitId, stats]) => - isKnownUnitId(unitId, options) && - isRecord(stats) && + return Object.entries(value).every(([unitId, stats]) => { + if (!isKnownUnitId(unitId, options) || !isRecord(stats)) { + return false; + } + const strategyUses = stats.strategyUses === undefined ? 0 : stats.strategyUses; + const signatureStrategyUses = stats.signatureStrategyUses === undefined ? 0 : stats.signatureStrategyUses; + return ( isBattleUnitStatValue(stats.damageDealt) && isBattleUnitStatValue(stats.damageTaken) && isBattleUnitStatValue(stats.defeats) && isBattleUnitStatValue(stats.actions) && isBattleUnitStatValue(stats.support) && + isOptionalBattleUnitStatValue(stats.strategyUses) && + isOptionalBattleUnitStatValue(stats.signatureStrategyUses) && isOptionalBattleUnitStatValue(stats.sortieBonusDamage) && isOptionalBattleUnitStatValue(stats.sortiePreventedDamage) && isOptionalBattleUnitStatValue(stats.sortieBonusHealing) && isOptionalBattleUnitStatValue(stats.sortieExtendedBondTriggers) && isOptionalBattleUnitStatValue(stats.intentDefeats) && isOptionalBattleUnitStatValue(stats.intentLineBreaks) && - isOptionalBattleUnitStatValue(stats.intentGuardSuccesses) - ); + isOptionalBattleUnitStatValue(stats.intentGuardSuccesses) && + typeof strategyUses === 'number' && + typeof signatureStrategyUses === 'number' && + signatureStrategyUses <= strategyUses + ); + }); } function isOptionalBattleUnitStatValue(value: unknown) { diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 22cfc42..70b2802 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -4,6 +4,10 @@ import { battleScenarios, type BattleScenarioId } from '../data/battles'; import { campaignRecruitUnits, type BattleBond, type UnitData } from '../data/scenario'; import { normalizeSortieFormationAssignments, type SortieFormationAssignments } from '../data/sortieDeployment'; import { coreSortieResonanceMinLevel } from '../data/sortieSynergy'; +import { + sortieRecommendationPlanIds, + type SortieRecommendationPlanId +} from '../data/sortieRecommendations'; import { isSortieOrderId, sortieOrderCheckIdsByOrder, @@ -64,6 +68,8 @@ export type CampaignSortieUnitPerformanceSnapshot = { defeats: number; actions: number; support: number; + strategyUses: number; + signatureStrategyUses: number; sortieBonusDamage: number; sortiePreventedDamage: number; sortieBonusHealing: number; @@ -90,6 +96,19 @@ export type CampaignSortieReviewSnapshot = { sourcePresetIds: CampaignSortiePresetId[]; }; +export type CampaignSortieRecommendationSnapshot = { + version: 1; + battleId: BattleScenarioId; + planId: SortieRecommendationPlanId; + label: string; + summary: string; + sortieOrderId?: SortieOrderId; + sortieResonanceBondId?: string; + selectedUnitIds: string[]; + formationAssignments: SortieFormationAssignments; + unitReasons: Record; +}; + export type CampaignSortieOrderResultSnapshot = SortieOrderResultSnapshot; export type FirstBattleReport = { @@ -109,6 +128,7 @@ export type FirstBattleReport = { sortiePerformance?: CampaignSortiePerformanceSnapshot; sortieReview?: CampaignSortieReviewSnapshot; sortieOrder?: CampaignSortieOrderResultSnapshot; + sortieRecommendation?: CampaignSortieRecommendationSnapshot; completedCampDialogues: string[]; completedCampVisits: string[]; createdAt: string; @@ -376,6 +396,7 @@ export type CampaignBattleSettlement = { sortiePerformance?: CampaignSortiePerformanceSnapshot; sortieReview?: CampaignSortieReviewSnapshot; sortieOrder?: CampaignSortieOrderResultSnapshot; + sortieRecommendation?: CampaignSortieRecommendationSnapshot; completedAt: string; }; @@ -420,6 +441,7 @@ export type CampaignState = { sortieFormationPresets: CampaignSortieFormationPresets; sortieOrderSelection?: CampaignSortieOrderSelection; sortieResonanceSelection?: CampaignSortieResonanceSelection; + sortieRecommendationSelection?: CampaignSortieRecommendationSnapshot; sortieOrderHistory: CampaignSortieOrderHistory; claimedSortieOrderRewardIds: string[]; reserveTrainingFocus: CampaignReserveTrainingFocusId; @@ -816,6 +838,14 @@ export function setFirstBattleReport(report: FirstBattleReport) { const state = ensureCampaignState(); const reportClone = cloneReport(report); const battleId = reportClone.battleId; + const normalizedRecommendation = normalizeCampaignSortieRecommendationSnapshot(reportClone.sortieRecommendation, { + expectedBattleId: battleId + }); + if (normalizedRecommendation) { + reportClone.sortieRecommendation = normalizedRecommendation; + } else { + delete reportClone.sortieRecommendation; + } const previousSettlement = state.battleHistory[battleId]; const completedCampDialogues = battleId === 'first-battle-zhuo-commandery' ? [...reportClone.completedCampDialogues] : [...state.completedCampDialogues]; @@ -881,6 +911,21 @@ export function setFirstBattleReport(report: FirstBattleReport) { orderId: reportClone.sortieOrder.orderId }; } + if (reportClone.sortieRecommendation) { + state.sortieRecommendationSelection = cloneCampaignSortieRecommendationSnapshot(reportClone.sortieRecommendation); + const resonanceBondId = reportClone.sortieRecommendation.sortieResonanceBondId; + const resonanceBond = resonanceBondId + ? resolveCampaignSortieResonanceBond(battleId, resonanceBondId, state.bonds) + : undefined; + const selectedUnitIds = new Set(state.selectedSortieUnitIds); + if ( + resonanceBond && + resonanceBond.level >= coreSortieResonanceMinLevel && + resonanceBond.unitIds.every((unitId) => selectedUnitIds.has(unitId)) + ) { + state.sortieResonanceSelection = { battleId: battleId as BattleScenarioId, bondId: resonanceBond.id }; + } + } state.step = retryStep; state.latestBattleId = battleId; state.updatedAt = new Date().toISOString(); @@ -926,6 +971,9 @@ export function setFirstBattleReport(report: FirstBattleReport) { if (state.sortieResonanceSelection?.battleId === battleId) { delete state.sortieResonanceSelection; } + if (state.sortieRecommendationSelection?.battleId === battleId) { + delete state.sortieRecommendationSelection; + } state.latestBattleId = battleId; state.updatedAt = new Date().toISOString(); @@ -1133,6 +1181,21 @@ function normalizeCampaignState(state: Partial): CampaignState { if (!normalized.sortieResonanceSelection) { delete normalized.sortieResonanceSelection; } + normalized.sortieRecommendationSelection = normalizeCampaignSortieRecommendationSnapshot( + normalized.sortieRecommendationSelection, + { allowedUnitIds: sortieUnitFilter } + ); + if ( + !normalized.sortieRecommendationSelection || + JSON.stringify(normalized.sortieRecommendationSelection.selectedUnitIds) !== JSON.stringify(normalized.selectedSortieUnitIds) || + normalized.sortieRecommendationSelection.selectedUnitIds.some((unitId) => ( + normalized.sortieRecommendationSelection?.formationAssignments[unitId] !== normalized.sortieFormationAssignments[unitId] + )) || + normalized.sortieRecommendationSelection.sortieOrderId !== normalized.sortieOrderSelection?.orderId || + normalized.sortieRecommendationSelection.sortieResonanceBondId !== normalized.sortieResonanceSelection?.bondId + ) { + delete normalized.sortieRecommendationSelection; + } normalized.sortieOrderHistory = normalizeCampaignSortieOrderHistory(normalized.sortieOrderHistory); normalized.claimedSortieOrderRewardIds = normalizeCampaignSortieOrderRewardClaims( normalized.claimedSortieOrderRewardIds, @@ -1335,6 +1398,67 @@ function normalizeCampaignSortieResonanceSelection( return { battleId: battleId as BattleScenarioId, bondId }; } +type CampaignSortieRecommendationNormalizationOptions = { + expectedBattleId?: string; + allowedUnitIds?: ReadonlySet; +}; + +export function normalizeCampaignSortieRecommendationSnapshot( + value: unknown, + options: CampaignSortieRecommendationNormalizationOptions = {} +): CampaignSortieRecommendationSnapshot | undefined { + if (!isPlainObject(value) || value.version !== 1) { + return undefined; + } + const battleId = typeof value.battleId === 'string' ? value.battleId : ''; + const planId = value.planId; + if ( + !hasCampaignBattleScenario(battleId) || + (options.expectedBattleId && options.expectedBattleId !== battleId) || + !sortieRecommendationPlanIds.includes(planId as SortieRecommendationPlanId) + ) { + return undefined; + } + const label = normalizeDisplayString(value.label).slice(0, 48); + const summary = normalizeDisplayString(value.summary).slice(0, 180); + const selectedUnitIds = uniqueStrings(value.selectedUnitIds) + .filter((unitId) => !options.allowedUnitIds || options.allowedUnitIds.has(unitId)) + .slice(0, maxCampaignSortieAssignmentUnits); + if (!label || !summary || selectedUnitIds.length === 0) { + return undefined; + } + const selectedUnitIdSet = new Set(selectedUnitIds); + const formationAssignments = normalizeSortieFormationAssignments( + recordOrEmpty(value.formationAssignments), + selectedUnitIdSet + ); + if (selectedUnitIds.some((unitId) => !formationAssignments[unitId])) { + return undefined; + } + const reasonRecord = recordOrEmpty(value.unitReasons); + const unitReasons = Object.fromEntries(selectedUnitIds.map((unitId) => [ + unitId, + typeof reasonRecord[unitId] === 'string' ? reasonRecord[unitId].trim().slice(0, 180) : '' + ])); + if (Object.values(unitReasons).some((reason) => reason.length === 0)) { + return undefined; + } + const sortieOrderId = isSortieOrderId(value.sortieOrderId) ? value.sortieOrderId : undefined; + const sortieResonanceBondId = normalizeKeyString(value.sortieResonanceBondId); + return { + version: 1, + battleId, + planId: planId as SortieRecommendationPlanId, + label, + summary, + ...(sortieOrderId ? { sortieOrderId } : {}), + ...(sortieResonanceBondId ? { sortieResonanceBondId } : {}), + selectedUnitIds, + formationAssignments, + unitReasons + }; +} + function resolveCampaignSortieResonanceBond( battleId: string, bondId: string, @@ -1596,13 +1720,20 @@ function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rost turnNumber: report.turnNumber }) : undefined; + const sortieRecommendation = sortiePerformance + ? normalizeCampaignSortieRecommendationSnapshot(report.sortieRecommendation, { + expectedBattleId: report.battleId, + allowedUnitIds: rosterUnitIds + }) + : undefined; const filtered = { ...report, units: report.units.filter((unit) => unit.faction !== 'ally' || rosterUnitIds.has(unit.id)), bonds: report.bonds.filter((bond) => bond.unitIds.every((unitId) => rosterUnitIds.has(unitId))), ...(sortiePerformance ? { sortiePerformance } : {}), ...(sortieReview ? { sortieReview } : {}), - ...(sortieOrder ? { sortieOrder } : {}) + ...(sortieOrder ? { sortieOrder } : {}), + ...(sortieRecommendation ? { sortieRecommendation } : {}) }; if (!sortiePerformance) { delete filtered.sortiePerformance; @@ -1613,6 +1744,9 @@ function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rost if (!sortieOrder) { delete filtered.sortieOrder; } + if (!sortieRecommendation) { + delete filtered.sortieRecommendation; + } if (filtered.mvp && !rosterUnitIds.has(filtered.mvp.unitId)) { delete filtered.mvp; } @@ -1636,6 +1770,12 @@ function filterBattleHistoryRosterReferences( turnNumber: settlement.turnNumber }) : undefined; + const sortieRecommendation = sortiePerformance + ? normalizeCampaignSortieRecommendationSnapshot(settlement.sortieRecommendation, { + expectedBattleId: settlement.battleId, + allowedUnitIds: rosterUnitIds + }) + : undefined; filteredHistory[battleId] = { ...settlement, units: settlement.units.filter((unit) => rosterUnitIds.has(unit.unitId)), @@ -1643,7 +1783,8 @@ function filterBattleHistoryRosterReferences( reserveTraining: settlement.reserveTraining?.filter((entry) => rosterUnitIds.has(entry.unitId)), ...(sortiePerformance ? { sortiePerformance } : {}), ...(sortieReview ? { sortieReview } : {}), - ...(sortieOrder ? { sortieOrder } : {}) + ...(sortieOrder ? { sortieOrder } : {}), + ...(sortieRecommendation ? { sortieRecommendation } : {}) }; if (!sortiePerformance) { delete filteredHistory[battleId].sortiePerformance; @@ -1654,6 +1795,9 @@ function filterBattleHistoryRosterReferences( if (!sortieOrder) { delete filteredHistory[battleId].sortieOrder; } + if (!sortieRecommendation) { + delete filteredHistory[battleId].sortieRecommendation; + } return filteredHistory; }, {}); } @@ -1679,6 +1823,9 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(settlement.sortiePerformance); const sortieReview = normalizeCampaignSortieReviewSnapshot(settlement.sortieReview, sortiePerformance); + const sortieRecommendation = normalizeCampaignSortieRecommendationSnapshot(settlement.sortieRecommendation, { + expectedBattleId: battleId + }); const turnNumber = normalizeNonNegativeInteger(settlement.turnNumber); const sortieOrder = sortieReview ? normalizeCampaignSortieOrderResultSnapshot(settlement.sortieOrder, settlement.outcome, { @@ -1702,6 +1849,7 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle ...(sortiePerformance ? { sortiePerformance } : {}), ...(sortieReview ? { sortieReview } : {}), ...(sortieOrder ? { sortieOrder } : {}), + ...(sortieRecommendation ? { sortieRecommendation } : {}), completedAt }; } @@ -1909,6 +2057,7 @@ export function normalizeCampaignSortiePerformanceSnapshot( return undefined; } const hpBefore = normalizeNonNegativeInteger(entry.hpBefore); + const strategyUses = normalizeNonNegativeInteger(entry.strategyUses); return { unitId, hpBefore, @@ -1918,6 +2067,8 @@ export function normalizeCampaignSortiePerformanceSnapshot( defeats: normalizeNonNegativeInteger(entry.defeats), actions: normalizeNonNegativeInteger(entry.actions), support: normalizeNonNegativeInteger(entry.support), + strategyUses, + signatureStrategyUses: Math.min(strategyUses, normalizeNonNegativeInteger(entry.signatureStrategyUses)), sortieBonusDamage: normalizeNonNegativeInteger(entry.sortieBonusDamage), sortiePreventedDamage: normalizeNonNegativeInteger(entry.sortiePreventedDamage), sortieBonusHealing: normalizeNonNegativeInteger(entry.sortieBonusHealing), @@ -2023,6 +2174,9 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(report.sortiePerformance); const sortieReview = normalizeCampaignSortieReviewSnapshot(report.sortieReview, sortiePerformance); + const sortieRecommendation = normalizeCampaignSortieRecommendationSnapshot(report.sortieRecommendation, { + expectedBattleId: battleId + }); const turnNumber = normalizeNonNegativeInteger(report.turnNumber); const sortieOrder = sortieReview ? normalizeCampaignSortieOrderResultSnapshot(report.sortieOrder, report.outcome, { @@ -2051,6 +2205,7 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi ...(sortiePerformance ? { sortiePerformance } : {}), ...(sortieReview ? { sortieReview } : {}), ...(sortieOrder ? { sortieOrder } : {}), + ...(sortieRecommendation ? { sortieRecommendation } : {}), completedCampDialogues: uniqueStrings(report.completedCampDialogues), completedCampVisits: uniqueStrings(report.completedCampVisits), createdAt: normalizeCampaignTimestamp(report.createdAt) ?? new Date().toISOString() @@ -2310,7 +2465,10 @@ function createBattleSettlement(report: FirstBattleReport, reserveTraining: Camp progress: report.sortieOrder.progress.map((entry) => ({ ...entry })), ...(report.sortieOrder.command ? { command: { ...report.sortieOrder.command } } : {}) } - } + } + : {}), + ...(report.sortieRecommendation + ? { sortieRecommendation: cloneCampaignSortieRecommendationSnapshot(report.sortieRecommendation) } : {}), completedAt: report.createdAt }; @@ -2454,6 +2612,15 @@ function cloneReport(report: FirstBattleReport): FirstBattleReport { return cloned; } +export function cloneCampaignSortieRecommendationSnapshot(snapshot: CampaignSortieRecommendationSnapshot) { + return { + ...snapshot, + selectedUnitIds: [...snapshot.selectedUnitIds], + formationAssignments: { ...snapshot.formationAssignments }, + unitReasons: { ...snapshot.unitReasons } + } satisfies CampaignSortieRecommendationSnapshot; +} + function cloneCampaignRewardSnapshot(rewards?: CampaignRewardSnapshot, battleId?: string): CampaignRewardSnapshot | undefined { if (!rewards) { return undefined;