/** * Story History Module * Stores received story output blocks in IndexedDB and tracks the render playhead. */ 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 = 3; this.historyStore = 'storyHistoryStore'; this.saveStore = 'storySaveStore'; this.db = null; this.currentGameId = null; this.nextBlockId = 1; this.latestRenderedBlockId = 0; this.renderedLineCount = 0; this.visibleLimit = 20; this.bindMethods([ 'initialize', 'openDB', 'startNewGame', 'setCurrentGame', 'recordBlock', 'recordBlocks', 'markRendered', 'updateBlockMetrics', 'saveSlot', 'loadSlot', 'hasSaveSlot', 'getSaveSlots', 'getBlocks', 'getBlock', 'getBlocksRange', 'getFirstBlockForTurn', 'getRenderedLineCount', 'findBlockForLine', '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('audioCacheStore')) { const audioStore = db.createObjectStore('audioCacheStore', { keyPath: 'hash' }); audioStore.createIndex('lastAccessed', 'lastAccessed', { unique: false }); audioStore.createIndex('size', 'size', { unique: false }); } 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; this.latestRenderedBlockId = 0; this.renderedLineCount = 0; const persistenceManager = this.getModule('persistence-manager'); persistenceManager?.updatePreference?.('app', 'currentGameId', gameId); return gameId; } setCurrentGame(gameId, latestBlockId = 0, latestRenderedBlockId = 0, renderedLineCount = 0) { this.currentGameId = gameId || this.currentGameId; this.nextBlockId = Math.max(1, Number(latestBlockId || 0) + 1); this.latestRenderedBlockId = Math.max(0, Number(latestRenderedBlockId || 0)); this.renderedLineCount = Math.max(0, Number(renderedLineCount || 0)); } 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); }); } async recordBlocks(blocks = []) { if (!Array.isArray(blocks) || blocks.length === 0) return []; const records = []; for (const block of blocks) { const record = await this.recordBlock(block); if (record) records.push(record); } return records; } markRendered(blockId) { const rendered = Math.max(0, Number(blockId || 0)); if (rendered > this.latestRenderedBlockId) { this.latestRenderedBlockId = rendered; } return this.latestRenderedBlockId; } async updateBlockMetrics(blockId, metrics = {}) { if (!this.db || !this.currentGameId || blockId == null) return null; const id = Math.max(1, Number(blockId || 1)); const key = `${this.currentGameId}:${id}`; const record = await new Promise((resolve, reject) => { const request = this.tx(this.historyStore).get(key); request.onsuccess = () => resolve(request.result || null); request.onerror = () => reject(request.error); }); if (!record) return null; const lineCount = Math.max(0, Number(metrics.lineCount ?? record.lineCount ?? 1)); const lineStart = Number.isFinite(Number(metrics.lineStart)) ? Math.max(0, Number(metrics.lineStart)) : Number.isFinite(Number(record.lineStart)) ? Math.max(0, Number(record.lineStart)) : this.renderedLineCount; const updated = { ...record, lineStart, lineCount, ...(Number.isFinite(Number(metrics.pageStart)) ? { pageStart: Math.max(0, Number(metrics.pageStart)) } : {}), ...(Number.isFinite(Number(metrics.pageEnd)) ? { pageEnd: Math.max(0, Number(metrics.pageEnd)) } : {}), ...(Number.isFinite(Number(metrics.pageLineStart)) ? { pageLineStart: Math.max(0, Number(metrics.pageLineStart)) } : {}), ...(Number.isFinite(Number(metrics.pageLineEnd)) ? { pageLineEnd: Math.max(0, Number(metrics.pageLineEnd)) } : {}), ...(Number.isFinite(Number(metrics.spreadStart)) ? { spreadStart: Math.max(0, Number(metrics.spreadStart)) } : {}), ...(Number.isFinite(Number(metrics.spreadEnd)) ? { spreadEnd: Math.max(0, Number(metrics.spreadEnd)) } : {}), ...(metrics.pagination ? { pagination: metrics.pagination } : {}), metricsUpdatedAt: Date.now() }; this.renderedLineCount = Math.max(this.renderedLineCount, lineStart + lineCount); await new Promise((resolve, reject) => { const request = this.tx(this.historyStore, 'readwrite').put(updated); request.onsuccess = () => resolve(true); request.onerror = () => reject(request.error); }); return updated; } 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), latestRenderedBlockId: Math.max(0, Number(saveData.latestRenderedBlockId ?? this.latestRenderedBlockId ?? 0)), renderedLineCount: Math.max(0, Number(saveData.renderedLineCount ?? this.renderedLineCount ?? 0)), 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); }); } getBlock(gameId = this.currentGameId, blockId = null) { if (!this.db || !gameId || blockId == null) return Promise.resolve(null); const id = Math.max(1, Number(blockId || 1)); return new Promise((resolve, reject) => { const request = this.tx(this.historyStore).get(`${gameId}:${id}`); request.onsuccess = () => resolve(request.result || null); request.onerror = () => reject(request.error); }); } getBlocksRange(gameId = this.currentGameId, startBlockId = 1, endBlockId = Infinity) { if (!this.db || !gameId) return Promise.resolve([]); const start = Math.max(1, Number(startBlockId || 1)); const end = Number.isFinite(endBlockId) ? Number(endBlockId) : Number.MAX_SAFE_INTEGER; if (end < start) return Promise.resolve([]); return new Promise((resolve, reject) => { const blocks = []; const index = this.tx(this.historyStore).index('gameOrder'); const range = IDBKeyRange.bound([gameId, start], [gameId, end]); const request = index.openCursor(range, 'next'); request.onsuccess = () => { const cursor = request.result; if (!cursor) { resolve(blocks); return; } blocks.push(cursor.value); cursor.continue(); }; request.onerror = () => reject(request.error); }); } async getFirstBlockForTurn(gameId = this.currentGameId, turnId) { if (!this.db || !gameId || turnId == null) return null; return new Promise((resolve, reject) => { const index = this.tx(this.historyStore).index('gameId'); const request = index.openCursor(IDBKeyRange.only(gameId), 'next'); request.onsuccess = () => { const cursor = request.result; if (!cursor) { resolve(null); return; } if (String(cursor.value?.turnId) === String(turnId)) { resolve(cursor.value); return; } cursor.continue(); }; request.onerror = () => reject(request.error); }); } async getRenderedLineCount(gameId = this.currentGameId, latestRenderedBlockId = this.latestRenderedBlockId) { const latest = Math.max(0, Number(latestRenderedBlockId || 0)); if (!this.db || !gameId || latest <= 0) return 0; const blocks = await this.getBlocksRange(gameId, 1, latest); const measured = blocks.reduce((max, block) => { const start = Number(block.lineStart); const count = Number(block.lineCount); if (!Number.isFinite(start) || !Number.isFinite(count)) return max; return Math.max(max, start + Math.max(1, count)); }, 0); this.renderedLineCount = Math.max(this.renderedLineCount, measured); return this.renderedLineCount; } async findBlockForLine(gameId = this.currentGameId, line = 0, latestRenderedBlockId = this.latestRenderedBlockId) { const latest = Math.max(0, Number(latestRenderedBlockId || 0)); if (!this.db || !gameId || latest <= 0) return null; const targetLine = Math.max(0, Number(line || 0)); const blocks = await this.getBlocksRange(gameId, 1, latest); return blocks.find((block) => { const start = Number(block.lineStart); const count = Math.max(1, Number(block.lineCount || 1)); return Number.isFinite(start) && targetLine >= start && targetLine < start + count; }) || blocks.at(-1) || null; } 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;