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
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:
@@ -1,4 +1,4 @@
|
||||
# Circular Dependencies
|
||||
Generated: 2026-05-27T13:22:21.442Z
|
||||
Generated: 2026-05-27T13:57:23.187Z
|
||||
|
||||
No circular dependencies found. ✅
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"_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.",
|
||||
"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."
|
||||
},
|
||||
|
||||
@@ -219,6 +219,7 @@ Mana-Loop/
|
||||
│ │ │ ├── guardian-names.test.ts
|
||||
│ │ │ ├── mana-utils.test.ts
|
||||
│ │ │ ├── pact-utils.test.ts
|
||||
│ │ │ ├── persistence.test.ts
|
||||
│ │ │ ├── regression-fixes.test.ts
|
||||
│ │ │ ├── room-utils-floor-state.test.ts
|
||||
│ │ │ ├── room-utils.test.ts
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user