From 4abfa3c73f2314b8c198194f79088587b337cacf Mon Sep 17 00:00:00 2001 From: max bittker Date: Sun, 8 Feb 2026 02:13:46 -0500 Subject: [PATCH] better pathfinding --- .codex/config.toml | 3 + agents.md | 326 ++++++++++++++++++++++++++++++++++++++++++ claude.md | 61 ++++++-- sdk/API.md | 34 +++++ sdk/actions.ts | 343 +++++++++++++++++++++++++++++++++++++++++---- sdk/types.ts | 19 +++ 6 files changed, 752 insertions(+), 34 deletions(-) create mode 100644 .codex/config.toml create mode 100644 agents.md diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 000000000..9b5b51131 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,3 @@ +[mcp_servers.rs-agent] +command = "bun" +args = ["run", "mcp/server.ts"] diff --git a/agents.md b/agents.md new file mode 100644 index 000000000..cee70a197 --- /dev/null +++ b/agents.md @@ -0,0 +1,326 @@ +# RS-Agent Bot Guide + +You're here to play the mmo game through the progressive development of botting scripts, starting small then adapting to your desires and ideas. + +## First Time Setup + +**Create a new bot using the setup script:** + +Ask the user for a bot name (max 12 chars, alphanumeric). If they skip, use the command without a username to auto-generate a random 9-character name. + +```bash +# With custom username +bun scripts/create-bot.ts {username} + +# Auto-generate random username +bun scripts/create-bot.ts +``` + +This automatically creates: +- `bots/{username}/bot.env` - Credentials with auto-generated password +- `bots/{username}/lab_log.md` - Session notes template +- `bots/{username}/script.ts` - Ready-to-run starter script + +## MCP Integration (Interactive Mode) + +The MCP server auto-discovers via `.mcp.json` when you open the project in Claude Code. + +### Quick Start + +1. Install dependencies: `bun install` (from project root) +2. Open project in Claude Code — approve the MCP server when prompted +3. Control your bot with suggestions. + +### Tools + +| Tool | Description | +|------|-------------| +| `execute_code(bot_name, code)` | Run code on a bot. Auto-connects on first use. | +| `list_bots()` | List connected bots | +| `disconnect_bot(name)` | Disconnect a bot | + +### Example + +```typescript +// Just execute - auto-connects on first use +execute_code({ + bot_name: "mybot", + code: ` + const state = sdk.getState(); + console.log('Position:', state.player.worldX, state.player.worldZ); + + // Chop trees for 1 minute + const endTime = Date.now() + 60_000; + while (Date.now() < endTime) { + await bot.dismissBlockingUI(); + const tree = sdk.findNearbyLoc(/^tree$/i); + if (tree) await bot.chopTree(tree); + } + + return sdk.getInventory(); + ` +}) +``` + + +**When to use MCP vs Scripts:** +- **MCP**: One-off fixes, probing, experimenting, quick state checks +- **Scripts**: Anything running in a loop, long-running automation, reproducible tasks, version control + +See `mcp/README.md` for detailed API reference. + +## Script Runner API + +Scripts should leverage `runScript` to manage their connections, initialization, and timeouts. +Make new scripts for different skills, for instance fishing.ts, woodcutting.ts, combat.ts, etc. +You may also wish to import or re-use code between them. + +**Run scripts:** +```bash +bun bots/{username}/script.ts +``` + +The runner automatically finds `bot.env` in the same directory as the script. Alternative methods: +- `bun script.ts {botname}` - loads `bots/{botname}/bot.env` +- `bun --env-file=bots/{name}/bot.env script.ts` - explicit env file + +```typescript +// bots/mybot/woodcutter.ts +import { runScript } from '../../sdk/runner'; + +const result = await runScript(async (ctx) => { + const { bot, sdk, log } = ctx; + + const endTime = Date.now() + 5 * 60_000; // 5 minutes + let logsChopped = 0; + + while (Date.now() < endTime) { + await bot.dismissBlockingUI(); + + const tree = sdk.findNearbyLoc(/^tree$/i); + if (tree) { + const r = await bot.chopTree(tree); + if (r.success) logsChopped++; + } + } + + log(`Chopped ${logsChopped} logs`); + return { logsChopped }; +}, { + timeout: 6 * 60_000, // Overall timeout +}); + +console.log(`Success: ${result.success}`); +``` + +### ScriptContext + +Scripts receive a context object with: + +| Property | Description | +|----------|-------------| +| `bot` | BotActions instance (high-level actions) | +| `sdk` | BotSDK instance (low-level SDK) | +| `log` | Captured logging (like console.log) | +| `warn` | Captured warnings | +| `error` | Captured errors | + +### RunOptions + +| Option | Default | Description | +|--------|---------|-------------| +| `timeout` | none | Overall timeout in ms | +| `autoConnect` | true | Connect if not connected | +| `disconnectAfter` | false | Disconnect when done | + +### RunResult + +```typescript +interface RunResult { + success: boolean; + result?: any; // Return value from script + error?: Error; // If failed + duration: number; // Total ms + logs: LogEntry[]; // Captured logs + finalState: BotWorldState; +} +``` + +The runner automatically prints formatted world state after execution + +## Session Workflow + +This is a **persistent character** - you don't restart fresh each time. The workflow is: + +### 1. Check World State First + +Before writing any script, check where the bot is and what it has: + +```bash +bun sdk/cli.ts {username} +``` + +This shows: position, inventory, skills, nearby NPCs/objects, and more. + +**Exception**: Skip this if you just created the character and know it's at spawn. + +**Tutorial Check**: If the character is in the tutorial area, call `await bot.sendSkipTutorial()` before running any other scripts. The tutorial blocks normal gameplay. + +### 2. Write Your Script + +Edit `bots/{username}/script_name.ts` with your goal. Keep scripts focused on one task. you may write multiple scripts for different tasks and switch between them. + +### 3. Run the Script + +```bash +bun bots/{username}/script_name.ts +``` + +### 4. Observe and Iterate + +Watch the output. After the script finishes (or fails), check state again: + +```bash +bun sdk/cli.ts {username} +``` + +Record observations in `lab_log.md`, then improve the script. + +## Script Duration Guidelines + +**Start short, extend as you gain confidence:** + +| Duration | Use When | +|----------|----------| +| **10-30s** | New script, single actions, untested logic, debugging | +| **2-5 min** | Validated approach, building confidence | +| **10+ min** | Proven strategy, grinding runs | + +A failed 5-minute run wastes more time than five 30 second diagnostic runs. **Fail fast and start simple.** + +Be extremely cognizant of pathing issues. It's very common to have issues because of closed doors and gates. +Look out for "I can't reach" messages - the solution is often to open closed gates. + +Read and grep in the learnings folder for tips. + +## SDK API Reference + +For the complete method reference, see **[sdk/API.md](sdk/API.md)** (auto-generated from source). + +**Quick overview:** +- `bot.*` - High-level actions that wait for effects to complete (chopTree, walkTo, attackNpc, etc.) +- `sdk.*` - Low-level methods that resolve on server acknowledgment (sendWalk, getState, findNearbyNpc, etc.) + +### bot.* Quick Reference + +| Method | What it does | +|--------|-------------| +| `walkTo(x, z, tolerance?)` | Pathfind to coords, opens doors along the way | +| `talkTo(target)` | Walk to NPC, start dialog | +| `interactNpc(target, option?)` | Walk to NPC, interact with any option (e.g. `'trade'`, `'fish'`) | +| `interactLoc(target, option?)` | Walk to loc, interact with any option (e.g. `'mine'`, `'smelt'`) | +| `attackNpc(target)` | Walk to NPC, start combat | +| `pickpocketNpc(target)` | Pickpocket NPC, detects XP gain vs stun | +| `castSpellOnNpc(target, spell)` | Cast combat spell on NPC | +| `chopTree(target?)` | Chop tree, wait for logs | +| `pickupItem(target)` | Pick up ground item | +| `openDoor(target?)` | Open a door or gate | +| `openBank()` | Open nearest bank | +| `depositItem(target, amount?)` | Deposit item to bank | +| `withdrawItem(slot, amount?)` | Withdraw item from bank | +| `openShop(target?)` | Open shop via shopkeeper NPC | +| `buyFromShop(target, amount?)` | Buy item from open shop | +| `sellToShop(target, amount?)` | Sell item to open shop | +| `equipItem(target)` | Equip from inventory | +| `unequipItem(target)` | Unequip to inventory | +| `eatFood(target)` | Eat food, returns HP gained | +| `useItemOnLoc(item, loc)` | Use inventory item on loc (e.g. fish on range) | +| `useItemOnNpc(item, npc)` | Use inventory item on NPC | +| `burnLogs(target?)` | Light logs with tinderbox | +| `fletchLogs(product?)` | Fletch logs with knife | +| `craftLeather(product?)` | Craft leather with needle | +| `smithAtAnvil(product)` | Smith bars at anvil | +| `dismissBlockingUI()` | Dismiss level-up dialogs (call in every loop) | +| `navigateDialog(choices)` | Auto-click through dialog options | +| `skipTutorial()` | Skip the tutorial island | + +### sdk.* Commonly Used Directly + +| Method | What it does | +|--------|-------------| +| `getState()` | Full world state snapshot | +| `getSkill(name)` / `getSkillXp(name)` | Skill info | +| `getInventory()` / `findInventoryItem(pattern)` | Inventory queries | +| `findNearbyNpc(pattern)` / `findNearbyLoc(pattern)` | Find nearby entities | +| `findGroundItem(pattern)` | Find ground items | +| `getDialog()` | Current dialog state | +| `sendClickDialog(option)` | Click dialog option | +| `sendClickComponent(id)` | Click interface button | +| `sendDropItem(slot)` | Drop inventory item | +| `sendUseItem(slot)` | Use inventory item (bury, etc.) | +| `sendUseItemOnItem(src, dst)` | Combine two items | +| `sendSay(message)` | Send chat message | +| `waitForCondition(pred)` | Wait for state predicate | +| `waitForTicks(n)` | Wait n game ticks | +| `scanNearbyLocs(radius?)` | Extended-range loc scan | + +--- + +### Dismiss Level-Up Dialogs + +```typescript +// In your main loop - always call this because level ups are blocking. +await bot.dismissBlockingUI(); + +// Or manually check +if (sdk.getState()?.dialog.isOpen) { + await sdk.sendClickDialog(0); +} +``` + +### Error Handling + +```typescript +const result = await bot.chopTree(); +if (!result.success) { + console.log(`Failed: ${result.message}`); + // Handle failure - maybe walk somewhere else +} +``` + +## Project Structure + +``` +server/ # game server infrastructure +├── engine/ # Game server core + +bots/ +└── {username}/ + ├── bot.env # Credentials (BOT_USERNAME, PASSWORD, SERVER) + ├── lab_log.md # Session notes and observations + └── script.ts # Current script + +sdk/ +├── index.ts # BotSDK (low-level) +├── actions.ts # BotActions (high-level) +├── cli.ts # CLI for checking state +└── types.ts # Type definitions + +learnings/ +├── banking.md +├── combat.md +├── shops.md +├── fletching.md +└── ...etc + +``` + +## Troubleshooting + +**"No state received"** - Bot isn't connected to game. Open browser first or use `autoLaunchBrowser: true`. + +**Script stalls** - Check for open dialogs (`state.dialog.isOpen`). Level-ups block everything. + +**"Can't reach"** - Path is blocked. Try walking closer first, or find a different target. + +**Wrong target** - Use more specific regex patterns: `/^tree$/i` not `/tree/i` (which matches "tree stump"). diff --git a/claude.md b/claude.md index ed73d1cc1..12a147cea 100644 --- a/claude.md +++ b/claude.md @@ -211,6 +211,58 @@ For the complete method reference, see **[sdk/API.md](sdk/API.md)** (auto-genera - `bot.*` - High-level actions that wait for effects to complete (chopTree, walkTo, attackNpc, etc.) - `sdk.*` - Low-level methods that resolve on server acknowledgment (sendWalk, getState, findNearbyNpc, etc.) +### bot.* Quick Reference + +| Method | What it does | +|--------|-------------| +| `walkTo(x, z, tolerance?)` | Pathfind to coords, opens doors along the way | +| `talkTo(target)` | Walk to NPC, start dialog | +| `interactNpc(target, option?)` | Walk to NPC, interact with any option (e.g. `'trade'`, `'fish'`) | +| `interactLoc(target, option?)` | Walk to loc, interact with any option (e.g. `'mine'`, `'smelt'`) | +| `attackNpc(target)` | Walk to NPC, start combat | +| `pickpocketNpc(target)` | Pickpocket NPC, detects XP gain vs stun | +| `castSpellOnNpc(target, spell)` | Cast combat spell on NPC | +| `chopTree(target?)` | Chop tree, wait for logs | +| `pickupItem(target)` | Pick up ground item | +| `openDoor(target?)` | Open a door or gate | +| `openBank()` | Open nearest bank | +| `depositItem(target, amount?)` | Deposit item to bank | +| `withdrawItem(slot, amount?)` | Withdraw item from bank | +| `openShop(target?)` | Open shop via shopkeeper NPC | +| `buyFromShop(target, amount?)` | Buy item from open shop | +| `sellToShop(target, amount?)` | Sell item to open shop | +| `equipItem(target)` | Equip from inventory | +| `unequipItem(target)` | Unequip to inventory | +| `eatFood(target)` | Eat food, returns HP gained | +| `useItemOnLoc(item, loc)` | Use inventory item on loc (e.g. fish on range) | +| `useItemOnNpc(item, npc)` | Use inventory item on NPC | +| `burnLogs(target?)` | Light logs with tinderbox | +| `fletchLogs(product?)` | Fletch logs with knife | +| `craftLeather(product?)` | Craft leather with needle | +| `smithAtAnvil(product)` | Smith bars at anvil | +| `dismissBlockingUI()` | Dismiss level-up dialogs (call in every loop) | +| `navigateDialog(choices)` | Auto-click through dialog options | +| `skipTutorial()` | Skip the tutorial island | + +### sdk.* Commonly Used Directly + +| Method | What it does | +|--------|-------------| +| `getState()` | Full world state snapshot | +| `getSkill(name)` / `getSkillXp(name)` | Skill info | +| `getInventory()` / `findInventoryItem(pattern)` | Inventory queries | +| `findNearbyNpc(pattern)` / `findNearbyLoc(pattern)` | Find nearby entities | +| `findGroundItem(pattern)` | Find ground items | +| `getDialog()` | Current dialog state | +| `sendClickDialog(option)` | Click dialog option | +| `sendClickComponent(id)` | Click interface button | +| `sendDropItem(slot)` | Drop inventory item | +| `sendUseItem(slot)` | Use inventory item (bury, etc.) | +| `sendUseItemOnItem(src, dst)` | Combine two items | +| `sendSay(message)` | Send chat message | +| `waitForCondition(pred)` | Wait for state predicate | +| `waitForTicks(n)` | Wait n game ticks | +| `scanNearbyLocs(radius?)` | Extended-range loc scan | --- @@ -239,15 +291,6 @@ if (!result.success) { ## Project Structure ``` -server/ # All game server infrastructure -├── engine/ # Game server core -├── gateway/ # WebSocket relay -├── webclient/ # Browser client -├── content/ # Game assets -├── vendor/ # External deps (rsmod-pathfinder) -├── Dockerfile # Server container build -├── fly.toml # Fly.io deployment config -└── start.ts # Server setup/management menu bots/ └── {username}/ diff --git a/sdk/API.md b/sdk/API.md index dfd2302ad..74c07b444 100644 --- a/sdk/API.md +++ b/sdk/API.md @@ -96,6 +96,9 @@ These methods wait for the **effect to complete**, not just server acknowledgmen |--------|-------------| | `useItemOnLoc(item, loc, options)` | Use an inventory item on a nearby location (e. | | `useItemOnNpc(item, npc, options)` | Use an inventory item on a nearby NPC (e. | +| `interactLoc(target, option)` | Interact with a nearby location object (rock, fishing spot, furnace, etc. | +| `interactNpc(target, option)` | Interact with a nearby NPC using a specified option (e. | +| `pickpocketNpc(target)` | Pickpocket an NPC. | --- @@ -523,3 +526,34 @@ interface UseItemOnNpcResult { reason?: 'item_not_found' | 'npc_not_found' | 'cant_reach' | 'timeout'; } ``` + +### InteractLocResult + +```typescript +interface InteractLocResult { + success: boolean; + message: string; + reason?: 'loc_not_found' | 'no_matching_option' | 'cant_reach' | 'timeout'; +} +``` + +### InteractNpcResult + +```typescript +interface InteractNpcResult { + success: boolean; + message: string; + reason?: 'npc_not_found' | 'no_matching_option' | 'cant_reach' | 'timeout'; +} +``` + +### PickpocketResult + +```typescript +interface PickpocketResult { + success: boolean; + message: string; + xpGained?: number; + reason?: 'npc_not_found' | 'no_pickpocket_option' | 'cant_reach' | 'stunned' | 'timeout'; +} +``` diff --git a/sdk/actions.ts b/sdk/actions.ts index a0371d8c3..0c842fefb 100644 --- a/sdk/actions.ts +++ b/sdk/actions.ts @@ -32,7 +32,10 @@ import type { BankDepositResult, BankWithdrawResult, UseItemOnLocResult, - UseItemOnNpcResult + UseItemOnNpcResult, + InteractLocResult, + InteractNpcResult, + PickpocketResult } from './types'; export class BotActions { @@ -613,54 +616,72 @@ export class BotActions { } } - /** Talk to an NPC and wait for dialog to open. */ + /** Talk to an NPC and wait for dialog to open. Walks to the NPC first (handling doors). */ async talkTo(target: NearbyNpc | string | RegExp): Promise { - return this.helpers.withDoorRetry( - () => this._talkToOnce(target), - (r) => !r.success && (r.message?.includes("can't reach") || r.message?.includes('Timed out')) - ); - } + await this.dismissBlockingUI(); - private async _talkToOnce(target: NearbyNpc | string | RegExp): Promise { const npc = this.helpers.resolveNpc(target); if (!npc) { return { success: false, message: 'NPC not found' }; } - const startTick = this.sdk.getState()?.tick || 0; + // Walk to the NPC first (handles doors) + if (npc.distance > 2) { + const walkResult = await this.walkTo(npc.x, npc.z, 2); + if (!walkResult.success) { + return { success: false, message: `Cannot reach ${npc.name}: ${walkResult.message}` }; + } + } - // Send talk directly - game client handles walk-to-interact via MOVE_OPCLICK - const result = await this.sdk.sendTalkToNpc(npc.index); + // Re-find the NPC after walking (it may have moved) + const npcPattern = typeof target === 'object' ? new RegExp(npc.name, 'i') : target; + const npcNow = this.helpers.resolveNpc(npcPattern); + if (!npcNow) { + return { success: false, message: `${npc.name} no longer visible` }; + } + + const startTick = this.sdk.getState()?.tick || 0; + let lastMoveTick = startTick; + let lastX = this.sdk.getState()?.player?.x ?? 0; + let lastZ = this.sdk.getState()?.player?.z ?? 0; + + const result = await this.sdk.sendTalkToNpc(npcNow.index); if (!result.success) { return { success: false, message: result.message }; } try { const finalState = await this.sdk.waitForCondition(state => { - // Check for "can't reach" messages + // Check for can't-reach messages for (const msg of state.gameMessages) { if (msg.tick > startTick) { const text = msg.text.toLowerCase(); - if (text.includes("can't reach") || text.includes("cannot reach")) { - return true; - } + if (text.includes("can't reach") || text.includes("cannot reach")) return true; } } - return state.dialog.isOpen; - }, 10000); - // Check if we got a "can't reach" message - for (const msg of finalState.gameMessages) { - if (msg.tick > startTick) { - const text = msg.text.toLowerCase(); - if (text.includes("can't reach") || text.includes("cannot reach")) { - return { success: false, message: `Cannot reach ${npc.name} - can't reach obstacle` }; - } + // Dialog opened — success + if (state.dialog.isOpen) return true; + + // Track movement + if (state.player && (state.player.x !== lastX || state.player.z !== lastZ)) { + lastX = state.player.x; + lastZ = state.player.z; + lastMoveTick = state.tick; } + + // Player idle for 2+ ticks with no dialog → give up + if (state.tick - lastMoveTick >= 2) return true; + + return false; + }, 30000); // safety net only + + if (this.helpers.checkCantReachMessage(startTick)) { + return { success: false, message: `Cannot reach ${npcNow.name}` }; } if (finalState.dialog.isOpen) { - return { success: true, dialog: finalState.dialog, message: `Talking to ${npc.name}` }; + return { success: true, dialog: finalState.dialog, message: `Talking to ${npcNow.name}` }; } return { success: false, message: 'Dialog did not open' }; @@ -2095,6 +2116,278 @@ export class BotActions { return { success: false, message: 'Smithing timed out', reason: 'timeout' }; } + + // ============ Porcelain: Generic Interactions ============ + + /** + * Interact with a nearby location object (rock, fishing spot, furnace, etc.). + * Walks to the target first (handling doors), sends the interaction, then waits + * for an effect (animation, dialog, interface) or detects failure when the player + * has been idle for 2 ticks with nothing happening. + * @param target - NearbyLoc object or name string/regex to find + * @param option - Option index or name regex to match (default: 1, the first option) + */ + async interactLoc( + target: NearbyLoc | string | RegExp, + option: number | string | RegExp = 1, + ): Promise { + await this.dismissBlockingUI(); + + const loc = this.helpers.resolveLocation(target, /./); + if (!loc) { + return { success: false, message: `Location not found: ${target}`, reason: 'loc_not_found' }; + } + + // Resolve option index + let opIndex: number; + if (typeof option === 'number') { + opIndex = option; + } else { + const regex = typeof option === 'string' ? new RegExp(option, 'i') : option; + const match = loc.optionsWithIndex.find(o => regex.test(o.text)); + if (!match) { + return { success: false, message: `No matching option on ${loc.name}`, reason: 'no_matching_option' }; + } + opIndex = match.opIndex; + } + + // Walk to the location first (handles doors) + if (loc.distance > 2) { + const walkResult = await this.walkTo(loc.x, loc.z, 2); + if (!walkResult.success) { + return { success: false, message: `Cannot reach ${loc.name}: ${walkResult.message}`, reason: 'cant_reach' }; + } + } + + // Re-find the location after walking (it may have changed) + const locPattern = typeof target === 'object' ? new RegExp(loc.name, 'i') : target; + const locNow = this.helpers.resolveLocation(locPattern, /./); + if (!locNow) { + return { success: false, message: `${loc.name} no longer visible`, reason: 'loc_not_found' }; + } + + const startTick = this.sdk.getState()?.tick || 0; + let lastMoveTick = startTick; + let lastX = this.sdk.getState()?.player?.x ?? 0; + let lastZ = this.sdk.getState()?.player?.z ?? 0; + + const result = await this.sdk.sendInteractLoc(locNow.x, locNow.z, locNow.id, opIndex); + if (!result.success) { + return { success: false, message: result.message, reason: 'timeout' }; + } + + try { + const finalState = await this.sdk.waitForCondition(state => { + // Check for can't-reach messages + for (const msg of state.gameMessages) { + if (msg.tick > startTick) { + const text = msg.text.toLowerCase(); + if (text.includes("can't reach") || text.includes("cannot reach")) return true; + } + } + + // Success indicators + if (state.dialog.isOpen || state.interface?.isOpen) return true; + if (state.player && state.player.animId !== -1) return true; + + // Track movement — if player moved, update last move tick + if (state.player && (state.player.x !== lastX || state.player.z !== lastZ)) { + lastX = state.player.x; + lastZ = state.player.z; + lastMoveTick = state.tick; + } + + // Player idle for 2+ ticks with nothing happening → give up + if (state.tick - lastMoveTick >= 2) return true; + + return false; + }, 30000); // safety net only + + if (this.helpers.checkCantReachMessage(startTick)) { + return { success: false, message: `Can't reach ${locNow.name}`, reason: 'cant_reach' }; + } + + if (finalState.dialog.isOpen || finalState.interface?.isOpen || + (finalState.player && finalState.player.animId !== -1)) { + return { success: true, message: `Interacted with ${locNow.name}` }; + } + + return { success: false, message: `Nothing happened interacting with ${locNow.name}`, reason: 'timeout' }; + } catch { + return { success: false, message: `Timed out interacting with ${locNow.name}`, reason: 'timeout' }; + } + } + + /** + * Interact with a nearby NPC using a specified option (e.g. "Trade", "Pickpocket", "Fish"). + * Walks to the NPC first (handling doors), sends the interaction, then waits + * for an effect (animation, dialog, interface) or detects failure when the player + * has been idle for 2 ticks with nothing happening. + * @param target - NearbyNpc object or name string/regex to find + * @param option - Option index or name regex to match (default: 1, the first option) + */ + async interactNpc( + target: NearbyNpc | string | RegExp, + option: number | string | RegExp = 1, + ): Promise { + await this.dismissBlockingUI(); + + const npc = this.helpers.resolveNpc(target); + if (!npc) { + return { success: false, message: `NPC not found: ${target}`, reason: 'npc_not_found' }; + } + + // Resolve option index + let opIndex: number; + if (typeof option === 'number') { + opIndex = option; + } else { + const regex = typeof option === 'string' ? new RegExp(option, 'i') : option; + const match = npc.optionsWithIndex.find(o => regex.test(o.text)); + if (!match) { + return { success: false, message: `No matching option on ${npc.name}`, reason: 'no_matching_option' }; + } + opIndex = match.opIndex; + } + + // Walk to the NPC first (handles doors) + if (npc.distance > 2) { + const walkResult = await this.walkTo(npc.x, npc.z, 2); + if (!walkResult.success) { + return { success: false, message: `Cannot reach ${npc.name}: ${walkResult.message}`, reason: 'cant_reach' }; + } + } + + // Re-find the NPC after walking (it may have moved) + const npcPattern = typeof target === 'object' ? new RegExp(npc.name, 'i') : target; + const npcNow = this.helpers.resolveNpc(npcPattern); + if (!npcNow) { + return { success: false, message: `${npc.name} no longer visible`, reason: 'npc_not_found' }; + } + + const startTick = this.sdk.getState()?.tick || 0; + let lastMoveTick = startTick; + let lastX = this.sdk.getState()?.player?.x ?? 0; + let lastZ = this.sdk.getState()?.player?.z ?? 0; + + const result = await this.sdk.sendInteractNpc(npcNow.index, opIndex); + if (!result.success) { + return { success: false, message: result.message, reason: 'timeout' }; + } + + try { + const finalState = await this.sdk.waitForCondition(state => { + // Check for can't-reach messages + for (const msg of state.gameMessages) { + if (msg.tick > startTick) { + const text = msg.text.toLowerCase(); + if (text.includes("can't reach") || text.includes("cannot reach")) return true; + } + } + + // Success indicators + if (state.dialog.isOpen || state.interface?.isOpen) return true; + if (state.player && state.player.animId !== -1) return true; + + // Track movement — if player moved, update last move tick + if (state.player && (state.player.x !== lastX || state.player.z !== lastZ)) { + lastX = state.player.x; + lastZ = state.player.z; + lastMoveTick = state.tick; + } + + // Player idle for 2+ ticks with nothing happening → give up + if (state.tick - lastMoveTick >= 2) return true; + + return false; + }, 30000); // safety net only + + if (this.helpers.checkCantReachMessage(startTick)) { + return { success: false, message: `Can't reach ${npcNow.name}`, reason: 'cant_reach' }; + } + + if (finalState.dialog.isOpen || finalState.interface?.isOpen || + (finalState.player && finalState.player.animId !== -1)) { + return { success: true, message: `Interacted with ${npcNow.name}` }; + } + + return { success: false, message: `Nothing happened interacting with ${npcNow.name}`, reason: 'timeout' }; + } catch { + return { success: false, message: `Timed out interacting with ${npcNow.name}`, reason: 'timeout' }; + } + } + + // ============ Porcelain: Thieving ============ + + /** Pickpocket an NPC. Handles door retrying if path is blocked. */ + async pickpocketNpc(target: NearbyNpc | string | RegExp): Promise { + return this.helpers.withDoorRetry( + () => this._pickpocketNpcOnce(target), + (r) => r.reason === 'cant_reach' || r.reason === 'timeout' + ); + } + + private async _pickpocketNpcOnce(target: NearbyNpc | string | RegExp): Promise { + const npc = this.helpers.resolveNpc(target); + if (!npc) { + return { success: false, message: `NPC not found: ${target}`, reason: 'npc_not_found' }; + } + + const pickOpt = npc.optionsWithIndex.find(o => /pickpocket/i.test(o.text)); + if (!pickOpt) { + return { success: false, message: `No pickpocket option on ${npc.name}`, reason: 'no_pickpocket_option' }; + } + + const thievingBefore = this.sdk.getSkill('Thieving')?.experience || 0; + const startTick = this.sdk.getState()?.tick || 0; + + const result = await this.sdk.sendInteractNpc(npc.index, pickOpt.opIndex); + if (!result.success) { + return { success: false, message: result.message, reason: 'timeout' }; + } + + try { + const finalState = await this.sdk.waitForCondition(state => { + // Check for XP gain + const thievingNow = state.skills.find(s => s.name === 'Thieving')?.experience || 0; + if (thievingNow > thievingBefore) return true; + + // Check game messages for stun/catch or can't reach + for (const msg of state.gameMessages) { + if (msg.tick > startTick) { + const text = msg.text.toLowerCase(); + if (text.includes('stunned') || text.includes('caught') || text.includes('stun')) return true; + if (text.includes("can't reach") || text.includes('cannot reach')) return true; + } + } + + return false; + }, 10000); + + // Check what happened + for (const msg of finalState.gameMessages) { + if (msg.tick > startTick) { + const text = msg.text.toLowerCase(); + if (text.includes("can't reach") || text.includes('cannot reach')) { + return { success: false, message: `Can't reach ${npc.name}`, reason: 'cant_reach' }; + } + if (text.includes('stunned') || text.includes('caught') || text.includes('stun')) { + return { success: false, message: `Stunned while pickpocketing ${npc.name}`, reason: 'stunned' }; + } + } + } + + const thievingAfter = this.sdk.getSkill('Thieving')?.experience || 0; + const xpGained = thievingAfter - thievingBefore; + if (xpGained > 0) { + return { success: true, message: `Pickpocketed ${npc.name}`, xpGained }; + } + + return { success: false, message: `Pickpocket failed on ${npc.name}`, reason: 'timeout' }; + } catch { + return { success: false, message: `Timed out pickpocketing ${npc.name}`, reason: 'timeout' }; + } + } } // Re-export for convenience diff --git a/sdk/types.ts b/sdk/types.ts index 7b0c44357..e2df78090 100644 --- a/sdk/types.ts +++ b/sdk/types.ts @@ -483,3 +483,22 @@ export interface UseItemOnNpcResult { message: string; reason?: 'item_not_found' | 'npc_not_found' | 'cant_reach' | 'timeout'; } + +export interface InteractLocResult { + success: boolean; + message: string; + reason?: 'loc_not_found' | 'no_matching_option' | 'cant_reach' | 'timeout'; +} + +export interface InteractNpcResult { + success: boolean; + message: string; + reason?: 'npc_not_found' | 'no_matching_option' | 'cant_reach' | 'timeout'; +} + +export interface PickpocketResult { + success: boolean; + message: string; + xpGained?: number; + reason?: 'npc_not_found' | 'no_pickpocket_option' | 'cant_reach' | 'stunned' | 'timeout'; +}