feat: add sortie formation history

This commit is contained in:
2026-07-11 00:19:02 +09:00
parent f3d0127ab7
commit 0920039b9c
6 changed files with 1768 additions and 30 deletions

View File

@@ -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();
}