Files
idlegame/src/state/persistence.ts
T

122 lines
3.7 KiB
TypeScript

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<string | null>;
save(serialized: string): Promise<void>;
clear(): Promise<void>;
}
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<string>(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<LoadResult> {
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<void> {
await backend.save(serializeSave(createSave(state, now)));
}