refactor: complete error handling standardization (issue #101)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m26s

Prestige Store:
- Convert doPrestige() to return Result<void> with specific error codes
  (INVALID_PRESTIGE_ID, PRESTIGE_MAX_LEVEL, INSUFFICIENT_INSIGHT)
- Convert startPactRitual() to return Result<void> with specific error codes
  (GUARDIAN_NOT_DEFEATED, PACT_ALREADY_SIGNED, PACT_SLOTS_FULL,
   INSUFFICIENT_MANA, RITUAL_IN_PROGRESS)

Combat Actions:
- Add try/catch wrapper inside processCombatTick with safe fallback defaults
- Add makeDefaultCombatTickResult helper for error recovery

LocalStorage Error Handling:
- Create safe-persist.ts utility wrapping localStorage with error handling
  (corrupted JSON, quota exceeded, unexpected failures)
- Update all 8 Zustand stores to use createSafeStorage() in persist middleware

UI Updates:
- Update GuardianPactsTab to use Result pattern for ritual error messages

Tests:
- Update store-actions-combat-prestige.test.ts for Result return types
- Update store-actions.test.ts ManaStore tests for Result pattern
- Remove duplicate Prestige/Discipline sections from store-actions.test.ts
- All files under 400 line limit

601 tests pass (3 pre-existing failures in spire-utils.test.ts)
This commit is contained in:
2026-05-22 09:19:20 +02:00
parent 8a7ddaae27
commit 49f8de01ca
21 changed files with 542 additions and 547 deletions
+2
View File
@@ -3,6 +3,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createSafeStorage } from '../utils/safe-persist';
import type { AttunementState } from '../types';
import { ATTUNEMENTS_DEF, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL } from '../data/attunements';
@@ -84,6 +85,7 @@ export const useAttunementStore = create<AttunementStoreState>()(
},
}),
{
storage: createSafeStorage(),
name: 'mana-loop-attunements',
partialize: (state) => ({
attunements: state.attunements,
+148 -140
View File
@@ -7,6 +7,29 @@ import type { CombatStore, CombatState } from './combat-state.types';
import type { SpellState } from '../types';
import { getFloorMaxHP, getFloorElement, calcDamage, canAffordSpellCost, deductSpellCost } from '../utils';
import { computeDisciplineEffects } from '../effects/discipline-effects';
import { ErrorCode } from '../utils/result';
/**
* Create a default CombatTickResult for safe fallback on error.
*/
function makeDefaultCombatTickResult(
rawMana: number,
elements: Record<string, { current: number; max: number; unlocked: boolean }>,
state: CombatState,
): CombatTickResult {
return {
rawMana,
elements,
logMessages: [],
totalManaGathered: 0,
currentFloor: state.currentFloor,
floorHP: state.floorHP,
floorMaxHP: state.floorMaxHP,
maxFloorReached: state.maxFloorReached,
castProgress: state.castProgress,
equipmentSpellStates: state.equipmentSpellStates,
};
}
export interface CombatTickResult {
rawMana: number;
@@ -41,158 +64,143 @@ export function processCombatTick(
let totalManaGathered = 0;
if (state.currentAction !== 'climb') {
return {
rawMana,
elements,
logMessages,
totalManaGathered,
currentFloor: state.currentFloor,
floorHP: state.floorHP,
floorMaxHP: state.floorMaxHP,
maxFloorReached: state.maxFloorReached,
castProgress: state.castProgress,
equipmentSpellStates: state.equipmentSpellStates,
};
return makeDefaultCombatTickResult(rawMana, elements, state);
}
const spellId = state.activeSpell;
const spellDef = SPELLS_DEF[spellId];
if (!spellDef) {
return makeDefaultCombatTickResult(rawMana, elements, state);
}
try {
// Compute discipline bonuses once per tick
const disciplineEffects = computeDisciplineEffects();
// Calculate cast speed (no skill bonus)
const totalAttackSpeed = attackSpeedMult;
const spellCastSpeed = spellDef.castSpeed || 1;
const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed;
let castProgress = (state.castProgress || 0) + progressPerTick;
let floorHP = state.floorHP;
let currentFloor = state.currentFloor;
let floorMaxHP = state.floorMaxHP;
// Process complete casts for active spell
while (castProgress >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements)) {
// Deduct spell cost
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
rawMana = afterCost.rawMana;
elements = afterCost.elements;
// Calculate base damage
const floorElement = getFloorElement(currentFloor);
const damage = calcDamage(
{ skills: {}, signedPacts },
spellId,
floorElement,
disciplineEffects,
);
// Let gameStore apply damage modifiers (executioner, berserker)
const result = onDamageDealt(damage);
rawMana = result.rawMana;
elements = result.elements;
const finalDamage = result.modifiedDamage || damage;
// Apply damage
floorHP = Math.max(0, floorHP - finalDamage);
castProgress -= 1;
// Check if floor is cleared
if (floorHP <= 0) {
const wasGuardian = GUARDIANS[currentFloor];
onFloorCleared(currentFloor, !!wasGuardian);
currentFloor = Math.min(currentFloor + 1, 100);
floorMaxHP = getFloorMaxHP(currentFloor);
floorHP = floorMaxHP;
castProgress = 0;
if (wasGuardian) {
logMessages.push(`⚔️ ${wasGuardian.name} defeated!`);
} else if (currentFloor % 5 === 0) {
logMessages.push(`🏰 Floor ${currentFloor - 1} cleared!`);
}
}
}
// Process equipment spell states (for progress bars in UI)
const updatedEquipmentSpellStates = [...state.equipmentSpellStates];
for (let i = 0; i < updatedEquipmentSpellStates.length; i++) {
const eSpell = updatedEquipmentSpellStates[i];
const eSpellDef = SPELLS_DEF[eSpell.spellId];
if (!eSpellDef) continue;
// Calculate progress for this equipment spell
const eSpellCastSpeed = eSpellDef.castSpeed || 1;
const eProgressPerTick = HOURS_PER_TICK * eSpellCastSpeed * totalAttackSpeed;
let eCastProgress = (eSpell.castProgress || 0) + eProgressPerTick;
// Process complete casts for equipment spells
while (eCastProgress >= 1 && canAffordSpellCost(eSpellDef.cost, rawMana, elements)) {
// Deduct cost
const eAfterCost = deductSpellCost(eSpellDef.cost, rawMana, elements);
rawMana = eAfterCost.rawMana;
elements = eAfterCost.elements;
// Calculate damage
const eFloorElement = getFloorElement(currentFloor);
const eDamage = calcDamage(
{ skills: {}, signedPacts },
eSpell.spellId,
eFloorElement,
disciplineEffects,
);
const eResult = onDamageDealt(eDamage);
rawMana = eResult.rawMana;
elements = eResult.elements;
const eFinalDamage = eResult.modifiedDamage || eDamage;
floorHP = Math.max(0, floorHP - eFinalDamage);
eCastProgress -= 1;
if (floorHP <= 0) break; // Floor cleared, stop processing
}
// Update equipment spell state
updatedEquipmentSpellStates[i] = { ...eSpell, castProgress: eCastProgress % 1 };
}
const newMaxFloorReached = Math.max(state.maxFloorReached, currentFloor);
set({
currentFloor,
floorHP,
floorMaxHP: getFloorMaxHP(currentFloor),
maxFloorReached: newMaxFloorReached,
castProgress,
equipmentSpellStates: updatedEquipmentSpellStates,
});
return {
rawMana,
elements,
logMessages,
totalManaGathered,
currentFloor: state.currentFloor,
floorHP: state.floorHP,
floorMaxHP: state.floorMaxHP,
maxFloorReached: state.maxFloorReached,
castProgress: state.castProgress,
equipmentSpellStates: state.equipmentSpellStates,
currentFloor,
floorHP,
floorMaxHP: getFloorMaxHP(currentFloor),
maxFloorReached: newMaxFloorReached,
castProgress,
equipmentSpellStates: updatedEquipmentSpellStates,
};
} catch (error) {
// Return safe defaults on error — combat tick should never crash the game
const errorMsg = error instanceof Error ? error.message : String(error);
logMessages.push(`⚠️ Combat error: ${errorMsg}`);
return makeDefaultCombatTickResult(rawMana, elements, state);
}
// Compute discipline bonuses once per tick
const disciplineEffects = computeDisciplineEffects();
// Calculate cast speed (no skill bonus)
const totalAttackSpeed = attackSpeedMult;
const spellCastSpeed = spellDef.castSpeed || 1;
const progressPerTick = HOURS_PER_TICK * spellCastSpeed * totalAttackSpeed;
let castProgress = (state.castProgress || 0) + progressPerTick;
let floorHP = state.floorHP;
let currentFloor = state.currentFloor;
let floorMaxHP = state.floorMaxHP;
// Process complete casts for active spell
while (castProgress >= 1 && canAffordSpellCost(spellDef.cost, rawMana, elements)) {
// Deduct spell cost
const afterCost = deductSpellCost(spellDef.cost, rawMana, elements);
rawMana = afterCost.rawMana;
elements = afterCost.elements;
// Calculate base damage
const floorElement = getFloorElement(currentFloor);
const damage = calcDamage(
{ skills: {}, signedPacts },
spellId,
floorElement,
disciplineEffects,
);
// Let gameStore apply damage modifiers (executioner, berserker)
const result = onDamageDealt(damage);
rawMana = result.rawMana;
elements = result.elements;
const finalDamage = result.modifiedDamage || damage;
// Apply damage
floorHP = Math.max(0, floorHP - finalDamage);
castProgress -= 1;
// Check if floor is cleared
if (floorHP <= 0) {
const wasGuardian = GUARDIANS[currentFloor];
onFloorCleared(currentFloor, !!wasGuardian);
currentFloor = Math.min(currentFloor + 1, 100);
floorMaxHP = getFloorMaxHP(currentFloor);
floorHP = floorMaxHP;
castProgress = 0;
if (wasGuardian) {
logMessages.push(`⚔️ ${wasGuardian.name} defeated!`);
} else if (currentFloor % 5 === 0) {
logMessages.push(`🏰 Floor ${currentFloor - 1} cleared!`);
}
}
}
// Process equipment spell states (for progress bars in UI)
const updatedEquipmentSpellStates = [...state.equipmentSpellStates];
for (let i = 0; i < updatedEquipmentSpellStates.length; i++) {
const eSpell = updatedEquipmentSpellStates[i];
const eSpellDef = SPELLS_DEF[eSpell.spellId];
if (!eSpellDef) continue;
// Calculate progress for this equipment spell
const eSpellCastSpeed = eSpellDef.castSpeed || 1;
const eProgressPerTick = HOURS_PER_TICK * eSpellCastSpeed * totalAttackSpeed;
let eCastProgress = (eSpell.castProgress || 0) + eProgressPerTick;
// Process complete casts for equipment spells
while (eCastProgress >= 1 && canAffordSpellCost(eSpellDef.cost, rawMana, elements)) {
// Deduct cost
const eAfterCost = deductSpellCost(eSpellDef.cost, rawMana, elements);
rawMana = eAfterCost.rawMana;
elements = eAfterCost.elements;
// Calculate damage
const eFloorElement = getFloorElement(currentFloor);
const eDamage = calcDamage(
{ skills: {}, signedPacts },
eSpell.spellId,
eFloorElement,
disciplineEffects,
);
const eResult = onDamageDealt(eDamage);
rawMana = eResult.rawMana;
elements = eResult.elements;
const eFinalDamage = eResult.modifiedDamage || eDamage;
floorHP = Math.max(0, floorHP - eFinalDamage);
eCastProgress -= 1;
if (floorHP <= 0) break; // Floor cleared, stop processing
}
// Update equipment spell state
updatedEquipmentSpellStates[i] = { ...eSpell, castProgress: eCastProgress % 1 };
}
const newMaxFloorReached = Math.max(state.maxFloorReached, currentFloor);
set({
currentFloor,
floorHP,
floorMaxHP: getFloorMaxHP(currentFloor),
maxFloorReached: newMaxFloorReached,
castProgress,
equipmentSpellStates: updatedEquipmentSpellStates,
});
return {
rawMana,
elements,
logMessages,
totalManaGathered,
currentFloor,
floorHP,
floorMaxHP: getFloorMaxHP(currentFloor),
maxFloorReached: newMaxFloorReached,
castProgress,
equipmentSpellStates: updatedEquipmentSpellStates,
};
}
// Helper function to create initial spells
+2
View File
@@ -3,6 +3,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createSafeStorage } from '../utils/safe-persist';
import type { GameAction, SpellState, FloorState, GolemancyState, ActivityLogEntry, AchievementState, EquipmentSpellState, ActivityEventType } from '../types';
import { getFloorMaxHP } from '../utils';
import { usePrestigeStore } from './prestigeStore';
@@ -271,6 +272,7 @@ export const useCombatStore = create<CombatStore>()(
}),
{
storage: createSafeStorage(),
name: 'mana-loop-combat',
partialize: (state) => ({
currentFloor: state.currentFloor,
+17
View File
@@ -13,6 +13,9 @@ import { useUIStore } from './uiStore';
import * as ApplicationActions from '../crafting-actions/application-actions';
import * as PreparationActions from '../crafting-actions/preparation-actions';
import * as CraftingEquipment from '../crafting-equipment';
import { ErrorCode } from '../utils/result';
import { createSafeStorage } from '../utils/safe-persist';
import type { Result } from '../utils/result';
export const useCraftingStore = create<CraftingStore>()(
persist(
@@ -217,6 +220,19 @@ export const useCraftingStore = create<CraftingStore>()(
);
if (result) {
useCombatStore.setState({ currentAction: 'prepare' });
set({ lastError: null });
} else {
const state = get();
const instance = state.equipmentInstances[equipmentInstanceId];
let message = 'Cannot start preparation';
if (!instance) {
message = `Equipment instance not found: ${equipmentInstanceId}`;
} else if (instance.tags?.includes('Ready for Enchantment')) {
message = 'Equipment is already prepared';
} else {
message = 'Insufficient mana for preparation';
}
set({ lastError: { code: ErrorCode.INVALID_INPUT, message, timestamp: Date.now() } });
}
return result;
},
@@ -363,6 +379,7 @@ export const useCraftingStore = create<CraftingStore>()(
};
},
{
storage: createSafeStorage(),
name: 'mana-loop-crafting',
partialize: (state) => ({
designProgress: state.designProgress,
@@ -9,6 +9,12 @@ import type {
DesignEffect,
} from '../types';
export interface CraftingError {
code: string;
message: string;
timestamp: number;
}
export interface CraftingState {
designProgress: DesignProgress | null;
designProgress2: DesignProgress | null;
@@ -30,6 +36,7 @@ export interface CraftingState {
selectedDesign: string | null;
selectedEquipmentInstance: string | null;
};
lastError: CraftingError | null;
}
export interface CraftingActions {
@@ -58,6 +65,7 @@ export interface CraftingActions {
setSelectedDesign: (id: string | null) => void;
setSelectedEquipmentInstance: (id: string | null) => void;
resetEnchantmentSelection: () => void;
clearLastError: () => void;
}
export type CraftingStore = CraftingState & CraftingActions;
+2 -1
View File
@@ -1,6 +1,7 @@
// ─── Discipline Store Slice ────────────────────────────────────────────────────
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createSafeStorage } from '../utils/safe-persist';
import type { DisciplineState } from '../types/disciplines';
import {
calculateManaDrain,
@@ -126,6 +127,6 @@ export const useDisciplineStore = create<DisciplineStore>()(
return { rawMana, elements };
},
}),
{ name: 'mana-loop-discipline-store' }
{ storage: createSafeStorage(), name: 'mana-loop-discipline-store' }
)
);
+23 -12
View File
@@ -31,6 +31,7 @@ import { useCraftingStore } from './craftingStore';
import { useDisciplineStore } from './discipline-slice';
import { ATTUNEMENTS_DEF, getAttunementConversionRate } from '../data/attunements';
import { createResetGame, createGatherMana } from './gameActions';
import { createSafeStorage } from '../utils/safe-persist';
import { createStartNewLoop } from './gameLoopActions';
import { buildTickContext, applyTickWrites } from './tick-pipeline';
import type { TickContext, TickWrites } from './tick-pipeline';
@@ -70,19 +71,20 @@ export const useGameStore = create<GameCoordinatorStore>()(
},
tick: () => {
// ── Phase 1: Read — snapshot all store states once ──────────────────
const ctx = buildTickContext({
game: get(),
ui: useUIStore.getState(),
prestige: usePrestigeStore.getState(),
mana: useManaStore.getState(),
combat: useCombatStore.getState(),
crafting: useCraftingStore.getState(),
attunement: useAttunementStore.getState(),
discipline: useDisciplineStore.getState(),
});
try {
// ── Phase 1: Read — snapshot all store states once ──────────────────
const ctx = buildTickContext({
game: get(),
ui: useUIStore.getState(),
prestige: usePrestigeStore.getState(),
mana: useManaStore.getState(),
combat: useCombatStore.getState(),
crafting: useCraftingStore.getState(),
attunement: useAttunementStore.getState(),
discipline: useDisciplineStore.getState(),
});
if (ctx.ui.gameOver || ctx.ui.paused) return;
if (ctx.ui.gameOver || ctx.ui.paused) return;
// ── Phase 2: Compute — derive all updates ───────────────────────────
const writes: TickWrites = { logs: [] };
@@ -322,6 +324,14 @@ export const useGameStore = create<GameCoordinatorStore>()(
setDiscipline: (w) => useDisciplineStore.setState(w),
addLogs: (msgs) => msgs.forEach((m) => useUIStore.getState().addLog(m)),
});
} catch (error) {
// Log error to UI store if available, otherwise console error
try {
useUIStore.getState().addLog(`⚠️ Tick error: ${error.message}`);
} catch {
console.error('Tick error:', error);
}
}
},
resetGame: createResetGame(set, initialState),
@@ -332,6 +342,7 @@ export const useGameStore = create<GameCoordinatorStore>()(
gatherMana: createGatherMana(),
}),
{
storage: createSafeStorage(),
name: 'mana-loop-game-storage',
partialize: (state) => ({
day: state.day,
+39 -16
View File
@@ -5,6 +5,9 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { ELEMENTS, MANA_PER_ELEMENT, BASE_UNLOCKED_ELEMENTS } from '../constants';
import type { ElementState } from '../types';
import { ok, okVoid, fail, ErrorCode } from '../utils/result';
import { createSafeStorage } from '../utils/safe-persist';
import type { Result } from '../utils/result';
// ─── Mana State (data only) ─────────────────────────────────────────────────
@@ -29,12 +32,12 @@ export interface ManaActions {
resetMeditateTicks: () => void;
// Elements
convertMana: (element: string, amount: number) => boolean;
unlockElement: (element: string, cost: number) => boolean;
convertMana: (element: string, amount: number) => Result<{ converted: number }>;
unlockElement: (element: string, cost: number) => Result<void>;
addElementMana: (element: string, amount: number, max: number) => void;
spendElementMana: (element: string, amount: number) => boolean;
spendElementMana: (element: string, amount: number) => Result<void>;
setElementMax: (max: number) => void;
craftComposite: (target: string, recipe: string[]) => boolean;
craftComposite: (target: string, recipe: string[]) => Result<void>;
// Helper for gameStore coordination
processConvertAction: (rawMana: number) => { rawMana: number; elements: Record<string, ElementState> } | null;
@@ -110,11 +113,17 @@ export const useManaStore = create<ManaStore>()(
convertMana: (element: string, amount: number) => {
const state = get();
const elem = state.elements[element];
if (!elem?.unlocked) return false;
if (!elem?.unlocked) {
return fail(ErrorCode.ELEMENT_NOT_UNLOCKED, `Element ${element} is not unlocked`);
}
const cost = MANA_PER_ELEMENT * amount;
if (state.rawMana < cost) return false;
if (elem.current >= elem.max) return false;
if (state.rawMana < cost) {
return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${cost} raw mana, have ${state.rawMana}`);
}
if (elem.current >= elem.max) {
return fail(ErrorCode.ELEMENT_MAX_CAPACITY, `Element ${element} is at max capacity`);
}
const canConvert = Math.min(
amount,
@@ -122,7 +131,9 @@ export const useManaStore = create<ManaStore>()(
elem.max - elem.current
);
if (canConvert <= 0) return false;
if (canConvert <= 0) {
return fail(ErrorCode.INVALID_INPUT, 'Cannot convert 0 or negative amount');
}
set({
rawMana: state.rawMana - canConvert * MANA_PER_ELEMENT,
@@ -132,13 +143,17 @@ export const useManaStore = create<ManaStore>()(
},
});
return true;
return ok({ converted: canConvert });
},
unlockElement: (element: string, cost: number) => {
const state = get();
if (state.elements[element]?.unlocked) return false;
if (state.rawMana < cost) return false;
if (state.elements[element]?.unlocked) {
return fail(ErrorCode.INVALID_INPUT, `Element ${element} is already unlocked`);
}
if (state.rawMana < cost) {
return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${cost} raw mana, have ${state.rawMana}`);
}
set({
rawMana: state.rawMana - cost,
@@ -148,7 +163,7 @@ export const useManaStore = create<ManaStore>()(
},
});
return true;
return okVoid();
},
addElementMana: (element: string, amount: number, max: number) => {
@@ -171,7 +186,12 @@ export const useManaStore = create<ManaStore>()(
spendElementMana: (element: string, amount: number) => {
const state = get();
const elem = state.elements[element];
if (!elem || elem.current < amount) return false;
if (!elem) {
return fail(ErrorCode.INVALID_ELEMENT, `Element ${element} does not exist`);
}
if (elem.current < amount) {
return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${amount} ${element} mana, have ${elem.current}`);
}
set({
elements: {
@@ -180,7 +200,7 @@ export const useManaStore = create<ManaStore>()(
},
});
return true;
return okVoid();
},
setElementMax: (max: number) => {
@@ -202,7 +222,9 @@ export const useManaStore = create<ManaStore>()(
// Check if we have all ingredients
for (const [r, amt] of Object.entries(costs)) {
if ((state.elements[r]?.current || 0) < amt) return false;
if ((state.elements[r]?.current || 0) < amt) {
return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${amt} ${r} mana, have ${state.elements[r]?.current || 0}`);
}
}
// Deduct ingredients
@@ -223,7 +245,7 @@ export const useManaStore = create<ManaStore>()(
};
set({ elements: newElems });
return true;
return okVoid();
},
processConvertAction: (rawMana: number) => {
@@ -272,6 +294,7 @@ export const useManaStore = create<ManaStore>()(
},
}),
{
storage: createSafeStorage(),
name: 'mana-loop-mana',
partialize: (state) => ({
rawMana: state.rawMana,
+17 -12
View File
@@ -3,8 +3,11 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createSafeStorage } from '../utils/safe-persist';
import type { Memory } from '../types';
import { GUARDIANS, PRESTIGE_DEF } from '../constants';
import { ok, okVoid, fail, ErrorCode } from '../utils/result';
import type { Result } from '../utils/result';
// ─── Prestige State (data only) ──────────────────────────────────────────────
@@ -41,11 +44,11 @@ export interface PrestigeState {
// ─── Prestige Actions ────────────────────────────────────────────────────────
export interface PrestigeActions {
doPrestige: (id: string) => boolean;
doPrestige: (id: string) => Result<void>;
addMemory: (memory: Memory) => void;
removeMemory: (skillId: string) => void;
clearMemories: () => void;
startPactRitual: (floor: number, rawMana: number) => boolean;
startPactRitual: (floor: number, rawMana: number) => Result<void>;
cancelPactRitual: () => void;
completePactRitual: (addLog: (msg: string) => void) => void;
updatePactRitualProgress: (hours: number) => void;
@@ -112,10 +115,11 @@ export const usePrestigeStore = create<PrestigeStore>()(
doPrestige: (id: string) => {
const state = get();
const pd = PRESTIGE_DEF[id];
if (!pd) return false;
if (!pd) return fail(ErrorCode.INVALID_PRESTIGE_ID, `Unknown prestige upgrade: ${id}`);
const lvl = state.prestigeUpgrades[id] || 0;
if (lvl >= pd.max || state.insight < pd.cost) return false;
if (lvl >= pd.max) return fail(ErrorCode.PRESTIGE_MAX_LEVEL, `Upgrade ${id} is already at max level (${pd.max})`);
if (state.insight < pd.cost) return fail(ErrorCode.INSUFFICIENT_INSIGHT, `Need ${pd.cost} insight, have ${state.insight}`);
const newPU = { ...state.prestigeUpgrades, [id]: lvl + 1 };
set({
@@ -124,7 +128,7 @@ export const usePrestigeStore = create<PrestigeStore>()(
memorySlots: id === 'deepMemory' ? state.memorySlots + 1 : state.memorySlots,
pactSlots: id === 'pactBinding' ? state.pactSlots + 1 : state.pactSlots,
});
return true;
return okVoid();
},
addMemory: (memory: Memory) => {
@@ -148,19 +152,19 @@ export const usePrestigeStore = create<PrestigeStore>()(
startPactRitual: (floor: number, rawMana: number) => {
const state = get();
const guardian = GUARDIANS[floor];
if (!guardian) return false;
if (!guardian) return fail(ErrorCode.INVALID_INPUT, `No guardian at floor ${floor}`);
if (!state.defeatedGuardians.includes(floor)) return false;
if (state.signedPacts.includes(floor)) return false;
if (state.signedPacts.length >= state.pactSlots) return false;
if (rawMana < guardian.pactCost) return false;
if (state.pactRitualFloor !== null) return false;
if (!state.defeatedGuardians.includes(floor)) return fail(ErrorCode.GUARDIAN_NOT_DEFEATED, `Guardian at floor ${floor} has not been defeated`);
if (state.signedPacts.includes(floor)) return fail(ErrorCode.PACT_ALREADY_SIGNED, `Pact with ${guardian.name} is already signed`);
if (state.signedPacts.length >= state.pactSlots) return fail(ErrorCode.PACT_SLOTS_FULL, `All pact slots are full (${state.pactSlots})`);
if (rawMana < guardian.pactCost) return fail(ErrorCode.INSUFFICIENT_MANA, `Need ${guardian.pactCost} raw mana, have ${rawMana}`);
if (state.pactRitualFloor !== null) return fail(ErrorCode.RITUAL_IN_PROGRESS, `A pact ritual is already in progress for floor ${state.pactRitualFloor}`);
set({
pactRitualFloor: floor,
pactRitualProgress: 0,
});
return true;
return okVoid();
},
cancelPactRitual: () => {
@@ -291,6 +295,7 @@ export const usePrestigeStore = create<PrestigeStore>()(
},
}),
{
storage: createSafeStorage(),
name: 'mana-loop-prestige',
partialize: (state) => ({
loopCount: state.loopCount,
+2 -1
View File
@@ -3,6 +3,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createSafeStorage } from '../utils/safe-persist';
export interface LogEntry {
message: string;
@@ -65,6 +66,6 @@ export const useUIStore = create<UIState>()(
});
},
}),
{ name: 'mana-loop-ui-storage' }
{ storage: createSafeStorage(), name: 'mana-loop-ui-storage' }
)
);