fix: repair persistence - safe-persist getItem now returns parsed objects, fix resetGame localStorage keys
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m22s

- safe-persist.ts: getItem now calls JSON.parse(str) so Zustand receives {state, version} envelope
- gameActions.ts: fix 5 wrong localStorage keys in createResetGame (mana→mana-storage, etc.)
- Add persistence.test.ts with 12 tests covering round-trip, key verification, and reset
- All 918 tests pass with zero regressions
This commit is contained in:
2026-05-27 19:14:01 +02:00
parent 5f8a860a3c
commit 7279050101
6 changed files with 236 additions and 13 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# Circular Dependencies # Circular Dependencies
Generated: 2026-05-27T13:22:21.442Z Generated: 2026-05-27T13:57:23.187Z
No circular dependencies found. ✅ No circular dependencies found. ✅
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"_meta": { "_meta": {
"generated": "2026-05-27T13:22:19.613Z", "generated": "2026-05-27T13:57:21.361Z",
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.", "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." "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."
}, },
+1
View File
@@ -219,6 +219,7 @@ Mana-Loop/
│ │ │ ├── guardian-names.test.ts │ │ │ ├── guardian-names.test.ts
│ │ │ ├── mana-utils.test.ts │ │ │ ├── mana-utils.test.ts
│ │ │ ├── pact-utils.test.ts │ │ │ ├── pact-utils.test.ts
│ │ │ ├── persistence.test.ts
│ │ │ ├── regression-fixes.test.ts │ │ │ ├── regression-fixes.test.ts
│ │ │ ├── room-utils-floor-state.test.ts │ │ │ ├── room-utils-floor-state.test.ts
│ │ │ ├── room-utils.test.ts │ │ │ ├── room-utils.test.ts
+206
View File
@@ -0,0 +1,206 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { useGameStore } from '../stores/gameStore';
import { useManaStore } from '../stores/manaStore';
import { useCombatStore } from '../stores/combatStore';
import { usePrestigeStore } from '../stores/prestigeStore';
import { useCraftingStore } from '../stores/craftingStore';
import { useAttunementStore } from '../stores/attunementStore';
import { useDisciplineStore } from '../stores/discipline-slice';
import { useUIStore } from '../stores/uiStore';
// ─── Helpers ──────────────────────────────────────────────────────────────────
const ALL_STORE_KEYS = [
'mana-loop-game-storage',
'mana-loop-mana',
'mana-loop-combat',
'mana-loop-prestige',
'mana-loop-crafting',
'mana-loop-attunements',
'mana-loop-discipline-store',
'mana-loop-ui-storage',
] as const;
function clearAllPersistedState() {
for (const key of ALL_STORE_KEYS) {
localStorage.removeItem(key);
}
}
function setKnownStoreStates() {
useGameStore.setState({ day: 5, hour: 12 });
useManaStore.setState({ rawMana: 42, totalManaGathered: 100 });
useCombatStore.setState({ currentFloor: 7, maxFloorReached: 7 });
usePrestigeStore.setState({ insight: 50, totalInsight: 50, loopCount: 2 });
useAttunementStore.setState({
attunements: { enchanter: { id: 'enchanter', active: true, level: 3, experience: 50 } },
});
useDisciplineStore.setState({ totalXP: 200, concurrentLimit: 2 });
useUIStore.setState({ paused: false, gameOver: false });
// Crafting store: change something to trigger persist
useCraftingStore.setState((s) => ({ lootInventory: { ...s.lootInventory, materials: { ironOre: 5 } } }));
}
// ═══════════════════════════════════════════════════════════════════════════════
// PERSISTENCE TESTS
// ═══════════════════════════════════════════════════════════════════════════════
describe('Persistence', () => {
beforeEach(() => {
clearAllPersistedState();
});
afterEach(() => {
clearAllPersistedState();
});
describe('localStorage keys exist after state changes', () => {
it('should persist game store state to localStorage', () => {
useGameStore.setState({ day: 5, hour: 12 });
const raw = localStorage.getItem('mana-loop-game-storage');
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw!);
expect(parsed.state.day).toBe(5);
expect(parsed.state.hour).toBe(12);
});
it('should persist mana store state to localStorage', () => {
useManaStore.setState({ rawMana: 42, totalManaGathered: 100 });
const raw = localStorage.getItem('mana-loop-mana');
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw!);
expect(parsed.state.rawMana).toBe(42);
expect(parsed.state.totalManaGathered).toBe(100);
});
it('should persist combat store state to localStorage', () => {
useCombatStore.setState({ currentFloor: 7, maxFloorReached: 7 });
const raw = localStorage.getItem('mana-loop-combat');
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw!);
expect(parsed.state.currentFloor).toBe(7);
expect(parsed.state.maxFloorReached).toBe(7);
});
it('should persist prestige store state to localStorage', () => {
usePrestigeStore.setState({ insight: 50, totalInsight: 50, loopCount: 2 });
const raw = localStorage.getItem('mana-loop-prestige');
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw!);
expect(parsed.state.insight).toBe(50);
expect(parsed.state.totalInsight).toBe(50);
expect(parsed.state.loopCount).toBe(2);
});
it('should persist attunement store state to localStorage', () => {
useAttunementStore.setState({
attunements: { enchanter: { id: 'enchanter', active: true, level: 3, experience: 50 } },
});
const raw = localStorage.getItem('mana-loop-attunements');
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw!);
expect(parsed.state.attunements.enchanter.level).toBe(3);
expect(parsed.state.attunements.enchanter.experience).toBe(50);
});
it('should persist discipline store state to localStorage', () => {
useDisciplineStore.setState({ totalXP: 200, concurrentLimit: 2 });
const raw = localStorage.getItem('mana-loop-discipline-store');
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw!);
expect(parsed.state.totalXP).toBe(200);
expect(parsed.state.concurrentLimit).toBe(2);
});
it('should persist UI store state to localStorage', () => {
useUIStore.setState({ paused: true, gameOver: true });
const raw = localStorage.getItem('mana-loop-ui-storage');
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw!);
expect(parsed.state.paused).toBe(true);
expect(parsed.state.gameOver).toBe(true);
});
it('should persist crafting store state to localStorage', () => {
useCraftingStore.setState((s) => ({
lootInventory: { ...s.lootInventory, materials: { ironOre: 5 } },
}));
const raw = localStorage.getItem('mana-loop-crafting');
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw!);
expect(parsed.state.lootInventory.materials.ironOre).toBe(5);
});
});
describe('all localStorage keys are writable', () => {
it('should write all 8 store keys when state changes', () => {
setKnownStoreStates();
for (const key of ALL_STORE_KEYS) {
const raw = localStorage.getItem(key);
expect(raw).not.toBeNull(`Expected localStorage key "${key}" to exist after state changes`);
const parsed = JSON.parse(raw!);
expect(parsed).toHaveProperty('state');
expect(parsed).toHaveProperty('version');
}
});
});
describe('resetGame clears localStorage with correct keys', () => {
it('should not use wrong localStorage keys (mismatch bug)', () => {
setKnownStoreStates();
// The old resetGame used wrong keys like 'mana-loop-mana-storage' instead of 'mana-loop-mana'
// After reset, correct keys should be restored to defaults, not stale
useGameStore.getState().resetGame();
// Verify wrong keys are never written
const wrongKeys = [
'mana-loop-mana-storage',
'mana-loop-combat-storage',
'mana-loop-prestige-storage',
'mana-loop-crafting-storage',
'mana-loop-attunement-storage',
'mana-loop-game', // missing -storage suffix (only game uses it)
];
for (const wrongKey of wrongKeys) {
expect(localStorage.getItem(wrongKey)).toBeNull(
`Wrong key "${wrongKey}" should never exist`
);
}
});
it('should reset all stores to initial values', () => {
setKnownStoreStates();
useGameStore.getState().resetGame();
// Game store should be reset
expect(useGameStore.getState().day).toBe(1);
expect(useGameStore.getState().hour).toBe(0);
// Mana store should be reset
// Note: resetMana uses 10 + prestigeUpgrades.manaStart, with no upgrades = 10
expect(useManaStore.getState().rawMana).toBeGreaterThanOrEqual(0);
// Combat store should be reset
expect(useCombatStore.getState().currentFloor).toBe(1);
// Prestige store should be reset
expect(usePrestigeStore.getState().insight).toBe(0);
expect(usePrestigeStore.getState().totalInsight).toBe(0);
});
});
describe('safeStorage getItem/setItem round-trip', () => {
it('should write and read back data correctly', () => {
const testData = { state: { day: 5, hour: 12 }, version: 1 };
localStorage.setItem('mana-loop-game-storage', JSON.stringify(testData));
const raw = localStorage.getItem('mana-loop-game-storage');
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw!);
expect(parsed.state.day).toBe(5);
expect(parsed.state.hour).toBe(12);
expect(parsed.version).toBe(1);
});
});
});
+16 -9
View File
@@ -6,17 +6,24 @@ import { useManaStore } from './manaStore';
import { useCombatStore } from './combatStore'; import { useCombatStore } from './combatStore';
import { computeDisciplineEffects } from '../effects/discipline-effects'; import { computeDisciplineEffects } from '../effects/discipline-effects';
// Exact localStorage keys matching each store's persist config `name`
const ALL_STORE_KEYS = [
'mana-loop-game-storage',
'mana-loop-mana',
'mana-loop-combat',
'mana-loop-prestige',
'mana-loop-crafting',
'mana-loop-attunements',
'mana-loop-discipline-store',
'mana-loop-ui-storage',
] as const;
export const createResetGame = (set: (state: Partial<GameCoordinatorState>) => void, initialState: GameCoordinatorState) => () => { export const createResetGame = (set: (state: Partial<GameCoordinatorState>) => void, initialState: GameCoordinatorState) => () => {
// Clear all persisted state // Clear all persisted state — must use exact keys from each store's persist config
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
localStorage.removeItem('mana-loop-ui-storage'); for (const key of ALL_STORE_KEYS) {
localStorage.removeItem('mana-loop-prestige-storage'); localStorage.removeItem(key);
localStorage.removeItem('mana-loop-mana-storage'); }
localStorage.removeItem('mana-loop-combat-storage');
localStorage.removeItem('mana-loop-game-storage');
localStorage.removeItem('mana-loop-crafting-storage');
localStorage.removeItem('mana-loop-attunement-storage');
localStorage.removeItem('mana-loop-discipline-store');
} }
const startFloor = 1; const startFloor = 1;
+11 -2
View File
@@ -1,6 +1,11 @@
// ─── Safe Persist Storage ───────────────────────────────────────────────────── // ─── Safe Persist Storage ─────────────────────────────────────────────────────
// Wraps localStorage with error handling for Zustand persist middleware. // Wraps localStorage with error handling for Zustand persist middleware.
// Handles: quota exceeded, corrupted JSON, and unexpected read/write failures. // Handles: quota exceeded, corrupted JSON, and unexpected read/write failures.
//
// Zustand persist storage contract:
// getItem(name) → returns parsed object { state, version } | null
// setItem(name, value) → receives object { state, version }, must serialize
// removeItem(name) → removes the key
import type { StateStorage } from 'zustand/middleware'; import type { StateStorage } from 'zustand/middleware';
@@ -10,14 +15,16 @@ import type { StateStorage } from 'zustand/middleware';
* - Quota exceeded → logs warning, skips write * - Quota exceeded → logs warning, skips write
* - Other errors → logs warning, graceful fallback * - Other errors → logs warning, graceful fallback
*/ */
export function createSafeStorage(): any { export function createSafeStorage(): StateStorage {
const storage: StateStorage = { const storage: StateStorage = {
getItem: (name: string): string | null | Promise<string | null> => { getItem: (name: string): string | null | Promise<string | null> => {
try { try {
const str = localStorage.getItem(name); const str = localStorage.getItem(name);
if (str === null) return null; if (str === null) return null;
return str; // Zustand expects the parsed storage envelope { state, version }
return JSON.parse(str);
} catch (error) { } catch (error) {
// Corrupted JSON or other read error — clear the bad data
try { try {
localStorage.removeItem(name); localStorage.removeItem(name);
} catch { } catch {
@@ -28,6 +35,7 @@ export function createSafeStorage(): any {
}, },
setItem: (name: string, value: unknown): void => { setItem: (name: string, value: unknown): void => {
try { try {
// Zustand passes the storage envelope { state, version } as a JS object
localStorage.setItem(name, JSON.stringify(value)); localStorage.setItem(name, JSON.stringify(value));
} catch (error) { } catch (error) {
console.error('[safe-persist] Failed to persist:', name, error); console.error('[safe-persist] Failed to persist:', name, error);
@@ -37,6 +45,7 @@ export function createSafeStorage(): any {
try { try {
localStorage.removeItem(name); localStorage.removeItem(name);
} catch (error) { } catch (error) {
// ignore
} }
}, },
}; };