From d868d3d5c800fe90ac3e646d87ec437e49d43b50 Mon Sep 17 00:00:00 2001 From: Joinkiee Date: Fri, 14 Aug 2026 14:03:31 +1000 Subject: [PATCH 1/2] feat(BankSeller): 1.0.3 - support F2P trade-restricted accounts - Detect the per-item trade-restriction notice in the sell offer setup and skip to the next inventory item without leaving the GE screen - List every offer at 50% of the actively traded price for instant fills - Sell each item's full stack in a single offer - Abort leftover unsold offers at the end, re-list them at 1gp, then collect and stop - Wait for pending offers and collect coins before stopping; final chatbox verdict when the bank is fully processed --- .../microbot/bankseller/BankSellerConfig.java | 41 +- .../microbot/bankseller/BankSellerPlugin.java | 102 +- .../microbot/bankseller/BankSellerScript.java | 1081 +++++++++++++++-- .../microbot/bankseller/docs/readme.md | 78 +- 4 files changed, 1075 insertions(+), 227 deletions(-) diff --git a/src/main/java/net/runelite/client/plugins/microbot/bankseller/BankSellerConfig.java b/src/main/java/net/runelite/client/plugins/microbot/bankseller/BankSellerConfig.java index cbe8b6aab8..29da2845c0 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/bankseller/BankSellerConfig.java +++ b/src/main/java/net/runelite/client/plugins/microbot/bankseller/BankSellerConfig.java @@ -1,19 +1,22 @@ -package net.runelite.client.plugins.microbot.bankseller; - -import net.runelite.client.config.Config; -import net.runelite.client.config.ConfigGroup; -import net.runelite.client.config.ConfigItem; - -@ConfigGroup("bankseller") -public interface BankSellerConfig extends Config { - @ConfigItem( - keyName = "instructions", - name = "Instructions", - description = "", - position = 0 - ) - default String instructions() { - return "1. Start near a bank at the Grand Exchange.\n" + - "2. The bot will withdraw tradeable items as notes and sell them."; - } -} \ No newline at end of file +package net.runelite.client.plugins.microbot.bankseller; + +import net.runelite.client.config.Config; +import net.runelite.client.config.ConfigGroup; +import net.runelite.client.config.ConfigItem; + +@ConfigGroup("bankseller") +public interface BankSellerConfig extends Config { + @ConfigItem( + keyName = "instructions", + name = "Instructions", + description = "", + position = 0 + ) + default String instructions() { + return "1. Start near a bank at the Grand Exchange.\n" + + "2. The bot banks first, withdraws all tradeable items as notes\n" + + "and sells each item's full stack in a single offer.\n" + + "3. Items the GE refuses (e.g. F2P trade-restricted items)\n" + + "are put back in the bank and skipped."; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/bankseller/BankSellerPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/bankseller/BankSellerPlugin.java index 547e846deb..3842766221 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/bankseller/BankSellerPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/bankseller/BankSellerPlugin.java @@ -1,51 +1,51 @@ -package net.runelite.client.plugins.microbot.bankseller; - -import com.google.inject.Provides; -import lombok.extern.slf4j.Slf4j; -import net.runelite.client.config.ConfigManager; -import net.runelite.client.plugins.Plugin; -import net.runelite.client.plugins.PluginDescriptor; -import net.runelite.client.plugins.microbot.Microbot; - -import javax.inject.Inject; -import java.awt.AWTException; -import net.runelite.client.plugins.microbot.PluginConstants; - -@PluginDescriptor( - name = PluginConstants.KSP + "Bank Seller", - description = "Withdraws tradeable bank items and sells them on the Grand Exchange", - tags = {"bank", "ge", "seller"}, - authors = {"KSP"}, - version = BankSellerPlugin.version, - minClientVersion = "1.9.8", - iconUrl = "https://chsami.github.io/Microbot-Hub/BankSellerPlugin/assets/bank.png", - cardUrl = "https://chsami.github.io/Microbot-Hub/BankSellerPlugin/assets/card.png", - enabledByDefault = PluginConstants.DEFAULT_ENABLED, - isExternal = PluginConstants.IS_EXTERNAL -) -@Slf4j -public class BankSellerPlugin extends Plugin { - - static final String version = "1.0.2"; - @Inject - private BankSellerConfig config; - - @Provides - BankSellerConfig provideConfig(ConfigManager configManager) { - return configManager.getConfig(BankSellerConfig.class); - } - - @Inject - private BankSellerScript bankSellerScript; - - @Override - protected void startUp() throws AWTException { - Microbot.pauseAllScripts.compareAndSet(true, false); - bankSellerScript.run(this); - } - - @Override - protected void shutDown() { - bankSellerScript.shutdown(); - } -} +package net.runelite.client.plugins.microbot.bankseller; + +import com.google.inject.Provides; +import lombok.extern.slf4j.Slf4j; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.plugins.microbot.Microbot; + +import javax.inject.Inject; +import java.awt.AWTException; +import net.runelite.client.plugins.microbot.PluginConstants; + +@PluginDescriptor( + name = PluginConstants.KSP + "Bank Seller", + description = "Withdraws tradeable bank items and sells them on the Grand Exchange", + tags = {"bank", "ge", "seller"}, + authors = {"KSP"}, + version = BankSellerPlugin.version, + minClientVersion = "1.9.8", + iconUrl = "https://chsami.github.io/Microbot-Hub/BankSellerPlugin/assets/bank.png", + cardUrl = "https://chsami.github.io/Microbot-Hub/BankSellerPlugin/assets/card.png", + enabledByDefault = PluginConstants.DEFAULT_ENABLED, + isExternal = PluginConstants.IS_EXTERNAL +) +@Slf4j +public class BankSellerPlugin extends Plugin { + + static final String version = "1.0.3"; + @Inject + private BankSellerConfig config; + + @Provides + BankSellerConfig provideConfig(ConfigManager configManager) { + return configManager.getConfig(BankSellerConfig.class); + } + + @Inject + private BankSellerScript bankSellerScript; + + @Override + protected void startUp() throws AWTException { + Microbot.pauseAllScripts.compareAndSet(true, false); + bankSellerScript.run(this); + } + + @Override + protected void shutDown() { + bankSellerScript.shutdown(); + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/bankseller/BankSellerScript.java b/src/main/java/net/runelite/client/plugins/microbot/bankseller/BankSellerScript.java index 616ff63dd4..44bf34de44 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/bankseller/BankSellerScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/bankseller/BankSellerScript.java @@ -1,121 +1,960 @@ -package net.runelite.client.plugins.microbot.bankseller; - -import net.runelite.client.plugins.microbot.Microbot; -import net.runelite.client.plugins.microbot.Script; -import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; -import net.runelite.client.plugins.microbot.util.grandexchange.GrandExchangeAction; -import net.runelite.client.plugins.microbot.util.grandexchange.GrandExchangeRequest; -import net.runelite.client.plugins.microbot.util.grandexchange.Rs2GrandExchange; -import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; -import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; - -import java.util.List; -import java.util.concurrent.TimeUnit; - -import static net.runelite.client.plugins.microbot.util.Global.sleepUntil; - -public class BankSellerScript extends Script { - - public boolean run(BankSellerPlugin plugin) { - Microbot.enableAutoRunOn = false; - mainScheduledFuture = scheduledExecutorService.scheduleWithFixedDelay(() -> { - try { - if (!Microbot.isLoggedIn()) return; - if (!super.run()) return; - - if (Rs2GrandExchange.hasSoldOffer()) { - if (Rs2GrandExchange.openExchange()) { - sleepUntil(Rs2GrandExchange::isOpen); - Rs2GrandExchange.collectAllToBank(); - sleepUntil(() -> !Rs2GrandExchange.hasSoldOffer()); - } - } - - if (!hasInventoryItems()) { - if (bankHasTradeableItems()) { - withdrawFromBank(); - } else { - Microbot.stopPlugin(plugin); - return; - } - } else { - sellInventory(); - } - } catch (Exception ex) { - Microbot.log(ex.getMessage()); - } - }, 0, 1000, TimeUnit.MILLISECONDS); - return true; - } - - private boolean bankHasTradeableItems() { - return Rs2Bank.bankItems().stream().anyMatch(Rs2ItemModel::isTradeable); - } - - private boolean hasInventoryItems() { - return Rs2Inventory.all(Rs2ItemModel::isTradeable).size() > 0; - } - - private void withdrawFromBank() { - if (!Rs2Bank.openBank()) { - return; - } - sleepUntil(Rs2Bank::isOpen); - Rs2Bank.depositAll(); - sleepUntil(Rs2Inventory::isEmpty); - Rs2Bank.setWithdrawAsNote(); - for (Rs2ItemModel item : Rs2Bank.bankItems()) { - if (Rs2Inventory.isFull()) { - break; - } - if (!item.isTradeable()) { - continue; - } - Rs2Bank.withdrawAll(item.getId()); - sleepUntil(() -> Rs2Inventory.hasItem(item.getName(), true)); - } - Rs2Bank.closeBank(); - } - - private void sellInventory() { - List items = Rs2Inventory.all(Rs2ItemModel::isTradeable); - if (items.isEmpty()) { - return; - } - - if (!Rs2GrandExchange.openExchange()) { - return; - } - sleepUntil(Rs2GrandExchange::isOpen); - - for (Rs2ItemModel item : items) { - while (Rs2GrandExchange.getAvailableSlotsCount() == 0) { - if (Rs2GrandExchange.hasSoldOffer()) { - Rs2GrandExchange.collectAllToBank(); - } - sleepUntil(() -> Rs2GrandExchange.getAvailableSlotsCount() > 0 - || Rs2GrandExchange.hasSoldOffer()); - } - if (Rs2GrandExchange.getAvailableSlotsCount() == 0) { - break; - } - int price = Rs2GrandExchange.getPrice(item.getId()); - if (price <= 0) { - price = 1; - } - GrandExchangeRequest request = GrandExchangeRequest.builder() - .action(GrandExchangeAction.SELL) - .itemName(item.getName()) - .quantity(item.getQuantity()) - .price(price) - .closeAfterCompletion(false) - .build(); - Rs2GrandExchange.processOffer(request); - sleepUntil(Rs2GrandExchange::isOpen); - sleepUntil(() -> !Rs2Inventory.hasItem(item.getName(), true)); - } - - Rs2GrandExchange.closeExchange(); - sleepUntil(() -> !Rs2GrandExchange.isOpen()); - } -} \ No newline at end of file +package net.runelite.client.plugins.microbot.bankseller; + +import net.runelite.api.ChatMessageType; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.gameval.VarClientID; +import net.runelite.api.widgets.ComponentID; +import net.runelite.api.widgets.Widget; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.Script; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.grandexchange.Rs2GrandExchange; +import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; +import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; + +import java.awt.event.KeyEvent; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import static net.runelite.client.plugins.microbot.util.Global.sleepUntil; + +public class BankSellerScript extends Script { + + /** Coins (995) and platinum tokens (13204) are never sold. */ + private static final Set IGNORED_ITEM_IDS = new HashSet<>(Arrays.asList(995, 13204)); + + private static final int SETUP_WAIT_TIMEOUT_MS = 5_000; + private static final int BANK_OPEN_TIMEOUT_MS = 10_000; + private static final int BANK_LOAD_GRACE_MS = 3_000; + private static final int EXCHANGE_OPEN_TIMEOUT_MS = 10_000; + private static final int CONFIRM_WAIT_TIMEOUT_MS = 10_000; + private static final int MAX_OFFER_ATTEMPTS = 2; + + /** An unsold leftover offer gets this long to fill before it is re-listed at 1gp. */ + private static final int LEFTOVER_OFFER_TIMEOUT_MS = 90_000; + + /** After the 1gp re-list the plugin stops anyway when nothing sold within this window. */ + private static final int LEFTOVER_GIVE_UP_MS = 60_000; + + /** + * Items the Grand Exchange refused to sell this session, e.g. items on the + * F2P new-account trade restriction list. They are put back in the bank + * and are not withdrawn again. + */ + private final Set unsellableItemIds = new HashSet<>(); + + private BankSellerPlugin plugin; + + /** Whether the GE offer setup opened for the current attempt (drives retry). */ + private boolean offerSetupSeen; + + /** Set when the current item's setup shows the red trade-restriction notice. */ + private boolean tradeRestrictionSeen; + private String tradeRestrictionText; + + /** + * Set once a bank scan finds nothing sellable and the inventory is empty. + * The main loop then stops opening the bank/GE every cycle and only + * watches the remaining offers (no mouse movement while waiting). + */ + private boolean bankDrained; + + /** When set (leftover-offer liquidation), every offer is placed at this price. */ + private Integer forcedSellPrice; + + /** Last time the drained watch saw an offer sell or finish. */ + private long lastOfferProgressAt; + + /** True once leftover offers have been aborted and re-listed at 1gp. */ + private boolean leftoverLiquidated; + + /** Session tallies driving the final chatbox verdict when nothing is left to sell. */ + private int sessionOffersPlaced; + private int sessionStacksRefused; + + public boolean run(BankSellerPlugin plugin) { + Microbot.enableAutoRunOn = false; + this.plugin = plugin; + unsellableItemIds.clear(); + offerSetupSeen = false; + tradeRestrictionSeen = false; + tradeRestrictionText = null; + bankDrained = false; + forcedSellPrice = null; + lastOfferProgressAt = System.currentTimeMillis(); + leftoverLiquidated = false; + sessionOffersPlaced = 0; + sessionStacksRefused = 0; + mainScheduledFuture = scheduledExecutorService.scheduleWithFixedDelay(() -> { + try { + if (!Microbot.isLoggedIn()) return; + if (!super.run()) return; + + collectSoldOffers(); + + if (bankDrained) { + // Bank and inventory are both drained of sellables - just watch + // the remaining offers. No bank/GE opening, no mouse movement. + if (Rs2GrandExchange.hasSoldOffer()) { + collectPendingOffers(); + lastOfferProgressAt = System.currentTimeMillis(); + } + if (Rs2GrandExchange.isAllSlotsEmpty()) { + announce(finalVerdictMessage()); + Microbot.stopPlugin(plugin); + return; + } + long watchIdleMs = System.currentTimeMillis() - lastOfferProgressAt; + if (!leftoverLiquidated && watchIdleMs > LEFTOVER_OFFER_TIMEOUT_MS) { + // An offer that will not fill at the instant-sell price gets + // one final attempt at 1gp so the run can actually finish + leftoverLiquidated = liquidateLeftoverOffers(); + // On failure retry in ~15s instead of waiting the full timeout again + lastOfferProgressAt = System.currentTimeMillis() + - (leftoverLiquidated ? 0 : LEFTOVER_OFFER_TIMEOUT_MS - 15_000); + } else if (leftoverLiquidated && watchIdleMs > LEFTOVER_GIVE_UP_MS) { + announce("1gp leftover offer(s) are still open - stopping anyway; " + + "collect them at the Grand Exchange later"); + Microbot.stopPlugin(plugin); + } + return; + } + + // Always bank first: deposit everything, then pull out every sellable item as notes + boolean bankHasSellables = bankAndWithdrawAll(); + + if (!hasSellableInventoryItems()) { + if (bankHasSellables) { + // Withdrawing failed (e.g. bank unreachable) - retry next cycle + return; + } + // The bank scan found nothing sellable - switch to the idle + // drained watch from the next cycle on + bankDrained = true; + lastOfferProgressAt = System.currentTimeMillis(); + return; + } + + sellInventory(); + } catch (Exception ex) { + Microbot.log(ex.getMessage()); + } + }, 0, 1000, TimeUnit.MILLISECONDS); + return true; + } + + private boolean isSellable(Rs2ItemModel item) { + return item.isTradeable() + && !IGNORED_ITEM_IDS.contains(item.getUnNotedId()) + && !unsellableItemIds.contains(item.getUnNotedId()); + } + + private boolean hasSellableInventoryItems() { + return Rs2Inventory.all(this::isSellable).size() > 0; + } + + private void collectSoldOffers() { + if (!Rs2GrandExchange.hasSoldOffer()) { + return; + } + if (!Rs2GrandExchange.openExchange()) { + return; + } + if (!sleepUntil(Rs2GrandExchange::isOpen, EXCHANGE_OPEN_TIMEOUT_MS)) { + return; + } + Rs2GrandExchange.collectAllToBank(); + sleepUntil(() -> !Rs2GrandExchange.hasSoldOffer()); + Rs2GrandExchange.closeExchange(); + sleepUntil(() -> !Rs2GrandExchange.isOpen()); + } + + /** + * Collects finished offers and checks if any offers are still pending. + * Only a positively opened exchange counts - a failed open means "unknown", + * never "done". + * + * @return true when every Grand Exchange slot is empty and the plugin can stop + */ + private boolean collectPendingOffers() { + if (!Rs2GrandExchange.openExchange()) { + return false; + } + if (!sleepUntil(Rs2GrandExchange::isOpen, EXCHANGE_OPEN_TIMEOUT_MS)) { + return false; + } + Rs2GrandExchange.collectAllToBank(); + sleepUntil(() -> !Rs2GrandExchange.hasSoldOffer()); + boolean allSlotsEmpty = Rs2GrandExchange.isAllSlotsEmpty(); + Rs2GrandExchange.closeExchange(); + sleepUntil(() -> !Rs2GrandExchange.isOpen()); + return allSlotsEmpty; + } + + /** + * Endgame sweep for offers that never filled: aborts them, takes the items + * back into the inventory and re-lists each stack at 1gp so the run finishes. + * + * @return true when the leftovers were re-listed (or nothing came back) + */ + private boolean liquidateLeftoverOffers() { + announce("Leftover offer(s) not selling - re-listing at 1gp"); + if (!Rs2GrandExchange.openExchange() + || !sleepUntil(Rs2GrandExchange::isOpen, EXCHANGE_OPEN_TIMEOUT_MS)) { + return false; + } + // abortAllOffers(false) aborts every open offer and collects to the inventory + Rs2GrandExchange.abortAllOffers(false); + sleepUntil(() -> !Rs2GrandExchange.hasOffer(), SETUP_WAIT_TIMEOUT_MS); + sleep(600, 1000); + if (!hasSellableInventoryItems()) { + return true; + } + forcedSellPrice = 1; + try { + sellInventory(); + } finally { + forcedSellPrice = null; + } + return true; + } + + /** + * Opens the bank, deposits the whole inventory and withdraws every sellable + * item (as notes) until the inventory is full. + * + * @return true when the bank still holds sellable items after withdrawing, + * or when the bank could not be opened/read (so the cycle retries + * instead of wrongly concluding there is nothing left to sell) + */ + private boolean bankAndWithdrawAll() { + if (!Rs2Bank.openBank()) { + return true; + } + if (!sleepUntil(Rs2Bank::isOpen, BANK_OPEN_TIMEOUT_MS)) { + // Bank never opened (e.g. bank pin) - retry next cycle + return true; + } + + Rs2Bank.depositAll(); + sleepUntil(Rs2Inventory::isEmpty); + + Rs2Bank.setWithdrawAsNote(); + + // Give the bank contents a moment to load before trusting an empty scan + sleepUntil(() -> !Rs2Bank.bankItems().isEmpty(), BANK_LOAD_GRACE_MS); + + boolean bankHasSellables = false; + for (Rs2ItemModel item : Rs2Bank.bankItems()) { + if (Thread.currentThread().isInterrupted()) { + // Plugin was stopped mid-pass - stop clicking items + break; + } + if (!isSellable(item)) { + continue; + } + bankHasSellables = true; + if (Rs2Inventory.isFull()) { + break; + } + Rs2Bank.withdrawAll(item.getId()); + boolean got = sleepUntil(() -> Rs2Inventory.hasItem(item.getName(), true)); + if (!got) { + // Items without a noted form ("This item cannot be withdrawn as + // a note") still need to be sold - withdraw them unnoted + Rs2Bank.setWithdrawAsItem(); + Rs2Bank.withdrawAll(item.getId()); + sleepUntil(() -> Rs2Inventory.hasItem(item.getName(), true)); + Rs2Bank.setWithdrawAsNote(); + } + } + + Rs2Bank.closeBank(); + sleepUntil(() -> !Rs2Bank.isOpen()); + return bankHasSellables; + } + + private void sellInventory() { + // Group by item so the whole stack of an item is sold in a single offer + Map> itemsById = Rs2Inventory.all(this::isSellable).stream() + .collect(Collectors.groupingBy(Rs2ItemModel::getUnNotedId, LinkedHashMap::new, Collectors.toList())); + if (itemsById.isEmpty()) { + return; + } + + if (!Rs2GrandExchange.openExchange()) { + return; + } + if (!sleepUntil(Rs2GrandExchange::isOpen, EXCHANGE_OPEN_TIMEOUT_MS)) { + return; + } + + List failedItems = new ArrayList<>(); + + for (Map.Entry> entry : itemsById.entrySet()) { + if (Thread.currentThread().isInterrupted()) { + // Plugin was stopped mid-pass - a set interrupt flag makes every + // sleepUntil return instantly, which would mass-flag good items + break; + } + Rs2ItemModel item = entry.getValue().get(0); + int quantity = entry.getValue().stream().mapToInt(Rs2ItemModel::getQuantity).sum(); + + // A failed offer flow can leave the GE window closed (ESC abort) - + // clicking "Offer" then hits the plain inventory and uses/drops items + if (!Rs2GrandExchange.isOpen()) { + if (!Rs2GrandExchange.openExchange() + || !sleepUntil(Rs2GrandExchange::isOpen, EXCHANGE_OPEN_TIMEOUT_MS)) { + break; + } + } + + waitForAvailableSlot(); + if (Rs2GrandExchange.getAvailableSlotsCount() == 0) { + break; + } + + int guidePrice = Rs2GrandExchange.getPrice(item.getUnNotedId()); + // Instant-sell pricing: half the active price so the offer fills + // immediately (the endgame leftover sweep forces 1gp instead) + int price = forcedSellPrice != null ? forcedSellPrice : Math.max(1, guidePrice / 2); + + boolean offerPlaced = placeSellOffer(item, quantity, price); + int remaining = inventoryTradeQuantity(item.getId()); + + if (!offerPlaced || remaining > 0) { + // The GE refused the item (e.g. F2P trade-restricted) - put it back in the bank + unsellableItemIds.add(item.getUnNotedId()); + failedItems.addAll(entry.getValue()); + sessionStacksRefused++; + } else { + sessionOffersPlaced++; + } + } + + // A refused last item leaves its offer setup open - back out of it + // before closing the GE so the window state is clean for the bank + if (isSetupVisible()) { + abortOfferSetup(); + sleepUntil(() -> !isSetupVisible(), SETUP_WAIT_TIMEOUT_MS); + } + + if (Rs2GrandExchange.isOpen()) { + Rs2GrandExchange.closeExchange(); + sleepUntil(() -> !Rs2GrandExchange.isOpen()); + } + + depositUnsellableItems(failedItems); + } + + /** + * Places a single sell offer for the full stack of an item using the same + * Grand Exchange widget flow as GodsFlipper: open the setup with "Offer", + * enter the price through the chatbox input, set the quantity with the + * "All" button and confirm. + * + * @return true when the offer was confirmed and the items left the inventory + */ + private boolean placeSellOffer(Rs2ItemModel item, int quantity, int price) { + for (int attempt = 1; attempt <= MAX_OFFER_ATTEMPTS; attempt++) { + offerSetupSeen = false; + tradeRestrictionSeen = false; + try { + boolean placed = doPlaceSellOffer(item, quantity, price); + // A setup that never opened, or a trade-restriction notice, is + // a genuine refusal - retrying would just waste another timeout + if (placed || tradeRestrictionSeen || !offerSetupSeen || attempt == MAX_OFFER_ATTEMPTS) { + return placed; + } + abortOfferSetup(); + sleepUntil(() -> !isSetupVisible(), SETUP_WAIT_TIMEOUT_MS); + if (!Rs2GrandExchange.isOpen()) { + if (!Rs2GrandExchange.openExchange() + || !sleepUntil(Rs2GrandExchange::isOpen, EXCHANGE_OPEN_TIMEOUT_MS)) { + return false; + } + } + } catch (RuntimeException ex) { + // Transient UI errors (e.g. a widget without bounds on a slow client) + // must not kill the whole sell pass - treat this item as refused + Microbot.log("Sell offer failed for " + item.getName() + ": " + ex.getMessage()); + abortOfferSetup(); + return false; + } + } + return false; + } + + private boolean doPlaceSellOffer(Rs2ItemModel item, int quantity, int price) { + // A setup left open by a trade-restricted item is reused instead of + // backing out: clicking 'Offer' on the next item switches the setup + // straight over to it, so refused items never close the GE flow + if (!Rs2Inventory.interact(item.getId(), "Offer")) { + return false; + } + + // A reused setup stays visible the whole time - the wait must hold + // out until the setup actually shows THIS item, not the previous one + if (!sleepUntil(() -> isSetupVisible() && sameTradeItem(resolveSetupItemId(item.getId()), item.getId()), + SETUP_WAIT_TIMEOUT_MS)) { + return false; + } + offerSetupSeen = true; + + // A red "restricted for trading" notice in the setup means Confirm is + // dead for this item - fail fast, no retry. The setup is LEFT OPEN so + // the next item's 'Offer' click switches straight over to it. + String restrictionNotice = findTradeRestrictionNotice(); + if (restrictionNotice != null) { + tradeRestrictionSeen = true; + tradeRestrictionText = restrictionNotice; + return false; + } + + if (!enterPrice(price)) { + abortOfferSetup(); + return false; + } + + if (!enterFullQuantity(quantity)) { + abortOfferSetup(); + return false; + } + + Widget confirmButton = findConfirmButton(); + if (!clickWidget(confirmButton)) { + abortOfferSetup(); + return false; + } + + // The GE warns when the price is far from the guide price (often, at + // instant-sell prices) - keep accepting it until the setup closes + long confirmDeadline = System.currentTimeMillis() + CONFIRM_WAIT_TIMEOUT_MS; + while (System.currentTimeMillis() < confirmDeadline) { + if (Thread.currentThread().isInterrupted()) { + return false; + } + boolean warningUp = isGePriceWarningVisible(); + if (!isSetupVisible() && !warningUp) { + return true; + } + if (warningUp) { + acceptGePriceWarning(); + } + sleep(200, 350); + } + + // The offer can go through despite a lingering setup - trust the inventory + return inventoryTradeQuantity(item.getId()) == 0; + } + + /** + * The GE price warning text varies by game version and side (buy/sell), so + * match every known variant plus a generic chatbox "Select an Option" menu + * (which is exactly what the Yes/No warning renders as). + */ + private boolean isGePriceWarningVisible() { + return Rs2Widget.hasWidget("Your offer is much") + || Rs2Widget.hasWidget("much lower than") + || Rs2Widget.hasWidget("much higher than") + || Rs2Widget.hasWidget("Select an Option"); + } + + private void acceptGePriceWarning() { + if (!Rs2Widget.clickWidget("Yes")) { + // Chatbox option menus also accept number keys: 1 = first option (Yes) + Rs2Keyboard.keyPress(KeyEvent.VK_1); + } + } + + /** + * Types the price into the GE "Enter price" chatbox input. + */ + private boolean enterPrice(int price) { + if (!clickWidget(findCustomPriceButton())) { + return false; + } + if (!sleepUntil(this::isChatboxInputVisible, SETUP_WAIT_TIMEOUT_MS)) { + return false; + } + if (!setChatboxInputValue(price)) { + return false; + } + sleep(250, 400); + Rs2Keyboard.keyPress(KeyEvent.VK_ENTER); + return sleepUntil(() -> !isChatboxInputVisible(), SETUP_WAIT_TIMEOUT_MS); + } + + /** + * Sets the offer quantity to the whole stack with the "All" button, + * falling back to the custom quantity chatbox input. + */ + private boolean enterFullQuantity(int quantity) { + Widget allButton = findAllQuantityButton(); + if (allButton != null && clickWidget(allButton)) { + sleep(300, 600); + return true; + } + + if (!clickWidget(findQuantityButton())) { + return false; + } + if (!sleepUntil(this::isChatboxInputVisible, SETUP_WAIT_TIMEOUT_MS)) { + return false; + } + if (!setChatboxInputValue(quantity)) { + return false; + } + sleep(250, 400); + Rs2Keyboard.keyPress(KeyEvent.VK_ENTER); + return sleepUntil(() -> !isChatboxInputVisible(), SETUP_WAIT_TIMEOUT_MS); + } + + /** + * Backs out of a half-finished offer setup so the next item starts clean. + * Uses the setup's Back arrow (returning to the GE overview) - a plain + * ESC would close the whole Grand Exchange window and force a reopen. + */ + private void abortOfferSetup() { + if (isChatboxInputVisible()) { + Rs2Keyboard.keyPress(KeyEvent.VK_ESCAPE); + sleep(300, 600); + } + if (isSetupVisible()) { + if (clickWidget(findBackButton()) && sleepUntil(() -> !isSetupVisible(), SETUP_WAIT_TIMEOUT_MS)) { + return; + } + Rs2Keyboard.keyPress(KeyEvent.VK_ESCAPE); + sleepUntil(() -> !isSetupVisible(), SETUP_WAIT_TIMEOUT_MS); + } + } + + private Widget findBackButton() { + return clientValue(() -> { + Widget setup = Microbot.getClient().getWidget(InterfaceID.GeOffers.SETUP); + if (!isVisible(setup)) { + return null; + } + // The back arrow is a sibling of the setup panel, not inside it + Widget parent = setup.getParent(); + Widget match = findWidgetRecursive(parent, this::isBackWidget, 0); + return match != null ? match : findWidgetRecursive(setup, this::isBackWidget, 0); + }, null); + } + + private boolean isBackWidget(Widget widget) { + if (widget == null) { + return false; + } + String[] actions = widget.getActions(); + if (actions == null) { + return false; + } + for (String action : actions) { + if (normalize(action).equals("back")) { + return true; + } + } + return false; + } + + private void waitForAvailableSlot() { + while (Rs2GrandExchange.getAvailableSlotsCount() == 0) { + if (Rs2GrandExchange.hasSoldOffer()) { + Rs2GrandExchange.collectAllToBank(); + } + sleepUntil(() -> Rs2GrandExchange.getAvailableSlotsCount() > 0 + || Rs2GrandExchange.hasSoldOffer()); + } + } + + private void depositUnsellableItems(List failedItems) { + if (failedItems.isEmpty()) { + return; + } + if (!Rs2Bank.openBank()) { + return; + } + sleepUntil(Rs2Bank::isOpen); + for (Rs2ItemModel item : failedItems) { + Rs2Bank.depositAll(item.getId()); + sleepUntil(() -> !Rs2Inventory.hasItem(item.getId())); + } + Rs2Bank.closeBank(); + sleepUntil(() -> !Rs2Bank.isOpen()); + } + + // ------------------------------------------------------------------ + // Grand Exchange widget helpers (mirrors GodsFlipper's sell setup flow) + // ------------------------------------------------------------------ + + private int inventoryTradeQuantity(int itemId) { + int quantity = 0; + for (Rs2ItemModel inventoryItem : Rs2Inventory.all()) { + if (inventoryItem != null && sameTradeItem(inventoryItem.getId(), itemId)) { + quantity += Math.max(0, inventoryItem.getQuantity()); + } + } + return quantity; + } + + private int canonicalTradeItemId(int itemId) { + if (itemId <= 0) { + return itemId; + } + int unnotedId = Rs2ItemModel.getUnNotedId(itemId); + return unnotedId > 0 ? unnotedId : itemId; + } + + private boolean sameTradeItem(int leftItemId, int rightItemId) { + return leftItemId > 0 + && rightItemId > 0 + && canonicalTradeItemId(leftItemId) == canonicalTradeItemId(rightItemId); + } + + private boolean isSetupVisible() { + return clientValue(() -> { + Widget setup = Microbot.getClient().getWidget(InterfaceID.GeOffers.SETUP); + return isVisible(setup); + }, false); + } + + private boolean isChatboxInputVisible() { + return clientValue(() -> { + Widget input = Microbot.getClient().getWidget(ComponentID.CHATBOX_FULL_INPUT); + return isVisible(input); + }, false); + } + + private boolean setChatboxInputValue(long value) { + if (value <= 0) { + return false; + } + return clientValue(() -> { + Widget input = Microbot.getClient().getWidget(ComponentID.CHATBOX_FULL_INPUT); + if (!isVisible(input)) { + return false; + } + input.setText(value + "*"); + Microbot.getClient().setVarcStrValue(VarClientID.MESLAYERINPUT, String.valueOf(value)); + return true; + }, false); + } + + /** + * Returns the item id shown in the open offer setup when it matches the + * expected item (noted or unnoted), otherwise -1. + */ + private int resolveSetupItemId(int expectedItemId) { + return clientValue(() -> { + Widget setup = Microbot.getClient().getWidget(InterfaceID.GeOffers.SETUP); + if (!isVisible(setup)) { + return -1; + } + return containsItemId(setup, expectedItemId, 0) ? expectedItemId : -1; + }, -1); + } + + /** + * Returns the red trade-restriction notice shown inside the offer setup on + * restricted accounts ("Your account will be restricted for trading until + * ..."), or null when the setup is clear. + */ + private String findTradeRestrictionNotice() { + return clientValue(() -> { + Widget setup = Microbot.getClient().getWidget(InterfaceID.GeOffers.SETUP); + Widget match = findWidgetRecursive(setup, widget -> { + String value = normalize(widget == null ? null : widget.getText()); + return value.contains("restricted for trading") || value.contains("restrictions will lift"); + }, 0); + return match == null ? null : match.getText().replaceAll("<[^>]*>", "").trim(); + }, null); + } + + private boolean containsItemId(Widget root, int expectedItemId, int depth) { + if (root == null || expectedItemId <= 0 || depth > 14) { + return false; + } + if (root.getItemId() > 0 && sameTradeItem(root.getItemId(), expectedItemId)) { + return true; + } + Widget[] dynamicChildren = root.getDynamicChildren(); + if (dynamicChildren != null) { + for (Widget child : dynamicChildren) { + if (containsItemId(child, expectedItemId, depth + 1)) { + return true; + } + } + } + Widget[] staticChildren = root.getStaticChildren(); + if (staticChildren != null) { + for (Widget child : staticChildren) { + if (containsItemId(child, expectedItemId, depth + 1)) { + return true; + } + } + } + return false; + } + + private Widget findCustomPriceButton() { + return clientValue(() -> { + Widget setup = Microbot.getClient().getWidget(InterfaceID.GeOffers.SETUP); + return findWidgetRecursive(setup, this::isCustomPriceWidget, 0); + }, null); + } + + private Widget findQuantityButton() { + return clientValue(() -> { + Widget offerContainer = Microbot.getClient().getWidget(ComponentID.GRAND_EXCHANGE_OFFER_CONTAINER); + if (isVisible(offerContainer)) { + Widget exactButton = offerContainer.getChild(7); + if (isClickable(exactButton)) { + return exactButton; + } + } + Widget setup = Microbot.getClient().getWidget(InterfaceID.GeOffers.SETUP); + return findWidgetRecursive(setup, this::isQuantityWidget, 0); + }, null); + } + + private Widget findAllQuantityButton() { + return clientValue(() -> { + Widget offerContainer = Microbot.getClient().getWidget(ComponentID.GRAND_EXCHANGE_OFFER_CONTAINER); + if (isVisible(offerContainer)) { + Widget exactButton = offerContainer.getChild(50); + if (isClickable(exactButton) && isAllQuantityWidget(exactButton)) { + return exactButton; + } + Widget legacyButton = offerContainer.getChild(6); + if (isClickable(legacyButton) && isAllQuantityWidget(legacyButton)) { + return legacyButton; + } + return findWidgetRecursive(offerContainer, this::isAllQuantityWidget, 0); + } + return null; + }, null); + } + + private Widget findConfirmButton() { + return clientValue(() -> { + Widget setup = Microbot.getClient().getWidget(InterfaceID.GeOffers.SETUP); + return findWidgetRecursive(setup, this::isConfirmWidget, 0); + }, null); + } + + private Widget findWidgetRecursive(Widget root, Predicate predicate, int depth) { + if (!isVisible(root) || predicate == null || depth > 14) { + return null; + } + if (predicate.test(root) && root.getBounds() != null) { + return root; + } + Widget[] dynamicChildren = root.getDynamicChildren(); + if (dynamicChildren != null) { + for (Widget child : dynamicChildren) { + Widget match = findWidgetRecursive(child, predicate, depth + 1); + if (match != null) { + return match; + } + } + } + Widget[] staticChildren = root.getStaticChildren(); + if (staticChildren != null) { + for (Widget child : staticChildren) { + Widget match = findWidgetRecursive(child, predicate, depth + 1); + if (match != null) { + return match; + } + } + } + return null; + } + + private boolean isCustomPriceWidget(Widget widget) { + if (widget == null) { + return false; + } + String[] actions = widget.getActions(); + if (actions == null) { + return false; + } + for (String action : actions) { + String value = normalize(action); + if (value.equals("enter price") + || value.contains("custom price") + || value.equals("set price")) { + return true; + } + } + return false; + } + + private boolean isQuantityWidget(Widget widget) { + if (widget == null) { + return false; + } + String text = normalize(widget.getText()); + String[] actions = widget.getActions(); + if (actions != null) { + for (String action : actions) { + String value = normalize(action); + if (value.equals("enter quantity") + || value.equals("set quantity") + || value.contains("custom quantity")) { + return true; + } + } + } + return text.contains("quantity") && hasAnyAction(widget); + } + + private boolean isAllQuantityWidget(Widget widget) { + if (widget == null) { + return false; + } + if ("all".equals(normalize(widget.getText()))) { + return true; + } + if (!hasAnyAction(widget)) { + return false; + } + String[] actions = widget.getActions(); + if (actions != null) { + for (String action : actions) { + String value = normalize(action); + if ("all".equals(value) + || value.contains("set all") + || (value.contains("all") && value.contains("quantity"))) { + return true; + } + } + } + return false; + } + + private boolean isConfirmWidget(Widget widget) { + if (widget == null) { + return false; + } + String[] actions = widget.getActions(); + if (actions != null) { + for (String action : actions) { + if (normalize(action).contains("confirm")) { + return true; + } + } + } + return normalize(widget.getText()).contains("confirm") && hasAnyAction(widget); + } + + private boolean clickWidget(Widget widget) { + // Widget state (isHidden etc.) asserts the client thread on newer + // clients, so every check must happen inside clientValue - a plain + // null check is the only thing safe to do on the script thread + if (widget == null) { + return false; + } + try { + return clientValue(() -> { + if (!isClickable(widget)) { + return false; + } + return Rs2Widget.clickWidget(widget); + }, false); + } catch (RuntimeException ex) { + return false; + } + } + + private boolean isClickable(Widget widget) { + return isVisible(widget) && widget.getBounds() != null; + } + + private boolean isVisible(Widget widget) { + return widget != null && !widget.isHidden(); + } + + private boolean hasAnyAction(Widget widget) { + String[] actions = widget == null ? null : widget.getActions(); + if (actions == null) { + return false; + } + for (String action : actions) { + if (action != null && !action.trim().isEmpty()) { + return true; + } + } + return false; + } + + private String normalize(String value) { + if (value == null) { + return ""; + } + return value + .replaceAll("<[^>]*>", "") + .trim() + .toLowerCase(Locale.ROOT); + } + + // ------------------------------------------------------------------ + // User-facing chatbox messages (final verdict, 1gp leftover sweep) + // ------------------------------------------------------------------ + + /** + * Posts a single user-facing line to the in-game chatbox and the client log. + */ + private void announce(String message) { + String line = "[BankSeller] " + message; + Microbot.log(line); + clientValue(() -> { + Microbot.getClient().addChatMessage(ChatMessageType.GAMEMESSAGE, "", line, null, false); + return true; + }, false); + } + + /** + * The single chatbox verdict when the bank is fully processed: + * an error when the GE refused everything, a summary otherwise. + */ + private String finalVerdictMessage() { + if (sessionStacksRefused > 0 && sessionOffersPlaced == 0) { + String message = "ERROR: the Grand Exchange refused every item (" + sessionStacksRefused + + " stack(s)) - nothing can be sold. Stopping."; + if (tradeRestrictionText != null) { + message += " " + tradeRestrictionText; + } + return message; + } + if (sessionStacksRefused > 0) { + return "Nothing left to sell - " + sessionStacksRefused + + " stack(s) were refused by the GE. Stopping."; + } + return "Nothing left to sell and all Grand Exchange slots are empty - stopping"; + } + + private T clientValue(Supplier supplier, T fallback) { + if (supplier == null) { + return fallback; + } + try { + T value = Microbot.getClientThread().invoke(supplier); + return value == null ? fallback : value; + } catch (RuntimeException exception) { + return fallback; + } + } +} diff --git a/src/main/resources/net/runelite/client/plugins/microbot/bankseller/docs/readme.md b/src/main/resources/net/runelite/client/plugins/microbot/bankseller/docs/readme.md index 1bd5134592..56c8a1e75e 100644 --- a/src/main/resources/net/runelite/client/plugins/microbot/bankseller/docs/readme.md +++ b/src/main/resources/net/runelite/client/plugins/microbot/bankseller/docs/readme.md @@ -1,36 +1,42 @@ -# Bank Seller Plugin - -Will obliterate any tradeable item from your bank - In other worlds will sell any tradeable item on the Grand Exchange - -## Features - -- **Automates Withdrawal of items from the bank and sells them on the Grand Exchange** - -## How It Works - -The plugin will loop Withdrawing and Selling items till neither the bank & Inventory contain sellable items. - - -## Usage - -1. **Start near a bank at the Grand Exchange.** -2. **Ensure that you already entered your bank ping or use QoL** -2. **Ensure that you have open GE Slots** - - -## Technical Details - -- **Plugin Version**: 1.0.2 -- **Author**: KSP -- **Minimum Client Version**: 1.9.6 -- **Dependencies**: N/A -- **Compatibility**: RuneLite with Microbot integration - - -## Support - -For issues, questions, or feature requests, please refer to the topic creaded on the [Microbot discord](https://discord.com/channels/1087718903985221642/1405996818323738644). - ---- - -*This plugin automates Withdrawal of items in noted form and Selling on the Grand Exchange.* \ No newline at end of file +# Bank Seller Plugin + +Will obliterate any tradeable item from your bank - In other worlds will sell any tradeable item on the Grand Exchange + +## Features + +- **Banks first**: deposits your whole inventory, then withdraws every tradeable item from the bank as notes +- **Sells full stacks**: the entire quantity of an item is sold in a single Grand Exchange offer +- **Works on F2P trade-restricted accounts**: items the Grand Exchange refuses to sell (e.g. trade-restricted items on new F2P accounts) are detected instantly from the offer screen and skipped - the plugin clicks straight through to the next item without ever closing the GE window, puts refused items back in the bank and carries on +- **Instant-sell pricing**: every offer is listed at 50% of the actively traded price so it fills immediately +- **Leftover-offer liquidation**: if an offer still has not sold at the end, it is aborted and re-listed at 1gp before the plugin finishes and disables itself +- **Coins and platinum tokens are never sold** +- **Waits for pending offers to sell and collects the coins before stopping** + +## How It Works + +The plugin will loop Withdrawing and Selling items till neither the bank & Inventory contain sellable items. + + +## Usage + +1. **Start near a bank at the Grand Exchange.** +2. **Ensure that you already entered your bank ping or use QoL** +2. **Ensure that you have open GE Slots** + + +## Technical Details + +- **Plugin Version**: 1.0.3 +- **Author**: KSP +- **Minimum Client Version**: 1.9.8 +- **Dependencies**: N/A +- **Compatibility**: RuneLite with Microbot integration + + +## Support + +For issues, questions, or feature requests, please refer to the topic creaded on the [Microbot discord](https://discord.com/channels/1087718903985221642/1405996818323738644). + +--- + +*This plugin automates Withdrawal of items in noted form and Selling on the Grand Exchange.* From c8f5926180775c4a138705fbf6daff1ccbcb60da Mon Sep 17 00:00:00 2001 From: Joinkiee Date: Sat, 15 Aug 2026 07:16:54 +1000 Subject: [PATCH 2/2] Isolate liquidation from foreign offers, retry transient offer-setup failures Address review on #528: - liquidateLeftoverOffers() no longer calls abortAllOffers(). Slots occupied when the plugin starts are snapshotted as foreign, and the liquidation now only aborts, collects and re-lists slots that hold an item this run actually listed. Aborts are slot-targeted (no collect-all), collection is per-slot to the inventory, and the 1gp re-list pass is filtered to owned items, so pre-existing or other plugins' offers can no longer be aborted or have their contents sold at 1gp. Completed unrelated offers are likewise never collected into the liquidation inventory. - placeSellOffer() now distinguishes a genuine refusal from a transient UI failure. Only the explicit trade-restriction notice marks an item unsellable. A setup that never opens or a widget timeout is retried and, after repeated failures, the item is just parked for the session instead of populating unsellableItemIds. --- .../microbot/bankseller/BankSellerScript.java | 379 +++++++++++++++--- 1 file changed, 317 insertions(+), 62 deletions(-) diff --git a/src/main/java/net/runelite/client/plugins/microbot/bankseller/BankSellerScript.java b/src/main/java/net/runelite/client/plugins/microbot/bankseller/BankSellerScript.java index 44bf34de44..857df41fcd 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/bankseller/BankSellerScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/bankseller/BankSellerScript.java @@ -1,6 +1,9 @@ package net.runelite.client.plugins.microbot.bankseller; import net.runelite.api.ChatMessageType; +import net.runelite.api.GrandExchangeOffer; +import net.runelite.api.GrandExchangeOfferState; +import net.runelite.api.MenuAction; import net.runelite.api.gameval.InterfaceID; import net.runelite.api.gameval.VarClientID; import net.runelite.api.widgets.ComponentID; @@ -8,15 +11,21 @@ import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.Script; import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.grandexchange.GrandExchangeSlots; import net.runelite.client.plugins.microbot.util.grandexchange.Rs2GrandExchange; +import net.runelite.client.plugins.microbot.util.grandexchange.models.GrandExchangeOfferDetails; import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard; +import net.runelite.client.plugins.microbot.util.menu.NewMenuEntry; +import net.runelite.client.plugins.microbot.util.misc.Rs2UiHelper; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; +import java.awt.Rectangle; import java.awt.event.KeyEvent; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; @@ -35,6 +44,10 @@ public class BankSellerScript extends Script { /** Coins (995) and platinum tokens (13204) are never sold. */ private static final Set IGNORED_ITEM_IDS = new HashSet<>(Arrays.asList(995, 13204)); + /** GE overview slot widgets: interface 465, children 7-14 (mirrors GrandExchangeWidget.getSlot). */ + private static final int GE_WIDGET_GROUP = 465; + private static final int GE_FIRST_SLOT_CHILD = 7; + private static final int SETUP_WAIT_TIMEOUT_MS = 5_000; private static final int BANK_OPEN_TIMEOUT_MS = 10_000; private static final int BANK_LOAD_GRACE_MS = 3_000; @@ -48,20 +61,63 @@ public class BankSellerScript extends Script { /** After the 1gp re-list the plugin stops anyway when nothing sold within this window. */ private static final int LEFTOVER_GIVE_UP_MS = 60_000; + /** A liquidation pass that aborts nothing gets this many retries before giving up. */ + private static final int MAX_LIQUIDATION_ATTEMPTS = 3; + + /** + * An item whose offer flow keeps failing on transient UI errors (the setup + * never opens, a widget times out, ...) is retried this many times before + * it is parked for the rest of the session so the run can still finish. + */ + private static final int MAX_TRANSIENT_FAILURES = 3; + + /** How a single offer placement ended. */ + private enum OfferResult { + /** Offer confirmed, the items left the inventory. */ + PLACED, + /** The setup showed the red trade-restriction notice - a definitive refusal. */ + RESTRICTED, + /** Transient UI failure (setup never opened, a timeout, an exception) - safe to retry. */ + FAILED + } + /** * Items the Grand Exchange refused to sell this session, e.g. items on the * F2P new-account trade restriction list. They are put back in the bank - * and are not withdrawn again. + * and are not withdrawn again. Only the explicit trade-restriction notice + * lands an item here - transient UI failures never do. */ private final Set unsellableItemIds = new HashSet<>(); - private BankSellerPlugin plugin; + /** + * Items whose offer flow failed on transient UI errors too many times in a + * row. Parked for the session (left in the bank) but NOT counted as + * GE refusals - unlike {@link #unsellableItemIds} they may sell fine on a + * later run. + */ + private final Set skippedItemIds = new HashSet<>(); + + /** Consecutive transient offer-flow failures per item, reset on any success. */ + private final Map transientFailureCounts = new HashMap<>(); - /** Whether the GE offer setup opened for the current attempt (drives retry). */ - private boolean offerSetupSeen; + /** + * Unnoted ids of every item this run successfully placed an offer for. + * Drives the endgame liquidation: only slots holding one of these items + * are aborted/collected/re-listed, so pre-existing offers (or offers from + * other plugins) are never touched. + */ + private final Set ownedOfferItemIds = new HashSet<>(); + + /** + * GE slots that were already occupied when the plugin started. Their + * offers are foreign to this run and are never aborted or collected, even + * when they happen to be for an item this run also listed. + */ + private final Set foreignSlotOrdinals = new HashSet<>(); + + private BankSellerPlugin plugin; /** Set when the current item's setup shows the red trade-restriction notice. */ - private boolean tradeRestrictionSeen; private String tradeRestrictionText; /** @@ -74,12 +130,18 @@ public class BankSellerScript extends Script { /** When set (leftover-offer liquidation), every offer is placed at this price. */ private Integer forcedSellPrice; + /** True while the liquidation pass re-lists items - restricts the sell pass to owned items. */ + private boolean liquidatingOwned; + /** Last time the drained watch saw an offer sell or finish. */ private long lastOfferProgressAt; /** True once leftover offers have been aborted and re-listed at 1gp. */ private boolean leftoverLiquidated; + /** Consecutive liquidation passes that aborted nothing. */ + private int liquidationAttempts; + /** Session tallies driving the final chatbox verdict when nothing is left to sell. */ private int sessionOffersPlaced; private int sessionStacksRefused; @@ -88,15 +150,20 @@ public boolean run(BankSellerPlugin plugin) { Microbot.enableAutoRunOn = false; this.plugin = plugin; unsellableItemIds.clear(); - offerSetupSeen = false; - tradeRestrictionSeen = false; + skippedItemIds.clear(); + transientFailureCounts.clear(); + ownedOfferItemIds.clear(); + foreignSlotOrdinals.clear(); tradeRestrictionText = null; bankDrained = false; forcedSellPrice = null; + liquidatingOwned = false; lastOfferProgressAt = System.currentTimeMillis(); leftoverLiquidated = false; + liquidationAttempts = 0; sessionOffersPlaced = 0; sessionStacksRefused = 0; + snapshotOccupiedSlots(); mainScheduledFuture = scheduledExecutorService.scheduleWithFixedDelay(() -> { try { if (!Microbot.isLoggedIn()) return; @@ -120,13 +187,20 @@ public boolean run(BankSellerPlugin plugin) { if (!leftoverLiquidated && watchIdleMs > LEFTOVER_OFFER_TIMEOUT_MS) { // An offer that will not fill at the instant-sell price gets // one final attempt at 1gp so the run can actually finish - leftoverLiquidated = liquidateLeftoverOffers(); + boolean done = liquidateLeftoverOffers(); + liquidationAttempts = done ? 0 : liquidationAttempts + 1; + leftoverLiquidated = done || liquidationAttempts >= MAX_LIQUIDATION_ATTEMPTS; // On failure retry in ~15s instead of waiting the full timeout again lastOfferProgressAt = System.currentTimeMillis() - (leftoverLiquidated ? 0 : LEFTOVER_OFFER_TIMEOUT_MS - 15_000); } else if (leftoverLiquidated && watchIdleMs > LEFTOVER_GIVE_UP_MS) { - announce("1gp leftover offer(s) are still open - stopping anyway; " - + "collect them at the Grand Exchange later"); + if (findOwnedActiveSlots().isEmpty()) { + // Only foreign offers are left open - they are not ours to collect + announce("Only pre-existing Grand Exchange offers remain - leaving them untouched. Stopping"); + } else { + announce("1gp leftover offer(s) are still open - stopping anyway; " + + "collect them at the Grand Exchange later"); + } Microbot.stopPlugin(plugin); } return; @@ -155,10 +229,42 @@ public boolean run(BankSellerPlugin plugin) { return true; } + /** + * Remembers which GE slots were already occupied when the run started. + * The offers in them belong to the user (or another plugin) and the + * endgame liquidation must never abort, collect or re-list them. + */ + private void snapshotOccupiedSlots() { + clientValue(() -> { + GrandExchangeOffer[] offers = Microbot.getClient().getGrandExchangeOffers(); + if (offers == null) { + return false; + } + for (int i = 0; i < offers.length; i++) { + GrandExchangeOffer offer = offers[i]; + if (offer != null && offer.getItemId() > 0 && offer.getState() != GrandExchangeOfferState.EMPTY) { + foreignSlotOrdinals.add(i); + } + } + return true; + }, false); + } + private boolean isSellable(Rs2ItemModel item) { return item.isTradeable() && !IGNORED_ITEM_IDS.contains(item.getUnNotedId()) - && !unsellableItemIds.contains(item.getUnNotedId()); + && !unsellableItemIds.contains(item.getUnNotedId()) + && !skippedItemIds.contains(item.getUnNotedId()); + } + + /** + * During the endgame liquidation only items this run placed offers for may + * be re-listed - anything else in the inventory is left alone. + */ + private boolean isLiquidationTarget(Rs2ItemModel item) { + return item.isTradeable() + && !IGNORED_ITEM_IDS.contains(item.getUnNotedId()) + && ownedOfferItemIds.contains(item.getUnNotedId()); } private boolean hasSellableInventoryItems() { @@ -203,34 +309,159 @@ private boolean collectPendingOffers() { return allSlotsEmpty; } + /** + * Slots currently holding an offer this run placed. Both conditions are + * required, so foreign offers are never touched: the slot must not have + * been occupied at startup, and the offer's item must be one this run + * successfully listed. + */ + private List findOwnedActiveSlots() { + List owned = new ArrayList<>(); + for (GrandExchangeSlots slot : Rs2GrandExchange.getActiveOfferSlots()) { + GrandExchangeOfferDetails details = Rs2GrandExchange.getOfferDetails(slot); + if (details == null) { + continue; + } + if (foreignSlotOrdinals.contains(Rs2GrandExchange.getSlotIndex(slot))) { + continue; + } + if (!ownedOfferItemIds.contains(canonicalTradeItemId(details.getItemId()))) { + continue; + } + owned.add(slot); + } + return owned; + } + + /** + * The current state of the offer in a slot, or null when the slot no + * longer holds the expected item (filled and collected meanwhile). + */ + private GrandExchangeOfferState ownedSlotState(GrandExchangeSlots slot, int expectedItemId) { + GrandExchangeOfferDetails details = Rs2GrandExchange.getOfferDetails(slot); + if (details == null || details.getItemId() != expectedItemId) { + return null; + } + return details.getState(); + } + /** * Endgame sweep for offers that never filled: aborts them, takes the items * back into the inventory and re-lists each stack at 1gp so the run finishes. + * Only offers this run placed are aborted and collected (slot by slot - + * a collect-all could drag unrelated finished offers into the inventory + * and re-list them at 1gp). * - * @return true when the leftovers were re-listed (or nothing came back) + * @return true when the leftovers were re-listed (or nothing was left to do) */ private boolean liquidateLeftoverOffers() { - announce("Leftover offer(s) not selling - re-listing at 1gp"); if (!Rs2GrandExchange.openExchange() || !sleepUntil(Rs2GrandExchange::isOpen, EXCHANGE_OPEN_TIMEOUT_MS)) { return false; } - // abortAllOffers(false) aborts every open offer and collects to the inventory - Rs2GrandExchange.abortAllOffers(false); - sleepUntil(() -> !Rs2GrandExchange.hasOffer(), SETUP_WAIT_TIMEOUT_MS); - sleep(600, 1000); - if (!hasSellableInventoryItems()) { + List ownedSlots = findOwnedActiveSlots(); + if (ownedSlots.isEmpty()) { + // Everything this run listed already filled (or nothing was listed) return true; } - forcedSellPrice = 1; - try { - sellInventory(); - } finally { - forcedSellPrice = null; + announce("Leftover offer(s) not selling - re-listing at 1gp"); + + // Abort every owned offer that is still open + for (GrandExchangeSlots slot : ownedSlots) { + if (Thread.currentThread().isInterrupted()) { + break; + } + GrandExchangeOfferDetails details = Rs2GrandExchange.getOfferDetails(slot); + if (details == null || details.getState() != GrandExchangeOfferState.SELLING) { + continue; + } + int itemId = details.getItemId(); + abortSlotOffer(slot); + // Wait for the abort to register before moving to the next slot + sleepUntil(() -> ownedSlotState(slot, itemId) != GrandExchangeOfferState.SELLING, SETUP_WAIT_TIMEOUT_MS); + sleep(400, 700); + } + + // Collect each owned slot on its own (cancelled offers return the items, + // offers that filled meanwhile return coins) + for (GrandExchangeSlots slot : ownedSlots) { + if (Thread.currentThread().isInterrupted()) { + break; + } + GrandExchangeOfferDetails details = Rs2GrandExchange.getOfferDetails(slot); + if (details == null) { + continue; + } + GrandExchangeOfferState state = details.getState(); + if (state != GrandExchangeOfferState.CANCELLED_SELL && state != GrandExchangeOfferState.SOLD) { + continue; + } + Rs2GrandExchange.collectOffer(slot, false); + sleep(300, 500); + Rs2GrandExchange.backToOverview(); + sleep(300, 500); + } + + boolean itemsBack = !Rs2Inventory.all(this::isLiquidationTarget).isEmpty(); + if (itemsBack) { + forcedSellPrice = 1; + liquidatingOwned = true; + try { + sellInventory(); + } finally { + liquidatingOwned = false; + forcedSellPrice = null; + } + return true; + } + + // Nothing came back to the inventory: fine when every owned offer filled, + // but an offer still in SELLING state means its abort never registered - + // report failure so the watch retries instead of waiting 90s again + for (GrandExchangeSlots slot : ownedSlots) { + GrandExchangeOfferDetails details = Rs2GrandExchange.getOfferDetails(slot); + if (details != null && details.getState() == GrandExchangeOfferState.SELLING) { + return false; + } } return true; } + /** + * Slot-targeted variant of Rs2GrandExchange.abortOffer: aborts exactly this + * slot and collects nothing. (abortOffer itself is name-based and ends in + * a collect-all, which would hit unrelated offers.) + */ + private void abortSlotOffer(GrandExchangeSlots slot) { + int[] widgetId = new int[1]; + Rectangle[] boundsHolder = new Rectangle[1]; + boolean found = clientValue(() -> { + Widget widget = Microbot.getClient().getWidget(GE_WIDGET_GROUP, GE_FIRST_SLOT_CHILD + slot.ordinal()); + if (!isVisible(widget)) { + return false; + } + widgetId[0] = widget.getId(); + boundsHolder[0] = widget.getBounds(); + return true; + }, false); + if (!found) { + return; + } + NewMenuEntry entry = new NewMenuEntry() + .option("Abort offer") + .target("") + .identifier(2) + .type(MenuAction.CC_OP) + .param0(2) + .param1(widgetId[0]) + .itemId(-1) + .forceLeftClick(false); + Rectangle bounds = boundsHolder[0] != null && Rs2UiHelper.isRectangleWithinCanvas(boundsHolder[0]) + ? boundsHolder[0] + : Rs2UiHelper.getDefaultRectangle(); + Microbot.doInvoke(entry, bounds); + } + /** * Opens the bank, deposits the whole inventory and withdraws every sellable * item (as notes) until the inventory is full. @@ -287,8 +518,10 @@ private boolean bankAndWithdrawAll() { } private void sellInventory() { - // Group by item so the whole stack of an item is sold in a single offer - Map> itemsById = Rs2Inventory.all(this::isSellable).stream() + // Group by item so the whole stack of an item is sold in a single offer. + // The liquidation pass only re-lists items this run placed offers for. + Map> itemsById = Rs2Inventory.all( + liquidatingOwned ? this::isLiquidationTarget : this::isSellable).stream() .collect(Collectors.groupingBy(Rs2ItemModel::getUnNotedId, LinkedHashMap::new, Collectors.toList())); if (itemsById.isEmpty()) { return; @@ -331,16 +564,31 @@ private void sellInventory() { // immediately (the endgame leftover sweep forces 1gp instead) int price = forcedSellPrice != null ? forcedSellPrice : Math.max(1, guidePrice / 2); - boolean offerPlaced = placeSellOffer(item, quantity, price); + OfferResult result = placeSellOffer(item, quantity, price); int remaining = inventoryTradeQuantity(item.getId()); - if (!offerPlaced || remaining > 0) { - // The GE refused the item (e.g. F2P trade-restricted) - put it back in the bank + if (result == OfferResult.PLACED || remaining == 0) { + // Offer confirmed - the items are gone even if the UI flow hiccuped + sessionOffersPlaced++; + ownedOfferItemIds.add(item.getUnNotedId()); + transientFailureCounts.remove(item.getUnNotedId()); + } else if (result == OfferResult.RESTRICTED) { + // The GE explicitly refused the item (F2P trade restriction) - bank it for good unsellableItemIds.add(item.getUnNotedId()); failedItems.addAll(entry.getValue()); sessionStacksRefused++; + transientFailureCounts.remove(item.getUnNotedId()); } else { - sessionOffersPlaced++; + // Transient UI failure (the setup never opened, a widget timed + // out, ...) - the item stays sellable, is NOT flagged unsellable + // and is retried on a later pass. Only after repeated failures + // it is parked so the run can still finish. + int failures = transientFailureCounts.merge(item.getUnNotedId(), 1, Integer::sum); + if (failures >= MAX_TRANSIENT_FAILURES) { + skippedItemIds.add(item.getUnNotedId()); + Microbot.log("Skipping " + item.getName() + " after " + failures + + " failed offer attempts (Grand Exchange interface not responding)"); + } } } @@ -365,78 +613,80 @@ private void sellInventory() { * enter the price through the chatbox input, set the quantity with the * "All" button and confirm. * - * @return true when the offer was confirmed and the items left the inventory + *

