diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index 7e1fb5c..8deb0f0 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -35,6 +35,7 @@ try { normalizeCampaignReserveTrainingAssignments, normalizeCampaignSortieFormationPresets, normalizeCampaignSortieItemAssignments, + normalizeCampaignSortiePerformanceSnapshot, resetCampaignState, saveCampaignState, setCampaignState, @@ -45,6 +46,24 @@ try { const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); const { campaignRecruitUnits } = await server.ssrLoadModule('/src/game/data/scenario.ts'); const { normalizeSortieFormationAssignments } = await server.ssrLoadModule('/src/game/data/sortieDeployment.ts'); + const sortieStatsFixture = (unitId, overrides = {}) => ({ + unitId, + hpBefore: 30, + maxHpBefore: 30, + damageDealt: 10, + damageTaken: 5, + defeats: 1, + actions: 2, + support: 0, + sortieBonusDamage: 1, + sortiePreventedDamage: 2, + sortieBonusHealing: 0, + sortieExtendedBondTriggers: 0, + intentDefeats: 0, + intentLineBreaks: 0, + intentGuardSuccesses: 0, + ...overrides + }); storage.clear(); const empty = loadCampaignState(); @@ -155,6 +174,88 @@ try { !Object.prototype.hasOwnProperty.call(cappedSortiePresets.elite, 'sortieItemAssignments'), `Expected each sortie preset and its role map to stay capped at 96 entries without persisting supplies: ${JSON.stringify(cappedSortiePresets)}` ); + const normalizedSortiePerformance = normalizeCampaignSortiePerformanceSnapshot( + { + selectedUnitIds: [' liu-bei ', 'guan-yu', 'liu-bei', 'ghost-unit', '', 7, 'x'.repeat(97)], + formationAssignments: { + ' liu-bei ': 'front', + 'liu-bei': 'support', + 'guan-yu': 'flank', + 'ghost-unit': 'reserve', + broken: 'center' + }, + unitStats: [ + sortieStatsFixture(' liu-bei ', { + hpBefore: '28', + maxHpBefore: '20', + damageDealt: '1000000', + damageTaken: -4, + defeats: '2.9', + actions: 'not-a-number', + support: '7', + sortieBonusDamage: '3', + sortiePreventedDamage: '4', + sortieBonusHealing: '5', + sortieExtendedBondTriggers: '6', + intentDefeats: '7', + intentLineBreaks: '8', + intentGuardSuccesses: '9', + finalHp: 1, + sortieItemAssignments: { bean: 2 } + }), + sortieStatsFixture('liu-bei', { damageDealt: 1 }), + sortieStatsFixture('guan-yu', { hpBefore: '12', maxHpBefore: '30', support: '4' }), + sortieStatsFixture('ghost-unit'), + { broken: true } + ], + sortieItemAssignments: { 'liu-bei': { bean: 2 } }, + itemAssignments: { 'guan-yu': { wine: 1 } } + }, + new Set(['liu-bei', 'guan-yu']) + ); + const normalizedLiuBeiPerformance = normalizedSortiePerformance?.unitStats.find((stats) => stats.unitId === 'liu-bei'); + const normalizedGuanYuPerformance = normalizedSortiePerformance?.unitStats.find((stats) => stats.unitId === 'guan-yu'); + assert( + JSON.stringify(normalizedSortiePerformance?.selectedUnitIds) === JSON.stringify(['liu-bei', 'guan-yu']) && + JSON.stringify(normalizedSortiePerformance?.formationAssignments) === JSON.stringify({ 'liu-bei': 'front', 'guan-yu': 'flank' }) && + normalizedSortiePerformance?.unitStats.length === 2 && + normalizedLiuBeiPerformance?.hpBefore === 28 && + normalizedLiuBeiPerformance.maxHpBefore === 28 && + normalizedLiuBeiPerformance.damageDealt === 999999 && + normalizedLiuBeiPerformance.damageTaken === 0 && + normalizedLiuBeiPerformance.defeats === 2 && + normalizedLiuBeiPerformance.actions === 0 && + normalizedLiuBeiPerformance.support === 7 && + normalizedLiuBeiPerformance.sortieBonusDamage === 3 && + normalizedLiuBeiPerformance.sortiePreventedDamage === 4 && + normalizedLiuBeiPerformance.sortieBonusHealing === 5 && + normalizedLiuBeiPerformance.sortieExtendedBondTriggers === 6 && + normalizedLiuBeiPerformance.intentDefeats === 7 && + normalizedLiuBeiPerformance.intentLineBreaks === 8 && + normalizedLiuBeiPerformance.intentGuardSuccesses === 9 && + normalizedGuanYuPerformance?.maxHpBefore === 30 && + normalizedGuanYuPerformance.support === 4 && + !Object.prototype.hasOwnProperty.call(normalizedSortiePerformance ?? {}, 'sortieItemAssignments') && + !Object.prototype.hasOwnProperty.call(normalizedSortiePerformance ?? {}, 'itemAssignments') && + !Object.prototype.hasOwnProperty.call(normalizedLiuBeiPerformance ?? {}, 'finalHp') && + !Object.prototype.hasOwnProperty.call(normalizedLiuBeiPerformance ?? {}, 'sortieItemAssignments'), + `Expected sortie performance snapshots to normalize ids, roles, HP baselines, numeric contribution fields, duplicates, and exclude supplies: ${JSON.stringify(normalizedSortiePerformance)}` + ); + const cappedSortiePerformance = normalizeCampaignSortiePerformanceSnapshot({ + selectedUnitIds: Array.from({ length: 120 }, (_, index) => `unit-${index}`), + formationAssignments: Object.fromEntries(Array.from({ length: 120 }, (_, index) => [`unit-${index}`, 'front'])), + unitStats: Array.from({ length: 120 }, (_, index) => sortieStatsFixture(`unit-${index}`)) + }); + assert( + cappedSortiePerformance?.selectedUnitIds.length === 96 && + cappedSortiePerformance.selectedUnitIds[95] === 'unit-95' && + cappedSortiePerformance.formationAssignments['unit-95'] === 'front' && + cappedSortiePerformance.formationAssignments['unit-96'] === undefined && + cappedSortiePerformance.unitStats.length === 96 && + cappedSortiePerformance.unitStats[95].unitId === 'unit-95' && + normalizeCampaignSortiePerformanceSnapshot({ selectedUnitIds: [], unitStats: [] }) === undefined, + `Expected sortie performance snapshots to stay capped and empty snapshots to be discarded: ${JSON.stringify(cappedSortiePerformance)}` + ); storage.clear(); storage.set( @@ -303,6 +404,47 @@ try { completedCampVisits: [], createdAt: '2026-07-03T09:00:00.000Z' }; + const performanceUnit = alliedFirstBattleUnits.find((unit) => unit.id === 'liu-bei') ?? alliedFirstBattleUnits[0]; + assert(performanceUnit, 'Expected an allied first-battle unit for sortie performance clone checks'); + const performanceReport = { + ...firstBattleReport, + sortiePerformance: { + selectedUnitIds: [performanceUnit.id], + formationAssignments: { [performanceUnit.id]: 'front' }, + unitStats: [sortieStatsFixture(performanceUnit.id, { hpBefore: performanceUnit.maxHp, maxHpBefore: performanceUnit.maxHp, damageDealt: 24 })] + }, + createdAt: '2026-07-03T09:05:00.000Z' + }; + resetCampaignState(); + setFirstBattleReport(performanceReport); + performanceReport.sortiePerformance.selectedUnitIds.push('ghost-unit'); + performanceReport.sortiePerformance.formationAssignments[performanceUnit.id] = 'support'; + performanceReport.sortiePerformance.unitStats[0].damageDealt = 999; + const clonedPerformanceSettlement = getCampaignState(); + const clonedReportPerformance = clonedPerformanceSettlement.firstBattleReport?.sortiePerformance; + const clonedHistoryPerformance = clonedPerformanceSettlement.battleHistory[firstScenario.id]?.sortiePerformance; + assert( + JSON.stringify(clonedReportPerformance?.selectedUnitIds) === JSON.stringify([performanceUnit.id]) && + clonedReportPerformance.formationAssignments[performanceUnit.id] === 'front' && + clonedReportPerformance.unitStats[0].damageDealt === 24 && + JSON.stringify(clonedHistoryPerformance?.selectedUnitIds) === JSON.stringify([performanceUnit.id]) && + clonedHistoryPerformance.formationAssignments[performanceUnit.id] === 'front' && + clonedHistoryPerformance.unitStats[0].damageDealt === 24, + `Expected battle report settlement to deep-clone nested sortie performance input: ${JSON.stringify(clonedPerformanceSettlement)}` + ); + clonedReportPerformance.selectedUnitIds.push('detached-unit'); + clonedReportPerformance.formationAssignments[performanceUnit.id] = 'reserve'; + clonedReportPerformance.unitStats[0].damageDealt = 777; + const unmutatedPerformanceSettlement = getCampaignState(); + assert( + clonedHistoryPerformance.unitStats[0].damageDealt === 24 && + JSON.stringify(unmutatedPerformanceSettlement.firstBattleReport?.sortiePerformance?.selectedUnitIds) === JSON.stringify([performanceUnit.id]) && + unmutatedPerformanceSettlement.firstBattleReport?.sortiePerformance?.formationAssignments[performanceUnit.id] === 'front' && + unmutatedPerformanceSettlement.firstBattleReport?.sortiePerformance?.unitStats[0].damageDealt === 24 && + unmutatedPerformanceSettlement.battleHistory[firstScenario.id]?.sortiePerformance?.unitStats[0].damageDealt === 24, + `Expected returned report, history, and persisted sortie performance snapshots to remain deeply detached: ${JSON.stringify(unmutatedPerformanceSettlement)}` + ); + resetCampaignState(); const reserveDrillTemplate = campaignRecruitUnits.find((unit) => unit.id === 'jian-yong'); assert(reserveDrillTemplate, 'Expected Jian Yong recruit template to exist for reserve drill assignment checks'); storage.clear(); @@ -357,6 +499,11 @@ try { repeatedSettlement.battleHistory[firstScenario.id]?.rewardGold === 100, `Expected repeated battle report settlement to avoid duplicate gold/items: ${JSON.stringify(repeatedSettlement)}` ); + assert( + repeatedSettlement.firstBattleReport?.sortiePerformance === undefined && + repeatedSettlement.battleHistory[firstScenario.id]?.sortiePerformance === undefined, + `Expected legacy reports without sortie performance to remain compatible in both latest report and history: ${JSON.stringify(repeatedSettlement)}` + ); assert( repeatedSettlement.firstBattleReport?.campaignRewards?.unlocks[0]?.battleId === 'second-battle-yellow-turban-pursuit' && repeatedSettlement.firstBattleReport.campaignRewards.unlocks[0].title === firstScenarioUnlocks[0]?.title && @@ -772,7 +919,12 @@ try { { id: 'liu-bei__guan-yu', title: 'Oath Bond', unitIds: ['liu-bei', 'guan-yu'], level: 2, exp: 30, battleExp: 4 }, { id: 'liu-bei__ghost', title: 'Ghost Bond', unitIds: ['liu-bei', 'ghost-unit'], level: 3, exp: 40, battleExp: 5 } ], - mvp: { unitId: 'ghost-unit', name: 'Ghost', damageDealt: 99, defeats: 4 } + mvp: { unitId: 'ghost-unit', name: 'Ghost', damageDealt: 99, defeats: 4 }, + sortiePerformance: { + selectedUnitIds: ['liu-bei', 'ghost-unit', 'liu-bei'], + formationAssignments: { 'liu-bei': 'front', 'ghost-unit': 'support' }, + unitStats: [sortieStatsFixture('liu-bei', { damageDealt: 31 }), sortieStatsFixture('ghost-unit', { damageDealt: 99 })] + } } }) ); @@ -780,8 +932,13 @@ try { assert( staleReportRefs.firstBattleReport?.bonds.length === 1 && staleReportRefs.firstBattleReport.bonds[0].id === 'liu-bei__guan-yu' && - staleReportRefs.firstBattleReport.mvp === undefined, - `Expected first battle report bonds and MVP to be filtered against the recovered roster: ${JSON.stringify(staleReportRefs)}` + staleReportRefs.firstBattleReport.mvp === undefined && + JSON.stringify(staleReportRefs.firstBattleReport.sortiePerformance?.selectedUnitIds) === JSON.stringify(['liu-bei']) && + JSON.stringify(staleReportRefs.firstBattleReport.sortiePerformance?.formationAssignments) === JSON.stringify({ 'liu-bei': 'front' }) && + staleReportRefs.firstBattleReport.sortiePerformance?.unitStats.length === 1 && + staleReportRefs.firstBattleReport.sortiePerformance.unitStats[0].unitId === 'liu-bei' && + staleReportRefs.firstBattleReport.sortiePerformance.unitStats[0].damageDealt === 31, + `Expected first battle report bonds, MVP, and sortie performance references to be filtered against the recovered roster: ${JSON.stringify(staleReportRefs)}` ); storage.clear(); @@ -956,6 +1113,25 @@ try { })) ], mvp: { unitId: 'liu-bei', name: 'Liu Bei', damageDealt: '1000000', defeats: '2' }, + sortiePerformance: { + selectedUnitIds: [' liu-bei ', 'ghost-unit', 'liu-bei', '', 9], + formationAssignments: { ' liu-bei ': 'front', 'ghost-unit': 'support', broken: 'center' }, + unitStats: [ + sortieStatsFixture(' liu-bei ', { + hpBefore: '40', + maxHpBefore: '30', + damageDealt: '1000000', + damageTaken: '-2', + defeats: '3.8', + actions: '4', + support: '5' + }), + sortieStatsFixture('liu-bei', { damageDealt: 1 }), + sortieStatsFixture('ghost-unit', { damageDealt: 88 }), + 'broken-performance' + ], + sortieItemAssignments: { 'liu-bei': { bean: 2 } } + }, itemRewards: ['Bean', 'Bean', 'Bean -2', 'Bean +0', 10, 'x'.repeat(97)], campaignRewards: { supplies: [' Bean ', 'Bean', 'Bean -2', 'Bean +0', 20, 'x'.repeat(97)], @@ -1019,6 +1195,22 @@ try { malformedReport.firstBattleReport.completedCampVisits.length === 0, `Expected malformed report fields to be normalized without discarding the report: ${JSON.stringify(malformedReport)}` ); + const malformedReportPerformance = malformedReport.firstBattleReport?.sortiePerformance; + assert( + JSON.stringify(malformedReportPerformance?.selectedUnitIds) === JSON.stringify(['liu-bei']) && + JSON.stringify(malformedReportPerformance?.formationAssignments) === JSON.stringify({ 'liu-bei': 'front' }) && + malformedReportPerformance?.unitStats.length === 1 && + malformedReportPerformance.unitStats[0].unitId === 'liu-bei' && + malformedReportPerformance.unitStats[0].hpBefore === 40 && + malformedReportPerformance.unitStats[0].maxHpBefore === 40 && + malformedReportPerformance.unitStats[0].damageDealt === 999999 && + malformedReportPerformance.unitStats[0].damageTaken === 0 && + malformedReportPerformance.unitStats[0].defeats === 3 && + malformedReportPerformance.unitStats[0].actions === 4 && + malformedReportPerformance.unitStats[0].support === 5 && + !Object.prototype.hasOwnProperty.call(malformedReportPerformance, 'sortieItemAssignments'), + `Expected malformed report sortie performance to normalize numbers, dedupe entries, filter stale roster ids, and exclude supplies: ${JSON.stringify(malformedReportPerformance)}` + ); storage.clear(); storage.set( @@ -1134,6 +1326,25 @@ try { battleExp: 1 })) ], + sortiePerformance: { + selectedUnitIds: ['liu-bei', 'ghost-unit', 'liu-bei', ''], + formationAssignments: { 'liu-bei': 'front', 'ghost-unit': 'reserve', broken: 'center' }, + unitStats: [ + sortieStatsFixture('liu-bei', { + hpBefore: '26', + maxHpBefore: '20', + damageDealt: '1000000', + damageTaken: '-3', + defeats: '2.9', + actions: '6', + support: '4' + }), + sortieStatsFixture('liu-bei', { damageDealt: 1 }), + sortieStatsFixture('ghost-unit', { damageDealt: 77 }), + 11 + ], + itemAssignments: { 'liu-bei': { bean: 2 } } + }, reserveTraining: [ { unitId: 'guan-yu', @@ -1233,6 +1444,22 @@ try { malformedHistory.battleHistory['first-battle-zhuo-commandery']?.reserveTraining?.[0].focusId === 'balanced', `Expected nested battle history progress arrays to filter invalid entries and normalize numbers: ${JSON.stringify(malformedHistory)}` ); + const malformedHistoryPerformance = malformedHistory.battleHistory['first-battle-zhuo-commandery']?.sortiePerformance; + assert( + JSON.stringify(malformedHistoryPerformance?.selectedUnitIds) === JSON.stringify(['liu-bei']) && + JSON.stringify(malformedHistoryPerformance?.formationAssignments) === JSON.stringify({ 'liu-bei': 'front' }) && + malformedHistoryPerformance?.unitStats.length === 1 && + malformedHistoryPerformance.unitStats[0].unitId === 'liu-bei' && + malformedHistoryPerformance.unitStats[0].hpBefore === 26 && + malformedHistoryPerformance.unitStats[0].maxHpBefore === 26 && + malformedHistoryPerformance.unitStats[0].damageDealt === 999999 && + malformedHistoryPerformance.unitStats[0].damageTaken === 0 && + malformedHistoryPerformance.unitStats[0].defeats === 2 && + malformedHistoryPerformance.unitStats[0].actions === 6 && + malformedHistoryPerformance.unitStats[0].support === 4 && + !Object.prototype.hasOwnProperty.call(malformedHistoryPerformance, 'itemAssignments'), + `Expected malformed historical sortie performance to normalize and filter roster references without persisting supplies: ${JSON.stringify(malformedHistoryPerformance)}` + ); storage.clear(); storage.set(campaignStorageKey, JSON.stringify({ version: 99, step: 'sixty-sixth-camp' })); @@ -1317,7 +1544,7 @@ try { `Expected explicitly loaded corrupted slot timestamp to be normalized: ${JSON.stringify(corruptedSlot)}` ); - console.log('Verified campaign save normalization, reward resettlement, camp reward idempotence, malformed-field/roster-equipment/bond-growth/bond-roster/report-reference/sortie-roster/sortie-preset/latest-history/detail recovery, timestamp-safe history/slot recovery, unknown-step/report/history recovery, unsupported-version rejection, and slotted fallback.'); + console.log('Verified campaign save normalization, reward resettlement, camp reward idempotence, malformed-field/roster-equipment/bond-growth/bond-roster/report-reference/sortie-roster/sortie-preset/sortie-performance/latest-history/detail recovery, sortie performance legacy/deep-clone safety, timestamp-safe history/slot recovery, unknown-step/report/history recovery, unsupported-version rejection, and slotted fallback.'); } finally { await server.close(); } diff --git a/scripts/verify-release-candidate.mjs b/scripts/verify-release-candidate.mjs index 939fc3e..e2d07a2 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -106,6 +106,59 @@ try { ); assert(!resultProbe.resultTexts.some((text) => text.includes('미달')), `Expected result screen to avoid harsh optional-goal wording: ${JSON.stringify(resultProbe.resultTexts)}`); + const firstResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const firstResultSave = await readCampaignSave(page); + const firstResultPerformance = firstResultState?.sortieEvaluation?.snapshot; + assert( + firstResultState?.sortieEvaluation?.available === true && + firstResultState.sortieEvaluation.open === false && + ['S', 'A', 'B', 'C', 'D'].includes(firstResultState.sortieEvaluation.grade) && + Number.isFinite(firstResultState.sortieEvaluation.score), + `Expected the first result to expose a closed formation evaluation with a grade: ${JSON.stringify(firstResultState?.sortieEvaluation)}` + ); + assert( + firstResultPerformance?.selectedUnitIds?.length === firstResultState?.deployedAllyIds?.length && + firstResultPerformance.selectedUnitIds.every((unitId) => firstResultState.deployedAllyIds.includes(unitId)) && + firstResultPerformance.selectedUnitIds.every((unitId) => ['front', 'flank', 'support', 'reserve'].includes(firstResultPerformance.formationAssignments?.[unitId])) && + firstResultPerformance.unitStats?.length === firstResultPerformance.selectedUnitIds.length && + !Object.prototype.hasOwnProperty.call(firstResultPerformance, 'sortieItemAssignments') && + !Object.prototype.hasOwnProperty.call(firstResultPerformance, 'itemAssignments'), + `Expected the first result snapshot to materialize every deployed officer and role without supplies: ${JSON.stringify(firstResultPerformance)}` + ); + assert( + sameJsonValue(firstResultSave.current?.firstBattleReport?.sortiePerformance, firstResultPerformance) && + sameJsonValue(firstResultSave.slot1?.firstBattleReport?.sortiePerformance, firstResultPerformance) && + sameJsonValue(firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortiePerformance, firstResultPerformance) && + sameJsonValue(firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortiePerformance, firstResultPerformance), + `Expected the first formation result snapshot to synchronize across report, history, current, and slot-1 saves: ${JSON.stringify(firstResultSave)}` + ); + + const firstEvaluationTogglePoint = await readBattleResultEvaluationControlPoint(page, 'toggle'); + await page.mouse.click(firstEvaluationTogglePoint.x, firstEvaluationTogglePoint.y); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === true, undefined, { timeout: 30000 }); + const firstEvaluationProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const state = window.__HEROS_DEBUG__?.battle(); + return { + evaluation: state?.sortieEvaluation, + texts: (scene?.resultFormationReviewObjects ?? []) + .filter((object) => object?.type === 'Text') + .map((object) => object.text) + }; + }); + assert( + firstEvaluationProbe.texts.includes('이번 편성 평가') && + firstEvaluationProbe.texts.some((text) => text.includes('명단·역할만 저장') && text.includes('장비·보급 제외')) && + firstEvaluationProbe.evaluation?.presetCards?.length === 3 && + firstEvaluationProbe.evaluation.presetCards.every((card) => card.saved === false && card.matching === false && card.actionButtonBounds), + `Expected the visible first-battle evaluation to explain its scope and show three empty preset actions: ${JSON.stringify(firstEvaluationProbe)}` + ); + await page.screenshot({ path: `${screenshotDir}/rc-first-battle-formation-evaluation.png`, fullPage: true }); + await assertCanvasPainted(page, 'first battle formation evaluation'); + const firstEvaluationClosePoint = await readBattleResultEvaluationControlPoint(page, 'close'); + await page.mouse.click(firstEvaluationClosePoint.x, firstEvaluationClosePoint.y); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === false, undefined, { timeout: 30000 }); + await page.mouse.click(738, 642); await waitForCampAfterBattleResult(page); await page.screenshot({ path: `${screenshotDir}/rc-first-camp.png`, fullPage: true }); @@ -1111,6 +1164,205 @@ try { `Expected a non-Peach-Garden officer to receive a generic sortie role effect: ${JSON.stringify(midNextBattleProbe.units)}` ); + const midResultFixture = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const state = window.__HEROS_DEBUG__?.battle(); + const contributor = state?.units?.find((unit) => + unit.faction === 'ally' && ['front', 'flank', 'support'].includes(unit.formationRole) + ); + const wounded = state?.units?.find((unit) => + unit.faction === 'ally' && unit.id !== contributor?.id && unit.hp > 1 + ) ?? contributor; + const liveWounded = wounded?.id ? scene?.debugUnitById?.(wounded.id) : undefined; + if (!scene || !contributor || !liveWounded || typeof scene.statsFor !== 'function') { + return { ready: false, contributorId: contributor?.id ?? null, woundedUnitId: wounded?.id ?? null }; + } + + const stats = scene.statsFor(contributor.id); + Object.assign(stats, { + damageDealt: 137, + damageTaken: 22, + defeats: 3, + actions: 5, + support: 11, + sortieBonusDamage: 17, + sortiePreventedDamage: 9, + sortieBonusHealing: 4, + sortieExtendedBondTriggers: 2, + intentDefeats: 1, + intentLineBreaks: 1, + intentGuardSuccesses: 1 + }); + liveWounded.hp = Math.max(1, liveWounded.maxHp - 7); + return { + ready: true, + contributorId: contributor.id, + woundedUnitId: liveWounded.id, + woundedHp: liveWounded.hp, + woundedMaxHp: liveWounded.maxHp, + expectedStats: { + damageDealt: 137, + damageTaken: 22, + defeats: 3, + actions: 5, + support: 11, + sortieBonusDamage: 17, + sortiePreventedDamage: 9, + sortieBonusHealing: 4, + sortieExtendedBondTriggers: 2, + intentDefeats: 1, + intentLineBreaks: 1, + intentGuardSuccesses: 1 + } + }; + }); + assert( + midResultFixture.ready === true && + midResultFixture.contributorId && + midResultFixture.woundedUnitId && + midResultFixture.woundedHp < midResultFixture.woundedMaxHp, + `Expected a deterministic contribution and recovery fixture for the mid-campaign result: ${JSON.stringify(midResultFixture)}` + ); + + await page.evaluate(() => window.__HEROS_DEBUG__?.forceBattleOutcome('victory')); + await waitForBattleOutcome(page, 'victory'); + const midResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const midResultSaveBeforeUpdate = await readCampaignSave(page); + const midResultPerformance = midResultState?.sortieEvaluation?.snapshot; + const midContributorPerformance = midResultPerformance?.unitStats?.find((stats) => stats.unitId === midResultFixture.contributorId); + const midBattleRoleById = Object.fromEntries( + (midNextBattleProbe?.deployedAllyPositions ?? []).map((unit) => [unit.id, unit.formationRole]) + ); + const expectedMidResultPreset = { + selectedUnitIds: [...(midResultPerformance?.selectedUnitIds ?? [])], + formationAssignments: { ...(midResultPerformance?.formationAssignments ?? {}) } + }; + assert( + midResultState?.sortieEvaluation?.available === true && + midResultState.sortieEvaluation.open === false && + midResultState.sortieEvaluation.totalDamage === midResultFixture.expectedStats.damageDealt && + midResultState.sortieEvaluation.totalDamageTaken === midResultFixture.expectedStats.damageTaken && + 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)}` + ); + assert( + midContributorPerformance && + Object.entries(midResultFixture.expectedStats).every(([key, value]) => midContributorPerformance[key] === value) && + midContributorPerformance.hpBefore > 0 && + midContributorPerformance.maxHpBefore >= midContributorPerformance.hpBefore && + sameJsonValue([...(midResultPerformance?.selectedUnitIds ?? [])].sort(), [...midCampSelectedSortieUnitIds].sort()) && + Object.keys(midResultPerformance?.formationAssignments ?? {}).length === midCampSelectedSortieUnitIds.length && + midCampSelectedSortieUnitIds.every( + (unitId) => midResultPerformance?.formationAssignments?.[unitId] === midBattleRoleById[unitId] + ), + `Expected the saved result snapshot to retain the exact roster, roles, starting HP, and contribution stats: ${JSON.stringify(midResultPerformance)}` + ); + assert( + sameJsonValue(midResultSaveBeforeUpdate.current?.firstBattleReport?.sortiePerformance, midResultPerformance) && + sameJsonValue(midResultSaveBeforeUpdate.slot1?.firstBattleReport?.sortiePerformance, midResultPerformance) && + sameJsonValue(midResultSaveBeforeUpdate.current?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) && + sameJsonValue(midResultSaveBeforeUpdate.slot1?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) && + !sameJsonValue(midResultSaveBeforeUpdate.current?.sortieFormationPresets?.mobile, expectedMidResultPreset), + `Expected the mid result snapshot to synchronize everywhere while remaining meaningfully different from the saved mobile preset: ${JSON.stringify({ + snapshot: midResultPerformance, + mobile: midResultSaveBeforeUpdate.current?.sortieFormationPresets?.mobile + })}` + ); + + const midEvaluationTogglePoint = await readBattleResultEvaluationControlPoint(page, 'toggle'); + await page.mouse.click(midEvaluationTogglePoint.x, midEvaluationTogglePoint.y); + await page.waitForFunction(() => window.__HEROS_DEBUG__?.battle()?.sortieEvaluation?.open === true, undefined, { timeout: 30000 }); + const midEvaluationProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const evaluation = window.__HEROS_DEBUG__?.battle()?.sortieEvaluation; + return { + evaluation, + texts: (scene?.resultFormationReviewObjects ?? []) + .filter((object) => object?.type === 'Text') + .map((object) => object.text) + }; + }); + const midEvaluationSave = await readCampaignSave(page); + const mobileResultCard = midEvaluationProbe.evaluation?.presetCards?.find((card) => card.id === 'mobile'); + assert( + midEvaluationProbe.texts.includes('이번 편성 평가') && + midEvaluationProbe.texts.some((text) => text.includes('기동') && text.includes('갱신')) && + mobileResultCard?.saved === true && + mobileResultCard.matching === false && + mobileResultCard.actionButtonBounds && + sameJsonValue(midEvaluationSave, midResultSaveBeforeUpdate), + `Expected opening the evaluation to expose a non-destructive mobile preset update action: ${JSON.stringify(midEvaluationProbe)}` + ); + + const firstMobileUpdatePoint = await readBattleResultEvaluationControlPoint(page, 'preset', 'mobile'); + await page.mouse.click(firstMobileUpdatePoint.x, firstMobileUpdatePoint.y); + await page.waitForFunction(() => { + const evaluation = window.__HEROS_DEBUG__?.battle()?.sortieEvaluation; + return evaluation?.open === true && + evaluation?.overwriteConfirmId === 'mobile' && + evaluation?.feedback?.includes('한 번 더'); + }, undefined, { timeout: 30000 }); + const pendingMobileUpdateState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const pendingMobileUpdateSave = await readCampaignSave(page); + assert( + pendingMobileUpdateState?.resultVisible === true && + pendingMobileUpdateState?.sortieEvaluation?.sourcePresetIds?.includes('mobile') === false && + sameJsonValue(pendingMobileUpdateSave, midResultSaveBeforeUpdate), + `Expected the first mobile update click to request confirmation without mutating either save: ${JSON.stringify({ + evaluation: pendingMobileUpdateState?.sortieEvaluation, + save: pendingMobileUpdateSave + })}` + ); + + const confirmMobileUpdatePoint = await readBattleResultEvaluationControlPoint(page, 'preset', 'mobile'); + await page.mouse.click(confirmMobileUpdatePoint.x, confirmMobileUpdatePoint.y); + await page.waitForFunction(() => { + const evaluation = window.__HEROS_DEBUG__?.battle()?.sortieEvaluation; + return evaluation?.open === true && + evaluation?.overwriteConfirmId === null && + evaluation?.sourcePresetIds?.includes('mobile') && + evaluation?.feedback?.includes('갱신 완료'); + }, undefined, { timeout: 30000 }); + const updatedMobileResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); + const updatedMobileResultSave = await readCampaignSave(page); + const updatedCurrentMobilePreset = updatedMobileResultSave.current?.sortieFormationPresets?.mobile; + const updatedSlotMobilePreset = updatedMobileResultSave.slot1?.sortieFormationPresets?.mobile; + assert( + sameJsonValue(updatedCurrentMobilePreset, expectedMidResultPreset) && + sameJsonValue(updatedSlotMobilePreset, expectedMidResultPreset) && + JSON.stringify(Object.keys(updatedCurrentMobilePreset ?? {}).sort()) === JSON.stringify(['formationAssignments', 'selectedUnitIds']) && + updatedCurrentMobilePreset?.sortieItemAssignments === undefined && + updatedCurrentMobilePreset?.itemAssignments === undefined && + updatedCurrentMobilePreset?.unitStats === undefined, + `Expected the confirmed result action to store only the evaluated roster and roles in current and slot-1 mobile presets: ${JSON.stringify(updatedMobileResultSave)}` + ); + assert( + JSON.stringify(updatedMobileResultSave.current?.selectedSortieUnitIds) === JSON.stringify(midResultSaveBeforeUpdate.current?.selectedSortieUnitIds) && + sameJsonValue(updatedMobileResultSave.current?.sortieFormationAssignments, midResultSaveBeforeUpdate.current?.sortieFormationAssignments) && + sameJsonValue(updatedMobileResultSave.current?.sortieItemAssignments, midResultSaveBeforeUpdate.current?.sortieItemAssignments) && + sameJsonValue(updatedMobileResultSave.slot1?.selectedSortieUnitIds, midResultSaveBeforeUpdate.slot1?.selectedSortieUnitIds) && + sameJsonValue(updatedMobileResultSave.slot1?.sortieFormationAssignments, midResultSaveBeforeUpdate.slot1?.sortieFormationAssignments) && + sameJsonValue(updatedMobileResultSave.slot1?.sortieItemAssignments, midResultSaveBeforeUpdate.slot1?.sortieItemAssignments) && + sameJsonValue(updatedMobileResultSave.current?.firstBattleReport?.sortiePerformance, midResultPerformance) && + sameJsonValue(updatedMobileResultSave.slot1?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) && + sameJsonValue(updatedMobileResultSave.current?.sortieFormationPresets?.elite, midResultSaveBeforeUpdate.current?.sortieFormationPresets?.elite) && + sameJsonValue(updatedMobileResultSave.current?.sortieFormationPresets?.siege, midResultSaveBeforeUpdate.current?.sortieFormationPresets?.siege), + `Expected result-to-preset update to leave the active sortie, supplies, report snapshot, and other presets unchanged: ${JSON.stringify({ + before: midResultSaveBeforeUpdate, + after: updatedMobileResultSave + })}` + ); + assert( + updatedMobileResultState?.sortieEvaluation?.presetCards?.find((card) => card.id === 'mobile')?.matching === true && + updatedMobileResultState?.sortieEvaluation?.feedback?.includes('장비·보급은 저장하지 않았습니다.'), + `Expected visible completion feedback and a matching mobile result card after update: ${JSON.stringify(updatedMobileResultState?.sortieEvaluation)}` + ); + await page.screenshot({ path: `${screenshotDir}/rc-mid-camp-formation-evaluation-updated.png`, fullPage: true }); + await assertCanvasPainted(page, 'mid camp formation evaluation update'); + await seedCampaignSave(page, { battleId: 'sixty-sixth-battle-wuzhang-final', battleTitle: '오장원 최종전', @@ -1430,6 +1682,47 @@ async function readCampaignSave(page) { }); } +async function readBattleResultEvaluationControlPoint(page, control, presetId) { + const probe = await page.evaluate(({ control, presetId }) => { + const scene = window.__HEROS_GAME__?.scene.getScene('BattleScene'); + const evaluation = window.__HEROS_DEBUG__?.battle()?.sortieEvaluation; + const canvas = document.querySelector('canvas'); + const presetCard = evaluation?.presetCards?.find((card) => card.id === presetId); + const bounds = control === 'toggle' + ? evaluation?.toggleButtonBounds + : control === 'close' + ? evaluation?.closeButtonBounds + : presetCard?.actionButtonBounds; + if (!scene || !canvas || !bounds) { + return { + ready: false, + control, + presetId: presetId ?? null, + evaluationOpen: evaluation?.open ?? false, + bounds: bounds ?? null + }; + } + + const canvasBounds = canvas.getBoundingClientRect(); + const centerX = bounds.x + bounds.width / 2; + const centerY = bounds.y + bounds.height / 2; + return { + ready: true, + control, + presetId: presetId ?? null, + evaluationOpen: evaluation?.open ?? false, + bounds, + x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height + }; + }, { control, presetId }); + assert( + probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), + `Expected a visible Phaser battle-result evaluation ${control} control with a canvas-scaled click point: ${JSON.stringify(probe)}` + ); + return probe; +} + async function readSortieComparisonActionPoint(page) { const probe = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); @@ -1567,6 +1860,7 @@ async function seedCampaignSave(page, options) { completedCampVisits: current.completedCampVisits ?? [], createdAt: now }; + delete report.sortiePerformance; const settlement = { battleId: report.battleId, battleTitle: report.battleTitle, diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index 0d34039..00be8f8 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -50,6 +50,7 @@ import { sortieRoleEffectSummaries } from '../data/sortieSynergy'; import { + campaignSortiePresetIds, campaignSaveSlotCount, getCampaignState, listCampaignSaveSlots, @@ -62,6 +63,8 @@ import { type CampaignRewardSnapshot, type CampaignState, type CampaignSortieItemAssignments, + type CampaignSortiePerformanceSnapshot, + type CampaignSortiePresetId, type CampaignStep } from '../state/campaignState'; import { @@ -1178,6 +1181,15 @@ const sortieRoleEffects: Partial = { normal: '보통', fast: '빠름', @@ -1265,6 +1277,49 @@ type UnitBattleStats = { intentGuardSuccesses: number; }; +type ResultFormationGrade = 'S' | 'A' | 'B' | 'C' | 'D'; + +type ResultSortieContributionTotals = { + bonusDamage: number; + preventedDamage: number; + bonusHealing: number; + extendedBondTriggers: number; + counterplays: number; +}; + +type ResultFormationReview = { + score: number; + grade: ResultFormationGrade; + gradeColor: number; + gradeTextColor: string; + headline: string; + strengths: string[]; + improvement: string; + objectiveAchieved: number; + objectiveTotal: number; + survivorCount: number; + deployedCount: number; + recoveryNeededCount: number; + totalDamage: number; + totalDamageTaken: number; + totalDefeats: number; + totalSupport: number; + roleCoverage: number; + activeBondCount: number; + contributionTotals: ResultSortieContributionTotals; + snapshot: CampaignSortiePerformanceSnapshot; +}; + +type ResultFormationReviewView = { + closeButton: Phaser.GameObjects.Rectangle; + presetButtons: Partial>; +}; + +type ResultButtonView = { + background: Phaser.GameObjects.Rectangle; + label: Phaser.GameObjects.Text; +}; + type ThreatTile = { x: number; y: number; @@ -3195,6 +3250,12 @@ export class BattleScene extends Phaser.Scene { private resultGaugeAnimations: ResultGaugeAnimation[] = []; private resultAnimationToken = 0; private resultSettlementText?: Phaser.GameObjects.Text; + private resultFormationReviewObjects: Phaser.GameObjects.GameObject[] = []; + private resultFormationReviewVisible = false; + private resultFormationReviewFeedback = ''; + private resultPresetOverwriteConfirmId?: CampaignSortiePresetId; + private resultFormationReviewButton?: ResultButtonView; + private resultFormationReviewView?: ResultFormationReviewView; private saveSlotPanelObjects: Phaser.GameObjects.GameObject[] = []; private saveSlotPanelMode?: SaveSlotMode; private unitViews = new Map(); @@ -3251,6 +3312,9 @@ export class BattleScene extends Phaser.Scene { create() { this.hideBattleResult(); + this.resultFormationReviewVisible = false; + this.resultFormationReviewFeedback = ''; + this.resultPresetOverwriteConfirmId = undefined; const campaign = getCampaignState(); this.resetBattleData(campaign); if (campaign.step === 'new' || campaign.step === 'prologue' || campaign.step === 'first-battle') { @@ -3316,6 +3380,12 @@ export class BattleScene extends Phaser.Scene { this.confirmLockedTargetPreview(); }); this.input.keyboard?.on('keydown-ESC', () => { + if (this.resultFormationReviewVisible) { + soundDirector.playSelect(); + this.hideResultFormationReview(); + return; + } + if (this.saveSlotPanelObjects.length > 0) { soundDirector.playSelect(); this.hideSaveSlotPanel(); @@ -10362,6 +10432,19 @@ export class BattleScene extends Phaser.Scene { })); this.resultSettlementText.setDepth(depth + 2); + const sortiePerformance = this.resultSortiePerformanceSnapshot(); + if (sortiePerformance) { + const formationReview = this.resultFormationReview(outcome, sortiePerformance); + this.resultFormationReviewButton = this.addResultButton( + `편성 평가 ${formationReview.grade}`, + left + 470, + top + 612, + 136, + () => this.toggleResultFormationReview(outcome), + depth + 3 + ); + } + if (outcome === 'victory') { const isFinalBattle = battleScenario.id === 'sixty-sixth-battle-wuzhang-final'; this.addResultButton(isFinalBattle ? '에필로그로' : '군영으로', left + panelWidth - 422, top + 612, 132, () => { @@ -10398,6 +10481,13 @@ export class BattleScene extends Phaser.Scene { private hideBattleResult() { this.resultAnimationToken += 1; + this.resultFormationReviewObjects.forEach((object) => object.destroy()); + this.resultFormationReviewObjects = []; + this.resultFormationReviewVisible = false; + this.resultFormationReviewFeedback = ''; + this.resultPresetOverwriteConfirmId = undefined; + this.resultFormationReviewButton = undefined; + this.resultFormationReviewView = undefined; this.resultObjects.forEach((object) => object.destroy()); this.resultObjects = []; this.resultGaugeAnimations = []; @@ -10547,6 +10637,46 @@ export class BattleScene extends Phaser.Scene { .sort((a, b) => b.score - a.score || b.stats.defeats - a.stats.defeats || b.stats.damageDealt - a.stats.damageDealt)[0]; } + private resultSortiePerformanceSnapshot(): CampaignSortiePerformanceSnapshot | undefined { + if (!battleScenario.sortie) { + return undefined; + } + + const campaign = getCampaignState(); + const alliedUnits = battleUnits.filter((unit) => unit.faction === 'ally'); + const alliedIds = new Set(alliedUnits.map((unit) => unit.id)); + const configuredIds = this.effectiveSortieUnitIds(campaign).filter((unitId) => alliedIds.has(unitId)); + const selectedUnitIds = [...new Set([...configuredIds, ...alliedUnits.map((unit) => unit.id)])]; + if (selectedUnitIds.length === 0) { + return undefined; + } + + const unitById = new Map(alliedUnits.map((unit) => [unit.id, unit])); + const initialUnitById = new Map(initialBattleUnits.filter((unit) => unit.faction === 'ally').map((unit) => [unit.id, unit])); + const formationAssignments = selectedUnitIds.reduce((assignments, unitId) => { + const unit = unitById.get(unitId); + if (unit) { + assignments[unitId] = this.sortieRoleForUnit(unit) ?? this.defaultSortieFormationRole(unit); + } + return assignments; + }, {}); + return { + selectedUnitIds, + formationAssignments, + unitStats: selectedUnitIds.map((unitId) => { + const stats = this.statsFor(unitId); + const unit = unitById.get(unitId); + const initialUnit = initialUnitById.get(unitId) ?? unit; + return { + unitId, + hpBefore: initialUnit?.hp ?? 0, + maxHpBefore: initialUnit?.maxHp ?? 0, + ...stats + }; + }) + }; + } + private publishFirstBattleReport(outcome: BattleOutcome) { const objectives = this.resultObjectives(outcome); const mvp = this.resultMvp(); @@ -10578,6 +10708,7 @@ export class BattleScene extends Phaser.Scene { : undefined, itemRewards: this.resultItemRewards(outcome), campaignRewards: rewardSnapshot, + sortiePerformance: this.resultSortiePerformanceSnapshot(), completedCampDialogues: [], completedCampVisits: [], createdAt: new Date().toISOString() @@ -10643,6 +10774,417 @@ export class BattleScene extends Phaser.Scene { }; } + private resultFormationReview( + outcome: BattleOutcome, + snapshot: CampaignSortiePerformanceSnapshot + ): ResultFormationReview { + const unitsById = new Map(battleUnits.filter((unit) => unit.faction === 'ally').map((unit) => [unit.id, unit])); + const deployedUnits = snapshot.selectedUnitIds + .map((unitId) => unitsById.get(unitId)) + .filter((unit): unit is UnitData => Boolean(unit)); + const objectives = this.resultObjectives(outcome); + const objectiveAchieved = objectives.filter((objective) => objective.achieved).length; + const survivorCount = deployedUnits.filter((unit) => unit.hp > 0).length; + const recoveryNeededCount = deployedUnits.filter((unit) => unit.hp > 0 && unit.hp < unit.maxHp).length; + const totalDamage = snapshot.unitStats.reduce((sum, stats) => sum + stats.damageDealt, 0); + const totalDamageTaken = snapshot.unitStats.reduce((sum, stats) => sum + stats.damageTaken, 0); + const totalDefeats = snapshot.unitStats.reduce((sum, stats) => sum + stats.defeats, 0); + const totalSupport = snapshot.unitStats.reduce((sum, stats) => sum + stats.support, 0); + const roleCoverage = coreSortieSynergyRoles.filter((role) => + snapshot.selectedUnitIds.some((unitId) => snapshot.formationAssignments[unitId] === role) + ).length; + const activeBondCount = this.activeSortieBonds().length; + const contributionTotals = this.sortieContributionTotals(); + const objectiveRate = objectives.length > 0 ? objectiveAchieved / objectives.length : 1; + const survivalRate = deployedUnits.length > 0 ? survivorCount / deployedUnits.length : 0; + const enemyCount = battleUnits.filter((unit) => unit.faction === 'enemy').length; + const defeatRate = enemyCount > 0 ? Math.min(1, totalDefeats / enemyCount) : 1; + const contributionScore = contributionTotals.bonusDamage + contributionTotals.preventedDamage + + contributionTotals.bonusHealing + contributionTotals.extendedBondTriggers * 4 + contributionTotals.counterplays * 3; + const score = Phaser.Math.Clamp(Math.round( + (outcome === 'victory' ? 35 : 10) + + objectiveRate * 25 + + survivalRate * 20 + + defeatRate * 10 + + (roleCoverage / coreSortieSynergyRoles.length) * 5 + + Math.min(5, activeBondCount * 2) + + Math.min(5, contributionScore / 12) + ), 0, 100); + const grade: ResultFormationGrade = score >= 90 ? 'S' : score >= 75 ? 'A' : score >= 60 ? 'B' : score >= 45 ? 'C' : 'D'; + const gradeTone: Record = { + S: { color: 0xd8b15f, text: '#fff0b5' }, + A: { color: 0x59d18c, text: '#caffdc' }, + B: { color: 0x72a7d8, text: '#d8ecff' }, + C: { color: 0xc87552, text: '#ffd0bd' }, + D: { color: 0x9a5b55, text: '#ffb6a6' } + }; + const headlines: Record = { + S: '빈틈없이 목표를 완수한 모범 편성', + A: '안정적으로 전선을 지켜낸 우수 편성', + B: '강점이 분명하고 보완 여지가 있는 편성', + C: '역할과 생존 계획을 다시 다듬을 편성', + D: '재도전 전 전면적인 재편이 필요한 편성' + }; + const strengths = [ + objectiveAchieved === objectives.length && objectives.length > 0 ? `목표 ${objectiveAchieved}/${objectives.length} 완수` : '', + survivorCount === deployedUnits.length && deployedUnits.length > 0 ? '출전 전원 생존' : '', + roleCoverage === coreSortieSynergyRoles.length ? '핵심 역할 3/3 충족' : '', + activeBondCount > 0 ? `활성 공명 ${activeBondCount}개` : '', + contributionScore > 0 ? `편성 효과 기여 ${contributionScore}` : '', + totalDefeats > 0 ? `적 ${totalDefeats}명 격파` : '' + ].filter(Boolean).slice(0, 3); + const missingRoleLabels = coreSortieSynergyRoles + .filter((role) => !snapshot.selectedUnitIds.some((unitId) => snapshot.formationAssignments[unitId] === role)) + .map((role) => sortieRoleEffects[role]?.label ?? role); + const improvement = outcome === 'defeat' + ? '패배 조건을 먼저 차단하고 필수 무장의 생존 동선을 확보하십시오.' + : survivorCount < deployedUnits.length + ? `퇴각 ${deployedUnits.length - survivorCount}명 · 전열 방호와 보급 배정을 강화하십시오.` + : missingRoleLabels.length > 0 + ? `${missingRoleLabels.join('·')} 공백 · 역할을 다시 배정하십시오.` + : objectiveAchieved < objectives.length + ? `미달 목표 ${objectives.length - objectiveAchieved}개 · 기동 담당과 동선을 조정하십시오.` + : recoveryNeededCount > 0 && totalSupport === 0 + ? `회복 필요 ${recoveryNeededCount}명 · 후원 담당의 회복·보급 사용을 늘리십시오.` + : '현재 명단과 역할은 편성책에 기록해 다시 활용할 가치가 있습니다.'; + + return { + score, + grade, + gradeColor: gradeTone[grade].color, + gradeTextColor: gradeTone[grade].text, + headline: headlines[grade], + strengths: strengths.length > 0 ? strengths : ['전투 기록 확보'], + improvement, + objectiveAchieved, + objectiveTotal: objectives.length, + survivorCount, + deployedCount: deployedUnits.length, + recoveryNeededCount, + totalDamage, + totalDamageTaken, + totalDefeats, + totalSupport, + roleCoverage, + activeBondCount, + contributionTotals, + snapshot + }; + } + + private resultFormationRoleContributionLine(review: ResultFormationReview) { + const statsById = new Map(review.snapshot.unitStats.map((stats) => [stats.unitId, stats])); + return coreSortieSynergyRoles.map((role) => { + const roleStats = review.snapshot.selectedUnitIds + .filter((unitId) => review.snapshot.formationAssignments[unitId] === role) + .map((unitId) => statsById.get(unitId)) + .filter((stats): stats is NonNullable => Boolean(stats)); + const value = role === 'front' + ? roleStats.reduce((sum, stats) => sum + stats.sortiePreventedDamage + stats.sortieBonusDamage, 0) + : role === 'flank' + ? roleStats.reduce((sum, stats) => sum + stats.sortieBonusDamage, 0) + : roleStats.reduce((sum, stats) => sum + stats.sortieBonusDamage + stats.sortieBonusHealing, 0); + const label = role === 'front' ? '전열' : role === 'flank' ? '돌파' : '후원'; + return `${label} ${value}`; + }).join(' · '); + } + + private resultFormationSnapshotMatchesPreset(presetId: CampaignSortiePresetId, snapshot: CampaignSortiePerformanceSnapshot) { + const preset = getCampaignState().sortieFormationPresets[presetId]; + return Boolean( + preset && + JSON.stringify(preset.selectedUnitIds) === JSON.stringify(snapshot.selectedUnitIds) && + snapshot.selectedUnitIds.every((unitId) => preset.formationAssignments[unitId] === snapshot.formationAssignments[unitId]) + ); + } + + private resultFormationSourcePresetIds(snapshot: CampaignSortiePerformanceSnapshot) { + return campaignSortiePresetIds.filter((presetId) => this.resultFormationSnapshotMatchesPreset(presetId, snapshot)); + } + + private toggleResultFormationReview(outcome: BattleOutcome) { + if (this.resultFormationReviewVisible) { + this.hideResultFormationReview(); + return; + } + this.showResultFormationReview(outcome); + } + + private showResultFormationReview(outcome: BattleOutcome) { + const snapshot = this.resultSortiePerformanceSnapshot(); + if (!snapshot) { + return; + } + this.resultFormationReviewObjects.forEach((object) => object.destroy()); + this.resultFormationReviewObjects = []; + this.resultFormationReviewView = undefined; + this.resultFormationReviewVisible = true; + this.resultFormationReviewButton?.label.setText('정산 보기'); + + const review = this.resultFormationReview(outcome, snapshot); + const depth = 130; + const width = 980; + const height = 420; + const x = Math.floor((this.scale.width - width) / 2); + const y = 200; + const bg = this.trackResultFormationReview(this.add.rectangle(x, y, width, height, 0x0b1219, 0.995)); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(2, review.gradeColor, 0.9); + bg.setInteractive(); + + this.trackResultFormationReview(this.add.text(x + 18, y + 12, '이번 편성 평가', this.resultFormationTextStyle(20, '#f2e3bf', true))) + .setDepth(depth + 1); + const closeButton = this.addResultFormationReviewButton('정산 보기', x + width - 108, y + 9, 92, 28, true, false, () => this.hideResultFormationReview(), depth + 2); + + const gradeSeal = this.trackResultFormationReview(this.add.circle(x + 58, y + 82, 34, review.gradeColor, 0.22)); + gradeSeal.setDepth(depth + 1); + gradeSeal.setStrokeStyle(2, review.gradeColor, 0.96); + this.trackResultFormationReview(this.add.text(x + 58, y + 82, review.grade, this.resultFormationTextStyle(34, review.gradeTextColor, true))) + .setOrigin(0.5) + .setDepth(depth + 2); + this.trackResultFormationReview(this.add.text(x + 104, y + 49, `${review.headline} · ${review.score}점`, this.resultFormationTextStyle(17, review.gradeTextColor, true))) + .setDepth(depth + 1); + this.trackResultFormationReview(this.add.text(x + 104, y + 75, `강점 · ${review.strengths.join(' · ')}`, this.resultFormationTextStyle(11, '#d4dce6', true))) + .setDepth(depth + 1); + this.trackResultFormationReview(this.add.text(x + 104, y + 96, `보완 · ${this.resultCompactText(review.improvement, 70)}`, this.resultFormationTextStyle(11, '#ffdf7b', true))) + .setDepth(depth + 1); + + const metricY = y + 126; + const metricGap = 10; + const metricWidth = Math.floor((width - 36 - metricGap * 3) / 4); + const metrics = [ + { label: '목표 달성', value: `${review.objectiveAchieved}/${review.objectiveTotal}` }, + { label: '생존·회복', value: `${review.survivorCount}/${review.deployedCount} · 회복 ${review.recoveryNeededCount}` }, + { label: '전과', value: `피해 ${review.totalDamage} · 격파 ${review.totalDefeats}` }, + { label: '지원·피격', value: `지원 ${review.totalSupport} · 피격 ${review.totalDamageTaken}` } + ]; + metrics.forEach((metric, index) => { + this.renderResultFormationMetric(metric.label, metric.value, x + 18 + index * (metricWidth + metricGap), metricY, metricWidth, depth + 1); + }); + + const analysisY = y + 184; + this.renderResultFormationAnalysisPanel( + '전술 판단', + [ + `강점 · ${review.strengths.join(' · ')}`, + `보완 · ${this.resultCompactText(review.improvement, 62)}` + ], + x + 18, + analysisY, + 596, + 72, + review.gradeColor, + depth + 1 + ); + this.renderResultFormationAnalysisPanel( + '역할·공명 기여', + [ + `${this.resultFormationRoleContributionLine(review)} · 역할 ${review.roleCoverage}/3`, + `공명 ${review.activeBondCount}개 · 연장 ${review.contributionTotals.extendedBondTriggers}회 · 파훼 ${review.contributionTotals.counterplays}` + ], + x + 624, + analysisY, + 338, + 72, + palette.blue, + depth + 1 + ); + + 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 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 roleLabel = role === 'front' ? '전' : role === 'flank' ? '돌' : role === 'support' ? '후' : '예'; + 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); + }); + + const presetTitleY = y + 337; + this.trackResultFormationReview(this.add.text(x + 18, presetTitleY, '이번 편성을 편성책에 반영', this.resultFormationTextStyle(13, '#f2e3bf', true))).setDepth(depth + 1); + this.trackResultFormationReview(this.add.text(x + 192, presetTitleY + 2, '명단·역할만 저장 · 장비·보급 제외', this.resultFormationTextStyle(10, '#9fb0bf', true))).setDepth(depth + 1); + const campaign = getCampaignState(); + const sourcePresetIds = this.resultFormationSourcePresetIds(review.snapshot); + const presetButtons: ResultFormationReviewView['presetButtons'] = {}; + const presetGap = 10; + const presetWidth = Math.floor((width - 36 - presetGap * 2) / 3); + resultSortiePresetDefinitions.forEach((definition, index) => { + const preset = campaign.sortieFormationPresets[definition.id]; + const matching = sourcePresetIds.includes(definition.id); + const confirming = this.resultPresetOverwriteConfirmId === definition.id; + const cardX = x + 18 + index * (presetWidth + presetGap); + const label = matching + ? `${definition.label} · 현재와 동일` + : preset + ? `${definition.label} · 저장 ${preset.selectedUnitIds.length}명` + : `${definition.label} · 비어 있음`; + const actionLabel = matching ? '저장됨' : confirming ? '갱신 확정' : preset ? '갱신' : '저장'; + const button = this.addResultFormationReviewButton( + `${label} ${actionLabel}`, + cardX, + presetTitleY + 24, + presetWidth, + 42, + !matching, + confirming || matching, + () => this.requestResultFormationPresetUpdate(definition.id, outcome), + depth + 2, + definition.accent + ); + presetButtons[definition.id] = button; + }); + + const feedback = this.resultFormationReviewFeedback || '평가를 비교한 뒤 저장할 편성책을 선택하십시오.'; + const feedbackColor = this.resultPresetOverwriteConfirmId + ? '#ffdf7b' + : this.resultFormationReviewFeedback + ? '#a8ffd0' + : '#9fb0bf'; + 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 }; + } + + private hideResultFormationReview() { + this.resultFormationReviewObjects.forEach((object) => object.destroy()); + this.resultFormationReviewObjects = []; + this.resultFormationReviewVisible = false; + this.resultFormationReviewFeedback = ''; + this.resultPresetOverwriteConfirmId = undefined; + this.resultFormationReviewView = undefined; + if (this.battleOutcome) { + const snapshot = this.resultSortiePerformanceSnapshot(); + if (snapshot) { + this.resultFormationReviewButton?.label.setText(`편성 평가 ${this.resultFormationReview(this.battleOutcome, snapshot).grade}`); + } + } + } + + private requestResultFormationPresetUpdate(presetId: CampaignSortiePresetId, outcome: BattleOutcome) { + const snapshot = this.resultSortiePerformanceSnapshot(); + if (!snapshot) { + return; + } + const review = this.resultFormationReview(outcome, snapshot); + const campaign = getCampaignState(); + const existing = campaign.sortieFormationPresets[presetId]; + const definition = resultSortiePresetDefinitions.find((candidate) => candidate.id === presetId) ?? resultSortiePresetDefinitions[0]; + if (this.resultFormationSnapshotMatchesPreset(presetId, review.snapshot)) { + this.resultFormationReviewFeedback = `${definition.label} 편성책이 이미 이번 명단·역할과 같습니다.`; + this.showResultFormationReview(outcome); + return; + } + if (existing && this.resultPresetOverwriteConfirmId !== presetId) { + this.resultPresetOverwriteConfirmId = presetId; + this.resultFormationReviewFeedback = `${definition.label} 편성책을 갱신하려면 한 번 더 누르십시오.`; + soundDirector.playSelect(); + this.showResultFormationReview(outcome); + return; + } + + campaign.sortieFormationPresets = { + ...campaign.sortieFormationPresets, + [presetId]: { + selectedUnitIds: [...review.snapshot.selectedUnitIds], + formationAssignments: { ...review.snapshot.formationAssignments } + } + }; + saveCampaignState(campaign); + this.resultPresetOverwriteConfirmId = undefined; + this.resultFormationReviewFeedback = `${definition.label} 편성책 갱신 완료 · 장비·보급은 저장하지 않았습니다.`; + soundDirector.playSelect(); + this.showResultFormationReview(outcome); + } + + private renderResultFormationMetric(label: string, value: string, x: number, y: number, width: number, depth: number) { + const bg = this.trackResultFormationReview(this.add.rectangle(x, y, width, 48, 0x111c26, 0.96)); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(1, palette.gold, 0.38); + this.trackResultFormationReview(this.add.text(x + 10, y + 7, label, this.resultFormationTextStyle(10, '#9fb0bf', true))).setDepth(depth + 1); + this.trackResultFormationReview(this.add.text(x + width - 10, y + 25, this.resultCompactText(value, 24), this.resultFormationTextStyle(12, '#f2e3bf', true))) + .setOrigin(1, 0) + .setDepth(depth + 1); + } + + private renderResultFormationAnalysisPanel( + title: string, + lines: string[], + x: number, + y: number, + width: number, + height: number, + accent: number, + depth: number + ) { + const bg = this.trackResultFormationReview(this.add.rectangle(x, y, width, height, 0x101820, 0.96)); + bg.setOrigin(0); + bg.setDepth(depth); + bg.setStrokeStyle(1, accent, 0.5); + this.trackResultFormationReview(this.add.text(x + 12, y + 8, title, this.resultFormationTextStyle(12, '#f2e3bf', true))).setDepth(depth + 1); + lines.slice(0, 2).forEach((line, index) => { + 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); + }); + } + + private addResultFormationReviewButton( + label: string, + x: number, + y: number, + width: number, + height: number, + enabled: boolean, + active: boolean, + action: () => void, + depth: number, + accent: number = palette.gold + ) { + const fill = active ? 0x263a2d : enabled ? 0x1a2630 : 0x111820; + const button = this.trackResultFormationReview(this.add.rectangle(x, y, width, height, fill, enabled || active ? 0.98 : 0.6)); + button.setOrigin(0); + button.setDepth(depth); + button.setStrokeStyle(1, active ? palette.green : enabled ? accent : 0x53606c, active ? 0.82 : enabled ? 0.62 : 0.3); + if (enabled) { + button.setInteractive({ useHandCursor: true }); + button.on('pointerover', () => button.setFillStyle(active ? 0x365044 : 0x283947, 1).setStrokeStyle(1, palette.gold, 0.94)); + button.on('pointerout', () => button.setFillStyle(fill, 0.98).setStrokeStyle(1, active ? palette.green : accent, active ? 0.82 : 0.62)); + button.on('pointerdown', action); + } + this.trackResultFormationReview(this.add.text(x + width / 2, y + height / 2, this.resultCompactText(label, 28), this.resultFormationTextStyle(10, enabled || active ? '#f2e3bf' : '#77818c', true))) + .setOrigin(0.5) + .setDepth(depth + 1); + return button; + } + + private resultFormationTextStyle(fontSize: number, color: string, bold = false): Phaser.Types.GameObjects.Text.TextStyle { + return { + fontFamily: '"Malgun Gothic", "Noto Sans KR", sans-serif', + fontSize: `${fontSize}px`, + color, + fontStyle: bold ? '700' : '400' + }; + } + + private resultCompactText(value: string, maxLength: number) { + return value.length <= maxLength ? value : `${value.slice(0, Math.max(0, maxLength - 1))}…`; + } + + private trackResultFormationReview(object: T) { + this.resultFormationReviewObjects.push(object); + return object; + } + private uniqueRewardLabels(labels: string[]) { return [...new Set(labels.map((label) => label.trim()).filter(Boolean))]; } @@ -11523,6 +12065,7 @@ export class BattleScene extends Phaser.Scene { text.setDepth(depth + 1); text.setInteractive({ useHandCursor: true }); text.on('pointerdown', action); + return { background: bg, label: text }; } private trackResultObject(object: T) { @@ -13483,7 +14026,9 @@ export class BattleScene extends Phaser.Scene { try { const campaign = loadCampaignState(slot); + this.launchSortieUnitIds = this.normalizeLaunchSortieUnitIds(campaign.selectedSortieUnitIds); this.launchSortieFormationAssignments = normalizeSortieFormationAssignments(campaign.sortieFormationAssignments); + this.launchSortieItemAssignments = this.normalizeLaunchSortieItemAssignments(campaign.sortieItemAssignments); this.applyBattleSaveState(state); this.renderSituationPanel(`슬롯 ${slot}의 전투를 불러왔습니다.\n${this.formatSavedAt(state.savedAt)}`); } catch { @@ -19899,12 +20444,27 @@ export class BattleScene extends Phaser.Scene { this.renderRosterPanel(this.rosterTab, text); } + private resultObjectBoundsDebug(object?: Phaser.GameObjects.Rectangle) { + if (!object?.active) { + return null; + } + const bounds = object.getBounds(); + return { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }; + } + getDebugState() { const campaign = getCampaignState(); const effectiveSortieUnitIds = this.effectiveSortieUnitIds(campaign); const sortieFormationAssignments = this.effectiveSortieFormationAssignments(campaign); const sortieItemAssignments = this.effectiveSortieItemAssignments(campaign); const deployedAllyIds = battleUnits.filter((unit) => unit.faction === 'ally').map((unit) => unit.id); + const resultSortiePerformance = this.battleOutcome ? this.resultSortiePerformanceSnapshot() : undefined; + const resultFormationReview = this.battleOutcome && resultSortiePerformance + ? this.resultFormationReview(this.battleOutcome, resultSortiePerformance) + : undefined; + const resultSourcePresetIds = resultFormationReview + ? this.resultFormationSourcePresetIds(resultFormationReview.snapshot) + : []; return { scene: this.scene.key, @@ -19940,6 +20500,46 @@ export class BattleScene extends Phaser.Scene { saveSlotPanelVisible: this.saveSlotPanelObjects.length > 0, saveSlotPanelMode: this.saveSlotPanelMode ?? null, resultVisible: this.resultObjects.length > 0, + sortieEvaluation: resultFormationReview + ? { + available: true, + open: this.resultFormationReviewVisible, + grade: resultFormationReview.grade, + score: resultFormationReview.score, + headline: resultFormationReview.headline, + strengths: [...resultFormationReview.strengths], + improvement: resultFormationReview.improvement, + objectiveAchieved: resultFormationReview.objectiveAchieved, + objectiveTotal: resultFormationReview.objectiveTotal, + survivorCount: resultFormationReview.survivorCount, + deployedCount: resultFormationReview.deployedCount, + recoveryNeededCount: resultFormationReview.recoveryNeededCount, + totalDamage: resultFormationReview.totalDamage, + totalDamageTaken: resultFormationReview.totalDamageTaken, + totalDefeats: resultFormationReview.totalDefeats, + totalSupport: resultFormationReview.totalSupport, + roleCoverage: resultFormationReview.roleCoverage, + activeBondCount: resultFormationReview.activeBondCount, + contributionTotals: { ...resultFormationReview.contributionTotals }, + snapshot: { + selectedUnitIds: [...resultFormationReview.snapshot.selectedUnitIds], + formationAssignments: { ...resultFormationReview.snapshot.formationAssignments }, + unitStats: resultFormationReview.snapshot.unitStats.map((stats) => ({ ...stats })) + }, + sourcePresetIds: [...resultSourcePresetIds], + overwriteConfirmId: this.resultPresetOverwriteConfirmId ?? null, + feedback: this.resultFormationReviewFeedback, + toggleButtonBounds: this.resultObjectBoundsDebug(this.resultFormationReviewButton?.background), + closeButtonBounds: this.resultObjectBoundsDebug(this.resultFormationReviewView?.closeButton), + presetCards: resultSortiePresetDefinitions.map((definition) => ({ + id: definition.id, + label: definition.label, + saved: Boolean(campaign.sortieFormationPresets[definition.id]), + matching: resultSourcePresetIds.includes(definition.id), + actionButtonBounds: this.resultObjectBoundsDebug(this.resultFormationReviewView?.presetButtons[definition.id]) + })) + } + : null, turnPromptVisible: this.turnPromptObjects.length > 0, turnPromptMode: this.turnPromptMode ?? null, pointerFeedbackVisible: Boolean(this.pointerFeedbackMarker?.visible), diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index a3325cb..2a88113 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -44,6 +44,30 @@ export type CampaignRewardSnapshot = { note?: string; }; +export type CampaignSortieUnitPerformanceSnapshot = { + unitId: string; + hpBefore: number; + maxHpBefore: number; + damageDealt: number; + damageTaken: number; + defeats: number; + actions: number; + support: number; + sortieBonusDamage: number; + sortiePreventedDamage: number; + sortieBonusHealing: number; + sortieExtendedBondTriggers: number; + intentDefeats: number; + intentLineBreaks: number; + intentGuardSuccesses: number; +}; + +export type CampaignSortiePerformanceSnapshot = { + selectedUnitIds: string[]; + formationAssignments: SortieFormationAssignments; + unitStats: CampaignSortieUnitPerformanceSnapshot[]; +}; + export type FirstBattleReport = { battleId: string; battleTitle: string; @@ -58,6 +82,7 @@ export type FirstBattleReport = { mvp?: CampMvpSnapshot; itemRewards: string[]; campaignRewards?: CampaignRewardSnapshot; + sortiePerformance?: CampaignSortiePerformanceSnapshot; completedCampDialogues: string[]; completedCampVisits: string[]; createdAt: string; @@ -320,6 +345,7 @@ export type CampaignBattleSettlement = { units: CampaignUnitProgressSnapshot[]; bonds: CampaignBondProgressSnapshot[]; reserveTraining?: CampaignReserveTrainingSnapshot[]; + sortiePerformance?: CampaignSortiePerformanceSnapshot; completedAt: string; }; @@ -1117,11 +1143,16 @@ function normalizeLatestBattleId(value: unknown, battleHistory: Record): FirstBattleReport { + const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(report.sortiePerformance, rosterUnitIds); 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))) + bonds: report.bonds.filter((bond) => bond.unitIds.every((unitId) => rosterUnitIds.has(unitId))), + ...(sortiePerformance ? { sortiePerformance } : {}) }; + if (!sortiePerformance) { + delete filtered.sortiePerformance; + } if (filtered.mvp && !rosterUnitIds.has(filtered.mvp.unitId)) { delete filtered.mvp; } @@ -1134,12 +1165,17 @@ function filterBattleHistoryRosterReferences( bondIds: Set ): Record { return Object.entries(history).reduce>((filteredHistory, [battleId, settlement]) => { + const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(settlement.sortiePerformance, rosterUnitIds); filteredHistory[battleId] = { ...settlement, units: settlement.units.filter((unit) => rosterUnitIds.has(unit.unitId)), bonds: bondIds.size > 0 ? settlement.bonds.filter((bond) => bondIds.has(bond.id)) : settlement.bonds, - reserveTraining: settlement.reserveTraining?.filter((entry) => rosterUnitIds.has(entry.unitId)) + reserveTraining: settlement.reserveTraining?.filter((entry) => rosterUnitIds.has(entry.unitId)), + ...(sortiePerformance ? { sortiePerformance } : {}) }; + if (!sortiePerformance) { + delete filteredHistory[battleId].sortiePerformance; + } return filteredHistory; }, {}); } @@ -1163,6 +1199,8 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle return undefined; } + const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(settlement.sortiePerformance); + return { battleId, battleTitle, @@ -1174,6 +1212,7 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle units: normalizeLimitedArray(settlement.units, normalizeCampaignUnitProgressSnapshot, maxCampaignBattleUnitEntries), bonds: normalizeLimitedArray(settlement.bonds, normalizeCampaignBondProgressSnapshot, maxCampaignBattleBondEntries), reserveTraining: normalizeLimitedArray(settlement.reserveTraining, normalizeReserveTrainingSnapshot, maxCampaignReserveTrainingEntries), + ...(sortiePerformance ? { sortiePerformance } : {}), completedAt }; } @@ -1355,6 +1394,60 @@ export function normalizeCampaignSortieFormationPresets(value: unknown, allowedU }, {}); } +export function normalizeCampaignSortiePerformanceSnapshot( + value: unknown, + allowedUnitIds?: Set +): CampaignSortiePerformanceSnapshot | undefined { + if (!isPlainObject(value)) { + return undefined; + } + + const selectedUnitIds = uniqueStrings(value.selectedUnitIds) + .filter((unitId) => !allowedUnitIds || allowedUnitIds.has(unitId)) + .slice(0, maxCampaignSortieAssignmentUnits); + if (selectedUnitIds.length === 0) { + return undefined; + } + + const selectedIdSet = new Set(selectedUnitIds); + const unitStats = uniqueByKey( + normalizeLimitedArray(value.unitStats, (entry): CampaignSortieUnitPerformanceSnapshot | undefined => { + if (!isPlainObject(entry)) { + return undefined; + } + const unitId = normalizeKeyString(entry.unitId); + if (!unitId || !selectedIdSet.has(unitId)) { + return undefined; + } + const hpBefore = normalizeNonNegativeInteger(entry.hpBefore); + return { + unitId, + hpBefore, + maxHpBefore: Math.max(hpBefore, normalizeNonNegativeInteger(entry.maxHpBefore)), + damageDealt: normalizeNonNegativeInteger(entry.damageDealt), + damageTaken: normalizeNonNegativeInteger(entry.damageTaken), + defeats: normalizeNonNegativeInteger(entry.defeats), + actions: normalizeNonNegativeInteger(entry.actions), + support: normalizeNonNegativeInteger(entry.support), + sortieBonusDamage: normalizeNonNegativeInteger(entry.sortieBonusDamage), + sortiePreventedDamage: normalizeNonNegativeInteger(entry.sortiePreventedDamage), + sortieBonusHealing: normalizeNonNegativeInteger(entry.sortieBonusHealing), + sortieExtendedBondTriggers: normalizeNonNegativeInteger(entry.sortieExtendedBondTriggers), + intentDefeats: normalizeNonNegativeInteger(entry.intentDefeats), + intentLineBreaks: normalizeNonNegativeInteger(entry.intentLineBreaks), + intentGuardSuccesses: normalizeNonNegativeInteger(entry.intentGuardSuccesses) + }; + }, maxCampaignSortieAssignmentUnits), + (entry) => entry.unitId + ); + + return { + selectedUnitIds, + formationAssignments: normalizeSortieFormationAssignments(recordOrEmpty(value.formationAssignments), selectedIdSet), + unitStats + }; +} + function normalizeReserveTrainingFocusId(focusId?: string): CampaignReserveTrainingFocusId { return isReserveTrainingFocusId(focusId) ? focusId as CampaignReserveTrainingFocusId @@ -1392,6 +1485,8 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi return undefined; } + const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(report.sortiePerformance); + return { battleId, battleTitle, @@ -1409,6 +1504,7 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi isPlainObject(report.campaignRewards) ? report.campaignRewards as CampaignRewardSnapshot : undefined, battleId ), + ...(sortiePerformance ? { sortiePerformance } : {}), completedCampDialogues: uniqueStrings(report.completedCampDialogues), completedCampVisits: uniqueStrings(report.completedCampVisits), createdAt: normalizeCampaignTimestamp(report.createdAt) ?? new Date().toISOString() @@ -1643,6 +1739,15 @@ function createBattleSettlement(report: FirstBattleReport, reserveTraining: Camp battleExp: bond.battleExp })), reserveTraining: reserveTraining.map((entry) => ({ ...entry })), + ...(report.sortiePerformance + ? { + sortiePerformance: { + selectedUnitIds: [...report.sortiePerformance.selectedUnitIds], + formationAssignments: { ...report.sortiePerformance.formationAssignments }, + unitStats: report.sortiePerformance.unitStats.map((stats) => ({ ...stats })) + } + } + : {}), completedAt: report.createdAt }; }