From d45d24087519436bc0eff848d6c44628ddfeb79a Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 5 Jul 2026 09:26:40 +0900 Subject: [PATCH] Normalize malformed campaign bonds --- .../verify-campaign-save-normalization.mjs | 17 +++++++-- src/game/state/campaignState.ts | 35 +++++++++++++++++-- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index 81bbf89..5ab5fbd 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -69,7 +69,11 @@ try { activeSaveSlot: '2', gold: '410', roster: { corrupted: true }, - bonds: { corrupted: true }, + bonds: [ + { id: 'broken-bond', title: 'Broken Bond', unitIds: { corrupted: true }, level: 3, exp: 12, battleExp: 2 }, + { id: 'oath-bond', title: 'Oath Bond', unitIds: ['liu-bei', 'guan-yu'], level: '2', exp: '14', battleExp: '5' }, + 9 + ], completedCampDialogues: ['dialogue-a', 17, 'dialogue-a', ''], completedCampVisits: 'visit-a', inventory: { bean: '3', wine: -2, empty: 0 }, @@ -86,7 +90,14 @@ try { 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( + Array.isArray(malformed.bonds) && + malformed.bonds.length === 1 && + malformed.bonds[0].id === 'oath-bond' && + malformed.bonds[0].level === 2 && + malformed.bonds[0].unitIds.length === 2, + `Expected malformed bond entries to be filtered while valid bonds normalize: ${JSON.stringify(malformed)}` + ); assert( malformed.completedCampDialogues.length === 1 && malformed.completedCampDialogues[0] === 'dialogue-a' && @@ -203,7 +214,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/report/history recovery, unsupported-version rejection, and slotted fallback.'); + console.log('Verified campaign save normalization, malformed-field/bond 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 ded20d1..bda9d4b 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -802,8 +802,12 @@ function normalizeCampaignState(state: Partial): CampaignState { normalized.step = normalizeCampaignStep(normalized.step); normalized.activeSaveSlot = normalizeSlot(normalized.activeSaveSlot); normalized.gold = normalizeNonNegativeInteger(normalized.gold); - normalized.roster = arrayOrEmpty(normalized.roster).map(cloneUnit); - normalized.bonds = arrayOrEmpty(normalized.bonds).map(cloneBondSnapshot); + normalized.roster = arrayOrEmpty(normalized.roster) + .filter(isPlainObject) + .map((unit) => cloneUnit(unit as UnitData)); + normalized.bonds = arrayOrEmpty(normalized.bonds) + .map(normalizeCampBondSnapshot) + .filter((bond): bond is CampBondSnapshot => Boolean(bond)); normalized.completedCampDialogues = uniqueStrings(normalized.completedCampDialogues); normalized.completedCampVisits = uniqueStrings(normalized.completedCampVisits); normalized.inventory = normalizeInventory(normalized.inventory); @@ -933,6 +937,33 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi } } +function normalizeCampBondSnapshot(value: unknown): CampBondSnapshot | undefined { + if (!isPlainObject(value)) { + return undefined; + } + + const unitIds = arrayOrEmpty(value.unitIds).filter((unitId): unitId is string => typeof unitId === 'string' && unitId.length > 0); + if ( + typeof value.id !== 'string' || + value.id.length === 0 || + typeof value.title !== 'string' || + value.title.length === 0 || + unitIds.length < 2 + ) { + return undefined; + } + + return { + ...(value as CampBondSnapshot), + id: value.id, + title: value.title, + unitIds: [unitIds[0], unitIds[1]] as [string, string], + level: normalizeNonNegativeInteger(value.level), + exp: normalizeNonNegativeInteger(value.exp), + battleExp: normalizeNonNegativeInteger(value.battleExp) + }; +} + function reserveTrainingFocusDefinition(focusId?: string) { const normalizedId = normalizeReserveTrainingFocusId(focusId); return campaignReserveTrainingFocusDefinitions.find((focus) => focus.id === normalizedId) ?? campaignReserveTrainingFocusDefinitions[0];