Files
max bittkerandClaude Opus 5.5 df3d8888ae feat(sdk,mcp): cancellable execute_code, bot.mineRock, bounded walkTo
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>
2026-10-12 14:30:19 -04:00

2095 lines
90 KiB
TypeScript

import type { GEState } from './ge-types';
export type { GEState, GEOffer, GEOfferRequest, GEActionResult } from './ge-types';
import { MarketClient, type GEAvailability, type MarketItems, type MarketQuote, type MarketHistory, type MarketItemsOptions, type MarketItemOptions, type MarketHistoryOptions } from './market';
export { MarketClient, GEUnavailableError } from './market';
export type { GEAvailability, MarketItems, MarketQuote, MarketHistory, MarketSort, MarketDays, MarketItemsOptions, MarketItemOptions, MarketHistoryOptions } from './market';
// Bot SDK - Standalone client for remote bot control
// Low-level WebSocket API that maps 1:1 to the action protocol
// Raw actions resolve when the browser validates/routes and dispatches them.
// Use BotActions when the caller needs observation of the resulting game effect.
import type {
BotWorldState,
BotAction,
ActionResult,
SkillState,
InventoryItem,
NearbyNpc,
NearbyPlayer,
NearbyLoc,
GroundItem,
DialogState,
BankItem,
SDKConfig,
ConnectionState,
SDKConnectionMode,
BotStatus,
PrayerState,
PrayerName,
GameMessage,
FindOptions
} from './types';
import type { TradeState } from './types';
import { PRAYER_INDICES, PRAYER_NAMES, PLAYER_CHAT_TYPES, isPlayerChat, TRADE_REQUEST_CHAT_TYPE } from './types';
import { ChatHistory } from './chat-history';
import { executionContext, executionWait, throwIfExecutionAborted } from './execution-context';
import { redactCredentials } from './redact';
import * as pathfinding from './pathfinding';
import { resolveInterfaceOption, shortestNameMatch, type InterfaceOptionSelector } from './action-quantity';
/**
* Apply {@link FindOptions} to a find* winner. `shortestNameMatch` already
* prefers a reachable match; `{reachable: true}` turns an unreachable-only
* result into null instead, so callers get "nothing usable" rather than a
* target whose interaction would fail with a silent `cant_reach`.
*/
// Player trade interface components (see server/webclient/src/bot/types.ts
// for the full trademain/tradeconfirm component map).
const TRADE_SIDE_INV_ID = 3322; // tradeside:inv - INV_BUTTON = Offer 1/5/10/All/X
const TRADE_MAIN_INV_ID = 3415; // trademain:inv - INV_BUTTON = Remove 1/5/10/All/X
const TRADE_MAIN_ACCEPT_ID = 3420; // trademain:accept
const TRADE_CONFIRM_ACCEPT_ID = 3546; // tradeconfirm:accept
function applyFindOptions<T extends { reachable?: boolean }>(match: T | null, options?: FindOptions): T | null {
if (options?.reachable === true && match?.reachable === false) return null;
return match;
}
function selectorLabel(selector: InterfaceOptionSelector): string {
if (typeof selector === 'string') return `"${selector}"`;
if (selector instanceof RegExp) return String(selector);
return `option "${selector.text}"`;
}
/**
* Derive the gateway WebSocket URL from a SERVER env value.
* - undefined/empty → ws://localhost:7780 (local default)
* - Full URL (ws:// or wss://) → used as-is
* - "localhost" or "localhost:PORT" → ws://localhost:PORT (plain WS)
* - anything else → wss://HOST/gateway (TLS, remote gateway path)
*/
export function deriveGatewayUrl(server?: string): string {
// GATEWAY_URL overrides derivation for hosts where SERVER doubles as the
// web origin but not the gateway (e.g. SERVER=localhost:8888 is the engine
// web port; the gateway is ws://localhost:7780). The lite runner honors
// the same variable.
if (typeof process !== 'undefined' && process.env?.GATEWAY_URL) return process.env.GATEWAY_URL;
if (!server) return 'ws://localhost:7780';
if (server.startsWith('ws://') || server.startsWith('wss://')) return server;
const isLocal = server === 'localhost' || server.startsWith('localhost:');
if (isLocal) {
return `ws://${server.includes(':') ? server : server + ':7780'}`;
}
return `wss://${server}/gateway`;
}
// Public servers whose gateway also listens on its own TLS port. Connecting
// there skips the game server's /gateway relay (which runs on the game's tick
// thread) and negotiates permessage-deflate, so state frames arrive ~6x
// smaller. Keyed by the relayed URL deriveGatewayUrl produces; HTTP endpoints
// keep using the relayed origin.
const DIRECT_GATEWAYS: Record<string, string> = {
'wss://rs-sdk-demo.fly.dev/gateway': 'wss://rs-sdk-demo.fly.dev:7443',
};
const directGatewayChecks = new Map<string, Promise<string>>();
/**
* The direct gateway WebSocket URL for a relayed one when its port answers,
* otherwise the URL unchanged. Checked once per process, so a network that
* blocks the port keeps working through the relay. GATEWAY_DIRECT=false opts out.
*/
export function preferDirectGateway(url: string, directGateways: Record<string, string> = DIRECT_GATEWAYS): Promise<string> {
const direct = directGateways[url];
if (!direct || (typeof process !== 'undefined' && process.env?.GATEWAY_DIRECT === 'false')) {
return Promise.resolve(url);
}
let check = directGatewayChecks.get(direct);
if (!check) {
check = fetch(direct.replace(/^ws/, 'http') + '/', { signal: AbortSignal.timeout(3000) })
.then(res => (res.ok ? direct : url), () => url);
directGatewayChecks.set(direct, check);
}
return check;
}
/** Drop a cached direct-gateway check so the next connect probes again. */
export function forgetDirectGateway(url: string, directGateways: Record<string, string> = DIRECT_GATEWAYS): void {
const direct = directGateways[url];
if (direct) directGatewayChecks.delete(direct);
}
interface SyncToSDKMessage {
type: 'sdk_connected' | 'sdk_state' | 'sdk_action_result' | 'sdk_error' | 'sdk_screenshot_response' | 'sdk_info';
success?: boolean;
state?: BotWorldState;
stateReceivedAt?: number; // Timestamp when gateway received state from bot
actionId?: string;
result?: ActionResult;
error?: string;
screenshotId?: string;
dataUrl?: string;
maxMessageLength?: number; // Server-configured chat cap (in sdk_connected / sdk_info)
}
interface PendingAction {
resolve: (result: ActionResult) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}
/**
* A dispatch that never produced a result: the browser client didn't answer in
* time, the socket closed mid-flight, or the gateway reported an error.
* sendAction converts these into failed ActionResults so a single dropped
* action reports itself instead of killing the caller's script.
*/
class ActionDispatchError extends Error {
constructor(message: string, readonly reason: 'timeout' | 'disconnected' | 'error') {
super(message);
// Own-property define rather than assignment: hosts that freeze
// Error.prototype make the inherited `name` non-writable and a plain
// assignment throws in strict mode (from ws.onclose, taking the process
// exit code with it).
Object.defineProperty(this, 'name', { value: 'ActionDispatchError', writable: true, configurable: true });
}
}
interface PendingScreenshot {
resolve: (dataUrl: string) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}
// Chat chunking lives in ./chunking so the gateway can share it without
// importing the full SDK; re-exported here for existing consumers and tests.
export { chunkMessage } from './chunking';
import { chunkMessage } from './chunking';
export { Spells, type SpellName } from './spells';
export class BotSDK {
readonly config: Required<SDKConfig>;
private ws: WebSocket | null = null;
private state: BotWorldState | null = null;
private stateReceivedAt: number = 0;
/**
* Server-configured max chat length, learned from the gateway handshake
* (sdk_connected / sdk_info). Defaults to the RS wire limit until known.
*/
private serverMaxMessageLength: number = 80;
/**
* Chat accumulated across state syncs. The client ring only holds 100
* messages (and each sync only carries 50), so without this an agent could
* never read further back than ~50 lines. See sdk/chat-history.ts.
*/
private chatHistory = new ChatHistory();
/** Cursor consumed by getNewChat(): value of chatHistory.seen at last read. */
private chatReadCursor: number = 0;
private pendingActions = new Map<string, PendingAction>();
private pendingScreenshots = new Map<string, PendingScreenshot>();
private stateListeners = new Set<(state: BotWorldState) => void>();
private connectionListeners = new Set<(state: ConnectionState, attempt?: number) => void>();
private connectPromise: Promise<void> | null = null;
private sdkClientId: string;
private temporaryDoorBlocks = new pathfinding.TemporaryDoorBlocklist();
/** True once the gateway has accepted our credentials (sdk_connected received). */
private authenticated = false;
// Reconnection state
private connectionState: ConnectionState = 'disconnected';
private reconnectAttempt = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private intentionalDisconnect = false;
constructor(config: SDKConfig) {
this.config = {
botUsername: config.botUsername,
password: config.password || '',
gatewayUrl: config.gatewayUrl || '',
host: config.host || 'localhost',
port: config.port || 7780,
connectionMode: config.connectionMode || 'control',
autoLaunchBrowser: config.autoLaunchBrowser ?? 'auto',
freshDataThreshold: config.freshDataThreshold ?? 3000,
browserLaunchUrl: config.browserLaunchUrl || '',
browserLaunchTimeout: config.browserLaunchTimeout || 30000,
readyTimeout: config.readyTimeout ?? 15000,
connectTimeout: config.connectTimeout ?? 30000,
actionTimeout: config.actionTimeout || 60000,
autoReconnect: config.autoReconnect ?? true,
reconnectMaxRetries: config.reconnectMaxRetries ?? Infinity,
reconnectBaseDelay: config.reconnectBaseDelay ?? 1000,
reconnectMaxDelay: config.reconnectMaxDelay ?? 30000,
showChat: config.showChat ?? true
};
this.sdkClientId = `sdk-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
// ============ Connection ============
/** Connect to the gateway WebSocket. */
async connect(): Promise<void> {
throwIfExecutionAborted();
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
return;
}
if (this.connectPromise) {
return this.connectPromise;
}
this.intentionalDisconnect = false;
const isReconnect = this.connectionState === 'reconnecting';
if (!isReconnect) {
this.setConnectionState('connecting');
}
// Auto-launch browser based on config
if (this.config.autoLaunchBrowser && !isReconnect) {
try {
const status = await this.checkBotStatus();
const shouldLaunch = this.shouldLaunchBrowser(status);
if (shouldLaunch) {
console.log(`[BotSDK] Launching browser...`);
await this.launchBrowser();
await this.waitForBotConnection();
}
} catch (error) {
console.error(`[BotSDK] Auto-launch failed:`, error);
// Continue anyway - maybe gateway is local and status endpoint doesn't work yet
}
}
const configuredUrl = this.config.gatewayUrl || `ws://${this.config.host}:${this.config.port}`;
throwIfExecutionAborted();
this.connectPromise = preferDirectGateway(configuredUrl).then(url => new Promise<void>((resolve, reject) => {
if (this.intentionalDisconnect) {
reject(new Error('Disconnected while connecting'));
return;
}
this.ws = new WebSocket(url);
// One deadline for the whole handshake - socket open AND the
// gateway's sdk_connected/sdk_error answer. The old timer was
// cleared on socket open, so a gateway that accepted the socket
// but never answered (dead session, half-open TCP during an
// outage) parked connect() forever with every liveness signal
// reading healthy (bug report mscll1sc). Sized above the
// gateway's own 15s auth bound so a slow-but-alive login server
// doesn't false-negative.
let handshakeDone = false;
const deadline = setTimeout(() => {
reject(new Error(`Gateway handshake timed out after ${this.config.connectTimeout}ms`));
this.ws?.close();
}, this.config.connectTimeout);
this.ws.onopen = () => {
this.send({
type: 'sdk_connect',
username: this.config.botUsername,
password: this.config.password,
clientId: this.sdkClientId,
mode: this.config.connectionMode
});
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data);
};
this.ws.onclose = () => {
console.warn(`[LOGOUT DEBUG] SDK WebSocket closed - autoReconnect=${this.config.autoReconnect}, intentionalDisconnect=${this.intentionalDisconnect}`);
// A close before the handshake completed must settle the
// pending connect() - otherwise its awaiters hang forever
// while the auto-reconnect below builds a fresh promise.
if (!handshakeDone) {
clearTimeout(deadline);
reject(new Error('Connection closed before the gateway handshake completed'));
}
this.connectPromise = null;
this.ws = null;
this.authenticated = false;
for (const [actionId, pending] of this.pendingActions) {
clearTimeout(pending.timeout);
pending.reject(new ActionDispatchError('Connection closed', 'disconnected'));
}
this.pendingActions.clear();
if (this.config.autoReconnect && !this.intentionalDisconnect) {
console.warn('[LOGOUT DEBUG] SDK scheduling auto-reconnect');
this.scheduleReconnect();
} else {
this.setConnectionState('disconnected');
}
};
this.ws.onerror = (error) => {
console.warn('[LOGOUT DEBUG] SDK WebSocket error event');
clearTimeout(deadline);
reject(new Error('WebSocket error'));
};
const checkConnected = (event: MessageEvent) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'sdk_connected') {
handshakeDone = true;
clearTimeout(deadline);
this.ws?.removeEventListener('message', checkConnected);
this.reconnectAttempt = 0;
this.authenticated = true;
if (typeof msg.maxMessageLength === 'number' && msg.maxMessageLength > 0) {
this.serverMaxMessageLength = msg.maxMessageLength;
}
this.setConnectionState('connected');
// Gateway accepted us. Anything that goes wrong past this point is a
// game-state problem, not a connection problem.
const readyTimeout = this.config.readyTimeout;
if (readyTimeout <= 0) {
// Caller opted to wait for state itself.
resolve();
return;
}
// Automatically wait for game state to be ready
this.waitForReady(readyTimeout)
.then(() => {
console.log('[BotSDK] Connected and game state ready');
resolve();
})
.catch((error) => {
console.warn('[BotSDK] Connected but game state not ready:', error.message);
console.warn('[BotSDK] Continuing anyway - state may load later');
resolve(); // Still resolve - allow usage even if state isn't fully ready
});
} else if (msg.type === 'sdk_error') {
// Handle authentication errors during connection
handshakeDone = true;
clearTimeout(deadline);
this.ws?.removeEventListener('message', checkConnected);
const errorMessage = msg.error || 'Authentication failed';
console.error(`[BotSDK] Connection error: ${errorMessage}`);
// Disable auto-reconnect for auth failures - they won't succeed on retry
this.intentionalDisconnect = true;
reject(new Error(errorMessage));
this.ws?.close();
}
} catch {}
};
this.ws.addEventListener('message', checkConnected);
})).catch(error => {
// Re-probe on the next attempt: a direct port that stopped answering
// falls back to the relay instead of failing every reconnect.
forgetDirectGateway(configuredUrl);
throw error;
});
return this.connectPromise;
}
private setConnectionState(state: ConnectionState, attempt?: number) {
this.connectionState = state;
for (const listener of this.connectionListeners) {
try {
listener(state, attempt);
} catch (e) {
console.error('Connection listener error:', e);
}
}
}
private scheduleReconnect() {
if (this.reconnectAttempt >= this.config.reconnectMaxRetries) {
console.log(`[BotSDK] Max reconnection attempts (${this.config.reconnectMaxRetries}) reached, giving up`);
this.setConnectionState('disconnected');
return;
}
this.reconnectAttempt++;
this.setConnectionState('reconnecting', this.reconnectAttempt);
const delay = Math.min(
this.config.reconnectBaseDelay * Math.pow(2, this.reconnectAttempt - 1),
this.config.reconnectMaxDelay
);
console.log(`[BotSDK] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempt})`);
// Reconnect belongs to the connection, not to the execution that first
// opened its socket; that execution may already be cancelled/finished.
this.reconnectTimer = executionContext.exit(() => setTimeout(async () => {
this.reconnectTimer = null;
try {
await this.connect();
console.log(`[BotSDK] Reconnected successfully after ${this.reconnectAttempt} attempt(s)`);
} catch (e) {
console.log(`[BotSDK] Reconnection attempt ${this.reconnectAttempt} failed`);
}
}, delay));
}
/** Disconnect from the gateway. */
async disconnect(): Promise<void> {
this.intentionalDisconnect = true;
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.ws) {
// Wait for websocket to actually close
await new Promise<void>((resolve) => {
if (this.ws!.readyState === WebSocket.CLOSED) {
resolve();
return;
}
this.ws!.addEventListener('close', () => resolve(), { once: true });
this.ws!.close();
});
this.ws = null;
}
this.connectPromise = null;
this.reconnectAttempt = 0;
this.setConnectionState('disconnected');
}
/** Check if WebSocket is connected. */
isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
/**
* Check if the gateway has accepted our credentials.
* True means the transport and auth are both fine - if state is still missing after
* this, the problem is that no game client is logged in, not the connection.
*/
isAuthenticated(): boolean {
return this.authenticated && this.isConnected();
}
/** Get current connection state (connecting, connected, reconnecting, disconnected). */
getConnectionState(): ConnectionState {
return this.connectionState;
}
/** Get current reconnection attempt number. */
getReconnectAttempt(): number {
return this.reconnectAttempt;
}
onConnectionStateChange(listener: (state: ConnectionState, attempt?: number) => void): () => void {
this.connectionListeners.add(listener);
return () => this.connectionListeners.delete(listener);
}
/** Get connection mode (control or observe). */
getConnectionMode(): SDKConnectionMode {
return this.config.connectionMode;
}
// ============ Bot Status & Auto-Launch ============
/**
* Check bot status via gateway HTTP endpoint.
* Returns info about whether bot is connected and who else is controlling/observing.
*/
async checkBotStatus(): Promise<BotStatus> {
const statusUrl = this.getStatusUrl();
try {
console.log(`[BotSDK] Checking bot status via URL: ${statusUrl}`);
const response = await fetch(statusUrl);
if (!response.ok) {
console.log(`[BotSDK] Status check HTTP error: ${response.status} ${response.statusText} (URL: ${statusUrl})`);
throw new Error(`Status check failed: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
// If endpoint doesn't exist or bot not found, return disconnected status
const errorMsg = error instanceof Error ? error.message : String(error);
console.log(`[BotSDK] Status check failed: ${errorMsg} (URL: ${statusUrl})`);
return {
status: 'dead',
inGame: false,
stateAge: null,
controllers: [],
observers: [],
player: null,
};
}
}
/**
* Check if bot is currently connected to gateway.
*/
async isBotConnected(): Promise<boolean> {
const status = await this.checkBotStatus();
return status.status !== 'dead';
}
/**
* Determine if browser should be launched based on config and current status.
* - 'auto': Launch only if session is dead or stale
* - true: Launch if bot not connected (dead)
* - false: Never launch
*/
private shouldLaunchBrowser(status: BotStatus): boolean {
if (this.config.autoLaunchBrowser === false) {
return false;
}
if (this.config.autoLaunchBrowser === true) {
// Legacy behavior: launch if not connected
if (status.status === 'dead') {
console.log(`[BotSDK] Bot not connected`);
return true;
}
console.log(`[BotSDK] Bot already connected (${status.controllers.length} controllers, ${status.observers.length} observers)`);
return false;
}
// 'auto' mode: use session status to decide
if (status.status === 'dead') {
console.log(`[BotSDK] Bot session is dead`);
return true;
}
if (status.status === 'stale') {
console.log(`[BotSDK] Bot session is stale (no recent state updates)`);
// Note: this will trigger graceful takeover via save_and_disconnect
return true;
}
console.log(`[BotSDK] Active client detected, skipping browser launch`);
return false;
}
/**
* Launch native browser to client URL.
* Uses the `open` package for cross-platform support (macOS, Windows, Linux, WSL).
* Falls back to printing the URL if no browser can be opened.
*/
async launchBrowser(): Promise<void> {
throwIfExecutionAborted();
const url = this.buildClientUrl();
console.log(`[BotSDK] Opening browser: ${redactCredentials(url)}`);
try {
const open = (await import('open')).default;
throwIfExecutionAborted();
await open(url);
} catch (e) {
console.warn(`[BotSDK] Could not open browser automatically.`);
console.warn(`[BotSDK] Open the bot client for ${this.config.botUsername} manually using the credentials in its bot.env file.`);
}
}
/**
* Wait for bot to connect to gateway after browser launch.
*/
async waitForBotConnection(timeout?: number): Promise<void> {
const timeoutMs = timeout || this.config.browserLaunchTimeout;
const startTime = Date.now();
const pollInterval = 500;
let attemptCount = 0;
console.log(`[BotSDK] Waiting for bot to connect and load game (timeout: ${timeoutMs}ms)...`);
while (Date.now() - startTime < timeoutMs) {
throwIfExecutionAborted();
attemptCount++;
const elapsed = Date.now() - startTime;
const status = await this.checkBotStatus();
console.log(`[BotSDK] Poll attempt ${attemptCount} (${elapsed}ms): status="${status.status}", inGame=${status.inGame}, controllers=${status.controllers.length}, observers=${status.observers.length}`);
if (status.status !== 'dead' && status.inGame) {
console.log(`[BotSDK] Bot connected and in-game!`);
return;
}
await new Promise(resolve => setTimeout(resolve, pollInterval));
}
throw new Error(`Bot did not fully load within ${timeoutMs}ms`);
}
/** Check whether this server enables GE. No connected bot or physical access needed. */
getGEAvailability(): Promise<GEAvailability> {
return new MarketClient(this.buildClientUrl()).status();
}
/** Read public Grand Exchange prices. Uses the game origin; no login or physical access needed. */
getMarketItems(query = '', options: MarketItemsOptions = {}): Promise<MarketItems> {
return new MarketClient(this.buildClientUrl()).items(query, options);
}
/** Read current offers, latest trade and selected-period volume for a canonical item ID. */
getMarketItem(itemId: number, options: MarketItemOptions = {}): Promise<MarketQuote> {
return new MarketClient(this.buildClientUrl()).item(itemId, options);
}
/** Read historical trade prices and volumes in hour/day UTC buckets (milliseconds). */
getMarketHistory(itemId: number, options: MarketHistoryOptions = {}): Promise<MarketHistory> {
return new MarketClient(this.buildClientUrl()).history(itemId, options);
}
/** Private GE state while at an open physical exchange. Null when closed or unsupported. */
getGEState(): GEState | null {
return this.state?.modalOpen ? this.state.ge ?? null : null;
}
/** Filter the open in-game GE catalogue. Success means dispatch; wait for the acknowledged query. */
async sendGESearch(query: string): Promise<ActionResult> {
if (!/^[a-zA-Z0-9 '()*-]{0,48}$/.test(query)) return {success: false, message: 'GE search accepts up to 48 letters, digits, spaces and apostrophes/parentheses/*/hyphens.', reason: 'invalid_argument'};
if (!this.getGEState()) {
const availability = await this.getGEAvailability();
if (!availability.enabled) return { success: false, message: availability.message ?? 'Grand Exchange is not available on this server.', reason: 'ge_unavailable' };
}
if (this.getGEState()?.screen !== 'catalog') return {success: false, message: 'Open the GE catalogue at the exchange first.', reason: 'no_interface'};
return this.sendAction({type: 'searchGE', query, reason: 'SDK'});
}
private getStatusUrl(): string {
const gatewayUrl = this.config.gatewayUrl || `http://${this.config.host}:${this.config.port}`;
// Convert ws:// to http:// and wss:// to https://
const httpUrl = gatewayUrl
.replace(/^ws:/, 'http:')
.replace(/^wss:/, 'https:')
.replace(/\/gateway$/, ''); // Remove /gateway suffix if present
return `${httpUrl}/status/${encodeURIComponent(this.config.botUsername)}`;
}
private buildClientUrl(): string {
if (this.config.browserLaunchUrl) {
const url = new URL(this.config.browserLaunchUrl);
url.searchParams.set('bot', this.config.botUsername);
url.searchParams.set('password', this.config.password);
return url.toString();
}
// Derive from gateway URL
const gatewayUrl = this.config.gatewayUrl || `ws://${this.config.host}:${this.config.port}`;
if (gatewayUrl.includes('localhost') || gatewayUrl.includes('127.0.0.1')) {
// Local development: assume client on port 8888
return `http://localhost:8888/bot?bot=${encodeURIComponent(this.config.botUsername)}&password=${encodeURIComponent(this.config.password)}`;
}
// Remote: assume same host with /bot path
const httpUrl = gatewayUrl
.replace(/^ws:/, 'http:')
.replace(/^wss:/, 'https:')
.replace(/\/gateway$/, '');
return `${httpUrl}/bot?bot=${encodeURIComponent(this.config.botUsername)}&password=${encodeURIComponent(this.config.password)}`;
}
/** Wait for WebSocket connection to be established. */
async waitForConnection(timeout: number = 60000): Promise<void> {
if (this.isConnected()) return;
return executionWait<void>((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error('waitForConnection timed out')), timeout);
const unsubscribe = this.onConnectionStateChange(state => {
if (state === 'connected') resolve();
else if (state === 'disconnected') reject(new Error('Connection failed'));
});
return () => { clearTimeout(timeoutId); unsubscribe(); };
});
}
// ============ State Access (Synchronous) ============
/** Get current game state snapshot. */
getState(): BotWorldState | null {
return this.state;
}
/** Get timestamp when state was last received (ms since epoch) */
getStateReceivedAt(): number {
return this.stateReceivedAt;
}
/** Get age of current state in milliseconds */
getStateAge(): number {
if (this.stateReceivedAt === 0) return 0;
return Date.now() - this.stateReceivedAt;
}
/**
* Read recent chat messages. Returns player chat (public + PMs) by default,
* newest last. Reads from the SDK's accumulated history — up to 500
* messages retained since connect — so old lines survive both system spam
* (level-ups, combat) and the client's own 100-deep ring eviction.
*
* @param opts.limit Max messages to return (default 20; pass 0 for the full history).
* @param opts.types Chat type codes to include (default player chat: 1/2/3/6/7). Pass e.g. `[0]` for system messages.
* @param opts.includeSelf Include your own messages (default false).
*/
getChat(opts: { limit?: number; types?: readonly number[]; includeSelf?: boolean } = {}): GameMessage[] {
const { limit = 20, types = PLAYER_CHAT_TYPES, includeSelf = false } = opts;
const typeSet = new Set(types);
const filtered = this.chatHistory.all().filter(m =>
typeSet.has(m.type) && (includeSelf || !m.fromSelf)
);
return limit > 0 ? filtered.slice(-limit) : filtered;
}
/**
* Read only chat messages that have arrived since the last call (cursor-based,
* newest last). Repeat polls never re-show the same message — no need to
* hand-roll a baseline. The first call returns everything seen since
* connect. Excludes your own messages by default.
*
* @param opts.types Chat type codes to include (default player chat: 1/2/3/6/7).
* @param opts.includeSelf Include your own messages (default false).
*/
getNewChat(opts: { types?: readonly number[]; includeSelf?: boolean } = {}): GameMessage[] {
const { types = PLAYER_CHAT_TYPES, includeSelf = false } = opts;
const typeSet = new Set(types);
// Advance the cursor past everything recorded so far (including system
// lines) so the next call only sees genuinely newer messages.
const prev = this.chatReadCursor;
this.chatReadCursor = this.chatHistory.seen;
return this.chatHistory.since(prev).filter(m =>
typeSet.has(m.type) && (includeSelf || !m.fromSelf)
);
}
/**
* Read recent chat from a specific sender (case-insensitive, substring match
* on name), newest last, from the accumulated history. Handy for "what did
* my partner say?" without regex-matching the sender field yourself.
*
* @param name Sender name (or substring) to match.
* @param opts.limit Max messages to return (default 20; pass 0 for all).
*/
getChatFrom(name: string, opts: { limit?: number } = {}): GameMessage[] {
const { limit = 20 } = opts;
const needle = name.toLowerCase();
const filtered = this.chatHistory.all().filter(m =>
m.sender !== '' && m.sender.toLowerCase().includes(needle)
);
return limit > 0 ? filtered.slice(-limit) : filtered;
}
/**
* Wait for the next chat message matching the given filters (messages
* arriving after this call; your own messages are excluded by default).
* The easy way to coordinate two bots: `sdk.say('ready'); const reply =
* await sdk.waitForChat({ from: 'partner', timeout: 60000 });`
*
* @param opts.from Only accept this sender (case-insensitive substring).
* @param opts.matching Only accept messages whose text matches this pattern.
* @param opts.types Chat type codes to accept (default player chat: 1/2/3/6/7).
* @param opts.includeSelf Accept your own messages (default false).
* @param opts.timeout Ms to wait before returning null (default 30000).
* @returns The first matching message, or null on timeout.
*/
async waitForChat(opts: {
from?: string;
matching?: RegExp | string;
types?: readonly number[];
includeSelf?: boolean;
timeout?: number;
} = {}): Promise<GameMessage | null> {
const { from, matching, types = PLAYER_CHAT_TYPES, includeSelf = false, timeout = 30000 } = opts;
const typeSet = new Set(types);
const needle = from?.toLowerCase();
const pattern = typeof matching === 'string' ? new RegExp(matching, 'i') : matching;
const accepts = (m: GameMessage) =>
typeSet.has(m.type) &&
(includeSelf || !m.fromSelf) &&
(!needle || m.sender.toLowerCase().includes(needle)) &&
(!pattern || pattern.test(m.text));
// Private cursor starting "now" — does not consume getNewChat's cursor.
let cursor = this.chatHistory.seen;
return executionWait<GameMessage | null>((resolve) => {
const timeoutId = setTimeout(() => {
resolve(null);
}, timeout);
const unsubscribe = this.onStateUpdate(() => {
const fresh = this.chatHistory.since(cursor);
cursor = this.chatHistory.seen;
const match = fresh.find(accepts);
if (match) {
resolve(match);
}
});
return () => { clearTimeout(timeoutId); unsubscribe(); };
});
}
/** Get a skill by name (case-insensitive; "hp"/"hitpoint" alias Hitpoints). */
getSkill(name: string): SkillState | null {
if (!this.state) return null;
const requested = name.trim().toLowerCase();
const normalized = /^(hp|hitpoint|hitpoints)$/.test(requested) ? 'hitpoints' : requested;
return this.state.skills.find(s => s.name.toLowerCase() === normalized) || null;
}
/** Get XP for a skill by name. */
getSkillXp(name: string): number | null {
const skill = this.getSkill(name);
return skill?.experience ?? null;
}
/** Get all skills. */
getSkills(): SkillState[] {
return this.state?.skills || [];
}
/** Get inventory item by slot number. */
getInventoryItem(slot: number): InventoryItem | null {
if (!this.state) return null;
return this.state.inventory.find(i => i.slot === slot) || null;
}
/** Find inventory item by name pattern (shortest matching name wins). */
findInventoryItem(pattern: string | RegExp): InventoryItem | null {
if (!this.state) return null;
return shortestNameMatch(this.state.inventory, pattern);
}
/**
* Get all inventory items.
*
* The array is COMPACTED (empty slots are omitted), so its index is not
* the inventory slot. `sendUseItem` / `sendUseItemOnItem` / `sendDropItem`
* take slot numbers: always pass `item.slot`, never the array index.
*/
getInventory(): InventoryItem[] {
return this.state?.inventory || [];
}
/**
* Count total item quantity matching a name pattern.
*
* This sums stack sizes across every matching slot. Use
* `getInventory().filter(...)` when the number of occupied slots is needed.
*/
countInventoryItems(pattern: string | RegExp): number {
if (!this.state) return 0;
const regex = typeof pattern === 'string'
? new RegExp(pattern, 'i')
: new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''));
return this.state.inventory.reduce(
(total, item) => total + (regex.test(item.name) ? item.count : 0),
0
);
}
/** Get equipment item by slot number. */
getEquipmentItem(slot: number): InventoryItem | null {
if (!this.state) return null;
return this.state.equipment.find(i => i.slot === slot) || null;
}
/** Find equipment item by name pattern (shortest matching name wins). */
findEquipmentItem(pattern: string | RegExp): InventoryItem | null {
if (!this.state) return null;
return shortestNameMatch(this.state.equipment, pattern);
}
/** Get all equipped items. */
getEquipment(): InventoryItem[] {
return this.state?.equipment || [];
}
/** Get bank item by slot number (bank must be open). */
getBankItem(slot: number): BankItem | null {
if (!this.state?.bank.isOpen) return null;
return this.state.bank.items.find(i => i.slot === slot) || null;
}
/** Find bank item by name pattern (bank must be open; shortest matching name wins). */
findBankItem(pattern: string | RegExp): BankItem | null {
if (!this.state?.bank.isOpen) return null;
return shortestNameMatch(this.state.bank.items, pattern);
}
/** Get all bank items (bank must be open). */
getBankItems(): BankItem[] {
return this.state?.bank.items || [];
}
/** Check if bank interface is open. */
isBankOpen(): boolean {
return this.state?.bank.isOpen || false;
}
/** Get NPC by index. */
getNearbyNpc(index: number): NearbyNpc | null {
if (!this.state) return null;
return this.state.nearbyNpcs.find(n => n.index === index) || null;
}
/** Find NPC by name pattern (shortest matching name wins, then nearest; reachable preferred). */
findNearbyNpc(pattern: string | RegExp, options?: FindOptions): NearbyNpc | null {
if (!this.state) return null;
return applyFindOptions(shortestNameMatch(this.state.nearbyNpcs, pattern), options);
}
/** Get all nearby NPCs. */
getNearbyNpcs(): NearbyNpc[] {
return this.state?.nearbyNpcs || [];
}
/** Find a nearby player by name pattern (shortest matching name wins, then nearest; reachable preferred). */
findNearbyPlayer(pattern: string | RegExp, options?: FindOptions): NearbyPlayer | null {
if (!this.state) return null;
return applyFindOptions(shortestNameMatch(this.state.nearbyPlayers, pattern), options);
}
/** Get all nearby players, nearest first. */
getNearbyPlayers(): NearbyPlayer[] {
return this.state?.nearbyPlayers || [];
}
/** Get location (object) by coordinates and ID. */
getNearbyLoc(x: number, z: number, id: number): NearbyLoc | null {
if (!this.state) return null;
return this.state.nearbyLocs.find(l =>
l.x === x && l.z === z && l.id === id
) || null;
}
/** Find location by name pattern (shortest matching name wins, then nearest; reachable preferred). */
findNearbyLoc(pattern: string | RegExp, options?: FindOptions): NearbyLoc | null {
if (!this.state) return null;
let candidates = this.state.nearbyLocs;
if (options?.withOption !== undefined) {
const optionPattern = options.withOption;
const regex = typeof optionPattern === 'string' ? new RegExp(optionPattern, 'i') : optionPattern;
candidates = candidates.filter(loc => loc.optionsWithIndex?.some(o => {
regex.lastIndex = 0;
return regex.test(o.text);
}));
}
return applyFindOptions(shortestNameMatch(candidates, pattern), options);
}
/** Get all nearby locations (trees, rocks, etc). */
getNearbyLocs(): NearbyLoc[] {
return this.state?.nearbyLocs || [];
}
/** Find ground item by name pattern (shortest matching name wins, then nearest; reachable preferred). */
findGroundItem(pattern: string | RegExp, options?: FindOptions): GroundItem | null {
if (!this.state) return null;
return applyFindOptions(shortestNameMatch(this.state.groundItems, pattern), options);
}
/** Get all ground items. */
getGroundItems(): GroundItem[] {
return this.state?.groundItems || [];
}
/** Get current dialog state. */
getDialog(): DialogState | null {
return this.state?.dialog || null;
}
// ============ On-Demand Scanning ============
// These methods scan the environment on-demand rather than relying on pushed state
// Use these for expensive scans of nearby locations and ground items
/**
* Scan for nearby locations with custom radius. Results are scoped to the
* player's current plane (each carries `level`); re-scan after climbing or
* descending rather than reusing old references.
* @param radius - Scan radius in tiles (default 15)
* @returns Array of nearby locations sorted by distance
*/
async scanNearbyLocs(radius?: number): Promise<NearbyLoc[]> {
const result = await this.sendAction({ type: 'scanNearbyLocs', radius, reason: 'SDK' });
if (result.success && result.data) {
return result.data as NearbyLoc[];
}
return [];
}
/**
* Scan for ground items on-demand.
* This is more efficient than constantly pushing this data in state updates.
* @param radius - Scan radius in tiles (default 15)
* @returns Array of ground items sorted by distance
*/
async scanGroundItems(radius?: number): Promise<GroundItem[]> {
const result = await this.sendAction({ type: 'scanGroundItems', radius, reason: 'SDK' });
if (result.success && result.data) {
return result.data as GroundItem[];
}
return [];
}
/**
* Find a nearby location by name pattern (on-demand scan).
* @param pattern - String or RegExp to match location name
* @param radius - Scan radius in tiles (default 15)
* @returns First matching location or null
*/
async scanFindNearbyLoc(pattern: string | RegExp, radius?: number): Promise<NearbyLoc | null> {
const locs = await this.scanNearbyLocs(radius);
const regex = typeof pattern === 'string'
? new RegExp(pattern, 'i')
: pattern;
return locs.find(l => regex.test(l.name)) || null;
}
/**
* Find a ground item by name pattern (on-demand scan).
* @param pattern - String or RegExp to match item name
* @param radius - Scan radius in tiles (default 15)
* @returns First matching item or null
*/
async scanFindGroundItem(pattern: string | RegExp, radius?: number): Promise<GroundItem | null> {
const items = await this.scanGroundItems(radius);
const regex = typeof pattern === 'string'
? new RegExp(pattern, 'i')
: pattern;
return items.find(i => regex.test(i.name)) || null;
}
// ============ State Subscriptions ============
onStateUpdate(listener: (state: BotWorldState) => void): () => void {
this.stateListeners.add(listener);
return () => this.stateListeners.delete(listener);
}
// ============ Plumbing: Raw Actions ============
private async sendAction(action: BotAction): Promise<ActionResult> {
if (this.connectionState === 'reconnecting') {
console.log(`[BotSDK] Waiting for reconnection before sending action: ${action.type}`);
await this.waitForConnection();
}
if (!this.isConnected()) {
throw new Error(`Not connected (state: ${this.connectionState})`);
}
const actionId = `act-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
try {
return await executionWait<ActionResult>((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingActions.delete(actionId);
reject(new ActionDispatchError(`Action timed out: ${action.type}`, 'timeout'));
}, this.config.actionTimeout);
this.pendingActions.set(actionId, { resolve, reject, timeout });
try {
this.send({
type: 'sdk_action',
username: this.config.botUsername,
actionId,
action,
actionTimeoutMs: this.config.actionTimeout
});
} catch (error) {
reject(error);
}
return () => {
clearTimeout(timeout);
this.pendingActions.delete(actionId);
};
});
} catch (err) {
throwIfExecutionAborted();
// A dropped dispatch is a reportable failure, not a fatal one: callers
// check result.success and can retry, reposition, or give up. Losing
// the connection outright still throws from the guard above.
return {
success: false,
message: err instanceof Error ? err.message : String(err),
reason: err instanceof ActionDispatchError ? err.reason : 'error',
phase: 'dispatch'
};
}
}
/** Send walk command to coordinates. */
async sendWalk(x: number, z: number, running: boolean = true): Promise<ActionResult> {
return this.sendAction({ type: 'walkTo', x, z, running, reason: 'SDK' });
}
/** Interact with a location (tree, rock, door, etc). */
async sendInteractLoc(x: number, z: number, locId: number, option: number = 1): Promise<ActionResult> {
return this.sendAction({ type: 'interactLoc', x, z, locId, optionIndex: option, reason: 'SDK' });
}
/** Interact with an NPC by index and option. */
async sendInteractNpc(npcIndex: number, option: number = 1): Promise<ActionResult> {
return this.sendAction({ type: 'interactNpc', npcIndex, optionIndex: option, reason: 'SDK' });
}
/** Interact with a player by index and option (1-5). Option 2 = Attack (wilderness), 3 = Follow, 4 = Trade. */
async sendInteractPlayer(playerIndex: number, option: number = 2): Promise<ActionResult> {
return this.sendAction({ type: 'interactPlayer', playerIndex, optionIndex: option, reason: 'SDK' });
}
/** Talk to an NPC by index. */
async sendTalkToNpc(npcIndex: number): Promise<ActionResult> {
return this.sendAction({ type: 'talkToNpc', npcIndex, reason: 'SDK' });
}
/** Pick up a ground item. */
async sendPickup(x: number, z: number, itemId: number): Promise<ActionResult> {
return this.sendAction({ type: 'pickupItem', x, z, itemId, reason: 'SDK' });
}
/**
* Use an inventory item (eat, equip, etc).
*
* `interfaceId` selects which inventory component holds the item. The main
* inventory (3214, the default) dispatches OPHELD1-5; any other component
* (trade offer, bank side inventory, ...) dispatches INV_BUTTON1-5, which
* is the packet family the engine actually handles for interface-defined
* item options - OPHELD with a foreign component id is silently dropped.
*/
async sendUseItem(slot: number, option: number = 1, interfaceId?: number): Promise<ActionResult> {
return this.sendAction({ type: 'useInventoryItem', slot, optionIndex: option, interfaceId, reason: 'SDK' });
}
/** Use an equipped item (remove, operate, etc). */
async sendUseEquipmentItem(slot: number, option: number = 1): Promise<ActionResult> {
return this.sendAction({ type: 'useEquipmentItem', slot, optionIndex: option, reason: 'SDK' });
}
/** Drop an inventory item. */
async sendDropItem(slot: number): Promise<ActionResult> {
return this.sendAction({ type: 'dropItem', slot, reason: 'SDK' });
}
/**
* Use one inventory item on another.
*
* Rejected up front while a shop or bank modal is open: those replace the
* inventory tab, so the server drops the packet as "component not visible"
* and sends no message at all. Close the modal first — `bot.closeShop()`,
* `bot.closeInterface()`, or `sendCloseModal()`.
*/
async sendUseItemOnItem(sourceSlot: number, targetSlot: number): Promise<ActionResult> {
const blocker = this.describeInventoryBlocker();
if (blocker) {
return { success: false, message: blocker, reason: 'modal_open' };
}
return this.sendAction({ type: 'useItemOnItem', sourceSlot, targetSlot, reason: 'SDK' });
}
/**
* Name the open modal that hides the inventory tab from the server, if any.
* Returns null when inventory packets can be expected to land.
*/
private describeInventoryBlocker(): string | null {
const state = this.getState();
if (!state) return null;
if (state.shop.isOpen) {
return 'Shop interface is open, which replaces the inventory tab - the server will silently drop this. Close it first (bot.closeShop() / sdk.sendCloseShop()).';
}
if (state.bank.isOpen) {
return 'Bank interface is open, which replaces the inventory tab - the server will silently drop this. Close it first (bot.closeInterface() / sdk.sendCloseModal()).';
}
if (state.trade?.isOpen) {
return 'Trade interface is open, which replaces the inventory tab - the server will silently drop this. Finish or decline the trade first (sdk.sendDeclineTrade()).';
}
return null;
}
/** Use an inventory item on a location. */
async sendUseItemOnLoc(itemSlot: number, x: number, z: number, locId: number): Promise<ActionResult> {
return this.sendAction({ type: 'useItemOnLoc', itemSlot, x, z, locId, reason: 'SDK' });
}
/** Use an inventory item on an NPC. */
async sendUseItemOnNpc(itemSlot: number, npcIndex: number): Promise<ActionResult> {
return this.sendAction({ type: 'useItemOnNpc', itemSlot, npcIndex, reason: 'SDK' });
}
/**
* Click a dialog option by its server-assigned index.
*
* IMPORTANT: `option` is the **server-assigned index** stored on each
* `DialogOption.index` field — NOT the array position in `dialog.options`.
* Server-assigned indices are 1-based: `dialog.options[0].index === 1`.
*
* Pass `0` only as the implicit "continue" click for dialogs with no
* selectable options (the common pattern: pass through narration pages).
*
* To click an option by its visible text, prefer `clickDialogByText()`,
* which also binds the click to its observed dialogue page. This low-level
* index method does not bind the click to a page.
*
* @example
* ```ts
* const opt = sdk.getDialog()?.options.find(o => /yes/i.test(o.text));
* await sdk.sendClickDialog(opt?.index ?? 0); // ← .index, NOT array position
* ```
*/
async sendClickDialog(option: number = 0): Promise<ActionResult> {
return this.sendAction({ type: 'clickDialogOption', optionIndex: option, reason: 'SDK' });
}
/**
* Click a dialog option whose visible text matches `pattern`.
*
* Convenience wrapper that resolves the server-assigned index for you,
* sidestepping the 1-based vs 0-based array-position confusion of
* `sendClickDialog()`. Matches against `DialogOption.text` (case-insensitive
* by default for string patterns).
* Binds the click to the observed page and component; it never falls back
* to an unguarded index when the page changes or the client is too old.
*
* @returns ActionResult with `success: false` and `reason: 'no_dialog'` if
* no dialog is open, or `reason: 'no_match'` if no option matches.
* A changed page returns `stale_dialog`; read fresh state before
* choosing again. Older clients return `unsupported_client` and
* must be reloaded/restarted to support guarded dialogue clicks.
*
* @example
* ```ts
* await sdk.clickDialogByText(/yes/i); // pay the toll
* await sdk.clickDialogByText('Is there anything down this alleyway?');
* ```
*/
async clickDialogByText(pattern: string | RegExp): Promise<ActionResult> {
const dialog = this.state?.dialog;
if (!dialog?.isOpen) {
return { success: false, message: 'No dialog open', reason: 'no_dialog' };
}
const regex = typeof pattern === 'string' ? new RegExp(pattern, 'i') : pattern;
const match = dialog.options.find(o => regex.test(o.text));
if (!match) {
const available = dialog.options.map(o => `"${o.text}"`).join(', ') || '(none)';
return {
success: false,
message: `No dialog option matched ${pattern}. Available: ${available}`,
reason: 'no_match'
};
}
if (!dialog.pageId || match.componentId === undefined) {
return {
success: false,
phase: 'validation',
reason: 'unsupported_client',
message: 'Client does not support guarded dialogue clicks; update and reload/restart it'
};
}
return this.sendAction({
type: 'clickDialogOption',
optionIndex: match.index,
guard: { pageId: dialog.pageId, componentId: match.componentId },
reason: 'SDK'
});
}
/** Click a component using IF_BUTTON packet - for simple buttons, spellcasting, etc. */
async sendClickComponent(componentId: number): Promise<ActionResult> {
return this.sendAction({ type: 'clickComponent', componentId, reason: 'SDK' });
}
/** Click a component using INV_BUTTON packet - for components with inventory operations (smithing, crafting, etc.) */
async sendClickComponentWithOption(componentId: number, optionIndex: number = 1, slot: number = 0): Promise<ActionResult> {
return this.sendAction({ type: 'clickComponentWithOption', componentId, optionIndex, slot, reason: 'SDK' });
}
/**
* Click an interface option by **0-based array position**.
*
* Note the mismatch: `InterfaceOption.index` is a 1-based display label, so
* passing one straight through clicks the option after the one you matched.
* Prefer `clickInterfaceOption()` when selecting from published state.
*/
async sendClickInterfaceOption(arrayPosition: number): Promise<ActionResult> {
const state = this.getState();
if (!state?.interface?.isOpen) {
return { success: false, message: 'No interface open', reason: 'no_interface' };
}
const options = state.interface.options;
const option = options[arrayPosition];
if (arrayPosition < 0 || arrayPosition >= options.length || !option) {
return {
success: false,
message: `Invalid array position ${arrayPosition}; interface has ${options.length} options (0..${options.length - 1})`,
reason: 'no_match',
};
}
return this.sendClickComponent(option.componentId);
}
/**
* Click exactly one interface option, selected by its state object or by
* visible text (substring for strings, match for regexes).
*
* This dispatches the option's `componentId` and never interprets
* `InterfaceOption.index` as an array position.
*/
async clickInterfaceOption(selector: InterfaceOptionSelector): Promise<ActionResult> {
const state = this.getState();
if (!state?.interface?.isOpen) {
return { success: false, message: 'No interface open', reason: 'no_interface' };
}
const option = resolveInterfaceOption(state.interface.options, selector);
if (!option) {
const available = state.interface.options.map(o => `"${o.text}"`).join(', ') || '(none)';
return {
success: false,
message: `No interface option matched ${selectorLabel(selector)}. Available: ${available}`,
reason: 'no_match',
};
}
return this.sendClickComponent(option.componentId);
}
/** Accept character design in tutorial. */
async sendAcceptCharacterDesign(): Promise<ActionResult> {
return this.sendAction({ type: 'acceptCharacterDesign', reason: 'SDK' });
}
/** Randomize character appearance in tutorial. */
async sendRandomizeCharacterDesign(): Promise<ActionResult> {
return this.sendAction({ type: 'randomizeCharacterDesign', reason: 'SDK' });
}
/** Buy from shop by slot and amount. */
async sendShopBuy(slot: number, amount: number = 1): Promise<ActionResult> {
return this.sendAction({ type: 'shopBuy', slot, amount, reason: 'SDK' });
}
/** Sell to shop by slot and amount. */
async sendShopSell(slot: number, amount: number = 1): Promise<ActionResult> {
return this.sendAction({ type: 'shopSell', slot, amount, reason: 'SDK' });
}
/** Close shop interface. */
async sendCloseShop(): Promise<ActionResult> {
return this.sendAction({ type: 'closeShop', reason: 'SDK' });
}
/** Close any modal interface. */
async sendCloseModal(): Promise<ActionResult> {
return this.sendAction({ type: 'closeModal', reason: 'SDK' });
}
/** Submit a numeric value to an open p_countdialog (Enter Amount) prompt. */
async sendCountDialog(value: number): Promise<ActionResult> {
return this.sendAction({ type: 'submitCountDialog', value, reason: 'SDK' });
}
// ============ Player Trading ============
/**
* Current player-to-player trade session state. Returns a closed-trade
* default when no state has arrived or the connected client predates
* trade support.
*/
getTradeState(): TradeState {
return this.state?.trade ?? {
isOpen: false,
screen: null,
partner: null,
myOffer: [],
theirOffer: [],
myAccepted: false,
partnerAccepted: false
};
}
/**
* Send (or accept) a trade request to another player. There is no
* separate "accept" packet: requesting a player who already requested
* you is the acceptance, and opens the trade screen for both. Otherwise
* the partner sees "<you> wishes to trade with you." and the trade opens
* when they request back.
*/
async sendTradeRequest(playerIndex: number): Promise<ActionResult> {
return this.sendInteractPlayer(playerIndex, 4);
}
/**
* Move items from your (trade-screen) side inventory into your offer.
* `slot` is the inventory slot. Amounts 1/5/10 and -1 (All) map to the
* game's offer buttons; any other amount uses Offer-X plus the count
* dialog. Only valid while the offer screen is open.
*/
async sendOfferItem(slot: number, amount: number = 1): Promise<ActionResult> {
return this.sendTradeInvButton(TRADE_SIDE_INV_ID, slot, amount);
}
/**
* Remove items from your offer back to your inventory. Same amount
* semantics as {@link sendOfferItem}. Note: removing (or adding) items
* resets both players' accepts server-side.
*/
async sendRetractItem(slot: number, amount: number = 1): Promise<ActionResult> {
return this.sendTradeInvButton(TRADE_MAIN_INV_ID, slot, amount);
}
private async sendTradeInvButton(componentId: number, slot: number, amount: number): Promise<ActionResult> {
if (!Number.isInteger(amount) || amount === 0 || (amount < 0 && amount !== -1)) {
return { success: false, message: `Invalid trade amount: ${amount}`, reason: 'invalid_amount' };
}
const option = amount === 1 ? 1
: amount === 5 ? 2
: amount === 10 ? 3
: (amount === -1 || amount >= 0x7fffffff) ? 4
: 5;
const result = await this.sendClickComponentWithOption(componentId, option, slot);
if (!result.success || option !== 5) return result;
// Offer-X: the server opens a count dialog; this waits for it and submits.
return this.sendCountDialog(amount);
}
/**
* Accept the currently open trade screen (first or confirm). The trade
* only advances when both players accept; an offer change resets accepts.
*/
async sendAcceptTrade(): Promise<ActionResult> {
const trade = this.getTradeState();
if (!trade.isOpen) {
return { success: false, message: 'No trade screen is open', reason: 'not_open' };
}
const componentId = trade.screen === 'confirm' ? TRADE_CONFIRM_ACCEPT_ID : TRADE_MAIN_ACCEPT_ID;
return this.sendClickComponent(componentId);
}
/**
* Decline the open trade (closes the screen; both sides get their items
* back and the partner sees "Other player declined trade.").
*/
async sendDeclineTrade(): Promise<ActionResult> {
const trade = this.getTradeState();
if (!trade.isOpen) {
return { success: false, message: 'No trade screen is open', reason: 'not_open' };
}
return this.sendCloseModal();
}
/**
* Wait for an incoming trade request ("X wishes to trade with you.").
* Requests arrive as chat type {@link TRADE_REQUEST_CHAT_TYPE}, which the
* default chat readers filter out. Returns the requester's name, or null
* on timeout.
*
* @param opts.from Only accept requests from this sender (substring match).
* @param opts.timeout Ms to wait (default 30000).
*/
async waitForTradeRequest(opts: { from?: string; timeout?: number } = {}): Promise<string | null> {
const message = await this.waitForChat({
from: opts.from,
types: [TRADE_REQUEST_CHAT_TYPE],
matching: /wishes to trade/i,
timeout: opts.timeout ?? 30000
});
return message?.sender ?? null;
}
/** Set combat style (0-3). */
async sendSetCombatStyle(style: number): Promise<ActionResult> {
return this.sendAction({ type: 'setCombatStyle', style, reason: 'SDK' });
}
// ============ Prayer ============
/** Toggle a prayer on or off by name or index (0-14). */
async sendTogglePrayer(prayer: PrayerName | number): Promise<ActionResult> {
const index = typeof prayer === 'number' ? prayer : PRAYER_INDICES[prayer];
if (index === undefined || index < 0 || index > 14) {
return { success: false, message: `Invalid prayer: ${prayer}` };
}
return this.sendAction({ type: 'togglePrayer', prayerIndex: index, reason: 'SDK' });
}
/** Get current prayer state from world state. */
getPrayerState(): PrayerState | null {
return this.state?.prayers || null;
}
/** Check if a specific prayer is currently active. */
isPrayerActive(prayer: PrayerName | number): boolean {
const prayerState = this.state?.prayers;
if (!prayerState) return false;
const index = typeof prayer === 'number' ? prayer : PRAYER_INDICES[prayer];
if (index === undefined || index < 0 || index >= prayerState.activePrayers.length) return false;
return !!prayerState.activePrayers[index];
}
/** Get list of all currently active prayer names. */
getActivePrayers(): PrayerName[] {
const prayerState = this.state?.prayers;
if (!prayerState) return [];
return prayerState.activePrayers
.map((active, i) => active ? PRAYER_NAMES[i] : null)
.filter((name): name is PrayerName => name !== null);
}
/** Cast spell on NPC using spell component ID (OPNPCT). */
async sendSpellOnNpc(npcIndex: number, spellComponent: number): Promise<ActionResult> {
return this.sendAction({ type: 'spellOnNpc', npcIndex, spellComponent, reason: 'SDK' });
}
/**
* Cast spell on another player using spell component ID (OPPLAYERT).
*
* `playerIndex` is a world slot from `nearbyPlayers`, a different space from
* npc indices - use {@link sendSpellOnTarget} to avoid mixing them up.
*/
async sendSpellOnPlayer(playerIndex: number, spellComponent: number): Promise<ActionResult> {
return this.sendAction({ type: 'spellOnPlayer', playerIndex, spellComponent, reason: 'SDK' });
}
/**
* Cast a spell on whatever the target is - npc or player - picking the right
* packet from `target.kind`. This is the one to reach for in code that fights
* both, e.g. `sdk.sendSpellOnTarget(sdk.findNearbyPlayer('Zezima'), Spells.WIND_STRIKE)`.
*/
async sendSpellOnTarget(target: NearbyNpc | NearbyPlayer, spellComponent: number): Promise<ActionResult> {
return target.kind === 'player'
? this.sendSpellOnPlayer(target.index, spellComponent)
: this.sendSpellOnNpc(target.index, spellComponent);
}
/** Cast spell on inventory item. */
async sendSpellOnItem(slot: number, spellComponent: number): Promise<ActionResult> {
return this.sendAction({ type: 'spellOnItem', slot, spellComponent, reason: 'SDK' });
}
/** Cast spell on ground item (e.g., Telekinetic Grab). */
async sendSpellOnGroundItem(x: number, z: number, itemId: number, spellComponent: number): Promise<ActionResult> {
return this.sendAction({ type: 'spellOnGroundItem', x, z, itemId, spellComponent, reason: 'SDK' });
}
/** Switch to a UI tab by index. */
async sendSetTab(tabIndex: number): Promise<ActionResult> {
return this.sendAction({ type: 'setTab', tabIndex, reason: 'SDK' });
}
/**
* Send a single chat message. The server caps public chat at {@link maxMessageLength}
* chars (400 on rs-sdk servers) and runs a word filter; `result.data` reports
* `{ sent, truncated, filtered, finalText }` so you know if your message was clipped
* or censored. For longer text that shouldn't be silently truncated, use {@link say}.
*/
async sendSay(message: string): Promise<ActionResult> {
return this.sendAction({ type: 'say', message, reason: 'SDK' });
}
/**
* Server-configured max chat length, learned from the gateway handshake. Defaults
* to 80 (the RS wire limit) until the bot session reports the server's value, which
* a server operator can raise via `node.maxMessageLength` in world.json.
*/
get maxMessageLength(): number {
return this.serverMaxMessageLength;
}
/**
* Send a message of any length, auto-split into chunks on word boundaries and sent
* in order (so a multi-sentence plan isn't lost to the chat-length cap). Waits a
* tick between chunks so they don't collide. Returns one ActionResult per chunk.
*
* @param text The full message to send.
* @param opts.maxLen Max chars per chunk. Defaults to (and is capped at) the
* server-configured {@link maxMessageLength}.
* @param opts.delayTicks Ticks to wait between chunks (default 1).
*/
async say(text: string, opts: { maxLen?: number; delayTicks?: number } = {}): Promise<ActionResult[]> {
const maxLen = Math.min(opts.maxLen ?? this.serverMaxMessageLength, this.serverMaxMessageLength);
const delayTicks = opts.delayTicks ?? 1;
const chunks = chunkMessage(text, maxLen);
const results: ActionResult[] = [];
for (let i = 0; i < chunks.length; i++) {
results.push(await this.sendSay(chunks[i]!));
if (i < chunks.length - 1 && delayTicks > 0) {
if (this.config.connectionMode === 'observe') {
// Observe mode may only dispatch 'say' - and a 'wait' would occupy
// the bot client's executor, interfering with whatever controller
// owns the bot. Pace with wall-clock time instead.
await new Promise(resolve => setTimeout(resolve, delayTicks * 600));
} else {
await this.sendWait(delayTicks);
}
}
}
return results;
}
/**
* Send a single private message to another player by name. Delivered if the
* target is online anywhere in the world - no friends-list setup needed. It
* arrives in their chat as type 3 ("From <you>"), and echoes locally as
* type 6 ("To <name>"). Same length cap and word filter as {@link sendSay};
* the server accepts at most one social packet (say or PM) per tick, so
* don't fire this back-to-back with public chat in the same tick.
* For longer text, use {@link dm}.
*/
async sendPrivateMessage(targetName: string, message: string): Promise<ActionResult> {
return this.sendAction({ type: 'privateMessage', targetName, message, reason: 'SDK' });
}
/**
* Send a private message of any length, auto-split into chunks like
* {@link say}. Returns one ActionResult per chunk.
*
* @param targetName The recipient's username (max 12 chars).
* @param text The full message to send.
* @param opts.maxLen Max chars per chunk, capped at {@link maxMessageLength}.
* @param opts.delayTicks Ticks to wait between chunks (default 1).
*/
async dm(targetName: string, text: string, opts: { maxLen?: number; delayTicks?: number } = {}): Promise<ActionResult[]> {
const maxLen = Math.min(opts.maxLen ?? this.serverMaxMessageLength, this.serverMaxMessageLength);
const delayTicks = opts.delayTicks ?? 1;
const chunks = chunkMessage(text, maxLen);
const results: ActionResult[] = [];
for (let i = 0; i < chunks.length; i++) {
results.push(await this.sendPrivateMessage(targetName, chunks[i]!));
if (i < chunks.length - 1 && delayTicks > 0) {
if (this.config.connectionMode === 'observe') {
// Observe mode may only dispatch chat actions - a 'wait' would
// occupy the executor of whatever controller owns the bot.
await new Promise(resolve => setTimeout(resolve, delayTicks * 600));
} else {
await this.sendWait(delayTicks);
}
}
}
return results;
}
/** Wait for specified number of game ticks. */
async sendWait(ticks: number = 1): Promise<ActionResult> {
return this.sendAction({ type: 'wait', ticks, reason: 'SDK' });
}
/** Deposit item to bank by slot. */
async sendBankDeposit(slot: number, amount: number = 1): Promise<ActionResult> {
// Amounts other than 1/5/10/All dispatch the "X" option; the client
// executor waits for the server's count dialog and submits `amount`.
return this.sendAction({ type: 'bankDeposit', slot, amount, reason: 'SDK' });
}
/** Withdraw item from bank by slot. */
async sendBankWithdraw(slot: number, amount: number = 1): Promise<ActionResult> {
return this.sendAction({ type: 'bankWithdraw', slot, amount, reason: 'SDK' });
}
// ============ Screenshot ============
/**
* Request a screenshot from the bot client.
* Returns the screenshot as a data URL (data:image/png;base64,...).
* @param timeout - Timeout in milliseconds (default 10000)
*/
async sendScreenshot(timeout: number = 10000): Promise<string> {
if (this.connectionState === 'reconnecting') {
console.log(`[BotSDK] Waiting for reconnection before requesting screenshot`);
await this.waitForConnection();
}
if (!this.isConnected()) {
throw new Error(`Not connected (state: ${this.connectionState})`);
}
const screenshotId = `ss-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
return executionWait<string>((resolve, reject) => {
const timeoutHandle = setTimeout(() => {
this.pendingScreenshots.delete(screenshotId);
reject(new Error('Screenshot request timed out'));
}, timeout);
this.pendingScreenshots.set(screenshotId, { resolve, reject, timeout: timeoutHandle });
try {
this.send({
type: 'sdk_screenshot_request',
username: this.config.botUsername,
screenshotId
});
} catch (error) {
reject(error);
}
return () => { clearTimeout(timeoutHandle); this.pendingScreenshots.delete(screenshotId); };
});
}
// ============ Local Pathfinding ============
/** Find path to destination using local collision data. */
findPath(
destX: number,
destZ: number,
maxWaypoints: number = 500
): { success: boolean; waypoints: Array<{ x: number; z: number; level: number }>; reachedDestination?: boolean; error?: string } {
const state = this.getState();
if (!state?.player) {
return { success: false, waypoints: [], error: 'No player state available' };
}
const { worldX: srcX, worldZ: srcZ, level } = state.player;
// Only require source zone to be allocated - we need to know where we ARE
// Destination zone may be unallocated (e.g., past a gate we haven't opened yet)
// The pathfinder will find a partial path to the edge of known areas
if (!pathfinding.isZoneAllocated(level, srcX, srcZ)) {
return { success: false, waypoints: [], error: 'Source zone not allocated (no collision data for current position)' };
}
const destZoneAllocated = pathfinding.isZoneAllocated(level, destX, destZ);
// 2048x2048 BFS grid handles any in-game distance in a single call.
const waypoints = pathfinding.findLongPath(
level,
srcX,
srcZ,
destX,
destZ,
maxWaypoints,
this.temporaryDoorBlocks.active()
);
// If no waypoints and destination zone isn't allocated, that's expected -
// we just can't path there yet (might need to open a door first)
if (waypoints.length === 0 && !destZoneAllocated) {
// Return success with empty waypoints - caller should try raw walking toward destination
return { success: true, waypoints: [], reachedDestination: false, error: 'Destination zone not allocated - try walking toward it' };
}
const lastWaypoint = waypoints[waypoints.length - 1];
const reachedDestination = lastWaypoint !== undefined &&
lastWaypoint.x === destX &&
lastWaypoint.z === destZ;
// Detect unreachable destinations: if the pathfinder couldn't reach the
// destination and the remaining distance is still very large, the target
// is likely on a different plane (underground areas share level 0 but use
// Z offsets of +6400, making them allocated but disconnected from the surface).
// The "just past a gate" case has a remaining distance of ~10-20 tiles, not hundreds.
if (!reachedDestination && waypoints.length > 0) {
const remainDist = Math.abs(lastWaypoint!.x - destX) + Math.abs(lastWaypoint!.z - destZ);
if (remainDist > 100) {
return { success: false, waypoints: [], error: `Destination (${destX}, ${destZ}) is unreachable — path ends ${remainDist} tiles away at (${lastWaypoint!.x}, ${lastWaypoint!.z}). The target may be underground or on a different plane that requires a ladder/stairs to access.` };
}
}
return { success: true, waypoints, reachedDestination };
}
/**
* Temporarily exclude a known door from this SDK instance's path queries.
* The shared collision map is never mutated beyond the synchronous query.
*/
blockDoorTemporarily(level: number, x: number, z: number, ttlMs: number = 30_000): boolean {
const door = pathfinding.getDoorAt(level, x, z);
if (!door) return false;
this.temporaryDoorBlocks.block(door, ttlMs);
return true;
}
/** Check this SDK session's non-expired temporary door evidence. */
isDoorTemporarilyBlocked(level: number, x: number, z: number): boolean {
return this.temporaryDoorBlocks.has(level, x, z);
}
/** Find path to destination (async alias for findPath). */
async sendFindPath(
destX: number,
destZ: number,
maxWaypoints: number = 500
): Promise<{ success: boolean; waypoints: Array<{ x: number; z: number; level: number }>; reachedDestination?: boolean; error?: string }> {
return this.findPath(destX, destZ, maxWaypoints);
}
// ============ Plumbing: State Waiting ============
/**
* Wait for game state to be fully loaded and ready.
* Ensures player position is valid (not 0,0), bot is in-game, and state is recent.
*
* @param timeout - Maximum time to wait in milliseconds (default: 15000)
* @returns Promise that resolves when state is ready
* @throws Error if timeout is reached
*
* @example
* ```ts
* await sdk.waitForReady();
* // Now safe to access player position, NPCs, etc.
* ```
*/
async waitForReady(timeout: number = 15000): Promise<BotWorldState> {
console.log('[BotSDK] Waiting for game state to be ready...');
try {
const state = await this.waitForCondition(s => {
const validPosition = !!(s.player && s.player.worldX !== 0 && s.player.worldZ !== 0);
const inGame = s.inGame;
const hasEntities = (s.nearbyNpcs?.length ?? 0) > 0 || (s.nearbyLocs?.length ?? 0) > 0 || (s.groundItems?.length ?? 0) > 0;
// Log progress for debugging
if (!validPosition) {
console.log(`[BotSDK] Waiting - invalid position: (${s.player?.worldX}, ${s.player?.worldZ})`);
} else if (!inGame) {
console.log('[BotSDK] Waiting - not in game');
} else if (!hasEntities) {
console.log('[BotSDK] Waiting - no entities loaded yet');
}
return inGame && validPosition && hasEntities;
}, timeout);
console.log('[BotSDK] Game state ready!');
return state;
} catch (error) {
console.error('[BotSDK] Timeout waiting for game state to be ready');
throw new Error('Game state not ready within timeout');
}
}
/**
* Wait until the predicate passes on a state update and return that state.
* THROWS `Error('waitForCondition timed out')` if the timeout expires -
* wrap in try/catch (or `.catch(...)`) when a timeout is an expected
* outcome rather than a fatal one.
*/
async waitForCondition(
predicate: (state: BotWorldState) => boolean,
timeout: number = 30000
): Promise<BotWorldState> {
if (this.state && predicate(this.state)) return this.state;
return this.waitForObservation(predicate, timeout, 'waitForCondition timed out');
}
private waitForObservation(
predicate: (state: BotWorldState) => boolean,
timeout: number,
timeoutMessage: string,
): Promise<BotWorldState> {
return executionWait<BotWorldState>((resolve, reject) => {
const timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), timeout);
const unsubscribe = this.onStateUpdate(state => {
try {
if (predicate(state)) resolve(state);
} catch (error) {
reject(error);
}
});
return () => { clearTimeout(timeoutId); unsubscribe(); };
});
}
/** Wait for next state update from server. */
async waitForStateChange(timeout: number = 30000): Promise<BotWorldState> {
return this.waitForObservation(() => true, timeout, 'waitForStateChange timed out');
}
/**
* Wait for a specific number of server ticks (~300ms each).
* @param ticks - Number of server ticks to wait
* @returns The state after waiting
*/
async waitForTicks(ticks: number = 1): Promise<BotWorldState> {
if (!this.state) throw new Error('waitForTicks: no state available');
if (ticks <= 0) return this.state;
const targetTick = this.state.tick + ticks;
return this.waitForObservation(
state => state.tick >= targetTick,
ticks * 1000 + 5000,
`waitForTicks(${ticks}) safety timeout - no state updates received`,
);
}
/**
* Wait for the next state update from the server.
* This is the most common waiting pattern - ensures fresh data after an action.
*
* State updates arrive once per server tick (~300ms) when PLAYER_INFO is received.
*
* @example
* ```ts
* await sdk.sendClickDialog(0);
* await sdk.waitForStateUpdate(); // Wait for server to confirm
* ```
*
* @returns The new state after the update
*/
async waitForStateUpdate(): Promise<BotWorldState> {
return this.waitForStateChange(5000);
}
// ============ Internal ============
private send(message: object) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
private handleMessage(data: string) {
let message: SyncToSDKMessage;
try {
message = JSON.parse(data);
} catch {
return;
}
// The server-configured chat cap can arrive after the initial handshake: if the
// SDK connects before the bot page registers, the gateway re-sends sdk_connected
// (and may send sdk_info) once the bot is known. Capture it whenever it appears.
if ((message.type === 'sdk_connected' || message.type === 'sdk_info') && typeof message.maxMessageLength === 'number' && message.maxMessageLength > 0) {
this.serverMaxMessageLength = message.maxMessageLength;
}
if (message.type === 'sdk_state' && message.state) {
// Filter out player chat messages unless showChat is enabled. Player
// chat = public (types 1/2) and private (3/6/7); system/game messages
// (level-ups, combat, examines) always pass through. Note this also
// hides DMs from getNewChat/waitForChat - keep showChat on (the
// default) for any bot that talks to other bots.
if (this.config.showChat === false && message.state.gameMessages) {
message.state.gameMessages = message.state.gameMessages.filter(
msg => !isPlayerChat(msg.type)
);
}
// Accumulate chat into the deep history buffer (post-showChat
// filter, so hidden player chat stays hidden). Must happen before
// state listeners fire so waitForChat sees the new messages.
if (message.state.gameMessages) {
this.chatHistory.record(message.state.gameMessages);
}
// Players publish coordinates as x/z (NPCs/locs also carry
// worldX/worldZ) - fill the aliases in so walkTo(p.worldX, ...)
// can't silently become walkTo(undefined, undefined).
if (message.state.nearbyPlayers) {
for (const p of message.state.nearbyPlayers) {
if (p.worldX === undefined) { p.worldX = p.x; p.worldZ = p.z; }
}
}
// Trade offers publish counts as `count`; mirror to `amount` so
// either spelling works (TradeResult item lists do the same).
if (message.state.trade) {
for (const list of [message.state.trade.myOffer, message.state.trade.theirOffer]) {
if (!list) continue;
for (const item of list) {
if (item.amount === undefined) item.amount = item.count;
}
}
}
// Wrap skills array with a Proxy so both state.skills[0] and state.skills.Woodcutting work
if (message.state.skills) {
const nameMap: Record<string, SkillState> = {};
for (const skill of message.state.skills) {
if (/^Stat\d+$/i.test(skill.name)) continue;
nameMap[skill.name] = skill;
}
message.state.skills = new Proxy(message.state.skills, {
get(target, prop, receiver) {
if (typeof prop === 'string' && prop in nameMap) {
return nameMap[prop];
}
return Reflect.get(target, prop, receiver);
}
}) as SkillState[];
}
this.state = message.state;
// Use server timestamp if available, otherwise use local time
this.stateReceivedAt = message.stateReceivedAt || Date.now();
for (const listener of this.stateListeners) {
try {
listener(message.state);
} catch (e) {
console.error('State listener error:', e);
}
}
}
if (message.type === 'sdk_action_result' && message.actionId) {
const pending = this.pendingActions.get(message.actionId);
if (pending) {
clearTimeout(pending.timeout);
this.pendingActions.delete(message.actionId);
if (message.result) {
pending.resolve(message.result);
} else {
pending.reject(new ActionDispatchError('No result in action response', 'error'));
}
}
}
if (message.type === 'sdk_error') {
// Handle controller pre-emption: another controller connected, so we were kicked
if (message.error?.includes('another controller connected')) {
console.warn(`[BotSDK] Pre-empted by another controller - disabling auto-reconnect`);
this.intentionalDisconnect = true;
}
if (message.actionId) {
const pending = this.pendingActions.get(message.actionId);
if (pending) {
clearTimeout(pending.timeout);
this.pendingActions.delete(message.actionId);
pending.reject(new ActionDispatchError(message.error || 'Unknown error', 'error'));
}
}
if (message.screenshotId) {
const pending = this.pendingScreenshots.get(message.screenshotId);
if (pending) {
clearTimeout(pending.timeout);
this.pendingScreenshots.delete(message.screenshotId);
pending.reject(new Error(message.error || 'Screenshot error'));
}
}
}
if (message.type === 'sdk_screenshot_response' && message.dataUrl) {
// The gateway broadcasts frames to every SDK session on the bot, so a
// frame carrying an id we did not issue belongs to another session (an
// observer polling at 1-2fps, say). Resolving "the first pending" for
// those handed controllers frames they never requested — drop them.
// The id-less fallback stays for responses that carry no id at all.
let pending: PendingScreenshot | undefined;
if (message.screenshotId) {
pending = this.pendingScreenshots.get(message.screenshotId);
if (pending) {
this.pendingScreenshots.delete(message.screenshotId);
}
} else if (this.pendingScreenshots.size > 0) {
const entry = this.pendingScreenshots.entries().next().value;
if (entry) {
const [firstId, firstPending] = entry;
pending = firstPending;
this.pendingScreenshots.delete(firstId);
}
}
if (pending) {
clearTimeout(pending.timeout);
pending.resolve(message.dataUrl);
}
}
}
}
// Re-export types for convenience
export * from './types';