Files
rs-sdk/sdk/actions-helpers.ts
max bittkerandClaude Opus 5.5 df3d8888ae feat(sdk,mcp): cancellable execute_code, bot.mineRock, bounded walkTo
execute_code
- Cancellation now reaches inside the SDK via an AsyncLocalStorage scope:
  timeout, MCP cancel, or a newer call rejects pending waits and blocks
  further action packets, including those inside walkTo().
- One execution per bot: a new call supersedes the running one and starts
  once it has unwound; disconnect_bot does the same.
- Cancelled user code parks on bot/sdk calls instead of throwing, so a
  catch-all loop can't spin and starve the server.
- Console capture is routed per execution, so concurrent bots no longer
  clobber each other's output; leftover logs go to stderr.
- Stale world state warns and still runs, so a stale client can be diagnosed.
- Credentials are redacted from logs, results and launch messages.

SDK
- bot.mineRock() waits for ore plus Mining XP; interactLoc docs note that
  success can mean the action only started.
- walkTo() takes options.timeout (default 120s), refuses stale state, and
  reports the real last position.
- runScript uses the same cancellation scope for timeouts and signals.

Docs trimmed of duplicated or stale execute_code text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:30:19 -04:00

639 lines
26 KiB
TypeScript

// Bot SDK - Action Helpers
// Private helper methods extracted from BotActions for reusability
import { BotSDK } from './index';
import { executionWait } from './execution-context';
import { shortestNameMatch } from './action-quantity';
import type {
NearbyLoc,
NearbyNpc,
NearbyPlayer,
CombatTarget,
InventoryItem,
GroundItem,
ShopItem,
BotWorldState,
} from './types';
/**
* Server refusals that stop a PvP attack or spell before it happens, lowercased.
* From `~pvp_is_attackable` and friends in
* server/content/scripts/skill_combat/scripts/pvp/pvp_combat.rs2.
*/
export const PVP_REFUSALS = [
"you can't attack players who aren't in the wilderness",
'your level difference is too great',
"i'm already under attack",
'someone else is already fighting your opponent',
'kolodion allows only magical combat'
] as const;
/**
* Refusals that clear on their own: single-way combat locks a fight to two
* participants for 8 ticks after the last hit, so retrying later can work. The
* other {@link PVP_REFUSALS} need you to move or pick a different opponent.
*
* Both entity spaces are here - the npc wording comes from player_combat.rs2
* ("I'm already under attack!", "Someone else is fighting that.") and the pvp
* wording from pvp_combat.rs2.
*/
export const ALREADY_FIGHTING_REFUSALS = [
'already under attack',
'someone else is fighting',
'someone else is already fighting your opponent'
] as const;
/** "You do not have enough..." / "You don't have enough...", the missing-rune refusal. */
export const NO_RUNES_REFUSALS = [
'do not have enough',
"don't have enough"
] as const;
export class ActionHelpers {
constructor(private sdk: BotSDK) {}
/**
* The first message published since a getMessageTick() cursor that contains
* one of `phrases` (lowercase), or null. Refusals are the reason a combat
* action silently does nothing: the packet lands, the server checks its
* rules, and answers with a game message instead of an effect.
*/
findRefusal(sinceMessageTick: number, phrases: readonly string[]): string | null {
const state = this.sdk.getState();
if (!state) return null;
for (const msg of state.gameMessages) {
if (this.isMessageAfter(msg, sinceMessageTick)) {
const text = msg.text.toLowerCase();
if (phrases.some(phrase => text.includes(phrase))) {
return msg.text;
}
}
}
return null;
}
/** The first PvP refusal since a getMessageTick() cursor, or null. */
findPvpRefusal(sinceMessageTick: number): string | null {
return this.findRefusal(sinceMessageTick, PVP_REFUSALS);
}
// ============ Door Retry Wrapper ============
/**
* Wraps an action with automatic door-opening and side-repositioning retry logic.
* If the action fails due to "can't reach", tries to open a nearby door and retries.
* If no door is found and adjacentTarget is provided, tries approaching from different
* cardinal sides (N/S/E/W) before retrying — useful for wall-adjacent locations like
* fireplaces, ranges, and furnaces.
*
* @param action - Function that performs the action and returns a result
* @param shouldRetry - Function that checks if the result indicates a "can't reach" failure
* @param maxRetries - Maximum number of retries (default 2)
* @param adjacentTarget - Optional coords to try approaching from different sides
* @returns The action result (either successful or final failure)
*
* @example
* ```ts
* return this.helpers.withDoorRetry(
* () => this._pickupItemOnce(target),
* (r) => r.reason === 'cant_reach',
* 2,
* { x: loc.x, z: loc.z }
* );
* ```
*/
async withDoorRetry<T extends { success: boolean }>(
action: () => Promise<T>,
shouldRetry: (result: T) => boolean,
maxRetries: number = 2,
adjacentTarget?: { x: number; z: number }
): Promise<T> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const result = await action();
// Success or non-retryable failure
if (result.success || !shouldRetry(result)) {
return result;
}
if (attempt < maxRetries) {
// Try approaching from a different side (common case for
// wall-adjacent locs like fireplaces, bank booths, furnaces)
if (adjacentTarget) {
const repositioned = await this.walkAdjacentTo(adjacentTarget.x, adjacentTarget.z);
if (repositioned) {
await this.sdk.waitForTicks(1);
continue;
}
}
// Only try doors if repositioning didn't help, with a tight radius
// to avoid walking to random distant doors
const doorOpened = await this.tryOpenBlockingDoor(5);
if (doorOpened) {
await this.sdk.waitForTicks(1);
continue;
}
}
// Nothing worked or max retries reached
return result;
}
// TypeScript needs this, but it's unreachable
return action();
}
// ============ Door Handling ============
/**
* Try to find and open a nearby blocking door/gate/fence.
* Walks to the door using raw sendWalk (not walkTo) to avoid recursion.
* @param maxDistance - Maximum distance to search for openable objects (default 15 tiles)
* @returns true if something was successfully opened
*/
async tryOpenBlockingDoor(maxDistance: number = 15): Promise<boolean> {
// Look for any loc with an "Open" option - covers doors, gates, fences, pens, etc.
const openables = this.sdk.getNearbyLocs()
.filter(l => l.optionsWithIndex.some(o => /^open$/i.test(o.text)))
.filter(l => l.distance <= maxDistance)
.sort((a, b) => a.distance - b.distance);
if (openables.length === 0) {
return false;
}
const door = openables[0]!;
const result = await this.openDoorAt(door.x, door.z);
return result === 'opened' || result === 'already_open';
}
/**
* Get the public tick of the most recent game message. Use this before an
* action to establish a baseline for checkCantReachMessage.
*/
getMessageTick(): number {
const state = this.sdk.getState();
if (!state?.gameMessages?.length) return 0;
// Take the max rather than indexing an end: StateCollector emits messages
// chronologically (oldest-first), but don't depend on that ordering here —
// reading the wrong end silently hands back a stale baseline, which makes
// every "since this tick" check match ancient messages.
let maxTick = 0;
for (const msg of state.gameMessages) {
const cursor = msg.observationId ?? msg.tick;
if (cursor > maxTick) maxTick = cursor;
}
return maxTick;
}
/** True when a message was published after a cursor from getMessageTick(). */
isMessageAfter(message: { tick: number; observationId?: number }, cursor: number): boolean {
return (message.observationId ?? message.tick) > cursor;
}
// ============ Op Rejection ============
/**
* Snapshot the op-rejection counter. Take this immediately before sending an
* op, hand it to wasOpRejectedSince() afterwards.
*
* Returns -1 when the connected client is too old to publish opFeedback,
* which wasOpRejectedSince() then reports as "no rejection" - the signal is
* an optimisation, never a precondition.
*/
opRejectionCursor(): number {
return this.sdk.getState()?.opFeedback?.opRejectedCount ?? -1;
}
/**
* True when the server has probably discarded an op since the cursor. Read
* it as "that click did nothing, try again", not as a diagnosis - nothing
* observable separates the possible causes (blocking modal, active stun or
* action delay, target out of view, option the target no longer has).
*/
wasOpRejectedSince(cursor: number, state?: BotWorldState): boolean {
if (cursor < 0) return false;
const feedback = (state ?? this.sdk.getState())?.opFeedback;
if (!feedback) return false;
return feedback.opRejectedCount > cursor;
}
/**
* Check recent game messages for "can't reach" indicators.
* @param sinceMessageTick - Only check messages with tick > this value.
*/
checkCantReachMessage(sinceMessageTick: number): boolean {
const state = this.sdk.getState();
if (!state) return false;
for (const msg of state.gameMessages) {
if (this.isMessageAfter(msg, sinceMessageTick)) {
const text = msg.text.toLowerCase();
if (text.includes("can't reach") || text.includes("cannot reach") || text.includes("i can't reach")) {
return true;
}
}
}
return false;
}
// ============ Walk Adjacent ============
/**
* Walk to a tile adjacent to the given coordinates using raw sendWalk.
* Picks the closest cardinal-adjacent tile to the player.
* Used before interacting with doors/gates to avoid server-side pathfinding
* through the very door we're trying to open.
* @returns true if already adjacent or successfully walked adjacent
*/
async walkAdjacentTo(targetX: number, targetZ: number): Promise<boolean> {
const playerState = this.sdk.getState()?.player;
if (!playerState) return false;
const px = playerState.worldX;
const pz = playerState.worldZ;
const dx = Math.abs(px - targetX);
const dz = Math.abs(pz - targetZ);
const isAdjacent = (dx <= 1 && dz <= 1) && (dx + dz > 0);
if (isAdjacent) return true;
// Sort candidates by distance from player (closest first)
const candidates = [
{ x: targetX, z: targetZ - 1 },
{ x: targetX, z: targetZ + 1 },
{ x: targetX - 1, z: targetZ },
{ x: targetX + 1, z: targetZ },
].sort((a, b) => {
const da = Math.abs(a.x - px) + Math.abs(a.z - pz);
const db = Math.abs(b.x - px) + Math.abs(b.z - pz);
return da - db;
});
// Try each candidate — the closest one may be on the other side of a wall
for (const target of candidates) {
await this.sdk.sendWalk(target.x, target.z, true);
const result = await this.waitForMovementComplete(target.x, target.z, 1);
if (result.arrived) return true;
// Check if we ended up adjacent to the target even if not at the candidate tile
const nowX = result.x;
const nowZ = result.z;
const adjDx = Math.abs(nowX - targetX);
const adjDz = Math.abs(nowZ - targetZ);
if ((adjDx <= 1 && adjDz <= 1) && (adjDx + adjDz > 0)) return true;
}
return false;
}
// ============ Movement Helpers ============
async waitForMovementComplete(
targetX: number,
targetZ: number,
tolerance: number = 3
): Promise<{ arrived: boolean; stoppedMoving: boolean; x: number; z: number }> {
// All logic is tick-based so it scales with any server tick rate.
// Running = 2 tiles/tick. Walking = 1 tile/tick.
const TILES_PER_TICK = 2;
const STUCK_TICKS = 2; // 2 ticks of no movement = stuck
const MIN_TICKS = 3; // minimum ticks to wait
const SAFETY_MS = 15_000; // hard ms failsafe if state updates stop entirely
const startState = this.sdk.getState();
if (!startState?.player) {
return { arrived: false, stoppedMoving: true, x: 0, z: 0 };
}
const startX = startState.player.worldX;
const startZ = startState.player.worldZ;
const startTick = startState.tick;
const distance = this.distance(startX, startZ, targetX, targetZ);
const expectedTicks = Math.ceil(distance / TILES_PER_TICK);
const maxTicks = Math.max(MIN_TICKS, Math.ceil(expectedTicks * 1.5));
let lastX = startX;
let lastZ = startZ;
let lastMoveTick = startTick;
return executionWait((resolve) => {
let resolved = false;
const done = (result: { arrived: boolean; stoppedMoving: boolean; x: number; z: number }) => {
if (resolved) return;
resolved = true;
resolve(result);
};
// Hard ms failsafe in case state updates stop arriving
const safetyTimer = setTimeout(() => {
const s = this.sdk.getState()?.player;
const fx = s?.worldX ?? lastX;
const fz = s?.worldZ ?? lastZ;
const fd = this.distance(fx, fz, targetX, targetZ);
done({ arrived: fd <= tolerance, stoppedMoving: true, x: fx, z: fz });
}, SAFETY_MS);
const unsub = this.sdk.onStateUpdate((state) => {
if (!state?.player) return;
const currentX = state.player.worldX;
const currentZ = state.player.worldZ;
const currentTick = state.tick;
// Check arrival
const distToTarget = this.distance(currentX, currentZ, targetX, targetZ);
if (distToTarget <= tolerance) {
done({ arrived: true, stoppedMoving: false, x: currentX, z: currentZ });
return;
}
// Track movement by tick
if (currentX !== lastX || currentZ !== lastZ) {
lastMoveTick = currentTick;
lastX = currentX;
lastZ = currentZ;
}
// Stuck: no movement for STUCK_TICKS
if (currentTick - lastMoveTick >= STUCK_TICKS) {
done({ arrived: false, stoppedMoving: true, x: currentX, z: currentZ });
return;
}
// Tick budget exceeded
if (currentTick - startTick >= maxTicks) {
done({ arrived: distToTarget <= tolerance, stoppedMoving: true, x: currentX, z: currentZ });
}
});
return () => { clearTimeout(safetyTimer); unsub(); };
});
}
// ============ Walk Step Helper ============
/**
* Take a single walk step toward a target and report the result.
* Used by walkTo to avoid duplicating walk-and-check logic.
*/
async walkStepToward(
targetX: number,
targetZ: number,
tolerance: number,
lastPos: { x: number; z: number }
): Promise<{ status: 'arrived' | 'progress' | 'stuck'; pos: { x: number; z: number } }> {
const dispatch = await this.sdk.sendWalk(targetX, targetZ, true);
if (!dispatch.success) return { status: 'stuck', pos: lastPos };
const moveResult = await this.waitForMovementComplete(targetX, targetZ, tolerance);
const pos = this.sdk.getState()?.player;
if (!pos) {
return { status: 'stuck', pos: lastPos };
}
const currentPos = { x: pos.worldX, z: pos.worldZ };
// Check if arrived
const distToTarget = this.distance(currentPos.x, currentPos.z, targetX, targetZ);
if (distToTarget <= tolerance) {
return { status: 'arrived', pos: currentPos };
}
// Check if stuck (didn't move much and stopped)
const moved = Math.sqrt(
Math.pow(currentPos.x - lastPos.x, 2) + Math.pow(currentPos.z - lastPos.z, 2)
);
if (moved < 2 && moveResult.stoppedMoving) {
return { status: 'stuck', pos: currentPos };
}
return { status: 'progress', pos: currentPos };
}
/**
* Tile distance between two points - Chebyshev, the game's own metric.
* Adjacency (including diagonal) is 1, so a tolerance of 1 accepts a
* diagonally adjacent tile; the old Euclidean 1.41 rejected it.
*/
distance(x1: number, z1: number, x2: number, z2: number): number {
return Math.max(Math.abs(x2 - x1), Math.abs(z2 - z1));
}
// ============ Specific Door Opening ============
/**
* Open a specific door at exact coordinates.
* Used by proactive door-opening when the pathfinder identifies doors along the route.
* @returns true if the door was opened (or was already open)
*/
/**
* Try to open a door at the given coordinates.
* Returns: 'opened' | 'already_open' | 'locked' | 'cant_reach' | 'not_found'
*/
async openDoorAt(doorX: number, doorZ: number): Promise<'opened' | 'already_open' | 'locked' | 'cant_reach' | 'not_found'> {
const locs = this.sdk.getNearbyLocs();
const door = locs.find(l => l.x === doorX && l.z === doorZ);
if (!door) return 'not_found';
const openOpt = door.optionsWithIndex.find(o => /^open$/i.test(o.text));
if (!openOpt) return 'already_open'; // Already open (has Close option instead)
// Helper: attempt interact and wait for result
const tryInteract = async (): Promise<'opened' | 'locked' | 'cant_reach' | 'timeout'> => {
const msgBaseline = this.getMessageTick();
await this.sdk.sendInteractLoc(doorX, doorZ, door.id, openOpt.opIndex);
try {
let failReason: 'locked' | 'cant_reach' | null = null;
await this.sdk.waitForCondition(state => {
for (const msg of state.gameMessages) {
if (this.isMessageAfter(msg, msgBaseline)) {
const text = msg.text.toLowerCase();
if (text.includes("locked")) { failReason = 'locked'; return true; }
if (text.includes("can't reach") || text.includes("cannot reach")) { failReason = 'cant_reach'; return true; }
}
}
const doorNow = state.nearbyLocs.find(l =>
l.x === doorX && l.z === doorZ && l.id === door.id
);
if (!doorNow) return true; // Door gone = opened
return !doorNow.optionsWithIndex.some(o => /^open$/i.test(o.text));
}, 8000);
if (failReason) return failReason;
// Verify door actually opened
const doorAfter = this.sdk.getState()?.nearbyLocs.find(l =>
l.x === doorX && l.z === doorZ && l.id === door.id
);
if (!doorAfter || !doorAfter.optionsWithIndex.some(o => /^open$/i.test(o.text))) {
return 'opened';
}
return 'timeout';
} catch {
return 'timeout';
}
};
// Attempt 1: interact directly — let the game server walk us to the door
const result1 = await tryInteract();
if (result1 === 'opened') return 'opened';
if (result1 === 'locked') return 'locked';
// Attempt 2: walk adjacent manually then retry interact
// This handles cases where server-side pathfinding can't route through the closed door
const adjacent = await this.walkAdjacentTo(doorX, doorZ);
if (!adjacent) return 'cant_reach';
const result2 = await tryInteract();
if (result2 === 'opened') return 'opened';
if (result2 === 'locked') return 'locked';
return 'cant_reach';
}
// ============ Resolution Helpers ============
/**
* Compact signature of the inventory for change detection. Some server
* scripts (cow milking, flour bin) produce no animation, dialog, or
* distinctive message — the item swap is the only observable evidence.
*/
inventorySignature(state: { inventory?: { slot: number; id: number; count: number }[] } | null): string {
if (!state?.inventory) return '';
return state.inventory.map(i => `${i.slot}:${i.id}:${i.count}`).join(',');
}
/**
* Re-find a previously resolved loc by identity (id + tile) in current
* state. Name-based re-resolution picks the nearest same-named loc, which
* is the wrong one when duplicates sit within a few tiles (Varrock cart
* crates). Returns null if that exact loc is no longer visible.
*/
refindLocation(original: { id: number; x: number; z: number }): NearbyLoc | null {
return this.sdk.getState()?.nearbyLocs.find(
l => l.id === original.id && l.x === original.x && l.z === original.z
) ?? null;
}
resolveLocation(
target: NearbyLoc | string | RegExp | undefined,
defaultPattern: RegExp,
withOption?: string | RegExp
): NearbyLoc | null {
if (typeof target === 'object' && target !== null && 'x' in target) {
// A loc reference carried across a climb/descend is stale: the
// click would resolve against whatever occupies these coordinates
// on the wrong floor. Re-resolve from current state instead, and
// fail loudly (null -> loc_not_found) if it isn't on this plane.
const playerLevel = this.sdk.getState()?.player?.level;
if (playerLevel === undefined || target.level === undefined || target.level === playerLevel) {
return target;
}
return this.sdk.getState()?.nearbyLocs.find(
l => l.id === target.id && l.x === target.x && l.z === target.z
) ?? null;
}
const pattern = target ?? defaultPattern;
if (withOption !== undefined) {
// Prefer a loc that actually offers the requested option -
// overlapping option-less duplicates (Lumbridge furnace) otherwise
// win on distance and dead-end the interaction. Fall back to plain
// name matching so the "no matching option" error can still name
// the loc it found.
const withOptionMatch = this.sdk.findNearbyLoc(pattern, { withOption });
if (withOptionMatch) return withOptionMatch;
}
return this.sdk.findNearbyLoc(pattern);
}
resolveInventoryItem(
target: InventoryItem | string | RegExp | undefined,
defaultPattern: RegExp
): InventoryItem | null {
if (!target) {
return this.sdk.findInventoryItem(defaultPattern);
}
if (typeof target === 'object' && 'slot' in target) {
return target;
}
return this.sdk.findInventoryItem(target);
}
resolveGroundItem(target: GroundItem | string | RegExp): GroundItem | null {
if (typeof target === 'object' && 'x' in target) {
return target;
}
return this.sdk.findGroundItem(target);
}
resolveNpc(target: NearbyNpc | string | RegExp): NearbyNpc | null {
if (typeof target === 'object' && 'index' in target) {
return target;
}
return this.sdk.findNearbyNpc(target);
}
/**
* Re-find an NPC the caller already chose, by its server slot `index`.
*
* The index is the entity handle interact packets address and it survives
* the npc walking (or a fishing spot relocating). Re-resolving by name
* instead hands the action to whichever same-named neighbour is nearest on
* arrival - the wrong fishing spot, the wrong "Man". Returns null when the
* slot is no longer in view.
*/
refindNpc(original: { index: number }): NearbyNpc | null {
return this.sdk.getState()?.nearbyNpcs.find(n => n.index === original.index) ?? null;
}
/**
* After walking, re-resolve an npc target: a pattern re-finds by pattern
* (keeping the caller's anchors), an entity re-finds by identity.
*/
refindNpcTarget(target: NearbyNpc | string | RegExp, resolved: NearbyNpc): NearbyNpc | null {
if (typeof target === 'object' && 'index' in target) {
return this.refindNpc(resolved);
}
return this.resolveNpc(target);
}
/**
* Resolve a combat target that may be an npc or another player.
*
* A passed-in entity is used as-is (its `kind` says which it is). A name or
* pattern is matched against nearby NPCs first and players second, so a
* player called "Goblin" standing next to a goblin does not steal the fight;
* pass the entity from `sdk.findNearbyPlayer(...)` when you mean the player.
*/
resolveCombatTarget(target: CombatTarget): NearbyNpc | NearbyPlayer | null {
if (typeof target === 'object' && 'index' in target) {
if (Array.isArray((target as { optionsWithIndex?: unknown }).optionsWithIndex)) {
return target;
}
// A bare {index, name} handle (kept across ticks by a re-attack
// loop) is not a live entry: reading .optionsWithIndex off it used
// to throw and silently stop the fight loop. Re-find the live entry
// by its server slot instead.
const state = this.sdk.getState();
const kind = (target as { kind?: string }).kind;
const npc = kind === 'player' ? null : state?.nearbyNpcs.find(n => n.index === target.index) ?? null;
const player = kind === 'npc' ? null : state?.nearbyPlayers.find(p => p.index === target.index) ?? null;
return npc ?? player ?? null;
}
return this.sdk.findNearbyNpc(target) ?? this.sdk.findNearbyPlayer(target);
}
resolveShopItem(
target: ShopItem | InventoryItem | string | RegExp,
items: ShopItem[]
): ShopItem | null {
if (typeof target === 'object' && 'id' in target && 'name' in target) {
return items.find(i => i.id === target.id) ?? null;
}
return shortestNameMatch(items, target);
}
}