mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
better pathfinding
This commit is contained in:
1 parent
c94893f211
commit
4abfa3c73f
6 files changed
+752
-34
No files matched your search
@@ -0,0 +1,3 @@
|
||||
[mcp_servers.rs-agent]
|
||||
command = "bun"
|
||||
args = ["run", "mcp/server.ts"]
|
||||
@@ -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").
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
Reference in new issue
Block a user