mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
execute_code - Cancellation now reaches inside the SDK via an AsyncLocalStorage scope: timeout, MCP cancel, or a newer call rejects pending waits and blocks further action packets, including those inside walkTo(). - One execution per bot: a new call supersedes the running one and starts once it has unwound; disconnect_bot does the same. - Cancelled user code parks on bot/sdk calls instead of throwing, so a catch-all loop can't spin and starve the server. - Console capture is routed per execution, so concurrent bots no longer clobber each other's output; leftover logs go to stderr. - Stale world state warns and still runs, so a stale client can be diagnosed. - Credentials are redacted from logs, results and launch messages. SDK - bot.mineRock() waits for ore plus Mining XP; interactLoc docs note that success can mean the action only started. - walkTo() takes options.timeout (default 120s), refuses stale state, and reports the real last position. - runScript uses the same cancellation scope for timeouts and signals. Docs trimmed of duplicated or stale execute_code text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
216 lines
7.1 KiB
TypeScript
216 lines
7.1 KiB
TypeScript
// Bot connection manager
|
|
// Supports multiple simultaneous bot connections
|
|
|
|
import { BotSDK, deriveGatewayUrl } from '../../sdk/index';
|
|
import { BotActions } from '../../sdk/actions';
|
|
import { readFile } from 'fs/promises';
|
|
import { join } from 'path';
|
|
import { existsSync } from 'fs';
|
|
import { withExecutionDeadline, throwIfExecutionAborted } from '../../sdk/execution-context';
|
|
|
|
export interface BotConnection {
|
|
/** Folder name as supplied at first connection; lookup is case-insensitive. */
|
|
name?: string;
|
|
sdk: BotSDK;
|
|
bot: BotActions;
|
|
username: string;
|
|
connected: boolean;
|
|
unsubscribeConnectionState?: () => void;
|
|
/**
|
|
* High-water tick for gameMessages already shown to the agent. Used by
|
|
* the MCP server's formatter so repeated execute_code calls only render
|
|
* NEW chat / system messages. Internal — not part of the SDK surface.
|
|
*/
|
|
lastShownMessageTick: number;
|
|
}
|
|
|
|
class BotManager {
|
|
private connections: Map<string, BotConnection> = new Map();
|
|
private defaultGatewayUrl = 'ws://localhost:7780';
|
|
|
|
/**
|
|
* Connect to a bot by name.
|
|
* If password is not provided, loads credentials from bots/{name}/bot.env
|
|
*/
|
|
async connect(name: string, password?: string, gatewayUrl?: string): Promise<BotConnection> {
|
|
const key = name.toLowerCase();
|
|
// Check if already connected
|
|
if (this.connections.has(key)) {
|
|
const existing = this.connections.get(key)!;
|
|
if (existing.connected) {
|
|
return existing;
|
|
}
|
|
// Reconnect if disconnected
|
|
await this.connectWithRetry(existing.sdk);
|
|
existing.connected = true;
|
|
return existing;
|
|
}
|
|
|
|
let username = name;
|
|
let pwd = password;
|
|
let gateway = gatewayUrl || this.defaultGatewayUrl;
|
|
let showChat = true;
|
|
|
|
// Load credentials from bot.env if no password provided
|
|
if (!password) {
|
|
// Try cwd first, then fall back to the repo root (this file lives in mcp/api/,
|
|
// so the root is two levels up)
|
|
const cwdPath = join(process.cwd(), 'bots', name, 'bot.env');
|
|
const repoPath = join(import.meta.dir, '..', '..', 'bots', name, 'bot.env');
|
|
const envPath = existsSync(cwdPath) ? cwdPath : repoPath;
|
|
|
|
if (!existsSync(envPath)) {
|
|
throw new Error(`Bot "${name}" not found. Create it first with: bun bots/create-bot.ts ${name}`);
|
|
}
|
|
|
|
const envContent = await readFile(envPath, 'utf-8');
|
|
const env = this.parseEnv(envContent);
|
|
|
|
username = env.BOT_USERNAME || name;
|
|
pwd = env.PASSWORD;
|
|
|
|
// A bot.env GATEWAY_URL (e.g. a private fleet gateway) wins, as in sdk/cli.ts.
|
|
if (env.GATEWAY_URL) {
|
|
gateway = env.GATEWAY_URL;
|
|
} else if (env.SERVER) {
|
|
gateway = deriveGatewayUrl(env.SERVER);
|
|
}
|
|
|
|
// Allow opting out of player chat via SHOW_CHAT=false in bot.env.
|
|
// Default: true, so multi-bot scripts can see each other's speech.
|
|
if (env.SHOW_CHAT?.toLowerCase() === 'false') {
|
|
showChat = false;
|
|
}
|
|
}
|
|
|
|
if (!pwd) {
|
|
throw new Error(`No password provided for bot "${name}"`);
|
|
}
|
|
|
|
console.error(`[MCP] Connecting bot "${name}":`);
|
|
console.error(`[MCP] username: ${username}`);
|
|
console.error(`[MCP] gateway: ${gateway}`);
|
|
console.error('[MCP] credentials loaded');
|
|
|
|
const sdk = new BotSDK({
|
|
botUsername: username,
|
|
password: pwd,
|
|
gatewayUrl: gateway,
|
|
connectionMode: 'control',
|
|
autoReconnect: true, // Enable auto-reconnect for connection stability
|
|
autoLaunchBrowser: 'auto', // Auto-launch browser if session is stale
|
|
showChat, // Show other players' chat (default: true; opt out with SHOW_CHAT=false)
|
|
});
|
|
|
|
const bot = new BotActions(sdk);
|
|
|
|
// Connect with retry to handle race conditions
|
|
console.error(`[MCP] Starting connection...`);
|
|
await this.connectWithRetry(sdk);
|
|
console.error(`[MCP] Bot "${name}" connected!`);
|
|
|
|
const connection: BotConnection = {
|
|
name,
|
|
sdk,
|
|
bot,
|
|
username,
|
|
connected: true,
|
|
lastShownMessageTick: -1
|
|
};
|
|
|
|
// Track connection state changes
|
|
connection.unsubscribeConnectionState = sdk.onConnectionStateChange((state) => {
|
|
const wasConnected = connection.connected;
|
|
connection.connected = state === 'connected';
|
|
if (wasConnected && !connection.connected) {
|
|
console.error(`[MCP] Bot "${name}" connection lost (${state}), will auto-reconnect...`);
|
|
} else if (!wasConnected && connection.connected) {
|
|
console.error(`[MCP] Bot "${name}" reconnected!`);
|
|
}
|
|
});
|
|
|
|
this.connections.set(key, connection);
|
|
return connection;
|
|
}
|
|
|
|
private async connectWithRetry(sdk: BotSDK, maxAttempts = 3, timeoutMs = 30000): Promise<void> {
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
try {
|
|
await withExecutionDeadline(timeoutMs, () => sdk.connect(), () => { throw new Error(`Connection timed out after ${timeoutMs / 1000}s`); });
|
|
return; // success
|
|
} catch (err) {
|
|
await sdk.disconnect().catch(() => {});
|
|
throwIfExecutionAborted();
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (attempt < maxAttempts) {
|
|
const delay = attempt * 2000; // 2s, 4s backoff
|
|
console.error(`[MCP] Connection attempt ${attempt}/${maxAttempts} failed: ${msg}. Retrying in ${delay / 1000}s...`);
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
} else {
|
|
throw new Error(`Failed to connect after ${maxAttempts} attempts. Last error: ${msg}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disconnect a bot by name
|
|
*/
|
|
async disconnect(name: string): Promise<void> {
|
|
const connection = this.connections.get(name.toLowerCase());
|
|
if (!connection) {
|
|
throw new Error(`Bot "${name}" is not connected`);
|
|
}
|
|
|
|
console.error(`[MCP] Disconnecting bot "${name}"...`);
|
|
if (connection.unsubscribeConnectionState) {
|
|
connection.unsubscribeConnectionState();
|
|
}
|
|
await connection.sdk.disconnect();
|
|
connection.connected = false;
|
|
this.connections.delete(name.toLowerCase());
|
|
console.error(`[MCP] Bot "${name}" disconnected`);
|
|
}
|
|
|
|
/**
|
|
* Get a bot connection by name
|
|
*/
|
|
get(name: string): BotConnection | undefined {
|
|
return this.connections.get(name.toLowerCase());
|
|
}
|
|
|
|
/**
|
|
* List all connected bots
|
|
*/
|
|
list(): Array<{ name: string; username: string; connected: boolean }> {
|
|
return Array.from(this.connections.entries()).map(([name, conn]) => ({
|
|
name: conn.name ?? name,
|
|
username: conn.username,
|
|
connected: conn.connected
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Check if a bot is connected
|
|
*/
|
|
has(name: string): boolean {
|
|
return this.connections.has(name.toLowerCase());
|
|
}
|
|
|
|
private parseEnv(content: string): Record<string, string> {
|
|
const result: Record<string, string> = {};
|
|
for (const line of content.split('\n')) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
const [key, ...valueParts] = trimmed.split('=');
|
|
if (key && valueParts.length > 0) {
|
|
result[key.trim()] = valueParts.join('=').trim();
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const botManager = new BotManager();
|