fix(client): route non-inventory useInventoryItem to INV_BUTTON

Both clients sent OPHELD1-5 no matter which interfaceId was passed, but
the engine only wires OPHELD to the main inventory (3214) - item options
on any other component (tradeside:inv, bank_side:inv, ...) are
inv_button triggers. The packet passed the wire and the handler silently
dropped it, which broke trade offers for weeks with zero error surface
(bug report msplxq69). Non-3214 components now dispatch INV_BUTTON1-5
via clickInterfaceIop, interfaceId is plumbed through the gateway action
and sdk.sendUseItem, and an encoding test pins both packet families.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Fable 5 committed 2026-08-12 12:49:26 -04:00
1 parent 697b43933d
commit 47bf4ee5d1
8 files changed
+67 -7

No files matched your search

+1 -1
View File
@@ -236,7 +236,7 @@
| `async sendInteractPlayer(playerIndex: number, option: number = 2): Promise<ActionResult>` | Interact with a player by index and option (1-5). Option 2 = Attack (wilderness), 3 = Follow, 4 = Trade. |
| `async sendTalkToNpc(npcIndex: number): Promise<ActionResult>` | Talk to an NPC by index. |
| `async sendPickup(x: number, z: number, itemId: number): Promise<ActionResult>` | Pick up a ground item. |
| `async sendUseItem(slot: number, option: number = 1): Promise<ActionResult>` | Use an inventory item (eat, equip, etc). |
| `async sendUseItem(slot: number, option: number = 1, interfaceId?: number): Promise<ActionResult>` | Use an inventory item (eat, equip, etc). `interfaceId` selects which inventory component holds the item. The main inventory (3214, the default) dispatches OPHELD1-5; any other component (trade offer, bank side inventory, ...) dispatches INV_BUTTON1-5, which is the packet family the engine actually handles for interface-defined item options - OPHELD with a foreign component id is silently dropped. |
| `async sendUseEquipmentItem(slot: number, option: number = 1): Promise<ActionResult>` | Use an equipped item (remove, operate, etc). |
| `async sendDropItem(slot: number): Promise<ActionResult>` | Drop an inventory item. |
| `async sendUseItemOnItem(sourceSlot: number, targetSlot: number): Promise<ActionResult>` | Use one inventory item on another. Rejected up front while a shop or bank modal is open: those replace the inventory tab, so the server drops the packet as "component not visible" and sends no message at all. Close the modal first — `bot.closeShop()`, `bot.closeInterface()`, or `sendCloseModal()`. |
+11 -3
View File
@@ -1081,9 +1081,17 @@ export class BotSDK {
return this.sendAction({ type: 'pickupItem', x, z, itemId, reason: 'SDK' });
}
/** Use an inventory item (eat, equip, etc). */
async sendUseItem(slot: number, option: number = 1): Promise<ActionResult> {
return this.sendAction({ type: 'useInventoryItem', slot, optionIndex: option, reason: 'SDK' });
/**
* Use an inventory item (eat, equip, etc).
*
* `interfaceId` selects which inventory component holds the item. The main
* inventory (3214, the default) dispatches OPHELD1-5; any other component
* (trade offer, bank side inventory, ...) dispatches INV_BUTTON1-5, which
* is the packet family the engine actually handles for interface-defined
* item options - OPHELD with a foreign component id is silently dropped.
*/
async sendUseItem(slot: number, option: number = 1, interfaceId?: number): Promise<ActionResult> {
return this.sendAction({ type: 'useInventoryItem', slot, optionIndex: option, interfaceId, reason: 'SDK' });
}
/** Use an equipped item (remove, operate, etc). */
+1 -1
View File
@@ -561,7 +561,7 @@ export type BotAction =
// Randomize character appearance (gender, body parts, colors) with valid random values
| { type: 'randomizeCharacterDesign'; reason: string }
| { type: 'walkTo'; x: number; z: number; running?: boolean; reason: string }
| { type: 'useInventoryItem'; slot: number; optionIndex: number; reason: string }
| { type: 'useInventoryItem'; slot: number; optionIndex: number; interfaceId?: number; reason: string }
| { type: 'useEquipmentItem'; slot: number; optionIndex: number; reason: string }
| { type: 'dropItem'; slot: number; reason: string }
| { type: 'pickupItem'; x: number; z: number; itemId: number; reason: string }
+3 -1
View File
@@ -108,7 +108,9 @@ export class ActionExecutor {
case 'useInventoryItem':
return this.wrapBool(
this.client.useInventoryItem(action.slot, action.optionIndex),
action.interfaceId !== undefined
? this.client.useInventoryItem(action.slot, action.optionIndex, action.interfaceId)
: this.client.useInventoryItem(action.slot, action.optionIndex),
`Using inventory slot ${action.slot}`,
'Failed to use inventory item'
);
+1 -1
View File
@@ -467,7 +467,7 @@ export type BotAction =
| { type: 'acceptCharacterDesign'; reason: string }
| { type: 'randomizeCharacterDesign'; reason: string }
| { type: 'walkTo'; x: number; z: number; running?: boolean; reason: string }
| { type: 'useInventoryItem'; slot: number; optionIndex: number; reason: string }
| { type: 'useInventoryItem'; slot: number; optionIndex: number; interfaceId?: number; reason: string }
| { type: 'dropItem'; slot: number; reason: string }
| { type: 'pickupItem'; x: number; z: number; itemId: number; reason: string }
| { type: 'interactGroundItem'; x: number; z: number; itemId: number; optionIndex: number; reason: string }
+8
View File
@@ -1442,6 +1442,14 @@ export class Client extends GameShell {
return false;
}
// The engine only wires OPHELD1-5 to the main inventory (3214); every
// other component's item options are inv_button triggers. OPHELD with
// a foreign com id passes the wire and is silently dropped by the
// handler, so route those to INV_BUTTON instead.
if (interfaceId !== 3214) {
return this.clickInterfaceIop(interfaceId, optionIndex, slot);
}
// Get item ID from the inventory slot
const component = IfType.list[interfaceId];
if (!component || !component.linkObjType) {
@@ -254,6 +254,39 @@ describe('outgoing action encoding', () => {
expect(r.g2()).toBe(INV_COMPONENT);
});
test('useInventoryItem routes non-inventory components to INV_BUTTON (no OPHELD handlers there)', () => {
if (!available) {
return;
}
const c = makeClient('bota');
giveItems(c, INV_COMPONENT, { 2: 385 }); // shark in the main inventory
giveItems(c, BANK_SIDE_INV, { 1: 385 }); // shark in the bank side inventory
// Main inventory (default): OPHELDn, as the engine expects for 3214.
let start = c.out.pos;
expect(c.useInventoryItem(2, 4)).toBe(true);
let r = new Capture(c.out, start);
expect(r.g1()).toBe(ClientProt.OPHELD4);
expect(r.g2()).toBe(385);
expect(r.g2()).toBe(2);
expect(r.g2()).toBe(INV_COMPONENT);
expect(r.at).toBe(c.out.pos);
// Foreign component: INV_BUTTONn with the same field order - OPHELD
// against a non-3214 com id is silently dropped by the engine.
start = c.out.pos;
expect(c.useInventoryItem(1, 4, BANK_SIDE_INV)).toBe(true);
r = new Capture(c.out, start);
expect(r.g1()).toBe(ClientProt.INV_BUTTON4);
expect(r.g2()).toBe(385);
expect(r.g2()).toBe(1);
expect(r.g2()).toBe(BANK_SIDE_INV);
expect(r.at).toBe(c.out.pos);
});
test('long walks clamp to the current scene edge like the browser client', () => {
if (!available) {
return;
+9
View File
@@ -204,6 +204,15 @@ export function useInventoryItem(c: LiteClient, slot: number, optionIndex: numbe
if (!c.isInGame() || optionIndex < 1 || optionIndex > 5) {
return false;
}
// The engine only wires OPHELD1-5 to the main inventory; every other
// component's item options are inv_button triggers (e.g. [inv_button4,
// tradeside:inv]). OPHELD with a foreign com id passes the wire and is
// silently dropped by the handler, so route those to INV_BUTTON instead.
if (interfaceId !== INVENTORY_INTERFACE_ID) {
return clickInterfaceIop(c, interfaceId, optionIndex, slot);
}
const itemId = slotItemId(interfaceId, slot);
if (!itemId) {
return false;