import { EventEmitter } from '$lib/utils/EventEmitter'; import type { GmcpHandler } from '$lib/gmcp/GmcpHandler'; // IAC codes for telnet negotiation enum TelnetCommand { IAC = 255, // Interpret As Command DONT = 254, DO = 253, WONT = 252, WILL = 251, SB = 250, // Subnegotiation Begin SE = 240, // Subnegotiation End GMCP = 201, // Generic MUD Communication Protocol } interface MudConnectionOptions { host: string; port: number; gmcpHandler?: GmcpHandler; useSSL?: boolean; } export class MudConnection extends EventEmitter { private host: string; private port: number; private useSSL: boolean; private webSocket: WebSocket | null = null; private gmcpHandler: GmcpHandler | null = null; private buffer: number[] = []; private connected: boolean = false; private negotiationBuffer: number[] = []; private isInIAC: boolean = false; private inSubnegotiation: boolean = false; private simulationMode: boolean = false; // Disable simulation mode to use real connections constructor(options: MudConnectionOptions) { super(); this.host = options.host; this.port = options.port; this.useSSL = options.useSSL || false; this.gmcpHandler = options.gmcpHandler || null; } /** * Connect to the MUD server */ public connect(): void { // For development/testing purposes, we'll use a simulated connection if (this.simulationMode) { this.connectSimulated(); return; } // Connect through the standalone WebSocket server on port 3001 const wsProtocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; const wsHost = `${window.location.hostname}:3001`; const url = `${wsProtocol}://${wsHost}/mud-ws?host=${encodeURIComponent(this.host)}&port=${this.port}&useSSL=${this.useSSL}`; console.log('Connecting to WebSocket server:', url); this.webSocket = new WebSocket(url); this.webSocket.binaryType = 'arraybuffer'; this.webSocket.onopen = () => { this.connected = true; this.emit('connected'); // Send GMCP negotiation upon connection if (this.gmcpHandler) { this.sendIAC(TelnetCommand.WILL, TelnetCommand.GMCP); } }; this.webSocket.onclose = () => { this.connected = false; this.emit('disconnected'); }; this.webSocket.onerror = (error) => { console.error('WebSocket error:', error); this.emit('error', `WebSocket error: Connection to ${this.host}:${this.port} failed. Please check your settings and ensure the MUD server is running.`); }; this.webSocket.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { // Binary data this.handleIncomingData(new Uint8Array(event.data)); } else if (typeof event.data === 'string') { // Text data this.emit('received', event.data); } else if (event.data instanceof Blob) { // Blob data (sometimes WebSockets send this instead of ArrayBuffer) const reader = new FileReader(); reader.onload = () => { if (reader.result instanceof ArrayBuffer) { this.handleIncomingData(new Uint8Array(reader.result)); } }; reader.readAsArrayBuffer(event.data); } }; } /** * Connect to a simulated MUD server for testing */ private connectSimulated(): void { console.log('Using simulated MUD connection'); // Simulate connection delay setTimeout(() => { this.connected = true; this.emit('connected'); // Send welcome message const welcomeMessage = ` ============================================================ | Welcome to ${this.host} | ============================================================ This is a simulated connection for testing purposes. Commands you type will be echoed back to the terminal. Type 'help' for a list of available commands. Type 'look' to see the current room. `; setTimeout(() => { this.emit('received', welcomeMessage); }, 500); }, 1000); } /** * Send text to the MUD server */ public send(text: string): void { if (!this.connected) { throw new Error('Not connected to MUD server'); } // In simulation mode, generate appropriate responses if (this.simulationMode) { this.handleSimulatedCommand(text); return; } if (!this.webSocket) { throw new Error('WebSocket not initialized'); } // Check if the WebSocket is in a valid state for sending if (this.webSocket.readyState !== WebSocket.OPEN) { this.emit('error', `Cannot send message: WebSocket is not open (state: ${this.webSocket.readyState})`); return; } try { // Append newline to the text const data = new TextEncoder().encode(text + '\n'); this.webSocket.send(data); // Emit the data for possible triggers this.emit('sent', text); } catch (error) { console.error('Error sending data:', error); this.emit('error', `Failed to send message: ${error.message}`); } } /** * Handle commands in simulation mode */ private handleSimulatedCommand(command: string): void { // Emit that we sent the command this.emit('sent', command); // Process command with a small delay to simulate network latency setTimeout(() => { const cmd = command.trim().toLowerCase(); if (cmd === 'help') { this.emit('received', ` Available commands: - help: Show this help message - look: Look at the current room - say : Say something - who: Show who's online - inventory: Show your inventory - quit: Disconnect `); } else if (cmd === 'look') { this.emit('received', ` The Testing Room ================ You are in a simple testing room. The walls are white and featureless, with a single door to the north. A sign on the wall reads "Simulation Mode". Obvious exits: north You see a test object here. `); } else if (cmd.startsWith('say ')) { const message = command.substring(4); this.emit('received', `You say, "${message}"\n`); } else if (cmd === 'who') { this.emit('received', ` Players online: - TestPlayer1 (Idle) - TestPlayer2 (AFK) - You Total: 3 players `); } else if (cmd === 'inventory' || cmd === 'i') { this.emit('received', ` You are carrying: - a test item - a notebook - some coins (15) `); } else if (cmd === 'quit' || cmd === 'exit') { this.emit('received', 'Goodbye! Thanks for testing.\n'); setTimeout(() => this.disconnect(), 1000); } else { this.emit('received', `Unknown command: ${command}\nType 'help' for a list of commands.\n`); } }, 200); } /** * Disconnect from the MUD server */ public disconnect(): void { if (this.simulationMode) { this.connected = false; this.emit('disconnected'); return; } if (this.webSocket) { this.webSocket.close(); this.webSocket = null; } } /** * Handle incoming data from the MUD server */ private handleIncomingData(data: Uint8Array): void { console.log(`Received ${data.length} bytes from server`); // Quickly check if we need to handle telnet negotiation let containsIAC = false; for (let i = 0; i < data.length; i++) { if (data[i] === TelnetCommand.IAC) { containsIAC = true; break; } } // Fast path if no IAC codes if (!containsIAC && !this.isInIAC) { const text = new TextDecoder().decode(data); this.emit('received', text); return; } // Debug: Log the raw bytes if IAC is present if (containsIAC) { const hexData = Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(' '); console.log(`Data with IAC: ${hexData}`); } // Process each byte in the incoming data for (let i = 0; i < data.length; i++) { const byte = data[i]; if (this.isInIAC) { // Add byte to negotiation buffer this.negotiationBuffer.push(byte); // Check for special sequences if (this.inSubnegotiation) { // Inside subnegotiation - look for IAC SE if (byte === TelnetCommand.SE && this.negotiationBuffer.length > 0 && this.negotiationBuffer[this.negotiationBuffer.length - 2] === TelnetCommand.IAC) { console.log('FOUND IAC SE SEQUENCE - End of subnegotiation'); // Process the complete subnegotiation this.handleCompleteSubnegotiation(); // Reset state this.isInIAC = false; this.inSubnegotiation = false; this.negotiationBuffer = []; } } else if (this.negotiationBuffer.length === 2) { // After IAC, check what command it is if (byte === TelnetCommand.SB) { // Start of subnegotiation this.inSubnegotiation = true; console.log('Started subnegotiation - waiting for option code'); } else if (byte === TelnetCommand.WILL || byte === TelnetCommand.DO) { // Need one more byte for option console.log(`Telnet WILL/DO command, waiting for option`); } else { // Simple 3-byte command this.processSimpleTelnetCommand(); this.isInIAC = false; this.negotiationBuffer = []; } } else if (this.negotiationBuffer.length === 3 && !this.inSubnegotiation) { // Complete 3-byte command like IAC WILL X or IAC DO X this.processSimpleTelnetCommand(); this.isInIAC = false; this.negotiationBuffer = []; } } else if (byte === TelnetCommand.IAC) { // Start of telnet command this.isInIAC = true; this.negotiationBuffer = [byte]; console.log('Found IAC - start of telnet command'); } else { // Normal data byte, add to buffer this.buffer.push(byte); } } // Process any complete text in the buffer if (this.buffer.length > 0) { const text = new TextDecoder().decode(new Uint8Array(this.buffer)); this.buffer = []; // Emit the received text for display and trigger processing this.emit('received', text); } } /** * Process a simple telnet command (3 bytes: IAC CMD OPTION) */ private processSimpleTelnetCommand(): void { try { const [iac, command, option] = this.negotiationBuffer; console.log(`Processing telnet command: IAC(${iac}) ${command} ${option}`); // Handle specific commands if ((command === TelnetCommand.WILL || command === TelnetCommand.DO) && option === TelnetCommand.GMCP) { console.log('Server indicates WILL/DO GMCP, responding with DO GMCP'); // Server wants to use GMCP, we'll respond with IAC DO GMCP this.sendIAC(TelnetCommand.DO, TelnetCommand.GMCP); // And request Core.Hello if (this.gmcpHandler) { console.log('Requesting GMCP capabilities'); this.gmcpHandler.requestCapabilities(); } } } catch (error) { console.error('Error processing telnet command:', error); } } /** * Handle a complete telnet subnegotiation sequence */ private handleCompleteSubnegotiation(): void { try { console.log(`Handling subnegotiation, buffer length: ${this.negotiationBuffer.length}`); // Check if this is a GMCP subnegotiation // IAC SB GMCP ... IAC SE // Indexes: 0 1 2 ... -2 -1 if (this.negotiationBuffer.length >= 5 && this.negotiationBuffer[2] === TelnetCommand.GMCP) { console.log('Processing complete GMCP subnegotiation'); try { // Extract the GMCP data (skip IAC SB GMCP, and the final IAC SE) const gmcpData = this.negotiationBuffer.slice(3, -2); const gmcpText = new TextDecoder().decode(new Uint8Array(gmcpData)); console.log('RECEIVED GMCP DATA:', gmcpText); // Process the GMCP message if (this.gmcpHandler) { setTimeout(() => { try { console.log('Passing GMCP to handler:', gmcpText); this.gmcpHandler?.handleGmcpMessage(gmcpText); } catch (error) { console.error('Error in GMCP handler:', error); } }, 0); } else { console.warn('No GMCP handler available for message:', gmcpText); } } catch (error) { console.error('Error processing GMCP data:', error, 'Buffer:', this.negotiationBuffer); } } else { console.log(`Non-GMCP subnegotiation complete, option: ${this.negotiationBuffer[2]}`); } } catch (error) { console.error('Error handling subnegotiation:', error); } } /** * Send a telnet IAC sequence */ private sendIAC(command: TelnetCommand, option: TelnetCommand): void { if (!this.connected || !this.webSocket) { return; } const data = new Uint8Array([TelnetCommand.IAC, command, option]); this.webSocket.send(data); } /** * Send a GMCP message */ public sendGmcp(module: string, data: any): void { if (!this.connected) { return; } if (this.simulationMode) { console.log(`GMCP send (simulated): ${module}`, data); return; } if (!this.webSocket) { return; } const gmcpString = `${module} ${JSON.stringify(data)}`; const gmcpData = new TextEncoder().encode(gmcpString); // Create the IAC SB GMCP IAC SE sequence const telnetSequence = new Uint8Array([ TelnetCommand.IAC, TelnetCommand.SB, TelnetCommand.GMCP, ...gmcpData, TelnetCommand.IAC, TelnetCommand.SE ]); this.webSocket.send(telnetSequence); } }