diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index 7de5484..5a1fc8c 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -596,6 +596,19 @@ try { reserveTraining: [], completedAt: '2026-07-04T02:00:00.000Z' }, + 'invalid-date-history-key': { + battleId: 'second-battle-yellow-turban-pursuit', + battleTitle: 'Invalid Date Battle', + outcome: 'victory', + rewardGold: 999, + itemRewards: [], + campaignRewards: { supplies: [], equipment: [], reputation: [], recruits: [], unlocks: [] }, + objectives: [], + units: [], + bonds: [], + reserveTraining: [], + completedAt: 'not-a-date' + }, corrupted: 'not-a-settlement' } }) @@ -654,7 +667,41 @@ 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, reward resettlement, camp reward idempotence, malformed-field/roster-equipment/bond-roster/report-reference/sortie-roster/latest-history/detail recovery, unknown-step/report/history recovery, unsupported-version rejection, and slotted fallback.'); + storage.clear(); + storage.set(campaignStorageKey, JSON.stringify({ version: 99, step: 'sixty-sixth-camp' })); + storage.set( + `${campaignStorageKey}:slot-1`, + JSON.stringify({ + version: 1, + updatedAt: 'zzzz-latest-looking-corruption', + step: 'sixty-sixth-camp', + activeSaveSlot: 1, + gold: 999 + }) + ); + storage.set( + `${campaignStorageKey}:slot-2`, + JSON.stringify({ + version: 1, + updatedAt: '2026-07-02T00:00:00.000Z', + step: 'second-camp', + activeSaveSlot: 2, + gold: 260 + }) + ); + const timestampSafeFallback = loadCampaignState(); + assert( + timestampSafeFallback.step === 'second-camp' && timestampSafeFallback.activeSaveSlot === 2, + `Expected latest slotted fallback to ignore invalid timestamp saves: ${JSON.stringify(timestampSafeFallback)}` + ); + + const corruptedSlot = loadCampaignState(1); + assert( + !Number.isNaN(Date.parse(corruptedSlot.updatedAt)) && corruptedSlot.updatedAt !== 'zzzz-latest-looking-corruption', + `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-roster/report-reference/sortie-roster/latest-history/detail recovery, timestamp-safe history/slot 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 17e2ab3..3cf76ed 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -831,7 +831,7 @@ function normalizeCampaignState(state: Partial): CampaignState { ...cloneCampaignState(state as CampaignState) }; normalized.version = 1; - normalized.updatedAt = typeof normalized.updatedAt === 'string' && normalized.updatedAt ? normalized.updatedAt : new Date().toISOString(); + normalized.updatedAt = normalizeCampaignTimestamp(normalized.updatedAt) ?? new Date().toISOString(); normalized.step = normalizeCampaignStep(normalized.step); normalized.activeSaveSlot = normalizeSlot(normalized.activeSaveSlot); normalized.gold = normalizeNonNegativeInteger(normalized.gold); @@ -899,6 +899,23 @@ function normalizeNonNegativeInteger(value: unknown) { return Number.isFinite(numeric) && numeric > 0 ? numeric : 0; } +function normalizeCampaignTimestamp(value: unknown) { + if (typeof value !== 'string' || value.length === 0) { + return undefined; + } + + return campaignTimestampMs(value) === undefined ? undefined : value; +} + +function campaignTimestampMs(value: unknown) { + if (typeof value !== 'string' || value.length === 0) { + return undefined; + } + + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : undefined; +} + function normalizeInventory(value: unknown) { return Object.entries(recordOrEmpty(value)).reduce>((inventory, [itemId, amount]) => { const normalizedItemId = itemId.replace(/\s+/g, ' ').trim(); @@ -930,7 +947,7 @@ function normalizeLatestBattleId(value: unknown, battleHistory: Record String(b.completedAt).localeCompare(String(a.completedAt)))[0]?.battleId; + .sort((a, b) => (campaignTimestampMs(b.completedAt) ?? 0) - (campaignTimestampMs(a.completedAt) ?? 0))[0]?.battleId; } function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rosterUnitIds: Set): FirstBattleReport { @@ -951,14 +968,14 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle const settlement = value as Partial; const battleId = typeof settlement.battleId === 'string' ? settlement.battleId : ''; + const completedAt = normalizeCampaignTimestamp(settlement.completedAt); if ( battleId.length === 0 || !(battleId in battleScenarios) || typeof settlement.battleTitle !== 'string' || settlement.battleTitle.length === 0 || (settlement.outcome !== 'victory' && settlement.outcome !== 'defeat') || - typeof settlement.completedAt !== 'string' || - settlement.completedAt.length === 0 + !completedAt ) { return undefined; } @@ -982,7 +999,7 @@ function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettle reserveTraining: arrayOrEmpty(settlement.reserveTraining) .map(normalizeReserveTrainingSnapshot) .filter((entry): entry is CampaignReserveTrainingSnapshot => Boolean(entry)), - completedAt: settlement.completedAt + completedAt }; } @@ -1154,7 +1171,7 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi ), completedCampDialogues: uniqueStrings(report.completedCampDialogues), completedCampVisits: uniqueStrings(report.completedCampVisits), - createdAt: typeof report.createdAt === 'string' && report.createdAt.length > 0 ? report.createdAt : new Date().toISOString() + createdAt: normalizeCampaignTimestamp(report.createdAt) ?? new Date().toISOString() }; } @@ -1276,6 +1293,11 @@ function reserveTrainingFocusDefinition(focusId?: string) { } function readStoredCampaignState(slot?: number) { + const parsed = readStoredCampaignStateObject(slot); + return parsed ? normalizeCampaignState(parsed) : undefined; +} + +function readStoredCampaignStateObject(slot?: number) { const raw = tryStorage()?.getItem(slot ? campaignSaveSlotKey(slot) : campaignStorageKey); if (!raw) { return undefined; @@ -1289,7 +1311,7 @@ function readStoredCampaignState(slot?: number) { if (parsed.version !== undefined && parsed.version !== 1) { return undefined; } - return normalizeCampaignState(parsed); + return parsed; } catch { return undefined; } @@ -1320,10 +1342,18 @@ function normalizeSlot(slot: number | undefined) { } function readLatestSlottedCampaignState() { - const latest = listCampaignSaveSlots() - .filter((slot) => slot.exists && slot.updatedAt) - .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))[0]; - return latest ? readStoredCampaignState(latest.slot) : undefined; + let latestSlot: number | undefined; + let latestUpdatedAt = Number.NEGATIVE_INFINITY; + + for (let slot = 1; slot <= campaignSaveSlotCount; slot += 1) { + const timestamp = campaignTimestampMs(readStoredCampaignStateObject(slot)?.updatedAt); + if (timestamp !== undefined && timestamp > latestUpdatedAt) { + latestSlot = slot; + latestUpdatedAt = timestamp; + } + } + + return latestSlot ? readStoredCampaignState(latestSlot) : undefined; } function advanceBond(bond: CampBondSnapshot, amount: number) {