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

420 lines
12 KiB
TypeScript

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<string, number>;
completedCampDialogues: 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' }
};
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<string, number>, rewards: string[], direction: 1 | -1) {
return rewards.reduce<Record<string, number>>((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]
};
}