kill-switch-mcp 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { register } from 'tsx/esm/api';
3
+ register();
4
+ await import('../src/server.ts');
package/package.json CHANGED
@@ -1,22 +1,29 @@
1
1
  {
2
2
  "name": "kill-switch-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "Kill Switch MCP Server — AI battle royale powered by Claude Code",
5
5
  "type": "module",
6
6
  "bin": {
7
- "kill-switch-mcp": "./dist/server.js"
7
+ "kill-switch-mcp": "./bin/kill-switch-mcp.js"
8
8
  },
9
9
  "files": [
10
- "dist/",
10
+ "bin/",
11
+ "src/",
11
12
  "defaults/"
12
13
  ],
13
14
  "scripts": {
14
- "build": "bun build src/server.ts --outdir dist --target node --format esm --splitting",
15
- "dev": "bun run src/server.ts"
15
+ "dev": "node bin/kill-switch-mcp.js"
16
16
  },
17
17
  "dependencies": {
18
- "@modelcontextprotocol/sdk": "^1.0.4"
18
+ "@modelcontextprotocol/sdk": "^1.0.4",
19
+ "tsx": "^4.19.0"
19
20
  },
20
- "keywords": ["mcp", "claude", "kill-switch", "ai", "battle-royale"],
21
+ "keywords": [
22
+ "mcp",
23
+ "claude",
24
+ "kill-switch",
25
+ "ai",
26
+ "battle-royale"
27
+ ],
21
28
  "license": "MIT"
22
29
  }
