shieldCells = Microbot.getRs2TileObjectCache().query()
.where(o -> o.getName() != null && o.getName().toLowerCase().contains("cell_tile"))
- .toList();
+ .toListOnClientThread();
if (Rs2Inventory.hasItemAmount(GUARDIAN_ESSENCE, 10)) {
for (Rs2TileObjectModel shieldCell : shieldCells) {
diff --git a/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/FireLine.java b/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/FireLine.java
new file mode 100644
index 0000000000..4573b12c1f
--- /dev/null
+++ b/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/FireLine.java
@@ -0,0 +1,17 @@
+package net.runelite.client.plugins.microbot.leaguesfiremaking;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import net.runelite.api.coords.WorldPoint;
+
+@Getter
+@RequiredArgsConstructor
+public class FireLine {
+ private final WorldPoint westEnd;
+ private final WorldPoint eastEnd;
+ private final int length;
+
+ public int getY() {
+ return westEnd.getY();
+ }
+}
diff --git a/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/LeaguesFiremakingConfig.java b/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/LeaguesFiremakingConfig.java
new file mode 100644
index 0000000000..e221422694
--- /dev/null
+++ b/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/LeaguesFiremakingConfig.java
@@ -0,0 +1,76 @@
+package net.runelite.client.plugins.microbot.leaguesfiremaking;
+
+import net.runelite.client.config.Config;
+import net.runelite.client.config.ConfigGroup;
+import net.runelite.client.config.ConfigInformation;
+import net.runelite.client.config.ConfigItem;
+import net.runelite.client.config.ConfigSection;
+import net.runelite.client.config.Range;
+
+@ConfigGroup("LeaguesFiremaking")
+@ConfigInformation("AI Firemaking
" +
+ "Version: " + LeaguesFiremakingPlugin.version + "
" +
+ "Withdraws logs from the bank, finds open space, and lights fires in lines.
" +
+ "Scans surrounding tiles to pick the best row, adapts around existing fires and obstacles.
" +
+ "Supports Bank Heist briefcase for instant banking.
")
+public interface LeaguesFiremakingConfig extends Config {
+
+ @ConfigSection(
+ name = "General",
+ description = "General settings",
+ position = 0
+ )
+ String generalSection = "general";
+
+ @ConfigSection(
+ name = "Banking",
+ description = "Banking settings",
+ position = 1
+ )
+ String bankingSection = "banking";
+
+ @ConfigItem(
+ keyName = "logType",
+ name = "Log type",
+ description = "Which logs to burn",
+ position = 0,
+ section = generalSection
+ )
+ default LogType logType() {
+ return LogType.LOGS;
+ }
+
+ @ConfigItem(
+ keyName = "progressiveMode",
+ name = "Progressive mode",
+ description = "Automatically pick the best log for your Firemaking level",
+ position = 1,
+ section = generalSection
+ )
+ default boolean progressiveMode() {
+ return false;
+ }
+
+ @Range(min = 10, max = 50)
+ @ConfigItem(
+ keyName = "scanRadius",
+ name = "Scan radius",
+ description = "How many tiles to scan around your starting position for open space",
+ position = 2,
+ section = generalSection
+ )
+ default int scanRadius() {
+ return 25;
+ }
+
+ @ConfigItem(
+ keyName = "useBriefcase",
+ name = "Use Bank Heist briefcase",
+ description = "Use the banker's briefcase to teleport to a bank instead of walking (Leagues relic)",
+ position = 0,
+ section = bankingSection
+ )
+ default boolean useBriefcase() {
+ return false;
+ }
+}
diff --git a/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/LeaguesFiremakingOverlay.java b/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/LeaguesFiremakingOverlay.java
new file mode 100644
index 0000000000..de87cab3ab
--- /dev/null
+++ b/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/LeaguesFiremakingOverlay.java
@@ -0,0 +1,57 @@
+package net.runelite.client.plugins.microbot.leaguesfiremaking;
+
+import net.runelite.client.plugins.microbot.Microbot;
+import net.runelite.client.ui.overlay.OverlayPanel;
+import net.runelite.client.ui.overlay.OverlayPosition;
+import net.runelite.client.ui.overlay.components.LineComponent;
+import net.runelite.client.ui.overlay.components.TitleComponent;
+
+import javax.inject.Inject;
+import java.awt.*;
+
+public class LeaguesFiremakingOverlay extends OverlayPanel {
+
+ @Inject
+ private LeaguesFiremakingScript script;
+
+ @Inject
+ public LeaguesFiremakingOverlay() {
+ setPosition(OverlayPosition.TOP_LEFT);
+ setNaughty();
+ }
+
+ @Override
+ public Dimension render(Graphics2D graphics) {
+ panelComponent.setPreferredSize(new Dimension(200, 0));
+
+ panelComponent.getChildren().add(TitleComponent.builder()
+ .text("AI Firemaking v" + LeaguesFiremakingPlugin.version)
+ .color(Color.GREEN)
+ .build());
+
+ panelComponent.getChildren().add(LineComponent.builder()
+ .left("Status")
+ .right(script.getStatus())
+ .build());
+
+ panelComponent.getChildren().add(LineComponent.builder()
+ .left("State")
+ .right(script.getState().name())
+ .build());
+
+ FireLine line = script.getCurrentLine();
+ if (line != null) {
+ panelComponent.getChildren().add(LineComponent.builder()
+ .left("Line length")
+ .right(String.valueOf(line.getLength()))
+ .build());
+ }
+
+ panelComponent.getChildren().add(LineComponent.builder()
+ .left("Microbot")
+ .right(Microbot.status)
+ .build());
+
+ return super.render(graphics);
+ }
+}
diff --git a/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/LeaguesFiremakingPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/LeaguesFiremakingPlugin.java
new file mode 100644
index 0000000000..6b8b1a6ce3
--- /dev/null
+++ b/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/LeaguesFiremakingPlugin.java
@@ -0,0 +1,54 @@
+package net.runelite.client.plugins.microbot.leaguesfiremaking;
+
+import com.google.inject.Provides;
+import lombok.extern.slf4j.Slf4j;
+import net.runelite.client.config.ConfigManager;
+import net.runelite.client.plugins.Plugin;
+import net.runelite.client.plugins.PluginDescriptor;
+import net.runelite.client.plugins.microbot.PluginConstants;
+import net.runelite.client.ui.overlay.OverlayManager;
+
+import javax.inject.Inject;
+
+@PluginDescriptor(
+ name = PluginConstants.DV + "AI Firemaking",
+ description = "Lights fires in lines anywhere — withdraws logs, scans for open tiles, adapts around obstacles",
+ tags = {"firemaking", "leagues", "microbot", "skilling"},
+ version = LeaguesFiremakingPlugin.version,
+ minClientVersion = "2.0.13",
+ enabledByDefault = PluginConstants.DEFAULT_ENABLED,
+ isExternal = PluginConstants.IS_EXTERNAL
+)
+@Slf4j
+public class LeaguesFiremakingPlugin extends Plugin {
+ public static final String version = "1.0.0";
+
+ @Inject
+ private LeaguesFiremakingConfig config;
+
+ @Inject
+ private LeaguesFiremakingScript script;
+
+ @Inject
+ private LeaguesFiremakingOverlay overlay;
+
+ @Inject
+ private OverlayManager overlayManager;
+
+ @Provides
+ LeaguesFiremakingConfig provideConfig(ConfigManager configManager) {
+ return configManager.getConfig(LeaguesFiremakingConfig.class);
+ }
+
+ @Override
+ protected void startUp() {
+ overlayManager.add(overlay);
+ script.run(config);
+ }
+
+ @Override
+ protected void shutDown() {
+ script.shutdown();
+ overlayManager.remove(overlay);
+ }
+}
diff --git a/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/LeaguesFiremakingScript.java b/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/LeaguesFiremakingScript.java
new file mode 100644
index 0000000000..5d34a7860d
--- /dev/null
+++ b/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/LeaguesFiremakingScript.java
@@ -0,0 +1,253 @@
+package net.runelite.client.plugins.microbot.leaguesfiremaking;
+
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import net.runelite.api.Skill;
+import net.runelite.api.coords.WorldPoint;
+import net.runelite.client.plugins.microbot.Microbot;
+import net.runelite.client.plugins.microbot.Script;
+import net.runelite.client.plugins.microbot.util.antiban.Rs2Antiban;
+import net.runelite.client.plugins.microbot.util.antiban.Rs2AntibanSettings;
+import net.runelite.client.plugins.microbot.util.bank.Rs2Bank;
+import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory;
+import net.runelite.client.plugins.microbot.util.player.Rs2Player;
+import net.runelite.client.plugins.microbot.util.tile.Rs2Tile;
+import net.runelite.client.plugins.microbot.util.walker.Rs2Walker;
+
+import java.util.concurrent.TimeUnit;
+
+@Slf4j
+public class LeaguesFiremakingScript extends Script {
+
+ private static final int TINDERBOX_ID = 590;
+ private static final String TINDERBOX_NAME = "Tinderbox";
+
+ @Getter
+ private State state = State.SCANNING;
+ @Getter
+ private String status = "Starting";
+ @Getter
+ private FireLine currentLine;
+
+ private WorldPoint startPosition;
+ private LogType activeLogType;
+
+ public boolean run(LeaguesFiremakingConfig config) {
+ Rs2Antiban.resetAntibanSettings();
+ Rs2Antiban.antibanSetupTemplates.applyFiremakingSetup();
+ Rs2AntibanSettings.actionCooldownChance = 0.1;
+
+ mainScheduledFuture = scheduledExecutorService.scheduleWithFixedDelay(() -> {
+ try {
+ if (!super.run()) return;
+ if (!Microbot.isLoggedIn()) return;
+ if (Rs2AntibanSettings.actionCooldownActive) return;
+
+ if (startPosition == null) {
+ startPosition = Rs2Player.getWorldLocation();
+ }
+
+ activeLogType = config.progressiveMode() ? LogType.getBestForLevel() : config.logType();
+
+ if (activeLogType == null || !activeLogType.hasRequiredLevel()) {
+ status = "Level too low for " + (activeLogType != null ? activeLogType.getLogName() : "any logs");
+ return;
+ }
+
+ switch (state) {
+ case SCANNING:
+ handleScanning(config);
+ break;
+ case WALKING_TO_LINE:
+ handleWalkingToLine();
+ break;
+ case BURNING:
+ handleBurning();
+ break;
+ case BANKING:
+ handleBanking(config);
+ break;
+ case WALKING_BACK:
+ handleWalkingBack(config);
+ break;
+ }
+ } catch (Exception ex) {
+ log.error("LeaguesFiremaking loop error", ex);
+ Microbot.log(ex.getMessage());
+ }
+ }, 0, 600, TimeUnit.MILLISECONDS);
+ return true;
+ }
+
+ private void handleScanning(LeaguesFiremakingConfig config) {
+ status = "Scanning for open space";
+
+ if (!Rs2Inventory.hasItem(activeLogType.getItemId())) {
+ state = State.BANKING;
+ return;
+ }
+
+ currentLine = TileScanner.findBestLine(startPosition, config.scanRadius());
+
+ if (currentLine == null) {
+ status = "No open space found — try moving to a more open area";
+ return;
+ }
+
+ status = "Found line: " + currentLine.getLength() + " tiles";
+ state = State.WALKING_TO_LINE;
+ }
+
+ private void handleWalkingToLine() {
+ if (currentLine == null) {
+ state = State.SCANNING;
+ return;
+ }
+
+ WorldPoint eastEnd = currentLine.getEastEnd();
+ status = "Walking to east end of line";
+
+ if (Rs2Player.getWorldLocation().distanceTo(eastEnd) <= 1) {
+ state = State.BURNING;
+ return;
+ }
+
+ if (!Rs2Player.isMoving()) {
+ Rs2Walker.walkTo(eastEnd, 0);
+ }
+ }
+
+ private void handleBurning() {
+ if (!Rs2Inventory.hasItem(activeLogType.getItemId())) {
+ status = "Out of logs";
+ state = State.BANKING;
+ return;
+ }
+
+ if (Rs2Player.isMoving()) {
+ status = "Walking after lighting...";
+ return;
+ }
+
+ if (Rs2Player.isAnimating()) {
+ status = "Lighting animation...";
+ return;
+ }
+
+ if (!Rs2Inventory.hasItem(TINDERBOX_NAME)) {
+ status = "No tinderbox — banking";
+ state = State.BANKING;
+ return;
+ }
+
+ // Check if we're standing on a fire — need to step west first
+ WorldPoint playerPos = Rs2Player.getWorldLocation();
+ boolean standingOnFire = TileScanner.hasFire(playerPos);
+
+ if (standingOnFire) {
+ // Step one tile west to get off the fire
+ WorldPoint westTile = new WorldPoint(playerPos.getX() - 1, playerPos.getY(), playerPos.getPlane());
+ if (!Rs2Tile.isWalkable(westTile)) {
+ // Can't go west — line is done, rescan
+ status = "Blocked west — rescanning";
+ state = State.SCANNING;
+ return;
+ }
+ Rs2Walker.walkTo(westTile, 0);
+ sleepUntil(() -> Rs2Player.getWorldLocation().distanceTo(westTile) <= 0, 3000);
+ return;
+ }
+
+ if (!Rs2Tile.isWalkable(playerPos)) {
+ status = "Standing on blocked tile — rescanning";
+ state = State.SCANNING;
+ return;
+ }
+
+ status = "Lighting " + activeLogType.getLogName();
+ WorldPoint beforeLight = Rs2Player.getWorldLocation();
+ Rs2Inventory.combine(TINDERBOX_NAME, activeLogType.getLogName());
+
+ // Wait for XP drop (fire lit) then wait for auto-walk west
+ if (Rs2Player.waitForXpDrop(Skill.FIREMAKING, 10000)) {
+ sleepUntil(() -> !Rs2Player.getWorldLocation().equals(beforeLight), 3000);
+ sleep(200, 400);
+ }
+
+ Rs2Antiban.actionCooldown();
+ Rs2Antiban.takeMicroBreakByChance();
+ }
+
+ private void handleBanking(LeaguesFiremakingConfig config) {
+ if (config.useBriefcase()) {
+ status = "Using briefcase to bank";
+ if (!Rs2Inventory.hasItem("Banker's briefcase")) {
+ status = "No briefcase found — walking to bank";
+ if (!Rs2Bank.walkToBankAndUseBank()) return;
+ } else {
+ if (!Rs2Bank.isOpen()) {
+ Rs2Inventory.interact("Banker's briefcase", "Bank");
+ sleepUntil(Rs2Bank::isOpen, 5000);
+ if (!Rs2Bank.isOpen()) return;
+ }
+ }
+ } else {
+ status = "Walking to bank";
+ if (!Rs2Bank.isOpen()) {
+ if (!Rs2Bank.walkToBankAndUseBank()) return;
+ }
+ }
+
+ status = "Depositing and withdrawing";
+
+ Rs2Bank.depositAll();
+ sleep(150, 300);
+
+ if (!Rs2Bank.hasItem(TINDERBOX_ID)) {
+ status = "No tinderbox in bank — stopping";
+ Microbot.log("No tinderbox found in bank.");
+ shutdown();
+ return;
+ }
+
+ Rs2Bank.withdrawOne(TINDERBOX_ID);
+ sleepUntil(() -> Rs2Inventory.hasItem(TINDERBOX_NAME), 3000);
+ sleep(150, 300);
+
+ if (!Rs2Bank.hasItem(activeLogType.getItemId())) {
+ status = "No " + activeLogType.getLogName() + " in bank — stopping";
+ Microbot.log("No " + activeLogType.getLogName() + " found in bank.");
+ shutdown();
+ return;
+ }
+
+ Rs2Bank.withdrawAll(activeLogType.getItemId());
+ sleepUntil(() -> Rs2Inventory.hasItem(activeLogType.getItemId()), 3000);
+ sleep(150, 300);
+
+ Rs2Bank.closeBank();
+ state = State.WALKING_BACK;
+ }
+
+ private void handleWalkingBack(LeaguesFiremakingConfig config) {
+ status = "Walking back to fire area";
+
+ if (Rs2Player.getWorldLocation().distanceTo(startPosition) <= config.scanRadius()) {
+ state = State.SCANNING;
+ return;
+ }
+
+ if (!Rs2Player.isMoving()) {
+ Rs2Walker.walkTo(startPosition, 3);
+ }
+ }
+
+ @Override
+ public void shutdown() {
+ super.shutdown();
+ Rs2Antiban.resetAntibanSettings();
+ startPosition = null;
+ currentLine = null;
+ state = State.SCANNING;
+ }
+}
diff --git a/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/LogType.java b/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/LogType.java
new file mode 100644
index 0000000000..007732bb01
--- /dev/null
+++ b/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/LogType.java
@@ -0,0 +1,44 @@
+package net.runelite.client.plugins.microbot.leaguesfiremaking;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import net.runelite.api.Skill;
+import net.runelite.api.gameval.ItemID;
+import net.runelite.client.plugins.microbot.util.player.Rs2Player;
+
+@Getter
+@RequiredArgsConstructor
+public enum LogType {
+ LOGS("Logs", ItemID.LOGS, 1),
+ OAK("Oak logs", ItemID.OAK_LOGS, 15),
+ WILLOW("Willow logs", ItemID.WILLOW_LOGS, 30),
+ TEAK("Teak logs", ItemID.TEAK_LOGS, 35),
+ MAPLE("Maple logs", ItemID.MAPLE_LOGS, 45),
+ MAHOGANY("Mahogany logs", ItemID.MAHOGANY_LOGS, 50),
+ YEW("Yew logs", ItemID.YEW_LOGS, 60),
+ MAGIC("Magic logs", ItemID.MAGIC_LOGS, 75),
+ REDWOOD("Redwood logs", ItemID.REDWOOD_LOGS, 90);
+
+ private final String logName;
+ private final int itemId;
+ private final int levelRequired;
+
+ public boolean hasRequiredLevel() {
+ return Rs2Player.getSkillRequirement(Skill.FIREMAKING, levelRequired);
+ }
+
+ public static LogType getBestForLevel() {
+ LogType best = LOGS;
+ for (LogType log : values()) {
+ if (log.hasRequiredLevel()) {
+ best = log;
+ }
+ }
+ return best;
+ }
+
+ @Override
+ public String toString() {
+ return logName;
+ }
+}
diff --git a/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/State.java b/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/State.java
new file mode 100644
index 0000000000..98e6caa0e2
--- /dev/null
+++ b/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/State.java
@@ -0,0 +1,9 @@
+package net.runelite.client.plugins.microbot.leaguesfiremaking;
+
+public enum State {
+ SCANNING,
+ WALKING_TO_LINE,
+ BURNING,
+ BANKING,
+ WALKING_BACK
+}
diff --git a/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/TileScanner.java b/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/TileScanner.java
new file mode 100644
index 0000000000..d41e0e531d
--- /dev/null
+++ b/src/main/java/net/runelite/client/plugins/microbot/leaguesfiremaking/TileScanner.java
@@ -0,0 +1,125 @@
+package net.runelite.client.plugins.microbot.leaguesfiremaking;
+
+import lombok.extern.slf4j.Slf4j;
+import net.runelite.api.coords.WorldPoint;
+import net.runelite.client.plugins.microbot.Microbot;
+import net.runelite.client.plugins.microbot.util.tile.Rs2Tile;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+@Slf4j
+public class TileScanner {
+
+ private static final int FIRE_ID = 26185;
+ private static final int FIRE_ID_ALT = 49927;
+
+ public enum TileState {
+ OPEN,
+ FIRE,
+ BLOCKED
+ }
+
+ public static TileState classifyTile(WorldPoint point, Set fireTiles, Set objectTiles) {
+ if (fireTiles.contains(point)) return TileState.FIRE;
+ if (objectTiles.contains(point)) return TileState.BLOCKED;
+ if (!Rs2Tile.isWalkable(point)) return TileState.BLOCKED;
+ return TileState.OPEN;
+ }
+
+ public static List findFireLines(WorldPoint center, int radius) {
+ Set fireTiles = new HashSet<>();
+ Set objectTiles = new HashSet<>();
+
+ Microbot.getRs2TileObjectCache().getStream()
+ .filter(obj -> obj.getWorldLocation().distanceTo(center) <= radius)
+ .forEach(obj -> {
+ int id = obj.getId();
+ WorldPoint loc = obj.getWorldLocation();
+ if (id == FIRE_ID || id == FIRE_ID_ALT) {
+ fireTiles.add(loc);
+ } else {
+ objectTiles.add(loc);
+ }
+ });
+
+ List lines = new ArrayList<>();
+ int plane = center.getPlane();
+
+ for (int y = center.getY() - radius; y <= center.getY() + radius; y++) {
+ int runStartX = -1;
+ int runLength = 0;
+
+ for (int x = center.getX() - radius; x <= center.getX() + radius; x++) {
+ WorldPoint point = new WorldPoint(x, y, plane);
+ TileState state = classifyTile(point, fireTiles, objectTiles);
+
+ if (state == TileState.OPEN) {
+ if (runStartX == -1) {
+ runStartX = x;
+ }
+ runLength++;
+ } else {
+ if (runLength >= 5) {
+ lines.add(new FireLine(
+ new WorldPoint(runStartX, y, plane),
+ new WorldPoint(runStartX + runLength - 1, y, plane),
+ runLength
+ ));
+ }
+ runStartX = -1;
+ runLength = 0;
+ }
+ }
+ if (runLength >= 5) {
+ lines.add(new FireLine(
+ new WorldPoint(runStartX, y, plane),
+ new WorldPoint(runStartX + runLength - 1, y, plane),
+ runLength
+ ));
+ }
+ }
+
+ // Score lines: balance length vs proximity to start position
+ // A nearby shorter line beats a far-away longer one
+ lines.sort(Comparator.comparingDouble((FireLine l) -> {
+ int distance = center.distanceTo(l.getEastEnd());
+ // Penalize distance heavily: each tile away reduces effective score
+ return -(l.getLength() - distance * 0.5);
+ }));
+
+ return lines;
+ }
+
+ public static FireLine findBestLine(WorldPoint center, int radius) {
+ List lines = findFireLines(center, radius);
+ return lines.isEmpty() ? null : lines.get(0);
+ }
+
+ public static boolean hasFire(WorldPoint point) {
+ return Microbot.getRs2TileObjectCache().getStream()
+ .anyMatch(obj -> obj.getWorldLocation().equals(point)
+ && (obj.getId() == FIRE_ID || obj.getId() == FIRE_ID_ALT));
+ }
+
+ public static Set buildFireSet(WorldPoint center, int radius) {
+ Set fireTiles = new HashSet<>();
+ Microbot.getRs2TileObjectCache().getStream()
+ .filter(obj -> obj.getWorldLocation().distanceTo(center) <= radius)
+ .filter(obj -> obj.getId() == FIRE_ID || obj.getId() == FIRE_ID_ALT)
+ .forEach(obj -> fireTiles.add(obj.getWorldLocation()));
+ return fireTiles;
+ }
+
+ public static Set buildObjectSet(WorldPoint center, int radius) {
+ Set objectTiles = new HashSet<>();
+ Microbot.getRs2TileObjectCache().getStream()
+ .filter(obj -> obj.getWorldLocation().distanceTo(center) <= radius)
+ .filter(obj -> obj.getId() != FIRE_ID && obj.getId() != FIRE_ID_ALT)
+ .forEach(obj -> objectTiles.add(obj.getWorldLocation()));
+ return objectTiles;
+ }
+}
diff --git a/src/main/java/net/runelite/client/plugins/microbot/leftclickcast/LeftClickCastConfig.java b/src/main/java/net/runelite/client/plugins/microbot/leftclickcast/LeftClickCastConfig.java
new file mode 100644
index 0000000000..f387be7e69
--- /dev/null
+++ b/src/main/java/net/runelite/client/plugins/microbot/leftclickcast/LeftClickCastConfig.java
@@ -0,0 +1,203 @@
+package net.runelite.client.plugins.microbot.leftclickcast;
+
+import net.runelite.client.config.Config;
+import net.runelite.client.config.ConfigGroup;
+import net.runelite.client.config.ConfigItem;
+import net.runelite.client.config.ConfigSection;
+import net.runelite.client.config.Keybind;
+
+@ConfigGroup("leftclickcast")
+public interface LeftClickCastConfig extends Config
+{
+ @ConfigItem(
+ keyName = "enabled",
+ name = "Enabled",
+ description = "Replace the left-click Attack option on NPCs with Cast Spell",
+ position = 0
+ )
+ default boolean enabled()
+ {
+ return true;
+ }
+
+ // Retained so existing stored config is not invalidated. Read once at startUp for migration into slot1Spell.
+ @ConfigItem(
+ keyName = "spell",
+ name = "Spell",
+ description = "Legacy single-spell setting — migrated into Slot 1 on startup.",
+ position = 1
+ )
+ default PertTargetSpell spell()
+ {
+ return PertTargetSpell.FIRE_STRIKE;
+ }
+
+ @ConfigItem(
+ keyName = "requireMagicWeapon",
+ name = "Require magic weapon",
+ description = "When enabled, the Cast entry is only inserted while a staff, bladed staff, powered staff, or powered wand is equipped. Disable to cast regardless of equipped weapon.",
+ position = 2
+ )
+ default boolean requireMagicWeapon()
+ {
+ return true;
+ }
+
+ @ConfigSection(
+ name = "Spell Slots",
+ description = "Up to five spells that can be bound to hotkeys for mid-fight swapping.",
+ position = 10
+ )
+ String spellSlotsSection = "spellSlots";
+
+ @ConfigSection(
+ name = "Hotkeys",
+ description = "Hotkey bindings that switch the active spell slot.",
+ position = 11
+ )
+ String hotkeysSection = "hotkeys";
+
+ @ConfigItem(
+ keyName = "slot1Spell",
+ name = "Slot 1 Spell",
+ description = "Spell for slot 1 (the startup-active slot).",
+ section = spellSlotsSection,
+ position = 0
+ )
+ default PertTargetSpell slot1Spell()
+ {
+ return PertTargetSpell.FIRE_STRIKE;
+ }
+
+ @ConfigItem(
+ keyName = "slot2Spell",
+ name = "Slot 2 Spell",
+ description = "Spell for slot 2.",
+ section = spellSlotsSection,
+ position = 1
+ )
+ default PertTargetSpell slot2Spell()
+ {
+ return PertTargetSpell.FIRE_STRIKE;
+ }
+
+ @ConfigItem(
+ keyName = "slot3Spell",
+ name = "Slot 3 Spell",
+ description = "Spell for slot 3.",
+ section = spellSlotsSection,
+ position = 2
+ )
+ default PertTargetSpell slot3Spell()
+ {
+ return PertTargetSpell.FIRE_STRIKE;
+ }
+
+ @ConfigItem(
+ keyName = "slot4Spell",
+ name = "Slot 4 Spell",
+ description = "Spell for slot 4.",
+ section = spellSlotsSection,
+ position = 3
+ )
+ default PertTargetSpell slot4Spell()
+ {
+ return PertTargetSpell.FIRE_STRIKE;
+ }
+
+ @ConfigItem(
+ keyName = "slot5Spell",
+ name = "Slot 5 Spell",
+ description = "Spell for slot 5.",
+ section = spellSlotsSection,
+ position = 4
+ )
+ default PertTargetSpell slot5Spell()
+ {
+ return PertTargetSpell.FIRE_STRIKE;
+ }
+
+ @ConfigItem(
+ keyName = "enabledToggleHotkey",
+ name = "Enable/Disable Hotkey",
+ description = "Hotkey that toggles the plugin on and off.",
+ section = hotkeysSection,
+ position = 0
+ )
+ default Keybind enabledToggleHotkey()
+ {
+ return Keybind.NOT_SET;
+ }
+
+ @ConfigItem(
+ keyName = "slot1Hotkey",
+ name = "Slot 1 Hotkey",
+ description = "Hotkey that activates slot 1.",
+ section = hotkeysSection,
+ position = 1
+ )
+ default Keybind slot1Hotkey()
+ {
+ return Keybind.NOT_SET;
+ }
+
+ @ConfigItem(
+ keyName = "slot2Hotkey",
+ name = "Slot 2 Hotkey",
+ description = "Hotkey that activates slot 2.",
+ section = hotkeysSection,
+ position = 2
+ )
+ default Keybind slot2Hotkey()
+ {
+ return Keybind.NOT_SET;
+ }
+
+ @ConfigItem(
+ keyName = "slot3Hotkey",
+ name = "Slot 3 Hotkey",
+ description = "Hotkey that activates slot 3.",
+ section = hotkeysSection,
+ position = 3
+ )
+ default Keybind slot3Hotkey()
+ {
+ return Keybind.NOT_SET;
+ }
+
+ @ConfigItem(
+ keyName = "slot4Hotkey",
+ name = "Slot 4 Hotkey",
+ description = "Hotkey that activates slot 4.",
+ section = hotkeysSection,
+ position = 4
+ )
+ default Keybind slot4Hotkey()
+ {
+ return Keybind.NOT_SET;
+ }
+
+ @ConfigItem(
+ keyName = "slot5Hotkey",
+ name = "Slot 5 Hotkey",
+ description = "Hotkey that activates slot 5.",
+ section = hotkeysSection,
+ position = 5
+ )
+ default Keybind slot5Hotkey()
+ {
+ return Keybind.NOT_SET;
+ }
+
+ @ConfigItem(
+ keyName = "chatFeedback",
+ name = "Chat feedback",
+ description = "Post a game chat message on plugin events (active slot change, enable/disable toggle).",
+ section = hotkeysSection,
+ position = 6
+ )
+ default boolean chatFeedback()
+ {
+ return true;
+ }
+}
diff --git a/src/main/java/net/runelite/client/plugins/microbot/leftclickcast/LeftClickCastPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/leftclickcast/LeftClickCastPlugin.java
new file mode 100644
index 0000000000..0a0ed5f989
--- /dev/null
+++ b/src/main/java/net/runelite/client/plugins/microbot/leftclickcast/LeftClickCastPlugin.java
@@ -0,0 +1,391 @@
+package net.runelite.client.plugins.microbot.leftclickcast;
+
+import com.google.inject.Provides;
+import java.util.concurrent.CompletableFuture;
+import javax.inject.Inject;
+import net.runelite.api.Actor;
+import net.runelite.api.ChatMessageType;
+import net.runelite.api.Client;
+import net.runelite.api.EnumComposition;
+import net.runelite.api.EnumID;
+import net.runelite.api.MenuAction;
+import net.runelite.api.MenuEntry;
+import net.runelite.api.Menu;
+import net.runelite.api.NPC;
+import net.runelite.api.ParamID;
+import net.runelite.api.Player;
+import net.runelite.api.StructComposition;
+import net.runelite.api.events.PostMenuSort;
+import net.runelite.api.gameval.VarbitID;
+import net.runelite.client.chat.ChatMessageManager;
+import net.runelite.client.chat.QueuedMessage;
+import net.runelite.client.config.ConfigManager;
+import net.runelite.client.config.Keybind;
+import net.runelite.client.eventbus.EventBus;
+import net.runelite.client.eventbus.Subscribe;
+import net.runelite.client.events.ConfigChanged;
+import net.runelite.client.events.ExternalPluginsChanged;
+import net.runelite.client.input.KeyManager;
+import net.runelite.client.plugins.Plugin;
+import net.runelite.client.plugins.PluginDescriptor;
+import net.runelite.api.widgets.Widget;
+import net.runelite.client.plugins.microbot.PluginConstants;
+import net.runelite.client.plugins.microbot.util.magic.Rs2Magic;
+import net.runelite.client.plugins.microbot.util.npc.Rs2NpcModel;
+import net.runelite.client.plugins.microbot.globval.enums.InterfaceTab;
+import net.runelite.client.plugins.microbot.util.tabs.Rs2Tab;
+import net.runelite.client.plugins.skillcalculator.skills.MagicAction;
+import net.runelite.client.util.HotkeyListener;
+
+@PluginDescriptor(
+ name = PluginConstants.PERT + "Left-Click Cast",
+ description = "Replaces left-click Attack on NPCs with a preconfigured Cast Spell action.",
+ tags = {"magic", "combat", "spell", "left-click", "cast", "pvm", "pvp"},
+ authors = {"Pert"},
+ version = LeftClickCastPlugin.version,
+ minClientVersion = "2.0.13",
+ enabledByDefault = PluginConstants.DEFAULT_ENABLED,
+ isExternal = PluginConstants.IS_EXTERNAL
+)
+public class LeftClickCastPlugin extends Plugin
+{
+ static final String version = "1.3.0";
+
+ private static final int SLOT_COUNT = 5;
+
+ @Inject
+ private Client client;
+
+ @Inject
+ private LeftClickCastConfig config;
+
+ @Inject
+ private KeyManager keyManager;
+
+ @Inject
+ private ChatMessageManager chatMessageManager;
+
+ @Inject
+ private ConfigManager configManager;
+
+ @Inject
+ private EventBus eventBus;
+
+ private volatile int activeSlot = 0;
+
+ private final HotkeyListener[] hotkeyListeners = new HotkeyListener[SLOT_COUNT];
+
+ private HotkeyListener enabledToggleListener;
+
+ @Provides
+ LeftClickCastConfig provideConfig(ConfigManager configManager)
+ {
+ return configManager.getConfig(LeftClickCastConfig.class);
+ }
+
+ @Override
+ protected void startUp()
+ {
+ // MicrobotConfigPanel renders boolean checkboxes from raw stored values; missing keys read as false
+ // even when the @ConfigItem default is true. Materialize defaults so the UI and the proxy agree.
+ configManager.setDefaultConfiguration(config, false);
+ activeSlot = 0;
+ for (int i = 0; i < SLOT_COUNT; i++)
+ {
+ final int slotIndex = i;
+ HotkeyListener listener = new HotkeyListener(() -> slotHotkeyFor(slotIndex))
+ {
+ @Override
+ public void hotkeyPressed()
+ {
+ onSlotHotkey(slotIndex);
+ }
+ };
+ hotkeyListeners[i] = listener;
+ keyManager.registerKeyListener(listener);
+ }
+ enabledToggleListener = new HotkeyListener(() -> config.enabledToggleHotkey())
+ {
+ @Override
+ public void hotkeyPressed()
+ {
+ onEnabledToggleHotkey();
+ }
+ };
+ keyManager.registerKeyListener(enabledToggleListener);
+ migrateLegacySpellKey();
+ }
+
+ @Override
+ protected void shutDown()
+ {
+ for (int i = 0; i < hotkeyListeners.length; i++)
+ {
+ HotkeyListener listener = hotkeyListeners[i];
+ if (listener != null)
+ {
+ keyManager.unregisterKeyListener(listener);
+ hotkeyListeners[i] = null;
+ }
+ }
+ if (enabledToggleListener != null)
+ {
+ keyManager.unregisterKeyListener(enabledToggleListener);
+ enabledToggleListener = null;
+ }
+ }
+
+ @Subscribe
+ public void onPostMenuSort(PostMenuSort event)
+ {
+ // Don't mutate while the right-click menu is open — entries are frozen at open-time.
+ if (client.isMenuOpen())
+ {
+ return;
+ }
+ if (!config.enabled())
+ {
+ return;
+ }
+ PertTargetSpell spell = slotSpellFor(activeSlot);
+ if (spell == null)
+ {
+ return;
+ }
+ if (config.requireMagicWeapon() && !isMagicWeaponEquipped())
+ {
+ return;
+ }
+
+ Menu menu = client.getMenu();
+ MenuEntry[] entries = menu.getMenuEntries();
+
+ // Find the top-most NPC or Player Attack entry (the game's already-sorted left-click candidate).
+ int attackIdx = -1;
+ Actor targetActor = null;
+ for (int i = entries.length - 1; i >= 0; i--)
+ {
+ MenuEntry e = entries[i];
+ if (!"Attack".equals(e.getOption()))
+ {
+ continue;
+ }
+ if (e.getNpc() != null)
+ {
+ attackIdx = i;
+ targetActor = e.getNpc();
+ break;
+ }
+ if (e.getPlayer() != null)
+ {
+ attackIdx = i;
+ targetActor = e.getPlayer();
+ break;
+ }
+ }
+ if (attackIdx < 0)
+ {
+ return;
+ }
+
+ MenuEntry attack = entries[attackIdx];
+ final Actor dispatchTarget = targetActor;
+ final PertTargetSpell dispatchSpell = spell;
+ attack.setOption("Cast " + dispatchSpell.getDisplayName());
+ attack.setType(MenuAction.RUNELITE);
+ attack.onClick(e -> castOnTargetFast(dispatchSpell, dispatchTarget));
+
+ // Move to the tail of the array — that slot is the left-click action in RuneLite's menu model.
+ if (attackIdx != entries.length - 1)
+ {
+ entries[attackIdx] = entries[entries.length - 1];
+ entries[entries.length - 1] = attack;
+ menu.setMenuEntries(entries);
+ }
+ }
+
+ private Keybind slotHotkeyFor(int index)
+ {
+ switch (index)
+ {
+ case 0:
+ return config.slot1Hotkey();
+ case 1:
+ return config.slot2Hotkey();
+ case 2:
+ return config.slot3Hotkey();
+ case 3:
+ return config.slot4Hotkey();
+ case 4:
+ return config.slot5Hotkey();
+ default:
+ return Keybind.NOT_SET;
+ }
+ }
+
+ private PertTargetSpell slotSpellFor(int index)
+ {
+ switch (index)
+ {
+ case 0:
+ return config.slot1Spell();
+ case 1:
+ return config.slot2Spell();
+ case 2:
+ return config.slot3Spell();
+ case 3:
+ return config.slot4Spell();
+ case 4:
+ return config.slot5Spell();
+ default:
+ return config.slot1Spell();
+ }
+ }
+
+ private void onSlotHotkey(int index)
+ {
+ activeSlot = index;
+ if (config.chatFeedback())
+ {
+ PertTargetSpell spell = slotSpellFor(index);
+ String display = spell != null ? spell.getDisplayName() : "(no spell)";
+ chatMessageManager.queue(QueuedMessage.builder()
+ .type(ChatMessageType.GAMEMESSAGE)
+ .value("Left-Click Cast: now casting " + display)
+ .build());
+ }
+ }
+
+ private void onEnabledToggleHotkey()
+ {
+ boolean newValue = !config.enabled();
+ configManager.setConfiguration("leftclickcast", "enabled", newValue);
+ // MicrobotConfigPanel doesn't subscribe to ConfigChanged for individual checkbox refresh, but it does
+ // rebuild on ExternalPluginsChanged. Posting that here makes the open config panel re-read this and
+ // every other config item, so the "Enabled" checkbox visually flips to match the keybind toggle.
+ eventBus.post(new ExternalPluginsChanged());
+ // Chat feedback is emitted by onConfigChanged so checkbox clicks and hotkey presses share one path.
+ }
+
+ @Subscribe
+ public void onConfigChanged(ConfigChanged event)
+ {
+ if (!"leftclickcast".equals(event.getGroup()) || !"enabled".equals(event.getKey()))
+ {
+ return;
+ }
+ if (!config.chatFeedback())
+ {
+ return;
+ }
+ boolean enabled = "true".equals(event.getNewValue());
+ chatMessageManager.queue(QueuedMessage.builder()
+ .type(ChatMessageType.GAMEMESSAGE)
+ .value("Left-Click Cast: " + (enabled ? "enabled" : "disabled"))
+ .build());
+ }
+
+ // Fast-path cast: fire two synchronous client.menuAction packets back-to-back so the server processes
+ // the spell selection and the spell-on-target dispatch on the same game tick. Falls back to
+ // Rs2Magic.castOn (tab switch + sleeps + clicks) if the spellbook widget isn't loaded yet or the
+ // spell isn't on the current spellbook.
+ private void castOnTargetFast(PertTargetSpell spell, Actor target)
+ {
+ if (target == null)
+ {
+ return;
+ }
+ MagicAction magic = spell.getMagicAction();
+ Widget magicRoot = client.getWidget(218, 0);
+ boolean widgetReady = magicRoot != null && magicRoot.getStaticChildren() != null;
+ if (widgetReady)
+ {
+ try
+ {
+ int spellWidgetId = magic.getWidgetId();
+ // Packet 1: select the spell client-side (WIDGET_TARGET on the spell widget).
+ client.menuAction(-1, spellWidgetId, MenuAction.WIDGET_TARGET, 1, -1, "Cast", magic.getName());
+ // Packet 2: dispatch the selected spell on the target, same tick.
+ if (target instanceof NPC)
+ {
+ NPC npc = (NPC) target;
+ client.menuAction(0, 0, MenuAction.WIDGET_TARGET_ON_NPC, npc.getIndex(), -1, "Use", npc.getName());
+ }
+ else if (target instanceof Player)
+ {
+ Player p = (Player) target;
+ client.menuAction(0, 0, MenuAction.WIDGET_TARGET_ON_PLAYER, p.getId(), -1, "Use", p.getName());
+ }
+ return;
+ }
+ catch (Exception ignored)
+ {
+ // Spell not on the active spellbook (e.g., modern while on ancients) — fall through.
+ }
+ }
+ else
+ {
+ // Spellbook widget not yet loaded this session; nudge it open so the next click is fast.
+ Rs2Tab.switchTo(InterfaceTab.MAGIC);
+ }
+ // Slow fallback path. Rs2Magic.castOn uses sleepUntil which is a no-op on the client thread, so dispatch async.
+ final Actor dispatch = target instanceof NPC ? new Rs2NpcModel((NPC) target) : target;
+ CompletableFuture.runAsync(() -> Rs2Magic.castOn(magic, dispatch));
+ }
+
+ // Best-effort: if the user had previously set the legacy `spell` key to a non-default value and
+ // slot1Spell is still at its default, copy the legacy value into slot1Spell so existing configs keep working.
+ private void migrateLegacySpellKey()
+ {
+ try
+ {
+ PertTargetSpell legacy = configManager.getConfiguration(
+ "leftclickcast", "spell", PertTargetSpell.class);
+ if (legacy == null || legacy == PertTargetSpell.FIRE_STRIKE)
+ {
+ return;
+ }
+ if (config.slot1Spell() != PertTargetSpell.FIRE_STRIKE)
+ {
+ return;
+ }
+ configManager.setConfiguration("leftclickcast", "slot1Spell", legacy);
+ }
+ catch (Exception ignored)
+ {
+ // Migration is best-effort; ignore any deserialization or storage errors.
+ }
+ }
+
+ // A weapon counts as "magic" when its style struct exposes Casting or Defensive Casting.
+ // Mirrors the core AttackStylesPlugin logic (EnumID.WEAPON_STYLES + ParamID.ATTACK_STYLE_NAME).
+ private boolean isMagicWeaponEquipped()
+ {
+ int weaponType = client.getVarbitValue(VarbitID.COMBAT_WEAPON_CATEGORY);
+ EnumComposition weaponStyles = client.getEnum(EnumID.WEAPON_STYLES);
+ if (weaponStyles == null)
+ {
+ return false;
+ }
+ int styleEnumId = weaponStyles.getIntValue(weaponType);
+ if (styleEnumId == -1)
+ {
+ return false;
+ }
+ int[] styleStructs = client.getEnum(styleEnumId).getIntVals();
+ for (int structId : styleStructs)
+ {
+ StructComposition sc = client.getStructComposition(structId);
+ if (sc == null)
+ {
+ continue;
+ }
+ String name = sc.getStringValue(ParamID.ATTACK_STYLE_NAME);
+ if ("Casting".equalsIgnoreCase(name) || "Defensive Casting".equalsIgnoreCase(name))
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/net/runelite/client/plugins/microbot/leftclickcast/PertTargetSpell.java b/src/main/java/net/runelite/client/plugins/microbot/leftclickcast/PertTargetSpell.java
new file mode 100644
index 0000000000..521072613b
--- /dev/null
+++ b/src/main/java/net/runelite/client/plugins/microbot/leftclickcast/PertTargetSpell.java
@@ -0,0 +1,96 @@
+package net.runelite.client.plugins.microbot.leftclickcast;
+
+import lombok.Getter;
+import net.runelite.client.plugins.skillcalculator.skills.MagicAction;
+
+@Getter
+public enum PertTargetSpell
+{
+ // Modern autocastable combat lines (Strike -> Surge)
+ WIND_STRIKE("Wind Strike", MagicAction.WIND_STRIKE),
+ WATER_STRIKE("Water Strike", MagicAction.WATER_STRIKE),
+ EARTH_STRIKE("Earth Strike", MagicAction.EARTH_STRIKE),
+ FIRE_STRIKE("Fire Strike", MagicAction.FIRE_STRIKE),
+ WIND_BOLT("Wind Bolt", MagicAction.WIND_BOLT),
+ WATER_BOLT("Water Bolt", MagicAction.WATER_BOLT),
+ EARTH_BOLT("Earth Bolt", MagicAction.EARTH_BOLT),
+ FIRE_BOLT("Fire Bolt", MagicAction.FIRE_BOLT),
+ WIND_BLAST("Wind Blast", MagicAction.WIND_BLAST),
+ WATER_BLAST("Water Blast", MagicAction.WATER_BLAST),
+ EARTH_BLAST("Earth Blast", MagicAction.EARTH_BLAST),
+ FIRE_BLAST("Fire Blast", MagicAction.FIRE_BLAST),
+ WIND_WAVE("Wind Wave", MagicAction.WIND_WAVE),
+ WATER_WAVE("Water Wave", MagicAction.WATER_WAVE),
+ EARTH_WAVE("Earth Wave", MagicAction.EARTH_WAVE),
+ FIRE_WAVE("Fire Wave", MagicAction.FIRE_WAVE),
+ WIND_SURGE("Wind Surge", MagicAction.WIND_SURGE),
+ WATER_SURGE("Water Surge", MagicAction.WATER_SURGE),
+ EARTH_SURGE("Earth Surge", MagicAction.EARTH_SURGE),
+ FIRE_SURGE("Fire Surge", MagicAction.FIRE_SURGE),
+
+ // Ancient autocastable combat lines (Rush/Burst/Blitz/Barrage for each element)
+ SMOKE_RUSH("Smoke Rush", MagicAction.SMOKE_RUSH),
+ SHADOW_RUSH("Shadow Rush", MagicAction.SHADOW_RUSH),
+ BLOOD_RUSH("Blood Rush", MagicAction.BLOOD_RUSH),
+ ICE_RUSH("Ice Rush", MagicAction.ICE_RUSH),
+ SMOKE_BURST("Smoke Burst", MagicAction.SMOKE_BURST),
+ SHADOW_BURST("Shadow Burst", MagicAction.SHADOW_BURST),
+ BLOOD_BURST("Blood Burst", MagicAction.BLOOD_BURST),
+ ICE_BURST("Ice Burst", MagicAction.ICE_BURST),
+ SMOKE_BLITZ("Smoke Blitz", MagicAction.SMOKE_BLITZ),
+ SHADOW_BLITZ("Shadow Blitz", MagicAction.SHADOW_BLITZ),
+ BLOOD_BLITZ("Blood Blitz", MagicAction.BLOOD_BLITZ),
+ ICE_BLITZ("Ice Blitz", MagicAction.ICE_BLITZ),
+ SMOKE_BARRAGE("Smoke Barrage", MagicAction.SMOKE_BARRAGE),
+ SHADOW_BARRAGE("Shadow Barrage", MagicAction.SHADOW_BARRAGE),
+ BLOOD_BARRAGE("Blood Barrage", MagicAction.BLOOD_BARRAGE),
+ ICE_BARRAGE("Ice Barrage", MagicAction.ICE_BARRAGE),
+
+ // Non-autocastable combat spells
+ CRUMBLE_UNDEAD("Crumble Undead", MagicAction.CRUMBLE_UNDEAD),
+ IBAN_BLAST("Iban Blast", MagicAction.IBAN_BLAST),
+ MAGIC_DART("Magic Dart", MagicAction.MAGIC_DART),
+ SARADOMIN_STRIKE("Saradomin Strike", MagicAction.SARADOMIN_STRIKE),
+ CLAWS_OF_GUTHIX("Claws of Guthix", MagicAction.CLAWS_OF_GUTHIX),
+ FLAMES_OF_ZAMORAK("Flames of Zamorak", MagicAction.FLAMES_OF_ZAMORAK),
+
+ // Arceuus offensive target spells
+ GHOSTLY_GRASP("Ghostly Grasp", MagicAction.GHOSTLY_GRASP),
+ SKELETAL_GRASP("Skeletal Grasp", MagicAction.SKELETAL_GRASP),
+ UNDEAD_GRASP("Undead Grasp", MagicAction.UNDEAD_GRASP),
+ INFERIOR_DEMONBANE("Inferior Demonbane", MagicAction.INFERIOR_DEMONBANE),
+ SUPERIOR_DEMONBANE("Superior Demonbane", MagicAction.SUPERIOR_DEMONBANE),
+ DARK_DEMONBANE("Dark Demonbane", MagicAction.DARK_DEMONBANE),
+ LESSER_CORRUPTION("Lesser Corruption", MagicAction.LESSER_CORRUPTION),
+ GREATER_CORRUPTION("Greater Corruption", MagicAction.GREATER_CORRUPTION),
+
+ // Utility target spells
+ CONFUSE("Confuse", MagicAction.CONFUSE),
+ WEAKEN("Weaken", MagicAction.WEAKEN),
+ CURSE("Curse", MagicAction.CURSE),
+ BIND("Bind", MagicAction.BIND),
+ SNARE("Snare", MagicAction.SNARE),
+ ENTANGLE("Entangle", MagicAction.ENTANGLE),
+ VULNERABILITY("Vulnerability", MagicAction.VULNERABILITY),
+ ENFEEBLE("Enfeeble", MagicAction.ENFEEBLE),
+ STUN("Stun", MagicAction.STUN),
+ TELE_BLOCK("Tele Block", MagicAction.TELE_BLOCK),
+ TELEOTHER_LUMBRIDGE("Tele Other Lumbridge", MagicAction.TELEOTHER_LUMBRIDGE),
+ TELEOTHER_FALADOR("Tele Other Falador", MagicAction.TELEOTHER_FALADOR),
+ TELEOTHER_CAMELOT("Tele Other Camelot", MagicAction.TELEOTHER_CAMELOT);
+
+ private final String displayName;
+ private final MagicAction magicAction;
+
+ PertTargetSpell(String displayName, MagicAction magicAction)
+ {
+ this.displayName = displayName;
+ this.magicAction = magicAction;
+ }
+
+ @Override
+ public String toString()
+ {
+ return displayName;
+ }
+}
diff --git a/src/main/java/net/runelite/client/plugins/microbot/mining/AutoMiningConfig.java b/src/main/java/net/runelite/client/plugins/microbot/mining/AutoMiningConfig.java
index 0c34142012..01f2345ecc 100644
--- a/src/main/java/net/runelite/client/plugins/microbot/mining/AutoMiningConfig.java
+++ b/src/main/java/net/runelite/client/plugins/microbot/mining/AutoMiningConfig.java
@@ -86,6 +86,17 @@ default int maxPlayersInArea() {
return 0;
}
+ @ConfigItem(
+ keyName = "leagueMode",
+ name = "League mode (anti-AFK)",
+ description = "Periodically presses a key to reset the idle timer so you never get logged out",
+ position = 4,
+ section = generalSection
+ )
+ default boolean leagueMode() {
+ return false;
+ }
+
@ConfigItem(
keyName = "UseBank",
name = "UseBank",
diff --git a/src/main/java/net/runelite/client/plugins/microbot/mining/AutoMiningPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/mining/AutoMiningPlugin.java
index 43f6db18b6..20384f2f44 100644
--- a/src/main/java/net/runelite/client/plugins/microbot/mining/AutoMiningPlugin.java
+++ b/src/main/java/net/runelite/client/plugins/microbot/mining/AutoMiningPlugin.java
@@ -24,7 +24,7 @@
)
@Slf4j
public class AutoMiningPlugin extends Plugin {
- public static final String version = "1.0.11";
+ public static final String version = "1.0.12";
@Inject
private AutoMiningConfig config;
@Provides
diff --git a/src/main/java/net/runelite/client/plugins/microbot/mining/AutoMiningScript.java b/src/main/java/net/runelite/client/plugins/microbot/mining/AutoMiningScript.java
index 3f3d861dde..8bd0eaad07 100644
--- a/src/main/java/net/runelite/client/plugins/microbot/mining/AutoMiningScript.java
+++ b/src/main/java/net/runelite/client/plugins/microbot/mining/AutoMiningScript.java
@@ -19,12 +19,14 @@
import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment;
import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject;
import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory;
+import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard;
import net.runelite.client.plugins.microbot.util.math.Rs2Random;
import net.runelite.client.plugins.microbot.util.player.Rs2Player;
import net.runelite.client.plugins.microbot.util.security.Login;
import net.runelite.client.plugins.microbot.util.walker.Rs2Walker;
import java.util.ArrayList;
+import java.awt.event.KeyEvent;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
@@ -54,11 +56,20 @@ public boolean run(AutoMiningConfig config) {
try {
if (!super.run()) return;
if (!Microbot.isLoggedIn()) return;
+ if (config.leagueMode() && Rs2Player.checkIdleLogout(Rs2Random.between(500, 1500))) {
+ int[] arrowKeys = { KeyEvent.VK_LEFT, KeyEvent.VK_RIGHT, KeyEvent.VK_UP, KeyEvent.VK_DOWN };
+ Rs2Keyboard.keyPress(arrowKeys[Rs2Random.between(0, arrowKeys.length - 1)]);
+ }
if (Rs2AntibanSettings.actionCooldownActive) return;
if (initialPlayerLocation == null) {
initialPlayerLocation = Rs2Player.getWorldLocation();
}
+ // Skip cycle if we don't have a valid location
+ if (initialPlayerLocation == null) {
+ return;
+ }
+
updateActiveRock(config);
if (config.progressiveMode() && ensureProgressiveLocation(config)) {
@@ -120,6 +131,16 @@ public boolean run(AutoMiningConfig config) {
return;
}
+ // Check if we're too far from mining location - walk back first
+ if (initialPlayerLocation != null) {
+ int distanceFromStart = Rs2Player.getWorldLocation().distanceTo(initialPlayerLocation);
+ if (distanceFromStart > config.distanceToStray()) {
+ Microbot.status = "Walking back to mining location...";
+ Rs2Walker.walkTo(initialPlayerLocation, config.distanceToStray());
+ return;
+ }
+ }
+
GameObject rock = Rs2GameObject.findReachableObject(activeRock.getName(), true, config.distanceToStray(), initialPlayerLocation);
if (rock != null) {
@@ -252,7 +273,9 @@ private boolean ensureProgressiveLocation(AutoMiningConfig config) {
WorldPoint targetPoint = activeLocation.getWorldPoint();
- if (initialPlayerLocation == null || !initialPlayerLocation.equals(targetPoint)) {
+ // Only update initialPlayerLocation if it's null
+ // Don't update just because player is far away (e.g., at bank) - that breaks return-to-location
+ if (initialPlayerLocation == null) {
initialPlayerLocation = targetPoint;
}
diff --git a/src/main/java/net/runelite/client/plugins/microbot/mining/data/LocationOption.java b/src/main/java/net/runelite/client/plugins/microbot/mining/data/LocationOption.java
index 65b9342fb6..307e29e558 100644
--- a/src/main/java/net/runelite/client/plugins/microbot/mining/data/LocationOption.java
+++ b/src/main/java/net/runelite/client/plugins/microbot/mining/data/LocationOption.java
@@ -131,8 +131,7 @@ public boolean hasRequirements() {
// bolt ammo slot ? when the ids is any ammo
if (numberOfItems+numberOfItemsInPouch +numberOfItemsInBank< requiredAmount) {
- log.warn("Missing required item: {} x{} (have {})", itemId, requiredAmount, numberOfItems);
- Microbot.log("Missing required item: " + itemId + " x" + requiredAmount + " (have " + numberOfItems + ")");
+ log.debug("Missing required item: {} x{} (have {})", itemId, requiredAmount, numberOfItems);
return false;
}
return true;
diff --git a/src/main/java/net/runelite/client/plugins/microbot/mining/data/Rocks.java b/src/main/java/net/runelite/client/plugins/microbot/mining/data/Rocks.java
index dd985e9f41..ad4e7e3b3f 100644
--- a/src/main/java/net/runelite/client/plugins/microbot/mining/data/Rocks.java
+++ b/src/main/java/net/runelite/client/plugins/microbot/mining/data/Rocks.java
@@ -22,7 +22,8 @@ public enum Rocks {
URT_SALT("Urt salt rocks", 72),
EFH_SALT("Efh salt rocks", 72),
TE_SALT("Te salt rocks", 72),
- RUNITE("runite rocks", 85);
+ RUNITE("runite rocks", 85),
+ NONE("None", 1);
private final String name;
private final int miningLevel;
diff --git a/src/main/resources/net/runelite/client/plugins/microbot/leftclickcast/docs/README.md b/src/main/resources/net/runelite/client/plugins/microbot/leftclickcast/docs/README.md
new file mode 100644
index 0000000000..be60ed19ae
--- /dev/null
+++ b/src/main/resources/net/runelite/client/plugins/microbot/leftclickcast/docs/README.md
@@ -0,0 +1,54 @@
+# Left-Click Cast
+
+Replaces the left-click **Attack** option on attackable NPCs and on players (wilderness / PvP) with a preconfigured **Cast Spell** action. The plugin stays invisible when you swap to a melee or ranged weapon, so leaving it enabled is safe.
+
+## How it works
+
+When an "Attack" menu entry is added for an NPC, the plugin inserts a new menu entry for the selected spell and places it above Attack, making the spell the left-click action.
+
+All casting is dispatched through the Microbot client's existing `Rs2Magic.castOn(MagicAction, Actor)` — the plugin does not re-implement rune checks, spellbook switching, or targeting.
+
+## Configuration
+
+| Option | Default | Description |
+| --- | --- | --- |
+| **Enabled** | `true` | Master switch. When off, no menu entries are inserted. |
+| **Spell** | `Fire Strike` | Legacy single-spell setting. On startup the plugin migrates this into **Slot 1 Spell** if Slot 1 is still at its default — keeps existing configs working without any manual action. |
+| **Require magic weapon** | `true` | When on, the Cast entry only shows while a staff, bladed staff, powered staff, or powered wand is equipped (detected via varbit `EQUIPPED_WEAPON_TYPE`). Disable to cast regardless of weapon. |
+
+### Spell slots and hotkeys
+
+The plugin exposes five independently configurable spell slots, grouped under two sections in the config panel:
+
+| Section | Options |
+| --- | --- |
+| **Spell Slots** | `Slot 1 Spell` … `Slot 5 Spell` — each picks any spell from the full supported-spells dropdown. All five default to `Fire Strike`. |
+| **Hotkeys** | `Slot 1 Hotkey` … `Slot 5 Hotkey` — RuneLite-standard hotkey pickers, all unbound by default. `Chat feedback on slot change` — toggles the chat message posted when a hotkey switches slots (default on). |
+
+**How slot switching works:**
+
+- **Slot 1 is always active at startup.** Enabling the plugin (or restarting the client) resets the active slot to Slot 1. The active slot is runtime-only — it is never written to config.
+- **Press a bound slot hotkey** (while the game window is focused and no text field is active) to make that slot the active slot. The next menu-sort uses the new slot's spell.
+- **Unbound hotkeys are inert.** A slot whose hotkey is `Not set` cannot be activated by keypress. RuneLite's hotkey plumbing suppresses hotkeys while you're typing in a chat or search widget, so hotkey letters won't accidentally swap slots during text entry.
+- **Slot 1 needs no hotkey.** Because it's the startup default, leave its hotkey unbound unless you want to explicitly return to it from another slot.
+- **Chat feedback** (when enabled) prints `Left-Click Cast: now casting ` on every slot change. Toggle it off if it's noisy during combat rotations.
+
+## Limitations
+
+- **No rune auto-management.** If you run out of runes, the cast fails cleanly and you see the normal "You do not have enough ..." chat message.
+- **No auto-spellbook switching.** If the selected spell is not on your current spellbook, the cast fails silently. Switch spellbooks manually.
+- **The dropdown is the source of truth.** Spells not listed in the dropdown are not supported by this plugin.
+- **Staff-only default.** With `Require magic weapon` enabled (default), non-magic weapon types produce normal Attack behavior. Disable the toggle if you want to cast from melee/ranged weapons as well.
+- **Cooperative menu composition.** If another plugin inserts menu entries after this one on the same tick, its entry becomes the top entry instead — a known limitation of RuneLite's menu model.
+
+## Supported spells
+
+**Modern combat (Strike → Surge):** Wind Strike, Water Strike, Earth Strike, Fire Strike, Wind Bolt, Water Bolt, Earth Bolt, Fire Bolt, Wind Blast, Water Blast, Earth Blast, Fire Blast, Wind Wave, Water Wave, Earth Wave, Fire Wave, Wind Surge, Water Surge, Earth Surge, Fire Surge.
+
+**Ancient combat:** Smoke / Shadow / Blood / Ice — Rush, Burst, Blitz, Barrage.
+
+**Non-autocastable combat:** Crumble Undead, Iban Blast, Magic Dart, Saradomin Strike, Claws of Guthix, Flames of Zamorak.
+
+**Arceuus offensive:** Ghostly Grasp, Skeletal Grasp, Undead Grasp, Inferior Demonbane, Superior Demonbane, Dark Demonbane, Lesser Corruption, Greater Corruption.
+
+**Utility target spells:** Confuse, Weaken, Curse, Bind, Snare, Entangle, Vulnerability, Enfeeble, Stun, Tele Block, Tele Other (Lumbridge / Falador / Camelot).
diff --git a/src/test/java/net/runelite/client/Microbot.java b/src/test/java/net/runelite/client/Microbot.java
index 0bbce8830b..8933287b08 100644
--- a/src/test/java/net/runelite/client/Microbot.java
+++ b/src/test/java/net/runelite/client/Microbot.java
@@ -10,6 +10,7 @@
import net.runelite.client.plugins.microbot.astralrc.AstralRunesPlugin;
import net.runelite.client.plugins.microbot.autofishing.AutoFishingPlugin;
import net.runelite.client.plugins.microbot.example.ExamplePlugin;
+import net.runelite.client.plugins.microbot.leftclickcast.LeftClickCastPlugin;
import net.runelite.client.plugins.microbot.sailing.MSailingPlugin;
import net.runelite.client.plugins.microbot.thieving.ThievingPlugin;
import net.runelite.client.plugins.microbot.woodcutting.AutoWoodcuttingPlugin;
@@ -20,7 +21,8 @@ public class Microbot
private static final Class>[] debugPlugins = {
AIOFighterPlugin.class,
- AgentServerPlugin.class
+ AgentServerPlugin.class,
+ LeftClickCastPlugin.class
};
public static void main(String[] args) throws Exception