kill-switch-mcp 1.0.1 → 1.1.2
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.
- package/bin/kill-switch-mcp.js +13 -0
- package/package.json +8 -6
- package/src/api/index.ts +188 -0
- package/src/sdk/actions-helpers.ts +450 -0
- package/src/sdk/actions.ts +3208 -0
- package/src/sdk/formatter.ts +263 -0
- package/src/sdk/index.ts +1313 -0
- package/src/sdk/pathfinding.ts +57 -0
- package/src/sdk/types.ts +584 -0
- package/src/server.ts +1088 -0
- package/src/wallet/index.ts +369 -0
- package/dist/server.js +0 -18160
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// Node 22+ has native TypeScript support (--experimental-strip-types).
|
|
4
|
+
// tsx's register() conflicts with Node 24's native .ts loader, causing
|
|
5
|
+
// ERR_REQUIRE_CYCLE_MODULE on dynamic import() of .ts action files.
|
|
6
|
+
// Only register tsx for older Node versions that lack native TS support.
|
|
7
|
+
const major = parseInt(process.versions.node.split('.')[0]);
|
|
8
|
+
if (major < 22) {
|
|
9
|
+
const { register } = await import('tsx/esm/api');
|
|
10
|
+
register();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
await import('../src/server.ts');
|
package/package.json
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kill-switch-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.2",
|
|
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": "
|
|
7
|
+
"kill-switch-mcp": "./bin/kill-switch-mcp.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
|
-
"
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
11
12
|
"defaults/"
|
|
12
13
|
],
|
|
13
14
|
"scripts": {
|
|
14
|
-
"
|
|
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",
|
|
20
|
+
"viem": "^2.47.6"
|
|
19
21
|
},
|
|
20
22
|
"keywords": [
|
|
21
23
|
"mcp",
|
package/src/api/index.ts
ADDED
|
@@ -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 dead
|
|
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
|
+
}
|