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.
@@ -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';