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:
max bittkerandClaude Opus 5 committed 2026-07-24 18:16:14 -04:00
1 parent 6949fb9874
commit 9958f2b074
9 files changed
+705 -176

No files matched your search

+11
View File
@@ -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
+17
View File
@@ -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
View File
@@ -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
View File
@@ -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';
}
```
+115
View File
@@ -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
View File
@@ -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
View File
@@ -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);
}
+154
View File
@@ -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
View File
@@ -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 {