mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
fix(sdk,clients): bug-report harvest 2026-08-16 - dialog choice regression, false failures, Windows paths
78 new reports since 8/12 triaged; the tradeoff-free fixes:
- dismissBlockingUI no longer clicks multi-option choice dialogs. The 8/12
fix (9afd6d961) made it click options[0].index, which ANSWERS a p_choice
as option 1 before every bot action - the wrong quest branch ran and the
caller's intended click was silently refused as stale. Root-caused via
live repro; the published index/componentId mapping was never wrong.
Blocks-quests class (Restless Ghost, Rune Mysteries, Al Kharid gate).
- interactLoc/useItemOnLoc/useItemOnNpc/equipItem accept real evidence:
same-level teleports (dungeon trapdoors, z +/-6400), inventory deltas
(cow milking, flour bin), late equipment arrival. No more false failures
on actions that worked.
- 'interactLoc froze the event loop' root cause was lazy synchronous
initPathfinding() (~30s on slow hosts) killing the socket mid-action:
runner + MCP server now warm it before connecting. Runner also exits
non-zero on signals (128+n), bounds disconnect, force-exits a wedged
event loop, and honors a second signal immediately.
- ground items on blocked tiles: browser client now mirrors the authentic
OP_OBJ handler (retry route with 1x1 footprint, dispatch regardless);
lite ground-item op dispatches unconditionally too. Unblocks Monk's
Friend / Plague City / Witch's House pickups.
- interactLoc/useItemOnLoc re-find an entity target by (id,x,z) identity,
not name - same-named neighbours (Varrock cart crates) can't win.
- lite runner/swarm repoRoot via fileURLToPath - Windows and
space-containing clones can run headless again (4 reports).
- smithAtAnvil: longest-key product match ('mithril longsword' no longer
smiths a sword), metal-prefix bar selection, honest smithed-product
report; castSpell resolves known spell names, rejects unknown ones, and
no longer reports a never-dispatched cast as a splash; buyFromShop fails
fast with out_of_stock/no_inventory_space; depositItem -1 with an
alternation pattern deposits every matching id; openShop falls back to
the nearest Trade-option NPC when called with no target; cli.ts honors
blank SERVER= as local instead of diagnosing against the demo gateway.
- NearbyNpc publishes tileX/tileZ (authoritative route tile) and size;
x/z are interpolated render coords, off by 1-3 tiles on movers and
floor(size/2) on multi-tile NPCs. Also lands the isSkulled/
autoRetaliateEnabled collector for the already-published type decls.
- Melzar's Maze funexit doors added to ONE_WAY_DOORS (verified in
collision data) so walkTo/attack stop stranding bots.
Checked: imp red-bead drop table is fair (5/128, reachable branch) - the
report was 1-in-18 variance, no fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1e344ace90
commit
d80066c78a
15 files changed
+461
-67
No files matched your search
@@ -18,6 +18,7 @@ import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { botManager } from './api/index.js';
|
||||
import { formatWorldState } from '../sdk/formatter.js';
|
||||
import { initPathfinding } from '../sdk/pathfinding.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -339,6 +340,11 @@ async function main() {
|
||||
console.error('[MCP Server] Starting RS-Agent MCP server v2.0...');
|
||||
console.error('[MCP Server] Bots auto-connect on the first execute_code call.');
|
||||
|
||||
// Warm the pathfinder before any live session exists: init is fully
|
||||
// synchronous (tens of seconds on slow hosts), and paying it lazily inside
|
||||
// a walkTo blocks the event loop until the gateway drops the connection.
|
||||
initPathfinding();
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
|
||||
+6
-6
@@ -69,7 +69,7 @@
|
||||
| Signature | Description |
|
||||
|---|---|
|
||||
| `async closeShop(timeout: number = 5000): Promise<ActionResult>` | Close the shop interface. |
|
||||
| `async openShop(target: NearbyNpc \| string \| RegExp = /shop\s*keeper/i): Promise<ActionResult>` | Open a shop by trading with an NPC. |
|
||||
| `async openShop(target?: NearbyNpc \| string \| RegExp): Promise<ActionResult>` | Open a shop by trading with an NPC. Defaults to the nearest shopkeeper. |
|
||||
| `async buyFromShop(target: ShopItem \| string \| RegExp, amount: number = 1): Promise<ShopResult>` | Buy an item from an open shop. `success` is true only when the full requested amount arrived; a short fill (out of stock, out of coins, full inventory) returns `success: false` with `partial: true` and the actual `amountBought`. |
|
||||
| `async sellToShop(target: InventoryItem \| ShopItem \| string \| RegExp, amount: SellAmount = 1): Promise<ShopSellResult>` | Sell an item to an open shop. `success` is true only when the full requested amount was sold; a short fill returns `success: false` with `partial: true` and `amountSold`. |
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
|---|---|
|
||||
| `async openBank(timeout: number = 10000): Promise<OpenBankResult>` | Open a bank booth or talk to a banker. |
|
||||
| `async closeBank(timeout: number = 5000): Promise<ActionResult>` | Close the bank interface. |
|
||||
| `async depositItem(target: InventoryItem \| string \| RegExp, amount: number = -1): Promise<BankDepositResult>` | Deposit an item into the bank. Use -1 for all. |
|
||||
| `async depositItem(target: InventoryItem \| string \| RegExp, amount: number = -1): Promise<BankDepositResult>` | Deposit an item into the bank. Use -1 for all. A pattern with `-1` deposits every distinct matching item: an alternation like `/^(shark\|burnt shark)$/i` covers both ids, not just whichever matched first. |
|
||||
| `async withdrawItem(target: BankItem \| string \| RegExp \| number, amount: number = 1, options: { asNote?: boolean } = {}): Promise<BankWithdrawResult>` | Withdraw an item from the bank by slot, name, or BankItem. `asNote: true` withdraws as a banknote: the note/item toggle is synced first (and synced back to items on the next plain withdrawal), and completion is detected on the noted item's arrival. Items the engine cannot note are withdrawn unnoted with a game message; the result then carries the unnoted item. |
|
||||
|
||||
### Player Trading
|
||||
@@ -104,8 +104,8 @@
|
||||
| `async eatFood(target: InventoryItem \| string \| RegExp): Promise<EatResult>` | Eat food to restore hitpoints. |
|
||||
| `async attack(target: CombatTarget, timeout: number = 5000): Promise<AttackResult>` | Attack an NPC or another player, walking to the target if needed. Takes either an entity (from `sdk.findNearbyNpc`/`sdk.findNearbyPlayer`) or a name/pattern, matched against NPCs first and players second - so pass the entity when a player shares a name with a monster. ```ts await bot.attack(/^chicken$/i); await bot.attack(sdk.findNearbyPlayer('Zezima')!); ``` PvP attacks are refused outside the wilderness and across too big a level gap; those come back as `reason: 'not_attackable'` with the server's own wording in `message`. |
|
||||
| `async attackPlayer(target: NearbyPlayer \| string \| RegExp, timeout: number = 5000): Promise<AttackResult>` | Attack another player (OPPLAYER2). Prefer {@link attack}, which also takes NPCs. |
|
||||
| `async castSpell(target: CombatTarget, spellComponent: number, timeout: number = 3000): Promise<CastSpellResult>` | Cast a combat spell on an NPC or another player. The two are the same action to the server (OPNPCT vs OPPLAYERT), so this takes either: an entity from `sdk.findNearbyNpc`/`sdk.findNearbyPlayer`, or a name/pattern matched against NPCs first and players second. ```ts await bot.castSpell('goblin', Spells.WIND_STRIKE); await bot.castSpell(sdk.findNearbyPlayer('Zezima')!, Spells.FIRE_STRIKE); ``` Magic XP is the evidence of a cast landing, so a splash still counts as success with `hit: false`. |
|
||||
| `async castSpellOnPlayer(target: NearbyPlayer \| string \| RegExp, spellComponent: number, timeout: number = 3000): Promise<CastSpellResult>` | Cast a combat spell on another player (OPPLAYERT). Prefer {@link castSpell}. |
|
||||
| `async castSpell(target: CombatTarget, spellComponent: number \| string, timeout: number = 3000): Promise<CastSpellResult>` | Cast a combat spell on an NPC or another player. The two are the same action to the server (OPNPCT vs OPPLAYERT), so this takes either: an entity from `sdk.findNearbyNpc`/`sdk.findNearbyPlayer`, or a name/pattern matched against NPCs first and players second. ```ts await bot.castSpell('goblin', Spells.WIND_STRIKE); await bot.castSpell(sdk.findNearbyPlayer('Zezima')!, Spells.FIRE_STRIKE); ``` Magic XP is the evidence of a cast landing, so a splash still counts as success with `hit: false`. |
|
||||
| `async castSpellOnPlayer(target: NearbyPlayer \| string \| RegExp, spellComponent: number \| string, timeout: number = 3000): Promise<CastSpellResult>` | Cast a combat spell on another player (OPPLAYERT). Prefer {@link castSpell}. |
|
||||
|
||||
### Condition Waiting
|
||||
|
||||
@@ -620,7 +620,7 @@ interface ShopResult {
|
||||
amountBought?: number;
|
||||
/** Some but not all of the requested amount was bought. */
|
||||
partial?: boolean;
|
||||
reason?: 'invalid_amount' | 'shop_not_open' | 'item_not_found' | 'partial_fill' | 'timeout';
|
||||
reason?: 'invalid_amount' | 'shop_not_open' | 'item_not_found' | 'partial_fill' | 'timeout' | 'out_of_stock' | 'no_inventory_space';
|
||||
}
|
||||
```
|
||||
|
||||
@@ -691,7 +691,7 @@ interface CastSpellResult {
|
||||
hit?: boolean;
|
||||
xpGained?: number;
|
||||
/** `npc_not_found` also covers players: no target matched. */
|
||||
reason?: 'npc_not_found' | 'out_of_reach' | 'no_runes' | 'not_attackable' | 'timeout';
|
||||
reason?: 'npc_not_found' | 'out_of_reach' | 'no_runes' | 'not_attackable' | 'timeout' | 'unknown_spell';
|
||||
/** What the resolved target was, when one was found. */
|
||||
targetType?: 'npc' | 'player';
|
||||
}
|
||||
|
||||
@@ -499,6 +499,28 @@ export class ActionHelpers {
|
||||
|
||||
// ============ 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,
|
||||
|
||||
+256
-35
@@ -246,6 +246,12 @@ export class BotActions {
|
||||
if (!state) break;
|
||||
|
||||
if (state.dialog.isOpen) {
|
||||
// A multi-option dialog is a CHOICE, not blocking UI: clicking
|
||||
// its first option answers the choice as option 1 on the
|
||||
// server, silently running the wrong quest branch and leaving
|
||||
// the caller's intended click refused as stale. Leave it for
|
||||
// the caller (navigateDialog/clickDialogByText) to answer.
|
||||
if (state.dialog.options.length > 1) break;
|
||||
// Click the server-assigned index of the first published option
|
||||
// ("Click here to continue" arrives at index 1, not 0). A bare
|
||||
// 0 is only a fallback for optionless dialogs mid-transition.
|
||||
@@ -280,8 +286,10 @@ export class BotActions {
|
||||
timeout: number
|
||||
): Promise<NonNullable<ReturnType<BotSDK['getState']>>> {
|
||||
const deadline = Date.now() + timeout;
|
||||
// A multi-option dialog is a choice the caller must answer, never
|
||||
// "safe" to dismiss - see dismissBlockingUI.
|
||||
const hasSafeBlockingUI = (state: NonNullable<ReturnType<BotSDK['getState']>>) =>
|
||||
state.dialog.isOpen ||
|
||||
(state.dialog.isOpen && state.dialog.options.length <= 1) ||
|
||||
(state.interface?.isOpen && !state.shop?.isOpen && !state.bank?.isOpen
|
||||
&& !NEVER_AUTO_CLOSE.has(state.interface.interfaceId));
|
||||
|
||||
@@ -463,17 +471,24 @@ export class BotActions {
|
||||
|
||||
// Re-find the location after walking (it may have moved in view). A RegExp
|
||||
// target is kept as-is — rebuilding it from the matched name used to drop
|
||||
// the caller's anchors, turning /^hopper$/i into one matching "Hopper controls".
|
||||
const locPattern = loc instanceof RegExp || typeof loc === 'string'
|
||||
? loc
|
||||
: exactNamePattern(resolvedLoc.name);
|
||||
const locNow = this.helpers.resolveLocation(locPattern, /./);
|
||||
// the caller's anchors, turning /^hopper$/i into one matching "Hopper
|
||||
// controls". An entity target re-finds by identity so same-named
|
||||
// neighbours can't win on distance.
|
||||
const locNow = loc instanceof RegExp || typeof loc === 'string'
|
||||
? this.helpers.resolveLocation(loc, /./)
|
||||
: this.helpers.refindLocation(resolvedLoc)
|
||||
?? this.helpers.resolveLocation(exactNamePattern(resolvedLoc.name), /./);
|
||||
if (!locNow) {
|
||||
return { success: false, message: `${resolvedLoc.name} no longer visible`, reason: 'loc_not_found' };
|
||||
}
|
||||
|
||||
const startTick = this.sdk.getState()?.tick || 0;
|
||||
const msgBaseline = this.helpers.getMessageTick();
|
||||
// Some loc scripts (flour bin) swap the item with no animation,
|
||||
// dialog, or reach failure — the inventory change is the evidence.
|
||||
const invBefore = this.helpers.inventorySignature(this.sdk.getState());
|
||||
const inventoryChanged = (state: Parameters<ActionHelpers['inventorySignature']>[0]): boolean =>
|
||||
invBefore !== '' && this.helpers.inventorySignature(state) !== invBefore;
|
||||
|
||||
// Use the item on the location
|
||||
const result = await this.sdk.sendUseItemOnLoc(resolvedItem.slot, locNow.x, locNow.z, locNow.id);
|
||||
@@ -504,6 +519,10 @@ export class BotActions {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inventoryChanged(state)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, timeout);
|
||||
|
||||
@@ -514,6 +533,11 @@ export class BotActions {
|
||||
|
||||
return { success: true, message: `Used ${resolvedItem.name} on ${locNow.name}` };
|
||||
} catch {
|
||||
// The effect can land after the wait expires with no other
|
||||
// evidence — an inventory change still proves the use worked.
|
||||
if (inventoryChanged(this.sdk.getState())) {
|
||||
return { success: true, message: `Used ${resolvedItem.name} on ${locNow.name}` };
|
||||
}
|
||||
return { success: false, message: `Timeout using ${resolvedItem.name} on ${locNow.name}`, reason: 'timeout' };
|
||||
}
|
||||
}
|
||||
@@ -560,6 +584,11 @@ export class BotActions {
|
||||
|
||||
const startTick = this.sdk.getState()?.tick || 0;
|
||||
const msgBaseline = this.helpers.getMessageTick();
|
||||
// Some NPC scripts (cow milking) swap the item with no animation or
|
||||
// dialog — the inventory change is the only observable evidence.
|
||||
const invBefore = this.helpers.inventorySignature(this.sdk.getState());
|
||||
const inventoryChanged = (state: Parameters<ActionHelpers['inventorySignature']>[0]): boolean =>
|
||||
invBefore !== '' && this.helpers.inventorySignature(state) !== invBefore;
|
||||
|
||||
// Use the item on the NPC
|
||||
const result = await this.sdk.sendUseItemOnNpc(resolvedItem.slot, npcNow.index);
|
||||
@@ -590,6 +619,10 @@ export class BotActions {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (inventoryChanged(state)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, timeout);
|
||||
|
||||
@@ -600,6 +633,11 @@ export class BotActions {
|
||||
|
||||
return { success: true, message: `Used ${resolvedItem.name} on ${npcNow.name}` };
|
||||
} catch {
|
||||
// The effect can land after the wait expires with no other
|
||||
// evidence — an inventory change still proves the use worked.
|
||||
if (inventoryChanged(this.sdk.getState())) {
|
||||
return { success: true, message: `Used ${resolvedItem.name} on ${npcNow.name}` };
|
||||
}
|
||||
return { success: false, message: `Timeout using ${resolvedItem.name} on ${npcNow.name}`, reason: 'timeout' };
|
||||
}
|
||||
}
|
||||
@@ -1192,13 +1230,24 @@ export class BotActions {
|
||||
}
|
||||
}
|
||||
|
||||
/** Open a shop by trading with an NPC. */
|
||||
async openShop(target: NearbyNpc | string | RegExp = /shop\s*keeper/i): Promise<ActionResult> {
|
||||
/** Open a shop by trading with an NPC. Defaults to the nearest shopkeeper. */
|
||||
async openShop(target?: NearbyNpc | string | RegExp): Promise<ActionResult> {
|
||||
await this.dismissBlockingUI();
|
||||
|
||||
const npc = this.helpers.resolveNpc(target);
|
||||
// Many shops are run by a named NPC (Bob's Brilliant Axes -> 'Bob'),
|
||||
// so with no explicit target, fall back from the /shop keeper/ name to
|
||||
// the nearest NPC that offers a Trade option. An explicit target that
|
||||
// doesn't match stays a failure - trading with someone else instead
|
||||
// would be worse.
|
||||
const npc = this.helpers.resolveNpc(target ?? /shop\s*keeper/i)
|
||||
?? (target === undefined
|
||||
? this.sdk.getState()?.nearbyNpcs
|
||||
.filter(n => n.optionsWithIndex.some(o => /trade/i.test(o.text)))
|
||||
.sort((a, b) => a.distance - b.distance)[0]
|
||||
: null)
|
||||
?? null;
|
||||
if (!npc) {
|
||||
return { success: false, message: 'Shopkeeper not found' };
|
||||
return { success: false, message: target === undefined ? 'No nearby NPC with a Trade option' : `Shopkeeper not found: ${target}` };
|
||||
}
|
||||
|
||||
const tradeOpt = npc.optionsWithIndex.find(o => /trade/i.test(o.text));
|
||||
@@ -1259,9 +1308,24 @@ export class BotActions {
|
||||
return { success: false, message: `Item not found in shop: ${target}`, reason: 'item_not_found' };
|
||||
}
|
||||
|
||||
// A shop lists every line it can ever sell; count is the live shelf
|
||||
// stock. Buying a count-0 line used to walk the whole funded flow and
|
||||
// come back success-shaped with amountBought 0.
|
||||
if (shopItem.count <= 0) {
|
||||
return { success: false, message: `${shopItem.name} is out of stock`, reason: 'out_of_stock', requestedAmount, amountBought: 0 };
|
||||
}
|
||||
|
||||
// Count total items across all inventory slots (handles non-stackable items)
|
||||
const countInvItems = () => countItems(this.sdk.getInventory(), shopItem.id);
|
||||
|
||||
// The buy-wait timeout fires identically for no-coins, no-stock and
|
||||
// no-free-slot; a full inventory with no existing stack of the item
|
||||
// can never receive it, so fail that loudly up front. (A full
|
||||
// inventory WITH a stack may still work when the item stacks.)
|
||||
if (this.sdk.getInventory().length >= 28 && countInvItems() === 0) {
|
||||
return { success: false, message: `No inventory space to buy ${shopItem.name}`, reason: 'no_inventory_space', requestedAmount, amountBought: 0 };
|
||||
}
|
||||
|
||||
const totalBefore = countInvItems();
|
||||
const outcome = (): ShopResult => {
|
||||
const amountBought = Math.max(0, countInvItems() - totalBefore);
|
||||
@@ -1305,7 +1369,7 @@ export class BotActions {
|
||||
} catch {
|
||||
const failed = outcome();
|
||||
if (failed.amountBought === 0) {
|
||||
failed.message = `Failed to buy ${shopItem.name} (no coins or out of stock?)`;
|
||||
failed.message = `Failed to buy ${shopItem.name} (no coins, out of stock, or full inventory?)`;
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
@@ -1689,7 +1753,13 @@ export class BotActions {
|
||||
return this.closeInterface(timeout);
|
||||
}
|
||||
|
||||
/** Deposit an item into the bank. Use -1 for all. */
|
||||
/**
|
||||
* Deposit an item into the bank. Use -1 for all.
|
||||
*
|
||||
* A pattern with `-1` deposits every distinct matching item: an
|
||||
* alternation like `/^(shark|burnt shark)$/i` covers both ids, not just
|
||||
* whichever matched first.
|
||||
*/
|
||||
async depositItem(target: InventoryItem | string | RegExp, amount: number = -1): Promise<BankDepositResult> {
|
||||
const validated = validateActionQuantity(amount, { allowAll: true, max: MAX_BANK_ACTION_QUANTITY });
|
||||
if (!validated.valid) {
|
||||
@@ -1702,6 +1772,33 @@ export class BotActions {
|
||||
return { success: false, message: 'Bank is not open', reason: 'bank_not_open' };
|
||||
}
|
||||
|
||||
// Deposit-all with a pattern: one deposit per distinct matching id.
|
||||
if (dispatchAmount === -1 && (typeof target === 'string' || target instanceof RegExp)) {
|
||||
const pattern = typeof target === 'string' ? new RegExp(target, 'i') : target;
|
||||
const matchingIds = [...new Set(state.inventory.filter(i => pattern.test(i.name)).map(i => i.id))];
|
||||
if (matchingIds.length > 1) {
|
||||
let totalDeposited = 0;
|
||||
const names: string[] = [];
|
||||
for (const id of matchingIds) {
|
||||
const slotItem = this.sdk.getInventory().find(i => i.id === id);
|
||||
if (!slotItem) continue;
|
||||
const one = await this.depositItem(slotItem, -1);
|
||||
totalDeposited += one.amountDeposited ?? 0;
|
||||
names.push(slotItem.name);
|
||||
if (!one.success) {
|
||||
return { ...one, amountDeposited: totalDeposited, message: `Deposited ${totalDeposited} across ${names.join(', ')}; stopped: ${one.message}` };
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: `Deposited ${totalDeposited} across ${names.join(', ')}`,
|
||||
requestedAmount: totalDeposited,
|
||||
amountDeposited: totalDeposited,
|
||||
partial: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const item = this.helpers.resolveInventoryItem(target, /./);
|
||||
if (!item) {
|
||||
return { success: false, message: `Item not found in inventory: ${target}`, reason: 'item_not_found' };
|
||||
@@ -2322,6 +2419,10 @@ export class BotActions {
|
||||
return { success: false, message: `No equip option on ${item.name}` };
|
||||
}
|
||||
|
||||
// Whether an identical item was already worn - if so, its presence in
|
||||
// the equipment can't serve as arrival evidence below.
|
||||
const equippedBefore = this.sdk.getEquipment().some(e => e.id === item.id);
|
||||
|
||||
const result = await this.sdk.sendUseItem(item.slot, equipOpt.opIndex);
|
||||
if (!result.success) {
|
||||
return { success: false, message: result.message };
|
||||
@@ -2329,11 +2430,17 @@ export class BotActions {
|
||||
|
||||
try {
|
||||
await this.sdk.waitForCondition(state =>
|
||||
!state.inventory.find(i => i.slot === item.slot && i.id === item.id),
|
||||
!state.inventory.find(i => i.slot === item.slot && i.id === item.id) ||
|
||||
(!equippedBefore && state.equipment.some(e => e.id === item.id)),
|
||||
5000
|
||||
);
|
||||
return { success: true, message: `Equipped ${item.name}` };
|
||||
} catch {
|
||||
// The equip can round-trip after the wait expires - the item
|
||||
// showing up worn is still proof it landed.
|
||||
if (!equippedBefore && this.sdk.getEquipment().some(e => e.id === item.id)) {
|
||||
return { success: true, message: `Equipped ${item.name}` };
|
||||
}
|
||||
return { success: false, message: `Failed to equip ${item.name}` };
|
||||
}
|
||||
}
|
||||
@@ -2706,18 +2813,26 @@ export class BotActions {
|
||||
* Magic XP is the evidence of a cast landing, so a splash still counts as
|
||||
* success with `hit: false`.
|
||||
*/
|
||||
async castSpell(target: CombatTarget, spellComponent: number, timeout: number = 3000): Promise<CastSpellResult> {
|
||||
async castSpell(target: CombatTarget, spellComponent: number | string, timeout: number = 3000): Promise<CastSpellResult> {
|
||||
const resolvedSpell = this.resolveSpellComponent(spellComponent);
|
||||
if (resolvedSpell === null) {
|
||||
return { success: false, message: `Unknown spell: ${spellComponent} - pass a component id (Spells.WIND_STRIKE = 1152) or a known combat spell name`, reason: 'unknown_spell' };
|
||||
}
|
||||
await this.dismissBlockingUI();
|
||||
|
||||
const entity = this.helpers.resolveCombatTarget(target);
|
||||
if (!entity) {
|
||||
return { success: false, message: `Target not found: ${target}`, reason: 'npc_not_found' };
|
||||
}
|
||||
return this.castSpellOnEntity(entity, spellComponent, timeout);
|
||||
return this.castSpellOnEntity(entity, resolvedSpell, timeout);
|
||||
}
|
||||
|
||||
/** Cast a combat spell on another player (OPPLAYERT). Prefer {@link castSpell}. */
|
||||
async castSpellOnPlayer(target: NearbyPlayer | string | RegExp, spellComponent: number, timeout: number = 3000): Promise<CastSpellResult> {
|
||||
async castSpellOnPlayer(target: NearbyPlayer | string | RegExp, spellComponent: number | string, timeout: number = 3000): Promise<CastSpellResult> {
|
||||
const resolvedSpell = this.resolveSpellComponent(spellComponent);
|
||||
if (resolvedSpell === null) {
|
||||
return { success: false, message: `Unknown spell: ${spellComponent} - pass a component id (Spells.WIND_STRIKE = 1152) or a known combat spell name`, reason: 'unknown_spell' };
|
||||
}
|
||||
await this.dismissBlockingUI();
|
||||
|
||||
const player = typeof target === 'object' && 'index' in target
|
||||
@@ -2726,9 +2841,45 @@ export class BotActions {
|
||||
if (!player) {
|
||||
return { success: false, message: `Player not found: ${target}`, reason: 'npc_not_found' };
|
||||
}
|
||||
return this.castSpellOnEntity(player, spellComponent, timeout);
|
||||
return this.castSpellOnEntity(player, resolvedSpell, timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* A string spell used to be accepted and silently cast nothing —
|
||||
* castSpell reported 16 "successful" casts with no runes consumed.
|
||||
* Resolve known combat-spell names; reject anything else loudly.
|
||||
*/
|
||||
private resolveSpellComponent(spell: number | string): number | null {
|
||||
if (typeof spell === 'number' && Number.isFinite(spell)) return spell;
|
||||
if (typeof spell === 'string') {
|
||||
return BotActions.SPELL_COMPONENTS[spell.toLowerCase().trim().replace(/[\s_-]+/g, ' ')] ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static readonly SPELL_COMPONENTS: Record<string, number> = {
|
||||
'wind strike': 1152,
|
||||
'confuse': 1153,
|
||||
'water strike': 1154,
|
||||
'earth strike': 1156,
|
||||
'weaken': 1157,
|
||||
'fire strike': 1158,
|
||||
'wind bolt': 1160,
|
||||
'curse': 1161,
|
||||
'water bolt': 1163,
|
||||
'earth bolt': 1166,
|
||||
'fire bolt': 1169,
|
||||
'wind blast': 1172,
|
||||
'water blast': 1175,
|
||||
'earth blast': 1177,
|
||||
'fire blast': 1181,
|
||||
'wind wave': 1183,
|
||||
'water wave': 1185,
|
||||
'earth wave': 1188,
|
||||
'fire wave': 1189,
|
||||
'bind': 1572,
|
||||
};
|
||||
|
||||
private async castSpellOnEntity(
|
||||
entity: NearbyNpc | NearbyPlayer,
|
||||
spellComponent: number,
|
||||
@@ -2742,6 +2893,14 @@ export class BotActions {
|
||||
}
|
||||
const msgBaseline = this.helpers.getMessageTick();
|
||||
const startMagicXp = startState.skills.find(s => s.name === 'Magic')?.experience ?? 0;
|
||||
// A real splash still consumes runes. No XP AND no inventory change
|
||||
// means the client never dispatched (e.g. unreachable target) - that
|
||||
// must not be reported as a splash. With no inventory data at all we
|
||||
// can't disprove a cast, so give the splash the benefit of the doubt.
|
||||
const invBefore = this.helpers.inventorySignature(startState);
|
||||
const runesConsumed = (): boolean =>
|
||||
invBefore === '' ||
|
||||
this.helpers.inventorySignature(this.sdk.getState()) !== invBefore;
|
||||
|
||||
const result = await this.sdk.sendSpellOnTarget(entity, spellComponent);
|
||||
if (!result.success) {
|
||||
@@ -2784,8 +2943,17 @@ export class BotActions {
|
||||
return { success: true, message: `Hit ${entity.name} for ${xpGained} Magic XP`, hit: true, xpGained, targetType };
|
||||
}
|
||||
|
||||
if (!runesConsumed()) {
|
||||
return { success: false, message: `No evidence ${entity.name} was cast on - no XP, no runes consumed (unreachable target?)`, reason: 'out_of_reach', targetType };
|
||||
}
|
||||
return { success: true, message: `Splashed on ${entity.name}`, hit: false, xpGained: 0, targetType };
|
||||
} catch {
|
||||
if (this.helpers.checkCantReachMessage(msgBaseline)) {
|
||||
return { success: false, message: `Cannot reach ${entity.name} - obstacle in the way`, reason: 'out_of_reach', targetType };
|
||||
}
|
||||
if (!runesConsumed()) {
|
||||
return { success: false, message: `No evidence ${entity.name} was cast on - no XP, no runes consumed (unreachable target?)`, reason: 'out_of_reach', targetType };
|
||||
}
|
||||
return { success: true, message: `Splashed on ${entity.name} (timeout)`, hit: false, xpGained: 0, targetType };
|
||||
}
|
||||
}
|
||||
@@ -3234,6 +3402,17 @@ export class BotActions {
|
||||
* Column 4 (1122): Med Helm, Full Helm, Sq Shield, Kiteshield
|
||||
* Column 5 (1123): Dart Tips, Arrowheads, Throwing Knives, Wire/Studs
|
||||
*/
|
||||
private static readonly METAL_BAR_PATTERNS: Record<string, RegExp> = {
|
||||
'bronze': /^bronze bar$/i,
|
||||
'iron': /^iron bar$/i,
|
||||
'steel': /^steel bar$/i,
|
||||
'mithril': /^mithril bar$/i,
|
||||
'adamant': /^adamantite bar$/i,
|
||||
'adamantite': /^adamantite bar$/i,
|
||||
'rune': /^runite bar$/i,
|
||||
'runite': /^runite bar$/i,
|
||||
};
|
||||
|
||||
private static readonly SMITHING_COMPONENTS: Record<string, { component: number; slot: number }> = {
|
||||
// Column 1 - Bladed weapons
|
||||
'dagger': { component: 1119, slot: 0 },
|
||||
@@ -3297,7 +3476,20 @@ export class BotActions {
|
||||
product: string | number = 'dagger',
|
||||
options: { barPattern?: RegExp; timeout?: number } = {}
|
||||
): Promise<SmithResult> {
|
||||
const { barPattern = /bar$/i, timeout = 10000 } = options;
|
||||
if (typeof product !== 'string' && typeof product !== 'number') {
|
||||
// A RegExp product used to throw deep inside on .toLowerCase().
|
||||
return { success: false, message: `smithAtAnvil takes a product name string (e.g. 'mithril longsword'), got: ${product}`, reason: 'level_too_low' };
|
||||
}
|
||||
|
||||
// A 'mithril longsword' request must smith with mithril bars — the
|
||||
// anvil interface uses whichever bar the click finds, so an
|
||||
// unqualified /bar$/i grabs the first bar in the inventory.
|
||||
const metalMatch = typeof product === 'string'
|
||||
? product.toLowerCase().match(/^(bronze|iron|steel|mithril|adamant(?:ite)?|run(?:e|ite))\s+/)
|
||||
: null;
|
||||
const metal = metalMatch?.[1];
|
||||
const metalBar = metal !== undefined ? BotActions.METAL_BAR_PATTERNS[metal] : undefined;
|
||||
const { barPattern = metalBar ?? /bar$/i, timeout = 10000 } = options;
|
||||
|
||||
await this.dismissBlockingUI();
|
||||
|
||||
@@ -3310,7 +3502,8 @@ export class BotActions {
|
||||
// Check for bars
|
||||
const bar = this.sdk.findInventoryItem(barPattern);
|
||||
if (!bar) {
|
||||
return { success: false, message: 'No bars in inventory', reason: 'no_bars' };
|
||||
const wanted = metal !== undefined ? `${metal} bars` : 'bars';
|
||||
return { success: false, message: `No ${wanted} in inventory`, reason: 'no_bars' };
|
||||
}
|
||||
|
||||
// Find anvil
|
||||
@@ -3325,16 +3518,22 @@ export class BotActions {
|
||||
if (typeof product === 'number') {
|
||||
componentId = product;
|
||||
} else {
|
||||
const key = product.toLowerCase();
|
||||
// Strip a leading metal so 'mithril longsword' hits the
|
||||
// 'longsword' entry directly.
|
||||
const key = metalMatch
|
||||
? product.toLowerCase().slice(metalMatch[0].length).trim()
|
||||
: product.toLowerCase();
|
||||
const directMatch = BotActions.SMITHING_COMPONENTS[key];
|
||||
if (directMatch) {
|
||||
componentId = directMatch.component;
|
||||
componentSlot = directMatch.slot;
|
||||
} else {
|
||||
// Try partial match
|
||||
const matchingKey = Object.keys(BotActions.SMITHING_COMPONENTS).find(k =>
|
||||
k.includes(key) || key.includes(k)
|
||||
);
|
||||
// Try partial match. Prefer the LONGEST matching key:
|
||||
// 'longsword'.includes('sword') is also true, and first-wins
|
||||
// used to silently smith the wrong product.
|
||||
const matchingKey = Object.keys(BotActions.SMITHING_COMPONENTS)
|
||||
.filter(k => k.includes(key) || key.includes(k))
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
const partialMatch = matchingKey ? BotActions.SMITHING_COMPONENTS[matchingKey] : undefined;
|
||||
if (partialMatch) {
|
||||
componentId = partialMatch.component;
|
||||
@@ -3349,6 +3548,17 @@ export class BotActions {
|
||||
const startTick = this.sdk.getState()?.tick || 0;
|
||||
const msgBaseline = this.helpers.getMessageTick();
|
||||
|
||||
// The generic product sweep below matches /axe/ against an equipped
|
||||
// "Bronze pickaxe" and reports it as the smithed item — prefer the
|
||||
// product the caller actually asked for.
|
||||
const findSmithedItem = (): InventoryItem | null => {
|
||||
if (typeof product === 'string') {
|
||||
const named = this.sdk.findInventoryItem(new RegExp(product.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'));
|
||||
if (named) return named;
|
||||
}
|
||||
return this.sdk.findInventoryItem(/dagger|(?<!pick)axe|mace|helm|sword|shield|body|legs|skirt|claws|knives|bolts|arrowtips|arrowheads|arrow|dart|nails/i);
|
||||
};
|
||||
|
||||
// Use bar on anvil
|
||||
const useResult = await this.sdk.sendUseItemOnLoc(bar.slot, anvil.x, anvil.z, anvil.id);
|
||||
if (!useResult.success) {
|
||||
@@ -3392,7 +3602,7 @@ export class BotActions {
|
||||
const currentXp = state.skills.find(s => s.name === 'Smithing')?.experience || 0;
|
||||
if (currentXp > smithingBefore) {
|
||||
// Find the smithed item
|
||||
const smithedItem = this.sdk.findInventoryItem(/dagger|axe|mace|helm|sword|shield|body|legs|skirt|claws|knives|bolts|arrowtips|arrowheads|arrow|dart|nails/i);
|
||||
const smithedItem = findSmithedItem();
|
||||
return {
|
||||
success: true,
|
||||
message: 'Smithed item successfully',
|
||||
@@ -3419,7 +3629,7 @@ export class BotActions {
|
||||
if (!state.interface?.isOpen) {
|
||||
const finalXp = this.sdk.getSkill('Smithing')?.experience || 0;
|
||||
if (finalXp > smithingBefore) {
|
||||
const smithedItem = this.sdk.findInventoryItem(/dagger|axe|mace|helm|sword|shield|body|legs|skirt|claws|knives|bolts|arrowtips|arrowheads|arrow|dart|nails/i);
|
||||
const smithedItem = findSmithedItem();
|
||||
return {
|
||||
success: true,
|
||||
message: 'Smithed item successfully',
|
||||
@@ -3507,11 +3717,12 @@ export class BotActions {
|
||||
}
|
||||
|
||||
// Re-find the location after walking (it may have changed). Keep a RegExp
|
||||
// target as-is so its anchors survive; only rebuild for entity targets.
|
||||
const locPattern = target instanceof RegExp || typeof target === 'string'
|
||||
? target
|
||||
: exactNamePattern(loc.name);
|
||||
const locNow = this.helpers.resolveLocation(locPattern, /./, optionFilter);
|
||||
// target as-is so its anchors survive; an entity target re-finds by
|
||||
// identity so same-named neighbours (Varrock cart crates) can't win.
|
||||
const locNow = target instanceof RegExp || typeof target === 'string'
|
||||
? this.helpers.resolveLocation(target, /./, optionFilter)
|
||||
: this.helpers.refindLocation(loc)
|
||||
?? this.helpers.resolveLocation(exactNamePattern(loc.name), /./, optionFilter);
|
||||
if (!locNow) {
|
||||
return { success: false, message: `${loc.name} no longer visible`, reason: 'loc_not_found' };
|
||||
}
|
||||
@@ -3528,6 +3739,15 @@ export class BotActions {
|
||||
const levelChanged = (state: { player: { level: number } | null } | null): boolean =>
|
||||
startLevel !== -1 && state?.player != null && state.player.level !== startLevel;
|
||||
|
||||
// Dungeon trapdoors/ladders teleport within the same level (the
|
||||
// underground band is a +6400 z offset), so also treat a jump far
|
||||
// beyond walking range as evidence the interaction worked.
|
||||
const dispatchX = lastX;
|
||||
const dispatchZ = lastZ;
|
||||
const teleported = (state: { player: { x: number; z: number } | null } | null): boolean =>
|
||||
state?.player != null &&
|
||||
Math.max(Math.abs(state.player.x - dispatchX), Math.abs(state.player.z - dispatchZ)) > 8;
|
||||
|
||||
const result = await this.sdk.sendInteractLoc(locNow.x, locNow.z, locNow.id, opIndex);
|
||||
if (!result.success) {
|
||||
return { success: false, message: result.message, reason: 'timeout' };
|
||||
@@ -3546,7 +3766,7 @@ export class BotActions {
|
||||
// Success indicators
|
||||
if (state.dialog.isOpen || state.interface?.isOpen) return true;
|
||||
if (state.player && state.player.animId !== -1) return true;
|
||||
if (levelChanged(state)) return true;
|
||||
if (levelChanged(state) || teleported(state)) return true;
|
||||
|
||||
// Track movement — if player moved, update last move tick
|
||||
if (state.player && (state.player.x !== lastX || state.player.z !== lastZ)) {
|
||||
@@ -3567,7 +3787,7 @@ export class BotActions {
|
||||
|
||||
if (finalState.dialog.isOpen || finalState.interface?.isOpen ||
|
||||
(finalState.player && finalState.player.animId !== -1) ||
|
||||
levelChanged(finalState)) {
|
||||
levelChanged(finalState) || teleported(finalState)) {
|
||||
return { success: true, message: `Interacted with ${locNow.name}` };
|
||||
}
|
||||
|
||||
@@ -3576,14 +3796,15 @@ export class BotActions {
|
||||
try {
|
||||
await this.sdk.waitForCondition(s =>
|
||||
s.dialog.isOpen || Boolean(s.interface?.isOpen) ||
|
||||
(s.player != null && s.player.animId !== -1) || levelChanged(s), 2000);
|
||||
(s.player != null && s.player.animId !== -1) ||
|
||||
levelChanged(s) || teleported(s), 2000);
|
||||
return { success: true, message: `Interacted with ${locNow.name}` };
|
||||
} catch {
|
||||
return { success: false, message: `Nothing happened interacting with ${locNow.name}`, reason: 'timeout' };
|
||||
}
|
||||
} catch {
|
||||
// Even on the safety-net timeout, a level transition means it worked.
|
||||
if (levelChanged(this.sdk.getState())) {
|
||||
if (levelChanged(this.sdk.getState()) || teleported(this.sdk.getState())) {
|
||||
return { success: true, message: `Interacted with ${locNow.name}` };
|
||||
}
|
||||
return { success: false, message: `Timed out interacting with ${locNow.name}`, reason: 'timeout' };
|
||||
|
||||
+8
-2
@@ -132,9 +132,15 @@ async function fetchState(botName: string, flags: { server: string; timeout: num
|
||||
username = botName;
|
||||
}
|
||||
|
||||
if (!server) server = 'rs-sdk-demo.fly.dev';
|
||||
// A bot.env with SERVER= explicitly blank means "local" (deriveGatewayUrl's
|
||||
// own default) - only fall back to the demo server when nothing anywhere
|
||||
// named a server at all. Diagnosing a local bot against the demo gateway
|
||||
// reports "connected fine, no game state", indistinguishable from a dead
|
||||
// client.
|
||||
const serverExplicitlyLocal = botEnv?.server !== undefined && botEnv.server === '' && !server;
|
||||
if (!server && !serverExplicitlyLocal) server = 'rs-sdk-demo.fly.dev';
|
||||
|
||||
const isLocal = server === 'localhost' || server.startsWith('localhost:');
|
||||
const isLocal = !server || server === 'localhost' || server.startsWith('localhost:') || server.startsWith('127.');
|
||||
|
||||
if (!username) {
|
||||
console.error('Error: Username required');
|
||||
|
||||
@@ -73,6 +73,9 @@ const populatedZones = new Set<string>();
|
||||
const ONE_WAY_DOORS = new Set<string>([
|
||||
'0,3108,3353', // Draynor Manor front door (west tile) — only opens from outside
|
||||
'0,3109,3353', // Draynor Manor front door (east tile) — only opens from outside
|
||||
'0,2924,9654', // Melzar's Maze basement 'funexit' door — only opens from the maze side
|
||||
'0,2927,9649', // Melzar's Maze basement 'funexit' door — only opens from the maze side
|
||||
'0,2931,9640', // Melzar's Maze basement 'funexit' door — only opens from the maze side
|
||||
]);
|
||||
|
||||
function doorKey(level: number, x: number, z: number): string {
|
||||
|
||||
+35
-7
@@ -3,6 +3,7 @@
|
||||
|
||||
import { BotSDK, deriveGatewayUrl } from './index';
|
||||
import { BotActions } from './actions';
|
||||
import { initPathfinding } from './pathfinding';
|
||||
import { formatWorldStateSummary } from './formatter';
|
||||
import type { BotWorldState } from './types';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
@@ -154,6 +155,14 @@ async function getOrCreateConnection(): Promise<BotConnection> {
|
||||
|
||||
const gatewayUrl = deriveGatewayUrl(server);
|
||||
|
||||
// Warm the pathfinder BEFORE the live socket exists. Init is fully
|
||||
// synchronous (tens of seconds on slow hosts), and paying it lazily
|
||||
// inside the first walkTo blocks the event loop long enough for the
|
||||
// gateway to kill the connection on missed pings - which presented as
|
||||
// "interactLoc froze the whole process". Idempotent, so repeat
|
||||
// connections skip it.
|
||||
initPathfinding();
|
||||
|
||||
console.error(`[Runner] Connecting to bot "${username}"...`);
|
||||
|
||||
const sdk = new BotSDK({
|
||||
@@ -283,15 +292,21 @@ export async function runScript(
|
||||
|
||||
// Clean exit on signals - prevents orphaned processes when parent shell is killed
|
||||
let signalReceived = false;
|
||||
const signalCleanup = (signal: string) => {
|
||||
if (signalReceived) return; // Prevent double-cleanup
|
||||
const signalCleanup = (signal: string, exitCode: number) => {
|
||||
if (signalReceived) {
|
||||
// Second signal: the disconnect is wedged, exit immediately.
|
||||
process.exit(exitCode);
|
||||
}
|
||||
signalReceived = true;
|
||||
console.error(`[Runner] Received ${signal} - disconnecting and exiting...`);
|
||||
sdk.disconnect().finally(() => process.exit(0));
|
||||
// Exit 128+signum, never 0: a supervisor must not read a killed run
|
||||
// as success. And a wedged disconnect must not hang the process.
|
||||
setTimeout(() => process.exit(exitCode), 5000);
|
||||
sdk.disconnect().finally(() => process.exit(exitCode));
|
||||
};
|
||||
const onSigterm = () => signalCleanup('SIGTERM');
|
||||
const onSigint = () => signalCleanup('SIGINT');
|
||||
const onSighup = () => signalCleanup('SIGHUP');
|
||||
const onSigterm = () => signalCleanup('SIGTERM', 143);
|
||||
const onSigint = () => signalCleanup('SIGINT', 130);
|
||||
const onSighup = () => signalCleanup('SIGHUP', 129);
|
||||
process.on('SIGTERM', onSigterm);
|
||||
process.on('SIGINT', onSigint);
|
||||
process.on('SIGHUP', onSighup);
|
||||
@@ -404,8 +419,21 @@ export async function runScript(
|
||||
const username = process.env.BOT_USERNAME;
|
||||
if (username) {
|
||||
console.error(`[Runner] Disconnecting bot "${username}"...`);
|
||||
await sdk.disconnect();
|
||||
// Bound the disconnect - a wedged socket must not hang the exit.
|
||||
await Promise.race([
|
||||
sdk.disconnect(),
|
||||
new Promise(resolve => setTimeout(resolve, 10000)),
|
||||
]);
|
||||
connections.delete(username);
|
||||
// A leaked handle (reconnect timer, half-closed socket) used to
|
||||
// keep the bun process alive for minutes after the script was
|
||||
// done. Unref'd, so it never delays a clean exit; it only fires
|
||||
// when something else is wedging the event loop.
|
||||
const forceExit = setTimeout(() => {
|
||||
console.error('[Runner] Event loop still alive after disconnect - forcing exit');
|
||||
process.exit(process.exitCode ?? 0);
|
||||
}, 5000);
|
||||
forceExit.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,4 +84,42 @@ describe('dismissBlockingUI()', () => {
|
||||
|
||||
expect(calls).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
// Regression for the harvested reports msw80ivq/mswmz7f3/mswa5fca/mswjr44j:
|
||||
// dismissBlockingUI runs before every BotActions call, and clicking the
|
||||
// first option of a p_choice menu ANSWERS the choice as option 1 on the
|
||||
// server — the caller's intended click then silently no-ops and the wrong
|
||||
// quest branch runs. A multi-option dialog is a decision, not blocking UI.
|
||||
test('never clicks a multi-option choice dialog', async () => {
|
||||
const { bot, calls } = mount([
|
||||
makeState({
|
||||
dialogOpen: true,
|
||||
options: [
|
||||
makeOption(1, "Who's Saradomin?"),
|
||||
makeOption(2, 'Nice place you have here.'),
|
||||
makeOption(3, "I'm looking for a quest!"),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
await bot.dismissBlockingUI();
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
test('still clears a continue page that precedes a choice, then stops', async () => {
|
||||
const { bot, calls } = mount([
|
||||
makeState({ dialogOpen: true, options: [makeOption(1, 'Click here to continue')] }),
|
||||
makeState({
|
||||
dialogOpen: true,
|
||||
options: [
|
||||
makeOption(1, "No thank you, I'll walk around."),
|
||||
makeOption(2, 'Who does my money go to?'),
|
||||
makeOption(3, 'Yes, ok.'),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
await bot.dismissBlockingUI();
|
||||
|
||||
expect(calls).toEqual([1]);
|
||||
});
|
||||
});
|
||||
+12
-2
@@ -88,6 +88,16 @@ export interface NearbyNpc {
|
||||
combatLevel: number;
|
||||
x: number;
|
||||
z: number;
|
||||
/**
|
||||
* Authoritative SW route tile. `x`/`z` are the interpolated render
|
||||
* position, which trails a moving NPC by 1-3 tiles and is offset by
|
||||
* floor(size/2) for multi-tile NPCs; drops spawn on THIS tile. Optional
|
||||
* only against clients older than this field.
|
||||
*/
|
||||
tileX?: number;
|
||||
tileZ?: number;
|
||||
/** Footprint in tiles (1 = single tile, cows/giants are 2+). */
|
||||
size?: number;
|
||||
distance: number;
|
||||
/** Current HP, or null until the server reveals it by updating the NPC. */
|
||||
hp: number | null;
|
||||
@@ -749,7 +759,7 @@ export interface ShopResult {
|
||||
amountBought?: number;
|
||||
/** Some but not all of the requested amount was bought. */
|
||||
partial?: boolean;
|
||||
reason?: 'invalid_amount' | 'shop_not_open' | 'item_not_found' | 'partial_fill' | 'timeout';
|
||||
reason?: 'invalid_amount' | 'shop_not_open' | 'item_not_found' | 'partial_fill' | 'timeout' | 'out_of_stock' | 'no_inventory_space';
|
||||
}
|
||||
|
||||
export interface ShopSellResult {
|
||||
@@ -799,7 +809,7 @@ export interface CastSpellResult {
|
||||
hit?: boolean;
|
||||
xpGained?: number;
|
||||
/** `npc_not_found` also covers players: no target matched. */
|
||||
reason?: 'npc_not_found' | 'out_of_reach' | 'no_runes' | 'not_attackable' | 'timeout';
|
||||
reason?: 'npc_not_found' | 'out_of_reach' | 'no_runes' | 'not_attackable' | 'timeout' | 'unknown_spell';
|
||||
/** What the resolved target was, when one was found. */
|
||||
targetType?: 'npc' | 'player';
|
||||
}
|
||||
|
||||
@@ -185,6 +185,11 @@ export class BotStateCollector implements ScanProvider {
|
||||
modalOpen: (c.mainModalId ?? -1) !== -1,
|
||||
modalInterface: c.mainModalId ?? -1,
|
||||
prayers: this.collectPrayerState(),
|
||||
// These policy-critical settings are ordinary server varps. Keep
|
||||
// them explicit rather than making controllers infer state from a
|
||||
// head icon or blindly click a control that might already be set.
|
||||
isSkulled: (c.var?.[78] ?? 0) > 0,
|
||||
autoRetaliateEnabled: (c.var?.[172] ?? 1) === 0,
|
||||
opFeedback: this.collectOpFeedback(currentTick)
|
||||
};
|
||||
}
|
||||
@@ -945,8 +950,11 @@ export class BotStateCollector implements ScanProvider {
|
||||
// The dispatch paths route to the NPC's route tile with its real
|
||||
// footprint (Client.talkToNpc etc.), so the probe must ask the
|
||||
// exact same question - see reach.ts invariant 1.
|
||||
const tileSceneX = npc.routeX?.[0] ?? npcX >> 7;
|
||||
const tileSceneZ = npc.routeZ?.[0] ?? npcZ >> 7;
|
||||
const size = (npcType.size as number | undefined) ?? 1;
|
||||
const reachable = this.reachProbeReady()
|
||||
? this.reachProbe.canReachEntity(npc.routeX?.[0] ?? npcX >> 7, npc.routeZ?.[0] ?? npcZ >> 7, npcType.size ?? 1)
|
||||
? this.reachProbe.canReachEntity(tileSceneX, tileSceneZ, size)
|
||||
: undefined;
|
||||
|
||||
npcs.push({
|
||||
@@ -957,6 +965,9 @@ export class BotStateCollector implements ScanProvider {
|
||||
combatLevel: npcType.vislevel || 0,
|
||||
x: npcWorldX,
|
||||
z: npcWorldZ,
|
||||
tileX: baseX + tileSceneX,
|
||||
tileZ: baseZ + tileSceneZ,
|
||||
size,
|
||||
distance,
|
||||
hp,
|
||||
maxHp,
|
||||
|
||||
@@ -80,6 +80,15 @@ export interface NearbyNpc {
|
||||
combatLevel: number;
|
||||
x: number;
|
||||
z: number;
|
||||
/**
|
||||
* Authoritative SW route tile (routeX[0]). `x`/`z` are the interpolated
|
||||
* render position, which trails a moving NPC by 1-3 tiles and is offset
|
||||
* by floor(size/2) for multi-tile NPCs; drops spawn on THIS tile.
|
||||
*/
|
||||
tileX: number;
|
||||
tileZ: number;
|
||||
/** Footprint in tiles (1 = single tile, cows/giants are 2+). */
|
||||
size: number;
|
||||
distance: number;
|
||||
/** Current HP, or null until the server reveals it by updating the NPC. */
|
||||
hp: number | null;
|
||||
@@ -428,6 +437,10 @@ export interface BotState {
|
||||
modalInterface: number;
|
||||
/** Prayer state (active prayers, prayer points) */
|
||||
prayers: PrayerState;
|
||||
/** Authoritative client varp 78: a PK skull is currently active. */
|
||||
isSkulled: boolean;
|
||||
/** Authoritative client varp 172: server auto-retaliation is enabled. */
|
||||
autoRetaliateEnabled: boolean;
|
||||
/** Whether the server accepted or silently discarded recent ops */
|
||||
opFeedback: OpFeedbackState;
|
||||
}
|
||||
|
||||
@@ -1349,8 +1349,13 @@ export class Client extends GameShell {
|
||||
return { success: false, reason: 'out_of_scene' };
|
||||
}
|
||||
|
||||
// First send MOVE_OPCLICK via tryMove (type=2) to walk to the item
|
||||
const routed = this.tryMove(
|
||||
// First send MOVE_OPCLICK via tryMove (type=2) to walk to the item.
|
||||
// Mirror the authentic menu handler (OP_OBJ1..5 above): when the
|
||||
// item's own tile is unroutable (it sits on a table or other blocked
|
||||
// scenery), retry with a 1x1 loc footprint, then dispatch regardless -
|
||||
// the server accepts an ADJACENT take (reachedEntity shape -2) and is
|
||||
// the authority on reachability.
|
||||
let routed = this.tryMove(
|
||||
this.localPlayer.routeX[0],
|
||||
this.localPlayer.routeZ[0],
|
||||
sceneX,
|
||||
@@ -1362,7 +1367,17 @@ export class Client extends GameShell {
|
||||
2 // type = MOVE_OPCLICK
|
||||
);
|
||||
if (!routed) {
|
||||
return { success: false, reason: 'cant_reach' };
|
||||
routed = this.tryMove(
|
||||
this.localPlayer.routeX[0],
|
||||
this.localPlayer.routeZ[0],
|
||||
sceneX,
|
||||
sceneZ,
|
||||
false,
|
||||
1, 1,
|
||||
0, 0,
|
||||
0,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
// Then send OPOBJ3 packet (Take is option 3, not option 1)
|
||||
@@ -1371,7 +1386,7 @@ export class Client extends GameShell {
|
||||
this.out.p2(worldZ);
|
||||
this.out.p2(itemId);
|
||||
|
||||
return { success: true };
|
||||
return { success: true, routed };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1395,8 +1410,10 @@ export class Client extends GameShell {
|
||||
return { success: false, reason: 'out_of_scene' };
|
||||
}
|
||||
|
||||
// First send MOVE_OPCLICK via tryMove (type=2) to walk to the item
|
||||
const routed = this.tryMove(
|
||||
// First send MOVE_OPCLICK via tryMove (type=2) to walk to the item.
|
||||
// As in pickupGroundItem: retry an unroutable tile with a 1x1
|
||||
// footprint and dispatch regardless, matching the authentic client.
|
||||
let routed = this.tryMove(
|
||||
this.localPlayer.routeX[0],
|
||||
this.localPlayer.routeZ[0],
|
||||
sceneX,
|
||||
@@ -1408,7 +1425,17 @@ export class Client extends GameShell {
|
||||
2 // type = MOVE_OPCLICK
|
||||
);
|
||||
if (!routed) {
|
||||
return { success: false, reason: 'cant_reach' };
|
||||
routed = this.tryMove(
|
||||
this.localPlayer.routeX[0],
|
||||
this.localPlayer.routeZ[0],
|
||||
sceneX,
|
||||
sceneZ,
|
||||
false,
|
||||
1, 1,
|
||||
0, 0,
|
||||
0,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
// Then send OPOBJ packet
|
||||
@@ -1424,7 +1451,7 @@ export class Client extends GameShell {
|
||||
this.out.p2(worldZ);
|
||||
this.out.p2(itemId);
|
||||
|
||||
return { success: true };
|
||||
return { success: true, routed };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -174,15 +174,20 @@ function groundItemOp(c: LiteClient, worldX: number, worldZ: number, itemId: num
|
||||
return { success: false, reason: 'out_of_scene' };
|
||||
}
|
||||
|
||||
if (!tryMoveTo(c, dest.x, dest.z, 0, 0, 0, 0, 0)) {
|
||||
return { success: false, reason: 'cant_reach' };
|
||||
}
|
||||
// Some quest ground items deliberately sit on blocked scenery (for
|
||||
// example the Fremennik longhall keg on a table) and are operated from an
|
||||
// adjacent square. Attempt the client route - retrying an unroutable tile
|
||||
// with a 1x1 footprint so the player still walks adjacent, exactly like
|
||||
// the authentic client's OP_OBJ menu handler - but always dispatch so the
|
||||
// authoritative server reach check/custom OPOBJ handler can decide.
|
||||
const routed = tryMoveTo(c, dest.x, dest.z, 0, 0, 0, 0, 0)
|
||||
|| tryMoveTo(c, dest.x, dest.z, 1, 1, 0, 0, 0);
|
||||
|
||||
c.writeOpcode([ClientProt.OPOBJ1, ClientProt.OPOBJ2, ClientProt.OPOBJ3, ClientProt.OPOBJ4, ClientProt.OPOBJ5][optionIndex - 1]);
|
||||
c.out.p2(worldX);
|
||||
c.out.p2(worldZ);
|
||||
c.out.p2(itemId);
|
||||
return { success: true };
|
||||
return { success: true, routed };
|
||||
}
|
||||
|
||||
// ============================================================== inventory
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
//
|
||||
// Run from server/webclient so the '#/*' import map resolves.
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { startSession, type LiteSession } from './session.js';
|
||||
import { type ActionResult } from '#/bot/ActionExecutor.js';
|
||||
import { BotActionQueue, type QueuedBotAction } from '#/bot/ActionQueue.js';
|
||||
@@ -355,7 +357,7 @@ if (!botName) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const repoRoot = new URL('../../../../', import.meta.url).pathname;
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url));
|
||||
const envPath = `${repoRoot}bots/${botName}/bot.env`;
|
||||
const env = Object.fromEntries(
|
||||
(await Bun.file(envPath).text())
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
// supervised: `onEnd` re-logs it in with backoff, keeping its stats and its share
|
||||
// of the gold, and the report marks anything that is currently offline.
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { startSession, type LiteSession, type SessionEnd } from './session.js';
|
||||
import { BotStateCollector } from '#/bot/StateCollector.js';
|
||||
import { ActionExecutor } from '#/bot/ActionExecutor.js';
|
||||
@@ -349,7 +351,7 @@ const collectorName = argv.find(a => a.startsWith('--collector='))?.slice('--col
|
||||
const minutes = Number(argv.find(a => a.startsWith('--minutes='))?.slice('--minutes='.length) ?? 0);
|
||||
const DEBUG = argv.find(a => a.startsWith('--debug='))?.slice('--debug='.length) ?? '';
|
||||
|
||||
const repoRoot = new URL('../../../../', import.meta.url).pathname;
|
||||
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url));
|
||||
|
||||
async function readEnv(bot: string): Promise<Record<string, string>> {
|
||||
const text = await Bun.file(`${repoRoot}bots/${bot}/bot.env`).text();
|
||||
|
||||
Reference in new issue
Block a user