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
+14 -6
View File
@@ -112,14 +112,20 @@ function getOrCreateEngine(socketId) {
}
return engine;
}
async function handleGameApi(socket, method, args) {
function withClientRequestId(turn, requestId) {
const id = Number(requestId || 0);
return Number.isInteger(id) && id > 0
? { ...turn, clientRequestId: id }
: turn;
}
async function handleGameApi(socket, method, args, requestId) {
const slots = getSlots(socket.id);
switch (method) {
case 'newGame':
case 'newGame()': {
const engine = new ink_engine_1.InkEngine(getStoryPath());
sessions.set(socket.id, engine);
socket.emit('narrativeResponse', engine.newGame());
socket.emit('narrativeResponse', withClientRequestId(engine.newGame(), requestId));
return {
success: true,
result: true,
@@ -138,7 +144,7 @@ async function handleGameApi(socket, method, args) {
if (!Number.isInteger(choiceIndex)) {
return { success: false, error: 'invalid_choice', result: false };
}
socket.emit('narrativeResponse', engine.chooseChoice(choiceIndex));
socket.emit('narrativeResponse', withClientRequestId(engine.chooseChoice(choiceIndex), requestId));
return { success: true, result: true };
}
case 'loadGame':
@@ -149,8 +155,8 @@ async function handleGameApi(socket, method, args) {
return { success: false, error: 'missing_save', result: false };
}
const engine = getOrCreateEngine(socket.id);
socket.emit('narrativeResponse', engine.loadGame(browserSave || slots.get(slot)));
socket.emit('gameLoaded', { slot });
socket.emit('narrativeResponse', withClientRequestId(engine.loadGame(browserSave || slots.get(slot)), requestId));
socket.emit('gameLoaded', { slot, clientRequestId: requestId });
return { success: true, result: true, running: true, slot };
}
case 'resumeGame':
@@ -204,7 +210,9 @@ io.on('connection', (socket) => {
socket.emit('gameConfig', (0, game_config_1.clientGameConfig)(engineConfig));
socket.on('gameApi', async (request, respond) => {
try {
const result = await handleGameApi(socket, String(request?.method ?? ''), Array.isArray(request?.args) ? request.args : []);
const result = await handleGameApi(socket, String(request?.method ?? ''), Array.isArray(request?.args) ? request.args : [], Number.isInteger(Number(request?.requestId)) && Number(request?.requestId) > 0
? Number(request?.requestId)
: undefined);
if (typeof respond === 'function')
respond(result);
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+15 -8
View File
@@ -103,7 +103,13 @@ function normalizeSaveSlot(slot) {
const value = Number(slot);
return Number.isInteger(value) && value > 0 ? value : 1;
}
async function startDemoGameForSocket(socket) {
function withClientRequestId(turn, requestId) {
const id = Number(requestId || 0);
return Number.isInteger(id) && id > 0
? { ...turn, clientRequestId: id }
: turn;
}
async function startDemoGameForSocket(socket, requestId) {
nextTurnIds.set(socket.id, 1);
const gameRunner = new game_runner_1.GameRunner();
const worldFile = (0, game_config_1.projectPath)(process.env.DEFAULT_WORLD_FILE || engineConfig.paths.mainGameFile);
@@ -114,7 +120,7 @@ async function startDemoGameForSocket(socket) {
...(0, turn_result_1.textToParagraphs)(gameState.world.introduction),
...(0, turn_result_1.textToParagraphs)(gameRunner.getCurrentRoomDescription()),
];
socket.emit('narrativeResponse', {
socket.emit('narrativeResponse', withClientRequestId({
turnId: nextTurnId(socket.id),
paragraphs,
choices: [],
@@ -122,16 +128,16 @@ async function startDemoGameForSocket(socket) {
gameState: {
currentRoomId: gameState.currentRoomId,
},
});
}, requestId));
return gameRunner;
}
async function handleGameApi(socket, method, args = []) {
async function handleGameApi(socket, method, args = [], requestId) {
const saveGames = socket.data.saveGames || new Map();
socket.data.saveGames = saveGames;
switch (method) {
case 'newGame':
case 'newGame()':
await startDemoGameForSocket(socket);
await startDemoGameForSocket(socket, requestId);
return { success: true, result: true, running: true, canLoad: saveGames.size > 0 };
case 'loadGame':
case 'loadGame()': {
@@ -139,8 +145,8 @@ async function handleGameApi(socket, method, args = []) {
if (!saveGames.has(slot)) {
return { success: false, error: 'missing_save', result: false };
}
await startDemoGameForSocket(socket);
socket.emit('gameLoaded', { slot });
await startDemoGameForSocket(socket, requestId);
socket.emit('gameLoaded', { slot, clientRequestId: requestId });
return { success: true, result: true, running: true, slot };
}
case 'saveGame':
@@ -176,7 +182,8 @@ io.on('connection', (socket) => {
socket.data.saveGames = new Map();
socket.on('gameApi', async (request, respond) => {
try {
const response = await handleGameApi(socket, String(request?.method || ''), Array.isArray(request?.args) ? request.args : []);
const requestId = Number(request?.requestId || 0);
const response = await handleGameApi(socket, String(request?.method || ''), Array.isArray(request?.args) ? request.args : [], Number.isInteger(requestId) && requestId > 0 ? requestId : undefined);
if (typeof respond === 'function') {
respond(response);
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+13 -5
View File
@@ -127,7 +127,13 @@ function getSlots(socketId) {
}
return slots;
}
async function handleGameApi(socket, method, args) {
function withClientRequestId(turn, requestId) {
const id = Number(requestId || 0);
return Number.isInteger(id) && id > 0
? { ...turn, clientRequestId: id }
: turn;
}
async function handleGameApi(socket, method, args, requestId) {
const slots = getSlots(socket.id);
debugLog(`gameApi request from ${socket.id}: ${method}`, { args });
switch (method) {
@@ -135,7 +141,7 @@ async function handleGameApi(socket, method, args) {
case 'newGame()': {
const engine = getOrCreateEngine(socket.id);
const turn = await engine.newGame();
socket.emit('narrativeResponse', toClientTurn(turn));
socket.emit('narrativeResponse', withClientRequestId(toClientTurn(turn), requestId));
return {
success: true,
result: true,
@@ -151,8 +157,8 @@ async function handleGameApi(socket, method, args) {
}
const engine = getOrCreateEngine(socket.id);
const turn = await engine.loadGame(slots.get(slot));
socket.emit('narrativeResponse', toClientTurn(turn));
socket.emit('gameLoaded', { slot });
socket.emit('narrativeResponse', withClientRequestId(toClientTurn(turn), requestId));
socket.emit('gameLoaded', { slot, clientRequestId: requestId });
return { success: true, result: true, running: true, slot };
}
case 'saveGame':
@@ -229,7 +235,9 @@ io.on('connection', (socket) => {
socket.emit('gameConfig', (0, game_config_1.clientGameConfig)(engineConfig));
socket.on('gameApi', async (request, respond) => {
try {
const result = await handleGameApi(socket, String(request?.method ?? ''), Array.isArray(request?.args) ? request.args : []);
const result = await handleGameApi(socket, String(request?.method ?? ''), Array.isArray(request?.args) ? request.args : [], Number.isInteger(Number(request?.requestId)) && Number(request?.requestId) > 0
? Number(request?.requestId)
: undefined);
debugLog(`gameApi response to ${socket.id}`, result);
if (typeof respond === 'function')
respond(result);
+1 -1
View File
File diff suppressed because one or more lines are too long
+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([
@@ -53,6 +56,17 @@ class GameLoopModule extends BaseModule {
]);
}
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");
return true;
@@ -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' }
+16 -5
View File
@@ -97,10 +97,18 @@ function getOrCreateEngine(socketId: string): InkEngine {
return engine;
}
function withClientRequestId<T extends object>(turn: T, requestId?: number): T {
const id = Number(requestId || 0);
return Number.isInteger(id) && id > 0
? { ...turn, clientRequestId: id }
: turn;
}
async function handleGameApi(
socket: ReturnType<SocketIOServer['sockets']['sockets']['get']> & { id: string },
method: string,
args: unknown[],
requestId?: number,
): Promise<object> {
const slots = getSlots(socket.id);
@@ -109,7 +117,7 @@ async function handleGameApi(
case 'newGame()': {
const engine = new InkEngine(getStoryPath());
sessions.set(socket.id, engine);
socket.emit('narrativeResponse', engine.newGame());
socket.emit('narrativeResponse', withClientRequestId(engine.newGame(), requestId));
return {
success: true,
result: true,
@@ -129,7 +137,7 @@ async function handleGameApi(
if (!Number.isInteger(choiceIndex)) {
return { success: false, error: 'invalid_choice', result: false };
}
socket.emit('narrativeResponse', engine.chooseChoice(choiceIndex));
socket.emit('narrativeResponse', withClientRequestId(engine.chooseChoice(choiceIndex), requestId));
return { success: true, result: true };
}
@@ -141,8 +149,8 @@ async function handleGameApi(
return { success: false, error: 'missing_save', result: false };
}
const engine = getOrCreateEngine(socket.id);
socket.emit('narrativeResponse', engine.loadGame(browserSave || slots.get(slot)!));
socket.emit('gameLoaded', { slot });
socket.emit('narrativeResponse', withClientRequestId(engine.loadGame(browserSave || slots.get(slot)!), requestId));
socket.emit('gameLoaded', { slot, clientRequestId: requestId });
return { success: true, result: true, running: true, slot };
}
@@ -206,7 +214,7 @@ io.on('connection', (socket) => {
socket.on(
'gameApi',
async (
request: { method?: string; args?: unknown[] },
request: { method?: string; args?: unknown[]; requestId?: number },
respond: (result: object) => void,
) => {
try {
@@ -214,6 +222,9 @@ io.on('connection', (socket) => {
socket as Parameters<typeof handleGameApi>[0],
String(request?.method ?? ''),
Array.isArray(request?.args) ? request.args : [],
Number.isInteger(Number(request?.requestId)) && Number(request?.requestId) > 0
? Number(request?.requestId)
: undefined,
);
if (typeof respond === 'function') respond(result);
} catch (error) {
+21 -8
View File
@@ -87,7 +87,14 @@ function normalizeSaveSlot(slot: unknown): number {
return Number.isInteger(value) && value > 0 ? value : 1;
}
async function startDemoGameForSocket(socket: any): Promise<GameRunner> {
function withClientRequestId<T extends object>(turn: T, requestId?: number): T {
const id = Number(requestId || 0);
return Number.isInteger(id) && id > 0
? { ...turn, clientRequestId: id }
: turn;
}
async function startDemoGameForSocket(socket: any, requestId?: number): Promise<GameRunner> {
nextTurnIds.set(socket.id, 1);
const gameRunner = new GameRunner();
const worldFile = projectPath(process.env.DEFAULT_WORLD_FILE || engineConfig.paths.mainGameFile);
@@ -100,7 +107,7 @@ async function startDemoGameForSocket(socket: any): Promise<GameRunner> {
...textToParagraphs(gameState.world.introduction),
...textToParagraphs(gameRunner.getCurrentRoomDescription()),
];
socket.emit('narrativeResponse', {
socket.emit('narrativeResponse', withClientRequestId({
turnId: nextTurnId(socket.id),
paragraphs,
choices: [],
@@ -108,19 +115,19 @@ async function startDemoGameForSocket(socket: any): Promise<GameRunner> {
gameState: {
currentRoomId: gameState.currentRoomId,
},
});
}, requestId));
return gameRunner;
}
async function handleGameApi(socket: any, method: string, args: unknown[] = []) {
async function handleGameApi(socket: any, method: string, args: unknown[] = [], requestId?: number) {
const saveGames: Map<number, any> = socket.data.saveGames || new Map<number, any>();
socket.data.saveGames = saveGames;
switch (method) {
case 'newGame':
case 'newGame()':
await startDemoGameForSocket(socket);
await startDemoGameForSocket(socket, requestId);
return { success: true, result: true, running: true, canLoad: saveGames.size > 0 };
case 'loadGame':
@@ -129,8 +136,8 @@ async function handleGameApi(socket: any, method: string, args: unknown[] = [])
if (!saveGames.has(slot)) {
return { success: false, error: 'missing_save', result: false };
}
await startDemoGameForSocket(socket);
socket.emit('gameLoaded', { slot });
await startDemoGameForSocket(socket, requestId);
socket.emit('gameLoaded', { slot, clientRequestId: requestId });
return { success: true, result: true, running: true, slot };
}
@@ -174,7 +181,13 @@ io.on('connection', (socket) => {
socket.on('gameApi', async (request, respond) => {
try {
const response = await handleGameApi(socket, String(request?.method || ''), Array.isArray(request?.args) ? request.args : []);
const requestId = Number(request?.requestId || 0);
const response = await handleGameApi(
socket,
String(request?.method || ''),
Array.isArray(request?.args) ? request.args : [],
Number.isInteger(requestId) && requestId > 0 ? requestId : undefined,
);
if (typeof respond === 'function') {
respond(response);
}
+15 -4
View File
@@ -114,12 +114,20 @@ function getSlots(socketId: string): Map<number, string> {
return slots;
}
function withClientRequestId<T extends object>(turn: T, requestId?: number): T {
const id = Number(requestId || 0);
return Number.isInteger(id) && id > 0
? { ...turn, clientRequestId: id }
: turn;
}
async function handleGameApi(
socket: ReturnType<SocketIOServer['sockets']['sockets']['get']> & {
id: string;
},
method: string,
args: unknown[],
requestId?: number,
): Promise<object> {
const slots = getSlots(socket.id);
debugLog(`gameApi request from ${socket.id}: ${method}`, { args });
@@ -129,7 +137,7 @@ async function handleGameApi(
case 'newGame()': {
const engine = getOrCreateEngine(socket.id);
const turn = await engine.newGame();
socket.emit('narrativeResponse', toClientTurn(turn));
socket.emit('narrativeResponse', withClientRequestId(toClientTurn(turn), requestId));
return {
success: true,
result: true,
@@ -146,8 +154,8 @@ async function handleGameApi(
}
const engine = getOrCreateEngine(socket.id);
const turn = await engine.loadGame(slots.get(slot)!);
socket.emit('narrativeResponse', toClientTurn(turn));
socket.emit('gameLoaded', { slot });
socket.emit('narrativeResponse', withClientRequestId(toClientTurn(turn), requestId));
socket.emit('gameLoaded', { slot, clientRequestId: requestId });
return { success: true, result: true, running: true, slot };
}
@@ -236,7 +244,7 @@ io.on('connection', (socket) => {
socket.on(
'gameApi',
async (
request: { method?: string; args?: unknown[] },
request: { method?: string; args?: unknown[]; requestId?: number },
respond: (result: object) => void,
) => {
try {
@@ -244,6 +252,9 @@ io.on('connection', (socket) => {
socket as Parameters<typeof handleGameApi>[0],
String(request?.method ?? ''),
Array.isArray(request?.args) ? request.args : [],
Number.isInteger(Number(request?.requestId)) && Number(request?.requestId) > 0
? Number(request?.requestId)
: undefined,
);
debugLog(`gameApi response to ${socket.id}`, result);
if (typeof respond === 'function') respond(result);