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

1135 lines
40 KiB
TypeScript

import { equipmentExpToNext, equipmentSlots } from '../data/battleItems';
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 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<UnitData>(normalized.roster).map(cloneUnit);
normalized.bonds = arrayOrEmpty<CampBondSnapshot>(normalized.bonds).map(cloneBondSnapshot);
normalized.completedCampDialogues = uniqueStrings(normalized.completedCampDialogues);
normalized.completedCampVisits = uniqueStrings(normalized.completedCampVisits);
normalized.inventory = normalizeInventory(normalized.inventory);
normalized.selectedSortieUnitIds = uniqueStrings(normalized.selectedSortieUnitIds);
normalized.sortieFormationAssignments = normalizeSortieFormationAssignments(recordOrEmpty(normalized.sortieFormationAssignments));
normalized.sortieItemAssignments = normalizeSortieItemAssignments(recordOrEmpty(normalized.sortieItemAssignments));
normalized.reserveTrainingFocus = normalizeReserveTrainingFocusId(normalized.reserveTrainingFocus);
normalized.battleHistory = recordOrEmpty<CampaignBattleSettlement>(normalized.battleHistory);
normalized.firstBattleReport = normalizeFirstBattleReport(normalized.firstBattleReport);
return normalized;
}
function arrayOrEmpty<T>(value: unknown): T[] {
return Array.isArray(value) ? value as T[] : [];
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function recordOrEmpty<T = unknown>(value: unknown): Record<string, T> {
return isPlainObject(value) ? value as Record<string, T> : {};
}
function uniqueStrings(value: unknown): string[] {
return [...new Set(arrayOrEmpty<unknown>(value).filter((entry): entry is string => typeof entry === 'string' && entry.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 normalizeSortieItemAssignments(assignments?: CampaignSortieItemAssignments) {
return Object.entries(assignments ?? {}).reduce<CampaignSortieItemAssignments>((next, [unitId, stocks]) => {
const unitStocks = Object.entries(stocks ?? {}).reduce<Record<string, number>>((nextStocks, [itemId, amount]) => {
const normalizedAmount = Math.floor(Number(amount));
if (itemId && normalizedAmount > 0) {
nextStocks[itemId] = 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;
}
try {
return cloneReport(report as FirstBattleReport);
} catch {
return undefined;
}
}
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;
}
return {
supplies: [...(rewards.supplies ?? [])],
equipment: [...(rewards.equipment ?? [])],
reputation: [...(rewards.reputation ?? [])],
recruits: (rewards.recruits ?? []).map((recruit) => ({ ...recruit })),
unlocks: (rewards.unlocks ?? []).map((unlock) => ({ ...unlock })),
note: rewards.note
};
}
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
};
}