mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
fix(sdk): correct interface-option clicks and make quantity actions honest
Audit of codex/porcelain-action-reliability, landing the changes that fix real defects and leaving the speculative parts behind. Interface options are published with a 1-based `index` label, but `sendClickInterfaceOption` takes a 0-based array position. `fletchLogs` and `craftLeather` matched an option by text and then fed its `index` straight into that method, so both crafted the product *after* the one requested — and matching the first option skipped it entirely. Adds `sdk.clickInterfaceOption(selector)`, which resolves by object or visible text and dispatches the option's `componentId`, and uses it at both call sites. `sendClickInterfaceOption` keeps its behaviour with a renamed parameter and a doc comment naming the trap. `buyFromShop`/`sellToShop` decomposed the requested amount into an array of 10/5/1 steps before sending anything, so `buyFromShop(item, 1e9)` allocated a hundred-million-element array. Quantities are now validated up front and the packets are streamed under a packet cap and deadline; `sellAllToShop`'s unbounded `while (true)` is bounded the same way. `sellToShop` reused the shop slot captured before the loop. Selling a batch of non-stackables removes that slot, so later packets sold whatever had slid into it. The slot is re-resolved by item id before each packet. Shop and bank quantity actions returned `success: true` for short fills, so a caller that asked for 28 proceeded believing it had 28. They now report `requestedAmount`, the amount that actually moved, and `partial: true`, and reserve `success` for an exact fill. `chopTree` returned success when the tree merely despawned — which happens when another player fells it — and then reported logs already in the inventory as the ones just chopped. Success now requires the log count to rise; the despawn still ends the wait, as a failure. `withdrawItem` identified the withdrawn item by diffing inventory slots, which names whatever landed in a changed slot rather than what was withdrawn. It now tracks the bank item's id. Also regenerates sdk/API.md, which was already stale on main (`bun run check` fails there at docs:api:check), and adds unit coverage for option resolution and quantity handling to test:focused. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6949fb9874
commit
9958f2b074
9 files changed
+705
-176
No files matched your search
@@ -24,6 +24,17 @@ const withdrawAll = await bot.withdrawItem(0, -1); // withdraws all from slot 0
|
||||
await bot.closeBank();
|
||||
```
|
||||
|
||||
`success` on deposit/withdraw means the **full** requested amount moved. A short
|
||||
fill returns `success: false` with `partial: true`, so check the amount instead
|
||||
of assuming you got everything:
|
||||
|
||||
```typescript
|
||||
const ore = await bot.withdrawItem(/iron ore/i, 28);
|
||||
if (!ore.success) {
|
||||
console.log(`Only got ${ore.amountWithdrawn} of ${ore.requestedAmount} (${ore.reason})`);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Depositing Items
|
||||
|
||||
@@ -85,6 +85,23 @@ if (shopItem && shopItem.count > 0) {
|
||||
}
|
||||
```
|
||||
|
||||
## Partial Fills
|
||||
|
||||
`bot.buyFromShop` / `bot.sellToShop` report `success: true` only when the whole
|
||||
requested amount went through. Running out of stock, coins, or inventory space
|
||||
gives `success: false` with `partial: true` — read the amount rather than
|
||||
assuming the request was filled:
|
||||
|
||||
```typescript
|
||||
const bought = await bot.buyFromShop(/bronze pickaxe/i, 5);
|
||||
if (!bought.success) {
|
||||
console.log(`Got ${bought.amountBought} of ${bought.requestedAmount} (${bought.reason})`);
|
||||
}
|
||||
```
|
||||
|
||||
Quantities above 1000 are rejected up front rather than expanded into thousands
|
||||
of Buy-1 packets.
|
||||
|
||||
## Money-Making Alternatives
|
||||
|
||||
Since general stores are unreliable for selling, consider:
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"docs:api:check": "bun sdk/generate-api-docs.ts --check",
|
||||
"fetch-collision-data": "bun sdk/fetch-collision-data.ts",
|
||||
"test:docs": "bun test sdk/test/api-docs.test.ts sdk/test/docs-snippets.test.ts",
|
||||
"test:focused": "bun sdk/test/chat-history.test.ts && bun sdk/test/click-dialog-by-text.test.ts && bun run test:docs",
|
||||
"test:focused": "bun sdk/test/chat-history.test.ts && bun sdk/test/click-dialog-by-text.test.ts && bun test sdk/test/action-quantity.test.ts && bun run test:docs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:webclient": "tsc --noEmit -p server/webclient/tsconfig.json"
|
||||
},
|
||||
|
||||
+44
-8
@@ -69,8 +69,8 @@
|
||||
|---|---|
|
||||
| `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 buyFromShop(target: ShopItem \| string \| RegExp, amount: number = 1): Promise<ShopResult>` | Buy an item from an open shop . |
|
||||
| `async sellToShop(target: InventoryItem \| ShopItem \| string \| RegExp, amount: SellAmount = 1): Promise<ShopSellResult>` | Sell an item to an open shop. |
|
||||
| `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`. |
|
||||
|
||||
### Banking
|
||||
|
||||
@@ -149,7 +149,7 @@
|
||||
| `getChat(opts: { limit?: number; types?: readonly number[]; includeSelf?: boolean } = {}): GameMessage[]` | Read recent chat messages. Returns player chat (public + PMs) by default, newest last. Reads from the SDK's accumulated history — up to 500 messages retained since connect — so old lines survive both system spam (level-ups, combat) and the client's own 100-deep ring eviction. |
|
||||
| `getNewChat(opts: { types?: readonly number[]; includeSelf?: boolean } = {}): GameMessage[]` | Read only chat messages that have arrived since the last call (cursor-based, newest last). Repeat polls never re-show the same message — no need to hand-roll a baseline. The first call returns everything seen since connect. Excludes your own messages by default. |
|
||||
| `getChatFrom(name: string, opts: { limit?: number } = {}): GameMessage[]` | Read recent chat from a specific sender (case-insensitive, substring match on name), newest last, from the accumulated history. Handy for "what did my partner say?" without regex-matching the sender field yourself. |
|
||||
| `getSkill(name: string): SkillState \| null` | Get a skill by name (case-insensitive). |
|
||||
| `getSkill(name: string): SkillState \| null` | Get a skill by name (case-insensitive; "hp"/"hitpoint" alias Hitpoints). |
|
||||
| `getSkillXp(name: string): number \| null` | Get XP for a skill by name. |
|
||||
| `getSkills(): SkillState[]` | Get all skills. |
|
||||
| `getInventoryItem(slot: number): InventoryItem \| null` | Get inventory item by slot number. |
|
||||
@@ -174,6 +174,7 @@
|
||||
| `getPrayerState(): PrayerState \| null` | Get current prayer state from world state. |
|
||||
| `isPrayerActive(prayer: PrayerName \| number): boolean` | Check if a specific prayer is currently active. |
|
||||
| `getActivePrayers(): PrayerName[]` | Get list of all currently active prayer names. |
|
||||
| `isDoorTemporarilyBlocked(level: number, x: number, z: number): boolean` | Check this SDK session's non-expired temporary door evidence. |
|
||||
|
||||
### Other
|
||||
|
||||
@@ -181,6 +182,9 @@
|
||||
|---|---|
|
||||
| `async checkBotStatus(): Promise<BotStatus>` | Check bot status via gateway HTTP endpoint. Returns info about whether bot is connected and who else is controlling/observing. |
|
||||
| `async launchBrowser(): Promise<void>` | Launch native browser to client URL. Uses the `open` package for cross-platform support (macOS, Windows, Linux, WSL). Falls back to printing the URL if no browser can be opened. |
|
||||
| `countInventoryItems(pattern: string \| RegExp): number` | Count total item quantity matching a name pattern. This sums stack sizes across every matching slot. Use `getInventory().filter(...)` when the number of occupied slots is needed. |
|
||||
| `async clickInterfaceOption(selector: InterfaceOptionSelector): Promise<ActionResult>` | Click exactly one interface option, selected by its state object or by visible text (substring for strings, match for regexes). This dispatches the option's `componentId` and never interprets `InterfaceOption.index` as an array position. |
|
||||
| `blockDoorTemporarily(level: number, x: number, z: number, ttlMs: number = 30_000): boolean` | Temporarily exclude a known door from this SDK instance's path queries. The shared collision map is never mutated beyond the synchronous query. |
|
||||
|
||||
### Condition Waiting
|
||||
|
||||
@@ -224,7 +228,7 @@
|
||||
| `async clickDialogByText(pattern: string \| RegExp): Promise<ActionResult>` | Click a dialog option whose visible text matches `pattern`. Convenience wrapper that resolves the server-assigned index for you, sidestepping the 1-based vs 0-based array-position confusion of `sendClickDialog()`. Matches against `DialogOption.text` (case-insensitive by default for string patterns). |
|
||||
| `async sendClickComponent(componentId: number): Promise<ActionResult>` | Click a component using IF_BUTTON packet - for simple buttons, spellcasting, etc. |
|
||||
| `async sendClickComponentWithOption(componentId: number, optionIndex: number = 1, slot: number = 0): Promise<ActionResult>` | Click a component using INV_BUTTON packet - for components with inventory operations (smithing, crafting, etc.) |
|
||||
| `async sendClickInterfaceOption(optionIndex: number): Promise<ActionResult>` | Click an interface option by index. Convenience wrapper that looks up componentId from state. |
|
||||
| `async sendClickInterfaceOption(arrayPosition: number): Promise<ActionResult>` | Click an interface option by **0-based array position**. Note the mismatch: `InterfaceOption.index` is a 1-based display label, so passing one straight through clicks the option after the one you matched. Prefer `clickInterfaceOption()` when selecting from published state. |
|
||||
| `async sendAcceptCharacterDesign(): Promise<ActionResult>` | Accept character design in tutorial. |
|
||||
| `async sendRandomizeCharacterDesign(): Promise<ActionResult>` | Randomize character appearance in tutorial. |
|
||||
| `async sendShopBuy(slot: number, amount: number = 1): Promise<ActionResult>` | Buy from shop by slot and amount. |
|
||||
@@ -295,6 +299,14 @@ interface PlayerState {
|
||||
spotanimId: number;
|
||||
/** Combat state tracking */
|
||||
combat: PlayerCombatState;
|
||||
/** True while the player's hitpoints are zero. */
|
||||
isDead: boolean;
|
||||
/** Changes after each observed death/respawn cycle. */
|
||||
lifeId: number;
|
||||
/** Number of respawns observed during this client session. */
|
||||
respawnCount: number;
|
||||
/** Public game tick when death was last observed, or null if none was observed. */
|
||||
lastDeathTick: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
@@ -327,7 +339,7 @@ interface DialogState {
|
||||
interface InterfaceState {
|
||||
isOpen: boolean;
|
||||
interfaceId: number;
|
||||
options: Array<{ index: number; text: string; componentId: number }>;
|
||||
options: InterfaceOption[];
|
||||
}
|
||||
```
|
||||
|
||||
@@ -390,6 +402,8 @@ interface PrayerResult {
|
||||
```typescript
|
||||
interface BotWorldState {
|
||||
tick: number;
|
||||
/** Monotonic state publication cursor; advances even for multiple publications in one game tick. */
|
||||
revision?: number;
|
||||
inGame: boolean;
|
||||
player: PlayerState | null;
|
||||
skills: SkillState[];
|
||||
@@ -423,6 +437,8 @@ interface ActionResult {
|
||||
data?: any;
|
||||
/** Machine-readable failure category (e.g. 'cant_reach', 'no_match', 'timeout') */
|
||||
reason?: string;
|
||||
/** Primitive actions report dispatch; porcelain actions may report observation/completion. */
|
||||
phase?: 'validation' | 'routing' | 'dispatch' | 'observation' | 'completion';
|
||||
}
|
||||
```
|
||||
|
||||
@@ -488,9 +504,15 @@ interface TalkResult {
|
||||
|
||||
```typescript
|
||||
interface ShopResult {
|
||||
/** True only when the full requested amount was bought. */
|
||||
success: boolean;
|
||||
item?: InventoryItem;
|
||||
message: string;
|
||||
requestedAmount?: number;
|
||||
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';
|
||||
}
|
||||
```
|
||||
|
||||
@@ -498,10 +520,15 @@ interface ShopResult {
|
||||
|
||||
```typescript
|
||||
interface ShopSellResult {
|
||||
/** True only when the full requested amount was sold. */
|
||||
success: boolean;
|
||||
message: string;
|
||||
requestedAmount?: number;
|
||||
amountSold?: number;
|
||||
/** Some but not all of the requested amount was sold. */
|
||||
partial?: boolean;
|
||||
rejected?: boolean;
|
||||
reason?: 'invalid_amount' | 'shop_not_open' | 'item_not_found' | 'rejected' | 'partial_fill' | 'timeout';
|
||||
}
|
||||
```
|
||||
|
||||
@@ -540,7 +567,7 @@ interface EatResult {
|
||||
interface AttackResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
reason?: 'npc_not_found' | 'no_attack_option' | 'out_of_reach' | 'already_in_combat' | 'timeout';
|
||||
reason?: 'npc_not_found' | 'no_attack_option' | 'out_of_reach' | 'already_in_combat' | 'died' | 'timeout';
|
||||
}
|
||||
```
|
||||
|
||||
@@ -617,10 +644,14 @@ interface OpenBankResult {
|
||||
|
||||
```typescript
|
||||
interface BankDepositResult {
|
||||
/** True only when the full requested amount was deposited. */
|
||||
success: boolean;
|
||||
message: string;
|
||||
requestedAmount?: number;
|
||||
amountDeposited?: number;
|
||||
reason?: 'bank_not_open' | 'item_not_found' | 'timeout';
|
||||
/** Some but not all of the requested amount was deposited. */
|
||||
partial?: boolean;
|
||||
reason?: 'invalid_amount' | 'bank_not_open' | 'item_not_found' | 'partial_fill' | 'timeout';
|
||||
}
|
||||
```
|
||||
|
||||
@@ -628,10 +659,15 @@ interface BankDepositResult {
|
||||
|
||||
```typescript
|
||||
interface BankWithdrawResult {
|
||||
/** True only when the full requested amount was withdrawn. */
|
||||
success: boolean;
|
||||
message: string;
|
||||
item?: InventoryItem;
|
||||
reason?: 'bank_not_open' | 'item_not_found' | 'timeout';
|
||||
requestedAmount?: number;
|
||||
amountWithdrawn?: number;
|
||||
/** Some but not all of the requested amount was withdrawn. */
|
||||
partial?: boolean;
|
||||
reason?: 'invalid_amount' | 'bank_not_open' | 'item_not_found' | 'partial_fill' | 'timeout';
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { InterfaceOption, InventoryItem } from './types';
|
||||
|
||||
/**
|
||||
* Helpers for porcelain actions that take a quantity and expand it into
|
||||
* repeated game packets, plus the interface-option resolution those actions
|
||||
* need. Kept separate from actions.ts so the logic is unit-testable without a
|
||||
* live connection.
|
||||
*/
|
||||
|
||||
export type InterfaceOptionSelector = InterfaceOption | string | RegExp;
|
||||
|
||||
function testPattern(pattern: string | RegExp, value: string): boolean {
|
||||
if (typeof pattern === 'string') {
|
||||
return value.toLowerCase().includes(pattern.toLowerCase());
|
||||
}
|
||||
// Reset in case the caller handed us a /g or /y regex, whose lastIndex
|
||||
// otherwise makes repeated .test() calls alternate between true and false.
|
||||
pattern.lastIndex = 0;
|
||||
return pattern.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a published interface option by object identity or visible text.
|
||||
*
|
||||
* `InterfaceOption.index` is a human-facing 1-based label, but
|
||||
* `sendClickInterfaceOption` takes a 0-based array position. Callers that fed
|
||||
* one into the other clicked the option *after* the one they matched; use this
|
||||
* instead and dispatch the returned `componentId`.
|
||||
*/
|
||||
export function resolveInterfaceOption(
|
||||
options: readonly InterfaceOption[],
|
||||
selector: InterfaceOptionSelector,
|
||||
): InterfaceOption | null {
|
||||
if (typeof selector === 'object' && !(selector instanceof RegExp)) {
|
||||
return options.find(option => option.componentId === selector.componentId) ?? null;
|
||||
}
|
||||
return options.find(option => testPattern(selector, option.text)) ?? null;
|
||||
}
|
||||
|
||||
/** Total count of an item across every inventory slot holding it. */
|
||||
export function countItems(
|
||||
items: readonly InventoryItem[],
|
||||
target: number | string | RegExp,
|
||||
): number {
|
||||
return items.reduce((total, item) => {
|
||||
const matches = typeof target === 'number'
|
||||
? item.id === target
|
||||
: testPattern(target, item.name);
|
||||
return matches ? total + item.count : total;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** Upper bound for quantities that expand into repeated shop/bank packets. */
|
||||
export const MAX_ACTION_QUANTITY = 10_000;
|
||||
/** Shops accept Buy/Sell-10 at most, so 1000 items is already 100 packets. */
|
||||
export const MAX_SHOP_ACTION_QUANTITY = 1_000;
|
||||
/** Hard cap on packets a single shop call may emit, independent of quantity. */
|
||||
export const MAX_SHOP_ACTION_PACKETS = 120;
|
||||
/** Wall-clock ceiling for a multi-packet shop call. */
|
||||
export const SHOP_ACTION_DEADLINE_MS = 60_000;
|
||||
|
||||
export type QuantityValidation =
|
||||
| { valid: true; amount: number }
|
||||
| { valid: false; message: string };
|
||||
|
||||
/**
|
||||
* Validate a quantity before it controls a loop or is serialized into a packet.
|
||||
* The -1 "all" sentinel is only accepted when `allowAll` is set.
|
||||
*/
|
||||
export function validateActionQuantity(
|
||||
amount: number,
|
||||
options: { allowAll?: boolean; max?: number } = {},
|
||||
): QuantityValidation {
|
||||
if (typeof amount !== 'number' || !Number.isFinite(amount)) {
|
||||
return { valid: false, message: 'Amount must be a finite number' };
|
||||
}
|
||||
if (options.allowAll && amount === -1) return { valid: true, amount };
|
||||
if (!Number.isSafeInteger(amount) || amount <= 0) {
|
||||
return { valid: false, message: `Amount must be a positive integer, got ${amount}` };
|
||||
}
|
||||
const max = options.max ?? MAX_ACTION_QUANTITY;
|
||||
if (amount > max) {
|
||||
return { valid: false, message: `Amount ${amount} exceeds the maximum ${max}` };
|
||||
}
|
||||
return { valid: true, amount };
|
||||
}
|
||||
|
||||
/** Largest shop step (10/5/1) that fits in `remaining`. */
|
||||
export function nextShopStep(remaining: number): number {
|
||||
return remaining >= 10 ? 10 : remaining >= 5 ? 5 : 1;
|
||||
}
|
||||
|
||||
export interface QuantityOutcome {
|
||||
requested: number;
|
||||
actual: number;
|
||||
/** True only when the full requested amount landed. */
|
||||
complete: boolean;
|
||||
/** True when some — but not all — of the requested amount landed. */
|
||||
partial: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quantity success is exact. A partial fill is reported as unsuccessful so a
|
||||
* caller that asked for 28 ore never proceeds believing it has 28.
|
||||
*/
|
||||
export function classifyQuantity(requested: number, actual: number): QuantityOutcome {
|
||||
const normalizedRequested = Math.max(0, Math.floor(requested));
|
||||
const normalizedActual = Math.max(0, Math.floor(actual));
|
||||
return {
|
||||
requested: normalizedRequested,
|
||||
actual: normalizedActual,
|
||||
complete: normalizedActual === normalizedRequested,
|
||||
partial: normalizedActual > 0 && normalizedActual < normalizedRequested,
|
||||
};
|
||||
}
|
||||
+276
-151
@@ -45,6 +45,16 @@ import type {
|
||||
StringAmuletResult,
|
||||
} from './types';
|
||||
import { PRAYER_INDICES, PRAYER_NAMES, PRAYER_LEVELS } from './types';
|
||||
import {
|
||||
classifyQuantity,
|
||||
countItems,
|
||||
nextShopStep,
|
||||
resolveInterfaceOption,
|
||||
validateActionQuantity,
|
||||
MAX_SHOP_ACTION_PACKETS,
|
||||
MAX_SHOP_ACTION_QUANTITY,
|
||||
SHOP_ACTION_DEADLINE_MS,
|
||||
} from './action-quantity';
|
||||
|
||||
// Modal interfaces dismissBlockingUI must never auto-close: closing these is
|
||||
// destructive (declines a two-party screen) or strands the player (must be
|
||||
@@ -565,26 +575,31 @@ export class BotActions {
|
||||
return { success: false, message: 'No tree found' };
|
||||
}
|
||||
|
||||
const invCountBefore = this.sdk.getInventory().length;
|
||||
const logsBefore = this.sdk.countInventoryItems(/logs/i);
|
||||
const result = await this.sdk.sendInteractLoc(tree.x, tree.z, tree.id, 1);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, message: result.message };
|
||||
}
|
||||
|
||||
// Success requires *gaining* logs. Returning on "the tree despawned"
|
||||
// alone reported success when another player felled it first, and then
|
||||
// handed back logs the bot was already carrying as the ones it chopped.
|
||||
// The despawn still short-circuits the wait — it just isn't success.
|
||||
try {
|
||||
await this.sdk.waitForCondition(state => {
|
||||
const newItem = state.inventory.length > invCountBefore;
|
||||
const treeGone = !state.nearbyLocs.find(l =>
|
||||
l.x === tree.x && l.z === tree.z && l.id === tree.id
|
||||
);
|
||||
return newItem || treeGone;
|
||||
const finalState = await this.sdk.waitForCondition(state => {
|
||||
if (countItems(state.inventory, /logs/i) > logsBefore) return true;
|
||||
return !state.nearbyLocs.some(l => l.x === tree.x && l.z === tree.z && l.id === tree.id);
|
||||
}, 30000);
|
||||
|
||||
const logs = this.sdk.findInventoryItem(/logs/i);
|
||||
return { success: true, logs: logs || undefined, message: 'Chopped tree' };
|
||||
if (countItems(finalState.inventory, /logs/i) <= logsBefore) {
|
||||
return { success: false, message: `${tree.name} was felled by someone else` };
|
||||
}
|
||||
|
||||
const logs = finalState.inventory.find(item => /logs/i.test(item.name));
|
||||
return { success: true, logs, message: `Chopped ${tree.name}` };
|
||||
} catch {
|
||||
return { success: false, message: 'Timed out waiting for tree chop' };
|
||||
return { success: false, message: 'Timed out waiting for logs from tree chop' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1047,114 +1062,161 @@ export class BotActions {
|
||||
}
|
||||
}
|
||||
|
||||
/** Buy an item from an open shop .*/
|
||||
/**
|
||||
* 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 buyFromShop(target: ShopItem | string | RegExp, amount: number = 1): Promise<ShopResult> {
|
||||
const validated = validateActionQuantity(amount, { max: MAX_SHOP_ACTION_QUANTITY });
|
||||
if (!validated.valid) {
|
||||
return { success: false, message: validated.message, reason: 'invalid_amount' };
|
||||
}
|
||||
const requestedAmount = validated.amount;
|
||||
|
||||
const shop = this.sdk.getState()?.shop;
|
||||
if (!shop?.isOpen) {
|
||||
return { success: false, message: 'Shop is not open' };
|
||||
return { success: false, message: 'Shop is not open', reason: 'shop_not_open' };
|
||||
}
|
||||
|
||||
const shopItem = this.helpers.resolveShopItem(target, shop.shopItems);
|
||||
if (!shopItem) {
|
||||
return { success: false, message: `Item not found in shop: ${target}` };
|
||||
return { success: false, message: `Item not found in shop: ${target}`, reason: 'item_not_found' };
|
||||
}
|
||||
|
||||
// Count total items across all inventory slots (handles non-stackable items)
|
||||
const countInvItems = () =>
|
||||
this.sdk.getInventory()
|
||||
.filter(i => i.id === shopItem.id)
|
||||
.reduce((sum, i) => sum + i.count, 0);
|
||||
const countInvItems = () => countItems(this.sdk.getInventory(), shopItem.id);
|
||||
|
||||
const totalBefore = countInvItems();
|
||||
const outcome = (): ShopResult => {
|
||||
const amountBought = Math.max(0, countInvItems() - totalBefore);
|
||||
const quantity = classifyQuantity(requestedAmount, amountBought);
|
||||
return {
|
||||
success: quantity.complete,
|
||||
item: this.sdk.getInventory().find(i => i.id === shopItem.id),
|
||||
requestedAmount,
|
||||
amountBought,
|
||||
partial: quantity.partial,
|
||||
message: quantity.complete
|
||||
? `Bought ${shopItem.name} x${amountBought}`
|
||||
: `Bought ${shopItem.name} x${amountBought} of ${requestedAmount} requested`,
|
||||
reason: quantity.complete ? undefined : quantity.partial ? 'partial_fill' : 'timeout',
|
||||
};
|
||||
};
|
||||
|
||||
// Decompose amount into valid buy commands (10, 5, 1)
|
||||
let remaining = Math.max(1, Math.floor(amount));
|
||||
const buySteps: number[] = [];
|
||||
while (remaining > 0) {
|
||||
if (remaining >= 10) { buySteps.push(10); remaining -= 10; }
|
||||
else if (remaining >= 5) { buySteps.push(5); remaining -= 5; }
|
||||
else { buySteps.push(1); remaining -= 1; }
|
||||
}
|
||||
|
||||
for (const stepAmount of buySteps) {
|
||||
// Stream Buy-10/5/1 packets rather than materializing an amount-sized
|
||||
// array of steps: buyFromShop(item, 1e9) used to allocate a
|
||||
// hundred-million-element array before sending a single packet.
|
||||
let remaining = requestedAmount;
|
||||
let packetsSent = 0;
|
||||
const deadline = Date.now() + SHOP_ACTION_DEADLINE_MS;
|
||||
while (remaining > 0 && packetsSent < MAX_SHOP_ACTION_PACKETS && Date.now() < deadline) {
|
||||
const stepAmount = nextShopStep(remaining);
|
||||
const countBefore = countInvItems();
|
||||
|
||||
const result = await this.sdk.sendShopBuy(shopItem.slot, stepAmount);
|
||||
if (!result.success) {
|
||||
const totalBought = countInvItems() - totalBefore;
|
||||
if (totalBought > 0) {
|
||||
const boughtItem = this.sdk.getInventory().find(i => i.id === shopItem.id);
|
||||
return { success: true, item: boughtItem, message: `Bought ${shopItem.name} x${totalBought} (wanted ${amount})` };
|
||||
}
|
||||
return { success: false, message: result.message };
|
||||
const failed = outcome();
|
||||
if (failed.amountBought === 0) failed.message = result.message;
|
||||
return failed;
|
||||
}
|
||||
packetsSent++;
|
||||
|
||||
try {
|
||||
await this.sdk.waitForCondition(state => {
|
||||
const total = state.inventory
|
||||
.filter(i => i.id === shopItem.id)
|
||||
.reduce((sum, i) => sum + i.count, 0);
|
||||
return total > countBefore;
|
||||
}, 5000);
|
||||
await this.sdk.waitForCondition(
|
||||
state => countItems(state.inventory, shopItem.id) > countBefore,
|
||||
5000,
|
||||
);
|
||||
} catch {
|
||||
const totalBought = countInvItems() - totalBefore;
|
||||
if (totalBought > 0) {
|
||||
const boughtItem = this.sdk.getInventory().find(i => i.id === shopItem.id);
|
||||
return { success: true, item: boughtItem, message: `Bought ${shopItem.name} x${totalBought} (wanted ${amount})` };
|
||||
const failed = outcome();
|
||||
if (failed.amountBought === 0) {
|
||||
failed.message = `Failed to buy ${shopItem.name} (no coins or out of stock?)`;
|
||||
}
|
||||
return { success: false, message: `Failed to buy ${shopItem.name} (no coins or out of stock?)` };
|
||||
return failed;
|
||||
}
|
||||
remaining -= stepAmount;
|
||||
}
|
||||
|
||||
const totalBought = countInvItems() - totalBefore;
|
||||
const boughtItem = this.sdk.getInventory().find(i => i.id === shopItem.id);
|
||||
return { success: true, item: boughtItem, message: `Bought ${shopItem.name} x${totalBought}` };
|
||||
return outcome();
|
||||
}
|
||||
|
||||
/** Sell an item to an open shop. */
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
async sellToShop(target: InventoryItem | ShopItem | string | RegExp, amount: SellAmount = 1): Promise<ShopSellResult> {
|
||||
const validated = amount === 'all'
|
||||
? null
|
||||
: validateActionQuantity(amount, { max: MAX_SHOP_ACTION_QUANTITY });
|
||||
if (validated && !validated.valid) {
|
||||
return { success: false, message: validated.message, reason: 'invalid_amount' };
|
||||
}
|
||||
|
||||
const shop = this.sdk.getState()?.shop;
|
||||
if (!shop?.isOpen) {
|
||||
return { success: false, message: 'Shop is not open' };
|
||||
return { success: false, message: 'Shop is not open', reason: 'shop_not_open' };
|
||||
}
|
||||
|
||||
const sellItem = this.helpers.resolveShopItem(target, shop.playerItems);
|
||||
if (!sellItem) {
|
||||
return { success: false, message: `Item not found to sell: ${target}` };
|
||||
return { success: false, message: `Item not found to sell: ${target}`, reason: 'item_not_found' };
|
||||
}
|
||||
|
||||
const startTick = this.sdk.getState()?.tick || 0;
|
||||
const msgBaseline = this.helpers.getMessageTick();
|
||||
|
||||
if (amount === 'all') {
|
||||
return this.sellAllToShop(sellItem, startTick, msgBaseline);
|
||||
}
|
||||
|
||||
const getTotalCount = (playerItems: typeof shop.playerItems) =>
|
||||
const getTotalCount = (playerItems: readonly ShopItem[]) =>
|
||||
playerItems.filter(i => i.id === sellItem.id).reduce((sum, i) => sum + i.count, 0);
|
||||
|
||||
// Decompose amount into valid sell commands (10, 5, 1)
|
||||
let remaining = Math.max(1, Math.floor(amount));
|
||||
const sellSteps: number[] = [];
|
||||
while (remaining > 0) {
|
||||
if (remaining >= 10) { sellSteps.push(10); remaining -= 10; }
|
||||
else if (remaining >= 5) { sellSteps.push(5); remaining -= 5; }
|
||||
else { sellSteps.push(1); remaining -= 1; }
|
||||
if (amount === 'all') {
|
||||
return this.sellAllToShop(sellItem, getTotalCount(shop.playerItems), msgBaseline);
|
||||
}
|
||||
|
||||
const totalCountBefore = getTotalCount(shop.playerItems);
|
||||
const requestedAmount = validated!.amount;
|
||||
const outcome = (rejected = false): ShopSellResult => {
|
||||
const amountSold = Math.max(
|
||||
0,
|
||||
totalCountBefore - getTotalCount(this.sdk.getState()?.shop.playerItems ?? []),
|
||||
);
|
||||
const quantity = classifyQuantity(requestedAmount, amountSold);
|
||||
const ok = quantity.complete && !rejected;
|
||||
return {
|
||||
success: ok,
|
||||
requestedAmount,
|
||||
amountSold,
|
||||
partial: quantity.partial,
|
||||
rejected,
|
||||
message: ok
|
||||
? `Sold ${sellItem.name} x${amountSold}`
|
||||
: `Sold ${sellItem.name} x${amountSold} of ${requestedAmount} requested`,
|
||||
reason: ok ? undefined : rejected ? 'rejected' : quantity.partial ? 'partial_fill' : 'timeout',
|
||||
};
|
||||
};
|
||||
|
||||
for (const stepAmount of sellSteps) {
|
||||
const countBefore = getTotalCount(this.sdk.getState()?.shop.playerItems ?? []);
|
||||
// Stream Sell-10/5/1 packets (see buyFromShop) and re-resolve the shop
|
||||
// slot before each one: selling a batch of non-stackables removes the
|
||||
// anchor slot, so reusing sellItem.slot sold whatever slid into it.
|
||||
let remaining = requestedAmount;
|
||||
let packetsSent = 0;
|
||||
const deadline = Date.now() + SHOP_ACTION_DEADLINE_MS;
|
||||
while (remaining > 0 && packetsSent < MAX_SHOP_ACTION_PACKETS && Date.now() < deadline) {
|
||||
const currentPlayerItems = this.sdk.getState()?.shop.playerItems ?? [];
|
||||
const currentSellItem = currentPlayerItems.find(i => i.id === sellItem.id);
|
||||
if (!currentSellItem) return outcome();
|
||||
const countBefore = getTotalCount(currentPlayerItems);
|
||||
const stepAmount = nextShopStep(remaining);
|
||||
|
||||
const result = await this.sdk.sendShopSell(sellItem.slot, stepAmount);
|
||||
const result = await this.sdk.sendShopSell(currentSellItem.slot, stepAmount);
|
||||
if (!result.success) {
|
||||
const totalSold = totalCountBefore - getTotalCount(this.sdk.getState()?.shop.playerItems ?? []);
|
||||
if (totalSold > 0) {
|
||||
return { success: true, message: `Sold ${sellItem.name} x${totalSold} (wanted ${amount})`, amountSold: totalSold };
|
||||
}
|
||||
return { success: false, message: result.message };
|
||||
const failed = outcome();
|
||||
if (failed.amountSold === 0) failed.message = result.message;
|
||||
return failed;
|
||||
}
|
||||
packetsSent++;
|
||||
|
||||
try {
|
||||
const finalState = await this.sdk.waitForCondition(state => {
|
||||
@@ -1174,40 +1236,44 @@ export class BotActions {
|
||||
for (const msg of finalState.gameMessages) {
|
||||
if (this.helpers.isMessageAfter(msg, msgBaseline)) {
|
||||
const text = msg.text.toLowerCase();
|
||||
let rejection: string | null = null;
|
||||
if (text.includes("can't sell this item to this shop")) {
|
||||
return { success: false, message: `Shop doesn't buy ${sellItem.name}`, rejected: true };
|
||||
rejection = `Shop doesn't buy ${sellItem.name}`;
|
||||
} else if (text.includes("can't sell this item to a shop")) {
|
||||
rejection = `Cannot sell ${sellItem.name} to any shop`;
|
||||
} else if (text.includes("can't sell this item")) {
|
||||
rejection = `${sellItem.name} is not tradeable`;
|
||||
}
|
||||
if (text.includes("can't sell this item to a shop")) {
|
||||
return { success: false, message: `Cannot sell ${sellItem.name} to any shop`, rejected: true };
|
||||
}
|
||||
if (text.includes("can't sell this item")) {
|
||||
return { success: false, message: `${sellItem.name} is not tradeable`, rejected: true };
|
||||
if (rejection) {
|
||||
const result = outcome(true);
|
||||
result.message = rejection;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const totalSold = totalCountBefore - getTotalCount(this.sdk.getState()?.shop.playerItems ?? []);
|
||||
if (totalSold > 0) {
|
||||
return { success: true, message: `Sold ${sellItem.name} x${totalSold} (wanted ${amount})`, amountSold: totalSold };
|
||||
}
|
||||
return { success: false, message: `Failed to sell ${sellItem.name} (timeout)` };
|
||||
const failed = outcome();
|
||||
if (failed.amountSold === 0) failed.message = `Failed to sell ${sellItem.name} (timeout)`;
|
||||
return failed;
|
||||
}
|
||||
remaining -= stepAmount;
|
||||
}
|
||||
|
||||
const totalCountAfter = getTotalCount(this.sdk.getState()?.shop.playerItems ?? []);
|
||||
const totalSold = totalCountBefore - totalCountAfter;
|
||||
|
||||
return { success: true, message: `Sold ${sellItem.name} x${totalSold}`, amountSold: totalSold };
|
||||
return outcome();
|
||||
}
|
||||
|
||||
private async sellAllToShop(sellItem: ShopItem, startTick: number, msgBaseline: number): Promise<ShopSellResult> {
|
||||
private async sellAllToShop(sellItem: ShopItem, requestedAmount: number, msgBaseline: number): Promise<ShopSellResult> {
|
||||
let totalSold = 0;
|
||||
let packetsSent = 0;
|
||||
const deadline = Date.now() + SHOP_ACTION_DEADLINE_MS;
|
||||
|
||||
const getTotalCount = (playerItems: ShopItem[]) => {
|
||||
return playerItems.filter(i => i.id === sellItem.id).reduce((sum, i) => sum + i.count, 0);
|
||||
};
|
||||
|
||||
while (true) {
|
||||
// Bounded rather than `while (true)`: a shop that accepts the packet but
|
||||
// never decrements used to spin here until the caller gave up.
|
||||
while (packetsSent < MAX_SHOP_ACTION_PACKETS && Date.now() < deadline) {
|
||||
const state = this.sdk.getState();
|
||||
if (!state?.shop.isOpen) {
|
||||
break;
|
||||
@@ -1226,6 +1292,7 @@ export class BotActions {
|
||||
if (!result.success) {
|
||||
break;
|
||||
}
|
||||
packetsSent++;
|
||||
|
||||
try {
|
||||
const finalState = await this.sdk.waitForCondition(s => {
|
||||
@@ -1246,20 +1313,26 @@ export class BotActions {
|
||||
const text = msg.text.toLowerCase();
|
||||
if (text.includes("can't sell this item to this shop")) {
|
||||
return {
|
||||
success: totalSold > 0,
|
||||
success: false,
|
||||
message: totalSold > 0
|
||||
? `Sold ${sellItem.name} x${totalSold}, then shop stopped buying`
|
||||
? `Sold ${sellItem.name} x${totalSold} of ${requestedAmount}, then shop stopped buying`
|
||||
: `Shop doesn't buy ${sellItem.name}`,
|
||||
requestedAmount,
|
||||
amountSold: totalSold,
|
||||
rejected: true
|
||||
partial: totalSold > 0,
|
||||
rejected: true,
|
||||
reason: 'rejected',
|
||||
};
|
||||
}
|
||||
if (text.includes("can't sell this item")) {
|
||||
return {
|
||||
success: false,
|
||||
message: `${sellItem.name} cannot be sold`,
|
||||
requestedAmount,
|
||||
amountSold: totalSold,
|
||||
rejected: true
|
||||
partial: totalSold > 0,
|
||||
rejected: true,
|
||||
reason: 'rejected',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1278,11 +1351,19 @@ export class BotActions {
|
||||
}
|
||||
}
|
||||
|
||||
if (totalSold === 0) {
|
||||
return { success: false, message: `Failed to sell any ${sellItem.name}` };
|
||||
}
|
||||
|
||||
return { success: true, message: `Sold ${sellItem.name} x${totalSold}`, amountSold: totalSold };
|
||||
const quantity = classifyQuantity(requestedAmount, totalSold);
|
||||
return {
|
||||
success: quantity.complete,
|
||||
message: quantity.complete
|
||||
? `Sold ${sellItem.name} x${totalSold}`
|
||||
: totalSold === 0
|
||||
? `Failed to sell any ${sellItem.name}`
|
||||
: `Sold ${sellItem.name} x${totalSold} of ${requestedAmount} requested`,
|
||||
requestedAmount,
|
||||
amountSold: totalSold,
|
||||
partial: quantity.partial,
|
||||
reason: quantity.complete ? undefined : quantity.partial ? 'partial_fill' : 'timeout',
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Porcelain: Bank Actions ============
|
||||
@@ -1435,6 +1516,12 @@ export class BotActions {
|
||||
|
||||
/** Deposit an item into the bank. Use -1 for all. */
|
||||
async depositItem(target: InventoryItem | string | RegExp, amount: number = -1): Promise<BankDepositResult> {
|
||||
const validated = validateActionQuantity(amount, { allowAll: true });
|
||||
if (!validated.valid) {
|
||||
return { success: false, message: validated.message, reason: 'invalid_amount' };
|
||||
}
|
||||
const dispatchAmount = validated.amount;
|
||||
|
||||
const state = this.sdk.getState();
|
||||
if (!state?.interface?.isOpen) {
|
||||
return { success: false, message: 'Bank is not open', reason: 'bank_not_open' };
|
||||
@@ -1445,68 +1532,108 @@ export class BotActions {
|
||||
return { success: false, message: `Item not found in inventory: ${target}`, reason: 'item_not_found' };
|
||||
}
|
||||
|
||||
const countBefore = state.inventory.filter(i => i.id === item.id).reduce((sum, i) => sum + i.count, 0);
|
||||
const countBefore = countItems(state.inventory, item.id);
|
||||
const requestedAmount = dispatchAmount === -1 ? countBefore : dispatchAmount;
|
||||
|
||||
await this.sdk.sendBankDeposit(item.slot, amount);
|
||||
await this.sdk.sendBankDeposit(item.slot, dispatchAmount);
|
||||
|
||||
try {
|
||||
await this.sdk.waitForCondition(s => {
|
||||
const countNow = s.inventory.filter(i => i.id === item.id).reduce((sum, i) => sum + i.count, 0);
|
||||
return countNow < countBefore;
|
||||
}, 5000);
|
||||
// Take the count from the state that satisfied the wait, not from a
|
||||
// fresh getState() that may have advanced past it.
|
||||
const finalState = await this.sdk.waitForCondition(
|
||||
s => countItems(s.inventory, item.id) < countBefore,
|
||||
5000,
|
||||
);
|
||||
|
||||
const finalState = this.sdk.getState();
|
||||
const countAfter = finalState?.inventory.filter(i => i.id === item.id).reduce((sum, i) => sum + i.count, 0) ?? 0;
|
||||
const amountDeposited = countBefore - countAfter;
|
||||
|
||||
return { success: true, message: `Deposited ${item.name} x${amountDeposited}`, amountDeposited };
|
||||
const amountDeposited = Math.max(0, countBefore - countItems(finalState.inventory, item.id));
|
||||
const quantity = classifyQuantity(requestedAmount, amountDeposited);
|
||||
return {
|
||||
success: quantity.complete,
|
||||
message: quantity.complete
|
||||
? `Deposited ${item.name} x${amountDeposited}`
|
||||
: `Deposited ${item.name} x${amountDeposited} of ${requestedAmount} requested`,
|
||||
requestedAmount,
|
||||
amountDeposited,
|
||||
partial: quantity.partial,
|
||||
reason: quantity.complete ? undefined : 'partial_fill',
|
||||
};
|
||||
} catch {
|
||||
return { success: false, message: `Timeout waiting for ${item.name} to be deposited`, reason: 'timeout' };
|
||||
return {
|
||||
success: false,
|
||||
message: `Timeout waiting for ${item.name} to be deposited`,
|
||||
requestedAmount,
|
||||
amountDeposited: 0,
|
||||
partial: false,
|
||||
reason: 'timeout',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Withdraw an item from the bank by slot number. */
|
||||
async withdrawItem(target: BankItem | string | RegExp | number, amount: number = 1): Promise<BankWithdrawResult> {
|
||||
const validated = validateActionQuantity(amount, { allowAll: true });
|
||||
if (!validated.valid) {
|
||||
return { success: false, message: validated.message, reason: 'invalid_amount' };
|
||||
}
|
||||
const dispatchAmount = validated.amount;
|
||||
|
||||
const state = this.sdk.getState();
|
||||
if (!state?.interface?.isOpen) {
|
||||
return { success: false, message: 'Bank is not open', reason: 'bank_not_open' };
|
||||
}
|
||||
|
||||
let bankSlot: number;
|
||||
// Resolve the whole bank item, not just its slot: the item id is what
|
||||
// lets us tell how much actually arrived.
|
||||
let bankItem: BankItem | undefined;
|
||||
if (typeof target === 'number') {
|
||||
bankSlot = target;
|
||||
bankItem = state.bank.items.find(i => i.slot === target);
|
||||
} else if (typeof target === 'object' && 'slot' in target) {
|
||||
bankSlot = target.slot;
|
||||
bankItem = state.bank.items.find(i => i.slot === target.slot && i.id === target.id)
|
||||
?? state.bank.items.find(i => i.id === target.id);
|
||||
} else {
|
||||
const found = this.sdk.findBankItem(target);
|
||||
if (!found) {
|
||||
return { success: false, message: `Bank item not found: ${target}`, reason: 'item_not_found' };
|
||||
}
|
||||
bankSlot = found.slot;
|
||||
bankItem = this.sdk.findBankItem(target) ?? undefined;
|
||||
}
|
||||
if (!bankItem) {
|
||||
return { success: false, message: `Bank item not found: ${target}`, reason: 'item_not_found' };
|
||||
}
|
||||
const itemId = bankItem.id;
|
||||
|
||||
const invCountBefore = state.inventory.length;
|
||||
const countBefore = countItems(state.inventory, itemId);
|
||||
const requestedAmount = dispatchAmount === -1 ? bankItem.count : dispatchAmount;
|
||||
|
||||
await this.sdk.sendBankWithdraw(bankSlot, amount);
|
||||
await this.sdk.sendBankWithdraw(bankItem.slot, dispatchAmount);
|
||||
|
||||
try {
|
||||
await this.sdk.waitForCondition(s => {
|
||||
return s.inventory.length > invCountBefore ||
|
||||
s.inventory.some(i => {
|
||||
const before = state.inventory.find(bi => bi.slot === i.slot);
|
||||
return before && i.count > before.count;
|
||||
});
|
||||
}, 5000);
|
||||
// Track the specific item id. The old slot-diff heuristic reported
|
||||
// whatever happened to land in a changed slot, which for a shifting
|
||||
// inventory is not necessarily what was withdrawn.
|
||||
const finalState = await this.sdk.waitForCondition(
|
||||
s => countItems(s.inventory, itemId) > countBefore,
|
||||
5000,
|
||||
);
|
||||
|
||||
const finalInv = this.sdk.getInventory();
|
||||
const newItem = finalInv.find(i => {
|
||||
const before = state.inventory.find(bi => bi.slot === i.slot);
|
||||
return !before || i.count > before.count;
|
||||
});
|
||||
|
||||
return { success: true, message: `Withdrew item from bank slot ${bankSlot}`, item: newItem };
|
||||
const amountWithdrawn = Math.max(0, countItems(finalState.inventory, itemId) - countBefore);
|
||||
const quantity = classifyQuantity(requestedAmount, amountWithdrawn);
|
||||
return {
|
||||
success: quantity.complete,
|
||||
message: quantity.complete
|
||||
? `Withdrew ${bankItem.name} x${amountWithdrawn}`
|
||||
: `Withdrew ${bankItem.name} x${amountWithdrawn} of ${requestedAmount} requested`,
|
||||
item: finalState.inventory.find(i => i.id === itemId),
|
||||
requestedAmount,
|
||||
amountWithdrawn,
|
||||
partial: quantity.partial,
|
||||
reason: quantity.complete ? undefined : 'partial_fill',
|
||||
};
|
||||
} catch {
|
||||
return { success: false, message: `Timeout waiting for item to be withdrawn`, reason: 'timeout' };
|
||||
return {
|
||||
success: false,
|
||||
message: `Timeout waiting for ${bankItem.name} to be withdrawn`,
|
||||
requestedAmount,
|
||||
amountWithdrawn: 0,
|
||||
partial: false,
|
||||
reason: 'timeout',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1960,20 +2087,17 @@ export class BotActions {
|
||||
|
||||
// Handle interface (make-x style)
|
||||
if (state.interface?.isOpen) {
|
||||
// Try to find product by text in options
|
||||
let targetIndex = 1;
|
||||
if (product) {
|
||||
const productLower = product.toLowerCase();
|
||||
const matchingOption = state.interface.options.find(o =>
|
||||
o.text.toLowerCase().includes(productLower)
|
||||
);
|
||||
if (matchingOption) {
|
||||
targetIndex = matchingOption.index;
|
||||
}
|
||||
}
|
||||
const matchingOption = product
|
||||
? resolveInterfaceOption(state.interface.options, product)
|
||||
: null;
|
||||
|
||||
if (!buttonClicked) {
|
||||
await this.sdk.sendClickInterfaceOption(targetIndex);
|
||||
// Dispatch by componentId. Passing matchingOption.index (a
|
||||
// 1-based label) to sendClickInterfaceOption (0-based array
|
||||
// position) clicked the product *after* the requested one.
|
||||
await (matchingOption
|
||||
? this.sdk.clickInterfaceOption(matchingOption)
|
||||
: this.sdk.sendClickInterfaceOption(1));
|
||||
buttonClicked = true;
|
||||
} else if (state.interface.options.length > 0 && state.interface.options[0]) {
|
||||
await this.sdk.sendClickInterfaceOption(0);
|
||||
@@ -2180,12 +2304,13 @@ export class BotActions {
|
||||
// Handle interface (leather crafting interface id=2311)
|
||||
if (state.interface?.isOpen) {
|
||||
if (product) {
|
||||
// Try to find matching option by text
|
||||
const productOption = state.interface.options.find(o =>
|
||||
o.text.toLowerCase().includes(product.toLowerCase())
|
||||
);
|
||||
// Try to find matching option by text, then dispatch its
|
||||
// componentId. Feeding productOption.index (1-based) into
|
||||
// sendClickInterfaceOption (0-based) crafted the item one
|
||||
// past the one the caller asked for.
|
||||
const productOption = resolveInterfaceOption(state.interface.options, product);
|
||||
if (productOption) {
|
||||
await this.sdk.sendClickInterfaceOption(productOption.index);
|
||||
await this.sdk.clickInterfaceOption(productOption);
|
||||
await this.sdk.waitForTicks(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
+49
-12
@@ -25,6 +25,13 @@ import type {
|
||||
import { PRAYER_INDICES, PRAYER_NAMES, PLAYER_CHAT_TYPES, isPlayerChat } from './types';
|
||||
import { ChatHistory } from './chat-history';
|
||||
import * as pathfinding from './pathfinding';
|
||||
import { resolveInterfaceOption, type InterfaceOptionSelector } from './action-quantity';
|
||||
|
||||
function selectorLabel(selector: InterfaceOptionSelector): string {
|
||||
if (typeof selector === 'string') return `"${selector}"`;
|
||||
if (selector instanceof RegExp) return String(selector);
|
||||
return `option "${selector.text}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the gateway WebSocket URL from a SERVER env value.
|
||||
@@ -704,12 +711,12 @@ export class BotSDK {
|
||||
});
|
||||
}
|
||||
|
||||
/** Get a skill by name (case-insensitive). */
|
||||
/** Get a skill by name (case-insensitive; "hp"/"hitpoint" alias Hitpoints). */
|
||||
getSkill(name: string): SkillState | null {
|
||||
if (!this.state) return null;
|
||||
return this.state.skills.find(s =>
|
||||
s.name.toLowerCase() === name.toLowerCase()
|
||||
) || null;
|
||||
const requested = name.trim().toLowerCase();
|
||||
const normalized = /^(hp|hitpoint|hitpoints)$/.test(requested) ? 'hitpoints' : requested;
|
||||
return this.state.skills.find(s => s.name.toLowerCase() === normalized) || null;
|
||||
}
|
||||
|
||||
/** Get XP for a skill by name. */
|
||||
@@ -1092,23 +1099,53 @@ export class BotSDK {
|
||||
return this.sendAction({ type: 'clickComponentWithOption', componentId, optionIndex, slot, reason: 'SDK' });
|
||||
}
|
||||
|
||||
/** Click an interface option by index. Convenience wrapper that looks up componentId from state. */
|
||||
async sendClickInterfaceOption(optionIndex: number): Promise<ActionResult> {
|
||||
/**
|
||||
* Click an interface option by **0-based array position**.
|
||||
*
|
||||
* Note the mismatch: `InterfaceOption.index` is a 1-based display label, so
|
||||
* passing one straight through clicks the option after the one you matched.
|
||||
* Prefer `clickInterfaceOption()` when selecting from published state.
|
||||
*/
|
||||
async sendClickInterfaceOption(arrayPosition: number): Promise<ActionResult> {
|
||||
const state = this.getState();
|
||||
if (!state?.interface?.isOpen) {
|
||||
return { success: false, message: 'No interface open' };
|
||||
return { success: false, message: 'No interface open', reason: 'no_interface' };
|
||||
}
|
||||
|
||||
const options = state.interface.options;
|
||||
if (optionIndex < 0 || optionIndex >= options.length) {
|
||||
return { success: false, message: `Invalid option index ${optionIndex}, interface has ${options.length} options` };
|
||||
const option = options[arrayPosition];
|
||||
if (arrayPosition < 0 || arrayPosition >= options.length || !option) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Invalid array position ${arrayPosition}; interface has ${options.length} options (0..${options.length - 1})`,
|
||||
reason: 'no_match',
|
||||
};
|
||||
}
|
||||
|
||||
const option = options[optionIndex];
|
||||
return this.sendClickComponent(option.componentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click exactly one interface option, selected by its state object or by
|
||||
* visible text (substring for strings, match for regexes).
|
||||
*
|
||||
* This dispatches the option's `componentId` and never interprets
|
||||
* `InterfaceOption.index` as an array position.
|
||||
*/
|
||||
async clickInterfaceOption(selector: InterfaceOptionSelector): Promise<ActionResult> {
|
||||
const state = this.getState();
|
||||
if (!state?.interface?.isOpen) {
|
||||
return { success: false, message: 'No interface open', reason: 'no_interface' };
|
||||
}
|
||||
const option = resolveInterfaceOption(state.interface.options, selector);
|
||||
if (!option) {
|
||||
return { success: false, message: `Option ${optionIndex} not found` };
|
||||
const available = state.interface.options.map(o => `"${o.text}"`).join(', ') || '(none)';
|
||||
return {
|
||||
success: false,
|
||||
message: `No interface option matched ${selectorLabel(selector)}. Available: ${available}`,
|
||||
reason: 'no_match',
|
||||
};
|
||||
}
|
||||
|
||||
return this.sendClickComponent(option.componentId);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
classifyQuantity,
|
||||
countItems,
|
||||
nextShopStep,
|
||||
resolveInterfaceOption,
|
||||
validateActionQuantity,
|
||||
MAX_ACTION_QUANTITY,
|
||||
MAX_SHOP_ACTION_QUANTITY,
|
||||
} from '../action-quantity';
|
||||
import type { InterfaceOption, InventoryItem } from '../types';
|
||||
|
||||
function option(index: number, text: string, componentId: number): InterfaceOption {
|
||||
return { index, text, componentId };
|
||||
}
|
||||
|
||||
function item(slot: number, id: number, name: string, count: number): InventoryItem {
|
||||
return { slot, id, name, count, optionsWithIndex: [] };
|
||||
}
|
||||
|
||||
// Interface options are published with a 1-based `index` label but consumed by
|
||||
// sendClickInterfaceOption as a 0-based array position. These cover the gap.
|
||||
describe('resolveInterfaceOption', () => {
|
||||
const options = [
|
||||
option(1, 'Leather body', 2311_04),
|
||||
option(2, 'Leather gloves', 2311_05),
|
||||
option(3, 'Leather chaps', 2311_06),
|
||||
];
|
||||
|
||||
test('matches by text without going through the 1-based index label', () => {
|
||||
const resolved = resolveInterfaceOption(options, 'gloves');
|
||||
expect(resolved?.componentId).toBe(2311_05);
|
||||
// The bug this replaces: options[resolved.index] is the *next* product.
|
||||
expect(options[resolved!.index]?.text).toBe('Leather chaps');
|
||||
});
|
||||
|
||||
test('matches the first option, which index-as-position skips entirely', () => {
|
||||
const resolved = resolveInterfaceOption(options, /body/i);
|
||||
expect(resolved?.componentId).toBe(2311_04);
|
||||
expect(resolved?.index).toBe(1);
|
||||
});
|
||||
|
||||
test('matches a passed-through option object by componentId', () => {
|
||||
expect(resolveInterfaceOption(options, options[2]!)?.text).toBe('Leather chaps');
|
||||
});
|
||||
|
||||
test('returns null rather than a neighbouring option when nothing matches', () => {
|
||||
expect(resolveInterfaceOption(options, 'dragonhide')).toBeNull();
|
||||
expect(resolveInterfaceOption([], 'gloves')).toBeNull();
|
||||
});
|
||||
|
||||
test('is not confused by a stateful regex reused across calls', () => {
|
||||
const sticky = /leather/gi;
|
||||
expect(resolveInterfaceOption(options, sticky)?.text).toBe('Leather body');
|
||||
expect(resolveInterfaceOption(options, sticky)?.text).toBe('Leather body');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateActionQuantity', () => {
|
||||
test('accepts ordinary positive quantities', () => {
|
||||
expect(validateActionQuantity(1)).toEqual({ valid: true, amount: 1 });
|
||||
expect(validateActionQuantity(28)).toEqual({ valid: true, amount: 28 });
|
||||
});
|
||||
|
||||
test('rejects quantities that would expand into an unbounded packet loop', () => {
|
||||
expect(validateActionQuantity(1e9).valid).toBe(false);
|
||||
expect(validateActionQuantity(MAX_ACTION_QUANTITY + 1).valid).toBe(false);
|
||||
expect(validateActionQuantity(MAX_ACTION_QUANTITY).valid).toBe(true);
|
||||
expect(validateActionQuantity(2000, { max: MAX_SHOP_ACTION_QUANTITY }).valid).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects non-integers, zero, negatives, and non-finite input', () => {
|
||||
for (const bad of [0, -5, 2.5, NaN, Infinity, -Infinity]) {
|
||||
expect(validateActionQuantity(bad).valid).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('accepts the -1 "all" sentinel only when the caller opts in', () => {
|
||||
expect(validateActionQuantity(-1, { allowAll: true })).toEqual({ valid: true, amount: -1 });
|
||||
expect(validateActionQuantity(-1).valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextShopStep', () => {
|
||||
test('picks the largest shop step that fits', () => {
|
||||
expect(nextShopStep(1)).toBe(1);
|
||||
expect(nextShopStep(4)).toBe(1);
|
||||
expect(nextShopStep(5)).toBe(5);
|
||||
expect(nextShopStep(9)).toBe(5);
|
||||
expect(nextShopStep(10)).toBe(10);
|
||||
expect(nextShopStep(37)).toBe(10);
|
||||
});
|
||||
|
||||
test('drains any quantity in a bounded number of steps', () => {
|
||||
let remaining = MAX_SHOP_ACTION_QUANTITY;
|
||||
let steps = 0;
|
||||
while (remaining > 0) {
|
||||
remaining -= nextShopStep(remaining);
|
||||
steps++;
|
||||
}
|
||||
expect(remaining).toBe(0);
|
||||
expect(steps).toBeLessThanOrEqual(120);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyQuantity', () => {
|
||||
test('treats a short fill as unsuccessful, not as success', () => {
|
||||
const outcome = classifyQuantity(28, 12);
|
||||
expect(outcome.complete).toBe(false);
|
||||
expect(outcome.partial).toBe(true);
|
||||
expect(outcome.actual).toBe(12);
|
||||
});
|
||||
|
||||
test('an exact fill is complete and not partial', () => {
|
||||
expect(classifyQuantity(28, 28)).toMatchObject({ complete: true, partial: false });
|
||||
});
|
||||
|
||||
test('a zero fill is neither complete nor partial', () => {
|
||||
expect(classifyQuantity(5, 0)).toMatchObject({ complete: false, partial: false });
|
||||
});
|
||||
|
||||
test('clamps nonsense observations instead of reporting negative amounts', () => {
|
||||
expect(classifyQuantity(5, -3)).toMatchObject({ actual: 0, complete: false, partial: false });
|
||||
expect(classifyQuantity(0, 0).complete).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countItems', () => {
|
||||
const inventory = [
|
||||
item(0, 1511, 'Logs', 1),
|
||||
item(1, 1511, 'Logs', 1),
|
||||
item(2, 995, 'Coins', 500),
|
||||
item(3, 1521, 'Oak logs', 1),
|
||||
];
|
||||
|
||||
test('sums a non-stackable item across every occupied slot', () => {
|
||||
expect(countItems(inventory, 1511)).toBe(2);
|
||||
});
|
||||
|
||||
test('sums stack sizes by id', () => {
|
||||
expect(countItems(inventory, 995)).toBe(500);
|
||||
});
|
||||
|
||||
test('matches by name pattern, anchored regexes included', () => {
|
||||
expect(countItems(inventory, /logs/i)).toBe(3);
|
||||
expect(countItems(inventory, /^logs$/i)).toBe(2);
|
||||
expect(countItems(inventory, 'coins')).toBe(500);
|
||||
});
|
||||
|
||||
test('returns 0 for an absent item', () => {
|
||||
expect(countItems(inventory, 4151)).toBe(0);
|
||||
expect(countItems([], /logs/i)).toBe(0);
|
||||
});
|
||||
});
|
||||
+38
-4
@@ -181,7 +181,20 @@ export interface DialogState {
|
||||
export interface InterfaceState {
|
||||
isOpen: boolean;
|
||||
interfaceId: number;
|
||||
options: Array<{ index: number; text: string; componentId: number }>;
|
||||
options: InterfaceOption[];
|
||||
}
|
||||
|
||||
/** A clickable viewport-interface option as published in world state. */
|
||||
export interface InterfaceOption {
|
||||
/**
|
||||
* Human-facing 1-based ordinal, for display only. Never pass this to
|
||||
* `sendClickInterfaceOption`, which takes a 0-based array position — use
|
||||
* `sdk.clickInterfaceOption(option)` to dispatch by componentId instead.
|
||||
*/
|
||||
index: number;
|
||||
text: string;
|
||||
/** Stable component dispatched when this option is selected. */
|
||||
componentId: number;
|
||||
}
|
||||
|
||||
export interface ShopItem {
|
||||
@@ -497,19 +510,31 @@ export interface TalkResult {
|
||||
}
|
||||
|
||||
export interface ShopResult {
|
||||
/** True only when the full requested amount was bought. */
|
||||
success: boolean;
|
||||
item?: InventoryItem;
|
||||
message: string;
|
||||
requestedAmount?: number;
|
||||
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';
|
||||
}
|
||||
|
||||
export interface ShopSellResult {
|
||||
/** True only when the full requested amount was sold. */
|
||||
success: boolean;
|
||||
message: string;
|
||||
requestedAmount?: number;
|
||||
amountSold?: number;
|
||||
/** Some but not all of the requested amount was sold. */
|
||||
partial?: boolean;
|
||||
rejected?: boolean;
|
||||
reason?: 'invalid_amount' | 'shop_not_open' | 'item_not_found' | 'rejected' | 'partial_fill' | 'timeout';
|
||||
}
|
||||
|
||||
export type SellAmount = 1 | 5 | 10 | 'all';
|
||||
/** Any positive integer up to MAX_SHOP_ACTION_QUANTITY, or 'all'. */
|
||||
export type SellAmount = number | 'all';
|
||||
|
||||
export interface EquipResult {
|
||||
success: boolean;
|
||||
@@ -580,17 +605,26 @@ export interface OpenBankResult {
|
||||
}
|
||||
|
||||
export interface BankDepositResult {
|
||||
/** True only when the full requested amount was deposited. */
|
||||
success: boolean;
|
||||
message: string;
|
||||
requestedAmount?: number;
|
||||
amountDeposited?: number;
|
||||
reason?: 'bank_not_open' | 'item_not_found' | 'timeout';
|
||||
/** Some but not all of the requested amount was deposited. */
|
||||
partial?: boolean;
|
||||
reason?: 'invalid_amount' | 'bank_not_open' | 'item_not_found' | 'partial_fill' | 'timeout';
|
||||
}
|
||||
|
||||
export interface BankWithdrawResult {
|
||||
/** True only when the full requested amount was withdrawn. */
|
||||
success: boolean;
|
||||
message: string;
|
||||
item?: InventoryItem;
|
||||
reason?: 'bank_not_open' | 'item_not_found' | 'timeout';
|
||||
requestedAmount?: number;
|
||||
amountWithdrawn?: number;
|
||||
/** Some but not all of the requested amount was withdrawn. */
|
||||
partial?: boolean;
|
||||
reason?: 'invalid_amount' | 'bank_not_open' | 'item_not_found' | 'partial_fill' | 'timeout';
|
||||
}
|
||||
|
||||
export interface UseItemOnLocResult {
|
||||
|
||||
Reference in new issue
Block a user