Files
ai.interactive.fiction/public/js/socket-client-module.js
T

1000 lines
35 KiB
JavaScript

/**
* Socket Client Module
* Handles WebSocket communication for receiving text fragments and game state
*/
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');
// Dependencies
this.dependencies = ['text-buffer', 'markup-parser', 'story-history'];
this.socket = null;
this.textBuffer = null;
this.storyHistory = null;
this.isConnected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 12;
this.reconnectDelay = 5000;
this.maxReconnectDelay = 5000;
this.reconnectTimer = null;
this.reconnectAlerted = false;
this.url = null;
this.eventListeners = {};
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;
this.gameApiRequestId = 0;
this.latestNarrativeRequestId = 0;
// Bind methods using parent's bindMethods utility
this.bindMethods([
'connect',
'disconnect',
'send',
'sendCommand',
'callGameApi',
'newGame',
'loadGame',
'saveGame',
'resumeGame',
'exportGameState',
'chooseChoice',
'hasSaveGame',
'getSaveGames',
'isGameRunning',
'requestStartGame',
'requestSaveGame',
'requestLoadGame',
'on',
'off',
'emitEvent',
'setupGameEventHandlers',
'processTurnResult',
'processParagraphResult',
'storeAndQueueBlocks',
'normalizeHistoryBlock',
'dispatchTurnTags',
'isTimedCueTag',
'isRenderMetadataTag',
'cueMarkersFromTags',
'dispatchChoices',
'dispatchInputMode',
'handleServerError',
'clearPendingCommand',
'translate',
'isStructuralTag',
'blocksFromTags',
'enqueueStructuredBlock',
'parseImageTagOptions',
'parseSfxTagOptions',
'parseMusicTagOptions',
'resolveAssetUrl',
'looksLikeAssetPath',
'attemptReconnect',
'stopReconnectLoop',
'notifyReconnectFailed',
'getConnectionStatus',
'loadSocketIO'
]);
}
/**
* Load Socket.IO client library
* @returns {Promise<void>}
*/
loadSocketIO() {
// Use parent's loadScript method
return this.loadScript('/socket.io/socket.io.js');
}
/**
* Initialize the module
* @returns {Promise<boolean>} - Resolves with success status
*/
async initialize() {
try {
this.reportProgress(10, "Initializing Socket Client");
// Dynamically load Socket.IO client if not already loaded
if (!window.io) {
this.reportProgress(20, "Loading Socket.IO client");
await this.loadSocketIO();
this.reportProgress(30, "Socket.IO client loaded");
}
// Get text buffer using parent's getModule method
this.textBuffer = this.getModule('text-buffer');
if (!this.textBuffer) {
console.error("Socket Client: Failed to get text-buffer module");
return false;
}
this.storyHistory = this.getModule('story-history');
if (!this.storyHistory) {
console.error("Socket Client: Failed to get story-history module");
return false;
}
this.reportProgress(50, "Setting up connection parameters");
// Use the current origin for the socket connection
const currentUrl = window.location.origin;
console.log(`Socket Client: Using origin for connection: ${currentUrl}`);
this.url = currentUrl;
this.reportProgress(100, "Socket client initialized");
return true;
} catch (error) {
console.error("Error initializing Socket Client:", error);
return false;
}
}
/**
* Connect to the Socket.IO server
* @param {string} url - Optional custom WebSocket URL
* @returns {Promise<boolean>} - Resolves with connection success
*/
connect(url = null) {
return new Promise((resolve) => {
if (this.isConnected) {
resolve(true);
return;
}
// Use provided URL or default
const socketUrl = url || this.url;
try {
console.log(`Socket Client: Connecting to ${socketUrl}`);
if (this.socket) {
this.socket.removeAllListeners();
this.socket.close();
this.socket = null;
}
// Create Socket.IO connection (will automatically use /socket.io endpoint)
this.socket = window.io(socketUrl, {
reconnection: false, // We handle reconnection ourselves
transports: ['polling', 'websocket']
});
this.socket.on('connect', () => {
console.log('Socket Client: Connected to server with ID:', this.socket.id);
this.isConnected = true;
this.reconnectAttempts = 0;
this.stopReconnectLoop();
this.reconnectAlerted = false;
this.emitEvent('connect');
resolve(true);
});
this.socket.on('disconnect', (reason) => {
console.log(`Socket Client: Connection closed: ${reason}`);
this.isConnected = false;
this.emitEvent('disconnect', reason);
this.attemptReconnect();
resolve(false);
});
this.socket.on('connect_error', (error) => {
console.error('Socket Client: Connection error:', error);
this.emitEvent('connect_error', error);
if (!this.isConnected) {
this.attemptReconnect();
}
resolve(false);
});
// Set up game-specific event handlers
this.setupGameEventHandlers();
} catch (error) {
console.error('Socket Client: Connection error:', error);
this.emitEvent('connect_error', error);
resolve(false);
}
});
}
/**
* Set up event handlers for game-specific Socket.IO events
*/
setupGameEventHandlers() {
if (!this.socket) return;
// Map all incoming Socket.IO events to our internal event system
this.socket.onAny((event, ...args) => {
console.log(`Socket Client: Received ${event} event from server`, args);
this.emitEvent(event, args[0]);
});
// 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);
});
this.socket.on('error', (error) => {
this.handleServerError(error);
});
this.socket.on('gameConfig', (data) => {
document.dispatchEvent(new CustomEvent('game:config', {
detail: data
}));
});
}
async processTurnResult(data) {
if (!data) return;
const turnId = Number(data.turnId);
if (!Number.isInteger(turnId) || turnId < 1 || !Array.isArray(data.paragraphs)) {
console.error('Socket Client: Invalid TurnResult received', data);
return;
}
if (turnId === 1) {
this.receivedBlockCounter = 0;
this.receivedParagraphCounter = 0;
}
const globalTags = Array.isArray(data.globalTags) ? [...data.globalTags] : [];
const endState = data.gameState?.endState || null;
if (endState && !globalTags.some((tag) => tag?.key === 'score' || tag?.key === 'error')) {
globalTags.push({
key: endState.type === 'error' ? 'error' : 'score',
value: endState.message || ''
});
}
if (globalTags.length > 0) {
document.dispatchEvent(new CustomEvent('story:global-tags', {
detail: globalTags
}));
this.dispatchTurnTags(globalTags.filter(tag => !this.isDeferredPopupTag(tag)), null);
}
const deferredGlobalTags = globalTags.filter(tag => this.isDeferredPopupTag(tag));
document.dispatchEvent(new CustomEvent('story:turn-start', {
detail: { turnId, turn: data }
}));
let pendingParagraph = {
role: null,
cueTags: []
};
const turnBlocks = [];
data.paragraphs.forEach((paragraph) => {
const result = this.processParagraphResult(paragraph, turnId, pendingParagraph);
pendingParagraph = result.pendingParagraph;
turnBlocks.push(...result.blocks);
});
if (deferredGlobalTags.length > 0) {
const targetBlock = [...turnBlocks].reverse().find(block => block?.type === 'paragraph' || block?.type === 'heading');
if (targetBlock) {
targetBlock.deferredTags = [
...(Array.isArray(targetBlock.deferredTags) ? targetBlock.deferredTags : []),
...deferredGlobalTags
];
} else {
this.dispatchTurnTags(deferredGlobalTags, null);
}
}
const choices = Array.isArray(data.choices) ? data.choices : [];
const inputMode = data.inputMode || (choices.length > 0 ? 'choice' : 'none');
this.dispatchChoices(choices);
this.dispatchInputMode(inputMode);
await this.storeAndQueueBlocks(turnBlocks);
document.dispatchEvent(new CustomEvent('story:turn-complete', {
detail: { turnId, turn: data, choices, inputMode }
}));
if (turnBlocks.length === 0 && choices.length > 0) {
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'ready', reason: 'choice-only-turn', turnId }
}));
} else if (turnBlocks.length === 0 && inputMode === 'end') {
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'ready', reason: 'empty-end-turn', turnId }
}));
}
}
dispatchTurnTags(tags, paragraph = null) {
if (!Array.isArray(tags)) return;
tags.forEach((tag) => {
if (!tag || !tag.key) return;
document.dispatchEvent(new CustomEvent('story:tag', {
detail: {
...tag,
paragraph
}
}));
});
}
dispatchChoices(choices) {
document.dispatchEvent(new CustomEvent('story:choices', {
detail: choices
}));
}
dispatchInputMode(inputMode) {
const mode = ['text', 'choice', 'end', 'none'].includes(inputMode) ? inputMode : 'none';
document.dispatchEvent(new CustomEvent('story:input-mode', {
detail: mode
}));
}
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
: { role: pendingParagraph || null, cueTags: [] };
const tags = Array.isArray(paragraph?.tags) ? paragraph.tags : [];
const { blocks, paragraphRole } = this.blocksFromTags(tags, turnId);
const rawText = String(paragraph?.text || '').trim();
const markupParser = this.getModule('markup-parser');
const parsedParagraph = rawText && markupParser && typeof markupParser.parseParagraph === 'function'
? markupParser.parseParagraph(rawText)
: null;
const text = String(parsedParagraph?.text || rawText).trim();
const layoutText = parsedParagraph?.layoutText || paragraph.layoutText || text;
const glossaryEntries = markupParser && typeof markupParser.extractGlossaryTags === 'function'
? markupParser.extractGlossaryTags(tags)
: [];
const ttsInstructions = markupParser && typeof markupParser.extractTtsInstructionTags === 'function'
? markupParser.extractTtsInstructionTags(tags)
: [];
const cueTags = tags.filter(tag => this.isTimedCueTag(tag));
const deferredTags = tags.filter(tag => this.isDeferredPopupTag(tag));
const immediateTags = tags.filter(tag =>
!this.isStructuralTag(tag) &&
!this.isTimedCueTag(tag) &&
!this.isRenderMetadataTag(tag) &&
!this.isDeferredPopupTag(tag)
);
this.dispatchTurnTags(immediateTags, paragraph);
if (!text) {
return {
blocks,
pendingParagraph: {
role: paragraphRole || pending.role || null,
cueTags: [
...(Array.isArray(pending.cueTags) ? pending.cueTags : []),
...cueTags
],
deferredTags: [
...(Array.isArray(pending.deferredTags) ? pending.deferredTags : []),
...deferredTags
]
}
};
}
const role = pending.role || paragraphRole || 'body';
const cueMarkers = [
...(Array.isArray(paragraph.cueMarkers) ? paragraph.cueMarkers : []),
...(Array.isArray(parsedParagraph?.cueMarkers) ? parsedParagraph.cueMarkers : []),
...this.cueMarkersFromTags([
...(Array.isArray(pending.cueTags) ? pending.cueTags : []),
...cueTags
])
];
blocks.push({
type: 'paragraph',
text,
layoutText,
glossaryEntries,
ttsInstructions,
cueMarkers,
deferredTags: [
...(Array.isArray(pending.deferredTags) ? pending.deferredTags : []),
...deferredTags
],
role,
isFirstParagraphInChapter: role === 'chapter-first' || role === 'textblock-first',
dropCap: role === 'chapter-first',
addTopSpace: role === 'textblock-first',
turnId
});
return { blocks, pendingParagraph: { role: null, cueTags: [], deferredTags: [] } };
}
async storeAndQueueBlocks(blocks = []) {
if (!Array.isArray(blocks) || blocks.length === 0) return;
if (!this.storyHistory) {
this.storyHistory = this.getModule('story-history');
}
const normalizedBlocks = blocks.map(block => this.normalizeHistoryBlock(block));
let records = normalizedBlocks;
if (this.storyHistory && typeof this.storyHistory.recordBlocks === 'function') {
records = await this.storyHistory.recordBlocks(normalizedBlocks);
document.dispatchEvent(new CustomEvent('story:history-updated', {
detail: {
gameId: this.storyHistory.currentGameId || null,
latestBlockId: this.storyHistory.nextBlockId - 1
}
}));
} else {
console.warn('Socket Client: Story history unavailable; queueing unstored blocks');
}
records.forEach(block => this.enqueueStructuredBlock(block));
}
normalizeHistoryBlock(block) {
const type = String(block?.type || 'paragraph');
this.receivedBlockCounter += 1;
const normalized = {
...block,
type,
id: block.id || `${type}-${block.turnId || 'turn'}-${this.receivedBlockCounter}`
};
if (type === 'paragraph') {
normalized.paragraphIndex = Number.isInteger(block.paragraphIndex)
? block.paragraphIndex
: this.receivedParagraphCounter;
this.receivedParagraphCounter += 1;
}
return normalized;
}
isStructuralTag(tag) {
const key = String(tag?.key || '').toLowerCase();
return ['chapter', 'heading', 'section', 'textblock', 'image', 'music'].includes(key);
}
isTimedCueTag(tag) {
const key = String(tag?.key || '').toLowerCase();
return ['sfx', 'sound', 'audio'].includes(key);
}
isRenderMetadataTag(tag) {
const key = String(tag?.key || '').toLowerCase();
return key === 'gloss' || key === 'tts' || key.startsWith('tts-');
}
isDeferredPopupTag(tag) {
const key = String(tag?.key || '').toLowerCase();
return ['alert', 'achievement', 'score', 'error'].includes(key);
}
cueMarkersFromTags(tags) {
if (!Array.isArray(tags)) return [];
return tags
.filter(tag => this.isTimedCueTag(tag))
.map(tag => {
const filename = String(tag?.value || tag?.filename || '').trim();
if (!filename) return null;
const options = this.parseSfxTagOptions(tag?.param || tag?.options || '');
return {
type: 'sfx',
...options,
filename,
url: this.resolveAssetUrl('sounds', filename),
wordIndex: 0,
charIndex: 0
};
})
.filter(Boolean);
}
blocksFromTags(tags, turnId = null) {
const result = {
blocks: [],
paragraphRole: null
};
if (!Array.isArray(tags)) return result;
tags.forEach((tag) => {
const key = String(tag?.key || '').toLowerCase();
const value = String(tag?.value || '').trim();
const param = String(tag?.param || tag?.options || '').trim();
if ((key === 'chapter' || key === 'heading') && value) {
result.blocks.push({
type: 'heading',
text: value,
layoutText: value,
role: 'chapter-heading',
turnId
});
result.paragraphRole = 'chapter-first';
} else if (key === 'section' || key === 'textblock') {
result.blocks.push({
type: 'heading',
text: value || '* * *',
layoutText: value || '* * *',
role: 'section-heading',
turnId
});
result.paragraphRole = 'textblock-first';
} else if (key === 'image') {
let filename = value;
let optionText = param;
if (this.looksLikeAssetPath(param) && value && !this.looksLikeAssetPath(value)) {
filename = param;
optionText = value;
}
if (!filename) return;
const options = this.parseImageTagOptions(optionText);
const chapterOpening = result.paragraphRole === 'chapter-first';
result.blocks.push({
type: 'image',
...options,
floatSide: chapterOpening && String(options.size || '').toLowerCase() === 'portrait' ? 'right' : 'left',
chapterOpening,
filename,
url: this.resolveAssetUrl('images', filename),
turnId
});
} else if (key === 'music') {
let filename = value;
let optionText = param;
if (this.looksLikeAssetPath(param) && value && !this.looksLikeAssetPath(value)) {
filename = param;
optionText = value;
}
if (!filename) return;
const options = this.parseMusicTagOptions(optionText);
const leadInSeconds = Number(options.leadInSeconds);
result.blocks.push({
type: 'music',
...options,
leadInSeconds: Number.isFinite(leadInSeconds) ? leadInSeconds : 0,
leadIn: Number.isFinite(leadInSeconds) ? leadInSeconds : 0,
pause: Number.isFinite(leadInSeconds) ? leadInSeconds : 0,
filename,
url: this.resolveAssetUrl('music', filename),
turnId
});
}
});
return result;
}
enqueueStructuredBlock(block) {
if (!block) return;
if (!this.textBuffer) {
this.textBuffer = this.getModule('text-buffer');
}
if (this.textBuffer && typeof this.textBuffer.addBlock === 'function') {
console.log(`Socket Client: Queueing ${block.type} block`);
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'waiting-generating', reason: 'server-response-received' }
}));
this.textBuffer.addBlock(block);
return;
}
console.error('Socket Client: Text buffer not available for structured block', block);
}
parseImageTagOptions(optionText) {
const parser = this.getModule('markup-parser');
if (parser && typeof parser.parseImageOptions === 'function') {
return parser.parseImageOptions(optionText);
}
return { size: 'landscape', leadInSeconds: 0 };
}
parseSfxTagOptions(optionText) {
const parser = this.getModule('markup-parser');
if (parser && typeof parser.parseSfxOptions === 'function') {
return parser.parseSfxOptions(optionText);
}
return { maxDurationSeconds: 0, endMode: 'stop', fadeDurationSeconds: 2 };
}
parseMusicTagOptions(optionText) {
const parser = this.getModule('markup-parser');
if (parser && typeof parser.parseMusicOptions === 'function') {
return parser.parseMusicOptions(optionText);
}
return { mode: 'crossfade', loop: true, leadInSeconds: 0 };
}
resolveAssetUrl(kind, filename) {
const parser = this.getModule('markup-parser');
if (parser && typeof parser.resolveAssetUrl === 'function') {
return parser.resolveAssetUrl(kind, filename);
}
const root = kind === 'images' ? '/images/' : kind === 'music' ? '/music/' : '/sounds/';
const safeName = String(filename || '').replace(/\\/g, '/').replace(/^\/+/, '');
if (!safeName || safeName.includes('..') || /^[a-z]+:/i.test(safeName)) {
return '';
}
return root + safeName.split('/').map(encodeURIComponent).join('/');
}
looksLikeAssetPath(value) {
return /[./\\]/.test(String(value || '')) || /\.(png|jpe?g|gif|webp|svg|ogg|mp3|wav|m4a|flac)$/i.test(String(value || ''));
}
/**
* Attempt to reconnect to the server
*/
attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.notifyReconnectFailed();
return;
}
if (this.reconnectTimer) {
return;
}
this.reconnectAttempts++;
const delay = Math.min(this.maxReconnectDelay, this.reconnectDelay * this.reconnectAttempts);
console.log(`Socket Client: Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`);
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'waiting-generating', reason: 'socket-reconnecting', attempt: this.reconnectAttempts }
}));
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
if (!this.isConnected) {
this.connect();
}
}, delay);
}
stopReconnectLoop() {
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
notifyReconnectFailed() {
if (this.reconnectAlerted) return;
this.reconnectAlerted = true;
this.stopReconnectLoop();
const message = this.translate(
'popup.serverUnavailable',
'The game server is currently unavailable. The client tried to reconnect for one minute. Please reload the page after the server is running again.'
);
console.error('Socket Client: Reconnect failed after one minute');
document.dispatchEvent(new CustomEvent('story:tag', {
detail: {
key: 'error',
value: message,
source: 'socket-reconnect'
}
}));
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'ready', reason: 'socket-reconnect-failed' }
}));
}
/**
* Disconnect from the server
*/
disconnect() {
if (this.socket && this.isConnected) {
this.socket.disconnect();
this.isConnected = false;
}
}
/**
* Send a message to the server
* @param {Object|string} data - Data to send
* @returns {boolean} - Success status
*/
send(data) {
if (!this.isConnected || !this.socket) {
console.error('Socket Client: Not connected');
return false;
}
try {
// For Socket.IO we send structured events
if (typeof data === 'object') {
const { type, ...restData } = data;
if (type) {
// Use the type as the event name
this.socket.emit(type, restData);
} else {
// Default to 'message' event
this.socket.emit('message', data);
}
} else {
// Plain strings go to 'message' event
this.socket.emit('message', { text: data });
}
return true;
} catch (error) {
console.error('Socket Client: Error sending message:', error);
return false;
}
}
/**
* Send a command to the server
* @param {string} command - The player's command
* @returns {boolean} - Success status
*/
sendCommand(command) {
if (!this.isConnected || !this.socket) {
console.error('Socket Client: Not connected, cannot send command');
return false;
}
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 }
}));
return true;
} catch (error) {
console.error('Socket Client: Error sending command:', error);
return false;
}
}
async callGameApi(method, args = []) {
if (!this.isConnected || !this.socket) {
const connected = await this.connect();
if (!connected || !this.socket) {
return { success: false, error: 'not_connected' };
}
}
return new Promise((resolve) => {
if (!this.socket) {
resolve({ success: false, error: 'not_connected' });
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;
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, requestId }, (response) => {
finish(response);
});
});
}
newGame() {
return this.callGameApi('newGame', []);
}
loadGame(slot = 1, savedState = null) {
return this.callGameApi('loadGame', savedState ? [slot, savedState] : [slot]);
}
saveGame(slot = 1) {
return this.callGameApi('saveGame', [slot]);
}
resumeGame(savedState) {
return this.callGameApi('resumeGame', [savedState]);
}
exportGameState() {
return this.callGameApi('exportGameState', []);
}
chooseChoice(choiceIndex) {
return this.callGameApi('chooseChoice', [choiceIndex]);
}
hasSaveGame(slot = 1) {
return this.callGameApi('hasSaveGame', [slot]);
}
getSaveGames() {
return this.callGameApi('getSaveGames', []);
}
isGameRunning() {
return this.callGameApi('isGameRunning', []);
}
/**
* Request to start a new game
* @returns {boolean} - Success status
*/
requestStartGame() {
this.newGame();
return true;
}
/**
* Request to save the current game state
* @returns {boolean} - Success status
*/
requestSaveGame() {
this.saveGame(1);
return true;
}
/**
* Request to load a saved game state
* @returns {boolean} - Success status
*/
requestLoadGame() {
this.loadGame(1);
return true;
}
/**
* Register an event handler
* @param {string} event - Event name
* @param {Function} callback - Event callback
*/
on(event, callback) {
if (!this.eventListeners[event]) {
this.eventListeners[event] = [];
}
this.eventListeners[event].push(callback);
}
/**
* Remove an event handler
* @param {string} event - Event name
* @param {Function} callback - Event callback to remove
*/
off(event, callback) {
if (!this.eventListeners[event]) return;
if (callback) {
// Remove specific callback
this.eventListeners[event] = this.eventListeners[event].filter(cb => cb !== callback);
} else {
// Remove all callbacks for this event
delete this.eventListeners[event];
}
}
/**
* Emit an event to all registered listeners
* @param {string} event - Event name
* @param {*} data - Event data
*/
emitEvent(event, data) {
if (!this.eventListeners[event]) return;
for (const callback of this.eventListeners[event]) {
try {
callback(data);
} catch (error) {
console.error(`Socket Client: Error in '${event}' event handler:`, error);
}
}
}
/**
* Check if the socket is connected
* @returns {boolean} - Connection status
*/
getConnectionStatus() {
return this.isConnected;
}
}
// Create the singleton instance
const SocketClient = new SocketClientModule();
// Export the module
export { SocketClient };