diff --git a/docs/RouteEngine.md b/docs/RouteEngine.md index e475a21c..849113b5 100644 --- a/docs/RouteEngine.md +++ b/docs/RouteEngine.md @@ -27,12 +27,13 @@ const engine = createRouteEngine({ ## Initialization -### `init({ initialState })` +### `init({ initialState, namespace })` Initializes the engine with project data and global settings. ```js engine.init({ + namespace: "my-visual-novel", initialState: { projectData: { resources: { @@ -59,6 +60,11 @@ engine.init({ }); ``` +For browser-backed save/load hydration, the runtime also exports +`createIndexedDbPersistence({ namespace })`. Use the same `namespace` both when +loading persisted data before init and when calling `engine.init(...)` so +different visual novels on the same domain do not share persistence. + Localization is not implemented in the current runtime. The planned patch-based l10n model is documented in [L10n.md](./L10n.md). diff --git a/docs/SaveLoad.md b/docs/SaveLoad.md index 62772e19..1c70c457 100644 --- a/docs/SaveLoad.md +++ b/docs/SaveLoad.md @@ -235,12 +235,12 @@ Current behavior: - `saveSlot` mutates `state.global.saveSlots` - then it emits a `saveSlots` effect -- the effect handler persists the full slot map to `localStorage` +- the built-in browser persistence helper persists the full slot map to IndexedDB Load is different: - `loadSlot` only restores in-memory engine state from `state.global.saveSlots` -- it does not read `localStorage` itself +- it does not read IndexedDB itself ### Dynamic Slot Selection @@ -389,6 +389,7 @@ The host app is responsible for: - hydrating `initialState.global.saveSlots` from durable storage before engine init - hydrating persistent global variables before engine init +- choosing and reusing a per-VN `namespace` during persistence hydration and `engine.init(...)` - providing thumbnail image payloads when a save action wants one - mapping dynamic UI/event data into the action `slotId` field when save/load is triggered from generated UI - executing storage effects emitted by the engine @@ -466,7 +467,7 @@ Current save flow: The store writes to the in-memory slot map first. -Persistence to `localStorage` happens later through the effect handler. +Persistence to IndexedDB happens later through the effect handler. ### Load Flow diff --git a/spec/indexedDbPersistence.test.js b/spec/indexedDbPersistence.test.js new file mode 100644 index 00000000..427b6000 --- /dev/null +++ b/spec/indexedDbPersistence.test.js @@ -0,0 +1,327 @@ +import { describe, expect, it, vi } from "vitest"; +import createEffectsHandler from "../src/createEffectsHandler.js"; +import createRouteEngine from "../src/RouteEngine.js"; +import { + createIndexedDbPersistence, + normalizeNamespace, +} from "../src/indexedDbPersistence.js"; + +const createTicker = () => ({ + add: vi.fn(), + remove: vi.fn(), +}); + +const cloneValue = (value) => { + if (value === undefined) { + return undefined; + } + + if (typeof structuredClone === "function") { + return structuredClone(value); + } + + return JSON.parse(JSON.stringify(value)); +}; + +class FakeRequest { + constructor() { + this.result = undefined; + this.error = null; + this.onsuccess = null; + this.onerror = null; + this.onupgradeneeded = null; + } +} + +class FakeObjectStore { + constructor(transaction, definition) { + this.transaction = transaction; + this.definition = definition; + } + + get(key) { + const request = new FakeRequest(); + + this.transaction.track(() => { + request.result = cloneValue(this.definition.records.get(key)); + request.onsuccess?.({ target: request }); + }); + + return request; + } + + put(value) { + const request = new FakeRequest(); + + this.transaction.track(() => { + const record = cloneValue(value); + const key = record[this.definition.keyPath]; + this.definition.records.set(key, record); + request.result = key; + request.onsuccess?.({ target: request }); + }); + + return request; + } +} + +class FakeTransaction { + constructor(database) { + this.database = database; + this.error = null; + this.oncomplete = null; + this.onerror = null; + this.onabort = null; + this.pendingCount = 0; + this.failed = false; + } + + objectStore(name) { + const definition = this.database.stores.get(name); + if (!definition) { + throw new Error(`Object store "${name}" does not exist.`); + } + + return new FakeObjectStore(this, definition); + } + + track(run) { + this.pendingCount += 1; + + queueMicrotask(() => { + if (this.failed) { + return; + } + + try { + run(); + } catch (error) { + this.failed = true; + this.error = error; + this.onerror?.({ target: this }); + this.onabort?.({ target: this }); + } finally { + this.pendingCount -= 1; + if (!this.failed && this.pendingCount === 0) { + queueMicrotask(() => { + if (!this.failed) { + this.oncomplete?.({ target: this }); + } + }); + } + } + }); + } +} + +class FakeDatabase { + constructor(name, version) { + this.name = name; + this.version = version; + this.stores = new Map(); + this.objectStoreNames = { + contains: (storeName) => this.stores.has(storeName), + }; + } + + createObjectStore(name, options = {}) { + this.stores.set(name, { + keyPath: options.keyPath, + records: new Map(), + }); + } + + transaction() { + return new FakeTransaction(this); + } +} + +const createFakeIndexedDB = () => { + const databases = new Map(); + + return { + open: (name, version) => { + const request = new FakeRequest(); + + queueMicrotask(() => { + let database = databases.get(name); + const shouldUpgrade = !database; + + if (!database) { + database = new FakeDatabase(name, version); + databases.set(name, database); + } + + request.result = database; + + if (shouldUpgrade) { + request.onupgradeneeded?.({ target: request }); + } + + request.onsuccess?.({ target: request }); + }); + + return request; + }, + }; +}; + +const flushAsync = async () => { + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); +}; + +const createProjectData = () => ({ + screen: { + width: 1920, + height: 1080, + }, + resources: {}, + story: { + initialSceneId: "scene1", + scenes: { + scene1: { + initialSectionId: "section1", + sections: { + section1: { + lines: [ + { + id: "line1", + actions: {}, + }, + ], + }, + }, + }, + }, + }, +}); + +describe("indexedDbPersistence", () => { + it("stores the init-time namespace on the engine", () => { + const engine = createRouteEngine({ + handlePendingEffects: vi.fn(), + }); + + engine.init({ + namespace: "sample-vn", + initialState: { + projectData: createProjectData(), + }, + }); + + expect(engine.getNamespace()).toBe("sample-vn"); + }); + + it("stores browser persistence in isolated namespaces", async () => { + const indexedDB = createFakeIndexedDB(); + const alphaPersistence = createIndexedDbPersistence({ + indexedDB, + namespace: "vn-alpha", + }); + const betaPersistence = createIndexedDbPersistence({ + indexedDB, + namespace: "vn-beta", + }); + + await alphaPersistence.saveSlots({ + 1: { + slotId: 1, + savedAt: 1700000000000, + }, + }); + await alphaPersistence.saveGlobalDeviceVariables({ + textSpeed: 42, + }); + + expect(await alphaPersistence.load()).toEqual({ + saveSlots: { + 1: { + slotId: 1, + savedAt: 1700000000000, + }, + }, + globalDeviceVariables: { + textSpeed: 42, + }, + globalAccountVariables: {}, + }); + + expect(await betaPersistence.load()).toEqual({ + saveSlots: {}, + globalDeviceVariables: {}, + globalAccountVariables: {}, + }); + }); + + it("requires an explicit namespace", () => { + expect(() => + createIndexedDbPersistence({ indexedDB: createFakeIndexedDB() }), + ).toThrowError( + "createIndexedDbPersistence requires a non-empty namespace.", + ); + }); + + it("normalizes namespace values", () => { + expect(normalizeNamespace(" sample-vn ")).toBe("sample-vn"); + expect(normalizeNamespace("")).toBeNull(); + expect(normalizeNamespace(" ")).toBeNull(); + }); + + it("uses IndexedDB persistence effects with the current namespace", async () => { + const indexedDB = createFakeIndexedDB(); + const engine = { + getNamespace: vi.fn(() => "effect-handler-vn"), + }; + const effectsHandler = createEffectsHandler({ + getEngine: () => engine, + indexedDB, + routeGraphics: { + render: vi.fn(), + }, + ticker: createTicker(), + }); + + effectsHandler([ + { + name: "saveSlots", + payload: { + saveSlots: { + 7: { + slotId: 7, + savedAt: 1700000000007, + }, + }, + }, + }, + { + name: "saveGlobalAccountVariables", + payload: { + globalAccountVariables: { + unlockedChapter: 3, + }, + }, + }, + ]); + + await flushAsync(); + + const persistence = createIndexedDbPersistence({ + indexedDB, + namespace: "effect-handler-vn", + }); + + expect(await persistence.load()).toEqual({ + saveSlots: { + 7: { + slotId: 7, + savedAt: 1700000000007, + }, + }, + globalDeviceVariables: {}, + globalAccountVariables: { + unlockedChapter: 3, + }, + }); + }); +}); diff --git a/src/RouteEngine.js b/src/RouteEngine.js index 5539b3dc..f78f9380 100644 --- a/src/RouteEngine.js +++ b/src/RouteEngine.js @@ -1,4 +1,5 @@ import { createSystemStore } from "./stores/system.store.js"; +import { normalizeNamespace } from "./indexedDbPersistence.js"; import { processActionTemplates } from "./util.js"; /** @@ -7,6 +8,7 @@ import { processActionTemplates } from "./util.js"; export default function createRouteEngine(options) { let _systemStore; let _renderSequence = 0; + let _namespace = null; const { handlePendingEffects } = options; @@ -26,13 +28,18 @@ export default function createRouteEngine(options) { } }; - const init = ({ initialState }) => { + const init = ({ initialState, namespace }) => { _systemStore = createSystemStore(initialState); _renderSequence = 0; + _namespace = normalizeNamespace(namespace); _systemStore.appendPendingEffect({ name: "handleLineActions" }); processEffectsUntilEmpty(); }; + const getNamespace = () => { + return _namespace; + }; + const selectPresentationState = () => { return _systemStore.selectPresentationState(); }; @@ -152,5 +159,6 @@ export default function createRouteEngine(options) { selectSaveSlotPage, selectSaveSlots: selectSaveSlotMap, handleLineActions, + getNamespace, }; } diff --git a/src/createEffectsHandler.js b/src/createEffectsHandler.js index b757d974..f028cf20 100644 --- a/src/createEffectsHandler.js +++ b/src/createEffectsHandler.js @@ -1,3 +1,5 @@ +import { createIndexedDbPersistence } from "./indexedDbPersistence.js"; + const createTimerState = () => { let elapsed = 0; let callback = null; @@ -164,21 +166,21 @@ const clearNextLineConfigTimer = ( nextLineConfigTimerState.setElapsed(0); }; -const saveSlots = ({}, payload) => { - localStorage.setItem("saveSlots", JSON.stringify(payload.saveSlots)); +const saveSlots = ({ enqueuePersistenceWrite }, payload) => { + enqueuePersistenceWrite((persistence) => + persistence.saveSlots(payload?.saveSlots), + ); }; -const saveGlobalDeviceVariables = ({}, payload) => { - localStorage.setItem( - "globalDeviceVariables", - JSON.stringify(payload.globalDeviceVariables), +const saveGlobalDeviceVariables = ({ enqueuePersistenceWrite }, payload) => { + enqueuePersistenceWrite((persistence) => + persistence.saveGlobalDeviceVariables(payload?.globalDeviceVariables), ); }; -const saveGlobalAccountVariables = ({}, payload) => { - localStorage.setItem( - "globalAccountVariables", - JSON.stringify(payload.globalAccountVariables), +const saveGlobalAccountVariables = ({ enqueuePersistenceWrite }, payload) => { + enqueuePersistenceWrite((persistence) => + persistence.saveGlobalAccountVariables(payload?.globalAccountVariables), ); }; @@ -247,15 +249,55 @@ const createEffectsHandler = ({ routeGraphics, ticker, handleUnhandledEffect, + handlePersistenceError, + indexedDB, + persistence: providedPersistence, + namespace, }) => { const autoTimer = createTimerState(); const skipTimer = createTimerState(); const nextLineConfigTimerState = createTimerState(); + let persistence = providedPersistence ?? null; + let persistenceWriteQueue = Promise.resolve(); let latestRenderId = null; let lastHandledRenderCompleteId = null; let handledIdlessRenderComplete = false; let renderDispatchCount = 0; + const reportPersistenceError = (error) => { + if (handlePersistenceError) { + handlePersistenceError(error); + return; + } + + console.error("RouteEngine persistence write failed.", error); + }; + + const getPersistence = () => { + if (persistence) { + return persistence; + } + + const engine = getEngine(); + persistence = createIndexedDbPersistence({ + indexedDB, + namespace: namespace ?? engine?.getNamespace?.(), + }); + return persistence; + }; + + const enqueuePersistenceWrite = (write) => { + persistenceWriteQueue = persistenceWriteQueue + .catch(() => undefined) + .then(() => { + const persistenceAdapter = getPersistence(); + return write(persistenceAdapter); + }) + .catch((error) => { + reportPersistenceError(error); + }); + }; + const trackRenderDispatch = (renderState) => { const renderId = typeof renderState?.id === "string" && renderState.id.length > 0 @@ -362,6 +404,7 @@ const createEffectsHandler = ({ nextLineConfigTimerState, trackRenderDispatch, getRenderDispatchCount, + enqueuePersistenceWrite, }; for (const effect of normalizedEffects) { diff --git a/src/index.js b/src/index.js index f0011dfa..729ac199 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,9 @@ import createRouteEngine from "./RouteEngine"; import createEffectsHandler from "./createEffectsHandler"; +import createIndexedDbPersistence from "./indexedDbPersistence.js"; import { resolveLayoutReferences } from "./resolveLayoutReferences.js"; export default createRouteEngine; export { createEffectsHandler }; +export { createIndexedDbPersistence }; export { resolveLayoutReferences }; diff --git a/src/indexedDbPersistence.js b/src/indexedDbPersistence.js new file mode 100644 index 00000000..1970409c --- /dev/null +++ b/src/indexedDbPersistence.js @@ -0,0 +1,263 @@ +const DEFAULT_DATABASE_NAME = "route-engine"; +const DEFAULT_OBJECT_STORE_NAME = "projectPersistence"; +const PERSISTENCE_RECORD_VERSION = 1; + +const createEmptyPersistedState = () => ({ + saveSlots: {}, + globalDeviceVariables: {}, + globalAccountVariables: {}, +}); + +const isPlainObject = (value) => + Object.prototype.toString.call(value) === "[object Object]"; + +const normalizePersistedState = (value = {}) => { + const normalizedValue = isPlainObject(value) ? value : {}; + + return { + saveSlots: isPlainObject(normalizedValue.saveSlots) + ? normalizedValue.saveSlots + : {}, + globalDeviceVariables: isPlainObject(normalizedValue.globalDeviceVariables) + ? normalizedValue.globalDeviceVariables + : {}, + globalAccountVariables: isPlainObject( + normalizedValue.globalAccountVariables, + ) + ? normalizedValue.globalAccountVariables + : {}, + }; +}; + +export const normalizeNamespace = (value) => { + if (typeof value !== "string") { + return null; + } + + const trimmedValue = value.trim(); + return trimmedValue.length > 0 ? trimmedValue : null; +}; + +const requireNamespace = (namespace) => { + const normalizedNamespace = normalizeNamespace(namespace); + if (!normalizedNamespace) { + throw new Error( + "createIndexedDbPersistence requires a non-empty namespace.", + ); + } + + return normalizedNamespace; +}; + +const resolveIndexedDb = (indexedDBOverride) => { + const indexedDBInstance = indexedDBOverride ?? globalThis.indexedDB; + if (!indexedDBInstance) { + throw new Error("IndexedDB is not available in this environment."); + } + + return indexedDBInstance; +}; + +const openDatabase = ({ indexedDB, databaseName, objectStoreName }) => + new Promise((resolve, reject) => { + const request = indexedDB.open(databaseName, 1); + + request.onupgradeneeded = () => { + const database = request.result; + if (!database.objectStoreNames.contains(objectStoreName)) { + database.createObjectStore(objectStoreName, { + keyPath: "namespace", + }); + } + }; + + request.onsuccess = () => { + resolve(request.result); + }; + + request.onerror = () => { + reject( + request.error ?? + new Error(`Failed to open IndexedDB database "${databaseName}".`), + ); + }; + }); + +const readNamespaceRecord = async ({ + databasePromise, + objectStoreName, + namespace, +}) => { + const database = await databasePromise; + + return new Promise((resolve, reject) => { + const transaction = database.transaction(objectStoreName, "readonly"); + const store = transaction.objectStore(objectStoreName); + const request = store.get(namespace); + + request.onsuccess = () => { + resolve(request.result ?? null); + }; + + request.onerror = () => { + reject( + request.error ?? + new Error( + `Failed to read persisted namespace "${namespace}" from IndexedDB.`, + ), + ); + }; + }); +}; + +const writeNamespaceRecord = async ({ + databasePromise, + objectStoreName, + namespace, + patch, +}) => { + const database = await databasePromise; + + return new Promise((resolve, reject) => { + const transaction = database.transaction(objectStoreName, "readwrite"); + const store = transaction.objectStore(objectStoreName); + const readRequest = store.get(namespace); + let settled = false; + + const rejectOnce = (error) => { + if (settled) { + return; + } + + settled = true; + reject(error); + }; + + const resolveOnce = () => { + if (settled) { + return; + } + + settled = true; + resolve(); + }; + + readRequest.onerror = () => { + rejectOnce( + readRequest.error ?? + new Error( + `Failed to read persisted namespace "${namespace}" before updating IndexedDB.`, + ), + ); + }; + + readRequest.onsuccess = () => { + const currentRecord = normalizePersistedState(readRequest.result); + const nextRecord = { + namespace, + version: PERSISTENCE_RECORD_VERSION, + ...createEmptyPersistedState(), + ...currentRecord, + ...patch, + }; + + const writeRequest = store.put(nextRecord); + writeRequest.onerror = () => { + rejectOnce( + writeRequest.error ?? + new Error( + `Failed to persist namespace "${namespace}" to IndexedDB.`, + ), + ); + }; + }; + + transaction.oncomplete = () => { + resolveOnce(); + }; + + transaction.onerror = () => { + rejectOnce( + transaction.error ?? + new Error( + `IndexedDB transaction failed while updating namespace "${namespace}".`, + ), + ); + }; + + transaction.onabort = () => { + rejectOnce( + transaction.error ?? + new Error( + `IndexedDB transaction aborted while updating namespace "${namespace}".`, + ), + ); + }; + }); +}; + +export const createIndexedDbPersistence = (options = {}) => { + const { + indexedDB: indexedDBOverride, + databaseName = DEFAULT_DATABASE_NAME, + objectStoreName = DEFAULT_OBJECT_STORE_NAME, + namespace, + } = options; + + const indexedDB = resolveIndexedDb(indexedDBOverride); + const resolvedNamespace = requireNamespace(namespace); + const databasePromise = openDatabase({ + indexedDB, + databaseName, + objectStoreName, + }); + + return { + namespace: resolvedNamespace, + load: async () => { + const record = await readNamespaceRecord({ + databasePromise, + objectStoreName, + namespace: resolvedNamespace, + }); + + return { + ...createEmptyPersistedState(), + ...normalizePersistedState(record), + }; + }, + saveSlots: async (saveSlots) => + writeNamespaceRecord({ + databasePromise, + objectStoreName, + namespace: resolvedNamespace, + patch: { + saveSlots: isPlainObject(saveSlots) ? saveSlots : {}, + }, + }), + saveGlobalDeviceVariables: async (globalDeviceVariables) => + writeNamespaceRecord({ + databasePromise, + objectStoreName, + namespace: resolvedNamespace, + patch: { + globalDeviceVariables: isPlainObject(globalDeviceVariables) + ? globalDeviceVariables + : {}, + }, + }), + saveGlobalAccountVariables: async (globalAccountVariables) => + writeNamespaceRecord({ + databasePromise, + objectStoreName, + namespace: resolvedNamespace, + patch: { + globalAccountVariables: isPlainObject(globalAccountVariables) + ? globalAccountVariables + : {}, + }, + }), + }; +}; + +export default createIndexedDbPersistence; diff --git a/vt/static/main.js b/vt/static/main.js index fb5dd2c3..0ac0378f 100644 --- a/vt/static/main.js +++ b/vt/static/main.js @@ -1,5 +1,8 @@ import { parse } from "https://cdn.jsdelivr.net/npm/yaml@2.7.1/+esm"; -import createRouteEngine, { createEffectsHandler } from "./RouteEngine.js"; +import createRouteEngine, { + createEffectsHandler, + createIndexedDbPersistence, +} from "./RouteEngine.js"; import { Ticker } from "https://cdn.jsdelivr.net/npm/pixi.js@8.0.0/+esm"; import { createSaveThumbnailAssetId } from "./saveSlotUtils.js"; @@ -19,6 +22,7 @@ import createRouteGraphics, { } from "./RouteGraphics.js"; const projectData = parse(window.yamlContent); +const namespace = `vt:${window.location.pathname}`; const init = async () => { const screenWidth = projectData?.screen?.width ?? 1920; @@ -183,9 +187,14 @@ const init = async () => { } return bytes.buffer; }; + const persistence = createIndexedDbPersistence({ namespace }); + const { saveSlots, globalDeviceVariables, globalAccountVariables } = + await persistence.load(); + let engine; const effectsHandler = createEffectsHandler({ getEngine: () => engine, + persistence, routeGraphics: { render: (renderState) => { routeGraphics.render(renderState); @@ -262,13 +271,9 @@ const init = async () => { }); engine = createRouteEngine({ handlePendingEffects: effectsHandler }); - const saveSlots = JSON.parse(localStorage.getItem("saveSlots")) || {}; - const globalDeviceVariables = - JSON.parse(localStorage.getItem("globalDeviceVariables")) || {}; - const globalAccountVariables = - JSON.parse(localStorage.getItem("globalAccountVariables")) || {}; engine.init({ + namespace, initialState: { global: { saveSlots, @@ -279,6 +284,8 @@ const init = async () => { }); window.__vtEngine = engine; + window.__vtPersistence = persistence; + window.__vtNamespace = persistence.namespace; window.addEventListener("vt:nextLine", () => { engine.handleActions({