Solidify campaign progression state
This commit is contained in:
@@ -34,40 +34,228 @@ export type FirstBattleReport = {
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
let firstBattleReport: FirstBattleReport | undefined;
|
||||
export type CampaignStep = 'new' | 'prologue' | 'first-battle' | 'first-camp' | 'first-victory-story';
|
||||
|
||||
export type CampaignState = {
|
||||
version: 1;
|
||||
updatedAt: string;
|
||||
step: CampaignStep;
|
||||
gold: number;
|
||||
roster: UnitData[];
|
||||
bonds: CampBondSnapshot[];
|
||||
inventory: Record<string, number>;
|
||||
completedCampDialogues: string[];
|
||||
firstBattleReport?: FirstBattleReport;
|
||||
};
|
||||
|
||||
export const campaignStorageKey = 'heros-web:campaign-state';
|
||||
|
||||
let campaignState: CampaignState | undefined;
|
||||
|
||||
export function createInitialCampaignState(): CampaignState {
|
||||
return {
|
||||
version: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
step: 'new',
|
||||
gold: 0,
|
||||
roster: [],
|
||||
bonds: [],
|
||||
inventory: {},
|
||||
completedCampDialogues: []
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
persistCampaignState(campaignState);
|
||||
return cloneCampaignState(campaignState);
|
||||
}
|
||||
|
||||
export function loadCampaignState() {
|
||||
campaignState = readStoredCampaignState() ?? createInitialCampaignState();
|
||||
return cloneCampaignState(campaignState);
|
||||
}
|
||||
|
||||
export function saveCampaignState(state = ensureCampaignState()) {
|
||||
campaignState = normalizeCampaignState(state);
|
||||
persistCampaignState(campaignState);
|
||||
return cloneCampaignState(campaignState);
|
||||
}
|
||||
|
||||
export function resetCampaignState() {
|
||||
campaignState = createInitialCampaignState();
|
||||
tryStorage()?.removeItem(campaignStorageKey);
|
||||
return cloneCampaignState(campaignState);
|
||||
}
|
||||
|
||||
export function hasCampaignSave() {
|
||||
return Boolean(tryStorage()?.getItem(campaignStorageKey));
|
||||
}
|
||||
|
||||
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) {
|
||||
firstBattleReport = cloneReport(report);
|
||||
const state = ensureCampaignState();
|
||||
const previousRewardGold = state.firstBattleReport?.rewardGold ?? 0;
|
||||
const reportClone = cloneReport(report);
|
||||
|
||||
state.firstBattleReport = reportClone;
|
||||
state.step = reportClone.outcome === 'victory' ? 'first-camp' : 'first-battle';
|
||||
state.gold = Math.max(0, state.gold - previousRewardGold + 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.updatedAt = new Date().toISOString();
|
||||
|
||||
persistCampaignState(state);
|
||||
}
|
||||
|
||||
export function getFirstBattleReport() {
|
||||
return firstBattleReport ? cloneReport(firstBattleReport) : undefined;
|
||||
const report = ensureCampaignState().firstBattleReport;
|
||||
return report ? cloneReport(report) : undefined;
|
||||
}
|
||||
|
||||
export function applyCampBondExp(dialogueId: string, bondId: string, amount: number) {
|
||||
if (!firstBattleReport) {
|
||||
const state = ensureCampaignState();
|
||||
if (!state.firstBattleReport) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const bond = firstBattleReport.bonds.find((candidate) => candidate.id === bondId);
|
||||
if (!bond) {
|
||||
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;
|
||||
}
|
||||
|
||||
if (!firstBattleReport.completedCampDialogues.includes(dialogueId)) {
|
||||
firstBattleReport.completedCampDialogues.push(dialogueId);
|
||||
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 = 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 };
|
||||
if (normalized.firstBattleReport) {
|
||||
normalized.firstBattleReport = cloneReport(normalized.firstBattleReport);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function readStoredCampaignState() {
|
||||
const raw = tryStorage()?.getItem(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) {
|
||||
tryStorage()?.setItem(campaignStorageKey, JSON.stringify(state));
|
||||
}
|
||||
|
||||
function tryStorage() {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
return window.localStorage;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return cloneReport(firstBattleReport);
|
||||
function inventoryFromRewards(rewards: string[]) {
|
||||
return rewards.reduce<Record<string, number>>((inventory, reward) => {
|
||||
const { label, amount } = parseRewardLabel(reward);
|
||||
inventory[label] = (inventory[label] ?? 0) + amount;
|
||||
return 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]
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user