/** * Socket Client Module * Handles WebSocket communication for receiving text fragments and game state */ import { BaseModule } from './base-module.js'; 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 = 5; this.reconnectDelay = 2000; this.url = null; this.eventListeners = {}; this.defaultHost = 'localhost:3000'; this.receivedBlockCounter = 0; this.receivedParagraphCounter = 0; // Bind methods using parent's bindMethods utility this.bindMethods([ 'connect', 'disconnect', 'send', 'sendCommand', 'callGameApi', 'newGame', 'loadGame', 'saveGame', 'chooseChoice', 'hasSaveGame', 'getSaveGames', 'isGameRunning', 'requestStartGame', 'requestSaveGame', 'requestLoadGame', 'on', 'off', 'emitEvent', 'setupGameEventHandlers', 'processTurnResult', 'processParagraphResult', 'storeAndQueueBlocks', 'normalizeHistoryBlock', 'dispatchTurnTags', 'isTimedCueTag', 'cueMarkersFromTags', 'dispatchChoices', 'dispatchInputMode', 'isStructuralTag', 'blocksFromTags', 'enqueueStructuredBlock', 'parseImageTagOptions', 'parseSfxTagOptions', 'parseMusicTagOptions', 'resolveAssetUrl', 'looksLikeAssetPath', 'attemptReconnect', 'getConnectionStatus', 'loadSocketIO' ]); } /** * Load Socket.IO client library * @returns {Promise} */ loadSocketIO() { // Use parent's loadScript method return this.loadScript('/socket.io/socket.io.js'); } /** * Initialize the module * @returns {Promise} - 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} - 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}`); // 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.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); 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) => { this.processTurnResult(data); }); 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); } } await this.storeAndQueueBlocks(turnBlocks); const choices = Array.isArray(data.choices) ? data.choices : []; const inputMode = data.inputMode || (choices.length > 0 ? 'choice' : 'none'); this.dispatchChoices(choices); this.dispatchInputMode(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 })); } 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 text = String(paragraph?.text || '').trim(); 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.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 : []), ...this.cueMarkersFromTags([ ...(Array.isArray(pending.cueTags) ? pending.cueTags : []), ...cueTags ]) ]; blocks.push({ type: 'paragraph', text, layoutText: paragraph.layoutText || text, 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); } 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) { console.error('Socket Client: Max reconnect attempts reached'); return; } this.reconnectAttempts++; const delay = this.reconnectDelay * this.reconnectAttempts; console.log(`Socket Client: Attempting to reconnect in ${delay}ms (attempt ${this.reconnectAttempts})`); setTimeout(() => { if (!this.isConnected) { this.connect(); } }, delay); } /** * 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.socket.emit('playerCommand', { command }); 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; } this.socket.emit('gameApi', { method, args }, (response) => { resolve(response || { success: false, error: 'empty_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]); } 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 };