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..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
@@ -904,4 +904,18 @@ 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,
+ 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 073c4825dc0..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";
@@ -969,6 +970,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..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
@@ -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,68 @@ 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) {
+ throw new IllegalArgumentException("Unresolved transport skill requirement '"
+ + requirement.trim() + "' in raw field '" + 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 +434,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 +490,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 +748,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..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
@@ -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,39 +54,36 @@ 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);
- 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();
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,71 @@ 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 (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");
+ }
+
+ 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 +217,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 +286,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 +296,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];
+ // 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 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];
- }
- return out;
- }
-
- 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 +551,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 +574,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 +589,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 +599,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 +612,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 +653,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 +663,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 +673,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 +698,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 +724,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 +770,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 +808,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 +817,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 +845,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 +936,74 @@ 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) {
+ 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,
+ // 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 +1022,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..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
@@ -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,22 @@ 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);
+ }
+
+ /**
+ * 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,
+ 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 +285,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();
@@ -334,9 +351,14 @@ 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());
+ 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 +471,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 +490,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 +574,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 +663,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);
}
@@ -656,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);
@@ -693,9 +742,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 +770,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 +784,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 +878,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 +909,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) {
@@ -1100,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() {
@@ -1245,40 +1265,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 (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(),
+ 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 +1320,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 +1340,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 +1348,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 +1357,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;
}
@@ -1341,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);
@@ -1357,14 +1386,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 +1502,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 +1581,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();
+ }
- if (refreshAvailableItemIds != null) {
- return transport.getItemIdRequirements()
- .stream()
- .flatMap(Collection::stream)
- .anyMatch(refreshAvailableItemIds::contains);
+ static boolean meetsItemRequirements(
+ List requirements,
+ java.util.function.IntUnaryOperator availableQuantity) {
+ if (requirements == null || requirements.isEmpty()) {
+ return true;
}
- return transport.getItemIdRequirements()
- .stream()
- .flatMap(Collection::stream)
- .anyMatch(itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId) || (ShortestPathPlugin.getPathfinderConfig().useBankItems && Rs2Bank.hasItem(itemId)));
+ return requirements.stream().allMatch(requirement -> requirement.isSatisfiedBy(availableQuantity));
+ }
+
+ private int availableItemQuantity(int itemId) {
+ if (itemId == ItemID.CHRONICLE && !hasChronicleCharges()) {
+ return 0;
+ }
+ 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 +1658,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 +1679,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 +2116,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 +2149,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..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,6 +101,25 @@ public static Object getPathfinderMutex()
// Config
// ------------------------------------------------------------------
+ /**
+ * 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;
+ }
+
/** @return the shared pathfinder configuration (transports, restrictions, toggles). */
public static PathfinderConfig 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..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
@@ -48,7 +46,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 +90,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());
@@ -73,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));
}
/**
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..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
@@ -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,481 @@ 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(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()
+ .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