From a09379d4f1b8eba0398e96869006aeee24683ecf Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 5 Jul 2026 09:16:53 +0900 Subject: [PATCH] Recover saves with malformed battle reports --- .../verify-campaign-save-normalization.mjs | 25 ++++++++++++++++++- src/game/state/campaignState.ts | 18 +++++++++---- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index fd693ff..6cd97dd 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -117,6 +117,29 @@ try { assert(invalidStep.step === 'new', `Expected unknown campaign step to reset safely: ${JSON.stringify(invalidStep)}`); assert(invalidStep.gold === 90, `Expected unknown-step save to keep harmless economy data: ${JSON.stringify(invalidStep)}`); + storage.clear(); + storage.set( + campaignStorageKey, + JSON.stringify({ + version: 1, + updatedAt: '2026-07-04T00:00:00.000Z', + step: 'fourth-camp', + activeSaveSlot: 1, + gold: 320, + firstBattleReport: { + battleId: 'first-battle-zhuo-commandery', + outcome: 'victory', + completedCampVisits: { corrupted: true } + } + }) + ); + const malformedReport = loadCampaignState(); + assert( + malformedReport.step === 'fourth-camp' && malformedReport.gold === 320, + `Expected malformed report to be dropped without losing campaign progress: ${JSON.stringify(malformedReport)}` + ); + assert(malformedReport.firstBattleReport === undefined, `Expected malformed report to be discarded: ${JSON.stringify(malformedReport)}`); + storage.clear(); storage.set(campaignStorageKey, JSON.stringify({ version: 99, step: 'sixty-sixth-camp' })); const unsupported = loadCampaignState(); @@ -137,7 +160,7 @@ try { const fallback = loadCampaignState(); assert(fallback.step === 'second-camp' && fallback.activeSaveSlot === 2, `Expected valid slotted save fallback: ${JSON.stringify(fallback)}`); - console.log('Verified campaign save normalization, malformed-field recovery, unknown-step recovery, unsupported-version rejection, and slotted fallback.'); + console.log('Verified campaign save normalization, malformed-field recovery, unknown-step and report recovery, unsupported-version rejection, and slotted fallback.'); } finally { await server.close(); } diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 295be36..250cc46 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -812,11 +812,7 @@ function normalizeCampaignState(state: Partial): CampaignState { normalized.sortieItemAssignments = normalizeSortieItemAssignments(recordOrEmpty(normalized.sortieItemAssignments)); normalized.reserveTrainingFocus = normalizeReserveTrainingFocusId(normalized.reserveTrainingFocus); normalized.battleHistory = recordOrEmpty(normalized.battleHistory); - if (isPlainObject(normalized.firstBattleReport)) { - normalized.firstBattleReport = cloneReport(normalized.firstBattleReport); - } else { - normalized.firstBattleReport = undefined; - } + normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport); return normalized; } @@ -878,6 +874,18 @@ function normalizeCampaignStep(step: unknown): CampaignStep { return typeof step === 'string' && campaignStepIds.has(step as CampaignStep) ? step as CampaignStep : 'new'; } +function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefined { + if (!isPlainObject(report)) { + return undefined; + } + + try { + return cloneReport(report as FirstBattleReport); + } catch { + return undefined; + } +} + function reserveTrainingFocusDefinition(focusId?: string) { const normalizedId = normalizeReserveTrainingFocusId(focusId); return campaignReserveTrainingFocusDefinitions.find((focus) => focus.id === normalizedId) ?? campaignReserveTrainingFocusDefinitions[0];