import { equipmentExpToNext, equipmentSlots, itemCatalog, type EquipmentSlot } from '../data/battleItems'; import { unitClasses } from '../data/battleRules'; import { battleScenarios, type BattleScenarioId } from '../data/battles'; import { findCityStayAfterBattle, type CityStayId } from '../data/cityStays'; import { campaignRecruitUnits, jianYongRecruitBond, jianYongRecruitUnit, type BattleBond, type UnitData } from '../data/scenario'; import { normalizeSortieFormationAssignments, type SortieFormationAssignments } from '../data/sortieDeployment'; import { coreSortieResonanceMinLevel } from '../data/sortieSynergy'; import { sortieRecommendationPlanIds, type SortieRecommendationPlanId } from '../data/sortieRecommendations'; import { isSortieOrderId, sortieOrderCheckIdsByOrder, sortieOrderDefinition, sortieOrderDisplayLabel, sortieOrderIds, sortieOrderRewardClaimId, type CampaignSortieOrderCommandSnapshot, type SortieOrderId, type SortieOrderResultSnapshot } from '../data/sortieOrders'; import { battleIdForCampaignStep, isCampCampaignStep } from './campaignRouting'; import { clearAllCampaignBattleSaves, clearCampaignBattleSavesForSlot } from './battleSaveKeys'; export type BattleObjectiveSnapshot = { id: string; label: string; achieved: boolean; status?: 'active' | 'done' | 'failed'; detail: string; category?: 'primary' | 'required' | 'bonus'; summary?: string; failureReason?: string; targetTile?: { x: number; y: number; radius?: number }; rewardGold: number; }; export type CampBondSnapshot = BattleBond & { battleExp: number; }; export type CampMvpSnapshot = { unitId: string; name: string; damageDealt: number; defeats: number; }; export type CampaignRewardSnapshot = { supplies: string[]; equipment: string[]; reputation: string[]; recruits: { unitId: string; name: string; }[]; unlocks: { battleId: string; title: string; }[]; note?: string; }; export const campaignVictoryRewardCategoryIds = [ 'gold', 'equipment', 'supplies', 'reputation', 'recruits', 'unlocks' ] as const; export type CampaignVictoryRewardCategoryId = (typeof campaignVictoryRewardCategoryIds)[number]; export type CampaignVictoryRewardAcknowledgements = Partial>; export type CampaignCampChoiceHistory = Record; const campaignVictoryRewardCategoryIdSet = new Set(campaignVictoryRewardCategoryIds); export type CampaignSortieUnitPerformanceSnapshot = { unitId: string; hpBefore: number; maxHpBefore: number; damageDealt: number; damageTaken: number; defeats: number; actions: number; support: number; strategyUses: number; signatureStrategyUses: number; sortieBonusDamage: number; sortiePreventedDamage: number; sortieBonusHealing: number; sortieExtendedBondTriggers: number; intentDefeats: number; intentLineBreaks: number; intentGuardSuccesses: number; }; export type CampaignSortiePerformanceSnapshot = { selectedUnitIds: string[]; formationAssignments: SortieFormationAssignments; unitStats: CampaignSortieUnitPerformanceSnapshot[]; }; export type CampaignSortieReviewGrade = 'S' | 'A' | 'B' | 'C' | 'D'; export type CampaignSortieReviewSnapshot = { version: 1; score: number; grade: CampaignSortieReviewGrade; activeBondCount: number; enemyCount: number; sourcePresetIds: CampaignSortiePresetId[]; }; export type CampaignSortieRecommendationSnapshot = { version: 1; battleId: BattleScenarioId; planId: SortieRecommendationPlanId; label: string; summary: string; sortieOrderId?: SortieOrderId; sortieResonanceBondId?: string; selectedUnitIds: string[]; formationAssignments: SortieFormationAssignments; unitReasons: Record; }; export type CampaignSortieCooperationSnapshot = { version: 1; bondId: string; unitIds: [string, string]; attempts: number; successes: number; additionalDamage: number; }; export type CampaignSortieOrderResultSnapshot = SortieOrderResultSnapshot; export type FirstBattleReport = { battleId: string; battleTitle: string; outcome: 'victory' | 'defeat'; turnNumber: number; rewardGold: number; defeatedEnemies: number; totalEnemies: number; objectives: BattleObjectiveSnapshot[]; units: UnitData[]; bonds: CampBondSnapshot[]; mvp?: CampMvpSnapshot; itemRewards: string[]; campaignRewards?: CampaignRewardSnapshot; sortiePerformance?: CampaignSortiePerformanceSnapshot; sortieReview?: CampaignSortieReviewSnapshot; sortieOrder?: CampaignSortieOrderResultSnapshot; sortieRecommendation?: CampaignSortieRecommendationSnapshot; sortieCooperation?: CampaignSortieCooperationSnapshot; completedCampDialogues: string[]; completedCampVisits: string[]; createdAt: string; }; export type CampaignVictoryRewardPresentation = { baseRewardGold: number; rewardGold: number; itemRewards: string[]; sortieOrderBonus?: { orderId: SortieOrderId; label: string; rewardGold: number; rewardItems: string[]; }; }; export type CampaignStep = | 'new' | 'prologue' | 'prologue-town' | 'prologue-camp' | 'first-battle' | 'first-camp' | 'first-victory-story' | 'second-battle' | 'second-camp' | 'third-battle' | 'third-camp' | 'fourth-battle' | 'fourth-camp' | 'fifth-battle' | 'fifth-camp' | 'sixth-battle' | 'sixth-camp' | 'seventh-battle' | 'seventh-camp' | 'eighth-battle' | 'eighth-camp' | 'ninth-battle' | 'ninth-camp' | 'tenth-battle' | 'tenth-camp' | 'eleventh-battle' | 'eleventh-camp' | 'twelfth-battle' | 'twelfth-camp' | 'thirteenth-battle' | 'thirteenth-camp' | 'fourteenth-battle' | 'fourteenth-camp' | 'fifteenth-battle' | 'fifteenth-camp' | 'sixteenth-battle' | 'sixteenth-camp' | 'seventeenth-battle' | 'seventeenth-camp' | 'eighteenth-battle' | 'eighteenth-camp' | 'nineteenth-battle' | 'nineteenth-camp' | 'twentieth-battle' | 'twentieth-camp' | 'twenty-first-battle' | 'twenty-first-camp' | 'twenty-second-battle' | 'twenty-second-camp' | 'twenty-third-battle' | 'twenty-third-camp' | 'twenty-fourth-battle' | 'twenty-fourth-camp' | 'twenty-fifth-battle' | 'twenty-fifth-camp' | 'twenty-sixth-battle' | 'twenty-sixth-camp' | 'twenty-seventh-battle' | 'twenty-seventh-camp' | 'twenty-eighth-battle' | 'twenty-eighth-camp' | 'twenty-ninth-battle' | 'twenty-ninth-camp' | 'thirtieth-battle' | 'thirtieth-camp' | 'thirty-first-battle' | 'thirty-first-camp' | 'thirty-second-battle' | 'thirty-second-camp' | 'thirty-third-battle' | 'thirty-third-camp' | 'thirty-fourth-battle' | 'thirty-fourth-camp' | 'thirty-fifth-battle' | 'thirty-fifth-camp' | 'thirty-sixth-battle' | 'thirty-sixth-camp' | 'thirty-seventh-battle' | 'thirty-seventh-camp' | 'thirty-eighth-battle' | 'thirty-eighth-camp' | 'thirty-ninth-battle' | 'thirty-ninth-camp' | 'fortieth-battle' | 'fortieth-camp' | 'forty-first-battle' | 'forty-first-camp' | 'forty-second-battle' | 'forty-second-camp' | 'forty-third-battle' | 'forty-third-camp' | 'forty-fourth-battle' | 'forty-fourth-camp' | 'forty-fifth-battle' | 'forty-fifth-camp' | 'forty-sixth-battle' | 'forty-sixth-camp' | 'forty-seventh-battle' | 'forty-seventh-camp' | 'forty-eighth-battle' | 'forty-eighth-camp' | 'forty-ninth-battle' | 'forty-ninth-camp' | 'fiftieth-battle' | 'fiftieth-camp' | 'fifty-first-battle' | 'fifty-first-camp' | 'fifty-second-battle' | 'fifty-second-camp' | 'fifty-third-battle' | 'fifty-third-camp' | 'fifty-fourth-battle' | 'fifty-fourth-camp' | 'fifty-fifth-battle' | 'fifty-fifth-camp' | 'fifty-sixth-battle' | 'fifty-sixth-camp' | 'fifty-seventh-battle' | 'fifty-seventh-camp' | 'fifty-eighth-battle' | 'fifty-eighth-camp' | 'fifty-ninth-battle' | 'fifty-ninth-camp' | 'sixtieth-battle' | 'sixtieth-camp' | 'sixty-first-battle' | 'sixty-first-camp' | 'sixty-second-battle' | 'sixty-second-camp' | 'sixty-third-battle' | 'sixty-third-camp' | 'sixty-fourth-battle' | 'sixty-fourth-camp' | 'sixty-fifth-battle' | 'sixty-fifth-camp' | 'sixty-sixth-battle' | 'sixty-sixth-camp' | 'ending-complete' | 'hanzhong-king-camp' | 'shu-han-foundation-camp' | 'baidi-entrustment-camp' | 'northern-campaign-prep-camp'; export type CampaignUnitProgressSnapshot = { unitId: string; name: string; level: number; exp: number; hp: number; maxHp: number; equipment: UnitData['equipment']; }; export type CampaignBondProgressSnapshot = { id: string; title: string; level: number; exp: number; battleExp: number; }; export type CampaignReserveTrainingSnapshot = { unitId: string; name: string; expGained: number; equipmentExpGained: number; bondExpGained?: number; focusId?: CampaignReserveTrainingFocusId; focusLabel?: string; level: number; exp: number; }; export type CampaignReserveTrainingFocusId = 'balanced' | 'class-practice' | 'bond-practice'; export type CampaignReserveTrainingFocusDefinition = { id: CampaignReserveTrainingFocusId; label: string; summary: string; expGained: number; equipmentExpGained: number; bondExpGained: number; }; export type CampaignReserveTrainingAssignments = Partial>; export const defaultCampaignReserveTrainingFocusId: CampaignReserveTrainingFocusId = 'balanced'; const defaultEquipmentBySlot: Record = { weapon: 'training-sword', armor: 'cloth-armor', accessory: 'grain-pouch' }; const sortieSupplyMaxByUsableId: Record = { bean: 2, wine: 1, salve: 1 }; const campaignInventoryCaps: Record = { ['\uCF69']: 120, ['\uC0C1\uCC98\uC57D']: 80, ['\uD0C1\uC8FC']: 80 }; const maxCampaignStringListEntries = 128; const maxCampaignStringListLength = 96; const maxCampaignCampHistoryEntries = 256; const maxCampaignUnknownInventoryAmount = 999; const maxCampaignRewardNoteLength = 180; const maxCampaignBattleObjectiveEntries = 24; const maxCampaignBattleUnitEntries = 96; const maxCampaignBattleBondEntries = 96; const maxCampaignReserveTrainingEntries = 96; const maxCampaignReserveTrainingAssignmentUnits = 96; const maxCampaignSortieAssignmentUnits = 96; const maxCampaignSortieOrderRewardClaims = 256; const maxCampaignDisplayTextLength = 180; const maxCampaignNumericValue = 999999; const maxCampaignObjectiveTargetRadius = 99; export const campaignReserveTrainingFocusDefinitions: CampaignReserveTrainingFocusDefinition[] = [ { id: 'balanced', label: '균형 훈련', summary: '대기 무장의 경험치와 장비 숙련을 고르게 올립니다.', expGained: 6, equipmentExpGained: 2, bondExpGained: 0 }, { id: 'class-practice', label: '병과 숙련', summary: '대기 무장의 장비 경험치를 더 올려 병과 운용을 다듬습니다.', expGained: 4, equipmentExpGained: 5, bondExpGained: 0 }, { id: 'bond-practice', label: '공명 합숙', summary: '대기 무장의 개인 성장은 낮지만 관련 공명 경험치를 함께 올립니다.', expGained: 5, equipmentExpGained: 1, bondExpGained: 4 } ]; export type CampaignBattleSettlement = { battleId: string; battleTitle: string; outcome: FirstBattleReport['outcome']; turnNumber: number; rewardGold: number; itemRewards: string[]; campaignRewards?: CampaignRewardSnapshot; objectives: BattleObjectiveSnapshot[]; units: CampaignUnitProgressSnapshot[]; bonds: CampaignBondProgressSnapshot[]; reserveTraining?: CampaignReserveTrainingSnapshot[]; sortiePerformance?: CampaignSortiePerformanceSnapshot; sortieReview?: CampaignSortieReviewSnapshot; sortieOrder?: CampaignSortieOrderResultSnapshot; sortieRecommendation?: CampaignSortieRecommendationSnapshot; sortieCooperation?: CampaignSortieCooperationSnapshot; completedAt: string; }; export type CampaignSortieItemAssignments = Record>; export const campaignSortiePresetIds = sortieOrderIds; export type CampaignSortiePresetId = SortieOrderId; export type CampaignSortiePreset = { selectedUnitIds: string[]; formationAssignments: SortieFormationAssignments; }; export type CampaignSortieFormationPresets = Partial>; export type CampaignSortieOrderSelection = { battleId: BattleScenarioId; orderId: CampaignSortiePresetId; }; export type CampaignSortieResonanceSelection = { battleId: BattleScenarioId; bondId: string; }; export type CampaignSortieOrderHistory = Partial< Record>> >; export const prologueVillageCampaignTutorialIds = { entered: 'prologue-village-entered', meetZhangFei: 'prologue-village-meet-zhang-fei', meetGuanYu: 'prologue-village-meet-guan-yu', registerVolunteers: 'prologue-village-register-volunteers', checkSupplies: 'prologue-village-check-supplies', complete: 'prologue-village-complete' } as const; export const prologueMilitiaCampCampaignTutorialIds = { entered: 'prologue-camp-entered', reviewScoutReport: 'prologue-camp-review-scout-report', readyVanguard: 'prologue-camp-ready-vanguard', inspectArms: 'prologue-camp-inspect-arms', reassureVolunteer: 'prologue-camp-reassure-volunteer', complete: 'prologue-camp-complete' } as const; export const campaignTutorialIds = [ 'first-battle-basic-controls', ...Object.values(prologueVillageCampaignTutorialIds), ...Object.values(prologueMilitiaCampCampaignTutorialIds) ] as const; export type CampaignTutorialId = (typeof campaignTutorialIds)[number]; const campaignTutorialIdSet = new Set(campaignTutorialIds); export type CampaignState = { version: 1; updatedAt: string; step: CampaignStep; activeSaveSlot: number; gold: number; roster: UnitData[]; bonds: CampBondSnapshot[]; inventory: Record; selectedSortieUnitIds: string[]; sortieFormationAssignments: SortieFormationAssignments; sortieItemAssignments: CampaignSortieItemAssignments; sortieFormationPresets: CampaignSortieFormationPresets; sortieOrderSelection?: CampaignSortieOrderSelection; sortieResonanceSelection?: CampaignSortieResonanceSelection; sortieRecommendationSelection?: CampaignSortieRecommendationSnapshot; sortieOrderHistory: CampaignSortieOrderHistory; claimedSortieOrderRewardIds: string[]; reserveTrainingFocus: CampaignReserveTrainingFocusId; reserveTrainingAssignments: CampaignReserveTrainingAssignments; completedTutorialIds: CampaignTutorialId[]; completedCampDialogues: string[]; completedCampVisits: string[]; campDialogueChoiceIds: CampaignCampChoiceHistory; campVisitChoiceIds: CampaignCampChoiceHistory; acknowledgedVictoryRewardBattleIds: string[]; acknowledgedVictoryRewardCategories: CampaignVictoryRewardAcknowledgements; dismissedVictoryRewardNoticeBattleIds: string[]; battleHistory: Record; latestBattleId?: string; pendingAftermathBattleId?: BattleScenarioId; activeCityStayId?: CityStayId; firstBattleReport?: FirstBattleReport; }; export const campaignStorageKey = 'heros-web:campaign-state'; export const campaignSaveSlotCount = 3; const campaignBattleSteps: Record = { 'first-battle-zhuo-commandery': { victory: 'first-camp', retry: 'first-battle' }, 'second-battle-yellow-turban-pursuit': { victory: 'second-camp', retry: 'second-battle' }, 'third-battle-guangzong-road': { victory: 'third-camp', retry: 'third-battle' }, 'fourth-battle-guangzong-camp': { victory: 'fourth-camp', retry: 'fourth-battle' }, 'fifth-battle-sishui-vanguard': { victory: 'fifth-camp', retry: 'fifth-battle' }, 'sixth-battle-jieqiao-relief': { victory: 'sixth-camp', retry: 'sixth-battle' }, 'seventh-battle-xuzhou-rescue': { victory: 'seventh-camp', retry: 'seventh-battle' }, 'eighth-battle-xiaopei-supply-road': { victory: 'eighth-camp', retry: 'eighth-battle' }, 'ninth-battle-xuzhou-gate-night-raid': { victory: 'ninth-camp', retry: 'ninth-battle' }, 'tenth-battle-xuzhou-breakout': { victory: 'tenth-camp', retry: 'tenth-battle' }, 'eleventh-battle-xudu-refuge-road': { victory: 'eleventh-camp', retry: 'eleventh-battle' }, 'twelfth-battle-xiapi-outer-siege': { victory: 'twelfth-camp', retry: 'twelfth-battle' }, 'thirteenth-battle-xiapi-final': { victory: 'thirteenth-camp', retry: 'thirteenth-battle' }, 'fourteenth-battle-cao-break': { victory: 'fourteenth-camp', retry: 'fourteenth-battle' }, 'fifteenth-battle-yuan-refuge-road': { victory: 'fifteenth-camp', retry: 'fifteenth-battle' }, 'sixteenth-battle-liu-biao-refuge': { victory: 'sixteenth-camp', retry: 'sixteenth-battle' }, 'seventeenth-battle-wolong-visit-road': { victory: 'seventeenth-camp', retry: 'seventeenth-battle' }, 'eighteenth-battle-bowang-ambush': { victory: 'eighteenth-camp', retry: 'eighteenth-battle' }, 'nineteenth-battle-changban-refuge': { victory: 'nineteenth-camp', retry: 'nineteenth-battle' }, 'twentieth-battle-jiangdong-envoy': { victory: 'twentieth-camp', retry: 'twentieth-battle' }, 'twenty-first-battle-red-cliffs-vanguard': { victory: 'twenty-first-camp', retry: 'twenty-first-battle' }, 'twenty-second-battle-red-cliffs-fire': { victory: 'twenty-second-camp', retry: 'twenty-second-battle' }, 'twenty-third-battle-jingzhou-south-entry': { victory: 'twenty-third-camp', retry: 'twenty-third-battle' }, 'twenty-fourth-battle-guiyang-persuasion': { victory: 'twenty-fourth-camp', retry: 'twenty-fourth-battle' }, 'twenty-fifth-battle-wuling-mountain-road': { victory: 'twenty-fifth-camp', retry: 'twenty-fifth-battle' }, 'twenty-sixth-battle-changsha-veteran': { victory: 'twenty-sixth-camp', retry: 'twenty-sixth-battle' }, 'twenty-seventh-battle-yizhou-relief-road': { victory: 'twenty-seventh-camp', retry: 'twenty-seventh-battle' }, 'twenty-eighth-battle-fu-pass-entry': { victory: 'twenty-eighth-camp', retry: 'twenty-eighth-battle' }, 'twenty-ninth-battle-luo-outer-wall': { victory: 'twenty-ninth-camp', retry: 'twenty-ninth-battle' }, 'thirtieth-battle-luofeng-ambush': { victory: 'thirtieth-camp', retry: 'thirtieth-battle' }, 'thirty-first-battle-luo-main-gate': { victory: 'thirty-first-camp', retry: 'thirty-first-battle' }, 'thirty-second-battle-mianzhu-gate': { victory: 'thirty-second-camp', retry: 'thirty-second-battle' }, 'thirty-third-battle-chengdu-surrender': { victory: 'thirty-third-camp', retry: 'thirty-third-battle' }, 'thirty-fourth-battle-jiameng-pass': { victory: 'thirty-fourth-camp', retry: 'thirty-fourth-battle' }, 'thirty-fifth-battle-yangping-scout': { victory: 'thirty-fifth-camp', retry: 'thirty-fifth-battle' }, 'thirty-sixth-battle-dingjun-vanguard': { victory: 'thirty-sixth-camp', retry: 'thirty-sixth-battle' }, 'thirty-seventh-battle-hanzhong-decisive': { victory: 'thirty-seventh-camp', retry: 'thirty-seventh-battle' }, 'thirty-eighth-battle-jing-defense': { victory: 'thirty-eighth-camp', retry: 'thirty-eighth-battle' }, 'thirty-ninth-battle-fan-castle-vanguard': { victory: 'thirty-ninth-camp', retry: 'thirty-ninth-battle' }, 'fortieth-battle-han-river-flood': { victory: 'fortieth-camp', retry: 'fortieth-battle' }, 'forty-first-battle-fan-castle-siege': { victory: 'forty-first-camp', retry: 'forty-first-battle' }, 'forty-second-battle-jing-rear-crisis': { victory: 'forty-second-camp', retry: 'forty-second-battle' }, 'forty-third-battle-gongan-collapse': { victory: 'forty-third-camp', retry: 'forty-third-battle' }, 'forty-fourth-battle-maicheng-isolation': { victory: 'forty-fourth-camp', retry: 'forty-fourth-battle' }, 'forty-fifth-battle-yiling-vanguard': { victory: 'forty-fifth-camp', retry: 'forty-fifth-battle' }, 'forty-sixth-battle-yiling-fire': { victory: 'forty-sixth-camp', retry: 'forty-sixth-battle' }, 'forty-seventh-battle-nanzhong-stabilization': { victory: 'forty-seventh-camp', retry: 'forty-seventh-battle' }, 'forty-eighth-battle-meng-huo-main-force': { victory: 'forty-eighth-camp', retry: 'forty-eighth-battle' }, 'forty-ninth-battle-meng-huo-second-capture': { victory: 'forty-ninth-camp', retry: 'forty-ninth-battle' }, 'fiftieth-battle-meng-huo-third-capture': { victory: 'fiftieth-camp', retry: 'fiftieth-battle' }, 'fifty-first-battle-meng-huo-fourth-capture': { victory: 'fifty-first-camp', retry: 'fifty-first-battle' }, 'fifty-second-battle-meng-huo-fifth-capture': { victory: 'fifty-second-camp', retry: 'fifty-second-battle' }, 'fifty-third-battle-meng-huo-sixth-capture': { victory: 'fifty-third-camp', retry: 'fifty-third-battle' }, 'fifty-fourth-battle-meng-huo-final-capture': { victory: 'fifty-fourth-camp', retry: 'fifty-fourth-battle' }, 'fifty-fifth-battle-northern-qishan-road': { victory: 'fifty-fifth-camp', retry: 'fifty-fifth-battle' }, 'fifty-sixth-battle-tianshui-advance': { victory: 'fifty-sixth-camp', retry: 'fifty-sixth-battle' }, 'fifty-seventh-battle-jieting-crisis': { victory: 'fifty-seventh-camp', retry: 'fifty-seventh-battle' }, 'fifty-eighth-battle-qishan-retreat': { victory: 'fifty-eighth-camp', retry: 'fifty-eighth-battle' }, 'fifty-ninth-battle-chencang-siege': { victory: 'fifty-ninth-camp', retry: 'fifty-ninth-battle' }, 'sixtieth-battle-wudu-yinping': { victory: 'sixtieth-camp', retry: 'sixtieth-battle' }, 'sixty-first-battle-hanzhong-rain-defense': { victory: 'sixty-first-camp', retry: 'sixty-first-battle' }, 'sixty-second-battle-qishan-renewed-offensive': { victory: 'sixty-second-camp', retry: 'sixty-second-battle' }, 'sixty-third-battle-lucheng-pursuit': { victory: 'sixty-third-camp', retry: 'sixty-third-battle' }, 'sixty-fourth-battle-weishui-camps': { victory: 'sixty-fourth-camp', retry: 'sixty-fourth-battle' }, 'sixty-fifth-battle-weishui-northbank': { victory: 'sixty-fifth-camp', retry: 'sixty-fifth-battle' }, 'sixty-sixth-battle-wuzhang-final': { victory: 'sixty-sixth-camp', retry: 'sixty-sixth-battle' } }; const campaignStepIds = new Set([ 'new', 'prologue', 'prologue-town', 'prologue-camp', 'first-victory-story', 'ending-complete', 'hanzhong-king-camp', 'shu-han-foundation-camp', 'baidi-entrustment-camp', 'northern-campaign-prep-camp', ...Object.values(campaignBattleSteps).flatMap(({ victory, retry }) => [victory, retry]) ]); export type CampaignSaveSlotSummary = { slot: number; exists: boolean; updatedAt?: string; step?: CampaignStep; gold?: number; battleTitle?: string; progressTitle?: string; progressMeta?: string; rosterCount?: number; inventoryCount?: number; }; let campaignState: CampaignState | undefined; const campaignRecruitUnitById = new Map(campaignRecruitUnits.map((unit) => [unit.id, unit])); const campaignStepProgressLabels: Partial> = { new: { title: '새 캠페인', meta: '시작 전' }, prologue: { title: '황건 봉기 · 탁현', meta: '의병의 시작' }, 'prologue-town': { title: '탁현에서 모인 뜻', meta: '장비·관우와의 만남' }, 'prologue-camp': { title: '탁현 의용군 막사', meta: '첫 출전 준비' }, 'first-victory-story': { title: '탁현 방어 성공', meta: '승리 후 이야기' }, 'hanzhong-king-camp': { title: '한중왕 즉위 준비', meta: '군영 의정' }, 'shu-han-foundation-camp': { title: '촉한 건국 선포', meta: '군영 의정' }, 'baidi-entrustment-camp': { title: '백제성 유탁', meta: '군영 의정' }, 'northern-campaign-prep-camp': { title: '북벌 준비 회의', meta: '군영 의정' }, 'ending-complete': { title: '북벌의 끝과 남은 뜻', meta: '완료' } }; export function summarizeCampaignProgress(state: CampaignState) { const pendingAftermath = state.pendingAftermathBattleId ? state.battleHistory[state.pendingAftermathBattleId] : undefined; if (pendingAftermath?.outcome === 'victory') { return { title: pendingAftermath.battleTitle, meta: '승리 후 이야기' }; } const battleId = battleIdForCampaignStep(state.step); const battleTitle = battleId ? scenarioTitle(battleId) : undefined; if (battleId && battleTitle) { const settlement = state.battleHistory[battleId]; return { title: battleTitle, meta: settlement?.outcome === 'defeat' ? '재도전 준비' : '출전 준비' }; } const special = campaignStepProgressLabels[state.step]; if (special) { return special; } const latestSettlement = latestCampaignSettlement(state); if (isCampCampaignStep(state.step) && latestSettlement) { return { title: latestSettlement.battleTitle, meta: latestSettlement.outcome === 'victory' ? '승리 후 군영' : '재정비' }; } if (latestSettlement) { return { title: latestSettlement.battleTitle, meta: latestSettlement.outcome === 'victory' ? '최근 승리' : '최근 전투' }; } if (state.firstBattleReport) { return { title: state.firstBattleReport.battleTitle, meta: state.firstBattleReport.outcome === 'victory' ? '승리 기록' : '전투 기록' }; } return { title: '캠페인 진행', meta: '진행 중' }; } function scenarioTitle(id?: BattleScenarioId | string) { if (id && id in battleScenarios) { return battleScenarios[id as BattleScenarioId].title; } return undefined; } function latestCampaignSettlement(state: CampaignState) { if (state.latestBattleId) { const latest = state.battleHistory[state.latestBattleId]; if (latest) { return latest; } } return Object.values(state.battleHistory) .sort((a, b) => String(b.completedAt).localeCompare(String(a.completedAt)))[0]; } function countCampaignInventory(inventory: Record) { return Object.values(inventory).reduce((total, amount) => total + Math.max(0, amount), 0); } export function createInitialCampaignState(): CampaignState { return { version: 1, updatedAt: new Date().toISOString(), step: 'new', activeSaveSlot: 1, gold: 0, roster: [], bonds: [], inventory: {}, selectedSortieUnitIds: [], sortieFormationAssignments: {}, sortieItemAssignments: {}, sortieFormationPresets: {}, sortieOrderHistory: {}, claimedSortieOrderRewardIds: [], reserveTrainingFocus: defaultCampaignReserveTrainingFocusId, reserveTrainingAssignments: {}, completedTutorialIds: [], completedCampDialogues: [], completedCampVisits: [], campDialogueChoiceIds: {}, campVisitChoiceIds: {}, acknowledgedVictoryRewardBattleIds: [], acknowledgedVictoryRewardCategories: {}, dismissedVictoryRewardNoticeBattleIds: [], battleHistory: {} }; } export function startNewCampaign() { const state = createInitialCampaignState(); clearCampaignBattleSavesForSlot(state.activeSaveSlot); state.step = 'prologue'; return setCampaignState(state); } export function getCampaignState() { return cloneCampaignState(ensureCampaignState()); } export function setCampaignState(state: CampaignState) { campaignState = normalizeCampaignState(state); campaignState.updatedAt = new Date().toISOString(); persistCampaignState(campaignState); return cloneCampaignState(campaignState); } export function loadCampaignState(slot?: number) { campaignState = readStoredCampaignState(slot) ?? readStoredCampaignState() ?? readLatestSlottedCampaignState() ?? createInitialCampaignState(); return cloneCampaignState(campaignState); } export function readCampaignSaveState(slot: number) { const state = readStoredCampaignState(slot); return state ? cloneCampaignState(state) : undefined; } export function saveCampaignState(state = ensureCampaignState(), slot = state.activeSaveSlot) { campaignState = normalizeCampaignState(state); campaignState.activeSaveSlot = normalizeSlot(slot); campaignState.updatedAt = new Date().toISOString(); persistCampaignState(campaignState); return cloneCampaignState(campaignState); } export function resetCampaignState() { clearAllCampaignBattleSaves(); campaignState = createInitialCampaignState(); tryStorage()?.removeItem(campaignStorageKey); for (let slot = 1; slot <= campaignSaveSlotCount; slot += 1) { tryStorage()?.removeItem(campaignSaveSlotKey(slot)); } return cloneCampaignState(campaignState); } export function hasCampaignSave() { return Boolean(readStoredCampaignState() ?? readLatestSlottedCampaignState()); } export function listCampaignSaveSlots(): CampaignSaveSlotSummary[] { return Array.from({ length: campaignSaveSlotCount }, (_, index) => { const slot = index + 1; const state = readStoredCampaignState(slot); if (!state) { return { slot, exists: false }; } const progress = summarizeCampaignProgress(state); return { slot, exists: true, updatedAt: state.updatedAt, step: state.step, gold: state.gold, battleTitle: progress.title, progressTitle: progress.title, progressMeta: progress.meta, rosterCount: state.roster.length, inventoryCount: countCampaignInventory(state.inventory) }; }); } export function markCampaignStep(step: CampaignStep) { const state = ensureCampaignState(); state.step = step; if (!normalizeActiveCityStayId(state.activeCityStayId, state.latestBattleId, step)) { delete state.activeCityStayId; } state.updatedAt = new Date().toISOString(); persistCampaignState(state); return cloneCampaignState(state); } export function setActiveCityStayId(cityStayId?: CityStayId) { const state = ensureCampaignState(); const normalizedCityStayId = normalizeActiveCityStayId(cityStayId, state.latestBattleId, state.step); if (normalizedCityStayId) { state.activeCityStayId = normalizedCityStayId; } else { delete state.activeCityStayId; } state.updatedAt = new Date().toISOString(); persistCampaignState(state); return cloneCampaignState(state); } export function hasCompletedCampaignTutorial(id: CampaignTutorialId, state = ensureCampaignState()) { return state.completedTutorialIds.includes(id); } export function completeCampaignTutorial(id: CampaignTutorialId) { const state = ensureCampaignState(); if (!state.completedTutorialIds.includes(id)) { state.completedTutorialIds.push(id); state.updatedAt = new Date().toISOString(); persistCampaignState(state); } return cloneCampaignState(state); } export function setCampaignSortieOrderSelection(battleId: BattleScenarioId, orderId?: CampaignSortiePresetId) { const state = ensureCampaignState(); if (!(battleId in battleScenarios)) { return cloneCampaignState(state); } if (orderId && isSortieOrderId(orderId)) { state.sortieOrderSelection = { battleId, orderId }; } else if (!state.sortieOrderSelection || state.sortieOrderSelection.battleId === battleId) { delete state.sortieOrderSelection; } state.updatedAt = new Date().toISOString(); persistCampaignState(state); return cloneCampaignState(state); } export function setCampaignSortieResonanceSelection(battleId: BattleScenarioId, bondId?: string) { const state = ensureCampaignState(); if (!hasCampaignBattleScenario(battleId)) { return cloneCampaignState(state); } if (bondId) { const normalizedBondId = normalizeKeyString(bondId); const bond = resolveCampaignSortieResonanceBond(battleId, normalizedBondId, state.bonds); const selectedUnitIds = new Set(state.selectedSortieUnitIds); if ( !bond || bond.level < coreSortieResonanceMinLevel || !bond.unitIds.every((unitId) => selectedUnitIds.has(unitId)) ) { return cloneCampaignState(state); } state.sortieResonanceSelection = { battleId, bondId: bond.id }; } else if (!state.sortieResonanceSelection || state.sortieResonanceSelection.battleId === battleId) { delete state.sortieResonanceSelection; } state.updatedAt = new Date().toISOString(); persistCampaignState(state); return cloneCampaignState(state); } export function setCampaignReserveTrainingFocus(focusId: CampaignReserveTrainingFocusId) { const state = ensureCampaignState(); const normalizedFocusId = normalizeReserveTrainingFocusId(focusId); const rosterUnitIds = normalizedRosterUnitIds(state.roster); const allowedUnitIds = rosterUnitIds.size > 0 ? rosterUnitIds : undefined; const assignments = normalizeCampaignReserveTrainingAssignments(state.reserveTrainingAssignments, allowedUnitIds); state.reserveTrainingFocus = normalizedFocusId; state.reserveTrainingAssignments = Object.fromEntries( Object.entries(assignments).filter(([, assignedFocusId]) => assignedFocusId !== normalizedFocusId) ) as CampaignReserveTrainingAssignments; state.updatedAt = new Date().toISOString(); persistCampaignState(state); return cloneCampaignState(state); } export function setCampaignReserveTrainingAssignment(unitId: string, focusId?: CampaignReserveTrainingFocusId) { const state = ensureCampaignState(); const normalizedUnitId = normalizeKeyString(unitId); const rosterUnitIds = normalizedRosterUnitIds(state.roster); const allowedUnitIds = rosterUnitIds.size > 0 ? rosterUnitIds : undefined; if (!normalizedUnitId || (allowedUnitIds && !allowedUnitIds.has(normalizedUnitId))) { return cloneCampaignState(state); } const assignments = normalizeCampaignReserveTrainingAssignments(state.reserveTrainingAssignments, allowedUnitIds); if (focusId && isReserveTrainingFocusId(focusId)) { assignments[normalizedUnitId] = focusId; } else { delete assignments[normalizedUnitId]; } state.reserveTrainingAssignments = assignments; state.updatedAt = new Date().toISOString(); persistCampaignState(state); return cloneCampaignState(state); } export function normalizeCampaignReserveTrainingAssignments( assignments?: Record, allowedUnitIds?: Set ): CampaignReserveTrainingAssignments { return Object.entries(assignments ?? {}).reduce((next, [unitId, focusId]) => { const normalizedUnitId = normalizeKeyString(unitId); if ( !normalizedUnitId || next[normalizedUnitId] || (allowedUnitIds && !allowedUnitIds.has(normalizedUnitId)) || (!allowedUnitIds && Object.keys(next).length >= maxCampaignReserveTrainingAssignmentUnits) || !isReserveTrainingFocusId(focusId) ) { return next; } next[normalizedUnitId] = focusId; return next; }, {}); } export function setFirstBattleReport(report: FirstBattleReport) { const state = ensureCampaignState(); const reportClone = cloneReport(report); const battleId = reportClone.battleId; const normalizedRecommendation = normalizeCampaignSortieRecommendationSnapshot(reportClone.sortieRecommendation, { expectedBattleId: battleId }); if (normalizedRecommendation) { reportClone.sortieRecommendation = normalizedRecommendation; } else { delete reportClone.sortieRecommendation; } const previousSettlement = state.battleHistory[battleId]; const completedCampDialogues = mergeCampCompletionIds( state.completedCampDialogues, reportClone.completedCampDialogues, Object.keys(state.campDialogueChoiceIds) ); const completedCampVisits = mergeCampCompletionIds( state.completedCampVisits, reportClone.completedCampVisits ?? [], Object.keys(state.campVisitChoiceIds) ); reportClone.completedCampDialogues = completedCampDialogues; reportClone.completedCampVisits = completedCampVisits; let pendingSortieOrderReward: ReturnType | undefined; let pendingSortieOrderClaimId: string | undefined; const sortieOrder = normalizeCampaignSortieOrderResultSnapshot( reportClone.sortieOrder, reportClone.outcome, { performance: normalizeCampaignSortiePerformanceSnapshot(reportClone.sortiePerformance), turnNumber: normalizeNonNegativeInteger(reportClone.turnNumber) } ); if (sortieOrder) { if (reportClone.outcome !== 'victory') { const victoryProgress = sortieOrder.progress.find((entry) => entry.id === 'victory'); if (victoryProgress) { victoryProgress.value = 0; victoryProgress.achieved = false; } sortieOrder.achieved = false; } sortieOrder.rewardGranted = false; const claimId = sortieOrderRewardClaimId(battleId, sortieOrder.orderId); if (sortieOrder.achieved && !state.claimedSortieOrderRewardIds.includes(claimId)) { pendingSortieOrderReward = sortieOrderDefinition(sortieOrder.orderId); pendingSortieOrderClaimId = claimId; sortieOrder.rewardGranted = true; } const history = state.sortieOrderHistory[battleId as BattleScenarioId] ?? {}; const previousOrderResult = history[sortieOrder.orderId]; const keepPreviousBest = Boolean( previousOrderResult && ( (previousOrderResult.achieved && !sortieOrder.achieved) || (previousOrderResult.achieved === sortieOrder.achieved && previousOrderResult.score > sortieOrder.score) ) ); const bestOrderResult = keepPreviousBest ? previousOrderResult! : sortieOrder; history[sortieOrder.orderId] = { ...bestOrderResult, rewardGranted: Boolean(previousOrderResult?.rewardGranted || sortieOrder.rewardGranted), progress: bestOrderResult.progress.map((entry) => ({ ...entry })), ...(bestOrderResult.command ? { command: { ...bestOrderResult.command } } : {}) }; state.sortieOrderHistory[battleId as BattleScenarioId] = history; reportClone.sortieOrder = sortieOrder; } else { delete reportClone.sortieOrder; } state.firstBattleReport = reportClone; const stepRule = campaignBattleSteps[battleId] ?? campaignBattleSteps['first-battle-zhuo-commandery']; const victoryStep: CampaignStep = stepRule.victory; const retryStep: CampaignStep = stepRule.retry; if (reportClone.outcome !== 'victory') { if (reportClone.sortieOrder) { state.sortieOrderSelection = { battleId: battleId as BattleScenarioId, orderId: reportClone.sortieOrder.orderId }; } if (reportClone.sortieRecommendation) { state.sortieRecommendationSelection = cloneCampaignSortieRecommendationSnapshot(reportClone.sortieRecommendation); const resonanceBondId = reportClone.sortieRecommendation.sortieResonanceBondId; const resonanceBond = resonanceBondId ? resolveCampaignSortieResonanceBond(battleId, resonanceBondId, state.bonds) : undefined; const selectedUnitIds = new Set(state.selectedSortieUnitIds); if ( resonanceBond && resonanceBond.level >= coreSortieResonanceMinLevel && resonanceBond.unitIds.every((unitId) => selectedUnitIds.has(unitId)) ) { state.sortieResonanceSelection = { battleId: battleId as BattleScenarioId, bondId: resonanceBond.id }; } } state.step = retryStep; state.latestBattleId = battleId; delete state.activeCityStayId; delete state.pendingAftermathBattleId; state.updatedAt = new Date().toISOString(); persistCampaignState(state); return cloneReport(reportClone); } state.step = victoryStep; state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold); const alliedReportUnits = reportClone.units.filter((unit) => unit.faction === 'ally'); const recruitUnitIds = new Set(reportClone.campaignRewards?.recruits.map((recruit) => recruit.unitId) ?? []); const reportUnitById = new Map(reportClone.units.filter((unit) => unit.faction === 'ally').map((unit) => [unit.id, unit])); const recruitedUnits = [...recruitUnitIds] .map((unitId) => reportUnitById.get(unitId) ?? campaignRecruitUnitById.get(unitId)) .filter((unit): unit is UnitData => Boolean(unit)); state.roster = mergeRosterProgress(state.roster, [...alliedReportUnits, ...recruitedUnits]); state.bonds = reportClone.bonds.map(cloneBondSnapshot); backfillEarlyJianYongRecruit(state); const reserveTraining = previousSettlement || reportClone.outcome !== 'victory' ? previousSettlement?.reserveTraining ?? [] : applyReserveTrainingProgress( state.roster, state.bonds, reportClone.units.filter((unit) => unit.faction === 'ally'), state.reserveTrainingFocus, state.reserveTrainingAssignments ); if (!previousSettlement && reportClone.outcome === 'victory') { reportClone.bonds = state.bonds.map(cloneBondSnapshot); } state.completedCampDialogues = completedCampDialogues; state.completedCampVisits = completedCampVisits; state.inventory = applyRewardDelta(state.inventory, previousSettlement?.itemRewards ?? [], -1); state.inventory = applyRewardDelta(state.inventory, reportClone.itemRewards, 1); if (pendingSortieOrderReward && pendingSortieOrderClaimId) { state.gold += pendingSortieOrderReward.rewardGold; state.inventory = applyRewardDelta(state.inventory, pendingSortieOrderReward.rewardItems, 1); state.claimedSortieOrderRewardIds.push(pendingSortieOrderClaimId); } state.battleHistory[battleId] = createBattleSettlement(reportClone, reserveTraining); if (state.sortieOrderSelection?.battleId === battleId) { delete state.sortieOrderSelection; } if (state.sortieResonanceSelection?.battleId === battleId) { delete state.sortieResonanceSelection; } if (state.sortieRecommendationSelection?.battleId === battleId) { delete state.sortieRecommendationSelection; } state.latestBattleId = battleId; delete state.activeCityStayId; state.pendingAftermathBattleId = battleId as BattleScenarioId; state.updatedAt = new Date().toISOString(); persistCampaignState(state); return cloneReport(reportClone); } export function getFirstBattleReport() { const report = ensureCampaignState().firstBattleReport; return report ? cloneReport(report) : undefined; } export function completeCampaignAftermath(battleId: BattleScenarioId) { const state = ensureCampaignState(); if (state.pendingAftermathBattleId !== battleId) { return cloneCampaignState(state); } delete state.pendingAftermathBattleId; state.updatedAt = new Date().toISOString(); persistCampaignState(state); return cloneCampaignState(state); } export type CampaignVictoryRewardReport = Pick< FirstBattleReport, 'battleId' | 'battleTitle' | 'outcome' | 'rewardGold' | 'itemRewards' | 'campaignRewards' | 'sortieOrder' >; export function campaignVictoryRewardPresentation(report: CampaignVictoryRewardReport): CampaignVictoryRewardPresentation { const definition = report.outcome === 'victory' && report.sortieOrder?.rewardGranted ? sortieOrderDefinition(report.sortieOrder.orderId) : undefined; const sortieOrderBonus = definition ? { orderId: definition.id, label: sortieOrderDisplayLabel(report.battleId, definition.id), rewardGold: definition.rewardGold, rewardItems: [...definition.rewardItems] } : undefined; return { baseRewardGold: report.rewardGold, rewardGold: report.rewardGold + (sortieOrderBonus?.rewardGold ?? 0), itemRewards: [...report.itemRewards, ...(sortieOrderBonus?.rewardItems ?? [])], sortieOrderBonus }; } type CampaignVictoryRewardSource = Pick< FirstBattleReport, 'battleId' | 'rewardGold' | 'itemRewards' | 'campaignRewards' | 'sortieOrder' >; export function campaignVictoryRewardCategoriesFor( report: CampaignVictoryRewardSource ): CampaignVictoryRewardCategoryId[] { const categories: CampaignVictoryRewardCategoryId[] = []; const rewards = report.campaignRewards; const orderDefinition = report.sortieOrder?.rewardGranted ? sortieOrderDefinition(report.sortieOrder.orderId) : undefined; if (report.rewardGold + (orderDefinition?.rewardGold ?? 0) > 0) { categories.push('gold'); } const categorizedRewardItems = new Set([ ...(rewards?.supplies ?? []), ...(rewards?.equipment ?? []), ...(rewards?.reputation ?? []) ]); const legacyExtraItems = report.itemRewards.filter((item) => !categorizedRewardItems.has(item)); const supplyItems = rewards ? [...rewards.supplies, ...legacyExtraItems, ...(orderDefinition?.rewardItems ?? [])] : [...report.itemRewards, ...(orderDefinition?.rewardItems ?? [])]; if ((rewards?.equipment.length ?? 0) > 0) { categories.push('equipment'); } if (supplyItems.length > 0) { categories.push('supplies'); } if ((rewards?.reputation.length ?? 0) > 0) { categories.push('reputation'); } if ((rewards?.recruits.length ?? 0) > 0) { categories.push('recruits'); } if ((rewards?.unlocks.length ?? 0) > 0) { categories.push('unlocks'); } return categories; } export function getPendingCampaignVictoryRewardCategories(battleId?: string) { const state = ensureCampaignState(); if (battleId === undefined) { const pending = new Set( campaignVictoryRewardBattleIds(state).flatMap((candidateBattleId) => pendingCampaignVictoryRewardCategoriesFor(state, candidateBattleId) ) ); return campaignVictoryRewardCategoryIds.filter((category) => pending.has(category)); } return pendingCampaignVictoryRewardCategoriesFor(state, normalizeKeyString(battleId)); } export function getPendingCampaignVictoryRewardBattleIds() { const state = ensureCampaignState(); return campaignVictoryRewardBattleIds(state).filter( (battleId) => pendingCampaignVictoryRewardCategoriesFor(state, battleId).length > 0 ); } function pendingCampaignVictoryRewardCategoriesFor(state: CampaignState, normalizedBattleId: string) { const report = campaignVictoryRewardSourceFor(state, normalizedBattleId); if (!normalizedBattleId || !report || !isCampaignVictory(report)) { return [] as CampaignVictoryRewardCategoryId[]; } if (state.acknowledgedVictoryRewardBattleIds.includes(normalizedBattleId)) { return [] as CampaignVictoryRewardCategoryId[]; } const acknowledged = new Set(state.acknowledgedVictoryRewardCategories[normalizedBattleId] ?? []); return campaignVictoryRewardCategoriesFor(report).filter((category) => !acknowledged.has(category)); } export function getPendingCampaignVictoryRewardReport() { const state = ensureCampaignState(); const battleId = [...campaignVictoryRewardBattleIds(state)] .reverse() .find((candidateBattleId) => pendingCampaignVictoryRewardCategoriesFor(state, candidateBattleId).length > 0); const report = battleId ? campaignVictoryRewardSourceFor(state, battleId) : undefined; if (!report || report.outcome !== 'victory') { return undefined; } return cloneCampaignVictoryRewardReport(report); } export function getPendingCampaignVictoryRewardNoticeReport() { const state = ensureCampaignState(); const battleId = state.latestBattleId; if ( !battleId || state.dismissedVictoryRewardNoticeBattleIds.includes(battleId) || pendingCampaignVictoryRewardCategoriesFor(state, battleId).length === 0 ) { return undefined; } const report = battleId ? campaignVictoryRewardSourceFor(state, battleId) : undefined; if (!report || report.outcome !== 'victory') { return undefined; } return cloneCampaignVictoryRewardReport(report); } export function dismissCampaignVictoryRewardNotice(battleId: string) { const state = ensureCampaignState(); const normalizedBattleId = normalizeKeyString(battleId); const report = campaignVictoryRewardSourceFor(state, normalizedBattleId); if ( !normalizedBattleId || !(normalizedBattleId in battleScenarios) || !report || !isCampaignVictory(report) || state.dismissedVictoryRewardNoticeBattleIds.includes(normalizedBattleId) ) { return cloneCampaignState(state); } state.dismissedVictoryRewardNoticeBattleIds = [ ...state.dismissedVictoryRewardNoticeBattleIds, normalizedBattleId ]; state.updatedAt = new Date().toISOString(); persistCampaignState(state); return cloneCampaignState(state); } export function acknowledgeCampaignVictoryReward( battleId: string, requestedCategories?: readonly CampaignVictoryRewardCategoryId[] ) { const state = ensureCampaignState(); const normalizedBattleId = normalizeKeyString(battleId); const report = campaignVictoryRewardSourceFor(state, normalizedBattleId); if ( !normalizedBattleId || !(normalizedBattleId in battleScenarios) || !report || !isCampaignVictory(report) ) { return cloneCampaignState(state); } const availableCategories = campaignVictoryRewardCategoriesFor(report); const availableCategorySet = new Set(availableCategories); const categories = requestedCategories === undefined ? availableCategories : uniqueVictoryRewardCategories(requestedCategories).filter((category) => availableCategorySet.has(category)); const previousCategories = state.acknowledgedVictoryRewardCategories[normalizedBattleId] ?? []; const acknowledgedCategories = uniqueVictoryRewardCategories([...previousCategories, ...categories]); const fullyAcknowledged = availableCategories.length > 0 && availableCategories.every((category) => acknowledgedCategories.includes(category)); const categoryChanged = JSON.stringify(previousCategories) !== JSON.stringify(acknowledgedCategories); const battleAcknowledged = state.acknowledgedVictoryRewardBattleIds.includes(normalizedBattleId); if (categoryChanged) { state.acknowledgedVictoryRewardCategories = { ...state.acknowledgedVictoryRewardCategories, [normalizedBattleId]: acknowledgedCategories }; } if (fullyAcknowledged && !battleAcknowledged) { state.acknowledgedVictoryRewardBattleIds = [ ...state.acknowledgedVictoryRewardBattleIds, normalizedBattleId ]; } if (categoryChanged || (fullyAcknowledged && !battleAcknowledged)) { state.updatedAt = new Date().toISOString(); persistCampaignState(state); } return cloneCampaignState(state); } function campaignVictoryRewardSourceFor(state: CampaignState, battleId: string) { if (state.firstBattleReport?.battleId === battleId) { return state.firstBattleReport; } return state.battleHistory[battleId]; } function campaignVictoryRewardBattleIds(state: CampaignState) { const battleIds = Object.keys(state.battleHistory).filter((battleId) => isCampaignVictory(state.battleHistory[battleId])); const latestReportBattleId = state.firstBattleReport?.outcome === 'victory' ? state.firstBattleReport.battleId : undefined; if (latestReportBattleId && !battleIds.includes(latestReportBattleId)) { battleIds.push(latestReportBattleId); } return battleIds; } function cloneCampaignVictoryRewardReport( report: CampaignVictoryRewardReport ): CampaignVictoryRewardReport { return { battleId: report.battleId, battleTitle: report.battleTitle, outcome: report.outcome, rewardGold: report.rewardGold, itemRewards: normalizeRewardStrings(report.itemRewards), campaignRewards: cloneCampaignRewardSnapshot(report.campaignRewards, report.battleId), ...(report.sortieOrder ? { sortieOrder: JSON.parse(JSON.stringify(report.sortieOrder)) as CampaignSortieOrderResultSnapshot } : {}) }; } function isCampaignVictory(report: FirstBattleReport | CampaignBattleSettlement) { return report.outcome === 'victory'; } function uniqueVictoryRewardCategories(value: unknown): CampaignVictoryRewardCategoryId[] { const categories = new Set(uniqueStrings(value).filter((category) => campaignVictoryRewardCategoryIdSet.has(category))); return campaignVictoryRewardCategoryIds.filter((category) => categories.has(category)); } export function applyCampBondExp(dialogueId: string, bondId: string, amount: number, choiceId?: string) { const state = ensureCampaignState(); if (!state.firstBattleReport) { return undefined; } if ( hasCampCompletionFlag( state.completedCampDialogues, state.firstBattleReport.completedCampDialogues, state.campDialogueChoiceIds, dialogueId ) ) { const completionSynced = syncCompletionFlag( state.completedCampDialogues, state.firstBattleReport.completedCampDialogues, dialogueId ); const choiceRemembered = rememberCampChoice(state.campDialogueChoiceIds, dialogueId, choiceId); if (completionSynced || choiceRemembered) { state.updatedAt = new Date().toISOString(); persistCampaignState(state); } return cloneReport(state.firstBattleReport); } const campaignBond = state.bonds.find((candidate) => candidate.id === bondId); const reportBond = state.firstBattleReport.bonds.find((candidate) => candidate.id === bondId); if (!campaignBond || !reportBond) { return undefined; } syncCompletionFlag(state.completedCampDialogues, state.firstBattleReport.completedCampDialogues, dialogueId); rememberCampChoice(state.campDialogueChoiceIds, dialogueId, choiceId); advanceBond(campaignBond, amount); reportBond.level = campaignBond.level; reportBond.exp = campaignBond.exp; reportBond.battleExp = campaignBond.battleExp; state.updatedAt = new Date().toISOString(); persistCampaignState(state); return cloneReport(state.firstBattleReport); } export function applyCampVisitReward( visitId: string, reward: { bondId?: string; bondExp?: number; gold?: number; itemRewards?: string[] }, choiceId?: string ) { const state = ensureCampaignState(); if (!state.firstBattleReport) { return undefined; } if ( hasCampCompletionFlag( state.completedCampVisits, state.firstBattleReport.completedCampVisits, state.campVisitChoiceIds, visitId ) ) { const completionSynced = syncCompletionFlag( state.completedCampVisits, state.firstBattleReport.completedCampVisits, visitId ); const choiceRemembered = rememberCampChoice(state.campVisitChoiceIds, visitId, choiceId); if (completionSynced || choiceRemembered) { state.updatedAt = new Date().toISOString(); persistCampaignState(state); } return cloneCampaignState(state); } syncCompletionFlag(state.completedCampVisits, state.firstBattleReport.completedCampVisits, visitId); rememberCampChoice(state.campVisitChoiceIds, visitId, choiceId); if (reward.bondId && reward.bondExp && reward.bondExp > 0) { const campaignBond = state.bonds.find((candidate) => candidate.id === reward.bondId); const reportBond = state.firstBattleReport.bonds.find((candidate) => candidate.id === reward.bondId); if (campaignBond) { advanceBond(campaignBond, reward.bondExp); if (reportBond) { reportBond.level = campaignBond.level; reportBond.exp = campaignBond.exp; reportBond.battleExp = campaignBond.battleExp; } } } if (reward.gold && reward.gold > 0) { state.gold += reward.gold; } if (reward.itemRewards?.length) { state.inventory = applyRewardDelta(state.inventory, reward.itemRewards, 1); } state.updatedAt = new Date().toISOString(); persistCampaignState(state); return cloneCampaignState(state); } function hasCampCompletionFlag( stateFlags: string[], reportFlags: string[], choiceHistory: CampaignCampChoiceHistory, flagId: string ) { const normalizedFlagId = normalizeKeyString(flagId); return ( stateFlags.includes(flagId) || reportFlags.includes(flagId) || Boolean(normalizedFlagId && choiceHistory[normalizedFlagId]) ); } function rememberCampChoice(history: CampaignCampChoiceHistory, entryId: string, choiceId?: string) { const normalizedEntryId = normalizeKeyString(entryId); const normalizedChoiceId = normalizeKeyString(choiceId); if (!normalizedEntryId || !normalizedChoiceId || history[normalizedEntryId]) { return false; } if (Object.keys(history).length >= maxCampaignCampHistoryEntries) { return false; } history[normalizedEntryId] = normalizedChoiceId; return true; } function syncCompletionFlag(stateFlags: string[], reportFlags: string[], flagId: string) { let changed = false; if (!stateFlags.includes(flagId)) { stateFlags.push(flagId); changed = true; } if (!reportFlags.includes(flagId)) { reportFlags.push(flagId); changed = true; } return changed; } export function ensureCampaignRosterUnits(units: UnitData[], bonds: BattleBond[] = []) { const state = ensureCampaignState(); let changed = false; const existingUnitIds = new Set(state.roster.map((unit) => unit.id)); units.forEach((unit) => { if (existingUnitIds.has(unit.id)) { return; } state.roster.push(cloneUnit(unit)); existingUnitIds.add(unit.id); changed = true; }); const existingBondIds = new Set(state.bonds.map((bond) => bond.id)); bonds.forEach((bond) => { if (existingBondIds.has(bond.id)) { return; } state.bonds.push(createCampBondSnapshot(bond)); existingBondIds.add(bond.id); changed = true; }); if (state.firstBattleReport) { const reportUnitIds = new Set(state.firstBattleReport.units.map((unit) => unit.id)); units.forEach((unit) => { if (reportUnitIds.has(unit.id)) { return; } state.firstBattleReport?.units.push(cloneUnit(unit)); reportUnitIds.add(unit.id); changed = true; }); const reportBondIds = new Set(state.firstBattleReport.bonds.map((bond) => bond.id)); bonds.forEach((bond) => { if (reportBondIds.has(bond.id)) { return; } state.firstBattleReport?.bonds.push(createCampBondSnapshot(bond)); reportBondIds.add(bond.id); changed = true; }); } if (changed) { state.updatedAt = new Date().toISOString(); persistCampaignState(state); } return cloneCampaignState(state); } const earlyJianYongRecruitBattleId: BattleScenarioId = 'first-battle-zhuo-commandery'; /** * 첫 승리 보상에 간옹이 추가되기 전에 만들어진 저장도 동일한 4인 편성 풀을 갖게 한다. * 반복 호출해도 roster, reward, report, bond에 중복 항목을 만들지 않는다. */ function backfillEarlyJianYongRecruit(state: CampaignState) { const firstSettlement = state.battleHistory[earlyJianYongRecruitBattleId]; const firstReport = state.firstBattleReport?.battleId === earlyJianYongRecruitBattleId ? state.firstBattleReport : undefined; const firstVictoryRecorded = firstSettlement?.outcome === 'victory' || firstReport?.outcome === 'victory'; if (!firstVictoryRecorded) { return; } if (!state.roster.some((unit) => unit.id === jianYongRecruitUnit.id)) { state.roster.push(cloneUnit(jianYongRecruitUnit)); } if (!state.bonds.some((bond) => bond.id === jianYongRecruitBond.id)) { // 전역 인연 진행도는 전투 보고서의 96개 스냅샷 제한 대상이 아니다. // 후반 저장을 마이그레이션할 때 기존 인연을 지우지 않고 누락 항목만 추가한다. state.bonds.push(createCampBondSnapshot(jianYongRecruitBond)); } if (firstReport?.outcome === 'victory') { if (!firstReport.bonds.some((bond) => bond.id === jianYongRecruitBond.id)) { appendRequiredLimitedMigrationEntry(firstReport.bonds, createCampBondSnapshot(jianYongRecruitBond), maxCampaignBattleBondEntries); } firstReport.campaignRewards = backfillEarlyJianYongReward(firstReport.campaignRewards); } if (firstSettlement?.outcome === 'victory') { if (!firstSettlement.bonds.some((bond) => bond.id === jianYongRecruitBond.id)) { appendRequiredLimitedMigrationEntry(firstSettlement.bonds, { id: jianYongRecruitBond.id, title: jianYongRecruitBond.title, level: jianYongRecruitBond.level, exp: jianYongRecruitBond.exp, battleExp: 0 }, maxCampaignBattleBondEntries); } firstSettlement.campaignRewards = backfillEarlyJianYongReward(firstSettlement.campaignRewards); } } function backfillEarlyJianYongReward(existing?: CampaignRewardSnapshot): CampaignRewardSnapshot { const rewardDefinition = battleScenarios[earlyJianYongRecruitBattleId].campaignReward; const snapshot = cloneCampaignRewardSnapshot(existing, earlyJianYongRecruitBattleId) ?? { supplies: [...(rewardDefinition?.supplies ?? [])], equipment: [...(rewardDefinition?.equipment ?? [])], reputation: [...(rewardDefinition?.reputation ?? [])], recruits: (rewardDefinition?.recruits ?? []).map((unitId) => ({ unitId, name: campaignRecruitUnitById.get(unitId)?.name ?? battleScenarios[earlyJianYongRecruitBattleId].units.find((unit) => unit.id === unitId)?.name ?? unitId })), unlocks: rewardDefinition?.unlockBattleId ? [{ battleId: rewardDefinition.unlockBattleId, title: rewardDefinition.unlockLabel ?? battleScenarios[rewardDefinition.unlockBattleId].title }] : [], ...(rewardDefinition?.note ? { note: rewardDefinition.note } : {}) }; if (!snapshot.recruits.some((recruit) => recruit.unitId === jianYongRecruitUnit.id)) { appendRequiredLimitedMigrationEntry( snapshot.recruits, { unitId: jianYongRecruitUnit.id, name: jianYongRecruitUnit.name }, maxCampaignStringListEntries ); } return snapshot; } function appendRequiredLimitedMigrationEntry(entries: T[], entry: T, limit: number) { if (entries.length >= limit) { entries.splice(limit - 1); } entries.push(entry); } function ensureCampaignState() { if (!campaignState) { campaignState = readStoredCampaignState() ?? createInitialCampaignState(); } return campaignState; } function normalizeCampaignState(state: Partial): CampaignState { const normalized = { ...createInitialCampaignState(), ...cloneCampaignState(state as CampaignState) }; normalized.version = 1; normalized.updatedAt = normalizeCampaignTimestamp(normalized.updatedAt) ?? new Date().toISOString(); normalized.step = normalizeCampaignStep(normalized.step); normalized.activeSaveSlot = normalizeSlot(normalized.activeSaveSlot); normalized.gold = normalizeNonNegativeInteger(normalized.gold); normalized.roster = arrayOrEmpty(normalized.roster) .map(normalizeUnitDataSnapshot) .filter((unit): unit is UnitData => Boolean(unit)); normalized.bonds = arrayOrEmpty(normalized.bonds) .map(normalizeCampBondSnapshot) .filter((bond): bond is CampBondSnapshot => Boolean(bond)); normalized.completedCampDialogues = uniqueCampHistoryIds(normalized.completedCampDialogues); normalized.completedCampVisits = uniqueCampHistoryIds(normalized.completedCampVisits); normalized.campDialogueChoiceIds = normalizeCampChoiceHistory(normalized.campDialogueChoiceIds); normalized.campVisitChoiceIds = normalizeCampChoiceHistory(normalized.campVisitChoiceIds); normalized.completedTutorialIds = uniqueStrings(normalized.completedTutorialIds) .filter((id): id is CampaignTutorialId => campaignTutorialIdSet.has(id)); normalized.acknowledgedVictoryRewardBattleIds = uniqueStrings(normalized.acknowledgedVictoryRewardBattleIds) .filter((battleId) => battleId in battleScenarios); normalized.acknowledgedVictoryRewardCategories = normalizeCampaignVictoryRewardAcknowledgements( normalized.acknowledgedVictoryRewardCategories ); normalized.dismissedVictoryRewardNoticeBattleIds = uniqueStrings(normalized.dismissedVictoryRewardNoticeBattleIds) .filter((battleId) => battleId in battleScenarios); normalized.inventory = normalizeInventory(normalized.inventory); normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory); normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport); normalized.completedCampDialogues = mergeCampCompletionIds( normalized.completedCampDialogues, normalized.firstBattleReport?.completedCampDialogues ?? [], Object.keys(normalized.campDialogueChoiceIds) ); normalized.completedCampVisits = mergeCampCompletionIds( normalized.completedCampVisits, normalized.firstBattleReport?.completedCampVisits ?? [], Object.keys(normalized.campVisitChoiceIds) ); if (normalized.firstBattleReport) { normalized.firstBattleReport.completedCampDialogues = [...normalized.completedCampDialogues]; normalized.firstBattleReport.completedCampVisits = [...normalized.completedCampVisits]; } backfillEarlyJianYongRecruit(normalized); const rosterUnitIds = normalizedRosterUnitIds(normalized.roster); const sortieUnitFilter = rosterUnitIds.size > 0 ? rosterUnitIds : undefined; if (sortieUnitFilter) { normalized.bonds = normalized.bonds.filter((bond) => bond.unitIds.every((unitId) => sortieUnitFilter.has(unitId))); } normalized.selectedSortieUnitIds = uniqueStrings(normalized.selectedSortieUnitIds) .filter((unitId) => !sortieUnitFilter || sortieUnitFilter.has(unitId)); normalized.sortieFormationAssignments = normalizeSortieFormationAssignments(recordOrEmpty(normalized.sortieFormationAssignments), sortieUnitFilter); normalized.sortieItemAssignments = normalizeCampaignSortieItemAssignments(recordOrEmpty(normalized.sortieItemAssignments), sortieUnitFilter); normalized.sortieFormationPresets = normalizeCampaignSortieFormationPresets(normalized.sortieFormationPresets, sortieUnitFilter); normalized.sortieOrderSelection = normalizeCampaignSortieOrderSelection(normalized.sortieOrderSelection); if (!normalized.sortieOrderSelection) { delete normalized.sortieOrderSelection; } normalized.sortieResonanceSelection = normalizeCampaignSortieResonanceSelection( normalized.sortieResonanceSelection, normalized.bonds, normalized.selectedSortieUnitIds ); if (!normalized.sortieResonanceSelection) { delete normalized.sortieResonanceSelection; } normalized.sortieRecommendationSelection = normalizeCampaignSortieRecommendationSnapshot( normalized.sortieRecommendationSelection, { allowedUnitIds: sortieUnitFilter } ); if ( !normalized.sortieRecommendationSelection || JSON.stringify(normalized.sortieRecommendationSelection.selectedUnitIds) !== JSON.stringify(normalized.selectedSortieUnitIds) || normalized.sortieRecommendationSelection.selectedUnitIds.some((unitId) => ( normalized.sortieRecommendationSelection?.formationAssignments[unitId] !== normalized.sortieFormationAssignments[unitId] )) || normalized.sortieRecommendationSelection.sortieOrderId !== normalized.sortieOrderSelection?.orderId || normalized.sortieRecommendationSelection.sortieResonanceBondId !== normalized.sortieResonanceSelection?.bondId ) { delete normalized.sortieRecommendationSelection; } normalized.sortieOrderHistory = normalizeCampaignSortieOrderHistory(normalized.sortieOrderHistory); normalized.claimedSortieOrderRewardIds = normalizeCampaignSortieOrderRewardClaims( normalized.claimedSortieOrderRewardIds, normalized.sortieOrderHistory ); normalized.reserveTrainingFocus = normalizeReserveTrainingFocusId(normalized.reserveTrainingFocus); normalized.reserveTrainingAssignments = normalizeCampaignReserveTrainingAssignments(recordOrEmpty(normalized.reserveTrainingAssignments), sortieUnitFilter); if (sortieUnitFilter) { normalized.battleHistory = filterBattleHistoryRosterReferences( normalized.battleHistory, sortieUnitFilter, normalized.bonds ); } normalized.latestBattleId = normalizeLatestBattleId(normalized.latestBattleId, normalized.battleHistory); const activeCityStayId = normalizeActiveCityStayId( normalized.activeCityStayId, normalized.latestBattleId, normalized.step ); if (activeCityStayId) { normalized.activeCityStayId = activeCityStayId; } else { delete normalized.activeCityStayId; } const pendingAftermathBattleId = typeof normalized.pendingAftermathBattleId === 'string' && normalized.pendingAftermathBattleId in battleScenarios ? normalized.pendingAftermathBattleId as BattleScenarioId : undefined; const pendingAftermathStep = pendingAftermathBattleId ? campaignBattleSteps[pendingAftermathBattleId]?.victory : undefined; if ( pendingAftermathBattleId && normalized.latestBattleId === pendingAftermathBattleId && normalized.step === pendingAftermathStep && normalized.firstBattleReport?.battleId === pendingAftermathBattleId && normalized.firstBattleReport.outcome === 'victory' && normalized.battleHistory[pendingAftermathBattleId]?.outcome === 'victory' ) { normalized.pendingAftermathBattleId = pendingAftermathBattleId; } else { delete normalized.pendingAftermathBattleId; } if (normalized.firstBattleReport && sortieUnitFilter) { normalized.firstBattleReport = filterFirstBattleReportRosterReferences(normalized.firstBattleReport, sortieUnitFilter); } const recordedVictoryBattleIds = new Set( Object.entries(normalized.battleHistory) .filter(([, settlement]) => settlement.outcome === 'victory') .map(([battleId]) => battleId) ); if (normalized.firstBattleReport?.outcome === 'victory') { recordedVictoryBattleIds.add(normalized.firstBattleReport.battleId); } normalized.acknowledgedVictoryRewardBattleIds = normalized.acknowledgedVictoryRewardBattleIds .filter((battleId) => recordedVictoryBattleIds.has(battleId)); normalized.dismissedVictoryRewardNoticeBattleIds = normalized.dismissedVictoryRewardNoticeBattleIds .filter((battleId) => recordedVictoryBattleIds.has(battleId)); const acknowledgedVictoryRewardCategories = Object.entries(normalized.acknowledgedVictoryRewardCategories) .reduce((acknowledgements, [battleId, categories]) => { if (!recordedVictoryBattleIds.has(battleId)) { return acknowledgements; } const source = campaignVictoryRewardSourceFor(normalized, battleId); if (!source || !isCampaignVictory(source)) { return acknowledgements; } const availableCategories = new Set(campaignVictoryRewardCategoriesFor(source)); const validCategories = uniqueVictoryRewardCategories(categories) .filter((category) => availableCategories.has(category)); if (validCategories.length > 0) { acknowledgements[battleId] = validCategories; } return acknowledgements; }, {}); normalized.acknowledgedVictoryRewardBattleIds.forEach((battleId) => { const source = campaignVictoryRewardSourceFor(normalized, battleId); if (source && isCampaignVictory(source)) { acknowledgedVictoryRewardCategories[battleId] = campaignVictoryRewardCategoriesFor(source); } }); normalized.acknowledgedVictoryRewardCategories = acknowledgedVictoryRewardCategories; normalized.acknowledgedVictoryRewardBattleIds = [...recordedVictoryBattleIds] .filter((battleId) => { const source = campaignVictoryRewardSourceFor(normalized, battleId); if (!source || !isCampaignVictory(source)) { return false; } const acknowledged = new Set(normalized.acknowledgedVictoryRewardCategories[battleId] ?? []); const available = campaignVictoryRewardCategoriesFor(source); return available.length > 0 && available.every((category) => acknowledged.has(category)); }); normalized.sortieOrderHistory = normalizeCampaignSortieOrderHistory( normalized.sortieOrderHistory, normalized.battleHistory, normalized.firstBattleReport ); return normalized; } function arrayOrEmpty(value: unknown): T[] { return Array.isArray(value) ? value as T[] : []; } function isPlainObject(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function recordOrEmpty(value: unknown): Record { return isPlainObject(value) ? value as Record : {}; } function normalizeCampaignVictoryRewardAcknowledgements(value: unknown): CampaignVictoryRewardAcknowledgements { return Object.entries(recordOrEmpty(value)) .slice(0, maxCampaignStringListEntries) .reduce((acknowledgements, [battleId, categories]) => { const normalizedBattleId = normalizeKeyString(battleId); const normalizedCategories = uniqueVictoryRewardCategories(categories); if (normalizedBattleId && normalizedBattleId in battleScenarios && normalizedCategories.length > 0) { acknowledgements[normalizedBattleId] = normalizedCategories; } return acknowledgements; }, {}); } function normalizeCampChoiceHistory(value: unknown): CampaignCampChoiceHistory { const history: CampaignCampChoiceHistory = {}; let acceptedEntryCount = 0; for (const [entryId, choiceId] of Object.entries(recordOrEmpty(value))) { if (acceptedEntryCount >= maxCampaignCampHistoryEntries) { break; } const normalizedEntryId = normalizeKeyString(entryId); const normalizedChoiceId = normalizeKeyString(choiceId); if (normalizedEntryId && normalizedChoiceId) { if (!history[normalizedEntryId]) { acceptedEntryCount += 1; } history[normalizedEntryId] = normalizedChoiceId; } } return history; } function uniqueCampHistoryIds(value: unknown): string[] { return [ ...new Set( arrayOrEmpty(value) .filter((entry): entry is string => typeof entry === 'string') .map((entry) => entry.trim()) .filter((entry) => entry.length > 0 && entry.length <= maxCampaignStringListLength) ) ].slice(0, maxCampaignCampHistoryEntries); } function mergeCampCompletionIds(...completionIdGroups: string[][]) { return uniqueCampHistoryIds(completionIdGroups.flat()); } function uniqueStrings(value: unknown): string[] { return [ ...new Set( arrayOrEmpty(value) .filter((entry): entry is string => typeof entry === 'string') .map((entry) => entry.trim()) .filter((entry) => entry.length > 0 && entry.length <= maxCampaignStringListLength) ) ].slice(0, maxCampaignStringListEntries); } function normalizeRewardStrings(value: unknown): string[] { return [ ...new Set( arrayOrEmpty(value) .filter((entry): entry is string => typeof entry === 'string') .map((entry) => entry.replace(/\s+/g, ' ').trim()) .filter((entry) => entry.length > 0 && entry.length <= maxCampaignStringListLength && Boolean(parseRewardLabel(entry))) ) ].slice(0, maxCampaignStringListEntries); } function uniqueByKey(entries: T[], keyForEntry: (entry: T) => string) { const seen = new Set(); return entries.filter((entry) => { const key = keyForEntry(entry); if (!key || seen.has(key)) { return false; } seen.add(key); return true; }); } function normalizeKeyString(value: unknown): string { if (typeof value !== 'string') { return ''; } const text = value.trim(); return text.length > 0 && text.length <= maxCampaignStringListLength ? text : ''; } function normalizeDisplayString(value: unknown, maxLength = maxCampaignDisplayTextLength): string { if (typeof value !== 'string') { return ''; } return value.trim().slice(0, maxLength); } function normalizeLimitedArray(value: unknown, normalizer: (entry: unknown) => T | undefined, maxEntries: number): T[] { const normalized: T[] = []; for (const entry of arrayOrEmpty(value)) { const normalizedEntry = normalizer(entry); if (normalizedEntry) { normalized.push(normalizedEntry); if (normalized.length >= maxEntries) { break; } } } return normalized; } function normalizedRosterUnitIds(roster: UnitData[]) { return new Set( roster .map((unit) => unit.id) .filter((unitId): unitId is string => typeof unitId === 'string' && unitId.length > 0) ); } function normalizeNonNegativeInteger(value: unknown) { const numeric = Math.floor(Number(value)); return Number.isFinite(numeric) && numeric > 0 ? Math.min(numeric, maxCampaignNumericValue) : 0; } function normalizeCharacterLevel(value: unknown) { return Math.min(99, Math.max(1, normalizeNonNegativeInteger(value))); } function normalizeCharacterExpForLevel(level: number, value: unknown) { const exp = normalizeNonNegativeInteger(value); return Math.min(exp, level >= 99 ? 100 : 99); } function normalizeBondLevel(value: unknown) { return Math.min(100, Math.max(1, normalizeNonNegativeInteger(value))); } function normalizeBondExpForLevel(level: number, value: unknown) { const exp = normalizeNonNegativeInteger(value); return Math.min(exp, level >= 100 ? 100 : 99); } 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(); const normalizedAmount = normalizeNonNegativeInteger(amount); if (normalizedItemId && normalizedItemId.length <= maxCampaignStringListLength && normalizedAmount > 0) { const nextAmount = (inventory[normalizedItemId] ?? 0) + normalizedAmount; const cap = campaignInventoryCaps[normalizedItemId] ?? maxCampaignUnknownInventoryAmount; inventory[normalizedItemId] = Math.min(nextAmount, cap); } return inventory; }, {}); } function normalizeCampaignSortieOrderSelection(value: unknown): CampaignSortieOrderSelection | undefined { if (!isPlainObject(value)) { return undefined; } const battleId = typeof value.battleId === 'string' ? value.battleId : ''; if (!(battleId in battleScenarios) || !isSortieOrderId(value.orderId)) { return undefined; } return { battleId: battleId as BattleScenarioId, orderId: value.orderId }; } function normalizeCampaignSortieResonanceSelection( value: unknown, bonds: readonly CampBondSnapshot[], selectedSortieUnitIds: readonly string[] ): CampaignSortieResonanceSelection | undefined { if (!isPlainObject(value)) { return undefined; } const battleId = typeof value.battleId === 'string' ? value.battleId : ''; const bondId = normalizeKeyString(value.bondId); if (!hasCampaignBattleScenario(battleId) || !bondId) { return undefined; } const bond = resolveCampaignSortieResonanceBond(battleId, bondId, bonds); const selectedUnitIds = new Set(selectedSortieUnitIds); if ( !bond || bond.level < coreSortieResonanceMinLevel || !bond.unitIds.every((unitId) => selectedUnitIds.has(unitId)) ) { return undefined; } return { battleId: battleId as BattleScenarioId, bondId }; } type CampaignSortieRecommendationNormalizationOptions = { expectedBattleId?: string; allowedUnitIds?: ReadonlySet; }; export function normalizeCampaignSortieRecommendationSnapshot( value: unknown, options: CampaignSortieRecommendationNormalizationOptions = {} ): CampaignSortieRecommendationSnapshot | undefined { if (!isPlainObject(value) || value.version !== 1) { return undefined; } const battleId = typeof value.battleId === 'string' ? value.battleId : ''; const planId = value.planId; if ( !hasCampaignBattleScenario(battleId) || (options.expectedBattleId && options.expectedBattleId !== battleId) || !sortieRecommendationPlanIds.includes(planId as SortieRecommendationPlanId) ) { return undefined; } const label = normalizeDisplayString(value.label).slice(0, 48); const summary = normalizeDisplayString(value.summary).slice(0, 180); const selectedUnitIds = uniqueStrings(value.selectedUnitIds) .filter((unitId) => !options.allowedUnitIds || options.allowedUnitIds.has(unitId)) .slice(0, maxCampaignSortieAssignmentUnits); if (!label || !summary || selectedUnitIds.length === 0) { return undefined; } const selectedUnitIdSet = new Set(selectedUnitIds); const formationAssignments = normalizeSortieFormationAssignments( recordOrEmpty(value.formationAssignments), selectedUnitIdSet ); if (selectedUnitIds.some((unitId) => !formationAssignments[unitId])) { return undefined; } const reasonRecord = recordOrEmpty(value.unitReasons); const unitReasons = Object.fromEntries(selectedUnitIds.map((unitId) => [ unitId, typeof reasonRecord[unitId] === 'string' ? reasonRecord[unitId].trim().slice(0, 180) : '' ])); if (Object.values(unitReasons).some((reason) => reason.length === 0)) { return undefined; } const sortieOrderId = isSortieOrderId(value.sortieOrderId) ? value.sortieOrderId : undefined; const sortieResonanceBondId = normalizeKeyString(value.sortieResonanceBondId); return { version: 1, battleId, planId: planId as SortieRecommendationPlanId, label, summary, ...(sortieOrderId ? { sortieOrderId } : {}), ...(sortieResonanceBondId ? { sortieResonanceBondId } : {}), selectedUnitIds, formationAssignments, unitReasons }; } type CampaignSortieCooperationNormalizationOptions = { allowedUnitIds?: ReadonlySet; allowedBondIds?: ReadonlySet; bonds?: readonly { id: string; unitIds: readonly string[]; }[]; }; export function normalizeCampaignSortieCooperationSnapshot( value: unknown, options: CampaignSortieCooperationNormalizationOptions = {} ): CampaignSortieCooperationSnapshot | undefined { if (!isPlainObject(value) || value.version !== 1) { return undefined; } const bondId = normalizeKeyString(value.bondId); const rawUnitIds = arrayOrEmpty(value.unitIds); if (!bondId || rawUnitIds.length !== 2) { return undefined; } const unitIds = rawUnitIds.map(normalizeKeyString); if ( !unitIds[0] || !unitIds[1] || unitIds[0] === unitIds[1] || (options.allowedBondIds && !options.allowedBondIds.has(bondId)) || unitIds.some((unitId) => options.allowedUnitIds && !options.allowedUnitIds.has(unitId)) ) { return undefined; } const matchingBond = options.bonds?.find((bond) => bond.id === bondId); if (options.bonds && !matchingBond) { return undefined; } if ( matchingBond && ( matchingBond.unitIds.length !== 2 || !matchingBond.unitIds.every((unitId) => unitIds.includes(unitId)) ) ) { return undefined; } const attempts = normalizeNonNegativeInteger(value.attempts); if (attempts <= 0) { return undefined; } const normalizedUnitIds = matchingBond ? [matchingBond.unitIds[0], matchingBond.unitIds[1]] as [string, string] : [unitIds[0], unitIds[1]] as [string, string]; const successes = Math.min(attempts, normalizeNonNegativeInteger(value.successes)); return { version: 1, bondId, unitIds: normalizedUnitIds, attempts, successes, additionalDamage: successes > 0 ? normalizeNonNegativeInteger(value.additionalDamage) : 0 }; } function resolveCampaignSortieResonanceBond( battleId: string, bondId: string, campaignBonds: readonly CampBondSnapshot[] ): BattleBond | undefined { if (!hasCampaignBattleScenario(battleId)) { return undefined; } const scenarioBond = battleScenarios[battleId].bonds.find((bond) => bond.id === bondId); if (!scenarioBond) { return undefined; } const campaignBond = campaignBonds.find((bond) => bond.id === bondId); return campaignBond ? { ...scenarioBond, level: campaignBond.level, exp: campaignBond.exp } : scenarioBond; } function hasCampaignBattleScenario(battleId: string): battleId is BattleScenarioId { return Object.prototype.hasOwnProperty.call(battleScenarios, battleId); } export function normalizeCampaignSortieOrderResultSnapshot( value: unknown, outcome?: FirstBattleReport['outcome'], commandContext?: CampaignSortieOrderCommandNormalizationContext ): CampaignSortieOrderResultSnapshot | undefined { if (!isPlainObject(value) || value.version !== 1 || !isSortieOrderId(value.orderId)) { return undefined; } const orderId = value.orderId; const progressById = new Map( arrayOrEmpty(value.progress) .filter(isPlainObject) .map((entry) => [entry.id, entry] as const) ); const progress = sortieOrderCheckIdsByOrder[orderId].map((id) => { const entry = progressById.get(id); if (!entry) { return { id, achieved: false, value: 0, target: 1 }; } return { id, achieved: entry.achieved === true, value: normalizeNonNegativeInteger(entry.value), target: Math.max(1, normalizeNonNegativeInteger(entry.target)) }; }); if (outcome === 'defeat') { const victoryProgress = progress.find((entry) => entry.id === 'victory'); if (victoryProgress) { victoryProgress.value = 0; victoryProgress.achieved = false; } } const achieved = progress.every((entry) => entry.achieved); const normalizedCommand = normalizeCampaignSortieOrderCommandSnapshot(value.command); const command = normalizedCommand && ( !commandContext || campaignSortieOrderCommandMatchesContext(normalizedCommand, commandContext) ) ? normalizedCommand : undefined; return { version: 1, orderId, score: Math.min(100, normalizeNonNegativeInteger(value.score)), achieved, progress, rewardGranted: achieved && value.rewardGranted === true, ...(command ? { command } : {}) }; } type CampaignSortieOrderCommandNormalizationContext = { performance?: CampaignSortiePerformanceSnapshot; turnNumber: number; }; function campaignSortieOrderCommandMatchesContext( command: CampaignSortieOrderCommandSnapshot, context: CampaignSortieOrderCommandNormalizationContext ) { const performance = context.performance; return Boolean( performance && command.usedTurn <= context.turnNumber && performance.selectedUnitIds.includes(command.actorId) && performance.formationAssignments[command.actorId] === command.role ); } function normalizeCampaignSortieOrderCommandSnapshot(value: unknown): CampaignSortieOrderCommandSnapshot | undefined { if ( !isPlainObject(value) || (value.source !== 'sortie-order' && value.source !== 'initiative') || (value.role !== 'front' && value.role !== 'flank' && value.role !== 'support') ) { return undefined; } const actorId = normalizeKeyString(value.actorId); const usedTurn = normalizeNonNegativeInteger(value.usedTurn); if (!actorId || usedTurn <= 0) { return undefined; } return { source: value.source, role: value.role, actorId, usedTurn, effectPending: value.effectPending === true, effectValue: normalizeNonNegativeInteger(value.effectValue), statusesCleared: normalizeNonNegativeInteger(value.statusesCleared), effectLost: value.effectLost === true }; } function normalizeCampaignSortieOrderHistory( value: unknown, battleHistory: Record = {}, latestReport?: FirstBattleReport ): CampaignSortieOrderHistory { return Object.entries(recordOrEmpty(value)).reduce((history, [battleId, orderResults]) => { if (!(battleId in battleScenarios)) { return history; } const normalizedByOrder = sortieOrderIds.reduce>>( (results, orderId) => { const rawResult = recordOrEmpty(orderResults)[orderId]; const standaloneResult = normalizeCampaignSortieOrderResultSnapshot(rawResult); const commandContext = standaloneResult ? campaignSortieOrderHistoryCommandContext(battleId, standaloneResult, battleHistory, latestReport) : undefined; const result = commandContext ? normalizeCampaignSortieOrderResultSnapshot(rawResult, undefined, commandContext) : standaloneResult; if (result?.orderId === orderId) { results[orderId] = result; } return results; }, {} ); if (Object.keys(normalizedByOrder).length > 0) { history[battleId as BattleScenarioId] = normalizedByOrder; } return history; }, {}); } function campaignSortieOrderHistoryCommandContext( battleId: string, result: CampaignSortieOrderResultSnapshot, battleHistory: Record, latestReport?: FirstBattleReport ): CampaignSortieOrderCommandNormalizationContext | undefined { const settlement = battleHistory[battleId]; const candidates = [ settlement?.sortiePerformance && settlement.sortieOrder ? { performance: settlement.sortiePerformance, turnNumber: settlement.turnNumber, result: settlement.sortieOrder } : undefined, latestReport?.battleId === battleId && latestReport.sortiePerformance && latestReport.sortieOrder ? { performance: latestReport.sortiePerformance, turnNumber: latestReport.turnNumber, result: latestReport.sortieOrder } : undefined ]; const matching = candidates.find((candidate) => ( candidate && campaignSortieOrderResultsShareEvaluation(candidate.result, result) )); return matching ? { performance: matching.performance, turnNumber: matching.turnNumber } : undefined; } function campaignSortieOrderResultsShareEvaluation( left: CampaignSortieOrderResultSnapshot, right: CampaignSortieOrderResultSnapshot ) { return ( left.orderId === right.orderId && left.score === right.score && left.achieved === right.achieved && left.progress.length === right.progress.length && left.progress.every((entry, index) => { const other = right.progress[index]; return Boolean( other && entry.id === other.id && entry.achieved === other.achieved && entry.value === other.value && entry.target === other.target ); }) ); } function normalizeCampaignSortieOrderRewardClaims( value: unknown, history: CampaignSortieOrderHistory ) { const validClaims = new Set(); arrayOrEmpty(value).forEach((claimId) => { if (typeof claimId !== 'string') { return; } const separatorIndex = claimId.lastIndexOf(':'); const battleId = claimId.slice(0, separatorIndex); const orderId = claimId.slice(separatorIndex + 1); if (separatorIndex > 0 && battleId in battleScenarios && isSortieOrderId(orderId)) { validClaims.add(sortieOrderRewardClaimId(battleId, orderId)); } }); Object.entries(history).forEach(([battleId, results]) => { sortieOrderIds.forEach((orderId) => { if (results?.[orderId]?.rewardGranted) { validClaims.add(sortieOrderRewardClaimId(battleId, orderId)); } }); }); return [...validClaims].slice(0, maxCampaignSortieOrderRewardClaims); } function normalizeBattleHistory(value: unknown) { return Object.entries(recordOrEmpty(value)).reduce>((history, [, settlement]) => { const normalizedSettlement = normalizeCampaignBattleSettlement(settlement); if (!normalizedSettlement) { return history; } history[normalizedSettlement.battleId] = normalizedSettlement; return history; }, {}); } function normalizeLatestBattleId(value: unknown, battleHistory: Record) { if (typeof value === 'string' && value in battleHistory) { return value; } return Object.values(battleHistory) .sort((a, b) => (campaignTimestampMs(b.completedAt) ?? 0) - (campaignTimestampMs(a.completedAt) ?? 0))[0]?.battleId; } function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rosterUnitIds: Set): FirstBattleReport { const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(report.sortiePerformance, rosterUnitIds); const sortiePerformanceUnchanged = sortiePerformanceSnapshotsShareRoster(report.sortiePerformance, sortiePerformance); const sortieReview = sortiePerformanceUnchanged ? normalizeCampaignSortieReviewSnapshot(report.sortieReview, sortiePerformance) : undefined; const sortieOrder = sortieReview ? normalizeCampaignSortieOrderResultSnapshot(report.sortieOrder, report.outcome, { performance: sortiePerformance, turnNumber: report.turnNumber }) : undefined; const sortieRecommendation = sortiePerformance ? normalizeCampaignSortieRecommendationSnapshot(report.sortieRecommendation, { expectedBattleId: report.battleId, allowedUnitIds: rosterUnitIds }) : undefined; const filteredBonds = report.bonds.filter((bond) => bond.unitIds.every((unitId) => rosterUnitIds.has(unitId))); const sortieCooperation = normalizeCampaignSortieCooperationSnapshot(report.sortieCooperation, { allowedUnitIds: rosterUnitIds, allowedBondIds: new Set(filteredBonds.map((bond) => bond.id)), bonds: filteredBonds }); const filtered = { ...report, units: report.units.filter((unit) => unit.faction !== 'ally' || rosterUnitIds.has(unit.id)), bonds: filteredBonds, ...(sortiePerformance ? { sortiePerformance } : {}), ...(sortieReview ? { sortieReview } : {}), ...(sortieOrder ? { sortieOrder } : {}), ...(sortieRecommendation ? { sortieRecommendation } : {}), ...(sortieCooperation ? { sortieCooperation } : {}) }; if (!sortiePerformance) { delete filtered.sortiePerformance; } if (!sortieReview) { delete filtered.sortieReview; } if (!sortieOrder) { delete filtered.sortieOrder; } if (!sortieRecommendation) { delete filtered.sortieRecommendation; } if (!sortieCooperation) { delete filtered.sortieCooperation; } if (filtered.mvp && !rosterUnitIds.has(filtered.mvp.unitId)) { delete filtered.mvp; } return filtered; } function filterBattleHistoryRosterReferences( history: Record, rosterUnitIds: Set, campaignBonds: readonly CampBondSnapshot[] ): Record { const bondIds = new Set(campaignBonds.map((bond) => bond.id)); return Object.entries(history).reduce>((filteredHistory, [battleId, settlement]) => { const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(settlement.sortiePerformance, rosterUnitIds); const sortiePerformanceUnchanged = sortiePerformanceSnapshotsShareRoster(settlement.sortiePerformance, sortiePerformance); const sortieReview = sortiePerformanceUnchanged ? normalizeCampaignSortieReviewSnapshot(settlement.sortieReview, sortiePerformance) : undefined; const sortieOrder = sortieReview ? normalizeCampaignSortieOrderResultSnapshot(settlement.sortieOrder, settlement.outcome, { performance: sortiePerformance, turnNumber: settlement.turnNumber }) : undefined; const sortieRecommendation = sortiePerformance ? normalizeCampaignSortieRecommendationSnapshot(settlement.sortieRecommendation, { expectedBattleId: settlement.battleId, allowedUnitIds: rosterUnitIds }) : undefined; const sortieCooperation = normalizeCampaignSortieCooperationSnapshot(settlement.sortieCooperation, { allowedUnitIds: rosterUnitIds, allowedBondIds: bondIds, bonds: campaignBonds }); filteredHistory[battleId] = { ...settlement, units: settlement.units.filter((unit) => rosterUnitIds.has(unit.unitId)), bonds: bondIds.size > 0 ? settlement.bonds.filter((bond) => bondIds.has(bond.id)) : settlement.bonds, reserveTraining: settlement.reserveTraining?.filter((entry) => rosterUnitIds.has(entry.unitId)), ...(sortiePerformance ? { sortiePerformance } : {}), ...(sortieReview ? { sortieReview } : {}), ...(sortieOrder ? { sortieOrder } : {}), ...(sortieRecommendation ? { sortieRecommendation } : {}), ...(sortieCooperation ? { sortieCooperation } : {}) }; if (!sortiePerformance) { delete filteredHistory[battleId].sortiePerformance; } if (!sortieReview) { delete filteredHistory[battleId].sortieReview; } if (!sortieOrder) { delete filteredHistory[battleId].sortieOrder; } if (!sortieRecommendation) { delete filteredHistory[battleId].sortieRecommendation; } if (!sortieCooperation) { delete filteredHistory[battleId].sortieCooperation; } return filteredHistory; }, {}); } function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettlement | undefined { if (!isPlainObject(value)) { return undefined; } const settlement = value as Partial; const battleId = typeof settlement.battleId === 'string' ? settlement.battleId : ''; const battleTitle = normalizeDisplayString(settlement.battleTitle) || battleScenarios[battleId as BattleScenarioId]?.title; const completedAt = normalizeCampaignTimestamp(settlement.completedAt); if ( battleId.length === 0 || !(battleId in battleScenarios) || !battleTitle || (settlement.outcome !== 'victory' && settlement.outcome !== 'defeat') || !completedAt ) { return undefined; } const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(settlement.sortiePerformance); const sortieReview = normalizeCampaignSortieReviewSnapshot(settlement.sortieReview, sortiePerformance); const sortieRecommendation = normalizeCampaignSortieRecommendationSnapshot(settlement.sortieRecommendation, { expectedBattleId: battleId }); const turnNumber = normalizeNonNegativeInteger(settlement.turnNumber); const sortieOrder = sortieReview ? normalizeCampaignSortieOrderResultSnapshot(settlement.sortieOrder, settlement.outcome, { performance: sortiePerformance, turnNumber }) : undefined; const units = normalizeLimitedArray( settlement.units, normalizeCampaignUnitProgressSnapshot, maxCampaignBattleUnitEntries ); const bonds = normalizeLimitedArray( settlement.bonds, normalizeCampaignBondProgressSnapshot, maxCampaignBattleBondEntries ); const sortieCooperation = normalizeCampaignSortieCooperationSnapshot(settlement.sortieCooperation, { allowedUnitIds: new Set(units.map((unit) => unit.unitId)), allowedBondIds: new Set(bonds.map((bond) => bond.id)) }); return { battleId, battleTitle, outcome: settlement.outcome, turnNumber, rewardGold: normalizeNonNegativeInteger(settlement.rewardGold), itemRewards: normalizeRewardStrings(settlement.itemRewards), campaignRewards: cloneCampaignRewardSnapshot(settlement.campaignRewards, battleId), objectives: normalizeLimitedArray(settlement.objectives, normalizeBattleObjectiveSnapshot, maxCampaignBattleObjectiveEntries), units, bonds, reserveTraining: normalizeLimitedArray(settlement.reserveTraining, normalizeReserveTrainingSnapshot, maxCampaignReserveTrainingEntries), ...(sortiePerformance ? { sortiePerformance } : {}), ...(sortieReview ? { sortieReview } : {}), ...(sortieOrder ? { sortieOrder } : {}), ...(sortieRecommendation ? { sortieRecommendation } : {}), ...(sortieCooperation ? { sortieCooperation } : {}), completedAt }; } function normalizeBattleObjectiveSnapshot(value: unknown): BattleObjectiveSnapshot | undefined { if (!isPlainObject(value)) { return undefined; } const id = normalizeKeyString(value.id); const label = normalizeDisplayString(value.label); if (!id || !label) { return undefined; } const status = value.status === 'active' || value.status === 'done' || value.status === 'failed' ? value.status : undefined; const category = value.category === 'primary' || value.category === 'required' || value.category === 'bonus' ? value.category : undefined; const targetTile = normalizeObjectiveTargetTile(value.targetTile); const summary = normalizeDisplayString(value.summary); const failureReason = normalizeDisplayString(value.failureReason); return { id, label, achieved: Boolean(value.achieved), ...(status ? { status } : {}), detail: normalizeDisplayString(value.detail), ...(category ? { category } : {}), ...(summary ? { summary } : {}), ...(failureReason ? { failureReason } : {}), ...(targetTile ? { targetTile } : {}), rewardGold: normalizeNonNegativeInteger(value.rewardGold) }; } function normalizeObjectiveTargetTile(value: unknown): BattleObjectiveSnapshot['targetTile'] | undefined { if (!isPlainObject(value)) { return undefined; } const x = Math.floor(Number(value.x)); const y = Math.floor(Number(value.y)); if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0) { return undefined; } const radius = Math.min(normalizeNonNegativeInteger(value.radius), maxCampaignObjectiveTargetRadius); return radius > 0 ? { x, y, radius } : { x, y }; } function normalizeCampaignUnitProgressSnapshot(value: unknown): CampaignUnitProgressSnapshot | undefined { if (!isPlainObject(value)) { return undefined; } const unitId = normalizeKeyString(value.unitId); const name = normalizeDisplayString(value.name); if (!unitId || !name) { return undefined; } const hp = normalizeNonNegativeInteger(value.hp); const maxHp = Math.max(hp, normalizeNonNegativeInteger(value.maxHp)); const level = normalizeCharacterLevel(value.level); return { unitId, name, level, exp: normalizeCharacterExpForLevel(level, value.exp), hp, maxHp: Math.max(1, maxHp), equipment: normalizeEquipmentSet(value.equipment) }; } function normalizeCampaignBondProgressSnapshot(value: unknown): CampaignBondProgressSnapshot | undefined { if (!isPlainObject(value)) { return undefined; } const id = normalizeKeyString(value.id); const title = normalizeDisplayString(value.title); if (!id || !title) { return undefined; } const level = normalizeBondLevel(value.level); return { id, title, level, exp: normalizeBondExpForLevel(level, value.exp), battleExp: normalizeNonNegativeInteger(value.battleExp) }; } function normalizeReserveTrainingSnapshot(value: unknown): CampaignReserveTrainingSnapshot | undefined { if (!isPlainObject(value)) { return undefined; } const unitId = normalizeKeyString(value.unitId); const name = normalizeDisplayString(value.name); const focusLabel = normalizeDisplayString(value.focusLabel); if (!unitId || !name) { return undefined; } const level = normalizeCharacterLevel(value.level); return { unitId, name, expGained: normalizeNonNegativeInteger(value.expGained), equipmentExpGained: normalizeNonNegativeInteger(value.equipmentExpGained), bondExpGained: normalizeNonNegativeInteger(value.bondExpGained), focusId: normalizeReserveTrainingFocusId(typeof value.focusId === 'string' ? value.focusId : undefined), ...(focusLabel ? { focusLabel } : {}), level, exp: normalizeCharacterExpForLevel(level, value.exp) }; } export function normalizeCampaignSortieItemAssignments(assignments?: Record, allowedUnitIds?: Set) { return Object.entries(assignments ?? {}).reduce((next, [unitId, stocks]) => { const normalizedUnitId = normalizeKeyString(unitId); if ( !normalizedUnitId || (allowedUnitIds && !allowedUnitIds.has(normalizedUnitId)) || (!allowedUnitIds && Object.keys(next).length >= maxCampaignSortieAssignmentUnits) ) { return next; } const unitStocks = Object.entries(recordOrEmpty(stocks)).reduce>((nextStocks, [itemId, amount]) => { const normalizedItemId = normalizeKeyString(itemId); const maxPerUnit = sortieSupplyMaxByUsableId[normalizedItemId]; if (!maxPerUnit) { return nextStocks; } const normalizedAmount = normalizeNonNegativeInteger(amount); if (normalizedItemId && normalizedAmount > 0) { nextStocks[normalizedItemId] = Math.min(maxPerUnit, normalizedAmount); } return nextStocks; }, {}); if (Object.keys(unitStocks).length > 0) { next[normalizedUnitId] = { ...(next[normalizedUnitId] ?? {}), ...unitStocks }; } return next; }, {}); } export function normalizeCampaignSortieFormationPresets(value: unknown, allowedUnitIds?: Set) { const source = recordOrEmpty(value); return campaignSortiePresetIds.reduce((presets, presetId) => { const candidate = source[presetId]; if (!isPlainObject(candidate)) { return presets; } const selectedUnitIds = uniqueStrings(candidate.selectedUnitIds) .filter((unitId) => !allowedUnitIds || allowedUnitIds.has(unitId)) .slice(0, maxCampaignSortieAssignmentUnits); if (selectedUnitIds.length === 0) { return presets; } presets[presetId] = { selectedUnitIds, formationAssignments: normalizeSortieFormationAssignments( recordOrEmpty(candidate.formationAssignments), new Set(selectedUnitIds) ) }; return presets; }, {}); } export function normalizeCampaignSortiePerformanceSnapshot( value: unknown, allowedUnitIds?: Set ): CampaignSortiePerformanceSnapshot | undefined { if (!isPlainObject(value)) { return undefined; } const selectedUnitIds = uniqueStrings(value.selectedUnitIds) .filter((unitId) => !allowedUnitIds || allowedUnitIds.has(unitId)) .slice(0, maxCampaignSortieAssignmentUnits); if (selectedUnitIds.length === 0) { return undefined; } const selectedIdSet = new Set(selectedUnitIds); const unitStats = uniqueByKey( normalizeLimitedArray(value.unitStats, (entry): CampaignSortieUnitPerformanceSnapshot | undefined => { if (!isPlainObject(entry)) { return undefined; } const unitId = normalizeKeyString(entry.unitId); if (!unitId || !selectedIdSet.has(unitId)) { return undefined; } const hpBefore = normalizeNonNegativeInteger(entry.hpBefore); const strategyUses = normalizeNonNegativeInteger(entry.strategyUses); return { unitId, hpBefore, maxHpBefore: Math.max(hpBefore, normalizeNonNegativeInteger(entry.maxHpBefore)), damageDealt: normalizeNonNegativeInteger(entry.damageDealt), damageTaken: normalizeNonNegativeInteger(entry.damageTaken), defeats: normalizeNonNegativeInteger(entry.defeats), actions: normalizeNonNegativeInteger(entry.actions), support: normalizeNonNegativeInteger(entry.support), strategyUses, signatureStrategyUses: Math.min(strategyUses, normalizeNonNegativeInteger(entry.signatureStrategyUses)), sortieBonusDamage: normalizeNonNegativeInteger(entry.sortieBonusDamage), sortiePreventedDamage: normalizeNonNegativeInteger(entry.sortiePreventedDamage), sortieBonusHealing: normalizeNonNegativeInteger(entry.sortieBonusHealing), sortieExtendedBondTriggers: normalizeNonNegativeInteger(entry.sortieExtendedBondTriggers), intentDefeats: normalizeNonNegativeInteger(entry.intentDefeats), intentLineBreaks: normalizeNonNegativeInteger(entry.intentLineBreaks), intentGuardSuccesses: normalizeNonNegativeInteger(entry.intentGuardSuccesses) }; }, maxCampaignSortieAssignmentUnits), (entry) => entry.unitId ); return { selectedUnitIds, formationAssignments: normalizeSortieFormationAssignments(recordOrEmpty(value.formationAssignments), selectedIdSet), unitStats }; } export function normalizeCampaignSortieReviewSnapshot( value: unknown, performance?: CampaignSortiePerformanceSnapshot ): CampaignSortieReviewSnapshot | undefined { if (!isPlainObject(value) || value.version !== 1 || !campaignSortiePerformanceSupportsReview(performance)) { return undefined; } const score = Math.min(100, normalizeNonNegativeInteger(value.score)); const grade: CampaignSortieReviewGrade = score >= 90 ? 'S' : score >= 75 ? 'A' : score >= 60 ? 'B' : score >= 45 ? 'C' : 'D'; const sourcePresetIdSet = new Set(arrayOrEmpty(value.sourcePresetIds)); const sourcePresetIds = campaignSortiePresetIds.filter((presetId) => sourcePresetIdSet.has(presetId)); return { version: 1, score, grade, activeBondCount: Math.min(maxCampaignBattleBondEntries, normalizeNonNegativeInteger(value.activeBondCount)), enemyCount: Math.min(maxCampaignBattleUnitEntries, normalizeNonNegativeInteger(value.enemyCount)), sourcePresetIds }; } function campaignSortiePerformanceSupportsReview( performance?: CampaignSortiePerformanceSnapshot ): performance is CampaignSortiePerformanceSnapshot { if (!performance || performance.selectedUnitIds.length === 0) { return false; } const statsUnitIds = new Set(performance.unitStats.map((stats) => stats.unitId)); return performance.selectedUnitIds.every((unitId) => ( Boolean(performance.formationAssignments[unitId]) && statsUnitIds.has(unitId) )); } function sortiePerformanceSnapshotsShareRoster( source?: CampaignSortiePerformanceSnapshot, filtered?: CampaignSortiePerformanceSnapshot ) { return Boolean( source && filtered && source.selectedUnitIds.length === filtered.selectedUnitIds.length && source.selectedUnitIds.every((unitId, index) => filtered.selectedUnitIds[index] === unitId) ); } function normalizeReserveTrainingFocusId(focusId?: string): CampaignReserveTrainingFocusId { return isReserveTrainingFocusId(focusId) ? focusId as CampaignReserveTrainingFocusId : defaultCampaignReserveTrainingFocusId; } function isReserveTrainingFocusId(value: unknown): value is CampaignReserveTrainingFocusId { return typeof value === 'string' && campaignReserveTrainingFocusDefinitions.some((focus) => focus.id === value); } export function isCampaignStep(value: unknown): value is CampaignStep { return typeof value === 'string' && campaignStepIds.has(value as CampaignStep); } function normalizeCampaignStep(step: unknown): CampaignStep { return isCampaignStep(step) ? step : 'new'; } function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefined { if (!isPlainObject(report)) { return undefined; } const battleId = typeof report.battleId === 'string' ? report.battleId : ''; if ( battleId.length === 0 || !(battleId in battleScenarios) || (report.outcome !== 'victory' && report.outcome !== 'defeat') ) { return undefined; } const battleTitle = normalizeDisplayString(report.battleTitle) || battleScenarios[battleId as BattleScenarioId]?.title; if (!battleTitle) { return undefined; } const sortiePerformance = normalizeCampaignSortiePerformanceSnapshot(report.sortiePerformance); const sortieReview = normalizeCampaignSortieReviewSnapshot(report.sortieReview, sortiePerformance); const sortieRecommendation = normalizeCampaignSortieRecommendationSnapshot(report.sortieRecommendation, { expectedBattleId: battleId }); const turnNumber = normalizeNonNegativeInteger(report.turnNumber); const sortieOrder = sortieReview ? normalizeCampaignSortieOrderResultSnapshot(report.sortieOrder, report.outcome, { performance: sortiePerformance, turnNumber }) : undefined; const units = normalizeLimitedArray( report.units, normalizeUnitDataSnapshot, maxCampaignBattleUnitEntries ); const bonds = normalizeLimitedArray( report.bonds, normalizeCampBondSnapshot, maxCampaignBattleBondEntries ); const sortieCooperation = normalizeCampaignSortieCooperationSnapshot(report.sortieCooperation, { allowedUnitIds: new Set( units.filter((unit) => unit.faction === 'ally').map((unit) => unit.id) ), allowedBondIds: new Set(bonds.map((bond) => bond.id)), bonds }); return { battleId, battleTitle, outcome: report.outcome, turnNumber, rewardGold: normalizeNonNegativeInteger(report.rewardGold), defeatedEnemies: normalizeNonNegativeInteger(report.defeatedEnemies), totalEnemies: normalizeNonNegativeInteger(report.totalEnemies), objectives: normalizeLimitedArray(report.objectives, normalizeBattleObjectiveSnapshot, maxCampaignBattleObjectiveEntries), units, bonds, mvp: normalizeCampMvpSnapshot(report.mvp), itemRewards: normalizeRewardStrings(report.itemRewards), campaignRewards: cloneCampaignRewardSnapshot( isPlainObject(report.campaignRewards) ? report.campaignRewards as CampaignRewardSnapshot : undefined, battleId ), ...(sortiePerformance ? { sortiePerformance } : {}), ...(sortieReview ? { sortieReview } : {}), ...(sortieOrder ? { sortieOrder } : {}), ...(sortieRecommendation ? { sortieRecommendation } : {}), ...(sortieCooperation ? { sortieCooperation } : {}), completedCampDialogues: uniqueCampHistoryIds(report.completedCampDialogues), completedCampVisits: uniqueCampHistoryIds(report.completedCampVisits), createdAt: normalizeCampaignTimestamp(report.createdAt) ?? new Date().toISOString() }; } function normalizeUnitDataSnapshot(value: unknown): UnitData | undefined { if (!isPlainObject(value) || (value.faction !== 'ally' && value.faction !== 'enemy')) { return undefined; } const id = normalizeKeyString(value.id); const name = normalizeDisplayString(value.name); if (!id || !name) { return undefined; } const stats = recordOrEmpty(value.stats); const hp = normalizeNonNegativeInteger(value.hp); const maxHp = Math.max(hp, normalizeNonNegativeInteger(value.maxHp)); const level = normalizeCharacterLevel(value.level); return { id, name, faction: value.faction, className: normalizeDisplayString(value.className), classKey: normalizeUnitClassKey(value.classKey), level, exp: normalizeCharacterExpForLevel(level, value.exp), hp, maxHp: Math.max(1, 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: normalizeEquipmentSet(value.equipment), x: normalizeNonNegativeInteger(value.x), y: normalizeNonNegativeInteger(value.y) }; } function normalizeUnitClassKey(value: unknown): UnitData['classKey'] { return typeof value === 'string' && value in unitClasses ? value as UnitData['classKey'] : 'infantry'; } function normalizeEquipmentSet(value: unknown): UnitData['equipment'] { const equipmentRecord = recordOrEmpty(value); return equipmentSlots.reduce((equipment, slot) => { const state = recordOrEmpty(equipmentRecord[slot]); const itemId = typeof state.itemId === 'string' && itemCatalog[state.itemId]?.slot === slot ? state.itemId : defaultEquipmentBySlot[slot]; const level = normalizeEquipmentLevel(state.level); equipment[slot] = { itemId, level, exp: normalizeEquipmentExpForLevel(level, state.exp) }; return equipment; }, {} as UnitData['equipment']); } function normalizeEquipmentLevel(value: unknown) { const level = normalizeNonNegativeInteger(value); return Math.min(9, Math.max(1, level)); } function normalizeEquipmentExpForLevel(level: number, value: unknown) { const exp = normalizeNonNegativeInteger(value); const next = equipmentExpToNext(level); return Math.min(exp, level >= 9 ? next : Math.max(0, next - 1)); } function normalizeCampMvpSnapshot(value: unknown): CampMvpSnapshot | undefined { if (!isPlainObject(value)) { return undefined; } const unitId = normalizeKeyString(value.unitId); const name = normalizeDisplayString(value.name); if (!unitId || !name) { return undefined; } return { unitId, name, damageDealt: normalizeNonNegativeInteger(value.damageDealt), defeats: normalizeNonNegativeInteger(value.defeats) }; } 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); const id = normalizeKeyString(value.id); const title = normalizeDisplayString(value.title); if (!id || !title || unitIds.length < 2 || unitIds[0] === unitIds[1]) { return undefined; } const level = normalizeBondLevel(value.level); return { ...(value as CampBondSnapshot), id, title, unitIds: [unitIds[0], unitIds[1]] as [string, string], level, exp: normalizeBondExpForLevel(level, value.exp), battleExp: normalizeNonNegativeInteger(value.battleExp) }; } function reserveTrainingFocusDefinition(focusId?: string) { const normalizedId = normalizeReserveTrainingFocusId(focusId); return campaignReserveTrainingFocusDefinitions.find((focus) => focus.id === normalizedId) ?? campaignReserveTrainingFocusDefinitions[0]; } function readStoredCampaignState(slot?: number) { const parsed = readStoredCampaignStateObject(slot); return parsed ? normalizeCampaignState(parsed) : undefined; } function readStoredCampaignStateObject(slot?: number) { const raw = tryStorage()?.getItem(slot === undefined ? campaignStorageKey : campaignSaveSlotKey(slot)); if (!raw) { return undefined; } try { const parsed = JSON.parse(raw) as Partial | null; if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { return undefined; } if (parsed.version !== undefined && parsed.version !== 1) { return undefined; } return parsed; } catch { return undefined; } } function persistCampaignState(state: CampaignState) { const storage = tryStorage(); storage?.setItem(campaignStorageKey, JSON.stringify(state)); storage?.setItem(campaignSaveSlotKey(state.activeSaveSlot), JSON.stringify(state)); } function tryStorage() { if (typeof window === 'undefined') { return undefined; } return window.localStorage; } function campaignSaveSlotKey(slot: number) { return `${campaignStorageKey}:slot-${normalizeSlot(slot)}`; } function normalizeSlot(slot: number | undefined) { if (!slot || Number.isNaN(slot)) { return 1; } return Math.min(campaignSaveSlotCount, Math.max(1, Math.floor(slot))); } function readLatestSlottedCampaignState() { 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) { const gained = normalizeNonNegativeInteger(amount); if (gained <= 0) { return; } bond.battleExp += gained; let exp = bond.exp + gained; while (bond.level < 100 && exp >= 100) { exp -= 100; bond.level += 1; } bond.exp = bond.level >= 100 ? Math.min(exp, 100) : exp; } function createBattleSettlement(report: FirstBattleReport, reserveTraining: CampaignReserveTrainingSnapshot[] = []): CampaignBattleSettlement { return { battleId: report.battleId, battleTitle: report.battleTitle, outcome: report.outcome, turnNumber: report.turnNumber, rewardGold: report.rewardGold, itemRewards: [...report.itemRewards], campaignRewards: cloneCampaignRewardSnapshot(report.campaignRewards, report.battleId), objectives: report.objectives.map((objective) => ({ ...objective })), units: report.units .filter((unit) => unit.faction === 'ally') .map((unit) => ({ unitId: unit.id, name: unit.name, level: unit.level, exp: unit.exp, hp: unit.hp, maxHp: unit.maxHp, equipment: cloneUnit(unit).equipment })), bonds: report.bonds.map((bond) => ({ id: bond.id, title: bond.title, level: bond.level, exp: bond.exp, battleExp: bond.battleExp })), reserveTraining: reserveTraining.map((entry) => ({ ...entry })), ...(report.sortiePerformance ? { sortiePerformance: { selectedUnitIds: [...report.sortiePerformance.selectedUnitIds], formationAssignments: { ...report.sortiePerformance.formationAssignments }, unitStats: report.sortiePerformance.unitStats.map((stats) => ({ ...stats })) } } : {}), ...(report.sortieReview ? { sortieReview: { ...report.sortieReview, sourcePresetIds: [...report.sortieReview.sourcePresetIds] } } : {}), ...(report.sortieOrder ? { sortieOrder: { ...report.sortieOrder, progress: report.sortieOrder.progress.map((entry) => ({ ...entry })), ...(report.sortieOrder.command ? { command: { ...report.sortieOrder.command } } : {}) } } : {}), ...(report.sortieRecommendation ? { sortieRecommendation: cloneCampaignSortieRecommendationSnapshot(report.sortieRecommendation) } : {}), ...(report.sortieCooperation ? { sortieCooperation: cloneCampaignSortieCooperationSnapshot(report.sortieCooperation) } : {}), completedAt: report.createdAt }; } function applyRewardDelta(inventory: Record, rewards: string[], direction: 1 | -1) { return rewards.reduce>((nextInventory, 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; if (nextInventory[label] === 0) { delete nextInventory[label]; } return nextInventory; }, { ...inventory }); } function mergeRosterProgress(currentRoster: UnitData[], deployedUnits: UnitData[]) { const merged = new Map(); currentRoster.forEach((unit) => merged.set(unit.id, cloneUnit(unit))); deployedUnits.forEach((unit) => merged.set(unit.id, cloneUnit(unit))); return Array.from(merged.values()); } function applyReserveTrainingProgress( roster: UnitData[], bonds: CampBondSnapshot[], deployedUnits: UnitData[], focusId: CampaignReserveTrainingFocusId, reserveTrainingAssignments: CampaignReserveTrainingAssignments = {} ): CampaignReserveTrainingSnapshot[] { const focus = reserveTrainingFocusDefinition(focusId); const deployedIds = new Set(deployedUnits.map((unit) => unit.id)); const reserveUnits = roster.filter((unit) => unit.faction === 'ally' && !deployedIds.has(unit.id)); const reserveIds = new Set(reserveUnits.map((unit) => unit.id)); const focusByUnitId = new Map( reserveUnits.map((unit) => [unit.id, reserveTrainingFocusDefinition(reserveTrainingAssignments[unit.id] ?? focus.id)]) ); const bondExpByUnit = new Map(); if (reserveIds.size > 0) { bonds.forEach((bond) => { const reservePartners = bond.unitIds .filter((unitId) => reserveIds.has(unitId)) .map((unitId) => ({ unitId, bondExpGained: focusByUnitId.get(unitId)?.bondExpGained ?? 0 })) .filter((partner) => partner.bondExpGained > 0); if (reservePartners.length === 0) { return; } advanceBond(bond, Math.max(...reservePartners.map((partner) => partner.bondExpGained))); reservePartners.forEach((partner) => { bondExpByUnit.set(partner.unitId, (bondExpByUnit.get(partner.unitId) ?? 0) + partner.bondExpGained); }); }); } return reserveUnits .map((unit) => { const unitFocus = focusByUnitId.get(unit.id) ?? focus; const expGained = unitFocus.expGained; const equipmentExpGained = unitFocus.equipmentExpGained; advanceUnitExp(unit, expGained); advanceReserveEquipment(unit, equipmentExpGained); return { unitId: unit.id, name: unit.name, expGained, equipmentExpGained, bondExpGained: bondExpByUnit.get(unit.id) ?? 0, focusId: unitFocus.id, focusLabel: unitFocus.label, level: unit.level, exp: unit.exp }; }); } function advanceUnitExp(unit: UnitData, amount: number) { let exp = unit.exp + amount; while (unit.level < 99 && exp >= 100) { exp -= 100; unit.level += 1; unit.maxHp += 2; unit.hp = Math.min(unit.maxHp, unit.hp + 2); unit.attack += 1; } unit.exp = unit.level >= 99 ? Math.min(exp, 100) : exp; } function advanceReserveEquipment(unit: UnitData, amount: number) { equipmentSlots.forEach((slot) => { const state = unit.equipment[slot]; let exp = state.exp + amount; while (state.level < 9 && exp >= equipmentExpToNext(state.level)) { exp -= equipmentExpToNext(state.level); state.level += 1; } state.exp = state.level >= 9 ? Math.min(exp, equipmentExpToNext(state.level)) : exp; }); } function parseRewardLabel(reward: string) { const normalized = reward.replace(/\s+/g, ' ').trim(); if (!normalized) { return undefined; } const match = normalized.match(/^(.*?)(?:\s*([+xX-]?)\s*(\d+))$/); if (!match) { return { label: normalized, amount: 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 { return JSON.parse(JSON.stringify(state)) as CampaignState; } function normalizeActiveCityStayId( cityStayId: unknown, latestBattleId: string | undefined, step: CampaignStep ): CityStayId | undefined { if (typeof cityStayId !== 'string' || !latestBattleId || !isCampCampaignStep(step)) { return undefined; } if (!(latestBattleId in battleScenarios)) { return undefined; } const cityStay = findCityStayAfterBattle(latestBattleId as BattleScenarioId); const expectedCampStep = cityStay ? campaignBattleSteps[cityStay.afterBattleId]?.victory : undefined; return cityStay?.id === cityStayId && expectedCampStep === step ? cityStay.id : undefined; } function cloneReport(report: FirstBattleReport): FirstBattleReport { const cloned = JSON.parse(JSON.stringify(report)) as FirstBattleReport; cloned.itemRewards = normalizeRewardStrings(cloned.itemRewards); cloned.completedCampDialogues = uniqueCampHistoryIds(cloned.completedCampDialogues); cloned.completedCampVisits = uniqueCampHistoryIds(cloned.completedCampVisits); if (cloned.campaignRewards) { cloned.campaignRewards = cloneCampaignRewardSnapshot(cloned.campaignRewards, cloned.battleId); } if (cloned.sortieCooperation) { cloned.sortieCooperation = cloneCampaignSortieCooperationSnapshot(cloned.sortieCooperation); } return cloned; } export function cloneCampaignSortieRecommendationSnapshot(snapshot: CampaignSortieRecommendationSnapshot) { return { ...snapshot, selectedUnitIds: [...snapshot.selectedUnitIds], formationAssignments: { ...snapshot.formationAssignments }, unitReasons: { ...snapshot.unitReasons } } satisfies CampaignSortieRecommendationSnapshot; } export function cloneCampaignSortieCooperationSnapshot(snapshot: CampaignSortieCooperationSnapshot) { return { ...snapshot, unitIds: [...snapshot.unitIds] as [string, string] } satisfies CampaignSortieCooperationSnapshot; } function cloneCampaignRewardSnapshot(rewards?: CampaignRewardSnapshot, battleId?: string): CampaignRewardSnapshot | undefined { if (!rewards) { return undefined; } const rewardRecord = recordOrEmpty(rewards); const note = typeof rewardRecord.note === 'string' ? rewardRecord.note.trim() : ''; return { supplies: normalizeRewardStrings(rewardRecord.supplies), equipment: normalizeRewardStrings(rewardRecord.equipment), reputation: normalizeRewardStrings(rewardRecord.reputation), recruits: uniqueByKey( arrayOrEmpty(rewardRecord.recruits) .filter(isPlainObject) .map((recruit) => ({ unitId: normalizeKeyString(recruit.unitId), name: normalizeDisplayString(recruit.name) })) .filter((recruit) => recruit.unitId && recruit.name && isKnownCampaignRewardRecruit(recruit.unitId, battleId)), (recruit) => recruit.unitId ) .slice(0, maxCampaignStringListEntries), unlocks: uniqueByKey( arrayOrEmpty(rewardRecord.unlocks) .filter(isPlainObject) .map((unlock) => ({ battleId: normalizeKeyString(unlock.battleId), title: normalizeDisplayString(unlock.title) })) .filter((unlock) => unlock.battleId && unlock.title && unlock.battleId in battleScenarios), (unlock) => unlock.battleId ) .slice(0, maxCampaignStringListEntries), note: note.length > 0 && note.length <= maxCampaignRewardNoteLength ? note : undefined }; } function isKnownCampaignRewardRecruit(unitId: string, battleId?: string) { if (campaignRecruitUnitById.has(unitId)) { return true; } if (!battleId || !(battleId in battleScenarios)) { return false; } return battleScenarios[battleId as BattleScenarioId].units.some((unit) => unit.faction === 'ally' && unit.id === unitId); } function cloneUnit(unit: UnitData): UnitData { return JSON.parse(JSON.stringify(unit)) as UnitData; } function cloneBondSnapshot(bond: CampBondSnapshot): CampBondSnapshot { return { ...bond, unitIds: [...bond.unitIds] as [string, string] }; } function createCampBondSnapshot(bond: BattleBond): CampBondSnapshot { return { ...bond, unitIds: [...bond.unitIds] as [string, string], battleExp: 0 }; }