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
+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 { 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) => () => {
// Clear all persisted state
// Clear all persisted state — must use exact keys from each store's persist config
if (typeof window !== 'undefined') {
localStorage.removeItem('mana-loop-ui-storage');
localStorage.removeItem('mana-loop-prestige-storage');
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');
for (const key of ALL_STORE_KEYS) {
localStorage.removeItem(key);
}
}
const startFloor = 1;
+11 -2
View File
@@ -1,6 +1,11 @@
// ─── Safe Persist Storage ─────────────────────────────────────────────────────
// Wraps localStorage with error handling for Zustand persist middleware.
// 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';
@@ -10,14 +15,16 @@ import type { StateStorage } from 'zustand/middleware';
* - Quota exceeded → logs warning, skips write
* - Other errors → logs warning, graceful fallback
*/
export function createSafeStorage(): any {
export function createSafeStorage(): StateStorage {
const storage: StateStorage = {
getItem: (name: string): string | null | Promise<string | null> => {
try {
const str = localStorage.getItem(name);
if (str === null) return null;
return str;
// Zustand expects the parsed storage envelope { state, version }
return JSON.parse(str);
} catch (error) {
// Corrupted JSON or other read error — clear the bad data
try {
localStorage.removeItem(name);
} catch {
@@ -28,6 +35,7 @@ export function createSafeStorage(): any {
},
setItem: (name: string, value: unknown): void => {
try {
// Zustand passes the storage envelope { state, version } as a JS object
localStorage.setItem(name, JSON.stringify(value));
} catch (error) {
console.error('[safe-persist] Failed to persist:', name, error);
@@ -37,6 +45,7 @@ export function createSafeStorage(): any {
try {
localStorage.removeItem(name);
} catch (error) {
// ignore
}
},
};