From 06627d46525961e83baa4a360ca3c9a18a1e0614 Mon Sep 17 00:00:00 2001 From: Joinkiee Date: Sun, 6 Sep 2026 20:22:24 +1000 Subject: [PATCH 01/16] fix(geflipper): add profit overlay, prevent OS key leak, and fix abort stalls (v1.2.5) Add FlipperOverlay displaying version (v1.2.5), session run time, Copilot GP/hr, total profit, and pending flips directly on client. Fix global keypress leak: replace OS-level keyboard/mouse events with direct client thread chatbox manipulation for Copilot quantity/price entry, eliminating unwanted keypresses when defocused or in other applications. Fix abort offer stalls: resolve infinite abort loops and stuck offer screens when aborting stale offers by checking actual offer state, handling confirm dialogs, and safely resetting back to overview. Add highlighted NPC bank/exchange interaction and bank withdrawal support for sell suggestions. Bump FlipperPlugin version to 1.2.5. --- .../microbot/geflipper/FlipperConfig.java | 47 +- .../microbot/geflipper/FlipperOverlay.java | 279 ++++++++ .../microbot/geflipper/FlipperPlugin.java | 36 +- .../microbot/geflipper/FlipperScript.java | 642 ++++++++++++++++-- 4 files changed, 930 insertions(+), 74 deletions(-) create mode 100644 src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperOverlay.java diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperConfig.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperConfig.java index 02cb3ddf47..acc53f04c3 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperConfig.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperConfig.java @@ -7,11 +7,27 @@ @ConfigGroup("Flipper Config") public interface FlipperConfig extends Config { - @ConfigItem( - keyName = "guide", - name = "How to use", - description = "How to use this plugin", - position = 0 + enum SelectionMethod { + HOTKEY("Hotkey (E)"), + MOUSE("Mouse"); + + private final String name; + + SelectionMethod(String name) { + this.name = name; + } + + @Override + public String toString() { + return name; + } + } + + @ConfigItem( + keyName = "guide", + name = "How to use", + description = "How to use this plugin", + position = 0 ) default String GUIDE() { return "Automates the Flipping copilot plugin from the plugin hub, \n" + @@ -22,5 +38,24 @@ default String GUIDE() { "~made by chocken \n" + "Extra tip: In game settings, disable grand exchange warnings for offers with the price too low/high, otherwise the script will get stuck."; } - + + @ConfigItem( + keyName = "selectionMethod", + name = "Suggestion Selection", + description = "Choose whether to use the hotkey (E) or mouse clicks to select what Copilot suggests", + position = 1 + ) + default SelectionMethod selectionMethod() { + return SelectionMethod.HOTKEY; + } + + @ConfigItem( + keyName = "showOverlay", + name = "Show Overlay", + description = "Display profit and GP/hr overlay on the top-left of the screen", + position = 2 + ) + default boolean showOverlay() { + return true; + } } diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperOverlay.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperOverlay.java new file mode 100644 index 0000000000..1bdd14cdf2 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperOverlay.java @@ -0,0 +1,279 @@ +package net.runelite.client.plugins.microbot.geflipper; + +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.ui.overlay.OverlayPanel; +import net.runelite.client.ui.overlay.OverlayPosition; +import net.runelite.client.ui.overlay.components.LineComponent; +import net.runelite.client.ui.overlay.components.TitleComponent; + +import javax.inject.Inject; +import javax.swing.JLabel; +import java.awt.*; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Locale; + +public class FlipperOverlay extends OverlayPanel { + private static final Color POSITIVE_COLOR = new Color(0x52D273); + private static final Color NEGATIVE_COLOR = new Color(0xFF6666); + private static final Color TITLE_COLOR = Color.CYAN; + + private final FlipperPlugin plugin; + private final FlipperConfig config; + + private Plugin flippingCopilot; + private Object flipManager; + private Object sessionManager; + private Object statsPanel; + + private String overallProfitStr = "0 gp"; + private Color overallProfitColor = Color.WHITE; + private String gpHrStr = "0 gp/hr"; + private Color gpHrColor = Color.WHITE; + private String sessionProfitStr = "0 gp"; + private Color sessionProfitColor = Color.WHITE; + private String sessionTimeStr = null; + + private long lastFetchTime = 0; + + @Inject + public FlipperOverlay(FlipperPlugin plugin, FlipperConfig config) { + super(plugin); + this.plugin = plugin; + this.config = config; + setPosition(OverlayPosition.TOP_LEFT); + setNaughty(); + } + + private void updateCopilotStats() { + long now = System.currentTimeMillis(); + if (now - lastFetchTime < 1000) return; + lastFetchTime = now; + + if (flippingCopilot == null) { + flippingCopilot = Microbot.getPluginManager() + .getPlugins() + .stream() + .filter(p -> p.getClass().getSimpleName().equalsIgnoreCase("FlippingCopilotPlugin")) + .findFirst() + .orElse(null); + } + if (flippingCopilot == null) { + overallProfitStr = "Copilot not found"; + overallProfitColor = Color.GRAY; + gpHrStr = "-"; + gpHrColor = Color.GRAY; + return; + } + + try { + if (flipManager == null) { + Field fmField = flippingCopilot.getClass().getDeclaredField("flipManager"); + fmField.setAccessible(true); + flipManager = fmField.get(flippingCopilot); + } + if (sessionManager == null) { + Field smField = flippingCopilot.getClass().getDeclaredField("sessionManager"); + smField.setAccessible(true); + sessionManager = smField.get(flippingCopilot); + } + if (statsPanel == null) { + try { + Field spField = flippingCopilot.getClass().getDeclaredField("statsPanel"); + spField.setAccessible(true); + statsPanel = spField.get(flippingCopilot); + } catch (Exception ignored) {} + } + } catch (Exception ignored) {} + + long overallProfit = 0; + long sessionProfit = 0; + long gpHr = 0; + boolean gotOverall = false; + boolean gotSession = false; + + // 1. Query flipManager for exact overall & session profits + if (flipManager != null) { + try { + // calculateStats(0, null) calculates all-time stats across all accounts + Method calculateStats = flipManager.getClass().getMethod("calculateStats", int.class, Integer.class); + Object overallStats = calculateStats.invoke(flipManager, 0, null); + if (overallStats != null) { + Field profitField = overallStats.getClass().getField("profit"); + overallProfit = profitField.getLong(overallStats); + gotOverall = true; + } + + // getIntervalStats() gets current interval / session stats + Method getIntervalStats = flipManager.getClass().getMethod("getIntervalStats"); + Object intervalStats = getIntervalStats.invoke(flipManager); + if (intervalStats != null) { + Field profitField = intervalStats.getClass().getField("profit"); + sessionProfit = profitField.getLong(intervalStats); + gotSession = true; + } + } catch (Exception ignored) {} + } + + // 2. Query sessionManager for runtime & compute gp/hr + if (sessionManager != null) { + try { + Method getCachedSessionData = sessionManager.getClass().getMethod("getCachedSessionData"); + Object sessionData = getCachedSessionData.invoke(sessionManager); + if (sessionData != null) { + Field durationField = sessionData.getClass().getField("durationMillis"); + long durationMillis = durationField.getLong(sessionData); + if (durationMillis > 0) { + double hours = durationMillis / 3600000.0; + if (hours > 0) { + gpHr = (long) (sessionProfit / hours); + } + long totalSeconds = durationMillis / 1000; + long h = totalSeconds / 3600; + long m = (totalSeconds % 3600) / 60; + long s = totalSeconds % 60; + sessionTimeStr = String.format("%02d:%02d:%02d", h, m, s); + } + } + } catch (Exception ignored) {} + } + + // 3. Check statsPanel UI labels if available to supplement + if (statsPanel != null) { + try { + // If hourlyProfitVal label has Copilot's formatted text + Field hourlyField = statsPanel.getClass().getDeclaredField("hourlyProfitVal"); + hourlyField.setAccessible(true); + Object hourlyObj = hourlyField.get(statsPanel); + if (hourlyObj instanceof JLabel) { + String text = ((JLabel) hourlyObj).getText(); + if (text != null && !text.trim().isEmpty() && !text.equals("0 gp/hr") && gpHr == 0) { + gpHrStr = text; + gpHrColor = getColorForText(text); + } + } + + // If overall wasn't found from flipManager, read totalProfitVal + if (!gotOverall) { + Field totalField = statsPanel.getClass().getDeclaredField("totalProfitVal"); + totalField.setAccessible(true); + Object totalObj = totalField.get(statsPanel); + if (totalObj instanceof JLabel) { + String text = ((JLabel) totalObj).getText(); + if (text != null && !text.trim().isEmpty()) { + overallProfitStr = text; + overallProfitColor = getColorForText(text); + gotOverall = true; + } + } + } + + // Session time label + Field timeField = statsPanel.getClass().getDeclaredField("sessionTimeVal"); + timeField.setAccessible(true); + Object timeObj = timeField.get(statsPanel); + if (timeObj instanceof JLabel) { + String text = ((JLabel) timeObj).getText(); + if (text != null && !text.trim().isEmpty() && !text.equals("00:00:00")) { + sessionTimeStr = text; + } + } + } catch (Exception ignored) {} + } + + if (gotOverall) { + overallProfitStr = formatProfit(overallProfit); + overallProfitColor = overallProfit > 0 ? POSITIVE_COLOR : (overallProfit < 0 ? NEGATIVE_COLOR : Color.WHITE); + } + if (gotSession) { + sessionProfitStr = formatProfit(sessionProfit); + sessionProfitColor = sessionProfit > 0 ? POSITIVE_COLOR : (sessionProfit < 0 ? NEGATIVE_COLOR : Color.WHITE); + } + if (gpHr != 0 || !gpHrStr.contains("gp/hr")) { + gpHrStr = formatGpHr(gpHr); + gpHrColor = gpHr > 0 ? POSITIVE_COLOR : (gpHr < 0 ? NEGATIVE_COLOR : Color.WHITE); + } + } + + public static String formatProfit(long amount) { + String sign = amount > 0 ? "+" : (amount < 0 ? "-" : ""); + long abs = Math.abs(amount); + if (abs >= 1_000_000_000L) { + return sign + String.format(Locale.ENGLISH, "%.2fB gp", abs / 1_000_000_000.0); + } else if (abs >= 1_000_000L) { + return sign + String.format(Locale.ENGLISH, "%.2fM gp", abs / 1_000_000.0); + } else if (abs >= 10_000L) { + return sign + String.format(Locale.ENGLISH, "%.1fK gp", abs / 1_000.0); + } else { + return sign + String.format(Locale.ENGLISH, "%,d gp", abs); + } + } + + public static String formatGpHr(long amount) { + String sign = amount > 0 ? "+" : (amount < 0 ? "-" : ""); + long abs = Math.abs(amount); + if (abs >= 1_000_000_000L) { + return sign + String.format(Locale.ENGLISH, "%.2fB gp/hr", abs / 1_000_000_000.0); + } else if (abs >= 1_000_000L) { + return sign + String.format(Locale.ENGLISH, "%.2fM gp/hr", abs / 1_000_000.0); + } else if (abs >= 10_000L) { + return sign + String.format(Locale.ENGLISH, "%.1fK gp/hr", abs / 1_000.0); + } else { + return sign + String.format(Locale.ENGLISH, "%,d gp/hr", abs); + } + } + + private static Color getColorForText(String text) { + if (text.startsWith("+") || (!text.startsWith("-") && !text.startsWith("0"))) { + return POSITIVE_COLOR; + } else if (text.startsWith("-")) { + return NEGATIVE_COLOR; + } + return Color.WHITE; + } + + @Override + public Dimension render(Graphics2D graphics) { + if (config != null && !config.showOverlay()) return null; + if (!Microbot.isLoggedIn()) return null; + + updateCopilotStats(); + + panelComponent.getChildren().clear(); + panelComponent.setPreferredSize(new Dimension(200, 0)); + + panelComponent.getChildren().add(TitleComponent.builder() + .text("Microbot Flipper v" + FlipperPlugin.version) + .color(TITLE_COLOR) + .build()); + + panelComponent.getChildren().add(LineComponent.builder() + .left("GP/hr:") + .right(gpHrStr) + .rightColor(gpHrColor) + .build()); + + panelComponent.getChildren().add(LineComponent.builder() + .left("Overall Profit:") + .right(overallProfitStr) + .rightColor(overallProfitColor) + .build()); + + panelComponent.getChildren().add(LineComponent.builder() + .left("Session Profit:") + .right(sessionProfitStr) + .rightColor(sessionProfitColor) + .build()); + + if (sessionTimeStr != null && !sessionTimeStr.isEmpty()) { + panelComponent.getChildren().add(LineComponent.builder() + .left("Session Time:") + .right(sessionTimeStr) + .rightColor(Color.LIGHT_GRAY) + .build()); + } + + return super.render(graphics); + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java index 0bfabf09c9..a0b58b7506 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java @@ -8,6 +8,8 @@ import net.runelite.client.plugins.PluginDescriptor; import net.runelite.client.plugins.microbot.PluginConstants; +import net.runelite.client.ui.overlay.OverlayManager; + import java.awt.*; @PluginDescriptor( @@ -30,20 +32,50 @@ public class FlipperPlugin extends Plugin { private FlipperScript flipperScript; @Inject private net.runelite.client.plugins.microbot.geflipper.FlipperConfig config; + @Inject + private OverlayManager overlayManager; + @Inject + private FlipperOverlay overlay; @Provides net.runelite.client.plugins.microbot.geflipper.FlipperConfig provideConfig(ConfigManager configManager) { return configManager.getConfig(FlipperConfig.class); } + private void disableGameChatAppender() { + try { + org.slf4j.Logger slf4jLogger = org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); + if (slf4jLogger instanceof ch.qos.logback.classic.Logger) { + ch.qos.logback.classic.Logger rootLogger = (ch.qos.logback.classic.Logger) slf4jLogger; + java.util.Iterator> it = rootLogger.iteratorForAppenders(); + while (it.hasNext()) { + ch.qos.logback.core.Appender appender = it.next(); + if (appender.getClass().getName().contains("GameChatAppender")) { + rootLogger.detachAppender(appender); + appender.stop(); + } + } + } + net.runelite.client.plugins.microbot.GameChatAppender.updateConfiguration(false, ch.qos.logback.classic.Level.OFF, false); + } catch (Throwable ignored) { + } + } + @Override protected void startUp() throws AWTException{ - flipperScript.run(); + disableGameChatAppender(); + if (overlayManager != null && overlay != null) { + overlayManager.add(overlay); + } + flipperScript.run(config); } @Override protected void shutDown() { + if (overlayManager != null && overlay != null) { + overlayManager.remove(overlay); + } flipperScript.state = State.GOING_TO_GE; flipperScript.shutdown(); } -} +} \ No newline at end of file diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java index 8edf816877..9bca9c5e6e 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java @@ -1,11 +1,16 @@ package net.runelite.client.plugins.microbot.geflipper; +import com.google.inject.Inject; import lombok.extern.slf4j.Slf4j; import net.runelite.api.MenuAction; +import net.runelite.api.NPC; import net.runelite.api.coords.WorldArea; +import net.runelite.api.coords.WorldPoint; import net.runelite.api.gameval.InterfaceID; import net.runelite.api.gameval.ItemID; import net.runelite.api.widgets.Widget; +import net.runelite.client.input.KeyManager; +import net.runelite.client.input.KeyListener; import net.runelite.client.plugins.Plugin; import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.Script; @@ -15,10 +20,14 @@ 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.math.Rs2Random; 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.npc.Rs2Npc; +import net.runelite.client.plugins.microbot.util.npc.Rs2NpcModel; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; import java.awt.*; @@ -45,6 +54,8 @@ public class FlipperScript extends Script { private static final int INTERACTION_TIMEOUT_VARIANCE = 11000; private static final int INVENTORY_WAIT_TIMEOUT = 5000; private static final int SCHEDULE_INTERVAL_MS = 600; + private static final int KEY_PRESS_DELAY_MIN = 250; + private static final int KEY_PRESS_DELAY_MAX = 400; private final WorldArea grandExchangeArea = new WorldArea(3136, 3465, 61, 54, 0); State state = State.GOING_TO_GE; @@ -55,6 +66,8 @@ public class FlipperScript extends Script { private long lastActionTime = 0; private long actionCooldown = DEFAULT_ACTION_COOLDOWN; private long interactionTimeout = DEFAULT_INTERACTION_TIMEOUT; + private long offerScreenOpenTime = 0; + private int offerScreenActionCount = 0; private int[] grandExchangeSlotIds = new int[] { InterfaceID.GeOffers.INDEX_0, @@ -67,6 +80,21 @@ public class FlipperScript extends Script { InterfaceID.GeOffers.INDEX_7 }; + @Inject + private FlipperConfig config; + + @Inject + private KeyManager keyManager; + + public boolean run(FlipperConfig config) { + this.config = config; + return run(); + } + + private boolean isMouseMode() { + return config != null && config.selectionMethod() == FlipperConfig.SelectionMethod.MOUSE; + } + public boolean run() { Rs2AntibanSettings.naturalMouse = true; Rs2Antiban.setActivityIntensity(ActivityIntensity.LOW); @@ -86,7 +114,9 @@ public boolean run() { state = State.MONITORING_COPILOT; return; } - if (!grandExchangeArea.contains(Microbot.getClientThread().invoke(() -> Microbot.getClient().getLocalPlayer().getWorldLocation()))) { + WorldPoint playerLocation = Rs2Player.getWorldLocation(); + if (playerLocation == null) return; + if (!grandExchangeArea.contains(playerLocation)) { Rs2GrandExchange.walkToGrandExchange(); } state = State.GETTING_COINS; @@ -108,33 +138,80 @@ public boolean run() { break; case MONITORING_COPILOT: - if (!Rs2GrandExchange.isOpen()) { - Rs2GrandExchange.openExchange(); - return; - } - - // Check interaction timeout first - reset ge window state if stuck - long currentTime = System.currentTimeMillis(); - if (Rs2GrandExchange.isOfferScreenOpen() && (currentTime - lastActionTime > interactionTimeout)) { - Rs2GrandExchange.backToOverview(); - - lastActionTime = System.currentTimeMillis(); - actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); - interactionTimeout = Rs2Random.randomGaussian(DEFAULT_INTERACTION_TIMEOUT, INTERACTION_TIMEOUT_VARIANCE); - - log.info("interactionTimeout reached, returning to GE overview."); - return; - } - - // Check for Copilot price/quantity messages in chat + long currentTime = System.currentTimeMillis(); + + // 0. Offer screen watchdog & loop detection + if (isOfferScreenOpen()) { + if (offerScreenOpenTime == 0) { + offerScreenOpenTime = currentTime; + offerScreenActionCount = 0; + } + + // Check if "Too much money!" warning is shown on offer screen + if (Rs2Widget.hasWidget("Too much money")) { + log.warn("Offer has 'Too much money!' error. Backing out to GE overview."); + backToOverview(); + return; + } + + // If on offer screen and Copilot suggests ABORT, abort or back out immediately + if (suggestionManager != null) { + try { + Object currentSuggestion = getSuggestion(suggestionManager); + if (currentSuggestion != null) { + Method isAbortMethod = currentSuggestion.getClass().getMethod("isAbortSuggestion"); + if ((Boolean) isAbortMethod.invoke(currentSuggestion)) { + Widget abortBtn = Rs2Widget.findWidget("Abort offer"); + if (abortBtn != null && Rs2Widget.isWidgetVisible(abortBtn.getId())) { + log.info("Aborting offer via offer screen button '{}'", abortBtn.getId()); + Rs2Widget.clickWidget(abortBtn); + sleep(200, 400); + backToOverview(); + } else { + log.info("Abort suggested while on offer screen - backing out to overview."); + backToOverview(); + } + lastActionTime = currentTime; + actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); + return; + } + } + } catch (Exception ignored) {} + } + + // If stuck on offer screen for > 30 seconds or after 10 repeated actions without closing + if (currentTime - offerScreenOpenTime > 30000 || offerScreenActionCount >= 10) { + log.warn("Offer screen stuck (openTime={}ms, actions={}). Backing out to GE overview.", + currentTime - offerScreenOpenTime, offerScreenActionCount); + backToOverview(); + return; + } + } else { + offerScreenOpenTime = 0; + offerScreenActionCount = 0; + } + + // 1. If bank is open, handle bank withdrawals + if (handleBankIfNeeded()) return; + + // 2. Check for Copilot price/quantity messages in chat if (checkAndPressCopilotKeybind()) return; - // Check if we need to abort any offers + // 3. Check if we need to abort any offers if (checkAndAbortOrModifyIfNeeded()) return; - // Check for highlighted widgets + // 4. Check for highlighted widgets if (checkAndClickHighlightedWidgets()) return; + // 5. Check for highlighted NPCs + if (checkAndInteractHighlightedNpc()) return; + + // 6. If neither GE nor Bank is open, open GE + if (!Rs2GrandExchange.isOpen() && !Rs2Bank.isOpen()) { + Rs2GrandExchange.openExchange(); + return; + } + break; } } catch (Exception ex) { @@ -219,6 +296,191 @@ private Object getSuggestionManager(Plugin flippingCopilot) return suggestionManager; } + private boolean isOfferScreenOpen() { + return Rs2GrandExchange.isOfferScreenOpen() + || Rs2Widget.isWidgetVisible(30474266) + || Rs2Widget.isWidgetVisible(30474267); + } + + private void backToOverview() { + log.info("Returning to GE overview."); + Rs2GrandExchange.backToOverview(); + if (isOfferScreenOpen()) { + Rs2Widget.clickWidget(30474244); + } + sleepUntil(() -> !isOfferScreenOpen(), 2500); + offerScreenOpenTime = 0; + offerScreenActionCount = 0; + lastActionTime = System.currentTimeMillis(); + actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); + } + + private boolean hasChatboxInput() { + Widget inputWidget = Rs2Widget.getWidget(10616876); + if (inputWidget == null) inputWidget = Rs2Widget.getWidget(162, 44); + if (inputWidget != null) { + String text = inputWidget.getText(); + if (text != null && !text.trim().isEmpty() && !text.trim().equals("*")) { + return true; + } + } + try { + String varcStr = Microbot.getClient().getVarcStrValue(359); + if (varcStr != null && !varcStr.trim().isEmpty() && !varcStr.trim().equals("*")) { + return true; + } + } catch (Exception ignored) {} + return false; + } + + private KeyManager getKeyManager() { + if (keyManager != null) return keyManager; + try { + if (Microbot.getInjector() != null) { + keyManager = Microbot.getInjector().getInstance(KeyManager.class); + } + } catch (Exception e) { + log.warn("Could not get KeyManager: {}", e.getMessage()); + } + return keyManager; + } + + private static class ExtendedKeyEvent extends KeyEvent { + private final int extCode; + + public ExtendedKeyEvent(Component source, int id, long when, int modifiers, int keyCode, char keyChar) { + super(source, id, when, modifiers, keyCode, keyChar); + this.extCode = keyCode; + } + + @Override + public int getExtendedKeyCode() { + return extCode; + } + } + + private void triggerCopilotQuickSet() { + int keyCode = KeyEvent.VK_E; + int modifiers = 0; + char keyChar = 'e'; + + // Retrieve configured hotkey from Flipping Copilot if available + if (flippingCopilot != null) { + try { + Field cfgField = flippingCopilot.getClass().getDeclaredField("config"); + cfgField.setAccessible(true); + Object copilotConfig = cfgField.get(flippingCopilot); + if (copilotConfig != null) { + Method qkMethod = copilotConfig.getClass().getMethod("quickSetKeybind"); + Object keybindObj = qkMethod.invoke(copilotConfig); + if (keybindObj instanceof net.runelite.client.config.Keybind) { + net.runelite.client.config.Keybind kb = (net.runelite.client.config.Keybind) keybindObj; + if (kb.getKeyCode() != KeyEvent.VK_UNDEFINED) { + keyCode = kb.getKeyCode(); + modifiers = kb.getModifiers(); + keyChar = Character.toLowerCase((char) keyCode); + } + } + } + } catch (Exception ignored) {} + } + + Canvas canvas = Microbot.getClient().getCanvas(); + + // Dispatch ExtendedKeyEvent (with overridden getExtendedKeyCode) to KeyManager and Canvas + Component source = canvas != null ? canvas : new Canvas(); + long now = System.currentTimeMillis(); + ExtendedKeyEvent pressEvent = new ExtendedKeyEvent(source, KeyEvent.KEY_PRESSED, now, modifiers, keyCode, keyChar); + ExtendedKeyEvent releaseEvent = new ExtendedKeyEvent(source, KeyEvent.KEY_RELEASED, now + 30, modifiers, keyCode, keyChar); + + KeyManager km = getKeyManager(); + if (km != null) { + km.processKeyPressed(pressEvent); + km.processKeyReleased(releaseEvent); + } + if (canvas != null) { + canvas.dispatchEvent(pressEvent); + canvas.dispatchEvent(releaseEvent); + } + + // 3. Trigger Copilot's handleKeybind on keyListener via ClientThread + if (flippingCopilot != null) { + try { + Field khField = flippingCopilot.getClass().getDeclaredField("keybindHandler"); + khField.setAccessible(true); + Object keybindHandler = khField.get(flippingCopilot); + if (keybindHandler != null) { + Field klField = keybindHandler.getClass().getDeclaredField("keyListener"); + klField.setAccessible(true); + Object keyListener = klField.get(keybindHandler); + if (keyListener != null) { + for (Method m : keyListener.getClass().getDeclaredMethods()) { + if (m.getName().equals("handleKeybind")) { + m.setAccessible(true); + Microbot.getClientThread().invokeLater(() -> { + try { + m.invoke(keyListener, true, false, false); + } catch (Exception ex) { + log.debug("handleKeybind invoke failed: {}", ex.getMessage()); + } + }); + break; + } + } + } + } + } catch (Exception e) { + log.debug("Could not trigger handleKeybind via reflection: {}", e.getMessage()); + } + } + } + + private void setCopilotChatboxValueDirectly(long val) { + if (val <= 0) return; + + // 1. OfferHandler in keybindHandler + if (flippingCopilot != null) { + try { + Field khField = flippingCopilot.getClass().getDeclaredField("keybindHandler"); + khField.setAccessible(true); + Object keybindHandler = khField.get(flippingCopilot); + if (keybindHandler != null) { + Field ohField = keybindHandler.getClass().getDeclaredField("offerHandler"); + ohField.setAccessible(true); + Object offerHandler = ohField.get(keybindHandler); + if (offerHandler != null) { + for (Method m : offerHandler.getClass().getMethods()) { + if (m.getName().equals("setChatboxValue") && m.getParameterCount() == 1) { + final long v = val; + Microbot.getClientThread().invokeLater(() -> { + try { + m.invoke(offerHandler, v); + } catch (Exception ignored) {} + }); + break; + } + } + } + } + } catch (Exception e) { + log.debug("Could not set chatbox value via offerHandler: {}", e.getMessage()); + } + } + + // 2. Direct client-thread update (identical to Copilot's OfferHandler.setChatboxValue) + final long finalVal = val; + Microbot.getClientThread().invokeLater(() -> { + try { + Widget widget = Microbot.getClient().getWidget(10616876); + if (widget == null) widget = Microbot.getClient().getWidget(162, 44); + if (widget != null) { + widget.setText(finalVal + "*"); + } + Microbot.getClient().setVarcStrValue(359, String.valueOf(finalVal)); + } catch (Exception ignored) {} + }); + } + private Object getSuggestion(Object suggestionManager) { if (suggestionManager == null) return null; @@ -290,7 +552,7 @@ private List getHighlightWidgets(Object highlightController) } catch (NoSuchFieldException e) { - log.warn("Overlay {} does not have a widget field, skipping.", highlightOverlay.getClass().getSimpleName()); + // Non-widget overlays like NpcHighlightOverlay are handled separately } catch (Exception e) { @@ -330,6 +592,7 @@ private Widget getWidgetFromOverlay(Object highlightController, String suggestio private boolean checkAndAbortOrModifyIfNeeded() { + if (!Rs2GrandExchange.isOpen() || isOfferScreenOpen()) return false; if (System.currentTimeMillis() - lastActionTime < actionCooldown) return false; if (flippingCopilot == null || highlightController == null || suggestionManager == null) return false; @@ -346,24 +609,62 @@ private boolean checkAndAbortOrModifyIfNeeded() if (!isAbort && !isModify) return false; - log.info("Found suggestion type: {}.", isAbort ? "ABORT" : "MODIFY"); - Widget abortWidget = getWidgetFromOverlay(highlightController, isAbort ? "abort" : "modify"); - if (abortWidget != null) + if (abortWidget == null) + { + try { + Method getBoxIdMethod = currentSuggestion.getClass().getMethod("getBoxId"); + int boxId = (Integer) getBoxIdMethod.invoke(currentSuggestion); + if (boxId >= 0 && boxId < grandExchangeSlotIds.length) { + abortWidget = Rs2Widget.getWidget(grandExchangeSlotIds[boxId]); + } + } catch (Exception ignored) {} + } + if (abortWidget != null && Rs2Widget.isWidgetVisible(abortWidget.getId())) { - NewMenuEntry menuEntry; - if (isModify) - menuEntry = new NewMenuEntry("Modify offer", "", 3, MenuAction.CC_OP, 2, abortWidget.getId(), false); - else - menuEntry = new NewMenuEntry("Abort offer", "", 2, MenuAction.CC_OP, 2, abortWidget.getId(), false); - - Rectangle bounds = abortWidget.getBounds() != null && Rs2UiHelper.isRectangleWithinCanvas(abortWidget.getBounds()) - ? abortWidget.getBounds() - : Rs2UiHelper.getDefaultRectangle(); - Microbot.doInvoke(menuEntry, bounds); - lastActionTime = System.currentTimeMillis(); - actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); - return true; + if (isAbort) + { + log.info("Executing suggestion ABORT: sending Abort offer on slot widget {}", abortWidget.getId()); + NewMenuEntry abortEntry = new NewMenuEntry() + .option("Abort offer") + .target("") + .identifier(2) + .type(MenuAction.CC_OP) + .param0(2) + .param1(abortWidget.getId()) + .itemId(-1) + .forceLeftClick(false); + Rectangle bounds = abortWidget.getBounds() != null && Rs2UiHelper.isRectangleWithinCanvas(abortWidget.getBounds()) + ? abortWidget.getBounds() + : Rs2UiHelper.getDefaultRectangle(); + Microbot.doInvoke(abortEntry, bounds); + lastActionTime = System.currentTimeMillis(); + actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); + return true; + } + else // isModify + { + log.info("Executing suggestion MODIFY: opening slot widget {}", abortWidget.getId()); + Rs2Widget.clickWidget(abortWidget); + lastActionTime = System.currentTimeMillis(); + actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); + return true; + } + } + else if (isAbort) + { + try { + Method getNameMethod = currentSuggestion.getClass().getMethod("getName"); + String itemName = (String) getNameMethod.invoke(currentSuggestion); + if (itemName != null && !itemName.isEmpty()) { + log.info("Executing suggestion ABORT via Rs2GrandExchange.abortOffer for item '{}'", itemName); + if (Rs2GrandExchange.abortOffer(itemName, false)) { + lastActionTime = System.currentTimeMillis(); + actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); + return true; + } + } + } catch (Exception ignored) {} } } catch (Exception e) @@ -378,11 +679,26 @@ private boolean checkAndPressCopilotKeybind() { Widget copilotWidget = Rs2Widget.findWidget("Copilot item:", null, false); if (copilotWidget != null && Rs2Widget.isWidgetVisible(copilotWidget.getId())) { log.info("Found chat widget Copilot item '{}'.", copilotWidget.getId()); - /// 2. Press only Enter if found in scroll contents (selecting item) - Rs2Keyboard.keyPress(KeyEvent.VK_ENTER); + if (isMouseMode()) { + log.info("Selecting Copilot item via mouse click."); + Rs2Widget.clickWidget(copilotWidget); + } else { + log.info("Selecting Copilot item via hotkey (ENTER)."); + Rs2Keyboard.keyPress(KeyEvent.VK_ENTER); + } - /// As these widgets tend to disappear quickly sometimes, we sleep after we interact with it to select the suggested item - sleepUntil(() -> System.currentTimeMillis() - lastActionTime < actionCooldown); + // Wait for item selection widget to disappear (fallback to enter if still visible after mouse click) + if (!sleepUntil(() -> !Rs2Widget.isWidgetVisible(copilotWidget.getId()), 2000)) { + Rs2Keyboard.keyPress(KeyEvent.VK_ENTER); + if (!sleepUntil(() -> !Rs2Widget.isWidgetVisible(copilotWidget.getId()), 1500)) { + // Fallback to mouse click if ENTER failed + if (Rs2Widget.isWidgetVisible(copilotWidget.getId())) { + Rs2Widget.clickWidget(copilotWidget); + sleepUntil(() -> !Rs2Widget.isWidgetVisible(copilotWidget.getId()), 1500); + } + } + } + offerScreenActionCount++; lastActionTime = System.currentTimeMillis(); actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); return true; @@ -392,25 +708,113 @@ private boolean checkAndPressCopilotKeybind() { // If it's time to set price/quantity - Widget chatbox = Rs2Widget.getWidget(InterfaceID.Chatbox.MES_LAYER); - if (chatbox == null) return false; - Widget copilotAction = Rs2Widget.findWidget("Set price", List.of(chatbox), true); - if (copilotAction == null) copilotAction = Rs2Widget.findWidget("Set quantity", List.of(chatbox), true); - if (copilotAction == null) return false; - - log.info("Using Copilot action widget '{}'.", copilotAction.getId()); - Rs2Widget.clickWidget(copilotAction); - if (!sleepUntil(() -> { - Widget input = Rs2Widget.getWidget(InterfaceID.Chatbox.MES_TEXT2); - return input != null && input.getText() != null && input.getText().endsWith("*"); - })) { - log.warn("Copilot did not populate the price/quantity input."); + Widget setPriceWidget = Rs2Widget.findWidget("Set a price for each item:", null, false); + Widget setQuantityWidget = Rs2Widget.findWidget("How many do you wish to ", null, false); + + boolean isPricePrompt = setPriceWidget != null && Rs2Widget.isWidgetVisible(setPriceWidget.getId()); + boolean isQuantityPrompt = setQuantityWidget != null && Rs2Widget.isWidgetVisible(setQuantityWidget.getId()); + + if (isPricePrompt || isQuantityPrompt) { + Widget promptWidget = isPricePrompt ? setPriceWidget : setQuantityWidget; + log.info("Found chat widget ({}) '{}'.", isPricePrompt ? "price" : "quantity", promptWidget.getId()); + + // 1. First attempt: Click Copilot's prompt button if visible, or press hotkey + Widget copilotButton = Rs2Widget.findWidget("to set to Copilot", null, false); + boolean copilotButtonVisible = copilotButton != null && Rs2Widget.isWidgetVisible(copilotButton.getId()); + + // Parse the suggested value from button text if available (e.g. "Press [E] to set to Copilot price: 979 gp") + long valFromButton = -1; + if (copilotButton != null && copilotButton.getText() != null) { + String btnText = copilotButton.getText().replaceAll("[^0-9]", ""); + if (!btnText.isEmpty()) { + try { + valFromButton = Long.parseLong(btnText); + } catch (Exception ignored) {} + } + } + + if (isMouseMode()) { + if (copilotButtonVisible) { + log.info("Clicking Copilot prompt button '{}' via mouse.", copilotButton.getId()); + Rs2Widget.clickWidget(copilotButton); + } else { + log.info("Copilot prompt button not visible, falling back to hotkey [E]."); + triggerCopilotQuickSet(); + } + sleepUntil(this::hasChatboxInput, 1500); + } else { + // Hotkey mode: Strictly use hotkey [E] without mouse clicks + log.info("Selecting Copilot suggestion via hotkey [E]."); + triggerCopilotQuickSet(); + + // If hotkey didn't populate within 600ms, set directly via Copilot offerHandler without mouse + if (!sleepUntil(this::hasChatboxInput, 600)) { + if (valFromButton > 0) { + log.info("Setting chatbox value ({}) directly from Copilot suggestion without mouse.", valFromButton); + setCopilotChatboxValueDirectly(valFromButton); + sleepUntil(this::hasChatboxInput, 600); + } + } + } + + // Fallback: If input is still not populated, extract suggestion value and set directly without typing + if (!hasChatboxInput()) { + long val = valFromButton; + + // If not found from button text, check currentSuggestion (ensuring it matches the offer screen item) + if (val <= 0) { + Object currentSuggestion = getSuggestion(suggestionManager); + if (currentSuggestion != null) { + try { + int currentOfferItemId = Microbot.getClient().getVarpValue(1151); + Method getItemIdMethod = currentSuggestion.getClass().getMethod("getItemId"); + int suggestionItemId = (Integer) getItemIdMethod.invoke(currentSuggestion); + if (currentOfferItemId <= 0 || currentOfferItemId == suggestionItemId) { + if (isPricePrompt) { + Method getPriceMethod = currentSuggestion.getClass().getMethod("getPrice"); + val = (Long) getPriceMethod.invoke(currentSuggestion); + } else if (isQuantityPrompt) { + Method getQuantityMethod = currentSuggestion.getClass().getMethod("getQuantity"); + val = (Integer) getQuantityMethod.invoke(currentSuggestion); + } + } else { + log.warn("Suggestion itemId ({}) does not match offer screen itemId ({})! Skipping suggestion value.", + suggestionItemId, currentOfferItemId); + } + } catch (Exception e) { + log.error("Failed to read suggestion value: {}", e.getMessage()); + } + } + } + + if (val > 0) { + log.info("Setting {} value directly on client thread: {}", isPricePrompt ? "price" : "quantity", val); + setCopilotChatboxValueDirectly(val); + sleepUntil(this::hasChatboxInput, 800); + } + } + + // Check if chatbox input was successfully populated + if (!hasChatboxInput()) { + log.warn("Failed to populate {} input! Cancelling prompt and backing out to GE overview.", + isPricePrompt ? "price" : "quantity"); + Rs2Keyboard.keyPress(KeyEvent.VK_ESCAPE); + sleep(200, 400); + backToOverview(); + return true; + } + + // Submit the value + sleep(KEY_PRESS_DELAY_MIN, KEY_PRESS_DELAY_MAX); + Rs2Keyboard.keyPress(KeyEvent.VK_ENTER); + sleepUntil(() -> !Rs2Widget.isWidgetVisible(promptWidget.getId()), 2500); + offerScreenActionCount++; + lastActionTime = System.currentTimeMillis(); + actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); return true; - } - Rs2Keyboard.keyPress(KeyEvent.VK_ENTER); - lastActionTime = System.currentTimeMillis(); - actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); - return true; + } + + return false; } private boolean checkAndClickHighlightedWidgets() @@ -426,16 +830,48 @@ private boolean checkAndClickHighlightedWidgets() if (isHighlightedVisible) { log.info("Clicking highlighted widget: {}", highlightedWidget.getId()); + // If GE close button is highlighted (container 30474242 or close button dynamic child) + if (highlightedWidget.getId() == 30474242 && Rs2GrandExchange.isOpen()) { + Rs2GrandExchange.closeExchange(); + sleepUntil(() -> !Rs2GrandExchange.isOpen(), 2500); + lastActionTime = currentTime; + actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); + return true; + } + // If Bank close button is highlighted (container 786434 or close button dynamic child) + if (highlightedWidget.getId() == 786434 && Rs2Bank.isOpen()) { + Rs2Bank.closeBank(); + sleepUntil(() -> !Rs2Bank.isOpen(), 2500); + lastActionTime = currentTime; + actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); + return true; + } + // If GE is on offer screen and has "Too much money!" warning, back out immediately + if (isOfferScreenOpen() && Rs2Widget.hasWidget("Too much money")) { + log.warn("Offer has 'Too much money!' error. Backing out to GE overview."); + backToOverview(); + return true; + } + Rs2Widget.clickWidget(highlightedWidget); Rs2Random.wait(100, 200); lastActionTime = currentTime; actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); - // Sometimes, flipping copilot suggestions cost more than what's available in inventory, we should detect and avoid that + if (isOfferScreenOpen()) { + offerScreenActionCount++; + } + + // If confirming an offer, dismiss any price warning dialog and wait for offer screen to close String[] actions = highlightedWidget.getActions(); - if (highlightedWidget.getText() != null && highlightedWidget.getText().contains("Confirm") || (actions != null && actions.length > 0 && actions[0].contains("Confirm"))) { - if (!sleepUntil(() -> !Rs2GrandExchange.isOfferScreenOpen())) { - Rs2GrandExchange.backToOverview(); + boolean isConfirm = (highlightedWidget.getText() != null && highlightedWidget.getText().contains("Confirm")) + || (actions != null && Arrays.stream(actions).filter(Objects::nonNull).anyMatch(a -> a.contains("Confirm"))); + if (isConfirm) { + if (sleepUntil(() -> Rs2Widget.hasWidget("Your offer is much") || Rs2Widget.hasWidget("Are you sure"), 1000)) { + Rs2Widget.clickWidget("Yes"); + } + if (!sleepUntil(() -> !isOfferScreenOpen(), 4000)) { + backToOverview(); return false; } } @@ -450,4 +886,78 @@ private boolean checkAndClickHighlightedWidgets() return false; } -} + + private boolean checkAndInteractHighlightedNpc() + { + if (Rs2GrandExchange.isOpen() || Rs2Bank.isOpen()) return false; + long currentTime = System.currentTimeMillis(); + if (currentTime - lastActionTime < actionCooldown) return false; + if (flippingCopilot == null || highlightController == null) return false; + + try { + List highlightOverlays = getHighlightOverlays(highlightController); + if (highlightOverlays == null) return false; + + for (Object overlay : highlightOverlays) { + if (overlay != null && overlay.getClass().getSimpleName().contains("NpcHighlightOverlay")) { + Field npcField = overlay.getClass().getDeclaredField("npc"); + npcField.setAccessible(true); + NPC npc = (NPC) npcField.get(overlay); + if (npc != null) { + String name = new Rs2NpcModel(npc).getName(); + log.info("Found highlighted NPC: {}", name); + if (Rs2Npc.hasAction(npc.getId(), "Bank") || (name != null && name.toLowerCase().contains("bank"))) { + Rs2Npc.interact(npc, "Bank"); + sleepUntil(Rs2Bank::isOpen, 3000); + } else { + Rs2Npc.interact(npc, "Exchange"); + sleepUntil(Rs2GrandExchange::isOpen, 3000); + } + lastActionTime = currentTime; + actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); + return true; + } + } + } + } catch (Exception e) { + log.error("Could not interact with highlighted NPC: {}", e.getMessage()); + } + return false; + } + + private boolean handleBankIfNeeded() + { + if (!Rs2Bank.isOpen()) return false; + + Object currentSuggestion = getSuggestion(suggestionManager); + if (currentSuggestion != null) { + try { + Method isSellMethod = currentSuggestion.getClass().getMethod("isSellSuggestion"); + boolean isSell = (Boolean) isSellMethod.invoke(currentSuggestion); + if (isSell) { + Method getItemIdMethod = currentSuggestion.getClass().getMethod("getItemId"); + int itemId = (Integer) getItemIdMethod.invoke(currentSuggestion); + Method getQuantityMethod = currentSuggestion.getClass().getMethod("getQuantity"); + int qty = (Integer) getQuantityMethod.invoke(currentSuggestion); + + int notedId = Rs2ItemModel.getNotedId(itemId); + if (Rs2Inventory.hasItem(itemId) && !Rs2Inventory.hasItem(notedId) && qty > 27) { + Rs2Bank.depositAll(itemId); + sleepUntil(() -> !Rs2Inventory.hasItem(itemId), 2000); + } + if (Rs2Bank.hasItem(itemId)) { + log.info("Withdrawing suggested item from bank: {} qty {}", itemId, qty); + Rs2Bank.withdrawX(true, itemId, qty); + sleepUntil(() -> Rs2Inventory.hasItem(itemId) || Rs2Inventory.hasItem(notedId), 2500); + Rs2Bank.closeBank(); + sleepUntil(() -> !Rs2Bank.isOpen(), 2500); + return true; + } + } + } catch (Exception e) { + log.error("Could not handle bank suggestion: {}", e.getMessage()); + } + } + return false; + } +} \ No newline at end of file From a3c6338ebac05ef9b9367567618161ec35664e8f Mon Sep 17 00:00:00 2001 From: Joinkiee Date: Sun, 6 Sep 2026 20:58:44 +1000 Subject: [PATCH 02/16] fix(geflipper): remove bank item withdrawal and restrict NPC interaction to GE clerks --- .../microbot/geflipper/FlipperScript.java | 58 ++++--------------- 1 file changed, 10 insertions(+), 48 deletions(-) diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java index 9bca9c5e6e..dd45a04b4a 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java @@ -20,7 +20,6 @@ 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.math.Rs2Random; import net.runelite.client.plugins.microbot.util.menu.NewMenuEntry; @@ -191,8 +190,12 @@ public boolean run() { offerScreenActionCount = 0; } - // 1. If bank is open, handle bank withdrawals - if (handleBankIfNeeded()) return; + // 1. If bank is open, close it (only coins are handled from bank at startup) + if (Rs2Bank.isOpen()) { + Rs2Bank.closeBank(); + sleepUntil(() -> !Rs2Bank.isOpen(), 2500); + return; + } // 2. Check for Copilot price/quantity messages in chat if (checkAndPressCopilotKeybind()) return; @@ -903,16 +906,11 @@ private boolean checkAndInteractHighlightedNpc() Field npcField = overlay.getClass().getDeclaredField("npc"); npcField.setAccessible(true); NPC npc = (NPC) npcField.get(overlay); - if (npc != null) { + if (npc != null && Rs2Npc.hasAction(npc.getId(), "Exchange")) { String name = new Rs2NpcModel(npc).getName(); - log.info("Found highlighted NPC: {}", name); - if (Rs2Npc.hasAction(npc.getId(), "Bank") || (name != null && name.toLowerCase().contains("bank"))) { - Rs2Npc.interact(npc, "Bank"); - sleepUntil(Rs2Bank::isOpen, 3000); - } else { - Rs2Npc.interact(npc, "Exchange"); - sleepUntil(Rs2GrandExchange::isOpen, 3000); - } + log.info("Found highlighted GE NPC: {}", name); + Rs2Npc.interact(npc, "Exchange"); + sleepUntil(Rs2GrandExchange::isOpen, 3000); lastActionTime = currentTime; actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); return true; @@ -924,40 +922,4 @@ private boolean checkAndInteractHighlightedNpc() } return false; } - - private boolean handleBankIfNeeded() - { - if (!Rs2Bank.isOpen()) return false; - - Object currentSuggestion = getSuggestion(suggestionManager); - if (currentSuggestion != null) { - try { - Method isSellMethod = currentSuggestion.getClass().getMethod("isSellSuggestion"); - boolean isSell = (Boolean) isSellMethod.invoke(currentSuggestion); - if (isSell) { - Method getItemIdMethod = currentSuggestion.getClass().getMethod("getItemId"); - int itemId = (Integer) getItemIdMethod.invoke(currentSuggestion); - Method getQuantityMethod = currentSuggestion.getClass().getMethod("getQuantity"); - int qty = (Integer) getQuantityMethod.invoke(currentSuggestion); - - int notedId = Rs2ItemModel.getNotedId(itemId); - if (Rs2Inventory.hasItem(itemId) && !Rs2Inventory.hasItem(notedId) && qty > 27) { - Rs2Bank.depositAll(itemId); - sleepUntil(() -> !Rs2Inventory.hasItem(itemId), 2000); - } - if (Rs2Bank.hasItem(itemId)) { - log.info("Withdrawing suggested item from bank: {} qty {}", itemId, qty); - Rs2Bank.withdrawX(true, itemId, qty); - sleepUntil(() -> Rs2Inventory.hasItem(itemId) || Rs2Inventory.hasItem(notedId), 2500); - Rs2Bank.closeBank(); - sleepUntil(() -> !Rs2Bank.isOpen(), 2500); - return true; - } - } - } catch (Exception e) { - log.error("Could not handle bank suggestion: {}", e.getMessage()); - } - } - return false; - } } \ No newline at end of file From c884e65f454ffb8049c68cd6982d127f4d407694 Mon Sep 17 00:00:00 2001 From: Joinkiee Date: Mon, 7 Sep 2026 16:25:16 +1000 Subject: [PATCH 03/16] fix(geflipper): support sub-region highlight clicks, price dialog confirmation, and chatbox input fallback (v1.2.6) - Bump FlipperPlugin version to 1.2.6 - Add HighlightTarget to calculate exact clickBounds using Flipping Copilot's relativeBounds overlay offsets, ensuring clicks hit the intended button sub-region rather than the parent widget bounds - Detect and automatically confirm in-game Grand Exchange price warning dialogs ('Your offer is much...' / 'Are you sure...') before actions and after clicking Confirm - Add keyboard typing fallback via Rs2Keyboard when Copilot direct chatbox setting is delayed or empty - Add prompt cancellation via ESC if chatbox input cannot be populated, preventing unwanted text leaking into public chat - Safely verify offer screen closure post-confirmation --- .../microbot/geflipper/FlipperPlugin.java | 2 +- .../microbot/geflipper/FlipperScript.java | 189 ++++++++++++------ 2 files changed, 124 insertions(+), 67 deletions(-) diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java index a0b58b7506..0b7ebefc81 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java @@ -25,7 +25,7 @@ isExternal = PluginConstants.IS_EXTERNAL ) public class FlipperPlugin extends Plugin { - public static final String version = "1.2.5"; + public static final String version = "1.2.6"; @Inject private Client client; @Inject diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java index dd45a04b4a..ef56caa8ec 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java @@ -460,7 +460,7 @@ private void setCopilotChatboxValueDirectly(long val) { m.invoke(offerHandler, v); } catch (Exception ignored) {} }); - break; + return; } } } @@ -469,19 +469,6 @@ private void setCopilotChatboxValueDirectly(long val) { log.debug("Could not set chatbox value via offerHandler: {}", e.getMessage()); } } - - // 2. Direct client-thread update (identical to Copilot's OfferHandler.setChatboxValue) - final long finalVal = val; - Microbot.getClientThread().invokeLater(() -> { - try { - Widget widget = Microbot.getClient().getWidget(10616876); - if (widget == null) widget = Microbot.getClient().getWidget(162, 44); - if (widget != null) { - widget.setText(finalVal + "*"); - } - Microbot.getClient().setVarcStrValue(359, String.valueOf(finalVal)); - } catch (Exception ignored) {} - }); } private Object getSuggestion(Object suggestionManager) @@ -534,65 +521,111 @@ private List getHighlightOverlays(Object highlightController) } } - private List getHighlightWidgets(Object highlightController) - { - final List highlightWidgets = new ArrayList<>(); - if (highlightController == null) - { - return highlightWidgets; + public static class HighlightTarget { + private final Widget widget; + private final Rectangle relativeBounds; + + public HighlightTarget(Widget widget, Rectangle relativeBounds) { + this.widget = widget; + this.relativeBounds = relativeBounds; } - List highlightOverlays = getHighlightOverlays(highlightController); - if (highlightOverlays == null) return highlightWidgets; + public Widget getWidget() { + return widget; + } - for (Object highlightOverlay : highlightOverlays) - { - try - { - Field widgetField = highlightOverlay.getClass().getDeclaredField("widget"); - widgetField.setAccessible(true); - highlightWidgets.add((Widget) widgetField.get(highlightOverlay)); - } - catch (NoSuchFieldException e) - { - // Non-widget overlays like NpcHighlightOverlay are handled separately + public Rectangle getRelativeBounds() { + return relativeBounds; + } + + public Rectangle getClickBounds() { + if (widget == null) return null; + Rectangle b = widget.getBounds(); + if (b == null) return null; + if (relativeBounds == null) return b; + return new Rectangle(b.x + relativeBounds.x, b.y + relativeBounds.y, relativeBounds.width, relativeBounds.height); + } + + public boolean isConfirmTarget() { + if (widget != null) { + String text = widget.getText(); + if (text != null && text.contains("Confirm")) return true; + String[] actions = widget.getActions(); + if (actions != null && Arrays.stream(actions).filter(Objects::nonNull).anyMatch(a -> a.contains("Confirm"))) { + return true; + } } - catch (Exception e) - { - log.error("Could not get widget from overlay: {} - ", e.getMessage(), e); + if (relativeBounds != null && relativeBounds.width >= 120 && relativeBounds.height >= 30) { + return true; } + return false; } - - return highlightWidgets; } - private Widget getWidgetFromOverlay(Object highlightController, String suggestionType) - { + private List getHighlightTargets(Object highlightController) { + List targets = new ArrayList<>(); + if (highlightController == null) return targets; List highlightOverlays = getHighlightOverlays(highlightController); - if (highlightOverlays == null || highlightOverlays.isEmpty()) - { - return null; + if (highlightOverlays == null) return targets; + + for (Object highlightOverlay : highlightOverlays) { + if (highlightOverlay == null) continue; + try { + Field widgetField = highlightOverlay.getClass().getDeclaredField("widget"); + widgetField.setAccessible(true); + Widget widget = (Widget) widgetField.get(highlightOverlay); + if (widget == null) continue; + + Rectangle relativeBounds = null; + try { + Field relBoundsField = highlightOverlay.getClass().getDeclaredField("relativeBounds"); + relBoundsField.setAccessible(true); + relativeBounds = (Rectangle) relBoundsField.get(highlightOverlay); + } catch (NoSuchFieldException ignored) {} + + targets.add(new HighlightTarget(widget, relativeBounds)); + } catch (NoSuchFieldException ignored) { + } catch (Exception e) { + log.error("Could not get target from overlay: {} - ", e.getMessage(), e); + } } + return targets; + } - if (Objects.equals(suggestionType, "abort") || Objects.equals(suggestionType, "modify")) - { - return getHighlightWidgets(highlightController).stream() - .filter(Objects::nonNull) - // Filter to "home" grand exchange slot widgets - .filter(widget -> Arrays.stream(grandExchangeSlotIds).anyMatch(id -> id == widget.getId())) + private HighlightTarget getTargetFromOverlay(Object highlightController, String suggestionType) { + List targets = getHighlightTargets(highlightController); + if (targets.isEmpty()) return null; + + if (Objects.equals(suggestionType, "abort") || Objects.equals(suggestionType, "modify")) { + return targets.stream() + .filter(t -> t.getWidget() != null) + .filter(t -> Arrays.stream(grandExchangeSlotIds).anyMatch(id -> id == t.getWidget().getId())) .findFirst() .orElse(null); - } - else - { - // For other suggestion types, we can return the first highlighted widget - return getHighlightWidgets(highlightController).stream() - .filter(Objects::nonNull) + } else { + return targets.stream() + .filter(t -> t.getWidget() != null && Rs2Widget.isWidgetVisible(t.getWidget().getId())) .findFirst() .orElse(null); } } + private List getHighlightWidgets(Object highlightController) { + List targets = getHighlightTargets(highlightController); + List highlightWidgets = new ArrayList<>(); + for (HighlightTarget target : targets) { + if (target.getWidget() != null) { + highlightWidgets.add(target.getWidget()); + } + } + return highlightWidgets; + } + + private Widget getWidgetFromOverlay(Object highlightController, String suggestionType) { + HighlightTarget target = getTargetFromOverlay(highlightController, suggestionType); + return target != null ? target.getWidget() : null; + } + private boolean checkAndAbortOrModifyIfNeeded() { if (!Rs2GrandExchange.isOpen() || isOfferScreenOpen()) return false; @@ -795,11 +828,18 @@ private boolean checkAndPressCopilotKeybind() { setCopilotChatboxValueDirectly(val); sleepUntil(this::hasChatboxInput, 800); } + + // Fallback: If still not populated, type the value into chatbox + if (!hasChatboxInput() && val > 0) { + log.info("Typing {} value into chatbox: {}", isPricePrompt ? "price" : "quantity", val); + Rs2Keyboard.typeString(String.valueOf(val)); + sleepUntil(this::hasChatboxInput, 1000); + } } // Check if chatbox input was successfully populated if (!hasChatboxInput()) { - log.warn("Failed to populate {} input! Cancelling prompt and backing out to GE overview.", + log.warn("Failed to populate {} input! Cancelling prompt with ESC to prevent chat spam and backing out to GE overview.", isPricePrompt ? "price" : "quantity"); Rs2Keyboard.keyPress(KeyEvent.VK_ESCAPE); sleep(200, 400); @@ -828,11 +868,21 @@ private boolean checkAndClickHighlightedWidgets() if (flippingCopilot == null || highlightController == null) return false; try { - Widget highlightedWidget = getWidgetFromOverlay(highlightController, ""); - boolean isHighlightedVisible = highlightedWidget != null && Rs2Widget.isWidgetVisible(highlightedWidget.getId()); + if (Rs2Widget.hasWidget("Your offer is much") || Rs2Widget.hasWidget("Are you sure")) { + log.info("Price warning dialog detected ('Your offer is much' / 'Are you sure'). Clicking 'Yes' to confirm..."); + Rs2Widget.clickWidget("Yes"); + lastActionTime = currentTime; + actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); + return true; + } + + HighlightTarget target = getTargetFromOverlay(highlightController, ""); + if (target != null && target.getWidget() != null && Rs2Widget.isWidgetVisible(target.getWidget().getId())) { + Widget highlightedWidget = target.getWidget(); + Rectangle clickBounds = target.getClickBounds(); + log.info("Processing highlighted target: widgetId={}, clickBounds={}, relativeBounds={}", + highlightedWidget.getId(), clickBounds, target.getRelativeBounds()); - if (isHighlightedVisible) { - log.info("Clicking highlighted widget: {}", highlightedWidget.getId()); // If GE close button is highlighted (container 30474242 or close button dynamic child) if (highlightedWidget.getId() == 30474242 && Rs2GrandExchange.isOpen()) { Rs2GrandExchange.closeExchange(); @@ -856,27 +906,34 @@ private boolean checkAndClickHighlightedWidgets() return true; } - Rs2Widget.clickWidget(highlightedWidget); + boolean isConfirm = target.isConfirmTarget(); + + if (clickBounds != null && Rs2UiHelper.isRectangleWithinCanvas(clickBounds)) { + Microbot.getMouse().click(clickBounds); + } else { + Rs2Widget.clickWidget(highlightedWidget); + } Rs2Random.wait(100, 200); lastActionTime = currentTime; - actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); + actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); if (isOfferScreenOpen()) { offerScreenActionCount++; } // If confirming an offer, dismiss any price warning dialog and wait for offer screen to close - String[] actions = highlightedWidget.getActions(); - boolean isConfirm = (highlightedWidget.getText() != null && highlightedWidget.getText().contains("Confirm")) - || (actions != null && Arrays.stream(actions).filter(Objects::nonNull).anyMatch(a -> a.contains("Confirm"))); if (isConfirm) { - if (sleepUntil(() -> Rs2Widget.hasWidget("Your offer is much") || Rs2Widget.hasWidget("Are you sure"), 1000)) { + log.info("Clicked Confirm button. Checking for warning dialog or waiting for offer screen to close..."); + if (sleepUntil(() -> Rs2Widget.hasWidget("Your offer is much") || Rs2Widget.hasWidget("Are you sure"), 1200)) { + log.info("Warning dialog appeared ('Your offer is much' / 'Are you sure'). Confirming 'Yes'..."); Rs2Widget.clickWidget("Yes"); } if (!sleepUntil(() -> !isOfferScreenOpen(), 4000)) { + log.warn("Offer screen did not close after confirm. Backing out to overview."); backToOverview(); return false; } + log.info("Offer placed successfully; offer screen closed."); } return true; From d59c93d42a28c390e4c8342f70f60b615a8207ab Mon Sep 17 00:00:00 2001 From: Joinkiee Date: Mon, 7 Sep 2026 22:04:09 +1000 Subject: [PATCH 04/16] fix(geflipper): dynamically detect slotActionSwap and support offer screen abort (v1.2.7) - Bump FlipperPlugin version to 1.2.7 - Dynamically detect Flipping Copilot 'slotActionSwap' setting via reflection and ConfigManager - Add multi-tier getOfferScreenAbortButton to locate and click the abort button on offer details screen - Support dual abort paths: overview left-click swap when slotActionSwap is true, and offer screen abort when false - Display live Slot Swap status on the client overlay - Handle abort confirmation dialogs and eliminate infinite overview-offer screen loop --- .../microbot/geflipper/FlipperOverlay.java | 7 + .../microbot/geflipper/FlipperPlugin.java | 6 +- .../microbot/geflipper/FlipperScript.java | 181 ++++++++++++++++-- 3 files changed, 173 insertions(+), 21 deletions(-) diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperOverlay.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperOverlay.java index 1bdd14cdf2..f00d3c77c2 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperOverlay.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperOverlay.java @@ -274,6 +274,13 @@ public Dimension render(Graphics2D graphics) { .build()); } + boolean slotSwap = plugin.getFlipperScript() != null && plugin.getFlipperScript().isSlotActionSwapEnabled(); + panelComponent.getChildren().add(LineComponent.builder() + .left("Slot Swap:") + .right(slotSwap ? "ON" : "OFF (Screen Abort)") + .rightColor(slotSwap ? POSITIVE_COLOR : Color.ORANGE) + .build()); + return super.render(graphics); } } diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java index 0b7ebefc81..dc6ec74e14 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java @@ -25,11 +25,15 @@ isExternal = PluginConstants.IS_EXTERNAL ) public class FlipperPlugin extends Plugin { - public static final String version = "1.2.6"; + public static final String version = "1.2.7"; @Inject private Client client; @Inject private FlipperScript flipperScript; + + public FlipperScript getFlipperScript() { + return flipperScript; + } @Inject private net.runelite.client.plugins.microbot.geflipper.FlipperConfig config; @Inject diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java index ef56caa8ec..2e079b4dfc 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java @@ -1,7 +1,6 @@ package net.runelite.client.plugins.microbot.geflipper; import com.google.inject.Inject; -import lombok.extern.slf4j.Slf4j; import net.runelite.api.MenuAction; import net.runelite.api.NPC; import net.runelite.api.coords.WorldArea; @@ -45,8 +44,8 @@ enum State { MONITORING_COPILOT } -@Slf4j public class FlipperScript extends Script { + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(FlipperScript.class); private static final int DEFAULT_ACTION_COOLDOWN = 1200; private static final int ACTION_COOLDOWN_VARIANCE = 600; private static final int DEFAULT_INTERACTION_TIMEOUT = 33000; @@ -153,21 +152,38 @@ public boolean run() { return; } - // If on offer screen and Copilot suggests ABORT, abort or back out immediately + // If on offer screen and Copilot suggests ABORT, abort via offer screen button if (suggestionManager != null) { try { Object currentSuggestion = getSuggestion(suggestionManager); if (currentSuggestion != null) { Method isAbortMethod = currentSuggestion.getClass().getMethod("isAbortSuggestion"); if ((Boolean) isAbortMethod.invoke(currentSuggestion)) { - Widget abortBtn = Rs2Widget.findWidget("Abort offer"); + Widget abortBtn = getOfferScreenAbortButton(); if (abortBtn != null && Rs2Widget.isWidgetVisible(abortBtn.getId())) { log.info("Aborting offer via offer screen button '{}'", abortBtn.getId()); Rs2Widget.clickWidget(abortBtn); - sleep(200, 400); + sleep(300, 500); + + // Check for confirmation dialog ('Are you sure...') + if (sleepUntil(() -> Rs2Widget.hasWidget("Are you sure") || Rs2Widget.hasWidget("Your offer is much"), 1200)) { + log.info("Abort confirmation dialog detected. Confirming 'Yes'..."); + Rs2Widget.clickWidget("Yes"); + sleep(200, 400); + } + + // Wait for abort to register, then back to overview + sleepUntil(() -> !isOfferScreenOpen() || getOfferScreenAbortButton() == null, 2500); backToOverview(); } else { - log.info("Abort suggested while on offer screen - backing out to overview."); + // Check if already aborted / cancelled on offer screen + Widget statusWidget = Rs2Widget.getWidget(InterfaceID.GeOffers.DETAILS_STATUS); + String statusText = statusWidget != null ? statusWidget.getText() : ""; + if (statusText != null && (statusText.toLowerCase().contains("cancelled") || statusText.toLowerCase().contains("aborted"))) { + log.info("Offer already cancelled on offer screen. Returning to overview."); + } else { + log.warn("Abort button not found on offer screen. Returning to overview."); + } backToOverview(); } lastActionTime = currentTime; @@ -318,6 +334,99 @@ private void backToOverview() { actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); } + public boolean isSlotActionSwapEnabled() { + if (flippingCopilot != null) { + try { + Field cfgField = flippingCopilot.getClass().getDeclaredField("config"); + cfgField.setAccessible(true); + Object copilotConfig = cfgField.get(flippingCopilot); + if (copilotConfig != null) { + Method m = copilotConfig.getClass().getMethod("slotActionSwap"); + Object val = m.invoke(copilotConfig); + if (val instanceof Boolean) { + return (Boolean) val; + } + } + } catch (Exception ignored) {} + } + try { + if (Microbot.getConfigManager() != null) { + String val = Microbot.getConfigManager().getConfiguration("flippingcopilot", "slotActionSwap"); + if (val != null) { + return Boolean.parseBoolean(val); + } + } + } catch (Exception ignored) {} + return true; + } + + private Widget getOfferScreenAbortButton() { + // 1. Direct widget ID for abort button on GE offer details screen (Interface 465, child 22 / DETAILS_GRAPHIC6) + Widget abortBtn = Rs2Widget.getWidget(InterfaceID.GeOffers.DETAILS_GRAPHIC6); + if (abortBtn != null && Rs2Widget.isWidgetVisible(abortBtn.getId())) { + return abortBtn; + } + abortBtn = Rs2Widget.getWidget(InterfaceID.GE_OFFERS, 22); + if (abortBtn != null && Rs2Widget.isWidgetVisible(abortBtn.getId())) { + return abortBtn; + } + + // 2. Search for any widget within GE_OFFERS with an "Abort" action + try { + java.util.Map actionWidgets = Rs2Widget.findWidgetsWithAction("Abort", InterfaceID.GE_OFFERS, false); + if (actionWidgets != null && !actionWidgets.isEmpty()) { + for (Widget w : actionWidgets.keySet()) { + if (w != null && Rs2Widget.isWidgetVisible(w.getId())) { + return w; + } + } + } + } catch (Exception ignored) {} + + // 3. Search children of DETAILS container (InterfaceID.GeOffers.DETAILS) + try { + Widget detailsContainer = Rs2Widget.getWidget(InterfaceID.GeOffers.DETAILS); + if (detailsContainer != null) { + Widget[] children = detailsContainer.getChildren(); + if (children != null) { + for (Widget child : children) { + if (child != null && Rs2Widget.isWidgetVisible(child.getId()) && child.getActions() != null) { + for (String action : child.getActions()) { + if (action != null && action.toLowerCase().contains("abort")) { + return child; + } + } + } + } + } + Widget[] dynamicChildren = detailsContainer.getDynamicChildren(); + if (dynamicChildren != null) { + for (Widget child : dynamicChildren) { + if (child != null && Rs2Widget.isWidgetVisible(child.getId()) && child.getActions() != null) { + for (String action : child.getActions()) { + if (action != null && action.toLowerCase().contains("abort")) { + return child; + } + } + } + } + } + } + } catch (Exception ignored) {} + + // 4. Search by widget text as final fallback + abortBtn = Rs2Widget.findWidget("Abort offer"); + if (abortBtn != null && Rs2Widget.isWidgetVisible(abortBtn.getId())) { + return abortBtn; + } + abortBtn = Rs2Widget.findWidget("Abort"); + if (abortBtn != null && Rs2Widget.isWidgetVisible(abortBtn.getId())) { + return abortBtn; + } + + return null; + } + private boolean hasChatboxInput() { Widget inputWidget = Rs2Widget.getWidget(10616876); if (inputWidget == null) inputWidget = Rs2Widget.getWidget(162, 44); @@ -660,20 +769,32 @@ private boolean checkAndAbortOrModifyIfNeeded() { if (isAbort) { - log.info("Executing suggestion ABORT: sending Abort offer on slot widget {}", abortWidget.getId()); - NewMenuEntry abortEntry = new NewMenuEntry() - .option("Abort offer") - .target("") - .identifier(2) - .type(MenuAction.CC_OP) - .param0(2) - .param1(abortWidget.getId()) - .itemId(-1) - .forceLeftClick(false); - Rectangle bounds = abortWidget.getBounds() != null && Rs2UiHelper.isRectangleWithinCanvas(abortWidget.getBounds()) - ? abortWidget.getBounds() - : Rs2UiHelper.getDefaultRectangle(); - Microbot.doInvoke(abortEntry, bounds); + boolean slotActionSwap = isSlotActionSwapEnabled(); + log.info("Executing suggestion ABORT on slot widget {} (slotActionSwap={})", abortWidget.getId(), slotActionSwap); + if (slotActionSwap) + { + NewMenuEntry abortEntry = new NewMenuEntry() + .option("Abort offer") + .target("") + .identifier(2) + .type(MenuAction.CC_OP) + .param0(2) + .param1(abortWidget.getId()) + .itemId(-1) + .forceLeftClick(false); + Rectangle bounds = abortWidget.getBounds() != null && Rs2UiHelper.isRectangleWithinCanvas(abortWidget.getBounds()) + ? abortWidget.getBounds() + : Rs2UiHelper.getDefaultRectangle(); + Microbot.doInvoke(abortEntry, bounds); + } + else + { + // When slotActionSwap is OFF, left-clicking the slot widget in OSRS opens "View offer". + // We open the offer screen and let getOfferScreenAbortButton perform the abort reliably. + log.info("slotActionSwap is disabled: opening slot widget {} to abort from offer screen.", abortWidget.getId()); + Rs2Widget.clickWidget(abortWidget); + sleepUntil(this::isOfferScreenOpen, 2500); + } lastActionTime = System.currentTimeMillis(); actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); return true; @@ -908,6 +1029,22 @@ private boolean checkAndClickHighlightedWidgets() boolean isConfirm = target.isConfirmTarget(); + boolean isSlotWidget = Arrays.stream(grandExchangeSlotIds).anyMatch(id -> id == highlightedWidget.getId()); + boolean isAbortOnSlot = false; + if (isSlotWidget && suggestionManager != null) { + try { + Object currentSuggestion = getSuggestion(suggestionManager); + if (currentSuggestion != null) { + Method isAbortMethod = currentSuggestion.getClass().getMethod("isAbortSuggestion"); + isAbortOnSlot = (Boolean) isAbortMethod.invoke(currentSuggestion); + } + } catch (Exception ignored) {} + } + if (isAbortOnSlot && !isSlotActionSwapEnabled()) { + log.info("Highlighted GE slot {} for abort with slotActionSwap=false; clicking slot to open offer screen.", + highlightedWidget.getId()); + } + if (clickBounds != null && Rs2UiHelper.isRectangleWithinCanvas(clickBounds)) { Microbot.getMouse().click(clickBounds); } else { @@ -917,6 +1054,10 @@ private boolean checkAndClickHighlightedWidgets() lastActionTime = currentTime; actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); + if (isAbortOnSlot && !isSlotActionSwapEnabled()) { + sleepUntil(this::isOfferScreenOpen, 2500); + } + if (isOfferScreenOpen()) { offerScreenActionCount++; } From 93e8651c3d1d1b617bb2b63fc7ab2199b3deb849 Mon Sep 17 00:00:00 2001 From: Joinkiee Date: Mon, 7 Sep 2026 22:37:01 +1000 Subject: [PATCH 05/16] feat(geflipper): automatically turn on Flipping Copilot slotActionSwap setting (v1.2.8) --- .../microbot/geflipper/FlipperPlugin.java | 17 ++++++++- .../microbot/geflipper/FlipperScript.java | 35 ++++++++++++++++--- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java index dc6ec74e14..6ce97c3cf7 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java @@ -25,7 +25,7 @@ isExternal = PluginConstants.IS_EXTERNAL ) public class FlipperPlugin extends Plugin { - public static final String version = "1.2.7"; + public static final String version = "1.2.8"; @Inject private Client client; @Inject @@ -40,12 +40,26 @@ public FlipperScript getFlipperScript() { private OverlayManager overlayManager; @Inject private FlipperOverlay overlay; + @Inject + private ConfigManager configManager; @Provides net.runelite.client.plugins.microbot.geflipper.FlipperConfig provideConfig(ConfigManager configManager) { return configManager.getConfig(FlipperConfig.class); } + private void ensureCopilotSlotActionSwap() { + try { + if (configManager != null) { + String current = configManager.getConfiguration("flippingcopilot", "slotActionSwap"); + if (!"true".equalsIgnoreCase(current)) { + configManager.setConfiguration("flippingcopilot", "slotActionSwap", true); + } + } + } catch (Throwable ignored) { + } + } + private void disableGameChatAppender() { try { org.slf4j.Logger slf4jLogger = org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); @@ -67,6 +81,7 @@ private void disableGameChatAppender() { @Override protected void startUp() throws AWTException{ + ensureCopilotSlotActionSwap(); disableGameChatAppender(); if (overlayManager != null && overlay != null) { overlayManager.add(overlay); diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java index 2e079b4dfc..f682d499e3 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java @@ -254,13 +254,20 @@ public void shutdown() private boolean initialize() { - if (flippingCopilot != null && suggestionManager != null && highlightController != null) return true; + if (flippingCopilot != null && suggestionManager != null && highlightController != null) { + ensureSlotActionSwapEnabled(); + return true; + } Plugin _flippingCopilot = getFlippingCopilot(); Object _suggestionManager = getSuggestionManager(_flippingCopilot); Object _highlightController = getHighlightController(_flippingCopilot); - return _flippingCopilot != null && _suggestionManager != null && _highlightController != null; + if (_flippingCopilot != null && _suggestionManager != null && _highlightController != null) { + ensureSlotActionSwapEnabled(); + return true; + } + return false; } private Plugin getFlippingCopilot() @@ -360,6 +367,20 @@ public boolean isSlotActionSwapEnabled() { return true; } + public void ensureSlotActionSwapEnabled() { + try { + if (Microbot.getConfigManager() != null) { + String val = Microbot.getConfigManager().getConfiguration("flippingcopilot", "slotActionSwap"); + if (!"true".equalsIgnoreCase(val)) { + log.info("Flipping Copilot 'slotActionSwap' is disabled; automatically enabling it in ConfigManager."); + Microbot.getConfigManager().setConfiguration("flippingcopilot", "slotActionSwap", true); + } + } + } catch (Exception e) { + log.warn("Failed to set flippingcopilot slotActionSwap setting: {}", e.getMessage()); + } + } + private Widget getOfferScreenAbortButton() { // 1. Direct widget ID for abort button on GE offer details screen (Interface 465, child 22 / DETAILS_GRAPHIC6) Widget abortBtn = Rs2Widget.getWidget(InterfaceID.GeOffers.DETAILS_GRAPHIC6); @@ -769,6 +790,7 @@ private boolean checkAndAbortOrModifyIfNeeded() { if (isAbort) { + ensureSlotActionSwapEnabled(); boolean slotActionSwap = isSlotActionSwapEnabled(); log.info("Executing suggestion ABORT on slot widget {} (slotActionSwap={})", abortWidget.getId(), slotActionSwap); if (slotActionSwap) @@ -1040,9 +1062,12 @@ private boolean checkAndClickHighlightedWidgets() } } catch (Exception ignored) {} } - if (isAbortOnSlot && !isSlotActionSwapEnabled()) { - log.info("Highlighted GE slot {} for abort with slotActionSwap=false; clicking slot to open offer screen.", - highlightedWidget.getId()); + if (isAbortOnSlot) { + ensureSlotActionSwapEnabled(); + if (!isSlotActionSwapEnabled()) { + log.info("Highlighted GE slot {} for abort with slotActionSwap=false; clicking slot to open offer screen.", + highlightedWidget.getId()); + } } if (clickBounds != null && Rs2UiHelper.isRectangleWithinCanvas(clickBounds)) { From 6cddb115a6a15f02995e207bb80a56492260c9b7 Mon Sep 17 00:00:00 2001 From: Joinkiee Date: Sat, 12 Sep 2026 09:11:57 +1000 Subject: [PATCH 06/16] chore(geflipper): label plugin build as v1.2.5 Keeps every fix from the 1.2.6-1.2.8 line (dynamic slotActionSwap detection, offer-screen abort, sub-region highlight clicks, price-dialog confirmation, chatbox input fallback, profit/stats overlay) and only changes the reported version string to 1.2.5 for release. --- .../client/plugins/microbot/geflipper/FlipperPlugin.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java index 6ce97c3cf7..56e39821f8 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java @@ -25,7 +25,7 @@ isExternal = PluginConstants.IS_EXTERNAL ) public class FlipperPlugin extends Plugin { - public static final String version = "1.2.8"; + public static final String version = "1.2.5"; @Inject private Client client; @Inject From 0e557b06bd92e6dc513321542485665aaf799608 Mon Sep 17 00:00:00 2001 From: Joinkiee Date: Sat, 12 Sep 2026 09:44:01 +1000 Subject: [PATCH 07/16] feat(geflipper): add 'Auto-Enable Copilot Slot Swap' config toggle (v1.2.5) Adds a user-facing toggle so hand-flippers can stop the plugin forcing Flipping Copilot's slotActionSwap setting on. Default true, preserving existing behaviour. When disabled the plugin respects the user's Copilot setting and falls back to aborting from the offer details screen. --- .../plugins/microbot/geflipper/FlipperConfig.java | 13 +++++++++++++ .../plugins/microbot/geflipper/FlipperScript.java | 3 +++ 2 files changed, 16 insertions(+) diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperConfig.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperConfig.java index acc53f04c3..45e2af494e 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperConfig.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperConfig.java @@ -58,4 +58,17 @@ default SelectionMethod selectionMethod() { default boolean showOverlay() { return true; } + + @ConfigItem( + keyName = "autoEnableSlotSwap", + name = "Auto-Enable Copilot Slot Swap", + description = "Automatically turn on Flipping Copilot's 'Swap slot left-click action' setting, " + + "so offers can be aborted straight from the Grand Exchange overview. " + + "
Turn this OFF if you also hand-flip and want left-clicking a GE slot to open the offer screen. " + + "
When off, the plugin respects your Copilot setting and falls back to aborting from the offer details screen.", + position = 3 + ) + default boolean autoEnableSlotSwap() { + return true; + } } diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java index f682d499e3..07e7c0a2c4 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java @@ -369,6 +369,9 @@ public boolean isSlotActionSwapEnabled() { public void ensureSlotActionSwapEnabled() { try { + if (config != null && !config.autoEnableSlotSwap()) { + return; + } if (Microbot.getConfigManager() != null) { String val = Microbot.getConfigManager().getConfiguration("flippingcopilot", "slotActionSwap"); if (!"true".equalsIgnoreCase(val)) { From dba459ffcf144768cd1d4f652635c85e598cc1fb Mon Sep 17 00:00:00 2001 From: Joinkiee Date: Sat, 12 Sep 2026 12:00:49 +1000 Subject: [PATCH 08/16] fix(geflipper): stop false 'abort button not found' and 'offer screen did not close' warnings Two timing faults produced warnings that all self-recovered, costing a wasted back-out and retry on every occurrence: 1. The offer-screen abort button was looked up once, instantly, right after the slot click. The GE details screen renders a tick later, so the lookup missed and the script backed out of a screen it could have used. It now polls for the button (waitForOfferScreenAbortButton, 2s) before giving up. 2. When the abort suggestion was already satisfied (Copilot drops the abort as soon as it registers), the missing button was logged as a warning. That case is now recognised via isAbortSuggestionSettled() and logged as info. 3. The price warning dialog ('Your offer is much...') was only looked for in the first 1200ms after Confirm. A dialog appearing later was never dismissed, so the offer screen stayed open until the 4s timeout and the offer was abandoned. The dialog is now re-checked for the whole wait (now 6s, exiting early once the screen closes) and swept once more at the deadline. --- .../microbot/geflipper/FlipperScript.java | 64 +++++++++++++++++-- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java index 07e7c0a2c4..5512374bab 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java @@ -159,7 +159,7 @@ public boolean run() { if (currentSuggestion != null) { Method isAbortMethod = currentSuggestion.getClass().getMethod("isAbortSuggestion"); if ((Boolean) isAbortMethod.invoke(currentSuggestion)) { - Widget abortBtn = getOfferScreenAbortButton(); + Widget abortBtn = waitForOfferScreenAbortButton(2000); if (abortBtn != null && Rs2Widget.isWidgetVisible(abortBtn.getId())) { log.info("Aborting offer via offer screen button '{}'", abortBtn.getId()); Rs2Widget.clickWidget(abortBtn); @@ -181,6 +181,10 @@ public boolean run() { String statusText = statusWidget != null ? statusWidget.getText() : ""; if (statusText != null && (statusText.toLowerCase().contains("cancelled") || statusText.toLowerCase().contains("aborted"))) { log.info("Offer already cancelled on offer screen. Returning to overview."); + } else if (isAbortSuggestionSettled(currentSuggestion)) { + // Copilot drops the abort suggestion as soon as the abort + // registers, so a changed suggestion means the work is done. + log.info("Abort suggestion already satisfied; no abort button needed. Returning to overview."); } else { log.warn("Abort button not found on offer screen. Returning to overview."); } @@ -451,6 +455,39 @@ private Widget getOfferScreenAbortButton() { return null; } + /** + * Poll for the abort button instead of checking once. The GE details screen renders a + * tick or two after the slot click, so a single instant lookup reports a false + * "button not found" and the script backs out of a screen it could have used. + */ + private Widget waitForOfferScreenAbortButton(long timeoutMs) { + long deadline = System.currentTimeMillis() + timeoutMs; + Widget btn = getOfferScreenAbortButton(); + while (btn == null && System.currentTimeMillis() < deadline) { + sleep(100, 200); + btn = getOfferScreenAbortButton(); + } + return btn; + } + + /** + * True when Copilot has already moved past the abort we were asked to perform. + * Copilot drops the abort suggestion as soon as the abort registers, so if the + * current suggestion is no longer an abort there is nothing left to click and the + * missing button is expected - not a fault worth warning about. + */ + private boolean isAbortSuggestionSettled(Object previousSuggestion) { + if (suggestionManager == null) return false; + try { + Object current = getSuggestion(suggestionManager); + if (current == null) return true; + Method isAbortMethod = current.getClass().getMethod("isAbortSuggestion"); + return !((Boolean) isAbortMethod.invoke(current)); + } catch (Exception e) { + return false; + } + } + private boolean hasChatboxInput() { Widget inputWidget = Rs2Widget.getWidget(10616876); if (inputWidget == null) inputWidget = Rs2Widget.getWidget(162, 44); @@ -1093,11 +1130,30 @@ private boolean checkAndClickHighlightedWidgets() // If confirming an offer, dismiss any price warning dialog and wait for offer screen to close if (isConfirm) { log.info("Clicked Confirm button. Checking for warning dialog or waiting for offer screen to close..."); - if (sleepUntil(() -> Rs2Widget.hasWidget("Your offer is much") || Rs2Widget.hasWidget("Are you sure"), 1200)) { - log.info("Warning dialog appeared ('Your offer is much' / 'Are you sure'). Confirming 'Yes'..."); + // The price warning dialog can appear later than the first check. Keep + // looking for it for the whole wait: if it is left on screen the offer + // screen never closes and the offer is abandoned. + long confirmDeadline = System.currentTimeMillis() + 6000; + boolean offerScreenClosed = false; + while (System.currentTimeMillis() < confirmDeadline) { + if (Rs2Widget.hasWidget("Your offer is much") || Rs2Widget.hasWidget("Are you sure")) { + log.info("Warning dialog appeared ('Your offer is much' / 'Are you sure'). Confirming 'Yes'..."); + Rs2Widget.clickWidget("Yes"); + sleep(300, 500); + continue; + } + if (!isOfferScreenOpen()) { + offerScreenClosed = true; + break; + } + sleep(100, 200); + } + if (!offerScreenClosed && (Rs2Widget.hasWidget("Your offer is much") || Rs2Widget.hasWidget("Are you sure"))) { + log.info("Warning dialog still open at timeout; confirming 'Yes' and rechecking."); Rs2Widget.clickWidget("Yes"); + offerScreenClosed = sleepUntil(() -> !isOfferScreenOpen(), 2000); } - if (!sleepUntil(() -> !isOfferScreenOpen(), 4000)) { + if (!offerScreenClosed) { log.warn("Offer screen did not close after confirm. Backing out to overview."); backToOverview(); return false; From 8c5d65b85a9c3ed08f1f787d8374161d464e253b Mon Sep 17 00:00:00 2001 From: Joinkiee Date: Sat, 12 Sep 2026 12:46:14 +1000 Subject: [PATCH 09/16] fix(geflipper): stop treating wide chatbox highlights as the GE Confirm button isConfirmTarget() fell back to 'relativeBounds >= 120x30 means Confirm'. Copilot also highlights the chatbox item widget (interface 162) with a box that wide, so the script clicked the chatbox while believing it had clicked Confirm. The offer screen then stayed open until it timed out and the script backed out - the 'offer screen did not close after confirm' warning, at 3% of confirms. The size heuristic now requires the widget to belong to the GE offer screen interface (InterfaceID.GE_OFFERS = 465). Text/action 'Confirm' matches are unchanged and still take priority. --- .../client/plugins/microbot/geflipper/FlipperScript.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java index 5512374bab..c92755cd4c 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java @@ -726,6 +726,14 @@ public boolean isConfirmTarget() { } } if (relativeBounds != null && relativeBounds.width >= 120 && relativeBounds.height >= 30) { + // Size alone does not identify the Confirm button: Copilot also highlights + // wide chatbox widgets, and clicking one of those instead of Confirm leaves + // the offer screen open until it times out ("offer screen did not close + // after confirm"). The GE Confirm button lives on the offer screen + // interface, so require that before trusting the size heuristic. + if (widget == null || (widget.getId() >> 16) != InterfaceID.GE_OFFERS) { + return false; + } return true; } return false; From 312a830587ebd39f484f60fba49d4b4484c8e708 Mon Sep 17 00:00:00 2001 From: Joinkiee Date: Sat, 12 Sep 2026 12:53:23 +1000 Subject: [PATCH 10/16] fix(geflipper): settle before confirming, and guard the post-login player lookup Pacing, but only where it pays: - The Confirm click now waits 250-400ms for the interface to settle, re-verifies the highlighted widget is still visible, and re-reads the click area from the live widget bounds. Copilot rebuilds its highlight list every tick, so a target captured mid-tick could be clicked at stale coordinates. Cost is ~300ms per offer, on the Confirm step only. - Wrapped the GOING_TO_GE player lookup: Rs2Player.getWorldLocation() throws inside the helper when localPlayer is not populated yet (first ticks after login), producing a per-startup ERROR. It now retries on the next tick. --- .../microbot/geflipper/FlipperScript.java | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java index c92755cd4c..498e1da398 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java @@ -112,7 +112,14 @@ public boolean run() { state = State.MONITORING_COPILOT; return; } - WorldPoint playerLocation = Rs2Player.getWorldLocation(); + WorldPoint playerLocation; + try { + playerLocation = Rs2Player.getWorldLocation(); + } catch (Exception e) { + // localPlayer is not populated yet on the first ticks after login, + // so the call throws inside Rs2Player. Retry next tick. + return; + } if (playerLocation == null) return; if (!grandExchangeArea.contains(playerLocation)) { Rs2GrandExchange.walkToGrandExchange(); @@ -1118,6 +1125,24 @@ private boolean checkAndClickHighlightedWidgets() } } + if (isConfirm) { + // Copilot rebuilds its highlight list every tick, so a target captured + // mid-tick can carry bounds that are stale by the time we act. Let the + // offer screen settle, re-verify the widget is still there, and re-read + // the click area so the click cannot land where the button used to be. + // This costs ~300ms per offer and only on the Confirm step. + sleep(250, 400); + if (!Rs2Widget.isWidgetVisible(highlightedWidget.getId())) { + log.info("Confirm target {} no longer visible; skipping stale highlight.", + highlightedWidget.getId()); + return false; + } + Rectangle refreshedBounds = target.getClickBounds(); + if (refreshedBounds != null) { + clickBounds = refreshedBounds; + } + } + if (clickBounds != null && Rs2UiHelper.isRectangleWithinCanvas(clickBounds)) { Microbot.getMouse().click(clickBounds); } else { From 34dced223cac3604174f05ba27d7dd1b306bcc6c Mon Sep 17 00:00:00 2001 From: Joinkiee Date: Sat, 12 Sep 2026 12:54:45 +1000 Subject: [PATCH 11/16] chore(geflipper): label 1.2.51 (was 1.2.5) Version now increments by +0.01 per fix so the overlay identifies the build that is actually running. --- .../client/plugins/microbot/geflipper/FlipperPlugin.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java index 56e39821f8..3032be0f45 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java @@ -25,7 +25,7 @@ isExternal = PluginConstants.IS_EXTERNAL ) public class FlipperPlugin extends Plugin { - public static final String version = "1.2.5"; + public static final String version = "1.2.51"; @Inject private Client client; @Inject From 64a2df7d1556a0c7e0c547186b0cd0fd1a73dcbc Mon Sep 17 00:00:00 2001 From: Joinkiee Date: Sat, 12 Sep 2026 12:58:48 +1000 Subject: [PATCH 12/16] fix(geflipper): wait for the player object before reading its location (1.2.52) The try/catch stopped the exception reaching the script, but ClientThread logs 'Exception during task execution' before the caller's catch runs, so the ERROR line survived. Now the script returns early while localPlayer is null, so the call is never made. Version bumped to 1.2.52 (+0.01 per fix). --- .../plugins/microbot/geflipper/FlipperPlugin.java | 2 +- .../plugins/microbot/geflipper/FlipperScript.java | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java index 3032be0f45..a909c7b05d 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java @@ -25,7 +25,7 @@ isExternal = PluginConstants.IS_EXTERNAL ) public class FlipperPlugin extends Plugin { - public static final String version = "1.2.51"; + public static final String version = "1.2.52"; @Inject private Client client; @Inject diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java index 498e1da398..c4e9edc999 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java @@ -112,12 +112,18 @@ public boolean run() { state = State.MONITORING_COPILOT; return; } + // The script can tick before the player object exists (the first + // moments after login). Calling into Rs2Player then throws inside + // ClientThread, which logs an ERROR even when the caller catches it, + // so wait for the player to exist before asking for its location. + if (Microbot.getClient() == null || Microbot.getClient().getLocalPlayer() == null) { + return; + } WorldPoint playerLocation; try { playerLocation = Rs2Player.getWorldLocation(); } catch (Exception e) { - // localPlayer is not populated yet on the first ticks after login, - // so the call throws inside Rs2Player. Retry next tick. + // Backstop for other transient player states; retry next tick. return; } if (playerLocation == null) return; From a1393f1f6061f669933149245ea443ce39133dd6 Mon Sep 17 00:00:00 2001 From: Joinkiee Date: Sat, 12 Sep 2026 14:24:25 +1000 Subject: [PATCH 13/16] fix(geflipper): recover in 10s, not 30s, when the offer screen gets no actions (1.2.53) Diagnosed from a 30s stall: the plugin opened the offer screen for a MODIFY, then processed no highlights at all for 30s. Copilot's own panel reported 'collect is suggested but there is nothing to collect' at the end of it. Copilot has no COLLECT suggestion type (BUY/SELL/ABORT/MODIFY_BUY/MODIFY_SELL/WAIT), so this is its uncollected-items state going out of sync: it wanted a collect, found nothing to collect, and produced no actionable highlight. Collect is a GE-overview action, so nothing on the offer screen could ever match it. Root cause is Copilot-side, but the plugin can stop holding the offer screen (and its slot) for 30s. When the action count on the open screen is zero, back out after 10s; keep the 30s allowance when actions are happening, since that path is making progress. --- .../plugins/microbot/geflipper/FlipperPlugin.java | 2 +- .../plugins/microbot/geflipper/FlipperScript.java | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java index a909c7b05d..266acbef42 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java @@ -25,7 +25,7 @@ isExternal = PluginConstants.IS_EXTERNAL ) public class FlipperPlugin extends Plugin { - public static final String version = "1.2.52"; + public static final String version = "1.2.53"; @Inject private Client client; @Inject diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java index c4e9edc999..d427c6b726 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java @@ -211,8 +211,14 @@ public boolean run() { } catch (Exception ignored) {} } - // If stuck on offer screen for > 30 seconds or after 10 repeated actions without closing - if (currentTime - offerScreenOpenTime > 30000 || offerScreenActionCount >= 10) { + // If nothing at all has been actioned on the open offer screen, the + // script is waiting on Copilot rather than making progress. Seen when + // Copilot flips its suggestion to COLLECT while the offer screen is + // open: Collect is a GE-overview action, so no highlight on the offer + // screen can ever match it. Recover in 10s instead of holding the + // screen (and the offer slot) for the full 30s. + long stuckLimitMs = offerScreenActionCount == 0 ? 10000 : 30000; + if (currentTime - offerScreenOpenTime > stuckLimitMs || offerScreenActionCount >= 10) { log.warn("Offer screen stuck (openTime={}ms, actions={}). Backing out to GE overview.", currentTime - offerScreenOpenTime, offerScreenActionCount); backToOverview(); From bf47526c0a55646aaa4edda264b337db3d808335 Mon Sep 17 00:00:00 2001 From: Joinkiee Date: Sat, 12 Sep 2026 14:56:45 +1000 Subject: [PATCH 14/16] fix(geflipper): the Auto-Enable toggle must gate BOTH writers of slotActionSwap (1.2.54) Found by testing the toggle with slot swap off: the setting came back true within minutes and aborts used the overview path anyway. slotActionSwap has two writers - FlipperPlugin.ensureCopilotSlotActionSwap() at startup and FlipperScript.ensureSlotActionSwapEnabled() - and only the second honoured the toggle. The startup hook ran first and silently re-enabled the setting, so turning the option off had no effect. Both now check autoEnableSlotSwap(), and the plugin reads the config from ConfigManager if the injected field is not populated yet. --- .../microbot/geflipper/FlipperPlugin.java | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java index 266acbef42..5366c062df 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java @@ -25,7 +25,7 @@ isExternal = PluginConstants.IS_EXTERNAL ) public class FlipperPlugin extends Plugin { - public static final String version = "1.2.53"; + public static final String version = "1.2.54"; @Inject private Client client; @Inject @@ -50,12 +50,25 @@ net.runelite.client.plugins.microbot.geflipper.FlipperConfig provideConfig(Confi private void ensureCopilotSlotActionSwap() { try { - if (configManager != null) { - String current = configManager.getConfiguration("flippingcopilot", "slotActionSwap"); - if (!"true".equalsIgnoreCase(current)) { - configManager.setConfiguration("flippingcopilot", "slotActionSwap", true); + if (configManager == null) return; + // This setting has TWO writers: this startup hook and + // FlipperScript.ensureSlotActionSwapEnabled(). Both must honour the + // user's toggle, or turning it off has no effect - the startup hook + // ran first and silently re-enabled the setting. + FlipperConfig cfg = config; + if (cfg == null) { + try { + cfg = configManager.getConfig(FlipperConfig.class); + } catch (Throwable ignored) { } } + if (cfg != null && !cfg.autoEnableSlotSwap()) { + return; + } + String current = configManager.getConfiguration("flippingcopilot", "slotActionSwap"); + if (!"true".equalsIgnoreCase(current)) { + configManager.setConfiguration("flippingcopilot", "slotActionSwap", true); + } } catch (Throwable ignored) { } } From 6f97dbac807bb11040f3469f83af2b9f67819a4f Mon Sep 17 00:00:00 2001 From: Joinkiee Date: Sun, 13 Sep 2026 07:22:30 +1000 Subject: [PATCH 15/16] fix(geflipper): reopen a closed Grand Exchange and escape a stray GE page (1.2.55) The script had no way back from a closed exchange while running - GOING_TO_GE was only set on shutdown - so a closed or hidden Grand Exchange stalled the bot with no log line. It now reopens the exchange after 4s, or walks to the GE when it cannot be opened from here. A mistimed click can open a GE info page (for example the Convenience Fees text). That page hides the offer list, nothing on it is actionable, and Copilot highlights nothing, so the bot idled forever. It now escapes back to the offer list after 8s. The slotActionSwap auto-enable check is now logged on every start: it used to fail silently, so a toggle that did not take effect left no evidence of why. --- .../microbot/geflipper/FlipperPlugin.java | 17 ++++-- .../microbot/geflipper/FlipperScript.java | 53 ++++++++++++++++++- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java index 5366c062df..2462e30329 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java @@ -25,7 +25,8 @@ isExternal = PluginConstants.IS_EXTERNAL ) public class FlipperPlugin extends Plugin { - public static final String version = "1.2.54"; + public static final String version = "1.2.55"; + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(FlipperPlugin.class); @Inject private Client client; @Inject @@ -59,17 +60,25 @@ private void ensureCopilotSlotActionSwap() { if (cfg == null) { try { cfg = configManager.getConfig(FlipperConfig.class); - } catch (Throwable ignored) { + } catch (Throwable e) { + log.info("slotActionSwap auto-enable: could not read the flipper config: {}", e.toString()); } } + String current = configManager.getConfiguration("flippingcopilot", "slotActionSwap"); + // Logged on every start: this check used to fail silently, so a toggle that + // did not take effect left no evidence of why. + log.info("slotActionSwap auto-enable check: toggle={}, current={}", + cfg == null ? "unreadable" : String.valueOf(cfg.autoEnableSlotSwap()), current); if (cfg != null && !cfg.autoEnableSlotSwap()) { + log.info("slotActionSwap auto-enable is disabled by configuration; leaving it as '{}'.", current); return; } - String current = configManager.getConfiguration("flippingcopilot", "slotActionSwap"); if (!"true".equalsIgnoreCase(current)) { + log.info("Enabling Flipping Copilot 'slotActionSwap' (was '{}').", current); configManager.setConfiguration("flippingcopilot", "slotActionSwap", true); } - } catch (Throwable ignored) { + } catch (Throwable e) { + log.info("slotActionSwap auto-enable failed: {}", e.toString()); } } diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java index d427c6b726..89f5e3ebe0 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java @@ -52,6 +52,9 @@ public class FlipperScript extends Script { private static final int INTERACTION_TIMEOUT_VARIANCE = 11000; private static final int INVENTORY_WAIT_TIMEOUT = 5000; private static final int SCHEDULE_INTERVAL_MS = 600; + // A closed exchange or a stray GE page used to stall this state machine silently. + private static final int GE_CLOSED_RECOVER_MS = 4000; + private static final int STRAY_PAGE_RECOVER_MS = 8000; private static final int KEY_PRESS_DELAY_MIN = 250; private static final int KEY_PRESS_DELAY_MAX = 400; @@ -66,6 +69,8 @@ public class FlipperScript extends Script { private long interactionTimeout = DEFAULT_INTERACTION_TIMEOUT; private long offerScreenOpenTime = 0; private int offerScreenActionCount = 0; + private long geClosedSince = 0; + private long strayPageSince = 0; private int[] grandExchangeSlotIds = new int[] { InterfaceID.GeOffers.INDEX_0, @@ -151,7 +156,53 @@ public boolean run() { case MONITORING_COPILOT: long currentTime = System.currentTimeMillis(); - // 0. Offer screen watchdog & loop detection + // 0a. Grand Exchange watchdog: this state had no way back from a closed + // exchange - GOING_TO_GE was only set on shutdown/startup - so a closed or + // hidden GE stalled the bot indefinitely and silently. Reopen it directly. + if (!Rs2GrandExchange.isOpen() && !Rs2Bank.isOpen()) { + if (geClosedSince == 0) { + geClosedSince = currentTime; + } else if (currentTime - geClosedSince > GE_CLOSED_RECOVER_MS) { + long closedFor = currentTime - geClosedSince; + geClosedSince = 0; + log.info("Grand Exchange closed for {}ms while running; reopening it.", closedFor); + if (!Rs2GrandExchange.openExchange()) { + log.info("Exchange could not be opened from here; walking to the Grand Exchange."); + state = State.GOING_TO_GE; + } else { + sleepUntil(Rs2GrandExchange::isOpen, 3000); + } + lastActionTime = System.currentTimeMillis(); + actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); + return; + } + } else { + geClosedSince = 0; + } + + // 0b. Stray GE page watchdog: a mistimed click can open a GE info page + // (for example the Convenience Fees text) which hides the offer list. + // Nothing on that page is actionable, Copilot highlights nothing, and the + // script would otherwise idle silently forever. Escape back to the list. + if (Rs2GrandExchange.isOpen() && !isOfferScreenOpen() + && !Rs2Widget.hasWidget("Select an offer slot")) { + if (strayPageSince == 0) { + strayPageSince = currentTime; + } else if (currentTime - strayPageSince > STRAY_PAGE_RECOVER_MS) { + long strayFor = currentTime - strayPageSince; + strayPageSince = 0; + log.info("GE is showing a non-offer page for {}ms; escaping back to the offer list.", strayFor); + Rs2Keyboard.keyPress(KeyEvent.VK_ESCAPE); + sleep(300, 600); + lastActionTime = System.currentTimeMillis(); + actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); + return; + } + } else { + strayPageSince = 0; + } + + // 0. Offer screen watchdog & loop detection if (isOfferScreenOpen()) { if (offerScreenOpenTime == 0) { offerScreenOpenTime = currentTime; From cd84b8e090f4832314028923a2893411caaa4fa3 Mon Sep 17 00:00:00 2001 From: Joinkiee Date: Wed, 23 Sep 2026 16:27:08 +1000 Subject: [PATCH 16/16] fix(geflipper): address logging and modify review feedback (1.2.6) --- .../microbot/geflipper/FlipperConfig.java | 59 ++- .../microbot/geflipper/FlipperOverlay.java | 11 +- .../microbot/geflipper/FlipperPlugin.java | 110 +++-- .../microbot/geflipper/FlipperScript.java | 449 +++++++++++------- .../geflipper/SlotActionExecutor.java | 72 +++ .../plugins/microbot/geflipper/docs/README.md | 15 +- .../geflipper/docs/REVIEW_VALIDATION.md | 38 ++ .../geflipper/FlipperPluginLoggingTest.java | 219 +++++++++ .../geflipper/FlipperScriptModifyTest.java | 68 +++ .../geflipper/SlotActionExecutorTest.java | 281 +++++++++++ 10 files changed, 1069 insertions(+), 253 deletions(-) create mode 100644 src/main/java/net/runelite/client/plugins/microbot/geflipper/SlotActionExecutor.java create mode 100644 src/main/resources/net/runelite/client/plugins/microbot/geflipper/docs/REVIEW_VALIDATION.md create mode 100644 src/test/java/net/runelite/client/plugins/microbot/geflipper/FlipperPluginLoggingTest.java create mode 100644 src/test/java/net/runelite/client/plugins/microbot/geflipper/FlipperScriptModifyTest.java create mode 100644 src/test/java/net/runelite/client/plugins/microbot/geflipper/SlotActionExecutorTest.java diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperConfig.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperConfig.java index 45e2af494e..869a09db36 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperConfig.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperConfig.java @@ -23,6 +23,51 @@ public String toString() { } } + /** How a slot is worked when Copilot asks for a modify or an abort. */ + enum SlotAction { + COPILOT_LEFT_CLICK("On", "Copilot left-click swap"), + MENU_OPTION("Off", "Slot menu action"); + + private final String label; + final String actionDescription; + + SlotAction(String label, String actionDescription) { + this.label = label; + this.actionDescription = actionDescription; + } + + @Override + public String toString() { + return label; + } + } + + @ConfigItem( + keyName = "slotActionMode", + name = "Copilot left-click swap", + description = "On: use Copilot's swapped left-click for Modify/Abort (enable slot swap in Copilot too). " + + "Off: select the supported Modify/Abort slot action directly. " + + "This setting controls GE Flipper and does not change Copilot's own setting. " + + "Hotkey/Mouse above controls price and quantity input.", + position = 2 + ) + default SlotAction slotAction() { + return SlotAction.COPILOT_LEFT_CLICK; + } + + + @ConfigItem( + keyName = "verboseLogging", + name = "Verbose Logging", + description = "Log this plugin's own activity at INFO instead of WARN. Leave it off to keep " + + "the in-game chat quiet; turn it on when you need a full trace of what the plugin did. " + + "This affects only this plugin's own logger - no other script's logging is touched.", + position = 90 + ) + default boolean verboseLogging() { + return false; + } + @ConfigItem( keyName = "guide", name = "How to use", @@ -53,22 +98,10 @@ default SelectionMethod selectionMethod() { keyName = "showOverlay", name = "Show Overlay", description = "Display profit and GP/hr overlay on the top-left of the screen", - position = 2 + position = 3 ) default boolean showOverlay() { return true; } - @ConfigItem( - keyName = "autoEnableSlotSwap", - name = "Auto-Enable Copilot Slot Swap", - description = "Automatically turn on Flipping Copilot's 'Swap slot left-click action' setting, " + - "so offers can be aborted straight from the Grand Exchange overview. " + - "
Turn this OFF if you also hand-flip and want left-clicking a GE slot to open the offer screen. " + - "
When off, the plugin respects your Copilot setting and falls back to aborting from the offer details screen.", - position = 3 - ) - default boolean autoEnableSlotSwap() { - return true; - } } diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperOverlay.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperOverlay.java index f00d3c77c2..791e975c23 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperOverlay.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperOverlay.java @@ -274,12 +274,11 @@ public Dimension render(Graphics2D graphics) { .build()); } - boolean slotSwap = plugin.getFlipperScript() != null && plugin.getFlipperScript().isSlotActionSwapEnabled(); - panelComponent.getChildren().add(LineComponent.builder() - .left("Slot Swap:") - .right(slotSwap ? "ON" : "OFF (Screen Abort)") - .rightColor(slotSwap ? POSITIVE_COLOR : Color.ORANGE) - .build()); + String slotStatus = plugin.getFlipperScript() == null ? "" : plugin.getFlipperScript().getSlotActionStatus(); + if (!slotStatus.isEmpty()) { + panelComponent.getChildren().add(LineComponent.builder() + .left(slotStatus).leftColor(Color.ORANGE).build()); + } return super.render(graphics); } diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java index 2462e30329..7d80904806 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperPlugin.java @@ -4,6 +4,8 @@ import com.google.inject.Provides; import net.runelite.api.Client; import net.runelite.client.config.ConfigManager; +import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.events.ConfigChanged; import net.runelite.client.plugins.Plugin; import net.runelite.client.plugins.PluginDescriptor; import net.runelite.client.plugins.microbot.PluginConstants; @@ -25,7 +27,7 @@ isExternal = PluginConstants.IS_EXTERNAL ) public class FlipperPlugin extends Plugin { - public static final String version = "1.2.55"; + public static final String version = "1.2.6"; private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(FlipperPlugin.class); @Inject private Client client; @@ -49,68 +51,84 @@ net.runelite.client.plugins.microbot.geflipper.FlipperConfig provideConfig(Confi return configManager.getConfig(FlipperConfig.class); } - private void ensureCopilotSlotActionSwap() { + + /** + * Keeps this plugin's own log volume in hand by setting the level of THIS package's logger only. + * + * An earlier build detached the root logger's GameChatAppender and switched the whole client's + * chat configuration off on every startup, and shutdown never put it back. Starting and stopping + * GE Flipper therefore silenced in-game diagnostics for every other Microbot script. Nothing + * outside this package is touched here. + */ + private void applyOwnLogLevel() { try { - if (configManager == null) return; - // This setting has TWO writers: this startup hook and - // FlipperScript.ensureSlotActionSwapEnabled(). Both must honour the - // user's toggle, or turning it off has no effect - the startup hook - // ran first and silently re-enabled the setting. - FlipperConfig cfg = config; - if (cfg == null) { - try { - cfg = configManager.getConfig(FlipperConfig.class); - } catch (Throwable e) { - log.info("slotActionSwap auto-enable: could not read the flipper config: {}", e.toString()); - } - } - String current = configManager.getConfiguration("flippingcopilot", "slotActionSwap"); - // Logged on every start: this check used to fail silently, so a toggle that - // did not take effect left no evidence of why. - log.info("slotActionSwap auto-enable check: toggle={}, current={}", - cfg == null ? "unreadable" : String.valueOf(cfg.autoEnableSlotSwap()), current); - if (cfg != null && !cfg.autoEnableSlotSwap()) { - log.info("slotActionSwap auto-enable is disabled by configuration; leaving it as '{}'.", current); - return; - } - if (!"true".equalsIgnoreCase(current)) { - log.info("Enabling Flipping Copilot 'slotActionSwap' (was '{}').", current); - configManager.setConfiguration("flippingcopilot", "slotActionSwap", true); + org.slf4j.Logger slf4jLogger = org.slf4j.LoggerFactory.getLogger("net.runelite.client.plugins.microbot.geflipper"); + if (slf4jLogger instanceof ch.qos.logback.classic.Logger) { + ((ch.qos.logback.classic.Logger) slf4jLogger).setLevel(verboseLoggingEnabled() + ? ch.qos.logback.classic.Level.INFO + : ch.qos.logback.classic.Level.WARN); } - } catch (Throwable e) { - log.info("slotActionSwap auto-enable failed: {}", e.toString()); + } catch (Throwable ignored) { } } - private void disableGameChatAppender() { + /** Whether the user asked for a full trace of this plugin's own activity. Read defensively. */ + private boolean verboseLoggingEnabled() { try { - org.slf4j.Logger slf4jLogger = org.slf4j.LoggerFactory.getLogger(org.slf4j.Logger.ROOT_LOGGER_NAME); - if (slf4jLogger instanceof ch.qos.logback.classic.Logger) { - ch.qos.logback.classic.Logger rootLogger = (ch.qos.logback.classic.Logger) slf4jLogger; - java.util.Iterator> it = rootLogger.iteratorForAppenders(); - while (it.hasNext()) { - ch.qos.logback.core.Appender appender = it.next(); - if (appender.getClass().getName().contains("GameChatAppender")) { - rootLogger.detachAppender(appender); - appender.stop(); - } - } - } - net.runelite.client.plugins.microbot.GameChatAppender.updateConfiguration(false, ch.qos.logback.classic.Level.OFF, false); + return config != null && config.verboseLogging(); } catch (Throwable ignored) { + return false; } } @Override protected void startUp() throws AWTException{ - ensureCopilotSlotActionSwap(); - disableGameChatAppender(); + migrateSlotActions(); + warnIfSlotSwapOff(); + applyOwnLogLevel(); if (overlayManager != null && overlay != null) { overlayManager.add(overlay); } flipperScript.run(config); } + private void migrateSlotActions() { + // ConfigManager persists new defaults before startup, so testing the new key for null + // cannot distinguish an upgrade from an explicit choice. Migrate old choices once. + if (configManager == null || "true".equals(configManager.getConfiguration("Flipper Config", "slotActionMigrated"))) return; + String oldStyle = configManager.getConfiguration("Flipper Config", "slotActionStyle"); + if (oldStyle != null) { + configManager.setConfiguration("Flipper Config", "slotActionMode", + "MENU_OPTION".equals(oldStyle) ? FlipperConfig.SlotAction.MENU_OPTION + : FlipperConfig.SlotAction.COPILOT_LEFT_CLICK); + } + configManager.setConfiguration("Flipper Config", "slotActionMigrated", true); + } + + /** + * Flipping Copilot's slot action swap belongs to the user. This plugin reads it and reports + * when it is off, but never writes it - enabling it silently is what made the user's own + * left-click setting flip back on. + */ + private void warnIfSlotSwapOff() { + if (configManager == null || config == null + || config.slotAction() != FlipperConfig.SlotAction.COPILOT_LEFT_CLICK) return; + if ("true".equals(configManager.getConfiguration("flippingcopilot", "slotActionSwap"))) return; + log.warn("GE Flipper's Copilot left-click swap is On, but Flipping Copilot's slot action swap is off. " + + "Enable it in Copilot's own settings, or set Copilot left-click swap to Off in GE Flipper. " + + "This plugin never changes that setting for you."); + } + + @Subscribe + public void onConfigChanged(ConfigChanged event) { + if (!"Flipper Config".equals(event.getGroup())) return; + if ("slotActionMode".equals(event.getKey())) { + configManager.setConfiguration("Flipper Config", "slotActionMigrated", true); + warnIfSlotSwapOff(); + } + if ("verboseLogging".equals(event.getKey())) applyOwnLogLevel(); + } + @Override protected void shutDown() { if (overlayManager != null && overlay != null) { @@ -119,4 +137,4 @@ protected void shutDown() { flipperScript.state = State.GOING_TO_GE; flipperScript.shutdown(); } -} \ No newline at end of file +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java index 89f5e3ebe0..588dde5813 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperScript.java @@ -88,6 +88,11 @@ public class FlipperScript extends Script { @Inject private KeyManager keyManager; + /** Only one FlipperScript may ever run at a time: Microbot starts the script on every enable and + * local reload and the old instances were never stopped, which left several bots trading. */ + private static final java.util.Set> LIVE_FUTURES = + java.util.concurrent.ConcurrentHashMap.newKeySet(); + public boolean run(FlipperConfig config) { this.config = config; @@ -99,6 +104,19 @@ private boolean isMouseMode() { } public boolean run() { + if (!LIVE_FUTURES.isEmpty()) { + // Only one instance may ever run. Microbot restarts this script on break cycles and + // local reloads; tracking only the newest future left the older ones running, which + // accumulated to several bots trading at once. Cancel every one still registered. + log.warn("Cancelling {} earlier FlipperScript instance(s) so only one runs.", LIVE_FUTURES.size()); + for (java.util.concurrent.ScheduledFuture f : LIVE_FUTURES) { + try { + f.cancel(true); + } catch (Exception ignored) { + } + } + LIVE_FUTURES.clear(); + } Rs2AntibanSettings.naturalMouse = true; Rs2Antiban.setActivityIntensity(ActivityIntensity.LOW); mainScheduledFuture = scheduledExecutorService.scheduleWithFixedDelay(() -> { @@ -155,6 +173,7 @@ public boolean run() { case MONITORING_COPILOT: long currentTime = System.currentTimeMillis(); + if (isSlotActionBlocked()) return; // 0a. Grand Exchange watchdog: this state had no way back from a closed // exchange - GOING_TO_GE was only set on shutdown/startup - so a closed or @@ -287,11 +306,9 @@ public boolean run() { return; } - // 2. Check for Copilot price/quantity messages in chat - if (checkAndPressCopilotKeybind()) return; - - // 3. Check if we need to abort any offers + // Handle slot suggestions before chat/highlight fallbacks. if (checkAndAbortOrModifyIfNeeded()) return; + if (checkAndPressCopilotKeybind()) return; // 4. Check for highlighted widgets if (checkAndClickHighlightedWidgets()) return; @@ -311,13 +328,33 @@ public boolean run() { log.error("Error in FlipperScript: {} - ", ex.getMessage(), ex); } }, 0, SCHEDULE_INTERVAL_MS, TimeUnit.MILLISECONDS); - + LIVE_FUTURES.add(mainScheduledFuture); return true; } @Override public void shutdown() { + // Cancel before clearing. Clearing alone dropped the reference and left the old + // schedule running, so every break-restart added another live instance. + for (java.util.concurrent.ScheduledFuture f : LIVE_FUTURES) { + try { + f.cancel(true); + } catch (Exception ignored) { + } + } + if (mainScheduledFuture != null) { + try { + mainScheduledFuture.cancel(true); + } catch (Exception ignored) { + } + } + + LIVE_FUTURES.clear(); + blockedSlotActionKey = null; + slotActionStatus = ""; + geClosedSince = strayPageSince = offerScreenOpenTime = 0; + offerScreenActionCount = 0; flippingCopilot = null; suggestionManager = null; highlightController = null; @@ -329,7 +366,6 @@ public void shutdown() private boolean initialize() { if (flippingCopilot != null && suggestionManager != null && highlightController != null) { - ensureSlotActionSwapEnabled(); return true; } @@ -338,7 +374,6 @@ private boolean initialize() Object _highlightController = getHighlightController(_flippingCopilot); if (_flippingCopilot != null && _suggestionManager != null && _highlightController != null) { - ensureSlotActionSwapEnabled(); return true; } return false; @@ -351,7 +386,12 @@ private Plugin getFlippingCopilot() flippingCopilot = Microbot.getPluginManager() .getPlugins() .stream() - .filter(plugin -> plugin.getClass().getSimpleName().equalsIgnoreCase("FlippingCopilotPlugin")) + .filter(plugin -> { + // Flip Assist and Flipping Copilot expose the same surface; drive whichever one is loaded. + String simpleName = plugin.getClass().getSimpleName(); + return simpleName.equalsIgnoreCase("FlippingCopilotPlugin") + || simpleName.equalsIgnoreCase("FlipAssistPlugin"); + }) .findFirst() .orElse(null); } @@ -438,24 +478,7 @@ public boolean isSlotActionSwapEnabled() { } } } catch (Exception ignored) {} - return true; - } - - public void ensureSlotActionSwapEnabled() { - try { - if (config != null && !config.autoEnableSlotSwap()) { - return; - } - if (Microbot.getConfigManager() != null) { - String val = Microbot.getConfigManager().getConfiguration("flippingcopilot", "slotActionSwap"); - if (!"true".equalsIgnoreCase(val)) { - log.info("Flipping Copilot 'slotActionSwap' is disabled; automatically enabling it in ConfigManager."); - Microbot.getConfigManager().setConfiguration("flippingcopilot", "slotActionSwap", true); - } - } - } catch (Exception e) { - log.warn("Failed to set flippingcopilot slotActionSwap setting: {}", e.getMessage()); - } + return false; } private Widget getOfferScreenAbortButton() { @@ -874,106 +897,211 @@ private Widget getWidgetFromOverlay(Object highlightController, String suggestio return target != null ? target.getWidget() : null; } - private boolean checkAndAbortOrModifyIfNeeded() - { - if (!Rs2GrandExchange.isOpen() || isOfferScreenOpen()) return false; - if (System.currentTimeMillis() - lastActionTime < actionCooldown) return false; + private String blockedSlotActionKey; + /** The chat label Flip Assist uses for its suggested item in the GE search. */ + private static final String FlipAssistItemLabel = "Flip Assist item: "; + + /** + * The line that selects the suggested item in the exchange search box. Copilot puts one there; + * Flip Assist puts one there under its own label, so both are tried. The result is returned + * rather than assigned to a shared local, because the callers capture that local in a lambda. + */ + private Widget findSuggestedItemWidget() { + Widget widget = Rs2Widget.findWidget("Copilot item:", null, false); + if (widget == null) { + widget = Rs2Widget.findWidget(FlipAssistItemLabel, null, false); + } + return widget; + } - if (flippingCopilot == null || highlightController == null || suggestionManager == null) return false; - try - { - Object currentSuggestion = getSuggestion(suggestionManager); - if (currentSuggestion == null) return false; + private volatile String slotActionStatus = ""; + private static final int SLOT_ACTION_SETTLE_MS = 3500; - // Use Suggestion helper methods (type is now SuggestionType enum, not String) - Method isAbortMethod = currentSuggestion.getClass().getMethod("isAbortSuggestion"); - Method isModifyMethod = currentSuggestion.getClass().getMethod("isModifySuggestion"); - boolean isAbort = (Boolean) isAbortMethod.invoke(currentSuggestion); - boolean isModify = (Boolean) isModifyMethod.invoke(currentSuggestion); + public enum SuggestedAction { NONE, ABORT, MODIFY } - if (!isAbort && !isModify) return false; + public static SuggestedAction classifySuggestion(boolean isAbort, boolean isModify, boolean slotActionSwap) { + if (isAbort) return SuggestedAction.ABORT; + if (isModify) return SuggestedAction.MODIFY; + return SuggestedAction.NONE; + } - Widget abortWidget = getWidgetFromOverlay(highlightController, isAbort ? "abort" : "modify"); - if (abortWidget == null) - { - try { - Method getBoxIdMethod = currentSuggestion.getClass().getMethod("getBoxId"); - int boxId = (Integer) getBoxIdMethod.invoke(currentSuggestion); - if (boxId >= 0 && boxId < grandExchangeSlotIds.length) { - abortWidget = Rs2Widget.getWidget(grandExchangeSlotIds[boxId]); - } - } catch (Exception ignored) {} - } - if (abortWidget != null && Rs2Widget.isWidgetVisible(abortWidget.getId())) - { - if (isAbort) - { - ensureSlotActionSwapEnabled(); - boolean slotActionSwap = isSlotActionSwapEnabled(); - log.info("Executing suggestion ABORT on slot widget {} (slotActionSwap={})", abortWidget.getId(), slotActionSwap); - if (slotActionSwap) - { - NewMenuEntry abortEntry = new NewMenuEntry() - .option("Abort offer") - .target("") - .identifier(2) - .type(MenuAction.CC_OP) - .param0(2) - .param1(abortWidget.getId()) - .itemId(-1) - .forceLeftClick(false); - Rectangle bounds = abortWidget.getBounds() != null && Rs2UiHelper.isRectangleWithinCanvas(abortWidget.getBounds()) - ? abortWidget.getBounds() - : Rs2UiHelper.getDefaultRectangle(); - Microbot.doInvoke(abortEntry, bounds); - } - else - { - // When slotActionSwap is OFF, left-clicking the slot widget in OSRS opens "View offer". - // We open the offer screen and let getOfferScreenAbortButton perform the abort reliably. - log.info("slotActionSwap is disabled: opening slot widget {} to abort from offer screen.", abortWidget.getId()); - Rs2Widget.clickWidget(abortWidget); - sleepUntil(this::isOfferScreenOpen, 2500); - } - lastActionTime = System.currentTimeMillis(); - actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); - return true; - } - else // isModify - { - log.info("Executing suggestion MODIFY: opening slot widget {}", abortWidget.getId()); - Rs2Widget.clickWidget(abortWidget); - lastActionTime = System.currentTimeMillis(); - actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); - return true; - } - } - else if (isAbort) - { - try { - Method getNameMethod = currentSuggestion.getClass().getMethod("getName"); - String itemName = (String) getNameMethod.invoke(currentSuggestion); - if (itemName != null && !itemName.isEmpty()) { - log.info("Executing suggestion ABORT via Rs2GrandExchange.abortOffer for item '{}'", itemName); - if (Rs2GrandExchange.abortOffer(itemName, false)) { - lastActionTime = System.currentTimeMillis(); - actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); - return true; - } - } - } catch (Exception ignored) {} - } - } - catch (Exception e) - { - log.error("Could not process suggestion: {} - ", e.getMessage(), e); - } - return false; - } + public String getSlotActionStatus() { + return slotActionStatus; + } + + private void slotActionStatus(String message) { + if (!message.equals(slotActionStatus) && !message.isEmpty()) log.warn(message); + slotActionStatus = message; + } + + private String slotActionKey(Object suggestion) throws ReflectiveOperationException { + StringBuilder key = new StringBuilder(config.slotAction().name()) + .append(':').append(isSlotActionSwapEnabled()); + for (String getter : new String[]{"getType", "getBoxId", "getName", "getPrice", "getQuantity"}) { + key.append(':').append(suggestion.getClass().getMethod(getter).invoke(suggestion)); + } + return key.toString(); + } + + // This guard runs before watchdogs, hotkeys and generic highlight clicks. A failed slot + // action must not fall through to a second click via another path on the following tick. + private boolean isSlotActionBlocked() { + if (blockedSlotActionKey == null) return false; + try { + Object suggestion = getSuggestion(suggestionManager); + if (suggestion == null || blockedSlotActionKey.equals(slotActionKey(suggestion))) return true; + blockedSlotActionKey = null; + slotActionStatus = ""; + geClosedSince = strayPageSince = offerScreenOpenTime = 0; + offerScreenActionCount = 0; + return false; + } catch (ReflectiveOperationException e) { + return true; + } + } + + private boolean sameSlotSuggestion(String key) { + try { + Object suggestion = getSuggestion(suggestionManager); + return suggestion != null && key.equals(slotActionKey(suggestion)); + } catch (ReflectiveOperationException e) { + return false; + } + } + + private boolean isModifySetupOpen() { + return Rs2Widget.isWidgetVisible(InterfaceID.GeOffers.SETUP); + } + + private net.runelite.api.Point slotActionPoint(int slotId, SlotActionExecutor.Action action) { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Widget slot = Microbot.getClient().getWidget(slotId); + Widget button = slot == null ? null : slot.getChild(2); + if (button == null || button.isHidden()) return null; + if (!SlotActionExecutor.supportsAction(button.getActions(), action)) return null; + Rectangle bounds = button.getBounds(); + if (bounds == null || bounds.width < 2 || bounds.height < 2 + || !Rs2UiHelper.isRectangleWithinCanvas(bounds)) return null; + return new net.runelite.api.Point((int) bounds.getCenterX(), (int) bounds.getCenterY()); + }).orElse(null); + } + + private boolean isSlotDefaultAction(int slotId, SlotActionExecutor.Action action, + net.runelite.api.Point point, String key) { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + if (Microbot.naturalMouse == null || Microbot.targetMenu != null || Microbot.getClient().isMenuOpen() + || !isSlotActionSwapEnabled() || !sameSlotSuggestion(key) || isOfferScreenOpen()) return false; + net.runelite.api.Point mouse = Microbot.getClient().getMouseCanvasPosition(); + Widget slot = Microbot.getClient().getWidget(slotId); + Widget button = slot == null ? null : slot.getChild(2); + if (button == null || button.isHidden() || mouse == null + || mouse.getX() != point.getX() || mouse.getY() != point.getY() + || !button.getBounds().contains(point.getX(), point.getY())) return false; + return SlotActionExecutor.matchesDefaultAction( + Microbot.getClient().getMenu().getMenuEntries(), slotId, action); + }).orElse(false); + } + + private boolean checkAndAbortOrModifyIfNeeded() { + if (!Rs2GrandExchange.isOpen() || isOfferScreenOpen()) return false; + if (flippingCopilot == null || highlightController == null || suggestionManager == null) return false; + try { + Object suggestion = getSuggestion(suggestionManager); + if (suggestion == null) { + slotActionStatus = ""; + return false; + } + boolean abort = (Boolean) suggestion.getClass().getMethod("isAbortSuggestion").invoke(suggestion); + boolean modify = (Boolean) suggestion.getClass().getMethod("isModifySuggestion").invoke(suggestion); + if (!abort && !modify) { + slotActionStatus = ""; + return false; + } + // Consume the tick even during cooldown: generic slot highlights must not bypass this handler. + if (System.currentTimeMillis() - lastActionTime < actionCooldown) return true; + final String key = slotActionKey(suggestion); + int boxId = (Integer) suggestion.getClass().getMethod("getBoxId").invoke(suggestion); + if (boxId < 0 || boxId >= grandExchangeSlotIds.length) { + blockedSlotActionKey = key; + slotActionStatus("Invalid Copilot slot. Refresh suggestions or restart GE Flipper."); + return true; + } + final int slotId = grandExchangeSlotIds[boxId]; + final SlotActionExecutor.Action action = abort + ? SlotActionExecutor.Action.ABORT : SlotActionExecutor.Action.MODIFY; + SlotActionExecutor.Result result = SlotActionExecutor.execute(config.slotAction(), action, slotId, + new SlotActionExecutor.Ui() { + public boolean slotSwapEnabled() { return isSlotActionSwapEnabled(); } + public net.runelite.api.Point actionPoint(int id, SlotActionExecutor.Action a) { + return slotActionPoint(id, a); + } + public void hover(net.runelite.api.Point point) { + // Mouse.move dispatches a single jump. Follow a smooth path on this + // script thread; never block the client thread for mouse movement. + if (Microbot.naturalMouse != null && !Thread.currentThread().isInterrupted()) { + Microbot.naturalMouse.moveTo(point.getX(), point.getY()); + } + } + public boolean awaitDefaultAction(int id, SlotActionExecutor.Action a, net.runelite.api.Point point) { + return sleepUntil(() -> isSlotDefaultAction(id, a, point, key), 1800); + } + public boolean clickDefaultAction(int id, SlotActionExecutor.Action a, net.runelite.api.Point point) { + if (!FlipperScript.this.isRunning() || Thread.currentThread().isInterrupted() + || !isSlotDefaultAction(id, a, point, key)) return false; + // Reuse the verified point; a rectangle would choose a different point. + Microbot.getMouse().click(point); + return true; + } + public boolean invokeAction(int id, SlotActionExecutor.Action a, net.runelite.api.Point point) { + if (Thread.currentThread().isInterrupted() || !sameSlotSuggestion(key) + || isOfferScreenOpen() || slotActionPoint(id, a) == null) return false; + Microbot.doInvoke(new NewMenuEntry().option(a.option).target("") + .identifier(a.identifier).type(MenuAction.CC_OP).param0(2).param1(id) + .itemId(-1).forceLeftClick(false), + new Rectangle(point.getX() - 1, point.getY() - 1, 2, 2)); + return true; + } + }); + lastActionTime = System.currentTimeMillis(); + actionCooldown = DEFAULT_ACTION_COOLDOWN; + if (handleSlotActionFailure(result, action, key)) return true; + slotActionStatus = ""; + log.info("Executed {} on slot {} using {}.", action.option, boxId + 1, config.slotAction().actionDescription); + if (modify && !sleepUntil(this::isModifySetupOpen, SLOT_ACTION_SETTLE_MS) + && sameSlotSuggestion(key)) { + // An invoke/left click is not proof that setup opened. Back out once, then + // hold this suggestion until it changes, the mode changes, or the plugin restarts. + if (isOfferScreenOpen()) backToOverview(); + blockedSlotActionKey = key; + slotActionStatus("Modify setup did not open. Check Copilot left-click swap or restart GE Flipper."); + } + lastActionTime = System.currentTimeMillis(); + actionCooldown = DEFAULT_ACTION_COOLDOWN; + return true; + } catch (ReflectiveOperationException e) { + slotActionStatus("Copilot suggestion unavailable. Refresh suggestions or restart GE Flipper."); + log.debug("Could not read Copilot slot suggestion", e); + return true; + } + } + /** Transient failures consume this tick without permanently blocking the suggestion. */ + boolean handleSlotActionFailure(SlotActionExecutor.Result result, SlotActionExecutor.Action action, String key) { + if (result == SlotActionExecutor.Result.ACTED) return false; + if (result == SlotActionExecutor.Result.SWAP_DISABLED) { + blockedSlotActionKey = key; + slotActionStatus("Enable Copilot slot swap, or set Copilot left-click swap to Off in GE Flipper to use Slot menu action."); + } else if (result == SlotActionExecutor.Result.SLOT_UNAVAILABLE) { + slotActionStatus("No supported " + action.option + " action is available. Waiting; if this persists, " + + "refresh Copilot suggestions or handle the offer manually."); + } else { + slotActionStatus("Waiting for Copilot's " + action.option + " left-click action. Will retry."); + } + return true; + } private boolean checkAndPressCopilotKeybind() { // 1. Search for a widget with text "Copilot item" (if it's time to select the item suggestion in the buy item window) - Widget copilotWidget = Rs2Widget.findWidget("Copilot item:", null, false); + Widget copilotWidget = findSuggestedItemWidget(); if (copilotWidget != null && Rs2Widget.isWidgetVisible(copilotWidget.getId())) { log.info("Found chat widget Copilot item '{}'.", copilotWidget.getId()); if (isMouseMode()) { @@ -1144,6 +1272,21 @@ private boolean checkAndClickHighlightedWidgets() log.info("Processing highlighted target: widgetId={}, clickBounds={}, relativeBounds={}", highlightedWidget.getId(), clickBounds, target.getRelativeBounds()); + // Suggestions can change after the main handler checked them. Route slot + // MODIFY/ABORT highlights through the same verified action path in either mode. + boolean isSlotWidget = Arrays.stream(grandExchangeSlotIds).anyMatch(id -> id == highlightedWidget.getId()); + if (isSlotWidget && suggestionManager != null) { + Object currentSuggestion = getSuggestion(suggestionManager); + if (currentSuggestion != null) { + boolean abort = (Boolean) currentSuggestion.getClass().getMethod("isAbortSuggestion").invoke(currentSuggestion); + boolean modify = (Boolean) currentSuggestion.getClass().getMethod("isModifySuggestion").invoke(currentSuggestion); + if (abort || modify) { + checkAndAbortOrModifyIfNeeded(); + return true; + } + } + } + // If GE close button is highlighted (container 30474242 or close button dynamic child) if (highlightedWidget.getId() == 30474242 && Rs2GrandExchange.isOpen()) { Rs2GrandExchange.closeExchange(); @@ -1169,43 +1312,6 @@ private boolean checkAndClickHighlightedWidgets() boolean isConfirm = target.isConfirmTarget(); - boolean isSlotWidget = Arrays.stream(grandExchangeSlotIds).anyMatch(id -> id == highlightedWidget.getId()); - boolean isAbortOnSlot = false; - if (isSlotWidget && suggestionManager != null) { - try { - Object currentSuggestion = getSuggestion(suggestionManager); - if (currentSuggestion != null) { - Method isAbortMethod = currentSuggestion.getClass().getMethod("isAbortSuggestion"); - isAbortOnSlot = (Boolean) isAbortMethod.invoke(currentSuggestion); - } - } catch (Exception ignored) {} - } - if (isAbortOnSlot) { - ensureSlotActionSwapEnabled(); - if (!isSlotActionSwapEnabled()) { - log.info("Highlighted GE slot {} for abort with slotActionSwap=false; clicking slot to open offer screen.", - highlightedWidget.getId()); - } - } - - if (isConfirm) { - // Copilot rebuilds its highlight list every tick, so a target captured - // mid-tick can carry bounds that are stale by the time we act. Let the - // offer screen settle, re-verify the widget is still there, and re-read - // the click area so the click cannot land where the button used to be. - // This costs ~300ms per offer and only on the Confirm step. - sleep(250, 400); - if (!Rs2Widget.isWidgetVisible(highlightedWidget.getId())) { - log.info("Confirm target {} no longer visible; skipping stale highlight.", - highlightedWidget.getId()); - return false; - } - Rectangle refreshedBounds = target.getClickBounds(); - if (refreshedBounds != null) { - clickBounds = refreshedBounds; - } - } - if (clickBounds != null && Rs2UiHelper.isRectangleWithinCanvas(clickBounds)) { Microbot.getMouse().click(clickBounds); } else { @@ -1215,10 +1321,6 @@ private boolean checkAndClickHighlightedWidgets() lastActionTime = currentTime; actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); - if (isAbortOnSlot && !isSlotActionSwapEnabled()) { - sleepUntil(this::isOfferScreenOpen, 2500); - } - if (isOfferScreenOpen()) { offerScreenActionCount++; } @@ -1226,30 +1328,11 @@ private boolean checkAndClickHighlightedWidgets() // If confirming an offer, dismiss any price warning dialog and wait for offer screen to close if (isConfirm) { log.info("Clicked Confirm button. Checking for warning dialog or waiting for offer screen to close..."); - // The price warning dialog can appear later than the first check. Keep - // looking for it for the whole wait: if it is left on screen the offer - // screen never closes and the offer is abandoned. - long confirmDeadline = System.currentTimeMillis() + 6000; - boolean offerScreenClosed = false; - while (System.currentTimeMillis() < confirmDeadline) { - if (Rs2Widget.hasWidget("Your offer is much") || Rs2Widget.hasWidget("Are you sure")) { - log.info("Warning dialog appeared ('Your offer is much' / 'Are you sure'). Confirming 'Yes'..."); - Rs2Widget.clickWidget("Yes"); - sleep(300, 500); - continue; - } - if (!isOfferScreenOpen()) { - offerScreenClosed = true; - break; - } - sleep(100, 200); - } - if (!offerScreenClosed && (Rs2Widget.hasWidget("Your offer is much") || Rs2Widget.hasWidget("Are you sure"))) { - log.info("Warning dialog still open at timeout; confirming 'Yes' and rechecking."); + if (sleepUntil(() -> Rs2Widget.hasWidget("Your offer is much") || Rs2Widget.hasWidget("Are you sure"), 1200)) { + log.info("Warning dialog appeared ('Your offer is much' / 'Are you sure'). Confirming 'Yes'..."); Rs2Widget.clickWidget("Yes"); - offerScreenClosed = sleepUntil(() -> !isOfferScreenOpen(), 2000); } - if (!offerScreenClosed) { + if (!sleepUntil(() -> !isOfferScreenOpen(), 4000)) { log.warn("Offer screen did not close after confirm. Backing out to overview."); backToOverview(); return false; @@ -1300,4 +1383,4 @@ private boolean checkAndInteractHighlightedNpc() } return false; } -} \ No newline at end of file +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/geflipper/SlotActionExecutor.java b/src/main/java/net/runelite/client/plugins/microbot/geflipper/SlotActionExecutor.java new file mode 100644 index 0000000000..cdd1239b5b --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/SlotActionExecutor.java @@ -0,0 +1,72 @@ +package net.runelite.client.plugins.microbot.geflipper; + +import net.runelite.api.MenuAction; +import net.runelite.api.MenuEntry; +import net.runelite.api.Point; + +/** Executes supported slot operations without falling back to "View offer". */ +final class SlotActionExecutor { + enum Action { + ABORT("Abort offer", 2), + MODIFY("Modify offer", 3); + + final String option; + final int identifier; + Action(String option, int identifier) { + this.option = option; + this.identifier = identifier; + } + } + + enum Result { + /** The action was issued. The caller must still confirm the expected screen opened. */ + ACTED, + /** Slot swap is off, so a left click would open "View offer" instead of the slot action. */ + SWAP_DISABLED, + /** The slot's button widget could not be resolved or is off screen. */ + SLOT_UNAVAILABLE, + /** The click point was not ready in time. */ + MENU_NOT_READY + } + + interface Ui { + boolean slotSwapEnabled(); + /** Returns null unless the visible slot exposes this exact widget operation. */ + Point actionPoint(int slotId, Action action); + void hover(Point point); + boolean awaitDefaultAction(int slotId, Action action, Point point); + /** Revalidates the suggestion and default action immediately before the click. */ + boolean clickDefaultAction(int slotId, Action action, Point point); + boolean invokeAction(int slotId, Action action, Point point); + } + + static Result execute(FlipperConfig.SlotAction mode, Action action, int slotId, Ui ui) { + if (mode == FlipperConfig.SlotAction.COPILOT_LEFT_CLICK && !ui.slotSwapEnabled()) { + return Result.SWAP_DISABLED; + } + Point point = ui.actionPoint(slotId, action); + if (point == null) return Result.SLOT_UNAVAILABLE; + if (mode == FlipperConfig.SlotAction.MENU_OPTION) { + return ui.invokeAction(slotId, action, point) ? Result.ACTED : Result.MENU_NOT_READY; + } + ui.hover(point); + if (!ui.awaitDefaultAction(slotId, action, point)) return Result.MENU_NOT_READY; + return ui.clickDefaultAction(slotId, action, point) ? Result.ACTED : Result.MENU_NOT_READY; + } + + static boolean supportsAction(String[] actions, Action action) { + return actions != null && action.identifier > 0 && actions.length >= action.identifier + && action.option.equals(actions[action.identifier - 1]); + } + + /** The last menu entry is the actual left-click operation after Copilot's swap. */ + static boolean matchesDefaultAction(MenuEntry[] entries, int slotId, Action action) { + if (entries == null || entries.length == 0) return false; + MenuEntry top = entries[entries.length - 1]; + return top != null + && top.getType() == MenuAction.CC_OP + && !top.isDeprioritized() + && top.getParam1() == slotId && top.getParam0() == 2 + && top.getIdentifier() == action.identifier && action.option.equals(top.getOption()); + } +} diff --git a/src/main/resources/net/runelite/client/plugins/microbot/geflipper/docs/README.md b/src/main/resources/net/runelite/client/plugins/microbot/geflipper/docs/README.md index 70df53cf4a..3b0f75cb89 100644 --- a/src/main/resources/net/runelite/client/plugins/microbot/geflipper/docs/README.md +++ b/src/main/resources/net/runelite/client/plugins/microbot/geflipper/docs/README.md @@ -45,11 +45,16 @@ The **GE Flipper Plugin** is an automation tool for Old School RuneScape, design ## Configuration -The plugin provides a configuration panel (`FlipperConfig`) where you can: +Set item preferences and flipping strategies in Flipping Copilot. In GE Flipper: -- Select items to flip -- Set margin thresholds and flipping strategies -- Adjust advanced options (delays, anti-patterns, etc.) +- **Suggestion Selection:** use Hotkey (E) or Mouse to accept Copilot's price and quantity prompts. +- **Copilot left-click swap:** directly below Suggestion Selection, choose **On** or **Off**. On uses Copilot's swapped left-click: enable slot swap in Copilot too. The mouse moves smoothly to the slot and waits for the suggested Modify/Abort action before clicking. Off selects the supported slot action directly. This controls GE Flipper without changing Copilot's own setting. Existing selections are preserved. If an action is unavailable, GE Flipper reports it instead of clicking View offer. +- **Show Overlay:** display profit, runtime and actionable errors. The permanent Slot Swap row is not shown. +- **Verbose Logging:** enable detailed GE Flipper logs without changing other plugins' logging. + +A successful Modify action opens the GE's modify setup, where GE Flipper accepts the suggested price and confirms it. It should not open View offer and repeatedly back out. If the required left-click action is unavailable, GE Flipper waits and shows the reason in its overlay. + +See [review validation](REVIEW_VALIDATION.md) for logging lifecycle and slot-action test coverage. --- @@ -96,4 +101,4 @@ The plugin provides a configuration panel (`FlipperConfig`) where you can: --- -**Automate your merchanting and maximize your profits with the GE Flipper Plugin!** \ No newline at end of file +**Automate your merchanting and maximize your profits with the GE Flipper Plugin!** diff --git a/src/main/resources/net/runelite/client/plugins/microbot/geflipper/docs/REVIEW_VALIDATION.md b/src/main/resources/net/runelite/client/plugins/microbot/geflipper/docs/REVIEW_VALIDATION.md new file mode 100644 index 0000000000..7e2acdca96 --- /dev/null +++ b/src/main/resources/net/runelite/client/plugins/microbot/geflipper/docs/REVIEW_VALIDATION.md @@ -0,0 +1,38 @@ +# GE Flipper 1.2.6 review validation + +This records the checks for the [requested changes on PR #550](https://github.com/chsami/Microbot-Hub/pull/550#pullrequestreview-5219550419). + +## Shared logging + +GE Flipper no longer detaches or stops ROOT appenders, changes ROOT's level, or changes shared GameChatAppender configuration. Verbose Logging changes only the GE Flipper package logger and takes effect immediately. + +`FlipperPluginLoggingTest` exercises actual plugin startup/shutdown with trading stubbed. It checks another script namespace's INFO/WARN messages before, during and after the lifecycle through the real GameChatAppender filters, plus verbosity changes through EventBus. ROOT appender identities, started states, ROOT level and shared chat settings must remain unchanged. Tests restore their logging state afterward. + +On 23 September 2026, a live client check also found the other script's verification messages in the game chat buffer before startup, while running, after shutdown and after restart. ROOT appenders and shared settings remained unchanged. This was observed on development build 1.2.85, whose logging implementation is retained in 1.2.6. + +## MODIFY with slot swap on and off + +The handler verifies that the slot exposes the exact supported Modify offer operation. With GE Flipper's Copilot left-click swap On, it moves smoothly to the target and checks the final default menu entry before clicking that same point. With Off, it invokes the validated slot operation directly, independently of Copilot's swap setting. + +If Copilot swap is disabled while GE Flipper is configured to use it, the suggestion pauses with setting instructions. Missing or unready actions do not fall back to View offer. A dispatched MODIFY that fails to open setup pauses the same suggestion before watchdog/highlight fallback paths can loop. + +`SlotActionExecutorTest` covers both swap states, the explicit operation path, missing actions, incorrect default action/slot/child/identifier, and changed final validation. `FlipperScriptModifyTest` covers actionable pauses, transient retries and supported widget operations. + +Live development-build checks on 23 September observed: + +- Swap on: actual Modify offer menu operations followed by E price input and successful confirmation. Two recorded mouse approaches contained 56 and 32 intermediate move events. +- Explicit slot action with swap off: Modify at 15:52:55 AEST followed by E price input and successful confirmation at 15:53:04. +- A reviewed run from 10:43–15:57 had no GE Flipper errors, 136 Modify actions, 40 Abort actions and 286 successful confirmations. Two temporary Modify waits recovered; two warnings occurred during mismatched setting changes. + +These observations verify the action routes and Hotkey selection. They do not claim a completed live Mouse-selection test on the final release label. + +## Reproduction + +The tests use JUnit 5, matching the repository's Gradle test runner. From a clean checkout with JDK 11 and Microbot 2.6.22: + +```sh +./gradlew FlipperPluginJar -PpluginList=FlipperPlugin -PmicrobotClientVersion=2.6.22 +./gradlew test --tests 'net.runelite.client.plugins.microbot.geflipper.*' -PpluginList=FlipperPlugin -PmicrobotClientVersion=2.6.22 +``` + +No live client is required by the automated tests. They include 25 cases across the three GE Flipper test classes. The plugin build and JUnit 5 suite were checked in a checkout containing only the proposed GE Flipper changes, excluding unrelated local edits. diff --git a/src/test/java/net/runelite/client/plugins/microbot/geflipper/FlipperPluginLoggingTest.java b/src/test/java/net/runelite/client/plugins/microbot/geflipper/FlipperPluginLoggingTest.java new file mode 100644 index 0000000000..d5d5a46dc3 --- /dev/null +++ b/src/test/java/net/runelite/client/plugins/microbot/geflipper/FlipperPluginLoggingTest.java @@ -0,0 +1,219 @@ +package net.runelite.client.plugins.microbot.geflipper; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.LoggerContext; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.Appender; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import net.runelite.client.eventbus.EventBus; +import net.runelite.client.events.ConfigChanged; +import net.runelite.client.plugins.microbot.GameChatAppender; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; + +import static org.junit.jupiter.api.Assertions.*; + +/** Exercises the real plugin lifecycle without starting trading or requiring a game client. */ +public class FlipperPluginLoggingTest { + private static final String PACKAGE = "net.runelite.client.plugins.microbot.geflipper"; + + @Test + public void quietStartupAndVerboseTogglePreserveOtherScriptsLogging() throws Exception { + verifyLifecycle(false); + } + + @Test + public void verboseStartupAndQuietTogglePreserveOtherScriptsLogging() throws Exception { + verifyLifecycle(true); + } + + private void verifyLifecycle(boolean initiallyVerbose) throws Exception { + LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory(); + Logger root = context.getLogger(Logger.ROOT_LOGGER_NAME); + Logger own = context.getLogger(PACKAGE); + Logger sibling = context.getLogger("net.runelite.client.plugins.microbot.loggingtest.OtherScript"); + Level originalRootLevel = root.getLevel(); + Level originalOwnLevel = own.getLevel(); + Level originalSiblingLevel = sibling.getLevel(); + boolean originalOwnAdditive = own.isAdditive(); + boolean originalSiblingAdditive = sibling.isAdditive(); + List> originalAppenders = appenders(root); + Map, Boolean> originalStarted = new IdentityHashMap<>(); + for (Appender appender : originalAppenders) { + originalStarted.put(appender, appender.isStarted()); + } + ChatConfiguration originalChat = ChatConfiguration.capture(); + CapturingGameChatAppender sentinel = new CapturingGameChatAppender(); + EventBus eventBus = new EventBus(); + FlipperPlugin plugin = new FlipperPlugin(); + StubScript script = new StubScript(); + AtomicBoolean verbose = new AtomicBoolean(initiallyVerbose); + FlipperConfig config = new FlipperConfig() { + @Override + public boolean verboseLogging() { + return verbose.get(); + } + }; + setPluginField(plugin, "config", config); + setPluginField(plugin, "flipperScript", script); + try { + root.setLevel(Level.INFO); + sibling.setLevel(Level.INFO); + sibling.setAdditive(true); + own.setAdditive(true); + GameChatAppender.updateConfiguration(true, Level.INFO, true); + sentinel.setContext(context); + sentinel.setName("GEFLIPPER_LOGGING_TEST"); + sentinel.start(); + root.addAppender(sentinel); + List> expectedAppenders = appenders(root); + ChatConfiguration expectedChat = ChatConfiguration.capture(); + + assertSiblingMessages(sibling, sentinel, "before startup"); + plugin.startUp(); + assertOwnLogVolume(own, sentinel, initiallyVerbose, "startup"); + assertSharedLoggingUnchanged(root, sentinel, expectedAppenders, expectedChat, originalStarted); + assertSiblingMessages(sibling, sentinel, "while running"); + + eventBus.register(plugin); + verbose.set(!initiallyVerbose); + ConfigChanged event = new ConfigChanged(); + event.setGroup("Flipper Config"); + event.setKey("verboseLogging"); + eventBus.post(event); + assertOwnLogVolume(own, sentinel, !initiallyVerbose, "config change"); + assertSharedLoggingUnchanged(root, sentinel, expectedAppenders, expectedChat, originalStarted); + assertSiblingMessages(sibling, sentinel, "after config change"); + + plugin.shutDown(); + assertEquals(1, script.starts); + assertEquals(1, script.stops); + assertSharedLoggingUnchanged(root, sentinel, expectedAppenders, expectedChat, originalStarted); + assertSiblingMessages(sibling, sentinel, "after shutdown"); + } finally { + eventBus.unregister(plugin); + // Restore even when a regression detaches/stops ROOT appenders or changes shared flags. + for (Appender appender : appenders(root)) { + root.detachAppender(appender); + } + for (Appender appender : originalAppenders) { + root.addAppender(appender); + if (originalStarted.get(appender) && !appender.isStarted()) { + appender.start(); + } else if (!originalStarted.get(appender) && appender.isStarted()) { + appender.stop(); + } + } + sentinel.stop(); + root.setLevel(originalRootLevel); + own.setLevel(originalOwnLevel); + own.setAdditive(originalOwnAdditive); + sibling.setLevel(originalSiblingLevel); + sibling.setAdditive(originalSiblingAdditive); + originalChat.restore(); + } + } + + private static void assertSharedLoggingUnchanged(Logger root, CapturingGameChatAppender sentinel, + List> expectedAppenders, ChatConfiguration expectedChat, + Map, Boolean> originalStarted) throws Exception { + assertEquals(Level.INFO, root.getLevel(), "GE Flipper must not change ROOT's level"); + assertEquals(expectedAppenders, appenders(root), "GE Flipper must not replace or detach ROOT appenders"); + assertTrue(sentinel.isStarted(), "GE Flipper must not stop the shared game chat appender"); + for (Map.Entry, Boolean> entry : originalStarted.entrySet()) { + assertEquals(entry.getValue().booleanValue(), entry.getKey().isStarted(), + "GE Flipper must not start or stop existing ROOT appenders"); + } + ChatConfiguration actual = ChatConfiguration.capture(); + assertEquals(expectedChat.enabled, actual.enabled, "Shared game chat enablement changed"); + assertEquals(expectedChat.minimum, actual.minimum, "Shared game chat minimum level changed"); + assertEquals(expectedChat.microbotOnly, actual.microbotOnly, "Shared game chat scope changed"); + } + + private static void assertSiblingMessages(Logger sibling, CapturingGameChatAppender sentinel, String phase) { + sibling.info(phase + " sibling INFO"); + sibling.warn(phase + " sibling WARN"); + assertTrue(sentinel.messages.contains(phase + " sibling INFO"), "Other script INFO lost " + phase); + assertTrue(sentinel.messages.contains(phase + " sibling WARN"), "Other script WARN lost " + phase); + } + + private static void assertOwnLogVolume(Logger own, CapturingGameChatAppender sentinel, boolean verbose, + String phase) { + assertEquals(verbose ? Level.INFO : Level.WARN, own.getLevel()); + own.info(phase + " own INFO"); + own.warn(phase + " own WARN"); + assertEquals(verbose, sentinel.messages.contains(phase + " own INFO")); + assertTrue(sentinel.messages.contains(phase + " own WARN")); + } + + private static List> appenders(Logger logger) { + List> values = new ArrayList<>(); + logger.iteratorForAppenders().forEachRemaining(values::add); + return values; + } + + private static void setPluginField(FlipperPlugin plugin, String name, Object value) throws Exception { + Field field = FlipperPlugin.class.getDeclaredField(name); + field.setAccessible(true); + field.set(plugin, value); + } + + private static final class StubScript extends FlipperScript { + private int starts; + private int stops; + + @Override + public boolean run(FlipperConfig config) { + starts++; + return true; + } + + @Override + public void shutdown() { + stops++; + } + } + + /** Keeps the real game chat filters, replacing only the client-dependent rendering operation. */ + private static final class CapturingGameChatAppender extends GameChatAppender { + private final List messages = new ArrayList<>(); + + @Override + protected void append(ILoggingEvent event) { + messages.add(event.getFormattedMessage()); + } + } + + private static final class ChatConfiguration { + private final boolean enabled; + private final Level minimum; + private final boolean microbotOnly; + + private ChatConfiguration(boolean enabled, Level minimum, boolean microbotOnly) { + this.enabled = enabled; + this.minimum = minimum; + this.microbotOnly = microbotOnly; + } + + private static ChatConfiguration capture() throws Exception { + return new ChatConfiguration((boolean) read("loggingEnabled"), (Level) read("minimumLevel"), + (boolean) read("onlyMicrobotLogging")); + } + + private static Object read(String name) throws Exception { + Field field = GameChatAppender.class.getDeclaredField(name); + field.setAccessible(true); + return field.get(null); + } + + private void restore() { + GameChatAppender.updateConfiguration(enabled, minimum, microbotOnly); + } + } +} diff --git a/src/test/java/net/runelite/client/plugins/microbot/geflipper/FlipperScriptModifyTest.java b/src/test/java/net/runelite/client/plugins/microbot/geflipper/FlipperScriptModifyTest.java new file mode 100644 index 0000000000..74a9851711 --- /dev/null +++ b/src/test/java/net/runelite/client/plugins/microbot/geflipper/FlipperScriptModifyTest.java @@ -0,0 +1,68 @@ +package net.runelite.client.plugins.microbot.geflipper; + +import java.lang.reflect.Field; +import org.junit.jupiter.api.Test; + +import static net.runelite.client.plugins.microbot.geflipper.SlotActionExecutor.Action.MODIFY; +import static net.runelite.client.plugins.microbot.geflipper.SlotActionExecutor.Result.*; +import static org.junit.jupiter.api.Assertions.*; + +/** Covers real script failure handling, complementing the swap-on/off executor tests. */ +public class FlipperScriptModifyTest { + @Test + public void swapOffPausesWithAnActionableSettingName() throws Exception { + FlipperScript script = new FlipperScript(); + assertTrue(script.handleSlotActionFailure(SWAP_DISABLED, MODIFY, "same-suggestion")); + assertEquals("same-suggestion", blockedKey(script)); + assertTrue(script.getSlotActionStatus().contains("Enable Copilot slot swap")); + assertTrue(script.getSlotActionStatus().contains("Slot menu action")); + } + + @Test + public void menuTimeoutConsumesTheTickButDoesNotPermanentlyPause() throws Exception { + FlipperScript script = new FlipperScript(); + assertTrue(script.handleSlotActionFailure(MENU_NOT_READY, MODIFY, "same-suggestion")); + assertNull(blockedKey(script)); + assertTrue(script.getSlotActionStatus().contains("Will retry")); + assertFalse(script.handleSlotActionFailure(ACTED, MODIFY, "same-suggestion")); + assertNull(blockedKey(script)); + } + + @Test + public void unavailableActionDoesNotBlockTheSuggestionAndOffersRecovery() throws Exception { + FlipperScript script = new FlipperScript(); + assertTrue(script.handleSlotActionFailure(SLOT_UNAVAILABLE, MODIFY, "same-suggestion")); + assertNull(blockedKey(script)); + assertTrue(script.getSlotActionStatus().contains("refresh Copilot suggestions")); + assertTrue(script.getSlotActionStatus().contains("handle the offer manually")); + } + + @Test + public void successfulModifyDoesNotEnterTheFailurePath() throws Exception { + FlipperScript script = new FlipperScript(); + assertFalse(script.handleSlotActionFailure(ACTED, MODIFY, "same-suggestion")); + assertNull(blockedKey(script)); + assertEquals("", script.getSlotActionStatus()); + } + + @Test + public void modifyRequiresTheActualSupportedWidgetOperation() { + assertTrue(SlotActionExecutor.supportsAction(new String[]{"View offer", "Abort offer", "Modify offer"}, MODIFY)); + assertFalse(SlotActionExecutor.supportsAction(new String[]{"View offer", "Abort offer"}, MODIFY)); + assertFalse(SlotActionExecutor.supportsAction(new String[]{"View offer", "Abort offer", "View offer"}, MODIFY)); + assertFalse(SlotActionExecutor.supportsAction(new String[]{"Modify offer", "Abort offer"}, MODIFY)); + } + + @Test + public void missingWidgetActionsNeverCauseAnInvalidIndexAccess() { + assertFalse(SlotActionExecutor.supportsAction(null, MODIFY)); + assertFalse(SlotActionExecutor.supportsAction(new String[0], MODIFY)); + assertFalse(SlotActionExecutor.supportsAction(new String[]{"View offer", "Abort offer", null}, MODIFY)); + } + + private static Object blockedKey(FlipperScript script) throws Exception { + Field field = FlipperScript.class.getDeclaredField("blockedSlotActionKey"); + field.setAccessible(true); + return field.get(script); + } +} diff --git a/src/test/java/net/runelite/client/plugins/microbot/geflipper/SlotActionExecutorTest.java b/src/test/java/net/runelite/client/plugins/microbot/geflipper/SlotActionExecutorTest.java new file mode 100644 index 0000000000..2b58ce0976 --- /dev/null +++ b/src/test/java/net/runelite/client/plugins/microbot/geflipper/SlotActionExecutorTest.java @@ -0,0 +1,281 @@ +package net.runelite.client.plugins.microbot.geflipper; + +import java.lang.reflect.Proxy; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import net.runelite.api.MenuAction; +import net.runelite.api.MenuEntry; +import net.runelite.api.Point; +import org.junit.jupiter.api.Test; + +import static net.runelite.client.plugins.microbot.geflipper.FlipperConfig.SlotAction.COPILOT_LEFT_CLICK; +import static net.runelite.client.plugins.microbot.geflipper.FlipperConfig.SlotAction.MENU_OPTION; +import static net.runelite.client.plugins.microbot.geflipper.SlotActionExecutor.Action.ABORT; +import static net.runelite.client.plugins.microbot.geflipper.SlotActionExecutor.Action.MODIFY; +import static net.runelite.client.plugins.microbot.geflipper.SlotActionExecutor.Result.ACTED; +import static net.runelite.client.plugins.microbot.geflipper.SlotActionExecutor.Result.MENU_NOT_READY; +import static net.runelite.client.plugins.microbot.geflipper.SlotActionExecutor.Result.SLOT_UNAVAILABLE; +import static net.runelite.client.plugins.microbot.geflipper.SlotActionExecutor.Result.SWAP_DISABLED; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Regression coverage for executing the swapped slot action without opening View offer. */ +public class SlotActionExecutorTest { + private static final int SLOT_ID = (465 << 16) | 9; + + @Test + public void modifyLeftClickWaitsForTheSwapAndClicksTheHoveredPoint() { + FakeUi ui = new FakeUi(); + + assertEquals(ACTED, SlotActionExecutor.execute(COPILOT_LEFT_CLICK, MODIFY, SLOT_ID, ui)); + + assertEquals(Arrays.asList("hover", "await", "click"), ui.operations); + assertSame(ui.point, ui.hoveredPoint); + assertSame(ui.hoveredPoint, ui.clickedPoint); + assertEquals(MODIFY, ui.lastAction); + assertEquals(SLOT_ID, ui.lastSlotId); + assertEquals(1, ui.clicks); + assertEquals(0, ui.invocations); + } + + @Test + public void abortLeftClickUsesTheSameVerifiedSwapPath() { + FakeUi ui = new FakeUi(); + + assertEquals(ACTED, SlotActionExecutor.execute(COPILOT_LEFT_CLICK, ABORT, SLOT_ID, ui)); + + assertEquals(Arrays.asList("hover", "await", "click"), ui.operations); + assertEquals(ABORT, ui.lastAction); + assertEquals(1, ui.clicks); + assertEquals(0, ui.invocations); + } + + @Test + public void disabledSwapCannotFallBackToOpeningTheOffer() { + for (SlotActionExecutor.Action action : SlotActionExecutor.Action.values()) { + FakeUi ui = new FakeUi(); + ui.swapEnabled = false; + + assertEquals(SWAP_DISABLED, + SlotActionExecutor.execute(COPILOT_LEFT_CLICK, action, SLOT_ID, ui)); + + assertTrue(ui.operations.isEmpty()); + assertEquals(0, ui.clicks); + assertEquals(0, ui.invocations); + } + } + + @Test + public void menuOptionDoesNotDependOnCopilotSwapForEitherAction() { + for (boolean swapEnabled : new boolean[]{false, true}) { + for (SlotActionExecutor.Action action : SlotActionExecutor.Action.values()) { + FakeUi ui = new FakeUi(); + ui.swapEnabled = swapEnabled; + + assertEquals(ACTED, SlotActionExecutor.execute(MENU_OPTION, action, SLOT_ID, ui)); + + assertEquals(Arrays.asList("invoke"), ui.operations); + assertEquals(action, ui.lastAction); + assertEquals(SLOT_ID, ui.lastSlotId); + assertEquals(0, ui.clicks); + assertEquals(1, ui.invocations); + } + } + } + + @Test + public void missingSlotNeverClicksADefaultCanvasRectangle() { + for (FlipperConfig.SlotAction mode : new FlipperConfig.SlotAction[]{COPILOT_LEFT_CLICK, MENU_OPTION}) { + FakeUi ui = new FakeUi(); + ui.point = null; + + assertEquals(SLOT_UNAVAILABLE, SlotActionExecutor.execute(mode, MODIFY, SLOT_ID, ui)); + + assertTrue(ui.operations.isEmpty()); + assertEquals(0, ui.clicks); + assertEquals(0, ui.invocations); + } + } + + @Test + public void unreadyMenuDoesNotClickOrInvokeAFallbackAction() { + FakeUi ui = new FakeUi(); + ui.menuReady = false; + + assertEquals(MENU_NOT_READY, + SlotActionExecutor.execute(COPILOT_LEFT_CLICK, MODIFY, SLOT_ID, ui)); + + assertEquals(Arrays.asList("hover", "await"), ui.operations); + assertEquals(0, ui.clicks); + assertEquals(0, ui.invocations); + } + + @Test + public void changedMenuAtFinalValidationDoesNotReportSuccessOrInvokeFallback() { + FakeUi ui = new FakeUi(); + ui.finalValidation = false; + + assertEquals(MENU_NOT_READY, + SlotActionExecutor.execute(COPILOT_LEFT_CLICK, MODIFY, SLOT_ID, ui)); + + assertEquals(Arrays.asList("hover", "await", "rejected click"), ui.operations); + assertEquals(0, ui.clicks); + assertEquals(0, ui.invocations); + } + + @Test + public void changedSuggestionPreventsTheMenuInvocation() { + FakeUi ui = new FakeUi(); + ui.finalValidation = false; + assertEquals(MENU_NOT_READY, SlotActionExecutor.execute(MENU_OPTION, MODIFY, SLOT_ID, ui)); + assertEquals(0, ui.clicks); + assertEquals(0, ui.invocations); + } + + @Test + public void acceptsTheExactTopModifyAndAbortEntries() { + assertTrue(matches(MODIFY, entry("Modify offer", 3, MenuAction.CC_OP, 2, SLOT_ID, false))); + assertTrue(matches(ABORT, entry("Abort offer", 2, MenuAction.CC_OP, 2, SLOT_ID, false))); + } + + @Test + public void matchingActionBelowViewOfferIsNotTheDefaultAction() { + assertFalse(SlotActionExecutor.matchesDefaultAction(new MenuEntry[]{ + entry("Modify offer", 3, MenuAction.CC_OP, 2, SLOT_ID, false), + entry("View offer", 1, MenuAction.CC_OP, 2, SLOT_ID, false) + }, SLOT_ID, MODIFY)); + } + + @Test + public void modifyDoesNotAcceptAbortOrSimilarOptionText() { + assertFalse(matches(MODIFY, entry("Abort offer", 2, MenuAction.CC_OP, 2, SLOT_ID, false))); + assertFalse(matches(MODIFY, entry("Modify offer later", 3, MenuAction.CC_OP, 2, SLOT_ID, false))); + } + + @Test + public void refusesADifferentSlot() { + assertFalse(matches(MODIFY, entry("Modify offer", 3, MenuAction.CC_OP, 2, SLOT_ID + 1, false))); + } + + @Test + public void refusesADifferentWidgetChild() { + assertFalse(matches(MODIFY, entry("Modify offer", 3, MenuAction.CC_OP, 1, SLOT_ID, false))); + } + + @Test + public void refusesTheWrongWidgetOperationIdentifier() { + assertFalse(matches(MODIFY, entry("Modify offer", 1, MenuAction.CC_OP, 2, SLOT_ID, false))); + assertFalse(matches(ABORT, entry("Abort offer", 3, MenuAction.CC_OP, 2, SLOT_ID, false))); + } + + @Test + public void refusesNonWidgetActionsEvenWhenTheLabelMatches() { + assertFalse(matches(MODIFY, entry("Modify offer", 3, MenuAction.RUNELITE, 2, SLOT_ID, false))); + } + + @Test + public void refusesAnActionThatIsStillDeprioritized() { + assertFalse(matches(MODIFY, entry("Modify offer", 3, MenuAction.CC_OP, 2, SLOT_ID, true))); + } + + @Test + public void absentMenuOrTopEntryIsNotReady() { + assertFalse(SlotActionExecutor.matchesDefaultAction(null, SLOT_ID, MODIFY)); + assertFalse(SlotActionExecutor.matchesDefaultAction(new MenuEntry[0], SLOT_ID, MODIFY)); + assertFalse(SlotActionExecutor.matchesDefaultAction(new MenuEntry[]{null}, SLOT_ID, MODIFY)); + } + + private static boolean matches(SlotActionExecutor.Action action, MenuEntry entry) { + return SlotActionExecutor.matchesDefaultAction(new MenuEntry[]{entry}, SLOT_ID, action); + } + + private static MenuEntry entry(String option, int identifier, MenuAction type, + int child, int slotId, boolean deprioritized) { + return (MenuEntry) Proxy.newProxyInstance(MenuEntry.class.getClassLoader(), + new Class[]{MenuEntry.class}, (proxy, method, args) -> { + switch (method.getName()) { + case "getOption": return option; + case "getIdentifier": return identifier; + case "getType": return type; + case "getParam0": return child; + case "getParam1": return slotId; + case "isDeprioritized": return deprioritized; + case "toString": return option + "@" + slotId; + default: throw new AssertionError("Unexpected MenuEntry call: " + method.getName()); + } + }); + } + + private static final class FakeUi implements SlotActionExecutor.Ui { + final List operations = new ArrayList<>(); + boolean swapEnabled = true; + boolean menuReady = true; + boolean finalValidation = true; + Point point = new Point(240, 175); + Point hoveredPoint; + Point clickedPoint; + SlotActionExecutor.Action lastAction; + int lastSlotId; + int clicks; + int invocations; + + @Override + public boolean slotSwapEnabled() { + return swapEnabled; + } + + @Override + public Point actionPoint(int slotId, SlotActionExecutor.Action action) { + lastSlotId = slotId; + lastAction = action; + return point; + } + + @Override + public void hover(Point point) { + assertSame(this.point, point); + hoveredPoint = point; + operations.add("hover"); + } + + @Override + public boolean awaitDefaultAction(int slotId, SlotActionExecutor.Action action, Point point) { + assertSame(hoveredPoint, point); + assertEquals(SLOT_ID, slotId); + assertEquals(lastAction, action); + operations.add("await"); + return menuReady; + } + + @Override + public boolean clickDefaultAction(int slotId, SlotActionExecutor.Action action, Point point) { + assertEquals(Arrays.asList("hover", "await"), operations); + assertSame(hoveredPoint, point); + assertTrue(menuReady); + assertEquals(SLOT_ID, slotId); + assertEquals(lastAction, action); + if (!finalValidation) { + operations.add("rejected click"); + return false; + } + clickedPoint = point; + clicks++; + operations.add("click"); + return true; + } + + @Override + public boolean invokeAction(int slotId, SlotActionExecutor.Action action, Point point) { + assertSame(this.point, point); + assertEquals(SLOT_ID, slotId); + assertEquals(lastAction, action); + if (!finalValidation) return false; + invocations++; + operations.add("invoke"); + return true; + } + } +}