1743 lines
61 KiB
TypeScript
1743 lines
61 KiB
TypeScript
import { equipmentExpToNext, equipmentSlots, itemCatalog, type EquipmentSlot } from '../data/battleItems';
|
|
import { unitClasses } from '../data/battleRules';
|
|
import { battleScenarios, type BattleScenarioId } from '../data/battles';
|
|
import { campaignRecruitUnits, type BattleBond, type UnitData } from '../data/scenario';
|
|
import { normalizeSortieFormationAssignments, type SortieFormationAssignments } from '../data/sortieDeployment';
|
|
import { battleIdForCampaignStep, isCampCampaignStep } from './campaignRouting';
|
|
|
|
export type BattleObjectiveSnapshot = {
|
|
id: string;
|
|
label: string;
|
|
achieved: boolean;
|
|
status?: 'active' | 'done' | 'failed';
|
|
detail: string;
|
|
category?: 'primary' | 'required' | 'bonus';
|
|
summary?: string;
|
|
failureReason?: string;
|
|
targetTile?: { x: number; y: number; radius?: number };
|
|
rewardGold: number;
|
|
};
|
|
|
|
export type CampBondSnapshot = BattleBond & {
|
|
battleExp: number;
|
|
};
|
|
|
|
export type CampMvpSnapshot = {
|
|
unitId: string;
|
|
name: string;
|
|
damageDealt: number;
|
|
defeats: number;
|
|
};
|
|
|
|
export type CampaignRewardSnapshot = {
|
|
supplies: string[];
|
|
equipment: string[];
|
|
reputation: string[];
|
|
recruits: {
|
|
unitId: string;
|
|
name: string;
|
|
}[];
|
|
unlocks: {
|
|
battleId: string;
|
|
title: string;
|
|
}[];
|
|
note?: string;
|
|
};
|
|
|
|
export type FirstBattleReport = {
|
|
battleId: string;
|
|
battleTitle: string;
|
|
outcome: 'victory' | 'defeat';
|
|
turnNumber: number;
|
|
rewardGold: number;
|
|
defeatedEnemies: number;
|
|
totalEnemies: number;
|
|
objectives: BattleObjectiveSnapshot[];
|
|
units: UnitData[];
|
|
bonds: CampBondSnapshot[];
|
|
mvp?: CampMvpSnapshot;
|
|
itemRewards: string[];
|
|
campaignRewards?: CampaignRewardSnapshot;
|
|
completedCampDialogues: string[];
|
|
completedCampVisits: string[];
|
|
createdAt: string;
|
|
};
|
|
|
|
export type CampaignStep =
|
|
| 'new'
|
|
| 'prologue'
|
|
| 'first-battle'
|
|
| 'first-camp'
|
|
| 'first-victory-story'
|
|
| 'second-battle'
|
|
| 'second-camp'
|
|
| 'third-battle'
|
|
| 'third-camp'
|
|
| 'fourth-battle'
|
|
| 'fourth-camp'
|
|
| 'fifth-battle'
|
|
| 'fifth-camp'
|
|
| 'sixth-battle'
|
|
| 'sixth-camp'
|
|
| 'seventh-battle'
|
|
| 'seventh-camp'
|
|
| 'eighth-battle'
|
|
| 'eighth-camp'
|
|
| 'ninth-battle'
|
|
| 'ninth-camp'
|
|
| 'tenth-battle'
|
|
| 'tenth-camp'
|
|
| 'eleventh-battle'
|
|
| 'eleventh-camp'
|
|
| 'twelfth-battle'
|
|
| 'twelfth-camp'
|
|
| 'thirteenth-battle'
|
|
| 'thirteenth-camp'
|
|
| 'fourteenth-battle'
|
|
| 'fourteenth-camp'
|
|
| 'fifteenth-battle'
|
|
| 'fifteenth-camp'
|
|
| 'sixteenth-battle'
|
|
| 'sixteenth-camp'
|
|
| 'seventeenth-battle'
|
|
| 'seventeenth-camp'
|
|
| 'eighteenth-battle'
|
|
| 'eighteenth-camp'
|
|
| 'nineteenth-battle'
|
|
| 'nineteenth-camp'
|
|
| 'twentieth-battle'
|
|
| 'twentieth-camp'
|
|
| 'twenty-first-battle'
|
|
| 'twenty-first-camp'
|
|
| 'twenty-second-battle'
|
|
| 'twenty-second-camp'
|
|
| 'twenty-third-battle'
|
|
| 'twenty-third-camp'
|
|
| 'twenty-fourth-battle'
|
|
| 'twenty-fourth-camp'
|
|
| 'twenty-fifth-battle'
|
|
| 'twenty-fifth-camp'
|
|
| 'twenty-sixth-battle'
|
|
| 'twenty-sixth-camp'
|
|
| 'twenty-seventh-battle'
|
|
| 'twenty-seventh-camp'
|
|
| 'twenty-eighth-battle'
|
|
| 'twenty-eighth-camp'
|
|
| 'twenty-ninth-battle'
|
|
| 'twenty-ninth-camp'
|
|
| 'thirtieth-battle'
|
|
| 'thirtieth-camp'
|
|
| 'thirty-first-battle'
|
|
| 'thirty-first-camp'
|
|
| 'thirty-second-battle'
|
|
| 'thirty-second-camp'
|
|
| 'thirty-third-battle'
|
|
| 'thirty-third-camp'
|
|
| 'thirty-fourth-battle'
|
|
| 'thirty-fourth-camp'
|
|
| 'thirty-fifth-battle'
|
|
| 'thirty-fifth-camp'
|
|
| 'thirty-sixth-battle'
|
|
| 'thirty-sixth-camp'
|
|
| 'thirty-seventh-battle'
|
|
| 'thirty-seventh-camp'
|
|
| 'thirty-eighth-battle'
|
|
| 'thirty-eighth-camp'
|
|
| 'thirty-ninth-battle'
|
|
| 'thirty-ninth-camp'
|
|
| 'fortieth-battle'
|
|
| 'fortieth-camp'
|
|
| 'forty-first-battle'
|
|
| 'forty-first-camp'
|
|
| 'forty-second-battle'
|
|
| 'forty-second-camp'
|
|
| 'forty-third-battle'
|
|
| 'forty-third-camp'
|
|
| 'forty-fourth-battle'
|
|
| 'forty-fourth-camp'
|
|
| 'forty-fifth-battle'
|
|
| 'forty-fifth-camp'
|
|
| 'forty-sixth-battle'
|
|
| 'forty-sixth-camp'
|
|
| 'forty-seventh-battle'
|
|
| 'forty-seventh-camp'
|
|
| 'forty-eighth-battle'
|
|
| 'forty-eighth-camp'
|
|
| 'forty-ninth-battle'
|
|
| 'forty-ninth-camp'
|
|
| 'fiftieth-battle'
|
|
| 'fiftieth-camp'
|
|
| 'fifty-first-battle'
|
|
| 'fifty-first-camp'
|
|
| 'fifty-second-battle'
|
|
| 'fifty-second-camp'
|
|
| 'fifty-third-battle'
|
|
| 'fifty-third-camp'
|
|
| 'fifty-fourth-battle'
|
|
| 'fifty-fourth-camp'
|
|
| 'fifty-fifth-battle'
|
|
| 'fifty-fifth-camp'
|
|
| 'fifty-sixth-battle'
|
|
| 'fifty-sixth-camp'
|
|
| 'fifty-seventh-battle'
|
|
| 'fifty-seventh-camp'
|
|
| 'fifty-eighth-battle'
|
|
| 'fifty-eighth-camp'
|
|
| 'fifty-ninth-battle'
|
|
| 'fifty-ninth-camp'
|
|
| 'sixtieth-battle'
|
|
| 'sixtieth-camp'
|
|
| 'sixty-first-battle'
|
|
| 'sixty-first-camp'
|
|
| 'sixty-second-battle'
|
|
| 'sixty-second-camp'
|
|
| 'sixty-third-battle'
|
|
| 'sixty-third-camp'
|
|
| 'sixty-fourth-battle'
|
|
| 'sixty-fourth-camp'
|
|
| 'sixty-fifth-battle'
|
|
| 'sixty-fifth-camp'
|
|
| 'sixty-sixth-battle'
|
|
| 'sixty-sixth-camp'
|
|
| 'ending-complete'
|
|
| 'hanzhong-king-camp'
|
|
| 'shu-han-foundation-camp'
|
|
| 'baidi-entrustment-camp'
|
|
| 'northern-campaign-prep-camp';
|
|
|
|
export type CampaignUnitProgressSnapshot = {
|
|
unitId: string;
|
|
name: string;
|
|
level: number;
|
|
exp: number;
|
|
hp: number;
|
|
maxHp: number;
|
|
equipment: UnitData['equipment'];
|
|
};
|
|
|
|
export type CampaignBondProgressSnapshot = {
|
|
id: string;
|
|
title: string;
|
|
level: number;
|
|
exp: number;
|
|
battleExp: number;
|
|
};
|
|
|
|
export type CampaignReserveTrainingSnapshot = {
|
|
unitId: string;
|
|
name: string;
|
|
expGained: number;
|
|
equipmentExpGained: number;
|
|
bondExpGained?: number;
|
|
focusId?: CampaignReserveTrainingFocusId;
|
|
focusLabel?: string;
|
|
level: number;
|
|
exp: number;
|
|
};
|
|
|
|
export type CampaignReserveTrainingFocusId = 'balanced' | 'class-practice' | 'bond-practice';
|
|
|
|
export type CampaignReserveTrainingFocusDefinition = {
|
|
id: CampaignReserveTrainingFocusId;
|
|
label: string;
|
|
summary: string;
|
|
expGained: number;
|
|
equipmentExpGained: number;
|
|
bondExpGained: number;
|
|
};
|
|
|
|
export const defaultCampaignReserveTrainingFocusId: CampaignReserveTrainingFocusId = 'balanced';
|
|
|
|
const defaultEquipmentBySlot: Record<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 maxCampaignUnknownInventoryAmount = 999;
|
|
const maxCampaignRewardNoteLength = 180;
|
|
const maxCampaignBattleObjectiveEntries = 24;
|
|
const maxCampaignBattleUnitEntries = 96;
|
|
const maxCampaignBattleBondEntries = 96;
|
|
const maxCampaignReserveTrainingEntries = 96;
|
|
const maxCampaignSortieAssignmentUnits = 96;
|
|
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'];
|
|
rewardGold: number;
|
|
itemRewards: string[];
|
|
campaignRewards?: CampaignRewardSnapshot;
|
|
objectives: BattleObjectiveSnapshot[];
|
|
units: CampaignUnitProgressSnapshot[];
|
|
bonds: CampaignBondProgressSnapshot[];
|
|
reserveTraining?: CampaignReserveTrainingSnapshot[];
|
|
completedAt: string;
|
|
};
|
|
|
|
export type CampaignSortieItemAssignments = Record<string, Record<string, number>>;
|
|
|
|
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;
|
|
reserveTrainingFocus: CampaignReserveTrainingFocusId;
|
|
completedCampDialogues: string[];
|
|
completedCampVisits: string[];
|
|
battleHistory: Record<string, CampaignBattleSettlement>;
|
|
latestBattleId?: string;
|
|
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',
|
|
'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: '프롤로그' },
|
|
'first-victory-story': { title: '탁현 방어 성공', meta: '승리 후 이야기' },
|
|
'hanzhong-king-camp': { title: '한중왕 즉위 준비', meta: '군영 의정' },
|
|
'shu-han-foundation-camp': { title: '촉한 건국 선포', meta: '군영 의정' },
|
|
'baidi-entrustment-camp': { title: '백제성 유탁', meta: '군영 의정' },
|
|
'northern-campaign-prep-camp': { title: '북벌 준비 회의', meta: '군영 의정' },
|
|
'ending-complete': { title: '북벌의 끝과 남은 뜻', meta: '완료' }
|
|
};
|
|
|
|
export function summarizeCampaignProgress(state: CampaignState) {
|
|
const battleId = battleIdForCampaignStep(state.step);
|
|
const battleTitle = battleId ? scenarioTitle(battleId) : undefined;
|
|
if (battleId && battleTitle) {
|
|
const settlement = state.battleHistory[battleId];
|
|
return {
|
|
title: battleTitle,
|
|
meta: settlement?.outcome === 'defeat' ? '재도전 준비' : '출전 준비'
|
|
};
|
|
}
|
|
|
|
const special = campaignStepProgressLabels[state.step];
|
|
if (special) {
|
|
return special;
|
|
}
|
|
|
|
const latestSettlement = latestCampaignSettlement(state);
|
|
if (isCampCampaignStep(state.step) && latestSettlement) {
|
|
return {
|
|
title: latestSettlement.battleTitle,
|
|
meta: latestSettlement.outcome === 'victory' ? '승리 후 군영' : '재정비'
|
|
};
|
|
}
|
|
|
|
if (latestSettlement) {
|
|
return {
|
|
title: latestSettlement.battleTitle,
|
|
meta: latestSettlement.outcome === 'victory' ? '최근 승리' : '최근 전투'
|
|
};
|
|
}
|
|
|
|
if (state.firstBattleReport) {
|
|
return {
|
|
title: state.firstBattleReport.battleTitle,
|
|
meta: state.firstBattleReport.outcome === 'victory' ? '승리 기록' : '전투 기록'
|
|
};
|
|
}
|
|
|
|
return {
|
|
title: '캠페인 진행',
|
|
meta: '진행 중'
|
|
};
|
|
}
|
|
|
|
function scenarioTitle(id?: BattleScenarioId | string) {
|
|
if (id && id in battleScenarios) {
|
|
return battleScenarios[id as BattleScenarioId].title;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function latestCampaignSettlement(state: CampaignState) {
|
|
if (state.latestBattleId) {
|
|
const latest = state.battleHistory[state.latestBattleId];
|
|
if (latest) {
|
|
return latest;
|
|
}
|
|
}
|
|
|
|
return Object.values(state.battleHistory)
|
|
.sort((a, b) => String(b.completedAt).localeCompare(String(a.completedAt)))[0];
|
|
}
|
|
|
|
function countCampaignInventory(inventory: Record<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: {},
|
|
reserveTrainingFocus: defaultCampaignReserveTrainingFocusId,
|
|
completedCampDialogues: [],
|
|
completedCampVisits: [],
|
|
battleHistory: {}
|
|
};
|
|
}
|
|
|
|
export function startNewCampaign() {
|
|
const state = createInitialCampaignState();
|
|
state.step = 'prologue';
|
|
return setCampaignState(state);
|
|
}
|
|
|
|
export function getCampaignState() {
|
|
return cloneCampaignState(ensureCampaignState());
|
|
}
|
|
|
|
export function setCampaignState(state: CampaignState) {
|
|
campaignState = normalizeCampaignState(state);
|
|
campaignState.updatedAt = new Date().toISOString();
|
|
persistCampaignState(campaignState);
|
|
return cloneCampaignState(campaignState);
|
|
}
|
|
|
|
export function loadCampaignState(slot?: number) {
|
|
campaignState = readStoredCampaignState(slot) ?? readStoredCampaignState() ?? readLatestSlottedCampaignState() ?? createInitialCampaignState();
|
|
return cloneCampaignState(campaignState);
|
|
}
|
|
|
|
export function saveCampaignState(state = ensureCampaignState(), slot = state.activeSaveSlot) {
|
|
campaignState = normalizeCampaignState(state);
|
|
campaignState.activeSaveSlot = normalizeSlot(slot);
|
|
campaignState.updatedAt = new Date().toISOString();
|
|
persistCampaignState(campaignState);
|
|
return cloneCampaignState(campaignState);
|
|
}
|
|
|
|
export function resetCampaignState() {
|
|
campaignState = createInitialCampaignState();
|
|
tryStorage()?.removeItem(campaignStorageKey);
|
|
for (let slot = 1; slot <= campaignSaveSlotCount; slot += 1) {
|
|
tryStorage()?.removeItem(campaignSaveSlotKey(slot));
|
|
}
|
|
return cloneCampaignState(campaignState);
|
|
}
|
|
|
|
export function hasCampaignSave() {
|
|
return Boolean(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;
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
export function setCampaignReserveTrainingFocus(focusId: CampaignReserveTrainingFocusId) {
|
|
const state = ensureCampaignState();
|
|
state.reserveTrainingFocus = normalizeReserveTrainingFocusId(focusId);
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
export function setFirstBattleReport(report: FirstBattleReport) {
|
|
const state = ensureCampaignState();
|
|
const reportClone = cloneReport(report);
|
|
const battleId = reportClone.battleId;
|
|
const previousSettlement = state.battleHistory[battleId];
|
|
const completedCampDialogues =
|
|
battleId === 'first-battle-zhuo-commandery' ? [...reportClone.completedCampDialogues] : [...state.completedCampDialogues];
|
|
const completedCampVisits =
|
|
battleId === 'first-battle-zhuo-commandery' ? [...(reportClone.completedCampVisits ?? [])] : [...state.completedCampVisits];
|
|
|
|
reportClone.completedCampDialogues = completedCampDialogues;
|
|
reportClone.completedCampVisits = completedCampVisits;
|
|
state.firstBattleReport = reportClone;
|
|
const stepRule = campaignBattleSteps[battleId] ?? campaignBattleSteps['first-battle-zhuo-commandery'];
|
|
const victoryStep: CampaignStep = stepRule.victory;
|
|
const retryStep: CampaignStep = stepRule.retry;
|
|
if (reportClone.outcome !== 'victory') {
|
|
state.step = retryStep;
|
|
state.latestBattleId = battleId;
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
return;
|
|
}
|
|
|
|
state.step = victoryStep;
|
|
state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold);
|
|
const alliedReportUnits = reportClone.units.filter((unit) => unit.faction === 'ally');
|
|
const recruitUnitIds = new Set(reportClone.campaignRewards?.recruits.map((recruit) => recruit.unitId) ?? []);
|
|
const reportUnitById = new Map(reportClone.units.filter((unit) => unit.faction === 'ally').map((unit) => [unit.id, unit]));
|
|
const recruitedUnits = [...recruitUnitIds]
|
|
.map((unitId) => reportUnitById.get(unitId) ?? campaignRecruitUnitById.get(unitId))
|
|
.filter((unit): unit is UnitData => Boolean(unit));
|
|
state.roster = mergeRosterProgress(state.roster, [...alliedReportUnits, ...recruitedUnits]);
|
|
state.bonds = reportClone.bonds.map(cloneBondSnapshot);
|
|
const reserveTraining = previousSettlement || reportClone.outcome !== 'victory'
|
|
? previousSettlement?.reserveTraining ?? []
|
|
: applyReserveTrainingProgress(
|
|
state.roster,
|
|
state.bonds,
|
|
reportClone.units.filter((unit) => unit.faction === 'ally'),
|
|
state.reserveTrainingFocus
|
|
);
|
|
if (!previousSettlement && reportClone.outcome === 'victory') {
|
|
reportClone.bonds = state.bonds.map(cloneBondSnapshot);
|
|
}
|
|
state.completedCampDialogues = completedCampDialogues;
|
|
state.completedCampVisits = completedCampVisits;
|
|
state.inventory = applyRewardDelta(state.inventory, previousSettlement?.itemRewards ?? [], -1);
|
|
state.inventory = applyRewardDelta(state.inventory, reportClone.itemRewards, 1);
|
|
state.battleHistory[battleId] = createBattleSettlement(reportClone, reserveTraining);
|
|
state.latestBattleId = battleId;
|
|
state.updatedAt = new Date().toISOString();
|
|
|
|
persistCampaignState(state);
|
|
}
|
|
|
|
export function getFirstBattleReport() {
|
|
const report = ensureCampaignState().firstBattleReport;
|
|
return report ? cloneReport(report) : undefined;
|
|
}
|
|
|
|
export function applyCampBondExp(dialogueId: string, bondId: string, amount: number) {
|
|
const state = ensureCampaignState();
|
|
if (!state.firstBattleReport) {
|
|
return undefined;
|
|
}
|
|
|
|
if (hasCompletionFlag(state.completedCampDialogues, state.firstBattleReport.completedCampDialogues, dialogueId)) {
|
|
if (syncCompletionFlag(state.completedCampDialogues, state.firstBattleReport.completedCampDialogues, dialogueId)) {
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
}
|
|
return cloneReport(state.firstBattleReport);
|
|
}
|
|
|
|
const campaignBond = state.bonds.find((candidate) => candidate.id === bondId);
|
|
const reportBond = state.firstBattleReport.bonds.find((candidate) => candidate.id === bondId);
|
|
if (!campaignBond || !reportBond) {
|
|
return undefined;
|
|
}
|
|
|
|
syncCompletionFlag(state.completedCampDialogues, state.firstBattleReport.completedCampDialogues, dialogueId);
|
|
advanceBond(campaignBond, amount);
|
|
reportBond.level = campaignBond.level;
|
|
reportBond.exp = campaignBond.exp;
|
|
reportBond.battleExp = campaignBond.battleExp;
|
|
state.updatedAt = new Date().toISOString();
|
|
|
|
persistCampaignState(state);
|
|
return cloneReport(state.firstBattleReport);
|
|
}
|
|
|
|
export function applyCampVisitReward(
|
|
visitId: string,
|
|
reward: { bondId?: string; bondExp?: number; gold?: number; itemRewards?: string[] }
|
|
) {
|
|
const state = ensureCampaignState();
|
|
if (!state.firstBattleReport) {
|
|
return undefined;
|
|
}
|
|
|
|
if (hasCompletionFlag(state.completedCampVisits, state.firstBattleReport.completedCampVisits, visitId)) {
|
|
if (syncCompletionFlag(state.completedCampVisits, state.firstBattleReport.completedCampVisits, visitId)) {
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
}
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
syncCompletionFlag(state.completedCampVisits, state.firstBattleReport.completedCampVisits, visitId);
|
|
|
|
if (reward.bondId && reward.bondExp && reward.bondExp > 0) {
|
|
const campaignBond = state.bonds.find((candidate) => candidate.id === reward.bondId);
|
|
const reportBond = state.firstBattleReport.bonds.find((candidate) => candidate.id === reward.bondId);
|
|
if (campaignBond) {
|
|
advanceBond(campaignBond, reward.bondExp);
|
|
if (reportBond) {
|
|
reportBond.level = campaignBond.level;
|
|
reportBond.exp = campaignBond.exp;
|
|
reportBond.battleExp = campaignBond.battleExp;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (reward.gold && reward.gold > 0) {
|
|
state.gold += reward.gold;
|
|
}
|
|
|
|
if (reward.itemRewards?.length) {
|
|
state.inventory = applyRewardDelta(state.inventory, reward.itemRewards, 1);
|
|
}
|
|
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
function hasCompletionFlag(stateFlags: string[], reportFlags: string[], flagId: string) {
|
|
return stateFlags.includes(flagId) || reportFlags.includes(flagId);
|
|
}
|
|
|
|
function syncCompletionFlag(stateFlags: string[], reportFlags: string[], flagId: string) {
|
|
let changed = false;
|
|
if (!stateFlags.includes(flagId)) {
|
|
stateFlags.push(flagId);
|
|
changed = true;
|
|
}
|
|
if (!reportFlags.includes(flagId)) {
|
|
reportFlags.push(flagId);
|
|
changed = true;
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
export function ensureCampaignRosterUnits(units: UnitData[], bonds: BattleBond[] = []) {
|
|
const state = ensureCampaignState();
|
|
let changed = false;
|
|
const existingUnitIds = new Set(state.roster.map((unit) => unit.id));
|
|
|
|
units.forEach((unit) => {
|
|
if (existingUnitIds.has(unit.id)) {
|
|
return;
|
|
}
|
|
state.roster.push(cloneUnit(unit));
|
|
existingUnitIds.add(unit.id);
|
|
changed = true;
|
|
});
|
|
|
|
const existingBondIds = new Set(state.bonds.map((bond) => bond.id));
|
|
bonds.forEach((bond) => {
|
|
if (existingBondIds.has(bond.id)) {
|
|
return;
|
|
}
|
|
state.bonds.push(createCampBondSnapshot(bond));
|
|
existingBondIds.add(bond.id);
|
|
changed = true;
|
|
});
|
|
|
|
if (state.firstBattleReport) {
|
|
const reportUnitIds = new Set(state.firstBattleReport.units.map((unit) => unit.id));
|
|
units.forEach((unit) => {
|
|
if (reportUnitIds.has(unit.id)) {
|
|
return;
|
|
}
|
|
state.firstBattleReport?.units.push(cloneUnit(unit));
|
|
reportUnitIds.add(unit.id);
|
|
changed = true;
|
|
});
|
|
|
|
const reportBondIds = new Set(state.firstBattleReport.bonds.map((bond) => bond.id));
|
|
bonds.forEach((bond) => {
|
|
if (reportBondIds.has(bond.id)) {
|
|
return;
|
|
}
|
|
state.firstBattleReport?.bonds.push(createCampBondSnapshot(bond));
|
|
reportBondIds.add(bond.id);
|
|
changed = true;
|
|
});
|
|
}
|
|
|
|
if (changed) {
|
|
state.updatedAt = new Date().toISOString();
|
|
persistCampaignState(state);
|
|
}
|
|
|
|
return cloneCampaignState(state);
|
|
}
|
|
|
|
function ensureCampaignState() {
|
|
if (!campaignState) {
|
|
campaignState = readStoredCampaignState() ?? createInitialCampaignState();
|
|
}
|
|
return campaignState;
|
|
}
|
|
|
|
function normalizeCampaignState(state: Partial<CampaignState>): 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 = uniqueStrings(normalized.completedCampDialogues);
|
|
normalized.completedCampVisits = uniqueStrings(normalized.completedCampVisits);
|
|
normalized.inventory = normalizeInventory(normalized.inventory);
|
|
const rosterUnitIds = normalizedRosterUnitIds(normalized.roster);
|
|
const sortieUnitFilter = rosterUnitIds.size > 0 ? rosterUnitIds : undefined;
|
|
if (sortieUnitFilter) {
|
|
normalized.bonds = normalized.bonds.filter((bond) => bond.unitIds.every((unitId) => sortieUnitFilter.has(unitId)));
|
|
}
|
|
normalized.selectedSortieUnitIds = uniqueStrings(normalized.selectedSortieUnitIds)
|
|
.filter((unitId) => !sortieUnitFilter || sortieUnitFilter.has(unitId));
|
|
normalized.sortieFormationAssignments = normalizeSortieFormationAssignments(recordOrEmpty(normalized.sortieFormationAssignments), sortieUnitFilter);
|
|
normalized.sortieItemAssignments = normalizeCampaignSortieItemAssignments(recordOrEmpty(normalized.sortieItemAssignments), sortieUnitFilter);
|
|
normalized.reserveTrainingFocus = normalizeReserveTrainingFocusId(normalized.reserveTrainingFocus);
|
|
normalized.battleHistory = normalizeBattleHistory(normalized.battleHistory);
|
|
if (sortieUnitFilter) {
|
|
normalized.battleHistory = filterBattleHistoryRosterReferences(
|
|
normalized.battleHistory,
|
|
sortieUnitFilter,
|
|
new Set(normalized.bonds.map((bond) => bond.id))
|
|
);
|
|
}
|
|
normalized.latestBattleId = normalizeLatestBattleId(normalized.latestBattleId, normalized.battleHistory);
|
|
normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport);
|
|
if (normalized.firstBattleReport && sortieUnitFilter) {
|
|
normalized.firstBattleReport = filterFirstBattleReportRosterReferences(normalized.firstBattleReport, sortieUnitFilter);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function arrayOrEmpty<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 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, 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 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 filtered = {
|
|
...report,
|
|
units: report.units.filter((unit) => unit.faction !== 'ally' || rosterUnitIds.has(unit.id)),
|
|
bonds: report.bonds.filter((bond) => bond.unitIds.every((unitId) => rosterUnitIds.has(unitId)))
|
|
};
|
|
if (filtered.mvp && !rosterUnitIds.has(filtered.mvp.unitId)) {
|
|
delete filtered.mvp;
|
|
}
|
|
return filtered;
|
|
}
|
|
|
|
function filterBattleHistoryRosterReferences(
|
|
history: Record<string, CampaignBattleSettlement>,
|
|
rosterUnitIds: Set<string>,
|
|
bondIds: Set<string>
|
|
): Record<string, CampaignBattleSettlement> {
|
|
return Object.entries(history).reduce<Record<string, CampaignBattleSettlement>>((filteredHistory, [battleId, settlement]) => {
|
|
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))
|
|
};
|
|
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;
|
|
}
|
|
|
|
return {
|
|
battleId,
|
|
battleTitle,
|
|
outcome: settlement.outcome,
|
|
rewardGold: normalizeNonNegativeInteger(settlement.rewardGold),
|
|
itemRewards: normalizeRewardStrings(settlement.itemRewards),
|
|
campaignRewards: cloneCampaignRewardSnapshot(settlement.campaignRewards, battleId),
|
|
objectives: normalizeLimitedArray(settlement.objectives, normalizeBattleObjectiveSnapshot, maxCampaignBattleObjectiveEntries),
|
|
units: normalizeLimitedArray(settlement.units, normalizeCampaignUnitProgressSnapshot, maxCampaignBattleUnitEntries),
|
|
bonds: normalizeLimitedArray(settlement.bonds, normalizeCampaignBondProgressSnapshot, maxCampaignBattleBondEntries),
|
|
reserveTraining: normalizeLimitedArray(settlement.reserveTraining, normalizeReserveTrainingSnapshot, maxCampaignReserveTrainingEntries),
|
|
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;
|
|
}, {});
|
|
}
|
|
|
|
function normalizeReserveTrainingFocusId(focusId?: string): CampaignReserveTrainingFocusId {
|
|
return campaignReserveTrainingFocusDefinitions.some((focus) => focus.id === focusId)
|
|
? focusId as CampaignReserveTrainingFocusId
|
|
: defaultCampaignReserveTrainingFocusId;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
return {
|
|
battleId,
|
|
battleTitle,
|
|
outcome: report.outcome,
|
|
turnNumber: normalizeNonNegativeInteger(report.turnNumber),
|
|
rewardGold: normalizeNonNegativeInteger(report.rewardGold),
|
|
defeatedEnemies: normalizeNonNegativeInteger(report.defeatedEnemies),
|
|
totalEnemies: normalizeNonNegativeInteger(report.totalEnemies),
|
|
objectives: normalizeLimitedArray(report.objectives, normalizeBattleObjectiveSnapshot, maxCampaignBattleObjectiveEntries),
|
|
units: normalizeLimitedArray(report.units, normalizeUnitDataSnapshot, maxCampaignBattleUnitEntries),
|
|
bonds: normalizeLimitedArray(report.bonds, normalizeCampBondSnapshot, maxCampaignBattleBondEntries),
|
|
mvp: normalizeCampMvpSnapshot(report.mvp),
|
|
itemRewards: normalizeRewardStrings(report.itemRewards),
|
|
campaignRewards: cloneCampaignRewardSnapshot(
|
|
isPlainObject(report.campaignRewards) ? report.campaignRewards as CampaignRewardSnapshot : undefined,
|
|
battleId
|
|
),
|
|
completedCampDialogues: uniqueStrings(report.completedCampDialogues),
|
|
completedCampVisits: uniqueStrings(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,
|
|
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 })),
|
|
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
|
|
): CampaignReserveTrainingSnapshot[] {
|
|
const focus = reserveTrainingFocusDefinition(focusId);
|
|
const deployedIds = new Set(deployedUnits.map((unit) => unit.id));
|
|
const reserveUnits = roster.filter((unit) => unit.faction === 'ally' && !deployedIds.has(unit.id));
|
|
const reserveIds = new Set(reserveUnits.map((unit) => unit.id));
|
|
const bondExpByUnit = new Map<string, number>();
|
|
|
|
if (focus.bondExpGained > 0 && reserveIds.size > 0) {
|
|
bonds.forEach((bond) => {
|
|
const reservePartners = bond.unitIds.filter((unitId) => reserveIds.has(unitId));
|
|
if (reservePartners.length === 0) {
|
|
return;
|
|
}
|
|
advanceBond(bond, focus.bondExpGained);
|
|
reservePartners.forEach((unitId) => {
|
|
bondExpByUnit.set(unitId, (bondExpByUnit.get(unitId) ?? 0) + focus.bondExpGained);
|
|
});
|
|
});
|
|
}
|
|
|
|
return reserveUnits
|
|
.map((unit) => {
|
|
const expGained = focus.expGained;
|
|
const equipmentExpGained = focus.equipmentExpGained;
|
|
advanceUnitExp(unit, expGained);
|
|
advanceReserveEquipment(unit, equipmentExpGained);
|
|
return {
|
|
unitId: unit.id,
|
|
name: unit.name,
|
|
expGained,
|
|
equipmentExpGained,
|
|
bondExpGained: bondExpByUnit.get(unit.id) ?? 0,
|
|
focusId: focus.id,
|
|
focusLabel: focus.label,
|
|
level: unit.level,
|
|
exp: unit.exp
|
|
};
|
|
});
|
|
}
|
|
|
|
function advanceUnitExp(unit: UnitData, amount: number) {
|
|
let exp = unit.exp + amount;
|
|
while (unit.level < 99 && exp >= 100) {
|
|
exp -= 100;
|
|
unit.level += 1;
|
|
unit.maxHp += 2;
|
|
unit.hp = Math.min(unit.maxHp, unit.hp + 2);
|
|
unit.attack += 1;
|
|
}
|
|
unit.exp = unit.level >= 99 ? Math.min(exp, 100) : exp;
|
|
}
|
|
|
|
function advanceReserveEquipment(unit: UnitData, amount: number) {
|
|
equipmentSlots.forEach((slot) => {
|
|
const state = unit.equipment[slot];
|
|
let exp = state.exp + amount;
|
|
while (state.level < 9 && exp >= equipmentExpToNext(state.level)) {
|
|
exp -= equipmentExpToNext(state.level);
|
|
state.level += 1;
|
|
}
|
|
state.exp = state.level >= 9 ? Math.min(exp, equipmentExpToNext(state.level)) : exp;
|
|
});
|
|
}
|
|
|
|
function parseRewardLabel(reward: string) {
|
|
const normalized = reward.replace(/\s+/g, ' ').trim();
|
|
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 cloneReport(report: FirstBattleReport): FirstBattleReport {
|
|
const cloned = JSON.parse(JSON.stringify(report)) as FirstBattleReport;
|
|
cloned.itemRewards = normalizeRewardStrings(cloned.itemRewards);
|
|
cloned.completedCampDialogues = uniqueStrings(cloned.completedCampDialogues);
|
|
cloned.completedCampVisits = uniqueStrings(cloned.completedCampVisits);
|
|
if (cloned.campaignRewards) {
|
|
cloned.campaignRewards = cloneCampaignRewardSnapshot(cloned.campaignRewards, cloned.battleId);
|
|
}
|
|
return cloned;
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|