/** * Story History Module * Stores rendered story blocks in IndexedDB and keeps only a short live window * in the page DOM. */ import { BaseModule } from './base-module.js'; class StoryHistoryModule extends BaseModule { constructor() { super('story-history', 'Story History'); this.dependencies = ['persistence-manager']; this.dbName = 'ttsAudioCacheDB'; this.dbVersion = 2; this.historyStore = 'storyHistoryStore'; this.saveStore = 'storySaveStore'; this.db = null; this.currentGameId = null; this.nextBlockId = 1; this.visibleLimit = 20; this.bindMethods([ 'initialize', 'openDB', 'startNewGame', 'setCurrentGame', 'recordBlock', 'saveSlot', 'loadSlot', 'hasSaveSlot', 'getSaveSlots', 'getBlocks', 'clearGame', 'tx' ]); } async initialize() { await this.openDB(); this.reportProgress(100, 'Story history ready'); return true; } openDB() { if (this.db) return Promise.resolve(this.db); return new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, this.dbVersion); request.onerror = () => reject(request.error); request.onsuccess = () => { this.db = request.result; resolve(this.db); }; request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains(this.historyStore)) { const historyStore = db.createObjectStore(this.historyStore, { keyPath: 'key' }); historyStore.createIndex('gameId', 'gameId', { unique: false }); historyStore.createIndex('gameOrder', ['gameId', 'blockId'], { unique: true }); } if (!db.objectStoreNames.contains(this.saveStore)) { db.createObjectStore(this.saveStore, { keyPath: 'slot' }); } }; }); } tx(storeName, mode = 'readonly') { return this.db.transaction(storeName, mode).objectStore(storeName); } async startNewGame() { const gameId = `game-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; this.currentGameId = gameId; this.nextBlockId = 1; const persistenceManager = this.getModule('persistence-manager'); persistenceManager?.updatePreference?.('app', 'currentGameId', gameId); return gameId; } setCurrentGame(gameId, latestBlockId = 0) { this.currentGameId = gameId || this.currentGameId; this.nextBlockId = Math.max(1, Number(latestBlockId || 0) + 1); } recordBlock(block) { if (!this.db || !this.currentGameId || !block) return Promise.resolve(null); const blockId = this.nextBlockId++; const record = { ...block, key: `${this.currentGameId}:${blockId}`, gameId: this.currentGameId, blockId, createdAt: Date.now() }; return new Promise((resolve, reject) => { const request = this.tx(this.historyStore, 'readwrite').put(record); request.onsuccess = () => resolve(record); request.onerror = () => reject(request.error); }); } saveSlot(slot = 1, saveData = {}) { if (!this.db) return Promise.resolve(false); const record = { slot, ...saveData, gameId: saveData.gameId || this.currentGameId, latestBlockId: Math.max(0, this.nextBlockId - 1), savedAt: Date.now() }; return new Promise((resolve, reject) => { const request = this.tx(this.saveStore, 'readwrite').put(record); request.onsuccess = () => resolve(record); request.onerror = () => reject(request.error); }); } loadSlot(slot = 1) { if (!this.db) return Promise.resolve(null); return new Promise((resolve, reject) => { const request = this.tx(this.saveStore).get(slot); request.onsuccess = () => resolve(request.result || null); request.onerror = () => reject(request.error); }); } async hasSaveSlot(slot = 1) { return Boolean(await this.loadSlot(slot)); } getSaveSlots() { if (!this.db) return Promise.resolve([]); return new Promise((resolve, reject) => { const request = this.tx(this.saveStore).getAllKeys(); request.onsuccess = () => resolve(request.result || []); request.onerror = () => reject(request.error); }); } getBlocks(gameId = this.currentGameId, limit = this.visibleLimit, beforeBlockId = Infinity) { if (!this.db || !gameId) return Promise.resolve([]); return new Promise((resolve, reject) => { const blocks = []; const index = this.tx(this.historyStore).index('gameOrder'); const upper = Number.isFinite(beforeBlockId) ? beforeBlockId : Number.MAX_SAFE_INTEGER; const range = IDBKeyRange.bound([gameId, 0], [gameId, upper], false, true); const request = index.openCursor(range, 'prev'); request.onsuccess = () => { const cursor = request.result; if (!cursor || blocks.length >= limit) { resolve(blocks.reverse()); return; } blocks.push(cursor.value); cursor.continue(); }; request.onerror = () => reject(request.error); }); } clearGame(gameId = this.currentGameId) { if (!this.db || !gameId) return Promise.resolve(); return new Promise((resolve, reject) => { const store = this.tx(this.historyStore, 'readwrite'); const index = store.index('gameId'); const request = index.openCursor(IDBKeyRange.only(gameId)); request.onsuccess = () => { const cursor = request.result; if (!cursor) { resolve(); return; } cursor.delete(); cursor.continue(); }; request.onerror = () => reject(request.error); }); } } const storyHistory = new StoryHistoryModule(); export { storyHistory as StoryHistory }; if (window.moduleRegistry) { window.moduleRegistry.register(storyHistory); } window.StoryHistory = storyHistory;