import type { BattleBond, UnitData } from '../data/scenario'; export type BattleObjectiveSnapshot = { id: string; label: string; achieved: boolean; detail: string; rewardGold: number; }; export type CampBondSnapshot = BattleBond & { battleExp: number; }; export type CampMvpSnapshot = { unitId: string; name: string; damageDealt: number; defeats: number; }; 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[]; completedCampDialogues: 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'; 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 CampaignBattleSettlement = { battleId: string; battleTitle: string; outcome: FirstBattleReport['outcome']; rewardGold: number; itemRewards: string[]; objectives: BattleObjectiveSnapshot[]; units: CampaignUnitProgressSnapshot[]; bonds: CampaignBondProgressSnapshot[]; completedAt: string; }; export type CampaignState = { version: 1; updatedAt: string; step: CampaignStep; activeSaveSlot: number; gold: number; roster: UnitData[]; bonds: CampBondSnapshot[]; inventory: Record; completedCampDialogues: string[]; battleHistory: Record; latestBattleId?: string; firstBattleReport?: FirstBattleReport; }; export const campaignStorageKey = 'heros-web:campaign-state'; export const campaignSaveSlotCount = 3; const campaignBattleSteps: Record = { 'first-battle-zhuo-commandery': { victory: 'first-camp', retry: 'first-battle' }, 'second-battle-yellow-turban-pursuit': { victory: 'second-camp', retry: 'second-battle' }, 'third-battle-guangzong-road': { victory: 'third-camp', retry: 'third-battle' }, 'fourth-battle-guangzong-camp': { victory: 'fourth-camp', retry: 'fourth-battle' }, 'fifth-battle-sishui-vanguard': { victory: 'fifth-camp', retry: 'fifth-battle' } }; export type CampaignSaveSlotSummary = { slot: number; exists: boolean; updatedAt?: string; step?: CampaignStep; gold?: number; battleTitle?: string; }; let campaignState: CampaignState | undefined; export function createInitialCampaignState(): CampaignState { return { version: 1, updatedAt: new Date().toISOString(), step: 'new', activeSaveSlot: 1, gold: 0, roster: [], bonds: [], inventory: {}, completedCampDialogues: [], 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 }; } return { slot, exists: true, updatedAt: state.updatedAt, step: state.step, gold: state.gold, battleTitle: state.firstBattleReport?.battleTitle }; }); } export function markCampaignStep(step: CampaignStep) { const state = ensureCampaignState(); state.step = step; 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]; reportClone.completedCampDialogues = completedCampDialogues; state.firstBattleReport = reportClone; const stepRule = campaignBattleSteps[battleId] ?? campaignBattleSteps['first-battle-zhuo-commandery']; const victoryStep: CampaignStep = stepRule.victory; const retryStep: CampaignStep = stepRule.retry; state.step = reportClone.outcome === 'victory' ? victoryStep : retryStep; state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold); state.roster = reportClone.units.filter((unit) => unit.faction === 'ally').map(cloneUnit); state.bonds = reportClone.bonds.map(cloneBondSnapshot); state.completedCampDialogues = completedCampDialogues; state.inventory = applyRewardDelta(state.inventory, previousSettlement?.itemRewards ?? [], -1); state.inventory = applyRewardDelta(state.inventory, reportClone.itemRewards, 1); state.battleHistory[battleId] = createBattleSettlement(reportClone); 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); } function ensureCampaignState() { if (!campaignState) { campaignState = readStoredCampaignState() ?? createInitialCampaignState(); } return campaignState; } function normalizeCampaignState(state: CampaignState): CampaignState { const normalized = cloneCampaignState(state); normalized.version = 1; normalized.updatedAt = normalized.updatedAt || new Date().toISOString(); normalized.activeSaveSlot = normalizeSlot(normalized.activeSaveSlot); normalized.gold = normalized.gold ?? 0; normalized.roster = (normalized.roster ?? []).map(cloneUnit); normalized.bonds = (normalized.bonds ?? []).map(cloneBondSnapshot); normalized.completedCampDialogues = [...new Set(normalized.completedCampDialogues ?? [])]; normalized.inventory = { ...(normalized.inventory ?? {}) }; normalized.battleHistory = normalized.battleHistory ?? {}; if (normalized.firstBattleReport) { normalized.firstBattleReport = cloneReport(normalized.firstBattleReport); } return normalized; } function readStoredCampaignState(slot?: number) { const raw = tryStorage()?.getItem(slot ? campaignSaveSlotKey(slot) : campaignStorageKey); if (!raw) { return undefined; } try { const parsed = JSON.parse(raw) as CampaignState; if (!parsed || 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): CampaignBattleSettlement { return { battleId: report.battleId, battleTitle: report.battleTitle, outcome: report.outcome, rewardGold: report.rewardGold, itemRewards: [...report.itemRewards], 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 })), completedAt: report.createdAt }; } function applyRewardDelta(inventory: Record, rewards: string[], direction: 1 | -1) { return rewards.reduce>((nextInventory, reward) => { const { label, amount } = parseRewardLabel(reward); nextInventory[label] = Math.max(0, (nextInventory[label] ?? 0) + amount * direction); if (nextInventory[label] === 0) { delete nextInventory[label]; } return nextInventory; }, { ...inventory }); } 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 { return JSON.parse(JSON.stringify(report)) as FirstBattleReport; } 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] }; }