From a23c7055838f96d7ae5c0560307a1165ee591215 Mon Sep 17 00:00:00 2001 From: bgatfa Date: Fri, 26 Jun 2026 10:32:29 -0500 Subject: [PATCH] feat(shortestpath): avoid pathing next to aggressive NPCs (undead trees) Aggressive NPCs such as the Draynor Manor undead trees attack any player who walks onto a tile next to them (0-3 dmg, aggressive at every combat level). The web walker routes the shortest path straight along the tree line, so long walks take repeated hits and can stall the walker adjacent to a tree. Add an "Avoid dangerous NPCs" pathfinder option (default on): - New dangerous_tiles.tsv lists hazard tiles (the Draynor undead trees). - PathfinderConfig expands each hazard tile to its 8-neighbour aggro ring and exposes isDangerousAdjacentTile(). - CollisionMap applies a g-cost penalty (not a hard block) to stepping into that ring when the option is on, in BOTH the forward (getNeighbors) and reverse (getReverseNeighbors) expansions, so the bidirectional search costs hazard edges consistently and can't pick a hazard-adjacent meeting. Paths keep >=2 tiles from a hazard when a detour exists; a true chokepoint still routes through. The check is a boolean + O(1) set lookup; no client-thread reads. - ShortestPathConfig gains the toggle; the sectionSettings positions are renumbered to stay unique. - Rs2Walker's unreachable-recovery fallback now also respects the ring: the planner avoids hazard tiles, but the runtime recovery click could otherwise park the player next to one and strand it in melee, so the recovery target is stepped back along the path to the nearest non-hazard tile. Data-driven: any hazard can be added by appending tiles to the tsv. --- .../shortestpath/ShortestPathConfig.java | 74 +++++++++++-------- .../shortestpath/pathfinder/CollisionMap.java | 28 ++++++- .../pathfinder/PathfinderConfig.java | 43 +++++++++++ .../microbot/util/walker/Rs2Walker.java | 15 ++++ .../microbot/shortestpath/dangerous_tiles.tsv | 14 ++++ 5 files changed, 141 insertions(+), 33 deletions(-) create mode 100644 runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/dangerous_tiles.tsv diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java index 8f53d20259..f239ab1530 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathConfig.java @@ -132,12 +132,24 @@ default boolean avoidWilderness() { return true; } + @ConfigItem( + keyName = "avoidDangerousNpcs", + name = "Avoid dangerous NPCs", + description = "Route around tiles next to aggressive NPCs that attack you while passing
" + + "(e.g. undead trees at Draynor Manor). A penalty, not a block, so chokepoints still work.", + position = 1, + section = sectionSettings + ) + default boolean avoidDangerousNpcs() { + return true; + } + @ConfigItem( keyName = "useAgilityShortcuts", name = "Use agility shortcuts", description = "Whether to include agility shortcuts in the path.
" + "You must also have the required agility level", - position = 1, + position = 2, section = sectionSettings ) default boolean useAgilityShortcuts() { @@ -149,7 +161,7 @@ default boolean useAgilityShortcuts() { name = "Use grapple shortcuts", description = "Whether to include crossbow grapple agility shortcuts in the path.
" + "You must also have the required agility, ranged and strength levels", - position = 2, + position = 3, section = sectionSettings ) default boolean useGrappleShortcuts() { @@ -161,7 +173,7 @@ default boolean useGrappleShortcuts() { name = "Use boats", description = "Whether to include small boats in the path
" + "(e.g. the boat to Fishing Platform)", - position = 3, + position = 4, section = sectionSettings ) default boolean useBoats() { @@ -172,7 +184,7 @@ default boolean useBoats() { keyName = "useCanoes", name = "Use canoes", description = "Whether to include canoes in the path", - position = 4, + position = 5, section = sectionSettings ) default boolean useCanoes() { @@ -183,7 +195,7 @@ default boolean useCanoes() { keyName = "useCharterShips", name = "Use charter ships", description = "Whether to include charter ships in the path", - position = 5, + position = 6, section = sectionSettings ) default boolean useCharterShips() { @@ -195,7 +207,7 @@ default boolean useCharterShips() { name = "Use ships", description = "Whether to include passenger ships in the path
" + "(e.g. the customs ships to Karamja)", - position = 6, + position = 7, section = sectionSettings ) default boolean useShips() { @@ -207,7 +219,7 @@ default boolean useShips() { name = "Use fairy rings", description = "Whether to include fairy rings in the path.
" + "You must also have completed the required quests or miniquests", - position = 7, + position = 8, section = sectionSettings ) default boolean useFairyRings() { @@ -218,7 +230,7 @@ default boolean useFairyRings() { keyName = "useGnomeGliders", name = "Use gnome gliders", description = "Whether to include gnome gliders in the path", - position = 8, + position = 9, section = sectionSettings ) default boolean useGnomeGliders() { @@ -230,7 +242,7 @@ default boolean useGnomeGliders() { name = "Use minecarts", description = "Whether to include minecarts in the path
" + "(e.g. the Keldagrim and Lovakengj minecart networks)", - position = 9, + position = 10, section = sectionSettings ) default boolean useMinecarts() { @@ -241,7 +253,7 @@ default boolean useMinecarts() { keyName = "useSpiritTrees", name = "Use spirit trees", description = "Whether to include spirit trees in the path", - position = 10, + position = 11, section = sectionSettings ) default boolean useSpiritTrees() { @@ -253,7 +265,7 @@ default boolean useSpiritTrees() { name = "Use teleportation items", description = "Whether to include teleportation items from the player's inventory and equipment.
" + "Options labelled (perm) only use permanent non-charge items.", - position = 11, + position = 12, section = sectionSettings ) default TeleportationItem useTeleportationItems() { @@ -265,7 +277,7 @@ default TeleportationItem useTeleportationItems() { name = "Use teleportation levers", description = "Whether to include teleportation levers in the path
" + "(e.g. the lever from Edgeville to Wilderness)", - position = 12, + position = 13, section = sectionSettings ) default boolean useTeleportationLevers() { @@ -277,7 +289,7 @@ default boolean useTeleportationLevers() { name = "Use teleportation portals", description = "Whether to include teleportation portals in the path
" + "(e.g. the portal from Ferox Enclave to Castle Wars)", - position = 13, + position = 14, section = sectionSettings ) default boolean useTeleportationPortals() { @@ -288,7 +300,7 @@ default boolean useTeleportationPortals() { keyName = "useTeleportationSpells", name = "Use teleportation spells", description = "Whether to include teleportation spells in the path", - position = 14, + position = 15, section = sectionSettings ) default boolean useTeleportationSpells() { @@ -300,7 +312,7 @@ default boolean useTeleportationSpells() { name = "Use teleportation to minigames", description = "Whether to include teleportation to minigames/activities/grouping in the path
" + "(e.g. the Nightmare Zone minigame teleport). These teleports share a 20 minute cooldown.", - position = 15, + position = 16, section = sectionSettings ) default boolean useTeleportationMinigames() { @@ -311,7 +323,7 @@ default boolean useTeleportationMinigames() { keyName = "useWildernessObelisks", name = "Use wilderness obelisks", description = "Whether to include wilderness obelisks in the path", - position = 16, + position = 17, section = sectionSettings ) default boolean useWildernessObelisks() { @@ -322,7 +334,7 @@ default boolean useWildernessObelisks() { keyName = "useNpcs", name = "Use npcs", description = "Whether to include npc transports in the path
(e.g. Tree gnome village maze or Lumbridge cellar)", - position = 17, + position = 18, section = sectionSettings ) default boolean useNpcs() { @@ -333,7 +345,7 @@ default boolean useNpcs() { keyName = "useQuetzals", name = "Use quetzals", description = "Whether to include quetzals in the path.
", - position = 18, + position = 19, section = sectionSettings ) default boolean useQuetzals() { @@ -344,7 +356,7 @@ default boolean useQuetzals() { keyName = "useMagicCarpets", name = "Use Magic Carpets", description = "Whether to include magic carpets in the path.
", - position = 19, + position = 20, section = sectionSettings ) default boolean useMagicCarpets() { @@ -355,7 +367,7 @@ default boolean useMagicCarpets() { keyName = "useHotAirBalloons", name = "Use Hot Air Balloons", description = "Whether to include hot air balloons in the path.", - position = 19, + position = 21, section = sectionSettings ) default boolean useHotAirBalloons() { @@ -366,7 +378,7 @@ default boolean useHotAirBalloons() { keyName = "useMagicMushtrees", name = "Use Magic Mushtrees", description = "Whether to include magic mushtrees in the path.", - position = 19, + position = 22, section = sectionSettings ) default boolean useMagicMushtrees() { @@ -377,7 +389,7 @@ default boolean useMagicMushtrees() { keyName = "useSeasonalTransports", name = "Use Seasonal Transports", description = "Whether to include seasonal League transports (e.g. Map of Alacrity) in the path. League worlds only.", - position = 19, + position = 23, section = sectionSettings ) default boolean useSeasonalTransports() { @@ -388,7 +400,7 @@ default boolean useSeasonalTransports() { keyName = "usePoh", name = "Use Player-owned-house Teleports", description = "Whether to include teleportation through the PoH", - position = 20, + position = 24, section = sectionSettings ) default boolean usePoh() { @@ -400,7 +412,7 @@ default boolean usePoh() { name = "Cancel instead of recalculating", description = "Whether the path should be cancelled rather than recalculated " + "when the recalculate distance limit is exceeded", - position = 21, + position = 25, section = sectionSettings ) default boolean cancelInstead() { @@ -411,7 +423,7 @@ default boolean cancelInstead() { keyName = "showTransportInfo", name = "Show transport info", description = "Whether to display transport destination hint info, e.g. which chat option and text to click", - position = 22, + position = 26, section = sectionSettings ) default boolean showTransportInfo() { @@ -423,7 +435,7 @@ default boolean showTransportInfo() { name = "Teleport distance", description = "Distance before using a teleport
" + "(This is to avoid using teleports when you are to close", - position = 23, + position = 27, section = sectionSettings ) default int distanceBeforeUsingTeleport() { @@ -438,7 +450,7 @@ default int distanceBeforeUsingTeleport() { keyName = "recalculateDistance", name = "Recalculate distance", description = "Distance from the path the player should be for it to be recalculated (-1 for never)", - position = 24, + position = 28, section = sectionSettings ) default int recalculateDistance() { @@ -453,7 +465,7 @@ default int recalculateDistance() { keyName = "finishDistance", name = "Finish distance", description = "Distance from the target tile at which the path should be ended (-1 for never)", - position = 25, + position = 29, section = sectionSettings ) default int reachedDistance() { @@ -464,7 +476,7 @@ default int reachedDistance() { keyName = "showTileCounter", name = "Show tile counter", description = "Whether to display the number of tiles travelled, number of tiles remaining or disable counting", - position = 26, + position = 30, section = sectionSettings ) default TileCounter showTileCounter() { @@ -475,7 +487,7 @@ default TileCounter showTileCounter() { keyName = "tileCounterStep", name = "Tile counter step", description = "The number of tiles between the displayed tile counter numbers", - position = 27, + position = 31, section = sectionSettings ) default int tileCounterStep() @@ -495,7 +507,7 @@ default int tileCounterStep() name = "Calculation cutoff", description = "The cutoff threshold in number of ticks (0.6 seconds) of no progress being
" + "made towards the path target before the calculation will be stopped", - position = 28, + position = 32, section = sectionSettings ) default int calculationCutoff() diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java index b35f09514a..cee0e1570e 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/CollisionMap.java @@ -160,6 +160,10 @@ private static int packedPointFromOrdinal(int startPacked, OrdinalDirection dire private volatile long cachedRegionIdTime = 0; private static final long REGION_CACHE_MS = 5000; private static final int TOA_PUZZLE_REGION = 14162; + // Extra g-cost (in tile-distance units) for stepping onto a tile next to an aggressive-NPC + // hazard. High enough to strongly prefer a detour, but a penalty (not a block) so a true + // chokepoint is still traversable. + private static final int DANGEROUS_TILE_PENALTY = 100; private int getCachedRegionId() { long now = System.currentTimeMillis(); @@ -261,7 +265,18 @@ public List getNeighbors(Node node, VisitedTiles visited, PathfinderConfig } if (traversable[i]) { - neighbors.add(new Node(neighborPacked, node)); + if (config.isAvoidDangerousNpcs() + && config.isDangerousAdjacentTile(neighborPacked) + && !targets.contains(neighborPacked)) { + // Penalty (not a skip): the path keeps >=2 tiles from the hazard when a + // reasonable detour exists, but a chokepoint still routes through. + int penalizedCost = node.cost + + WorldPointUtil.distanceBetween(node.packedPosition, neighborPacked) + + DANGEROUS_TILE_PENALTY; + neighbors.add(new Node(neighborPacked, node, penalizedCost)); + } else { + neighbors.add(new Node(neighborPacked, node)); + } } else if (Math.abs(d.x + d.y) == 1 && isBlocked(x + d.x, y + d.y, z)) { // The transport starts from a blocked adjacent tile, e.g. fairy ring // Only checks non-teleport transports (includes portals and levers, but not items and spells) @@ -354,7 +369,16 @@ public List getReverseNeighbors(Node node, VisitedTiles visitedBackward, P } if (traversable[i]) { - neighbors.add(new Node(prevPacked, node)); + if (config.isAvoidDangerousNpcs() && config.isDangerousAdjacentTile(prevPacked)) { + // Mirror the forward danger penalty so the bidirectional search costs the same + // edge consistently from both ends and can't pick a hazard-adjacent meeting. + int penalizedCost = node.cost + + WorldPointUtil.distanceBetween(node.packedPosition, prevPacked) + + DANGEROUS_TILE_PENALTY; + neighbors.add(new Node(prevPacked, node, penalizedCost)); + } else { + neighbors.add(new Node(prevPacked, node)); + } } else if (Math.abs(d.x + d.y) == 1 && isBlocked(WorldPointUtil.unpackWorldX(prevPacked), WorldPointUtil.unpackWorldY(prevPacked), z)) { int wx = WorldPointUtil.unpackWorldX(prevPacked); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java index 918c52459c..211e1fdeef 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfig.java @@ -59,6 +59,9 @@ public class PathfinderConfig { private static final WorldPoint SPIRIT_TREE_HOSIDIUS = new WorldPoint(1693, 3540, 0); private static final WorldPoint SPIRIT_TREE_FARMING_GUILD = new WorldPoint(1251, 3750, 0); private static final Set STATIC_BLOCKED_EDGES_PACKED = loadStaticBlockedEdgesFromResources(); + // Tiles within 1 of an aggressive-NPC hazard tile (the melee-aggro ring). Stepping onto one + // gets a high pathfinding penalty when avoidDangerousNpcs is on, so paths keep >=2 tiles away. + private static final Set DANGEROUS_ADJACENT_TILES_PACKED = loadDangerousTilesFromResources(); /** Order matches {@link #spiritTreeDestinationToggle(int)} — add destinations in both places only here + switch. */ private static final WorldPoint[] SPIRIT_TREE_DESTINATIONS_ORDERED = { @@ -102,6 +105,8 @@ public class PathfinderConfig { @Getter private volatile boolean avoidWilderness; @Getter + private volatile boolean avoidDangerousNpcs; + @Getter private volatile boolean useSpiritTrees; private volatile boolean useAgilityShortcuts, useGrappleShortcuts, @@ -192,6 +197,7 @@ public CollisionMap getMap() { public void refresh(WorldPoint target) { calculationCutoffMillis = (long) config.calculationCutoff() * Constants.GAME_TICK_LENGTH; avoidWilderness = ShortestPathPlugin.override("avoidWilderness", config.avoidWilderness()); + avoidDangerousNpcs = ShortestPathPlugin.override("avoidDangerousNpcs", config.avoidDangerousNpcs()); useAgilityShortcuts = ShortestPathPlugin.override("useAgilityShortcuts", config.useAgilityShortcuts()); useGrappleShortcuts = ShortestPathPlugin.override("useGrappleShortcuts", config.useGrappleShortcuts()); useBoats = ShortestPathPlugin.override("useBoats", config.useBoats()); @@ -594,6 +600,43 @@ private static void addStaticEdge(Set edges, WorldPoint origin, WorldPoint WorldPointUtil.packWorldPoint(destination))); } + /** True if {@code packedPoint} is within 1 tile of an aggressive-NPC hazard tile. */ + public boolean isDangerousAdjacentTile(int packedPoint) { + return DANGEROUS_ADJACENT_TILES_PACKED.contains(packedPoint); + } + + private static Set loadDangerousTilesFromResources() { + Set tiles = new HashSet<>(); + final String prefixComment = "#"; + final String delimColumn = "\t"; + + try { + String s = new String(Util.readAllBytes( + ShortestPathPlugin.class.getResourceAsStream("dangerous_tiles.tsv")), StandardCharsets.UTF_8); + Scanner scanner = new Scanner(s); + while (scanner.hasNextLine()) { + String line = scanner.nextLine(); + if (line.startsWith(prefixComment) || line.isBlank()) { + continue; + } + WorldPoint hazard = parseBlockedEdgePoint(line.split(delimColumn)[0]); + // Penalize the hazard tile and all 8 neighbours (the melee-aggro ring) so the + // path keeps >=2 tiles away. Hazard tiles themselves are usually blocked anyway. + for (int dx = -1; dx <= 1; dx++) { + for (int dy = -1; dy <= 1; dy++) { + tiles.add(WorldPointUtil.packWorldPoint( + hazard.getX() + dx, hazard.getY() + dy, hazard.getPlane())); + } + } + } + scanner.close(); + } catch (IOException e) { + throw new RuntimeException("Unable to load shortest-path dangerous tiles", e); + } + + return Collections.unmodifiableSet(tiles); + } + private static boolean blocksWalkingEdgeWhenUnavailable(Transport transport) { if (transport == null || transport.getOrigin() == null || transport.getDestination() == null) { return false; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java index c90ff191d9..750e5dd419 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java @@ -1645,6 +1645,21 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part path.get(i), RECOVERY_MINIMAP_REACH_EUCLIDEAN - 1, wp -> inInstance || isKnownWalkableOrUnloaded(wp)); } + // Don't let recovery park the player on a tile next to an aggressive NPC + // (e.g. an undead tree). The planner avoids those via avoidDangerousNpcs, + // but this runtime fallback would otherwise strand us in melee. Step the + // target back along the path to the nearest non-hazard tile. + PathfinderConfig dangerCfg = ShortestPathPlugin.pathfinderConfig; + if (dangerCfg != null && dangerCfg.isAvoidDangerousNpcs() && recoverTarget != null + && dangerCfg.isDangerousAdjacentTile(WorldPointUtil.packWorldPoint(recoverTarget))) { + int safeIdx = recoverIdx; + while (safeIdx > indexOfStartPoint + && dangerCfg.isDangerousAdjacentTile(WorldPointUtil.packWorldPoint(path.get(safeIdx)))) { + safeIdx--; + } + recoverIdx = safeIdx; + recoverTarget = path.get(safeIdx); + } boolean clicked = recoverTarget != null && !recoverTarget.equals(playerLoc) && Rs2Walker.walkMiniMap(recoverTarget); diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/dangerous_tiles.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/dangerous_tiles.tsv new file mode 100644 index 0000000000..2149505b46 --- /dev/null +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/dangerous_tiles.tsv @@ -0,0 +1,14 @@ +# Tile Display info +# Tiles occupied by aggressive NPCs that attack you while you path next to them. +# When the "Avoid dangerous NPCs" pathfinder option is on, a high step penalty is applied +# to every tile within 1 of these (the melee-aggro ring), so paths keep >=2 tiles away +# when a reasonable detour exists. It is a penalty, not a block: at a true chokepoint the +# path still goes through. Each entry is "x y plane". +3103 3347 0 Draynor Manor undead tree +3107 3342 0 Draynor Manor undead tree +3107 3344 0 Draynor Manor undead tree +3108 3346 0 Draynor Manor undead tree +3111 3339 0 Draynor Manor undead tree +3111 3348 0 Draynor Manor undead tree +3115 3344 0 Draynor Manor undead tree +3120 3344 0 Draynor Manor undead tree