import { equipmentExpToNext, equipmentSlots, itemCatalog, type EquipmentSlot } from '../data/battleItems'; import { unitClasses } from '../data/battleRules'; import { battleScenarios, type BattleScenarioId } from '../data/battles'; import { campaignRecruitUnits, type BattleBond, type UnitData } from '../data/scenario'; import { normalizeSortieFormationAssignments, type SortieFormationAssignments } from '../data/sortieDeployment'; import { battleIdForCampaignStep, isCampCampaignStep } from './campaignRouting'; 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 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; completedCampDialogues: string[]; completedCampVisits: string[]; createdAt: string; }; export type CampaignStep = | 'new' | 'prologue' | '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 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 }; 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']; rewardGold: number; itemRewards: string[]; campaignRewards?: CampaignRewardSnapshot; objectives: BattleObjectiveSnapshot[]; units: CampaignUnitProgressSnapshot[]; bonds: CampaignBondProgressSnapshot[]; reserveTraining?: CampaignReserveTrainingSnapshot[]; completedAt: string; }; export type CampaignSortieItemAssignments = Record>; 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; reserveTrainingFocus: CampaignReserveTrainingFocusId; completedCampDialogues: string[]; completedCampVisits: string[]; battleHistory: Record; latestBattleId?: string; 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', '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: '프롤로그' }, '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 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: {}, reserveTrainingFocus: defaultCampaignReserveTrainingFocusId, completedCampDialogues: [], completedCampVisits: [], battleHistory: {} }; } export function startNewCampaign() { const state = createInitialCampaignState(); 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 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() { 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(tryStorage()?.getItem(campaignStorageKey) ?? 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; state.updatedAt = new Date().toISOString(); persistCampaignState(state); return cloneCampaignState(state); } export function setCampaignReserveTrainingFocus(focusId: CampaignReserveTrainingFocusId) { const state = ensureCampaignState(); state.reserveTrainingFocus = normalizeReserveTrainingFocusId(focusId); state.updatedAt = new Date().toISOString(); persistCampaignState(state); return cloneCampaignState(state); } export function setFirstBattleReport(report: FirstBattleReport) { const state = ensureCampaignState(); const reportClone = cloneReport(report); const battleId = reportClone.battleId; const previousSettlement = state.battleHistory[battleId]; const completedCampDialogues = battleId === 'first-battle-zhuo-commandery' ? [...reportClone.completedCampDialogues] : [...state.completedCampDialogues]; const completedCampVisits = battleId === 'first-battle-zhuo-commandery' ? [...(reportClone.completedCampVisits ?? [])] : [...state.completedCampVisits]; reportClone.completedCampDialogues = completedCampDialogues; reportClone.completedCampVisits = completedCampVisits; 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') { state.step = retryStep; state.latestBattleId = battleId; state.updatedAt = new Date().toISOString(); persistCampaignState(state); return; } 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); const reserveTraining = previousSettlement || reportClone.outcome !== 'victory' ? previousSettlement?.reserveTraining ?? [] : applyReserveTrainingProgress( state.roster, state.bonds, reportClone.units.filter((unit) => unit.faction === 'ally'), state.reserveTrainingFocus ); 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); state.battleHistory[battleId] = createBattleSettlement(reportClone, reserveTraining); state.latestBattleId = battleId; state.updatedAt = new Date().toISOString(); persistCampaignState(state); } export function getFirstBattleReport() { const report = ensureCampaignState().firstBattleReport; return report ? cloneReport(report) : undefined; } export function applyCampBondExp(dialogueId: string, bondId: string, amount: number) { const state = ensureCampaignState(); if (!state.firstBattleReport) { return undefined; } if (hasCompletionFlag(state.completedCampDialogues, state.firstBattleReport.completedCampDialogues, dialogueId)) { if (syncCompletionFlag(state.completedCampDialogues, state.firstBattleReport.completedCampDialogues, dialogueId)) { 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); 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[] } ) { const state = ensureCampaignState(); if (!state.firstBattleReport) { return undefined; } if (hasCompletionFlag(state.completedCampVisits, state.firstBattleReport.completedCampVisits, visitId)) { if (syncCompletionFlag(state.completedCampVisits, state.firstBattleReport.completedCampVisits, visitId)) { state.updatedAt = new Date().toISOString(); persistCampaignState(state); } return cloneCampaignState(state); } syncCompletionFlag(state.completedCampVisits, state.firstBattleReport.completedCampVisits, visitId); 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 hasCompletionFlag(stateFlags: string[], reportFlags: string[], flagId: string) { return stateFlags.includes(flagId) || reportFlags.includes(flagId); } 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); } 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 = typeof normalized.updatedAt === 'string' && normalized.updatedAt ? 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 = uniqueStrings(normalized.completedCampDialogues); normalized.completedCampVisits = uniqueStrings(normalized.completedCampVisits); normalized.inventory = normalizeInventory(normalized.inventory); 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.reserveTrainingFocus = normalizeReserveTrainingFocusId(normalized.reserveTrainingFocus); normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory); normalized.latestBattleId = normalizeLatestBattleId(normalized.latestBattleId, normalized.battleHistory); normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport); if (normalized.firstBattleReport && sortieUnitFilter) { normalized.firstBattleReport = filterFirstBattleReportRosterReferences(normalized.firstBattleReport, sortieUnitFilter); } return normalized; } 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 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) ) ]; } 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 ? numeric : 0; } 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 && normalizedAmount > 0) { const nextAmount = (inventory[normalizedItemId] ?? 0) + normalizedAmount; const cap = campaignInventoryCaps[normalizedItemId]; inventory[normalizedItemId] = cap ? Math.min(nextAmount, cap) : nextAmount; } return inventory; }, {}); } 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) => String(b.completedAt).localeCompare(String(a.completedAt)))[0]?.battleId; } function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rosterUnitIds: Set): FirstBattleReport { const filtered = { ...report, bonds: report.bonds.filter((bond) => bond.unitIds.every((unitId) => rosterUnitIds.has(unitId))) }; if (filtered.mvp && !rosterUnitIds.has(filtered.mvp.unitId)) { delete filtered.mvp; } return filtered; } function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettlement | undefined { if (!isPlainObject(value)) { return undefined; } const settlement = value as Partial; if ( typeof settlement.battleId !== 'string' || settlement.battleId.length === 0 || typeof settlement.battleTitle !== 'string' || settlement.battleTitle.length === 0 || (settlement.outcome !== 'victory' && settlement.outcome !== 'defeat') || typeof settlement.completedAt !== 'string' || settlement.completedAt.length === 0 ) { return undefined; } return { battleId: settlement.battleId, battleTitle: settlement.battleTitle, outcome: settlement.outcome, rewardGold: normalizeNonNegativeInteger(settlement.rewardGold), itemRewards: uniqueStrings(settlement.itemRewards), campaignRewards: cloneCampaignRewardSnapshot(settlement.campaignRewards), objectives: arrayOrEmpty(settlement.objectives) .map(normalizeBattleObjectiveSnapshot) .filter((objective): objective is BattleObjectiveSnapshot => Boolean(objective)), units: arrayOrEmpty(settlement.units) .map(normalizeCampaignUnitProgressSnapshot) .filter((unit): unit is CampaignUnitProgressSnapshot => Boolean(unit)), bonds: arrayOrEmpty(settlement.bonds) .map(normalizeCampaignBondProgressSnapshot) .filter((bond): bond is CampaignBondProgressSnapshot => Boolean(bond)), reserveTraining: arrayOrEmpty(settlement.reserveTraining) .map(normalizeReserveTrainingSnapshot) .filter((entry): entry is CampaignReserveTrainingSnapshot => Boolean(entry)), completedAt: settlement.completedAt }; } function normalizeBattleObjectiveSnapshot(value: unknown): BattleObjectiveSnapshot | undefined { if (!isPlainObject(value) || typeof value.id !== 'string' || value.id.length === 0 || typeof value.label !== 'string' || value.label.length === 0) { 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); return { id: value.id, label: value.label, achieved: Boolean(value.achieved), ...(status ? { status } : {}), detail: typeof value.detail === 'string' ? value.detail : '', ...(category ? { category } : {}), ...(typeof value.summary === 'string' ? { summary: value.summary } : {}), ...(typeof value.failureReason === 'string' ? { failureReason: value.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)) { return undefined; } const radius = normalizeNonNegativeInteger(value.radius); return radius > 0 ? { x, y, radius } : { x, y }; } function normalizeCampaignUnitProgressSnapshot(value: unknown): CampaignUnitProgressSnapshot | undefined { if (!isPlainObject(value) || typeof value.unitId !== 'string' || value.unitId.length === 0 || typeof value.name !== 'string' || value.name.length === 0) { return undefined; } const hp = normalizeNonNegativeInteger(value.hp); const maxHp = Math.max(hp, normalizeNonNegativeInteger(value.maxHp)); return { unitId: value.unitId, name: value.name, level: Math.max(1, normalizeNonNegativeInteger(value.level)), exp: normalizeNonNegativeInteger(value.exp), hp, maxHp: Math.max(1, maxHp), equipment: normalizeEquipmentSet(value.equipment) }; } function normalizeCampaignBondProgressSnapshot(value: unknown): CampaignBondProgressSnapshot | undefined { if (!isPlainObject(value) || typeof value.id !== 'string' || value.id.length === 0 || typeof value.title !== 'string' || value.title.length === 0) { return undefined; } return { id: value.id, title: value.title, level: normalizeNonNegativeInteger(value.level), exp: normalizeNonNegativeInteger(value.exp), battleExp: normalizeNonNegativeInteger(value.battleExp) }; } function normalizeReserveTrainingSnapshot(value: unknown): CampaignReserveTrainingSnapshot | undefined { if (!isPlainObject(value) || typeof value.unitId !== 'string' || value.unitId.length === 0 || typeof value.name !== 'string' || value.name.length === 0) { return undefined; } return { unitId: value.unitId, name: value.name, expGained: normalizeNonNegativeInteger(value.expGained), equipmentExpGained: normalizeNonNegativeInteger(value.equipmentExpGained), bondExpGained: normalizeNonNegativeInteger(value.bondExpGained), focusId: normalizeReserveTrainingFocusId(typeof value.focusId === 'string' ? value.focusId : undefined), ...(typeof value.focusLabel === 'string' ? { focusLabel: value.focusLabel } : {}), level: normalizeNonNegativeInteger(value.level), exp: normalizeNonNegativeInteger(value.exp) }; } export function normalizeCampaignSortieItemAssignments(assignments?: Record, allowedUnitIds?: Set) { return Object.entries(assignments ?? {}).reduce((next, [unitId, stocks]) => { if (!unitId || (allowedUnitIds && !allowedUnitIds.has(unitId))) { return next; } const unitStocks = Object.entries(recordOrEmpty(stocks)).reduce>((nextStocks, [itemId, amount]) => { const maxPerUnit = sortieSupplyMaxByUsableId[itemId]; if (!maxPerUnit) { return nextStocks; } const normalizedAmount = normalizeNonNegativeInteger(amount); if (itemId && normalizedAmount > 0) { nextStocks[itemId] = Math.min(maxPerUnit, normalizedAmount); } return nextStocks; }, {}); if (Object.keys(unitStocks).length > 0) { next[unitId] = unitStocks; } return next; }, {}); } function normalizeReserveTrainingFocusId(focusId?: string): CampaignReserveTrainingFocusId { return campaignReserveTrainingFocusDefinitions.some((focus) => focus.id === focusId) ? focusId as CampaignReserveTrainingFocusId : defaultCampaignReserveTrainingFocusId; } function normalizeCampaignStep(step: unknown): CampaignStep { return typeof step === 'string' && campaignStepIds.has(step as CampaignStep) ? step as CampaignStep : 'new'; } function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefined { if (!isPlainObject(report)) { return undefined; } if ( typeof report.battleId !== 'string' || report.battleId.length === 0 || (report.outcome !== 'victory' && report.outcome !== 'defeat') ) { return undefined; } const battleTitle = typeof report.battleTitle === 'string' && report.battleTitle.length > 0 ? report.battleTitle : battleScenarios[report.battleId as BattleScenarioId]?.title; if (!battleTitle) { return undefined; } return { battleId: report.battleId, battleTitle, outcome: report.outcome, turnNumber: normalizeNonNegativeInteger(report.turnNumber), rewardGold: normalizeNonNegativeInteger(report.rewardGold), defeatedEnemies: normalizeNonNegativeInteger(report.defeatedEnemies), totalEnemies: normalizeNonNegativeInteger(report.totalEnemies), objectives: arrayOrEmpty(report.objectives) .map(normalizeBattleObjectiveSnapshot) .filter((objective): objective is BattleObjectiveSnapshot => Boolean(objective)), units: arrayOrEmpty(report.units) .map(normalizeUnitDataSnapshot) .filter((unit): unit is UnitData => Boolean(unit)), bonds: arrayOrEmpty(report.bonds) .map(normalizeCampBondSnapshot) .filter((bond): bond is CampBondSnapshot => Boolean(bond)), mvp: normalizeCampMvpSnapshot(report.mvp), itemRewards: uniqueStrings(report.itemRewards), campaignRewards: cloneCampaignRewardSnapshot( isPlainObject(report.campaignRewards) ? report.campaignRewards as CampaignRewardSnapshot : undefined ), completedCampDialogues: uniqueStrings(report.completedCampDialogues), completedCampVisits: uniqueStrings(report.completedCampVisits), createdAt: typeof report.createdAt === 'string' && report.createdAt.length > 0 ? report.createdAt : new Date().toISOString() }; } function normalizeUnitDataSnapshot(value: unknown): UnitData | undefined { if ( !isPlainObject(value) || typeof value.id !== 'string' || value.id.length === 0 || typeof value.name !== 'string' || value.name.length === 0 || (value.faction !== 'ally' && value.faction !== 'enemy') ) { return undefined; } const stats = recordOrEmpty(value.stats); const hp = normalizeNonNegativeInteger(value.hp); const maxHp = Math.max(hp, normalizeNonNegativeInteger(value.maxHp)); return { id: value.id, name: value.name, faction: value.faction, className: typeof value.className === 'string' ? value.className : '', classKey: normalizeUnitClassKey(value.classKey), level: Math.max(1, normalizeNonNegativeInteger(value.level)), exp: normalizeNonNegativeInteger(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]; equipment[slot] = { itemId, level: normalizeEquipmentLevel(state.level), exp: normalizeNonNegativeInteger(state.exp) }; return equipment; }, {} as UnitData['equipment']); } function normalizeEquipmentLevel(value: unknown) { const level = normalizeNonNegativeInteger(value); return Math.min(9, Math.max(1, level)); } function normalizeCampMvpSnapshot(value: unknown): CampMvpSnapshot | undefined { if ( !isPlainObject(value) || typeof value.unitId !== 'string' || value.unitId.length === 0 || typeof value.name !== 'string' || value.name.length === 0 ) { return undefined; } return { unitId: value.unitId, name: value.name, damageDealt: normalizeNonNegativeInteger(value.damageDealt), defeats: normalizeNonNegativeInteger(value.defeats) }; } function normalizeCampBondSnapshot(value: unknown): CampBondSnapshot | undefined { if (!isPlainObject(value)) { return undefined; } const unitIds = arrayOrEmpty(value.unitIds).filter((unitId): unitId is string => typeof unitId === 'string' && unitId.length > 0); if ( typeof value.id !== 'string' || value.id.length === 0 || typeof value.title !== 'string' || value.title.length === 0 || unitIds.length < 2 ) { return undefined; } return { ...(value as CampBondSnapshot), id: value.id, title: value.title, unitIds: [unitIds[0], unitIds[1]] as [string, string], level: normalizeNonNegativeInteger(value.level), exp: normalizeNonNegativeInteger(value.exp), battleExp: normalizeNonNegativeInteger(value.battleExp) }; } function reserveTrainingFocusDefinition(focusId?: string) { const normalizedId = normalizeReserveTrainingFocusId(focusId); return campaignReserveTrainingFocusDefinitions.find((focus) => focus.id === normalizedId) ?? campaignReserveTrainingFocusDefinitions[0]; } function readStoredCampaignState(slot?: number) { const raw = tryStorage()?.getItem(slot ? campaignSaveSlotKey(slot) : campaignStorageKey); 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 normalizeCampaignState(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() { const latest = listCampaignSaveSlots() .filter((slot) => slot.exists && slot.updatedAt) .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))[0]; return latest ? readStoredCampaignState(latest.slot) : undefined; } function advanceBond(bond: CampBondSnapshot, amount: number) { bond.battleExp += amount; bond.exp += amount; while (bond.exp >= 100) { bond.exp -= 100; bond.level = Math.min(100, bond.level + 1); } } function createBattleSettlement(report: FirstBattleReport, reserveTraining: CampaignReserveTrainingSnapshot[] = []): CampaignBattleSettlement { return { battleId: report.battleId, battleTitle: report.battleTitle, outcome: report.outcome, rewardGold: report.rewardGold, itemRewards: [...report.itemRewards], campaignRewards: cloneCampaignRewardSnapshot(report.campaignRewards), 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 })), completedAt: report.createdAt }; } function applyRewardDelta(inventory: Record, rewards: string[], direction: 1 | -1) { return rewards.reduce>((nextInventory, reward) => { const { label, amount } = parseRewardLabel(reward); 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 ): 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 bondExpByUnit = new Map(); if (focus.bondExpGained > 0 && reserveIds.size > 0) { bonds.forEach((bond) => { const reservePartners = bond.unitIds.filter((unitId) => reserveIds.has(unitId)); if (reservePartners.length === 0) { return; } advanceBond(bond, focus.bondExpGained); reservePartners.forEach((unitId) => { bondExpByUnit.set(unitId, (bondExpByUnit.get(unitId) ?? 0) + focus.bondExpGained); }); }); } return reserveUnits .map((unit) => { const expGained = focus.expGained; const equipmentExpGained = focus.equipmentExpGained; advanceUnitExp(unit, expGained); advanceReserveEquipment(unit, equipmentExpGained); return { unitId: unit.id, name: unit.name, expGained, equipmentExpGained, bondExpGained: bondExpByUnit.get(unit.id) ?? 0, focusId: focus.id, focusLabel: focus.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(); const match = normalized.match(/^(.*?)(?:\s*[+xX]?\s*(\d+))$/); if (!match) { return { label: normalized, amount: 1 }; } return { label: match[1].trim() || normalized, amount: Number(match[2] ?? 1) }; } function cloneCampaignState(state: CampaignState): CampaignState { return JSON.parse(JSON.stringify(state)) as CampaignState; } function cloneReport(report: FirstBattleReport): FirstBattleReport { const cloned = JSON.parse(JSON.stringify(report)) as FirstBattleReport; cloned.completedCampDialogues = [...new Set(cloned.completedCampDialogues ?? [])]; cloned.completedCampVisits = [...new Set(cloned.completedCampVisits ?? [])]; if (cloned.campaignRewards) { cloned.campaignRewards = cloneCampaignRewardSnapshot(cloned.campaignRewards); } return cloned; } function cloneCampaignRewardSnapshot(rewards?: CampaignRewardSnapshot): CampaignRewardSnapshot | undefined { if (!rewards) { return undefined; } const rewardRecord = recordOrEmpty(rewards); return { supplies: uniqueStrings(rewardRecord.supplies), equipment: uniqueStrings(rewardRecord.equipment), reputation: uniqueStrings(rewardRecord.reputation), recruits: arrayOrEmpty(rewardRecord.recruits) .filter(isPlainObject) .map((recruit) => ({ unitId: typeof recruit.unitId === 'string' ? recruit.unitId.trim() : '', name: typeof recruit.name === 'string' ? recruit.name.trim() : '' })) .filter((recruit) => recruit.unitId && recruit.name), unlocks: arrayOrEmpty(rewardRecord.unlocks) .filter(isPlainObject) .map((unlock) => ({ battleId: typeof unlock.battleId === 'string' ? unlock.battleId.trim() : '', title: typeof unlock.title === 'string' ? unlock.title.trim() : '' })) .filter((unlock) => unlock.battleId && unlock.title), note: typeof rewardRecord.note === 'string' && rewardRecord.note.trim().length > 0 ? rewardRecord.note.trim() : undefined }; } 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 }; }