diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index 00815c8..3f08b84 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -200,6 +200,39 @@ try { `Expected stale bonds to be filtered against the recovered roster while valid bonds survive: ${JSON.stringify(staleBonds)}` ); + storage.clear(); + storage.set( + campaignStorageKey, + JSON.stringify({ + version: 1, + updatedAt: '2026-07-03T13:30:00.000Z', + step: 'third-camp', + activeSaveSlot: 1, + roster: [ + { id: 'liu-bei', name: 'Liu Bei', faction: 'ally' }, + { id: 'guan-yu', name: 'Guan Yu', faction: 'ally' } + ], + firstBattleReport: { + battleId: 'first-battle-zhuo-commandery', + outcome: 'victory', + createdAt: '2026-07-03T13:30:00.000Z', + units: [{ id: 'rebel-a', name: 'Rebel', faction: 'enemy' }], + bonds: [ + { id: 'liu-bei__guan-yu', title: 'Oath Bond', unitIds: ['liu-bei', 'guan-yu'], level: 2, exp: 30, battleExp: 4 }, + { id: 'liu-bei__ghost', title: 'Ghost Bond', unitIds: ['liu-bei', 'ghost-unit'], level: 3, exp: 40, battleExp: 5 } + ], + mvp: { unitId: 'ghost-unit', name: 'Ghost', damageDealt: 99, defeats: 4 } + } + }) + ); + const staleReportRefs = loadCampaignState(); + assert( + staleReportRefs.firstBattleReport?.bonds.length === 1 && + staleReportRefs.firstBattleReport.bonds[0].id === 'liu-bei__guan-yu' && + staleReportRefs.firstBattleReport.mvp === undefined, + `Expected first battle report bonds and MVP to be filtered against the recovered roster: ${JSON.stringify(staleReportRefs)}` + ); + storage.clear(); storage.set( campaignStorageKey, @@ -462,7 +495,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/roster-equipment/bond-roster/sortie-roster/latest-history/detail recovery, unknown-step/report/history recovery, unsupported-version rejection, and slotted fallback.'); + console.log('Verified campaign save normalization, 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.'); } finally { await server.close(); } diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index bc98a7a..1a1ae3e 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -837,6 +837,9 @@ function normalizeCampaignState(state: Partial): CampaignState { normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory); normalized.latestBattleId = normalizeLatestBattleId(normalized.latestBattleId, normalized.battleHistory); normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport); + if (normalized.firstBattleReport && sortieUnitFilter) { + normalized.firstBattleReport = filterFirstBattleReportRosterReferences(normalized.firstBattleReport, sortieUnitFilter); + } return normalized; } @@ -900,6 +903,17 @@ function normalizeLatestBattleId(value: unknown, battleHistory: Record String(b.completedAt).localeCompare(String(a.completedAt)))[0]?.battleId; } +function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rosterUnitIds: Set): FirstBattleReport { + const filtered = { + ...report, + bonds: report.bonds.filter((bond) => bond.unitIds.every((unitId) => rosterUnitIds.has(unitId))) + }; + if (filtered.mvp && !rosterUnitIds.has(filtered.mvp.unitId)) { + delete filtered.mvp; + } + return filtered; +} + function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettlement | undefined { if (!isPlainObject(value)) { return undefined;