From 41b533d2cd5205ac99e124fa0ea948145b1583f9 Mon Sep 17 00:00:00 2001 From: runsonmypc Date: Fri, 19 Jun 2026 12:43:44 -0400 Subject: [PATCH 1/3] feat(farmingcontract): add Farming Contracts plugin Automates Jane's farming contracts end to end: gets (and optionally downgrades) contracts, banks tools/seeds/compost, plants and harvests across patch types, clears and replants trees by paying the guild gardeners, and turns in completed contracts. Correctness details worth noting: - itemQuantity (not count) for stackable coins/seeds; count() returns the slot count, which caused false "1 coin" / banking loops - pay guild gardeners Rosie/Nikkie by name + tree-specific action when no generic "Pay" NPC is present, so checked/old trees get cleared - detect check-healthed trees via the "Chop down" action and clear them - guard auto-downgrade on needsDowngrade to stop re-downgrade loops - re-rake weeds that regrow during composting and confirm a seed was consumed before reporting a successful plant --- .../FarmingContractConfig.java | 138 ++ .../farmingcontract/FarmingContractData.java | 167 ++ .../FarmingContractOverlay.java | 58 + .../FarmingContractPlugin.java | 63 + .../FarmingContractScript.java | 1642 +++++++++++++++++ 5 files changed, 2068 insertions(+) create mode 100644 src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractConfig.java create mode 100644 src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractData.java create mode 100644 src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractOverlay.java create mode 100644 src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractPlugin.java create mode 100644 src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractScript.java diff --git a/src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractConfig.java b/src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractConfig.java new file mode 100644 index 0000000000..b60d017817 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractConfig.java @@ -0,0 +1,138 @@ +package net.runelite.client.plugins.microbot.farmingcontract; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import net.runelite.client.config.*; + +@ConfigInformation("Automates Farming Guild contracts end-to-end.

" + + "Start: anywhere — the plugin walks to the guild.
" + + "Have seeds, compost, rake, spade, and seed dibber banked.

