Files
heros_web/src/game/state/campaignState.ts

3527 lines
128 KiB
TypeScript

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<Record<string, CampaignVictoryRewardCategoryId[]>>;
export type CampaignCampChoiceHistory = Record<string, string>;
const campaignVictoryRewardCategoryIdSet = new Set<string>(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<string, string>;
};
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<Record<string, CampaignReserveTrainingFocusId>>;
export const defaultCampaignReserveTrainingFocusId: CampaignReserveTrainingFocusId = 'balanced';
const defaultEquipmentBySlot: Record<EquipmentSlot, string> = {
weapon: 'training-sword',
armor: 'cloth-armor',
accessory: 'grain-pouch'
};
const sortieSupplyMaxByUsableId: Record<string, number> = {
bean: 2,
wine: 1,
salve: 1
};
const campaignInventoryCaps: Record<string, number> = {
['\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<string, Record<string, number>>;
export const campaignSortiePresetIds = sortieOrderIds;
export type CampaignSortiePresetId = SortieOrderId;
export type CampaignSortiePreset = {
selectedUnitIds: string[];
formationAssignments: SortieFormationAssignments;
};
export type CampaignSortieFormationPresets = Partial<Record<CampaignSortiePresetId, CampaignSortiePreset>>;
export type CampaignSortieOrderSelection = {
battleId: BattleScenarioId;
orderId: CampaignSortiePresetId;
};
export type CampaignSortieResonanceSelection = {
battleId: BattleScenarioId;
bondId: string;
};
export type CampaignSortieOrderHistory = Partial<
Record<BattleScenarioId, Partial<Record<CampaignSortiePresetId, CampaignSortieOrderResultSnapshot>>>
>;
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<string>(campaignTutorialIds);
export type CampaignState = {
version: 1;
updatedAt: string;
step: CampaignStep;
activeSaveSlot: number;
gold: number;
roster: UnitData[];
bonds: CampBondSnapshot[];
inventory: Record<string, number>;
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<string, CampaignBattleSettlement>;
latestBattleId?: string;
pendingAftermathBattleId?: BattleScenarioId;
activeCityStayId?: CityStayId;
firstBattleReport?: FirstBattleReport;
};
export const campaignStorageKey = 'heros-web:campaign-state';
export const campaignSaveSlotCount = 3;
const campaignBattleSteps: Record<string, { victory: CampaignStep; retry: CampaignStep }> = {
'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<CampaignStep>([
'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<Record<CampaignStep, { title: string; meta: string }>> = {
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<string, number>) {
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<string, unknown>,
allowedUnitIds?: Set<string>
): CampaignReserveTrainingAssignments {
return Object.entries(assignments ?? {}).reduce<CampaignReserveTrainingAssignments>((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<typeof sortieOrderDefinition> | 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<T>(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>): 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<unknown>(normalized.roster)
.map(normalizeUnitDataSnapshot)
.filter((unit): unit is UnitData => Boolean(unit));
normalized.bonds = arrayOrEmpty<unknown>(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<CampaignVictoryRewardAcknowledgements>((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<T>(value: unknown): T[] {
return Array.isArray(value) ? value as T[] : [];
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function recordOrEmpty<T = unknown>(value: unknown): Record<string, T> {
return isPlainObject(value) ? value as Record<string, T> : {};
}
function normalizeCampaignVictoryRewardAcknowledgements(value: unknown): CampaignVictoryRewardAcknowledgements {
return Object.entries(recordOrEmpty(value))
.slice(0, maxCampaignStringListEntries)
.reduce<CampaignVictoryRewardAcknowledgements>((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<unknown>(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<unknown>(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<unknown>(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<T>(entries: T[], keyForEntry: (entry: T) => string) {
const seen = new Set<string>();
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<T>(value: unknown, normalizer: (entry: unknown) => T | undefined, maxEntries: number): T[] {
const normalized: T[] = [];
for (const entry of arrayOrEmpty<unknown>(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<Record<string, number>>((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<string>;
};
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<string>;
allowedBondIds?: ReadonlySet<string>;
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<unknown>(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<unknown>(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<string, CampaignBattleSettlement> = {},
latestReport?: FirstBattleReport
): CampaignSortieOrderHistory {
return Object.entries(recordOrEmpty(value)).reduce<CampaignSortieOrderHistory>((history, [battleId, orderResults]) => {
if (!(battleId in battleScenarios)) {
return history;
}
const normalizedByOrder = sortieOrderIds.reduce<Partial<Record<CampaignSortiePresetId, CampaignSortieOrderResultSnapshot>>>(
(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<string, CampaignBattleSettlement>,
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<string>();
arrayOrEmpty<unknown>(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<unknown>(value)).reduce<Record<string, CampaignBattleSettlement>>((history, [, settlement]) => {
const normalizedSettlement = normalizeCampaignBattleSettlement(settlement);
if (!normalizedSettlement) {
return history;
}
history[normalizedSettlement.battleId] = normalizedSettlement;
return history;
}, {});
}
function normalizeLatestBattleId(value: unknown, battleHistory: Record<string, CampaignBattleSettlement>) {
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<string>): 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<string, CampaignBattleSettlement>,
rosterUnitIds: Set<string>,
campaignBonds: readonly CampBondSnapshot[]
): Record<string, CampaignBattleSettlement> {
const bondIds = new Set(campaignBonds.map((bond) => bond.id));
return Object.entries(history).reduce<Record<string, CampaignBattleSettlement>>((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<CampaignBattleSettlement>;
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<string, unknown>, allowedUnitIds?: Set<string>) {
return Object.entries(assignments ?? {}).reduce<CampaignSortieItemAssignments>((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<Record<string, number>>((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<string>) {
const source = recordOrEmpty(value);
return campaignSortiePresetIds.reduce<CampaignSortieFormationPresets>((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<string>
): 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<unknown>(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<UnitData['equipment']>((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<unknown>(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<CampaignState> | 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<string, number>, rewards: string[], direction: 1 | -1) {
return rewards.reduce<Record<string, number>>((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<string, UnitData>();
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<string, number>();
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<unknown>(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<unknown>(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
};
}