better pathfinding

This commit is contained in:
max bittker committed 2026-02-08 02:13:46 -05:00
1 parent c94893f211
commit 4abfa3c73f
6 files changed
+752 -34

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
[mcp_servers.rs-agent]
command = "bun"
args = ["run", "mcp/server.ts"]
+326
View File
@@ -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").
+52 -9
View File
@@ -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}/
+34
View File
@@ -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';
}
```
+318 -25
View File
@@ -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<TalkResult> {
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<TalkResult> {
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<InteractLocResult> {
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<InteractNpcResult> {
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<PickpocketResult> {
return this.helpers.withDoorRetry(
() => this._pickpocketNpcOnce(target),
(r) => r.reason === 'cant_reach' || r.reason === 'timeout'
);
}
private async _pickpocketNpcOnce(target: NearbyNpc | string | RegExp): Promise<PickpocketResult> {
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
+19
View File
@@ -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';
}