From dac0411ad1c98136657a1010bdce3e47351da057 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 5 Jul 2026 09:21:20 +0900 Subject: [PATCH] Normalize malformed battle history saves --- .../verify-campaign-save-normalization.mjs | 45 ++++++++++- src/game/state/campaignState.ts | 74 +++++++++++++++++-- 2 files changed, 111 insertions(+), 8 deletions(-) diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index 6cd97dd..81bbf89 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -140,6 +140,49 @@ try { ); assert(malformedReport.firstBattleReport === undefined, `Expected malformed report to be discarded: ${JSON.stringify(malformedReport)}`); + storage.clear(); + storage.set( + campaignStorageKey, + JSON.stringify({ + version: 1, + updatedAt: '2026-07-04T01:00:00.000Z', + step: 'fifth-camp', + battleHistory: { + 'first-battle-zhuo-commandery': { + battleId: 'first-battle-zhuo-commandery', + battleTitle: 'Zhuo Commandery', + outcome: 'victory', + rewardGold: '120', + itemRewards: ['Bean', 12, 'Bean'], + campaignRewards: { + supplies: { corrupted: true }, + equipment: ['Iron Sword'], + reputation: 'Fame', + recruits: [{ unitId: 'test-recruit', name: 'Test Recruit' }, { broken: true }], + unlocks: { corrupted: true } + }, + objectives: [], + units: [], + bonds: [], + completedAt: '2026-07-04T01:00:00.000Z' + }, + corrupted: 'not-a-settlement' + } + }) + ); + const malformedHistory = loadCampaignState(); + assert( + Object.keys(malformedHistory.battleHistory).length === 1 && + malformedHistory.battleHistory['first-battle-zhuo-commandery']?.rewardGold === 120, + `Expected malformed battle history entries to be filtered while keeping valid settlements: ${JSON.stringify(malformedHistory)}` + ); + assert( + malformedHistory.battleHistory['first-battle-zhuo-commandery']?.itemRewards.length === 1 && + malformedHistory.battleHistory['first-battle-zhuo-commandery']?.campaignRewards?.equipment[0] === 'Iron Sword' && + malformedHistory.battleHistory['first-battle-zhuo-commandery']?.campaignRewards?.recruits.length === 1, + `Expected nested battle history rewards to normalize: ${JSON.stringify(malformedHistory)}` + ); + storage.clear(); storage.set(campaignStorageKey, JSON.stringify({ version: 99, step: 'sixty-sixth-camp' })); const unsupported = loadCampaignState(); @@ -160,7 +203,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 and report recovery, unsupported-version rejection, and slotted fallback.'); + console.log('Verified campaign save normalization, malformed-field recovery, unknown-step/report/history 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 250cc46..ded20d1 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -811,7 +811,7 @@ function normalizeCampaignState(state: Partial): CampaignState { normalized.sortieFormationAssignments = normalizeSortieFormationAssignments(recordOrEmpty(normalized.sortieFormationAssignments)); normalized.sortieItemAssignments = normalizeSortieItemAssignments(recordOrEmpty(normalized.sortieItemAssignments)); normalized.reserveTrainingFocus = normalizeReserveTrainingFocusId(normalized.reserveTrainingFocus); - normalized.battleHistory = recordOrEmpty(normalized.battleHistory); + normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory); normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport); return normalized; } @@ -847,6 +847,53 @@ function normalizeInventory(value: unknown) { }, {}); } +function normalizeBattleHistory(value: unknown) { + return Object.entries(recordOrEmpty(value)).reduce>((history, [key, settlement]) => { + if (!isCampaignBattleSettlement(settlement)) { + return history; + } + + history[key] = { + battleId: settlement.battleId, + battleTitle: settlement.battleTitle, + outcome: settlement.outcome, + rewardGold: normalizeNonNegativeInteger(settlement.rewardGold), + itemRewards: uniqueStrings(settlement.itemRewards), + campaignRewards: cloneCampaignRewardSnapshot(settlement.campaignRewards), + objectives: settlement.objectives.map((objective) => ({ ...objective })), + units: settlement.units.map((unit) => ({ + ...unit, + equipment: recordOrEmpty(unit.equipment) as UnitData['equipment'] + })), + bonds: settlement.bonds.map((bond) => ({ ...bond })), + reserveTraining: settlement.reserveTraining?.map((entry) => ({ ...entry })), + completedAt: settlement.completedAt + }; + return history; + }, {}); +} + +function isCampaignBattleSettlement(value: unknown): value is CampaignBattleSettlement { + if (!isPlainObject(value)) { + return false; + } + + const settlement = value as Partial; + return ( + typeof settlement.battleId === 'string' && + settlement.battleId.length > 0 && + typeof settlement.battleTitle === 'string' && + settlement.battleTitle.length > 0 && + (settlement.outcome === 'victory' || settlement.outcome === 'defeat') && + Array.isArray(settlement.itemRewards) && + Array.isArray(settlement.objectives) && + Array.isArray(settlement.units) && + Array.isArray(settlement.bonds) && + typeof settlement.completedAt === 'string' && + settlement.completedAt.length > 0 + ); +} + function normalizeSortieItemAssignments(assignments?: CampaignSortieItemAssignments) { return Object.entries(assignments ?? {}).reduce((next, [unitId, stocks]) => { const unitStocks = Object.entries(stocks ?? {}).reduce>((nextStocks, [itemId, amount]) => { @@ -1104,13 +1151,26 @@ function cloneCampaignRewardSnapshot(rewards?: CampaignRewardSnapshot): Campaign return undefined; } + const rewardRecord = recordOrEmpty(rewards); return { - supplies: [...(rewards.supplies ?? [])], - equipment: [...(rewards.equipment ?? [])], - reputation: [...(rewards.reputation ?? [])], - recruits: (rewards.recruits ?? []).map((recruit) => ({ ...recruit })), - unlocks: (rewards.unlocks ?? []).map((unlock) => ({ ...unlock })), - note: rewards.note + supplies: uniqueStrings(rewardRecord.supplies), + equipment: uniqueStrings(rewardRecord.equipment), + reputation: uniqueStrings(rewardRecord.reputation), + recruits: arrayOrEmpty(rewardRecord.recruits) + .filter(isPlainObject) + .map((recruit) => ({ + unitId: typeof recruit.unitId === 'string' ? recruit.unitId : '', + name: typeof recruit.name === 'string' ? recruit.name : '' + })) + .filter((recruit) => recruit.unitId && recruit.name), + unlocks: arrayOrEmpty(rewardRecord.unlocks) + .filter(isPlainObject) + .map((unlock) => ({ + battleId: typeof unlock.battleId === 'string' ? unlock.battleId : '', + title: typeof unlock.title === 'string' ? unlock.title : '' + })) + .filter((unlock) => unlock.battleId && unlock.title), + note: typeof rewardRecord.note === 'string' ? rewardRecord.note : undefined }; }