kill-switch-mcp 1.0.1 → 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.
- package/bin/kill-switch-mcp.js +4 -0
- package/package.json +7 -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 +838 -0
- package/dist/server.js +0 -18160
|
@@ -0,0 +1,3208 @@
|
|
|
1
|
+
// Bot SDK - Porcelain Layer
|
|
2
|
+
// High-level domain-aware methods that wrap plumbing with game knowledge
|
|
3
|
+
// Actions resolve when the EFFECT is complete (not just acknowledged)
|
|
4
|
+
|
|
5
|
+
import { BotSDK } from './index';
|
|
6
|
+
import { ActionHelpers } from './actions-helpers';
|
|
7
|
+
import { findDoorsAlongPath, blockDoor, } from './pathfinding';
|
|
8
|
+
import type {
|
|
9
|
+
ActionResult,
|
|
10
|
+
SkillState,
|
|
11
|
+
InventoryItem,
|
|
12
|
+
BankItem,
|
|
13
|
+
NearbyNpc,
|
|
14
|
+
NearbyPlayer,
|
|
15
|
+
NearbyLoc,
|
|
16
|
+
GroundItem,
|
|
17
|
+
ShopItem,
|
|
18
|
+
ChopTreeResult,
|
|
19
|
+
BurnLogsResult,
|
|
20
|
+
PickupResult,
|
|
21
|
+
TalkResult,
|
|
22
|
+
ShopResult,
|
|
23
|
+
ShopSellResult,
|
|
24
|
+
SellAmount,
|
|
25
|
+
EquipResult,
|
|
26
|
+
UnequipResult,
|
|
27
|
+
EatResult,
|
|
28
|
+
AttackResult,
|
|
29
|
+
CastSpellResult,
|
|
30
|
+
OpenDoorResult,
|
|
31
|
+
FletchResult,
|
|
32
|
+
CraftLeatherResult,
|
|
33
|
+
SmithResult,
|
|
34
|
+
OpenBankResult,
|
|
35
|
+
BankDepositResult,
|
|
36
|
+
BankWithdrawResult,
|
|
37
|
+
UseItemOnLocResult,
|
|
38
|
+
UseItemOnNpcResult,
|
|
39
|
+
InteractLocResult,
|
|
40
|
+
InteractNpcResult,
|
|
41
|
+
PickpocketResult,
|
|
42
|
+
PrayerResult,
|
|
43
|
+
PrayerName,
|
|
44
|
+
CraftJewelryResult,
|
|
45
|
+
EnchantResult,
|
|
46
|
+
StringAmuletResult,
|
|
47
|
+
} from './types';
|
|
48
|
+
import { PRAYER_INDICES, PRAYER_NAMES, PRAYER_LEVELS } from './types';
|
|
49
|
+
|
|
50
|
+
export class BotActions {
|
|
51
|
+
private helpers: ActionHelpers;
|
|
52
|
+
|
|
53
|
+
constructor(private sdk: BotSDK) {
|
|
54
|
+
this.helpers = new ActionHelpers(sdk);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ============ Porcelain: UI Helpers ============
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Skip tutorial by navigating dialogs and talking to tutorial NPCs.
|
|
61
|
+
* This is a porcelain method - domain logic that was moved from bot client.
|
|
62
|
+
* @param options.randomizeAppearance - If true, randomizes character appearance when the design screen appears. Default: true.
|
|
63
|
+
*/
|
|
64
|
+
async skipTutorial(options: { randomizeAppearance?: boolean } = {}): Promise<ActionResult> {
|
|
65
|
+
const { randomizeAppearance = true } = options;
|
|
66
|
+
const state = this.sdk.getState();
|
|
67
|
+
if (!state?.inGame) {
|
|
68
|
+
return { success: false, message: 'Not in game' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Helper to check and handle character design modal
|
|
72
|
+
const checkAndHandleDesignModal = async (): Promise<boolean> => {
|
|
73
|
+
const s = this.sdk.getState();
|
|
74
|
+
if (s?.modalOpen && s?.modalInterface === 3559) {
|
|
75
|
+
if (randomizeAppearance) {
|
|
76
|
+
await this.sdk.sendRandomizeCharacterDesign();
|
|
77
|
+
await this.sdk.waitForTicks(1);
|
|
78
|
+
}
|
|
79
|
+
await this.sdk.sendAcceptCharacterDesign();
|
|
80
|
+
await this.sdk.waitForTicks(1);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Check for character design modal (interface 3559) and handle it
|
|
87
|
+
await checkAndHandleDesignModal();
|
|
88
|
+
|
|
89
|
+
// If dialog open, navigate through it (may take multiple clicks)
|
|
90
|
+
if (state.dialog.isOpen) {
|
|
91
|
+
let clickCount = 0;
|
|
92
|
+
const MAX_CLICKS = 10;
|
|
93
|
+
|
|
94
|
+
while (clickCount < MAX_CLICKS) {
|
|
95
|
+
// Check for design modal each iteration
|
|
96
|
+
await checkAndHandleDesignModal();
|
|
97
|
+
|
|
98
|
+
const currentState = this.sdk.getState();
|
|
99
|
+
if (!currentState?.dialog.isOpen) {
|
|
100
|
+
return { success: true, message: `Dialog completed after ${clickCount} clicks` };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (currentState.dialog.isWaiting) {
|
|
104
|
+
await this.sdk.waitForTicks(1);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const options = currentState.dialog.options;
|
|
109
|
+
if (options.length > 0) {
|
|
110
|
+
// Smart option selection: skip > yes > confirm > first option
|
|
111
|
+
const skipOption = options.find(o => /skip|complete|finish/i.test(o.text));
|
|
112
|
+
const yesOption = options.find(o => /yes|continue|proceed/i.test(o.text));
|
|
113
|
+
const confirmOption = options.find(o => /confirm|accept|agree|ok/i.test(o.text));
|
|
114
|
+
|
|
115
|
+
const selectedOption = skipOption || yesOption || confirmOption || options[0];
|
|
116
|
+
await this.sdk.sendClickDialog(selectedOption!.index);
|
|
117
|
+
} else {
|
|
118
|
+
await this.sdk.sendClickDialog(0);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
clickCount++;
|
|
122
|
+
await this.sdk.waitForTicks(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { success: true, message: `Clicked through ${clickCount} dialogs` };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Find tutorial NPC
|
|
129
|
+
const guide = this.sdk.findNearbyNpc(/runescape guide|guide|tutorial/i);
|
|
130
|
+
if (guide) {
|
|
131
|
+
const talkOpt = guide.optionsWithIndex.find(o => /talk/i.test(o.text));
|
|
132
|
+
if (!talkOpt) {
|
|
133
|
+
return { success: false, message: 'No Talk option on tutorial NPC' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const result = await this.sdk.sendInteractNpc(guide.index, talkOpt.opIndex);
|
|
137
|
+
if (!result.success) {
|
|
138
|
+
return { success: false, message: result.message };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Wait for dialog to open
|
|
142
|
+
try {
|
|
143
|
+
await this.sdk.waitForCondition(s => s.dialog.isOpen, 5000);
|
|
144
|
+
await this.sdk.waitForTicks(1);
|
|
145
|
+
|
|
146
|
+
// Loop through all dialog pages until closed
|
|
147
|
+
let clickCount = 0;
|
|
148
|
+
const MAX_CLICKS = 10;
|
|
149
|
+
|
|
150
|
+
while (clickCount < MAX_CLICKS) {
|
|
151
|
+
// Check for design modal each iteration
|
|
152
|
+
await checkAndHandleDesignModal();
|
|
153
|
+
|
|
154
|
+
const currentState = this.sdk.getState();
|
|
155
|
+
if (!currentState?.dialog.isOpen) {
|
|
156
|
+
return { success: true, message: `Tutorial skipped after ${clickCount} dialog clicks` };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (currentState.dialog.isWaiting) {
|
|
160
|
+
await this.sdk.waitForTicks(1);
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const options = currentState.dialog.options;
|
|
165
|
+
if (options.length > 0) {
|
|
166
|
+
// Smart option selection: skip > yes > confirm > first option
|
|
167
|
+
const skipOption = options.find(o => /skip|complete|finish/i.test(o.text));
|
|
168
|
+
const yesOption = options.find(o => /yes|continue|proceed/i.test(o.text));
|
|
169
|
+
const confirmOption = options.find(o => /confirm|accept|agree|ok/i.test(o.text));
|
|
170
|
+
|
|
171
|
+
const selectedOption = skipOption || yesOption || confirmOption || options[0];
|
|
172
|
+
await this.sdk.sendClickDialog(selectedOption!.index);
|
|
173
|
+
} else {
|
|
174
|
+
await this.sdk.sendClickDialog(0);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
clickCount++;
|
|
178
|
+
await this.sdk.waitForTicks(1);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { success: true, message: `Clicked through ${clickCount} dialogs` };
|
|
182
|
+
} catch {
|
|
183
|
+
return { success: false, message: 'Timed out waiting for dialog to open' };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { success: false, message: 'No tutorial NPC found' };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Dismiss any blocking UI like level-up dialogs. */
|
|
191
|
+
async dismissBlockingUI(): Promise<void> {
|
|
192
|
+
const maxAttempts = 10;
|
|
193
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
194
|
+
const state = this.sdk.getState();
|
|
195
|
+
if (!state) break;
|
|
196
|
+
|
|
197
|
+
if (state.dialog.isOpen) {
|
|
198
|
+
await this.sdk.sendClickDialog(0);
|
|
199
|
+
await this.sdk.waitForStateChange(2000).catch(() => {});
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ============ Porcelain: Smart Actions ============
|
|
208
|
+
|
|
209
|
+
/** Open a door or gate, walking to it if needed. */
|
|
210
|
+
async openDoor(target?: NearbyLoc | string | RegExp): Promise<OpenDoorResult> {
|
|
211
|
+
await this.dismissBlockingUI();
|
|
212
|
+
|
|
213
|
+
const door = this.helpers.resolveLocation(target, /door|gate/i);
|
|
214
|
+
if (!door) {
|
|
215
|
+
return { success: false, message: 'No door found nearby', reason: 'door_not_found' };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const openOpt = door.optionsWithIndex.find(o => /^open$/i.test(o.text));
|
|
219
|
+
if (!openOpt) {
|
|
220
|
+
const closeOpt = door.optionsWithIndex.find(o => /^close$/i.test(o.text));
|
|
221
|
+
if (closeOpt) {
|
|
222
|
+
return { success: true, message: `${door.name} is already open`, reason: 'already_open', door };
|
|
223
|
+
}
|
|
224
|
+
const optTexts = door.optionsWithIndex.map(o => o.text);
|
|
225
|
+
return { success: false, message: `${door.name} has no Open option (options: ${optTexts.join(', ')})`, reason: 'no_open_option', door };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (door.distance > 2) {
|
|
229
|
+
const walkResult = await this.walkTo(door.x, door.z);
|
|
230
|
+
if (!walkResult.success) {
|
|
231
|
+
return { success: false, message: `Could not walk to ${door.name}: ${walkResult.message}`, reason: 'walk_failed', door };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const doorsNow = this.sdk.getNearbyLocs().filter(l =>
|
|
235
|
+
l.x === door.x && l.z === door.z && /door|gate/i.test(l.name)
|
|
236
|
+
);
|
|
237
|
+
const refreshedDoor = doorsNow[0];
|
|
238
|
+
if (!refreshedDoor) {
|
|
239
|
+
return { success: true, message: `${door.name} is no longer visible (may have been opened)`, door };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const refreshedOpenOpt = refreshedDoor.optionsWithIndex.find(o => /^open$/i.test(o.text));
|
|
243
|
+
if (!refreshedOpenOpt) {
|
|
244
|
+
const hasClose = refreshedDoor.optionsWithIndex.some(o => /^close$/i.test(o.text));
|
|
245
|
+
if (hasClose) {
|
|
246
|
+
return { success: true, message: `${door.name} is already open`, reason: 'already_open', door: refreshedDoor };
|
|
247
|
+
}
|
|
248
|
+
return { success: false, message: `${door.name} no longer has Open option`, reason: 'no_open_option', door: refreshedDoor };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
await this.sdk.sendInteractLoc(refreshedDoor.x, refreshedDoor.z, refreshedDoor.id, refreshedOpenOpt.opIndex);
|
|
252
|
+
} else {
|
|
253
|
+
await this.sdk.sendInteractLoc(door.x, door.z, door.id, openOpt.opIndex);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const doorX = door.x;
|
|
257
|
+
const doorZ = door.z;
|
|
258
|
+
const startTick = this.sdk.getState()?.tick || 0;
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
await this.sdk.waitForCondition(state => {
|
|
262
|
+
for (const msg of state.gameMessages) {
|
|
263
|
+
if (msg.tick > startTick) {
|
|
264
|
+
const text = msg.text.toLowerCase();
|
|
265
|
+
if (text.includes("can't reach") || text.includes("cannot reach")) {
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const doorNow = state.nearbyLocs.find(l =>
|
|
272
|
+
l.x === doorX && l.z === doorZ && /door|gate/i.test(l.name)
|
|
273
|
+
);
|
|
274
|
+
if (!doorNow) {
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
const hasClose = doorNow.optionsWithIndex.some(o => /^close$/i.test(o.text));
|
|
278
|
+
const hasOpen = doorNow.optionsWithIndex.some(o => /^open$/i.test(o.text));
|
|
279
|
+
return hasClose && !hasOpen;
|
|
280
|
+
}, 5000);
|
|
281
|
+
|
|
282
|
+
if (this.helpers.checkCantReachMessage(startTick)) {
|
|
283
|
+
return { success: false, message: `Cannot reach ${door.name} - still blocked`, reason: 'open_failed', door };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const doorAfter = this.sdk.getState()?.nearbyLocs.find(l =>
|
|
287
|
+
l.x === doorX && l.z === doorZ && /door|gate/i.test(l.name)
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
if (!doorAfter) {
|
|
291
|
+
return { success: true, message: `Opened ${door.name}`, door };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const hasCloseNow = doorAfter.optionsWithIndex.some(o => /^close$/i.test(o.text));
|
|
295
|
+
if (hasCloseNow) {
|
|
296
|
+
return { success: true, message: `Opened ${door.name}`, door: doorAfter };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return { success: false, message: `${door.name} did not open`, reason: 'open_failed', door: doorAfter };
|
|
300
|
+
|
|
301
|
+
} catch {
|
|
302
|
+
return { success: false, message: `Timeout waiting for ${door.name} to open`, reason: 'timeout', door };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Use an inventory item on a nearby location (e.g., fish on range, ore on furnace).
|
|
308
|
+
* Walks to the location first (handling doors), then uses the item.
|
|
309
|
+
*/
|
|
310
|
+
async useItemOnLoc(
|
|
311
|
+
item: InventoryItem | string | RegExp,
|
|
312
|
+
loc: NearbyLoc | string | RegExp,
|
|
313
|
+
options: { timeout?: number } = {}
|
|
314
|
+
): Promise<UseItemOnLocResult> {
|
|
315
|
+
const { timeout = 10000 } = options;
|
|
316
|
+
|
|
317
|
+
await this.dismissBlockingUI();
|
|
318
|
+
|
|
319
|
+
// Resolve item
|
|
320
|
+
const resolvedItem = this.helpers.resolveInventoryItem(item, /./);
|
|
321
|
+
if (!resolvedItem) {
|
|
322
|
+
return { success: false, message: `Item not found in inventory: ${item}`, reason: 'item_not_found' };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Resolve location
|
|
326
|
+
const resolvedLoc = this.helpers.resolveLocation(loc, /./);
|
|
327
|
+
if (!resolvedLoc) {
|
|
328
|
+
return { success: false, message: `Location not found nearby: ${loc}`, reason: 'loc_not_found' };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Walk to the location first (handles doors)
|
|
332
|
+
if (resolvedLoc.distance > 2) {
|
|
333
|
+
const walkResult = await this.walkTo(resolvedLoc.x, resolvedLoc.z, 2);
|
|
334
|
+
if (!walkResult.success) {
|
|
335
|
+
return { success: false, message: `Cannot reach ${resolvedLoc.name}: ${walkResult.message}`, reason: 'cant_reach' };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Re-find the location after walking (it may have moved in view)
|
|
340
|
+
const locPattern = typeof loc === 'object' ? new RegExp(resolvedLoc.name, 'i') : loc;
|
|
341
|
+
const locNow = this.helpers.resolveLocation(locPattern, /./);
|
|
342
|
+
if (!locNow) {
|
|
343
|
+
return { success: false, message: `${resolvedLoc.name} no longer visible`, reason: 'loc_not_found' };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const startTick = this.sdk.getState()?.tick || 0;
|
|
347
|
+
|
|
348
|
+
// Use the item on the location
|
|
349
|
+
const result = await this.sdk.sendUseItemOnLoc(resolvedItem.slot, locNow.x, locNow.z, locNow.id);
|
|
350
|
+
if (!result.success) {
|
|
351
|
+
return { success: false, message: result.message };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Wait for interaction to complete or fail
|
|
355
|
+
try {
|
|
356
|
+
await this.sdk.waitForCondition(state => {
|
|
357
|
+
// Check for "can't reach" messages
|
|
358
|
+
for (const msg of state.gameMessages) {
|
|
359
|
+
if (msg.tick > startTick) {
|
|
360
|
+
const text = msg.text.toLowerCase();
|
|
361
|
+
if (text.includes("can't reach") || text.includes("cannot reach")) {
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Check if dialog/interface opened (crafting menu, etc.)
|
|
368
|
+
if (state.dialog.isOpen || state.interface?.isOpen) {
|
|
369
|
+
return true;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Check if player started animating (cooking, smelting, etc.)
|
|
373
|
+
if (state.player && state.player.animId !== -1) {
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return false;
|
|
378
|
+
}, timeout);
|
|
379
|
+
|
|
380
|
+
// Check for failure
|
|
381
|
+
if (this.helpers.checkCantReachMessage(startTick)) {
|
|
382
|
+
return { success: false, message: `Cannot reach ${locNow.name}`, reason: 'cant_reach' };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return { success: true, message: `Used ${resolvedItem.name} on ${locNow.name}` };
|
|
386
|
+
} catch {
|
|
387
|
+
return { success: false, message: `Timeout using ${resolvedItem.name} on ${locNow.name}`, reason: 'timeout' };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Use an inventory item on a nearby NPC (e.g., bones on altar keeper, item on NPC).
|
|
393
|
+
* Walks to the NPC first (handling doors), then uses the item.
|
|
394
|
+
*/
|
|
395
|
+
async useItemOnNpc(
|
|
396
|
+
item: InventoryItem | string | RegExp,
|
|
397
|
+
npc: NearbyNpc | string | RegExp,
|
|
398
|
+
options: { timeout?: number } = {}
|
|
399
|
+
): Promise<UseItemOnNpcResult> {
|
|
400
|
+
const { timeout = 10000 } = options;
|
|
401
|
+
|
|
402
|
+
await this.dismissBlockingUI();
|
|
403
|
+
|
|
404
|
+
// Resolve item
|
|
405
|
+
const resolvedItem = this.helpers.resolveInventoryItem(item, /./);
|
|
406
|
+
if (!resolvedItem) {
|
|
407
|
+
return { success: false, message: `Item not found in inventory: ${item}`, reason: 'item_not_found' };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Resolve NPC
|
|
411
|
+
const resolvedNpc = this.helpers.resolveNpc(npc);
|
|
412
|
+
if (!resolvedNpc) {
|
|
413
|
+
return { success: false, message: `NPC not found nearby: ${npc}`, reason: 'npc_not_found' };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Walk to the NPC first (handles doors)
|
|
417
|
+
if (resolvedNpc.distance > 2) {
|
|
418
|
+
const walkResult = await this.walkTo(resolvedNpc.x, resolvedNpc.z, 2);
|
|
419
|
+
if (!walkResult.success) {
|
|
420
|
+
return { success: false, message: `Cannot reach ${resolvedNpc.name}: ${walkResult.message}`, reason: 'cant_reach' };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Re-find the NPC after walking (it may have moved)
|
|
425
|
+
const npcPattern = typeof npc === 'object' && 'index' in npc ? new RegExp(resolvedNpc.name, 'i') : npc;
|
|
426
|
+
const npcNow = this.helpers.resolveNpc(npcPattern);
|
|
427
|
+
if (!npcNow) {
|
|
428
|
+
return { success: false, message: `${resolvedNpc.name} no longer visible`, reason: 'npc_not_found' };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const startTick = this.sdk.getState()?.tick || 0;
|
|
432
|
+
|
|
433
|
+
// Use the item on the NPC
|
|
434
|
+
const result = await this.sdk.sendUseItemOnNpc(resolvedItem.slot, npcNow.index);
|
|
435
|
+
if (!result.success) {
|
|
436
|
+
return { success: false, message: result.message };
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Wait for interaction to complete or fail
|
|
440
|
+
try {
|
|
441
|
+
await this.sdk.waitForCondition(state => {
|
|
442
|
+
// Check for "can't reach" messages
|
|
443
|
+
for (const msg of state.gameMessages) {
|
|
444
|
+
if (msg.tick > startTick) {
|
|
445
|
+
const text = msg.text.toLowerCase();
|
|
446
|
+
if (text.includes("can't reach") || text.includes("cannot reach")) {
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Check if dialog/interface opened
|
|
453
|
+
if (state.dialog.isOpen || state.interface?.isOpen) {
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Check if player started animating
|
|
458
|
+
if (state.player && state.player.animId !== -1) {
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return false;
|
|
463
|
+
}, timeout);
|
|
464
|
+
|
|
465
|
+
// Check for failure
|
|
466
|
+
if (this.helpers.checkCantReachMessage(startTick)) {
|
|
467
|
+
return { success: false, message: `Cannot reach ${npcNow.name}`, reason: 'cant_reach' };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return { success: true, message: `Used ${resolvedItem.name} on ${npcNow.name}` };
|
|
471
|
+
} catch {
|
|
472
|
+
return { success: false, message: `Timeout using ${resolvedItem.name} on ${npcNow.name}`, reason: 'timeout' };
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Chop a tree and wait for logs to appear in inventory. */
|
|
477
|
+
async chopTree(target?: NearbyLoc | string | RegExp): Promise<ChopTreeResult> {
|
|
478
|
+
await this.dismissBlockingUI();
|
|
479
|
+
|
|
480
|
+
const tree = this.helpers.resolveLocation(target, /^tree$/i);
|
|
481
|
+
if (!tree) {
|
|
482
|
+
return { success: false, message: 'No tree found' };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const invCountBefore = this.sdk.getInventory().length;
|
|
486
|
+
const result = await this.sdk.sendInteractLoc(tree.x, tree.z, tree.id, 1);
|
|
487
|
+
|
|
488
|
+
if (!result.success) {
|
|
489
|
+
return { success: false, message: result.message };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
try {
|
|
493
|
+
await this.sdk.waitForCondition(state => {
|
|
494
|
+
const newItem = state.inventory.length > invCountBefore;
|
|
495
|
+
const treeGone = !state.nearbyLocs.find(l =>
|
|
496
|
+
l.x === tree.x && l.z === tree.z && l.id === tree.id
|
|
497
|
+
);
|
|
498
|
+
return newItem || treeGone;
|
|
499
|
+
}, 30000);
|
|
500
|
+
|
|
501
|
+
const logs = this.sdk.findInventoryItem(/logs/i);
|
|
502
|
+
return { success: true, logs: logs || undefined, message: 'Chopped tree' };
|
|
503
|
+
} catch {
|
|
504
|
+
return { success: false, message: 'Timed out waiting for tree chop' };
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Burn logs using a tinderbox, wait for firemaking XP. */
|
|
509
|
+
async burnLogs(logsTarget?: InventoryItem | string | RegExp): Promise<BurnLogsResult> {
|
|
510
|
+
await this.dismissBlockingUI();
|
|
511
|
+
|
|
512
|
+
const tinderbox = this.sdk.findInventoryItem(/tinderbox/i);
|
|
513
|
+
if (!tinderbox) {
|
|
514
|
+
return { success: false, xpGained: 0, message: 'No tinderbox in inventory' };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const logs = this.helpers.resolveInventoryItem(logsTarget, /logs/i);
|
|
518
|
+
if (!logs) {
|
|
519
|
+
return { success: false, xpGained: 0, message: 'No logs in inventory' };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const fmBefore = this.sdk.getSkill('Firemaking')?.experience || 0;
|
|
523
|
+
|
|
524
|
+
const result = await this.sdk.sendUseItemOnItem(tinderbox.slot, logs.slot);
|
|
525
|
+
if (!result.success) {
|
|
526
|
+
return { success: false, xpGained: 0, message: result.message };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const startTick = this.sdk.getState()?.tick || 0;
|
|
530
|
+
let lastDialogClickTick = 0;
|
|
531
|
+
|
|
532
|
+
try {
|
|
533
|
+
await this.sdk.waitForCondition(state => {
|
|
534
|
+
const fmXp = state.skills.find(s => s.name === 'Firemaking')?.experience || 0;
|
|
535
|
+
if (fmXp > fmBefore) {
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (state.dialog.isOpen && (state.tick - lastDialogClickTick) >= 3) {
|
|
540
|
+
lastDialogClickTick = state.tick;
|
|
541
|
+
this.sdk.sendClickDialog(0).catch(() => {});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const failureMessages = ["can't light a fire", "you need to move", "can't do that here"];
|
|
545
|
+
for (const msg of state.gameMessages) {
|
|
546
|
+
if (msg.tick > startTick) {
|
|
547
|
+
const text = msg.text.toLowerCase();
|
|
548
|
+
if (failureMessages.some(f => text.includes(f))) {
|
|
549
|
+
return true;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return false;
|
|
555
|
+
}, 30000);
|
|
556
|
+
|
|
557
|
+
const fmAfter = this.sdk.getSkill('Firemaking')?.experience || 0;
|
|
558
|
+
const xpGained = fmAfter - fmBefore;
|
|
559
|
+
|
|
560
|
+
return {
|
|
561
|
+
success: xpGained > 0,
|
|
562
|
+
xpGained,
|
|
563
|
+
message: xpGained > 0 ? 'Burned logs' : 'Failed to light fire (possibly bad location)'
|
|
564
|
+
};
|
|
565
|
+
} catch {
|
|
566
|
+
return { success: false, xpGained: 0, message: 'Timed out waiting for fire' };
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Pick up an item from the ground. */
|
|
571
|
+
async pickupItem(target: GroundItem | string | RegExp): Promise<PickupResult> {
|
|
572
|
+
return this.helpers.withDoorRetry(
|
|
573
|
+
() => this._pickupItemOnce(target),
|
|
574
|
+
(r) => r.reason === 'cant_reach'
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private async _pickupItemOnce(target: GroundItem | string | RegExp): Promise<PickupResult> {
|
|
579
|
+
await this.dismissBlockingUI();
|
|
580
|
+
|
|
581
|
+
const item = this.helpers.resolveGroundItem(target);
|
|
582
|
+
if (!item) {
|
|
583
|
+
return { success: false, message: 'Item not found on ground', reason: 'item_not_found' };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const invCountBefore = this.sdk.getInventory().length;
|
|
587
|
+
|
|
588
|
+
// Walk close to the item first (server handles final positioning via sendPickup)
|
|
589
|
+
if (item.distance > 2) {
|
|
590
|
+
const walkResult = await this.walkTo(item.x, item.z, 2);
|
|
591
|
+
if (!walkResult.success) {
|
|
592
|
+
return { success: false, message: walkResult.message, reason: 'cant_reach' };
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Wait one tick before picking up
|
|
597
|
+
await this.sdk.waitForTicks(1);
|
|
598
|
+
|
|
599
|
+
// Capture startTick AFTER walk so we only check messages from the pickup, not the walk
|
|
600
|
+
const startTick = this.sdk.getState()?.tick || 0;
|
|
601
|
+
|
|
602
|
+
// Now send the pickup command
|
|
603
|
+
const result = await this.sdk.sendPickup(item.x, item.z, item.id);
|
|
604
|
+
if (!result.success) {
|
|
605
|
+
return { success: false, message: result.message };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Track total inventory item count (handles stackables)
|
|
609
|
+
const invTotalBefore = this.sdk.getInventory().reduce((sum, i) => sum + i.count, 0);
|
|
610
|
+
|
|
611
|
+
try {
|
|
612
|
+
const finalState = await this.sdk.waitForCondition(state => {
|
|
613
|
+
// Check for failure messages
|
|
614
|
+
for (const msg of state.gameMessages) {
|
|
615
|
+
if (msg.tick > startTick) {
|
|
616
|
+
const text = msg.text.toLowerCase();
|
|
617
|
+
if (text.includes("can't reach") || text.includes("cannot reach")) {
|
|
618
|
+
return true;
|
|
619
|
+
}
|
|
620
|
+
if (text.includes("inventory") && text.includes("full")) {
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
// Item disappeared from ground (picked up by us or someone else)
|
|
626
|
+
const stillOnGround = state.groundItems.some(g => g.x === item.x && g.z === item.z && g.id === item.id);
|
|
627
|
+
if (!stillOnGround) return true;
|
|
628
|
+
// New inventory slot appeared (non-stackable)
|
|
629
|
+
if (state.inventory.length > invCountBefore) return true;
|
|
630
|
+
// Existing stack grew (stackable)
|
|
631
|
+
const invTotalNow = state.inventory.reduce((sum, i) => sum + i.count, 0);
|
|
632
|
+
if (invTotalNow > invTotalBefore) return true;
|
|
633
|
+
return false;
|
|
634
|
+
}, 10000);
|
|
635
|
+
|
|
636
|
+
// Check for failure reasons
|
|
637
|
+
for (const msg of finalState.gameMessages) {
|
|
638
|
+
if (msg.tick > startTick) {
|
|
639
|
+
const text = msg.text.toLowerCase();
|
|
640
|
+
if (text.includes("can't reach") || text.includes("cannot reach")) {
|
|
641
|
+
return { success: false, message: `Cannot reach ${item.name} - path blocked`, reason: 'cant_reach' };
|
|
642
|
+
}
|
|
643
|
+
if (text.includes("inventory") && text.includes("full")) {
|
|
644
|
+
return { success: false, message: 'Inventory is full', reason: 'inventory_full' };
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const pickedUp = this.sdk.getInventory().find(i => i.id === item.id);
|
|
650
|
+
|
|
651
|
+
// Wait one tick after picking up
|
|
652
|
+
await this.sdk.waitForTicks(1);
|
|
653
|
+
|
|
654
|
+
return { success: true, item: pickedUp, message: `Picked up ${item.name}` };
|
|
655
|
+
} catch {
|
|
656
|
+
return { success: false, message: 'Timed out waiting for pickup', reason: 'timeout' };
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Talk to an NPC and wait for dialog to open. Walks to the NPC first (handling doors). */
|
|
661
|
+
async talkTo(target: NearbyNpc | string | RegExp): Promise<TalkResult> {
|
|
662
|
+
await this.dismissBlockingUI();
|
|
663
|
+
|
|
664
|
+
const npc = this.helpers.resolveNpc(target);
|
|
665
|
+
if (!npc) {
|
|
666
|
+
return { success: false, message: 'NPC not found' };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Walk to the NPC first (handles doors)
|
|
670
|
+
if (npc.distance > 2) {
|
|
671
|
+
const walkResult = await this.walkTo(npc.x, npc.z, 2);
|
|
672
|
+
if (!walkResult.success) {
|
|
673
|
+
return { success: false, message: `Cannot reach ${npc.name}: ${walkResult.message}` };
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Re-find the NPC after walking (it may have moved)
|
|
678
|
+
const npcPattern = typeof target === 'object' ? new RegExp(npc.name, 'i') : target;
|
|
679
|
+
const npcNow = this.helpers.resolveNpc(npcPattern);
|
|
680
|
+
if (!npcNow) {
|
|
681
|
+
return { success: false, message: `${npc.name} no longer visible` };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const startTick = this.sdk.getState()?.tick || 0;
|
|
685
|
+
let lastMoveTick = startTick;
|
|
686
|
+
let lastX = this.sdk.getState()?.player?.x ?? 0;
|
|
687
|
+
let lastZ = this.sdk.getState()?.player?.z ?? 0;
|
|
688
|
+
|
|
689
|
+
const result = await this.sdk.sendTalkToNpc(npcNow.index);
|
|
690
|
+
if (!result.success) {
|
|
691
|
+
return { success: false, message: result.message };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
try {
|
|
695
|
+
const finalState = await this.sdk.waitForCondition(state => {
|
|
696
|
+
// Check for can't-reach messages
|
|
697
|
+
for (const msg of state.gameMessages) {
|
|
698
|
+
if (msg.tick > startTick) {
|
|
699
|
+
const text = msg.text.toLowerCase();
|
|
700
|
+
if (text.includes("can't reach") || text.includes("cannot reach")) return true;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Dialog opened — success
|
|
705
|
+
if (state.dialog.isOpen) return true;
|
|
706
|
+
|
|
707
|
+
// Track movement
|
|
708
|
+
if (state.player && (state.player.x !== lastX || state.player.z !== lastZ)) {
|
|
709
|
+
lastX = state.player.x;
|
|
710
|
+
lastZ = state.player.z;
|
|
711
|
+
lastMoveTick = state.tick;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Player idle for 2+ ticks with no dialog → give up
|
|
715
|
+
if (state.tick - lastMoveTick >= 2) return true;
|
|
716
|
+
|
|
717
|
+
return false;
|
|
718
|
+
}, 30000); // safety net only
|
|
719
|
+
|
|
720
|
+
if (this.helpers.checkCantReachMessage(startTick)) {
|
|
721
|
+
return { success: false, message: `Cannot reach ${npcNow.name}` };
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (finalState.dialog.isOpen) {
|
|
725
|
+
return { success: true, dialog: finalState.dialog, message: `Talking to ${npcNow.name}` };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
return { success: false, message: 'Dialog did not open' };
|
|
729
|
+
} catch {
|
|
730
|
+
return { success: false, message: 'Timed out waiting for dialog' };
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Walk to coordinates using pathfinding, auto-opening doors. */
|
|
735
|
+
async walkTo(x: number, z: number, tolerance: number = 3): Promise<ActionResult> {
|
|
736
|
+
await this.dismissBlockingUI();
|
|
737
|
+
|
|
738
|
+
const state = this.sdk.getState();
|
|
739
|
+
if (!state?.player) return { success: false, message: 'No player state' };
|
|
740
|
+
|
|
741
|
+
const distTo = (pos: { x: number; z: number }) => this.helpers.distance(pos.x, pos.z, x, z);
|
|
742
|
+
let pos = { x: state.player.worldX, z: state.player.worldZ };
|
|
743
|
+
|
|
744
|
+
if (distTo(pos) <= tolerance) {
|
|
745
|
+
return { success: true, message: 'Already at destination' };
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const MAX_ITERATIONS = 50;
|
|
749
|
+
const MAX_DOOR_RETRIES = 3;
|
|
750
|
+
let doorRetryCount = 0;
|
|
751
|
+
let poorProgressCount = 0;
|
|
752
|
+
const blockedDoors = new Set<string>(); // Doors we failed to open (locked etc.)
|
|
753
|
+
|
|
754
|
+
// Try to open a blocking door. Returns true if door was opened.
|
|
755
|
+
const tryOpenDoor = async (): Promise<boolean> => {
|
|
756
|
+
if (doorRetryCount >= MAX_DOOR_RETRIES) return false;
|
|
757
|
+
if (await this.helpers.tryOpenBlockingDoor()) {
|
|
758
|
+
doorRetryCount++;
|
|
759
|
+
await this.sdk.waitForTicks(1);
|
|
760
|
+
return true;
|
|
761
|
+
}
|
|
762
|
+
// Door open failed — block the nearest openable door in pathfinding
|
|
763
|
+
// so subsequent path queries route around it
|
|
764
|
+
const nearest = this.sdk.getNearbyLocs()
|
|
765
|
+
.filter(l => l.optionsWithIndex.some(o => /^open$/i.test(o.text)))
|
|
766
|
+
.filter(l => l.distance <= 15)
|
|
767
|
+
.sort((a, b) => a.distance - b.distance)[0];
|
|
768
|
+
if (nearest) {
|
|
769
|
+
const level = this.sdk.getState()?.player?.level ?? 0;
|
|
770
|
+
const key = `${nearest.x},${nearest.z}`;
|
|
771
|
+
if (!blockedDoors.has(key)) {
|
|
772
|
+
blockedDoors.add(key);
|
|
773
|
+
blockDoor(level, nearest.x, nearest.z);
|
|
774
|
+
console.log(`[walkTo] Blocked impassable door at (${nearest.x}, ${nearest.z}) — re-routing`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return false;
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
|
781
|
+
// Try pathfinding (with one retry)
|
|
782
|
+
let path = await this.sdk.sendFindPath(x, z, 500);
|
|
783
|
+
if (!path.success || !path.waypoints?.length) {
|
|
784
|
+
await this.sdk.waitForTicks(1);
|
|
785
|
+
path = await this.sdk.sendFindPath(x, z, 500);
|
|
786
|
+
if (!path.success || !path.waypoints?.length) {
|
|
787
|
+
console.error(`[walkTo] PATHFINDING FAILED: ${path.error ?? 'no waypoints'} - from (${pos.x}, ${pos.z}) to (${x}, ${z})`);
|
|
788
|
+
return { success: false, message: `No path to (${x}, ${z}) from (${pos.x}, ${pos.z}): ${path.error ?? 'no waypoints'}` };
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Identify doors the path crosses through so we can open them proactively
|
|
793
|
+
const requiredDoors = findDoorsAlongPath(path.waypoints);
|
|
794
|
+
const requiredDoorKeys = new Set(requiredDoors.map(d => `${d.x},${d.z}`));
|
|
795
|
+
|
|
796
|
+
// Walk waypoints
|
|
797
|
+
const startPos = { ...pos };
|
|
798
|
+
let consecutiveStuck = 0;
|
|
799
|
+
|
|
800
|
+
for (const wp of path.waypoints) {
|
|
801
|
+
// Proactively open doors the path requires — only when we're close enough to see them
|
|
802
|
+
if (requiredDoorKeys.size > 0) {
|
|
803
|
+
const wpDoorKey = `${wp.x},${wp.z}`;
|
|
804
|
+
const isNearDoor = requiredDoorKeys.has(wpDoorKey) ||
|
|
805
|
+
requiredDoorKeys.has(`${wp.x + 1},${wp.z}`) ||
|
|
806
|
+
requiredDoorKeys.has(`${wp.x - 1},${wp.z}`) ||
|
|
807
|
+
requiredDoorKeys.has(`${wp.x},${wp.z + 1}`) ||
|
|
808
|
+
requiredDoorKeys.has(`${wp.x},${wp.z - 1}`);
|
|
809
|
+
|
|
810
|
+
if (isNearDoor) {
|
|
811
|
+
const dist = this.helpers.distance(pos.x, pos.z, wp.x, wp.z);
|
|
812
|
+
if (dist <= 15) {
|
|
813
|
+
// Find which required door is closest to this waypoint
|
|
814
|
+
for (const door of requiredDoors) {
|
|
815
|
+
const doorKey = `${door.x},${door.z}`;
|
|
816
|
+
if (blockedDoors.has(doorKey)) break; // Already known locked
|
|
817
|
+
const doorDist = Math.abs(door.x - wp.x) + Math.abs(door.z - wp.z);
|
|
818
|
+
if (doorDist <= 1) {
|
|
819
|
+
const opened = await this.helpers.openDoorAt(door.x, door.z);
|
|
820
|
+
if (opened) {
|
|
821
|
+
requiredDoorKeys.delete(doorKey);
|
|
822
|
+
await this.sdk.waitForTicks(1);
|
|
823
|
+
} else {
|
|
824
|
+
// Door failed to open (locked, etc.) — block in pathfinder
|
|
825
|
+
blockedDoors.add(doorKey);
|
|
826
|
+
blockDoor(door.level, door.x, door.z);
|
|
827
|
+
requiredDoorKeys.delete(doorKey);
|
|
828
|
+
console.log(`[walkTo] Blocked impassable door at (${door.x}, ${door.z}) — re-routing`);
|
|
829
|
+
break; // Re-query path on next iteration
|
|
830
|
+
}
|
|
831
|
+
break;
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const result = await this.helpers.walkStepToward(wp.x, wp.z, 2, pos);
|
|
839
|
+
if (distTo(result.pos) <= tolerance) return { success: true, message: 'Arrived' };
|
|
840
|
+
|
|
841
|
+
if (result.status === 'stuck') {
|
|
842
|
+
if (++consecutiveStuck >= 3) {
|
|
843
|
+
await tryOpenDoor();
|
|
844
|
+
break; // Re-query path
|
|
845
|
+
}
|
|
846
|
+
} else {
|
|
847
|
+
consecutiveStuck = 0;
|
|
848
|
+
pos = result.pos;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Check progress since path query started
|
|
853
|
+
const distMoved = this.helpers.distance(startPos.x, startPos.z, pos.x, pos.z);
|
|
854
|
+
|
|
855
|
+
if (distMoved >= 5) {
|
|
856
|
+
poorProgressCount = 0;
|
|
857
|
+
} else if (++poorProgressCount >= 3) {
|
|
858
|
+
if (!await tryOpenDoor()) {
|
|
859
|
+
return { success: false, message: `Stuck at (${pos.x}, ${pos.z})` };
|
|
860
|
+
}
|
|
861
|
+
poorProgressCount = 0;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
return { success: false, message: `Could not reach (${x}, ${z}) - stopped at (${pos.x}, ${pos.z})` };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// ============ Porcelain: Shop Actions ============
|
|
869
|
+
|
|
870
|
+
/** Close the shop interface. */
|
|
871
|
+
async closeShop(timeout: number = 5000): Promise<ActionResult> {
|
|
872
|
+
const state = this.sdk.getState();
|
|
873
|
+
if (!state?.shop.isOpen && !state?.interface?.isOpen) {
|
|
874
|
+
return { success: true, message: 'Shop already closed' };
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
await this.sdk.sendCloseShop();
|
|
878
|
+
|
|
879
|
+
try {
|
|
880
|
+
await this.sdk.waitForCondition(s => {
|
|
881
|
+
const shopClosed = !s.shop.isOpen;
|
|
882
|
+
const interfaceClosed = !s.interface?.isOpen;
|
|
883
|
+
return shopClosed && interfaceClosed;
|
|
884
|
+
}, timeout);
|
|
885
|
+
|
|
886
|
+
return { success: true, message: 'Shop closed' };
|
|
887
|
+
} catch {
|
|
888
|
+
await this.sdk.sendCloseShop();
|
|
889
|
+
await this.sdk.waitForTicks(1);
|
|
890
|
+
const finalState = this.sdk.getState();
|
|
891
|
+
|
|
892
|
+
if (!finalState?.shop.isOpen && !finalState?.interface?.isOpen) {
|
|
893
|
+
return { success: true, message: 'Shop closed (second attempt)' };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
return {
|
|
897
|
+
success: false,
|
|
898
|
+
message: `Shop close timeout - shop.isOpen=${finalState?.shop.isOpen}, interface.isOpen=${finalState?.interface?.isOpen}`
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** Open a shop by trading with an NPC. */
|
|
904
|
+
async openShop(target: NearbyNpc | string | RegExp = /shop\s*keeper/i): Promise<ActionResult> {
|
|
905
|
+
await this.dismissBlockingUI();
|
|
906
|
+
|
|
907
|
+
const npc = this.helpers.resolveNpc(target);
|
|
908
|
+
if (!npc) {
|
|
909
|
+
return { success: false, message: 'Shopkeeper not found' };
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const tradeOpt = npc.optionsWithIndex.find(o => /trade/i.test(o.text));
|
|
913
|
+
if (!tradeOpt) {
|
|
914
|
+
return { success: false, message: 'No trade option on NPC' };
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Walk near NPC first - this handles doors
|
|
918
|
+
if (npc.distance > 2) {
|
|
919
|
+
const walkResult = await this.walkTo(npc.x, npc.z, 2);
|
|
920
|
+
if (!walkResult.success) {
|
|
921
|
+
return { success: false, message: `Cannot reach ${npc.name}: ${walkResult.message}` };
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
const result = await this.sdk.sendInteractNpc(npc.index, tradeOpt.opIndex);
|
|
926
|
+
if (!result.success) {
|
|
927
|
+
return result;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
try {
|
|
931
|
+
const finalState = await this.sdk.waitForCondition(state => {
|
|
932
|
+
if (state.shop.isOpen) return true;
|
|
933
|
+
return false;
|
|
934
|
+
}, 10000);
|
|
935
|
+
|
|
936
|
+
if (finalState.shop.isOpen) {
|
|
937
|
+
return { success: true, message: `Opened shop: ${this.sdk.getState()?.shop.title}` };
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
return { success: false, message: 'Shop did not open' };
|
|
941
|
+
} catch {
|
|
942
|
+
return { success: false, message: 'Timed out waiting for shop to open' };
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/** Buy an item from an open shop .*/
|
|
947
|
+
async buyFromShop(target: ShopItem | string | RegExp, amount: number = 1): Promise<ShopResult> {
|
|
948
|
+
const shop = this.sdk.getState()?.shop;
|
|
949
|
+
if (!shop?.isOpen) {
|
|
950
|
+
return { success: false, message: 'Shop is not open' };
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const shopItem = this.helpers.resolveShopItem(target, shop.shopItems);
|
|
954
|
+
if (!shopItem) {
|
|
955
|
+
return { success: false, message: `Item not found in shop: ${target}` };
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Count total items across all inventory slots (handles non-stackable items)
|
|
959
|
+
const countInvItems = () =>
|
|
960
|
+
this.sdk.getInventory()
|
|
961
|
+
.filter(i => i.id === shopItem.id)
|
|
962
|
+
.reduce((sum, i) => sum + i.count, 0);
|
|
963
|
+
|
|
964
|
+
const totalBefore = countInvItems();
|
|
965
|
+
|
|
966
|
+
// Decompose amount into valid buy commands (10, 5, 1)
|
|
967
|
+
let remaining = Math.max(1, Math.floor(amount));
|
|
968
|
+
const buySteps: number[] = [];
|
|
969
|
+
while (remaining > 0) {
|
|
970
|
+
if (remaining >= 10) { buySteps.push(10); remaining -= 10; }
|
|
971
|
+
else if (remaining >= 5) { buySteps.push(5); remaining -= 5; }
|
|
972
|
+
else { buySteps.push(1); remaining -= 1; }
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
for (const stepAmount of buySteps) {
|
|
976
|
+
const countBefore = countInvItems();
|
|
977
|
+
|
|
978
|
+
const result = await this.sdk.sendShopBuy(shopItem.slot, stepAmount);
|
|
979
|
+
if (!result.success) {
|
|
980
|
+
const totalBought = countInvItems() - totalBefore;
|
|
981
|
+
if (totalBought > 0) {
|
|
982
|
+
const boughtItem = this.sdk.getInventory().find(i => i.id === shopItem.id);
|
|
983
|
+
return { success: true, item: boughtItem, message: `Bought ${shopItem.name} x${totalBought} (wanted ${amount})` };
|
|
984
|
+
}
|
|
985
|
+
return { success: false, message: result.message };
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
try {
|
|
989
|
+
await this.sdk.waitForCondition(state => {
|
|
990
|
+
const total = state.inventory
|
|
991
|
+
.filter(i => i.id === shopItem.id)
|
|
992
|
+
.reduce((sum, i) => sum + i.count, 0);
|
|
993
|
+
return total > countBefore;
|
|
994
|
+
}, 5000);
|
|
995
|
+
} catch {
|
|
996
|
+
const totalBought = countInvItems() - totalBefore;
|
|
997
|
+
if (totalBought > 0) {
|
|
998
|
+
const boughtItem = this.sdk.getInventory().find(i => i.id === shopItem.id);
|
|
999
|
+
return { success: true, item: boughtItem, message: `Bought ${shopItem.name} x${totalBought} (wanted ${amount})` };
|
|
1000
|
+
}
|
|
1001
|
+
return { success: false, message: `Failed to buy ${shopItem.name} (no coins or out of stock?)` };
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const totalBought = countInvItems() - totalBefore;
|
|
1006
|
+
const boughtItem = this.sdk.getInventory().find(i => i.id === shopItem.id);
|
|
1007
|
+
return { success: true, item: boughtItem, message: `Bought ${shopItem.name} x${totalBought}` };
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/** Sell an item to an open shop. */
|
|
1011
|
+
async sellToShop(target: InventoryItem | ShopItem | string | RegExp, amount: SellAmount = 1): Promise<ShopSellResult> {
|
|
1012
|
+
const shop = this.sdk.getState()?.shop;
|
|
1013
|
+
if (!shop?.isOpen) {
|
|
1014
|
+
return { success: false, message: 'Shop is not open' };
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const sellItem = this.helpers.resolveShopItem(target, shop.playerItems);
|
|
1018
|
+
if (!sellItem) {
|
|
1019
|
+
return { success: false, message: `Item not found to sell: ${target}` };
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const startTick = this.sdk.getState()?.tick || 0;
|
|
1023
|
+
|
|
1024
|
+
if (amount === 'all') {
|
|
1025
|
+
return this.sellAllToShop(sellItem, startTick);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const getTotalCount = (playerItems: typeof shop.playerItems) =>
|
|
1029
|
+
playerItems.filter(i => i.id === sellItem.id).reduce((sum, i) => sum + i.count, 0);
|
|
1030
|
+
|
|
1031
|
+
// Decompose amount into valid sell commands (10, 5, 1)
|
|
1032
|
+
let remaining = Math.max(1, Math.floor(amount));
|
|
1033
|
+
const sellSteps: number[] = [];
|
|
1034
|
+
while (remaining > 0) {
|
|
1035
|
+
if (remaining >= 10) { sellSteps.push(10); remaining -= 10; }
|
|
1036
|
+
else if (remaining >= 5) { sellSteps.push(5); remaining -= 5; }
|
|
1037
|
+
else { sellSteps.push(1); remaining -= 1; }
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const totalCountBefore = getTotalCount(shop.playerItems);
|
|
1041
|
+
|
|
1042
|
+
for (const stepAmount of sellSteps) {
|
|
1043
|
+
const countBefore = getTotalCount(this.sdk.getState()?.shop.playerItems ?? []);
|
|
1044
|
+
|
|
1045
|
+
const result = await this.sdk.sendShopSell(sellItem.slot, stepAmount);
|
|
1046
|
+
if (!result.success) {
|
|
1047
|
+
const totalSold = totalCountBefore - getTotalCount(this.sdk.getState()?.shop.playerItems ?? []);
|
|
1048
|
+
if (totalSold > 0) {
|
|
1049
|
+
return { success: true, message: `Sold ${sellItem.name} x${totalSold} (wanted ${amount})`, amountSold: totalSold };
|
|
1050
|
+
}
|
|
1051
|
+
return { success: false, message: result.message };
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
try {
|
|
1055
|
+
const finalState = await this.sdk.waitForCondition(state => {
|
|
1056
|
+
for (const msg of state.gameMessages) {
|
|
1057
|
+
if (msg.tick > startTick) {
|
|
1058
|
+
const text = msg.text.toLowerCase();
|
|
1059
|
+
if (text.includes("can't sell this item")) {
|
|
1060
|
+
return true;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const totalCountNow = getTotalCount(state.shop.playerItems);
|
|
1066
|
+
return totalCountNow < countBefore;
|
|
1067
|
+
}, 5000);
|
|
1068
|
+
|
|
1069
|
+
for (const msg of finalState.gameMessages) {
|
|
1070
|
+
if (msg.tick > startTick) {
|
|
1071
|
+
const text = msg.text.toLowerCase();
|
|
1072
|
+
if (text.includes("can't sell this item to this shop")) {
|
|
1073
|
+
return { success: false, message: `Shop doesn't buy ${sellItem.name}`, rejected: true };
|
|
1074
|
+
}
|
|
1075
|
+
if (text.includes("can't sell this item to a shop")) {
|
|
1076
|
+
return { success: false, message: `Cannot sell ${sellItem.name} to any shop`, rejected: true };
|
|
1077
|
+
}
|
|
1078
|
+
if (text.includes("can't sell this item")) {
|
|
1079
|
+
return { success: false, message: `${sellItem.name} is not tradeable`, rejected: true };
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
} catch {
|
|
1084
|
+
const totalSold = totalCountBefore - getTotalCount(this.sdk.getState()?.shop.playerItems ?? []);
|
|
1085
|
+
if (totalSold > 0) {
|
|
1086
|
+
return { success: true, message: `Sold ${sellItem.name} x${totalSold} (wanted ${amount})`, amountSold: totalSold };
|
|
1087
|
+
}
|
|
1088
|
+
return { success: false, message: `Failed to sell ${sellItem.name} (timeout)` };
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
const totalCountAfter = getTotalCount(this.sdk.getState()?.shop.playerItems ?? []);
|
|
1093
|
+
const totalSold = totalCountBefore - totalCountAfter;
|
|
1094
|
+
|
|
1095
|
+
return { success: true, message: `Sold ${sellItem.name} x${totalSold}`, amountSold: totalSold };
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
private async sellAllToShop(sellItem: ShopItem, startTick: number): Promise<ShopSellResult> {
|
|
1099
|
+
let totalSold = 0;
|
|
1100
|
+
|
|
1101
|
+
const getTotalCount = (playerItems: ShopItem[]) => {
|
|
1102
|
+
return playerItems.filter(i => i.id === sellItem.id).reduce((sum, i) => sum + i.count, 0);
|
|
1103
|
+
};
|
|
1104
|
+
|
|
1105
|
+
while (true) {
|
|
1106
|
+
const state = this.sdk.getState();
|
|
1107
|
+
if (!state?.shop.isOpen) {
|
|
1108
|
+
break;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const currentItem = state.shop.playerItems.find(i => i.id === sellItem.id);
|
|
1112
|
+
if (!currentItem || currentItem.count === 0) {
|
|
1113
|
+
break;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
const totalCountBefore = getTotalCount(state.shop.playerItems);
|
|
1117
|
+
const sellAmount = Math.min(10, currentItem.count);
|
|
1118
|
+
const currentSlot = currentItem.slot;
|
|
1119
|
+
|
|
1120
|
+
const result = await this.sdk.sendShopSell(currentSlot, sellAmount);
|
|
1121
|
+
if (!result.success) {
|
|
1122
|
+
break;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
try {
|
|
1126
|
+
const finalState = await this.sdk.waitForCondition(s => {
|
|
1127
|
+
for (const msg of s.gameMessages) {
|
|
1128
|
+
if (msg.tick > startTick) {
|
|
1129
|
+
if (msg.text.toLowerCase().includes("can't sell this item")) {
|
|
1130
|
+
return true;
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const totalCountNow = getTotalCount(s.shop.playerItems);
|
|
1136
|
+
return totalCountNow < totalCountBefore;
|
|
1137
|
+
}, 3000);
|
|
1138
|
+
|
|
1139
|
+
for (const msg of finalState.gameMessages) {
|
|
1140
|
+
if (msg.tick > startTick) {
|
|
1141
|
+
const text = msg.text.toLowerCase();
|
|
1142
|
+
if (text.includes("can't sell this item to this shop")) {
|
|
1143
|
+
return {
|
|
1144
|
+
success: totalSold > 0,
|
|
1145
|
+
message: totalSold > 0
|
|
1146
|
+
? `Sold ${sellItem.name} x${totalSold}, then shop stopped buying`
|
|
1147
|
+
: `Shop doesn't buy ${sellItem.name}`,
|
|
1148
|
+
amountSold: totalSold,
|
|
1149
|
+
rejected: true
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
if (text.includes("can't sell this item")) {
|
|
1153
|
+
return {
|
|
1154
|
+
success: false,
|
|
1155
|
+
message: `${sellItem.name} cannot be sold`,
|
|
1156
|
+
amountSold: totalSold,
|
|
1157
|
+
rejected: true
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const totalCountAfter = getTotalCount(finalState.shop.playerItems);
|
|
1164
|
+
const soldThisRound = totalCountBefore - totalCountAfter;
|
|
1165
|
+
totalSold += soldThisRound;
|
|
1166
|
+
|
|
1167
|
+
if (soldThisRound === 0) {
|
|
1168
|
+
break;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
} catch {
|
|
1172
|
+
break;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if (totalSold === 0) {
|
|
1177
|
+
return { success: false, message: `Failed to sell any ${sellItem.name}` };
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
return { success: true, message: `Sold ${sellItem.name} x${totalSold}`, amountSold: totalSold };
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// ============ Porcelain: Bank Actions ============
|
|
1184
|
+
|
|
1185
|
+
/** Open a bank booth or talk to a banker. */
|
|
1186
|
+
async openBank(timeout: number = 10000): Promise<OpenBankResult> {
|
|
1187
|
+
const state = this.sdk.getState();
|
|
1188
|
+
if (state?.interface?.isOpen) {
|
|
1189
|
+
return { success: true, message: 'Bank already open' };
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
await this.dismissBlockingUI();
|
|
1193
|
+
|
|
1194
|
+
const banker = this.sdk.findNearbyNpc(/banker/i);
|
|
1195
|
+
const bankBooth = this.sdk.findNearbyLoc(/bank booth|bank chest/i);
|
|
1196
|
+
|
|
1197
|
+
if (!banker && !bankBooth) {
|
|
1198
|
+
return { success: false, message: 'No banker NPC or bank booth found nearby', reason: 'no_bank_found' };
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// Walk near the bank target first - this handles doors
|
|
1202
|
+
const target = banker || bankBooth!;
|
|
1203
|
+
if (target.distance > 2) {
|
|
1204
|
+
const walkResult = await this.walkTo(target.x, target.z, 2);
|
|
1205
|
+
if (!walkResult.success) {
|
|
1206
|
+
return { success: false, message: `Cannot reach bank: ${walkResult.message}`, reason: 'cant_reach' };
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Re-find targets after walking (they may have changed)
|
|
1211
|
+
const bankerNow = this.sdk.findNearbyNpc(/banker/i);
|
|
1212
|
+
const bankBoothNow = this.sdk.findNearbyLoc(/bank booth|bank chest/i);
|
|
1213
|
+
|
|
1214
|
+
let interactSuccess = false;
|
|
1215
|
+
|
|
1216
|
+
if (bankerNow) {
|
|
1217
|
+
const bankOpt = bankerNow.optionsWithIndex.find(o => /^bank$/i.test(o.text));
|
|
1218
|
+
if (bankOpt) {
|
|
1219
|
+
await this.sdk.sendInteractNpc(bankerNow.index, bankOpt.opIndex);
|
|
1220
|
+
interactSuccess = true;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
if (!interactSuccess && bankBoothNow) {
|
|
1225
|
+
const bankOpt = bankBoothNow.optionsWithIndex.find(o => /^bank$/i.test(o.text)) ||
|
|
1226
|
+
bankBoothNow.optionsWithIndex.find(o => /use/i.test(o.text));
|
|
1227
|
+
if (bankOpt) {
|
|
1228
|
+
await this.sdk.sendInteractLoc(bankBoothNow.x, bankBoothNow.z, bankBoothNow.id, bankOpt.opIndex);
|
|
1229
|
+
interactSuccess = true;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
if (!interactSuccess) {
|
|
1234
|
+
return { success: false, message: 'No banker NPC or bank booth found nearby', reason: 'no_bank_found' };
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
const startTime = Date.now();
|
|
1238
|
+
|
|
1239
|
+
while (Date.now() - startTime < timeout) {
|
|
1240
|
+
try {
|
|
1241
|
+
const finalState = await this.sdk.waitForCondition(s => {
|
|
1242
|
+
if (s.interface?.isOpen === true || s.dialog?.isOpen === true) return true;
|
|
1243
|
+
return false;
|
|
1244
|
+
}, Math.min(2000, timeout - (Date.now() - startTime)));
|
|
1245
|
+
|
|
1246
|
+
const currentState = this.sdk.getState();
|
|
1247
|
+
|
|
1248
|
+
if (currentState?.interface?.isOpen) {
|
|
1249
|
+
return { success: true, message: `Bank opened (interfaceId: ${currentState.interface.interfaceId})` };
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
if (currentState?.dialog?.isOpen) {
|
|
1253
|
+
const opt = currentState.dialog.options?.[0];
|
|
1254
|
+
await this.sdk.sendClickDialog(opt?.index ?? 0);
|
|
1255
|
+
await this.sdk.waitForTicks(1);
|
|
1256
|
+
continue;
|
|
1257
|
+
}
|
|
1258
|
+
} catch {
|
|
1259
|
+
// Timeout on waitForCondition, loop will continue or exit
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const finalState = this.sdk.getState();
|
|
1264
|
+
if (finalState?.interface?.isOpen) {
|
|
1265
|
+
return { success: true, message: `Bank opened (interfaceId: ${finalState.interface.interfaceId})` };
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
return { success: false, message: 'Timeout waiting for bank interface to open', reason: 'timeout' };
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/** Close the bank interface. */
|
|
1272
|
+
async closeBank(timeout: number = 5000): Promise<ActionResult> {
|
|
1273
|
+
const state = this.sdk.getState();
|
|
1274
|
+
if (!state?.interface?.isOpen) {
|
|
1275
|
+
return { success: true, message: 'Bank already closed' };
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
await this.sdk.sendCloseModal();
|
|
1279
|
+
|
|
1280
|
+
try {
|
|
1281
|
+
await this.sdk.waitForCondition(s => !s.interface?.isOpen, timeout);
|
|
1282
|
+
return { success: true, message: 'Bank closed' };
|
|
1283
|
+
} catch {
|
|
1284
|
+
await this.sdk.sendCloseModal();
|
|
1285
|
+
await this.sdk.waitForTicks(1);
|
|
1286
|
+
|
|
1287
|
+
const finalState = this.sdk.getState();
|
|
1288
|
+
if (!finalState?.interface?.isOpen) {
|
|
1289
|
+
return { success: true, message: 'Bank closed (second attempt)' };
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
return { success: false, message: `Bank close timeout - interface.isOpen=${finalState?.interface?.isOpen}` };
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/** Deposit an item into the bank. Use -1 for all. */
|
|
1297
|
+
async depositItem(target: InventoryItem | string | RegExp, amount: number = -1): Promise<BankDepositResult> {
|
|
1298
|
+
const state = this.sdk.getState();
|
|
1299
|
+
if (!state?.interface?.isOpen) {
|
|
1300
|
+
return { success: false, message: 'Bank is not open', reason: 'bank_not_open' };
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
const item = this.helpers.resolveInventoryItem(target, /./);
|
|
1304
|
+
if (!item) {
|
|
1305
|
+
return { success: false, message: `Item not found in inventory: ${target}`, reason: 'item_not_found' };
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
const countBefore = state.inventory.filter(i => i.id === item.id).reduce((sum, i) => sum + i.count, 0);
|
|
1309
|
+
|
|
1310
|
+
await this.sdk.sendBankDeposit(item.slot, amount);
|
|
1311
|
+
|
|
1312
|
+
try {
|
|
1313
|
+
await this.sdk.waitForCondition(s => {
|
|
1314
|
+
const countNow = s.inventory.filter(i => i.id === item.id).reduce((sum, i) => sum + i.count, 0);
|
|
1315
|
+
return countNow < countBefore;
|
|
1316
|
+
}, 5000);
|
|
1317
|
+
|
|
1318
|
+
const finalState = this.sdk.getState();
|
|
1319
|
+
const countAfter = finalState?.inventory.filter(i => i.id === item.id).reduce((sum, i) => sum + i.count, 0) ?? 0;
|
|
1320
|
+
const amountDeposited = countBefore - countAfter;
|
|
1321
|
+
|
|
1322
|
+
return { success: true, message: `Deposited ${item.name} x${amountDeposited}`, amountDeposited };
|
|
1323
|
+
} catch {
|
|
1324
|
+
return { success: false, message: `Timeout waiting for ${item.name} to be deposited`, reason: 'timeout' };
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/** Withdraw an item from the bank by slot number. */
|
|
1329
|
+
async withdrawItem(target: BankItem | string | RegExp | number, amount: number = 1): Promise<BankWithdrawResult> {
|
|
1330
|
+
const state = this.sdk.getState();
|
|
1331
|
+
if (!state?.interface?.isOpen) {
|
|
1332
|
+
return { success: false, message: 'Bank is not open', reason: 'bank_not_open' };
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
let bankSlot: number;
|
|
1336
|
+
if (typeof target === 'number') {
|
|
1337
|
+
bankSlot = target;
|
|
1338
|
+
} else if (typeof target === 'object' && 'slot' in target) {
|
|
1339
|
+
bankSlot = target.slot;
|
|
1340
|
+
} else {
|
|
1341
|
+
const found = this.sdk.findBankItem(target);
|
|
1342
|
+
if (!found) {
|
|
1343
|
+
return { success: false, message: `Bank item not found: ${target}`, reason: 'item_not_found' };
|
|
1344
|
+
}
|
|
1345
|
+
bankSlot = found.slot;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
const invCountBefore = state.inventory.length;
|
|
1349
|
+
|
|
1350
|
+
await this.sdk.sendBankWithdraw(bankSlot, amount);
|
|
1351
|
+
|
|
1352
|
+
try {
|
|
1353
|
+
await this.sdk.waitForCondition(s => {
|
|
1354
|
+
return s.inventory.length > invCountBefore ||
|
|
1355
|
+
s.inventory.some(i => {
|
|
1356
|
+
const before = state.inventory.find(bi => bi.slot === i.slot);
|
|
1357
|
+
return before && i.count > before.count;
|
|
1358
|
+
});
|
|
1359
|
+
}, 5000);
|
|
1360
|
+
|
|
1361
|
+
const finalInv = this.sdk.getInventory();
|
|
1362
|
+
const newItem = finalInv.find(i => {
|
|
1363
|
+
const before = state.inventory.find(bi => bi.slot === i.slot);
|
|
1364
|
+
return !before || i.count > before.count;
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
return { success: true, message: `Withdrew item from bank slot ${bankSlot}`, item: newItem };
|
|
1368
|
+
} catch {
|
|
1369
|
+
return { success: false, message: `Timeout waiting for item to be withdrawn`, reason: 'timeout' };
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// ============ Porcelain: Equipment & Combat ============
|
|
1374
|
+
|
|
1375
|
+
/** Equip an item from inventory. */
|
|
1376
|
+
async equipItem(target: InventoryItem | string | RegExp): Promise<EquipResult> {
|
|
1377
|
+
await this.dismissBlockingUI();
|
|
1378
|
+
|
|
1379
|
+
const item = this.helpers.resolveInventoryItem(target, /./);
|
|
1380
|
+
if (!item) {
|
|
1381
|
+
return { success: false, message: `Item not found: ${target}` };
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
const equipOpt = item.optionsWithIndex.find(o => /wield|wear|equip/i.test(o.text));
|
|
1385
|
+
if (!equipOpt) {
|
|
1386
|
+
return { success: false, message: `No equip option on ${item.name}` };
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
const result = await this.sdk.sendUseItem(item.slot, equipOpt.opIndex);
|
|
1390
|
+
if (!result.success) {
|
|
1391
|
+
return { success: false, message: result.message };
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
try {
|
|
1395
|
+
await this.sdk.waitForCondition(state =>
|
|
1396
|
+
!state.inventory.find(i => i.slot === item.slot && i.id === item.id),
|
|
1397
|
+
5000
|
|
1398
|
+
);
|
|
1399
|
+
return { success: true, message: `Equipped ${item.name}` };
|
|
1400
|
+
} catch {
|
|
1401
|
+
return { success: false, message: `Failed to equip ${item.name}` };
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/** Unequip an item to inventory. */
|
|
1406
|
+
async unequipItem(target: InventoryItem | string | RegExp): Promise<UnequipResult> {
|
|
1407
|
+
await this.dismissBlockingUI();
|
|
1408
|
+
|
|
1409
|
+
let item: InventoryItem | null = null;
|
|
1410
|
+
if (typeof target === 'object' && 'slot' in target) {
|
|
1411
|
+
item = target;
|
|
1412
|
+
} else {
|
|
1413
|
+
item = this.sdk.findEquipmentItem(target);
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
if (!item) {
|
|
1417
|
+
return { success: false, message: `Item not found in equipment: ${target}` };
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
const invCountBefore = this.sdk.getInventory().length;
|
|
1421
|
+
const result = await this.sdk.sendUseEquipmentItem(item.slot, 1);
|
|
1422
|
+
if (!result.success) {
|
|
1423
|
+
return { success: false, message: result.message };
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
try {
|
|
1427
|
+
await this.sdk.waitForCondition(state =>
|
|
1428
|
+
state.inventory.length > invCountBefore ||
|
|
1429
|
+
state.inventory.some(i => i.id === item!.id),
|
|
1430
|
+
5000
|
|
1431
|
+
);
|
|
1432
|
+
|
|
1433
|
+
const unequippedItem = this.sdk.findInventoryItem(new RegExp(item.name, 'i'));
|
|
1434
|
+
return { success: true, message: `Unequipped ${item.name}`, item: unequippedItem || undefined };
|
|
1435
|
+
} catch {
|
|
1436
|
+
return { success: false, message: `Failed to unequip ${item.name}` };
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/** Get all currently equipped items. */
|
|
1441
|
+
getEquipment(): InventoryItem[] {
|
|
1442
|
+
return this.sdk.getEquipment();
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
/** Find an equipped item by name pattern. */
|
|
1446
|
+
findEquippedItem(pattern: string | RegExp): InventoryItem | null {
|
|
1447
|
+
return this.sdk.findEquipmentItem(pattern);
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/** Eat food to restore hitpoints. */
|
|
1451
|
+
async eatFood(target: InventoryItem | string | RegExp): Promise<EatResult> {
|
|
1452
|
+
await this.dismissBlockingUI();
|
|
1453
|
+
|
|
1454
|
+
const food = this.helpers.resolveInventoryItem(target, /./);
|
|
1455
|
+
if (!food) {
|
|
1456
|
+
return { success: false, hpGained: 0, message: `Food not found: ${target}` };
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
const eatOpt = food.optionsWithIndex.find(o => /eat/i.test(o.text));
|
|
1460
|
+
if (!eatOpt) {
|
|
1461
|
+
return { success: false, hpGained: 0, message: `No eat option on ${food.name}` };
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
const hpBefore = this.sdk.getSkill('Hitpoints')?.level ?? 10;
|
|
1465
|
+
const foodCountBefore = this.sdk.getInventory().filter(i => i.id === food.id).length;
|
|
1466
|
+
|
|
1467
|
+
const result = await this.sdk.sendUseItem(food.slot, eatOpt.opIndex);
|
|
1468
|
+
if (!result.success) {
|
|
1469
|
+
return { success: false, hpGained: 0, message: result.message };
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
try {
|
|
1473
|
+
await this.sdk.waitForCondition(state => {
|
|
1474
|
+
const hp = state.skills.find(s => s.name === 'Hitpoints')?.level ?? 10;
|
|
1475
|
+
const foodCount = state.inventory.filter(i => i.id === food.id).length;
|
|
1476
|
+
return hp > hpBefore || foodCount < foodCountBefore;
|
|
1477
|
+
}, 5000);
|
|
1478
|
+
|
|
1479
|
+
const hpAfter = this.sdk.getSkill('Hitpoints')?.level ?? 10;
|
|
1480
|
+
return { success: true, hpGained: hpAfter - hpBefore, message: `Ate ${food.name}` };
|
|
1481
|
+
} catch {
|
|
1482
|
+
return { success: false, hpGained: 0, message: `Failed to eat ${food.name}` };
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/** Attack an NPC, walking to it if needed. */
|
|
1487
|
+
async attackNpc(target: NearbyNpc | string | RegExp, timeout: number = 5000): Promise<AttackResult> {
|
|
1488
|
+
await this.dismissBlockingUI();
|
|
1489
|
+
|
|
1490
|
+
const npc = this.helpers.resolveNpc(target);
|
|
1491
|
+
if (!npc) {
|
|
1492
|
+
return { success: false, message: `NPC not found: ${target}`, reason: 'npc_not_found' };
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// Sanity check: NPC coordinates should be within reasonable distance of player
|
|
1496
|
+
// If coords are wildly off, the NPC data is corrupted
|
|
1497
|
+
const state = this.sdk.getState();
|
|
1498
|
+
if (state?.player) {
|
|
1499
|
+
const coordDist = Math.sqrt(
|
|
1500
|
+
Math.pow(npc.x - state.player.worldX, 2) + Math.pow(npc.z - state.player.worldZ, 2)
|
|
1501
|
+
);
|
|
1502
|
+
// If calculated coord distance is way more than reported distance, coords are bad
|
|
1503
|
+
// Allow tolerance of 5 tiles for small distances (handles distance=0 edge case)
|
|
1504
|
+
if (coordDist > 200 || (npc.distance > 0 && npc.distance < 50 && coordDist > Math.max(5, npc.distance * 3))) {
|
|
1505
|
+
return { success: false, message: `NPC "${npc.name}" has invalid coordinates`, reason: 'npc_not_found' };
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
const attackOpt = npc.optionsWithIndex.find(o => /attack/i.test(o.text));
|
|
1510
|
+
if (!attackOpt) {
|
|
1511
|
+
return { success: false, message: `No attack option on ${npc.name}`, reason: 'no_attack_option' };
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// Walk near NPC first - this handles doors
|
|
1515
|
+
if (npc.distance > 2) {
|
|
1516
|
+
const walkResult = await this.walkTo(npc.x, npc.z, 2);
|
|
1517
|
+
if (!walkResult.success) {
|
|
1518
|
+
return { success: false, message: `Cannot reach ${npc.name}: ${walkResult.message}`, reason: 'out_of_reach' };
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
const startTick = this.sdk.getState()?.tick || 0;
|
|
1523
|
+
const result = await this.sdk.sendInteractNpc(npc.index, attackOpt.opIndex);
|
|
1524
|
+
if (!result.success) {
|
|
1525
|
+
return { success: false, message: result.message };
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
try {
|
|
1529
|
+
const finalState = await this.sdk.waitForCondition(state => {
|
|
1530
|
+
for (const msg of state.gameMessages) {
|
|
1531
|
+
if (msg.tick > startTick) {
|
|
1532
|
+
const text = msg.text.toLowerCase();
|
|
1533
|
+
if (text.includes("someone else is fighting") || text.includes("already under attack")) {
|
|
1534
|
+
return true;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
const targetNpc = state.nearbyNpcs.find(n => n.index === npc.index);
|
|
1540
|
+
if (!targetNpc) {
|
|
1541
|
+
return true;
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
if (targetNpc.distance <= 2) {
|
|
1545
|
+
return true;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
return false;
|
|
1549
|
+
}, timeout);
|
|
1550
|
+
|
|
1551
|
+
// Check for "already in combat"
|
|
1552
|
+
for (const msg of finalState.gameMessages) {
|
|
1553
|
+
if (msg.tick > startTick) {
|
|
1554
|
+
const text = msg.text.toLowerCase();
|
|
1555
|
+
if (text.includes("someone else is fighting") || text.includes("already under attack")) {
|
|
1556
|
+
return { success: false, message: `${npc.name} is already in combat`, reason: 'already_in_combat' };
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
return { success: true, message: `Attacking ${npc.name}` };
|
|
1562
|
+
} catch {
|
|
1563
|
+
return { success: false, message: `Timeout waiting to attack ${npc.name}`, reason: 'timeout' };
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
/**
|
|
1568
|
+
* Attack another player. Walks closer if far away, then sends the attack interaction.
|
|
1569
|
+
* Returns once the game acknowledges the attack — does NOT wait for melee range,
|
|
1570
|
+
* since ranged/mage attacks initiate from distance. Strategy (when to attack, who
|
|
1571
|
+
* to target, eating during combat) belongs in the calling code, not here.
|
|
1572
|
+
*
|
|
1573
|
+
* @param target - Player object, exact name string, or regex to match
|
|
1574
|
+
* @param timeout - How long to wait for acknowledgment (default 5000ms)
|
|
1575
|
+
*/
|
|
1576
|
+
async attackPlayer(target: NearbyPlayer | string | RegExp, timeout: number = 5000): Promise<AttackResult> {
|
|
1577
|
+
await this.dismissBlockingUI();
|
|
1578
|
+
|
|
1579
|
+
const player = this.helpers.resolvePlayer(target);
|
|
1580
|
+
if (!player) {
|
|
1581
|
+
return { success: false, message: `Player not found: ${target}`, reason: 'player_not_found' };
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// Sanity check: player coordinates should be reasonable
|
|
1585
|
+
const state = this.sdk.getState();
|
|
1586
|
+
if (state?.player) {
|
|
1587
|
+
const coordDist = Math.sqrt(
|
|
1588
|
+
Math.pow(player.x - state.player.worldX, 2) + Math.pow(player.z - state.player.worldZ, 2)
|
|
1589
|
+
);
|
|
1590
|
+
if (coordDist > 200 || (player.distance > 0 && player.distance < 50 && coordDist > Math.max(5, player.distance * 3))) {
|
|
1591
|
+
return { success: false, message: `Player "${player.name}" has invalid coordinates`, reason: 'player_not_found' };
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// Walk closer if far away — use loose tolerance since the server
|
|
1596
|
+
// handles exact attack distance based on weapon type (melee/ranged/mage)
|
|
1597
|
+
if (player.distance > 15) {
|
|
1598
|
+
const walkResult = await this.walkTo(player.x, player.z, 10);
|
|
1599
|
+
if (!walkResult.success) {
|
|
1600
|
+
return { success: false, message: `Cannot reach ${player.name}: ${walkResult.message}`, reason: 'out_of_reach' };
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
const startTick = this.sdk.getState()?.tick || 0;
|
|
1605
|
+
|
|
1606
|
+
// Option 2 is "Attack" — PvP combat scripts use opplayer2/applayer2
|
|
1607
|
+
const result = await this.sdk.sendInteractPlayer(player.index, 2);
|
|
1608
|
+
if (!result.success) {
|
|
1609
|
+
return { success: false, message: result.message };
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
try {
|
|
1613
|
+
await this.sdk.waitForCondition(state => {
|
|
1614
|
+
// Check for error messages
|
|
1615
|
+
for (const msg of state.gameMessages) {
|
|
1616
|
+
if (msg.tick > startTick) {
|
|
1617
|
+
const text = msg.text.toLowerCase();
|
|
1618
|
+
if (text.includes("already under attack") || text.includes("someone else is fighting")) {
|
|
1619
|
+
return true;
|
|
1620
|
+
}
|
|
1621
|
+
if (text.includes("can't reach") || text.includes("cannot reach")) {
|
|
1622
|
+
return true;
|
|
1623
|
+
}
|
|
1624
|
+
if (text.includes("can't attack") || text.includes("cannot attack")) {
|
|
1625
|
+
return true;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// Check if we see combat events (we're hitting or being hit)
|
|
1631
|
+
if (state.combatEvents && state.combatEvents.length > 0) {
|
|
1632
|
+
const recentCombat = state.combatEvents.some(e => e.tick > startTick);
|
|
1633
|
+
if (recentCombat) return true;
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// Check if target player is now close (we walked to them)
|
|
1637
|
+
const targetNow = state.nearbyPlayers.find(p => p.index === player.index);
|
|
1638
|
+
if (targetNow && targetNow.distance <= 2) {
|
|
1639
|
+
return true;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// Target disappeared (logged out, died, etc)
|
|
1643
|
+
if (!targetNow) {
|
|
1644
|
+
return true;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
return false;
|
|
1648
|
+
}, timeout);
|
|
1649
|
+
|
|
1650
|
+
// Check what happened
|
|
1651
|
+
for (const msg of this.sdk.getState()?.gameMessages ?? []) {
|
|
1652
|
+
if (msg.tick > startTick) {
|
|
1653
|
+
const text = msg.text.toLowerCase();
|
|
1654
|
+
if (text.includes("already under attack") || text.includes("someone else is fighting")) {
|
|
1655
|
+
return { success: false, message: `${player.name} is already in combat`, reason: 'already_in_combat' };
|
|
1656
|
+
}
|
|
1657
|
+
if (text.includes("can't reach") || text.includes("cannot reach")) {
|
|
1658
|
+
return { success: false, message: `Can't reach ${player.name}`, reason: 'out_of_reach' };
|
|
1659
|
+
}
|
|
1660
|
+
if (text.includes("can't attack") || text.includes("cannot attack")) {
|
|
1661
|
+
return { success: false, message: `Can't attack ${player.name}`, reason: 'out_of_reach' };
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// Check if target vanished
|
|
1667
|
+
const targetAfter = this.sdk.getState()?.nearbyPlayers.find(p => p.index === player.index);
|
|
1668
|
+
if (!targetAfter) {
|
|
1669
|
+
return { success: false, message: `${player.name} is no longer nearby`, reason: 'player_not_found' };
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
return { success: true, message: `Attacking ${player.name}` };
|
|
1673
|
+
} catch {
|
|
1674
|
+
return { success: false, message: `Timeout waiting to attack ${player.name}`, reason: 'timeout' };
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
/** Cast a combat spell on an NPC. */
|
|
1679
|
+
async castSpellOnNpc(target: NearbyNpc | string | RegExp, spellComponent: number, timeout: number = 3000): Promise<CastSpellResult> {
|
|
1680
|
+
await this.dismissBlockingUI();
|
|
1681
|
+
|
|
1682
|
+
const npc = this.helpers.resolveNpc(target);
|
|
1683
|
+
if (!npc) {
|
|
1684
|
+
return { success: false, message: `NPC not found: ${target}`, reason: 'npc_not_found' };
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
const startState = this.sdk.getState();
|
|
1688
|
+
if (!startState) {
|
|
1689
|
+
return { success: false, message: 'No game state available' };
|
|
1690
|
+
}
|
|
1691
|
+
const startTick = startState.tick;
|
|
1692
|
+
const startMagicXp = startState.skills.find(s => s.name === 'Magic')?.experience ?? 0;
|
|
1693
|
+
|
|
1694
|
+
const result = await this.sdk.sendSpellOnNpc(npc.index, spellComponent);
|
|
1695
|
+
if (!result.success) {
|
|
1696
|
+
return { success: false, message: result.message };
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
try {
|
|
1700
|
+
const finalState = await this.sdk.waitForCondition(state => {
|
|
1701
|
+
for (const msg of state.gameMessages) {
|
|
1702
|
+
if (msg.tick > startTick) {
|
|
1703
|
+
const text = msg.text.toLowerCase();
|
|
1704
|
+
if (text.includes("can't reach") || text.includes("cannot reach")) {
|
|
1705
|
+
return true;
|
|
1706
|
+
}
|
|
1707
|
+
if (text.includes("do not have enough") || text.includes("don't have enough")) {
|
|
1708
|
+
return true;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
const currentMagicXp = state.skills.find(s => s.name === 'Magic')?.experience ?? 0;
|
|
1714
|
+
if (currentMagicXp > startMagicXp) {
|
|
1715
|
+
return true;
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
return false;
|
|
1719
|
+
}, timeout);
|
|
1720
|
+
|
|
1721
|
+
// Check for "not enough runes" first
|
|
1722
|
+
for (const msg of finalState.gameMessages) {
|
|
1723
|
+
if (msg.tick > startTick) {
|
|
1724
|
+
const text = msg.text.toLowerCase();
|
|
1725
|
+
if (text.includes("do not have enough") || text.includes("don't have enough")) {
|
|
1726
|
+
return { success: false, message: `Not enough runes to cast spell`, reason: 'no_runes' };
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
|
|
1731
|
+
if (this.helpers.checkCantReachMessage(startTick)) {
|
|
1732
|
+
return { success: false, message: `Cannot reach ${npc.name} - obstacle in the way`, reason: 'out_of_reach' };
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
const finalMagicXp = finalState.skills.find(s => s.name === 'Magic')?.experience ?? 0;
|
|
1736
|
+
const xpGained = finalMagicXp - startMagicXp;
|
|
1737
|
+
if (xpGained > 0) {
|
|
1738
|
+
return { success: true, message: `Hit ${npc.name} for ${xpGained} Magic XP`, hit: true, xpGained };
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
return { success: true, message: `Splashed on ${npc.name}`, hit: false, xpGained: 0 };
|
|
1742
|
+
} catch {
|
|
1743
|
+
return { success: true, message: `Splashed on ${npc.name} (timeout)`, hit: false, xpGained: 0 };
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
// ============ Porcelain: Condition Helpers ============
|
|
1748
|
+
|
|
1749
|
+
/** Wait until a skill reaches a target level. */
|
|
1750
|
+
async waitForSkillLevel(skillName: string, targetLevel: number, timeout: number = 60000): Promise<SkillState> {
|
|
1751
|
+
const state = await this.sdk.waitForCondition(s => {
|
|
1752
|
+
const skill = s.skills.find(sk => sk.name.toLowerCase() === skillName.toLowerCase());
|
|
1753
|
+
return skill !== undefined && skill.baseLevel >= targetLevel;
|
|
1754
|
+
}, timeout);
|
|
1755
|
+
|
|
1756
|
+
return state.skills.find(s => s.name.toLowerCase() === skillName.toLowerCase())!;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
/** Wait until an item appears in inventory. */
|
|
1760
|
+
async waitForInventoryItem(pattern: string | RegExp, timeout: number = 30000): Promise<InventoryItem> {
|
|
1761
|
+
const regex = typeof pattern === 'string' ? new RegExp(pattern, 'i') : pattern;
|
|
1762
|
+
|
|
1763
|
+
const state = await this.sdk.waitForCondition(s =>
|
|
1764
|
+
s.inventory.some(i => regex.test(i.name)),
|
|
1765
|
+
timeout
|
|
1766
|
+
);
|
|
1767
|
+
|
|
1768
|
+
return state.inventory.find(i => regex.test(i.name))!;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
/** Wait for dialog to close. */
|
|
1772
|
+
async waitForDialogClose(timeout: number = 30000): Promise<void> {
|
|
1773
|
+
await this.sdk.waitForCondition(s => !s.dialog.isOpen, timeout);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
/** Wait for player to stop moving. */
|
|
1777
|
+
async waitForIdle(timeout: number = 10000): Promise<void> {
|
|
1778
|
+
const initialState = this.sdk.getState();
|
|
1779
|
+
if (!initialState?.player) {
|
|
1780
|
+
throw new Error('No player state');
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
const initialX = initialState.player.x;
|
|
1784
|
+
const initialZ = initialState.player.z;
|
|
1785
|
+
|
|
1786
|
+
await this.sdk.waitForStateChange(timeout);
|
|
1787
|
+
|
|
1788
|
+
await this.sdk.waitForCondition(state => {
|
|
1789
|
+
if (!state.player) return false;
|
|
1790
|
+
return state.player.x === initialX && state.player.z === initialZ;
|
|
1791
|
+
}, timeout);
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// ============ Porcelain: Sequences ============
|
|
1795
|
+
|
|
1796
|
+
async navigateDialog(choices: (number | string | RegExp)[]): Promise<void> {
|
|
1797
|
+
for (const choice of choices) {
|
|
1798
|
+
const dialog = this.sdk.getDialog();
|
|
1799
|
+
let optionIndex: number;
|
|
1800
|
+
|
|
1801
|
+
if (typeof choice === 'number') {
|
|
1802
|
+
optionIndex = choice;
|
|
1803
|
+
} else {
|
|
1804
|
+
const regex = typeof choice === 'string' ? new RegExp(choice, 'i') : choice;
|
|
1805
|
+
const match = dialog?.options.find(o => regex.test(o.text));
|
|
1806
|
+
optionIndex = match?.index ?? 0;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
await this.sdk.sendClickDialog(optionIndex);
|
|
1810
|
+
await this.sdk.waitForTicks(1);
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
// ============ Crafting & Fletching ============
|
|
1815
|
+
|
|
1816
|
+
/** Fletch logs into bows or arrow shafts using a knife. */
|
|
1817
|
+
async fletchLogs(product?: string): Promise<FletchResult> {
|
|
1818
|
+
await this.dismissBlockingUI();
|
|
1819
|
+
|
|
1820
|
+
const knife = this.sdk.findInventoryItem(/knife/i);
|
|
1821
|
+
if (!knife) {
|
|
1822
|
+
return { success: false, message: 'No knife in inventory' };
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
const logs = this.sdk.findInventoryItem(/logs/i);
|
|
1826
|
+
if (!logs) {
|
|
1827
|
+
return { success: false, message: 'No logs in inventory' };
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
// Check if we're using oak or higher-tier logs (affects button order)
|
|
1831
|
+
const isOakOrHigherLogs = /oak|willow|maple|yew|magic/i.test(logs.name);
|
|
1832
|
+
|
|
1833
|
+
const fletchingBefore = this.sdk.getSkill('Fletching')?.experience || 0;
|
|
1834
|
+
const startTick = this.sdk.getState()?.tick || 0;
|
|
1835
|
+
|
|
1836
|
+
// Use knife on logs to open fletching dialog
|
|
1837
|
+
const result = await this.sdk.sendUseItemOnItem(knife.slot, logs.slot);
|
|
1838
|
+
if (!result.success) {
|
|
1839
|
+
return { success: false, message: result.message };
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// Wait for dialog/interface to open
|
|
1843
|
+
try {
|
|
1844
|
+
await this.sdk.waitForCondition(
|
|
1845
|
+
s => s.dialog.isOpen || s.interface?.isOpen,
|
|
1846
|
+
5000
|
|
1847
|
+
);
|
|
1848
|
+
} catch {
|
|
1849
|
+
return { success: false, message: 'Fletching dialog did not open' };
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
// Handle product selection and crafting
|
|
1853
|
+
const MAX_ATTEMPTS = 30;
|
|
1854
|
+
let buttonClicked = false;
|
|
1855
|
+
|
|
1856
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
1857
|
+
const state = this.sdk.getState();
|
|
1858
|
+
if (!state) {
|
|
1859
|
+
return { success: false, message: 'Lost game state' };
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// Check if XP was gained (success!)
|
|
1863
|
+
const currentXp = state.skills.find(s => s.name === 'Fletching')?.experience || 0;
|
|
1864
|
+
if (currentXp > fletchingBefore) {
|
|
1865
|
+
const craftedProduct = this.sdk.findInventoryItem(/shortbow|longbow|arrow shaft|stock/i);
|
|
1866
|
+
return {
|
|
1867
|
+
success: true,
|
|
1868
|
+
message: 'Fletched logs successfully',
|
|
1869
|
+
xpGained: currentXp - fletchingBefore,
|
|
1870
|
+
product: craftedProduct || undefined
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
// Handle interface (make-x style)
|
|
1875
|
+
if (state.interface?.isOpen) {
|
|
1876
|
+
// Try to find product by text in options
|
|
1877
|
+
let targetIndex = 1;
|
|
1878
|
+
if (product) {
|
|
1879
|
+
const productLower = product.toLowerCase();
|
|
1880
|
+
const matchingOption = state.interface.options.find(o =>
|
|
1881
|
+
o.text.toLowerCase().includes(productLower)
|
|
1882
|
+
);
|
|
1883
|
+
if (matchingOption) {
|
|
1884
|
+
targetIndex = matchingOption.index;
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
if (!buttonClicked) {
|
|
1889
|
+
await this.sdk.sendClickInterfaceOption(targetIndex);
|
|
1890
|
+
buttonClicked = true;
|
|
1891
|
+
} else if (state.interface.options.length > 0 && state.interface.options[0]) {
|
|
1892
|
+
await this.sdk.sendClickInterfaceOption(0);
|
|
1893
|
+
}
|
|
1894
|
+
await this.sdk.waitForTicks(1);
|
|
1895
|
+
continue;
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
// Handle dialog - use allComponents to find the right button
|
|
1899
|
+
if (state.dialog.isOpen) {
|
|
1900
|
+
if (!buttonClicked && product && state.dialog.allComponents) {
|
|
1901
|
+
// Find the button that matches our product by looking at allComponents text
|
|
1902
|
+
const productLower = product.toLowerCase();
|
|
1903
|
+
|
|
1904
|
+
// Build a mapping of product text to button index
|
|
1905
|
+
// allComponents contains both text labels and "Ok" buttons
|
|
1906
|
+
// We need to find which "Ok" button corresponds to our product
|
|
1907
|
+
|
|
1908
|
+
// Look for a component whose text matches the product
|
|
1909
|
+
const matchingComponents = state.dialog.allComponents.filter(c => {
|
|
1910
|
+
const text = c.text.toLowerCase();
|
|
1911
|
+
// Match patterns like "shortbow", "longbow", "arrow shaft"
|
|
1912
|
+
if (productLower.includes('short') && text.includes('shortbow')) return true;
|
|
1913
|
+
if (productLower.includes('long') && text.includes('longbow')) return true;
|
|
1914
|
+
if (productLower.includes('arrow') && text.includes('arrow')) return true;
|
|
1915
|
+
if (productLower.includes('shaft') && text.includes('shaft')) return true;
|
|
1916
|
+
if (productLower.includes('stock') && text.includes('stock')) return true;
|
|
1917
|
+
// Generic match
|
|
1918
|
+
return text.includes(productLower);
|
|
1919
|
+
});
|
|
1920
|
+
|
|
1921
|
+
if (matchingComponents.length > 0) {
|
|
1922
|
+
// Found a matching text component - now find the associated Ok button
|
|
1923
|
+
// The Ok buttons in dialog.options should correspond to the products
|
|
1924
|
+
// Try to find the index by matching component IDs or order
|
|
1925
|
+
|
|
1926
|
+
// Get all Ok buttons from options
|
|
1927
|
+
const okButtons = state.dialog.options.filter(o =>
|
|
1928
|
+
o.text.toLowerCase() === 'ok'
|
|
1929
|
+
);
|
|
1930
|
+
|
|
1931
|
+
if (okButtons.length > 0) {
|
|
1932
|
+
// Try to determine which Ok button to click based on product type
|
|
1933
|
+
// Button order depends on log type:
|
|
1934
|
+
// - Regular logs: [Arrow shafts, Shortbow, Longbow] - 3 main products
|
|
1935
|
+
// - Oak/higher logs: [Shortbow, Longbow] - 2 main products (no arrow shafts option)
|
|
1936
|
+
let okIndex = 0; // Default to first
|
|
1937
|
+
|
|
1938
|
+
if (productLower.includes('short')) {
|
|
1939
|
+
if (isOakOrHigherLogs) {
|
|
1940
|
+
// Oak/higher logs: Shortbow is first (index 0)
|
|
1941
|
+
okIndex = 0;
|
|
1942
|
+
} else {
|
|
1943
|
+
// Regular logs: Shortbow is second (index 1, after arrow shafts)
|
|
1944
|
+
okIndex = Math.min(1, okButtons.length - 1);
|
|
1945
|
+
}
|
|
1946
|
+
} else if (productLower.includes('long')) {
|
|
1947
|
+
if (isOakOrHigherLogs) {
|
|
1948
|
+
// Oak/higher logs: Longbow is second (index 1)
|
|
1949
|
+
okIndex = Math.min(1, okButtons.length - 1);
|
|
1950
|
+
} else {
|
|
1951
|
+
// Regular logs: Longbow is third (index 2)
|
|
1952
|
+
okIndex = Math.min(2, okButtons.length - 1);
|
|
1953
|
+
}
|
|
1954
|
+
} else if (productLower.includes('stock')) {
|
|
1955
|
+
okIndex = Math.min(3, okButtons.length - 1);
|
|
1956
|
+
}
|
|
1957
|
+
// arrow/shaft stays at 0
|
|
1958
|
+
|
|
1959
|
+
const targetButton = okButtons[okIndex];
|
|
1960
|
+
if (targetButton) {
|
|
1961
|
+
await this.sdk.sendClickDialog(targetButton.index);
|
|
1962
|
+
buttonClicked = true;
|
|
1963
|
+
await this.sdk.waitForTicks(1);
|
|
1964
|
+
continue;
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
// Fallback: use index-based approach if we couldn't match by text
|
|
1971
|
+
if (!buttonClicked) {
|
|
1972
|
+
// Determine fallback index based on product keyword and log type
|
|
1973
|
+
let targetButtonIndex = 1; // Default: first option
|
|
1974
|
+
if (product) {
|
|
1975
|
+
const productLower = product.toLowerCase();
|
|
1976
|
+
if (productLower.includes('short')) {
|
|
1977
|
+
// Oak/higher: shortbow is button 1; Regular: button 2
|
|
1978
|
+
targetButtonIndex = isOakOrHigherLogs ? 1 : 2;
|
|
1979
|
+
} else if (productLower.includes('long')) {
|
|
1980
|
+
// Oak/higher: longbow is button 2; Regular: button 3
|
|
1981
|
+
targetButtonIndex = isOakOrHigherLogs ? 2 : 3;
|
|
1982
|
+
} else if (productLower.includes('stock')) {
|
|
1983
|
+
targetButtonIndex = 4;
|
|
1984
|
+
}
|
|
1985
|
+
// arrow/shaft stays at 1
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
if (state.dialog.options.length >= targetButtonIndex) {
|
|
1989
|
+
await this.sdk.sendClickDialog(targetButtonIndex);
|
|
1990
|
+
buttonClicked = true;
|
|
1991
|
+
await this.sdk.waitForTicks(1);
|
|
1992
|
+
continue;
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// If we already clicked or don't have enough options, click continue/first
|
|
1997
|
+
if (state.dialog.options.length > 0 && state.dialog.options[0]) {
|
|
1998
|
+
await this.sdk.sendClickDialog(state.dialog.options[0].index);
|
|
1999
|
+
} else {
|
|
2000
|
+
await this.sdk.sendClickDialog(0);
|
|
2001
|
+
}
|
|
2002
|
+
await this.sdk.waitForTicks(1);
|
|
2003
|
+
continue;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
// Check for failure messages
|
|
2007
|
+
for (const msg of state.gameMessages) {
|
|
2008
|
+
if (msg.tick > startTick) {
|
|
2009
|
+
const text = msg.text.toLowerCase();
|
|
2010
|
+
if (text.includes("need a higher") || text.includes("level to")) {
|
|
2011
|
+
return { success: false, message: 'Fletching level too low' };
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
await this.sdk.waitForTicks(1);
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
// Final XP check
|
|
2020
|
+
const finalXp = this.sdk.getSkill('Fletching')?.experience || 0;
|
|
2021
|
+
if (finalXp > fletchingBefore) {
|
|
2022
|
+
const craftedProduct = this.sdk.findInventoryItem(/shortbow|longbow|arrow shaft|stock/i);
|
|
2023
|
+
return {
|
|
2024
|
+
success: true,
|
|
2025
|
+
message: 'Fletched logs successfully',
|
|
2026
|
+
xpGained: finalXp - fletchingBefore,
|
|
2027
|
+
product: craftedProduct || undefined
|
|
2028
|
+
};
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
return { success: false, message: 'Fletching timed out' };
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
/** Craft leather into armour using needle and thread. */
|
|
2035
|
+
async craftLeather(product?: string): Promise<CraftLeatherResult> {
|
|
2036
|
+
await this.dismissBlockingUI();
|
|
2037
|
+
|
|
2038
|
+
const needle = this.sdk.findInventoryItem(/needle/i);
|
|
2039
|
+
if (!needle) {
|
|
2040
|
+
return { success: false, message: 'No needle in inventory', reason: 'no_needle' };
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
const leather = this.sdk.findInventoryItem(/^leather$/i);
|
|
2044
|
+
if (!leather) {
|
|
2045
|
+
return { success: false, message: 'No leather in inventory', reason: 'no_leather' };
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
const thread = this.sdk.findInventoryItem(/thread/i);
|
|
2049
|
+
if (!thread) {
|
|
2050
|
+
return { success: false, message: 'No thread in inventory', reason: 'no_thread' };
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
const craftingBefore = this.sdk.getSkill('Crafting')?.experience || 0;
|
|
2054
|
+
const startTick = this.sdk.getState()?.tick || 0;
|
|
2055
|
+
|
|
2056
|
+
// Use needle on leather to open crafting interface
|
|
2057
|
+
const result = await this.sdk.sendUseItemOnItem(needle.slot, leather.slot);
|
|
2058
|
+
if (!result.success) {
|
|
2059
|
+
return { success: false, message: result.message };
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
// Wait for interface/dialog to open
|
|
2063
|
+
try {
|
|
2064
|
+
await this.sdk.waitForCondition(
|
|
2065
|
+
s => s.dialog.isOpen || s.interface?.isOpen,
|
|
2066
|
+
10000
|
|
2067
|
+
);
|
|
2068
|
+
} catch {
|
|
2069
|
+
return { success: false, message: 'Crafting interface did not open', reason: 'interface_not_opened' };
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
// Handle product selection and crafting
|
|
2073
|
+
const MAX_ATTEMPTS = 50;
|
|
2074
|
+
|
|
2075
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
2076
|
+
const state = this.sdk.getState();
|
|
2077
|
+
if (!state) {
|
|
2078
|
+
return { success: false, message: 'Lost game state' };
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
// Check if XP was gained (success!)
|
|
2082
|
+
const currentXp = state.skills.find(s => s.name === 'Crafting')?.experience || 0;
|
|
2083
|
+
if (currentXp > craftingBefore) {
|
|
2084
|
+
return {
|
|
2085
|
+
success: true,
|
|
2086
|
+
message: 'Crafted leather item successfully',
|
|
2087
|
+
xpGained: currentXp - craftingBefore,
|
|
2088
|
+
itemsCrafted: 1
|
|
2089
|
+
};
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
// Handle interface (leather crafting interface id=2311)
|
|
2093
|
+
if (state.interface?.isOpen) {
|
|
2094
|
+
if (product) {
|
|
2095
|
+
// Try to find matching option by text
|
|
2096
|
+
const productOption = state.interface.options.find(o =>
|
|
2097
|
+
o.text.toLowerCase().includes(product.toLowerCase())
|
|
2098
|
+
);
|
|
2099
|
+
if (productOption) {
|
|
2100
|
+
await this.sdk.sendClickInterfaceOption(productOption.index);
|
|
2101
|
+
await this.sdk.waitForTicks(1);
|
|
2102
|
+
continue;
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
// Leather crafting interface (2311) - options are 1-indexed in state but
|
|
2107
|
+
// sendClickInterfaceOption uses 0-based array indices.
|
|
2108
|
+
// option.index 1 = leather body (lvl 14), array idx 0
|
|
2109
|
+
// option.index 2 = leather gloves (lvl 1), array idx 1
|
|
2110
|
+
// option.index 3 = leather chaps (lvl 18), array idx 2
|
|
2111
|
+
if (state.interface.interfaceId === 2311) {
|
|
2112
|
+
// Map product names to array indices (0-based)
|
|
2113
|
+
let optionIndex = 1; // Default: gloves (array idx 1, lowest level requirement)
|
|
2114
|
+
if (product) {
|
|
2115
|
+
const productLower = product.toLowerCase();
|
|
2116
|
+
if (productLower.includes('body') || productLower.includes('armour')) {
|
|
2117
|
+
optionIndex = 0; // array idx 0 -> option.index 1 = body
|
|
2118
|
+
} else if (productLower.includes('chaps') || productLower.includes('legs')) {
|
|
2119
|
+
optionIndex = 2; // array idx 2 -> option.index 3 = chaps
|
|
2120
|
+
} else if (productLower.includes('glove') || productLower.includes('vamb')) {
|
|
2121
|
+
optionIndex = 1; // array idx 1 -> option.index 2 = gloves
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
await this.sdk.sendClickInterfaceOption(optionIndex);
|
|
2125
|
+
} else if (state.interface.options.length > 0 && state.interface.options[0]) {
|
|
2126
|
+
await this.sdk.sendClickInterfaceOption(0);
|
|
2127
|
+
}
|
|
2128
|
+
await this.sdk.waitForTicks(1);
|
|
2129
|
+
continue;
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
// Handle dialog
|
|
2133
|
+
if (state.dialog.isOpen) {
|
|
2134
|
+
const craftOption = state.dialog.options.find(o =>
|
|
2135
|
+
/glove|make|craft|leather|body|chaps/i.test(o.text)
|
|
2136
|
+
);
|
|
2137
|
+
if (craftOption) {
|
|
2138
|
+
await this.sdk.sendClickDialog(craftOption.index);
|
|
2139
|
+
} else if (state.dialog.options.length > 0 && state.dialog.options[0]) {
|
|
2140
|
+
await this.sdk.sendClickDialog(state.dialog.options[0].index);
|
|
2141
|
+
} else {
|
|
2142
|
+
await this.sdk.sendClickDialog(0);
|
|
2143
|
+
}
|
|
2144
|
+
await this.sdk.waitForTicks(1);
|
|
2145
|
+
continue;
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// Check for failure messages
|
|
2149
|
+
for (const msg of state.gameMessages) {
|
|
2150
|
+
if (msg.tick > startTick) {
|
|
2151
|
+
const text = msg.text.toLowerCase();
|
|
2152
|
+
if (text.includes("need a crafting level") || text.includes("level to")) {
|
|
2153
|
+
return { success: false, message: 'Crafting level too low', reason: 'level_too_low' };
|
|
2154
|
+
}
|
|
2155
|
+
if (text.includes("don't have") && text.includes("thread")) {
|
|
2156
|
+
return { success: false, message: 'Out of thread', reason: 'no_thread' };
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
// Check if leather is gone (possibly consumed)
|
|
2162
|
+
const currentLeather = this.sdk.findInventoryItem(/^leather$/i);
|
|
2163
|
+
if (!currentLeather) {
|
|
2164
|
+
// Check XP one more time
|
|
2165
|
+
const finalXp = this.sdk.getSkill('Crafting')?.experience || 0;
|
|
2166
|
+
if (finalXp > craftingBefore) {
|
|
2167
|
+
return {
|
|
2168
|
+
success: true,
|
|
2169
|
+
message: 'Crafted leather item successfully',
|
|
2170
|
+
xpGained: finalXp - craftingBefore,
|
|
2171
|
+
itemsCrafted: 1
|
|
2172
|
+
};
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
await this.sdk.waitForTicks(1);
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
// Final XP check
|
|
2180
|
+
const finalXp = this.sdk.getSkill('Crafting')?.experience || 0;
|
|
2181
|
+
if (finalXp > craftingBefore) {
|
|
2182
|
+
return {
|
|
2183
|
+
success: true,
|
|
2184
|
+
message: 'Crafted leather item successfully',
|
|
2185
|
+
xpGained: finalXp - craftingBefore,
|
|
2186
|
+
itemsCrafted: 1
|
|
2187
|
+
};
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
return { success: false, message: 'Crafting timed out', reason: 'timeout' };
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
// ============ Smithing ============
|
|
2194
|
+
|
|
2195
|
+
/**
|
|
2196
|
+
* Smithing interface layout: 5 columns (pack IDs 1119-1123), each with up to 5 slots.
|
|
2197
|
+
* Maps product name -> { component (column pack ID), slot (row within column) }.
|
|
2198
|
+
*
|
|
2199
|
+
* Column 1 (1119): Dagger, Sword, Scimitar, Longsword, 2H Sword
|
|
2200
|
+
* Column 2 (1120): Axe, Mace, Warhammer, Battleaxe
|
|
2201
|
+
* Column 3 (1121): Chainbody, Platelegs, Plateskirt, Platebody
|
|
2202
|
+
* Column 4 (1122): Med Helm, Full Helm, Sq Shield, Kiteshield
|
|
2203
|
+
* Column 5 (1123): Dart Tips, Arrowheads, Throwing Knives, Wire/Studs
|
|
2204
|
+
*/
|
|
2205
|
+
private static readonly SMITHING_COMPONENTS: Record<string, { component: number; slot: number }> = {
|
|
2206
|
+
// Column 1 - Bladed weapons
|
|
2207
|
+
'dagger': { component: 1119, slot: 0 },
|
|
2208
|
+
'sword': { component: 1119, slot: 1 },
|
|
2209
|
+
'scimitar': { component: 1119, slot: 2 },
|
|
2210
|
+
'longsword': { component: 1119, slot: 3 },
|
|
2211
|
+
'long sword': { component: 1119, slot: 3 },
|
|
2212
|
+
'2h sword': { component: 1119, slot: 4 },
|
|
2213
|
+
'two-handed sword': { component: 1119, slot: 4 },
|
|
2214
|
+
// Column 2 - Blunt/axe weapons
|
|
2215
|
+
'axe': { component: 1120, slot: 0 },
|
|
2216
|
+
'mace': { component: 1120, slot: 1 },
|
|
2217
|
+
'warhammer': { component: 1120, slot: 2 },
|
|
2218
|
+
'war hammer': { component: 1120, slot: 2 },
|
|
2219
|
+
'battleaxe': { component: 1120, slot: 3 },
|
|
2220
|
+
'battle axe': { component: 1120, slot: 3 },
|
|
2221
|
+
// Column 3 - Armour
|
|
2222
|
+
'chainbody': { component: 1121, slot: 0 },
|
|
2223
|
+
'chain body': { component: 1121, slot: 0 },
|
|
2224
|
+
'platelegs': { component: 1121, slot: 1 },
|
|
2225
|
+
'plate legs': { component: 1121, slot: 1 },
|
|
2226
|
+
'plateskirt': { component: 1121, slot: 2 },
|
|
2227
|
+
'plate skirt': { component: 1121, slot: 2 },
|
|
2228
|
+
'platebody': { component: 1121, slot: 3 },
|
|
2229
|
+
'plate body': { component: 1121, slot: 3 },
|
|
2230
|
+
// Column 4 - Helms/shields
|
|
2231
|
+
'med helm': { component: 1122, slot: 0 },
|
|
2232
|
+
'medium helm': { component: 1122, slot: 0 },
|
|
2233
|
+
'full helm': { component: 1122, slot: 1 },
|
|
2234
|
+
'sq shield': { component: 1122, slot: 2 },
|
|
2235
|
+
'square shield': { component: 1122, slot: 2 },
|
|
2236
|
+
'kiteshield': { component: 1122, slot: 3 },
|
|
2237
|
+
'kite shield': { component: 1122, slot: 3 },
|
|
2238
|
+
// Column 5 - Projectiles/misc
|
|
2239
|
+
'dart tips': { component: 1123, slot: 0 },
|
|
2240
|
+
'arrowheads': { component: 1123, slot: 1 },
|
|
2241
|
+
'arrow tips': { component: 1123, slot: 1 },
|
|
2242
|
+
'throwing knives': { component: 1123, slot: 2 },
|
|
2243
|
+
'knives': { component: 1123, slot: 2 },
|
|
2244
|
+
};
|
|
2245
|
+
|
|
2246
|
+
/**
|
|
2247
|
+
* Smith a bar into an item at an anvil.
|
|
2248
|
+
*
|
|
2249
|
+
* @param product - The item to smith (e.g., 'dagger', 'axe', 'platebody') or component ID
|
|
2250
|
+
* @param options - Optional configuration
|
|
2251
|
+
* @returns Result with XP gained and item created
|
|
2252
|
+
*
|
|
2253
|
+
* @example
|
|
2254
|
+
* ```ts
|
|
2255
|
+
* // Smith a bronze dagger
|
|
2256
|
+
* const result = await bot.smithAtAnvil('dagger');
|
|
2257
|
+
*
|
|
2258
|
+
* // Smith using component ID directly
|
|
2259
|
+
* const result = await bot.smithAtAnvil(1119);
|
|
2260
|
+
* ```
|
|
2261
|
+
*/
|
|
2262
|
+
async smithAtAnvil(
|
|
2263
|
+
product: string | number = 'dagger',
|
|
2264
|
+
options: { barPattern?: RegExp; timeout?: number } = {}
|
|
2265
|
+
): Promise<SmithResult> {
|
|
2266
|
+
const { barPattern = /bar$/i, timeout = 10000 } = options;
|
|
2267
|
+
|
|
2268
|
+
await this.dismissBlockingUI();
|
|
2269
|
+
|
|
2270
|
+
// Check for hammer
|
|
2271
|
+
const hammer = this.sdk.findInventoryItem(/hammer/i);
|
|
2272
|
+
if (!hammer) {
|
|
2273
|
+
return { success: false, message: 'No hammer in inventory', reason: 'no_hammer' };
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
// Check for bars
|
|
2277
|
+
const bar = this.sdk.findInventoryItem(barPattern);
|
|
2278
|
+
if (!bar) {
|
|
2279
|
+
return { success: false, message: 'No bars in inventory', reason: 'no_bars' };
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
// Find anvil
|
|
2283
|
+
const anvil = this.sdk.findNearbyLoc(/anvil/i);
|
|
2284
|
+
if (!anvil) {
|
|
2285
|
+
return { success: false, message: 'No anvil nearby', reason: 'no_anvil' };
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
// Determine component ID and slot
|
|
2289
|
+
let componentId: number;
|
|
2290
|
+
let componentSlot: number = 0;
|
|
2291
|
+
if (typeof product === 'number') {
|
|
2292
|
+
componentId = product;
|
|
2293
|
+
} else {
|
|
2294
|
+
const key = product.toLowerCase();
|
|
2295
|
+
const directMatch = BotActions.SMITHING_COMPONENTS[key];
|
|
2296
|
+
if (directMatch) {
|
|
2297
|
+
componentId = directMatch.component;
|
|
2298
|
+
componentSlot = directMatch.slot;
|
|
2299
|
+
} else {
|
|
2300
|
+
// Try partial match
|
|
2301
|
+
const matchingKey = Object.keys(BotActions.SMITHING_COMPONENTS).find(k =>
|
|
2302
|
+
k.includes(key) || key.includes(k)
|
|
2303
|
+
);
|
|
2304
|
+
const partialMatch = matchingKey ? BotActions.SMITHING_COMPONENTS[matchingKey] : undefined;
|
|
2305
|
+
if (partialMatch) {
|
|
2306
|
+
componentId = partialMatch.component;
|
|
2307
|
+
componentSlot = partialMatch.slot;
|
|
2308
|
+
} else {
|
|
2309
|
+
return { success: false, message: `Unknown smithing product: ${product}`, reason: 'level_too_low' };
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
const smithingBefore = this.sdk.getSkill('Smithing')?.experience || 0;
|
|
2315
|
+
const startTick = this.sdk.getState()?.tick || 0;
|
|
2316
|
+
|
|
2317
|
+
// Use bar on anvil
|
|
2318
|
+
const useResult = await this.sdk.sendUseItemOnLoc(bar.slot, anvil.x, anvil.z, anvil.id);
|
|
2319
|
+
if (!useResult.success) {
|
|
2320
|
+
return { success: false, message: useResult.message, reason: 'no_anvil' };
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
// Wait for smithing interface to open
|
|
2324
|
+
try {
|
|
2325
|
+
await this.sdk.waitForCondition(
|
|
2326
|
+
s => s.interface?.isOpen && s.interface.interfaceId === 994,
|
|
2327
|
+
5000
|
|
2328
|
+
);
|
|
2329
|
+
|
|
2330
|
+
} catch {
|
|
2331
|
+
return { success: false, message: 'Smithing interface did not open', reason: 'interface_not_opened' };
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
// Click the smithing component (uses INV_BUTTON)
|
|
2335
|
+
const clickResult = await this.sdk.sendClickComponentWithOption(componentId, 1, componentSlot);
|
|
2336
|
+
if (!clickResult.success) {
|
|
2337
|
+
return { success: false, message: 'Failed to click smithing option', reason: 'interface_not_opened' };
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
// Wait for XP gain or timeout
|
|
2341
|
+
const startTime = Date.now();
|
|
2342
|
+
while (Date.now() - startTime < timeout) {
|
|
2343
|
+
const state = this.sdk.getState();
|
|
2344
|
+
if (!state) {
|
|
2345
|
+
await this.sdk.waitForTicks(1);
|
|
2346
|
+
continue;
|
|
2347
|
+
}
|
|
2348
|
+
|
|
2349
|
+
// Check for XP gain
|
|
2350
|
+
const currentXp = state.skills.find(s => s.name === 'Smithing')?.experience || 0;
|
|
2351
|
+
if (currentXp > smithingBefore) {
|
|
2352
|
+
// Find the smithed item
|
|
2353
|
+
const smithedItem = this.sdk.findInventoryItem(/dagger|axe|mace|helm|sword|shield|body|legs|skirt|claws|knives|bolts|arrowtips|arrowheads|arrow|dart/i);
|
|
2354
|
+
return {
|
|
2355
|
+
success: true,
|
|
2356
|
+
message: 'Smithed item successfully',
|
|
2357
|
+
xpGained: currentXp - smithingBefore,
|
|
2358
|
+
itemsSmithed: 1,
|
|
2359
|
+
product: smithedItem || undefined
|
|
2360
|
+
};
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
// Check for failure messages
|
|
2364
|
+
for (const msg of state.gameMessages) {
|
|
2365
|
+
if (msg.tick > startTick) {
|
|
2366
|
+
const text = msg.text.toLowerCase();
|
|
2367
|
+
if (text.includes("need a smithing level") || text.includes("level to")) {
|
|
2368
|
+
return { success: false, message: 'Smithing level too low', reason: 'level_too_low' };
|
|
2369
|
+
}
|
|
2370
|
+
if (text.includes("don't have enough")) {
|
|
2371
|
+
return { success: false, message: 'Not enough bars', reason: 'no_bars' };
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
// If interface closed without XP, might need to retry
|
|
2377
|
+
if (!state.interface?.isOpen) {
|
|
2378
|
+
const finalXp = this.sdk.getSkill('Smithing')?.experience || 0;
|
|
2379
|
+
if (finalXp > smithingBefore) {
|
|
2380
|
+
const smithedItem = this.sdk.findInventoryItem(/dagger|axe|mace|helm|sword|shield|body|legs|skirt|claws|knives|bolts|arrowtips|arrowheads|arrow|dart/i);
|
|
2381
|
+
return {
|
|
2382
|
+
success: true,
|
|
2383
|
+
message: 'Smithed item successfully',
|
|
2384
|
+
xpGained: finalXp - smithingBefore,
|
|
2385
|
+
itemsSmithed: 1,
|
|
2386
|
+
product: smithedItem || undefined
|
|
2387
|
+
};
|
|
2388
|
+
}
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
await this.sdk.waitForTicks(1);
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
// Final XP check
|
|
2395
|
+
const finalXp = this.sdk.getSkill('Smithing')?.experience || 0;
|
|
2396
|
+
if (finalXp > smithingBefore) {
|
|
2397
|
+
const smithedItem = this.sdk.findInventoryItem(/dagger|axe|mace|helm|sword|shield|body|legs|skirt|claws|knives|bolts|arrowtips|arrowheads|arrow|dart/i);
|
|
2398
|
+
return {
|
|
2399
|
+
success: true,
|
|
2400
|
+
message: 'Smithed item successfully',
|
|
2401
|
+
xpGained: finalXp - smithingBefore,
|
|
2402
|
+
itemsSmithed: 1,
|
|
2403
|
+
product: smithedItem || undefined
|
|
2404
|
+
};
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
return { success: false, message: 'Smithing timed out', reason: 'timeout' };
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
// ============ Porcelain: Generic Interactions ============
|
|
2411
|
+
|
|
2412
|
+
/**
|
|
2413
|
+
* Interact with a nearby location object (rock, fishing spot, furnace, etc.).
|
|
2414
|
+
* Walks to the target first (handling doors), sends the interaction, then waits
|
|
2415
|
+
* for an effect (animation, dialog, interface) or detects failure when the player
|
|
2416
|
+
* has been idle for 2 ticks with nothing happening.
|
|
2417
|
+
* @param target - NearbyLoc object or name string/regex to find
|
|
2418
|
+
* @param option - Option index or name regex to match (default: 1, the first option)
|
|
2419
|
+
*/
|
|
2420
|
+
async interactLoc(
|
|
2421
|
+
target: NearbyLoc | string | RegExp,
|
|
2422
|
+
option: number | string | RegExp = 1,
|
|
2423
|
+
): Promise<InteractLocResult> {
|
|
2424
|
+
await this.dismissBlockingUI();
|
|
2425
|
+
|
|
2426
|
+
const loc = this.helpers.resolveLocation(target, /./);
|
|
2427
|
+
if (!loc) {
|
|
2428
|
+
return { success: false, message: `Location not found: ${target}`, reason: 'loc_not_found' };
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
// Resolve option index
|
|
2432
|
+
let opIndex: number;
|
|
2433
|
+
if (typeof option === 'number') {
|
|
2434
|
+
opIndex = option;
|
|
2435
|
+
} else {
|
|
2436
|
+
const regex = typeof option === 'string' ? new RegExp(option, 'i') : option;
|
|
2437
|
+
const match = loc.optionsWithIndex.find(o => regex.test(o.text));
|
|
2438
|
+
if (!match) {
|
|
2439
|
+
return { success: false, message: `No matching option on ${loc.name}`, reason: 'no_matching_option' };
|
|
2440
|
+
}
|
|
2441
|
+
opIndex = match.opIndex;
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
// Walk to the location first (handles doors)
|
|
2445
|
+
if (loc.distance > 2) {
|
|
2446
|
+
const walkResult = await this.walkTo(loc.x, loc.z, 2);
|
|
2447
|
+
if (!walkResult.success) {
|
|
2448
|
+
return { success: false, message: `Cannot reach ${loc.name}: ${walkResult.message}`, reason: 'cant_reach' };
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
// Re-find the location after walking (it may have changed)
|
|
2453
|
+
const locPattern = typeof target === 'object' ? new RegExp(loc.name, 'i') : target;
|
|
2454
|
+
const locNow = this.helpers.resolveLocation(locPattern, /./);
|
|
2455
|
+
if (!locNow) {
|
|
2456
|
+
return { success: false, message: `${loc.name} no longer visible`, reason: 'loc_not_found' };
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
const startTick = this.sdk.getState()?.tick || 0;
|
|
2460
|
+
let lastMoveTick = startTick;
|
|
2461
|
+
let lastX = this.sdk.getState()?.player?.x ?? 0;
|
|
2462
|
+
let lastZ = this.sdk.getState()?.player?.z ?? 0;
|
|
2463
|
+
|
|
2464
|
+
const result = await this.sdk.sendInteractLoc(locNow.x, locNow.z, locNow.id, opIndex);
|
|
2465
|
+
if (!result.success) {
|
|
2466
|
+
return { success: false, message: result.message, reason: 'timeout' };
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
try {
|
|
2470
|
+
const finalState = await this.sdk.waitForCondition(state => {
|
|
2471
|
+
// Check for can't-reach messages
|
|
2472
|
+
for (const msg of state.gameMessages) {
|
|
2473
|
+
if (msg.tick > startTick) {
|
|
2474
|
+
const text = msg.text.toLowerCase();
|
|
2475
|
+
if (text.includes("can't reach") || text.includes("cannot reach")) return true;
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
// Success indicators
|
|
2480
|
+
if (state.dialog.isOpen || state.interface?.isOpen) return true;
|
|
2481
|
+
if (state.player && state.player.animId !== -1) return true;
|
|
2482
|
+
|
|
2483
|
+
// Track movement — if player moved, update last move tick
|
|
2484
|
+
if (state.player && (state.player.x !== lastX || state.player.z !== lastZ)) {
|
|
2485
|
+
lastX = state.player.x;
|
|
2486
|
+
lastZ = state.player.z;
|
|
2487
|
+
lastMoveTick = state.tick;
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
// Player idle for 2+ ticks with nothing happening → give up
|
|
2491
|
+
if (state.tick - lastMoveTick >= 2) return true;
|
|
2492
|
+
|
|
2493
|
+
return false;
|
|
2494
|
+
}, 30000); // safety net only
|
|
2495
|
+
|
|
2496
|
+
if (this.helpers.checkCantReachMessage(startTick)) {
|
|
2497
|
+
return { success: false, message: `Can't reach ${locNow.name}`, reason: 'cant_reach' };
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
if (finalState.dialog.isOpen || finalState.interface?.isOpen ||
|
|
2501
|
+
(finalState.player && finalState.player.animId !== -1)) {
|
|
2502
|
+
return { success: true, message: `Interacted with ${locNow.name}` };
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
return { success: false, message: `Nothing happened interacting with ${locNow.name}`, reason: 'timeout' };
|
|
2506
|
+
} catch {
|
|
2507
|
+
return { success: false, message: `Timed out interacting with ${locNow.name}`, reason: 'timeout' };
|
|
2508
|
+
}
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
/**
|
|
2512
|
+
* Interact with a nearby NPC using a specified option (e.g. "Trade", "Pickpocket", "Fish").
|
|
2513
|
+
* Walks to the NPC first (handling doors), sends the interaction, then waits
|
|
2514
|
+
* for an effect (animation, dialog, interface) or detects failure when the player
|
|
2515
|
+
* has been idle for 2 ticks with nothing happening.
|
|
2516
|
+
* @param target - NearbyNpc object or name string/regex to find
|
|
2517
|
+
* @param option - Option index or name regex to match (default: 1, the first option)
|
|
2518
|
+
*/
|
|
2519
|
+
async interactNpc(
|
|
2520
|
+
target: NearbyNpc | string | RegExp,
|
|
2521
|
+
option: number | string | RegExp = 1,
|
|
2522
|
+
): Promise<InteractNpcResult> {
|
|
2523
|
+
await this.dismissBlockingUI();
|
|
2524
|
+
|
|
2525
|
+
const npc = this.helpers.resolveNpc(target);
|
|
2526
|
+
if (!npc) {
|
|
2527
|
+
return { success: false, message: `NPC not found: ${target}`, reason: 'npc_not_found' };
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
// Resolve option index
|
|
2531
|
+
let opIndex: number;
|
|
2532
|
+
if (typeof option === 'number') {
|
|
2533
|
+
opIndex = option;
|
|
2534
|
+
} else {
|
|
2535
|
+
const regex = typeof option === 'string' ? new RegExp(option, 'i') : option;
|
|
2536
|
+
const match = npc.optionsWithIndex.find(o => regex.test(o.text));
|
|
2537
|
+
if (!match) {
|
|
2538
|
+
return { success: false, message: `No matching option on ${npc.name}`, reason: 'no_matching_option' };
|
|
2539
|
+
}
|
|
2540
|
+
opIndex = match.opIndex;
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
// Walk to the NPC first (handles doors)
|
|
2544
|
+
if (npc.distance > 2) {
|
|
2545
|
+
const walkResult = await this.walkTo(npc.x, npc.z, 2);
|
|
2546
|
+
if (!walkResult.success) {
|
|
2547
|
+
return { success: false, message: `Cannot reach ${npc.name}: ${walkResult.message}`, reason: 'cant_reach' };
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
// Re-find the NPC after walking (it may have moved)
|
|
2552
|
+
const npcPattern = typeof target === 'object' ? new RegExp(npc.name, 'i') : target;
|
|
2553
|
+
const npcNow = this.helpers.resolveNpc(npcPattern);
|
|
2554
|
+
if (!npcNow) {
|
|
2555
|
+
return { success: false, message: `${npc.name} no longer visible`, reason: 'npc_not_found' };
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
const startTick = this.sdk.getState()?.tick || 0;
|
|
2559
|
+
let lastMoveTick = startTick;
|
|
2560
|
+
let lastX = this.sdk.getState()?.player?.x ?? 0;
|
|
2561
|
+
let lastZ = this.sdk.getState()?.player?.z ?? 0;
|
|
2562
|
+
|
|
2563
|
+
const result = await this.sdk.sendInteractNpc(npcNow.index, opIndex);
|
|
2564
|
+
if (!result.success) {
|
|
2565
|
+
return { success: false, message: result.message, reason: 'timeout' };
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
try {
|
|
2569
|
+
const finalState = await this.sdk.waitForCondition(state => {
|
|
2570
|
+
// Check for can't-reach messages
|
|
2571
|
+
for (const msg of state.gameMessages) {
|
|
2572
|
+
if (msg.tick > startTick) {
|
|
2573
|
+
const text = msg.text.toLowerCase();
|
|
2574
|
+
if (text.includes("can't reach") || text.includes("cannot reach")) return true;
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
// Success indicators
|
|
2579
|
+
if (state.dialog.isOpen || state.interface?.isOpen) return true;
|
|
2580
|
+
if (state.player && state.player.animId !== -1) return true;
|
|
2581
|
+
|
|
2582
|
+
// Track movement — if player moved, update last move tick
|
|
2583
|
+
if (state.player && (state.player.x !== lastX || state.player.z !== lastZ)) {
|
|
2584
|
+
lastX = state.player.x;
|
|
2585
|
+
lastZ = state.player.z;
|
|
2586
|
+
lastMoveTick = state.tick;
|
|
2587
|
+
}
|
|
2588
|
+
|
|
2589
|
+
// Player idle for 2+ ticks with nothing happening → give up
|
|
2590
|
+
if (state.tick - lastMoveTick >= 2) return true;
|
|
2591
|
+
|
|
2592
|
+
return false;
|
|
2593
|
+
}, 30000); // safety net only
|
|
2594
|
+
|
|
2595
|
+
if (this.helpers.checkCantReachMessage(startTick)) {
|
|
2596
|
+
return { success: false, message: `Can't reach ${npcNow.name}`, reason: 'cant_reach' };
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
if (finalState.dialog.isOpen || finalState.interface?.isOpen ||
|
|
2600
|
+
(finalState.player && finalState.player.animId !== -1)) {
|
|
2601
|
+
return { success: true, message: `Interacted with ${npcNow.name}` };
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2604
|
+
return { success: false, message: `Nothing happened interacting with ${npcNow.name}`, reason: 'timeout' };
|
|
2605
|
+
} catch {
|
|
2606
|
+
return { success: false, message: `Timed out interacting with ${npcNow.name}`, reason: 'timeout' };
|
|
2607
|
+
}
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
// ============ Porcelain: Thieving ============
|
|
2611
|
+
|
|
2612
|
+
/** Pickpocket an NPC. Handles door retrying if path is blocked. */
|
|
2613
|
+
async pickpocketNpc(target: NearbyNpc | string | RegExp): Promise<PickpocketResult> {
|
|
2614
|
+
return this.helpers.withDoorRetry(
|
|
2615
|
+
() => this._pickpocketNpcOnce(target),
|
|
2616
|
+
(r) => r.reason === 'cant_reach' || r.reason === 'timeout'
|
|
2617
|
+
);
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
private async _pickpocketNpcOnce(target: NearbyNpc | string | RegExp): Promise<PickpocketResult> {
|
|
2621
|
+
await this.dismissBlockingUI();
|
|
2622
|
+
|
|
2623
|
+
const npc = this.helpers.resolveNpc(target);
|
|
2624
|
+
if (!npc) {
|
|
2625
|
+
return { success: false, message: `NPC not found: ${target}`, reason: 'npc_not_found' };
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
const pickOpt = npc.optionsWithIndex.find(o => /pickpocket/i.test(o.text));
|
|
2629
|
+
if (!pickOpt) {
|
|
2630
|
+
return { success: false, message: `No pickpocket option on ${npc.name}`, reason: 'no_pickpocket_option' };
|
|
2631
|
+
}
|
|
2632
|
+
|
|
2633
|
+
const thievingBefore = this.sdk.getSkill('Thieving')?.experience || 0;
|
|
2634
|
+
const startTick = this.sdk.getState()?.tick || 0;
|
|
2635
|
+
|
|
2636
|
+
const result = await this.sdk.sendInteractNpc(npc.index, pickOpt.opIndex);
|
|
2637
|
+
if (!result.success) {
|
|
2638
|
+
return { success: false, message: result.message, reason: 'timeout' };
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
try {
|
|
2642
|
+
const finalState = await this.sdk.waitForCondition(state => {
|
|
2643
|
+
// Check for XP gain
|
|
2644
|
+
const thievingNow = state.skills.find(s => s.name === 'Thieving')?.experience || 0;
|
|
2645
|
+
if (thievingNow > thievingBefore) return true;
|
|
2646
|
+
|
|
2647
|
+
// Check game messages for stun/catch or can't reach
|
|
2648
|
+
for (const msg of state.gameMessages) {
|
|
2649
|
+
if (msg.tick > startTick) {
|
|
2650
|
+
const text = msg.text.toLowerCase();
|
|
2651
|
+
if (text.includes('stunned') || text.includes('caught') || text.includes('stun')) return true;
|
|
2652
|
+
if (text.includes("can't reach") || text.includes('cannot reach')) return true;
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
return false;
|
|
2657
|
+
}, 10000);
|
|
2658
|
+
|
|
2659
|
+
// Check what happened
|
|
2660
|
+
for (const msg of finalState.gameMessages) {
|
|
2661
|
+
if (msg.tick > startTick) {
|
|
2662
|
+
const text = msg.text.toLowerCase();
|
|
2663
|
+
if (text.includes("can't reach") || text.includes('cannot reach')) {
|
|
2664
|
+
return { success: false, message: `Can't reach ${npc.name}`, reason: 'cant_reach' };
|
|
2665
|
+
}
|
|
2666
|
+
if (text.includes('stunned') || text.includes('caught') || text.includes('stun')) {
|
|
2667
|
+
return { success: false, message: `Stunned while pickpocketing ${npc.name}`, reason: 'stunned' };
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
const thievingAfter = this.sdk.getSkill('Thieving')?.experience || 0;
|
|
2673
|
+
const xpGained = thievingAfter - thievingBefore;
|
|
2674
|
+
if (xpGained > 0) {
|
|
2675
|
+
return { success: true, message: `Pickpocketed ${npc.name}`, xpGained };
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
return { success: false, message: `Pickpocket failed on ${npc.name}`, reason: 'timeout' };
|
|
2679
|
+
} catch {
|
|
2680
|
+
return { success: false, message: `Timed out pickpocketing ${npc.name}`, reason: 'timeout' };
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
// ============ Porcelain: Prayer Actions ============
|
|
2685
|
+
|
|
2686
|
+
/**
|
|
2687
|
+
* Activate a prayer by name or index.
|
|
2688
|
+
* Checks preconditions (level, prayer points, not already active) before toggling.
|
|
2689
|
+
*/
|
|
2690
|
+
async activatePrayer(prayer: PrayerName | number): Promise<PrayerResult> {
|
|
2691
|
+
await this.dismissBlockingUI();
|
|
2692
|
+
|
|
2693
|
+
const index = typeof prayer === 'number' ? prayer : PRAYER_INDICES[prayer];
|
|
2694
|
+
if (index === undefined || index < 0 || index > 14) {
|
|
2695
|
+
return { success: false, message: `Invalid prayer: ${prayer}`, reason: 'invalid_prayer' };
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
const prayerName = PRAYER_NAMES[index];
|
|
2699
|
+
const prayerState = this.sdk.getPrayerState();
|
|
2700
|
+
if (!prayerState) {
|
|
2701
|
+
return { success: false, message: 'No prayer state available' };
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2704
|
+
// Check if already active
|
|
2705
|
+
if (prayerState.activePrayers[index]) {
|
|
2706
|
+
return { success: true, message: `${prayerName} is already active`, reason: 'already_active' };
|
|
2707
|
+
}
|
|
2708
|
+
|
|
2709
|
+
// Check prayer points
|
|
2710
|
+
if (prayerState.prayerPoints <= 0) {
|
|
2711
|
+
return { success: false, message: 'No prayer points remaining', reason: 'no_prayer_points' };
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
// Check prayer level
|
|
2715
|
+
const requiredLevel = PRAYER_LEVELS[index] ?? 1;
|
|
2716
|
+
if (prayerState.prayerLevel < requiredLevel) {
|
|
2717
|
+
return { success: false, message: `Need prayer level ${requiredLevel} for ${prayerName} (have ${prayerState.prayerLevel})`, reason: 'level_too_low' };
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
// Send toggle
|
|
2721
|
+
const result = await this.sdk.sendTogglePrayer(index);
|
|
2722
|
+
if (!result.success) {
|
|
2723
|
+
return { success: false, message: result.message };
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
// Wait for prayer to become active
|
|
2727
|
+
try {
|
|
2728
|
+
await this.sdk.waitForCondition(state => {
|
|
2729
|
+
return state.prayers.activePrayers[index] === true;
|
|
2730
|
+
}, 5000);
|
|
2731
|
+
return { success: true, message: `Activated ${prayerName}` };
|
|
2732
|
+
} catch {
|
|
2733
|
+
return { success: false, message: `Timeout waiting for ${prayerName} to activate`, reason: 'timeout' };
|
|
2734
|
+
}
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
/**
|
|
2738
|
+
* Deactivate a prayer by name or index.
|
|
2739
|
+
* Checks if the prayer is actually active before toggling.
|
|
2740
|
+
*/
|
|
2741
|
+
async deactivatePrayer(prayer: PrayerName | number): Promise<PrayerResult> {
|
|
2742
|
+
await this.dismissBlockingUI();
|
|
2743
|
+
|
|
2744
|
+
const index = typeof prayer === 'number' ? prayer : PRAYER_INDICES[prayer];
|
|
2745
|
+
if (index === undefined || index < 0 || index > 14) {
|
|
2746
|
+
return { success: false, message: `Invalid prayer: ${prayer}`, reason: 'invalid_prayer' };
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
const prayerName = PRAYER_NAMES[index];
|
|
2750
|
+
const prayerState = this.sdk.getPrayerState();
|
|
2751
|
+
if (!prayerState) {
|
|
2752
|
+
return { success: false, message: 'No prayer state available' };
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
// Check if already inactive
|
|
2756
|
+
if (!prayerState.activePrayers[index]) {
|
|
2757
|
+
return { success: true, message: `${prayerName} is already inactive`, reason: 'already_inactive' };
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
// Send toggle
|
|
2761
|
+
const result = await this.sdk.sendTogglePrayer(index);
|
|
2762
|
+
if (!result.success) {
|
|
2763
|
+
return { success: false, message: result.message };
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
// Wait for prayer to become inactive
|
|
2767
|
+
try {
|
|
2768
|
+
await this.sdk.waitForCondition(state => {
|
|
2769
|
+
return state.prayers.activePrayers[index] === false;
|
|
2770
|
+
}, 5000);
|
|
2771
|
+
return { success: true, message: `Deactivated ${prayerName}` };
|
|
2772
|
+
} catch {
|
|
2773
|
+
return { success: false, message: `Timeout waiting for ${prayerName} to deactivate`, reason: 'timeout' };
|
|
2774
|
+
}
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
/**
|
|
2778
|
+
* Deactivate all currently active prayers.
|
|
2779
|
+
* Toggles each active prayer off one by one.
|
|
2780
|
+
*/
|
|
2781
|
+
async deactivateAllPrayers(): Promise<PrayerResult> {
|
|
2782
|
+
const prayerState = this.sdk.getPrayerState();
|
|
2783
|
+
if (!prayerState) {
|
|
2784
|
+
return { success: false, message: 'No prayer state available' };
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
const activePrayers = prayerState.activePrayers
|
|
2788
|
+
.map((active, i) => active ? i : -1)
|
|
2789
|
+
.filter(i => i !== -1);
|
|
2790
|
+
|
|
2791
|
+
if (activePrayers.length === 0) {
|
|
2792
|
+
return { success: true, message: 'No prayers are active' };
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
for (const index of activePrayers) {
|
|
2796
|
+
const result = await this.deactivatePrayer(index);
|
|
2797
|
+
if (!result.success && result.reason !== 'already_inactive') {
|
|
2798
|
+
return { success: false, message: `Failed to deactivate ${PRAYER_NAMES[index]}: ${result.message}` };
|
|
2799
|
+
}
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
return { success: true, message: `Deactivated ${activePrayers.length} prayer(s)` };
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
// ============ Jewelry Crafting & Enchanting ============
|
|
2806
|
+
|
|
2807
|
+
/** Enchantment spell component IDs, indexed by level (1-5). */
|
|
2808
|
+
private static readonly ENCHANT_SPELLS: Record<number, number> = {
|
|
2809
|
+
1: 1155, // Sapphire — Level 7 Magic
|
|
2810
|
+
2: 1165, // Emerald — Level 27 Magic
|
|
2811
|
+
3: 1176, // Ruby — Level 49 Magic
|
|
2812
|
+
4: 1180, // Diamond — Level 57 Magic
|
|
2813
|
+
5: 1187, // Dragonstone — Level 68 Magic
|
|
2814
|
+
};
|
|
2815
|
+
|
|
2816
|
+
/**
|
|
2817
|
+
* Jewelry crafting interface (4161) component mapping.
|
|
2818
|
+
*
|
|
2819
|
+
* Layout: 3 columns (ring, necklace, amulet), each with 5 gem slots:
|
|
2820
|
+
* slot 0 = plain gold, 1 = sapphire, 2 = emerald, 3 = ruby, 4 = diamond
|
|
2821
|
+
*/
|
|
2822
|
+
private static readonly JEWELRY_COMPONENTS: Record<string, number> = {
|
|
2823
|
+
'ring': 4233,
|
|
2824
|
+
'necklace': 4239,
|
|
2825
|
+
'amulet': 4245,
|
|
2826
|
+
};
|
|
2827
|
+
|
|
2828
|
+
private static readonly JEWELRY_GEM_SLOTS: Record<string, number> = {
|
|
2829
|
+
'gold': 0,
|
|
2830
|
+
'plain': 0,
|
|
2831
|
+
'sapphire': 1,
|
|
2832
|
+
'emerald': 2,
|
|
2833
|
+
'ruby': 3,
|
|
2834
|
+
'diamond': 4,
|
|
2835
|
+
};
|
|
2836
|
+
|
|
2837
|
+
/**
|
|
2838
|
+
* Craft jewelry at a furnace using a gold/silver bar and optional gem.
|
|
2839
|
+
*
|
|
2840
|
+
* Requires: bar + mould in inventory (ring mould, necklace mould, or amulet mould).
|
|
2841
|
+
* Optionally a gem for gem-set jewelry.
|
|
2842
|
+
*
|
|
2843
|
+
* @param options.barPattern - Regex to find the bar (default: /gold bar/i)
|
|
2844
|
+
* @param options.product - Product type: 'ring', 'necklace', or 'amulet' (default: auto-detect from mould)
|
|
2845
|
+
* @param options.gem - Gem type: 'sapphire', 'emerald', 'ruby', 'diamond', or 'gold'/'plain' for no gem (default: auto-detect from inventory)
|
|
2846
|
+
* @param options.timeout - Max wait time in ms (default: 10000)
|
|
2847
|
+
*
|
|
2848
|
+
* @example
|
|
2849
|
+
* ```ts
|
|
2850
|
+
* // Craft a gold ring (need gold bar + ring mould)
|
|
2851
|
+
* const result = await bot.craftJewelry({ product: 'ring' });
|
|
2852
|
+
*
|
|
2853
|
+
* // Craft a ruby amulet (need gold bar + ruby + amulet mould)
|
|
2854
|
+
* const result = await bot.craftJewelry({ product: 'amulet', gem: 'ruby' });
|
|
2855
|
+
*
|
|
2856
|
+
* // Auto-detect: picks product from mould, gem from inventory
|
|
2857
|
+
* const result = await bot.craftJewelry();
|
|
2858
|
+
* ```
|
|
2859
|
+
*/
|
|
2860
|
+
async craftJewelry(options: {
|
|
2861
|
+
barPattern?: RegExp;
|
|
2862
|
+
product?: string;
|
|
2863
|
+
gem?: string;
|
|
2864
|
+
timeout?: number;
|
|
2865
|
+
} = {}): Promise<CraftJewelryResult> {
|
|
2866
|
+
const { barPattern = /gold bar/i, timeout = 10000 } = options;
|
|
2867
|
+
|
|
2868
|
+
await this.dismissBlockingUI();
|
|
2869
|
+
|
|
2870
|
+
// Check for bar
|
|
2871
|
+
const bar = this.sdk.findInventoryItem(barPattern);
|
|
2872
|
+
if (!bar) {
|
|
2873
|
+
return { success: false, message: 'No bar in inventory', reason: 'no_bar' };
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
// Check for a mould
|
|
2877
|
+
const mould = this.sdk.findInventoryItem(/mould/i);
|
|
2878
|
+
if (!mould) {
|
|
2879
|
+
return { success: false, message: 'No mould in inventory (need ring mould, necklace mould, or amulet mould)', reason: 'no_mould' };
|
|
2880
|
+
}
|
|
2881
|
+
|
|
2882
|
+
// Determine product type from option or mould name
|
|
2883
|
+
let product = options.product?.toLowerCase();
|
|
2884
|
+
if (!product) {
|
|
2885
|
+
const mouldName = mould.name.toLowerCase();
|
|
2886
|
+
if (mouldName.includes('ring')) product = 'ring';
|
|
2887
|
+
else if (mouldName.includes('necklace')) product = 'necklace';
|
|
2888
|
+
else if (mouldName.includes('amulet')) product = 'amulet';
|
|
2889
|
+
else product = 'ring'; // fallback
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
const componentId = BotActions.JEWELRY_COMPONENTS[product];
|
|
2893
|
+
if (!componentId) {
|
|
2894
|
+
return { success: false, message: `Unknown jewelry product: ${product}. Use 'ring', 'necklace', or 'amulet'.`, reason: 'no_mould' };
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
// Determine gem slot from option or inventory
|
|
2898
|
+
let gem = options.gem?.toLowerCase();
|
|
2899
|
+
if (!gem) {
|
|
2900
|
+
// Auto-detect from inventory
|
|
2901
|
+
const gemItem = this.sdk.findInventoryItem(/^(sapphire|emerald|ruby|diamond|dragonstone)$/i);
|
|
2902
|
+
gem = gemItem ? gemItem.name.toLowerCase() : 'gold';
|
|
2903
|
+
}
|
|
2904
|
+
|
|
2905
|
+
const gemSlot = BotActions.JEWELRY_GEM_SLOTS[gem] ?? 0;
|
|
2906
|
+
|
|
2907
|
+
// Find furnace
|
|
2908
|
+
const furnace = this.sdk.findNearbyLoc(/furnace/i);
|
|
2909
|
+
if (!furnace) {
|
|
2910
|
+
return { success: false, message: 'No furnace nearby', reason: 'no_furnace' };
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
const craftingBefore = this.sdk.getSkill('Crafting')?.experience || 0;
|
|
2914
|
+
const startTick = this.sdk.getState()?.tick || 0;
|
|
2915
|
+
|
|
2916
|
+
// Walk to furnace if needed
|
|
2917
|
+
if (furnace.distance > 2) {
|
|
2918
|
+
const walkResult = await this.walkTo(furnace.x, furnace.z, 2);
|
|
2919
|
+
if (!walkResult.success) {
|
|
2920
|
+
return { success: false, message: `Cannot reach furnace: ${walkResult.message}`, reason: 'no_furnace' };
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2924
|
+
// Use bar on furnace to open jewelry interface (4161)
|
|
2925
|
+
const useResult = await this.sdk.sendUseItemOnLoc(bar.slot, furnace.x, furnace.z, furnace.id);
|
|
2926
|
+
if (!useResult.success) {
|
|
2927
|
+
return { success: false, message: useResult.message, reason: 'no_furnace' };
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
// Wait for jewelry crafting interface to open
|
|
2931
|
+
try {
|
|
2932
|
+
await this.sdk.waitForCondition(
|
|
2933
|
+
s => s.interface?.isOpen && s.interface.interfaceId === 4161,
|
|
2934
|
+
5000
|
|
2935
|
+
);
|
|
2936
|
+
} catch {
|
|
2937
|
+
return { success: false, message: 'Jewelry crafting interface did not open', reason: 'interface_not_opened' };
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
// Click the product component with the correct gem slot
|
|
2941
|
+
const clickResult = await this.sdk.sendClickComponentWithOption(componentId, 1, gemSlot);
|
|
2942
|
+
if (!clickResult.success) {
|
|
2943
|
+
return { success: false, message: 'Failed to click jewelry option', reason: 'interface_not_opened' };
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
// Wait for XP gain or timeout
|
|
2947
|
+
const startTime = Date.now();
|
|
2948
|
+
while (Date.now() - startTime < timeout) {
|
|
2949
|
+
const state = this.sdk.getState();
|
|
2950
|
+
if (!state) {
|
|
2951
|
+
await this.sdk.waitForTicks(1);
|
|
2952
|
+
continue;
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
// Check for XP gain
|
|
2956
|
+
const currentXp = state.skills.find(s => s.name === 'Crafting')?.experience || 0;
|
|
2957
|
+
if (currentXp > craftingBefore) {
|
|
2958
|
+
await this.dismissBlockingUI();
|
|
2959
|
+
const crafted = this.sdk.findInventoryItem(/ring|necklace|amulet|bracelet/i);
|
|
2960
|
+
return {
|
|
2961
|
+
success: true,
|
|
2962
|
+
message: 'Crafted jewelry successfully',
|
|
2963
|
+
xpGained: currentXp - craftingBefore,
|
|
2964
|
+
product: crafted || undefined
|
|
2965
|
+
};
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2968
|
+
// Check for failure messages
|
|
2969
|
+
for (const msg of state.gameMessages) {
|
|
2970
|
+
if (msg.tick > startTick) {
|
|
2971
|
+
const text = msg.text.toLowerCase();
|
|
2972
|
+
if (text.includes("need a crafting level") || text.includes("level to")) {
|
|
2973
|
+
return { success: false, message: 'Crafting level too low', reason: 'level_too_low' };
|
|
2974
|
+
}
|
|
2975
|
+
if (text.includes("don't have")) {
|
|
2976
|
+
return { success: false, message: msg.text, reason: 'no_gem' };
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
await this.sdk.waitForTicks(1);
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
// Final XP check
|
|
2985
|
+
const finalXp = this.sdk.getSkill('Crafting')?.experience || 0;
|
|
2986
|
+
if (finalXp > craftingBefore) {
|
|
2987
|
+
await this.dismissBlockingUI();
|
|
2988
|
+
const crafted = this.sdk.findInventoryItem(/ring|necklace|amulet|bracelet/i);
|
|
2989
|
+
return {
|
|
2990
|
+
success: true,
|
|
2991
|
+
message: 'Crafted jewelry successfully',
|
|
2992
|
+
xpGained: finalXp - craftingBefore,
|
|
2993
|
+
product: crafted || undefined
|
|
2994
|
+
};
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
return { success: false, message: 'Jewelry crafting timed out', reason: 'timeout' };
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
/**
|
|
3001
|
+
* Cast an enchantment spell on a jewelry item.
|
|
3002
|
+
*
|
|
3003
|
+
* @param target - Item to enchant (InventoryItem, name string, or regex)
|
|
3004
|
+
* @param level - Enchantment level 1-5 (1=Sapphire, 2=Emerald, 3=Ruby, 4=Diamond, 5=Dragonstone)
|
|
3005
|
+
* @param options.timeout - Max wait time in ms (default: 5000)
|
|
3006
|
+
*
|
|
3007
|
+
* @example
|
|
3008
|
+
* ```ts
|
|
3009
|
+
* // Enchant a sapphire ring into a ring of recoil
|
|
3010
|
+
* const result = await bot.enchantItem(/sapphire ring/i, 1);
|
|
3011
|
+
*
|
|
3012
|
+
* // Enchant an emerald amulet
|
|
3013
|
+
* const result = await bot.enchantItem('emerald amulet', 2);
|
|
3014
|
+
* ```
|
|
3015
|
+
*/
|
|
3016
|
+
async enchantItem(
|
|
3017
|
+
target: InventoryItem | string | RegExp,
|
|
3018
|
+
level: 1 | 2 | 3 | 4 | 5,
|
|
3019
|
+
options: { timeout?: number } = {}
|
|
3020
|
+
): Promise<EnchantResult> {
|
|
3021
|
+
const { timeout = 5000 } = options;
|
|
3022
|
+
|
|
3023
|
+
await this.dismissBlockingUI();
|
|
3024
|
+
|
|
3025
|
+
// Resolve item
|
|
3026
|
+
let item: InventoryItem | null;
|
|
3027
|
+
if (typeof target === 'string' || target instanceof RegExp) {
|
|
3028
|
+
const pattern = typeof target === 'string' ? new RegExp(target, 'i') : target;
|
|
3029
|
+
item = this.sdk.findInventoryItem(pattern);
|
|
3030
|
+
} else {
|
|
3031
|
+
item = target;
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
if (!item) {
|
|
3035
|
+
return { success: false, message: `Item not found: ${target}`, reason: 'item_not_found' };
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
const spellComponent = BotActions.ENCHANT_SPELLS[level];
|
|
3039
|
+
if (!spellComponent) {
|
|
3040
|
+
return { success: false, message: `Invalid enchant level: ${level}`, reason: 'item_not_found' };
|
|
3041
|
+
}
|
|
3042
|
+
|
|
3043
|
+
const magicBefore = this.sdk.getSkill('Magic')?.experience || 0;
|
|
3044
|
+
const startTick = this.sdk.getState()?.tick || 0;
|
|
3045
|
+
|
|
3046
|
+
// Cast the enchant spell on the item
|
|
3047
|
+
const castResult = await this.sdk.sendSpellOnItem(item.slot, spellComponent);
|
|
3048
|
+
if (!castResult.success) {
|
|
3049
|
+
return { success: false, message: castResult.message, reason: 'no_runes' };
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
// Wait for XP gain or failure
|
|
3053
|
+
const startTime = Date.now();
|
|
3054
|
+
while (Date.now() - startTime < timeout) {
|
|
3055
|
+
const state = this.sdk.getState();
|
|
3056
|
+
if (!state) {
|
|
3057
|
+
await this.sdk.waitForTicks(1);
|
|
3058
|
+
continue;
|
|
3059
|
+
}
|
|
3060
|
+
|
|
3061
|
+
// Check for Magic XP gain
|
|
3062
|
+
const currentXp = state.skills.find(s => s.name === 'Magic')?.experience || 0;
|
|
3063
|
+
if (currentXp > magicBefore) {
|
|
3064
|
+
// Find the enchanted item (it replaces the original in the same slot)
|
|
3065
|
+
const enchanted = state.inventory.find(i => i.slot === item!.slot);
|
|
3066
|
+
return {
|
|
3067
|
+
success: true,
|
|
3068
|
+
message: 'Enchanted item successfully',
|
|
3069
|
+
xpGained: currentXp - magicBefore,
|
|
3070
|
+
product: enchanted || undefined
|
|
3071
|
+
};
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
// Check for failure messages
|
|
3075
|
+
for (const msg of state.gameMessages) {
|
|
3076
|
+
if (msg.tick > startTick) {
|
|
3077
|
+
const text = msg.text.toLowerCase();
|
|
3078
|
+
if (text.includes("do not have enough") || text.includes("don't have enough") || text.includes("need runes")) {
|
|
3079
|
+
return { success: false, message: 'Not enough runes', reason: 'no_runes' };
|
|
3080
|
+
}
|
|
3081
|
+
if (text.includes("need a magic level") || text.includes("level to cast")) {
|
|
3082
|
+
return { success: false, message: 'Magic level too low', reason: 'level_too_low' };
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
await this.sdk.waitForTicks(1);
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
// Final XP check
|
|
3091
|
+
const finalXp = this.sdk.getSkill('Magic')?.experience || 0;
|
|
3092
|
+
if (finalXp > magicBefore) {
|
|
3093
|
+
const enchanted = this.sdk.getState()?.inventory.find(i => i.slot === item!.slot);
|
|
3094
|
+
return {
|
|
3095
|
+
success: true,
|
|
3096
|
+
message: 'Enchanted item successfully',
|
|
3097
|
+
xpGained: finalXp - magicBefore,
|
|
3098
|
+
product: enchanted || undefined
|
|
3099
|
+
};
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
return { success: false, message: 'Enchantment timed out', reason: 'timeout' };
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
/**
|
|
3106
|
+
* String an amulet using a ball of wool.
|
|
3107
|
+
*
|
|
3108
|
+
* @param target - Unstrung amulet (InventoryItem, name string, or regex). Default: /amulet/i
|
|
3109
|
+
* @param options.timeout - Max wait time in ms (default: 5000)
|
|
3110
|
+
*
|
|
3111
|
+
* @example
|
|
3112
|
+
* ```ts
|
|
3113
|
+
* // String a gold amulet
|
|
3114
|
+
* const result = await bot.stringAmulet(/gold amulet/i);
|
|
3115
|
+
*
|
|
3116
|
+
* // String any unstrung amulet
|
|
3117
|
+
* const result = await bot.stringAmulet();
|
|
3118
|
+
* ```
|
|
3119
|
+
*/
|
|
3120
|
+
async stringAmulet(
|
|
3121
|
+
target: InventoryItem | string | RegExp = /amulet/i,
|
|
3122
|
+
options: { timeout?: number } = {}
|
|
3123
|
+
): Promise<StringAmuletResult> {
|
|
3124
|
+
const { timeout = 5000 } = options;
|
|
3125
|
+
|
|
3126
|
+
await this.dismissBlockingUI();
|
|
3127
|
+
|
|
3128
|
+
// Resolve amulet
|
|
3129
|
+
let amulet: InventoryItem | null;
|
|
3130
|
+
if (typeof target === 'string' || target instanceof RegExp) {
|
|
3131
|
+
const pattern = typeof target === 'string' ? new RegExp(target, 'i') : target;
|
|
3132
|
+
amulet = this.sdk.findInventoryItem(pattern);
|
|
3133
|
+
} else {
|
|
3134
|
+
amulet = target;
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
if (!amulet) {
|
|
3138
|
+
return { success: false, message: `Amulet not found: ${target}`, reason: 'no_amulet' };
|
|
3139
|
+
}
|
|
3140
|
+
|
|
3141
|
+
// Find ball of wool / string
|
|
3142
|
+
const string = this.sdk.findInventoryItem(/ball of wool/i);
|
|
3143
|
+
if (!string) {
|
|
3144
|
+
return { success: false, message: 'No ball of wool in inventory', reason: 'no_string' };
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
const craftingBefore = this.sdk.getSkill('Crafting')?.experience || 0;
|
|
3148
|
+
const startTick = this.sdk.getState()?.tick || 0;
|
|
3149
|
+
|
|
3150
|
+
// Use string on amulet
|
|
3151
|
+
const useResult = await this.sdk.sendUseItemOnItem(string.slot, amulet.slot);
|
|
3152
|
+
if (!useResult.success) {
|
|
3153
|
+
return { success: false, message: useResult.message, reason: 'no_amulet' };
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
// Wait for XP gain or failure
|
|
3157
|
+
const startTime = Date.now();
|
|
3158
|
+
while (Date.now() - startTime < timeout) {
|
|
3159
|
+
const state = this.sdk.getState();
|
|
3160
|
+
if (!state) {
|
|
3161
|
+
await this.sdk.waitForTicks(1);
|
|
3162
|
+
continue;
|
|
3163
|
+
}
|
|
3164
|
+
|
|
3165
|
+
// Check for Crafting XP gain
|
|
3166
|
+
const currentXp = state.skills.find(s => s.name === 'Crafting')?.experience || 0;
|
|
3167
|
+
if (currentXp > craftingBefore) {
|
|
3168
|
+
const strung = this.sdk.findInventoryItem(/amulet/i);
|
|
3169
|
+
return {
|
|
3170
|
+
success: true,
|
|
3171
|
+
message: 'Strung amulet successfully',
|
|
3172
|
+
xpGained: currentXp - craftingBefore,
|
|
3173
|
+
product: strung || undefined
|
|
3174
|
+
};
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
// Check for failure messages
|
|
3178
|
+
for (const msg of state.gameMessages) {
|
|
3179
|
+
if (msg.tick > startTick) {
|
|
3180
|
+
const text = msg.text.toLowerCase();
|
|
3181
|
+
if (text.includes("need a crafting level") || text.includes("level to")) {
|
|
3182
|
+
return { success: false, message: 'Crafting level too low', reason: 'level_too_low' };
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
await this.sdk.waitForTicks(1);
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
// Final XP check
|
|
3191
|
+
const finalXp = this.sdk.getSkill('Crafting')?.experience || 0;
|
|
3192
|
+
if (finalXp > craftingBefore) {
|
|
3193
|
+
const strung = this.sdk.findInventoryItem(/amulet/i);
|
|
3194
|
+
return {
|
|
3195
|
+
success: true,
|
|
3196
|
+
message: 'Strung amulet successfully',
|
|
3197
|
+
xpGained: finalXp - craftingBefore,
|
|
3198
|
+
product: strung || undefined
|
|
3199
|
+
};
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
return { success: false, message: 'Stringing amulet timed out', reason: 'timeout' };
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
// Re-export for convenience
|
|
3207
|
+
export { BotSDK } from './index';
|
|
3208
|
+
export * from './types';
|