@@ -0,0 +1,188 @@
1
+ // Bot connection manager
2
+ // Supports multiple simultaneous bot connections
3
+
4
+ import { BotSDK, deriveGatewayUrl } from '../sdk/index';
5
+ import { BotActions } from '../sdk/actions';
6
+ import { readFile } from 'fs/promises';
7
+ import { join } from 'path';
8
+ import { existsSync } from 'fs';
9
+
10
+ export interface BotConnection {
11
+ sdk: BotSDK;
12
+ bot: BotActions;
13
+ username: string;
14
+ connected: boolean;
15
+ unsubscribeConnectionState?: () => void;
16
+ }
17
+
18
+ class BotManager {
19
+ private connections: Map<string, BotConnection> = new Map();
20
+ private defaultGatewayUrl = 'ws://localhost:7780';
21
+
22
+ /**
23
+ * Connect to a bot by name.
24
+ * If password is not provided, loads credentials from bots/{name}/bot.env
25
+ */
26
+ async connect(name: string, password?: string, gatewayUrl?: string): Promise<BotConnection> {
27
+ // Check if already connected
28
+ if (this.connections.has(name)) {
29
+ const existing = this.connections.get(name)!;
30
+ if (existing.connected) {
31
+ return existing;
32
+ }
33
+ // Reconnect if disconnected
34
+ await this.connectWithTimeout(existing.sdk, 30000);
35
+ existing.connected = true;
36
+ return existing;
37
+ }
38
+
39
+ let username = name;
40
+ let pwd = password;
41
+ let gateway = gatewayUrl || this.defaultGatewayUrl;
42
+ let showChat = false;
43
+
44
+ // Load credentials from bot.env if no password provided
45
+ if (!password) {
46
+ const envPath = join(process.cwd(), 'bots', name, 'bot.env');
47
+
48
+ if (!existsSync(envPath)) {
49
+ throw new Error(`Bot "${name}" not found. No bots/${name}/bot.env file exists. Call login with an agent_name to create one.`);
50
+ }
51
+
52
+ const envContent = await readFile(envPath, 'utf-8');
53
+ const env = this.parseEnv(envContent);
54
+
55
+ username = env.BOT_USERNAME || name;
56
+ pwd = env.PASSWORD;
57
+
58
+ if (env.SERVER) {
59
+ gateway = deriveGatewayUrl(env.SERVER);
60
+ }
61
+
62
+ // Check if chat should be shown (default: false for safety)
63
+ if (env.SHOW_CHAT?.toLowerCase() === 'true') {
64
+ showChat = true;
65
+ }
66
+ }
67
+
68
+ if (!pwd) {
69
+ throw new Error(`No password provided for bot "${name}"`);
70
+ }
71
+
72
+ console.error(`[MCP] Connecting bot "${name}":`);
73
+ console.error(`[MCP] username: ${username}`);
74
+ console.error(`[MCP] gateway: ${gateway}`);
75
+ console.error(`[MCP] password: ${pwd ? pwd.substring(0, 3) + '...' : 'MISSING'}`);
76
+
77
+ const sdk = new BotSDK({
78
+ botUsername: username,
79
+ password: pwd,
80
+ gatewayUrl: gateway,
81
+ connectionMode: 'control',
82
+ autoReconnect: true, // Enable auto-reconnect for connection stability
83
+ autoLaunchBrowser: 'auto', // Auto-launch browser if session is stale
84
+ showChat, // Show other players' chat (default: false for safety)
85
+ });
86
+
87
+ const bot = new BotActions(sdk);
88
+
89
+ // Connect with 30s timeout to avoid blocking forever
90
+ console.error(`[MCP] Starting connection...`);
91
+ await this.connectWithTimeout(sdk, 30000);
92
+ console.error(`[MCP] Bot "${name}" connected!`);
93
+
94
+ const connection: BotConnection = {
95
+ sdk,
96
+ bot,
97
+ username,
98
+ connected: true
99
+ };
100
+
101
+ // Track connection state changes
102
+ connection.unsubscribeConnectionState = sdk.onConnectionStateChange((state) => {
103
+ const wasConnected = connection.connected;
104
+ connection.connected = state === 'connected';
105
+ if (wasConnected && !connection.connected) {
106
+ console.error(`[MCP] Bot "${name}" connection lost (${state}), will auto-reconnect...`);
107
+ } else if (!wasConnected && connection.connected) {
108
+ console.error(`[MCP] Bot "${name}" reconnected!`);
109
+ }
110
+ });
111
+
112
+ this.connections.set(name, connection);
113
+ return connection;
114
+ }
115
+
116
+ private async connectWithTimeout(sdk: BotSDK, timeoutMs: number): Promise<void> {
117
+ let timeoutId: ReturnType<typeof setTimeout>;
118
+ const timeoutPromise = new Promise<never>((_, reject) => {
119
+ timeoutId = setTimeout(() => reject(new Error(`Connection timed out after ${timeoutMs / 1000}s`)), timeoutMs);
120
+ });
121
+
122
+ try {
123
+ await Promise.race([sdk.connect(), timeoutPromise]);
124
+ } finally {
125
+ clearTimeout(timeoutId!);
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Disconnect a bot by name
131
+ */
132
+ async disconnect(name: string): Promise<void> {
133
+ const connection = this.connections.get(name);
134
+ if (!connection) {
135
+ throw new Error(`Bot "${name}" is not connected`);
136
+ }
137
+
138
+ console.error(`[MCP] Disconnecting bot "${name}"...`);
139
+ if (connection.unsubscribeConnectionState) {
140
+ connection.unsubscribeConnectionState();
141
+ }
142
+ connection.sdk.disconnect();
143
+ connection.connected = false;
144
+ this.connections.delete(name);
145
+ console.error(`[MCP] Bot "${name}" disconnected`);
146
+ }
147
+
148
+ /**
149
+ * Get a bot connection by name
150
+ */
151
+ get(name: string): BotConnection | undefined {
152
+ return this.connections.get(name);
153
+ }
154
+
155
+ /**
156
+ * List all connected bots
157
+ */
158
+ list(): Array<{ name: string; username: string; connected: boolean }> {
159
+ return Array.from(this.connections.entries()).map(([name, conn]) => ({
160
+ name,
161
+ username: conn.username,
162
+ connected: conn.connected
163
+ }));
164
+ }
165
+
166
+ /**
167
+ * Check if a bot is connected
168
+ */
169
+ has(name: string): boolean {
170
+ return this.connections.has(name);
171
+ }
172
+
173
+ private parseEnv(content: string): Record<string, string> {
174
+ const result: Record<string, string> = {};
175
+ for (const line of content.split('\n')) {
176
+ const trimmed = line.trim();
177
+ if (!trimmed || trimmed.startsWith('#')) continue;
178
+ const [key, ...valueParts] = trimmed.split('=');
179
+ if (key && valueParts.length > 0) {
180
+ result[key.trim()] = valueParts.join('=').trim();
181
+ }
182
+ }
183
+ return result;
184
+ }
185
+ }
186
+
187
+ // Export singleton instance
188
+ export const botManager = new BotManager();
@@ -0,0 +1,450 @@
1
+ // Bot SDK - Action Helpers
2
+ // Private helper methods extracted from BotActions for reusability
3
+
4
+ import { BotSDK } from './index';
5
+ import type {
6
+ NearbyLoc,
7
+ NearbyNpc,
8
+ NearbyPlayer,
9
+ InventoryItem,
10
+ GroundItem,
11
+ ShopItem,
12
+ } from './types';
13
+
14
+ export class ActionHelpers {
15
+ constructor(private sdk: BotSDK) {}
16
+
17
+ // ============ Door Retry Wrapper ============
18
+
19
+ /**
20
+ * Wraps an action with automatic door-opening retry logic.
21
+ * If the action fails due to "can't reach", tries to open a nearby door and retries.
22
+ *
23
+ * @param action - Function that performs the action and returns a result
24
+ * @param shouldRetry - Function that checks if the result indicates a "can't reach" failure
25
+ * @param maxRetries - Maximum number of door-open retries (default 2)
26
+ * @returns The action result (either successful or final failure)
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * return this.helpers.withDoorRetry(
31
+ * () => this._pickupItemOnce(target),
32
+ * (r) => r.reason === 'cant_reach'
33
+ * );
34
+ * ```
35
+ */
36
+ async withDoorRetry<T extends { success: boolean }>(
37
+ action: () => Promise<T>,
38
+ shouldRetry: (result: T) => boolean,
39
+ maxRetries: number = 2
40
+ ): Promise<T> {
41
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
42
+ const result = await action();
43
+
44
+ // Success or non-retryable failure
45
+ if (result.success || !shouldRetry(result)) {
46
+ return result;
47
+ }
48
+
49
+ // Try opening a door before retrying
50
+ if (attempt < maxRetries) {
51
+ const doorOpened = await this.tryOpenBlockingDoor();
52
+ if (doorOpened) {
53
+ await this.sdk.waitForTicks(1);
54
+ continue;
55
+ }
56
+ }
57
+
58
+ // No door to open or max retries reached
59
+ return result;
60
+ }
61
+
62
+ // TypeScript needs this, but it's unreachable
63
+ return action();
64
+ }
65
+
66
+ // ============ Door Handling ============
67
+
68
+ /**
69
+ * Try to find and open a nearby blocking door/gate/fence.
70
+ * Walks to the door using raw sendWalk (not walkTo) to avoid recursion.
71
+ * @param maxDistance - Maximum distance to search for openable objects (default 15 tiles)
72
+ * @returns true if something was successfully opened
73
+ */
74
+ async tryOpenBlockingDoor(maxDistance: number = 15): Promise<boolean> {
75
+ // Look for any loc with an "Open" option - covers doors, gates, fences, pens, etc.
76
+ const openables = this.sdk.getNearbyLocs()
77
+ .filter(l => l.optionsWithIndex.some(o => /^open$/i.test(o.text)))
78
+ .filter(l => l.distance <= maxDistance)
79
+ .sort((a, b) => a.distance - b.distance);
80
+
81
+ if (openables.length === 0) {
82
+ return false;
83
+ }
84
+
85
+ const door = openables[0]!;
86
+ const doorX = door.x;
87
+ const doorZ = door.z;
88
+ const doorId = door.id;
89
+
90
+ const openOpt = door.optionsWithIndex.find(o => /^open$/i.test(o.text));
91
+ if (!openOpt) {
92
+ return true; // Already open (has Close option instead)
93
+ }
94
+
95
+ // Walk to an adjacent tile first — sendInteractLoc uses server-side
96
+ // pathfinding which enforces closed door collision, so it can't route
97
+ // through the very door we're trying to open.
98
+ await this.walkAdjacentTo(door.x, door.z);
99
+
100
+ const startTick = this.sdk.getState()?.tick || 0;
101
+ await this.sdk.sendInteractLoc(door.x, door.z, door.id, openOpt.opIndex);
102
+
103
+ // Wait for door to open (with longer timeout to allow for walking)
104
+ try {
105
+ await this.sdk.waitForCondition(state => {
106
+ // Check for failure messages - locked doors, can't reach, etc.
107
+ for (const msg of state.gameMessages) {
108
+ if (msg.tick > startTick) {
109
+ const text = msg.text.toLowerCase();
110
+ if (text.includes("can't reach") || text.includes("cannot reach") || text.includes("locked")) {
111
+ return true; // Exit early — door can't be opened
112
+ }
113
+ }
114
+ }
115
+
116
+ const doorNow = state.nearbyLocs.find(l =>
117
+ l.x === doorX && l.z === doorZ && l.id === doorId
118
+ );
119
+ if (!doorNow) return true; // Door gone = opened
120
+ return !doorNow.optionsWithIndex.some(o => /^open$/i.test(o.text)); // No "Open" option = opened
121
+ }, 8000); // Longer timeout to allow walking + opening
122
+
123
+ // Check if we got a "can't reach" message
124
+ const finalState = this.sdk.getState();
125
+ for (const msg of finalState?.gameMessages ?? []) {
126
+ if (msg.tick > startTick) {
127
+ const text = msg.text.toLowerCase();
128
+ if (text.includes("can't reach") || text.includes("cannot reach")) {
129
+ return false;
130
+ }
131
+ }
132
+ }
133
+
134
+ // Verify door actually opened
135
+ const doorAfter = finalState?.nearbyLocs.find(l =>
136
+ l.x === doorX && l.z === doorZ && l.id === doorId
137
+ );
138
+ if (!doorAfter || !doorAfter.optionsWithIndex.some(o => /^open$/i.test(o.text))) {
139
+ return true;
140
+ }
141
+
142
+ return false;
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Check recent game messages for "can't reach" indicators.
150
+ * @param startTick - Only check messages after this tick
151
+ */
152
+ checkCantReachMessage(startTick: number): boolean {
153
+ const state = this.sdk.getState();
154
+ if (!state) return false;
155
+
156
+ for (const msg of state.gameMessages) {
157
+ if (msg.tick > startTick) {
158
+ const text = msg.text.toLowerCase();
159
+ if (text.includes("can't reach") || text.includes("cannot reach") || text.includes("i can't reach")) {
160
+ return true;
161
+ }
162
+ }
163
+ }
164
+ return false;
165
+ }
166
+
167
+ // ============ Walk Adjacent ============
168
+
169
+ /**
170
+ * Walk to a tile adjacent to the given coordinates using raw sendWalk.
171
+ * Picks the closest cardinal-adjacent tile to the player.
172
+ * Used before interacting with doors/gates to avoid server-side pathfinding
173
+ * through the very door we're trying to open.
174
+ * @returns true if already adjacent or successfully walked adjacent
175
+ */
176
+ private async walkAdjacentTo(targetX: number, targetZ: number): Promise<boolean> {
177
+ const playerState = this.sdk.getState()?.player;
178
+ if (!playerState) return false;
179
+
180
+ const px = playerState.worldX;
181
+ const pz = playerState.worldZ;
182
+ const dx = Math.abs(px - targetX);
183
+ const dz = Math.abs(pz - targetZ);
184
+ const isAdjacent = (dx <= 1 && dz <= 1) && (dx + dz > 0);
185
+
186
+ if (isAdjacent) return true;
187
+
188
+ const candidates = [
189
+ { x: targetX, z: targetZ - 1 },
190
+ { x: targetX, z: targetZ + 1 },
191
+ { x: targetX - 1, z: targetZ },
192
+ { x: targetX + 1, z: targetZ },
193
+ ].sort((a, b) => {
194
+ const da = Math.abs(a.x - px) + Math.abs(a.z - pz);
195
+ const db = Math.abs(b.x - px) + Math.abs(b.z - pz);
196
+ return da - db;
197
+ });
198
+
199
+ const target = candidates[0]!;
200
+ await this.sdk.sendWalk(target.x, target.z, true);
201
+ await this.waitForMovementComplete(target.x, target.z, 1);
202
+ return true;
203
+ }
204
+
205
+ // ============ Movement Helpers ============
206
+
207
+ async waitForMovementComplete(
208
+ targetX: number,
209
+ targetZ: number,
210
+ tolerance: number = 3
211
+ ): Promise<{ arrived: boolean; stoppedMoving: boolean; x: number; z: number }> {
212
+ // All logic is tick-based so it scales with any server tick rate.
213
+ // Running = 2 tiles/tick. Walking = 1 tile/tick.
214
+ const TILES_PER_TICK = 2;
215
+ const STUCK_TICKS = 2; // 2 ticks of no movement = stuck
216
+ const MIN_TICKS = 3; // minimum ticks to wait
217
+ const SAFETY_MS = 15_000; // hard ms failsafe if state updates stop entirely
218
+
219
+ const startState = this.sdk.getState();
220
+ if (!startState?.player) {
221
+ return { arrived: false, stoppedMoving: true, x: 0, z: 0 };
222
+ }
223
+
224
+ const startX = startState.player.worldX;
225
+ const startZ = startState.player.worldZ;
226
+ const startTick = startState.tick;
227
+
228
+ const distance = Math.sqrt(
229
+ Math.pow(targetX - startX, 2) + Math.pow(targetZ - startZ, 2)
230
+ );
231
+ const expectedTicks = Math.ceil(distance / TILES_PER_TICK);
232
+ const maxTicks = Math.max(MIN_TICKS, Math.ceil(expectedTicks * 1.5));
233
+
234
+ let lastX = startX;
235
+ let lastZ = startZ;
236
+ let lastMoveTick = startTick;
237
+
238
+ return new Promise((resolve) => {
239
+ let resolved = false;
240
+ const done = (result: { arrived: boolean; stoppedMoving: boolean; x: number; z: number }) => {
241
+ if (resolved) return;
242
+ resolved = true;
243
+ clearTimeout(safetyTimer);
244
+ unsub();
245
+ resolve(result);
246
+ };
247
+
248
+ // Hard ms failsafe in case state updates stop arriving
249
+ const safetyTimer = setTimeout(() => {
250
+ const s = this.sdk.getState()?.player;
251
+ const fx = s?.worldX ?? lastX;
252
+ const fz = s?.worldZ ?? lastZ;
253
+ const fd = Math.sqrt(Math.pow(targetX - fx, 2) + Math.pow(targetZ - fz, 2));
254
+ done({ arrived: fd <= tolerance, stoppedMoving: true, x: fx, z: fz });
255
+ }, SAFETY_MS);
256
+
257
+ const unsub = this.sdk.onStateUpdate((state) => {
258
+ if (!state?.player) return;
259
+
260
+ const currentX = state.player.worldX;
261
+ const currentZ = state.player.worldZ;
262
+ const currentTick = state.tick;
263
+
264
+ // Check arrival
265
+ const distToTarget = Math.sqrt(
266
+ Math.pow(targetX - currentX, 2) + Math.pow(targetZ - currentZ, 2)
267
+ );
268
+ if (distToTarget <= tolerance) {
269
+ done({ arrived: true, stoppedMoving: false, x: currentX, z: currentZ });
270
+ return;
271
+ }
272
+
273
+ // Track movement by tick
274
+ if (currentX !== lastX || currentZ !== lastZ) {
275
+ lastMoveTick = currentTick;
276
+ lastX = currentX;
277
+ lastZ = currentZ;
278
+ }
279
+
280
+ // Stuck: no movement for STUCK_TICKS
281
+ if (currentTick - lastMoveTick >= STUCK_TICKS) {
282
+ done({ arrived: false, stoppedMoving: true, x: currentX, z: currentZ });
283
+ return;
284
+ }
285
+
286
+ // Tick budget exceeded
287
+ if (currentTick - startTick >= maxTicks) {
288
+ done({ arrived: distToTarget <= tolerance, stoppedMoving: true, x: currentX, z: currentZ });
289
+ }
290
+ });
291
+ });
292
+ }
293
+
294
+ // ============ Walk Step Helper ============
295
+
296
+ /**
297
+ * Take a single walk step toward a target and report the result.
298
+ * Used by walkTo to avoid duplicating walk-and-check logic.
299
+ */
300
+ async walkStepToward(
301
+ targetX: number,
302
+ targetZ: number,
303
+ tolerance: number,
304
+ lastPos: { x: number; z: number }
305
+ ): Promise<{ status: 'arrived' | 'progress' | 'stuck'; pos: { x: number; z: number } }> {
306
+ await this.sdk.sendWalk(targetX, targetZ, true);
307
+ const moveResult = await this.waitForMovementComplete(targetX, targetZ, tolerance);
308
+
309
+ const pos = this.sdk.getState()?.player;
310
+ if (!pos) {
311
+ return { status: 'stuck', pos: lastPos };
312
+ }
313
+
314
+ const currentPos = { x: pos.worldX, z: pos.worldZ };
315
+
316
+ // Check if arrived
317
+ const distToTarget = Math.sqrt(
318
+ Math.pow(targetX - currentPos.x, 2) + Math.pow(targetZ - currentPos.z, 2)
319
+ );
320
+ if (distToTarget <= tolerance) {
321
+ return { status: 'arrived', pos: currentPos };
322
+ }
323
+
324
+ // Check if stuck (didn't move much and stopped)
325
+ const moved = Math.sqrt(
326
+ Math.pow(currentPos.x - lastPos.x, 2) + Math.pow(currentPos.z - lastPos.z, 2)
327
+ );
328
+ if (moved < 2 && moveResult.stoppedMoving) {
329
+ return { status: 'stuck', pos: currentPos };
330
+ }
331
+
332
+ return { status: 'progress', pos: currentPos };
333
+ }
334
+
335
+ /**
336
+ * Calculate distance between two points.
337
+ */
338
+ distance(x1: number, z1: number, x2: number, z2: number): number {
339
+ return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(z2 - z1, 2));
340
+ }
341
+
342
+ // ============ Specific Door Opening ============
343
+
344
+ /**
345
+ * Open a specific door at exact coordinates.
346
+ * Used by proactive door-opening when the pathfinder identifies doors along the route.
347
+ * @returns true if the door was opened (or was already open)
348
+ */
349
+ async openDoorAt(doorX: number, doorZ: number): Promise<boolean> {
350
+ const locs = this.sdk.getNearbyLocs();
351
+ const door = locs.find(l => l.x === doorX && l.z === doorZ);
352
+ if (!door) return false;
353
+
354
+ const openOpt = door.optionsWithIndex.find(o => /^open$/i.test(o.text));
355
+ if (!openOpt) return true; // Already open (has Close option instead)
356
+
357
+ // Walk to an adjacent tile using raw sendWalk to avoid recursion
358
+ await this.walkAdjacentTo(doorX, doorZ);
359
+
360
+ const startTick = this.sdk.getState()?.tick || 0;
361
+ await this.sdk.sendInteractLoc(doorX, doorZ, door.id, openOpt.opIndex);
362
+
363
+ try {
364
+ await this.sdk.waitForCondition(state => {
365
+ // Check for failure messages (locked, can't reach, etc.)
366
+ for (const msg of state.gameMessages) {
367
+ if (msg.tick > startTick) {
368
+ const text = msg.text.toLowerCase();
369
+ if (text.includes("locked") || text.includes("can't reach") || text.includes("cannot reach")) {
370
+ return true; // Exit early — door can't be opened
371
+ }
372
+ }
373
+ }
374
+ const doorNow = state.nearbyLocs.find(l =>
375
+ l.x === doorX && l.z === doorZ && l.id === door.id
376
+ );
377
+ if (!doorNow) return true; // Door gone = opened
378
+ return !doorNow.optionsWithIndex.some(o => /^open$/i.test(o.text));
379
+ }, 8000);
380
+
381
+ // Verify door actually opened
382
+ const doorAfter = this.sdk.getState()?.nearbyLocs.find(l =>
383
+ l.x === doorX && l.z === doorZ && l.id === door.id
384
+ );
385
+ return !doorAfter || !doorAfter.optionsWithIndex.some(o => /^open$/i.test(o.text));
386
+ } catch {
387
+ return false;
388
+ }
389
+ }
390
+
391
+ // ============ Resolution Helpers ============
392
+
393
+ resolveLocation(
394
+ target: NearbyLoc | string | RegExp | undefined,
395
+ defaultPattern: RegExp
396
+ ): NearbyLoc | null {
397
+ if (!target) {
398
+ return this.sdk.findNearbyLoc(defaultPattern);
399
+ }
400
+ if (typeof target === 'object' && 'x' in target) {
401
+ return target;
402
+ }
403
+ return this.sdk.findNearbyLoc(target);
404
+ }
405
+
406
+ resolveInventoryItem(
407
+ target: InventoryItem | string | RegExp | undefined,
408
+ defaultPattern: RegExp
409
+ ): InventoryItem | null {
410
+ if (!target) {
411
+ return this.sdk.findInventoryItem(defaultPattern);
412
+ }
413
+ if (typeof target === 'object' && 'slot' in target) {
414
+ return target;
415
+ }
416
+ return this.sdk.findInventoryItem(target);
417
+ }
418
+
419
+ resolveGroundItem(target: GroundItem | string | RegExp): GroundItem | null {
420
+ if (typeof target === 'object' && 'x' in target) {
421
+ return target;
422
+ }
423
+ return this.sdk.findGroundItem(target);
424
+ }
425
+
426
+ resolveNpc(target: NearbyNpc | string | RegExp): NearbyNpc | null {
427
+ if (typeof target === 'object' && 'index' in target) {
428
+ return target;
429
+ }
430
+ return this.sdk.findNearbyNpc(target);
431
+ }
432
+
433
+ resolvePlayer(target: NearbyPlayer | string | RegExp): NearbyPlayer | null {
434
+ if (typeof target === 'object' && 'index' in target) {
435
+ return target;
436
+ }
437
+ return this.sdk.findNearbyPlayer(target);
438
+ }
439
+
440
+ resolveShopItem(
441
+ target: ShopItem | InventoryItem | string | RegExp,
442
+ items: ShopItem[]
443
+ ): ShopItem | null {
444
+ if (typeof target === 'object' && 'id' in target && 'name' in target) {
445
+ return items.find(i => i.id === target.id) ?? null;
446
+ }
447
+ const regex = typeof target === 'string' ? new RegExp(target, 'i') : target;
448
+ return items.find(i => regex.test(i.name)) ?? null;
449
+ }
450
+ }