diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index 8deb0f0..ee4f167 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -36,6 +36,7 @@ try { normalizeCampaignSortieFormationPresets, normalizeCampaignSortieItemAssignments, normalizeCampaignSortiePerformanceSnapshot, + normalizeCampaignSortieReviewSnapshot, resetCampaignState, saveCampaignState, setCampaignState, @@ -256,6 +257,49 @@ try { normalizeCampaignSortiePerformanceSnapshot({ selectedUnitIds: [], unitStats: [] }) === undefined, `Expected sortie performance snapshots to stay capped and empty snapshots to be discarded: ${JSON.stringify(cappedSortiePerformance)}` ); + const normalizedSortieReview = normalizeCampaignSortieReviewSnapshot( + { + version: 1, + score: '88.9', + grade: 'D', + activeBondCount: '999', + enemyCount: '1000', + sourcePresetIds: ['siege', 'elite', 'siege', 'ghost', 'mobile', 7], + sortieItemAssignments: { 'liu-bei': { bean: 2 } } + }, + normalizedSortiePerformance + ); + assert( + normalizedSortieReview?.version === 1 && + normalizedSortieReview.score === 88 && + normalizedSortieReview.grade === 'A' && + normalizedSortieReview.activeBondCount === 96 && + normalizedSortieReview.enemyCount === 96 && + JSON.stringify(normalizedSortieReview.sourcePresetIds) === JSON.stringify(['elite', 'mobile', 'siege']) && + !Object.prototype.hasOwnProperty.call(normalizedSortieReview, 'sortieItemAssignments'), + `Expected sortie reviews to clamp evaluator inputs, derive grade from score, canonicalize known preset ids, and exclude unrelated fields: ${JSON.stringify(normalizedSortieReview)}` + ); + assert( + normalizeCampaignSortieReviewSnapshot({ ...normalizedSortieReview, version: 2 }, normalizedSortiePerformance) === undefined && + normalizeCampaignSortieReviewSnapshot({ score: 88, grade: 'A', sourcePresetIds: ['elite'] }, normalizedSortiePerformance) === undefined && + normalizeCampaignSortieReviewSnapshot(normalizedSortieReview) === undefined && + normalizeCampaignSortieReviewSnapshot(normalizedSortieReview, { + selectedUnitIds: ['liu-bei'], + formationAssignments: {}, + unitStats: [] + }) === undefined, + 'Expected sortie reviews with unsupported/missing nested versions or missing/incomplete performance snapshots to be discarded' + ); + const sortieReviewGradeBoundaries = [[90, 'S'], [75, 'A'], [60, 'B'], [45, 'C'], [44, 'D']]; + assert( + sortieReviewGradeBoundaries.every(([score, grade]) => ( + normalizeCampaignSortieReviewSnapshot( + { ...normalizedSortieReview, score, grade: 'D' }, + normalizedSortiePerformance + )?.grade === grade + )), + `Expected persisted sortie review grades to match evaluator boundaries: ${JSON.stringify(sortieReviewGradeBoundaries)}` + ); storage.clear(); storage.set( @@ -413,6 +457,14 @@ try { formationAssignments: { [performanceUnit.id]: 'front' }, unitStats: [sortieStatsFixture(performanceUnit.id, { hpBefore: performanceUnit.maxHp, maxHpBefore: performanceUnit.maxHp, damageDealt: 24 })] }, + sortieReview: { + version: 1, + score: 88, + grade: 'A', + activeBondCount: 2, + enemyCount: 4, + sourcePresetIds: ['elite', 'mobile'] + }, createdAt: '2026-07-03T09:05:00.000Z' }; resetCampaignState(); @@ -420,29 +472,51 @@ try { performanceReport.sortiePerformance.selectedUnitIds.push('ghost-unit'); performanceReport.sortiePerformance.formationAssignments[performanceUnit.id] = 'support'; performanceReport.sortiePerformance.unitStats[0].damageDealt = 999; + performanceReport.sortieReview.score = 1; + performanceReport.sortieReview.grade = 'D'; + performanceReport.sortieReview.sourcePresetIds.push('siege'); const clonedPerformanceSettlement = getCampaignState(); const clonedReportPerformance = clonedPerformanceSettlement.firstBattleReport?.sortiePerformance; const clonedHistoryPerformance = clonedPerformanceSettlement.battleHistory[firstScenario.id]?.sortiePerformance; + const clonedReportReview = clonedPerformanceSettlement.firstBattleReport?.sortieReview; + const clonedHistoryReview = clonedPerformanceSettlement.battleHistory[firstScenario.id]?.sortieReview; 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)}` + clonedHistoryPerformance.unitStats[0].damageDealt === 24 && + clonedReportReview?.score === 88 && + clonedReportReview.grade === 'A' && + JSON.stringify(clonedReportReview.sourcePresetIds) === JSON.stringify(['elite', 'mobile']) && + clonedHistoryReview?.score === 88 && + clonedHistoryReview.grade === 'A' && + JSON.stringify(clonedHistoryReview.sourcePresetIds) === JSON.stringify(['elite', 'mobile']), + `Expected battle report settlement to deep-clone nested sortie performance and review input: ${JSON.stringify(clonedPerformanceSettlement)}` ); clonedReportPerformance.selectedUnitIds.push('detached-unit'); clonedReportPerformance.formationAssignments[performanceUnit.id] = 'reserve'; clonedReportPerformance.unitStats[0].damageDealt = 777; + clonedReportReview.score = 2; + clonedReportReview.grade = 'D'; + clonedReportReview.sourcePresetIds.push('siege'); const unmutatedPerformanceSettlement = getCampaignState(); assert( clonedHistoryPerformance.unitStats[0].damageDealt === 24 && + clonedHistoryReview.score === 88 && + clonedHistoryReview.grade === 'A' && + JSON.stringify(clonedHistoryReview.sourcePresetIds) === JSON.stringify(['elite', 'mobile']) && 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)}` + unmutatedPerformanceSettlement.battleHistory[firstScenario.id]?.sortiePerformance?.unitStats[0].damageDealt === 24 && + unmutatedPerformanceSettlement.firstBattleReport?.sortieReview?.score === 88 && + unmutatedPerformanceSettlement.firstBattleReport?.sortieReview?.grade === 'A' && + JSON.stringify(unmutatedPerformanceSettlement.firstBattleReport?.sortieReview?.sourcePresetIds) === JSON.stringify(['elite', 'mobile']) && + unmutatedPerformanceSettlement.battleHistory[firstScenario.id]?.sortieReview?.score === 88 && + JSON.stringify(unmutatedPerformanceSettlement.battleHistory[firstScenario.id]?.sortieReview?.sourcePresetIds) === JSON.stringify(['elite', 'mobile']), + `Expected returned report, history, and persisted sortie performance/review snapshots to remain deeply detached: ${JSON.stringify(unmutatedPerformanceSettlement)}` ); resetCampaignState(); const reserveDrillTemplate = campaignRecruitUnits.find((unit) => unit.id === 'jian-yong'); @@ -501,8 +575,10 @@ try { ); 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)}` + repeatedSettlement.battleHistory[firstScenario.id]?.sortiePerformance === undefined && + repeatedSettlement.firstBattleReport?.sortieReview === undefined && + repeatedSettlement.battleHistory[firstScenario.id]?.sortieReview === undefined, + `Expected legacy reports without sortie performance/review to remain compatible in both latest report and history: ${JSON.stringify(repeatedSettlement)}` ); assert( repeatedSettlement.firstBattleReport?.campaignRewards?.unlocks[0]?.battleId === 'second-battle-yellow-turban-pursuit' && @@ -1132,6 +1208,15 @@ try { ], sortieItemAssignments: { 'liu-bei': { bean: 2 } } }, + sortieReview: { + version: 1, + score: '88.9', + grade: 'D', + activeBondCount: '999', + enemyCount: '1000', + sourcePresetIds: ['siege', 'elite', 'siege', 'ghost', 'mobile', 7], + itemAssignments: { '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)], @@ -1196,6 +1281,7 @@ try { `Expected malformed report fields to be normalized without discarding the report: ${JSON.stringify(malformedReport)}` ); const malformedReportPerformance = malformedReport.firstBattleReport?.sortiePerformance; + const malformedReportReview = malformedReport.firstBattleReport?.sortieReview; assert( JSON.stringify(malformedReportPerformance?.selectedUnitIds) === JSON.stringify(['liu-bei']) && JSON.stringify(malformedReportPerformance?.formationAssignments) === JSON.stringify({ 'liu-bei': 'front' }) && @@ -1211,6 +1297,10 @@ try { !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)}` ); + assert( + malformedReportReview === undefined, + `Expected report reviews to be discarded when roster recovery removes part of their recorded sortie: ${JSON.stringify(malformedReportReview)}` + ); storage.clear(); storage.set( @@ -1345,6 +1435,15 @@ try { ], itemAssignments: { 'liu-bei': { bean: 2 } } }, + sortieReview: { + version: 1, + score: '101.2', + grade: 'D', + activeBondCount: '999', + enemyCount: '-4', + sourcePresetIds: ['mobile', 'elite', 'mobile', 'ghost'], + sortieItemAssignments: { 'liu-bei': { bean: 2 } } + }, reserveTraining: [ { unitId: 'guan-yu', @@ -1445,6 +1544,7 @@ try { `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; + const malformedHistoryReview = malformedHistory.battleHistory['first-battle-zhuo-commandery']?.sortieReview; assert( JSON.stringify(malformedHistoryPerformance?.selectedUnitIds) === JSON.stringify(['liu-bei']) && JSON.stringify(malformedHistoryPerformance?.formationAssignments) === JSON.stringify({ 'liu-bei': 'front' }) && @@ -1460,6 +1560,10 @@ try { !Object.prototype.hasOwnProperty.call(malformedHistoryPerformance, 'itemAssignments'), `Expected malformed historical sortie performance to normalize and filter roster references without persisting supplies: ${JSON.stringify(malformedHistoryPerformance)}` ); + assert( + malformedHistoryReview === undefined, + `Expected historical reviews to be discarded when roster recovery removes part of their recorded sortie: ${JSON.stringify(malformedHistoryReview)}` + ); storage.clear(); storage.set(campaignStorageKey, JSON.stringify({ version: 99, step: 'sixty-sixth-camp' })); @@ -1544,7 +1648,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/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.'); + 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/sortie-review/latest-history/detail recovery, sortie performance/review 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 645a656..fb59059 100644 --- a/scripts/verify-release-candidate.mjs +++ b/scripts/verify-release-candidate.mjs @@ -132,6 +132,28 @@ try { 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 expectedFirstSortieReview = { + version: 1, + score: firstResultState.sortieEvaluation.score, + grade: firstResultState.sortieEvaluation.grade, + activeBondCount: firstResultState.sortieEvaluation.activeBondCount, + enemyCount: firstResultState.units.filter((unit) => unit.faction === 'enemy').length, + sourcePresetIds: [] + }; + assert( + sameJsonValue(firstResultSave.current?.firstBattleReport?.sortieReview, expectedFirstSortieReview) && + sameJsonValue(firstResultSave.slot1?.firstBattleReport?.sortieReview, expectedFirstSortieReview) && + sameJsonValue(firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview, expectedFirstSortieReview) && + sameJsonValue(firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview, expectedFirstSortieReview) && + firstResultSave.current.firstBattleReport.sortieReview.sourcePresetIds.length === 0, + `Expected the first persisted sortie review to match the live grade, score, enemies, bonds, and empty preset attribution everywhere: ${JSON.stringify({ + expected: expectedFirstSortieReview, + currentReport: firstResultSave.current?.firstBattleReport?.sortieReview, + slotReport: firstResultSave.slot1?.firstBattleReport?.sortieReview, + currentHistory: firstResultSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview, + slotHistory: firstResultSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview + })}` + ); const firstEvaluationTogglePoint = await readBattleResultEvaluationControlPoint(page, 'toggle'); await page.mouse.click(firstEvaluationTogglePoint.x, firstEvaluationTogglePoint.y); @@ -218,6 +240,7 @@ try { ); const firstCampEvaluation = firstCampProbe.state?.reportFormationEvaluation; + const firstCampHistory = firstCampProbe.state?.reportFormationHistory; const firstCampEvaluationSaveBefore = await readCampaignSave(page); assert( firstCampEvaluation?.available === true && @@ -229,6 +252,24 @@ try { firstCampEvaluation.toggleButtonBounds, `Expected the first camp report to expose the persisted battle formation evaluation: ${JSON.stringify(firstCampEvaluation)}` ); + assert( + firstCampHistory?.available === true && + firstCampHistory.open === false && + firstCampHistory.recordCount === 1 && + firstCampHistory.page === 0 && + firstCampHistory.pageCount === 1 && + firstCampHistory.records?.length === 1 && + firstCampHistory.records[0].battleId === 'first-battle-zhuo-commandery' && + firstCampHistory.records[0].grade === expectedFirstSortieReview.grade && + firstCampHistory.records[0].score === expectedFirstSortieReview.score && + sameJsonValue(firstCampHistory.records[0].sourcePresetIds, expectedFirstSortieReview.sourcePresetIds) && + firstCampHistory.selectedBattleId === 'first-battle-zhuo-commandery' && + firstCampHistory.best?.battleId === 'first-battle-zhuo-commandery' && + firstCampHistory.loadableBest?.battleId === 'first-battle-zhuo-commandery' && + firstCampHistory.loadUndoAvailable === false && + firstCampHistory.toggleButtonBounds, + `Expected the first camp report to expose one closed, persisted formation-history record and its best loadable sortie: ${JSON.stringify(firstCampHistory)}` + ); const firstCampEvaluationTogglePoint = await readCampReportFormationEvaluationControlPoint(page, 'toggle'); await page.mouse.click(firstCampEvaluationTogglePoint.x, firstCampEvaluationTogglePoint.y); await page.waitForFunction( @@ -267,6 +308,338 @@ try { { timeout: 30000 } ); + const firstCampHistoryMutationFixture = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const campaign = scene?.campaign; + const snapshot = campaign?.battleHistory?.['first-battle-zhuo-commandery']?.sortiePerformance; + const targetId = snapshot?.selectedUnitIds?.find((unitId) => scene?.selectedSortieUnitIds?.includes(unitId)); + const historicalRole = targetId ? snapshot?.formationAssignments?.[targetId] : undefined; + const divergentRole = ['front', 'flank', 'support', 'reserve'].find((role) => role !== historicalRole); + if ( + !scene || + !campaign || + !snapshot?.selectedUnitIds?.length || + !targetId || + !historicalRole || + !divergentRole || + typeof scene.persistSortieSelection !== 'function' || + typeof scene.render !== 'function' + ) { + return { + ready: false, + targetId: targetId ?? null, + historicalRole: historicalRole ?? null, + divergentRole: divergentRole ?? null + }; + } + + const before = { + selectedUnitIds: [...scene.selectedSortieUnitIds], + formationAssignments: { ...scene.sortieFormationAssignments }, + itemAssignments: structuredClone(scene.sortieItemAssignments ?? {}), + focusedUnitId: scene.sortieFocusedUnitId, + planFeedback: scene.sortiePlanFeedback, + rosterScroll: scene.sortieRosterScroll + }; + scene.sortieFormationAssignments = { + ...scene.sortieFormationAssignments, + [targetId]: divergentRole + }; + scene.persistSortieSelection(); + scene.render(); + return { + ready: true, + targetId, + historicalRole, + divergentRole, + before, + divergent: { + selectedUnitIds: [...scene.selectedSortieUnitIds], + formationAssignments: { ...scene.sortieFormationAssignments }, + itemAssignments: structuredClone(scene.sortieItemAssignments ?? {}) + } + }; + }); + assert( + firstCampHistoryMutationFixture.ready === true && + firstCampHistoryMutationFixture.historicalRole !== firstCampHistoryMutationFixture.divergentRole && + firstCampHistoryMutationFixture.divergent?.formationAssignments?.[firstCampHistoryMutationFixture.targetId] === + firstCampHistoryMutationFixture.divergentRole, + `Expected a deterministic current-formation mismatch before exercising best-history loading: ${JSON.stringify(firstCampHistoryMutationFixture)}` + ); + const firstCampHistorySaveBeforeOpen = await readCampaignSave(page); + assert( + sameJsonValue( + firstCampHistorySaveBeforeOpen.current?.selectedSortieUnitIds, + firstCampHistoryMutationFixture.divergent.selectedUnitIds + ) && + sameJsonValue( + firstCampHistorySaveBeforeOpen.current?.sortieFormationAssignments, + firstCampHistoryMutationFixture.divergent.formationAssignments + ) && + sameJsonValue( + firstCampHistorySaveBeforeOpen.current?.sortieItemAssignments, + firstCampHistoryMutationFixture.divergent.itemAssignments + ) && + sameJsonValue( + firstCampHistorySaveBeforeOpen.slot1?.selectedSortieUnitIds, + firstCampHistoryMutationFixture.divergent.selectedUnitIds + ) && + sameJsonValue( + firstCampHistorySaveBeforeOpen.slot1?.sortieFormationAssignments, + firstCampHistoryMutationFixture.divergent.formationAssignments + ) && + sameJsonValue( + firstCampHistorySaveBeforeOpen.slot1?.sortieItemAssignments, + firstCampHistoryMutationFixture.divergent.itemAssignments + ), + `Expected the deliberate history-load mismatch to synchronize across current and slot-1 saves: ${JSON.stringify(firstCampHistorySaveBeforeOpen)}` + ); + + const firstCampHistoryTogglePoint = await readCampReportFormationHistoryControlPoint(page, 'toggle'); + await page.mouse.click(firstCampHistoryTogglePoint.x, firstCampHistoryTogglePoint.y); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.reportFormationHistory?.open === true, + undefined, + { timeout: 30000 } + ); + const firstCampHistoryOpenProbe = await page.evaluate(() => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + return { + history: window.__HEROS_DEBUG__?.camp()?.reportFormationHistory, + texts: (scene?.reportFormationHistoryObjects ?? []) + .filter((object) => object?.type === 'Text') + .map((object) => object.text) + }; + }); + const firstCampHistorySaveOpen = await readCampaignSave(page); + const firstCampHistoryLoadProjection = firstCampHistoryOpenProbe.history?.loadableBest; + assert( + firstCampHistoryOpenProbe.history?.open === true && + firstCampHistoryOpenProbe.history.recordCount === 1 && + firstCampHistoryOpenProbe.history.comparison?.matchesCurrent === false && + firstCampHistoryOpenProbe.history.closeButtonBounds && + firstCampHistoryOpenProbe.history.bestButtonBounds && + firstCampHistoryOpenProbe.history.loadBestButtonBounds && + firstCampHistoryOpenProbe.history.undoButtonBounds === null && + sameJsonValue(firstCampHistoryLoadProjection?.projectedSelectedUnitIds, firstResultPerformance.selectedUnitIds) && + firstResultPerformance.selectedUnitIds.every( + (unitId) => firstCampHistoryLoadProjection?.projectedFormationAssignments?.[unitId] === firstResultPerformance.formationAssignments[unitId] + ) && + sameJsonValue( + firstCampHistoryLoadProjection?.projectedItemAssignments, + firstCampHistoryMutationFixture.divergent.itemAssignments + ) && + firstCampHistoryOpenProbe.texts.includes('편성 전적표') && + firstCampHistoryOpenProbe.texts.includes('최고 편성') && + firstCampHistoryOpenProbe.texts.includes('현재 출전안 vs 최고 편성') && + sameJsonValue(firstCampHistorySaveOpen, firstCampHistorySaveBeforeOpen), + `Expected opening the first camp formation history to remain non-destructive and expose best/load controls: ${JSON.stringify(firstCampHistoryOpenProbe)}` + ); + await page.screenshot({ path: `${screenshotDir}/rc-first-camp-formation-history.png`, fullPage: true }); + await assertCanvasPainted(page, 'first camp formation history'); + + const firstCampHistoryBestPoint = await readCampReportFormationHistoryControlPoint(page, 'best'); + await page.mouse.click(firstCampHistoryBestPoint.x, firstCampHistoryBestPoint.y); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.reportFormationHistory?.selectedBattleId === 'first-battle-zhuo-commandery', + undefined, + { timeout: 30000 } + ); + const firstCampHistorySaveAfterBest = await readCampaignSave(page); + assert( + sameJsonValue(firstCampHistorySaveAfterBest, firstCampHistorySaveBeforeOpen), + `Expected selecting the best history card to leave campaign saves untouched: ${JSON.stringify(firstCampHistorySaveAfterBest)}` + ); + + const firstCampHistoryLoadBestPoint = await readCampReportFormationHistoryControlPoint(page, 'loadBest'); + await page.mouse.click(firstCampHistoryLoadBestPoint.x, firstCampHistoryLoadBestPoint.y); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.reportFormationHistory?.loadUndoAvailable === true, + undefined, + { timeout: 30000 } + ); + const firstCampHistoryAppliedState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const firstCampHistoryAppliedSave = await readCampaignSave(page); + assert( + sameJsonValue(firstCampHistoryAppliedState?.selectedSortieUnitIds, firstCampHistoryLoadProjection.projectedSelectedUnitIds) && + sameJsonValue( + firstCampHistoryAppliedState?.sortieFormationAssignments, + firstCampHistoryLoadProjection.projectedFormationAssignments + ) && + sameJsonValue( + firstCampHistoryAppliedState?.sortieItemAssignments, + firstCampHistoryLoadProjection.projectedItemAssignments + ) && + firstCampHistoryAppliedState?.reportFormationHistory?.comparison?.matchesCurrent === true && + firstCampHistoryAppliedState?.reportFormationHistory?.loadUndoAvailable === true && + firstCampHistoryAppliedState?.reportFormationHistory?.undoButtonBounds && + firstCampHistoryAppliedState?.reportFormationHistory?.feedback?.includes('최고 편성 적용') && + sameJsonValue( + firstCampHistoryAppliedSave.current?.selectedSortieUnitIds, + firstCampHistoryLoadProjection.projectedSelectedUnitIds + ) && + sameJsonValue( + firstCampHistoryAppliedSave.slot1?.selectedSortieUnitIds, + firstCampHistoryLoadProjection.projectedSelectedUnitIds + ) && + sameJsonValue( + firstCampHistoryAppliedSave.current?.sortieFormationAssignments, + firstCampHistoryAppliedState.sortieFormationAssignments + ) && + sameJsonValue( + firstCampHistoryAppliedSave.slot1?.sortieFormationAssignments, + firstCampHistoryAppliedState.sortieFormationAssignments + ) && + sameJsonValue( + firstCampHistoryAppliedSave.current?.sortieItemAssignments, + firstCampHistoryLoadProjection.projectedItemAssignments + ) && + sameJsonValue( + firstCampHistoryAppliedSave.slot1?.sortieItemAssignments, + firstCampHistoryLoadProjection.projectedItemAssignments + ) && + sameJsonValue(firstCampHistoryAppliedSave.current?.firstBattleReport?.sortieReview, expectedFirstSortieReview) && + sameJsonValue( + firstCampHistoryAppliedSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview, + expectedFirstSortieReview + ) && + sameJsonValue(firstCampHistoryAppliedSave.slot1?.firstBattleReport?.sortieReview, expectedFirstSortieReview) && + sameJsonValue( + firstCampHistoryAppliedSave.slot1?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview, + expectedFirstSortieReview + ), + `Expected one best-history click to apply persisted names and roles, preserve common supplies, and keep the stored review immutable: ${JSON.stringify({ + state: firstCampHistoryAppliedState, + save: firstCampHistoryAppliedSave + })}` + ); + + const firstCampHistoryUndoPoint = await readCampReportFormationHistoryControlPoint(page, 'undo'); + await page.mouse.click(firstCampHistoryUndoPoint.x, firstCampHistoryUndoPoint.y); + await page.waitForFunction( + (fixture) => { + const state = window.__HEROS_DEBUG__?.camp(); + return state?.reportFormationHistory?.loadUndoAvailable === false && + JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(fixture.divergent.selectedUnitIds) && + JSON.stringify(state?.sortieFormationAssignments) === JSON.stringify(fixture.divergent.formationAssignments) && + JSON.stringify(state?.sortieItemAssignments) === JSON.stringify(fixture.divergent.itemAssignments); + }, + firstCampHistoryMutationFixture, + { timeout: 30000 } + ); + const firstCampHistoryUndoneState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + const firstCampHistoryUndoneSave = await readCampaignSave(page); + assert( + firstCampHistoryUndoneState?.reportFormationHistory?.open === true && + firstCampHistoryUndoneState.reportFormationHistory.loadUndoAvailable === false && + firstCampHistoryUndoneState.reportFormationHistory.undoButtonBounds === null && + firstCampHistoryUndoneState.reportFormationHistory.comparison?.matchesCurrent === false && + firstCampHistoryUndoneState.reportFormationHistory.feedback?.includes('되돌리고') && + sameJsonValue( + firstCampHistoryUndoneSave.current?.selectedSortieUnitIds, + firstCampHistoryMutationFixture.divergent.selectedUnitIds + ) && + sameJsonValue( + firstCampHistoryUndoneSave.current?.sortieFormationAssignments, + firstCampHistoryMutationFixture.divergent.formationAssignments + ) && + sameJsonValue( + firstCampHistoryUndoneSave.current?.sortieItemAssignments, + firstCampHistoryMutationFixture.divergent.itemAssignments + ) && + sameJsonValue( + firstCampHistoryUndoneSave.slot1?.selectedSortieUnitIds, + firstCampHistoryMutationFixture.divergent.selectedUnitIds + ) && + sameJsonValue( + firstCampHistoryUndoneSave.slot1?.sortieFormationAssignments, + firstCampHistoryMutationFixture.divergent.formationAssignments + ) && + sameJsonValue( + firstCampHistoryUndoneSave.slot1?.sortieItemAssignments, + firstCampHistoryMutationFixture.divergent.itemAssignments + ) && + sameJsonValue(firstCampHistoryUndoneSave.current?.firstBattleReport?.sortieReview, expectedFirstSortieReview) && + sameJsonValue( + firstCampHistoryUndoneSave.current?.battleHistory?.['first-battle-zhuo-commandery']?.sortieReview, + expectedFirstSortieReview + ), + `Expected the real history undo control to restore the exact prior names, roles, and supplies without touching the battle review: ${JSON.stringify({ + state: firstCampHistoryUndoneState, + save: firstCampHistoryUndoneSave + })}` + ); + + const firstCampHistoryClosePoint = await readCampReportFormationHistoryControlPoint(page, 'close'); + await page.mouse.click(firstCampHistoryClosePoint.x, firstCampHistoryClosePoint.y); + await page.waitForFunction( + () => window.__HEROS_DEBUG__?.camp()?.reportFormationHistory?.open === false, + undefined, + { timeout: 30000 } + ); + const firstCampHistoryClosedState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); + assert( + firstCampHistoryClosedState?.reportFormationHistory?.recordCount === 1 && + firstCampHistoryClosedState.reportFormationHistory.open === false && + firstCampHistoryClosedState.reportFormationHistory.closeButtonBounds === null && + firstCampHistoryClosedState.reportFormationHistory.toggleButtonBounds, + `Expected the real history close control to return to the first camp report: ${JSON.stringify(firstCampHistoryClosedState?.reportFormationHistory)}` + ); + + await page.evaluate((before) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + if (!scene || typeof scene.persistSortieSelection !== 'function' || typeof scene.render !== 'function') { + throw new Error('Expected CampScene runtime methods while restoring the first-camp history fixture.'); + } + scene.selectedSortieUnitIds = [...before.selectedUnitIds]; + scene.sortieFormationAssignments = { ...before.formationAssignments }; + scene.sortieItemAssignments = structuredClone(before.itemAssignments); + scene.sortieFocusedUnitId = before.focusedUnitId; + scene.sortiePlanFeedback = before.planFeedback; + scene.sortieRosterScroll = before.rosterScroll; + scene.persistSortieSelection(); + scene.render(); + }, firstCampHistoryMutationFixture.before); + await page.waitForFunction( + (before) => { + const state = window.__HEROS_DEBUG__?.camp(); + return JSON.stringify(state?.selectedSortieUnitIds) === JSON.stringify(before.selectedUnitIds) && + JSON.stringify(state?.sortieFormationAssignments) === JSON.stringify(before.formationAssignments) && + JSON.stringify(state?.sortieItemAssignments) === JSON.stringify(before.itemAssignments); + }, + firstCampHistoryMutationFixture.before, + { timeout: 30000 } + ); + const firstCampHistoryRestoredSave = await readCampaignSave(page); + assert( + sameJsonValue( + firstCampHistoryRestoredSave.current?.selectedSortieUnitIds, + firstCampHistoryMutationFixture.before.selectedUnitIds + ) && + sameJsonValue( + firstCampHistoryRestoredSave.current?.sortieFormationAssignments, + firstCampHistoryMutationFixture.before.formationAssignments + ) && + sameJsonValue( + firstCampHistoryRestoredSave.current?.sortieItemAssignments, + firstCampHistoryMutationFixture.before.itemAssignments + ) && + sameJsonValue( + firstCampHistoryRestoredSave.slot1?.selectedSortieUnitIds, + firstCampHistoryMutationFixture.before.selectedUnitIds + ) && + sameJsonValue( + firstCampHistoryRestoredSave.slot1?.sortieFormationAssignments, + firstCampHistoryMutationFixture.before.formationAssignments + ) && + sameJsonValue( + firstCampHistoryRestoredSave.slot1?.sortieItemAssignments, + firstCampHistoryMutationFixture.before.itemAssignments + ), + `Expected the RC-only mismatch fixture to restore the original first-camp sortie before continuing: ${JSON.stringify(firstCampHistoryRestoredSave)}` + ); + const firstCampSupplyMutationFixture = await page.evaluate(() => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); const state = window.__HEROS_DEBUG__?.camp(); @@ -1396,6 +1769,9 @@ try { const midResultState = await page.evaluate(() => window.__HEROS_DEBUG__?.battle()); const midResultSaveBeforeUpdate = await readCampaignSave(page); const midResultPerformance = midResultState?.sortieEvaluation?.snapshot; + const midResultSortieReviewBeforePresetUpdate = structuredClone( + midResultSaveBeforeUpdate.current?.battleHistory?.[midCampNextBattleId]?.sortieReview + ); const midContributorPerformance = midResultPerformance?.unitStats?.find((stats) => stats.unitId === midResultFixture.contributorId); const midBattleRoleById = Object.fromEntries( (midNextBattleProbe?.deployedAllyPositions ?? []).map((unit) => [unit.id, unit.formationRole]) @@ -1438,6 +1814,20 @@ try { mobile: midResultSaveBeforeUpdate.current?.sortieFormationPresets?.mobile })}` ); + assert( + midResultSortieReviewBeforePresetUpdate?.version === 1 && + midResultSortieReviewBeforePresetUpdate.score === midResultState?.sortieEvaluation?.score && + midResultSortieReviewBeforePresetUpdate.grade === midResultState?.sortieEvaluation?.grade && + midResultSortieReviewBeforePresetUpdate.activeBondCount === midResultState?.sortieEvaluation?.activeBondCount && + midResultSortieReviewBeforePresetUpdate.enemyCount === midNextBattleProbe.units.filter((unit) => unit.faction === 'enemy').length && + sameJsonValue(midResultSaveBeforeUpdate.current?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) && + sameJsonValue(midResultSaveBeforeUpdate.slot1?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) && + sameJsonValue(midResultSaveBeforeUpdate.slot1?.battleHistory?.[midCampNextBattleId]?.sortieReview, midResultSortieReviewBeforePresetUpdate), + `Expected the mid-campaign result to persist one canonical sortie review before any result-screen preset update: ${JSON.stringify({ + review: midResultSortieReviewBeforePresetUpdate, + evaluation: midResultState?.sortieEvaluation + })}` + ); const midEvaluationTogglePoint = await readBattleResultEvaluationControlPoint(page, 'toggle'); await page.mouse.click(midEvaluationTogglePoint.x, midEvaluationTogglePoint.y); @@ -1515,6 +1905,10 @@ try { sameJsonValue(updatedMobileResultSave.slot1?.sortieItemAssignments, midResultSaveBeforeUpdate.slot1?.sortieItemAssignments) && sameJsonValue(updatedMobileResultSave.current?.firstBattleReport?.sortiePerformance, midResultPerformance) && sameJsonValue(updatedMobileResultSave.slot1?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) && + sameJsonValue(updatedMobileResultSave.current?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) && + sameJsonValue(updatedMobileResultSave.slot1?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) && + sameJsonValue(updatedMobileResultSave.current?.battleHistory?.[midCampNextBattleId]?.sortieReview, midResultSortieReviewBeforePresetUpdate) && + sameJsonValue(updatedMobileResultSave.slot1?.battleHistory?.[midCampNextBattleId]?.sortieReview, midResultSortieReviewBeforePresetUpdate) && 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({ @@ -1543,6 +1937,7 @@ try { const midCampReviewState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const midCampReviewSaveBefore = await readCampaignSave(page); const midCampFormationEvaluation = midCampReviewState?.reportFormationEvaluation; + const midCampMobileCard = midCampFormationEvaluation?.presetCards?.find((card) => card.id === 'mobile'); const midCampSiegeCard = midCampFormationEvaluation?.presetCards?.find((card) => card.id === 'siege'); assert( midCampFormationEvaluation?.available === true && @@ -1551,8 +1946,9 @@ try { midCampFormationEvaluation.grade === updatedMobileResultState?.sortieEvaluation?.grade && midCampFormationEvaluation.score === updatedMobileResultState?.sortieEvaluation?.score && sameJsonValue(midCampFormationEvaluation.snapshot, midResultPerformance) && - midCampFormationEvaluation.sourcePresetIds?.includes('mobile') === true && - midCampFormationEvaluation.sourcePresetIds?.includes('siege') === false && + sameJsonValue(midCampFormationEvaluation.sourcePresetIds, midResultSortieReviewBeforePresetUpdate.sourcePresetIds) && + midCampMobileCard?.saved === true && + midCampMobileCard.matching === true && midCampSiegeCard?.saved === true && midCampSiegeCard.matching === false && midCampFormationEvaluation.toggleButtonBounds && @@ -1605,7 +2001,10 @@ try { const pendingCampSiegeUpdateState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); const pendingCampSiegeUpdateSave = await readCampaignSave(page); assert( - pendingCampSiegeUpdateState?.reportFormationEvaluation?.sourcePresetIds?.includes('siege') === false && + sameJsonValue( + pendingCampSiegeUpdateState?.reportFormationEvaluation?.sourcePresetIds, + midResultSortieReviewBeforePresetUpdate.sourcePresetIds + ) && pendingCampSiegeUpdateState?.reportFormationEvaluation?.presetCards?.find((card) => card.id === 'siege')?.matching === false && sameJsonValue(pendingCampSiegeUpdateSave, midCampReviewSaveBefore), `Expected the first camp siege update click to request confirmation without mutating either save: ${JSON.stringify({ @@ -1620,7 +2019,7 @@ try { const evaluation = window.__HEROS_DEBUG__?.camp()?.reportFormationEvaluation; return evaluation?.open === true && evaluation?.overwriteConfirmId === null && - evaluation?.sourcePresetIds?.includes('siege') && + evaluation?.presetCards?.find((card) => card.id === 'siege')?.matching === true && evaluation?.feedback?.includes('갱신 완료'); }, undefined, { timeout: 30000 }); const updatedCampSiegeState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()); @@ -1645,6 +2044,10 @@ try { sameJsonValue(updatedCampSiegeSave.slot1?.sortieItemAssignments, midCampReviewSaveBefore.slot1?.sortieItemAssignments) && sameJsonValue(updatedCampSiegeSave.current?.firstBattleReport?.sortiePerformance, midResultPerformance) && sameJsonValue(updatedCampSiegeSave.slot1?.battleHistory?.[midCampNextBattleId]?.sortiePerformance, midResultPerformance) && + sameJsonValue(updatedCampSiegeSave.current?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) && + sameJsonValue(updatedCampSiegeSave.slot1?.firstBattleReport?.sortieReview, midResultSortieReviewBeforePresetUpdate) && + sameJsonValue(updatedCampSiegeSave.current?.battleHistory?.[midCampNextBattleId]?.sortieReview, midResultSortieReviewBeforePresetUpdate) && + sameJsonValue(updatedCampSiegeSave.slot1?.battleHistory?.[midCampNextBattleId]?.sortieReview, midResultSortieReviewBeforePresetUpdate) && sameJsonValue(updatedCampSiegeSave.current?.sortieFormationPresets?.mobile, midCampReviewSaveBefore.current?.sortieFormationPresets?.mobile) && sameJsonValue(updatedCampSiegeSave.current?.sortieFormationPresets?.elite, midCampReviewSaveBefore.current?.sortieFormationPresets?.elite) && JSON.stringify(updatedCampSiegeState?.selectedSortieUnitIds) === JSON.stringify(midCampReviewState?.selectedSortieUnitIds) && @@ -2078,6 +2481,50 @@ async function readCampReportFormationEvaluationControlPoint(page, control, pres return probe; } +async function readCampReportFormationHistoryControlPoint(page, control) { + const probe = await page.evaluate((control) => { + const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); + const history = window.__HEROS_DEBUG__?.camp()?.reportFormationHistory; + const canvas = document.querySelector('canvas'); + const bounds = control === 'toggle' + ? history?.toggleButtonBounds + : control === 'close' + ? history?.closeButtonBounds + : control === 'best' + ? history?.bestButtonBounds + : control === 'loadBest' + ? history?.loadBestButtonBounds + : history?.undoButtonBounds; + if (!scene || !canvas || !bounds) { + return { + ready: false, + control, + historyOpen: history?.open ?? false, + loadUndoAvailable: history?.loadUndoAvailable ?? 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, + historyOpen: history?.open ?? false, + loadUndoAvailable: history?.loadUndoAvailable ?? false, + bounds, + x: canvasBounds.left + centerX * canvasBounds.width / scene.scale.width, + y: canvasBounds.top + centerY * canvasBounds.height / scene.scale.height + }; + }, control); + assert( + probe.ready === true && Number.isFinite(probe.x) && Number.isFinite(probe.y), + `Expected a visible Phaser camp formation-history ${control} control with a canvas-scaled click point: ${JSON.stringify(probe)}` + ); + return probe; +} + async function readCampContentTextControlPoint(page, label, occurrence = 0) { const probe = await page.evaluate(({ label, occurrence }) => { const scene = window.__HEROS_GAME__?.scene.getScene('CampScene'); @@ -2249,6 +2696,7 @@ async function seedCampaignSave(page, options) { createdAt: now }; delete report.sortiePerformance; + delete report.sortieReview; const settlement = { battleId: report.battleId, battleTitle: report.battleTitle, diff --git a/src/game/data/sortiePerformanceReview.ts b/src/game/data/sortiePerformanceReview.ts index e519b4a..70aae21 100644 --- a/src/game/data/sortiePerformanceReview.ts +++ b/src/game/data/sortiePerformanceReview.ts @@ -1,7 +1,9 @@ import { coreSortieSynergyRoles } from './sortieSynergy'; import type { CampaignSortiePerformanceSnapshot, - CampaignSortiePreset + CampaignSortiePreset, + CampaignSortiePresetId, + CampaignSortieReviewSnapshot } from '../state/campaignState'; export type SortiePerformanceGrade = 'S' | 'A' | 'B' | 'C' | 'D'; @@ -68,6 +70,18 @@ const gradeHeadlines: Record = { D: '재도전 전 전면적인 재편이 필요한 편성' }; +export function sortiePerformanceGradeTone(grade: SortiePerformanceGrade) { + return { ...gradeTone[grade] }; +} + +export function sortiePerformanceGradeHeadline(grade: SortiePerformanceGrade) { + return gradeHeadlines[grade]; +} + +export function sortiePerformanceGradeForScore(score: number): SortiePerformanceGrade { + return score >= 90 ? 'S' : score >= 75 ? 'A' : score >= 60 ? 'B' : score >= 45 ? 'C' : 'D'; +} + const roleLabels = { front: '전열 방호', flank: '돌파 선봉', @@ -118,7 +132,7 @@ export function evaluateSortiePerformanceReview(input: SortiePerformanceReviewIn Math.min(5, input.activeBondCount * 2) + Math.min(5, contributionScore / 12) ))); - const grade: SortiePerformanceGrade = score >= 90 ? 'S' : score >= 75 ? 'A' : score >= 60 ? 'B' : score >= 45 ? 'C' : 'D'; + const grade = sortiePerformanceGradeForScore(score); const strengths = [ objectiveAchieved === input.objectives.length && input.objectives.length > 0 ? `목표 ${objectiveAchieved}/${input.objectives.length} 완수` : '', survivorCount === deployedUnits.length && deployedUnits.length > 0 ? '출전 전원 생존' : '', @@ -193,3 +207,18 @@ export function sortiePerformanceMatchesPreset( snapshot.selectedUnitIds.every((unitId) => preset.formationAssignments[unitId] === snapshot.formationAssignments[unitId]) ); } + +export function createCampaignSortieReviewSnapshot( + review: SortiePerformanceReview, + enemyCount: number, + sourcePresetIds: CampaignSortiePresetId[] +): CampaignSortieReviewSnapshot { + return { + version: 1, + score: review.score, + grade: review.grade, + activeBondCount: review.activeBondCount, + enemyCount: Math.max(0, Math.floor(enemyCount)), + sourcePresetIds: [...new Set(sourcePresetIds)] + }; +} diff --git a/src/game/scenes/BattleScene.ts b/src/game/scenes/BattleScene.ts index cd72245..77bdc6a 100644 --- a/src/game/scenes/BattleScene.ts +++ b/src/game/scenes/BattleScene.ts @@ -50,6 +50,7 @@ import { sortieRoleEffectSummaries } from '../data/sortieSynergy'; import { + createCampaignSortieReviewSnapshot, evaluateSortiePerformanceReview, sortiePerformanceMatchesPreset, sortiePerformanceRoleContributionLine, @@ -10656,6 +10657,14 @@ export class BattleScene extends Phaser.Scene { const rewardSnapshot = this.resultCampaignRewards(outcome); const defeatedEnemies = battleUnits.filter((unit) => unit.faction === 'enemy' && unit.hp <= 0).length; const totalEnemies = battleUnits.filter((unit) => unit.faction === 'enemy').length; + const sortiePerformance = this.resultSortiePerformanceSnapshot(); + const sortieReview = sortiePerformance + ? createCampaignSortieReviewSnapshot( + this.resultFormationReview(outcome, sortiePerformance), + totalEnemies, + this.resultFormationSourcePresetIds(sortiePerformance) + ) + : undefined; setFirstBattleReport({ battleId: battleScenario.id, @@ -10681,7 +10690,8 @@ export class BattleScene extends Phaser.Scene { : undefined, itemRewards: this.resultItemRewards(outcome), campaignRewards: rewardSnapshot, - sortiePerformance: this.resultSortiePerformanceSnapshot(), + sortiePerformance, + sortieReview, completedCampDialogues: [], completedCampVisits: [], createdAt: new Date().toISOString() diff --git a/src/game/scenes/CampScene.ts b/src/game/scenes/CampScene.ts index 7925f9c..5af5048 100644 --- a/src/game/scenes/CampScene.ts +++ b/src/game/scenes/CampScene.ts @@ -40,6 +40,9 @@ import { } from '../data/sortieSynergy'; import { evaluateSortiePerformanceReview, + sortiePerformanceGradeForScore, + sortiePerformanceGradeHeadline, + sortiePerformanceGradeTone, sortiePerformanceMatchesPreset, sortiePerformanceRoleContributionLine, type SortiePerformanceReview @@ -132,6 +135,7 @@ import { setCampaignReserveTrainingAssignment, setCampaignReserveTrainingFocus, type CampaignSaveSlotSummary, + type CampaignBattleSettlement, type CampaignStep, type CampaignState, type CampaignReserveTrainingFocusDefinition, @@ -447,6 +451,63 @@ type ReportFormationReviewView = { presetButtons: Partial>; }; +type ReportFormationHistoryRecord = { + battleId: string; + battleTitle: string; + completedAt: string; + outcome: FirstBattleReport['outcome']; + review: SortiePerformanceReview; + sourcePresetIds: CampaignSortiePresetId[]; + unitNames: Record; + endUnits: Record; +}; + +type ReportFormationHistoryProjection = { + battleId: string; + selectedUnitIds: string[]; + formationAssignments: SortieFormationAssignments; + itemAssignments: CampaignSortieItemAssignments; + omittedUnitNames: string[]; +}; + +type ReportFormationHistoryComparison = { + currentCount: number; + projectedCount: number; + commonCount: number; + incomingUnitNames: string[]; + outgoingUnitNames: string[]; + roleChangeCount: number; + currentRoleCoverage: number; + projectedRoleCoverage: number; + currentBondCount: number; + projectedBondCount: number; + matchesCurrent: boolean; +}; + +type ReportFormationPresetAverage = { + presetId: CampaignSortiePresetId; + count: number; + averageScore: number | null; + bestScore: number | null; + grade: SortiePerformanceReview['grade'] | null; +}; + +type ReportFormationHistoryUndoState = { + battleId: string; + before: SortieConfigurationSnapshot; + applied: SortieConfigurationSnapshot; +}; + +type ReportFormationHistoryView = { + closeButton: Phaser.GameObjects.Rectangle; + rowButtons: Record; + previousButton?: Phaser.GameObjects.Rectangle; + nextButton?: Phaser.GameObjects.Rectangle; + bestButton?: Phaser.GameObjects.Rectangle; + loadBestButton?: Phaser.GameObjects.Rectangle; + undoButton?: Phaser.GameObjects.Rectangle; +}; + type SortieComparisonAction = { kind: 'confirm-swap' | 'undo-swap' | 'confirm-preset' | 'undo-preset'; label: string; @@ -765,6 +826,8 @@ const sortiePresetDefinitions: { { id: 'siege', label: '공성', summary: '전열·원거리 중심', accent: 0xc87552 } ]; +const reportFormationHistoryPageSize = 7; + const defaultSortieRule: SortieRuleDefinition = { maxUnits: maxSortieUnits, requiredUnitIds: defaultRequiredSortieUnitIds, @@ -10912,6 +10975,14 @@ export class CampScene extends Phaser.Scene { private reportFormationPresetOverwriteConfirmId?: CampaignSortiePresetId; private reportFormationReviewToggleButton?: Phaser.GameObjects.Rectangle; private reportFormationReviewView?: ReportFormationReviewView; + private reportFormationHistoryObjects: Phaser.GameObjects.GameObject[] = []; + private reportFormationHistoryVisible = false; + private reportFormationHistoryPage = 0; + private reportFormationHistorySelectedBattleId?: string; + private reportFormationHistoryFeedback = ''; + private reportFormationHistoryToggleButton?: Phaser.GameObjects.Rectangle; + private reportFormationHistoryView?: ReportFormationHistoryView; + private reportFormationHistoryUndoState?: ReportFormationHistoryUndoState; private tabButtons: CampTabButtonView[] = []; private visitedTabs = new Set(); private selectedSortieUnitIds: string[] = []; @@ -10968,6 +11039,14 @@ export class CampScene extends Phaser.Scene { this.reportFormationPresetOverwriteConfirmId = undefined; this.reportFormationReviewToggleButton = undefined; this.reportFormationReviewView = undefined; + this.reportFormationHistoryObjects = []; + this.reportFormationHistoryVisible = false; + this.reportFormationHistoryPage = 0; + this.reportFormationHistorySelectedBattleId = undefined; + this.reportFormationHistoryFeedback = ''; + this.reportFormationHistoryToggleButton = undefined; + this.reportFormationHistoryView = undefined; + this.reportFormationHistoryUndoState = undefined; this.tabButtons = []; this.visitedTabs = new Set(['status']); this.campaign = getCampaignState(); @@ -12380,6 +12459,12 @@ export class CampScene extends Phaser.Scene { return; } + if (this.reportFormationHistoryVisible) { + soundDirector.playSelect(); + this.hideReportFormationHistory(); + return; + } + if (this.sortieObjects.length > 0) { soundDirector.playSelect(); this.hideSortiePrep(); @@ -12465,6 +12550,7 @@ export class CampScene extends Phaser.Scene { private render() { this.hideReportFormationReview(); + this.hideReportFormationHistory(); this.hideCampSaveSlotPanel(); this.clearContent(); this.visitedTabs.add(this.activeTab); @@ -12503,6 +12589,7 @@ export class CampScene extends Phaser.Scene { private showSortiePrep() { const wasVisible = this.sortieObjects.length > 0; this.hideReportFormationReview(); + this.hideReportFormationHistory(); this.hideCampSaveSlotPanel(); this.hideSortiePrep(false); this.campaign = getCampaignState(); @@ -16638,6 +16725,7 @@ export class CampScene extends Phaser.Scene { } this.reportFormationReviewToggleButton = undefined; + this.reportFormationHistoryToggleButton = undefined; const x = 42; const y = 590; const bg = this.track(this.add.rectangle(x, y, 1180, 84, 0x101820, 0.86)); @@ -16648,7 +16736,7 @@ export class CampScene extends Phaser.Scene { this.add.text( x + 18, y + 39, - this.compactText(`MVP ${report.mvp?.name ?? '-'} · 전투 보상 ${report.rewardGold} · 전리품 ${report.itemRewards.join(', ') || '없음'}`, 66), + this.compactText(`MVP ${report.mvp?.name ?? '-'} · 전투 보상 ${report.rewardGold} · 전리품 ${report.itemRewards.join(', ') || '없음'}`, 56), this.textStyle(14, '#d4dce6') ) ); @@ -16656,21 +16744,39 @@ export class CampScene extends Phaser.Scene { this.add.text( x + 18, y + 62, - this.compactText(this.reportCampaignRewardLine(report), 70), + this.compactText(this.reportCampaignRewardLine(report), 64), this.textStyle(12, '#d8b15f', true) ) ); const review = this.reportFormationReview(); + const historyRecords = this.reportFormationHistoryRecords(); + const actions: { label: string; action: () => void; assign: (button: Phaser.GameObjects.Rectangle) => void }[] = []; if (review) { - this.reportFormationReviewToggleButton = this.addReportFormationReviewToggleButton( - `편성 평가 ${review.grade}`, - x + 1084, - y + 42, - 158, - () => this.showReportFormationReview() - ); + actions.push({ + label: `최근 평가 ${review.grade}`, + action: () => this.showReportFormationReview(), + assign: (button) => { this.reportFormationReviewToggleButton = button; } + }); } + if (historyRecords.length > 0) { + actions.push({ + label: '편성 전적표', + action: () => this.showReportFormationHistory(), + assign: (button) => { this.reportFormationHistoryToggleButton = button; } + }); + } + actions.forEach((action, index) => { + const reverseIndex = actions.length - index - 1; + const button = this.addReportFormationReviewToggleButton( + action.label, + x + 1088 - reverseIndex * 152, + y + 42, + 142, + action.action + ); + action.assign(button); + }); } private reportFormationReview(): SortiePerformanceReview | undefined { @@ -16696,15 +16802,29 @@ export class CampScene extends Phaser.Scene { } const selectedIds = new Set(snapshot.selectedUnitIds); - const activeBondCount = report.bonds.filter((bond) => bond.unitIds.every((unitId) => selectedIds.has(unitId))).length; - return evaluateSortiePerformanceReview({ + const storedReview = settlement?.sortieReview ?? report.sortieReview; + const activeBondCount = storedReview?.activeBondCount ?? report.bonds.filter((bond) => bond.unitIds.every((unitId) => selectedIds.has(unitId))).length; + const review = evaluateSortiePerformanceReview({ outcome: settlement?.outcome ?? report.outcome, objectives: settlement?.objectives ?? report.objectives, - enemyCount: report.totalEnemies, + enemyCount: storedReview?.enemyCount ?? report.totalEnemies, activeBondCount, endUnits, snapshot }); + if (!storedReview) { + return review; + } + const tone = sortiePerformanceGradeTone(storedReview.grade); + return { + ...review, + score: storedReview.score, + grade: storedReview.grade, + gradeColor: tone.color, + gradeTextColor: tone.text, + headline: sortiePerformanceGradeHeadline(storedReview.grade), + activeBondCount: storedReview.activeBondCount + }; } private reportFormationSourcePresetIds(snapshot: CampaignSortiePerformanceSnapshot) { @@ -16712,6 +16832,262 @@ export class CampScene extends Phaser.Scene { return campaignSortiePresetIds.filter((presetId) => sortiePerformanceMatchesPreset(snapshot, presets[presetId])); } + private reportFormationHistoryRecords(): ReportFormationHistoryRecord[] { + const campaign = this.campaign ?? getCampaignState(); + return Object.values(campaign.battleHistory) + .map((settlement) => this.reportFormationHistoryRecord(settlement)) + .filter((record): record is ReportFormationHistoryRecord => Boolean(record)) + .sort((left, right) => ( + String(right.completedAt).localeCompare(String(left.completedAt)) || + right.battleId.localeCompare(left.battleId) + )); + } + + private reportFormationHistoryRecord(settlement: CampaignBattleSettlement): ReportFormationHistoryRecord | undefined { + const snapshot = settlement.sortiePerformance; + const storedReview = settlement.sortieReview; + if (!snapshot?.selectedUnitIds.length || !storedReview) { + return undefined; + } + + const endUnits = settlement.units.map((unit) => ({ id: unit.unitId, hp: unit.hp, maxHp: unit.maxHp })); + const endUnitIds = new Set(endUnits.map((unit) => unit.id)); + if (!snapshot.selectedUnitIds.every((unitId) => endUnitIds.has(unitId))) { + return undefined; + } + + const review = evaluateSortiePerformanceReview({ + outcome: settlement.outcome, + objectives: settlement.objectives, + enemyCount: storedReview.enemyCount, + activeBondCount: storedReview.activeBondCount, + endUnits, + snapshot + }); + const tone = sortiePerformanceGradeTone(storedReview.grade); + const resolvedReview = { + ...review, + score: storedReview.score, + grade: storedReview.grade, + gradeColor: tone.color, + gradeTextColor: tone.text, + headline: sortiePerformanceGradeHeadline(storedReview.grade), + activeBondCount: storedReview.activeBondCount + }; + return { + battleId: settlement.battleId, + battleTitle: settlement.battleTitle, + completedAt: settlement.completedAt, + outcome: settlement.outcome, + review: resolvedReview, + sourcePresetIds: [...storedReview.sourcePresetIds], + unitNames: Object.fromEntries(settlement.units.map((unit) => [unit.unitId, unit.name])), + endUnits: Object.fromEntries(settlement.units.map((unit) => [unit.unitId, { hp: unit.hp, maxHp: unit.maxHp }])) + }; + } + + private reportFormationHistoryBestRecord(records = this.reportFormationHistoryRecords()) { + return [...records].sort((left, right) => this.compareReportFormationHistoryRecords(left, right))[0]; + } + + private reportFormationHistoryBestLoadableRecord(records = this.reportFormationHistoryRecords()) { + return [...records] + .sort((left, right) => this.compareReportFormationHistoryRecords(left, right)) + .find((record) => Boolean(this.reportFormationHistoryProjection(record))); + } + + private compareReportFormationHistoryRecords( + left: ReportFormationHistoryRecord, + right: ReportFormationHistoryRecord + ) { + const leftObjectiveRate = left.review.objectiveTotal > 0 ? left.review.objectiveAchieved / left.review.objectiveTotal : 1; + const rightObjectiveRate = right.review.objectiveTotal > 0 ? right.review.objectiveAchieved / right.review.objectiveTotal : 1; + const leftSurvivalRate = left.review.deployedCount > 0 ? left.review.survivorCount / left.review.deployedCount : 0; + const rightSurvivalRate = right.review.deployedCount > 0 ? right.review.survivorCount / right.review.deployedCount : 0; + return ( + right.review.score - left.review.score || + rightObjectiveRate - leftObjectiveRate || + rightSurvivalRate - leftSurvivalRate || + String(right.completedAt).localeCompare(String(left.completedAt)) || + left.battleId.localeCompare(right.battleId) + ); + } + + private reportFormationPresetAverages(records = this.reportFormationHistoryRecords()): ReportFormationPresetAverage[] { + return campaignSortiePresetIds.map((presetId) => { + const matchingRecords = records.filter((record) => record.sourcePresetIds.includes(presetId)); + if (matchingRecords.length === 0) { + return { presetId, count: 0, averageScore: null, bestScore: null, grade: null }; + } + const rawAverage = matchingRecords.reduce((total, record) => total + record.review.score, 0) / matchingRecords.length; + const averageScore = Math.round(rawAverage * 10) / 10; + return { + presetId, + count: matchingRecords.length, + averageScore, + bestScore: Math.max(...matchingRecords.map((record) => record.review.score)), + grade: sortiePerformanceGradeForScore(averageScore) + }; + }); + } + + private ensureReportFormationHistorySelection(records: ReportFormationHistoryRecord[]) { + if (records.length === 0) { + this.reportFormationHistorySelectedBattleId = undefined; + this.reportFormationHistoryPage = 0; + return; + } + let selectedIndex = records.findIndex((record) => record.battleId === this.reportFormationHistorySelectedBattleId); + if (selectedIndex < 0) { + selectedIndex = 0; + this.reportFormationHistorySelectedBattleId = records[0].battleId; + } + const pageCount = Math.max(1, Math.ceil(records.length / reportFormationHistoryPageSize)); + this.reportFormationHistoryPage = Phaser.Math.Clamp( + Math.floor(selectedIndex / reportFormationHistoryPageSize), + 0, + pageCount - 1 + ); + } + + private reportFormationHistorySelectedRecord(records = this.reportFormationHistoryRecords()) { + return records.find((record) => record.battleId === this.reportFormationHistorySelectedBattleId) ?? records[0]; + } + + private selectReportFormationHistoryRecord(battleId: string) { + const records = this.reportFormationHistoryRecords(); + const index = records.findIndex((record) => record.battleId === battleId); + if (index < 0) { + return; + } + this.reportFormationHistorySelectedBattleId = battleId; + this.reportFormationHistoryPage = Math.floor(index / reportFormationHistoryPageSize); + this.reportFormationHistoryFeedback = ''; + soundDirector.playSelect(); + this.renderReportFormationHistory(); + } + + private changeReportFormationHistoryPage(delta: number) { + const records = this.reportFormationHistoryRecords(); + if (records.length === 0) { + return; + } + const pageCount = Math.max(1, Math.ceil(records.length / reportFormationHistoryPageSize)); + const nextPage = Phaser.Math.Clamp(this.reportFormationHistoryPage + delta, 0, pageCount - 1); + if (nextPage === this.reportFormationHistoryPage) { + return; + } + this.reportFormationHistoryPage = nextPage; + this.reportFormationHistorySelectedBattleId = records[nextPage * reportFormationHistoryPageSize]?.battleId; + this.reportFormationHistoryFeedback = ''; + soundDirector.playSelect(); + this.renderReportFormationHistory(); + } + + private reportFormationHistoryProjection( + record: ReportFormationHistoryRecord | undefined + ): ReportFormationHistoryProjection | undefined { + if (!record || !this.nextSortieScenario()) { + return undefined; + } + const availableUnits = this.sortieAllies(); + const availableIds = new Set(availableUnits.map((unit) => unit.id)); + const eligibleUnitIds = record.review.snapshot.selectedUnitIds.filter((unitId) => availableIds.has(unitId)); + if (eligibleUnitIds.length === 0) { + return undefined; + } + const selectedUnitIds = this.normalizedSortieUnitIds(eligibleUnitIds); + if (!selectedUnitIds.some((unitId) => eligibleUnitIds.includes(unitId))) { + return undefined; + } + const selectedIdSet = new Set(selectedUnitIds); + const historicalIdSet = new Set(record.review.snapshot.selectedUnitIds); + const rosterById = new Map(this.sortieRosterUnits().map((unit) => [unit.id, unit])); + const scenario = this.nextSortieScenario(); + const formationAssignments = selectedUnitIds.reduce((assignments, unitId) => { + const unit = rosterById.get(unitId); + if (!unit) { + return assignments; + } + assignments[unitId] = historicalIdSet.has(unitId) && record.review.snapshot.formationAssignments[unitId] + ? record.review.snapshot.formationAssignments[unitId] + : this.sortieFormationRole(unit, scenario); + return assignments; + }, {}); + const itemAssignments = Object.entries(this.cloneSortieItemAssignments(this.sortieItemAssignments)) + .reduce((assignments, [unitId, stocks]) => { + if (selectedIdSet.has(unitId)) { + assignments[unitId] = { ...stocks }; + } + return assignments; + }, {}); + return { + battleId: record.battleId, + selectedUnitIds, + formationAssignments: normalizeSortieFormationAssignments(formationAssignments, selectedIdSet), + itemAssignments, + omittedUnitNames: record.review.snapshot.selectedUnitIds + .filter((unitId) => !selectedIdSet.has(unitId)) + .map((unitId) => record.unitNames[unitId] ?? this.unitName(unitId)) + }; + } + + private reportFormationHistoryProjectionMatchesCurrent(projection: ReportFormationHistoryProjection | undefined) { + if (!projection || JSON.stringify(projection.selectedUnitIds) !== JSON.stringify(this.selectedSortieUnitIds)) { + return false; + } + const scenario = this.nextSortieScenario(); + const rosterById = new Map(this.sortieRosterUnits().map((unit) => [unit.id, unit])); + return projection.selectedUnitIds.every((unitId) => ( + rosterById.has(unitId) && + this.sortieFormationRole(rosterById.get(unitId)!, scenario, projection.formationAssignments) === + this.sortieFormationRole(rosterById.get(unitId)!, scenario, this.sortieFormationAssignments) + )); + } + + private reportFormationHistoryComparison( + projection: ReportFormationHistoryProjection | undefined + ): ReportFormationHistoryComparison | undefined { + if (!projection) { + return undefined; + } + const currentIds = new Set(this.selectedSortieUnitIds); + const projectedIds = new Set(projection.selectedUnitIds); + const commonIds = this.selectedSortieUnitIds.filter((unitId) => projectedIds.has(unitId)); + const scenario = this.nextSortieScenario(); + const rosterById = new Map(this.sortieRosterUnits().map((unit) => [unit.id, unit])); + const currentSynergy = this.sortieSynergySnapshot(); + const projectedSynergy = this.sortieSynergySnapshot( + projection.selectedUnitIds, + this.nextSortieScenario(), + projection.formationAssignments + ); + return { + currentCount: this.selectedSortieUnitIds.length, + projectedCount: projection.selectedUnitIds.length, + commonCount: commonIds.length, + incomingUnitNames: projection.selectedUnitIds + .filter((unitId) => !currentIds.has(unitId)) + .map((unitId) => this.unitName(unitId)), + outgoingUnitNames: this.selectedSortieUnitIds + .filter((unitId) => !projectedIds.has(unitId)) + .map((unitId) => this.unitName(unitId)), + roleChangeCount: commonIds.filter((unitId) => { + const unit = rosterById.get(unitId); + return Boolean( + unit && + this.sortieFormationRole(unit, scenario, projection.formationAssignments) !== + this.sortieFormationRole(unit, scenario, this.sortieFormationAssignments) + ); + }).length, + currentRoleCoverage: currentSynergy.coveredRoleCount, + projectedRoleCoverage: projectedSynergy.coveredRoleCount, + currentBondCount: currentSynergy.activeBondCount, + projectedBondCount: projectedSynergy.activeBondCount, + matchesCurrent: this.reportFormationHistoryProjectionMatchesCurrent(projection) + }; + } + private addReportFormationReviewToggleButton( label: string, x: number, @@ -16755,6 +17131,7 @@ export class CampScene extends Phaser.Scene { if (!this.reportFormationReview()) { return; } + this.hideReportFormationHistory(); this.hideCampSaveSlotPanel(); this.reportFormationReviewVisible = true; this.renderReportFormationReview(); @@ -17059,6 +17436,550 @@ export class CampScene extends Phaser.Scene { return object; } + private showReportFormationHistory() { + const records = this.reportFormationHistoryRecords(); + if (records.length === 0) { + return; + } + this.hideReportFormationReview(); + this.hideCampSaveSlotPanel(); + this.ensureReportFormationHistorySelection(records); + this.reportFormationHistoryVisible = true; + this.renderReportFormationHistory(); + } + + private hideReportFormationHistory(resetFeedback = true) { + this.reportFormationHistoryObjects.forEach((object) => object.destroy()); + this.reportFormationHistoryObjects = []; + this.reportFormationHistoryVisible = false; + this.reportFormationHistoryView = undefined; + if (resetFeedback) { + this.reportFormationHistoryFeedback = ''; + } + } + + private renderReportFormationHistory() { + const records = this.reportFormationHistoryRecords(); + if (records.length === 0) { + this.hideReportFormationHistory(); + return; + } + this.ensureReportFormationHistorySelection(records); + const selectedRecord = this.reportFormationHistorySelectedRecord(records); + const bestRecord = this.reportFormationHistoryBestRecord(records); + const bestLoadableRecord = this.reportFormationHistoryBestLoadableRecord(records); + const presetAverages = this.reportFormationPresetAverages(records); + const bestProjection = this.reportFormationHistoryProjection(bestLoadableRecord); + const comparison = this.reportFormationHistoryComparison(bestProjection); + const pageCount = Math.max(1, Math.ceil(records.length / reportFormationHistoryPageSize)); + const pageStart = this.reportFormationHistoryPage * reportFormationHistoryPageSize; + const visibleRecords = records.slice(pageStart, pageStart + reportFormationHistoryPageSize); + + this.reportFormationHistoryObjects.forEach((object) => object.destroy()); + this.reportFormationHistoryObjects = []; + this.reportFormationHistoryView = undefined; + const depth = 52; + const x = 46; + const y = 22; + const width = 1188; + const height = 676; + const shade = this.trackReportFormationHistory(this.add.rectangle(0, 0, this.scale.width, this.scale.height, 0x020406, 0.8)); + shade.setOrigin(0); + shade.setDepth(depth); + shade.setInteractive(); + const panel = this.trackReportFormationHistory(this.add.rectangle(x, y, width, height, 0x0b1219, 0.995)); + panel.setOrigin(0); + panel.setDepth(depth + 1); + panel.setStrokeStyle(3, palette.gold, 0.9); + panel.setInteractive(); + + this.trackReportFormationHistory(this.add.text(x + 24, y + 18, '편성 전적표', this.textStyle(28, '#f2e3bf', true))) + .setDepth(depth + 2); + this.trackReportFormationHistory( + this.add.text( + x + 24, + y + 54, + '전투별 편성 성과를 비교하고 검증된 명단·역할을 다음 출전에 불러옵니다.', + this.textStyle(13, '#b9c3cf') + ) + ).setDepth(depth + 2); + const closeButton = this.addReportFormationHistoryButton( + '군영으로', + x + width - 120, + y + 18, + 96, + 30, + true, + () => { + soundDirector.playSelect(); + this.hideReportFormationHistory(); + }, + depth + 3 + ); + this.reportFormationHistoryView = { closeButton, rowButtons: {} }; + + this.renderReportFormationHistoryList( + records, + visibleRecords, + pageCount, + 70, + 112, + 292, + 534, + depth + 2 + ); + this.renderReportFormationHistoryDetail(selectedRecord, 378, 112, 832, 208, depth + 2); + this.renderReportFormationHistoryBest(bestRecord, 378, 334, 400, 190, depth + 2); + this.renderReportFormationHistoryAverages(presetAverages, 790, 334, 420, 190, depth + 2); + this.renderReportFormationHistoryComparison( + bestRecord, + bestLoadableRecord, + bestProjection, + comparison, + 378, + 538, + 832, + 108, + depth + 2 + ); + } + + private renderReportFormationHistoryList( + records: ReportFormationHistoryRecord[], + visibleRecords: ReportFormationHistoryRecord[], + pageCount: number, + x: number, + y: number, + width: number, + height: number, + depth: number + ) { + const background = this.trackReportFormationHistory(this.add.rectangle(x, y, width, height, 0x101820, 0.96)); + background.setOrigin(0); + background.setDepth(depth); + background.setStrokeStyle(1, palette.blue, 0.54); + this.trackReportFormationHistory(this.add.text(x + 16, y + 14, `최근 전투 · ${records.length}전`, this.textStyle(17, '#f2e3bf', true))) + .setDepth(depth + 1); + + visibleRecords.forEach((record, index) => { + const rowX = x + 16; + const rowY = y + 42 + index * 58; + const selected = record.battleId === this.reportFormationHistorySelectedBattleId; + const row = this.trackReportFormationHistory(this.add.rectangle(rowX, rowY, width - 32, 52, selected ? 0x25384a : 0x151f2a, selected ? 0.98 : 0.9)); + row.setOrigin(0); + row.setDepth(depth + 1); + row.setStrokeStyle(1, selected ? palette.gold : record.review.gradeColor, selected ? 0.9 : 0.4); + row.setInteractive({ useHandCursor: true }); + row.on('pointerover', () => row.setFillStyle(selected ? 0x31495d : 0x22303c, 0.98)); + row.on('pointerout', () => row.setFillStyle(selected ? 0x25384a : 0x151f2a, selected ? 0.98 : 0.9)); + row.on('pointerdown', () => this.selectReportFormationHistoryRecord(record.battleId)); + if (this.reportFormationHistoryView) { + this.reportFormationHistoryView.rowButtons[record.battleId] = row; + } + + const grade = this.trackReportFormationHistory(this.add.circle(rowX + 22, rowY + 26, 15, record.review.gradeColor, 0.22)); + grade.setDepth(depth + 2); + grade.setStrokeStyle(1, record.review.gradeColor, 0.88); + this.trackReportFormationHistory(this.add.text(rowX + 22, rowY + 26, record.review.grade, this.textStyle(15, record.review.gradeTextColor, true))) + .setOrigin(0.5) + .setDepth(depth + 3); + this.trackReportFormationHistory(this.add.text(rowX + 46, rowY + 7, this.compactText(record.battleTitle, 16), this.textStyle(13, '#f2e3bf', true))) + .setDepth(depth + 2); + const dateText = this.trackReportFormationHistory(this.add.text(rowX + width - 44, rowY + 8, this.formatCampSaveUpdatedAt(record.completedAt).split(' ')[0], this.textStyle(9, '#8593a0', true))); + dateText.setOrigin(1, 0); + dateText.setDepth(depth + 2); + this.trackReportFormationHistory( + this.add.text( + rowX + 46, + rowY + 29, + `${record.review.score}점 · 목표 ${record.review.objectiveAchieved}/${record.review.objectiveTotal} · 생존 ${record.review.survivorCount}/${record.review.deployedCount}`, + this.textStyle(10, '#aeb8c3', true) + ) + ).setDepth(depth + 2); + }); + + const previousButton = this.addReportFormationHistoryButton( + '이전', + x + 16, + y + 482, + 72, + 30, + this.reportFormationHistoryPage > 0, + () => this.changeReportFormationHistoryPage(-1), + depth + 2, + palette.blue + ); + const nextButton = this.addReportFormationHistoryButton( + '다음', + x + width - 88, + y + 482, + 72, + 30, + this.reportFormationHistoryPage < pageCount - 1, + () => this.changeReportFormationHistoryPage(1), + depth + 2, + palette.blue + ); + this.trackReportFormationHistory(this.add.text(x + width / 2, y + 497, `${this.reportFormationHistoryPage + 1}/${pageCount}`, this.textStyle(12, '#d8b15f', true))) + .setOrigin(0.5) + .setDepth(depth + 2); + this.trackReportFormationHistory(this.add.text(x + 16, y + height - 16, `저장된 평가 ${records.length}전 · 최신순`, this.textStyle(10, '#8593a0', true))) + .setDepth(depth + 1); + if (this.reportFormationHistoryView) { + this.reportFormationHistoryView.previousButton = previousButton; + this.reportFormationHistoryView.nextButton = nextButton; + } + } + + private renderReportFormationHistoryDetail( + record: ReportFormationHistoryRecord, + x: number, + y: number, + width: number, + height: number, + depth: number + ) { + const review = record.review; + const background = this.trackReportFormationHistory(this.add.rectangle(x, y, width, height, 0x101820, 0.97)); + background.setOrigin(0); + background.setDepth(depth); + background.setStrokeStyle(1, review.gradeColor, 0.66); + const grade = this.trackReportFormationHistory(this.add.circle(x + 42, y + 58, 28, review.gradeColor, 0.22)); + grade.setDepth(depth + 1); + grade.setStrokeStyle(2, review.gradeColor, 0.9); + this.trackReportFormationHistory(this.add.text(x + 42, y + 58, review.grade, this.textStyle(27, review.gradeTextColor, true))) + .setOrigin(0.5) + .setDepth(depth + 2); + this.trackReportFormationHistory(this.add.text(x + 84, y + 16, this.compactText(`${record.battleTitle} · ${review.score}점`, 38), this.textStyle(20, review.gradeTextColor, true))) + .setDepth(depth + 1); + this.trackReportFormationHistory(this.add.text(x + 84, y + 49, `강점 · ${this.compactText(review.strengths.join(' · '), 62)}`, this.textStyle(11, '#d4dce6', true))) + .setDepth(depth + 1); + this.trackReportFormationHistory(this.add.text(x + 84, y + 70, `보완 · ${this.compactText(review.improvement, 62)}`, this.textStyle(11, '#ffdf7b', true))) + .setDepth(depth + 1); + + 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}` } + ]; + const metricGap = 8; + const metricWidth = Math.floor((width - 36 - metricGap * 3) / 4); + metrics.forEach((metric, index) => { + this.renderReportFormationHistoryMetric(metric.label, metric.value, x + 18 + index * (metricWidth + metricGap), y + 98, metricWidth, 44, depth + 1); + }); + + const visibleIds = review.snapshot.selectedUnitIds.slice(0, 6); + const hiddenCount = Math.max(0, review.snapshot.selectedUnitIds.length - visibleIds.length); + this.trackReportFormationHistory(this.add.text(x + 18, y + 151, `명단·역할${hiddenCount > 0 ? ` · 외 ${hiddenCount}명` : ''}`, this.textStyle(10, '#9fb0bf', true))) + .setDepth(depth + 1); + const chipGap = 6; + const chipWidth = Math.min(127, Math.floor((width - 36 - chipGap * Math.max(0, visibleIds.length - 1)) / Math.max(1, visibleIds.length))); + visibleIds.forEach((unitId, index) => { + const role = this.reportFormationRoleShortLabel(review.snapshot.formationAssignments[unitId]); + const endUnit = record.endUnits[unitId]; + const chipX = x + 18 + index * (chipWidth + chipGap); + const chip = this.trackReportFormationHistory(this.add.rectangle(chipX, y + 171, chipWidth, 26, endUnit?.hp ? 0x172a22 : 0x2a1b18, 0.94)); + chip.setOrigin(0); + chip.setDepth(depth + 1); + chip.setStrokeStyle(1, endUnit?.hp ? palette.green : palette.red, 0.46); + this.trackReportFormationHistory( + this.add.text( + chipX + 7, + y + 177, + this.compactText(`${role} ${record.unitNames[unitId] ?? this.unitName(unitId)} ${endUnit?.hp ?? 0}/${endUnit?.maxHp ?? 0}`, 15), + this.textStyle(9, '#e2e8ef', true) + ) + ).setDepth(depth + 2); + }); + } + + private renderReportFormationHistoryBest( + record: ReportFormationHistoryRecord, + x: number, + y: number, + width: number, + height: number, + depth: number + ) { + const background = this.trackReportFormationHistory(this.add.rectangle(x, y, width, height, 0x101820, 0.97)); + background.setOrigin(0); + background.setDepth(depth); + background.setStrokeStyle(1, record.review.gradeColor, 0.72); + background.setInteractive({ useHandCursor: true }); + background.on('pointerover', () => background.setFillStyle(0x172633, 0.99)); + background.on('pointerout', () => background.setFillStyle(0x101820, 0.97)); + background.on('pointerdown', () => this.selectReportFormationHistoryRecord(record.battleId)); + if (this.reportFormationHistoryView) { + this.reportFormationHistoryView.bestButton = background; + } + this.trackReportFormationHistory(this.add.text(x + 18, y + 14, '최고 편성', this.textStyle(17, '#f2e3bf', true))).setDepth(depth + 1); + const hint = this.trackReportFormationHistory(this.add.text(x + width - 18, y + 17, '선택해 상세 보기', this.textStyle(10, '#9fb0bf', true))); + hint.setOrigin(1, 0); + hint.setDepth(depth + 1); + const grade = this.trackReportFormationHistory(this.add.circle(x + 42, y + 64, 24, record.review.gradeColor, 0.22)); + grade.setDepth(depth + 1); + grade.setStrokeStyle(2, record.review.gradeColor, 0.9); + this.trackReportFormationHistory(this.add.text(x + 42, y + 64, record.review.grade, this.textStyle(24, record.review.gradeTextColor, true))) + .setOrigin(0.5) + .setDepth(depth + 2); + this.trackReportFormationHistory(this.add.text(x + 80, y + 42, this.compactText(record.battleTitle, 24), this.textStyle(16, record.review.gradeTextColor, true))).setDepth(depth + 1); + this.trackReportFormationHistory(this.add.text(x + 80, y + 68, `${record.review.score}점 · 목표 ${record.review.objectiveAchieved}/${record.review.objectiveTotal} · 생존 ${record.review.survivorCount}/${record.review.deployedCount}`, this.textStyle(11, '#d4dce6', true))).setDepth(depth + 1); + this.trackReportFormationHistory(this.add.text(x + 18, y + 98, `명단 ${record.review.deployedCount}명 · 역할 ${record.review.roleCoverage}/3 · 공명 ${record.review.activeBondCount}개`, this.textStyle(11, '#d8b15f', true))).setDepth(depth + 1); + + record.review.snapshot.selectedUnitIds.slice(0, 6).forEach((unitId, index) => { + const column = index % 3; + const row = Math.floor(index / 3); + const chipX = x + 18 + column * 122; + const chipY = y + 120 + row * 32; + const role = this.reportFormationRoleShortLabel(record.review.snapshot.formationAssignments[unitId]); + const chip = this.trackReportFormationHistory(this.add.rectangle(chipX, chipY, 116, 26, 0x151f2a, 0.94)); + chip.setOrigin(0); + chip.setDepth(depth + 1); + chip.setStrokeStyle(1, record.review.gradeColor, 0.36); + this.trackReportFormationHistory(this.add.text(chipX + 7, chipY + 6, this.compactText(`${role} ${record.unitNames[unitId] ?? this.unitName(unitId)}`, 12), this.textStyle(9, '#e2e8ef', true))).setDepth(depth + 2); + }); + } + + private renderReportFormationHistoryAverages( + averages: ReportFormationPresetAverage[], + x: number, + y: number, + width: number, + height: number, + depth: number + ) { + const background = this.trackReportFormationHistory(this.add.rectangle(x, y, width, height, 0x101820, 0.97)); + background.setOrigin(0); + background.setDepth(depth); + background.setStrokeStyle(1, palette.blue, 0.58); + this.trackReportFormationHistory(this.add.text(x + 18, y + 14, '편성책 평균', this.textStyle(17, '#f2e3bf', true))).setDepth(depth + 1); + this.trackReportFormationHistory(this.add.text(x + width - 18, y + 17, '출전 당시 저장본 기준', this.textStyle(10, '#9fb0bf', true))) + .setOrigin(1, 0) + .setDepth(depth + 1); + + averages.forEach((average, index) => { + const definition = this.sortiePresetDefinition(average.presetId); + const rowY = y + 46 + index * 45; + const row = this.trackReportFormationHistory(this.add.rectangle(x + 18, rowY, width - 36, 38, 0x151f2a, 0.94)); + row.setOrigin(0); + row.setDepth(depth + 1); + row.setStrokeStyle(1, definition.accent, 0.44); + const gradeColor = average.grade ? sortiePerformanceGradeTone(average.grade) : undefined; + const badge = this.trackReportFormationHistory(this.add.rectangle(x + 28, rowY + 7, 30, 24, definition.accent, 0.18)); + badge.setOrigin(0); + badge.setDepth(depth + 2); + badge.setStrokeStyle(1, definition.accent, 0.7); + this.trackReportFormationHistory(this.add.text(x + 43, rowY + 19, average.grade ?? '-', this.textStyle(13, gradeColor?.text ?? '#7f8994', true))) + .setOrigin(0.5) + .setDepth(depth + 3); + this.trackReportFormationHistory(this.add.text(x + 70, rowY + 9, definition.label, this.textStyle(13, '#f2e3bf', true))).setDepth(depth + 2); + const summary = average.count > 0 + ? `평균 ${average.averageScore?.toFixed(1)}점 · ${average.count}전 · 최고 ${average.bestScore}` + : '기록 없음'; + const summaryText = this.trackReportFormationHistory(this.add.text(x + width - 28, rowY + 10, summary, this.textStyle(11, average.count > 0 ? '#d4dce6' : '#7f8994', true))); + summaryText.setOrigin(1, 0); + summaryText.setDepth(depth + 2); + }); + } + + private renderReportFormationHistoryComparison( + bestRecord: ReportFormationHistoryRecord, + loadableRecord: ReportFormationHistoryRecord | undefined, + projection: ReportFormationHistoryProjection | undefined, + comparison: ReportFormationHistoryComparison | undefined, + x: number, + y: number, + width: number, + height: number, + depth: number + ) { + const background = this.trackReportFormationHistory(this.add.rectangle(x, y, width, height, 0x101820, 0.97)); + background.setOrigin(0); + background.setDepth(depth); + background.setStrokeStyle(1, palette.gold, 0.62); + const comparisonTitle = loadableRecord && loadableRecord.battleId !== bestRecord.battleId + ? '현재 출전안 vs 적용 가능 최고' + : '현재 출전안 vs 최고 편성'; + this.trackReportFormationHistory(this.add.text(x + 18, y + 12, comparisonTitle, this.textStyle(15, '#f2e3bf', true))).setDepth(depth + 1); + + const countLine = comparison + ? `명단 ${comparison.currentCount} ↔ ${comparison.projectedCount} · 공통 ${comparison.commonCount} · 교체 ${comparison.incomingUnitNames.length} · 역할 변경 ${comparison.roleChangeCount}` + : '현재 전장에 불러올 수 있는 최고 편성 무장이 없습니다.'; + const synergyLine = comparison + ? `역할 ${comparison.currentRoleCoverage}/3 ↔ ${comparison.projectedRoleCoverage}/3 · 공명 ${comparison.currentBondCount} ↔ ${comparison.projectedBondCount}${projection?.omittedUnitNames.length ? ` · 제외 ${projection.omittedUnitNames.length}명` : ''}` + : `최고 기록 · ${bestRecord.battleTitle} ${bestRecord.review.grade} ${bestRecord.review.score}점`; + this.trackReportFormationHistory(this.add.text(x + 18, y + 39, this.compactText(countLine, 62), this.textStyle(11, '#d4dce6', true))).setDepth(depth + 1); + this.trackReportFormationHistory(this.add.text(x + 18, y + 60, this.compactText(synergyLine, 62), this.textStyle(11, '#d8b15f', true))).setDepth(depth + 1); + const feedback = this.reportFormationHistoryFeedback || '명단·역할만 불러오며 공통 무장의 보급만 유지합니다.'; + this.trackReportFormationHistory(this.add.text(x + 18, y + 84, this.compactText(feedback, 66), this.textStyle(10, this.reportFormationHistoryFeedback ? '#a8ffd0' : '#9fb0bf', true))).setDepth(depth + 1); + + const canLoad = Boolean(this.nextSortieScenario() && projection && comparison && !comparison.matchesCurrent); + const loadLabel = !this.nextSortieScenario() + ? '다음 출전 없음' + : !projection + ? '불러오기 불가' + : comparison?.matchesCurrent + ? '현재 적용 중' + : projection.omittedUnitNames.length > 0 + ? `${projection.selectedUnitIds.length}명 불러오기` + : '최고 편성 불러오기'; + const loadButton = this.addReportFormationHistoryButton( + loadLabel, + x + width - 192, + y + 18, + 174, + 36, + canLoad, + () => this.loadBestReportFormationHistory(), + depth + 2, + palette.gold, + true + ); + if (this.reportFormationHistoryView) { + this.reportFormationHistoryView.loadBestButton = loadButton; + } + + if (this.reportFormationHistoryUndoAvailable()) { + const undoButton = this.addReportFormationHistoryButton( + '적용 되돌리기', + x + width - 192, + y + 62, + 174, + 30, + true, + () => this.undoReportFormationHistoryLoad(), + depth + 2, + palette.blue + ); + if (this.reportFormationHistoryView) { + this.reportFormationHistoryView.undoButton = undoButton; + } + } + } + + private loadBestReportFormationHistory() { + const records = this.reportFormationHistoryRecords(); + const bestRecord = this.reportFormationHistoryBestLoadableRecord(records); + const projection = this.reportFormationHistoryProjection(bestRecord); + const comparison = this.reportFormationHistoryComparison(projection); + if (!bestRecord || !projection || !comparison || !this.nextSortieScenario()) { + this.reportFormationHistoryFeedback = '현재 장면에는 최고 편성을 불러올 수 없습니다.'; + this.renderReportFormationHistory(); + return; + } + if (comparison.matchesCurrent) { + this.reportFormationHistoryFeedback = '최고 편성이 이미 현재 명단·역할에 적용되어 있습니다.'; + this.renderReportFormationHistory(); + return; + } + + const before = this.captureSortieConfiguration(); + this.reportFormationHistoryUndoState = undefined; + this.selectedSortieUnitIds = [...projection.selectedUnitIds]; + this.sortieFormationAssignments = { ...projection.formationAssignments }; + this.sortieItemAssignments = this.cloneSortieItemAssignments(projection.itemAssignments); + this.sortieFocusedUnitId = projection.selectedUnitIds.includes(this.sortieFocusedUnitId) + ? this.sortieFocusedUnitId + : projection.selectedUnitIds[0]; + this.sortieRosterScroll = 0; + this.sortiePlanFeedback = `${bestRecord.battleTitle} 최고 편성 적용 · 장비·보급 재점검`; + this.persistSortieSelection(); + this.reportFormationHistoryUndoState = { + battleId: bestRecord.battleId, + before, + applied: this.captureSortieConfiguration() + }; + this.reportFormationHistoryFeedback = `최고 편성 적용 · 명단 ${projection.selectedUnitIds.length}명과 역할 불러옴 · 공통 무장 보급 유지`; + soundDirector.playSelect(); + this.renderReportFormationHistory(); + } + + private reportFormationHistoryUndoAvailable() { + return Boolean( + this.reportFormationHistoryUndoState && + this.sortiePersistedConfigurationMatches(this.reportFormationHistoryUndoState.applied) + ); + } + + private undoReportFormationHistoryLoad() { + const undo = this.reportFormationHistoryUndoState; + if (!undo || !this.sortiePersistedConfigurationMatches(undo.applied)) { + this.reportFormationHistoryUndoState = undefined; + this.reportFormationHistoryFeedback = '이후 편성이 변경되어 최고 편성 적용을 되돌릴 수 없습니다.'; + this.renderReportFormationHistory(); + return; + } + this.reportFormationHistoryUndoState = 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.persistSortieSelection(); + this.reportFormationHistoryFeedback = '최고 편성 적용을 되돌리고 이전 명단·역할·보급으로 복원했습니다.'; + soundDirector.playSelect(); + this.renderReportFormationHistory(); + } + + private reportFormationRoleShortLabel(role?: SortieFormationRole) { + return role === 'front' ? '전' : role === 'flank' ? '돌' : role === 'support' ? '후' : '예'; + } + + private renderReportFormationHistoryMetric( + label: string, + value: string, + x: number, + y: number, + width: number, + height: number, + depth: number + ) { + const background = this.trackReportFormationHistory(this.add.rectangle(x, y, width, height, 0x111c26, 0.96)); + background.setOrigin(0); + background.setDepth(depth); + background.setStrokeStyle(1, palette.gold, 0.32); + this.trackReportFormationHistory(this.add.text(x + 9, y + 6, label, this.textStyle(9, '#9fb0bf', true))).setDepth(depth + 1); + const valueText = this.trackReportFormationHistory(this.add.text(x + width - 9, y + 22, this.compactText(value, 20), this.textStyle(12, '#f2e3bf', true))); + valueText.setOrigin(1, 0); + valueText.setDepth(depth + 1); + } + + private addReportFormationHistoryButton( + label: string, + x: number, + y: number, + width: number, + height: number, + enabled: boolean, + action: () => void, + depth: number, + accent: number = palette.gold, + primary = false + ) { + const fill = enabled ? (primary ? 0x2d3022 : 0x1a2630) : 0x111820; + const button = this.trackReportFormationHistory(this.add.rectangle(x, y, width, height, fill, enabled ? 0.98 : 0.62)); + button.setOrigin(0); + button.setDepth(depth); + button.setStrokeStyle(1, enabled ? accent : 0x53606c, enabled ? 0.72 : 0.3); + if (enabled) { + button.setInteractive({ useHandCursor: true }); + button.on('pointerover', () => button.setFillStyle(primary ? 0x49472b : 0x283947, 1).setStrokeStyle(1, palette.gold, 0.96)); + button.on('pointerout', () => button.setFillStyle(fill, 0.98).setStrokeStyle(1, accent, 0.72)); + button.on('pointerdown', action); + } + this.trackReportFormationHistory(this.add.text(x + width / 2, y + height / 2, this.compactText(label, 22), this.textStyle(11, enabled ? '#f2e3bf' : '#77818c', true))) + .setOrigin(0.5) + .setDepth(depth + 1); + return button; + } + + private trackReportFormationHistory(object: T) { + this.reportFormationHistoryObjects.push(object); + return object; + } + private renderProgressPanel() { const x = 394; const y = 120; @@ -18398,6 +19319,7 @@ export class CampScene extends Phaser.Scene { } private persistSortieSelection() { + this.reportFormationHistoryUndoState = undefined; this.sortieSwapUndoState = undefined; this.sortiePinnedSwapCandidateUnitId = undefined; this.sortiePresetUndoState = undefined; @@ -18632,6 +19554,7 @@ export class CampScene extends Phaser.Scene { this.contentObjects.forEach((object) => object.destroy()); this.contentObjects = []; this.reportFormationReviewToggleButton = undefined; + this.reportFormationHistoryToggleButton = undefined; } private sortieDeploymentPreviewDebug() { @@ -18669,9 +19592,23 @@ export class CampScene extends Phaser.Scene { const recommendedPresetId = this.recommendedSortiePresetId(); const sortieFormationPresets = this.sortieFormationPresets(); const reportFormationReview = this.reportFormationReview(); - const reportFormationSourcePresetIds = reportFormationReview + const reportFormationMatchingPresetIds = reportFormationReview ? this.reportFormationSourcePresetIds(reportFormationReview.snapshot) : []; + const reportFormationSourcePresetIds = this.report + ? this.campaign?.battleHistory[this.report.battleId]?.sortieReview?.sourcePresetIds ?? this.report.sortieReview?.sourcePresetIds ?? [] + : []; + const reportFormationHistoryRecords = this.reportFormationHistoryRecords(); + this.ensureReportFormationHistorySelection(reportFormationHistoryRecords); + const reportFormationHistorySelected = this.reportFormationHistorySelectedRecord(reportFormationHistoryRecords); + const reportFormationHistoryBest = this.reportFormationHistoryBestRecord(reportFormationHistoryRecords); + const reportFormationHistoryLoadableBest = this.reportFormationHistoryBestLoadableRecord(reportFormationHistoryRecords); + const reportFormationHistoryProjection = this.reportFormationHistoryProjection(reportFormationHistoryLoadableBest); + const reportFormationHistoryComparison = this.reportFormationHistoryComparison(reportFormationHistoryProjection); + const reportFormationHistoryPageCount = Math.max( + 1, + Math.ceil(reportFormationHistoryRecords.length / reportFormationHistoryPageSize) + ); return { scene: this.scene.key, activeTab: this.activeTab, @@ -18704,6 +19641,7 @@ export class CampScene extends Phaser.Scene { unitStats: reportFormationReview.snapshot.unitStats.map((stats) => ({ ...stats })) }, sourcePresetIds: [...reportFormationSourcePresetIds], + matchingPresetIds: [...reportFormationMatchingPresetIds], overwriteConfirmId: this.reportFormationPresetOverwriteConfirmId ?? null, feedback: this.reportFormationReviewFeedback, toggleButtonBounds: this.sortieObjectBoundsDebug(this.reportFormationReviewToggleButton), @@ -18712,7 +19650,7 @@ export class CampScene extends Phaser.Scene { id: definition.id, label: definition.label, saved: Boolean(sortieFormationPresets[definition.id]), - matching: reportFormationSourcePresetIds.includes(definition.id), + matching: reportFormationMatchingPresetIds.includes(definition.id), actionButtonBounds: this.sortieObjectBoundsDebug(this.reportFormationReviewView?.presetButtons[definition.id]) })) } @@ -18722,12 +19660,133 @@ export class CampScene extends Phaser.Scene { battleId: this.report?.battleId ?? null, battleTitle: this.report?.battleTitle ?? null, sourcePresetIds: [], + matchingPresetIds: [], overwriteConfirmId: null, feedback: '', toggleButtonBounds: null, closeButtonBounds: null, presetCards: [] }, + reportFormationHistory: reportFormationHistoryRecords.length > 0 + ? { + available: true, + open: this.reportFormationHistoryVisible, + recordCount: reportFormationHistoryRecords.length, + page: this.reportFormationHistoryPage, + pageCount: reportFormationHistoryPageCount, + pageSize: reportFormationHistoryPageSize, + selectedBattleId: reportFormationHistorySelected?.battleId ?? null, + records: reportFormationHistoryRecords.map((record) => ({ + battleId: record.battleId, + battleTitle: record.battleTitle, + completedAt: record.completedAt, + outcome: record.outcome, + grade: record.review.grade, + score: record.review.score, + objectiveAchieved: record.review.objectiveAchieved, + objectiveTotal: record.review.objectiveTotal, + survivorCount: record.review.survivorCount, + deployedCount: record.review.deployedCount, + roleCoverage: record.review.roleCoverage, + activeBondCount: record.review.activeBondCount, + sourcePresetIds: [...record.sourcePresetIds], + selected: record.battleId === reportFormationHistorySelected?.battleId, + rowBounds: this.sortieObjectBoundsDebug(this.reportFormationHistoryView?.rowButtons[record.battleId]) + })), + selected: reportFormationHistorySelected + ? { + battleId: reportFormationHistorySelected.battleId, + battleTitle: reportFormationHistorySelected.battleTitle, + grade: reportFormationHistorySelected.review.grade, + score: reportFormationHistorySelected.review.score, + headline: reportFormationHistorySelected.review.headline, + strengths: [...reportFormationHistorySelected.review.strengths], + improvement: reportFormationHistorySelected.review.improvement, + objectiveAchieved: reportFormationHistorySelected.review.objectiveAchieved, + objectiveTotal: reportFormationHistorySelected.review.objectiveTotal, + survivorCount: reportFormationHistorySelected.review.survivorCount, + deployedCount: reportFormationHistorySelected.review.deployedCount, + recoveryNeededCount: reportFormationHistorySelected.review.recoveryNeededCount, + totalDamage: reportFormationHistorySelected.review.totalDamage, + totalDamageTaken: reportFormationHistorySelected.review.totalDamageTaken, + totalDefeats: reportFormationHistorySelected.review.totalDefeats, + totalSupport: reportFormationHistorySelected.review.totalSupport, + roleCoverage: reportFormationHistorySelected.review.roleCoverage, + activeBondCount: reportFormationHistorySelected.review.activeBondCount, + sourcePresetIds: [...reportFormationHistorySelected.sourcePresetIds], + snapshot: { + selectedUnitIds: [...reportFormationHistorySelected.review.snapshot.selectedUnitIds], + formationAssignments: { ...reportFormationHistorySelected.review.snapshot.formationAssignments }, + unitStats: reportFormationHistorySelected.review.snapshot.unitStats.map((stats) => ({ ...stats })) + } + } + : null, + best: reportFormationHistoryBest + ? { + battleId: reportFormationHistoryBest.battleId, + battleTitle: reportFormationHistoryBest.battleTitle, + grade: reportFormationHistoryBest.review.grade, + score: reportFormationHistoryBest.review.score, + selectedUnitIds: [...reportFormationHistoryBest.review.snapshot.selectedUnitIds], + formationAssignments: { ...reportFormationHistoryBest.review.snapshot.formationAssignments } + } + : null, + loadableBest: reportFormationHistoryLoadableBest + ? { + battleId: reportFormationHistoryLoadableBest.battleId, + battleTitle: reportFormationHistoryLoadableBest.battleTitle, + grade: reportFormationHistoryLoadableBest.review.grade, + score: reportFormationHistoryLoadableBest.review.score, + selectedUnitIds: [...reportFormationHistoryLoadableBest.review.snapshot.selectedUnitIds], + formationAssignments: { ...reportFormationHistoryLoadableBest.review.snapshot.formationAssignments }, + projectedSelectedUnitIds: reportFormationHistoryProjection ? [...reportFormationHistoryProjection.selectedUnitIds] : [], + projectedFormationAssignments: reportFormationHistoryProjection ? { ...reportFormationHistoryProjection.formationAssignments } : {}, + projectedItemAssignments: reportFormationHistoryProjection ? this.cloneSortieItemAssignments(reportFormationHistoryProjection.itemAssignments) : {}, + omittedUnitNames: reportFormationHistoryProjection ? [...reportFormationHistoryProjection.omittedUnitNames] : [] + } + : null, + presetAverages: this.reportFormationPresetAverages(reportFormationHistoryRecords).map((average) => ({ ...average })), + comparison: reportFormationHistoryComparison + ? { + ...reportFormationHistoryComparison, + incomingUnitNames: [...reportFormationHistoryComparison.incomingUnitNames], + outgoingUnitNames: [...reportFormationHistoryComparison.outgoingUnitNames] + } + : null, + feedback: this.reportFormationHistoryFeedback, + loadUndoAvailable: this.reportFormationHistoryUndoAvailable(), + toggleButtonBounds: this.sortieObjectBoundsDebug(this.reportFormationHistoryToggleButton), + closeButtonBounds: this.sortieObjectBoundsDebug(this.reportFormationHistoryView?.closeButton), + previousButtonBounds: this.sortieObjectBoundsDebug(this.reportFormationHistoryView?.previousButton), + nextButtonBounds: this.sortieObjectBoundsDebug(this.reportFormationHistoryView?.nextButton), + bestButtonBounds: this.sortieObjectBoundsDebug(this.reportFormationHistoryView?.bestButton), + loadBestButtonBounds: this.sortieObjectBoundsDebug(this.reportFormationHistoryView?.loadBestButton), + undoButtonBounds: this.sortieObjectBoundsDebug(this.reportFormationHistoryView?.undoButton) + } + : { + available: false, + open: false, + recordCount: 0, + page: 0, + pageCount: 0, + pageSize: reportFormationHistoryPageSize, + selectedBattleId: null, + records: [], + selected: null, + best: null, + loadableBest: null, + presetAverages: [], + comparison: null, + feedback: '', + loadUndoAvailable: false, + toggleButtonBounds: null, + closeButtonBounds: null, + previousButtonBounds: null, + nextButtonBounds: null, + bestButtonBounds: null, + loadBestButtonBounds: null, + undoButtonBounds: null + }, sortieVisible: this.sortieObjects.length > 0, sortiePrepStep: this.sortiePrepStep, stagedSortiePrep, diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 2a88113..9a62c03 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -68,6 +68,17 @@ export type CampaignSortiePerformanceSnapshot = { unitStats: CampaignSortieUnitPerformanceSnapshot[]; }; +export type CampaignSortieReviewGrade = 'S' | 'A' | 'B' | 'C' | 'D'; + +export type CampaignSortieReviewSnapshot = { + version: 1; + score: number; + grade: CampaignSortieReviewGrade; + activeBondCount: number; + enemyCount: number; + sourcePresetIds: CampaignSortiePresetId[]; +}; + export type FirstBattleReport = { battleId: string; battleTitle: string; @@ -83,6 +94,7 @@ export type FirstBattleReport = { itemRewards: string[]; campaignRewards?: CampaignRewardSnapshot; sortiePerformance?: CampaignSortiePerformanceSnapshot; + sortieReview?: CampaignSortieReviewSnapshot; completedCampDialogues: string[]; completedCampVisits: string[]; createdAt: string; @@ -346,6 +358,7 @@ export type CampaignBattleSettlement = { bonds: CampaignBondProgressSnapshot[]; reserveTraining?: CampaignReserveTrainingSnapshot[]; sortiePerformance?: CampaignSortiePerformanceSnapshot; + sortieReview?: CampaignSortieReviewSnapshot; completedAt: string; }; @@ -1144,15 +1157,23 @@ function normalizeLatestBattleId(value: unknown, battleHistory: Record): FirstBattleReport { const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(report.sortiePerformance, rosterUnitIds); + const sortiePerformanceUnchanged = sortiePerformanceSnapshotsShareRoster(report.sortiePerformance, sortiePerformance); + const sortieReview = sortiePerformanceUnchanged + ? normalizeCampaignSortieReviewSnapshot(report.sortieReview, sortiePerformance) + : undefined; const filtered = { ...report, units: report.units.filter((unit) => unit.faction !== 'ally' || rosterUnitIds.has(unit.id)), bonds: report.bonds.filter((bond) => bond.unitIds.every((unitId) => rosterUnitIds.has(unitId))), - ...(sortiePerformance ? { sortiePerformance } : {}) + ...(sortiePerformance ? { sortiePerformance } : {}), + ...(sortieReview ? { sortieReview } : {}) }; if (!sortiePerformance) { delete filtered.sortiePerformance; } + if (!sortieReview) { + delete filtered.sortieReview; + } if (filtered.mvp && !rosterUnitIds.has(filtered.mvp.unitId)) { delete filtered.mvp; } @@ -1166,16 +1187,24 @@ function filterBattleHistoryRosterReferences( ): Record { return Object.entries(history).reduce>((filteredHistory, [battleId, settlement]) => { const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(settlement.sortiePerformance, rosterUnitIds); + const sortiePerformanceUnchanged = sortiePerformanceSnapshotsShareRoster(settlement.sortiePerformance, sortiePerformance); + const sortieReview = sortiePerformanceUnchanged + ? normalizeCampaignSortieReviewSnapshot(settlement.sortieReview, sortiePerformance) + : undefined; 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)), - ...(sortiePerformance ? { sortiePerformance } : {}) + ...(sortiePerformance ? { sortiePerformance } : {}), + ...(sortieReview ? { sortieReview } : {}) }; if (!sortiePerformance) { delete filteredHistory[battleId].sortiePerformance; } + if (!sortieReview) { + delete filteredHistory[battleId].sortieReview; + } return filteredHistory; }, {}); } @@ -1200,6 +1229,7 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle } const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(settlement.sortiePerformance); + const sortieReview = normalizeCampaignSortieReviewSnapshot(settlement.sortieReview, sortiePerformance); return { battleId, @@ -1213,6 +1243,7 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle bonds: normalizeLimitedArray(settlement.bonds, normalizeCampaignBondProgressSnapshot, maxCampaignBattleBondEntries), reserveTraining: normalizeLimitedArray(settlement.reserveTraining, normalizeReserveTrainingSnapshot, maxCampaignReserveTrainingEntries), ...(sortiePerformance ? { sortiePerformance } : {}), + ...(sortieReview ? { sortieReview } : {}), completedAt }; } @@ -1448,6 +1479,53 @@ export function normalizeCampaignSortiePerformanceSnapshot( }; } +export function normalizeCampaignSortieReviewSnapshot( + value: unknown, + performance?: CampaignSortiePerformanceSnapshot +): CampaignSortieReviewSnapshot | undefined { + if (!isPlainObject(value) || value.version !== 1 || !campaignSortiePerformanceSupportsReview(performance)) { + return undefined; + } + + const score = Math.min(100, normalizeNonNegativeInteger(value.score)); + const grade: CampaignSortieReviewGrade = score >= 90 ? 'S' : score >= 75 ? 'A' : score >= 60 ? 'B' : score >= 45 ? 'C' : 'D'; + const sourcePresetIdSet = new Set(arrayOrEmpty(value.sourcePresetIds)); + const sourcePresetIds = campaignSortiePresetIds.filter((presetId) => sourcePresetIdSet.has(presetId)); + + return { + version: 1, + score, + grade, + activeBondCount: Math.min(maxCampaignBattleBondEntries, normalizeNonNegativeInteger(value.activeBondCount)), + enemyCount: Math.min(maxCampaignBattleUnitEntries, normalizeNonNegativeInteger(value.enemyCount)), + sourcePresetIds + }; +} + +function campaignSortiePerformanceSupportsReview( + performance?: CampaignSortiePerformanceSnapshot +): performance is CampaignSortiePerformanceSnapshot { + if (!performance || performance.selectedUnitIds.length === 0) { + return false; + } + const statsUnitIds = new Set(performance.unitStats.map((stats) => stats.unitId)); + return performance.selectedUnitIds.every((unitId) => ( + Boolean(performance.formationAssignments[unitId]) && statsUnitIds.has(unitId) + )); +} + +function sortiePerformanceSnapshotsShareRoster( + source?: CampaignSortiePerformanceSnapshot, + filtered?: CampaignSortiePerformanceSnapshot +) { + return Boolean( + source && + filtered && + source.selectedUnitIds.length === filtered.selectedUnitIds.length && + source.selectedUnitIds.every((unitId, index) => filtered.selectedUnitIds[index] === unitId) + ); +} + function normalizeReserveTrainingFocusId(focusId?: string): CampaignReserveTrainingFocusId { return isReserveTrainingFocusId(focusId) ? focusId as CampaignReserveTrainingFocusId @@ -1486,6 +1564,7 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi } const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(report.sortiePerformance); + const sortieReview = normalizeCampaignSortieReviewSnapshot(report.sortieReview, sortiePerformance); return { battleId, @@ -1505,6 +1584,7 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi battleId ), ...(sortiePerformance ? { sortiePerformance } : {}), + ...(sortieReview ? { sortieReview } : {}), completedCampDialogues: uniqueStrings(report.completedCampDialogues), completedCampVisits: uniqueStrings(report.completedCampVisits), createdAt: normalizeCampaignTimestamp(report.createdAt) ?? new Date().toISOString() @@ -1746,6 +1826,14 @@ function createBattleSettlement(report: FirstBattleReport, reserveTraining: Camp formationAssignments: { ...report.sortiePerformance.formationAssignments }, unitStats: report.sortiePerformance.unitStats.map((stats) => ({ ...stats })) } + } + : {}), + ...(report.sortieReview + ? { + sortieReview: { + ...report.sortieReview, + sourcePresetIds: [...report.sortieReview.sourcePresetIds] + } } : {}), completedAt: report.createdAt