659 lines
21 KiB
TypeScript
659 lines
21 KiB
TypeScript
import { equipmentExpToNext, equipmentSlots } from '../data/battleItems';
|
|
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[];
|
|
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';
|
|
|
|
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;
|
|
level: number;
|
|
exp: number;
|
|
};
|
|
|
|
export type CampaignBattleSettlement = {
|
|
battleId: string;
|
|
battleTitle: string;
|
|
outcome: FirstBattleReport['outcome'];
|
|
rewardGold: number;
|
|
itemRewards: string[];
|
|
objectives: BattleObjectiveSnapshot[];
|
|
units: CampaignUnitProgressSnapshot[];
|
|
bonds: CampaignBondProgressSnapshot[];
|
|
reserveTraining?: CampaignReserveTrainingSnapshot[];
|
|
completedAt: string;
|
|
};
|
|
|
|
export type CampaignState = {
|
|
version: 1;
|
|
updatedAt: string;
|
|
step: CampaignStep;
|
|
activeSaveSlot: number;
|
|
gold: number;
|
|
roster: UnitData[];
|
|
bonds: CampBondSnapshot[];
|
|
inventory: Record<string, number>;
|
|
selectedSortieUnitIds: string[];
|
|
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' }
|
|
};
|
|
|
|
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: {},
|
|
selectedSortieUnitIds: [],
|
|
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 };
|
|
}
|
|
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];
|
|
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;
|
|
state.step = reportClone.outcome === 'victory' ? victoryStep : retryStep;
|
|
state.gold = Math.max(0, state.gold - (previousSettlement?.rewardGold ?? 0) + reportClone.rewardGold);
|
|
state.roster = mergeRosterProgress(state.roster, reportClone.units.filter((unit) => unit.faction === 'ally'));
|
|
const reserveTraining = previousSettlement || reportClone.outcome !== 'victory'
|
|
? previousSettlement?.reserveTraining ?? []
|
|
: applyReserveTrainingProgress(state.roster, reportClone.units.filter((unit) => unit.faction === 'ally'));
|
|
state.bonds = reportClone.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: 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.completedCampVisits = [...new Set(normalized.completedCampVisits ?? [])];
|
|
normalized.inventory = { ...(normalized.inventory ?? {}) };
|
|
normalized.selectedSortieUnitIds = [...new Set(normalized.selectedSortieUnitIds ?? [])];
|
|
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, reserveTraining: CampaignReserveTrainingSnapshot[] = []): 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
|
|
})),
|
|
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);
|
|
nextInventory[label] = Math.max(0, (nextInventory[label] ?? 0) + amount * direction);
|
|
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[], deployedUnits: UnitData[]): CampaignReserveTrainingSnapshot[] {
|
|
const deployedIds = new Set(deployedUnits.map((unit) => unit.id));
|
|
return roster
|
|
.filter((unit) => unit.faction === 'ally' && !deployedIds.has(unit.id))
|
|
.map((unit) => {
|
|
const expGained = 6;
|
|
const equipmentExpGained = 2;
|
|
advanceUnitExp(unit, expGained);
|
|
advanceReserveEquipment(unit, equipmentExpGained);
|
|
return {
|
|
unitId: unit.id,
|
|
name: unit.name,
|
|
expGained,
|
|
equipmentExpGained,
|
|
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 ?? [])];
|
|
return cloned;
|
|
}
|
|
|
|
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
|
|
};
|
|
}
|