Fix stale restore after game restart

This commit is contained in:
2026-05-20 22:27:36 +02:00
parent 8258ea2321
commit beac5a2be3
12 changed files with 167 additions and 44 deletions
+40 -4
View File
@@ -30,6 +30,9 @@ class GameLoopModule extends BaseModule {
this.autoSaveQueued = false;
this.resumeAttempted = false;
this.lastInkState = null;
this.clientResetGeneration = 0;
this.restoreGeneration = 0;
this.pendingHistoryRestoreCleanup = null;
// Bind methods using parent's bindMethods utility
this.bindMethods([
@@ -52,6 +55,17 @@ class GameLoopModule extends BaseModule {
'resetClientPlaybackAndDisplay'
]);
}
clearPendingHistoryRestore(reason = 'cancelled') {
if (this.pendingHistoryRestoreCleanup) {
this.pendingHistoryRestoreCleanup(reason);
this.pendingHistoryRestoreCleanup = null;
return;
}
document.dispatchEvent(new CustomEvent('story:history-restoring', {
detail: { active: false, reason }
}));
}
async initialize() {
this.reportProgress(100, "Game loop initialized");
@@ -402,6 +416,12 @@ class GameLoopModule extends BaseModule {
if (options.resetDisplay) {
await this.resetClientPlaybackAndDisplay();
}
const restoreGeneration = ++this.restoreGeneration;
const resetGeneration = this.clientResetGeneration;
const isCurrentRestore = () =>
restoreGeneration === this.restoreGeneration &&
resetGeneration === this.clientResetGeneration;
this.clearPendingHistoryRestore(`${reason}-superseded`);
document.dispatchEvent(new CustomEvent('story:history-restoring', {
detail: { active: true, reason }
}));
@@ -416,10 +436,12 @@ class GameLoopModule extends BaseModule {
const uiController = this.getModule('ui-controller');
if (browserSave && uiController?.displayHandler?.restoreFromHistory) {
await uiController.displayHandler.restoreFromHistory(browserSave);
if (!isCurrentRestore()) return;
}
const audioManager = this.getModule('audio-manager');
if (browserSave?.musicState && audioManager?.restoreMusicState) {
await audioManager.restoreMusicState(browserSave.musicState);
if (!isCurrentRestore()) return;
}
const hasUnrenderedHistory = this.hasUnrenderedHistory(browserSave);
if (hasUnrenderedHistory) {
@@ -430,19 +452,27 @@ class GameLoopModule extends BaseModule {
}));
}
if (hasUnrenderedHistory) {
await this.queueUnrenderedHistoryBlocks(browserSave);
await this.queueUnrenderedHistoryBlocks(browserSave, isCurrentRestore);
if (!isCurrentRestore()) return;
}
if (!hasUnrenderedHistory) {
document.dispatchEvent(new CustomEvent('story:history-restoring', {
detail: { active: false, reason: `${reason}-complete` }
}));
} else {
const clearRestoring = () => {
const clearRestoring = (eventOrReason = 'pending-output-drained') => {
const clearReason = typeof eventOrReason === 'string'
? eventOrReason
: 'pending-output-drained';
document.dispatchEvent(new CustomEvent('story:history-restoring', {
detail: { active: false, reason: 'pending-output-drained' }
detail: { active: false, reason: clearReason }
}));
document.removeEventListener('tts:queue-empty', clearRestoring);
if (this.pendingHistoryRestoreCleanup === clearRestoring) {
this.pendingHistoryRestoreCleanup = null;
}
};
this.pendingHistoryRestoreCleanup = clearRestoring;
document.addEventListener('tts:queue-empty', clearRestoring);
}
}
@@ -522,7 +552,7 @@ class GameLoopModule extends BaseModule {
}
}
async queueUnrenderedHistoryBlocks(saveRecord = {}) {
async queueUnrenderedHistoryBlocks(saveRecord = {}, isCurrentRestore = null) {
const storyHistory = this.getModule('story-history');
const textBuffer = this.getModule('text-buffer');
if (!storyHistory || !textBuffer || typeof textBuffer.addBlocks !== 'function') return;
@@ -530,10 +560,16 @@ class GameLoopModule extends BaseModule {
const end = Math.max(0, Number(saveRecord.latestBlockId || 0));
if (end < start) return;
const blocks = await storyHistory.getBlocksRange(saveRecord.gameId, start, end);
if (typeof isCurrentRestore === 'function' && !isCurrentRestore()) return;
if (saveRecord.gameId && storyHistory.currentGameId !== saveRecord.gameId) return;
textBuffer.addBlocks(blocks);
}
async resetClientPlaybackAndDisplay() {
this.clientResetGeneration += 1;
this.restoreGeneration += 1;
this.clearPendingHistoryRestore('client-reset');
const playbackCoordinator = this.getModule('playback-coordinator');
if (playbackCoordinator && typeof playbackCoordinator.stop === 'function') {
await playbackCoordinator.stop();
+17 -1
View File
@@ -33,6 +33,8 @@ class SocketClientModule extends BaseModule {
this.pendingCommand = null;
this.gameApiTimeoutMs = GAME_API_TIMEOUT_MS;
this.playerCommandTimeoutMs = PLAYER_COMMAND_TIMEOUT_MS;
this.gameApiRequestId = 0;
this.latestNarrativeRequestId = 0;
// Bind methods using parent's bindMethods utility
this.bindMethods([
@@ -220,6 +222,15 @@ class SocketClientModule extends BaseModule {
// Special handling for narrative text
this.socket.on('narrativeResponse', (data) => {
const responseRequestId = Number(data?.clientRequestId || 0);
if (responseRequestId > 0 && responseRequestId !== this.latestNarrativeRequestId) {
console.warn('Socket Client: Ignoring stale narrative response', {
responseRequestId,
latestNarrativeRequestId: this.latestNarrativeRequestId,
turnId: data?.turnId
});
return;
}
this.clearPendingCommand('narrative-response');
this.processTurnResult(data);
});
@@ -834,6 +845,11 @@ class SocketClientModule extends BaseModule {
return;
}
const requestId = ++this.gameApiRequestId;
const normalizedMethod = String(method || '').replace(/\(\)$/, '');
if (['newGame', 'loadGame', 'chooseChoice'].includes(normalizedMethod)) {
this.latestNarrativeRequestId = requestId;
}
let settled = false;
const finish = (response) => {
if (settled) return;
@@ -852,7 +868,7 @@ class SocketClientModule extends BaseModule {
finish({ success: false, error: 'timeout', method });
}, this.gameApiTimeoutMs);
this.socket.emit('gameApi', { method, args }, (response) => {
this.socket.emit('gameApi', { method, args, requestId }, (response) => {
finish(response);
});
});
+13
View File
@@ -2335,6 +2335,19 @@ class UIDisplayHandlerModule extends BaseModule {
}
clear() {
this.renderWindowToken += 1;
this.scrollRequestId += 1;
if (this.scrollAnimationFrameId != null) {
cancelAnimationFrame(this.scrollAnimationFrameId);
this.scrollAnimationFrameId = null;
}
if (this.scrollAnimationResolve) {
this.scrollAnimationResolve();
this.scrollAnimationResolve = null;
this.scrollAnimationPromise = null;
}
this.storyScrollAnimation = null;
if (document.documentElement.dataset.skippablePause === 'true') {
document.dispatchEvent(new CustomEvent('ui:command', {
detail: { moduleId: this.id, type: 'continue', source: 'display-clear' }