From b371476ffc5ccdbc0f85fd9b0e40c4bff0570260 Mon Sep 17 00:00:00 2001 From: Patrick Date: Wed, 20 May 2026 23:25:33 -0400 Subject: [PATCH 1/3] fix(combat-hotkeys): move prayer toggles off key event thread Prayer hotkeys were calling Rs2Prayer.toggle() directly on the key listener thread, causing focus loss and input lag on every press. Replaced runOnSeperateThread (silently drops calls when the shared ClientThread future is busy) with a plugin-owned ExecutorService. Also added a debug overlay panel toggled via config. Bump to v1.1.2. --- .../combathotkeys/CombatHotkeysConfig.java | 164 ++++++----- .../combathotkeys/CombatHotkeysOverlay.java | 216 ++++++++++++-- .../combathotkeys/CombatHotkeysPlugin.java | 268 ++++++++++++++---- 3 files changed, 507 insertions(+), 141 deletions(-) diff --git a/src/main/java/net/runelite/client/plugins/microbot/combathotkeys/CombatHotkeysConfig.java b/src/main/java/net/runelite/client/plugins/microbot/combathotkeys/CombatHotkeysConfig.java index 2bb17e9285..d8a3e05141 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/combathotkeys/CombatHotkeysConfig.java +++ b/src/main/java/net/runelite/client/plugins/microbot/combathotkeys/CombatHotkeysConfig.java @@ -12,6 +12,35 @@ @ConfigGroup("combathotkeys") public interface CombatHotkeysConfig extends Config { + // ========================================================================= + // DEBUG SECTION — appears at the top (position 0) so it's always visible + // ========================================================================= + + @ConfigSection( + name = "Debug", + description = "Enable the on-screen debug panel and verbose logging to the RuneLite log", + position = 0, + closedByDefault = true + ) + String debugSection = "debugSection"; + + @ConfigItem( + keyName = "debugMode", + name = "Show debug panel", + description = "Renders a debug overlay showing the last key received, last action dispatched, " + + "submitted/succeeded/failed counters, and any error. Also enables TRACE-level logging " + + "from the plugin — check the RuneLite log (Help → Open logs folder) for full detail.", + position = 0, + section = debugSection + ) + default boolean debugMode() { + return false; + } + + // ========================================================================= + // OFFENSIVE SECTION + // ========================================================================= + @ConfigSection( name = "Offensive Hotkeys", description = "Offensive Prayer and attack hotkeys", @@ -86,6 +115,10 @@ public interface CombatHotkeysConfig extends Config { ) default Keybind specialAttackKey() { return Keybind.NOT_SET; } + // ========================================================================= + // DEFENSIVE PRAYERS SECTION + // ========================================================================= + @ConfigSection( name = "Defensive Prayers", description = "Defensive Prayer hotkeys", @@ -129,6 +162,10 @@ default Keybind protectFromMelee() return Keybind.NOT_SET; } + // ========================================================================= + // FOOD & POTIONS SECTION + // ========================================================================= + @ConfigSection( name = "Food & Potions", description = "Food & Potions", @@ -172,6 +209,10 @@ default Keybind drinkPrayerPotion() return Keybind.NOT_SET; } + // ========================================================================= + // GEAR SETUPS SECTION + // ========================================================================= + @ConfigSection( name = "Gear setups", description = "Gear setups", @@ -179,6 +220,17 @@ default Keybind drinkPrayerPotion() ) String gearSetup = "gearSetup"; + @ConfigItem( + keyName = "maxDelay", + name = "Max Equip Delay (ms)", + description = "Maximum random delay (in milliseconds) between equipping items", + position = 0, + section = gearSetup + ) + default int maxDelay() { + return 500; + } + @ConfigItem( keyName = "Hotkey for gear 1", name = "Hotkey for gear 1", @@ -286,13 +338,46 @@ default Keybind gear5() { keyName = "Gear IDs 5", name = "Gear IDs 5", description = "List of Gear IDs comma separated", - position = 2, + position = 10, section = gearSetup ) default String gearList5() { return ""; } + // ========================================================================= + // MULTISKILLING SECTION + // ========================================================================= + + @ConfigSection( + name = "Multiskilling", + description = "Multiskilling hotkeys", + position = 5 + ) + String multiskillingSection = "multiskillingSection"; + + @ConfigItem( + keyName = "Alchemy", + name = "Alchemy", + description = "Keybind to perform alchemy spell", + position = 0, + section = multiskillingSection + ) + default Keybind highAlchemyKey() { return Keybind.NOT_SET; } + + @ConfigItem( + keyName = "itemToAlch", + name = "Item to Alch", + description = "Enter exact item name to alch (e.g. 'Gold Bar')", + position = 1, + section = multiskillingSection + ) + default String itemToAlch() { return null; } + + // ========================================================================= + // DANCE SECTION + // ========================================================================= + @ConfigItem( keyName = "dance boolean", name = "dance", @@ -303,7 +388,6 @@ default boolean yesDance() { return false; } - // config item for a keybind to enable the dance feature @ConfigItem( keyName = "dance", name = "Dance", @@ -314,7 +398,6 @@ default Keybind dance() { return Keybind.NOT_SET; } - // hidden config for worldpoint called tile1 @ConfigItem( keyName = "tile1", name = "", @@ -324,6 +407,7 @@ default Keybind dance() { default WorldPoint tile1() { return null; } + @ConfigItem( keyName = "tile2", name = "", @@ -334,60 +418,22 @@ default WorldPoint tile2() { return null; } - @ConfigItem( - keyName = "maxDelay", - name = "Max Equip Delay (ms)", - description = "Maximum random delay (in milliseconds) between equipping items", - position = 0, - section = gearSetup - ) - default int maxDelay() { - return 500; // default max delay of 500ms - } - - @ConfigSection( - name = "Multiskilling", - description = "Multiskilling hotkeys", - position = 5 - ) - String multiskillingSection = "multiskillingSection"; + // ========================================================================= + // ENUMS + // ========================================================================= - @ConfigItem( - keyName = "Alchemy", - name = "Alchemy", - description = "Keybind to perform alchemy spell", - position = 0, - section = multiskillingSection - ) - default Keybind highAlchemyKey() { return Keybind.NOT_SET; } - - @ConfigItem( - keyName = "itemToAlch", - name = "Item to Alch", - description = "Enter exact item name to alch (e.g. 'Gold Bar')", - position = 1, - section = multiskillingSection - ) - default String itemToAlch() { return null; } - - public enum MeleePrayerOption { + enum MeleePrayerOption { SUPERHUMAN_STRENGTH(Rs2PrayerEnum.SUPERHUMAN_STRENGTH), ULTIMATE_STRENGTH(Rs2PrayerEnum.ULTIMATE_STRENGTH), CHIVALRY(Rs2PrayerEnum.CHIVALRY), PIETY(Rs2PrayerEnum.PIETY); private final Rs2PrayerEnum prayer; - - MeleePrayerOption(Rs2PrayerEnum prayer) { - this.prayer = prayer; - } - - public Rs2PrayerEnum getPrayer() { - return prayer; - } + MeleePrayerOption(Rs2PrayerEnum prayer) { this.prayer = prayer; } + public Rs2PrayerEnum getPrayer() { return prayer; } } - public enum RangedPrayerOption { + enum RangedPrayerOption { SHARP_EYE(Rs2PrayerEnum.SHARP_EYE), HAWK_EYE(Rs2PrayerEnum.HAWK_EYE), EAGLE_EYE(Rs2PrayerEnum.EAGLE_EYE), @@ -395,30 +441,18 @@ public enum RangedPrayerOption { RIGOUR(Rs2PrayerEnum.RIGOUR); private final Rs2PrayerEnum prayer; - - RangedPrayerOption(Rs2PrayerEnum prayer) { - this.prayer = prayer; - } - - public Rs2PrayerEnum getPrayer() { - return prayer; - } + RangedPrayerOption(Rs2PrayerEnum prayer) { this.prayer = prayer; } + public Rs2PrayerEnum getPrayer() { return prayer; } } - public enum MagicPrayerOption { + enum MagicPrayerOption { MYSTIC_WILL(Rs2PrayerEnum.MYSTIC_WILL), MYSTIC_LORE(Rs2PrayerEnum.MYSTIC_LORE), MYSTIC_MIGHT(Rs2PrayerEnum.MYSTIC_MIGHT), AUGURY(Rs2PrayerEnum.AUGURY); private final Rs2PrayerEnum prayer; - - MagicPrayerOption(Rs2PrayerEnum prayer) { - this.prayer = prayer; - } - - public Rs2PrayerEnum getPrayer() { - return prayer; - } + MagicPrayerOption(Rs2PrayerEnum prayer) { this.prayer = prayer; } + public Rs2PrayerEnum getPrayer() { return prayer; } } } diff --git a/src/main/java/net/runelite/client/plugins/microbot/combathotkeys/CombatHotkeysOverlay.java b/src/main/java/net/runelite/client/plugins/microbot/combathotkeys/CombatHotkeysOverlay.java index b8b9fea28b..7e425e610d 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/combathotkeys/CombatHotkeysOverlay.java +++ b/src/main/java/net/runelite/client/plugins/microbot/combathotkeys/CombatHotkeysOverlay.java @@ -15,58 +15,234 @@ import javax.annotation.Nullable; import javax.inject.Inject; import java.awt.*; +import java.time.Instant; public class CombatHotkeysOverlay extends Overlay { - CombatHotkeysConfig config; + private static final int PANEL_X = 10; + private static final int PANEL_Y = 10; + private static final int PANEL_W = 310; + private static final int ROW_H = 16; + private static final int PAD = 6; + + private static final Color BG_COLOR = new Color(0, 0, 0, 180); + private static final Color BORDER_COLOR = new Color(255, 165, 0, 220); // orange + private static final Color LABEL_COLOR = new Color(180, 180, 180); + private static final Color VALUE_COLOR = Color.WHITE; + private static final Color OK_COLOR = new Color(80, 220, 80); + private static final Color ERR_COLOR = new Color(255, 80, 80); + private static final Color TITLE_COLOR = new Color(255, 165, 0); + + private final CombatHotkeysPlugin plugin; + private final CombatHotkeysConfig config; + @Inject CombatHotkeysOverlay(CombatHotkeysPlugin plugin, CombatHotkeysConfig config) { - super(plugin); + this.plugin = plugin; this.config = config; setPosition(OverlayPosition.DYNAMIC); setNaughty(); setLayer(OverlayLayer.ABOVE_SCENE); } + @Override public Dimension render(Graphics2D graphics) { try { - if(config.yesDance()) { + // ---------------------------------------------------------------- + // Dance tiles (original behaviour — always rendered when enabled) + // ---------------------------------------------------------------- + if (config.yesDance()) { drawTile(graphics, config.tile1(), Color.GREEN, "Tile 1", new BasicStroke(2)); drawTile(graphics, config.tile2(), Color.GREEN, "Tile 2", new BasicStroke(2)); } - } catch(Exception ex) { + + // ---------------------------------------------------------------- + // Debug panel — only rendered when debug mode is on + // ---------------------------------------------------------------- + if (config.debugMode()) { + renderDebugPanel(graphics); + } + + } catch (Exception ex) { Microbot.logStackTrace(this.getClass().getSimpleName(), ex); } return null; } - private void drawTile(Graphics2D graphics, WorldPoint point, Color color, @Nullable String label, Stroke borderStroke) + + // ------------------------------------------------------------------------- + // DEBUG PANEL + // ------------------------------------------------------------------------- + + /** + * Renders a compact diagnostic panel in the top-left corner of the game + * canvas. All data is read atomically from the plugin's public fields so + * this method never touches game state. + * + * Layout (each row is ROW_H px tall): + * ┌─ Combat Hotkeys DEBUG (v1.1.2) ──────────────────────────┐ + * │ Last key received : protectMelee │ + * │ Key age : 0.3 s ago │ + * │ Last action : toggle prayer PROTECT_MELEE │ + * │ Submitted : 4 │ + * │ Succeeded : 4 Failed: 0 │ + * │ Last error : - │ + * │ Logged in : true │ + * │ Thread : AWT-EventQueue-0 │ + * └──────────────────────────────────────────────────────────┘ + * + * Reading this panel tells you at a glance: + * - "Last key received" never updates → keyPressed is not firing; the + * keybind in config does not match what you're pressing, or the + * KeyManager is not registered. + * - "Last key received" updates but "Last action" doesn't → dispatch() + * was reached but the executor was null/shutdown (logged as an error). + * - Submitted increments but Succeeded doesn't → Rs2Prayer.toggle() + * threw; check "Last error" and the RuneLite log. + * - Everything looks fine but prayer doesn't toggle → Rs2Prayer itself + * has a bug (e.g. prayer tab not open, out of prayer points). + */ + private void renderDebugPanel(Graphics2D graphics) { + Font originalFont = graphics.getFont(); + Font monoFont = new Font(Font.MONOSPACED, Font.PLAIN, 11); + graphics.setFont(monoFont); + FontMetrics fm = graphics.getFontMetrics(monoFont); + + // Collect all rows to render first so we can size the background + String keyReceived = plugin.getLastKeyReceived().get(); + String lastAction = plugin.getLastActionDispatched().get(); + int submitted = plugin.getTotalActionsSubmitted().get(); + int succeeded = plugin.getTotalActionsSucceeded().get(); + int failed = plugin.getTotalActionsFailed().get(); + String lastError = plugin.getLastError().get(); + boolean loggedIn = Microbot.isLoggedIn(); + + // Age of last keypress + String keyAge; + long ts = plugin.getLastKeyTimestamp(); + if (ts == 0) { + keyAge = "never"; + } else { + long ageSec = (Instant.now().toEpochMilli() - ts); + keyAge = String.format("%.1f s ago", ageSec / 1000.0); + } + + String[] labels = { + "Last key :", + "Key age :", + "Last action :", + "Submitted :", + "Succeeded :", + "Failed :", + "Last error :", + "Logged in :", + }; + String[] values = { + keyReceived, + keyAge, + lastAction, + String.valueOf(submitted), + String.valueOf(succeeded), + String.valueOf(failed), + lastError, + String.valueOf(loggedIn), + }; + Color[] valueColors = { + VALUE_COLOR, + LABEL_COLOR, + VALUE_COLOR, + VALUE_COLOR, + succeeded > 0 ? OK_COLOR : LABEL_COLOR, + failed > 0 ? ERR_COLOR : LABEL_COLOR, + lastError.equals("-") ? LABEL_COLOR : ERR_COLOR, + loggedIn ? OK_COLOR : ERR_COLOR, + }; + + int rows = labels.length; + int titleH = ROW_H + 4; + int totalH = titleH + rows * ROW_H + PAD * 2; + int x = PANEL_X; + int y = PANEL_Y; + + // Background + graphics.setColor(BG_COLOR); + graphics.fillRoundRect(x, y, PANEL_W, totalH, 8, 8); + + // Border + graphics.setColor(BORDER_COLOR); + graphics.setStroke(new BasicStroke(1.5f)); + graphics.drawRoundRect(x, y, PANEL_W, totalH, 8, 8); + + // Title bar + graphics.setColor(TITLE_COLOR); + Font titleFont = monoFont.deriveFont(Font.BOLD, 11f); + graphics.setFont(titleFont); + String title = "Combat Hotkeys DEBUG v" + CombatHotkeysPlugin.version; + graphics.drawString(title, x + PAD, y + PAD + fm.getAscent()); + graphics.setFont(monoFont); + + // Divider under title + graphics.setColor(BORDER_COLOR); + graphics.setStroke(new BasicStroke(1f)); + graphics.drawLine(x + 1, y + titleH, x + PANEL_W - 1, y + titleH); + + // Data rows + int rowY = y + titleH + PAD; + for (int i = 0; i < rows; i++) { + int baseY = rowY + i * ROW_H + fm.getAscent(); + + // Label (dimmer) + graphics.setColor(LABEL_COLOR); + graphics.drawString(labels[i], x + PAD, baseY); + + // Value (coloured) + graphics.setColor(valueColors[i]); + int labelW = fm.stringWidth(labels[i]); + // Truncate long values so they don't overflow the panel + String val = truncate(values[i], fm, PANEL_W - labelW - PAD * 3); + graphics.drawString(val, x + PAD + labelW + 4, baseY); + } + + graphics.setFont(originalFont); + } + + /** Truncate a string to fit within maxWidth pixels, appending "…" if needed. */ + private String truncate(String s, FontMetrics fm, int maxWidth) { + if (s == null) return "null"; + if (fm.stringWidth(s) <= maxWidth) return s; + while (s.length() > 1 && fm.stringWidth(s + "…") > maxWidth) { + s = s.substring(0, s.length() - 1); + } + return s + "…"; + } + + // ------------------------------------------------------------------------- + // DANCE TILE DRAWING (unchanged from original) + // ------------------------------------------------------------------------- + + private void drawTile(Graphics2D graphics, WorldPoint point, Color color, + @Nullable String label, Stroke borderStroke) { + if (point == null) return; + WorldPoint playerLocation = Rs2Player.getWorldLocation(); + if (playerLocation == null) return; - if (point.distanceTo(playerLocation) >= 32) - { - return; - } + if (point.distanceTo(playerLocation) >= 32) return; LocalPoint lp = LocalPoint.fromWorld(Microbot.getClient(), point); - if (lp == null) - { - return; - } + if (lp == null) return; Polygon poly = Perspective.getCanvasTilePoly(Microbot.getClient(), lp); - if (poly != null) - { + if (poly != null) { OverlayUtil.renderPolygon(graphics, poly, color, new Color(0, 0, 0, 50), borderStroke); } - if (!Strings.isNullOrEmpty(label)) - { - Point canvasTextLocation = Perspective.getCanvasTextLocation(Microbot.getClient(), graphics, lp, label, 0); - if (canvasTextLocation != null) - { + if (!Strings.isNullOrEmpty(label)) { + Point canvasTextLocation = Perspective.getCanvasTextLocation( + Microbot.getClient(), graphics, lp, label, 0); + if (canvasTextLocation != null) { OverlayUtil.renderTextLocation(graphics, canvasTextLocation, label, color); } } diff --git a/src/main/java/net/runelite/client/plugins/microbot/combathotkeys/CombatHotkeysPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/combathotkeys/CombatHotkeysPlugin.java index 54974e744a..8ab43b4ba3 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/combathotkeys/CombatHotkeysPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/combathotkeys/CombatHotkeysPlugin.java @@ -1,6 +1,7 @@ package net.runelite.client.plugins.microbot.combathotkeys; import com.google.inject.Provides; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import net.runelite.api.MenuAction; import net.runelite.api.events.MenuEntryAdded; @@ -24,6 +25,11 @@ import javax.inject.Inject; import java.awt.*; import java.awt.event.KeyEvent; +import java.time.Instant; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import static net.runelite.client.plugins.microbot.util.Global.sleep; @@ -41,7 +47,57 @@ ) @Slf4j public class CombatHotkeysPlugin extends Plugin implements KeyListener { - public static final String version = "1.1.0"; + // v1.1.2 — fix: replaced runOnSeperateThread (silently drops calls when a prior + // task is still running on the shared ClientThread executor) with a + // dedicated single-thread ExecutorService owned by this plugin. + // Added debug logging + on-screen overlay panel to trace hotkey dispatch. + public static final String version = "1.1.2"; + + // ------------------------------------------------------------------------- + // DEBUG STATE — read by CombatHotkeysOverlay to render the debug panel + // ------------------------------------------------------------------------- + + /** True while debug mode is on (toggled via config). */ + @Getter + volatile boolean debugMode = false; + + /** Last hotkey name that reached keyPressed. */ + @Getter + final AtomicReference lastKeyReceived = new AtomicReference<>("-"); + + /** Timestamp of the last keyPressed hit (epoch ms). */ + @Getter + volatile long lastKeyTimestamp = 0; + + /** Last action that was dispatched to the executor. */ + @Getter + final AtomicReference lastActionDispatched = new AtomicReference<>("-"); + + /** How many hotkey actions have been submitted to the executor total. */ + @Getter + final AtomicInteger totalActionsSubmitted = new AtomicInteger(0); + + /** How many hotkey actions completed without throwing. */ + @Getter + final AtomicInteger totalActionsSucceeded = new AtomicInteger(0); + + /** How many hotkey actions threw an exception. */ + @Getter + final AtomicInteger totalActionsFailed = new AtomicInteger(0); + + /** Last error message from the executor, if any. */ + @Getter + final AtomicReference lastError = new AtomicReference<>("-"); + + // ------------------------------------------------------------------------- + // PRIVATE EXECUTOR + // runOnSeperateThread() uses a single scheduledFuture on the ClientThread + // singleton. If *any* other plugin or the script loop has submitted a task + // that hasn't finished yet the gate `if (!scheduledFuture.isDone()) return` + // silently drops our call. A plugin-owned executor has no such contention. + // ------------------------------------------------------------------------- + private ExecutorService hotkeyExecutor; + @Inject private CombatHotkeysConfig config; @@ -62,165 +118,265 @@ CombatHotkeysConfig provideConfig(ConfigManager configManager) { @Inject private CombatHotkeysScript script; + // ------------------------------------------------------------------------- + // LIFECYCLE + // ------------------------------------------------------------------------- @Override protected void startUp() throws AWTException { + hotkeyExecutor = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "CombatHotkeys-executor"); + t.setDaemon(true); + return t; + }); + log.info("[CombatHotkeys] Plugin starting — executor created"); + keyManager.registerKeyListener(this); if (overlayManager != null) { overlayManager.add(overlay); } script.run(config); + log.info("[CombatHotkeys] Plugin started successfully (v{})", version); } + @Override protected void shutDown() { script.shutdown(); keyManager.unregisterKeyListener(this); overlayManager.remove(overlay); + + if (hotkeyExecutor != null) { + hotkeyExecutor.shutdownNow(); + hotkeyExecutor = null; + } + log.info("[CombatHotkeys] Plugin shut down"); + } + + // ------------------------------------------------------------------------- + // HELPERS + // ------------------------------------------------------------------------- + + /** + * Submit an action to the plugin-owned executor. + * + * Every submission is logged so we can tell in the debug overlay (and in + * the RuneLite log) whether the keypress is reaching the dispatcher at all, + * whether the executor accepted it, and whether it threw. + */ + private void dispatch(String actionName, Runnable action) { + if (hotkeyExecutor == null || hotkeyExecutor.isShutdown()) { + log.warn("[CombatHotkeys] dispatch('{}') — executor is null/shutdown, ignoring", actionName); + lastError.set("executor null/shutdown for: " + actionName); + return; + } + + lastActionDispatched.set(actionName); + totalActionsSubmitted.incrementAndGet(); + log.debug("[CombatHotkeys] Submitting '{}' to executor", actionName); + + hotkeyExecutor.submit(() -> { + try { + log.debug("[CombatHotkeys] Executing '{}'", actionName); + action.run(); + totalActionsSucceeded.incrementAndGet(); + log.debug("[CombatHotkeys] '{}' completed OK", actionName); + } catch (Exception ex) { + totalActionsFailed.incrementAndGet(); + lastError.set(actionName + ": " + ex.getMessage()); + log.error("[CombatHotkeys] '{}' threw an exception: {}", actionName, ex.getMessage(), ex); + } + }); } + /** Record which key was just pressed and log it. */ + private void recordKeyHit(String keyName) { + lastKeyReceived.set(keyName); + lastKeyTimestamp = Instant.now().toEpochMilli(); + log.debug("[CombatHotkeys] keyPressed matched: '{}' | loggedIn={} | thread={}", + keyName, + Microbot.isLoggedIn(), + Thread.currentThread().getName()); + } + + // ------------------------------------------------------------------------- + // KEY LISTENER + // ------------------------------------------------------------------------- + @Override public void keyTyped(KeyEvent e) { } @Override public void keyPressed(KeyEvent e) { - if (!Microbot.isLoggedIn()){ + // Refresh debug flag from config on every keypress so toggling it in + // the config panel takes effect immediately without a restart. + debugMode = config.debugMode(); + + if (!Microbot.isLoggedIn()) { + if (debugMode) { + log.debug("[CombatHotkeys] keyPressed — not logged in, ignoring (keyCode={})", e.getKeyCode()); + } return; } - if(config.dance().matches(e)){ + if (config.dance().matches(e)) { + recordKeyHit("dance"); e.consume(); script.dance = !script.dance; + log.debug("[CombatHotkeys] dance toggled -> {}", script.dance); } + // ------------------------------------------------------------------ + // OFFENSIVE PRAYERS + // ------------------------------------------------------------------ if (config.offensiveMeleeKey().matches(e)) { + recordKeyHit("offensiveMelee"); e.consume(); - Rs2Prayer.toggle(config.offensiveMeleePrayer().getPrayer()); + final Rs2PrayerEnum prayer = config.offensiveMeleePrayer().getPrayer(); + dispatch("toggle prayer " + prayer.getName(), () -> Rs2Prayer.toggle(prayer)); } if (config.offensiveRangeKey().matches(e)) { + recordKeyHit("offensiveRange"); e.consume(); - Rs2Prayer.toggle(config.offensiveRangePrayer().getPrayer()); + final Rs2PrayerEnum prayer = config.offensiveRangePrayer().getPrayer(); + dispatch("toggle prayer " + prayer.getName(), () -> Rs2Prayer.toggle(prayer)); } if (config.offensiveMagicKey().matches(e)) { + recordKeyHit("offensiveMagic"); e.consume(); - Rs2Prayer.toggle(config.offensiveMagicPrayer().getPrayer()); + final Rs2PrayerEnum prayer = config.offensiveMagicPrayer().getPrayer(); + dispatch("toggle prayer " + prayer.getName(), () -> Rs2Prayer.toggle(prayer)); } if (config.specialAttackKey().matches(e)) { + recordKeyHit("specialAttack"); e.consume(); - Microbot.getClientThread().runOnSeperateThread(() -> { - Rs2Combat.setSpecState(!Rs2Combat.getSpecState()); - return null; - }); + dispatch("toggle spec", () -> Rs2Combat.setSpecState(!Rs2Combat.getSpecState())); } + // ------------------------------------------------------------------ + // DEFENSIVE / PROTECTION PRAYERS + // ------------------------------------------------------------------ if (config.protectFromMagic().matches(e)) { + recordKeyHit("protectMagic"); e.consume(); - Rs2Prayer.toggle(Rs2PrayerEnum.PROTECT_MAGIC); + dispatch("toggle prayer PROTECT_MAGIC", () -> Rs2Prayer.toggle(Rs2PrayerEnum.PROTECT_MAGIC)); } if (config.protectFromMissles().matches(e)) { + recordKeyHit("protectRange"); e.consume(); - Rs2Prayer.toggle(Rs2PrayerEnum.PROTECT_RANGE); + dispatch("toggle prayer PROTECT_RANGE", () -> Rs2Prayer.toggle(Rs2PrayerEnum.PROTECT_RANGE)); } if (config.protectFromMelee().matches(e)) { + recordKeyHit("protectMelee"); e.consume(); - Rs2Prayer.toggle(Rs2PrayerEnum.PROTECT_MELEE); + dispatch("toggle prayer PROTECT_MELEE", () -> Rs2Prayer.toggle(Rs2PrayerEnum.PROTECT_MELEE)); } + // ------------------------------------------------------------------ + // FOOD & POTIONS + // ------------------------------------------------------------------ if (config.eatBestFood().matches(e)) { + recordKeyHit("eatBestFood"); e.consume(); - Microbot.getClientThread().runOnSeperateThread(() -> { - Rs2Player.useFood(); - return null; - }); + dispatch("eat best food", Rs2Player::useFood); } if (config.eatFastFood().matches(e)) { + recordKeyHit("eatFastFood"); e.consume(); - Microbot.getClientThread().runOnSeperateThread(() -> { - Rs2Player.useFastFood(); - return null; - }); + dispatch("eat fast food", Rs2Player::useFastFood); } if (config.drinkPrayerPotion().matches(e)) { + recordKeyHit("drinkPrayerPotion"); e.consume(); - Microbot.getClientThread().runOnSeperateThread(() -> { - Rs2Player.drinkPrayerPotion(); - return null; - }); + dispatch("drink prayer potion", Rs2Player::drinkPrayerPotion); } + // ------------------------------------------------------------------ + // GEAR SWAPS + // ------------------------------------------------------------------ if (config.gear1().matches(e)) { + recordKeyHit("gear1"); e.consume(); - Microbot.getClientThread().runOnSeperateThread(() -> { - equipGear(config.gearList1()); - return null; - }); + final String list = config.gearList1(); + dispatch("equip gear 1", () -> equipGear(list)); } if (config.gear2().matches(e)) { + recordKeyHit("gear2"); e.consume(); - Microbot.getClientThread().runOnSeperateThread(() -> { - equipGear(config.gearList2()); - return null; - }); + final String list = config.gearList2(); + dispatch("equip gear 2", () -> equipGear(list)); } if (config.gear3().matches(e)) { + recordKeyHit("gear3"); e.consume(); - Microbot.getClientThread().runOnSeperateThread(() -> { - equipGear(config.gearList3()); - return null; - }); + final String list = config.gearList3(); + dispatch("equip gear 3", () -> equipGear(list)); } if (config.gear4().matches(e)) { + recordKeyHit("gear4"); e.consume(); - Microbot.getClientThread().runOnSeperateThread(() -> { - equipGear(config.gearList4()); - return null; - }); + final String list = config.gearList4(); + dispatch("equip gear 4", () -> equipGear(list)); } if (config.gear5().matches(e)) { + recordKeyHit("gear5"); e.consume(); - Microbot.getClientThread().runOnSeperateThread(() -> { - equipGear(config.gearList5()); - return null; - }); + final String list = config.gearList5(); + dispatch("equip gear 5", () -> equipGear(list)); } + // ------------------------------------------------------------------ + // ALCHEMY + // ------------------------------------------------------------------ if (config.highAlchemyKey().matches(e)) { + recordKeyHit("highAlchemy"); e.consume(); - Microbot.getClientThread().runOnSeperateThread(() -> { - Rs2Magic.alch(config.itemToAlch(),50, 75); - return null; - }); + final String item = config.itemToAlch(); + dispatch("high alch " + item, () -> Rs2Magic.alch(item, 50, 75)); } } - private void equipGear(String gearListConfig) { + if (gearListConfig == null || gearListConfig.isBlank()) { + log.warn("[CombatHotkeys] equipGear called with empty/null gear list"); + return; + } String[] itemIDs = gearListConfig.split(","); - for (String value : itemIDs) { - int itemId = Integer.parseInt(value); - Rs2Inventory.equip(itemId); - - int delay = Rs2Random.between(0, config.maxDelay()); - sleep(delay); + value = value.trim(); + if (value.isEmpty()) continue; + try { + int itemId = Integer.parseInt(value); + log.debug("[CombatHotkeys] Equipping item id={}", itemId); + Rs2Inventory.equip(itemId); + int delay = Rs2Random.between(0, config.maxDelay()); + sleep(delay); + } catch (NumberFormatException ex) { + log.error("[CombatHotkeys] Invalid item ID in gear list: '{}'", value); + lastError.set("bad gear ID: " + value); + } } } @Override public void keyReleased(KeyEvent e) {} + // ------------------------------------------------------------------------- + // MENU ENTRY EVENTS (dance tile marking) + // ------------------------------------------------------------------------- + @Subscribe public void onMenuEntryAdded(MenuEntryAdded event) { From 910d0b8a08d02df704240cc65f4f9cf42d14a1da Mon Sep 17 00:00:00 2001 From: Patrick Date: Thu, 21 May 2026 15:38:36 -0400 Subject: [PATCH 2/3] feat(butterfly-catcher): add Butterfly Catcher plugin v1.0.0 Automates butterfly and moth catching for Hunter XP. Supports Ruby Harvest through Moonlight Moth (Varlamore). Barehanded and butterfly net modes with level/equipment checks on startup. --- .../plugins/microbot/PluginConstants.java | 1 + .../ButterflyCatcherConfig.java | 42 +++++ .../ButterflyCatcherPlugin.java | 80 +++++++++ .../ButterflyCatcherScript.java | 169 ++++++++++++++++++ .../butterflycatcher/ButterflyType.java | 139 ++++++++++++++ .../microbot/butterflycatcher/CatchMode.java | 37 ++++ .../microbot/butterflycatcher/docs/README.md | 43 +++++ 7 files changed, 511 insertions(+) create mode 100644 src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyCatcherConfig.java create mode 100644 src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyCatcherPlugin.java create mode 100644 src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyCatcherScript.java create mode 100644 src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyType.java create mode 100644 src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/CatchMode.java create mode 100644 src/main/resources/net/runelite/client/plugins/microbot/butterflycatcher/docs/README.md diff --git a/src/main/java/net/runelite/client/plugins/microbot/PluginConstants.java b/src/main/java/net/runelite/client/plugins/microbot/PluginConstants.java index ed2554033d..0f078012af 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/PluginConstants.java +++ b/src/main/java/net/runelite/client/plugins/microbot/PluginConstants.java @@ -37,6 +37,7 @@ private PluginConstants() public static final String PERT = "[P] "; public static final String DV = "[DV] "; public static final String RED_BRACKET = "[RB] "; + public static final String STKS = "[STKS] "; public static final boolean DEFAULT_ENABLED = false; public static final boolean IS_EXTERNAL = true; //test diff --git a/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyCatcherConfig.java b/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyCatcherConfig.java new file mode 100644 index 0000000000..43fda2eae6 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyCatcherConfig.java @@ -0,0 +1,42 @@ +package net.runelite.client.plugins.microbot.butterflycatcher; + +import net.runelite.client.config.Config; +import net.runelite.client.config.ConfigGroup; +import net.runelite.client.config.ConfigInformation; +import net.runelite.client.config.ConfigItem; + +@ConfigInformation( + "Butterfly Catcher — by StonksCode

" + + "Species (net / barehanded):
" + + "Ruby Harvest 5/15  |  Sapphire Glacialis 25/35  |  Snowy Knight 35/45
" + + "Black Warlock 45/55  |  Sunlight Moth 65/75  |  Moonlight Moth 75/85

" + + "Barehanded — XP only, nothing in inventory
" + + "Butterfly Net — equip net first, 10 levels lower requirement" +) +@ConfigGroup("ButterflyCatcher") +public interface ButterflyCatcherConfig extends Config { + + @ConfigItem( + keyName = "butterflyType", + name = "Butterfly / Moth", + description = "Which creature to hunt.
" + + "Classic: Ruby Harvest, Sapphire Glacialis, Snowy Knight, Black Warlock.
" + + "Varlamore: Sunlight Moth, Moonlight Moth.
" + + "Net level shown in parentheses; barehanded = net + 10.", + position = 0 + ) + default ButterflyType butterflyType() { + return ButterflyType.BLACK_WARLOCK; + } + + @ConfigItem( + keyName = "catchMode", + name = "Catch Mode", + description = "BAREHANDED: catch and release for XP — nothing enters inventory.
" + + "BUTTERFLY_NET: use an equipped butterfly net (lower level requirement).", + position = 1 + ) + default CatchMode catchMode() { + return CatchMode.BAREHANDED; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyCatcherPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyCatcherPlugin.java new file mode 100644 index 0000000000..029c0ad281 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyCatcherPlugin.java @@ -0,0 +1,80 @@ +package net.runelite.client.plugins.microbot.butterflycatcher; + +import com.google.inject.Provides; +import lombok.extern.slf4j.Slf4j; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.plugins.microbot.PluginConstants; + +import javax.inject.Inject; +import java.awt.*; + +/** + * ===================================================================== + * Butterfly Catcher + * Author: StonksCode + * ===================================================================== + * + * Automates butterfly and moth catching for Hunter XP training. + * + * Supported species: + * - Ruby Harvest (net lvl 5 / bare lvl 15) + * - Sapphire Glacialis (net lvl 25 / bare lvl 35) + * - Snowy Knight (net lvl 35 / bare lvl 45) + * - Black Warlock (net lvl 45 / bare lvl 55) + * - Sunlight Moth (net lvl 65 / bare lvl 75) + * - Moonlight Moth (net lvl 75 / bare lvl 85) + * + * Catch modes: + * BAREHANDED — catch and release for XP; nothing enters inventory. + * BUTTERFLY_NET — equip a butterfly net or magic butterfly net before + * starting; allows catching at 10 levels lower than + * the barehanded requirement. + * + * Usage: + * 1. Stand near a spawn of your chosen species. + * 2. If using Butterfly Net mode, equip your net first. + * 3. Select your species and catch mode in the config panel. + * 4. Start the plugin — it will run indefinitely with no banking. + * ===================================================================== + */ +@PluginDescriptor( + name = PluginConstants.STKS + "Butterfly Catcher", + description = "Automates butterfly and moth catching for Hunter XP. Stand near a spawn, pick your species and mode, start the plugin.", + tags = {"hunter", "butterfly", "moth", "sunlight", "moonlight", "net", "microbot", "stonkscode"}, + authors = {"StonksCode"}, + version = ButterflyCatcherPlugin.version, + minClientVersion = "2.0.8", + enabledByDefault = PluginConstants.DEFAULT_ENABLED, + isExternal = PluginConstants.IS_EXTERNAL +) +@Slf4j +public class ButterflyCatcherPlugin extends Plugin { + + public static final String version = "1.0.0"; + + @Inject + private ButterflyCatcherConfig config; + + @Inject + private ButterflyCatcherScript script; + + @Provides + ButterflyCatcherConfig provideConfig(ConfigManager configManager) { + return configManager.getConfig(ButterflyCatcherConfig.class); + } + + @Override + protected void startUp() throws AWTException { + script.run(config); + log.info("[ButterflyCatcher] Started — target: {}, mode: {}", + config.butterflyType().getDisplayName(), config.catchMode()); + } + + @Override + protected void shutDown() { + script.shutdown(); + log.info("[ButterflyCatcher] Stopped."); + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyCatcherScript.java b/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyCatcherScript.java new file mode 100644 index 0000000000..c0739d4bb9 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyCatcherScript.java @@ -0,0 +1,169 @@ +package net.runelite.client.plugins.microbot.butterflycatcher; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Skill; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.Script; +import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; + +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + +/** + * ButterflyCatcherScript + * + * Two modes — both run indefinitely with no banking: + * + * BAREHANDED: + * Click catch, wait for animation/movement to resolve, repeat. + * Requires Hunter level >= target.getBarehandedLevelRequired(). + * + * BUTTERFLY_NET: + * Same loop, but requires a butterfly net or magic butterfly net to be + * equipped. Verified once on startup. Requires Hunter level >= + * target.getNetLevelRequired() (10 levels lower than barehanded). + * + * Item IDs for nets: + * Butterfly net : 10010 + * Magic butterfly net : 11259 + */ +@Slf4j +public class ButterflyCatcherScript extends Script { + + private static final int NET_ITEM_ID = 10010; + private static final int MAGIC_NET_ITEM_ID = 11259; + private static final String TAG = "[ButterflyCatcher]"; + private static final int TICK = 600; + + private ButterflyType targetButterfly; + private CatchMode catchMode; + private boolean catchCommitted = false; + + // ------------------------------------------------------------------------- + // Entry point + // ------------------------------------------------------------------------- + + public boolean run(ButterflyCatcherConfig config) { + this.targetButterfly = config.butterflyType(); + this.catchMode = config.catchMode(); + this.catchCommitted = false; + + int requiredLevel = effectiveLevel(); + Microbot.log(TAG + " Starting — target: " + targetButterfly.getDisplayName() + + " | mode: " + catchMode + + " | Required Hunter level: " + requiredLevel); + + // Net equipment check (BUTTERFLY_NET mode only) + if (catchMode == CatchMode.BUTTERFLY_NET) { + if (!isNetEquipped()) { + Microbot.log(TAG + " No butterfly net equipped! " + + "Equip a Butterfly Net (id 10010) or Magic Butterfly Net (id 11259) " + + "before starting. Stopping."); + Microbot.showMessage("Butterfly Catcher: no butterfly net equipped. Equip one and restart."); + return false; + } + Microbot.log(TAG + " Butterfly net verified."); + } + + // Hunter level check + int hunterLevel = Microbot.getClient().getRealSkillLevel(Skill.HUNTER); + if (hunterLevel < requiredLevel) { + Microbot.log(TAG + " Hunter level too low (" + + hunterLevel + " / " + requiredLevel + + ") for " + targetButterfly.getDisplayName() + + " in " + catchMode + " mode. Stopping."); + Microbot.showMessage("Butterfly Catcher: Hunter level too low (" + + hunterLevel + " / " + requiredLevel + ")."); + return false; + } + + mainScheduledFuture = scheduledExecutorService.scheduleWithFixedDelay( + this::tick, 0, TICK, TimeUnit.MILLISECONDS); + return true; + } + + @Override + public void shutdown() { + super.shutdown(); + catchCommitted = false; + } + + // ------------------------------------------------------------------------- + // Main tick + // ------------------------------------------------------------------------- + + private void tick() { + try { + if (!Microbot.isLoggedIn()) return; + if (!super.run()) return; + + // Stop if level drops below threshold (e.g. de-boost) + int hunterLevel = Microbot.getClient().getRealSkillLevel(Skill.HUNTER); + if (hunterLevel < effectiveLevel()) { + Microbot.log(TAG + " Hunter level too low — stopping."); + shutdown(); + return; + } + + tickCatching(); + + } catch (Exception e) { + log.error(TAG + " Unexpected error in tick: {}", e.getMessage(), e); + } + } + + // ------------------------------------------------------------------------- + // Catching logic + // ------------------------------------------------------------------------- + + private void tickCatching() { + // Don't spam-click while already committed to a catch attempt + if (catchCommitted) { + if (Rs2Player.isAnimating() || Rs2Player.isMoving()) { + return; + } + catchCommitted = false; + } + + if (Rs2Player.isAnimating()) return; + + // Find nearest target NPC using the singleton cache + var target = Microbot.getRs2NpcCache() + .query() + .withIds(targetButterfly.getNpcIds()) + .nearest(); + + if (target == null) { + Microbot.log(TAG + " No " + targetButterfly.getDisplayName() + + " found nearby (IDs: " + Arrays.toString(targetButterfly.getNpcIds()) + ") — waiting."); + return; + } + + boolean clicked = target.click("Catch"); + if (!clicked) { + log.warn(TAG + " click(Catch) failed on {} at {}", + targetButterfly.getDisplayName(), target.getWorldLocation()); + return; + } + + catchCommitted = true; + log.debug(TAG + " Clicked Catch on {} at {}", + targetButterfly.getDisplayName(), target.getWorldLocation()); + sleepUntil(() -> Rs2Player.isAnimating() || Rs2Player.isMoving(), 1500); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private int effectiveLevel() { + return catchMode == CatchMode.BUTTERFLY_NET + ? targetButterfly.getNetLevelRequired() + : targetButterfly.getBarehandedLevelRequired(); + } + + private boolean isNetEquipped() { + return Rs2Equipment.isWearing(NET_ITEM_ID) || Rs2Equipment.isWearing(MAGIC_NET_ITEM_ID); + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyType.java b/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyType.java new file mode 100644 index 0000000000..dabff24f1f --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyType.java @@ -0,0 +1,139 @@ +package net.runelite.client.plugins.microbot.butterflycatcher; + +import lombok.Getter; + +/** + * ButterflyType + * + * Defines all catchable butterflies and moths from the Hunter skill. + * Data sourced from: https://oldschool.runescape.wiki/w/Butterfly_(Hunter) + * + * Level requirements: + * The game requires a Hunter level 10 ABOVE the net level for barehanded catching. + * This enum stores the NET level as the baseline; the script derives the + * barehanded level by adding 10. + * + * Species Net Barehanded + * ─────────────────── ──── ────────── + * Ruby Harvest 5 15 + * Sapphire Glacialis 25 35 + * Snowy Knight 35 45 + * Black Warlock 45 55 + * Sunlight Moth 65 75 + * Moonlight Moth 75 85 + * + * Item IDs: + * Butterfly net : 10010 + * Magic butterfly net: 11259 + * Butterfly jar : 10012 (same for all species) + */ +@Getter +public enum ButterflyType { + + RUBY_HARVEST( + "Ruby Harvest", + new int[]{ 5525 }, + 5, + 10012, + 10009 + ), + SAPPHIRE_GLACIALIS( + "Sapphire Glacialis", + new int[]{ 5526 }, + 25, + 10012, + 10011 + ), + SNOWY_KNIGHT( + "Snowy Knight", + new int[]{ 5527 }, + 35, + 10012, + 10013 + ), + BLACK_WARLOCK( + "Black Warlock", + new int[]{ 5553 }, + 45, + 10012, + 10010 + ), + + /** + * Sunlight Moth — Avium Savannah south of the Hunter Guild. + * Net: 65 | Barehanded: 75 + */ + SUNLIGHT_MOTH( + "Sunlight Moth", + new int[]{ 12770 }, + 65, + 10012, + 28890 + ), + + /** + * Moonlight Moth — Neypotzli / Hunter Guild basement / Tonali Cavern. + * Net: 75 | Barehanded: 85 + * NPC IDs: 12771, 12772, 12773 (variants per location). + */ + MOONLIGHT_MOTH( + "Moonlight Moth", + new int[]{ 12771, 12772, 12773 }, + 75, + 10012, + 28893 + ); + + // ------------------------------------------------------------------------- + + /** Human-readable name shown in the config dropdown. */ + private final String displayName; + + /** + * All NPC IDs for this creature. + * Most species have exactly one. Moonlight Moth has three location variants. + */ + private final int[] npcIds; + + /** + * Hunter level required to catch with a butterfly net (or magic butterfly net). + */ + private final int netLevelRequired; + + /** Item ID of the empty butterfly jar (always 10012). */ + private final int jarItemId; + + /** Item ID placed in the inventory after a successful jar catch. */ + private final int caughtItemId; + + // ------------------------------------------------------------------------- + + ButterflyType(String displayName, int[] npcIds, int netLevelRequired, + int jarItemId, int caughtItemId) { + this.displayName = displayName; + this.npcIds = npcIds; + this.netLevelRequired = netLevelRequired; + this.jarItemId = jarItemId; + this.caughtItemId = caughtItemId; + } + + /** + * Hunter level required to catch bare-handed (always netLevelRequired + 10). + */ + public int getBarehandedLevelRequired() { + return netLevelRequired + 10; + } + + /** + * Returns the primary NPC ID (first in the array). Used for logging. + * The script always iterates all IDs in the array when searching. + */ + public int getNpcId() { + return npcIds[0]; + } + + @Override + public String toString() { + return displayName; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/CatchMode.java b/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/CatchMode.java new file mode 100644 index 0000000000..f1aef6db59 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/CatchMode.java @@ -0,0 +1,37 @@ +package net.runelite.client.plugins.microbot.butterflycatcher; + +/** + * CatchMode + * + * Controls whether the plugin catches bare-handed (XP only) or uses a + * butterfly net / magic butterfly net. + * + * Banking has been intentionally removed — the script runs indefinitely + * without ever needing to visit a bank. + */ +public enum CatchMode { + + /** + * Catch the butterfly/moth bare-handed. + * The creature is instantly released on catch, so nothing enters the + * inventory. Requires Hunter level = netLevelRequired + 10. + */ + BAREHANDED, + + /** + * Catch using a butterfly net (or magic butterfly net). + * The net must be equipped before the script starts — the script will + * verify this on startup and stop with a message if not equipped. + * Requires Hunter level = netLevelRequired (the lower threshold). + */ + BUTTERFLY_NET; + + @Override + public String toString() { + switch (this) { + case BAREHANDED: return "Barehanded"; + case BUTTERFLY_NET: return "Butterfly Net"; + default: return name(); + } + } +} diff --git a/src/main/resources/net/runelite/client/plugins/microbot/butterflycatcher/docs/README.md b/src/main/resources/net/runelite/client/plugins/microbot/butterflycatcher/docs/README.md new file mode 100644 index 0000000000..e213532d98 --- /dev/null +++ b/src/main/resources/net/runelite/client/plugins/microbot/butterflycatcher/docs/README.md @@ -0,0 +1,43 @@ +# Butterfly Catcher +**Author:** StonksCode | **Version:** 1.0.0 + +Automates butterfly and moth catching for Hunter XP. Runs indefinitely with no banking required. + +--- + +## Supported Species + +| Species | Net Level | Barehanded Level | Location | +|---|---|---|---| +| Ruby Harvest | 5 | 15 | Puro-Puro / various | +| Sapphire Glacialis | 25 | 35 | Asgarnian Ice Dungeon area | +| Snowy Knight | 35 | 45 | Asgarnian Ice Dungeon area | +| Black Warlock | 45 | 55 | Feldip Hunter area | +| Sunlight Moth | 65 | 75 | Avium Savannah (Varlamore) | +| Moonlight Moth | 75 | 85 | Hunter Guild / Tonali Cavern (Varlamore) | + +--- + +## Setup + +1. Travel to a spawn location for your chosen species. +2. If using **Butterfly Net** mode, equip your net before starting. +3. Open the plugin config, select your species and catch mode. +4. Enable the plugin — it will run until you stop it. + +--- + +## Catch Modes + +**Barehanded** — Catch and instantly release. Nothing enters your inventory. Requires Hunter level 10 higher than the net threshold. + +**Butterfly Net** — Requires a Butterfly Net (id 10010) or Magic Butterfly Net (id 11259) to be equipped before starting. Allows catching at 10 levels lower than barehanded. + +--- + +## Notes + +- The plugin checks your Hunter level on startup and stops with a message if you don't meet the requirement. +- In Butterfly Net mode, the plugin also verifies your net is equipped before starting. +- No banking — the script runs indefinitely at your chosen spawn. +- Moonlight Moth supports all three location NPC variants (Hunter Guild, Neypotzli, Tonali Cavern). From 06bcba79ac8dad79e063ba47abdac78dbce344bd Mon Sep 17 00:00:00 2001 From: Patrick Date: Tue, 26 May 2026 09:41:50 -0400 Subject: [PATCH 3/3] fix(butterfly-catcher): correct NPC IDs for Ruby Harvest, Sapphire Glacialis, Snowy Knight IDs 5525/5526/5527 were wrong. Correct IDs verified against OSRS wiki: Ruby Harvest 5525 -> 5556 Sapphire Glacialis 5526 -> 5555 Snowy Knight 5527 -> 5554 Bump to v1.0.1. --- .../ButterflyCatcherPlugin.java | 34 +++---------------- .../butterflycatcher/ButterflyType.java | 30 ++++++++-------- 2 files changed, 19 insertions(+), 45 deletions(-) diff --git a/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyCatcherPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyCatcherPlugin.java index 029c0ad281..51bd53037d 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyCatcherPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyCatcherPlugin.java @@ -10,35 +10,6 @@ import javax.inject.Inject; import java.awt.*; -/** - * ===================================================================== - * Butterfly Catcher - * Author: StonksCode - * ===================================================================== - * - * Automates butterfly and moth catching for Hunter XP training. - * - * Supported species: - * - Ruby Harvest (net lvl 5 / bare lvl 15) - * - Sapphire Glacialis (net lvl 25 / bare lvl 35) - * - Snowy Knight (net lvl 35 / bare lvl 45) - * - Black Warlock (net lvl 45 / bare lvl 55) - * - Sunlight Moth (net lvl 65 / bare lvl 75) - * - Moonlight Moth (net lvl 75 / bare lvl 85) - * - * Catch modes: - * BAREHANDED — catch and release for XP; nothing enters inventory. - * BUTTERFLY_NET — equip a butterfly net or magic butterfly net before - * starting; allows catching at 10 levels lower than - * the barehanded requirement. - * - * Usage: - * 1. Stand near a spawn of your chosen species. - * 2. If using Butterfly Net mode, equip your net first. - * 3. Select your species and catch mode in the config panel. - * 4. Start the plugin — it will run indefinitely with no banking. - * ===================================================================== - */ @PluginDescriptor( name = PluginConstants.STKS + "Butterfly Catcher", description = "Automates butterfly and moth catching for Hunter XP. Stand near a spawn, pick your species and mode, start the plugin.", @@ -52,7 +23,10 @@ @Slf4j public class ButterflyCatcherPlugin extends Plugin { - public static final String version = "1.0.0"; + // v1.0.1 — fix: corrected NPC IDs for Ruby Harvest (5556), Sapphire Glacialis (5555), + // and Snowy Knight (5554). Original IDs (5525/5526/5527) were wrong, + // causing those three species to never find targets and do nothing. + public static final String version = "1.0.1"; @Inject private ButterflyCatcherConfig config; diff --git a/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyType.java b/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyType.java index dabff24f1f..f72c06f015 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyType.java +++ b/src/main/java/net/runelite/client/plugins/microbot/butterflycatcher/ButterflyType.java @@ -6,21 +6,21 @@ * ButterflyType * * Defines all catchable butterflies and moths from the Hunter skill. - * Data sourced from: https://oldschool.runescape.wiki/w/Butterfly_(Hunter) + * NPC IDs verified against: https://oldschool.runescape.wiki/w/Butterfly_(Hunter) * * Level requirements: * The game requires a Hunter level 10 ABOVE the net level for barehanded catching. * This enum stores the NET level as the baseline; the script derives the * barehanded level by adding 10. * - * Species Net Barehanded - * ─────────────────── ──── ────────── - * Ruby Harvest 5 15 - * Sapphire Glacialis 25 35 - * Snowy Knight 35 45 - * Black Warlock 45 55 - * Sunlight Moth 65 75 - * Moonlight Moth 75 85 + * Species Net Barehanded NPC ID + * ─────────────────── ──── ────────── ────── + * Ruby Harvest 5 15 5556 + * Sapphire Glacialis 25 35 5555 + * Snowy Knight 35 45 5554 + * Black Warlock 45 55 5553 + * Sunlight Moth 65 75 12770 + * Moonlight Moth 75 85 12771, 12772, 12773 * * Item IDs: * Butterfly net : 10010 @@ -32,28 +32,28 @@ public enum ButterflyType { RUBY_HARVEST( "Ruby Harvest", - new int[]{ 5525 }, + new int[]{ 5556 }, // was 5525 (wrong — wiki confirms 5556) 5, 10012, 10009 ), SAPPHIRE_GLACIALIS( "Sapphire Glacialis", - new int[]{ 5526 }, + new int[]{ 5555 }, // was 5526 (wrong — wiki confirms 5555) 25, 10012, 10011 ), SNOWY_KNIGHT( "Snowy Knight", - new int[]{ 5527 }, + new int[]{ 5554 }, // was 5527 (wrong — wiki confirms 5554) 35, 10012, 10013 ), BLACK_WARLOCK( "Black Warlock", - new int[]{ 5553 }, + new int[]{ 5553 }, // correct 45, 10012, 10010 @@ -65,7 +65,7 @@ public enum ButterflyType { */ SUNLIGHT_MOTH( "Sunlight Moth", - new int[]{ 12770 }, + new int[]{ 12770 }, // correct 65, 10012, 28890 @@ -78,7 +78,7 @@ public enum ButterflyType { */ MOONLIGHT_MOTH( "Moonlight Moth", - new int[]{ 12771, 12772, 12773 }, + new int[]{ 12771, 12772, 12773 }, // correct 75, 10012, 28893