diff --git a/scripts/verify-campaign-save-normalization.mjs b/scripts/verify-campaign-save-normalization.mjs index 5a1fc8c..89c2586 100644 --- a/scripts/verify-campaign-save-normalization.mjs +++ b/scripts/verify-campaign-save-normalization.mjs @@ -125,6 +125,15 @@ try { visitResynced.firstBattleReport?.completedCampVisits.includes('repeat-visit'), `Expected report-only camp visit completion to resync without duplicate rewards: ${JSON.stringify(visitResynced)}` ); + applyCampVisitReward('dirty-reward-visit', { itemRewards: ['', ' ', 'Bean -2', 'Bean +0', 'Bean x2'] }); + const dirtyRewardVisit = getCampaignState(); + assert( + dirtyRewardVisit.inventory.Bean === 7 && + dirtyRewardVisit.inventory[''] === undefined && + dirtyRewardVisit.inventory['Bean -'] === undefined && + dirtyRewardVisit.completedCampVisits.includes('dirty-reward-visit'), + `Expected malformed reward labels to be ignored while valid rewards apply: ${JSON.stringify(dirtyRewardVisit)}` + ); const repeatBondId = firstScenario.bonds[0].id; applyCampBondExp('repeat-dialogue', repeatBondId, 10); const repeatedDialogueOnce = getCampaignState(); diff --git a/src/game/state/campaignState.ts b/src/game/state/campaignState.ts index 3cf76ed..4c38f32 100644 --- a/src/game/state/campaignState.ts +++ b/src/game/state/campaignState.ts @@ -1399,7 +1399,12 @@ function createBattleSettlement(report: FirstBattleReport, reserveTraining: Camp function applyRewardDelta(inventory: Record, rewards: string[], direction: 1 | -1) { return rewards.reduce>((nextInventory, reward) => { - const { label, amount } = parseRewardLabel(reward); + const parsed = parseRewardLabel(reward); + if (!parsed) { + return nextInventory; + } + + const { label, amount } = parsed; const nextAmount = Math.max(0, (nextInventory[label] ?? 0) + amount * direction); const cap = direction === 1 ? campaignInventoryCaps[label] : undefined; nextInventory[label] = cap ? Math.min(nextAmount, cap) : nextAmount; @@ -1488,16 +1493,23 @@ function advanceReserveEquipment(unit: UnitData, amount: number) { function parseRewardLabel(reward: string) { const normalized = reward.replace(/\s+/g, ' ').trim(); - const match = normalized.match(/^(.*?)(?:\s*[+xX]?\s*(\d+))$/); + if (!normalized) { + return undefined; + } + + const match = normalized.match(/^(.*?)(?:\s*([+xX-]?)\s*(\d+))$/); if (!match) { return { label: normalized, amount: 1 }; } - return { - label: match[1].trim() || normalized, - amount: Number(match[2] ?? 1) - }; + const amount = Number(match[3] ?? 1) * (match[2] === '-' ? -1 : 1); + const label = match[1].trim(); + if (!label || !Number.isSafeInteger(amount) || amount <= 0) { + return undefined; + } + + return { label, amount }; } function cloneCampaignState(state: CampaignState): CampaignState {