/** * Socket Client Module * Handles WebSocket communication for receiving text fragments and game state */ import { BaseModule } from './base-module.js'; import { moduleRegistry } from './module-registry.js'; class SocketClientModule extends BaseModule { constructor() { super('socket-client', 'Socket Client'); this.socket = null; this.textBuffer = null; this.isConnected = false; this.reconnectAttempts = 0; this.maxReconnectAttempts = 5; this.reconnectDelay = 2000; // 2 seconds this.url = null; this.eventListeners = {}; this.defaultHost = 'localhost:3000'; // Default to localhost:3000 if not running in same origin } /** * Load module dependencies * @returns {Promise} - Resolves when dependencies are loaded */ async loadDependencies() { try { // We depend on the text-buffer module this.reportProgress(30, "Waiting for text buffer"); // Dynamically load Socket.IO client if not already loaded if (!window.io) { this.reportProgress(40, "Loading Socket.IO client"); await this.loadSocketIO(); this.reportProgress(45, "Socket.IO client loaded"); } return true; } catch (error) { console.error("Error loading Socket Client dependencies:", error); return false; } } /** * Load Socket.IO client library * @returns {Promise} */ loadSocketIO() { return new Promise((resolve, reject) => { // Check if Socket.IO is already loaded if (typeof window.io !== 'undefined') { resolve(); return; } // Load the Socket.IO client from the same server that served this page const script = document.createElement('script'); script.src = '/socket.io/socket.io.js'; // Socket.IO automatically serves this script.async = true; script.onload = () => { if (typeof window.io !== 'undefined') { resolve(); } else { reject(new Error('Failed to load Socket.IO client')); } }; script.onerror = () => { reject(new Error('Failed to load Socket.IO client script')); }; document.head.appendChild(script); }); } /** * Wait for dependencies to be ready */ async waitForDependencies() { try { // Wait for the text buffer module to be available const textBufferReady = await moduleRegistry.waitForModule('text-buffer', 10000); if (textBufferReady) { this.textBuffer = window.TextBuffer; this.reportProgress(60, "Text buffer module ready"); return true; } else { console.warn("Text buffer module not ready, Socket Client will have limited functionality"); return true; // Continue anyway for graceful degradation } } catch (error) { console.error("Error waiting for dependencies:", error); return false; } } /** * Initialize the module * @returns {Promise} - Resolves with success status */ async initialize() { try { // Use the current origin for the socket connection // This automatically handles the Docker port mapping situation 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: ['websocket', 'polling'] // Prefer 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) => { if (data && data.text && this.textBuffer) { this.processTextFragment(data.text); } }); // Special handling for introduction text this.socket.on('gameIntroduction', (data) => { if (data && data.introduction && this.textBuffer) { this.processTextFragment(data.introduction); } if (data && data.initialRoomDescription && this.textBuffer) { this.processTextFragment(data.initialRoomDescription); } }); } /** * Process a text fragment by adding it to the TextBuffer * @param {string} text - Text fragment to process */ processTextFragment(text) { if (!text) return; // Add text to the buffer if available if (this.textBuffer) { console.log(`Socket Client: Processing text fragment: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`); this.textBuffer.addText(text); } else { console.error('Socket Client: Text buffer not available'); // Attempt to get text buffer again this.textBuffer = moduleRegistry.getModule('text-buffer'); if (this.textBuffer) { this.textBuffer.addText(text); } else { // Emit a text event as fallback if no text buffer this.emitEvent('text', text); } } } /** * 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 }); return true; } catch (error) { console.error('Socket Client: Error sending command:', error); return false; } } /** * Request to start a new game * @returns {boolean} - Success status */ requestStartGame() { if (!this.isConnected || !this.socket) { console.error('Socket Client: Not connected, cannot start game'); return false; } try { this.socket.emit('startGame'); return true; } catch (error) { console.error('Socket Client: Error starting game:', error); return false; } } /** * Request to save the current game state * @returns {boolean} - Success status */ requestSaveGame() { if (!this.isConnected || !this.socket) { console.error('Socket Client: Not connected, cannot save game'); return false; } try { this.socket.emit('saveGame'); return true; } catch (error) { console.error('Socket Client: Error saving game:', error); return false; } } /** * Request to load a saved game state * @returns {boolean} - Success status */ requestLoadGame() { if (!this.isConnected || !this.socket) { console.error('Socket Client: Not connected, cannot load game'); return false; } try { this.socket.emit('loadGame'); return true; } catch (error) { console.error('Socket Client: Error loading game:', error); return false; } } /** * 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(); // Register with the module registry moduleRegistry.register(SocketClient); // Export the module export { SocketClient }; // Keep a reference in window for loader system window.SocketClient = SocketClient;