122 lines
3.7 KiB
TypeScript
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)));
|
|
}
|