Add storage-backed story history
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user