From 4be59e5c808bfbe46fc174b19cb0b4870056c361 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Sun, 5 Jul 2026 10:01:06 +0900 Subject: [PATCH] Recover malformed first battle reports --- .../verify-campaign-save-normalization.mjs | 66 +++++++++++- src/game/state/campaignState.ts | 102 +++++++++++++++++- 2 files changed, 162 insertions(+), 6 deletions(-) diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index f1c7bce..413dd85 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -207,16 +207,76 @@ try { firstBattleReport: { battleId: 'first-battle-zhuo-commandery', outcome: 'victory', - completedCampVisits: { corrupted: true } + turnNumber: '4', + rewardGold: '120', + defeatedEnemies: '3', + totalEnemies: '5', + objectives: [ + { id: 'village', label: 'Village', achieved: true, detail: 12, rewardGold: '80' }, + { id: '', label: 'Broken', achieved: true } + ], + units: [ + { + id: 'liu-bei', + name: 'Liu Bei', + faction: 'ally', + className: 'Lord', + classKey: 'lord', + level: '5', + exp: '15', + hp: '41', + maxHp: '44', + attack: '21', + move: '5', + stats: { might: '18', intelligence: '17', leadership: '20', agility: '12', luck: '11' }, + equipment: {}, + x: '2', + y: '3' + }, + { id: 'broken-unit', name: 'Broken' } + ], + bonds: [ + { id: 'liu-bei__guan-yu', title: 'Oath Bond', unitIds: ['liu-bei', 'guan-yu'], level: '2', exp: '30', battleExp: '4' }, + { id: 'broken-bond', title: 'Broken Bond', unitIds: ['liu-bei'] } + ], + mvp: { unitId: 'liu-bei', name: 'Liu Bei', damageDealt: '88', defeats: '2' }, + itemRewards: ['Bean', 'Bean', 10], + campaignRewards: { + supplies: ['Bean', 'Bean', 20], + equipment: ['Iron Sword'], + reputation: ['Village Thanks'], + recruits: [{ unitId: 'guan-yu', name: 'Guan Yu' }, { unitId: '', name: 'Broken' }], + unlocks: [{ battleId: 'second-battle-yellow-turban-pursuit', title: 'Next' }, { battleId: '', title: 'Broken' }] + }, + completedCampDialogues: ['intro', 'intro', 7], + completedCampVisits: { corrupted: true }, + createdAt: 42 } }) ); const malformedReport = loadCampaignState(); assert( malformedReport.step === 'fourth-camp' && malformedReport.gold === 320, - `Expected malformed report to be dropped without losing campaign progress: ${JSON.stringify(malformedReport)}` + `Expected report recovery to keep campaign progress: ${JSON.stringify(malformedReport)}` + ); + assert( + malformedReport.firstBattleReport?.battleId === 'first-battle-zhuo-commandery' && + malformedReport.firstBattleReport.battleTitle.length > 0 && + malformedReport.firstBattleReport.turnNumber === 4 && + malformedReport.firstBattleReport.objectives.length === 1 && + malformedReport.firstBattleReport.objectives[0].rewardGold === 80 && + malformedReport.firstBattleReport.units.length === 1 && + malformedReport.firstBattleReport.units[0].level === 5 && + malformedReport.firstBattleReport.bonds.length === 1 && + malformedReport.firstBattleReport.bonds[0].battleExp === 4 && + malformedReport.firstBattleReport.mvp?.damageDealt === 88 && + malformedReport.firstBattleReport.itemRewards.length === 1 && + malformedReport.firstBattleReport.campaignRewards?.supplies.length === 1 && + malformedReport.firstBattleReport.campaignRewards?.recruits.length === 1 && + malformedReport.firstBattleReport.completedCampDialogues.length === 1 && + malformedReport.firstBattleReport.completedCampVisits.length === 0, + `Expected malformed report fields to be normalized without discarding the report: ${JSON.stringify(malformedReport)}` ); - assert(malformedReport.firstBattleReport === undefined, `Expected malformed report to be discarded: ${JSON.stringify(malformedReport)}`); storage.clear(); storage.set( diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 3279de0..033e2c7 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -1051,11 +1051,107 @@ function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefi return undefined; } - try { - return cloneReport(report as FirstBattleReport); - } catch { + if ( + typeof report.battleId !== 'string' || + report.battleId.length === 0 || + (report.outcome !== 'victory' && report.outcome !== 'defeat') + ) { return undefined; } + + const battleTitle = + typeof report.battleTitle === 'string' && report.battleTitle.length > 0 + ? report.battleTitle + : battleScenarios[report.battleId as BattleScenarioId]?.title; + if (!battleTitle) { + return undefined; + } + + return { + battleId: report.battleId, + battleTitle, + outcome: report.outcome, + turnNumber: normalizeNonNegativeInteger(report.turnNumber), + rewardGold: normalizeNonNegativeInteger(report.rewardGold), + defeatedEnemies: normalizeNonNegativeInteger(report.defeatedEnemies), + totalEnemies: normalizeNonNegativeInteger(report.totalEnemies), + objectives: arrayOrEmpty(report.objectives) + .map(normalizeBattleObjectiveSnapshot) + .filter((objective): objective is BattleObjectiveSnapshot => Boolean(objective)), + units: arrayOrEmpty(report.units) + .map(normalizeBattleReportUnitSnapshot) + .filter((unit): unit is UnitData => Boolean(unit)), + bonds: arrayOrEmpty(report.bonds) + .map(normalizeCampBondSnapshot) + .filter((bond): bond is CampBondSnapshot => Boolean(bond)), + mvp: normalizeCampMvpSnapshot(report.mvp), + itemRewards: uniqueStrings(report.itemRewards), + campaignRewards: cloneCampaignRewardSnapshot( + isPlainObject(report.campaignRewards) ? report.campaignRewards as CampaignRewardSnapshot : undefined + ), + completedCampDialogues: uniqueStrings(report.completedCampDialogues), + completedCampVisits: uniqueStrings(report.completedCampVisits), + createdAt: typeof report.createdAt === 'string' && report.createdAt.length > 0 ? report.createdAt : new Date().toISOString() + }; +} + +function normalizeBattleReportUnitSnapshot(value: unknown): UnitData | undefined { + if ( + !isPlainObject(value) || + typeof value.id !== 'string' || + value.id.length === 0 || + typeof value.name !== 'string' || + value.name.length === 0 || + (value.faction !== 'ally' && value.faction !== 'enemy') + ) { + return undefined; + } + + const stats = recordOrEmpty(value.stats); + const hp = normalizeNonNegativeInteger(value.hp); + const maxHp = Math.max(hp, normalizeNonNegativeInteger(value.maxHp)); + return { + id: value.id, + name: value.name, + faction: value.faction, + className: typeof value.className === 'string' ? value.className : '', + classKey: typeof value.classKey === 'string' ? value.classKey as UnitData['classKey'] : 'infantry', + level: normalizeNonNegativeInteger(value.level), + exp: normalizeNonNegativeInteger(value.exp), + hp, + maxHp, + attack: normalizeNonNegativeInteger(value.attack), + move: normalizeNonNegativeInteger(value.move), + stats: { + might: normalizeNonNegativeInteger(stats.might), + intelligence: normalizeNonNegativeInteger(stats.intelligence), + leadership: normalizeNonNegativeInteger(stats.leadership), + agility: normalizeNonNegativeInteger(stats.agility), + luck: normalizeNonNegativeInteger(stats.luck) + }, + equipment: recordOrEmpty(value.equipment) as UnitData['equipment'], + x: normalizeNonNegativeInteger(value.x), + y: normalizeNonNegativeInteger(value.y) + }; +} + +function normalizeCampMvpSnapshot(value: unknown): CampMvpSnapshot | undefined { + if ( + !isPlainObject(value) || + typeof value.unitId !== 'string' || + value.unitId.length === 0 || + typeof value.name !== 'string' || + value.name.length === 0 + ) { + return undefined; + } + + return { + unitId: value.unitId, + name: value.name, + damageDealt: normalizeNonNegativeInteger(value.damageDealt), + defeats: normalizeNonNegativeInteger(value.defeats) + }; } function normalizeCampBondSnapshot(value: unknown): CampBondSnapshot | undefined {