diff --git a/src/main/java/net/runelite/client/plugins/microbot/attackrangesplus/AttackRangesPlusCalc.java b/src/main/java/net/runelite/client/plugins/microbot/attackrangesplus/AttackRangesPlusCalc.java new file mode 100644 index 0000000000..e3acf3a64e --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/attackrangesplus/AttackRangesPlusCalc.java @@ -0,0 +1,101 @@ +package net.runelite.client.plugins.microbot.attackrangesplus; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.client.plugins.microbot.util.combat.Rs2Combat; +import net.runelite.client.plugins.microbot.util.combat.weapons.Melee; +import net.runelite.client.plugins.microbot.util.combat.weapons.Weapon; +import net.runelite.client.plugins.microbot.util.combat.weapons.WeaponsGenerator; + +import javax.inject.Inject; +import java.util.Map; + +/** + * Resolves the attack radius (in tiles) for the overlay. + * + *

Player: AUTO delegates to {@link Rs2Combat#getAttackRange()}, the maintained Microbot + * helper that reads the equipped weapon and the selected attack style and returns the real range: + * accurate per weapon, long-range aware, the spell range while autocasting, halberds = 2, and 1 for + * melee/unknown. The manual modes are fixed representative ranges for previewing a style you are not + * currently using.

+ * + *

Opponent: another player's attack-style varbits are not readable, so their range is + * looked up from their equipped weapon id against the same weapon data Microbot uses + * ({@link WeaponsGenerator#generate()}). That gives the weapon's base reach (no style modifier); + * melee weapons resolve to 1 since their stored range is the special-attack range.

+ */ +@Slf4j +public class AttackRangesPlusCalc +{ + @Inject + private AttackRangesPlusConfig config; + + private static final int MELEE_RADIUS = 1; + private static final int RANGED_PREVIEW_RADIUS = 7; + private static final int MAGIC_RADIUS = 10; + + /** + * Initialization-on-demand holder: the map is built once, thread-safely, on first use. + * Same data Rs2Combat uses; reused for opponent weapon lookups. + */ + private static final class WeaponsHolder + { + private static final Map MAP = WeaponsGenerator.generate(); + } + + /** Set once when the AUTO range lookup fails, so the fallback is logged once rather than per tick. */ + private boolean autoRangeFailureLogged; + + /** + * @return the local player's attack radius in tiles (>= 0). Never throws. + */ + public int getPlayerRangeRadius() + { + switch (config.style()) + { + case MELEE: + return MELEE_RADIUS; + case RANGED: + return RANGED_PREVIEW_RADIUS; + case MAGIC: + return MAGIC_RADIUS; + case AUTO: + default: + try + { + return Math.max(Rs2Combat.getAttackRange(), 0); + } + catch (Exception e) + { + if (!autoRangeFailureLogged) + { + autoRangeFailureLogged = true; + log.debug("Rs2Combat.getAttackRange failed; falling back to melee radius", e); + } + return MELEE_RADIUS; + } + } + } + + /** + * Threat radius for another player's equipped weapon. Their attack style is not knowable, so + * this is the weapon's base reach. + * + * @param weaponId the opponent's equipped weapon item id + * @return the radius in tiles (>= 1) + */ + public int getWeaponRadius(int weaponId) + { + final Weapon w = WeaponsHolder.MAP.get(weaponId); + if (w == null) + { + return MELEE_RADIUS; // unknown weapon -> assume melee reach + } + if (w instanceof Melee) + { + return MELEE_RADIUS; // Melee.range stores the special-attack range, not normal reach + } + // "Accurate" yields the weapon's base (non-long-range) reach across all Weapon subtypes: + // Halberd=2, ranged base, magic/powered-staff spell range. + return Math.max(w.getRange("Accurate"), MELEE_RADIUS); + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/attackrangesplus/AttackRangesPlusConfig.java b/src/main/java/net/runelite/client/plugins/microbot/attackrangesplus/AttackRangesPlusConfig.java new file mode 100644 index 0000000000..18ff37c6f1 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/attackrangesplus/AttackRangesPlusConfig.java @@ -0,0 +1,110 @@ +package net.runelite.client.plugins.microbot.attackrangesplus; + +import net.runelite.client.config.Alpha; +import net.runelite.client.config.Config; +import net.runelite.client.config.ConfigGroup; +import net.runelite.client.config.ConfigInformation; +import net.runelite.client.config.ConfigItem; + +import java.awt.Color; + +@ConfigGroup("attackrangesplus") +@ConfigInformation("

Attack Ranges Plus

" + + "

Version: " + AttackRangesPlusPlugin.version + "

" + + "

This overlay draws your attack range on the ground, clipped to line of sight. It runs no script and never moves or banks for you.

" + + "

" + + "

1. Attack style: how the range is sized. Auto reads your weapon and falls back to melee. Pick Melee, Ranged, or Magic to force a size. Use Magic when casting at 10 tiles.

" + + "

" + + "

2. Line color: the color of the range outline.

" + + "

" + + "

3. Fill area: shades the tiles inside your range. The shading is redrawn every frame and can cost FPS at large ranges like magic. Leave it off for the cheapest outline only overlay.

" + + "

" + + "

4. Fill color: the color and opacity of the shaded area. Used only when Fill area is on.

" + + "

" + + "

5. Show overlay: when to draw it. Always shows it everywhere. In PvP areas limits it to the Wilderness, PvP and Deadman worlds, and PvP flagged zones.

" + + "

" + + "

6. Show target's range: also outline the range of the player you are fighting. Their exact style is not knowable, so this is their weapon's base reach.

" + + "

" + + "

7. Target line color: the outline color used for your target's range.

") +public interface AttackRangesPlusConfig extends Config +{ + @ConfigItem( + keyName = "style", + name = "Attack style", + description = "How to size the overlay. Auto detects ranged weapons and falls back to melee. Pick Magic when casting (10 tiles).", + position = 0 + ) + default RangeMode style() + { + return RangeMode.AUTO; + } + + @Alpha + @ConfigItem( + keyName = "borderColor", + name = "Line color", + description = "Color of the attack-range outline.", + position = 1 + ) + default Color borderColor() + { + return Color.WHITE; + } + + @ConfigItem( + keyName = "showFill", + name = "Fill area", + description = "Shade the tiles inside your attack range. Note: the fill is repainted every frame and costs FPS at large ranges (e.g. magic). Leave off for the cheapest outline-only overlay.", + position = 2 + ) + default boolean showFill() + { + return false; + } + + @Alpha + @ConfigItem( + keyName = "fillColor", + name = "Fill color", + description = "Color and opacity of the shaded area (used only when Fill area is on).", + position = 3 + ) + default Color fillColor() + { + return new Color(0, 0, 0, 35); + } + + @ConfigItem( + keyName = "displayMode", + name = "Show overlay", + description = "When to show the overlay. 'In PvP areas' covers the Wilderness, PvP/Deadman worlds, and PvP-flagged zones.", + position = 4 + ) + default DisplayMode displayMode() + { + return DisplayMode.ALWAYS; + } + + @ConfigItem( + keyName = "showOpponent", + name = "Show target's range", + description = "Also outline the attack range of the player you are fighting. Their exact style is not knowable, so this is their weapon's base reach.", + position = 5 + ) + default boolean showOpponent() + { + return false; + } + + @Alpha + @ConfigItem( + keyName = "opponentColor", + name = "Target line color", + description = "Outline color for your target's range.", + position = 6 + ) + default Color opponentColor() + { + return new Color(255, 64, 64); + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/attackrangesplus/AttackRangesPlusOverlay.java b/src/main/java/net/runelite/client/plugins/microbot/attackrangesplus/AttackRangesPlusOverlay.java new file mode 100644 index 0000000000..d8ea0a9555 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/attackrangesplus/AttackRangesPlusOverlay.java @@ -0,0 +1,346 @@ +package net.runelite.client.plugins.microbot.attackrangesplus; + +import net.runelite.api.Actor; +import net.runelite.api.Client; +import net.runelite.api.Perspective; +import net.runelite.api.Player; +import net.runelite.api.Point; +import net.runelite.api.WorldType; +import net.runelite.api.WorldView; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldArea; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.VarbitID; +import net.runelite.api.kit.KitType; +import net.runelite.client.plugins.microbot.util.player.Rs2Pvp; +import net.runelite.client.ui.overlay.Overlay; +import net.runelite.client.ui.overlay.OverlayLayer; +import net.runelite.client.ui.overlay.OverlayPosition; +import net.runelite.client.ui.overlay.OverlayPriority; + +import javax.inject.Inject; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.geom.GeneralPath; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.Set; + +/** + * Draws the tiles you can actually attack: the Chebyshev attack square clipped to the tiles in line + * of sight, outlined along its boundary so it "molds" around walls. Optionally also draws the same + * for the player you are fighting (their weapon's reach), clipped to their line of sight. + * + *

Performance: the line-of-sight set and the projected fill/outline paths are cached per region. + * The LOS set is rebuilt when the region's origin/radius changes or once per game tick (so dynamic + * obstacles that change reachability are picked up); the projected paths are rebuilt on those changes + * or when the camera moves. A static scene therefore paints one fill plus one stroke per region per + * frame instead of doing work per tile every frame.

+ */ +class AttackRangesPlusOverlay extends Overlay +{ + private static final int HALF = Perspective.LOCAL_HALF_TILE_SIZE; + + private final Client client; + private final AttackRangesPlusCalc rangesCalc; + private final AttackRangesPlusConfig config; + + private final Region playerRegion = new Region(); + private final Region opponentRegion = new Region(); + + // Shared camera state; a change invalidates every region's projected paths. + private boolean haveCamera = false; + private int camX, camY, camZ, camPitch, camYaw; + + // Radius derivation (and the line-of-sight set) are refreshed at most once per game tick rather + // than per render frame: the resolved radius depends on an enum/struct varbit chain that does not + // change between ticks, and recomputing the LOS set each tick picks up dynamic obstacles (doors, + // gates, walls) that change reachability while the player stands still. + private int lastTick = -1; + private int cachedPlayerRadius; + private int cachedOpponentWeaponId = Integer.MIN_VALUE; + private int cachedOpponentRadius; + + @Inject + private AttackRangesPlusOverlay(Client client, AttackRangesPlusCalc rangesCalc, AttackRangesPlusConfig config) + { + this.client = client; + this.rangesCalc = rangesCalc; + this.config = config; + setPosition(OverlayPosition.DYNAMIC); + setLayer(OverlayLayer.ABOVE_SCENE); + setPriority(OverlayPriority.MED); + } + + @Override + public Dimension render(Graphics2D graphics) + { + if (!shouldDisplay()) + { + return null; + } + + final Player local = client.getLocalPlayer(); + if (local == null) + { + return null; + } + final WorldView wv = local.getWorldView(); + if (wv == null) + { + return null; + } + + final int ncamX = client.getCameraX(); + final int ncamY = client.getCameraY(); + final int ncamZ = client.getCameraZ(); + final int ncamPitch = client.getCameraPitch(); + final int ncamYaw = client.getCameraYaw(); + final boolean cameraChanged = !haveCamera + || ncamX != camX || ncamY != camY || ncamZ != camZ + || ncamPitch != camPitch || ncamYaw != camYaw; + + final int tick = client.getTickCount(); + final boolean tickChanged = tick != lastTick; + if (tickChanged) + { + cachedPlayerRadius = rangesCalc.getPlayerRangeRadius(); + lastTick = tick; + } + + final boolean buildFill = config.showFill(); + final boolean havePlayer = playerRegion.update( + local.getWorldArea(), wv, local.getWorldLocation(), cachedPlayerRadius, cameraChanged, tickChanged, buildFill); + + boolean haveOpponent = false; + if (config.showOpponent()) + { + final Actor target = local.getInteracting(); + if (target instanceof Player && target != local) + { + final Player opp = (Player) target; + final int weaponId = opp.getPlayerComposition() != null + ? opp.getPlayerComposition().getEquipmentId(KitType.WEAPON) + : -1; + if (weaponId != cachedOpponentWeaponId) + { + cachedOpponentRadius = rangesCalc.getWeaponRadius(weaponId); + cachedOpponentWeaponId = weaponId; + } + haveOpponent = opponentRegion.update( + opp.getWorldArea(), wv, opp.getWorldLocation(), cachedOpponentRadius, cameraChanged, tickChanged, buildFill); + } + else + { + opponentRegion.clear(); + cachedOpponentWeaponId = Integer.MIN_VALUE; + } + } + else + { + opponentRegion.clear(); + cachedOpponentWeaponId = Integer.MIN_VALUE; + } + + camX = ncamX; + camY = ncamY; + camZ = ncamZ; + camPitch = ncamPitch; + camYaw = ncamYaw; + haveCamera = true; + + if (havePlayer) + { + paint(graphics, playerRegion, config.borderColor(), config.fillColor()); + } + if (haveOpponent) + { + paint(graphics, opponentRegion, config.opponentColor(), faint(config.opponentColor())); + } + return null; + } + + private void paint(Graphics2D graphics, Region region, Color border, Color fill) + { + if (config.showFill() && region.fill != null) + { + // The fill needs no edge antialiasing (the outline covers its border); turning AA off + // here makes the per-frame area fill a bit cheaper. + final Object aa = graphics.getRenderingHint(RenderingHints.KEY_ANTIALIASING); + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF); + graphics.setColor(fill); + graphics.fill(region.fill); + graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, + aa != null ? aa : RenderingHints.VALUE_ANTIALIAS_DEFAULT); + } + if (region.outline != null) + { + graphics.setColor(border); + graphics.draw(region.outline); + } + } + + private static Color faint(Color c) + { + return new Color(c.getRed(), c.getGreen(), c.getBlue(), 40); + } + + private boolean shouldDisplay() + { + switch (config.displayMode()) + { + case WILDERNESS_ONLY: + return Rs2Pvp.isInWilderness(); + case IN_PVP_AREAS: + return inPvpSituation(); + case ALWAYS: + default: + return true; + } + } + + private boolean inPvpSituation() + { + if (Rs2Pvp.isInWilderness()) + { + return true; + } + final EnumSet worldTypes = client.getWorldType(); + if (worldTypes != null && (worldTypes.contains(WorldType.PVP) || worldTypes.contains(WorldType.DEADMAN))) + { + return true; + } + return client.getVarbitValue(VarbitID.PVP_AREA_CLIENT) == 1; + } + + private Set computeAttackable(WorldArea area, WorldView wv, WorldPoint origin, int radius) + { + final int plane = origin.getPlane(); + final Set tiles = new HashSet<>(); + for (int dx = -radius; dx <= radius; dx++) + { + for (int dy = -radius; dy <= radius; dy++) + { + WorldPoint tile = new WorldPoint(origin.getX() + dx, origin.getY() + dy, plane); + if (area.hasLineOfSightTo(wv, tile)) + { + tiles.add(tile); + } + } + } + return tiles; + } + + private Point project(int localX, int localY, WorldView wv, int plane) + { + return Perspective.localToCanvas(client, new LocalPoint(localX, localY, wv), plane); + } + + private static void segment(GeneralPath path, Point a, Point b) + { + path.moveTo(a.getX(), a.getY()); + path.lineTo(b.getX(), b.getY()); + } + + /** + * One attackable area (the player's, or the opponent's) with its own caches. + */ + private final class Region + { + private WorldPoint origin; + private int radius = -1; + private Set set = new HashSet<>(); + private GeneralPath fill; + private GeneralPath outline; + + boolean update(WorldArea area, WorldView wv, WorldPoint o, int r, boolean cameraChanged, boolean tickChanged, boolean buildFill) + { + if (area == null || o == null || r <= 0) + { + clear(); + return false; + } + + // A new tick forces an LOS recompute even when the origin and radius are unchanged, so a + // door/gate/wall toggling reachability under a stationary player is picked up. + final boolean setChanged = !o.equals(origin) || r != radius || tickChanged; + if (setChanged) + { + set = computeAttackable(area, wv, o, r); + origin = o; + radius = r; + } + // The fill path is only constructed while the fill is enabled; toggling it on rebuilds once. + if (setChanged || cameraChanged || outline == null || (buildFill && fill == null)) + { + buildPaths(wv, o.getPlane(), buildFill); + } + return outline != null; + } + + void clear() + { + origin = null; + radius = -1; + fill = null; + outline = null; + if (!set.isEmpty()) + { + set = new HashSet<>(); + } + } + + private void buildPaths(WorldView wv, int plane, boolean buildFill) + { + final GeneralPath f = buildFill ? new GeneralPath(GeneralPath.WIND_NON_ZERO) : null; + final GeneralPath o = new GeneralPath(); + + for (WorldPoint tile : set) + { + final LocalPoint lp = LocalPoint.fromWorld(client, tile); + if (lp == null) + { + continue; // off-scene + } + final int cx = lp.getX(); + final int cy = lp.getY(); + + final Point sw = project(cx - HALF, cy - HALF, wv, plane); + final Point se = project(cx + HALF, cy - HALF, wv, plane); + final Point ne = project(cx + HALF, cy + HALF, wv, plane); + final Point nw = project(cx - HALF, cy + HALF, wv, plane); + + if (buildFill && sw != null && se != null && ne != null && nw != null) + { + f.moveTo(sw.getX(), sw.getY()); + f.lineTo(se.getX(), se.getY()); + f.lineTo(ne.getX(), ne.getY()); + f.lineTo(nw.getX(), nw.getY()); + f.closePath(); + } + + if (!set.contains(tile.dy(1)) && nw != null && ne != null) // north + { + segment(o, nw, ne); + } + if (!set.contains(tile.dy(-1)) && sw != null && se != null) // south + { + segment(o, sw, se); + } + if (!set.contains(tile.dx(1)) && se != null && ne != null) // east + { + segment(o, se, ne); + } + if (!set.contains(tile.dx(-1)) && sw != null && nw != null) // west + { + segment(o, sw, nw); + } + } + + fill = f; + outline = o; + } + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/attackrangesplus/AttackRangesPlusPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/attackrangesplus/AttackRangesPlusPlugin.java new file mode 100644 index 0000000000..5426303b77 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/attackrangesplus/AttackRangesPlusPlugin.java @@ -0,0 +1,53 @@ +package net.runelite.client.plugins.microbot.attackrangesplus; + +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 net.runelite.client.ui.overlay.OverlayManager; + +import javax.inject.Inject; + +@Slf4j +@PluginDescriptor( + name = "[P] " + "Attack Ranges Plus", + description = "Draws your attack range (and optionally your target's), auto-detected from your weapon and clipped to line of sight.", + tags = {"range", "pvp", "combat", "overlay"}, + authors = {"pjmarz"}, + version = AttackRangesPlusPlugin.version, + minClientVersion = "2.0.13", + cardUrl = "https://chsami.github.io/Microbot-Hub/AttackRangesPlusPlugin/assets/card.png", + iconUrl = "https://chsami.github.io/Microbot-Hub/AttackRangesPlusPlugin/assets/icon.png", + enabledByDefault = PluginConstants.DEFAULT_ENABLED, + isExternal = PluginConstants.IS_EXTERNAL +) +public class AttackRangesPlusPlugin extends Plugin +{ + public static final String version = "0.2.3"; + + @Inject + private OverlayManager overlayManager; + + @Inject + private AttackRangesPlusOverlay overlay; + + @Provides + AttackRangesPlusConfig provideConfig(ConfigManager configManager) + { + return configManager.getConfig(AttackRangesPlusConfig.class); + } + + @Override + protected void startUp() + { + overlayManager.add(overlay); + } + + @Override + protected void shutDown() + { + overlayManager.remove(overlay); + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/attackrangesplus/DisplayMode.java b/src/main/java/net/runelite/client/plugins/microbot/attackrangesplus/DisplayMode.java new file mode 100644 index 0000000000..baa214acb8 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/attackrangesplus/DisplayMode.java @@ -0,0 +1,31 @@ +package net.runelite.client.plugins.microbot.attackrangesplus; + +/** + * When the overlay is shown. + * + *
    + *
  • {@link #ALWAYS} - always.
  • + *
  • {@link #IN_PVP_AREAS} - only where players can fight: the Wilderness, PvP/Deadman worlds, + * and PvP-flagged areas.
  • + *
  • {@link #WILDERNESS_ONLY} - only in the Wilderness.
  • + *
+ */ +public enum DisplayMode +{ + ALWAYS("Always"), + IN_PVP_AREAS("In PvP areas"), + WILDERNESS_ONLY("Wilderness only"); + + private final String label; + + DisplayMode(String label) + { + this.label = label; + } + + @Override + public String toString() + { + return label; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/attackrangesplus/RangeMode.java b/src/main/java/net/runelite/client/plugins/microbot/attackrangesplus/RangeMode.java new file mode 100644 index 0000000000..9eb8f7e9d3 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/attackrangesplus/RangeMode.java @@ -0,0 +1,34 @@ +package net.runelite.client.plugins.microbot.attackrangesplus; + +/** + * How the overlay decides your attack radius. + * + *
    + *
  • {@link #AUTO} - read your equipped weapon and selected attack style (via the maintained + * Rs2Combat helper) and use the real range: ranged with long-range, the spell range while + * autocasting, halberds 2, melee 1. This is the recommended setting.
  • + *
  • {@link #MELEE} - fixed 1 tile.
  • + *
  • {@link #RANGED} - fixed 7 tiles (a representative preview; AUTO gives the exact value).
  • + *
  • {@link #MAGIC} - fixed 10 tiles (use this if you click-cast without an autocast set).
  • + *
+ */ +public enum RangeMode +{ + AUTO("Auto (detect)"), + MELEE("Melee (1)"), + RANGED("Ranged (7)"), + MAGIC("Magic (10)"); + + private final String label; + + RangeMode(String label) + { + this.label = label; + } + + @Override + public String toString() + { + return label; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankActuator.java b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankActuator.java new file mode 100644 index 0000000000..330dcb78c1 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankActuator.java @@ -0,0 +1,1119 @@ +/* + * Copyright (c) 2026, bgatfa + * All rights reserved. Redistribution and use in source and binary forms, with + * or without modification, are permitted provided the copyright notice is kept. + */ +package net.runelite.client.plugins.microbot.bankorganizer; + +import java.awt.Rectangle; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.inject.Inject; +import net.runelite.api.Client; +import net.runelite.api.ItemComposition; +import net.runelite.api.Varbits; +import net.runelite.api.widgets.Widget; +import net.runelite.client.game.ItemManager; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.Global; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; +import net.runelite.client.plugins.microbot.util.misc.Rs2UiHelper; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; + +final class BankActuator +{ + private static final int BANK_GROUP_ID = 12; + private static final int BANK_INSERT_BUTTON_CHILD_ID = 17; + private static final int BANK_TAB_CONTAINER_DYNAMIC_MAIN_INDEX = 10; + private static final int[] TAB_COUNT_VARBITS = { + Varbits.BANK_TAB_ONE_COUNT, + Varbits.BANK_TAB_TWO_COUNT, + Varbits.BANK_TAB_THREE_COUNT, + Varbits.BANK_TAB_FOUR_COUNT, + Varbits.BANK_TAB_FIVE_COUNT, + Varbits.BANK_TAB_SIX_COUNT, + Varbits.BANK_TAB_SEVEN_COUNT, + Varbits.BANK_TAB_EIGHT_COUNT, + Varbits.BANK_TAB_NINE_COUNT + }; + + private final Client client; + private final ItemManager itemManager; + private final BankSnapshotReader snapshotReader; + private final Map itemNameCache = new HashMap<>(); + + @Inject + BankActuator(Client client, ItemManager itemManager, BankSnapshotReader snapshotReader) + { + this.client = client; + this.itemManager = itemManager; + this.snapshotReader = snapshotReader; + } + + boolean ensureBankOpen() + { + if (Rs2Bank.isOpen()) + { + return true; + } + return Rs2Bank.openBank() && Global.sleepUntil(Rs2Bank::isOpen, 5000); + } + + boolean isBankInsertMode() + { + return bankRearrangeMode() == 1; + } + + ActuatorResult ensureBankInsertMode() + { + if (!ensureBankOpen()) + { + return ActuatorResult.fail("Bank is not open."); + } + if (isBankInsertMode()) + { + return ActuatorResult.ok("Bank rearrange mode is already Insert."); + } + + if (!Rs2Widget.clickWidget(BANK_GROUP_ID, BANK_INSERT_BUTTON_CHILD_ID)) + { + return ActuatorResult.fail("Could not click bank Insert mode."); + } + + boolean verified = Global.sleepUntil(this::isBankInsertMode, 2500); + return verified + ? ActuatorResult.ok("Bank rearrange mode set to Insert.") + : ActuatorResult.fail("Bank rearrange mode did not switch to Insert."); + } + + ActuatorResult openMainTab() + { + if (!ensureBankOpen()) + { + return ActuatorResult.fail("Bank is not open."); + } + if (Rs2Bank.getCurrentTab() == 0) + { + return ActuatorResult.ok("Main tab already open."); + } + if (!Rs2Bank.openMainTab()) + { + return ActuatorResult.fail("Could not invoke main tab."); + } + boolean verified = Global.sleepUntil(() -> Rs2Bank.getCurrentTab() == 0, 2500); + return verified ? ActuatorResult.ok("Main tab opened.") : ActuatorResult.fail("Main tab did not become active."); + } + + ActuatorResult openTab(int tabIndex) + { + if (!ensureBankOpen()) + { + return ActuatorResult.fail("Bank is not open."); + } + if (tabIndex == 0) + { + return openMainTab(); + } + if (tabIndex < 1 || tabIndex > 9 || tabCount(tabIndex) <= 0) + { + return ActuatorResult.fail("Tab " + tabIndex + " does not exist."); + } + if (Rs2Bank.getCurrentTab() == tabIndex) + { + return ActuatorResult.ok("Tab " + tabIndex + " already open."); + } + if (!Rs2Bank.openTab(tabIndex)) + { + return ActuatorResult.fail("Could not invoke tab " + tabIndex + "."); + } + boolean verified = Global.sleepUntil(() -> Rs2Bank.getCurrentTab() == tabIndex, 2500); + return verified ? ActuatorResult.ok("Tab " + tabIndex + " opened.") : ActuatorResult.fail("Tab " + tabIndex + " did not become active."); + } + + ActuatorResult dragItemFromTabToNewTab(int itemId, int sourceTab) + { + if (!ensureBankOpen()) + { + return ActuatorResult.fail("Bank is not open."); + } + + ActuatorResult sourceTabOpen = openTab(sourceTab); + if (!sourceTabOpen.success()) + { + return sourceTabOpen; + } + + Rs2ItemModel item = findBankItem(itemId); + if (item == null) + { + return ActuatorResult.fail("Item " + itemId + " is not in the bank."); + } + + int beforeRealTabs = realTabCount(); + if (beforeRealTabs >= 9) + { + return ActuatorResult.fail("All nine real bank tabs already exist."); + } + int newTabIndex = beforeRealTabs + 1; + int beforeCount = tabCount(newTabIndex); + int beforeSourceCount = sourceTab > 0 ? tabCount(sourceTab) : 0; + + if (!Rs2Bank.scrollBankToSlot(item.getSlot())) + { + return ActuatorResult.fail("Could not scroll source item into view."); + } + + Rectangle source = Rs2Bank.getItemBounds(item.getSlot()); + Rectangle target = tabBounds(newTabTargetDynamicIndex()); + if (!inCanvas(source) || !inCanvas(target)) + { + return ActuatorResult.fail("Source or new-tab bounds were outside the canvas."); + } + + int originalQuantity = item.getQuantity(); + Microbot.drag(source, target); + boolean verified = Global.sleepUntil(() -> + tabCount(newTabIndex) > beforeCount + && (sourceTab <= 0 || tabCount(sourceTab) < beforeSourceCount) + && quantityFor(itemId) == originalQuantity, 5000); + return verified + ? ActuatorResult.ok("Dragged item to new tab " + newTabIndex + ".") + : ActuatorResult.fail("New-tab drag was not verified."); + } + + ActuatorResult dragItemFromTabToMainTab(int itemId, int sourceTab) + { + if (!ensureBankOpen()) + { + return ActuatorResult.fail("Bank is not open."); + } + if (sourceTab <= 0) + { + return ActuatorResult.ok("Item is already in main tab."); + } + if (sourceTab > 9 || tabCount(sourceTab) <= 0) + { + return ActuatorResult.fail("Source tab " + sourceTab + " does not exist."); + } + + ActuatorResult sourceTabOpen = openTab(sourceTab); + if (!sourceTabOpen.success()) + { + return sourceTabOpen; + } + + Rs2ItemModel item = findBankItem(itemId); + if (item == null) + { + return ActuatorResult.fail("Item " + itemId + " is not in the bank."); + } + + BankSnapshot beforeSnapshot = snapshotReader.read(); + int beforeMainCount = beforeSnapshot.mainTabCount(); + int beforeSourceCount = tabCount(sourceTab); + int originalQuantity = item.getQuantity(); + if (!Rs2Bank.scrollBankToSlot(item.getSlot())) + { + return ActuatorResult.fail("Could not scroll source item into view."); + } + + Rectangle source = Rs2Bank.getItemBounds(item.getSlot()); + Rectangle target = tabBounds(BANK_TAB_CONTAINER_DYNAMIC_MAIN_INDEX); + if (!inCanvas(source) || !inCanvas(target)) + { + return ActuatorResult.fail("Source or main tab bounds were outside the canvas."); + } + + Microbot.drag(source, target); + boolean verified = Global.sleepUntil(() -> { + if (tabCount(sourceTab) >= beforeSourceCount || quantityFor(itemId) != originalQuantity) + { + return false; + } + try + { + return snapshotReader.read().mainTabCount() > beforeMainCount; + } + catch (Throwable ignored) + { + return false; + } + }, 5000); + return verified + ? ActuatorResult.ok("Dragged item to main tab.") + : ActuatorResult.fail("Main-tab drag was not verified."); + } + + ActuatorResult dragItemFromTabToExistingTab(int itemId, int sourceTab, int tabIndex) + { + if (!ensureBankOpen()) + { + return ActuatorResult.fail("Bank is not open."); + } + if (tabIndex < 1 || tabIndex > 9 || tabCount(tabIndex) <= 0) + { + return ActuatorResult.fail("Destination tab " + tabIndex + " does not exist."); + } + if (sourceTab == tabIndex) + { + return ActuatorResult.ok("Item is already in destination tab " + tabIndex + "."); + } + + ActuatorResult sourceTabOpen = openTab(sourceTab); + if (!sourceTabOpen.success()) + { + return sourceTabOpen; + } + + Rs2ItemModel item = findBankItem(itemId); + if (item == null) + { + return ActuatorResult.fail("Item " + itemId + " is not in the bank."); + } + + int beforeCount = tabCount(tabIndex); + int beforeSourceCount = sourceTab > 0 ? tabCount(sourceTab) : 0; + int originalQuantity = item.getQuantity(); + if (!Rs2Bank.scrollBankToSlot(item.getSlot())) + { + return ActuatorResult.fail("Could not scroll source item into view."); + } + + Rectangle source = Rs2Bank.getItemBounds(item.getSlot()); + Rectangle target = tabBounds(BANK_TAB_CONTAINER_DYNAMIC_MAIN_INDEX + tabIndex); + if (!inCanvas(source) || !inCanvas(target)) + { + return ActuatorResult.fail("Source or destination tab bounds were outside the canvas."); + } + + Microbot.drag(source, target); + boolean verified = Global.sleepUntil(() -> { + if (quantityFor(itemId) != originalQuantity) + { + return false; + } + try + { + BankSnapshot.BankStack moved = stackByItemId(snapshotReader.read(), itemId); + return moved != null && moved.tab() == tabIndex && tabCount(tabIndex) > beforeCount + && (sourceTab <= 0 || sourceTab == tabIndex || tabCount(sourceTab) < beforeSourceCount); + } + catch (Throwable ignored) + { + return false; + } + }, 5000); + return verified + ? ActuatorResult.ok("Dragged item to existing tab " + tabIndex + ".") + : ActuatorResult.fail("Existing-tab drag was not verified."); + } + + FullOrganizeResult runBankTagLayoutDelta(BankTagLayoutPlan plan, boolean forceInsertVariants) + { + if (!ensureBankOpen()) + { + return FullOrganizeResult.fail("Bank is not open.", null, 0, 0); + } + if (plan == null) + { + return FullOrganizeResult.fail("No layout plan was provided.", safeSnapshot(), 0, 0); + } + ActuatorResult insertMode = ensureBankInsertMode(); + if (!insertMode.success()) + { + return FullOrganizeResult.fail(insertMode.message(), safeSnapshot(), 0, 0); + } + + BankSnapshot baseline = snapshotReader.read(); + int originalCount = baseline.stackCount(); + Map originalQuantities = quantityMap(baseline); + if (forceInsertVariants) + { + cacheLayoutItemNames(plan.tabs()); + } + int createdTabs = 0; + int moved = 0; + int sorted = 0; + List steps = new ArrayList<>(); + + Map> actionsByTab = layoutActionsByTargetTab(plan.actions()); + List mainActions = actionsByTab.get(0); + if (mainActions != null && !mainActions.isEmpty()) + { + for (BankTagLayoutMoveAction action : mainActions) + { + if (Thread.currentThread().isInterrupted()) + { + return FullOrganizeResult.fail("Layout organize interrupted.", safeSnapshot(), createdTabs, moved); + } + + BankSnapshot.BankStack currentStack = stackByItemId(snapshotReader.read(), action.itemId()); + if (currentStack == null) + { + return FullOrganizeResult.fail("Could not find " + action.name() + + " before moving it to main.", safeSnapshot(), createdTabs, moved); + } + + ActuatorResult move = dragItemFromTabToMainTab(action.itemId(), currentStack.tab()); + steps.add("Main: " + move.message()); + if (!move.success()) + { + return FullOrganizeResult.fail(joinSteps(steps), safeSnapshot(), createdTabs, moved); + } + moved++; + + BankSnapshot afterMove = snapshotReader.read(); + String verificationError = verifySnapshotUnchanged(originalCount, originalQuantities, afterMove); + if (verificationError != null) + { + return FullOrganizeResult.fail("After moving " + action.name() + " to main: " + + verificationError, afterMove, createdTabs, moved); + } + } + } + + for (BankTagLayoutTab tab : plan.tabs()) + { + List actions = actionsByTab.get(tab.tabIndex()); + if (actions == null || actions.isEmpty()) + { + continue; + } + if (Thread.currentThread().isInterrupted()) + { + return FullOrganizeResult.fail("Layout organize interrupted.", safeSnapshot(), createdTabs, moved); + } + + int startIndex = 0; + if (tabCount(tab.tabIndex()) <= 0) + { + int appendTab = realTabCount() + 1; + if (tab.tabIndex() != appendTab) + { + return FullOrganizeResult.fail("Cannot create missing layout tab " + tab.tabIndex() + + " (" + tab.name() + ") because the next appendable tab is " + appendTab + ".", + safeSnapshot(), createdTabs, moved); + } + + BankTagLayoutMoveAction seed = actions.get(0); + BankSnapshot.BankStack currentSeed = stackByItemId(snapshotReader.read(), seed.itemId()); + if (currentSeed == null) + { + return FullOrganizeResult.fail("Could not find " + seed.name() + + " before creating layout tab " + tab.name() + ".", safeSnapshot(), createdTabs, moved); + } + + ActuatorResult createTab = dragItemFromTabToNewTab(seed.itemId(), currentSeed.tab()); + steps.add(tab.name() + ": " + createTab.message()); + if (!createTab.success()) + { + return FullOrganizeResult.fail(joinSteps(steps), safeSnapshot(), createdTabs, moved); + } + createdTabs++; + moved++; + startIndex = 1; + + BankSnapshot afterSeed = snapshotReader.read(); + String verificationError = verifySnapshotUnchanged(originalCount, originalQuantities, afterSeed); + if (verificationError != null) + { + return FullOrganizeResult.fail("After creating layout tab " + tab.name() + ": " + + verificationError, afterSeed, createdTabs, moved); + } + if (tabCount(tab.tabIndex()) != 1) + { + return FullOrganizeResult.fail("Expected layout tab " + tab.tabIndex() + + " count 1 after seed, got " + tabCount(tab.tabIndex()) + ".", afterSeed, createdTabs, moved); + } + } + + for (int i = startIndex; i < actions.size(); i++) + { + if (Thread.currentThread().isInterrupted()) + { + return FullOrganizeResult.fail("Layout organize interrupted.", safeSnapshot(), createdTabs, moved); + } + + BankTagLayoutMoveAction action = actions.get(i); + BankSnapshot.BankStack currentStack = stackByItemId(snapshotReader.read(), action.itemId()); + if (currentStack == null) + { + return FullOrganizeResult.fail("Could not find " + action.name() + + " before moving it to " + tab.name() + ".", safeSnapshot(), createdTabs, moved); + } + if (currentStack.tab() == tab.tabIndex()) + { + continue; + } + + ActuatorResult move = dragItemFromTabToExistingTab(action.itemId(), currentStack.tab(), tab.tabIndex()); + steps.add(tab.name() + ": " + move.message()); + if (!move.success()) + { + return FullOrganizeResult.fail(joinSteps(steps), safeSnapshot(), createdTabs, moved); + } + moved++; + + BankSnapshot afterMove = snapshotReader.read(); + String verificationError = verifySnapshotUnchanged(originalCount, originalQuantities, afterMove); + if (verificationError != null) + { + return FullOrganizeResult.fail("After moving " + action.name() + ": " + + verificationError, afterMove, createdTabs, moved); + } + } + } + + for (BankTagLayoutTab tab : plan.tabs()) + { + if (Thread.currentThread().isInterrupted()) + { + return FullOrganizeResult.fail("Layout sort interrupted.", safeSnapshot(), createdTabs, moved + sorted); + } + + SortResult sort = sortTabByLayout(tab, forceInsertVariants, originalCount, originalQuantities); + if (!sort.success()) + { + return FullOrganizeResult.fail(sort.message(), safeSnapshot(), createdTabs, moved + sorted); + } + sorted += sort.moves(); + } + + BankSnapshot finalSnapshot = snapshotReader.read(); + String verificationError = verifySnapshotUnchanged(originalCount, originalQuantities, finalSnapshot); + if (verificationError != null) + { + return FullOrganizeResult.fail(verificationError, finalSnapshot, createdTabs, moved); + } + + return FullOrganizeResult.ok("Layout delta moved " + moved + " stacks, sorted " + sorted + + " positions, and created " + createdTabs + + " missing tabs. Bank count/quantities verified.", finalSnapshot, createdTabs, moved + sorted); + } + + private SortResult sortTabByLayout( + BankTagLayoutTab tab, + boolean forceInsertVariants, + int originalCount, + Map originalQuantities) + { + if (tabCount(tab.tabIndex()) <= 0) + { + return SortResult.ok("Tab " + tab.tabIndex() + " does not exist; no sort needed.", 0); + } + + ActuatorResult open = openTab(tab.tabIndex()); + if (!open.success()) + { + return SortResult.fail(open.message(), 0); + } + + ActuatorResult insertMode = ensureBankInsertMode(); + if (!insertMode.success()) + { + return SortResult.fail(insertMode.message(), 0); + } + + int moves = 0; + for (int targetPosition = 0; ; targetPosition++) + { + BankSnapshot snapshot = snapshotReader.read(); + List tabStacks = tabStacks(snapshot, tab.tabIndex()); + List desiredPresent = desiredPresentItemIds(tab, tabStacks, forceInsertVariants); + if (targetPosition >= desiredPresent.size()) + { + return SortResult.ok("Sorted tab " + tab.tabIndex() + ".", moves); + } + + int desiredItemId = desiredPresent.get(targetPosition); + if (targetPosition >= tabStacks.size()) + { + return SortResult.fail("Tab " + tab.tabIndex() + " has fewer stacks than expected while sorting.", moves); + } + if (tabStacks.get(targetPosition).itemId() == desiredItemId) + { + continue; + } + + int sourcePosition = indexOfItemId(tabStacks, desiredItemId); + if (sourcePosition < 0) + { + return SortResult.fail("Could not find item " + desiredItemId + " in tab " + tab.tabIndex() + + " while sorting.", moves); + } + if (sourcePosition < targetPosition) + { + return SortResult.fail("Sorting prefix drifted in tab " + tab.tabIndex() + ".", moves); + } + + BankSnapshot.BankStack source = tabStacks.get(sourcePosition); + BankSnapshot.BankStack target = tabStacks.get(targetPosition); + ActuatorResult drag = dragItemWithinOpenTab(source, target); + if (!drag.success()) + { + return SortResult.fail("Tab " + tab.tabIndex() + ": " + drag.message(), moves); + } + moves++; + + int verifiedPrefixLength = targetPosition + 1; + boolean sortedPrefix = Global.sleepUntil(() -> { + BankSnapshot afterMove = snapshotReader.read(); + return verifySnapshotUnchanged(originalCount, originalQuantities, afterMove) == null + && isOrderedPrefix(afterMove, tab, forceInsertVariants, verifiedPrefixLength); + }, 5000); + if (!sortedPrefix) + { + BankSnapshot afterMove = snapshotReader.read(); + String unchangedError = verifySnapshotUnchanged(originalCount, originalQuantities, afterMove); + if (unchangedError != null) + { + return SortResult.fail("After sorting tab " + tab.tabIndex() + ": " + unchangedError, moves); + } + return SortResult.fail("Tab " + tab.tabIndex() + " order was not verified after moving " + + source.name() + ".", moves); + } + } + } + + private static ActuatorResult dragItemWithinOpenTab(BankSnapshot.BankStack sourceStack, BankSnapshot.BankStack targetStack) + { + if (!Rs2Bank.scrollBankToSlot(sourceStack.slot())) + { + return ActuatorResult.fail("Could not scroll source item into view."); + } + + Rectangle source = Rs2Bank.getItemBounds(sourceStack.slot()); + Rectangle target = Rs2Bank.getItemBounds(targetStack.slot()); + if (!inCanvas(source) || !inCanvas(target)) + { + return ActuatorResult.fail("Source or target slot bounds were outside the canvas."); + } + + Microbot.drag(source, target); + return ActuatorResult.ok("Inserted " + sourceStack.name() + " before " + targetStack.name() + "."); + } + + int realTabCount() + { + int count = 0; + for (int i = 1; i <= 9; i++) + { + if (tabCount(i) > 0) + { + count = i; + } + } + return count; + } + + int tabCount(int tabIndex) + { + if (tabIndex < 1 || tabIndex > TAB_COUNT_VARBITS.length) + { + return 0; + } + return Microbot.getClientThread().runOnClientThreadOptional(() -> + client.getVarbitValue(TAB_COUNT_VARBITS[tabIndex - 1])).orElse(0); + } + + int bankRearrangeMode() + { + return Microbot.getClientThread().runOnClientThreadOptional(() -> + client.getVarbitValue(Varbits.BANK_REARRANGE_MODE)).orElse(-1); + } + + int quantityFor(int itemId) + { + Rs2ItemModel item = findBankItem(itemId); + return item == null ? 0 : item.getQuantity(); + } + + private static Rs2ItemModel findBankItem(int itemId) + { + return Rs2Bank.bankItems().stream() + .filter(item -> item.getId() == itemId) + .min(Comparator.comparingInt(Rs2ItemModel::getSlot)) + .orElse(null); + } + + private int newTabTargetDynamicIndex() + { + return BANK_TAB_CONTAINER_DYNAMIC_MAIN_INDEX + realTabCount() + 1; + } + + private static Widget tabWidget(int dynamicIndex) + { + List tabs = Rs2Bank.getTabs(); + if (dynamicIndex < 0 || dynamicIndex >= tabs.size()) + { + return null; + } + return tabs.get(dynamicIndex); + } + + private static Rectangle tabBounds(int dynamicIndex) + { + Widget tab = tabWidget(dynamicIndex); + return tab == null ? null : tab.getBounds(); + } + + private static boolean inCanvas(Rectangle rectangle) + { + return rectangle != null && Rs2UiHelper.isRectangleWithinCanvas(rectangle); + } + + private static String joinSteps(List steps) + { + return String.join(" ", steps); + } + + private BankSnapshot safeSnapshot() + { + try + { + return snapshotReader.read(); + } + catch (Throwable ignored) + { + return null; + } + } + + private static Map quantityMap(BankSnapshot snapshot) + { + Map quantities = new HashMap<>(); + for (BankSnapshot.BankStack stack : snapshot.items()) + { + quantities.merge(stack.itemId(), stack.quantity(), Integer::sum); + } + return quantities; + } + + private static BankSnapshot.BankStack stackByItemId(BankSnapshot snapshot, int itemId) + { + for (BankSnapshot.BankStack stack : snapshot.items()) + { + if (stack.itemId() == itemId) + { + return stack; + } + } + return null; + } + + private static Map> layoutActionsByTargetTab(List actions) + { + Map> actionsByTab = new HashMap<>(); + for (BankTagLayoutMoveAction action : actions) + { + actionsByTab.computeIfAbsent(action.targetTab(), ignored -> new ArrayList<>()).add(action); + } + return actionsByTab; + } + + private static List tabStacks(BankSnapshot snapshot, int tabIndex) + { + List stacks = new ArrayList<>(); + for (BankSnapshot.BankStack stack : snapshot.items()) + { + if (stack.tab() == tabIndex) + { + stacks.add(stack); + } + } + stacks.sort(Comparator.comparingInt(BankSnapshot.BankStack::allItemsIndex)); + return stacks; + } + + private List desiredPresentItemIds( + BankTagLayoutTab tab, + List tabStacks, + boolean forceInsertVariants) + { + Map present = new HashMap<>(); + for (BankSnapshot.BankStack stack : tabStacks) + { + present.putIfAbsent(stack.itemId(), stack); + } + + if (forceInsertVariants) + { + return desiredPresentItemIdsWithForcedVariants(tab, present); + } + + List desired = new ArrayList<>(); + Set added = new HashSet<>(); + for (int itemId : tab.orderedItemIds()) + { + if (present.containsKey(itemId) && added.add(itemId)) + { + desired.add(itemId); + } + } + return desired; + } + + private List desiredPresentItemIdsWithForcedVariants( + BankTagLayoutTab tab, + Map present) + { + Map> presentVariantsByBase = new HashMap<>(); + for (BankSnapshot.BankStack stack : present.values()) + { + Variant variant = Variant.fromName(stack.name()); + if (variant != null) + { + presentVariantsByBase.computeIfAbsent(variant.baseName(), ignored -> new ArrayList<>()).add(stack); + } + } + for (List variants : presentVariantsByBase.values()) + { + variants.sort((left, right) -> { + Variant leftVariant = Variant.fromName(left.name()); + Variant rightVariant = Variant.fromName(right.name()); + int leftCharge = leftVariant == null ? -1 : leftVariant.charge(); + int rightCharge = rightVariant == null ? -1 : rightVariant.charge(); + int chargeCompare = Integer.compare(rightCharge, leftCharge); + return chargeCompare != 0 ? chargeCompare : Integer.compare(left.itemId(), right.itemId()); + }); + } + + List desired = new ArrayList<>(); + Set added = new HashSet<>(); + for (int itemId : tab.orderedItemIds()) + { + if (added.contains(itemId)) + { + continue; + } + + Variant csvVariant = Variant.fromName(itemName(itemId)); + if (csvVariant != null) + { + List family = presentVariantsByBase.get(csvVariant.baseName()); + if (family != null && !family.isEmpty()) + { + for (BankSnapshot.BankStack variantStack : family) + { + if (added.add(variantStack.itemId())) + { + desired.add(variantStack.itemId()); + } + } + continue; + } + } + + if (present.containsKey(itemId) && added.add(itemId)) + { + desired.add(itemId); + } + } + return desired; + } + + private static int indexOfItemId(List stacks, int itemId) + { + for (int i = 0; i < stacks.size(); i++) + { + if (stacks.get(i).itemId() == itemId) + { + return i; + } + } + return -1; + } + + private String itemName(int itemId) + { + String cached = itemNameCache.get(itemId); + if (cached != null) + { + return cached; + } + + String name = Microbot.getClientThread().runOnClientThreadOptional(() -> itemNameOnClientThread(itemId)).orElse(""); + itemNameCache.put(itemId, name); + return name; + } + + private void cacheLayoutItemNames(List tabs) + { + Set missing = new HashSet<>(); + for (BankTagLayoutTab tab : tabs) + { + for (int itemId : tab.orderedItemIds()) + { + if (!itemNameCache.containsKey(itemId)) + { + missing.add(itemId); + } + } + } + if (missing.isEmpty()) + { + return; + } + + Map names = Microbot.getClientThread().runOnClientThreadOptional(() -> { + Map resolved = new HashMap<>(); + for (int itemId : missing) + { + resolved.put(itemId, itemNameOnClientThread(itemId)); + } + return resolved; + }).orElse(null); + if (names != null) + { + itemNameCache.putAll(names); + } + } + + private String itemNameOnClientThread(int itemId) + { + try + { + ItemComposition composition = itemManager.getItemComposition(itemId); + String name = composition.getName(); + return name == null ? "" : name; + } + catch (Throwable ignored) + { + return ""; + } + } + + private boolean isOrderedPrefix( + BankSnapshot snapshot, + BankTagLayoutTab tab, + boolean forceInsertVariants, + int prefixLength) + { + List stacks = tabStacks(snapshot, tab.tabIndex()); + List desired = desiredPresentItemIds(tab, stacks, forceInsertVariants); + if (desired.size() < prefixLength || stacks.size() < prefixLength) + { + return false; + } + for (int i = 0; i < prefixLength; i++) + { + if (stacks.get(i).itemId() != desired.get(i)) + { + return false; + } + } + return true; + } + + private static String verifySnapshotUnchanged(int expectedCount, Map expectedQuantities, BankSnapshot snapshot) + { + if (snapshot.stackCount() != expectedCount) + { + return "bank stack count changed from " + expectedCount + " to " + snapshot.stackCount() + "."; + } + Map actualQuantities = quantityMap(snapshot); + if (!expectedQuantities.equals(actualQuantities)) + { + return "item quantities changed."; + } + return null; + } + + private static final class Variant + { + private final String baseName; + private final int charge; + + private Variant(String baseName, int charge) + { + this.baseName = baseName; + this.charge = charge; + } + + static Variant fromName(String name) + { + if (name == null) + { + return null; + } + + String trimmed = name.trim(); + if (!trimmed.endsWith(")")) + { + return null; + } + + int open = trimmed.lastIndexOf('('); + if (open <= 0 || open + 1 >= trimmed.length() - 1) + { + return null; + } + + String chargeText = trimmed.substring(open + 1, trimmed.length() - 1); + for (int i = 0; i < chargeText.length(); i++) + { + if (!Character.isDigit(chargeText.charAt(i))) + { + return null; + } + } + + int charge; + try + { + charge = Integer.parseInt(chargeText); + } + catch (NumberFormatException ex) + { + return null; + } + if (charge <= 0) + { + return null; + } + + String baseName = trimmed.substring(0, open).trim().toLowerCase(); + return baseName.isEmpty() ? null : new Variant(baseName, charge); + } + + String baseName() + { + return baseName; + } + + int charge() + { + return charge; + } + } + + static final class ActuatorResult + { + private final boolean success; + private final String message; + + private ActuatorResult(boolean success, String message) + { + this.success = success; + this.message = message; + } + + static ActuatorResult ok(String message) + { + return new ActuatorResult(true, message); + } + + static ActuatorResult fail(String message) + { + return new ActuatorResult(false, message); + } + + boolean success() + { + return success; + } + + String message() + { + return message; + } + } + + private static final class SortResult + { + private final boolean success; + private final String message; + private final int moves; + + private SortResult(boolean success, String message, int moves) + { + this.success = success; + this.message = message; + this.moves = moves; + } + + static SortResult ok(String message, int moves) + { + return new SortResult(true, message, moves); + } + + static SortResult fail(String message, int moves) + { + return new SortResult(false, message, moves); + } + + boolean success() + { + return success; + } + + String message() + { + return message; + } + + int moves() + { + return moves; + } + } + + static final class FullOrganizeResult + { + private final boolean success; + private final String message; + private final BankSnapshot finalSnapshot; + private final int createdTabs; + private final int movedStacks; + + private FullOrganizeResult(boolean success, String message, BankSnapshot finalSnapshot, int createdTabs, int movedStacks) + { + this.success = success; + this.message = message; + this.finalSnapshot = finalSnapshot; + this.createdTabs = createdTabs; + this.movedStacks = movedStacks; + } + + static FullOrganizeResult ok(String message, BankSnapshot finalSnapshot, int createdTabs, int movedStacks) + { + return new FullOrganizeResult(true, message, finalSnapshot, createdTabs, movedStacks); + } + + static FullOrganizeResult fail(String message, BankSnapshot finalSnapshot, int createdTabs, int movedStacks) + { + return new FullOrganizeResult(false, message, finalSnapshot, createdTabs, movedStacks); + } + + boolean success() + { + return success; + } + + String message() + { + return message; + } + + BankSnapshot finalSnapshot() + { + return finalSnapshot; + } + + int createdTabs() + { + return createdTabs; + } + + int movedStacks() + { + return movedStacks; + } + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankOrganizerConfig.java b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankOrganizerConfig.java new file mode 100644 index 0000000000..582d8626dc --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankOrganizerConfig.java @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2026, bgatfa + * All rights reserved. Redistribution and use in source and binary forms, with + * or without modification, are permitted provided the copyright notice is kept. + */ +package net.runelite.client.plugins.microbot.bankorganizer; + +import net.runelite.client.config.Config; +import net.runelite.client.config.ConfigGroup; +import net.runelite.client.config.ConfigItem; + +@ConfigGroup(BankOrganizerConfig.GROUP) +public interface BankOrganizerConfig extends Config +{ + String GROUP = "bankorganizer"; + String DEFAULT_LAYOUT_TAB_1 = "banktags,1,Gathering,1511,layout,0,1511,1,1521,2,6333,3,6332,4,1519,5,1517,6,1515,7,1513,8,8778,9,8780,10,960,11,8782,12,32904,13,32907,14,32910,15,19669,16,438,17,440,18,453,19,447,20,449,21,451,22,442,23,444,24,2349,25,2351,26,2353,27,2359,28,2361,29,2363,30,2355,31,2357,32,436,33,31719,34,32892,35,32889,36,31716,37,434,38,1761,39,2922,40,1623,41,1621,42,1619,43,1617,44,1631,45,1625,46,1627,47,1629,48,1607,49,1605,50,1603,51,1601,52,1615,53,1609,54,1611,55,1613,56,1656,57,1639,58,1660,59,1681,60,1683,61,21090,62,21102,63,21105,64,1597,65,1592,66,1595,67,1700,68,1702,69,1759,70,21111,71,21114,72,1757,73,1733,74,1734,75,21504,76,1781,77,1783,78,1785,79,1775"; + + @ConfigItem( + keyName = "forceInsertVariants", + name = "Force insert variants", + description = "Group numeric charged variants at the first matching CSV position and sort them high-to-low.", + position = 0 + ) + default boolean forceInsertVariants() + { + return false; + } + + @ConfigItem( + keyName = "layoutTab1Active", + name = "Tab 1", + description = "Use tab 1 when organizing.", + position = 1 + ) + default boolean layoutTab1Active() + { + return false; + } + + @ConfigItem( + keyName = "layoutTab1", + name = "", + description = "RuneLite bank tag or layout CSV for real tab 1.", + position = 2 + ) + default String layoutTab1() + { + return DEFAULT_LAYOUT_TAB_1; + } + + @ConfigItem( + keyName = "layoutTab2Active", + name = "Tab 2", + description = "Use tab 2 when organizing.", + position = 3 + ) + default boolean layoutTab2Active() + { + return false; + } + + @ConfigItem( + keyName = "layoutTab2", + name = "", + description = "RuneLite bank tag or layout CSV for real tab 2.", + position = 4 + ) + default String layoutTab2() + { + return ""; + } + + @ConfigItem( + keyName = "layoutTab3Active", + name = "Tab 3", + description = "Use tab 3 when organizing.", + position = 5 + ) + default boolean layoutTab3Active() + { + return false; + } + + @ConfigItem( + keyName = "layoutTab3", + name = "", + description = "RuneLite bank tag or layout CSV for real tab 3.", + position = 6 + ) + default String layoutTab3() + { + return ""; + } + + @ConfigItem( + keyName = "layoutTab4Active", + name = "Tab 4", + description = "Use tab 4 when organizing.", + position = 7 + ) + default boolean layoutTab4Active() + { + return false; + } + + @ConfigItem( + keyName = "layoutTab4", + name = "", + description = "RuneLite bank tag or layout CSV for real tab 4.", + position = 8 + ) + default String layoutTab4() + { + return ""; + } + + @ConfigItem( + keyName = "layoutTab5Active", + name = "Tab 5", + description = "Use tab 5 when organizing.", + position = 9 + ) + default boolean layoutTab5Active() + { + return false; + } + + @ConfigItem( + keyName = "layoutTab5", + name = "", + description = "RuneLite bank tag or layout CSV for real tab 5.", + position = 10 + ) + default String layoutTab5() + { + return ""; + } + + @ConfigItem( + keyName = "layoutTab6Active", + name = "Tab 6", + description = "Use tab 6 when organizing.", + position = 11 + ) + default boolean layoutTab6Active() + { + return false; + } + + @ConfigItem( + keyName = "layoutTab6", + name = "", + description = "RuneLite bank tag or layout CSV for real tab 6.", + position = 12 + ) + default String layoutTab6() + { + return ""; + } + + @ConfigItem( + keyName = "layoutTab7Active", + name = "Tab 7", + description = "Use tab 7 when organizing.", + position = 13 + ) + default boolean layoutTab7Active() + { + return false; + } + + @ConfigItem( + keyName = "layoutTab7", + name = "", + description = "RuneLite bank tag or layout CSV for real tab 7.", + position = 14 + ) + default String layoutTab7() + { + return ""; + } + + @ConfigItem( + keyName = "layoutTab8Active", + name = "Tab 8", + description = "Use tab 8 when organizing.", + position = 15 + ) + default boolean layoutTab8Active() + { + return false; + } + + @ConfigItem( + keyName = "layoutTab8", + name = "", + description = "RuneLite bank tag or layout CSV for real tab 8.", + position = 16 + ) + default String layoutTab8() + { + return ""; + } + + @ConfigItem( + keyName = "layoutTab9Active", + name = "Tab 9", + description = "Use tab 9 when organizing.", + position = 17 + ) + default boolean layoutTab9Active() + { + return false; + } + + @ConfigItem( + keyName = "layoutTab9", + name = "", + description = "RuneLite bank tag or layout CSV for real tab 9.", + position = 18 + ) + default String layoutTab9() + { + return ""; + } + +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankOrganizerPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankOrganizerPlugin.java new file mode 100644 index 0000000000..ab7c52d416 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankOrganizerPlugin.java @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2026, bgatfa + * All rights reserved. Redistribution and use in source and binary forms, with + * or without modification, are permitted provided the copyright notice is kept. + */ +package net.runelite.client.plugins.microbot.bankorganizer; + +import net.runelite.client.plugins.microbot.PluginConstants; + +import com.google.inject.Provides; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.function.IntFunction; +import javax.inject.Inject; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.ItemComposition; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.game.ItemManager; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.plugins.microbot.Microbot; + +@PluginDescriptor( + name = PluginConstants.BGA + "Bank Organizer", + description = "Organizes real bank tabs from practical item categories.", + tags = {"bank", "organizer", "tabs", "sort"}, + authors = {"bgatfa"}, + version = BankOrganizerPlugin.version, + minClientVersion = "2.0.61", + iconUrl = "", + cardUrl = "", + enabledByDefault = PluginConstants.DEFAULT_ENABLED, + isExternal = PluginConstants.IS_EXTERNAL +) +@Slf4j +public class BankOrganizerPlugin extends Plugin +{ + public static final String version = "1.0.0"; + + @Inject + private BankOrganizerConfig config; + + @Inject + private BankSnapshotReader snapshotReader; + + @Inject + private BankActuator actuator; + + @Inject + private ItemManager itemManager; + + private final BankTagLayoutParser layoutParser = new BankTagLayoutParser(); + private final BankTagLayoutPlanner layoutPlanner = new BankTagLayoutPlanner(); + + private ExecutorService executor; + private Future task; + private volatile boolean stopRequested; + + @Provides + BankOrganizerConfig provideConfig(ConfigManager configManager) + { + return configManager.getConfig(BankOrganizerConfig.class); + } + + @Override + protected void startUp() + { + stopRequested = false; + executor = Executors.newSingleThreadExecutor(); + log.info("Starting Bank Organizer."); + startOrganizer(); + } + + @Override + protected void shutDown() + { + stopRequested = true; + if (executor != null) + { + executor.shutdownNow(); + executor = null; + } + task = null; + log.info("Bank Organizer stopped."); + } + + private void startOrganizer() + { + if (task != null && !task.isDone()) + { + log.info("Bank Organizer is already running."); + return; + } + + ExecutorService currentExecutor = executor; + if (currentExecutor == null) + { + return; + } + + stopRequested = false; + log.info("Reading bank snapshot."); + task = currentExecutor.submit(this::runOrganizer); + } + + private void runOrganizer() + { + try + { + if (stopRequested) + { + return; + } + + log.info("Parsing configured bank tag layouts."); + List tabs = layoutParser.parse(config); + if (tabs.isEmpty()) + { + log.warn("No active layout tabs. Enable at least one layout tab active toggle."); + return; + } + + List conflicts = layoutPlanner.conflicts(tabs); + if (!conflicts.isEmpty()) + { + log.warn("Resolve duplicate layout item IDs before enabling organizer: {} conflict(s).", conflicts.size()); + return; + } + if (stopRequested) + { + return; + } + + log.info("Opening bank."); + if (!actuator.ensureBankOpen()) + { + throw new IllegalStateException("Could not open bank."); + } + if (stopRequested) + { + return; + } + + log.info("Checking bank rearrange mode."); + BankActuator.ActuatorResult insertMode = actuator.ensureBankInsertMode(); + if (!insertMode.success()) + { + throw new IllegalStateException(insertMode.message()); + } + if (stopRequested) + { + return; + } + + log.info("Reading bank snapshot."); + BankSnapshot snapshot = snapshotReader.read(); + if (stopRequested) + { + return; + } + + runBankTagLayoutPlanner(snapshot, tabs); + } + catch (Throwable t) + { + log.warn("Bank Organizer failed: {}", t.getMessage(), t); + } + } + + private void runBankTagLayoutPlanner(BankSnapshot snapshot, List tabs) + { + IntFunction itemNameLookup = config.forceInsertVariants() + ? itemNameLookup(tabs) + : this::itemName; + BankTagLayoutPlan plan = layoutPlanner.plan(snapshot, tabs, config.forceInsertVariants(), itemNameLookup); + if (stopRequested) + { + return; + } + + log.info("Live layout delta organize requested: {} planned action(s).", plan.actions().size()); + runLiveLayoutOrganizer(plan); + } + + private void runLiveLayoutOrganizer(BankTagLayoutPlan plan) + { + if (stopRequested) + { + return; + } + + log.info("Moving {} listed stack(s) into {} configured layout tab(s).", plan.actions().size(), plan.tabs().size()); + BankActuator.FullOrganizeResult result = actuator.runBankTagLayoutDelta(plan, config.forceInsertVariants()); + if (result.success()) + { + log.info("Bank Organizer completed: {}", result.message()); + } + else + { + log.warn("Bank Organizer blocked: {}", result.message()); + } + } + + private IntFunction itemNameLookup(List tabs) + { + Map names = layoutItemNames(tabs); + return itemId -> names.getOrDefault(itemId, ""); + } + + private Map layoutItemNames(List tabs) + { + Set itemIds = new HashSet<>(); + for (BankTagLayoutTab tab : tabs) + { + itemIds.addAll(tab.orderedItemIds()); + } + + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Map names = new HashMap<>(); + for (int itemId : itemIds) + { + names.put(itemId, itemNameOnClientThread(itemId)); + } + return names; + }).orElse(Collections.emptyMap()); + } + + private String itemName(int itemId) + { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + return itemNameOnClientThread(itemId); + }).orElse(""); + } + + private String itemNameOnClientThread(int itemId) + { + try + { + ItemComposition composition = itemManager.getItemComposition(itemId); + String name = composition.getName(); + return name == null ? "" : name; + } + catch (Throwable ignored) + { + return ""; + } + } + +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankSnapshot.java b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankSnapshot.java new file mode 100644 index 0000000000..22119c9d46 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankSnapshot.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2026, bgatfa + * All rights reserved. Redistribution and use in source and binary forms, with + * or without modification, are permitted provided the copyright notice is kept. + */ +package net.runelite.client.plugins.microbot.bankorganizer; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +final class BankSnapshot +{ + private final List items; + private final int[] tabCounts; + private final int currentTab; + + BankSnapshot(List items, int[] tabCounts, int currentTab) + { + this.items = Collections.unmodifiableList(new ArrayList<>(items)); + this.tabCounts = tabCounts.clone(); + this.currentTab = currentTab; + } + + List items() + { + return items; + } + + int[] tabCounts() + { + return tabCounts.clone(); + } + + int currentTab() + { + return currentTab; + } + + int stackCount() + { + return items.size(); + } + + int tabbedStackCount() + { + int total = 0; + for (int count : tabCounts) + { + total += count; + } + return total; + } + + int mainTabCount() + { + return Math.max(0, stackCount() - tabbedStackCount()); + } + + static final class BankStack + { + private final int itemId; + private final String name; + private final int quantity; + private final int slot; + private final int allItemsIndex; + private final int tab; + private final boolean stackable; + private final boolean tradeable; + private final boolean geTradeable; + private final boolean equipable; + + BankStack( + int itemId, + String name, + int quantity, + int slot, + int allItemsIndex, + int tab, + boolean stackable, + boolean tradeable, + boolean geTradeable, + boolean equipable) + { + this.itemId = itemId; + this.name = name; + this.quantity = quantity; + this.slot = slot; + this.allItemsIndex = allItemsIndex; + this.tab = tab; + this.stackable = stackable; + this.tradeable = tradeable; + this.geTradeable = geTradeable; + this.equipable = equipable; + } + + int itemId() + { + return itemId; + } + + String name() + { + return name; + } + + int quantity() + { + return quantity; + } + + int slot() + { + return slot; + } + + int allItemsIndex() + { + return allItemsIndex; + } + + int tab() + { + return tab; + } + + boolean stackable() + { + return stackable; + } + + boolean tradeable() + { + return tradeable; + } + + boolean geTradeable() + { + return geTradeable; + } + + boolean equipable() + { + return equipable; + } + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankSnapshotReader.java b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankSnapshotReader.java new file mode 100644 index 0000000000..22de962226 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankSnapshotReader.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2026, bgatfa + * All rights reserved. Redistribution and use in source and binary forms, with + * or without modification, are permitted provided the copyright notice is kept. + */ +package net.runelite.client.plugins.microbot.bankorganizer; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import javax.inject.Inject; +import net.runelite.api.Client; +import net.runelite.api.ItemComposition; +import net.runelite.api.Varbits; +import net.runelite.client.game.ItemManager; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; + +final class BankSnapshotReader +{ + private static final int[] TAB_COUNT_VARBITS = { + Varbits.BANK_TAB_ONE_COUNT, + Varbits.BANK_TAB_TWO_COUNT, + Varbits.BANK_TAB_THREE_COUNT, + Varbits.BANK_TAB_FOUR_COUNT, + Varbits.BANK_TAB_FIVE_COUNT, + Varbits.BANK_TAB_SIX_COUNT, + Varbits.BANK_TAB_SEVEN_COUNT, + Varbits.BANK_TAB_EIGHT_COUNT, + Varbits.BANK_TAB_NINE_COUNT + }; + + private final Client client; + private final ItemManager itemManager; + + @Inject + BankSnapshotReader(Client client, ItemManager itemManager) + { + this.client = client; + this.itemManager = itemManager; + } + + BankSnapshot read() + { + return Microbot.getClientThread().runOnClientThreadOptional(this::readOnClientThread) + .orElseThrow(() -> new IllegalStateException("Could not read bank snapshot on the client thread.")); + } + + private BankSnapshot readOnClientThread() + { + if (!Rs2Bank.isOpen()) + { + throw new IllegalStateException("Open your bank before running Bank Organizer."); + } + + List bankItems = new ArrayList<>(Rs2Bank.bankItems()); + bankItems.sort(Comparator.comparingInt(Rs2ItemModel::getSlot)); + + int[] tabCounts = readTabCounts(); + List stacks = new ArrayList<>(); + for (int i = 0; i < bankItems.size(); i++) + { + Rs2ItemModel item = bankItems.get(i); + int itemId = item.getId(); + ItemComposition composition = itemManager.getItemComposition(itemId); + String name = composition.getName(); + if (name == null || name.isEmpty() || "null".equalsIgnoreCase(name)) + { + name = item.getName(); + } + + stacks.add(new BankSnapshot.BankStack( + itemId, + name, + item.getQuantity(), + item.getSlot(), + i, + tabForIndex(i, tabCounts), + composition.isStackable(), + composition.isTradeable(), + composition.isGeTradeable(), + isEquipable(composition))); + } + + return new BankSnapshot(stacks, tabCounts, client.getVarbitValue(Varbits.CURRENT_BANK_TAB)); + } + + private int[] readTabCounts() + { + int[] counts = new int[TAB_COUNT_VARBITS.length]; + for (int i = 0; i < TAB_COUNT_VARBITS.length; i++) + { + counts[i] = client.getVarbitValue(TAB_COUNT_VARBITS[i]); + } + return counts; + } + + private static int mainTabCount(int stackCount, int[] tabCounts) + { + int tabbed = 0; + for (int count : tabCounts) + { + tabbed += count; + } + return Math.max(0, stackCount - tabbed); + } + + private static int tabForIndex(int index, int[] tabCounts) + { + int cursor = 0; + for (int i = 0; i < tabCounts.length; i++) + { + cursor += tabCounts[i]; + if (index < cursor) + { + return i + 1; + } + } + return 0; + } + + private static boolean isEquipable(ItemComposition composition) + { + String[] actions = composition.getInventoryActions(); + if (actions == null) + { + return false; + } + for (String action : actions) + { + if (action == null) + { + continue; + } + String lower = action.toLowerCase(); + if (lower.contains("wear") || lower.contains("wield") || lower.contains("equip")) + { + return true; + } + } + return false; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankTagLayoutConflict.java b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankTagLayoutConflict.java new file mode 100644 index 0000000000..1873128948 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankTagLayoutConflict.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, bgatfa + * All rights reserved. Redistribution and use in source and binary forms, with + * or without modification, are permitted provided the copyright notice is kept. + */ +package net.runelite.client.plugins.microbot.bankorganizer; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +final class BankTagLayoutConflict +{ + private final int itemId; + private final List tabIndexes; + + BankTagLayoutConflict(int itemId, List tabIndexes) + { + this.itemId = itemId; + this.tabIndexes = Collections.unmodifiableList(new ArrayList<>(tabIndexes)); + } + + int itemId() + { + return itemId; + } + + List tabIndexes() + { + return tabIndexes; + } + + String tabIndexesDisplay() + { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < tabIndexes.size(); i++) + { + if (i > 0) + { + builder.append(", "); + } + builder.append(tabIndexes.get(i)); + } + return builder.toString(); + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankTagLayoutMoveAction.java b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankTagLayoutMoveAction.java new file mode 100644 index 0000000000..88207ae90e --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankTagLayoutMoveAction.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026, bgatfa + * All rights reserved. Redistribution and use in source and binary forms, with + * or without modification, are permitted provided the copyright notice is kept. + */ +package net.runelite.client.plugins.microbot.bankorganizer; + +final class BankTagLayoutMoveAction +{ + private final int itemId; + private final String name; + private final int quantity; + private final String layoutName; + private final int sourceTab; + private final int targetTab; + private final int targetSlot; + + BankTagLayoutMoveAction(int itemId, String name, int quantity, String layoutName, int sourceTab, int targetTab, int targetSlot) + { + this.itemId = itemId; + this.name = name; + this.quantity = quantity; + this.layoutName = layoutName; + this.sourceTab = sourceTab; + this.targetTab = targetTab; + this.targetSlot = targetSlot; + } + + int itemId() + { + return itemId; + } + + String name() + { + return name; + } + + int quantity() + { + return quantity; + } + + String layoutName() + { + return layoutName; + } + + int sourceTab() + { + return sourceTab; + } + + int targetTab() + { + return targetTab; + } + + int targetSlot() + { + return targetSlot; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankTagLayoutParser.java b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankTagLayoutParser.java new file mode 100644 index 0000000000..d53c98ba1d --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankTagLayoutParser.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2026, bgatfa + * All rights reserved. Redistribution and use in source and binary forms, with + * or without modification, are permitted provided the copyright notice is kept. + */ +package net.runelite.client.plugins.microbot.bankorganizer; + +import java.util.ArrayList; +import java.util.List; +import java.util.TreeMap; + +final class BankTagLayoutParser +{ + List parse(BankOrganizerConfig config) + { + List tabs = new ArrayList<>(); + addIfActive(tabs, 1, config.layoutTab1Active(), config.layoutTab1()); + addIfActive(tabs, 2, config.layoutTab2Active(), config.layoutTab2()); + addIfActive(tabs, 3, config.layoutTab3Active(), config.layoutTab3()); + addIfActive(tabs, 4, config.layoutTab4Active(), config.layoutTab4()); + addIfActive(tabs, 5, config.layoutTab5Active(), config.layoutTab5()); + addIfActive(tabs, 6, config.layoutTab6Active(), config.layoutTab6()); + addIfActive(tabs, 7, config.layoutTab7Active(), config.layoutTab7()); + addIfActive(tabs, 8, config.layoutTab8Active(), config.layoutTab8()); + addIfActive(tabs, 9, config.layoutTab9Active(), config.layoutTab9()); + return tabs; + } + + private static void addIfActive(List tabs, int tabIndex, boolean active, String csv) + { + if (!active) + { + return; + } + + BankTagLayoutTab tab = parseOne(tabIndex, csv); + if (tab != null) + { + tabs.add(tab); + } + } + + static BankTagLayoutTab parseOne(int tabIndex, String csv) + { + if (csv == null || csv.trim().isEmpty()) + { + return null; + } + + List tokens = parseCsv(csv); + if (tokens.size() < 5) + { + throw new IllegalArgumentException("Layout tab " + tabIndex + " is too short to be a bank tags CSV."); + } + + String name = tokens.size() > 2 ? tokens.get(2) : "Layout " + tabIndex; + int iconItemId = parseIntOrDefault(tokens.size() > 3 ? tokens.get(3) : "", -1); + int layoutIndex = indexOf(tokens, "layout"); + List itemIds = layoutIndex >= 0 + ? parseLayoutItemIds(tabIndex, tokens, layoutIndex) + : parsePlainBankTagItemIds(tabIndex, tokens); + + return new BankTagLayoutTab(tabIndex, name, iconItemId, itemIds); + } + + private static List parseLayoutItemIds(int tabIndex, List tokens, int layoutIndex) + { + TreeMap bySlot = new TreeMap<>(); + for (int i = layoutIndex + 1; i + 1 < tokens.size(); i += 2) + { + Integer slot = parseInt(tokens.get(i)); + Integer itemId = parseInt(tokens.get(i + 1)); + if (slot == null || itemId == null || itemId <= 0) + { + continue; + } + bySlot.put(slot, itemId); + } + + List itemIds = new ArrayList<>(bySlot.values()); + if (itemIds.isEmpty()) + { + throw new IllegalArgumentException("Layout tab " + tabIndex + " has no item IDs in its layout section."); + } + return itemIds; + } + + private static List parsePlainBankTagItemIds(int tabIndex, List tokens) + { + List itemIds = new ArrayList<>(); + for (int i = 4; i < tokens.size(); i++) + { + Integer itemId = parseInt(tokens.get(i)); + if (itemId != null && itemId > 0) + { + itemIds.add(itemId); + } + } + + if (itemIds.isEmpty()) + { + throw new IllegalArgumentException("Layout tab " + tabIndex + " has no item IDs."); + } + return itemIds; + } + + private static int indexOf(List tokens, String value) + { + for (int i = 0; i < tokens.size(); i++) + { + if (value.equalsIgnoreCase(tokens.get(i).trim())) + { + return i; + } + } + return -1; + } + + private static Integer parseInt(String value) + { + try + { + return Integer.parseInt(value.trim()); + } + catch (NumberFormatException ex) + { + return null; + } + } + + private static int parseIntOrDefault(String value, int fallback) + { + Integer parsed = parseInt(value); + return parsed == null ? fallback : parsed; + } + + private static List parseCsv(String csv) + { + List tokens = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + boolean quoted = false; + for (int i = 0; i < csv.length(); i++) + { + char ch = csv.charAt(i); + if (ch == '"') + { + if (quoted && i + 1 < csv.length() && csv.charAt(i + 1) == '"') + { + current.append('"'); + i++; + } + else + { + quoted = !quoted; + } + } + else if (ch == ',' && !quoted) + { + tokens.add(current.toString().trim()); + current.setLength(0); + } + else + { + current.append(ch); + } + } + tokens.add(current.toString().trim()); + return tokens; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankTagLayoutPlan.java b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankTagLayoutPlan.java new file mode 100644 index 0000000000..5df31e35b1 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankTagLayoutPlan.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026, bgatfa + * All rights reserved. Redistribution and use in source and binary forms, with + * or without modification, are permitted provided the copyright notice is kept. + */ +package net.runelite.client.plugins.microbot.bankorganizer; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +final class BankTagLayoutPlan +{ + private final List tabs; + private final List actions; + private final int matchedStacks; + private final int unlistedStacks; + private final int unlistedActiveTabbedStacks; + + BankTagLayoutPlan( + List tabs, + List actions, + int matchedStacks, + int unlistedStacks, + int unlistedActiveTabbedStacks) + { + this.tabs = Collections.unmodifiableList(new ArrayList<>(tabs)); + this.actions = Collections.unmodifiableList(new ArrayList<>(actions)); + this.matchedStacks = matchedStacks; + this.unlistedStacks = unlistedStacks; + this.unlistedActiveTabbedStacks = unlistedActiveTabbedStacks; + } + + List tabs() + { + return tabs; + } + + List actions() + { + return actions; + } + + int matchedStacks() + { + return matchedStacks; + } + + int unlistedStacks() + { + return unlistedStacks; + } + + int unlistedActiveTabbedStacks() + { + return unlistedActiveTabbedStacks; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankTagLayoutPlanner.java b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankTagLayoutPlanner.java new file mode 100644 index 0000000000..a2d9de481d --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankTagLayoutPlanner.java @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2026, bgatfa + * All rights reserved. Redistribution and use in source and binary forms, with + * or without modification, are permitted provided the copyright notice is kept. + */ +package net.runelite.client.plugins.microbot.bankorganizer; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.function.IntFunction; + +final class BankTagLayoutPlanner +{ + List conflicts(List tabs) + { + Map> tabIndexesByItemId = new TreeMap<>(); + if (tabs == null) + { + return Collections.emptyList(); + } + + for (BankTagLayoutTab tab : tabs) + { + for (int itemId : tab.uniqueItemIds()) + { + tabIndexesByItemId.computeIfAbsent(itemId, ignored -> new ArrayList<>()).add(tab.tabIndex()); + } + } + + List conflicts = new ArrayList<>(); + for (Map.Entry> entry : tabIndexesByItemId.entrySet()) + { + if (entry.getValue().size() > 1) + { + conflicts.add(new BankTagLayoutConflict(entry.getKey(), entry.getValue())); + } + } + return conflicts; + } + + BankTagLayoutPlan plan( + BankSnapshot snapshot, + List tabs, + boolean forceInsertVariants, + IntFunction itemNameLookup) + { + if (tabs == null || tabs.isEmpty()) + { + throw new IllegalArgumentException("No bank tag layouts are configured."); + } + + Map targetByItemId = new HashMap<>(); + Set activeTabIndexes = new HashSet<>(); + for (BankTagLayoutTab tab : tabs) + { + activeTabIndexes.add(tab.tabIndex()); + List ids = tab.orderedItemIds(); + for (int slot = 0; slot < ids.size(); slot++) + { + targetByItemId.putIfAbsent(ids.get(slot), new Target(tab, slot)); + } + } + if (forceInsertVariants) + { + addForcedVariantTargets(snapshot, tabs, targetByItemId, itemNameLookup); + } + + List actions = new ArrayList<>(); + int matched = 0; + int unlisted = 0; + int unlistedActiveTabbed = 0; + for (BankSnapshot.BankStack stack : snapshot.items()) + { + Target target = targetByItemId.get(stack.itemId()); + if (target == null) + { + unlisted++; + if (activeTabIndexes.contains(stack.tab())) + { + unlistedActiveTabbed++; + actions.add(new BankTagLayoutMoveAction( + stack.itemId(), + stack.name(), + stack.quantity(), + "Main", + stack.tab(), + 0, + -1)); + } + continue; + } + matched++; + if (stack.tab() == target.tab.tabIndex()) + { + continue; + } + actions.add(new BankTagLayoutMoveAction( + stack.itemId(), + stack.name(), + stack.quantity(), + target.tab.name(), + stack.tab(), + target.tab.tabIndex(), + target.slot)); + } + + return new BankTagLayoutPlan(tabs, actions, matched, unlisted, unlistedActiveTabbed); + } + + private static void addForcedVariantTargets( + BankSnapshot snapshot, + List tabs, + Map targetByItemId, + IntFunction itemNameLookup) + { + Map targetByVariantBase = new HashMap<>(); + for (BankTagLayoutTab tab : tabs) + { + for (int itemId : tab.orderedItemIds()) + { + Target target = targetByItemId.get(itemId); + if (target == null) + { + continue; + } + + String name = itemNameLookup == null ? "" : itemNameLookup.apply(itemId); + Variant variant = Variant.fromName(name); + if (variant == null) + { + continue; + } + + Target existing = targetByVariantBase.get(variant.baseName()); + if (existing == null || target.before(existing)) + { + targetByVariantBase.put(variant.baseName(), target); + } + } + } + + for (BankSnapshot.BankStack stack : snapshot.items()) + { + Variant variant = Variant.fromName(stack.name()); + if (variant == null) + { + continue; + } + + Target target = targetByVariantBase.get(variant.baseName()); + if (target != null) + { + targetByItemId.put(stack.itemId(), target); + } + } + } + + private static final class Target + { + private final BankTagLayoutTab tab; + private final int slot; + + private Target(BankTagLayoutTab tab, int slot) + { + this.tab = tab; + this.slot = slot; + } + + private boolean before(Target other) + { + if (tab.tabIndex() != other.tab.tabIndex()) + { + return tab.tabIndex() < other.tab.tabIndex(); + } + return slot < other.slot; + } + } + + private static final class Variant + { + private final String baseName; + + private Variant(String baseName) + { + this.baseName = baseName; + } + + static Variant fromName(String name) + { + if (name == null) + { + return null; + } + + String trimmed = name.trim(); + if (!trimmed.endsWith(")")) + { + return null; + } + + int open = trimmed.lastIndexOf('('); + if (open <= 0 || open + 1 >= trimmed.length() - 1) + { + return null; + } + + String chargeText = trimmed.substring(open + 1, trimmed.length() - 1); + for (int i = 0; i < chargeText.length(); i++) + { + if (!Character.isDigit(chargeText.charAt(i))) + { + return null; + } + } + + try + { + if (Integer.parseInt(chargeText) <= 0) + { + return null; + } + } + catch (NumberFormatException ex) + { + return null; + } + + String baseName = trimmed.substring(0, open).trim().toLowerCase(); + return baseName.isEmpty() ? null : new Variant(baseName); + } + + String baseName() + { + return baseName; + } + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankTagLayoutTab.java b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankTagLayoutTab.java new file mode 100644 index 0000000000..173abde593 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/bankorganizer/BankTagLayoutTab.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026, bgatfa + * All rights reserved. Redistribution and use in source and binary forms, with + * or without modification, are permitted provided the copyright notice is kept. + */ +package net.runelite.client.plugins.microbot.bankorganizer; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +final class BankTagLayoutTab +{ + private final int tabIndex; + private final String name; + private final int iconItemId; + private final List orderedItemIds; + private final Set itemIds; + + BankTagLayoutTab(int tabIndex, String name, int iconItemId, List orderedItemIds) + { + this.tabIndex = tabIndex; + this.name = name == null || name.trim().isEmpty() ? "Layout " + tabIndex : name.trim(); + this.iconItemId = iconItemId; + this.orderedItemIds = Collections.unmodifiableList(new ArrayList<>(orderedItemIds)); + this.itemIds = Collections.unmodifiableSet(new HashSet<>(orderedItemIds)); + } + + int tabIndex() + { + return tabIndex; + } + + String name() + { + return name; + } + + int iconItemId() + { + return iconItemId; + } + + List orderedItemIds() + { + return orderedItemIds; + } + + Set uniqueItemIds() + { + return itemIds; + } + + boolean containsItemId(int itemId) + { + return itemIds.contains(itemId); + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/birdhouseruns/FornBirdhouseRunsPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/birdhouseruns/FornBirdhouseRunsPlugin.java index 3be4a82047..795fc3e042 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/birdhouseruns/FornBirdhouseRunsPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/birdhouseruns/FornBirdhouseRunsPlugin.java @@ -26,7 +26,7 @@ ) @Slf4j public class FornBirdhouseRunsPlugin extends Plugin { - final static String version = "1.1.3"; + final static String version = "1.1.4"; @Provides FornBirdhouseRunsConfig provideConfig(ConfigManager configManager) { return configManager.getConfig(FornBirdhouseRunsConfig.class); diff --git a/src/main/java/net/runelite/client/plugins/microbot/birdhouseruns/FornBirdhouseRunsScript.java b/src/main/java/net/runelite/client/plugins/microbot/birdhouseruns/FornBirdhouseRunsScript.java index f4986560ca..9b82c54715 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/birdhouseruns/FornBirdhouseRunsScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/birdhouseruns/FornBirdhouseRunsScript.java @@ -22,6 +22,7 @@ import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import net.runelite.client.plugins.microbot.shortestpath.Restriction; import net.runelite.client.plugins.microbot.shortestpath.ShortestPathPlugin; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; @@ -42,6 +43,8 @@ public class FornBirdhouseRunsScript extends Script { private static final WorldPoint birdhouseLocation3 = new WorldPoint(3677, 3882, 0); private static final WorldPoint birdhouseLocation4 = new WorldPoint(3679, 3815, 0); private static final WorldPoint SOUTH_ROWBOAT = new WorldPoint(3724, 3807, 0); + private static final WorldPoint VERDANT_MUSHTREE = new WorldPoint(3757, 3757, 0); + private static final int MUSHTREE_OBJECT_ID = 30924; // Each location maps to a BIRDHOUSE_TRANSMIT_* varp. See isEmpty/isBuilt/isSeeded // below for the canonical state decoding (matches RuneLite's BirdHouseState). private static final int VARP_HOUSE_1 = VarPlayerID.BIRDHOUSE_TRANSMIT_D; // Verdant SW @@ -176,6 +179,7 @@ public boolean run() { switch (botStatus) { case TELEPORTING: case VERDANT_TELEPORT: + Rs2Walker.walkTo(birdhouseLocation1); botStatus = states.DISMANTLE_HOUSE_1; advanced = true; break; @@ -216,6 +220,10 @@ public boolean run() { } break; case MUSHROOM_TELEPORT: + Rs2GameObject.interact(MUSHTREE_OBJECT_ID, "Use"); + sleepUntil(() -> Rs2Widget.findWidget("Mycelium Transportation System") != null, 5000); + Rs2Widget.clickWidget("Mushroom Meadow"); + sleepUntil(() -> Rs2Player.distanceTo(birdhouseLocation3) < 20, 10000); botStatus = states.DISMANTLE_HOUSE_3; advanced = true; break; diff --git a/src/main/java/net/runelite/client/plugins/microbot/cooking/AutoCookingPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/cooking/AutoCookingPlugin.java index 9d700132ad..31568553d8 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/cooking/AutoCookingPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/cooking/AutoCookingPlugin.java @@ -30,7 +30,7 @@ ) @Slf4j public class AutoCookingPlugin extends Plugin { - public final static String version = "1.1.4"; + public final static String version = "1.1.5"; @Inject AutoCookingScript autoCookingScript; @Inject diff --git a/src/main/java/net/runelite/client/plugins/microbot/cooking/enums/CookingItem.java b/src/main/java/net/runelite/client/plugins/microbot/cooking/enums/CookingItem.java index 340ea01491..c1ad2cc2bc 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/cooking/enums/CookingItem.java +++ b/src/main/java/net/runelite/client/plugins/microbot/cooking/enums/CookingItem.java @@ -48,7 +48,7 @@ public enum CookingItem UNCOOKED_APPLE_PIE("uncooked apple pie", ItemID.UNCOOKED_APPLE_PIE, 30, "apple pie", ItemID.APPLE_PIE, "burnt pie", ItemID.BURNT_PIE, CookingAreaType.RANGE), RAW_GARDEN_PIE("raw garden pie", ItemID.UNCOOKED_GARDEN_PIE, 34, "garden pie", ItemID.GARDEN_PIE, "burnt pie", ItemID.BURNT_PIE, CookingAreaType.RANGE), RAW_FISH_PIE("raw fish pie", ItemID.UNCOOKED_FISH_PIE, 47, "fish pie", ItemID.FISH_PIE, "burnt pie", ItemID.BURNT_PIE, CookingAreaType.RANGE), - UNCOOKED_BOTANICAL_PIE("uncooked batanical pie", ItemID.UNCOOKED_BOTANICAL_PIE, 52, "botanical pie", ItemID.BOTANICAL_PIE, "burnt pie", ItemID.BURNT_PIE, CookingAreaType.RANGE), + UNCOOKED_BOTANICAL_PIE("uncooked botanical pie", ItemID.UNCOOKED_BOTANICAL_PIE, 52, "botanical pie", ItemID.BOTANICAL_PIE, "burnt pie", ItemID.BURNT_PIE, CookingAreaType.RANGE), UNCOOKED_MUSHROOM_PIE("uncooked mushroom pie", ItemID.UNCOOKED_MUSHROOM_PIE, 60, "mushroom pie", ItemID.MUSHROOM_PIE, "burnt pie", ItemID.BURNT_PIE, CookingAreaType.RANGE), RAW_ADMIRAL_PIE("raw admiral pie", ItemID.UNCOOKED_ADMIRAL_PIE, 70, "admiral pie", ItemID.ADMIRAL_PIE, "burnt pie", ItemID.BURNT_PIE, CookingAreaType.RANGE), UNCOOKED_DRAGONFRUIT_PIE("uncooked dragonfruit pie", ItemID.UNCOOKED_DRAGONFRUIT_PIE, 73, "dragonfruit pie", ItemID.DRAGONFRUIT_PIE, "burnt pie", ItemID.BURNT_PIE, CookingAreaType.RANGE), @@ -63,7 +63,7 @@ public enum CookingItem UNCOOKED_CAKE("uncooked cake", ItemID.UNCOOKED_CAKE, 40, "cake", ItemID.CAKE, "burnt cake", ItemID.BURNT_CAKE, CookingAreaType.RANGE), // Vegetable POTATO("potato", ItemID.POTATO, 7, "baked potato", ItemID.POTATO_BAKED, "burnt potato", ItemID.POTATO_BURNT, CookingAreaType.RANGE), - UNCOOKED_EGG("uncooked egg", ItemID.SCRAMBLED_EGG, 13, "scrambled egg", ItemID.SCRAMBLED_EGG, "burnt egg", ItemID.BOWL_EGG_BURNT, CookingAreaType.BOTH), + UNCOOKED_EGG("uncooked egg", ItemID.BOWL_EGG_RAW, 13, "scrambled egg", ItemID.BOWL_EGG_SCRAMBLED, "burnt egg", ItemID.BOWL_EGG_BURNT, CookingAreaType.BOTH), SWEETCORN("sweetcorn", ItemID.SWEETCORN, 28, "cooked sweetcorn", ItemID.SWEETCORN_COOKED, "burnt sweetcorn", ItemID.SWEETCORN_BURNT, CookingAreaType.BOTH), ; diff --git a/src/main/java/net/runelite/client/plugins/microbot/eventdismiss/EventDismissOverlay.java b/src/main/java/net/runelite/client/plugins/microbot/eventdismiss/EventDismissOverlay.java index d0f7e4c067..384956de17 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/eventdismiss/EventDismissOverlay.java +++ b/src/main/java/net/runelite/client/plugins/microbot/eventdismiss/EventDismissOverlay.java @@ -37,7 +37,7 @@ public Dimension render(Graphics2D graphics) { panelComponent.setPreferredSize(new Dimension(200, 0)); panelComponent.getChildren().add(TitleComponent.builder() - .text("Event Dismiss") + .text("Random Event Handler") .color(Color.CYAN) .build()); diff --git a/src/main/java/net/runelite/client/plugins/microbot/eventdismiss/EventDismissPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/eventdismiss/EventDismissPlugin.java index 3f5547c93c..d69c72d140 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/eventdismiss/EventDismissPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/eventdismiss/EventDismissPlugin.java @@ -13,7 +13,7 @@ import java.awt.*; @PluginDescriptor( - name = PluginDescriptor.Default + "Event Dismiss", + name = PluginDescriptor.Default + "Random Event Handler", description = "Dismisses random events and optionally accepts lamps from Genie/Count Check", tags = {"random", "events", "microbot", "lamp", "genie"}, authors = {"Unknown"}, @@ -26,7 +26,7 @@ ) @Slf4j public class EventDismissPlugin extends Plugin { - public static final String version = "2.1.0"; + public static final String version = "2.1.1"; @Inject private EventDismissConfig config; diff --git a/src/main/java/net/runelite/client/plugins/microbot/eventdismiss/LampUtility.java b/src/main/java/net/runelite/client/plugins/microbot/eventdismiss/LampUtility.java index ce655080b8..9aafaaa268 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/eventdismiss/LampUtility.java +++ b/src/main/java/net/runelite/client/plugins/microbot/eventdismiss/LampUtility.java @@ -5,6 +5,7 @@ import net.runelite.api.ItemID; import net.runelite.api.Skill; import net.runelite.client.plugins.microbot.util.Global; +import net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue; import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; @@ -83,6 +84,15 @@ public static boolean useLamp(Skill skill) { Rs2Widget.clickWidget(LAMP_WIDGET_GROUP, LAMP_CONFIRM_BUTTON); + // Confirming the skill produces an XP-award "Click here to continue" dialogue. + // Dismiss it so the lamp is actually consumed and the leftover dialogue does not + // block other plugins once this blocking event releases the script-pause gate. + Global.sleepUntil(() -> Rs2Dialogue.hasContinue() || !Rs2Inventory.contains(ItemID.LAMP), 2000); + for (int i = 0; i < 5 && Rs2Dialogue.hasContinue(); i++) { + Rs2Dialogue.clickContinue(); + Global.sleep(600, 1200); + } + if (!Global.sleepUntil(() -> !Rs2Inventory.contains(ItemID.LAMP), 3000)) { log.warn("Lamp was not consumed after confirm"); return false; diff --git a/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/AutoFiremakingPlusConfig.java b/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/AutoFiremakingPlusConfig.java new file mode 100644 index 0000000000..7c0af73dc6 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/AutoFiremakingPlusConfig.java @@ -0,0 +1,140 @@ +package net.runelite.client.plugins.microbot.firemakingplus; + +import net.runelite.client.config.Config; +import net.runelite.client.config.ConfigGroup; +import net.runelite.client.config.ConfigInformation; +import net.runelite.client.config.ConfigItem; +import net.runelite.client.config.ConfigSection; +import net.runelite.client.config.Range; + +@ConfigGroup("FiremakingPlus") +@ConfigInformation("

Auto Firemaking Plus

" + + "

Version: " + AutoFiremakingPlusPlugin.version + "

" + + "

1. Method: Forester's Campfire (stand at a bank, add logs to a " + + "campfire, creating one if none is nearby - AFK) or Line firemaking (light logs in a " + + "line stepping west, then bank - higher XP/hr). Stand near a bank (the Grand Exchange is ideal) " + + "with logs + a tinderbox in the bank.

" + + "

2. Log type: which logs to burn. Progressive auto-picks the " + + "best logs your level can burn.

" + + "

3. Scan radius (Line only): how far to search for an open line.

" + + "

4. Stop after / Target level: auto-shutdown thresholds. Target level banks first.

" + + "

5. League mode: periodic arrow-key press to defeat the idle-logout.

" + + "

6. Speed mode: disables Microbot antiban. Throwaway accounts only.

") +public interface AutoFiremakingPlusConfig extends Config { + + @ConfigSection(name = "General", description = "General settings", position = 0) + String generalSection = "general"; + + @ConfigItem( + keyName = "method", + name = "Method", + description = "Forester's Campfire (AFK, one spot) or Line firemaking (higher XP/hr, walks a line).", + position = 0, + section = generalSection + ) + default FiremakingMethod method() { + return FiremakingMethod.CAMPFIRE; + } + + @ConfigItem( + keyName = "logType", + name = "Log type", + description = "Which logs to burn (ignored when Progressive is on).", + position = 1, + section = generalSection + ) + default Logs logType() { + return Logs.MAPLE; + } + + @ConfigItem( + keyName = "progressiveMode", + name = "Progressive", + description = "Automatically burn the best logs your Firemaking level allows.", + position = 2, + section = generalSection + ) + default boolean progressiveMode() { + return false; + } + + @ConfigItem( + keyName = "maximizeLogSpace", + name = "Maximize log space", + description = "Campfire method only: when a Forester's Campfire is already nearby, bank the " + + "tinderbox and carry one extra log (28 instead of 27). When no campfire is up it still " + + "withdraws a tinderbox to light its own. Off = always carry a tinderbox (fewer bank trips).", + position = 3, + section = generalSection + ) + default boolean maximizeLogSpace() { + return true; + } + + @Range(min = 10, max = 50) + @ConfigItem( + keyName = "scanRadius", + name = "Scan radius", + description = "Line firemaking only: how far around your start tile to search for an open line.", + position = 4, + section = generalSection + ) + default int scanRadius() { + return 25; + } + + @ConfigItem( + keyName = "stopAfterMinutes", + name = "Stop after (minutes)", + description = "Auto-shutdown after this many minutes of runtime. 0 = no limit.", + position = 5, + section = generalSection + ) + default int stopAfterMinutes() { + return 0; + } + + @ConfigItem( + keyName = "stopAfterXp", + name = "Stop after (XP gained)", + description = "Auto-shutdown after gaining this much Firemaking XP. 0 = no limit.", + position = 6, + section = generalSection + ) + default int stopAfterXp() { + return 0; + } + + @ConfigItem( + keyName = "targetLevel", + name = "Target level", + description = "Stop when Firemaking reaches this level. Banks the inventory first. 0 = disabled.", + position = 7, + section = generalSection + ) + default int targetLevel() { + return 0; + } + + @ConfigItem( + keyName = "leagueMode", + name = "League mode (anti-AFK)", + description = "Periodically presses an arrow key to reset the idle-logout timer.", + position = 8, + section = generalSection + ) + default boolean leagueMode() { + return false; + } + + @ConfigItem( + keyName = "speedMode", + name = "Speed mode (less antiban)", + description = "Disables Microbot's antiban. Faster, more pattern-detectable. Throwaway only.", + position = 9, + section = generalSection + ) + default boolean speedMode() { + return false; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/AutoFiremakingPlusOverlay.java b/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/AutoFiremakingPlusOverlay.java new file mode 100644 index 0000000000..402f9e6471 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/AutoFiremakingPlusOverlay.java @@ -0,0 +1,167 @@ +package net.runelite.client.plugins.microbot.firemakingplus; + +import net.runelite.api.Client; +import net.runelite.api.Experience; +import net.runelite.api.Skill; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.overlay.OverlayPanel; +import net.runelite.client.ui.overlay.OverlayPosition; +import net.runelite.client.ui.overlay.components.ButtonComponent; +import net.runelite.client.ui.overlay.components.LineComponent; +import net.runelite.client.ui.overlay.components.TitleComponent; + +import javax.inject.Inject; +import java.awt.*; +import java.text.NumberFormat; +import java.time.Duration; + +public class AutoFiremakingPlusOverlay extends OverlayPanel { + private static final Color TITLE_COLOR = new Color(0, 170, 0); + private static final Color HEADER_COLOR = new Color(140, 220, 140); + private static final Color NORMAL_TEXT_COLOR = Color.WHITE; + private static final Color HIGHLIGHT_COLOR = new Color(255, 235, 145); + + private final AutoFiremakingPlusPlugin plugin; + private final Client client; + private final AutoFiremakingPlusConfig config; + + public final ButtonComponent pauseButton; + + @Inject + AutoFiremakingPlusOverlay(AutoFiremakingPlusPlugin plugin, Client client, AutoFiremakingPlusConfig config) { + super(plugin); + this.plugin = plugin; + this.client = client; + this.config = config; + setPosition(OverlayPosition.TOP_LEFT); + setNaughty(); + + pauseButton = new ButtonComponent("Pause"); + pauseButton.setPreferredSize(new Dimension(100, 25)); + pauseButton.setParentOverlay(this); + pauseButton.setFont(FontManager.getRunescapeBoldFont()); + pauseButton.setOnClick(() -> { + Microbot.pauseAllScripts.set(!Microbot.pauseAllScripts.get()); + if (Microbot.pauseAllScripts.get()) { + Rs2Walker.setTarget(null); + } + }); + } + + @Override + public Dimension render(Graphics2D graphics) { + try { + panelComponent.setPreferredSize(new Dimension(240, 300)); + + panelComponent.getChildren().add(TitleComponent.builder() + .text("AutoFiremakingPlus v" + AutoFiremakingPlusPlugin.version) + .color(TITLE_COLOR) + .build()); + + panelComponent.getChildren().add(LineComponent.builder() + .left("Status:") + .right(Microbot.status == null ? "Idle" : Microbot.status) + .rightColor(HIGHLIGHT_COLOR) + .build()); + + panelComponent.getChildren().add(LineComponent.builder().left("").build()); + + panelComponent.getChildren().add(LineComponent.builder() + .left("Statistics") + .leftColor(HEADER_COLOR) + .build()); + + AutoFiremakingPlusScript script = plugin.getScript(); + if (script != null && script.getStartTimeMillis() > 0) { + int currentLevel = client.getRealSkillLevel(Skill.FIREMAKING); + int currentXp = client.getSkillExperience(Skill.FIREMAKING); + int xpGained = currentXp - script.getStartSkillXp(); + long runtimeMillis = System.currentTimeMillis() - script.getStartTimeMillis(); + long xpPerHour = (runtimeMillis > 1000) ? (xpGained * 3600000L / runtimeMillis) : 0; + + int levelDelta = currentLevel - script.getStartSkillLevel(); + String levelStr = currentLevel + (levelDelta > 0 ? " (+" + levelDelta + ")" : ""); + + panelComponent.getChildren().add(LineComponent.builder() + .left("Firemaking level:") + .right(levelStr) + .rightColor(NORMAL_TEXT_COLOR) + .build()); + + panelComponent.getChildren().add(LineComponent.builder() + .left("XP gained:") + .right(NumberFormat.getInstance().format(xpGained)) + .rightColor(NORMAL_TEXT_COLOR) + .build()); + + panelComponent.getChildren().add(LineComponent.builder() + .left("XP/hr:") + .right(NumberFormat.getInstance().format(xpPerHour)) + .rightColor(NORMAL_TEXT_COLOR) + .build()); + + panelComponent.getChildren().add(LineComponent.builder() + .left("Logs burnt:") + .right(String.valueOf(script.getActionsCompleted())) + .rightColor(NORMAL_TEXT_COLOR) + .build()); + + // Firemaking spends logs (no product), so this is a cost rate, not profit. Uses the + // configured log's GE price (approximate under Progressive, which varies the log). + long logCostPerHour = 0; + if (config.logType() != null && runtimeMillis > 1000) { + int logPrice = Microbot.getItemManager().getItemPrice(config.logType().getItemId()); + if (logPrice > 0) { + logCostPerHour = (long) script.getActionsCompleted() * logPrice * 3600000L / runtimeMillis; + } + } + panelComponent.getChildren().add(LineComponent.builder() + .left("Log cost/hr:") + .right(logCostPerHour > 0 ? "-" + NumberFormat.getInstance().format(logCostPerHour) : "0") + .rightColor(NORMAL_TEXT_COLOR) + .build()); + + panelComponent.getChildren().add(LineComponent.builder() + .left("Runtime:") + .right(formatDuration(Duration.ofMillis(runtimeMillis))) + .rightColor(NORMAL_TEXT_COLOR) + .build()); + + if (config.targetLevel() > 0) { + int toGo = Math.max(0, config.targetLevel() - currentLevel); + panelComponent.getChildren().add(LineComponent.builder() + .left("Target:") + .right(config.targetLevel() + (toGo > 0 ? " (" + toGo + " to go)" : " (reached)")) + .rightColor(HIGHLIGHT_COLOR) + .build()); + if (toGo > 0 && xpPerHour > 0) { + long xpRemaining = Math.max(0, Experience.getXpForLevel(config.targetLevel()) - currentXp); + panelComponent.getChildren().add(LineComponent.builder() + .left("ETA:") + .right(formatDuration(Duration.ofMillis(xpRemaining * 3600000L / xpPerHour))) + .rightColor(HIGHLIGHT_COLOR) + .build()); + } + } + } else { + panelComponent.getChildren().add(LineComponent.builder() + .left("(not running)") + .leftColor(NORMAL_TEXT_COLOR) + .build()); + } + + pauseButton.setText(Microbot.pauseAllScripts.get() ? "Resume" : "Pause"); + panelComponent.getChildren().add(pauseButton); + + } catch (Exception ex) { + Microbot.logStackTrace(this.getClass().getSimpleName(), ex); + } + return super.render(graphics); + } + + private String formatDuration(Duration duration) { + return String.format("%02d:%02d:%02d", duration.toHours(), duration.toMinutesPart(), duration.toSecondsPart()); + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/AutoFiremakingPlusPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/AutoFiremakingPlusPlugin.java new file mode 100644 index 0000000000..7023cbc79e --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/AutoFiremakingPlusPlugin.java @@ -0,0 +1,71 @@ +package net.runelite.client.plugins.microbot.firemakingplus; + +import com.google.inject.Provides; +import lombok.extern.slf4j.Slf4j; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.PluginConstants; +import net.runelite.client.ui.overlay.OverlayManager; + +import javax.inject.Inject; +import java.awt.*; + +@PluginDescriptor( + name = "[P] " + "Auto Firemaking Plus", + description = "Firemaking trainer: add logs to a Forester's Campfire or light a line of fires, with stop conditions, target level, and overlay/pause.", + tags = {"firemaking", "campfire", "skilling", "microbot", "plus"}, + authors = {"pjmarz"}, + version = AutoFiremakingPlusPlugin.version, + minClientVersion = "2.0.13", + cardUrl = "https://chsami.github.io/Microbot-Hub/AutoFiremakingPlusPlugin/assets/card.png", + iconUrl = "https://chsami.github.io/Microbot-Hub/AutoFiremakingPlusPlugin/assets/icon.png", + enabledByDefault = PluginConstants.DEFAULT_ENABLED, + isExternal = PluginConstants.IS_EXTERNAL +) +@Slf4j +public class AutoFiremakingPlusPlugin extends Plugin { + public static final String version = "0.2.2"; + + @Inject + private AutoFiremakingPlusConfig config; + + @Provides + AutoFiremakingPlusConfig provideConfig(ConfigManager configManager) { + return configManager.getConfig(AutoFiremakingPlusConfig.class); + } + + @Inject + private OverlayManager overlayManager; + + @Inject + private AutoFiremakingPlusOverlay overlay; + + @Inject + AutoFiremakingPlusScript script; + + @Override + protected void startUp() throws AWTException { + Microbot.pauseAllScripts.compareAndSet(true, false); + if (overlayManager != null) { + overlayManager.add(overlay); + overlay.pauseButton.hookMouseListener(); + } + script.run(config); + } + + @Override + protected void shutDown() { + Microbot.pauseAllScripts.compareAndSet(true, false); + script.shutdown(); + if (overlay != null) { + overlay.pauseButton.unhookMouseListener(); + } + overlayManager.remove(overlay); + } + + public AutoFiremakingPlusScript getScript() { + return script; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/AutoFiremakingPlusScript.java b/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/AutoFiremakingPlusScript.java new file mode 100644 index 0000000000..9d5d9cbe81 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/AutoFiremakingPlusScript.java @@ -0,0 +1,494 @@ +package net.runelite.client.plugins.microbot.firemakingplus; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Skill; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.ItemID; +import net.runelite.api.gameval.ObjectID; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.Script; +import net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel; +import net.runelite.client.plugins.microbot.util.antiban.Rs2Antiban; +import net.runelite.client.plugins.microbot.util.antiban.Rs2AntibanSettings; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; +import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard; +import net.runelite.client.plugins.microbot.util.math.Rs2Random; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; +import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; + +import java.awt.event.KeyEvent; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Firemaking trainer with two selectable methods (Forester's Campfire and Line firemaking) wrapped + * in the Plus layer (stop conditions, target level + clean shutdown, overlay/pause, speed/league + * modes). + * + *

The LINE method does tinderbox-on-log, auto-steps west, and uses TileScanner line finding with + * a blocked-line guard. The CAMPFIRE method stands at a bank, finds or lights a fire, then uses logs + * on it until the inventory is empty.

+ */ +@Slf4j +public class AutoFiremakingPlusScript extends Script { + + private static final int TINDERBOX_ID = ItemID.TINDERBOX; + private static final String TINDERBOX_NAME = "Tinderbox"; + private static final int FIRE_ID = ObjectID.FIRE; + private static final int FIRE_ID_ALT = 49927; + // Using a log on a Forester's Campfire opens this "Burn" make-X dialog; SPACE burns the whole + // inventory. + private static final int BURN_INTERFACE_WIDGET = 17694735; + + private State state = State.SCANNING; + private WorldPoint startPosition; + private Logs activeLog; + private FireLine currentLine; + + // Line blocked-line guard: the Y of the last line that blocked, excluded from the next scan. + private int blockedLineY = Integer.MIN_VALUE; + private int emptyScans = 0; + + // Stats (read by AutoFiremakingPlusOverlay). + private long startTimeMillis = 0; + private int startSkillXp = 0; + private int startSkillLevel = 0; + private int actionsCompleted = 0; + + private boolean shutdownAfterCleanup = false; + + // Campfire burn tracking (tick-driven). + // lastLogCount = active-log count observed last tick (-1 = reset/unknown); + // lastBurnProgressMs = last time the count dropped or we (re)initiated a burn. + private int lastLogCount = -1; + private long lastBurnProgressMs = 0; + + public long getStartTimeMillis() { return startTimeMillis; } + public int getStartSkillXp() { return startSkillXp; } + public int getStartSkillLevel() { return startSkillLevel; } + public int getActionsCompleted() { return actionsCompleted; } + + public boolean run(AutoFiremakingPlusConfig config) { + startPosition = null; + state = State.SCANNING; + activeLog = null; + currentLine = null; + blockedLineY = Integer.MIN_VALUE; + emptyScans = 0; + lastLogCount = -1; + lastBurnProgressMs = 0; + + startTimeMillis = System.currentTimeMillis(); + startSkillXp = Microbot.getClientThread().runOnClientThreadOptional(() -> + Microbot.getClient().getSkillExperience(Skill.FIREMAKING)).orElse(0); + startSkillLevel = Microbot.getClientThread().runOnClientThreadOptional(() -> + Microbot.getClient().getRealSkillLevel(Skill.FIREMAKING)).orElse(1); + actionsCompleted = 0; + shutdownAfterCleanup = false; + + Microbot.enableAutoRunOn = true; + Rs2Walker.disableTeleports = true; // keep banking on foot + Rs2Antiban.resetAntibanSettings(); + Rs2Antiban.antibanSetupTemplates.applyFiremakingSetup(); + if (config.speedMode()) { + Rs2AntibanSettings.antibanEnabled = false; + } + + mainScheduledFuture = scheduledExecutorService.scheduleWithFixedDelay(() -> { + try { + if (!Microbot.isLoggedIn()) return; + if (!super.run()) return; + + if (Microbot.pauseAllScripts.get()) { + Microbot.status = "[PAUSED]"; + return; + } + + if (config.stopAfterMinutes() > 0 && !shutdownAfterCleanup + && (System.currentTimeMillis() - startTimeMillis) / 60000 >= config.stopAfterMinutes()) { + Microbot.log("AutoFiremakingPlus: reached stopAfterMinutes. Banking then shutting down."); + shutdownAfterCleanup = true; + state = State.BANKING; + } + if (config.stopAfterXp() > 0 && !shutdownAfterCleanup) { + int currentXp = Microbot.getClientThread().runOnClientThreadOptional(() -> + Microbot.getClient().getSkillExperience(Skill.FIREMAKING)).orElse(startSkillXp); + if (currentXp - startSkillXp >= config.stopAfterXp()) { + Microbot.log("AutoFiremakingPlus: reached stopAfterXp. Banking then shutting down."); + shutdownAfterCleanup = true; + state = State.BANKING; + } + } + if (config.targetLevel() > 0 && !shutdownAfterCleanup) { + int currentLevel = Microbot.getClientThread().runOnClientThreadOptional(() -> + Microbot.getClient().getRealSkillLevel(Skill.FIREMAKING)).orElse(startSkillLevel); + if (currentLevel >= config.targetLevel()) { + Microbot.log("AutoFiremakingPlus: reached targetLevel (" + currentLevel + + "). Banking then shutting down."); + shutdownAfterCleanup = true; + state = State.BANKING; + } + } + + if (config.leagueMode() && Rs2Player.checkIdleLogout(Rs2Random.between(500, 1500))) { + int[] arrowKeys = { KeyEvent.VK_LEFT, KeyEvent.VK_RIGHT, KeyEvent.VK_UP, KeyEvent.VK_DOWN }; + Rs2Keyboard.keyPress(arrowKeys[Rs2Random.between(0, arrowKeys.length - 1)]); + } + + // Skip the tick while the global action cooldown is active. Speed mode bypasses the + // gate entirely so it actually runs without antiban pacing. + if (!config.speedMode() && Rs2AntibanSettings.actionCooldownActive) return; + + if (startPosition == null) startPosition = Rs2Player.getWorldLocation(); + + activeLog = config.progressiveMode() ? Logs.getBestForLevel() : config.logType(); + if (activeLog == null || !activeLog.hasRequiredLevel()) { + Microbot.status = "Firemaking level too low for " + + (activeLog != null ? activeLog.getLogName() : "any logs"); + return; + } + + if (config.method() == FiremakingMethod.CAMPFIRE) { + runCampfire(config); + } else { + runLine(config); + } + // Arm the cooldown / micro-break by chance after each tick's work, unless speed mode + // has disabled antiban. + if (!config.speedMode()) { + Rs2Antiban.actionCooldown(); + Rs2Antiban.takeMicroBreakByChance(); + } + } catch (Exception ex) { + Microbot.logStackTrace("AutoFiremakingPlusScript", ex); + } + }, 0, 600, TimeUnit.MILLISECONDS); + return true; + } + + // --- Forester's Campfire: stand at a bank, find/light a fire, use logs on it until empty. --- + + private void runCampfire(AutoFiremakingPlusConfig config) { + if (shutdownAfterCleanup || !Rs2Inventory.hasItem(activeLog.getItemId())) { + lastLogCount = -1; // reset burn tracking before banking + handleBanking(config); + return; + } + // Only block on movement (walking back from the bank). The burn is tick-driven below. + if (Rs2Player.isMoving()) { + Microbot.status = "Returning to campfire"; + return; + } + + WorldPoint anchor = Rs2Player.getWorldLocation(); + Rs2TileObjectModel target = findCampfire(anchor, 12); + + if (target == null) { + // No fire or campfire nearby: light our own fire with a tinderbox, then burn logs on it. + // Using logs on a self-lit fire opens the same Burn dialog as a Forester's Campfire, so + // the burn logic below handles either one. + if (!Rs2Inventory.hasItem(TINDERBOX_NAME)) { + Microbot.status = "No tinderbox - banking for one"; + lastLogCount = -1; + handleBanking(config); + return; + } + Microbot.status = "Lighting own fire"; + WorldPoint here = Rs2Player.getWorldLocation(); + Rs2Inventory.combine(TINDERBOX_NAME, activeLog.getLogName()); + if (Rs2Player.waitForXpDrop(Skill.FIREMAKING, 5000)) { + // Fire lit (one log burned); the player auto-steps back. Next tick findCampfire + // finds the new fire and the burn logic adds the rest of the inventory to it. + actionsCompleted++; + lastLogCount = -1; + } else { + // "You can't light a fire here" -> step to a nearby lightable tile and retry. + Microbot.status = "Can't light here - repositioning"; + WorldPoint spot = findLightableTile(here); + if (spot != null) { + Rs2Walker.walkTo(spot, 0); + sleepUntil(() -> !Rs2Player.isMoving(), 3000); + } + } + return; + } + + int count = Rs2Inventory.count(activeLog.getItemId()); + long now = System.currentTimeMillis(); + + // Burn in progress: the count dropped since last tick. Credit each log burned and let it + // keep going -- do NOT re-initiate, which would re-open the dialog mid-burn. + if (lastLogCount >= 0 && count < lastLogCount) { + actionsCompleted += (lastLogCount - count); + lastLogCount = count; + lastBurnProgressMs = now; + Microbot.status = "Burning logs (" + count + " left)"; + return; + } + // Recently (re)initiated and still inside the grace window: give the burn time to tick + // before re-kicking. The loop stays responsive (pause/stop honoured every tick). + if (lastLogCount >= 0 && now - lastBurnProgressMs < 5000) { + lastLogCount = count; + Microbot.status = "Burning logs (" + count + " left)"; + return; + } + + // Fresh start, or the burn stalled with logs remaining -> (re)initiate it. Use a log on the + // campfire (menu-based) -> "Burn" make-X dialog (widget 17694735) -> SPACE. Using a log is + // what yields XP. Select the log and wait for it to actually enter "use" mode before + // interacting, since useItemOnObject's internal 100ms check is too tight under antiban. + Microbot.status = "Adding logs to campfire"; + Rs2Inventory.use(activeLog.getItemId()); + if (!sleepUntil(Rs2Inventory::isItemSelected, 2000)) { + Microbot.log("[Firemaking] log did not select; retrying next tick"); + return; + } + Rs2GameObject.interact(target); + if (sleepUntil(() -> Rs2Widget.getWidget(BURN_INTERFACE_WIDGET) != null, 5000)) { + Rs2Keyboard.keyPress(KeyEvent.VK_SPACE); + lastLogCount = count; + lastBurnProgressMs = System.currentTimeMillis(); + sleep(600, 900); + } else { + Microbot.log("[Firemaking] burn dialog did NOT open; retrying next tick"); + } + } + + /** + * Find a nearby Forester's Campfire (by name) or plain fire (by id) within radius of anchor. + * Queried on the client thread: the script loop runs on a background thread, and off-thread reads + * of the tile-object cache can return a stale campfire that has already burned out. On the client + * thread the cache reflects the despawn, so this correctly returns null when no fire exists. + */ + private Rs2TileObjectModel findCampfire(WorldPoint anchor, int radius) { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Rs2TileObjectModel t = Microbot.getRs2TileObjectCache().query() + .withNameContains("ampfire") + .nearest(anchor, radius); + if (t == null) { + t = Microbot.getRs2TileObjectCache().query() + .where(o -> o.getId() == FIRE_ID || o.getId() == FIRE_ID_ALT) + .nearest(anchor, radius); + } + return t; + }).orElse(null); + } + + /** Find a nearby walkable tile with no fire on it, so we can light our own there. */ + private WorldPoint findLightableTile(WorldPoint from) { + // One client-thread scan for nearby fires, then cheap local membership checks per offset, + // instead of streaming the tile-object cache once per candidate tile. + java.util.Set fires = TileScanner.fireTilesNear(from, 2); + int[][] offsets = { {-1, 0}, {1, 0}, {0, -1}, {0, 1}, {-1, -1}, {1, 1}, {-1, 1}, {1, -1} }; + for (int[] o : offsets) { + WorldPoint p = new WorldPoint(from.getX() + o[0], from.getY() + o[1], from.getPlane()); + if (!fires.contains(p) && Rs2Tile.isWalkable(p)) { + return p; + } + } + return null; + } + + // --- Line firemaking: light logs in a line stepping west, with a blocked-line guard. --- + + private void runLine(AutoFiremakingPlusConfig config) { + switch (state) { + case SCANNING: + lineScan(config); + break; + case WALKING_TO_LINE: + lineWalkTo(); + break; + case BURNING: + lineBurn(); + break; + case BANKING: + handleBanking(config); + break; + case WALKING_BACK: + lineWalkBack(config); + break; + } + } + + private void lineScan(AutoFiremakingPlusConfig config) { + Microbot.status = "Scanning for open space"; + if (!Rs2Inventory.hasItem(activeLog.getItemId())) { + state = State.BANKING; + return; + } + // Scan once and derive both the best line and the blocked-row fallback from the same list + // (findFireLines returns lines already sorted best-first). + List lines = TileScanner.findFireLines(startPosition, config.scanRadius()); + FireLine line = lines.isEmpty() ? null : lines.get(0); + // Guard: if the best line is the same row we just got blocked on, pick a different row. + if (line != null && line.getY() == blockedLineY) { + line = lines.stream() + .filter(l -> l.getY() != blockedLineY) + .findFirst().orElse(null); + } + if (line == null) { + emptyScans++; + if (emptyScans >= 5) { + Microbot.status = "No open line found - move me to open ground"; + emptyScans = 0; + blockedLineY = Integer.MIN_VALUE; // reset so banking->return can retry fresh + state = State.BANKING; + } else { + Microbot.status = "No open space found - retrying"; + } + return; + } + emptyScans = 0; + currentLine = line; + Microbot.status = "Found line: " + line.getLength() + " tiles"; + state = State.WALKING_TO_LINE; + } + + private void lineWalkTo() { + if (currentLine == null) { + state = State.SCANNING; + return; + } + WorldPoint eastEnd = currentLine.getEastEnd(); + Microbot.status = "Walking to line"; + if (Rs2Player.getWorldLocation().distanceTo(eastEnd) <= 1) { + state = State.BURNING; + return; + } + if (!Rs2Player.isMoving()) { + Rs2Walker.walkTo(eastEnd, 0); + } + } + + private void lineBurn() { + if (!Rs2Inventory.hasItem(activeLog.getItemId())) { + state = State.BANKING; + return; + } + if (Rs2Player.isMoving()) { + Microbot.status = "Walking after lighting"; + return; + } + if (Rs2Player.isAnimating()) { + Microbot.status = "Lighting animation"; + return; + } + if (!Rs2Inventory.hasItem(TINDERBOX_NAME)) { + state = State.BANKING; + return; + } + + WorldPoint playerPos = Rs2Player.getWorldLocation(); + if (TileScanner.hasFire(playerPos)) { + WorldPoint westTile = new WorldPoint(playerPos.getX() - 1, playerPos.getY(), playerPos.getPlane()); + if (!Rs2Tile.isWalkable(westTile)) { + blockedLineY = playerPos.getY(); // remember this row so the next scan avoids it + Microbot.status = "Blocked west - rescanning"; + state = State.SCANNING; + return; + } + Rs2Walker.walkTo(westTile, 0); + sleepUntil(() -> Rs2Player.getWorldLocation().distanceTo(westTile) <= 0, 3000); + return; + } + if (!Rs2Tile.isWalkable(playerPos)) { + blockedLineY = playerPos.getY(); + Microbot.status = "Blocked tile - rescanning"; + state = State.SCANNING; + return; + } + + Microbot.status = "Lighting " + activeLog.getLogName(); + WorldPoint before = Rs2Player.getWorldLocation(); + Rs2Inventory.combine(TINDERBOX_NAME, activeLog.getLogName()); + if (Rs2Player.waitForXpDrop(Skill.FIREMAKING, 10000)) { + actionsCompleted++; + sleepUntil(() -> !Rs2Player.getWorldLocation().equals(before), 3000); + sleep(200, 400); + } + } + + private void lineWalkBack(AutoFiremakingPlusConfig config) { + Microbot.status = "Walking back"; + if (Rs2Player.getWorldLocation().distanceTo(startPosition) <= config.scanRadius()) { + state = State.SCANNING; + return; + } + if (!Rs2Player.isMoving()) { + Rs2Walker.walkTo(startPosition, 3); + } + } + + // --- Shared banking (both methods). --- + + private void handleBanking(AutoFiremakingPlusConfig config) { + if (Rs2Player.isMoving()) return; + Microbot.status = "Banking"; + if (!Rs2Bank.isOpen()) { + if (!Rs2Bank.walkToBankAndUseBank()) return; + } + if (!Rs2Bank.isOpen()) return; + + // Decide whether to carry a tinderbox this trip. The line method always lights fires, so it + // always needs one. The campfire method needs one only to light its OWN fire: if a Forester's + // Campfire is already within burn range and maximizeLogSpace is on, bank the tinderbox and + // carry one extra log instead. findCampfire reads on the client thread (live scene) and uses + // the same radius runCampfire burns at, so it reliably predicts whether we will need to light. + boolean needTinderbox = config.method() == FiremakingMethod.LINE + || !config.maximizeLogSpace() + || findCampfire(Rs2Player.getWorldLocation(), 12) == null; + + if (needTinderbox) { + Rs2Bank.depositAllExcept(TINDERBOX_NAME, activeLog.getLogName()); + } else { + // A campfire is up: bank the tinderbox too, freeing a slot for one more log. + Rs2Bank.depositAllExcept(activeLog.getLogName()); + } + sleep(300); + + if (shutdownAfterCleanup) { + Rs2Bank.closeBank(); + Microbot.log("AutoFiremakingPlus: target reached, banked, shutting down."); + super.shutdown(); + return; + } + + if (needTinderbox && !Rs2Inventory.hasItem(TINDERBOX_NAME)) { + if (!Rs2Bank.hasItem(TINDERBOX_ID)) { + Microbot.showMessage("No tinderbox in the bank!"); + super.shutdown(); + return; + } + Rs2Bank.withdrawOne(TINDERBOX_ID); + sleepUntil(() -> Rs2Inventory.hasItem(TINDERBOX_NAME), 3000); + } + + if (!Rs2Bank.hasItem(activeLog.getItemId())) { + Microbot.showMessage("No " + activeLog.getLogName() + " in the bank!"); + super.shutdown(); + return; + } + Microbot.status = "Withdrawing logs"; + Rs2Bank.withdrawAll(activeLog.getItemId()); + sleepUntil(() -> Rs2Inventory.hasItem(activeLog.getItemId()), 3000); + Rs2Random.wait(300, 700); + Rs2Bank.closeBank(); + + // Resume: campfire burns where it stands; line walks back to its start area then rescans. + state = (config.method() == FiremakingMethod.CAMPFIRE) ? State.BURNING : State.WALKING_BACK; + } + + @Override + public void shutdown() { + super.shutdown(); + Rs2Walker.disableTeleports = false; + Rs2Antiban.resetAntibanSettings(); + startPosition = null; + currentLine = null; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/FireLine.java b/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/FireLine.java new file mode 100644 index 0000000000..1a125ebaa4 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/FireLine.java @@ -0,0 +1,21 @@ +// Adapted from the leaguesfiremaking plugin (FireLine). +package net.runelite.client.plugins.microbot.firemakingplus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import net.runelite.api.coords.WorldPoint; + +/** + * A horizontal run of open tiles to lay fires along. + */ +@Getter +@RequiredArgsConstructor +public class FireLine { + private final WorldPoint westEnd; + private final WorldPoint eastEnd; + private final int length; + + public int getY() { + return westEnd.getY(); + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/FiremakingMethod.java b/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/FiremakingMethod.java new file mode 100644 index 0000000000..073d4c6d75 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/FiremakingMethod.java @@ -0,0 +1,20 @@ +package net.runelite.client.plugins.microbot.firemakingplus; + +/** + * Which firemaking method AutoFiremakingPlus performs. + */ +public enum FiremakingMethod { + CAMPFIRE("Forester's Campfire"), + LINE("Line firemaking"); + + private final String label; + + FiremakingMethod(String label) { + this.label = label; + } + + @Override + public String toString() { + return label; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/Logs.java b/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/Logs.java new file mode 100644 index 0000000000..8f8040da5b --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/Logs.java @@ -0,0 +1,49 @@ +// Adapted from the leaguesfiremaking plugin (LogType). +package net.runelite.client.plugins.microbot.firemakingplus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import net.runelite.api.Skill; +import net.runelite.api.gameval.ItemID; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; + +/** + * Logs burnable for Firemaking, with level requirements. + */ +@Getter +@RequiredArgsConstructor +public enum Logs { + LOGS("Logs", ItemID.LOGS, 1), + OAK("Oak logs", ItemID.OAK_LOGS, 15), + WILLOW("Willow logs", ItemID.WILLOW_LOGS, 30), + TEAK("Teak logs", ItemID.TEAK_LOGS, 35), + MAPLE("Maple logs", ItemID.MAPLE_LOGS, 45), + MAHOGANY("Mahogany logs", ItemID.MAHOGANY_LOGS, 50), + YEW("Yew logs", ItemID.YEW_LOGS, 60), + MAGIC("Magic logs", ItemID.MAGIC_LOGS, 75), + REDWOOD("Redwood logs", ItemID.REDWOOD_LOGS, 90); + + private final String logName; + private final int itemId; + private final int levelRequired; + + public boolean hasRequiredLevel() { + return Rs2Player.getSkillRequirement(Skill.FIREMAKING, levelRequired); + } + + /** Highest-level log the player can currently burn (for progressive mode). */ + public static Logs getBestForLevel() { + Logs best = LOGS; + for (Logs log : values()) { + if (log.hasRequiredLevel()) { + best = log; + } + } + return best; + } + + @Override + public String toString() { + return logName; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/State.java b/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/State.java new file mode 100644 index 0000000000..9b00ec770c --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/State.java @@ -0,0 +1,13 @@ +package net.runelite.client.plugins.microbot.firemakingplus; + +/** + * Script states. The CAMPFIRE method only uses BURNING/BANKING (driven by inventory); the LINE + * method walks the full scan -> walk-to -> burn -> bank -> walk-back cycle. + */ +public enum State { + SCANNING, + WALKING_TO_LINE, + BURNING, + BANKING, + WALKING_BACK +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/TileScanner.java b/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/TileScanner.java new file mode 100644 index 0000000000..650a164f1c --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/firemakingplus/TileScanner.java @@ -0,0 +1,140 @@ +// Adapted from the leaguesfiremaking plugin (TileScanner). +package net.runelite.client.plugins.microbot.firemakingplus; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.ObjectID; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Finds horizontal lines of open tiles for line firemaking and detects fire tiles. + */ +@Slf4j +public class TileScanner { + + private static final int FIRE_ID = ObjectID.FIRE; + private static final int FIRE_ID_ALT = 49927; + + private enum TileState { + OPEN, + FIRE, + BLOCKED + } + + private static TileState classifyTile(WorldPoint point, Set fireTiles, Set objectTiles) { + if (fireTiles.contains(point)) return TileState.FIRE; + if (objectTiles.contains(point)) return TileState.BLOCKED; + if (!Rs2Tile.isWalkable(point)) return TileState.BLOCKED; + return TileState.OPEN; + } + + /** Snapshot of fire and non-fire object tiles near a point, built on the client thread. */ + private static final class ObjectSnapshot { + final Set fireTiles; + final Set objectTiles; + + ObjectSnapshot(Set fireTiles, Set objectTiles) { + this.fireTiles = fireTiles; + this.objectTiles = objectTiles; + } + } + + /** + * Builds the snapshot entirely inside the client-thread call and hands it back as the call's + * result, so the off-thread reader gets fully populated sets with a proper happens-before edge. + */ + private static ObjectSnapshot snapshotObjects(WorldPoint center, int radius) { + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + Set fires = new HashSet<>(); + Set objects = new HashSet<>(); + Microbot.getRs2TileObjectCache().getStream() + .filter(obj -> obj.getWorldLocation().distanceTo(center) <= radius) + .forEach(obj -> { + int id = obj.getId(); + WorldPoint loc = obj.getWorldLocation(); + if (id == FIRE_ID || id == FIRE_ID_ALT) { + fires.add(loc); + } else { + objects.add(loc); + } + }); + return new ObjectSnapshot(fires, objects); + }).orElseGet(() -> new ObjectSnapshot(new HashSet<>(), new HashSet<>())); + } + + /** + * Fire tiles within {@code radius} of {@code center}. One client-thread scan; callers can then + * do cheap local membership checks instead of streaming the cache per tile. + */ + public static Set fireTilesNear(WorldPoint center, int radius) { + return snapshotObjects(center, radius).fireTiles; + } + + public static List findFireLines(WorldPoint center, int radius) { + // The grid loop below is safe off-thread: the snapshot sets are immutable-by-convention + // results of the client-thread call, and classifyTile's Rs2Tile.isWalkable self-guards. + final ObjectSnapshot snapshot = snapshotObjects(center, radius); + final Set fireTiles = snapshot.fireTiles; + final Set objectTiles = snapshot.objectTiles; + + List lines = new ArrayList<>(); + int plane = center.getPlane(); + + for (int y = center.getY() - radius; y <= center.getY() + radius; y++) { + int runStartX = -1; + int runLength = 0; + + for (int x = center.getX() - radius; x <= center.getX() + radius; x++) { + WorldPoint point = new WorldPoint(x, y, plane); + TileState state = classifyTile(point, fireTiles, objectTiles); + + if (state == TileState.OPEN) { + if (runStartX == -1) { + runStartX = x; + } + runLength++; + } else { + if (runLength >= 5) { + lines.add(new FireLine( + new WorldPoint(runStartX, y, plane), + new WorldPoint(runStartX + runLength - 1, y, plane), + runLength + )); + } + runStartX = -1; + runLength = 0; + } + } + if (runLength >= 5) { + lines.add(new FireLine( + new WorldPoint(runStartX, y, plane), + new WorldPoint(runStartX + runLength - 1, y, plane), + runLength + )); + } + } + + // Score lines: balance length vs proximity to start position (higher score = better). + // A nearby shorter line beats a far-away longer one; sort best-first. + lines.sort(Comparator.comparingDouble( + (FireLine l) -> l.getLength() - center.distanceTo(l.getEastEnd()) * 0.5 + ).reversed()); + + return lines; + } + + public static boolean hasFire(WorldPoint point) { + return Microbot.getClientThread().runOnClientThreadOptional(() -> + Microbot.getRs2TileObjectCache().getStream() + .anyMatch(obj -> obj.getWorldLocation().equals(point) + && (obj.getId() == FIRE_ID || obj.getId() == FIRE_ID_ALT)) + ).orElse(false); + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/gotr/GotrPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/gotr/GotrPlugin.java index 7f552ea253..3dbd787f09 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/gotr/GotrPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/gotr/GotrPlugin.java @@ -41,7 +41,7 @@ ) @Slf4j public class GotrPlugin extends Plugin { - public static final String version = "1.5.4"; + public static final String version = "1.5.7"; @Inject private GotrConfig config; diff --git a/src/main/java/net/runelite/client/plugins/microbot/gotr/GotrScript.java b/src/main/java/net/runelite/client/plugins/microbot/gotr/GotrScript.java index c36c592c6c..c5f856f767 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/gotr/GotrScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/gotr/GotrScript.java @@ -107,6 +107,23 @@ private void initializeGuardianPortalInfo() { public boolean run(GotrConfig config) { this.config = config; + // Static (and singleton-instance) state persists for the whole JVM session and leaks + // across plugin disable/re-enable (see docs/PLUGIN_DEBUGGING_NOTES.md §5). Reset it here + // so a restart behaves like a first start instead of inheriting a stale state machine. + shouldMineGuardianRemains = true; + isInMiniGame = false; + isFirstPortal = true; + state = null; + nextGameStart = Optional.empty(); + timeSincePortal = Optional.empty(); + elementalRewardPoints = 0; + catalyticRewardPoints = 0; + useNpcContact = true; + initCheck = false; + optimizedEssenceLoop = false; + guardians.clear(); + activeGuardianPortals.clear(); + greatGuardian = null; mainScheduledFuture = scheduledExecutorService.scheduleWithFixedDelay(() -> { try { if (!Microbot.isLoggedIn()) return; @@ -122,8 +139,6 @@ public boolean run(GotrConfig config) { initCheck = true; } - Rs2Walker.setTarget(null); - if (!Rs2Inventory.hasItem("pickaxe") && !Rs2Equipment.isWearing("pickaxe")) { log("You need to have a pickaxe before you can participate in this minigame."); return; @@ -233,6 +248,11 @@ private boolean waitingForGameToStart(int timeToStart) { if (getStartTimer() > Rs2Random.randomGaussian(35, Rs2Random.between(1, 5)) || getStartTimer() == -1 || timeToStart > 10) { + // A round just ended (or hasn't started yet) and this path runs instead of the + // craft branch — bank any crafted runes into the pool before prepping for the next + // game, so we never carry runes over. + if (depositRunesIntoPool()) return true; + // Only take cells if we don't already have them if (!Rs2Inventory.hasItem("Uncharged cell")) { // If in large mine and need cells, leave first @@ -243,7 +263,7 @@ private boolean waitingForGameToStart(int timeToStart) { // Return to large mine if we were there before if (!isInLargeMine() && shouldMineGuardianRemains) { if (Rs2Walker.walkTo(new WorldPoint(3632, 9503, 0), 20)) { - Microbot.getRs2TileObjectCache().query().interact(ObjectID.RUBBLE_43724); + interactObject(ObjectID.RUBBLE_43724); return true; } } @@ -261,33 +281,42 @@ private boolean waitingForGameToStart(int timeToStart) { private boolean repairCells() { Rs2ItemModel cell = Rs2Inventory.get(CellType.PoweredCellList().stream().mapToInt(i -> i).toArray()); - if (cell != null && isInMainRegion() && isInMiniGame() && !shouldMineGuardianRemains && !isInLargeMine() && !isInHugeMine()) { - int cellTier = CellType.GetCellTier(cell.getId()); - List shieldCells = Microbot.getRs2TileObjectCache().query() - .where(o -> o.getName() != null && o.getName().toLowerCase().contains("cell_tile")) - .toListOnClientThread(); - - if (Rs2Inventory.hasItemAmount(GUARDIAN_ESSENCE, 10)) { - for (Rs2TileObjectModel shieldCell : shieldCells) { - if (CellType.GetShieldTier(shieldCell.getId()) < cellTier) { - Microbot.log("Upgrading power cell at " + shieldCell.getWorldLocation()); - shieldCell.click("Place-cell"); - sleepUntil(() -> !Rs2Player.isMoving()); - return true; - } + if (cell == null || !isInMainRegion() || !isInMiniGame() || shouldMineGuardianRemains || isInLargeMine() || isInHugeMine()) { + return false; + } + int cellTier = CellType.GetCellTier(cell.getId()); + // Identify the shield pylons by object id (CellType.GetShieldTier knows them all and + // returns -1 for anything else). The previous filter matched on a name containing + // "cell_tile", but the real pylon objects aren't named that, so the query always came + // back empty — yet the method still returned true unconditionally below. That made the + // main loop short-circuit at `if (repairCells()) return;` on every tick whenever a + // powered cell was held, leaving the bot standing idle until the next game start. Match + // by id, and only claim the tick when we actually place/use a cell. + List shieldCells = Microbot.getRs2TileObjectCache().query() + .where(o -> CellType.GetShieldTier(o.getId()) >= 0) + .toListOnClientThread(); + + if (Rs2Inventory.hasItemAmount(GUARDIAN_ESSENCE, 10)) { + for (Rs2TileObjectModel shieldCell : shieldCells) { + if (CellType.GetShieldTier(shieldCell.getId()) < cellTier) { + Microbot.log("Upgrading power cell at " + shieldCell.getWorldLocation()); + shieldCell.click("Place-cell"); + sleepUntil(() -> !Rs2Player.isMoving()); + return true; } } - Rs2TileObjectModel cellToUse = shieldCells.stream() - .filter(o -> o.getId() != ObjectID.CELL_TILE_BROKEN) - .findFirst().orElse(null); - if (cellToUse != null) { - cellToUse.click(); - log("Using cell with id " + cellToUse.getId()); - sleep(Rs2Random.randomGaussian(1000, 300)); - sleepUntil(() -> !Rs2Player.isMoving()); - } + } + Rs2TileObjectModel cellToUse = shieldCells.stream() + .filter(o -> CellType.GetShieldTier(o.getId()) > 0) + .findFirst().orElse(null); + if (cellToUse != null) { + cellToUse.click(); + log("Using cell with id " + cellToUse.getId()); + sleep(Rs2Random.randomGaussian(1000, 300)); + sleepUntil(() -> !Rs2Player.isMoving()); return true; } + // Nothing to place — don't pretend we handled the tick, or the loop will never craft. return false; } @@ -314,7 +343,7 @@ private void takeUnchargedCells() { } } - Microbot.getRs2TileObjectCache().query().interact(ObjectID.UNCHARGED_CELLS_43732, "Take-10"); + interactObject(ObjectID.UNCHARGED_CELLS_43732, "Take-10"); log("Taking uncharged cells..."); Rs2Player.waitForAnimation(); } @@ -336,15 +365,24 @@ private boolean usePortal() { } private boolean depositRunesIntoPool() { - if (config.shouldDepositRunes() && Rs2Inventory.hasItem(runeIds.stream().mapToInt(i -> i).toArray()) && !isInLargeMine() && !isInHugeMine() && !Rs2Inventory.isFull() && !optimizedEssenceLoop) { - if (Rs2Player.isMoving()) return true; - if (Microbot.getRs2TileObjectCache().query().interact(ObjectID.DEPOSIT_POOL)) { - log("Deposit runes into pool..."); - sleep(600, 2400); - } - return true; + if (!config.shouldDepositRunes() + || !Rs2Inventory.hasItem(runeIds.stream().mapToInt(i -> i).toArray()) + || isInLargeMine() || isInHugeMine()) { + return false; } - return false; + if (Rs2Player.isMoving()) return true; + // Walk-first interaction, but only claim the tick when the pool actually exists — otherwise + // return false so we never lock the loop standing around holding runes. Dropped the old + // !isFull / !optimizedEssenceLoop guards: they skipped exactly the end-of-round case, where + // a full inventory of crafted runes would otherwise never be deposited and carried into the + // next round. + Rs2TileObjectModel pool = Microbot.getRs2TileObjectCache().query().withId(ObjectID.DEPOSIT_POOL).nearest(); + if (pool == null) return false; + if (interactObject(pool, null)) { + log("Deposit runes into pool..."); + sleep(600, 2400); + } + return true; } private boolean enterAltar() { @@ -362,7 +400,7 @@ private boolean enterAltar() { } private boolean craftGuardianEssences() { - if (Microbot.getRs2TileObjectCache().query().interact(ObjectID.WORKBENCH_43754)) { + if (interactObject(ObjectID.WORKBENCH_43754)) { state = GotrState.CRAFT_GUARDIAN_ESSENCE; sleep(Rs2Random.randomGaussian(Rs2Random.between(600, 900), Rs2Random.between(150, 300))); log("Crafting guardian essences..."); @@ -373,7 +411,7 @@ private boolean craftGuardianEssences() { private boolean leaveLargeMine() { if (isInLargeMine()) { - Microbot.getRs2TileObjectCache().query().interact(ObjectID.RUBBLE_43726); + interactObject(ObjectID.RUBBLE_43726); Rs2Player.waitForAnimation(); log("Leaving large mine..."); state = GotrState.LEAVING_LARGE_MINE; @@ -416,13 +454,13 @@ private boolean craftRunes() { if (Rs2Inventory.hasItem(GUARDIAN_ESSENCE)) { state = GotrState.CRAFTING_RUNES; optimizedEssenceLoop = false; - Microbot.getRs2TileObjectCache().query().interact(rcAltar.getId()); + interactObject(rcAltar, null); log("Crafting runes on altar " + rcAltar.getId()); sleep(Rs2Random.randomGaussian(Rs2Random.between(1000, 1500), 300)); } else if (!Rs2Player.isMoving()) { state = GotrState.LEAVING_ALTAR; Rs2TileObjectModel rcPortal = findPortalToLeaveAltar(); - if (Microbot.getRs2TileObjectCache().query().interact(rcPortal.getId())) { + if (interactObject(rcPortal, null)) { log("Leaving the altar..."); sleepUntilTrue(GotrScript::isInMainRegion,100,10000); sleep(Rs2Random.randomGaussian(750, 150)); @@ -437,7 +475,7 @@ private boolean craftRunes() { private static boolean waitForMinigameToStart() { if (!isInMainRegion()) { Rs2TileObjectModel rcPortal = findPortalToLeaveAltar(); - if (rcPortal != null && Microbot.getRs2TileObjectCache().query().interact(rcPortal.getId())) { + if (rcPortal != null && interactObject(rcPortal, null)) { state = GotrState.LEAVING_ALTAR; return true; } @@ -446,13 +484,13 @@ private static boolean waitForMinigameToStart() { if (state != GotrState.WAITING) { state = GotrState.WAITING; log("Make sure to start the script near the minigame barrier."); - Microbot.getRs2TileObjectCache().query().interact(ObjectID.BARRIER_43849, "Peek"); + interactObject(ObjectID.BARRIER_43849, "Peek"); } return state == GotrState.WAITING; } private static boolean enterMinigame() { - if (Microbot.getRs2TileObjectCache().query().interact(ObjectID.BARRIER_43700, "quick-pass")) { + if (interactObject(ObjectID.BARRIER_43700, "quick-pass")) { Rs2Player.waitForWalking(); state = GotrState.ENTER_GAME; GotrScript.shouldMineGuardianRemains = true; @@ -479,10 +517,10 @@ private boolean mineHugeGuardianRemain() { } if (!Rs2Inventory.isFull()) { if (!Rs2Player.isAnimating()) { - Microbot.getRs2TileObjectCache().query().interact(ObjectID.HUGE_GUARDIAN_REMAINS); + interactObject(ObjectID.HUGE_GUARDIAN_REMAINS); Rs2Player.waitForAnimation(); if (!Rs2Player.isAnimating()) - Microbot.getRs2TileObjectCache().query().interact(ObjectID.HUGE_GUARDIAN_REMAINS); + interactObject(ObjectID.HUGE_GUARDIAN_REMAINS); } } else { if (Rs2Inventory.allPouchesFull()) { @@ -493,7 +531,7 @@ private boolean mineHugeGuardianRemain() { Rs2Inventory.fillPouches(); sleep(Rs2Random.randomGaussian(Rs2Random.between(600, 1200), Rs2Random.between(100, 300))); if (!Rs2Inventory.isFull()) { - Microbot.getRs2TileObjectCache().query().interact(ObjectID.HUGE_GUARDIAN_REMAINS); + interactObject(ObjectID.HUGE_GUARDIAN_REMAINS); } } } @@ -518,13 +556,13 @@ private void mineGuardianRemains() { if (!isInLargeMine() && !isInHugeMine() && (!Rs2Inventory.hasItem(GUARDIAN_FRAGMENTS) || getStartTimer() == -1)) { if (Rs2Walker.walkTo(new WorldPoint(3632, 9503, 0), 20)) { log("Traveling to large mine..."); - Microbot.getRs2TileObjectCache().query().interact(ObjectID.RUBBLE_43724); + interactObject(ObjectID.RUBBLE_43724); if (sleepUntil(Rs2Player::isAnimating)) { sleepUntil(GotrScript::isInLargeMine); if (isInLargeMine()) { sleep(Rs2Random.randomGaussian(Rs2Random.between(2000, 2400), Rs2Random.between(100, 300))); log("Interacting with large guardian remains..."); - Microbot.getRs2TileObjectCache().query().interact(ObjectID.LARGE_GUARDIAN_REMAINS); + interactObject(ObjectID.LARGE_GUARDIAN_REMAINS); sleepGaussian(1200, 150); } } @@ -538,7 +576,7 @@ private void mineGuardianRemains() { checkPouches(Rs2Random.between(1, 20) == 2, Rs2Random.between(100, 600), Rs2Random.between(100, 300)); repairPouches(); - Microbot.getRs2TileObjectCache().query().interact(ObjectID.LARGE_GUARDIAN_REMAINS); + interactObject(ObjectID.LARGE_GUARDIAN_REMAINS); sleepGaussian(1200, 150); } } @@ -552,7 +590,7 @@ private void mineGuardianRemains() { Rs2Combat.setSpecState(true, 1000); } repairPouches(); - Microbot.getRs2TileObjectCache().query().interact(ObjectID.GUARDIAN_PARTS_43716); + interactObject(ObjectID.GUARDIAN_PARTS_43716); sleepGaussian(1200, 150); // we can assume that if the player is mining within the startTimer range, he will get enough guardian remains for the game shouldMineGuardianRemains = false; @@ -561,7 +599,7 @@ private void mineGuardianRemains() { } private void leaveHugeMine() { - Microbot.getRs2TileObjectCache().query().interact(38044); + interactObject(38044); log("Leave huge mine..."); Global.sleepUntil(() -> !isInHugeMine(), 5000); @@ -765,6 +803,40 @@ public static void resetPlugin() { Microbot.getClient().clearHintArrow(); } + /** + * Walk-first object interaction. + * + *

The migrated Queryable API ({@code cache.query().interact(id, action)}) resolves + * {@code nearestReachable()} and clicks at the player's current tile — it does NOT walk into + * range. Legacy {@code Rs2GameObject.interact(id, action)} auto-walked when the target was + * more than 51 tiles away. After the query-API migration GOTR lost that auto-walk, so any + * interaction issued while out of range silently no-ops every tick and the bot just stands + * there (see docs/PLUGIN_DEBUGGING_NOTES.md §3). This restores the legacy behaviour: web-walk + * when far, hand off to the game's click-to-walk once close. + */ + private static boolean interactObject(int id) { + return interactObject(id, null); + } + + private static boolean interactObject(int id, String action) { + return interactObject(Microbot.getRs2TileObjectCache().query().withId(id).nearest(), action); + } + + private static boolean interactObject(Rs2TileObjectModel obj, String action) { + if (obj == null) return false; + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + WorldPoint objLoc = obj.getWorldLocation(); + if (playerLoc != null && objLoc != null && playerLoc.distanceTo(objLoc) > 51) { + log("Object " + obj.getId() + " is " + playerLoc.distanceTo(objLoc) + " tiles away, walking into range..."); + Rs2Walker.walkTo(objLoc); + return false; + } + // In click range: drop any lingering web-walk target so the game's click-to-walk drives + // the final approach, then interact. + Rs2Walker.setTarget(null); + return (action == null || action.isEmpty()) ? obj.click() : obj.click(action); + } + public static Rs2TileObjectModel findRcAltar() { return Microbot.getRs2TileObjectCache().query().withIds( ObjectID.ALTAR_34760, ObjectID.ALTAR_34761, ObjectID.ALTAR_34762, ObjectID.ALTAR_34763, ObjectID.ALTAR_34764, @@ -784,7 +856,7 @@ public static boolean leaveMinigame() { return true; // Already outside the minigame, successfully left } if(isInLargeMine()) { - Microbot.getRs2TileObjectCache().query().interact(ObjectID.RUBBLE_43726); + interactObject(ObjectID.RUBBLE_43726); Rs2Player.waitForAnimation(); sleepUntil(()-> !isInLargeMine()); if (isInLargeMine()){ @@ -793,7 +865,7 @@ public static boolean leaveMinigame() { } } - Microbot.getRs2TileObjectCache().query().interact(ObjectID.BARRIER_43700, "quick-pass"); + interactObject(ObjectID.BARRIER_43700, "quick-pass"); Rs2Player.waitForWalking(); sleepUntil( ()-> {return !(!isOutsideBarrier() && isInMainRegion());}, 200); GotrScript.isInMiniGame = !isOutsideBarrier() && isInMainRegion(); diff --git a/src/main/java/net/runelite/client/plugins/microbot/karambwans/GabulhasKarambwansPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/karambwans/GabulhasKarambwansPlugin.java index ae5ec4543a..d50647e105 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/karambwans/GabulhasKarambwansPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/karambwans/GabulhasKarambwansPlugin.java @@ -25,7 +25,7 @@ ) @Slf4j public class GabulhasKarambwansPlugin extends Plugin { - public static final String version = "1.2.0"; + public static final String version = "1.2.1"; @Inject private GabulhasKarambwansConfig config; diff --git a/src/main/java/net/runelite/client/plugins/microbot/karambwans/GabulhasKarambwansScript.java b/src/main/java/net/runelite/client/plugins/microbot/karambwans/GabulhasKarambwansScript.java index ab99202a8b..95d5d395e3 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/karambwans/GabulhasKarambwansScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/karambwans/GabulhasKarambwansScript.java @@ -28,9 +28,11 @@ @Slf4j public class GabulhasKarambwansScript extends Script { - public static final int FAIRY_RING_ID = 29228; + // Zanaris fairy ring object ID — Rs2GameObject.getAll() does NOT find it; use the tile object cache. + public static final int FAIRY_RING_ID = 29560; public static final int SPIRITUAL_FAIRY_TREE_ID = 35003; - private final WorldPoint zanarisRingPoint = new WorldPoint(2412, 4435, 0); + // Fairy ring is at 4434, not 4435 — off-by-one causes findObjectByLocation to miss it. + private final WorldPoint zanarisRingPoint = new WorldPoint(2412, 4434, 0); private final WorldPoint fishingPoint = new WorldPoint(2899, 3118, 0); private final WorldPoint bankPoint = new WorldPoint(2381, 4455, 0); private GabulhasKarambwansConfig config; @@ -86,7 +88,7 @@ public void shutdown() { private void fishingLoop() { while (!Rs2Inventory.isFull() && super.isRunning()) { - if (!Rs2Player.isInteracting() || !Rs2Player.isAnimating()) { + if (!Rs2Player.isAnimating()) { if (Rs2Inventory.contains(ItemID.TBWT_RAW_KARAMBWANJI)) { interactWithFishingSpot(); Rs2Player.waitForAnimation(); @@ -138,6 +140,11 @@ private void useBank() { Rs2Bank.depositAll("Scroll"); Rs2Inventory.waitForInventoryChanges(2000); } + if (Rs2Inventory.contains("scrollbox") || Rs2Inventory.contains("Scrollbox")) { + Rs2Bank.depositAll("scrollbox"); + Rs2Bank.depositAll("Scrollbox"); + Rs2Inventory.waitForInventoryChanges(2000); + } if (Rs2Inventory.contains(ItemID.FISH_BARREL_OPEN) || Rs2Inventory.contains(ItemID.FISH_BARREL_CLOSED)) { Rs2Bank.emptyFishBarrel(); Rs2Inventory.waitForInventoryChanges(2000); @@ -174,17 +181,16 @@ private void walkToFish() { Rs2Walker.walkTo(zanarisRingPoint, 3); Rs2Player.waitForWalking(); - // Ensure the fairy ring at Zanaris is actually loaded before trying to interact. - sleepUntil(() -> Microbot.getRs2TileObjectCache().query().nearest(zanarisRingPoint, 3) != null, 5000); + sleepUntil(() -> Microbot.getRs2TileObjectCache().query().withId(FAIRY_RING_ID).nearestOnClientThread() != null, 5000); - var zanarisRing = Microbot.getRs2TileObjectCache().query().nearest(zanarisRingPoint, 3); - boolean interacted = false; - if (zanarisRing != null) { - // Prefer the explicit last-destination option, fall back to a generic interact if needed. - interacted = zanarisRing.click("Last-destination (DKP)") - || zanarisRing.click("Last-destination") - || zanarisRing.click("Use"); - } + // Action is "Last-destination", NOT "Last-destination (DKP)" — the code suffix is not part of the action text. + boolean interacted = Microbot.getRs2TileObjectCache().query() + .withId(FAIRY_RING_ID) + .nearestOnClientThread() != null + && Microbot.getRs2TileObjectCache().query() + .withId(FAIRY_RING_ID) + .nearestOnClientThread() + .click("Last-destination"); if (interacted) { waitTillPlayerNextToFishingSpot(); diff --git a/src/main/java/net/runelite/client/plugins/microbot/looter/AutoLooterConfig.java b/src/main/java/net/runelite/client/plugins/microbot/looter/AutoLooterConfig.java index dcef70b44e..b95c0dc78c 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/looter/AutoLooterConfig.java +++ b/src/main/java/net/runelite/client/plugins/microbot/looter/AutoLooterConfig.java @@ -83,6 +83,17 @@ default boolean useNextWorld() { return false; } + @ConfigItem( + keyName = "priorityMode", + name = "Priority Mode", + description = "Pauses other plugins during loot pickup. Use when running alongside another plugin (e.g. alching).", + position = 4, + section = generalSection + ) + default boolean priorityMode() { + return false; + } + @ConfigItem( name = "Loot Style", keyName = "lootStyle", diff --git a/src/main/java/net/runelite/client/plugins/microbot/looter/AutoLooterPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/looter/AutoLooterPlugin.java index 29dc4412da..58b4089697 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/looter/AutoLooterPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/looter/AutoLooterPlugin.java @@ -25,7 +25,7 @@ isExternal = PluginConstants.IS_EXTERNAL ) public class AutoLooterPlugin extends Plugin { - public static final String version = "1.1.3"; + public static final String version = "1.2.0"; @Inject DefaultScript defaultScript; @Inject @@ -52,7 +52,9 @@ protected void startUp() throws AWTException { switch (config.looterActivity()) { case DEFAULT: defaultScript.run(config); - defaultScript.handleWalk(config); + if (!config.priorityMode()) { + defaultScript.handleWalk(config); + } break; case FLAX: flaxScript.run(config); diff --git a/src/main/java/net/runelite/client/plugins/microbot/looter/scripts/DefaultScript.java b/src/main/java/net/runelite/client/plugins/microbot/looter/scripts/DefaultScript.java index 79bd84eb48..48a217e8df 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/looter/scripts/DefaultScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/looter/scripts/DefaultScript.java @@ -42,6 +42,11 @@ public boolean run(AutoLooterConfig config) { if (!Microbot.isLoggedIn() || Rs2Combat.inCombat()) return; if (Rs2AntibanSettings.actionCooldownActive) return; + if (config.priorityMode()) { + handlePriorityLoot(config); + return; + } + long startTime = System.currentTimeMillis(); if (initialPlayerLocation == null) { @@ -55,48 +60,14 @@ public boolean run(AutoLooterConfig config) { if (isAwayFromBase(config)) return; if (config.worldHop()) { - if (config.looterStyle() == DefaultLooterStyle.ITEM_LIST) { - lootExists = Arrays.stream(config.listOfItemsToLoot().trim().split(",")) - .anyMatch(itemName -> Rs2GroundItem.exists(itemName, config.distanceToStray())); - } else if (config.looterStyle() == DefaultLooterStyle.GE_PRICE_RANGE) { - lootExists = Rs2GroundItem.isItemBasedOnValueOnGround(config.minPriceOfItem(), config.distanceToStray()); - } else if (config.looterStyle() == DefaultLooterStyle.MIXED) { - lootExists = Arrays.stream(config.listOfItemsToLoot().trim().split(",")) - .anyMatch(itemName -> Rs2GroundItem.exists(itemName, config.distanceToStray())) - || Rs2GroundItem.isItemBasedOnValueOnGround(config.minPriceOfItem(), config.distanceToStray()); - } + lootExists = hasMatchingLoot(config); } else { lootExists = true; } if (lootExists) { failedLootAttempts = 0; - - if (config.looterStyle() == DefaultLooterStyle.ITEM_LIST || config.looterStyle() == DefaultLooterStyle.MIXED) { - LootingParameters itemLootParams = new LootingParameters( - config.distanceToStray(), - 1, - 1, - config.minFreeSlots(), - config.toggleDelayedLooting(), - config.toggleLootMyItemsOnly(), - config.listOfItemsToLoot().split(",") - ); - Rs2GroundItem.lootItemsBasedOnNames(itemLootParams); - } - - if (config.looterStyle() == DefaultLooterStyle.GE_PRICE_RANGE || config.looterStyle() == DefaultLooterStyle.MIXED) { - LootingParameters valueParams = new LootingParameters( - config.minPriceOfItem(), - config.maxPriceOfItem(), - config.distanceToStray(), - 1, - config.minFreeSlots(), - config.toggleDelayedLooting(), - config.toggleLootMyItemsOnly() - ); - Rs2GroundItem.lootItemBasedOnValue(valueParams); - } + lootItems(config); Microbot.pauseAllScripts.set(false); Rs2Antiban.actionCooldown(); @@ -156,6 +127,7 @@ public boolean run(AutoLooterConfig config) { @Override public void shutdown() { + Microbot.pauseAllScripts.set(false); super.shutdown(); Rs2Antiban.resetAntibanSettings(); } @@ -193,6 +165,70 @@ public boolean handleWalk(AutoLooterConfig config) { return true; } + private void handlePriorityLoot(AutoLooterConfig config) { + if (!hasMatchingLoot(config)) return; + if (Rs2Inventory.emptySlotCount() <= config.minFreeSlots()) return; + + Microbot.pauseAllScripts.set(true); + try { + while (hasMatchingLoot(config) + && Rs2Inventory.emptySlotCount() > config.minFreeSlots() + && this.isRunning()) { + lootItems(config); + } + } finally { + Microbot.pauseAllScripts.set(false); + } + + Rs2Antiban.actionCooldown(); + Rs2Antiban.takeMicroBreakByChance(); + } + + private boolean hasMatchingLoot(AutoLooterConfig config) { + int distance = config.distanceToStray(); + switch (config.looterStyle()) { + case ITEM_LIST: + return Arrays.stream(config.listOfItemsToLoot().trim().split(",")) + .anyMatch(name -> Rs2GroundItem.exists(name.trim(), distance)); + case GE_PRICE_RANGE: + return Rs2GroundItem.isItemBasedOnValueOnGround(config.minPriceOfItem(), distance); + case MIXED: + return Arrays.stream(config.listOfItemsToLoot().trim().split(",")) + .anyMatch(name -> Rs2GroundItem.exists(name.trim(), distance)) + || Rs2GroundItem.isItemBasedOnValueOnGround(config.minPriceOfItem(), distance); + default: + return false; + } + } + + private void lootItems(AutoLooterConfig config) { + if (config.looterStyle() == DefaultLooterStyle.ITEM_LIST || config.looterStyle() == DefaultLooterStyle.MIXED) { + LootingParameters itemLootParams = new LootingParameters( + config.distanceToStray(), + 1, + 1, + config.minFreeSlots(), + config.toggleDelayedLooting(), + config.toggleLootMyItemsOnly(), + config.listOfItemsToLoot().split(",") + ); + Rs2GroundItem.lootItemsBasedOnNames(itemLootParams); + } + + if (config.looterStyle() == DefaultLooterStyle.GE_PRICE_RANGE || config.looterStyle() == DefaultLooterStyle.MIXED) { + LootingParameters valueParams = new LootingParameters( + config.minPriceOfItem(), + config.maxPriceOfItem(), + config.distanceToStray(), + 1, + config.minFreeSlots(), + config.toggleDelayedLooting(), + config.toggleLootMyItemsOnly() + ); + Rs2GroundItem.lootItemBasedOnValue(valueParams); + } + } + private boolean isAwayFromBase(AutoLooterConfig config) { return initialPlayerLocation == null || Rs2Player.isMoving() diff --git a/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/DashboardPanel.java b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/DashboardPanel.java new file mode 100644 index 0000000000..beda5b2cd8 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/DashboardPanel.java @@ -0,0 +1,157 @@ +package net.runelite.client.plugins.microbot.microbotdashboardplus; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.client.plugins.microbot.microbotdashboardplus.data.PollSnapshot; +import net.runelite.client.plugins.microbot.microbotdashboardplus.poller.GameStatePoller; +import net.runelite.client.plugins.microbot.microbotdashboardplus.window.DashboardWindow; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; +import net.runelite.client.ui.PluginPanel; + +import javax.swing.JButton; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingUtilities; +import javax.swing.border.EmptyBorder; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.GridLayout; +import java.util.function.Consumer; + +/** + * Right-sidebar plugin panel: compact at-a-glance summary + launcher button + * for the full {@link DashboardWindow}. Mirrors the Hub convention used by + * ShootingStar / AutoBankStander / ActionReplay. + * + *

Subscribes to the {@link GameStatePoller}; updates labels on each + * snapshot. The "Open Dashboard" button calls into the window owner (the + * plugin) to show or focus the floating window. + */ +@Slf4j +public class DashboardPanel extends PluginPanel { + + private final GameStatePoller poller; + private final Runnable openDashboard; + private final Consumer snapshotListener; + + private final JLabel statusValue = new JLabel("--"); + private final JLabel playerValue = new JLabel("--"); + private final JLabel worldValue = new JLabel("--"); + private final JLabel scriptsValue = new JLabel("--"); + + public DashboardPanel(GameStatePoller poller, Runnable openDashboard) { + super(); + this.poller = poller; + this.openDashboard = openDashboard; + + setBorder(new EmptyBorder(10, 10, 10, 10)); + setLayout(new BorderLayout()); + + add(buildTopArea(), BorderLayout.NORTH); + add(buildStatsArea(), BorderLayout.CENTER); + add(buildButtonArea(), BorderLayout.SOUTH); + + snapshotListener = this::applySnapshot; + poller.addListener(snapshotListener); + } + + private JPanel buildTopArea() { + JPanel top = new JPanel(new BorderLayout()); + top.setOpaque(false); + top.setBorder(new EmptyBorder(0, 0, 10, 0)); + + JLabel title = new JLabel("Dashboard"); + title.setForeground(Color.WHITE); + title.setFont(FontManager.getRunescapeBoldFont()); + top.add(title, BorderLayout.NORTH); + + JLabel sub = new JLabel("MicrobotDashboardPlus v" + MicrobotDashboardPlusPlugin.version); + sub.setForeground(Color.GRAY); + sub.setFont(FontManager.getRunescapeSmallFont()); + top.add(sub, BorderLayout.SOUTH); + + return top; + } + + private JPanel buildStatsArea() { + JPanel stats = new JPanel(new GridLayout(0, 1, 0, 4)); + stats.setOpaque(false); + + stats.add(makeRow("Status", statusValue)); + stats.add(makeRow("Player", playerValue)); + stats.add(makeRow("World", worldValue)); + stats.add(makeRow("Active", scriptsValue)); + + return stats; + } + + private JPanel makeRow(String label, JLabel valueLabel) { + JPanel row = new JPanel(new BorderLayout(8, 0)); + row.setOpaque(false); + + JLabel lbl = new JLabel(label); + lbl.setForeground(Color.LIGHT_GRAY); + lbl.setFont(FontManager.getRunescapeSmallFont()); + lbl.setPreferredSize(new Dimension(60, 16)); + row.add(lbl, BorderLayout.WEST); + + valueLabel.setForeground(Color.WHITE); + valueLabel.setFont(FontManager.getRunescapeSmallFont()); + row.add(valueLabel, BorderLayout.CENTER); + + return row; + } + + private JPanel buildButtonArea() { + JPanel buttons = new JPanel(new GridLayout(0, 1, 0, 6)); + buttons.setOpaque(false); + buttons.setBorder(new EmptyBorder(12, 0, 0, 0)); + + JButton openBtn = new JButton("Open Dashboard"); + openBtn.setBackground(ColorScheme.BRAND_ORANGE); + openBtn.setForeground(Color.WHITE); + openBtn.setFocusPainted(false); + openBtn.setFont(FontManager.getRunescapeBoldFont()); + openBtn.addActionListener(e -> { + if (openDashboard != null) openDashboard.run(); + }); + buttons.add(openBtn); + + JButton refreshBtn = new JButton("Refresh now"); + refreshBtn.setBackground(ColorScheme.MEDIUM_GRAY_COLOR); + refreshBtn.setForeground(Color.WHITE); + refreshBtn.setFocusPainted(false); + refreshBtn.setFont(FontManager.getRunescapeSmallFont()); + refreshBtn.addActionListener(e -> poller.refreshNow()); + buttons.add(refreshBtn); + + return buttons; + } + + /** Plugin shutDown calls this. Removes listener so the poller stops feeding us after disposal. */ + public void detach() { + poller.removeListener(snapshotListener); + } + + private void applySnapshot(PollSnapshot snapshot) { + if (snapshot == null) return; + if (snapshot.isLoggedIn()) { + statusValue.setText("Connected"); + statusValue.setForeground(ColorScheme.PROGRESS_COMPLETE_COLOR); + } else { + statusValue.setText("Disconnected"); + statusValue.setForeground(ColorScheme.PROGRESS_ERROR_COLOR); + } + playerValue.setText(snapshot.getPlayerName()); + // World id is 0 before login; show a placeholder instead of a fake-looking "0". + worldValue.setText(snapshot.getWorldId() > 0 ? String.valueOf(snapshot.getWorldId()) : "--"); + scriptsValue.setText(snapshot.getActiveScripts() == null ? "0" + : Integer.toString(snapshot.getActiveScripts().size())); + + SwingUtilities.invokeLater(() -> { + revalidate(); + repaint(); + }); + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/MicrobotDashboardPlusConfig.java b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/MicrobotDashboardPlusConfig.java new file mode 100644 index 0000000000..845a906b16 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/MicrobotDashboardPlusConfig.java @@ -0,0 +1,225 @@ +package net.runelite.client.plugins.microbot.microbotdashboardplus; + +import net.runelite.client.config.Config; +import net.runelite.client.config.ConfigGroup; +import net.runelite.client.config.ConfigInformation; +import net.runelite.client.config.ConfigItem; +import net.runelite.client.config.ConfigSection; +import net.runelite.client.config.Range; + +/** + * Configuration for the MicrobotDashboardPlus plugin. + * + *

Sections: + *

    + *
  • Behavior - core polling + window open semantics.
  • + *
  • Layout - per-section visibility toggles.
  • + *
  • Notifications - Discord webhook + which events fire.
  • + *
  • Alerts - per-skill level-threshold alerts.
  • + *
+ */ +@ConfigGroup("MicrobotDashboardPlus") +@ConfigInformation( + "

Microbot Dashboard Plus

" + + "

Version: " + MicrobotDashboardPlusPlugin.version + "

" + + "

Aggregate session dashboard. A floating window with nine live-updating panels: Player, Active Scripts, Inventory, Skills, Nearby NPCs, Antiban State, XP Chart, Event Log, and Guide. A green chart-line icon in the right sidebar (while the plugin is enabled) opens the dashboard.

" + + "

" + + "

Behavior

" + + "

1. Auto-open dashboard: opens the floating window automatically when the plugin enables. Untick to launch it manually from the sidebar.

" + + "

" + + "

2. Poll interval: seconds between dashboard refreshes from game state. Lower is more responsive but uses slightly more CPU. Default 5, range 1 to 60.

" + + "

" + + "

3. Nearby NPCs max distance: tile radius for the Nearby NPCs panel. Higher shows more NPCs and polls a little slower. Default 20, range 1 to 200.

" + + "

" + + "

Layout

" + + "

4. Panel toggles: one show or hide switch per panel (Player, Active Scripts, Inventory, Skills, Nearby NPCs, Antiban State, XP Chart, Event Log, Guide). Untick any you do not want in the window. Hide the Guide once you know the panels.

" + + "

" + + "

Notifications

" + + "

5. Discord webhook URL: paste a channel webhook to send alerts to Discord. Leave blank to disable Discord. Keep this URL secret.

" + + "

" + + "

6. Notify on level-up: posts to Discord when any skill levels up.

" + + "

" + + "

7. Notify on pet drop: banner plus Discord post when you receive a pet (detected from the funny-feeling game messages).

" + + "

" + + "

8. Notify on session start or stop: posts when the plugin enables or disables. Off by default.

" + + "

" + + "

9. Notify on alert threshold: posts when a configured Alert Threshold is crossed.

" + + "

" + + "

Alerts

" + + "

10. Alert thresholds: comma-separated SKILL:LEVEL pairs, for example MINING:60, WOODCUTTING:80. Use uppercase OSRS skill names. A crossing fires an in-dashboard banner and, if enabled above, a Discord notification.

" + + "

" + + "

11. Skill targets (ETA): comma-separated SKILL:LEVEL pairs, for example MINING:70, AGILITY:60. The Skills section shows an ETA to each target from the current XP per hour. A skill with no target still shows an ETA to its next level while it is being trained.

" + + "

" + + "

Panels of note

" + + "

Antiban State: shows whether the script is running or is being held by an intentional anti-AFK pause such as a micro break, an action cooldown, a global pause, or a blocking event. Use it to tell a real stall from expected behavior.

" +) +public interface MicrobotDashboardPlusConfig extends Config { + + @ConfigSection(name = "Behavior", description = "Window + polling settings", position = 0) + String behaviorSection = "behavior"; + + @ConfigSection(name = "Layout", description = "Which sections to show", position = 1) + String layoutSection = "layout"; + + @ConfigSection(name = "Notifications", description = "Discord webhook + which events fire", position = 2) + String notificationsSection = "notifications"; + + @ConfigSection(name = "Alerts", description = "Per-skill level-threshold alerts", position = 3) + String alertsSection = "alerts"; + + // ------------------------------------------------------------------ + // Behavior + // ------------------------------------------------------------------ + + @ConfigItem( + keyName = "autoOpenDashboard", + name = "Auto-open dashboard on startup", + description = "When the plugin enables, open the floating dashboard window automatically. Untick this if you'd rather launch it manually from the sidebar panel.", + position = 0, + section = behaviorSection + ) + default boolean autoOpenDashboard() { + return true; + } + + @ConfigItem( + keyName = "pollIntervalSeconds", + name = "Poll interval (sec)", + description = "How often to refresh the dashboard from in-process game state. Lower = more responsive, slightly higher CPU. Default 5.", + position = 1, + section = behaviorSection + ) + @Range(min = 1, max = 60) + default int pollIntervalSeconds() { + return 5; + } + + @ConfigItem( + keyName = "npcMaxDistance", + name = "Nearby NPCs max distance (tiles)", + description = "Maximum tile distance for NPCs to show in the Nearby NPCs section. Higher = more NPCs visible, slightly slower poll.", + position = 2, + section = behaviorSection + ) + @Range(min = 1, max = 200) + default int npcMaxDistance() { + return 20; + } + + // ------------------------------------------------------------------ + // Layout (per-section visibility) + // ------------------------------------------------------------------ + + @ConfigItem(keyName = "showPlayer", name = "Show Player", description = "Show the Player section.", position = 0, section = layoutSection) + default boolean showPlayer() { return true; } + + @ConfigItem(keyName = "showActiveScripts", name = "Show Active Scripts", description = "Show the Active Scripts section.", position = 1, section = layoutSection) + default boolean showActiveScripts() { return true; } + + @ConfigItem(keyName = "showInventory", name = "Show Inventory", description = "Show the Inventory section.", position = 2, section = layoutSection) + default boolean showInventory() { return true; } + + @ConfigItem(keyName = "showSkills", name = "Show Skills", description = "Show the Skills section.", position = 3, section = layoutSection) + default boolean showSkills() { return true; } + + @ConfigItem(keyName = "showNearbyNpcs", name = "Show Nearby NPCs", description = "Show the Nearby NPCs section.", position = 4, section = layoutSection) + default boolean showNearbyNpcs() { return true; } + + @ConfigItem(keyName = "showAntibanState", name = "Show Antiban State", description = "Show the Antiban State section. It tells a silent stall apart from an intentional anti-AFK pause such as a micro break or action cooldown.", position = 5, section = layoutSection) + default boolean showAntibanState() { return true; } + + @ConfigItem(keyName = "showXpChart", name = "Show XP Chart", description = "Show the XP-over-time chart section.", position = 6, section = layoutSection) + default boolean showXpChart() { return true; } + + @ConfigItem(keyName = "showEventLog", name = "Show Event Log", description = "Show the Event Log ring buffer section.", position = 7, section = layoutSection) + default boolean showEventLog() { return true; } + + @ConfigItem(keyName = "showGuide", name = "Show Guide", description = "Show the Guide section at the bottom of the dashboard window. Hide it once you're familiar with the panels and config options.", position = 8, section = layoutSection) + default boolean showGuide() { return true; } + + // ------------------------------------------------------------------ + // Notifications (Discord webhook) + // ------------------------------------------------------------------ + + @ConfigItem( + keyName = "discordWebhookUrl", + name = "Discord webhook URL", + description = "Paste a Discord channel webhook URL (https://discord.com/api/webhooks/...). Leave blank to disable Discord notifications. Treat this URL as a secret. Do not share it.", + position = 0, + section = notificationsSection, + secret = true + ) + default String discordWebhookUrl() { + return ""; + } + + @ConfigItem( + keyName = "notifyLevelUp", + name = "Notify on level-up", + description = "Send a Discord message when any skill level increases.", + position = 1, + section = notificationsSection + ) + default boolean notifyLevelUp() { + return true; + } + + @ConfigItem( + keyName = "notifyPetDrop", + name = "Notify on pet drop", + description = "Fire the in-dashboard banner and (if a webhook is set) a Discord message when you receive a pet, detected from the funny-feeling game messages.", + position = 2, + section = notificationsSection + ) + default boolean notifyPetDrop() { + return true; + } + + @ConfigItem( + keyName = "notifySessionLifecycle", + name = "Notify on session start/stop", + description = "Send a Discord message when the dashboard plugin enables (session start) or disables (session stop).", + position = 3, + section = notificationsSection + ) + default boolean notifySessionLifecycle() { + return false; + } + + @ConfigItem( + keyName = "notifyAlerts", + name = "Notify on alert threshold", + description = "Send a Discord message when any configured Alert Threshold is crossed (see Alerts section).", + position = 4, + section = notificationsSection + ) + default boolean notifyAlerts() { + return true; + } + + // ------------------------------------------------------------------ + // Alerts + // ------------------------------------------------------------------ + + @ConfigItem( + keyName = "alertThresholds", + name = "Alert thresholds", + description = "Comma-separated SKILL:LEVEL pairs. Example: MINING:60, WOODCUTTING:80, FISHING:70. Skill names follow the OSRS API enum (uppercase). Crossings fire an in-dashboard banner and (if enabled) a Discord notification.", + position = 0, + section = alertsSection + ) + default String alertThresholds() { + return ""; + } + + @ConfigItem( + keyName = "skillTargets", + name = "Skill targets (ETA)", + description = "Comma-separated SKILL:LEVEL pairs the Skills section uses for its ETA column. Example: MINING:70, AGILITY:60. Skill names follow the OSRS API enum (uppercase). A skill with no target still shows an ETA to its next level while it is being trained.", + position = 1, + section = alertsSection + ) + default String skillTargets() { + return ""; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/MicrobotDashboardPlusPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/MicrobotDashboardPlusPlugin.java new file mode 100644 index 0000000000..6476628af4 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/MicrobotDashboardPlusPlugin.java @@ -0,0 +1,260 @@ +package net.runelite.client.plugins.microbot.microbotdashboardplus; + +import com.google.inject.Provides; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.ChatMessageType; +import net.runelite.api.events.ChatMessage; +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.Microbot; +import net.runelite.client.plugins.microbot.PluginConstants; +import net.runelite.client.plugins.microbot.microbotdashboardplus.notify.AlertManager; +import net.runelite.client.plugins.microbot.microbotdashboardplus.notify.DiscordNotifier; +import net.runelite.client.plugins.microbot.microbotdashboardplus.poller.GameStatePoller; +import net.runelite.client.plugins.microbot.microbotdashboardplus.window.DashboardWindow; +import net.runelite.client.ui.ClientToolbar; +import net.runelite.client.ui.NavigationButton; + +import javax.inject.Inject; +import javax.swing.SwingUtilities; +import java.awt.AWTException; +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; + +/** + * MicrobotDashboardPlus is part of the Microbot Plus suite. + * + *

The dashboard opens in a native floating RuneLite window. A compact + * sidebar panel lives in the right-hand toolbar; clicking + * Open Dashboard launches the full {@link DashboardWindow}. + * Game state is read in-process via {@link Microbot#getClient()} and the Rs2 + * utility APIs, so there is no embedded HTTP server and no Agent Server + * dependency. + * + *

Known limitations

+ *
    + *
  • Active scripts list is heuristic (enumerates enabled plugins).
  • + *
  • The blocking-event "running" flag has no public getter on the client, + * so the Antiban State panel reads it by reflection and omits it when + * that is not available on the running client version.
  • + *
+ */ +@PluginDescriptor( + name = "[P] " + "Microbot Dashboard Plus", + description = "Native Swing monitoring dashboard for your Microbot session. Floating window plus a compact sidebar panel. No HTTP, no Agent Server dependency.", + tags = {"dashboard", "monitoring", "microbot", "plus"}, + authors = {"pjmarz"}, + version = MicrobotDashboardPlusPlugin.version, + minClientVersion = "2.0.13", + cardUrl = "https://chsami.github.io/Microbot-Hub/MicrobotDashboardPlusPlugin/assets/card.png", + iconUrl = "https://chsami.github.io/Microbot-Hub/MicrobotDashboardPlusPlugin/assets/icon.png", + enabledByDefault = PluginConstants.DEFAULT_ENABLED, + isExternal = PluginConstants.IS_EXTERNAL +) +@Slf4j +public class MicrobotDashboardPlusPlugin extends Plugin { + + public static final String version = "1.2.1"; + + @Inject + private MicrobotDashboardPlusConfig config; + + @Inject + private ClientToolbar clientToolbar; + + private GameStatePoller poller; + private DashboardPanel sidebarPanel; + private NavigationButton navButton; + private DashboardWindow window; + private DiscordNotifier notifier; + private AlertManager alertManager; + + @Provides + MicrobotDashboardPlusConfig provideConfig(ConfigManager configManager) { + return configManager.getConfig(MicrobotDashboardPlusConfig.class); + } + + @Override + protected void startUp() throws AWTException { + // 0. Notification stack. + notifier = new DiscordNotifier(); + notifier.setWebhookUrl(config.discordWebhookUrl()); + notifier.start(); + alertManager = new AlertManager(); + alertManager.setThresholdsFromConfig(config.alertThresholds()); + + // 1. Build the poller. Single-thread executor; starts immediately. + poller = new GameStatePoller(); + poller.setNpcMaxDistance(config.npcMaxDistance()); + poller.setNotifier(notifier); + poller.setAlertManager(alertManager); + poller.setNotificationToggles( + config.notifyLevelUp(), + config.notifyAlerts()); + poller.start(config.pollIntervalSeconds()); + + if (config.notifySessionLifecycle()) { + notifier.send("Dashboard session started."); + } + + // 2. Build the floating window (hidden until shown). + window = new DashboardWindow(poller, config); + + // 3. Build the sidebar panel + register it. + sidebarPanel = new DashboardPanel(poller, this::showWindow); + navButton = NavigationButton.builder() + .tooltip("Microbot Dashboard Plus") + .icon(buildPlaceholderIcon()) + .priority(7) + .panel(sidebarPanel) + .build(); + clientToolbar.addNavigation(navButton); + + // 4. Optional auto-open on enable. + if (config.autoOpenDashboard()) { + showWindow(); + } + + Microbot.log("MicrobotDashboardPlus v" + version + " started (native Swing mode)"); + } + + @Override + protected void shutDown() { + if (notifier != null && config.notifySessionLifecycle()) { + notifier.send("Dashboard session stopped."); + } + if (window != null) { + window.disposeWindow(); + window = null; + } + if (sidebarPanel != null) { + sidebarPanel.detach(); + sidebarPanel = null; + } + if (navButton != null) { + clientToolbar.removeNavigation(navButton); + navButton = null; + } + if (poller != null) { + poller.stop(); + poller = null; + } + if (notifier != null) { + notifier.shutdown(); + notifier = null; + } + alertManager = null; + Microbot.log("MicrobotDashboardPlus v" + version + " stopped"); + } + + /** + * Pet-drop detection. The game announces a pet via one of three "funny feeling" + * game messages; matching on those needs no inventory or NPC scanning. Fires the + * in-window banner always (when enabled) and Discord when a webhook is set. + */ + @Subscribe + public void onChatMessage(ChatMessage event) { + if (event.getType() != ChatMessageType.GAMEMESSAGE) return; + if (config == null || !config.notifyPetDrop()) return; + String msg = event.getMessage() == null ? "" : event.getMessage().toLowerCase(); + + final boolean backpack = msg.contains("something weird sneaking into your backpack"); + final boolean duplicate = msg.contains("funny feeling like you would have been followed"); + final boolean follower = msg.contains("funny feeling like you're being followed"); + if (!backpack && !duplicate && !follower) return; + + final String text = backpack ? "Pet drop! It went to your inventory." + : duplicate ? "Pet drop! (you already own it, check your collection log)" + : "Pet drop! It is now following you."; + if (window != null) { + window.showAlertBanner(text); + } + if (notifier != null) { + notifier.send("PET: " + text); + } + Microbot.log("MicrobotDashboardPlus: " + text); + } + + @Subscribe + public void onConfigChanged(ConfigChanged event) { + if (!"MicrobotDashboardPlus".equals(event.getGroup())) return; + String key = event.getKey(); + + if (poller != null) { + switch (key) { + case "pollIntervalSeconds": + poller.stop(); + poller.start(config.pollIntervalSeconds()); + break; + case "npcMaxDistance": + poller.setNpcMaxDistance(config.npcMaxDistance()); + poller.refreshNow(); + break; + case "alertThresholds": + poller.setAlertThresholds(config.alertThresholds()); + break; + case "discordWebhookUrl": + if (notifier != null) notifier.setWebhookUrl(config.discordWebhookUrl()); + break; + case "notifyLevelUp": + case "notifyAlerts": + poller.setNotificationToggles( + config.notifyLevelUp(), + config.notifyAlerts()); + break; + default: + break; + } + } + + // Section visibility: any show* key triggers a re-evaluation. + if (window != null && key != null && key.startsWith("show")) { + window.applyVisibility(); + } + } + + private void showWindow() { + if (window == null) return; + SwingUtilities.invokeLater(window::showOrFocus); + } + + /** + * Programmatic 16x16 dashboard icon: a dark background with a small + * "rising chart line" in RuneLite green to evoke the dashboard. + */ + private static BufferedImage buildPlaceholderIcon() { + BufferedImage img = new BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = img.createGraphics(); + try { + g.setRenderingHint(java.awt.RenderingHints.KEY_ANTIALIASING, + java.awt.RenderingHints.VALUE_ANTIALIAS_ON); + + // Dark rounded background. + g.setColor(new Color(0x2B, 0x2B, 0x2B)); + g.fillRoundRect(0, 0, 16, 16, 4, 4); + + // Subtle border. + g.setColor(new Color(0x44, 0x44, 0x44)); + g.drawRoundRect(0, 0, 15, 15, 4, 4); + + // Rising chart line in RuneLite green. + g.setColor(new Color(0x00, 0xAA, 0x00)); + g.setStroke(new java.awt.BasicStroke(1.5f)); + // Polyline: low-left up to high-right. + int[] xs = {3, 6, 8, 11, 13}; + int[] ys = {12, 9, 10, 5, 4}; + g.drawPolyline(xs, ys, xs.length); + + // Small dot at the right endpoint for emphasis. + g.fillOval(12, 3, 3, 3); + } finally { + g.dispose(); + } + return img; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/data/PollSnapshot.java b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/data/PollSnapshot.java new file mode 100644 index 0000000000..9c3bd37f3c --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/data/PollSnapshot.java @@ -0,0 +1,141 @@ +package net.runelite.client.plugins.microbot.microbotdashboardplus.data; + +import lombok.Builder; +import lombok.Value; +import net.runelite.api.Skill; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Immutable snapshot of all data the dashboard renders in one poll cycle. + * + *

Produced by {@link net.runelite.client.plugins.microbot.microbotdashboardplus.poller.GameStatePoller} + * on each tick or scheduled refresh. Panels read fields directly; no shared + * mutable state between poller and UI. + * + *

Use {@link #empty()} for the "no data yet" initial state. + */ +@Value +@Builder +public class PollSnapshot { + + /** Wall-clock millis when this snapshot was produced. */ + long timestampMillis; + + /** Whether the client was logged in when this snapshot was produced. */ + boolean loggedIn; + + // ------- Player section ------- + String playerName; + int combatLevel; + String gameState; + int worldId; + String profileName; + String positionText; + String animationText; + + // ------- Skills section ------- + /** Per-skill total XP. */ + Map skillXp; + + /** Per-skill level (real, not virtual). */ + Map skillLevels; + + // ------- Active scripts section ------- + List activeScripts; + + // ------- Inventory ------- + /** Inventory items (slot 0..27) with display names + quantities. */ + List inventory; + + // ------- Nearby NPCs ------- + List nearbyNpcs; + + // ------- Antiban + pause state (read in-process) ------- + AntibanState antibanState; + + public static PollSnapshot empty() { + return PollSnapshot.builder() + .timestampMillis(System.currentTimeMillis()) + .loggedIn(false) + .playerName("--") + .gameState("--") + .profileName("--") + .positionText("--") + .animationText("--") + .skillXp(Collections.emptyMap()) + .skillLevels(Collections.emptyMap()) + .activeScripts(Collections.emptyList()) + .inventory(Collections.emptyList()) + .nearbyNpcs(Collections.emptyList()) + .antibanState(AntibanState.builder() + .antibanEnabled(false) + .summary("--") + .build()) + .build(); + } + + @Value + @Builder + public static class ScriptStatus { + String pluginClassName; + String displayName; + String status; // "Running", "Paused", etc. + long runtimeMillis; + } + + @Value + @Builder + public static class InventoryItem { + int slot; + int itemId; + String name; + int quantity; + boolean noted; + } + + @Value + @Builder + public static class NearbyNpc { + String name; + int combatLevel; + int distance; + boolean randomEvent; + } + + /** + * In-process antiban and pause state. Lets a user tell a silent stall + * (everything idle, nothing intentional) from a deliberate anti-AFK pause + * (a micro break or action cooldown is running). + */ + @Value + @Builder + public static class AntibanState { + /** Whether antiban is globally enabled. */ + boolean antibanEnabled; + /** True while an action cooldown is holding the script. */ + boolean actionCooldownActive; + /** True while a micro break is holding the script. */ + boolean microBreakActive; + /** Whether micro breaks are configured to fire at all. */ + boolean takeMicroBreaks; + /** True when every script is globally paused (Microbot.pauseAllScripts). */ + boolean allScriptsPaused; + /** Number of registered blocking-event handlers (login, level-up, death, and so on). */ + int blockingEventCount; + /** + * True if a blocking event is actively running right now. Read by + * reflection; null when the running flag could not be read on this + * client version. + */ + Boolean blockingEventRunning; + /** + * One-line plain reason for the current hold, or "Running" when nothing + * is holding the script. Examples: "Micro break in progress", + * "Action cooldown", "All scripts paused". + */ + String summary; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/data/XpHistory.java b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/data/XpHistory.java new file mode 100644 index 0000000000..28e5a9defd --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/data/XpHistory.java @@ -0,0 +1,149 @@ +package net.runelite.client.plugins.microbot.microbotdashboardplus.data; + +import net.runelite.api.Skill; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.EnumMap; +import java.util.Map; + +/** + * Tracks per-skill XP history to compute deltas and rolling XP/hr rates. + * + *

For each skill, maintains: + *

    + *
  • The XP value observed at page-load time (baseline for "Δ since session start")
  • + *
  • A bounded deque of (timestamp, xp) samples within the rolling window + * (default 5 minutes) used to compute XP/hr
  • + *
+ * + *

Thread-safety: record() runs on the client thread while the read methods + * (getSamples, xpPerHour, deltaSinceBaseline, baselineFor) run on the EDT + * during paint. All public methods are synchronized so the per-skill deques + * and baseline map are never mutated and iterated concurrently. + */ +public class XpHistory { + + /** Retention window for the sample deque. Long enough to feed the 24h chart. */ + public static final long RETENTION_WINDOW_MS = 24 * 60 * 60 * 1000L; // 24 h + /** Inner window used for XP/hr rate calculation. */ + public static final long RATE_WINDOW_MS = 5 * 60 * 1000L; // 5 min + /** + * Hard per-skill sample cap so memory stays bounded regardless of poll rate + * (24h at a 1s poll would otherwise be ~86k samples per skill). 17,280 covers + * the full 24h window at the default 5s poll; at faster polls the oldest + * samples roll off sooner, which only coarsens the far end of the chart. + */ + public static final int MAX_SAMPLES_PER_SKILL = 17_280; + + private final Map baselineXp = new EnumMap<>(Skill.class); + private final Map> samplesBySkill = new EnumMap<>(Skill.class); + + /** + * Record an XP observation. First call per skill establishes the baseline. + */ + public synchronized void record(Skill skill, int currentXp) { + baselineXp.putIfAbsent(skill, currentXp); + + long now = System.currentTimeMillis(); + Deque samples = samplesBySkill.computeIfAbsent(skill, k -> new ArrayDeque<>()); + samples.addLast(new Sample(now, currentXp)); + + // Trim samples beyond the long retention window (keeps chart data + // available; XP/hr filters internally for the rate window). + long cutoff = now - RETENTION_WINDOW_MS; + while (!samples.isEmpty() && samples.peekFirst().timestampMillis < cutoff) { + samples.pollFirst(); + } + // Bound memory regardless of poll rate. + while (samples.size() > MAX_SAMPLES_PER_SKILL) { + samples.pollFirst(); + } + } + + /** XP gained since the first observation for this skill. */ + public synchronized int deltaSinceBaseline(Skill skill, int currentXp) { + Integer baseline = baselineXp.get(skill); + return baseline == null ? 0 : Math.max(0, currentXp - baseline); + } + + /** + * Extrapolated XP/hr based on the {@link #RATE_WINDOW_MS} rolling window. + * Returns 0 if fewer than 2 samples in that window or if no XP has been + * gained in it. + */ + public synchronized int xpPerHour(Skill skill) { + Deque samples = samplesBySkill.get(skill); + if (samples == null || samples.size() < 2) { + return 0; + } + + long now = System.currentTimeMillis(); + long rateCutoff = now - RATE_WINDOW_MS; + + Sample first = null; + for (Sample s : samples) { + if (s.timestampMillis >= rateCutoff) { + first = s; + break; + } + } + if (first == null) return 0; + + Sample last = samples.peekLast(); + long elapsedMs = last.timestampMillis - first.timestampMillis; + if (elapsedMs <= 0) return 0; + + int xpDelta = last.xp - first.xp; + if (xpDelta <= 0) return 0; + + return (int) ((xpDelta * 3_600_000.0) / elapsedMs); + } + + /** Reset all tracking. Used on plugin reload or "Clear" action. */ + public synchronized void reset() { + baselineXp.clear(); + samplesBySkill.clear(); + } + + /** + * Snapshot of all samples for a skill, oldest first. Used by the XP chart + * for time-series rendering. Returned list is a defensive copy. + */ + public synchronized java.util.List getSamples(Skill skill) { + Deque samples = samplesBySkill.get(skill); + if (samples == null) return java.util.Collections.emptyList(); + java.util.List out = new java.util.ArrayList<>(samples.size()); + for (Sample s : samples) { + out.add(new SamplePoint(s.timestampMillis, s.xp)); + } + return out; + } + + /** Baseline XP for a skill (the first observation we ever recorded). 0 if none. */ + public synchronized int baselineFor(Skill skill) { + Integer b = baselineXp.get(skill); + return b == null ? 0 : b; + } + + private static final class Sample { + final long timestampMillis; + final int xp; + + Sample(long timestampMillis, int xp) { + this.timestampMillis = timestampMillis; + this.xp = xp; + } + } + + /** Public projection of an XP sample for chart use. */ + public static final class SamplePoint { + public final long timestampMillis; + public final int xp; + + public SamplePoint(long timestampMillis, int xp) { + this.timestampMillis = timestampMillis; + this.xp = xp; + } + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/notify/AlertManager.java b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/notify/AlertManager.java new file mode 100644 index 0000000000..de42ec61a6 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/notify/AlertManager.java @@ -0,0 +1,89 @@ +package net.runelite.client.plugins.microbot.microbotdashboardplus.notify; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Skill; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Parses + tracks per-skill level alert thresholds and detects crossings. + * + *

Threshold input format: {@code "MINING:60, WOODCUTTING:80, FISHING:70"}. + * Skill names follow the OSRS API {@link Skill} enum (case-insensitive on + * parse). Levels are integers in [1, 99]. Invalid entries are skipped with a + * debug log. + * + *

State: a set of {@code (Skill, level)} pairs already fired. Crossings + * fire exactly once per (skill, level) pair across the session. + */ +@Slf4j +public class AlertManager { + + private Map thresholds = new EnumMap<>(Skill.class); + private final Set fired = new HashSet<>(); + + public synchronized void setThresholdsFromConfig(String csv) { + // Re-arm the once-per-session dedupe so adjusted or re-added thresholds + // can fire again after the user edits config. + resetFired(); + Map next = new EnumMap<>(Skill.class); + if (csv == null || csv.trim().isEmpty()) { + this.thresholds = next; + return; + } + for (String token : csv.split(",")) { + String t = token.trim(); + if (t.isEmpty()) continue; + int colon = t.indexOf(':'); + if (colon <= 0 || colon >= t.length() - 1) { + log.debug("AlertManager: skipping malformed token '{}'", t); + continue; + } + String skillStr = t.substring(0, colon).trim().toUpperCase(); + String levelStr = t.substring(colon + 1).trim(); + try { + Skill s = Skill.valueOf(skillStr); + int level = Integer.parseInt(levelStr); + if (level >= 1 && level <= 99) next.put(s, level); + else log.debug("AlertManager: level out of range '{}'", level); + } catch (Throwable ex) { + log.debug("AlertManager: invalid token '{}' ({})", t, ex.getClass().getSimpleName()); + } + } + this.thresholds = next; + } + + /** + * Returns true if {@code level} just crossed (i.e. {@code >=}) the + * configured threshold for {@code skill} and we haven't fired the alert + * for that pair yet. Marks the pair as fired. + */ + public synchronized boolean checkCrossing(Skill skill, int level) { + Integer threshold = thresholds.get(skill); + if (threshold == null) return false; + if (level < threshold) return false; + String key = skill.name() + ":" + threshold; + if (fired.contains(key)) return false; + fired.add(key); + return true; + } + + /** Threshold for a given skill, or null if not configured. */ + public synchronized Integer thresholdFor(Skill skill) { + return thresholds.get(skill); + } + + /** Read-only snapshot of configured thresholds. */ + public synchronized Map getThresholds() { + return Collections.unmodifiableMap(new EnumMap<>(thresholds)); + } + + /** Reset fire-state. Used when user changes config or resets the session. */ + public synchronized void resetFired() { + fired.clear(); + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/notify/DiscordNotifier.java b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/notify/DiscordNotifier.java new file mode 100644 index 0000000000..64680628ea --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/notify/DiscordNotifier.java @@ -0,0 +1,159 @@ +package net.runelite.client.plugins.microbot.microbotdashboardplus.notify; + +import lombok.extern.slf4j.Slf4j; + +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * Async Discord webhook sender. + * + *

POST a JSON body {@code {"content": "..."}} to the configured webhook URL. + * Single-thread executor with a daemon worker, so notifications never block + * the poller thread or the EDT. Webhook URL is treated as a secret: never + * logged, never returned in errors, only used internally. + * + *

Discord rate limit is generous (~30/min per webhook); we don't bother + * with backoff. Failures are logged at DEBUG level so a misconfigured URL + * doesn't spam INFO/WARN. + * + *

Usage: + *

+ *   DiscordNotifier d = new DiscordNotifier();
+ *   d.setWebhookUrl("https://discord.com/api/webhooks/...");
+ *   d.send("Mining reached level 60!");
+ *   ...
+ *   d.shutdown();
+ * 
+ */ +@Slf4j +public class DiscordNotifier { + + private static final int CONNECT_TIMEOUT_MS = 4_000; + private static final int READ_TIMEOUT_MS = 4_000; + private static final int MAX_BODY_LENGTH = 1900; // Discord cap is 2000 -- leave headroom. + + private volatile String webhookUrl = ""; + private ScheduledExecutorService executor; + + public synchronized void setWebhookUrl(String url) { + this.webhookUrl = url == null ? "" : url.trim(); + } + + public boolean isConfigured() { + String u = webhookUrl; + if (u == null || u.isEmpty()) return false; + try { + URI uri = URI.create(u); + if (!"https".equalsIgnoreCase(uri.getScheme())) return false; + String host = uri.getHost(); + if (host == null) return false; + host = host.toLowerCase(); + boolean validHost = host.equals("discord.com") || host.equals("discordapp.com") + || host.endsWith(".discord.com") || host.endsWith(".discordapp.com"); + String path = uri.getPath(); + return validHost && path != null && path.startsWith("/api/webhooks/"); + } catch (Throwable t) { + return false; + } + } + + public synchronized void start() { + if (executor != null) return; + executor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "MicrobotDashboardPlus-Discord"); + t.setDaemon(true); + return t; + }); + } + + public synchronized void shutdown() { + if (executor != null) { + executor.shutdownNow(); + try { executor.awaitTermination(1, TimeUnit.SECONDS); } + catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } + executor = null; + } + } + + /** Fire-and-forget notification. Returns immediately. */ + public void send(String message) { + if (!isConfigured()) return; + if (message == null || message.isEmpty()) return; + if (executor == null) start(); + + // Snapshot URL so the worker doesn't race against setWebhookUrl. + final String url = webhookUrl; + final String body = truncate(message, MAX_BODY_LENGTH); + try { + executor.submit(() -> postSafely(url, body)); + } catch (Throwable t) { + log.debug("Discord submit failed: {}", t.getMessage()); + } + } + + private static void postSafely(String url, String content) { + HttpURLConnection conn = null; + try { + URI uri = URI.create(url); + URL u = uri.toURL(); + conn = (HttpURLConnection) u.openConnection(); + conn.setRequestMethod("POST"); + conn.setConnectTimeout(CONNECT_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + conn.setRequestProperty("User-Agent", "MicrobotDashboardPlus"); + + String payload = "{\"content\":\"" + jsonEscape(content) + "\"}"; + byte[] bytes = payload.getBytes(StandardCharsets.UTF_8); + try (OutputStream os = conn.getOutputStream()) { + os.write(bytes); + } + + int code = conn.getResponseCode(); + // Discord returns 204 No Content on success. + if (code < 200 || code >= 300) { + log.debug("Discord webhook returned {} (not logging URL or body)", code); + } + } catch (Throwable t) { + // Never include the URL in the log message. + log.debug("Discord webhook send failed: {}", t.getClass().getSimpleName()); + } finally { + if (conn != null) conn.disconnect(); + } + } + + private static String jsonEscape(String s) { + if (s == null) return ""; + StringBuilder b = new StringBuilder(s.length() + 8); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"': b.append("\\\""); break; + case '\\': b.append("\\\\"); break; + case '\n': b.append("\\n"); break; + case '\r': b.append("\\r"); break; + case '\t': b.append("\\t"); break; + default: + if (c < 0x20) { + b.append(String.format("\\u%04x", (int) c)); + } else { + b.append(c); + } + } + } + return b.toString(); + } + + private static String truncate(String s, int max) { + if (s == null) return ""; + return s.length() <= max ? s : s.substring(0, max - 1) + "…"; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/AntibanStatePanel.java b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/AntibanStatePanel.java new file mode 100644 index 0000000000..908e2f639b --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/AntibanStatePanel.java @@ -0,0 +1,140 @@ +package net.runelite.client.plugins.microbot.microbotdashboardplus.panels; + +import net.runelite.client.plugins.microbot.microbotdashboardplus.data.PollSnapshot; +import net.runelite.client.plugins.microbot.microbotdashboardplus.poller.GameStatePoller; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; + +import javax.swing.JLabel; +import javax.swing.JPanel; +import java.awt.Color; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; + +/** + * Antiban state section: shows whether the script is genuinely running or is + * being held by intentional anti-AFK behavior. + * + *

When a session looks stuck this panel tells you whether it is a real + * stall (nothing holding it, so something is wrong) or a deliberate pause (a + * micro break or action cooldown is running, which is expected). It reads the + * in-process antiban flags, the global pause switch, and the blocking-event + * handlers; nothing here touches the disk. + * + *

    + *
  • State - one-line plain reason ("Running", "Micro break in + * progress", "All scripts paused", and so on).
  • + *
  • Antiban - whether antiban is globally enabled.
  • + *
  • Action cooldown / Micro break - the two anti-AFK holds.
  • + *
  • All scripts paused - the global pause switch.
  • + *
  • Blocking events - registered handler count, with "(running)" + * appended when one is executing.
  • + *
+ */ +public class AntibanStatePanel extends DashboardSection { + + private final JLabel state = mkValue(); + private final JLabel antiban = mkValue(); + private final JLabel cooldown = mkValue(); + private final JLabel microBreak = mkValue(); + private final JLabel paused = mkValue(); + private final JLabel blocking = mkValue(); + + public AntibanStatePanel(GameStatePoller poller) { + super("Antiban State", poller); + setSubtitle("(stall vs intentional pause)"); + add(buildGrid(), java.awt.BorderLayout.CENTER); + } + + private JPanel buildGrid() { + JPanel grid = new JPanel(new GridBagLayout()); + grid.setOpaque(false); + + GridBagConstraints c = new GridBagConstraints(); + c.fill = GridBagConstraints.HORIZONTAL; + c.anchor = GridBagConstraints.WEST; + c.insets = new Insets(2, 4, 2, 4); + + int row = 0; + addRow(grid, c, row++, "State", state); + addRow(grid, c, row++, "Antiban", antiban); + addRow(grid, c, row++, "Action cooldown", cooldown); + addRow(grid, c, row++, "Micro break", microBreak); + addRow(grid, c, row++, "All scripts paused", paused); + addRow(grid, c, row, "Blocking events", blocking); + + return grid; + } + + private static void addRow(JPanel grid, GridBagConstraints c, int row, String label, JLabel value) { + c.gridx = 0; + c.gridy = row; + c.weightx = 0; + JLabel lbl = new JLabel(label); + lbl.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + lbl.setFont(FontManager.getRunescapeSmallFont()); + grid.add(lbl, c); + + c.gridx = 1; + c.weightx = 1.0; + grid.add(value, c); + } + + private static JLabel mkValue() { + JLabel l = new JLabel("--"); + l.setForeground(Color.WHITE); + l.setFont(FontManager.getRunescapeSmallFont()); + return l; + } + + @Override + protected void applySnapshot(PollSnapshot snapshot) { + if (snapshot == null || snapshot.getAntibanState() == null) { + state.setText("--"); + return; + } + PollSnapshot.AntibanState a = snapshot.getAntibanState(); + + String summary = a.getSummary() == null ? "--" : a.getSummary(); + state.setText(summary); + // Green when genuinely running, gold when intentionally held, gray when + // unknown. Lets the user read the state at a glance. + boolean held = a.isAllScriptsPaused() || a.isMicroBreakActive() + || a.isActionCooldownActive() || Boolean.TRUE.equals(a.getBlockingEventRunning()); + if ("--".equals(summary) || "unavailable".equals(summary)) { + state.setForeground(Color.GRAY); + } else if (held) { + state.setForeground(ColorScheme.PROGRESS_INPROGRESS_COLOR); + } else { + state.setForeground(ColorScheme.PROGRESS_COMPLETE_COLOR); + } + + antiban.setText(a.isAntibanEnabled() ? "on" : "off"); + antiban.setForeground(a.isAntibanEnabled() ? Color.WHITE : Color.GRAY); + + setBool(cooldown, a.isActionCooldownActive()); + setBool(microBreak, a.isMicroBreakActive()); + setBool(paused, a.isAllScriptsPaused()); + + blocking.setText(blockingText(a)); + blocking.setForeground(Color.WHITE); + } + + /** "yes" highlighted gold when an anti-AFK hold is active, "no" muted otherwise. */ + private static void setBool(JLabel label, boolean active) { + label.setText(active ? "yes" : "no"); + label.setForeground(active ? ColorScheme.PROGRESS_INPROGRESS_COLOR : Color.GRAY); + } + + private static String blockingText(PollSnapshot.AntibanState a) { + int count = a.getBlockingEventCount(); + Boolean running = a.getBlockingEventRunning(); + StringBuilder sb = new StringBuilder(); + sb.append(count).append(count == 1 ? " handler" : " handlers"); + if (Boolean.TRUE.equals(running)) { + sb.append(" (running)"); + } + return sb.toString(); + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/DashboardSection.java b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/DashboardSection.java new file mode 100644 index 0000000000..e8e954cd38 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/DashboardSection.java @@ -0,0 +1,111 @@ +package net.runelite.client.plugins.microbot.microbotdashboardplus.panels; + +import net.runelite.client.plugins.microbot.microbotdashboardplus.data.PollSnapshot; +import net.runelite.client.plugins.microbot.microbotdashboardplus.poller.GameStatePoller; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; + +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.border.EmptyBorder; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.FlowLayout; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.util.function.Consumer; + +/** + * Base for every dashboard section panel. Provides: + *
    + *
  • A header row that NEVER truncates the title: a {@link GridBagLayout} + * with title + subtitle pinned left (weightx=0), a flex spacer in the + * middle (weightx=1), and controls pinned right (weightx=0).
  • + *
  • BorderLayout body with the section content in the center.
  • + *
  • Auto-registration with the {@link GameStatePoller} on construct and + * {@link #detach()} cleanup on plugin shutdown.
  • + *
+ * + *

The header uses GridBagLayout rather than BorderLayout(WEST/EAST) + + * FlowLayout so the title JLabel doesn't auto-ellipsize when its preferred + * width is calculated tightly against the custom RuneLite font. + */ +public abstract class DashboardSection extends JPanel { + + protected final GameStatePoller poller; + private final Consumer snapshotListener; + private final JLabel subtitleLabel; + private final JPanel headerRight; + + protected DashboardSection(String title, GameStatePoller poller) { + super(new BorderLayout(0, 4)); + this.poller = poller; + setBackground(ColorScheme.DARKER_GRAY_COLOR); + setBorder(new EmptyBorder(8, 10, 8, 10)); + + JPanel header = new JPanel(new GridBagLayout()); + header.setOpaque(false); + + GridBagConstraints c = new GridBagConstraints(); + c.fill = GridBagConstraints.NONE; + c.anchor = GridBagConstraints.WEST; + c.weightx = 0; + c.weighty = 0; + c.insets = new Insets(0, 0, 0, 6); + + // Title (col 0). + JLabel titleLabel = new JLabel(title); + titleLabel.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + titleLabel.setFont(FontManager.getRunescapeBoldFont()); + c.gridx = 0; + header.add(titleLabel, c); + + // Subtitle (col 1). + subtitleLabel = new JLabel(""); + subtitleLabel.setForeground(Color.GRAY); + subtitleLabel.setFont(FontManager.getRunescapeSmallFont()); + c.gridx = 1; + header.add(subtitleLabel, c); + + // Flex spacer (col 2). + c.gridx = 2; + c.weightx = 1.0; + c.fill = GridBagConstraints.HORIZONTAL; + JPanel spacer = new JPanel(); + spacer.setOpaque(false); + header.add(spacer, c); + + // Controls (col 3). + c.gridx = 3; + c.weightx = 0; + c.fill = GridBagConstraints.NONE; + c.anchor = GridBagConstraints.EAST; + c.insets = new Insets(0, 6, 0, 0); + headerRight = new JPanel(new FlowLayout(FlowLayout.RIGHT, 6, 0)); + headerRight.setOpaque(false); + header.add(headerRight, c); + + add(header, BorderLayout.NORTH); + + snapshotListener = this::applySnapshot; + poller.addListener(snapshotListener); + } + + public void detach() { + poller.removeListener(snapshotListener); + } + + protected void setSubtitle(String text) { + subtitleLabel.setText(text == null ? "" : text); + } + + /** Add an in-header control (right-aligned). */ + protected void addHeaderControl(Component component) { + headerRight.add(component); + } + + /** Called on every snapshot. Always invoked on the EDT. */ + protected abstract void applySnapshot(PollSnapshot snapshot); +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/EventLogPanel.java b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/EventLogPanel.java new file mode 100644 index 0000000000..92eb710c22 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/EventLogPanel.java @@ -0,0 +1,122 @@ +package net.runelite.client.plugins.microbot.microbotdashboardplus.panels; + +import net.runelite.client.plugins.microbot.microbotdashboardplus.data.PollSnapshot; +import net.runelite.client.plugins.microbot.microbotdashboardplus.poller.GameStatePoller; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; + +import javax.swing.DefaultListCellRenderer; +import javax.swing.DefaultListModel; +import javax.swing.JList; +import javax.swing.JScrollPane; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayDeque; +import java.util.Deque; + +/** + * Event Log section: rolling ring buffer of the last 10 state-change events + * observed by the dashboard (login / logout / world hop). Spans full width. + * + *

Events are derived from snapshot diffs. The ring is in-memory only. + */ +public class EventLogPanel extends DashboardSection { + + private static final int RING_SIZE = 10; + private static final DateTimeFormatter TIME_FMT = DateTimeFormatter + .ofPattern("HH:mm:ss") + .withZone(ZoneId.systemDefault()); + + private final DefaultListModel model = new DefaultListModel<>(); + private final Deque ring = new ArrayDeque<>(RING_SIZE); + private final JList list; + + private boolean lastLoggedIn = false; + private int lastWorld = -1; + private String lastPlayerName = null; + private boolean firstSnapshot = true; + + public EventLogPanel(GameStatePoller poller) { + super("Event Log", poller); + setSubtitle("(last " + RING_SIZE + ")"); + + list = new JList<>(model); + list.setBackground(ColorScheme.DARKER_GRAY_COLOR); + list.setForeground(Color.WHITE); + list.setFont(FontManager.getRunescapeSmallFont()); + list.setCellRenderer(new EntryRenderer()); + + JScrollPane scroll = new JScrollPane(list); + scroll.setBorder(null); + scroll.getViewport().setBackground(ColorScheme.DARKER_GRAY_COLOR); + scroll.setBackground(ColorScheme.DARKER_GRAY_COLOR); + scroll.setPreferredSize(new Dimension(780, 140)); + add(scroll, BorderLayout.CENTER); + } + + @Override + protected void applySnapshot(PollSnapshot snapshot) { + if (snapshot == null) return; + + if (firstSnapshot) { + // Establish baseline without firing events. + lastLoggedIn = snapshot.isLoggedIn(); + lastWorld = snapshot.getWorldId(); + lastPlayerName = snapshot.getPlayerName(); + firstSnapshot = false; + } else { + if (snapshot.isLoggedIn() != lastLoggedIn) { + add(snapshot.getTimestampMillis(), + snapshot.isLoggedIn() ? "Logged in" : "Logged out"); + lastLoggedIn = snapshot.isLoggedIn(); + } + if (lastWorld != snapshot.getWorldId() && snapshot.getWorldId() > 0) { + add(snapshot.getTimestampMillis(), + "World hop -> " + snapshot.getWorldId()); + lastWorld = snapshot.getWorldId(); + } + String n = snapshot.getPlayerName(); + if (n != null && !n.equals(lastPlayerName) && !"--".equals(n)) { + add(snapshot.getTimestampMillis(), "Player: " + n); + lastPlayerName = n; + } + } + } + + private void add(long timestampMillis, String text) { + LogEntry entry = new LogEntry(timestampMillis, text); + if (ring.size() >= RING_SIZE) ring.pollLast(); + ring.offerFirst(entry); + // Rebuild model in order (newest first). + model.clear(); + for (LogEntry e : ring) model.addElement(e); + } + + private static final class LogEntry { + final long timestamp; + final String text; + LogEntry(long t, String s) { this.timestamp = t; this.text = s; } + } + + private static class EntryRenderer extends DefaultListCellRenderer { + EntryRenderer() { setFont(FontManager.getRunescapeSmallFont()); } + + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, + boolean isSelected, boolean cellHasFocus) { + super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (!(value instanceof LogEntry)) return this; + LogEntry e = (LogEntry) value; + String time = TIME_FMT.format(Instant.ofEpochMilli(e.timestamp)); + setText(time + " " + e.text); + setBackground(ColorScheme.DARKER_GRAY_COLOR); + setForeground(Color.WHITE); + return this; + } + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/GuidePanel.java b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/GuidePanel.java new file mode 100644 index 0000000000..969670d48d --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/GuidePanel.java @@ -0,0 +1,93 @@ +package net.runelite.client.plugins.microbot.microbotdashboardplus.panels; + +import net.runelite.client.plugins.microbot.microbotdashboardplus.data.PollSnapshot; +import net.runelite.client.plugins.microbot.microbotdashboardplus.poller.GameStatePoller; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; + +import javax.swing.JEditorPane; +import javax.swing.JScrollPane; +import javax.swing.border.EmptyBorder; +import javax.swing.text.html.HTMLEditorKit; +import javax.swing.text.html.StyleSheet; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; + +/** + * Guide section: in-dashboard reference for panels + config options. Lives at + * the bottom of the floating window. Default ON; users untick + * {@code showGuide} in the Layout config once they're familiar. + * + *

Content is static HTML rendered into a {@link JEditorPane} so we get + * hyperlinks, bullets, and bold formatting for free. + */ +public class GuidePanel extends DashboardSection { + + private static final String GUIDE_HTML = + "" + + "

Panels (toggle visibility in plugin config → Layout):

" + + "
    " + + "
  • Player: name, combat level, login state, world, profile, session duration, position, animation.
  • " + + "
  • Active Scripts: user-facing Microbot plugins currently enabled, per-plugin runtime, Stop button per row.
  • " + + "
  • Inventory: slot grid with item names + quantities; noted items styled distinctly.
  • " + + "
  • Skills: all 22 skills with current level, total XP, gain since session start, rolling 5-min XP/hr, and an ETA to your target level (or the next level while training).
  • " + + "
  • Nearby NPCs: NPC list sorted by distance, max-distance spinner, random-event NPCs highlighted orange.
  • " + + "
  • Antiban State: tells a silent stall apart from an intentional anti-AFK pause. Shows the current state, antiban on/off, action cooldown, micro break, global pause, and blocking-event handlers.
  • " + + "
  • XP Over Time: Java2D line chart with skill + window selectors (5m to 24h). Selection persists across launches.
  • " + + "
  • Event Log: rolling 10-entry ring buffer of login / logout / world-hop events.
  • " + + "
" + + "

Config options:

" + + "
    " + + "
  1. Auto-open dashboard on startup: open the floating window when the plugin enables.
  2. " + + "
  3. Poll interval (sec): refresh rate from game state (1-60, default 5).
  4. " + + "
  5. Nearby NPCs max distance: filter for the NPC panel (1-200 tiles, default 20).
  6. " + + "
  7. Layout toggles: nine on/off switches, one per panel (including this Guide).
  8. " + + "
  9. Discord webhook URL: paste your channel webhook (field is masked; blank disables Discord).
  10. " + + "
  11. Notify on level-up / pet drop / session lifecycle / alert threshold: four independent toggles.
  12. " + + "
  13. Alert thresholds: comma-separated SKILL:LEVEL pairs (e.g. MINING:60, WOODCUTTING:80). Crossings show an in-window banner and (if Discord is set) send a notification.
  14. " + + "
  15. Skill targets (ETA): comma-separated SKILL:LEVEL pairs (e.g. MINING:70, AGILITY:60) that drive the Skills ETA column.
  16. " + + "
" + + "

Hide this section by unticking Show Guide in plugin config → Layout.

" + + ""; + + public GuidePanel(GameStatePoller poller) { + super("Guide", poller); + setSubtitle("(hide via Layout config once familiar)"); + + JEditorPane editor = new JEditorPane(); + editor.setEditable(false); + editor.setOpaque(false); + editor.setBorder(new EmptyBorder(4, 4, 4, 4)); + + // Use an HTMLEditorKit with a stylesheet that matches the RuneLite + // dark theme. + HTMLEditorKit kit = new HTMLEditorKit(); + StyleSheet css = kit.getStyleSheet(); + String font = FontManager.getRunescapeSmallFont().getFamily(); + int fontSize = FontManager.getRunescapeSmallFont().getSize(); + css.addRule("body { color: #c0c0c0; font-family: '" + font + "'; font-size: " + fontSize + "pt; }"); + css.addRule("b { color: #ffffff; }"); + css.addRule("p { margin: 4px 0; }"); + css.addRule("ul, ol { margin-top: 4px; margin-bottom: 8px; padding-left: 18px; }"); + css.addRule("li { margin: 2px 0; }"); + css.addRule("code { color: #ffeb91; font-family: monospace; }"); + + editor.setEditorKit(kit); + editor.setText(GUIDE_HTML); + editor.setBackground(ColorScheme.DARKER_GRAY_COLOR); + editor.setForeground(Color.LIGHT_GRAY); + + JScrollPane scroll = new JScrollPane(editor); + scroll.setBorder(null); + scroll.getViewport().setBackground(ColorScheme.DARKER_GRAY_COLOR); + scroll.setBackground(ColorScheme.DARKER_GRAY_COLOR); + scroll.setPreferredSize(new Dimension(780, 320)); + add(scroll, BorderLayout.CENTER); + } + + @Override + protected void applySnapshot(PollSnapshot snapshot) { + // Static content; nothing to update. + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/InventoryPanel.java b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/InventoryPanel.java new file mode 100644 index 0000000000..9097dbca6f --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/InventoryPanel.java @@ -0,0 +1,90 @@ +package net.runelite.client.plugins.microbot.microbotdashboardplus.panels; + +import net.runelite.client.plugins.microbot.microbotdashboardplus.data.PollSnapshot; +import net.runelite.client.plugins.microbot.microbotdashboardplus.poller.GameStatePoller; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; + +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.SwingConstants; +import javax.swing.border.LineBorder; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.GridLayout; +import java.text.NumberFormat; +import java.util.Collections; +import java.util.List; + +/** + * Inventory section. Grid of slot cells with name + quantity, fed from the + * {@link PollSnapshot} (the poller reads items via Rs2Inventory). Noted items + * are styled distinctly. + */ +public class InventoryPanel extends DashboardSection { + + private static final NumberFormat NUM = NumberFormat.getIntegerInstance(); + private final JPanel grid; + + public InventoryPanel(GameStatePoller poller) { + super("Inventory", poller); + + grid = new JPanel(new GridLayout(0, 2, 4, 4)); + grid.setOpaque(false); + + JScrollPane scroll = new JScrollPane(grid); + scroll.setBorder(null); + scroll.getViewport().setBackground(ColorScheme.DARKER_GRAY_COLOR); + scroll.setBackground(ColorScheme.DARKER_GRAY_COLOR); + scroll.setPreferredSize(new Dimension(380, 360)); + add(scroll, BorderLayout.CENTER); + } + + @Override + protected void applySnapshot(PollSnapshot snapshot) { + if (snapshot == null) return; + List items = snapshot.getInventory(); + rebuildGrid(items == null ? Collections.emptyList() : items); + setSubtitle(items == null ? "" : "(" + items.size() + ")"); + } + + private void rebuildGrid(List items) { + grid.removeAll(); + if (items.isEmpty()) { + JLabel empty = new JLabel("Inventory empty or unavailable"); + empty.setForeground(Color.GRAY); + empty.setFont(FontManager.getRunescapeSmallFont()); + empty.setHorizontalAlignment(SwingConstants.CENTER); + grid.add(empty); + } else { + for (PollSnapshot.InventoryItem item : items) { + grid.add(makeCell(item)); + } + } + grid.revalidate(); + grid.repaint(); + } + + private JPanel makeCell(PollSnapshot.InventoryItem item) { + JPanel cell = new JPanel(new BorderLayout(4, 0)); + cell.setBackground(ColorScheme.DARK_GRAY_COLOR); + cell.setBorder(new LineBorder(ColorScheme.MEDIUM_GRAY_COLOR, 1)); + + JLabel name = new JLabel(item.getName() == null ? "?" : item.getName()); + name.setForeground(item.isNoted() ? ColorScheme.PROGRESS_INPROGRESS_COLOR : Color.WHITE); + name.setFont(FontManager.getRunescapeSmallFont()); + name.setBorder(new javax.swing.border.EmptyBorder(2, 6, 2, 0)); + cell.add(name, BorderLayout.CENTER); + + JLabel qty = new JLabel(NUM.format(item.getQuantity())); + qty.setForeground(ColorScheme.PROGRESS_COMPLETE_COLOR); + qty.setFont(FontManager.getRunescapeSmallFont()); + qty.setBorder(new javax.swing.border.EmptyBorder(2, 0, 2, 6)); + qty.setHorizontalAlignment(SwingConstants.RIGHT); + cell.add(qty, BorderLayout.EAST); + + return cell; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/NearbyNpcsPanel.java b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/NearbyNpcsPanel.java new file mode 100644 index 0000000000..465ed5a672 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/NearbyNpcsPanel.java @@ -0,0 +1,102 @@ +package net.runelite.client.plugins.microbot.microbotdashboardplus.panels; + +import net.runelite.client.plugins.microbot.microbotdashboardplus.data.PollSnapshot; +import net.runelite.client.plugins.microbot.microbotdashboardplus.poller.GameStatePoller; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; + +import javax.swing.DefaultListCellRenderer; +import javax.swing.DefaultListModel; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JScrollPane; +import javax.swing.JSpinner; +import javax.swing.SpinnerNumberModel; +import javax.swing.event.ChangeListener; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.util.Collections; +import java.util.List; + +/** + * Nearby NPCs section. JList sorted by distance, with an in-header spinner + * to adjust the max-distance filter (1-200 tiles). Updates {@link GameStatePoller#setNpcMaxDistance(int)} + * immediately so the next poll reflects the new bound. + */ +public class NearbyNpcsPanel extends DashboardSection { + + private final DefaultListModel model = new DefaultListModel<>(); + private final JList list; + private final JSpinner maxDistanceSpinner; + + public NearbyNpcsPanel(GameStatePoller poller) { + super("Nearby NPCs", poller); + + // Header spinner: max distance. + JLabel maxLbl = new JLabel("Max"); + maxLbl.setForeground(Color.LIGHT_GRAY); + maxLbl.setFont(FontManager.getRunescapeSmallFont()); + addHeaderControl(maxLbl); + + maxDistanceSpinner = new JSpinner(new SpinnerNumberModel(poller.getNpcMaxDistance(), 1, 200, 1)); + maxDistanceSpinner.setPreferredSize(new Dimension(60, 20)); + maxDistanceSpinner.addChangeListener((ChangeListener) e -> { + int v = (Integer) maxDistanceSpinner.getValue(); + poller.setNpcMaxDistance(v); + poller.refreshNow(); + }); + addHeaderControl(maxDistanceSpinner); + + JLabel tilesLbl = new JLabel("tiles"); + tilesLbl.setForeground(Color.LIGHT_GRAY); + tilesLbl.setFont(FontManager.getRunescapeSmallFont()); + addHeaderControl(tilesLbl); + + // List body. + list = new JList<>(model); + list.setBackground(ColorScheme.DARKER_GRAY_COLOR); + list.setForeground(Color.WHITE); + list.setFont(FontManager.getRunescapeSmallFont()); + list.setSelectionForeground(Color.WHITE); + list.setSelectionBackground(ColorScheme.DARK_GRAY_HOVER_COLOR); + list.setCellRenderer(new NpcCellRenderer()); + + JScrollPane scroll = new JScrollPane(list); + scroll.setBorder(null); + scroll.getViewport().setBackground(ColorScheme.DARKER_GRAY_COLOR); + scroll.setBackground(ColorScheme.DARKER_GRAY_COLOR); + scroll.setPreferredSize(new Dimension(380, 240)); + add(scroll, BorderLayout.CENTER); + } + + @Override + protected void applySnapshot(PollSnapshot snapshot) { + if (snapshot == null) return; + List npcs = snapshot.getNearbyNpcs(); + if (npcs == null) npcs = Collections.emptyList(); + model.clear(); + for (PollSnapshot.NearbyNpc n : npcs) model.addElement(n); + setSubtitle("(" + npcs.size() + " within " + poller.getNpcMaxDistance() + " tiles)"); + } + + private static class NpcCellRenderer extends DefaultListCellRenderer { + NpcCellRenderer() { setFont(FontManager.getRunescapeSmallFont()); } + + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, + boolean isSelected, boolean cellHasFocus) { + super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (!(value instanceof PollSnapshot.NearbyNpc)) return this; + PollSnapshot.NearbyNpc npc = (PollSnapshot.NearbyNpc) value; + String text = npc.getName() + + (npc.getCombatLevel() > 0 ? " (lvl " + npc.getCombatLevel() + ")" : "") + + " - " + npc.getDistance() + " tiles"; + setText(text); + setForeground(npc.isRandomEvent() ? ColorScheme.PROGRESS_INPROGRESS_COLOR : Color.WHITE); + setBackground(isSelected ? ColorScheme.DARK_GRAY_HOVER_COLOR : ColorScheme.DARKER_GRAY_COLOR); + return this; + } + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/PlayerPanel.java b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/PlayerPanel.java new file mode 100644 index 0000000000..3d5a65ea08 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/PlayerPanel.java @@ -0,0 +1,112 @@ +package net.runelite.client.plugins.microbot.microbotdashboardplus.panels; + +import net.runelite.client.plugins.microbot.microbotdashboardplus.data.PollSnapshot; +import net.runelite.client.plugins.microbot.microbotdashboardplus.poller.GameStatePoller; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; + +import javax.swing.JLabel; +import javax.swing.JPanel; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.time.Duration; + +/** + * Player section: name, combat level, login state, world, profile, session + * duration, position, animation. Static key-value grid laid out with + * GridBagLayout. + */ +public class PlayerPanel extends DashboardSection { + + private final long sessionStartMillis = System.currentTimeMillis(); + + private final JLabel name = mkValue(); + private final JLabel combat = mkValue(); + private final JLabel loggedIn = mkValue(); + private final JLabel gameState = mkValue(); + private final JLabel world = mkValue(); + private final JLabel profile = mkValue(); + private final JLabel sessionDuration = mkValue(); + private final JLabel position = mkValue(); + private final JLabel animation = mkValue(); + + public PlayerPanel(GameStatePoller poller) { + super("Player", poller); + add(buildGrid(), java.awt.BorderLayout.CENTER); + } + + private JPanel buildGrid() { + JPanel grid = new JPanel(new GridBagLayout()); + grid.setOpaque(false); + + GridBagConstraints c = new GridBagConstraints(); + c.fill = GridBagConstraints.HORIZONTAL; + c.anchor = GridBagConstraints.WEST; + c.insets = new Insets(2, 4, 2, 4); + c.weightx = 0; + + int row = 0; + addRow(grid, c, row++, "Name", name); + addRow(grid, c, row++, "Combat", combat); + addRow(grid, c, row++, "Logged in", loggedIn); + addRow(grid, c, row++, "Game state", gameState); + addRow(grid, c, row++, "World", world); + addRow(grid, c, row++, "Profile", profile); + addRow(grid, c, row++, "Session", sessionDuration); + addRow(grid, c, row++, "Position", position); + addRow(grid, c, row, "Animation", animation); + + return grid; + } + + private static void addRow(JPanel grid, GridBagConstraints c, int row, String label, JLabel value) { + c.gridx = 0; + c.gridy = row; + c.weightx = 0; + JLabel lbl = new JLabel(label); + lbl.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + lbl.setFont(FontManager.getRunescapeSmallFont()); + grid.add(lbl, c); + + c.gridx = 1; + c.weightx = 1.0; + grid.add(value, c); + } + + private static JLabel mkValue() { + JLabel l = new JLabel("--"); + l.setForeground(java.awt.Color.WHITE); + l.setFont(FontManager.getRunescapeSmallFont()); + return l; + } + + @Override + protected void applySnapshot(PollSnapshot snapshot) { + if (snapshot == null) return; + name.setText(safe(snapshot.getPlayerName())); + combat.setText(snapshot.getCombatLevel() <= 0 ? "--" : Integer.toString(snapshot.getCombatLevel())); + loggedIn.setText(snapshot.isLoggedIn() ? "yes" : "no"); + gameState.setText(safe(snapshot.getGameState())); + world.setText(snapshot.getWorldId() <= 0 ? "--" : Integer.toString(snapshot.getWorldId())); + profile.setText(safe(snapshot.getProfileName())); + sessionDuration.setText(formatDuration(System.currentTimeMillis() - sessionStartMillis)); + position.setText(safe(snapshot.getPositionText())); + animation.setText(safe(snapshot.getAnimationText())); + } + + private static String safe(String s) { + return (s == null || s.isEmpty()) ? "--" : s; + } + + private static String formatDuration(long millis) { + if (millis < 0) millis = 0; + Duration d = Duration.ofMillis(millis); + long h = d.toHours(); + long m = d.minusHours(h).toMinutes(); + long s = d.minusHours(h).minusMinutes(m).getSeconds(); + if (h > 0) return String.format("%dh %02dm %02ds", h, m, s); + if (m > 0) return String.format("%dm %02ds", m, s); + return String.format("%ds", s); + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/ScriptsPanel.java b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/ScriptsPanel.java new file mode 100644 index 0000000000..46b661606c --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/ScriptsPanel.java @@ -0,0 +1,212 @@ +package net.runelite.client.plugins.microbot.microbotdashboardplus.panels; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.microbotdashboardplus.data.PollSnapshot; +import net.runelite.client.plugins.microbot.microbotdashboardplus.poller.GameStatePoller; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; + +import javax.swing.AbstractCellEditor; +import javax.swing.JButton; +import javax.swing.JScrollPane; +import javax.swing.JTable; +import javax.swing.SwingConstants; +import javax.swing.table.AbstractTableModel; +import javax.swing.table.DefaultTableCellRenderer; +import javax.swing.table.JTableHeader; +import javax.swing.table.TableCellEditor; +import javax.swing.table.TableCellRenderer; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Active Scripts section. JTable: Plugin / Status / Runtime / Action (Stop). + * + *

Stop button calls {@link Microbot#stopPlugin(Plugin)} for the row's + * plugin. Refresh happens implicitly on the next poll. + */ +@Slf4j +public class ScriptsPanel extends DashboardSection { + + private static final String[] COLUMNS = {"Plugin", "Status", "Runtime", "Action"}; + + private final ScriptsTableModel model = new ScriptsTableModel(); + private final JTable table; + + public ScriptsPanel(GameStatePoller poller) { + super("Active Scripts", poller); + + table = new JTable(model); + table.setBackground(ColorScheme.DARKER_GRAY_COLOR); + table.setForeground(Color.WHITE); + table.setFont(FontManager.getRunescapeSmallFont()); + table.setRowHeight(22); + table.setGridColor(ColorScheme.MEDIUM_GRAY_COLOR); + table.setShowVerticalLines(false); + table.setShowHorizontalLines(false); + table.setFillsViewportHeight(true); + table.setOpaque(false); + + JTableHeader header = table.getTableHeader(); + header.setBackground(ColorScheme.DARK_GRAY_COLOR); + header.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + header.setFont(FontManager.getRunescapeSmallFont()); + header.setReorderingAllowed(false); + + table.getColumnModel().getColumn(0).setPreferredWidth(180); + table.getColumnModel().getColumn(1).setPreferredWidth(80); + table.getColumnModel().getColumn(2).setPreferredWidth(80); + table.getColumnModel().getColumn(3).setPreferredWidth(70); + + TableCellRenderer textRenderer = new TextRenderer(); + table.getColumnModel().getColumn(0).setCellRenderer(textRenderer); + table.getColumnModel().getColumn(1).setCellRenderer(textRenderer); + table.getColumnModel().getColumn(2).setCellRenderer(textRenderer); + table.getColumnModel().getColumn(3).setCellRenderer(new StopButtonRenderer()); + table.getColumnModel().getColumn(3).setCellEditor(new StopButtonEditor()); + + JScrollPane scroll = new JScrollPane(table); + scroll.setBorder(null); + scroll.getViewport().setBackground(ColorScheme.DARKER_GRAY_COLOR); + scroll.setBackground(ColorScheme.DARKER_GRAY_COLOR); + scroll.setPreferredSize(new Dimension(380, 200)); + add(scroll, BorderLayout.CENTER); + } + + @Override + protected void applySnapshot(PollSnapshot snapshot) { + if (snapshot == null) return; + List rows = snapshot.getActiveScripts(); + model.update(rows == null ? Collections.emptyList() : rows); + setSubtitle("(" + model.getRowCount() + ")"); + } + + private void stopPlugin(String pluginClassName) { + if (pluginClassName == null) return; + try { + Plugin target = null; + for (Plugin p : Microbot.getPluginManager().getPlugins()) { + if (p != null && p.getClass().getName().equals(pluginClassName)) { + target = p; + break; + } + } + if (target == null) { + log.warn("Stop requested but plugin {} not found", pluginClassName); + return; + } + Microbot.stopPlugin(target); + poller.refreshNow(); + } catch (Throwable t) { + log.warn("Stop plugin failed for {}: {}", pluginClassName, t.getMessage(), t); + } + } + + // ------------------------------------------------------------------ + // Table model + // ------------------------------------------------------------------ + + private static final class ScriptsTableModel extends AbstractTableModel { + private List rows = new ArrayList<>(); + + void update(List newRows) { + this.rows = new ArrayList<>(newRows); + fireTableDataChanged(); + } + + @Override public int getRowCount() { return rows.size(); } + @Override public int getColumnCount() { return COLUMNS.length; } + @Override public String getColumnName(int col) { return COLUMNS[col]; } + @Override public boolean isCellEditable(int row, int col) { return col == 3; } + @Override public Class getColumnClass(int col) { return String.class; } + + @Override + public Object getValueAt(int row, int col) { + PollSnapshot.ScriptStatus s = rows.get(row); + switch (col) { + case 0: return s.getDisplayName(); + case 1: return s.getStatus() == null ? "--" : s.getStatus(); + case 2: return formatRuntime(s.getRuntimeMillis()); + case 3: return s.getPluginClassName(); + default: return ""; + } + } + } + + private static String formatRuntime(long ms) { + if (ms <= 0) return "--"; + long secs = ms / 1000; + long h = secs / 3600; + long m = (secs % 3600) / 60; + long s = secs % 60; + if (h > 0) return String.format("%dh %02dm", h, m); + if (m > 0) return String.format("%dm %02ds", m, s); + return String.format("%ds", s); + } + + // ------------------------------------------------------------------ + // Renderers + editors + // ------------------------------------------------------------------ + + private static class TextRenderer extends DefaultTableCellRenderer { + TextRenderer() { setFont(FontManager.getRunescapeSmallFont()); } + @Override + public Component getTableCellRendererComponent(JTable t, Object v, boolean sel, boolean focus, int r, int c) { + Component cmp = super.getTableCellRendererComponent(t, v, sel, focus, r, c); + cmp.setBackground(ColorScheme.DARKER_GRAY_COLOR); + cmp.setForeground(Color.WHITE); + return cmp; + } + } + + private static class StopButtonRenderer extends JButton implements TableCellRenderer { + StopButtonRenderer() { + setText("Stop"); + setFont(FontManager.getRunescapeSmallFont()); + setBackground(new Color(0x5c2929)); + setForeground(Color.WHITE); + setFocusPainted(false); + setBorderPainted(false); + setHorizontalAlignment(SwingConstants.CENTER); + } + @Override + public Component getTableCellRendererComponent(JTable t, Object v, boolean sel, boolean focus, int r, int c) { + return this; + } + } + + private class StopButtonEditor extends AbstractCellEditor implements TableCellEditor { + private final JButton button; + private String activePluginClass; + + StopButtonEditor() { + button = new JButton("Stop"); + button.setFont(FontManager.getRunescapeSmallFont()); + button.setBackground(new Color(0x7c3939)); + button.setForeground(Color.WHITE); + button.setFocusPainted(false); + button.setBorderPainted(false); + button.addActionListener(e -> { + String target = activePluginClass; + fireEditingStopped(); + if (target != null) stopPlugin(target); + }); + } + @Override + public Component getTableCellEditorComponent(JTable t, Object v, boolean sel, int r, int c) { + activePluginClass = (v == null) ? null : v.toString(); + return button; + } + @Override + public Object getCellEditorValue() { + return activePluginClass; + } + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/SkillsPanel.java b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/SkillsPanel.java new file mode 100644 index 0000000000..29be9bb5de --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/SkillsPanel.java @@ -0,0 +1,324 @@ +package net.runelite.client.plugins.microbot.microbotdashboardplus.panels; + +import net.runelite.api.Experience; +import net.runelite.api.Skill; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.microbotdashboardplus.data.PollSnapshot; +import net.runelite.client.plugins.microbot.microbotdashboardplus.data.XpHistory; +import net.runelite.client.plugins.microbot.microbotdashboardplus.poller.GameStatePoller; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; + +import javax.swing.JScrollPane; +import javax.swing.JTable; +import javax.swing.SwingConstants; +import javax.swing.table.AbstractTableModel; +import javax.swing.table.DefaultTableCellRenderer; +import javax.swing.table.JTableHeader; +import javax.swing.table.TableCellRenderer; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.Dimension; +import java.text.NumberFormat; +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +/** + * Skills section: 6-column JTable. Skill / Level / XP / +Δ / XP/hr / ETA. + * + *

    + *
  • Δ is gain since first observation (set when the poller starts).
  • + *
  • XP/hr is extrapolated from the 5-minute rolling window in {@link XpHistory}.
  • + *
  • ETA is the time to reach a target level, computed from the current + * XP/hr and {@link Experience#getXpForLevel(int)}. The target is read + * from the per-skill targets you set in config; the skill you are + * actively training (the one currently gaining XP) falls back to the + * next level when no target is set.
  • + *
  • Zero values render muted; positive deltas render green.
  • + *
+ */ +public class SkillsPanel extends DashboardSection { + + private static final NumberFormat NUM = NumberFormat.getIntegerInstance(); + private static final String[] COLUMNS = {"Skill", "Level", "XP", "+Δ", "XP/hr", "ETA"}; + private static final Skill[] SKILL_ORDER = buildSkillOrder(); + + private static final String CONFIG_GROUP = "MicrobotDashboardPlus"; + private static final String K_SKILL_TARGETS = "skillTargets"; + + private static Skill[] buildSkillOrder() { + List list = new ArrayList<>(); + for (Skill s : Skill.values()) { + if (s == Skill.OVERALL) continue; + list.add(s); + } + return list.toArray(new Skill[0]); + } + + private final SkillsTableModel model; + private final JTable table; + + public SkillsPanel(GameStatePoller poller) { + super("Skills", poller); + setSubtitle("(Δ since session start · XP/hr 5-min rolling · ETA to target)"); + + model = new SkillsTableModel(); + table = new JTable(model); + table.setBackground(ColorScheme.DARKER_GRAY_COLOR); + table.setForeground(Color.WHITE); + table.setFont(FontManager.getRunescapeSmallFont()); + table.setRowHeight(18); + table.setGridColor(ColorScheme.MEDIUM_GRAY_COLOR); + table.setShowVerticalLines(false); + table.setShowHorizontalLines(false); + table.setFillsViewportHeight(true); + table.setOpaque(false); + table.setSelectionBackground(ColorScheme.DARK_GRAY_HOVER_COLOR); + table.setSelectionForeground(Color.WHITE); + + JTableHeader header = table.getTableHeader(); + header.setBackground(ColorScheme.DARK_GRAY_COLOR); + header.setForeground(ColorScheme.LIGHT_GRAY_COLOR); + header.setFont(FontManager.getRunescapeSmallFont()); + header.setReorderingAllowed(false); + + // Column widths. + table.getColumnModel().getColumn(0).setPreferredWidth(100); + table.getColumnModel().getColumn(1).setPreferredWidth(45); + table.getColumnModel().getColumn(2).setPreferredWidth(75); + table.getColumnModel().getColumn(3).setPreferredWidth(55); + table.getColumnModel().getColumn(4).setPreferredWidth(65); + table.getColumnModel().getColumn(5).setPreferredWidth(80); + + // Renderers. + TableCellRenderer rightAlignMono = new MonoRightRenderer(); + table.getColumnModel().getColumn(1).setCellRenderer(rightAlignMono); + table.getColumnModel().getColumn(2).setCellRenderer(rightAlignMono); + table.getColumnModel().getColumn(3).setCellRenderer(new DeltaRenderer()); + table.getColumnModel().getColumn(4).setCellRenderer(new RateRenderer()); + table.getColumnModel().getColumn(5).setCellRenderer(new EtaRenderer()); + + JScrollPane scroll = new JScrollPane(table); + scroll.setBorder(null); + scroll.getViewport().setBackground(ColorScheme.DARKER_GRAY_COLOR); + scroll.setBackground(ColorScheme.DARKER_GRAY_COLOR); + scroll.setPreferredSize(new Dimension(440, 420)); + add(scroll, BorderLayout.CENTER); + } + + @Override + protected void applySnapshot(PollSnapshot snapshot) { + if (snapshot == null) return; + model.update(snapshot, poller.getXpHistory()); + } + + // ------------------------------------------------------------------ + // Table model + // ------------------------------------------------------------------ + + private static final class SkillsTableModel extends AbstractTableModel { + private final Object[][] rows = new Object[SKILL_ORDER.length][COLUMNS.length]; + + SkillsTableModel() { + for (int i = 0; i < SKILL_ORDER.length; i++) { + rows[i][0] = capitalize(SKILL_ORDER[i].getName()); + rows[i][1] = "--"; + rows[i][2] = "--"; + rows[i][3] = 0; + rows[i][4] = 0; + rows[i][5] = "--"; + } + } + + void update(PollSnapshot snapshot, XpHistory history) { + Map targets = readSkillTargets(); + for (int i = 0; i < SKILL_ORDER.length; i++) { + Skill s = SKILL_ORDER[i]; + Integer xp = snapshot.getSkillXp() == null ? null : snapshot.getSkillXp().get(s); + Integer lvl = snapshot.getSkillLevels() == null ? null : snapshot.getSkillLevels().get(s); + int rate = history.xpPerHour(s); + rows[i][1] = lvl == null ? "--" : NUM.format(lvl); + rows[i][2] = xp == null ? "--" : NUM.format(xp); + rows[i][3] = xp == null ? 0 : history.deltaSinceBaseline(s, xp); + rows[i][4] = rate; + rows[i][5] = computeEta(s, xp, lvl, rate, targets); + } + fireTableDataChanged(); + } + + /** + * ETA text to the target level for this skill. + * + *

Target selection: an explicit per-skill target wins. Otherwise, + * if the skill is currently gaining XP (rate > 0) we target the next + * level so the skill being actively trained always shows an estimate. + * Skills with no target and no XP gain show "--". + */ + private static String computeEta(Skill s, Integer xp, Integer lvl, int rate, Map targets) { + if (xp == null || lvl == null) return "--"; + + Integer target = targets.get(s); + if (target == null) { + // No explicit target: only the actively-training skill gets a + // next-level estimate. + if (rate <= 0 || lvl >= Experience.MAX_REAL_LEVEL) return "--"; + target = lvl + 1; + } + if (target <= lvl) return "done"; + if (target > Experience.MAX_REAL_LEVEL) target = Experience.MAX_REAL_LEVEL; + if (rate <= 0) return "--"; // need a rate to estimate + + int targetXp; + try { + targetXp = Experience.getXpForLevel(target); + } catch (IllegalArgumentException ex) { + return "--"; + } + int remaining = targetXp - xp; + if (remaining <= 0) return "done"; + + double hours = remaining / (double) rate; + return "L" + target + " " + formatEta(hours); + } + + @Override public int getRowCount() { return rows.length; } + @Override public int getColumnCount() { return COLUMNS.length; } + @Override public String getColumnName(int col) { return COLUMNS[col]; } + @Override public Object getValueAt(int row, int col) { return rows[row][col]; } + @Override public boolean isCellEditable(int row, int col) { return false; } + @Override public Class getColumnClass(int col) { + return (col == 3 || col == 4) ? Integer.class : String.class; + } + } + + /** Hours as a compact human ETA: "12m", "3h 25m", "2d 4h". */ + private static String formatEta(double hours) { + if (hours <= 0 || Double.isNaN(hours) || Double.isInfinite(hours)) return "--"; + long totalMinutes = Math.round(hours * 60.0); + if (totalMinutes < 1) return "<1m"; + if (totalMinutes < 60) return totalMinutes + "m"; + long h = totalMinutes / 60; + long m = totalMinutes % 60; + if (h < 24) return h + "h " + m + "m"; + long d = h / 24; + long remH = h % 24; + return d + "d " + remH + "h"; + } + + /** + * Parse the per-skill targets config (comma-separated SKILL:LEVEL pairs, + * for example "MINING:70, AGILITY:60"). Unknown skill names and bad levels + * are skipped. Returns an empty map when nothing is configured. + */ + private static Map readSkillTargets() { + Map out = new EnumMap<>(Skill.class); + String raw; + try { + raw = Microbot.getConfigManager().getConfiguration(CONFIG_GROUP, K_SKILL_TARGETS); + } catch (Throwable t) { + return out; + } + if (raw == null || raw.trim().isEmpty()) return out; + + for (String token : raw.split(",")) { + String pair = token.trim(); + if (pair.isEmpty()) continue; + int colon = pair.indexOf(':'); + if (colon <= 0 || colon >= pair.length() - 1) continue; + String name = pair.substring(0, colon).trim().toUpperCase(); + String levelStr = pair.substring(colon + 1).trim(); + try { + Skill skill = Skill.valueOf(name); + int level = Integer.parseInt(levelStr); + if (level >= 1 && level <= Experience.MAX_REAL_LEVEL) { + out.put(skill, level); + } + } catch (IllegalArgumentException ignored) { + // Unknown skill name or non-numeric level: skip silently. + } + } + return out; + } + + private static String capitalize(String name) { + if (name == null || name.isEmpty()) return name; + return Character.toUpperCase(name.charAt(0)) + name.substring(1).toLowerCase(); + } + + // ------------------------------------------------------------------ + // Cell renderers + // ------------------------------------------------------------------ + + private static class MonoRightRenderer extends DefaultTableCellRenderer { + MonoRightRenderer() { + setHorizontalAlignment(SwingConstants.RIGHT); + setFont(FontManager.getRunescapeSmallFont()); + } + @Override + public Component getTableCellRendererComponent(JTable t, Object v, boolean sel, boolean focus, int r, int c) { + Component cmp = super.getTableCellRendererComponent(t, v, sel, focus, r, c); + cmp.setBackground(ColorScheme.DARKER_GRAY_COLOR); + cmp.setForeground(Color.WHITE); + return cmp; + } + } + + private static class DeltaRenderer extends DefaultTableCellRenderer { + DeltaRenderer() { + setHorizontalAlignment(SwingConstants.RIGHT); + setFont(FontManager.getRunescapeSmallFont()); + } + @Override + public Component getTableCellRendererComponent(JTable t, Object v, boolean sel, boolean focus, int r, int c) { + Component cmp = super.getTableCellRendererComponent(t, v, sel, focus, r, c); + int delta = (v instanceof Integer) ? (Integer) v : 0; + setText(delta > 0 ? "+" + NUM.format(delta) : "0"); + cmp.setBackground(ColorScheme.DARKER_GRAY_COLOR); + cmp.setForeground(delta > 0 ? ColorScheme.PROGRESS_COMPLETE_COLOR : Color.GRAY); + return cmp; + } + } + + private static class RateRenderer extends DefaultTableCellRenderer { + RateRenderer() { + setHorizontalAlignment(SwingConstants.RIGHT); + setFont(FontManager.getRunescapeSmallFont()); + } + @Override + public Component getTableCellRendererComponent(JTable t, Object v, boolean sel, boolean focus, int r, int c) { + Component cmp = super.getTableCellRendererComponent(t, v, sel, focus, r, c); + int rate = (v instanceof Integer) ? (Integer) v : 0; + setText(rate > 0 ? NUM.format(rate) : "--"); + cmp.setBackground(ColorScheme.DARKER_GRAY_COLOR); + cmp.setForeground(rate > 0 ? Color.WHITE : Color.GRAY); + return cmp; + } + } + + private static class EtaRenderer extends DefaultTableCellRenderer { + EtaRenderer() { + setHorizontalAlignment(SwingConstants.RIGHT); + setFont(FontManager.getRunescapeSmallFont()); + } + @Override + public Component getTableCellRendererComponent(JTable t, Object v, boolean sel, boolean focus, int r, int c) { + Component cmp = super.getTableCellRendererComponent(t, v, sel, focus, r, c); + String text = v == null ? "--" : v.toString(); + setText(text); + cmp.setBackground(ColorScheme.DARKER_GRAY_COLOR); + boolean muted = "--".equals(text); + boolean done = "done".equals(text); + if (done) { + cmp.setForeground(ColorScheme.PROGRESS_COMPLETE_COLOR); + } else if (muted) { + cmp.setForeground(Color.GRAY); + } else { + cmp.setForeground(Color.WHITE); + } + return cmp; + } + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/XpChartPanel.java b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/XpChartPanel.java new file mode 100644 index 0000000000..d115735316 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/panels/XpChartPanel.java @@ -0,0 +1,338 @@ +package net.runelite.client.plugins.microbot.microbotdashboardplus.panels; + +import net.runelite.api.Skill; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.microbotdashboardplus.data.PollSnapshot; +import net.runelite.client.plugins.microbot.microbotdashboardplus.data.XpHistory; +import net.runelite.client.plugins.microbot.microbotdashboardplus.poller.GameStatePoller; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; + +import javax.swing.JComboBox; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.SwingConstants; +import java.awt.BasicStroke; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.geom.Path2D; +import java.util.List; + +/** + * XP-over-time chart. Custom Java2D paint, no dependency on external charting + * libraries. + * + *

Controls (in section header): skill JComboBox + window JComboBox. + * Default selection: Mining @ 30 minute window. Y axis: XP gained relative + * to the earliest sample in the visible window. X axis: time. + * + *

Spans full width of the dashboard grid. + */ +public class XpChartPanel extends DashboardSection { + + private static final WindowChoice[] WINDOWS = { + new WindowChoice("5 min", 5L * 60_000), + new WindowChoice("15 min", 15L * 60_000), + new WindowChoice("30 min", 30L * 60_000), + new WindowChoice("1 hour", 60L * 60_000), + new WindowChoice("4 hours", 4L * 60 * 60_000), + new WindowChoice("24 hours", 24L * 60 * 60_000), + }; + private static final int DEFAULT_WINDOW_INDEX = 2; // 30 min + + private static final String CONFIG_GROUP = "MicrobotDashboardPlus"; + private static final String K_CHART_SKILL = "chartSkill"; + private static final String K_CHART_WINDOW_INDEX = "chartWindowIndex"; + + private final Skill[] selectableSkills; + private final JComboBox skillCombo; + private final JComboBox windowCombo; + private final ChartCanvas canvas; + + public XpChartPanel(GameStatePoller poller) { + super("XP Over Time", poller); + + selectableSkills = buildSelectableSkills(); + + // Canvas first; lambdas below capture it. + canvas = new ChartCanvas(); + canvas.setPreferredSize(new Dimension(780, 240)); + + // Header controls. + JLabel skillLbl = new JLabel("Skill"); + skillLbl.setForeground(Color.LIGHT_GRAY); + skillLbl.setFont(FontManager.getRunescapeSmallFont()); + addHeaderControl(skillLbl); + + skillCombo = new JComboBox<>(selectableSkills); + skillCombo.setPreferredSize(new Dimension(110, 20)); + skillCombo.setRenderer(new SkillRenderer()); + Skill defaultSkill = restoreSkill(findDefault(selectableSkills, Skill.MINING)); + skillCombo.setSelectedItem(defaultSkill); + skillCombo.addActionListener(e -> { + Object sel = skillCombo.getSelectedItem(); + if (sel instanceof Skill) persistString(K_CHART_SKILL, ((Skill) sel).name()); + canvas.repaint(); + }); + addHeaderControl(skillCombo); + + JLabel windowLbl = new JLabel("Window"); + windowLbl.setForeground(Color.LIGHT_GRAY); + windowLbl.setFont(FontManager.getRunescapeSmallFont()); + addHeaderControl(windowLbl); + + windowCombo = new JComboBox<>(WINDOWS); + windowCombo.setPreferredSize(new Dimension(90, 20)); + windowCombo.setSelectedIndex(restoreWindowIndex()); + windowCombo.addActionListener(e -> { + persistString(K_CHART_WINDOW_INDEX, Integer.toString(windowCombo.getSelectedIndex())); + canvas.repaint(); + }); + addHeaderControl(windowCombo); + + // Body. + JPanel body = new JPanel(new BorderLayout()); + body.setOpaque(false); + body.add(canvas, BorderLayout.CENTER); + add(body, BorderLayout.CENTER); + } + + private static Skill[] buildSelectableSkills() { + java.util.List list = new java.util.ArrayList<>(); + for (Skill s : Skill.values()) { + if (s == Skill.OVERALL) continue; + list.add(s); + } + return list.toArray(new Skill[0]); + } + + private static Skill findDefault(Skill[] skills, Skill preferred) { + for (Skill s : skills) if (s == preferred) return s; + return skills.length == 0 ? null : skills[0]; + } + + // ----------------------------------------------------------------- + // Persistence + // ----------------------------------------------------------------- + + private Skill restoreSkill(Skill fallback) { + try { + String raw = Microbot.getConfigManager().getConfiguration(CONFIG_GROUP, K_CHART_SKILL); + if (raw == null || raw.isEmpty()) return fallback; + Skill found = Skill.valueOf(raw); + for (Skill s : selectableSkills) if (s == found) return s; + return fallback; + } catch (Throwable t) { + return fallback; + } + } + + private int restoreWindowIndex() { + try { + String raw = Microbot.getConfigManager().getConfiguration(CONFIG_GROUP, K_CHART_WINDOW_INDEX); + if (raw == null || raw.isEmpty()) return DEFAULT_WINDOW_INDEX; + int idx = Integer.parseInt(raw); + if (idx < 0 || idx >= WINDOWS.length) return DEFAULT_WINDOW_INDEX; + return idx; + } catch (Throwable t) { + return DEFAULT_WINDOW_INDEX; + } + } + + private static void persistString(String key, String value) { + try { + Microbot.getConfigManager().setConfiguration(CONFIG_GROUP, key, value); + } catch (Throwable ignored) { /* config not ready -- swallow */ } + } + + @Override + protected void applySnapshot(PollSnapshot snapshot) { + // The chart redraws on its own data view; just trigger a repaint. + canvas.repaint(); + } + + // --------------------------------------------------------------------- + // Chart canvas + // --------------------------------------------------------------------- + + private final class ChartCanvas extends JPanel { + + ChartCanvas() { + setBackground(ColorScheme.DARKER_GRAY_COLOR); + } + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + Graphics2D g2 = (Graphics2D) g.create(); + try { + g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + int w = getWidth(); + int h = getHeight(); + + // Background. + g2.setColor(ColorScheme.DARKER_GRAY_COLOR); + g2.fillRect(0, 0, w, h); + + Skill skill = (Skill) skillCombo.getSelectedItem(); + WindowChoice window = (WindowChoice) windowCombo.getSelectedItem(); + if (skill == null || window == null) return; + + XpHistory history = poller.getXpHistory(); + List all = history.getSamples(skill); + long now = System.currentTimeMillis(); + long cutoff = now - window.windowMs; + + // Filter samples within the window. + java.util.List samples = new java.util.ArrayList<>(); + for (XpHistory.SamplePoint p : all) { + if (p.timestampMillis >= cutoff) samples.add(p); + } + + // Chart bounds. + int padLeft = 60; + int padRight = 16; + int padTop = 14; + int padBottom = 28; + int plotW = Math.max(10, w - padLeft - padRight); + int plotH = Math.max(10, h - padTop - padBottom); + + // Axes. + g2.setColor(ColorScheme.MEDIUM_GRAY_COLOR); + g2.drawLine(padLeft, padTop, padLeft, padTop + plotH); // Y axis + g2.drawLine(padLeft, padTop + plotH, padLeft + plotW, padTop + plotH); // X axis + + if (samples.size() < 2) { + drawCenteredString(g2, "Waiting for XP samples in selected window", + padLeft, padTop, plotW, plotH, Color.GRAY); + return; + } + + int baseline = samples.get(0).xp; + int maxDelta = 0; + for (XpHistory.SamplePoint p : samples) { + maxDelta = Math.max(maxDelta, p.xp - baseline); + } + if (maxDelta <= 0) { + drawCenteredString(g2, "No XP gained in window", + padLeft, padTop, plotW, plotH, Color.GRAY); + return; + } + + // Y ticks: 4 horizontal grid lines. + g2.setFont(FontManager.getRunescapeSmallFont()); + g2.setColor(Color.GRAY); + for (int i = 1; i <= 4; i++) { + int yVal = (int) ((maxDelta * (long) i) / 4); + int y = padTop + plotH - (int) ((yVal * (long) plotH) / maxDelta); + g2.setColor(new Color(80, 80, 80)); + g2.drawLine(padLeft + 1, y, padLeft + plotW, y); + g2.setColor(Color.LIGHT_GRAY); + String lbl = formatXpShort(yVal); + int lblWidth = g2.getFontMetrics().stringWidth(lbl); + g2.drawString(lbl, padLeft - lblWidth - 4, y + 4); + } + + // X ticks: 4 vertical positions (~quarter through, half, etc.). + long windowMs = window.windowMs; + for (int i = 0; i <= 4; i++) { + long t = now - windowMs + (windowMs * i / 4); + int x = padLeft + (int) ((plotW * (long) i) / 4); + g2.setColor(new Color(80, 80, 80)); + g2.drawLine(x, padTop, x, padTop + plotH); + g2.setColor(Color.LIGHT_GRAY); + String lbl = (i == 4) ? "now" : (humanAgo(now - t)); + int lblWidth = g2.getFontMetrics().stringWidth(lbl); + g2.drawString(lbl, x - lblWidth / 2, padTop + plotH + 16); + } + + // Line path. + Path2D.Double path = new Path2D.Double(); + boolean first = true; + for (XpHistory.SamplePoint p : samples) { + double xf = (double) (p.timestampMillis - cutoff) / (double) windowMs; + int x = padLeft + (int) (xf * plotW); + int yVal = p.xp - baseline; + int y = padTop + plotH - (int) ((yVal * (long) plotH) / maxDelta); + if (first) { path.moveTo(x, y); first = false; } + else { path.lineTo(x, y); } + } + + g2.setStroke(new BasicStroke(2f)); + g2.setColor(ColorScheme.PROGRESS_COMPLETE_COLOR); + g2.draw(path); + + // Summary text top-right. + String summary = String.format("Δ %s XP · %s window", + formatXpFull(maxDelta), window.label); + g2.setColor(Color.LIGHT_GRAY); + g2.setFont(FontManager.getRunescapeSmallFont()); + int sumWidth = g2.getFontMetrics().stringWidth(summary); + g2.drawString(summary, padLeft + plotW - sumWidth, padTop - 2); + + } finally { + g2.dispose(); + } + } + + private void drawCenteredString(Graphics2D g2, String s, int x, int y, int w, int h, Color color) { + g2.setColor(color); + g2.setFont(FontManager.getRunescapeSmallFont()); + int sw = g2.getFontMetrics().stringWidth(s); + int sh = g2.getFontMetrics().getHeight(); + g2.drawString(s, x + (w - sw) / 2, y + (h - sh) / 2 + g2.getFontMetrics().getAscent()); + } + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + private static String formatXpShort(int xp) { + if (xp >= 1_000_000) return String.format("%.1fM", xp / 1_000_000.0); + if (xp >= 1_000) return String.format("%.1fk", xp / 1_000.0); + return Integer.toString(xp); + } + + private static String formatXpFull(int xp) { + return java.text.NumberFormat.getIntegerInstance().format(xp); + } + + private static String humanAgo(long ms) { + long sec = ms / 1000; + if (sec < 60) return sec + "s"; + long min = sec / 60; + if (min < 60) return min + "m"; + long hr = min / 60; + return hr + "h"; + } + + private static final class WindowChoice { + final String label; + final long windowMs; + + WindowChoice(String label, long windowMs) { + this.label = label; + this.windowMs = windowMs; + } + @Override public String toString() { return label; } + } + + private static class SkillRenderer extends javax.swing.DefaultListCellRenderer { + @Override + public java.awt.Component getListCellRendererComponent(javax.swing.JList list, Object value, + int index, boolean isSelected, boolean cellHasFocus) { + super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + if (value instanceof Skill) { + String n = ((Skill) value).getName(); + setText(Character.toUpperCase(n.charAt(0)) + n.substring(1).toLowerCase()); + } + setHorizontalAlignment(SwingConstants.LEFT); + return this; + } + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/poller/GameStatePoller.java b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/poller/GameStatePoller.java new file mode 100644 index 0000000000..18a9c375da --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/poller/GameStatePoller.java @@ -0,0 +1,557 @@ +package net.runelite.client.plugins.microbot.microbotdashboardplus.poller; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Client; +import net.runelite.api.GameState; +import net.runelite.api.NPC; +import net.runelite.api.Player; +import net.runelite.api.Skill; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.microbot.BlockingEventManager; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.microbotdashboardplus.data.PollSnapshot; +import net.runelite.client.plugins.microbot.microbotdashboardplus.data.XpHistory; +import net.runelite.client.plugins.microbot.microbotdashboardplus.notify.AlertManager; +import net.runelite.client.plugins.microbot.microbotdashboardplus.notify.DiscordNotifier; +import net.runelite.client.plugins.microbot.util.antiban.Rs2AntibanSettings; +import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; + +import javax.swing.SwingUtilities; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +/** + * Background poller that builds a {@link PollSnapshot} on a fixed cadence and + * notifies listeners on the EDT. Reads player state, skills, inventory, nearby + * NPCs, active scripts, and the in-process antiban flags. + */ +@Slf4j +public class GameStatePoller { + + /** Package prefix used to identify Microbot Hub plugins. */ + private static final String MICROBOT_PACKAGE_PREFIX = "net.runelite.client.plugins.microbot."; + + /** + * Catalog of known random-event NPC names. Matched case-insensitively + * against NPC.getName() to flag NearbyNpc.randomEvent=true so the panel + * renderer highlights them orange. Both wiki-disambiguation spellings are + * listed (Bee keeper / Beekeeper, Dr Jekyll / Dr. Jekyll). + */ + private static final Set RANDOM_EVENT_NPC_NAMES = new HashSet<>(Arrays.asList( + "genie", "sandwich lady", "drunken dwarf", "mysterious old man", + "bee keeper", "beekeeper", "count check", + "frog prince", "frog princess", "rick turpentine", + "dr jekyll", "dr. jekyll", + "niles", "miles", "giles", // Mime event NPCs + "freaky forester", "prison pete", // deferred-engagement events + "evil bob", "leo", "pillory guard", "tilt" // teleport-event NPCs + )); + + /** + * Substrings (lower-cased) that mark a plugin as infrastructure rather + * than a user-facing script. Checked against both the display name and + * the simple class name. Lower-cased comparison. + * + *

Captures: Antiban settings, the dashboard itself, Web Walker (utility + * called by other scripts, not run standalone), MInventory Setups + * (configuration helper), test harnesses, and the bare "Microbot" core + * plugin. + */ + private static final String[] INFRA_NAME_SUBSTRINGS = { + "antiban", + "harness", + "web walker", // covers "[M] Web Walker" and any package "WebWalker*" + "webwalker", // covers simple class names without spaces + "minventory", // "[M] MInventory Setups" + "microbot dashboard plus", // the dashboard itself + "test runner", // dev-infrastructure: shouldn't appear as a user script + "testrunner" + }; + + /** + * Bare class simple names always excluded (defense in depth). Distinct + * from display matching since some plugins do not set display names. + */ + private static final Set EXCLUDED_SIMPLE_CLASS_NAMES = new HashSet<>(Arrays.asList( + "MicrobotPlugin", + "MicrobotDashboardPlusPlugin" + )); + + private static boolean isInfrastructurePlugin(String displayName, String simpleClassName) { + String d = displayName == null ? "" : displayName.toLowerCase(); + String c = simpleClassName == null ? "" : simpleClassName.toLowerCase(); + if (EXCLUDED_SIMPLE_CLASS_NAMES.contains(simpleClassName)) return true; + // Exact equals for bare "Microbot" (avoid false positives in containing strings). + if ("microbot".equals(d)) return true; + for (String s : INFRA_NAME_SUBSTRINGS) { + if (d.contains(s) || c.contains(s)) return true; + } + return false; + } + + private final XpHistory xpHistory = new XpHistory(); + private final List> listeners = new CopyOnWriteArrayList<>(); + + /** Class name -> first observed enabled-millis. Resets when plugin disables. */ + private final Map pluginStartMillis = new HashMap<>(); + + /** Per-skill last-observed level for level-up detection. */ + private final Map lastSkillLevels = new EnumMap<>(Skill.class); + private boolean skillBaselineEstablished = false; + + private DiscordNotifier notifier; + private AlertManager alertManager; + private boolean notifyLevelUp = true; + private boolean notifyAlerts = true; + + /** + * Optional UI-side alert callback. Fired with a short message ("Mining + * reached level 60") whenever an alert threshold crosses, regardless of + * Discord configuration. DashboardWindow registers one to drive the + * in-window banner. + */ + private Consumer bannerCallback; + + private ScheduledExecutorService executor; + private ScheduledFuture scheduledTask; + private volatile PollSnapshot lastSnapshot = PollSnapshot.empty(); + private volatile int pollIntervalSeconds = 5; + private volatile int npcMaxDistance = 20; + + public void start(int pollIntervalSeconds) { + this.pollIntervalSeconds = Math.max(1, pollIntervalSeconds); + if (executor != null) return; + executor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "MicrobotDashboardPlus-Poller"); + t.setDaemon(true); + return t; + }); + scheduledTask = executor.scheduleAtFixedRate(this::tickSafely, 0, this.pollIntervalSeconds, TimeUnit.SECONDS); + log.info("MicrobotDashboardPlus poller started (interval={}s)", this.pollIntervalSeconds); + } + + public void stop() { + if (scheduledTask != null) { + scheduledTask.cancel(false); + scheduledTask = null; + } + if (executor != null) { + executor.shutdownNow(); + executor = null; + } + pluginStartMillis.clear(); + log.info("MicrobotDashboardPlus poller stopped"); + } + + public void addListener(Consumer listener) { + listeners.add(listener); + SwingUtilities.invokeLater(() -> listener.accept(lastSnapshot)); + } + + public void removeListener(Consumer listener) { + listeners.remove(listener); + } + + public PollSnapshot getLastSnapshot() { return lastSnapshot; } + public XpHistory getXpHistory() { return xpHistory; } + + public void setNpcMaxDistance(int distance) { + this.npcMaxDistance = Math.max(1, Math.min(200, distance)); + } + public int getNpcMaxDistance() { return npcMaxDistance; } + + public void setNotifier(DiscordNotifier notifier) { this.notifier = notifier; } + public void setAlertManager(AlertManager alertManager) { this.alertManager = alertManager; } + public void setNotificationToggles(boolean levelUp, boolean alerts) { + this.notifyLevelUp = levelUp; + this.notifyAlerts = alerts; + } + public void setAlertThresholds(String csv) { + if (alertManager != null) alertManager.setThresholdsFromConfig(csv); + } + public void setBannerCallback(Consumer bannerCallback) { + this.bannerCallback = bannerCallback; + } + + public void refreshNow() { + // Capture locally: stop() can null the field between the check and the submit. + ScheduledExecutorService ex = executor; + if (ex != null && !ex.isShutdown()) { + ex.submit(this::tickSafely); + } + } + + private void tickSafely() { + try { + PollSnapshot snapshot = buildSnapshot(); + lastSnapshot = snapshot; + detectAndFireNotifications(snapshot); + notifyListeners(snapshot); + } catch (Throwable t) { + log.warn("Poll iteration failed: {}", t.getMessage(), t); + } + } + + // --------------------------------------------------------------------- + // Notification triggers + // --------------------------------------------------------------------- + + private void detectAndFireNotifications(PollSnapshot snapshot) { + if (snapshot == null) return; + + // Level-up detection. Establish baseline only after we're logged in, + // otherwise the login-screen 0 → real-level jump on first poll after + // login fires a "Level up: Attack 0 -> 43" notification for every skill. + Map currentLevels = snapshot.getSkillLevels(); + if (currentLevels != null && !currentLevels.isEmpty()) { + if (!skillBaselineEstablished) { + if (snapshot.isLoggedIn()) { + lastSkillLevels.putAll(currentLevels); + skillBaselineEstablished = true; + } + // Skip notifications until we have a real baseline. + } else { + for (Map.Entry e : currentLevels.entrySet()) { + Skill skill = e.getKey(); + int newLevel = e.getValue() == null ? 0 : e.getValue(); + Integer prev = lastSkillLevels.get(skill); + if (prev != null && newLevel > prev) { + onLevelUp(skill, prev, newLevel); + } + lastSkillLevels.put(skill, newLevel); + } + } + } + + } + + private void onLevelUp(Skill skill, int from, int to) { + String skillName = capitalize(skill.getName()); + + // Alert threshold crossings take priority + use a louder prefix. + boolean alertFired = false; + if (alertManager != null && alertManager.checkCrossing(skill, to)) { + alertFired = true; + // thresholdFor is a separate call; if the config changed in between it can + // return null, so fall back to the level that triggered the crossing. + Integer threshold = alertManager.thresholdFor(skill); + String alertMsg = skillName + " reached level " + (threshold != null ? threshold : to) + "!"; + if (notifyAlerts && notifier != null) { + notifier.send("ALERT: " + alertMsg); + } + // Always fire the UI banner on a threshold crossing, even if + // Discord is off or not configured. + if (bannerCallback != null) { + try { bannerCallback.accept(alertMsg); } + catch (Throwable t) { log.debug("Banner callback threw: {}", t.getMessage()); } + } + } + if (!alertFired && notifyLevelUp && notifier != null) { + notifier.send("Level up: " + skillName + " " + from + " -> " + to); + } + } + + private static String capitalize(String s) { + if (s == null || s.isEmpty()) return s; + return Character.toUpperCase(s.charAt(0)) + s.substring(1).toLowerCase(); + } + + private void notifyListeners(PollSnapshot snapshot) { + SwingUtilities.invokeLater(() -> { + for (Consumer l : listeners) { + try { l.accept(snapshot); } + catch (Throwable t) { log.warn("Listener threw: {}", t.getMessage(), t); } + } + }); + } + + // --------------------------------------------------------------------- + // Snapshot construction + // --------------------------------------------------------------------- + + private PollSnapshot buildSnapshot() { + Client client = Microbot.getClient(); + if (client == null) return PollSnapshot.empty(); + + // Inventory + active-scripts/plus-plugins lists must read on the + // client thread for safety (Rs2Inventory hits widgets / item containers). + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + PollSnapshot.PollSnapshotBuilder b = PollSnapshot.builder() + .timestampMillis(System.currentTimeMillis()); + + GameState gs = client.getGameState(); + boolean loggedIn = (gs == GameState.LOGGED_IN); + b.loggedIn(loggedIn); + b.gameState(gs == null ? "--" : gs.name()); + + Player local = client.getLocalPlayer(); + if (local != null) { + b.playerName(safe(local.getName())); + b.combatLevel(local.getCombatLevel()); + WorldPoint wp = local.getWorldLocation(); + b.positionText(wp == null ? "--" : (wp.getX() + "," + wp.getY() + "," + wp.getPlane())); + int anim = local.getAnimation(); + b.animationText(anim < 0 ? "idle" : Integer.toString(anim)); + } else { + b.playerName("--").positionText("--").animationText("--"); + } + + b.worldId(client.getWorld()); + b.profileName(profileName()); + + // Skills. Record XP samples only while logged in; on the login + // screen client.getSkillExperience returns 0, which would otherwise + // become the baseline and inflate every delta after login. + Map xp = new EnumMap<>(Skill.class); + Map levels = new EnumMap<>(Skill.class); + for (Skill s : Skill.values()) { + if (s == Skill.OVERALL) continue; + int currentXp = client.getSkillExperience(s); + xp.put(s, currentXp); + levels.put(s, client.getRealSkillLevel(s)); + if (loggedIn) { + xpHistory.record(s, currentXp); + } + } + b.skillXp(Collections.unmodifiableMap(xp)); + b.skillLevels(Collections.unmodifiableMap(levels)); + + b.inventory(collectInventory(loggedIn)); + b.nearbyNpcs(collectNearbyNpcs(client, local)); + b.activeScripts(collectActiveScripts()); + b.antibanState(collectAntibanState()); + + return b.build(); + }).orElse(PollSnapshot.empty()); + } + + private static String safe(String s) { return s == null ? "--" : s; } + + private static String profileName() { + try { return safe(Microbot.getConfigManager().getRSProfileKey()); } + catch (Throwable t) { return "--"; } + } + + // --------------------------------------------------------------------- + // Inventory + // --------------------------------------------------------------------- + + private List collectInventory(boolean loggedIn) { + if (!loggedIn) return Collections.emptyList(); + try { + List items = Rs2Inventory.items().collect(Collectors.toList()); + if (items.isEmpty()) return Collections.emptyList(); + + List out = new ArrayList<>(items.size()); + for (Rs2ItemModel item : items) { + if (item == null) continue; + out.add(PollSnapshot.InventoryItem.builder() + .slot(item.getSlot()) + .itemId(item.getId()) + .name(safe(item.getName())) + .quantity(item.getQuantity()) + .noted(item.isNoted()) + .build()); + } + return Collections.unmodifiableList(out); + } catch (Throwable t) { + log.debug("collectInventory failed: {}", t.getMessage()); + return Collections.emptyList(); + } + } + + // --------------------------------------------------------------------- + // NPCs + // --------------------------------------------------------------------- + + private List collectNearbyNpcs(Client client, Player local) { + if (local == null) return Collections.emptyList(); + WorldPoint playerWp = local.getWorldLocation(); + if (playerWp == null) return Collections.emptyList(); + + List out = new ArrayList<>(); + for (NPC npc : client.getNpcs()) { + if (npc == null) continue; + WorldPoint npcWp = npc.getWorldLocation(); + if (npcWp == null) continue; + int dist = playerWp.distanceTo(npcWp); + if (dist > npcMaxDistance) continue; + + String name = safe(npc.getName()); + boolean isRandomEvent = RANDOM_EVENT_NPC_NAMES.contains(name.toLowerCase()); + + out.add(PollSnapshot.NearbyNpc.builder() + .name(name) + .combatLevel(npc.getCombatLevel()) + .distance(dist) + .randomEvent(isRandomEvent) + .build()); + } + out.sort((a, b) -> Integer.compare(a.getDistance(), b.getDistance())); + return Collections.unmodifiableList(out); + } + + // --------------------------------------------------------------------- + // Scripts + runtime tracking + // --------------------------------------------------------------------- + + /** + * Active scripts: enabled Microbot Hub plugins minus a static exclusion + * set (Antiban, Microbot core utility, the dashboard itself). Per-plugin + * runtime is tracked from the first time we observe each plugin enabled. + */ + private List collectActiveScripts() { + try { + long now = System.currentTimeMillis(); + Set seenThisTick = new HashSet<>(); + List out = new ArrayList<>(); + + for (Plugin p : Microbot.getPluginManager().getPlugins()) { + if (p == null) continue; + if (!Microbot.isPluginEnabled(p.getClass())) continue; + if (!p.getClass().getName().startsWith(MICROBOT_PACKAGE_PREFIX)) continue; + + String simpleName = p.getClass().getSimpleName(); + String displayName = p.getName(); + if (displayName == null || displayName.isEmpty()) displayName = simpleName; + + if (isInfrastructurePlugin(displayName, simpleName)) continue; + + String className = p.getClass().getName(); + seenThisTick.add(className); + long startMs = pluginStartMillis.computeIfAbsent(className, k -> now); + long runtimeMs = Math.max(0, now - startMs); + + out.add(PollSnapshot.ScriptStatus.builder() + .pluginClassName(className) + .displayName(displayName) + .status("Running") + .runtimeMillis(runtimeMs) + .build()); + } + + // Reset runtimes for plugins that disabled since last tick. + pluginStartMillis.keySet().retainAll(seenThisTick); + + out.sort((a, b) -> a.getDisplayName().compareToIgnoreCase(b.getDisplayName())); + return Collections.unmodifiableList(out); + } catch (Throwable t) { + log.debug("collectActiveScripts failed: {}", t.getMessage()); + return Collections.emptyList(); + } + } + + // --------------------------------------------------------------------- + // Antiban + pause state + // --------------------------------------------------------------------- + + /** + * Reads the in-process antiban and pause flags so a user can tell a silent + * stall (everything idle, nothing intentional) from a deliberate anti-AFK + * pause (a micro break or action cooldown is holding the script). + * + *

Reads the static {@link Rs2AntibanSettings} flags, the global + * {@link Microbot#pauseAllScripts} switch, and the registered + * blocking-event handlers. The "blocking event running" flag has no public + * getter, so it is read by reflection and reported as unknown (null) when + * that fails on the running client version. + */ + private PollSnapshot.AntibanState collectAntibanState() { + try { + boolean antibanEnabled = Rs2AntibanSettings.antibanEnabled; + boolean cooldown = Rs2AntibanSettings.actionCooldownActive; + boolean microBreak = Rs2AntibanSettings.microBreakActive; + boolean takeMicroBreaks = Rs2AntibanSettings.takeMicroBreaks; + + boolean allPaused = false; + try { allPaused = Microbot.pauseAllScripts != null && Microbot.pauseAllScripts.get(); } + catch (Throwable t) { log.debug("read pauseAllScripts failed: {}", t.getMessage()); } + + int blockingCount = 0; + Boolean blockingRunning = null; + try { + BlockingEventManager mgr = Microbot.getBlockingEventManager(); + if (mgr != null) { + try { + java.util.List events = mgr.getEvents(); + blockingCount = events == null ? 0 : events.size(); + } catch (Throwable t) { + log.debug("read blocking events failed: {}", t.getMessage()); + } + blockingRunning = readBlockingEventRunning(mgr); + } + } catch (Throwable t) { + log.debug("read blocking event manager failed: {}", t.getMessage()); + } + + String summary = buildAntibanSummary(allPaused, microBreak, cooldown, + Boolean.TRUE.equals(blockingRunning), antibanEnabled); + + return PollSnapshot.AntibanState.builder() + .antibanEnabled(antibanEnabled) + .actionCooldownActive(cooldown) + .microBreakActive(microBreak) + .takeMicroBreaks(takeMicroBreaks) + .allScriptsPaused(allPaused) + .blockingEventCount(blockingCount) + .blockingEventRunning(blockingRunning) + .summary(summary) + .build(); + } catch (Throwable t) { + log.debug("collectAntibanState failed: {}", t.getMessage()); + return PollSnapshot.AntibanState.builder() + .antibanEnabled(false).summary("unavailable").build(); + } + } + + /** + * The BlockingEventManager keeps its "is an event executing right now" flag + * private with no public getter. Probe it by reflection so we can surface a + * running blocker, and return null (unknown) when the field is absent or + * unreadable on this client version. + */ + private static Boolean readBlockingEventRunning(Object manager) { + try { + java.lang.reflect.Field f = manager.getClass().getDeclaredField("isRunning"); + f.setAccessible(true); + Object v = f.get(manager); + if (v instanceof java.util.concurrent.atomic.AtomicBoolean) { + return ((java.util.concurrent.atomic.AtomicBoolean) v).get(); + } + if (v instanceof Boolean) { + return (Boolean) v; + } + return null; + } catch (Throwable t) { + return null; + } + } + + /** Plain one-line reason for the current hold. Highest-priority cause wins. */ + private static String buildAntibanSummary(boolean allPaused, boolean microBreak, + boolean cooldown, boolean blockingRunning, + boolean antibanEnabled) { + if (allPaused) return "All scripts paused"; + if (microBreak) return "Micro break in progress"; + if (cooldown) return "Action cooldown"; + if (blockingRunning) return "Handling a blocking event"; + if (!antibanEnabled) return "Running (antiban off)"; + return "Running"; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/window/DashboardWindow.java b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/window/DashboardWindow.java new file mode 100644 index 0000000000..cf37a742d2 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/microbotdashboardplus/window/DashboardWindow.java @@ -0,0 +1,419 @@ +package net.runelite.client.plugins.microbot.microbotdashboardplus.window; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.client.plugins.microbot.microbotdashboardplus.MicrobotDashboardPlusConfig; +import net.runelite.client.plugins.microbot.microbotdashboardplus.data.PollSnapshot; +import net.runelite.client.plugins.microbot.microbotdashboardplus.panels.AntibanStatePanel; +import net.runelite.client.plugins.microbot.microbotdashboardplus.panels.DashboardSection; +import net.runelite.client.plugins.microbot.microbotdashboardplus.panels.EventLogPanel; +import net.runelite.client.plugins.microbot.microbotdashboardplus.panels.GuidePanel; +import net.runelite.client.plugins.microbot.microbotdashboardplus.panels.InventoryPanel; +import net.runelite.client.plugins.microbot.microbotdashboardplus.panels.NearbyNpcsPanel; +import net.runelite.client.plugins.microbot.microbotdashboardplus.panels.PlayerPanel; +import net.runelite.client.plugins.microbot.microbotdashboardplus.panels.ScriptsPanel; +import net.runelite.client.plugins.microbot.microbotdashboardplus.panels.SkillsPanel; +import net.runelite.client.plugins.microbot.microbotdashboardplus.panels.XpChartPanel; +import net.runelite.client.plugins.microbot.microbotdashboardplus.poller.GameStatePoller; +import net.runelite.client.ui.ColorScheme; +import net.runelite.client.ui.FontManager; + +import net.runelite.client.plugins.microbot.Microbot; + +import javax.swing.BoxLayout; +import javax.swing.JButton; +import javax.swing.JFrame; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.SwingUtilities; +import javax.swing.WindowConstants; +import javax.swing.border.EmptyBorder; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.awt.event.ComponentAdapter; +import java.awt.event.ComponentEvent; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * Floating Swing window that hosts all dashboard sections. + * + *

Mirrors the RuneLite Var Inspector pattern: a top-level JFrame + * independent of the client window. Lifecycle is managed by the plugin; the + * window closes via {@link WindowConstants#HIDE_ON_CLOSE} so the sidebar + * "Open Dashboard" button can re-show it. + * + *

Layout: 2-column GridBagLayout for the section grid, plus 3 full-width + * sections (XP Over Time, Event Log, Guide) that span both columns. + */ +@Slf4j +public class DashboardWindow extends JFrame { + + private static final DateTimeFormatter TIME_FMT = DateTimeFormatter + .ofPattern("HH:mm:ss") + .withZone(ZoneId.systemDefault()); + + private final GameStatePoller poller; + private final MicrobotDashboardPlusConfig config; + private final Consumer snapshotListener; + private final List sections = new ArrayList<>(); + + /** Map of section -> the predicate that decides if it's currently visible. */ + private final java.util.Map visibilityPredicates = + new java.util.LinkedHashMap<>(); + + private final JLabel statusLabel = new JLabel("Connecting..."); + private final JLabel lastPollLabel = new JLabel("Last poll: never"); + + /** Alert banner: yellow strip at top, hidden by default, shown when threshold crosses. */ + private JPanel alertBanner; + private JLabel alertBannerText; + + private static final String CONFIG_GROUP = "MicrobotDashboardPlus"; + private static final String K_WIN_X = "windowX"; + private static final String K_WIN_Y = "windowY"; + private static final String K_WIN_W = "windowWidth"; + private static final String K_WIN_H = "windowHeight"; + + public DashboardWindow(GameStatePoller poller, MicrobotDashboardPlusConfig config) { + super("Microbot Dashboard Plus"); + this.poller = poller; + this.config = config; + + setDefaultCloseOperation(WindowConstants.HIDE_ON_CLOSE); + setMinimumSize(new Dimension(900, 600)); + + restoreWindowBounds(); + installBoundsPersistenceListener(); + + JPanel root = new JPanel(new BorderLayout()); + root.setBackground(ColorScheme.DARK_GRAY_COLOR); + root.setBorder(new EmptyBorder(8, 12, 8, 12)); + + // North = header + (hidden) alert banner stacked vertically. + JPanel northContainer = new JPanel(); + northContainer.setLayout(new BoxLayout(northContainer, BoxLayout.Y_AXIS)); + northContainer.setOpaque(false); + northContainer.add(buildHeader()); + northContainer.add(buildAlertBanner()); + root.add(northContainer, BorderLayout.NORTH); + + root.add(buildSectionScroll(), BorderLayout.CENTER); + root.add(buildFooter(), BorderLayout.SOUTH); + + setContentPane(root); + + // Wire poller banner callback once everything's built. + poller.setBannerCallback(this::showAlertBanner); + + snapshotListener = this::applySnapshot; + poller.addListener(snapshotListener); + } + + /** Re-evaluate each section's visibility predicate. Call when config changes. */ + public void applyVisibility() { + SwingUtilities.invokeLater(() -> { + for (java.util.Map.Entry e + : visibilityPredicates.entrySet()) { + boolean visible = true; + try { visible = e.getValue().getAsBoolean(); } + catch (Throwable t) { /* defensive */ } + e.getKey().setVisible(visible); + } + revalidate(); + repaint(); + }); + } + + public void showOrFocus() { + SwingUtilities.invokeLater(() -> { + if (!isVisible()) setVisible(true); + setState(JFrame.NORMAL); + toFront(); + requestFocus(); + }); + } + + // --------------------------------------------------------------------- + // Bounds persistence + // --------------------------------------------------------------------- + + private void restoreWindowBounds() { + Integer x = readInt(K_WIN_X); + Integer y = readInt(K_WIN_Y); + Integer w = readInt(K_WIN_W); + Integer h = readInt(K_WIN_H); + if (w == null || h == null || w < 600 || h < 400) { + // No saved bounds (or sanity-fail) -- use defaults. + setSize(1100, 800); + setLocationRelativeTo(null); + return; + } + setSize(w, h); + if (x != null && y != null && isOnVisibleScreen(x, y, w, h)) { + setLocation(x, y); + } else { + setLocationRelativeTo(null); + } + } + + private static boolean isOnVisibleScreen(int x, int y, int w, int h) { + try { + java.awt.Rectangle visible = new java.awt.Rectangle(); + for (java.awt.GraphicsDevice gd : java.awt.GraphicsEnvironment + .getLocalGraphicsEnvironment().getScreenDevices()) { + visible = visible.union(gd.getDefaultConfiguration().getBounds()); + } + // Require at least 100x100 of the saved window to land inside any monitor. + java.awt.Rectangle saved = new java.awt.Rectangle(x, y, Math.max(100, w), Math.max(100, h)); + return visible.intersects(saved); + } catch (Throwable t) { + return false; + } + } + + private void installBoundsPersistenceListener() { + addComponentListener(new ComponentAdapter() { + @Override + public void componentMoved(ComponentEvent e) { saveBounds(); } + @Override + public void componentResized(ComponentEvent e) { saveBounds(); } + }); + } + + private void saveBounds() { + try { + writeInt(K_WIN_X, getX()); + writeInt(K_WIN_Y, getY()); + writeInt(K_WIN_W, getWidth()); + writeInt(K_WIN_H, getHeight()); + } catch (Throwable t) { + log.debug("saveBounds failed: {}", t.getMessage()); + } + } + + private static Integer readInt(String key) { + try { + String raw = Microbot.getConfigManager().getConfiguration(CONFIG_GROUP, key); + return raw == null || raw.isEmpty() ? null : Integer.parseInt(raw); + } catch (Throwable t) { + return null; + } + } + + private static void writeInt(String key, int value) { + try { + Microbot.getConfigManager().setConfiguration(CONFIG_GROUP, key, Integer.toString(value)); + } catch (Throwable ignored) { + // ConfigManager not yet ready in some lifecycle edge cases; swallow. + } + } + + public void disposeWindow() { + poller.removeListener(snapshotListener); + for (DashboardSection s : sections) { + try { s.detach(); } catch (Throwable ignored) { /* best effort */ } + } + SwingUtilities.invokeLater(() -> { + setVisible(false); + dispose(); + }); + } + + // --------------------------------------------------------------------- + // Layout + // --------------------------------------------------------------------- + + private JPanel buildHeader() { + JPanel header = new JPanel(new BorderLayout()); + header.setBackground(ColorScheme.DARKER_GRAY_COLOR); + header.setBorder(new EmptyBorder(6, 10, 6, 10)); + + JLabel title = new JLabel("Microbot Dashboard Plus"); + title.setForeground(ColorScheme.BRAND_ORANGE); + title.setFont(FontManager.getRunescapeBoldFont()); + header.add(title, BorderLayout.WEST); + + JPanel right = new JPanel(new FlowLayout(FlowLayout.RIGHT, 10, 0)); + right.setOpaque(false); + statusLabel.setForeground(ColorScheme.PROGRESS_INPROGRESS_COLOR); + statusLabel.setFont(FontManager.getRunescapeSmallFont()); + lastPollLabel.setForeground(Color.LIGHT_GRAY); + lastPollLabel.setFont(FontManager.getRunescapeSmallFont()); + right.add(statusLabel); + right.add(lastPollLabel); + header.add(right, BorderLayout.EAST); + + return header; + } + + private JScrollPane buildSectionScroll() { + JPanel sectionGrid = new JPanel(new GridBagLayout()); + sectionGrid.setBackground(ColorScheme.DARK_GRAY_COLOR); + sectionGrid.setBorder(new EmptyBorder(8, 0, 8, 0)); + + // Build real panels. + PlayerPanel player = new PlayerPanel(poller); + ScriptsPanel scripts = new ScriptsPanel(poller); + InventoryPanel inventory = new InventoryPanel(poller); + SkillsPanel skills = new SkillsPanel(poller); + NearbyNpcsPanel npcs = new NearbyNpcsPanel(poller); + AntibanStatePanel antiban = new AntibanStatePanel(poller); + EventLogPanel eventLog = new EventLogPanel(poller); + XpChartPanel xpChart = new XpChartPanel(poller); + GuidePanel guide = new GuidePanel(poller); + + sections.add(player); + sections.add(scripts); + sections.add(inventory); + sections.add(skills); + sections.add(npcs); + sections.add(antiban); + sections.add(xpChart); + sections.add(eventLog); + sections.add(guide); + + // Wire each section to its config-driven visibility predicate. + visibilityPredicates.put(player, config::showPlayer); + visibilityPredicates.put(scripts, config::showActiveScripts); + visibilityPredicates.put(inventory, config::showInventory); + visibilityPredicates.put(skills, config::showSkills); + visibilityPredicates.put(npcs, config::showNearbyNpcs); + visibilityPredicates.put(antiban, config::showAntibanState); + visibilityPredicates.put(xpChart, config::showXpChart); + visibilityPredicates.put(eventLog, config::showEventLog); + visibilityPredicates.put(guide, config::showGuide); + + applyVisibility(); + + // 2-column grid with 3 full-width spans. + GridBagConstraints c = new GridBagConstraints(); + c.fill = GridBagConstraints.BOTH; + c.insets = new Insets(4, 4, 4, 4); + c.weightx = 1.0; + c.weighty = 0; + + addSection(sectionGrid, player, c, 0, 0, 1); + addSection(sectionGrid, scripts, c, 1, 0, 1); + + addSection(sectionGrid, inventory, c, 0, 1, 1); + addSection(sectionGrid, skills, c, 1, 1, 1); + + addSection(sectionGrid, npcs, c, 0, 2, 1); + addSection(sectionGrid, antiban, c, 1, 2, 1); + + addSection(sectionGrid, xpChart, c, 0, 3, 2); + addSection(sectionGrid, eventLog, c, 0, 4, 2); + addSection(sectionGrid, guide, c, 0, 5, 2); + + // Push everything to the top. + c.gridx = 0; + c.gridy = 6; + c.gridwidth = 2; + c.weighty = 1.0; + JPanel filler = new JPanel(); + filler.setOpaque(false); + sectionGrid.add(filler, c); + + JScrollPane scroll = new JScrollPane(sectionGrid, + JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED, + JScrollPane.HORIZONTAL_SCROLLBAR_NEVER); + scroll.setBorder(null); + scroll.getViewport().setBackground(ColorScheme.DARK_GRAY_COLOR); + scroll.getVerticalScrollBar().setUnitIncrement(16); + return scroll; + } + + private static void addSection(JPanel parent, DashboardSection section, GridBagConstraints c, + int col, int row, int span) { + c.gridx = col; + c.gridy = row; + c.gridwidth = span; + parent.add(section, c); + } + + private JPanel buildAlertBanner() { + alertBanner = new JPanel(new BorderLayout(8, 0)); + alertBanner.setBackground(new Color(0xD4, 0xA0, 0x17)); // RuneLite warning gold + alertBanner.setBorder(new EmptyBorder(6, 12, 6, 8)); + + alertBannerText = new JLabel(""); + alertBannerText.setForeground(new Color(0x1E, 0x1E, 0x1E)); + alertBannerText.setFont(FontManager.getRunescapeBoldFont()); + alertBanner.add(alertBannerText, BorderLayout.CENTER); + + JButton dismiss = new JButton("Dismiss"); + dismiss.setFont(FontManager.getRunescapeSmallFont()); + dismiss.setBackground(new Color(0x66, 0x4D, 0x09)); + dismiss.setForeground(Color.WHITE); + dismiss.setFocusPainted(false); + dismiss.setBorderPainted(false); + dismiss.addActionListener(e -> hideAlertBanner()); + alertBanner.add(dismiss, BorderLayout.EAST); + + alertBanner.setVisible(false); + // Bound the height so the BoxLayout doesn't stretch it. + alertBanner.setMaximumSize(new Dimension(Integer.MAX_VALUE, 32)); + return alertBanner; + } + + /** Called from the poller's banner callback (any thread). Switches to EDT internally. */ + public void showAlertBanner(String message) { + SwingUtilities.invokeLater(() -> { + if (alertBanner == null || alertBannerText == null) return; + alertBannerText.setText("ALERT: " + (message == null ? "Threshold reached" : message)); + alertBanner.setVisible(true); + revalidate(); + repaint(); + }); + } + + public void hideAlertBanner() { + SwingUtilities.invokeLater(() -> { + if (alertBanner == null) return; + alertBanner.setVisible(false); + revalidate(); + repaint(); + }); + } + + private JPanel buildFooter() { + JPanel footer = new JPanel(new FlowLayout(FlowLayout.LEFT)); + footer.setBackground(ColorScheme.DARKER_GRAY_COLOR); + footer.setBorder(new EmptyBorder(4, 10, 4, 10)); + + JLabel info = new JLabel("v" + net.runelite.client.plugins.microbot.microbotdashboardplus.MicrobotDashboardPlusPlugin.version); + info.setForeground(Color.GRAY); + info.setFont(FontManager.getRunescapeSmallFont()); + footer.add(info); + + return footer; + } + + // --------------------------------------------------------------------- + // Listener + // --------------------------------------------------------------------- + + private void applySnapshot(PollSnapshot snapshot) { + if (snapshot == null) return; + + if (snapshot.isLoggedIn()) { + statusLabel.setText("Connected"); + statusLabel.setForeground(ColorScheme.PROGRESS_COMPLETE_COLOR); + } else { + statusLabel.setText("Disconnected"); + statusLabel.setForeground(ColorScheme.PROGRESS_ERROR_COLOR); + } + + lastPollLabel.setText("Last poll: " + TIME_FMT.format(Instant.ofEpochMilli(snapshot.getTimestampMillis()))); + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/motherloadmine/MotherloadMinePlugin.java b/src/main/java/net/runelite/client/plugins/microbot/motherloadmine/MotherloadMinePlugin.java index da326260b1..acadbec918 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/motherloadmine/MotherloadMinePlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/motherloadmine/MotherloadMinePlugin.java @@ -29,7 +29,7 @@ ) public class MotherloadMinePlugin extends Plugin { - static final String version = "1.9.5"; + static final String version = "1.9.6"; @Inject private MotherloadMineConfig config; diff --git a/src/main/java/net/runelite/client/plugins/microbot/motherloadmine/MotherloadMineScript.java b/src/main/java/net/runelite/client/plugins/microbot/motherloadmine/MotherloadMineScript.java index c4584ae674..08d218e9b0 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/motherloadmine/MotherloadMineScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/motherloadmine/MotherloadMineScript.java @@ -127,40 +127,50 @@ private void initialize() private void executeTask() { - if (!super.run() || !Microbot.isLoggedIn()) + // Guard the whole loop body: an unhandled exception on a single tick would otherwise + // silently cancel the scheduleWithFixedDelay task (JDK semantics), freezing the plugin + // on its last status with no log. Catching here lets the next 600ms tick recover state. + try { - resetMiningState(true); - return; - } + if (!super.run() || !Microbot.isLoggedIn()) + { + resetMiningState(true); + return; + } - determineStatusFromInventory(); - logStatusTransitionIfChanged(); + determineStatusFromInventory(); + logStatusTransitionIfChanged(); - switch (status) + switch (status) + { + case IDLE: + break; + case MINING: + Rs2Antiban.setActivityIntensity(Rs2Antiban.getActivity().getActivityIntensity()); + handleMining(); + break; + case EMPTY_SACK: + if (Rs2Player.isAnimating()) return; + Rs2Antiban.setActivityIntensity(ActivityIntensity.EXTREME); + emptySack(); + break; + case FIXING_WATERWHEEL: + if (Rs2Player.isAnimating()) return; + fixWaterwheel(); + break; + case DEPOSIT_HOPPER: + if (Rs2Player.isAnimating()) return; + depositHopper(); + break; + case DROP_GEMS: + if (Rs2Player.isAnimating()) return; + dropGems(); + break; + } + } + catch (Exception ex) { - case IDLE: - break; - case MINING: - Rs2Antiban.setActivityIntensity(Rs2Antiban.getActivity().getActivityIntensity()); - handleMining(); - break; - case EMPTY_SACK: - if (Rs2Player.isAnimating()) return; - Rs2Antiban.setActivityIntensity(ActivityIntensity.EXTREME); - emptySack(); - break; - case FIXING_WATERWHEEL: - if (Rs2Player.isAnimating()) return; - fixWaterwheel(); - break; - case DEPOSIT_HOPPER: - if (Rs2Player.isAnimating()) return; - depositHopper(); - break; - case DROP_GEMS: - if (Rs2Player.isAnimating()) return; - dropGems(); - break; + log.error("MLM executeTask error; recovering on next tick", ex); } } diff --git a/src/main/resources/net/runelite/client/plugins/microbot/attackrangesplus/docs/README.md b/src/main/resources/net/runelite/client/plugins/microbot/attackrangesplus/docs/README.md new file mode 100644 index 0000000000..631736cf5a --- /dev/null +++ b/src/main/resources/net/runelite/client/plugins/microbot/attackrangesplus/docs/README.md @@ -0,0 +1,64 @@ +# Attack Ranges Plus + +![preview](assets/card.png) + +Attack Ranges Plus draws a live ground outline showing every tile you can actually reach with your current weapon and attack style, clipped to line of sight so the shape molds around walls and obstacles. It is part of the Microbot "Plus" suite of overlay and utility plugins. + +--- + +## Feature Overview + +| Feature | Description | +|---------|-------------| +| **Attack style - Auto (detect)** | Reads your equipped weapon and selected attack style to show your exact reach - long-range, autocast spell range, halberds at 2 tiles, melee at 1. Recommended. | +| **Attack style - Melee / Ranged / Magic** | Fixed overrides: Melee forces 1 tile, Ranged forces 7 tiles (representative preview), Magic forces 10 tiles (use this when you click-cast without autocast set). | +| **Line color** | Color of the attack-range outline drawn on the ground. | +| **Fill area** | Shades the tiles inside your attack range. Off by default - the fill is repainted every frame and costs FPS at large ranges such as magic. The outline alone is cheap. | +| **Fill color** | Color and opacity of the shaded area (used only when Fill area is on). | +| **Show overlay** | Controls when the overlay appears: Always, In PvP areas (Wilderness, PvP/Deadman worlds, and PvP-flagged zones), or Wilderness only. | +| **Show target's range** | Also outlines the attack range of the player you are currently fighting, based on their equipped weapon's base reach. | +| **Target line color** | Outline color used for the target's range indicator. | + +--- + +## Requirements + +- Microbot RuneLite client +- No skill level, quest, or item requirements - the overlay works with any weapon and style +- Works in F2P and P2P; PvP-specific visibility modes are useful in the Wilderness, PvP worlds, and Deadman worlds + +--- + +## How It Works + +1. Enable the plugin from the Microbot plugin list and configure your preferred settings. +2. Log in and equip your weapon. With Attack style set to Auto, the plugin reads your weapon and active attack style each frame. +3. The overlay computes the Chebyshev (king-move) attack square from your position, then clips it to only the tiles you have line of sight to - matching where shots and strikes actually connect. +4. The resulting area is outlined along its outer edge. If Fill area is enabled, the tiles inside are shaded as well. +5. If Show target's range is on and you are interacting with another player, a second outline appears around the tiles they can reach from their position. +6. The outline updates in real time as you move, rotate the camera, or change weapons. + +--- + +## Configuration + +Set **Attack style** to Auto for accurate real-time detection. Use the fixed overrides only when you want to preview a style before switching gear, or when you click-cast spells without an autocast configured (use Magic in that case). + +Set **Show overlay** to "In PvP areas" or "Wilderness only" if you only want the overlay active during PvP content - this keeps your screen clean during PvM. + +**Target's range** is useful for PvP spacing but has limits - see Limitations below. + +--- + +## Limitations + +- **Your own range works everywhere** (PvM and PvP) because it is based on your own equipped weapon and style. The target outline is players only - RuneLite does not expose NPC attack ranges, so the overlay cannot be drawn for monsters without hardcoding per-boss data. +- **Target's range is approximate.** Another player's attack-style varbits are not readable, so the target outline uses their equipped weapon's base reach. It does not account for the long-range modifier and cannot detect whether a staff user is autocasting or click-casting. +- Auto magic detection requires an autocast spell to be set. If you click-cast without autocast, use the Magic style override. +- Line-of-sight is computed in world coordinates and is not designed for instanced areas. This is generally not relevant for Wilderness and PvP use cases. + +--- + +## Credits + +Originally ported from [ry-java/AttackRanges2](https://github.com/ry-java/AttackRanges2) and rebuilt for the Microbot client. diff --git a/src/main/resources/net/runelite/client/plugins/microbot/attackrangesplus/docs/assets/card.png b/src/main/resources/net/runelite/client/plugins/microbot/attackrangesplus/docs/assets/card.png new file mode 100644 index 0000000000..59e31e67af Binary files /dev/null and b/src/main/resources/net/runelite/client/plugins/microbot/attackrangesplus/docs/assets/card.png differ diff --git a/src/main/resources/net/runelite/client/plugins/microbot/attackrangesplus/docs/assets/icon.png b/src/main/resources/net/runelite/client/plugins/microbot/attackrangesplus/docs/assets/icon.png new file mode 100644 index 0000000000..4fc99c94dc Binary files /dev/null and b/src/main/resources/net/runelite/client/plugins/microbot/attackrangesplus/docs/assets/icon.png differ diff --git a/src/main/resources/net/runelite/client/plugins/microbot/bankorganizer/docs/README.md b/src/main/resources/net/runelite/client/plugins/microbot/bankorganizer/docs/README.md new file mode 100644 index 0000000000..2774b76d9c --- /dev/null +++ b/src/main/resources/net/runelite/client/plugins/microbot/bankorganizer/docs/README.md @@ -0,0 +1,7 @@ +# Bank Organizer + +Organizes bank tabs using bank tab CSVs like https://runetags.com/ + +## Setup + +Start with the bank open and your tabs marked as active with valid CSVs. diff --git a/src/main/resources/net/runelite/client/plugins/microbot/bankorganizer/docs/assets/card.png b/src/main/resources/net/runelite/client/plugins/microbot/bankorganizer/docs/assets/card.png new file mode 100644 index 0000000000..9082d1f14e Binary files /dev/null and b/src/main/resources/net/runelite/client/plugins/microbot/bankorganizer/docs/assets/card.png differ diff --git a/src/main/resources/net/runelite/client/plugins/microbot/bankorganizer/docs/assets/icon.png b/src/main/resources/net/runelite/client/plugins/microbot/bankorganizer/docs/assets/icon.png new file mode 100644 index 0000000000..17f533758a Binary files /dev/null and b/src/main/resources/net/runelite/client/plugins/microbot/bankorganizer/docs/assets/icon.png differ diff --git a/src/main/resources/net/runelite/client/plugins/microbot/firemakingplus/docs/README.md b/src/main/resources/net/runelite/client/plugins/microbot/firemakingplus/docs/README.md new file mode 100644 index 0000000000..64876f064a --- /dev/null +++ b/src/main/resources/net/runelite/client/plugins/microbot/firemakingplus/docs/README.md @@ -0,0 +1,70 @@ +# Auto Firemaking Plus + +![preview](assets/card.png) + +Auto Firemaking Plus trains Firemaking on a bank-and-burn loop with two selectable methods, progressive log selection, and a live statistics overlay. It is part of the Microbot "Plus" suite and shares the same stop-condition system and overlay as the other Plus plugins. It is fully F2P friendly. + +--- + +## Feature Overview + +| Feature | Description | +|---------|-------------| +| **Method** | Forester's Campfire (default, AFK) or Line firemaking (higher XP/hr). | +| **Forester's Campfire** | Adds logs to a nearby campfire or fire. If none is found, the bot lights its own with a tinderbox, then burns on it. Once a fire is established you rarely move. | +| **Line firemaking** | Lights a horizontal line of fires stepping west, walks back, banks, and repeats. Needs an open run of tiles. | +| **Log type** | Which logs to burn. Ignored when Progressive is on. | +| **Progressive** | Burns the best logs your current level allows: Logs (1), Oak (15), Willow (30), Teak (35), Maple (45), Mahogany (50), Yew (60), Magic (75), Redwood (90). | +| **Maximize log space** | Campfire method only. When a campfire is already active nearby, the tinderbox is banked so a 28th log is carried; otherwise a tinderbox is kept to light a new fire. | +| **Scan radius** | Line firemaking only. How far around the start tile to search for an open row (range 10 to 50). | +| **Stop conditions** | Stop after minutes, XP gained, or Target level. The bot banks before shutting down. | +| **League mode (anti-AFK)** | Periodically presses a key to reset the idle-logout timer. | +| **Speed mode** | Disables Microbot antiban for faster throughput. More detectable, throwaway accounts only. | +| **Live overlay** | Current level (and levels gained), XP gained, XP/hr, logs burnt, log cost/hr, runtime, target progress, ETA, and a Pause/Resume button. | + +--- + +## Requirements + +- Microbot RuneLite client +- Logs of your chosen type in the bank +- A tinderbox in the bank (used to light a fire when none is nearby) +- A bank to start next to. The Grand Exchange is ideal for the campfire method. +- F2P friendly. No quests or membership required for the standard log types. + +--- + +## How It Works + +1. Stock your bank with the logs you want to burn and a tinderbox. +2. Stand next to a bank. The Grand Exchange is recommended for the campfire method. +3. Set the Method, Log type (or enable Progressive), and any stop conditions, then enable the plugin. +4. Campfire method: the bot finds a nearby campfire or fire, or lights its own, then adds logs until the inventory is empty, banks, and repeats. +5. Line method: the bot finds an open row, lights logs while stepping west, walks back to the start, banks, and repeats. +6. When a stop condition is reached the bot banks its current inventory, then shuts itself down. + +--- + +## Configuration + +**Method** is the main choice. Forester's Campfire is the AFK option and self-sufficient: it lights its own fire if none is present, so it works even when no Forester's Campfire exists nearby. Line firemaking gives higher XP per hour but needs an open area with a clear run of tiles to the west. + +**Log type** and **Progressive** control what you burn. Turn on Progressive to let the bot upgrade logs automatically as your level rises. + +**Maximize log space** squeezes one extra log per trip when a campfire is already available. Banking happens on foot with teleports disabled so the bot never home-teleports away from the bank. + +**Stop conditions** all default to 0 (disabled). Set any combination; the bot stops at the first one met. + +--- + +## Limitations + +- Line firemaking needs an open area with a clear westward run. In crowded or walled spots use the campfire method. +- Forester's Campfires at the Grand Exchange are temporary and burn out. The bot handles this by lighting its own fire when none is found. +- Wintertodt is out of scope; it has a dedicated minigame plugin. + +--- + +## Credits + +Auto Firemaking Plus is a from-scratch Plus build by pjmarz. The line-firemaking logic was adapted from the Microbot leagues firemaking code. diff --git a/src/main/resources/net/runelite/client/plugins/microbot/firemakingplus/docs/assets/card.png b/src/main/resources/net/runelite/client/plugins/microbot/firemakingplus/docs/assets/card.png new file mode 100644 index 0000000000..b7ca143e7b Binary files /dev/null and b/src/main/resources/net/runelite/client/plugins/microbot/firemakingplus/docs/assets/card.png differ diff --git a/src/main/resources/net/runelite/client/plugins/microbot/firemakingplus/docs/assets/icon.png b/src/main/resources/net/runelite/client/plugins/microbot/firemakingplus/docs/assets/icon.png new file mode 100644 index 0000000000..4c7ff18d7a Binary files /dev/null and b/src/main/resources/net/runelite/client/plugins/microbot/firemakingplus/docs/assets/icon.png differ diff --git a/src/main/resources/net/runelite/client/plugins/microbot/microbotdashboardplus/docs/README.md b/src/main/resources/net/runelite/client/plugins/microbot/microbotdashboardplus/docs/README.md new file mode 100644 index 0000000000..5ec1ca54cc --- /dev/null +++ b/src/main/resources/net/runelite/client/plugins/microbot/microbotdashboardplus/docs/README.md @@ -0,0 +1,97 @@ +# Microbot Dashboard Plus + +![preview](assets/dashboard-top.png) + +Microbot Dashboard Plus is a passive monitoring plugin for the Microbot RuneLite client. It opens a floating Swing window outside the game canvas with nine live-updating panels covering your session state, plus a compact sidebar panel for quick access. No HTTP server, no port binding, and no Agent Server dependency - it reads game state in-process. + +--- + +## Feature Overview + +| Feature | Description | +|---------|-------------| +| **Floating dashboard window** | A native Swing window that opens outside the game, keeping your canvas clear of stacked overlays | +| **Sidebar panel** | A compact summary in the RuneLite right toolbar with status, player, world, script count, and quick-launch buttons | +| **Player section** | Name, combat level, login state, game state, world, profile, session duration, tile position, and current animation | +| **Active Scripts section** | All enabled Microbot plugins with per-plugin runtime and a Stop button per row | +| **Inventory section** | Slot grid showing item names and quantities; noted items styled distinctly | +| **Skills section** | All 22 skills with current level, total XP, session gain, rolling 5-minute XP/hr, and an ETA to your target level | +| **Nearby NPCs section** | NPC list sorted by distance; random-event NPCs highlighted orange | +| **Antiban State section** | Tells a silent stall apart from an intentional anti-AFK pause (micro break, action cooldown, global pause, blocking event) | +| **XP Over Time chart** | Java2D line chart with skill and time-window selectors (5m to 24h) | +| **Event Log section** | Rolling 10-entry ring buffer of login, logout, and world-hop events | +| **Discord notifications** | Optional webhook for level-ups, pet drops, alert threshold crossings, and session start/stop | +| **Pet drop alert** | In-dashboard banner plus optional Discord ping when you receive a pet, detected from the funny-feeling game messages | +| **Alert thresholds** | Comma-separated SKILL:LEVEL pairs that fire an in-dashboard banner and optional Discord ping when crossed | +| **Skill targets (ETA)** | Comma-separated SKILL:LEVEL pairs that drive the ETA column in the Skills section | +| **Per-section visibility** | Toggle any of the nine panels on or off; the window updates immediately | +| **Auto-open on enable** | Dashboard window launches automatically when the plugin enables (configurable) | +| **Configurable poll rate** | Refresh interval from 1 to 60 seconds | + +![Dashboard window - bottom half](assets/dashboard-bottom.png) + +--- + +## Requirements + +- Microbot RuneLite client v2.0.13 or newer +- No external dependencies - no HTTP server, no port binding, no other plugins required +- Optional: a Discord channel webhook URL for notifications + +--- + +## How It Works + +1. Enable **Microbot Dashboard Plus** from the plugin list +2. A green chart-line icon appears in the right sidebar toolbar +3. Click the icon to open the sidebar panel, then click **Open Dashboard** to launch the floating window (or enable "Auto-open dashboard on startup" to skip this step) +4. The dashboard polls game state on a background thread at the configured interval and updates all visible panels +5. The floating window remembers its size and position across launches +6. Disabling the plugin removes the sidebar icon, the panel, and the floating window cleanly + +![Plugin settings panel](assets/settings-panel.png) + +--- + +## Configuration + +The plugin config has four sections. + +**Behavior** - controls the window and polling: +- Auto-open dashboard on startup (default ON) - launches the floating window when the plugin enables +- Poll interval in seconds (default 5, range 1-60) - how often to refresh from game state +- Nearby NPCs max distance in tiles (default 20, range 1-200) - filter for the NPC section + +**Layout** - nine boolean toggles, one per panel, all default ON. Untick any section to hide it; the window re-evaluates immediately. + +**Notifications** - requires a Discord webhook URL in the field (masked in the UI, treated as a secret): +- Notify on level-up (default ON) +- Notify on pet drop (default ON; also fires the in-dashboard banner without a webhook) +- Notify on alert threshold crossing (default ON) +- Notify on session start/stop (default OFF) + +**Alerts** - two comma-separated `SKILL:LEVEL` lists: +- Alert thresholds, e.g. `MINING:60, WOODCUTTING:80`. Each threshold fires an in-dashboard banner and optional Discord ping exactly once per session. +- Skill targets (ETA), e.g. `MINING:70, AGILITY:60`. The Skills section shows an ETA to each target from the current XP per hour. A skill with no target still shows an ETA to its next level while it is being trained. + +Skill names follow the OSRS API enum (uppercase). + +--- + +## Limitations + +- This plugin observes and reports only - it does not run game logic, schedule scripts, or make decisions +- Not a remote-view tool - lives in the client process with no HTTP server or LAN access +- Not for vanilla RuneLite users - requires Microbot client APIs +- The Active Scripts list is heuristic: it enumerates enabled plugins rather than reading a dedicated script registry +- The Antiban State section reads the blocking-event running flag by reflection; on a client version where that flag is not accessible it shows the handler count without the live running marker +- Skill ETAs need a non-zero XP per hour to estimate; a skill that is not currently gaining XP shows no ETA unless it has already reached its target + +--- + +## Credits + +- Original dashboard concept and Java port: pjmarz +- Iterative development via Claude Code + +For issues or suggestions, open an issue on the Microbot Hub repository. diff --git a/src/main/resources/net/runelite/client/plugins/microbot/microbotdashboardplus/docs/assets/card.png b/src/main/resources/net/runelite/client/plugins/microbot/microbotdashboardplus/docs/assets/card.png new file mode 100644 index 0000000000..4e17677cf9 Binary files /dev/null and b/src/main/resources/net/runelite/client/plugins/microbot/microbotdashboardplus/docs/assets/card.png differ diff --git a/src/main/resources/net/runelite/client/plugins/microbot/microbotdashboardplus/docs/assets/dashboard-bottom.png b/src/main/resources/net/runelite/client/plugins/microbot/microbotdashboardplus/docs/assets/dashboard-bottom.png new file mode 100644 index 0000000000..f26f1feb76 Binary files /dev/null and b/src/main/resources/net/runelite/client/plugins/microbot/microbotdashboardplus/docs/assets/dashboard-bottom.png differ diff --git a/src/main/resources/net/runelite/client/plugins/microbot/microbotdashboardplus/docs/assets/dashboard-top.png b/src/main/resources/net/runelite/client/plugins/microbot/microbotdashboardplus/docs/assets/dashboard-top.png new file mode 100644 index 0000000000..26411d4081 Binary files /dev/null and b/src/main/resources/net/runelite/client/plugins/microbot/microbotdashboardplus/docs/assets/dashboard-top.png differ diff --git a/src/main/resources/net/runelite/client/plugins/microbot/microbotdashboardplus/docs/assets/icon.png b/src/main/resources/net/runelite/client/plugins/microbot/microbotdashboardplus/docs/assets/icon.png new file mode 100644 index 0000000000..d38c4fd561 Binary files /dev/null and b/src/main/resources/net/runelite/client/plugins/microbot/microbotdashboardplus/docs/assets/icon.png differ diff --git a/src/main/resources/net/runelite/client/plugins/microbot/microbotdashboardplus/docs/assets/settings-panel.png b/src/main/resources/net/runelite/client/plugins/microbot/microbotdashboardplus/docs/assets/settings-panel.png new file mode 100644 index 0000000000..6185b47306 Binary files /dev/null and b/src/main/resources/net/runelite/client/plugins/microbot/microbotdashboardplus/docs/assets/settings-panel.png differ