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..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 @@ -7,11 +7,72 @@ @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; + } + } + + /** 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", + description = "How to use this plugin", + position = 0 ) default String GUIDE() { return "Automates the Flipping copilot plugin from the plugin hub, \n" + @@ -22,5 +83,25 @@ 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 = 3 + ) + 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..791e975c23 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/geflipper/FlipperOverlay.java @@ -0,0 +1,285 @@ +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()); + } + + 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 0bfabf09c9..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,10 +4,14 @@ 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; +import net.runelite.client.ui.overlay.OverlayManager; + import java.awt.*; @PluginDescriptor( @@ -23,26 +27,113 @@ 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"; + private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(FlipperPlugin.class); @Inject private Client client; @Inject private FlipperScript flipperScript; + + public FlipperScript getFlipperScript() { + return flipperScript; + } @Inject private net.runelite.client.plugins.microbot.geflipper.FlipperConfig config; + @Inject + 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); } + + /** + * 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 { + 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 ignored) { + } + } + + /** Whether the user asked for a full trace of this plugin's own activity. Read defensively. */ + private boolean verboseLoggingEnabled() { + try { + return config != null && config.verboseLogging(); + } catch (Throwable ignored) { + return false; + } + } + @Override protected void startUp() throws AWTException{ - flipperScript.run(); + 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) { + overlayManager.remove(overlay); + } flipperScript.state = State.GOING_TO_GE; flipperScript.shutdown(); } 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..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 @@ -1,11 +1,15 @@ package net.runelite.client.plugins.microbot.geflipper; -import lombok.extern.slf4j.Slf4j; +import com.google.inject.Inject; 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; @@ -19,6 +23,9 @@ 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.*; @@ -37,14 +44,19 @@ 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; 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; private final WorldArea grandExchangeArea = new WorldArea(3136, 3465, 61, 54, 0); State state = State.GOING_TO_GE; @@ -55,6 +67,10 @@ 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 long geClosedSince = 0; + private long strayPageSince = 0; private int[] grandExchangeSlotIds = new int[] { InterfaceID.GeOffers.INDEX_0, @@ -67,7 +83,40 @@ public class FlipperScript extends Script { InterfaceID.GeOffers.INDEX_7 }; + @Inject + private FlipperConfig config; + + @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; + return run(); + } + + private boolean isMouseMode() { + return config != null && config.selectionMethod() == FlipperConfig.SelectionMethod.MOUSE; + } + 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(() -> { @@ -86,7 +135,22 @@ public boolean run() { state = State.MONITORING_COPILOT; return; } - if (!grandExchangeArea.contains(Microbot.getClientThread().invoke(() -> Microbot.getClient().getLocalPlayer().getWorldLocation()))) { + // 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) { + // Backstop for other transient player states; retry next tick. + return; + } + if (playerLocation == null) return; + if (!grandExchangeArea.contains(playerLocation)) { Rs2GrandExchange.walkToGrandExchange(); } state = State.GETTING_COINS; @@ -108,46 +172,189 @@ public boolean run() { break; case MONITORING_COPILOT: - if (!Rs2GrandExchange.isOpen()) { - Rs2GrandExchange.openExchange(); - return; - } + 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 + // 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; + 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 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 = waitForOfferScreenAbortButton(2000); + if (abortBtn != null && Rs2Widget.isWidgetVisible(abortBtn.getId())) { + log.info("Aborting offer via offer screen button '{}'", abortBtn.getId()); + Rs2Widget.clickWidget(abortBtn); + sleep(300, 500); - // Check interaction timeout first - reset ge window state if stuck - long currentTime = System.currentTimeMillis(); - if (Rs2GrandExchange.isOfferScreenOpen() && (currentTime - lastActionTime > interactionTimeout)) { - Rs2GrandExchange.backToOverview(); + // 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); + } - lastActionTime = System.currentTimeMillis(); - actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); - interactionTimeout = Rs2Random.randomGaussian(DEFAULT_INTERACTION_TIMEOUT, INTERACTION_TIMEOUT_VARIANCE); + // Wait for abort to register, then back to overview + sleepUntil(() -> !isOfferScreenOpen() || getOfferScreenAbortButton() == null, 2500); + backToOverview(); + } else { + // 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 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."); + } + backToOverview(); + } + lastActionTime = currentTime; + actionCooldown = Rs2Random.randomGaussian(DEFAULT_ACTION_COOLDOWN, ACTION_COOLDOWN_VARIANCE); + return; + } + } + } catch (Exception ignored) {} + } - log.info("interactionTimeout reached, returning to GE overview."); + // 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(); + return; + } + } else { + offerScreenOpenTime = 0; + offerScreenActionCount = 0; + } + + // 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; } - // Check for Copilot price/quantity messages in chat - if (checkAndPressCopilotKeybind()) return; - - // Check if we need to abort any offers + // Handle slot suggestions before chat/highlight fallbacks. if (checkAndAbortOrModifyIfNeeded()) return; + if (checkAndPressCopilotKeybind()) 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) { 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; @@ -158,13 +365,18 @@ public void shutdown() private boolean initialize() { - if (flippingCopilot != null && suggestionManager != null && highlightController != null) return true; + if (flippingCopilot != null && suggestionManager != null && highlightController != null) { + 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) { + return true; + } + return false; } private Plugin getFlippingCopilot() @@ -174,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); } @@ -219,6 +436,304 @@ 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); + } + + 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 false; + } + + 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; + } + + /** + * 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); + 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) {} + }); + return; + } + } + } + } + } catch (Exception e) { + log.debug("Could not set chatbox value via offerHandler: {}", e.getMessage()); + } + } + } + private Object getSuggestion(Object suggestionManager) { if (suggestionManager == null) return null; @@ -269,120 +784,346 @@ 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) - { - log.warn("Overlay {} does not have a widget field, skipping.", highlightOverlay.getClass().getSimpleName()); + 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) { + // 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; } - - 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 boolean checkAndAbortOrModifyIfNeeded() - { - if (System.currentTimeMillis() - lastActionTime < actionCooldown) return false; - - if (flippingCopilot == null || highlightController == null || suggestionManager == null) return false; - try - { - Object currentSuggestion = getSuggestion(suggestionManager); - if (currentSuggestion == null) return false; - - // 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); - - if (!isAbort && !isModify) return false; - - log.info("Found suggestion type: {}.", isAbort ? "ABORT" : "MODIFY"); - - Widget abortWidget = getWidgetFromOverlay(highlightController, isAbort ? "abort" : "modify"); - if (abortWidget != null) - { - 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; + 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()); } } - catch (Exception e) - { - log.error("Could not process suggestion: {} - ", e.getMessage(), e); - } - return false; + return highlightWidgets; + } + + private Widget getWidgetFromOverlay(Object highlightController, String suggestionType) { + HighlightTarget target = getTargetFromOverlay(highlightController, suggestionType); + return target != null ? target.getWidget() : null; } + 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; + } + + private volatile String slotActionStatus = ""; + private static final int SLOT_ACTION_SETTLE_MS = 3500; + + public enum SuggestedAction { NONE, ABORT, MODIFY } + + public static SuggestedAction classifySuggestion(boolean isAbort, boolean isModify, boolean slotActionSwap) { + if (isAbort) return SuggestedAction.ABORT; + if (isModify) return SuggestedAction.MODIFY; + return SuggestedAction.NONE; + } + + 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()); - /// 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 +1133,120 @@ 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); + } + + // 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 with ESC to prevent chat spam 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() @@ -421,23 +1257,87 @@ 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()); - Rs2Widget.clickWidget(highlightedWidget); + // 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(); + 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; + } + + 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++; + } - // Sometimes, flipping copilot suggestions cost more than what's available in inventory, we should detect and avoid that - 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(); + // 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'..."); + 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; @@ -450,4 +1350,37 @@ 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 && Rs2Npc.hasAction(npc.getId(), "Exchange")) { + String name = new Rs2NpcModel(npc).getName(); + 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; + } + } + } + } catch (Exception e) { + log.error("Could not interact with highlighted NPC: {}", e.getMessage()); + } + return false; + } } 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; + } + } +}