From 56b01a5c94de059f71c8c66c9361321dad67fc8a Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 5 Jul 2026 09:04:06 +0900 Subject: [PATCH] Harden malformed campaign save recovery --- .../verify-campaign-save-normalization.mjs | 45 ++++++++++++++- src/game/state/campaignState.ts | 57 +++++++++++++++---- 2 files changed, 89 insertions(+), 13 deletions(-) diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index 14107f9..144b21f 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -59,6 +59,49 @@ try { `Expected modern sortie fields to be restored: ${JSON.stringify(legacy)}` ); + storage.clear(); + storage.set( + campaignStorageKey, + JSON.stringify({ + version: 1, + updatedAt: '', + step: 'third-camp', + activeSaveSlot: '2', + gold: '410', + roster: { corrupted: true }, + bonds: { corrupted: true }, + completedCampDialogues: ['dialogue-a', 17, 'dialogue-a', ''], + completedCampVisits: 'visit-a', + inventory: { bean: '3', wine: -2, empty: 0 }, + selectedSortieUnitIds: { corrupted: true }, + sortieFormationAssignments: 'front', + sortieItemAssignments: { + 'liu-bei': { bean: '2', wine: -1 }, + 'guan-yu': 'broken' + }, + battleHistory: [] + }) + ); + const malformed = loadCampaignState(); + assert(malformed.step === 'third-camp', `Expected malformed save progress to survive: ${JSON.stringify(malformed)}`); + assert(malformed.activeSaveSlot === 2 && malformed.gold === 410, `Expected malformed numeric fields to normalize: ${JSON.stringify(malformed)}`); + assert(Array.isArray(malformed.roster) && malformed.roster.length === 0, `Expected malformed roster to reset to an array: ${JSON.stringify(malformed)}`); + assert(Array.isArray(malformed.bonds) && malformed.bonds.length === 0, `Expected malformed bonds to reset to an array: ${JSON.stringify(malformed)}`); + assert( + malformed.completedCampDialogues.length === 1 && + malformed.completedCampDialogues[0] === 'dialogue-a' && + malformed.completedCampVisits.length === 0, + `Expected malformed completion lists to keep unique strings only: ${JSON.stringify(malformed)}` + ); + assert( + malformed.inventory.bean === 3 && + malformed.inventory.wine === undefined && + malformed.sortieItemAssignments['liu-bei']?.bean === 2 && + malformed.sortieItemAssignments['liu-bei']?.wine === undefined && + Object.keys(malformed.sortieFormationAssignments).length === 0, + `Expected malformed inventory and sortie assignments to normalize: ${JSON.stringify(malformed)}` + ); + storage.clear(); storage.set(campaignStorageKey, JSON.stringify({ version: 99, step: 'sixty-sixth-camp' })); const unsupported = loadCampaignState(); @@ -79,7 +122,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, legacy recovery, unsupported-version rejection, and slotted fallback.'); + console.log('Verified campaign save normalization, malformed-field 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 6db06d8..a07d7c2 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -786,25 +786,58 @@ function normalizeCampaignState(state: Partial): CampaignState { ...cloneCampaignState(state as CampaignState) }; normalized.version = 1; - normalized.updatedAt = normalized.updatedAt || new Date().toISOString(); + normalized.updatedAt = typeof normalized.updatedAt === 'string' && normalized.updatedAt ? normalized.updatedAt : new Date().toISOString(); normalized.activeSaveSlot = normalizeSlot(normalized.activeSaveSlot); - normalized.gold = normalized.gold ?? 0; - normalized.roster = (normalized.roster ?? []).map(cloneUnit); - normalized.bonds = (normalized.bonds ?? []).map(cloneBondSnapshot); - normalized.completedCampDialogues = [...new Set(normalized.completedCampDialogues ?? [])]; - normalized.completedCampVisits = [...new Set(normalized.completedCampVisits ?? [])]; - normalized.inventory = { ...(normalized.inventory ?? {}) }; - normalized.selectedSortieUnitIds = [...new Set(normalized.selectedSortieUnitIds ?? [])]; - normalized.sortieFormationAssignments = normalizeSortieFormationAssignments(normalized.sortieFormationAssignments); - normalized.sortieItemAssignments = normalizeSortieItemAssignments(normalized.sortieItemAssignments); + normalized.gold = normalizeNonNegativeInteger(normalized.gold); + normalized.roster = arrayOrEmpty(normalized.roster).map(cloneUnit); + normalized.bonds = arrayOrEmpty(normalized.bonds).map(cloneBondSnapshot); + normalized.completedCampDialogues = uniqueStrings(normalized.completedCampDialogues); + normalized.completedCampVisits = uniqueStrings(normalized.completedCampVisits); + normalized.inventory = normalizeInventory(normalized.inventory); + normalized.selectedSortieUnitIds = uniqueStrings(normalized.selectedSortieUnitIds); + normalized.sortieFormationAssignments = normalizeSortieFormationAssignments(recordOrEmpty(normalized.sortieFormationAssignments)); + normalized.sortieItemAssignments = normalizeSortieItemAssignments(recordOrEmpty(normalized.sortieItemAssignments)); normalized.reserveTrainingFocus = normalizeReserveTrainingFocusId(normalized.reserveTrainingFocus); - normalized.battleHistory = normalized.battleHistory ?? {}; - if (normalized.firstBattleReport) { + normalized.battleHistory = recordOrEmpty(normalized.battleHistory); + if (isPlainObject(normalized.firstBattleReport)) { normalized.firstBattleReport = cloneReport(normalized.firstBattleReport); + } else { + normalized.firstBattleReport = undefined; } return normalized; } +function arrayOrEmpty(value: unknown): T[] { + return Array.isArray(value) ? value as T[] : []; +} + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function recordOrEmpty(value: unknown): Record { + return isPlainObject(value) ? value as Record : {}; +} + +function uniqueStrings(value: unknown): string[] { + return [...new Set(arrayOrEmpty(value).filter((entry): entry is string => typeof entry === 'string' && entry.length > 0))]; +} + +function normalizeNonNegativeInteger(value: unknown) { + const numeric = Math.floor(Number(value)); + return Number.isFinite(numeric) && numeric > 0 ? numeric : 0; +} + +function normalizeInventory(value: unknown) { + return Object.entries(recordOrEmpty(value)).reduce>((inventory, [itemId, amount]) => { + const normalizedAmount = normalizeNonNegativeInteger(amount); + if (itemId && normalizedAmount > 0) { + inventory[itemId] = normalizedAmount; + } + return inventory; + }, {}); +} + function normalizeSortieItemAssignments(assignments?: CampaignSortieItemAssignments) { return Object.entries(assignments ?? {}).reduce((next, [unitId, stocks]) => { const unitStocks = Object.entries(stocks ?? {}).reduce>((nextStocks, [itemId, amount]) => {