From d850e1f06960187382593006655de55c2cb1b412 Mon Sep 17 00:00:00 2001 From: runsonmypc Date: Wed, 27 May 2026 13:48:12 -0400 Subject: [PATCH 1/3] feat(HerbrunPlugin): add allotment, flower patch, and leprechaun compost support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add optional allotment (2 per location) and flower (1 per location) patch handling at 6 farming locations. Non-bottomless compost is now withdrawn on-demand from the tool leprechaun instead of carried from bank. - AllotmentSeedType/FlowerSeedType enums with level-gated selection - Phase system (HERB→FLOWER→ALLOTMENT→DONE) per location - ObjectID tracking for multi-tile allotment patches - Action-based patch state detection (Rake/Pick/Clear/varbit) - Produce noting via leprechaun for allotment and flower harvests - Clean plugin shutdown via Microbot.stopPlugin() on completion --- .../microbot/herbrun/AllotmentSeedType.java | 48 ++ .../microbot/herbrun/FlowerSeedType.java | 33 + .../microbot/herbrun/HerbrunConfig.java | 58 ++ .../microbot/herbrun/HerbrunPlugin.java | 2 +- .../microbot/herbrun/HerbrunScript.java | 804 +++++++++++++----- 5 files changed, 709 insertions(+), 236 deletions(-) create mode 100644 src/main/java/net/runelite/client/plugins/microbot/herbrun/AllotmentSeedType.java create mode 100644 src/main/java/net/runelite/client/plugins/microbot/herbrun/FlowerSeedType.java diff --git a/src/main/java/net/runelite/client/plugins/microbot/herbrun/AllotmentSeedType.java b/src/main/java/net/runelite/client/plugins/microbot/herbrun/AllotmentSeedType.java new file mode 100644 index 0000000000..19a4003687 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/herbrun/AllotmentSeedType.java @@ -0,0 +1,48 @@ +package net.runelite.client.plugins.microbot.herbrun; + +import lombok.Getter; +import net.runelite.api.gameval.ItemID; + +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +public enum AllotmentSeedType { + BEST("Best available", -1, 0), + POTATO("Potato seed", ItemID.POTATO_SEED, 1), + ONION("Onion seed", ItemID.ONION_SEED, 5), + CABBAGE("Cabbage seed", ItemID.CABBAGE_SEED, 7), + TOMATO("Tomato seed", ItemID.TOMATO_SEED, 12), + SWEETCORN("Sweetcorn seed", ItemID.SWEETCORN_SEED, 20), + STRAWBERRY("Strawberry seed", ItemID.STRAWBERRY_SEED, 31), + WATERMELON("Watermelon seed", ItemID.WATERMELON_SEED, 47), + SNAPE_GRASS("Snape grass seed", ItemID.SNAPE_GRASS_SEED, 61); + + private final String seedName; + private final int itemId; + private final int levelRequired; + + AllotmentSeedType(String seedName, int itemId, int levelRequired) { + this.seedName = seedName; + this.itemId = itemId; + this.levelRequired = levelRequired; + } + + public static List getPlantableSeeds(int farmingLevel) { + return Arrays.stream(values()) + .filter(seed -> seed != BEST && seed.levelRequired <= farmingLevel) + .sorted(Comparator.comparingInt(AllotmentSeedType::getLevelRequired).reversed()) + .collect(Collectors.toList()); + } + + public boolean canPlant(int farmingLevel) { + return this != BEST && farmingLevel >= this.levelRequired; + } + + @Override + public String toString() { + return seedName; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/herbrun/FlowerSeedType.java b/src/main/java/net/runelite/client/plugins/microbot/herbrun/FlowerSeedType.java new file mode 100644 index 0000000000..7cdf9012c0 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/herbrun/FlowerSeedType.java @@ -0,0 +1,33 @@ +package net.runelite.client.plugins.microbot.herbrun; + +import lombok.Getter; +import net.runelite.api.gameval.ItemID; + +@Getter +public enum FlowerSeedType { + MARIGOLD("Marigold seed", ItemID.MARIGOLD_SEED, 2), + ROSEMARY("Rosemary seed", ItemID.ROSEMARY_SEED, 11), + NASTURTIUM("Nasturtium seed", ItemID.NASTURTIUM_SEED, 24), + WOAD("Woad seed", ItemID.WOAD_SEED, 25), + LIMPWURT("Limpwurt seed", ItemID.LIMPWURT_SEED, 26), + WHITE_LILY("White lily seed", ItemID.WHITE_LILY_SEED, 58); + + private final String seedName; + private final int itemId; + private final int levelRequired; + + FlowerSeedType(String seedName, int itemId, int levelRequired) { + this.seedName = seedName; + this.itemId = itemId; + this.levelRequired = levelRequired; + } + + public boolean canPlant(int farmingLevel) { + return farmingLevel >= this.levelRequired; + } + + @Override + public String toString() { + return seedName; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/herbrun/HerbrunConfig.java b/src/main/java/net/runelite/client/plugins/microbot/herbrun/HerbrunConfig.java index 2e1774ec7d..44841c551d 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/herbrun/HerbrunConfig.java +++ b/src/main/java/net/runelite/client/plugins/microbot/herbrun/HerbrunConfig.java @@ -231,4 +231,62 @@ default boolean enableGuild() { ) String locationSection = "Location"; + @ConfigSection( + name = "Allotments", + description = "Allotment patch settings", + position = 4 + ) + String allotmentSection = "allotments"; + + @ConfigItem( + keyName = "enableAllotments", + name = "Enable Allotments", + description = "Plant and harvest allotment patches at each location", + section = allotmentSection, + position = 0 + ) + default boolean enableAllotments() { + return false; + } + + @ConfigItem( + keyName = "allotmentSeedType", + name = "Allotment Seed Type", + description = "Choose which allotment seeds to plant (3 seeds per patch)", + section = allotmentSection, + position = 1 + ) + default AllotmentSeedType allotmentSeedType() { + return AllotmentSeedType.SWEETCORN; + } + + @ConfigSection( + name = "Flowers", + description = "Flower patch settings", + position = 5 + ) + String flowerSection = "flowers"; + + @ConfigItem( + keyName = "enableFlowers", + name = "Enable Flowers", + description = "Plant and harvest flower patches at each location", + section = flowerSection, + position = 0 + ) + default boolean enableFlowers() { + return false; + } + + @ConfigItem( + keyName = "flowerSeedType", + name = "Flower Seed Type", + description = "Choose which flower seeds to plant (White lily protects all allotments)", + section = flowerSection, + position = 1 + ) + default FlowerSeedType flowerSeedType() { + return FlowerSeedType.LIMPWURT; + } + } diff --git a/src/main/java/net/runelite/client/plugins/microbot/herbrun/HerbrunPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/herbrun/HerbrunPlugin.java index d90f0d0d1b..3caeb7ef5b 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/herbrun/HerbrunPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/herbrun/HerbrunPlugin.java @@ -26,7 +26,7 @@ @Slf4j public class HerbrunPlugin extends Plugin { - public static final String version = "1.1.1"; + public static final String version = "1.2.0"; @Inject private HerbrunConfig config; diff --git a/src/main/java/net/runelite/client/plugins/microbot/herbrun/HerbrunScript.java b/src/main/java/net/runelite/client/plugins/microbot/herbrun/HerbrunScript.java index fc5fbbc17d..3bbe11d0fb 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/herbrun/HerbrunScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/herbrun/HerbrunScript.java @@ -8,6 +8,7 @@ import net.runelite.client.callback.ClientThread; import net.runelite.client.config.ConfigManager; import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.Rs2Leprechaun; import net.runelite.client.plugins.microbot.Script; import net.runelite.client.plugins.microbot.questhelper.helpers.mischelpers.farmruns.CropState; import net.runelite.client.plugins.microbot.questhelper.helpers.mischelpers.farmruns.FarmingHandler; @@ -21,7 +22,9 @@ import net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; +import net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel; import net.runelite.client.plugins.timetracking.Tab; +import net.runelite.api.coords.WorldPoint; import javax.inject.Inject; import java.util.*; @@ -43,6 +46,13 @@ public class HerbrunScript extends Script { ClientThread clientThread; private boolean initialized = false; + private enum LocationPhase { HERB, FLOWER, ALLOTMENT, DONE } + private LocationPhase currentPhase = LocationPhase.HERB; + + private static final Set ALLOTMENT_FLOWER_REGIONS = Set.of( + "Ardougne", "Catherby", "Civitas illa Fortis", "Falador", "Kourend", "Morytania" + ); + @Inject public HerbrunScript(HerbrunPlugin plugin, HerbrunConfig config) { this.plugin = plugin; @@ -50,48 +60,50 @@ public HerbrunScript(HerbrunPlugin plugin, HerbrunConfig config) { } private final List herbPatches = new ArrayList<>(); + private final Map> allotmentsByRegion = new HashMap<>(); + private final Map flowerByRegion = new HashMap<>(); + private final Set handledAllotmentIds = new HashSet<>(); + private int currentAllotmentId = -1; - public boolean run() { + public boolean run() { mainScheduledFuture = scheduledExecutorService.scheduleWithFixedDelay(() -> { if (!Microbot.isLoggedIn()) return; if (!super.run()) return; if (!initialized) { initialized = true; HerbrunPlugin.status = "Gearing up"; - populateHerbPatches(); - - // Wait for herbPatches to be populated by client thread (bounded wait with 1000ms timeout) + populatePatches(); + if (!sleepUntil(() -> !herbPatches.isEmpty(), 1000)) { - // If still empty after timeout, check once more to handle edge cases - if (herbPatches.isEmpty()) { + if (herbPatches.isEmpty()) { return; } } - + if (config.useInventorySetup()) { var inventorySetup = new Rs2InventorySetup(config.inventorySetup(), mainScheduledFuture); if (!inventorySetup.doesInventoryMatch() || !inventorySetup.doesEquipmentMatch()) { Rs2Walker.walkTo(Rs2Bank.getNearestBank().getWorldPoint(), 20); - if (!inventorySetup.loadEquipment() || !inventorySetup.loadInventory()) { + if (!inventorySetup.loadEquipment() || !inventorySetup.loadInventory()) { return; } Rs2Bank.closeBank(); } } else { - // Auto banking mode if (!setupAutoInventory()) { return; } } - log("Will visit " + herbPatches.size() + " herb patches"); + int allotmentRegions = allotmentsByRegion.size(); + int flowerCount = flowerByRegion.size(); + log("Will visit " + herbPatches.size() + " herb patches, " + allotmentRegions + " allotment locations, " + flowerCount + " flower patches"); } - - if (Rs2Inventory.hasItem("Weeds")) { - Rs2Inventory.drop("Weeds"); + if (currentPatch == null) { + getNextPatch(); + currentPhase = LocationPhase.HERB; } - if (currentPatch == null) getNextPatch(); if (currentPatch == null) { HerbrunPlugin.status = "Finishing up"; if (config.goToBank()) { @@ -100,38 +112,115 @@ public boolean run() { Rs2Bank.depositAll(); } HerbrunPlugin.status = "Finished"; + Microbot.stopPlugin(plugin); return; } - // Skip patch if it's been disabled while running if (!currentPatch.isEnabled()) { currentPatch = null; return; } - if (!currentPatch.isInRange(10)) { + if (!currentPatch.isInRange(40)) { HerbrunPlugin.status = "Walking to " + currentPatch.getRegionName(); Rs2Walker.walkTo(currentPatch.getLocation(), 20); - + return; } - HerbrunPlugin.status = "Farming " + currentPatch.getRegionName(); - if (handleHerbPatch()) getNextPatch(); + String region = currentPatch.getRegionName(); + switch (currentPhase) { + case HERB: + HerbrunPlugin.status = "Farming herbs at " + region; + if (handleHerbPatch()) { + currentPhase = LocationPhase.FLOWER; + } + break; + case FLOWER: + if (!config.enableFlowers() || !flowerByRegion.containsKey(region)) { + log("Skipping flowers at " + region + " (enabled=" + config.enableFlowers() + " hasRegion=" + flowerByRegion.containsKey(region) + " keys=" + flowerByRegion.keySet() + ")"); + currentPhase = LocationPhase.ALLOTMENT; + } else { + HerbrunPlugin.status = "Farming flowers at " + region; + if (handleFlowerPatch(region)) { + currentPhase = LocationPhase.ALLOTMENT; + } + } + break; + case ALLOTMENT: + if (!config.enableAllotments() || !allotmentsByRegion.containsKey(region)) { + log("Skipping allotments at " + region + " (enabled=" + config.enableAllotments() + " hasRegion=" + allotmentsByRegion.containsKey(region) + " keys=" + allotmentsByRegion.keySet() + ")"); + currentPhase = LocationPhase.DONE; + } else { + HerbrunPlugin.status = "Farming allotments at " + region; + if (handleAllotmentPatches(region)) { + currentPhase = LocationPhase.DONE; + } + } + break; + case DONE: + currentPatch = null; + currentPhase = LocationPhase.HERB; + handledAllotmentIds.clear(); + currentAllotmentId = -1; + break; + } }, 0, 1000, TimeUnit.MILLISECONDS); return true; } - private void populateHerbPatches() { + private void populatePatches() { this.farmingHandler = new FarmingHandler(Microbot.getClient(), configManager); herbPatches.clear(); + allotmentsByRegion.clear(); + flowerByRegion.clear(); + clientThread.runOnClientThreadOptional(() -> { + Map allHerbsByRegion = new HashMap<>(); + for (FarmingPatch patch : farmingWorld.getTabs().get(Tab.HERB)) { HerbPatch _patch = new HerbPatch(patch, config, farmingHandler); - if (_patch.getPrediction() != CropState.GROWING && _patch.isEnabled()) herbPatches.add(_patch); + if (!_patch.isEnabled()) continue; + allHerbsByRegion.put(_patch.getRegionName(), _patch); + if (_patch.getPrediction() != CropState.GROWING) { + herbPatches.add(_patch); + } + } + + if (config.enableAllotments()) { + for (FarmingPatch patch : farmingWorld.getTabs().get(Tab.ALLOTMENT)) { + String region = patch.getRegion().getName(); + if (!ALLOTMENT_FLOWER_REGIONS.contains(region)) continue; + CropState prediction = farmingHandler.predictPatch(patch); + if (prediction != CropState.GROWING) { + allotmentsByRegion.computeIfAbsent(region, k -> new ArrayList<>()).add(patch); + } + } } + + if (config.enableFlowers()) { + for (FarmingPatch patch : farmingWorld.getTabs().get(Tab.FLOWER)) { + String region = patch.getRegion().getName(); + if (!ALLOTMENT_FLOWER_REGIONS.contains(region)) continue; + CropState prediction = farmingHandler.predictPatch(patch); + if (prediction != CropState.GROWING) { + flowerByRegion.put(region, patch); + } + } + } + + for (String region : allHerbsByRegion.keySet()) { + boolean alreadyInList = herbPatches.stream().anyMatch(p -> p.getRegionName().equals(region)); + if (alreadyInList) continue; + boolean hasAllotmentWork = allotmentsByRegion.containsKey(region); + boolean hasFlowerWork = flowerByRegion.containsKey(region); + if (hasAllotmentWork || hasFlowerWork) { + herbPatches.add(allHerbsByRegion.get(region)); + } + } + return true; }); } @@ -142,7 +231,6 @@ private void getNextPatch() { return; } - // Start with weiss, getNearestBank doesn't like that area! currentPatch = herbPatches.stream() .filter(patch -> patch.isEnabled() && Objects.equals(patch.getRegionName(), "Weiss")) .findFirst() @@ -150,100 +238,84 @@ private void getNextPatch() { .filter(HerbPatch::isEnabled) .findFirst() .orElse(null)); - + if (currentPatch != null) { herbPatches.remove(currentPatch); } } } + private static final int[] ALLOTMENT_PATCH_IDS = { + ObjectID.FARMING_VEG_PATCH_1, ObjectID.FARMING_VEG_PATCH_2, + ObjectID.FARMING_VEG_PATCH_3, ObjectID.FARMING_VEG_PATCH_4, + ObjectID.FARMING_VEG_PATCH_5, ObjectID.FARMING_VEG_PATCH_6, + ObjectID.FARMING_VEG_PATCH_7, ObjectID.FARMING_VEG_PATCH_8, + ObjectID.FARMING_VEG_PATCH_9, ObjectID.FARMING_VEG_PATCH_10, + ObjectID.FARMING_VEG_PATCH_11, ObjectID.FARMING_VEG_PATCH_12, + ObjectID.FARMING_VEG_PATCH_13, ObjectID.FARMING_VEG_PATCH_14, + ObjectID.FARMING_VEG_PATCH_15, ObjectID.FARMING_VEG_PATCH_16, + ObjectID.FARMING_VEG_PATCH_17 + }; + + private static final int[] HERB_PATCH_IDS = { + ObjectID.MYARM_HERBPATCH, + ObjectID.FARMING_HERB_PATCH_2, + ObjectID.FARMING_HERB_PATCH_4, + ObjectID.FARMING_HERB_PATCH_8, + ObjectID.FARMING_HERB_PATCH_6, + ObjectID.FARMING_HERB_PATCH_3, + ObjectID.FARMING_HERB_PATCH_1, + ObjectID.FARMING_HERB_PATCH_7, + ObjectID.MY2ARM_HERBPATCH, + ObjectID.FARMING_HERB_PATCH_5 + }; + + // --- Herb patch handling (existing logic, refactored for shared compost/noting) --- + private boolean handleHerbPatch() { - if (Rs2Inventory.isFull()) { - Rs2NpcModel leprechaun = Microbot.getRs2NpcCache().query().withName("Tool leprechaun").nearestOnClientThread(); - if (leprechaun != null) { - Rs2ItemModel unNoted = Rs2Inventory.getUnNotedItem("Grimy", false); - if (unNoted != null) { - Rs2Inventory.use(unNoted); - leprechaun.click("Talk-to"); - Rs2Inventory.waitForInventoryChanges(10000); - } else { - // No grimy herbs to note - try to drop weeds or empty buckets as fallback - if (Rs2Inventory.hasItem("Weeds")) { - Rs2Inventory.drop("Weeds"); - } else if (Rs2Inventory.hasItem(ItemID.BUCKET_EMPTY)) { - Rs2Inventory.drop(ItemID.BUCKET_EMPTY); - } - } - } + if (!ensureInventorySpace()) return false; + + final var obj = Microbot.getRs2TileObjectCache().query().withIds(HERB_PATCH_IDS).nearest(); + if (obj == null) return true; + String state = getHerbPatchState(obj); + + if (state.equals("Harvestable")) { + obj.click("Pick"); + Rs2Player.waitForWalking(); + sleepUntil(() -> getHerbPatchState(obj).equals("Empty") || Rs2Inventory.isFull(), 20000); return false; } - var obj = Microbot.getRs2TileObjectCache().query().withIds( - ObjectID.MYARM_HERBPATCH, - ObjectID.FARMING_HERB_PATCH_2, - ObjectID.FARMING_HERB_PATCH_4, - ObjectID.FARMING_HERB_PATCH_8, - ObjectID.FARMING_HERB_PATCH_6, - ObjectID.FARMING_HERB_PATCH_3, - ObjectID.FARMING_HERB_PATCH_1, - ObjectID.FARMING_HERB_PATCH_7, - ObjectID.MY2ARM_HERBPATCH, - ObjectID.FARMING_HERB_PATCH_5 - ).nearest(); - if (obj == null) return false; - var tileObj = Rs2GameObject.findObjectById(obj.getId()); - if (tileObj == null) return false; - var state = getHerbPatchState(tileObj); - switch (state) { - case "Empty": - if (config.compostType() != CompostType.NONE) { - CompostType compost = config.compostType(); - if (!Rs2Inventory.hasItem(compost.getItemId())) { - log("Configured compost not found in inventory: " + compost.getCompostName()); - return false; - } - Rs2Inventory.use(compost.getItemId()); - obj.click("Compost"); - Rs2Player.waitForXpDrop(Skill.FARMING, 10000, false); + if (state.equals("Weeds")) { + obj.click("Rake"); + Rs2Player.waitForWalking(); + sleepUntil(() -> !getHerbPatchState(obj).equals("Weeds"), 15000); + state = getHerbPatchState(obj); + } - if (config.dropEmptyBuckets() && !config.compostType().isBottomless()) { - Rs2Inventory.drop(ItemID.BUCKET_EMPTY); - } - } - HerbSeedType seedInInventory = getFirstHerbSeedInInventory(); - if (seedInInventory != null) { - Rs2Inventory.use(seedInInventory.getItemId()); - obj.click("Plant"); - sleepUntil(() -> { - var re = Rs2GameObject.findObjectById(obj.getId()); - return re != null && getHerbPatchState(re).equals("Growing"); - }, 10000); - } else { - log("No herb seeds found in inventory for planting"); - } - return false; - case "Harvestable": - obj.click("Pick"); - sleepUntil(() -> { - var re = Rs2GameObject.findObjectById(obj.getId()); - return (re != null && getHerbPatchState(re).equals("Empty")) || Rs2Inventory.isFull(); - }, 20000); - return false; - case "Weeds": - obj.click("Rake"); - Rs2Player.waitForAnimation(10000); - return false; - case "Dead": - obj.click("Clear"); - sleepUntil(() -> { - var re = Rs2GameObject.findObjectById(obj.getId()); - return re != null && getHerbPatchState(re).equals("Empty"); - }, 10000); - return false; - default: - currentPatch = null; + if (state.equals("Dead")) { + obj.click("Clear"); + Rs2Player.waitForWalking(); + sleepUntil(() -> getHerbPatchState(obj).equals("Empty"), 10000); + state = getHerbPatchState(obj); + } + + if (state.equals("Empty")) { + if (Rs2Inventory.hasItem("Weeds")) Rs2Inventory.dropAll("Weeds"); + HerbSeedType seedInInventory = getFirstHerbSeedInInventory(); + if (seedInInventory == null) { + log("No herb seeds found in inventory, skipping patch"); return true; + } + if (!applyCompost(obj)) return false; + Rs2Inventory.use(seedInInventory.getItemId()); + obj.click("Plant"); + Rs2Player.waitForWalking(); + sleepUntil(() -> getHerbPatchState(obj).equals("Growing"), 10000); + return false; } + + return true; } private static String getHerbPatchState(TileObject rs2TileObject) { @@ -307,11 +379,278 @@ private static String getHerbPatchState(TileObject rs2TileObject) { return "Empty"; } + // --- Flower patch handling --- + + private static final int[] FLOWER_PATCH_IDS = { + ObjectID.FARMING_FLOWER_PATCH_1, ObjectID.FARMING_FLOWER_PATCH_2, + ObjectID.FARMING_FLOWER_PATCH_3, ObjectID.FARMING_FLOWER_PATCH_4, + ObjectID.FARMING_FLOWER_PATCH_5, ObjectID.FARMING_FLOWER_PATCH_6, + ObjectID.FARMING_FLOWER_PATCH_7, ObjectID.FARMING_FLOWER_PATCH_8, + ObjectID.FARMING_FLOWER_PATCH_9 + }; + + private boolean handleFlowerPatch(String region) { + if (!ensureInventorySpace()) return false; + + final var obj = Microbot.getRs2TileObjectCache().query().withIds(FLOWER_PATCH_IDS).nearest(); + if (obj == null) { + log("[Flower] No flower patch object found at " + region); + return true; + } + + String state = getPatchState(obj); + log("[Flower] Patch at " + obj.getWorldLocation() + " state=" + state); + + switch (state) { + case "Harvestable": + obj.click("Pick"); + Rs2Player.waitForWalking(); + sleepUntil(() -> getPatchState(obj).equals("Empty") || Rs2Inventory.isFull(), 10000); + return false; + case "Weeds": + obj.click("Rake"); + Rs2Player.waitForWalking(); + sleepUntil(() -> !getPatchState(obj).equals("Weeds"), 15000); + return false; + case "Dead": + obj.click("Clear"); + Rs2Player.waitForWalking(); + sleepUntil(() -> getPatchState(obj).equals("Empty"), 10000); + return false; + case "Empty": + if (Rs2Inventory.hasItem("Weeds")) Rs2Inventory.dropAll("Weeds"); + FlowerSeedType flowerSeed = config.flowerSeedType(); + if (!Rs2Inventory.hasItem(flowerSeed.getItemId())) { + log("[Flower] No " + flowerSeed.getSeedName() + " in inventory, skipping"); + return true; + } + if (!applyCompost(obj)) return false; + Rs2Inventory.use(flowerSeed.getItemId()); + obj.click("Plant"); + Rs2Player.waitForWalking(); + sleepUntil(() -> getPatchState(obj).equals("Growing"), 10000); + log("[Flower] Planted at " + obj.getWorldLocation()); + return true; + default: + log("[Flower] Patch is " + state + ", skipping"); + return true; + } + } + + // --- Allotment patch handling --- + + private boolean handleAllotmentPatches(String region) { + if (!ensureInventorySpace()) return false; + + var allObjects = Microbot.getRs2TileObjectCache().query().withIds(ALLOTMENT_PATCH_IDS).toList(); + if (allObjects == null || allObjects.isEmpty()) { + log("[Allotment] No allotment objects found in scene"); + currentAllotmentId = -1; + return true; + } + + Rs2TileObjectModel pinned = null; + if (currentAllotmentId >= 0) { + pinned = allObjects.stream() + .filter(o -> o.getId() == currentAllotmentId && o.isReachable()) + .min(Comparator.comparingInt(o -> o.getWorldLocation().distanceTo(Rs2Player.getWorldLocation()))) + .orElse(null); + if (pinned == null || handledAllotmentIds.contains(currentAllotmentId)) { + log("[Allotment] Finished patch id=" + currentAllotmentId + ", looking for next"); + currentAllotmentId = -1; + pinned = null; + } + } + if (currentAllotmentId < 0) { + pinned = allObjects.stream() + .filter(o -> !handledAllotmentIds.contains(o.getId()) && o.isReachable()) + .min(Comparator.comparingInt(o -> o.getWorldLocation().distanceTo(Rs2Player.getWorldLocation()))) + .orElse(null); + if (pinned == null) { + log("[Allotment] All patches handled at " + region); + return true; + } + currentAllotmentId = pinned.getId(); + log("[Allotment] Starting patch id=" + currentAllotmentId + " near " + pinned.getWorldLocation()); + } + final var obj = pinned; + + String state = getPatchState(obj); + log("[Allotment] Patch id=" + currentAllotmentId + " state=" + state); + + switch (state) { + case "Harvestable": + obj.click("Pick"); + Rs2Player.waitForWalking(); + sleepUntil(() -> getPatchState(obj).equals("Empty") || Rs2Inventory.isFull(), 20000); + return false; + case "Weeds": + obj.click("Rake"); + Rs2Player.waitForWalking(); + sleepUntil(() -> !getPatchState(obj).equals("Weeds"), 15000); + return false; + case "Dead": + obj.click("Clear"); + Rs2Player.waitForWalking(); + sleepUntil(() -> getPatchState(obj).equals("Empty"), 10000); + return false; + case "Empty": + if (Rs2Inventory.hasItem("Weeds")) Rs2Inventory.dropAll("Weeds"); + AllotmentSeedType allotmentSeed = getFirstAllotmentSeedInInventory(); + if (allotmentSeed == null || Rs2Inventory.itemQuantity(allotmentSeed.getItemId()) < 3) { + log("[Allotment] Not enough seeds (need 3), skipping id=" + currentAllotmentId); + handledAllotmentIds.add(currentAllotmentId); + currentAllotmentId = -1; + return false; + } + if (!applyCompost(obj)) return false; + Rs2Inventory.use(allotmentSeed.getItemId()); + obj.click("Plant"); + Rs2Player.waitForWalking(); + sleepUntil(() -> getPatchState(obj).equals("Growing"), 10000); + log("[Allotment] Planted id=" + currentAllotmentId); + handledAllotmentIds.add(currentAllotmentId); + currentAllotmentId = -1; + return false; + default: + log("[Allotment] Patch id=" + currentAllotmentId + " is " + state + ", marking done"); + handledAllotmentIds.add(currentAllotmentId); + currentAllotmentId = -1; + return false; + } + } + + // --- Shared patch state detection via game object actions --- + + private static String getPatchState(TileObject tileObj) { + if (Rs2GameObject.hasAction(tileObj, "Rake")) return "Weeds"; + if (Rs2GameObject.hasAction(tileObj, "Pick")) return "Harvestable"; + if (Rs2GameObject.hasAction(tileObj, "Harvest")) return "Harvestable"; + if (Rs2GameObject.hasAction(tileObj, "Clear")) return "Dead"; + + var comp = Rs2GameObject.convertToObjectComposition(tileObj, true); + int varbit = Microbot.getVarbitValue(comp.getVarbitId()); + if (varbit <= 5) return "Empty"; + + return "Growing"; + } + + // --- Shared compost application --- + + private boolean applyCompost(Rs2TileObjectModel obj) { + CompostType compost = config.compostType(); + if (compost == CompostType.NONE) return true; + + if (compost.isBottomless()) { + if (!Rs2Inventory.hasItem(compost.getItemId())) { + log("Bottomless compost bucket not found in inventory"); + return false; + } + } else { + if (!Rs2Inventory.hasItem(compost.getItemId())) { + if (!Rs2Leprechaun.withdrawCompost(compost.getItemId())) { + log("Failed to withdraw " + compost.getCompostName() + " from leprechaun"); + return false; + } + return false; + } + } + + int xpBefore = Microbot.getClient().getSkillExperience(Skill.FARMING); + Rs2Inventory.use(compost.getItemId()); + obj.click("Compost"); + Rs2Player.waitForWalking(); + boolean applied = sleepUntil( + () -> Microbot.getClient().getSkillExperience(Skill.FARMING) > xpBefore, 5000); + + if (!applied) { + log("Patch already composted, skipping"); + } else if (config.dropEmptyBuckets() && !compost.isBottomless()) { + Rs2Inventory.drop(ItemID.BUCKET_EMPTY); + } + return true; + } + + // --- Shared inventory space management --- + + private boolean ensureInventorySpace() { + if (!Rs2Inventory.isFull()) return true; + return noteProduceViaLeprechaun(); + } + + private boolean noteProduceViaLeprechaun() { + Rs2NpcModel leprechaun = Microbot.getRs2NpcCache().query().withName("Tool leprechaun").nearestOnClientThread(); + if (leprechaun == null) return false; + + Rs2ItemModel unNoted = Rs2Inventory.getUnNotedItem("Grimy", false); + if (unNoted == null) unNoted = getFirstUnNotedProduce(); + + if (unNoted != null) { + Rs2Inventory.use(unNoted); + leprechaun.click("Talk-to"); + Rs2Inventory.waitForInventoryChanges(10000); + return true; + } + + if (Rs2Inventory.hasItem("Weeds")) { + Rs2Inventory.dropAll("Weeds"); + return true; + } + if (Rs2Inventory.hasItem(ItemID.BUCKET_EMPTY)) { + Rs2Inventory.drop(ItemID.BUCKET_EMPTY); + return true; + } + return false; + } + + private static final String[] ALLOTMENT_PRODUCE = { + "Potato", "Onion", "Cabbage", "Tomato", "Sweetcorn", "Strawberry", "Watermelon", "Snape grass" + }; + private static final String[] FLOWER_PRODUCE = { + "Marigold", "Rosemary", "Nasturtium", "Woad leaf", "Limpwurt root", "White lily" + }; + + private Rs2ItemModel getFirstUnNotedProduce() { + for (String name : ALLOTMENT_PRODUCE) { + Rs2ItemModel item = Rs2Inventory.getUnNotedItem(name, true); + if (item != null) return item; + } + for (String name : FLOWER_PRODUCE) { + Rs2ItemModel item = Rs2Inventory.getUnNotedItem(name, true); + if (item != null) return item; + } + return null; + } + + // --- Seed helpers --- + + private HerbSeedType getFirstHerbSeedInInventory() { + for (HerbSeedType herbType : HerbSeedType.values()) { + if (herbType != HerbSeedType.BEST && Rs2Inventory.hasItem(herbType.getItemId())) { + return herbType; + } + } + return null; + } + + private AllotmentSeedType getFirstAllotmentSeedInInventory() { + if (config.allotmentSeedType() != AllotmentSeedType.BEST) { + AllotmentSeedType selected = config.allotmentSeedType(); + if (Rs2Inventory.hasItem(selected.getItemId())) return selected; + return null; + } + for (AllotmentSeedType seed : AllotmentSeedType.getPlantableSeeds( + Microbot.getClient().getRealSkillLevel(Skill.FARMING))) { + if (Rs2Inventory.hasItem(seed.getItemId())) return seed; + } + return null; + } + + // --- Banking --- + private boolean setupAutoInventory() { - // Walk to nearest bank Rs2Walker.walkTo(Rs2Bank.getNearestBank().getWorldPoint(), 20); - - // Open bank + if (!Rs2Bank.openBank()) { log("Failed to open bank"); return false; @@ -320,15 +659,13 @@ private boolean setupAutoInventory() { log("Timeout waiting for bank to open after 10 seconds"); return false; } - - // Deposit all items into bank + Rs2Bank.depositAll(); Rs2Inventory.waitForInventoryChanges(5000); - - // Count enabled patches to know how many seeds/compost we need - int patchCount = (int) herbPatches.stream().filter(HerbPatch::isEnabled).count(); - - // Withdraw farming tools (fail fast if missing) + + int herbPatchCount = (int) herbPatches.stream().filter(HerbPatch::isEnabled).count(); + int allotmentLocationCount = countEnabledAllotmentFlowerLocations(); + boolean toolsOk = true; toolsOk &= Rs2Bank.withdrawX(ItemID.RAKE, 1); toolsOk &= Rs2Bank.withdrawX(ItemID.SPADE, 1); @@ -337,190 +674,187 @@ private boolean setupAutoInventory() { log("Missing farming tools in bank (rake/spade/dibber)"); return false; } - - // Withdraw magic secateurs if available + if (Rs2Bank.hasItem(ItemID.FAIRY_ENCHANTED_SECATEURS)) { Rs2Bank.withdrawX(ItemID.FAIRY_ENCHANTED_SECATEURS, 1); } - - // Withdraw teleportation runes + boolean missingRunes = false; missingRunes |= !Rs2Bank.withdrawX(ItemID.LAWRUNE, 20); missingRunes |= !Rs2Bank.withdrawX(ItemID.AIRRUNE, 50); missingRunes |= !Rs2Bank.withdrawX(ItemID.EARTHRUNE, 50); missingRunes |= !Rs2Bank.withdrawX(ItemID.FIRERUNE, 50); missingRunes |= !Rs2Bank.withdrawX(ItemID.WATERRUNE, 50); - + if (missingRunes) { log("Missing teleportation runes - cannot complete herb run"); return false; } - - // Withdraw Ectophial if Morytania is enabled + if (config.enableMorytania() && Rs2Bank.hasItem(ItemID.ECTOPHIAL)) { Rs2Bank.withdrawX(ItemID.ECTOPHIAL, 1); } - + // Withdraw herb seeds HerbSeedType seedType = config.herbSeedType(); if (seedType == HerbSeedType.BEST) { - // Handle dynamic seed selection based on farming level and availability - if (!withdrawBestAvailableSeeds(patchCount)) { - log("Failed to withdraw best available seeds for " + patchCount + " patches"); + if (!withdrawBestAvailableSeeds(herbPatchCount)) { + log("Failed to withdraw best available herb seeds for " + herbPatchCount + " patches"); return false; } } else { - // Original logic for specific seed type - int seedsNeeded = patchCount; // 1 seed per patch - - // Validate farming level requirement - int farmingLevel = Microbot.getClient().getRealSkillLevel(Skill.FARMING); - if (!seedType.canPlant(farmingLevel)) { - log("Cannot plant " + seedType.getSeedName() + " - requires Farming level " + - seedType.getLevelRequired() + " (you have " + farmingLevel + ")"); + if (!withdrawSpecificSeeds(seedType.getItemId(), seedType.getSeedName(), herbPatchCount, + seedType.getLevelRequired(), config.allowPartialRuns())) { return false; } - - if (!Rs2Bank.withdrawX(seedType.getItemId(), seedsNeeded)) { - if (!config.allowPartialRuns()) { - log("Failed to withdraw " + seedsNeeded + " " + seedType.getSeedName()); - return false; + } + + // Withdraw allotment seeds + if (config.enableAllotments() && allotmentLocationCount > 0) { + int allotmentSeedsNeeded = 3 * 2 * allotmentLocationCount; + AllotmentSeedType allotmentSeed = config.allotmentSeedType(); + if (allotmentSeed == AllotmentSeedType.BEST) { + if (!withdrawBestAvailableAllotmentSeeds(allotmentSeedsNeeded)) { + log("Failed to withdraw allotment seeds"); + if (!config.allowPartialRuns()) return false; } - // Try to withdraw whatever is available - int availableSeeds = Rs2Bank.count(seedType.getItemId()); - if (availableSeeds > 0) { - if (Rs2Bank.withdrawX(seedType.getItemId(), availableSeeds)) { - log("Partial run: withdrew " + availableSeeds + " " + seedType.getSeedName() + " instead of " + seedsNeeded); - } else { - log("Failed to withdraw any " + seedType.getSeedName()); - return false; - } - } else { - log("No " + seedType.getSeedName() + " available in bank"); - return false; + } else { + if (!withdrawSpecificSeeds(allotmentSeed.getItemId(), allotmentSeed.getSeedName(), + allotmentSeedsNeeded, allotmentSeed.getLevelRequired(), config.allowPartialRuns())) { + if (!config.allowPartialRuns()) return false; } } } - - // Withdraw compost if enabled + + // Withdraw flower seeds + if (config.enableFlowers() && allotmentLocationCount > 0) { + FlowerSeedType flowerSeed = config.flowerSeedType(); + int flowerSeedsNeeded = allotmentLocationCount; + if (!withdrawSpecificSeeds(flowerSeed.getItemId(), flowerSeed.getSeedName(), + flowerSeedsNeeded, flowerSeed.getLevelRequired(), config.allowPartialRuns())) { + if (!config.allowPartialRuns()) return false; + } + } + + // Withdraw compost (bottomless only — non-bottomless comes from leprechaun) CompostType compostType = config.compostType(); - if (compostType != CompostType.NONE) { - if (compostType.isBottomless()) { - // For bottomless bucket, just withdraw the bucket itself - if (!Rs2Bank.withdrawX(compostType.getItemId(), 1)) { - log("Failed to withdraw bottomless compost bucket"); - return false; - } - } else { - // For regular compost, withdraw 1 bucket per patch - int compostNeeded = patchCount; - if (!Rs2Bank.withdrawX(compostType.getItemId(), compostNeeded)) { - log("Failed to withdraw " + compostNeeded + " " + compostType.getCompostName()); - return false; - } + if (compostType != CompostType.NONE && compostType.isBottomless()) { + if (!Rs2Bank.withdrawX(compostType.getItemId(), 1)) { + log("Failed to withdraw bottomless compost bucket"); + return false; } } - - // Close bank + Rs2Bank.closeBank(); sleepUntil(() -> !Rs2Bank.isOpen(), 5000); - - log("Inventory setup complete - starting herb run"); + + log("Inventory setup complete - starting farm run"); return true; } - /** - * Helper method to find the first herb seed present in inventory - * - * @return HerbSeedType of the first seed found, or null if none found - */ - private HerbSeedType getFirstHerbSeedInInventory() { - for (HerbSeedType herbType : HerbSeedType.values()) { - if (herbType != HerbSeedType.BEST && Rs2Inventory.hasItem(herbType.getItemId())) { - return herbType; + private boolean withdrawSpecificSeeds(int itemId, String seedName, int count, int levelRequired, boolean allowPartial) { + int farmingLevel = Microbot.getClient().getRealSkillLevel(Skill.FARMING); + if (farmingLevel < levelRequired) { + log("Cannot plant " + seedName + " - requires Farming level " + levelRequired + " (you have " + farmingLevel + ")"); + return false; + } + + if (!Rs2Bank.withdrawX(itemId, count)) { + if (!allowPartial) { + log("Failed to withdraw " + count + " " + seedName); + return false; + } + int available = Rs2Bank.count(itemId); + if (available > 0) { + int toWithdraw = Math.min(available, count); + Rs2Bank.withdrawX(itemId, toWithdraw); + log("Partial run: withdrew " + toWithdraw + " " + seedName + " instead of " + count); + } else { + log("No " + seedName + " available in bank"); + return false; } } - return null; + Rs2Inventory.waitForInventoryChanges(2000); + return true; } - /** - * Withdraws the best available herb seeds based on farming level and bank availability - * - * @param patchCount Number of patches that need seeds - * @return true if enough seeds were withdrawn for all patches - */ private boolean withdrawBestAvailableSeeds(int patchCount) { - // Get player's farming level int farmingLevel = Microbot.getClient().getRealSkillLevel(Skill.FARMING); - - // Get all plantable herbs sorted by level (highest first) List plantableHerbs = HerbSeedType.getPlantableHerbs(farmingLevel); - + if (plantableHerbs.isEmpty()) { log("No herbs can be planted at farming level " + farmingLevel); return false; } - + int seedsWithdrawn = 0; - int seedsNeeded = patchCount; - - // Track which seed types we're withdrawing for logging - Map withdrawnSeeds = new HashMap<>(); - - // Try to withdraw seeds starting from highest level herbs + for (HerbSeedType herb : plantableHerbs) { - if (seedsWithdrawn >= seedsNeeded) { - break; // We have enough seeds - } - - // Check how many of this seed type we have in bank + if (seedsWithdrawn >= patchCount) break; int availableSeeds = Rs2Bank.count(herb.getItemId()); - if (availableSeeds > 0) { - // Calculate how many to withdraw (up to what we need) - int toWithdraw = Math.min(availableSeeds, seedsNeeded - seedsWithdrawn); - - // Withdraw the seeds + int toWithdraw = Math.min(availableSeeds, patchCount - seedsWithdrawn); if (Rs2Bank.withdrawX(herb.getItemId(), toWithdraw)) { seedsWithdrawn += toWithdraw; - withdrawnSeeds.put(herb, toWithdraw); - log("Withdrew " + toWithdraw + " " + herb.getSeedName() + " (level " + herb.getLevelRequired() + ")"); - } else { - log("Failed to withdraw " + herb.getSeedName() + " despite having " + availableSeeds + " in bank"); + log("Withdrew " + toWithdraw + " " + herb.getSeedName()); } } } - - // Check if we got enough seeds - if (seedsWithdrawn < seedsNeeded) { - log("Could only withdraw " + seedsWithdrawn + " seeds, need " + seedsNeeded + " for all patches"); - - // Log what we managed to get - if (!withdrawnSeeds.isEmpty()) { - StringBuilder sb = new StringBuilder("Seeds withdrawn: "); - withdrawnSeeds.forEach((herb, count) -> - sb.append(count).append("x ").append(herb.getSeedName()).append(", ")); - log(sb.toString()); - } - - if (!config.allowPartialRuns()) { - return false; // Require all seeds when partial runs are disabled + + if (seedsWithdrawn < patchCount && !config.allowPartialRuns()) { + return false; + } + return seedsWithdrawn > 0; + } + + private boolean withdrawBestAvailableAllotmentSeeds(int totalSeedsNeeded) { + int farmingLevel = Microbot.getClient().getRealSkillLevel(Skill.FARMING); + List plantableSeeds = AllotmentSeedType.getPlantableSeeds(farmingLevel); + + if (plantableSeeds.isEmpty()) { + log("No allotment seeds can be planted at farming level " + farmingLevel); + return false; + } + + int seedsWithdrawn = 0; + + for (AllotmentSeedType seed : plantableSeeds) { + if (seedsWithdrawn >= totalSeedsNeeded) break; + int available = Rs2Bank.count(seed.getItemId()); + if (available > 0) { + int toWithdraw = Math.min(available, totalSeedsNeeded - seedsWithdrawn); + if (Rs2Bank.withdrawX(seed.getItemId(), toWithdraw)) { + seedsWithdrawn += toWithdraw; + log("Withdrew " + toWithdraw + " " + seed.getSeedName()); + } } - return seedsWithdrawn > 0; // Return true if we got at least some seeds - } - - // Success - log summary - StringBuilder summary = new StringBuilder("Successfully withdrew seeds for " + patchCount + " patches: "); - withdrawnSeeds.forEach((herb, count) -> - summary.append(count).append("x ").append(herb.getSeedName()).append(" (lvl ").append(herb.getLevelRequired()).append("), ")); - log(summary.toString()); - - return true; + } + + if (seedsWithdrawn < totalSeedsNeeded && !config.allowPartialRuns()) { + return false; + } + return seedsWithdrawn > 0; + } + + private int countEnabledAllotmentFlowerLocations() { + int count = 0; + if (config.enableArdougne()) count++; + if (config.enableCatherby()) count++; + if (config.enableVarlamore()) count++; + if (config.enableFalador()) count++; + if (config.enableHosidius()) count++; + if (config.enableMorytania()) count++; + return count; } @Override public void shutdown() { super.shutdown(); initialized = false; + allotmentsByRegion.clear(); + flowerByRegion.clear(); + currentPhase = LocationPhase.HERB; + handledAllotmentIds.clear(); + currentAllotmentId = -1; } } From 750d967cabd7a91540f7589df6fa5731dc8dad13 Mon Sep 17 00:00:00 2001 From: runsonmypc Date: Thu, 4 Jun 2026 21:44:00 -0400 Subject: [PATCH 2/3] fix(HerbrunPlugin): reachable allotment tiles, nearest-tile selection, plant retry, note all produce - Allotment tile selection: replace isReachable() (returns true for any same-worldview object) with a standable-neighbour check so interior tiles of a multi-tile patch are skipped and an actually-reachable edge tile is chosen. - Pick the nearest tile by squared Euclidean distance instead of WorldPoint.distanceTo (Chebyshev), which tied diagonal and orthogonal tiles and broke ties by list order. - Planting (allotment + flower): only mark the patch done once it is confirmed Growing; otherwise leave it pinned and retry, so a plant that fails (weeds regrew between clearing and planting, or a missed click) no longer silently gives up. - Leprechaun noting: note every notable produce type held in quantity > 1 (any grimy herb plus allotment/flower produce), not just the crop currently being harvested, so produce from an earlier patch can no longer fill the inventory unnoted. --- .../microbot/herbrun/HerbrunScript.java | 102 +++++++++++++----- 1 file changed, 75 insertions(+), 27 deletions(-) diff --git a/src/main/java/net/runelite/client/plugins/microbot/herbrun/HerbrunScript.java b/src/main/java/net/runelite/client/plugins/microbot/herbrun/HerbrunScript.java index 3bbe11d0fb..0f10d18435 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/herbrun/HerbrunScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/herbrun/HerbrunScript.java @@ -21,6 +21,7 @@ import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; import net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel; import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel; import net.runelite.client.plugins.timetracking.Tab; @@ -428,9 +429,14 @@ private boolean handleFlowerPatch(String region) { Rs2Inventory.use(flowerSeed.getItemId()); obj.click("Plant"); Rs2Player.waitForWalking(); - sleepUntil(() -> getPatchState(obj).equals("Growing"), 10000); - log("[Flower] Planted at " + obj.getWorldLocation()); - return true; + // Only advance once Growing is confirmed; otherwise retry next tick (weeds may have + // regrown between clearing and planting, or the click missed). + if (sleepUntil(() -> getPatchState(obj).equals("Growing"), 10000)) { + log("[Flower] Planted at " + obj.getWorldLocation()); + return true; + } + log("[Flower] Plant not confirmed (state=" + getPatchState(obj) + "), retrying"); + return false; default: log("[Flower] Patch is " + state + ", skipping"); return true; @@ -439,6 +445,30 @@ private boolean handleFlowerPatch(String region) { // --- Allotment patch handling --- + /** + * A multi-tile allotment patch shares one ObjectID across ~12 tiles. Interior tiles cannot be + * interacted with because the player has no adjacent tile to stand on — all four neighbours are + * other (blocked) patch tiles. An edge tile is interactable: at least one orthogonal neighbour is + * open ground (passes the BLOCK_MOVEMENT_FULL collision check). isReachable() can't distinguish + * these (it returns true for any same-worldview object), so we test for a standable neighbour here. + */ + private static boolean hasStandableNeighbor(WorldPoint tile) { + if (tile == null) return false; + return Rs2Tile.isWalkable(tile.dx(1)) + || Rs2Tile.isWalkable(tile.dx(-1)) + || Rs2Tile.isWalkable(tile.dy(1)) + || Rs2Tile.isWalkable(tile.dy(-1)); + } + + /** Squared Euclidean distance — finer than WorldPoint.distanceTo (Chebyshev), so ties between a + * diagonal and an orthogonal tile resolve toward the orthogonal (genuinely nearest) one. */ + private static int sqDist(WorldPoint a, WorldPoint b) { + if (a == null || b == null) return Integer.MAX_VALUE; + int dx = a.getX() - b.getX(); + int dy = a.getY() - b.getY(); + return dx * dx + dy * dy; + } + private boolean handleAllotmentPatches(String region) { if (!ensureInventorySpace()) return false; @@ -449,11 +479,15 @@ private boolean handleAllotmentPatches(String region) { return true; } + // distanceTo() is Chebyshev (max(|dx|,|dy|)), so many tiles tie and the tie is broken by list + // order — picking a diagonal tile over the orthogonally-adjacent one. Compare by squared + // Euclidean distance instead so we land on the genuinely-nearest tile at decision time. + final WorldPoint playerLoc = Rs2Player.getWorldLocation(); Rs2TileObjectModel pinned = null; if (currentAllotmentId >= 0) { pinned = allObjects.stream() - .filter(o -> o.getId() == currentAllotmentId && o.isReachable()) - .min(Comparator.comparingInt(o -> o.getWorldLocation().distanceTo(Rs2Player.getWorldLocation()))) + .filter(o -> o.getId() == currentAllotmentId && hasStandableNeighbor(o.getWorldLocation())) + .min(Comparator.comparingInt(o -> sqDist(o.getWorldLocation(), playerLoc))) .orElse(null); if (pinned == null || handledAllotmentIds.contains(currentAllotmentId)) { log("[Allotment] Finished patch id=" + currentAllotmentId + ", looking for next"); @@ -463,8 +497,8 @@ private boolean handleAllotmentPatches(String region) { } if (currentAllotmentId < 0) { pinned = allObjects.stream() - .filter(o -> !handledAllotmentIds.contains(o.getId()) && o.isReachable()) - .min(Comparator.comparingInt(o -> o.getWorldLocation().distanceTo(Rs2Player.getWorldLocation()))) + .filter(o -> !handledAllotmentIds.contains(o.getId()) && hasStandableNeighbor(o.getWorldLocation())) + .min(Comparator.comparingInt(o -> sqDist(o.getWorldLocation(), playerLoc))) .orElse(null); if (pinned == null) { log("[Allotment] All patches handled at " + region); @@ -507,10 +541,16 @@ private boolean handleAllotmentPatches(String region) { Rs2Inventory.use(allotmentSeed.getItemId()); obj.click("Plant"); Rs2Player.waitForWalking(); - sleepUntil(() -> getPatchState(obj).equals("Growing"), 10000); - log("[Allotment] Planted id=" + currentAllotmentId); - handledAllotmentIds.add(currentAllotmentId); - currentAllotmentId = -1; + // Only mark done once the patch is confirmed Growing. If the plant didn't take + // (e.g. weeds regrew between clearing and planting, or the click missed), leave the + // patch pinned so the next tick re-rakes/re-plants instead of silently giving up. + if (sleepUntil(() -> getPatchState(obj).equals("Growing"), 10000)) { + log("[Allotment] Planted id=" + currentAllotmentId); + handledAllotmentIds.add(currentAllotmentId); + currentAllotmentId = -1; + } else { + log("[Allotment] Plant not confirmed (state=" + getPatchState(obj) + "), retrying id=" + currentAllotmentId); + } return false; default: log("[Allotment] Patch id=" + currentAllotmentId + " is " + state + ", marking done"); @@ -582,15 +622,26 @@ private boolean noteProduceViaLeprechaun() { Rs2NpcModel leprechaun = Microbot.getRs2NpcCache().query().withName("Tool leprechaun").nearestOnClientThread(); if (leprechaun == null) return false; - Rs2ItemModel unNoted = Rs2Inventory.getUnNotedItem("Grimy", false); - if (unNoted == null) unNoted = getFirstUnNotedProduce(); - - if (unNoted != null) { - Rs2Inventory.use(unNoted); + // Note EVERY notable produce type we hold more than one of, not just the patch we're currently + // harvesting. Otherwise produce from an earlier patch (e.g. grimy herbs) keeps occupying slots + // and space never actually gets freed. Using an item on the leprechaun notes its whole stack. + Set seen = new HashSet<>(); + List toNote = new ArrayList<>(); + for (Rs2ItemModel item : Rs2Inventory.all()) { + if (item == null || item.isNoted() || item.getName() == null) continue; + if (!isNotableProduce(item.getName())) continue; + if (Rs2Inventory.count(item.getId()) <= 1) continue; + if (seen.add(item.getId())) toNote.add(item); + } + + boolean notedAny = false; + for (Rs2ItemModel item : toNote) { + Rs2Inventory.use(item); leprechaun.click("Talk-to"); Rs2Inventory.waitForInventoryChanges(10000); - return true; + notedAny = true; } + if (notedAny) return true; if (Rs2Inventory.hasItem("Weeds")) { Rs2Inventory.dropAll("Weeds"); @@ -610,16 +661,13 @@ private boolean noteProduceViaLeprechaun() { "Marigold", "Rosemary", "Nasturtium", "Woad leaf", "Limpwurt root", "White lily" }; - private Rs2ItemModel getFirstUnNotedProduce() { - for (String name : ALLOTMENT_PRODUCE) { - Rs2ItemModel item = Rs2Inventory.getUnNotedItem(name, true); - if (item != null) return item; - } - for (String name : FLOWER_PRODUCE) { - Rs2ItemModel item = Rs2Inventory.getUnNotedItem(name, true); - if (item != null) return item; - } - return null; + /** A farming product worth noting for space: any grimy herb, or allotment/flower produce. Produce + * names are matched exactly so seeds ("Potato seed", "Marigold seed") are never caught. */ + private static boolean isNotableProduce(String name) { + if (name.startsWith("Grimy")) return true; + for (String p : ALLOTMENT_PRODUCE) if (name.equals(p)) return true; + for (String p : FLOWER_PRODUCE) if (name.equals(p)) return true; + return false; } // --- Seed helpers --- From ea59c37db119b8aec91648adb0d8001feff657f9 Mon Sep 17 00:00:00 2001 From: runsonmypc Date: Fri, 19 Jun 2026 12:38:08 -0400 Subject: [PATCH 3/3] fix(herbrun): prevent flower-harvest inventory overflow loss Flower patches harvest in a single action; for limpwurt the multi-root yield could overflow a near-full inventory and the surplus dropped to the ground and was lost. - Note all notable produce via the tool leprechaun before every flower Pick, freeing space so the full yield fits. - After harvest, reclaim only our own dropped limpwurt roots (ownership-filtered, bounded loop), noting between pickups to make room. --- .../microbot/herbrun/HerbrunScript.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/main/java/net/runelite/client/plugins/microbot/herbrun/HerbrunScript.java b/src/main/java/net/runelite/client/plugins/microbot/herbrun/HerbrunScript.java index 0f10d18435..4e76209587 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/herbrun/HerbrunScript.java +++ b/src/main/java/net/runelite/client/plugins/microbot/herbrun/HerbrunScript.java @@ -20,6 +20,7 @@ import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; import net.runelite.client.plugins.microbot.api.npc.models.Rs2NpcModel; +import net.runelite.client.plugins.microbot.api.tileitem.models.Rs2TileItemModel; 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; @@ -404,9 +405,13 @@ private boolean handleFlowerPatch(String region) { switch (state) { case "Harvestable": + // A flower patch harvests in one action. Free as much space as possible first so a + // bulk yield (e.g. limpwurt roots) fits rather than overflowing onto the ground. + noteProduceViaLeprechaun(); obj.click("Pick"); Rs2Player.waitForWalking(); sleepUntil(() -> getPatchState(obj).equals("Empty") || Rs2Inventory.isFull(), 10000); + recoverOwnLimpwurtDrops(); return false; case "Weeds": obj.click("Rake"); @@ -654,6 +659,23 @@ private boolean noteProduceViaLeprechaun() { return false; } + /** A limpwurt patch harvests in bulk; if the inventory fills mid-harvest the surplus roots drop + * to the ground. Reclaim ONLY our own dropped roots (never another player's), noting between + * pickups to make room. Bounded loop so a contested/uncollectable drop can't spin forever. */ + private void recoverOwnLimpwurtDrops() { + for (int i = 0; i < 10; i++) { + Rs2TileItemModel root = Microbot.getRs2TileItemCache().query() + .withId(ItemID.LIMPWURT_ROOT) + .where(Rs2TileItemModel::isOwned) + .within(3) + .nearest(); + if (root == null) return; + if (Rs2Inventory.isFull() && !noteProduceViaLeprechaun()) return; // can't free space, give up + if (!root.pickup()) return; + Rs2Inventory.waitForInventoryChanges(3000); + } + } + private static final String[] ALLOTMENT_PRODUCE = { "Potato", "Onion", "Cabbage", "Tomato", "Sweetcorn", "Strawberry", "Watermelon", "Snape grass" };