Prepare battle scenario progression foundations

This commit is contained in:
2026-06-22 17:39:09 +09:00
parent 635b3a7a81
commit 49822831f5
5 changed files with 418 additions and 147 deletions

View File

@@ -20,6 +20,8 @@ export type CampMvpSnapshot = {
};
export type FirstBattleReport = {
battleId: string;
battleTitle: string;
outcome: 'victory' | 'defeat';
turnNumber: number;
rewardGold: number;
@@ -36,19 +38,62 @@ export type FirstBattleReport = {
export type CampaignStep = 'new' | 'prologue' | 'first-battle' | 'first-camp' | 'first-victory-story';
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;
export type CampaignSaveSlotSummary = {
slot: number;
exists: boolean;
updatedAt?: string;
step?: CampaignStep;
gold?: number;
battleTitle?: string;
};
let campaignState: CampaignState | undefined;
@@ -57,11 +102,13 @@ export function createInitialCampaignState(): CampaignState {
version: 1,
updatedAt: new Date().toISOString(),
step: 'new',
activeSaveSlot: 1,
gold: 0,
roster: [],
bonds: [],
inventory: {},
completedCampDialogues: []
completedCampDialogues: [],
battleHistory: {}
};
}
@@ -77,17 +124,20 @@ export function getCampaignState() {
export function setCampaignState(state: CampaignState) {
campaignState = normalizeCampaignState(state);
campaignState.updatedAt = new Date().toISOString();
persistCampaignState(campaignState);
return cloneCampaignState(campaignState);
}
export function loadCampaignState() {
campaignState = readStoredCampaignState() ?? createInitialCampaignState();
export function loadCampaignState(slot?: number) {
campaignState = readStoredCampaignState(slot) ?? readStoredCampaignState() ?? readLatestSlottedCampaignState() ?? createInitialCampaignState();
return cloneCampaignState(campaignState);
}
export function saveCampaignState(state = ensureCampaignState()) {
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);
}
@@ -95,11 +145,32 @@ export function saveCampaignState(state = ensureCampaignState()) {
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));
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) {
@@ -112,16 +183,20 @@ export function markCampaignStep(step: CampaignStep) {
export function setFirstBattleReport(report: FirstBattleReport) {
const state = ensureCampaignState();
const previousRewardGold = state.firstBattleReport?.rewardGold ?? 0;
const reportClone = cloneReport(report);
const battleId = reportClone.battleId;
const previousSettlement = state.battleHistory[battleId];
state.firstBattleReport = reportClone;
state.step = reportClone.outcome === 'victory' ? 'first-camp' : 'first-battle';
state.gold = Math.max(0, state.gold - previousRewardGold + reportClone.rewardGold);
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 = [...reportClone.completedCampDialogues];
state.inventory = inventoryFromRewards(reportClone.itemRewards);
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);
@@ -171,19 +246,22 @@ function ensureCampaignState() {
function normalizeCampaignState(state: CampaignState): CampaignState {
const normalized = cloneCampaignState(state);
normalized.version = 1;
normalized.updatedAt = new Date().toISOString();
normalized.roster = normalized.roster.map(cloneUnit);
normalized.bonds = normalized.bonds.map(cloneBondSnapshot);
normalized.completedCampDialogues = [...new Set(normalized.completedCampDialogues)];
normalized.inventory = { ...normalized.inventory };
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() {
const raw = tryStorage()?.getItem(campaignStorageKey);
function readStoredCampaignState(slot?: number) {
const raw = tryStorage()?.getItem(slot ? campaignSaveSlotKey(slot) : campaignStorageKey);
if (!raw) {
return undefined;
}
@@ -200,7 +278,9 @@ function readStoredCampaignState() {
}
function persistCampaignState(state: CampaignState) {
tryStorage()?.setItem(campaignStorageKey, JSON.stringify(state));
const storage = tryStorage();
storage?.setItem(campaignStorageKey, JSON.stringify(state));
storage?.setItem(campaignSaveSlotKey(state.activeSaveSlot), JSON.stringify(state));
}
function tryStorage() {
@@ -210,6 +290,24 @@ function tryStorage() {
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;
@@ -219,12 +317,45 @@ function advanceBond(bond: CampBondSnapshot, amount: number) {
}
}
function inventoryFromRewards(rewards: string[]) {
return rewards.reduce<Record<string, number>>((inventory, reward) => {
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);
inventory[label] = (inventory[label] ?? 0) + amount;
return inventory;
}, {});
nextInventory[label] = Math.max(0, (nextInventory[label] ?? 0) + amount * direction);
if (nextInventory[label] === 0) {
delete nextInventory[label];
}
return nextInventory;
}, { ...inventory });
}
function parseRewardLabel(reward: string) {