refactor: complete error handling standardization (issue #101)
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m26s
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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
);
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// ─── Standardized Result Type ─────────────────────────────────────────────────
|
||||
// Provides consistent error handling across the codebase.
|
||||
// Use Result<T> 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<T = void> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: string; code: ErrorCodeType };
|
||||
|
||||
/** Create a success result. */
|
||||
export function ok<T>(data: T): Result<T> {
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
/** Create a void success result. */
|
||||
export function okVoid(): Result<void> {
|
||||
return { success: true, data: undefined };
|
||||
}
|
||||
|
||||
/** Create a failure result. */
|
||||
export function fail(code: ErrorCodeType, error: string): Result<never> {
|
||||
return { success: false, error, code };
|
||||
}
|
||||
|
||||
/** Create a failure result with a typed data field. */
|
||||
export function failTyped<T>(code: ErrorCodeType, error: string): Result<T> {
|
||||
return { success: false, error, code };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap a result, returning the data or a default value.
|
||||
*/
|
||||
export function unwrapOr<T>(result: Result<T>, defaultValue: T): T {
|
||||
return result.success ? result.data : defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a result is a failure with a specific error code.
|
||||
*/
|
||||
export function isErrorCode<T>(result: Result<T>, code: ErrorCodeType): boolean {
|
||||
return !result.success && result.code === code;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user