From b96029bce32eb4ea6c8174170c3dd2eadc8d7241 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Wed, 15 Jul 2026 06:30:26 +0900 Subject: [PATCH] feat: make sortie feedback actionable --- scripts/verify-release-candidate.mjs | 197 +++++- scripts/verify-save-retry-flow.mjs | 172 ++++- scripts/verify-sortie-recommendations.mjs | 158 +++++ .../data/sortieRecommendationImprovement.ts | 305 +++++++++ src/game/scenes/BattleScene.ts | 102 ++- src/game/scenes/CampScene.ts | 610 +++++++++++++++++- 6 files changed, 1506 insertions(+), 38 deletions(-) create mode 100644 src/game/data/sortieRecommendationImprovement.ts diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index fd58097..2d5e283 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -5361,15 +5361,110 @@ try { await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-formation-evaluation-updated.png`, fullPage: true }); await assertCanvasPainted(page, 'mid camp formation evaluation update'); - const closeMidBattleEvaluationPoint = await readBattleResultEvaluationControlPoint(page, 'close'); - await page.mouse.click(closeMidBattleEvaluationPoint.x, closeMidBattleEvaluationPoint.y); + const midResultImprovementAction = updatedMobileResultState?.sortieEvaluation?.improvementAction; + const midImprovementTargetBattleId = midResultImprovementAction?.targetBattleId; + assert( + midResultImprovementAction?.available === true && + midResultImprovementAction.label === '다음 출전 보완' && + midResultImprovementAction.sourceBattleId === midCampNextBattleId && + typeof midImprovementTargetBattleId === 'string' && + midImprovementTargetBattleId !== midCampNextBattleId && + midResultImprovementAction.planId === midRecommendationLaunchFixture.selection.planId && + isFiniteBounds(midResultImprovementAction.buttonBounds) && + boundsInside(midResultImprovementAction.buttonBounds, fhdViewportBounds), + `Expected the victory evaluation to expose an in-FHD next-sortie improvement action: ${JSON.stringify(midResultImprovementAction)}` + ); + const midImprovementPoint = await readBattleResultEvaluationControlPoint(page, 'improvement'); + await page.mouse.click(midImprovementPoint.x, midImprovementPoint.y); + await waitForSortiePrep(page); + + const guidedPreviewState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const guidedPreviewSave = await readCampaignSave(page); + const guidedPreview = guidedPreviewState?.sortieGuidedImprovement; + assert( + guidedPreviewState?.sortiePrepStep === 'formation' && + guidedPreviewState.nextSortieBattleId === midImprovementTargetBattleId && + guidedPreview?.stage === 'preview' && + guidedPreview.sourceBattleId === midCampNextBattleId && + guidedPreview.targetBattleId === midImprovementTargetBattleId && + guidedPreview.proposal?.planId === midResultImprovementAction.planId && + guidedImprovementHasOneAction(guidedPreview.proposal) && + guidedPreviewState.sortieComparisonPreview?.source === 'guided-improvement' && + guidedPreviewState.sortieComparisonAction?.kind === 'confirm-improvement' && + isFiniteBounds(guidedPreviewState.sortieComparisonAction?.buttonBounds) && + boundsInside(guidedPreviewState.sortieComparisonAction.buttonBounds, fhdViewportBounds) && + sameJsonValue( + campaignSaveWithoutCampRecruitmentEffects(guidedPreviewSave), + campaignSaveWithoutCampRecruitmentEffects(updatedMobileResultSave) + ), + `Expected a non-destructive one-officer improvement preview for the next battle: ${JSON.stringify({ + prepStep: guidedPreviewState?.sortiePrepStep, + nextBattleId: guidedPreviewState?.nextSortieBattleId, + guidedImprovement: guidedPreview, + comparisonPreviewSource: guidedPreviewState?.sortieComparisonPreview?.source, + comparisonAction: guidedPreviewState?.sortieComparisonAction, + stableSaveUnchanged: sameJsonValue( + campaignSaveWithoutCampRecruitmentEffects(guidedPreviewSave), + campaignSaveWithoutCampRecruitmentEffects(updatedMobileResultSave) + ) + })}` + ); + await page.screenshot({ path: `${screenshotDir}/rc-mid-sortie-guided-improvement-preview.png`, fullPage: true }); + await assertCanvasPainted(page, 'mid sortie guided improvement preview'); + + const guidedApplyPoint = await readSortieComparisonActionPoint(page); + await page.mouse.click(guidedApplyPoint.x, guidedApplyPoint.y); + await page.waitForFunction(() => { + const camp = window.__HEROS_DEBUG__?.camp?.(); + return camp?.sortieGuidedImprovement?.stage === 'applied' && + camp?.sortieComparisonAction?.kind === 'undo-improvement'; + }, undefined, { timeout: 30000 }); + const guidedAppliedState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const guidedAppliedSave = await readCampaignSave(page); + const guidedProposal = guidedAppliedState?.sortieGuidedImprovement?.proposal; + assert( + sameJsonValue(guidedAppliedState?.selectedSortieUnitIds, guidedProposal?.projectedSelectedUnitIds) && + selectedAssignmentsMatch(guidedAppliedState?.sortieFormationAssignments, guidedProposal?.projectedFormationAssignments, guidedProposal?.projectedSelectedUnitIds) && + sameJsonValue(guidedAppliedSave.current?.selectedSortieUnitIds, guidedProposal?.projectedSelectedUnitIds) && + selectedAssignmentsMatch(guidedAppliedSave.current?.sortieFormationAssignments, guidedProposal?.projectedFormationAssignments, guidedProposal?.projectedSelectedUnitIds) && + guidedAppliedSave.current?.sortieRecommendationSelection?.battleId === midImprovementTargetBattleId && + guidedAppliedSave.current?.sortieRecommendationSelection?.planId === midResultImprovementAction.planId && + guidedAppliedState?.sortieGuidedImprovementUndo?.available === true, + `Expected confirmation to persist exactly the projected roster/role change and revised recommendation provenance: ${JSON.stringify({ + state: guidedAppliedState, + save: guidedAppliedSave + })}` + ); + + const guidedUndoPoint = await readSortieComparisonActionPoint(page); + await page.mouse.click(guidedUndoPoint.x, guidedUndoPoint.y); + await page.waitForFunction((baselineIds) => { + const camp = window.__HEROS_DEBUG__?.camp?.(); + return camp?.sortieGuidedImprovement === null && + camp?.sortieGuidedImprovementUndo === null && + JSON.stringify(camp?.selectedSortieUnitIds) === JSON.stringify(baselineIds); + }, guidedProposal.baselineSelectedUnitIds, { timeout: 30000 }); + const guidedUndoState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const guidedUndoSave = await readCampaignSave(page); + assert( + sameJsonValue(guidedUndoState?.selectedSortieUnitIds, guidedProposal.baselineSelectedUnitIds) && + selectedAssignmentsMatch(guidedUndoState?.sortieFormationAssignments, guidedProposal.baselineFormationAssignments, guidedProposal.baselineSelectedUnitIds) && + sameJsonValue(guidedUndoSave.current?.selectedSortieUnitIds, guidedProposal.baselineSelectedUnitIds) && + selectedAssignmentsMatch(guidedUndoSave.current?.sortieFormationAssignments, guidedProposal.baselineFormationAssignments, guidedProposal.baselineSelectedUnitIds) && + guidedUndoSave.current?.sortieRecommendationSelection === undefined, + `Expected one-click undo to restore the exact pre-improvement formation: ${JSON.stringify({ + state: guidedUndoState, + save: guidedUndoSave + })}` + ); + + const returnFromGuidedImprovementPoint = await readSortieTextControlPoint(page, '군영으로'); + await page.mouse.click(returnFromGuidedImprovementPoint.x, returnFromGuidedImprovementPoint.y); await page.waitForFunction( - () => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === false, + () => window.__HEROS_DEBUG__?.camp?.()?.sortieVisible === false, undefined, { timeout: 30000 } ); - await clickLegacyUi(page, 738, 642); - await waitForCampAfterBattleResult(page); const midCampReviewState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const midCampReviewSaveBefore = await readCampaignSave(page); @@ -5387,6 +5482,11 @@ try { recommendationFeedbackSemantic(midCampFormationEvaluation.recommendationFeedback), recommendationFeedbackSemantic(updatedMobileResultState?.sortieEvaluation?.recommendationFeedback) ) && + midCampFormationEvaluation.improvementAction?.available === true && + midCampFormationEvaluation.improvementAction.label === '다음 출전 보완' && + midCampFormationEvaluation.improvementAction.sourceBattleId === midCampNextBattleId && + midCampFormationEvaluation.improvementAction.targetBattleId === midImprovementTargetBattleId && + midCampFormationEvaluation.improvementAction.planId === midResultImprovementAction.planId && sameJsonValue(midCampFormationEvaluation.sourcePresetIds, midResultSortieReviewBeforePresetUpdate.sourcePresetIds) && midCampMobileCard?.saved === true && midCampMobileCard.matching === true && @@ -5425,6 +5525,9 @@ try { openedMidCampReviewProbe.state?.reportFormationEvaluation?.open === true && openedMidCampReviewProbe.texts.includes('최근 전투 편성 평가') && openedMidCampReviewProbe.texts.some((text) => text.includes('추천안 검증') && text.includes(midRecommendationLaunchFixture.selection.label)) && + openedMidCampReviewProbe.texts.includes('다음 출전 보완') && + isFiniteBounds(openedMidCampReviewProbe.state?.reportFormationEvaluation?.improvementAction?.buttonBounds) && + boundsInside(openedMidCampReviewProbe.state.reportFormationEvaluation.improvementAction.buttonBounds, fhdViewportBounds) && isFiniteBounds(openedCampRecommendationFeedback?.panelBounds) && boundsInside(openedCampRecommendationFeedback.panelBounds, fhdViewportBounds) && sameJsonValue(openedCampRecommendationFeedback.displayedUnitIds, midRecommendationLaunchFixture.selection.selectedUnitIds) && @@ -8122,7 +8225,9 @@ async function readBattleResultEvaluationControlPoint(page, control, presetId) { ? evaluation?.toggleButtonBounds : control === 'close' ? evaluation?.closeButtonBounds - : presetCard?.actionButtonBounds; + : control === 'improvement' + ? evaluation?.improvementAction?.buttonBounds + : presetCard?.actionButtonBounds; if (!scene || !canvas || !bounds) { return { ready: false, @@ -8190,7 +8295,9 @@ async function readCampReportFormationEvaluationControlPoint(page, control, pres ? evaluation?.toggleButtonBounds : control === 'close' ? evaluation?.closeButtonBounds - : presetCard?.actionButtonBounds; + : control === 'improvement' + ? evaluation?.improvementAction?.buttonBounds + : presetCard?.actionButtonBounds; if (!scene || !canvas || !bounds) { return { ready: false, @@ -8962,10 +9069,86 @@ function assertSameMembers(values, expected, message) { assert(expected.every((value) => valueSet.has(value)), message); } +function selectedAssignmentsMatch(actual, expected, selectedUnitIds) { + return Boolean( + actual && expected && Array.isArray(selectedUnitIds) && + selectedUnitIds.every((unitId) => actual[unitId] === expected[unitId]) + ); +} + +function guidedImprovementHasOneAction(proposal) { + if (!proposal) { + return false; + } + const baselineIds = proposal.baselineSelectedUnitIds ?? []; + const projectedIds = proposal.projectedSelectedUnitIds ?? []; + const idSet = new Set([...baselineIds, ...projectedIds]); + const rosterDelta = [...idSet].filter( + (unitId) => baselineIds.includes(unitId) !== projectedIds.includes(unitId) + ); + const roleDelta = [...idSet].filter( + (unitId) => proposal.baselineFormationAssignments?.[unitId] !== proposal.projectedFormationAssignments?.[unitId] + ); + if (proposal.kind === 'swap') { + return baselineIds.length === projectedIds.length && + rosterDelta.length === 2 && + !projectedIds.includes(proposal.outgoingUnitId) && + projectedIds.includes(proposal.incomingUnitId); + } + if (proposal.kind === 'add') { + return projectedIds.length === baselineIds.length + 1 && + rosterDelta.length === 1 && + rosterDelta[0] === proposal.incomingUnitId && + projectedIds.includes(proposal.incomingUnitId); + } + if (proposal.kind === 'role') { + return sameJsonValue(baselineIds, projectedIds) && + roleDelta.length === 1 && + roleDelta[0] === proposal.unitId; + } + return proposal.kind === 'tactic' && + sameJsonValue(baselineIds, projectedIds) && + roleDelta.length === 0 && + typeof proposal.instruction === 'string' && + proposal.instruction.length > 0; +} + function sameJsonValue(value, expected) { return stableJson(value) === stableJson(expected); } +function campaignSaveWithoutCampRecruitmentEffects(save) { + const stableState = (state) => { + if (!state) { + return state; + } + const { + updatedAt: _updatedAt, + roster: _roster, + bonds: _bonds, + firstBattleReport, + ...stableFields + } = state; + if (!firstBattleReport) { + return stableFields; + } + const { + units: _reportUnits, + bonds: _reportBonds, + ...stableReportFields + } = firstBattleReport; + return { + ...stableFields, + firstBattleReport: stableReportFields + }; + }; + + return { + current: stableState(save?.current), + slot1: stableState(save?.slot1) + }; +} + function recommendationFeedbackSemantic(feedback) { if (!feedback) { return null; diff --git a/scripts/verify-save-retry-flow.mjs b/scripts/verify-save-retry-flow.mjs index a07bdeb..ea83aac 100644 --- a/scripts/verify-save-retry-flow.mjs +++ b/scripts/verify-save-retry-flow.mjs @@ -35,6 +35,47 @@ try { throw new Error(`Expected first battle auto-save in current and slot 1 saves: ${JSON.stringify(autoSave)}`); } + await page.evaluate(() => { + const battleId = 'first-battle-zhuo-commandery'; + const installRecommendation = (key) => { + const raw = window.localStorage.getItem(key); + if (!raw) { + return; + } + const state = JSON.parse(raw); + const orderId = state.sortieOrderSelection?.orderId ?? 'elite'; + state.sortieOrderSelection = { battleId, orderId }; + delete state.sortieResonanceSelection; + const selectedUnitIds = state.selectedSortieUnitIds?.length + ? [...state.selectedSortieUnitIds] + : ['liu-bei', 'guan-yu', 'zhang-fei']; + state.selectedSortieUnitIds = [...selectedUnitIds]; + const fallbackRoles = ['front', 'flank', 'support', 'reserve']; + const formationAssignments = Object.fromEntries(selectedUnitIds.map((unitId, index) => [ + unitId, + state.sortieFormationAssignments?.[unitId] ?? fallbackRoles[index] ?? 'reserve' + ])); + state.sortieFormationAssignments = { + ...(state.sortieFormationAssignments ?? {}), + ...formationAssignments + }; + state.sortieRecommendationSelection = { + version: 1, + battleId, + planId: 'order', + label: '재도전 검증안', + summary: '패배 후 같은 전장 보완 동선을 검증합니다.', + sortieOrderId: orderId, + selectedUnitIds, + formationAssignments, + unitReasons: Object.fromEntries(selectedUnitIds.map((unitId) => [unitId, '추천 역할 수행을 검증합니다.'])) + }; + window.localStorage.setItem(key, JSON.stringify(state)); + }; + installRecommendation('heros-web:campaign-state'); + installRecommendation('heros-web:campaign-state:slot-1'); + }); + await page.reload({ waitUntil: 'domcontentloaded' }); await waitForTitle(page); await page.screenshot({ path: 'dist/verification-save-title-continue.png', fullPage: true }); @@ -42,7 +83,12 @@ try { await startTitleDefaultAction(page); await waitForBattleReady(page, 'first-battle-zhuo-commandery'); const continuedBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); - if (continuedBattle?.battleOutcome !== null || continuedBattle?.resultVisible !== false || continuedBattle?.turnNumber !== 1) { + if ( + continuedBattle?.battleOutcome !== null || + continuedBattle?.resultVisible !== false || + continuedBattle?.turnNumber !== 1 || + continuedBattle?.sortieRecommendation?.battleId !== 'first-battle-zhuo-commandery' + ) { throw new Error(`Expected continue to reopen a clean first battle: ${JSON.stringify(continuedBattle)}`); } await page.screenshot({ path: 'dist/verification-save-continued-battle.png', fullPage: true }); @@ -56,7 +102,86 @@ try { throw new Error(`Expected defeat to save retry-ready first battle state: ${JSON.stringify(defeatSave.current)}`); } - await clickLegacyUi(page, 884, 642); + const closedDefeatEvaluation = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation); + if ( + closedDefeatEvaluation?.improvementAction?.available !== true || + closedDefeatEvaluation.improvementAction.label !== '재도전 보완' || + closedDefeatEvaluation.improvementAction.sourceBattleId !== 'first-battle-zhuo-commandery' || + closedDefeatEvaluation.improvementAction.targetBattleId !== 'first-battle-zhuo-commandery' + ) { + throw new Error(`Expected defeat to expose same-battle improvement semantics: ${JSON.stringify(closedDefeatEvaluation)}`); + } + await clickDebugBounds(page, 'BattleScene', closedDefeatEvaluation.toggleButtonBounds); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === true, + undefined, + { timeout: 30000 } + ); + const openedDefeatEvaluation = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation); + if (!insideViewport(openedDefeatEvaluation?.improvementAction?.buttonBounds, baselineViewport)) { + throw new Error(`Expected an in-FHD defeat improvement button: ${JSON.stringify(openedDefeatEvaluation)}`); + } + await clickDebugBounds(page, 'BattleScene', openedDefeatEvaluation.improvementAction.buttonBounds); + try { + await page.waitForFunction(() => { + const camp = window.__HEROS_DEBUG__?.camp?.(); + return camp?.sortieVisible === true && + camp?.sortiePrepStep === 'formation' && + camp?.nextSortieBattleId === 'first-battle-zhuo-commandery' && + camp?.sortieComparisonAction?.kind === 'confirm-improvement'; + }, undefined, { timeout: 30000 }); + } catch { + const transitionState = await page.evaluate(() => ({ + activeScenes: window.__HEROS_GAME__?.scene.getScenes(true).map((scene) => scene.scene.key), + battleOutcome: window.__HEROS_DEBUG__?.battle?.()?.battleOutcome, + camp: (() => { + const camp = window.__HEROS_DEBUG__?.camp?.(); + return camp + ? { + sortieVisible: camp.sortieVisible, + sortiePrepStep: camp.sortiePrepStep, + nextSortieBattleId: camp.nextSortieBattleId, + selectedSortieUnitIds: camp.campaign?.selectedSortieUnitIds, + guidedImprovement: camp.sortieGuidedImprovement, + comparisonAction: camp.sortieComparisonAction, + feedback: camp.sortiePlanFeedback + } + : null; + })() + })); + throw new Error(`Expected defeat improvement CTA to open same-battle Camp preview: ${JSON.stringify(transitionState)}`); + } + const defeatGuidedPreview = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + if ( + defeatGuidedPreview?.sortieGuidedImprovement?.sourceBattleId !== 'first-battle-zhuo-commandery' || + defeatGuidedPreview.sortieGuidedImprovement.targetBattleId !== 'first-battle-zhuo-commandery' || + defeatGuidedPreview.sortieComparisonPreview?.source !== 'guided-improvement' || + !insideViewport(defeatGuidedPreview.sortieComparisonAction?.buttonBounds, baselineViewport) + ) { + throw new Error(`Expected same-battle guided preview after defeat: ${JSON.stringify(defeatGuidedPreview)}`); + } + await clickDebugBounds(page, 'CampScene', defeatGuidedPreview.sortieComparisonAction.buttonBounds); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp?.()?.sortieComparisonAction?.kind === 'undo-improvement', + undefined, + { timeout: 30000 } + ); + const appliedDefeatImprovement = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const appliedDefeatProposal = appliedDefeatImprovement?.sortieGuidedImprovement?.proposal; + if ( + !appliedDefeatProposal || + appliedDefeatImprovement?.sortieRecommendationSelection?.battleId !== 'first-battle-zhuo-commandery' || + appliedDefeatImprovement.sortieRecommendationSelection.planId !== appliedDefeatProposal.planId + ) { + throw new Error(`Expected defeat improvement to preserve same-battle recommendation provenance: ${JSON.stringify(appliedDefeatImprovement)}`); + } + await clickLegacyUi(page, 1116, 656); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp?.()?.sortiePrepStep === 'loadout', + undefined, + { timeout: 30000 } + ); + await clickLegacyUi(page, 1116, 656); await waitForBattleReady(page, 'first-battle-zhuo-commandery'); const retriedBattle = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const retriedLiuBei = retriedBattle?.units?.find((unit) => unit.id === 'liu-bei'); @@ -66,6 +191,9 @@ try { retriedBattle?.battleOutcome !== null || retriedBattle?.resultVisible !== false || retriedBattle?.turnNumber !== 1 || + retriedBattle?.sortieRecommendation?.battleId !== 'first-battle-zhuo-commandery' || + retriedBattle?.sortieRecommendation?.planId !== appliedDefeatProposal.planId || + JSON.stringify(retriedBattle?.selectedSortieUnitIds) !== JSON.stringify(appliedDefeatProposal.projectedSelectedUnitIds) || !retriedLiuBei || retriedLiuBei.hp !== retriedLiuBei.maxHp || damagedAlliesAfterRetry.length > 0 || @@ -275,3 +403,43 @@ function delay(ms) { async function clickLegacyUi(page, x, y, options) { await page.mouse.click(x * legacyUiScale, y * legacyUiScale, options); } + +async function clickDebugBounds(page, sceneKey, bounds) { + if (!bounds) { + throw new Error(`Expected ${sceneKey} debug bounds before click.`); + } + const point = await page.evaluate(({ sceneKey, bounds }) => { + const scene = window.__HEROS_GAME__?.scene.getScene(sceneKey); + const canvas = document.querySelector('canvas'); + if (!scene || !canvas) { + return null; + } + const canvasBounds = canvas.getBoundingClientRect(); + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return { + x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height + }; + }, { sceneKey, bounds }); + if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) { + throw new Error(`Expected a canvas-scaled ${sceneKey} click point: ${JSON.stringify({ bounds, point })}`); + } + await page.mouse.click(point.x, point.y); +} + +function insideViewport(bounds, viewport) { + return Boolean( + bounds && + Number.isFinite(bounds.x) && + Number.isFinite(bounds.y) && + Number.isFinite(bounds.width) && + Number.isFinite(bounds.height) && + bounds.width > 0 && + bounds.height > 0 && + bounds.x >= 0 && + bounds.y >= 0 && + bounds.x + bounds.width <= viewport.width && + bounds.y + bounds.height <= viewport.height + ); +} diff --git a/scripts/verify-sortie-recommendations.mjs b/scripts/verify-sortie-recommendations.mjs index 185fd30..e292084 100644 --- a/scripts/verify-sortie-recommendations.mjs +++ b/scripts/verify-sortie-recommendations.mjs @@ -20,6 +20,7 @@ try { sortieRecommendationContributionCompactLabel, sortieRecommendationDisplayUnitIds } = await server.ssrLoadModule('/src/game/data/sortieRecommendationOutcome.ts'); + const { buildSortieRecommendationImprovement } = await server.ssrLoadModule('/src/game/data/sortieRecommendationImprovement.ts'); const { getUnitStrategyCoverage, usableCatalog } = await server.ssrLoadModule('/src/game/data/battleUsables.ts'); const signatureStrategyByOwner = new Map( Object.values(usableCatalog) @@ -117,6 +118,7 @@ try { verifyRequiredOverflow(fixtureContext, buildSortieRecommendationPlans); verifyDuplicateAndNonAllyCandidates(fixtureContext, buildSortieRecommendationPlans, formationRoles); verifyMissingOrderFallback(fixtureContext, buildSortieRecommendationPlans, formationRoles); + verifyRecommendationImprovement(buildSortieRecommendationImprovement); verifyRecommendationOutcomeFeedback( evaluateSortieRecommendationOutcome, sortieRecommendationContributionCompactLabel, @@ -449,6 +451,162 @@ function verifyMissingOrderFallback(base, buildPlans, formationRoles) { ); } +function verifyRecommendationImprovement(buildImprovement) { + const names = { + 'liu-bei': '유비', + 'guan-yu': '관우', + 'zhao-yun': '조운', + 'zhuge-liang': '제갈량' + }; + const targetPlan = { + id: 'resonance', + label: '공명·추격형', + score: 92, + selectedUnitIds: ['liu-bei', 'zhao-yun', 'zhuge-liang'], + formationAssignments: { + 'liu-bei': 'front', + 'zhao-yun': 'flank', + 'zhuge-liang': 'support' + }, + unitReasons: { + 'liu-bei': '전열을 지킵니다.', + 'zhao-yun': '공명 추격을 여는 돌파 역할입니다.', + 'zhuge-liang': '후원 전법을 담당합니다.' + } + }; + const swapInput = { + plan: targetPlan, + selectedUnitIds: ['liu-bei', 'guan-yu', 'zhuge-liang'], + formationAssignments: { + 'liu-bei': 'front', + 'guan-yu': 'flank', + 'zhuge-liang': 'support' + }, + requiredUnitIds: ['liu-bei'], + unitNames: names, + previousContributions: [ + { unitId: 'guan-yu', status: 'miss', score: 18, deployed: true, headline: '추천 역할 미달', reason: '돌파 부족' } + ] + }; + const swap = deterministicImprovement(buildImprovement, swapInput, 'swap fixture'); + assert(swap.kind === 'swap', `Expected one-unit swap improvement: ${JSON.stringify(swap)}`); + assert( + swap.outgoingUnitId === 'guan-yu' && swap.incomingUnitId === 'zhao-yun', + `Swap must remove the weakest optional officer and add the target officer: ${JSON.stringify(swap)}` + ); + assert(swap.projectedSelectedUnitIds.length === swap.baselineSelectedUnitIds.length, 'Swap must retain sortie size.'); + assert( + symmetricDifference(swap.baselineSelectedUnitIds, swap.projectedSelectedUnitIds).length === 2, + `Swap must have exactly one OUT and one IN: ${JSON.stringify(swap)}` + ); + assert(swap.projectedSelectedUnitIds.includes('liu-bei'), 'Swap must preserve required officers.'); + assert( + swap.projectedFormationAssignments['guan-yu'] === undefined && + swap.projectedFormationAssignments['zhao-yun'] === 'flank' && + swap.projectedFormationAssignments['liu-bei'] === 'front' && + swap.projectedFormationAssignments['zhuge-liang'] === 'support', + `Swap must only replace the outgoing assignment with the target role: ${JSON.stringify(swap)}` + ); + + const addInput = { + plan: targetPlan, + selectedUnitIds: ['liu-bei', 'zhuge-liang'], + formationAssignments: { + 'liu-bei': 'front', + 'zhuge-liang': 'support' + }, + requiredUnitIds: ['liu-bei'], + unitNames: names + }; + const add = deterministicImprovement(buildImprovement, addInput, 'open-slot fixture'); + assert(add.kind === 'add', `Expected one-officer addition for an open slot: ${JSON.stringify(add)}`); + assert( + add.incomingUnitId === 'zhao-yun' && + symmetricDifference(add.baselineSelectedUnitIds, add.projectedSelectedUnitIds).length === 1 && + add.projectedFormationAssignments['zhao-yun'] === 'flank', + `Open-slot improvement must fill exactly one target officer and role: ${JSON.stringify(add)}` + ); + + const roleInput = { + plan: targetPlan, + selectedUnitIds: [...targetPlan.selectedUnitIds], + formationAssignments: { + ...targetPlan.formationAssignments, + 'zhao-yun': 'support' + }, + requiredUnitIds: ['liu-bei'], + unitNames: names, + previousContributions: [ + { unitId: 'zhao-yun', status: 'partial', score: 46, deployed: true, headline: '역할 부분 달성', reason: '돌파 부족' } + ] + }; + const role = deterministicImprovement(buildImprovement, roleInput, 'role fixture'); + assert(role.kind === 'role', `Expected one-role improvement: ${JSON.stringify(role)}`); + assert( + JSON.stringify(role.projectedSelectedUnitIds) === JSON.stringify(role.baselineSelectedUnitIds), + `Role improvement must not change the roster: ${JSON.stringify(role)}` + ); + assert( + JSON.stringify(changedAssignmentKeys(role.baselineFormationAssignments, role.projectedFormationAssignments)) === JSON.stringify(['zhao-yun']) && + role.fromRole === 'support' && role.toRole === 'flank', + `Role improvement must change exactly one officer from support to flank: ${JSON.stringify(role)}` + ); + + const tacticInput = { + plan: targetPlan, + selectedUnitIds: [...targetPlan.selectedUnitIds], + formationAssignments: { ...targetPlan.formationAssignments }, + requiredUnitIds: ['liu-bei'], + unitNames: names, + previousContributions: [ + { unitId: 'zhao-yun', status: 'partial', score: 30, deployed: true, headline: '공명 추격 미발동', reason: '추격 기회 부족' } + ] + }; + const tactic = deterministicImprovement(buildImprovement, tacticInput, 'tactic fixture'); + assert(tactic.kind === 'tactic', `Expected tactical fallback when roster and roles already match: ${JSON.stringify(tactic)}`); + assert( + JSON.stringify(tactic.projectedSelectedUnitIds) === JSON.stringify(tactic.baselineSelectedUnitIds) && + JSON.stringify(tactic.projectedFormationAssignments) === JSON.stringify(tactic.baselineFormationAssignments) && + tactic.instruction.includes('추격 1회'), + `Tactical fallback must preserve configuration and give one concrete pursuit action: ${JSON.stringify(tactic)}` + ); + + const requiredGuardInput = { + plan: { + ...targetPlan, + selectedUnitIds: ['liu-bei', 'zhao-yun'], + formationAssignments: { 'liu-bei': 'front', 'zhao-yun': 'flank' } + }, + selectedUnitIds: ['liu-bei', 'guan-yu'], + formationAssignments: { 'liu-bei': 'front', 'guan-yu': 'flank' }, + requiredUnitIds: ['liu-bei', 'guan-yu'], + unitNames: names + }; + const requiredGuard = deterministicImprovement(buildImprovement, requiredGuardInput, 'required guard fixture'); + assert(requiredGuard.kind !== 'swap', `Required officers must never be selected as swap OUT: ${JSON.stringify(requiredGuard)}`); +} + +function deterministicImprovement(buildImprovement, input, label) { + const frozenInput = JSON.stringify(input); + const result = buildImprovement(input); + const repeated = buildImprovement(input); + assert(JSON.stringify(result) === JSON.stringify(repeated), `${label}: improvement must be deterministic`); + assert(JSON.stringify(input) === frozenInput, `${label}: improvement must not mutate its input`); + return result; +} + +function symmetricDifference(left, right) { + const leftSet = new Set(left); + const rightSet = new Set(right); + return [...new Set([...left, ...right])].filter((value) => leftSet.has(value) !== rightSet.has(value)); +} + +function changedAssignmentKeys(left, right) { + return [...new Set([...Object.keys(left), ...Object.keys(right)])] + .filter((key) => left[key] !== right[key]) + .sort(); +} + function verifyRecommendationOutcomeFeedback(evaluateOutcome, compactContribution, displayUnitIds) { const selectedUnitIds = ['fixture-front', 'fixture-flank', 'fixture-support']; const formationAssignments = { diff --git a/src/game/data/sortieRecommendationImprovement.ts b/src/game/data/sortieRecommendationImprovement.ts new file mode 100644 index 0000000..48fcad5 --- /dev/null +++ b/src/game/data/sortieRecommendationImprovement.ts @@ -0,0 +1,305 @@ +import type { SortieFormationAssignments, SortieFormationRole } from './sortieDeployment'; +import type { + SortieRecommendationOutcomeStatus, + SortieRecommendationUnitContribution +} from './sortieRecommendationOutcome'; +import type { SortieRecommendationPlanId } from './sortieRecommendations'; + +export type SortieRecommendationImprovementKind = 'swap' | 'add' | 'role' | 'tactic'; + +export type SortieRecommendationImprovementPlan = { + id: SortieRecommendationPlanId; + label: string; + score: number; + selectedUnitIds: readonly string[]; + formationAssignments: Readonly; + unitReasons: Readonly>; +}; + +export type SortieRecommendationContributionSummary = Pick< + SortieRecommendationUnitContribution, + 'unitId' | 'status' | 'score' | 'deployed' | 'headline' | 'reason' +>; + +export type SortieRecommendationImprovementInput = { + plan: SortieRecommendationImprovementPlan; + selectedUnitIds: readonly string[]; + formationAssignments: Readonly; + requiredUnitIds?: readonly string[]; + unitNames?: Readonly>; + previousContributions?: readonly SortieRecommendationContributionSummary[]; +}; + +type SortieRecommendationImprovementBase = { + kind: SortieRecommendationImprovementKind; + planId: SortieRecommendationPlanId; + planLabel: string; + planScore: number; + title: string; + detail: string; + focusUnitId?: string; + sourceStatus?: SortieRecommendationOutcomeStatus; + baselineSelectedUnitIds: string[]; + baselineFormationAssignments: SortieFormationAssignments; + projectedSelectedUnitIds: string[]; + projectedFormationAssignments: SortieFormationAssignments; +}; + +export type SortieRecommendationSwapImprovement = SortieRecommendationImprovementBase & { + kind: 'swap'; + outgoingUnitId: string; + outgoingUnitName: string; + incomingUnitId: string; + incomingUnitName: string; + incomingRole: SortieFormationRole; +}; + +export type SortieRecommendationAddImprovement = SortieRecommendationImprovementBase & { + kind: 'add'; + incomingUnitId: string; + incomingUnitName: string; + incomingRole: SortieFormationRole; +}; + +export type SortieRecommendationRoleImprovement = SortieRecommendationImprovementBase & { + kind: 'role'; + unitId: string; + unitName: string; + fromRole: SortieFormationRole; + toRole: SortieFormationRole; +}; + +export type SortieRecommendationTacticImprovement = SortieRecommendationImprovementBase & { + kind: 'tactic'; + unitId?: string; + unitName?: string; + instruction: string; +}; + +export type SortieRecommendationImprovement = + | SortieRecommendationSwapImprovement + | SortieRecommendationAddImprovement + | SortieRecommendationRoleImprovement + | SortieRecommendationTacticImprovement; + +const roleLabels: Record = { + front: '전열', + flank: '돌파', + support: '후원', + reserve: '예비' +}; + +const statusPriority: Record = { + miss: 0, + partial: 1, + hit: 2 +}; + +/** + * 다음 전장 추천안과 현재 편성의 차이를 한 번의 교체 또는 역할 변경으로 줄인다. + * 명단과 역할이 이미 맞으면 직전 미달 기록에서 구체적인 전술 행동 하나를 제시한다. + */ +export function buildSortieRecommendationImprovement( + input: SortieRecommendationImprovementInput +): SortieRecommendationImprovement { + const currentIds = unique(input.selectedUnitIds); + const targetIds = unique(input.plan.selectedUnitIds); + const currentIdSet = new Set(currentIds); + const targetIdSet = new Set(targetIds); + const requiredIdSet = new Set(input.requiredUnitIds ?? []); + const contributionById = new Map((input.previousContributions ?? []).map((entry) => [entry.unitId, entry])); + const unitName = (unitId: string) => input.unitNames?.[unitId] ?? unitId; + const baselineAssignments = assignmentsFor(currentIds, input.formationAssignments); + + const incomingIds = targetIds + .filter((unitId) => !currentIdSet.has(unitId)) + .sort((left, right) => compareContributionPriority(left, right, contributionById, targetIds)); + const outgoingIds = currentIds + .filter((unitId) => !targetIdSet.has(unitId) && !requiredIdSet.has(unitId)) + .sort((left, right) => compareContributionPriority(left, right, contributionById, currentIds)); + const incomingUnitId = incomingIds[0]; + const outgoingUnitId = outgoingIds[0]; + if (incomingUnitId && currentIds.length < targetIds.length) { + const incomingRole = input.plan.formationAssignments[incomingUnitId] ?? 'reserve'; + const projectedIds = [...currentIds, incomingUnitId]; + const projectedAssignments = { ...baselineAssignments, [incomingUnitId]: incomingRole }; + const contribution = contributionById.get(incomingUnitId); + const reason = normalizedText(input.plan.unitReasons[incomingUnitId]); + return { + kind: 'add', + planId: input.plan.id, + planLabel: input.plan.label, + planScore: input.plan.score, + title: `${unitName(incomingUnitId)} 한 명 추가`, + detail: reason || `${input.plan.label}의 빈 출전 자리를 한 자리만 보완합니다.`, + focusUnitId: incomingUnitId, + sourceStatus: contribution?.status, + baselineSelectedUnitIds: currentIds, + baselineFormationAssignments: baselineAssignments, + projectedSelectedUnitIds: projectedIds, + projectedFormationAssignments: projectedAssignments, + incomingUnitId, + incomingUnitName: unitName(incomingUnitId), + incomingRole + }; + } + if (incomingUnitId && outgoingUnitId) { + const incomingRole = input.plan.formationAssignments[incomingUnitId] ?? 'reserve'; + const projectedIds = currentIds.map((unitId) => unitId === outgoingUnitId ? incomingUnitId : unitId); + const projectedAssignments = { ...baselineAssignments }; + delete projectedAssignments[outgoingUnitId]; + projectedAssignments[incomingUnitId] = incomingRole; + const contribution = contributionById.get(incomingUnitId); + const reason = normalizedText(input.plan.unitReasons[incomingUnitId]); + return { + kind: 'swap', + planId: input.plan.id, + planLabel: input.plan.label, + planScore: input.plan.score, + title: `${unitName(outgoingUnitId)} → ${unitName(incomingUnitId)} 한 명 교체`, + detail: reason || `${input.plan.label}의 명단 차이를 한 자리만 보완합니다.`, + focusUnitId: incomingUnitId, + sourceStatus: contribution?.status, + baselineSelectedUnitIds: currentIds, + baselineFormationAssignments: baselineAssignments, + projectedSelectedUnitIds: projectedIds, + projectedFormationAssignments: projectedAssignments, + outgoingUnitId, + outgoingUnitName: unitName(outgoingUnitId), + incomingUnitId, + incomingUnitName: unitName(incomingUnitId), + incomingRole + }; + } + + const roleUnitId = targetIds + .filter((unitId) => currentIdSet.has(unitId)) + .filter((unitId) => { + const currentRole = baselineAssignments[unitId]; + const targetRole = input.plan.formationAssignments[unitId]; + return Boolean(currentRole && targetRole && currentRole !== targetRole); + }) + .sort((left, right) => compareContributionPriority(left, right, contributionById, targetIds))[0]; + if (roleUnitId) { + const fromRole = baselineAssignments[roleUnitId] ?? 'reserve'; + const toRole = input.plan.formationAssignments[roleUnitId] ?? fromRole; + const projectedAssignments = { ...baselineAssignments, [roleUnitId]: toRole }; + const contribution = contributionById.get(roleUnitId); + const reason = normalizedText(input.plan.unitReasons[roleUnitId]); + return { + kind: 'role', + planId: input.plan.id, + planLabel: input.plan.label, + planScore: input.plan.score, + title: `${unitName(roleUnitId)} ${roleLabels[fromRole]} → ${roleLabels[toRole]}`, + detail: reason || `${unitName(roleUnitId)}의 역할을 추천 임무에 맞춥니다.`, + focusUnitId: roleUnitId, + sourceStatus: contribution?.status, + baselineSelectedUnitIds: currentIds, + baselineFormationAssignments: baselineAssignments, + projectedSelectedUnitIds: currentIds, + projectedFormationAssignments: projectedAssignments, + unitId: roleUnitId, + unitName: unitName(roleUnitId), + fromRole, + toRole + }; + } + + const contribution = [...(input.previousContributions ?? [])] + .filter((entry) => entry.status !== 'hit') + .sort((left, right) => ( + statusPriority[left.status] - statusPriority[right.status] || + left.score - right.score || + targetIndex(left.unitId, targetIds) - targetIndex(right.unitId, targetIds) || + left.unitId.localeCompare(right.unitId) + ))[0]; + const tacticUnitId = contribution?.unitId; + const tacticUnitName = tacticUnitId ? unitName(tacticUnitId) : undefined; + const instruction = tacticInstruction(contribution, tacticUnitName, input.plan.id); + return { + kind: 'tactic', + planId: input.plan.id, + planLabel: input.plan.label, + planScore: input.plan.score, + title: tacticUnitName ? `${tacticUnitName} 전술 행동 보완` : `${input.plan.label} 실행 순서 보완`, + detail: contribution?.headline || instruction, + focusUnitId: tacticUnitId, + sourceStatus: contribution?.status, + baselineSelectedUnitIds: currentIds, + baselineFormationAssignments: baselineAssignments, + projectedSelectedUnitIds: currentIds, + projectedFormationAssignments: baselineAssignments, + unitId: tacticUnitId, + unitName: tacticUnitName, + instruction + }; +} + +export function sortieRecommendationImprovementRoleLabel(role: SortieFormationRole) { + return roleLabels[role]; +} + +function tacticInstruction( + contribution: SortieRecommendationContributionSummary | undefined, + unitName: string | undefined, + planId: SortieRecommendationPlanId +) { + const subject = unitName ?? '추천 무장'; + const headline = normalizedText(contribution?.headline); + if (!contribution?.deployed) { + return `${subject}의 출전 가능 여부를 확인하고 추천 임무를 맡기십시오.`; + } + if (headline.includes('고유 전법 미사용')) { + return `${subject}의 고유 전법을 첫 교전 이후 우선 사용하십시오.`; + } + if (headline.includes('추천 전법 미사용')) { + return `${subject}의 추천 전법을 목표 동선에서 한 번 이상 사용하십시오.`; + } + if (headline.includes('공명 추격 미발동')) { + return `${subject}을 공명 상대의 공격 범위에 맞춰 추격 1회를 노리십시오.`; + } + if (planId === 'resonance') { + return `${subject}과 공명 상대의 사거리를 겹쳐 추격 기회를 먼저 만드십시오.`; + } + if (planId === 'terrain-strategy') { + return `${subject}의 전법을 목표 지형에 진입한 첫 교전에서 사용하십시오.`; + } + return `${subject}의 추천 역할 행동을 군령 목표보다 먼저 한 번 실행하십시오.`; +} + +function compareContributionPriority( + leftId: string, + rightId: string, + contributionById: ReadonlyMap, + targetIds: readonly string[] +) { + const left = contributionById.get(leftId); + const right = contributionById.get(rightId); + return ( + statusPriority[left?.status ?? 'hit'] - statusPriority[right?.status ?? 'hit'] || + (left?.score ?? 100) - (right?.score ?? 100) || + targetIndex(leftId, targetIds) - targetIndex(rightId, targetIds) || + leftId.localeCompare(rightId) + ); +} + +function assignmentsFor(unitIds: readonly string[], assignments: Readonly) { + const selected = new Set(unitIds); + return Object.fromEntries( + Object.entries(assignments).filter(([unitId]) => selected.has(unitId)) + ) as SortieFormationAssignments; +} + +function targetIndex(unitId: string, targetIds: readonly string[]) { + const index = targetIds.indexOf(unitId); + return index >= 0 ? index : Number.MAX_SAFE_INTEGER; +} + +function normalizedText(value: string | undefined) { + return typeof value === 'string' ? value.trim() : ''; +} + +function unique(values: readonly string[]) { + return [...new Set(values.filter((value) => typeof value === 'string' && value.length > 0))]; +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index a389f6b..08bec09 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -8,8 +8,10 @@ import { type BattleCampaignRewardDefinition, type BattleObjectiveDefinition, type BattleScenarioDefinition, + type BattleScenarioId, type BattleTacticalGuideEventKey } from '../data/battles'; +import { getSortieFlow } from '../data/campaignFlow'; import { getTerrainRule, getUnitClass, type TerrainType, type UnitClassKey } from '../data/battleRules'; import { ensureUnitAnimations, @@ -1548,6 +1550,7 @@ type UnitBattleStats = { type ResultFormationReviewView = { closeButton: Phaser.GameObjects.Rectangle; + improvementButton?: Phaser.GameObjects.Rectangle; presetButtons: Partial>; recommendationPanel?: Phaser.GameObjects.Rectangle; recommendationRows: { @@ -3603,9 +3606,19 @@ export class BattleScene extends Phaser.Scene { const selectedResonanceBondId = campaign.sortieResonanceSelection?.battleId === battleScenario.id ? campaign.sortieResonanceSelection.bondId : undefined; - this.launchSortieUnitIds = this.normalizeLaunchSortieUnitIds(data?.selectedSortieUnitIds); - this.launchSortieFormationAssignments = normalizeSortieFormationAssignments(data?.sortieFormationAssignments); - this.launchSortieItemAssignments = this.normalizeLaunchSortieItemAssignments(data?.sortieItemAssignments); + this.launchSortieUnitIds = this.normalizeLaunchSortieUnitIds( + data?.selectedSortieUnitIds?.length ? data.selectedSortieUnitIds : campaign.selectedSortieUnitIds + ); + this.launchSortieFormationAssignments = normalizeSortieFormationAssignments( + data?.sortieFormationAssignments && Object.keys(data.sortieFormationAssignments).length > 0 + ? data.sortieFormationAssignments + : campaign.sortieFormationAssignments + ); + this.launchSortieItemAssignments = this.normalizeLaunchSortieItemAssignments( + data?.sortieItemAssignments && Object.keys(data.sortieItemAssignments).length > 0 + ? data.sortieItemAssignments + : campaign.sortieItemAssignments + ); this.launchSortieOrderId = this.normalizeLaunchSortieOrderId(data?.sortieOrderId ?? selectedOrderId); this.launchSortieResonanceBondId = this.normalizeLaunchSortieResonanceBondId( data?.sortieResonanceBondId ?? selectedResonanceBondId @@ -12189,6 +12202,41 @@ export class BattleScene extends Phaser.Scene { }); } + private resultSortieImprovementTargetBattleId(outcome: BattleOutcome): BattleScenarioId | undefined { + if (outcome === 'defeat') { + return battleScenario.id; + } + const campaign = getCampaignState(); + return getSortieFlow(campaign.latestBattleId, campaign.step).nextBattleId; + } + + private openResultSortieImprovement(outcome: BattleOutcome) { + const targetBattleId = this.resultSortieImprovementTargetBattleId(outcome); + if (!targetBattleId || !this.launchSortieRecommendation) { + return; + } + soundDirector.playSelect(); + const campSceneData = { + openSortiePrep: true, + openSortieImprovement: true, + ...(outcome === 'defeat' + ? { + retryBattleId: targetBattleId, + skipIntroStory: true + } + : {}) + }; + if (outcome === 'victory' && battleScenario.id === 'first-battle-zhuo-commandery') { + void startLazyScene(this, 'StoryScene', { + pages: firstBattleVictoryPages, + nextScene: 'CampScene', + nextSceneData: campSceneData + }); + return; + } + void startLazyScene(this, 'CampScene', campSceneData); + } + private publishFirstBattleReport(outcome: BattleOutcome) { const objectives = this.resultObjectives(outcome); const mvp = this.resultMvp(); @@ -12757,6 +12805,9 @@ export class BattleScene extends Phaser.Scene { const review = this.resultFormationReview(outcome, snapshot); const recommendationOutcome = this.resultSortieRecommendationOutcome(outcome, snapshot, review); + const improvementTargetBattleId = recommendationOutcome + ? this.resultSortieImprovementTargetBattleId(outcome) + : undefined; const depth = 130; const width = 980; const height = 420; @@ -12805,8 +12856,8 @@ export class BattleScene extends Phaser.Scene { : '전술 판단', recommendationOutcome ? [ - `${recommendationOutcome.statusLabel} ${recommendationOutcome.score}점 · ${recommendationOutcome.kpis.map((kpi) => `${kpi.label} ${kpi.status === 'hit' ? '적중' : kpi.status === 'partial' ? '부분' : '미달'}`).join(' · ')}`, - `다음 조정 · ${this.resultCompactText(recommendationOutcome.improvement, 58)}` + this.resultCompactText(`${recommendationOutcome.statusLabel} ${recommendationOutcome.score}점 · ${recommendationOutcome.kpis.map((kpi) => `${kpi.label} ${kpi.status === 'hit' ? '적중' : kpi.status === 'partial' ? '부분' : '미달'}`).join(' · ')}`, improvementTargetBattleId ? 40 : 72), + `다음 조정 · ${this.resultCompactText(recommendationOutcome.improvement, improvementTargetBattleId ? 32 : 58)}` ] : [ `강점 · ${review.strengths.join(' · ')}`, @@ -12819,6 +12870,20 @@ export class BattleScene extends Phaser.Scene { review.gradeColor, depth + 1 ); + const improvementButton = recommendationOutcome && improvementTargetBattleId + ? this.addResultFormationReviewButton( + outcome === 'defeat' ? '재도전 보완' : '다음 출전 보완', + x + 456, + analysisY + 18, + 146, + 36, + true, + false, + () => this.openResultSortieImprovement(outcome), + depth + 2, + palette.gold + ) + : undefined; this.renderResultFormationAnalysisPanel( '역할·공명 기여', [ @@ -12927,7 +12992,13 @@ 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, recommendationPanel: recommendationOutcome ? recommendationPanel : undefined, recommendationRows }; + this.resultFormationReviewView = { + closeButton, + improvementButton, + presetButtons, + recommendationPanel: recommendationOutcome ? recommendationPanel : undefined, + recommendationRows + }; } private hideResultFormationReview() { @@ -24779,6 +24850,9 @@ export class BattleScene extends Phaser.Scene { const resultRecommendationOutcome = this.battleOutcome && resultSortiePerformance && resultFormationReview ? this.resultSortieRecommendationOutcome(this.battleOutcome, resultSortiePerformance, resultFormationReview) : undefined; + const resultImprovementTargetBattleId = this.battleOutcome && resultRecommendationOutcome + ? this.resultSortieImprovementTargetBattleId(this.battleOutcome) + : undefined; const resultSourcePresetIds = resultFormationReview ? this.resultFormationSourcePresetIds(resultFormationReview.snapshot) : []; @@ -24905,6 +24979,22 @@ export class BattleScene extends Phaser.Scene { }) } : null, + improvementAction: resultRecommendationOutcome && this.battleOutcome + ? { + available: Boolean(resultImprovementTargetBattleId), + label: resultImprovementTargetBattleId + ? this.battleOutcome === 'defeat' ? '재도전 보완' : '다음 출전 보완' + : null, + sourceBattleId: battleScenario.id, + targetBattleId: resultImprovementTargetBattleId ?? null, + retry: this.battleOutcome === 'defeat', + planId: resultRecommendationOutcome.planId, + weakUnitIds: resultRecommendationOutcome.unitContributions + .filter((entry) => entry.status !== 'hit') + .map((entry) => entry.unitId), + buttonBounds: this.resultObjectBoundsDebug(this.resultFormationReviewView?.improvementButton) + } + : 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 321ad37..76b86ea 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -20,7 +20,7 @@ import { } from '../data/battleUsables'; import { getUnitClass, terrainRules, type TerrainType, type UnitClassKey } from '../data/battleRules'; import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles'; -import { getSortieFlow } from '../data/campaignFlow'; +import { getSortieFlow, type SortieFlow } from '../data/campaignFlow'; import { getCampSoundscape, type CampSoundscape } from '../data/campSoundscapes'; import { campaignPortraitKeysByUnitId, @@ -65,6 +65,10 @@ import { sortieRecommendationDisplayUnitIds, type SortieRecommendationOutcome } from '../data/sortieRecommendationOutcome'; +import { + buildSortieRecommendationImprovement, + type SortieRecommendationImprovement +} from '../data/sortieRecommendationImprovement'; import { sortieOrderDefinition, sortieOrderDefinitions, @@ -441,8 +445,8 @@ type SortieComparisonMetric = { }; type SortieFormationComparisonPreview = { - source: 'focus' | 'hover-add' | 'hover-swap' | 'pinned-swap' | 'hover-blocked' | 'hover-recommendation' | 'pinned-recommendation' | 'hover-preset' | 'pinned-preset'; - mode: SortieUnitSynergyPreview['mode'] | 'swap' | 'recommendation' | 'preset'; + source: 'focus' | 'guided-improvement' | 'hover-add' | 'hover-swap' | 'pinned-swap' | 'hover-blocked' | 'hover-recommendation' | 'pinned-recommendation' | 'hover-preset' | 'pinned-preset'; + mode: SortieUnitSynergyPreview['mode'] | 'swap' | 'recommendation' | 'preset' | 'improvement'; unitId: string; recommendationId?: SortieRecommendationPlanId; presetId?: CampaignSortiePresetId; @@ -561,6 +565,30 @@ type SortieRecommendationUndoState = { applied: SortieConfigurationSnapshot; }; +type SortieGuidedImprovementState = { + sourceBattleId: string; + targetBattleId: BattleScenarioId; + orderId?: CampaignSortieOrderId; + coreBondId?: string; + proposal: SortieRecommendationImprovement; +}; + +type SortieGuidedImprovementProjection = { + selectedUnitIds: string[]; + formationAssignments: SortieFormationAssignments; + itemAssignments: CampaignSortieItemAssignments; +}; + +type SortieGuidedImprovementUndoState = { + sourceBattleId: string; + targetBattleId: BattleScenarioId; + proposal: SortieRecommendationImprovement; + beforeCoreBondId?: string; + beforeRecommendation?: CampaignSortieRecommendationSnapshot; + before: SortieConfigurationSnapshot; + applied: SortieConfigurationSnapshot; +}; + type SortiePresetUndoState = { presetId: CampaignSortiePresetId; before: SortieConfigurationSnapshot; @@ -627,6 +655,7 @@ type SortieOrderBrowserView = { type ReportFormationReviewView = { closeButton: Phaser.GameObjects.Rectangle; + improvementButton?: Phaser.GameObjects.Rectangle; presetButtons: Partial>; recommendationPanel?: Phaser.GameObjects.Rectangle; recommendationRows: { @@ -696,7 +725,7 @@ type ReportFormationHistoryView = { }; type SortieComparisonAction = { - kind: 'confirm-swap' | 'undo-swap' | 'confirm-recommendation' | 'undo-recommendation' | 'confirm-preset' | 'undo-preset'; + kind: 'confirm-improvement' | 'undo-improvement' | 'confirm-swap' | 'undo-swap' | 'confirm-recommendation' | 'undo-recommendation' | 'confirm-preset' | 'undo-preset'; label: string; outgoingUnitId?: string; incomingUnitId?: string; @@ -715,6 +744,8 @@ type CampaignTimelineChapter = { type CampSceneData = { openSortiePrep?: boolean; + openSortieImprovement?: boolean; + retryBattleId?: BattleScenarioId; skipIntroStory?: boolean; }; @@ -11222,6 +11253,8 @@ export class CampScene extends Phaser.Scene { private sortieHoveredRecommendationId?: SortieRecommendationPlanId; private sortiePinnedRecommendationId?: SortieRecommendationPlanId; private sortieRecommendationUndoState?: SortieRecommendationUndoState; + private sortieGuidedImprovement?: SortieGuidedImprovementState; + private sortieGuidedImprovementUndoState?: SortieGuidedImprovementUndoState; private sortieRecommendationPlanCache?: { key: string; plans: SortieRecommendationPlan[] }; private sortieRecommendationBrowserView?: SortieRecommendationBrowserView; private sortieRecommendationReasonPanelView?: SortieRecommendationReasonPanelView; @@ -11247,6 +11280,8 @@ export class CampScene extends Phaser.Scene { private sortiePrepStep: SortiePrepStep = 'briefing'; private terrainCountCache = new Map(); private openSortiePrepOnCreate = false; + private openSortieImprovementOnCreate = false; + private retrySortieBattleId?: BattleScenarioId; private skipIntroStoryForSortie = false; constructor() { @@ -11255,6 +11290,8 @@ export class CampScene extends Phaser.Scene { init(data?: CampSceneData) { this.openSortiePrepOnCreate = Boolean(data?.openSortiePrep); + this.openSortieImprovementOnCreate = Boolean(data?.openSortieImprovement); + this.retrySortieBattleId = data?.retryBattleId; this.skipIntroStoryForSortie = Boolean(data?.skipIntroStory); } @@ -11279,6 +11316,8 @@ export class CampScene extends Phaser.Scene { this.sortieHoveredRecommendationId = undefined; this.sortiePinnedRecommendationId = undefined; this.sortieRecommendationUndoState = undefined; + this.sortieGuidedImprovement = undefined; + this.sortieGuidedImprovementUndoState = undefined; this.sortieRecommendationPlanCache = undefined; this.sortieRecommendationBrowserView = undefined; this.sortieRecommendationReasonPanelView = undefined; @@ -11313,7 +11352,9 @@ export class CampScene extends Phaser.Scene { this.visitedTabs = new Set(['status']); this.campaign = getCampaignState(); this.report = this.campaign.firstBattleReport ?? getFirstBattleReport() ?? this.createFallbackReport(); - this.ensureCurrentCampRecruitment(); + if (!this.retrySortieBattleId) { + this.ensureCurrentCampRecruitment(); + } const sortieFlow = this.currentSortieFlow(); this.campSkinSelection = selectCampSkin({ campaignStep: this.campaign.step, @@ -11462,7 +11503,12 @@ export class CampScene extends Phaser.Scene { this.renderStaticButtons(); this.render(); if (this.openSortiePrepOnCreate) { - this.time.delayedCall(0, () => this.showSortiePrep()); + this.time.delayedCall(0, () => { + if (this.openSortieImprovementOnCreate) { + this.prepareGuidedSortieImprovement(); + } + this.showSortiePrep(); + }); } } @@ -12999,7 +13045,9 @@ export class CampScene extends Phaser.Scene { const isFirstSortieSlice = this.isFirstSortiePrepSlice(flow); const usesStagedPrep = this.usesStagedSortiePrep(flow); if (usesStagedPrep && !wasVisible) { - this.sortiePrepStep = 'briefing'; + this.sortiePrepStep = this.sortieGuidedImprovement || this.sortieGuidedImprovementUndoState + ? 'formation' + : 'briefing'; this.sortiePortraitRosterPage = 0; } const checklist = this.sortieChecklist(); @@ -14009,7 +14057,7 @@ export class CampScene extends Phaser.Scene { actionButton.on('pointerout', () => { const action = this.sortieComparisonAction(); if (actionButton.visible && action) { - const undo = action.kind === 'undo-swap' || action.kind === 'undo-recommendation' || action.kind === 'undo-preset'; + const undo = action.kind === 'undo-improvement' || action.kind === 'undo-swap' || action.kind === 'undo-recommendation' || action.kind === 'undo-preset'; actionButton .setFillStyle(undo ? 0x243442 : 0x263a2d, 0.98) .setStrokeStyle(1, undo ? palette.blue : palette.green, 0.78); @@ -14125,7 +14173,7 @@ export class CampScene extends Phaser.Scene { const action = this.sortieCoreResonanceSelectorOpen ? undefined : this.sortieComparisonAction(preview); this.refreshSortieRecommendationReasonPanel(preview); this.refreshSortieComparisonActionButton(view, action); - const isUndoAction = action?.kind === 'undo-swap' || action?.kind === 'undo-recommendation' || action?.kind === 'undo-preset'; + const isUndoAction = action?.kind === 'undo-improvement' || action?.kind === 'undo-swap' || action?.kind === 'undo-recommendation' || action?.kind === 'undo-preset'; const current = preview?.current ?? this.sortieSynergySnapshot(); const projected = isUndoAction ? current : preview?.projected ?? current; const showMetricTransition = !isUndoAction && Boolean(preview?.projected); @@ -14138,7 +14186,7 @@ export class CampScene extends Phaser.Scene { const lostStrategies = strategyCoverageComparison.lost; const strategyCoverageDelta = strategyCoverageComparison.delta; this.setCampaignSortieCoreSelectorVisible(false); - const isHoverPreview = preview?.source === 'hover-add' || preview?.source === 'hover-swap' || preview?.source === 'pinned-swap' || + const isHoverPreview = preview?.source === 'guided-improvement' || preview?.source === 'hover-add' || preview?.source === 'hover-swap' || preview?.source === 'pinned-swap' || preview?.source === 'hover-recommendation' || preview?.source === 'pinned-recommendation' || preview?.source === 'hover-preset' || preview?.source === 'pinned-preset'; const lostRole = Boolean(comparison?.lostRoles.length); @@ -14207,12 +14255,16 @@ export class CampScene extends Phaser.Scene { return; } - const title = action?.kind === 'undo-recommendation' && this.sortieRecommendationUndoState + const title = action?.kind === 'undo-improvement' && this.sortieGuidedImprovementUndoState + ? `보완 완료 · ${this.sortieGuidedImprovementUndoState.proposal.title}` + : action?.kind === 'undo-recommendation' && this.sortieRecommendationUndoState ? `${this.sortieRecommendationDefinition(this.sortieRecommendationUndoState.recommendationId).label} 적용 완료` : action?.kind === 'undo-preset' && this.sortiePresetUndoState ? `${this.sortiePresetDefinition(this.sortiePresetUndoState.presetId).label} 적용 완료` : isUndoAction && this.sortieSwapUndoState ? `교체 완료 · ${this.sortieSwapUndoState.outgoingUnitName} → ${this.sortieSwapUndoState.incomingUnitName}` + : preview.source === 'guided-improvement' + ? `전투 보고 보완 · ${preview.recommendationId ? this.sortieRecommendationDefinition(preview.recommendationId).label : '추천안'}` : preview.source === 'pinned-recommendation' || preview.source === 'hover-recommendation' ? `${this.sortieRecommendationDefinition(preview.recommendationId ?? 'order').label} 비교` : preview.source === 'pinned-preset' || preview.source === 'hover-preset' @@ -14231,7 +14283,7 @@ export class CampScene extends Phaser.Scene { 26 ) ); - const headlineColor = isUndoAction || preview.source === 'hover-swap' || preview.source === 'pinned-swap' || + const headlineColor = isUndoAction || preview.source === 'guided-improvement' || preview.source === 'hover-swap' || preview.source === 'pinned-swap' || preview.source === 'hover-recommendation' || preview.source === 'pinned-recommendation' || preview.source === 'hover-preset' || preview.source === 'pinned-preset' || preview.mode === 'add' ? '#a8ffd0' @@ -14242,7 +14294,9 @@ export class CampScene extends Phaser.Scene { : preview.mode === 'blocked' ? '#87919c' : '#d4dce6'; - const headline = action?.kind === 'undo-recommendation' && this.sortieRecommendationUndoState + const headline = action?.kind === 'undo-improvement' && this.sortieGuidedImprovementUndoState + ? `${this.sortieGuidedImprovementUndoState.proposal.title} · 출진 전 점검` + : action?.kind === 'undo-recommendation' && this.sortieRecommendationUndoState ? `추천 명단 ${current.selectedUnitIds.length}명·역할 적용 · 장비·보급 재점검` : action?.kind === 'undo-preset' && this.sortiePresetUndoState ? `명단 ${current.selectedUnitIds.length}명·역할 저장값 적용 · 장비·보급 재점검` @@ -14271,6 +14325,16 @@ export class CampScene extends Phaser.Scene { .setColor(color); }); + if (action?.kind === 'undo-improvement' && this.sortieGuidedImprovementUndoState) { + view.impact + .setText(this.compactText(this.sortieGuidedImprovementUndoState.proposal.detail, 68)) + .setColor('#a8ffd0'); + view.detail + .setText('직전 전투의 보완을 적용했습니다. 되돌리기는 한 번만 가능합니다.') + .setColor('#ffdf7b'); + return; + } + if (action?.kind === 'undo-recommendation' && this.sortieRecommendationUndoState) { view.impact .setText(`현재 전법 ${currentStrategyCoverage.count}/${currentStrategyCoverage.total} · 역할 ${current.coveredRoleCount}/3 · 추격 ${pursuitChanges.currentPairs.length}조 · 지형 ${current.terrainGrade}`) @@ -14311,6 +14375,28 @@ export class CampScene extends Phaser.Scene { return; } + if (preview.mode === 'improvement' && this.sortieGuidedImprovement) { + const proposal = this.sortieGuidedImprovement.proposal; + view.impact + .setText(this.compactText(proposal.detail, 52)) + .setColor('#a8ffd0'); + view.detail + .setText( + this.compactText( + proposal.kind === 'swap' + ? `한 명만 교체 · OUT ${proposal.outgoingUnitName} / IN ${proposal.incomingUnitName} · 신규 무장 보급 미배정 · 실제 편성 미변경` + : proposal.kind === 'add' + ? `빈 자리에 한 명만 추가 · IN ${proposal.incomingUnitName} · 신규 무장 보급 미배정 · 실제 편성 미변경` + : proposal.kind === 'role' + ? `한 명의 역할만 변경 · ${proposal.unitName} ${this.sortieFormationRoleLabel(proposal.fromRole, true)}→${this.sortieFormationRoleLabel(proposal.toRole, true)} · 실제 편성 미변경` + : `${proposal.instruction} · 확인 전까지 추천 기록 미변경`, + 64 + ) + ) + .setColor('#ffdf7b'); + return; + } + if (preview.mode === 'recommendation' && preview.recommendationId) { const recommendation = this.sortieRecommendationProjection(preview.recommendationId); const pursuitDelta = pursuitChanges.projectedPairs.length - pursuitChanges.currentPairs.length; @@ -14388,7 +14474,7 @@ export class CampScene extends Phaser.Scene { return; } - const undo = action.kind === 'undo-swap' || action.kind === 'undo-recommendation' || action.kind === 'undo-preset'; + const undo = action.kind === 'undo-improvement' || action.kind === 'undo-swap' || action.kind === 'undo-recommendation' || action.kind === 'undo-preset'; view.actionButton .setInteractive({ useHandCursor: true }) .setFillStyle(undo ? 0x243442 : 0x263a2d, 0.98) @@ -14399,6 +14485,18 @@ export class CampScene extends Phaser.Scene { private sortieComparisonAction( preview = this.sortieFormationComparisonPreview() ): SortieComparisonAction | undefined { + if ( + preview?.source === 'guided-improvement' && + preview.mode === 'improvement' && + this.sortieGuidedImprovement && + this.sortieGuidedImprovementProjection(this.sortieGuidedImprovement) + ) { + return { + kind: 'confirm-improvement', + label: this.sortieGuidedImprovement.proposal.kind === 'tactic' ? '행동 확인' : '보완 적용' + }; + } + if ( preview?.source === 'pinned-recommendation' && preview.mode === 'recommendation' && @@ -14448,6 +14546,14 @@ export class CampScene extends Phaser.Scene { return undefined; } + const guidedUndo = this.sortieGuidedImprovementUndoState; + if (guidedUndo && this.sortiePersistedConfigurationMatches(guidedUndo.applied)) { + return { + kind: 'undo-improvement', + label: '되돌리기' + }; + } + const recommendationUndo = this.sortieRecommendationUndoState; if (recommendationUndo && this.sortiePersistedConfigurationMatches(recommendationUndo.applied)) { return { @@ -14480,6 +14586,14 @@ export class CampScene extends Phaser.Scene { private handleSortieComparisonAction() { const action = this.sortieComparisonAction(); + if (action?.kind === 'confirm-improvement') { + this.confirmGuidedSortieImprovement(); + return; + } + if (action?.kind === 'undo-improvement') { + this.undoGuidedSortieImprovement(); + return; + } if (action?.kind === 'confirm-recommendation' && action.recommendationId) { this.confirmPinnedSortieRecommendation(action.recommendationId); return; @@ -16051,6 +16165,21 @@ export class CampScene extends Phaser.Scene { return scenario && selection?.battleId === scenario.id ? selection.bondId : undefined; } + private validCurrentSortieResonanceBondId( + scenario = this.nextSortieScenario(), + selectedUnitIds: readonly string[] = this.selectedSortieUnitIds + ) { + const bondId = this.currentSortieResonanceBondId(scenario); + if (!scenario || !bondId) { + return undefined; + } + const selected = new Set(selectedUnitIds); + const bond = this.sortieSynergyBonds(scenario).find((candidate) => candidate.id === bondId); + return bond && bond.level >= coreSortieResonanceMinLevel && bond.unitIds.every((unitId) => selected.has(unitId)) + ? bond.id + : undefined; + } + private sortieCoreResonanceCandidates( snapshot = this.sortieSynergySnapshot(), scenario = this.nextSortieScenario() @@ -16107,10 +16236,14 @@ export class CampScene extends Phaser.Scene { this.persistSortieSelection(); const clearing = this.currentSortieResonanceBondId(scenario) === candidate.id; this.campaign = setCampaignSortieResonanceSelection(scenario.id, clearing ? undefined : candidate.id); + const guidedRecalculated = this.resetRecommendationAfterSortieStrategyChange(); const pairLabel = `${candidate.unitNames[0]}↔${candidate.unitNames[1]}`; this.sortiePlanFeedback = clearing ? `핵심 공명조 해제 · ${pairLabel}` : `핵심 공명조 지정 · ${pairLabel} · 추격 ${candidate.coreChainRate}%`; + if (guidedRecalculated) { + this.sortiePlanFeedback += ' · 보완안 재계산'; + } this.sortieCoreResonancePage = 0; soundDirector.playSelect(); this.showCampNotice(this.sortiePlanFeedback); @@ -16321,6 +16454,150 @@ export class CampScene extends Phaser.Scene { return this.sortieRecommendationProjections(scenario).find((projection) => projection.recommendationId === recommendationId); } + private prepareGuidedSortieImprovement() { + const report = this.report; + const review = this.reportFormationReview(); + const scenario = this.nextSortieScenario(); + const outcome = review ? this.reportSortieRecommendationOutcome(review, report) : undefined; + if (!report || !review || !scenario || !outcome) { + this.sortieGuidedImprovement = undefined; + this.sortieGuidedImprovementUndoState = undefined; + return false; + } + + const plans = this.sortieRecommendationPlans(scenario).filter((plan) => plan.applicable && plan.selectedUnitIds.length > 0); + const plan = plans.find((candidate) => candidate.id === outcome.planId) ?? plans[0]; + if (!plan) { + this.sortieGuidedImprovement = undefined; + this.sortieGuidedImprovementUndoState = undefined; + return false; + } + + const rosterById = new Map(this.sortieRosterUnits().map((unit) => [unit.id, unit])); + const currentFormationAssignments = this.selectedSortieUnitIds.reduce((assignments, unitId) => { + const unit = rosterById.get(unitId); + if (unit) { + assignments[unitId] = this.sortieFormationRole(unit, scenario); + } + return assignments; + }, {}); + const unitNames = Object.fromEntries(this.sortieRosterUnits().map((unit) => [unit.id, unit.name])); + const proposal = buildSortieRecommendationImprovement({ + plan, + selectedUnitIds: this.selectedSortieUnitIds, + formationAssignments: currentFormationAssignments, + requiredUnitIds: [...this.requiredSortieUnitIdsFor(scenario)], + unitNames, + previousContributions: outcome.unitContributions + }); + this.sortieGuidedImprovement = { + sourceBattleId: report.battleId, + targetBattleId: scenario.id, + orderId: this.currentSortieOrderId(), + coreBondId: this.validCurrentSortieResonanceBondId(scenario), + proposal + }; + this.sortieGuidedImprovementUndoState = undefined; + this.sortieRecommendationUndoState = undefined; + this.sortiePresetUndoState = undefined; + this.sortieSwapUndoState = undefined; + this.closeSortieFormationPanelBrowser(); + this.sortieCoreResonanceSelectorOpen = false; + this.sortiePrepStep = 'formation'; + this.sortieFocusedUnitId = proposal.focusUnitId && this.selectedSortieUnitIds.includes(proposal.focusUnitId) + ? proposal.focusUnitId + : this.selectedSortieUnitIds[0]; + this.sortiePlanFeedback = `${proposal.title} · 적용 전 미리보기`; + return true; + } + + private sortieGuidedImprovementProjection( + state = this.sortieGuidedImprovement + ): SortieGuidedImprovementProjection | undefined { + const scenario = this.nextSortieScenario(); + if (!state || !scenario || scenario.id !== state.targetBattleId) { + return undefined; + } + const proposal = state.proposal; + const rosterById = new Map(this.sortieRosterUnits().map((unit) => [unit.id, unit])); + if ( + state.orderId !== this.currentSortieOrderId() || + state.coreBondId !== this.validCurrentSortieResonanceBondId(scenario) || + JSON.stringify(proposal.baselineSelectedUnitIds) !== JSON.stringify(this.selectedSortieUnitIds) || + proposal.baselineSelectedUnitIds.some((unitId) => { + const unit = rosterById.get(unitId); + return !unit || proposal.baselineFormationAssignments[unitId] !== this.sortieFormationRole(unit, scenario); + }) + ) { + return undefined; + } + + const selectedUnitIds = this.normalizedSortieUnitIds([...proposal.projectedSelectedUnitIds]); + const selectedIds = new Set(selectedUnitIds); + const requiredIds = this.requiredSortieUnitIdsFor(scenario); + if ( + selectedUnitIds.length !== proposal.projectedSelectedUnitIds.length || + proposal.projectedSelectedUnitIds.some((unitId) => !selectedIds.has(unitId)) || + [...requiredIds].some((unitId) => rosterById.has(unitId) && !selectedIds.has(unitId)) || + selectedUnitIds.some((unitId) => { + const unit = rosterById.get(unitId); + return !unit || !this.sortieUnitAvailability(unit, scenario).available; + }) + ) { + return undefined; + } + + const formationAssignments = selectedUnitIds.reduce((assignments, unitId) => { + const unit = rosterById.get(unitId); + if (unit) { + assignments[unitId] = proposal.projectedFormationAssignments[unitId] ?? this.sortieFormationRole(unit, scenario); + } + return assignments; + }, {}); + const itemAssignments = Object.entries(this.cloneSortieItemAssignments(this.sortieItemAssignments)) + .reduce((assignments, [unitId, stocks]) => { + if (selectedIds.has(unitId)) { + assignments[unitId] = { ...stocks }; + } + return assignments; + }, {}); + return { selectedUnitIds, formationAssignments, itemAssignments }; + } + + private createGuidedSortieRecommendationSnapshot( + state: Pick + ): CampaignSortieRecommendationSnapshot | undefined { + const scenario = this.nextSortieScenario(); + if (!scenario || scenario.id !== state.targetBattleId) { + return undefined; + } + const plan = this.sortieRecommendationPlans(scenario).find((candidate) => candidate.id === state.proposal.planId); + if (!plan) { + return undefined; + } + const sortieOrderId = this.currentSortieOrderId(); + const sortieResonanceBondId = this.validCurrentSortieResonanceBondId(scenario); + const focusUnitId = state.proposal.focusUnitId; + const unitReasons = Object.fromEntries(this.selectedSortieUnitIds.map((unitId) => [ + unitId, + focusUnitId === unitId + ? state.proposal.detail + : plan.unitReasons[unitId] ?? `${this.unitName(unitId)}은 보완 후 편성에서 기존 임무를 유지합니다.` + ])); + return { + version: 1, + battleId: scenario.id, + planId: state.proposal.planId, + label: `${plan.label} · 보완안`, + summary: state.proposal.title, + ...(sortieOrderId ? { sortieOrderId } : {}), + ...(sortieResonanceBondId ? { sortieResonanceBondId } : {}), + selectedUnitIds: [...this.selectedSortieUnitIds], + formationAssignments: { ...this.sortieFormationAssignments }, + unitReasons + }; + } + private sortieRecommendationProjectionMatchesCurrent(projection: SortieRecommendationProjection) { if (JSON.stringify(projection.selectedUnitIds) !== JSON.stringify(this.selectedSortieUnitIds)) { return false; @@ -16487,6 +16764,60 @@ export class CampScene extends Phaser.Scene { if (this.sortieOrderBrowserOpen) { return undefined; } + const guidedImprovement = this.sortieGuidedImprovement; + if (guidedImprovement) { + const projection = this.sortieGuidedImprovementProjection(guidedImprovement); + if (projection) { + const proposal = guidedImprovement.proposal; + const projected = this.sortieSynergySnapshot( + projection.selectedUnitIds, + scenario, + projection.formationAssignments + ); + return { + source: 'guided-improvement', + mode: 'improvement', + unitId: proposal.focusUnitId ?? proposal.planId, + recommendationId: proposal.planId, + incomingUnitId: proposal.kind === 'swap' || proposal.kind === 'add' ? proposal.incomingUnitId : null, + incomingUnitName: proposal.kind === 'swap' || proposal.kind === 'add' ? proposal.incomingUnitName : null, + outgoingUnitId: proposal.kind === 'swap' ? proposal.outgoingUnitId : null, + outgoingUnitName: proposal.kind === 'swap' ? proposal.outgoingUnitName : null, + selectedUnitIds, + projectedSelectedUnitIds: projection.selectedUnitIds, + headline: proposal.title, + detail: proposal.kind === 'tactic' ? proposal.instruction : proposal.detail, + metrics: this.sortiePresetComparisonMetrics(projection, scenario), + current, + projected, + comparison: compareSortieSynergy(current, projected) + }; + } + } + const guidedUndo = this.sortieGuidedImprovementUndoState; + if (guidedUndo && this.sortiePersistedConfigurationMatches(guidedUndo.applied)) { + const proposal = guidedUndo.proposal; + return { + source: 'guided-improvement', + mode: 'improvement', + unitId: proposal.focusUnitId ?? proposal.planId, + recommendationId: proposal.planId, + incomingUnitId: proposal.kind === 'swap' || proposal.kind === 'add' ? proposal.incomingUnitId : null, + incomingUnitName: proposal.kind === 'swap' || proposal.kind === 'add' ? proposal.incomingUnitName : null, + outgoingUnitId: proposal.kind === 'swap' ? proposal.outgoingUnitId : null, + outgoingUnitName: proposal.kind === 'swap' ? proposal.outgoingUnitName : null, + selectedUnitIds, + projectedSelectedUnitIds: selectedUnitIds, + headline: `${proposal.title} 적용 완료`, + detail: proposal.kind === 'tactic' ? proposal.instruction : proposal.detail, + metrics: this.sortiePresetComparisonMetrics({ + selectedUnitIds, + formationAssignments: this.sortieFormationAssignments + }, scenario), + current, + projected: current + }; + } const recommendationId = this.sortieHoveredRecommendationId ?? this.sortiePinnedRecommendationId; if (recommendationId) { const projection = this.sortieRecommendationProjection(recommendationId); @@ -16805,12 +17136,29 @@ export class CampScene extends Phaser.Scene { return; } const definition = sortieOrderDefinition(orderId); + const changed = this.currentSortieOrderId() !== orderId; this.campaign = setCampaignSortieOrderSelection(scenario.id, orderId); + const guidedRecalculated = changed + ? this.resetRecommendationAfterSortieStrategyChange() + : false; this.sortiePlanFeedback = `${definition.label} 군령 선택 · ${definition.condition}`; + if (guidedRecalculated) { + this.sortiePlanFeedback += ' · 보완안 재계산'; + } soundDirector.playSelect(); this.showSortiePrep(); } + private resetRecommendationAfterSortieStrategyChange() { + const hadGuidedImprovement = Boolean( + this.sortieGuidedImprovement || this.sortieGuidedImprovementUndoState + ); + this.sortieGuidedImprovement = undefined; + this.sortieGuidedImprovementUndoState = undefined; + this.persistSortieSelection(null); + return hadGuidedImprovement ? this.prepareGuidedSortieImprovement() : false; + } + private toggleSortiePresetBrowser(forceOpen?: boolean) { const open = forceOpen ?? !this.sortiePresetBrowserOpen; if (!open) { @@ -16973,6 +17321,86 @@ export class CampScene extends Phaser.Scene { this.showCampNotice(`${definition.label} 편성책 저장 완료 · 보급은 포함하지 않았습니다.`); } + private confirmGuidedSortieImprovement() { + const state = this.sortieGuidedImprovement; + const projection = this.sortieGuidedImprovementProjection(state); + if (!state || !projection) { + this.sortieGuidedImprovement = undefined; + this.refreshCampaignSortieComparisonPanel(); + this.showCampNotice('편성 조건이 달라졌습니다. 전투 보고에서 보완안을 다시 여십시오.'); + return; + } + + const beforeCoreBondId = this.currentSortieResonanceBondId(); + const beforeRecommendation = this.currentSortieRecommendationSelection(); + const before = this.captureSortieConfiguration(); + const proposal = state.proposal; + this.sortieRecommendationUndoState = undefined; + this.sortiePresetUndoState = undefined; + this.sortieSwapUndoState = undefined; + this.selectedSortieUnitIds = [...projection.selectedUnitIds]; + this.sortieFormationAssignments = { ...projection.formationAssignments }; + this.sortieItemAssignments = this.cloneSortieItemAssignments(projection.itemAssignments); + this.sortieFocusedUnitId = proposal.focusUnitId && projection.selectedUnitIds.includes(proposal.focusUnitId) + ? proposal.focusUnitId + : projection.selectedUnitIds[0]; + this.sortieRosterScroll = 0; + this.sortiePortraitRosterPage = 0; + this.sortiePlanFeedback = proposal.kind === 'tactic' + ? `전술 행동 확인 · ${proposal.instruction}` + : `${proposal.title} 적용 완료 · 장비·보급 재점검`; + this.sortieGuidedImprovement = undefined; + this.sortieGuidedImprovementUndoState = { + sourceBattleId: state.sourceBattleId, + targetBattleId: state.targetBattleId, + proposal, + beforeCoreBondId, + beforeRecommendation, + before, + applied: this.captureSortieConfiguration() + }; + this.persistSortieSelection(this.createGuidedSortieRecommendationSnapshot(state)); + this.closeSortieFormationPanelBrowser(); + soundDirector.playSelect(); + this.showSortiePrep(); + this.showCampNotice( + proposal.kind === 'tactic' + ? `${proposal.instruction} 출진 전 행동 지침으로 기록했습니다.` + : `${proposal.title} 적용 완료 · 바뀐 무장의 장비와 보급을 확인하십시오.` + ); + } + + private undoGuidedSortieImprovement() { + const undo = this.sortieGuidedImprovementUndoState; + if (!undo || !this.sortiePersistedConfigurationMatches(undo.applied)) { + this.sortieGuidedImprovementUndoState = undefined; + this.refreshCampaignSortieComparisonPanel(); + this.showCampNotice('이후 편성이 변경되어 직전 보완을 되돌릴 수 없습니다.'); + return; + } + + this.sortieGuidedImprovementUndoState = undefined; + this.selectedSortieUnitIds = [...undo.before.selectedUnitIds]; + this.sortieFormationAssignments = { ...undo.before.formationAssignments }; + this.sortieItemAssignments = this.cloneSortieItemAssignments(undo.before.itemAssignments); + this.sortieFocusedUnitId = undo.before.focusedUnitId; + this.sortiePlanFeedback = undo.before.planFeedback; + this.sortieRosterScroll = undo.before.rosterScroll; + this.sortiePortraitRosterPage = undo.before.portraitRosterPage; + this.persistSortieSelection(null); + const scenario = this.nextSortieScenario(); + if (scenario) { + this.campaign = setCampaignSortieResonanceSelection(scenario.id, undo.beforeCoreBondId); + } + if (undo.beforeRecommendation) { + this.persistSortieSelection(undo.beforeRecommendation); + } + this.closeSortieFormationPanelBrowser(); + soundDirector.playSelect(); + this.showSortiePrep(); + this.showCampNotice(`${undo.proposal.title} 적용을 되돌렸습니다.`); + } + private confirmPinnedSortieRecommendation(recommendationId: SortieRecommendationPlanId) { const preview = this.sortieFormationComparisonPreview(); if (preview?.source !== 'pinned-recommendation' || preview.recommendationId !== recommendationId) { @@ -17243,7 +17671,7 @@ export class CampScene extends Phaser.Scene { return undefined; } const sortieOrderId = this.currentSortieOrderId(); - const sortieResonanceBondId = this.currentSortieResonanceBondId(scenario); + const sortieResonanceBondId = this.validCurrentSortieResonanceBondId(scenario, projection.selectedUnitIds); return { version: 1, battleId: scenario.id, @@ -17268,7 +17696,7 @@ export class CampScene extends Phaser.Scene { selection.formationAssignments[unitId] === this.sortieFormationAssignments[unitId] )) && selection.sortieOrderId === this.currentSortieOrderId() && - selection.sortieResonanceBondId === this.currentSortieResonanceBondId(scenario) + selection.sortieResonanceBondId === this.validCurrentSortieResonanceBondId(scenario) ); } @@ -18321,7 +18749,20 @@ export class CampScene extends Phaser.Scene { }; } - private currentSortieFlow() { + private currentSortieFlow(): SortieFlow { + if (this.retrySortieBattleId) { + const scenario = getBattleScenario(this.retrySortieBattleId); + return { + afterBattleId: this.retrySortieBattleId, + eyebrow: '재도전 준비', + title: scenario.title, + description: '직전 전투의 추천 검증을 바탕으로 한 자리만 보완한 뒤 같은 전장에 다시 출진합니다.', + rewardHint: `재도전 목표: ${scenario.victoryConditionLabel}`, + nextBattleId: this.retrySortieBattleId, + campaignStep: this.campaign?.step, + pages: [] + }; + } return getSortieFlow(this.campaign?.latestBattleId, this.campaign?.step); } @@ -18531,7 +18972,7 @@ export class CampScene extends Phaser.Scene { return; } - if (!flow.nextBattleId || !flow.campaignStep || flow.pages.length === 0) { + if (!flow.nextBattleId || !flow.campaignStep || (!this.skipIntroStoryForSortie && flow.pages.length === 0)) { this.hideSortiePrep(); if (!flow.nextBattleId && flow.campaignStep && flow.pages.length > 0) { if (this.campaign?.step === flow.campaignStep) { @@ -19360,6 +19801,36 @@ export class CampScene extends Phaser.Scene { this.renderReportFormationReview(); } + private reportSortieImprovementTargetBattleId(report = this.report): BattleScenarioId | undefined { + if (!report) { + return undefined; + } + if (report.outcome === 'defeat') { + return report.battleId as BattleScenarioId; + } + return this.currentSortieFlow().nextBattleId; + } + + private openReportGuidedSortieImprovement() { + const report = this.report; + const targetBattleId = this.reportSortieImprovementTargetBattleId(report); + if (!report || !targetBattleId) { + this.showCampNotice('보완안을 적용할 다음 전장이 없습니다.'); + return; + } + this.retrySortieBattleId = report.outcome === 'defeat' ? targetBattleId : undefined; + this.skipIntroStoryForSortie = report.outcome === 'defeat'; + this.sortieRecommendationPlanCache = undefined; + this.hideReportFormationReview(); + if (!this.prepareGuidedSortieImprovement()) { + this.showCampNotice('현재 편성 조건으로 보완안을 계산할 수 없습니다. 출전 전략 추천을 확인하십시오.'); + this.showSortiePrep(); + return; + } + soundDirector.playSelect(); + this.showSortiePrep(); + } + private renderReportFormationReview() { const review = this.reportFormationReview(); const report = this.report; @@ -19373,6 +19844,9 @@ export class CampScene extends Phaser.Scene { this.reportFormationReviewView = undefined; const recommendationOutcome = this.reportSortieRecommendationOutcome(review, report); const recommendationSnapshot = this.reportSortieRecommendationSnapshot(report); + const improvementTargetBattleId = recommendationOutcome + ? this.reportSortieImprovementTargetBattleId(report) + : undefined; const depth = 52; const width = 980; const height = 420; @@ -19445,8 +19919,8 @@ export class CampScene extends Phaser.Scene { : '전술 판단', recommendationOutcome ? [ - `${recommendationOutcome.statusLabel} ${recommendationOutcome.score}점 · ${recommendationOutcome.kpis.map((kpi) => `${kpi.label} ${kpi.status === 'hit' ? '적중' : kpi.status === 'partial' ? '부분' : '미달'}`).join(' · ')}`, - `다음 조정 · ${this.compactText(recommendationOutcome.improvement, 58)}` + this.compactText(`${recommendationOutcome.statusLabel} ${recommendationOutcome.score}점 · ${recommendationOutcome.kpis.map((kpi) => `${kpi.label} ${kpi.status === 'hit' ? '적중' : kpi.status === 'partial' ? '부분' : '미달'}`).join(' · ')}`, improvementTargetBattleId ? 40 : 72), + `다음 조정 · ${this.compactText(recommendationOutcome.improvement, improvementTargetBattleId ? 32 : 58)}` ] : [ `강점 · ${review.strengths.join(' · ')}`, @@ -19459,6 +19933,20 @@ export class CampScene extends Phaser.Scene { review.gradeColor, depth + 2 ); + const improvementButton = recommendationOutcome && improvementTargetBattleId + ? this.addReportFormationReviewButton( + report.outcome === 'defeat' ? '재도전 보완' : '다음 출전 보완', + x + 456, + analysisY + 18, + 146, + 36, + true, + false, + () => this.openReportGuidedSortieImprovement(), + depth + 3, + palette.gold + ) + : undefined; this.renderReportFormationAnalysisPanel( '역할·공명 기여', [ @@ -19577,6 +20065,7 @@ export class CampScene extends Phaser.Scene { .setDepth(depth + 2); this.reportFormationReviewView = { closeButton, + improvementButton, presetButtons, recommendationPanel: recommendationOutcome ? recommendationPanel : undefined, recommendationRows @@ -21529,13 +22018,17 @@ export class CampScene extends Phaser.Scene { private sortieAllies() { const scenario = this.nextSortieScenario(); const rule = this.nextSortieRule(scenario); - return this.currentUnits().filter((unit) => this.sortieUnitAvailability(unit, scenario, rule).available); + return this.sortieRosterUnits().filter((unit) => this.sortieUnitAvailability(unit, scenario, rule).available); } private sortieRosterUnits() { const campaignOrder = new Map(this.currentUnits().map((unit, index) => [unit.id, index])); + const restoresFreshRetryReadiness = Boolean(this.retrySortieBattleId && !this.campaign?.roster.length); return this.currentUnits() .filter((unit) => unit.faction === 'ally') + .map((unit) => restoresFreshRetryReadiness && unit.hp <= 0 + ? { ...unit, hp: unit.maxHp } + : unit) .sort((a, b) => (campaignOrder.get(a.id) ?? 0) - (campaignOrder.get(b.id) ?? 0)); } @@ -21731,7 +22224,7 @@ export class CampScene extends Phaser.Scene { return true; } - private persistSortieSelection(recommendationSelection?: CampaignSortieRecommendationSnapshot) { + private persistSortieSelection(recommendationSelection?: CampaignSortieRecommendationSnapshot | null) { this.reportFormationHistoryUndoState = undefined; this.sortieSwapUndoState = undefined; this.sortiePinnedSwapCandidateUnitId = undefined; @@ -21748,7 +22241,13 @@ 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; + const guidedRecommendation = this.sortieGuidedImprovementUndoState && + this.sortiePersistedConfigurationMatches(this.sortieGuidedImprovementUndoState.applied) + ? this.createGuidedSortieRecommendationSnapshot(this.sortieGuidedImprovementUndoState) + : undefined; + const activeRecommendation = recommendationSelection === null + ? undefined + : recommendationSelection ?? guidedRecommendation ?? campaign.sortieRecommendationSelection; if (this.sortieRecommendationSelectionMatchesCurrent(activeRecommendation)) { campaign.sortieRecommendationSelection = { ...activeRecommendation!, @@ -22119,6 +22618,9 @@ export class CampScene extends Phaser.Scene { const reportRecommendationOutcome = reportFormationReview ? this.reportSortieRecommendationOutcome(reportFormationReview) : undefined; + const reportImprovementTargetBattleId = reportRecommendationOutcome + ? this.reportSortieImprovementTargetBattleId() + : undefined; const reportRecommendationSnapshot = this.reportSortieRecommendationSnapshot(); const reportFormationMatchingPresetIds = reportFormationReview ? this.reportFormationSourcePresetIds(reportFormationReview.snapshot) @@ -22308,6 +22810,22 @@ export class CampScene extends Phaser.Scene { }) } : null, + improvementAction: reportRecommendationOutcome + ? { + available: Boolean(reportImprovementTargetBattleId), + label: reportImprovementTargetBattleId + ? this.report?.outcome === 'defeat' ? '재도전 보완' : '다음 출전 보완' + : null, + sourceBattleId: this.report?.battleId ?? null, + targetBattleId: reportImprovementTargetBattleId ?? null, + retry: this.report?.outcome === 'defeat', + planId: reportRecommendationOutcome.planId, + weakUnitIds: reportRecommendationOutcome.unitContributions + .filter((entry) => entry.status !== 'hit') + .map((entry) => entry.unitId), + buttonBounds: this.sortieObjectBoundsDebug(this.reportFormationReviewView?.improvementButton) + } + : null, toggleButtonBounds: this.sortieObjectBoundsDebug(this.reportFormationReviewToggleButton), closeButtonBounds: this.sortieObjectBoundsDebug(this.reportFormationReviewView?.closeButton), presetCards: sortiePresetDefinitions.map((definition) => ({ @@ -22328,6 +22846,7 @@ export class CampScene extends Phaser.Scene { overwriteConfirmId: null, feedback: '', recommendationFeedback: null, + improvementAction: null, toggleButtonBounds: null, closeButtonBounds: null, presetCards: [] @@ -22655,6 +23174,33 @@ export class CampScene extends Phaser.Scene { }; }) }, + sortieGuidedImprovement: this.sortieGuidedImprovement + ? { + stage: 'preview', + sourceBattleId: this.sortieGuidedImprovement.sourceBattleId, + targetBattleId: this.sortieGuidedImprovement.targetBattleId, + proposal: { + ...this.sortieGuidedImprovement.proposal, + baselineSelectedUnitIds: [...this.sortieGuidedImprovement.proposal.baselineSelectedUnitIds], + baselineFormationAssignments: { ...this.sortieGuidedImprovement.proposal.baselineFormationAssignments }, + projectedSelectedUnitIds: [...this.sortieGuidedImprovement.proposal.projectedSelectedUnitIds], + projectedFormationAssignments: { ...this.sortieGuidedImprovement.proposal.projectedFormationAssignments } + } + } + : this.sortieGuidedImprovementUndoState + ? { + stage: 'applied', + sourceBattleId: this.sortieGuidedImprovementUndoState.sourceBattleId, + targetBattleId: this.sortieGuidedImprovementUndoState.targetBattleId, + proposal: { + ...this.sortieGuidedImprovementUndoState.proposal, + baselineSelectedUnitIds: [...this.sortieGuidedImprovementUndoState.proposal.baselineSelectedUnitIds], + baselineFormationAssignments: { ...this.sortieGuidedImprovementUndoState.proposal.baselineFormationAssignments }, + projectedSelectedUnitIds: [...this.sortieGuidedImprovementUndoState.proposal.projectedSelectedUnitIds], + projectedFormationAssignments: { ...this.sortieGuidedImprovementUndoState.proposal.projectedFormationAssignments } + } + } + : null, sortieComparisonPreview: sortieComparisonPreview ? { ...sortieComparisonPreview, @@ -22698,6 +23244,24 @@ export class CampScene extends Phaser.Scene { beforeCoreBondId: this.sortieRecommendationUndoState.beforeCoreBondId ?? null } : null, + sortieGuidedImprovementUndo: this.sortieGuidedImprovementUndoState + ? { + available: sortieComparisonAction?.kind === 'undo-improvement', + sourceBattleId: this.sortieGuidedImprovementUndoState.sourceBattleId, + targetBattleId: this.sortieGuidedImprovementUndoState.targetBattleId, + kind: this.sortieGuidedImprovementUndoState.proposal.kind, + before: { + selectedUnitIds: [...this.sortieGuidedImprovementUndoState.before.selectedUnitIds], + formationAssignments: { ...this.sortieGuidedImprovementUndoState.before.formationAssignments }, + itemAssignments: this.cloneSortieItemAssignments(this.sortieGuidedImprovementUndoState.before.itemAssignments) + }, + applied: { + selectedUnitIds: [...this.sortieGuidedImprovementUndoState.applied.selectedUnitIds], + formationAssignments: { ...this.sortieGuidedImprovementUndoState.applied.formationAssignments }, + itemAssignments: this.cloneSortieItemAssignments(this.sortieGuidedImprovementUndoState.applied.itemAssignments) + } + } + : null, sortieFocusedUnit: this.sortieFocusedUnitSummary(), sortieStrategyCoverage: { total: sortieStrategyCoverage.total,