456 lines
14 KiB
TypeScript
456 lines
14 KiB
TypeScript
|
|
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 <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);
|
||
|
|
}
|
||
|
|
}
|