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:
" +
+ "" +
+ "- Auto-open dashboard on startup: open the floating window when the plugin enables.
" +
+ "- Poll interval (sec): refresh rate from game state (1-60, default 5).
" +
+ "- Nearby NPCs max distance: filter for the NPC panel (1-200 tiles, default 20).
" +
+ "- Layout toggles: nine on/off switches, one per panel (including this Guide).
" +
+ "- Discord webhook URL: paste your channel webhook (field is masked; blank disables Discord).
" +
+ "- Notify on level-up / pet drop / session lifecycle / alert threshold: four independent toggles.
" +
+ "- 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. " +
+ "- Skill targets (ETA): comma-separated
SKILL:LEVEL pairs (e.g. MINING:70, AGILITY:60) that drive the Skills ETA column. " +
+ "
" +
+ "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
+
+
+
+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
+
+
+
+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
+
+
+
+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 |
+
+
+
+---
+
+## 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
+
+
+
+---
+
+## 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