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

440 lines
14 KiB
JavaScript

/**
* 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<void>}
*/
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<boolean>} - 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<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}`);
// 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;