2025-04-21 14:12:36 +02:00
import { EventEmitter } from '$lib/utils/EventEmitter' ;
2025-04-22 18:21:10 +02:00
import { GmcpHandler } from '$lib/gmcp/GmcpHandler' ;
2025-04-21 14:12:36 +02:00
// 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 ;
useSSL? : boolean ;
2025-04-22 18:21:10 +02:00
id : string ;
2025-04-21 14:12:36 +02:00
}
2025-04-22 18:21:10 +02:00
/**
* MudConnection - Handles a single connection to a MUD server
* Each instance has its own GMCP handler and maintains its own state
*/
2025-04-21 14:12:36 +02:00
export class MudConnection extends EventEmitter {
private host : string ;
private port : number ;
private useSSL : boolean ;
private webSocket : WebSocket | null = null ;
2025-04-22 18:21:10 +02:00
private gmcpHandler : GmcpHandler ;
2025-04-21 14:12:36 +02:00
private buffer : number [] = [];
private connected : boolean = false ;
private negotiationBuffer : number [] = [];
private isInIAC : boolean = false ;
private inSubnegotiation : boolean = false ;
2025-04-22 18:21:10 +02:00
public readonly id : string ;
2025-04-21 14:12:36 +02:00
constructor ( options : MudConnectionOptions ) {
super ();
this . host = options . host ;
this . port = options . port ;
this . useSSL = options . useSSL || false ;
2025-04-22 18:21:10 +02:00
this . id = options . id ;
// Create GMCP handler
this . gmcpHandler = new GmcpHandler ();
// Set up GMCP event forwarding
this . setupGmcpEvents ();
console . log ( `MudConnection created for ${ this . host } : ${ this . port } with ID ${ this . id } ` );
}
/**
* Set up event forwarding from GMCP handler
*/
private setupGmcpEvents () : void {
// Forward all GMCP events to listeners of this connection
this . gmcpHandler . on ( 'gmcp' , ( module , data ) => {
this . emit ( 'gmcp' , module , data );
});
// Forward specific module events (like gmcp:Core.Ping)
this . gmcpHandler . on ( '*' , ( eventName , ... args ) => {
if ( eventName . startsWith ( 'gmcp:' )) {
this . emit ( eventName , ... args );
}
});
// Handle GMCP events that need special processing
this . gmcpHandler . on ( 'playSound' , ( url , volume , loop ) => {
console . log ( `MudConnection forwarding playSound event: ${ url } ` );
this . emit ( 'playSound' , { url , volume , loop });
});
// Listen for sendGmcp events from the GMCP handler
this . gmcpHandler . on ( 'sendGmcp' , ( module , data ) => {
this . sendGmcp ( module , data );
});
2025-04-21 14:12:36 +02:00
}
/**
* Connect to the MUD server
*/
public connect () : void {
2025-04-22 18:21:10 +02:00
if ( this . connected ) {
console . log ( `Already connected to ${ this . host } : ${ this . port } ` );
2025-04-21 14:12:36 +02:00
return ;
}
2025-04-21 23:43:50 +02:00
// Determine the WebSocket URL based on environment
2025-04-21 14:12:36 +02:00
const wsProtocol = window . location . protocol === 'https:' ? 'wss' : 'ws' ;
2025-04-21 23:43:50 +02:00
let wsUrl ;
2025-04-21 14:12:36 +02:00
2025-04-21 23:43:50 +02:00
// In development, use port 3001
if ( window . location . hostname === 'localhost' || window . location . hostname === '127.0.0.1' ) {
wsUrl = ` ${ wsProtocol } :// ${ window . location . hostname } :3001/mud-ws?host= ${ encodeURIComponent ( this . host ) } &port= ${ this . port } &useSSL= ${ this . useSSL } ` ;
} else {
// In production, use the same domain & port as the web app
wsUrl = ` ${ wsProtocol } :// ${ window . location . host } /mud-ws?host= ${ encodeURIComponent ( this . host ) } &port= ${ this . port } &useSSL= ${ this . useSSL } ` ;
}
2025-04-21 14:12:36 +02:00
2025-04-22 18:21:10 +02:00
console . log ( `Connecting to WebSocket server: ${ wsUrl } ` );
2025-04-21 23:43:50 +02:00
this . webSocket = new WebSocket ( wsUrl );
2025-04-21 14:12:36 +02:00
this . webSocket . binaryType = 'arraybuffer' ;
this . webSocket . onopen = () => {
this . connected = true ;
2025-04-22 18:21:10 +02:00
console . log ( `Connected to ${ this . host } : ${ this . port } ` );
2025-04-21 14:12:36 +02:00
this . emit ( 'connected' );
// Send GMCP negotiation upon connection
2025-04-22 18:21:10 +02:00
console . log ( 'Sending GMCP negotiation' );
this . sendIAC ( TelnetCommand . WILL , TelnetCommand . GMCP );
2025-04-21 14:12:36 +02:00
};
this . webSocket . onclose = () => {
this . connected = false ;
2025-04-22 18:21:10 +02:00
console . log ( `Disconnected from ${ this . host } : ${ this . port } ` );
2025-04-21 14:12:36 +02:00
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 );
}
};
}
/**
* Send text to the MUD server
*/
public send ( text : string ) : void {
if ( ! this . connected ) {
throw new Error ( 'Not connected to MUD server' );
}
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 } ` );
}
}
/**
* Disconnect from the MUD server
*/
public disconnect () : void {
if ( this . webSocket ) {
this . webSocket . close ();
this . webSocket = null ;
}
}
/**
* Handle incoming data from the MUD server
*/
private handleIncomingData ( data : Uint8Array ) : void {
// 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 ;
}
}
2025-04-22 18:21:10 +02:00
// Debug: Log raw data for debugging if it contains IAC
if ( containsIAC ) {
const hexData = Array . from ( data ). map ( b => b . toString ( 16 ). padStart ( 2 , '0' )). join ( ' ' );
console . log ( `Raw data with IAC: ${ hexData } ` );
}
2025-04-21 14:12:36 +02:00
// Fast path if no IAC codes
if ( ! containsIAC && ! this . isInIAC ) {
const text = new TextDecoder (). decode ( data );
this . emit ( 'received' , text );
return ;
}
// 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 ) {
2025-04-22 18:21:10 +02:00
console . log ( 'End of subnegotiation found' );
2025-04-21 14:12:36 +02:00
// 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 ;
} else if ( byte === TelnetCommand . WILL || byte === TelnetCommand . DO ) {
// Need one more byte 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 ];
2025-04-22 18:21:10 +02:00
console . log ( 'IAC command detected' );
2025-04-21 14:12:36 +02:00
} 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 ;
// Handle specific commands
if (( command === TelnetCommand . WILL || command === TelnetCommand . DO ) && option === TelnetCommand . GMCP ) {
2025-04-22 18:21:10 +02:00
console . log ( 'Server supports GMCP, responding with DO GMCP' );
2025-04-21 14:12:36 +02:00
// Server wants to use GMCP, we'll respond with IAC DO GMCP
this . sendIAC ( TelnetCommand . DO , TelnetCommand . GMCP );
2025-04-22 18:21:10 +02:00
// Request GMCP capabilities
console . log ( 'Requesting GMCP capabilities' );
this . gmcpHandler . requestCapabilities ();
2025-04-21 14:12:36 +02:00
}
} catch ( error ) {
console . error ( 'Error processing telnet command:' , error );
}
}
/**
* Handle a complete telnet subnegotiation sequence
*/
private handleCompleteSubnegotiation () : void {
try {
2025-04-22 18:21:10 +02:00
// Debug buffer contents
const bufferHex = this . negotiationBuffer . map ( b => b . toString ( 16 ). padStart ( 2 , '0' )). join ( ' ' );
console . log ( `Processing subnegotiation, buffer: ${ bufferHex } ` );
2025-04-21 14:12:36 +02:00
// 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 ) {
2025-04-22 18:21:10 +02:00
console . log ( 'Processing GMCP subnegotiation' );
2025-04-21 14:12:36 +02:00
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 ));
2025-04-22 18:21:10 +02:00
console . log ( `GMCP message: ${ gmcpText } ` );
2025-04-21 14:12:36 +02:00
2025-04-22 18:21:10 +02:00
// Process the GMCP message immediately
console . log ( 'Passing GMCP to handler:' , gmcpText );
this . gmcpHandler . handleGmcpMessage ( gmcpText );
2025-04-21 14:12:36 +02:00
} catch ( error ) {
2025-04-22 18:21:10 +02:00
console . error ( 'Error processing GMCP data:' , error );
2025-04-21 14:12:36 +02:00
}
} else {
2025-04-22 18:21:10 +02:00
console . log ( `Non-GMCP subnegotiation received: ${ this . negotiationBuffer [ 2 ] } ` );
2025-04-21 14:12:36 +02:00
}
} 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 {
2025-04-22 18:21:10 +02:00
if ( ! this . connected || ! this . webSocket ) {
console . log ( 'Cannot send GMCP - not connected' );
2025-04-21 14:12:36 +02:00
return ;
}
2025-04-22 18:21:10 +02:00
console . log ( `Sending GMCP: ${ module } ` , data );
2025-04-21 14:12:36 +02:00
const gmcpString = ` ${ module } ${ JSON . stringify ( data ) } ` ;
const gmcpData = new TextEncoder (). encode ( gmcpString );
// Create the IAC SB GMCP <data> IAC SE sequence
const telnetSequence = new Uint8Array ([
TelnetCommand . IAC ,
TelnetCommand . SB ,
TelnetCommand . GMCP ,
... gmcpData ,
TelnetCommand . IAC ,
TelnetCommand . SE
]);
this . webSocket . send ( telnetSequence );
}
2025-04-22 18:21:10 +02:00
/**
* Get the GMCP handler associated with this connection
*/
public getGmcpHandler () : GmcpHandler {
return this . gmcpHandler ;
}
/**
* Check if the connection is active
*/
public isConnected () : boolean {
return this . connected ;
}
2025-04-21 14:12:36 +02:00
}