2025-04-21 14:12:36 +02:00
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 ;
}
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-21 23:43:50 +02:00
console . log ( 'Connecting to WebSocket server:' , wsUrl );
this . webSocket = new WebSocket ( wsUrl );
2025-04-21 14:12:36 +02:00
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 <message>: 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 <data> IAC SE sequence
const telnetSequence = new Uint8Array ([
TelnetCommand . IAC ,
TelnetCommand . SB ,
TelnetCommand . GMCP ,
... gmcpData ,
TelnetCommand . IAC ,
TelnetCommand . SE
]);
this . webSocket . send ( telnetSequence );
}
}