mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
feat(sdk): player-to-player trade API
Three layers over the game's request/offer/accept/confirm trade flow:
Layer 0 - state and primitives. StateCollector publishes a `trade` slice
(screen, partner, both offers, both accept flags) read from the
trademain/tradeconfirm interfaces; accept flags come from the server-set
status text, the only place the game reports them. Sends reuse existing
packets: request is interactPlayer op 4 (requesting someone who already
requested you IS the accept), offers are INV_BUTTON with automatic
Offer-X count-dialog handling, accept is IF_BUTTON, decline is
close-modal. waitForTradeRequest surfaces chat type 4, which the default
chat readers filter out.
Layer 1 - bot.trade(target, {give, want, accept}). Opens the session,
offers, holds accept until the partner's offer satisfies the policy, and
re-verifies on the confirm screen before the irreversible second accept.
Offer changes reset both accepts server-side, so the confirm screen's
offers are final - re-checking there makes offer-switching unable to slip
past. Reports the observed inventory delta rather than assuming.
Layer 2 - bot.serveTrades(policy). The receiving half of a muling setup,
as an explicit loop that owns the bot rather than a background hook.
Two engine behaviours the porcelain has to work around, both found by
running it against two live bots:
- Accepts are gated on tick advancement. Every executed action publishes
a snapshot with an unchanged tick, so a retry loop waiting on "any
state change" spins at full speed, exhausts the engine's per-tick
packet budget, and backlogs that client's own update stream by minutes
while the trade advances server-side.
- "Trade closed" needs two ticks of confirmation. openMainModal writes an
IfClose for the side modal before the IfOpenMain of the confirm screen,
so a single closed-looking snapshot mid-trade is the offer->confirm
transition, not a decline.
Verified end to end against a local server with two lite clients: gift,
mutual exchange with both sides enforcing `want`, and an unmet-want
decline. Adds 15 unit tests covering the pure helpers and the porcelain
flows including the confirm-screen re-verification.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6fd02c9fb5
commit
2fa65b8342
12 files changed
+1350
-1
No files matched your search
+68
@@ -82,6 +82,17 @@
|
||||
| `async depositItem(target: InventoryItem \| string \| RegExp, amount: number = -1): Promise<BankDepositResult>` | Deposit an item into the bank. Use -1 for all. |
|
||||
| `async withdrawItem(target: BankItem \| string \| RegExp \| number, amount: number = 1): Promise<BankWithdrawResult>` | Withdraw an item from the bank by slot number. |
|
||||
|
||||
### Player Trading
|
||||
|
||||
| Signature | Description |
|
||||
|---|---|
|
||||
| `async tradeWith(target: NearbyPlayer \| string \| RegExp, timeout: number = 30_000): Promise<ActionResult>` | Open a trade session with another player, walking closer if needed. Requesting a player who already requested you accepts their request; otherwise this waits (re-requesting periodically) until they request back or the timeout expires. |
|
||||
| `async offerTradeItems(items: TradeItemSpec[], timeout: number = 15_000): Promise<ActionResult>` | Place items into your side of an open trade. Each spec resolves against your inventory; `amount` -1 offers all of that item (default 1). Waits until the offer window reflects each addition. Adding items resets both players' accepts server-side. |
|
||||
| `async acceptTrade(timeout: number = 10_000): Promise<ActionResult>` | Accept the current trade screen and wait for observable progress: the screen advancing (offer -> confirm), the trade completing, or the acceptance being registered while waiting on the partner. |
|
||||
| `async declineTrade(timeout: number = 5_000): Promise<ActionResult>` | Decline (close) the open trade and wait for the screen to close. |
|
||||
| `async trade(target: NearbyPlayer \| string \| RegExp, options: TradeOptions = {}): Promise<TradeResult>` | Full trade happy path with one player: open the session, offer `give`, accept once the partner's offer satisfies `want` (or the `accept` predicate), re-verify on the confirm screen, and report the actual inventory delta. Any offer change resets both accepts server-side, so the offer seen at accept time is the offer that reaches the confirm screen; the confirm re-verification makes offer-switching structurally impossible to slip past this method. |
|
||||
| `async serveTrades(options: ServeTradesOptions = {}): Promise<ServeTradesResult>` | Serve incoming trades: wait for "wishes to trade with you." requests, accept ones matching the `from` filter, and run each session with the shared give/want/accept policy. The receiving half of a muling setup: ```ts // Worker: bot.trade('mule01', { give: [{ item: /ore/i, amount: -1 }] }) // Mule: bot.serveTrades({ from: /^fleet_/i, until: () => sdk.getInventory().length >= 26 }) ``` This owns the bot while running - it is an explicit serving loop, not a background hook, so it never fights another controller for the session. |
|
||||
|
||||
### Combat & Equipment
|
||||
|
||||
| Signature | Description |
|
||||
@@ -176,6 +187,7 @@
|
||||
| `findGroundItem(pattern: string \| RegExp, options?: FindOptions): GroundItem \| null` | Find ground item by name pattern (shortest matching name wins, then nearest; reachable preferred). |
|
||||
| `getGroundItems(): GroundItem[]` | Get all ground items. |
|
||||
| `getDialog(): DialogState \| null` | Get current dialog state. |
|
||||
| `getTradeState(): TradeState` | Current player-to-player trade session state. Returns a closed-trade default when no state has arrived or the connected client predates trade support. |
|
||||
| `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. |
|
||||
@@ -198,6 +210,7 @@
|
||||
| `async waitForBotConnection(timeout?: number): Promise<void>` | Wait for bot to connect to gateway after browser launch. |
|
||||
| `async waitForConnection(timeout: number = 60000): Promise<void>` | Wait for WebSocket connection to be established. |
|
||||
| `async waitForChat(opts: { from?: string; matching?: RegExp \| string; types?: readonly number[]; includeSelf?: boolean; timeout?: number; } = {}): Promise<GameMessage \| null>` | Wait for the next chat message matching the given filters (messages arriving after this call; your own messages are excluded by default). The easy way to coordinate two bots: `sdk.say('ready'); const reply = await sdk.waitForChat({ from: 'partner', timeout: 60000 });` |
|
||||
| `async waitForTradeRequest(opts: { from?: string; timeout?: number } = {}): Promise<string \| null>` | Wait for an incoming trade request ("X wishes to trade with you."). Requests arrive as chat type {@link TRADE_REQUEST_CHAT_TYPE}, which the default chat readers filter out. Returns the requester's name, or null on timeout. |
|
||||
| `async waitForReady(timeout: number = 15000): Promise<BotWorldState>` | Wait for game state to be fully loaded and ready. Ensures player position is valid (not 0,0), bot is in-game, and state is recent. |
|
||||
| `async waitForCondition(predicate: (state: BotWorldState) => boolean, timeout: number = 30000): Promise<BotWorldState>` | _No description provided._ |
|
||||
| `async waitForStateChange(timeout: number = 30000): Promise<BotWorldState>` | Wait for next state update from server. |
|
||||
@@ -241,6 +254,11 @@
|
||||
| `async sendCloseShop(): Promise<ActionResult>` | Close shop interface. |
|
||||
| `async sendCloseModal(): Promise<ActionResult>` | Close any modal interface. |
|
||||
| `async sendCountDialog(value: number): Promise<ActionResult>` | Submit a numeric value to an open p_countdialog (Enter Amount) prompt. |
|
||||
| `async sendTradeRequest(playerIndex: number): Promise<ActionResult>` | Send (or accept) a trade request to another player. There is no separate "accept" packet: requesting a player who already requested you is the acceptance, and opens the trade screen for both. Otherwise the partner sees "<you> wishes to trade with you." and the trade opens when they request back. |
|
||||
| `async sendOfferItem(slot: number, amount: number = 1): Promise<ActionResult>` | Move items from your (trade-screen) side inventory into your offer. `slot` is the inventory slot. Amounts 1/5/10 and -1 (All) map to the game's offer buttons; any other amount uses Offer-X plus the count dialog. Only valid while the offer screen is open. |
|
||||
| `async sendRetractItem(slot: number, amount: number = 1): Promise<ActionResult>` | Remove items from your offer back to your inventory. Same amount semantics as {@link sendOfferItem}. Note: removing (or adding) items resets both players' accepts server-side. |
|
||||
| `async sendAcceptTrade(): Promise<ActionResult>` | Accept the currently open trade screen (first or confirm). The trade only advances when both players accept; an offer change resets accepts. |
|
||||
| `async sendDeclineTrade(): Promise<ActionResult>` | Decline the open trade (closes the screen; both sides get their items back and the partner sees "Other player declined trade."). |
|
||||
| `async sendSetCombatStyle(style: number): Promise<ActionResult>` | Set combat style (0-3). |
|
||||
| `async sendTogglePrayer(prayer: PrayerName \| number): Promise<ActionResult>` | Toggle a prayer on or off by name or index (0-14). |
|
||||
| `async sendSpellOnNpc(npcIndex: number, spellComponent: number): Promise<ActionResult>` | Cast spell on NPC using spell component ID (OPNPCT). |
|
||||
@@ -373,6 +391,54 @@ interface BankState {
|
||||
}
|
||||
```
|
||||
|
||||
### TradeState
|
||||
|
||||
Player-to-player trade session state, read from the trademain/tradeconfirm interfaces. Accept flags are parsed from the server-set status text — on each screen at most one of myAccepted/partnerAccepted is ever true, because the second accept advances (or completes) the trade. Any offer change on the offer screen resets both accepts server-side, which is the built-in anti-scam property: the confirm screen always shows the final offers.
|
||||
|
||||
```typescript
|
||||
interface TradeState {
|
||||
isOpen: boolean;
|
||||
/** 'offer' = first screen (offers editable), 'confirm' = final screen. */
|
||||
screen: 'offer' | 'confirm' | null;
|
||||
/** Partner display name, from "Trading With: <name>". */
|
||||
partner: string | null;
|
||||
myOffer: TradeItem[];
|
||||
theirOffer: TradeItem[];
|
||||
/** True when this client accepted the current screen and is waiting on the partner. */
|
||||
myAccepted: boolean;
|
||||
/** True when the partner accepted the current screen. */
|
||||
partnerAccepted: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### TradeResult
|
||||
|
||||
```typescript
|
||||
interface TradeResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
/** Partner display name, when known. */
|
||||
partner?: string;
|
||||
/** Items removed from your inventory (by the completed trade). */
|
||||
gave: TradeItem[];
|
||||
/** Items added to your inventory (by the completed trade). */
|
||||
received: TradeItem[];
|
||||
reason?: 'player_not_found' | 'no_response' | 'declined' | 'busy' | 'offer_failed' | 'want_not_met' | 'no_space' | 'not_open' | 'timeout' | 'error';
|
||||
}
|
||||
```
|
||||
|
||||
### ServeTradesResult
|
||||
|
||||
```typescript
|
||||
interface ServeTradesResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
/** Completed trades, in order. */
|
||||
trades: TradeResult[];
|
||||
reason?: 'until' | 'max_trades' | 'timeout' | 'error';
|
||||
}
|
||||
```
|
||||
|
||||
### CombatStyleState
|
||||
|
||||
```typescript
|
||||
@@ -449,6 +515,8 @@ interface BotWorldState {
|
||||
interface: InterfaceState;
|
||||
shop: ShopState;
|
||||
bank: BankState;
|
||||
/** Absent when the connected client predates trade support. */
|
||||
trade?: TradeState;
|
||||
modalOpen: boolean;
|
||||
modalInterface: number;
|
||||
combatStyle?: CombatStyleState;
|
||||
|
||||
+463
@@ -47,7 +47,22 @@ import type {
|
||||
EnchantResult,
|
||||
StringAmuletResult,
|
||||
} from './types';
|
||||
import type {
|
||||
TradeItem,
|
||||
TradeItemSpec,
|
||||
TradeOptions,
|
||||
TradeResult,
|
||||
ServeTradesOptions,
|
||||
ServeTradesResult,
|
||||
} from './types';
|
||||
import { PRAYER_INDICES, PRAYER_NAMES, PRAYER_LEVELS } from './types';
|
||||
import {
|
||||
countInventoryById,
|
||||
countMatching,
|
||||
diffInventories,
|
||||
missingFromOffer,
|
||||
offerSatisfies,
|
||||
} from './trade-helpers';
|
||||
import {
|
||||
classifyQuantity,
|
||||
countItems,
|
||||
@@ -1752,6 +1767,454 @@ export class BotActions {
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Porcelain: Player Trading ============
|
||||
|
||||
/**
|
||||
* Open a trade session with another player, walking closer if needed.
|
||||
* Requesting a player who already requested you accepts their request;
|
||||
* otherwise this waits (re-requesting periodically) until they request
|
||||
* back or the timeout expires.
|
||||
*/
|
||||
async tradeWith(target: NearbyPlayer | string | RegExp, timeout: number = 30_000): Promise<ActionResult> {
|
||||
await this.dismissBlockingUI();
|
||||
|
||||
const open = this.sdk.getTradeState();
|
||||
if (open.isOpen) {
|
||||
return { success: true, message: `Trade already open with ${open.partner ?? 'unknown partner'}` };
|
||||
}
|
||||
|
||||
const resolvePlayer = (): NearbyPlayer | null =>
|
||||
typeof target === 'object' && 'index' in target
|
||||
? (this.sdk.getState()?.nearbyPlayers.find(p => p.index === target.index) ?? target)
|
||||
: this.sdk.findNearbyPlayer(target);
|
||||
|
||||
let player = resolvePlayer();
|
||||
if (!player) {
|
||||
return { success: false, message: `Player not found nearby: ${target}`, reason: 'player_not_found' };
|
||||
}
|
||||
|
||||
if (player.distance > 12) {
|
||||
await this.walkTo(player.x, player.z, 8);
|
||||
player = resolvePlayer();
|
||||
if (!player) {
|
||||
return { success: false, message: `Player left before trade could start: ${target}`, reason: 'player_not_found' };
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + timeout;
|
||||
const msgBaseline = this.helpers.getMessageTick();
|
||||
const REREQUEST_INTERVAL = 8_000;
|
||||
|
||||
let failReason: 'busy' | null = null;
|
||||
while (Date.now() < deadline) {
|
||||
const current = resolvePlayer();
|
||||
if (current) player = current;
|
||||
await this.sdk.sendTradeRequest(player.index);
|
||||
|
||||
try {
|
||||
const finalState = await this.waitForActionCondition(state => {
|
||||
if (this.helpers.findRefusal(msgBaseline, ['is busy at the moment'])) {
|
||||
failReason = 'busy';
|
||||
return true;
|
||||
}
|
||||
return state.trade?.isOpen === true;
|
||||
}, Math.min(REREQUEST_INTERVAL, Math.max(1, deadline - Date.now())));
|
||||
|
||||
if (failReason) {
|
||||
return { success: false, message: `${player.name} is busy at the moment`, reason: failReason };
|
||||
}
|
||||
const partner = finalState.trade?.partner ?? player.name;
|
||||
return { success: true, message: `Trade opened with ${partner}` };
|
||||
} catch {
|
||||
// No response yet - re-request and keep waiting until deadline.
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, message: `No response to trade request from ${player.name}`, reason: 'no_response' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Place items into your side of an open trade. Each spec resolves against
|
||||
* your inventory; `amount` -1 offers all of that item (default 1).
|
||||
* Waits until the offer window reflects each addition. Adding items
|
||||
* resets both players' accepts server-side.
|
||||
*/
|
||||
async offerTradeItems(items: TradeItemSpec[], timeout: number = 15_000): Promise<ActionResult> {
|
||||
const deadline = Date.now() + timeout;
|
||||
|
||||
for (const spec of items) {
|
||||
const state = this.sdk.getState();
|
||||
const trade = this.sdk.getTradeState();
|
||||
if (!state || !trade.isOpen || trade.screen !== 'offer') {
|
||||
return { success: false, message: 'Trade offer screen is not open', reason: 'not_open' };
|
||||
}
|
||||
|
||||
const item = this.helpers.resolveInventoryItem(spec.item, /./);
|
||||
if (!item) {
|
||||
return { success: false, message: `Item not found in inventory: ${spec.item}`, reason: 'item_not_found' };
|
||||
}
|
||||
|
||||
const available = countItems(state.inventory, item.id);
|
||||
const amount = spec.amount ?? 1;
|
||||
const adding = amount === -1 ? available : Math.min(amount, available);
|
||||
const expected = countMatching(trade.myOffer, spec.item) + adding;
|
||||
|
||||
const msgBaseline = this.helpers.getMessageTick();
|
||||
await this.sdk.sendOfferItem(item.slot, amount);
|
||||
|
||||
try {
|
||||
let refused: string | null = null;
|
||||
await this.waitForActionCondition(s => {
|
||||
refused = this.helpers.findRefusal(msgBaseline, ["you can't trade this item"]);
|
||||
if (refused) return true;
|
||||
return !!s.trade && countMatching(s.trade.myOffer, spec.item) >= expected;
|
||||
}, Math.max(1, deadline - Date.now()));
|
||||
if (refused) {
|
||||
return { success: false, message: `${item.name} is not tradeable`, reason: 'untradeable' };
|
||||
}
|
||||
} catch {
|
||||
return { success: false, message: `Timeout offering ${item.name}`, reason: 'timeout' };
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, message: `Offered ${items.length} item type(s)` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept the current trade screen and wait for observable progress:
|
||||
* the screen advancing (offer -> confirm), the trade completing, or the
|
||||
* acceptance being registered while waiting on the partner.
|
||||
*/
|
||||
async acceptTrade(timeout: number = 10_000): Promise<ActionResult> {
|
||||
const before = this.sdk.getTradeState();
|
||||
if (!before.isOpen) {
|
||||
return { success: false, message: 'No trade screen is open', reason: 'not_open' };
|
||||
}
|
||||
const screenBefore = before.screen;
|
||||
|
||||
await this.sdk.sendAcceptTrade();
|
||||
|
||||
try {
|
||||
const finalState = await this.waitForActionCondition(s => {
|
||||
const t = s.trade;
|
||||
if (!t || !t.isOpen) return true; // completed (or declined)
|
||||
if (t.screen !== screenBefore) return true; // advanced to confirm
|
||||
return t.myAccepted; // registered, waiting on partner
|
||||
}, timeout);
|
||||
|
||||
const after = finalState.trade;
|
||||
if (!after || !after.isOpen) {
|
||||
return { success: true, message: 'Trade screen closed' };
|
||||
}
|
||||
if (after.screen !== screenBefore) {
|
||||
return { success: true, message: 'Advanced to confirm screen' };
|
||||
}
|
||||
return { success: true, message: 'Accepted - waiting for other player' };
|
||||
} catch {
|
||||
return { success: false, message: 'No visible response to accept', reason: 'timeout' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Decline (close) the open trade and wait for the screen to close. */
|
||||
async declineTrade(timeout: number = 5_000): Promise<ActionResult> {
|
||||
const trade = this.sdk.getTradeState();
|
||||
if (!trade.isOpen) {
|
||||
return { success: true, message: 'No trade was open' };
|
||||
}
|
||||
await this.sdk.sendDeclineTrade();
|
||||
try {
|
||||
await this.waitForActionCondition(s => s.trade?.isOpen !== true, timeout);
|
||||
return { success: true, message: 'Trade declined' };
|
||||
} catch {
|
||||
return { success: false, message: 'Trade screen did not close', reason: 'timeout' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full trade happy path with one player: open the session, offer `give`,
|
||||
* accept once the partner's offer satisfies `want` (or the `accept`
|
||||
* predicate), re-verify on the confirm screen, and report the actual
|
||||
* inventory delta.
|
||||
*
|
||||
* Any offer change resets both accepts server-side, so the offer seen at
|
||||
* accept time is the offer that reaches the confirm screen; the confirm
|
||||
* re-verification makes offer-switching structurally impossible to slip
|
||||
* past this method.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Pure gift (muling): give all herbs, expect nothing back
|
||||
* await bot.trade('mule01', { give: [{ item: /herb/i, amount: -1 }] });
|
||||
*
|
||||
* // Exchange: give 100 coins only if 5 lobsters are offered
|
||||
* await bot.trade('cook', {
|
||||
* give: [{ item: 'coins', amount: 100 }],
|
||||
* want: [{ item: 'lobster', amount: 5 }],
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
async trade(target: NearbyPlayer | string | RegExp, options: TradeOptions = {}): Promise<TradeResult> {
|
||||
const timeout = options.timeout ?? 60_000;
|
||||
const deadline = Date.now() + timeout;
|
||||
|
||||
const opened = await this.tradeWith(target, timeout);
|
||||
if (!opened.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: opened.message,
|
||||
gave: [],
|
||||
received: [],
|
||||
reason: (opened.reason as TradeResult['reason']) ?? 'no_response',
|
||||
};
|
||||
}
|
||||
|
||||
return this.runOpenTradeSession(options, deadline);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve incoming trades: wait for "wishes to trade with you." requests,
|
||||
* accept ones matching the `from` filter, and run each session with the
|
||||
* shared give/want/accept policy. The receiving half of a muling setup:
|
||||
*
|
||||
* ```ts
|
||||
* // Worker: bot.trade('mule01', { give: [{ item: /ore/i, amount: -1 }] })
|
||||
* // Mule: bot.serveTrades({ from: /^fleet_/i, until: () => sdk.getInventory().length >= 26 })
|
||||
* ```
|
||||
*
|
||||
* This owns the bot while running - it is an explicit serving loop, not a
|
||||
* background hook, so it never fights another controller for the session.
|
||||
*/
|
||||
async serveTrades(options: ServeTradesOptions = {}): Promise<ServeTradesResult> {
|
||||
const {
|
||||
from,
|
||||
onTrade,
|
||||
until,
|
||||
maxTrades,
|
||||
tradeTimeout = 60_000,
|
||||
timeout = 600_000,
|
||||
} = options;
|
||||
const deadline = Date.now() + timeout;
|
||||
const trades: TradeResult[] = [];
|
||||
|
||||
const fromMatches = (name: string): boolean => {
|
||||
if (!from) return true;
|
||||
if (typeof from === 'string') return name.toLowerCase().includes(from.toLowerCase());
|
||||
return from.test(name);
|
||||
};
|
||||
|
||||
while (true) {
|
||||
if (until?.()) {
|
||||
return { success: true, message: `Stop condition met after ${trades.length} trade(s)`, trades, reason: 'until' };
|
||||
}
|
||||
if (maxTrades !== undefined && trades.length >= maxTrades) {
|
||||
return { success: true, message: `Completed ${trades.length} trade(s)`, trades, reason: 'max_trades' };
|
||||
}
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) {
|
||||
return { success: true, message: `Serving window ended after ${trades.length} trade(s)`, trades, reason: 'timeout' };
|
||||
}
|
||||
|
||||
// A session can already be open (e.g. a request raced the loop).
|
||||
let sessionOpen = this.sdk.getTradeState().isOpen;
|
||||
if (sessionOpen) {
|
||||
const partner = this.sdk.getTradeState().partner;
|
||||
if (partner && !fromMatches(partner)) {
|
||||
await this.declineTrade();
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
const requester = await this.sdk.waitForTradeRequest({ timeout: Math.min(remaining, 15_000) });
|
||||
if (!requester) continue;
|
||||
if (!fromMatches(requester)) continue;
|
||||
|
||||
const player = this.sdk.findNearbyPlayer(exactNamePattern(requester));
|
||||
if (!player) continue;
|
||||
|
||||
await this.sdk.sendTradeRequest(player.index);
|
||||
try {
|
||||
await this.waitForActionCondition(s => s.trade?.isOpen === true, 10_000);
|
||||
sessionOpen = true;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.runOpenTradeSession(
|
||||
{ give: options.give, want: options.want, accept: options.accept },
|
||||
Date.now() + Math.min(tradeTimeout, Math.max(1, deadline - Date.now()))
|
||||
);
|
||||
trades.push(result);
|
||||
onTrade?.(result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive an already-open trade session to completion: offer, accept with
|
||||
* re-accept on partner offer changes, confirm-screen re-verification,
|
||||
* and inventory-delta reporting.
|
||||
*/
|
||||
private async runOpenTradeSession(options: TradeOptions, deadline: number): Promise<TradeResult> {
|
||||
const want = options.want ?? [];
|
||||
const acceptOffer = options.accept ?? ((offer: TradeItem[]) => offerSatisfies(want, offer));
|
||||
const startState = this.sdk.getState();
|
||||
const partner = this.sdk.getTradeState().partner ?? undefined;
|
||||
const invBefore = countInventoryById(startState?.inventory ?? []);
|
||||
const sessionBaseline = this.helpers.getMessageTick();
|
||||
|
||||
const fail = async (message: string, reason: TradeResult['reason'], decline: boolean): Promise<TradeResult> => {
|
||||
if (decline) await this.declineTrade();
|
||||
return { success: false, message, partner, gave: [], received: [], reason };
|
||||
};
|
||||
|
||||
// 1. Put our items in.
|
||||
if (options.give?.length) {
|
||||
const offered = await this.offerTradeItems(options.give, Math.max(1, deadline - Date.now()));
|
||||
if (!offered.success) {
|
||||
if (!this.sdk.getTradeState().isOpen) {
|
||||
return this.classifyClosedSession(sessionBaseline, partner, invBefore);
|
||||
}
|
||||
return fail(offered.message, 'offer_failed', true);
|
||||
}
|
||||
}
|
||||
|
||||
// Accept retries are gated on tick advancement, never on "any state
|
||||
// change": every executed action publishes a snapshot with an
|
||||
// unchanged tick, so a state-change wait resolves instantly against
|
||||
// stale data and floods the engine's per-tick packet budget. That
|
||||
// backlogs this client's own update stream by whole minutes - the
|
||||
// trade advances server-side while both clients read stale screens.
|
||||
const waitForNextTick = async () => {
|
||||
const tick = this.sdk.getState()?.tick ?? 0;
|
||||
await this.sdk.waitForCondition(
|
||||
s => s.tick > tick,
|
||||
Math.min(2_000, Math.max(1, deadline - Date.now()))
|
||||
).catch(() => {});
|
||||
};
|
||||
|
||||
// 2. Offer screen: accept whenever the partner's visible offer passes
|
||||
// the policy. A partner offer change resets accepts server-side, so
|
||||
// loop until the screen advances.
|
||||
let lastAcceptTick = -1;
|
||||
while (true) {
|
||||
if (Date.now() >= deadline) {
|
||||
return fail('Trade timed out on the offer screen', 'timeout', true);
|
||||
}
|
||||
const trade = this.sdk.getTradeState();
|
||||
if (!trade.isOpen) {
|
||||
if (await this.tradeClosedForGood()) {
|
||||
return this.classifyClosedSession(sessionBaseline, partner, invBefore);
|
||||
}
|
||||
continue; // transition blip - the confirm screen is opening
|
||||
}
|
||||
if (trade.screen === 'confirm') break;
|
||||
|
||||
const tick = this.sdk.getState()?.tick ?? 0;
|
||||
if (acceptOffer(trade.theirOffer) && !trade.myAccepted && tick !== lastAcceptTick) {
|
||||
lastAcceptTick = tick;
|
||||
await this.sdk.sendAcceptTrade();
|
||||
}
|
||||
await waitForNextTick();
|
||||
}
|
||||
|
||||
// 3. Confirm screen: the offers here are final. Re-verify before the
|
||||
// irreversible accept.
|
||||
{
|
||||
const confirm = this.sdk.getTradeState();
|
||||
if (!acceptOffer(confirm.theirOffer)) {
|
||||
const missing = missingFromOffer(want, confirm.theirOffer)
|
||||
.map(spec => `${spec.item}${spec.amount && spec.amount > 1 ? ` x${spec.amount}` : ''}`)
|
||||
.join(', ');
|
||||
return fail(
|
||||
`Partner's final offer does not satisfy requirements${missing ? ` (missing: ${missing})` : ''} - declined`,
|
||||
'want_not_met',
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lastAcceptTick = -1;
|
||||
while (true) {
|
||||
if (Date.now() >= deadline) {
|
||||
return fail('Trade timed out on the confirm screen', 'timeout', true);
|
||||
}
|
||||
const trade = this.sdk.getTradeState();
|
||||
if (!trade.isOpen) {
|
||||
if (await this.tradeClosedForGood()) {
|
||||
return this.classifyClosedSession(sessionBaseline, partner, invBefore);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (trade.screen !== 'confirm') {
|
||||
// Back on the offer screen: only possible if this session was
|
||||
// re-entered mid-transition; let the offer-screen rules apply.
|
||||
return fail('Trade returned to the offer screen unexpectedly', 'error', true);
|
||||
}
|
||||
const tick = this.sdk.getState()?.tick ?? 0;
|
||||
if (!trade.myAccepted && tick !== lastAcceptTick) {
|
||||
lastAcceptTick = tick;
|
||||
await this.sdk.sendAcceptTrade();
|
||||
}
|
||||
await waitForNextTick();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True once "no trade open" has persisted for two game ticks. The engine
|
||||
* closes the side modal with an IfClose packet before opening the confirm
|
||||
* screen (Player.openMainModal), so a single snapshot showing no trade is
|
||||
* not proof the session ended - it can be the offer->confirm transition.
|
||||
*/
|
||||
private async tradeClosedForGood(): Promise<boolean> {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
if (this.sdk.getTradeState().isOpen) return false;
|
||||
await this.sdk.waitForTicks(1).catch(() => {});
|
||||
}
|
||||
return !this.sdk.getTradeState().isOpen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Once the trade screen has closed, decide from game messages whether the
|
||||
* trade completed ("Accepted trade.") or was declined, and report the
|
||||
* inventory delta for a completion.
|
||||
*/
|
||||
private async classifyClosedSession(
|
||||
sessionBaseline: number,
|
||||
partner: string | undefined,
|
||||
invBefore: Map<number, { name: string; count: number }>
|
||||
): Promise<TradeResult> {
|
||||
// Let the post-trade inventory update land before diffing.
|
||||
await this.sdk.waitForTicks(1).catch(() => {});
|
||||
|
||||
const declined = this.helpers.findRefusal(sessionBaseline, ['declined trade']);
|
||||
const noSpace = this.helpers.findRefusal(sessionBaseline, ['enough inventory space']);
|
||||
const accepted = this.helpers.findRefusal(sessionBaseline, ['accepted trade']);
|
||||
|
||||
if (accepted) {
|
||||
const invAfter = countInventoryById(this.sdk.getState()?.inventory ?? []);
|
||||
const { gained, lost } = diffInventories(invBefore, invAfter);
|
||||
const gaveText = lost.length ? lost.map(i => `${i.name} x${i.count}`).join(', ') : 'nothing';
|
||||
const receivedText = gained.length ? gained.map(i => `${i.name} x${i.count}`).join(', ') : 'nothing';
|
||||
return {
|
||||
success: true,
|
||||
message: `Trade completed with ${partner ?? 'partner'}: gave ${gaveText}, received ${receivedText}`,
|
||||
partner,
|
||||
gave: lost,
|
||||
received: gained,
|
||||
};
|
||||
}
|
||||
if (noSpace) {
|
||||
return { success: false, message: noSpace, partner, gave: [], received: [], reason: 'no_space' };
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: declined ?? 'Trade screen closed before completion',
|
||||
partner,
|
||||
gave: [],
|
||||
received: [],
|
||||
reason: 'declined',
|
||||
};
|
||||
}
|
||||
|
||||
// ============ Porcelain: Equipment & Combat ============
|
||||
|
||||
/** Equip an item from inventory. */
|
||||
|
||||
@@ -259,6 +259,31 @@ export function formatWorldState(
|
||||
}
|
||||
}
|
||||
|
||||
// Trade state
|
||||
if (state.trade?.isOpen) {
|
||||
lines.push('');
|
||||
lines.push(`## Trade (${state.trade.screen} screen)`);
|
||||
lines.push(`Trading with: ${state.trade.partner ?? 'Unknown'}`);
|
||||
if (state.trade.myAccepted) lines.push('You have accepted - waiting for partner.');
|
||||
if (state.trade.partnerAccepted) lines.push('Partner has accepted.');
|
||||
lines.push('**Your offer:**');
|
||||
if (state.trade.myOffer.length === 0) {
|
||||
lines.push(' (Nothing)');
|
||||
} else {
|
||||
for (const item of state.trade.myOffer) {
|
||||
lines.push(` [${item.slot}] ${item.name} x${item.count}`);
|
||||
}
|
||||
}
|
||||
lines.push('**Their offer:**');
|
||||
if (state.trade.theirOffer.length === 0) {
|
||||
lines.push(' (Nothing)');
|
||||
} else {
|
||||
for (const item of state.trade.theirOffer) {
|
||||
lines.push(` [${item.slot}] ${item.name} x${item.count}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skills (filter out unknown/placeholder skills like Stat18, Stat19)
|
||||
lines.push('');
|
||||
lines.push('## Skills');
|
||||
|
||||
@@ -146,6 +146,7 @@ function methodCategory(name: string, surface: 'actions' | 'sdk'): string {
|
||||
if (lower.includes('pickup')) return 'Items & Inventory';
|
||||
if (lower.includes('door')) return 'Doors';
|
||||
if (lower.includes('talk') || lower.includes('interact') || lower.includes('pickpocket')) return 'NPC & Object Interaction';
|
||||
if (lower.includes('trade')) return 'Player Trading';
|
||||
if (lower.includes('shop') || lower.includes('buy') || lower.includes('sell')) return 'Shopping';
|
||||
if (lower.includes('bank') || lower.includes('deposit') || lower.includes('withdraw')) return 'Banking';
|
||||
if (lower.includes('wait')) return 'Condition Waiting';
|
||||
|
||||
+125
-1
@@ -24,7 +24,8 @@ import type {
|
||||
GameMessage,
|
||||
FindOptions
|
||||
} from './types';
|
||||
import { PRAYER_INDICES, PRAYER_NAMES, PLAYER_CHAT_TYPES, isPlayerChat } from './types';
|
||||
import type { TradeState } from './types';
|
||||
import { PRAYER_INDICES, PRAYER_NAMES, PLAYER_CHAT_TYPES, isPlayerChat, TRADE_REQUEST_CHAT_TYPE } from './types';
|
||||
import { ChatHistory } from './chat-history';
|
||||
import * as pathfinding from './pathfinding';
|
||||
import { resolveInterfaceOption, shortestNameMatch, type InterfaceOptionSelector } from './action-quantity';
|
||||
@@ -35,6 +36,13 @@ import { resolveInterfaceOption, shortestNameMatch, type InterfaceOptionSelector
|
||||
* result into null instead, so callers get "nothing usable" rather than a
|
||||
* target whose interaction would fail with a silent `cant_reach`.
|
||||
*/
|
||||
// Player trade interface components (see server/webclient/src/bot/types.ts
|
||||
// for the full trademain/tradeconfirm component map).
|
||||
const TRADE_SIDE_INV_ID = 3322; // tradeside:inv - INV_BUTTON = Offer 1/5/10/All/X
|
||||
const TRADE_MAIN_INV_ID = 3415; // trademain:inv - INV_BUTTON = Remove 1/5/10/All/X
|
||||
const TRADE_MAIN_ACCEPT_ID = 3420; // trademain:accept
|
||||
const TRADE_CONFIRM_ACCEPT_ID = 3546; // tradeconfirm:accept
|
||||
|
||||
function applyFindOptions<T extends { reachable?: boolean }>(match: T | null, options?: FindOptions): T | null {
|
||||
if (options?.reachable === true && match?.reachable === false) return null;
|
||||
return match;
|
||||
@@ -54,6 +62,11 @@ function selectorLabel(selector: InterfaceOptionSelector): string {
|
||||
* - anything else → wss://HOST/gateway (TLS, remote gateway path)
|
||||
*/
|
||||
export function deriveGatewayUrl(server?: string): string {
|
||||
// GATEWAY_URL overrides derivation for hosts where SERVER doubles as the
|
||||
// web origin but not the gateway (e.g. SERVER=localhost:8888 is the engine
|
||||
// web port; the gateway is ws://localhost:7780). The lite runner honors
|
||||
// the same variable.
|
||||
if (typeof process !== 'undefined' && process.env?.GATEWAY_URL) return process.env.GATEWAY_URL;
|
||||
if (!server) return 'ws://localhost:7780';
|
||||
if (server.startsWith('ws://') || server.startsWith('wss://')) return server;
|
||||
const isLocal = server === 'localhost' || server.startsWith('localhost:');
|
||||
@@ -1101,6 +1114,9 @@ export class BotSDK {
|
||||
if (state.bank.isOpen) {
|
||||
return 'Bank interface is open, which replaces the inventory tab - the server will silently drop this. Close it first (bot.closeInterface() / sdk.sendCloseModal()).';
|
||||
}
|
||||
if (state.trade?.isOpen) {
|
||||
return 'Trade interface is open, which replaces the inventory tab - the server will silently drop this. Finish or decline the trade first (sdk.sendDeclineTrade()).';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1267,6 +1283,114 @@ export class BotSDK {
|
||||
return this.sendAction({ type: 'submitCountDialog', value, reason: 'SDK' });
|
||||
}
|
||||
|
||||
// ============ Player Trading ============
|
||||
|
||||
/**
|
||||
* Current player-to-player trade session state. Returns a closed-trade
|
||||
* default when no state has arrived or the connected client predates
|
||||
* trade support.
|
||||
*/
|
||||
getTradeState(): TradeState {
|
||||
return this.state?.trade ?? {
|
||||
isOpen: false,
|
||||
screen: null,
|
||||
partner: null,
|
||||
myOffer: [],
|
||||
theirOffer: [],
|
||||
myAccepted: false,
|
||||
partnerAccepted: false
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send (or accept) a trade request to another player. There is no
|
||||
* separate "accept" packet: requesting a player who already requested
|
||||
* you is the acceptance, and opens the trade screen for both. Otherwise
|
||||
* the partner sees "<you> wishes to trade with you." and the trade opens
|
||||
* when they request back.
|
||||
*/
|
||||
async sendTradeRequest(playerIndex: number): Promise<ActionResult> {
|
||||
return this.sendInteractPlayer(playerIndex, 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move items from your (trade-screen) side inventory into your offer.
|
||||
* `slot` is the inventory slot. Amounts 1/5/10 and -1 (All) map to the
|
||||
* game's offer buttons; any other amount uses Offer-X plus the count
|
||||
* dialog. Only valid while the offer screen is open.
|
||||
*/
|
||||
async sendOfferItem(slot: number, amount: number = 1): Promise<ActionResult> {
|
||||
return this.sendTradeInvButton(TRADE_SIDE_INV_ID, slot, amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove items from your offer back to your inventory. Same amount
|
||||
* semantics as {@link sendOfferItem}. Note: removing (or adding) items
|
||||
* resets both players' accepts server-side.
|
||||
*/
|
||||
async sendRetractItem(slot: number, amount: number = 1): Promise<ActionResult> {
|
||||
return this.sendTradeInvButton(TRADE_MAIN_INV_ID, slot, amount);
|
||||
}
|
||||
|
||||
private async sendTradeInvButton(componentId: number, slot: number, amount: number): Promise<ActionResult> {
|
||||
if (!Number.isInteger(amount) || amount === 0 || (amount < 0 && amount !== -1)) {
|
||||
return { success: false, message: `Invalid trade amount: ${amount}`, reason: 'invalid_amount' };
|
||||
}
|
||||
const option = amount === 1 ? 1
|
||||
: amount === 5 ? 2
|
||||
: amount === 10 ? 3
|
||||
: (amount === -1 || amount >= 0x7fffffff) ? 4
|
||||
: 5;
|
||||
const result = await this.sendClickComponentWithOption(componentId, option, slot);
|
||||
if (!result.success || option !== 5) return result;
|
||||
// Offer-X: the server opens a count dialog; this waits for it and submits.
|
||||
return this.sendCountDialog(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept the currently open trade screen (first or confirm). The trade
|
||||
* only advances when both players accept; an offer change resets accepts.
|
||||
*/
|
||||
async sendAcceptTrade(): Promise<ActionResult> {
|
||||
const trade = this.getTradeState();
|
||||
if (!trade.isOpen) {
|
||||
return { success: false, message: 'No trade screen is open', reason: 'not_open' };
|
||||
}
|
||||
const componentId = trade.screen === 'confirm' ? TRADE_CONFIRM_ACCEPT_ID : TRADE_MAIN_ACCEPT_ID;
|
||||
return this.sendClickComponent(componentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decline the open trade (closes the screen; both sides get their items
|
||||
* back and the partner sees "Other player declined trade.").
|
||||
*/
|
||||
async sendDeclineTrade(): Promise<ActionResult> {
|
||||
const trade = this.getTradeState();
|
||||
if (!trade.isOpen) {
|
||||
return { success: false, message: 'No trade screen is open', reason: 'not_open' };
|
||||
}
|
||||
return this.sendCloseModal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an incoming trade request ("X wishes to trade with you.").
|
||||
* Requests arrive as chat type {@link TRADE_REQUEST_CHAT_TYPE}, which the
|
||||
* default chat readers filter out. Returns the requester's name, or null
|
||||
* on timeout.
|
||||
*
|
||||
* @param opts.from Only accept requests from this sender (substring match).
|
||||
* @param opts.timeout Ms to wait (default 30000).
|
||||
*/
|
||||
async waitForTradeRequest(opts: { from?: string; timeout?: number } = {}): Promise<string | null> {
|
||||
const message = await this.waitForChat({
|
||||
from: opts.from,
|
||||
types: [TRADE_REQUEST_CHAT_TYPE],
|
||||
matching: /wishes to trade/i,
|
||||
timeout: opts.timeout ?? 30000
|
||||
});
|
||||
return message?.sender ?? null;
|
||||
}
|
||||
|
||||
/** Set combat style (0-3). */
|
||||
async sendSetCombatStyle(style: number): Promise<ActionResult> {
|
||||
return this.sendAction({ type: 'setCombatStyle', style, reason: 'SDK' });
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { BotActions } from '../actions';
|
||||
import type { NearbyPlayer, TradeItem, TradeState } from '../types';
|
||||
|
||||
const partner: NearbyPlayer = {
|
||||
kind: 'player',
|
||||
index: 7,
|
||||
name: 'MuleBot',
|
||||
combatLevel: 3,
|
||||
x: 3222,
|
||||
z: 3222,
|
||||
distance: 1,
|
||||
reachable: true,
|
||||
};
|
||||
|
||||
const CLOSED_TRADE: TradeState = {
|
||||
isOpen: false,
|
||||
screen: null,
|
||||
partner: null,
|
||||
myOffer: [],
|
||||
theirOffer: [],
|
||||
myAccepted: false,
|
||||
partnerAccepted: false,
|
||||
};
|
||||
|
||||
const item = (slot: number, id: number, name: string, count: number): TradeItem => ({ slot, id, name, count });
|
||||
|
||||
interface Frame {
|
||||
trade?: Partial<TradeState>;
|
||||
inventory?: Array<{ slot: number; id: number; name: string; count: number }>;
|
||||
message?: string;
|
||||
tick?: number;
|
||||
}
|
||||
|
||||
function state(frame: Frame, tick: number) {
|
||||
const trade: TradeState = { ...CLOSED_TRADE, ...(frame.trade ?? {}) };
|
||||
return {
|
||||
tick,
|
||||
revision: tick,
|
||||
player: { worldX: 3222, worldZ: 3222, lifeId: 1, isDead: false },
|
||||
inventory: frame.inventory ?? [],
|
||||
nearbyPlayers: [partner],
|
||||
nearbyNpcs: [],
|
||||
skills: [],
|
||||
gameMessages: frame.message
|
||||
? [{ tick, text: frame.message, type: 0, sender: '', fromSelf: false }]
|
||||
: [],
|
||||
combatEvents: [],
|
||||
dialog: { isOpen: false, options: [], isWaiting: false },
|
||||
interface: trade.isOpen
|
||||
? { isOpen: true, interfaceId: trade.screen === 'confirm' ? 3443 : 3323, options: [] }
|
||||
: { isOpen: false, interfaceId: -1, options: [] },
|
||||
shop: { isOpen: false },
|
||||
bank: { isOpen: false },
|
||||
trade,
|
||||
} as any;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bot whose state advances one frame per wait call. Messages accumulate is
|
||||
* not simulated - each frame carries its own gameMessages. sendDeclineTrade
|
||||
* fast-forwards to the last frame (the trade being closed), mirroring the
|
||||
* server closing both screens.
|
||||
*/
|
||||
function createHarness(frames: Frame[]) {
|
||||
const sequence = frames.map((frame, i) => state(frame, i + 1));
|
||||
let idx = 0;
|
||||
const dispatched: Array<{ call: string; args: unknown[] }> = [];
|
||||
const advance = () => {
|
||||
if (idx < sequence.length - 1) idx++;
|
||||
return sequence[idx];
|
||||
};
|
||||
const record = (call: string, effect?: () => void) => async (...args: unknown[]) => {
|
||||
dispatched.push({ call, args });
|
||||
effect?.();
|
||||
return { success: true, message: 'dispatched' };
|
||||
};
|
||||
const sdk: any = {
|
||||
getState: () => sequence[idx],
|
||||
getTradeState: () => sequence[idx].trade ?? CLOSED_TRADE,
|
||||
waitForStateChange: async () => advance(),
|
||||
waitForTicks: async () => advance(),
|
||||
waitForCondition: async (pred: (s: any) => boolean) => {
|
||||
for (let guard = 0; guard < sequence.length + 1; guard++) {
|
||||
if (pred(sequence[idx])) return sequence[idx];
|
||||
if (idx >= sequence.length - 1) break;
|
||||
idx++;
|
||||
}
|
||||
throw new Error('waitForCondition timed out');
|
||||
},
|
||||
findNearbyPlayer: (pattern: string | RegExp) => {
|
||||
const regex = typeof pattern === 'string' ? new RegExp(pattern, 'i') : pattern;
|
||||
return regex.test(partner.name) ? partner : null;
|
||||
},
|
||||
findInventoryItem: (pattern: string | RegExp) => {
|
||||
const regex = typeof pattern === 'string' ? new RegExp(pattern, 'i') : pattern;
|
||||
return sequence[idx].inventory.find((i: any) => regex.test(i.name)) ?? null;
|
||||
},
|
||||
sendTradeRequest: record('sendTradeRequest'),
|
||||
sendOfferItem: record('sendOfferItem'),
|
||||
sendAcceptTrade: record('sendAcceptTrade'),
|
||||
sendDeclineTrade: record('sendDeclineTrade', () => { idx = sequence.length - 1; }),
|
||||
waitForTradeRequest: async () => null,
|
||||
};
|
||||
return { bot: new BotActions(sdk), sdk, dispatched };
|
||||
}
|
||||
|
||||
const logs = (count: number) => item(0, 1511, 'Logs', count);
|
||||
|
||||
describe('bot.trade gift flow', () => {
|
||||
test('offers, double-accepts, and reports the inventory delta', async () => {
|
||||
const inventoryBefore = [{ slot: 0, id: 1511, name: 'Logs', count: 1 }];
|
||||
const harness = createHarness([
|
||||
// 0: no trade yet, partner nearby
|
||||
{ inventory: inventoryBefore },
|
||||
// 1: request answered, offer screen open
|
||||
{ trade: { isOpen: true, screen: 'offer', partner: 'MuleBot' }, inventory: inventoryBefore },
|
||||
// 2: our logs are in the offer window
|
||||
{ trade: { isOpen: true, screen: 'offer', partner: 'MuleBot', myOffer: [logs(1)] }, inventory: [] },
|
||||
// 3: both accepted -> confirm screen
|
||||
{ trade: { isOpen: true, screen: 'confirm', partner: 'MuleBot', myOffer: [logs(1)] }, inventory: [] },
|
||||
// 4: trade completed
|
||||
{ message: 'Accepted trade.', inventory: [] },
|
||||
]);
|
||||
|
||||
const result = await harness.bot.trade(partner, {
|
||||
give: [{ item: 'logs', amount: -1 }],
|
||||
timeout: 3_000,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.reason).toBeUndefined();
|
||||
expect(result.partner).toBe('MuleBot');
|
||||
expect(result.gave).toEqual([{ slot: -1, id: 1511, name: 'Logs', count: 1 }]);
|
||||
expect(result.received).toEqual([]);
|
||||
expect(harness.dispatched.map(d => d.call)).toEqual([
|
||||
'sendTradeRequest',
|
||||
'sendOfferItem',
|
||||
'sendAcceptTrade', // offer screen
|
||||
'sendAcceptTrade', // confirm screen
|
||||
]);
|
||||
expect(harness.dispatched[1]).toEqual({ call: 'sendOfferItem', args: [0, -1] });
|
||||
expect(harness.dispatched[0]).toEqual({ call: 'sendTradeRequest', args: [partner.index] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('bot.trade want enforcement', () => {
|
||||
test('declines on the confirm screen when the final offer shrank', async () => {
|
||||
const lobsters = (n: number) => item(0, 379, 'Lobster', n);
|
||||
const harness = createHarness([
|
||||
{ },
|
||||
// Offer screen shows 5 lobsters - passes the want check.
|
||||
{ trade: { isOpen: true, screen: 'offer', partner: 'MuleBot', theirOffer: [lobsters(5)] } },
|
||||
// Confirm screen shows only 4 - the re-verification must catch this.
|
||||
{ trade: { isOpen: true, screen: 'confirm', partner: 'MuleBot', theirOffer: [lobsters(4)] } },
|
||||
// Frame declineTrade jumps to: closed with the decline message.
|
||||
{ message: 'Other player declined trade.' },
|
||||
]);
|
||||
|
||||
const result = await harness.bot.trade(partner, {
|
||||
want: [{ item: 'lobster', amount: 5 }],
|
||||
timeout: 3_000,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.reason).toBe('want_not_met');
|
||||
expect(harness.dispatched.map(d => d.call)).toEqual([
|
||||
'sendTradeRequest',
|
||||
'sendAcceptTrade', // offer screen accept (offer looked fine there)
|
||||
'sendDeclineTrade', // confirm screen re-check refused
|
||||
]);
|
||||
});
|
||||
|
||||
test('does not accept the offer screen until want is met', async () => {
|
||||
const harness = createHarness([
|
||||
{ },
|
||||
// Offer screen with nothing offered - must NOT accept.
|
||||
{ trade: { isOpen: true, screen: 'offer', partner: 'MuleBot' } },
|
||||
// Partner closes the trade.
|
||||
{ message: 'Other player declined trade.' },
|
||||
]);
|
||||
|
||||
const result = await harness.bot.trade(partner, {
|
||||
want: [{ item: 'coins', amount: 100 }],
|
||||
timeout: 3_000,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.reason).toBe('declined');
|
||||
expect(harness.dispatched.map(d => d.call)).toEqual(['sendTradeRequest']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bot.tradeWith failure modes', () => {
|
||||
test('reports a busy partner instead of waiting out the clock', async () => {
|
||||
const harness = createHarness([
|
||||
{ },
|
||||
{ message: 'MuleBot is busy at the moment.' },
|
||||
]);
|
||||
|
||||
const result = await harness.bot.tradeWith(partner, 3_000);
|
||||
expect(result).toMatchObject({ success: false, reason: 'busy' });
|
||||
});
|
||||
|
||||
test('fails fast when the player is not nearby', async () => {
|
||||
const harness = createHarness([{ }]);
|
||||
const result = await harness.bot.tradeWith('NoSuchPlayer', 500);
|
||||
expect(result).toMatchObject({ success: false, reason: 'player_not_found' });
|
||||
expect(harness.dispatched).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bot.serveTrades', () => {
|
||||
test('serves an incoming gift and stops on the until condition', async () => {
|
||||
const coins = (n: number) => item(0, 995, 'Coins', n);
|
||||
const harness = createHarness([
|
||||
{ },
|
||||
{ trade: { isOpen: true, screen: 'offer', partner: 'MuleBot', theirOffer: [coins(250)] } },
|
||||
{ trade: { isOpen: true, screen: 'confirm', partner: 'MuleBot', theirOffer: [coins(250)] } },
|
||||
{ message: 'Accepted trade.', inventory: [{ slot: 0, id: 995, name: 'Coins', count: 250 }] },
|
||||
]);
|
||||
harness.sdk.waitForTradeRequest = async () => 'MuleBot';
|
||||
|
||||
let served = false;
|
||||
const result = await harness.bot.serveTrades({
|
||||
from: /mule/i,
|
||||
onTrade: r => { served = r.success; },
|
||||
until: () => served,
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.reason).toBe('until');
|
||||
expect(result.trades).toHaveLength(1);
|
||||
expect(result.trades[0]!.received).toEqual([{ slot: -1, id: 995, name: 'Coins', count: 250 }]);
|
||||
expect(harness.dispatched.map(d => d.call)).toEqual([
|
||||
'sendTradeRequest', // requesting back = accepting
|
||||
'sendAcceptTrade',
|
||||
'sendAcceptTrade',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ignores requesters that fail the from filter', async () => {
|
||||
const harness = createHarness([{ }]);
|
||||
let calls = 0;
|
||||
harness.sdk.waitForTradeRequest = async () => {
|
||||
calls++;
|
||||
return calls === 1 ? 'Stranger' : null;
|
||||
};
|
||||
|
||||
const result = await harness.bot.serveTrades({ from: /^fleet_/i, timeout: 300 });
|
||||
expect(result.trades).toHaveLength(0);
|
||||
expect(harness.dispatched).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { countInventoryById, countMatching, diffInventories, missingFromOffer, offerSatisfies } from '../trade-helpers';
|
||||
import type { TradeItem } from '../types';
|
||||
|
||||
const offer = (...items: Array<[string, number]>): TradeItem[] =>
|
||||
items.map(([name, count], slot) => ({ slot, id: slot + 100, name, count }));
|
||||
|
||||
describe('countMatching', () => {
|
||||
test('sums counts across matching slots', () => {
|
||||
const items = offer(['Guam leaf', 2], ['Harralander', 1], ['Coins', 500]);
|
||||
expect(countMatching(items, /leaf|harralander/i)).toBe(3);
|
||||
});
|
||||
|
||||
test('string patterns are case-insensitive substrings, not regex', () => {
|
||||
const items = offer(['Steel arrow (p)', 10]);
|
||||
expect(countMatching(items, 'arrow (p)')).toBe(10);
|
||||
expect(countMatching(items, 'bronze')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('offerSatisfies', () => {
|
||||
test('empty or missing want accepts anything, including nothing', () => {
|
||||
expect(offerSatisfies([], [])).toBe(true);
|
||||
expect(offerSatisfies(undefined, offer(['Coins', 1]))).toBe(true);
|
||||
});
|
||||
|
||||
test('requires every wanted item at the wanted amount', () => {
|
||||
const theirs = offer(['Lobster', 5], ['Coins', 100]);
|
||||
expect(offerSatisfies([{ item: 'lobster', amount: 5 }], theirs)).toBe(true);
|
||||
expect(offerSatisfies([{ item: 'lobster', amount: 6 }], theirs)).toBe(false);
|
||||
expect(offerSatisfies([{ item: 'lobster' }, { item: 'coins', amount: 100 }], theirs)).toBe(true);
|
||||
expect(offerSatisfies([{ item: 'shark' }], theirs)).toBe(false);
|
||||
});
|
||||
|
||||
test('amount defaults to 1', () => {
|
||||
expect(offerSatisfies([{ item: 'coins' }], offer(['Coins', 1]))).toBe(true);
|
||||
expect(offerSatisfies([{ item: 'coins' }], [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('missingFromOffer', () => {
|
||||
test('reports only the unmet specs', () => {
|
||||
const theirs = offer(['Lobster', 2]);
|
||||
const missing = missingFromOffer([{ item: 'lobster', amount: 2 }, { item: 'coins', amount: 50 }], theirs);
|
||||
expect(missing).toEqual([{ item: 'coins', amount: 50 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inventory diffing', () => {
|
||||
test('aggregates split stacks by id', () => {
|
||||
const map = countInventoryById([
|
||||
{ id: 1, name: 'Logs', count: 1 },
|
||||
{ id: 1, name: 'Logs', count: 1 },
|
||||
{ id: 2, name: 'Coins', count: 30 },
|
||||
]);
|
||||
expect(map.get(1)).toEqual({ name: 'Logs', count: 2 });
|
||||
expect(map.get(2)).toEqual({ name: 'Coins', count: 30 });
|
||||
});
|
||||
|
||||
test('reports gains and losses between snapshots', () => {
|
||||
const before = countInventoryById([
|
||||
{ id: 1, name: 'Logs', count: 3 },
|
||||
{ id: 2, name: 'Coins', count: 100 },
|
||||
]);
|
||||
const after = countInventoryById([
|
||||
{ id: 2, name: 'Coins', count: 350 },
|
||||
{ id: 3, name: 'Lobster', count: 2 },
|
||||
]);
|
||||
const { gained, lost } = diffInventories(before, after);
|
||||
expect(gained).toEqual([
|
||||
{ slot: -1, id: 2, name: 'Coins', count: 250 },
|
||||
{ slot: -1, id: 3, name: 'Lobster', count: 2 },
|
||||
]);
|
||||
expect(lost).toEqual([{ slot: -1, id: 1, name: 'Logs', count: 3 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
// Bot SDK - Trade helpers
|
||||
// Pure functions for player-to-player trade logic, kept side-effect free so
|
||||
// they can be unit tested without a connected client.
|
||||
|
||||
import type { TradeItem, TradeItemSpec } from './types';
|
||||
|
||||
/** Total count of items in an offer whose name matches the spec pattern. */
|
||||
export function countMatching(offer: TradeItem[], pattern: string | RegExp): number {
|
||||
const regex = typeof pattern === 'string' ? new RegExp(escapeRegExp(pattern), 'i') : pattern;
|
||||
let total = 0;
|
||||
for (const item of offer) {
|
||||
if (regex.test(item.name)) total += item.count;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the offer contains every wanted item at (at least) the wanted
|
||||
* amount. An empty/undefined want list is satisfied by any offer, including
|
||||
* an empty one — the pure-gift case.
|
||||
*/
|
||||
export function offerSatisfies(want: TradeItemSpec[] | undefined, offer: TradeItem[]): boolean {
|
||||
if (!want || want.length === 0) return true;
|
||||
return want.every(spec => countMatching(offer, spec.item) >= (spec.amount ?? 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* The wanted specs the offer does not (yet) meet, for failure messages.
|
||||
*/
|
||||
export function missingFromOffer(want: TradeItemSpec[] | undefined, offer: TradeItem[]): TradeItemSpec[] {
|
||||
if (!want) return [];
|
||||
return want.filter(spec => countMatching(offer, spec.item) < (spec.amount ?? 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff two inventory snapshots into net gains and losses by item id.
|
||||
* Snapshots are (id -> {name, count}) maps built with countInventoryById.
|
||||
*/
|
||||
export function diffInventories(
|
||||
before: Map<number, { name: string; count: number }>,
|
||||
after: Map<number, { name: string; count: number }>
|
||||
): { gained: TradeItem[]; lost: TradeItem[] } {
|
||||
const gained: TradeItem[] = [];
|
||||
const lost: TradeItem[] = [];
|
||||
const ids = new Set([...before.keys(), ...after.keys()]);
|
||||
for (const id of ids) {
|
||||
const b = before.get(id);
|
||||
const a = after.get(id);
|
||||
const delta = (a?.count ?? 0) - (b?.count ?? 0);
|
||||
const name = a?.name ?? b?.name ?? 'Unknown';
|
||||
if (delta > 0) gained.push({ slot: -1, id, name, count: delta });
|
||||
else if (delta < 0) lost.push({ slot: -1, id, name, count: -delta });
|
||||
}
|
||||
return { gained, lost };
|
||||
}
|
||||
|
||||
/** Aggregate an inventory-like item list into an (id -> {name, count}) map. */
|
||||
export function countInventoryById(items: Array<{ id: number; name: string; count: number }>): Map<number, { name: string; count: number }> {
|
||||
const map = new Map<number, { name: string; count: number }>();
|
||||
for (const item of items) {
|
||||
const existing = map.get(item.id);
|
||||
if (existing) existing.count += item.count;
|
||||
else map.set(item.id, { name: item.name, count: item.count });
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
+105
@@ -280,6 +280,109 @@ export interface BankState {
|
||||
items: BankItem[];
|
||||
}
|
||||
|
||||
// ============ Trade Types ============
|
||||
|
||||
/** An item slot inside a trade offer window. */
|
||||
export interface TradeItem {
|
||||
slot: number;
|
||||
id: number;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Player-to-player trade session state, read from the trademain/tradeconfirm
|
||||
* interfaces. Accept flags are parsed from the server-set status text — on
|
||||
* each screen at most one of myAccepted/partnerAccepted is ever true, because
|
||||
* the second accept advances (or completes) the trade. Any offer change on
|
||||
* the offer screen resets both accepts server-side, which is the built-in
|
||||
* anti-scam property: the confirm screen always shows the final offers.
|
||||
*/
|
||||
export interface TradeState {
|
||||
isOpen: boolean;
|
||||
/** 'offer' = first screen (offers editable), 'confirm' = final screen. */
|
||||
screen: 'offer' | 'confirm' | null;
|
||||
/** Partner display name, from "Trading With: <name>". */
|
||||
partner: string | null;
|
||||
myOffer: TradeItem[];
|
||||
theirOffer: TradeItem[];
|
||||
/** True when this client accepted the current screen and is waiting on the partner. */
|
||||
myAccepted: boolean;
|
||||
/** True when the partner accepted the current screen. */
|
||||
partnerAccepted: boolean;
|
||||
}
|
||||
|
||||
/** Chat type code the client uses for "X wishes to trade with you." requests. */
|
||||
export const TRADE_REQUEST_CHAT_TYPE = 4;
|
||||
|
||||
/** One item pattern a trade expects or gives. */
|
||||
export interface TradeItemSpec {
|
||||
/** Item name pattern (string = case-insensitive substring, or RegExp). */
|
||||
item: string | RegExp;
|
||||
/** For `give`: -1 = all (default 1). For `want`: minimum count required (default 1). */
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export interface TradeOptions {
|
||||
/** Items to place in your offer. Default: give nothing. */
|
||||
give?: TradeItemSpec[];
|
||||
/**
|
||||
* Items the partner's offer must contain before you accept. Default `[]`
|
||||
* accepts any partner offer including nothing (a pure gift). Re-verified
|
||||
* on the confirm screen before the final accept.
|
||||
*/
|
||||
want?: TradeItemSpec[];
|
||||
/**
|
||||
* Escape hatch replacing `want`: accept only when this predicate passes
|
||||
* on the partner's visible offer. Also re-checked on the confirm screen.
|
||||
*/
|
||||
accept?: (theirOffer: TradeItem[]) => boolean;
|
||||
/** Overall deadline in ms (default 60_000). On expiry the trade is declined. */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface TradeResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
/** Partner display name, when known. */
|
||||
partner?: string;
|
||||
/** Items removed from your inventory (by the completed trade). */
|
||||
gave: TradeItem[];
|
||||
/** Items added to your inventory (by the completed trade). */
|
||||
received: TradeItem[];
|
||||
reason?: 'player_not_found' | 'no_response' | 'declined' | 'busy' | 'offer_failed'
|
||||
| 'want_not_met' | 'no_space' | 'not_open' | 'timeout' | 'error';
|
||||
}
|
||||
|
||||
export interface ServeTradesOptions {
|
||||
/** Only accept trades from players whose name matches. Default: anyone. */
|
||||
from?: string | RegExp;
|
||||
/** Items to offer in every served trade. Default: nothing. */
|
||||
give?: TradeItemSpec[];
|
||||
/** Items each incoming offer must contain. Default `[]` = accept anything. */
|
||||
want?: TradeItemSpec[];
|
||||
/** Predicate alternative to `want`, applied to the partner's offer. */
|
||||
accept?: (theirOffer: TradeItem[]) => boolean;
|
||||
/** Called after each completed (or failed) trade. */
|
||||
onTrade?: (result: TradeResult) => void;
|
||||
/** Serving stops once this returns true (checked between trades). */
|
||||
until?: () => boolean;
|
||||
/** Stop after this many completed trades. Default: unlimited. */
|
||||
maxTrades?: number;
|
||||
/** Per-trade deadline in ms once a session opens (default 60_000). */
|
||||
tradeTimeout?: number;
|
||||
/** Overall serving deadline in ms (default 10 minutes). */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface ServeTradesResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
/** Completed trades, in order. */
|
||||
trades: TradeResult[];
|
||||
reason?: 'until' | 'max_trades' | 'timeout' | 'error';
|
||||
}
|
||||
|
||||
export interface CombatStyleOption {
|
||||
/** Value com_mode takes for this style, and what sendSetCombatStyle expects. */
|
||||
index: number;
|
||||
@@ -403,6 +506,8 @@ export interface BotWorldState {
|
||||
interface: InterfaceState;
|
||||
shop: ShopState;
|
||||
bank: BankState;
|
||||
/** Absent when the connected client predates trade support. */
|
||||
trade?: TradeState;
|
||||
modalOpen: boolean;
|
||||
modalInterface: number;
|
||||
combatStyle?: CombatStyleState;
|
||||
|
||||
@@ -19,6 +19,17 @@ import {
|
||||
EQUIPMENT_INTERFACE_ID,
|
||||
SHOP_TEMPLATE_INV_ID,
|
||||
SHOP_TEMPLATE_SIDE_INV_ID,
|
||||
TRADE_MAIN_ID,
|
||||
TRADE_MAIN_INV_ID,
|
||||
TRADE_MAIN_OTHER_INV_ID,
|
||||
TRADE_MAIN_PARTNER_TEXT_ID,
|
||||
TRADE_MAIN_STATUS_TEXT_ID,
|
||||
TRADE_CONFIRM_ID,
|
||||
TRADE_CONFIRM_INV1_ID,
|
||||
TRADE_CONFIRM_INV2_ID,
|
||||
TRADE_CONFIRM_OTHER_INV1_ID,
|
||||
TRADE_CONFIRM_OTHER_INV2_ID,
|
||||
TRADE_CONFIRM_STATUS_TEXT_ID,
|
||||
type BotState,
|
||||
type SkillState,
|
||||
type InventoryItem,
|
||||
@@ -36,6 +47,8 @@ import {
|
||||
type ShopItem,
|
||||
type BankState,
|
||||
type BankItem,
|
||||
type TradeState,
|
||||
type TradeItem,
|
||||
type PlayerState,
|
||||
type CombatStyleState,
|
||||
type CombatStyleOption,
|
||||
@@ -164,6 +177,7 @@ export class BotStateCollector implements ScanProvider {
|
||||
menuActions: this.collectMenuActions(),
|
||||
shop: this.collectShopState(),
|
||||
bank: this.collectBankState(),
|
||||
trade: this.collectTradeState(),
|
||||
inGame: c.ingame || false,
|
||||
combatEvents: [...this.combatEvents], // Return copy of events
|
||||
dialog: this.collectDialogState(),
|
||||
@@ -1432,6 +1446,82 @@ export class BotStateCollector implements ScanProvider {
|
||||
return bankState;
|
||||
}
|
||||
|
||||
/**
|
||||
* Player-to-player trade state, read from the trademain / tradeconfirm
|
||||
* interfaces. Works on both screens:
|
||||
* - offer screen: offers from trademain:inv / trademain:otherinv, accept
|
||||
* flags parsed from the trademain:status text.
|
||||
* - confirm screen: the engine transmits to inv1 (<=13 items) OR inv2
|
||||
* (14+), so read whichever is non-empty; the unused variant is zeroed
|
||||
* by UPDATE_INV_STOP_TRANSMIT when a trade closes. The partner name is
|
||||
* read from trademain:otherplayer, which retains its text from the
|
||||
* offer screen of the same trade.
|
||||
*/
|
||||
private collectTradeState(): TradeState {
|
||||
const c = this.client as any;
|
||||
const mainModalId = c.mainModalId ?? -1;
|
||||
|
||||
if (mainModalId !== TRADE_MAIN_ID && mainModalId !== TRADE_CONFIRM_ID) {
|
||||
return {
|
||||
isOpen: false,
|
||||
screen: null,
|
||||
partner: null,
|
||||
myOffer: [],
|
||||
theirOffer: [],
|
||||
myAccepted: false,
|
||||
partnerAccepted: false
|
||||
};
|
||||
}
|
||||
|
||||
const componentText = (id: number): string => {
|
||||
try {
|
||||
return IfType.list[id]?.text ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// "Trading With: <name>" — set on the offer screen, retained on confirm.
|
||||
const partnerText = componentText(TRADE_MAIN_PARTNER_TEXT_ID);
|
||||
const partnerMatch = partnerText.match(/^Trading With:\s*(.+)$/i);
|
||||
const partner = partnerMatch ? partnerMatch[1].trim() : null;
|
||||
|
||||
if (mainModalId === TRADE_MAIN_ID) {
|
||||
const status = componentText(TRADE_MAIN_STATUS_TEXT_ID);
|
||||
return {
|
||||
isOpen: true,
|
||||
screen: 'offer',
|
||||
partner,
|
||||
myOffer: this.collectTradeItems(TRADE_MAIN_INV_ID),
|
||||
theirOffer: this.collectTradeItems(TRADE_MAIN_OTHER_INV_ID),
|
||||
myAccepted: /waiting for other player/i.test(status),
|
||||
partnerAccepted: /other player has accepted/i.test(status)
|
||||
};
|
||||
}
|
||||
|
||||
const status = componentText(TRADE_CONFIRM_STATUS_TEXT_ID);
|
||||
const myInv1 = this.collectTradeItems(TRADE_CONFIRM_INV1_ID);
|
||||
const theirInv1 = this.collectTradeItems(TRADE_CONFIRM_OTHER_INV1_ID);
|
||||
return {
|
||||
isOpen: true,
|
||||
screen: 'confirm',
|
||||
partner,
|
||||
myOffer: myInv1.length > 0 ? myInv1 : this.collectTradeItems(TRADE_CONFIRM_INV2_ID),
|
||||
theirOffer: theirInv1.length > 0 ? theirInv1 : this.collectTradeItems(TRADE_CONFIRM_OTHER_INV2_ID),
|
||||
myAccepted: /waiting for other player/i.test(status),
|
||||
partnerAccepted: /other player has accepted/i.test(status)
|
||||
};
|
||||
}
|
||||
|
||||
private collectTradeItems(interfaceId: number): TradeItem[] {
|
||||
return this.collectInventoryItems(interfaceId).map(item => ({
|
||||
slot: item.slot,
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
count: item.count
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate shop price using the game's formula from shop.rs2
|
||||
* Formula: scale(max(100, multiplier - min(1000, max(-5000, stockDiff * haggle))), 1000, baseCost)
|
||||
|
||||
@@ -201,6 +201,31 @@ export function formatBotState(state: BotState): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Trade state (if open)
|
||||
if (state.trade && state.trade.isOpen) {
|
||||
lines.push('');
|
||||
lines.push(`--- TRADE OPEN (${state.trade.screen} screen) ---`);
|
||||
lines.push(`Partner: ${state.trade.partner ?? 'Unknown'}`);
|
||||
if (state.trade.myAccepted) lines.push('You have accepted - waiting for partner.');
|
||||
if (state.trade.partnerAccepted) lines.push('Partner has accepted.');
|
||||
lines.push('Your Offer:');
|
||||
if (state.trade.myOffer.length === 0) {
|
||||
lines.push(' Nothing');
|
||||
} else {
|
||||
for (const item of state.trade.myOffer) {
|
||||
lines.push(` [${item.slot}] ${item.name} x${item.count}`);
|
||||
}
|
||||
}
|
||||
lines.push('Their Offer:');
|
||||
if (state.trade.theirOffer.length === 0) {
|
||||
lines.push(' Nothing');
|
||||
} else {
|
||||
for (const item of state.trade.theirOffer) {
|
||||
lines.push(` [${item.slot}] ${item.name} x${item.count}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,23 @@ export const BANK_MAIN_ID = 5292; // Main bank interface (mainModalId)
|
||||
export const BANK_MAIN_INV_ID = 5382; // Bank inventory component (bank_main:inv)
|
||||
export const BANK_SIDE_INV_ID = 2006; // Side panel inventory for depositing
|
||||
|
||||
// Player trade interface IDs (interface_trade content pack)
|
||||
export const TRADE_SIDE_ID = 3321; // tradeside - side panel (your inventory, "Offer" options)
|
||||
export const TRADE_SIDE_INV_ID = 3322; // tradeside:inv - INV_BUTTON1..5 = Offer 1/5/10/All/X
|
||||
export const TRADE_MAIN_ID = 3323; // trademain - first trade screen (mainModalId)
|
||||
export const TRADE_MAIN_INV_ID = 3415; // trademain:inv - your offer, INV_BUTTON1..5 = Remove 1/5/10/All/X
|
||||
export const TRADE_MAIN_OTHER_INV_ID = 3416; // trademain:otherinv - partner's offer (read-only)
|
||||
export const TRADE_MAIN_PARTNER_TEXT_ID = 3417; // trademain:otherplayer - "Trading With: <name>"
|
||||
export const TRADE_MAIN_ACCEPT_ID = 3420; // trademain:accept - IF_BUTTON
|
||||
export const TRADE_MAIN_STATUS_TEXT_ID = 3431; // trademain:status - ""/"Waiting for other player..."/"Other player has accepted."
|
||||
export const TRADE_CONFIRM_ID = 3443; // tradeconfirm - second trade screen (mainModalId)
|
||||
export const TRADE_CONFIRM_INV1_ID = 3542; // tradeconfirm:inv1 - your offer (<= 13 items variant)
|
||||
export const TRADE_CONFIRM_INV2_ID = 3538; // tradeconfirm:inv2 - your offer (> 13 items variant)
|
||||
export const TRADE_CONFIRM_OTHER_INV1_ID = 3532; // tradeconfirm:otherinv1 - partner offer (<= 13 items)
|
||||
export const TRADE_CONFIRM_OTHER_INV2_ID = 3539; // tradeconfirm:otherinv2 - partner offer (> 13 items)
|
||||
export const TRADE_CONFIRM_STATUS_TEXT_ID = 3535; // tradeconfirm:com_91 - "Are you sure..."/"Waiting for other player."/"Other player has accepted."
|
||||
export const TRADE_CONFIRM_ACCEPT_ID = 3546; // tradeconfirm:accept - IF_BUTTON
|
||||
|
||||
// Interfaces for state data
|
||||
export interface SkillState {
|
||||
name: string;
|
||||
@@ -187,6 +204,35 @@ export interface BankItem {
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** An item slot inside a trade offer window. */
|
||||
export interface TradeItem {
|
||||
slot: number;
|
||||
id: number;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Player-to-player trade session state, read from the trademain/tradeconfirm
|
||||
* interfaces. Accept flags are parsed from the server-set status text — on
|
||||
* each screen at most one of myAccepted/partnerAccepted is ever true, because
|
||||
* the second accept advances (or completes) the trade. Any offer change
|
||||
* resets both accepts server-side.
|
||||
*/
|
||||
export interface TradeState {
|
||||
isOpen: boolean;
|
||||
/** 'offer' = first screen (offers editable), 'confirm' = final screen. */
|
||||
screen: 'offer' | 'confirm' | null;
|
||||
/** Partner display name, from "Trading With: <name>". */
|
||||
partner: string | null;
|
||||
myOffer: TradeItem[];
|
||||
theirOffer: TradeItem[];
|
||||
/** True when this client accepted and is waiting on the partner. */
|
||||
myAccepted: boolean;
|
||||
/** True when the partner accepted the current screen. */
|
||||
partnerAccepted: boolean;
|
||||
}
|
||||
|
||||
export interface BankState {
|
||||
isOpen: boolean;
|
||||
items: BankItem[];
|
||||
@@ -339,6 +385,7 @@ export interface BotState {
|
||||
menuActions: MenuAction[];
|
||||
shop: ShopState;
|
||||
bank: BankState;
|
||||
trade: TradeState;
|
||||
inGame: boolean;
|
||||
/** Recent combat events (damage, kills) - bounded to last ~50 ticks */
|
||||
combatEvents: CombatEvent[];
|
||||
|
||||
Reference in new issue
Block a user