Files
ai.interactive.fiction/public/js/story-history-module.js
T

352 lines
14 KiB
JavaScript

/**
* 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;