" + + "Cycle: get contract → bank → compost & plant → harvest → turn in → repeat.
" + + "Auto tier picks the highest contract your level supports.") +@ConfigGroup("farmingcontract") +public interface FarmingContractConfig extends Config { + + @ConfigSection( + name = "Contract", + description = "Contract preferences", + position = 0 + ) + String contractSection = "contract"; + + @ConfigItem( + keyName = "contractTier", + name = "Contract Tier", + description = "Which contract tier to request from Jane", + position = 1, + section = contractSection + ) + default ContractTier contractTier() { + return ContractTier.AUTO; + } + + @ConfigItem( + keyName = "autoDowngrade", + name = "Auto Downgrade", + description = "Request an easier contract if seeds are unavailable", + position = 2, + section = contractSection + ) + default boolean autoDowngrade() { + return true; + } + + @ConfigItem( + keyName = "downgradeTree", + name = "Downgrade Trees", + description = "Automatically downgrade tree contracts (slow to grow)", + position = 3, + section = contractSection + ) + default boolean downgradeTree() { + return false; + } + + @ConfigItem( + keyName = "downgradeFruitTree", + name = "Downgrade Fruit Trees", + description = "Automatically downgrade fruit tree contracts (slow to grow)", + position = 4, + section = contractSection + ) + default boolean downgradeFruitTree() { + return false; + } + + @ConfigSection( + name = "Farming", + description = "Farming preferences", + position = 10 + ) + String farmingSection = "farming"; + + @ConfigItem( + keyName = "compostType", + name = "Compost Type", + description = "Type of compost to apply before planting", + position = 11, + section = farmingSection + ) + default CompostType compostType() { + return CompostType.ULTRACOMPOST; + } + + @ConfigItem( + keyName = "enableComposting", + name = "Compost Bin", + description = "Fill big compost bin with pineapples and collect ultracompost before doing contracts", + position = 12, + section = farmingSection + ) + default boolean enableComposting() { + return false; + } + + @ConfigItem( + keyName = "protectTrees", + name = "Protect Trees", + description = "Pay the gardener to protect tree and fruit tree contracts while they grow", + position = 13, + section = farmingSection + ) + default boolean protectTrees() { + return false; + } + + @Getter + @RequiredArgsConstructor + enum ContractTier { + AUTO("Auto"), + EASY("Easy"), + MEDIUM("Medium"), + HARD("Hard"); + + private final String label; + + @Override + public String toString() { + return label; + } + } + + @Getter + @RequiredArgsConstructor + enum CompostType { + NONE("None", -1), + COMPOST("Compost", 6032), + SUPERCOMPOST("Supercompost", 6034), + ULTRACOMPOST("Ultracompost", 21483); + + private final String name; + private final int itemId; + + @Override + public String toString() { + return name; + } + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractData.java b/src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractData.java new file mode 100644 index 0000000000..b09e1cc30d --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractData.java @@ -0,0 +1,167 @@ +package net.runelite.client.plugins.microbot.farmingcontract; + +import com.google.common.collect.ImmutableMap; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.ItemID; +import net.runelite.client.plugins.timetracking.farming.PatchImplementation; +import net.runelite.client.plugins.timetracking.farming.Produce; + +import java.util.Map; + +public final class FarmingContractData { + + static final WorldPoint JANE_LOCATION = new WorldPoint(1248, 3727, 0); + static final WorldPoint BIN_LOCATION = new WorldPoint(1272, 3729, 0); + static final int PINEAPPLE = ItemID.PINEAPPLE; + static final int VOLCANIC_ASH = ItemID.FOSSIL_VOLCANIC_ASH; + static final int BUCKET_EMPTY = ItemID.BUCKET_EMPTY; + static final int BUCKET_ULTRACOMPOST = ItemID.BUCKET_ULTRACOMPOST; + static final int MAGIC_SECATEURS = ItemID.FAIRY_ENCHANTED_SECATEURS; + static final int VOLCANIC_ASH_PER_BIN = 50; + static final int BIN_CAPACITY = 30; + + static final Map PATCH_NAMES = ImmutableMap.builder() + .put(PatchImplementation.HERB, "Herb patch") + .put(PatchImplementation.ALLOTMENT, "Allotment") + .put(PatchImplementation.FLOWER, "Flower Patch") + .put(PatchImplementation.BUSH, "Bush Patch") + .put(PatchImplementation.TREE, "Tree patch") + .put(PatchImplementation.FRUIT_TREE, "Fruit Tree Patch") + .put(PatchImplementation.CACTUS, "Cactus patch") + .build(); + + static final Map PATCH_LOCATIONS = ImmutableMap.builder() + .put(PatchImplementation.HERB, new WorldPoint(1238, 3726, 0)) + .put(PatchImplementation.ALLOTMENT, new WorldPoint(1267, 3727, 0)) + .put(PatchImplementation.FLOWER, new WorldPoint(1260, 3725, 0)) + .put(PatchImplementation.BUSH, new WorldPoint(1260, 3733, 0)) + .put(PatchImplementation.TREE, new WorldPoint(1231, 3735, 0)) + .put(PatchImplementation.FRUIT_TREE, new WorldPoint(1242, 3758, 0)) + .put(PatchImplementation.CACTUS, new WorldPoint(1264, 3747, 0)) + .build(); + + static final Map SEED_MAPPINGS = ImmutableMap.builder() + // Allotments + .put(Produce.POTATO, ItemID.POTATO_SEED) + .put(Produce.ONION, ItemID.ONION_SEED) + .put(Produce.CABBAGE, ItemID.CABBAGE_SEED) + .put(Produce.TOMATO, ItemID.TOMATO_SEED) + .put(Produce.SWEETCORN, ItemID.SWEETCORN_SEED) + .put(Produce.STRAWBERRY, ItemID.STRAWBERRY_SEED) + .put(Produce.WATERMELON, ItemID.WATERMELON_SEED) + .put(Produce.SNAPE_GRASS, ItemID.SNAPE_GRASS_SEED) + // Flowers + .put(Produce.MARIGOLD, ItemID.MARIGOLD_SEED) + .put(Produce.ROSEMARY, ItemID.ROSEMARY_SEED) + .put(Produce.NASTURTIUM, ItemID.NASTURTIUM_SEED) + .put(Produce.WOAD, ItemID.WOAD_SEED) + .put(Produce.LIMPWURT, ItemID.LIMPWURT_SEED) + .put(Produce.WHITE_LILY, ItemID.WHITE_LILY_SEED) + // Herbs + .put(Produce.GUAM, ItemID.GUAM_SEED) + .put(Produce.MARRENTILL, ItemID.MARRENTILL_SEED) + .put(Produce.TARROMIN, ItemID.TARROMIN_SEED) + .put(Produce.HARRALANDER, ItemID.HARRALANDER_SEED) + .put(Produce.RANARR, ItemID.RANARR_SEED) + .put(Produce.TOADFLAX, ItemID.TOADFLAX_SEED) + .put(Produce.IRIT, ItemID.IRIT_SEED) + .put(Produce.AVANTOE, ItemID.AVANTOE_SEED) + .put(Produce.KWUARM, ItemID.KWUARM_SEED) + .put(Produce.SNAPDRAGON, ItemID.SNAPDRAGON_SEED) + .put(Produce.CADANTINE, ItemID.CADANTINE_SEED) + .put(Produce.LANTADYME, ItemID.LANTADYME_SEED) + .put(Produce.DWARF_WEED, ItemID.DWARF_WEED_SEED) + .put(Produce.TORSTOL, ItemID.TORSTOL_SEED) + // Trees (saplings) + .put(Produce.OAK, ItemID.PLANTPOT_OAK_SAPLING) + .put(Produce.WILLOW, ItemID.PLANTPOT_WILLOW_SAPLING) + .put(Produce.MAPLE, ItemID.PLANTPOT_MAPLE_SAPLING) + .put(Produce.YEW, ItemID.PLANTPOT_YEW_SAPLING) + .put(Produce.MAGIC, ItemID.PLANTPOT_MAGIC_TREE_SAPLING) + // Fruit trees (saplings) + .put(Produce.APPLE, ItemID.PLANTPOT_APPLE_SAPLING) + .put(Produce.BANANA, ItemID.PLANTPOT_BANANA_SAPLING) + .put(Produce.ORANGE, ItemID.PLANTPOT_ORANGE_SAPLING) + .put(Produce.CURRY, ItemID.PLANTPOT_CURRY_SAPLING) + .put(Produce.PINEAPPLE, ItemID.PLANTPOT_PINEAPPLE_SAPLING) + .put(Produce.PAPAYA, ItemID.PLANTPOT_PAPAYA_SAPLING) + .put(Produce.PALM, ItemID.PLANTPOT_PALM_SAPLING) + .put(Produce.DRAGONFRUIT, ItemID.PLANTPOT_DRAGONFRUIT_SAPLING) + // Bushes + .put(Produce.REDBERRIES, ItemID.REDBERRY_BUSH_SEED) + .put(Produce.CADAVABERRIES, ItemID.CADAVABERRY_BUSH_SEED) + .put(Produce.DWELLBERRIES, ItemID.DWELLBERRY_BUSH_SEED) + .put(Produce.JANGERBERRIES, ItemID.JANGERBERRY_BUSH_SEED) + .put(Produce.WHITEBERRIES, ItemID.WHITEBERRY_BUSH_SEED) + .put(Produce.POISON_IVY, ItemID.POISONIVY_BUSH_SEED) + // Cactus + .put(Produce.CACTUS, ItemID.CACTUS_SEED) + .put(Produce.POTATO_CACTUS, ItemID.POTATO_CACTUS_SEED) + .build(); + + static final Map PROTECTION_PAYMENTS = ImmutableMap.builder() + // Trees + .put(Produce.OAK, new int[]{ItemID.BASKET_TOMATO_5, 1}) + .put(Produce.WILLOW, new int[]{ItemID.BASKET_APPLE_5, 1}) + .put(Produce.MAPLE, new int[]{ItemID.BASKET_ORANGE_5, 1}) + .put(Produce.YEW, new int[]{ItemID.CACTUS_SPINE, 10}) + .put(Produce.MAGIC, new int[]{ItemID.COCONUT, 25}) + // Fruit trees + .put(Produce.APPLE, new int[]{ItemID.SWEETCORN, 9}) + .put(Produce.BANANA, new int[]{ItemID.BASKET_APPLE_5, 4}) + .put(Produce.ORANGE, new int[]{ItemID.BASKET_STRAWBERRY_5, 3}) + .put(Produce.CURRY, new int[]{ItemID.BASKET_BANANA_5, 5}) + .put(Produce.PINEAPPLE, new int[]{ItemID.WATERMELON, 10}) + .put(Produce.PAPAYA, new int[]{ItemID.PINEAPPLE, 10}) + .put(Produce.PALM, new int[]{ItemID.PAPAYA, 15}) + .put(Produce.DRAGONFRUIT, new int[]{ItemID.COCONUT, 15}) + .build(); + + static int getProtectionItemId(Produce produce) { + int[] payment = PROTECTION_PAYMENTS.get(produce); + return payment != null ? payment[0] : -1; + } + + static int getProtectionItemQty(Produce produce) { + int[] payment = PROTECTION_PAYMENTS.get(produce); + return payment != null ? payment[1] : 0; + } + + static boolean hasProtectionData(Produce produce) { + return PROTECTION_PAYMENTS.containsKey(produce); + } + + static int getSeedId(Produce produce) { + return SEED_MAPPINGS.getOrDefault(produce, -1); + } + + static int getSeedsRequired(Produce produce) { + return produce.getPatchImplementation() == PatchImplementation.ALLOTMENT ? 3 : 1; + } + + static boolean needsCoins(Produce produce) { + PatchImplementation type = produce.getPatchImplementation(); + return type == PatchImplementation.TREE || type == PatchImplementation.FRUIT_TREE; + } + + static boolean usesSecateurs(PatchImplementation type) { + return type == PatchImplementation.ALLOTMENT + || type == PatchImplementation.HERB + || type == PatchImplementation.FLOWER + || type == PatchImplementation.BUSH; + } + + static boolean needsClearAfterHarvest(PatchImplementation type) { + return type == PatchImplementation.BUSH + || type == PatchImplementation.CACTUS; + } + + static boolean needsCheckHealth(PatchImplementation type) { + return type == PatchImplementation.TREE + || type == PatchImplementation.FRUIT_TREE + || type == PatchImplementation.BUSH + || type == PatchImplementation.CACTUS; + } + + private FarmingContractData() {} +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractOverlay.java b/src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractOverlay.java new file mode 100644 index 0000000000..ba33942f79 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractOverlay.java @@ -0,0 +1,58 @@ +package net.runelite.client.plugins.microbot.farmingcontract; + +import net.runelite.api.Skill; +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 lombok.extern.slf4j.Slf4j; + +import javax.inject.Inject; +import java.awt.*; + +@Slf4j +public class FarmingContractOverlay extends OverlayPanel { + + @Inject + FarmingContractOverlay(FarmingContractPlugin plugin) { + super(plugin); + setPosition(OverlayPosition.TOP_LEFT); + setNaughty(); + } + + @Override + public Dimension render(Graphics2D graphics) { + try { + panelComponent.setPreferredSize(new Dimension(200, 0)); + panelComponent.getChildren().add(TitleComponent.builder() + .text("Farming Contract") + .color(Color.GREEN) + .build()); + + panelComponent.getChildren().add(LineComponent.builder() + .left("Status:") + .right(FarmingContractPlugin.getStatus()) + .build()); + + String contractName = FarmingContractScript.getContractName(); + panelComponent.getChildren().add(LineComponent.builder() + .left("Contract:") + .right(contractName != null ? contractName : "None") + .build()); + + if (Microbot.isLoggedIn()) { + int level = Microbot.getClient().getRealSkillLevel(Skill.FARMING); + String tier = level >= 85 ? "Hard" : level >= 65 ? "Medium" : level >= 45 ? "Easy" : "N/A"; + panelComponent.getChildren().add(LineComponent.builder() + .left("Farming:") + .right(level + " (" + tier + ")") + .build()); + } + } catch (Exception ex) { + log.error("Overlay render error", ex); + } + return super.render(graphics); + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractPlugin.java new file mode 100644 index 0000000000..30fd5c7790 --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractPlugin.java @@ -0,0 +1,63 @@ +package net.runelite.client.plugins.microbot.farmingcontract; + +import com.google.inject.Provides; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.PluginConstants; +import net.runelite.client.ui.overlay.OverlayManager; + +import javax.inject.Inject; + +@PluginDescriptor( + name = PluginConstants.PERT + "Farming Contracts", + description = "Automates Farming Guild contracts. Start the plugin while inside the Farming Guild.", + tags = {"farming", "contract", "guild", "jane"}, + authors = {"Pert"}, + version = FarmingContractPlugin.VERSION, + minClientVersion = "2.6.0", + enabledByDefault = PluginConstants.DEFAULT_ENABLED, + isExternal = PluginConstants.IS_EXTERNAL +) +@Slf4j +public class FarmingContractPlugin extends Plugin { + static final String VERSION = "0.2.1"; + + @Getter + @Setter + static String status = "Idle"; + + @Inject + private FarmingContractConfig config; + + @Inject + private OverlayManager overlayManager; + + @Inject + private FarmingContractOverlay overlay; + + @Inject + private FarmingContractScript script; + + @Provides + FarmingContractConfig provideConfig(ConfigManager configManager) { + return configManager.getConfig(FarmingContractConfig.class); + } + + @Override + protected void startUp() { + overlayManager.add(overlay); + script.run(config); + } + + @Override + protected void shutDown() { + script.shutdown(); + overlayManager.remove(overlay); + status = "Idle"; + } +} diff --git a/src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractScript.java b/src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractScript.java new file mode 100644 index 0000000000..ced93cfbce --- /dev/null +++ b/src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractScript.java @@ -0,0 +1,1642 @@ +package net.runelite.client.plugins.microbot.farmingcontract; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.GameObject; +import net.runelite.api.Skill; +import net.runelite.api.TileObject; +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.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue; +import net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject; +import net.runelite.client.plugins.microbot.util.grounditem.LootingParameters; +import net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem; +import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.npc.Rs2Npc; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; +import net.runelite.client.plugins.timetracking.farming.PatchImplementation; +import net.runelite.client.plugins.timetracking.farming.Produce; + +import javax.inject.Inject; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Slf4j +public class FarmingContractScript extends Script { + + private static final Pattern CONTRACT_PATTERN = Pattern.compile( + "(?:We need you to grow|Please could you grow) (?:some|a|an) ([a-zA-Z ]+)(?: for us\\?|\\.)" + ); + private static final String CONFIG_GROUP = "farmingcontract"; + private static final String CONFIG_KEY_CONTRACT = "contract"; + + @Getter + private static String contractName; + + private FarmingContractConfig config; + private Produce contract; + private Phase phase; + private boolean needsDowngrade; + private CompostStep compostStep; + + enum Phase { + COMPOSTING, + GET_CONTRACT, + BANKING, + HANDLE_PATCH, + TURN_IN, + DONE + } + + enum CompostStep { + CHECK_BIN, + DRAIN_BIN, + COLLECT_TRIP_1, + COLLECT_TRIP_2_AND_FILL, + FILL_FINAL, + FILL_TRIP_1, + FILL_TRIP_2 + } + + enum PatchState { + EMPTY, WEEDS, GROWING, GROWN_CHECK, HARVESTABLE, CHECKED_TREE, DEAD + } + + @Inject + public FarmingContractScript() {} + + public boolean run(FarmingContractConfig config) { + this.config = config; + this.needsDowngrade = false; + this.contract = null; + this.contractName = null; + this.compostStep = CompostStep.CHECK_BIN; + phase = config.enableComposting() ? Phase.COMPOSTING : Phase.GET_CONTRACT; + + mainScheduledFuture = scheduledExecutorService.scheduleWithFixedDelay(() -> { + if (!Microbot.isLoggedIn()) return; + if (!super.run()) return; + + try { + if (Rs2Player.getWorldLocation().distanceTo(FarmingContractData.JANE_LOCATION) > 20) { + FarmingContractPlugin.setStatus("Walking to guild"); + Rs2Walker.walkTo(FarmingContractData.JANE_LOCATION); + return; + } + + FarmingContractPlugin.setStatus(phase.name()); + + switch (phase) { + case COMPOSTING: + if (handleComposting()) { + phase = Phase.GET_CONTRACT; + } + break; + case GET_CONTRACT: + if (handleGetContract()) { + phase = Phase.BANKING; + } + break; + case BANKING: + if (handleBanking()) { + phase = Phase.HANDLE_PATCH; + } + break; + case HANDLE_PATCH: + Phase next = handlePatch(); + if (next != null) { + phase = next; + } + break; + case TURN_IN: + if (handleTurnIn()) { + phase = contract != null ? Phase.BANKING : Phase.GET_CONTRACT; + } + break; + case DONE: + FarmingContractPlugin.setStatus("Done"); + Microbot.stopPlugin(Microbot.getPluginManager() + .getPlugins().stream() + .filter(p -> p instanceof FarmingContractPlugin) + .findFirst().orElse(null)); + break; + } + } catch (Exception e) { + log.error("Error in farming contract loop", e); + } + }, 0, 600, TimeUnit.MILLISECONDS); + + return true; + } + + // --- Contract Management --- + + private void saveContract() { + if (contract != null) { + Microbot.getConfigManager().setRSProfileConfiguration( + CONFIG_GROUP, CONFIG_KEY_CONTRACT, String.valueOf(contract.getItemID())); + contractName = contract.getName(); + } else { + Microbot.getConfigManager().unsetRSProfileConfiguration(CONFIG_GROUP, CONFIG_KEY_CONTRACT); + contractName = null; + } + } + + private Produce findProduceByContractName(String name) { + for (Produce p : Produce.values()) { + if (p.getContractName() != null && p.getContractName().equalsIgnoreCase(name)) { + return p; + } + } + return null; + } + + + // --- Phase: COMPOSTING --- + + private boolean handleComposting() { + FarmingContractPlugin.setStatus("Composting: " + compostStep.name()); + + switch (compostStep) { + case CHECK_BIN: + return checkBinState(); + case DRAIN_BIN: + return drainBin(); + case COLLECT_TRIP_1: + return collectTrip1(); + case COLLECT_TRIP_2_AND_FILL: + return collectTrip2AndFill(); + case FILL_FINAL: + return fillFinal(); + case FILL_TRIP_1: + return fillTrip1(); + case FILL_TRIP_2: + return fillTrip2(); + default: + return true; + } + } + + private boolean checkBinState() { + TileObject bin = findCompostBin(); + if (bin == null) { + log.info("Compost bin not found, walking to guild"); + Rs2Walker.walkTo(FarmingContractData.JANE_LOCATION); + sleepUntil(() -> !Rs2Player.isMoving(), 15000); + return false; + } + + if (binHasAction(bin, "Take")) { + log.info("Bin is already open with compost, banking for ash + buckets"); + compostStep = CompostStep.COLLECT_TRIP_1; + return false; + } + + if (binHasAction(bin, "Close")) { + log.info("Bin is open and empty, filling"); + compostStep = CompostStep.FILL_TRIP_1; + return false; + } + + if (!binHasAction(bin, "Open")) { + log.info("Bin is empty (no actions), filling"); + compostStep = CompostStep.FILL_TRIP_1; + return false; + } + + log.info("Bin is closed, banking for supplies then trying to open"); + compostStep = CompostStep.COLLECT_TRIP_1; + return false; + } + + private boolean binHasAction(TileObject bin, String action) { + return Rs2GameObject.hasAction(bin, action, true); + } + + private boolean drainBin() { + FarmingContractPlugin.setStatus("Compost: draining bin"); + + if (!openNearestBank()) return false; + + Rs2Bank.depositAll(); + sleep(600, 900); + + if (!Rs2Bank.hasBankItem(FarmingContractData.BUCKET_EMPTY, 1)) { + log.warn("No empty buckets, skipping composting"); + Rs2Bank.closeBank(); + return true; + } + + Rs2Bank.withdrawX(FarmingContractData.BUCKET_EMPTY, 28); + sleep(600, 900); + + + TileObject bin = walkToBin(); + if (bin == null || !super.run()) return false; + + Rs2GameObject.interact(bin, "Take"); + sleepUntil(() -> { + if (!Rs2Inventory.contains(FarmingContractData.BUCKET_EMPTY)) return true; + TileObject b = findCompostBin(); + return b == null || !binHasAction(b, "Take"); + }, 60000); + sleep(600, 1200); + + bin = findCompostBin(); + if (bin == null) return false; + + if (binHasAction(bin, "Take")) { + log.info("Bin still has compost, draining again"); + return false; + } + + log.info("Bin fully drained, moving to fill"); + compostStep = CompostStep.FILL_TRIP_1; + return false; + } + + private boolean collectTrip1() { + FarmingContractPlugin.setStatus("Compost: banking for ash + buckets"); + + if (!openNearestBank()) return false; + + Rs2Bank.depositAll(); + sleep(600, 900); + + if (!Rs2Bank.hasBankItem(FarmingContractData.VOLCANIC_ASH, FarmingContractData.VOLCANIC_ASH_PER_BIN)) { + log.warn("Not enough volcanic ash (need {}), skipping composting", + FarmingContractData.VOLCANIC_ASH_PER_BIN); + Rs2Bank.closeBank(); + return true; + } + if (!Rs2Bank.hasBankItem(FarmingContractData.BUCKET_EMPTY, 27)) { + log.warn("Not enough empty buckets (need 27), skipping composting"); + Rs2Bank.closeBank(); + return true; + } + + Rs2Bank.withdrawX(FarmingContractData.VOLCANIC_ASH, FarmingContractData.VOLCANIC_ASH_PER_BIN); + sleep(600, 900); + Rs2Bank.withdrawX(FarmingContractData.BUCKET_EMPTY, 27); + sleep(600, 900); + + int bucketCount = Rs2Inventory.count(FarmingContractData.BUCKET_EMPTY); + log.info("Withdrew {} empty buckets (expected 27), ash in inventory: {}", + bucketCount, Rs2Inventory.contains(FarmingContractData.VOLCANIC_ASH)); + + TileObject bin = walkToBin(); + if (bin == null) return false; + + if (!binHasAction(bin, "Take")) { + Rs2GameObject.interact(bin, "Open"); + sleepUntil(() -> { + if (Rs2Dialogue.isInDialogue()) return true; + TileObject b = findCompostBin(); + if (b == null) return false; + return binHasAction(b, "Take") || binHasAction(b, "Close"); + }, 10000); + + if (Rs2Dialogue.isInDialogue()) { + log.info("Bin not ready yet (error dialogue), skipping composting"); + Rs2Dialogue.clickContinue(); + sleep(600, 900); + return true; + } + + bin = findCompostBin(); + if (bin != null && !binHasAction(bin, "Take") && binHasAction(bin, "Close")) { + log.info("Bin opened but empty, switching to fill"); + compostStep = CompostStep.FILL_TRIP_1; + return false; + } + } + + if (!super.run()) return false; + + bin = findCompostBin(); + if (bin == null) return false; + Rs2Inventory.useItemOnObject(FarmingContractData.VOLCANIC_ASH, bin.getId()); + sleepUntil(() -> !Rs2Player.isMoving(), 10000); + sleepUntil(() -> Rs2Dialogue.isInDialogue() + || !Rs2Inventory.contains(FarmingContractData.VOLCANIC_ASH), 10000); + + if (Rs2Dialogue.isInDialogue()) { + log.info("Bin already ashed, skipping to take"); + Rs2Dialogue.clickContinue(); + sleep(600, 900); + } + + while (Rs2Inventory.contains(FarmingContractData.BUCKET_EMPTY) && super.run()) { + bin = findCompostBin(); + if (bin == null || !binHasAction(bin, "Take")) break; + Rs2GameObject.interact(bin, "Take"); + sleepUntil(() -> { + if (!Rs2Inventory.contains(FarmingContractData.BUCKET_EMPTY)) return true; + TileObject b = findCompostBin(); + return b == null || !binHasAction(b, "Take"); + }, 60000); + sleep(600, 900); + } + + bin = findCompostBin(); + if (bin != null && binHasAction(bin, "Take")) { + compostStep = CompostStep.COLLECT_TRIP_2_AND_FILL; + } else { + compostStep = CompostStep.FILL_TRIP_1; + } + return false; + } + + private boolean collectTrip2AndFill() { + FarmingContractPlugin.setStatus("Compost: banking for buckets + pineapples"); + + if (!openNearestBank()) return false; + + Rs2Bank.depositAll(); + sleep(600, 900); + + Rs2Bank.withdrawX(FarmingContractData.BUCKET_EMPTY, 3); + sleep(600, 900); + Rs2Bank.withdrawX(FarmingContractData.PINEAPPLE, 25); + sleep(600, 900); + + + TileObject bin = walkToBin(); + if (bin == null) return false; + + if (Rs2Inventory.contains(FarmingContractData.BUCKET_EMPTY)) { + Rs2GameObject.interact(bin, "Take"); + sleepUntil(() -> { + if (!Rs2Inventory.contains(FarmingContractData.BUCKET_EMPTY)) return true; + TileObject b = findCompostBin(); + return b == null || !binHasAction(b, "Take"); + }, 30000); + sleep(600, 900); + if (!super.run()) return false; + } + + bin = findCompostBin(); + if (bin == null) return false; + if (!super.run()) return false; + + Rs2Inventory.useItemOnObject(FarmingContractData.PINEAPPLE, bin.getId()); + sleepUntil(() -> !Rs2Inventory.contains(FarmingContractData.PINEAPPLE), 60000); + sleep(600, 1200); + + compostStep = CompostStep.FILL_FINAL; + return false; + } + + private boolean fillFinal() { + FarmingContractPlugin.setStatus("Compost: final pineapple trip"); + + if (!openNearestBank()) return false; + + Rs2Bank.depositAll(); + sleep(600, 900); + Rs2Bank.withdrawX(FarmingContractData.PINEAPPLE, 5); + sleep(600, 900); + + + TileObject bin = walkToBin(); + if (bin == null || !super.run()) return false; + + Rs2Inventory.useItemOnObject(FarmingContractData.PINEAPPLE, bin.getId()); + sleepUntil(() -> !Rs2Inventory.contains(FarmingContractData.PINEAPPLE), 60000); + sleep(600, 1200); + + bin = findCompostBin(); + if (bin == null) return false; + Rs2GameObject.interact(bin, "Close"); + sleep(1200, 1800); + + log.info("Compost bin filled and closed"); + return true; + } + + private boolean fillTrip1() { + FarmingContractPlugin.setStatus("Compost: filling bin (trip 1)"); + + if (!openNearestBank()) return false; + + Rs2Bank.depositAll(); + sleep(600, 900); + + if (!Rs2Bank.hasBankItem(FarmingContractData.PINEAPPLE, FarmingContractData.BIN_CAPACITY)) { + log.warn("Not enough pineapples (need {}), skipping composting", + FarmingContractData.BIN_CAPACITY); + Rs2Bank.closeBank(); + return true; + } + + Rs2Bank.withdrawX(FarmingContractData.PINEAPPLE, 28); + sleep(600, 900); + + TileObject bin = walkToBin(); + if (bin == null || !super.run()) return false; + + Rs2Inventory.useItemOnObject(FarmingContractData.PINEAPPLE, bin.getId()); + sleepUntil(() -> !Rs2Inventory.contains(FarmingContractData.PINEAPPLE), 60000); + sleep(600, 1200); + + compostStep = CompostStep.FILL_TRIP_2; + return false; + } + + private boolean fillTrip2() { + FarmingContractPlugin.setStatus("Compost: filling bin (trip 2)"); + + if (!openNearestBank()) return false; + + Rs2Bank.depositAll(); + sleep(600, 900); + Rs2Bank.withdrawX(FarmingContractData.PINEAPPLE, 2); + sleep(600, 900); + + + TileObject bin = walkToBin(); + if (bin == null || !super.run()) return false; + + Rs2Inventory.useItemOnObject(FarmingContractData.PINEAPPLE, bin.getId()); + sleepUntil(() -> !Rs2Inventory.contains(FarmingContractData.PINEAPPLE), 30000); + sleep(600, 1200); + + bin = findCompostBin(); + if (bin == null) return false; + Rs2GameObject.interact(bin, "Close"); + sleep(1200, 1800); + + log.info("Compost bin filled and closed"); + return true; + } + + private TileObject walkToBin() { + try { + if (Rs2Bank.isOpen()) Rs2Bank.closeBank(); + TileObject bin = findCompostBin(); + if (bin == null) { + Rs2Walker.walkTo(FarmingContractData.BIN_LOCATION); + sleepUntil(() -> !Rs2Player.isMoving(), 15000); + sleep(600, 900); + bin = findCompostBin(); + } + return bin; + } catch (RuntimeException e) { + log.debug("walkToBin interrupted, will retry", e); + return null; + } + } + + private TileObject findCompostBin() { + try { + for (GameObject obj : Rs2GameObject.getGameObjects( + o -> { + var name = Rs2GameObject.getCompositionName(o); + return name.isPresent() && name.get().equalsIgnoreCase("Big compost bin"); + }, FarmingContractData.BIN_LOCATION, 5)) { + return obj; + } + } catch (RuntimeException e) { + log.debug("findCompostBin interrupted, will retry", e); + } + return null; + } + + // --- Phase: GET_CONTRACT --- + + private boolean handleGetContract() { + FarmingContractPlugin.setStatus(needsDowngrade ? "Downgrading contract" : "Getting contract"); + + if (Rs2Npc.getNpc("Guildmaster Jane") == null) { + Rs2Walker.walkTo(FarmingContractData.JANE_LOCATION); + sleepUntil(() -> !Rs2Player.isMoving(), 15000); + return false; + } + if (!Rs2Npc.interact("Guildmaster Jane", "Contract") + && !Rs2Npc.interact("Guildmaster Jane", "Talk-to")) { + return false; + } + sleepUntil(Rs2Dialogue::isInDialogue, 15000); + if (!Rs2Dialogue.isInDialogue()) return false; + + int idleTicks = 0; + while (idleTicks < 5 && super.run()) { + if (Rs2Dialogue.hasSelectAnOption()) { + idleTicks = 0; + String tier = getDesiredTier(); + if (Rs2Dialogue.hasDialogueOption(tier)) { + Rs2Dialogue.clickOption(tier); + sleep(600, 900); + continue; + } + if (Rs2Dialogue.hasDialogueOption("Yes please")) { + Rs2Dialogue.clickOption("Yes please"); + sleep(600, 900); + continue; + } + if (Rs2Dialogue.hasDialogueOption("I'd like a farming contract")) { + Rs2Dialogue.clickOption("I'd like a farming contract"); + sleep(600, 900); + continue; + } + if (Rs2Dialogue.hasDialogueOption("Do you have any jobs for me?")) { + Rs2Dialogue.clickOption("Do you have any jobs for me?"); + sleep(600, 900); + continue; + } + if (Rs2Dialogue.hasDialogueOption("Thank you")) { + if (shouldDowngradeContract() + && Rs2Dialogue.hasDialogueOption("Do you have anything easier?")) { + log.info("Auto-downgrading {} (type: {}), asking for easier", + contract != null ? contract.getName() : "unknown", + contract != null ? contract.getPatchImplementation() : "unknown"); + contract = null; + Rs2Dialogue.clickOption("Do you have anything easier?"); + sleep(600, 900); + continue; + } + needsDowngrade = false; + Rs2Dialogue.clickOption("Thank you"); + sleep(600, 900); + continue; + } + } + + if (Rs2Dialogue.isInDialogue()) { + idleTicks = 0; + String text = Rs2Dialogue.getDialogueText(); + if (text != null) { + Matcher m = CONTRACT_PATTERN.matcher(text); + if (m.find()) { + String cropName = m.group(1).trim(); + contract = findProduceByContractName(cropName); + if (contract != null) { + log.info("Got contract: {}", contract.getName()); + saveContract(); + } + } + } + Rs2Dialogue.clickContinue(); + sleep(600, 900); + continue; + } + + Rs2Dialogue.clickContinue(); + sleep(600, 900); + idleTicks++; + } + + return contract != null; + } + + private boolean shouldDowngradeContract() { + if (needsDowngrade) return true; + if (contract == null) return false; + PatchImplementation type = contract.getPatchImplementation(); + if (type == PatchImplementation.TREE && config.downgradeTree()) return true; + if (type == PatchImplementation.FRUIT_TREE && config.downgradeFruitTree()) return true; + return false; + } + + private String getDesiredTier() { + if (needsDowngrade) { + String current = getCurrentTierForLevel(); + switch (current) { + case "Hard": return "Medium"; + case "Medium": return "Easy"; + default: return "Easy"; + } + } + + FarmingContractConfig.ContractTier tier = config.contractTier(); + if (tier == FarmingContractConfig.ContractTier.AUTO) { + return getCurrentTierForLevel(); + } + return tier.getLabel(); + } + + private String getCurrentTierForLevel() { + int level = Microbot.getClient().getRealSkillLevel(Skill.FARMING); + if (level >= 85) return "Hard"; + if (level >= 65) return "Medium"; + return "Easy"; + } + + // --- Phase: BANKING --- + + private boolean handleBanking() { + if (contract == null) { + phase = Phase.GET_CONTRACT; + return false; + } + + boolean needsHarvest = false; + try { + java.util.List patches = findAllPatchesAt(contract.getPatchImplementation()); + for (TileObject p : patches) { + if (isWrongCrop(p)) continue; + PatchState state = detectPatchState(p); + log.info("Pre-bank patch state: {} at {}", state, p.getWorldLocation()); + if (state == PatchState.GROWING) { + log.info("Contract crop already growing, nothing to do"); + FarmingContractPlugin.setStatus("Growing - stopping"); + phase = Phase.DONE; + return false; + } + if (state == PatchState.HARVESTABLE || state == PatchState.GROWN_CHECK) { + needsHarvest = true; + break; + } + } + } catch (RuntimeException e) { + log.debug("Pre-bank patch check failed, will bank for planting", e); + } + + FarmingContractPlugin.setStatus("Banking"); + + openSeedPacks(); + + boolean wantSecateurs = FarmingContractData.usesSecateurs(contract.getPatchImplementation()); + + if (needsHarvest) { + log.info("Crop ready to harvest, banking for tools only"); + boolean needCoins = FarmingContractData.needsCoins(contract); + boolean hasTools = Rs2Inventory.contains("Spade") && Rs2Inventory.contains("Rake") + && (!wantSecateurs || Rs2Inventory.contains(FarmingContractData.MAGIC_SECATEURS)) + && (!needCoins || Rs2Inventory.itemQuantity("Coins") >= 200); + if (!hasTools) { + if (!openNearestBank()) return false; + Rs2Bank.depositAllExcept("Spade", "Rake", "Seed dibber", "Magic secateurs", "Coins"); + ensureTool("Spade"); + ensureTool("Rake"); + ensureTool("Seed dibber"); + if (wantSecateurs) ensureItem(FarmingContractData.MAGIC_SECATEURS); + if (needCoins && Rs2Inventory.itemQuantity("Coins") < 200 && Rs2Bank.hasBankItem("Coins", 200)) { + Rs2Bank.withdrawX("Coins", 200); + sleep(600, 900); + } + Rs2Bank.closeBank(); + sleepUntil(() -> !Rs2Bank.isOpen(), 2000); + } + return true; + } + + boolean needsCompost = config.compostType() != FarmingContractConfig.CompostType.NONE; + int compostId = needsCompost ? config.compostType().getItemId() : -1; + boolean wantProtection = config.protectTrees() + && FarmingContractData.hasProtectionData(contract); + + if (Rs2Inventory.contains("Spade") && Rs2Inventory.contains("Rake") + && Rs2Inventory.contains("Seed dibber") && hasRequiredSeeds() + && (!wantSecateurs || Rs2Inventory.contains(FarmingContractData.MAGIC_SECATEURS)) + && (!needsCompost || Rs2Inventory.contains(compostId)) + && (!wantProtection || hasProtectionItems())) { + return true; + } + + if (!openNearestBank()) return false; + + Rs2Bank.depositAllExcept("Spade", "Rake", "Seed dibber", "Magic secateurs", "Coins"); + + if (!ensureTool("Spade")) return bankFailed("No spade in bank"); + if (!ensureTool("Rake")) return bankFailed("No rake in bank"); + if (!ensureTool("Seed dibber")) return bankFailed("No seed dibber in bank"); + if (wantSecateurs) ensureItem(FarmingContractData.MAGIC_SECATEURS); + + int seedId = FarmingContractData.getSeedId(contract); + int seedQty = FarmingContractData.getSeedsRequired(contract); + if (seedId != -1 && (!Rs2Inventory.contains(seedId) || Rs2Inventory.count(seedId) < seedQty)) { + log.info("Need seed id={} qty={}, bankHas={}", seedId, seedQty, Rs2Bank.hasBankItem(seedId, seedQty)); + if (Rs2Bank.hasBankItem(seedId, seedQty)) { + Rs2Bank.withdrawX(seedId, seedQty); + sleep(600, 900); + } + } + + if (needsCompost) { + if (!Rs2Inventory.contains(compostId) && Rs2Bank.hasBankItem(compostId, 1)) { + Rs2Bank.withdrawX(compostId, 1); + sleep(600, 900); + } + } + + if (FarmingContractData.needsCoins(contract)) { + if (Rs2Inventory.itemQuantity("Coins") < 200 && Rs2Bank.hasBankItem("Coins", 200)) { + Rs2Bank.withdrawX("Coins", 200); + sleep(600, 900); + } + } + + if (wantProtection && !hasProtectionItems()) { + int protItemId = FarmingContractData.getProtectionItemId(contract); + int protItemQty = FarmingContractData.getProtectionItemQty(contract); + Rs2Bank.setWithdrawAsNote(); + sleep(300, 600); + if (Rs2Bank.hasBankItem(protItemId, protItemQty)) { + Rs2Bank.withdrawX(protItemId, protItemQty); + sleep(600, 900); + } else { + log.warn("Protection items unavailable (id={}, need={}), will plant without protection", + protItemId, protItemQty); + } + Rs2Bank.setWithdrawAsItem(); + sleep(300, 600); + } + + if (!hasRequiredSeeds()) { + log.warn("Banking complete but seeds missing (id={}, have={})", seedId, Rs2Inventory.count(seedId)); + Rs2Bank.closeBank(); + sleepUntil(() -> !Rs2Bank.isOpen(), 2000); + if (config.autoDowngrade() && !needsDowngrade) { + log.info("Seeds unavailable, requesting easier contract"); + needsDowngrade = true; + contract = null; + saveContract(); + phase = Phase.GET_CONTRACT; + return false; + } + FarmingContractPlugin.setStatus("Missing seeds - stopping"); + phase = Phase.DONE; + return false; + } + + Rs2Bank.closeBank(); + return true; + } + + private void openSeedPacks() { + while (Rs2Inventory.contains("Seed pack")) { + FarmingContractPlugin.setStatus("Opening seed pack"); + Rs2Inventory.interact("Seed pack", "Take-all"); + Rs2Inventory.waitForInventoryChanges(5000); + sleep(600, 900); + } + } + + private static final WorldPoint GUILD_BANK = new WorldPoint(1253, 3741, 0); + + private boolean openNearestBank() { + if (Rs2Bank.isOpen()) return true; + + GameObject bankObj = Rs2GameObject.findBank(40); + if (bankObj != null) { + Rs2GameObject.interact(bankObj); + sleepUntil(() -> !Rs2Player.isMoving(), 10000); + sleep(600, 900); + sleepUntil(Rs2Bank::isOpen, 5000); + if (Rs2Bank.isOpen()) return true; + } + + Rs2Walker.walkTo(GUILD_BANK); + sleepUntil(() -> Rs2Player.getWorldLocation().distanceTo(GUILD_BANK) < 5, 15000); + return Rs2Bank.openBank(); + } + + private boolean ensureTool(String name) { + if (Rs2Inventory.contains(name)) return true; + if (Rs2Bank.hasBankItem(name, 1)) { + Rs2Bank.withdrawX(name, 1); + sleep(600, 900); + return true; + } + return false; + } + + private void ensureItem(int itemId) { + if (Rs2Inventory.contains(itemId)) return; + if (Rs2Bank.hasBankItem(itemId, 1)) { + Rs2Bank.withdrawX(itemId, 1); + sleep(600, 900); + } + } + + private boolean bankFailed(String reason) { + log.error(reason); + Rs2Bank.closeBank(); + FarmingContractPlugin.setStatus(reason); + phase = Phase.DONE; + return false; + } + + // --- Phase: HANDLE_PATCH --- + + private Phase handlePatch() { + PatchImplementation patchType = contract.getPatchImplementation(); + + if (patchType == PatchImplementation.ALLOTMENT) { + return handleAllotmentPatch(); + } + + TileObject patch = findPatchAt(patchType); + if (patch == null) { + WorldPoint patchLoc = FarmingContractData.PATCH_LOCATIONS.get(patchType); + if (patchLoc != null) { + log.warn("{} patch not found, walking closer", patchType); + Rs2Walker.walkTo(patchLoc); + sleepUntil(() -> !Rs2Player.isMoving(), 15000); + } + return null; + } + + PatchState state; + try { + state = detectPatchState(patch); + } catch (RuntimeException e) { + log.debug("detectPatchState interrupted, retrying next tick", e); + return null; + } + FarmingContractPlugin.setStatus("Patch: " + state.name()); + log.info("Patch state: {}, type: {}", state, patchType); + + switch (state) { + case GROWING: + if (isWrongCrop(patch)) { + clearPatch(patch, patchType); + return null; + } + FarmingContractPlugin.setStatus("Growing - stopping"); + return Phase.DONE; + + case WEEDS: + if (!Rs2Inventory.contains("Rake")) return Phase.BANKING; + rakeWeeds(patch); + patch = findObjectAt(patch.getWorldLocation()); + if (patch == null) return null; + // fall through to EMPTY + case EMPTY: + if (!hasRequiredSeeds()) { + if (config.autoDowngrade() && !needsDowngrade) { + log.info("Seeds unavailable, requesting easier contract"); + needsDowngrade = true; + contract = null; + saveContract(); + return Phase.GET_CONTRACT; + } + FarmingContractPlugin.setStatus("Missing seeds"); + return Phase.DONE; + } + if (!plantCrop(patch)) return null; + protectTree(); + FarmingContractPlugin.setStatus("Planted - stopping"); + return Phase.DONE; + + case GROWN_CHECK: + checkHealth(patch); + return null; + + case HARVESTABLE: + if (needsSpadeForAction(patchType) && !Rs2Inventory.contains("Spade")) return Phase.BANKING; + boolean wrongCrop = isWrongCrop(patch); + harvestPatch(patch, patchType); + if (FarmingContractData.needsClearAfterHarvest(patchType)) { + clearPatch(patch, patchType); + noteItemsWithLeprechaun(contract.getItemID()); + } + if (wrongCrop) return null; + return Phase.TURN_IN; + + case CHECKED_TREE: + if (FarmingContractData.needsCoins(contract) && Rs2Inventory.itemQuantity("Coins") < 200) return Phase.BANKING; + boolean wrongTree = isWrongCrop(patch); + clearPatch(patch, patchType); + if (wrongTree) return null; + return Phase.TURN_IN; + + case DEAD: + if (!Rs2Inventory.contains("Spade")) return Phase.BANKING; + if (FarmingContractData.needsClearAfterHarvest(patchType)) { + harvestPatch(patch, patchType); + } + clearPatch(patch, patchType); + if (contract != null) noteItemsWithLeprechaun(contract.getItemID()); + return null; + + default: + return null; + } + } + + private Phase handleAllotmentPatch() { + java.util.List patches; + try { + patches = findAllPatchesAt(PatchImplementation.ALLOTMENT); + } catch (RuntimeException e) { + log.debug("findAllPatchesAt interrupted, retrying next tick", e); + return null; + } + + if (patches.isEmpty()) { + WorldPoint patchLoc = FarmingContractData.PATCH_LOCATIONS.get(PatchImplementation.ALLOTMENT); + if (patchLoc != null) { + log.warn("Allotment patches not found, walking closer"); + Rs2Walker.walkTo(patchLoc); + sleepUntil(() -> !Rs2Player.isMoving(), 15000); + } + return null; + } + + TileObject rightCropPatch = null; + PatchState rightCropState = null; + TileObject usablePatch = null; + PatchState usableState = null; + + for (TileObject p : patches) { + PatchState state; + try { + state = detectPatchState(p); + } catch (RuntimeException e) { + log.debug("detectPatchState interrupted for allotment patch, retrying", e); + return null; + } + log.info("Allotment patch at {} state: {}, wrongCrop: {}", p.getWorldLocation(), state, isWrongCrop(p)); + + if (!isWrongCrop(p) && (state == PatchState.GROWING || state == PatchState.HARVESTABLE || state == PatchState.GROWN_CHECK)) { + rightCropPatch = p; + rightCropState = state; + break; + } + + if (state == PatchState.DEAD) { + usablePatch = p; + usableState = state; + } else if ((state == PatchState.EMPTY || state == PatchState.WEEDS) + && (usableState == null || usableState != PatchState.DEAD)) { + usablePatch = p; + usableState = state; + } + } + + if (rightCropPatch != null) { + if (rightCropState == PatchState.GROWING) { + FarmingContractPlugin.setStatus("Growing - stopping"); + return Phase.DONE; + } + if (!Rs2Inventory.contains("Spade")) return Phase.BANKING; + harvestPatch(rightCropPatch, PatchImplementation.ALLOTMENT); + return Phase.TURN_IN; + } + + TileObject target = usablePatch != null ? usablePatch : patches.get(0); + PatchState state = usableState; + if (state == null) { + try { + state = detectPatchState(target); + } catch (RuntimeException e) { + log.debug("detectPatchState interrupted, retrying", e); + return null; + } + } + + FarmingContractPlugin.setStatus("Patch: " + state.name()); + log.info("Using allotment at {} state: {}", target.getWorldLocation(), state); + + switch (state) { + case WEEDS: + if (!Rs2Inventory.contains("Rake")) return Phase.BANKING; + rakeWeeds(target); + target = findObjectAt(target.getWorldLocation()); + if (target == null) return null; + // fall through to EMPTY — patch is now clear + case EMPTY: + if (!hasRequiredSeeds()) { + if (config.autoDowngrade() && !needsDowngrade) { + needsDowngrade = true; + contract = null; + saveContract(); + return Phase.GET_CONTRACT; + } + FarmingContractPlugin.setStatus("Missing seeds"); + return Phase.DONE; + } + if (!plantCrop(target)) return null; + FarmingContractPlugin.setStatus("Planted - stopping"); + return Phase.DONE; + case DEAD: + if (!Rs2Inventory.contains("Spade")) return Phase.BANKING; + clearPatch(target, PatchImplementation.ALLOTMENT); + target = findObjectAt(target.getWorldLocation()); + if (target == null) return null; + // fall through to EMPTY — patch is now clear + if (!hasRequiredSeeds()) { + if (config.autoDowngrade() && !needsDowngrade) { + needsDowngrade = true; + contract = null; + saveContract(); + return Phase.GET_CONTRACT; + } + FarmingContractPlugin.setStatus("Missing seeds"); + return Phase.DONE; + } + if (!plantCrop(target)) return null; + FarmingContractPlugin.setStatus("Planted - stopping"); + return Phase.DONE; + case GROWING: + case HARVESTABLE: + if (!Rs2Inventory.contains("Spade")) return Phase.BANKING; + clearPatch(target, PatchImplementation.ALLOTMENT); + return null; + default: + return null; + } + } + + // --- Phase: TURN_IN --- + + private boolean handleTurnIn() { + FarmingContractPlugin.setStatus("Turning in contract"); + + if (Rs2Npc.getNpc("Guildmaster Jane") == null) { + Rs2Walker.walkTo(FarmingContractData.JANE_LOCATION); + sleepUntil(() -> !Rs2Player.isMoving(), 15000); + return false; + } + if (!Rs2Npc.interact("Guildmaster Jane", "Contract") + && !Rs2Npc.interact("Guildmaster Jane", "Talk-to")) { + log.info("Turn-in: failed to interact with Jane"); + return false; + } + sleepUntil(() -> Rs2Dialogue.isInDialogue() || Rs2Dialogue.hasSelectAnOption(), 15000); + log.info("Turn-in: dialogue started: inDialogue={}, hasOptions={}", + Rs2Dialogue.isInDialogue(), Rs2Dialogue.hasSelectAnOption()); + + contract = null; + contractName = null; + + int idleTicks = 0; + while (idleTicks < 5 && super.run()) { + if (Rs2Dialogue.hasSelectAnOption()) { + idleTicks = 0; + String tier = getDesiredTier(); + log.info("Turn-in: select option visible, tier={}", tier); + if (Rs2Dialogue.hasDialogueOption("Take another " + tier.toLowerCase())) { + Rs2Dialogue.clickOption("Take another " + tier.toLowerCase()); + sleep(600, 900); + continue; + } + if (Rs2Dialogue.hasDialogueOption(tier)) { + Rs2Dialogue.clickOption(tier); + sleep(600, 900); + continue; + } + if (Rs2Dialogue.hasDialogueOption("Yes please")) { + Rs2Dialogue.clickOption("Yes please"); + sleep(600, 900); + continue; + } + if (Rs2Dialogue.hasDialogueOption("Yes")) { + Rs2Dialogue.clickOption("Yes"); + sleep(600, 900); + continue; + } + if (Rs2Dialogue.hasDialogueOption("Thank you")) { + if (shouldDowngradeContract() + && Rs2Dialogue.hasDialogueOption("Do you have anything easier?")) { + log.info("Turn-in: auto-downgrading {} (type: {}), asking for easier", + contract != null ? contract.getName() : "unknown", + contract != null ? contract.getPatchImplementation() : "unknown"); + contract = null; + Rs2Dialogue.clickOption("Do you have anything easier?"); + sleep(600, 900); + continue; + } + needsDowngrade = false; + Rs2Dialogue.clickOption("Thank you"); + sleep(600, 900); + continue; + } + log.warn("Turn-in: no matching option found, clicking continue"); + Rs2Dialogue.clickContinue(); + sleep(600, 900); + continue; + } + + if (Rs2Dialogue.isInDialogue()) { + idleTicks = 0; + String text = Rs2Dialogue.getDialogueText(); + log.info("Turn-in: dialogue text: {}", text); + if (text != null) { + Matcher m = CONTRACT_PATTERN.matcher(text); + if (m.find()) { + String cropName = m.group(1).trim(); + contract = findProduceByContractName(cropName); + if (contract != null) { + log.info("New contract: {}", contract.getName()); + saveContract(); + } + } + } + Rs2Dialogue.clickContinue(); + sleep(600, 900); + continue; + } + + Rs2Dialogue.clickContinue(); + sleep(600, 900); + idleTicks++; + } + + log.info("Turn-in: loop exited, contract={}", contract != null ? contract.getName() : "null"); + + openSeedPacks(); + + return true; + } + + // --- Patch State Detection --- + + private PatchState detectPatchState(TileObject patch) { + WorldPoint loc = patch.getWorldLocation(); + + for (String[] entry : new String[][]{ + {"Check-health", "GROWN_CHECK"}, {"Harvest", "HARVESTABLE"}, {"Pick", "HARVESTABLE"}, + {"Pick-from", "HARVESTABLE"}, {"Pick-spine", "HARVESTABLE"}, + {"Chop down", "CHECKED_TREE"}, {"Clear", "DEAD"}, {"Rake", "WEEDS"} + }) { + for (GameObject obj : Rs2GameObject.getGameObjects( + o -> Rs2GameObject.hasAction(o, entry[0], true), loc, 3)) { + return PatchState.valueOf(entry[1]); + } + } + + var name = (patch instanceof GameObject) ? Rs2GameObject.getCompositionName((GameObject) patch) : java.util.Optional.empty(); + String lower = name.isPresent() ? name.get().toLowerCase() : ""; + if (lower.endsWith("patch") || lower.equals("allotment")) { + return PatchState.EMPTY; + } + log.info("detectPatchState id={} name={} no action matched, defaulting GROWING", patch.getId(), lower); + return PatchState.GROWING; + } + + private boolean isWrongCrop(TileObject patch) { + if (contract == null) return false; + var comp = Rs2GameObject.convertToObjectComposition(patch.getId(), false); + if (comp == null) return false; + String name = comp.getName().toLowerCase(); + if (name.endsWith("patch")) return false; + String patchName = FarmingContractData.PATCH_NAMES.getOrDefault( + contract.getPatchImplementation(), "").toLowerCase(); + if (!patchName.isEmpty() && name.contains(patchName)) return false; + if (name.equals("allotment") || name.equals("herb") || name.equals("flower") + || name.equals("bush") || name.equals("cactus")) return false; + String expected = contract.getName().toLowerCase(); + return !name.contains(expected); + } + + // --- Patch Actions --- + + private void rakeWeeds(TileObject patch) { + FarmingContractPlugin.setStatus("Raking weeds"); + WorldPoint patchLoc = patch.getWorldLocation(); + while (super.run()) { + if (Rs2GameObject.getGameObjects( + o -> Rs2GameObject.hasAction(o, "Rake", true), patchLoc, 3).isEmpty()) { + break; + } + if (Rs2Inventory.isFull()) { + Rs2Inventory.dropAll("Weeds"); + sleep(600, 900); + } + Rs2GameObject.interact(patch, "Rake"); + sleepUntil(() -> { + if (Rs2Inventory.isFull()) return true; + return Rs2GameObject.getGameObjects( + o -> Rs2GameObject.hasAction(o, "Rake", true), patchLoc, 3).isEmpty(); + }, 15000); + sleep(600, 900); + } + if (Rs2Inventory.contains("Weeds")) { + Rs2Inventory.dropAll("Weeds"); + sleep(600, 900); + } + } + + private boolean plantCrop(TileObject patch) { + WorldPoint targetLoc = patch.getWorldLocation(); + + int seedId = FarmingContractData.getSeedId(contract); + if (seedId == -1 || !Rs2Inventory.contains(seedId)) { + log.error("No seeds to plant"); + return false; + } + + patch = clearWeeds(targetLoc); + if (patch == null) return false; + + if (config.compostType() != FarmingContractConfig.CompostType.NONE) { + int compostId = config.compostType().getItemId(); + if (Rs2Inventory.contains(compostId)) { + FarmingContractPlugin.setStatus("Composting"); + for (int attempt = 0; attempt < 3 && Rs2Inventory.contains(compostId) && super.run(); attempt++) { + patch = findObjectAt(targetLoc); + if (patch == null) return false; + Rs2Inventory.useItemOnObject(compostId, patch.getId()); + sleepUntil(() -> Rs2Player.isMoving() || Rs2Player.isAnimating(), 5000); + if (Rs2Player.isMoving() || Rs2Player.isAnimating()) { + sleepUntil(() -> !Rs2Inventory.contains(compostId), 10000); + break; + } + sleep(1200, 1800); + } + sleep(600, 900); + } + } + + // Seeds are stackable; itemQuantity tracks the real count, count() only the slot. + int qtyBefore = Rs2Inventory.itemQuantity(seedId); + FarmingContractPlugin.setStatus("Planting " + contract.getName()); + for (int attempt = 0; attempt < 3 && Rs2Inventory.contains(seedId) && super.run(); attempt++) { + // Weeds can regrow while composting — clear them again before each plant attempt. + patch = clearWeeds(targetLoc); + if (patch == null) return false; + Rs2Inventory.useItemOnObject(seedId, patch.getId()); + // Success = a seed was actually consumed, not merely that the player moved. + if (sleepUntil(() -> Rs2Inventory.itemQuantity(seedId) < qtyBefore, 8000)) { + return true; + } + sleep(1200, 1800); + } + return Rs2Inventory.itemQuantity(seedId) < qtyBefore; + } + + private TileObject clearWeeds(WorldPoint loc) { + TileObject patch = findObjectAt(loc); + while (patch != null && detectPatchState(patch) == PatchState.WEEDS && super.run()) { + log.info("Patch has weeds, raking"); + rakeWeeds(patch); + patch = findObjectAt(loc); + } + return patch; + } + + private void harvestPatch(TileObject patch, PatchImplementation patchType) { + String action; + switch (patchType) { + case HERB: + case FLOWER: + action = "Pick"; + break; + case ALLOTMENT: + action = "Harvest"; + break; + case BUSH: + action = "Pick-from"; + break; + case CACTUS: + action = "Pick-spine"; + break; + default: + return; + } + + FarmingContractPlugin.setStatus("Harvesting"); + int cropItemId = contract.getItemID(); + WorldPoint patchLoc = patch.getWorldLocation(); + + if (patchType == PatchImplementation.FLOWER) { + noteItemsWithLeprechaun(cropItemId); + } + + while (super.run()) { + if (!Rs2GameObject.getGameObjects( + o -> Rs2GameObject.hasAction(o, action, true), patchLoc, 3).isEmpty()) { + Rs2GameObject.interact( + Rs2GameObject.getGameObjects( + o -> Rs2GameObject.hasAction(o, action, true), patchLoc, 3).get(0), action); + sleepUntil(() -> { + if (Rs2Inventory.isFull()) return true; + return Rs2GameObject.getGameObjects( + o -> Rs2GameObject.hasAction(o, action, true), patchLoc, 3).isEmpty(); + }, 60000); + } else { + break; + } + + if (!FarmingContractData.needsClearAfterHarvest(patchType) + && Rs2Inventory.count(cropItemId) > 1) { + noteItemsWithLeprechaun(cropItemId); + } + } + + if (patchType == PatchImplementation.FLOWER) { + String cropName = Microbot.getClientThread().runOnClientThreadOptional( + () -> Microbot.getItemManager().getItemComposition(cropItemId).getName() + ).orElse(""); + if (!cropName.isEmpty()) { + LootingParameters params = new LootingParameters( + 5, 1, 1, 1, false, true, cropName); + while (Rs2GroundItem.lootItemsBasedOnNames(params) && super.run()) { + Rs2Inventory.waitForInventoryChanges(5000); + sleep(600, 900); + noteItemsWithLeprechaun(cropItemId); + } + } + } + } + + private void noteItemsWithLeprechaun(int itemId) { + if (!Rs2Inventory.contains(itemId)) return; + FarmingContractPlugin.setStatus("Noting items"); + var leprechaun = Rs2Npc.getNpc("Tool leprechaun"); + if (leprechaun == null) return; + Rs2Inventory.useItemOnNpc(itemId, leprechaun); + Rs2Inventory.waitForInventoryChanges(10000); + sleep(600, 900); + } + + private void checkHealth(TileObject patch) { + FarmingContractPlugin.setStatus("Checking health"); + Rs2GameObject.interact(patch, "Check-health"); + Rs2Player.waitForXpDrop(Skill.FARMING); + sleep(600, 1200); + } + + private void clearPatch(TileObject patch, PatchImplementation patchType) { + if (patchType == PatchImplementation.TREE || patchType == PatchImplementation.FRUIT_TREE) { + if (payGardener(patchType)) return; + } + + FarmingContractPlugin.setStatus("Clearing patch"); + WorldPoint patchLoc = patch.getWorldLocation(); + TileObject current = findObjectAt(patchLoc); + if (current == null) current = patch; + + // Chop first if needed + GameObject chopTarget = Rs2GameObject.findObjectByImposter(current.getId(), "Chop", false); + if (chopTarget != null) { + Rs2GameObject.interact(chopTarget, "Chop down"); + sleepUntil(() -> !Rs2Player.isAnimating(), 15000); + sleep(600, 1200); + current = findObjectAt(patchLoc); + if (current == null) return; + } + + WorldPoint clearLoc = current.getWorldLocation(); + Rs2GameObject.interact(current, "Clear"); + sleepUntil(() -> Rs2Dialogue.isInDialogue() || Rs2Player.isAnimating(), 5000); + + if (Rs2Dialogue.isInDialogue()) { + if (Rs2Dialogue.hasDialogueOption("Yes, don't ask me again")) { + Rs2Dialogue.clickOption("Yes, don't ask me again"); + } else if (Rs2Dialogue.hasDialogueOption("Yes")) { + Rs2Dialogue.clickOption("Yes"); + } + sleep(600, 900); + } + + sleepUntil(() -> Rs2GameObject.getGameObjects( + o -> Rs2GameObject.hasAction(o, "Clear", true), clearLoc, 3).isEmpty(), 10000); + } + + private boolean payGardener(PatchImplementation patchType) { + if (Rs2Inventory.itemQuantity("Coins") < 200) return false; + + var gardener = Rs2Npc.getNearestNpcWithAction("Pay"); + if (gardener != null) { + FarmingContractPlugin.setStatus("Paying gardener"); + Rs2Npc.interact(gardener, "Pay"); + } else { + String npcName = patchType == PatchImplementation.FRUIT_TREE ? "Nikkie" : "Rosie"; + String payAction = patchType == PatchImplementation.FRUIT_TREE ? "Pay (Fruit tree)" : "Pay (tree patch)"; + var guildGardener = Microbot.getRs2NpcCache().query().withName(npcName).nearestOnClientThread(); + if (guildGardener == null) return false; + FarmingContractPlugin.setStatus("Paying gardener"); + guildGardener.click(payAction); + } + + sleepUntil(Rs2Dialogue::isInDialogue, 5000); + if (!Rs2Dialogue.isInDialogue()) return false; + + sleep(600, 900); + if (!Rs2Dialogue.hasSelectAnOption()) { + Rs2Dialogue.clickContinue(); + sleep(600, 900); + } + sleepUntil(Rs2Dialogue::hasSelectAnOption, 5000); + + if (Rs2Dialogue.hasSelectAnOption()) { + Rs2Dialogue.clickOption("Yes"); + sleep(600, 1200); + } + + while (Rs2Dialogue.isInDialogue()) { + Rs2Dialogue.clickContinue(); + sleep(600, 900); + } + sleep(1200, 2400); + return true; + } + + private boolean protectTree() { + if (!config.protectTrees() || contract == null) return false; + if (!FarmingContractData.hasProtectionData(contract)) return false; + + int protItemId = FarmingContractData.getProtectionItemId(contract); + int protItemQty = FarmingContractData.getProtectionItemQty(contract); + if (countWithNoted(protItemId) < protItemQty) { + log.warn("Missing protection items (have={}, need={}), skipping", + countWithNoted(protItemId), protItemQty); + return false; + } + + PatchImplementation patchType = contract.getPatchImplementation(); + var gardener = Rs2Npc.getNearestNpcWithAction("Pay"); + if (gardener != null) { + FarmingContractPlugin.setStatus("Protecting tree"); + Rs2Npc.interact(gardener, "Pay"); + } else { + String npcName = patchType == PatchImplementation.FRUIT_TREE ? "Nikkie" : "Rosie"; + String payAction = patchType == PatchImplementation.FRUIT_TREE ? "Pay (Fruit tree)" : "Pay (tree patch)"; + var guildGardener = Microbot.getRs2NpcCache().query().withName(npcName).nearestOnClientThread(); + if (guildGardener == null) { + log.warn("No gardener found for protection"); + return false; + } + FarmingContractPlugin.setStatus("Protecting tree"); + guildGardener.click(payAction); + } + sleepUntil(Rs2Dialogue::isInDialogue, 5000); + + if (!Rs2Dialogue.isInDialogue()) { + log.warn("Gardener dialogue did not open"); + return false; + } + + sleep(600, 900); + if (Rs2Dialogue.hasDialogueText("already looking after") + || Rs2Dialogue.hasDialogueText("Leave it with me")) { + while (Rs2Dialogue.isInDialogue() && super.run()) { + Rs2Dialogue.clickContinue(); + sleep(600, 900); + } + log.info("Gardener already protecting tree"); + return true; + } + + if (!Rs2Dialogue.hasSelectAnOption()) { + Rs2Dialogue.clickContinue(); + sleepUntil(Rs2Dialogue::hasSelectAnOption, 5000); + } + + if (Rs2Dialogue.hasSelectAnOption()) { + if (!Rs2Dialogue.clickOption("don't ask")) { + Rs2Dialogue.clickOption("Yes"); + } + sleep(600, 1200); + } + + while (Rs2Dialogue.isInDialogue() && super.run()) { + Rs2Dialogue.clickContinue(); + sleep(600, 900); + } + sleep(600, 1200); + + log.info("Tree protection paid for {}", contract.getName()); + return true; + } + + private boolean hasProtectionItems() { + if (contract == null) return false; + int protItemId = FarmingContractData.getProtectionItemId(contract); + int protItemQty = FarmingContractData.getProtectionItemQty(contract); + if (protItemId == -1) return false; + return countWithNoted(protItemId) >= protItemQty; + } + + private int countWithNoted(int itemId) { + int qty = Rs2Inventory.itemQuantity(itemId); + Integer noted = Microbot.getClientThread().runOnClientThreadOptional(() -> { + var comp = Microbot.getItemManager().getItemComposition(itemId); + int linked = comp.getLinkedNoteId(); + return linked > 0 ? linked : null; + }).orElse(null); + if (noted != null) qty += Rs2Inventory.itemQuantity(noted); + return qty; + } + + // --- Helpers --- + + private java.util.List findAllPatchesAt(PatchImplementation patchType) { + WorldPoint loc = FarmingContractData.PATCH_LOCATIONS.get(patchType); + String patchName = FarmingContractData.PATCH_NAMES.get(patchType); + if (loc == null || patchName == null) return java.util.Collections.emptyList(); + + String lower = patchName.toLowerCase(); + int radius = patchType == PatchImplementation.ALLOTMENT ? 15 : 5; + + java.util.List results = new java.util.ArrayList<>(); + + String cropLower = contract != null ? contract.getName().toLowerCase() : null; + for (GameObject obj : Rs2GameObject.getGameObjects(o -> true, loc, radius)) { + var name = Rs2GameObject.getCompositionName(obj); + if (name.isEmpty()) continue; + String n = name.get().toLowerCase(); + if ((cropLower != null && n.contains(cropLower)) || n.contains(lower)) { + if (isDistinctPatch(results, obj)) { + results.add(obj); + } + } + } + + if (results.isEmpty()) { + for (GameObject obj : Rs2GameObject.getGameObjects(o -> true, loc, radius)) { + var comp = Rs2GameObject.convertToObjectComposition(obj); + if (comp == null || comp.getActions() == null) continue; + for (String action : comp.getActions()) { + if (action != null && FARMING_ACTIONS.contains(action)) { + if (isDistinctPatch(results, obj)) { + results.add(obj); + } + break; + } + } + } + } + + return results; + } + + private boolean isDistinctPatch(java.util.List existing, TileObject candidate) { + int y = candidate.getWorldLocation().getY(); + boolean isNorth = y >= 3732; + for (TileObject t : existing) { + boolean existingNorth = t.getWorldLocation().getY() >= 3732; + if (isNorth == existingNorth) return false; + } + return true; + } + + private TileObject findPatchAt(PatchImplementation patchType) { + WorldPoint loc = FarmingContractData.PATCH_LOCATIONS.get(patchType); + String patchName = FarmingContractData.PATCH_NAMES.get(patchType); + if (loc == null || patchName == null) return null; + + String lower = patchName.toLowerCase(); + int radius = patchType == PatchImplementation.ALLOTMENT ? 15 : 5; + + // If we have a contract, check for a growing crop first (name changes when growing) + if (contract != null) { + String cropLower = contract.getName().toLowerCase(); + for (GameObject obj : Rs2GameObject.getGameObjects(o -> true, loc, radius)) { + var name = Rs2GameObject.getCompositionName(obj); + if (name.isPresent() && name.get().toLowerCase().contains(cropLower)) { + return obj; + } + } + } + + // Search by patch base name (empty/weeded patches keep their base name) + for (GameObject obj : Rs2GameObject.getGameObjects(o -> true, loc, radius)) { + var name = Rs2GameObject.getCompositionName(obj); + if (name.isPresent() && name.get().toLowerCase().contains(lower)) { + return obj; + } + } + + // Fallback: find nearest with farming actions + for (GameObject obj : Rs2GameObject.getGameObjects(o -> true, loc, radius)) { + var comp = Rs2GameObject.convertToObjectComposition(obj); + if (comp == null || comp.getActions() == null) continue; + for (String action : comp.getActions()) { + if (action != null && FARMING_ACTIONS.contains(action)) { + return obj; + } + } + } + + log.debug("No {} patch found near {}", patchType, loc); + return null; + } + + private TileObject findObjectAt(WorldPoint loc) { + for (GameObject obj : Rs2GameObject.getGameObjects(o -> true, loc, 1)) { + if (obj.getWorldLocation().equals(loc)) return obj; + } + return null; + } + + private static final java.util.Set FARMING_ACTIONS = java.util.Set.of( + "Rake", "Pick", "Harvest", "Pick-from", "Pick-spine", + "Check-health", "Chop down", "Clear", "Inspect", "Guide" + ); + + private boolean needsSpadeForAction(PatchImplementation patchType) { + return patchType == PatchImplementation.ALLOTMENT; + } + + private boolean hasRequiredSeeds() { + int seedId = FarmingContractData.getSeedId(contract); + int seedQty = FarmingContractData.getSeedsRequired(contract); + return seedId != -1 && Rs2Inventory.itemQuantity(seedId) >= seedQty; + } + + + @Override + public void shutdown() { + super.shutdown(); + contractName = null; + } +} From d8e62c7fbb4e89df578ffe44603a04c66a45a466 Mon Sep 17 00:00:00 2001 From: runsonmypc Date: Fri, 19 Jun 2026 12:55:48 -0400 Subject: [PATCH 2/3] feat(farmingcontract): add plugin icon and card art Use the Guildmaster Jane chathead as the icon and card; wire up iconUrl/cardUrl in the descriptor. Bump version 0.2.1 -> 0.2.2. --- .../farmingcontract/FarmingContractPlugin.java | 4 +++- .../farmingcontract/docs/assets/card.png | Bin 0 -> 5438 bytes .../farmingcontract/docs/assets/icon.png | Bin 0 -> 5438 bytes 3 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/net/runelite/client/plugins/microbot/farmingcontract/docs/assets/card.png create mode 100644 src/main/resources/net/runelite/client/plugins/microbot/farmingcontract/docs/assets/icon.png diff --git a/src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractPlugin.java b/src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractPlugin.java index 30fd5c7790..39d74bd9e0 100644 --- a/src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractPlugin.java +++ b/src/main/java/net/runelite/client/plugins/microbot/farmingcontract/FarmingContractPlugin.java @@ -20,12 +20,14 @@ authors = {"Pert"}, version = FarmingContractPlugin.VERSION, minClientVersion = "2.6.0", + iconUrl = "https://chsami.github.io/Microbot-Hub/FarmingContractPlugin/assets/icon.png", + cardUrl = "https://chsami.github.io/Microbot-Hub/FarmingContractPlugin/assets/card.png", enabledByDefault = PluginConstants.DEFAULT_ENABLED, isExternal = PluginConstants.IS_EXTERNAL ) @Slf4j public class FarmingContractPlugin extends Plugin { - static final String VERSION = "0.2.1"; + static final String VERSION = "0.2.2"; @Getter @Setter diff --git a/src/main/resources/net/runelite/client/plugins/microbot/farmingcontract/docs/assets/card.png b/src/main/resources/net/runelite/client/plugins/microbot/farmingcontract/docs/assets/card.png new file mode 100644 index 0000000000000000000000000000000000000000..69a6b376ba6a35cc067b8179dd2315a413550445 GIT binary patch literal 5438 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 literal 0 HcmV?d00001 diff --git a/src/main/resources/net/runelite/client/plugins/microbot/farmingcontract/docs/assets/icon.png b/src/main/resources/net/runelite/client/plugins/microbot/farmingcontract/docs/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..69a6b376ba6a35cc067b8179dd2315a413550445 GIT binary patch literal 5438 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 literal 0 HcmV?d00001 From 30359679434fc294ba6838f703d1ab0f04bd62c5 Mon Sep 17 00:00:00 2001 From: runsonmypc Date: Fri, 19 Jun 2026 12:57:39 -0400 Subject: [PATCH 3/3] docs(farmingcontract): add plugin README --- .../microbot/farmingcontract/docs/readme.md | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/main/resources/net/runelite/client/plugins/microbot/farmingcontract/docs/readme.md diff --git a/src/main/resources/net/runelite/client/plugins/microbot/farmingcontract/docs/readme.md b/src/main/resources/net/runelite/client/plugins/microbot/farmingcontract/docs/readme.md new file mode 100644 index 0000000000..164148c07d --- /dev/null +++ b/src/main/resources/net/runelite/client/plugins/microbot/farmingcontract/docs/readme.md @@ -0,0 +1,24 @@ +# Farming Contracts + +![icon.png](assets/icon.png) + +Automates Guildmaster Jane's farming contracts in the Farming Guild. Start the plugin while inside the guild and it runs the full loop: take a contract, gather supplies, plant, harvest, and turn in. + +## Features + +| Feature | Description | +|---|---| +| **Contract handling** | Takes a contract from Jane and turns it in when complete. | +| **Auto banking** | Withdraws the tools, seeds, compost, and coins needed for the current contract. | +| **All patch types** | Plants and harvests allotment, herb, flower, bush, cactus, tree, and fruit tree contracts. | +| **Tree clearing** | Pays the guild gardeners (Rosie, Nikkie) to clear old or check-healthed trees before replanting. | +| **Weed handling** | Rakes patches before planting and re-rakes if weeds regrow during composting. | +| **Optional composting** | Fills the compost bin with pineapples for ultracompost. | +| **Tree protection** | Optionally pays a gardener to protect newly planted trees and fruit trees. | +| **Auto downgrade** | Requests an easier contract when the assigned crop's seeds are unavailable. | + +## Requirements + +- Inside the Farming Guild with the matching farming level for the contract tier. +- Seeds, compost, and coins available in the bank. +- Standard farming tools (spade, rake, seed dibber, secateurs).