From e3013b4a3352c313040e5e0ad17bbde4fca7c41b Mon Sep 17 00:00:00 2001 From: infuse21 Date: Fri, 14 Aug 2026 21:28:28 +0100 Subject: [PATCH 1/4] =?UTF-8?q?feat(shortestpath):=20the=20pathfinder=20en?= =?UTF-8?q?gine=20=E2=80=94=20sealed-target=20probe,=20live=20collision,?= =?UTF-8?q?=20honest=20costs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 3 of splitting #1838 into reviewable PRs (opens after part 1). The planning engine as it runs on the fork today: - Sealed-target reverse probe: a bounded reverse flood proves an unreachable destination SEALED in ~1ms and substitutes its walkable rim, replacing the 1.1M-node full-world floods an unreachable tile used to cost (measured 37 times in one evening); the search also remembers a REACHED rim substitute so the executor can retarget once. - Live collision: capture of the loaded scene's collision into a persisted per-region overlay (doors and runtime-cleared obstacles get full-footprint deferral to the static map — a closed door's corner diagonals must never persist as walls), with conflict telemetry, a live route validator, and versioned stores that self-discard when capture semantics change. - Transport planning: config-state cache keyed by inventory/varbit fingerprint (refresh dropped from ~500ms to single-digit ms on cache hits), TransportExecutionRegistry + planning-policy seam so the planner only admits transports the executor can actually perform, item requirement resolution (staff/tome/rune providers), and PathEdge/PathTerminationReason for honest terminations. - Session-scoped learned blocked edges replace the persisted store (blocked_edges.tsv is the durable authority). Seam notes for review: dev's ShortestPathPlugin gains ONE verbatim overload (override(String, PlannerSelectionMode)) and Rs2PathApi ONE facade method (invalidateTransportRefreshCache) — the full plugin and walker wiring arrive in part 4, so the live-collision refresh loop and planner-selection machinery are compiled and unit-tested here but not yet driven at runtime. Rs2Staff/Rs2Tome/Rs2LeaguesTransport travel as the engine's item/league providers. Full suite (:client:runUnitTests) green, including the route corpus against part 1's data. Co-Authored-By: Claude Fable 5 --- .../shortestpath/PlannerSelectionMode.java | 48 ++ .../shortestpath/PurchasableItemCatalog.java | 2 +- .../shortestpath/ShortestPathConfig.java | 13 + .../shortestpath/ShortestPathPlugin.java | 9 + .../microbot/shortestpath/Transport.java | 158 ++++- .../TransportExecutionRegistry.java | 341 +++++++++ .../TransportItemRequirement.java | 280 ++++++++ .../shortestpath/TransportItemResolver.java | 169 +++++ .../shortestpath/UPSTREAM_COMPARISON.md | 245 ++++++- .../WEBWALKER_IMPROVEMENT_PLAN.md | 10 +- .../shortestpath/pathfinder/CollisionMap.java | 42 +- .../pathfinder/LearnedBlockedEdges.java | 236 ------- .../shortestpath/pathfinder/Node.java | 23 +- .../shortestpath/pathfinder/PathEdge.java | 126 ++++ .../pathfinder/PathTerminationReason.java | 17 + .../shortestpath/pathfinder/Pathfinder.java | 668 ++++++++++++------ .../pathfinder/PathfinderConfig.java | 447 +++++++----- .../pathfinder/SealedVerdictMemo.java | 74 ++ .../pathfinder/TransportNode.java | 10 +- .../pathfinder/TransportPlanningPolicy.java | 24 + .../pathfinder/live/LiveCollisionCapture.java | 15 +- .../live/LiveCollisionConflicts.java | 74 ++ .../pathfinder/live/LiveRouteValidator.java | 19 + .../LeaguesTransportInjection.java | 20 +- .../leaguetransport/Rs2LeaguesTransport.java | 14 +- .../plugins/microbot/util/magic/Rs2Staff.java | 24 +- .../plugins/microbot/util/magic/Rs2Tome.java | 20 +- .../microbot/util/walker/Rs2PathApi.java | 19 + .../walker/Rs2TransportPlanningPolicy.java | 28 + .../shortestpath/agility_shortcuts.tsv | 32 +- .../plugins/microbot/shortestpath/canoes.tsv | 92 +-- .../microbot/shortestpath/quetzals.tsv | 2 +- .../shortestpath/teleportation_items.tsv | 92 +-- .../microbot/shortestpath/transports.tsv | 16 +- .../shortestpath/LiveCollisionTest.java | 8 + .../RouteClickTargetRegressionTest.java | 41 +- .../SealedTargetFastPathTest.java | 207 ++++++ .../shortestpath/ShortestPathCoreTest.java | 528 +++++++++++++- .../TransportExecutionRegistryTest.java | 196 +++++ .../TransportItemRequirementTest.java | 201 ++++++ .../TransportSkillRequirementDataTest.java | 143 ++++ .../shortestpath/WalkerRouteCorpusTest.java | 594 +++++++++++++++- .../LearnedBlockedEdgeSessionTest.java | 72 ++ .../LearnedBlockedEdgeStrikesTest.java | 109 --- .../pathfinder/LearnedBlockedEdgesTest.java | 138 ---- ...hfinderConfigTransportRefreshHashTest.java | 21 + .../PathfinderHomeTeleportTest.java | 39 + .../PathfinderItemRequirementTest.java | 87 +++ .../PathfinderPathMaterializationTest.java | 107 +++ .../PathfinderSpecialRequirementTest.java | 41 ++ .../PathfinderTerminationReasonTest.java | 166 +++++ .../SealedVerdictBudgetExhaustionTest.java | 155 ++++ .../pathfinder/SealedVerdictMemoTest.java | 83 +++ .../TransportPlanningPolicyTest.java | 63 ++ .../live/LiveCollisionConflictsTest.java | 43 ++ 55 files changed, 5369 insertions(+), 1082 deletions(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PlannerSelectionMode.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportExecutionRegistry.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemRequirement.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemResolver.java delete mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdges.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathEdge.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathTerminationReason.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/SealedVerdictMemo.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicy.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportPlanningPolicy.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/SealedTargetFastPathTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportExecutionRegistryTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemRequirementTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportSkillRequirementDataTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgeSessionTest.java delete mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgeStrikesTest.java delete mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgesTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderHomeTeleportTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderItemRequirementTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderPathMaterializationTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderSpecialRequirementTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderTerminationReasonTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/SealedVerdictBudgetExhaustionTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/SealedVerdictMemoTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicyTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PlannerSelectionMode.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PlannerSelectionMode.java new file mode 100644 index 00000000000..d089857657a --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PlannerSelectionMode.java @@ -0,0 +1,48 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +/** + * Explicit production planner rollout state. + * + *

A single mode prevents contradictory combinations such as selecting the upstream planner while + * comparison telemetry is disabled. The canary is deliberately limited to resolved F2P policy; members + * routes remain local until their own evidence gate is accepted.

+ */ +public enum PlannerSelectionMode +{ + /** Run only the local Microbot planner. */ + LOCAL, + /** Keep the local planner authoritative and compare the pinned upstream planner asynchronously. */ + SHADOW, + /** Select a semantically matching upstream result for F2P routes, with an automatic local fallback. */ + UPSTREAM_F2P_CANARY; + + public boolean comparisonEnabled() + { + return this != LOCAL; + } + + public boolean f2pCanaryEnabled() + { + return this == UPSTREAM_F2P_CANARY; + } + + public static PlannerSelectionMode fromConfigValue(Object value, PlannerSelectionMode defaultValue) + { + if (value instanceof PlannerSelectionMode) + { + return (PlannerSelectionMode) value; + } + if (value instanceof String) + { + try + { + return PlannerSelectionMode.valueOf(((String) value).trim().toUpperCase()); + } + catch (IllegalArgumentException ignored) + { + // Invalid test/plugin-message overrides fail closed to the persisted/default mode. + } + } + return defaultValue; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PurchasableItemCatalog.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PurchasableItemCatalog.java index d8130de3438..a6330e27483 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PurchasableItemCatalog.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/PurchasableItemCatalog.java @@ -22,7 +22,7 @@ * consulted here — transports.tsv carries a duplicate-row OR (item row + currency-twin row) so the * pathfinder already plans through the transport for either holding. * - *

Parsing is lenient like {@code LearnedBlockedEdges}: a malformed row is logged and skipped, + *

Parsing is lenient: a malformed row is logged and skipped, * never fatal. */ @Slf4j 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 f718e343095..d6612a06d4e 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 @@ -904,4 +904,17 @@ default boolean useLiveCollision() { default boolean resetLearnedCollision() { return false; } + + @ConfigItem( + keyName = "plannerSelectionMode", + name = "Planner rollout mode", + description = "Local is the production default. Shadow compares the pinned upstream planner. " + + "The F2P canary selects only semantically matching upstream routes and automatically " + + "falls back to local; members routes remain local.", + position = 3, + section = sectionDeveloper + ) + default PlannerSelectionMode plannerSelectionMode() { + return PlannerSelectionMode.LOCAL; + } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java index 073c4825dc0..c62e99ba581 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java @@ -969,6 +969,15 @@ private Color override(String configOverrideKey, Color defaultValue) { return defaultValue; } + public static PlannerSelectionMode override( + String configOverrideKey, PlannerSelectionMode defaultValue) { + if (!configOverride.isEmpty()) { + return PlannerSelectionMode.fromConfigValue( + configOverride.get(configOverrideKey), defaultValue); + } + return defaultValue; + } + public static int override(String configOverrideKey, int defaultValue) { if (!configOverride.isEmpty()) { Object value = configOverride.get(configOverrideKey); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/Transport.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/Transport.java index db3d2900553..8ffcc0c36ac 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/Transport.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/Transport.java @@ -16,6 +16,11 @@ */ @Slf4j public class Transport { + public static final int TOTAL_LEVEL_INDEX = Skill.values().length; + public static final int COMBAT_LEVEL_INDEX = TOTAL_LEVEL_INDEX + 1; + public static final int QUEST_POINTS_INDEX = COMBAT_LEVEL_INDEX + 1; + public static final int REQUIREMENT_LEVEL_COUNT = QUEST_POINTS_INDEX + 1; + //START microbot variables @Getter @Setter @@ -46,7 +51,7 @@ public class Transport { * The skill levels required to use this transport */ @Getter - private final int[] skillLevels = new int[Skill.values().length]; + private final int[] skillLevels = new int[REQUIREMENT_LEVEL_COUNT]; /** * The quests required to use this transport @@ -55,14 +60,19 @@ public class Transport { private Map quests = new HashMap<>(); /** - * The ids of items required to use this transport. - * If the player has **any** of the matching list of items, - * this transport is valid + * Compatibility view of the item IDs required to use this transport. New code should use + * {@link #getItemRequirements()} so AND groups and quantities are not discarded. */ @Getter - @Setter private Set> itemIdRequirements = new HashSet<>(); + /** + * Lossless item requirements. Entries are AND-ed; alternatives within an entry are OR-ed. + * {@link #itemIdRequirements} remains as the compatibility view used by older callers. + */ + @Getter + private List itemRequirements = new ArrayList<>(); + /** * The type of transport */ @@ -138,6 +148,8 @@ public Transport(Transport origin, Transport destination) { this.itemIdRequirements.addAll(origin.itemIdRequirements); this.itemIdRequirements.addAll(destination.itemIdRequirements); + this.itemRequirements.addAll(origin.itemRequirements); + this.itemRequirements.addAll(destination.itemRequirements); this.type = origin.type; @@ -186,7 +198,13 @@ public Transport(WorldPoint origin, WorldPoint destination, String displayInfo, * Object interaction Transport constructor */ public Transport(WorldPoint origin, WorldPoint destination, String displayInfo, TransportType transportType, boolean isMember, String action, String target, int objectId) { - this(origin, destination, displayInfo, transportType, isMember, 1); + this(origin, destination, displayInfo, transportType, isMember, action, target, objectId, 1); + } + + /** Object interaction transport with an explicit planner cost in ticks. */ + public Transport(WorldPoint origin, WorldPoint destination, String displayInfo, TransportType transportType, + boolean isMember, String action, String target, int objectId, int duration) { + this(origin, destination, displayInfo, transportType, isMember, duration); this.action = action; this.name = target; this.objectId = objectId; @@ -198,7 +216,7 @@ public Transport(WorldPoint origin, WorldPoint destination, String displayInfo, public Transport(WorldPoint destination, String displayInfo, TransportType transportType, boolean isMember, int maxWildernessLevel, Set> itemIdRequirements) { this(null, destination, displayInfo, transportType, isMember, 1); this.maxWildernessLevel = maxWildernessLevel; - this.itemIdRequirements = itemIdRequirements != null ? new HashSet<>(itemIdRequirements) : new HashSet<>(); + setItemIdRequirements(itemIdRequirements); } /** @@ -244,10 +262,17 @@ public Transport(WorldPoint destination, String displayInfo, TransportType trans if ((value = fieldMap.get("menuOption menuTarget objectID")) != null && !value.trim().isEmpty()) { value = value.trim(); // Remove leading/trailing spaces - // Regex pattern for semicolon-separated values - String regex = "^([^;]+);([^;]+);(\\d+)$"; - java.util.regex.Pattern pattern = java.util.regex.Pattern.compile(regex); - java.util.regex.Matcher matcher = pattern.matcher(value); + // Microbot historically used semicolons while upstream uses whitespace. In the + // whitespace form the option is one token, the object id is the final numeric token, + // and the target may contain spaces. + java.util.regex.Matcher matcher = java.util.regex.Pattern + .compile("^([^;]+);([^;]+);(\\d+)$") + .matcher(value); + if (!matcher.matches()) { + matcher = java.util.regex.Pattern + .compile("^(\\S+)\\s+(.+?)\\s+(\\d+)$") + .matcher(value); + } if (matcher.matches()) { // Extract matched groups @@ -276,35 +301,70 @@ public Transport(WorldPoint destination, String displayInfo, TransportType trans String[] skillRequirements = value.split(DELIM_MULTI); for (String requirement : skillRequirements) { - String[] levelAndSkill = requirement.split(DELIM); + String[] levelAndSkill = requirement.trim().split("\\s+", 2); if (levelAndSkill.length < 2) { continue; } int level = Integer.parseInt(levelAndSkill[0]); - String skillName = levelAndSkill[1]; + String skillName = levelAndSkill[1].trim(); + boolean resolved = false; Skill[] skills = Skill.values(); for (int i = 0; i < skills.length; i++) { if (skills[i].getName().equals(skillName)) { skillLevels[i] = level; + resolved = true; break; } } + String normalizedSkillName = skillName.toLowerCase(Locale.ROOT); + if (normalizedSkillName.startsWith("total")) { + skillLevels[TOTAL_LEVEL_INDEX] = level; + resolved = true; + } else if (normalizedSkillName.startsWith("combat")) { + skillLevels[COMBAT_LEVEL_INDEX] = level; + resolved = true; + } else if (normalizedSkillName.startsWith("quest")) { + skillLevels[QUEST_POINTS_INDEX] = level; + resolved = true; + } + // A requirement we cannot resolve used to vanish without a word, and an unset level is + // indistinguishable from "no requirement" — so the transport became usable by everyone. + // That is how "42 Agility7" (a Duration separated by spaces instead of a tab) + // turned the Draynor underwall tunnel into a free shortcut: the name read as + // "Agility 7", matched nothing, and the 42 was silently dropped. Worse than a + // no-op, because blocksWalkingEdgeWhenUnavailable would otherwise have routed AROUND + // an unusable shortcut; with the gate erased the planner actively prefers it. + if (!resolved) { + log.warn("Transport skill requirement '{}' does not name a known skill (raw field '{}') " + + "— the requirement is being DROPPED, which makes this transport usable " + + "by any account. Check for spaces where the TSV needs a tab.", + requirement.trim(), value.trim()); + } } } - if ((value = fieldMap.get("Item IDs")) != null && !value.trim().isEmpty()) { - String[] itemIdsList = value.split(DELIM_MULTI); - for (String listIds : itemIdsList) { - Set multiitemList = new HashSet<>(); - String[] itemIds = listIds.split(DELIM); - for (String item : itemIds) { - int itemId = Integer.parseInt(item); - multiitemList.add(itemId); + if ((value = fieldMap.get("Items")) != null && !value.trim().isEmpty()) { + setItemRequirements(TransportItemRequirement.parseRequirements(value)); + } else if ((value = fieldMap.get("Item IDs")) != null && !value.trim().isEmpty()) { + if (value.contains("=") || value.contains("&") || value.contains("|")) { + setItemRequirements(TransportItemRequirement.parseRequirements(value)); + } else { + Set> legacyGroups = new LinkedHashSet<>(); + for (String listIds : value.split(DELIM_MULTI)) { + Set group = new LinkedHashSet<>(); + for (String item : listIds.trim().split("\\s+")) { + if (!item.isEmpty()) { + group.add(Integer.parseInt(item)); + } + } + if (!group.isEmpty()) { + legacyGroups.add(group); + } } - itemIdRequirements.add(multiitemList); + setItemIdRequirements(legacyGroups); } } @@ -376,7 +436,11 @@ public Transport(WorldPoint destination, String displayInfo, TransportType trans } } - if ((value = fieldMap.get("Varplayers")) != null && !value.trim().isEmpty()) { + value = fieldMap.get("Varplayers"); + if ((value == null || value.trim().isEmpty())) { + value = fieldMap.get("VarPlayers"); + } + if (value != null && !value.trim().isEmpty()) { for (String varplayerCheck : value.split(DELIM_MULTI)) { if (varplayerCheck.isBlank()) { continue; @@ -428,6 +492,53 @@ private int getRequiredLevel(Skill skill) { return skillLevels[skill.ordinal()]; } + public int getRequiredTotalLevel() { + return skillLevels[TOTAL_LEVEL_INDEX]; + } + + public int getRequiredCombatLevel() { + return skillLevels[COMBAT_LEVEL_INDEX]; + } + + public int getRequiredQuestPoints() { + return skillLevels[QUEST_POINTS_INDEX]; + } + + /** + * Updates the legacy compatibility view. Historically every ID in this structure was treated as + * an alternative, regardless of its nested set, so preserve that behavior as one OR requirement. + */ + public void setItemIdRequirements(Set> requirements) { + Set> copied = new LinkedHashSet<>(); + Set alternatives = new LinkedHashSet<>(); + if (requirements != null) { + for (Set group : requirements) { + if (group == null || group.isEmpty()) { + continue; + } + Set copiedGroup = new LinkedHashSet<>(group); + copied.add(Collections.unmodifiableSet(copiedGroup)); + alternatives.addAll(copiedGroup); + } + } + this.itemIdRequirements = copied; + this.itemRequirements = alternatives.isEmpty() + ? new ArrayList<>() + : new ArrayList<>(Collections.singletonList( + TransportItemRequirement.legacyAlternatives(alternatives))); + } + + private void setItemRequirements(List requirements) { + this.itemRequirements = requirements == null + ? new ArrayList<>() + : new ArrayList<>(requirements); + Set> compatibility = new LinkedHashSet<>(); + for (TransportItemRequirement requirement : this.itemRequirements) { + compatibility.add(Collections.unmodifiableSet(new LinkedHashSet<>(requirement.getAllItemIds()))); + } + this.itemIdRequirements = compatibility; + } + /** * Whether the transport has one or more quest requirements */ @@ -639,6 +750,7 @@ public String toString() { ", skillLevels=" + Arrays.toString(skillLevels) + ", quests=" + quests + ", itemIdRequirements=" + itemIdRequirements + + ", itemRequirements=" + itemRequirements + ", type=" + type + ", duration=" + duration + ", displayInfo='" + displayInfo + '\'' + diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportExecutionRegistry.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportExecutionRegistry.java new file mode 100644 index 00000000000..5e0a2a8e834 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportExecutionRegistry.java @@ -0,0 +1,341 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.ItemID; +import net.runelite.client.plugins.microbot.util.poh.PohTransport; +import net.runelite.client.plugins.skillcalculator.skills.MagicAction; + +import java.util.Arrays; +import java.util.EnumSet; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Pure planner-side description of the Microbot executor capabilities. + * + *

A transport must not be offered to automated pathfinding unless the walker has a concrete + * execution branch for it. Keeping that decision here prevents catalog convergence from silently + * turning data coverage into routes that the runtime cannot complete.

+ */ +public final class TransportExecutionRegistry +{ + public enum Executor + { + BARROWS_DIG, + CANOE, + CHARTER_SHIP, + FAIRY_RING, + GNOME_GLIDER, + HOT_AIR_BALLOON, + ITEM_TELEPORT, + MAGIC_CARPET, + MAGIC_MUSHTREE, + MINIGAME_TELEPORT, + OBJECT, + POH, + QUETZAL, + SEASONAL, + SPELL_TELEPORT, + SPIRIT_TREE, + TERMINAL_TRAVEL, + WILDERNESS_OBELISK + } + + /** Planner-visible interaction sequence supported by the terminal-travel executor. */ + public enum TerminalTravelMode + { + DIRECT, + DIALOGUE_DESTINATION + } + + /** Exact destination labels presented by the unlocked balloon network map. */ + public enum BalloonDestination + { + CASTLE_WARS("Castle Wars"), + GRAND_TREE("Grand Tree"), + CRAFTING_GUILD("Crafting Guild"), + ENTRANA("Entrana"), + TAVERLEY("Taverley"), + VARROCK("Varrock"); + + private final String displayName; + + BalloonDestination(String displayName) + { + this.displayName = displayName; + } + + public String getDisplayName() + { + return displayName; + } + } + + /** Home teleports are zero-rune spellbook widgets rather than ordinary {@link MagicAction}s. */ + public enum HomeTeleport + { + LUMBRIDGE("Lumbridge Home Teleport"), + EDGEVILLE("Edgeville Home Teleport"), + LUNAR("Lunar Home Teleport"), + ARCEUUS("Arceuus Home Teleport"); + + private final String displayName; + + HomeTeleport(String displayName) + { + this.displayName = displayName; + } + + public String getDisplayName() + { + return displayName; + } + } + + private static final Set GENERIC_OBJECT_EXECUTORS = EnumSet.of( + TransportType.TRANSPORT, + TransportType.AGILITY_SHORTCUT, + TransportType.GRAPPLE_SHORTCUT, + TransportType.MINECART, + TransportType.TELEPORTATION_LEVER, + TransportType.TELEPORTATION_PORTAL, + TransportType.MAGIC_MUSHTREE); + + /** + * Barrows mound digs are inventory-item interactions, not scene-object interactions. Keep the + * six deterministic surface-to-crypt mappings exact so an arbitrary object-less transport row + * cannot acquire the spade executor by naming itself "Dig". + */ + private static final Map + BARROWS_DIG_DESTINATIONS = Map.of( + new WorldPoint(3564, 3291, 0), new WorldPoint(3559, 9703, 3), + new WorldPoint(3575, 3299, 0), new WorldPoint(3558, 9718, 3), + new WorldPoint(3578, 3281, 0), new WorldPoint(3534, 9706, 3), + new WorldPoint(3567, 3274, 0), new WorldPoint(3546, 9686, 3), + new WorldPoint(3553, 3281, 0), new WorldPoint(3566, 9683, 3), + new WorldPoint(3556, 3297, 0), new WorldPoint(3578, 9704, 3)); + + private TransportExecutionRegistry() + { + } + + public static boolean canExecute(Transport transport) + { + return executorFor(transport).isPresent(); + } + + /** Resolve the walker branch without reading live client state. */ + public static Optional executorFor(Transport transport) + { + if (transport == null || transport.getType() == null || transport.getDestination() == null) + { + return Optional.empty(); + } + + TransportType type = transport.getType(); + if (isBarrowsDig(transport)) + { + return Optional.of(Executor.BARROWS_DIG); + } + if (type == TransportType.TELEPORTATION_SPELL) + { + return hasRegisteredSpell(transport.getDisplayInfo()) + ? Optional.of(Executor.SPELL_TELEPORT) + : Optional.empty(); + } + if (type == TransportType.POH) + { + return transport instanceof PohTransport + ? Optional.of(Executor.POH) + : Optional.empty(); + } + if (type == TransportType.HOT_AIR_BALLOON) + { + return hasRegisteredBalloon(transport) + ? Optional.of(Executor.HOT_AIR_BALLOON) + : Optional.empty(); + } + if (isTerminalTravelType(type)) + { + return terminalTravelModeFor(transport).isPresent() + ? Optional.of(Executor.TERMINAL_TRAVEL) + : Optional.empty(); + } + if (GENERIC_OBJECT_EXECUTORS.contains(type)) + { + return hasObjectInteraction(transport) + ? Optional.of(type == TransportType.MAGIC_MUSHTREE + ? Executor.MAGIC_MUSHTREE + : Executor.OBJECT) + : Optional.empty(); + } + + return Optional.ofNullable(specializedExecutor(type)); + } + + private static boolean isBarrowsDig(Transport transport) + { + if (transport.getType() != TransportType.TRANSPORT + || transport.getObjectId() != 0 + || !"Dig".equalsIgnoreCase(transport.getAction()) + || !"Barrow".equalsIgnoreCase(transport.getName()) + || !transport.getDestination().equals(BARROWS_DIG_DESTINATIONS.get(transport.getOrigin())) + || transport.getItemRequirements().size() != 1) + { + return false; + } + TransportItemRequirement spade = transport.getItemRequirements().get(0); + return spade.getAllItemIds().equals(Set.of(ItemID.SPADE)) + && spade.getRequiredQuantity(ItemID.SPADE) == 1; + } + + private static Executor specializedExecutor(TransportType type) + { + switch (type) + { + case CANOE: + return Executor.CANOE; + case CHARTER_SHIP: + return Executor.CHARTER_SHIP; + case FAIRY_RING: + return Executor.FAIRY_RING; + case GNOME_GLIDER: + return Executor.GNOME_GLIDER; + case MAGIC_CARPET: + return Executor.MAGIC_CARPET; + case QUETZAL: + return Executor.QUETZAL; + case SPIRIT_TREE: + return Executor.SPIRIT_TREE; + case TELEPORTATION_ITEM: + return Executor.ITEM_TELEPORT; + case TELEPORTATION_MINIGAME: + return Executor.MINIGAME_TELEPORT; + case WILDERNESS_OBELISK: + return Executor.WILDERNESS_OBELISK; + case SEASONAL_TRANSPORT: + return Executor.SEASONAL; + default: + return null; + } + } + + /** + * Resolve the complete interaction flow, not merely the catalog family. + * + *

The SHIP/NPC/BOAT files describe journeys and contain both NPC and scene-object targets. + * Target kind is therefore resolved live. Interaction sequence is catalog policy, however, and must + * be known before planning. Unknown or currently unimplemented sequences fail closed here.

+ */ + public static Optional terminalTravelModeFor(Transport transport) + { + if (transport == null + || !isTerminalTravelType(transport.getType()) + || transport.getOrigin() == null + || transport.getDestination() == null + || transport.getObjectId() <= 0 + || isBlank(transport.getName()) + || isBlank(transport.getAction())) + { + return Optional.empty(); + } + + if (requiresUnsupportedTerminalDestinationSelection(transport)) + { + return Optional.empty(); + } + if ("Mountain Guide".equalsIgnoreCase(transport.getName())) + { + return isBlank(transport.getDisplayInfo()) + ? Optional.empty() + : Optional.of(TerminalTravelMode.DIALOGUE_DESTINATION); + } + return Optional.of(TerminalTravelMode.DIRECT); + } + + private static boolean isTerminalTravelType(TransportType type) + { + return type == TransportType.SHIP || type == TransportType.NPC || type == TransportType.BOAT; + } + + private static boolean requiresUnsupportedTerminalDestinationSelection(Transport transport) + { + String action = transport.getAction(); + String target = transport.getName(); + if (transport.getType() == TransportType.BOAT && !isBlank(transport.getDisplayInfo())) + { + return ("Board".equalsIgnoreCase(action) + && ("Boaty".equalsIgnoreCase(target) || "Boat".equalsIgnoreCase(target))) + || ("Travel".equalsIgnoreCase(action) && "Rowboat".equalsIgnoreCase(target)); + } + return "Talk-to".equalsIgnoreCase(action) + && ("Captain Shanks".equalsIgnoreCase(target) || "Pirate Pete".equalsIgnoreCase(target)); + } + + private static boolean hasObjectInteraction(Transport transport) + { + return transport.getOrigin() != null + && transport.getObjectId() > 0 + && !isBlank(transport.getAction()); + } + + private static boolean hasRegisteredSpell(String displayInfo) + { + if (isBlank(displayInfo)) + { + return false; + } + if (homeTeleportFor(displayInfo).isPresent()) + { + return true; + } + String spellName = displayInfo.contains(":") + ? displayInfo.substring(0, displayInfo.indexOf(':')).trim() + : displayInfo.trim(); + return Arrays.stream(MagicAction.values()) + .anyMatch(action -> action.getName().toLowerCase(Locale.ROOT) + .contains(spellName.toLowerCase(Locale.ROOT))); + } + + /** Resolve the exact home-teleport widget family shared by planning and execution. */ + public static Optional homeTeleportFor(String displayInfo) + { + if (isBlank(displayInfo)) + { + return Optional.empty(); + } + String normalized = displayInfo.trim().toLowerCase(Locale.ROOT); + return Arrays.stream(HomeTeleport.values()) + .filter(teleport -> teleport.getDisplayName().toLowerCase(Locale.ROOT).equals(normalized)) + .findFirst(); + } + + /** Resolve an exact balloon-map destination shared by capability filtering and runtime dispatch. */ + public static Optional balloonDestinationFor(String displayInfo) + { + if (isBlank(displayInfo)) + { + return Optional.empty(); + } + String normalized = displayInfo.trim().toLowerCase(Locale.ROOT); + return Arrays.stream(BalloonDestination.values()) + .filter(destination -> destination.getDisplayName().toLowerCase(Locale.ROOT).equals(normalized)) + .findFirst(); + } + + private static boolean hasRegisteredBalloon(Transport transport) + { + return hasObjectInteraction(transport) + && (transport.getObjectId() == 19128 || transport.getObjectId() == 19129) + && "Use".equalsIgnoreCase(transport.getAction()) + && "Basket".equalsIgnoreCase(transport.getName()) + && balloonDestinationFor(transport.getDisplayInfo()).isPresent(); + } + + private static boolean isBlank(String value) + { + return value == null || value.trim().isEmpty(); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemRequirement.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemRequirement.java new file mode 100644 index 00000000000..d25d88163a2 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemRequirement.java @@ -0,0 +1,280 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import lombok.Getter; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.IntPredicate; +import java.util.function.IntUnaryOperator; + +/** + * One item requirement for a transport. + * + *

The alternatives are OR-ed and retain their individual quantities. A transport may contain + * multiple instances of this class; those requirements are AND-ed. The upstream parser normalizes + * every alternative in an OR group to that group's maximum quantity; {@link #parseRequirements} + * deliberately applies the same rule.

+ */ +public final class TransportItemRequirement { + @Getter + private final Map alternatives; + @Getter + private final Set staffAlternatives; + @Getter + private final Set offhandAlternatives; + @Getter + private final boolean runeOnly; + + public TransportItemRequirement(Map alternatives) { + this(alternatives, Collections.emptySet(), Collections.emptySet(), false); + } + + public TransportItemRequirement(Map alternatives, + Set staffAlternatives, Set offhandAlternatives, boolean runeOnly) { + if (alternatives == null || alternatives.isEmpty()) { + throw new IllegalArgumentException("item requirement must contain an alternative"); + } + Map copy = new LinkedHashMap<>(); + for (Map.Entry entry : alternatives.entrySet()) { + Integer itemId = entry.getKey(); + Integer quantity = entry.getValue(); + if (itemId == null || itemId <= 0) { + throw new IllegalArgumentException("item id must be positive: " + itemId); + } + if (quantity == null || quantity < 0) { + throw new IllegalArgumentException("item quantity must be non-negative: " + quantity); + } + if (copy.put(itemId, quantity) != null) { + throw new IllegalArgumentException("duplicate item alternative: " + itemId); + } + } + this.alternatives = Collections.unmodifiableMap(copy); + this.staffAlternatives = immutablePositiveIds(staffAlternatives, "staff"); + this.offhandAlternatives = immutablePositiveIds(offhandAlternatives, "offhand"); + this.runeOnly = runeOnly; + } + + private static Set immutablePositiveIds(Set itemIds, String label) { + if (itemIds == null || itemIds.isEmpty()) { + return Collections.emptySet(); + } + LinkedHashSet copy = new LinkedHashSet<>(); + for (Integer itemId : itemIds) { + if (itemId == null || itemId <= 0) { + throw new IllegalArgumentException(label + " item id must be positive: " + itemId); + } + copy.add(itemId); + } + return Collections.unmodifiableSet(copy); + } + + public static TransportItemRequirement legacyAlternatives(Set itemIds) { + Map alternatives = new LinkedHashMap<>(); + for (Integer itemId : itemIds) { + alternatives.put(itemId, 1); + } + return new TransportItemRequirement(alternatives); + } + + /** + * Parses the numeric subset of the upstream item grammar. Symbolic item collections must be + * resolved by the pinned schema adapter before reaching Microbot resources. + */ + public static List parseNumericRequirements(String value) { + return parseRequirements(value, false); + } + + /** + * Parses numeric item ids plus explicitly supported symbolic collections from the pinned adapter. + * Unsupported collections fail closed rather than being omitted from the transport. + */ + public static List parseRequirements(String value) { + return parseRequirements(value, true); + } + + private static List parseRequirements(String value, boolean allowSymbols) { + if (value == null || value.trim().isEmpty()) { + return Collections.emptyList(); + } + String normalized = value.replace(" ", "") + .replace("&&", "&") + .replace("||", "|"); + List requirements = new ArrayList<>(); + for (String andPart : normalized.split("&", -1)) { + if (andPart.isEmpty()) { + throw new IllegalArgumentException("empty AND item requirement in: " + value); + } + Map parsedAlternatives = new LinkedHashMap<>(); + Set staffAlternatives = new LinkedHashSet<>(); + Set offhandAlternatives = new LinkedHashSet<>(); + boolean runeOnly = true; + int maximumQuantity = -1; + for (String orPart : andPart.split("\\|", -1)) { + String[] itemAndQuantity = orPart.split("=", -1); + if (itemAndQuantity.length != 2) { + throw new IllegalArgumentException("invalid item requirement: " + orPart); + } + final int quantity; + try { + quantity = Integer.parseInt(itemAndQuantity[1]); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "unresolved symbolic or invalid item requirement: " + orPart, e); + } + Set itemIds; + try { + itemIds = Collections.singleton(Integer.parseInt(itemAndQuantity[0])); + runeOnly = false; + } catch (NumberFormatException e) { + TransportItemResolver.Resolution resolution = allowSymbols + ? TransportItemResolver.resolve(itemAndQuantity[0]) : null; + if (resolution == null) { + throw new IllegalArgumentException( + "unresolved symbolic or invalid item requirement: " + orPart, e); + } + itemIds = resolution.getItemIds(); + staffAlternatives.addAll(resolution.getStaffIds()); + offhandAlternatives.addAll(resolution.getOffhandIds()); + runeOnly &= resolution.isRune(); + } + for (Integer itemId : itemIds) { + parsedAlternatives.merge(itemId, quantity, Math::max); + } + maximumQuantity = Math.max(maximumQuantity, quantity); + } + Map alternatives = new LinkedHashMap<>(); + for (Integer itemId : parsedAlternatives.keySet()) { + alternatives.put(itemId, maximumQuantity); + } + requirements.add(new TransportItemRequirement( + alternatives, staffAlternatives, offhandAlternatives, runeOnly)); + } + return Collections.unmodifiableList(requirements); + } + + public Set getItemIds() { + return Collections.unmodifiableSet(new LinkedHashSet<>(alternatives.keySet())); + } + + public int getRequiredQuantity(int itemId) { + return alternatives.getOrDefault(itemId, -1); + } + + public boolean isSatisfiedBy(IntUnaryOperator availableQuantity) { + for (Map.Entry alternative : alternatives.entrySet()) { + int required = alternative.getValue(); + int available = Math.max(0, availableQuantity.applyAsInt(alternative.getKey())); + if ((required == 0 && available == 0) || (required > 0 && available >= required)) { + return true; + } + } + return false; + } + + boolean isSatisfiedBy(IntUnaryOperator availableQuantity, int staffItemId, int offhandItemId) { + return isSatisfiedBy(availableQuantity) + || staffAlternatives.contains(staffItemId) + || offhandAlternatives.contains(offhandItemId); + } + + /** + * Select at most one future weapon and one future offhand that make every AND-clause true. + * The same combination staff may satisfy multiple elemental rune clauses, matching the game. + */ + public static Optional selectProviders( + List requirements, + IntUnaryOperator availableQuantity, + IntPredicate staffAvailable, + IntPredicate offhandAvailable) { + if (requirements == null || requirements.isEmpty()) { + return Optional.of(ProviderSelection.NONE); + } + TreeSet staffs = new TreeSet<>(); + TreeSet offhands = new TreeSet<>(); + for (TransportItemRequirement requirement : requirements) { + requirement.staffAlternatives.stream().filter(staffAvailable::test).forEach(staffs::add); + requirement.offhandAlternatives.stream().filter(offhandAvailable::test).forEach(offhands::add); + } + List staffCandidates = new ArrayList<>(); + staffCandidates.add(ProviderSelection.NO_ITEM); + staffCandidates.addAll(staffs); + List offhandCandidates = new ArrayList<>(); + offhandCandidates.add(ProviderSelection.NO_ITEM); + offhandCandidates.addAll(offhands); + for (Integer staff : staffCandidates) { + for (Integer offhand : offhandCandidates) { + boolean satisfied = true; + for (TransportItemRequirement requirement : requirements) { + if (!requirement.isSatisfiedBy(availableQuantity, staff, offhand)) { + satisfied = false; + break; + } + } + if (satisfied) { + return Optional.of(new ProviderSelection(staff, offhand)); + } + } + } + return Optional.empty(); + } + + public Set getAllItemIds() { + LinkedHashSet itemIds = new LinkedHashSet<>(alternatives.keySet()); + itemIds.addAll(staffAlternatives); + itemIds.addAll(offhandAlternatives); + return Collections.unmodifiableSet(itemIds); + } + + public static final class ProviderSelection { + static final int NO_ITEM = -1; + static final ProviderSelection NONE = new ProviderSelection(NO_ITEM, NO_ITEM); + + private final int staffItemId; + private final int offhandItemId; + + private ProviderSelection(int staffItemId, int offhandItemId) { + this.staffItemId = staffItemId; + this.offhandItemId = offhandItemId; + } + + public int getStaffItemId() { return staffItemId; } + public int getOffhandItemId() { return offhandItemId; } + public boolean hasStaff() { return staffItemId > 0; } + public boolean hasOffhand() { return offhandItemId > 0; } + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof TransportItemRequirement)) { + return false; + } + TransportItemRequirement that = (TransportItemRequirement) other; + return alternatives.equals(that.alternatives) + && staffAlternatives.equals(that.staffAlternatives) + && offhandAlternatives.equals(that.offhandAlternatives) + && runeOnly == that.runeOnly; + } + + @Override + public int hashCode() { + int result = alternatives.hashCode(); + result = 31 * result + staffAlternatives.hashCode(); + result = 31 * result + offhandAlternatives.hashCode(); + return 31 * result + Boolean.hashCode(runeOnly); + } + + @Override + public String toString() { + return alternatives + " staff=" + staffAlternatives + " offhand=" + offhandAlternatives; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemResolver.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemResolver.java new file mode 100644 index 00000000000..25f07bc04b6 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemResolver.java @@ -0,0 +1,169 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import net.runelite.api.gameval.ItemID; +import net.runelite.client.plugins.microbot.util.magic.Rs2Staff; +import net.runelite.client.plugins.microbot.util.magic.Rs2Tome; +import net.runelite.client.plugins.microbot.util.magic.Runes; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Pinned adapter for symbolic item collections used by Shortest Path transport resources. + * + *

Only collections whose semantics can be represented by {@link TransportItemRequirement} belong + * here. Rune symbols delegate to Microbot's canonical rune, staff and tome catalogs so pathfinding and + * actual casting cannot acquire separate provider lists. Unknown or unsupported symbols fail closed.

+ */ +final class TransportItemResolver { + private static final Map SYMBOLS = buildSymbols(); + + private TransportItemResolver() { + } + + static Resolution resolve(String symbol) { + if (symbol == null) { + return null; + } + return SYMBOLS.get(symbol.trim().toUpperCase(Locale.ROOT)); + } + + private static Map buildSymbols() { + Map symbols = new LinkedHashMap<>(); + addRune(symbols, "AIR_RUNE", Runes.AIR); + addRune(symbols, "ASTRAL_RUNE", Runes.ASTRAL); + add(symbols, "AXE", + ItemID.BRONZE_AXE, ItemID.IRON_AXE, ItemID.STEEL_AXE, ItemID.BLACK_AXE, + ItemID.MITHRIL_AXE, ItemID.ADAMANT_AXE, ItemID.RUNE_AXE, ItemID.DRAGON_AXE, + ItemID.CRYSTAL_AXE, ItemID.TRAIL_GILDED_AXE, ItemID.INFERNAL_AXE, ItemID._3A_AXE); + add(symbols, "BANANA", ItemID.BANANA); + addRune(symbols, "BLOOD_RUNE", Runes.BLOOD); + add(symbols, "BROWN_APRON", + ItemID.BROWN_APRON, ItemID.GOLDEN_APRON, ItemID.SKILLCAPE_CRAFTING, + ItemID.SKILLCAPE_CRAFTING_TRIMMED, ItemID.SKILLCAPE_CRAFTING_HOOD); + add(symbols, "CLIMBING_BOOTS", ItemID.DEATH_CLIMBINGBOOTS, ItemID.CLIMBING_BOOTS_G); + add(symbols, "COINS", ItemID.COINS); + add(symbols, "CROSSBOW", + ItemID.CROSSBOW, ItemID.PHOENIX_CROSSBOW, ItemID.DTTD_BONE_CROSSBOW, + ItemID.HUNTING_CROSSBOW, ItemID.XBOWS_CROSSBOW_BRONZE, ItemID.XBOWS_CROSSBOW_IRON, + ItemID.XBOWS_CROSSBOW_STEEL, ItemID.XBOWS_CROSSBOW_MITHRIL, + ItemID.XBOWS_CROSSBOW_ADAMANTITE, ItemID.XBOWS_CROSSBOW_RUNITE, + ItemID.XBOWS_CROSSBOW_DRAGON, ItemID.DRAGONHUNTER_XBOW, + ItemID.BARROWS_KARIL_WEAPON, ItemID.BARROWS_KARIL_WEAPON_BROKEN, + ItemID.BARROWS_KARIL_WEAPON_25, ItemID.BARROWS_KARIL_WEAPON_50, + ItemID.BARROWS_KARIL_WEAPON_75, ItemID.BARROWS_KARIL_WEAPON_100, + ItemID.ACB, ItemID.ZARYTE_XBOW); + add(symbols, "DUSTY_KEY", ItemID.DUSTY_KEY); + addRune(symbols, "DUST_RUNE", Runes.DUST); + addRune(symbols, "EARTH_RUNE", Runes.EARTH); + add(symbols, "ECTO_TOKEN", ItemID.ECTOTOKEN); + add(symbols, "GLOWING_FUNGUS", ItemID.GLOWING_FUNGUS); + addRune(symbols, "FIRE_RUNE", Runes.FIRE); + addRune(symbols, "LAVA_RUNE", Runes.LAVA); + addRune(symbols, "LAW_RUNE", Runes.LAW); + add(symbols, "MACHETE", + ItemID.MACHETTE, ItemID.MACHETTE_OPAL, ItemID.MACHETTE_JADE, ItemID.MACHETTE_REDTOPAZ); + add(symbols, "MAX_CAPE", + ItemID.SKILLCAPE_MAX, ItemID.SKILLCAPE_MAX_WORN, ItemID.SKILLCAPE_MAX_FIRECAPE, + ItemID.SKILLCAPE_MAX_FIRECAPE_DUMMY, ItemID.SKILLCAPE_MAX_FIRECAPE_TROUVER, + ItemID.SKILLCAPE_MAX_SARADOMIN, ItemID.SKILLCAPE_MAX_ZAMORAK, + ItemID.SKILLCAPE_MAX_GUTHIX, ItemID.SKILLCAPE_MAX_ANMA, ItemID.SKILLCAPE_MAX_ARDY, + ItemID.SKILLCAPE_MAX_INFERNALCAPE, ItemID.SKILLCAPE_MAX_INFERNALCAPE_DUMMY, + ItemID.SKILLCAPE_MAX_INFERNALCAPE_TROUVER, ItemID.SKILLCAPE_MAX_SARADOMIN2, + ItemID.SKILLCAPE_MAX_SARADOMIN2_TROUVER, ItemID.SKILLCAPE_MAX_ZAMORAK2, + ItemID.SKILLCAPE_MAX_ZAMORAK2_TROUVER, ItemID.SKILLCAPE_MAX_GUTHIX2, + ItemID.SKILLCAPE_MAX_GUTHIX2_TROUVER, ItemID.SKILLCAPE_MAX_ASSEMBLER, + ItemID.SKILLCAPE_MAX_ASSEMBLER_TROUVER, ItemID.SKILLCAPE_MAX_MYTHICAL, + ItemID.SKILLCAPE_MAX_ASSEMBLER_MASORI, ItemID.SKILLCAPE_MAX_ASSEMBLER_MASORI_TROUVER, + ItemID.SKILLCAPE_MAX_DIZANAS, ItemID.SKILLCAPE_MAX_DIZANAS_TROUVER); + add(symbols, "MAX_HOOD", + ItemID.SKILLCAPE_MAX_HOOD, ItemID.SKILLCAPE_MAX_HOOD_FIRECAPE, + ItemID.SKILLCAPE_MAX_HOOD_SARADOMIN, ItemID.SKILLCAPE_MAX_HOOD_ZAMORAK, + ItemID.SKILLCAPE_MAX_HOOD_GUTHIX, ItemID.SKILLCAPE_MAX_HOOD_ANMA, + ItemID.SKILLCAPE_MAX_HOOD_ARDY, ItemID.SKILLCAPE_MAX_HOOD_INFERNALCAPE, + ItemID.SKILLCAPE_MAX_HOOD_SARADOMIN2, ItemID.SKILLCAPE_MAX_HOOD_ZAMORAK2, + ItemID.SKILLCAPE_MAX_HOOD_GUTHIX2, ItemID.SKILLCAPE_MAX_HOOD_ASSEMBLER, + ItemID.SKILLCAPE_MAX_HOOD_MYTHICAL, ItemID.SKILLCAPE_MAX_HOOD_ASSEMBLER_MASORI, + ItemID.SKILLCAPE_MAX_HOOD_DIZANAS); + add(symbols, "MAZE_KEY", ItemID.MELZARKEY); + addRune(symbols, "MIND_RUNE", Runes.MIND); + addRune(symbols, "MIST_RUNE", Runes.MIST); + add(symbols, "MITH_GRAPPLE", ItemID.XBOWS_GRAPPLE_TIP_BOLT_MITHRIL_ROPE); + addRune(symbols, "MUD_RUNE", Runes.MUD); + addRune(symbols, "NATURE_RUNE", Runes.NATURE); + add(symbols, "PICKAXE", + ItemID.BRONZE_PICKAXE, ItemID.IRON_PICKAXE, ItemID.STEEL_PICKAXE, + ItemID.BLACK_PICKAXE, ItemID.MITHRIL_PICKAXE, ItemID.ADAMANT_PICKAXE, + ItemID.RUNE_PICKAXE, ItemID.DRAGON_PICKAXE, ItemID.CRYSTAL_PICKAXE, + ItemID.TRAIL_GILDED_PICKAXE, ItemID._3A_PICKAXE, ItemID.DRAGON_PICKAXE_PRETTY, + ItemID.ZALCANO_PICKAXE, ItemID.TRAILBLAZER_PICKAXE_NO_INFERNAL, + ItemID.TRAILBLAZER_RELOADED_PICKAXE_NO_INFERNAL, ItemID.INFERNAL_PICKAXE); + add(symbols, "ROPE", ItemID.ROPE); + add(symbols, "SHANTAY_PASS", ItemID.SHANTAY_PASS); + add(symbols, "SKAVID_MAP", ItemID.SKAVIDMAP); + addRune(symbols, "SMOKE_RUNE", Runes.SMOKE); + addRune(symbols, "SOUL_RUNE", Runes.SOUL); + addRune(symbols, "STEAM_RUNE", Runes.STEAM); + addRune(symbols, "WATER_RUNE", Runes.WATER); + return Collections.unmodifiableMap(symbols); + } + + private static void add(Map symbols, String name, Integer... itemIds) { + put(symbols, name, new Resolution(ids(itemIds), Collections.emptySet(), + Collections.emptySet(), false)); + } + + private static void addRune(Map symbols, String name, Runes rune) { + LinkedHashSet itemIds = new LinkedHashSet<>(); + itemIds.add(rune.getItemId()); + Arrays.stream(Runes.getComboRunes(rune)) + .map(Runes::getItemId) + .forEach(itemIds::add); + put(symbols, name, new Resolution( + itemIds, + Rs2Staff.itemIdsProviding(rune), + Rs2Tome.itemIdsProviding(rune), + true)); + } + + private static Set ids(Integer... itemIds) { + return Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList(itemIds))); + } + + private static void put(Map symbols, String name, Resolution resolution) { + if (resolution.itemIds.isEmpty() + || resolution.itemIds.stream().anyMatch(id -> id == null || id <= 0) + || resolution.staffIds.stream().anyMatch(id -> id == null || id <= 0) + || resolution.offhandIds.stream().anyMatch(id -> id == null || id <= 0)) { + throw new IllegalArgumentException("invalid item collection: " + name); + } + if (symbols.put(name, resolution) != null) { + throw new IllegalArgumentException("duplicate item collection: " + name); + } + } + + static final class Resolution { + private final Set itemIds; + private final Set staffIds; + private final Set offhandIds; + private final boolean rune; + + private Resolution(Set itemIds, Set staffIds, + Set offhandIds, boolean rune) { + this.itemIds = Set.copyOf(itemIds); + this.staffIds = Set.copyOf(staffIds); + this.offhandIds = Set.copyOf(offhandIds); + this.rune = rune; + } + + Set getItemIds() { return itemIds; } + Set getStaffIds() { return staffIds; } + Set getOffhandIds() { return offhandIds; } + boolean isRune() { return rune; } + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/UPSTREAM_COMPARISON.md b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/UPSTREAM_COMPARISON.md index 6201c47efb1..14f14c95568 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/UPSTREAM_COMPARISON.md +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/UPSTREAM_COMPARISON.md @@ -3,6 +3,242 @@ Comparison of [Skretzo/shortest-path](https://github.com/Skretzo/shortest-path) (upstream) against the Microbot fork. Original baseline: `07fca57` ("Data fixes and minor cleanups (#400)"). **Everything below the "Re-baseline" section refers to that old baseline and is partly superseded — read the re-baseline first.** +The maintained plan and ownership boundary are in `docs/walker-roadmap.md`. The machine-readable +baseline is `scripts/shortest-path-upstream-baseline.json` and can be checked with +`scripts/check-shortest-path-upstream.py`. + +--- + +## Re-baseline 2026-08-05 → upstream `ff8e961b32` + +Upstream moved by two collision-map commits since the 2026-07-20 review. The exact reviewed commit and +resource blobs are now recorded outside this narrative document so drift is machine-detectable. + +- Imported upstream `collision-map.zip` (`sha256:3a99d42fec10e12dbda96bbaae45b354d8e2270c4c1a453d033e95b7da2670d2`). + The map grew from 2,724 to 2,726 regions. Before import, the candidate passed `ShortestPathCoreTest`, + `WalkerRouteCorpusTest` and `PathfinderBenchmarkTest` in an isolated worktree. +- Closed the home-teleport coverage gap with Edgeville, Lunar and Arceuus destinations. Microbot uses one + semantic row per spellbook and intentionally rejects upstream's animation-duration variants because a + display/animation setting must not gate planner availability. +- Added `scripts/compare-shortest-path-transports.py` plus an exact reviewed semantic-debt baseline. The + comparator matches named network endpoints when boarding/landing coordinates differ, compares fares and + requirement dimensions, and makes identity swaps visible through content digests. It is enforced for + affected pull requests and by the weekly upstream workflow. +- Brought `minecarts.tsv` to exact compared parity. The old local rows charged 20 coins after The Forsaken + Tower; paid variants now require `7796<11` and free variants require `7796=11`. +- Closed all minigame-teleport identity gaps (Guardians of the Rift and the Varrock/Keldagrim Rat Pits + landings) and added Total-level, Combat-level and Quest-points gating to the local transport model. The + Pest Control minigame teleport now actually enforces its upstream 40 Combat requirement. +- Added a structured item-requirement compatibility layer. Numeric upstream expressions retain AND, + OR and quantity semantics, including upstream's maximum quantity within an OR group; legacy + `Item IDs` rows remain one OR group. Pathfinder, + transport-refresh caching, bank planning and Slayer transport preparation now consume that model. +- Imported the direct Max-cape and Quest-point-cape family from the pinned teleport-item artifact: + 16 route identities converged, four previously missing Max-cape destinations were restored, and the + duplicate Black chinchompa row was removed. Multi-level labels now resolve their leaf item sub-action; + POH-home variants remain programmatic Microbot behavior. +- Added a pinned symbolic collection adapter for walking tools, grapple gear, keys, passes, currencies + and cape/apron families. Unknown symbols fail closed. Rune symbols now delegate to Microbot's canonical + rune, staff and tome catalogs, preserve equipment-provider semantics through the immutable route edge, + and produce one atomic bank withdrawal/equipment loadout. All 43 shipped non-home spell rows carry the + reviewed upstream item requirements while retaining the existing Microbot landing coordinates; disputed + coordinate changes remain deferred for route/live evidence. All 45 River Lum and River Dougne canoe + routes now have exact compared field parity; the executor chooses the chain-specific map interface and + route-corpus coverage pins the new western network. Live River Dougne execution remains pending. +- Brought all 28 Quetzal network identities to exact compared parity and added a dedicated semantic + mapping from upstream `quetzal_whistle.tsv` to Microbot's inline teleport-item rows. All 14 whistle + destinations retain the current Quetzacalli Gorge landing, Cam Torum map label, canonical unlock + bitmasks and Twilight's Promise gate. Microbot intentionally records 14 item/consumability differences: + charged whistles remain consumable while perfected-infinite item `33120` remains available to the + Inventory (perm) policy, rather than inheriting upstream's family-level `Consumable=T` flag. +- Corrected the executor's wilderness boundary check to the same inclusive maximum used by the planner. + The old executor-only `+1` admitted a teleport one Wilderness level beyond the planned limit. +- The comparator now ignores a comparable field only when its column is absent from one entire schema. + This reduced false field drift while preserving exact identity/content digests for real changes. +- Closed the lossless agility-shortcut requirement slice: all twelve reviewed grapple edges now require + both a crossbow family and a mith grapple, and the Trollheim rope edge carries its rope plus unlock + varbit in executable fields. Corrected current landings and durations for the Lumbridge-farm fence and + northern Varlamore rocks; real pathfinder corpus cases select both edges. The comparator now uses + RuneLite's explicit course-obstacle catalog to classify 114 known course identities without removing + them from total debt. Three Trollheim climbing-rock ascents moved from generic transports to exact + boots-gated agility edges while their descents remain generic; a pathfinder case proves the ascent. + The complete 88-edge Isafdar forest family now matches upstream landings, levels and durations: 66 + unconditional generic rows became Agility shortcuts, 22 missing edges were added and four stale local + landing variants were removed. The route corpus proves the three-edge dense-forest chain. The other 28 + exact cross-file identities that were still unrestricted generic transports now retain upstream's + Agility requirements across Brimhaven Dungeon (6), the Lumbridge cellar (2), Karamja rocks (6), Slayer + Tower (8) and Darkmeyer (6). Catalog tests pin their levels, durations and unlock varbits, and one real + pathfinder route per family proves graph selection. The semantic comparator now exposes any upstream + agility identity represented only as a local generic transport and pins that bypass class at zero. The + two diagonal Darkmeyer approaches that upstream intentionally models both ways remain exactly once as + generic transports alongside their gated shortcut variants. The current semantic inventory is 6,601 + shared, 1,016 upstream-only and 952 Microbot-only route identities, with 1,379 comparable field drifts; + agility debt is 231 identities, split into 114 course obstacles and 117 ordinary-world or unresolved + routes. Live interaction evidence for this slice remains pending. +- Added route-corpus coverage for Draynor's east sewer transition and for planner selection of `SHIP`, + `NPC` and `BOAT` travel families while retaining Microbot's explicit ship-deck/gangplank model. +- Added twelve intentional Microbot-only Barrows edges absent from the reviewed upstream artifact: six + exact spade-gated mound digs into the individual crypts and six object-backed crypt exits to + representative anchors on their matching surface mounds. A dedicated registry capability prevents + arbitrary object-less `Dig` rows from becoming executable. Static route coverage pins all six pairs and + rejects every sarcophagus object as a deterministic tunnel edge; the empty crypt is randomized and must + be observed by a future state-aware executor. Live mound round-trip evidence remains pending. +- Completed the static Laguna Aurorae spirit-tree perimeter from the pinned artifact. Nine object-`26262` + origins now feed the existing `Travel` executor and the already-present Pandemonium-gated destination; + route coverage proves the north-west origin selects the network. Spirit-tree shared identities rise from + 145 to 154 and upstream-only debt falls from 11 to the two POH directions already owned by Microbot's + programmatic POH integration. Tests reject adding those POH routes to the static TSV. A live Laguna + round trip remains required. +- Closed the executable part of ordinary `transports.tsv` debt. The two Elemental Workshop wall directions + now use current object `26115`, require the concrete battered key and sit behind a curated collision-edge + override so the planner cannot walk through the closed wall. The upstream steel-key-ring alternative is + intentionally stricter locally: ring possession does not prove that the battered key is stored. The + remaining 15 upstream-only identities are fully classified and digest-pinned (four superseded Piscatoris + anchors, eight id-less Marim stairs, one interaction-less Daero jump and two unsafe Varrock trellis rows), + leaving zero unexplained ordinary route identities. A route regression proves battered-key selection and + key-ring-only rejection; live wall execution remains pending. +- Imported the four genuinely missing Pandemonium ship directions between Port Sarim, Musa Point and the + island using the reviewed Captain Tobias, Customs officer and Seaman Morris ids/actions. All four retain + the quest gate and 30-coin fare, resolve to direct terminal travel, and have real-pathfinder selection plus + fail-closed prerequisite coverage. The remaining six upstream-only ship identities are exact-classified + representations of Microbot's current Corsair Cove, Ardougne and Void Outpost deck/landing coordinates; + a live Pandemonium round trip remains pending. +- Added a pinned dual-engine evaluation harness. A declared adapter patch retains upstream's exact selected + transport object through `NodeGraph`/`PathStep`, avoiding the ambiguous endpoint rematch documented by + upstream itself. Static, real White Wolf surface-tunnel-surface, same-edge network alternatives and + bank-disabled/start-at-bank policies agree on reachability, termination, exact selected corpus IDs and + route cost. A separate-bank detour and four source-aware spell slices (carried/banked raw runes, + separate staff/tome, and a missing ordinary item) also agree. The corpus explicitly pins one reviewed, game-semantic + divergence: upstream rejects one Twinflame staff as the provider for both fire and water clauses because + its requirement evaluator consumes the staff substitution after the first clause; Microbot reuses the + same selected combination staff across every compatible clause. The 16-case gate requires 15 exact + parity results plus this one documented expected divergence, and fails if either an unexpected difference + appears or the reviewed difference disappears. The local adapter performs several workflow searches while + upstream carries bank state through one graph, so node/time metrics for that case are diagnostic rather + than core-performance parity. The gate runs the local core, the production-packaged pinned upstream adapter + and an independently compiled temporary checkout. It requires the two upstream executions to agree on all + semantic result fields unless the corpus already declares an input-policy divergence. +- Converted the local side of that harness and synchronous production planning to one `Rs2RoutePlanner` + boundary. `Rs2PathApi` now resolves an immutable policy snapshot before engine dispatch, including bank, + Wilderness, dangerous-NPC, teleport, membership, live-collision, cutoff, enabled-family and restriction + state; unresolved requests fail instead of consulting mutable globals. Exact local transport identity is + retained only as an opaque package-private payload on the immutable edge. Microbot executor admission and + zero-rune home-teleport capability are injected at plugin composition through `TransportPlanningPolicy`, + and the pathfinder core is CI-guarded against importing the executor registry. ADR 0006 established the + production-capable upstream shadow adapter as the next milestone before further broad family imports. +- Packaged the reviewed non-UI upstream core in an isolated source set and added a default-off production + adapter. Both engines consume the same resolved request and immutable planning snapshot; upstream maps a + selected transport back to the exact already-admitted Microbot edge by object identity. Shadow execution is + bounded to one worker and one queued request, never publishes an execution route, rejects stale active-route + generations and covers synchronous queries, ordinary active walker routes and cave-route selection. The + facade exposes the latest structured comparison plus aggregate match, divergence, failure, stale, discard + and exact-route-shape-difference counters. Completed outcomes distinguish ordinary replans from recovery, + classify explicit bank-workflow legs, retain selected transport executor/type families and count live + collision only when the overlay answers a search edge. The schema-versioned Agent Server endpoint exposes + this coordinate-free evidence through `microbot-cli walker shadow`, and + `evaluate-walker-shadow-evidence.py` enforces recovery, bank-workflow, collision and transport-family + diversity plus terminal blocking-walk/recovered-arrival outcomes rather than treating enabled settings or + a matching replan as behavioral evidence. + Twelve accepted live-shadow sessions now provide 141/141 semantic matches and 71/71 walker arrivals. The + aggregate closes every F2P live minimum, including 75 active routes, 15 active replans, 11 recovery replans, + 39 underground comparisons, 18 walking-only cave selections and ten explicit item-gated bank-to-target + comparisons. There is no semantic divergence, planner failure, pending/discarded work, unreachable result or + exit. Seventy-six exact-shape differences are equal-cost alternatives with matching selected transports; + retained diagnostics classify them across transport-free replan/recovery, bank/canoe and mixed surface + slices, with none in the underground comparisons and none associated with a non-arrival. Five clean samples + from the exact evaluated revision pass the timing gate at a `0.407` upstream/local comparable-suite median + ratio. Sanitized source snapshots and accepted reports are tracked under + `docs/evidence/walker/2026-08-05/`. The explicit F2P selector and rollback test are also complete; + members-policy selection requires separate representative members-world evidence. + `check-shortest-path-vendored-core.py` pins all source/metadata digests and can prove every undeclared file + byte-identical to the reviewed checkout. +- Added an explicit `LOCAL`, `SHADOW` and `UPSTREAM_F2P_CANARY` selection state. `LOCAL` remains the default; + `SHADOW` cannot select a route; and the canary is eligible only for resolved non-members policy. The canary + keeps active publication in the calculating phase until both candidates finish, selects upstream only for + a semantic match, and otherwise retains local with separate divergence/failure fallback counters. This is + conservative containment rather than treating local as a correctness oracle. Exact upstream selections + are temporarily materialized into the legacy completed-pathfinder view for existing runtime consumers. + Remove that shell with the local planner after the two-release/1,000-comparison fallback sunset. A live + F2P-17 underground run made ten upstream selections and ten arrivals without divergence or failure. A + separate test-only forced-failure run made zero upstream selections, ten local failure fallbacks and ten + arrivals. This validates the opt-in selector; the default remains local until an F2P release is explicitly + approved. +- Migrated active destination bank-item discovery to exact immutable `Rs2TransportEdge` values. Fare, + rune, fairy-ring, purchasable-item and structured AND/OR requirement selection no longer require a + concrete selected `Transport`; the transitional `LegacyRoutePlan` handoff is removed and CI prevents it + returning. The deprecated concrete helper remains only as a Hub compatibility API, outside the active + banking and executor contracts. +- Bound active runtime transport discovery to the exact selected route edge. Completed pathfinders publish + an immutable, source-identity-checked route snapshot; raw-segment dispatch, ranged classification and + nearby current-tile recovery no longer rescan all catalog rows at an origin. Immutable edges carry an + explicit Microbot executor capability, while the exact local concrete object is retained only as an opaque + package-private payload for behavior-bearing handlers such as POH. Catalog rows without a registered + executor fail closed before planning. All four home teleports use one exact-name, zero-rune widget + executor shared by planner capability and runtime dispatch. The 225 directed hot-air-balloon edges now + use a dedicated exact-destination map executor and observed-landing contract. Static and dual-engine + selection coverage is green; live evidence proves all edges remain unavailable when station unlocks are + absent, while a successful flight still needs an unlocked-account run. +- Closed the live Port Sarim/Musa Point terminal-ship incident without changing upstream planning data. + Current NPC menus expose `Travel` while the reviewed catalog retains destination labels, and current + travel lands directly on the ground after auto-completing the catalogued deck/gangplank pair. The executor + now preserves the configured action first, applies a conservative `SHIP`-only `Travel` fallback, limits an + exact selected edge to one interaction per top-level walk, and accepts only the immediate planned landing + continuation. Live walks in both directions produced one click, an observed handoff and no timeout. +- Tightened the Microbot-owned Al Kharid toll executor after live investigation exposed both a false landing + and stale object-id collision. The raw door scanner now defers the catalog edge to one selected-transport + owner; that owner resolves the transformed live Gate by configured action and exact edge geometry, with no + historical-id fallback. Completion requires the exact opposite-side destination and an unresolved + interaction bubbles back without a handoff. Rebuilt live walks in both directions selected the Gate, + issued one toll interaction, reached the exact selected landing and emitted the expected handoff without + raw-obstacle interception or timeout. +- Removed the local `Open;Manhole;881` Varrock Sewers row after a live walk proved that it modeled cover + preparation as though it were a surface-to-underground transition. The reviewed upstream catalog contains + only `Climb-down;Manhole;882`; Microbot's closed-object handling can still open `881`, refind `882` and + execute that exact edge. A static catalog regression pins the distinction, and five consecutive F2P live + walks arrived on the exact sewer tile without a trapdoor timeout or route stall. +- Replaced the terminal-travel type assumption with a row-level execution contract. `SHIP`, `NPC` and + `BOAT` describe journeys whose configured target may be an NPC or scene object; immutable route edges now + carry a direct or dialogue-destination mode in addition to `TERMINAL_TRAVEL`. Semantic live matching + admits the direct Al Kharid/Tempoross `Board;Ferry` edge without trusting its historical object id. + Forty-one multi-step terminal rows remain deliberately fail-closed and are pinned by interaction group + until their destination-selection flows are implemented. The Ferry is statically selected by the route + corpus, while successful members-world outbound/reverse execution remains pending; the rebuilt free-world + run correctly admitted zero boat edges and therefore did not produce false runtime evidence. +- The July architectural conclusion still holds: use upstream as a tracked planner/data reference and + retain Microbot ownership of runtime execution and automation policy. +- Advanced the production planner boundary without selecting a replacement engine: synchronous walker + queries and active route restart/cancellation now enter through immutable `Rs2RouteRequest` policy and + `Rs2PathApi`. Configuration refresh, cave walking-only selection, executor ownership and local + `Pathfinder` construction are confined to that seam, with CI rejecting reintroduction in the walker and + lifecycle packages. NPC target selection and bank-route comparison also use request-scoped policy; bank + diagnostics retain the exact typed edges chosen by search rather than rematching the mutable catalog. + The next migration slice is also complete: a generation-tagged immutable active-route status now serves + walker progress/recovery, Quest Helper and obstacle consumers, while CI rejects concrete active planner + reads outside the facade. Slayer bank-item preparation is request-scoped and exposes an exact immutable + edge replacement for its deprecated concrete-transport helper. Explicit walker policy/config operations + are now named facade calls, and CI rejects mutable configuration in the walker. Leagues cache invalidation + also enters through the facade, while its catalog injection receives a narrow transport-usability + predicate instead of `PathfinderConfig`; no production consumer outside the facade or shortest-path + implementation imports the mutable config. Concrete transport payloads and overlay ownership are the next + boundary decision, not another blanket type migration. The first classified payload slice is now complete: + hot-air-balloon execution consumes the immutable selected edge, recovery and obstacle code ask only for + transport-origin presence, door catalog classification uses immutable edge views, and bank-route distance + scoring follows the exact ordered route steps rather than rematching a same-endpoint catalog entry. + `TransportRouteAnalysis` now retains every compared leg's exact steps, and withdrawal planning consumes + the selected bank-to-target edges instead of running a second search from the pre-bank location. CI + prohibits concrete transport imports from migrated packages and rejects that compare-then-replan pattern. + +Next work is to approve the F2P-scoped release decision and collect representative members-only evidence before +any members-policy selection. Broad family-by-family transport convergence remains paused except for +incident-driven fixes. +Runtime interaction changes still require live harness evidence. Do not use the old priority list at the +bottom of this historical document as the active queue. + +The opt-in F2P harness exports the endpoint's coordinate-free schema-v2 snapshot and rejects empty, unsettled, +divergent or failed ordinary shadow runs. The full accepted aggregate now covers the required surface, +recovery, bank, teleport, network and terminal-travel mix. The fixed Varrock manhole route also serves as the +selection/rollback release case described above. + --- ## Re-baseline 2026-07-20 → upstream `7e7e5bf94b` @@ -44,14 +280,13 @@ Microbot loads 22 TSVs (see `Transport.java`). File-level diff vs `skretzo/maste - Of the 778: only **14 named** transports; **764 anonymous route objects** (372 Climb, 137 Ladder, 123 Stairs, 43 Staircase, gates/doors/caves…). - **Not drift:** 737 distinct origins in the 778, of which only 24 overlap a Microbot origin — **713 are genuinely new origin tiles**. Concentrated central 2500–2999 (399), Varlamore/Kebos 1500–1999 (133), Misthalin 3000–3499 (131). - **Verdict:** real coverage gap (new route objects + new areas Microbot's baseline predates). -- **✅ DONE (2026-07-20):** imported **768** of the 778 (`transports.tsv` 4949→5717), scripted + validated. Conversion: action `space`→`;` form; inserted `Currency`/`isMembers` columns; upstream named item variations + `|` OR-sets → Microbot numeric id-sets (`AXE`/`MACHETE`/`PICKAXE`/`ROPE` via `ItemVariations`→`ItemID`; `COINS=N`→Currency); fixed upstream typo `Shadows`→`Shadow of the Storm` (else the `Quest` gate silently drops). **Excluded:** 15 id-less rows (bare `Climb-up Staircase`, name-only walls/gates) unmatchable in Microbot's id-based format, and **2 Garden of Tranquillity trellis rows (obj 2149)** Microbot intentionally omits — caught by `testVarrockSewerPathAvoidsDisabledPalaceTrellisShortcut`. Paired with the **updated collision map** (`collision-map.zip` 2663→2724 regions) so new-area routes have collision coverage. Validated: shortestpath suite green (73 tests, incl. real cross-region pathfinding). +- **✅ DONE (2026-07-20):** imported **768** of the 778 (`transports.tsv` 4949→5717), scripted + validated. Conversion: action `space`→`;` form; inserted `Currency`/`isMembers` columns; upstream named item variations + `|` OR-sets → Microbot numeric id-sets (`AXE`/`MACHETE`/`PICKAXE`/`ROPE` via `ItemVariations`→`ItemID`; `COINS=N`→Currency); fixed upstream typo `Shadows`→`Shadow of the Storm` (else the `Quest` gate silently drops). The original import excluded 15 id-less rows and **2 Garden of Tranquillity trellis rows (obj 2149)**. On 2026-08-05 the two Elemental Workshop wall directions were recovered with current object `26115`; the remaining 15 identities are now explicitly classified rather than unexplained. The trellis remains intentionally omitted and is caught by `testVarrockSewerPathAvoidsDisabledPalaceTrellisShortcut`. Paired with the **updated collision map** (`collision-map.zip` 2663→2724 regions) so new-area routes have collision coverage. Validated: shortestpath suite green (73 tests, incl. real cross-region pathfinding). - **Caveat:** imported members-area routes carry an empty `isMembers` (upstream lacked that column) — same as upstream's own behaviour; harmless since F2P can't reach those origins anyway. ### Recommended next Stage-4 target -**Home-teleport coverage** is the next confirmed upstream data gap: compare upstream -`teleportation_spells_home.tsv` (16 variants) with Microbot's two home-teleport rows and backfill only -the missing, valid variants. #2 and #3 are complete, #11 is a stale premise, and #10/#12/#30 have no -confirmed upstream implementation to backport. +✅ Completed 2026-08-04. Edgeville, Lunar and Arceuus were added with spellbook, quest, membership and +cooldown requirements. Animation-setting duplicates were intentionally not imported. See the newer +re-baseline above for the next work. --- diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/WEBWALKER_IMPROVEMENT_PLAN.md b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/WEBWALKER_IMPROVEMENT_PLAN.md index 5806f82f2d7..32a939cffb4 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/WEBWALKER_IMPROVEMENT_PLAN.md +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/WEBWALKER_IMPROVEMENT_PLAN.md @@ -60,7 +60,7 @@ The "completeness of navigating the world" half. | 19 | DONE | After a door interact, if the player didn't move and `isQuestLockedDoorDialogue()` matches ("quest" / "you need to" / "you must" / "cannot enter" / "requires you" / …), log `warn` with door details + dialogue text, add the tile to `sessionBlacklistedDoors`, close the dialogue, refresh `PathfinderConfig` (re-read quest/varbit state) and `recalculatePath()`. Entry of `handleDoors` short-circuits on blacklisted tiles to break the retry loop. | `Rs2Walker.java:1256–1275, 1376–1396` | M | | 20 | DONE | POH `convertInstancedWorldPoint()` null-path diagnostics added: `handleDoors` null log now includes rawFrom/rawTo/fromWp/toWp and `idx/pathSize`; `setTarget` POH instance start now null-checks `WorldPoint.fromLocalInstance` (falls back to raw world location with a `warn` when it returns null) | `Rs2Walker.java:1206–1213, 1473–1486` | M | | 21 | DONE | Minimap click now scans forward from first past-threshold tile to the furthest same-plane, non-transport-origin tile within ~14-tile Chebyshev reach, then advances the loop index past the intermediate tiles. Cuts tick count on long diagonal runs by ~30-40% since Chebyshev reach is 1.4× the cardinal step count. | `Rs2Walker.java:476–531` | M | -| 22 | DONE | `Telemetry.recordUnreachable(cause, player, target, pathEndpoint, pathSize, threshold, pathfinder)` logs at `warn` with pathfinder stats; wired into both UNREACHABLE exits (no-walkable-path and partial-retries-exhausted) with `unreachableCount` counter exposed to probes | `Rs2Walker.java:108, 128–141, 158, 306–308, 532–533` | S | +| 22 | DONE | `Telemetry.recordUnreachable(cause, player, target, pathEndpoint, pathSize, threshold, routeMetrics)` logs at `warn` with planner-independent route metrics; wired into both UNREACHABLE exits (no-walkable-path and partial-retries-exhausted) with `unreachableCount` counter exposed to probes | `Rs2Walker.java` | S | --- @@ -128,6 +128,14 @@ Items already catalogued in `UPSTREAM_COMPARISON.md` are surfaced here only wher ## Facade migration (2026-07-20) +> **2026-08-05 boundary review:** direct `ShortestPathPlugin` state access has been migrated back behind +> `Rs2PathApi` and is now CI-enforced by `scripts/check-shortest-path-boundary.py`. The class remains a +> compatibility seam rather than the final stable API because it still exposes concrete `Pathfinder`, +> mutable `PathfinderConfig` and `Transport` values. The canonical next steps are in +> `docs/walker-roadmap.md` “Tighten the planner boundary.” The first operation-level slice now provides +> immutable route requests/results and has migrated bank, deposit-box and banked-destination searches off +> direct `Pathfinder` construction. + **Goal:** decouple automation from the shortest-path *internals* so future upstream backports stop rippling into `Rs2Walker` and the other consumers. The fork stays a fork; this is a boundary, not a rewrite. Once the boundary exists, backporting an upstream fix means changing code behind the facade only. ### Why first 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 edb7b7cdaea..e40d9123ebe 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 @@ -13,6 +13,7 @@ import net.runelite.client.plugins.microbot.util.player.Rs2Player; import java.util.*; +import java.util.function.IntSupplier; @Slf4j public class CollisionMap { @@ -29,23 +30,38 @@ public class CollisionMap { */ private final LiveCollisionOverlay overlay; + /** + * Supplies the live player region for instance-only obstacle policy. Static/offline maps use a + * sentinel supplier so pathfinding tests never reach into the RuneLite client thread. + */ + private final IntSupplier currentRegionIdSupplier; + /** * Live view pinned for the duration of one search, so a mid-search merge on the client thread cannot * mix two states into a single path. Refreshed via {@link #beginSearch()}. */ private LiveEdgeSource pinnedLive; + /** Number of edge reads answered by the pinned live overlay during the current search. */ + private long liveEdgeQueries; + public byte[] getPlanes() { return collisionData.getRegionMapPlaneCounts(); } public CollisionMap(SplitFlagMap collisionData) { - this(collisionData, new LiveCollisionOverlay()); + this(collisionData, new LiveCollisionOverlay(), () -> -1); } public CollisionMap(SplitFlagMap collisionData, LiveCollisionOverlay overlay) { + this(collisionData, overlay, CollisionMap::readLivePlayerRegionId); + } + + CollisionMap(SplitFlagMap collisionData, LiveCollisionOverlay overlay, + IntSupplier currentRegionIdSupplier) { this.collisionData = collisionData; this.overlay = overlay; + this.currentRegionIdSupplier = currentRegionIdSupplier; } /** @@ -55,6 +71,7 @@ public CollisionMap(SplitFlagMap collisionData, LiveCollisionOverlay overlay) { */ public void beginSearch() { pinnedLive = overlay.current(); + liveEdgeQueries = 0L; } private boolean get(int x, int y, int z, int flag) { @@ -62,12 +79,17 @@ private boolean get(int x, int y, int z, int flag) { if (live != null) { final Boolean liveEdge = live.edge(x, y, z, flag); if (liveEdge != null) { + liveEdgeQueries++; return liveEdge; } } return collisionData.get(x, y, z, flag); } + public long getLiveEdgeQueries() { + return liveEdgeQueries; + } + public boolean n(int x, int y, int z) { return get(x, y, z, 0); } @@ -219,8 +241,7 @@ private int getCachedRegionId() { long now = System.currentTimeMillis(); if (now - cachedRegionIdTime > REGION_CACHE_MS) { try { - WorldPoint loc = Rs2Player.getWorldLocation(); - cachedRegionId = loc != null ? loc.getRegionID() : -1; + cachedRegionId = currentRegionIdSupplier.getAsInt(); } catch (Exception e) { cachedRegionId = -1; } @@ -229,6 +250,11 @@ private int getCachedRegionId() { return cachedRegionId; } + private static int readLivePlayerRegionId() { + WorldPoint loc = Rs2Player.getWorldLocation(); + return loc != null ? loc.getRegionID() : -1; + } + public List getNeighbors(Node node, VisitedTiles visited, PathfinderConfig config, Set targets) { final int x = WorldPointUtil.unpackWorldX(node.packedPosition); final int y = WorldPointUtil.unpackWorldY(node.packedPosition); @@ -264,14 +290,15 @@ public List getNeighbors(Node node, VisitedTiles visited, PathfinderConfig continue; } int cost = config.getDistanceBeforeUsingTeleport() + transport.getDuration(); - neighbors.add(new TransportNode(transport.getDestination(), node, cost)); + neighbors.add(new TransportNode(transport.getDestination(), node, cost, transport)); if (isMoa) { moaAddedHere++; if (moaCosts == null) moaCosts = new ArrayList<>(); moaCosts.add(cost); } } else { - neighbors.add(new TransportNode(transport.getDestination(), node, transport.getDuration())); + neighbors.add(new TransportNode( + transport.getDestination(), node, transport.getDuration(), transport)); } //END microbot variables } @@ -371,9 +398,10 @@ public List getReverseNeighbors(Node node, VisitedTiles visitedBackward, P if (config.isIgnoreTeleportAndItems()) { continue; } - neighbors.add(new TransportNode(origin, node, config.getDistanceBeforeUsingTeleport() + transport.getDuration())); + neighbors.add(new TransportNode(origin, node, + config.getDistanceBeforeUsingTeleport() + transport.getDuration(), transport)); } else { - neighbors.add(new TransportNode(origin, node, transport.getDuration())); + neighbors.add(new TransportNode(origin, node, transport.getDuration(), transport)); } } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdges.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdges.java deleted file mode 100644 index e7b74d4a9af..00000000000 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdges.java +++ /dev/null @@ -1,236 +0,0 @@ -package net.runelite.client.plugins.microbot.shortestpath.pathfinder; - -import lombok.extern.slf4j.Slf4j; -import net.runelite.api.coords.WorldPoint; -import net.runelite.client.RuneLite; - -import java.io.File; -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.StandardOpenOption; -import java.util.ArrayList; -import java.util.List; -import java.util.Scanner; - -/** - * Human-editable, on-disk store of blocked walking edges the walker learned at runtime — a - * door it physically failed to traverse the same way twice (e.g. a one-way door, or door geometry the - * static map doesn't encode). Distinct from the shipped {@code blocked_edges.tsv} resource (curated map- - * data gaps) and from {@code restrictions.tsv} (quest/skill/item-gated tiles that auto-lift): entries - * here are stable map properties safe to avoid permanently. - * - *

The file lives under {@code /microbot/learned-blocked-edges.tsv} and shares the first - * four columns of {@code blocked_edges.tsv} so a line can be copied between them by hand. Because it is - * user-owned, parsing is deliberately lenient: a malformed row is logged and skipped, never fatal — - * unlike the resource loader, which throws. Delete the file to reset everything the walker has learned. - * - *

Columns 5–6 ({@code Strikes}, {@code Last strike ms}) implement two-strike hardening: one bad - * observation must not poison the store permanently (a mid-walk sample once blacklisted the Wydin shop - * door and needed a hand-edit). A row is only enforced on load once two independent - * observations agree; a first-strike row is probation — blocked for the session that observed it, - * ignored by later sessions until re-confirmed. Rows without the columns (legacy, or hand-copied from - * {@code blocked_edges.tsv}) parse as already-confirmed so existing behavior is preserved. - * - *

This class only does file I/O and parsing. The packed-edge encoding, the strike accounting and the - * pathfinder wiring live in {@link PathfinderConfig}, which owns the authoritative in-memory state. - */ -@Slf4j -public final class LearnedBlockedEdges { - private static final String DELIM_COLUMN = "\t"; - private static final String PREFIX_COMMENT = "#"; - private static final String HEADER = "# Origin\tDestination\tBidirectional\tDisplay info\tStrikes\tLast strike ms"; - /** Rows predating the strike columns were trusted unconditionally; keep them that way. */ - static final int LEGACY_STRIKES = 2; - - /** - * One parsed row. {@code bidirectional} blocks the reverse edge too; {@code info} is free-text; - * {@code strikes}/{@code lastStrikeAtMs} carry the two-strike confirmation state. - */ - public static final class Edge { - public final WorldPoint origin; - public final WorldPoint destination; - public final boolean bidirectional; - public final String info; - public final int strikes; - public final long lastStrikeAtMs; - - public Edge(WorldPoint origin, WorldPoint destination, boolean bidirectional, String info) { - this(origin, destination, bidirectional, info, 1, 0L); - } - - public Edge(WorldPoint origin, WorldPoint destination, boolean bidirectional, String info, - int strikes, long lastStrikeAtMs) { - this.origin = origin; - this.destination = destination; - this.bidirectional = bidirectional; - this.info = info == null ? "" : info; - this.strikes = strikes; - this.lastStrikeAtMs = lastStrikeAtMs; - } - - /** A copy with one more strike stamped at {@code atMs}. */ - public Edge withStrikeAt(long atMs) { - return new Edge(origin, destination, bidirectional, info, strikes + 1, atMs); - } - } - - private LearnedBlockedEdges() { - } - - /** Default store location, mirroring {@code LiveCollisionPersistence}'s {@code microbot} subdir. */ - public static File defaultFile() { - return new File(new File(RuneLite.RUNELITE_DIR, "microbot"), "learned-blocked-edges.tsv"); - } - - /** - * Reads every well-formed row. A missing file yields an empty list; a malformed row is skipped with - * a warning so one bad hand-edit can't stop the walker from loading the rest. - */ - public static List load(File file) { - List edges = new ArrayList<>(); - if (file == null || !file.isFile()) { - return edges; - } - - try { - String content = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); - try (Scanner scanner = new Scanner(content)) { - while (scanner.hasNextLine()) { - String line = scanner.nextLine(); - if (line.startsWith(PREFIX_COMMENT) || line.isBlank()) { - continue; - } - Edge edge = parseRow(line); - if (edge != null) { - edges.add(edge); - } - } - } - } catch (IOException e) { - log.warn("[Walker] Unable to read learned blocked edges from {}: {}", file, e.getMessage()); - } - - return edges; - } - - private static Edge parseRow(String line) { - String[] fields = line.split(DELIM_COLUMN); - if (fields.length < 2) { - log.warn("[Walker] Skipping malformed learned-blocked-edge row (need Origin and Destination): {}", line); - return null; - } - - WorldPoint origin = parsePoint(fields[0]); - WorldPoint destination = parsePoint(fields[1]); - if (origin == null || destination == null) { - log.warn("[Walker] Skipping learned-blocked-edge row with unparseable point(s): {}", line); - return null; - } - - boolean bidirectional = fields.length > 2 && Boolean.parseBoolean(fields[2].trim()); - String info = fields.length > 3 ? fields[3].trim() : ""; - int strikes = LEGACY_STRIKES; - if (fields.length > 4 && !fields[4].trim().isEmpty()) { - try { - strikes = Integer.parseInt(fields[4].trim()); - } catch (NumberFormatException e) { - log.warn("[Walker] Unparseable strike count, treating as confirmed: {}", line); - } - } - long lastStrikeAtMs = 0L; - if (fields.length > 5 && !fields[5].trim().isEmpty()) { - try { - lastStrikeAtMs = Long.parseLong(fields[5].trim()); - } catch (NumberFormatException e) { - // timestamp is advisory; a missing one just widens the independence window - } - } - return new Edge(origin, destination, bidirectional, info, strikes, lastStrikeAtMs); - } - - private static WorldPoint parsePoint(String field) { - if (field == null || field.isBlank()) { - return null; - } - String[] parts = field.trim().split(" "); - if (parts.length != 3) { - return null; - } - try { - return new WorldPoint( - Integer.parseInt(parts[0]), - Integer.parseInt(parts[1]), - Integer.parseInt(parts[2])); - } catch (NumberFormatException e) { - return null; - } - } - - /** - * Appends one row, creating the parent directory and header on first write. Callers are responsible - * for de-duplication (the {@link PathfinderConfig} in-memory set is the source of truth). - */ - public static void append(File file, Edge edge) { - if (file == null || edge == null || edge.origin == null || edge.destination == null) { - return; - } - try { - File parent = file.getParentFile(); - if (parent != null && !parent.isDirectory()) { - Files.createDirectories(parent.toPath()); - } - boolean newFile = !file.isFile() || file.length() == 0; - StringBuilder sb = new StringBuilder(); - if (newFile) { - sb.append(HEADER).append(System.lineSeparator()); - } - sb.append(formatRow(edge)).append(System.lineSeparator()); - Files.write(file.toPath(), sb.toString().getBytes(StandardCharsets.UTF_8), - StandardOpenOption.CREATE, StandardOpenOption.APPEND); - } catch (IOException e) { - log.warn("[Walker] Unable to append learned blocked edge to {}: {}", file, e.getMessage()); - } - } - - /** - * Rewrites the whole store (header + rows). Used when a strike count changes; {@link #append} - * stays the cheap path for brand-new rows. The file is tiny — a walker learns a handful of edges - * over its lifetime — so a full rewrite is simpler than in-place editing. - */ - public static void save(File file, List edges) { - if (file == null || edges == null) { - return; - } - try { - File parent = file.getParentFile(); - if (parent != null && !parent.isDirectory()) { - Files.createDirectories(parent.toPath()); - } - StringBuilder sb = new StringBuilder(HEADER).append(System.lineSeparator()); - for (Edge edge : edges) { - if (edge == null || edge.origin == null || edge.destination == null) { - continue; - } - sb.append(formatRow(edge)).append(System.lineSeparator()); - } - Files.write(file.toPath(), sb.toString().getBytes(StandardCharsets.UTF_8), - StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); - } catch (IOException e) { - log.warn("[Walker] Unable to save learned blocked edges to {}: {}", file, e.getMessage()); - } - } - - private static String formatRow(Edge edge) { - return formatPoint(edge.origin) + DELIM_COLUMN - + formatPoint(edge.destination) + DELIM_COLUMN - + edge.bidirectional + DELIM_COLUMN - + (edge.info == null ? "" : edge.info) + DELIM_COLUMN - + edge.strikes + DELIM_COLUMN - + edge.lastStrikeAtMs; - } - - private static String formatPoint(WorldPoint p) { - return p.getX() + " " + p.getY() + " " + p.getPlane(); - } -} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Node.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Node.java index b5235d3b9d3..258fcb6f214 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Node.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Node.java @@ -12,18 +12,12 @@ public class Node { public final int packedPosition; public final Node previous; public final int cost; - public int heuristic; // Per-node random value used as a secondary priority-queue comparator. Breaks ties - // between equal-fCost nodes in random order so the pathfinder explores equivalent + // between equal-cost nodes in random order so the pathfinder explores equivalent // routes in a different sequence each run, producing distinct (but still optimal) - // tile sequences between the same start/target pair. Prevents the "identical route - // every trip" fingerprint a deterministic A* would leave. + // tile sequences between the same start/target pair. public final int tiebreaker; - public int fCost() { - return cost + heuristic; - } - public Node(WorldPoint position, Node previous, int wait) { this.packedPosition = WorldPointUtil.packWorldPoint(position); this.previous = previous; @@ -47,10 +41,19 @@ public Node(int packedPosition, Node previous) { } public List getPath() { - List path = new ArrayList<>(); - for (Node n = this; n != null; n = n.previous) { + List nodes = getNodePath(); + List path = new ArrayList<>(nodes.size()); + for (Node n : nodes) { path.add(WorldPointUtil.unpackWorldPoint(n.packedPosition)); } + return path; + } + + List getNodePath() { + List path = new ArrayList<>(); + for (Node n = this; n != null; n = n.previous) { + path.add(n); + } Collections.reverse(path); return path; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathEdge.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathEdge.java new file mode 100644 index 00000000000..59d6b14d8ae --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathEdge.java @@ -0,0 +1,126 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** One materialized edge in a chosen local pathfinder route. */ +public final class PathEdge +{ + private final WorldPoint from; + private final WorldPoint to; + private final Transport transport; + + PathEdge(WorldPoint from, WorldPoint to, Transport transport) + { + this.from = from; + this.to = to; + this.transport = transport; + } + + static List fromForwardChain(Node lastNode) + { + if (lastNode == null) + { + return Collections.emptyList(); + } + List nodes = lastNode.getNodePath(); + List edges = new ArrayList<>(Math.max(0, nodes.size() - 1)); + for (int i = 1; i < nodes.size(); i++) + { + Node from = nodes.get(i - 1); + Node to = nodes.get(i); + Transport transport = to instanceof TransportNode + ? ((TransportNode) to).getTransport() + : null; + edges.add(new PathEdge( + WorldPointUtil.unpackWorldPoint(from.packedPosition), + WorldPointUtil.unpackWorldPoint(to.packedPosition), + transport)); + } + return Collections.unmodifiableList(edges); + } + + /** + * Build the temporary local compatibility view for a completed engine-neutral route. + * The transport list is aligned with path edges and may contain {@code null} walking entries. + */ + static List fromMaterializedRoute( + List path, List transportsByStep) + { + if (path == null || transportsByStep == null) + { + throw new IllegalArgumentException("materialized path and transports are required"); + } + int expected = Math.max(0, path.size() - 1); + if (transportsByStep.size() != expected) + { + throw new IllegalArgumentException( + "materialized transport count must match path edge count"); + } + List edges = new ArrayList<>(expected); + for (int i = 0; i < expected; i++) + { + WorldPoint from = path.get(i); + WorldPoint to = path.get(i + 1); + if (from == null || to == null) + { + throw new IllegalArgumentException("materialized path points must be non-null"); + } + Transport transport = transportsByStep.get(i); + if (transport != null && !to.equals(transport.getDestination())) + { + throw new IllegalArgumentException( + "materialized transport destination must match its route step"); + } + edges.add(new PathEdge(from, to, transport)); + } + return Collections.unmodifiableList(edges); + } + + /** + * Combine a normal start-to-meeting chain with the reverse-search meeting-to-goal chain. + * Reverse transport metadata belongs to the {@code from} node, unlike a forward chain where it + * belongs to the {@code to} node. + */ + static List fromBidirectionalChains(Node forwardAtMeet, Node backwardAtMeet) + { + List edges = new ArrayList<>(fromForwardChain(forwardAtMeet)); + for (Node from = backwardAtMeet; from != null && from.previous != null; from = from.previous) + { + Node to = from.previous; + Transport transport = from instanceof TransportNode + ? ((TransportNode) from).getTransport() + : null; + edges.add(new PathEdge( + WorldPointUtil.unpackWorldPoint(from.packedPosition), + WorldPointUtil.unpackWorldPoint(to.packedPosition), + transport)); + } + return Collections.unmodifiableList(edges); + } + + public WorldPoint getFrom() + { + return from; + } + + public WorldPoint getTo() + { + return to; + } + + public Transport getTransport() + { + return transport; + } + + public boolean isTransport() + { + return transport != null; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathTerminationReason.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathTerminationReason.java new file mode 100644 index 00000000000..a2d4c5bebe6 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathTerminationReason.java @@ -0,0 +1,17 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +/** + * Why a pathfinder run stopped. + * + *

The first four values intentionally match the tracked shortest-path upstream contract. Microbot + * adds {@link #FAILED} because its legacy pathfinder catches runtime failures in order to keep the + * client alive; callers must be able to distinguish that case from an exhausted graph.

+ */ +public enum PathTerminationReason +{ + TARGET_REACHED, + SEARCH_EXHAUSTED, + CUTOFF_REACHED, + CANCELLED, + FAILED +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java index f7d32a4b1f9..10a320dcc4b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java @@ -25,8 +25,7 @@ private static void pathfinderDiag(String format, Object... args) { } private static final Comparator NODE_ORDER = Comparator - .comparingInt(Node::fCost) - .thenComparingInt(n -> n.cost) + .comparingInt((Node n) -> n.cost) .thenComparingInt(n -> n.tiebreaker); /** @@ -39,6 +38,11 @@ private static void pathfinderDiag(String format, Object... args) { @Getter private volatile boolean done = false; private volatile boolean cancelled = false; + @Getter + private volatile PathTerminationReason terminationReason; + /** Search cost of the returned raw path, or {@code -1} when no path node was selected. */ + @Getter + private volatile long selectedPathCost = -1L; private final int start; private final Set targets; @@ -50,24 +54,18 @@ private static void pathfinderDiag(String format, Object... args) { private CollisionMap map; private final boolean targetInWilderness; - // Walking subgraph uses A* (boundary is a PQ keyed on f = g + Chebyshev heuristic), - // so among walking nodes the search picks the most promising direction first. - // Transports stay in a separate PQ keyed on g-cost only — they're picked when their - // travel cost is cheaper than any frontier walking node's g-cost, preserving the - // existing "try cheap transports before walking farther" selection behavior. + // Both walking and transport frontiers are ordered by travelled cost. A geometric + // heuristic is not admissible in a graph containing canoes, teleports and other + // long-distance edges: it can permanently visit a farther transport origin before + // a cheaper origin whose straight-line direction initially points away from the + // target. Cost ordering matches the reviewed upstream search semantics and keeps + // exact selected transport identity stable across the engine boundary. // - // Comparator chain is (fCost, gCost, tiebreaker): - // 1. fCost — standard A* primary ordering. - // 2. gCost — required for correctness under early-discovery. addNeighbors() marks - // a neighbor visited at insert time (not at pop), so a node only ever enters - // the PQ once. If two equal-fCost nodes have different gCost, popping the - // higher-gCost one first would fix their shared neighbor's gCost to a - // suboptimal value (because visited is already set when the lower-g node later - // tries to discover the same neighbor). Preferring lower gCost on ties keeps - // early-discovery optimal. - // 3. tiebreaker — per-node random. Among nodes with identical (f, g) — common in - // open-grid regions where many tiles share the same distance-from-start and - // distance-to-goal — this rotates the exploration order each run so paths + // Comparator chain is (gCost, tiebreaker): + // 1. gCost — required for correctness because addNeighbors() marks a neighbor + // visited at insert time and therefore never relaxes it later. + // 2. tiebreaker — per-node random. Among nodes with identical cost — common in + // open-grid regions — this rotates the exploration order each run so paths // diverge tile-by-tile between successive searches with the same endpoints. // Kills the deterministic "identical route every trip" fingerprint. private final Queue boundary = new PriorityQueue<>(4096, NODE_ORDER); @@ -78,11 +76,14 @@ private static void pathfinderDiag(String format, Object... args) { private volatile List path = Collections.emptyList(); private volatile List smoothedPath = Collections.emptyList(); - private volatile boolean pathNeedsUpdate = false; + /** Node identity represented by {@link #path}; avoids a lost-update race with live path readers. */ + private volatile Node materializedPathLastNode; private volatile boolean smoothed = false; private volatile Node bestLastNode; /** When set, {@link #getPath()} returns this list (bidirectional join or early exact hit). */ private volatile List joinedPath; + /** Edge-preserving counterpart to {@link #joinedPath}. */ + private volatile List joinedPathEdges; /** * Teleportation transports are updated when this changes. * Can be either: @@ -120,6 +121,67 @@ public Pathfinder(PathfinderConfig config, WorldPoint start, WorldPoint target) this(config, start, Set.of(target)); } + /** + * Materialize a completed planner-independent route behind the legacy concrete pathfinder surface. + * + *

This is a transitional adapter for the shortest-path overlays and out-of-tree callers that still + * consume {@code ShortestPathPlugin.pathfinder}. New walker code must consume the immutable route + * contract instead. Remove this factory with the local planner after the staged rollout sunset.

+ */ + public static Pathfinder completedRoute( + PathfinderConfig config, + WorldPoint start, + Set targets, + List path, + List transportsByStep, + PathTerminationReason terminationReason, + long selectedPathCost, + long searchNanos, + long nodesChecked, + long transportsChecked, + long liveCollisionEdgesChecked) { + Objects.requireNonNull(config, "config"); + Objects.requireNonNull(start, "start"); + Objects.requireNonNull(targets, "targets"); + Objects.requireNonNull(path, "path"); + Objects.requireNonNull(transportsByStep, "transportsByStep"); + Objects.requireNonNull(terminationReason, "terminationReason"); + if (!path.isEmpty() && !start.equals(path.get(0))) { + throw new IllegalArgumentException("materialized route must start at the requested start"); + } + if (selectedPathCost < -1L || searchNanos < -1L || nodesChecked < -1L + || transportsChecked < -1L || liveCollisionEdgesChecked < -1L) { + throw new IllegalArgumentException("materialized route metrics must be non-negative or unavailable"); + } + + Pathfinder completed = new Pathfinder(config, start, targets); + List immutablePath = Collections.unmodifiableList(new ArrayList<>(path)); + completed.map = config.getMap(); + completed.joinedPath = immutablePath; + completed.joinedPathEdges = PathEdge.fromMaterializedRoute(immutablePath, transportsByStep); + completed.terminationReason = terminationReason; + completed.selectedPathCost = selectedPathCost; + completed.cancelled = false; + completed.done = true; + completed.stats.complete( + metricOrZero(searchNanos), + metricAsInt(nodesChecked), + metricAsInt(transportsChecked), + metricOrZero(liveCollisionEdgesChecked)); + return completed; + } + + private static long metricOrZero(long metric) { + return metric < 0L ? 0L : metric; + } + + private static int metricAsInt(long metric) { + if (metric < 0L) { + return 0; + } + return (int) Math.min(Integer.MAX_VALUE, metric); + } + public WorldPoint getStart() { return WorldPointUtil.unpackWorldPoint(start); } @@ -151,13 +213,30 @@ public List getPath() { return path; } - if (pathNeedsUpdate) { - path = lastNode.getPath(); - pathNeedsUpdate = false; + List currentPath = path; + if (materializedPathLastNode != lastNode) { + // The walker may read a partial path while this search is still running. Identity-based + // invalidation is required here: a reader clearing a shared dirty flag can otherwise erase + // a newer pathfinder-thread update, leaving getPath() and getPathEdges() on different nodes. + currentPath = Collections.unmodifiableList(lastNode.getPath()); + path = currentPath; + materializedPathLastNode = lastNode; smoothed = false; } - return path; + return currentPath; + } + + /** + * Materialized edges for the current best route. Transport edges retain the exact catalog entry + * selected by the search; callers outside shortest-path should map them to owned immutable values. + */ + public List getPathEdges() { + List joined = joinedPathEdges; + if (joined != null) { + return joined; + } + return PathEdge.fromForwardChain(bestLastNode); } /** @@ -203,7 +282,6 @@ private Set buildTransportAnchors(List path) { private void addNeighbors(Node node) { List nodes = map.getNeighbors(node, visited, config, targets); - boolean afterTransport = node instanceof TransportNode; for (Node neighbor : nodes) { if (config.avoidWilderness(node.packedPosition, neighbor.packedPosition, targetInWilderness)) { continue; @@ -214,200 +292,233 @@ private void addNeighbors(Node node) { pending.add(neighbor); ++stats.transportsChecked; } else { - neighbor.heuristic = afterTransport ? 0 : heuristicToNearestTarget(neighbor.packedPosition); boundary.add(neighbor); ++stats.nodesChecked; } } } - // Admissible A* heuristic: Chebyshev 2D to the nearest target, with a modulo-6400 - // fallback for the surface↔underground Y-offset convention (OSRS shifts underground - // coords by +6400 on the Y axis, so Varrock sewers live at y≈9800 while Varrock sits - // at y≈3400). Plain Chebyshev would claim ~6200 tiles to any underground point, which - // misdirects A* into expanding the surface southward instead of routing through a - // nearby ladder/stairs transport. Taking min(direct, mod-6400) stays admissible - // because reaching a y-mirrored underground point still requires ≥ one transport - // (cost ≥ 0) on top of the mod-6400 walking distance. The band-aware distance lives in - // WorldPointUtil.undergroundAwareDistance so the walker uses the same metric. - - private int heuristicToNearestTarget(int packedPos) { - return applyLandmarks(packedPos, baseHeuristicToNearestTarget(packedPos), - fwdLandmark, fwdLandmarkResidual); - } - - private int baseHeuristicToNearestTarget(int packedPos) { - int posX = WorldPointUtil.unpackWorldX(packedPos); - int posY = WorldPointUtil.unpackWorldY(packedPos); + private int minChebyshevStartToAnyTarget() { int best = Integer.MAX_VALUE; - for (int target : targetsPacked) { - int tx = WorldPointUtil.unpackWorldX(target); - int ty = WorldPointUtil.unpackWorldY(target); - int h = WorldPointUtil.undergroundAwareDistance(posX, posY, tx, ty); - if (h < best) { - best = h; + for (int t : targetsPacked) { + int d = Math.max( + Math.abs(WorldPointUtil.unpackWorldX(start) - WorldPointUtil.unpackWorldX(t)), + Math.abs(WorldPointUtil.unpackWorldY(start) - WorldPointUtil.unpackWorldY(t))); + if (d < best) { + best = d; } } return best; } - private int heuristicFromStart(int packedPos) { - return applyLandmarks(packedPos, baseHeuristicFromStart(packedPos), - backLandmark, backLandmarkResidual); + // ---- sealed-target fast path --------------------------------------------------------------- + + /** Reverse-flood budget: clears any fenced yard or walled room in well under this, ~1-3ms. */ + private static final int SEALED_PROBE_NODE_BUDGET = 1024; + private static final int SEALED_SUBSTITUTE_TARGET_CAP = 8; + /** The rim substitutes can themselves prove unreachable; they get a short leash, not 18s. */ + private static final long SEALED_SUBSTITUTE_CUTOFF_MS = 2_000L; + /** + * Node budget for the substitute pass. The time leash alone still allowed a 2-million-node flood + * (a sealed moat tile whose rim is itself an unreachable pocket): two seconds at search speed IS + * the flood. Truncating a genuinely long approach to a sealed destination is fine — the walker + * walks the partial path, replans closer, and the next probe answers from nearer. + */ + private static final long SEALED_SUBSTITUTE_NODE_BUDGET = 50_000L; + /** + * Budget when {@link SealedVerdictMemo} already holds a fresh rim-unreachable proof for this + * goal. The best partial node is found in the first few thousand nodes of a substitute search + * (the rest of the full budget is undirected flood); repeats keep nearly all partial quality. + */ + private static final long SEALED_REPEAT_NODE_BUDGET = 5_000L; + + /** The targets the search LOOPS actually chase; equals {@link #targetsPacked} except in sealed mode. */ + private volatile int[] searchTargetsPacked; + private volatile boolean sealedTargetMode; + private long sealedSubstituteNodeBudget = SEALED_SUBSTITUTE_NODE_BUDGET; + /** Test seam: when > 0, replaces {@link #SEALED_SUBSTITUTE_NODE_BUDGET} for this instance's runs. */ + private long sealedSubstituteNodeBudgetOverride = -1L; + private int sealedMemoKey; + private long cutoffOverrideMillis = -1L; + + void setSealedSubstituteNodeBudgetForTest(long budget) { + sealedSubstituteNodeBudgetOverride = budget; } + /** See {@link #getReachedSealedSubstitute()}. Volatile: written by the search thread, read by the walker. */ + private volatile int reachedSealedSubstitutePacked = -1; - private int baseHeuristicFromStart(int packedPos) { - int posX = WorldPointUtil.unpackWorldX(packedPos); - int posY = WorldPointUtil.unpackWorldY(packedPos); - int sx = WorldPointUtil.unpackWorldX(start); - int sy = WorldPointUtil.unpackWorldY(start); - return WorldPointUtil.undergroundAwareDistance(posX, posY, sx, sy); + private long effectiveCutoffMillis() { + long configured = config.getCalculationCutoffMillis(); + return cutoffOverrideMillis > 0 ? Math.min(cutoffOverrideMillis, configured) : configured; } - // --- Network-transport-aware heuristic --------------------------------------------------- - // - // Network transports (fairy rings, spirit trees, gnome gliders, quetzals) are fully-connected - // hubs: reaching ANY origin lets you hop to ANY destination of that network for ~free. Plain - // Chebyshev is blind to this — a node next to the Ardougne fairy ring reads "~1350 tiles from - // the Farming Guild" by straight line, so A* buries the (optimal) cloak->fairy->CIR chain under - // a single direct teleport that the heuristic makes look closer. We fold the hubs into the - // heuristic as landmarks: for each enabled network whose destinations reach near the goal, every - // network origin is a landmark with residual = min(dest -> goal). Then - // h(node) = min(directWalk, dist(node, nearestOrigin) + residual). - // Each landmark term is a true lower bound (walking to the origin, a free-ish hop, then the - // residual walk to goal), so taking min with the admissible Chebyshev keeps the result both - // admissible AND consistent (the landmark set is fixed for the whole search). A* optimality is - // therefore preserved, while the search is now pulled toward useful hubs instead of ignoring - // them. The backward (bidirectional) arrays are symmetric: landmarks are destinations, residual - // is min(origin -> start). Unlike the reverted chain-bridge injection this adds no graph edges - // (so it can never teleport the player out of a building), and unlike the reverted post-transport - // cascade it never zeroes the heuristic (so it can never collapse into a whole-map Dijkstra). - private static final EnumSet NETWORK_HEURISTIC_TYPES = EnumSet.of( - TransportType.FAIRY_RING, TransportType.SPIRIT_TREE, - TransportType.GNOME_GLIDER, TransportType.QUETZAL); - - private int[] fwdLandmark = null; // packed network origins (reach a hub -> hop toward target) - private int[] fwdLandmarkResidual = null; // parallel: that network's min(dest -> nearest target) Chebyshev - private int[] backLandmark = null; // packed network destinations (symmetric, for backward search) - private int[] backLandmarkResidual = null; // parallel: that network's min(origin -> start) Chebyshev - - private int applyLandmarks(int packedPos, int base, int[] landmarks, int[] residuals) { - if (landmarks == null || landmarks.length == 0) { - return base; - } - int px = WorldPointUtil.unpackWorldX(packedPos); - int py = WorldPointUtil.unpackWorldY(packedPos); - int best = base; - for (int i = 0; i < landmarks.length; i++) { - int lx = WorldPointUtil.unpackWorldX(landmarks[i]); - int ly = WorldPointUtil.unpackWorldY(landmarks[i]); - int viaHub = Math.max(Math.abs(px - lx), Math.abs(py - ly)) + residuals[i]; - if (viaHub < best) { - best = viaHub; - } - } - return best; + /** + * The rim substitute the sealed-target search actually REACHED, or {@code null}. Non-null only + * when the destination was proven sealed AND the substitute pass ended ON a walkable rim tile — + * i.e. this run's path is a complete route to the closest standable spot beside the sealed + * pocket. The walker uses it to retarget the walk to the rim ONCE: without that, the + * SEARCH_EXHAUSTED termination made every pass treat the plan as partial and replan it, and + * each replan re-ran this substitute search — the 17:54 walk burned 50k nodes per replan + * crawling ~300 tiles toward a destination one tile inside a fence. + */ + public WorldPoint getReachedSealedSubstitute() { + int packed = reachedSealedSubstitutePacked; + return packed == -1 ? null : WorldPointUtil.unpackWorldPoint(packed); } /** - * Builds {@link #fwdLandmark}/{@link #backLandmark} once per pathfind from the enabled network - * transports. A network only contributes landmarks if it gets you strictly closer to the goal - * (resp. start) than you already are — otherwise it is pure heuristic overhead with no benefit. + * The nearest walkable rim tile of a PROVEN-sealed destination, or {@code null} when this run + * was not a sealed-target run. Available even when the substitute search never REACHED the rim: + * the substitute node budget covers a ~125-tile flood radius, so a sealed goal further away + * than that exhausts every search en route and the whole journey degrades to a partial crawl — + * measured Falador->Burthorpe against a clicked hatch tile, one truncated 50k-node search per + * pass, ending outside the pub's south wall. The rim tiles themselves are ORDINARY reachable + * tiles; the walker retargets to this one and plans it as a normal full-budget search instead. */ - private void computeNetworkLandmarks() { - Map> all = config.getTransports(); - if (all == null || all.isEmpty()) { - return; + public WorldPoint getNearestSealedRimSubstitute() { + int[] chased = searchTargetsPacked; + if (!sealedTargetMode || chased == null || chased.length == 0) { + return null; } + // Nearest to the search start by construction (see sealedTargetSubstitutes' sort). + return WorldPointUtil.unpackWorldPoint(chased[0]); + } - EnumMap> originsByType = new EnumMap<>(TransportType.class); - EnumMap> destsByType = new EnumMap<>(TransportType.class); - for (Set set : all.values()) { - if (set == null) { - continue; - } - for (Transport t : set) { - TransportType type = t.getType(); - if (type == null || !NETWORK_HEURISTIC_TYPES.contains(type)) { + /** + * Bounded reverse flood from the single target, deciding whether its graph component is provably + * SEALED — unreachable by walking, by any transport whose origin exists, and not landed in by any + * anywhere-teleport. + *

+ * Exists because an unreachable destination made the forward search flood the ENTIRE world + * component before giving up: measured 37 times in one evening at ~1.1M nodes and 1.2-3.8s of CPU + * each, mostly for destinations TWO TILES away (a sealed map-data tile, or an interaction target + * the caller asked for by coordinate). The reverse flood explores only the target's own component, + * which for every observed case is tiny, and answers in ~1ms. + *

+ * Correctness leans on three things. The flood uses {@code getReverseNeighbors} with the + * incoming-transports index, so a room entered by a staircase or door transport GROWS past its + * walls and reads reachable — an upstairs destination is never falsely sealed. Anywhere-teleports + * (null origin, excluded from that index) are checked per component tile instead. And the budget + * makes big components INCONCLUSIVE rather than sealed: only a frontier that genuinely drains + * under budget without touching {@code start} proves anything. + * + * @return {@code null} when reachable or inconclusive (run the normal search); otherwise the + * component's walkable rim — same-plane cardinal neighbours just outside it with at least one + * open edge — nearest-first to the goal, possibly empty (a void tile with a void rim). + */ + private int[] sealedTargetSubstitutes(int goalPacked) { + final Map> incoming = new HashMap<>(512); + final Set anywhereTeleportDests = new HashSet<>(); + for (Map.Entry> e : config.getTransports().entrySet()) { + for (Transport t : e.getValue()) { + if (t.getDestination() == null) { continue; } - WorldPoint o = t.getOrigin(); - WorldPoint d = t.getDestination(); - if (o == null || d == null) { - continue; + int dp = WorldPointUtil.packWorldPoint(t.getDestination()); + if (t.getOrigin() == null) { + anywhereTeleportDests.add(dp); + } else { + incoming.computeIfAbsent(dp, k -> new HashSet<>()).add(t); } - originsByType.computeIfAbsent(type, k -> new HashSet<>()).add(WorldPointUtil.packWorldPoint(o)); - destsByType.computeIfAbsent(type, k -> new HashSet<>()).add(WorldPointUtil.packWorldPoint(d)); } } - if (originsByType.isEmpty()) { - return; - } - - int startToGoal = minChebyshevStartToAnyTarget(); - List fwd = new ArrayList<>(); // {originPacked, residual} - List back = new ArrayList<>(); // {destPacked, residual} - for (Map.Entry> e : originsByType.entrySet()) { - Set origins = e.getValue(); - Set dests = destsByType.getOrDefault(e.getKey(), Collections.emptySet()); - if (origins.isEmpty() || dests.isEmpty()) { - continue; + final Set puzzleAllow = new HashSet<>(4); + puzzleAllow.add(goalPacked); + puzzleAllow.add(start); + final VisitedTiles probeVisited = new VisitedTiles(map); + final ArrayDeque frontier = new ArrayDeque<>(); + final Set component = new LinkedHashSet<>(); + frontier.add(new Node(goalPacked, null)); + probeVisited.set(goalPacked); + int expanded = 0; + while (!frontier.isEmpty()) { + if (expanded >= SEALED_PROBE_NODE_BUDGET) { + return null; // big component: inconclusive, let the real search decide } - - int residualFwd = Integer.MAX_VALUE; - for (int d : dests) { - residualFwd = Math.min(residualFwd, baseHeuristicToNearestTarget(d)); + Node n = frontier.poll(); + expanded++; + if (anywhereTeleportDests.contains(n.packedPosition)) { + return null; // an anywhere-teleport lands inside: reachable } - if (residualFwd < startToGoal) { - for (int o : origins) { - fwd.add(new int[]{o, residualFwd}); + component.add(n.packedPosition); + for (Node pred : map.getReverseNeighbors(n, probeVisited, config, puzzleAllow, incoming)) { + if (pred.packedPosition == start) { + return null; // reachable } + probeVisited.set(pred.packedPosition); + frontier.add(pred); } + } - int residualBack = Integer.MAX_VALUE; - for (int o : origins) { - residualBack = Math.min(residualBack, baseHeuristicFromStart(o)); - } - if (residualBack < startToGoal) { - for (int d : dests) { - back.add(new int[]{d, residualBack}); + final int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + final Set rim = new LinkedHashSet<>(); + for (int packed : component) { + final int x = WorldPointUtil.unpackWorldX(packed); + final int y = WorldPointUtil.unpackWorldY(packed); + final int z = WorldPointUtil.unpackWorldPlane(packed); + for (int[] d : dirs) { + final int nx = x + d[0]; + final int ny = y + d[1]; + final int np = WorldPointUtil.packWorldPoint(nx, ny, z); + if (component.contains(np) || rim.contains(np)) { + continue; + } + for (int[] out : dirs) { + if (map.canStep(nx, ny, z, out[0], out[1])) { + rim.add(np); + break; + } } } } - - fwdLandmark = packLandmarkPositions(fwd); - fwdLandmarkResidual = packLandmarkResiduals(fwd); - backLandmark = packLandmarkPositions(back); - backLandmarkResidual = packLandmarkResiduals(back); - } - - private static int[] packLandmarkPositions(List landmarks) { - int[] out = new int[landmarks.size()]; - for (int i = 0; i < out.length; i++) { - out[i] = landmarks.get(i)[0]; - } - return out; - } - - private static int[] packLandmarkResiduals(List landmarks) { - int[] out = new int[landmarks.size()]; - for (int i = 0; i < out.length; i++) { - out[i] = landmarks.get(i)[1]; + // Nearest to START, not to the goal: the reachable rim is on the approach side, and ranking + // it first lets the substitute search REACH a target in hundreds of nodes. Goal-side rim + // tiles are usually inside the sealed pocket's far side — unreachable by construction — and + // ranking them first burned the whole substitute node budget on best-effort (measured 50k + // nodes at Shantay Pass vs a direct walk to the near-side rim). + final List nearest = new ArrayList<>(rim); + nearest.sort(Comparator.comparingInt(p -> WorldPointUtil.distanceBetween(p, start))); + final int take = Math.min(SEALED_SUBSTITUTE_TARGET_CAP, nearest.size()); + final int[] substitutes = new int[take]; + for (int i = 0; i < take; i++) { + substitutes[i] = nearest.get(i); } - return out; + WebWalkLog.pf("target_sealed dst={} component={} rim={} probeNodes={}", + WorldPointUtil.toString(goalPacked), component.size(), rim.size(), expanded); + return substitutes; } - private int minChebyshevStartToAnyTarget() { - int best = Integer.MAX_VALUE; - for (int t : targetsPacked) { - int d = Math.max( - Math.abs(WorldPointUtil.unpackWorldX(start) - WorldPointUtil.unpackWorldX(t)), - Math.abs(WorldPointUtil.unpackWorldY(start) - WorldPointUtil.unpackWorldY(t))); - if (d < best) { - best = d; + /** + * A sealed component's nearest rim tile can itself be another sealed map-data tile. Resolve + * those nested shells here, before publishing a substitute to the walker, so one requested goal + * produces one effective approach target instead of a chain of recursive retargets. + */ + private int[] normalizeSealedRim(int[] initial) { + List current = Arrays.stream(initial).boxed().collect(Collectors.toList()); + Set probed = new HashSet<>(); + for (int depth = 0; depth < 4 && !current.isEmpty(); depth++) { + int candidate = current.get(0); + if (!probed.add(candidate)) { + break; + } + int[] nested = sealedTargetSubstitutes(candidate); + if (nested == null || nested.length == 0) { + break; + } + LinkedHashSet next = new LinkedHashSet<>(current); + next.remove(candidate); + for (int substitute : nested) { + if (!probed.contains(substitute)) { + next.add(substitute); + } + } + current = new ArrayList<>(next); + current.sort(Comparator.comparingInt(p -> WorldPointUtil.distanceBetween(p, start))); + if (current.size() > SEALED_SUBSTITUTE_TARGET_CAP) { + current = new ArrayList<>(current.subList(0, SEALED_SUBSTITUTE_TARGET_CAP)); } } - return best; + return current.stream().mapToInt(Integer::intValue).toArray(); } private void buildIncomingByDestination(Map> out) { @@ -436,16 +547,19 @@ private List combineBidirectionalPath(Node forwardAtMeet, Node backw List head = forwardAtMeet.getPath(); List full = new ArrayList<>(head.size() + 64); full.addAll(head); - for (Node n = backwardAtMeet.previous; n != null; n = n.previous) { - full.add(WorldPointUtil.unpackWorldPoint(n.packedPosition)); + + List edges = PathEdge.fromBidirectionalChains(forwardAtMeet, backwardAtMeet); + for (Node from = backwardAtMeet; from != null && from.previous != null; from = from.previous) { + Node to = from.previous; + full.add(WorldPointUtil.unpackWorldPoint(to.packedPosition)); } + joinedPathEdges = edges; return full; } private void addNeighborsForwardWithMeet(Node node, Map forwardAt, Map backwardAt, long[] bestMeetingCost, Node[] meetF, Node[] meetB) { List nodes = map.getNeighbors(node, visited, config, targets); - boolean afterTransport = node instanceof TransportNode; for (Node neighbor : nodes) { if (config.avoidWilderness(node.packedPosition, neighbor.packedPosition, targetInWilderness)) { continue; @@ -456,7 +570,6 @@ private void addNeighborsForwardWithMeet(Node node, Map forwardAt pending.add(neighbor); ++stats.transportsChecked; } else { - neighbor.heuristic = afterTransport ? 0 : heuristicToNearestTarget(neighbor.packedPosition); boundary.add(neighbor); ++stats.nodesChecked; } @@ -472,7 +585,6 @@ private void addNeighborsBackwardWithMeet(Node node, VisitedTiles visitedB, Map< Set puzzleAllow, Map forwardAt, Map backwardAt, long[] bestMeetingCost, Node[] meetF, Node[] meetB) { List nodes = map.getReverseNeighbors(node, visitedB, config, puzzleAllow, incoming); - boolean afterTransport = node instanceof TransportNode; for (Node pred : nodes) { if (config.avoidWilderness(pred.packedPosition, node.packedPosition, targetInWilderness)) { continue; @@ -483,7 +595,6 @@ private void addNeighborsBackwardWithMeet(Node node, VisitedTiles visitedB, Map< pendingBackward.add(pred); ++stats.transportsChecked; } else { - pred.heuristic = afterTransport ? 0 : heuristicFromStart(pred.packedPosition); boundaryBackward.add(pred); ++stats.nodesChecked; } @@ -497,12 +608,11 @@ private void addNeighborsBackwardWithMeet(Node node, VisitedTiles visitedB, Map< private void runUnidirectional() { Node startNode = new Node(start, null); - startNode.heuristic = heuristicToNearestTarget(start); boundary.add(startNode); int bestDistance = Integer.MAX_VALUE; long bestHeuristic = Integer.MAX_VALUE; - long cutoffDurationMillis = config.getCalculationCutoffMillis(); + long cutoffDurationMillis = effectiveCutoffMillis(); long cutoffTimeMillis = System.currentTimeMillis() + cutoffDurationMillis; config.refreshTeleports(start, 31); boolean reachedGoal = false; @@ -539,10 +649,9 @@ private void runUnidirectional() { final int nodePos = node.packedPosition; boolean reached = false; - for (int target : targetsPacked) { + for (int target : searchTargetsPacked) { if (nodePos == target) { bestLastNode = node; - pathNeedsUpdate = true; reached = true; break; } @@ -550,7 +659,6 @@ private void runUnidirectional() { long heuristic = distance + (long) WorldPointUtil.distanceBetween(nodePos, target, 2); if (heuristic < bestHeuristic || (heuristic <= bestHeuristic && distance < bestDistance)) { bestLastNode = node; - pathNeedsUpdate = true; bestDistance = distance; bestHeuristic = heuristic; cutoffTimeMillis = System.currentTimeMillis() + cutoffDurationMillis; @@ -561,9 +669,10 @@ private void runUnidirectional() { break; } - if (System.currentTimeMillis() > cutoffTimeMillis) { + if (System.currentTimeMillis() > cutoffTimeMillis + || (sealedTargetMode && stats.getNodesChecked() > sealedSubstituteNodeBudget)) { timedOut = true; - WebWalkLog.pf("cutoff bestDist={} nodes={}", bestDistance, stats.getNodesChecked()); + WebWalkLog.pf("cutoff bestDist={} nodes={} sealedMode={}", bestDistance, stats.getNodesChecked(), sealedTargetMode); break; } @@ -585,15 +694,20 @@ private void runUnidirectional() { WebWalkLog.pf("uni_loop_exit cancelled={} bEmpty={} pEmpty={} bestLast={}", cancelled, boundary.isEmpty(), pending.isEmpty(), bestLastNode == null ? "null" : WorldPointUtil.toString(bestLastNode.packedPosition)); + + terminationReason = cancelled ? PathTerminationReason.CANCELLED + : reachedGoal ? PathTerminationReason.TARGET_REACHED + : timedOut ? PathTerminationReason.CUTOFF_REACHED + : PathTerminationReason.SEARCH_EXHAUSTED; } private void runBidirectional() { - int goalPacked = targetsPacked[0]; + int goalPacked = searchTargetsPacked[0]; Map> incoming = new HashMap<>(512); buildIncomingByDestination(incoming); Set puzzleAllow = new HashSet<>(targets.size() + 1); - for (int t : targetsPacked) { + for (int t : searchTargetsPacked) { puzzleAllow.add(t); } puzzleAllow.add(start); @@ -606,20 +720,19 @@ private void runBidirectional() { Node[] meetB = new Node[1]; Node startNode = new Node(start, null); - startNode.heuristic = heuristicToNearestTarget(start); boundary.add(startNode); forwardAt.put(start, startNode); Node goalNode = new Node(goalPacked, null); - goalNode.heuristic = heuristicFromStart(goalPacked); boundaryBackward.add(goalNode); backwardAt.put(goalPacked, goalNode); int bestDistance = Integer.MAX_VALUE; long bestHeuristic = Integer.MAX_VALUE; - long cutoffDurationMillis = config.getCalculationCutoffMillis(); + long cutoffDurationMillis = effectiveCutoffMillis(); long cutoffTimeMillis = System.currentTimeMillis() + cutoffDurationMillis; config.refreshTeleports(start, 31); + boolean timedOut = false; while (!cancelled && (!boundary.isEmpty() || !pending.isEmpty() || !boundaryBackward.isEmpty() || !pendingBackward.isEmpty())) { if (!boundary.isEmpty() || !pending.isEmpty()) { @@ -653,19 +766,19 @@ private void runBidirectional() { final int nodePos = node.packedPosition; if (nodePos == goalPacked) { + joinedPathEdges = PathEdge.fromForwardChain(node); joinedPath = node.getPath(); - pathNeedsUpdate = false; + selectedPathCost = node.cost; bestLastNode = null; WebWalkLog.pf("bidir forward_hit_goal"); break; } - for (int target : targetsPacked) { + for (int target : searchTargetsPacked) { int distance = WorldPointUtil.distanceBetween(nodePos, target); long heuristic = distance + (long) WorldPointUtil.distanceBetween(nodePos, target, 2); if (heuristic < bestHeuristic || (heuristic <= bestHeuristic && distance < bestDistance)) { bestLastNode = node; - pathNeedsUpdate = true; bestDistance = distance; bestHeuristic = heuristic; cutoffTimeMillis = System.currentTimeMillis() + cutoffDurationMillis; @@ -691,7 +804,7 @@ private void runBidirectional() { if (node.packedPosition == start) { joinedPath = combineBidirectionalPath(forwardAt.get(start), node); - pathNeedsUpdate = false; + selectedPathCost = node.cost; bestLastNode = null; WebWalkLog.pf("bidir backward_hit_start"); break; @@ -700,15 +813,17 @@ private void runBidirectional() { addNeighborsBackwardWithMeet(node, visitedB, incoming, puzzleAllow, forwardAt, backwardAt, bestMeetingCost, meetF, meetB); } - if (System.currentTimeMillis() > cutoffTimeMillis) { - WebWalkLog.pf("bidir_cutoff nodes={}", stats.getNodesChecked()); + if (System.currentTimeMillis() > cutoffTimeMillis + || (sealedTargetMode && stats.getNodesChecked() > sealedSubstituteNodeBudget)) { + timedOut = true; + WebWalkLog.pf("bidir_cutoff nodes={} sealedMode={}", stats.getNodesChecked(), sealedTargetMode); break; } } if (joinedPath == null && meetF[0] != null && meetB[0] != null && bestMeetingCost[0] < Long.MAX_VALUE) { joinedPath = combineBidirectionalPath(meetF[0], meetB[0]); - pathNeedsUpdate = false; + selectedPathCost = bestMeetingCost[0]; bestLastNode = null; WebWalkLog.pf("bidir meet_at={} cost={}", WorldPointUtil.toString(meetF[0].packedPosition), bestMeetingCost[0]); @@ -726,26 +841,83 @@ private void runBidirectional() { WebWalkLog.pf("bidir_exit joined={} meetCost={}", joinedPath == null ? "null" : Integer.toString(joinedPath.size()), bestMeetingCost[0] == Long.MAX_VALUE ? "n/a" : Long.toString(bestMeetingCost[0])); + + terminationReason = cancelled ? PathTerminationReason.CANCELLED + : joinedPath != null ? PathTerminationReason.TARGET_REACHED + : timedOut ? PathTerminationReason.CUTOFF_REACHED + : PathTerminationReason.SEARCH_EXHAUSTED; } @Override public void run() { WebWalkLog.pf("run_start src={} dst={} cutoffMs={}", WorldPointUtil.toString(start), WorldPointUtil.toString(targets), config.getCalculationCutoffMillis()); + path = Collections.emptyList(); + smoothedPath = Collections.emptyList(); + materializedPathLastNode = null; + smoothed = false; joinedPath = null; - // Pathfinder instances are commonly constructed on the client thread and submitted to the - // shortest-path executor. Resolve both ThreadLocal-backed objects here so the collision map, - // visited state and pinned live snapshot all belong to the search thread for this run. - map = config.getMap(); - visited = new VisitedTiles(map); - // Pin the live-collision snapshot for this whole search so a mid-search swap on the client - // thread cannot mix two scenes into one path. No-op when live collision is disabled. - map.beginSearch(); + joinedPathEdges = null; + terminationReason = null; + selectedPathCost = -1L; try { + // Pathfinder instances are commonly constructed on the client thread and submitted to the + // shortest-path executor. Resolve both ThreadLocal-backed objects here so the collision map, + // visited state and pinned live snapshot all belong to the search thread for this run. + map = config.getMap(); + visited = new VisitedTiles(map); + // Pin the live-collision snapshot for this whole search so a mid-search swap on the client + // thread cannot mix two scenes into one path. No-op when live collision is disabled. + map.beginSearch(); stats.start(); - computeNetworkLandmarks(); + + searchTargetsPacked = targetsPacked; + sealedTargetMode = false; + sealedSubstituteNodeBudget = sealedSubstituteNodeBudgetOverride > 0 + ? sealedSubstituteNodeBudgetOverride + : SEALED_SUBSTITUTE_NODE_BUDGET; + cutoffOverrideMillis = -1L; + reachedSealedSubstitutePacked = -1; + if (targetsPacked.length == 1 && targetsPacked[0] != start) { + int[] rim = null; + try { + rim = sealedTargetSubstitutes(targetsPacked[0]); + if (rim != null && rim.length > 0) { + rim = normalizeSealedRim(rim); + } + } catch (RuntimeException probeFailure) { + // The probe is an optimisation; any anomaly degrades to the full search, never + // to a failed run. (First seen with a mocked CollisionMap whose VisitedTiles had + // no region planes.) + log.debug("[Pathfinder] sealed-target probe failed, running full search: {}", + probeFailure.toString()); + } + if (rim != null) { + sealedTargetMode = true; + if (rim.length == 0) { + // A sealed component with a void rim (off-map or instance-template garbage): + // nothing to walk toward, nothing to search for. + WebWalkLog.pf("target_sealed no_walkable_rim dst={}", + WorldPointUtil.toString(targetsPacked[0])); + terminationReason = PathTerminationReason.SEARCH_EXHAUSTED; + return; + } + // Search for the rim instead: the walk still ends beside the sealed area — the + // same best-effort the old full flood produced — at a thousandth of the cost. + searchTargetsPacked = rim; + cutoffOverrideMillis = SEALED_SUBSTITUTE_CUTOFF_MS; + sealedMemoKey = config.getLastTransportRefreshKeyHash(); + if (SealedVerdictMemo.isRimUnreachable(targetsPacked[0], sealedMemoKey, + System.currentTimeMillis())) { + sealedSubstituteNodeBudget = SEALED_REPEAT_NODE_BUDGET; + WebWalkLog.pf("sealed_memo repeat dst={} budget={}", + WorldPointUtil.toString(targetsPacked[0]), SEALED_REPEAT_NODE_BUDGET); + } + } + } + int minCheb = minChebyshevStartToAnyTarget(); - boolean useBidir = targetsPacked.length == 1 + boolean useBidir = searchTargetsPacked.length == 1 && minCheb >= BIDIRECTIONAL_MIN_CHEBYSHEV; pathfinderDiag("run mode decision useBidir=%s minCheb=%d bidirThreshold=%d targetsPacked=%d cutoffMs=%d cancelAlready=%s", useBidir, @@ -760,27 +932,66 @@ public void run() { } else { runUnidirectional(); } + // Reaching a rim substitute is not reaching the caller's target, and the substitute pass + // hitting its short leash (the rim itself can be unreachable — a sealed tile inside a + // locked interior) changes nothing either: the original destination's unreachability is + // already PROVEN, and callers keying decisions off the termination must hear exactly that. + // A genuinely REACHED rim is remembered before the remap, though — it is the walker's + // signal to retarget the walk to the rim once instead of replaying this search forever. + if (sealedTargetMode) { + if (terminationReason == PathTerminationReason.TARGET_REACHED && bestLastNode != null) { + reachedSealedSubstitutePacked = bestLastNode.packedPosition; + SealedVerdictMemo.clear(targetsPacked[0]); + } else if (terminationReason == PathTerminationReason.SEARCH_EXHAUSTED) { + // The frontier genuinely drained without touching the rim: proven unreachable, + // remember it so the partial crawl's replans and script reachability polls stop + // re-proving the same verdict at full price. CUTOFF_REACHED (time leash or node + // budget) proves nothing — a long route can exhaust the budget with the rim + // perfectly reachable, and memoing that dropped every replan to the repeat + // budget, guaranteeing none could ever finish (observed Varlamore→Burthorpe: + // 50k nodes spent at bestDist=112, then 5k-node replans flip-flopping). + SealedVerdictMemo.record(targetsPacked[0], sealedMemoKey, System.currentTimeMillis()); + } + if (terminationReason == PathTerminationReason.TARGET_REACHED + || terminationReason == PathTerminationReason.CUTOFF_REACHED) { + terminationReason = PathTerminationReason.SEARCH_EXHAUSTED; + } + } } catch (Exception e) { + terminationReason = PathTerminationReason.FAILED; log.error("[Pathfinder] Exception in run(): ", e); } finally { + if (terminationReason == null) { + terminationReason = cancelled + ? PathTerminationReason.CANCELLED + : PathTerminationReason.SEARCH_EXHAUSTED; + } + if (selectedPathCost < 0 && bestLastNode != null) { + selectedPathCost = bestLastNode.cost; + } done = !cancelled; boundary.clear(); pending.clear(); boundaryBackward.clear(); pendingBackward.clear(); - visited.clear(); + if (visited != null) { + visited.clear(); + } - stats.end(); + stats.end(map == null ? 0L : map.getLiveEdgeQueries()); - WebWalkLog.pf("run_done done={} cancelled={} stats={}", - done, cancelled, getStats() != null ? getStats().toString() : "null"); + WebWalkLog.pf("run_done done={} cancelled={} termination={} stats={}", + done, cancelled, terminationReason, + getStats() != null ? getStats().toString() : "null"); } } public static class PathfinderStats { @Getter private int nodesChecked = 0, transportsChecked = 0; + @Getter + private long liveCollisionEdgesChecked = 0L; private long startNanos, endNanos; private volatile boolean started = false, ended = false; @@ -799,14 +1010,31 @@ private void start() { startNanos = System.nanoTime(); } - private void end() { + private void complete( + long elapsedNanos, + int nodesChecked, + int transportsChecked, + long liveCollisionEdgesChecked) { + this.started = true; + this.nodesChecked = nodesChecked; + this.transportsChecked = transportsChecked; + this.liveCollisionEdgesChecked = liveCollisionEdgesChecked; + this.startNanos = 0L; + this.endNanos = elapsedNanos; + this.ended = true; + } + + private void end(long liveCollisionEdgesChecked) { + this.liveCollisionEdgesChecked = liveCollisionEdgesChecked; endNanos = System.nanoTime(); ended = true; } @Override public String toString() { - return String.format("PathfinderStats(nodes=%d,transports=%d,time=%dms)", nodesChecked, transportsChecked, getElapsedTimeNanos() / 1_000_000); + return String.format("PathfinderStats(nodes=%d,transports=%d,liveEdges=%d,time=%dms)", + nodesChecked, transportsChecked, liveCollisionEdgesChecked, + getElapsedTimeNanos() / 1_000_000); } } } 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 80510af1637..885f35f53de 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 @@ -18,6 +18,7 @@ import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; import net.runelite.client.plugins.microbot.util.magic.Rs2Magic; import net.runelite.client.plugins.microbot.util.magic.Rs2Spells; import net.runelite.client.plugins.microbot.util.magic.RuneFilter; @@ -93,6 +94,12 @@ public class PathfinderConfig { private final Map> allTransports; @Setter private volatile Set usableTeleports; + + /** Immutable exact-object snapshot for planner adapters after transport admission has run. */ + public Set getUsableTeleportsSnapshot() { + Set current = usableTeleports; + return current == null ? Collections.emptySet() : Set.copyOf(current); + } private final List filteredTargets = new CopyOnWriteArrayList<>(); @Getter @@ -110,24 +117,10 @@ public class PathfinderConfig { * they survive. Loaded once in the constructor; grown by {@link #learnBlockedEdge}. */ private final Set learnedBlockedEdgeKeys = ConcurrentHashMap.newKeySet(); - /** Backing file for {@link #learnedBlockedEdgeKeys}; redirectable for tests. */ - private volatile File learnedBlockedEdgesFile; - /** - * Two-strike hardening state: every row of the learned store (probation included), in file order, - * plus a by-key index for strike accounting. {@link #learnedBlockedEdgeKeys} holds only what is - * ENFORCED this session (confirmed rows + this session's own observations). Guarded by - * {@link #learnedEdgeLock}. - */ - private final List learnedEdgeRows = new ArrayList<>(); - private final Map learnedEdgeRowsByKey = new HashMap<>(); - private final Object learnedEdgeLock = new Object(); - /** Observations needed before a learned block survives into LATER sessions. */ - static final int LEARNED_EDGE_ENFORCE_STRIKES = 2; - /** A repeat observation only counts as independent evidence after this long. */ - static final long LEARNED_EDGE_STRIKE_INDEPENDENCE_MS = 10 * 60_000L; private final Client client; private final ShortestPathConfig config; + private final TransportPlanningPolicy transportPlanningPolicy; private final List questStateOrder = Arrays.asList( QuestState.NOT_STARTED, @@ -153,12 +146,21 @@ public class PathfinderConfig { */ private volatile int lastComputedInvFingerprint; private volatile int previousRefreshInvFingerprint; + /** + * The transport-refresh cache key computed by the most recent {@code refreshTransports} — + * the invalidation key for {@link SealedVerdictMemo} (a verdict proven under one transport + * set must not survive into another). + */ + @Getter + private volatile int lastTransportRefreshKeyHash; /** Which verification component moved on the most recent verify-miss; see the miss log. */ private volatile String lastVerifyMissDetail = ""; @Getter private volatile boolean avoidWilderness; @Getter private volatile boolean avoidDangerousNpcs; + @Getter + private volatile PlannerSelectionMode plannerSelectionMode = PlannerSelectionMode.LOCAL; @Getter private volatile boolean useSpiritTrees; private volatile boolean useAgilityShortcuts, @@ -210,7 +212,8 @@ public class PathfinderConfig { // Used to include bank items when searching for item requirements private volatile boolean useBankItems = false; - private Set refreshAvailableItemIds; + private Map refreshAvailableItemQuantities; + private Map refreshAvailableRuneQuantities; private int[] refreshBoostedLevels; private Map refreshCurrencyCache; // Varplayer values snapshot for the current refreshTransports pass. Without it, every varp @@ -259,8 +262,17 @@ protected boolean removeEldestEntry(Map.Entry public PathfinderConfig(SplitFlagMap mapData, Map> transports, List restrictions, Client client, ShortestPathConfig config) { + this(mapData, transports, restrictions, client, config, TransportPlanningPolicy.ALLOW_ALL); + } + + public PathfinderConfig(SplitFlagMap mapData, Map> transports, + List restrictions, + Client client, ShortestPathConfig config, + TransportPlanningPolicy transportPlanningPolicy) { this.mapData = mapData; - this.map = ThreadLocal.withInitial(() -> new CollisionMap(this.mapData, this.liveCollisionOverlay)); + this.map = ThreadLocal.withInitial(() -> client == null + ? new CollisionMap(this.mapData, this.liveCollisionOverlay, () -> -1) + : new CollisionMap(this.mapData, this.liveCollisionOverlay)); this.allTransports = Collections.synchronizedMap(new HashMap<>()); replaceAllTransports(transports); this.usableTeleports = ConcurrentHashMap.newKeySet(allTransports.size() / 20); @@ -268,10 +280,10 @@ public PathfinderConfig(SplitFlagMap mapData, Map> tr this.transportsPacked = new PrimitiveIntHashMap<>(allTransports.size() / 2); this.blockedTransportEdgesPacked = ConcurrentHashMap.newKeySet(); addStaticBlockedEdges(); - this.learnedBlockedEdgesFile = LearnedBlockedEdges.defaultFile(); - loadLearnedBlockedEdges(); this.client = client; this.config = config; + this.transportPlanningPolicy = Objects.requireNonNull( + transportPlanningPolicy, "transportPlanningPolicy"); //START microbot variables this.resourceRestrictions = restrictions; this.customRestrictions = Collections.emptyList(); @@ -337,6 +349,8 @@ 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()); + plannerSelectionMode = ShortestPathPlugin.override( + "plannerSelectionMode", config.plannerSelectionMode()); useAgilityShortcuts = ShortestPathPlugin.override("useAgilityShortcuts", config.useAgilityShortcuts()); useGrappleShortcuts = ShortestPathPlugin.override("useGrappleShortcuts", config.useGrappleShortcuts()); useBoats = ShortestPathPlugin.override("useBoats", config.useBoats()); @@ -449,6 +463,12 @@ public void filterLocations(Set locations, boolean canReviveFiltered * @param target Optional target destination for optimized filtering (null for standard filtering) */ private void refreshTransports(WorldPoint target) { + // The 1.1s post-login client-thread freeze hid in the UNMEASURED parts of this method: the + // stage timers summed to ~30ms while the outer wrapper read 1154ms, and the slow-stage log + // never fired. Three regions were dark: this entry block (quest-state + bank/item gates), + // the cache-key phase, and the verify/capture block after filtering. Each now has a timer, + // carried on both the stage log and the slow log, so the next slow login names its stage. + long entryStart = System.currentTimeMillis(); useFairyRings = ShortestPathPlugin.override("useFairyRings", config.useFairyRings()) && !QuestState.NOT_STARTED.equals(Rs2Player.getQuestState(Quest.FAIRYTALE_II__CURE_A_QUEEN)) && (Rs2Inventory.contains(ItemID.DRAMEN_STAFF, ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF) @@ -462,20 +482,26 @@ private void refreshTransports(WorldPoint target) { useQuetzals = ShortestPathPlugin.override("useQuetzals", config.useQuetzals()) && QuestState.FINISHED.equals(Rs2Player.getQuestState(Quest.TWILIGHTS_PROMISE)); + long entryTime = System.currentTimeMillis() - entryStart; + + long keyStart = System.currentTimeMillis(); final Rs2LeaguesTransport.LeaguesContext leaguesCtx = Rs2LeaguesTransport.leaguesContext(); + lastKeyLeaguesMs = System.currentTimeMillis() - keyStart; final int refreshCacheKeyHash = computeTransportRefreshCacheKeyHash(target, leaguesCtx); + lastTransportRefreshKeyHash = refreshCacheKeyHash; + long keyTime = System.currentTimeMillis() - keyStart; TransportRefreshSnapshot snap = transportRefreshSnapshots.get(refreshCacheKeyHash); if (snap != null && client != null) { - int[] boostedProbe = new int[SKILLS.length]; + int[] boostedProbe = new int[Transport.REQUIREMENT_LEVEL_COUNT]; final int[] probeOrdinals = snap.sortedSkillOrdinals; Microbot.getClientThread().runOnClientThreadOptional(() -> { // Only the skills some transport gates on; probing all 23 both cost client-thread // time and let hitpoints/prayer drift invalidate an otherwise valid cache. if (probeOrdinals != null) { for (int ordinal : probeOrdinals) { - if (ordinal >= 0 && ordinal < SKILLS.length) { - boostedProbe[ordinal] = client.getBoostedSkillLevel(SKILLS[ordinal]); + if (ordinal >= 0 && ordinal < Transport.REQUIREMENT_LEVEL_COUNT) { + boostedProbe[ordinal] = currentRequirementLevel(ordinal); } } } @@ -540,13 +566,20 @@ private void refreshTransports(WorldPoint target) { long mergeTime = System.currentTimeMillis() - mergeStart; long cacheStart = System.currentTimeMillis(); - refreshAvailableItemIds = new HashSet<>(); + refreshAvailableItemQuantities = new HashMap<>(); refreshCurrencyCache = new HashMap<>(); - Rs2Inventory.items().forEach(item -> refreshAvailableItemIds.add(item.getId())); - Rs2Equipment.all().forEach(item -> refreshAvailableItemIds.add(item.getId())); + Rs2Inventory.items().forEach(item -> refreshAvailableItemQuantities.merge( + item.getId(), Math.max(0, item.getQuantity()), Integer::sum)); + Rs2Equipment.all().forEach(item -> refreshAvailableItemQuantities.merge( + item.getId(), Math.max(0, item.getQuantity()), Integer::sum)); if (useBankItems) { - Rs2Bank.getAll().forEach(item -> refreshAvailableItemIds.add(item.getId())); + Rs2Bank.getAll().forEach(item -> refreshAvailableItemQuantities.merge( + item.getId(), Math.max(0, item.getQuantity()), Integer::sum)); } + refreshAvailableRuneQuantities = new HashMap<>(); + Rs2Magic.getRunes(RuneFilter.builder().includeBank(useBankItems).build()) + .forEach((rune, quantity) -> refreshAvailableRuneQuantities.put( + rune.getItemId(), quantity)); Set varbitIds = new HashSet<>(); List varbitConditions = new ArrayList<>(); @@ -622,12 +655,17 @@ private void refreshTransports(WorldPoint target) { ? Collections.unmodifiableSet(relevantItemIds) : null; - refreshBoostedLevels = new int[SKILLS.length]; + refreshBoostedLevels = new int[Transport.REQUIREMENT_LEVEL_COUNT]; Map varplayerValues = new HashMap<>(); Microbot.getClientThread().runOnClientThreadOptional(() -> { for (int i = 0; i < SKILLS.length; i++) { refreshBoostedLevels[i] = client.getBoostedSkillLevel(SKILLS[i]); } + refreshBoostedLevels[Transport.TOTAL_LEVEL_INDEX] = client.getTotalLevel(); + Player localPlayer = client.getLocalPlayer(); + refreshBoostedLevels[Transport.COMBAT_LEVEL_INDEX] = + localPlayer == null ? 0 : localPlayer.getCombatLevel(); + refreshBoostedLevels[Transport.QUEST_POINTS_INDEX] = client.getVarpValue(VarPlayer.QUEST_POINTS); for (int id : varbitIds) { Microbot.getVarbitValue(id); } @@ -693,9 +731,16 @@ private void refreshTransports(WorldPoint target) { } } - Rs2LeaguesTransport.injectLeaguesTransports(this, leaguesCtx, usableTeleports, transports, transportsPacked, typeStats); + Rs2LeaguesTransport.injectLeaguesTransports( + transport -> isTransportUsableWithLeaguesContext(transport, leaguesCtx), + leaguesCtx, + usableTeleports, + transports, + transportsPacked, + typeStats); long filterTime = System.currentTimeMillis() - filterStart; + long verifyStart = System.currentTimeMillis(); int[] sortedVarbitConditions = encodeSortedConditionTriples(varbitConditions); int[] sortedVarplayerConditions = encodeSortedConditionTriples(varplayerConditions); int[] sortedQuestIds = mergedList.values().stream() @@ -714,10 +759,13 @@ private void refreshTransports(WorldPoint target) { sortedVarbitConditions, sortedVarplayerConditions, sortedQuestIds); int[] verificationComponents = computeTransportRefreshVerificationComponents(refreshBoostedLevels, sortedSkillOrdinals, sortedVarbitConditions, sortedVarplayerConditions, sortedQuestIds); + long verifyTime = System.currentTimeMillis() - verifyStart; + long captureStart = System.currentTimeMillis(); transportRefreshSnapshots.put(refreshCacheKeyHash, TransportRefreshSnapshot.capture( refreshCacheKeyHash, verificationHash, verificationComponents, sortedSkillOrdinals, sortedVarbitConditions, sortedVarplayerConditions, sortedQuestIds, transports, usableTeleports)); + long captureTime = System.currentTimeMillis() - captureStart; long similarStart = System.currentTimeMillis(); if (useBankItems && config.maxSimilarTransportDistance() > 0) { @@ -725,23 +773,29 @@ private void refreshTransports(WorldPoint target) { } long similarTime = System.currentTimeMillis() - similarStart; - refreshAvailableItemIds = null; + refreshAvailableItemQuantities = null; + refreshAvailableRuneQuantities = null; refreshBoostedLevels = null; refreshCurrencyCache = null; refreshVarplayerValues = null; // varbit/varplayer counts = distinct ids referenced by merged transport definitions this refresh, not total client var space. - WebWalkLog.cfg("refresh_transports merge={}ms cache={}ms filter={}ms useTrans={}ms similar={}ms total/chk={}/{} usablePost={} vb={} vp={}", - mergeTime, cacheTime, filterTime, useTransportTimeNanos / 1_000_000, similarTime, + WebWalkLog.cfg("refresh_transports entry={}ms key={}ms merge={}ms cache={}ms filter={}ms useTrans={}ms verify={}ms capture={}ms similar={}ms total/chk={}/{} usablePost={} vb={} vp={}", + entryTime, keyTime, mergeTime, cacheTime, filterTime, useTransportTimeNanos / 1_000_000, + verifyTime, captureTime, similarTime, totalTransports, checkedTransports, usableTeleports.size(), varbitIds.size(), varplayerIds.size()); // Surface the same breakdown at INFO when the miss is slow enough to be the visible cold // start, so the dominant stage is identifiable without enabling debug logging. - long refreshTransportsTotalMs = mergeTime + cacheTime + filterTime + similarTime; + long refreshTransportsTotalMs = entryTime + keyTime + mergeTime + cacheTime + filterTime + + verifyTime + captureTime + similarTime; if (refreshTransportsTotalMs >= SLOW_REFRESH_LOG_THRESHOLD_MS) { - WebWalkLog.cfgSlow("slow refresh_transports merge={}ms cache={}ms filter={}ms useTrans={}ms similar={}ms total/chk={}/{} vb={} vp={}", - mergeTime, cacheTime, filterTime, useTransportTimeNanos / 1_000_000, similarTime, + WebWalkLog.cfgSlow("slow refresh_transports entry={}ms key={}ms merge={}ms cache={}ms filter={}ms useTrans={}ms verify={}ms capture={}ms similar={}ms total/chk={}/{} vb={} vp={}", + entryTime, keyTime, mergeTime, cacheTime, filterTime, useTransportTimeNanos / 1_000_000, + verifyTime, captureTime, similarTime, totalTransports, checkedTransports, varbitIds.size(), varplayerIds.size()); + WebWalkLog.cfgSlow("slow refresh_transports keyDetail leagues={}ms inv={}ms equip={}ms bank={}ms", + lastKeyLeaguesMs, lastKeyInvMs, lastKeyEquipMs, lastKeyBankMs); typeStats.entrySet().stream() .sorted((a, b) -> Integer.compare(b.getValue()[2], a.getValue()[2])) .limit(3) @@ -813,63 +867,25 @@ private void addStaticBlockedEdges() { } /** - * (Re)loads the human-editable learned-blocked-edges TSV. Only rows with - * {@link #LEARNED_EDGE_ENFORCE_STRIKES}+ strikes are applied to the live block set — a - * single-strike row is probation: the session that observed it blocked it at the time, but a - * fresh session ignores it until a second independent observation confirms (one bad sample must - * not poison the store permanently). A reload drops previously-applied learned keys first so the - * test seam can simulate a restart; static blocked edges are re-added and unaffected. - */ - private void loadLearnedBlockedEdges() { - synchronized (learnedEdgeLock) { - blockedTransportEdgesPacked.removeAll(learnedBlockedEdgeKeys); - addStaticBlockedEdges(); - learnedBlockedEdgeKeys.clear(); - learnedEdgeRows.clear(); - learnedEdgeRowsByKey.clear(); - for (LearnedBlockedEdges.Edge edge : LearnedBlockedEdges.load(learnedBlockedEdgesFile)) { - long key = transportEdgeKey( - WorldPointUtil.packWorldPoint(edge.origin), - WorldPointUtil.packWorldPoint(edge.destination)); - learnedEdgeRows.add(edge); - learnedEdgeRowsByKey.put(key, edge); - boolean enforced = edge.strikes >= LEARNED_EDGE_ENFORCE_STRIKES; - if (enforced) { - learnedBlockedEdgeKeys.add(key); - blockedTransportEdgesPacked.add(key); - } else { - log.debug("[Walker] Learned edge on probation (strike {}/{}), not enforced: {} -> {}", - edge.strikes, LEARNED_EDGE_ENFORCE_STRIKES, edge.origin, edge.destination); - } - if (edge.bidirectional) { - long reverse = transportEdgeKey( - WorldPointUtil.packWorldPoint(edge.destination), - WorldPointUtil.packWorldPoint(edge.origin)); - learnedEdgeRowsByKey.putIfAbsent(reverse, edge); - if (enforced) { - learnedBlockedEdgeKeys.add(reverse); - blockedTransportEdgesPacked.add(reverse); - } - } - } - } - } - - /** - * Records a walking edge the walker just failed to traverse (e.g. a door that moved the player the - * wrong way). The observing session blocks the edge immediately — it just watched the failure, and - * anything less loops the walker into the same door. PERSISTENCE is two-strike gated: the row is - * written on probation (strike 1) and later sessions ignore it until a second observation at least - * {@link #LEARNED_EDGE_STRIKE_INDEPENDENCE_MS} later confirms it. One bad sample (the Wydin door - * poisoning) therefore self-heals on restart instead of requiring a hand-edit. - * - *

Only the attempted direction is blocked — not bidirectionally — so a genuinely one-way door - * stays usable the other way. Callers must only pass stable map properties here; temporary, - * quest/skill-gated doors are handled by {@code restrictions.tsv} and must not be learned, or the - * bot would avoid them forever after the requirement is met. + * Records a walking edge the walker just failed to traverse (e.g. a door that moved the player + * the wrong way, or a route click the reachability net proved walled). The observing session + * blocks the edge immediately — it just watched the failure, and anything less loops the walker + * into the same obstacle. + *

+ * SESSION-ONLY by policy (2026-08-07): nothing is persisted, and nothing learned in an earlier + * session is loaded. The hand-curated {@code blocked_edges.tsv} is the sole cross-session + * authority. The two-strike persistent store this replaces spent its history managing its own + * failure modes — the Wydin door poisoning needed probation semantics to self-heal, and the + * store's default file leaked developer state into every test that built a config. An edge worth + * remembering across sessions is worth a reviewed TSV row. + *

+ * Only the attempted direction is blocked — not bidirectionally — so a genuinely one-way door + * stays usable the other way. Callers must only pass stable map properties here; + * temporary, quest/skill-gated doors are handled by {@code restrictions.tsv} and must not be + * learned, or the bot would avoid them for the rest of the session after the requirement is met. * * @return {@code true} if this edge was newly blocked for this session; {@code false} if it was - * already enforced. + * already blocked. */ public boolean learnBlockedEdge(WorldPoint origin, WorldPoint destination, String reason) { if (origin == null || destination == null) { @@ -882,43 +898,33 @@ public boolean learnBlockedEdge(WorldPoint origin, WorldPoint destination, Strin return false; } blockedTransportEdgesPacked.add(key); - long now = System.currentTimeMillis(); - synchronized (learnedEdgeLock) { - LearnedBlockedEdges.Edge existing = learnedEdgeRowsByKey.get(key); - if (existing == null) { - LearnedBlockedEdges.Edge row = new LearnedBlockedEdges.Edge( - origin, destination, false, reason == null ? "" : reason, 1, now); - learnedEdgeRows.add(row); - learnedEdgeRowsByKey.put(key, row); - LearnedBlockedEdges.append(learnedBlockedEdgesFile, row); - log.info("[Walker] Learned blocked edge {} -> {} ({}) — strike 1/{}: blocked this session, " - + "enforced across sessions only after independent confirmation; {}", - origin, destination, reason, LEARNED_EDGE_ENFORCE_STRIKES, learnedBlockedEdgesFile); - } else if (existing.strikes < LEARNED_EDGE_ENFORCE_STRIKES - && now - existing.lastStrikeAtMs > LEARNED_EDGE_STRIKE_INDEPENDENCE_MS) { - LearnedBlockedEdges.Edge confirmed = existing.withStrikeAt(now); - int idx = learnedEdgeRows.indexOf(existing); - if (idx >= 0) { - learnedEdgeRows.set(idx, confirmed); - } - learnedEdgeRowsByKey.put(key, confirmed); - LearnedBlockedEdges.save(learnedBlockedEdgesFile, learnedEdgeRows); - log.info("[Walker] Learned blocked edge {} -> {} ({}) — strike {}/{}: persistently enforced", - origin, destination, reason, confirmed.strikes, LEARNED_EDGE_ENFORCE_STRIKES); - } else { - // Probation row re-observed within the independence window (e.g. a rapid client - // restart into the same stuck spot): session block stands, persistence unchanged. - log.debug("[Walker] Learned blocked edge {} -> {} re-observed within the independence " - + "window; probation unchanged", origin, destination); - } - } + log.info("[Walker] Learned blocked edge {} -> {} ({}) — blocked for THIS SESSION only; " + + "permanent blocks belong in blocked_edges.tsv", origin, destination, reason); return true; } - /** Test seam: redirect the learned-edge store to a temp file and (re)load it. */ - void setLearnedBlockedEdgesFileForTest(File file) { - this.learnedBlockedEdgesFile = file; - loadLearnedBlockedEdges(); + /** + * Reverse of {@link #learnBlockedEdge}: removes the learned block so the edge is plannable again. + * Exists for condition-scoped blocks (a door that refused to open for game-state reasons) that the + * walker withdraws at the next walk session start. Static rows from blocked_edges.tsv are not + * touched — they were never in {@code learnedBlockedEdgeKeys}, and {@code blockedTransportEdgesPacked} + * only drops the key when it was a learned one. + */ + public boolean unlearnBlockedEdge(WorldPoint origin, WorldPoint destination, String reason) { + if (origin == null || destination == null) { + return false; + } + long key = transportEdgeKey( + WorldPointUtil.packWorldPoint(origin), + WorldPointUtil.packWorldPoint(destination)); + if (!learnedBlockedEdgeKeys.remove(key)) { + return false; + } + if (!STATIC_BLOCKED_EDGES_PACKED.contains(key)) { + blockedTransportEdgesPacked.remove(key); + } + log.info("[Walker] Unlearned blocked edge {} -> {} ({})", origin, destination, reason); + return true; } private void addBlockedEdge(WorldPoint origin, WorldPoint destination) { @@ -1245,40 +1251,49 @@ private int getLiveVarplayerValue(int varplayerId) { } private boolean useTransport(Transport transport) { + // This runs once per expanded catalog edge during every refresh. Keep individual rejection + // reasons at TRACE; DEBUG already receives the per-type aggregate emitted by refreshTransports. + if (!transportPlanningPolicy.isAdmitted(transport)) { + log.trace("Transport ( O: {} D: {} type={} ) has no registered Microbot executor", + transport == null ? null : transport.getOrigin(), + transport == null ? null : transport.getDestination(), + transport == null ? null : transport.getType()); + return false; + } // Check if the feature flag is disabled if (!isFeatureEnabled(transport)) { - log.debug("Transport Type {} is disabled by feature flag", transport.getType()); + log.trace("Transport Type {} is disabled by feature flag", transport.getType()); return false; } // If the transport requires you to be in a members world (used for more granular member requirements) if (transport.isMembers() && !client.getWorldType().contains(WorldType.MEMBERS)) { - log.debug("Transport ( O: {} D: {} ) requires members world", transport.getOrigin(), transport.getDestination()); + log.trace("Transport ( O: {} D: {} ) requires members world", transport.getOrigin(), transport.getDestination()); return false; } if (transport.getType() == TransportType.SPIRIT_TREE && !isSpiritTreeRouteEnabled(transport)) { - log.debug("Transport ( O: {} D: {} ) is a spirit tree route but the tree is disabled", transport.getOrigin(), transport.getDestination()); + log.trace("Transport ( O: {} D: {} ) is a spirit tree route but the tree is disabled", transport.getOrigin(), transport.getDestination()); return false; } // If you don't meet level requirements if (!hasRequiredLevels(transport)) { - log.debug("Transport ( O: {} D: {} ) requires skill levels {}", transport.getOrigin(), transport.getDestination(), Arrays.toString(transport.getSkillLevels())); + log.trace("Transport ( O: {} D: {} ) requires skill levels {}", transport.getOrigin(), transport.getDestination(), Arrays.toString(transport.getSkillLevels())); return false; } // If the transport has quest requirements & the quest haven't been completed if (transport.isQuestLocked() && !completedQuests(transport)) { - log.debug("Transport ( O: {} D: {} ) requires quests {}", transport.getOrigin(), transport.getDestination(), transport.getQuests()); + log.trace("Transport ( O: {} D: {} ) requires quests {}", transport.getOrigin(), transport.getDestination(), transport.getQuests()); return false; } // If the transport has varbit requirements & the varbits do not match if (!varbitChecks(transport)) { - log.debug("Transport ( O: {} D: {} ) requires varbits {}", transport.getOrigin(), transport.getDestination(), transport.getVarbits()); + log.trace("Transport ( O: {} D: {} ) requires varbits {}", transport.getOrigin(), transport.getDestination(), transport.getVarbits()); return false; } // If the transport has varplayer requirements & the varplayers do not match if (!varplayerChecks(transport)) { - log.debug("Transport ( O: {} D: {} ) requires varplayers {}", transport.getOrigin(), transport.getDestination(), transport.getVarplayers()); + log.trace("Transport ( O: {} D: {} ) requires varplayers {}", transport.getOrigin(), transport.getDestination(), transport.getVarplayers()); return false; } @@ -1291,19 +1306,19 @@ private boolean useTransport(Transport transport) { return new int[]{invCount, bankCount}; }); if (cached[0] < transport.getCurrencyAmount() && cached[1] < transport.getCurrencyAmount()) { - log.debug("Transport ( O: {} D: {} ) requires {} x {}", transport.getOrigin(), transport.getDestination(), transport.getCurrencyAmount(), transport.getCurrencyName()); + log.trace("Transport ( O: {} D: {} ) requires {} x {}", transport.getOrigin(), transport.getDestination(), transport.getCurrencyAmount(), transport.getCurrencyName()); return false; } } else if (!Rs2Inventory.hasItemAmount(transport.getCurrencyName(), transport.getCurrencyAmount()) && !(useBankItems && Rs2Bank.count(transport.getCurrencyName()) >= transport.getCurrencyAmount())) { - log.debug("Transport ( O: {} D: {} ) requires {} x {}", transport.getOrigin(), transport.getDestination(), transport.getCurrencyAmount(), transport.getCurrencyName()); + log.trace("Transport ( O: {} D: {} ) requires {} x {}", transport.getOrigin(), transport.getDestination(), transport.getCurrencyAmount(), transport.getCurrencyName()); return false; } } // Check if Teleports are globally disabled if (TransportType.isTeleport(transport.getType(), transport.getOrigin()) && Rs2Walker.disableTeleports) { - log.debug("Transport ( O: {} D: {} ) is a teleport but teleports are globally disabled", transport.getOrigin(), transport.getDestination()); + log.trace("Transport ( O: {} D: {} ) is a teleport but teleports are globally disabled", transport.getOrigin(), transport.getDestination()); return false; } @@ -1311,7 +1326,7 @@ private boolean useTransport(Transport transport) { if (transport.getType() == TELEPORTATION_ITEM) { boolean isUsable = isTeleportationItemUsable(transport); if (!isUsable) { - log.debug("Transport ( O: {} D: {} ) is a teleport item but is not usable", transport.getOrigin(), transport.getDestination()); + log.trace("Transport ( O: {} D: {} ) is a teleport item but is not usable", transport.getOrigin(), transport.getDestination()); } return isUsable; } @@ -1319,7 +1334,7 @@ private boolean useTransport(Transport transport) { if (transport.getType() == TELEPORTATION_SPELL) { boolean isUsable = isTeleportationSpellUsable(transport); if (!isUsable) { - log.debug("Transport ( O: {} D: {} ) is a teleport spell but is not usable", transport.getOrigin(), transport.getDestination()); + log.trace("Transport ( O: {} D: {} ) is a teleport spell but is not usable", transport.getOrigin(), transport.getDestination()); } return isUsable; } @@ -1328,7 +1343,7 @@ private boolean useTransport(Transport transport) { if (!transport.getItemIdRequirements().isEmpty()) { boolean hasRequiredItems = hasRequiredItems(transport); if (!hasRequiredItems) { - log.debug("Transport ( O: {} D: {} ) requires items {}", transport.getOrigin(), transport.getDestination(), transport.getItemIdRequirements().stream().flatMap(Set::stream).collect(Collectors.toSet())); + log.trace("Transport ( O: {} D: {} ) requires items {}", transport.getOrigin(), transport.getDestination(), transport.getItemIdRequirements().stream().flatMap(Set::stream).collect(Collectors.toSet())); } return hasRequiredItems; } @@ -1357,14 +1372,40 @@ public boolean isTransportUsableWithLeaguesContext(Transport transport, Rs2Leagu private boolean hasRequiredLevels(Transport transport) { int[] requiredLevels = transport.getSkillLevels(); if (refreshBoostedLevels != null) { - for (int i = 0; i < requiredLevels.length; i++) { - if (requiredLevels[i] > 0 && refreshBoostedLevels[i] < requiredLevels[i]) return false; - } - return true; + return meetsRequiredLevels(requiredLevels, refreshBoostedLevels); } return IntStream.range(0, requiredLevels.length) .filter(i -> requiredLevels[i] > 0) - .allMatch(i -> Microbot.getClient().getBoostedSkillLevel(SKILLS[i]) >= requiredLevels[i]); + .allMatch(i -> currentRequirementLevel(i) >= requiredLevels[i]); + } + + static boolean meetsRequiredLevels(int[] requiredLevels, int[] currentLevels) { + if (requiredLevels == null || currentLevels == null || currentLevels.length < requiredLevels.length) { + return false; + } + for (int i = 0; i < requiredLevels.length; i++) { + if (requiredLevels[i] > 0 && currentLevels[i] < requiredLevels[i]) { + return false; + } + } + return true; + } + + private int currentRequirementLevel(int index) { + if (index >= 0 && index < SKILLS.length) { + return client.getBoostedSkillLevel(SKILLS[index]); + } + if (index == Transport.TOTAL_LEVEL_INDEX) { + return client.getTotalLevel(); + } + if (index == Transport.COMBAT_LEVEL_INDEX) { + Player localPlayer = client.getLocalPlayer(); + return localPlayer == null ? 0 : localPlayer.getCombatLevel(); + } + if (index == Transport.QUEST_POINTS_INDEX) { + return client.getVarpValue(VarPlayer.QUEST_POINTS); + } + return 0; } /** @@ -1447,6 +1488,29 @@ private boolean isFeatureEnabled(Transport transport) { } } + return isTransportTypeEnabled(type); + } + + /** Immutable feature-toggle snapshot for planner-independent request policy. */ + public Set getEnabledTransportTypes() { + EnumSet enabled = EnumSet.noneOf(TransportType.class); + for (TransportType type : TransportType.values()) { + if (isTransportTypeEnabled(type)) { + enabled.add(type); + } + } + return Collections.unmodifiableSet(enabled); + } + + public TeleportationItem getTeleportationItemPolicy() { + return useTeleportationItems == null ? TeleportationItem.NONE : useTeleportationItems; + } + + public boolean isMembersWorld() { + return client == null || client.getWorldType().contains(WorldType.MEMBERS); + } + + private boolean isTransportTypeEnabled(TransportType type) { switch (type) { case AGILITY_SHORTCUT: return useAgilityShortcuts; @@ -1503,30 +1567,70 @@ private boolean isFeatureEnabled(Transport transport) { * Checks if a teleportation item is usable */ private boolean isTeleportationItemUsable(Transport transport) { - if (useTeleportationItems == TeleportationItem.NONE) return false; - // Check consumable items configuration - if (useTeleportationItems == TeleportationItem.INVENTORY_NON_CONSUMABLE && transport.isConsumable()) + if (!isTeleportationItemAllowedByPolicy(useTeleportationItems, transport.isConsumable())) { return false; + } return hasRequiredItems(transport); } + static boolean isTeleportationItemAllowedByPolicy( + TeleportationItem policy, + boolean consumable) { + return policy != TeleportationItem.NONE + && (policy != TeleportationItem.INVENTORY_NON_CONSUMABLE || !consumable); + } + /** * Checks if the player has any of the required equipment and inventory items for the transport */ private boolean hasRequiredItems(Transport transport) { - if (requiresChronicle(transport)) return hasChronicleCharges(); + return TransportItemRequirement.selectProviders( + transport.getItemRequirements(), + this::availableRequirementItemQuantity, + itemId -> availableItemQuantity(itemId) > 0, + itemId -> availableItemQuantity(itemId) > 0).isPresent(); + } + + static boolean meetsItemRequirements( + List requirements, + java.util.function.IntUnaryOperator availableQuantity) { + if (requirements == null || requirements.isEmpty()) { + return true; + } + return requirements.stream().allMatch(requirement -> requirement.isSatisfiedBy(availableQuantity)); + } - if (refreshAvailableItemIds != null) { - return transport.getItemIdRequirements() - .stream() - .flatMap(Collection::stream) - .anyMatch(refreshAvailableItemIds::contains); + private int availableItemQuantity(int itemId) { + if (itemId == ItemID.CHRONICLE && !hasChronicleCharges()) { + return 0; } - return transport.getItemIdRequirements() - .stream() - .flatMap(Collection::stream) - .anyMatch(itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId) || (ShortestPathPlugin.getPathfinderConfig().useBankItems && Rs2Bank.hasItem(itemId))); + if (refreshAvailableItemQuantities != null) { + return refreshAvailableItemQuantities.getOrDefault(itemId, 0); + } + int quantity = Rs2Inventory.itemQuantity(itemId); + Rs2ItemModel equipped = Rs2Equipment.get(itemId); + if (equipped != null) { + quantity += Math.max(1, equipped.getQuantity()); + } + if (useBankItems) { + quantity += Rs2Bank.count(itemId); + } + return quantity; + } + + private int availableRequirementItemQuantity(int itemId) { + Map runeSnapshot = refreshAvailableRuneQuantities; + if (runeSnapshot != null) { + return Math.max(availableItemQuantity(itemId), runeSnapshot.getOrDefault(itemId, 0)); + } + Runes rune = Runes.byItemId(itemId); + if (rune == null) { + return availableItemQuantity(itemId); + } + int runeQuantity = Rs2Magic.getRunes( + RuneFilter.builder().includeBank(useBankItems).build()).getOrDefault(rune, 0); + return Math.max(availableItemQuantity(itemId), runeQuantity); } /** @@ -1540,7 +1644,16 @@ private boolean hasRequiredItems(Restriction restriction) { } - private boolean isTeleportationSpellUsable(Transport transport) { + boolean isTeleportationSpellUsable(Transport transport) { + if (transportPlanningPolicy.isZeroRuneSpell(transport)) { + // Every spellbook home teleport is a zero-rune widget action. Spellbook, membership, + // quest, Wilderness and cooldown requirements were checked earlier in useTransport(). + return true; + } + + if (!transport.getItemRequirements().isEmpty()) { + return hasRequiredItems(transport); + } boolean hasMultipleDestination = transport.getDisplayInfo().contains(":"); String displayInfo = hasMultipleDestination @@ -1552,16 +1665,6 @@ private boolean isTeleportationSpellUsable(Transport transport) { // return Rs2Magic.quickCanCast(displayInfo); } - /** - * Checks if the transport requires the Chronicle - */ - private boolean requiresChronicle(Transport transport) { - return transport.getItemIdRequirements() - .stream() - .flatMap(Collection::stream) - .anyMatch(itemId -> itemId == ItemID.CHRONICLE); - } - /** * Checks if the Chronicle has charges */ @@ -1999,19 +2102,32 @@ private static int currencyItemId(String currencyName) { } } + // The cold-login key phase measured 658ms of an 833ms client-thread refresh (2026-08-13 19:40, + // reason=no_snapshot; warm refreshes read 1ms) — these name which read pays it. Written on every + // fingerprint, printed only on the slow log. + private volatile long lastKeyLeaguesMs; + private volatile long lastKeyInvMs; + private volatile long lastKeyEquipMs; + private volatile long lastKeyBankMs; + private int fingerprintInventoryEquipmentBank() { final Set ids = transportRelevantItemIds; final int[] h = {1}; + long t = System.currentTimeMillis(); Rs2Inventory.items().forEach(item -> { if (!itemAffectsTransportUsability(item.getId(), ids)) return; h[0] = 31 * h[0] + item.getId(); h[0] = 31 * h[0] + item.getQuantity(); }); + lastKeyInvMs = System.currentTimeMillis() - t; + t = System.currentTimeMillis(); Rs2Equipment.all().forEach(item -> { if (!itemAffectsTransportUsability(item.getId(), ids)) return; h[0] = 31 * h[0] + item.getId(); h[0] = 31 * h[0] + item.getQuantity(); }); + lastKeyEquipMs = System.currentTimeMillis() - t; + t = System.currentTimeMillis(); if (useBankItems) { Rs2Bank.getAll().forEach(item -> { if (!itemAffectsTransportUsability(item.getId(), ids)) return; @@ -2019,6 +2135,7 @@ private int fingerprintInventoryEquipmentBank() { h[0] = 31 * h[0] + item.getQuantity(); }); } + lastKeyBankMs = System.currentTimeMillis() - t; return h[0]; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/SealedVerdictMemo.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/SealedVerdictMemo.java new file mode 100644 index 00000000000..95e5ac03504 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/SealedVerdictMemo.java @@ -0,0 +1,74 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Remembers, per destination tile, that a sealed-target substitute search already ran to exhaustion + * WITHOUT reaching any rim tile — i.e. the goal is sealed AND its rim is unreachable from where the + * walker is operating. + *

+ * Exists because that verdict was re-proven from scratch on every replan. Two live patterns paid for + * it constantly: the partial-path crawl replans each pass (a walk to a goal behind an uncatalogued + * gate re-ran the full {@code SEALED_SUBSTITUTE_NODE_BUDGET} search ~15 times in one walk), and + * scripts polling reachability of tiles on unconnected components (agility rooftop marks) re-ran it + * every lap for hours. The first proof stays exhaustive; while a fresh memo entry matches, repeats + * drop to {@link Pathfinder} 's reduced budget — the best partial node is found early in the search, + * so the truncated repeat yields nearly the same partial path at a tenth of the cost. + *

+ * Safety: the memo only ever REDUCES the budget of a search whose outcome is already proven; it never + * changes reachability decisions. A goal that becomes reachable stops probing as sealed and never + * consults the memo. A rim that becomes reachable (a door opened) is caught by the entry's TTL and by + * the transport-refresh key changing; the reduced budget is also still comfortably above the + * hundreds of nodes a genuinely reachable near-side rim costs to reach. + */ +final class SealedVerdictMemo { + /** A door opening does not change the refresh key, so staleness is time-bounded too. */ + static final long TTL_MS = 60_000L; + /** Hard cap; beyond it the whole memo resets (verdicts are cheap to re-prove once). */ + static final int MAX_ENTRIES = 64; + + private static final Map ENTRIES = new ConcurrentHashMap<>(); + + private SealedVerdictMemo() { + } + + private static final class Entry { + final int refreshKey; + final long recordedAtMs; + + Entry(int refreshKey, long recordedAtMs) { + this.refreshKey = refreshKey; + this.recordedAtMs = recordedAtMs; + } + } + + /** True when a fresh verdict for this goal exists under the same transport-refresh key. */ + static boolean isRimUnreachable(int goalPacked, int refreshKey, long nowMs) { + Entry e = ENTRIES.get(goalPacked); + if (e == null) { + return false; + } + if (e.refreshKey != refreshKey || nowMs - e.recordedAtMs >= TTL_MS) { + ENTRIES.remove(goalPacked); + return false; + } + return true; + } + + static void record(int goalPacked, int refreshKey, long nowMs) { + if (ENTRIES.size() >= MAX_ENTRIES && !ENTRIES.containsKey(goalPacked)) { + ENTRIES.clear(); + } + ENTRIES.put(goalPacked, new Entry(refreshKey, nowMs)); + } + + /** The substitute search reached a rim: the rim IS reachable, drop any stale verdict. */ + static void clear(int goalPacked) { + ENTRIES.remove(goalPacked); + } + + static void clearAll() { + ENTRIES.clear(); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportNode.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportNode.java index 28b048e6c0b..4a2c0791858 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportNode.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportNode.java @@ -1,9 +1,12 @@ package net.runelite.client.plugins.microbot.shortestpath.pathfinder; import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.Transport; public class TransportNode extends Node implements Comparable { - public TransportNode(WorldPoint point, Node previous, int travelTime) { + private final Transport transport; + + public TransportNode(WorldPoint point, Node previous, int travelTime, Transport transport) { // Use Node(int, Node, int cost) which assigns cost directly. The WorldPoint // Node constructor re-adds previous.cost via its cost(previous, wait) method, // which caused (a) double-counting when we passed prev.cost + travelTime as @@ -12,6 +15,11 @@ public TransportNode(WorldPoint point, Node previous, int travelTime) { super(net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil.packWorldPoint(point), previous, (previous != null ? previous.cost : 0) + travelTime); + this.transport = transport; + } + + public Transport getTransport() { + return transport; } @Override diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicy.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicy.java new file mode 100644 index 00000000000..0edf4ba9863 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicy.java @@ -0,0 +1,24 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.client.plugins.microbot.shortestpath.Transport; + +/** + * Engine-side admission seam for an already parsed transport catalog. + * + *

The pathfinder owns graph search, not knowledge of which interactions Microbot can execute. + * Production therefore supplies a Microbot-owned policy, while headless planner tests may admit an + * explicitly constructed catalog without depending on runtime executor classes.

+ */ +public interface TransportPlanningPolicy +{ + TransportPlanningPolicy ALLOW_ALL = transport -> true; + + /** Whether this catalog row may enter the planner graph. */ + boolean isAdmitted(Transport transport); + + /** Whether a spell row is a registered zero-rune widget action. */ + default boolean isZeroRuneSpell(Transport transport) + { + return false; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java index 1e90bbed1d6..1f57cb95e49 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionCapture.java @@ -46,12 +46,15 @@ public final class LiveCollisionCapture { * disagree with what a fresh capture would now produce, so {@link LiveCollisionPersistence} rejects the * stale data on load instead of trusting it. This is what removes the manual "Reset learned collision" * step: e.g. adding the rockfall exemption changed what a rockfall tile records, so that data must not - * survive the change. History: v1 = original translation; v2 = rockfall (26679/26680) exemption. + * survive the change. History: v1 = original translation; v2 = rockfall (26679/26680) exemption; + * v3 = wall-door FOOTPRINT deferral — v2 stores could hold known+blocked diagonal edges around a + * closed door (the oriented mask missed them), and two such doors sealed the Falador farm interior, + * so every v2 store is potentially door-poisoned and must be discarded. * (Door edges changing from unknown to known-passable did NOT need a bump: v2 stores hold no door * edges at all — they were always unknown — so old data cannot disagree, it is merely less informed * and gets filled in by the next capture.) */ - public static final int CAPTURE_VERSION = 2; + public static final int CAPTURE_VERSION = 3; /** * Actions that mark a wall object as a door the walker opens at runtime. Mirrors the door-action set @@ -164,6 +167,14 @@ private static LiveCollisionDoorMask findDoorEdges(WorldView wv, int planeCount) if (wall != null && wallDoorIds.computeIfAbsent( wall.getId(), LiveCollisionCapture::isOpenableDoor)) { doorEdges.markWall(z, sx, sy, wall.getOrientationA(), wall.getOrientationB()); + // A closed door blocks more than its oriented edge in the live flags: the + // wall also blocks the DIAGONAL edges cutting its corners, which the + // oriented mask missed — those were captured known+blocked and PERSISTED, + // and two such doors sealed the Falador farm interior (2026-08-14 17:43), + // turning every plan to it into a SEARCH_EXHAUSTED partial-segment crawl. + // Defer every edge touching the door's tile to the static map, exactly the + // treatment a game-object door footprint already gets. + doorEdges.markGameObject(z, sx, sy, sx, sy); } final GameObject[] gameObjects = tile.getGameObjects(); if (gameObjects == null) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionConflicts.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionConflicts.java index 3bbb25fb208..cd2ca7b1830 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionConflicts.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionConflicts.java @@ -44,6 +44,80 @@ public boolean isEmpty() { } } + /** + * How much of this scene's disagreement with the shipped map the accumulated overlay ALREADY knew. + *

+ * {@link Tally} answers "how wrong is the static map here", which is the disease, not the treatment — + * it compares live against STATIC and reads identically whether or not the persistent store is doing + * its job. This answers the question that actually matters once persistence exists: on arriving + * somewhere, had we already learned it on a previous visit? + */ + public static final class Coverage { + /** Static was wrong and the overlay already had the right answer — a previous visit paid off. */ + public final int alreadyKnown; + /** Static was wrong and the overlay had nothing — the blind first visit this store exists to end. */ + public final int newInformation; + /** The overlay had a DIFFERENT value than this capture: world changed, or stale learning. */ + public final int changed; + + Coverage(int alreadyKnown, int newInformation, int changed) { + this.alreadyKnown = alreadyKnown; + this.newInformation = newInformation; + this.changed = changed; + } + + public int total() { + return alreadyKnown + newInformation + changed; + } + + /** Percentage of this scene's static-map errors already covered before arriving. 0 when nothing conflicts. */ + public int alreadyKnownPercent() { + final int t = total(); + return t == 0 ? 0 : (int) Math.round(100.0 * alreadyKnown / t); + } + } + + /** + * Compares the capture against the overlay as it stood BEFORE this scene was merged in. + * + * @param priorView the overlay view pinned before the merge; {@code null} means nothing was learned + * yet, so every disagreement counts as new information + */ + public static Coverage coverage(LiveCollisionSnapshot snapshot, SplitFlagMap staticMap, + LiveCollisionView priorView) { + if (snapshot == null || staticMap == null) { + return new Coverage(0, 0, 0); + } + int alreadyKnown = 0; + int newInformation = 0; + int changed = 0; + final int baseX = snapshot.getBaseX(); + final int baseY = snapshot.getBaseY(); + for (int z = 0; z < snapshot.getPlaneCount(); z++) { + for (int ly = 0; ly < SCENE_SIZE; ly++) { + for (int lx = 0; lx < SCENE_SIZE; lx++) { + final int x = baseX + lx; + final int y = baseY + ly; + for (int flag = LiveCollisionSnapshot.FLAG_NORTH; flag <= LiveCollisionSnapshot.FLAG_EAST; flag++) { + final Boolean live = snapshot.edge(x, y, z, flag); + if (live == null || live == staticMap.get(x, y, z, flag)) { + continue; // unknown, or static was right — nothing for the store to carry + } + final Boolean known = priorView == null ? null : priorView.edge(x, y, z, flag); + if (known == null) { + newInformation++; + } else if (known.equals(live)) { + alreadyKnown++; + } else { + changed++; + } + } + } + } + } + return new Coverage(alreadyKnown, newInformation, changed); + } + private LiveCollisionConflicts() { } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveRouteValidator.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveRouteValidator.java index 88341e6174c..ba6da489968 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveRouteValidator.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveRouteValidator.java @@ -4,6 +4,7 @@ import net.runelite.client.plugins.microbot.shortestpath.pathfinder.CollisionMap; import java.util.List; +import java.util.function.BiPredicate; /** * Validates the walking steps of an in-progress route against a {@link CollisionMap}, so the walker can @@ -50,6 +51,21 @@ public static int nearestIndex(List path, WorldPoint player) { * route is clear. Caller must have pinned the map's snapshot ({@link CollisionMap#beginSearch()}). */ public static int firstBlockedStep(List path, int fromIndex, int lookahead, CollisionMap map) { + return firstBlockedStep(path, fromIndex, lookahead, map, null); + } + + /** + * @param transportStep answers whether the {@code a -> b} step was planned as a CATALOG TRANSPORT. + * The plane/adjacency heuristics above cannot see one class of transport: a + * door transport joins two ADJACENT SAME-PLANE tiles, so its step is + * indistinguishable from walking — and while shut it reads as blocked, which + * made this validator recalculate the route out from under the walker as it + * stood at the door handling it (observed twice, both catalog transport + * doors). A transport edge's "blocked" is its normal shut state; the runtime + * executor owns it, and it is never this validator's business. + */ + public static int firstBlockedStep(List path, int fromIndex, int lookahead, CollisionMap map, + BiPredicate transportStep) { if (path == null || map == null) { return -1; } @@ -68,6 +84,9 @@ public static int firstBlockedStep(List path, int fromIndex, int loo if (Math.abs(dx) > 1 || Math.abs(dy) > 1) { continue; // non-adjacent: a transport jump, not a walking step } + if (transportStep != null && transportStep.test(a, b)) { + continue; // planned door-transport edge: shut is its normal state, the executor owns it + } if (!map.canStep(a.getX(), a.getY(), a.getPlane(), dx, dy)) { return i; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportInjection.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportInjection.java index b259fccdade..874f0e01bf6 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportInjection.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportInjection.java @@ -4,7 +4,6 @@ import net.runelite.client.plugins.microbot.shortestpath.Transport; import net.runelite.client.plugins.microbot.shortestpath.TransportType; import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; -import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; import net.runelite.client.plugins.microbot.util.walker.WebWalkLog; import net.runelite.client.plugins.microbot.shortestpath.PrimitiveIntHashMap; @@ -13,6 +12,7 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Predicate; /** * Pathfinder injection for Leagues Area and catalog transports. @@ -26,14 +26,14 @@ private LeaguesTransportInjection() private static volatile EnumSet lastInjectedUnlockedForBlacklistPrune = null; static void injectLeaguesTransports( - PathfinderConfig pathfinderConfig, + Predicate transportUsable, Rs2LeaguesTransport.LeaguesContext ctx, Set usableTeleports, Map> transports, PrimitiveIntHashMap> transportsPacked, Map typeStats) { - if (pathfinderConfig == null || ctx == null || !ctx.isActive() || ctx.getUnlockedRegions().isEmpty() + if (transportUsable == null || ctx == null || !ctx.isActive() || ctx.getUnlockedRegions().isEmpty() || usableTeleports == null || transports == null || transportsPacked == null || typeStats == null) { return; @@ -57,8 +57,8 @@ static void injectLeaguesTransports( // Uses same unlock snapshot as inject below (tickLeaguesCalibration still rate-limits standalone probes). LeaguesTransportTeleport.calibrateMissingLandingsAsync(unlockedNow); - injectLeaguesAreaTeleports(pathfinderConfig, ctx, ctx.getUnlockedRegions(), usableTeleports, typeStats); - injectLeaguesCatalogTransports(pathfinderConfig, ctx, ctx.getUnlockedRegions(), usableTeleports, transports, transportsPacked, typeStats); + injectLeaguesAreaTeleports(transportUsable, ctx.getUnlockedRegions(), usableTeleports, typeStats); + injectLeaguesCatalogTransports(transportUsable, ctx.getUnlockedRegions(), usableTeleports, transports, transportsPacked, typeStats); } private static boolean mergeOriginlessTeleportByBestDuration(Set usableTeleports, Transport candidate) @@ -90,8 +90,7 @@ private static boolean mergeOriginlessTeleportByBestDuration(Set usab } private static void injectLeaguesAreaTeleports( - PathfinderConfig pathfinderConfig, - Rs2LeaguesTransport.LeaguesContext ctx, + Predicate transportUsable, EnumSet unlockedLeaguesRegions, Set usableTeleports, Map typeStats) @@ -115,7 +114,7 @@ private static void injectLeaguesAreaTeleports( true, 31, java.util.Collections.emptySet()); - if (!pathfinderConfig.isTransportUsableWithLeaguesContext(t, ctx)) + if (!transportUsable.test(t)) { continue; } @@ -136,8 +135,7 @@ private static void injectLeaguesAreaTeleports( } private static void injectLeaguesCatalogTransports( - PathfinderConfig pathfinderConfig, - Rs2LeaguesTransport.LeaguesContext ctx, + Predicate transportUsable, EnumSet unlockedLeaguesRegions, Set usableTeleports, Map> transports, @@ -156,7 +154,7 @@ private static void injectLeaguesCatalogTransports( continue; } - if (!pathfinderConfig.isTransportUsableWithLeaguesContext(t, ctx)) + if (!transportUsable.test(t)) { continue; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2LeaguesTransport.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2LeaguesTransport.java index 09fdec97b98..00b935cfd7d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2LeaguesTransport.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2LeaguesTransport.java @@ -2,18 +2,18 @@ import lombok.extern.slf4j.Slf4j; import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.ShortestPathPlugin; import net.runelite.client.plugins.microbot.shortestpath.Transport; import net.runelite.client.plugins.microbot.shortestpath.TransportType; -import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; import net.runelite.client.plugins.microbot.shortestpath.PrimitiveIntHashMap; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.text.Rs2TextSanitizer; +import net.runelite.client.plugins.microbot.util.walker.Rs2PathApi; import java.util.EnumSet; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Predicate; import java.util.regex.Matcher; /** @@ -198,11 +198,7 @@ public static boolean isTransportAllowed(LeaguesContext ctx, Transport transport public static void invalidateContext() { - PathfinderConfig cfg = ShortestPathPlugin.pathfinderConfig; - if (cfg != null) - { - cfg.invalidateTransportRefreshCache(); - } + Rs2PathApi.invalidateTransportRefreshCache(); } public static boolean isDestinationBlacklisted(int packedWorldPoint) @@ -250,7 +246,7 @@ public static java.util.List loadCatalogTransports(EnumSet transportUsable, LeaguesContext ctx, Set usableTeleports, Map> transports, @@ -258,7 +254,7 @@ public static void injectLeaguesTransports( Map typeStats) { LeaguesTransportInjection.injectLeaguesTransports( - pathfinderConfig, ctx, usableTeleports, transports, transportsPacked, typeStats); + transportUsable, ctx, usableTeleports, transports, transportsPacked, typeStats); } public static LeaguesRegion parseRegionName(String regionNameRaw) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Staff.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Staff.java index 64e84005308..338f836f1f6 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Staff.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Staff.java @@ -6,8 +6,10 @@ import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -40,7 +42,10 @@ public enum Rs2Staff { MYSTIC_MUD_STAFF(ItemID.MYSTIC_MUD_STAFF, List.of(Runes.WATER, Runes.EARTH)), MYSTIC_SMOKE_STAFF(ItemID.MYSTIC_SMOKE_BATTLESTAFF, List.of(Runes.AIR, Runes.FIRE)), MYSTIC_STEAM_STAFF(ItemID.MYSTIC_STEAM_BATTLESTAFF, List.of(Runes.WATER, Runes.FIRE)), - TWINFLAME_STAFF(ItemID.TWINFLAME_STAFF, List.of(Runes.FIRE, Runes.WATER)); + TWINFLAME_STAFF(ItemID.TWINFLAME_STAFF, List.of(Runes.FIRE, Runes.WATER)), + BRYOPHYTAS_STAFF(ItemID.NATURE_STAFF_CHARGED, List.of(Runes.NATURE)), + SHADOWFLAME_QUADRANT(ItemID.SHADOWFLAME_QUADRANT, + List.of(Runes.AIR, Runes.WATER, Runes.EARTH, Runes.FIRE)); private final int itemID; private final List runes; @@ -49,7 +54,22 @@ public enum Rs2Staff { .filter(s -> s != NONE) .collect(Collectors.toMap(Rs2Staff::getItemID, Function.identity())); - static Rs2Staff byItemId(int itemID) { + public boolean provides(Runes rune) { + if (rune == null) return false; + if (runes.contains(rune)) return true; + Runes[] baseRunes = rune.getBaseRunes(); + return baseRunes.length > 0 && runes.containsAll(Arrays.asList(baseRunes)); + } + + public static Set itemIdsProviding(Runes rune) { + LinkedHashSet itemIds = Arrays.stream(values()) + .filter(staff -> staff != NONE && staff.provides(rune)) + .map(Rs2Staff::getItemID) + .collect(Collectors.toCollection(LinkedHashSet::new)); + return Collections.unmodifiableSet(itemIds); + } + + public static Rs2Staff byItemId(int itemID) { return BY_ITEM_ID.getOrDefault(itemID, NONE); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Tome.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Tome.java index 7a6fdadc740..51da5e6f34f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Tome.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Tome.java @@ -6,10 +6,13 @@ import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; + @Getter @RequiredArgsConstructor public enum Rs2Tome { @@ -27,7 +30,22 @@ public enum Rs2Tome { .filter(t -> t != NONE) .collect(Collectors.toMap(Rs2Tome::getItemID, Function.identity())); - static Rs2Tome byItemId(int itemID) { + public boolean provides(Runes rune) { + if (rune == null) return false; + if (runes.contains(rune)) return true; + Runes[] baseRunes = rune.getBaseRunes(); + return baseRunes.length > 0 && runes.containsAll(Arrays.asList(baseRunes)); + } + + public static Set itemIdsProviding(Runes rune) { + LinkedHashSet itemIds = Arrays.stream(values()) + .filter(tome -> tome != NONE && tome.provides(rune)) + .map(Rs2Tome::getItemID) + .collect(Collectors.toCollection(LinkedHashSet::new)); + return Collections.unmodifiableSet(itemIds); + } + + public static Rs2Tome byItemId(int itemID) { return BY_ITEM_ID.getOrDefault(itemID, NONE); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApi.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApi.java index c22ee17ba07..fd93dd2ac0b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApi.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApi.java @@ -102,6 +102,25 @@ public static Object getPathfinderMutex() // ------------------------------------------------------------------ /** @return the shared pathfinder configuration (transports, restrictions, toggles). */ + /** + * Invalidate the planner's transport refresh cache so the next plan re-evaluates transport + * availability (league relics and similar unlocks change what is usable without any + * inventory change). + */ + public static boolean invalidateTransportRefreshCache() + { + PathfinderConfig config = getPathfinderConfig(); + if (config == null) + { + return false; + } + synchronized (getPathfinderMutex()) + { + config.invalidateTransportRefreshCache(); + } + return true; + } + public static PathfinderConfig getPathfinderConfig() { return ShortestPathPlugin.getPathfinderConfig(); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportPlanningPolicy.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportPlanningPolicy.java new file mode 100644 index 00000000000..792b3c778fd --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2TransportPlanningPolicy.java @@ -0,0 +1,28 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportExecutionRegistry; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.TransportPlanningPolicy; + +/** Microbot executor capabilities projected into the local planner's catalog-admission seam. */ +public final class Rs2TransportPlanningPolicy implements TransportPlanningPolicy +{ + public static final Rs2TransportPlanningPolicy INSTANCE = new Rs2TransportPlanningPolicy(); + + private Rs2TransportPlanningPolicy() + { + } + + @Override + public boolean isAdmitted(Transport transport) + { + return TransportExecutionRegistry.canExecute(transport); + } + + @Override + public boolean isZeroRuneSpell(Transport transport) + { + return transport != null + && TransportExecutionRegistry.homeTeleportFor(transport.getDisplayInfo()).isPresent(); + } +} diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/agility_shortcuts.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/agility_shortcuts.tsv index a5fed36fd46..83b7252114e 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/agility_shortcuts.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/agility_shortcuts.tsv @@ -5,16 +5,16 @@ 2556 3074 1 2556 3075 0 Jump;Wall;17048 4 Agility 2936 3355 0 2934 3355 0 Climb-over;Crumbling wall;24222 5 Agility 2934 3355 0 2936 3355 0 Climb-over;Crumbling wall;24222 5 Agility -3246 3179 0 3259 3179 0 Grapple;Broken Raft;17068 8 Agility;37 Ranged;19 Strength 9419 -3259 3179 0 3246 3179 0 Grapple;Broken Raft;17068 8 Agility;37 Ranged;19 Strength 9419 +3246 3179 0 3259 3179 0 Grapple;Broken Raft;17068 8 Agility;37 Ranged;19 Strength CROSSBOW=1&MITH_GRAPPLE=1 +3259 3179 0 3246 3179 0 Grapple;Broken Raft;17068 8 Agility;37 Ranged;19 Strength CROSSBOW=1&MITH_GRAPPLE=1 2546 2873 0 2546 2871 0 Climb;Rocks;31757 10 Agility 2546 2871 0 2546 2873 0 Climb;Rocks;31757 10 Agility -2766 3665 0 2766 3663 0 Use;Rope -> Boulder;5842 10 Agility 954 260>0 10 -3033 3390 0 3033 3389 1 Grapple;Wall;17049 11 Agility;19 Ranged;37 Strength 9419 -3032 3388 0 3032 3389 1 Grapple;Wall;17050 11 Agility;19 Ranged;37 Strength 9419 -2820 3635 0 2822 3635 0 Climb;Rocks;3748 15 Agility 3105 23413 -2857 3611 0 2857 3613 0 Climb;Rocks;3748 15 Agility 3105 23413 -2856 3611 0 2856 3613 0 Climb;Rocks;3748 15 Agility 3105 23413 +2766 3665 0 2766 3663 0 Use;Rope -> Boulder;5842 10 Agility ROPE=1 260>0 10 +3033 3390 0 3033 3389 1 Grapple;Wall;17049 11 Agility;19 Ranged;37 Strength CROSSBOW=1&MITH_GRAPPLE=1 +3032 3388 0 3032 3389 1 Grapple;Wall;17050 11 Agility;19 Ranged;37 Strength CROSSBOW=1&MITH_GRAPPLE=1 +2820 3635 0 2822 3635 0 Climb;Rocks;3748 15 Agility CLIMBING_BOOTS=1 +2857 3611 0 2857 3613 0 Climb;Rocks;3748 15 Agility CLIMBING_BOOTS=1 +2856 3611 0 2856 3613 0 Climb;Rocks;3748 15 Agility CLIMBING_BOOTS=1 2575 3107 0 2575 3112 0 Climb-under;Castle wall;16519 16 Agility 2575 3112 0 2575 3107 0 Climb-into;Hole;16520 16 Agility 2603 3477 0 2598 3477 0 Walk-across;Log balance;23274 20 Agility @@ -38,17 +38,17 @@ 3153 3363 0 3152 3363 0 Jump-onto;Stepping stone;16533 31 Agility 4 3152 3363 0 3151 3363 0 Jump-onto;Stepping stone;16533 31 Agility 4 3151 3363 0 3150 3363 0 Jump-onto;Stepping stone;16533 31 Agility 4 -2866 3428 0 2869 3428 0 Grapple;Rocks;17042 32 Agility;35 Ranged;35 Strength 9419 +2866 3428 0 2869 3428 0 Grapple;Rocks;17042 32 Agility;35 Ranged;35 Strength CROSSBOW=1&MITH_GRAPPLE=1 2602 3336 0 2598 3336 0 Walk-across;Log balance;16548 33 Agility 2598 3336 0 2602 3336 0 Walk-across;Log balance;16546 33 Agility 2599 3337 0 2602 3336 0 Walk-across;Log balance;16546 33 Agility -2841 3427 0 2841 3433 0 Grapple Crossbow;Tree;17062 36 Agility;39 Ranged;22 Strength 9419 +2841 3427 0 2841 3433 0 Grapple Crossbow;Tree;17062 36 Agility;39 Ranged;22 Strength CROSSBOW=1&MITH_GRAPPLE=1 2486 3515 0 2489 3521 0 Climb;Rocks;16534 37 Agility The Grand Tree 9 2489 3521 0 2486 3515 0 Climb;Rocks;16535 37 Agility The Grand Tree 9 3306 3315 0 3302 3315 0 Climb;Rocks;16549 38 Agility 3302 3315 0 3306 3315 0 Climb;Rocks;16550 38 Agility -2556 3072 0 2556 3073 1 Grapple;Wall;17047 39 Agility;21 Ranged;38 Strength 9419 -2556 3075 0 2556 3074 1 Grapple;Wall;17047 39 Agility;21 Ranged;38 Strength 9419 +2556 3072 0 2556 3073 1 Grapple;Wall;17047 39 Agility;21 Ranged;38 Strength CROSSBOW=1&MITH_GRAPPLE=1 +2556 3075 0 2556 3074 1 Grapple;Wall;17047 39 Agility;21 Ranged;38 Strength CROSSBOW=1&MITH_GRAPPLE=1 2872 3671 0 2869 3671 0 Climb;Rocks;16521 41 Agility 2869 3671 0 2872 3671 0 Climb;Rocks;16521 41 Agility 3070 3260 0 3064 3260 0 Climb-into;Underwall tunnel;19036 42 Agility @@ -89,10 +89,10 @@ 1769 3849 0 1774 3849 0 Climb;Rocks;27988 52 Agility 1774 3849 0 1769 3849 0 Climb;Rocks;27987 52 Agility 2998 3916 0 2998 3931 0 Open;Door;23555 52 Agility -2874 3133 0 2874 3127 0 Grapple;Strong Tree;17074 53 Agility;42 Ranged;21 Strength 9419 -2874 3127 0 2874 3133 0 Grapple;Strong Tree;17074 53 Agility;42 Ranged;21 Strength 9419 -2874 3136 0 2874 3142 0 Grapple;Strong Tree;17074 53 Agility;42 Ranged;21 Strength 9419 -2874 3142 0 2874 3136 0 Grapple;Strong Tree;17074 53 Agility;42 Ranged;21 Strength 9419 +2874 3133 0 2874 3127 0 Grapple;Strong Tree;17074 53 Agility;42 Ranged;21 Strength CROSSBOW=1&MITH_GRAPPLE=1 +2874 3127 0 2874 3133 0 Grapple;Strong Tree;17074 53 Agility;42 Ranged;21 Strength CROSSBOW=1&MITH_GRAPPLE=1 +2874 3136 0 2874 3142 0 Grapple;Strong Tree;17074 53 Agility;42 Ranged;21 Strength CROSSBOW=1&MITH_GRAPPLE=1 +2874 3142 0 2874 3136 0 Grapple;Strong Tree;17074 53 Agility;42 Ranged;21 Strength CROSSBOW=1&MITH_GRAPPLE=1 2573 3859 0 2575 3861 0 Cross;Stepping stone;11768 55 Agility 2575 3861 0 2573 3859 0 Cross;Stepping stone;11768 55 Agility 2688 3697 0 2691 3697 0 Jump;Broken Fence;544 57 Agility diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/canoes.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/canoes.tsv index 7fe318264d4..3ae62528631 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/canoes.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/canoes.tsv @@ -1,67 +1,67 @@ -# Origin Destination menuOption menuTarget objectID Skills Item IDs Duration Display info +# Origin Destination menuOption menuTarget objectID Skills Items Duration Display info # River Lum chain # Edgeville -3132 3510 0 3109 3415 0 Paddle Canoe;Canoe Station;12166 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Barbarian Village -3132 3510 0 3199 3344 0 Paddle Canoe;Canoe Station;12166 27 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Champions Guild -3132 3510 0 3240 3242 0 Paddle Canoe;Canoe Station;12166 42 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Lumbridge -3132 3510 0 3154 3638 0 Paddle Canoe;Canoe Station;12166 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 20 Ferox Enclave -3132 3510 0 3141 3796 0 Paddle Canoe;Canoe Station;12166 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 20 Wilderness Pond +3132 3510 0 3109 3415 0 Paddle Canoe;Canoe Station;12166 12 Woodcutting AXE=1 30 Barbarian Village +3132 3510 0 3199 3344 0 Paddle Canoe;Canoe Station;12166 27 Woodcutting AXE=1 30 Champions Guild +3132 3510 0 3240 3242 0 Paddle Canoe;Canoe Station;12166 42 Woodcutting AXE=1 30 Lumbridge +3132 3510 0 3154 3638 0 Paddle Canoe;Canoe Station;12166 57 Woodcutting AXE=1 20 Ferox Enclave +3132 3510 0 3141 3796 0 Paddle Canoe;Canoe Station;12166 57 Woodcutting AXE=1 20 Wilderness Pond # Barbarian Village -3112 3411 0 3199 3344 0 Paddle Canoe;Canoe Station;12165 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Champions Guild -3112 3411 0 3128 3503 0 Paddle Canoe;Canoe Station;12165 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Edgeville -3112 3411 0 3240 3242 0 Paddle Canoe;Canoe Station;12165 27 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Lumbridge -3112 3411 0 3154 3638 0 Paddle Canoe;Canoe Station;12165 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 20 Ferox Enclave -3112 3411 0 3141 3796 0 Paddle Canoe;Canoe Station;12165 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 20 Wilderness Pond +3112 3411 0 3199 3344 0 Paddle Canoe;Canoe Station;12165 12 Woodcutting AXE=1 30 Champions Guild +3112 3411 0 3128 3503 0 Paddle Canoe;Canoe Station;12165 12 Woodcutting AXE=1 30 Edgeville +3112 3411 0 3240 3242 0 Paddle Canoe;Canoe Station;12165 27 Woodcutting AXE=1 30 Lumbridge +3112 3411 0 3154 3638 0 Paddle Canoe;Canoe Station;12165 57 Woodcutting AXE=1 20 Ferox Enclave +3112 3411 0 3141 3796 0 Paddle Canoe;Canoe Station;12165 57 Woodcutting AXE=1 20 Wilderness Pond # Champions' Guild -3202 3343 0 3240 3242 0 Paddle Canoe;Canoe Station;12164 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Lumbridge -3202 3343 0 3109 3415 0 Paddle Canoe;Canoe Station;12164 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Barbarian Village -3202 3343 0 3128 3503 0 Paddle Canoe;Canoe Station;12164 27 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Edgeville -3202 3343 0 3154 3638 0 Paddle Canoe;Canoe Station;12164 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 20 Ferox Enclave -3202 3343 0 3141 3796 0 Paddle Canoe;Canoe Station;12164 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 20 Wilderness Pond +3202 3343 0 3240 3242 0 Paddle Canoe;Canoe Station;12164 12 Woodcutting AXE=1 30 Lumbridge +3202 3343 0 3109 3415 0 Paddle Canoe;Canoe Station;12164 12 Woodcutting AXE=1 30 Barbarian Village +3202 3343 0 3128 3503 0 Paddle Canoe;Canoe Station;12164 27 Woodcutting AXE=1 30 Edgeville +3202 3343 0 3154 3638 0 Paddle Canoe;Canoe Station;12164 57 Woodcutting AXE=1 20 Ferox Enclave +3202 3343 0 3141 3796 0 Paddle Canoe;Canoe Station;12164 57 Woodcutting AXE=1 20 Wilderness Pond # Lumbridge -3243 3237 0 3199 3344 0 Paddle Canoe;Canoe Station;12163 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Champions Guild -3243 3237 0 3109 3415 0 Paddle Canoe;Canoe Station;12163 27 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Barbarian Village -3243 3237 0 3128 3503 0 Paddle Canoe;Canoe Station;12163 42 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Edgeville -3243 3237 0 3154 3638 0 Paddle Canoe;Canoe Station;12163 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 20 Ferox Enclave -3243 3237 0 3141 3796 0 Paddle Canoe;Canoe Station;12163 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 20 Wilderness Pond +3243 3237 0 3199 3344 0 Paddle Canoe;Canoe Station;12163 12 Woodcutting AXE=1 30 Champions Guild +3243 3237 0 3109 3415 0 Paddle Canoe;Canoe Station;12163 27 Woodcutting AXE=1 30 Barbarian Village +3243 3237 0 3128 3503 0 Paddle Canoe;Canoe Station;12163 42 Woodcutting AXE=1 30 Edgeville +3243 3237 0 3154 3638 0 Paddle Canoe;Canoe Station;12163 57 Woodcutting AXE=1 20 Ferox Enclave +3243 3237 0 3141 3796 0 Paddle Canoe;Canoe Station;12163 57 Woodcutting AXE=1 20 Wilderness Pond # Ferox Enclave -3154 3630 0 3128 3503 0 Paddle Canoe;Canoe Station;39638 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 20 Edgeville -3154 3630 0 3109 3415 0 Paddle Canoe;Canoe Station;39638 27 Woodcutting 1351;1349;1361;1353;1355;1357;1359 20 Barbarian Village -3154 3630 0 3199 3344 0 Paddle Canoe;Canoe Station;39638 42 Woodcutting 1351;1349;1361;1353;1355;1357;1359 20 Champions Guild -3154 3630 0 3240 3242 0 Paddle Canoe;Canoe Station;39638 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 20 Lumbridge -3154 3630 0 3141 3796 0 Paddle Canoe;Canoe Station;39638 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 20 Wilderness Pond +3154 3630 0 3128 3503 0 Paddle Canoe;Canoe Station;39638 12 Woodcutting AXE=1 20 Edgeville +3154 3630 0 3109 3415 0 Paddle Canoe;Canoe Station;39638 27 Woodcutting AXE=1 20 Barbarian Village +3154 3630 0 3199 3344 0 Paddle Canoe;Canoe Station;39638 42 Woodcutting AXE=1 20 Champions Guild +3154 3630 0 3240 3242 0 Paddle Canoe;Canoe Station;39638 57 Woodcutting AXE=1 20 Lumbridge +3154 3630 0 3141 3796 0 Paddle Canoe;Canoe Station;39638 57 Woodcutting AXE=1 20 Wilderness Pond # River Dougne chain # Castle Wars -2439 3135 0 2483 3188 0 Paddle Canoe;Canoe Station;60845 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Tree Gnome Village -2439 3135 0 2577 3261 0 Paddle Canoe;Canoe Station;60845 27 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 The Clock Tower -2439 3135 0 2571 3360 0 Paddle Canoe;Canoe Station;60845 42 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Chaos Druid Tower -2439 3135 0 2523 3408 0 Paddle Canoe;Canoe Station;60845 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Tree Gnome Stronghold +2439 3135 0 2483 3188 0 Paddle Canoe;Canoe Station;60845 12 Woodcutting AXE=1 30 Tree Gnome Village +2439 3135 0 2577 3261 0 Paddle Canoe;Canoe Station;60845 27 Woodcutting AXE=1 30 The Clock Tower +2439 3135 0 2571 3360 0 Paddle Canoe;Canoe Station;60845 42 Woodcutting AXE=1 30 Chaos Druid Tower +2439 3135 0 2523 3408 0 Paddle Canoe;Canoe Station;60845 57 Woodcutting AXE=1 30 Tree Gnome Stronghold # Tree Gnome Village -2485 3192 0 2436 3134 0 Paddle Canoe;Canoe Station;60846 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Castle Wars -2485 3192 0 2577 3261 0 Paddle Canoe;Canoe Station;60846 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 The Clock Tower -2485 3192 0 2571 3360 0 Paddle Canoe;Canoe Station;60846 27 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Chaos Druid Tower -2485 3192 0 2523 3408 0 Paddle Canoe;Canoe Station;60846 42 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Tree Gnome Stronghold +2485 3192 0 2436 3134 0 Paddle Canoe;Canoe Station;60846 12 Woodcutting AXE=1 30 Castle Wars +2485 3192 0 2577 3261 0 Paddle Canoe;Canoe Station;60846 12 Woodcutting AXE=1 30 The Clock Tower +2485 3192 0 2571 3360 0 Paddle Canoe;Canoe Station;60846 27 Woodcutting AXE=1 30 Chaos Druid Tower +2485 3192 0 2523 3408 0 Paddle Canoe;Canoe Station;60846 42 Woodcutting AXE=1 30 Tree Gnome Stronghold # The Clock Tower -2579 3260 0 2436 3134 0 Paddle Canoe;Canoe Station;60847 27 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Castle Wars -2579 3260 0 2483 3188 0 Paddle Canoe;Canoe Station;60847 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Tree Gnome Village -2579 3260 0 2571 3360 0 Paddle Canoe;Canoe Station;60847 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Chaos Druid Tower -2579 3260 0 2523 3408 0 Paddle Canoe;Canoe Station;60847 27 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Tree Gnome Stronghold +2579 3260 0 2436 3134 0 Paddle Canoe;Canoe Station;60847 27 Woodcutting AXE=1 30 Castle Wars +2579 3260 0 2483 3188 0 Paddle Canoe;Canoe Station;60847 12 Woodcutting AXE=1 30 Tree Gnome Village +2579 3260 0 2571 3360 0 Paddle Canoe;Canoe Station;60847 12 Woodcutting AXE=1 30 Chaos Druid Tower +2579 3260 0 2523 3408 0 Paddle Canoe;Canoe Station;60847 27 Woodcutting AXE=1 30 Tree Gnome Stronghold # Chaos Druid Tower -2573 3358 0 2436 3134 0 Paddle Canoe;Canoe Station;60848 42 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Castle Wars -2573 3358 0 2483 3188 0 Paddle Canoe;Canoe Station;60848 27 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Tree Gnome Village -2573 3358 0 2577 3261 0 Paddle Canoe;Canoe Station;60848 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 The Clock Tower -2573 3358 0 2523 3408 0 Paddle Canoe;Canoe Station;60848 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Tree Gnome Stronghold +2573 3358 0 2436 3134 0 Paddle Canoe;Canoe Station;60848 42 Woodcutting AXE=1 30 Castle Wars +2573 3358 0 2483 3188 0 Paddle Canoe;Canoe Station;60848 27 Woodcutting AXE=1 30 Tree Gnome Village +2573 3358 0 2577 3261 0 Paddle Canoe;Canoe Station;60848 12 Woodcutting AXE=1 30 The Clock Tower +2573 3358 0 2523 3408 0 Paddle Canoe;Canoe Station;60848 12 Woodcutting AXE=1 30 Tree Gnome Stronghold # Tree Gnome Stronghold -2525 3408 0 2436 3134 0 Paddle Canoe;Canoe Station;60849 57 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Castle Wars -2525 3408 0 2483 3188 0 Paddle Canoe;Canoe Station;60849 42 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Tree Gnome Village -2525 3408 0 2577 3261 0 Paddle Canoe;Canoe Station;60849 27 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 The Clock Tower -2525 3408 0 2571 3360 0 Paddle Canoe;Canoe Station;60849 12 Woodcutting 1351;1349;1361;1353;1355;1357;1359 30 Chaos Druid Tower +2525 3408 0 2436 3134 0 Paddle Canoe;Canoe Station;60849 57 Woodcutting AXE=1 30 Castle Wars +2525 3408 0 2483 3188 0 Paddle Canoe;Canoe Station;60849 42 Woodcutting AXE=1 30 Tree Gnome Village +2525 3408 0 2577 3261 0 Paddle Canoe;Canoe Station;60849 27 Woodcutting AXE=1 30 The Clock Tower +2525 3408 0 2571 3360 0 Paddle Canoe;Canoe Station;60849 12 Woodcutting AXE=1 30 Chaos Druid Tower diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/quetzals.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/quetzals.tsv index 86bb2359316..168c7c2de89 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/quetzals.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/quetzals.tsv @@ -1,4 +1,4 @@ -# Origin Destination menuOption menuTarget objectID Quests Duration Display info Varplayers +# Origin Destination menuOption menuTarget objectID Quests Duration Display info VarPlayers 1389 2901 0 Travel Renu 13350 Twilight's Promise 6 1411 3361 0 Travel Renu 13350 Twilight's Promise 6 1697 3140 0 Travel Renu 13350 Twilight's Promise 6 diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/teleportation_items.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/teleportation_items.tsv index acb17c152b0..76756471520 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/teleportation_items.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/teleportation_items.tsv @@ -241,25 +241,25 @@ 3239 6077 0 13280;13342 2187=7 Y F 19 4 Max cape: Home # 8 - Hosidius 1740 3517 0 13280;13342 2187=8 Y F 19 4 Max cape: Home -2952 3224 0 2376 Total 13280 13342 Y F 20 4 Max cape: POH Portals: Rimmington -2892 3465 0 2376 Total 13280 13342 Y F 20 4 Max cape: POH Portals: Taverley -3339 3001 0 2376 Total 13280 13342 Y F 20 4 Max cape: POH Portals: Pollnivneach -1743 3517 0 2376 Total 13280 13342 Y F 20 4 Max cape: POH Portals: Hosidius -2669 3629 0 2376 Total 13280 13342 Y F 20 4 Max cape: POH Portals: Rellekka -2756 3176 0 2376 Total 13280 13342 Y F 20 4 Max cape: POH Portals: Brimhaven -2545 3097 0 2376 Total 13280 13342 Y F 20 4 Max cape: POH Portals: Yanille -3239 6077 0 2376 Total 13280 13342 Y F 20 4 Max cape: POH Portals: Prifddinas -2865 3546 0 2376 Total 13280 13342 Y F 20 4 Max cape: Warriors' Guild -2604 3401 0 2376 Total 13280 13342 Y F 20 4 Max cape: Fishing Teleports: Fishing Guild -2504 3484 0 2376 Total 13280 13342 Y F 20 4 Max cape: Fishing Teleports: Otto's Grotto -2931 3286 0 2376 Total 13280 13342 Y F 20 4 Max cape: Crafting Guild -2556 2917 0 2376 Total 13280 13342 Y T 20 4 Max cape: Other Teleports: Feldip Hills -3144 3772 0 2376 Total 13280 13342 Y T 20 4 Max cape: Other Teleports: Black chinchompa -1558 3046 0 2376 Total 13280 13342 Y F 20 4 Max cape: Other Teleports: Hunter Guild -1248 3725 0 2376 Total 13280 13342 Y F 20 4 Max cape: Other Teleports: Farming Guild -3048 2972 0 2376 Total 13280 13342 Y F 20 4 Max cape: Other Teleports: The Pandemonium +2952 3224 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: POH Portals: Rimmington +2892 3465 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: POH Portals: Taverley +3339 3001 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: POH Portals: Pollnivneach +1743 3517 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: POH Portals: Hosidius +2669 3629 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: POH Portals: Rellekka +2756 3176 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: POH Portals: Brimhaven +2545 3097 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: POH Portals: Yanille +3239 6077 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: POH Portals: Prifddinas +2865 3546 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: Warriors' Guild +2604 3401 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: Fishing Teleports: Fishing Guild +2504 3484 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: Fishing Teleports: Otto's Grotto +2931 3286 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: Crafting Guild +2556 2917 0 2376 Total 13280=1||13342=1 Y T 20 4 Max cape: Other Teleports: Feldip Hills +3144 3772 0 2376 Total 13280=1||13342=1 Y T 20 4 Max cape: Other Teleports: Black chinchompa +1558 3046 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: Other Teleports: Hunter Guild +1248 3725 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: Other Teleports: Farming Guild +3048 2972 0 2376 Total 13280=1||13342=1 Y F 20 4 Max cape: Other Teleports: The Pandemonium # Quest point cape (instead of using the item name we use the action teleport, we use the itemids to verifiy the item) -2729 3348 0 327 Quest 9813 13068 Y F 20 4 Quest point cape: Teleport +2729 3348 0 327 Quest 9813=1||13068=1 Y F 20 4 Quest point cape: Teleport 2689 3547 0 13221;13222 Y F 19 4 Music cape: Teleport 2574 3323 0 13069;19476 Y F 19 4 Achievement diary cape: Two-pints 3302 3122 0 13069;19476 Y F 19 4 Achievement diary cape: Jarr @@ -356,34 +356,34 @@ # Quetzal whistles — charged variants are consumable; the perfected infinite whistle is permanent. # Separate variants preserve Microbot's Inventory (perm) policy while retaining upstream destinations and unlocks. -1389 2901 0 29271 29273 29275 Twilight's Promise Y T 20 4 Quetzal whistle: Aldarin -1411 3361 0 29271 29273 29275 Twilight's Promise Y T 20 4 Quetzal whistle: Auburnvale -1697 3140 0 29271 29273 29275 Twilight's Promise Y T 20 4 Quetzal whistle: Civitas illa Fortis -1585 3053 0 29271 29273 29275 Twilight's Promise Y T 20 4 Quetzal whistle: Hunter Guild -1510 3222 0 29271 29273 29275 Twilight's Promise Y T 20 4 Quetzal whistle: Quetzacalli Gorge -1548 2995 0 29271 29273 29275 Twilight's Promise Y T 20 4 Quetzal whistle: Sunset Coast -1226 3091 0 29271 29273 29275 Twilight's Promise Y T 20 4 Quetzal whistle: Tal Teklan -1437 3171 0 29271 29273 29275 Twilight's Promise Y T 20 4 Quetzal whistle: The Teomat -1779 3111 0 29271 29273 29275 Twilight's Promise 4182&256 Y T 20 4 Quetzal whistle: Fortis Colosseum -1344 3022 0 29271 29273 29275 Twilight's Promise 4182&16384 Y T 20 4 Quetzal whistle: Kastori -1700 3037 0 29271 29273 29275 Twilight's Promise 4182&128 Y T 20 4 Quetzal whistle: Outer Fortis -1670 2933 0 29271 29273 29275 Twilight's Promise 4182&64 Y T 20 4 Quetzal whistle: Colossal Wyrm Remains -1446 3108 0 29271 29273 29275 Twilight's Promise 4182&32 Y T 20 4 Quetzal whistle: Cam Torum -1613 3300 0 29271 29273 29275 Twilight's Promise 4182&2048 Y T 20 4 Quetzal whistle: Salvager Overlook -1389 2901 0 33120 Twilight's Promise Y F 20 4 Quetzal whistle: Aldarin -1411 3361 0 33120 Twilight's Promise Y F 20 4 Quetzal whistle: Auburnvale -1697 3140 0 33120 Twilight's Promise Y F 20 4 Quetzal whistle: Civitas illa Fortis -1585 3053 0 33120 Twilight's Promise Y F 20 4 Quetzal whistle: Hunter Guild -1510 3222 0 33120 Twilight's Promise Y F 20 4 Quetzal whistle: Quetzacalli Gorge -1548 2995 0 33120 Twilight's Promise Y F 20 4 Quetzal whistle: Sunset Coast -1226 3091 0 33120 Twilight's Promise Y F 20 4 Quetzal whistle: Tal Teklan -1437 3171 0 33120 Twilight's Promise Y F 20 4 Quetzal whistle: The Teomat -1779 3111 0 33120 Twilight's Promise 4182&256 Y F 20 4 Quetzal whistle: Fortis Colosseum -1344 3022 0 33120 Twilight's Promise 4182&16384 Y F 20 4 Quetzal whistle: Kastori -1700 3037 0 33120 Twilight's Promise 4182&128 Y F 20 4 Quetzal whistle: Outer Fortis -1670 2933 0 33120 Twilight's Promise 4182&64 Y F 20 4 Quetzal whistle: Colossal Wyrm Remains -1446 3108 0 33120 Twilight's Promise 4182&32 Y F 20 4 Quetzal whistle: Cam Torum -1613 3300 0 33120 Twilight's Promise 4182&2048 Y F 20 4 Quetzal whistle: Salvager Overlook +1389 2901 0 29271=1||29273=1||29275=1 Twilight's Promise Y T 20 4 Quetzal whistle: Aldarin +1411 3361 0 29271=1||29273=1||29275=1 Twilight's Promise Y T 20 4 Quetzal whistle: Auburnvale +1697 3140 0 29271=1||29273=1||29275=1 Twilight's Promise Y T 20 4 Quetzal whistle: Civitas illa Fortis +1585 3053 0 29271=1||29273=1||29275=1 Twilight's Promise Y T 20 4 Quetzal whistle: Hunter Guild +1510 3222 0 29271=1||29273=1||29275=1 Twilight's Promise Y T 20 4 Quetzal whistle: Quetzacalli Gorge +1548 2995 0 29271=1||29273=1||29275=1 Twilight's Promise Y T 20 4 Quetzal whistle: Sunset Coast +1226 3091 0 29271=1||29273=1||29275=1 Twilight's Promise Y T 20 4 Quetzal whistle: Tal Teklan +1437 3171 0 29271=1||29273=1||29275=1 Twilight's Promise Y T 20 4 Quetzal whistle: The Teomat +1779 3111 0 29271=1||29273=1||29275=1 Twilight's Promise 4182&256 Y T 20 4 Quetzal whistle: Fortis Colosseum +1344 3022 0 29271=1||29273=1||29275=1 Twilight's Promise 4182&16384 Y T 20 4 Quetzal whistle: Kastori +1700 3037 0 29271=1||29273=1||29275=1 Twilight's Promise 4182&128 Y T 20 4 Quetzal whistle: Outer Fortis +1670 2933 0 29271=1||29273=1||29275=1 Twilight's Promise 4182&64 Y T 20 4 Quetzal whistle: Colossal Wyrm Remains +1446 3108 0 29271=1||29273=1||29275=1 Twilight's Promise 4182&32 Y T 20 4 Quetzal whistle: Cam Torum +1613 3300 0 29271=1||29273=1||29275=1 Twilight's Promise 4182&2048 Y T 20 4 Quetzal whistle: Salvager Overlook +1389 2901 0 33120=1 Twilight's Promise Y F 20 4 Quetzal whistle: Aldarin +1411 3361 0 33120=1 Twilight's Promise Y F 20 4 Quetzal whistle: Auburnvale +1697 3140 0 33120=1 Twilight's Promise Y F 20 4 Quetzal whistle: Civitas illa Fortis +1585 3053 0 33120=1 Twilight's Promise Y F 20 4 Quetzal whistle: Hunter Guild +1510 3222 0 33120=1 Twilight's Promise Y F 20 4 Quetzal whistle: Quetzacalli Gorge +1548 2995 0 33120=1 Twilight's Promise Y F 20 4 Quetzal whistle: Sunset Coast +1226 3091 0 33120=1 Twilight's Promise Y F 20 4 Quetzal whistle: Tal Teklan +1437 3171 0 33120=1 Twilight's Promise Y F 20 4 Quetzal whistle: The Teomat +1779 3111 0 33120=1 Twilight's Promise 4182&256 Y F 20 4 Quetzal whistle: Fortis Colosseum +1344 3022 0 33120=1 Twilight's Promise 4182&16384 Y F 20 4 Quetzal whistle: Kastori +1700 3037 0 33120=1 Twilight's Promise 4182&128 Y F 20 4 Quetzal whistle: Outer Fortis +1670 2933 0 33120=1 Twilight's Promise 4182&64 Y F 20 4 Quetzal whistle: Colossal Wyrm Remains +1446 3108 0 33120=1 Twilight's Promise 4182&32 Y F 20 4 Quetzal whistle: Cam Torum +1613 3300 0 33120=1 Twilight's Promise 4182&2048 Y F 20 4 Quetzal whistle: Salvager Overlook #Giantsoul Amulet 3174 9898 0 30638 Y T 19 4 Giantsoul Amulet: Bryophyta 6208 6336 0 30638 Y T 19 4 Giantsoul Amulet: Obor diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv index 0a751b9167d..514da412b26 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/transports.tsv @@ -5892,8 +5892,8 @@ # Elemental Workshop odd-looking wall. The steel key ring is not sufficient evidence that the # battered key is stored on it, so these rows deliberately accept only the concrete key item. -2709 3495 0 2709 3496 0 Open;Odd-looking wall;26115 2887 2 -2709 3496 0 2709 3495 0 Open;Odd-looking wall;26115 2887 2 +2709 3495 0 2709 3496 0 Open;Odd-looking wall;26115 2887=1 2 +2709 3496 0 2709 3495 0 Open;Odd-looking wall;26115 2887=1 2 2709 3498 0 2716 9888 0 Climb-down;Staircase;3415 1 2716 9888 0 2709 3497 0 Climb-up;Staircase;3416 1 @@ -5996,12 +5996,12 @@ 3025 3511 1 3026 3511 1 Open;Sturdy door;2339 # Barrows mounds and individual crypt exits (surface destinations are representative mound anchors) -3564 3291 0 3559 9703 3 Dig;Barrow;0 952 Y 3 Ahrim's Barrow -3575 3299 0 3558 9718 3 Dig;Barrow;0 952 Y 3 Dharok's Barrow -3578 3281 0 3534 9706 3 Dig;Barrow;0 952 Y 3 Guthan's Barrow -3567 3274 0 3546 9686 3 Dig;Barrow;0 952 Y 3 Karil's Barrow -3553 3281 0 3566 9683 3 Dig;Barrow;0 952 Y 3 Torag's Barrow -3556 3297 0 3578 9704 3 Dig;Barrow;0 952 Y 3 Verac's Barrow +3564 3291 0 3559 9703 3 Dig;Barrow;0 952=1 Y 3 Ahrim's Barrow +3575 3299 0 3558 9718 3 Dig;Barrow;0 952=1 Y 3 Dharok's Barrow +3578 3281 0 3534 9706 3 Dig;Barrow;0 952=1 Y 3 Guthan's Barrow +3567 3274 0 3546 9686 3 Dig;Barrow;0 952=1 Y 3 Karil's Barrow +3553 3281 0 3566 9683 3 Dig;Barrow;0 952=1 Y 3 Torag's Barrow +3556 3297 0 3578 9704 3 Dig;Barrow;0 952=1 Y 3 Verac's Barrow 3559 9703 3 3564 3291 0 Climb-up;Staircase;20667 Y 1 Ahrim's Barrow exit 3558 9718 3 3575 3299 0 Climb-up;Staircase;20668 Y 1 Dharok's Barrow exit 3534 9706 3 3578 3281 0 Climb-up;Staircase;20669 Y 1 Guthan's Barrow exit diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java index 374a5e0cf6c..e3d0eab7077 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/LiveCollisionTest.java @@ -252,6 +252,7 @@ public void noOverlay_readsIdenticalToStaticMap() { assertEquals(plain.isBlocked(x, y, 0), withEmptyOverlay.isBlocked(x, y, 0)); } } + assertEquals(0L, withEmptyOverlay.getLiveEdgeQueries()); } @Test @@ -287,12 +288,19 @@ public void overlayBlocksAnOpenStaticEdge_andFallsBackOutsideScene() { // overlay wins inside the scene assertTrue("precondition: static edge open", staticMap.n(tx, ty, 0)); assertFalse("overlay must block the edge", live.n(tx, ty, 0)); + assertEquals(1L, live.getLiveEdgeQueries()); // a tile far outside the snapshot falls back to the static map int farX = baseX + 5000; int farY = baseY + 5000; assertEquals(staticMap.n(farX, farY, 0), live.n(farX, farY, 0)); assertEquals(staticMap.e(farX, farY, 0), live.e(farX, farY, 0)); + assertEquals("static fallback must not count as live evidence", 1L, + live.getLiveEdgeQueries()); + + live.beginSearch(); + assertEquals("a new search resets the live evidence counter", 0L, + live.getLiveEdgeQueries()); } // ---- Stage 3: route validation (LiveRouteValidator) ---- diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/RouteClickTargetRegressionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/RouteClickTargetRegressionTest.java index d21f4faeae6..b472f12f9ed 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/RouteClickTargetRegressionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/RouteClickTargetRegressionTest.java @@ -48,7 +48,41 @@ public static void load() { // Computed once: each Pathfinder.run() reloads all transports and, via // CollisionMap.getCachedRegionId, calls Rs2Player.getWorldLocation(), which has no client // thread under test and blocks for its full timeout. - sharedRawPath = computeRawPath(START, GOAL); + sharedRawPath = computeRawPathReachingGoal(START, GOAL); + } + + /** + * Computes the route, and refuses to report a starved run as a route regression. + * + *

{@code calculationCutoffMillis} is a NO-PROGRESS wall-clock guard. Under CPU contention — + * a full-suite run, or a client running alongside the build — the search can be starved into + * returning a best-effort PARTIAL path, and a partial path wanders through tiles the assertions + * below require to be absent. That failure looks exactly like the regression this class exists + * to catch, and it has already been misread as one: a red run here sent an investigation off + * hunting a route-data change that did not exist. + * + *

So: a generous cutoff, one retry, and if the path still does not reach the goal, fail as + * explicitly inconclusive rather than as a route change. + */ + private static List computeRawPathReachingGoal(WorldPoint start, WorldPoint goal) { + List path = computeRawPath(start, goal); + if (reachesGoal(path, goal)) { + return path; + } + path = computeRawPath(start, goal); + if (reachesGoal(path, goal)) { + return path; + } + throw new AssertionError("pathfinder starved — INCONCLUSIVE, not a route regression: the " + + "search did not reach " + goal + " within its no-progress cutoff on two attempts " + + "(got " + path.size() + " tiles, ending at " + + (path.isEmpty() ? "nothing" : path.get(path.size() - 1)) + "). Re-run this test on an " + + "idle machine before treating it as a routing change."); + } + + /** The pathfinder returns a best-effort partial path when starved, so check the endpoint. */ + private static boolean reachesGoal(List path, WorldPoint goal) { + return !path.isEmpty() && path.get(path.size() - 1).equals(goal); } private static List computeRawPath(WorldPoint start, WorldPoint goal) { @@ -58,7 +92,10 @@ private static List computeRawPath(WorldPoint start, WorldPoint goal try { java.lang.reflect.Field f = PathfinderConfig.class.getDeclaredField("calculationCutoffMillis"); f.setAccessible(true); - f.setLong(config, 10000); + // 30s of NO PROGRESS, not 30s of runtime: the guard resets on every heuristic + // improvement, so this costs nothing on a healthy run and only buys headroom on a + // contended one. + f.setLong(config, 30_000); for (Map.Entry> e : transports.entrySet()) { if (e.getKey() == null) continue; config.getTransports().put(e.getKey(), e.getValue()); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/SealedTargetFastPathTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/SealedTargetFastPathTest.java new file mode 100644 index 00000000000..41389ce4139 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/SealedTargetFastPathTest.java @@ -0,0 +1,207 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.CollisionMap; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathTerminationReason; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +/** + * The sealed-target fast path: an unreachable destination must fail in ~a thousand nodes, not by + * flooding the entire world component. + *

+ * Pinned against the live failure of 2026-08-06/07: 37 {@code SEARCH_EXHAUSTED} terminations at + * ~1.1M nodes and 1.2-3.8s each, mostly for destinations TWO TILES from the player — a sealed tile + * targeted by coordinate. The reverse probe explores only the target's own component and answers in + * about a millisecond; the search then runs against the component's walkable rim so the walk still + * ends beside the sealed area, which is all the old flood's best-effort path ever bought. + */ +public class SealedTargetFastPathTest { + + private static SplitFlagMap collisionMap; + private static HashMap> transports; + + /** Lumbridge courtyard: mapped, ordinary, walkable ground. */ + private static final WorldPoint SRC = new WorldPoint(3222, 3218, 0); + + /** Generous ceiling: the old failure mode expanded ~1.1M nodes; the fast path needs ~1k. */ + private static final long NODE_CEILING = 60_000; + + @BeforeClass + public static void load() { + collisionMap = SplitFlagMap.fromResources(); + transports = Transport.loadAllFromResources(); + } + + private static PathfinderConfig newConfig() { + PathfinderConfig config = new PathfinderConfig(collisionMap, transports, + Collections.emptyList(), null, null); + try { + java.lang.reflect.Field f = PathfinderConfig.class.getDeclaredField("calculationCutoffMillis"); + f.setAccessible(true); + f.setLong(config, 10_000); + for (Map.Entry> e : transports.entrySet()) { + if (e.getKey() == null) continue; + config.getTransports().put(e.getKey(), e.getValue()); + config.getTransportsPacked().put(WorldPointUtil.packWorldPoint(e.getKey()), e.getValue()); + } + } catch (Exception ex) { + throw new RuntimeException(ex); + } + return config; + } + + private static boolean hasAnyStepOut(CollisionMap map, int x, int y, int z) { + return map.canStep(x, y, z, 1, 0) || map.canStep(x, y, z, -1, 0) + || map.canStep(x, y, z, 0, 1) || map.canStep(x, y, z, 0, -1); + } + + /** + * Mirrors the probe's sealed reading: no neighbour can step INTO the tile from any of the 8 + * directions. An object footprint blocks entry from every side while its own edge flags can + * still read as notional exits, so an exit-based test misses exactly the live case's tiles. + */ + private static boolean noEntry(CollisionMap map, int x, int y, int z) { + int[][] all = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {1, -1}, {-1, 1}, {-1, -1}}; + for (int[] d : all) { + if (map.canStep(x - d[0], y - d[1], z, d[0], d[1])) { + return false; + } + } + return true; + } + + /** A floorless upper plane has no map data: every edge reads blocked, and its rim is equally void. */ + @Test + public void voidTargetFailsFastWithNoPath() { + PathfinderConfig config = newConfig(); + WorldPoint dst = new WorldPoint(3222, 3218, 3); + assumeTrue("precondition: the shipped map must seal the void tile", + !hasAnyStepOut(config.getMap(), dst.getX(), dst.getY(), dst.getPlane())); + + long startedAt = System.currentTimeMillis(); + Pathfinder pf = new Pathfinder(config, SRC, dst); + pf.run(); + long elapsed = System.currentTimeMillis() - startedAt; + + assertEquals(PathTerminationReason.SEARCH_EXHAUSTED, pf.getTerminationReason()); + assertTrue("void target must fail fast, took " + elapsed + "ms", elapsed < 2_000); + assertTrue("void target must not flood: nodes=" + pf.getStats().getNodesChecked(), + pf.getStats().getNodesChecked() < NODE_CEILING); + assertTrue("no walkable rim means no path", pf.getPath().isEmpty()); + } + + /** + * A fully-blocked tile beside walkable ground (an interactable's footprint, the live case's + * shape): the search must end SEARCH_EXHAUSTED quickly WITH a best-effort path that stops on the + * rim beside the sealed tile — the same utility the 1.1M-node flood used to buy for 3.8s. + */ + @Test + public void sealedTileWithWalkableRimYieldsTheApproachPath() { + PathfinderConfig config = newConfig(); + CollisionMap map = config.getMap(); + map.beginSearch(); + + // Self-locating with a PROVABLY REACHABLE rim: BFS the walkable area around SRC first, then + // pick a sealed tile one of whose neighbours is in that area. Earlier attempts picked sealed + // tiles by geometry alone and landed on moat/interior tiles whose rim is an unreachable + // pocket — the unreachable-rim case, which is bounded elsewhere; this test is the live case: + // an interactable's sealed footprint beside ground the player can stand on. + Set reachable = new java.util.HashSet<>(); + java.util.ArrayDeque frontier = new java.util.ArrayDeque<>(); + reachable.add(SRC); + frontier.add(SRC); + int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + while (!frontier.isEmpty() && reachable.size() < 1_500) { + WorldPoint c = frontier.poll(); + for (int[] d : dirs) { + if (!map.canStep(c.getX(), c.getY(), 0, d[0], d[1])) { + continue; + } + WorldPoint n = new WorldPoint(c.getX() + d[0], c.getY() + d[1], 0); + if (reachable.add(n)) { + frontier.add(n); + } + } + } + WorldPoint dst = null; + int bestDist = Integer.MAX_VALUE; + for (WorldPoint open : reachable) { + for (int[] d : dirs) { + int x = open.getX() + d[0]; + int y = open.getY() + d[1]; + WorldPoint cand = new WorldPoint(x, y, 0); + if (reachable.contains(cand) || !noEntry(map, x, y, 0)) { + continue; + } + int dist = Math.max(Math.abs(x - SRC.getX()), Math.abs(y - SRC.getY())); + if (dist >= 3 && dist < bestDist) { + bestDist = dist; + dst = cand; + } + } + } + assumeTrue("precondition: found a sealed tile whose rim the player can stand on", dst != null); + + long startedAt = System.currentTimeMillis(); + Pathfinder pf = new Pathfinder(config, SRC, dst); + pf.run(); + long elapsed = System.currentTimeMillis() - startedAt; + + assertEquals("the ORIGINAL target is unreachable and the caller must hear it", + PathTerminationReason.SEARCH_EXHAUSTED, pf.getTerminationReason()); + assertTrue("sealed target must fail fast, took " + elapsed + "ms for dst=" + dst, + elapsed < 3_000); + assertTrue("sealed target must not flood: nodes=" + pf.getStats().getNodesChecked() + " dst=" + dst, + pf.getStats().getNodesChecked() < NODE_CEILING); + assertTrue("the walk still gets an approach path to the rim", !pf.getPath().isEmpty()); + WorldPoint last = pf.getPath().get(pf.getPath().size() - 1); + assertNotNull(last); + assertTrue("approach path must end beside the sealed tile, ended at " + last + " for dst=" + dst, + last.distanceTo2D(dst) <= 2); + } + + /** The probe must not disturb ordinary reachable routes: same courtyard, short hop, reached. */ + @Test + public void reachableTargetStillReached() { + PathfinderConfig config = newConfig(); + WorldPoint dst = new WorldPoint(3232, 3218, 0); + + Pathfinder pf = new Pathfinder(config, SRC, dst); + pf.run(); + + assertEquals(PathTerminationReason.TARGET_REACHED, pf.getTerminationReason()); + assertTrue(!pf.getPath().isEmpty()); + assertEquals(dst, pf.getPath().get(pf.getPath().size() - 1)); + } + + /** Regression from 2026-08-15: 3539 -> 3538 -> 3537 must be normalized in one plan. */ + @Test + public void burthorpeNestedSealedShellPublishesTheOuterApproachOnce() { + PathfinderConfig config = newConfig(); + WorldPoint src = new WorldPoint(2935, 3456, 0); + WorldPoint dst = new WorldPoint(2907, 3539, 0); + + Pathfinder pf = new Pathfinder(config, src, dst); + pf.run(); + + assertEquals(PathTerminationReason.SEARCH_EXHAUSTED, pf.getTerminationReason()); + WorldPoint substitute = pf.getNearestSealedRimSubstitute(); + assertNotNull(substitute); + assertTrue("nested sealed rim must be resolved beyond the immediate 3538 shell: " + substitute, + !substitute.equals(new WorldPoint(2907, 3538, 0))); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathCoreTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathCoreTest.java index 33d56b82645..4bd34d5a8c9 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathCoreTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathCoreTest.java @@ -1,6 +1,7 @@ package net.runelite.client.plugins.microbot.shortestpath; import net.runelite.api.Quest; +import net.runelite.api.QuestState; import net.runelite.api.VarPlayer; import net.runelite.api.coords.WorldArea; import net.runelite.api.coords.WorldPoint; @@ -372,17 +373,472 @@ public void testNewTransportTypesLoaded() { } @Test - public void testLumbridgeHomeTeleportTransportLoaded() { - Transport transport = getLumbridgeHomeTeleportTransport(); + public void testMinigameTeleportsUseCurrentLandingsAndSpecialRequirements() { + Set teleports = Transport.loadAllFromResources() + .getOrDefault(null, Collections.emptySet()); - assertTrue("Lumbridge Home Teleport should stay gated to the standard spellbook", - transport.getVarbits().stream().anyMatch(v -> v.getVarbitId() == 4070 && v.getValue() == 0)); - assertFalse("Lumbridge Home Teleport should not depend on the buff-display disabled varbit", - transport.getVarbits().stream().anyMatch(v -> v.getVarbitId() == 12353)); - assertTrue("Lumbridge Home Teleport should be gated by LAST_HOME_TELEPORT cooldown", - transport.getVarplayers().stream().anyMatch(v -> v.getVarplayerId() == VarPlayer.LAST_HOME_TELEPORT - && v.getOperator() == TransportVarPlayer.Operator.COOLDOWN_MINUTES - && v.getValue() == 30)); + Transport guardians = findTeleport(teleports, "Guardians of the Rift"); + assertEquals("Guardians teleport should land inside the Temple of the Eye", + new WorldPoint(3614, 9477, 0), guardians.getDestination()); + + Transport keldagrimRatPits = findTeleport(teleports, "Rat Pits: Keldagrim"); + assertEquals(new WorldPoint(2914, 10193, 0), keldagrimRatPits.getDestination()); + Transport varrockRatPits = findTeleport(teleports, "Rat Pits: Varrock"); + assertEquals(new WorldPoint(3262, 3405, 0), varrockRatPits.getDestination()); + + Transport pestControl = findTeleport(teleports, "Pest Control"); + assertEquals("Pest Control teleport should retain its 40 combat gate", + 40, pestControl.getRequiredCombatLevel()); + } + + @Test + public void testTransportParserSupportsUpstreamSpecialLevelRequirements() { + Map fields = new HashMap<>(); + fields.put("Destination", "1 2 0"); + fields.put("Skills", "2376 Total;40 Combat;327 Quest points"); + Transport transport = new Transport(fields, TransportType.TELEPORTATION_ITEM); + + assertEquals(2376, transport.getRequiredTotalLevel()); + assertEquals(40, transport.getRequiredCombatLevel()); + assertEquals(327, transport.getRequiredQuestPoints()); + } + + @Test + public void testDirectMaxCapeAndQuestCapeImportPreservesRequirementsAndDestinations() { + Set teleports = Transport.loadAllFromResources() + .getOrDefault(null, Collections.emptySet()); + + List directMaxCape = new ArrayList<>(); + for (Transport transport : teleports) { + if (transport.getType() == TransportType.TELEPORTATION_ITEM + && transport.getDisplayInfo() != null + && transport.getDisplayInfo().startsWith("Max cape:") + && !transport.getDisplayInfo().equals("Max cape: Home")) { + directMaxCape.add(transport); + } + } + + assertEquals("The reviewed direct Max-cape family should contain every upstream destination", + 17, directMaxCape.size()); + Set routeIdentities = new HashSet<>(); + for (Transport transport : directMaxCape) { + assertEquals(2376, transport.getRequiredTotalLevel()); + assertEquals(20, transport.getMaxWildernessLevel()); + assertEquals(1, transport.getItemRequirements().size()); + assertEquals(Set.of(13280, 13342), transport.getItemRequirements().get(0).getItemIds()); + assertTrue("Duplicate Max-cape route: " + transport.getDisplayInfo(), + routeIdentities.add(transport.getDestination() + "|" + transport.getDisplayInfo())); + } + + Transport hunterGuild = findItemTeleport(teleports, + "Max cape: Other Teleports: Hunter Guild"); + assertEquals(new WorldPoint(1558, 3046, 0), hunterGuild.getDestination()); + Transport pandemonium = findItemTeleport(teleports, + "Max cape: Other Teleports: The Pandemonium"); + assertEquals(new WorldPoint(3048, 2972, 0), pandemonium.getDestination()); + + Transport questCape = findItemTeleport(teleports, "Quest point cape: Teleport"); + assertEquals(new WorldPoint(2729, 3348, 0), questCape.getDestination()); + assertEquals(327, questCape.getRequiredQuestPoints()); + assertEquals(20, questCape.getMaxWildernessLevel()); + assertEquals(Set.of(9813, 13068), questCape.getItemRequirements().get(0).getItemIds()); + } + + @Test + public void testQuetzalNetworkAndWhistleFamilyMatchReviewedUpstream() { + HashMap> transports = Transport.loadAllFromResources(); + WorldPoint aldarin = new WorldPoint(1389, 2901, 0); + WorldPoint quetzacalli = new WorldPoint(1510, 3222, 0); + WorldPoint oldQuetzacalli = new WorldPoint(1510, 3221, 0); + WorldPoint camTorum = new WorldPoint(1446, 3108, 0); + + assertFalse("the obsolete one-tile-off Quetzacalli origin must be gone", + transports.getOrDefault(oldQuetzacalli, Collections.emptySet()).stream() + .anyMatch(transport -> transport.getType() == TransportType.QUETZAL)); + Transport aldarinToCamTorum = transports.getOrDefault(aldarin, Collections.emptySet()).stream() + .filter(transport -> transport.getType() == TransportType.QUETZAL) + .filter(transport -> camTorum.equals(transport.getDestination())) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing Aldarin -> Cam Torum quetzal route")); + assertEquals("Travel", aldarinToCamTorum.getAction()); + assertEquals("Renu", aldarinToCamTorum.getName()); + assertEquals(13350, aldarinToCamTorum.getObjectId()); + assertEquals("Cam Torum", aldarinToCamTorum.getDisplayInfo()); + assertTrue(aldarinToCamTorum.getVarplayers().stream().anyMatch(requirement -> + requirement.getVarplayerId() == 4182 + && requirement.getOperator() == TransportVarPlayer.Operator.BIT_SET + && requirement.getValue() == 32)); + assertTrue("the corrected Quetzacalli origin must participate in the network", + transports.getOrDefault(quetzacalli, Collections.emptySet()).stream() + .anyMatch(transport -> transport.getType() == TransportType.QUETZAL)); + + List whistles = transports.getOrDefault(null, Collections.emptySet()).stream() + .filter(transport -> transport.getType() == TransportType.TELEPORTATION_ITEM) + .filter(transport -> transport.getDisplayInfo() != null + && transport.getDisplayInfo().startsWith("Quetzal whistle:")) + .collect(java.util.stream.Collectors.toList()); + assertEquals("every whistle destination needs charged and permanent variants", 28, whistles.size()); + Set whistleVariants = new HashSet<>(); + for (Transport whistle : whistles) { + Set itemIds = whistle.getItemRequirements().get(0).getItemIds(); + if (whistle.isConsumable()) { + assertEquals(Set.of(29271, 29273, 29275), itemIds); + } else { + assertEquals(Set.of(33120), itemIds); + } + assertEquals(QuestState.FINISHED, whistle.getQuests().get(Quest.TWILIGHTS_PROMISE)); + assertEquals(20, whistle.getMaxWildernessLevel()); + assertTrue("duplicate whistle policy variant: " + whistle.getDisplayInfo(), + whistleVariants.add(whistle.getDisplayInfo() + "|" + whistle.isConsumable())); + assertFalse("obsolete executor label must not survive", + whistle.getDisplayInfo().contains("Cam Torum Entrance")); + } + assertEquals("each destination must have one charged and one permanent variant", + 28, whistleVariants.size()); + Transport quetzacalliWhistle = whistles.stream() + .filter(transport -> "Quetzal whistle: Quetzacalli Gorge".equals(transport.getDisplayInfo())) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing Quetzacalli whistle destination")); + assertEquals(quetzacalli, quetzacalliWhistle.getDestination()); + } + + @Test + public void testBothCanoeChainsUsePinnedAxeCollectionAndUpstreamCosts() { + HashMap> transports = Transport.loadAllFromResources(); + Set riverLumOrigins = Set.of( + new WorldPoint(3132, 3510, 0), + new WorldPoint(3112, 3411, 0), + new WorldPoint(3202, 3343, 0), + new WorldPoint(3243, 3237, 0), + new WorldPoint(3154, 3630, 0)); + Set riverDougneOrigins = Set.of( + new WorldPoint(2439, 3135, 0), + new WorldPoint(2485, 3192, 0), + new WorldPoint(2579, 3260, 0), + new WorldPoint(2573, 3358, 0), + new WorldPoint(2525, 3408, 0)); + Set supportedOrigins = new HashSet<>(riverLumOrigins); + supportedOrigins.addAll(riverDougneOrigins); + List canoes = supportedOrigins.stream() + .flatMap(origin -> transports.getOrDefault(origin, Collections.emptySet()).stream()) + .filter(transport -> transport.getType() == TransportType.CANOE) + .collect(java.util.stream.Collectors.toList()); + + assertEquals("both supported canoe chains must retain all reviewed upstream routes", 45, canoes.size()); + for (Transport canoe : canoes) { + assertEquals("Paddle Canoe", canoe.getAction()); + assertEquals("Canoe Station", canoe.getName()); + assertTrue(canoe.getDuration() == 20 || canoe.getDuration() == 30); + assertEquals(1, canoe.getItemRequirements().size()); + Set axes = canoe.getItemRequirements().get(0).getItemIds(); + assertEquals(12, axes.size()); + assertTrue(axes.contains(net.runelite.api.gameval.ItemID.BRONZE_AXE)); + assertTrue(axes.contains(net.runelite.api.gameval.ItemID.CRYSTAL_AXE)); + } + List dougneCanoes = canoes.stream() + .filter(transport -> transport.getObjectId() >= 60845 && transport.getObjectId() <= 60849) + .collect(java.util.stream.Collectors.toList()); + assertEquals("River Dougne has four destinations from each of five stations", 20, dougneCanoes.size()); + assertEquals(Set.of(60845, 60846, 60847, 60848, 60849), dougneCanoes.stream() + .map(Transport::getObjectId) + .collect(java.util.stream.Collectors.toSet())); + } + + @Test + public void testGrappleShortcutsRequireCrossbowAndMithGrapple() { + Set reviewedGrappleObjects = Set.of(17042, 17047, 17049, 17050, 17062, 17068, 17074); + List grappleShortcuts = Transport.loadAllFromResources().values().stream() + .flatMap(Collection::stream) + .filter(transport -> transport.getType() == TransportType.GRAPPLE_SHORTCUT) + .filter(transport -> reviewedGrappleObjects.contains(transport.getObjectId())) + .collect(java.util.stream.Collectors.toList()); + + assertEquals("every reviewed grapple edge must retain the upstream equipment pair", + 12, grappleShortcuts.size()); + for (Transport grapple : grappleShortcuts) { + assertEquals("crossbow and grapple are independent AND requirements: " + grapple, + 2, grapple.getItemRequirements().size()); + assertTrue("a usable crossbow family is required: " + grapple, + grapple.getItemRequirements().stream().anyMatch(requirement -> + requirement.getItemIds().contains(net.runelite.api.gameval.ItemID.CROSSBOW) + && requirement.getItemIds().contains(net.runelite.api.gameval.ItemID.ZARYTE_XBOW))); + assertTrue("the mith grapple is required separately: " + grapple, + grapple.getItemRequirements().stream().anyMatch(requirement -> + requirement.getItemIds().equals(Set.of( + net.runelite.api.gameval.ItemID.XBOWS_GRAPPLE_TIP_BOLT_MITHRIL_ROPE)))); + } + } + + @Test + public void testTrollheimRopeShortcutRetainsItemAndUnlockVarbit() { + WorldPoint origin = new WorldPoint(2766, 3665, 0); + Transport rope = Transport.loadAllFromResources().getOrDefault(origin, Collections.emptySet()).stream() + .filter(transport -> new WorldPoint(2766, 3663, 0).equals(transport.getDestination())) + .filter(transport -> transport.getObjectId() == 5842) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing Trollheim rope shortcut")); + + assertEquals(1, rope.getItemRequirements().size()); + assertEquals(Set.of(net.runelite.api.gameval.ItemID.ROPE), + rope.getItemRequirements().get(0).getItemIds()); + assertTrue("shortcut is available only after the rope has been attached", + rope.getVarbits().stream().anyMatch(requirement -> requirement.getVarbitId() == 260 + && requirement.getOperator() == TransportVarbit.Operator.GREATER_THAN + && requirement.getValue() == 0)); + assertEquals(10, rope.getDuration()); + } + + @Test + public void testTrollheimClimbingRockAscentsRequireBootsButDescentsDoNot() { + HashMap> transports = Transport.loadAllFromResources(); + Map ascents = Map.of( + new WorldPoint(2820, 3635, 0), new WorldPoint(2822, 3635, 0), + new WorldPoint(2856, 3611, 0), new WorldPoint(2856, 3613, 0), + new WorldPoint(2857, 3611, 0), new WorldPoint(2857, 3613, 0)); + + for (Map.Entry edge : ascents.entrySet()) { + Transport ascent = transports.getOrDefault(edge.getKey(), Collections.emptySet()).stream() + .filter(transport -> edge.getValue().equals(transport.getDestination())) + .filter(transport -> transport.getObjectId() == 3748) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing Trollheim ascent: " + edge)); + assertEquals(TransportType.AGILITY_SHORTCUT, ascent.getType()); + assertEquals(1, ascent.getItemRequirements().size()); + assertEquals(Set.of( + net.runelite.api.gameval.ItemID.DEATH_CLIMBINGBOOTS, + net.runelite.api.gameval.ItemID.CLIMBING_BOOTS_G), + ascent.getItemRequirements().get(0).getItemIds()); + + Transport descent = transports.getOrDefault(edge.getValue(), Collections.emptySet()).stream() + .filter(transport -> edge.getKey().equals(transport.getDestination())) + .filter(transport -> transport.getObjectId() == 3748) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing unrestricted Trollheim descent: " + edge)); + assertEquals(TransportType.TRANSPORT, descent.getType()); + assertTrue(descent.getItemRequirements().isEmpty()); + } + } + + @Test + public void testIsafdarForestObstaclesRetainAgilityAndDurationRequirements() { + Set forestObjectIds = Set.of( + 3921, 3922, 3925, 3931, 3932, 3933, 3937, 3938, 3939, 3998, 3999); + Map requiredAgility = Map.ofEntries( + Map.entry(3921, 1), + Map.entry(3922, 1), + Map.entry(3925, 1), + Map.entry(3931, 45), + Map.entry(3932, 45), + Map.entry(3933, 45), + Map.entry(3937, 56), + Map.entry(3938, 56), + Map.entry(3939, 56), + Map.entry(3998, 56), + Map.entry(3999, 56)); + Map expectedDuration = Map.ofEntries( + Map.entry(3921, 8), + Map.entry(3922, 6), + Map.entry(3925, 4), + Map.entry(3931, 8), + Map.entry(3932, 8), + Map.entry(3933, 9), + Map.entry(3937, 4), + Map.entry(3938, 4), + Map.entry(3939, 4), + Map.entry(3998, 4), + Map.entry(3999, 4)); + + HashMap> transports = Transport.loadAllFromResources(); + List forestShortcuts = transports.values().stream() + .flatMap(Collection::stream) + .filter(transport -> forestObjectIds.contains(transport.getObjectId())) + .filter(transport -> transport.getOrigin() != null + && transport.getOrigin().getX() >= 2100 && transport.getOrigin().getX() <= 2310 + && transport.getOrigin().getY() >= 3100 && transport.getOrigin().getY() <= 3300) + .collect(java.util.stream.Collectors.toList()); + + assertEquals("the complete reviewed Isafdar obstacle family must be loaded", 88, + forestShortcuts.size()); + for (Transport shortcut : forestShortcuts) { + assertEquals("forest obstacles must not bypass the agility toggle or level gate: " + shortcut, + TransportType.AGILITY_SHORTCUT, shortcut.getType()); + assertEquals("wrong Agility requirement for object " + shortcut.getObjectId(), + requiredAgility.get(shortcut.getObjectId()).intValue(), + shortcut.getSkillLevels()[net.runelite.api.Skill.AGILITY.ordinal()]); + assertEquals("wrong traversal duration for object " + shortcut.getObjectId(), + expectedDuration.get(shortcut.getObjectId()).intValue(), shortcut.getDuration()); + } + + assertTrue("current stick landing must replace the stale 2295,3215 origin", + transports.getOrDefault(new WorldPoint(2295, 3213, 0), Collections.emptySet()).stream() + .anyMatch(transport -> transport.getObjectId() == 3922 + && new WorldPoint(2295, 3217, 0).equals(transport.getDestination()))); + assertFalse("stale stick landing must not remain as a generic transport", + transports.getOrDefault(new WorldPoint(2295, 3215, 0), Collections.emptySet()).stream() + .anyMatch(transport -> transport.getObjectId() == 3922)); + assertTrue("current dense-forest landing must replace the stale 2279,3221 origin", + transports.getOrDefault(new WorldPoint(2279, 3222, 0), Collections.emptySet()).stream() + .anyMatch(transport -> transport.getObjectId() == 3938 + && new WorldPoint(2279, 3225, 0).equals(transport.getDestination()))); + assertFalse("stale dense-forest landing must not remain as a generic transport", + transports.getOrDefault(new WorldPoint(2279, 3221, 0), Collections.emptySet()).stream() + .anyMatch(transport -> transport.getObjectId() == 3938)); + } + + @Test + public void testConvertedGenericShortcutFamiliesRetainUpstreamRequirements() { + Set reviewedObjects = Set.of( + 21727, 21738, 21739, 20882, 20884, // Brimhaven Dungeon + 6905, // Lumbridge cellar + 2231, // Karamja rocks + 16537, 16538, // Slayer Tower ground floor + 39541, 39542); // Darkmeyer walls + Map expectedAgility = Map.ofEntries( + Map.entry(21727, 1), + Map.entry(21738, 1), + Map.entry(21739, 1), + Map.entry(20882, 1), + Map.entry(20884, 1), + Map.entry(6905, 13), + Map.entry(2231, 15), + Map.entry(16537, 61), + Map.entry(16538, 61), + Map.entry(39541, 63), + Map.entry(39542, 63)); + Map expectedDuration = Map.ofEntries( + Map.entry(21727, 13), + Map.entry(21738, 7), + Map.entry(21739, 7), + Map.entry(20882, 7), + Map.entry(20884, 7), + Map.entry(6905, 3), + Map.entry(2231, 5), + Map.entry(16537, 0), + Map.entry(16538, 0), + Map.entry(39541, 0), + Map.entry(39542, 0)); + + HashMap> transports = Transport.loadAllFromResources(); + java.util.function.Predicate reviewedFamily = transport -> { + WorldPoint origin = transport.getOrigin(); + if (origin == null || !reviewedObjects.contains(transport.getObjectId())) { + return false; + } + int objectId = transport.getObjectId(); + if (objectId == 16537 || objectId == 16538) { + return origin.getX() >= 3421 && origin.getX() <= 3423 + && origin.getY() >= 3549 && origin.getY() <= 3551; + } + return true; + }; + List shortcuts = transports.values().stream() + .flatMap(Collection::stream) + .filter(reviewedFamily) + .filter(transport -> transport.getType() == TransportType.AGILITY_SHORTCUT) + .collect(java.util.stream.Collectors.toList()); + + assertEquals("all 28 reviewed generic edges must become agility shortcuts", 28, shortcuts.size()); + for (Transport shortcut : shortcuts) { + assertEquals("wrong Agility level for " + shortcut, + expectedAgility.get(shortcut.getObjectId()).intValue(), + shortcut.getSkillLevels()[net.runelite.api.Skill.AGILITY.ordinal()]); + assertEquals("wrong traversal duration for " + shortcut, + expectedDuration.get(shortcut.getObjectId()).intValue(), shortcut.getDuration()); + } + + List cellar = shortcuts.stream() + .filter(transport -> transport.getObjectId() == 6905) + .collect(java.util.stream.Collectors.toList()); + assertEquals(2, cellar.size()); + assertTrue("Lumbridge cellar hole must use the quest-progress varbit, not a completion-only wall", + cellar.stream().allMatch(transport -> transport.getVarbits().stream().anyMatch(requirement -> + requirement.getVarbitId() == 532 + && requirement.getOperator() == TransportVarbit.Operator.GREATER_THAN + && requirement.getValue() == 3))); + assertFalse("obsolete Lost Tribe wall objects must not survive the representation change", + transports.values().stream().flatMap(Collection::stream) + .anyMatch(transport -> transport.getObjectId() == 6898 || transport.getObjectId() == 6899)); + + assertTrue("west Darkmeyer wall must retain its unlock varbit", + shortcuts.stream().filter(transport -> transport.getObjectId() == 39542) + .allMatch(transport -> transport.getVarbits().stream().anyMatch(requirement -> + requirement.getVarbitId() == 10449 + && requirement.getOperator() == TransportVarbit.Operator.EQUAL + && requirement.getValue() == 1))); + assertTrue("east Darkmeyer wall must retain its unlock varbit", + shortcuts.stream().filter(transport -> transport.getObjectId() == 39541) + .allMatch(transport -> transport.getVarbits().stream().anyMatch(requirement -> + requirement.getVarbitId() == 10450 + && requirement.getOperator() == TransportVarbit.Operator.EQUAL + && requirement.getValue() == 1))); + + Set genericReviewedEdges = transports.values().stream() + .flatMap(Collection::stream) + .filter(reviewedFamily) + .filter(transport -> transport.getType() == TransportType.TRANSPORT) + .map(transport -> transport.getOrigin() + " -> " + transport.getDestination()) + .collect(java.util.stream.Collectors.toSet()); + assertEquals("only upstream's two intentional Darkmeyer diagonal generic approaches may remain", + Set.of( + new WorldPoint(3672, 3376, 0) + " -> " + new WorldPoint(3670, 3375, 0), + new WorldPoint(3672, 3374, 0) + " -> " + new WorldPoint(3670, 3375, 0)), + genericReviewedEdges); + } + + private static Transport findTeleport(Set teleports, String displayInfo) { + return teleports.stream() + .filter(transport -> transport.getType() == TransportType.TELEPORTATION_MINIGAME) + .filter(transport -> displayInfo.equals(transport.getDisplayInfo())) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing minigame teleport: " + displayInfo)); + } + + private static Transport findItemTeleport(Set teleports, String displayInfo) { + return teleports.stream() + .filter(transport -> transport.getType() == TransportType.TELEPORTATION_ITEM) + .filter(transport -> displayInfo.equals(transport.getDisplayInfo())) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing item teleport: " + displayInfo)); + } + + @Test + public void testLovakengjMinecartsRespectForsakenTowerUnlock() { + HashMap> transports = Transport.loadAllFromResources(); + WorldPoint arceuusOrigin = new WorldPoint(1670, 3832, 0); + WorldPoint farmingGuildDestination = new WorldPoint(1218, 3737, 0); + Set atArceuus = transports.getOrDefault(arceuusOrigin, Collections.emptySet()); + + boolean paidBeforeUnlock = false; + boolean freeAfterUnlock = false; + boolean ungatedVariant = false; + for (Transport transport : atArceuus) { + if (transport.getType() != TransportType.MINECART + || !farmingGuildDestination.equals(transport.getDestination())) { + continue; + } + boolean beforeUnlock = transport.getVarbits().stream().anyMatch(v -> v.getVarbitId() == 7796 + && v.getOperator() == TransportVarbit.Operator.LESS_THAN && v.getValue() == 11); + boolean afterUnlock = transport.getVarbits().stream().anyMatch(v -> v.getVarbitId() == 7796 + && v.getOperator() == TransportVarbit.Operator.EQUAL && v.getValue() == 11); + paidBeforeUnlock |= beforeUnlock && !afterUnlock + && transport.getCurrencyAmount() == 20 && "Coins".equals(transport.getCurrencyName()); + freeAfterUnlock |= afterUnlock && !beforeUnlock && transport.getCurrencyAmount() == 0; + ungatedVariant |= !beforeUnlock && !afterUnlock; + } + + assertTrue("Arceuus minecart should cost 20 coins before The Forsaken Tower unlock", paidBeforeUnlock); + assertTrue("Arceuus minecart should be free after The Forsaken Tower unlock", freeAfterUnlock); + assertFalse("Lovakengj minecart routes must not have an ungated fare variant", ungatedVariant); + } + + @Test + public void testAllSpellbookHomeTeleportTransportsLoaded() { + assertHomeTeleport("Lumbridge Home Teleport", new WorldPoint(3221, 3218, 0), 0, null, false); + assertHomeTeleport("Edgeville Home Teleport", new WorldPoint(3087, 3504, 0), 1, + Quest.DESERT_TREASURE_I, true); + assertHomeTeleport("Lunar Home Teleport", new WorldPoint(2113, 3915, 0), 2, + Quest.LUNAR_DIPLOMACY, true); + assertHomeTeleport("Arceuus Home Teleport", new WorldPoint(1700, 3882, 0), 3, null, true); } @Test @@ -403,17 +859,42 @@ public void testLumbridgeHomeTeleportCooldownRejectsRecentUse() { } private static Transport getLumbridgeHomeTeleportTransport() { + return getHomeTeleportTransport("Lumbridge Home Teleport", new WorldPoint(3221, 3218, 0)); + } + + private static void assertHomeTeleport(String displayInfo, WorldPoint destination, int spellbook, + Quest requiredQuest, boolean members) { + Transport transport = getHomeTeleportTransport(displayInfo, destination); + + assertEquals(displayInfo + " should have exactly one spellbook requirement", + 1, transport.getVarbits().size()); + assertTrue(displayInfo + " should require spellbook " + spellbook, + transport.getVarbits().stream().anyMatch(v -> v.getVarbitId() == 4070 && v.getValue() == spellbook)); + assertTrue(displayInfo + " should be gated by LAST_HOME_TELEPORT cooldown", + transport.getVarplayers().stream().anyMatch(v -> v.getVarplayerId() == VarPlayer.LAST_HOME_TELEPORT + && v.getOperator() == TransportVarPlayer.Operator.COOLDOWN_MINUTES + && v.getValue() == 30)); + assertEquals(displayInfo + " membership requirement", members, transport.isMembers()); + if (requiredQuest == null) { + assertTrue(displayInfo + " should not have a quest requirement", transport.getQuests().isEmpty()); + } else { + assertEquals(displayInfo + " quest requirement", QuestState.FINISHED, + transport.getQuests().get(requiredQuest)); + } + } + + private static Transport getHomeTeleportTransport(String displayInfo, WorldPoint destination) { HashMap> transports = Transport.loadAllFromResources(); - Optional lumbridgeHomeTeleport = transports.values().stream() + Optional homeTeleport = transports.values().stream() .flatMap(Set::stream) .filter(t -> t.getType() == TransportType.TELEPORTATION_SPELL - && "Lumbridge Home Teleport".equals(t.getDisplayInfo()) - && new WorldPoint(3221, 3218, 0).equals(t.getDestination())) + && displayInfo.equals(t.getDisplayInfo()) + && destination.equals(t.getDestination())) .findFirst(); - assertTrue("Lumbridge Home Teleport should be loaded", lumbridgeHomeTeleport.isPresent()); - return lumbridgeHomeTeleport.get(); + assertTrue(displayInfo + " should be loaded", homeTeleport.isPresent()); + return homeTeleport.get(); } private static void assertTollGateTransport(HashMap> transports, @@ -824,6 +1305,23 @@ public void testVarrockSewerPathAvoidsDisabledPalaceTrellisShortcut() { endpoint.distanceTo(dst) <= 1); } + @Test + public void testVarrockSewerManholeCatalogContainsOnlyTheTraversingEdge() { + WorldPoint origin = new WorldPoint(3236, 3458, 0); + WorldPoint destination = new WorldPoint(3237, 9858, 0); + List manholeEdges = Transport.loadAllFromResources() + .getOrDefault(origin, Collections.emptySet()).stream() + .filter(transport -> destination.equals(transport.getDestination())) + .collect(java.util.stream.Collectors.toList()); + + assertEquals("Opening the cover is object state preparation, not a traversing graph edge", + 1, manholeEdges.size()); + Transport manhole = manholeEdges.get(0); + assertEquals("Climb-down", manhole.getAction()); + assertEquals("Manhole", manhole.getName()); + assertEquals(882, manhole.getObjectId()); + } + @Test public void testVarrockSewerPathAvoidsPalaceGardenSouthFenceCollisionGap() { PathfinderConfig config = createConfigWithUnavailableShortcutEdges(TransportType.AGILITY_SHORTCUT); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportExecutionRegistryTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportExecutionRegistryTest.java new file mode 100644 index 00000000000..694c34a5d08 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportExecutionRegistryTest.java @@ -0,0 +1,196 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.ItemID; +import org.junit.Test; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class TransportExecutionRegistryTest +{ + @Test + public void objectExecutorRequiresAnExecutableInteraction() + { + WorldPoint origin = new WorldPoint(3200, 3200, 0); + WorldPoint destination = new WorldPoint(3200, 3200, 1); + Transport executable = new Transport( + origin, destination, "Upstairs", TransportType.TRANSPORT, false, + "Climb-up", "Staircase", 16671); + Transport missingObject = new Transport( + origin, destination, "Upstairs", TransportType.TRANSPORT, false, 1); + + assertEquals(TransportExecutionRegistry.Executor.OBJECT, + TransportExecutionRegistry.executorFor(executable).orElse(null)); + assertFalse(TransportExecutionRegistry.canExecute(missingObject)); + } + + @Test + public void barrowsDigExecutorRequiresAnExactMoundMappingAndSpade() + { + WorldPoint origin = new WorldPoint(3564, 3291, 0); + WorldPoint destination = new WorldPoint(3559, 9703, 3); + Transport valid = new Transport( + origin, destination, "Ahrim's Barrow", TransportType.TRANSPORT, true, + "Dig", "Barrow", 0, 3); + valid.setItemIdRequirements(Set.of(Set.of(ItemID.SPADE))); + + assertEquals(TransportExecutionRegistry.Executor.BARROWS_DIG, + TransportExecutionRegistry.executorFor(valid).orElse(null)); + + Transport wrongDestination = new Transport( + origin, new WorldPoint(3558, 9718, 3), "Wrong crypt", TransportType.TRANSPORT, true, + "Dig", "Barrow", 0, 3); + wrongDestination.setItemIdRequirements(Set.of(Set.of(ItemID.SPADE))); + Transport missingSpade = new Transport( + origin, destination, "Missing spade", TransportType.TRANSPORT, true, + "Dig", "Barrow", 0, 3); + + assertFalse(TransportExecutionRegistry.canExecute(wrongDestination)); + assertFalse(TransportExecutionRegistry.canExecute(missingSpade)); + } + + @Test + public void spellExecutorMatchesTheActualMagicActionCatalog() + { + assertTrue(TransportExecutionRegistry.canExecute(spell("Lumbridge Home Teleport"))); + assertTrue(TransportExecutionRegistry.canExecute(spell("Edgeville Home Teleport"))); + assertTrue(TransportExecutionRegistry.canExecute(spell("Lunar Home Teleport"))); + assertTrue(TransportExecutionRegistry.canExecute(spell("Arceuus Home Teleport"))); + assertTrue(TransportExecutionRegistry.canExecute(spell("Varrock Teleport: Grand Exchange"))); + assertFalse(TransportExecutionRegistry.canExecute(spell("Unknown Home Teleport"))); + } + + @Test + public void homeTeleportMappingIsExactAndSharedWithExecution() + { + for (TransportExecutionRegistry.HomeTeleport homeTeleport + : TransportExecutionRegistry.HomeTeleport.values()) + { + assertEquals(homeTeleport, + TransportExecutionRegistry.homeTeleportFor(homeTeleport.getDisplayName()).orElse(null)); + assertEquals(homeTeleport, + TransportExecutionRegistry.homeTeleportFor( + " " + homeTeleport.getDisplayName().toUpperCase(Locale.ROOT) + " ").orElse(null)); + } + assertFalse(TransportExecutionRegistry.homeTeleportFor("Lumbridge Home Teleport: Fake").isPresent()); + } + + @Test + public void balloonExecutorRequiresAnExactKnownNetworkRow() + { + WorldPoint origin = new WorldPoint(2461, 3111, 0); + WorldPoint destination = new WorldPoint(3299, 3482, 0); + Transport valid = new Transport( + origin, destination, "Varrock", TransportType.HOT_AIR_BALLOON, true, + "Use", "Basket", 19129, 7); + assertEquals(TransportExecutionRegistry.Executor.HOT_AIR_BALLOON, + TransportExecutionRegistry.executorFor(valid).orElse(null)); + + Transport unknownDestination = new Transport( + origin, destination, "Unknown", TransportType.HOT_AIR_BALLOON, true, + "Use", "Basket", 19129, 7); + Transport unknownObject = new Transport( + origin, destination, "Varrock", TransportType.HOT_AIR_BALLOON, true, + "Use", "Basket", 99999, 7); + assertFalse(TransportExecutionRegistry.canExecute(unknownDestination)); + assertFalse(TransportExecutionRegistry.canExecute(unknownObject)); + } + + @Test + public void terminalTravelExecutorDoesNotAssumeTheCatalogTargetIsAnNpc() + { + WorldPoint origin = new WorldPoint(3271, 3144, 0); + WorldPoint destination = new WorldPoint(3148, 2843, 0); + for (TransportType type : List.of(TransportType.SHIP, TransportType.NPC, TransportType.BOAT)) + { + Transport transport = new Transport( + origin, destination, "", type, true, + "Board", "Ferry", 41311, 8); + assertEquals(TransportExecutionRegistry.Executor.TERMINAL_TRAVEL, + TransportExecutionRegistry.executorFor(transport).orElse(null)); + assertEquals(TransportExecutionRegistry.TerminalTravelMode.DIRECT, + TransportExecutionRegistry.terminalTravelModeFor(transport).orElse(null)); + } + } + + @Test + public void terminalTravelModesFailClosedForUnimplementedDestinationSelection() + { + WorldPoint origin = new WorldPoint(1342, 3645, 0); + Transport multiDestinationBoat = new Transport( + origin, new WorldPoint(1408, 3612, 0), "Shayzien", TransportType.BOAT, true, + "Board", "Boaty", 33614, 5); + Transport mountainGuide = new Transport( + new WorldPoint(1277, 3558, 0), new WorldPoint(1401, 3536, 0), + "The Shayzien Outpost", TransportType.NPC, true, + "Travel", "Mountain Guide", 24190, 4); + + assertFalse(TransportExecutionRegistry.canExecute(multiDestinationBoat)); + assertFalse(TransportExecutionRegistry.terminalTravelModeFor(multiDestinationBoat).isPresent()); + assertEquals(TransportExecutionRegistry.TerminalTravelMode.DIALOGUE_DESTINATION, + TransportExecutionRegistry.terminalTravelModeFor(mountainGuide).orElse(null)); + assertEquals(TransportExecutionRegistry.Executor.TERMINAL_TRAVEL, + TransportExecutionRegistry.executorFor(mountainGuide).orElse(null)); + } + + @Test + public void resourceCatalogHasOnlyExplicitTerminalExecutionDebt() + { + Map> catalog = Transport.loadAllFromResources(); + List unsupported = catalog.values().stream() + .flatMap(Set::stream) + .filter(transport -> !TransportExecutionRegistry.canExecute(transport)) + .collect(Collectors.toList()); + + assertEquals("only explicitly audited terminal rows may remain fail-closed: " + describe(unsupported), + 41, unsupported.size()); + assertTrue("non-terminal execution debt: " + describe(unsupported), + unsupported.stream().allMatch(transport -> + transport.getType() == TransportType.SHIP + || transport.getType() == TransportType.NPC + || transport.getType() == TransportType.BOAT)); + Map debtByInteraction = unsupported.stream().collect(Collectors.groupingBy( + transport -> transport.getType() + ":" + transport.getAction() + ":" + transport.getName(), + Collectors.counting())); + assertEquals(Map.of( + "BOAT:Board:Boat", 18L, + "BOAT:Board:Boaty", 12L, + "BOAT:Talk-to:Pirate Pete", 2L, + "BOAT:Travel:Rowboat", 6L, + "SHIP:Talk-to:Captain Shanks", 3L), debtByInteraction); + assertEquals("all teleport spells must have a registered executor", + 0L, unsupported.stream() + .filter(transport -> transport.getType() == TransportType.TELEPORTATION_SPELL) + .count()); + assertEquals("every expanded hot-air-balloon edge must use the dedicated executor", + 225L, catalog.values().stream() + .flatMap(Set::stream) + .filter(transport -> transport.getType() == TransportType.HOT_AIR_BALLOON) + .filter(transport -> TransportExecutionRegistry.executorFor(transport) + .orElse(null) == TransportExecutionRegistry.Executor.HOT_AIR_BALLOON) + .count()); + } + + private static Transport spell(String displayInfo) + { + return new Transport( + null, new WorldPoint(3200, 3200, 0), displayInfo, + TransportType.TELEPORTATION_SPELL, false, 1); + } + + private static String describe(List transports) + { + return transports.stream() + .map(transport -> transport.getType() + ":" + transport.getDisplayInfo() + + "@" + transport.getOrigin() + "->" + transport.getDestination()) + .collect(Collectors.joining(", ")); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemRequirementTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemRequirementTest.java new file mode 100644 index 00000000000..497796d1360 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportItemRequirementTest.java @@ -0,0 +1,201 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.ItemID; +import org.junit.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.fail; +import static org.junit.Assert.assertTrue; + +public class TransportItemRequirementTest { + @Test + public void numericUpstreamGrammarPreservesAndOrAndUsesUpstreamMaximumQuantity() { + List requirements = + TransportItemRequirement.parseNumericRequirements("100=2||101=3&&200=1"); + + assertEquals(2, requirements.size()); + assertEquals(3, requirements.get(0).getRequiredQuantity(100)); + assertEquals(3, requirements.get(0).getRequiredQuantity(101)); + assertEquals(Set.of(100, 101), requirements.get(0).getItemIds()); + assertEquals(Set.of(200), requirements.get(1).getItemIds()); + + Map available = new HashMap<>(); + available.put(100, 3); + available.put(200, 1); + assertTrue(requirements.stream().allMatch( + requirement -> requirement.isSatisfiedBy(id -> available.getOrDefault(id, 0)))); + + available.put(200, 0); + assertFalse(requirements.stream().allMatch( + requirement -> requirement.isSatisfiedBy(id -> available.getOrDefault(id, 0)))); + } + + @Test + public void legacySemicolonIdsRemainOneAlternativeRequirement() { + Map fields = new HashMap<>(); + fields.put("Destination", "1 2 0"); + fields.put("Item IDs", "3853;3855;3857"); + + Transport transport = new Transport(fields, TransportType.TELEPORTATION_ITEM); + + assertEquals(1, transport.getItemRequirements().size()); + assertEquals(Set.of(3853, 3855, 3857), transport.getItemRequirements().get(0).getItemIds()); + assertTrue(transport.getItemRequirements().get(0).isSatisfiedBy(id -> id == 3855 ? 1 : 0)); + } + + @Test + public void transportAcceptsNumericUpstreamGrammarInCompatibilityColumn() { + Map fields = new HashMap<>(); + fields.put("Destination", "1 2 0"); + fields.put("Item IDs", "13280=1||13342=1&&995=20"); + + Transport transport = new Transport(fields, TransportType.TELEPORTATION_ITEM); + + assertEquals(2, transport.getItemRequirements().size()); + assertEquals(Set.of(13280, 13342), transport.getItemRequirements().get(0).getItemIds()); + assertEquals(20, transport.getItemRequirements().get(1).getRequiredQuantity(995)); + } + + @Test + public void transportAcceptsUpstreamInteractionAndVarPlayersGrammar() { + Map fields = new HashMap<>(); + fields.put("Origin", "1 2 0"); + fields.put("Destination", "3 4 0"); + fields.put("menuOption menuTarget objectID", "Travel Renu 13350"); + fields.put("VarPlayers", "4182&32"); + + Transport transport = new Transport(fields, TransportType.QUETZAL); + + assertEquals("Travel", transport.getAction()); + assertEquals("Renu", transport.getName()); + assertEquals(13350, transport.getObjectId()); + assertEquals(1, transport.getVarplayers().size()); + TransportVarPlayer varplayer = transport.getVarplayers().iterator().next(); + assertEquals(4182, varplayer.getVarplayerId()); + assertEquals(32, varplayer.getValue()); + assertEquals(TransportVarPlayer.Operator.BIT_SET, + varplayer.getOperator()); + } + + @Test + public void supportedSymbolicCollectionsExpandWithoutFlatteningAndGroups() { + List requirements = + TransportItemRequirement.parseRequirements("CROSSBOW=1&MITH_GRAPPLE=1"); + + assertEquals(2, requirements.size()); + assertTrue(requirements.get(0).getItemIds().contains(ItemID.CROSSBOW)); + assertTrue(requirements.get(0).getItemIds().contains(ItemID.ZARYTE_XBOW)); + assertEquals(20, requirements.get(0).getItemIds().size()); + assertEquals(Set.of(ItemID.XBOWS_GRAPPLE_TIP_BOLT_MITHRIL_ROPE), + requirements.get(1).getItemIds()); + } + + @Test + public void transportAcceptsPinnedAxeCollectionFromUpstreamItemsColumn() { + Map fields = new HashMap<>(); + fields.put("Origin", "1 2 0"); + fields.put("Destination", "3 4 0"); + fields.put("Items", "AXE=1"); + + Transport transport = new Transport(fields, TransportType.CANOE); + + assertEquals(1, transport.getItemRequirements().size()); + Set axes = transport.getItemRequirements().get(0).getItemIds(); + assertEquals(12, axes.size()); + assertTrue(axes.contains(ItemID.BRONZE_AXE)); + assertTrue(axes.contains(ItemID.CRYSTAL_AXE)); + assertTrue(axes.contains(ItemID._3A_AXE)); + } + + @Test + public void runeCollectionRetainsComboRuneAndEquipmentProviders() { + TransportItemRequirement requirement = + TransportItemRequirement.parseRequirements("AIR_RUNE=3").get(0); + + assertEquals(3, requirement.getRequiredQuantity(ItemID.AIRRUNE)); + assertEquals(3, requirement.getRequiredQuantity(ItemID.MISTRUNE)); + assertEquals(3, requirement.getRequiredQuantity(ItemID.DUSTRUNE)); + assertEquals(3, requirement.getRequiredQuantity(ItemID.SMOKERUNE)); + assertTrue(requirement.getStaffAlternatives().contains(ItemID.STAFF_OF_AIR)); + assertTrue(requirement.getStaffAlternatives().contains(ItemID.SHADOWFLAME_QUADRANT)); + assertTrue(requirement.getOffhandAlternatives().isEmpty()); + assertTrue(requirement.isRuneOnly()); + } + + @Test + public void oneCombinationStaffCanSatisfyMultipleRuneClauses() { + List requirements = + TransportItemRequirement.parseRequirements("FIRE_RUNE=2&WATER_RUNE=2"); + + TransportItemRequirement.ProviderSelection selection = + TransportItemRequirement.selectProviders( + requirements, + ignored -> 0, + itemId -> itemId == ItemID.TWINFLAME_STAFF, + ignored -> false) + .orElseThrow(() -> new AssertionError("Twinflame staff should satisfy both clauses")); + + assertEquals(ItemID.TWINFLAME_STAFF, selection.getStaffItemId()); + assertFalse(selection.hasOffhand()); + } + + @Test + public void unequippedStaffIsNotMistakenForOrdinaryRuneQuantity() { + List requirements = + TransportItemRequirement.parseRequirements("FIRE_RUNE=2"); + + assertFalse(TransportItemRequirement.selectProviders( + requirements, + itemId -> itemId == ItemID.STAFF_OF_FIRE ? 1 : 0, + ignored -> false, + ignored -> false).isPresent()); + assertTrue(TransportItemRequirement.selectProviders( + requirements, + itemId -> itemId == ItemID.STAFF_OF_FIRE ? 1 : 0, + itemId -> itemId == ItemID.STAFF_OF_FIRE, + ignored -> false).isPresent()); + } + + @Test + public void unsupportedSlotCollectionStillFailsClosed() { + try { + TransportItemRequirement.parseRequirements("CAPESLOT=1"); + fail("slot requirements need explicit equipment-slot semantics"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("unresolved symbolic")); + } + } + + @Test + public void mergedTransportRequiresBothEndpointRequirementGroups() { + Transport origin = new Transport( + new WorldPoint(1, 2, 0), "origin", TransportType.TRANSPORT, true, 19, + Set.of(Set.of(10, 11))); + Transport destination = new Transport( + new WorldPoint(3, 4, 0), "destination", TransportType.TRANSPORT, true, 19, + Set.of(Set.of(20))); + + Transport merged = new Transport(origin, destination); + + assertEquals(2, merged.getItemRequirements().size()); + assertEquals(Set.of(10, 11), merged.getItemRequirements().get(0).getItemIds()); + assertEquals(Set.of(20), merged.getItemRequirements().get(1).getItemIds()); + } + + @Test + public void unresolvedSymbolicItemsFailClosed() { + try { + TransportItemRequirement.parseNumericRequirements("COINS=20"); + fail("symbolic item names must be resolved by the schema adapter"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("unresolved symbolic")); + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportSkillRequirementDataTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportSkillRequirementDataTest.java new file mode 100644 index 00000000000..8ec12ee66ac --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportSkillRequirementDataTest.java @@ -0,0 +1,143 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import net.runelite.api.Skill; +import org.junit.Test; + +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Every Skills entry in the shipped transport data must name a skill the parser can resolve. + * + *

An unresolvable requirement does not fail loudly — {@code Transport} matches the skill name + * against {@link Skill#getName()} and simply never writes {@code skillLevels}, leaving it 0. Zero is + * how "no requirement" is encoded, so a malformed requirement silently becomes NO requirement and the + * transport turns usable by every account. + * + *

That is not merely permissive. {@code PathfinderConfig.blocksWalkingEdgeWhenUnavailable} blocks + * the walking edge a shortcut spans when the shortcut is unusable, so the planner routes around it — + * with the gate erased the edge stays open and the planner actively PREFERS the shortcut as the + * shortest route, sending the walker back repeatedly. + * + *

Live case: the Draynor underwall tunnel rows carried {@code "42 Agility7"}, a Duration + * value separated by spaces instead of a tab. The field parsed as skill name {@code "Agility 7"}, + * matched nothing, and a 42 Agility shortcut became free. It looks correct in an editor, which is + * exactly why it needs a test rather than review. + */ +public class TransportSkillRequirementDataTest { + + private static final String RESOURCE_DIR = + "/net/runelite/client/plugins/microbot/shortestpath/"; + + /** Every transport TSV that carries a Skills column. */ + private static final List FILES = Arrays.asList( + "transports.tsv", + "agility_shortcuts.tsv", + "boats.tsv", + "canoes.tsv", + "charter_ships.tsv", + "fairy_rings.tsv", + "gnome_gliders.tsv", + "hot_air_balloons.tsv", + "magic_carpets.tsv", + "magic_mushtrees.tsv", + "minecarts.tsv", + "quetzals.tsv", + "ships.tsv", + "spirit_trees.tsv", + "teleportation_items.tsv"); + + /** Names the parser accepts: any Skill, plus the total/combat/quest-points prefixes. */ + private static boolean resolvable(String skillName) { + for (Skill skill : Skill.values()) { + if (skill.getName().equals(skillName)) { + return true; + } + } + String lower = skillName.toLowerCase(); + return lower.startsWith("total") || lower.startsWith("combat") || lower.startsWith("quest"); + } + + @Test + public void everySkillRequirementInShippedDataResolves() { + List offenders = new ArrayList<>(); + Set filesChecked = new HashSet<>(); + + for (String file : FILES) { + try (InputStream in = getClass().getResourceAsStream(RESOURCE_DIR + file)) { + if (in == null) { + continue; // file genuinely absent from this branch; other rows still get checked + } + filesChecked.add(file); + BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)); + String headerLine = reader.readLine(); + if (headerLine == null) { + continue; + } + String[] header = headerLine.split("\t", -1); + int skillsCol = -1; + for (int i = 0; i < header.length; i++) { + if ("Skills".equals(header[i].trim())) { + skillsCol = i; + break; + } + } + if (skillsCol < 0) { + continue; + } + + String line; + int lineNo = 1; + while ((line = reader.readLine()) != null) { + lineNo++; + if (line.startsWith("#") || line.trim().isEmpty()) { + continue; + } + String[] fields = line.split("\t", -1); + if (skillsCol >= fields.length) { + continue; + } + String cell = fields[skillsCol]; + if (cell.trim().isEmpty()) { + continue; + } + for (String requirement : cell.split(";")) { + String trimmed = requirement.trim(); + if (trimmed.isEmpty()) { + continue; + } + String[] levelAndSkill = trimmed.split("\\s+", 2); + if (levelAndSkill.length < 2) { + offenders.add(file + ":" + lineNo + " [" + cell + "] — no skill name"); + continue; + } + if (!resolvable(levelAndSkill[1].trim())) { + offenders.add(file + ":" + lineNo + " [" + cell + "] — '" + + levelAndSkill[1].trim() + "' is not a known skill " + + "(spaces where a tab belongs?)"); + } + } + } + } catch (Exception e) { + throw new AssertionError("failed reading " + file, e); + } + } + + assertFalse("precondition: the transport resources should be readable", filesChecked.isEmpty()); + assertTrue("skill requirements that the parser will silently DROP, making these transports " + + "usable by any account:\n " + + offenders.stream().collect(Collectors.joining("\n ")), + offenders.isEmpty()); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java index e9544ec8d99..f71005c7bd2 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/WalkerRouteCorpusTest.java @@ -1,8 +1,10 @@ package net.runelite.client.plugins.microbot.shortestpath; import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.ItemID; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathEdge; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap; import org.junit.BeforeClass; import org.junit.Test; @@ -18,6 +20,7 @@ import java.util.function.Predicate; import java.util.stream.Collectors; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -96,11 +99,25 @@ private static PathfinderConfig configWith(Predicate allow) { return config; } - private static List route(PathfinderConfig config, WorldPoint from, WorldPoint to) { + private static Pathfinder runPathfinder(PathfinderConfig config, WorldPoint from, WorldPoint to) { Pathfinder pf = new Pathfinder(config, from, to); pf.run(); assertTrue("pathfinder did not complete for " + from + " -> " + to, pf.isDone()); - return pf.getPath(); + return pf; + } + + private static List route(PathfinderConfig config, WorldPoint from, WorldPoint to) { + return runPathfinder(config, from, to).getPath(); + } + + private static boolean selectsTransport(Pathfinder pathfinder, Predicate predicate) { + List edges = pathfinder.getPathEdges(); + return edges != null && edges.stream().anyMatch(edge -> + edge.getTransport() != null && predicate.test(edge.getTransport())); + } + + private static boolean selectsTransportObject(Pathfinder pathfinder, int objectId) { + return selectsTransport(pathfinder, transport -> transport.getObjectId() == objectId); } private static boolean arrives(List path, WorldPoint goal, int tolerance) { @@ -116,10 +133,74 @@ private static boolean visits(List path, WorldPoint tile, int radius p.getPlane() == tile.getPlane() && p.distanceTo2D(tile) <= radius); } + private static boolean usesTransportType(List path, TransportType type) { + if (path == null || path.size() < 2) { + return false; + } + for (int index = 0; index < path.size() - 1; index++) { + WorldPoint origin = path.get(index); + WorldPoint destination = path.get(index + 1); + if (allTransports.getOrDefault(origin, Collections.emptySet()).stream().anyMatch(transport -> + transport.getType() == type && destination.equals(transport.getDestination()))) { + return true; + } + } + return false; + } + + private static boolean usesTransportObject(List path, int objectId) { + if (path == null || path.size() < 2) { + return false; + } + for (int index = 0; index < path.size() - 1; index++) { + WorldPoint origin = path.get(index); + WorldPoint destination = path.get(index + 1); + if (allTransports.getOrDefault(origin, Collections.emptySet()).stream().anyMatch(transport -> + transport.getObjectId() == objectId && destination.equals(transport.getDestination()))) { + return true; + } + } + return false; + } + private static final WorldPoint LUMBRIDGE = new WorldPoint(3222, 3218, 0); // ---- baseline ---------------------------------------------------------------------------------- + @Test + public void standardApeAtollSpellRetainsSourceAwareRequirementsWithoutMovingItsLanding() { + WorldPoint reviewedLanding = new WorldPoint(2797, 2798, 1); + Transport teleport = allTransports.getOrDefault(null, Collections.emptySet()).stream() + .filter(transport -> transport.getType() == TransportType.TELEPORTATION_SPELL) + .filter(transport -> reviewedLanding.equals(transport.getDestination())) + .filter(transport -> "Ape Atoll Teleport".equals(transport.getDisplayInfo())) + .findFirst() + .orElseThrow(() -> new AssertionError("standard Ape Atoll spell row is missing")); + + assertEquals("the requirement import must not change the reviewed landing", + reviewedLanding, teleport.getDestination()); + assertEquals("fire, water, law and banana are separate AND-clauses", + 4, teleport.getItemRequirements().size()); + TransportItemRequirement fire = teleport.getItemRequirements().stream() + .filter(requirement -> requirement.getAlternatives().containsKey(ItemID.FIRERUNE)) + .findFirst() + .orElseThrow(() -> new AssertionError("fire-rune clause is missing")); + TransportItemRequirement water = teleport.getItemRequirements().stream() + .filter(requirement -> requirement.getAlternatives().containsKey(ItemID.WATERRUNE)) + .findFirst() + .orElseThrow(() -> new AssertionError("water-rune clause is missing")); + TransportItemRequirement banana = teleport.getItemRequirements().stream() + .filter(requirement -> requirement.getAlternatives().containsKey(ItemID.BANANA)) + .findFirst() + .orElseThrow(() -> new AssertionError("banana clause is missing")); + + assertTrue("one Twinflame staff must satisfy both elemental clauses", + fire.getStaffAlternatives().contains(ItemID.TWINFLAME_STAFF) + && water.getStaffAlternatives().contains(ItemID.TWINFLAME_STAFF)); + assertTrue("ordinary inventory items must not become equipment providers", + banana.getStaffAlternatives().isEmpty() && banana.getOffhandAlternatives().isEmpty()); + } + @Test public void lumbridgeToGrandExchange_plainWalkArrives() { List path = route(configWith(WalkerRouteCorpusTest::unrestricted), @@ -127,6 +208,248 @@ public void lumbridgeToGrandExchange_plainWalkArrives() { assertTrue("baseline overland route must arrive", arrives(path, new WorldPoint(3164, 3485, 0), 5)); } + @Test + public void quetzalNetworkUsesCurrentQuetzacalliLanding() { + WorldPoint aldarin = new WorldPoint(1389, 2901, 0); + WorldPoint quetzacalli = new WorldPoint(1510, 3222, 0); + List path = route(configWith(transport -> unrestricted(transport) + || transport.getType() == TransportType.QUETZAL), + aldarin, quetzacalli); + + assertTrue("quetzal route must arrive at the current Gorge landing", + arrives(path, quetzacalli, 1)); + assertTrue("the long Varlamore crossing must use the QUETZAL network", + usesTransportType(path, TransportType.QUETZAL)); + } + + @Test + public void riverDougneCanoeConnectsCastleWarsToTreeGnomeStronghold() { + WorldPoint castleWarsStation = new WorldPoint(2439, 3135, 0); + WorldPoint strongholdLanding = new WorldPoint(2523, 3408, 0); + List path = route(configWith(transport -> unrestricted(transport) + || transport.getType() == TransportType.CANOE), + castleWarsStation, strongholdLanding); + + assertTrue("River Dougne route must arrive at Tree Gnome Stronghold", + arrives(path, strongholdLanding, 1)); + assertTrue("the long western crossing must use the CANOE network", + usesTransportType(path, TransportType.CANOE)); + } + + @Test + public void lagunaAuroraeSpiritTreeHasTheCompleteReviewedOutboundPerimeter() { + Set reviewedOrigins = Set.of( + new WorldPoint(1201, 2788, 0), + new WorldPoint(1202, 2788, 0), + new WorldPoint(1201, 2787, 0), + new WorldPoint(1201, 2786, 0), + new WorldPoint(1204, 2786, 0), + new WorldPoint(1201, 2785, 0), + new WorldPoint(1202, 2785, 0), + new WorldPoint(1203, 2785, 0), + new WorldPoint(1204, 2785, 0)); + WorldPoint grandExchange = new WorldPoint(3185, 3508, 0); + + for (WorldPoint origin : reviewedOrigins) { + assertTrue("Laguna perimeter origin must offer the reviewed spirit-tree network: " + origin, + allTransports.getOrDefault(origin, Collections.emptySet()).stream() + .anyMatch(transport -> transport.getType() == TransportType.SPIRIT_TREE + && transport.getObjectId() == 26262 + && grandExchange.equals(transport.getDestination()))); + } + + WorldPoint pohSpiritTree = new WorldPoint(2007, 5700, 0); + assertFalse("POH spirit-tree execution is programmatic and must not be duplicated in static data", + allTransports.getOrDefault(pohSpiritTree, Collections.emptySet()).stream() + .anyMatch(transport -> transport.getType() == TransportType.SPIRIT_TREE)); + assertFalse("the static destination list must not duplicate the programmatic POH spirit tree", + allTransports.getOrDefault(null, Collections.emptySet()).stream() + .anyMatch(transport -> transport.getType() == TransportType.SPIRIT_TREE + && pohSpiritTree.equals(transport.getDestination()))); + + WorldPoint northWestApproach = new WorldPoint(1201, 2788, 0); + List path = route(configWith(transport -> unrestricted(transport) + || transport.getType() == TransportType.SPIRIT_TREE), + northWestApproach, grandExchange); + assertTrue("Laguna Aurorae must route outbound through its spirit tree", + arrives(path, grandExchange, 1)); + assertTrue("the selected Laguna edge must retain the current object id", + usesTransportObject(path, 26262)); + } + + @Test + public void elementalWorkshopWallUsesConcreteObjectAndFailsClosedForUnverifiedKeyring() { + WorldPoint south = new WorldPoint(2709, 3495, 0); + WorldPoint north = new WorldPoint(2709, 3496, 0); + Set endpoints = Set.of(south, north); + + for (WorldPoint origin : endpoints) { + WorldPoint destination = origin.equals(south) ? north : south; + Transport wall = allTransports.getOrDefault(origin, Collections.emptySet()).stream() + .filter(transport -> destination.equals(transport.getDestination())) + .filter(transport -> transport.getObjectId() == 26115) + .findFirst() + .orElseThrow(() -> new AssertionError( + "Elemental Workshop wall edge is missing: " + origin + " -> " + destination)); + + assertEquals("the reviewed wall action must remain explicit", "Open", wall.getAction()); + assertEquals("the wall has one OR-clause", 1, wall.getItemRequirements().size()); + assertEquals("only the concrete battered key is currently verifiable", + Collections.singleton(ItemID.ELEMENTAL_WORKSHOP_KEY), + wall.getItemRequirements().get(0).getItemIds()); + assertFalse("a steel key ring does not prove that it contains the battered key", + wall.getItemRequirements().get(0).getItemIds().contains(ItemID.FAVOUR_KEY_RING)); + } + + Predicate batteredKeyState = transport -> unrestricted(transport) + || transport.getItemRequirements().stream().allMatch(requirement -> + requirement.isSatisfiedBy(itemId -> + itemId == ItemID.ELEMENTAL_WORKSHOP_KEY ? 1 : 0)); + Pathfinder withKeyPathfinder = runPathfinder(configWith(batteredKeyState), south, north); + List withKey = withKeyPathfinder.getPath(); + assertTrue("the concrete battered key must unlock the direct wall crossing", + arrives(withKey, north, 0)); + assertTrue("the route must select the current Elemental Workshop wall object", + selectsTransportObject(withKeyPathfinder, 26115)); + + Predicate keyRingOnlyState = transport -> unrestricted(transport) + || transport.getItemRequirements().stream().allMatch(requirement -> + requirement.isSatisfiedBy(itemId -> itemId == ItemID.FAVOUR_KEY_RING ? 1 : 0)); + Pathfinder keyRingOnlyPathfinder = runPathfinder(configWith(keyRingOnlyState), south, north); + List keyRingOnly = keyRingOnlyPathfinder.getPath(); + assertFalse("an unverified key-ring state must not select the wall transport", + selectsTransportObject(keyRingOnlyPathfinder, 26115)); + } + + @Test + public void lumbridgeFarmFenceUsesCurrentOneTileLanding() { + WorldPoint south = new WorldPoint(3240, 3334, 0); + WorldPoint north = new WorldPoint(3240, 3335, 0); + List path = route(configWith(transport -> unrestricted(transport) + || transport.getObjectId() == 16518), + south, north); + + assertTrue("the current fence landing must be reachable", arrives(path, north, 0)); + assertTrue("crossing the closed fence must select the agility shortcut edge", + usesTransportType(path, TransportType.AGILITY_SHORTCUT)); + } + + @Test + public void northernVarlamoreRocksUseCurrentEightTileLanding() { + WorldPoint south = new WorldPoint(1324, 3777, 0); + WorldPoint north = new WorldPoint(1324, 3785, 0); + List path = route(configWith(transport -> unrestricted(transport) + || transport.getObjectId() == 34397), + south, north); + + assertTrue("the reviewed northern landing must be reachable", arrives(path, north, 0)); + assertTrue("the rock face must select the agility shortcut edge", + usesTransportType(path, TransportType.AGILITY_SHORTCUT)); + } + + @Test + public void trollheimClimbingRocksUseBootsGatedAscent() { + WorldPoint west = new WorldPoint(2820, 3635, 0); + WorldPoint east = new WorldPoint(2822, 3635, 0); + List path = route(configWith(transport -> unrestricted(transport) + || (transport.getType() == TransportType.AGILITY_SHORTCUT + && transport.getObjectId() == 3748)), + west, east); + + assertTrue("the climbing-rock landing must be reachable", arrives(path, east, 0)); + assertTrue("the ascent must use the boots-gated agility edge", + usesTransportType(path, TransportType.AGILITY_SHORTCUT)); + } + + @Test + public void isafdarDenseForestChainUsesReviewedShortcutLandings() { + WorldPoint south = new WorldPoint(2188, 3162, 0); + WorldPoint north = new WorldPoint(2188, 3171, 0); + Set chainObjectIds = Set.of(3939, 3998, 3999); + List path = route(configWith(transport -> unrestricted(transport) + || (transport.getType() == TransportType.AGILITY_SHORTCUT + && chainObjectIds.contains(transport.getObjectId()))), + south, north); + + assertTrue("the three-obstacle forest chain must reach its reviewed northern landing", + arrives(path, north, 0)); + assertTrue("the route must traverse the first dense-forest landing", + visits(path, new WorldPoint(2188, 3165, 0), 0)); + assertTrue("the route must traverse the second dense-forest landing", + visits(path, new WorldPoint(2188, 3168, 0), 0)); + assertTrue("the forest chain must use Agility-gated shortcut edges", + usesTransportType(path, TransportType.AGILITY_SHORTCUT)); + } + + @Test + public void brimhavenDungeonPipeUsesAgilityShortcut() { + WorldPoint south = new WorldPoint(2698, 9492, 0); + WorldPoint north = new WorldPoint(2698, 9500, 0); + List path = route(configWith(transport -> unrestricted(transport) + || transport.getObjectId() == 21727), south, north); + + assertTrue("Brimhaven pipe route must reach the reviewed landing", arrives(path, north, 0)); + assertTrue("Brimhaven pipe route must use object 21727", usesTransportObject(path, 21727)); + assertTrue("Brimhaven pipe must be represented as an agility shortcut", + usesTransportType(path, TransportType.AGILITY_SHORTCUT)); + } + + @Test + public void karamjaRocksUseAgilityShortcut() { + WorldPoint west = new WorldPoint(2791, 2978, 0); + WorldPoint east = new WorldPoint(2795, 2978, 0); + List path = route(configWith(transport -> unrestricted(transport) + || transport.getObjectId() == 2231), west, east); + + assertTrue("Karamja rocks route must reach the reviewed landing", arrives(path, east, 0)); + assertTrue("Karamja rocks route must use object 2231", usesTransportObject(path, 2231)); + assertTrue("Karamja rocks must be represented as an agility shortcut", + usesTransportType(path, TransportType.AGILITY_SHORTCUT)); + } + + @Test + public void lumbridgeCellarHoleUsesQuestProgressShortcut() { + WorldPoint west = new WorldPoint(3219, 9618, 0); + WorldPoint east = new WorldPoint(3221, 9618, 0); + List path = route(configWith(transport -> unrestricted(transport) + || transport.getObjectId() == 6905), west, east); + + assertTrue("Lumbridge cellar route must reach the reviewed hole landing", arrives(path, east, 0)); + assertTrue("Lumbridge cellar route must use hole object 6905", usesTransportObject(path, 6905)); + assertTrue("Lumbridge cellar hole must be represented as an agility shortcut", + usesTransportType(path, TransportType.AGILITY_SHORTCUT)); + } + + @Test + public void slayerTowerGroundFloorChainUsesAgilityShortcut() { + WorldPoint ground = new WorldPoint(3421, 3550, 0); + WorldPoint firstFloor = new WorldPoint(3421, 3550, 1); + List path = route(configWith(transport -> unrestricted(transport) + || transport.getObjectId() == 16537), ground, firstFloor); + + assertTrue("Slayer Tower chain must reach the first floor", arrives(path, firstFloor, 0)); + assertTrue("Slayer Tower route must use chain object 16537", usesTransportObject(path, 16537)); + assertTrue("Slayer Tower chain must be represented as an agility shortcut", + usesTransportType(path, TransportType.AGILITY_SHORTCUT)); + } + + @Test + public void darkmeyerWallChainUsesBothAgilityShortcuts() { + WorldPoint west = new WorldPoint(3667, 3375, 0); + WorldPoint middle = new WorldPoint(3670, 3375, 0); + WorldPoint east = new WorldPoint(3673, 3375, 0); + List path = route(configWith(transport -> unrestricted(transport) + || transport.getObjectId() == 39541 + || transport.getObjectId() == 39542), west, east); + + assertTrue("Darkmeyer wall chain must reach the eastern landing", arrives(path, east, 0)); + assertTrue("Darkmeyer wall chain must cross the middle landing", visits(path, middle, 0)); + assertTrue("Darkmeyer wall chain must use west wall object 39542", usesTransportObject(path, 39542)); + assertTrue("Darkmeyer wall chain must use east wall object 39541", usesTransportObject(path, 39541)); + assertTrue("Darkmeyer walls must be represented as agility shortcuts", + usesTransportType(path, TransportType.AGILITY_SHORTCUT)); + } + // ---- Falador-area farm (the walk-1 "hairpin" report — resolved: no bug) ------------------------ @Test @@ -154,6 +477,94 @@ public void ruinsOfUnkah_reachedViaTheFerry() { assertTrue("route to Ruins of Unkah must arrive", arrives(path, unkahBank, 5)); assertTrue("route to Ruins of Unkah must use the ferry landing", visits(path, new WorldPoint(3148, 2843, 0), 3)); + assertTrue("route to Ruins of Unkah must contain a BOAT transport edge", + usesTransportType(path, TransportType.BOAT)); + } + + @Test + public void portSarimToMusaPoint_usesShipAndGangplank() { + WorldPoint portSarim = new WorldPoint(3029, 3217, 0); + WorldPoint musaPoint = new WorldPoint(2956, 3146, 0); + List path = route(configWith(t -> unrestricted(t) + || (t.getType() == TransportType.SHIP + && t.getCurrencyAmount() == 30 + && (t.getQuests() == null || t.getQuests().isEmpty()))), + portSarim, musaPoint); + + assertTrue("30-coin ship route must arrive at Musa Point", arrives(path, musaPoint, 1)); + assertTrue("Port Sarim to Musa Point must contain a SHIP transport edge", + usesTransportType(path, TransportType.SHIP)); + assertTrue("Microbot's ship route must retain the Musa Point deck/gangplank transition", + visits(path, new WorldPoint(2956, 3143, 1), 0)); + } + + @Test + public void pandemoniumShipsAreQuestAndFareGatedDirectTerminalEdges() { + WorldPoint portSarim = new WorldPoint(3029, 3217, 0); + WorldPoint musaPoint = new WorldPoint(2956, 3146, 0); + WorldPoint pandemonium = new WorldPoint(3064, 3003, 0); + Object[][] reviewed = { + {portSarim, pandemonium, 14979, "The Pandemonium", "Pandemonium"}, + {pandemonium, portSarim, 8631, "Port Sarim", "Port Sarim"}, + {musaPoint, pandemonium, 14985, "The Pandemonium", "Pandemonium"}, + {pandemonium, musaPoint, 8631, "Musa Point", "Musa Point"} + }; + + for (Object[] expectation : reviewed) { + WorldPoint origin = (WorldPoint) expectation[0]; + WorldPoint destination = (WorldPoint) expectation[1]; + int npcId = (int) expectation[2]; + String action = (String) expectation[3]; + String display = (String) expectation[4]; + Transport ship = allTransports.getOrDefault(origin, Collections.emptySet()).stream() + .filter(transport -> destination.equals(transport.getDestination())) + .filter(transport -> transport.getType() == TransportType.SHIP) + .filter(transport -> transport.getObjectId() == npcId) + .findFirst() + .orElseThrow(() -> new AssertionError( + "Pandemonium ship edge is missing: " + origin + " -> " + destination)); + + assertEquals("the current NPC menu action must remain exact", action, ship.getAction()); + assertEquals("the network label must remain stable", display, ship.getDisplayInfo()); + assertEquals("all four routes charge the reviewed fare", 30, ship.getCurrencyAmount()); + assertFalse("the Pandemonium quest gate must not be dropped", ship.getQuests().isEmpty()); + assertEquals(TransportExecutionRegistry.Executor.TERMINAL_TRAVEL, + TransportExecutionRegistry.executorFor(ship).orElse(null)); + assertEquals(TransportExecutionRegistry.TerminalTravelMode.DIRECT, + TransportExecutionRegistry.terminalTravelModeFor(ship).orElse(null)); + } + + Predicate pandemoniumShip = transport -> transport.getType() == TransportType.SHIP + && (transport.getObjectId() == 14979 + || transport.getObjectId() == 14985 + || transport.getObjectId() == 8631) + && (pandemonium.equals(transport.getOrigin()) + || pandemonium.equals(transport.getDestination())); + Pathfinder unlocked = runPathfinder(configWith(transport -> unrestricted(transport) + || pandemoniumShip.test(transport)), + portSarim, pandemonium); + assertTrue("the reviewed ship must reach the Pandemonium dock", + arrives(unlocked.getPath(), pandemonium, 0)); + assertTrue("the selected path must own an explicit Pandemonium SHIP edge", + selectsTransport(unlocked, pandemoniumShip)); + + Pathfinder locked = runPathfinder(configWith(WalkerRouteCorpusTest::unrestricted), + portSarim, pandemonium); + assertFalse("without the quest and fare the planner must not select a Pandemonium ship", + selectsTransport(locked, pandemoniumShip)); + } + + @Test + public void treeGnomeVillageShortcut_usesElkoyNpcTravel() { + WorldPoint mazeEntrance = new WorldPoint(2503, 3193, 0); + WorldPoint villageSide = new WorldPoint(2515, 3159, 0); + List path = route(configWith(t -> unrestricted(t) + || (t.getType() == TransportType.NPC && "Elkoy".equals(t.getName()))), + mazeEntrance, villageSide); + + assertTrue("Elkoy shortcut must reach the village side", arrives(path, villageSide, 1)); + assertTrue("Tree Gnome Village shortcut must contain an NPC transport edge", + usesTransportType(path, TransportType.NPC)); } @Test @@ -197,11 +608,42 @@ public void shantaySouthbound_withCoinsOnly_crossesTheGate() { public void shantaySouthbound_withNothing_neverCrossesTheGate() { List path = route(configWith(WalkerRouteCorpusTest::unrestricted), NORTH_OF_GATE, SOUTH_OF_GATE); - // Same predicate as the positive tests. The old form required BOTH tiles either side of the - // gate at radius 0, so a diagonal step across the gate satisfied neither and the assertion - // passed while the route did cross. - assertFalse("without a ticket or coins the route must not cross the gate", - visits(path, GATE, 2)); + // Crossing means a path tile strictly SOUTH of the gate line at the pass. The previous + // proximity proxy (visits within 2 of the gate) also failed a route that walks UP TO the + // gate's north side and stops — which is exactly what the sealed-target fast path now + // produces, and exactly what a player without coins does. (The proxy before THAT required + // both flanking tiles at radius 0 and missed a diagonal crossing; measuring the crossing + // itself ends the proxy games.) + boolean crossed = path.stream().anyMatch(p -> p != null + && p.getPlane() == GATE.getPlane() + && p.getY() < GATE.getY() + && Math.abs(p.getX() - GATE.getX()) <= 4); + assertFalse("without a ticket or coins the route must not cross the gate", crossed); + assertFalse("without a ticket or coins the route must not arrive south", + arrives(path, SOUTH_OF_GATE, 3)); + } + + // ---- Varrock museum interior (the Kudos dead-end) ---------------------------------------------- + + /** + * The museum guard barrier (24536) is a MOVES-YOU gate, measured 2026-08-08 through the agent + * server: one click on "Open" relocates the player across it (3447 -> 3446 -> 3447, reproduced + * three times) and the gate never enters an open state, so the runtime door pipeline can never + * resolve it. Two independent defects kept the museum interior unroutable: restrictions.tsv + * banned the doorway tiles outright (planner could not stand there), and the barrier had no + * catalog rows (executor had nothing to click). Assert the route SELECTS the transport rather + * than merely passing near the gate tile — an earlier version of this test checked proximity and + * would have passed on a route that never crossed. + */ + @Test + public void varrockMuseumGuardBarrierIsATransport() { + PathfinderConfig config = configWith(WalkerRouteCorpusTest::unrestricted); + Pathfinder pf = runPathfinder(config, + new WorldPoint(3261, 3449, 0), new WorldPoint(3261, 3443, 0)); + assertTrue("route across the museum barrier must select gate 24536", + selectsTransportObject(pf, 24536)); + assertTrue("route must arrive south of the barrier", + arrives(pf.getPath(), new WorldPoint(3261, 3443, 0), 1)); } // ---- Port Sarim, Wydin's shop (the door-poisoning incident) ------------------------------------ @@ -322,4 +764,142 @@ public void whiteWolfTunnel_notUsedWithoutTheQuest() { assertFalse("a player without Fishing Contest must not be routed through the tunnel", visits(surface, TUNNEL_EAST_UNDER, 5)); } + + // ---- Draynor sewers (surface/underground transition coverage) --------------------------------- + + private static final WorldPoint DRAYNOR_SEWER_EAST_SURFACE = new WorldPoint(3118, 3243, 0); + private static final WorldPoint DRAYNOR_SEWER_EAST_UNDER = new WorldPoint(3118, 9644, 0); + private static final WorldPoint DRAYNOR_SEWER_WEST_UNDER = new WorldPoint(3084, 9673, 0); + + private static boolean isDraynorWestTransition(Transport transport) { + WorldPoint origin = transport.getOrigin(); + WorldPoint destination = transport.getDestination(); + if (origin == null || destination == null) { + return false; + } + boolean originWest = origin.getX() >= 3083 && origin.getX() <= 3085 + && (origin.getY() >= 3271 && origin.getY() <= 3273 + || origin.getY() >= 9671 && origin.getY() <= 9673); + boolean destinationWest = destination.getX() >= 3083 && destination.getX() <= 3085 + && (destination.getY() >= 3271 && destination.getY() <= 3273 + || destination.getY() >= 9671 && destination.getY() <= 9673); + return originWest && destinationWest; + } + + @Test + public void draynorSewer_eastEntranceConnectsSurfaceAndWestUnderground() { + // Disable the west ladders so both directions must use the east transition and traverse the + // underground corridor. This prevents a regression from being hidden by walking above ground + // to a different trapdoor before entering the sewer. + PathfinderConfig config = configWith(t -> unrestricted(t) && !isDraynorWestTransition(t)); + + List descending = route(config, DRAYNOR_SEWER_EAST_SURFACE, DRAYNOR_SEWER_WEST_UNDER); + assertTrue("east trapdoor must reach the west side of Draynor sewers", + arrives(descending, DRAYNOR_SEWER_WEST_UNDER, 1)); + assertTrue("descent route must enter at the mapped east underground landing", + visits(descending, DRAYNOR_SEWER_EAST_UNDER, 2)); + + List ascending = route(config, DRAYNOR_SEWER_WEST_UNDER, DRAYNOR_SEWER_EAST_SURFACE); + assertTrue("west sewer must return to the surface through the east ladder", + arrives(ascending, DRAYNOR_SEWER_EAST_SURFACE, 1)); + assertTrue("ascent route must approach the mapped east underground ladder", + visits(ascending, DRAYNOR_SEWER_EAST_UNDER, 2)); + } + + // ---- Barrows mounds, individual crypts and randomized tunnel boundary ------------------------- + + /** + * Surface dig/route-anchor tile, deterministic individual-crypt stair/landing, sarcophagus approach + * and exit-stair object id. These values are shared with Quest Helper's reviewed Barrows zones and + * object steps; the fifth value is the sarcophagus object id, which must never become a static + * tunnel edge because the empty crypt is randomized per run. A crypt exit can spawn on another tile + * within its surface mound, so the surface point is a planner anchor rather than an exact live landing. + */ + private static final Object[][] BARROWS_CRYPTS = { + {new WorldPoint(3564, 3291, 0), new WorldPoint(3559, 9703, 3), + new WorldPoint(3554, 9699, 3), 20667, 20770}, + {new WorldPoint(3575, 3299, 0), new WorldPoint(3558, 9718, 3), + new WorldPoint(3555, 9713, 3), 20668, 20720}, + {new WorldPoint(3578, 3281, 0), new WorldPoint(3534, 9706, 3), + new WorldPoint(3539, 9702, 3), 20669, 20722}, + {new WorldPoint(3567, 3274, 0), new WorldPoint(3546, 9686, 3), + new WorldPoint(3549, 9683, 3), 20670, 20771}, + {new WorldPoint(3553, 3281, 0), new WorldPoint(3566, 9683, 3), + new WorldPoint(3568, 9686, 3), 20671, 20721}, + {new WorldPoint(3556, 3297, 0), new WorldPoint(3578, 9704, 3), + new WorldPoint(3572, 9706, 3), 20672, 20772} + }; + + private static boolean isBarrowsDig(Transport transport) { + return TransportExecutionRegistry.executorFor(transport).orElse(null) + == TransportExecutionRegistry.Executor.BARROWS_DIG; + } + + private static boolean usesExactTransport(List path, WorldPoint origin, + WorldPoint destination, + TransportExecutionRegistry.Executor executor) { + if (path == null || path.size() < 2) { + return false; + } + for (int index = 0; index < path.size() - 1; index++) { + if (!origin.equals(path.get(index)) || !destination.equals(path.get(index + 1))) { + continue; + } + if (allTransports.getOrDefault(origin, Collections.emptySet()).stream().anyMatch(transport -> + destination.equals(transport.getDestination()) + && TransportExecutionRegistry.executorFor(transport).orElse(null) == executor)) { + return true; + } + } + return false; + } + + @Test + public void barrowsMoundsAndIndividualCryptExitsAreStaticallyRoutable() { + PathfinderConfig withSpade = configWith(transport -> unrestricted(transport) || isBarrowsDig(transport)); + PathfinderConfig withoutSpecialRequirements = configWith(WalkerRouteCorpusTest::unrestricted); + + for (Object[] crypt : BARROWS_CRYPTS) { + WorldPoint surface = (WorldPoint) crypt[0]; + WorldPoint stair = (WorldPoint) crypt[1]; + WorldPoint sarcophagusApproach = (WorldPoint) crypt[2]; + int stairObjectId = (int) crypt[3]; + + List entering = route(withSpade, surface, sarcophagusApproach); + assertTrue("mound dig must enter the matching individual crypt: " + surface, + arrives(entering, sarcophagusApproach, 0)); + assertTrue("mound route must retain the exact spade executor edge: " + surface, + usesExactTransport(entering, surface, stair, + TransportExecutionRegistry.Executor.BARROWS_DIG)); + + List leaving = route(withoutSpecialRequirements, sarcophagusApproach, surface); + assertTrue("individual crypt must route to its own surface-mound anchor: " + stair, + arrives(leaving, surface, 2)); + assertTrue("crypt exit must use its reviewed staircase object: " + stairObjectId, + usesTransportObject(leaving, stairObjectId)); + } + } + + @Test + public void barrowsRandomSarcophagusTunnelIsNotInventedAsAStaticTransport() { + Set sarcophagusIds = Arrays.stream(BARROWS_CRYPTS) + .map(crypt -> (Integer) crypt[4]) + .collect(Collectors.toSet()); + List staticSarcophagusEdges = allTransports.values().stream() + .flatMap(Set::stream) + .filter(transport -> sarcophagusIds.contains(transport.getObjectId())) + .collect(Collectors.toList()); + + assertTrue("the empty sarcophagus is randomized and must be observed live, not statically routed: " + + staticSarcophagusEdges, + staticSarcophagusEdges.isEmpty()); + + WorldPoint surface = (WorldPoint) BARROWS_CRYPTS[0][0]; + WorldPoint tunnelChest = new WorldPoint(3551, 9695, 0); + List attempted = route( + configWith(transport -> unrestricted(transport) || isBarrowsDig(transport)), + surface, tunnelChest); + assertFalse("a mound dig alone must not claim deterministic access to the randomized tunnel", + arrives(attempted, tunnelChest, 2)); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgeSessionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgeSessionTest.java new file mode 100644 index 00000000000..5753476e33c --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgeSessionTest.java @@ -0,0 +1,72 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.api.coords.WorldPoint; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.Collections; +import java.util.HashMap; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Learned blocked edges are SESSION-ONLY by policy (2026-08-07): the observing session blocks the + * edge immediately — it watched the failure happen, and anything less loops the walker into the same + * obstacle — but nothing is persisted and nothing is loaded. The hand-curated blocked_edges.tsv is + * the sole cross-session authority. This replaces the two-strike persistent store, whose probation + * machinery existed to manage its own poisonings and whose default file leaked developer state into + * every test that built a config. + */ +public class LearnedBlockedEdgeSessionTest { + + private static final WorldPoint FROM = new WorldPoint(3012, 3204, 0); + private static final WorldPoint TO = new WorldPoint(3011, 3204, 0); + + private static SplitFlagMap collisionMap; + + @BeforeClass + public static void loadMap() { + collisionMap = SplitFlagMap.fromResources(); + } + + private static PathfinderConfig newConfig() { + return new PathfinderConfig(collisionMap, new HashMap<>(), Collections.emptyList(), null, null); + } + + @Test + public void firstObservationBlocksTheSession() { + PathfinderConfig config = newConfig(); + assertTrue("first observation must block this session", + config.learnBlockedEdge(FROM, TO, "wrong-traversal")); + assertFalse("repeat in the same session is already blocked", + config.learnBlockedEdge(FROM, TO, "wrong-traversal")); + } + + /** Directionality: a one-way failure must not condemn the reverse crossing. */ + @Test + public void onlyTheAttemptedDirectionIsBlocked() { + PathfinderConfig config = newConfig(); + assertTrue(config.learnBlockedEdge(FROM, TO, "wrong-traversal")); + assertTrue("the reverse direction is a separate observation", + config.learnBlockedEdge(TO, FROM, "wrong-traversal")); + } + + /** The whole policy: nothing learned in one session exists in the next. */ + @Test + public void nothingSurvivesIntoAFreshConfig() { + PathfinderConfig first = newConfig(); + assertTrue(first.learnBlockedEdge(FROM, TO, "wrong-traversal")); + + PathfinderConfig restarted = newConfig(); + assertTrue("a fresh session must not inherit the block", + restarted.learnBlockedEdge(FROM, TO, "wrong-traversal")); + } + + @Test + public void nullEndpointsAreRejected() { + PathfinderConfig config = newConfig(); + assertFalse(config.learnBlockedEdge(null, TO, "x")); + assertFalse(config.learnBlockedEdge(FROM, null, "x")); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgeStrikesTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgeStrikesTest.java deleted file mode 100644 index e8706353264..00000000000 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgeStrikesTest.java +++ /dev/null @@ -1,109 +0,0 @@ -package net.runelite.client.plugins.microbot.shortestpath.pathfinder; - -import net.runelite.api.coords.WorldPoint; -import org.junit.Before; -import org.junit.BeforeClass; -import org.junit.Test; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * Two-strike hardening, tested purely through {@code learnBlockedEdge}'s return value (true = newly - * blocked this session, false = already enforced) and the on-disk rows — no private state: - * - *

    - *
  • The observing session blocks immediately (it watched the failure happen).
  • - *
  • A single strike does NOT survive a restart — the row is probation, so one bad sample (the - * Wydin door poisoning, which needed a hand-edit) self-heals.
  • - *
  • A second observation, independent by the 10-minute window, confirms and enforces forever.
  • - *
  • Legacy rows without strike columns keep their unconditional trust.
  • - *
- * - * A "restart" is simulated with the same package-private seam the store already exposes: - * {@code setLearnedBlockedEdgesFileForTest} clears and reloads from the file. - */ -public class LearnedBlockedEdgeStrikesTest { - - private static final WorldPoint FROM = new WorldPoint(3012, 3204, 0); - private static final WorldPoint TO = new WorldPoint(3011, 3204, 0); - - private static SplitFlagMap collisionMap; - - private PathfinderConfig config; - private File store; - - @BeforeClass - public static void loadMap() { - collisionMap = SplitFlagMap.fromResources(); - } - - @Before - public void setUp() throws Exception { - store = Files.createTempFile("learned-strikes", ".tsv").toFile(); - store.deleteOnExit(); - Files.delete(store.toPath()); - config = new PathfinderConfig(collisionMap, new HashMap<>(), Collections.emptyList(), null, null); - config.setLearnedBlockedEdgesFileForTest(store); - } - - private void simulateRestart() { - config.setLearnedBlockedEdgesFileForTest(store); - } - - @Test - public void firstStrikeBlocksTheSessionButDoesNotSurviveRestart() { - assertTrue("first observation must block this session", - config.learnBlockedEdge(FROM, TO, "wrong-traversal")); - assertFalse("repeat in the same session is already enforced", - config.learnBlockedEdge(FROM, TO, "wrong-traversal")); - - List rows = LearnedBlockedEdges.load(store); - assertEquals(1, rows.size()); - assertEquals("persisted on probation", 1, rows.get(0).strikes); - - simulateRestart(); - assertTrue("a probation row must NOT be enforced on load — learning it again must succeed", - config.learnBlockedEdge(FROM, TO, "wrong-traversal")); - assertEquals("a re-observation within the independence window must not confirm", - 1, LearnedBlockedEdges.load(store).get(0).strikes); - } - - @Test - public void independentSecondStrikeConfirmsAndEnforces() { - long elevenMinutesAgo = System.currentTimeMillis() - 11 * 60_000L; - LearnedBlockedEdges.append(store, new LearnedBlockedEdges.Edge( - FROM, TO, false, "wrong-traversal", 1, elevenMinutesAgo)); - simulateRestart(); - - assertTrue("probation row is not enforced, so the session may observe it again", - config.learnBlockedEdge(FROM, TO, "wrong-traversal")); - - List rows = LearnedBlockedEdges.load(store); - assertEquals(1, rows.size()); - assertEquals("independent second strike must confirm", 2, rows.get(0).strikes); - - simulateRestart(); - assertFalse("a confirmed row must be enforced on load", - config.learnBlockedEdge(FROM, TO, "wrong-traversal")); - } - - @Test - public void legacyRowsWithoutStrikeColumnsStayEnforced() throws Exception { - String content = "# Origin\tDestination\tBidirectional\tDisplay info" + System.lineSeparator() - + "3012 3204 0\t3011 3204 0\tfalse\tlegacy hand-copied row" + System.lineSeparator(); - Files.write(store.toPath(), content.getBytes(StandardCharsets.UTF_8)); - simulateRestart(); - - assertFalse("legacy rows predate strike tracking and keep their unconditional trust", - config.learnBlockedEdge(FROM, TO, "wrong-traversal")); - } -} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgesTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgesTest.java deleted file mode 100644 index 474b9fe7f92..00000000000 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/LearnedBlockedEdgesTest.java +++ /dev/null @@ -1,138 +0,0 @@ -package net.runelite.client.plugins.microbot.shortestpath.pathfinder; - -import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; -import org.junit.Test; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * Covers the learned-blocked-edge substrate: the human-editable TSV round-trip and its lenient parsing, - * plus the packed-edge block check the pathfinder actually consults ({@link PathfinderConfig#isBlockedTransportStep}). - * Deliberately avoids constructing a full {@link PathfinderConfig} (heavy game deps) — the graph wiring is - * exercised through the same static predicate {@code getNeighbors}/{@code getReverseNeighbors} use. - */ -public class LearnedBlockedEdgesTest { - - private static final WorldPoint FROM = new WorldPoint(3200, 3200, 0); - private static final WorldPoint TO = new WorldPoint(3201, 3200, 0); // one tile east - - @Test - public void appendThenLoadRoundTrips() throws Exception { - File file = Files.createTempFile("learned-edges", ".tsv").toFile(); - file.deleteOnExit(); - Files.delete(file.toPath()); // start from "no file" so append writes the header - - LearnedBlockedEdges.append(file, new LearnedBlockedEdges.Edge(FROM, TO, false, "wrong-traversal door @ 3200,3200,0")); - - List loaded = LearnedBlockedEdges.load(file); - assertEquals(1, loaded.size()); - assertEquals(FROM, loaded.get(0).origin); - assertEquals(TO, loaded.get(0).destination); - assertFalse(loaded.get(0).bidirectional); - assertTrue(loaded.get(0).info.contains("wrong-traversal")); - } - - @Test - public void strikeColumnsRoundTripAndSaveRewrites() throws Exception { - File file = Files.createTempFile("learned-edges-strikes", ".tsv").toFile(); - file.deleteOnExit(); - Files.delete(file.toPath()); - - LearnedBlockedEdges.append(file, new LearnedBlockedEdges.Edge(FROM, TO, false, "probation", 1, 123456789L)); - List loaded = LearnedBlockedEdges.load(file); - assertEquals(1, loaded.size()); - assertEquals(1, loaded.get(0).strikes); - assertEquals(123456789L, loaded.get(0).lastStrikeAtMs); - - LearnedBlockedEdges.save(file, List.of(loaded.get(0).withStrikeAt(987654321L))); - loaded = LearnedBlockedEdges.load(file); - assertEquals("save must rewrite, not append", 1, loaded.size()); - assertEquals(2, loaded.get(0).strikes); - assertEquals(987654321L, loaded.get(0).lastStrikeAtMs); - assertEquals("row identity survives the rewrite", FROM, loaded.get(0).origin); - } - - @Test - public void legacyRowsWithoutStrikeColumnsParseAsConfirmed() throws Exception { - File file = Files.createTempFile("learned-edges-legacy", ".tsv").toFile(); - file.deleteOnExit(); - String content = String.join(System.lineSeparator(), - "# Origin\tDestination\tBidirectional\tDisplay info", - "3200 3200 0\t3201 3200 0\tfalse\tlegacy row"); - Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8)); - - List loaded = LearnedBlockedEdges.load(file); - assertEquals(1, loaded.size()); - assertEquals("rows predating strike tracking stay unconditionally trusted", - LearnedBlockedEdges.LEGACY_STRIKES, loaded.get(0).strikes); - assertEquals(0L, loaded.get(0).lastStrikeAtMs); - } - - @Test - public void loadMissingFileYieldsEmpty() { - File missing = new File(System.getProperty("java.io.tmpdir"), "learned-edges-does-not-exist-" + System.nanoTime() + ".tsv"); - assertTrue(LearnedBlockedEdges.load(missing).isEmpty()); - } - - @Test - public void malformedRowsAreSkippedNotFatal() throws Exception { - File file = Files.createTempFile("learned-edges-malformed", ".tsv").toFile(); - file.deleteOnExit(); - String content = String.join(System.lineSeparator(), - "# Origin\tDestination\tBidirectional\tDisplay info", - "3200 3200 0\t3201 3200 0\tfalse\tgood row", - "this is not a valid row", // too few columns - "3200 3200\t3201 3200 0\tfalse\tbad origin (2 coords)", // unparseable point - "3300 3300 0\t3301 3300 0\ttrue\tsecond good row (bidirectional)"); - Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8)); - - List loaded = LearnedBlockedEdges.load(file); - assertEquals(2, loaded.size()); - assertTrue(loaded.get(1).bidirectional); - } - - @Test - public void learnedEdgeKeyBlocksTheCardinalStep() { - Set blocked = new HashSet<>(); - blocked.add(PathfinderConfig.transportEdgeKey( - WorldPointUtil.packWorldPoint(FROM), - WorldPointUtil.packWorldPoint(TO))); - - // The exact learned direction is blocked... - assertTrue(PathfinderConfig.isBlockedTransportStep( - WorldPointUtil.packWorldPoint(FROM), - WorldPointUtil.packWorldPoint(TO), - blocked)); - - // ...but the reverse edge is not (we learn only the attempted direction). - assertFalse(PathfinderConfig.isBlockedTransportStep( - WorldPointUtil.packWorldPoint(TO), - WorldPointUtil.packWorldPoint(FROM), - blocked)); - - // An unrelated edge stays open. - WorldPoint elsewhere = new WorldPoint(3500, 3500, 0); - assertFalse(PathfinderConfig.isBlockedTransportStep( - WorldPointUtil.packWorldPoint(elsewhere), - WorldPointUtil.packWorldPoint(new WorldPoint(3501, 3500, 0)), - blocked)); - } - - @Test - public void emptyBlockSetNeverBlocks() { - assertFalse(PathfinderConfig.isBlockedTransportStep( - WorldPointUtil.packWorldPoint(FROM), - WorldPointUtil.packWorldPoint(TO), - new HashSet<>())); - } -} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigTransportRefreshHashTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigTransportRefreshHashTest.java index 63e526e1b40..4af64e69ebe 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigTransportRefreshHashTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigTransportRefreshHashTest.java @@ -3,6 +3,7 @@ import net.runelite.api.Quest; import net.runelite.api.QuestState; import net.runelite.api.Skill; +import net.runelite.client.plugins.microbot.shortestpath.Transport; import net.runelite.client.plugins.microbot.shortestpath.TransportVarPlayer; import net.runelite.client.plugins.microbot.shortestpath.TransportVarbit; import net.runelite.client.plugins.microbot.util.magic.Runes; @@ -118,6 +119,26 @@ public void requiredSkillChangeStillInvalidates() { hashWithLevels(sortedSkillOrdinals, after)); } + @Test + public void requiredSpecialLevelChangeInvalidates() { + int[] tracked = new int[]{ + Transport.TOTAL_LEVEL_INDEX, + Transport.COMBAT_LEVEL_INDEX, + Transport.QUEST_POINTS_INDEX, + }; + int[] before = new int[Transport.REQUIREMENT_LEVEL_COUNT]; + before[Transport.TOTAL_LEVEL_INDEX] = 2000; + before[Transport.COMBAT_LEVEL_INDEX] = 39; + before[Transport.QUEST_POINTS_INDEX] = 100; + + for (int ordinal : tracked) { + int[] after = before.clone(); + after[ordinal]++; + assertNotEquals("special requirement changes must invalidate ordinal " + ordinal, + hashWithLevels(tracked, before), hashWithLevels(tracked, after)); + } + } + /** * A cooldown gate must not churn the cache while it ticks. {@code COOLDOWN_MINUTES} compares * against wall-clock minutes, so hashing its raw varplayer value invalidated the transport cache diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderHomeTeleportTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderHomeTeleportTest.java new file mode 100644 index 00000000000..58db9b8d384 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderHomeTeleportTest.java @@ -0,0 +1,39 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportExecutionRegistry; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.client.plugins.microbot.util.walker.Rs2TransportPlanningPolicy; +import org.junit.Test; + +import java.util.Collections; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PathfinderHomeTeleportTest +{ + @Test + public void everyRegisteredHomeTeleportIsZeroRuneUsable() + { + PathfinderConfig config = new PathfinderConfig( + null, Collections.emptyMap(), Collections.emptyList(), null, null, + Rs2TransportPlanningPolicy.INSTANCE); + + for (TransportExecutionRegistry.HomeTeleport homeTeleport + : TransportExecutionRegistry.HomeTeleport.values()) + { + assertTrue(homeTeleport.getDisplayName(), + config.isTeleportationSpellUsable(spell(homeTeleport.getDisplayName()))); + } + assertFalse(config.isTeleportationSpellUsable(spell("Unknown Home Teleport"))); + } + + private static Transport spell(String displayInfo) + { + return new Transport( + null, new WorldPoint(3200, 3200, 0), displayInfo, + TransportType.TELEPORTATION_SPELL, false, 1); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderItemRequirementTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderItemRequirementTest.java new file mode 100644 index 00000000000..b3fe8f1694d --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderItemRequirementTest.java @@ -0,0 +1,87 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.client.plugins.microbot.shortestpath.TeleportationItem; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportItemRequirement; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import org.junit.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +public class PathfinderItemRequirementTest { + private static final List REQUIREMENTS = List.of( + new TransportItemRequirement(Map.of(100, 2, 101, 3)), + new TransportItemRequirement(Map.of(200, 1))); + + @Test + public void everyAndGroupMustBeSatisfied() { + assertTrue(PathfinderConfig.meetsItemRequirements(REQUIREMENTS, itemId -> { + if (itemId == 100) return 2; + if (itemId == 200) return 1; + return 0; + })); + + assertFalse(PathfinderConfig.meetsItemRequirements(REQUIREMENTS, itemId -> + itemId == 100 ? 2 : 0)); + } + + @Test + public void alternativeQuantitiesAreEvaluatedIndependently() { + assertTrue(PathfinderConfig.meetsItemRequirements(REQUIREMENTS, itemId -> { + if (itemId == 101) return 3; + if (itemId == 200) return 1; + return 0; + })); + assertFalse(PathfinderConfig.meetsItemRequirements(REQUIREMENTS, itemId -> { + if (itemId == 101) return 2; + if (itemId == 200) return 1; + return 0; + })); + } + + @Test + public void noRequirementsAreSatisfied() { + assertTrue(PathfinderConfig.meetsItemRequirements(List.of(), itemId -> 0)); + assertTrue(PathfinderConfig.meetsItemRequirements(null, itemId -> 0)); + } + + @Test + public void permanentItemPolicyKeepsOnlyInfiniteQuetzalWhistles() { + Set originlessTransports = Transport.loadAllFromResources().get(null); + assertNotNull(originlessTransports); + List whistles = originlessTransports.stream() + .filter(transport -> transport.getType() == TransportType.TELEPORTATION_ITEM) + .filter(transport -> transport.getDisplayInfo() != null + && transport.getDisplayInfo().startsWith("Quetzal whistle:")) + .collect(Collectors.toList()); + + assertEquals(28, whistles.size()); + List permanent = whistles.stream() + .filter(transport -> PathfinderConfig.isTeleportationItemAllowedByPolicy( + TeleportationItem.INVENTORY_NON_CONSUMABLE, + transport.isConsumable())) + .collect(Collectors.toList()); + + assertEquals(14, permanent.size()); + assertTrue(permanent.stream().noneMatch(Transport::isConsumable)); + assertTrue(permanent.stream().allMatch(transport -> + transport.getItemRequirements().size() == 1 + && transport.getItemRequirements().get(0).getItemIds().equals(Set.of(33120)))); + assertTrue(whistles.stream().allMatch(transport -> + PathfinderConfig.isTeleportationItemAllowedByPolicy( + TeleportationItem.INVENTORY, + transport.isConsumable()))); + assertTrue(whistles.stream().noneMatch(transport -> + PathfinderConfig.isTeleportationItemAllowedByPolicy( + TeleportationItem.NONE, + transport.isConsumable()))); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderPathMaterializationTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderPathMaterializationTest.java new file mode 100644 index 00000000000..6dce71ff12c --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderPathMaterializationTest.java @@ -0,0 +1,107 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; + +public class PathfinderPathMaterializationTest +{ + @Test + public void completedRoutePreservesExactTransportAndMetrics() + { + WorldPoint start = new WorldPoint(3222, 3218, 0); + WorldPoint destination = new WorldPoint(3222, 9618, 0); + Transport exact = new Transport( + start, destination, "synthetic", TransportType.TRANSPORT, false, + "Climb-down", "Tunnel", 1001, 3); + PathfinderConfig config = new PathfinderConfig( + SplitFlagMap.fromResources(), new HashMap<>(), Collections.emptyList(), null, null); + + Pathfinder completed = Pathfinder.completedRoute( + config, + start, + Set.of(destination), + List.of(start, destination), + List.of(exact), + PathTerminationReason.TARGET_REACHED, + 3L, + 1234L, + 7L, + 2L, + 5L); + + assertTrue(completed.isDone()); + assertEquals(List.of(start, destination), completed.getPath()); + assertEquals(1, completed.getPathEdges().size()); + assertSame(exact, completed.getPathEdges().get(0).getTransport()); + assertEquals(PathTerminationReason.TARGET_REACHED, completed.getTerminationReason()); + assertEquals(3L, completed.getSelectedPathCost()); + assertEquals(1234L, completed.getStats().getElapsedTimeNanos()); + assertEquals(7, completed.getStats().getNodesChecked()); + assertEquals(2, completed.getStats().getTransportsChecked()); + assertEquals(5L, completed.getStats().getLiveCollisionEdgesChecked()); + } + + @Test + public void newerBestNodeRematerializesAfterAnEarlierLiveRead() throws Exception + { + int start = WorldPointUtil.packWorldPoint(new WorldPoint(3000, 3200, 0)); + Pathfinder pathfinder = new Pathfinder( + mock(PathfinderConfig.class), start, Collections.singleton(start)); + + Node first = new Node(new WorldPoint(3002, 3200, 0), + new Node(new WorldPoint(3001, 3200, 0), + new Node(new WorldPoint(3000, 3200, 0), null))); + setBestLastNode(pathfinder, first); + markLegacyPathDirtyIfPresent(pathfinder); + assertEquals(3, pathfinder.getPath().size()); + + Node latest = new Node(new WorldPoint(3005, 3200, 0), + new Node(new WorldPoint(3004, 3200, 0), + new Node(new WorldPoint(3003, 3200, 0), first))); + setBestLastNode(pathfinder, latest); + + List latestPath = pathfinder.getPath(); + assertEquals("a live reader must not leave the completed route on an older node", + 6, latestPath.size()); + assertEquals("typed edges and path points must describe the same node chain", + latestPath.size() - 1, pathfinder.getPathEdges().size()); + } + + private static void setBestLastNode(Pathfinder pathfinder, Node node) throws Exception + { + Field field = Pathfinder.class.getDeclaredField("bestLastNode"); + field.setAccessible(true); + field.set(pathfinder, node); + } + + /** + * Models the old dirty-flag implementation so this regression would fail before identity invalidation. + */ + private static void markLegacyPathDirtyIfPresent(Pathfinder pathfinder) throws Exception + { + try + { + Field field = Pathfinder.class.getDeclaredField("pathNeedsUpdate"); + field.setAccessible(true); + field.setBoolean(pathfinder, true); + } + catch (NoSuchFieldException ignored) + { + // Current implementation invalidates by Node identity and has no dirty flag. + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderSpecialRequirementTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderSpecialRequirementTest.java new file mode 100644 index 00000000000..a07db45665e --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderSpecialRequirementTest.java @@ -0,0 +1,41 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class PathfinderSpecialRequirementTest { + @Test + public void specialLevelsParticipateInTransportAvailability() { + int[] required = new int[Transport.REQUIREMENT_LEVEL_COUNT]; + required[Transport.TOTAL_LEVEL_INDEX] = 2000; + required[Transport.COMBAT_LEVEL_INDEX] = 40; + required[Transport.QUEST_POINTS_INDEX] = 100; + + int[] current = new int[Transport.REQUIREMENT_LEVEL_COUNT]; + current[Transport.TOTAL_LEVEL_INDEX] = 2000; + current[Transport.COMBAT_LEVEL_INDEX] = 40; + current[Transport.QUEST_POINTS_INDEX] = 100; + + assertTrue(PathfinderConfig.meetsRequiredLevels(required, current)); + + current[Transport.COMBAT_LEVEL_INDEX] = 39; + assertFalse(PathfinderConfig.meetsRequiredLevels(required, current)); + current[Transport.COMBAT_LEVEL_INDEX] = 40; + current[Transport.TOTAL_LEVEL_INDEX] = 1999; + assertFalse(PathfinderConfig.meetsRequiredLevels(required, current)); + current[Transport.TOTAL_LEVEL_INDEX] = 2000; + current[Transport.QUEST_POINTS_INDEX] = 99; + assertFalse(PathfinderConfig.meetsRequiredLevels(required, current)); + } + + @Test + public void malformedLevelArraysFailClosed() { + assertFalse(PathfinderConfig.meetsRequiredLevels( + new int[Transport.REQUIREMENT_LEVEL_COUNT], + new int[Transport.REQUIREMENT_LEVEL_COUNT - 1])); + assertFalse(PathfinderConfig.meetsRequiredLevels(null, new int[Transport.REQUIREMENT_LEVEL_COUNT])); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderTerminationReasonTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderTerminationReasonTest.java new file mode 100644 index 00000000000..f1cb8f8fa75 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderTerminationReasonTest.java @@ -0,0 +1,166 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.Collections; +import java.util.concurrent.ConcurrentHashMap; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class PathfinderTerminationReasonTest +{ + private static final int START = WorldPointUtil.packWorldPoint(new WorldPoint(3200, 3200, 0)); + private static final int TARGET = WorldPointUtil.packWorldPoint(new WorldPoint(3201, 3200, 0)); + private static final int FAR_TARGET = WorldPointUtil.packWorldPoint(new WorldPoint(6000, 3200, 0)); + + @BeforeClass + public static void initializeCollisionExtents() + { + // VisitedTiles uses the resource-derived global region extents even when its CollisionMap is mocked. + SplitFlagMap.fromResources(); + } + + @Test + public void exactTargetReportsReached() + { + Scenario scenario = scenario(10_000L); + Pathfinder pathfinder = new Pathfinder(scenario.config, START, Collections.singleton(START)); + + pathfinder.run(); + + assertEquals(PathTerminationReason.TARGET_REACHED, pathfinder.getTerminationReason()); + assertTrue(pathfinder.isDone()); + } + + @Test + public void drainedFrontierReportsSearchExhausted() + { + Scenario scenario = scenario(10_000L); + when(scenario.map.getNeighbors(any(Node.class), any(VisitedTiles.class), + eq(scenario.config), anySet())).thenReturn(Collections.emptyList()); + Pathfinder pathfinder = new Pathfinder(scenario.config, START, Collections.singleton(TARGET)); + + pathfinder.run(); + + assertEquals(PathTerminationReason.SEARCH_EXHAUSTED, pathfinder.getTerminationReason()); + assertTrue(pathfinder.isDone()); + } + + @Test + public void elapsedCutoffReportsCutoffReached() + { + Scenario scenario = scenario(-1L); + Pathfinder pathfinder = new Pathfinder(scenario.config, START, Collections.singleton(TARGET)); + + pathfinder.run(); + + assertEquals(PathTerminationReason.CUTOFF_REACHED, pathfinder.getTerminationReason()); + assertTrue(pathfinder.isDone()); + } + + @Test + public void bidirectionalDrainedFrontiersReportSearchExhausted() + { + Scenario scenario = scenario(10_000L); + when(scenario.map.getNeighbors(any(Node.class), any(VisitedTiles.class), + eq(scenario.config), anySet())).thenReturn(Collections.emptyList()); + when(scenario.map.getReverseNeighbors(any(Node.class), any(VisitedTiles.class), + eq(scenario.config), anySet(), anyMap())).thenReturn(Collections.emptyList()); + Pathfinder pathfinder = new Pathfinder( + scenario.config, START, Collections.singleton(FAR_TARGET)); + + pathfinder.run(); + + assertEquals(PathTerminationReason.SEARCH_EXHAUSTED, pathfinder.getTerminationReason()); + assertTrue(pathfinder.isDone()); + } + + @Test + public void cancellationReportsCancelled() + { + Scenario scenario = scenario(10_000L); + Pathfinder pathfinder = new Pathfinder(scenario.config, START, Collections.singleton(TARGET)); + pathfinder.cancel(); + + pathfinder.run(); + + assertEquals(PathTerminationReason.CANCELLED, pathfinder.getTerminationReason()); + assertFalse(pathfinder.isDone()); + } + + @Test + public void caughtPlannerExceptionReportsFailed() + { + Scenario scenario = scenario(10_000L); + when(scenario.map.getNeighbors(any(Node.class), any(VisitedTiles.class), + eq(scenario.config), anySet())).thenThrow(new IllegalStateException("synthetic planner failure")); + Pathfinder pathfinder = new Pathfinder(scenario.config, START, Collections.singleton(TARGET)); + + pathfinder.run(); + + assertEquals(PathTerminationReason.FAILED, pathfinder.getTerminationReason()); + assertTrue("the worker stopped even though planning failed", pathfinder.isDone()); + } + + @Test + public void reverseChainRetainsForwardTransportIdentity() + { + WorldPoint start = new WorldPoint(3200, 3200, 0); + WorldPoint meeting = new WorldPoint(3201, 3200, 0); + WorldPoint goal = new WorldPoint(3201, 3200, 1); + Transport stairs = new Transport( + meeting, goal, "Upper floor", TransportType.TRANSPORT, false, + "Climb-up", "Staircase", 16671); + + Node forwardStart = new Node(start, null); + Node forwardMeeting = new Node(meeting, forwardStart); + Node backwardGoal = new Node(goal, null); + Node backwardMeeting = new TransportNode(meeting, backwardGoal, 1, stairs); + + java.util.List edges = + PathEdge.fromBidirectionalChains(forwardMeeting, backwardMeeting); + + assertEquals(2, edges.size()); + assertFalse(edges.get(0).isTransport()); + assertTrue(edges.get(1).isTransport()); + assertEquals(meeting, edges.get(1).getFrom()); + assertEquals(goal, edges.get(1).getTo()); + assertSame(stairs, edges.get(1).getTransport()); + } + + private static Scenario scenario(long cutoffMillis) + { + PathfinderConfig config = mock(PathfinderConfig.class); + CollisionMap map = mock(CollisionMap.class); + when(config.getMap()).thenReturn(map); + when(config.getCalculationCutoffMillis()).thenReturn(cutoffMillis); + when(config.getTransports()).thenReturn(new ConcurrentHashMap<>()); + return new Scenario(config, map); + } + + private static final class Scenario + { + private final PathfinderConfig config; + private final CollisionMap map; + + private Scenario(PathfinderConfig config, CollisionMap map) + { + this.config = config; + this.map = map; + } + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/SealedVerdictBudgetExhaustionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/SealedVerdictBudgetExhaustionTest.java new file mode 100644 index 00000000000..73121633940 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/SealedVerdictBudgetExhaustionTest.java @@ -0,0 +1,155 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; + +import java.util.ArrayDeque; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assume.assumeTrue; + +/** + * A sealed-target substitute search that dies on its NODE BUDGET has proven nothing about the rim — + * it simply ran out of nodes. Recording a rim-unreachable verdict for it poisoned every subsequent + * replan down to the reduced repeat budget, guaranteeing none could ever finish either. + *

+ * Pinned against the live failure of 2026-08-15 (Varlamore→Burthorpe, ~1500 tiles): the first + * sealed-substitute pass exhausted its 50k nodes at bestDist=112 with the rim perfectly reachable, + * the exhaustion was memoed as "rim unreachable", and every replan after that got 5k nodes — + * alternating between partial endpoints 124 and 1459 tiles from the goal while the player thrashed + * in place. Only a genuinely drained frontier (SEARCH_EXHAUSTED with empty queues) is a proof and + * may be memoed; {@link SealedVerdictMemo}'s own contract says as much. + */ +public class SealedVerdictBudgetExhaustionTest { + + private static SplitFlagMap collisionMap; + private static HashMap> transports; + + /** Lumbridge courtyard: mapped, ordinary, walkable ground. */ + private static final WorldPoint SRC = new WorldPoint(3222, 3218, 0); + + @BeforeClass + public static void load() { + collisionMap = SplitFlagMap.fromResources(); + transports = Transport.loadAllFromResources(); + } + + @Before + public void clearMemo() { + SealedVerdictMemo.clearAll(); + } + + private static PathfinderConfig newConfig() { + PathfinderConfig config = new PathfinderConfig(collisionMap, transports, + Collections.emptyList(), null, null); + try { + java.lang.reflect.Field f = PathfinderConfig.class.getDeclaredField("calculationCutoffMillis"); + f.setAccessible(true); + f.setLong(config, 10_000); + for (Map.Entry> e : transports.entrySet()) { + if (e.getKey() == null) continue; + config.getTransports().put(e.getKey(), e.getValue()); + config.getTransportsPacked().put(WorldPointUtil.packWorldPoint(e.getKey()), e.getValue()); + } + } catch (Exception ex) { + throw new RuntimeException(ex); + } + return config; + } + + /** Mirrors the probe's sealed reading: no neighbour can step INTO the tile from any direction. */ + private static boolean noEntry(CollisionMap map, int x, int y, int z) { + int[][] all = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}, {1, 1}, {1, -1}, {-1, 1}, {-1, -1}}; + for (int[] d : all) { + if (map.canStep(x - d[0], y - d[1], z, d[0], d[1])) { + return false; + } + } + return true; + } + + /** + * Self-locating, same as SealedTargetFastPathTest: BFS the walkable area around SRC, then pick + * a sealed tile one of whose neighbours is in that area — a sealed footprint beside ground the + * player can stand on, i.e. a rim that IS reachable given enough budget. + */ + private static WorldPoint locateSealedTileWithReachableRim(CollisionMap map) { + Set reachable = new HashSet<>(); + ArrayDeque frontier = new ArrayDeque<>(); + reachable.add(SRC); + frontier.add(SRC); + int[][] dirs = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}}; + while (!frontier.isEmpty() && reachable.size() < 1_500) { + WorldPoint c = frontier.poll(); + for (int[] d : dirs) { + if (!map.canStep(c.getX(), c.getY(), 0, d[0], d[1])) { + continue; + } + WorldPoint n = new WorldPoint(c.getX() + d[0], c.getY() + d[1], 0); + if (reachable.add(n)) { + frontier.add(n); + } + } + } + WorldPoint dst = null; + int bestDist = Integer.MAX_VALUE; + for (WorldPoint open : reachable) { + for (int[] d : dirs) { + int x = open.getX() + d[0]; + int y = open.getY() + d[1]; + WorldPoint cand = new WorldPoint(x, y, 0); + if (reachable.contains(cand) || !noEntry(map, x, y, 0)) { + continue; + } + int dist = Math.max(Math.abs(x - SRC.getX()), Math.abs(y - SRC.getY())); + if (dist >= 3 && dist < bestDist) { + bestDist = dist; + dst = cand; + } + } + } + return dst; + } + + @Test + public void budgetExhaustionMustNotRecordARimUnreachableVerdict() { + PathfinderConfig config = newConfig(); + CollisionMap map = config.getMap(); + map.beginSearch(); + WorldPoint dst = locateSealedTileWithReachableRim(map); + assumeTrue("precondition: found a sealed tile whose rim the player can stand on", dst != null); + + // A one-node budget forces the substitute pass to die on the budget before it can pop a + // rim tile — the exact shape of the live failure, minus the 1500 tiles. + Pathfinder capped = new Pathfinder(config, SRC, dst); + capped.setSealedSubstituteNodeBudgetForTest(1); + capped.run(); + + assertEquals("callers must still hear the original target is unreachable", + PathTerminationReason.SEARCH_EXHAUSTED, capped.getTerminationReason()); + assertNull("the rim was never reached", capped.getReachedSealedSubstitute()); + assertFalse("an exhausted budget proves nothing — no rim-unreachable verdict may be recorded", + SealedVerdictMemo.isRimUnreachable(WorldPointUtil.packWorldPoint(dst), + config.getLastTransportRefreshKeyHash(), System.currentTimeMillis())); + + // With no poisoned memo, the very next full-budget search completes the approach. + Pathfinder full = new Pathfinder(config, SRC, dst); + full.run(); + assertTrue("full-budget follow-up must produce the approach path", !full.getPath().isEmpty()); + WorldPoint last = full.getPath().get(full.getPath().size() - 1); + assertTrue("approach must end beside the sealed tile, ended at " + last + " for dst=" + dst, + last.distanceTo2D(dst) <= 2); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/SealedVerdictMemoTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/SealedVerdictMemoTest.java new file mode 100644 index 00000000000..28e8de2c435 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/SealedVerdictMemoTest.java @@ -0,0 +1,83 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * The sealed-verdict memo's whole contract: a recorded rim-unreachable proof is honoured only for + * the same goal, under the same transport-refresh key, within the TTL — and a reached rim erases it. + */ +public class SealedVerdictMemoTest { + private static final int GOAL = 12345; + private static final int KEY = 777; + private static final long NOW = 1_000_000L; + + @Before + @After + public void reset() { + SealedVerdictMemo.clearAll(); + } + + @Test + public void unknownGoalIsNotMemoized() { + assertFalse(SealedVerdictMemo.isRimUnreachable(GOAL, KEY, NOW)); + } + + @Test + public void freshVerdictUnderSameKeyHits() { + SealedVerdictMemo.record(GOAL, KEY, NOW); + assertTrue(SealedVerdictMemo.isRimUnreachable(GOAL, KEY, NOW + SealedVerdictMemo.TTL_MS - 1)); + } + + @Test + public void verdictExpiresAtTtl() { + SealedVerdictMemo.record(GOAL, KEY, NOW); + assertFalse(SealedVerdictMemo.isRimUnreachable(GOAL, KEY, NOW + SealedVerdictMemo.TTL_MS)); + // The expired entry is evicted, not just skipped: a later probe under the old key stays cold. + assertFalse(SealedVerdictMemo.isRimUnreachable(GOAL, KEY, NOW)); + } + + @Test + public void refreshKeyChangeInvalidates() { + SealedVerdictMemo.record(GOAL, KEY, NOW); + assertFalse(SealedVerdictMemo.isRimUnreachable(GOAL, KEY + 1, NOW)); + // A key mismatch drops the stale entry entirely. + assertFalse(SealedVerdictMemo.isRimUnreachable(GOAL, KEY, NOW)); + } + + @Test + public void differentGoalDoesNotHit() { + SealedVerdictMemo.record(GOAL, KEY, NOW); + assertFalse(SealedVerdictMemo.isRimUnreachable(GOAL + 1, KEY, NOW)); + } + + @Test + public void reachedRimClearsVerdict() { + SealedVerdictMemo.record(GOAL, KEY, NOW); + SealedVerdictMemo.clear(GOAL); + assertFalse(SealedVerdictMemo.isRimUnreachable(GOAL, KEY, NOW)); + } + + @Test + public void rerecordRefreshesTimestamp() { + SealedVerdictMemo.record(GOAL, KEY, NOW); + SealedVerdictMemo.record(GOAL, KEY, NOW + SealedVerdictMemo.TTL_MS); + assertTrue(SealedVerdictMemo.isRimUnreachable(GOAL, KEY, NOW + 2 * SealedVerdictMemo.TTL_MS - 1)); + } + + @Test + public void capOverflowResetsInsteadOfGrowing() { + for (int i = 0; i < SealedVerdictMemo.MAX_ENTRIES; i++) { + SealedVerdictMemo.record(GOAL + i, KEY, NOW); + } + assertTrue(SealedVerdictMemo.isRimUnreachable(GOAL, KEY, NOW)); + // One more distinct goal trips the cap: the memo resets wholesale and holds only the newcomer. + SealedVerdictMemo.record(GOAL - 1, KEY, NOW); + assertTrue(SealedVerdictMemo.isRimUnreachable(GOAL - 1, KEY, NOW)); + assertFalse(SealedVerdictMemo.isRimUnreachable(GOAL, KEY, NOW)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicyTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicyTest.java new file mode 100644 index 00000000000..9a9eb407c1f --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicyTest.java @@ -0,0 +1,63 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import org.junit.Test; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.HashMap; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class TransportPlanningPolicyTest +{ + @Test + public void localCoreRetainsInjectedAdmissionAndZeroRunePolicies() throws Exception + { + Transport admitted = transport("Allowed"); + Transport rejected = transport("Rejected"); + Transport home = transport("Home"); + TransportPlanningPolicy policy = new TransportPlanningPolicy() + { + @Override + public boolean isAdmitted(Transport transport) + { + return transport != rejected; + } + + @Override + public boolean isZeroRuneSpell(Transport transport) + { + return transport == home; + } + }; + PathfinderConfig config = new PathfinderConfig( + SplitFlagMap.fromResources(), new HashMap<>(), Collections.emptyList(), null, null, policy); + + Field field = PathfinderConfig.class.getDeclaredField("transportPlanningPolicy"); + field.setAccessible(true); + TransportPlanningPolicy installed = (TransportPlanningPolicy) field.get(config); + + assertSame(policy, installed); + assertTrue(installed.isAdmitted(admitted)); + assertFalse(installed.isAdmitted(rejected)); + assertTrue(installed.isZeroRuneSpell(home)); + } + + private static Transport transport(String displayInfo) + { + return new Transport( + new WorldPoint(3200, 3200, 0), + new WorldPoint(3200, 3201, 0), + displayInfo, + TransportType.TRANSPORT, + false, + "Open", + "Door", + 1); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionConflictsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionConflictsTest.java index 73e5498473b..2889b93e8e8 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionConflictsTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/live/LiveCollisionConflictsTest.java @@ -119,4 +119,47 @@ public void unknownEdgesNeverCount() { assertTrue(LiveCollisionConflicts.tally(null, staticMap).isEmpty()); assertTrue(LiveCollisionConflicts.tally(allUnknown, null).isEmpty()); } + + // ---- overlay coverage: is the persistent store actually paying off? ----------------------------- + + /** + * The Tally buckets compare live against STATIC, so they read the same whether or not the persistent + * store works — they measure how wrong the shipped map is, not whether we had already learned it. + * Coverage is the number that tells you the store is earning its keep. + */ + @Test + public void coverage_countsUnknownEdgesAsNewInformation() { + boolean statik = staticMap.get(PROBE_X, PROBE_Y, 0, LiveCollisionSnapshot.FLAG_NORTH); + LiveCollisionConflicts.Coverage c = LiveCollisionConflicts.coverage( + snapshotWithNorthEdge(!statik), staticMap, null); + assertEquals(1, c.newInformation); + assertEquals(0, c.alreadyKnown); + assertEquals(0, c.alreadyKnownPercent()); + } + + /** An edge the overlay already had, with the same value — a previous visit spared us the blind one. */ + @Test + public void coverage_countsMatchingOverlayEdgesAsAlreadyKnown() { + boolean statik = staticMap.get(PROBE_X, PROBE_Y, 0, LiveCollisionSnapshot.FLAG_NORTH); + LiveCollisionSnapshot scene = snapshotWithNorthEdge(!statik); + + LiveCollisionOverlay overlay = new LiveCollisionOverlay(); + overlay.setEnabled(true); + overlay.mergeScene(scene); // "previous visit" + LiveCollisionView prior = overlay.current(); + + LiveCollisionConflicts.Coverage c = LiveCollisionConflicts.coverage(scene, staticMap, prior); + assertEquals(1, c.alreadyKnown); + assertEquals(0, c.newInformation); + assertEquals(100, c.alreadyKnownPercent()); + } + + /** Agreement with static is not the store's business and must not be counted either way. */ + @Test + public void coverage_ignoresEdgesWhereStaticWasRight() { + boolean statik = staticMap.get(PROBE_X, PROBE_Y, 0, LiveCollisionSnapshot.FLAG_NORTH); + LiveCollisionConflicts.Coverage c = LiveCollisionConflicts.coverage( + snapshotWithNorthEdge(statik), staticMap, null); + assertEquals(0, c.total()); + } } From bc7fe6dbba1739b839e9489bb00e4755e56a1e0e Mon Sep 17 00:00:00 2001 From: infuse21 Date: Fri, 28 Aug 2026 20:28:56 +0100 Subject: [PATCH 2/4] fix(shortestpath): address pathfinder review findings --- .../shortestpath/ShortestPathConfig.java | 3 +- .../shortestpath/ShortestPathPlugin.java | 3 +- .../microbot/shortestpath/Transport.java | 6 ++-- .../shortestpath/pathfinder/Pathfinder.java | 12 +++++-- .../pathfinder/PathfinderConfig.java | 2 +- .../microbot/util/walker/Rs2PathApi.java | 2 +- .../shortestpath/ShortestPathCoreTest.java | 9 +++++ .../TransportSkillRequirementDataTest.java | 17 ++++----- .../PathfinderTerminationReasonTest.java | 36 +++++++++++++++++++ .../TransportPlanningPolicyTest.java | 12 +++++++ 10 files changed, 82 insertions(+), 20 deletions(-) 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 d6612a06d4e..25b033b9bcb 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 @@ -912,7 +912,8 @@ default boolean resetLearnedCollision() { + "The F2P canary selects only semantically matching upstream routes and automatically " + "falls back to local; members routes remain local.", position = 3, - section = sectionDeveloper + section = sectionDeveloper, + hidden = true ) default PlannerSelectionMode plannerSelectionMode() { return PlannerSelectionMode.LOCAL; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java index c62e99ba581..27ce274dee3 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPlugin.java @@ -402,7 +402,8 @@ public boolean isNearPath(WorldPoint location) { "minBankRouteSavings", "bankTripWhenCacheUnavailable", "preferTransportToTarget", - "maxSimilarTransportDistance" + "maxSimilarTransportDistance", + "plannerSelectionMode" ); private static final String RELOAD_TRANSPORT_DEFINITIONS_KEY = "reloadTransportDefinitions"; private static final String RESET_LEARNED_COLLISION_KEY = "resetLearnedCollision"; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/Transport.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/Transport.java index 8ffcc0c36ac..f6d3a5ab9cb 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/Transport.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/Transport.java @@ -338,10 +338,8 @@ public Transport(WorldPoint destination, String displayInfo, TransportType trans // no-op, because blocksWalkingEdgeWhenUnavailable would otherwise have routed AROUND // an unusable shortcut; with the gate erased the planner actively prefers it. if (!resolved) { - log.warn("Transport skill requirement '{}' does not name a known skill (raw field '{}') " - + "— the requirement is being DROPPED, which makes this transport usable " - + "by any account. Check for spaces where the TSV needs a tab.", - requirement.trim(), value.trim()); + throw new IllegalArgumentException("Unresolved transport skill requirement '" + + requirement.trim() + "' in raw field '" + value.trim() + "'"); } } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java index 10a320dcc4b..fd8a26a0dde 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java @@ -939,8 +939,16 @@ public void run() { // A genuinely REACHED rim is remembered before the remap, though — it is the walker's // signal to retarget the walk to the rim once instead of replaying this search forever. if (sealedTargetMode) { - if (terminationReason == PathTerminationReason.TARGET_REACHED && bestLastNode != null) { - reachedSealedSubstitutePacked = bestLastNode.packedPosition; + int reachedPacked = -1; + if (terminationReason == PathTerminationReason.TARGET_REACHED) { + if (bestLastNode != null) { + reachedPacked = bestLastNode.packedPosition; + } else if (joinedPath != null && !joinedPath.isEmpty()) { + reachedPacked = WorldPointUtil.packWorldPoint(joinedPath.get(joinedPath.size() - 1)); + } + } + if (reachedPacked != -1) { + reachedSealedSubstitutePacked = reachedPacked; SealedVerdictMemo.clear(targetsPacked[0]); } else if (terminationReason == PathTerminationReason.SEARCH_EXHAUSTED) { // The frontier genuinely drained without touching the rim: proven unreachable, 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 885f35f53de..8c1fee24118 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 @@ -1253,7 +1253,7 @@ private int getLiveVarplayerValue(int varplayerId) { private boolean useTransport(Transport transport) { // This runs once per expanded catalog edge during every refresh. Keep individual rejection // reasons at TRACE; DEBUG already receives the per-type aggregate emitted by refreshTransports. - if (!transportPlanningPolicy.isAdmitted(transport)) { + if (transport == null || !transportPlanningPolicy.isAdmitted(transport)) { log.trace("Transport ( O: {} D: {} type={} ) has no registered Microbot executor", transport == null ? null : transport.getOrigin(), transport == null ? null : transport.getDestination(), diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApi.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApi.java index fd93dd2ac0b..828c84dfe54 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApi.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2PathApi.java @@ -101,7 +101,6 @@ public static Object getPathfinderMutex() // Config // ------------------------------------------------------------------ - /** @return the shared pathfinder configuration (transports, restrictions, toggles). */ /** * Invalidate the planner's transport refresh cache so the next plan re-evaluates transport * availability (league relics and similar unlocks change what is usable without any @@ -121,6 +120,7 @@ public static boolean invalidateTransportRefreshCache() return true; } + /** @return the shared pathfinder configuration (transports, restrictions, toggles). */ public static PathfinderConfig getPathfinderConfig() { return ShortestPathPlugin.getPathfinderConfig(); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathCoreTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathCoreTest.java index 4bd34d5a8c9..92ab4c6e6dd 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathCoreTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathCoreTest.java @@ -403,6 +403,15 @@ public void testTransportParserSupportsUpstreamSpecialLevelRequirements() { assertEquals(327, transport.getRequiredQuestPoints()); } + @Test(expected = IllegalArgumentException.class) + public void testTransportParserRejectsUnknownSkillRequirements() { + Map fields = new HashMap<>(); + fields.put("Destination", "1 2 0"); + fields.put("Skills", "42 Imaginary"); + + new Transport(fields, TransportType.TELEPORTATION_ITEM); + } + @Test public void testDirectMaxCapeAndQuestCapeImportPreservesRequirementsAndDestinations() { Set teleports = Transport.loadAllFromResources() diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportSkillRequirementDataTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportSkillRequirementDataTest.java index 8ec12ee66ac..f2eeca478b3 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportSkillRequirementDataTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportSkillRequirementDataTest.java @@ -20,15 +20,13 @@ /** * Every Skills entry in the shipped transport data must name a skill the parser can resolve. * - *

An unresolvable requirement does not fail loudly — {@code Transport} matches the skill name - * against {@link Skill#getName()} and simply never writes {@code skillLevels}, leaving it 0. Zero is - * how "no requirement" is encoded, so a malformed requirement silently becomes NO requirement and the - * transport turns usable by every account. + *

{@code Transport} rejects a row when a requirement cannot be resolved against + * {@link Skill#getName()} or the supported total/combat/quest-point aliases. This resource-wide test + * catches those malformed rows directly and keeps shipped data loadable. * - *

That is not merely permissive. {@code PathfinderConfig.blocksWalkingEdgeWhenUnavailable} blocks - * the walking edge a shortcut spans when the shortcut is unusable, so the planner routes around it — - * with the gate erased the edge stays open and the planner actively PREFERS the shortcut as the - * shortest route, sending the walker back repeatedly. + *

Failing closed matters because {@code PathfinderConfig.blocksWalkingEdgeWhenUnavailable} blocks + * the walking edge a shortcut spans when the shortcut is unusable. If a requirement were erased, + * that edge would stay open and the planner could repeatedly prefer an unusable shortcut. * *

Live case: the Draynor underwall tunnel rows carried {@code "42 Agility7"}, a Duration * value separated by spaces instead of a tab. The field parsed as skill name {@code "Agility 7"}, @@ -135,8 +133,7 @@ public void everySkillRequirementInShippedDataResolves() { } assertFalse("precondition: the transport resources should be readable", filesChecked.isEmpty()); - assertTrue("skill requirements that the parser will silently DROP, making these transports " - + "usable by any account:\n " + assertTrue("skill requirements that the parser cannot resolve:\n " + offenders.stream().collect(Collectors.joining("\n ")), offenders.isEmpty()); } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderTerminationReasonTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderTerminationReasonTest.java index f1cb8f8fa75..8f2d32ab714 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderTerminationReasonTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderTerminationReasonTest.java @@ -7,6 +7,7 @@ import org.junit.BeforeClass; import org.junit.Test; +import java.util.Arrays; import java.util.Collections; import java.util.concurrent.ConcurrentHashMap; @@ -15,6 +16,7 @@ import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.ArgumentMatchers.eq; @@ -89,6 +91,40 @@ public void bidirectionalDrainedFrontiersReportSearchExhausted() assertTrue(pathfinder.isDone()); } + @Test + public void bidirectionalSealedTargetPublishesReachedRimSubstitute() + { + Scenario scenario = scenario(10_000L); + SplitFlagMap.RegionExtent extents = SplitFlagMap.getRegionExtents(); + byte[] planes = new byte[(extents.getWidth() + 1) * (extents.getHeight() + 1)]; + Arrays.fill(planes, (byte) 4); + when(scenario.map.getPlanes()).thenReturn(planes); + WorldPoint sealedTarget = new WorldPoint(6000, 3200, 0); + WorldPoint rim = new WorldPoint(6001, 3200, 0); + int sealedTargetPacked = WorldPointUtil.packWorldPoint(sealedTarget); + int rimPacked = WorldPointUtil.packWorldPoint(rim); + when(scenario.map.canStep(anyInt(), anyInt(), anyInt(), anyInt(), anyInt())) + .thenAnswer(invocation -> invocation.getArgument(0, Integer.class) == rim.getX() + && invocation.getArgument(1, Integer.class) == rim.getY()); + when(scenario.map.getReverseNeighbors(any(Node.class), any(VisitedTiles.class), + eq(scenario.config), anySet(), anyMap())).thenAnswer(invocation -> + { + Node node = invocation.getArgument(0); + return node.packedPosition == rimPacked + ? Collections.singletonList(new Node(START, node)) + : Collections.emptyList(); + }); + Pathfinder pathfinder = new Pathfinder( + scenario.config, START, Collections.singleton(sealedTargetPacked)); + + pathfinder.run(); + + assertEquals(PathTerminationReason.SEARCH_EXHAUSTED, pathfinder.getTerminationReason()); + assertEquals(rim, pathfinder.getNearestSealedRimSubstitute()); + assertEquals(rim, pathfinder.getReachedSealedSubstitute()); + assertEquals(rim, pathfinder.getPath().get(pathfinder.getPath().size() - 1)); + } + @Test public void cancellationReportsCancelled() { diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicyTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicyTest.java index 9a9eb407c1f..8b027597921 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicyTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicyTest.java @@ -6,6 +6,7 @@ import org.junit.Test; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.Collections; import java.util.HashMap; @@ -48,6 +49,17 @@ public boolean isZeroRuneSpell(Transport transport) assertTrue(installed.isZeroRuneSpell(home)); } + @Test + public void nullTransportIsRejectedBeforeFeatureChecks() throws Exception + { + PathfinderConfig config = new PathfinderConfig( + SplitFlagMap.fromResources(), new HashMap<>(), Collections.emptyList(), null, null); + Method useTransport = PathfinderConfig.class.getDeclaredMethod("useTransport", Transport.class); + useTransport.setAccessible(true); + + assertFalse((Boolean) useTransport.invoke(config, new Object[] {null})); + } + private static Transport transport(String displayInfo) { return new Transport( From 6b160011deb51ad9435101c147620f0c093486ae Mon Sep 17 00:00:00 2001 From: infuse21 Date: Fri, 28 Aug 2026 20:53:52 +0100 Subject: [PATCH 3/4] fix(shortestpath): harden planner boundary validation --- .../shortestpath/pathfinder/Pathfinder.java | 8 ++- .../pathfinder/PathfinderConfig.java | 20 ++++++- .../PathfinderPathMaterializationTest.java | 58 +++++++++++++++++++ .../TransportPlanningPolicyTest.java | 41 +++++++++++++ 4 files changed, 122 insertions(+), 5 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java index fd8a26a0dde..fe077e6d462 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/Pathfinder.java @@ -69,9 +69,9 @@ private static void pathfinderDiag(String format, Object... args) { // diverge tile-by-tile between successive searches with the same endpoints. // Kills the deterministic "identical route every trip" fingerprint. private final Queue boundary = new PriorityQueue<>(4096, NODE_ORDER); - private final Queue pending = new PriorityQueue<>(256); + private final Queue pending = new PriorityQueue<>(256, NODE_ORDER); private final Queue boundaryBackward = new PriorityQueue<>(4096, NODE_ORDER); - private final Queue pendingBackward = new PriorityQueue<>(256); + private final Queue pendingBackward = new PriorityQueue<>(256, NODE_ORDER); private VisitedTiles visited; private volatile List path = Collections.emptyList(); @@ -149,6 +149,10 @@ public static Pathfinder completedRoute( if (!path.isEmpty() && !start.equals(path.get(0))) { throw new IllegalArgumentException("materialized route must start at the requested start"); } + if (terminationReason == PathTerminationReason.TARGET_REACHED + && (path.isEmpty() || !targets.contains(path.get(path.size() - 1)))) { + throw new IllegalArgumentException("reached route must end at a requested target"); + } if (selectedPathCost < -1L || searchNanos < -1L || nodesChecked < -1L || transportsChecked < -1L || liveCollisionEdgesChecked < -1L) { throw new IllegalArgumentException("materialized route metrics must be non-negative or unavailable"); 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 8c1fee24118..c97af11958d 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 @@ -265,6 +265,11 @@ public PathfinderConfig(SplitFlagMap mapData, Map> tr this(mapData, transports, restrictions, client, config, TransportPlanningPolicy.ALLOW_ALL); } + /** + * Creates pathfinder state. Null client/config dependencies are supported for offline planning + * and tests. A null client uses only static collision data, and refresh returns without reading + * live state when either dependency is absent. + */ public PathfinderConfig(SplitFlagMap mapData, Map> transports, List restrictions, Client client, ShortestPathConfig config, @@ -346,6 +351,9 @@ private static Map edgeReadout(CollisionMap m, int x, int y, int } public void refresh(WorldPoint target) { + if (client == null || config == null) { + return; + } calculationCutoffMillis = (long) config.calculationCutoff() * Constants.GAME_TICK_LENGTH; avoidWilderness = ShortestPathPlugin.override("avoidWilderness", config.avoidWilderness()); avoidDangerousNpcs = ShortestPathPlugin.override("avoidDangerousNpcs", config.avoidDangerousNpcs()); @@ -694,6 +702,9 @@ private void refreshTransports(WorldPoint target) { WorldPoint point = entry.getKey(); Set usableTransports = new HashSet<>(entry.getValue().size()); for (Transport transport : entry.getValue()) { + if (transport == null) { + continue; + } totalTransports++; updateActionBasedOnQuestState(transport); @@ -1106,8 +1117,11 @@ private void replaceAllTransports(Map> source) { if (source == null || source.isEmpty()) { return; } - source.forEach((origin, set) -> - allTransports.put(origin, set == null ? Collections.emptySet() : new HashSet<>(set))); + source.forEach((origin, set) -> { + Set valid = set == null ? new HashSet<>() : new HashSet<>(set); + valid.remove(null); + allTransports.put(origin, valid); + }); } private void refreshRestrictionData() { @@ -1356,7 +1370,7 @@ private boolean useTransport(Transport transport) { * (Leagues catalog / Area teleports): quest action patch, {@link #useTransport}, {@link Rs2LeaguesTransport#isTransportAllowed}. */ public boolean isTransportUsableWithLeaguesContext(Transport transport, Rs2LeaguesTransport.LeaguesContext leaguesCtx) { - if (transport == null || leaguesCtx == null) { + if (client == null || transport == null || leaguesCtx == null) { return false; } updateActionBasedOnQuestState(transport); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderPathMaterializationTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderPathMaterializationTest.java index 6dce71ff12c..66229f675a1 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderPathMaterializationTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderPathMaterializationTest.java @@ -10,9 +10,11 @@ import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.PriorityQueue; import java.util.Set; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; @@ -55,6 +57,49 @@ public void completedRoutePreservesExactTransportAndMetrics() assertEquals(5L, completed.getStats().getLiveCollisionEdgesChecked()); } + @Test(expected = IllegalArgumentException.class) + public void reachedMaterializedRouteRejectsEmptyPath() + { + WorldPoint start = new WorldPoint(3200, 3200, 0); + Pathfinder.completedRoute( + config(), start, Set.of(new WorldPoint(3201, 3200, 0)), + Collections.emptyList(), Collections.emptyList(), + PathTerminationReason.TARGET_REACHED, -1L, -1L, -1L, -1L, -1L); + } + + @Test(expected = IllegalArgumentException.class) + public void reachedMaterializedRouteRejectsEndpointOutsideTargets() + { + WorldPoint start = new WorldPoint(3200, 3200, 0); + Pathfinder.completedRoute( + config(), start, Set.of(new WorldPoint(3201, 3200, 0)), + List.of(start, new WorldPoint(3202, 3200, 0)), Collections.emptyList(), + PathTerminationReason.TARGET_REACHED, -1L, -1L, -1L, -1L, -1L); + } + + @Test + public void nonReachedMaterializedRouteMayBeEmpty() + { + WorldPoint start = new WorldPoint(3200, 3200, 0); + Pathfinder completed = Pathfinder.completedRoute( + config(), start, Set.of(new WorldPoint(3201, 3200, 0)), + Collections.emptyList(), Collections.emptyList(), + PathTerminationReason.SEARCH_EXHAUSTED, -1L, -1L, -1L, -1L, -1L); + + assertTrue(completed.isDone()); + assertTrue(completed.getPath().isEmpty()); + } + + @Test + public void transportFrontiersUseExplicitNodeComparator() throws Exception + { + Pathfinder pathfinder = new Pathfinder( + config(), new WorldPoint(3200, 3200, 0), new WorldPoint(3201, 3200, 0)); + + assertNotNull(priorityQueue(pathfinder, "pending").comparator()); + assertNotNull(priorityQueue(pathfinder, "pendingBackward").comparator()); + } + @Test public void newerBestNodeRematerializesAfterAnEarlierLiveRead() throws Exception { @@ -88,6 +133,19 @@ private static void setBestLastNode(Pathfinder pathfinder, Node node) throws Exc field.set(pathfinder, node); } + private static PathfinderConfig config() + { + return new PathfinderConfig( + SplitFlagMap.fromResources(), new HashMap<>(), Collections.emptyList(), null, null); + } + + private static PriorityQueue priorityQueue(Pathfinder pathfinder, String name) throws Exception + { + Field field = Pathfinder.class.getDeclaredField(name); + field.setAccessible(true); + return (PriorityQueue) field.get(pathfinder); + } + /** * Models the old dirty-flag implementation so this regression would fail before identity invalidation. */ diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicyTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicyTest.java index 8b027597921..f647e4ec6bb 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicyTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportPlanningPolicyTest.java @@ -1,6 +1,7 @@ package net.runelite.client.plugins.microbot.shortestpath.pathfinder; import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.ShortestPathConfig; import net.runelite.client.plugins.microbot.shortestpath.Transport; import net.runelite.client.plugins.microbot.shortestpath.TransportType; import org.junit.Test; @@ -9,10 +10,17 @@ import java.lang.reflect.Method; import java.util.Collections; import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; public class TransportPlanningPolicyTest { @@ -60,6 +68,39 @@ public void nullTransportIsRejectedBeforeFeatureChecks() throws Exception assertFalse((Boolean) useTransport.invoke(config, new Object[] {null})); } + @Test + public void offlineRefreshDoesNotReadLiveConfig() + { + ShortestPathConfig liveConfig = mock(ShortestPathConfig.class); + PathfinderConfig config = new PathfinderConfig( + SplitFlagMap.fromResources(), new HashMap<>(), Collections.emptyList(), null, liveConfig); + + config.refresh(); + + verifyNoInteractions(liveConfig); + assertNotNull(config.getMap()); + } + + @Test + @SuppressWarnings("unchecked") + public void constructorFiltersNullTransportElements() throws Exception + { + WorldPoint origin = new WorldPoint(3200, 3200, 0); + Transport valid = transport("Valid"); + Set inputSet = new HashSet<>(); + inputSet.add(valid); + inputSet.add(null); + Map> input = new HashMap<>(); + input.put(origin, inputSet); + PathfinderConfig config = new PathfinderConfig( + SplitFlagMap.fromResources(), input, Collections.emptyList(), null, null); + Field field = PathfinderConfig.class.getDeclaredField("allTransports"); + field.setAccessible(true); + Map> stored = (Map>) field.get(config); + + assertEquals(Collections.singleton(valid), stored.get(origin)); + } + private static Transport transport(String displayInfo) { return new Transport( From 7c45c150042335097759f5305158de17936f533f Mon Sep 17 00:00:00 2001 From: infuse21 Date: Fri, 28 Aug 2026 21:01:52 +0100 Subject: [PATCH 4/4] test(shortestpath): make route click regression deterministic --- .../RouteClickTargetRegressionTest.java | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/RouteClickTargetRegressionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/RouteClickTargetRegressionTest.java index b472f12f9ed..6000212adba 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/RouteClickTargetRegressionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/RouteClickTargetRegressionTest.java @@ -4,6 +4,7 @@ import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap; +import net.runelite.client.plugins.microbot.util.walker.geometry.WalkerPathGeometry; import org.junit.BeforeClass; import org.junit.Test; @@ -13,16 +14,16 @@ import java.util.Map; import java.util.Set; -import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; /** * Regression for the "walker deviates wide / traps itself near Varrock West Bank" bug. * - *

The click layer had overshot onto {@code (3176,3428)} — a tile that is not on the raw - * route — because route selection returned null on a stale anchor and the caller fell through - * to clamping a far smoothed waypoint to a Euclidean radius. The game then pathed to that off-route - * tile its own way, which is what produced the wide deviation and the backtracking. + *

The click layer had overshot onto {@code (3176,3428)} — a tile that was not on the raw + * route in the captured incident — because route selection returned null on a stale anchor and + * the caller fell through to clamping a far smoothed waypoint to a Euclidean radius. The game then + * pathed to that off-route tile its own way, producing the wide deviation and backtracking. * *

The invariant this pins is therefore "the click target is on the raw route", * not "the click target is in line of sight". A minimap click is resolved by the game's own @@ -37,9 +38,6 @@ public class RouteClickTargetRegressionTest { private static final WorldPoint START = new WorldPoint(3183, 3435, 0); private static final WorldPoint GOAL = new WorldPoint(3173, 3399, 0); - /** The historic bad click: ~10 tiles out, and crucially NOT on the raw route. */ - private static final WorldPoint OLD_DEVIATING_CLICK = new WorldPoint(3176, 3428, 0); - private static List sharedRawPath; @BeforeClass @@ -110,11 +108,14 @@ private static List computeRawPath(WorldPoint start, WorldPoint goal } @Test - public void theHistoricDeviatingClickIsNotOnTheRawRoute() { - assertFalse("raw path should not be empty", sharedRawPath.isEmpty()); - assertFalse("(3176,3428) must not be on the raw route — selecting only on-route points is " - + "what prevents the game improvising a detour", - sharedRawPath.contains(OLD_DEVIATING_CLICK)); + public void primaryClickSelectionStaysOnComputedRawRoute() { + WorldPoint selected = WalkerPathGeometry.findFurthestRawPathPointMatching( + sharedRawPath, START, 10, 0, point -> true, + sharedRawPath.size(), () -> 0); + + assertNotNull("the real route should offer a primary minimap target", selected); + assertTrue("primary click selection must return a tile on the current randomized raw route", + sharedRawPath.contains(selected)); } /**