import { del, get, set } from 'idb-keyval'; import type { Content } from '../content/schema'; import { createGameState, type GameState } from '../engine/game'; import { applyOfflineProgress, createSave, deserializeSave, serializeSave } from '../engine/save'; /** * Persistence adapter (IO layer — deliberately outside the pure engine). * * A `SaveBackend` is a pluggable string store; the orchestration (`loadGame`, * `saveGame`) turns it into typed game state via the engine's save module and * credits offline progress on boot. The real backend prefers IndexedDB * (idb-keyval) and falls back to localStorage, then to an in-memory store so the * game still runs (without persistence) in hostile environments. */ export const SAVE_KEY = 'idlegame:save:v1'; export interface SaveBackend { load(): Promise; save(serialized: string): Promise; clear(): Promise; } export function createMemoryBackend(initial: string | null = null): SaveBackend { let value = initial; return { load: () => Promise.resolve(value), save: (serialized) => { value = serialized; return Promise.resolve(); }, clear: () => { value = null; return Promise.resolve(); }, }; } function createLocalStorageBackend(key: string): SaveBackend { return { load: () => Promise.resolve(globalThis.localStorage.getItem(key)), save: (serialized) => { globalThis.localStorage.setItem(key, serialized); return Promise.resolve(); }, clear: () => { globalThis.localStorage.removeItem(key); return Promise.resolve(); }, }; } function createIdbBackend(key: string): SaveBackend { return { load: async () => (await get(key)) ?? null, save: (serialized) => set(key, serialized), clear: () => del(key), }; } /** Choose the best available backend for the current environment. */ export function createDefaultBackend(key: string = SAVE_KEY): SaveBackend { if (typeof indexedDB !== 'undefined') { return createIdbBackend(key); } if (typeof localStorage !== 'undefined') { return createLocalStorageBackend(key); } return createMemoryBackend(); } export interface LoadResult { state: GameState; /** Milliseconds of offline time credited on this load (0 for a fresh game). */ offlineMs: number; } /** * Load and hydrate game state, crediting offline progress. A missing or corrupt * save yields a fresh game rather than throwing — never block boot on bad data. */ export async function loadGame( content: Content, backend: SaveBackend, now: number, ): Promise { const raw = await backend.load(); if (!raw) { return { state: createGameState(content), offlineMs: 0 }; } let savedAt: number; let state: GameState; try { const save = deserializeSave(raw); const base = createGameState(content); const activeActionId = save.state.activeActionId && content.actionsById[save.state.activeActionId] ? save.state.activeActionId : null; state = { resources: { ...base.resources, ...save.state.resources }, activeActionId, actionElapsedMs: save.state.actionElapsedMs, actionQueue: [...(save.state.actionQueue ?? [])], storyFlags: { ...save.state.storyFlags }, currentStoryNodeId: save.state.currentStoryNodeId ?? '', seenStoryNodeIds: [...(save.state.seenStoryNodeIds ?? [])], }; savedAt = save.savedAt; } catch { return { state: createGameState(content), offlineMs: 0 }; } const offlineMs = applyOfflineProgress(state, content, savedAt, now); return { state, offlineMs }; } export async function saveGame(state: GameState, backend: SaveBackend, now: number): Promise { await backend.save(serializeSave(createSave(state, now))); }