Only the explicit trade-restriction notice is a definitive refusal. + * A setup that never opens or a step that times out is treated as a + * transient UI failure and retried.

+ * + * @return PLACED when the offer was confirmed and the items left the + * inventory, RESTRICTED on the trade-restriction notice, FAILED + * for transient UI failures */ - private boolean placeSellOffer(Rs2ItemModel item, int quantity, int price) { + private OfferResult placeSellOffer(Rs2ItemModel item, int quantity, int price) { for (int attempt = 1; attempt <= MAX_OFFER_ATTEMPTS; attempt++) { - offerSetupSeen = false; - tradeRestrictionSeen = false; try { - boolean placed = doPlaceSellOffer(item, quantity, price); - // A setup that never opened, or a trade-restriction notice, is - // a genuine refusal - retrying would just waste another timeout - if (placed || tradeRestrictionSeen || !offerSetupSeen || attempt == MAX_OFFER_ATTEMPTS) { - return placed; + OfferResult result = doPlaceSellOffer(item, quantity, price); + if (result != OfferResult.FAILED || attempt == MAX_OFFER_ATTEMPTS) { + return result; } abortOfferSetup(); sleepUntil(() -> !isSetupVisible(), SETUP_WAIT_TIMEOUT_MS); if (!Rs2GrandExchange.isOpen()) { if (!Rs2GrandExchange.openExchange() || !sleepUntil(Rs2GrandExchange::isOpen, EXCHANGE_OPEN_TIMEOUT_MS)) { - return false; + return OfferResult.FAILED; } } } catch (RuntimeException ex) { // Transient UI errors (e.g. a widget without bounds on a slow client) - // must not kill the whole sell pass - treat this item as refused + // must not kill the whole sell pass or flag the item unsellable Microbot.log("Sell offer failed for " + item.getName() + ": " + ex.getMessage()); abortOfferSetup(); - return false; + return OfferResult.FAILED; } } - return false; + return OfferResult.FAILED; } - private boolean doPlaceSellOffer(Rs2ItemModel item, int quantity, int price) { + private OfferResult doPlaceSellOffer(Rs2ItemModel item, int quantity, int price) { // A setup left open by a trade-restricted item is reused instead of // backing out: clicking 'Offer' on the next item switches the setup // straight over to it, so refused items never close the GE flow if (!Rs2Inventory.interact(item.getId(), "Offer")) { - return false; + return OfferResult.FAILED; } // A reused setup stays visible the whole time - the wait must hold - // out until the setup actually shows THIS item, not the previous one + // out until the setup actually shows THIS item, not the previous one. + // A timeout here is transient (slow client/UI) and is retried by the + // caller - it never flags the item unsellable. if (!sleepUntil(() -> isSetupVisible() && sameTradeItem(resolveSetupItemId(item.getId()), item.getId()), SETUP_WAIT_TIMEOUT_MS)) { - return false; + return OfferResult.FAILED; } - offerSetupSeen = true; // A red "restricted for trading" notice in the setup means Confirm is - // dead for this item - fail fast, no retry. The setup is LEFT OPEN so - // the next item's 'Offer' click switches straight over to it. + // dead for this item - the only genuine refusal. The setup is LEFT OPEN + // so the next item's 'Offer' click switches straight over to it. String restrictionNotice = findTradeRestrictionNotice(); if (restrictionNotice != null) { - tradeRestrictionSeen = true; tradeRestrictionText = restrictionNotice; - return false; + return OfferResult.RESTRICTED; } if (!enterPrice(price)) { abortOfferSetup(); - return false; + return OfferResult.FAILED; } if (!enterFullQuantity(quantity)) { abortOfferSetup(); - return false; + return OfferResult.FAILED; } Widget confirmButton = findConfirmButton(); if (!clickWidget(confirmButton)) { abortOfferSetup(); - return false; + return OfferResult.FAILED; } // The GE warns when the price is far from the guide price (often, at @@ -444,11 +694,11 @@ private boolean doPlaceSellOffer(Rs2ItemModel item, int quantity, int price) { long confirmDeadline = System.currentTimeMillis() + CONFIRM_WAIT_TIMEOUT_MS; while (System.currentTimeMillis() < confirmDeadline) { if (Thread.currentThread().isInterrupted()) { - return false; + return OfferResult.FAILED; } boolean warningUp = isGePriceWarningVisible(); if (!isSetupVisible() && !warningUp) { - return true; + return OfferResult.PLACED; } if (warningUp) { acceptGePriceWarning(); @@ -457,7 +707,7 @@ private boolean doPlaceSellOffer(Rs2ItemModel item, int quantity, int price) { } // The offer can go through despite a lingering setup - trust the inventory - return inventoryTradeQuantity(item.getId()) == 0; + return inventoryTradeQuantity(item.getId()) == 0 ? OfferResult.PLACED : OfferResult.FAILED; } /** @@ -850,11 +1100,12 @@ private boolean isConfirmWidget(Widget widget) { return false; } String[] actions = widget.getActions(); - if (actions != null) { - for (String action : actions) { - if (normalize(action).contains("confirm")) { - return true; - } + if (actions == null) { + return false; + } + for (String action : actions) { + if (normalize(action).contains("confirm")) { + return true; } } return normalize(widget.getText()).contains("confirm") && hasAnyAction(widget); @@ -939,6 +1190,10 @@ private String finalVerdictMessage() { } return message; } + if (sessionOffersPlaced == 0 && !skippedItemIds.isEmpty()) { + return "ERROR: the Grand Exchange interface did not respond - " + skippedItemIds.size() + + " stack(s) could not be sold and were left in the bank. Stopping."; + } if (sessionStacksRefused > 0) { return "Nothing left to sell - " + sessionStacksRefused + " stack(s) were refused by the GE. Stopping.";