Stabilize playback state and cursor feedback

This commit is contained in:
2026-05-18 20:57:20 +02:00
parent 6e908037fb
commit 751ac5f62b
13 changed files with 580 additions and 82 deletions
+96 -1
View File
@@ -4,6 +4,9 @@
*/
import { BaseModule } from './base-module.js';
const GAME_API_TIMEOUT_MS = 60000;
const PLAYER_COMMAND_TIMEOUT_MS = 60000;
class SocketClientModule extends BaseModule {
constructor() {
super('socket-client', 'Socket Client');
@@ -23,6 +26,10 @@ class SocketClientModule extends BaseModule {
this.defaultHost = 'localhost:3000';
this.receivedBlockCounter = 0;
this.receivedParagraphCounter = 0;
this.pendingCommandTimer = null;
this.pendingCommand = null;
this.gameApiTimeoutMs = GAME_API_TIMEOUT_MS;
this.playerCommandTimeoutMs = PLAYER_COMMAND_TIMEOUT_MS;
// Bind methods using parent's bindMethods utility
this.bindMethods([
@@ -54,6 +61,9 @@ class SocketClientModule extends BaseModule {
'cueMarkersFromTags',
'dispatchChoices',
'dispatchInputMode',
'handleServerError',
'clearPendingCommand',
'translate',
'isStructuralTag',
'blocksFromTags',
'enqueueStructuredBlock',
@@ -191,8 +201,13 @@ class SocketClientModule extends BaseModule {
// Special handling for narrative text
this.socket.on('narrativeResponse', (data) => {
this.clearPendingCommand('narrative-response');
this.processTurnResult(data);
});
this.socket.on('error', (error) => {
this.handleServerError(error);
});
this.socket.on('gameConfig', (data) => {
document.dispatchEvent(new CustomEvent('game:config', {
@@ -300,6 +315,45 @@ class SocketClientModule extends BaseModule {
}));
}
handleServerError(error) {
const message = String(error?.message || error?.error || error || 'The game server reported an error.');
console.error('Socket Client: Server error event:', error);
this.clearPendingCommand('server-error');
document.dispatchEvent(new CustomEvent('story:tag', {
detail: {
key: 'error',
value: message,
source: 'server'
}
}));
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'ready', reason: 'server-error', message }
}));
}
clearPendingCommand(reason = 'cleared') {
if (this.pendingCommandTimer) {
clearTimeout(this.pendingCommandTimer);
this.pendingCommandTimer = null;
}
if (this.pendingCommand) {
console.log('Socket Client: Command wait cleared', {
reason,
command: this.pendingCommand
});
}
this.pendingCommand = null;
}
translate(key, fallback, params = {}) {
const localization = this.getModule('localization');
if (localization && typeof localization.translate === 'function') {
const translated = localization.translate(key, params);
if (translated && translated !== key) return translated;
}
return fallback;
}
processParagraphResult(paragraph, turnId, pendingParagraph = null) {
const pending = pendingParagraph && typeof pendingParagraph === 'object'
? pendingParagraph
@@ -652,7 +706,30 @@ class SocketClientModule extends BaseModule {
}
try {
this.clearPendingCommand('new-command');
this.socket.emit('playerCommand', { command });
this.pendingCommand = command;
this.pendingCommandTimer = setTimeout(() => {
const timedOutCommand = this.pendingCommand;
this.clearPendingCommand('timeout');
console.warn('Socket Client: Player command timed out', {
timeoutMs: this.playerCommandTimeoutMs,
command: timedOutCommand
});
document.dispatchEvent(new CustomEvent('story:tag', {
detail: {
key: 'alert',
value: this.translate(
'popup.commandTimeout',
'The game server did not answer in time. You can try again.'
),
source: 'client-timeout'
}
}));
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'ready', reason: 'command-timeout', command: timedOutCommand }
}));
}, this.playerCommandTimeoutMs);
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'command-waiting', reason: 'command-sent', command }
}));
@@ -677,8 +754,26 @@ class SocketClientModule extends BaseModule {
return;
}
this.socket.emit('gameApi', { method, args }, (response) => {
let settled = false;
const finish = (response) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
resolve(response || { success: false, error: 'empty_response' });
};
const timeoutId = setTimeout(() => {
console.warn('Socket Client: gameApi call timed out', {
method,
timeoutMs: this.gameApiTimeoutMs
});
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'ready', reason: 'game-api-timeout', method }
}));
finish({ success: false, error: 'timeout', method });
}, this.gameApiTimeoutMs);
this.socket.emit('gameApi', { method, args }, (response) => {
finish(response);
});
});
}