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
|
# 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,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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 { 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;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user