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

1500 lines
54 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
};
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(tryStorage()?.getItem(campaignStorageKey) ?? readLatestSlottedCampaignState());
}
export function listCampaignSaveSlots(): CampaignSaveSlotSummary[] {
return Array.from({ length: campaignSaveSlotCount }, (_, index) => {
const slot = index + 1;
const state = readStoredCampaignState(slot);
if (!state) {
return { slot, exists: false };
}
const progress = summarizeCampaignProgress(state);
return {
slot,
exists: true,
updatedAt: state.updatedAt,
step: state.step,
gold: state.gold,
battleTitle: progress.title,
progressTitle: progress.title,
progressMeta: progress.meta,
rosterCount: state.roster.length,
inventoryCount: countCampaignInventory(state.inventory)
};
});
}
export function markCampaignStep(step: CampaignStep) {
const state = ensureCampaignState();
state.step = step;
state.updatedAt = new Date().toISOString();
persistCampaignState(state);
return cloneCampaignState(state);
}
export function setCampaignReserveTrainingFocus(focusId: CampaignReserveTrainingFocusId) {
const state = ensureCampaignState();
state.reserveTrainingFocus = normalizeReserveTrainingFocusId(focusId);
state.updatedAt = new Date().toISOString();
persistCampaignState(state);
return cloneCampaignState(state);
}
export function setFirstBattleReport(report: FirstBattleReport) {
const state = ensureCampaignState();
const reportClone = cloneReport(report);
const battleId = reportClone.battleId;
const previousSettlement = state.battleHistory[battleId];
const completedCampDialogues =
battleId === 'first-battle-zhuo-commandery' ? [...reportClone.completedCampDialogues] : [...state.completedCampDialogues];
const completedCampVisits =
battleId === 'first-battle-zhuo-commandery' ? [...(reportClone.completedCampVisits ?? [])] : [...state.completedCampVisits];
reportClone.completedCampDialogues = completedCampDialogues;
reportClone.completedCampVisits = completedCampVisits;
state.firstBattleReport = reportClone;
const stepRule = campaignBattleSteps[battleId] ?? campaignBattleSteps['first-battle-zhuo-commandery'];
const victoryStep: CampaignStep = stepRule.victory;
const retryStep: CampaignStep = stepRule.retry;
if (reportClone.outcome !== 'victory') {
state.step = retryStep;
state.latestBattleId = battleId;
state.updatedAt = new Date().toISOString();
persistCampaignState(state);
return;
}
state.step = victoryStep;
state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold);
const alliedReportUnits = reportClone.units.filter((unit) => unit.faction === 'ally');
const recruitUnitIds = new Set(reportClone.campaignRewards?.recruits.map((recruit) => recruit.unitId) ?? []);
const reportUnitById = new Map(reportClone.units.filter((unit) => unit.faction === 'ally').map((unit) => [unit.id, unit]));
const recruitedUnits = [...recruitUnitIds]
.map((unitId) => reportUnitById.get(unitId) ?? campaignRecruitUnitById.get(unitId))
.filter((unit): unit is UnitData => Boolean(unit));
state.roster = mergeRosterProgress(state.roster, [...alliedReportUnits, ...recruitedUnits]);
state.bonds = reportClone.bonds.map(cloneBondSnapshot);
const reserveTraining = previousSettlement || reportClone.outcome !== 'victory'
? previousSettlement?.reserveTraining ?? []
: applyReserveTrainingProgress(
state.roster,
state.bonds,
reportClone.units.filter((unit) => unit.faction === 'ally'),
state.reserveTrainingFocus
);
if (!previousSettlement && reportClone.outcome === 'victory') {
reportClone.bonds = state.bonds.map(cloneBondSnapshot);
}
state.completedCampDialogues = completedCampDialogues;
state.completedCampVisits = completedCampVisits;
state.inventory = applyRewardDelta(state.inventory, previousSettlement?.itemRewards ?? [], -1);
state.inventory = applyRewardDelta(state.inventory, reportClone.itemRewards, 1);
state.battleHistory[battleId] = createBattleSettlement(reportClone, reserveTraining);
state.latestBattleId = battleId;
state.updatedAt = new Date().toISOString();
persistCampaignState(state);
}
export function getFirstBattleReport() {
const report = ensureCampaignState().firstBattleReport;
return report ? cloneReport(report) : undefined;
}
export function applyCampBondExp(dialogueId: string, bondId: string, amount: number) {
const state = ensureCampaignState();
if (!state.firstBattleReport) {
return undefined;
}
if (!state.completedCampDialogues.includes(dialogueId)) {
state.completedCampDialogues.push(dialogueId);
}
if (!state.firstBattleReport.completedCampDialogues.includes(dialogueId)) {
state.firstBattleReport.completedCampDialogues.push(dialogueId);
}
const campaignBond = state.bonds.find((candidate) => candidate.id === bondId);
const reportBond = state.firstBattleReport.bonds.find((candidate) => candidate.id === bondId);
if (!campaignBond || !reportBond) {
return undefined;
}
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 (state.completedCampVisits.includes(visitId)) {
return cloneCampaignState(state);
}
state.completedCampVisits.push(visitId);
if (!state.firstBattleReport.completedCampVisits.includes(visitId)) {
state.firstBattleReport.completedCampVisits.push(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);
}
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 = typeof normalized.updatedAt === 'string' && normalized.updatedAt ? normalized.updatedAt : new Date().toISOString();
normalized.step = normalizeCampaignStep(normalized.step);
normalized.activeSaveSlot = normalizeSlot(normalized.activeSaveSlot);
normalized.gold = normalizeNonNegativeInteger(normalized.gold);
normalized.roster = arrayOrEmpty<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);
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' && entry.length > 0))];
}
function normalizedRosterUnitIds(roster: UnitData[]) {
return new Set(
roster
.map((unit) => unit.id)
.filter((unitId): unitId is string => typeof unitId === 'string' && unitId.length > 0)
);
}
function normalizeNonNegativeInteger(value: unknown) {
const numeric = Math.floor(Number(value));
return Number.isFinite(numeric) && numeric > 0 ? numeric : 0;
}
function normalizeInventory(value: unknown) {
return Object.entries(recordOrEmpty(value)).reduce<Record<string, number>>((inventory, [itemId, amount]) => {
const normalizedAmount = normalizeNonNegativeInteger(amount);
if (itemId && normalizedAmount > 0) {
inventory[itemId] = normalizedAmount;
}
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) => String(b.completedAt).localeCompare(String(a.completedAt)))[0]?.battleId;
}
function filterFirstBattleReportRosterReferences(report: FirstBattleReport, rosterUnitIds: Set<string>): FirstBattleReport {
const filtered = {
...report,
bonds: report.bonds.filter((bond) => bond.unitIds.every((unitId) => rosterUnitIds.has(unitId)))
};
if (filtered.mvp && !rosterUnitIds.has(filtered.mvp.unitId)) {
delete filtered.mvp;
}
return filtered;
}
function normalizeCampaignBattleSettlement(value: unknown): CampaignBattleSettlement | undefined {
if (!isPlainObject(value)) {
return undefined;
}
const settlement = value as Partial<CampaignBattleSettlement>;
if (
typeof settlement.battleId !== 'string' ||
settlement.battleId.length === 0 ||
typeof settlement.battleTitle !== 'string' ||
settlement.battleTitle.length === 0 ||
(settlement.outcome !== 'victory' && settlement.outcome !== 'defeat') ||
typeof settlement.completedAt !== 'string' ||
settlement.completedAt.length === 0
) {
return undefined;
}
return {
battleId: settlement.battleId,
battleTitle: settlement.battleTitle,
outcome: settlement.outcome,
rewardGold: normalizeNonNegativeInteger(settlement.rewardGold),
itemRewards: uniqueStrings(settlement.itemRewards),
campaignRewards: cloneCampaignRewardSnapshot(settlement.campaignRewards),
objectives: arrayOrEmpty<unknown>(settlement.objectives)
.map(normalizeBattleObjectiveSnapshot)
.filter((objective): objective is BattleObjectiveSnapshot => Boolean(objective)),
units: arrayOrEmpty<unknown>(settlement.units)
.map(normalizeCampaignUnitProgressSnapshot)
.filter((unit): unit is CampaignUnitProgressSnapshot => Boolean(unit)),
bonds: arrayOrEmpty<unknown>(settlement.bonds)
.map(normalizeCampaignBondProgressSnapshot)
.filter((bond): bond is CampaignBondProgressSnapshot => Boolean(bond)),
reserveTraining: arrayOrEmpty<unknown>(settlement.reserveTraining)
.map(normalizeReserveTrainingSnapshot)
.filter((entry): entry is CampaignReserveTrainingSnapshot => Boolean(entry)),
completedAt: settlement.completedAt
};
}
function normalizeBattleObjectiveSnapshot(value: unknown): BattleObjectiveSnapshot | undefined {
if (!isPlainObject(value) || typeof value.id !== 'string' || value.id.length === 0 || typeof value.label !== 'string' || value.label.length === 0) {
return undefined;
}
const status = value.status === 'active' || value.status === 'done' || value.status === 'failed' ? value.status : undefined;
const category = value.category === 'primary' || value.category === 'required' || value.category === 'bonus' ? value.category : undefined;
const targetTile = normalizeObjectiveTargetTile(value.targetTile);
return {
id: value.id,
label: value.label,
achieved: Boolean(value.achieved),
...(status ? { status } : {}),
detail: typeof value.detail === 'string' ? value.detail : '',
...(category ? { category } : {}),
...(typeof value.summary === 'string' ? { summary: value.summary } : {}),
...(typeof value.failureReason === 'string' ? { failureReason: value.failureReason } : {}),
...(targetTile ? { targetTile } : {}),
rewardGold: normalizeNonNegativeInteger(value.rewardGold)
};
}
function normalizeObjectiveTargetTile(value: unknown): BattleObjectiveSnapshot['targetTile'] | undefined {
if (!isPlainObject(value)) {
return undefined;
}
const x = Math.floor(Number(value.x));
const y = Math.floor(Number(value.y));
if (!Number.isFinite(x) || !Number.isFinite(y)) {
return undefined;
}
const radius = normalizeNonNegativeInteger(value.radius);
return radius > 0 ? { x, y, radius } : { x, y };
}
function normalizeCampaignUnitProgressSnapshot(value: unknown): CampaignUnitProgressSnapshot | undefined {
if (!isPlainObject(value) || typeof value.unitId !== 'string' || value.unitId.length === 0 || typeof value.name !== 'string' || value.name.length === 0) {
return undefined;
}
const hp = normalizeNonNegativeInteger(value.hp);
const maxHp = Math.max(hp, normalizeNonNegativeInteger(value.maxHp));
return {
unitId: value.unitId,
name: value.name,
level: Math.max(1, normalizeNonNegativeInteger(value.level)),
exp: normalizeNonNegativeInteger(value.exp),
hp,
maxHp: Math.max(1, maxHp),
equipment: normalizeEquipmentSet(value.equipment)
};
}
function normalizeCampaignBondProgressSnapshot(value: unknown): CampaignBondProgressSnapshot | undefined {
if (!isPlainObject(value) || typeof value.id !== 'string' || value.id.length === 0 || typeof value.title !== 'string' || value.title.length === 0) {
return undefined;
}
return {
id: value.id,
title: value.title,
level: normalizeNonNegativeInteger(value.level),
exp: normalizeNonNegativeInteger(value.exp),
battleExp: normalizeNonNegativeInteger(value.battleExp)
};
}
function normalizeReserveTrainingSnapshot(value: unknown): CampaignReserveTrainingSnapshot | undefined {
if (!isPlainObject(value) || typeof value.unitId !== 'string' || value.unitId.length === 0 || typeof value.name !== 'string' || value.name.length === 0) {
return undefined;
}
return {
unitId: value.unitId,
name: value.name,
expGained: normalizeNonNegativeInteger(value.expGained),
equipmentExpGained: normalizeNonNegativeInteger(value.equipmentExpGained),
bondExpGained: normalizeNonNegativeInteger(value.bondExpGained),
focusId: normalizeReserveTrainingFocusId(typeof value.focusId === 'string' ? value.focusId : undefined),
...(typeof value.focusLabel === 'string' ? { focusLabel: value.focusLabel } : {}),
level: normalizeNonNegativeInteger(value.level),
exp: normalizeNonNegativeInteger(value.exp)
};
}
export function normalizeCampaignSortieItemAssignments(assignments?: Record<string, unknown>, allowedUnitIds?: Set<string>) {
return Object.entries(assignments ?? {}).reduce<CampaignSortieItemAssignments>((next, [unitId, stocks]) => {
if (!unitId || (allowedUnitIds && !allowedUnitIds.has(unitId))) {
return next;
}
const unitStocks = Object.entries(recordOrEmpty(stocks)).reduce<Record<string, number>>((nextStocks, [itemId, amount]) => {
const maxPerUnit = sortieSupplyMaxByUsableId[itemId];
if (!maxPerUnit) {
return nextStocks;
}
const normalizedAmount = normalizeNonNegativeInteger(amount);
if (itemId && normalizedAmount > 0) {
nextStocks[itemId] = Math.min(maxPerUnit, normalizedAmount);
}
return nextStocks;
}, {});
if (Object.keys(unitStocks).length > 0) {
next[unitId] = unitStocks;
}
return next;
}, {});
}
function normalizeReserveTrainingFocusId(focusId?: string): CampaignReserveTrainingFocusId {
return campaignReserveTrainingFocusDefinitions.some((focus) => focus.id === focusId)
? focusId as CampaignReserveTrainingFocusId
: defaultCampaignReserveTrainingFocusId;
}
function normalizeCampaignStep(step: unknown): CampaignStep {
return typeof step === 'string' && campaignStepIds.has(step as CampaignStep) ? step as CampaignStep : 'new';
}
function normalizeFirstBattleReport(report: unknown): FirstBattleReport | undefined {
if (!isPlainObject(report)) {
return undefined;
}
if (
typeof report.battleId !== 'string' ||
report.battleId.length === 0 ||
(report.outcome !== 'victory' && report.outcome !== 'defeat')
) {
return undefined;
}
const battleTitle =
typeof report.battleTitle === 'string' && report.battleTitle.length > 0
? report.battleTitle
: battleScenarios[report.battleId as BattleScenarioId]?.title;
if (!battleTitle) {
return undefined;
}
return {
battleId: report.battleId,
battleTitle,
outcome: report.outcome,
turnNumber: normalizeNonNegativeInteger(report.turnNumber),
rewardGold: normalizeNonNegativeInteger(report.rewardGold),
defeatedEnemies: normalizeNonNegativeInteger(report.defeatedEnemies),
totalEnemies: normalizeNonNegativeInteger(report.totalEnemies),
objectives: arrayOrEmpty<unknown>(report.objectives)
.map(normalizeBattleObjectiveSnapshot)
.filter((objective): objective is BattleObjectiveSnapshot => Boolean(objective)),
units: arrayOrEmpty<unknown>(report.units)
.map(normalizeUnitDataSnapshot)
.filter((unit): unit is UnitData => Boolean(unit)),
bonds: arrayOrEmpty<unknown>(report.bonds)
.map(normalizeCampBondSnapshot)
.filter((bond): bond is CampBondSnapshot => Boolean(bond)),
mvp: normalizeCampMvpSnapshot(report.mvp),
itemRewards: uniqueStrings(report.itemRewards),
campaignRewards: cloneCampaignRewardSnapshot(
isPlainObject(report.campaignRewards) ? report.campaignRewards as CampaignRewardSnapshot : undefined
),
completedCampDialogues: uniqueStrings(report.completedCampDialogues),
completedCampVisits: uniqueStrings(report.completedCampVisits),
createdAt: typeof report.createdAt === 'string' && report.createdAt.length > 0 ? report.createdAt : new Date().toISOString()
};
}
function normalizeUnitDataSnapshot(value: unknown): UnitData | undefined {
if (
!isPlainObject(value) ||
typeof value.id !== 'string' ||
value.id.length === 0 ||
typeof value.name !== 'string' ||
value.name.length === 0 ||
(value.faction !== 'ally' && value.faction !== 'enemy')
) {
return undefined;
}
const stats = recordOrEmpty(value.stats);
const hp = normalizeNonNegativeInteger(value.hp);
const maxHp = Math.max(hp, normalizeNonNegativeInteger(value.maxHp));
return {
id: value.id,
name: value.name,
faction: value.faction,
className: typeof value.className === 'string' ? value.className : '',
classKey: normalizeUnitClassKey(value.classKey),
level: Math.max(1, normalizeNonNegativeInteger(value.level)),
exp: normalizeNonNegativeInteger(value.exp),
hp,
maxHp: Math.max(1, maxHp),
attack: normalizeNonNegativeInteger(value.attack),
move: normalizeNonNegativeInteger(value.move),
stats: {
might: normalizeNonNegativeInteger(stats.might),
intelligence: normalizeNonNegativeInteger(stats.intelligence),
leadership: normalizeNonNegativeInteger(stats.leadership),
agility: normalizeNonNegativeInteger(stats.agility),
luck: normalizeNonNegativeInteger(stats.luck)
},
equipment: normalizeEquipmentSet(value.equipment),
x: normalizeNonNegativeInteger(value.x),
y: normalizeNonNegativeInteger(value.y)
};
}
function normalizeUnitClassKey(value: unknown): UnitData['classKey'] {
return typeof value === 'string' && value in unitClasses ? value as UnitData['classKey'] : 'infantry';
}
function normalizeEquipmentSet(value: unknown): UnitData['equipment'] {
const equipmentRecord = recordOrEmpty(value);
return equipmentSlots.reduce<UnitData['equipment']>((equipment, slot) => {
const state = recordOrEmpty(equipmentRecord[slot]);
const itemId =
typeof state.itemId === 'string' && itemCatalog[state.itemId]?.slot === slot
? state.itemId
: defaultEquipmentBySlot[slot];
equipment[slot] = {
itemId,
level: normalizeEquipmentLevel(state.level),
exp: normalizeNonNegativeInteger(state.exp)
};
return equipment;
}, {} as UnitData['equipment']);
}
function normalizeEquipmentLevel(value: unknown) {
const level = normalizeNonNegativeInteger(value);
return Math.min(9, Math.max(1, level));
}
function normalizeCampMvpSnapshot(value: unknown): CampMvpSnapshot | undefined {
if (
!isPlainObject(value) ||
typeof value.unitId !== 'string' ||
value.unitId.length === 0 ||
typeof value.name !== 'string' ||
value.name.length === 0
) {
return undefined;
}
return {
unitId: value.unitId,
name: value.name,
damageDealt: normalizeNonNegativeInteger(value.damageDealt),
defeats: normalizeNonNegativeInteger(value.defeats)
};
}
function normalizeCampBondSnapshot(value: unknown): CampBondSnapshot | undefined {
if (!isPlainObject(value)) {
return undefined;
}
const unitIds = arrayOrEmpty<unknown>(value.unitIds).filter((unitId): unitId is string => typeof unitId === 'string' && unitId.length > 0);
if (
typeof value.id !== 'string' ||
value.id.length === 0 ||
typeof value.title !== 'string' ||
value.title.length === 0 ||
unitIds.length < 2
) {
return undefined;
}
return {
...(value as CampBondSnapshot),
id: value.id,
title: value.title,
unitIds: [unitIds[0], unitIds[1]] as [string, string],
level: normalizeNonNegativeInteger(value.level),
exp: normalizeNonNegativeInteger(value.exp),
battleExp: normalizeNonNegativeInteger(value.battleExp)
};
}
function reserveTrainingFocusDefinition(focusId?: string) {
const normalizedId = normalizeReserveTrainingFocusId(focusId);
return campaignReserveTrainingFocusDefinitions.find((focus) => focus.id === normalizedId) ?? campaignReserveTrainingFocusDefinitions[0];
}
function readStoredCampaignState(slot?: number) {
const raw = tryStorage()?.getItem(slot ? campaignSaveSlotKey(slot) : campaignStorageKey);
if (!raw) {
return undefined;
}
try {
const parsed = JSON.parse(raw) as Partial<CampaignState> | null;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return undefined;
}
if (parsed.version !== undefined && parsed.version !== 1) {
return undefined;
}
return normalizeCampaignState(parsed);
} catch {
return undefined;
}
}
function persistCampaignState(state: CampaignState) {
const storage = tryStorage();
storage?.setItem(campaignStorageKey, JSON.stringify(state));
storage?.setItem(campaignSaveSlotKey(state.activeSaveSlot), JSON.stringify(state));
}
function tryStorage() {
if (typeof window === 'undefined') {
return undefined;
}
return window.localStorage;
}
function campaignSaveSlotKey(slot: number) {
return `${campaignStorageKey}:slot-${normalizeSlot(slot)}`;
}
function normalizeSlot(slot: number | undefined) {
if (!slot || Number.isNaN(slot)) {
return 1;
}
return Math.min(campaignSaveSlotCount, Math.max(1, Math.floor(slot)));
}
function readLatestSlottedCampaignState() {
const latest = listCampaignSaveSlots()
.filter((slot) => slot.exists && slot.updatedAt)
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))[0];
return latest ? readStoredCampaignState(latest.slot) : undefined;
}
function advanceBond(bond: CampBondSnapshot, amount: number) {
bond.battleExp += amount;
bond.exp += amount;
while (bond.exp >= 100) {
bond.exp -= 100;
bond.level = Math.min(100, bond.level + 1);
}
}
function createBattleSettlement(report: FirstBattleReport, reserveTraining: CampaignReserveTrainingSnapshot[] = []): CampaignBattleSettlement {
return {
battleId: report.battleId,
battleTitle: report.battleTitle,
outcome: report.outcome,
rewardGold: report.rewardGold,
itemRewards: [...report.itemRewards],
campaignRewards: cloneCampaignRewardSnapshot(report.campaignRewards),
objectives: report.objectives.map((objective) => ({ ...objective })),
units: report.units
.filter((unit) => unit.faction === 'ally')
.map((unit) => ({
unitId: unit.id,
name: unit.name,
level: unit.level,
exp: unit.exp,
hp: unit.hp,
maxHp: unit.maxHp,
equipment: cloneUnit(unit).equipment
})),
bonds: report.bonds.map((bond) => ({
id: bond.id,
title: bond.title,
level: bond.level,
exp: bond.exp,
battleExp: bond.battleExp
})),
reserveTraining: reserveTraining.map((entry) => ({ ...entry })),
completedAt: report.createdAt
};
}
function applyRewardDelta(inventory: Record<string, number>, rewards: string[], direction: 1 | -1) {
return rewards.reduce<Record<string, number>>((nextInventory, reward) => {
const { label, amount } = parseRewardLabel(reward);
const nextAmount = Math.max(0, (nextInventory[label] ?? 0) + amount * direction);
const cap = direction === 1 ? campaignInventoryCaps[label] : undefined;
nextInventory[label] = cap ? Math.min(nextAmount, cap) : nextAmount;
if (nextInventory[label] === 0) {
delete nextInventory[label];
}
return nextInventory;
}, { ...inventory });
}
function mergeRosterProgress(currentRoster: UnitData[], deployedUnits: UnitData[]) {
const merged = new Map<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();
const match = normalized.match(/^(.*?)(?:\s*[+xX]?\s*(\d+))$/);
if (!match) {
return { label: normalized, amount: 1 };
}
return {
label: match[1].trim() || normalized,
amount: Number(match[2] ?? 1)
};
}
function cloneCampaignState(state: CampaignState): CampaignState {
return JSON.parse(JSON.stringify(state)) as CampaignState;
}
function cloneReport(report: FirstBattleReport): FirstBattleReport {
const cloned = JSON.parse(JSON.stringify(report)) as FirstBattleReport;
cloned.completedCampVisits = [...new Set(cloned.completedCampVisits ?? [])];
if (cloned.campaignRewards) {
cloned.campaignRewards = cloneCampaignRewardSnapshot(cloned.campaignRewards);
}
return cloned;
}
function cloneCampaignRewardSnapshot(rewards?: CampaignRewardSnapshot): CampaignRewardSnapshot | undefined {
if (!rewards) {
return undefined;
}
const rewardRecord = recordOrEmpty(rewards);
return {
supplies: uniqueStrings(rewardRecord.supplies),
equipment: uniqueStrings(rewardRecord.equipment),
reputation: uniqueStrings(rewardRecord.reputation),
recruits: arrayOrEmpty<unknown>(rewardRecord.recruits)
.filter(isPlainObject)
.map((recruit) => ({
unitId: typeof recruit.unitId === 'string' ? recruit.unitId : '',
name: typeof recruit.name === 'string' ? recruit.name : ''
}))
.filter((recruit) => recruit.unitId && recruit.name),
unlocks: arrayOrEmpty<unknown>(rewardRecord.unlocks)
.filter(isPlainObject)
.map((unlock) => ({
battleId: typeof unlock.battleId === 'string' ? unlock.battleId : '',
title: typeof unlock.title === 'string' ? unlock.title : ''
}))
.filter((unlock) => unlock.battleId && unlock.title),
note: typeof rewardRecord.note === 'string' ? rewardRecord.note : undefined
};
}
function cloneUnit(unit: UnitData): UnitData {
return JSON.parse(JSON.stringify(unit)) as UnitData;
}
function cloneBondSnapshot(bond: CampBondSnapshot): CampBondSnapshot {
return {
...bond,
unitIds: [...bond.unitIds] as [string, string]
};
}
function createCampBondSnapshot(bond: BattleBond): CampBondSnapshot {
return {
...bond,
unitIds: [...bond.unitIds] as [string, string],
battleExp: 0
};
}