Checkpoint Eibenreith ink architecture

This commit is contained in:
2026-05-24 09:09:41 +02:00
parent beac5a2be3
commit d42540f29d
35 changed files with 12015 additions and 54 deletions
+11 -8
View File
@@ -517,17 +517,20 @@ export class BrowserTTSModule extends TTSHandlerModule {
}
/**
* Speak preloaded speech
* Not applicable for the browser TTS (always returns false)
* @param {Object} preloadData - Preloaded speech data
* @param {Function} callback - Callback for when speech completes
* @returns {boolean} - Success status (always false)
* Speak preloaded speech metadata.
* Browser TTS cannot preload audio bytes, so its preload result carries the
* text and duration estimate and playback still happens through speak().
*/
speakPreloaded(preloadData, callback = null) {
if (typeof callback === 'function') {
callback({ success: false, reason: 'not_supported' });
const text = String(preloadData?.text || '').trim();
if (!text) {
if (typeof callback === 'function') {
callback({ success: false, reason: 'empty_preloaded_text' });
}
return false;
}
return false;
return this.speak(text, callback);
}
}
+123 -9
View File
@@ -15,6 +15,9 @@ class ChoiceDisplayModule extends BaseModule {
this.choices = [];
this.inputMode = 'none';
this.processState = document.documentElement.dataset.processState || 'loading';
this.currentTurnId = 0;
this.autoTurnCounter = 0;
this.lastAutoTurn = new Map();
this.template = {
cells: {
default: {
@@ -39,8 +42,15 @@ class ChoiceDisplayModule extends BaseModule {
'shuffleChoices',
'randomInt',
'assignLetters',
'selectAutoChoice',
'isAutoChoiceReady',
'getAutoChoiceConfig',
'getAutoChoiceKey',
'getAutoChoiceDelay',
'markAutoChoiceTriggered',
'selectChoice',
'getTagValue',
'getTag',
'getTemplateCell',
'renderChoiceText'
]);
@@ -60,10 +70,23 @@ class ChoiceDisplayModule extends BaseModule {
this.addEventListener(document, 'story:process-state', (event) => {
this.handleProcessState(event.detail?.state || 'ready');
});
this.addEventListener(document, 'story:turn-start', () => {
this.addEventListener(document, 'story:turn-start', (event) => {
const turnId = Number(event.detail?.turnId);
if (turnId === 1) {
this.currentTurnId = 0;
this.autoTurnCounter = 0;
this.lastAutoTurn.clear();
}
this.processState = 'waiting-generating';
this.render();
});
this.addEventListener(document, 'story:turn-complete', (event) => {
const turnId = Number(event.detail?.turnId);
if (Number.isInteger(turnId) && turnId !== this.currentTurnId) {
this.currentTurnId = turnId;
this.autoTurnCounter += 1;
}
});
this.addEventListener(document, 'story:history-restoring', (event) => {
document.documentElement.dataset.historyRestoring = event.detail?.active ? 'true' : 'false';
this.render();
@@ -130,7 +153,7 @@ class ChoiceDisplayModule extends BaseModule {
}
const letter = event.key.toUpperCase();
const choice = this.choices.find((item) => item.letter === letter);
const choice = this.choices.find((item) => !item.auto && item.letter === letter);
if (!choice) {
return;
}
@@ -151,11 +174,20 @@ class ChoiceDisplayModule extends BaseModule {
sourceOrder: order,
optional: this.hasTag(tags, 'optional'),
letter: '',
templateCell: this.getTemplateCell({ ...choice, tags, category })
templateCell: this.getTemplateCell({ ...choice, tags, category }),
auto: this.hasTag(tags, 'auto')
};
});
return this.assignLetters(this.orderChoicesForPresentation(normalized));
const autoChoices = normalized
.filter((choice) => choice.auto)
.sort((a, b) => a.sourceOrder - b.sourceOrder);
const visibleChoices = normalized.filter((choice) => !choice.auto);
return [
...autoChoices,
...this.assignLetters(this.orderChoicesForPresentation(visibleChoices))
];
}
orderChoicesForPresentation(choices) {
@@ -254,11 +286,17 @@ class ChoiceDisplayModule extends BaseModule {
}
getTagValue(tags, key) {
const normalizedKey = String(key).toLowerCase();
const tag = tags.find((item) => String(item?.key || '').toLowerCase() === normalizedKey);
const tag = this.getTag(tags, key);
return tag?.value;
}
getTag(tags, key) {
const normalizedKey = String(key).toLowerCase();
return Array.isArray(tags)
? tags.find((item) => String(item?.key || '').toLowerCase() === normalizedKey)
: undefined;
}
hasTag(tags, key) {
const normalizedKey = String(key).toLowerCase();
return Array.isArray(tags) && tags.some((item) => String(item?.key || '').toLowerCase() === normalizedKey);
@@ -276,8 +314,15 @@ class ChoiceDisplayModule extends BaseModule {
this.inputMode === 'choice' &&
this.choices.length > 0 &&
this.processState === 'ready';
this.container.hidden = !readyForChoices;
this.container.dataset.choiceReady = readyForChoices ? 'true' : 'false';
if (readyForChoices && this.selectAutoChoice()) {
return;
}
const visibleChoices = this.choices.filter((choice) => !choice.auto);
const hasVisibleChoices = visibleChoices.length > 0;
this.container.hidden = !readyForChoices || !hasVisibleChoices;
this.container.dataset.choiceReady = readyForChoices && hasVisibleChoices ? 'true' : 'false';
if (this.container.hidden) {
return;
}
@@ -285,7 +330,7 @@ class ChoiceDisplayModule extends BaseModule {
const list = document.createElement('ol');
list.className = 'choice-list choice-template-default';
this.choices.forEach((choice) => {
visibleChoices.forEach((choice) => {
const item = document.createElement('li');
item.className = 'choice-list-item';
item.classList.toggle('choice-optional', Boolean(choice.optional));
@@ -307,6 +352,75 @@ class ChoiceDisplayModule extends BaseModule {
this.container.appendChild(list);
}
selectAutoChoice() {
const autoChoice = this.choices.find((choice) => choice.auto && this.isAutoChoiceReady(choice));
if (!autoChoice) {
return false;
}
this.markAutoChoiceTriggered(autoChoice);
this.selectChoice(autoChoice.index);
return true;
}
isAutoChoiceReady(choice) {
const config = this.getAutoChoiceConfig(choice);
if (!config) {
return false;
}
if (config.delay <= 0) {
return true;
}
const lastTurn = this.lastAutoTurn.get(config.key);
if (!Number.isFinite(lastTurn)) {
return true;
}
return (this.autoTurnCounter - lastTurn) >= config.delay;
}
getAutoChoiceConfig(choice) {
const autoTag = this.getTag(choice.tags, 'auto');
if (!autoTag) {
return null;
}
return {
key: this.getAutoChoiceKey(autoTag),
delay: this.getAutoChoiceDelay(autoTag)
};
}
getAutoChoiceKey(autoTag) {
const value = String(autoTag?.value || '').trim();
return value || '__global';
}
getAutoChoiceDelay(autoTag) {
const candidates = [autoTag?.param, autoTag?.value];
for (const candidate of candidates) {
const text = String(candidate || '').trim();
if (!text) continue;
const namedMatch = text.match(/(?:turns?|delay|cooldown)\s*=\s*(\d+)/i);
const plainMatch = text.match(/^\d+$/);
const value = namedMatch ? Number(namedMatch[1]) : plainMatch ? Number(text) : NaN;
if (Number.isFinite(value)) {
return Math.max(0, Math.floor(value));
}
}
return 0;
}
markAutoChoiceTriggered(choice) {
const config = this.getAutoChoiceConfig(choice);
if (!config) {
return;
}
this.lastAutoTurn.set(config.key, this.autoTurnCounter);
}
async selectChoice(index) {
if (!this.socketClient) {
this.socketClient = this.getModule('socket-client');
+30 -1
View File
@@ -33,6 +33,8 @@ class GameLoopModule extends BaseModule {
this.clientResetGeneration = 0;
this.restoreGeneration = 0;
this.pendingHistoryRestoreCleanup = null;
this.gameOperationGeneration = 0;
this.autoSaveGeneration = 0;
// Bind methods using parent's bindMethods utility
this.bindMethods([
@@ -212,6 +214,8 @@ class GameLoopModule extends BaseModule {
async resumeAutosaveIfAvailable() {
if (this.resumeAttempted) return false;
this.resumeAttempted = true;
const operationGeneration = ++this.gameOperationGeneration;
const isCurrentOperation = () => operationGeneration === this.gameOperationGeneration;
const storyHistory = this.getModule('story-history');
const socketClient = this.getModule('socket-client');
@@ -220,11 +224,13 @@ class GameLoopModule extends BaseModule {
}
const browserSave = await storyHistory.loadSlot(this.autoSaveSlot);
if (!isCurrentOperation()) return false;
if (!browserSave?.inkState || browserSave.running === false) {
return false;
}
await this.resetClientPlaybackAndDisplay();
if (!isCurrentOperation()) return false;
this.currentChoices = [];
this.currentInputMode = 'none';
document.dispatchEvent(new CustomEvent('story:choices', { detail: [] }));
@@ -234,6 +240,7 @@ class GameLoopModule extends BaseModule {
}));
const response = await socketClient.resumeGame(browserSave.inkState);
if (!isCurrentOperation()) return false;
if (!response?.success) {
console.warn('GameLoop: autosave resume failed', response);
document.dispatchEvent(new CustomEvent('story:history-restoring', {
@@ -249,6 +256,7 @@ class GameLoopModule extends BaseModule {
this.gameState.canLoad = true;
this.updateUIState();
await this.restoreBrowserSave(browserSave, 'autosave-resume', { resetDisplay: false });
if (!isCurrentOperation()) return false;
this.restoreInputStateFromSave(browserSave, 'autosave-resume');
return true;
}
@@ -297,6 +305,8 @@ class GameLoopModule extends BaseModule {
async requestStartGame() {
const socketClient = this.getModule('socket-client');
if (!socketClient) return;
const operationGeneration = ++this.gameOperationGeneration;
const isCurrentOperation = () => operationGeneration === this.gameOperationGeneration;
this.gameState.started = true;
this.gameState.startedOnce = true;
@@ -304,9 +314,11 @@ class GameLoopModule extends BaseModule {
this.gameState.canSave = true;
this.updateUIState();
await this.resetClientPlaybackAndDisplay();
if (!isCurrentOperation()) return;
const storyHistory = this.getModule('story-history');
if (storyHistory && typeof storyHistory.startNewGame === 'function') {
await storyHistory.startNewGame();
if (!isCurrentOperation()) return;
if (typeof storyHistory.saveSlot === 'function') {
await storyHistory.saveSlot(this.autoSaveSlot, {
inkState: null,
@@ -317,6 +329,7 @@ class GameLoopModule extends BaseModule {
}
}
const response = await socketClient.newGame();
if (!isCurrentOperation()) return;
if (!response?.success) {
console.error('GameLoop: newGame failed', response);
this.gameState.started = false;
@@ -331,6 +344,7 @@ class GameLoopModule extends BaseModule {
this.gameState.canLoad = Boolean(response.canLoad);
this.updateUIState();
if (response.savedState && storyHistory && typeof storyHistory.saveSlot === 'function') {
if (!isCurrentOperation()) return;
await storyHistory.saveSlot(this.autoSaveSlot, {
inkState: response.savedState,
choices: [],
@@ -375,12 +389,16 @@ class GameLoopModule extends BaseModule {
async requestLoadGame() {
const socketClient = this.getModule('socket-client');
if (!socketClient) return;
const operationGeneration = ++this.gameOperationGeneration;
const isCurrentOperation = () => operationGeneration === this.gameOperationGeneration;
const storyHistory = this.getModule('story-history');
const browserSave = storyHistory && typeof storyHistory.loadSlot === 'function'
? await storyHistory.loadSlot(1)
: null;
if (!isCurrentOperation()) return;
const hasSave = browserSave ? { result: true } : await socketClient.hasSaveGame(1);
if (!isCurrentOperation()) return;
if (!hasSave?.result) {
this.gameState.canLoad = false;
this.updateUIState();
@@ -394,7 +412,9 @@ class GameLoopModule extends BaseModule {
this.gameState.canLoad = true;
this.updateUIState();
await this.restoreBrowserSave(browserSave, 'load-game', { resetDisplay: true });
if (!isCurrentOperation()) return;
const response = await socketClient.loadGame(1, browserSave?.inkState || null);
if (!isCurrentOperation()) return;
if (response?.success && browserSave && Array.isArray(browserSave.choices)) {
this.currentChoices = browserSave.choices;
this.currentInputMode = browserSave.inputMode || this.currentInputMode;
@@ -523,11 +543,16 @@ class GameLoopModule extends BaseModule {
return;
}
const autoSaveGeneration = this.autoSaveGeneration;
const gameId = storyHistory.currentGameId || null;
this.autoSaveInProgress = true;
try {
const response = this.gameState.started && typeof socketClient.exportGameState === 'function'
? await socketClient.exportGameState()
: null;
if (autoSaveGeneration !== this.autoSaveGeneration || storyHistory.currentGameId !== gameId) {
return;
}
if (!response?.success || !response.savedState) {
return;
}
@@ -535,6 +560,7 @@ class GameLoopModule extends BaseModule {
const audioManager = this.getModule('audio-manager');
await storyHistory.saveSlot(this.autoSaveSlot, {
gameId,
inkState: response.savedState,
latestRenderedBlockId: storyHistory.latestRenderedBlockId || 0,
renderedLineCount: storyHistory.renderedLineCount || 0,
@@ -545,9 +571,11 @@ class GameLoopModule extends BaseModule {
});
} finally {
this.autoSaveInProgress = false;
if (this.autoSaveQueued) {
if (autoSaveGeneration === this.autoSaveGeneration && this.autoSaveQueued) {
this.autoSaveQueued = false;
this.autoSaveCurrentSession();
} else {
this.autoSaveQueued = false;
}
}
}
@@ -568,6 +596,7 @@ class GameLoopModule extends BaseModule {
async resetClientPlaybackAndDisplay() {
this.clientResetGeneration += 1;
this.restoreGeneration += 1;
this.autoSaveGeneration += 1;
this.clearPendingHistoryRestore('client-reset');
const playbackCoordinator = this.getModule('playback-coordinator');
+25 -3
View File
@@ -30,6 +30,7 @@ class SentenceQueueModule extends BaseModule {
this.assetPreloadTimeoutMs = ASSET_PRELOAD_TIMEOUT_MS;
this.generationRequests = new Map();
this.assetPreloadRequests = new Map();
this.queueGeneration = 0;
// Bind methods
this.bindMethods([
@@ -64,6 +65,7 @@ class SentenceQueueModule extends BaseModule {
'getDropCapText',
'extractDropCapText',
'calculateAnimationTiming',
'isCurrentQueueItem',
'clear'
]);
}
@@ -170,12 +172,14 @@ class SentenceQueueModule extends BaseModule {
this.isProcessing = true;
const item = this.sentenceQueue[0];
const queueGeneration = this.queueGeneration;
try {
if (this.pauseBeforeNextReason) {
const reason = this.pauseBeforeNextReason;
this.pauseBeforeNextReason = null;
await this.waitForManualContinue(reason);
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
}
document.dispatchEvent(new CustomEvent('story:process-state', {
@@ -189,10 +193,12 @@ class SentenceQueueModule extends BaseModule {
}));
const sentence = await this.getPreparedSentence(item);
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
// Prefetch far enough ahead that media pauses do not block TTS
// generation for the next spoken paragraph.
this.prefetchAhead();
this.prefetchAhead(4, queueGeneration);
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
// Notify display handler with complete sentence
if (this.onSentenceReadyCallback) {
@@ -201,22 +207,28 @@ class SentenceQueueModule extends BaseModule {
sentence.playbackStartedAt = performance.now();
this.onSentenceReadyCallback(sentence, resolve);
});
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
}
const mediaPauseSeconds = this.getMediaPauseSeconds(sentence);
if (mediaPauseSeconds > 0) {
await this.waitForSkippableMediaPause(mediaPauseSeconds, sentence.kind, sentence.id);
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
}
if (this.shouldPauseAfterSentence(sentence)) {
await this.waitForManualContinue(sentence.id);
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
}
// Remove from queue and continue
this.sentenceQueue.shift();
if (this.sentenceQueue[0] === item) {
this.sentenceQueue.shift();
}
if (item.callback) item.callback({ success: true });
} catch (error) {
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
console.error("SentenceQueue: Error processing sentence:", error);
const failedItem = this.sentenceQueue.shift();
console.warn('SentenceQueue: Dropped failed queue item so playback can continue', {
@@ -843,7 +855,11 @@ class SentenceQueueModule extends BaseModule {
return this.prepareSentence(item);
}
prefetchAhead(maxLookahead = 4) {
isCurrentQueueItem(item, queueGeneration = this.queueGeneration) {
return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item;
}
prefetchAhead(maxLookahead = 4, queueGeneration = this.queueGeneration) {
if (this.sentenceQueue.length <= 1) {
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-ready', reason: 'no-prefetch-needed', sentenceId: this.sentenceQueue[0]?.id }
@@ -871,11 +887,13 @@ class SentenceQueueModule extends BaseModule {
console.log(`Process state: ${state}`, { reason: 'prefetch-start', sentenceId: nextItem.id, queueIndex: index });
const promise = (async () => {
if (queueGeneration !== this.queueGeneration) return null;
await this.preloadAssetsForItem(nextItem, {
sentenceId: nextItem.id,
blocking: false,
prefetch: true
});
if (queueGeneration !== this.queueGeneration) return null;
if (!this.isSpeechItem(nextItem)) {
return null;
@@ -892,6 +910,7 @@ class SentenceQueueModule extends BaseModule {
});
})()
.then(() => {
if (queueGeneration !== this.queueGeneration) return false;
console.log('SentenceQueue: Prefetched queued speech/media', { sentenceId: nextItem.id, queueIndex: index });
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'playing-ready', reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index }
@@ -1316,10 +1335,13 @@ class SentenceQueueModule extends BaseModule {
}
clear() {
this.queueGeneration += 1;
this.sentenceQueue = [];
this.isProcessing = false;
this.cancelGenerationRequests('sentence-queue-cleared');
this.cancelAssetPreloads('sentence-queue-cleared');
this.prefetchingSpeech.clear();
this.pauseBeforeNextReason = null;
document.dispatchEvent(new CustomEvent('tts:queue-empty', {
detail: { reason: 'sentence-queue-cleared' }
}));
+3 -1
View File
@@ -798,7 +798,9 @@ class SocketClientModule extends BaseModule {
try {
this.clearPendingCommand('new-command');
this.socket.emit('playerCommand', { command });
const requestId = ++this.gameApiRequestId;
this.latestNarrativeRequestId = requestId;
this.socket.emit('playerCommand', { command, requestId });
this.pendingCommand = command;
this.pendingCommandTimer = setTimeout(() => {
const timedOutCommand = this.pendingCommand;
+1 -1
View File
@@ -813,7 +813,7 @@ class TTSFactoryModule extends BaseModule {
if (typeof handler.preloadSpeech === 'function') {
console.log(`TTS Factory: Generating and caching speech for hash ${hash}`);
const preloadData = await handler.preloadSpeech(text, options);
if (preloadData && preloadData.success) {
if (preloadData && preloadData.success && preloadData.audioData) {
// Cache the speech
await this.cacheSpeech(hash, preloadData.audioData, preloadData.duration);
+92 -11
View File
@@ -38,6 +38,7 @@ class UIDisplayHandlerModule extends BaseModule {
this.scrollTargetLine = null;
this.scrollRequestId = 0;
this.renderWindowToken = 0;
this.displayGeneration = 0;
this.wheelLineAccumulator = 0;
this.viewportLineCount = 1;
this.lineHeightPx = 24;
@@ -124,6 +125,7 @@ class UIDisplayHandlerModule extends BaseModule {
'rerenderStory',
'clear',
'scheduleRerender',
'isDisplayGenerationCurrent',
'measureText',
'loadCSS',
'showChoices',
@@ -955,15 +957,35 @@ class UIDisplayHandlerModule extends BaseModule {
return null;
}
const generation = this.displayGeneration;
const sentenceGameId = sentence.gameId || null;
const isCurrent = () => this.isDisplayGenerationCurrent(generation, sentenceGameId);
try {
await this.ensureLiveTailWindow();
if (!isCurrent()) return null;
await this.scrollTo(this.getLiveEndLine(), { mode: 'enter-live-tail', smooth: false });
if (!isCurrent()) return null;
this.rebuildLayoutExclusions(this.renderedItems);
this.layoutFlowLine = this.getFlowLineFromItems(this.renderedItems);
const element = await this.renderStoryBlock(sentence, { animate: true, playback: true, placement: 'append' });
const element = await this.renderStoryBlock(sentence, {
animate: true,
playback: true,
placement: 'append',
token: this.renderWindowToken,
generation
});
if (!element) return null;
if (!isCurrent()) {
element.remove();
return null;
}
sentence.element = element;
await this.scrollTo(this.getLiveEndLine(), { mode: 'append-live' });
if (!isCurrent()) {
element.remove();
return null;
}
if (sentence.kind === 'image') {
this.revealImageBlock(element);
@@ -972,6 +994,7 @@ class UIDisplayHandlerModule extends BaseModule {
} else if (sentence.kind === 'music') {
console.log('UIDisplayHandler: Music block started', sentence.metadata || {});
}
if (!isCurrent()) return null;
this.dispatchDeferredTagsForBlock(sentence);
@@ -990,34 +1013,51 @@ class UIDisplayHandlerModule extends BaseModule {
async rerenderStory() {
if (!this.paragraphContainer || this.renderedItems.length === 0) return;
console.log('UIDisplayHandler: Re-typesetting story after page resize');
const generation = this.displayGeneration;
const activeLine = this.getCurrentScrollLine();
await this.renderHistoryWindow([...this.renderedItems]);
await this.renderHistoryWindow([...this.renderedItems], { generation });
if (!this.isDisplayGenerationCurrent(generation)) return;
await this.scrollTo(activeLine, { mode: 'rerender-preserve', smooth: false });
}
isDisplayGenerationCurrent(generation = this.displayGeneration, gameId = null) {
if (generation !== this.displayGeneration) return false;
if (gameId && this.storyHistory?.currentGameId && this.storyHistory.currentGameId !== gameId) {
return false;
}
return true;
}
async restoreFromHistory(saveRecord = {}) {
if (!this.paragraphContainer || !this.storyHistory || !saveRecord?.gameId) return;
const generation = this.displayGeneration;
const latestRenderedBlockId = Math.max(0, Number(saveRecord.latestRenderedBlockId || 0));
if (!this.storyHistory.renderedLineCount) {
await this.storyHistory.getRenderedLineCount(saveRecord.gameId, latestRenderedBlockId);
if (!this.isDisplayGenerationCurrent(generation, saveRecord.gameId)) return;
}
const targetLine = Math.max(0, latestRenderedBlockId > 0 ? this.storyHistory.renderedLineCount - 1 : 0);
if (latestRenderedBlockId > 0) {
const targetBlock = await this.storyHistory.findBlockForLine(saveRecord.gameId, targetLine, latestRenderedBlockId);
if (!this.isDisplayGenerationCurrent(generation, saveRecord.gameId)) return;
const targetBlockId = Math.max(1, Number(targetBlock?.blockId || latestRenderedBlockId));
await this.renderWindowForBounds({
start: Math.max(1, targetBlockId - this.historyBufferBlocks),
end: Math.min(latestRenderedBlockId, targetBlockId + this.historyBufferBlocks),
targetBlockId,
windowOriginLine: this.getTopLineForActiveLine(targetLine)
windowOriginLine: this.getTopLineForActiveLine(targetLine),
gameId: saveRecord.gameId,
generation
});
} else {
await this.renderHistoryWindow([], { windowOriginLine: 0 });
await this.renderHistoryWindow([], { windowOriginLine: 0, generation });
}
if (!this.isDisplayGenerationCurrent(generation, saveRecord.gameId)) return;
await this.scrollTo(targetLine, {
mode: 'restore-bottom',
smooth: false
});
if (!this.isDisplayGenerationCurrent(generation, saveRecord.gameId)) return;
this.updateStoryScrollbar({ latestBlockId: saveRecord.latestBlockId || latestRenderedBlockId || 1 });
}
@@ -1038,10 +1078,12 @@ class UIDisplayHandlerModule extends BaseModule {
targetContainer = this.paragraphContainer,
renderedItemsTarget = this.renderedItems,
token = null,
recordMetrics = true
recordMetrics = true,
generation = this.displayGeneration
} = options;
if (!item || !this.paragraphContainer) return null;
const renderable = await this.prepareRenderableBlock(item);
if (!this.isDisplayGenerationCurrent(generation, item.gameId || null)) return null;
if (token != null && token !== this.renderWindowToken) return null;
if (!renderable) return null;
@@ -1110,6 +1152,10 @@ class UIDisplayHandlerModule extends BaseModule {
if (recordMetrics && item.blockId != null) {
const updated = await this.recordRenderedMetrics(item.blockId, element, renderable.lineCount, renderable.lineStart);
if (!this.isDisplayGenerationCurrent(generation, item.gameId || null)) {
element?.remove();
return null;
}
if (token != null && token !== this.renderWindowToken) {
element?.remove();
return null;
@@ -1130,6 +1176,8 @@ class UIDisplayHandlerModule extends BaseModule {
async renderHistoryWindow(blocks = [], options = {}) {
if (!this.paragraphContainer) return;
const generation = options.generation ?? this.displayGeneration;
if (!this.isDisplayGenerationCurrent(generation, options.gameId || blocks[0]?.gameId || null)) return;
const token = ++this.renderWindowToken;
const orderedBlocks = Array.isArray(blocks)
? [...blocks].sort((left, right) => Number(left?.blockId || 0) - Number(right?.blockId || 0))
@@ -1152,12 +1200,15 @@ class UIDisplayHandlerModule extends BaseModule {
placement: 'append',
targetContainer: fragment,
renderedItemsTarget: nextRenderedItems,
token
token,
generation
});
if (token !== this.renderWindowToken) return;
if (!this.isDisplayGenerationCurrent(generation, item.gameId || options.gameId || null)) return;
}
if (token !== this.renderWindowToken) return;
if (!this.isDisplayGenerationCurrent(generation, options.gameId || orderedBlocks[0]?.gameId || null)) return;
this.paragraphContainer.replaceChildren(fragment);
this.renderedItems = nextRenderedItems;
this.historyWindowStartId = orderedBlocks[0]?.blockId || 1;
@@ -1263,9 +1314,11 @@ class UIDisplayHandlerModule extends BaseModule {
async reflowTextBlocksForActiveExclusions(token = this.renderWindowToken) {
if (!this.layoutExclusions.length || !this.paragraphContainer) return;
const generation = this.displayGeneration;
const candidates = this.renderedItems.filter(item => this.blockIntersectsExclusions(item));
for (const item of candidates) {
if (token !== this.renderWindowToken) return;
if (!this.isDisplayGenerationCurrent(generation, item.gameId || null)) return;
const oldElement = this.paragraphContainer.querySelector(`[data-story-block-id="${CSS.escape(String(item.blockId))}"]`);
if (!oldElement) continue;
const previousFlowLine = this.layoutFlowLine;
@@ -1274,6 +1327,7 @@ class UIDisplayHandlerModule extends BaseModule {
this.layoutFlowLine = previousFlowLine;
this.layoutExclusions = previousExclusions;
if (token !== this.renderWindowToken) return;
if (!this.isDisplayGenerationCurrent(generation, item.gameId || null)) return;
renderable.layout.lineCount = Math.max(1, Number(item.lineCount ?? item.metadata?.lineCount ?? renderable.lineCount));
renderable.lineCount = renderable.layout.lineCount;
const replacement = this.layoutRenderer.renderParagraph(renderable.layout, { id: renderable.id });
@@ -1294,6 +1348,9 @@ class UIDisplayHandlerModule extends BaseModule {
async renderIncrementalWindow(bounds = {}, requestId = null) {
if (!this.storyHistory || !this.paragraphContainer) return;
const generation = bounds.generation ?? this.displayGeneration;
const gameId = bounds.gameId || this.storyHistory.currentGameId;
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
if (!this.renderedItems.length) {
await this.renderWindowForBounds(bounds, requestId);
return;
@@ -1310,15 +1367,17 @@ class UIDisplayHandlerModule extends BaseModule {
const missingBeforeEnd = Math.min(end, (this.historyWindowStartId || start) - 1);
const missingAfterStart = Math.max(start, (this.historyWindowEndId || 0) + 1);
const missingBeforeBlocks = start <= missingBeforeEnd
? await this.storyHistory.getBlocksRange(this.storyHistory.currentGameId, start, missingBeforeEnd)
? await this.storyHistory.getBlocksRange(gameId, start, missingBeforeEnd)
: [];
if (requestId != null && requestId !== this.scrollRequestId) return;
if (token !== this.renderWindowToken) return;
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
const missingAfterBlocks = missingAfterStart <= end
? await this.storyHistory.getBlocksRange(this.storyHistory.currentGameId, missingAfterStart, end)
? await this.storyHistory.getBlocksRange(gameId, missingAfterStart, end)
: [];
if (requestId != null && requestId !== this.scrollRequestId) return;
if (token !== this.renderWindowToken) return;
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
const uniqueBeforeBlocks = missingBeforeBlocks.filter(block => {
const id = Number(block?.blockId || 0);
@@ -1355,6 +1414,7 @@ class UIDisplayHandlerModule extends BaseModule {
targetContainer: beforeFragment,
renderedItemsTarget: beforeItems,
token,
generation,
recordMetrics: false
});
if (token !== this.renderWindowToken || (requestId != null && requestId !== this.scrollRequestId)) {
@@ -1378,6 +1438,7 @@ class UIDisplayHandlerModule extends BaseModule {
targetContainer: afterFragment,
renderedItemsTarget: afterItems,
token,
generation,
recordMetrics: false
});
if (token !== this.renderWindowToken || (requestId != null && requestId !== this.scrollRequestId)) {
@@ -1402,6 +1463,7 @@ class UIDisplayHandlerModule extends BaseModule {
this.rebuildLayoutExclusions(this.renderedItems);
await this.reflowTextBlocksForActiveExclusions(token);
if (token !== this.renderWindowToken) return;
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
this.setVirtualPadding();
this.updateStoryScrollbar();
}
@@ -2033,6 +2095,8 @@ class UIDisplayHandlerModule extends BaseModule {
async ensureLiveTailWindow() {
if (!this.storyHistory || !this.paragraphContainer) return;
const generation = this.displayGeneration;
const gameId = this.storyHistory.currentGameId;
const latestRendered = Math.max(0, Number(this.storyHistory.latestRenderedBlockId || 0));
if (latestRendered > 0) {
const start = Math.max(1, latestRendered - (this.visibleBlockLimit - 2));
@@ -2043,8 +2107,11 @@ class UIDisplayHandlerModule extends BaseModule {
start,
end,
targetBlockId: latestRendered,
windowOriginLine: this.getTopLineForActiveLine(liveEndLine)
windowOriginLine: this.getTopLineForActiveLine(liveEndLine),
gameId,
generation
});
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
}
} else if (this.renderedItems.length) {
this.paragraphContainer.innerHTML = '';
@@ -2104,11 +2171,20 @@ class UIDisplayHandlerModule extends BaseModule {
}
async renderWindowForBounds(bounds = {}, requestId = null) {
if (!this.storyHistory) return;
const generation = bounds.generation ?? this.displayGeneration;
const gameId = bounds.gameId || this.storyHistory.currentGameId;
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
const start = Math.max(1, Number(bounds.start || 1));
const end = Math.max(start, Number(bounds.end || start));
const blocks = await this.storyHistory.getBlocksRange(this.storyHistory.currentGameId, start, end);
const blocks = await this.storyHistory.getBlocksRange(gameId, start, end);
if (requestId != null && requestId !== this.scrollRequestId) return;
await this.renderHistoryWindow(blocks, { windowOriginLine: bounds.windowOriginLine });
if (!this.isDisplayGenerationCurrent(generation, gameId)) return;
await this.renderHistoryWindow(blocks, {
windowOriginLine: bounds.windowOriginLine,
gameId,
generation
});
}
focusTurn(turnId) {
@@ -2335,6 +2411,7 @@ class UIDisplayHandlerModule extends BaseModule {
}
clear() {
this.displayGeneration += 1;
this.renderWindowToken += 1;
this.scrollRequestId += 1;
if (this.scrollAnimationFrameId != null) {
@@ -2370,6 +2447,10 @@ class UIDisplayHandlerModule extends BaseModule {
this.historyWindowStartId = 1;
this.historyWindowEndId = 0;
this.storyTopLine = 0;
this.scrollTargetLine = null;
this.wheelLineAccumulator = 0;
this.draggingStoryScrollbar = false;
this.scrollbarPreviewLine = null;
this.activeCenterBlockId = null;
this.layoutFlowLine = 0;
this.layoutExclusions = [];