diff --git a/src/components/GameLayout.tsx b/src/components/GameLayout.tsx
index f22e944..7a00ba4 100644
--- a/src/components/GameLayout.tsx
+++ b/src/components/GameLayout.tsx
@@ -24,6 +24,7 @@ import { PetDisplay } from "./PetDisplay";
import { SettingsPanel } from "./SettingsPanel";
import { StatsBar } from "./StatsBar";
import { StatsPanel } from "./StatsPanel";
+import { StorageBanner } from "./StorageBanner";
import { UpgradesSidebar } from "./UpgradesSidebar";
export function GameLayout() {
@@ -153,6 +154,7 @@ export function GameLayout() {
onClose={() => setSettingsOpen(false)}
/>
+
{konamiVisible && (
!getStorageAvailable());
+ const [quotaError, setQuotaError] = useState(false);
+ const [dismissed, setDismissed] = useState(false);
+
+ const handleStorageError = useCallback((error: StorageError) => {
+ if (error.type === "quota-exceeded") {
+ setQuotaError(true);
+ setDismissed(false); // Re-show on new quota errors
+ }
+ }, []);
+
+ useEffect(() => {
+ setStorageErrorHandler(handleStorageError);
+ }, [handleStorageError]);
+
+ if (dismissed) return null;
+
+ if (storageUnavailable) {
+ return (
+
setDismissed(true)}
+ style={{
+ position: "fixed",
+ bottom: 16,
+ left: "50%",
+ transform: "translateX(-50%)",
+ zIndex: 9999,
+ maxWidth: 480,
+ fontFamily: "monospace",
+ }}
+ data-testid="storage-banner"
+ >
+
+ localStorage is unavailable. Your progress will not be saved between
+ sessions. Use "Copy Save to Clipboard" in Settings to back up
+ manually.
+
+
+ );
+ }
+
+ if (quotaError) {
+ return (
+
setDismissed(true)}
+ style={{
+ position: "fixed",
+ bottom: 16,
+ left: "50%",
+ transform: "translateX(-50%)",
+ zIndex: 9999,
+ maxWidth: 480,
+ fontFamily: "monospace",
+ }}
+ data-testid="storage-banner"
+ >
+
+ Storage is full! Your latest progress may not be saved. Export your
+ save from Settings or clear other site data to free space.
+
+
+ );
+ }
+
+ return null;
+}
diff --git a/src/components/index.ts b/src/components/index.ts
index 3468847..21ba58a 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -12,4 +12,5 @@ export { SettingsPanel } from "./SettingsPanel";
export { SpeechBubble } from "./SpeechBubble";
export { StatsBar } from "./StatsBar";
export { StatsPanel } from "./StatsPanel";
+export { StorageBanner } from "./StorageBanner";
export { UpgradesSidebar } from "./UpgradesSidebar";
diff --git a/src/store/dailyStore.ts b/src/store/dailyStore.ts
index 93b5033..ef3833e 100644
--- a/src/store/dailyStore.ts
+++ b/src/store/dailyStore.ts
@@ -1,6 +1,7 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { getCurrentDateUTC } from "../engine/dailyObjectivesEngine";
+import { safeStorage } from "../utils/safeStorage";
// ── State ─────────────────────────────────────────────────────────────────────
@@ -93,6 +94,6 @@ export const useDailyStore = create
()(
};
}),
}),
- { name: "glorp-daily" },
+ { name: "glorp-daily", storage: safeStorage },
),
);
diff --git a/src/store/gameStore.ts b/src/store/gameStore.ts
index 4627cf8..ed5e22c 100644
--- a/src/store/gameStore.ts
+++ b/src/store/gameStore.ts
@@ -31,6 +31,7 @@ import {
getUpgradeCost,
} from "../engine/upgradeEngine";
import { D, Decimal, toDecimal } from "../utils/decimal";
+import { safeStorage } from "../utils/safeStorage";
export interface GameState {
trainingData: Decimal;
@@ -494,35 +495,46 @@ export const useGameStore = create()(
}),
{
name: "glorp-game-state",
+ storage: safeStorage,
merge: (persisted, current) => {
- const saved = persisted as Partial> | undefined;
- if (!saved) return current;
+ try {
+ const saved = persisted as
+ | Partial>
+ | undefined;
+ if (!saved) return current;
- // Convert Decimal fields from persisted strings/numbers back to Decimal
- const merged = { ...current, ...(saved as Partial) };
- for (const key of DECIMAL_KEYS) {
- const val = saved[key];
- if (val !== undefined) {
- (merged as Record)[key] = toDecimal(
- val as string | number | null,
- );
+ // Convert Decimal fields from persisted strings/numbers back to Decimal
+ const merged = { ...current, ...(saved as Partial) };
+ for (const key of DECIMAL_KEYS) {
+ const val = saved[key];
+ if (val !== undefined) {
+ (merged as Record)[key] = toDecimal(
+ val as string | number | null,
+ );
+ }
}
- }
- // Migrate old saves: convert wisdomTokens to spendable balance
- if (saved.prestigeUpgrades === undefined) {
- merged.prestigeUpgrades = {};
- }
- if (saved.prestigeTokenBalance === undefined) {
- merged.prestigeTokenBalance = (saved.wisdomTokens as number) ?? 0;
- }
- if (saved.hasOpenedPrestigeShop === undefined) {
- merged.hasOpenedPrestigeShop = false;
- }
- if (saved.runStart === undefined) {
- merged.runStart = Date.now();
+ // Migrate old saves: convert wisdomTokens to spendable balance
+ if (saved.prestigeUpgrades === undefined) {
+ merged.prestigeUpgrades = {};
+ }
+ if (saved.prestigeTokenBalance === undefined) {
+ merged.prestigeTokenBalance = (saved.wisdomTokens as number) ?? 0;
+ }
+ if (saved.hasOpenedPrestigeShop === undefined) {
+ merged.hasOpenedPrestigeShop = false;
+ }
+ if (saved.runStart === undefined) {
+ merged.runStart = Date.now();
+ }
+ return merged;
+ } catch (e) {
+ console.error(
+ "[gameStore] Failed to merge persisted state, falling back to defaults:",
+ e,
+ );
+ return current;
}
- return merged;
},
},
),
diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts
index 1e53164..bbb37f7 100644
--- a/src/store/settingsStore.ts
+++ b/src/store/settingsStore.ts
@@ -1,5 +1,6 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
+import { safeStorage } from "../utils/safeStorage";
export type BuyMode = 1 | 10 | 100 | "max";
@@ -42,6 +43,6 @@ export const useSettingsStore = create()(
setNumberFormat: (numberFormat) => set({ numberFormat }),
setSoundEnabled: (soundEnabled) => set({ soundEnabled }),
}),
- { name: "glorp-settings" },
+ { name: "glorp-settings", storage: safeStorage },
),
);
diff --git a/src/utils/safeStorage.test.ts b/src/utils/safeStorage.test.ts
new file mode 100644
index 0000000..60cdcde
--- /dev/null
+++ b/src/utils/safeStorage.test.ts
@@ -0,0 +1,233 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ getStorageAvailable,
+ isLocalStorageAvailable,
+ resetStorageAvailableCache,
+ type StorageError,
+ type StorageErrorHandler,
+ safeGetItem,
+ safeRemoveItem,
+ safeSetItem,
+ safeStateStorage,
+ setStorageErrorHandler,
+} from "./safeStorage";
+
+beforeEach(() => {
+ localStorage.clear();
+ resetStorageAvailableCache();
+ setStorageErrorHandler(null as unknown as StorageErrorHandler);
+ vi.restoreAllMocks();
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("isLocalStorageAvailable", () => {
+ it("returns true when localStorage works normally", () => {
+ expect(isLocalStorageAvailable()).toBe(true);
+ });
+
+ it("returns false when localStorage.setItem throws", () => {
+ const origSetItem = localStorage.setItem.bind(localStorage);
+ localStorage.setItem = () => {
+ throw new Error("SecurityError");
+ };
+ expect(isLocalStorageAvailable()).toBe(false);
+ localStorage.setItem = origSetItem;
+ });
+});
+
+describe("getStorageAvailable", () => {
+ it("caches the result after first call", () => {
+ const origSetItem = localStorage.setItem.bind(localStorage);
+ let callCount = 0;
+ localStorage.setItem = (...args: [string, string]) => {
+ callCount++;
+ return origSetItem(...args);
+ };
+ getStorageAvailable();
+ const firstCallCount = callCount;
+ getStorageAvailable();
+ // Second call should not call setItem again (cached)
+ expect(callCount).toBe(firstCallCount);
+ localStorage.setItem = origSetItem;
+ });
+
+ it("returns false when storage is unavailable", () => {
+ const origSetItem = localStorage.setItem.bind(localStorage);
+ localStorage.setItem = () => {
+ throw new Error("SecurityError");
+ };
+ expect(getStorageAvailable()).toBe(false);
+ localStorage.setItem = origSetItem;
+ });
+});
+
+describe("safeGetItem", () => {
+ it("returns the stored value", () => {
+ localStorage.setItem("test-key", "test-value");
+ expect(safeGetItem("test-key")).toBe("test-value");
+ });
+
+ it("returns null for missing keys", () => {
+ expect(safeGetItem("nonexistent")).toBeNull();
+ });
+
+ it("returns null and logs error when getItem throws", () => {
+ // Ensure storage is detected as available
+ getStorageAvailable();
+ const origGetItem = localStorage.getItem.bind(localStorage);
+ localStorage.getItem = () => {
+ throw new Error("read failure");
+ };
+ const handler = vi.fn();
+ setStorageErrorHandler(handler);
+ expect(safeGetItem("test-key")).toBeNull();
+ expect(handler).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "read-error" }),
+ );
+ localStorage.getItem = origGetItem;
+ });
+
+ it("returns null when storage is unavailable without throwing", () => {
+ const origSetItem = localStorage.setItem.bind(localStorage);
+ localStorage.setItem = () => {
+ throw new Error("SecurityError");
+ };
+ resetStorageAvailableCache();
+ expect(safeGetItem("test-key")).toBeNull();
+ localStorage.setItem = origSetItem;
+ });
+});
+
+describe("safeSetItem", () => {
+ it("returns true on successful write", () => {
+ expect(safeSetItem("key", "value")).toBe(true);
+ expect(localStorage.getItem("key")).toBe("value");
+ });
+
+ it("returns false and notifies on QuotaExceededError", () => {
+ // Ensure storage is detected as available first
+ getStorageAvailable();
+ const origSetItem = localStorage.setItem.bind(localStorage);
+ const quotaError = new DOMException("Quota exceeded", "QuotaExceededError");
+ localStorage.setItem = () => {
+ throw quotaError;
+ };
+ const handler = vi.fn();
+ setStorageErrorHandler(handler);
+ expect(safeSetItem("game-state", "data")).toBe(false);
+ expect(handler).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "quota-exceeded" }),
+ );
+ localStorage.setItem = origSetItem;
+ });
+
+ it("returns false and notifies on generic write error", () => {
+ getStorageAvailable();
+ const origSetItem = localStorage.setItem.bind(localStorage);
+ localStorage.setItem = () => {
+ throw new Error("write failure");
+ };
+ const handler = vi.fn();
+ setStorageErrorHandler(handler);
+ expect(safeSetItem("game-state", "data")).toBe(false);
+ expect(handler).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "write-error" }),
+ );
+ localStorage.setItem = origSetItem;
+ });
+
+ it("returns false without throwing when storage is unavailable", () => {
+ const origSetItem = localStorage.setItem.bind(localStorage);
+ localStorage.setItem = () => {
+ throw new Error("SecurityError");
+ };
+ resetStorageAvailableCache();
+ expect(safeSetItem("key", "value")).toBe(false);
+ localStorage.setItem = origSetItem;
+ });
+});
+
+describe("safeRemoveItem", () => {
+ it("removes the item from storage", () => {
+ localStorage.setItem("to-remove", "value");
+ safeRemoveItem("to-remove");
+ expect(localStorage.getItem("to-remove")).toBeNull();
+ });
+
+ it("does not throw when storage is unavailable", () => {
+ const origSetItem = localStorage.setItem.bind(localStorage);
+ localStorage.setItem = () => {
+ throw new Error("SecurityError");
+ };
+ resetStorageAvailableCache();
+ expect(() => safeRemoveItem("key")).not.toThrow();
+ localStorage.setItem = origSetItem;
+ });
+
+ it("notifies handler on removeItem error", () => {
+ getStorageAvailable();
+ const origRemoveItem = localStorage.removeItem.bind(localStorage);
+ localStorage.removeItem = () => {
+ throw new Error("remove failure");
+ };
+ const handler = vi.fn();
+ setStorageErrorHandler(handler);
+ safeRemoveItem("key");
+ expect(handler).toHaveBeenCalledWith(
+ expect.objectContaining({ type: "write-error" }),
+ );
+ localStorage.removeItem = origRemoveItem;
+ });
+});
+
+describe("safeStateStorage (Zustand StateStorage adapter)", () => {
+ it("implements getItem, setItem, removeItem", () => {
+ expect(typeof safeStateStorage.getItem).toBe("function");
+ expect(typeof safeStateStorage.setItem).toBe("function");
+ expect(typeof safeStateStorage.removeItem).toBe("function");
+ });
+
+ it("round-trips a value", () => {
+ safeStateStorage.setItem("zustand-key", '{"state":{"count":1}}');
+ expect(safeStateStorage.getItem("zustand-key")).toBe(
+ '{"state":{"count":1}}',
+ );
+ });
+
+ it("returns null after removeItem", () => {
+ safeStateStorage.setItem("zustand-key", "data");
+ safeStateStorage.removeItem("zustand-key");
+ expect(safeStateStorage.getItem("zustand-key")).toBeNull();
+ });
+});
+
+describe("error handler registration", () => {
+ it("calls the registered handler on errors", () => {
+ const errors: StorageError[] = [];
+ setStorageErrorHandler((e: StorageError) => errors.push(e));
+ getStorageAvailable();
+ const origGetItem = localStorage.getItem.bind(localStorage);
+ localStorage.getItem = () => {
+ throw new Error("fail");
+ };
+ safeGetItem("key");
+ expect(errors).toHaveLength(1);
+ expect(errors[0].type).toBe("read-error");
+ localStorage.getItem = origGetItem;
+ });
+
+ it("handles null handler gracefully", () => {
+ setStorageErrorHandler(null as unknown as StorageErrorHandler);
+ getStorageAvailable();
+ const origGetItem = localStorage.getItem.bind(localStorage);
+ localStorage.getItem = () => {
+ throw new Error("fail");
+ };
+ expect(() => safeGetItem("key")).not.toThrow();
+ localStorage.getItem = origGetItem;
+ });
+});
diff --git a/src/utils/safeStorage.ts b/src/utils/safeStorage.ts
new file mode 100644
index 0000000..348431b
--- /dev/null
+++ b/src/utils/safeStorage.ts
@@ -0,0 +1,159 @@
+/**
+ * Safe localStorage wrapper that handles all edge cases:
+ * - localStorage completely unavailable (private browsing, iframe sandbox)
+ * - QuotaExceededError on writes
+ * - Corrupted data on reads (invalid JSON)
+ *
+ * Provides a Zustand-compatible StateStorage adapter and a reactive
+ * "storage available" flag for the UI banner.
+ */
+import { createJSONStorage, type StateStorage } from "zustand/middleware";
+
+/** Callback signature for storage error notifications. */
+export type StorageErrorHandler = (error: StorageError) => void;
+
+export interface StorageError {
+ type: "quota-exceeded" | "read-error" | "write-error" | "unavailable";
+ message: string;
+ originalError?: unknown;
+}
+
+let errorHandler: StorageErrorHandler | null = null;
+
+/** Register a handler that is called when storage errors occur. */
+export function setStorageErrorHandler(handler: StorageErrorHandler): void {
+ errorHandler = handler;
+}
+
+function notifyError(error: StorageError): void {
+ console.error(
+ `[safeStorage] ${error.type}: ${error.message}`,
+ error.originalError,
+ );
+ errorHandler?.(error);
+}
+
+/**
+ * Detect whether localStorage is available and functional.
+ * Returns false if the browser blocks access entirely or throws on use.
+ */
+export function isLocalStorageAvailable(): boolean {
+ const testKey = "__glorp_storage_test__";
+ try {
+ localStorage.setItem(testKey, "1");
+ localStorage.removeItem(testKey);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+/** Cached availability flag, computed once at module load. */
+let storageAvailable: boolean | null = null;
+
+/** Returns whether localStorage is available (cached after first call). */
+export function getStorageAvailable(): boolean {
+ if (storageAvailable === null) {
+ storageAvailable = isLocalStorageAvailable();
+ }
+ return storageAvailable;
+}
+
+/**
+ * Reset the cached availability flag. Only used in tests.
+ * @internal
+ */
+export function resetStorageAvailableCache(): void {
+ storageAvailable = null;
+}
+
+/**
+ * Safe wrapper around localStorage.getItem.
+ * Returns null if storage is unavailable or the key doesn't exist.
+ */
+export function safeGetItem(key: string): string | null {
+ if (!getStorageAvailable()) return null;
+ try {
+ return localStorage.getItem(key);
+ } catch (e) {
+ notifyError({
+ type: "read-error",
+ message: `Failed to read key "${key}" from localStorage`,
+ originalError: e,
+ });
+ return null;
+ }
+}
+
+/**
+ * Safe wrapper around localStorage.setItem.
+ * Catches QuotaExceededError and other write failures.
+ * Returns true if the write succeeded, false otherwise.
+ */
+export function safeSetItem(key: string, value: string): boolean {
+ if (!getStorageAvailable()) return false;
+ try {
+ localStorage.setItem(key, value);
+ return true;
+ } catch (e) {
+ if (e instanceof DOMException && e.name === "QuotaExceededError") {
+ notifyError({
+ type: "quota-exceeded",
+ message:
+ "localStorage is full. Your progress may not be saved. Try exporting your save or clearing browser data.",
+ originalError: e,
+ });
+ } else {
+ notifyError({
+ type: "write-error",
+ message: `Failed to write key "${key}" to localStorage`,
+ originalError: e,
+ });
+ }
+ return false;
+ }
+}
+
+/**
+ * Safe wrapper around localStorage.removeItem.
+ * Silently handles errors — removal failure is non-critical.
+ */
+export function safeRemoveItem(key: string): void {
+ if (!getStorageAvailable()) return;
+ try {
+ localStorage.removeItem(key);
+ } catch (e) {
+ notifyError({
+ type: "write-error",
+ message: `Failed to remove key "${key}" from localStorage`,
+ originalError: e,
+ });
+ }
+}
+
+/**
+ * Raw StateStorage implementation using the safe wrappers.
+ */
+const safeStateStorage: StateStorage = {
+ getItem: (name: string): string | null => {
+ return safeGetItem(name);
+ },
+ setItem: (name: string, value: string): void => {
+ safeSetItem(name, value);
+ },
+ removeItem: (name: string): void => {
+ safeRemoveItem(name);
+ },
+};
+
+/**
+ * Zustand-compatible PersistStorage created via createJSONStorage.
+ * Use this as the `storage` option in Zustand persist config.
+ */
+export const safeStorage = createJSONStorage(() => safeStateStorage);
+
+/**
+ * Re-export the raw StateStorage for testing.
+ * @internal
+ */
+export { safeStateStorage };
diff --git a/src/utils/saveManager.test.ts b/src/utils/saveManager.test.ts
index 4f96c94..2cac22a 100644
--- a/src/utils/saveManager.test.ts
+++ b/src/utils/saveManager.test.ts
@@ -125,6 +125,30 @@ describe("resetGame", () => {
resetGame();
expect(useGameStore.getState().rebirthCount).toBe(0);
});
+
+ it("clears stale localStorage save keys before re-persisting", () => {
+ // Put corrupt data in all three keys
+ localStorage.setItem("glorp-game-state", "CORRUPT");
+ localStorage.setItem("glorp-settings", "CORRUPT");
+ localStorage.setItem("glorp-daily", "CORRUPT");
+ resetGame();
+ // Settings and daily should be cleared (game store re-persists fresh state)
+ expect(localStorage.getItem("glorp-settings")).toBeNull();
+ expect(localStorage.getItem("glorp-daily")).toBeNull();
+ // Game state key should be re-written with fresh initial state, not corrupt
+ const stored = localStorage.getItem("glorp-game-state");
+ expect(stored).not.toBe("CORRUPT");
+ });
+
+ it("succeeds even when localStorage throws", () => {
+ useGameStore.setState({ trainingData: D(9999) });
+ vi.spyOn(Storage.prototype, "removeItem").mockImplementation(() => {
+ throw new Error("storage unavailable");
+ });
+ // Should not throw — falls back gracefully
+ resetGame();
+ expect(useGameStore.getState().trainingData.toNumber()).toBe(0);
+ });
});
describe("exportSave", () => {
diff --git a/src/utils/saveManager.ts b/src/utils/saveManager.ts
index a27d8e6..a07635d 100644
--- a/src/utils/saveManager.ts
+++ b/src/utils/saveManager.ts
@@ -130,7 +130,21 @@ export function applySave(save: GameState): void {
useGameStore.setState(migrateSave(save));
}
+/**
+ * Hard-reset the game to initial state.
+ * Defensive: clears localStorage keys directly, then resets the store.
+ * Succeeds even when localStorage is corrupt or throws on access.
+ */
export function resetGame(): void {
+ // Best-effort: wipe the persisted save keys so a corrupt save
+ // cannot re-hydrate on next load.
+ for (const key of ["glorp-game-state", "glorp-settings", "glorp-daily"]) {
+ try {
+ localStorage.removeItem(key);
+ } catch {
+ // Storage may be unavailable — that's fine, just reset in-memory.
+ }
+ }
useGameStore.setState({ ...initialGameState, lastSaved: Date.now() });
}