diff --git a/docs/circular-deps.txt b/docs/circular-deps.txt index a4c81c3..625bff9 100644 --- a/docs/circular-deps.txt +++ b/docs/circular-deps.txt @@ -1,8 +1,8 @@ # Circular Dependencies -Generated: 2026-05-20T17:48:45.265Z +Generated: 2026-05-20T19:05:27.642Z Found: 4 circular chain(s) — these MUST be fixed before modifying involved files. -1. Processed 126 files (1.4s) (3 warnings) +1. Processed 126 files (1.3s) (3 warnings) 2. 1) stores/gameStore.ts > stores/gameActions.ts 3. 2) stores/gameStore.ts > stores/gameLoopActions.ts 4. 3) stores/gameStore.ts > stores/tick-pipeline.ts diff --git a/docs/dependency-graph.json b/docs/dependency-graph.json index 2d86217..6af75ee 100644 --- a/docs/dependency-graph.json +++ b/docs/dependency-graph.json @@ -1,6 +1,6 @@ { "_meta": { - "generated": "2026-05-20T17:48:43.703Z", + "generated": "2026-05-20T19:05:26.102Z", "description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.", "usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry." }, @@ -85,14 +85,16 @@ ], "crafting-actions/application-actions.ts": [ "crafting-apply.ts", + "stores/craftingStore.types.ts", "types.ts" ], "crafting-actions/computed-getters.ts": [ "data/enchantment-effects.ts", - "types.ts" + "stores/craftingStore.types.ts" ], "crafting-actions/crafting-equipment-actions.ts": [ "crafting-equipment.ts", + "stores/craftingStore.types.ts", "types.ts" ], "crafting-actions/design-actions.ts": [ @@ -100,13 +102,15 @@ "crafting-utils.ts", "effects/special-effects.ts", "effects/upgrade-effects.ts", + "stores/craftingStore.types.ts", "types.ts" ], "crafting-actions/disenchant-actions.ts": [ - "types.ts" + "stores/craftingStore.types.ts" ], "crafting-actions/equipment-actions.ts": [ "crafting-utils.ts", + "stores/craftingStore.types.ts", "types.ts" ], "crafting-actions/index.ts": [ @@ -534,6 +538,7 @@ "types/attunements.ts", "types/elements.ts", "types/equipment.ts", + "types/equipmentSlot.ts", "types/game.ts", "types/spells.ts" ], diff --git a/docs/project-structure.txt b/docs/project-structure.txt index 11b63c5..19ce6a2 100644 --- a/docs/project-structure.txt +++ b/docs/project-structure.txt @@ -334,7 +334,9 @@ Mana-Loop/ │ │ │ ├── index.ts │ │ │ ├── mana-utils.ts │ │ │ ├── pact-utils.ts +│ │ │ ├── result.ts │ │ │ ├── room-utils.ts +│ │ │ ├── safe-persist.ts │ │ │ └── spire-utils.ts │ │ ├── constants.ts │ │ ├── crafting-apply.ts diff --git a/src/components/game/tabs/GuardianPactsTab.tsx b/src/components/game/tabs/GuardianPactsTab.tsx index 99f9170..499e081 100644 --- a/src/components/game/tabs/GuardianPactsTab.tsx +++ b/src/components/game/tabs/GuardianPactsTab.tsx @@ -89,11 +89,11 @@ export const GuardianPactsTab: React.FC = () => { const guardian = GUARDIANS[floor]; if (!guardian) return; - const success = startPactRitual(floor, rawMana); - if (success) { + const result = startPactRitual(floor, rawMana); + if (result.success) { addLog(`📜 Began pact ritual with ${guardian.name}…`); } else { - addLog(`⚠️ Cannot start pact ritual with ${guardian.name}.`); + addLog(`⚠️ ${result.error}`); } }, [startPactRitual, rawMana, addLog]); diff --git a/src/lib/game/__tests__/store-actions-combat-prestige.test.ts b/src/lib/game/__tests__/store-actions-combat-prestige.test.ts index b7720b3..579d289 100644 --- a/src/lib/game/__tests__/store-actions-combat-prestige.test.ts +++ b/src/lib/game/__tests__/store-actions-combat-prestige.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { useCombatStore } from '../stores/combatStore'; import { usePrestigeStore } from '../stores/prestigeStore'; import { getFloorMaxHP } from '../utils'; +import { ErrorCode } from '../utils/result'; function resetCombatStore() { useCombatStore.setState({ @@ -188,7 +189,7 @@ describe('PrestigeStore', () => { describe('doPrestige', () => { it('should purchase upgrade when affordable', () => { const result = usePrestigeStore.getState().doPrestige('manaWell'); - expect(result).toBe(true); + expect(result.success).toBe(true); expect(usePrestigeStore.getState().prestigeUpgrades.manaWell).toBe(1); expect(usePrestigeStore.getState().insight).toBeLessThan(500); }); @@ -196,18 +197,25 @@ describe('PrestigeStore', () => { it('should return false when cannot afford', () => { usePrestigeStore.setState({ insight: 0 }); const result = usePrestigeStore.getState().doPrestige('manaWell'); - expect(result).toBe(false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe(ErrorCode.INSUFFICIENT_INSIGHT); + } }); - it('should return false for invalid upgrade id', () => { + it('should fail for invalid upgrade id', () => { const result = usePrestigeStore.getState().doPrestige('nonexistent'); - expect(result).toBe(false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe(ErrorCode.INVALID_PRESTIGE_ID); + } }); it('should increase memorySlots with deepMemory', () => { usePrestigeStore.setState({ insight: 2000 }); const before = usePrestigeStore.getState().memorySlots; - usePrestigeStore.getState().doPrestige('deepMemory'); + const deepResult = usePrestigeStore.getState().doPrestige('deepMemory'); + expect(deepResult.success).toBe(true); expect(usePrestigeStore.getState().memorySlots).toBe(before + 1); }); }); @@ -280,31 +288,43 @@ describe('PrestigeStore', () => { it('should start ritual when conditions met', () => { usePrestigeStore.setState({ defeatedGuardians: [10], signedPacts: [], insight: 10000 }); const result = usePrestigeStore.getState().startPactRitual(10, 10000); - expect(result).toBe(true); + expect(result.success).toBe(true); expect(usePrestigeStore.getState().pactRitualFloor).toBe(10); }); it('should return false when guardian not defeated', () => { const result = usePrestigeStore.getState().startPactRitual(10, 10000); - expect(result).toBe(false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe(ErrorCode.GUARDIAN_NOT_DEFEATED); + } }); - it('should return false when already signed', () => { + it('should fail when already signed', () => { usePrestigeStore.setState({ defeatedGuardians: [10], signedPacts: [10] }); const result = usePrestigeStore.getState().startPactRitual(10, 10000); - expect(result).toBe(false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe(ErrorCode.PACT_ALREADY_SIGNED); + } }); - it('should return false when pact slots full', () => { + it('should fail when pact slots full', () => { usePrestigeStore.setState({ defeatedGuardians: [10], signedPacts: [20], pactSlots: 1 }); const result = usePrestigeStore.getState().startPactRitual(10, 10000); - expect(result).toBe(false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe(ErrorCode.PACT_SLOTS_FULL); + } }); - it('should return false when insufficient mana', () => { + it('should fail when insufficient mana', () => { usePrestigeStore.setState({ defeatedGuardians: [10], signedPacts: [] }); const result = usePrestigeStore.getState().startPactRitual(10, 0); - expect(result).toBe(false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe(ErrorCode.INSUFFICIENT_MANA); + } }); }); diff --git a/src/lib/game/__tests__/store-actions-mana.test.ts b/src/lib/game/__tests__/store-actions-mana.test.ts index 3910d2e..d2ecc5b 100644 --- a/src/lib/game/__tests__/store-actions-mana.test.ts +++ b/src/lib/game/__tests__/store-actions-mana.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { useManaStore, makeInitialElements } from '../stores/manaStore'; import { MANA_PER_ELEMENT } from '../constants'; +import { ErrorCode } from '../utils/result'; function resetManaStore() { useManaStore.setState({ @@ -74,47 +75,65 @@ describe('ManaStore', () => { it('should convert raw mana to element mana', () => { useManaStore.setState({ rawMana: 500 }); const result = useManaStore.getState().convertMana('transference', 2); - expect(result).toBe(true); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.converted).toBe(2); + } expect(useManaStore.getState().rawMana).toBe(500 - 2 * MANA_PER_ELEMENT); expect(useManaStore.getState().elements.transference.current).toBe(2); }); - it('should return false for locked element', () => { + it('should fail for locked element', () => { const result = useManaStore.getState().convertMana('fire', 1); - expect(result).toBe(false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe(ErrorCode.ELEMENT_NOT_UNLOCKED); + } }); - it('should return false when insufficient raw mana', () => { + it('should fail when insufficient raw mana', () => { useManaStore.setState({ rawMana: 50 }); const result = useManaStore.getState().convertMana('transference', 1); - expect(result).toBe(false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe(ErrorCode.INSUFFICIENT_MANA); + } }); - it('should return false when element is at max', () => { + it('should fail when element is at max', () => { const elements = useManaStore.getState().elements; elements.transference.current = elements.transference.max; useManaStore.setState({ elements }); const result = useManaStore.getState().convertMana('transference', 1); - expect(result).toBe(false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe(ErrorCode.ELEMENT_MAX_CAPACITY); + } }); }); describe('unlockElement', () => { it('should unlock element and deduct cost', () => { const result = useManaStore.getState().unlockElement('fire', 50); - expect(result).toBe(true); + expect(result.success).toBe(true); expect(useManaStore.getState().rawMana).toBe(50); expect(useManaStore.getState().elements.fire.unlocked).toBe(true); }); - it('should return false when already unlocked', () => { + it('should fail when already unlocked', () => { const result = useManaStore.getState().unlockElement('transference', 0); - expect(result).toBe(false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe(ErrorCode.INVALID_INPUT); + } }); - it('should return false when insufficient mana', () => { + it('should fail when insufficient mana', () => { const result = useManaStore.getState().unlockElement('fire', 200); - expect(result).toBe(false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe(ErrorCode.INSUFFICIENT_MANA); + } }); }); @@ -132,13 +151,16 @@ describe('ManaStore', () => { it('should deduct element mana when sufficient', () => { useManaStore.getState().addElementMana('transference', 20, 50); const result = useManaStore.getState().spendElementMana('transference', 10); - expect(result).toBe(true); + expect(result.success).toBe(true); expect(useManaStore.getState().elements.transference.current).toBe(10); }); - it('should return false when insufficient element mana', () => { + it('should fail when insufficient element mana', () => { const result = useManaStore.getState().spendElementMana('transference', 10); - expect(result).toBe(false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe(ErrorCode.INSUFFICIENT_MANA); + } }); }); @@ -149,16 +171,19 @@ describe('ManaStore', () => { useManaStore.getState().addElementMana('fire', 5, 50); useManaStore.getState().addElementMana('earth', 5, 50); const result = useManaStore.getState().craftComposite('metal', ['fire', 'earth']); - expect(result).toBe(true); + expect(result.success).toBe(true); expect(useManaStore.getState().elements.fire.current).toBe(4); expect(useManaStore.getState().elements.earth.current).toBe(4); expect(useManaStore.getState().elements.metal.current).toBe(1); expect(useManaStore.getState().elements.metal.unlocked).toBe(true); }); - it('should return false when missing ingredients', () => { + it('should fail when missing ingredients', () => { const result = useManaStore.getState().craftComposite('metal', ['fire', 'earth']); - expect(result).toBe(false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe(ErrorCode.INSUFFICIENT_MANA); + } }); }); diff --git a/src/lib/game/__tests__/store-actions.test.ts b/src/lib/game/__tests__/store-actions.test.ts index 4c5c3d8..977cb79 100644 --- a/src/lib/game/__tests__/store-actions.test.ts +++ b/src/lib/game/__tests__/store-actions.test.ts @@ -1,10 +1,9 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { useManaStore, makeInitialElements } from '../stores/manaStore'; import { useCombatStore } from '../stores/combatStore'; -import { usePrestigeStore } from '../stores/prestigeStore'; -import { useDisciplineStore } from '../stores/discipline-slice'; import { MANA_PER_ELEMENT } from '../constants'; import { getFloorMaxHP } from '../utils'; +import { ErrorCode } from '../utils/result'; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -44,37 +43,6 @@ function resetCombatStore() { }); } -function resetPrestigeStore() { - usePrestigeStore.setState({ - loopCount: 0, - insight: 500, - totalInsight: 500, - loopInsight: 0, - prestigeUpgrades: {}, - memorySlots: 3, - pactSlots: 1, - memories: [], - defeatedGuardians: [], - signedPacts: [], - signedPactDetails: {}, - pactRitualFloor: null, - pactRitualProgress: 0, - }); -} - -function resetDisciplineStore() { - useDisciplineStore.setState({ - disciplines: {}, - activeIds: [], - concurrentLimit: 1, - totalXP: 0, - }); -} - -// ═══════════════════════════════════════════════════════════════════════════════ -// MANA STORE -// ═══════════════════════════════════════════════════════════════════════════════ - describe('ManaStore', () => { beforeEach(resetManaStore); @@ -138,47 +106,65 @@ describe('ManaStore', () => { it('should convert raw mana to element mana', () => { useManaStore.setState({ rawMana: 500 }); const result = useManaStore.getState().convertMana('transference', 2); - expect(result).toBe(true); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.converted).toBe(2); + } expect(useManaStore.getState().rawMana).toBe(500 - 2 * MANA_PER_ELEMENT); expect(useManaStore.getState().elements.transference.current).toBe(2); }); - it('should return false for locked element', () => { + it('should fail for locked element', () => { const result = useManaStore.getState().convertMana('fire', 1); - expect(result).toBe(false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe(ErrorCode.ELEMENT_NOT_UNLOCKED); + } }); - it('should return false when insufficient raw mana', () => { + it('should fail when insufficient raw mana', () => { useManaStore.setState({ rawMana: 50 }); const result = useManaStore.getState().convertMana('transference', 1); - expect(result).toBe(false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe(ErrorCode.INSUFFICIENT_MANA); + } }); - it('should return false when element is at max', () => { + it('should fail when element is at max', () => { const elements = useManaStore.getState().elements; elements.transference.current = elements.transference.max; useManaStore.setState({ elements }); const result = useManaStore.getState().convertMana('transference', 1); - expect(result).toBe(false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe(ErrorCode.ELEMENT_MAX_CAPACITY); + } }); }); describe('unlockElement', () => { it('should unlock element and deduct cost', () => { const result = useManaStore.getState().unlockElement('fire', 50); - expect(result).toBe(true); + expect(result.success).toBe(true); expect(useManaStore.getState().rawMana).toBe(50); expect(useManaStore.getState().elements.fire.unlocked).toBe(true); }); - it('should return false when already unlocked', () => { + it('should fail when already unlocked', () => { const result = useManaStore.getState().unlockElement('transference', 0); - expect(result).toBe(false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe(ErrorCode.INVALID_INPUT); + } }); - it('should return false when insufficient mana', () => { + it('should fail when insufficient mana', () => { const result = useManaStore.getState().unlockElement('fire', 200); - expect(result).toBe(false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe(ErrorCode.INSUFFICIENT_MANA); + } }); }); @@ -196,13 +182,16 @@ describe('ManaStore', () => { it('should deduct element mana when sufficient', () => { useManaStore.getState().addElementMana('transference', 20, 50); const result = useManaStore.getState().spendElementMana('transference', 10); - expect(result).toBe(true); + expect(result.success).toBe(true); expect(useManaStore.getState().elements.transference.current).toBe(10); }); - it('should return false when insufficient element mana', () => { + it('should fail when insufficient element mana', () => { const result = useManaStore.getState().spendElementMana('transference', 10); - expect(result).toBe(false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe(ErrorCode.INSUFFICIENT_MANA); + } }); }); @@ -213,16 +202,19 @@ describe('ManaStore', () => { useManaStore.getState().addElementMana('fire', 5, 50); useManaStore.getState().addElementMana('earth', 5, 50); const result = useManaStore.getState().craftComposite('metal', ['fire', 'earth']); - expect(result).toBe(true); + expect(result.success).toBe(true); expect(useManaStore.getState().elements.fire.current).toBe(4); expect(useManaStore.getState().elements.earth.current).toBe(4); expect(useManaStore.getState().elements.metal.current).toBe(1); expect(useManaStore.getState().elements.metal.unlocked).toBe(true); }); - it('should return false when missing ingredients', () => { + it('should fail when missing ingredients', () => { const result = useManaStore.getState().craftComposite('metal', ['fire', 'earth']); - expect(result).toBe(false); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.code).toBe(ErrorCode.INSUFFICIENT_MANA); + } }); }); @@ -402,273 +394,3 @@ describe('CombatStore', () => { }); }); }); - -// ═══════════════════════════════════════════════════════════════════════════════ -// PRESTIGE STORE -// ═══════════════════════════════════════════════════════════════════════════════ - -describe('PrestigeStore', () => { - beforeEach(resetPrestigeStore); - - describe('doPrestige', () => { - it('should purchase upgrade when affordable', () => { - const result = usePrestigeStore.getState().doPrestige('manaWell'); - expect(result).toBe(true); - expect(usePrestigeStore.getState().prestigeUpgrades.manaWell).toBe(1); - expect(usePrestigeStore.getState().insight).toBeLessThan(500); - }); - - it('should return false when cannot afford', () => { - usePrestigeStore.setState({ insight: 0 }); - const result = usePrestigeStore.getState().doPrestige('manaWell'); - expect(result).toBe(false); - }); - - it('should return false for invalid upgrade id', () => { - const result = usePrestigeStore.getState().doPrestige('nonexistent'); - expect(result).toBe(false); - }); - - it('should increase memorySlots with deepMemory', () => { - usePrestigeStore.setState({ insight: 2000 }); - const before = usePrestigeStore.getState().memorySlots; - usePrestigeStore.getState().doPrestige('deepMemory'); - expect(usePrestigeStore.getState().memorySlots).toBe(before + 1); - }); - }); - - describe('addMemory / removeMemory', () => { - it('should add a memory when slots available', () => { - usePrestigeStore.getState().addMemory({ skillId: 'manaFlow', level: 3 }); - expect(usePrestigeStore.getState().memories.length).toBe(1); - }); - - it('should not add duplicate memory', () => { - usePrestigeStore.getState().addMemory({ skillId: 'manaFlow', level: 3 }); - usePrestigeStore.getState().addMemory({ skillId: 'manaFlow', level: 5 }); - expect(usePrestigeStore.getState().memories.length).toBe(1); - }); - - it('should not exceed memory slots', () => { - usePrestigeStore.setState({ memorySlots: 1 }); - usePrestigeStore.getState().addMemory({ skillId: 'manaFlow', level: 1 }); - usePrestigeStore.getState().addMemory({ skillId: 'manaSpring', level: 1 }); - expect(usePrestigeStore.getState().memories.length).toBe(1); - }); - - it('should remove memory by skillId', () => { - usePrestigeStore.getState().addMemory({ skillId: 'manaFlow', level: 3 }); - usePrestigeStore.getState().removeMemory('manaFlow'); - expect(usePrestigeStore.getState().memories.length).toBe(0); - }); - - it('should clear all memories', () => { - usePrestigeStore.getState().addMemory({ skillId: 'manaFlow', level: 1 }); - usePrestigeStore.getState().addMemory({ skillId: 'manaSpring', level: 1 }); - usePrestigeStore.getState().clearMemories(); - expect(usePrestigeStore.getState().memories.length).toBe(0); - }); - }); - - describe('defeatGuardian / signedPacts', () => { - it('should add defeated guardian', () => { - usePrestigeStore.getState().defeatGuardian(10); - expect(usePrestigeStore.getState().defeatedGuardians).toContain(10); - }); - - it('should not duplicate defeated guardian', () => { - usePrestigeStore.getState().defeatGuardian(10); - usePrestigeStore.getState().defeatGuardian(10); - expect(usePrestigeStore.getState().defeatedGuardians.filter(f => f === 10).length).toBe(1); - }); - - it('should not defeat already signed guardian', () => { - usePrestigeStore.setState({ signedPacts: [10] }); - usePrestigeStore.getState().defeatGuardian(10); - expect(usePrestigeStore.getState().defeatedGuardians).not.toContain(10); - }); - - it('should add signed pact', () => { - usePrestigeStore.getState().addSignedPact(10); - expect(usePrestigeStore.getState().signedPacts).toContain(10); - }); - - it('should remove pact', () => { - usePrestigeStore.setState({ signedPacts: [10, 20] }); - usePrestigeStore.getState().removePact(10); - expect(usePrestigeStore.getState().signedPacts).not.toContain(10); - expect(usePrestigeStore.getState().signedPacts).toContain(20); - }); - }); - - describe('startPactRitual', () => { - it('should start ritual when conditions met', () => { - usePrestigeStore.setState({ defeatedGuardians: [10], signedPacts: [], insight: 10000 }); - const result = usePrestigeStore.getState().startPactRitual(10, 10000); - expect(result).toBe(true); - expect(usePrestigeStore.getState().pactRitualFloor).toBe(10); - }); - - it('should return false when guardian not defeated', () => { - const result = usePrestigeStore.getState().startPactRitual(10, 10000); - expect(result).toBe(false); - }); - - it('should return false when already signed', () => { - usePrestigeStore.setState({ defeatedGuardians: [10], signedPacts: [10] }); - const result = usePrestigeStore.getState().startPactRitual(10, 10000); - expect(result).toBe(false); - }); - - it('should return false when pact slots full', () => { - usePrestigeStore.setState({ defeatedGuardians: [10], signedPacts: [20], pactSlots: 1 }); - const result = usePrestigeStore.getState().startPactRitual(10, 10000); - expect(result).toBe(false); - }); - - it('should return false when insufficient mana', () => { - usePrestigeStore.setState({ defeatedGuardians: [10], signedPacts: [] }); - const result = usePrestigeStore.getState().startPactRitual(10, 0); - expect(result).toBe(false); - }); - }); - - describe('cancelPactRitual', () => { - it('should cancel active ritual', () => { - usePrestigeStore.setState({ pactRitualFloor: 10, pactRitualProgress: 1 }); - usePrestigeStore.getState().cancelPactRitual(); - expect(usePrestigeStore.getState().pactRitualFloor).toBeNull(); - expect(usePrestigeStore.getState().pactRitualProgress).toBe(0); - }); - }); - - describe('startNewLoop', () => { - it('should increment loop count and add insight', () => { - usePrestigeStore.setState({ insight: 100, totalInsight: 100 }); - usePrestigeStore.getState().startNewLoop(50); - expect(usePrestigeStore.getState().loopCount).toBe(1); - expect(usePrestigeStore.getState().insight).toBe(150); - expect(usePrestigeStore.getState().totalInsight).toBe(150); - }); - - it('should reset loop-specific state', () => { - usePrestigeStore.setState({ - defeatedGuardians: [10], - signedPacts: [20], - pactRitualFloor: 10, - pactRitualProgress: 5, - }); - usePrestigeStore.getState().startNewLoop(0); - expect(usePrestigeStore.getState().defeatedGuardians).toEqual([]); - expect(usePrestigeStore.getState().signedPacts).toEqual([]); - expect(usePrestigeStore.getState().pactRitualFloor).toBeNull(); - }); - }); - - describe('resetPrestigeForNewLoop', () => { - it('should preserve insight and upgrades, reset loop state', () => { - usePrestigeStore.getState().resetPrestigeForNewLoop(200, { manaWell: 2 }, [], 4); - expect(usePrestigeStore.getState().insight).toBe(200); - expect(usePrestigeStore.getState().prestigeUpgrades).toEqual({ manaWell: 2 }); - expect(usePrestigeStore.getState().memorySlots).toBe(4); - expect(usePrestigeStore.getState().defeatedGuardians).toEqual([]); - }); - }); - - describe('resetPrestige', () => { - it('should reset everything to initial state', () => { - usePrestigeStore.setState({ insight: 1000, loopCount: 5, prestigeUpgrades: { manaWell: 3 } }); - usePrestigeStore.getState().resetPrestige(); - expect(usePrestigeStore.getState().insight).toBe(0); - expect(usePrestigeStore.getState().loopCount).toBe(0); - expect(usePrestigeStore.getState().prestigeUpgrades).toEqual({}); - }); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════════ -// DISCIPLINE STORE -// ═══════════════════════════════════════════════════════════════════════════════ - -describe('DisciplineStore', () => { - beforeEach(resetDisciplineStore); - - describe('activate', () => { - it('should activate raw discipline', () => { - useDisciplineStore.getState().activate('raw-mastery'); - expect(useDisciplineStore.getState().activeIds).toContain('raw-mastery'); - expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(false); - }); - - it('should not activate same discipline twice', () => { - useDisciplineStore.getState().activate('raw-mastery'); - useDisciplineStore.getState().activate('raw-mastery'); - expect(useDisciplineStore.getState().activeIds.filter(id => id === 'raw-mastery').length).toBe(1); - }); - - it('should not activate when concurrent limit reached', () => { - useDisciplineStore.getState().activate('raw-mastery'); - useDisciplineStore.getState().activate('elemental-attunement'); - expect(useDisciplineStore.getState().activeIds.length).toBe(1); - }); - - it('should activate when no prior discipline state (optimistic)', () => { - useDisciplineStore.getState().activate('elemental-attunement', { - elements: { fire: { unlocked: false } }, - }); - expect(useDisciplineStore.getState().activeIds).toContain('elemental-attunement'); - }); - - it('should not activate when existing state has insufficient mana', () => { - useDisciplineStore.setState({ - disciplines: { 'raw-mastery': { id: 'raw-mastery', xp: 1000, paused: false } }, - }); - useDisciplineStore.getState().activate('raw-mastery', { elements: {} }); - expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery'); - }); - - it('should activate when required element is unlocked', () => { - useDisciplineStore.getState().activate('elemental-attunement', { - elements: { fire: { unlocked: true } }, - }); - expect(useDisciplineStore.getState().activeIds).toContain('elemental-attunement'); - }); - }); - - describe('deactivate', () => { - it('should remove discipline from active list', () => { - useDisciplineStore.getState().activate('raw-mastery'); - useDisciplineStore.getState().deactivate('raw-mastery'); - expect(useDisciplineStore.getState().activeIds).not.toContain('raw-mastery'); - }); - }); - - describe('processTick', () => { - it('should accrue XP for active discipline', () => { - useDisciplineStore.getState().activate('raw-mastery'); - useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} }); - expect(useDisciplineStore.getState().disciplines['raw-mastery'].xp).toBe(1); - expect(useDisciplineStore.getState().totalXP).toBe(1); - }); - - it('should drain raw mana for raw discipline', () => { - useDisciplineStore.getState().activate('raw-mastery'); - const result = useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} }); - expect(result.rawMana).toBeLessThan(1000); - }); - - it('should pause discipline when insufficient mana', () => { - useDisciplineStore.getState().activate('raw-mastery'); - useDisciplineStore.getState().processTick({ rawMana: 0, elements: {} }); - expect(useDisciplineStore.getState().disciplines['raw-mastery'].paused).toBe(true); - }); - - it('should increase concurrent limit at 500 total XP', () => { - useDisciplineStore.setState({ totalXP: 499 }); - useDisciplineStore.getState().activate('raw-mastery'); - useDisciplineStore.getState().processTick({ rawMana: 1000, elements: {} }); - expect(useDisciplineStore.getState().totalXP).toBe(500); - expect(useDisciplineStore.getState().concurrentLimit).toBeGreaterThan(1); - }); - }); -}); diff --git a/src/lib/game/crafting-equipment.ts b/src/lib/game/crafting-equipment.ts index 2269d10..3ae409f 100644 --- a/src/lib/game/crafting-equipment.ts +++ b/src/lib/game/crafting-equipment.ts @@ -5,6 +5,8 @@ import type { EquipmentInstance, EquipmentCraftingProgress } from './types'; import { CRAFTING_RECIPES, canCraftRecipe, type CraftingRecipe } from './data/crafting-recipes'; import { EQUIPMENT_TYPES } from './data/equipment'; import { generateInstanceId } from './crafting-utils'; +import { ok, fail, ErrorCode } from './utils/result'; +import type { Result } from './utils/result'; // ─── Equipment Crafting Validation ────────────────────────────────────────── @@ -110,14 +112,14 @@ export function calculateCraftingTick(currentProgress: number, required: number) export function completeEquipmentCrafting( blueprintId: string, recipe: CraftingRecipe -): { +): Result<{ instanceId: string; instance: EquipmentInstance; logMessage: string; -} { +}> { const equipType = EQUIPMENT_TYPES[recipe.equipmentTypeId]; if (!equipType) { - throw new Error(`Invalid equipment type: ${recipe.equipmentTypeId}`); + return fail(ErrorCode.INVALID_EQUIPMENT_TYPE, `Invalid equipment type: ${recipe.equipmentTypeId}`); } const instanceId = `equip_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; @@ -133,11 +135,11 @@ export function completeEquipmentCrafting( tags: [], }; - return { + return ok({ instanceId, instance: newInstance, logMessage: `🔨 Crafted ${recipe.name}!`, - }; + }); } // ─── Crafting Cancellation ────────────────────────────────────────────────── diff --git a/src/lib/game/stores/attunementStore.ts b/src/lib/game/stores/attunementStore.ts index 11e0cc9..16ca3ae 100644 --- a/src/lib/game/stores/attunementStore.ts +++ b/src/lib/game/stores/attunementStore.ts @@ -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()( }, }), { + storage: createSafeStorage(), name: 'mana-loop-attunements', partialize: (state) => ({ attunements: state.attunements, diff --git a/src/lib/game/stores/combat-actions.ts b/src/lib/game/stores/combat-actions.ts index 47c4088..1a8e96d 100644 --- a/src/lib/game/stores/combat-actions.ts +++ b/src/lib/game/stores/combat-actions.ts @@ -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, + 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 diff --git a/src/lib/game/stores/combatStore.ts b/src/lib/game/stores/combatStore.ts index c5dfa3c..f022dee 100755 --- a/src/lib/game/stores/combatStore.ts +++ b/src/lib/game/stores/combatStore.ts @@ -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()( }), { + storage: createSafeStorage(), name: 'mana-loop-combat', partialize: (state) => ({ currentFloor: state.currentFloor, diff --git a/src/lib/game/stores/craftingStore.ts b/src/lib/game/stores/craftingStore.ts index 2ec7859..8e269ed 100644 --- a/src/lib/game/stores/craftingStore.ts +++ b/src/lib/game/stores/craftingStore.ts @@ -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()( persist( @@ -217,6 +220,19 @@ export const useCraftingStore = create()( ); 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()( }; }, { + storage: createSafeStorage(), name: 'mana-loop-crafting', partialize: (state) => ({ designProgress: state.designProgress, diff --git a/src/lib/game/stores/craftingStore.types.ts b/src/lib/game/stores/craftingStore.types.ts index bc17462..e1af7c6 100644 --- a/src/lib/game/stores/craftingStore.types.ts +++ b/src/lib/game/stores/craftingStore.types.ts @@ -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; diff --git a/src/lib/game/stores/discipline-slice.ts b/src/lib/game/stores/discipline-slice.ts index cf85440..54128c2 100644 --- a/src/lib/game/stores/discipline-slice.ts +++ b/src/lib/game/stores/discipline-slice.ts @@ -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()( return { rawMana, elements }; }, }), - { name: 'mana-loop-discipline-store' } + { storage: createSafeStorage(), name: 'mana-loop-discipline-store' } ) ); \ No newline at end of file diff --git a/src/lib/game/stores/gameStore.ts b/src/lib/game/stores/gameStore.ts index 42a65e9..851d1a0 100755 --- a/src/lib/game/stores/gameStore.ts +++ b/src/lib/game/stores/gameStore.ts @@ -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()( }, 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()( 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()( gatherMana: createGatherMana(), }), { + storage: createSafeStorage(), name: 'mana-loop-game-storage', partialize: (state) => ({ day: state.day, diff --git a/src/lib/game/stores/manaStore.ts b/src/lib/game/stores/manaStore.ts index c965694..3787e62 100755 --- a/src/lib/game/stores/manaStore.ts +++ b/src/lib/game/stores/manaStore.ts @@ -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; addElementMana: (element: string, amount: number, max: number) => void; - spendElementMana: (element: string, amount: number) => boolean; + spendElementMana: (element: string, amount: number) => Result; setElementMax: (max: number) => void; - craftComposite: (target: string, recipe: string[]) => boolean; + craftComposite: (target: string, recipe: string[]) => Result; // Helper for gameStore coordination processConvertAction: (rawMana: number) => { rawMana: number; elements: Record } | null; @@ -110,11 +113,17 @@ export const useManaStore = create()( 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()( 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()( }, }); - 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()( }, }); - return true; + return okVoid(); }, addElementMana: (element: string, amount: number, max: number) => { @@ -171,7 +186,12 @@ export const useManaStore = create()( 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()( }, }); - return true; + return okVoid(); }, setElementMax: (max: number) => { @@ -202,7 +222,9 @@ export const useManaStore = create()( // 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()( }; set({ elements: newElems }); - return true; + return okVoid(); }, processConvertAction: (rawMana: number) => { @@ -272,6 +294,7 @@ export const useManaStore = create()( }, }), { + storage: createSafeStorage(), name: 'mana-loop-mana', partialize: (state) => ({ rawMana: state.rawMana, diff --git a/src/lib/game/stores/prestigeStore.ts b/src/lib/game/stores/prestigeStore.ts index 6449d54..d251dbf 100755 --- a/src/lib/game/stores/prestigeStore.ts +++ b/src/lib/game/stores/prestigeStore.ts @@ -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; addMemory: (memory: Memory) => void; removeMemory: (skillId: string) => void; clearMemories: () => void; - startPactRitual: (floor: number, rawMana: number) => boolean; + startPactRitual: (floor: number, rawMana: number) => Result; cancelPactRitual: () => void; completePactRitual: (addLog: (msg: string) => void) => void; updatePactRitualProgress: (hours: number) => void; @@ -112,10 +115,11 @@ export const usePrestigeStore = create()( 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()( 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()( 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()( }, }), { + storage: createSafeStorage(), name: 'mana-loop-prestige', partialize: (state) => ({ loopCount: state.loopCount, diff --git a/src/lib/game/stores/uiStore.ts b/src/lib/game/stores/uiStore.ts index 28d310d..f1f7e34 100755 --- a/src/lib/game/stores/uiStore.ts +++ b/src/lib/game/stores/uiStore.ts @@ -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()( }); }, }), - { name: 'mana-loop-ui-storage' } + { storage: createSafeStorage(), name: 'mana-loop-ui-storage' } ) ); diff --git a/src/lib/game/utils/index.ts b/src/lib/game/utils/index.ts index 7342ddd..af1409d 100644 --- a/src/lib/game/utils/index.ts +++ b/src/lib/game/utils/index.ts @@ -2,6 +2,9 @@ // Re-export everything from the focused modules export { fmt, fmtDec, formatSpellCost, getSpellCostColor, formatStudyTime, formatHour } from './formatting'; +export { createSafeStorage } from './safe-persist'; +export { ok, okVoid, fail, failTyped, unwrapOr, isErrorCode, ErrorCode } from './result'; +export type { Result, ErrorCodeType } from './result'; export { getFloorMaxHP, getFloorElement } from './floor-utils'; export { computeMaxMana, diff --git a/src/lib/game/utils/result.ts b/src/lib/game/utils/result.ts new file mode 100644 index 0000000..a89947f --- /dev/null +++ b/src/lib/game/utils/result.ts @@ -0,0 +1,89 @@ +// ─── Standardized Result Type ───────────────────────────────────────────────── +// Provides consistent error handling across the codebase. +// Use Result for expected failures; reserve throw for truly unexpected errors. + +/** + * Error codes for categorizing failures. + * Enables callers to programmatically handle specific error types. + */ +export const ErrorCode = { + // Mana errors + INSUFFICIENT_MANA: 'INSUFFICIENT_MANA', + ELEMENT_NOT_UNLOCKED: 'ELEMENT_NOT_UNLOCKED', + ELEMENT_MAX_CAPACITY: 'ELEMENT_MAX_CAPACITY', + INVALID_ELEMENT: 'INVALID_ELEMENT', + + // Crafting errors + INVALID_BLUEPRINT: 'INVALID_BLUEPRINT', + BLUEPRINT_NOT_ACQUIRED: 'BLUEPRINT_NOT_ACQUIRED', + MISSING_MATERIALS: 'MISSING_MATERIALS', + INVALID_EQUIPMENT_TYPE: 'INVALID_EQUIPMENT_TYPE', + INVALID_EFFECT: 'INVALID_EFFECT', + EFFECT_NOT_ALLOWED: 'EFFECT_NOT_ALLOWED', + STACKS_EXCEED_MAX: 'STACKS_EXCEED_MAX', + INSUFFICIENT_CAPACITY: 'INSUFFICIENT_CAPACITY', + EQUIPMENT_NOT_FOUND: 'EQUIPMENT_NOT_FOUND', + DESIGN_NOT_FOUND: 'DESIGN_NOT_FOUND', + EQUIPMENT_NOT_PREPARED: 'EQUIPMENT_NOT_PREPARED', + ALREADY_PREPARED: 'ALREADY_PREPARED', + + // Action state errors + INVALID_ACTION_STATE: 'INVALID_ACTION_STATE', + + // Prestige errors + INSUFFICIENT_INSIGHT: 'INSUFFICIENT_INSIGHT', + GUARDIAN_NOT_DEFEATED: 'GUARDIAN_NOT_DEFEATED', + PACT_ALREADY_SIGNED: 'PACT_ALREADY_SIGNED', + PACT_SLOTS_FULL: 'PACT_SLOTS_FULL', + RITUAL_IN_PROGRESS: 'RITUAL_IN_PROGRESS', + PRESTIGE_MAX_LEVEL: 'PRESTIGE_MAX_LEVEL', + INVALID_PRESTIGE_ID: 'INVALID_PRESTIGE_ID', + + // General + INVALID_INPUT: 'INVALID_INPUT', + NOT_INITIALIZED: 'NOT_INITIALIZED', +} as const; + +export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode]; + +/** + * Standard result type for operations that can fail. + * T is the success payload type. + */ +export type Result = + | { success: true; data: T } + | { success: false; error: string; code: ErrorCodeType }; + +/** Create a success result. */ +export function ok(data: T): Result { + return { success: true, data }; +} + +/** Create a void success result. */ +export function okVoid(): Result { + return { success: true, data: undefined }; +} + +/** Create a failure result. */ +export function fail(code: ErrorCodeType, error: string): Result { + return { success: false, error, code }; +} + +/** Create a failure result with a typed data field. */ +export function failTyped(code: ErrorCodeType, error: string): Result { + return { success: false, error, code }; +} + +/** + * Unwrap a result, returning the data or a default value. + */ +export function unwrapOr(result: Result, defaultValue: T): T { + return result.success ? result.data : defaultValue; +} + +/** + * Check if a result is a failure with a specific error code. + */ +export function isErrorCode(result: Result, code: ErrorCodeType): boolean { + return !result.success && result.code === code; +} diff --git a/src/lib/game/utils/safe-persist.ts b/src/lib/game/utils/safe-persist.ts new file mode 100644 index 0000000..0ce9138 --- /dev/null +++ b/src/lib/game/utils/safe-persist.ts @@ -0,0 +1,49 @@ +// ─── Safe Persist Storage ───────────────────────────────────────────────────── +// Wraps localStorage with error handling for Zustand persist middleware. +// Handles: quota exceeded, corrupted JSON, and unexpected read/write failures. + +import type { StateStorage } from 'zustand/middleware'; + +/** + * Creates a safe localStorage wrapper for Zustand persist. + * - Corrupted JSON → returns null (Zustand uses initial state) + * - Quota exceeded → logs warning, skips write + * - Other errors → logs warning, graceful fallback + */ +export function createSafeStorage(): StateStorage { + return { + getItem: (name: string): unknown => { + try { + const str = localStorage.getItem(name); + if (str === null) return null; + return JSON.parse(str); + } catch (error) { + console.warn(`[persist] Failed to read "${name}" from localStorage:`, error); + try { + localStorage.removeItem(name); + } catch { + // ignore + } + return null; + } + }, + setItem: (name: string, value: unknown): void => { + try { + localStorage.setItem(name, JSON.stringify(value)); + } catch (error) { + if (error instanceof DOMException && error.name === 'QuotaExceededError') { + console.warn(`[persist] localStorage quota exceeded for "${name}". State will not persist this tick.`); + } else { + console.warn(`[persist] Failed to write "${name}" to localStorage:`, error); + } + } + }, + removeItem: (name: string): void => { + try { + localStorage.removeItem(name); + } catch (error) { + console.warn(`[persist] Failed to remove "${name}" from localStorage:`, error); + } + }, + }; +}