From 32744d700bfca7c507f26baabda1c0b75ef2268f Mon Sep 17 00:00:00 2001 From: Haliax <18099301+TheHaliax@users.noreply.github.com> Date: Mon, 11 May 2026 08:42:58 -0500 Subject: [PATCH 1/9] refactor(pathfinder): consolidate transport edge loading Unify edge injection and blocked-edge loading so pathfinder builds stable transport graph. --- .../microbot/shortestpath/Transport.java | 97 +++- .../microbot/shortestpath/TransportType.java | 21 +- .../shortestpath/pathfinder/CollisionMap.java | 176 +++++-- .../shortestpath/pathfinder/Pathfinder.java | 390 +++++++++++++-- .../pathfinder/PathfinderConfig.java | 451 +++++++++++++++--- .../policy/TransportRequirementPolicy.java | 36 ++ .../shortestpath/teleportation_items.tsv | 17 +- .../shortestpath/TransportTypeTest.java | 35 ++ ...hfinderConfigTransportRefreshHashTest.java | 53 ++ 9 files changed, 1118 insertions(+), 158 deletions(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/policy/TransportRequirementPolicy.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportTypeTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigTransportRefreshHashTest.java 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 6c454db897e..b9bc49d3417 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 @@ -7,8 +7,6 @@ import net.runelite.api.QuestState; import net.runelite.api.Skill; import net.runelite.api.coords.WorldPoint; -import net.runelite.client.plugins.microbot.Microbot; - import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.*; @@ -262,12 +260,14 @@ public Transport(WorldPoint destination, String displayInfo, TransportType trans } if ((value = fieldMap.get("Currency")) != null) { - // Split the string by space String[] parts = value.split(DELIM); if (parts.length > 1) { - // Parse the first part as an integer amount - currencyAmount = Integer.parseInt(parts[0]); - currencyName = parts[1]; + try { + currencyAmount = Integer.parseInt(parts[0].trim()); + currencyName = parts[1].trim(); + } catch (NumberFormatException e) { + log.debug("Skipping invalid Currency field: {}", value); + } } } //END microbot variables @@ -316,7 +316,7 @@ public Transport(WorldPoint destination, String displayInfo, TransportType trans this.duration = Integer.parseInt(value); } - if (TransportType.isTeleport(transportType)) { + if (TransportType.isTeleport(transportType, origin)) { // Teleports should always have a non-zero wait, // so the pathfinder doesn't calculate the cost by distance this.duration = Math.max(this.duration, 1); @@ -340,6 +340,9 @@ public Transport(WorldPoint destination, String displayInfo, TransportType trans if ((value = fieldMap.get("Varbits")) != null && !value.trim().isEmpty()) { for (String varbitCheck : value.split(DELIM_MULTI)) { + if (varbitCheck.isBlank()) { + continue; + } String[] parts; TransportVarbit.Operator operator; @@ -359,17 +362,25 @@ public Transport(WorldPoint destination, String displayInfo, TransportType trans parts = varbitCheck.split("@"); operator = TransportVarbit.Operator.COOLDOWN_MINUTES; } else { - throw new IllegalArgumentException("Invalid varbit format: " + varbitCheck); + log.debug("Skipping invalid varbit token: {}", varbitCheck); + continue; } - int varbitId = Integer.parseInt(parts[0]); - int varbitValue = Integer.parseInt(parts[1]); - varbits.add(new TransportVarbit(varbitId, varbitValue, operator)); + try { + int varbitId = Integer.parseInt(parts[0].trim()); + int varbitValue = Integer.parseInt(parts[1].trim()); + varbits.add(new TransportVarbit(varbitId, varbitValue, operator)); + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + log.debug("Skipping malformed varbit token: {}", varbitCheck); + } } } if ((value = fieldMap.get("Varplayers")) != null && !value.trim().isEmpty()) { for (String varplayerCheck : value.split(DELIM_MULTI)) { + if (varplayerCheck.isBlank()) { + continue; + } String[] parts; TransportVarPlayer.Operator operator; @@ -389,12 +400,17 @@ public Transport(WorldPoint destination, String displayInfo, TransportType trans parts = varplayerCheck.split("@"); operator = TransportVarPlayer.Operator.COOLDOWN_MINUTES; } else { - throw new IllegalArgumentException("Invalid varplayer format: " + varplayerCheck); + log.debug("Skipping invalid varplayer token: {}", varplayerCheck); + continue; } - int varplayerId = Integer.parseInt(parts[0]); - int varplayerValue = Integer.parseInt(parts[1]); - varplayers.add(new TransportVarPlayer(varplayerId, varplayerValue, operator)); + try { + int varplayerId = Integer.parseInt(parts[0].trim()); + int varplayerValue = Integer.parseInt(parts[1].trim()); + varplayers.add(new TransportVarPlayer(varplayerId, varplayerValue, operator)); + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + log.debug("Skipping malformed varplayer token: {}", varplayerCheck); + } } } @@ -465,19 +481,37 @@ private static void addTransports(Map> transports, St final String PREFIX_COMMENT = "#"; try { - String s = new String(Util.readAllBytes(ShortestPathPlugin.class.getResourceAsStream(path)), StandardCharsets.UTF_8); + java.io.InputStream stream = ShortestPathPlugin.class.getResourceAsStream(path); + if (stream == null) { + log.warn("Transport resource missing, skipping: {}", path); + return; + } + String s = new String(Util.readAllBytes(stream), StandardCharsets.UTF_8); Scanner scanner = new Scanner(s); + if (!scanner.hasNextLine()) { + scanner.close(); + log.warn("Transport resource empty, skipping: {}", path); + return; + } // Header line is the first line in the file and will start with either '#' or '# ' String headerLine = scanner.nextLine(); + if (headerLine.endsWith("\r")) { + headerLine = headerLine.substring(0, headerLine.length() - 1); + } headerLine = headerLine.startsWith(PREFIX_COMMENT + " ") ? headerLine.replace(PREFIX_COMMENT + " ", PREFIX_COMMENT) : headerLine; headerLine = headerLine.startsWith(PREFIX_COMMENT) ? headerLine.replace(PREFIX_COMMENT, "") : headerLine; String[] headers = headerLine.split(DELIM_COLUMN); Set newTransports = new HashSet<>(); + int lineNumber = 1; while (scanner.hasNextLine()) { + lineNumber++; String line = scanner.nextLine(); + if (line.endsWith("\r")) { + line = line.substring(0, line.length() - 1); + } if (line.startsWith(PREFIX_COMMENT) || line.isBlank()) { continue; @@ -491,11 +525,12 @@ private static void addTransports(Map> transports, St } } - - Transport transport = new Transport(fieldMap, transportType); - - newTransports.add(transport); - + try { + Transport transport = new Transport(fieldMap, transportType); + newTransports.add(transport); + } catch (RuntimeException e) { + log.warn("Skipping transport row {} in {}: {}", lineNumber, path, e.getMessage()); + } } scanner.close(); @@ -548,14 +583,11 @@ private static void addTransports(Map> transports, St } } } catch (IOException e) { - Microbot.log(e.getMessage()); - e.printStackTrace(); - throw new RuntimeException(e); + log.warn("Failed to read transport file {}: {}", path, e.getMessage()); } } - public static HashMap> loadAllFromResources() { - HashMap> transports = new HashMap<>(); + private static void appendStandardTransportFiles(HashMap> transports) { addTransports(transports, "transports.tsv", TransportType.TRANSPORT); addTransports(transports, "agility_shortcuts.tsv", TransportType.AGILITY_SHORTCUT); addTransports(transports, "boats.tsv", TransportType.BOAT); @@ -578,10 +610,23 @@ public static HashMap> loadAllFromResources() { addTransports(transports, "magic_mushtrees.tsv", TransportType.MAGIC_MUSHTREE, 5); addTransports(transports, "seasonal_transports.tsv", TransportType.SEASONAL_TRANSPORT); addTransports(transports, "npcs.tsv", TransportType.NPC); + } + + public static HashMap> loadAllFromResources() { + HashMap> transports = new HashMap<>(); + appendStandardTransportFiles(transports); System.out.println("Loaded " + transports.size() + " transports"); return transports; } + /** + * Reload transport TSVs from the plugin classpath (same as {@link #loadAllFromResources()}). + * Apply to {@link PathfinderConfig} via {@link ShortestPathPlugin} config hot-reload. + */ + public static HashMap> reloadFromResources() { + return loadAllFromResources(); + } + // To string method for debugging @Override public String toString() { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportType.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportType.java index 60b1cac35e4..c0c8bbf8adc 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportType.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/TransportType.java @@ -1,5 +1,7 @@ package net.runelite.client.plugins.microbot.shortestpath; +import net.runelite.api.coords.WorldPoint; + public enum TransportType { TRANSPORT, AGILITY_SHORTCUT, @@ -32,15 +34,32 @@ public enum TransportType { * and not teleports because they have a pre-defined origin and no * wilderness level limit. */ + /** + * Teleport classification when origin is unknown. Seasonal rows default to {@code true} so + * disabling teleports / item detection stays conservative. + */ public static boolean isTeleport(TransportType transportType) { + return isTeleport(transportType, null); + } + + /** + * Whether this transport should follow teleport costing and walker teleport branches. + * {@link #SEASONAL_TRANSPORT} rows with a non-null origin (object/NPC anchored) are treated like + * ordinary transports: walk to origin, then use — no {@code distanceBeforeUsingTeleport} penalty. + * + * @param origin transport origin; {@code null} means originless (catalog teleport-style seasonal) + */ + public static boolean isTeleport(TransportType transportType, WorldPoint origin) { if (transportType == null) { return false; } + if (transportType == SEASONAL_TRANSPORT) { + return origin == null; + } switch (transportType) { case TELEPORTATION_ITEM: case TELEPORTATION_MINIGAME: case TELEPORTATION_SPELL: - case SEASONAL_TRANSPORT: return true; default: return false; 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 17b0e1b6894..65d4ba3a061 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 @@ -69,13 +69,9 @@ public boolean isBlocked(int x, int y, int z) { /** * Single walking step permission check from (x,y,z) in direction (dx,dy). - * Mirrors the traversability logic in {@link #getNeighbors} so that a - * line-of-sight trace approves exactly the sequences of moves the BFS - * could have taken without a transport edge. - * - *

Walls, closed doors, and diagonal corner-cutting are all blocked - * here — which is the invariant the path smoother relies on to avoid - * skipping across transport origins. + * Used by {@link PathSmoother}. Graph expansion uses {@link #fillTraversableLegacy} + * instead; they intentionally differ where legacy blocked-tile logic diverges from + * {@code canStep}. */ public boolean canStep(int x, int y, int z, int dx, int dy) { if (dx == 0 && dy == 0) return true; @@ -98,6 +94,40 @@ public boolean canStep(int x, int y, int z, int dx, int dy) { return false; } + /** + * Legacy neighbor traversability for {@link #getNeighbors} / {@link #getReverseNeighbors}. + * {@link #canStep} remains for {@link PathSmoother} line traces. + */ + private void fillTraversableLegacy(int x, int y, int z, boolean[] out) { + if (isBlocked(x, y, z)) { + boolean westBlocked = isBlocked(x - 1, y, z); + boolean eastBlocked = isBlocked(x + 1, y, z); + boolean southBlocked = isBlocked(x, y - 1, z); + boolean northBlocked = isBlocked(x, y + 1, z); + boolean southWestBlocked = isBlocked(x - 1, y - 1, z); + boolean southEastBlocked = isBlocked(x + 1, y - 1, z); + boolean northWestBlocked = isBlocked(x - 1, y + 1, z); + boolean northEastBlocked = isBlocked(x + 1, y + 1, z); + out[0] = !westBlocked; + out[1] = !eastBlocked; + out[2] = !southBlocked; + out[3] = !northBlocked; + out[4] = !southWestBlocked && !westBlocked && !southBlocked; + out[5] = !southEastBlocked && !eastBlocked && !southBlocked; + out[6] = !northWestBlocked && !westBlocked && !northBlocked; + out[7] = !northEastBlocked && !eastBlocked && !northBlocked; + } else { + out[0] = w(x, y, z); + out[1] = e(x, y, z); + out[2] = s(x, y, z); + out[3] = n(x, y, z); + out[4] = sw(x, y, z); + out[5] = se(x, y, z); + out[6] = nw(x, y, z); + out[7] = ne(x, y, z); + } + } + private static int packedPointFromOrdinal(int startPacked, OrdinalDirection direction) { final int x = WorldPointUtil.unpackWorldX(startPacked); final int y = WorldPointUtil.unpackWorldY(startPacked); @@ -108,6 +138,7 @@ private static int packedPointFromOrdinal(int startPacked, OrdinalDirection dire // This is only safe if pathfinding is single-threaded private final List neighbors = new ArrayList<>(16); private final boolean[] traversable = new boolean[8]; + private final boolean[] traversableReverseAccum = new boolean[8]; public static final Set ignoreCollisionPacked; static { @@ -173,7 +204,7 @@ public List getNeighbors(Node node, VisitedTiles visited, PathfinderConfig continue; } - if (TransportType.isTeleport(transport.getType())) { + if (TransportType.isTeleport(transport.getType(), transport.getOrigin())) { if (config.isIgnoreTeleportAndItems()) { if (isMoa) moaIgnored++; continue; @@ -198,33 +229,7 @@ public List getNeighbors(Node node, VisitedTiles visited, PathfinderConfig moaCosts == null ? "[]" : moaCosts); } - if (isBlocked(x, y, z)) { - boolean westBlocked = isBlocked(x - 1, y, z); - boolean eastBlocked = isBlocked(x + 1, y, z); - boolean southBlocked = isBlocked(x, y - 1, z); - boolean northBlocked = isBlocked(x, y + 1, z); - boolean southWestBlocked = isBlocked(x - 1, y - 1, z); - boolean southEastBlocked = isBlocked(x + 1, y - 1, z); - boolean northWestBlocked = isBlocked(x - 1, y + 1, z); - boolean northEastBlocked = isBlocked(x + 1, y + 1, z); - traversable[0] = !westBlocked; - traversable[1] = !eastBlocked; - traversable[2] = !southBlocked; - traversable[3] = !northBlocked; - traversable[4] = !southWestBlocked && !westBlocked && !southBlocked; - traversable[5] = !southEastBlocked && !eastBlocked && !southBlocked; - traversable[6] = !northWestBlocked && !westBlocked && !northBlocked; - traversable[7] = !northEastBlocked && !eastBlocked && !northBlocked; - } else { - traversable[0] = w(x, y, z); - traversable[1] = e(x, y, z); - traversable[2] = s(x, y, z); - traversable[3] = n(x, y, z); - traversable[4] = sw(x, y, z); - traversable[5] = se(x, y, z); - traversable[6] = nw(x, y, z); - traversable[7] = ne(x, y, z); - } + fillTraversableLegacy(x, y, z, traversable); for (int i = 0; i < traversable.length; i++) { OrdinalDirection d = ORDINAL_VALUES[i]; @@ -272,4 +277,105 @@ public List getNeighbors(Node node, VisitedTiles visited, PathfinderConfig return neighbors; } + + /** + * Predecessor expansion for bidirectional search: every forward edge {@code pred → node} appears as + * a {@code node} expansion to {@code pred}. Origin-less teleports are omitted (caller builds + * {@code incomingByDestPacked} without them). + */ + public List getReverseNeighbors(Node node, VisitedTiles visitedBackward, PathfinderConfig config, + Set puzzleAllowPacked, Map> incomingByDestPacked) { + final int x = WorldPointUtil.unpackWorldX(node.packedPosition); + final int y = WorldPointUtil.unpackWorldY(node.packedPosition); + final int z = WorldPointUtil.unpackWorldPlane(node.packedPosition); + + neighbors.clear(); + + if (incomingByDestPacked != null) { + Set incoming = incomingByDestPacked.getOrDefault(node.packedPosition, Collections.emptySet()); + for (Transport transport : incoming) { + WorldPoint origin = transport.getOrigin(); + if (origin == null) { + continue; + } + int originPacked = WorldPointUtil.packWorldPoint(origin); + if (visitedBackward.get(originPacked)) { + continue; + } + if (TransportType.isTeleport(transport.getType(), transport.getOrigin())) { + if (config.isIgnoreTeleportAndItems()) { + continue; + } + neighbors.add(new TransportNode(origin, node, config.getDistanceBeforeUsingTeleport() + transport.getDuration())); + } else { + neighbors.add(new TransportNode(origin, node, transport.getDuration())); + } + } + } + + for (int i = 0; i < 8; i++) { + OrdinalDirection d = ORDINAL_VALUES[i]; + fillTraversableLegacy(x - d.x, y - d.y, z, traversable); + traversableReverseAccum[i] = traversable[i]; + } + System.arraycopy(traversableReverseAccum, 0, traversable, 0, 8); + + for (int i = 0; i < traversable.length; i++) { + OrdinalDirection d = ORDINAL_VALUES[i]; + int prevPacked = WorldPointUtil.packWorldPoint(x - d.x, y - d.y, z); + if (visitedBackward.get(prevPacked)) { + continue; + } + if (config.getRestrictedPointsPacked().contains(prevPacked)) { + continue; + } + if (config.getCustomRestrictions().contains(prevPacked)) { + continue; + } + if (config.isBlockedTransportStep(prevPacked, node.packedPosition)) { + continue; + } + + if (ignoreCollisionPacked.contains(node.packedPosition)) { + neighbors.add(new Node(prevPacked, node)); + continue; + } + + if (getCachedRegionId() == TOA_PUZZLE_REGION) { + if (!puzzleAllowPacked.contains(prevPacked)) { + WorldPoint globalWorldPoint = Rs2WorldPoint.convertInstancedWorldPoint(WorldPointUtil.unpackWorldPoint(prevPacked)); + if (globalWorldPoint != null) { + TileObject go = Rs2GameObject.getGroundObject(globalWorldPoint); + if (go != null && go.getId() == 45340) { + continue; + } + } + } + } + + if (traversable[i]) { + neighbors.add(new Node(prevPacked, node)); + } else if (Math.abs(d.x + d.y) == 1 && isBlocked(x, y, z)) { + int wx = x - d.x; + int wy = y - d.y; + int bpx = wx + d.x; + int bpy = wy + d.y; + if (bpx != x || bpy != y) { + continue; + } + Set ts = config.getTransportsPacked().getOrDefault(node.packedPosition, Collections.emptySet()); + for (Transport transport : ts) { + if (transport.getOrigin() == null) { + continue; + } + if (WorldPointUtil.packWorldPoint(transport.getOrigin()) != node.packedPosition) { + continue; + } + neighbors.add(new Node(WorldPointUtil.packWorldPoint(wx, wy, z), node)); + } + } + } + + return neighbors; + } } 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 b21426978c2..be6f264dd07 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 @@ -2,15 +2,38 @@ import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import net.runelite.client.plugins.microbot.Microbot; import net.runelite.api.coords.WorldPoint; import net.runelite.client.plugins.microbot.shortestpath.Transport; import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; +import net.runelite.client.plugins.microbot.util.walker.WebWalkLog; import java.util.*; import java.util.stream.Collectors; +import org.slf4j.event.Level; + @Slf4j public class Pathfinder implements Runnable { + /** + * Detailed pathfinder traces — set logger {@code net.runelite.client.plugins.microbot.shortestpath.pathfinder} + * to DEBUG, or use {@link Microbot#log(org.slf4j.event.Level, String, Object...)} routing via Microbot. + */ + private static void pathfinderDiag(String format, Object... args) { + Microbot.log(Level.DEBUG, "[PathfinderDiag] " + format, args); + } + + private static final Comparator NODE_ORDER = Comparator + .comparingInt(Node::fCost) + .thenComparingInt(n -> n.cost) + .thenComparingInt(n -> n.tiebreaker); + + /** + * Bidirectional search only for single-target routes at least this Chebyshev distance apart. + * Medium-range paths often expand fewer nodes unidirectionally; very long routes (e.g. surface↔underground) + * benefit from meet-in-the-middle. Wilderness {@code refreshTeleports} stays forward-only. + */ + private static final int BIDIRECTIONAL_MIN_CHEBYSHEV = 2000; private PathfinderStats stats; @Getter private volatile boolean done = false; @@ -46,11 +69,10 @@ public class Pathfinder implements Runnable { // distance-to-goal — 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, - Comparator.comparingInt(Node::fCost) - .thenComparingInt(n -> n.cost) - .thenComparingInt(n -> n.tiebreaker)); + private final Queue boundary = new PriorityQueue<>(4096, NODE_ORDER); private final Queue pending = new PriorityQueue<>(256); + private final Queue boundaryBackward = new PriorityQueue<>(4096, NODE_ORDER); + private final Queue pendingBackward = new PriorityQueue<>(256); private final VisitedTiles visited; private volatile List path = Collections.emptyList(); @@ -58,6 +80,8 @@ public class Pathfinder implements Runnable { private volatile boolean pathNeedsUpdate = false; 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; /** * Teleportation transports are updated when this changes. * Can be either: @@ -82,7 +106,7 @@ public Pathfinder(PathfinderConfig config, int start, Set targets) { visited = new VisitedTiles(map); targetInWilderness = PathfinderConfig.isInWildernessPackedPoint(targets); wildernessLevel = 31; - log.debug("Created Pathfinder src={} dst={} config={}", + WebWalkLog.pf("created src={} dst={} config={}", WorldPointUtil.toString(this.start), WorldPointUtil.toString(this.targets), config @@ -119,6 +143,10 @@ public PathfinderStats getStats() { } public List getPath() { + List joined = joinedPath; + if (joined != null) { + return joined; + } Node lastNode = bestLastNode; // For thread safety, read bestLastNode once if (lastNode == null) { return path; @@ -221,22 +249,241 @@ private int heuristicToNearestTarget(int packedPos) { return best; } - @Override - public void run() { - log.info("[Pathfinder] run() started: src={}, dst={}, cutoff={}ms", - WorldPointUtil.toString(start), WorldPointUtil.toString(targets), config.getCalculationCutoffMillis()); - try { - stats.start(); - 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 cutoffTimeMillis = System.currentTimeMillis() + cutoffDurationMillis; - config.refreshTeleports(start, 31); - while (!cancelled && (!boundary.isEmpty() || !pending.isEmpty())) { + private int heuristicFromStart(int packedPos) { + int posX = WorldPointUtil.unpackWorldX(packedPos); + int posY = WorldPointUtil.unpackWorldY(packedPos); + int sx = WorldPointUtil.unpackWorldX(start); + int sy = WorldPointUtil.unpackWorldY(start); + int dx = Math.abs(posX - sx); + int direct = Math.max(dx, Math.abs(posY - sy)); + int wrapped = Math.max(dx, Math.abs(((posY % UNDERGROUND_Y_OFFSET) - (sy % UNDERGROUND_Y_OFFSET)))); + return Math.min(direct, wrapped); + } + + 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; + } + } + return best; + } + + private void buildIncomingByDestination(Map> out) { + out.clear(); + for (Map.Entry> e : config.getTransports().entrySet()) { + for (Transport t : e.getValue()) { + if (t.getDestination() == null || t.getOrigin() == null) { + continue; + } + int dp = WorldPointUtil.packWorldPoint(t.getDestination()); + out.computeIfAbsent(dp, k -> new HashSet<>()).add(t); + } + } + } + + private static void maybeImproveMeeting(Node forwardNode, Node backwardNode, long[] bestCost, Node[] bestForward, Node[] bestBackward) { + long sum = (long) forwardNode.cost + (long) backwardNode.cost; + if (sum < bestCost[0]) { + bestCost[0] = sum; + bestForward[0] = forwardNode; + bestBackward[0] = backwardNode; + } + } + + private List combineBidirectionalPath(Node forwardAtMeet, Node backwardAtMeet) { + 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)); + } + 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); + for (Node neighbor : nodes) { + if (config.avoidWilderness(node.packedPosition, neighbor.packedPosition, targetInWilderness)) { + continue; + } + + visited.set(neighbor.packedPosition); + if (neighbor instanceof TransportNode) { + pending.add(neighbor); + ++stats.transportsChecked; + } else { + neighbor.heuristic = heuristicToNearestTarget(neighbor.packedPosition); + boundary.add(neighbor); + ++stats.nodesChecked; + } + forwardAt.putIfAbsent(neighbor.packedPosition, neighbor); + Node b = backwardAt.get(neighbor.packedPosition); + if (b != null) { + maybeImproveMeeting(neighbor, b, bestMeetingCost, meetF, meetB); + } + } + } + + private void addNeighborsBackwardWithMeet(Node node, VisitedTiles visitedB, Map> incoming, + Set puzzleAllow, Map forwardAt, Map backwardAt, + long[] bestMeetingCost, Node[] meetF, Node[] meetB) { + List nodes = map.getReverseNeighbors(node, visitedB, config, puzzleAllow, incoming); + for (Node pred : nodes) { + if (config.avoidWilderness(pred.packedPosition, node.packedPosition, targetInWilderness)) { + continue; + } + + visitedB.set(pred.packedPosition); + if (pred instanceof TransportNode) { + pendingBackward.add(pred); + ++stats.transportsChecked; + } else { + pred.heuristic = heuristicFromStart(pred.packedPosition); + boundaryBackward.add(pred); + ++stats.nodesChecked; + } + backwardAt.putIfAbsent(pred.packedPosition, pred); + Node f = forwardAt.get(pred.packedPosition); + if (f != null) { + maybeImproveMeeting(f, pred, bestMeetingCost, meetF, meetB); + } + } + } + + 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 cutoffTimeMillis = System.currentTimeMillis() + cutoffDurationMillis; + config.refreshTeleports(start, 31); + boolean reachedGoal = false; + boolean timedOut = false; + while (!cancelled && (!boundary.isEmpty() || !pending.isEmpty())) { + Node b = boundary.peek(); + Node p = pending.peek(); + Node node; + if (p != null && (b == null || p.cost < b.cost)) { + node = pending.poll(); + } else { + node = boundary.poll(); + } + + if (wildernessLevel > 0) { + boolean update = false; + + if (wildernessLevel > 30 && !config.isInLevel30Wilderness(node.packedPosition)) { + wildernessLevel = 30; + update = true; + } + if (wildernessLevel > 20 && !config.isInLevel20Wilderness(node.packedPosition)) { + wildernessLevel = 20; + update = true; + } + if (wildernessLevel > 0 && !PathfinderConfig.isInWilderness(node.packedPosition)) { + wildernessLevel = 0; + update = true; + } + if (update) { + config.refreshTeleports(node.packedPosition, wildernessLevel); + } + } + + final int nodePos = node.packedPosition; + boolean reached = false; + for (int target : targetsPacked) { + if (nodePos == target) { + bestLastNode = node; + pathNeedsUpdate = true; + reached = true; + break; + } + 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; + } + } + if (reached) { + reachedGoal = true; + break; + } + + if (System.currentTimeMillis() > cutoffTimeMillis) { + timedOut = true; + WebWalkLog.pf("cutoff bestDist={} nodes={}", bestDistance, stats.getNodesChecked()); + break; + } + + addNeighbors(node); + } + + String uniExit = cancelled ? "cancelled" + : reachedGoal ? "reached-goal" + : timedOut ? "time-cutoff" + : (boundary.isEmpty() && pending.isEmpty()) ? "queues-drained" : "loop-ended"; + pathfinderDiag("uni finished exit=%s cancelled=%s boundaryEmpty=%s pendingEmpty=%s bestLastNode=%s cutoffMs=%d", + uniExit, + cancelled, + boundary.isEmpty(), + pending.isEmpty(), + bestLastNode == null ? "null" : WorldPointUtil.toString(bestLastNode.packedPosition), + config.getCalculationCutoffMillis()); + + WebWalkLog.pf("uni_loop_exit cancelled={} bEmpty={} pEmpty={} bestLast={}", + cancelled, boundary.isEmpty(), pending.isEmpty(), + bestLastNode == null ? "null" : WorldPointUtil.toString(bestLastNode.packedPosition)); + } + + private void runBidirectional() { + int goalPacked = targetsPacked[0]; + Map> incoming = new HashMap<>(512); + buildIncomingByDestination(incoming); + + Set puzzleAllow = new HashSet<>(targets.size() + 1); + for (int t : targetsPacked) { + puzzleAllow.add(t); + } + puzzleAllow.add(start); + + VisitedTiles visitedB = new VisitedTiles(map); + Map forwardAt = new HashMap<>(4096); + Map backwardAt = new HashMap<>(4096); + long[] bestMeetingCost = new long[]{Long.MAX_VALUE}; + Node[] meetF = new Node[1]; + 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 cutoffTimeMillis = System.currentTimeMillis() + cutoffDurationMillis; + config.refreshTeleports(start, 31); + + while (!cancelled && (!boundary.isEmpty() || !pending.isEmpty() || !boundaryBackward.isEmpty() || !pendingBackward.isEmpty())) { + if (!boundary.isEmpty() || !pending.isEmpty()) { Node b = boundary.peek(); Node p = pending.peek(); Node node; @@ -248,7 +495,6 @@ public void run() { if (wildernessLevel > 0) { boolean update = false; - if (wildernessLevel > 30 && !config.isInLevel30Wilderness(node.packedPosition)) { wildernessLevel = 30; update = true; @@ -267,14 +513,15 @@ public void run() { } final int nodePos = node.packedPosition; - boolean reached = false; + if (nodePos == goalPacked) { + joinedPath = node.getPath(); + pathNeedsUpdate = false; + bestLastNode = null; + WebWalkLog.pf("bidir forward_hit_goal"); + break; + } + for (int target : targetsPacked) { - if (nodePos == target) { - bestLastNode = node; - pathNeedsUpdate = true; - reached = true; - break; - } int distance = WorldPointUtil.distanceBetween(nodePos, target); long heuristic = distance + (long) WorldPointUtil.distanceBetween(nodePos, target, 2); if (heuristic < bestHeuristic || (heuristic <= bestHeuristic && distance < bestDistance)) { @@ -285,19 +532,86 @@ public void run() { cutoffTimeMillis = System.currentTimeMillis() + cutoffDurationMillis; } } - if (reached) break; - if (System.currentTimeMillis() > cutoffTimeMillis) { - log.info("[Pathfinder] Cutoff reached. bestDistance={}, nodesChecked={}", bestDistance, stats.getNodesChecked()); + addNeighborsForwardWithMeet(node, forwardAt, backwardAt, bestMeetingCost, meetF, meetB); + } + + if (joinedPath != null) { + break; + } + + if (!boundaryBackward.isEmpty() || !pendingBackward.isEmpty()) { + Node b = boundaryBackward.peek(); + Node p = pendingBackward.peek(); + Node node; + if (p != null && (b == null || p.cost < b.cost)) { + node = pendingBackward.poll(); + } else { + node = boundaryBackward.poll(); + } + + if (node.packedPosition == start) { + joinedPath = combineBidirectionalPath(forwardAt.get(start), node); + pathNeedsUpdate = false; + bestLastNode = null; + WebWalkLog.pf("bidir backward_hit_start"); break; } - addNeighbors(node); + addNeighborsBackwardWithMeet(node, visitedB, incoming, puzzleAllow, forwardAt, backwardAt, bestMeetingCost, meetF, meetB); } - log.info("[Pathfinder] Loop exited. cancelled={}, boundaryEmpty={}, pendingEmpty={}, bestLastNode={}", - cancelled, boundary.isEmpty(), pending.isEmpty(), - bestLastNode == null ? "null" : WorldPointUtil.toString(bestLastNode.packedPosition)); + if (System.currentTimeMillis() > cutoffTimeMillis) { + WebWalkLog.pf("bidir_cutoff nodes={}", stats.getNodesChecked()); + break; + } + } + + if (joinedPath == null && meetF[0] != null && meetB[0] != null && bestMeetingCost[0] < Long.MAX_VALUE) { + joinedPath = combineBidirectionalPath(meetF[0], meetB[0]); + pathNeedsUpdate = false; + bestLastNode = null; + WebWalkLog.pf("bidir meet_at={} cost={}", + WorldPointUtil.toString(meetF[0].packedPosition), bestMeetingCost[0]); + } + + pathfinderDiag("bidir finished joinedPath=%s meetCost=%s forwardFrontier=%d/%d backwardFrontier=%d/%d cancelled=%s", + joinedPath == null ? "null" : joinedPath.size(), + bestMeetingCost[0] == Long.MAX_VALUE ? "n/a" : bestMeetingCost[0], + boundary.size(), + pending.size(), + boundaryBackward.size(), + pendingBackward.size(), + cancelled); + + WebWalkLog.pf("bidir_exit joined={} meetCost={}", + joinedPath == null ? "null" : Integer.toString(joinedPath.size()), + bestMeetingCost[0] == Long.MAX_VALUE ? "n/a" : Long.toString(bestMeetingCost[0])); + } + + @Override + public void run() { + WebWalkLog.pf("run_start src={} dst={} cutoffMs={}", + WorldPointUtil.toString(start), WorldPointUtil.toString(targets), config.getCalculationCutoffMillis()); + joinedPath = null; + try { + stats.start(); + int minCheb = minChebyshevStartToAnyTarget(); + boolean useBidir = targetsPacked.length == 1 + && minCheb >= BIDIRECTIONAL_MIN_CHEBYSHEV; + pathfinderDiag("run mode decision useBidir=%s minCheb=%d bidirThreshold=%d targetsPacked=%d cutoffMs=%d cancelAlready=%s", + useBidir, + minCheb, + BIDIRECTIONAL_MIN_CHEBYSHEV, + targetsPacked.length, + config.getCalculationCutoffMillis(), + cancelled); + if (useBidir) { + WebWalkLog.pf("mode bidir cheb>={}", BIDIRECTIONAL_MIN_CHEBYSHEV); + runBidirectional(); + } else { + runUnidirectional(); + } } catch (Exception e) { log.error("[Pathfinder] Exception in run(): ", e); } finally { @@ -305,11 +619,13 @@ public void run() { boundary.clear(); pending.clear(); + boundaryBackward.clear(); + pendingBackward.clear(); visited.clear(); stats.end(); - log.info("[Pathfinder] run() completed. done={}, cancelled={}, stats={}", + WebWalkLog.pf("run_done done={} cancelled={} stats={}", done, cancelled, getStats() != null ? getStats().toString() : "null"); } } 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 3b9c60ed4d6..69f1333c35e 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 @@ -10,8 +10,8 @@ import net.runelite.api.gameval.VarbitID; import net.runelite.client.plugins.itemcharges.ItemChargeConfig; import net.runelite.client.plugins.microbot.Microbot; -import net.runelite.client.plugins.microbot.globval.enums.InterfaceTab; import net.runelite.client.plugins.microbot.shortestpath.*; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.policy.TransportRequirementPolicy; 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; @@ -19,14 +19,17 @@ import net.runelite.client.plugins.microbot.util.magic.Rs2Spells; import net.runelite.client.plugins.microbot.util.magic.RuneFilter; import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.leaguetransport.Rs2LeaguesTransport; +import net.runelite.client.plugins.microbot.util.leaguetransport.Rs2MapOfAlacrityTransport; import net.runelite.client.plugins.microbot.util.poh.PohTeleports; -import net.runelite.client.plugins.microbot.util.tabs.Rs2Tab; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; +import net.runelite.client.plugins.microbot.util.walker.WebWalkLog; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.IntFunction; import java.util.stream.Collectors; import java.util.stream.IntStream; import java.util.stream.Stream; @@ -58,6 +61,15 @@ public class PathfinderConfig { private static final WorldPoint SPIRIT_TREE_FARMING_GUILD = new WorldPoint(1251, 3750, 0); private static final Set STATIC_BLOCKED_EDGES_PACKED = loadStaticBlockedEdgesFromResources(); + /** Order matches {@link #spiritTreeDestinationToggle(int)} — add destinations in both places only here + switch. */ + private static final WorldPoint[] SPIRIT_TREE_DESTINATIONS_ORDERED = { + SPIRIT_TREE_ETCETERIA, + SPIRIT_TREE_BRIMHAVEN, + SPIRIT_TREE_PORT_SARIM, + SPIRIT_TREE_HOSIDIUS, + SPIRIT_TREE_FARMING_GUILD, + }; + private final SplitFlagMap mapData; private final ThreadLocal map; /** @@ -145,12 +157,19 @@ public class PathfinderConfig { private Map refreshCurrencyCache; private static final Skill[] SKILLS = Skill.values(); + /** + * Memo of last {@link #refreshTransports} result when {@link #computeTransportRefreshCacheKeyHash} and + * verification (boosted skills + transport varbits/varplayers) match. Cleared by {@link #invalidateTransportRefreshCache()}. + */ + private volatile TransportRefreshSnapshot transportRefreshSnapshot; + public PathfinderConfig(SplitFlagMap mapData, Map> transports, List restrictions, Client client, ShortestPathConfig config) { this.mapData = mapData; this.map = ThreadLocal.withInitial(() -> new CollisionMap(this.mapData)); - this.allTransports = transports; + this.allTransports = new ConcurrentHashMap<>(); + replaceAllTransports(transports); this.usableTeleports = ConcurrentHashMap.newKeySet(allTransports.size() / 20); this.transports = new ConcurrentHashMap<>(allTransports.size() / 2); this.transportsPacked = new PrimitiveIntHashMap<>(allTransports.size() / 2); @@ -190,6 +209,9 @@ public void refresh(WorldPoint target) { useSpiritTreePortSarim = ShortestPathPlugin.override("spiritTreePortSarim", config.spiritTreePortSarim()); useSpiritTreeHosidius = ShortestPathPlugin.override("spiritTreeHosidius", config.spiritTreeHosidius()); useSpiritTreeFarmingGuild = ShortestPathPlugin.override("spiritTreeFarmingGuild", config.spiritTreeFarmingGuild()); + + // Keep the master spirit-tree toggle authoritative. Destination toggles only + // gate explicit optional destinations listed in SPIRIT_TREE_DESTINATIONS_ORDERED. useTeleportationItems = ShortestPathPlugin.override("useTeleportationItems", config.useTeleportationItems()); useTeleportationMinigames = ShortestPathPlugin.override("useTeleportationMinigames", config.useTeleportationMinigames()); useTeleportationLevers = ShortestPathPlugin.override("useTeleportationLevers", config.useTeleportationLevers()); @@ -214,14 +236,11 @@ public void refresh(WorldPoint target) { refreshRestrictionData(); long t2 = System.currentTimeMillis(); - // Do not switch back to inventory tab if we are inside of the telekinetic room in Mage Training Arena - if (Rs2Player.getWorldLocation().getRegionID() != 13463) { - Rs2Tab.switchTo(InterfaceTab.INVENTORY); - } - long t3 = System.currentTimeMillis(); + // Do not switch tabs here. refresh() runs often (pathfinder restarts, walker compareRoutes); + // forcing inventory was disruptive and unnecessary — Rs2Inventory reads containers without it. - log.info("[PathfinderConfig] refresh: transports={}ms, restrictions={}ms, tabSwitch={}ms, total={}ms", - t1 - t0, t2 - t1, t3 - t2, t3 - t0); + WebWalkLog.cfg("refresh transports={}ms restr={}ms total={}ms", + t1 - t0, t2 - t1, t2 - t0); //END microbot variables } } @@ -254,6 +273,7 @@ public void refreshTeleports(int packedLocation, int wildernessLevel) { } transportsPacked.put(packedLocation, usableWildyTeleports); } + } public void filterLocations(Set locations, boolean canReviveFiltered) { @@ -292,6 +312,29 @@ private void refreshTransports(WorldPoint target) { useSpiritTrees &= QuestState.FINISHED.equals(Rs2Player.getQuestState(Quest.TREE_GNOME_VILLAGE)); useQuetzals &= QuestState.FINISHED.equals(Rs2Player.getQuestState(Quest.TWILIGHTS_PROMISE)); + final Rs2LeaguesTransport.LeaguesContext leaguesCtx = Rs2LeaguesTransport.leaguesContext(); + final int refreshCacheKeyHash = computeTransportRefreshCacheKeyHash(target, leaguesCtx); + + TransportRefreshSnapshot snap = transportRefreshSnapshot; + if (snap != null && snap.cacheKeyHash == refreshCacheKeyHash && client != null) { + int[] boostedProbe = new int[SKILLS.length]; + Microbot.getClientThread().runOnClientThreadOptional(() -> { + for (int i = 0; i < SKILLS.length; i++) { + boostedProbe[i] = client.getBoostedSkillLevel(SKILLS[i]); + } + return true; + }); + int verProbe = computeTransportRefreshVerificationHash(boostedProbe, snap.sortedVarbits, snap.sortedVarplayers, snap.sortedQuestIds); + if (verProbe == snap.verificationHash) { + snap.restoreInto(this); + if (useBankItems && config != null && config.maxSimilarTransportDistance() > 0) { + filterSimilarTransports(target); + } + WebWalkLog.cfg("refresh_transports cache_hit key={}", refreshCacheKeyHash); + return; + } + } + transports.clear(); transportsPacked.clear(); blockedTransportEdgesPacked.clear(); @@ -344,6 +387,11 @@ private void refreshTransports(WorldPoint target) { int moaSeen = 0; int moaKept = 0; + // One snapshot for this refreshTransports pass (avoid re-querying unlocked regions per transport). + // Trade-off: unlock mid-refresh is picked up on next refresh — acceptable vs client-thread churn per edge. + // Scripts that must path immediately after unlock should trigger an explicit transport refresh / recalc. + // Reviewers: do not "fix" staleness by calling leaguesContext() per transport — intentional batching; callers refresh explicitly when needed. + for (Map.Entry> entry : mergedList.entrySet()) { WorldPoint point = entry.getKey(); Set usableTransports = new HashSet<>(entry.getValue().size()); @@ -351,9 +399,7 @@ private void refreshTransports(WorldPoint target) { totalTransports++; updateActionBasedOnQuestState(transport); - boolean isMoa = transport.getType() == TransportType.SEASONAL_TRANSPORT - && transport.getDisplayInfo() != null - && transport.getDisplayInfo().toLowerCase().contains("map of alacrity"); + boolean isMoa = isMapOfAlacritySeasonalRow(transport); if (isMoa) moaSeen++; long t0 = System.nanoTime(); @@ -367,10 +413,16 @@ private void refreshTransports(WorldPoint target) { stats[2] += (int)(elapsed / 1_000); if (usable) stats[1]++; + // stats[1] is incremented when useTransport() is true; isTransportAllowed may still reject below. + // moaKept reflects the final kept set; per-type stats[1] can exceed checkedTransports when Leagues filters. if (!usable) { addBlockedTransportEdgeIfNeeded(transport); continue; } + + if (!Rs2LeaguesTransport.isTransportAllowed(leaguesCtx, transport)) { + continue; + } checkedTransports++; if (point == null) { usableTeleports.add(transport); @@ -385,6 +437,8 @@ private void refreshTransports(WorldPoint target) { transportsPacked.put(WorldPointUtil.packWorldPoint(point), usableTransports); } } + + Rs2LeaguesTransport.injectLeaguesTransports(this, leaguesCtx, usableTeleports, transports, transportsPacked, typeStats); long filterTime = System.currentTimeMillis() - filterStart; long similarStart = System.currentTimeMillis(); @@ -393,23 +447,41 @@ private void refreshTransports(WorldPoint target) { } long similarTime = System.currentTimeMillis() - similarStart; + int[] sortedVarbits = varbitIds.stream().mapToInt(Integer::intValue).sorted().toArray(); + int[] sortedVarplayers = varplayerIds.stream().mapToInt(Integer::intValue).sorted().toArray(); + int[] sortedQuestIds = mergedList.values().stream() + .flatMap(Set::stream) + .filter(Objects::nonNull) + .map(Transport::getQuests) + .filter(Objects::nonNull) + .flatMap(m -> m.keySet().stream()) + .filter(Objects::nonNull) + .mapToInt(Quest::getId) + .distinct() + .sorted() + .toArray(); + int verificationHash = computeTransportRefreshVerificationHash(refreshBoostedLevels, sortedVarbits, sortedVarplayers, sortedQuestIds); + transportRefreshSnapshot = TransportRefreshSnapshot.capture( + refreshCacheKeyHash, verificationHash, sortedVarbits, sortedVarplayers, sortedQuestIds, transports, usableTeleports); + refreshAvailableItemIds = null; refreshBoostedLevels = null; refreshCurrencyCache = null; - log.info("[refreshTransports] merge={}ms, cache={}ms, filter={}ms (useTransport={}ms), similar={}ms, total/usable={}/{}, teleports={}, varbits={}, varplayers={}", + // 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={} moaS={} moaK={} vb={} vp={}", mergeTime, cacheTime, filterTime, useTransportTimeNanos / 1_000_000, similarTime, - totalTransports, checkedTransports, usableTeleports.size(), varbitIds.size(), varplayerIds.size()); + totalTransports, checkedTransports, usableTeleports.size(), moaSeen, moaKept, varbitIds.size(), varplayerIds.size()); typeStats.entrySet().stream() .sorted((a, b) -> Integer.compare(b.getValue()[2], a.getValue()[2])) .limit(5) - .forEach(e -> log.info("[refreshTransports] {} : count={}, usable={}, time={}ms", + .forEach(e -> WebWalkLog.cfg("refresh_transports type {} cnt={} passed={} timeMs={}", e.getKey(), e.getValue()[0], e.getValue()[1], e.getValue()[2] / 1000)); log.debug("[MoA] refreshTransports: seen={} kept={} (useSeasonalTransports={}, VarbitID.LEAGUE_TYPE={})", moaSeen, moaKept, useSeasonalTransports, - Microbot.getVarbitValue(10032)); + Microbot.getVarbitValue(VarbitID.LEAGUE_TYPE)); } public boolean isBlockedTransportEdge(int originPacked, int destinationPacked) { @@ -577,6 +649,36 @@ public void refresh() { refresh(null); } + /** + * Drop {@link #transportRefreshSnapshot} so the next {@link #refresh(WorldPoint)} rebuilds transport maps + * (inventory/quest/varbit changes that are not captured by the memo key, script-driven transport mutations, etc.). + */ + public void invalidateTransportRefreshCache() { + transportRefreshSnapshot = null; + } + + /** + * Rebuilds base transport definitions from packaged TSV resources and swaps them into {@link #allTransports}. + * The next {@link #refresh(WorldPoint)} will use the reloaded definitions. + * + * @return number of origin nodes loaded + */ + public int reloadTransportDefinitionsFromResources() { + Map> reloaded = Transport.reloadFromResources(); + replaceAllTransports(reloaded); + invalidateTransportRefreshCache(); + return allTransports.size(); + } + + private void replaceAllTransports(Map> source) { + allTransports.clear(); + if (source == null || source.isEmpty()) { + return; + } + source.forEach((origin, set) -> + allTransports.put(origin, set == null ? Collections.emptySet() : new HashSet<>(set))); + } + private void refreshRestrictionData() { internalRestrictedPointsPacked.clear(); List allRestrictions = Stream.concat(resourceRestrictions.stream(), customRestrictions.stream()) @@ -686,20 +788,11 @@ public boolean isInLevel30Wilderness(int packedPoint) { } private boolean completedQuests(Transport transport) { - return transport.getQuests().entrySet().stream() - .allMatch(entry -> { - QuestState playerState = Rs2Player.getQuestState(entry.getKey()); - QuestState requiredState = entry.getValue(); - int playerIndex = questStateOrder.indexOf(playerState); - int requiredIndex = questStateOrder.indexOf(requiredState); - return playerIndex >= requiredIndex; - }); + return TransportRequirementPolicy.completedQuests(transport, questStateOrder); } private boolean varbitChecks(Transport transport) { - return transport.getVarbits().isEmpty() || - transport.getVarbits().stream() - .allMatch(varbitCheck -> varbitCheck.matches(Microbot.getVarbitValue(varbitCheck.getVarbitId()))); + return TransportRequirementPolicy.varbitChecks(transport); } private boolean varplayerChecks(Transport transport) { @@ -714,14 +807,22 @@ private int getLiveVarplayerValue(int varplayerId) { .orElse(0); } + /** + * MoA seasonal row: same predicate for refresh stats and {@link #useTransport}. + * MoA TSV rows are expected to be {@link TransportType#SEASONAL_TRANSPORT} with a non-null destination. + */ + private static boolean isMapOfAlacritySeasonalRow(Transport transport) { + return transport != null + && transport.getType() == TransportType.SEASONAL_TRANSPORT + && transport.getDestination() != null + && Rs2MapOfAlacrityTransport.isMapOfAlacrityTransport(transport); + } + private boolean useTransport(Transport transport) { - boolean traceMoa = transport.getType() == TransportType.SEASONAL_TRANSPORT - && transport.getDisplayInfo() != null - && transport.getDisplayInfo().toLowerCase().contains("map of alacrity"); + boolean traceMoa = isMapOfAlacritySeasonalRow(transport); - // Session blacklist: once an MoA destination fails at runtime (locked region or - // unrecognised name), don't let the pathfinder keep routing through it. - if (traceMoa && Rs2Walker.blacklistedMoaDestinations.contains( + // MoA: fail once -> block same dest this session (avoid reroute spam). + if (traceMoa && Rs2MapOfAlacrityTransport.isMoaDestinationBlacklisted( WorldPointUtil.packWorldPoint(transport.getDestination()))) { return false; } @@ -731,14 +832,16 @@ private boolean useTransport(Transport transport) { // the pathfinder keeps picking a different Asgarnia/Desert/etc. destination on // each re-path — walker fails, blacklists one, re-path picks the next, infinite // "running around" loop. Display info format: "Map of Alacrity: - ". - if (traceMoa && !Rs2Walker.lockedMoaRegions.isEmpty()) { + if (traceMoa) { String disp = transport.getDisplayInfo(); - int colon = disp.indexOf(':'); - int dash = colon >= 0 ? disp.indexOf(" - ", colon) : -1; - if (colon >= 0 && dash > colon) { - String region = disp.substring(colon + 1, dash).trim().toLowerCase(); - if (Rs2Walker.lockedMoaRegions.contains(region)) { - return false; + if (disp != null) { + int colon = disp.indexOf(':'); + int dash = colon >= 0 ? disp.indexOf(" - ", colon) : -1; + if (colon >= 0 && dash > colon) { + String region = disp.substring(colon + 1, dash).trim(); + if (Rs2MapOfAlacrityTransport.isMoaRegionLocked(region)) { + return false; + } } } } @@ -774,7 +877,7 @@ private boolean useTransport(Transport transport) { if (!varbitChecks(transport)) { log.debug("Transport ( O: {} D: {} ) requires varbits {}", transport.getOrigin(), transport.getDestination(), transport.getVarbits()); if (traceMoa) log.debug("[MoA] rejected '{}' — varbit check failed (varbits={}, LEAGUE_TYPE={})", - transport.getDisplayInfo(), transport.getVarbits(), Microbot.getVarbitValue(10032)); + transport.getDisplayInfo(), transport.getVarbits(), Microbot.getVarbitValue(VarbitID.LEAGUE_TYPE)); return false; } @@ -804,7 +907,7 @@ private boolean useTransport(Transport transport) { } // Check if Teleports are globally disabled - if (TransportType.isTeleport(transport.getType()) && Rs2Walker.disableTeleports) { + 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()); return false; } @@ -840,6 +943,21 @@ private boolean useTransport(Transport transport) { return true; } + /** + * Same gating as the main {@link #refreshTransports} loop, for rows injected after the merge pass + * (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) { + return false; + } + updateActionBasedOnQuestState(transport); + if (!useTransport(transport)) { + return false; + } + return Rs2LeaguesTransport.isTransportAllowed(leaguesCtx, transport); + } + /** * Checks if the player has all the required skill levels for the transport */ @@ -877,25 +995,35 @@ private void updateActionBasedOnQuestState(Transport transport) { } } + /** + * Toggle for {@link #SPIRIT_TREE_DESTINATIONS_ORDERED}[{@code index}]. Must stay aligned with array length. + */ + private boolean spiritTreeDestinationToggle(int index) { + switch (index) { + case 0: + return useSpiritTreeEtceteria; + case 1: + return useSpiritTreeBrimhaven; + case 2: + return useSpiritTreePortSarim; + case 3: + return useSpiritTreeHosidius; + case 4: + return useSpiritTreeFarmingGuild; + default: + throw new AssertionError("spirit tree index " + index); + } + } + private boolean isSpiritTreeDestinationEnabled(Transport transport) { WorldPoint destination = transport.getDestination(); if (destination == null) { return true; } - if (destination.equals(SPIRIT_TREE_ETCETERIA)) { - return useSpiritTreeEtceteria; - } - if (destination.equals(SPIRIT_TREE_BRIMHAVEN)) { - return useSpiritTreeBrimhaven; - } - if (destination.equals(SPIRIT_TREE_PORT_SARIM)) { - return useSpiritTreePortSarim; - } - if (destination.equals(SPIRIT_TREE_HOSIDIUS)) { - return useSpiritTreeHosidius; - } - if (destination.equals(SPIRIT_TREE_FARMING_GUILD)) { - return useSpiritTreeFarmingGuild; + for (int i = 0; i < SPIRIT_TREE_DESTINATIONS_ORDERED.length; i++) { + if (destination.equals(SPIRIT_TREE_DESTINATIONS_ORDERED[i])) { + return spiritTreeDestinationToggle(i); + } } return true; } @@ -1341,6 +1469,215 @@ private String getTransportTypeName(Transport transport) { } } + private int computeTransportRefreshCacheKeyHash(WorldPoint target, Rs2LeaguesTransport.LeaguesContext leaguesCtx) { + assert leaguesCtx != null; + int targetPacked = target == null ? 0 : WorldPointUtil.packWorldPoint(target); + int invFp = fingerprintInventoryEquipmentBank(); + int members = (client != null && client.getWorldType().contains(WorldType.MEMBERS)) ? 1 : 0; + int preferTp = (config != null && config.preferTransportToTarget()) ? 1 : 0; + int maxSimilar = config != null ? config.maxSimilarTransportDistance() : 0; + return Objects.hash( + packTransportRefreshToggleBits(), + useTeleportationItems, + ignoreTeleportAndItems, + useBankItems, + useNpcs, + targetPacked, + invFp, + members, + Rs2Walker.disableTeleports, + Rs2MapOfAlacrityTransport.moaTransportCacheFingerprint(), + Microbot.getVarbitValue(VarbitID.LEAGUE_TYPE), + leaguesCtx.isActive(), + leaguesCtx.getUnlockedRegions().hashCode(), + usePoh, + PohTeleports.isInHouse(), + maxSimilar, + preferTp, + distanceBeforeUsingTeleport); + } + + private long packTransportRefreshToggleBits() { + long bits = 0; + int s = 0; + if (useAgilityShortcuts) bits |= 1L << s; + s++; + if (useGrappleShortcuts) bits |= 1L << s; + s++; + if (useBoats) bits |= 1L << s; + s++; + if (useCanoes) bits |= 1L << s; + s++; + if (useCharterShips) bits |= 1L << s; + s++; + if (useShips) bits |= 1L << s; + s++; + if (useFairyRings) bits |= 1L << s; + s++; + if (useGnomeGliders) bits |= 1L << s; + s++; + if (useMinecarts) bits |= 1L << s; + s++; + if (usePoh) bits |= 1L << s; + s++; + if (useQuetzals) bits |= 1L << s; + s++; + if (useSpiritTrees) bits |= 1L << s; + s++; + if (useTeleportationLevers) bits |= 1L << s; + s++; + if (useTeleportationMinigames) bits |= 1L << s; + s++; + if (useTeleportationPortals) bits |= 1L << s; + s++; + if (useTeleportationSpells) bits |= 1L << s; + s++; + if (useMagicCarpets) bits |= 1L << s; + s++; + if (useHotAirBalloons) bits |= 1L << s; + s++; + if (useMagicMushtrees) bits |= 1L << s; + s++; + if (useSeasonalTransports) bits |= 1L << s; + s++; + if (useWildernessObelisks) bits |= 1L << s; + s++; + if (useSpiritTreeEtceteria) bits |= 1L << s; + s++; + if (useSpiritTreeBrimhaven) bits |= 1L << s; + s++; + if (useSpiritTreePortSarim) bits |= 1L << s; + s++; + if (useSpiritTreeHosidius) bits |= 1L << s; + s++; + if (useSpiritTreeFarmingGuild) bits |= 1L << s; + s++; + if (avoidWilderness) bits |= 1L << s; + return bits; + } + + private int fingerprintInventoryEquipmentBank() { + final int[] h = {1}; + Rs2Inventory.items().forEach(item -> { + h[0] = 31 * h[0] + item.getId(); + h[0] = 31 * h[0] + item.getQuantity(); + }); + Rs2Equipment.all().forEach(item -> { + h[0] = 31 * h[0] + item.getId(); + h[0] = 31 * h[0] + item.getQuantity(); + }); + if (useBankItems) { + Rs2Bank.getAll().forEach(item -> { + h[0] = 31 * h[0] + item.getId(); + h[0] = 31 * h[0] + item.getQuantity(); + }); + } + return h[0]; + } + + private static int computeTransportRefreshVerificationHash(int[] boostedLevels, int[] sortedVarbits, int[] sortedVarplayers, int[] sortedQuestIds) { + return computeTransportRefreshVerificationHash(boostedLevels, sortedVarbits, sortedVarplayers, sortedQuestIds, questId -> { + Quest quest = resolveQuestById(questId); + return quest == null ? QuestState.NOT_STARTED : Rs2Player.getQuestState(quest); + }); + } + + static int computeTransportRefreshVerificationHash(int[] boostedLevels, int[] sortedVarbits, int[] sortedVarplayers, + int[] sortedQuestIds, IntFunction questStateProvider) { + assert boostedLevels != null; + int h = Arrays.hashCode(boostedLevels); + for (int id : sortedVarbits) { + h = 31 * h + id; + h = 31 * h + Microbot.getVarbitValue(id); + } + for (int id : sortedVarplayers) { + h = 31 * h + id; + h = 31 * h + Microbot.getVarbitPlayerValue(id); + } + for (int questId : sortedQuestIds) { + h = 31 * h + questId; + h = 31 * h + questStateHashCode(questStateProvider.apply(questId)); + } + int clientOfKourendId = Quest.CLIENT_OF_KOUREND.getId(); + if (Arrays.binarySearch(sortedQuestIds, clientOfKourendId) < 0) { + h = 31 * h + clientOfKourendId; + h = 31 * h + questStateHashCode(questStateProvider.apply(clientOfKourendId)); + } + return h; + } + + private static int questStateHashCode(QuestState state) { + if (state == null) { + return -1; + } + switch (state) { + case NOT_STARTED: + return 0; + case IN_PROGRESS: + return 1; + case FINISHED: + return 2; + default: + return state.ordinal() + 3; + } + } + + private static Quest resolveQuestById(int questId) { + for (Quest quest : Quest.values()) { + if (quest.getId() == questId) { + return quest; + } + } + return null; + } + + private static final class TransportRefreshSnapshot { + private final int cacheKeyHash; + private final int verificationHash; + private final int[] sortedVarbits; + private final int[] sortedVarplayers; + private final int[] sortedQuestIds; + private final Map> transportsData; + private final Set usableData; + + private TransportRefreshSnapshot(int cacheKeyHash, int verificationHash, int[] sortedVarbits, int[] sortedVarplayers, + int[] sortedQuestIds, + Map> transportsData, Set usableData) { + this.cacheKeyHash = cacheKeyHash; + this.verificationHash = verificationHash; + this.sortedVarbits = sortedVarbits; + this.sortedVarplayers = sortedVarplayers; + this.sortedQuestIds = sortedQuestIds; + this.transportsData = transportsData; + this.usableData = usableData; + } + + static TransportRefreshSnapshot capture(int cacheKeyHash, int verificationHash, int[] sortedVarbits, int[] sortedVarplayers, + int[] sortedQuestIds, + Map> srcTransports, Set srcUsable) { + assert srcTransports != null && srcUsable != null; + Map> copy = new HashMap<>(srcTransports.size()); + for (Map.Entry> e : srcTransports.entrySet()) { + copy.put(e.getKey(), new HashSet<>(e.getValue())); + } + Set usableCopy = new HashSet<>(srcUsable); + return new TransportRefreshSnapshot(cacheKeyHash, verificationHash, sortedVarbits, sortedVarplayers, sortedQuestIds, copy, usableCopy); + } + + void restoreInto(PathfinderConfig c) { + c.transports.clear(); + c.transportsPacked.clear(); + c.usableTeleports.clear(); + for (Map.Entry> e : transportsData.entrySet()) { + WorldPoint wp = e.getKey(); + Set set = new HashSet<>(e.getValue()); + c.transports.put(wp, set); + c.transportsPacked.put(WorldPointUtil.packWorldPoint(wp), set); + } + c.usableTeleports.addAll(new HashSet<>(usableData)); + } + } + @Override public String toString() { return String.format("PathfinderConfig(useAgilityShortcuts=%b, useGrappleShortcuts=%b, useBoats=%b, useCanoes=%b, " + diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/policy/TransportRequirementPolicy.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/policy/TransportRequirementPolicy.java new file mode 100644 index 00000000000..7f16287629c --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/policy/TransportRequirementPolicy.java @@ -0,0 +1,36 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder.policy; + +import net.runelite.api.QuestState; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; + +import java.util.List; + +public final class TransportRequirementPolicy { + private TransportRequirementPolicy() { + } + + public static boolean completedQuests(Transport transport, List questStateOrder) { + return transport.getQuests().entrySet().stream() + .allMatch(entry -> { + QuestState playerState = Rs2Player.getQuestState(entry.getKey()); + QuestState requiredState = entry.getValue(); + int playerIndex = questStateOrder.indexOf(playerState); + int requiredIndex = questStateOrder.indexOf(requiredState); + return playerIndex >= requiredIndex; + }); + } + + public static boolean varbitChecks(Transport transport) { + return transport.getVarbits().isEmpty() + || transport.getVarbits().stream() + .allMatch(varbitCheck -> varbitCheck.matches(Microbot.getVarbitValue(varbitCheck.getVarbitId()))); + } + + public static boolean varplayerChecks(Transport transport) { + return transport.getVarplayers().isEmpty() + || transport.getVarplayers().stream() + .allMatch(varplayerCheck -> varplayerCheck.matches(Microbot.getVarbitPlayerValue(varplayerCheck.getVarplayerId()))); + } +} 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 6ef95f0acc7..9c355898ba7 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 @@ -351,8 +351,21 @@ 1367 3087 0 29893 16752=1 Y T 19 4 Pendant of ates: Kastori 1364 3275 0 29893 16757=1 Y T 19 4 Pendant of ates: Nemus Retreat -#Quetzal Whistle -1585 3053 0 29271;29273;29275 Children of the Sun Y T 19 4 Quetzal whistle +#Quetzal Whistle — one row per map destination; quest Children of the Sun (item unlock). Varbit columns match quetzals.tsv for locked map pins. +1389 2901 0 29271;29273;29275 Children of the Sun Y T 19 4 Quetzal whistle: Aldarin +1697 3140 0 29271;29273;29275 Children of the Sun Y T 19 4 Quetzal whistle: Civitas illa Fortis +1585 3053 0 29271;29273;29275 Children of the Sun Y T 19 4 Quetzal whistle: Hunter Guild +1510 3221 0 29271;29273;29275 Children of the Sun Y T 19 4 Quetzal whistle: Quetzacalli Gorge +1548 2995 0 29271;29273;29275 Children of the Sun Y T 19 4 Quetzal whistle: Sunset Coast +1437 3171 0 29271;29273;29275 Children of the Sun Y T 19 4 Quetzal whistle: The Teomat +1779 3111 0 29271;29273;29275 Children of the Sun 9958=1 Y T 19 4 Quetzal whistle: Fortis Colosseum +1700 3037 0 29271;29273;29275 Children of the Sun 9957=1 Y T 19 4 Quetzal whistle: Outer Fortis +1670 2933 0 29271;29273;29275 Children of the Sun 9956=1 Y T 19 4 Quetzal whistle: Colossal Wyrm Remains +1446 3108 0 29271;29273;29275 Children of the Sun 9955=1 Y T 19 4 Quetzal whistle: Cam Torum Entrance +1613 3300 0 29271;29273;29275 Children of the Sun 11379=1 Y T 19 4 Quetzal whistle: Salvager Overlook +1226 3091 0 29271;29273;29275 Children of the Sun Y T 19 4 Quetzal whistle: Tal Teklan +1344 3022 0 29271;29273;29275 Children of the Sun 17757=1 Y T 19 4 Quetzal whistle: Kastori +1411 3361 0 29271;29273;29275 Children of the Sun Y T 19 4 Quetzal whistle: Auburnvale #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/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportTypeTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportTypeTest.java new file mode 100644 index 00000000000..d8a0f191116 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/TransportTypeTest.java @@ -0,0 +1,35 @@ +package net.runelite.client.plugins.microbot.shortestpath; + +import net.runelite.api.coords.WorldPoint; +import org.junit.Assert; +import org.junit.Test; + +public class TransportTypeTest +{ + @Test + public void seasonalTeleportOriginlessIsTeleportForPathfinding() + { + Assert.assertTrue(TransportType.isTeleport(TransportType.SEASONAL_TRANSPORT, null)); + } + + @Test + public void seasonalTeleportAnchoredIsNotTeleportForPathfinding() + { + WorldPoint origin = new WorldPoint(3200, 3200, 0); + Assert.assertFalse(TransportType.isTeleport(TransportType.SEASONAL_TRANSPORT, origin)); + } + + @Test + public void seasonalOneArgRemainsTeleportWhenOriginUnknown() + { + Assert.assertTrue(TransportType.isTeleport(TransportType.SEASONAL_TRANSPORT)); + } + + @Test + public void spellTeleportIgnoresOrigin() + { + WorldPoint origin = new WorldPoint(1, 1, 0); + Assert.assertTrue(TransportType.isTeleport(TransportType.TELEPORTATION_SPELL, origin)); + Assert.assertTrue(TransportType.isTeleport(TransportType.TELEPORTATION_SPELL, null)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigTransportRefreshHashTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigTransportRefreshHashTest.java new file mode 100644 index 00000000000..b5f9c829e39 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigTransportRefreshHashTest.java @@ -0,0 +1,53 @@ +package net.runelite.client.plugins.microbot.shortestpath.pathfinder; + +import net.runelite.api.Quest; +import net.runelite.api.QuestState; +import org.junit.Test; + +import static org.junit.Assert.assertNotEquals; + +public class PathfinderConfigTransportRefreshHashTest { + + @Test + public void verificationHashDiffersForNotStartedVsInProgressQuestState() { + int[] boostedLevels = new int[]{1, 50, 99}; + int[] sortedVarbits = new int[0]; + int[] sortedVarplayers = new int[0]; + int trackedQuestId = 987654; + int[] sortedQuestIds = new int[]{trackedQuestId}; + int clientOfKourendId = Quest.CLIENT_OF_KOUREND.getId(); + + int hashNotStarted = PathfinderConfig.computeTransportRefreshVerificationHash( + boostedLevels, + sortedVarbits, + sortedVarplayers, + sortedQuestIds, + questId -> { + if (questId == trackedQuestId) { + return QuestState.NOT_STARTED; + } + if (questId == clientOfKourendId) { + return QuestState.FINISHED; + } + return QuestState.NOT_STARTED; + }); + + int hashInProgress = PathfinderConfig.computeTransportRefreshVerificationHash( + boostedLevels, + sortedVarbits, + sortedVarplayers, + sortedQuestIds, + questId -> { + if (questId == trackedQuestId) { + return QuestState.IN_PROGRESS; + } + if (questId == clientOfKourendId) { + return QuestState.FINISHED; + } + return QuestState.NOT_STARTED; + }); + + assertNotEquals("Quest state transition should invalidate cached transport refresh snapshot", + hashNotStarted, hashInProgress); + } +} From 046d82ae87196a31a8ed7290ea4b479b227ebcc5 Mon Sep 17 00:00:00 2001 From: Haliax <18099301+TheHaliax@users.noreply.github.com> Date: Mon, 11 May 2026 08:43:10 -0500 Subject: [PATCH 2/9] refactor(walker): extract runtime await and stall modules Split walker internals into focused runtime modules for await, door, stall, lifecycle, and banking flow. --- .../walker/awaits/Rs2WalkerRuntimeAwaits.java | 31 ++ .../banking/Rs2WalkerBankingPlanner.java | 509 ++++++++++++++++++ .../walker/door/Rs2DoorAheadResolver.java | 45 ++ .../util/walker/door/Rs2DoorHandler.java | 74 +++ .../util/walker/door/Rs2WalkerAwaits.java | 132 +++++ .../util/walker/door/model/AwaitTicket.java | 21 + .../util/walker/door/model/DoorCandidate.java | 46 ++ .../util/walker/door/model/DoorEdge.java | 26 + .../walker/door/model/DoorFailureReason.java | 10 + .../walker/door/model/DoorResolution.java | 9 + .../lifecycle/Rs2WalkerLifecycleRuntime.java | 136 +++++ .../util/walker/shared/Rs2WalkerProgress.java | 38 ++ .../walker/stall/Rs2WalkerStallPolicy.java | 54 ++ .../transport/Rs2WalkerTransportAwaits.java | 24 + 14 files changed, 1155 insertions(+) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/awaits/Rs2WalkerRuntimeAwaits.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/banking/Rs2WalkerBankingPlanner.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorAheadResolver.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/AwaitTicket.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/DoorCandidate.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/DoorEdge.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/DoorFailureReason.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/DoorResolution.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/shared/Rs2WalkerProgress.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicy.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/Rs2WalkerTransportAwaits.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/awaits/Rs2WalkerRuntimeAwaits.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/awaits/Rs2WalkerRuntimeAwaits.java new file mode 100644 index 00000000000..635253f34b4 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/awaits/Rs2WalkerRuntimeAwaits.java @@ -0,0 +1,31 @@ +package net.runelite.client.plugins.microbot.util.walker.awaits; + +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; + +import java.util.function.BooleanSupplier; + +import static net.runelite.client.plugins.microbot.util.Global.sleepUntilTrue; + +public final class Rs2WalkerRuntimeAwaits { + private Rs2WalkerRuntimeAwaits() { + } + + public static boolean awaitPathfinderDone(Pathfinder pathfinder, int timeoutMs) { + if (pathfinder == null) { + return false; + } + return sleepUntilTrue(pathfinder::isDone, 100, timeoutMs); + } + + public static boolean awaitCondition(BooleanSupplier condition, int pollMs, int timeoutMs) { + if (condition == null) { + return false; + } + return sleepUntilTrue(condition, pollMs, timeoutMs); + } + + public static boolean awaitMovementSettled(int pollMs, int timeoutMs) { + return sleepUntilTrue(() -> !Rs2Player.isMoving() && !Rs2Player.isAnimating(), pollMs, timeoutMs); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/banking/Rs2WalkerBankingPlanner.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/banking/Rs2WalkerBankingPlanner.java new file mode 100644 index 00000000000..82465beaf98 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/banking/Rs2WalkerBankingPlanner.java @@ -0,0 +1,509 @@ +package net.runelite.client.plugins.microbot.util.walker.banking; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.ItemID; +import net.runelite.api.gameval.VarbitID; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; +import net.runelite.client.plugins.microbot.util.magic.Rs2Magic; +import net.runelite.client.plugins.microbot.util.magic.Rs2Spells; +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.util.equipment.Rs2Equipment; +import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; +import net.runelite.client.plugins.microbot.util.bank.enums.BankLocation; +import net.runelite.client.plugins.microbot.util.inventory.Rs2Inventory; +import net.runelite.client.plugins.microbot.util.magic.Runes; +import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; +import net.runelite.client.plugins.microbot.util.walker.TransportRouteAnalysis; +import net.runelite.client.plugins.microbot.util.walker.WebWalkLog; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +public final class Rs2WalkerBankingPlanner { + + private Rs2WalkerBankingPlanner() { + } + + public static List getTransportsForDestination(WorldPoint destination, boolean useBankItems, TransportType prefTransportType) { + if (destination == null) { + return new ArrayList<>(); + } + + boolean originalUseBankItems = ShortestPathPlugin.getPathfinderConfig().isUseBankItems(); + try { + ShortestPathPlugin.getPathfinderConfig().setUseBankItems(useBankItems); + ShortestPathPlugin.getPathfinderConfig().refresh(); + Pathfinder pf = new Pathfinder(ShortestPathPlugin.getPathfinderConfig(), Rs2Player.getWorldLocation(), destination); + pf.run(); + + List path = pf.getPath(); + if (path.isEmpty()) { + log.debug("Unable to find path to destination: " + destination); + return new ArrayList<>(); + } + + List transports = Rs2Walker.getTransportsForPath(path, 0, prefTransportType, true); + transports.forEach(t -> log.debug("Transport found: " + t)); + return transports; + } finally { + ShortestPathPlugin.getPathfinderConfig().setUseBankItems(originalUseBankItems); + ShortestPathPlugin.getPathfinderConfig().refresh(); + } + } + + public static boolean hasRequiredTransportItems(Transport transport) { + if (transport == null) { + return false; + } + + if (transport.getType() == TransportType.FAIRY_RING) { + return Rs2Inventory.hasItem(ItemID.DRAMEN_STAFF) + || Rs2Equipment.isWearing(ItemID.DRAMEN_STAFF) + || Rs2Inventory.hasItem(ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF) + || Rs2Equipment.isWearing(ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF) + || Microbot.getVarbitValue(VarbitID.LUMBRIDGE_DIARY_ELITE_COMPLETE) == 1; + } else if (transport.getType() == TransportType.TELEPORTATION_ITEM + || transport.getType() == TransportType.TELEPORTATION_SPELL + || transport.getType() == TransportType.CANOE + || transport.getType() == TransportType.BOAT + || transport.getType() == TransportType.CHARTER_SHIP + || transport.getType() == TransportType.SHIP + || transport.getType() == TransportType.MINECART + || transport.getType() == TransportType.MAGIC_CARPET) { + if (transport.getType() == TransportType.TELEPORTATION_SPELL && transport.getDisplayInfo() != null) { + String spellName = transport.getDisplayInfo().contains(":") + ? transport.getDisplayInfo().split(":")[0].trim() + : transport.getDisplayInfo().trim(); + boolean hasMultipleDestination = transport.getDisplayInfo().contains(":"); + String displayInfo = hasMultipleDestination + ? transport.getDisplayInfo().split(":")[0].trim().toLowerCase() + : transport.getDisplayInfo(); + log.debug("Looking for spell rune requirements for: '{}' - display info {}", spellName, displayInfo); + Rs2Spells rs2Spell = Rs2Magic.getRs2Spell(displayInfo); + return Rs2Magic.hasRequiredRunes(rs2Spell); + } + if (isCurrencyBasedTransport(transport.getType()) + && (transport.getItemIdRequirements() == null || transport.getItemIdRequirements().isEmpty()) + && transport.getCurrencyName() != null + && !transport.getCurrencyName().isEmpty() + && transport.getCurrencyAmount() > 0) { + int currencyItemId = getCurrencyItemId(transport.getCurrencyName()); + return Rs2Inventory.count(currencyItemId) >= transport.getCurrencyAmount(); + } + if (transport.getItemIdRequirements() == null || transport.getItemIdRequirements().isEmpty()) { + return true; + } + + return transport.getItemIdRequirements() + .stream() + .flatMap(Collection::stream) + .anyMatch(itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId)); + } + + return true; + } + + public static List getMissingTransports(List transports) { + if (transports == null) { + return new ArrayList<>(); + } + + return transports.stream() + .filter(t -> !hasRequiredTransportItems(t)) + .collect(Collectors.toList()); + } + + public static Map getMissingTransportItemIdsWithQuantities(List transports) { + if (transports == null) { + return new HashMap<>(); + } + + Map itemQuantityMap = new HashMap<>(); + + transports.forEach(transport -> { + if (transport.getType() == TransportType.TELEPORTATION_SPELL) { + Map spellRuneRequirements = getSpellRuneRequirements(transport); + if (!spellRuneRequirements.isEmpty()) { + spellRuneRequirements.forEach((runeItemId, requiredQuantity) -> { + try { + int bankQuantity = Rs2Bank.count(runeItemId); + if (bankQuantity >= requiredQuantity) { + int currentQuantity = itemQuantityMap.getOrDefault(runeItemId, 0); + itemQuantityMap.put(runeItemId, currentQuantity + requiredQuantity); + log.debug("Added teleportation spell rune requirement: {} (ID: {}) x{} (bank has: {})", + runeItemId, runeItemId, requiredQuantity, bankQuantity); + } + } catch (Exception e) { + log.debug("Could not check bank for rune " + runeItemId + ": " + e.getMessage()); + } + }); + } + return; + } + + if (transport.getItemIdRequirements() != null) { + for (Set alternativeItems : transport.getItemIdRequirements()) { + boolean hasAnyAlternative = alternativeItems.stream().anyMatch(itemId -> { + try { + if (isCurrencyBasedTransport(transport.getType()) && transport.getCurrencyAmount() > 0) { + return Rs2Bank.count(itemId) >= transport.getCurrencyAmount(); + } + return Rs2Bank.hasItem(itemId); + } catch (Exception e) { + log.debug("Could not check bank for item " + itemId + ": " + e.getMessage()); + return false; + } + }); + + if (hasAnyAlternative) { + alternativeItems.stream() + .filter(itemId -> { + try { + if (isCurrencyBasedTransport(transport.getType()) && transport.getCurrencyAmount() > 0) { + return Rs2Bank.count(itemId) >= transport.getCurrencyAmount(); + } + return Rs2Bank.hasItem(itemId); + } catch (Exception e) { + log.debug("Could not check bank for item " + itemId + ": " + e.getMessage()); + return false; + } + }) + .findFirst() + .ifPresent(itemId -> { + int requiredQuantity; + if (isCurrencyBasedTransport(transport.getType()) && transport.getCurrencyAmount() > 0) { + requiredQuantity = transport.getCurrencyAmount(); + log.debug("Currency-based transport {} requires {} x{}", + transport.getType(), transport.getCurrencyName(), requiredQuantity); + } else { + requiredQuantity = 1; + } + + int currentQuantity = itemQuantityMap.getOrDefault(itemId, 0); + itemQuantityMap.put(itemId, currentQuantity + requiredQuantity); + }); + break; + } + } + } + }); + + return itemQuantityMap; + } + + public static List getMissingTransportItemIds(List transports) { + return new ArrayList<>(getMissingTransportItemIdsWithQuantities(transports).keySet()); + } + + public static TransportRouteAnalysis compareRoutes(WorldPoint startPoint, WorldPoint target) { + long totalStartTime = System.nanoTime(); + StringBuilder performanceLog = new StringBuilder(); + performanceLog.append("\n\t=== compareRoutes Performance Analysis ===\n"); + if (target == null) { + return new TransportRouteAnalysis(new ArrayList<>(), null, null, new ArrayList<>(), new ArrayList<>(), "Target location is null"); + } + + if (startPoint == null) { + startPoint = Rs2Player.getWorldLocation(); + } + + if (startPoint == null) { + return new TransportRouteAnalysis(new ArrayList<>(), null, null, new ArrayList<>(), new ArrayList<>(), "Cannot determine starting location"); + } + + try { + performanceLog.append("\tStart Point: ").append(startPoint).append(", Target: ").append(target).append("\n"); + long directPathStartTime = System.nanoTime(); + List directPath = Rs2Walker.getWalkPath(startPoint, target); + long directPathEndTime = System.nanoTime(); + double directPathTimeMs = (directPathEndTime - directPathStartTime) / 1_000_000.0; + + int directDistance = Rs2Walker.getTotalTilesFromPath(directPath, target); + performanceLog.append("\t-Direct path calculation: ").append(String.format("%.2f ms", directPathTimeMs)) + .append(" (").append(directPath.size()).append(" waypoints, ").append(directDistance).append(" tiles)\n"); + + BankLocation nearestBank = null; + List pathToBank = new ArrayList<>(); + List pathFromBankToTarget = new ArrayList<>(); + int bankingRouteDistance = -1; + + try { + boolean originalUseBankItems = ShortestPathPlugin.getPathfinderConfig().isUseBankItems(); + try { + ShortestPathPlugin.getPathfinderConfig().setUseBankItems(true); + ShortestPathPlugin.getPathfinderConfig().refresh(target); + + performanceLog.append("\t-Bank items available: ").append(Rs2Bank.bankItems().size()).append("\n"); + + long bankSearchStartTime = System.nanoTime(); + nearestBank = Rs2Bank.getNearestBank(startPoint); + long bankSearchEndTime = System.nanoTime(); + double bankSearchTimeMs = (bankSearchEndTime - bankSearchStartTime) / 1_000_000.0; + + if (nearestBank != null) { + WorldPoint bankLocation = nearestBank.getWorldPoint(); + performanceLog.append("\t-Nearest bank search: ").append(String.format("%.2f ms", bankSearchTimeMs)); + performanceLog.append("\t -> Found: ").append(nearestBank).append(" at ").append(bankLocation).append("\n"); + + long pathToBankStartTime = System.nanoTime(); + pathToBank = Rs2Walker.getWalkPath(startPoint, bankLocation); + long pathToBankEndTime = System.nanoTime(); + double pathToBankTimeMs = (pathToBankEndTime - pathToBankStartTime) / 1_000_000.0; + int distanceToBank = Rs2Walker.getTotalTilesFromPath(pathToBank, bankLocation); + + long pathFromBankStartTime = System.nanoTime(); + pathFromBankToTarget = Rs2Walker.getWalkPath(bankLocation, target); + long pathFromBankEndTime = System.nanoTime(); + double pathFromBankTimeMs = (pathFromBankEndTime - pathFromBankStartTime) / 1_000_000.0; + List bankLegTransports = Rs2Walker.getTransportsForPath( + pathFromBankToTarget, 0, TransportType.TELEPORTATION_SPELL, true); + long spellCount = bankLegTransports.stream() + .filter(t -> t.getType() == TransportType.TELEPORTATION_SPELL) + .count(); + long itemCount = bankLegTransports.stream() + .filter(t -> t.getType() == TransportType.TELEPORTATION_ITEM) + .count(); + int distanceFromBankRaw = Rs2Walker.getTotalTilesFromPath(pathFromBankToTarget, target); + int distanceFromBank = effectiveDistanceFromBank(pathFromBankToTarget, distanceFromBankRaw); + + performanceLog.append("\t-Path to bank calculation: ").append(String.format("%.2f ms", pathToBankTimeMs)) + .append(" (").append(pathToBank.size()).append(" waypoints, ").append(distanceToBank).append(" tiles)\n"); + performanceLog.append("\t-Path from bank to target with banked items: ").append(String.format("%.2f ms", pathFromBankTimeMs)) + .append(" (").append(pathFromBankToTarget.size()).append(" waypoints, ").append(distanceFromBank).append(" tiles)\n"); + performanceLog.append("\t-Bank leg transports: total=").append(bankLegTransports.size()) + .append(" spells=").append(spellCount) + .append(" items=").append(itemCount) + .append("\n"); + Transport firstSpellTransport = bankLegTransports.stream() + .filter(t -> t.getType() == TransportType.TELEPORTATION_SPELL) + .findFirst() + .orElse(null); + if (firstSpellTransport != null) { + performanceLog.append("\t-First bank-leg spell transport: ") + .append(firstSpellTransport.getDisplayInfo()) + .append(" -> ") + .append(firstSpellTransport.getDestination()) + .append("\n"); + } + WebWalkLog.spInfo("compare_bank_leg | total={} spells={} items={} firstSpell={}", + bankLegTransports.size(), + spellCount, + itemCount, + firstSpellTransport == null + ? "none" + : firstSpellTransport.getDisplayInfo() + " -> " + firstSpellTransport.getDestination()); + if (distanceFromBankRaw != distanceFromBank) { + performanceLog.append("\t-Adjusted bank leg for immediate teleport: raw=") + .append(distanceFromBankRaw) + .append(" adjusted=") + .append(distanceFromBank) + .append(" tiles\n"); + } + + if (distanceToBank != -1 && distanceFromBank != -1) { + bankingRouteDistance = distanceToBank + distanceFromBank; + } + performanceLog.append("\t-Total banking route distance: ").append(bankingRouteDistance).append(" tiles\n"); + } else { + performanceLog.append("\t-Nearest bank search: ").append(String.format("%.2f ms", bankSearchTimeMs)) + .append("\t -> No accessible bank found\n"); + } + } finally { + ShortestPathPlugin.getPathfinderConfig().setUseBankItems(originalUseBankItems); + ShortestPathPlugin.getPathfinderConfig().refresh(); + } + } catch (Exception e) { + performanceLog.append("Banking route calculation failed: ").append(e.getMessage()).append("\n"); + log.debug("Could not calculate banking route: " + e.getMessage()); + } + + long totalEndTime = System.nanoTime(); + double totalTimeMs = (totalEndTime - totalStartTime) / 1_000_000.0; + performanceLog.append("\t=== Total compareRoutes time: ").append(String.format("%.2f ms", totalTimeMs)).append(" ===\n"); + + if (bankingRouteDistance == -1) { + performanceLog.append("\tResult: Direct route only (banking route unavailable)\n"); + WebWalkLog.compareDetail(performanceLog.toString()); + WebWalkLog.compareSummary(totalTimeMs, directDistance, -1, "direct_only_bank_unavailable"); + return new TransportRouteAnalysis(directPath, null, null, new ArrayList<>(), new ArrayList<>(), + "Direct route only (banking route unavailable)"); + } + + final boolean tie = directDistance == bankingRouteDistance; + final boolean directStrictlyFaster = directDistance < bankingRouteDistance; + final boolean preferTransportToTarget = ShortestPathPlugin.override("preferTransportToTarget", false); + final String recommendation; + final String verdictOneLine; + if (tie) { + if (preferTransportToTarget) { + recommendation = String.format("\tSame tile distance (%d); prefer banking route (prefer transport to target enabled)", directDistance); + verdictOneLine = String.format("tie %dt (prefer bank: transport-to-target)", directDistance); + } else { + recommendation = String.format("\tSame tile distance (%d); prefer direct (no bank hop)", directDistance); + verdictOneLine = String.format("tie %dt (prefer direct)", directDistance); + } + } else if (directStrictlyFaster) { + recommendation = String.format("\tDirect route is faster (%d vs %d tiles)", directDistance, bankingRouteDistance); + verdictOneLine = String.format("direct faster %dt vs %dt", directDistance, bankingRouteDistance); + } else { + recommendation = String.format("\tBanking route is faster (%d vs %d tiles)", bankingRouteDistance, directDistance); + verdictOneLine = String.format("bank faster %dt vs %dt", bankingRouteDistance, directDistance); + } + + performanceLog.append("\tResult:\n\t\t ").append(recommendation).append("\n"); + WebWalkLog.compareDetail(performanceLog.toString()); + WebWalkLog.compareSummary(totalTimeMs, directDistance, bankingRouteDistance, verdictOneLine); + + return new TransportRouteAnalysis(directPath, + nearestBank, nearestBank != null ? nearestBank.getWorldPoint() : null, pathToBank, pathFromBankToTarget, recommendation, + directDistance, bankingRouteDistance); + } catch (Exception e) { + long totalEndTime = System.nanoTime(); + double totalTimeMs = (totalEndTime - totalStartTime) / 1_000_000.0; + performanceLog.append("ERROR after ").append(String.format("%.2f ms", totalTimeMs)).append(": ").append(e.getMessage()).append("\n"); + WebWalkLog.compareDetail(performanceLog.toString()); + WebWalkLog.compareError(totalTimeMs, target, e.getMessage()); + return new TransportRouteAnalysis(new ArrayList<>(), null, null, new ArrayList<>(), new ArrayList<>(), "Error calculating routes: " + e.getMessage()); + } + } + + private static Map getSpellRuneRequirements(Transport transport) { + Map runeRequirements = new HashMap<>(); + if (transport.getType() != TransportType.TELEPORTATION_SPELL || transport.getDisplayInfo() == null) { + return runeRequirements; + } + try { + String spellName = transport.getDisplayInfo().contains(":") + ? transport.getDisplayInfo().split(":")[0].trim() + : transport.getDisplayInfo().trim(); + boolean hasMultipleDestination = transport.getDisplayInfo().contains(":"); + String displayInfo = hasMultipleDestination + ? transport.getDisplayInfo().split(":")[0].trim().toLowerCase() + : transport.getDisplayInfo(); + log.debug("Looking for spell rune requirements for: '{}' - display info {}", spellName, displayInfo); + Rs2Spells rs2Spell = Rs2Magic.getRs2Spell(displayInfo); + if (rs2Spell == null) { + return runeRequirements; + } + Map requiredRunes = Rs2Magic.getRequiredRunes(rs2Spell, 1, true); + List elementalRunes = rs2Spell.getElementalRunes(); + log.debug("Spell '{}' requires {} runes, including {} elemental runes", + spellName, requiredRunes.size(), elementalRunes.size()); + requiredRunes.forEach((rune, quantity) -> { + int runeItemId = rune.getItemId(); + runeRequirements.put(runeItemId, quantity); + log.debug("Spell '{}' requires {} x {} (ID: {})", + spellName, quantity, rune.name(), runeItemId); + }); + } catch (Exception e) { + log.warn("Error getting spell rune requirements for transport '{}': {}", + transport.getDisplayInfo(), e.getMessage()); + } + + return runeRequirements; + } + + private static boolean isCurrencyBasedTransport(TransportType transportType) { + return transportType == TransportType.BOAT + || transportType == TransportType.CHARTER_SHIP + || transportType == TransportType.SHIP + || transportType == TransportType.MINECART + || transportType == TransportType.MAGIC_CARPET; + } + + private static int getCurrencyItemId(String currencyName) { + if (currencyName == null || currencyName.trim().isEmpty()) { + return -1; + } + + String currency = currencyName.trim().toLowerCase(); + switch (currency) { + case "coins": + return ItemID.COINS; + case "ecto-token": + return ItemID.ECTOTOKEN; + default: + log.warn("Unknown currency type: {}", currencyName); + return -1; + } + } + + /** + * Score bank->target distance in a way that reflects "bank then immediate teleport" behavior. + * For originless TELEPORTATION_ITEM / TELEPORTATION_SPELL edges, trim pre-teleport walking + * from the bank leg metric and keep the post-teleport tail. + */ + private static int effectiveDistanceFromBank(List pathFromBankToTarget, int rawDistance) { + if (pathFromBankToTarget == null || pathFromBankToTarget.isEmpty() || rawDistance == Integer.MAX_VALUE) { + return rawDistance; + } + + List transports = Rs2Walker.getTransportsForPath(pathFromBankToTarget, 0, TransportType.TELEPORTATION_SPELL, true); + if (transports.isEmpty()) { + return rawDistance; + } + + // Use first transport that the bank->target path actually consumes and model: + // walk_to_transport + transport_hop + post_transport_tail. + Transport firstTransport = transports.get(0); + int modeledDistance = transportModeledDistance(pathFromBankToTarget, firstTransport, rawDistance); + if (modeledDistance == Integer.MAX_VALUE) { + return rawDistance; + } + return Math.min(rawDistance, modeledDistance); + } + + private static boolean isImmediateBankTeleport(Transport transport) { + if (transport == null || transport.getOrigin() != null) { + return false; + } + return transport.getType() == TransportType.TELEPORTATION_ITEM + || transport.getType() == TransportType.TELEPORTATION_SPELL; + } + + private static int transportModeledDistance(List pathFromBankToTarget, Transport transport, int fallbackRawDistance) { + if (transport == null || pathFromBankToTarget == null || pathFromBankToTarget.isEmpty()) { + return fallbackRawDistance; + } + + WorldPoint destination = transport.getDestination(); + if (destination == null) { + return fallbackRawDistance; + } + int destinationIndex = pathFromBankToTarget.indexOf(destination); + if (destinationIndex < 0) { + return fallbackRawDistance; + } + + int originIndex; + if (isImmediateBankTeleport(transport)) { + originIndex = 0; + } else { + WorldPoint origin = transport.getOrigin(); + originIndex = origin == null ? 0 : pathFromBankToTarget.indexOf(origin); + if (originIndex < 0) { + originIndex = 0; + } + } + + if (destinationIndex < originIndex) { + return fallbackRawDistance; + } + + int walkToTransport = Math.max(0, originIndex); + int transportHop = 1; + int postTransportTail = Math.max(0, pathFromBankToTarget.size() - destinationIndex); + return walkToTransport + transportHop + postTransportTail; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorAheadResolver.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorAheadResolver.java new file mode 100644 index 00000000000..54828c47f5b --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorAheadResolver.java @@ -0,0 +1,45 @@ +package net.runelite.client.plugins.microbot.util.walker.door; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; + +import java.util.ArrayList; +import java.util.List; + +public final class Rs2DoorAheadResolver { + private Rs2DoorAheadResolver() { + } + + public static List buildSegmentProbes(WorldPoint fromWp, WorldPoint toWp, WorldPoint doorWp) { + List probes = new ArrayList<>(); + probes.add(doorWp); + if (fromWp == null || toWp == null || doorWp == null) { + return probes; + } + + boolean diagonal = Math.abs(fromWp.getX() - toWp.getX()) > 0 + && Math.abs(fromWp.getY() - toWp.getY()) > 0; + if (diagonal) { + probes.add(new WorldPoint(toWp.getX(), fromWp.getY(), doorWp.getPlane())); + probes.add(new WorldPoint(fromWp.getX(), toWp.getY(), doorWp.getPlane())); + } + return probes; + } + + public static boolean isPathEdgeBlocked(WorldPoint from, WorldPoint to) { + if (from == null || to == null) { + return false; + } + return !Rs2Tile.isTileReachable(to) || !hasLineOfSightBetween(from, to); + } + + private static boolean hasLineOfSightBetween(WorldPoint a, WorldPoint b) { + if (a == null || b == null) { + return false; + } + return a.toWorldArea().hasLineOfSightTo( + Microbot.getClient().getTopLevelWorldView(), + b.toWorldArea()); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java new file mode 100644 index 00000000000..4357448c64d --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorHandler.java @@ -0,0 +1,74 @@ +package net.runelite.client.plugins.microbot.util.walker.door; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.walker.door.model.DoorEdge; + +import java.util.Map; + +public final class Rs2DoorHandler { + private Rs2DoorHandler() { + } + + public static String doorAttemptKey(WorldPoint doorTile, WorldPoint fromWp, WorldPoint toWp) { + if (fromWp != null && toWp != null) { + return new DoorEdge(fromWp, toWp).normalizedKey(); + } + return compactWorldPoint(doorTile) + "|" + compactWorldPoint(fromWp) + "->" + compactWorldPoint(toWp); + } + + public static boolean shouldThrottleDoorAttempt(Map recentDoorAttemptByEdge, + long cooldownMs, + WorldPoint doorTile, + WorldPoint fromWp, + WorldPoint toWp) { + String key = doorAttemptKey(doorTile, fromWp, toWp); + long now = System.currentTimeMillis(); + recentDoorAttemptByEdge.entrySet().removeIf(entry -> now - entry.getValue() > cooldownMs); + Long last = recentDoorAttemptByEdge.get(key); + return last != null && now - last < cooldownMs; + } + + public static void markDoorAttempt(Map recentDoorAttemptByEdge, + WorldPoint doorTile, + WorldPoint fromWp, + WorldPoint toWp) { + recentDoorAttemptByEdge.put(doorAttemptKey(doorTile, fromWp, toWp), System.currentTimeMillis()); + } + + public static void markStationaryDoorOpened(Map recentlyOpenedStationaryDoors, WorldPoint doorTile) { + if (doorTile != null) { + recentlyOpenedStationaryDoors.put(doorTile, System.currentTimeMillis()); + } + } + + public static boolean recentlyOpenedStationaryDoorOnSegment(Map recentlyOpenedStationaryDoors, + long suppressMs, + WorldPoint fromWp, + WorldPoint toWp) { + if (fromWp == null || toWp == null) { + return false; + } + final int segmentDoorSuppressDist = 2; + long now = System.currentTimeMillis(); + recentlyOpenedStationaryDoors.entrySet().removeIf(entry -> now - entry.getValue() > suppressMs); + return recentlyOpenedStationaryDoors.keySet().stream() + .anyMatch(door -> door != null + && door.getPlane() == fromWp.getPlane() + && (door.distanceTo2D(fromWp) <= segmentDoorSuppressDist || door.distanceTo2D(toWp) <= segmentDoorSuppressDist)); + } + + public static boolean shouldThrottleGlobalDoorInteraction(long nextDoorInteractionAllowedAtMs) { + return System.currentTimeMillis() < nextDoorInteractionAllowedAtMs; + } + + public static long markGlobalDoorInteractionCooldown(long cooldownMs) { + return System.currentTimeMillis() + cooldownMs; + } + + private static String compactWorldPoint(WorldPoint wp) { + if (wp == null) { + return "?"; + } + return wp.getX() + "," + wp.getY() + ",p" + wp.getPlane(); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java new file mode 100644 index 00000000000..25333d62821 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaits.java @@ -0,0 +1,132 @@ +package net.runelite.client.plugins.microbot.util.walker.door; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; +import net.runelite.client.plugins.microbot.util.walker.door.model.AwaitTicket; +import net.runelite.client.plugins.microbot.util.walker.door.model.DoorResolution; +import net.runelite.client.plugins.microbot.util.walker.shared.Rs2WalkerProgress; + +import static net.runelite.client.plugins.microbot.util.Global.sleepUntil; + +public final class Rs2WalkerAwaits { + private static final int DOOR_INTERACTION_START_WAIT_MS = 700; + private static final int DOOR_TRAVERSAL_PROGRESS_WAIT_MS = 2200; + + private Rs2WalkerAwaits() { + } + + public static AwaitTicket beginTicket() { + return new AwaitTicket(System.currentTimeMillis(), Rs2Player.getWorldLocation()); + } + + public static void awaitDoorInteractionProgress(AwaitTicket ticket, WorldPoint fromWp, WorldPoint toWp) { + if (ticket == null) { + return; + } + sleepUntil(() -> { + if (Thread.currentThread().isInterrupted()) { + return true; + } + WorldPoint now = Rs2Player.getWorldLocation(); + if (ticket.beforePosition() != null && now != null && !ticket.beforePosition().equals(now)) { + return true; + } + return Rs2Player.isMoving() || Rs2Player.isAnimating() || isDoorEdgeResolved(fromWp, toWp); + }, DOOR_INTERACTION_START_WAIT_MS); + sleepUntil(() -> { + if (Thread.currentThread().isInterrupted()) { + return true; + } + WorldPoint now = Rs2Player.getWorldLocation(); + if (now == null) { + return false; + } + boolean edgeResolved = isDoorEdgeResolved(fromWp, toWp); + if (edgeResolved) { + return true; + } + if (Rs2WalkerProgress.isWithinChebyshev(now, toWp, 1)) { + return true; + } + if (hasMeaningfulDoorProgress(ticket.beforePosition(), now, fromWp, toWp)) { + return true; + } + long elapsedMs = System.currentTimeMillis() - ticket.startedAtMs(); + return shouldAcceptIdleDoorAwait( + Rs2Player.isMoving(), + Rs2Player.isAnimating(), + elapsedMs, + edgeResolved); + }, DOOR_TRAVERSAL_PROGRESS_WAIT_MS); + } + + static boolean shouldAcceptIdleDoorAwait(boolean moving, boolean animating, long elapsedMs, boolean edgeResolved) { + if (moving || animating) { + return false; + } + if (elapsedMs <= 1_200L) { + return false; + } + return edgeResolved; + } + + public static DoorResolution awaitDoorEdgeResolution(WorldPoint fromWp, WorldPoint toWp, int timeoutMs) { + if (fromWp == null || toWp == null) { + return DoorResolution.FAILED_INVALID; + } + if (isDoorEdgeResolved(fromWp, toWp)) { + return DoorResolution.RESOLVED; + } + sleepUntil(() -> isDoorEdgeResolved(fromWp, toWp), timeoutMs); + return isDoorEdgeResolved(fromWp, toWp) ? DoorResolution.RESOLVED : DoorResolution.FAILED_TIMEOUT; + } + + public static boolean isDoorEdgeResolved(WorldPoint fromWp, WorldPoint toWp) { + if (fromWp == null || toWp == null) { + return false; + } + WorldPoint player = Rs2Player.getWorldLocation(); + if (player == null || player.getPlane() != toWp.getPlane()) { + return false; + } + if (Rs2WalkerProgress.isWithinChebyshev(player, toWp, 1)) { + return true; + } + int toDist = player.distanceTo2D(toWp); + int fromDist = player.distanceTo2D(fromWp); + if (toDist + 1 < fromDist) { + return true; + } + try { + if (!Rs2Player.isMoving() && toDist <= 4 && Rs2Tile.isTileReachable(toWp)) { + return true; + } + } catch (RuntimeException ex) { + // Script shutdown can interrupt client-thread invoke in reachability probe. + Throwable cause = ex.getCause(); + if (Thread.currentThread().isInterrupted() || cause instanceof InterruptedException) { + return false; + } + throw ex; + } + return false; + } + + private static boolean hasMeaningfulDoorProgress(WorldPoint before, WorldPoint now, WorldPoint fromWp, WorldPoint toWp) { + if (before == null || now == null || toWp == null) { + return false; + } + if (!now.equals(before) && Rs2WalkerProgress.isWithinChebyshev(now, toWp, 2)) { + return true; + } + if (fromWp == null || before.getPlane() != now.getPlane() || now.getPlane() != toWp.getPlane()) { + return false; + } + int beforeTo = before.distanceTo2D(toWp); + int nowTo = now.distanceTo2D(toWp); + int beforeFrom = before.distanceTo2D(fromWp); + int nowFrom = now.distanceTo2D(fromWp); + return nowTo < beforeTo && nowFrom >= beforeFrom; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/AwaitTicket.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/AwaitTicket.java new file mode 100644 index 00000000000..5200f7d4e7e --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/AwaitTicket.java @@ -0,0 +1,21 @@ +package net.runelite.client.plugins.microbot.util.walker.door.model; + +import net.runelite.api.coords.WorldPoint; + +public final class AwaitTicket { + private final long startedAtMs; + private final WorldPoint beforePosition; + + public AwaitTicket(long startedAtMs, WorldPoint beforePosition) { + this.startedAtMs = startedAtMs; + this.beforePosition = beforePosition; + } + + public long startedAtMs() { + return startedAtMs; + } + + public WorldPoint beforePosition() { + return beforePosition; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/DoorCandidate.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/DoorCandidate.java new file mode 100644 index 00000000000..41a24367614 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/DoorCandidate.java @@ -0,0 +1,46 @@ +package net.runelite.client.plugins.microbot.util.walker.door.model; + +import net.runelite.api.TileObject; +import net.runelite.api.coords.WorldPoint; + +public final class DoorCandidate { + private final TileObject object; + private final WorldPoint probe; + private final WorldPoint from; + private final WorldPoint to; + private final String action; + private final String mode; + + public DoorCandidate(TileObject object, WorldPoint probe, WorldPoint from, WorldPoint to, String action, String mode) { + this.object = object; + this.probe = probe; + this.from = from; + this.to = to; + this.action = action; + this.mode = mode; + } + + public TileObject object() { + return object; + } + + public WorldPoint probe() { + return probe; + } + + public WorldPoint from() { + return from; + } + + public WorldPoint to() { + return to; + } + + public String action() { + return action; + } + + public String mode() { + return mode; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/DoorEdge.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/DoorEdge.java new file mode 100644 index 00000000000..3cf3d425956 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/DoorEdge.java @@ -0,0 +1,26 @@ +package net.runelite.client.plugins.microbot.util.walker.door.model; + +import net.runelite.api.coords.WorldPoint; + +public final class DoorEdge { + private final WorldPoint from; + private final WorldPoint to; + + public DoorEdge(WorldPoint from, WorldPoint to) { + this.from = from; + this.to = to; + } + + public String normalizedKey() { + String a = compact(from); + String b = compact(to); + return a.compareTo(b) <= 0 ? a + "<->" + b : b + "<->" + a; + } + + private static String compact(WorldPoint wp) { + if (wp == null) { + return "?"; + } + return wp.getX() + "," + wp.getY() + ",p" + wp.getPlane(); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/DoorFailureReason.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/DoorFailureReason.java new file mode 100644 index 00000000000..8d36c703e6b --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/DoorFailureReason.java @@ -0,0 +1,10 @@ +package net.runelite.client.plugins.microbot.util.walker.door.model; + +public enum DoorFailureReason { + THROTTLED_EDGE, + THROTTLED_GLOBAL, + INTERACT_EXCEPTION, + INTERACT_FAILED, + NOT_TRAVERSED, + QUEST_LOCKED +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/DoorResolution.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/DoorResolution.java new file mode 100644 index 00000000000..6c1fc0b4178 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/DoorResolution.java @@ -0,0 +1,9 @@ +package net.runelite.client.plugins.microbot.util.walker.door.model; + +public enum DoorResolution { + RESOLVED, + AWAITING, + FAILED_TIMEOUT, + FAILED_CANCELLED, + FAILED_INVALID +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java new file mode 100644 index 00000000000..6ebff447716 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java @@ -0,0 +1,136 @@ +package net.runelite.client.plugins.microbot.util.walker.lifecycle; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Client; +import net.runelite.api.Player; +import net.runelite.api.coords.LocalPoint; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.shortestpath.ShortestPathPlugin; +import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; +import net.runelite.client.ui.overlay.worldmap.WorldMapPoint; +import net.runelite.client.ui.overlay.worldmap.WorldMapPointManager; + +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; + +@Slf4j +public final class Rs2WalkerLifecycleRuntime { + + private Rs2WalkerLifecycleRuntime() { + } + + public static void applyWalkerDestination(WorldPoint target) { + if (target == null) { + return; + } + if (!Microbot.isLoggedIn()) { + log.warn("Unable to apply walker destination: not logged in"); + return; + } + Client client = Microbot.getClient(); + if (client == null) { + log.warn("Unable to apply walker destination: client unavailable"); + return; + } + Player localPlayer = client.getLocalPlayer(); + if (!ShortestPathPlugin.isStartPointSet() && localPlayer == null) { + log.warn("Start point is not set and player is null"); + return; + } + + WorldMapPointManager wmm = Microbot.getWorldMapPointManager(); + if (wmm == null) { + Rs2Walker.clearWalkingRoute("walker:wmm-unavailable retry-setTarget dest=" + target); + return; + } + wmm.removeIf(x -> x == ShortestPathPlugin.getMarker()); + ShortestPathPlugin.setMarker(new WorldMapPoint(target, ShortestPathPlugin.MARKER_IMAGE)); + ShortestPathPlugin.getMarker().setName("Target"); + ShortestPathPlugin.getMarker().setTarget(ShortestPathPlugin.getMarker().getWorldPoint()); + ShortestPathPlugin.getMarker().setJumpOnClick(true); + wmm.add(ShortestPathPlugin.getMarker()); + + WorldPoint start; + if (client.getTopLevelWorldView().isInstance()) { + LocalPoint localLoc = Rs2Player.getLocalLocation(); + start = localLoc != null ? WorldPoint.fromLocalInstance(client, localLoc) : null; + if (start == null) { + log.warn("[Walker] setTarget: instance localPoint conversion returned null (localLoc={} target={}) — falling back to raw world location", + localLoc, target); + start = Rs2Player.getWorldLocation(); + } + } else { + start = Rs2Player.getWorldLocation(); + } + if (client.getTopLevelWorldView().isInstance()) { + WorldPoint exitPortal = net.runelite.client.plugins.microbot.shortestpath.PohPanel.getExitPortalTile(); + if (exitPortal != null) { + Microbot.log("[Walker] In POH instance — remapping pathfinder start " + start + + " -> exit portal " + exitPortal); + start = exitPortal; + } + } + ShortestPathPlugin.setLastLocation(start); + final Pathfinder pathfinder = ShortestPathPlugin.getPathfinder(); + if (ShortestPathPlugin.isStartPointSet() && pathfinder != null) { + start = pathfinder.getStart(); + } + if (client.isClientThread()) { + final WorldPoint startPoint = start; + Microbot.getClientThread().runOnSeperateThread(() -> restartPathfinding(startPoint, target)); + } else { + restartPathfinding(start, target); + } + } + + public static boolean restartPathfinding(WorldPoint start, WorldPoint end) { + return restartPathfinding(start, Set.of(end)); + } + + public static boolean restartPathfinding(WorldPoint start, Set ends) { + if (Microbot.getClient().isClientThread()) { + return false; + } + + Pathfinder pathfinder = ShortestPathPlugin.getPathfinder(); + if (pathfinder != null) { + pathfinder.cancel(); + if (ShortestPathPlugin.getPathfinderFuture() != null) { + ShortestPathPlugin.getPathfinderFuture().cancel(true); + } + } + + if (ShortestPathPlugin.getPathfindingExecutor() == null) { + ThreadFactory shortestPathNaming = new ThreadFactoryBuilder().setNameFormat("shortest-path-%d").build(); + ShortestPathPlugin.setPathfindingExecutor(Executors.newSingleThreadExecutor(shortestPathNaming)); + } + + WorldPoint refreshTarget = ends != null && !ends.isEmpty() ? ends.iterator().next() : null; + ShortestPathPlugin.getPathfinderConfig().refresh(refreshTarget); + if (Rs2Player.isInCave()) { + pathfinder = new Pathfinder(ShortestPathPlugin.getPathfinderConfig(), start, ends); + pathfinder.run(); + ShortestPathPlugin.getPathfinderConfig().setIgnoreTeleportAndItems(true); + Pathfinder pathfinderWithoutTeleports = new Pathfinder(ShortestPathPlugin.getPathfinderConfig(), start, ends); + pathfinderWithoutTeleports.run(); + var lastPath = pathfinderWithoutTeleports.getPath().get(pathfinderWithoutTeleports.getPath().size() - 1); + int reachedDistance = Rs2Walker.config != null ? Rs2Walker.config.reachedDistance() : 10; + var pathWithoutTeleportsIsReachable = lastPath.distanceTo(ends.stream().findFirst().orElse(lastPath)) <= reachedDistance; + if (pathWithoutTeleportsIsReachable && pathfinder.getPath().size() >= pathfinderWithoutTeleports.getPath().size()) { + ShortestPathPlugin.setPathfinder(pathfinderWithoutTeleports); + } else { + ShortestPathPlugin.setPathfinder(pathfinder); + } + ShortestPathPlugin.getPathfinderConfig().setIgnoreTeleportAndItems(false); + } else { + ShortestPathPlugin.setPathfinder(new Pathfinder(ShortestPathPlugin.getPathfinderConfig(), start, ends)); + ShortestPathPlugin.setPathfinderFuture(ShortestPathPlugin.getPathfindingExecutor().submit(ShortestPathPlugin.getPathfinder())); + } + return true; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/shared/Rs2WalkerProgress.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/shared/Rs2WalkerProgress.java new file mode 100644 index 00000000000..6e836e750c5 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/shared/Rs2WalkerProgress.java @@ -0,0 +1,38 @@ +package net.runelite.client.plugins.microbot.util.walker.shared; + +import net.runelite.api.coords.WorldPoint; + +public final class Rs2WalkerProgress { + private Rs2WalkerProgress() { + } + + public static boolean hasMovementOrProgress(WorldPoint before, WorldPoint now, WorldPoint expectedDestination, WorldPoint target) { + if (before == null || now == null) { + return false; + } + if (!now.equals(before)) { + return true; + } + if (isWithinChebyshev(now, expectedDestination, 1)) { + return true; + } + return madeProgressToward(before, now, target); + } + + public static boolean madeProgressToward(WorldPoint before, WorldPoint now, WorldPoint target) { + if (before == null || now == null || target == null) { + return false; + } + return now.distanceTo2D(target) < before.distanceTo2D(target); + } + + public static boolean isWithinChebyshev(WorldPoint from, WorldPoint to, int maxInclusive) { + if (from == null || to == null) { + return false; + } + if (from.getPlane() != to.getPlane()) { + return false; + } + return from.distanceTo2D(to) <= maxInclusive; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicy.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicy.java new file mode 100644 index 00000000000..05e2b2b9313 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicy.java @@ -0,0 +1,54 @@ +package net.runelite.client.plugins.microbot.util.walker.stall; + +import net.runelite.api.widgets.ComponentID; +import net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue; +import net.runelite.client.plugins.microbot.util.leaguetransport.Rs2LeaguesTransport; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; + +public final class Rs2WalkerStallPolicy { + private Rs2WalkerStallPolicy() { + } + + public static boolean shouldSkipStallAccounting(long leaguesPendingMaxAgeMs) { + if (Rs2LeaguesTransport.isTeleportInProgress()) { + return true; + } + if (Rs2LeaguesTransport.isLeaguesAreaTeleportPending(leaguesPendingMaxAgeMs)) { + return true; + } + if (Rs2Dialogue.isInDialogue()) { + return true; + } + return !Rs2Widget.isHidden(ComponentID.FAIRY_RING_TELEPORT_BUTTON); + } + + public static long computeThresholdMs(long baseMs, + double combatMultiplier, + double animatingMultiplier, + double movingMultiplier, + double interimMultiplier, + double interactingMultiplier, + boolean inCombat, + boolean animating, + boolean moving, + boolean hasInterimTarget, + boolean interactingNearPath) { + double multiplier = 1.0; + if (inCombat) { + multiplier = Math.max(multiplier, combatMultiplier); + } + if (animating) { + multiplier = Math.max(multiplier, animatingMultiplier); + } + if (moving) { + multiplier = Math.max(multiplier, movingMultiplier); + } + if (hasInterimTarget) { + multiplier = Math.max(multiplier, interimMultiplier); + } + if (interactingNearPath) { + multiplier = Math.max(multiplier, interactingMultiplier); + } + return Math.round(baseMs * multiplier); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/Rs2WalkerTransportAwaits.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/Rs2WalkerTransportAwaits.java new file mode 100644 index 00000000000..96b1e97754c --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/transport/Rs2WalkerTransportAwaits.java @@ -0,0 +1,24 @@ +package net.runelite.client.plugins.microbot.util.walker.transport; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.walker.shared.Rs2WalkerProgress; + +import static net.runelite.client.plugins.microbot.util.Global.sleepUntil; + +public final class Rs2WalkerTransportAwaits { + private Rs2WalkerTransportAwaits() { + } + + public static boolean didCurrentTileTransportProgress(WorldPoint before, WorldPoint expectedDestination, WorldPoint target) { + if (before == null) { + return false; + } + sleepUntil(() -> { + WorldPoint now = Rs2Player.getWorldLocation(); + return Rs2WalkerProgress.hasMovementOrProgress(before, now, expectedDestination, target); + }, 1800); + WorldPoint after = Rs2Player.getWorldLocation(); + return Rs2WalkerProgress.hasMovementOrProgress(before, after, expectedDestination, target); + } +} From 252912a05393e2ff8dfbff1d773240dac1cb3bb0 Mon Sep 17 00:00:00 2001 From: Haliax <18099301+TheHaliax@users.noreply.github.com> Date: Mon, 11 May 2026 08:43:26 -0500 Subject: [PATCH 3/9] feat(leaguetransport): add lock catalog and transport flow Add leagues lock/chat transport stack and wire seasonal transport handlers into runtime flow. --- docs/api/Rs2LeaguesTransport.md | 39 + .../plugins/microbot/MicrobotPlugin.java | 66 +- .../client/plugins/microbot/Script.java | 1 - .../breakhandler/BreakHandlerScript.java | 2 +- .../microbot/questhelper/QuestScript.java | 6 +- .../quests/ghostsahoy/DyeShipSteps.java | 18 +- .../quests/theforsakentower/JugPuzzle.java | 3 +- .../questhelper/logic/PiratesTreasure.java | 2 +- .../util/events/WelcomeScreenEvent.java | 61 +- .../microbot/util/inventory/Rs2Gembag.java | 7 +- .../microbot/util/inventory/Rs2LogBasket.java | 8 +- .../LeagueTransportWidgets.java | 48 + .../util/leaguetransport/LeaguesRegion.java | 247 ++++ .../LeaguesTeleportFailureReason.java | 23 + .../LeaguesTeleportResult.java | 80 ++ .../LeaguesTransportAttemptSnapshot.java | 17 + .../LeaguesTransportAttempts.java | 222 +++ .../leaguetransport/LeaguesTransportChat.java | 232 ++++ .../LeaguesTransportInjection.java | 234 ++++ .../LeaguesTransportLockCatalogue.java | 34 + .../LeaguesTransportObservations.java | 680 +++++++++ .../LeaguesTransportPersistence.java | 732 ++++++++++ .../LeaguesTransportRegions.java | 273 ++++ .../LeaguesTransportTeleport.java | 1222 +++++++++++++++++ .../leaguetransport/Rs2LeaguesTransport.java | 365 +++++ .../Rs2MapOfAlacrityTransport.java | 583 ++++++++ .../SeasonalTransportHandler.java | 22 + .../SeasonalTransportHandlers.java | 50 + .../util/logging/Rs2LogRateLimit.java | 39 + .../microbot/util/misc/Rs2UiHelper.java | 29 +- .../microbot/util/text/Rs2TextSanitizer.java | 350 +++++ .../plugins/microbot/util/tile/Rs2Tile.java | 141 +- .../java/net/runelite/client/ui/ClientUI.java | 2 +- .../LeaguesTransportChatTest.java | 149 ++ .../LeaguesTransportLockCatalogueTest.java | 21 + .../Rs2LeaguesTransportRegionParseTest.java | 62 + ...ortShouldRecalculatePathAfterLockTest.java | 30 + .../Rs2MapOfAlacrityTransportTest.java | 45 + .../SeasonalTransportHandlersTest.java | 17 + .../util/text/Rs2TextSanitizerTest.java | 63 + 40 files changed, 6142 insertions(+), 83 deletions(-) create mode 100644 docs/api/Rs2LeaguesTransport.md create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeagueTransportWidgets.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesRegion.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTeleportFailureReason.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTeleportResult.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportAttemptSnapshot.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportAttempts.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportChat.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportInjection.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportLockCatalogue.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportObservations.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportPersistence.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportRegions.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportTeleport.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2LeaguesTransport.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2MapOfAlacrityTransport.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/SeasonalTransportHandler.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/SeasonalTransportHandlers.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/logging/Rs2LogRateLimit.java create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/text/Rs2TextSanitizer.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportChatTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportLockCatalogueTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2LeaguesTransportRegionParseTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2LeaguesTransportShouldRecalculatePathAfterLockTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2MapOfAlacrityTransportTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/SeasonalTransportHandlersTest.java create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/text/Rs2TextSanitizerTest.java diff --git a/docs/api/Rs2LeaguesTransport.md b/docs/api/Rs2LeaguesTransport.md new file mode 100644 index 00000000000..59304c63d78 --- /dev/null +++ b/docs/api/Rs2LeaguesTransport.md @@ -0,0 +1,39 @@ +# Rs2LeaguesTransport + +**Source:** `runelite-client/.../microbot/util/leaguetransport/Rs2LeaguesTransport.java` +**Related:** `LeaguesRegion`, `LeagueTransportWidgets` + +API driver for Leagues area teleports via UI chain: + +`Activities -> Leagues -> View Areas -> Teleport to ` + +## Primary call + +- `LeaguesTeleportResult leaguesTeleport(LeaguesRegion region)` + - Runs context gates + unlocked-region scan in one client-thread pass + - Blocks caller thread only (no client-thread blocking) + - Performs full UI chain internally + - Returns rich result: success, failure reason enum, message, target, unlocked snapshot + +## Gates + +- **Seasonal world**: `WorldType.SEASONAL` +- **League save active**: `VarbitID.LEAGUE_ACCOUNT > 0` +- **Region unlocked**: `region.getAreaId()` present in `LEAGUE_AREA_SELECTION_0..5` + +## Unlocked regions snapshot (future webwalker interrupt) + +- `EnumSet unlockedRegions()` + - Returns empty set when not in leagues context + - Uses `LEAGUE_AREA_SELECTION_0..5` varbits, mapped to `LeaguesRegion` by `areaId` + +## Advanced: non-blocking driver + +- `Rs2LeaguesTransport.LeaguesTeleportDriver` + - Returned internally today; intended for advanced callers + - Call `tick()` from script loop until `!isActive()` + +## Widget ids + +Widget ids live in package-private `LeagueTransportWidgets`. Re-verify ids after game/client bumps if chain stops working. + diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java index 604625a81d8..d3f48ce38f7 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java @@ -29,6 +29,9 @@ import net.runelite.client.plugins.microbot.util.overlay.GembagOverlay; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.reflection.Rs2Reflection; +import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; +import net.runelite.client.plugins.microbot.util.leaguetransport.Rs2LeaguesTransport; +import net.runelite.client.plugins.microbot.util.leaguetransport.SeasonalTransportHandlers; import net.runelite.client.plugins.microbot.api.boat.Rs2BoatCache; import net.runelite.client.plugins.microbot.util.shop.Rs2Shop; import net.runelite.client.plugins.microbot.util.tabs.Rs2Tab; @@ -40,6 +43,7 @@ import net.runelite.client.ui.overlay.OverlayManager; import net.runelite.client.ui.overlay.OverlayMenuEntry; import net.runelite.client.util.ImageUtil; + import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -47,15 +51,16 @@ import javax.inject.Provider; import javax.inject.Singleton; import javax.swing.*; -import java.awt.*; +import java.awt.AWTException; +import java.awt.Rectangle; import java.awt.image.BufferedImage; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.Arrays; -import java.util.*; import java.util.List; - +import java.util.Objects; +import java.util.Optional; @PluginDescriptor( name = PluginDescriptor.Default + "Microbot", description = "Microbot", @@ -67,6 +72,13 @@ @Slf4j public class MicrobotPlugin extends Plugin { + /** + * Max age of {@code lastTransportAttempt} for attributing locked-region chat to a click. + * Canonical value is {@link Rs2LeaguesTransport#LEAGUES_LOCK_CHAT_MAX_ATTEMPT_AGE_MS}; kept here for script compatibility. + * + * @apiNote Treat as stable external API: renames or semantic changes break scripts — note in changelog when modifying. + */ + public static final long LEAGUES_LOCK_CHAT_MAX_ATTEMPT_AGE_MS = Rs2LeaguesTransport.LEAGUES_LOCK_CHAT_MAX_ATTEMPT_AGE_MS; @Inject private Provider pluginListPanelProvider; @@ -176,6 +188,8 @@ protected void startUp() throws AWTException Microbot.getPouchScript().startUp(); + Rs2Walker.setSeasonalTransportHandlers(SeasonalTransportHandlers.defaultHandlerList()); + if (overlayManager != null) { overlayManager.add(microbotOverlay); @@ -309,6 +323,7 @@ public void onGameStateChanged(GameStateChanged gameStateChanged) // and we also handle correct cache loading in onRuneScapeProfileChanged event LoginManager.markLoggedOut(); Microbot.setLastKnownRegions(null); + Rs2LeaguesTransport.onLogout(); } // update last known game state to track login/logout transitions LoginManager.setLastKnownGameState(gameStateChanged.getGameState()); @@ -383,18 +398,51 @@ private void onMenuOptionClicked(MenuOptionClicked event) @Subscribe private void onChatMessage(ChatMessage event) { - if (event.getType() == ChatMessageType.ENGINE && event.getMessage().equalsIgnoreCase("I can't reach that!")) + if (event.getType() == ChatMessageType.ENGINE) { - Microbot.cantReachTarget = true; + String msg = event.getMessage(); + if (msg != null && msg.equalsIgnoreCase("I can't reach that!")) + { + Microbot.cantReachTarget = true; + } } - if (event.getType() == ChatMessageType.GAMEMESSAGE && event.getMessage().toLowerCase().contains("you can't log into a non-members")) + if (event.getType() == ChatMessageType.GAMEMESSAGE) { - Microbot.cantHopWorld = true; + String msg = event.getMessage(); + if (msg != null && containsIgnoreCase(msg, "you can't log into a non-members")) + { + Microbot.cantHopWorld = true; + } + + // Leagues: "haven't unlocked access to X area" -> blacklist last transport dest. + Rs2LeaguesTransport.onLockedRegionGameMessage(msg); } Microbot.getPouchScript().onChatMessage(event); Rs2Gembag.onChatMessage(event); } + private static boolean containsIgnoreCase(String haystack, String needle) + { + if (haystack == null || needle == null || needle.isEmpty()) + { + return false; + } + int hLen = haystack.length(); + int nLen = needle.length(); + if (nLen > hLen) + { + return false; + } + for (int i = 0; i <= hLen - nLen; i++) + { + if (haystack.regionMatches(true, i, needle, 0, nLen)) + { + return true; + } + } + return false; + } + @Subscribe public void onConfigChanged(ConfigChanged ev) { @@ -517,8 +565,8 @@ public void onOverlayMenuClicked(OverlayMenuClicked overlayMenuClicked) @Subscribe public void onGameTick(GameTick event) { - // Cache loading is now handled properly during login/profile changes - // No need to call loadInitialCacheFromCurrentConfig on every tick + // Start Leagues teleport calibration ASAP after login (non-blocking; prompts for consent once). + Rs2LeaguesTransport.tickLeaguesCalibration(); } @Subscribe(priority = 100) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java index c755474b9d7..e9b18417d71 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/Script.java @@ -63,7 +63,6 @@ public void shutdown() { Microbot.pauseAllScripts.set(false); Rs2Walker.disableTeleports = false; Microbot.getSpecialAttackConfigs().reset(); - Rs2Walker.setTarget(null); } if (scheduledFuture != null && !scheduledFuture.isDone()) { scheduledFuture.cancel(true); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/BreakHandlerScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/BreakHandlerScript.java index 57175053999..0068091d98c 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/BreakHandlerScript.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/breakhandler/BreakHandlerScript.java @@ -356,7 +356,7 @@ private void handleInitiatingBreakState() { // Pause all scripts Microbot.pauseAllScripts.compareAndSet(false, true); PluginPauseEvent.setPaused(true); - Rs2Walker.setTarget(null); + Rs2Walker.clearWalkingRoute("break-handler:initiating-break"); // Remember the world we were in before the break preBreakWorld = Microbot.getClientThread().runOnClientThreadOptional( diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestScript.java index 1f87a277a8f..1416cbe5161 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestScript.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestScript.java @@ -218,7 +218,7 @@ public boolean run(QuestHelperConfig config, QuestHelperPlugin mQuestPlugin) { } if (Rs2Dialogue.isInDialogue() && dialogueStartedStep == questStep) { - Rs2Walker.setTarget(null); + Rs2Walker.clearWalkingRoute("quest-helper:dialogue-space-step"); Rs2Keyboard.keyPress(KeyEvent.VK_SPACE); return; } else { @@ -1296,7 +1296,7 @@ public boolean applyNpcStep(NpcStep step) { if (npc != null && npc.getLocalLocation() != null && Rs2Camera.isTileOnScreen(npc.getLocalLocation()) && (Microbot.getClient().isInInstancedRegion() || Rs2Walker.canReach(npc.getWorldLocation()))) { - Rs2Walker.setTarget(null); + Rs2Walker.clearWalkingRoute("quest-helper:npc-step-visible-interact"); if (step.getText().stream().anyMatch(x -> x.toLowerCase().contains("kill"))) { if (!Rs2Combat.inCombat()) { @@ -1428,7 +1428,7 @@ public boolean applyObjectStep(ObjectStep step) { } if (hasLineOfSightToObject(object) || object != null && (Rs2Camera.isTileOnScreen(object.getLocalLocation()) || object.getCanvasLocation() != null)) { - Rs2Walker.setTarget(null); + Rs2Walker.clearWalkingRoute("quest-helper:object-step-interact"); if (itemId == -1) object.click(chooseCorrectObjectOption(step, object)); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/ghostsahoy/DyeShipSteps.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/ghostsahoy/DyeShipSteps.java index 2bad7e60a27..1f6aa8cd1e3 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/ghostsahoy/DyeShipSteps.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/ghostsahoy/DyeShipSteps.java @@ -38,12 +38,15 @@ import net.runelite.api.gameval.ObjectID; import net.runelite.api.widgets.Widget; import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.plugins.microbot.util.text.Rs2TextSanitizer; +import lombok.extern.slf4j.Slf4j; import java.util.Arrays; import java.util.Collection; import java.util.HashMap; import java.util.List; +@Slf4j public class DyeShipSteps extends DetailedOwnerStep { boolean coloursKnown = false; @@ -96,18 +99,27 @@ private void updateCurrentColours() { return; } - String[] splitOnNewLines = text.split("
"); + String normalized = Rs2TextSanitizer.normalizeGameText(text); + String[] splitOnNewLines = normalized.split("
"); if (splitOnNewLines.length > 1) { for (String splitOnNewLine : splitOnNewLines) { - updateCurrentColoursFromString(splitOnNewLine); + updateCurrentColoursFromString(Rs2TextSanitizer.stripTagsToSpace(splitOnNewLine)); } } - String[] splitText = text.split("dye the "); + String plain = Rs2TextSanitizer.stripTagsToSpace(normalized); + String[] splitText = plain.split("dye the "); if (splitText.length < 2) { + if (log.isDebugEnabled()) + { + int h = plain.hashCode(); + int len = plain.length(); + String prefix = plain.length() > 60 ? plain.substring(0, 60) + "…" : plain; + log.debug("DyeShipSteps: expected 'dye the ' in objectbox text; len={} hash={} prefix='{}'", len, Integer.toHexString(h), prefix); + } return; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/theforsakentower/JugPuzzle.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/theforsakentower/JugPuzzle.java index d2142ed6bc6..88a68fe21a9 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/theforsakentower/JugPuzzle.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/theforsakentower/JugPuzzle.java @@ -48,6 +48,7 @@ import net.runelite.api.widgets.Widget; import net.runelite.client.eventbus.EventBus; import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.plugins.microbot.util.text.Rs2TextSanitizer; import java.util.*; import java.util.regex.Matcher; @@ -97,7 +98,7 @@ protected void updateSteps() if (widget != null) { - String text = widget.getText().replace("
", " "); + String text = Rs2TextSanitizer.sanitizeWidgetMultilineText(widget.getText()); Matcher jugOnJugMatcher = JUG_VALUES_MATCHER.matcher(text); Matcher jugEmptiedMatcher = JUG_EMPTIED.matcher(text); Matcher jugFilledMatcher = JUG_FILLED.matcher(text); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/PiratesTreasure.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/PiratesTreasure.java index 26802677ee1..2334d0e8557 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/PiratesTreasure.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/PiratesTreasure.java @@ -122,7 +122,7 @@ public boolean executeCustomLogic() { if (questStep.getText().contains("Dig in the middle of the cross in Falador Park, and kill the Gardener (level 4) who appears. Once killed, dig again.")) { if (!Rs2Inventory.contains(SPADE)) { System.out.println("here2"); - Rs2Walker.setTarget(null); + Rs2Walker.clearWalkingRoute("quest:pirates-treasure:detour-for-spade"); sleep(1200); Rs2Walker.walkTo(2982, 3369, 0); sleep(1200); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/events/WelcomeScreenEvent.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/events/WelcomeScreenEvent.java index 8d7d6ebaab7..14ce48a48af 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/events/WelcomeScreenEvent.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/events/WelcomeScreenEvent.java @@ -1,15 +1,16 @@ package net.runelite.client.plugins.microbot.util.events; import lombok.extern.slf4j.Slf4j; -import net.runelite.api.annotations.Component; +import net.runelite.api.Client; import net.runelite.api.gameval.InterfaceID; import net.runelite.api.widgets.Widget; import net.runelite.client.plugins.microbot.BlockingEvent; import net.runelite.client.plugins.microbot.BlockingEventPriority; import net.runelite.client.plugins.microbot.Microbot; -import net.runelite.client.plugins.microbot.util.Global; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; +import java.util.function.Supplier; + import static net.runelite.client.plugins.microbot.util.Global.sleepUntil; @Slf4j @@ -22,34 +23,42 @@ public boolean validate() { @Override public boolean execute() { - Widget updateBottomRibbon = Rs2Widget.getWidget(InterfaceID.WelcomeScreen.URL); - if (updateBottomRibbon != null) { - updateBottomRibbon.setOnClickListener(null); - updateBottomRibbon.setOnOpListener(null); - log.info("WelcomeScreenEvent execute: Cleared update ribbon listener to avoid accidental page opening."); - } else { - log.info("WelcomeScreenEvent execute: Update ribbon widget is null"); - } + // Widget mutations must run on the client thread; this event executes on Microbot-BlockingEvent. + Boolean clickedPlay = Microbot.getClientThread().invoke((Supplier) () -> { + Client client = Microbot.getClient(); + Widget updateBottomRibbon = client.getWidget(InterfaceID.WelcomeScreen.URL); + if (updateBottomRibbon != null) { + updateBottomRibbon.setOnClickListener((Object[]) null); + updateBottomRibbon.setOnOpListener((Object[]) null); + log.info("WelcomeScreenEvent execute: Cleared update ribbon listener to avoid accidental page opening."); + } else { + log.info("WelcomeScreenEvent execute: Update ribbon widget is null"); + } - Widget newsBanner = Rs2Widget.getWidget(InterfaceID.WelcomeScreen.BANNER); - if (newsBanner != null) { - newsBanner.setHidden(true); - log.info("WelcomeScreenEvent execute: Cleared banner to avoid accidental page openings."); - } else { - log.info("WelcomeScreenEvent execute: Banner widget is null"); - } + Widget newsBanner = client.getWidget(InterfaceID.WelcomeScreen.BANNER); + if (newsBanner != null) { + newsBanner.setHidden(true); + log.info("WelcomeScreenEvent execute: Cleared banner to avoid accidental page openings."); + } else { + log.info("WelcomeScreenEvent execute: Banner widget is null"); + } - Widget playWidget = Rs2Widget.getWidget(InterfaceID.WelcomeScreen.PLAY); - boolean isPlayWidgetVisible = Rs2Widget.isWidgetVisible(InterfaceID.WelcomeScreen.PLAY); - boolean wasNewsBannerHandled = (newsBanner == null || Rs2Widget.isHidden(InterfaceID.WelcomeScreen.BANNER)); - boolean wasUpdateRibbonHandled = (updateBottomRibbon == null || updateBottomRibbon.getOnOpListener() == null); + Widget playWidget = client.getWidget(InterfaceID.WelcomeScreen.PLAY); + boolean isPlayWidgetVisible = playWidget != null && !playWidget.isHidden(); + boolean wasNewsBannerHandled = newsBanner == null || newsBanner.isHidden(); + boolean wasUpdateRibbonHandled = updateBottomRibbon == null || updateBottomRibbon.getOnOpListener() == null; - if (playWidget != null && isPlayWidgetVisible && wasUpdateRibbonHandled && wasNewsBannerHandled) { - log.info("WelcomeScreenEvent execute: Clicking play button."); - Rs2Widget.clickWidget(playWidget); - sleepUntil(() -> !validate()); - } else { + if (playWidget != null && isPlayWidgetVisible && wasUpdateRibbonHandled && wasNewsBannerHandled) { + log.info("WelcomeScreenEvent execute: Clicking play button."); + Rs2Widget.clickWidget(playWidget); + return true; + } log.info("WelcomeScreenEvent execute: Play button is null"); + return false; + }); + + if (Boolean.TRUE.equals(clickedPlay)) { + sleepUntil(() -> !validate()); } return !validate(); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/inventory/Rs2Gembag.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/inventory/Rs2Gembag.java index a43c7acdc97..dd75663cfd5 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/inventory/Rs2Gembag.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/inventory/Rs2Gembag.java @@ -11,6 +11,7 @@ import net.runelite.client.plugins.microbot.util.Global; import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; import net.runelite.client.plugins.microbot.util.misc.Rs2UiHelper; +import net.runelite.client.plugins.microbot.util.text.Rs2TextSanitizer; import java.util.*; import java.util.regex.Matcher; @@ -23,6 +24,7 @@ public class Rs2Gembag { private static final Pattern GEM_PATTERN = Pattern.compile("You just found (?:an|a) (sapphire|emerald|ruby|diamond|dragonstone)!", Pattern.CASE_INSENSITIVE); private static final Pattern CHECK_PATTERN = Pattern.compile("Sapphires: (\\d+) / Emeralds: (\\d+) / Rubies: (\\d+) / Diamonds: (\\d+) / Dragonstones: (\\d+)"); + private static final Pattern BR_TAG = Pattern.compile("(?i)"); @Getter private static final List gemBagItemIds = List.of(ItemID.GEM_BAG, ItemID.GEM_BAG_OPEN); @@ -46,7 +48,10 @@ public static void onChatMessage(ChatMessage event) { if (gemItem != null) updateGem(gemItem, 1); } - String cleanedMessage = message.replace("
", " / "); + // Keep line breaks as separators for CHECK_PATTERN ("... / ... / ..."); strip tags/entities so does not break match. + String withBreaks = BR_TAG.matcher(Rs2TextSanitizer.normalizeGameText(message)).replaceAll(" / "); + String cleanedMessage = Rs2TextSanitizer.normalizeApostrophes( + Rs2TextSanitizer.stripTags(Rs2TextSanitizer.decodeKnownEntities(withBreaks))).trim(); Matcher checkMatcher = CHECK_PATTERN.matcher(cleanedMessage); if (checkMatcher.find()) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/inventory/Rs2LogBasket.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/inventory/Rs2LogBasket.java index c1e9feae070..5f71e6e3e18 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/inventory/Rs2LogBasket.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/inventory/Rs2LogBasket.java @@ -9,6 +9,7 @@ import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; import net.runelite.client.plugins.microbot.util.dialogues.Rs2Dialogue; import net.runelite.client.plugins.microbot.util.misc.Rs2UiHelper; +import net.runelite.client.plugins.microbot.util.text.Rs2TextSanitizer; import java.util.List; import java.util.regex.Matcher; @@ -313,11 +314,8 @@ private static BasketContents parseBasketText(String text) { return BasketContents.empty(); } - // clean up the text - remove html tags and normalize - String cleanText = Rs2UiHelper.stripColTags(text) - .replace("
", " ") - .replace(" ", " ") - .trim(); + // Clean up widget text: tags +
+ nbsp/entities. + String cleanText = Rs2TextSanitizer.sanitizeWidgetMultilineText(text); // pattern to match "X x LogType logs" or "X x LogType log" Pattern pattern = Pattern.compile("(\\d+)\\s*x\\s*([a-zA-Z\\s]+?)\\s*logs?", Pattern.CASE_INSENSITIVE); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeagueTransportWidgets.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeagueTransportWidgets.java new file mode 100644 index 00000000000..c8065bef91c --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeagueTransportWidgets.java @@ -0,0 +1,48 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +/** + * Interface group + child ids for {@link Rs2LeaguesTransport} (Activities → Leagues → View Areas → teleport row). + * Use {@link #pack(int, int)} for {@link net.runelite.api.Client#menuAction} {@code param1} (packed component id). + *

+ * Optional per-region overrides and areas-menu shield tabs: {@link LeaguesRegion}. + */ +final class LeagueTransportWidgets +{ + private LeagueTransportWidgets() + { + } + + /** Activities panel: open Leagues. */ + static final int ACTIVITIES_GROUP = 161; + static final int ACTIVITIES_CHILD = 61; + + /** Tab strip row child with {@code Actions=[Leagues,...]} (packed id e.g. {@code 41222178}). */ + static final int LEAGUES_GROUP = 629; + static final int LEAGUES_CHILD = 34; + + static final int VIEW_AREAS_GROUP = 656; + static final int VIEW_AREAS_CHILD = 40; + + /** Root panel for areas view (e.g. {@code 33554433}). */ + static final int AREAS_PANEL_GROUP = 512; + static final int AREAS_PANEL_CHILD = 1; + + /** + * Parent of teleport rows in the areas menu ({@code 33554474}). Visibility gate + parent for + * {@link LeaguesRegion#getTeleportListRowDynamicIndex()}. + */ + static final int AREAS_LIST_CONTAINER_GROUP = 512; + static final int AREAS_LIST_CONTAINER_CHILD = 42; + + /** + * Row widget template id ({@code 33554516}); multiple instances share this id under the container — + * pick the correct row by dynamic index, not by {@link net.runelite.api.Client#getWidget(int, int)} alone. + */ + static final int TELEPORT_ROW_GROUP = 512; + static final int TELEPORT_ROW_CHILD = 84; + + static int pack(int group, int child) + { + return (group << 16) | (child & 0xFFFF); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesRegion.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesRegion.java new file mode 100644 index 00000000000..f3e218df8f5 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesRegion.java @@ -0,0 +1,247 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +import java.util.Objects; + +/** + * Leagues area teleport targets. {@link #getAreaId()} matches values stored in + * {@link net.runelite.api.gameval.VarbitID#LEAGUE_AREA_SELECTION_0} + * through {@code LEAGUE_AREA_SELECTION_5} + * for seasonal leagues; values may vary by league season. + * Area ids are authoritative game values (not contiguous). + * + *

Optional widget overrides ({@link #getAreasListRootGroup()} / {@link #getAreasListRootChild()}, + * {@link #getTeleportCcOpGroup()} / {@link #getTeleportCcOpChild()}): + * list container {@code group == 0} uses {@link LeagueTransportWidgets#AREAS_LIST_CONTAINER_GROUP}; + * teleport {@code group == 0} uses {@link #getTeleportListRowDynamicIndex()} under that container. + * + *

The areas menu rebuilds its list when a shield tab is selected. Use {@link #getAreasMenuShield()} + * with {@link AreasMenuShield#isActive()} so transport clicks the correct tab before waiting on the row / teleport. + */ +public enum LeaguesRegion +{ + /** + * Indices are positions in the areas list {@link net.runelite.api.widgets.Widget#getDynamicChildren()} under + * the list container — tune when client layout or shield tab changes list order. + */ + MISTHALIN(1, "Misthalin", shieldTab(46, "Misthalin"), 0), + KARAMJA(2, "Karamja", shieldTab(47, "Karamja"), 1), + ASGARNIA(3, "Asgarnia", shieldTab(50, "Asgarnia"), 2), + KANDARIN(4, "Kandarin", shieldTab(51, "Kandarin"), 3), + MORYTANIA(5, "Morytania", shieldTab(49, "Morytania"), 4), + DESERT(6, "Kharidian Desert", shieldTab(48, "Kharidian Desert"), 5), + TIRANNWN(7, "Tirannwn", shieldTab(53, "Tirannwn"), 6), + FREMENNIK(8, "Fremennik Province", shieldTab(52, "Fremennik Province"), 7), + WILDERNESS(11, "Wilderness", shieldTab(54, "Wilderness"), 8), + KEBOS_AND_KOUREND(20, "Great Kourend and Kebos Lowlands", shieldTab(55, "Great Kourend and Kebos Lowlands"), 9), + VARLAMORE(21, "Varlamore", shieldTab(57, "Varlamore"), 10); + + /** + * Shield tab on the Leagues areas menu: interface group/child + {@code CC_OP} strings. + * {@link #none()} skips the click (prototype-style flow when only one tab is shown). + */ + public static final class AreasMenuShield + { + private final int group; + private final int child; + private final String ccOpOption; + private final String ccOpTarget; + + public AreasMenuShield(int group, int child, String ccOpOption, String ccOpTarget) + { + this.group = group; + this.child = child; + this.ccOpOption = ccOpOption != null ? ccOpOption : ""; + this.ccOpTarget = ccOpTarget != null ? ccOpTarget : ""; + } + + public static AreasMenuShield none() + { + return new AreasMenuShield(0, 0, "", ""); + } + + /** Inactive when {@code group == 0} ({@code child} ignored). */ + public boolean isActive() + { + return group != 0; + } + + public int getGroup() + { + return group; + } + + public int getChild() + { + return child; + } + + public String getCcOpOption() + { + return ccOpOption; + } + + public String getCcOpTarget() + { + return ccOpTarget; + } + } + + private final int areaId; + private final String displayName; + /** Areas list container; {@code group == 0} = default {@link LeagueTransportWidgets#AREAS_LIST_CONTAINER_GROUP}. */ + private final int areasListRootGroup; + private final int areasListRootChild; + /** Final {@link net.runelite.api.MenuAction#CC_OP} target; {@code group == 0} = use row from list + index. */ + private final int teleportCcOpGroup; + private final int teleportCcOpChild; + private final String teleportCcOpOption; + private final AreasMenuShield areasMenuShield; + /** + * Which row under the list container’s dynamic/static children (same packed row id for every row in IF3). + */ + private final int teleportListRowDynamicIndex; + + LeaguesRegion(int areaId, String displayName, AreasMenuShield areasMenuShield, int teleportListRowDynamicIndex) + { + // Empirical: widget dump shows Actions=[Teleport to], but menuAction requires option "Teleport". + this(areaId, displayName, 0, 0, 0, 0, "Teleport", areasMenuShield, teleportListRowDynamicIndex); + } + + LeaguesRegion( + int areaId, + String displayName, + int areasListRootGroup, + int areasListRootChild, + int teleportCcOpGroup, + int teleportCcOpChild, + int teleportListRowDynamicIndex) + { + // Empirical: widget dump shows Actions=[Teleport to], but menuAction requires option "Teleport". + this(areaId, displayName, areasListRootGroup, areasListRootChild, teleportCcOpGroup, teleportCcOpChild, "Teleport", AreasMenuShield.none(), teleportListRowDynamicIndex); + } + + LeaguesRegion( + int areaId, + String displayName, + int areasListRootGroup, + int areasListRootChild, + int teleportCcOpGroup, + int teleportCcOpChild, + String teleportCcOpOption, + int teleportListRowDynamicIndex) + { + this(areaId, displayName, areasListRootGroup, areasListRootChild, teleportCcOpGroup, teleportCcOpChild, teleportCcOpOption, AreasMenuShield.none(), teleportListRowDynamicIndex); + } + + LeaguesRegion( + int areaId, + String displayName, + int areasListRootGroup, + int areasListRootChild, + int teleportCcOpGroup, + int teleportCcOpChild, + String teleportCcOpOption, + AreasMenuShield areasMenuShield, + int teleportListRowDynamicIndex) + { + this.areaId = areaId; + this.displayName = displayName; + this.areasListRootGroup = areasListRootGroup; + this.areasListRootChild = areasListRootChild; + this.teleportCcOpGroup = teleportCcOpGroup; + this.teleportCcOpChild = teleportCcOpChild; + this.teleportCcOpOption = Objects.requireNonNull(teleportCcOpOption, "teleportCcOpOption"); + this.areasMenuShield = areasMenuShield != null ? areasMenuShield : AreasMenuShield.none(); + this.teleportListRowDynamicIndex = teleportListRowDynamicIndex; + } + + public int getAreaId() + { + return areaId; + } + + public String getDisplayName() + { + return displayName; + } + + /** + * Interface group for the areas list container (visibility wait + row parent). + * {@code 0} means use the shared default from {@link LeagueTransportWidgets#AREAS_LIST_CONTAINER_GROUP}. + */ + public int getAreasListRootGroup() + { + return areasListRootGroup; + } + + /** + * Child id paired with {@link #getAreasListRootGroup()}; ignored when group is {@code 0}. + */ + public int getAreasListRootChild() + { + return areasListRootChild; + } + + /** + * Interface group for the final {@code CC_OP} when fixed (non-resolved) row. + * {@code 0} means use {@link #getTeleportListRowDynamicIndex()} under the list container. + */ + public int getTeleportCcOpGroup() + { + return teleportCcOpGroup; + } + + /** + * Child id paired with {@link #getTeleportCcOpGroup()}; ignored when group is {@code 0}. + */ + public int getTeleportCcOpChild() + { + return teleportCcOpChild; + } + + /** + * Menu option for the final {@code CC_OP} (e.g. {@code Teleport to} vs {@code Teleport}). + */ + public String getTeleportCcOpOption() + { + return teleportCcOpOption; + } + + /** + * Index into {@link net.runelite.api.widgets.Widget#getDynamicChildren()} (else {@link net.runelite.api.widgets.Widget#getChildren()}) + * under the areas list container for this region’s teleport row. + */ + public int getTeleportListRowDynamicIndex() + { + return teleportListRowDynamicIndex; + } + + /** + * Tab (“shield”) on the areas menu that must be selected so the list is rebuilt for this region. + */ + public AreasMenuShield getAreasMenuShield() + { + return areasMenuShield; + } + + /** + * Menu target string for the {@link net.runelite.api.MenuAction#CC_OP} chain. + * Built from {@link #getDisplayName()} (not raw {@link #getAreaId()}); + * must match the areas list row {@code Name}. + * {@literal } markup must stay aligned with the client UI. + */ + public String toMenuTarget() + { + return "" + displayName + ""; + } + + private static AreasMenuShield shieldTab(int child, String ccOpTarget) + { + Objects.requireNonNull(ccOpTarget, "ccOpTarget"); + return new AreasMenuShield( + LeagueTransportWidgets.AREAS_LIST_CONTAINER_GROUP, + child, + "View", + ccOpTarget); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTeleportFailureReason.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTeleportFailureReason.java new file mode 100644 index 00000000000..5169e305be3 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTeleportFailureReason.java @@ -0,0 +1,23 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +/** + * Failure classification for {@link LeaguesTeleportResult}. Callers that switch on this enum should treat + * {@link #TELEPORT_TIMEOUT} like other non-success outcomes unless a dedicated UX is required. + * {@link #CLIENT_UNAVAILABLE} and {@link #INVOKED_ON_CLIENT_THREAD} are caller/environment issues (same UX as other hard + * failures unless you special-case UI) and are not fixed by blind retry; {@link #CLIENT_THREAD_UNAVAILABLE} is an empty + * client-thread gate callback, distinct from those two. + */ +public enum LeaguesTeleportFailureReason +{ + CLIENT_UNAVAILABLE, + NOT_SEASONAL_WORLD, + LEAGUE_ACCOUNT_INACTIVE, + CLIENT_THREAD_UNAVAILABLE, + /** Caller invoked {@link net.runelite.client.plugins.microbot.util.leaguetransport.Rs2LeaguesTransport#leaguesTeleport} on the RuneLite client thread (disallowed; blocks UI). */ + INVOKED_ON_CLIENT_THREAD, + REGION_LOCKED, + UI_TIMEOUT, + TELEPORT_TIMEOUT, + UNKNOWN +} + diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTeleportResult.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTeleportResult.java new file mode 100644 index 00000000000..963bd452918 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTeleportResult.java @@ -0,0 +1,80 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +import java.util.EnumSet; +import java.util.Objects; + +import javax.annotation.Nullable; + +public final class LeaguesTeleportResult +{ + private final boolean success; + private final @Nullable LeaguesTeleportFailureReason failureReason; + private final String message; + private final LeaguesRegion target; + private final EnumSet unlockedRegionsSnapshot; + + private LeaguesTeleportResult( + boolean success, + @Nullable LeaguesTeleportFailureReason failureReason, + String message, + LeaguesRegion target, + EnumSet unlockedRegionsSnapshot) + { + this.success = success; + this.failureReason = success + ? null + : (failureReason != null ? failureReason : LeaguesTeleportFailureReason.UNKNOWN); + this.message = message != null ? message : ""; + this.target = target; + this.unlockedRegionsSnapshot = unlockedRegionsSnapshot != null ? EnumSet.copyOf(unlockedRegionsSnapshot) : null; + } + + public static LeaguesTeleportResult ok(LeaguesRegion target, EnumSet unlockedRegionsSnapshot) + { + return new LeaguesTeleportResult(true, null, "", target, unlockedRegionsSnapshot); + } + + public static LeaguesTeleportResult failure( + LeaguesTeleportFailureReason reason, + String message, + LeaguesRegion target, + EnumSet unlockedRegionsSnapshot) + { + Objects.requireNonNull(reason, "reason"); + Objects.requireNonNull(message, "message"); + if (message.isEmpty()) + { + throw new IllegalArgumentException("failure message must not be empty"); + } + return new LeaguesTeleportResult(false, reason, message, target, unlockedRegionsSnapshot); + } + + public boolean isSuccess() + { + return success; + } + + /** + * @return failure reason when {@link #isSuccess()} is {@code false}; {@code null} on success (not {@link LeaguesTeleportFailureReason#UNKNOWN}). + */ + public @Nullable LeaguesTeleportFailureReason getFailureReason() + { + return failureReason; + } + + public String getMessage() + { + return message; + } + + public LeaguesRegion getTarget() + { + return target; + } + + public EnumSet getUnlockedRegionsSnapshot() + { + return unlockedRegionsSnapshot != null ? EnumSet.copyOf(unlockedRegionsSnapshot) : null; + } +} + diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportAttemptSnapshot.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportAttemptSnapshot.java new file mode 100644 index 00000000000..5cf3dc15a80 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportAttemptSnapshot.java @@ -0,0 +1,17 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * Bind chat messages to last attempted transport. Immutable snapshot for the recent-attempt ring. + */ +@Getter +@AllArgsConstructor(access = AccessLevel.PACKAGE) +public final class LeaguesTransportAttemptSnapshot +{ + private final Integer packedDest; + private final String method; + private final long tsMs; +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportAttempts.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportAttempts.java new file mode 100644 index 00000000000..156a99cd97b --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportAttempts.java @@ -0,0 +1,222 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.gameval.VarbitID; +import net.runelite.client.plugins.microbot.Microbot; +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.util.logging.Rs2LogRateLimit; + +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Recent transport attempt ring and method labels for locked-region chat correlation. + */ +@Slf4j +final class LeaguesTransportAttempts +{ + private LeaguesTransportAttempts() + { + } + + private static final int TRANSPORT_ATTEMPT_DISPLAY_INFO_MAX = 256; + + private static volatile LeaguesTransportAttemptSnapshot lastTransportAttempt = null; + + private static final int RECENT_TRANSPORT_ATTEMPTS_MAX = 4; + private static final Object RECENT_TRANSPORT_ATTEMPTS_LOCK = new Object(); + private static final LeaguesTransportAttemptSnapshot[] RECENT_TRANSPORT_ATTEMPTS = + new LeaguesTransportAttemptSnapshot[RECENT_TRANSPORT_ATTEMPTS_MAX]; + private static int recentTransportAttemptsCount = 0; + + private static final String LEAGUES_AREA_ATTEMPT_PREFIX = + TransportType.SEASONAL_TRANSPORT + ":Leagues Area:"; + + private static final AtomicBoolean LOGGED_NULL_SEASONAL_TRANSPORT_TYPE = new AtomicBoolean(false); + + private static boolean isLeaguesActive() + { + return Microbot.getVarbitValue(VarbitID.LEAGUE_TYPE) > 0; + } + + static Integer getLastTransportAttemptPackedDest() + { + LeaguesTransportAttemptSnapshot s = lastTransportAttempt; + return s != null ? s.getPackedDest() : null; + } + + static String getLastTransportAttemptMethod() + { + LeaguesTransportAttemptSnapshot s = lastTransportAttempt; + return s != null ? s.getMethod() : null; + } + + static boolean isLeaguesAreaTeleportPending(long maxAgeMs) + { + if (maxAgeMs < 0) + { + return false; + } + LeaguesTransportAttemptSnapshot s = lastTransportAttempt; + if (s == null) + { + return false; + } + String m = s.getMethod(); + if (m == null || !m.startsWith(LEAGUES_AREA_ATTEMPT_PREFIX)) + { + return false; + } + return System.currentTimeMillis() - s.getTsMs() <= maxAgeMs; + } + + static LeaguesTransportAttemptSnapshot getLastTransportAttemptSnapshot() + { + return lastTransportAttempt; + } + + static void clearLastTransportAttempt() + { + lastTransportAttempt = null; + synchronized (RECENT_TRANSPORT_ATTEMPTS_LOCK) + { + for (int i = 0; i < RECENT_TRANSPORT_ATTEMPTS_MAX; i++) + { + RECENT_TRANSPORT_ATTEMPTS[i] = null; + } + recentTransportAttemptsCount = 0; + } + } + + private static void pushRecentTransportAttempt(LeaguesTransportAttemptSnapshot snap) + { + if (snap == null) + { + return; + } + lastTransportAttempt = snap; + synchronized (RECENT_TRANSPORT_ATTEMPTS_LOCK) + { + for (int i = Math.min(RECENT_TRANSPORT_ATTEMPTS_MAX - 1, recentTransportAttemptsCount); i > 0; i--) + { + RECENT_TRANSPORT_ATTEMPTS[i] = RECENT_TRANSPORT_ATTEMPTS[i - 1]; + } + RECENT_TRANSPORT_ATTEMPTS[0] = snap; + if (recentTransportAttemptsCount < RECENT_TRANSPORT_ATTEMPTS_MAX) + { + recentTransportAttemptsCount++; + } + } + } + + /** + * Same attribution model as {@code 1a5c485}: bind locked-region chat to the single latest transport attempt + * (within {@code maxAgeMs}), not to a filtered ring-buffer entry. Chat supplies the locked shard name when recording; + * the attempt supplies {@code packedDest}. + * + * @param regionCaptured unused (kept for API stability); region text is applied in {@link LeaguesTransportRegions#recordBlockedDestinationFromChat} + */ + static Optional findTransportAttemptForLockedRegionChat( + @SuppressWarnings("unused") String regionCaptured, long nowMs, long maxAgeMs) + { + if (maxAgeMs < 0L) + { + return Optional.empty(); + } + LeaguesTransportAttemptSnapshot s = lastTransportAttempt; + if (s == null) + { + return Optional.empty(); + } + long ageMs = nowMs - s.getTsMs(); + if (ageMs > maxAgeMs || ageMs < 0L) + { + return Optional.empty(); + } + return Optional.of(s); + } + + static void recordTransportAttempt(Transport transport, String attemptHandler) + { + if (transport == null || transport.getDestination() == null) + { + return; + } + if (!isLeaguesActive()) + { + return; + } + + if (transport.getType() == null) + { + if (log.isDebugEnabled() && Rs2LogRateLimit.once(LOGGED_NULL_SEASONAL_TRANSPORT_TYPE)) + { + String di = transport.getDisplayInfo(); + String sample = di == null ? "" : (di.length() > TRANSPORT_ATTEMPT_DISPLAY_INFO_MAX + ? di.substring(0, TRANSPORT_ATTEMPT_DISPLAY_INFO_MAX) + "…" + : di); + Integer packedDest = WorldPointUtil.packWorldPoint(transport.getDestination()); + log.debug("recordTransportAttempt: transport.getType() null; check merged transport / TSV. destPacked={} dest={} displayInfoSample='{}'", + packedDest, transport.getDestination(), sample); + } + Integer packed = WorldPointUtil.packWorldPoint(transport.getDestination()); + pushRecentTransportAttempt(new LeaguesTransportAttemptSnapshot(packed, + withAttemptHandlerSuffix(buildNullTypeAttemptMethodLabel(transport), attemptHandler), + System.currentTimeMillis())); + LeaguesTransportObservations.appendTransportObservationInternal("attempt", transport, null, ""); + return; + } + + TransportType type = transport.getType(); + Integer packed = WorldPointUtil.packWorldPoint(transport.getDestination()); + String method = withAttemptHandlerSuffix(buildTransportAttemptMethodLabel(transport), attemptHandler); + pushRecentTransportAttempt(new LeaguesTransportAttemptSnapshot(packed, method, System.currentTimeMillis())); + + if (type == TransportType.SEASONAL_TRANSPORT) + { + LeaguesTransportObservations.appendTransportObservationInternal("attempt", transport, null, ""); + } + } + + private static String withAttemptHandlerSuffix(String method, String attemptHandler) + { + if (attemptHandler == null || attemptHandler.isEmpty()) + { + return method; + } + return method + "|handler=" + attemptHandler; + } + + private static String buildNullTypeAttemptMethodLabel(Transport transport) + { + String di = transport.getDisplayInfo(); + if (di == null || di.isEmpty()) + { + return "UNKNOWN:"; + } + if (di.length() <= TRANSPORT_ATTEMPT_DISPLAY_INFO_MAX) + { + return "UNKNOWN:" + di; + } + return "UNKNOWN:" + di.substring(0, TRANSPORT_ATTEMPT_DISPLAY_INFO_MAX) + "…h" + + Integer.toHexString(di.hashCode()); + } + + private static String buildTransportAttemptMethodLabel(Transport transport) + { + String prefix = transport.getType() + ":"; + String di = transport.getDisplayInfo(); + if (di == null || di.isEmpty()) + { + return prefix; + } + if (di.length() <= TRANSPORT_ATTEMPT_DISPLAY_INFO_MAX) + { + return prefix + di; + } + return prefix + di.substring(0, TRANSPORT_ATTEMPT_DISPLAY_INFO_MAX) + "…h" + + Integer.toHexString(di.hashCode()); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportChat.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportChat.java new file mode 100644 index 00000000000..0326891a2bf --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportChat.java @@ -0,0 +1,232 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +import com.google.common.annotations.VisibleForTesting; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Client; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.logging.Rs2LogRateLimit; +import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; +import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; + +import java.util.Locale; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Locked-region gamemessage handling for Leagues transport blacklist attribution. + */ +@Slf4j +public final class LeaguesTransportChat +{ + private static final AtomicInteger LEAGUES_LOCK_CHAT_TRUNC_WARN = new AtomicInteger(0); + private static final int LEAGUES_STALE_LOCK_CHAT_INFO_INTERVAL = 50; + private static final AtomicInteger LEAGUES_STALE_LOCK_CHAT_IGNORED = new AtomicInteger(0); + + private static final String LEAGUES_AREA_TOKEN = " area"; + private static final int LEAGUES_LOCK_ATTRIBUTED_INFO_INTERVAL = 25; + private static final AtomicInteger LEAGUES_LOCK_ATTRIBUTED_INFO = new AtomicInteger(0); + private static final int LEAGUES_LOCK_ATTRIBUTED_DEBUG_INTERVAL = 25; + private static final AtomicInteger LEAGUES_LOCK_ATTRIBUTED_DEBUG = new AtomicInteger(0); + private static final int LEAGUES_LOCK_REROUTE_INFO_INTERVAL = 25; + private static final AtomicInteger LEAGUES_LOCK_REROUTE_INFO = new AtomicInteger(0); + + private LeaguesTransportChat() + { + } + + static void onLockedRegionGameMessage(String msg) + { + if (!Rs2LeaguesTransport.isLeaguesActive()) + { + return; + } + if (msg == null) + { + return; + } + boolean hasAccess = msg.contains("access") || msg.contains("Access"); + boolean hasArea = msg.contains(" area") || msg.contains(" Area"); + if (!hasAccess || !hasArea) + { + return; + } + String lower = msg.toLowerCase(Locale.ROOT); + if (!isLeaguesLockedAccessMessageLower(lower)) + { + return; + } + + String rawForMatch = clipLeaguesLockChatRawForMatch(msg); + if (msg.length() > Rs2LeaguesTransport.LEAGUES_LOCK_CHAT_MAX_NORMALIZE_CHARS + && Rs2LogRateLimit.everyN(LEAGUES_LOCK_CHAT_TRUNC_WARN, Rs2LeaguesTransport.LEAGUES_LOCK_CHAT_TRUNC_WARN_INTERVAL)) + { + log.warn("[Leagues] locked-region gamemessage length {} exceeds cap {}; matching on first {} chars only", + msg.length(), Rs2LeaguesTransport.LEAGUES_LOCK_CHAT_MAX_NORMALIZE_CHARS, rawForMatch.length()); + } + + String region = Rs2LeaguesTransport.captureLockedRegionFromChatRaw(rawForMatch).orElse(null); + if (region != null) + { + handleLeaguesLockedRegionMatch(region, rawForMatch); + } + } + + private static void handleLeaguesLockedRegionMatch(String region, String rawForMatch) + { + long nowMs = System.currentTimeMillis(); + Optional snapOpt = Rs2LeaguesTransport.findTransportAttemptForLockedRegionChat( + region, nowMs, Rs2LeaguesTransport.LEAGUES_LOCK_CHAT_MAX_ATTEMPT_AGE_MS); + if (!snapOpt.isPresent()) + { + Rs2Walker.Telemetry.incrementLeaguesLockStale(); + handleLeaguesLockedRegionStale(region, -1); + return; + } + LeaguesTransportAttemptSnapshot snap = snapOpt.get(); + Integer packedDest = snap.getPackedDest(); + String methodSafe = snap.getMethod() != null ? snap.getMethod() : ""; + long ageMs = nowMs - snap.getTsMs(); + + if (packedDest == null || ageMs > Rs2LeaguesTransport.LEAGUES_LOCK_CHAT_MAX_ATTEMPT_AGE_MS) + { + Rs2Walker.Telemetry.incrementLeaguesLockStale(); + handleLeaguesLockedRegionStale(region, ageMs); + Rs2LeaguesTransport.clearLastTransportAttempt(); + return; + } + + boolean willDebug = log.isDebugEnabled() + && Rs2LogRateLimit.everyN(LEAGUES_LOCK_ATTRIBUTED_DEBUG, LEAGUES_LOCK_ATTRIBUTED_DEBUG_INTERVAL); + boolean willInfo = !willDebug + && Rs2LogRateLimit.everyN(LEAGUES_LOCK_ATTRIBUTED_INFO, LEAGUES_LOCK_ATTRIBUTED_INFO_INTERVAL); + if (willDebug || willInfo) + { + var dest = WorldPointUtil.unpackWorldPoint(packedDest); + if (willDebug) + { + log.debug("[Leagues] locked-region rawMsg='{}' region='{}' method='{}' destPacked={} dest={}", + rawForMatch, + region, + methodSafe, + packedDest, + dest); + } + else + { + log.info("[Leagues] locked-region region='{}' method='{}' destPacked={} dest={} (summary every {} msgs)", + region, + methodSafe, + packedDest, + dest, + LEAGUES_LOCK_ATTRIBUTED_INFO_INTERVAL); + } + } + + boolean recorded = Rs2LeaguesTransport.recordBlockedDestinationFromChat( + region, + packedDest, + methodSafe); + if (!recorded) + { + return; + } + + if (Rs2LogRateLimit.everyN(LEAGUES_LOCK_REROUTE_INFO, LEAGUES_LOCK_REROUTE_INFO_INTERVAL)) + { + log.info("[Leagues] reroute: locked region='{}' method='{}' destPacked={} (summary every {} msgs)", + region, methodSafe, packedDest, LEAGUES_LOCK_REROUTE_INFO_INTERVAL); + } + if (!Rs2LeaguesTransport.shouldRecalculatePathAfterLock(region, packedDest)) + { + return; + } + Client client = Microbot.getClient(); + if (client == null) + { + return; + } + if (client.isClientThread()) + { + Rs2Walker.recalculatePath(); + } + else + { + var clientThread = Microbot.getClientThread(); + if (clientThread == null) + { + return; + } + clientThread.invokeLater(Rs2Walker::recalculatePath); + } + } + + private static boolean isLeaguesLockedAccessMessageLower(String lower) + { + if (lower == null) + { + return false; + } + int accessIdx = lower.indexOf("access to the "); + if (accessIdx < 0) + { + return false; + } + if (lower.indexOf(LEAGUES_AREA_TOKEN, accessIdx) < 0) + { + return false; + } + return lower.indexOf("haven't unlocked access") >= 0 + || lower.indexOf("havent unlocked access") >= 0 + || lower.indexOf("don't have access") >= 0 + || lower.indexOf("do not have access") >= 0 + || lower.indexOf("cannot access to the ") >= 0 + || lower.indexOf("cannot access the ") >= 0; + } + + private static void handleLeaguesLockedRegionStale(String region, long ageMs) + { + if (!Rs2LogRateLimit.everyN(LEAGUES_STALE_LOCK_CHAT_IGNORED, LEAGUES_STALE_LOCK_CHAT_INFO_INTERVAL)) + { + return; + } + int n = LEAGUES_STALE_LOCK_CHAT_IGNORED.get(); + String age = ageMs >= 0 ? Long.toString(ageMs) : "unknown"; + log.info("[Leagues] locked-region stale/no-attempt summary: count={} lastRegion='{}' lastAgeMs={}", + n, region, age); + if (log.isDebugEnabled()) + { + log.debug("[Leagues] locked-region msg ignored (stale/no attempt): region='{}' ageMs={}", region, age); + } + } + + private static String clipLeaguesLockChatRawForMatch(String msg) + { + return msg.length() > Rs2LeaguesTransport.LEAGUES_LOCK_CHAT_MAX_NORMALIZE_CHARS + ? msg.substring(0, Rs2LeaguesTransport.LEAGUES_LOCK_CHAT_MAX_NORMALIZE_CHARS) + : msg; + } + + @VisibleForTesting + public static boolean isLeaguesLockedAccessMessage(String msg) + { + if (msg == null) + { + return false; + } + return isLeaguesLockedAccessMessageLower(msg.toLowerCase(Locale.ROOT)); + } + + /** + * First capture after sanitize on {@code rawForMatch}, for unit tests. + */ + @VisibleForTesting + public static String leaguesLockedRegionCapturedRegionAfterNormalizeForTests(String rawForMatch) + { + if (rawForMatch == null) + { + return null; + } + String clipped = clipLeaguesLockChatRawForMatch(rawForMatch); + return Rs2LeaguesTransport.captureLockedRegionFromChatRaw(clipped).orElse(null); + } +} 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 new file mode 100644 index 00000000000..b259fccdade --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportInjection.java @@ -0,0 +1,234 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.TransportType; +import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; +import 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; + +import java.util.EnumSet; +import java.util.HashSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Pathfinder injection for Leagues Area and catalog transports. + */ +final class LeaguesTransportInjection +{ + private LeaguesTransportInjection() + { + } + + private static volatile EnumSet lastInjectedUnlockedForBlacklistPrune = null; + + static void injectLeaguesTransports( + PathfinderConfig pathfinderConfig, + Rs2LeaguesTransport.LeaguesContext ctx, + Set usableTeleports, + Map> transports, + PrimitiveIntHashMap> transportsPacked, + Map typeStats) + { + if (pathfinderConfig == null || ctx == null || !ctx.isActive() || ctx.getUnlockedRegions().isEmpty() + || usableTeleports == null || transports == null || transportsPacked == null || typeStats == null) + { + return; + } + + EnumSet unlockedNow = EnumSet.copyOf(ctx.getUnlockedRegions()); + EnumSet prevUnlocked = lastInjectedUnlockedForBlacklistPrune; + if (prevUnlocked != null) + { + for (LeaguesRegion r : unlockedNow) + { + if (!prevUnlocked.contains(r)) + { + LeaguesTransportPersistence.invalidateBlacklistFor(r); + } + } + } + lastInjectedUnlockedForBlacklistPrune = unlockedNow; + + // Match monolith inject order: calibrate landings before area + catalog inject so cache is ready. + // 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); + } + + private static boolean mergeOriginlessTeleportByBestDuration(Set usableTeleports, Transport candidate) + { + if (candidate == null || candidate.getOrigin() != null || candidate.getDestination() == null) + { + return false; + } + int p = WorldPointUtil.packWorldPoint(candidate.getDestination()); + int minDur = candidate.getDuration(); + for (Transport o : usableTeleports) + { + if (o == null || o.getOrigin() != null || o.getDestination() == null) + { + continue; + } + if (WorldPointUtil.packWorldPoint(o.getDestination()) == p) + { + minDur = Math.min(minDur, o.getDuration()); + } + } + if (candidate.getDuration() > minDur) + { + return false; + } + usableTeleports.removeIf(o -> o != null && o.getOrigin() == null && o.getDestination() != null + && WorldPointUtil.packWorldPoint(o.getDestination()) == p); + return usableTeleports.add(candidate); + } + + private static void injectLeaguesAreaTeleports( + PathfinderConfig pathfinderConfig, + Rs2LeaguesTransport.LeaguesContext ctx, + EnumSet unlockedLeaguesRegions, + Set usableTeleports, + Map typeStats) + { + int before = usableTeleports.size(); + int added = 0; + + for (LeaguesRegion region : unlockedLeaguesRegions) + { + Optional landingOpt = LeaguesTransportPersistence.getCachedRegionLanding(region); + if (!landingOpt.isPresent()) + { + continue; + } + + WorldPoint landing = landingOpt.get(); + Transport t = new Transport( + landing, + "Leagues Area: " + region.getDisplayName(), + TransportType.SEASONAL_TRANSPORT, + true, + 31, + java.util.Collections.emptySet()); + if (!pathfinderConfig.isTransportUsableWithLeaguesContext(t, ctx)) + { + continue; + } + if (mergeOriginlessTeleportByBestDuration(usableTeleports, t)) + { + added++; + } + } + + if (added > 0) + { + int[] stats = typeStats.computeIfAbsent(TransportType.SEASONAL_TRANSPORT, k -> new int[]{0, 0, 0}); + stats[0] += added; + stats[1] += added; + WebWalkLog.leagues("inject_area n={} originless {} -> {}", + added, before, usableTeleports.size()); + } + } + + private static void injectLeaguesCatalogTransports( + PathfinderConfig pathfinderConfig, + Rs2LeaguesTransport.LeaguesContext ctx, + EnumSet unlockedLeaguesRegions, + Set usableTeleports, + Map> transports, + PrimitiveIntHashMap> transportsPacked, + Map typeStats) + { + int beforeOriginless = usableTeleports.size(); + int addedOriginless = 0; + int addedOriginBased = 0; + + java.util.List catalog = LeaguesTransportObservations.loadCatalogTransports(unlockedLeaguesRegions); + for (Transport t : catalog) + { + if (t == null || t.getDestination() == null) + { + continue; + } + + if (!pathfinderConfig.isTransportUsableWithLeaguesContext(t, ctx)) + { + continue; + } + + TransportType tt = t.getType(); + if (t.getOrigin() == null) + { + if (mergeOriginlessTeleportByBestDuration(usableTeleports, t)) + { + addedOriginless++; + if (tt != null) + { + int[] stats = typeStats.computeIfAbsent(tt, k -> new int[]{0, 0, 0}); + stats[0] += 1; + stats[1] += 1; + } + } + } + else + { + transports.computeIfAbsent(t.getOrigin(), k -> new HashSet<>()).add(t); + + int packedOrigin = WorldPointUtil.packWorldPoint(t.getOrigin()); + Set packedSet = transportsPacked.get(packedOrigin); + if (packedSet == null) + { + packedSet = new HashSet<>(); + transportsPacked.put(packedOrigin, packedSet); + } + packedSet.add(t); + addedOriginBased++; + if (tt != null) + { + int[] stats = typeStats.computeIfAbsent(tt, k -> new int[]{0, 0, 0}); + stats[0] += 1; + stats[1] += 1; + } + } + } + + if (addedOriginless + addedOriginBased > 0) + { + WebWalkLog.leagues("inject_catalog n={} originlessAdd={} originAdd={} usable {} -> {}", + addedOriginless + addedOriginBased, + addedOriginless, + addedOriginBased, + beforeOriginless, + usableTeleports.size()); + } + } + + static boolean isTransportAllowed(Rs2LeaguesTransport.LeaguesContext ctx, Transport transport) + { + if (transport == null) + { + return false; + } + if (ctx == null || !ctx.isActive()) + { + return true; + } + WorldPoint dest = transport.getDestination(); + if (dest == null) + { + return transport.getType() != TransportType.SEASONAL_TRANSPORT; + } + int packed = WorldPointUtil.packWorldPoint(dest); + if (LeaguesTransportPersistence.isDestinationBlacklisted(packed)) + { + return false; + } + LeaguesRegion learned = LeaguesTransportPersistence.getBlacklistedDestinationRegionsSnapshot().get(packed); + return learned == null || ctx.getUnlockedRegions().contains(learned); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportLockCatalogue.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportLockCatalogue.java new file mode 100644 index 00000000000..0d3d3c74c29 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportLockCatalogue.java @@ -0,0 +1,34 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +/** + * Dedupe key and method normalization for {@code kind=lock-catalogue} JSONL rows. + */ +final class LeaguesTransportLockCatalogue +{ + private LeaguesTransportLockCatalogue() + { + } + + /** + * Strip optional {@code |handler=...} suffix from attempt method labels. + */ + static String normalizeLockCatalogueMethod(String method) + { + if (method == null || method.isEmpty()) + { + return ""; + } + int idx = method.indexOf("|handler="); + if (idx >= 0) + { + return method.substring(0, idx); + } + return method; + } + + static String buildDedupeKey(int packedDest, String normalizedMethod) + { + String m = normalizedMethod != null ? normalizedMethod : ""; + return packedDest + "|" + m; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportObservations.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportObservations.java new file mode 100644 index 00000000000..507c82a7ea3 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportObservations.java @@ -0,0 +1,680 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.gameval.VarbitID; +import net.runelite.client.plugins.microbot.Microbot; +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 java.io.BufferedReader; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * JSONL observations and catalog file I/O for Leagues transports. + */ +@Slf4j +final class LeaguesTransportObservations +{ + private LeaguesTransportObservations() + { + } + + private static Path observationsFile() + { + return LeaguesTransportPersistence.leaguesVersionDir().resolve("leagues-transport-observations.jsonl"); + } + + private static final int CATALOG_SCHEMA_VERSION = 2; + private static final long LEAGUES_OBS_JSONL_MAX_BYTES = 5L * 1024 * 1024; + private static final int LEAGUES_OBS_JSONL_ROTATION_SLOTS = 3; + + private static Path catalogFile() + { + return LeaguesTransportPersistence.leaguesVersionDir().resolve("leagues-transport-catalog.jsonl"); + } + + private static Path lockCatalogueFile() + { + return LeaguesTransportPersistence.leaguesVersionDir().resolve("leagues-lock-catalogue.jsonl"); + } + + private static final Set LOCK_CATALOGUE_KEYS_SEEN = ConcurrentHashMap.newKeySet(); + /** Serializes bootstrap + dedupe rollback; append body runs outside this lock. */ + private static final Object LOCK_CATALOGUE_BOOTSTRAP_LOCK = new Object(); + private static volatile boolean lockCatalogueKeysBootstrapped = false; + /** When leagues season / catalog version changes, drop in-memory keys so the next append rescans the new file. */ + private static volatile String lockCatalogueKeysBootstrappedForCatalogVersion = null; + private static final int LEAGUES_LOCK_CATALOGUE_BOOTSTRAP_MAX_LINES = 100_000; + + private static Path catalogDir() + { + return LeaguesTransportPersistence.leaguesVersionDir().resolve("leagues-transport-catalog.d"); + } + + private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); + + private static final Map CATALOG_FILE_PARSE_CACHE = new ConcurrentHashMap<>(); + private static final AtomicBoolean LOGGED_OLD_CATALOG_SCHEMA = new AtomicBoolean(false); + + private static final class CatalogFileSnapshot + { + private final long mtimeMs; + private final List rows; + + private CatalogFileSnapshot(long mtimeMs, List rows) + { + this.mtimeMs = mtimeMs; + this.rows = rows; + } + } + + private static final class CatalogParsedRow + { + private final LeaguesRegion required; + private final String dedupeKey; + private final Transport transport; + + private CatalogParsedRow(LeaguesRegion required, String dedupeKey, Transport transport) + { + this.required = required; + this.dedupeKey = dedupeKey; + this.transport = transport; + } + } + + private static boolean isLeaguesActive() + { + return Microbot.getVarbitValue(VarbitID.LEAGUE_TYPE) > 0; + } + + static void maybeRotateLeaguesObservationsJsonl(Path mainFile) + { + try + { + if (!Files.exists(mainFile)) + { + return; + } + long sz = Files.size(mainFile); + if (sz < LEAGUES_OBS_JSONL_MAX_BYTES) + { + return; + } + Path dir = mainFile.getParent(); + if (dir == null) + { + return; + } + String base = mainFile.getFileName().toString(); + int lastSlot = LEAGUES_OBS_JSONL_ROTATION_SLOTS - 1; + if (lastSlot >= 1) + { + Path oldest = dir.resolve(base + "." + lastSlot); + if (Files.exists(oldest)) + { + Files.delete(oldest); + } + } + for (int slot = lastSlot - 1; slot >= 1; slot--) + { + Path from = dir.resolve(base + "." + slot); + Path to = dir.resolve(base + "." + (slot + 1)); + if (Files.exists(from)) + { + Files.move(from, to, StandardCopyOption.REPLACE_EXISTING); + } + } + Path first = dir.resolve(base + ".1"); + Files.move(mainFile, first, StandardCopyOption.REPLACE_EXISTING); + } + catch (IOException e) + { + log.debug("[Leagues] observation JSONL rotate failed: {}", e.getMessage()); + } + } + + static void appendTransportObservationInternal(String phase, Transport transport, Boolean success, String detail) + { + if (phase == null || transport == null) + { + return; + } + if (transport.getType() != TransportType.SEASONAL_TRANSPORT) + { + return; + } + if (success == null && !"attempt".equals(phase)) + { + throw new IllegalArgumentException("appendTransportObservation: outcome required unless phase=attempt"); + } + + final Map blockedDestRegionsSnapshot = + LeaguesTransportPersistence.getBlacklistedDestinationRegionsSnapshot(); + + try + { + Path file = observationsFile(); + Files.createDirectories(file.getParent()); + maybeRotateLeaguesObservationsJsonl(file); + + JsonObject obj = new JsonObject(); + obj.addProperty("kind", "transport-observation"); + obj.addProperty("phase", phase); + obj.addProperty("tsMs", System.currentTimeMillis()); + obj.addProperty("catalogVersion", LeaguesTransportPersistence.leaguesCatalogVersion()); + obj.addProperty("leaguesActive", isLeaguesActive()); + if (success != null) + { + obj.addProperty("success", success); + } + if (detail != null && !detail.isEmpty()) + { + obj.addProperty("detail", detail); + } + + TransportType type = transport.getType(); + obj.addProperty("transportType", type != null ? type.name() : ""); + obj.addProperty("displayInfo", transport.getDisplayInfo() != null ? transport.getDisplayInfo() : ""); + obj.addProperty("action", transport.getAction() != null ? transport.getAction() : ""); + obj.addProperty("name", transport.getName() != null ? transport.getName() : ""); + obj.addProperty("objectId", transport.getObjectId()); + obj.addProperty("members", transport.isMembers()); + + WorldPoint origin = transport.getOrigin(); + WorldPoint dest = transport.getDestination(); + if (origin != null) + { + JsonObject o = new JsonObject(); + o.addProperty("x", origin.getX()); + o.addProperty("y", origin.getY()); + o.addProperty("p", origin.getPlane()); + obj.add("origin", o); + } + if (dest != null) + { + JsonObject d = new JsonObject(); + d.addProperty("x", dest.getX()); + d.addProperty("y", dest.getY()); + d.addProperty("p", dest.getPlane()); + obj.add("destination", d); + + int packed = WorldPointUtil.packWorldPoint(dest); + obj.addProperty("destPacked", packed); + + LeaguesRegion learned = blockedDestRegionsSnapshot.get(packed); + if (learned != null) + { + obj.addProperty("learnedRegion", learned.name()); + } + } + + try (Writer w = new OutputStreamWriter(Files.newOutputStream( + file, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.APPEND), StandardCharsets.UTF_8)) + { + w.write(GSON.toJson(obj)); + w.write("\n"); + } + } + catch (Exception e) + { + log.debug("[Leagues] observation append failed: {}", e.getMessage()); + } + } + + static void appendCatalogTransport(LeaguesRegion requiredRegion, Transport transport, String note) + { + if (requiredRegion == null || transport == null || transport.getDestination() == null) + { + return; + } + + try + { + Path file = catalogFile(); + Files.createDirectories(file.getParent()); + + JsonObject obj = new JsonObject(); + obj.addProperty("kind", "catalog-transport"); + obj.addProperty("catalogVersion", LeaguesTransportPersistence.leaguesCatalogVersion()); + obj.addProperty("schema", CATALOG_SCHEMA_VERSION); + obj.addProperty("tsMs", System.currentTimeMillis()); + obj.addProperty("requiredRegion", requiredRegion.name()); + obj.addProperty("transportType", transport.getType() != null ? transport.getType().name() : ""); + obj.addProperty("displayInfo", transport.getDisplayInfo() != null ? transport.getDisplayInfo() : ""); + obj.addProperty("action", transport.getAction() != null ? transport.getAction() : ""); + obj.addProperty("name", transport.getName() != null ? transport.getName() : ""); + obj.addProperty("objectId", transport.getObjectId()); + obj.addProperty("members", transport.isMembers()); + if (note != null && !note.isEmpty()) + { + obj.addProperty("note", note); + } + + WorldPoint origin = transport.getOrigin(); + WorldPoint dest = transport.getDestination(); + if (origin != null) + { + JsonObject o = new JsonObject(); + o.addProperty("x", origin.getX()); + o.addProperty("y", origin.getY()); + o.addProperty("p", origin.getPlane()); + obj.add("origin", o); + } + JsonObject d = new JsonObject(); + d.addProperty("x", dest.getX()); + d.addProperty("y", dest.getY()); + d.addProperty("p", dest.getPlane()); + obj.add("destination", d); + + try (Writer w = new OutputStreamWriter(Files.newOutputStream( + file, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.APPEND), StandardCharsets.UTF_8)) + { + w.write(GSON.toJson(obj)); + w.write("\n"); + } + } + catch (Exception e) + { + log.debug("[Leagues] catalog append failed: {}", e.getMessage()); + } + } + + private static void bootstrapLockCatalogueKeysFromDiskIfNeeded() + { + String catalogVersion = LeaguesTransportPersistence.leaguesCatalogVersion(); + if (lockCatalogueKeysBootstrapped + && lockCatalogueKeysBootstrappedForCatalogVersion != null + && !catalogVersion.equals(lockCatalogueKeysBootstrappedForCatalogVersion)) + { + synchronized (LOCK_CATALOGUE_BOOTSTRAP_LOCK) + { + if (lockCatalogueKeysBootstrapped + && lockCatalogueKeysBootstrappedForCatalogVersion != null + && !catalogVersion.equals(lockCatalogueKeysBootstrappedForCatalogVersion)) + { + LOCK_CATALOGUE_KEYS_SEEN.clear(); + lockCatalogueKeysBootstrapped = false; + lockCatalogueKeysBootstrappedForCatalogVersion = null; + } + } + } + if (lockCatalogueKeysBootstrapped) + { + return; + } + synchronized (LOCK_CATALOGUE_BOOTSTRAP_LOCK) + { + if (lockCatalogueKeysBootstrapped) + { + return; + } + Path file = lockCatalogueFile(); + if (Files.exists(file)) + { + try (BufferedReader br = Files.newBufferedReader(file, StandardCharsets.UTF_8)) + { + String line; + int lineNum = 0; + while ((line = br.readLine()) != null) + { + lineNum++; + if (lineNum > LEAGUES_LOCK_CATALOGUE_BOOTSTRAP_MAX_LINES) + { + log.warn("[Leagues] lock-catalogue bootstrap stopped after {} lines (max={}); dedupe may miss older keys", + lineNum, LEAGUES_LOCK_CATALOGUE_BOOTSTRAP_MAX_LINES); + break; + } + line = line.trim(); + if (line.isEmpty()) + { + continue; + } + JsonObject obj; + try + { + obj = GSON.fromJson(line, JsonObject.class); + } + catch (Exception ignored) + { + continue; + } + if (obj != null + && obj.has("kind") + && "lock-catalogue".equals(obj.get("kind").getAsString()) + && obj.has("k")) + { + try + { + LOCK_CATALOGUE_KEYS_SEEN.add(obj.get("k").getAsString()); + } + catch (Exception ignored) + { + } + } + } + } + catch (IOException e) + { + log.debug("[Leagues] lock-catalogue bootstrap read failed: {}", e.getMessage()); + } + } + lockCatalogueKeysBootstrapped = true; + lockCatalogueKeysBootstrappedForCatalogVersion = catalogVersion; + } + } + + /** + * Append one {@code kind=lock-catalogue} JSONL row; skips when dedupe key {@code k} already exists on disk or in-memory set. + */ + static void appendLockCatalogueEntry(LeaguesRegion region, int packedDest, String methodRaw) + { + if (region == null) + { + return; + } + bootstrapLockCatalogueKeysFromDiskIfNeeded(); + String normalizedMethod = LeaguesTransportLockCatalogue.normalizeLockCatalogueMethod(methodRaw); + String k = LeaguesTransportLockCatalogue.buildDedupeKey(packedDest, normalizedMethod); + synchronized (LOCK_CATALOGUE_BOOTSTRAP_LOCK) + { + if (!LOCK_CATALOGUE_KEYS_SEEN.add(k)) + { + return; + } + } + + try + { + Path file = lockCatalogueFile(); + Files.createDirectories(file.getParent()); + + JsonObject obj = new JsonObject(); + obj.addProperty("kind", "lock-catalogue"); + obj.addProperty("catalogVersion", LeaguesTransportPersistence.leaguesCatalogVersion()); + obj.addProperty("r", region.name()); + obj.addProperty("d", packedDest); + obj.addProperty("t", System.currentTimeMillis()); + obj.addProperty("m", normalizedMethod); + obj.addProperty("k", k); + + try (Writer w = new OutputStreamWriter(Files.newOutputStream( + file, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.APPEND), StandardCharsets.UTF_8)) + { + w.write(GSON.toJson(obj)); + w.write("\n"); + } + } + catch (Exception e) + { + synchronized (LOCK_CATALOGUE_BOOTSTRAP_LOCK) + { + LOCK_CATALOGUE_KEYS_SEEN.remove(k); + } + log.debug("[Leagues] lock-catalogue append failed: {}", e.getMessage()); + } + } + + static List loadCatalogTransports(Set unlockedRegions) + { + if (unlockedRegions == null || unlockedRegions.isEmpty()) + { + return Collections.emptyList(); + } + + List out = new ArrayList<>(); + Set seenKeys = new HashSet<>(); + + List sources = new ArrayList<>(); + Path file = catalogFile(); + Path dir = catalogDir(); + sources.add(file); + + try + { + if (Files.isDirectory(dir)) + { + try (DirectoryStream ds = Files.newDirectoryStream(dir, "*.jsonl")) + { + for (Path p : ds) + { + sources.add(p); + } + } + } + } + catch (Exception ignored) + { + } + + for (Path source : sources) + { + if (source == null || !Files.exists(source)) + { + continue; + } + for (CatalogParsedRow row : loadCatalogFileRowsCached(source)) + { + if (!unlockedRegions.contains(row.required)) + { + continue; + } + if (!seenKeys.add(row.dedupeKey)) + { + continue; + } + out.add(row.transport); + } + } + + return out; + } + + private static List loadCatalogFileRowsCached(Path source) + { + if (source == null || !Files.exists(source)) + { + return Collections.emptyList(); + } + try + { + long mtime = Files.getLastModifiedTime(source).toMillis(); + String cacheKey = source.toAbsolutePath().normalize().toString(); + CatalogFileSnapshot snap = CATALOG_FILE_PARSE_CACHE.get(cacheKey); + if (snap != null && snap.mtimeMs == mtime) + { + return snap.rows; + } + List rows = parseCatalogFileRows(source); + CATALOG_FILE_PARSE_CACHE.put(cacheKey, new CatalogFileSnapshot(mtime, rows)); + return rows; + } + catch (IOException e) + { + return Collections.emptyList(); + } + } + + private static List parseCatalogFileRows(Path source) + { + List out = new ArrayList<>(); + try (BufferedReader br = Files.newBufferedReader(source, StandardCharsets.UTF_8)) + { + String line; + while ((line = br.readLine()) != null) + { + line = line.trim(); + if (line.isEmpty()) + { + continue; + } + + JsonObject obj; + try + { + obj = GSON.fromJson(line, JsonObject.class); + } + catch (Exception ignored) + { + continue; + } + + if (obj == null || !obj.has("kind") || !"catalog-transport".equals(obj.get("kind").getAsString())) + { + continue; + } + + int schema = 0; + try + { + schema = obj.has("schema") ? obj.get("schema").getAsInt() : 0; + } + catch (Exception ignored) + { + schema = 0; + } + if (schema != CATALOG_SCHEMA_VERSION) + { + if (LOGGED_OLD_CATALOG_SCHEMA.compareAndSet(false, true)) + { + log.info("[Leagues] ignoring old catalog schema (expected {}, got {})", CATALOG_SCHEMA_VERSION, schema); + } + continue; + } + + String req = obj.has("requiredRegion") ? obj.get("requiredRegion").getAsString() : ""; + LeaguesRegion required; + try + { + required = req != null && !req.isEmpty() ? LeaguesRegion.valueOf(req) : null; + } + catch (Exception e) + { + required = null; + } + if (required == null) + { + continue; + } + + String typeRaw = obj.has("transportType") ? obj.get("transportType").getAsString() : ""; + TransportType type; + try + { + type = typeRaw != null && !typeRaw.isEmpty() ? TransportType.valueOf(typeRaw) : null; + } + catch (Exception e) + { + type = null; + } + if (type == null) + { + continue; + } + + WorldPoint dest = parsePoint( + obj.has("destination") && obj.get("destination").isJsonObject() + ? obj.getAsJsonObject("destination") + : null); + if (dest == null) + { + continue; + } + WorldPoint origin = parsePoint( + obj.has("origin") && obj.get("origin").isJsonObject() + ? obj.getAsJsonObject("origin") + : null); + + String displayInfo = obj.has("displayInfo") ? obj.get("displayInfo").getAsString() : ""; + boolean members = obj.has("members") && obj.get("members").getAsBoolean(); + String action = obj.has("action") ? obj.get("action").getAsString() : ""; + String name = obj.has("name") ? obj.get("name").getAsString() : ""; + int objectId = obj.has("objectId") ? obj.get("objectId").getAsInt() : -1; + + String key = required.name() + "|" + type.name() + "|" + + (origin != null ? WorldPointUtil.packWorldPoint(origin) : 0) + "|" + + WorldPointUtil.packWorldPoint(dest) + "|" + + displayInfo + "|" + action + "|" + objectId; + + Transport t; + if (origin == null) + { + t = new Transport(dest, displayInfo, type, members, 31, (Set>) null); + } + else if (objectId > 0 && action != null && !action.isEmpty()) + { + t = new Transport(origin, dest, displayInfo, type, members, action, name, objectId); + } + else + { + t = new Transport(origin, dest, displayInfo, type, members, 1); + } + + out.add(new CatalogParsedRow(required, key, t)); + } + } + catch (Exception ignored) + { + } + return out; + } + + private static final int PARSE_POINT_COORD_MAX_EXCLUSIVE = 16384; + private static final int PARSE_POINT_PLANE_MAX_INCLUSIVE = 3; + + private static WorldPoint parsePoint(JsonObject obj) + { + if (obj == null) + { + return null; + } + try + { + int x = obj.get("x").getAsInt(); + int y = obj.get("y").getAsInt(); + int p = obj.get("p").getAsInt(); + if (x < 0 || x >= PARSE_POINT_COORD_MAX_EXCLUSIVE + || y < 0 || y >= PARSE_POINT_COORD_MAX_EXCLUSIVE + || p < 0 || p > PARSE_POINT_PLANE_MAX_INCLUSIVE) + { + log.warn("[Leagues] catalog parsePoint out of bounds x={} y={} p={}", x, y, p); + return null; + } + return new WorldPoint(x, y, p); + } + catch (Exception e) + { + return null; + } + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportPersistence.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportPersistence.java new file mode 100644 index 00000000000..774f5189690 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportPersistence.java @@ -0,0 +1,732 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.coords.WorldPoint; +import net.runelite.client.config.ConfigManager; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.api.gameval.VarbitID; + +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Share-file persistence for Leagues blacklist, region landings, and calibration consent flags. + */ +@Slf4j +final class LeaguesTransportPersistence +{ + private LeaguesTransportPersistence() + { + } + + static final String PERSIST_GROUP = net.runelite.client.plugins.microbot.MicrobotConfig.configGroup; + static final String KEY_BLOCKED_DESTS = "leaguesBlockedDestinations"; + static final String KEY_BLOCKED_DEST_REGIONS = "leaguesBlockedDestinationRegions"; + static final String KEY_BLOCKED_DEST_METHODS = "leaguesBlockedDestinationMethods"; + static final String KEY_REGION_LANDINGS = "leaguesAreaTeleportLandings"; + static final String KEY_CALIBRATION_CONSENT = "leaguesCalibrationConsent"; + static final String KEY_PROFILE_PURGE_MARKER = "leaguesProfilePersistencePurged"; + + private static final Map CATALOG_VERSION_BY_MAJOR = new HashMap<>(); + private static final Object PATH_SNAPSHOT_LOCK = new Object(); + private static final Object SHARE_FILE_IO_LOCK = new Object(); + private static volatile String cachedCatalogVersion; + private static volatile Path cachedVersionDir; + private static volatile Path cachedShareFile; + + static + { + CATALOG_VERSION_BY_MAJOR.put(6, "6.0.0"); + } + + private static int leaguesMajor() + { + int v = Microbot.getVarbitValue(VarbitID.LEAGUE_TYPE); + return v > 0 ? v : 0; + } + + private static void ensurePathSnapshot() + { + if (cachedShareFile != null) + { + return; + } + synchronized (PATH_SNAPSHOT_LOCK) + { + if (cachedShareFile != null) + { + return; + } + int major = leaguesMajor(); + String curated = CATALOG_VERSION_BY_MAJOR.get(major); + String version = curated != null ? curated : major + ".0.0"; + Path dir = Path.of( + System.getProperty("user.home"), + ".runelite", + "microbot", + "leagues-transport", + "v" + version); + cachedCatalogVersion = version; + cachedVersionDir = dir; + cachedShareFile = dir.resolve("leagues-transport-cache.properties"); + } + } + + static String leaguesCatalogVersion() + { + ensurePathSnapshot(); + return cachedCatalogVersion; + } + + static Path leaguesVersionDir() + { + ensurePathSnapshot(); + return cachedVersionDir; + } + + private static Path shareFile() + { + ensurePathSnapshot(); + return cachedShareFile; + } + + private static final Set PERSIST_BLOCKED_DESTS = ConcurrentHashMap.newKeySet(); + private static final Map PERSIST_BLOCKED_DEST_REGIONS = new ConcurrentHashMap<>(); + private static final Map PERSIST_BLOCKED_DEST_METHODS = new ConcurrentHashMap<>(); + private static volatile boolean persistLoaded = false; + private static final Map PERSIST_REGION_LANDINGS = new ConcurrentHashMap<>(); + + private static final AtomicBoolean CALIBRATION_CONSENT_ALLOWED = new AtomicBoolean(false); + private static final AtomicBoolean CALIBRATION_CONSENT_DENIED = new AtomicBoolean(false); + private static final AtomicBoolean CALIBRATION_CONSENT_PROMPT_QUEUED = new AtomicBoolean(false); + private static final AtomicLong CALIBRATION_CONSENT_RETRY_AFTER_MS = new AtomicLong(0L); + + private static final AtomicBoolean PROFILE_KEYS_PURGED = new AtomicBoolean(false); + + static void ensureLoaded() + { + if (persistLoaded) + { + return; + } + synchronized (LeaguesTransportPersistence.class) + { + if (persistLoaded) + { + return; + } + loadFromShareFile(); + maybePurgeLegacyProfileKeys(); + persistLoaded = true; + } + } + + static boolean isCalibrationConsentAllowed() + { + return CALIBRATION_CONSENT_ALLOWED.get(); + } + + static boolean isCalibrationConsentDenied() + { + return CALIBRATION_CONSENT_DENIED.get(); + } + + static boolean compareAndSetCalibrationConsentPromptQueued(boolean expect, boolean update) + { + return CALIBRATION_CONSENT_PROMPT_QUEUED.compareAndSet(expect, update); + } + + static void setCalibrationConsentRetryAfterMs(long ms) + { + CALIBRATION_CONSENT_RETRY_AFTER_MS.set(ms); + } + + static long getCalibrationConsentRetryAfterMs() + { + return CALIBRATION_CONSENT_RETRY_AFTER_MS.get(); + } + + static void setCalibrationConsentAllowed(boolean allowed) + { + CALIBRATION_CONSENT_ALLOWED.set(allowed); + } + + static void setCalibrationConsentDenied(boolean denied) + { + CALIBRATION_CONSENT_DENIED.set(denied); + } + + static void setCalibrationConsentPromptQueued(boolean queued) + { + CALIBRATION_CONSENT_PROMPT_QUEUED.set(queued); + } + + static void resetCalibrationConsentPromptStateOnLogout() + { + CALIBRATION_CONSENT_PROMPT_QUEUED.set(false); + CALIBRATION_CONSENT_RETRY_AFTER_MS.set(0L); + } + + static boolean hasRegionLanding(LeaguesRegion region) + { + Objects.requireNonNull(region, "region"); + ensureLoaded(); + return PERSIST_REGION_LANDINGS.containsKey(region); + } + + static Map copyRegionLandingsSnapshot() + { + ensureLoaded(); + return new HashMap<>(PERSIST_REGION_LANDINGS); + } + + static boolean isDestinationBlacklisted(int packedWorldPoint) + { + ensureLoaded(); + return PERSIST_BLOCKED_DESTS.contains(packedWorldPoint); + } + + static void invalidateBlacklistFor(LeaguesRegion region) + { + Objects.requireNonNull(region, "region"); + ensureLoaded(); + ArrayList drop = new ArrayList<>(); + for (Map.Entry e : PERSIST_BLOCKED_DEST_REGIONS.entrySet()) + { + if (region.equals(e.getValue())) + { + drop.add(e.getKey()); + } + } + if (drop.isEmpty()) + { + return; + } + for (Integer packed : drop) + { + PERSIST_BLOCKED_DEST_REGIONS.remove(packed); + PERSIST_BLOCKED_DESTS.remove(packed); + PERSIST_BLOCKED_DEST_METHODS.remove(packed); + } + flush(); + } + + static Map getBlacklistedDestinationRegionsSnapshot() + { + ensureLoaded(); + return Collections.unmodifiableMap(new HashMap<>(PERSIST_BLOCKED_DEST_REGIONS)); + } + + static void persistBlacklistDestination(int packedWorldPoint, LeaguesRegion region, String method) + { + if (packedWorldPoint == 0) + { + return; + } + ensureLoaded(); + PERSIST_BLOCKED_DESTS.add(packedWorldPoint); + if (region != null) + { + PERSIST_BLOCKED_DEST_REGIONS.put(packedWorldPoint, region); + } + if (method != null && !method.isEmpty()) + { + PERSIST_BLOCKED_DEST_METHODS.put(packedWorldPoint, method); + } + flush(); + } + + static Optional getCachedRegionLanding(LeaguesRegion region) + { + Objects.requireNonNull(region, "region"); + ensureLoaded(); + WorldPoint landing = PERSIST_REGION_LANDINGS.get(region); + if (landing == null) + { + return Optional.empty(); + } + return Optional.of(landing); + } + + static void persistRegionLanding(LeaguesRegion region, WorldPoint landing) + { + Objects.requireNonNull(region, "region"); + if (landing == null) + { + return; + } + ensureLoaded(); + PERSIST_REGION_LANDINGS.put(region, landing); + flush(); + } + + static void flush() + { + if (!maybePurgeLegacyProfileKeys()) + { + writeShareFile(); + } + } + + private static boolean maybePurgeLegacyProfileKeys() + { + if (PROFILE_KEYS_PURGED.get()) + { + return false; + } + try + { + if (isShareFileMarkerSet(KEY_PROFILE_PURGE_MARKER)) + { + PROFILE_KEYS_PURGED.set(true); + return false; + } + } + catch (Exception e) + { + log.debug("[Leagues] purge marker probe failed: {}", e.toString()); + } + + ConfigManager cm = Microbot.getConfigManager(); + if (cm == null) + { + return false; + } + if (!PROFILE_KEYS_PURGED.compareAndSet(false, true)) + { + return false; + } + + try + { + cm.unsetConfiguration(PERSIST_GROUP, KEY_BLOCKED_DESTS); + cm.unsetConfiguration(PERSIST_GROUP, KEY_BLOCKED_DEST_REGIONS); + cm.unsetConfiguration(PERSIST_GROUP, KEY_BLOCKED_DEST_METHODS); + cm.unsetConfiguration(PERSIST_GROUP, KEY_REGION_LANDINGS); + writeShareFile(); + return true; + } + catch (Exception e) + { + PROFILE_KEYS_PURGED.set(false); + log.debug("[Leagues] legacy profile purge failed (will retry): {}", e.toString()); + return false; + } + } + + private static boolean isShareFileMarkerSet(String markerKey) + { + if (markerKey == null || markerKey.isEmpty()) + { + return false; + } + try + { + Path file = shareFile(); + if (!Files.exists(file)) + { + return false; + } + try (BufferedReader br = Files.newBufferedReader(file, StandardCharsets.UTF_8)) + { + String rawLine; + while ((rawLine = br.readLine()) != null) + { + String line = rawLine.trim(); + if (line.isEmpty() || line.startsWith("#")) + { + continue; + } + int eq = line.indexOf('='); + if (eq <= 0) + { + continue; + } + String key = line.substring(0, eq).trim(); + if (!markerKey.equals(key)) + { + continue; + } + String val = line.substring(eq + 1).trim(); + return "true".equalsIgnoreCase(val) || "1".equals(val); + } + } + } + catch (Exception e) + { + log.debug("[Leagues] share marker read failed key={} type={} msg={}", + markerKey, e.getClass().getName(), e.getMessage()); + } + return false; + } + + private static void loadFromShareFile() + { + try + { + Path file = shareFile(); + if (!Files.exists(file)) + { + return; + } + try (BufferedReader br = Files.newBufferedReader(file, StandardCharsets.UTF_8)) + { + String rawLine; + while ((rawLine = br.readLine()) != null) + { + String line = rawLine != null ? rawLine.trim() : ""; + if (line.isEmpty() || line.startsWith("#")) + { + continue; + } + int eq = line.indexOf('='); + if (eq <= 0 || eq >= line.length() - 1) + { + log.debug("[Leagues] share file malformed line (no '=' value)"); + continue; + } + String key = line.substring(0, eq).trim(); + String val = line.substring(eq + 1).trim(); + if (key.equals(KEY_BLOCKED_DESTS)) + { + loadCsvInts(val, PERSIST_BLOCKED_DESTS); + } + else if (key.equals(KEY_BLOCKED_DEST_REGIONS)) + { + loadDestRegionMap(val, PERSIST_BLOCKED_DEST_REGIONS); + } + else if (key.equals(KEY_BLOCKED_DEST_METHODS)) + { + loadDestStringMap(val, PERSIST_BLOCKED_DEST_METHODS); + } + else if (key.equals(KEY_REGION_LANDINGS)) + { + loadRegionLandings(val, PERSIST_REGION_LANDINGS); + } + else if (key.equals(KEY_CALIBRATION_CONSENT)) + { + if ("allowed".equalsIgnoreCase(val)) + { + CALIBRATION_CONSENT_ALLOWED.set(true); + CALIBRATION_CONSENT_DENIED.set(false); + } + else if ("denied".equalsIgnoreCase(val)) + { + CALIBRATION_CONSENT_DENIED.set(true); + CALIBRATION_CONSENT_ALLOWED.set(false); + } + else + { + CALIBRATION_CONSENT_ALLOWED.set(false); + CALIBRATION_CONSENT_DENIED.set(false); + } + } + else + { + log.debug("[Leagues] share file ignored key={}", key); + } + } + } + } + catch (IOException e) + { + log.debug("[Leagues] share file read failed path={}: {}", shareFile(), e.getMessage()); + } + } + + private static void writeShareFile() + { + synchronized (SHARE_FILE_IO_LOCK) + { + Path tmp = null; + try + { + Path file = shareFile(); + Files.createDirectories(file.getParent()); + String consentLine = ""; + if (CALIBRATION_CONSENT_ALLOWED.get() || CALIBRATION_CONSENT_DENIED.get()) + { + consentLine = KEY_CALIBRATION_CONSENT + "=" + + (CALIBRATION_CONSENT_ALLOWED.get() ? "allowed" : "denied") + + "\n"; + } + String content = "" + + "# Microbot Leagues transport cache (shareable)\n" + + "# Copy between machines/profiles to share learned data.\n" + + "# catalogVersion=" + leaguesCatalogVersion() + "\n" + + KEY_PROFILE_PURGE_MARKER + "=true\n" + + KEY_BLOCKED_DESTS + "=" + joinCsvInts(PERSIST_BLOCKED_DESTS) + "\n" + + KEY_BLOCKED_DEST_REGIONS + "=" + joinDestRegionMap(PERSIST_BLOCKED_DEST_REGIONS) + "\n" + + KEY_BLOCKED_DEST_METHODS + "=" + joinDestStringMap(PERSIST_BLOCKED_DEST_METHODS) + "\n" + + KEY_REGION_LANDINGS + "=" + joinRegionLandings(PERSIST_REGION_LANDINGS) + "\n" + + consentLine; + + tmp = Files.createTempFile(file.getParent(), file.getFileName().toString() + ".", ".tmp"); + Files.writeString(tmp, content, StandardCharsets.UTF_8); + Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } + catch (Exception e) + { + if (tmp != null) + { + try + { + Files.deleteIfExists(tmp); + } + catch (Exception ignored) + { + } + } + log.debug("[Leagues] share file write failed path={} type={} msg={}", + shareFile(), e.getClass().getName(), e.getMessage()); + } + } + } + + private static void loadCsvInts(String raw, Set out) + { + if (raw == null || raw.isEmpty()) + { + return; + } + String[] parts = raw.split(","); + for (int i = 0; i < parts.length; i++) + { + String p = parts[i].trim(); + if (p.isEmpty()) + { + continue; + } + try + { + out.add(Integer.parseInt(p)); + } + catch (NumberFormatException ignored) + { + } + } + } + + private static String joinCsvInts(Set values) + { + if (values.isEmpty()) + { + return ""; + } + StringBuilder sb = new StringBuilder(values.size() * 6); + boolean first = true; + for (Integer v : values) + { + if (v == null) + { + continue; + } + if (!first) + { + sb.append(','); + } + first = false; + sb.append(v); + } + return sb.toString(); + } + + private static void loadDestRegionMap(String raw, Map out) + { + if (raw == null || raw.isEmpty()) + { + return; + } + String[] entries = raw.split(";"); + for (int i = 0; i < entries.length; i++) + { + String e = entries[i].trim(); + if (e.isEmpty()) + { + continue; + } + int eq = e.indexOf('='); + if (eq <= 0 || eq >= e.length() - 1) + { + continue; + } + try + { + int packed = Integer.parseInt(e.substring(0, eq).trim()); + String regionName = e.substring(eq + 1).trim(); + LeaguesRegion region = LeaguesRegion.valueOf(regionName); + out.put(packed, region); + } + catch (Exception ignored) + { + } + } + } + + private static String joinDestRegionMap(Map map) + { + if (map.isEmpty()) + { + return ""; + } + StringBuilder sb = new StringBuilder(map.size() * 10); + boolean first = true; + for (Map.Entry e : map.entrySet()) + { + if (e.getKey() == null || e.getValue() == null) + { + continue; + } + if (!first) + { + sb.append(';'); + } + first = false; + sb.append(e.getKey()).append('=').append(e.getValue().name()); + } + return sb.toString(); + } + + private static void loadDestStringMap(String raw, Map out) + { + if (raw == null || raw.isEmpty()) + { + return; + } + String[] entries = raw.split(";"); + for (int i = 0; i < entries.length; i++) + { + String e = entries[i].trim(); + if (e.isEmpty()) + { + continue; + } + int eq = e.indexOf('='); + if (eq <= 0 || eq >= e.length() - 1) + { + continue; + } + try + { + int packed = Integer.parseInt(e.substring(0, eq).trim()); + String val = e.substring(eq + 1).trim(); + out.put(packed, val); + } + catch (Exception ignored) + { + } + } + } + + private static String joinDestStringMap(Map map) + { + if (map.isEmpty()) + { + return ""; + } + StringBuilder sb = new StringBuilder(map.size() * 12); + boolean first = true; + for (Map.Entry e : map.entrySet()) + { + Integer k = e.getKey(); + String v = e.getValue(); + if (k == null || v == null || v.isEmpty()) + { + continue; + } + String safe = v.replace(";", " ").replace("=", " ").trim(); + if (safe.isEmpty()) + { + continue; + } + if (!first) + { + sb.append(';'); + } + first = false; + sb.append(k).append('=').append(safe); + } + return sb.toString(); + } + + private static void loadRegionLandings(String raw, Map out) + { + if (raw == null || raw.isEmpty()) + { + return; + } + String[] entries = raw.split(";"); + for (int i = 0; i < entries.length; i++) + { + String e = entries[i].trim(); + if (e.isEmpty()) + { + continue; + } + int eq = e.indexOf('='); + if (eq <= 0 || eq >= e.length() - 1) + { + continue; + } + try + { + LeaguesRegion r = LeaguesRegion.valueOf(e.substring(0, eq).trim()); + String[] parts = e.substring(eq + 1).trim().split("\\s+"); + if (parts.length != 3) + { + continue; + } + int x = Integer.parseInt(parts[0]); + int y = Integer.parseInt(parts[1]); + int p = Integer.parseInt(parts[2]); + out.put(r, new WorldPoint(x, y, p)); + } + catch (Exception ignored) + { + } + } + } + + private static String joinRegionLandings(Map map) + { + if (map.isEmpty()) + { + return ""; + } + StringBuilder sb = new StringBuilder(map.size() * 18); + boolean first = true; + for (Map.Entry e : map.entrySet()) + { + LeaguesRegion r = e.getKey(); + WorldPoint wp = e.getValue(); + if (r == null || wp == null) + { + continue; + } + if (!first) + { + sb.append(';'); + } + first = false; + sb.append(r.name()).append('=') + .append(wp.getX()).append(' ') + .append(wp.getY()).append(' ') + .append(wp.getPlane()); + } + return sb.toString(); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportRegions.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportRegions.java new file mode 100644 index 00000000000..fc042d20085 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportRegions.java @@ -0,0 +1,273 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.client.plugins.microbot.util.logging.Rs2LogRateLimit; +import net.runelite.client.plugins.microbot.util.text.Rs2TextSanitizer; +import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; + +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.regex.Pattern; + +/** + * Leagues region parsing and locked-region chat handling. + */ +@Slf4j +final class LeaguesTransportRegions +{ + private LeaguesTransportRegions() + { + } + + static final Pattern LEAGUES_AREA_PREFIX = Pattern.compile("(?i)leagues\\s*area:\\s*"); + static final Pattern LEAGUES_LOCKED_REGION_CHAT = Pattern.compile( + "(?:" + + "haven[''\\u2019\\u2018\\u02BC\\u2032`]*t\\s+unlocked\\s+access\\s+to\\s+the" + + "|don[''\\u2019\\u2018\\u02BC\\u2032`]*t\\s+have\\s+access\\s+to\\s+the" + + "|do\\s+not\\s+have\\s+access\\s+to\\s+the" + + "|can(?:not|[''\\u2019\\u2018\\u02BC\\u2032`]t)\\s+access(?:\\s+to)?\\s+the" + + ")\\s+(.+)\\s+area(?:\\s*\\([^)]+\\))?(?:\\s+\\p{Alnum}+)*\\s*[\\p{Punct}]*$"); + + private static final Set LEAGUES_LOCKED_REGION_PARSE_MISS_SAMPLES = ConcurrentHashMap.newKeySet(); + private static final Set LEAGUES_PARSE_MISS_AT_CAP_SEEN = ConcurrentHashMap.newKeySet(); + private static final AtomicInteger LEAGUES_PARSE_MISS_AT_CAP_SEEN_COUNT = new AtomicInteger(0); + private static final int LEAGUES_PARSE_MISS_AT_CAP_SEEN_MAX = 512; + private static final Object LEAGUES_PARSE_MISS_SAMPLES_LOCK = new Object(); + private static final Object LEAGUES_PARSE_MISS_AT_CAP_LOCK = new Object(); + private static final int LEAGUES_PARSE_MISS_DISTINCT_LOG_CAP = 64; + private static final AtomicInteger LEAGUES_PARSE_MISS_AFTER_CAP_COUNT = new AtomicInteger(0); + private static final int LEAGUES_PARSE_MISS_AFTER_CAP_LOG_INTERVAL = 50; + private static final AtomicInteger LEAGUES_PARSE_MISS_INFO = new AtomicInteger(0); + private static final int LEAGUES_PARSE_MISS_INFO_INTERVAL = 25; + private static final AtomicInteger LEAGUES_PARSE_MISS_AT_CAP_SEEN_FULL_LOG = new AtomicInteger(0); + private static final int LEAGUES_PARSE_MISS_AT_CAP_SEEN_FULL_LOG_INTERVAL = 25; + + private static final AtomicInteger LEAGUES_BLOCKED_DEST_FROM_CHAT_INFO = new AtomicInteger(0); + private static final int LEAGUES_BLOCKED_DEST_FROM_CHAT_INFO_INTERVAL = 25; + + private static final long LEAGUES_REROUTE_DEDUPE_WINDOW_MS = 10_000L; + private static final Object LEAGUES_REROUTE_LOCK = new Object(); + private static volatile long lastLeaguesRerouteMs = 0L; + private static volatile int lastLeaguesReroutePackedDest = Integer.MIN_VALUE; + private static volatile String lastLeaguesRerouteRegion = ""; + + static LeaguesRegion parseRegionName(String regionNameRaw) + { + String s = normalizeRegionNameForLockedChat(regionNameRaw); + if (s.isEmpty()) + { + return null; + } + return parseRegionNameNormalized(s); + } + + static LeaguesRegion parseRegionNameNormalized(String s) + { + if (s.contains("misthalin")) + { + return LeaguesRegion.MISTHALIN; + } + if (s.contains("kourend") || s.contains("kebos")) + { + return LeaguesRegion.KEBOS_AND_KOUREND; + } + if (s.contains("varlamore")) + { + return LeaguesRegion.VARLAMORE; + } + if (s.contains("fremennik")) + { + return LeaguesRegion.FREMENNIK; + } + if (s.contains("tirannwn")) + { + return LeaguesRegion.TIRANNWN; + } + if (s.contains("morytania")) + { + return LeaguesRegion.MORYTANIA; + } + if (s.contains("wilderness")) + { + return LeaguesRegion.WILDERNESS; + } + if (s.contains("karamja")) + { + return LeaguesRegion.KARAMJA; + } + if (s.contains("kandarin")) + { + return LeaguesRegion.KANDARIN; + } + if (s.contains("asgarnia")) + { + return LeaguesRegion.ASGARNIA; + } + if (s.contains("kharidian")) + { + return LeaguesRegion.DESERT; + } + if (s.contains("desert")) + { + return LeaguesRegion.DESERT; + } + return null; + } + + static String normalizeRegionNameForLockedChat(String regionNameRaw) + { + if (regionNameRaw == null) + { + return ""; + } + return regionNameRaw.replace('’', '\'').trim().toLowerCase(Locale.ROOT); + } + + static Optional captureLockedRegionFromChatRaw(String rawForMatch) + { + return captureLockedRegionFromSanitizedLower(Rs2TextSanitizer.sanitizeForParsing(rawForMatch)); + } + + static Optional captureLockedRegionFromSanitizedLower(String sanitizedLower) + { + return Rs2TextSanitizer.captureFirstGroup(LEAGUES_LOCKED_REGION_CHAT, sanitizedLower); + } + + static boolean recordBlockedDestinationFromChat(String regionNameRaw, Integer packedDest, String method) + { + if (packedDest == null) + { + return false; + } + String norm = normalizeRegionNameForLockedChat(regionNameRaw); + LeaguesRegion region = norm.isEmpty() ? null : parseRegionNameNormalized(norm); + if (region == null) + { + if (norm.isEmpty()) + { + return false; + } + LeaguesTransportPersistence.persistBlacklistDestination(packedDest, null, method); + Rs2LeaguesTransport.invalidateContext(); + LeaguesTransportAttempts.clearLastTransportAttempt(); + String missKey = norm.length() > 160 + ? norm.substring(0, 160) + "|h" + Integer.toHexString(norm.hashCode()) + : norm; + boolean emitSampleLog = false; + boolean atCapSkip = false; + + synchronized (LEAGUES_PARSE_MISS_SAMPLES_LOCK) + { + int distinct = LEAGUES_LOCKED_REGION_PARSE_MISS_SAMPLES.size(); + if (distinct < LEAGUES_PARSE_MISS_DISTINCT_LOG_CAP) + { + if (!LEAGUES_LOCKED_REGION_PARSE_MISS_SAMPLES.add(missKey)) + { + return true; + } + emitSampleLog = true; + Rs2Walker.Telemetry.incrementLeaguesLockParseMiss(); + } + else + { + if (LEAGUES_LOCKED_REGION_PARSE_MISS_SAMPLES.contains(missKey)) + { + return true; + } + atCapSkip = true; + } + } + + if (atCapSkip) + { + synchronized (LEAGUES_PARSE_MISS_AT_CAP_LOCK) + { + if (LEAGUES_PARSE_MISS_AT_CAP_SEEN_COUNT.get() >= LEAGUES_PARSE_MISS_AT_CAP_SEEN_MAX + && !LEAGUES_PARSE_MISS_AT_CAP_SEEN.contains(missKey)) + { + boolean emitOverflowSummary = Rs2LogRateLimit.everyN( + LEAGUES_PARSE_MISS_AT_CAP_SEEN_FULL_LOG, LEAGUES_PARSE_MISS_AT_CAP_SEEN_FULL_LOG_INTERVAL); + if (emitOverflowSummary) + { + log.info("[Leagues] locked-region parse-miss: at-cap dedupe set full (max={}); dropped novel keys — extend parseRegionName or raise cap", + LEAGUES_PARSE_MISS_AT_CAP_SEEN_MAX); + } + if (log.isDebugEnabled() && emitOverflowSummary) + { + log.debug("[Leagues] locked-region parse-miss dropped (at-cap dedupe set full); missKey prefix='{}'", + missKey.length() > 80 ? missKey.substring(0, 80) + "…" : missKey); + } + return true; + } + if (!LEAGUES_PARSE_MISS_AT_CAP_SEEN.add(missKey)) + { + return true; + } + Rs2Walker.Telemetry.incrementLeaguesLockParseMiss(); + int prev = LEAGUES_PARSE_MISS_AT_CAP_SEEN_COUNT.get(); + if (prev < LEAGUES_PARSE_MISS_AT_CAP_SEEN_MAX) + { + LEAGUES_PARSE_MISS_AT_CAP_SEEN_COUNT.set(prev + 1); + } + } + int n = LEAGUES_PARSE_MISS_AFTER_CAP_COUNT.incrementAndGet(); + if (n == 1 || n % LEAGUES_PARSE_MISS_AFTER_CAP_LOG_INTERVAL == 0) + { + log.info("[Leagues] locked-region parse-miss skipped={} (distinct-sample cap {}); extend parseRegionName", + n, LEAGUES_PARSE_MISS_DISTINCT_LOG_CAP); + } + return true; + } + + if (emitSampleLog && Rs2LogRateLimit.everyN(LEAGUES_PARSE_MISS_INFO, LEAGUES_PARSE_MISS_INFO_INTERVAL)) + { + String sample = regionNameRaw == null ? "" + : regionNameRaw.length() > 120 ? regionNameRaw.substring(0, 120) + "…" : regionNameRaw; + log.info("[Leagues] locked-region chat did not map to LeaguesRegion; dest-only blacklist applied. sample='{}'", sample); + } + return true; + } + if (Rs2LogRateLimit.everyN(LEAGUES_BLOCKED_DEST_FROM_CHAT_INFO, LEAGUES_BLOCKED_DEST_FROM_CHAT_INFO_INTERVAL)) + { + log.info("[Leagues] blocked transport destPacked={} region='{}' method='{}'", + packedDest, regionNameRaw, method != null ? method : ""); + } + LeaguesTransportPersistence.persistBlacklistDestination(packedDest, region, method); + LeaguesTransportObservations.appendLockCatalogueEntry(region, packedDest, method); + // Pathfinder transport memo hash omits blacklist JSONL; refresh so injected graph matches persistence. + Rs2LeaguesTransport.invalidateContext(); + LeaguesTransportAttempts.clearLastTransportAttempt(); + Rs2Walker.Telemetry.incrementLeaguesLockAttributed(); + return true; + } + + static boolean shouldRecalculatePathAfterLock(String region, Integer packedDest) + { + if (region == null || packedDest == null) + { + return true; + } + synchronized (LEAGUES_REROUTE_LOCK) + { + long now = System.currentTimeMillis(); + String prevRegion = lastLeaguesRerouteRegion; + int prevPacked = lastLeaguesReroutePackedDest; + long prevMs = lastLeaguesRerouteMs; + if (packedDest == prevPacked && region.equals(prevRegion) && (now - prevMs) <= LEAGUES_REROUTE_DEDUPE_WINDOW_MS) + { + if (log.isDebugEnabled()) + { + log.debug("[Leagues] reroute deduped: region='{}' destPacked={} ageMs={}", region, packedDest, (now - prevMs)); + } + return false; + } + lastLeaguesRerouteRegion = region; + lastLeaguesReroutePackedDest = packedDest; + lastLeaguesRerouteMs = now; + return true; + } + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportTeleport.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportTeleport.java new file mode 100644 index 00000000000..3fec30d9b04 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportTeleport.java @@ -0,0 +1,1222 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Client; +import net.runelite.api.MenuAction; +import net.runelite.api.WorldType; +import net.runelite.api.widgets.Widget; +import net.runelite.api.gameval.InterfaceID; +import net.runelite.api.gameval.VarbitID; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.util.Global; +import net.runelite.client.plugins.microbot.util.menu.NewMenuEntry; +import net.runelite.client.plugins.microbot.util.player.Rs2Player; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; +import net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard; +import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; +import net.runelite.client.plugins.microbot.util.logging.Rs2LogRateLimit; +import net.runelite.client.plugins.microbot.util.walker.WebWalkLog; +import net.runelite.api.coords.WorldPoint; + +import java.awt.Rectangle; +import java.awt.event.KeyEvent; +import java.util.EnumSet; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +import javax.swing.JOptionPane; +import javax.swing.SwingUtilities; + +import static net.runelite.client.plugins.microbot.util.Global.sleepUntilTrue; + +/** + * Leagues Area teleport UI, calibration worker, and client-thread-safe arrival waits. + */ +@Slf4j +final class LeaguesTransportTeleport +{ + private LeaguesTransportTeleport() + { + } + + /** Shared across all {@code calibrateMissingLandingsAsync} CAS races — one throttle for duplicate-queue skips. */ + private static final AtomicInteger CALIBRATE_SKIP_LOG_COUNTER = new AtomicInteger(0); + private static final AtomicBoolean CALIBRATION_RUNNING = new AtomicBoolean(false); + private static final AtomicBoolean CALIBRATION_CANCEL_REQUESTED = new AtomicBoolean(false); + private static final AtomicLong CALIBRATION_PROBE_MS = new AtomicLong(0L); + private static final long CALIBRATION_PROBE_MIN_INTERVAL_MS = 5000L; + private static final AtomicBoolean CALIBRATION_COMPLETE_PROMPT_QUEUED = new AtomicBoolean(false); + private static final AtomicBoolean CALIBRATION_COMPLETE_PROMPT_SHOWN = new AtomicBoolean(false); + private static final AtomicLong CALIBRATION_COMPLETE_RETRY_AFTER_MS = new AtomicLong(0L); + private static final AtomicBoolean TELEPORT_IN_PROGRESS = new AtomicBoolean(false); + private static final AtomicLong WIDGET_VISIBILITY_CAP_HIT_LOG_MS = new AtomicLong(0L); + private static final AtomicLong WIDGET_VISIBILITY_CHECK_TIMEOUT_LOG_MS = new AtomicLong(0L); + private static final AtomicLong CALIBRATION_COMPLETE_DIALOG_FAIL_LOG_MS = new AtomicLong(0L); + private static final AtomicBoolean LOGGED_TELEPORT_ROW_NAME_MISMATCH = new AtomicBoolean(false); + + private static final int[] LEAGUE_AREA_SELECTION_VARBITS = { + VarbitID.LEAGUE_AREA_SELECTION_0, + VarbitID.LEAGUE_AREA_SELECTION_1, + VarbitID.LEAGUE_AREA_SELECTION_2, + VarbitID.LEAGUE_AREA_SELECTION_3, + VarbitID.LEAGUE_AREA_SELECTION_4, + VarbitID.LEAGUE_AREA_SELECTION_5, + }; + + private static final int LEAGUE_TRANSPORT_CC_OP_IDENTIFIER = 1; + private static final int LEAGUE_TRANSPORT_CC_OP_PARAM0 = -1; + + private static final int DEFAULT_TIMEOUT_MS = 60_000; + private static final int POLL_MS = 100; + + static boolean isTeleportInProgress() + { + return TELEPORT_IN_PROGRESS.get(); + } + + static void onLogout() + { + CALIBRATION_CANCEL_REQUESTED.set(true); + LeaguesTransportPersistence.resetCalibrationConsentPromptStateOnLogout(); + CALIBRATION_COMPLETE_PROMPT_QUEUED.set(false); + CALIBRATION_COMPLETE_PROMPT_SHOWN.set(false); + CALIBRATION_COMPLETE_RETRY_AFTER_MS.set(0L); + CALIBRATION_PROBE_MS.set(0L); + TELEPORT_IN_PROGRESS.set(false); + WIDGET_VISIBILITY_CAP_HIT_LOG_MS.set(0L); + WIDGET_VISIBILITY_CHECK_TIMEOUT_LOG_MS.set(0L); + } + + static void tickLeaguesCalibration() + { + Client c = Microbot.getClient(); + if (!isClientReadyForCalibration(c)) + { + return; + } + + if (!isLeaguesActive()) + { + return; + } + long now = System.currentTimeMillis(); + long prev = CALIBRATION_PROBE_MS.get(); + if (prev != 0L && (now - prev) < CALIBRATION_PROBE_MIN_INTERVAL_MS) + { + return; + } + if (!CALIBRATION_PROBE_MS.compareAndSet(prev, now)) + { + return; + } + + EnumSet unlocked = unlockedRegions(); + if (unlocked.isEmpty()) + { + return; + } + calibrateMissingLandingsAsync(unlocked); + } + + private static boolean isClientReadyForCalibration(Client client) + { + if (!Microbot.isLoggedIn()) + { + return false; + } + if (client == null) + { + return false; + } + boolean welcomeVisible; + if (client.isClientThread()) + { + Widget w = client.getWidget(InterfaceID.WelcomeScreen.PLAY); + welcomeVisible = isWidgetEffectivelyVisible(w); + } + else + { + net.runelite.client.callback.ClientThread clientThread = Microbot.getClientThread(); + if (clientThread == null) + { + return false; + } + Optional vis = clientThread.runOnClientThreadOptional(() -> + { + Widget w = client.getWidget(InterfaceID.WelcomeScreen.PLAY); + return isWidgetEffectivelyVisible(w); + }); + if (vis.isEmpty()) + { + long now = System.currentTimeMillis(); + long prev = WIDGET_VISIBILITY_CHECK_TIMEOUT_LOG_MS.get(); + if (prev == 0L || (now - prev) >= 3_600_000L) + { + if (WIDGET_VISIBILITY_CHECK_TIMEOUT_LOG_MS.compareAndSet(prev, now)) + { + log.debug("[Leagues] widget visibility check timed out/empty; gating calibration as not-ready"); + } + } + return false; + } + welcomeVisible = vis.get(); + } + if (welcomeVisible) + { + return false; + } + return client.getGameState() == net.runelite.api.GameState.LOGGED_IN && client.getLocalPlayer() != null; + } + + private static boolean isWidgetEffectivelyVisible(Widget w) + { + if (w == null) + { + return false; + } + Widget slow = w; + Widget fast = w; + final int cap = 20; + for (int i = 0; i < cap && slow != null; i++) + { + Widget cur = slow; + if (cur.isHidden()) + { + return false; + } + Widget parent = cur.getParent(); + if (parent == cur) + { + return false; + } + slow = parent; + fast = fast != null ? fast.getParent() : null; + fast = fast != null ? fast.getParent() : null; + if (slow != null && slow == fast) + { + return false; + } + } + if (slow == null) + { + return true; + } + if (log.isDebugEnabled()) + { + long now = System.currentTimeMillis(); + long prev = WIDGET_VISIBILITY_CAP_HIT_LOG_MS.get(); + if (prev == 0L || (now - prev) >= 3_600_000L) + { + if (WIDGET_VISIBILITY_CAP_HIT_LOG_MS.compareAndSet(prev, now)) + { + log.debug("[Leagues] widget visibility parent chain exceeded cap={}", cap); + } + } + } + return false; + } + + private static void promptCalibrationConsentIfNeeded() + { + LeaguesTransportPersistence.ensureLoaded(); + if (LeaguesTransportPersistence.isCalibrationConsentAllowed() + || LeaguesTransportPersistence.isCalibrationConsentDenied()) + { + return; + } + long now = System.currentTimeMillis(); + long retryAfter = LeaguesTransportPersistence.getCalibrationConsentRetryAfterMs(); + if (retryAfter != 0L && now < retryAfter) + { + return; + } + if (!LeaguesTransportPersistence.compareAndSetCalibrationConsentPromptQueued(false, true)) + { + return; + } + + SwingUtilities.invokeLater(() -> + { + try + { + if (LeaguesTransportPersistence.isCalibrationConsentAllowed() + || LeaguesTransportPersistence.isCalibrationConsentDenied()) + { + return; + } + Client client = Microbot.getClient(); + if (!isClientReadyForCalibration(client)) + { + LeaguesTransportPersistence.setCalibrationConsentRetryAfterMs(System.currentTimeMillis() + 5000L); + return; + } + + int res = JOptionPane.showConfirmDialog( + null, + "Calibrating Leagues teleports required.\n" + + "This will teleport your character briefly to learn landing tiles.\n\n" + + "Start calibration now?", + "Microbot: Calibrate Leagues teleports", + JOptionPane.YES_NO_OPTION, + JOptionPane.INFORMATION_MESSAGE); + + if (res == JOptionPane.YES_OPTION) + { + LeaguesTransportPersistence.setCalibrationConsentAllowed(true); + LeaguesTransportPersistence.flush(); + } + else + { + LeaguesTransportPersistence.setCalibrationConsentDenied(true); + LeaguesTransportPersistence.flush(); + } + } + finally + { + LeaguesTransportPersistence.setCalibrationConsentPromptQueued(false); + } + }); + } + + private static void promptCalibrationComplete(EnumSet unlockedRegions) + { + if (unlockedRegions == null || unlockedRegions.isEmpty()) + { + return; + } + long now = System.currentTimeMillis(); + long retryAfter = CALIBRATION_COMPLETE_RETRY_AFTER_MS.get(); + if (retryAfter != 0L && now < retryAfter) + { + return; + } + if (CALIBRATION_COMPLETE_PROMPT_SHOWN.get()) + { + return; + } + if (!CALIBRATION_COMPLETE_PROMPT_QUEUED.compareAndSet(false, true)) + { + return; + } + + LeaguesTransportPersistence.ensureLoaded(); + Map landings = LeaguesTransportPersistence.copyRegionLandingsSnapshot(); + + int known = 0; + int missing = 0; + StringBuilder sb = new StringBuilder(1024); + sb.append("Leagues teleport calibration complete.\n\n"); + sb.append("Unlocked regions: ").append(unlockedRegions.size()).append('\n'); + + for (LeaguesRegion r : unlockedRegions) + { + WorldPoint wp = landings.get(r); + if (wp != null) + { + known++; + } + else + { + missing++; + } + } + sb.append("Learned landings: ").append(known).append('\n'); + sb.append("Missing landings: ").append(missing).append("\n\n"); + sb.append("Unlocked Leagues Area teleports:\n"); + + for (LeaguesRegion r : unlockedRegions) + { + WorldPoint wp = landings.get(r); + sb.append("- ").append(r.getDisplayName()); + if (wp != null) + { + sb.append(" -> ").append(wp.toString()); + } + else + { + sb.append(" -> (not learned)"); + } + sb.append('\n'); + } + + final int maxChars = 8000; + final String msg = sb.length() > maxChars ? sb.substring(0, maxChars) + "\n…(truncated)" : sb.toString(); + + SwingUtilities.invokeLater(() -> + { + try + { + Client client = Microbot.getClient(); + if (!isClientReadyForCalibration(client)) + { + CALIBRATION_COMPLETE_RETRY_AFTER_MS.set(System.currentTimeMillis() + 5000L); + return; + } + if (!CALIBRATION_COMPLETE_PROMPT_SHOWN.compareAndSet(false, true)) + { + return; + } + try + { + JOptionPane.showMessageDialog( + null, + msg, + "Microbot: Leagues calibration complete", + JOptionPane.INFORMATION_MESSAGE); + } + catch (Exception e) + { + CALIBRATION_COMPLETE_RETRY_AFTER_MS.set(System.currentTimeMillis() + 5000L); + CALIBRATION_COMPLETE_PROMPT_SHOWN.set(false); + if (log.isDebugEnabled()) + { + long nowMs = System.currentTimeMillis(); + long prev = CALIBRATION_COMPLETE_DIALOG_FAIL_LOG_MS.get(); + if (prev == 0L || (nowMs - prev) >= 3_600_000L) + { + if (CALIBRATION_COMPLETE_DIALOG_FAIL_LOG_MS.compareAndSet(prev, nowMs)) + { + log.debug("[Leagues] completion dialog failed", e); + } + } + } + } + } + finally + { + CALIBRATION_COMPLETE_PROMPT_QUEUED.set(false); + } + }); + } + + static void calibrateMissingLandingsAsync(EnumSet unlockedRegions) + { + calibrateMissingLandingsAsync(unlockedRegions, false); + } + + static void calibrateMissingLandingsAsync(EnumSet unlockedRegions, + boolean logNoOpWhenFullyCalibrated) + { + if (unlockedRegions == null || unlockedRegions.isEmpty()) + { + return; + } + if (!isLeaguesActive()) + { + return; + } + LeaguesTransportPersistence.ensureLoaded(); + + int missingCount = 0; + for (LeaguesRegion r : unlockedRegions) + { + if (!LeaguesTransportPersistence.hasRegionLanding(r)) + { + missingCount++; + } + } + if (missingCount == 0) + { + if (logNoOpWhenFullyCalibrated) + { + WebWalkLog.leaguesInfo( + "calibrate noop | all landing tiles already cached | unlockedRegions={}", + unlockedRegions.size()); + } + return; + } + + WebWalkLog.leagues("calibrate queue | missingLandings={} unlockedRegions={}", + missingCount, unlockedRegions.size()); + + promptCalibrationConsentIfNeeded(); + if (!LeaguesTransportPersistence.isCalibrationConsentAllowed()) + { + return; + } + + if (!CALIBRATION_RUNNING.compareAndSet(false, true)) + { + if (Rs2LogRateLimit.everyN(CALIBRATE_SKIP_LOG_COUNTER, 50)) + { + WebWalkLog.leagues("calibrate skip | worker already running"); + } + return; + } + + CALIBRATION_CANCEL_REQUESTED.set(false); + + final EnumSet unlockedSnapshot = EnumSet.copyOf(unlockedRegions); + final int missingLandingsForWorkerLog = missingCount; + Thread t = new Thread(() -> + { + try + { + WebWalkLog.leaguesInfo("calibration worker starting | missingLandings={}", missingLandingsForWorkerLog); + int ok = 0; + int fail = 0; + int tried = 0; + + for (LeaguesRegion target : unlockedSnapshot) + { + if (CALIBRATION_CANCEL_REQUESTED.get()) + { + dismissOpenMenusAfterCalibrationCancel(); + break; + } + if (target == null) + { + continue; + } + if (LeaguesTransportPersistence.hasRegionLanding(target)) + { + continue; + } + tried++; + + log.info("[Leagues] calibrate landing start: {}", target); + if (CALIBRATION_CANCEL_REQUESTED.get()) + { + dismissOpenMenusAfterCalibrationCancel(); + break; + } + final WorldPoint before = Rs2Player.getWorldLocation(); + LeaguesTeleportResult res = leaguesTeleport(target); + if (!res.isSuccess()) + { + fail++; + log.info("[Leagues] calibrate landing failed: {} reason={} msg='{}'", + target, res.getFailureReason(), res.getMessage()); + continue; + } + + boolean moved = sleepUntilTrue(() -> + { + WorldPoint now = Rs2Player.getWorldLocation(); + return now != null && before != null && !now.equals(before); + }, 100, 8000); + if (!moved) + { + fail++; + continue; + } + + WorldPoint after = Rs2Player.getWorldLocation(); + if (after != null) + { + ok++; + LeaguesTransportPersistence.persistRegionLanding(target, after); + log.info("[Leagues] calibrate landing ok: {} -> {}", target, after); + } + else + { + fail++; + } + } + + if (tried > 0) + { + promptCalibrationComplete(unlockedSnapshot); + } + } + catch (Exception e) + { + log.debug("[Leagues] calibrate landing thread exc: type={} msg={}", + e.getClass().getName(), e.getMessage()); + } + finally + { + CALIBRATION_RUNNING.set(false); + } + }, "microbot-leagues-landing-calibration"); + t.setDaemon(true); + t.setUncaughtExceptionHandler((thread, ex) -> + log.warn("[Leagues] calibrate landing uncaught on {}", thread != null ? thread.getName() : "?", ex)); + t.start(); + } + + static EnumSet unlockedRegions() + { + Optional> unlockedOpt = Microbot.getClientThread().runOnClientThreadOptional(() -> + { + if (!leaguesContextRejectOrEmptySuccess().isEmpty()) + { + return EnumSet.noneOf(LeaguesRegion.class); + } + return readUnlockedRegionsFromSelectionVarbits(); + }); + + return unlockedOpt.orElse(EnumSet.noneOf(LeaguesRegion.class)); + } + + static LeaguesTeleportResult leaguesTeleport(LeaguesRegion region) + { + return leaguesTeleport(region, DEFAULT_TIMEOUT_MS); + } + + static LeaguesTeleportResult leaguesTeleport(LeaguesRegion region, int timeoutMs) + { + Objects.requireNonNull(region, "region"); + if (timeoutMs <= 0) + { + throw new IllegalArgumentException("timeoutMs must be > 0"); + } + + Client client = Microbot.getClient(); + if (client == null) + { + String msg = "Client not available."; + Microbot.status = msg; + return LeaguesTeleportResult.failure( + LeaguesTeleportFailureReason.CLIENT_UNAVAILABLE, + msg, + region, + null); + } + if (client.isClientThread()) + { + String msg = "leaguesTeleport must not run on the RuneLite client thread."; + Microbot.status = msg; + return LeaguesTeleportResult.failure( + LeaguesTeleportFailureReason.INVOKED_ON_CLIENT_THREAD, + msg, + region, + null); + } + + final long startedAtMs = System.currentTimeMillis(); + final WorldPoint before = Rs2Player.getWorldLocation(); + + Optional gateOpt = evaluateTeleportGates(region); + if (!gateOpt.isPresent()) + { + String msg = "Leagues teleport gates: empty client-thread gate result (" + + LeaguesTeleportFailureReason.CLIENT_THREAD_UNAVAILABLE.name() + + "; not null Client / not wrong thread)."; + Microbot.status = msg; + return LeaguesTeleportResult.failure( + LeaguesTeleportFailureReason.CLIENT_THREAD_UNAVAILABLE, + msg, + region, + null); + } + + TeleportGateSnapshot gate = gateOpt.get(); + if (gate.contextFailureReason != null) + { + Microbot.status = gate.contextFailureMessage; + return LeaguesTeleportResult.failure( + gate.contextFailureReason, + gate.contextFailureMessage, + region, + null); + } + + if (!gate.unlockedRegions.contains(region)) + { + String msg = "Region not unlocked: " + region.getDisplayName() + "."; + Microbot.status = msg; + return LeaguesTeleportResult.failure( + LeaguesTeleportFailureReason.REGION_LOCKED, + msg, + region, + gate.unlockedRegions); + } + + boolean marked = TELEPORT_IN_PROGRESS.compareAndSet(false, true); + if (!marked) + { + String msg = "Leagues transport: another teleport in progress."; + Microbot.status = msg; + return LeaguesTeleportResult.failure( + LeaguesTeleportFailureReason.UNKNOWN, + msg, + region, + gate.unlockedRegions); + } + try + { + if (!performTeleportSequence(region, timeoutMs)) + { + String msg = "Leagues transport: UI timeout."; + Microbot.status = msg; + return LeaguesTeleportResult.failure( + LeaguesTeleportFailureReason.UI_TIMEOUT, + msg, + region, + gate.unlockedRegions); + } + + int remaining = remainingMs(startedAtMs, timeoutMs); + final boolean arrived; + if (remaining <= 0) + { + arrived = false; + } + else + { + final int rem = remaining; + final WorldPoint bef = before; + final LeaguesRegion reg = region; + boolean[] box = {false}; + Rs2Walker.runWithWalkerLockReleased(() -> box[0] = waitForTeleportArrival(reg, bef, rem)); + arrived = box[0]; + } + if (!arrived) + { + String msg = "Leagues transport: teleport timeout."; + Microbot.status = msg; + return LeaguesTeleportResult.failure( + LeaguesTeleportFailureReason.TELEPORT_TIMEOUT, + msg, + region, + gate.unlockedRegions); + } + + return LeaguesTeleportResult.ok(region, gate.unlockedRegions); + } + finally + { + if (marked) + { + TELEPORT_IN_PROGRESS.set(false); + } + } + } + + private static boolean waitForTeleportArrival(LeaguesRegion region, WorldPoint before, int timeoutMs) + { + Objects.requireNonNull(region, "region"); + final long startedAtMs = System.currentTimeMillis(); + final int teleportDistanceThreshold = 20; + + final boolean animatingStarted = sleepUntilTrue(Rs2Player::isAnimating, POLL_MS, remainingMs(startedAtMs, timeoutMs)); + final long moveWaitStartedAtMs = System.currentTimeMillis(); + return sleepUntilTrue(() -> + { + WorldPoint now = Rs2Player.getWorldLocation(); + if (now == null || before == null) + { + return false; + } + if (now.equals(before)) + { + return false; + } + if (now.getRegionID() != before.getRegionID()) + { + return true; + } + return now.distanceTo(before) > teleportDistanceThreshold; + }, POLL_MS, remainingMs(moveWaitStartedAtMs, animatingStarted ? remainingMs(startedAtMs, timeoutMs) : remainingMs(startedAtMs, timeoutMs))); + } + + private static boolean performTeleportSequence(LeaguesRegion region, int timeoutMs) + { + final long startedAtMs = System.currentTimeMillis(); + + if (isFixedTeleportRowReady(region)) + { + return invokeTeleportToRegion(region); + } + + if (!Rs2Widget.isWidgetVisible(LeagueTransportWidgets.LEAGUES_GROUP, LeagueTransportWidgets.LEAGUES_CHILD)) + { + invokeCcOp(LeagueTransportWidgets.pack(LeagueTransportWidgets.ACTIVITIES_GROUP, LeagueTransportWidgets.ACTIVITIES_CHILD), "Leagues", ""); + if (!sleepUntilTrue(() -> Rs2Widget.isWidgetVisible(LeagueTransportWidgets.LEAGUES_GROUP, LeagueTransportWidgets.LEAGUES_CHILD), POLL_MS, remainingMs(startedAtMs, timeoutMs))) + { + return false; + } + } + + if (!Rs2Widget.isWidgetVisible(LeagueTransportWidgets.VIEW_AREAS_GROUP, LeagueTransportWidgets.VIEW_AREAS_CHILD)) + { + invokeCcOp(LeagueTransportWidgets.pack(LeagueTransportWidgets.LEAGUES_GROUP, LeagueTransportWidgets.LEAGUES_CHILD), "Leagues", ""); + if (!sleepUntilTrue(() -> Rs2Widget.isWidgetVisible(LeagueTransportWidgets.VIEW_AREAS_GROUP, LeagueTransportWidgets.VIEW_AREAS_CHILD), POLL_MS, remainingMs(startedAtMs, timeoutMs))) + { + return false; + } + } + + if (!ensureAreasMenuShowsTargetRow(region, startedAtMs, timeoutMs)) + { + return false; + } + + return invokeTeleportToRegion(region); + } + + private static boolean ensureAreasMenuShowsTargetRow(LeaguesRegion region, long startedAtMs, int timeoutMs) + { + Objects.requireNonNull(region, "region"); + + int viewAreasPacked = LeagueTransportWidgets.pack(LeagueTransportWidgets.VIEW_AREAS_GROUP, LeagueTransportWidgets.VIEW_AREAS_CHILD); + if (!Global.sleepUntil( + () -> Rs2Widget.isWidgetVisible(LeagueTransportWidgets.AREAS_PANEL_GROUP, LeagueTransportWidgets.AREAS_PANEL_CHILD), + () -> invokeCcOp(viewAreasPacked, "View Areas", ""), + remainingMs(startedAtMs, timeoutMs), + POLL_MS)) + { + return false; + } + + LeaguesRegion.AreasMenuShield shield = region.getAreasMenuShield(); + if (shield.isActive()) + { + invokeCcOp(LeagueTransportWidgets.pack(shield.getGroup(), shield.getChild()), shield.getCcOpOption(), shield.getCcOpTarget()); + } + + int listRootPacked = areasListRootPacked(region); + if (!Global.sleepUntil( + () -> Rs2Widget.isWidgetVisible(areasListRootGroup(region), areasListRootChild(region)), + () -> { + if (shield.isActive()) + { + invokeCcOp(LeagueTransportWidgets.pack(shield.getGroup(), shield.getChild()), shield.getCcOpOption(), shield.getCcOpTarget()); + } + }, + remainingMs(startedAtMs, timeoutMs), + POLL_MS)) + { + return false; + } + + return Global.sleepUntil( + () -> isFixedTeleportRowReady(region), + () -> { + if (shield.isActive()) + { + invokeCcOp(LeagueTransportWidgets.pack(shield.getGroup(), shield.getChild()), shield.getCcOpOption(), shield.getCcOpTarget()); + } + }, + remainingMs(startedAtMs, timeoutMs), + POLL_MS); + } + + private static boolean isFixedTeleportRowReady(LeaguesRegion region) + { + Objects.requireNonNull(region, "region"); + int packed = LeagueTransportWidgets.pack(LeagueTransportWidgets.TELEPORT_ROW_GROUP, LeagueTransportWidgets.TELEPORT_ROW_CHILD); + return Microbot.getClientThread().runOnClientThreadOptional(() -> + { + Widget row = Rs2Widget.getWidget(packed); + if (row == null || row.isHidden()) + { + return false; + } + String name = row.getName(); + if (name == null || !name.equals(region.toMenuTarget())) + { + return false; + } + return true; + }).orElse(false); + } + + private static boolean isTargetRowVisible(LeaguesRegion region, int listContainerPackedId) + { + Objects.requireNonNull(region, "region"); + return Microbot.getClientThread().runOnClientThreadOptional(() -> + { + Widget listContainer = Rs2Widget.getWidget(listContainerPackedId); + if (listContainer == null || listContainer.isHidden()) + { + return false; + } + Widget row = resolveTeleportRowWidget(listContainer, region); + return row != null && !row.isHidden(); + }).orElse(false); + } + + private static Widget resolveTeleportRowWidget(Widget listContainer, LeaguesRegion region) + { + Objects.requireNonNull(listContainer, "listContainer"); + Objects.requireNonNull(region, "region"); + int idx = region.getTeleportListRowDynamicIndex(); + if (idx < 0) + { + return null; + } + Widget[] dynamic = listContainer.getDynamicChildren(); + if (dynamic != null && idx < dynamic.length) + { + Widget w = dynamic[idx]; + if (w != null) + { + return w; + } + } + Widget[] nested = listContainer.getNestedChildren(); + if (nested != null && idx < nested.length) + { + return nested[idx]; + } + Widget[] children = listContainer.getChildren(); + if (children != null && idx < children.length) + { + return children[idx]; + } + return null; + } + + private static int areasListRootGroup(LeaguesRegion region) + { + Objects.requireNonNull(region, "region"); + int g = region.getAreasListRootGroup(); + return g != 0 ? g : LeagueTransportWidgets.AREAS_LIST_CONTAINER_GROUP; + } + + private static int areasListRootChild(LeaguesRegion region) + { + Objects.requireNonNull(region, "region"); + return region.getAreasListRootGroup() != 0 ? region.getAreasListRootChild() : LeagueTransportWidgets.AREAS_LIST_CONTAINER_CHILD; + } + + private static int areasListRootPacked(LeaguesRegion region) + { + return LeagueTransportWidgets.pack(areasListRootGroup(region), areasListRootChild(region)); + } + + private static boolean invokeTeleportToRegion(LeaguesRegion region) + { + Objects.requireNonNull(region, "region"); + + if (region.getTeleportCcOpGroup() != 0) + { + invokeCcOp( + LeagueTransportWidgets.pack(region.getTeleportCcOpGroup(), region.getTeleportCcOpChild()), + region.getTeleportCcOpOption(), + region.toMenuTarget()); + scheduleDebugCheckTeleportRowNameMatches(region); + return true; + } + + invokeCcOp( + LeagueTransportWidgets.pack(LeagueTransportWidgets.TELEPORT_ROW_GROUP, LeagueTransportWidgets.TELEPORT_ROW_CHILD), + region.getTeleportCcOpOption(), + region.toMenuTarget()); + scheduleDebugCheckTeleportRowNameMatches(region); + return true; + } + + private static void scheduleDebugCheckTeleportRowNameMatches(LeaguesRegion region) + { + if (region == null || !log.isDebugEnabled()) + { + return; + } + Microbot.getClientThread().invokeLater(() -> + { + int packed = LeagueTransportWidgets.pack(LeagueTransportWidgets.TELEPORT_ROW_GROUP, LeagueTransportWidgets.TELEPORT_ROW_CHILD); + Widget row = Rs2Widget.getWidget(packed); + if (row == null || row.isHidden()) + { + return; + } + String name = row.getName(); + String expect = region.toMenuTarget(); + if (name != null && expect != null && !name.equals(expect) && LOGGED_TELEPORT_ROW_NAME_MISMATCH.compareAndSet(false, true)) + { + log.debug("[Leagues] TELEPORT_ROW name mismatch after click: region={} expected={} actual={}", region, expect, name); + } + }); + } + + private static void dismissOpenMenusAfterCalibrationCancel() + { + var ct = Microbot.getClientThread(); + if (ct == null) + { + return; + } + ct.invokeLater(() -> Rs2Keyboard.keyPress(KeyEvent.VK_ESCAPE)); + } + + private static int remainingMs(long startedAtMs, int timeoutMs) + { + long elapsed = System.currentTimeMillis() - startedAtMs; + long remaining = timeoutMs - elapsed; + if (remaining <= 0) + { + return 1; + } + return remaining > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) remaining; + } + + public static class LeaguesTeleportDriver + { + private final LeaguesRegion targetRegion; + private boolean active = true; + private int step; + private boolean areasMenuShieldClicked; + + protected LeaguesTeleportDriver(LeaguesRegion targetRegion) + { + this.targetRegion = targetRegion; + } + + public boolean isActive() + { + return active; + } + + public void stop() + { + active = false; + } + + public void tick() + { + if (!active) + { + return; + } + if (Microbot.getClient() == null) + { + active = false; + return; + } + + if (!passVisibilityGate(step)) + { + return; + } + + switch (step) + { + case 0: + if (Rs2Widget.isWidgetVisible(LeagueTransportWidgets.LEAGUES_GROUP, LeagueTransportWidgets.LEAGUES_CHILD)) + { + step = 1; + return; + } + Microbot.status = "Leagues: open activities"; + invokeCcOp(LeagueTransportWidgets.pack(LeagueTransportWidgets.ACTIVITIES_GROUP, LeagueTransportWidgets.ACTIVITIES_CHILD), "Leagues", ""); + step = 1; + return; + case 1: + if (Rs2Widget.isWidgetVisible(LeagueTransportWidgets.VIEW_AREAS_GROUP, LeagueTransportWidgets.VIEW_AREAS_CHILD)) + { + step = 2; + return; + } + Microbot.status = "Leagues: open leagues"; + invokeCcOp(LeagueTransportWidgets.pack(LeagueTransportWidgets.LEAGUES_GROUP, LeagueTransportWidgets.LEAGUES_CHILD), "Leagues", ""); + step = 2; + return; + case 2: + if (Rs2Widget.isWidgetVisible(LeagueTransportWidgets.AREAS_PANEL_GROUP, LeagueTransportWidgets.AREAS_PANEL_CHILD)) + { + step = 3; + return; + } + Microbot.status = "Leagues: open areas"; + invokeCcOp(LeagueTransportWidgets.pack(LeagueTransportWidgets.VIEW_AREAS_GROUP, LeagueTransportWidgets.VIEW_AREAS_CHILD), "View Areas", ""); + step = 3; + return; + case 3: + { + if (isFixedTeleportRowReady(targetRegion)) + { + Microbot.status = "Leagues: teleport " + targetRegion.getDisplayName(); + if (!invokeTeleportToRegion(targetRegion)) + { + log.warn("LeaguesTeleportDriver: teleport invoke failed for {}", targetRegion); + } + areasMenuShieldClicked = false; + active = false; + step = 0; + return; + } + LeaguesRegion.AreasMenuShield shield = targetRegion.getAreasMenuShield(); + int listRootPacked = areasListRootPacked(targetRegion); + if (shield.isActive() && !areasMenuShieldClicked) + { + Microbot.status = "Leagues: areas menu shield"; + invokeCcOp(LeagueTransportWidgets.pack(shield.getGroup(), shield.getChild()), shield.getCcOpOption(), shield.getCcOpTarget()); + areasMenuShieldClicked = true; + return; + } + if (!isTargetRowVisible(targetRegion, listRootPacked)) + { + return; + } + Microbot.status = "Leagues: teleport " + targetRegion.getDisplayName(); + if (!invokeTeleportToRegion(targetRegion)) + { + log.warn("LeaguesTeleportDriver: teleport invoke failed for {}", targetRegion); + } + areasMenuShieldClicked = false; + active = false; + step = 0; + return; + } + default: + log.warn("LeaguesTeleportDriver unexpected step {}", step); + active = false; + } + } + + private boolean passVisibilityGate(int step) + { + switch (step) + { + case 0: + return true; + case 1: + return Rs2Widget.isWidgetVisible(LeagueTransportWidgets.LEAGUES_GROUP, LeagueTransportWidgets.LEAGUES_CHILD); + case 2: + return Rs2Widget.isWidgetVisible(LeagueTransportWidgets.VIEW_AREAS_GROUP, LeagueTransportWidgets.VIEW_AREAS_CHILD); + case 3: + return true; + default: + return false; + } + } + } + + private static final class TeleportGateSnapshot + { + private final LeaguesTeleportFailureReason contextFailureReason; + private final String contextFailureMessage; + private final EnumSet unlockedRegions; + + private TeleportGateSnapshot( + LeaguesTeleportFailureReason contextFailureReason, + String contextFailureMessage, + EnumSet unlockedRegions) + { + this.contextFailureReason = contextFailureReason; + this.contextFailureMessage = contextFailureMessage; + this.unlockedRegions = unlockedRegions != null ? EnumSet.copyOf(unlockedRegions) : EnumSet.noneOf(LeaguesRegion.class); + } + } + + private static Optional evaluateTeleportGates(LeaguesRegion region) + { + Objects.requireNonNull(region, "region"); + return Microbot.getClientThread().runOnClientThreadOptional(() -> + { + String ctxReject = leaguesContextRejectOrEmptySuccess(); + if (!ctxReject.isEmpty()) + { + return new TeleportGateSnapshot(mapContextFailureReason(ctxReject), ctxReject, null); + } + return new TeleportGateSnapshot(null, null, readUnlockedRegionsFromSelectionVarbits()); + }); + } + + private static EnumSet readUnlockedRegionsFromSelectionVarbits() + { + EnumSet unlocked = EnumSet.noneOf(LeaguesRegion.class); + for (int vb : LEAGUE_AREA_SELECTION_VARBITS) + { + int areaId = Microbot.getVarbitValue(vb); + LeaguesRegion r = byAreaIdOrNull(areaId); + if (r != null) + { + unlocked.add(r); + } + } + return unlocked; + } + + private static LeaguesRegion byAreaIdOrNull(int areaId) + { + if (areaId <= 0) + { + return null; + } + for (LeaguesRegion r : LeaguesRegion.values()) + { + if (r.getAreaId() == areaId) + { + return r; + } + } + return null; + } + + private static LeaguesTeleportFailureReason mapContextFailureReason(String message) + { + if ("Client not available.".equals(message)) + { + return LeaguesTeleportFailureReason.CLIENT_UNAVAILABLE; + } + if ("Not on a Leagues / seasonal world.".equals(message)) + { + return LeaguesTeleportFailureReason.NOT_SEASONAL_WORLD; + } + if ("League account not active.".equals(message)) + { + return LeaguesTeleportFailureReason.LEAGUE_ACCOUNT_INACTIVE; + } + if ("Leagues context: client thread unavailable.".equals(message)) + { + return LeaguesTeleportFailureReason.CLIENT_THREAD_UNAVAILABLE; + } + return LeaguesTeleportFailureReason.UNKNOWN; + } + + static String leaguesContextRejectOrEmptySuccess() + { + Client c = Microbot.getClient(); + if (c == null) + { + return "Client not available."; + } + EnumSet types = c.getWorldType(); + if (types == null || !types.contains(WorldType.SEASONAL)) + { + return "Not on a Leagues / seasonal world."; + } + if (Microbot.getVarbitValue(VarbitID.LEAGUE_ACCOUNT) <= 0) + { + return "League account not active."; + } + return ""; + } + + static String verifyLeaguesContextOrNull() + { + Optional msgOpt = Microbot.getClientThread().runOnClientThreadOptional(LeaguesTransportTeleport::leaguesContextRejectOrEmptySuccess); + if (!msgOpt.isPresent()) + { + return "Leagues context: client thread unavailable."; + } + String msg = msgOpt.get(); + return msg.isEmpty() ? null : msg; + } + + private static boolean isLeaguesActive() + { + return Microbot.getVarbitValue(VarbitID.LEAGUE_TYPE) > 0; + } + + private static void invokeCcOp(int packedWidgetId, String option, String target) + { + Objects.requireNonNull(option, "option"); + String targetNonNull = target != null ? target : ""; + + Rectangle bounds = Microbot.getClientThread().runOnClientThreadOptional(() -> + { + Widget w = Rs2Widget.getWidget(packedWidgetId); + return w != null ? w.getBounds() : null; + }).orElse(null); + + Rectangle clickRect = bounds != null ? bounds : new Rectangle(1, 1, 1, 1); + Microbot.doInvoke(new NewMenuEntry() + .option(option) + .target(targetNonNull) + .identifier(LEAGUE_TRANSPORT_CC_OP_IDENTIFIER) + .opcode(MenuAction.CC_OP.getId()) + .param0(LEAGUE_TRANSPORT_CC_OP_PARAM0) + .param1(packedWidgetId) + .itemId(-1), + clickRect); + } +} 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 new file mode 100644 index 00000000000..09fdec97b98 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2LeaguesTransport.java @@ -0,0 +1,365 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +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 java.util.EnumSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; + +/** + * Leagues transport façade. Implementation split across {@code LeaguesTransport*} types (audit T6.2). + * + *

Threading: Do not call {@link #leaguesTeleport} (or {@link #tryHandleLeaguesAreaTransport}) from the RuneLite + * client thread — they synchronize with client-thread UI and would stall the client. + */ +@Slf4j +public final class Rs2LeaguesTransport +{ + /** Defensive bound before sanitize+regex on locked-region chat messages. */ + public static final int LEAGUES_LOCK_CHAT_MAX_NORMALIZE_CHARS = 4096; + public static final int LEAGUES_LOCK_CHAT_TRUNC_WARN_INTERVAL = 200; + + /** + * Max age of last transport attempt for correlating locked-region gamemessages. + * + * @apiNote Stable for scripts; document in changelog when changing semantics. + */ + public static final long LEAGUES_LOCK_CHAT_MAX_ATTEMPT_AGE_MS = 15_000L; + + public static final class LeaguesContext + { + private final boolean active; + private final EnumSet unlockedRegions; + + private LeaguesContext(boolean active, EnumSet unlockedRegions) + { + this.active = active; + this.unlockedRegions = unlockedRegions != null ? unlockedRegions : EnumSet.noneOf(LeaguesRegion.class); + } + + public boolean isActive() + { + return active; + } + + public EnumSet getUnlockedRegions() + { + return unlockedRegions; + } + } + + private static final LeaguesContext INACTIVE_CONTEXT = + new LeaguesContext(false, EnumSet.noneOf(LeaguesRegion.class)); + + private Rs2LeaguesTransport() + { + } + + public static Integer getLastTransportAttemptPackedDest() + { + return LeaguesTransportAttempts.getLastTransportAttemptPackedDest(); + } + + public static String getLastTransportAttemptMethod() + { + return LeaguesTransportAttempts.getLastTransportAttemptMethod(); + } + + public static boolean isLeaguesAreaTeleportPending(long maxAgeMs) + { + return LeaguesTransportAttempts.isLeaguesAreaTeleportPending(maxAgeMs); + } + + public static LeaguesTransportAttemptSnapshot getLastTransportAttemptSnapshot() + { + return LeaguesTransportAttempts.getLastTransportAttemptSnapshot(); + } + + public static void clearLastTransportAttempt() + { + LeaguesTransportAttempts.clearLastTransportAttempt(); + } + + /** + * Latest transport attempt if younger than {@code maxAgeMs}. Matches {@code 1a5c485}: no geographic or label filtering; + * {@code regionCaptured} is ignored at lookup time (region comes from chat when persisting the blacklist row). + */ + public static Optional findTransportAttemptForLockedRegionChat( + String regionCaptured, long nowMs, long maxAgeMs) + { + return LeaguesTransportAttempts.findTransportAttemptForLockedRegionChat(regionCaptured, nowMs, maxAgeMs); + } + + public static void recordTransportAttempt(Transport transport) + { + LeaguesTransportAttempts.recordTransportAttempt(transport, null); + } + + public static void recordTransportAttempt(Transport transport, String attemptHandler) + { + LeaguesTransportAttempts.recordTransportAttempt(transport, attemptHandler); + } + + public static boolean tryHandleLeaguesAreaTransport(Transport transport) + { + return tryHandleLeaguesAreaTransportResult(transport).map(LeaguesTeleportResult::isSuccess).orElse(false); + } + + public static boolean matchesLeaguesAreaTransportPrefix(Transport transport) + { + if (transport == null || transport.getDisplayInfo() == null || !isLeaguesActive()) + { + return false; + } + String displayInfoForPrefix = Rs2TextSanitizer.normalizeAsciiColons(transport.getDisplayInfo()); + Matcher areaPrefix = LeaguesTransportRegions.LEAGUES_AREA_PREFIX.matcher(displayInfoForPrefix); + return areaPrefix.lookingAt(); + } + + public static Optional tryHandleLeaguesAreaTransportResult(Transport transport) + { + if (transport == null || transport.getDisplayInfo() == null) + { + return Optional.empty(); + } + if (!isLeaguesActive()) + { + return Optional.empty(); + } + + String displayInfo = transport.getDisplayInfo(); + String displayInfoForPrefix = Rs2TextSanitizer.normalizeAsciiColons(displayInfo); + Matcher areaPrefix = LeaguesTransportRegions.LEAGUES_AREA_PREFIX.matcher(displayInfoForPrefix); + if (!areaPrefix.lookingAt()) + { + return Optional.empty(); + } + + String regionRaw = areaPrefix.replaceFirst("").trim(); + String sanitizedRegion = Rs2TextSanitizer.sanitizeLeaguesLockedRegionName(regionRaw); + LeaguesRegion region = LeaguesTransportRegions.parseRegionName(sanitizedRegion); + if (region == null) + { + if (log.isDebugEnabled()) + { + log.debug("Leagues Area transport: parseRegionName miss after sanitize; rawLabel='{}' sanitized='{}'", + regionRaw, sanitizedRegion); + } + return Optional.empty(); + } + + recordTransportAttempt(transport, "LeaguesArea"); + LeaguesTeleportResult res = LeaguesTransportTeleport.leaguesTeleport(region); + if (!res.isSuccess() && log.isDebugEnabled()) + { + log.debug("Leagues Area transport failed: region={} reason={} message={}", + region, res.getFailureReason(), res.getMessage()); + } + if (res.isSuccess()) + { + WorldPoint after = Rs2Player.getWorldLocation(); + if (after != null) + { + LeaguesTransportPersistence.persistRegionLanding(region, after); + } + clearLastTransportAttempt(); + } + return Optional.of(res); + } + + public static void onLockedRegionGameMessage(String msg) + { + LeaguesTransportChat.onLockedRegionGameMessage(msg); + } + + public static LeaguesContext leaguesContext() + { + if (!isLeaguesActive()) + { + return INACTIVE_CONTEXT; + } + return new LeaguesContext(true, LeaguesTransportTeleport.unlockedRegions()); + } + + public static boolean isTransportAllowed(LeaguesContext ctx, Transport transport) + { + return LeaguesTransportInjection.isTransportAllowed(ctx, transport); + } + + public static void invalidateContext() + { + PathfinderConfig cfg = ShortestPathPlugin.pathfinderConfig; + if (cfg != null) + { + cfg.invalidateTransportRefreshCache(); + } + } + + public static boolean isDestinationBlacklisted(int packedWorldPoint) + { + return LeaguesTransportPersistence.isDestinationBlacklisted(packedWorldPoint); + } + + public static void invalidateBlacklistFor(LeaguesRegion region) + { + LeaguesTransportPersistence.invalidateBlacklistFor(region); + } + + public static Map getBlacklistedDestinationRegionsSnapshot() + { + return LeaguesTransportPersistence.getBlacklistedDestinationRegionsSnapshot(); + } + + public static void persistBlacklistDestination(int packedWorldPoint, LeaguesRegion region, String method) + { + LeaguesTransportPersistence.persistBlacklistDestination(packedWorldPoint, region, method); + } + + public static void appendTransportAttemptObservation(Transport transport, String detail) + { + LeaguesTransportObservations.appendTransportObservationInternal("attempt", transport, null, detail); + } + + public static void appendTransportObservation(String phase, Transport transport, boolean success, String detail) + { + if (!"result".equals(phase)) + { + throw new IllegalArgumentException("appendTransportObservation(boolean): phase must be \"result\""); + } + LeaguesTransportObservations.appendTransportObservationInternal(phase, transport, Boolean.valueOf(success), detail); + } + + public static void appendCatalogTransport(LeaguesRegion requiredRegion, Transport transport, String note) + { + LeaguesTransportObservations.appendCatalogTransport(requiredRegion, transport, note); + } + + public static java.util.List loadCatalogTransports(EnumSet unlockedRegions) + { + return LeaguesTransportObservations.loadCatalogTransports(unlockedRegions); + } + + public static void injectLeaguesTransports( + PathfinderConfig pathfinderConfig, + LeaguesContext ctx, + Set usableTeleports, + Map> transports, + PrimitiveIntHashMap> transportsPacked, + Map typeStats) + { + LeaguesTransportInjection.injectLeaguesTransports( + pathfinderConfig, ctx, usableTeleports, transports, transportsPacked, typeStats); + } + + public static LeaguesRegion parseRegionName(String regionNameRaw) + { + return LeaguesTransportRegions.parseRegionName(regionNameRaw); + } + + public static Optional captureLockedRegionFromChatRaw(String rawForMatch) + { + return LeaguesTransportRegions.captureLockedRegionFromChatRaw(rawForMatch); + } + + public static Optional captureLockedRegionFromSanitizedLower(String sanitizedLower) + { + return LeaguesTransportRegions.captureLockedRegionFromSanitizedLower(sanitizedLower); + } + + public static boolean recordBlockedDestinationFromChat(String regionNameRaw, Integer packedDest, String method) + { + return LeaguesTransportRegions.recordBlockedDestinationFromChat(regionNameRaw, packedDest, method); + } + + public static boolean shouldRecalculatePathAfterLock(String region, Integer packedDest) + { + return LeaguesTransportRegions.shouldRecalculatePathAfterLock(region, packedDest); + } + + public static Optional getCachedRegionLanding(LeaguesRegion region) + { + return LeaguesTransportPersistence.getCachedRegionLanding(region); + } + + public static void persistRegionLanding(LeaguesRegion region, WorldPoint landing) + { + LeaguesTransportPersistence.persistRegionLanding(region, landing); + } + + public static void calibrateMissingLandingsAsync(EnumSet unlockedRegions) + { + calibrateMissingLandingsAsync(unlockedRegions, false); + } + + /** + * @param logNoOpWhenFullyCalibrated when {@code true}, logs one INFO line if every unlocked region + * already has a persisted landing (e.g. varbit-driven refresh). + */ + public static void calibrateMissingLandingsAsync(EnumSet unlockedRegions, + boolean logNoOpWhenFullyCalibrated) + { + LeaguesTransportTeleport.calibrateMissingLandingsAsync(unlockedRegions, logNoOpWhenFullyCalibrated); + } + + public static boolean isTeleportInProgress() + { + return LeaguesTransportTeleport.isTeleportInProgress(); + } + + public static void tickLeaguesCalibration() + { + LeaguesTransportTeleport.tickLeaguesCalibration(); + } + + public static void onLogout() + { + LeaguesTransportTeleport.onLogout(); + } + + public static boolean isLeaguesContext() + { + return LeaguesTransportTeleport.verifyLeaguesContextOrNull() == null; + } + + public static boolean isLeaguesActive() + { + return net.runelite.client.plugins.microbot.Microbot.getVarbitValue( + net.runelite.api.gameval.VarbitID.LEAGUE_TYPE) > 0; + } + + public static EnumSet unlockedRegions() + { + return LeaguesTransportTeleport.unlockedRegions(); + } + + public static LeaguesTeleportResult leaguesTeleport(LeaguesRegion region) + { + return LeaguesTransportTeleport.leaguesTeleport(region); + } + + public static LeaguesTeleportResult leaguesTeleport(LeaguesRegion region, int timeoutMs) + { + return LeaguesTransportTeleport.leaguesTeleport(region, timeoutMs); + } + + /** + * Non-blocking driver for advanced callers. Call {@link #tick()} from script loop until inactive. + */ + public static final class LeaguesTeleportDriver extends LeaguesTransportTeleport.LeaguesTeleportDriver + { + public LeaguesTeleportDriver(LeaguesRegion targetRegion) + { + super(targetRegion); + } + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2MapOfAlacrityTransport.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2MapOfAlacrityTransport.java new file mode 100644 index 00000000000..2b050d8aaab --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2MapOfAlacrityTransport.java @@ -0,0 +1,583 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.widgets.Widget; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.shortestpath.Transport; +import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; +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.keyboard.Rs2Keyboard; +import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; +import net.runelite.client.plugins.microbot.util.text.Rs2TextSanitizer; +import net.runelite.client.plugins.microbot.util.logging.Rs2LogRateLimit; + +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.Objects; +import java.util.Optional; + +import static net.runelite.client.plugins.microbot.util.Global.sleepUntil; +import static net.runelite.client.plugins.microbot.util.Global.sleepUntilNotNull; + +/** + * Map of Alacrity seasonal transport (Leagues). + * + * Used by {@link net.runelite.client.plugins.microbot.util.walker.Rs2Walker} via + * {@code TransportType.SEASONAL_TRANSPORT} displayInfo rows. + * + * State: keeps session blacklist (bad/locked rows) to stop reroute spam. + */ +@Slf4j +public final class Rs2MapOfAlacrityTransport +{ + /** + * Fallback when {@link Rs2Inventory#get(String, boolean)} cannot find the relic by name (league id drift). + * Prefer {@link #resolveMapOfAlacrityRelic()} in {@link #tryUse}. + */ + private static final int MAP_OF_ALACRITY_ITEM_ID_FALLBACK = 33233; + private static final AtomicInteger MOA_RELIC_ID_MISMATCH_LOG = new AtomicInteger(0); + // From client widget dump (Leagues MoA interface); update if Jagex changes group/child IDs. + private static final int MAP_OF_ALACRITY_WIDGET_GROUP = 187; + private static final int MAP_OF_ALACRITY_LIST_CHILD = 3; + private static final String MOA_LOCKED_MARKUP = ""; + + /** + * Session-only mutable sets: intended for walker / MoA handler coordination only. + * Scripts must not clear or mutate these; doing so fights MoA blacklist/lock state. + */ + private static final Set blacklistedMoaDestinations = ConcurrentHashMap.newKeySet(); + private static final Set lockedMoaRegions = ConcurrentHashMap.newKeySet(); + + public static boolean isMoaDestinationBlacklisted(int packedDest) + { + return blacklistedMoaDestinations.contains(packedDest); + } + + public static boolean isMoaRegionLocked(String region) + { + return region != null && lockedMoaRegions.contains(region.toLowerCase(Locale.ROOT)); + } + + /** + * Changes when session MoA blacklist / lock sets mutate — included in {@code PathfinderConfig} transport refresh memo key. + */ + public static int moaTransportCacheFingerprint() + { + int h = blacklistedMoaDestinations.hashCode(); + h = 31 * h + lockedMoaRegions.hashCode(); + return h; + } + + private static void addBlacklistedMoaDestination(int packedDest) + { + if (packedDest == 0) + { + return; + } + if (blacklistedMoaDestinations.add(packedDest)) + { + Rs2LeaguesTransport.persistBlacklistDestination(packedDest, null, "MoA"); + } + } + + /** + * Resolves relic by exact name first, then {@value #MAP_OF_ALACRITY_ITEM_ID_FALLBACK}. Logs once per session when name id + * differs from fallback (update constant after league settles). + */ + private static Rs2ItemModel resolveMapOfAlacrityRelic() + { + Rs2ItemModel byName = Rs2Inventory.get("Map of Alacrity", true); + if (byName != null) + { + int id = byName.getId(); + if (id != MAP_OF_ALACRITY_ITEM_ID_FALLBACK && MOA_RELIC_ID_MISMATCH_LOG.compareAndSet(0, 1)) + { + log.warn("[MoA] Map of Alacrity resolved by name id={} != fallback={}; update fallback when ids stable.", + id, MAP_OF_ALACRITY_ITEM_ID_FALLBACK); + } + return byName; + } + return Rs2Inventory.get(MAP_OF_ALACRITY_ITEM_ID_FALLBACK); + } + + private static int moaListPageSignature(Widget listRoot) + { + if (listRoot == null) + { + return 0; + } + return Microbot.getClientThread().runOnClientThreadOptional(() -> + { + Widget[] d = listRoot.getDynamicChildren(); + int n = d == null ? 0 : d.length; + int h = n * 31; + if (d != null) + { + int cap = Math.min(4, d.length); + for (int i = 0; i < cap; i++) + { + Widget w = d[i]; + String t = w != null ? w.getText() : ""; + h = h * 31 + (t != null ? t.hashCode() : 0); + } + } + return h; + }).orElse(0); + } + + private static void addLockedMoaRegion(String region) + { + if (region != null && !region.isEmpty()) + { + lockedMoaRegions.add(region.toLowerCase(Locale.ROOT)); + } + } + + // Matches the OSRS menu-row hotkey prefix, e.g. "[1] ..." or "1: ..." or "A. ...". + private static final Pattern MOA_HOTKEY_PATTERN = + Pattern.compile("^\\s*(?:\\[([0-9A-Za-z])\\]|([0-9A-Za-z])\\s*[:.])"); + private static final Pattern MOA_MARKUP_PATTERN = Pattern.compile("<[^>]*>"); + private static final Pattern MOA_PUNCT_PATTERN = Pattern.compile("[^a-zA-Z0-9 ]"); + private static final Pattern MOA_WHITESPACE_PATTERN = Pattern.compile("\\s+"); + /** When {@code " - "} absent after {@link #normalizeMoaRegionShortcutSeparator}, split on hyphen/en-dash/em-dash/minus (allows hyphen-glued titles). */ + private static final Pattern MOA_REGION_SHORTCUT_FALLBACK_SPLIT = Pattern.compile("\\s*[-\\u2013\\u2014\\u2212]\\s*"); + private static final List MOA_RELIC_ACTION_FALLBACK = List.of("Read", "Open", "Teleport", "Invoke"); + + private Rs2MapOfAlacrityTransport() + { + } + + /** Fullwidth / compatibility colon forms → ASCII {@code ':'} for title parsing. */ + private static String normalizeMoaTitleColons(String displayInfo) + { + if (displayInfo == null) + { + return ""; + } + return Rs2TextSanitizer.normalizeAsciiColons(displayInfo); + } + + /** + * Normalizes dashes so {@link #tryUse} can find {@code " - "} or ASCII hyphens: space-en-dash / space-em-dash → {@code " - "}; + * remaining en/em/minus signs → ASCII {@code '-'} (so {@code lastIndexOf(" - ")} and the fallback hyphen regex both see a delimiter). + */ + private static String normalizeMoaRegionShortcutSeparator(String rest) + { + if (rest == null || rest.isEmpty()) + { + return ""; + } + String s = rest.replace(" \u2013 ", " - ").replace(" \u2014 ", " - "); + s = s.replace('\u2013', '-').replace('\u2014', '-').replace('\u2212', '-'); + return s; + } + + // TSV / menu: {@code Map of Alacrity: - } (ASCII {@code :} after {@link #normalizeMoaTitleColons}); {@link #tryUse} splits on first {@code :}. + private static final String MOA_DISPLAY_TITLE_PREFIX = "Map of Alacrity"; + /** + * Strips Unicode format characters ({@code \p{Cf}}) only in the substring before the first {@code ':'} (title part) so ZWJ + * / variation selectors do not break the {@value #MOA_DISPLAY_TITLE_PREFIX} prefix check. Region/shortcut text after + * {@code ':'} is left unchanged. + */ + private static final Pattern MOA_FORMAT_CHARS = Pattern.compile("\\p{Cf}"); + + /** Same pipeline as {@link #isMapOfAlacrityTransport} prefix check: colons + strip format chars + trim. */ + private static String normalizeMoaDisplayInfoForParsing(String raw) + { + if (raw == null) + { + return ""; + } + String colons = normalizeMoaTitleColons(raw); + int colon = colons.indexOf(':'); + if (colon < 0) + { + return MOA_FORMAT_CHARS.matcher(colons).replaceAll("").trim(); + } + String title = colons.substring(0, colon); + String restText = colons.substring(colon + 1); + String cleanTitle = MOA_FORMAT_CHARS.matcher(title).replaceAll("").trim(); + String cleanRest = restText.trim(); + return cleanRest.isEmpty() ? (cleanTitle + ":") : (cleanTitle + ": " + cleanRest); + } + + /** + * True when {@code displayInfo} begins with the Map of Alacrity title (trimmed, case-insensitive). + * Callers that gate pathfinding should also require {@link net.runelite.client.plugins.microbot.shortestpath.TransportType#SEASONAL_TRANSPORT}; + * this method intentionally ignores type so tests/helpers can pass partial mocks. + */ + public static boolean isMapOfAlacrityTransport(Transport transport) + { + if (transport == null || transport.getDisplayInfo() == null) + { + return false; + } + String t = normalizeMoaDisplayInfoForParsing(transport.getDisplayInfo()); + return t.regionMatches(true, 0, MOA_DISPLAY_TITLE_PREFIX, 0, MOA_DISPLAY_TITLE_PREFIX.length()); + } + + public static boolean tryUse(Transport transport) + { + if (transport == null || transport.getDisplayInfo() == null || transport.getDestination() == null) + { + return false; + } + + if (!isMapOfAlacrityTransport(transport)) + { + return false; + } + + final String displayInfo = normalizeMoaDisplayInfoForParsing(transport.getDisplayInfo()); + if (log.isDebugEnabled()) + { + int first = displayInfo.indexOf(':'); + int second = first < 0 ? -1 : displayInfo.indexOf(':', first + 1); + if (second >= 0) + { + String sample = displayInfo.length() > 120 ? displayInfo.substring(0, 120) + "…" : displayInfo; + log.debug("[MoA] multiple ':' in normalized displayInfo — split uses first only; sample='{}'", sample); + } + } + + int packedDest = WorldPointUtil.packWorldPoint(transport.getDestination()); + if (isMoaDestinationBlacklisted(packedDest)) + { + return false; + } + + Rs2ItemModel relic = resolveMapOfAlacrityRelic(); + if (relic == null) + { + return false; + } + + Optional parsedOpt = parseMoaDisplayInfo(displayInfo); + if (!parsedOpt.isPresent()) + { + return false; + } + MoaParsedRow parsed = parsedOpt.get(); + String region = parsed.region; + String shortName = parsed.shortcutName; + + if (isMoaRegionLocked(region)) + { + addBlacklistedMoaDestination(packedDest); + return false; + } + + String action = relic.getAction("Read"); + if (action == null) action = relic.getActionFromList(MOA_RELIC_ACTION_FALLBACK); + if (action == null) + { + return false; + } + if (!Rs2Inventory.interact(relic, action)) + { + return false; + } + Rs2LeaguesTransport.recordTransportAttempt(transport, "MoA"); + + if (!sleepUntil(() -> Rs2Widget.isWidgetVisible(MAP_OF_ALACRITY_WIDGET_GROUP, MAP_OF_ALACRITY_LIST_CHILD), 3000)) + { + return false; + } + + Widget regionRoot = Rs2Widget.getWidget(MAP_OF_ALACRITY_WIDGET_GROUP, MAP_OF_ALACRITY_LIST_CHILD); + if (regionRoot == null) + { + return false; + } + + Widget regionMatch = findMoaWidget(regionRoot, region); + if (regionMatch == null) + { + return false; + } + + String regionText = Microbot.getClientThread().runOnClientThreadOptional(regionMatch::getText).orElse(""); + if (regionText != null && regionText.contains(MOA_LOCKED_MARKUP)) + { + addLockedMoaRegion(region); + addBlacklistedMoaDestination(packedDest); + return false; + } + + Character regionHotkey = extractMoaHotkey(regionText); + if (regionHotkey == null) regionHotkey = computeMoaHotkeyByIndex(regionRoot, regionMatch); + final int sigBefore = moaListPageSignature(regionRoot); + if (regionHotkey != null) + { + Rs2Keyboard.keyPress(regionHotkey); + if (sigBefore != 0) + { + sleepUntil(() -> + { + Widget r = Rs2Widget.getWidget(MAP_OF_ALACRITY_WIDGET_GROUP, MAP_OF_ALACRITY_LIST_CHILD); + return r != null && moaListPageSignature(r) != sigBefore; + }, 3000); + } + } + else + { + if (!Rs2Widget.clickWidget(regionMatch)) + { + return false; + } + } + + Widget destMatch = sleepUntilNotNull(() -> + { + Widget root = Rs2Widget.getWidget(MAP_OF_ALACRITY_WIDGET_GROUP, MAP_OF_ALACRITY_LIST_CHILD); + if (root == null) return null; + return findMoaWidget(root, shortName); + }, 3000); + + if (destMatch == null) + { + addBlacklistedMoaDestination(packedDest); + return false; + } + + String destText = Microbot.getClientThread().runOnClientThreadOptional(destMatch::getText).orElse(""); + if (destText != null && destText.contains(MOA_LOCKED_MARKUP)) + { + addBlacklistedMoaDestination(packedDest); + return false; + } + + Character destHotkey = extractMoaHotkey(destText); + if (destHotkey == null) + { + Widget destRoot = Rs2Widget.getWidget(MAP_OF_ALACRITY_WIDGET_GROUP, MAP_OF_ALACRITY_LIST_CHILD); + destHotkey = computeMoaHotkeyByIndex(destRoot, destMatch); + } + if (destHotkey != null) + { + Rs2Keyboard.keyPress(destHotkey); + } + else + { + if (!Rs2Widget.clickWidget(destMatch)) + { + return false; + } + } + + return true; + } + + static final class MoaParsedRow + { + private final String region; + private final String shortcutName; + + private MoaParsedRow(String region, String shortcutName) + { + this.region = Objects.requireNonNull(region, "region"); + this.shortcutName = Objects.requireNonNull(shortcutName, "shortcutName"); + } + + String getRegion() + { + return region; + } + + String getShortcutName() + { + return shortcutName; + } + } + + /** + * Parse "Map of Alacrity: <Region> - <Shortcut>" (after {@link #normalizeMoaDisplayInfoForParsing}). + * Returns empty if format is unexpected. + */ + static Optional parseMoaDisplayInfo(String normalizedDisplayInfo) + { + if (normalizedDisplayInfo == null || normalizedDisplayInfo.isEmpty()) + { + return Optional.empty(); + } + + int colon = normalizedDisplayInfo.indexOf(':'); + if (colon >= 0 && colon < MOA_DISPLAY_TITLE_PREFIX.length()) + { + if (log.isDebugEnabled()) + { + String sample = normalizedDisplayInfo.length() > 80 ? normalizedDisplayInfo.substring(0, 80) + "…" : normalizedDisplayInfo; + log.debug("[MoA] ':' appears before end of title prefix (minIndex={}); sample='{}'", + MOA_DISPLAY_TITLE_PREFIX.length(), sample); + } + return Optional.empty(); + } + + String rest = colon >= 0 ? normalizedDisplayInfo.substring(colon + 1).trim() : normalizedDisplayInfo.trim(); + rest = normalizeMoaRegionShortcutSeparator(rest); + String region; + String shortName; + // Last " - " so region may contain that substring; shortcut may contain " - " (e.g. "Place - Wing"). + int spacedDash = rest.lastIndexOf(" - "); + if (spacedDash >= 0) + { + region = rest.substring(0, spacedDash).trim(); + shortName = rest.substring(spacedDash + 3).trim(); + } + else + { + // Last dash match: shortcut may be unhyphenated while region contains several (game-dependent). + Matcher dashSplit = MOA_REGION_SHORTCUT_FALLBACK_SPLIT.matcher(rest); + int lastStart = -1; + int lastEnd = -1; + while (dashSplit.find()) + { + lastStart = dashSplit.start(); + lastEnd = dashSplit.end(); + } + if (lastStart < 0) + { + return Optional.empty(); + } + region = rest.substring(0, lastStart).trim(); + shortName = rest.substring(lastEnd).trim(); + } + if (region.isEmpty() || shortName.isEmpty()) + { + return Optional.empty(); + } + return Optional.of(new MoaParsedRow(region, shortName)); + } + + private static Widget findMoaWidget(Widget root, String needle) + { + String normalised = normaliseMoaText(needle); + if (normalised.isEmpty()) return null; + String[] tokens = normalised.split(" "); + return Microbot.getClientThread().runOnClientThreadOptional(() -> + { + Widget exact = null; + Widget tokenMatch = null; + int bestTokenHayLen = Integer.MAX_VALUE; + String bestTokenHay = null; + for (Widget w : collectMoaChildren(root)) + { + String hay = normaliseMoaText(w.getText()); + if (hay.isEmpty()) + { + continue; + } + if (hay.equals(normalised)) + { + exact = w; + break; + } + boolean all = true; + for (String t : tokens) + { + if (t.isEmpty()) + { + continue; + } + if (!hay.contains(t)) + { + all = false; + break; + } + } + if (all) + { + // Tie on length: lexical order — deterministic, not semantic “best” if two rows normalize the same. + int lenCmp = Integer.compare(hay.length(), bestTokenHayLen); + boolean better = lenCmp < 0 + || (lenCmp == 0 && (bestTokenHay == null || hay.compareTo(bestTokenHay) < 0)); + if (better) + { + tokenMatch = w; + bestTokenHayLen = hay.length(); + bestTokenHay = hay; + } + } + } + return exact != null ? exact : tokenMatch; + }).orElse(null); + } + + private static List collectMoaChildren(Widget root) + { + List out = new ArrayList<>(); + if (root == null) return out; + Widget[] dyn = root.getDynamicChildren(); + Widget[] kids = dyn != null && dyn.length > 0 ? dyn : root.getChildren(); + if (kids == null) return out; + for (Widget w : kids) + { + if (w == null) continue; + String txt = w.getText(); + if (txt == null || txt.isEmpty()) continue; + out.add(w); + } + return out; + } + + private static String normaliseMoaText(String raw) + { + if (raw == null) return ""; + String s = raw.toLowerCase(Locale.ROOT); + s = Rs2TextSanitizer.stripTagsToSpace(s); + s = s.replace('’', '\''); + s = MOA_PUNCT_PATTERN.matcher(s).replaceAll(" "); + s = MOA_WHITESPACE_PATTERN.matcher(s).replaceAll(" ").trim(); + return s; + } + + private static Character extractMoaHotkey(String rawText) + { + if (rawText == null) return null; + String plain = MOA_MARKUP_PATTERN.matcher(rawText).replaceAll(""); + java.util.regex.Matcher m = MOA_HOTKEY_PATTERN.matcher(plain); + if (!m.find()) return null; + String c = m.group(1) != null ? m.group(1) : m.group(2); + if (c == null || c.isEmpty()) return null; + return c.charAt(0); + } + + /** + * Fallback hotkey when markup parsing fails. Assumes {@code root}'s child array order matches + * on-screen row order (1–9, then A–Z); padding or hidden rows can skew keys — {@link Rs2Widget#clickWidget} + * remains the fallback when this returns null. + */ + private static Character computeMoaHotkeyByIndex(Widget root, Widget match) + { + if (root == null || match == null) + { + return null; + } + return Microbot.getClientThread().runOnClientThreadOptional(() -> + { + Widget[] dyn = root.getDynamicChildren(); + Widget[] kids = dyn != null && dyn.length > 0 ? dyn : root.getChildren(); + if (kids == null) return null; + for (int i = 0; i < kids.length; i++) + { + if (kids[i] == match) + { + int idx = i + 1; + if (idx >= 1 && idx <= 9) return (char) ('0' + idx); + int alpha = idx - 10; + if (alpha >= 0 && alpha < 26) return (char) ('A' + alpha); + return null; + } + } + return null; + }).orElse(null); + } +} + diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/SeasonalTransportHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/SeasonalTransportHandler.java new file mode 100644 index 00000000000..e2f72f92ea9 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/SeasonalTransportHandler.java @@ -0,0 +1,22 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +import net.runelite.client.plugins.microbot.shortestpath.Transport; + +/** + * Pluggable seasonal row executor for {@link net.runelite.client.plugins.microbot.util.walker.Rs2Walker}. + * Order is defined by the list passed to {@link net.runelite.client.plugins.microbot.util.walker.Rs2Walker#setSeasonalTransportHandlers}. + */ +public interface SeasonalTransportHandler +{ + /** + * Cheap gate before {@link #tryUse(Transport)} — should not open UI or block. + */ + boolean matches(Transport transport); + + /** + * Attempts this handler's seasonal flow (not on the RuneLite client thread — same rules as Leagues/MoA). + * + * @return {@code true} when this handler consumed the transport attempt successfully + */ + boolean tryUse(Transport transport); +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/SeasonalTransportHandlers.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/SeasonalTransportHandlers.java new file mode 100644 index 00000000000..4ac7265d05d --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/SeasonalTransportHandlers.java @@ -0,0 +1,50 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +import net.runelite.client.plugins.microbot.shortestpath.Transport; + +import java.util.List; + +/** + * Built-in {@link SeasonalTransportHandler} instances and default ordering (Leagues Area, then Map of Alacrity). + */ +public final class SeasonalTransportHandlers +{ + private SeasonalTransportHandlers() + { + } + + public static final SeasonalTransportHandler LEAGUES_AREA = new SeasonalTransportHandler() + { + @Override + public boolean matches(Transport transport) + { + return Rs2LeaguesTransport.matchesLeaguesAreaTransportPrefix(transport); + } + + @Override + public boolean tryUse(Transport transport) + { + return Rs2LeaguesTransport.tryHandleLeaguesAreaTransport(transport); + } + }; + + public static final SeasonalTransportHandler MAP_OF_ALACRITY = new SeasonalTransportHandler() + { + @Override + public boolean matches(Transport transport) + { + return Rs2MapOfAlacrityTransport.isMapOfAlacrityTransport(transport); + } + + @Override + public boolean tryUse(Transport transport) + { + return Rs2MapOfAlacrityTransport.tryUse(transport); + } + }; + + public static List defaultHandlerList() + { + return List.of(LEAGUES_AREA, MAP_OF_ALACRITY); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/logging/Rs2LogRateLimit.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/logging/Rs2LogRateLimit.java new file mode 100644 index 00000000000..62571a115b4 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/logging/Rs2LogRateLimit.java @@ -0,0 +1,39 @@ +package net.runelite.client.plugins.microbot.util.logging; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Tiny helpers for rate-limited logging patterns used across long-lived clients. + * Keeps call sites readable and consistent. + */ +public final class Rs2LogRateLimit +{ + private Rs2LogRateLimit() + { + } + + /** + * @return {@code true} on first successful {@code compareAndSet(false, true)} for {@code token}. + * Callers may arm another one-shot window with {@code token.set(false)}. + */ + public static boolean once(AtomicBoolean token) + { + return token != null && token.compareAndSet(false, true); + } + + /** + * Increment and return true when counter hits 1 or every {@code interval} after. + * Useful for "log summary every N occurrences". + */ + public static boolean everyN(AtomicInteger counter, int interval) + { + if (counter == null || interval <= 0) + { + return false; + } + int n = counter.incrementAndGet(); + return n == 1 || n % interval == 0; + } +} + diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelper.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelper.java index 18e70f316f7..0e305528652 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelper.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelper.java @@ -9,6 +9,7 @@ import net.runelite.client.plugins.microbot.util.antiban.Rs2AntibanSettings; import net.runelite.client.plugins.microbot.util.math.Rs2Random; import net.runelite.client.plugins.microbot.util.menu.NewMenuEntry; +import net.runelite.client.plugins.microbot.util.text.Rs2TextSanitizer; import java.awt.*; import java.util.concurrent.ThreadLocalRandom; @@ -18,10 +19,6 @@ @Slf4j public class Rs2UiHelper { - public static final Pattern COL_TAG_PATTERN = Pattern.compile("]+>|"); - // Regex to extract base name and numeric suffix, e.g., "Super attack (4)" -> "Super attack", 4 - public static final Pattern ITEM_NAME_SUFFIX_PATTERN = Pattern.compile("^(.*?)(?:\\s*\\((\\d+)\\))?$"); - public static boolean isRectangleWithinViewport(Rectangle rectangle) { int viewportHeight = Microbot.getClient().getViewportHeight(); int viewportWidth = Microbot.getClient().getViewportWidth(); @@ -139,15 +136,31 @@ public static boolean isGameObject(NewMenuEntry entry) { } /** - * Strips color tags from the provided text. + * Strips RuneLite/Jagex markup tags from the provided text. * - * @param text the text from which to strip color tags. - * @return the text without color tags. + * @param text the text from which to strip tags. + * @return the text without tags. + * + * @deprecated Use {@link #stripTags(String)} or {@link net.runelite.client.plugins.microbot.util.text.Rs2TextSanitizer#stripTags(String)}. */ + @Deprecated public static String stripColTags(String text) { - return text != null ? COL_TAG_PATTERN.matcher(text).replaceAll("") : ""; + // Historic API name; in practice callers want RuneLite/Jagex markup removed, not only . + return Rs2TextSanitizer.stripTags(text); } + /** Strip RuneLite/Jagex markup tags from text. */ + public static String stripTags(String text) + { + return Rs2TextSanitizer.stripTags(text); + } + + /** Strip tags and normalize whitespace to single spaces. */ + public static String stripTagsToSpace(String text) + { + return Rs2TextSanitizer.stripTagsToSpace(text); + } + public static Rectangle getDefaultRectangle() { int randomValue = ThreadLocalRandom.current().nextInt(3) - 1; return new Rectangle(randomValue, randomValue, Microbot.getClient().getCanvasWidth(), Microbot.getClient().getCanvasHeight()); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/text/Rs2TextSanitizer.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/text/Rs2TextSanitizer.java new file mode 100644 index 00000000000..2f56478556d --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/text/Rs2TextSanitizer.java @@ -0,0 +1,350 @@ +package net.runelite.client.plugins.microbot.util.text; + +import java.text.Normalizer; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalInt; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import net.runelite.client.util.Text; + +/** + * RuneLite/Jagex text cleanup helpers. + * + * Scope: removes RuneLite-style markup tags (e.g. {@code }) and decodes the small set of entities/escapes we + * actively see in chat/widget strings. Not a general-purpose HTML sanitizer. + */ +public final class Rs2TextSanitizer +{ + private Rs2TextSanitizer() + { + } + + /** Allows empty {@code <>} as well as non-empty tags (chat sometimes emits zero-length markup). */ + private static final Pattern TAG_STRIP = Pattern.compile("<[^>]*>"); + private static final Pattern DEC_ENTITY = Pattern.compile("&#(\\d{1,7});"); + private static final Pattern HEX_ENTITY = Pattern.compile("&#(?i)x([0-9a-fA-F]{1,6});"); + // Extract base name and numeric suffix, e.g. "Super attack (4)" -> "Super attack", 4 + private static final Pattern ITEM_NAME_SUFFIX_PATTERN = Pattern.compile("^(.*?)(?:\\s*\\((\\d+)\\))?$"); + + /** Strip markup tags, repeatedly, and drop dangling {@code <} with no {@code >}. */ + /** + * Fullwidth / compatibility Unicode colons → ASCII {@code ':'} for prefix parsing (Leagues Area titles, MoA). + */ + public static String normalizeAsciiColons(String raw) + { + if (raw == null || raw.isEmpty()) + { + return raw == null ? "" : raw; + } + return raw.replace('\uFF1A', ':').replace('\uFE55', ':').replace('\u2236', ':'); + } + + public static String stripTags(String raw) + { + if (raw == null || raw.isEmpty()) + { + return ""; + } + String s = raw; + String prev; + do + { + prev = s; + s = TAG_STRIP.matcher(s).replaceAll(""); + } + while (!s.equals(prev)); + + for (;;) + { + int lt = s.indexOf('<'); + if (lt < 0) + { + break; + } + int gt = s.indexOf('>', lt); + if (gt >= 0) + { + break; + } + s = s.substring(0, lt) + s.substring(lt + 1); + } + return s; + } + + /** Strip tags and replace them with a single space (useful for tokenization/matching). */ + public static String stripTagsToSpace(String raw) + { + if (raw == null || raw.isEmpty()) + { + return ""; + } + String s = raw; + String prev; + do + { + prev = s; + s = TAG_STRIP.matcher(s).replaceAll(" "); + } + while (!s.equals(prev)); + s = s.replace('<', ' '); + return s.replaceAll("\\s+", " ").trim(); + } + + /** + * Widget-friendly cleanup: handles {@code
} and compresses spaces, then applies our known entity decode and + * normalization. Intended for parsing widget text, not HTML. + */ + public static String sanitizeWidgetMultilineText(String raw) + { + if (raw == null || raw.isEmpty()) + { + return ""; + } + // Text.sanitizeMultilineText removes tags and handles
-> space + space compression. + String s = Text.sanitizeMultilineText(raw); + // No extra stripTags: Text.sanitizeMultilineText already removed tags. + return normalizeApostrophes(decodeKnownEntities(normalizeGameText(s))).trim(); + } + + /** + * Decode entities observed in game strings. + * - numeric decimal/hex entities + * - a small named entity set we actively see + */ + public static String decodeKnownEntities(String raw) + { + if (raw == null || raw.isEmpty()) + { + return ""; + } + String s = raw; + for (int pass = 0; pass < 4; pass++) + { + String next = replaceNumericEntities(replaceNumericEntitiesHex(s)) + .replace("&", "&").replace("<", "<").replace(">", ">") + .replace(""", "\"").replace(""", "\"") + .replace("'", "'").replace("'", "'") + .replace(" ", " ").replace(" ", " ") + .replace("­", "").replace("­", "") + .replace("–", "\u2013").replace("—", "\u2014") + .replace("…", "\u2026").replace("‘", "\u2018").replace("’", "\u2019") + .replace("“", "\u201c").replace("”", "\u201d") + .replace("½", "\u00BD").replace("¼", "\u00BC").replace("¾", "\u00BE") + .replace("é", "\u00E9").replace("É", "\u00C9") + .replace("á", "\u00E1").replace("Á", "\u00C1") + .replace("í", "\u00ED").replace("Í", "\u00CD") + .replace("ó", "\u00F3").replace("Ó", "\u00D3") + .replace("ú", "\u00FA").replace("Ú", "\u00DA") + .replace("à", "\u00E0").replace("è", "\u00E8").replace("ò", "\u00F2") + .replace("â", "\u00E2").replace("ê", "\u00EA").replace("ô", "\u00F4") + .replace("ñ", "\u00F1").replace("ç", "\u00E7").replace("Ç", "\u00C7"); + if (next.equals(s)) + { + break; + } + s = next; + } + return s; + } + + public static String normalizeApostrophes(String raw) + { + if (raw == null || raw.isEmpty()) + { + return ""; + } + return raw + .replace('\u2019', '\'') + .replace('\u2018', '\'') + .replace('\u02BC', '\''); + } + + /** NFKC + NBSP/NNBSP to space + soft hyphen removal. */ + public static String normalizeGameText(String raw) + { + if (raw == null || raw.isEmpty()) + { + return ""; + } + return Normalizer.normalize(raw, Normalizer.Form.NFKC) + .replace('\u00A0', ' ') + .replace('\u202F', ' ') + .replace("\u00AD", ""); + } + + public static String normalizeGameTextLower(String raw) + { + return normalizeGameText(raw).toLowerCase(Locale.ROOT); + } + + /** + * Cleanup for parsing: decode entities, strip tags, normalize apostrophes, trim, and lowercase. + * Intended for comparisons / matching, not for UI display. Null returns empty string. + */ + public static String sanitizeForParsing(String raw) + { + return sanitizeCore(raw).toLowerCase(Locale.ROOT); + } + + /** + * Cleanup for region name matching (Leagues locked chat): decode entities + strip tags + normalize apostrophes + trim. + * Caller decides casing. + */ + public static String sanitizeLeaguesLockedRegionName(String raw) + { + return sanitizeCore(raw); + } + + /** @return trimmed, non-empty {@code group(1)} from first {@link Matcher#find()} match; otherwise empty. */ + public static Optional captureFirstGroup(Pattern pattern, String input) + { + if (pattern == null || input == null || input.isEmpty()) + { + return Optional.empty(); + } + Matcher m = pattern.matcher(input); + if (!m.find()) + { + return Optional.empty(); + } + String g1 = m.group(1); + if (g1 == null) + { + return Optional.empty(); + } + String trimmed = g1.trim(); + return trimmed.isEmpty() ? Optional.empty() : Optional.of(trimmed); + } + + private static String sanitizeCore(String raw) + { + if (raw == null) + { + return ""; + } + return normalizeApostrophes(stripTags(decodeKnownEntities(normalizeGameText(raw)))).trim(); + } + + public static final class ItemNameWithSuffix + { + private final String baseName; + private final OptionalInt suffix; + + private ItemNameWithSuffix(String baseName, OptionalInt suffix) + { + this.baseName = Objects.requireNonNull(baseName, "baseName"); + this.suffix = Objects.requireNonNull(suffix, "suffix"); + } + + public String getBaseName() + { + return baseName; + } + + /** @return numeric suffix in "(N)" if present. */ + public OptionalInt getSuffix() + { + return suffix; + } + } + + /** + * Parses a name optionally suffixed with "(N)" (e.g. potion doses). + * Strips tags and trims before matching. + */ + public static Optional parseItemNameSuffix(String raw) + { + if (raw == null) + { + return Optional.empty(); + } + String s = stripTags(raw).trim(); + if (s.isEmpty()) + { + return Optional.empty(); + } + Matcher m = ITEM_NAME_SUFFIX_PATTERN.matcher(s); + if (!m.matches()) + { + return Optional.empty(); + } + String base = m.group(1) != null ? m.group(1).trim() : ""; + if (base.isEmpty()) + { + return Optional.empty(); + } + OptionalInt suffix = OptionalInt.empty(); + String suffixRaw = m.group(2); + if (suffixRaw != null && !suffixRaw.isEmpty()) + { + try + { + suffix = OptionalInt.of(Integer.parseInt(suffixRaw)); + } + catch (NumberFormatException ignored) + { + return Optional.empty(); + } + } + return Optional.of(new ItemNameWithSuffix(base, suffix)); + } + + private static String replaceNumericEntities(String s) + { + Matcher m = DEC_ENTITY.matcher(s); + StringBuilder out = new StringBuilder(); + int last = 0; + while (m.find()) + { + out.append(s, last, m.start()); + try + { + out.append(codePointToString(Integer.parseInt(m.group(1)))); + } + catch (IllegalArgumentException ex) + { + out.append(m.group()); + } + last = m.end(); + } + out.append(s, last, s.length()); + return out.toString(); + } + + private static String replaceNumericEntitiesHex(String s) + { + Matcher m = HEX_ENTITY.matcher(s); + StringBuilder out = new StringBuilder(); + int last = 0; + while (m.find()) + { + out.append(s, last, m.start()); + try + { + out.append(codePointToString(Integer.parseInt(m.group(1), 16))); + } + catch (IllegalArgumentException ex) + { + out.append(m.group()); + } + last = m.end(); + } + out.append(s, last, s.length()); + return out.toString(); + } + + private static String codePointToString(int cp) + { + if (cp < 0 || cp > Character.MAX_CODE_POINT) + { + throw new IllegalArgumentException("cp"); + } + return cp < Character.MIN_SUPPLEMENTARY_CODE_POINT + ? String.valueOf((char) cp) + : new String(Character.toChars(cp)); + } +} + diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java index 3193ce542c2..a28bf33859f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/tile/Rs2Tile.java @@ -24,6 +24,8 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; +import java.util.function.Supplier; import java.util.stream.Collectors; public abstract class Rs2Tile implements Tile { @@ -48,6 +50,24 @@ public abstract class Rs2Tile implements Tile { private static final ThreadLocal BUFFER_X_TL = ThreadLocal.withInitial(() -> new int[4096]); private static final ThreadLocal BUFFER_Y_TL = ThreadLocal.withInitial(() -> new int[4096]); + /** + * Runs collision / scene reads on the client thread when the caller is off-thread (e.g. script executor). + */ + private static T runClientRead(Supplier supplier, T ifNoClient) { + final Client client = Microbot.getClient(); + if (client == null) { + return ifNoClient; + } + if (client.isClientThread()) { + return supplier.get(); + } + return Microbot.getClientThread().invoke(supplier); + } + + private static boolean runClientReadBoolean(BooleanSupplier action) { + return Boolean.TRUE.equals(runClientRead(() -> action.getAsBoolean(), false)); + } + /** * Initializes the tile executor * This will handle the removal of dangerous tiles after a certain amount of time @@ -187,22 +207,32 @@ public static WorldPoint getSafeTile() { } public static boolean isWalkable(Tile tile) { + return runClientReadBoolean(() -> isWalkableTileInternal(tile)); + } + + private static boolean isWalkableTileInternal(Tile tile) { if (tile == null) return false; - final int[][] flags = getFlags(); + final int[][] flags = getFlagsInternal(); if (flags == null) return false; return isWalkable(flags, tile.getSceneLocation().getX(), tile.getSceneLocation().getY()); } public static boolean isWalkable(WorldPoint worldPoint) { + return runClientReadBoolean(() -> isWalkableWorldPointInternal(worldPoint)); + } + + private static boolean isWalkableWorldPointInternal(WorldPoint worldPoint) { + if (worldPoint == null) return false; + final WorldView wv = Microbot.getClient().getTopLevelWorldView(); if (wv == null) return false; - return isWalkable(LocalPoint.fromWorld(wv, worldPoint)); + return isWalkableLocalInternal(LocalPoint.fromWorld(wv, worldPoint)); } - private static int[][] getFlags() { + private static int[][] getFlagsInternal() { final WorldView wv = Microbot.getClient().getTopLevelWorldView(); if (wv == null) return null; @@ -227,9 +257,13 @@ private static boolean isWalkable(int[][] flags, int x, int y) { } public static boolean isWalkable(LocalPoint localPoint) { + return runClientReadBoolean(() -> isWalkableLocalInternal(localPoint)); + } + + private static boolean isWalkableLocalInternal(LocalPoint localPoint) { if (localPoint == null) return false; - final int[][] flags = getFlags(); + final int[][] flags = getFlagsInternal(); if (flags == null) return false; final int data = flags[localPoint.getSceneX()][localPoint.getSceneY()]; @@ -241,21 +275,30 @@ public static List getWalkableTilesAroundPlayer(int radius) { } public static List getWalkableTilesAroundTile(WorldPoint point, int radius) { + return runClientRead(() -> getWalkableTilesAroundTileInternal(point, radius), Collections.emptyList()); + } + + private static List getWalkableTilesAroundTileInternal(WorldPoint point, int radius) { + int useRadius = radius; final LocalPoint localPoint = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), point); if (localPoint == null) return Collections.emptyList(); - final int[][] flags = getFlags(); + final int[][] flags = getFlagsInternal(); if (flags == null) return Collections.emptyList(); // this differs from original impl. would return all tiles final int sceneX = localPoint.getSceneX(); final int sceneY = localPoint.getSceneY(); // limit radius to the size of flags - if (sceneX - radius < 0 || sceneX + radius >= FLAG_DATA_SIZE) radius = Math.min(sceneX, FLAG_DATA_SIZE - sceneX - 1); - if (sceneY - radius < 0 || sceneY + radius >= FLAG_DATA_SIZE) radius = Math.min(sceneY, FLAG_DATA_SIZE - sceneY - 1); + if (sceneX - useRadius < 0 || sceneX + useRadius >= FLAG_DATA_SIZE) { + useRadius = Math.min(sceneX, FLAG_DATA_SIZE - sceneX - 1); + } + if (sceneY - useRadius < 0 || sceneY + useRadius >= FLAG_DATA_SIZE) { + useRadius = Math.min(sceneY, FLAG_DATA_SIZE - sceneY - 1); + } final List worldPoints = new ArrayList<>(); - for (int dx = -radius; dx <= radius; dx++) { - for (int dy = -radius; dy <= radius; dy++) { + for (int dx = -useRadius; dx <= useRadius; dx++) { + for (int dy = -useRadius; dy <= useRadius; dy++) { if (dx == 0 && dy == 0) continue; // Skip the player's current position if (!isWalkable(flags, sceneX + dx, sceneY + dy)) continue; worldPoints.add(new WorldPoint(point.getX() + dx, point.getY() + dy, point.getPlane())); @@ -304,6 +347,10 @@ public static HashMap getReachableTilesFromTileIgnoreCollis * @return A HashMap containing WorldPoints and their corresponding distances from the start tile. */ public static HashMap getReachableTilesFromTile(WorldPoint tile, int distance, boolean ignoreCollision) { + return runClientRead(() -> getReachableTilesFromTileInternal(tile, distance, ignoreCollision), new HashMap<>()); + } + + private static HashMap getReachableTilesFromTileInternal(WorldPoint tile, int distance, boolean ignoreCollision) { final HashMap tileDistances = new HashMap<>(); tileDistances.put(tile, 0); @@ -393,6 +440,10 @@ public static HashMap getReachableTilesFromTile(WorldPoint * otherwise false. */ public static boolean isTileReachable(WorldPoint targetPoint) { + return runClientReadBoolean(() -> isTileReachableInternal(targetPoint)); + } + + private static boolean isTileReachableInternal(WorldPoint targetPoint) { if (targetPoint == null) return false; final WorldPoint playerLoc = Rs2Player.getWorldLocation(); @@ -402,7 +453,7 @@ public static boolean isTileReachable(WorldPoint targetPoint) { if (CollisionMap.ignoreCollisionPacked.contains(WorldPointUtil.packWorldPoint(targetPoint))) return true; final boolean[][] visited = new boolean[FLAG_DATA_SIZE][FLAG_DATA_SIZE]; - final int[][] flags = getFlags(); + final int[][] flags = getFlagsInternal(); if (flags == null) return false; final int startX; @@ -447,6 +498,10 @@ public static boolean isTileReachable(WorldPoint targetPoint) { * @return true if any surrounding tile is walkable, false otherwise. */ public static boolean areSurroundingTilesWalkable(WorldPoint worldPoint, int sizeX, int sizeY) { + return runClientReadBoolean(() -> areSurroundingTilesWalkableInternal(worldPoint, sizeX, sizeY)); + } + + private static boolean areSurroundingTilesWalkableInternal(WorldPoint worldPoint, int sizeX, int sizeY) { int plane = worldPoint.getPlane(); // Calculate the boundaries of the object @@ -464,7 +519,7 @@ public static boolean areSurroundingTilesWalkable(WorldPoint worldPoint, int siz } // Check if the surrounding tile is walkable - if (isTileReachable(new WorldPoint(x, y, plane))) { + if (isTileReachableInternal(new WorldPoint(x, y, plane))) { return true; } } @@ -543,7 +598,10 @@ private static boolean isVisited(WorldPoint worldPoint, boolean[][] visited) { int x = 0; int y = 0; if (Microbot.getClient().getTopLevelWorldView().getScene().isInstance()) { - LocalPoint localPoint = Rs2Player.getLocalLocation(); + LocalPoint localPoint = Rs2LocalPoint.fromWorldInstance(worldPoint); + if (localPoint == null) { + return false; + } x = localPoint.getSceneX(); y = localPoint.getSceneY(); } else { @@ -597,10 +655,14 @@ private static WorldPoint getNeighbour(Direction direction, WorldPoint source) { * @return The nearest walkable tile, or null if no walkable tile is found. */ public static WorldPoint getNearestWalkableTile(WorldPoint source) { + return runClientRead(() -> getNearestWalkableTileWorldInternal(source), null); + } + + private static WorldPoint getNearestWalkableTileWorldInternal(WorldPoint source) { for (Direction direction : Direction.values()) { WorldPoint neighbour = getNeighbour(direction, source); if (neighbour.equals(Rs2Player.getWorldLocation())) continue; - if (isWalkable(neighbour)) { + if (isWalkableWorldPointInternal(neighbour)) { return neighbour; } } @@ -629,19 +691,26 @@ public static WorldPoint getNearestWalkableTile(WorldPoint source) { * tile is found. */ public static WorldPoint getNearestWalkableTileWithLineOfSight(WorldPoint source) { + return runClientRead(() -> getNearestWalkableTileWithLineOfSightInternal(source), null); + } + + private static WorldPoint getNearestWalkableTileWithLineOfSightInternal(WorldPoint source) { + final WorldView wv = Microbot.getClient().getTopLevelWorldView(); + if (wv == null) return null; + // check if source is walkable - if (!tileHasWalls(source) - && isValidTile(getTile(source.getX(), source.getY())) - && (isWalkable(LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), source.getX(), source.getY())) || isBankBooth(source))) { + if (!tileHasWallsInternal(source) + && isValidTileInternal(getTileInternal(source.getX(), source.getY())) + && (isWalkableLocalInternal(LocalPoint.fromWorld(wv, source.getX(), source.getY())) || isBankBoothInternal(source))) { return source; } //check if neightbours are walkable for (Direction direction : Direction.values()) { WorldPoint neighbour = getNeighbour(direction, source); if (neighbour.equals(Rs2Player.getWorldLocation())) continue; - if (!tileHasWalls(neighbour) - && isValidTile(getTile(neighbour.getX(), neighbour.getY())) - && (isWalkable(LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), neighbour.getX(), neighbour.getY())) || isBankBooth(neighbour))) { + if (!tileHasWallsInternal(neighbour) + && isValidTileInternal(getTileInternal(neighbour.getX(), neighbour.getY())) + && (isWalkableLocalInternal(LocalPoint.fromWorld(wv, neighbour.getX(), neighbour.getY())) || isBankBoothInternal(neighbour))) { return neighbour; } } @@ -660,6 +729,10 @@ && isValidTile(getTile(neighbour.getX(), neighbour.getY())) * @return An {@link Rs2WorldPoint} representing the nearest walkable tile around the object, or {@code null} if none are found. */ public static Rs2WorldPoint getNearestWalkableTile(GameObject tileObject) { + return runClientRead(() -> getNearestWalkableTileForObjectInternal(tileObject), null); + } + + private static Rs2WorldPoint getNearestWalkableTileForObjectInternal(GameObject tileObject) { // Cache player's location and top-level world view Rs2WorldPoint playerLocation = Rs2Player.getRs2WorldPoint(); WorldView topLevelWorldView = Microbot.getClient().getTopLevelWorldView(); @@ -681,7 +754,7 @@ public static Rs2WorldPoint getNearestWalkableTile(GameObject tileObject) { // Filter points that are walkable List walkablePoints = interactablePoints.stream() - .filter(Rs2Tile::isWalkable) + .filter(Rs2Tile::isWalkableWorldPointInternal) .collect(Collectors.toList()); if (walkablePoints.isEmpty()) { @@ -730,7 +803,7 @@ private static List getInteractablePoints(Rs2WorldArea gameObjectAre if (interactablePoints.isEmpty()) { // If no melee points, remove points with walls interactablePoints = gameObjectArea.getInteractable(); - interactablePoints.removeIf(Rs2Tile::tileHasWalls); + interactablePoints.removeIf(Rs2Tile::tileHasWallsInternal); } } @@ -752,6 +825,10 @@ private static List getInteractablePoints(Rs2WorldArea gameObjectAre * @return True if the tile has walls or obstacles, false otherwise. */ public static boolean tileHasWalls(WorldPoint source) { + return runClientReadBoolean(() -> tileHasWallsInternal(source)); + } + + private static boolean tileHasWallsInternal(WorldPoint source) { return Rs2GameObject.getWallObjects().stream().filter(x -> x.getWorldLocation().equals(source)).findFirst().orElse(null) != null; } @@ -769,6 +846,10 @@ public static boolean tileHasWalls(WorldPoint source) { * @return True if the tile contains a bank booth, false otherwise. */ public static boolean isBankBooth(WorldPoint source) { + return runClientReadBoolean(() -> isBankBoothInternal(source)); + } + + private static boolean isBankBoothInternal(WorldPoint source) { GameObject gameObject = Rs2GameObject.getGameObjects().stream().filter(x -> x.getWorldLocation().equals(source)).findFirst().orElse(null); if (gameObject != null) { ObjectComposition objectComposition = Rs2GameObject.convertToObjectComposition(gameObject); @@ -792,6 +873,10 @@ public static boolean isBankBooth(WorldPoint source) { * @return The Tile at the specified coordinates, or null if the tile is invalid or not in the scene. */ public static Tile getTile(int x, int y) { + return runClientRead(() -> getTileInternal(x, y), null); + } + + private static Tile getTileInternal(int x, int y) { WorldPoint worldPoint = new WorldPoint(x, y, Microbot.getClient().getTopLevelWorldView().getPlane()); LocalPoint localPoint; @@ -820,6 +905,10 @@ public static Tile getTile(int x, int y) { * @return True if the tile is valid (not blocked by movement restrictions), false otherwise. */ public static boolean isValidTile(Tile tile) { + return runClientReadBoolean(() -> isValidTileInternal(tile)); + } + + private static boolean isValidTileInternal(Tile tile) { if (tile == null) return false; int[][] flags = Microbot.getClient().getCollisionMaps()[Microbot.getClient().getPlane()].getFlags(); int data = flags[tile.getSceneLocation().getX()][tile.getSceneLocation().getY()]; @@ -845,6 +934,10 @@ public static boolean isValidTile(Tile tile) { * or null if no path is found. */ public static List fullPathTo(Tile source, Tile other) { + return runClientRead(() -> fullPathToInternal(source, other), null); + } + + private static List fullPathToInternal(Tile source, Tile other) { int z = source.getPlane(); if (z != other.getPlane()) { return null; @@ -1070,9 +1163,11 @@ public static List fullPathTo(Tile source, Tile other) { * @param other The destination tile to reach. * @return A list of tiles representing the path from source to destination, or null if no path is found. */ - public static List pathTo(Tile source,Tile other) - { + public static List pathTo(Tile source, Tile other) { + return runClientRead(() -> pathToInternal(source, other), null); + } + private static List pathToInternal(Tile source, Tile other) { int z = source.getPlane(); if (z != other.getPlane()) { diff --git a/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java b/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java index 3d2837b206b..7f1d29b0a24 100644 --- a/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java +++ b/runelite-client/src/main/java/net/runelite/client/ui/ClientUI.java @@ -615,7 +615,7 @@ else if (OSType.getOSType() == OSType.MacOS && SystemInfo.isMacFullWindowContent configManager.setConfiguration(QuestHelperConfig.QUEST_HELPER_GROUP, "TurnOn", !isEnabled); questHelperNavBtn.setIcon(new ImageIcon(!isEnabled ? questIconOn : questIconOff )); questHelperNavBtn.setToolTipText(!isEnabled ? "Disable 'Semi-Auto' Questing" : "Enable 'Semi-Auto' Questing"); - if (isEnabled) Rs2Walker.setTarget(null); + if (isEnabled) Rs2Walker.clearWalkingRoute("client-ui:quest-helper-toggle-disable"); }) .build(), false ); diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportChatTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportChatTest.java new file mode 100644 index 00000000000..13bba8474a0 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportChatTest.java @@ -0,0 +1,149 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +/** + * Locked-region gametext: regex capture must yield phrases that {@link LeaguesTransportRegions#parseRegionNameNormalized} + * can map to {@link LeaguesRegion} — tests use region-tier wording (Misthalin, Kandarin, …), not city labels. + */ +public class LeaguesTransportChatTest +{ + private static final int CAP = 4096; + private static final String PHRASE = "You haven't unlocked access to the Misthalin area."; + + @Test + public void capturesRegionPlain() + { + assertEquals("misthalin", + LeaguesTransportChat.leaguesLockedRegionCapturedRegionAfterNormalizeForTests( + "You haven't unlocked access to the Misthalin area")); + } + + @Test + public void capturesRegionWithTrailingPeriod() + { + assertEquals("misthalin", + LeaguesTransportChat.leaguesLockedRegionCapturedRegionAfterNormalizeForTests( + "You haven't unlocked access to the Misthalin area.")); + } + + @Test + public void capturesRegionWithTrailingComma() + { + assertEquals("kandarin", + LeaguesTransportChat.leaguesLockedRegionCapturedRegionAfterNormalizeForTests( + "You haven't unlocked access to the Kandarin area,")); + } + + @Test + public void capturesRegionWithTrailingParen() + { + assertEquals("kourend", + LeaguesTransportChat.leaguesLockedRegionCapturedRegionAfterNormalizeForTests( + "You haven't unlocked access to the Kourend area)")); + } + + @Test + public void capturesRegionWithParentheticalAfterArea() + { + assertEquals("asgarnia", + LeaguesTransportChat.leaguesLockedRegionCapturedRegionAfterNormalizeForTests( + "You haven't unlocked access to the Asgarnia area (retry)")); + } + + @Test + public void capturesRegionWithUnicodeEllipsisTail() + { + assertEquals("misthalin", + LeaguesTransportChat.leaguesLockedRegionCapturedRegionAfterNormalizeForTests( + "You haven't unlocked access to the Misthalin area\u2026")); + } + + @Test + public void greedyLastAreaWinsWhenInnerAreaInName() + { + assertEquals("southern desert", + LeaguesTransportChat.leaguesLockedRegionCapturedRegionAfterNormalizeForTests( + "You haven't unlocked access to the southern desert area.")); + } + + @Test + public void stripsColourTagsAndSmartApostrophe() + { + assertEquals("misthalin", + LeaguesTransportChat.leaguesLockedRegionCapturedRegionAfterNormalizeForTests( + "You haven\u2019t unlocked access to the Misthalin area.")); + } + + @Test + public void noMatchWrongCopy() + { + assertNull(LeaguesTransportChat.leaguesLockedRegionCapturedRegionAfterNormalizeForTests( + "Welcome to Misthalin area")); + } + + @Test + public void gateMatchesExpectedCopy() + { + assertEquals(true, LeaguesTransportChat.isLeaguesLockedAccessMessage( + "You haven't unlocked access to the Misthalin area.")); + } + + @Test + public void gateMatchesGliderCopy() + { + assertEquals(true, LeaguesTransportChat.isLeaguesLockedAccessMessage( + "You cannot take a glider to that destination as you don't have access to the Kharidian Desert area.")); + } + + @Test + public void gateMatchesBlockedTeleportPrefixCopy() + { + assertEquals(true, LeaguesTransportChat.isLeaguesLockedAccessMessage( + "Your teleport is blocked as you haven't unlocked access to the Asgarnia area.")); + } + + @Test + public void capturesRegionBlockedTeleportPrefixCopy() + { + assertEquals("asgarnia", + LeaguesTransportChat.leaguesLockedRegionCapturedRegionAfterNormalizeForTests( + "Your teleport is blocked as you haven't unlocked access to the Asgarnia area.")); + } + + @Test + public void gateRejectsWrongOrder() + { + assertEquals(false, LeaguesTransportChat.isLeaguesLockedAccessMessage( + "Area unlocked access to the Misthalin.")); + } + + @Test + public void clipPreservesMatchWhenPhraseInsideCap() + { + int prefixLen = CAP - PHRASE.length(); + StringBuilder prefix = new StringBuilder(prefixLen); + for (int i = 0; i < prefixLen; i++) + { + prefix.append('x'); + } + String raw = prefix + PHRASE; + assertEquals(CAP, raw.length()); + assertEquals("misthalin", LeaguesTransportChat.leaguesLockedRegionCapturedRegionAfterNormalizeForTests(raw)); + } + + @Test + public void clipDropsMatchWhenPhraseOnlyAfterCutoff() + { + StringBuilder prefix = new StringBuilder(CAP); + for (int i = 0; i < CAP; i++) + { + prefix.append('y'); + } + String raw = prefix + PHRASE; + assertNull(LeaguesTransportChat.leaguesLockedRegionCapturedRegionAfterNormalizeForTests(raw)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportLockCatalogueTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportLockCatalogueTest.java new file mode 100644 index 00000000000..0a6649de011 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportLockCatalogueTest.java @@ -0,0 +1,21 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public class LeaguesTransportLockCatalogueTest +{ + @Test + public void normalizeMethodStripsHandlerSuffix() + { + String raw = "TELEPORTATION_SPELL:Lumbridge Teleport|handler=Foo"; + assertEquals("TELEPORTATION_SPELL:Lumbridge Teleport", LeaguesTransportLockCatalogue.normalizeLockCatalogueMethod(raw)); + } + + @Test + public void buildDedupeKeyJoinsPackedDestAndMethod() + { + assertEquals("12345|SPELL:Foo", LeaguesTransportLockCatalogue.buildDedupeKey(12345, "SPELL:Foo")); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2LeaguesTransportRegionParseTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2LeaguesTransportRegionParseTest.java new file mode 100644 index 00000000000..282d0a124a8 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2LeaguesTransportRegionParseTest.java @@ -0,0 +1,62 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +import org.junit.Test; + +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; + +/** + * Table-style coverage for {@link Rs2LeaguesTransport#parseRegionName(String)} and locked-region capture + * (audit Tier 5.4). + */ +public class Rs2LeaguesTransportRegionParseTest +{ + @Test + public void parseRegionName_mapsEachEnumDisplayName() + { + for (LeaguesRegion r : LeaguesRegion.values()) + { + assertEquals(r, Rs2LeaguesTransport.parseRegionName(r.getDisplayName())); + } + } + + @Test + public void parseRegionName_trimsAndLowercases() + { + assertEquals(LeaguesRegion.MISTHALIN, Rs2LeaguesTransport.parseRegionName(" MISTHALIN ")); + assertEquals(LeaguesRegion.KEBOS_AND_KOUREND, Rs2LeaguesTransport.parseRegionName("great kourend")); + } + + @Test + public void parseRegionName_unicodeApostropheNormalized() + { + assertEquals(LeaguesRegion.MISTHALIN, Rs2LeaguesTransport.parseRegionName("Misthalin")); + assertEquals(LeaguesRegion.FREMENNIK, Rs2LeaguesTransport.parseRegionName("Fremennik Province")); + } + + @Test + public void parseRegionName_unknownReturnsNull() + { + assertNull(Rs2LeaguesTransport.parseRegionName("Aethermoor")); + assertNull(Rs2LeaguesTransport.parseRegionName("")); + assertNull(Rs2LeaguesTransport.parseRegionName(" ")); + } + + @Test + public void captureLockedRegionFromChatRaw_extractsRegionPhrase() + { + Optional cap = Rs2LeaguesTransport.captureLockedRegionFromChatRaw( + "You haven't unlocked access to the Misthalin area yet."); + assertEquals(Optional.of("misthalin"), cap); + } + + @Test + public void captureLockedRegionFromChatRaw_longRegionNameMatches() + { + Optional cap = Rs2LeaguesTransport.captureLockedRegionFromChatRaw( + "You haven't unlocked access to the Great Kourend and Kebos Lowlands area."); + assertEquals(Optional.of("great kourend and kebos lowlands"), cap); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2LeaguesTransportShouldRecalculatePathAfterLockTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2LeaguesTransportShouldRecalculatePathAfterLockTest.java new file mode 100644 index 00000000000..9389806fecf --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2LeaguesTransportShouldRecalculatePathAfterLockTest.java @@ -0,0 +1,30 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +import org.junit.Test; + +import static org.junit.Assert.assertTrue; + +/** + * Guards {@link Rs2LeaguesTransport#shouldRecalculatePathAfterLock} defensive null semantics + * (unknown callers / tests — production chat path normally passes non-null after a successful record). + */ +public class Rs2LeaguesTransportShouldRecalculatePathAfterLockTest +{ + /** + * Arbitrary non-null second argument; {@link Rs2LeaguesTransport#shouldRecalculatePathAfterLock(String, Integer)} + * ignores {@code packedDest} when {@code region == null} (arguments are still evaluated by the caller). + */ + private static final Integer PACKED_DEST_PLACEHOLDER_WHEN_REGION_NULL = 0x13371337; + + @Test + public void nullRegionForcesRecalculateDecisionTrue() + { + assertTrue(Rs2LeaguesTransport.shouldRecalculatePathAfterLock(null, PACKED_DEST_PLACEHOLDER_WHEN_REGION_NULL)); + } + + @Test + public void nullPackedDestForcesRecalculateDecisionTrue() + { + assertTrue(Rs2LeaguesTransport.shouldRecalculatePathAfterLock("Ardougne", null)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2MapOfAlacrityTransportTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2MapOfAlacrityTransportTest.java new file mode 100644 index 00000000000..9e682e0e668 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2MapOfAlacrityTransportTest.java @@ -0,0 +1,45 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +import org.junit.Test; + +import java.util.Optional; + +import static org.junit.Assert.*; + +public class Rs2MapOfAlacrityTransportTest +{ + @Test + public void parsesSpacedDashFormat() + { + Optional opt = Rs2MapOfAlacrityTransport.parseMoaDisplayInfo("Map of Alacrity: Kourend - Castle"); + assertTrue(opt.isPresent()); + assertEquals("Kourend", opt.get().getRegion()); + assertEquals("Castle", opt.get().getShortcutName()); + } + + @Test + public void parsesHyphenFallbackFormat() + { + Optional opt = Rs2MapOfAlacrityTransport.parseMoaDisplayInfo("Map of Alacrity: Kourend-Castle"); + assertTrue(opt.isPresent()); + assertEquals("Kourend", opt.get().getRegion()); + assertEquals("Castle", opt.get().getShortcutName()); + } + + @Test + public void parsesRegionContainingSpacedDash() + { + Optional opt = Rs2MapOfAlacrityTransport.parseMoaDisplayInfo("Map of Alacrity: Kourend - Kingdom - Castle"); + assertTrue(opt.isPresent()); + assertEquals("Kourend - Kingdom", opt.get().getRegion()); + assertEquals("Castle", opt.get().getShortcutName()); + } + + @Test + public void rejectsColonBeforeTitleEnd() + { + Optional opt = Rs2MapOfAlacrityTransport.parseMoaDisplayInfo("Map: of Alacrity: Kourend - Castle"); + assertFalse(opt.isPresent()); + } +} + diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/SeasonalTransportHandlersTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/SeasonalTransportHandlersTest.java new file mode 100644 index 00000000000..7f24588aeb4 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/SeasonalTransportHandlersTest.java @@ -0,0 +1,17 @@ +package net.runelite.client.plugins.microbot.util.leaguetransport; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +public class SeasonalTransportHandlersTest +{ + @Test + public void defaultHandlerList_orderAndSize() + { + assertEquals(2, SeasonalTransportHandlers.defaultHandlerList().size()); + assertSame(SeasonalTransportHandlers.LEAGUES_AREA, SeasonalTransportHandlers.defaultHandlerList().get(0)); + assertSame(SeasonalTransportHandlers.MAP_OF_ALACRITY, SeasonalTransportHandlers.defaultHandlerList().get(1)); + } +} diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/text/Rs2TextSanitizerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/text/Rs2TextSanitizerTest.java new file mode 100644 index 00000000000..2a0a0af33a5 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/text/Rs2TextSanitizerTest.java @@ -0,0 +1,63 @@ +package net.runelite.client.plugins.microbot.util.text; + +import org.junit.Test; + +import java.util.Optional; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +public class Rs2TextSanitizerTest +{ + @Test + public void normalizeAsciiColonsMapsFullwidthAndCompatibilityForms() + { + assertEquals("Leagues Area: Kourend", Rs2TextSanitizer.normalizeAsciiColons("Leagues Area\uFF1a Kourend")); + assertEquals("a:b", Rs2TextSanitizer.normalizeAsciiColons("a\uFE55b")); + assertEquals("a:b", Rs2TextSanitizer.normalizeAsciiColons("a\u2236b")); + } + + @Test + public void stripsTagsAndDecodesEntities() + { + String raw = "Zeah ('Test') and’more"; + String s = Rs2TextSanitizer.sanitizeLeaguesLockedRegionName(raw).replace('\u00A0', ' '); + assertEquals("Zeah ('Test') and'more", s); + } + + @Test + public void dropsDanglingLtAndTrims() + { + String raw = " Kourend< "; + assertEquals("Kourend", Rs2TextSanitizer.sanitizeLeaguesLockedRegionName(raw)); + } + + @Test + public void handlesNumericCodePoints() + { + String raw = "Morytania’s End"; + assertEquals("Morytania's End", Rs2TextSanitizer.sanitizeLeaguesLockedRegionName(raw)); + } + + @Test + public void parsesItemNameSuffix() + { + Optional superAttack = Rs2TextSanitizer.parseItemNameSuffix("Super attack (4)"); + assertEquals("Super attack", superAttack.get().getBaseName()); + assertEquals(4, superAttack.get().getSuffix().getAsInt()); + Optional oakLogs = Rs2TextSanitizer.parseItemNameSuffix("Oak logs"); + assertEquals("Oak logs", oakLogs.get().getBaseName()); + assertFalse(oakLogs.get().getSuffix().isPresent()); + Optional prayerPot = Rs2TextSanitizer.parseItemNameSuffix("Prayer potion (1)"); + assertEquals("Prayer potion", prayerPot.get().getBaseName()); + assertEquals(1, prayerPot.get().getSuffix().getAsInt()); + } + + @Test + public void sanitizeWidgetMultilineTextRemovesTagsAndBr() + { + String raw = "Hello
World !"; + assertEquals("Hello World !", Rs2TextSanitizer.sanitizeWidgetMultilineText(raw).replaceAll("\\s+", " ").trim()); + } +} + From 994214b55405c9fc5e9f6e6e271b2c6621f296da Mon Sep 17 00:00:00 2001 From: Haliax <18099301+TheHaliax@users.noreply.github.com> Date: Mon, 11 May 2026 08:43:31 -0500 Subject: [PATCH 4/9] fix(bank): stabilize mirror cache and setup checks Harden bank mirror sync and inventory setup retain flow so banking state stays trustworthy. --- docs/entity-guides/items.md | 76 +- .../microbot/util/Rs2InventorySetup.java | 760 ++++++++++++++++-- .../plugins/microbot/util/bank/Rs2Bank.java | 361 ++++++++- .../bank/Rs2BankSetupDepositRetainTest.java | 59 ++ 4 files changed, 1149 insertions(+), 107 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/Rs2BankSetupDepositRetainTest.java diff --git a/docs/entity-guides/items.md b/docs/entity-guides/items.md index 069c74b23cb..04d2708bf15 100644 --- a/docs/entity-guides/items.md +++ b/docs/entity-guides/items.md @@ -58,4 +58,78 @@ When you know the *intent* (consume, equip, drop) but not the verb the game uses --- - +## 2. After opening the bank, wait for a live `ItemContainerChanged(BANK)` before trusting `bankItems()` / `hasBankItem` + +`Rs2Bank` mirrors the bank into `Rs2BankData` from `ItemContainerChanged` on the client thread. The interface can report open before the first container event is processed, so a script that calls `hasBankItem` / `withdraw*` in the same tick can see an empty or stale cache and conclude the item is missing. + +**Why this matters:** Intermittent false "not in bank" after `openBank()` and rare races when the cache is one tick behind the widget. + +**Pattern to follow:** + +- Use `Rs2Bank.openBank()` (it waits for a new bank epoch after the UI opens). If you open the bank through a custom path, wait until `ItemContainerChanged` has run or delay one game tick before bulk lookups. +- `hasBankItem` retries 1-2 ticks when the bank is open and the first lookup saw quantity zero (insufficient quantity still fails immediately). + +**Where this applies:** `Rs2Bank.openBank`, `updateLocalBank`, `hasBankItem`, `count`, `findBankItem` call sites. + +**Defensive check:** Enable DEBUG and watch for `[Rs2Bank] hasBankItem miss after cache retry` or `no BANK ItemContainerChanged within` after opening. + +--- + +## 3. Bank cache skips placeholder rows; automation only sees real stacks + +`Rs2Bank.updateLocalBank` drops items whose `ItemComposition.getPlaceholderTemplateId() > 0`. The client may show a placeholder in the slot; the cached list has no entry for it. + +**Why this matters:** Scripts that expect "any bank stack" for an item id may see false negatives when the account only has a placeholder until a real item is deposited. + +**Pattern to follow:** Treat placeholder-only slots as "not withdrawable" unless you add a dedicated placeholder-aware path. Document user-facing behavior in script configs. + +**Where this applies:** `Rs2Bank.updateLocalBank`, any helper using `bankItems()` / `findBankItem`. + +--- + +## 4. Saved item id vs bank row (noted/unnoted and cache drift) + +`hasBankItem(id)`, `count(id)`, `hasItem(id)`, and id-based `withdraw*` resolve the bank row in order: **exact id** → **linked noted/unnoted id** from `ItemComposition` → **fuzzy name** from composition (`getMembersName` / `getName`) against cached bank stacks. + +**Why this matters:** Inventory setups and scripts often store `ItemID` constants; Jagex renumbers or the bank holds the noted variant while the preset uses the unnoted id (or vice versa). Without fallback you get false “not in bank”. + +**Pattern to follow:** Prefer fuzzy or name-based setup rows when ids are unstable; enable DEBUG to see `[Rs2Bank] bank id drift` when a fallback row differs from the requested id. + +**Where this applies:** `Rs2Bank.findBankStackRowForSavedId`, `resolveBankStackForSavedId`, id overloads of `hasBankItem` / `count` / `withdraw*`. + +--- + +## 5. Optional inventory-setup validation (Tier A.3) + +Set JVM flag `-Dmicrobot.bank.validateInventorySetup=true` so `Rs2InventorySetup.loadInventory` warns once per issue: invalid id, missing `ItemComposition`, id/name mismatch vs cache, and a **single inventory row** with quantity greater than 1 for a **non-stackable** item (same rule as withdraw grouping: use one row per unstacked item or fuzzy mode). + +**Where this applies:** `Rs2InventorySetup.validateInventorySetupAgainstDefsIfEnabled`. + +--- + +## 6. Inventory-setup load: keep-list uses ids + names, deposit only when needed + +`Rs2InventorySetup.loadInventory()` (default) skips the bank when the inventory already matches the setup **and** there are no “foreign” stacks (items not in the setup’s keep list) **and** quantities do not exceed the setup’s grouped targets. Otherwise it opens the bank and calls `Rs2Bank.depositAllExcept(Set, Map)`: non-fuzzy rows contribute exact ids (plus linked noted/unnoted ids); fuzzy rows contribute name keys (`true` = substring keep). The keep list includes inventory, equipment, additional filtered items, and rune pouch entries. + +**Why this matters:** Name-only `depositAllExcept(Map)` missed noted/unnoted pairs and extra sections; unconditional deposit caused unnecessary UI churn. + +**Pattern to follow:** Use `loadInventory(false)` when you must always open the bank (legacy behavior). For custom scripts, reuse `Rs2Bank.isInventoryItemRetainedForSetupDeposit` semantics when building keep predicates. + +**Where this applies:** `Rs2InventorySetup.loadInventory`, `loadEquipment`, `Rs2Bank.depositAllExcept(Set, Map)`. + +--- + +## 7. Release / regression — bank mirror (Tier C) + +**Automated (CI):** `Rs2BankSetupDepositRetainTest` covers `isInventoryItemRetainedForSetupDeposit` (id + fuzzy + exact name). + +**Manual smoke after a banking-affecting change:** + +1. Open bank on a live profile; confirm inventory-setup load (or a script using `Rs2Bank.hasBankItem`) sees **coins** (`995`) and at least **one rune** you know is in the bank. +2. If setup load aborts with `Bank item mirror not ready after open`, capture DEBUG `Rs2Bank` logs and check `getBankLiveEpoch()` / `ItemContainerChanged(BANK)` delivery. + +**Where this applies:** `Rs2Bank.getBankLiveEpoch`, `verifyBankMirrorAfterOpen`, `Rs2InventorySetup.loadInventory` / `loadEquipment`. + +--- + + diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java index 70766a6d348..a2f04ab66fc 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java @@ -1,10 +1,13 @@ package net.runelite.client.plugins.microbot.util; +import net.runelite.api.Client; +import net.runelite.api.ItemComposition; import net.runelite.api.gameval.ItemID; import net.runelite.api.gameval.VarbitID; import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.inventorysetups.InventorySetup; import net.runelite.client.plugins.microbot.inventorysetups.InventorySetupsItem; +import net.runelite.client.plugins.microbot.inventorysetups.InventorySetupsStackCompareID; import net.runelite.client.plugins.microbot.inventorysetups.MInventorySetupsPlugin; import net.runelite.client.plugins.microbot.util.bank.Rs2Bank; import net.runelite.client.plugins.microbot.util.equipment.Rs2Equipment; @@ -38,6 +41,157 @@ */ public class Rs2InventorySetup { + /** + * When {@code -Dmicrobot.bank.validateInventorySetup=true}, {@link #loadInventory()} logs warnings for stale preset + * ids, id/name drift, and impossible non-stackable quantities on a single row. + */ + private static final String PROP_VALIDATE_INVENTORY_SETUP = "microbot.bank.validateInventorySetup"; + + /** + * {@link Client#getItemDefinition(int)} is client-thread-only; inventory setup runs from script executor threads. + */ + private static ItemComposition getItemDefinitionThreadSafe(int id) { + Client c = Microbot.getClient(); + if (c == null) { + return null; + } + if (c.isClientThread()) { + return c.getItemDefinition(id); + } + return Microbot.getClientThread().runOnClientThreadOptional(() -> c.getItemDefinition(id)).orElse(null); + } + + /** + * Non-fuzzy rows: use cache definition so stackables (runes, bolts, etc.) always use total-quantity math, + * not "missing slot count" ({@code matchingRows - invStacks}), which blows up for duplicate rows or bad heuristics. + */ + private static boolean setupRowIsStackableByDefinition(InventorySetupsItem setupItem) { + if (setupItem == null || setupItem.isFuzzy()) { + return false; + } + int id = setupItem.getId(); + if (id <= 0) { + return false; + } + ItemComposition comp = getItemDefinitionThreadSafe(id); + return comp != null && comp.isStackable(); + } + + /** + * Matches {@link #calculateWithdrawQuantity}: stack total vs stack count for unslotted checks. + */ + private static boolean inventoryMatchUseStackQuantity(InventorySetupsItem groupRep, int groupSize, int desiredSum) { + assert groupRep != null; + if (groupSize != 1) { + return false; + } + if (setupRowIsStackableByDefinition(groupRep)) { + return true; + } + return desiredSum > 1; + } + + /** + * Inventory Setups "<" stack indicator: UI only highlights when current qty is below the saved qty. + * For automation, treat saved qty as a soft target — withdraw up to bank max and accept inv under target after load. + */ + private static boolean setupSoftMinStackTarget(InventorySetupsItem setupItem) { + if (setupItem == null) { + return false; + } + InventorySetupsStackCompareID sc = setupItem.getStackCompare(); + return sc == InventorySetupsStackCompareID.Less_Than; + } + + /** + * Withdraw up to bank holdings when short; do not pause for less-than-preset totals. Applies to + * {@link InventorySetupsStackCompareID#Less_Than} and {@link InventorySetupsStackCompareID#Standard} ({@code !=}). + */ + private static boolean setupWithdrawIgnoresExactBankTotal(InventorySetupsItem setupItem) { + if (setupItem == null) { + return false; + } + InventorySetupsStackCompareID sc = stackCompareOf(setupItem); + return sc == InventorySetupsStackCompareID.Less_Than || sc == InventorySetupsStackCompareID.Standard; + } + + private static InventorySetupsStackCompareID stackCompareOf(InventorySetupsItem setupItem) { + if (setupItem == null || setupItem.getStackCompare() == null) { + return InventorySetupsStackCompareID.None; + } + return setupItem.getStackCompare(); + } + + /** + * Slotted row: stack indicator from Inventory Setups (None / != / < / >). + *

{@link InventorySetupsStackCompareID#Standard} ({@code !=}): automation only requires the correct item in the + * slot, not a matching stack size (withdraw also uses partial-bank semantics). + * {@link InventorySetupsStackCompareID#None}: exact quantity. {@link InventorySetupsStackCompareID#Less_Than}: + * same presence-only qty check as Standard here. + */ + private static boolean inventorySlotQuantityMatchesPreset(int invQty, InventorySetupsItem setupItem) { + assert setupItem != null; + int need = setupItem.getQuantity(); + switch (stackCompareOf(setupItem)) { + case None: + return invQty == need; + case Standard: + case Less_Than: + return true; + case Greater_Than: + if (need <= 0) { + return true; + } + return invQty > 0 && invQty <= need; + default: + return invQty >= need; + } + } + + /** + * Unslotted pooled check aligned with {@link InventorySetupsSlot#shouldHighlightSlotBasedOnStack}. + */ + private boolean unslottedInventorySatisfiesPreset(InventorySetupsItem setupItem, int withdrawQuantity, boolean useStackQuantity) { + assert setupItem != null; + if (withdrawQuantity <= 0) { + return true; + } + int invStack = Rs2Inventory.itemQuantity(setupItem.getName()); + int invCount = Rs2Inventory.count(setupItem.getName(), false); + switch (stackCompareOf(setupItem)) { + case Less_Than: + return useStackQuantity ? invStack > 0 : invCount > 0; + case Standard: + if (useStackQuantity) { + return invStack > 0; + } + return invCount == withdrawQuantity; + case Greater_Than: + if (useStackQuantity) { + return invStack > 0 && invStack <= withdrawQuantity; + } + return invCount > 0 && invCount <= withdrawQuantity; + case None: + if (useStackQuantity) { + return invStack == withdrawQuantity; + } + return invCount == withdrawQuantity; + default: + return Rs2Inventory.hasItemAmount(setupItem.getName(), withdrawQuantity, useStackQuantity); + } + } + + private static void addRuneLikePouchRows(List out, List pouch) { + if (pouch == null) { + return; + } + for (InventorySetupsItem x : pouch) { + if (x != null && x.getId() != -1 && x.getQuantity() > 0 && !InventorySetupsItem.itemIsDummy(x)) { + out.add(x); + } + } + } + InventorySetup inventorySetup; ScheduledFuture _mainScheduler; @@ -90,48 +244,169 @@ public boolean isMainSchedulerCancelled() { return _mainScheduler != null && _mainScheduler.isCancelled(); } + /** + * Level-aware structured lines: {@code [InventorySetup:] message} via {@link Microbot#log(Level, String, Object...)}. + */ + private void logSetup(Level level, String format, Object... args) { + String prefix = "[InventorySetup:" + (inventorySetup != null ? inventorySetup.getName() : "?") + "] "; + if (args == null || args.length == 0) { + Microbot.log(prefix + format, level); + } else { + Microbot.log(level, prefix + format, args); + } + } + + /** + * Total quantity in bank mirror for a preset row (id total, with lowercase name fallback when id total is 0). + */ + private static int bankQtyForPresetRow(InventorySetupsItem item, String lowerCaseName, boolean isFuzzy) { + assert item != null; + assert lowerCaseName != null; + if (isFuzzy) { + return Rs2Bank.count(lowerCaseName); + } + int byId = Rs2Bank.count(item.getId()); + if (byId > 0) { + return byId; + } + return Rs2Bank.count(lowerCaseName, false); + } + /** * Loads the inventory setup from the bank. * * @return true if the inventory matches the setup after loading, false otherwise. */ public boolean loadInventory() { + return loadInventory(true); + } + + /** + * @param skipIfAlreadyMatching when {@code true}, skip bank open when inventory already matches the setup, + * there are no foreign items, and quantities are not over setup limits. + */ + public boolean loadInventory(boolean skipIfAlreadyMatching) { + if (inventorySetup == null) { + return false; + } + + validateInventorySetupAgainstDefsIfEnabled(); + + Set retainIds = computeSetupRetainItemIds(); + Map fuzzy = computeSetupFuzzyKeepNames(); + if (skipIfAlreadyMatching && doesInventoryMatch() && !needsDepositCleanupBeforeBanking(retainIds, fuzzy)) { + return true; + } + + boolean bankWasOpen = Rs2Bank.isOpen(); + int bankEpochBeforeOpen = Rs2Bank.getBankLiveEpoch(); Rs2Bank.openBank(); if (!Rs2Bank.isOpen()) { return false; } + if (!Rs2Bank.verifyBankMirrorAfterOpen(bankWasOpen, bankEpochBeforeOpen)) { + logSetup(Level.WARN, "bank mirror not ready after open (epoch before=%d after=%d) — abort load", + bankEpochBeforeOpen, Rs2Bank.getBankLiveEpoch()); + return false; + } if (!Rs2Bank.findLockedSlots().isEmpty()) { Rs2Bank.toggleAllLocks(); } - Rs2Bank.depositAllExcept(itemsToNotDeposit()); + if (Rs2Inventory.isFull()) { + int epochBeforeDeposit = Rs2Bank.getBankLiveEpoch(); + if (Rs2Bank.depositAll()) { + Rs2Bank.syncBankInventoryAfterChange(epochBeforeDeposit); + } + } else if (needsDepositCleanupBeforeBanking(retainIds, fuzzy)) { + int epochBeforeDeposit = Rs2Bank.getBankLiveEpoch(); + if (Rs2Bank.depositAllExcept(retainIds, fuzzy)) { + Rs2Bank.syncBankInventoryAfterChange(epochBeforeDeposit); + } + } List setupItems = inventorySetup.getInventory(); + boolean toleratedShortfallWithExistingInventory = false; + Set withdrewInventoryGroups = new HashSet<>(); for (InventorySetupsItem item : setupItems) { if (isMainSchedulerCancelled()) break; if (InventorySetupsItem.itemIsDummy(item)) continue; + String withdrawGroupKey = item.isFuzzy() + ? "f:" + item.getName().toLowerCase(Locale.ROOT) + : "i:" + item.getId(); + if (!withdrewInventoryGroups.add(withdrawGroupKey)) { + continue; + } + List matchingItems = setupItems.stream() .filter(i -> i.matches(item)) .collect(Collectors.toList()); - int withdrawQuantity = calculateWithdrawQuantity(matchingItems, item); - if (withdrawQuantity == 0) continue; - Rs2ItemModel existingItem = new Rs2ItemModel(item.getId(), withdrawQuantity, withdrawQuantity); + int desiredWithdraw = calculateWithdrawQuantity(matchingItems, item); + if (desiredWithdraw == 0) continue; + + Rs2ItemModel existingItem = new Rs2ItemModel(item.getId(), desiredWithdraw, desiredWithdraw); boolean isNoted = existingItem.isNoted(); //for noted items, we also need to use the name of the hasBankItem method, or the unnoted id String lowerCaseName = item.getName().toLowerCase(); boolean isFuzzy = item.isFuzzy(); Object identifier = isFuzzy ? lowerCaseName : item.getId(); + boolean partialBankWithdraw = matchingItems.stream().anyMatch(Rs2InventorySetup::setupWithdrawIgnoresExactBankTotal); + boolean stackableRow = setupRowIsStackableByDefinition(item); + int bankAvail = bankQtyForPresetRow(item, lowerCaseName, isFuzzy); + int withdrawQuantity = partialBankWithdraw ? Math.min(desiredWithdraw, bankAvail) : desiredWithdraw; + int setupTotal = matchingItems.stream().mapToInt(InventorySetupsItem::getQuantity).sum(); + int invQty = isFuzzy ? Rs2Inventory.itemQuantity(lowerCaseName) : Rs2Inventory.itemQuantity(item.getId()); + boolean canTolerateShortfall = invQty > 0 && (partialBankWithdraw || stackableRow); + + if (withdrawQuantity == 0 && desiredWithdraw > 0) { + // Stack-compare rows (!= / <) may proceed with partial inventory when bank cannot top up. + if (canTolerateShortfall) { + logSetup(Level.INFO, + "bank short but continuing %s: bank=%d inv=%d setup_total=%d missing=%d (partial stack mode)", + item.getName(), bankAvail, invQty, setupTotal, desiredWithdraw); + toleratedShortfallWithExistingInventory = true; + continue; + } + Microbot.pauseAllScripts.compareAndSet(false, true); + logSetup(Level.WARN, + "bank short: %s | bank=%d | inv=%d | setup_total=%d | withdraw=%d (id=%d fuzzy=%b noted=%b)", + item.getName(), bankAvail, invQty, setupTotal, desiredWithdraw, + item.getId(), isFuzzy, isNoted); + return false; + } + if (withdrawQuantity == 0) { + continue; + } + + if (partialBankWithdraw && withdrawQuantity < desiredWithdraw) { + logSetup(Level.INFO, "partial withdraw %s: take=%d wanted=%d (!= or < stack mode)", + item.getName(), withdrawQuantity, desiredWithdraw); + } + boolean hasBankItem = isFuzzy || isNoted ? Rs2Bank.hasBankItem((String) identifier, withdrawQuantity, false) : Rs2Bank.hasBankItem((int) identifier, withdrawQuantity); + if (!hasBankItem && !isFuzzy && !isNoted) { + hasBankItem = Rs2Bank.hasBankItem(lowerCaseName, withdrawQuantity, false); + } if (!hasBankItem) { + if (canTolerateShortfall) { + logSetup(Level.INFO, + "bank verify short but continuing %s: bank=%d inv=%d setup_total=%d missing=%d (partial stack mode)", + item.getName(), bankAvail, invQty, setupTotal, withdrawQuantity); + toleratedShortfallWithExistingInventory = true; + continue; + } Microbot.pauseAllScripts.compareAndSet(false, true); - Microbot.log("Bank is missing the following item: " + item.getName(), Level.WARN); + logSetup(Level.WARN, + "bank short: %s | bank=%d | inv=%d | setup_total=%d | withdraw=%d (id=%d fuzzy=%b noted=%b)", + item.getName(), bankAvail, invQty, setupTotal, withdrawQuantity, + item.getId(), isFuzzy, isNoted); return false; } @@ -153,7 +428,7 @@ public boolean loadInventory() { )); if (!Rs2RunePouch.loadFromInventorySetup(inventorySetupRunes)) { - Microbot.log("Failed to load rune pouch.", Level.WARN); + logSetup(Level.WARN, "rune pouch load failed"); return false; } } @@ -161,8 +436,248 @@ public boolean loadInventory() { sleep(800, 1200); lockLockedItemsFromSetup(inventorySetup); + boolean inventoryMatches = doesInventoryMatch(); + if (!inventoryMatches && toleratedShortfallWithExistingInventory) { + logSetup(Level.INFO, "continuing with partial stack shortfall because inventory already has required stack item(s)"); + return true; + } + return inventoryMatches; + } + + private static String firstNonEmptyCompositionName(ItemComposition comp) + { + if (comp == null) + { + return null; + } + String a = comp.getMembersName(); + String b = comp.getName(); + if (a != null && !a.isEmpty()) + { + return a; + } + if (b != null && !b.isEmpty()) + { + return b; + } + return null; + } + + private void validateInventorySetupAgainstDefsIfEnabled() + { + if (!Boolean.parseBoolean(System.getProperty(PROP_VALIDATE_INVENTORY_SETUP, "false"))) + { + return; + } + if (inventorySetup == null) + { + return; + } + List inv = inventorySetup.getInventory(); + List equip = inventorySetup.getEquipment(); + List all = new ArrayList<>(); + if (inv != null) + { + all.addAll(inv); + } + if (equip != null) + { + all.addAll(equip); + } + if (inventorySetup.getAdditionalFilteredItems() != null) + { + all.addAll(inventorySetup.getAdditionalFilteredItems().values()); + } + addRuneLikePouchRows(all, inventorySetup.getRune_pouch()); + addRuneLikePouchRows(all, inventorySetup.getBoltPouch()); + addRuneLikePouchRows(all, inventorySetup.getQuiver()); - return doesInventoryMatch(); + Set loggedIdNameDrift = new HashSet<>(); + + for (InventorySetupsItem item : all) + { + if (item == null || InventorySetupsItem.itemIsDummy(item)) + { + continue; + } + if (item.isFuzzy()) + { + continue; + } + int id = item.getId(); + if (id <= 0) + { + logSetup(Level.WARN, "validate: non-fuzzy row invalid id for \"%s\"", item.getName()); + continue; + } + ItemComposition comp = getItemDefinitionThreadSafe(id); + if (comp == null) + { + logSetup(Level.WARN, "validate: no ItemComposition id=%d name=\"%s\"", id, item.getName()); + continue; + } + String defName = firstNonEmptyCompositionName(comp); + String setupName = item.getName(); + if (defName != null && setupName != null && loggedIdNameDrift.add(id) + && !defName.equalsIgnoreCase(setupName) + && !defName.toLowerCase(Locale.ROOT).contains(setupName.toLowerCase(Locale.ROOT)) + && !setupName.toLowerCase(Locale.ROOT).contains(defName.toLowerCase(Locale.ROOT))) + { + logSetup(Level.WARN, "validate: id/name drift id=%d setup=\"%s\" def=\"%s\"", id, setupName, defName); + } + } + + if (inv == null) + { + return; + } + + Set warnedNonStackableQty = new HashSet<>(); + for (InventorySetupsItem item : inv) + { + if (InventorySetupsItem.itemIsDummy(item) || item.isFuzzy()) + { + continue; + } + List matchingItems = inv.stream().filter(i -> i.matches(item)).collect(Collectors.toList()); + int desiredQuantity = matchingItems.stream().mapToInt(InventorySetupsItem::getQuantity).sum(); + if (matchingItems.size() != 1 || desiredQuantity <= 1) + { + continue; + } + int itemId = item.getId(); + if (itemId <= 0) + { + continue; + } + ItemComposition comp = getItemDefinitionThreadSafe(itemId); + if (comp == null || comp.isStackable()) + { + continue; + } + if (warnedNonStackableQty.add(itemId)) + { + logSetup(Level.WARN, + "validate: non-stackable \"%s\" (id=%d) qty=%d on one row — split rows or use fuzzy", + item.getName(), itemId, desiredQuantity); + } + } + } + + private List allNonDummySetupItemsForDepositRules() { + List out = new ArrayList<>(); + if (inventorySetup == null) { + return out; + } + if (inventorySetup.getInventory() != null) { + for (InventorySetupsItem x : inventorySetup.getInventory()) { + if (x != null && !InventorySetupsItem.itemIsDummy(x)) { + out.add(x); + } + } + } + if (inventorySetup.getEquipment() != null) { + for (InventorySetupsItem x : inventorySetup.getEquipment()) { + if (x != null && !InventorySetupsItem.itemIsDummy(x)) { + out.add(x); + } + } + } + if (inventorySetup.getAdditionalFilteredItems() != null) { + for (InventorySetupsItem x : inventorySetup.getAdditionalFilteredItems().values()) { + if (x != null && !InventorySetupsItem.itemIsDummy(x)) { + out.add(x); + } + } + } + addRuneLikePouchRows(out, inventorySetup.getRune_pouch()); + addRuneLikePouchRows(out, inventorySetup.getBoltPouch()); + addRuneLikePouchRows(out, inventorySetup.getQuiver()); + return out; + } + + private Set computeSetupRetainItemIds() { + Set ids = new HashSet<>(); + for (InventorySetupsItem item : allNonDummySetupItemsForDepositRules()) { + if (item.isFuzzy()) { + continue; + } + int id = item.getId(); + if (id <= 0) { + continue; + } + ids.add(id); + ItemComposition comp = getItemDefinitionThreadSafe(id); + if (comp != null) { + int linked = comp.getLinkedNoteId(); + if (linked > 0 && linked != id) { + ids.add(linked); + } + } + } + return ids; + } + + private Map computeSetupFuzzyKeepNames() { + Map map = new LinkedHashMap<>(); + for (InventorySetupsItem item : allNonDummySetupItemsForDepositRules()) { + String name = item.getName(); + if (name == null || name.isEmpty()) { + continue; + } + map.merge(name, item.isFuzzy(), (a, b) -> a || b); + } + return map; + } + + private boolean needsDepositCleanupBeforeBanking(Set retainIds, Map fuzzy) { + if (retainIds == null || fuzzy == null) { + return true; + } + + boolean foreign = Rs2Inventory.items() + .anyMatch(inv -> !Rs2Bank.isInventoryItemRetainedForSetupDeposit(inv, retainIds, fuzzy)); + return foreign || inventoryExceedsSetupQuantities(); + } + + private boolean inventoryExceedsSetupQuantities() { + if (inventorySetup == null || inventorySetup.getInventory() == null) { + return false; + } + List setupItems = inventorySetup.getInventory(); + Set seenGroup = new HashSet<>(); + for (InventorySetupsItem item : setupItems) { + if (InventorySetupsItem.itemIsDummy(item)) { + continue; + } + String key = item.isFuzzy() + ? "f:" + item.getName().toLowerCase(Locale.ROOT) + : "i:" + item.getId(); + if (!seenGroup.add(key)) { + continue; + } + List matching = setupItems.stream().filter(i -> i.matches(item)).collect(Collectors.toList()); + int desiredQuantity = matching.stream().mapToInt(InventorySetupsItem::getQuantity).sum(); + boolean isFuzzy = item.isFuzzy(); + int itemId = item.getId(); + String itemName = item.getName().toLowerCase(Locale.ROOT); + boolean singleStackRow = matching.size() == 1 && desiredQuantity > 1; + if (singleStackRow) { + int currentQuantity = isFuzzy ? Rs2Inventory.itemQuantity(itemName) : Rs2Inventory.itemQuantity(itemId); + boolean allowExcess = matching.stream().anyMatch(Rs2InventorySetup::setupWithdrawIgnoresExactBankTotal); + if (currentQuantity > desiredQuantity && !allowExcess) { + return true; + } + } else { + long alreadyPresent = isFuzzy + ? Rs2Inventory.items(i -> i.getName().toLowerCase(Locale.ROOT).contains(itemName)).count() + : Rs2Inventory.items(i -> i.getId() == itemId).count(); + if (alreadyPresent > matching.size()) { + return true; + } + } + } + return false; } /** @@ -174,7 +689,7 @@ public boolean loadInventory() { */ private int calculateWithdrawQuantity(List setupItems, InventorySetupsItem setupItem) { int itemId = setupItem.getId(); - String itemName = setupItem.getName().toLowerCase(); + String itemName = setupItem.getName().toLowerCase(Locale.ROOT); boolean isFuzzy = setupItem.isFuzzy(); int desiredQuantity = setupItems.stream() @@ -189,11 +704,15 @@ private int calculateWithdrawQuantity(List setupItems, Inve return 0; } - boolean isStackable = (setupItems.size() == 1) && (desiredQuantity > 1); + if (setupRowIsStackableByDefinition(setupItem)) { + return desiredQuantity - currentQuantity; + } + + boolean legacyStackableHeuristic = (setupItems.size() == 1) && (desiredQuantity > 1); - if (!isStackable) { + if (!legacyStackableHeuristic) { long alreadyPresent = isFuzzy - ? Rs2Inventory.items(i -> i.getName().toLowerCase().contains(itemName)).count() + ? Rs2Inventory.items(i -> i.getName().toLowerCase(Locale.ROOT).contains(itemName)).count() : Rs2Inventory.items(i -> i.getId() == itemId).count(); int missing = setupItems.size() - (int) alreadyPresent; @@ -239,17 +758,46 @@ private void withdrawItem(InventorySetupsItem item, int quantity, boolean isNot * @return true if the equipment matches the setup after loading, false otherwise. */ public boolean loadEquipment() { + return loadEquipment(true); + } + + /** + * @param skipIfAlreadyMatching when {@code true}, skip bank if equipment matches and inventory needs no deposit cleanup. + */ + public boolean loadEquipment(boolean skipIfAlreadyMatching) { + if (inventorySetup == null) { + return false; + } + + Set retainIds = computeSetupRetainItemIds(); + Map fuzzy = computeSetupFuzzyKeepNames(); + if (skipIfAlreadyMatching && doesEquipmentMatch() && !needsDepositCleanupBeforeBanking(retainIds, fuzzy)) { + return true; + } + + boolean bankWasOpen = Rs2Bank.isOpen(); + int bankEpochBeforeOpen = Rs2Bank.getBankLiveEpoch(); Rs2Bank.openBank(); if (!Rs2Bank.isOpen()) { return false; } + if (!Rs2Bank.verifyBankMirrorAfterOpen(bankWasOpen, bankEpochBeforeOpen)) { + logSetup(Level.WARN, "bank mirror not ready after open (epoch before=%d after=%d) — abort equipment load", + bankEpochBeforeOpen, Rs2Bank.getBankLiveEpoch()); + return false; + } //Clear inventory if full if (Rs2Inventory.isFull()) { - Rs2Bank.depositAll(); - } else { - //only deposit the items we don't need - Rs2Bank.depositAllExcept(itemsToNotDeposit()); + int epochBeforeDeposit = Rs2Bank.getBankLiveEpoch(); + if (Rs2Bank.depositAll()) { + Rs2Bank.syncBankInventoryAfterChange(epochBeforeDeposit); + } + } else if (needsDepositCleanupBeforeBanking(retainIds, fuzzy)) { + int epochBeforeDeposit = Rs2Bank.getBankLiveEpoch(); + if (Rs2Bank.depositAllExcept(retainIds, fuzzy)) { + Rs2Bank.syncBankInventoryAfterChange(epochBeforeDeposit); + } } @@ -264,7 +812,7 @@ public boolean loadEquipment() { ); if (hasExtraGearEquipped) { - Microbot.log("Found Extra Gear that is not contained within the setup", Level.DEBUG); + logSetup(Level.DEBUG, "extra gear not in setup — deposit equipment"); Rs2Bank.depositEquipment(); sleepUntil(() -> Rs2Equipment.items().stream().noneMatch(Objects::nonNull)); } @@ -286,13 +834,20 @@ public boolean loadEquipment() { ? Rs2Inventory.hasItem((String) identifier) || Rs2Inventory.hasItemAmount((String) identifier, item.getQuantity()) : Rs2Inventory.hasItem((int) identifier) || Rs2Inventory.hasItemAmount((int) identifier, item.getQuantity()); - // Check in bank + // Check in bank (name fallback covers stale preset ids vs live bank row ids) boolean inBank = isFuzzy ? Rs2Bank.hasItem((String) identifier) || Rs2Bank.hasBankItem((String) identifier, item.getQuantity(), false) : Rs2Bank.hasItem((int) identifier) || Rs2Bank.hasBankItem((int) identifier, item.getQuantity()); + if (!inBank && !isFuzzy) { + inBank = Rs2Bank.hasBankItem(lowerCaseName, item.getQuantity(), false); + } if (!inInventory && !inBank) { - Microbot.log("Missing " + item.getName() + " in the bank and inventory. Shutting down"); + int bankGear = bankQtyForPresetRow(item, lowerCaseName, isFuzzy); + int invGear = isFuzzy ? Rs2Inventory.itemQuantity((String) identifier) : Rs2Inventory.itemQuantity((int) identifier); + logSetup(Level.WARN, + "missing gear %s | bank=%d | inv=%d | need=%d — pausing", + item.getName(), bankGear, invGear, item.getQuantity()); Microbot.pauseAllScripts.compareAndSet(false, true); continue; } @@ -342,13 +897,40 @@ public boolean wearEquipment() { return doesEquipmentMatch(); } - /** - * Checks if the current inventory matches the setup defined in the inventory setup. - * It compares the quantity and stackability of items in the current inventory - * against the quantities required by the inventory setup. - * - * @return true if the inventory matches the setup, false otherwise. - */ + /** + * Normalizes names like {@code Prayer potion(4)} so dose-only differences still match strict slots. + */ + private static String inventorySetupBaseItemName(String name) { + if (name == null) { + return ""; + } + return name.replaceAll("(?i)\\s*\\(\\d\\)\\s*$", "").trim().toLowerCase(); + } + + /** + * True when the inventory item satisfies the setup row (fuzzy, exact id, or same base name with different dose id). + */ + private static boolean slotItemMatchesPreset(InventorySetupsItem setupItem, Rs2ItemModel invItem) { + assert setupItem != null; + assert invItem != null; + if (setupItem.isFuzzy()) { + return invItem.getName().toLowerCase().contains(setupItem.getName().toLowerCase()); + } + if (invItem.getId() == setupItem.getId()) { + return true; + } + String baseSetup = inventorySetupBaseItemName(setupItem.getName()); + String baseInv = inventorySetupBaseItemName(invItem.getName()); + return !baseSetup.isEmpty() && baseSetup.equals(baseInv); + } + + /** + * Checks if the current inventory matches the setup defined in the inventory setup. + * It compares the quantity and stackability of items in the current inventory + * against the quantities required by the inventory setup. + * + * @return true if the inventory matches the setup, false otherwise. + */ public boolean doesInventoryMatch() { if (inventorySetup == null || inventorySetup.getInventory() == null) { return false; @@ -363,13 +945,15 @@ public boolean doesInventoryMatch() { InventorySetupsItem item = entry.getValue().get(0); if (item.getId() == -1) continue; + int groupSize = entry.getValue().size(); + int desiredSum = entry.getValue().stream().mapToInt(InventorySetupsItem::getQuantity).sum(); int withdrawQuantity; - boolean isStackable = false; - if (entry.getValue().size() == 1) { + boolean useStackQuantity = inventoryMatchUseStackQuantity(item, groupSize, desiredSum); + + if (groupSize == 1) { withdrawQuantity = item.getQuantity(); - isStackable = withdrawQuantity > 1; } else { - withdrawQuantity = entry.getValue().size(); + withdrawQuantity = groupSize; } for (InventorySetupsItem setupItem : entry.getValue()) { @@ -379,23 +963,33 @@ public boolean doesInventoryMatch() { Rs2ItemModel invItem = Rs2Inventory.getItemInSlot(expectedSlot); boolean itemDoesntExist = invItem == null; - boolean itemDoesntMatch = invItem != null && (setupItem.isFuzzy() - ? !invItem.getName().toLowerCase().contains(setupItem.getName().toLowerCase()) - : invItem.getId() != setupItem.getId()); + boolean itemDoesntMatch = invItem != null && !slotItemMatchesPreset(setupItem, invItem); if (itemDoesntExist || itemDoesntMatch) { - Microbot.log("Slot mismatch: expected " + setupItem.getName() + " in slot " + expectedSlot, Level.WARN); + int inSlotQty = invItem != null ? invItem.getQuantity() : 0; + logSetup(Level.WARN, + "slot mismatch: want %s x%d in slot %d%s | in_slot_qty=%d (wrong or empty slot)", + setupItem.getName(), setupItem.getQuantity(), expectedSlot, + invItem != null ? " found " + invItem.getName() : " empty", + inSlotQty); found = false; continue; } - if (invItem.getQuantity() < setupItem.getQuantity()) { - Microbot.log("Wrong quantity in slot " + expectedSlot + " for " + setupItem.getName(), Level.WARN); + if (!inventorySlotQuantityMatchesPreset(invItem.getQuantity(), setupItem)) { + logSetup(Level.WARN, "wrong qty slot %d %s | inv=%d | need=%d | stack=%s", + expectedSlot, setupItem.getName(), invItem.getQuantity(), setupItem.getQuantity(), + stackCompareOf(setupItem)); found = false; } } else { - if (!Rs2Inventory.hasItemAmount(setupItem.getName(), withdrawQuantity, isStackable)) { - Microbot.log("Missing item: " + setupItem.getName() + " with amount " + setupItem.getQuantity(), Level.WARN); + if (!unslottedInventorySatisfiesPreset(setupItem, withdrawQuantity, useStackQuantity)) { + int invHave = useStackQuantity + ? Rs2Inventory.itemQuantity(setupItem.getName()) + : Rs2Inventory.count(setupItem.getName(), false); + logSetup(Level.WARN, "missing item: %s | inv=%d | need=%d (use_stack_qty=%b stack=%s)", + setupItem.getName(), invHave, withdrawQuantity, useStackQuantity, + stackCompareOf(setupItem)); found = false; } } @@ -423,7 +1017,7 @@ public boolean doesInventoryMatch() { )); if (!Rs2RunePouch.contains(requiredRunes, false)) { - Microbot.log("Rune pouch contents do not match expected setup.", Level.WARN); + logSetup(Level.WARN, "rune pouch contents do not match expected setup"); found = false; } } @@ -447,7 +1041,18 @@ public boolean doesEquipmentMatch() { boolean exact = !inventorySetupsItem.isFuzzy() && !InventorySetupsItem.isBarrowsItem(inventorySetupsItem.getName().toLowerCase()); if (!Rs2Equipment.isWearing(inventorySetupsItem.getName(), exact)) { - Microbot.log("Missing item " + inventorySetupsItem.getName(), Level.WARN); + int needEq = Math.max(1, inventorySetupsItem.getQuantity()); + boolean fuzzyEq = inventorySetupsItem.isFuzzy(); + String lowEq = inventorySetupsItem.getName().toLowerCase(Locale.ROOT); + int invEq = fuzzyEq ? Rs2Inventory.itemQuantity(lowEq) : Rs2Inventory.itemQuantity(inventorySetupsItem.getId()); + if (Rs2Bank.isOpen()) { + int bankEq = bankQtyForPresetRow(inventorySetupsItem, lowEq, fuzzyEq); + logSetup(Level.WARN, "missing equipment: %s | bank=%d | inv=%d | need=%d", + inventorySetupsItem.getName(), bankEq, invEq, needEq); + } else { + logSetup(Level.WARN, "missing equipment: %s | inv=%d | need=%d (bank closed)", + inventorySetupsItem.getName(), invEq, needEq); + } return false; } } @@ -477,30 +1082,23 @@ public List getEquipmentItems() { * @return A list of valid additional filtered items. */ public List getAdditionalItems() { - return inventorySetup.getAdditionalFilteredItems().values().stream().filter(x -> x.getId() != -1).collect(Collectors.toList()); + if (inventorySetup.getAdditionalFilteredItems() == null) { + return Collections.emptyList(); + } + return inventorySetup.getAdditionalFilteredItems().values().stream() + .filter(Objects::nonNull) + .filter(x -> x.getId() != -1) + .collect(Collectors.toList()); } /** - * Creates a list of item names that should not be deposited into the bank. - * Combines items from both the inventory setup and the equipment setup. + * Names that should not be deposited (exact vs fuzzy), derived from inventory, equipment, additional items, and rune pouch. + * For automation, prefer {@link Rs2Bank#depositAllExcept(Set, Map)} with ids from non-fuzzy rows plus linked noted/unnoted ids. * - * @return A list of item names that should not be deposited. + * @return map suitable for {@link Rs2Bank#depositAllExcept(Map)} */ public Map itemsToNotDeposit() { - List inventorySetupItems = getInventoryItems(); - List equipmentSetupItems = getEquipmentItems(); - - List combined = new ArrayList<>(); - - combined.addAll(inventorySetupItems); - combined.addAll(equipmentSetupItems); - - return combined.stream() - .collect(Collectors.toMap( - InventorySetupsItem::getName, - InventorySetupsItem::isFuzzy, - (existing, replacement) -> existing) - ); + return new LinkedHashMap<>(computeSetupFuzzyKeepNames()); } /** @@ -509,7 +1107,11 @@ public Map itemsToNotDeposit() { * @return true if the current spellbook matches the setup, false otherwise. */ public boolean hasSpellBook() { - return inventorySetup.getSpellBook() == Microbot.getVarbitValue(VarbitID.SPELLBOOK); + int setupBook = inventorySetup.getSpellBook(); + if (setupBook == 4) { + return true; + } + return setupBook == Microbot.getVarbitValue(VarbitID.SPELLBOOK); } /** @@ -569,17 +1171,17 @@ private void sortInventoryItems(List setupItems) { if (itemToMove != null) { int sourceSlot = itemToMove.getSlot(); - Microbot.log("Moving " + itemToMove.getName() + " from slot " + sourceSlot + " to slot " + targetSlot, Level.DEBUG); + logSetup(Level.DEBUG, "moving %s from slot %d to slot %d", itemToMove.getName(), sourceSlot, targetSlot); if (Rs2Inventory.moveItemToSlot(itemToMove, targetSlot)) { Rs2Inventory.waitForInventoryChanges(2000); } } else { - Microbot.log("No available item found for " + setupItem.getName() + " to place in slot " + targetSlot, Level.DEBUG); + logSetup(Level.DEBUG, "no inv item for %s to place in slot %d", setupItem.getName(), targetSlot); } } - Microbot.log("Inventory sorting complete", Level.DEBUG); + logSetup(Level.DEBUG, "inventory sorting complete"); } /** @@ -635,7 +1237,7 @@ public boolean prePot(List potionsToPrePot) if (additionalItems.isEmpty()) { - Microbot.log("No additional items to pre-pot.", Level.WARN); + logSetup(Level.WARN, "no additional items to pre-pot"); return false; } @@ -643,7 +1245,7 @@ public boolean prePot(List potionsToPrePot) if (Rs2Inventory.isFull()) { - Microbot.log("Inventory is full, temporarily storing items to make space", Level.INFO); + logSetup(Level.INFO, "inventory full — temporarily storing items for space"); Rs2Inventory.items() .sorted(Comparator.comparing(Rs2ItemModel::isStackable)) .limit(3) @@ -738,7 +1340,7 @@ else if (Rs2Inventory.hasItem(ItemID.VIAL_EMPTY)) if (!storedItems.isEmpty()) { - Microbot.log("Restoring temporarily stored items", Level.INFO); + logSetup(Level.INFO, "restoring temporarily stored items"); for (Rs2ItemModel storedItem : storedItems) { @@ -749,7 +1351,7 @@ else if (Rs2Inventory.hasItem(ItemID.VIAL_EMPTY)) if (Rs2Inventory.isFull()) { - Microbot.log("Inventory full, cannot restore all stored items", Level.WARN); + logSetup(Level.WARN, "inventory full — cannot restore all stored items"); return false; } @@ -777,7 +1379,7 @@ else if (Rs2Inventory.hasItem(ItemID.VIAL_EMPTY)) */ private boolean handleChugBarrel() { if (!Rs2Bank.hasItem(ItemID.MM_PREPOT_DEVICE)) { - Microbot.log("Chugging barrel found in Inventory Setup, but not in bank", Level.WARN); + logSetup(Level.WARN, "chug barrel in setup but not in bank"); return false; } Rs2Bank.withdrawItem(ItemID.MM_PREPOT_DEVICE); @@ -901,7 +1503,7 @@ private void handleHealing(List additionalItems) { ); if (healingFood == null) { - Microbot.log("Unable to find highest healing food in bank", Level.WARN); + logSetup(Level.WARN, "no healing food found in bank"); return; } @@ -1056,18 +1658,18 @@ public boolean bankItemsNotInInventorySetup(boolean excludeTeleportItems) { List itemsToBank = getItemsNotInInventorySetup(excludeTeleportItems); if (itemsToBank.isEmpty()) { - Microbot.log("No items to bank from inventory - all items match setup", Level.DEBUG); + logSetup(Level.DEBUG, "no inv items to bank — matches setup"); return true; } // Ensure bank is open if (!Rs2Bank.isOpen()) { - Microbot.log("Bank must be open to deposit items not in setup", Level.WARN); + logSetup(Level.WARN, "bank must be open to deposit inv items not in setup"); return false; } - Microbot.log("Banking {} items not in inventory setup (teleport exclusion: {})", - itemsToBank.size(), excludeTeleportItems, Level.INFO); + logSetup(Level.INFO, "banking %d inv items not in setup (teleport exclude=%s)", + itemsToBank.size(), excludeTeleportItems); for (Rs2ItemModel item : itemsToBank) { if (isMainSchedulerCancelled()) { @@ -1075,11 +1677,11 @@ public boolean bankItemsNotInInventorySetup(boolean excludeTeleportItems) { } try { - Microbot.log("Depositing item not in setup: {}", item.getName(), Level.DEBUG); + logSetup(Level.DEBUG, "deposit inv not in setup: %s", item.getName()); Rs2Bank.depositAll(item.getId()); sleep(Rs2Random.between(200, 400)); } catch (Exception e) { - Microbot.log("Failed to deposit item {}: {}", item.getName(), e.getMessage(), Level.WARN); + logSetup(Level.WARN, "deposit inv failed %s: %s", item.getName(), e.getMessage()); } } @@ -1097,18 +1699,18 @@ public boolean bankEquipmentNotInSetup(boolean excludeTeleportItems) { List equipmentToBank = getEquipmentNotInSetup(excludeTeleportItems); if (equipmentToBank.isEmpty()) { - Microbot.log("No equipment to bank - all equipment matches setup", Level.DEBUG); + logSetup(Level.DEBUG, "no equipment to bank — matches setup"); return true; } // Ensure bank is open if (!Rs2Bank.isOpen()) { - Microbot.log("Bank must be open to deposit equipment not in setup", Level.WARN); + logSetup(Level.WARN, "bank must be open to deposit equipment not in setup"); return false; } - Microbot.log("Banking {} equipment items not in setup (teleport exclusion: {})", - equipmentToBank.size(), excludeTeleportItems, Level.INFO); + logSetup(Level.INFO, "banking %d equipment slots not in setup (teleport exclude=%s)", + equipmentToBank.size(), excludeTeleportItems); for (Rs2ItemModel item : equipmentToBank) { if (isMainSchedulerCancelled()) { @@ -1116,7 +1718,7 @@ public boolean bankEquipmentNotInSetup(boolean excludeTeleportItems) { } try { - Microbot.log("Depositing equipment not in setup: {}", item.getName(), Level.DEBUG); + logSetup(Level.DEBUG, "deposit equipment not in setup: %s", item.getName()); // Remove equipment first, then deposit Rs2Equipment.unEquip(item.getId()); sleep(Rs2Random.between(300, 500)); @@ -1125,7 +1727,7 @@ public boolean bankEquipmentNotInSetup(boolean excludeTeleportItems) { sleep(Rs2Random.between(200, 400)); } } catch (Exception e) { - Microbot.log("Failed to deposit equipment {}: {}", item.getName(), e.getMessage(), Level.WARN); + logSetup(Level.WARN, "deposit equipment failed %s: %s", item.getName(), e.getMessage()); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java index 64959213351..f6b95a7c76a 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java @@ -49,6 +49,7 @@ import java.util.List; import java.util.*; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.function.IntPredicate; import java.util.function.Predicate; @@ -85,6 +86,178 @@ public class Rs2Bank { // Used to synchronize calls private static final Object lock = new Object(); + /** + * Incremented on each applied {@link ItemContainerChanged} for {@link InventoryID#BANK}. Used to wait for at least one + * live snapshot after {@link #openBank()} so {@link #bankItems()} is not read before {@link #updateLocalBank} runs. + */ + private static final AtomicInteger BANK_LIVE_EPOCH = new AtomicInteger(0); + + private static final int BANK_OPEN_CACHE_SYNC_TIMEOUT_MS = 4_000; + + /** + * Monotonic counter incremented in {@link #updateLocalBank} for each applied bank container snapshot. + */ + public static int getBankLiveEpoch() { + return BANK_LIVE_EPOCH.get(); + } + + /** + * Tier C gate: after {@link #openBank()}, {@link #bankItems()} is trustworthy only if a snapshot arrived. + * If the bank was already open before {@code openBank()}, require {@code getBankLiveEpoch() > 0}; otherwise require + * the epoch to have advanced past {@code epochBeforeOpenBankCall}. + * + * @param bankWasOpenBeforeOpenBankCall {@code true} if {@link #isOpen()} was already {@code true} before calling {@code openBank()} + * @param epochBeforeOpenBankCall value of {@link #getBankLiveEpoch()} immediately before {@code openBank()} + */ + public static boolean verifyBankMirrorAfterOpen(boolean bankWasOpenBeforeOpenBankCall, int epochBeforeOpenBankCall) { + if (!isOpen()) { + return false; + } + int e = BANK_LIVE_EPOCH.get(); + if (bankWasOpenBeforeOpenBankCall) { + return e > 0; + } + return e > epochBeforeOpenBankCall; + } + + private static boolean awaitBankContainerSnapshotSince(int epochBeforeInteract) + { + boolean advanced = sleepUntil(() -> BANK_LIVE_EPOCH.get() > epochBeforeInteract, BANK_OPEN_CACHE_SYNC_TIMEOUT_MS); + if (!advanced && log.isDebugEnabled()) + { + log.debug("[Rs2Bank] bank UI open but no BANK ItemContainerChanged within {}ms (epoch before={} after={})", + BANK_OPEN_CACHE_SYNC_TIMEOUT_MS, epochBeforeInteract, BANK_LIVE_EPOCH.get()); + } + return advanced; + } + + /** + * After depositing to the bank (or other mutations), wait for {@link #updateLocalBank} so {@link #bankItems()} + * matches the server/container. Call with {@link #getBankLiveEpoch()} captured immediately before the mutation. + */ + public static boolean syncBankInventoryAfterChange(int epochBeforeMutation) + { + return awaitBankContainerSnapshotSince(epochBeforeMutation); + } + + /** + * When the bank is open and the lookup returned zero, the cache may lag one tick behind the widget; retry 1-2 ticks. + */ + private static boolean bankItemRaceRetryWarranted(int observedCount) + { + return observedCount == 0 && isOpen(); + } + + private static void logBankHasMissDebug(String kind, int id, String name, int amountRequested, String detail) + { + if (log.isDebugEnabled()) + { + log.debug("[Rs2Bank] hasBankItem miss after cache retry kind={} id={} name={} amount={} {}", + kind, id, name != null ? name : "", amountRequested, detail != null ? detail : ""); + } + } + + private static String firstNonEmpty(String a, String b) + { + if (a != null && !a.isEmpty()) + { + return a; + } + if (b != null && !b.isEmpty()) + { + return b; + } + return null; + } + + private static void logBankIdDriftDebug(int requestedId, Rs2ItemModel found, String kind) + { + if (!log.isDebugEnabled() || found == null) + { + return; + } + log.debug("[Rs2Bank] bank id drift kind={} requestedId={} foundId={} foundName={} qty={}", + kind, requestedId, found.getId(), found.getName(), found.getQuantity()); + } + + /** + * {@link Client#getItemDefinition(int)} is client-thread-only; pathfinder refresh and scripts call + * {@link #findBankStackRowForSavedId(int)} off-thread. + */ + private static ItemComposition getItemDefinitionThreadSafe(int id) + { + Client c = Microbot.getClient(); + if (c == null) + { + return null; + } + if (c.isClientThread()) + { + return c.getItemDefinition(id); + } + return Microbot.getClientThread().runOnClientThreadOptional(() -> c.getItemDefinition(id)).orElse(null); + } + + /** + * Resolves a bank row for a saved item id: exact id, noted/unnoted linked id, then fuzzy name from {@link ItemComposition} + * (covers stale {@link net.runelite.api.gameval.ItemID} constants and noted vs unnoted bank stacks). + */ + private static Rs2ItemModel findBankStackRowForSavedId(int id) + { + assert id > 0; + + Rs2ItemModel direct = findBankItem(id); + if (direct != null) + { + return direct; + } + + ItemComposition comp = getItemDefinitionThreadSafe(id); + if (comp != null) + { + int linked = comp.getLinkedNoteId(); + if (linked > 0 && linked != id) + { + Rs2ItemModel alt = findBankItem(linked); + if (alt != null) + { + logBankIdDriftDebug(id, alt, "linked-note-id"); + return alt; + } + } + } + + if (comp == null) + { + return null; + } + + String lookupName = firstNonEmpty(comp.getMembersName(), comp.getName()); + if (lookupName == null) + { + return null; + } + + Rs2ItemModel byName = findBankItem(lookupName, false, 1); + if (byName != null && byName.getId() != id) + { + logBankIdDriftDebug(id, byName, "name-fuzzy"); + } + return byName; + } + + private static Rs2ItemModel resolveBankStackForSavedId(int id, int minAmount) + { + assert minAmount > 0; + + Rs2ItemModel row = findBankStackRowForSavedId(id); + if (row == null) + { + return null; + } + return row.getQuantity() >= minAmount ? row : null; + } + /** * Container describes from what interface the action happens * eg: withdraw means the contailer will be the bank container @@ -162,6 +335,8 @@ public static void updateLocalBank(ItemContainerChanged event) { return; } + BANK_LIVE_EPOCH.incrementAndGet(); + final Item[] items = event.getItemContainer().getItems(); if (items == null) { rs2BankData.setEmpty(); @@ -227,7 +402,7 @@ public static Rs2ItemModel findBankItem(String name) { * @return boolean */ public static boolean hasItem(int id) { - return findBankItem(id) != null; + return id > 0 && findBankStackRowForSavedId(id) != null; } /** @@ -318,7 +493,7 @@ public static boolean hasItem(Collection names, boolean exact, int amoun */ public static boolean hasItem(int[] ids) { return Arrays.stream(ids) - .anyMatch(id -> findBankItem(id) != null); + .anyMatch(id -> id > 0 && findBankStackRowForSavedId(id) != null); } /** @@ -329,7 +504,7 @@ public static boolean hasItem(int[] ids) { */ public static boolean hasAllItems(int[] ids) { return Arrays.stream(ids) - .allMatch(id -> findBankItem(id) != null); + .allMatch(id -> id > 0 && findBankStackRowForSavedId(id) != null); } /** @@ -341,10 +516,7 @@ public static boolean hasAllItems(int[] ids) { */ public static boolean hasItem(int[] ids, int amount) { return Arrays.stream(ids) - .anyMatch(id -> { - Rs2ItemModel item = findBankItem(id); - return item != null && item.getQuantity() >= amount; - }); + .anyMatch(id -> id > 0 && resolveBankStackForSavedId(id, amount) != null); } /** @@ -356,10 +528,7 @@ public static boolean hasItem(int[] ids, int amount) { */ public static boolean hasAllItems(int[] ids, int amount) { return Arrays.stream(ids) - .allMatch(id -> { - Rs2ItemModel item = findBankItem(id); - return item != null && item.getQuantity() >= amount; - }); + .allMatch(id -> id > 0 && resolveBankStackForSavedId(id, amount) != null); } /** @@ -370,7 +539,18 @@ public static boolean hasAllItems(int[] ids, int amount) { * @return boolean */ public static boolean hasBankItem(String name) { - return findBankItem(name, false, 1) != null; + if (findBankItem(name, false, 1) != null) { + return true; + } + if (!bankItemRaceRetryWarranted(0)) { + return false; + } + sleepTicks(2); + boolean ok = findBankItem(name, false, 1) != null; + if (!ok) { + logBankHasMissDebug("name", -1, name, 1, "cacheSize=" + bankItems().size()); + } + return ok; } /** @@ -392,7 +572,28 @@ public static boolean hasBankItem(String name, int amount) { * @return boolean */ public static boolean hasBankItem(String name, int amount, boolean exact) { - return findBankItem(name, exact, amount) != null; + if (amount <= 0) { + return true; + } + if (findBankItem(name, exact, amount) != null) { + return true; + } + if (!isOpen()) { + return false; + } + Rs2ItemModel any = findBankItem(name, exact, 1); + if (any != null && any.getQuantity() < amount) { + return false; + } + if (!bankItemRaceRetryWarranted(0)) { + return false; + } + sleepTicks(2); + boolean ok = findBankItem(name, exact, amount) != null; + if (!ok) { + logBankHasMissDebug("name", -1, name, amount, "cacheSize=" + bankItems().size()); + } + return ok; } /** @@ -404,12 +605,53 @@ public static boolean hasBankItem(String name, int amount, boolean exact) { * @return boolean */ public static boolean hasBankItem(String name, boolean exact) { - return findBankItem(name, exact) != null; + if (findBankItem(name, exact) != null) { + return true; + } + if (!bankItemRaceRetryWarranted(0)) { + return false; + } + sleepTicks(2); + boolean ok = findBankItem(name, exact) != null; + if (!ok) { + logBankHasMissDebug("name", -1, name, 1, "exact=" + exact + " cacheSize=" + bankItems().size()); + } + return ok; } //hasBankItem overload to check with id and amount public static boolean hasBankItem(int id, int amount) { - return count(id) >= amount; + if (amount <= 0) { + return true; + } + if (id <= 0) { + return false; + } + Rs2ItemModel enough = resolveBankStackForSavedId(id, amount); + if (enough != null) { + return true; + } + Rs2ItemModel row = findBankStackRowForSavedId(id); + if (row != null) { + return false; + } + if (!isOpen()) { + return false; + } + if (!bankItemRaceRetryWarranted(0)) { + return false; + } + sleepTicks(2); + enough = resolveBankStackForSavedId(id, amount); + if (enough != null) { + return true; + } + row = findBankStackRowForSavedId(id); + if (row != null) { + return false; + } + logBankHasMissDebug("id", id, null, amount, "cacheSize=" + bankItems().size()); + return false; } /** @@ -424,9 +666,11 @@ public static int count(Predicate predicate) { * Query count of item inside of bank */ public static int count(int id) { - Rs2ItemModel bankItem = findBankItem(id); - if (bankItem == null) return 0; - return bankItem.getQuantity(); + if (id <= 0) { + return 0; + } + Rs2ItemModel bankItem = findBankStackRowForSavedId(id); + return bankItem == null ? 0 : bankItem.getQuantity(); } /** @@ -1176,6 +1420,56 @@ public static boolean depositAllExcept(boolean exact, String... names) { return depositAllExcept(Rs2ItemModel.matches(exact, names)); } + /** + * Deposits all inventory items except those retained by exact id and/or name rules. + *

+ * Include noted and unnoted ids in {@code retainItemIds} when both may appear in inventory. + * Name map semantics match {@link #depositAllExcept(Map)}: {@code true} = case-insensitive substring, + * {@code false} = exact name ({@link String#equalsIgnoreCase}). + * + * @param retainItemIds ids to keep in inventory (may be empty) + * @param fuzzyOrExactNames setup names to keep; value is fuzzy ({@code contains}) vs exact + * @return {@code true} if any item was deposited + */ + public static boolean depositAllExcept(Set retainItemIds, Map fuzzyOrExactNames) { + final Set ids = retainItemIds == null ? Collections.emptySet() : retainItemIds; + final Map names = fuzzyOrExactNames == null ? Collections.emptyMap() : fuzzyOrExactNames; + return depositAllExcept(item -> isInventoryItemRetainedForSetupDeposit(item, ids, names)); + } + + /** + * Whether an inventory stack should be kept when trimming inventory for an inventory-setup load. + */ + public static boolean isInventoryItemRetainedForSetupDeposit( + Rs2ItemModel item, Set retainIds, Map fuzzyNames) { + if (item == null) { + return false; + } + if (retainIds != null && retainIds.contains(item.getId())) { + return true; + } + if (fuzzyNames == null || fuzzyNames.isEmpty()) { + return false; + } + String invName = item.getName(); + if (invName == null) { + return false; + } + String invLower = invName.toLowerCase(Locale.ROOT); + for (Map.Entry e : fuzzyNames.entrySet()) { + String key = e.getKey(); + if (key == null || key.isEmpty()) { + continue; + } + boolean fuzzy = Boolean.TRUE.equals(e.getValue()); + String keyLower = key.toLowerCase(Locale.ROOT); + if (fuzzy ? invLower.contains(keyLower) : invName.equalsIgnoreCase(key)) { + return true; + } + } + return false; + } + /** * withdraw one item identified by its ItemWidget. * @@ -1198,7 +1492,7 @@ private static boolean withdrawOne(Rs2ItemModel rs2Item) { * @param id the item id */ public static boolean withdrawOne(int id) { - return withdrawOne(findBankItem(id)); + return withdrawOne(findBankStackRowForSavedId(id)); } public static boolean withdrawItem(String name) { @@ -1245,7 +1539,7 @@ public static boolean withdrawOne(String name) { * @param id the item id */ public static boolean withdrawAllButOne(int id) { - return withdrawAllButOne(findBankItem(id)); + return withdrawAllButOne(findBankStackRowForSavedId(id)); } /** @@ -1386,7 +1680,7 @@ public static boolean withdrawX(Predicate filter, int amount) { * @param amount amount to withdraw */ public static boolean withdrawX(int id, int amount) { - return withdrawXItem(findBankItem(id), amount); + return withdrawXItem(findBankStackRowForSavedId(id), amount); } /** @@ -1464,7 +1758,7 @@ public static boolean withdrawAll(String name) { * @return */ public static boolean withdrawAll(int id) { - return withdrawAll(findBankItem(id)); + return withdrawAll(findBankStackRowForSavedId(id)); } /** @@ -1624,6 +1918,8 @@ public static boolean openBank() { if (isOpen()) return true; + int epochBeforeInteract = BANK_LIVE_EPOCH.get(); + WorldPoint anchor = Rs2Player.getWorldLocation(); List candidates = Stream.of( @@ -1646,7 +1942,10 @@ public static boolean openBank() { if (banker == null || !Rs2Npc.interact(banker, "Bank")) return false; } - return sleepUntil(Rs2Bank::isOpen, 5_000); + if (!sleepUntil(Rs2Bank::isOpen, 5_000)) { + return false; + } + return awaitBankContainerSnapshotSince(epochBeforeInteract); } catch (Exception ex) { Microbot.logStackTrace("Rs2Bank", ex); return false; @@ -1764,15 +2063,19 @@ public static boolean openBank(Rs2NpcModel npc) { if (npc == null) return false; + int epochBeforeInteract = BANK_LIVE_EPOCH.get(); + boolean interactResult = Rs2Npc.interact(npc, "bank"); if (!interactResult) { return false; } - sleepUntil(Rs2Bank::isOpen); + if (!sleepUntil(Rs2Bank::isOpen)) { + return false; + } sleep(Rs2Random.randomGaussian(800,200)); - return true; + return awaitBankContainerSnapshotSince(epochBeforeInteract); } catch (Exception ex) { Microbot.logStackTrace("Rs2Bank", ex); } @@ -1798,15 +2101,19 @@ public static boolean openBank(TileObject object) { if (object == null) return false; + int epochBeforeInteract = BANK_LIVE_EPOCH.get(); + boolean interactResult = Rs2GameObject.interact(object, "bank"); if (!interactResult) { return false; } - sleepUntil(Rs2Bank::isOpen); + if (!sleepUntil(Rs2Bank::isOpen)) { + return false; + } sleep(Rs2Random.randomGaussian(800,200)); - return true; + return awaitBankContainerSnapshotSince(epochBeforeInteract); } catch (Exception ex) { Microbot.logStackTrace("Rs2Bank", ex); } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/Rs2BankSetupDepositRetainTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/Rs2BankSetupDepositRetainTest.java new file mode 100644 index 00000000000..3fd33a519ab --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/bank/Rs2BankSetupDepositRetainTest.java @@ -0,0 +1,59 @@ +package net.runelite.client.plugins.microbot.util.bank; + +import net.runelite.client.plugins.microbot.util.inventory.Rs2ItemModel; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +public class Rs2BankSetupDepositRetainTest { + @Test + public void retainMatchesExactId() { + Rs2ItemModel item = Mockito.mock(Rs2ItemModel.class); + Mockito.when(item.getId()).thenReturn(995); + Mockito.when(item.getName()).thenReturn("Coins"); + + boolean keep = Rs2Bank.isInventoryItemRetainedForSetupDeposit(item, Set.of(995, 996), Collections.emptyMap()); + Assert.assertTrue(keep); + } + + @Test + public void retainMatchesFuzzyNameSubstring() { + Rs2ItemModel item = Mockito.mock(Rs2ItemModel.class); + Mockito.when(item.getId()).thenReturn(123); + Mockito.when(item.getName()).thenReturn("Shark"); + + Map fuzzy = new LinkedHashMap<>(); + fuzzy.put("shark", true); + + boolean keep = Rs2Bank.isInventoryItemRetainedForSetupDeposit(item, Collections.emptySet(), fuzzy); + Assert.assertTrue(keep); + } + + @Test + public void retainExactNameDoesNotMatchSubstring() { + Rs2ItemModel item = Mockito.mock(Rs2ItemModel.class); + Mockito.when(item.getId()).thenReturn(321); + Mockito.when(item.getName()).thenReturn("Raw shark"); + + Map exact = new LinkedHashMap<>(); + exact.put("Shark", false); + + boolean keep = Rs2Bank.isInventoryItemRetainedForSetupDeposit(item, Collections.emptySet(), exact); + Assert.assertFalse(keep); + } + + @Test + public void foreignItemNotRetained() { + Rs2ItemModel item = Mockito.mock(Rs2ItemModel.class); + Mockito.when(item.getId()).thenReturn(1); + Mockito.when(item.getName()).thenReturn("Junk"); + + boolean keep = Rs2Bank.isInventoryItemRetainedForSetupDeposit(item, Set.of(995), Map.of("lobster", true)); + Assert.assertFalse(keep); + } +} From 17ad68eb3c34232c83694c14049bb7b439336526 Mon Sep 17 00:00:00 2001 From: Haliax <18099301+TheHaliax@users.noreply.github.com> Date: Mon, 11 May 2026 08:43:43 -0500 Subject: [PATCH 5/9] fix(walker): harden door flow and banked route decisions Tighten post-transport door handling and bank-vs-direct compare so walker picks stable routes. --- .../shortestpath/ShortestPathConfig.java | 24 +- .../shortestpath/ShortestPathPanel.java | 4 +- .../shortestpath/ShortestPathPlugin.java | 43 +- .../shortestpath/ShortestPathScript.java | 89 +- .../microbot/util/walker/Rs2Walker.java | 4975 +++++++++++++---- .../util/walker/TransportRouteAnalysis.java | 36 +- .../microbot/util/walker/WebWalkLog.java | 151 + 7 files changed, 4298 insertions(+), 1024 deletions(-) create mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/WebWalkLog.java 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 05bea3fb3fa..0e176154796 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 @@ -823,7 +823,25 @@ default boolean spiritTreeHosidius() { position = 4, section = sectionSpiritTrees ) - default boolean spiritTreeFarmingGuild() { - return true; - } + default boolean spiritTreeFarmingGuild() { + return true; + } + + @ConfigSection( + name = "Developer", + description = "Optional — most users can ignore.", + position = 100 + ) + String sectionDeveloper = "sectionDeveloper"; + + @ConfigItem( + keyName = "reloadTransportDefinitions", + name = "Reload transport TSVs", + description = "Turn ON to reload web-walker transport tables from the client JAR; saves OFF automatically. Use after replacing packaged TSVs in a dev build.", + position = 0, + section = sectionDeveloper + ) + default boolean reloadTransportDefinitions() { + return false; + } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPanel.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPanel.java index ec15191077e..ea830704801 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPanel.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathPanel.java @@ -34,7 +34,6 @@ import net.runelite.client.plugins.microbot.util.bank.enums.BankLocation; import net.runelite.client.plugins.microbot.util.depositbox.DepositBoxLocation; import net.runelite.client.plugins.microbot.util.depositbox.Rs2DepositBox; -import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import net.runelite.client.plugins.microbot.util.walker.enums.Allotments; import net.runelite.client.plugins.microbot.util.walker.enums.Birds; import net.runelite.client.plugins.microbot.util.walker.enums.Bushes; @@ -701,8 +700,7 @@ void startWalking(WorldPoint point) void stopWalking() { Microbot.log("Web walking stopping.."); - plugin.getShortestPathScript().setTriggerWalker(null); - Rs2Walker.setTarget(null); + plugin.getShortestPathScript().setTriggerWalker(null, "panel:stop-walking-button"); } /* ------------------------------------------------------------------ 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 9aaddaa302d..d7711bdb55a 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 @@ -146,6 +146,9 @@ public class ShortestPathPlugin extends Plugin implements KeyListener { @Inject private KeyManager keyManager; + @Inject + private ConfigManager configManager; + boolean drawCollisionMap; boolean drawMap; boolean drawMinimap; @@ -371,7 +374,20 @@ public boolean isNearPath(WorldPoint location) { return false; } - private final Pattern TRANSPORT_OPTIONS_REGEX = Pattern.compile("^(avoidWilderness|use\\w+|useTeleportationItems)$"); + private static final Set PATH_REFRESH_CONFIG_KEYS = Set.of( + "avoidWilderness", + "distanceBeforeUsingTeleports", + "recalculateDistance", + "finishDistance", + "calculationCutoff", + "walkWithBankedTransports", + "minBankRouteSavings", + "preferNonConsumableTeleportAndSpells", + "preferTransportToTarget", + "maxSimilarTransportDistance" + ); + private static final String RELOAD_TRANSPORT_DEFINITIONS_KEY = "reloadTransportDefinitions"; + private final Pattern TRANSPORT_OPTIONS_REGEX = Pattern.compile("^use\\w+$"); @Subscribe public void onConfigChanged(ConfigChanged event) { @@ -379,6 +395,8 @@ public void onConfigChanged(ConfigChanged event) { return; } + cacheConfigValues(); + // Reset config in Rs2Walker when changed Rs2Walker.setConfig(config); @@ -400,12 +418,29 @@ public void onConfigChanged(ConfigChanged event) { return; } - // Transport option changed; rerun pathfinding - if (TRANSPORT_OPTIONS_REGEX.matcher(event.getKey()).find()) { + boolean reloadRequested = RELOAD_TRANSPORT_DEFINITIONS_KEY.equals(event.getKey()) + && Boolean.parseBoolean(event.getNewValue()); + if (reloadRequested && pathfinderConfig != null) { + int reloadedOrigins = pathfinderConfig.reloadTransportDefinitionsFromResources(); + log.info("[ShortestPath] Reloaded transport TSV definitions from resources (origins={})", reloadedOrigins); + } + + // Transport/path option changed; rerun pathfinding so PathfinderConfig.refresh() rehydrates snapshots. + if (reloadRequested + || TRANSPORT_OPTIONS_REGEX.matcher(event.getKey()).matches() + || PATH_REFRESH_CONFIG_KEYS.contains(event.getKey())) { + if (pathfinderConfig != null) { + pathfinderConfig.invalidateTransportRefreshCache(); + } if (pathfinder != null) { restartPathfinding(pathfinder.getStart(), pathfinder.getTargets()); } } + + // One-shot developer toggle: switch itself back off after handling. + if (reloadRequested) { + configManager.setConfiguration(CONFIG_GROUP, RELOAD_TRANSPORT_DEFINITIONS_KEY, false); + } } @Subscribe @@ -518,9 +553,9 @@ public void onGameStateChanged(GameStateChanged event) { void handlePendingLoginRefresh() { if (pendingLoginRefresh && pathfinderConfig != null) { - pendingLoginRefresh = false; try { pathfinderConfig.refresh(); + pendingLoginRefresh = false; } catch (Exception e) { log.warn("[ShortestPath] post-login refresh failed", e); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathScript.java index b5f93e949c5..4ddb99959f9 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathScript.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathScript.java @@ -3,6 +3,7 @@ import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Player; import net.runelite.api.coords.WorldPoint; import net.runelite.client.plugins.microbot.Microbot; import net.runelite.client.plugins.microbot.Script; @@ -23,6 +24,10 @@ public class ShortestPathScript extends Script { private volatile ShortestPathConfig config; private volatile Future walkTaskFuture; private final AtomicBoolean walkTaskRunning = new AtomicBoolean(false); + private volatile WorldPoint lastExitRetryTarget; + private volatile int consecutiveExitRetries = 0; + private static final int MAX_CONSECUTIVE_EXIT_RETRIES = 3; + private static final long USER_STOP_REASON_WINDOW_MS = 3_000L; public boolean run(ShortestPathConfig config) { this.config = config; @@ -47,11 +52,20 @@ public void shutdown() { } public void setTriggerWalker(WorldPoint point) { + setTriggerWalker(point, null); + } + + /** + * @param stopReason when {@code point} is null, passed to {@link Rs2Walker#clearWalkingRoute(String)} (e.g. {@code hotkey:ctrl+x}) + */ + public void setTriggerWalker(WorldPoint point, String stopReason) { if (point == null) { - log.debug("ShortestPathScript: setTriggerWalker called with null point"); + String r = stopReason != null && !stopReason.isBlank() + ? stopReason + : "shortest-path-script:trigger-null"; triggerWalker = null; - Rs2Walker.setTarget(null); + Rs2Walker.clearWalkingRoute(r); Future future = walkTaskFuture; if (future != null && !future.isDone()) { future.cancel(true); @@ -82,10 +96,15 @@ private void startWalkTask() { state = Rs2Walker.walkWithState(target); } - if (target.equals(getTriggerWalker()) - && (state == WalkerState.ARRIVED || state == WalkerState.UNREACHABLE || state == WalkerState.EXIT)) { - triggerWalker = null; - Rs2Walker.setTarget(null); + if (target.equals(getTriggerWalker())) { + if (state == WalkerState.EXIT && shouldRetryAfterExit(target)) { + return; + } + if (state == WalkerState.ARRIVED || state == WalkerState.UNREACHABLE || state == WalkerState.EXIT) { + resetExitRetryState(); + triggerWalker = null; + Rs2Walker.clearWalkingRoute("shortest-path-script:walk-task-terminal-state"); + } } } catch (Exception ex) { log.error("Exception in ShortestPathScript walk task: {} - ", ex.getMessage(), ex); @@ -94,4 +113,62 @@ private void startWalkTask() { } }); } + + private boolean shouldRetryAfterExit(WorldPoint target) { + if (target == null || !target.equals(getTriggerWalker())) { + resetExitRetryState(); + return false; + } + if (isLocalPlayerDead()) { + resetExitRetryState(); + return false; + } + if (isRecentUserStopClear()) { + resetExitRetryState(); + return false; + } + if (!target.equals(lastExitRetryTarget)) { + lastExitRetryTarget = target; + consecutiveExitRetries = 0; + } + if (consecutiveExitRetries >= MAX_CONSECUTIVE_EXIT_RETRIES) { + log.warn("[ShortestPathScript] EXIT retry limit reached for target={} retries={}", + target, consecutiveExitRetries); + resetExitRetryState(); + return false; + } + consecutiveExitRetries++; + log.info("[ShortestPathScript] EXIT auto-retry {}/{} for target={}", + consecutiveExitRetries, MAX_CONSECUTIVE_EXIT_RETRIES, target); + return true; + } + + private boolean isRecentUserStopClear() { + long clearAt = Rs2Walker.getLastRouteClearAtMs(); + if (clearAt <= 0 || System.currentTimeMillis() - clearAt > USER_STOP_REASON_WINDOW_MS) { + return false; + } + String reason = Rs2Walker.getLastRouteClearReason(); + if (reason == null) { + return false; + } + String normalized = reason.toLowerCase(); + return normalized.contains("ctrl+x") + || normalized.contains("stop-walking-button") + || normalized.contains("trigger-null"); + } + + private boolean isLocalPlayerDead() { + return Microbot.getClientThread() + .runOnClientThreadOptional(() -> { + Player local = Microbot.getClient().getLocalPlayer(); + return local != null && local.isDead(); + }) + .orElse(false); + } + + private void resetExitRetryState() { + lastExitRetryTarget = null; + consecutiveExitRetries = 0; + } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java index 4478841dfff..cba148fdf03 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java @@ -1,6 +1,6 @@ package net.runelite.client.plugins.microbot.util.walker; -import com.google.common.util.concurrent.ThreadFactoryBuilder; +import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import net.runelite.api.*; @@ -43,26 +43,46 @@ import net.runelite.client.plugins.microbot.util.npc.Rs2NpcModel; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.player.Rs2Pvp; +import net.runelite.client.plugins.microbot.util.leaguetransport.Rs2LeaguesTransport; +import net.runelite.client.plugins.microbot.util.leaguetransport.Rs2MapOfAlacrityTransport; +import net.runelite.client.plugins.microbot.util.leaguetransport.SeasonalTransportHandler; +import net.runelite.client.plugins.microbot.util.leaguetransport.SeasonalTransportHandlers; +import net.runelite.client.plugins.microbot.util.logging.Rs2LogRateLimit; +import java.util.function.BooleanSupplier; +import org.slf4j.event.Level; import net.runelite.client.plugins.microbot.util.poh.PohTeleports; import net.runelite.client.plugins.microbot.util.poh.PohTransport; import net.runelite.client.plugins.microbot.util.tabs.Rs2Tab; +import net.runelite.client.plugins.microbot.util.leaguetransport.LeaguesRegion; import net.runelite.client.plugins.microbot.util.tile.Rs2Tile; import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorAheadResolver; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorHandler; +import net.runelite.client.plugins.microbot.util.walker.door.Rs2WalkerAwaits; +import net.runelite.client.plugins.microbot.util.walker.door.model.AwaitTicket; +import net.runelite.client.plugins.microbot.util.walker.door.model.DoorResolution; +import net.runelite.client.plugins.microbot.util.walker.banking.Rs2WalkerBankingPlanner; +import net.runelite.client.plugins.microbot.util.walker.awaits.Rs2WalkerRuntimeAwaits; +import net.runelite.client.plugins.microbot.util.walker.stall.Rs2WalkerStallPolicy; +import net.runelite.client.plugins.microbot.util.walker.transport.Rs2WalkerTransportAwaits; +import net.runelite.client.plugins.microbot.util.walker.lifecycle.Rs2WalkerLifecycleRuntime; import net.runelite.client.plugins.skillcalculator.skills.MagicAction; import net.runelite.client.ui.overlay.worldmap.WorldMapPoint; +import net.runelite.client.ui.overlay.worldmap.WorldMapPointManager; import javax.inject.Named; import java.awt.*; import java.util.*; import java.util.List; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; import java.util.concurrent.Future; -import java.util.concurrent.ThreadFactory; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; import java.util.stream.IntStream; @@ -72,6 +92,8 @@ /** * TODO: * 1. fix teleports starting from inside the POH + *

+ * Seasonal handlers ({@link Rs2LeaguesTransport#tryHandleLeaguesAreaTransport}, MoA) must not run on the client thread — same contract as {@link Rs2LeaguesTransport#leaguesTeleport}. */ @Slf4j public class Rs2Walker { @@ -80,14 +102,550 @@ public class Rs2Walker { static int stuckCount = 0; static WorldPoint lastPosition; static long lastMovedTimeMs = 0; + /** Rising-edge detection for {@link #checkIfStuck()} animation progress without tile delta. */ + private static boolean prevAnimatingForStuckCheck = false; static volatile WorldPoint currentTarget; static int nextWalkingDistance = 10; - static final int OFFSET = 10; // max offset of the exact area we teleport to + /** + * Active Microbot walk destination ({@code null} when no scripted walk). ShortestPath overlay + * must not clear {@link ShortestPathPlugin#getPathfinder()} while this is non-null — otherwise + * {@link #processWalk} loses the pathfinder while {@link #currentTarget} stays set (pathfinder-still-null EXIT). + */ + public static WorldPoint getCurrentTarget() { + return currentTarget; + } + + /** + * Sticky interim minimap click target to avoid destination flapping when the minimap flag + * disappears around bends. Once we click a reachable point, keep it until we get close + * (<= {@link #INTERIM_CLOSE_TILES}) or progress stalls for {@link #INTERIM_PROGRESS_TIMEOUT_MS}. + */ + private static volatile WorldPoint interimTargetWp = null; + private static volatile int interimTargetIdx = -1; + private static volatile long interimSetAtMs = 0L; + private static volatile long interimLastProgressAtMs = 0L; + private static volatile int interimLastBestPathIdx = -1; + private static volatile long interimLastRetargetAtMs = 0L; + + /** Cooldown so partial-segment in-transit {@link #recalculatePath()} does not spam. */ + private static volatile long lastPartialTransRecalcMs = 0L; + private static final long PARTIAL_TRANS_RECAL_COOLDOWN_MS = 3500L; + + private static final int INTERIM_CLOSE_TILES = 4; + private static final long INTERIM_PROGRESS_TIMEOUT_MS = 2500L; + private static final long INTERIM_MAX_AGE_MS = 10_000L; + private static final long INTERIM_RETARGET_COOLDOWN_MS = 900L; + private static final long RAW_SCAN_DOOR_FOCUS_MAX_MS = 2200L; + private static final int RAW_SCAN_DOOR_FOCUS_MAX_ATTEMPTS = 3; + private static final long DOOR_POST_INTERACT_SETTLE_MS = 900L; + private static final long DOOR_EDGE_SKIP_COOLDOWN_MS = 700L; + private static final long RECOVERY_MOVEMENT_IN_FLIGHT_MS = 1400L; + private static final long DOOR_TRAVERSAL_RECOVERY_BLOCK_MS = 2_200L; + private static final int PATHFINDER_DONE_POLL_WAIT_MS = 1200; + private static final int PATHFINDER_DONE_RETRY_SLEEP_MIN_MS = 120; + private static final int PATHFINDER_DONE_RETRY_SLEEP_MAX_MS = 220; + private static final long STARTUP_FIRST_CLICK_BUDGET_MS = 2200L; + private static final int POST_DOOR_FAST_CLICK_MAX_EUCLIDEAN = 13; + private static final int QUETZAL_MAP_VISIBLE_WAIT_MS = 7_000; + private static final int QUETZAL_ICON_READY_WAIT_MS = 3_000; + private static final int FINAL_ADJACENT_CANVAS_NUDGE_CHEBYSHEV = 1; + private static final int PATH_ADJ_COMPONENT_LINK_MAX_TILE_GAP = 6; + private static final int PATH_ADJ_COMPONENT_LINK_MAX_EDGE_GAP = 6; + private static final int SEGMENT_DOOR_FAMILY_MARK_RADIUS = 2; + private static final long POST_TRANSPORT_PATH_TMARK_WINDOW_MS = 15_000L; + private static final long POST_TRANSPORT_OFFPATH_WAIT_BUDGET_MS = 2_500L; + private static final int POST_TRANSPORT_OFFPATH_WAIT_SLICE_MS = 450; + private static final int TRANSPORT_DEST_MATCH_CHEBYSHEV = 1; + private static final int PATH_VARIANCE_TOLERANCE_CHEBYSHEV = 3; + private static final int POST_TRANSPORT_RAW_SCAN_TRANSPORT_LOOKAHEAD_EDGES = 6; + private static final int POST_TRANSPORT_RAW_SCAN_TRANSPORT_MAX_DIST = 15; + private static final long TRANSPORT_POST_INTERACT_SETTLE_MS = 900L; + private static volatile Integer rawScanFocusedDoorIdx = null; + private static volatile long rawScanFocusedDoorSetAtMs = 0L; + private static volatile int rawScanFocusedDoorAttempts = 0; + private static volatile long doorInteractionSettleUntilMs = 0L; + private static volatile long lastDoorEdgePassSkipAtMs = 0L; + private static volatile long lastUnreachableRecoveryClickAtMs = 0L; + private static volatile long walkSessionStartedAtMs = 0L; + private static volatile boolean firstMovementClickMarked = false; + private static volatile long lastTransportHandledAtMs = 0L; + private static volatile WorldPoint lastTransportHandledAtLocation = null; + private static final java.util.Deque expectedTransportDestinations = new ArrayDeque<>(); + private static final Set startupPhasesLogged = ConcurrentHashMap.newKeySet(); + + /** + * Max Chebyshev "radius" for Quetzal / near-destination checks — guards use {@code distanceTo2D < OFFSET}. + * {@link WorldPoint#distanceTo(WorldPoint)} delegates to {@link WorldPoint#distanceTo2D(WorldPoint)} when both + * points share a plane, so mixed {@code distanceTo}/{@code distanceTo2D} call sites agree for walking goals. + * If planes differ, {@code distanceTo} returns {@link Integer#MAX_VALUE} (not {@code distanceTo2D}) — do not use + * for cross-plane teleport semantics without an explicit plane check. + * Integer Chebyshev distance: {@code < OFFSET} is the same as {@code <= OFFSET - 1}. + * + * @see WorldPoint#distanceTo(WorldPoint) + */ + static final int OFFSET = 10; + + /** Post-travel poll/timeout for Spirit Tree, Quetzal, glider, fairy ring, and other same-plane landing waits. */ + private static final int TRANSPORT_LANDING_WAIT_POLL_MS = 100; + private static final int TRANSPORT_LANDING_WAIT_TIMEOUT_MS = 12_000; + + /** Ship / charter / glider — landing predicate uses {@link #isPlayerWithinChebyshevOf} with this exclusive bound. */ + private static final int TRANSPORT_NEAR_LANDING_CHEBYSHEV = 10; + + /** Max wait after ship/NPC/boat dialogue until near destination (must match {@link #sleepUntil} timeout + warn text). */ + private static final int SHIP_NPC_BOAT_LANDING_WAIT_MS = 10_000; + + /** After scene-object transport {@link #handleObject} — landing poll timeout + matching warn (cf. {@link #SHIP_NPC_BOAT_LANDING_WAIT_MS}). */ + private static final int POST_HANDLE_OBJECT_LANDING_WAIT_MS = 5_000; + + /** Teleport “already near destination” skip in path loop — same semantics as prior {@code distanceTo2D < 3}. */ + private static final int TELEPORT_NEAR_SKIP_CHEBYSHEV = 3; + + /** + * When the last walkable path tile is within this Chebyshev distance of the goal, treat the leg as a + * "short interior" finish (e.g. door → small room): cap {@link #tightFinishThreshold} so we do not + * return {@link WalkerState#ARRIVED} while still outside the building. + */ + private static final int TIGHT_PATH_GOAL_GAP = 4; // Set this to true, if you want to calculate the path but do not want to walk to it static boolean debug = false; + /** Bounds tail recursion that was previously unbounded {@code processWalk} self-calls. */ + private static final int MAX_PROCESS_WALK_TAIL_ITERATIONS = 64; + + /** + * Verbose walker traces — enable DEBUG logging for {@code net.runelite.client.plugins.microbot}. + * Uses {@link Microbot#log(Level, String, Object...)} so levels route consistently. + */ + private static void walkerDiag(String format, Object... args) { + Microbot.log(Level.DEBUG, "[WalkerDiag] " + format, args); + } + + /** + * Compact {@code x,y,p} for logs (world API coords). Similar comma coords exist in test harnesses — keep here until + * a shared microbot util is justified. + */ + private static String compactWorldPoint(WorldPoint wp) { + if (wp == null) { + return "?"; + } + return wp.getX() + "," + wp.getY() + ",p" + wp.getPlane(); + } + + private static void markWalkSessionStart(WorldPoint target) { + walkSessionStartedAtMs = System.currentTimeMillis(); + firstMovementClickMarked = false; + startupPhasesLogged.clear(); + lastTransportHandledAtLocation = null; + synchronized (expectedTransportDestinations) { + expectedTransportDestinations.clear(); + } + WebWalkLog.tmark("walk_start", 0, target, Rs2Player.getWorldLocation(), "target_set"); + } + + private static void markFirstMovementClick(String phase, WorldPoint target, WorldPoint at, String detail) { + if (firstMovementClickMarked) { + return; + } + long startedAt = walkSessionStartedAtMs; + if (startedAt <= 0) { + return; + } + firstMovementClickMarked = true; + WebWalkLog.tmark(phase, System.currentTimeMillis() - startedAt, target, at, detail); + } + + private static void markStartupPhase(String phase, WorldPoint target, String detail) { + if (firstMovementClickMarked || !startupPhasesLogged.add(phase)) { + return; + } + long startedAt = walkSessionStartedAtMs; + if (startedAt <= 0) { + return; + } + WebWalkLog.tmark(phase, System.currentTimeMillis() - startedAt, target, Rs2Player.getWorldLocation(), detail); + } + + private static void tmarkPostTransport(String phase, WorldPoint target, String detail) { + long handledAt = lastTransportHandledAtMs; + if (handledAt <= 0L) { + return; + } + long elapsed = System.currentTimeMillis() - handledAt; + if (elapsed < 0L || elapsed > POST_TRANSPORT_PATH_TMARK_WINDOW_MS) { + return; + } + WebWalkLog.tmark(phase, elapsed, target, Rs2Player.getWorldLocation(), detail); + } + + private enum WalkerPhase { + STARTUP, + STEADY + } + + private interface ObstaclePolicy { + long segmentDoorTimeoutMs(); + long unreachableDoorTimeoutMs(); + int edgeResolutionWaitTimeoutMs(); + long pathAdjacentProbeTimeoutMs(); + boolean allowBroadRawHandlers(); + boolean allowPathAdjacentProbe(); + boolean allowNearbyFallback(); + } + + private static final class StartupObstaclePolicy implements ObstaclePolicy { + @Override + public long segmentDoorTimeoutMs() { + return 800L; + } + + @Override + public long unreachableDoorTimeoutMs() { + return 800L; + } + + @Override + public int edgeResolutionWaitTimeoutMs() { + return 700; + } + + @Override + public long pathAdjacentProbeTimeoutMs() { + return 700L; + } + + @Override + public boolean allowBroadRawHandlers() { + return false; + } + + @Override + public boolean allowPathAdjacentProbe() { + return false; + } + + @Override + public boolean allowNearbyFallback() { + return false; + } + } + + private static final class SteadyObstaclePolicy implements ObstaclePolicy { + @Override + public long segmentDoorTimeoutMs() { + return 1500L; + } + + @Override + public long unreachableDoorTimeoutMs() { + return 1500L; + } + + @Override + public int edgeResolutionWaitTimeoutMs() { + return 1800; + } + + @Override + public long pathAdjacentProbeTimeoutMs() { + return 1500L; + } + + @Override + public boolean allowBroadRawHandlers() { + return true; + } + + @Override + public boolean allowPathAdjacentProbe() { + return true; + } + + @Override + public boolean allowNearbyFallback() { + return true; + } + } + + private static final ObstaclePolicy STARTUP_OBSTACLE_POLICY = new StartupObstaclePolicy(); + private static final ObstaclePolicy STEADY_OBSTACLE_POLICY = new SteadyObstaclePolicy(); + + private static WalkerPhase currentWalkerPhase() { + if (firstMovementClickMarked) { + return WalkerPhase.STEADY; + } + long startedAt = walkSessionStartedAtMs; + if (startedAt <= 0) { + return WalkerPhase.STEADY; + } + return (System.currentTimeMillis() - startedAt) <= STARTUP_FIRST_CLICK_BUDGET_MS + ? WalkerPhase.STARTUP + : WalkerPhase.STEADY; + } + + private static ObstaclePolicy obstaclePolicyForCurrentPhase() { + return currentWalkerPhase() == WalkerPhase.STARTUP + ? STARTUP_OBSTACLE_POLICY + : STEADY_OBSTACLE_POLICY; + } + + /** + * Same-plane Chebyshev distance from player to {@code dest} strictly less than {@code maxChebyshevExclusive}. + * Requires matching {@link WorldPoint#getPlane()} before using {@link WorldPoint#distanceTo2D} — that method only + * compares X/Y, so same X/Y on different planes still reads as distance {@code 0} without an explicit plane check. + */ + private static boolean isPlayerWithinChebyshevOf(WorldPoint dest, int maxChebyshevExclusive) { + if (dest == null) { + return false; + } + WorldPoint pl = Rs2Player.getWorldLocation(); + return pl != null && pl.getPlane() == dest.getPlane() + && pl.distanceTo2D(dest) < maxChebyshevExclusive; + } + + /** + * Same-plane Chebyshev distance {@code <= maxInclusiveChebyshev} (e.g. adjacent transport uses {@code 0} for same tile). + */ + private static boolean isPlayerWithinChebyshevInclusive(WorldPoint dest, int maxInclusiveChebyshev) { + if (dest == null) { + return false; + } + WorldPoint pl = Rs2Player.getWorldLocation(); + return pl != null && pl.getPlane() == dest.getPlane() + && pl.distanceTo2D(dest) <= maxInclusiveChebyshev; + } + + /** + * Caps configured finish distance when the route already ends very close to the marked goal. + * Without this, a large "Finish distance" (e.g. 5) allows {@link WalkerState#ARRIVED} on the + * wrong side of a wall/door for small interiors. When {@code dLast < TIGHT_PATH_GOAL_GAP}, cap is {@code 1}; + * when {@code dLast == TIGHT_PATH_GOAL_GAP}, cap is {@code 2} (outdoor micro-walking relief at the gap radius). + */ + private static int tightFinishThreshold(WorldPoint goal, WorldPoint pathLastWalkable, int configuredChebyshev) { + int cfg = Math.max(0, configuredChebyshev); + if (goal == null || pathLastWalkable == null) { + return cfg; + } + if (goal.getPlane() != pathLastWalkable.getPlane()) { + return cfg; + } + int dLast = pathLastWalkable.distanceTo2D(goal); + if (dLast <= TIGHT_PATH_GOAL_GAP) { + if (dLast < TIGHT_PATH_GOAL_GAP) { + return Math.min(cfg, 1); + } + return Math.min(cfg, 2); + } + return cfg; + } + + /** + * After opening a door, if the walk goal is still close, scene-click a random walkable tile near the + * goal so the next movement is not an immediate minimap path segment (less robotic than + * door → minimap in the same beat). + */ + private static final int DOOR_OPEN_CANVAS_NUDGE_MAX_GOAL_DIST = 18; + private static final int DOOR_OPEN_CANVAS_NUDGE_GOAL_SAMPLE_RADIUS = 3; + private static final int DOOR_OPEN_CANVAS_NUDGE_MAX_FROM_PLAYER = 15; + + /** + * After a successful door canvas nudge, {@link #tryDirectShortWalk} is skipped briefly so the next + * movement beat is not minimap (same-frame minimap after scene click looks robotic). + */ + private static volatile long suppressTryDirectShortWalkUntilMs = 0L; + private static final long POST_DOOR_NUDGE_SUPPRESS_TRY_DIRECT_MS = 2200L; + + /** Max wait after scene canvas / recovery clicks until movement stops (avoids minimap churn while in-flight). */ + private static final int POST_SCENE_WALK_IDLE_WAIT_MS_MAX = 10_000; + + /** If phase 1 exits on arrival distance while still moving, wait briefly for idle-only (reduces tail churn). */ + private static final int POST_SCENE_WALK_IDLE_SECOND_PHASE_MS_MAX = 4_000; + + private static void waitUntilIdleAfterSceneWalk(WorldPoint cancelGoal, int timeoutMs) { + waitUntilIdleAfterSceneWalk(cancelGoal, timeoutMs, null, 0); + } + + /** + * Waits until idle, walk cancel, or player within {@code arrivalMaxChebyshev} Chebyshev steps of + * {@code arrivalGoal} (same plane; see {@link WorldPoint#distanceTo2D(WorldPoint)}) — avoids burning full + * timeout when {@code Rs2Player#isMoving()} lies during animations. Arrival uses an inclusive bound: + * {@code distanceTo2D(arrivalGoal) <= arrivalMaxChebyshev} (unlike {@link #OFFSET}-style guards that use + * {@code distanceTo2D < OFFSET}). If arrival distance triggers while still + * moving, runs a short second phase idle-only wait. Phase 2 does not run when phase 1 ends only due to the + * outer timeout while still far from {@code arrivalGoal} (by design). + */ + private static void waitUntilIdleAfterSceneWalk(WorldPoint cancelGoal, int timeoutMs, + WorldPoint arrivalGoal, int arrivalMaxChebyshev) { + assert cancelGoal != null; + assert timeoutMs > 0; + sleepUntil(() -> { + if (isWalkCancelled(cancelGoal)) { + return true; + } + WorldPoint pl = Rs2Player.getWorldLocation(); + if (arrivalGoal != null && arrivalMaxChebyshev >= 0 && pl != null + && arrivalGoal.getPlane() == pl.getPlane() + && pl.distanceTo2D(arrivalGoal) <= arrivalMaxChebyshev) { + return true; + } + return !Rs2Player.isMoving(); + }, timeoutMs); + // Sample player once after phase 1 — rare tick skew vs isMoving(); phase 2 only refines idle after arrival exit. + WorldPoint plAfter = Rs2Player.getWorldLocation(); + boolean withinArrival = arrivalGoal != null && arrivalMaxChebyshev >= 0 && plAfter != null + && arrivalGoal.getPlane() == plAfter.getPlane() + && plAfter.distanceTo2D(arrivalGoal) <= arrivalMaxChebyshev; + if (withinArrival && Rs2Player.isMoving()) { + sleepUntil(() -> isWalkCancelled(cancelGoal) || !Rs2Player.isMoving(), + POST_SCENE_WALK_IDLE_SECOND_PHASE_MS_MAX); + } + } + + /** Door / gate from main path loop vs {@link #handleNearbyRawPathSceneObjects} raw-path scan (same nudge UX). */ + private static boolean shouldCanvasNudgeAfterDoorLikeExit(String exitReason) { + if (exitReason == null) { + return false; + } + if (exitReason.startsWith("door-handled")) { + return true; + } + return "raw-path-scene-object-handled".equals(exitReason) + || "post-click-raw-path-scene-object-handled".equals(exitReason); + } + + private static void maybeCanvasNudgeAfterDoor(WorldPoint goal, int configuredDistance, List path) { + if (goal == null || path == null || path.isEmpty()) { + return; + } + WorldPoint p = Rs2Player.getWorldLocation(); + if (p == null || goal.getPlane() != p.getPlane()) { + return; + } + if (isWalkCancelled(goal)) { + return; + } + WorldPoint pathLast = path.get(path.size() - 1); + int finishTh = tightFinishThreshold(goal, pathLast, configuredDistance); + int dGoal = p.distanceTo2D(goal); + if (dGoal <= finishTh) { + return; + } + // Only nudge with fast-canvas when we are effectively on the final approach. + // This avoids immediate scene-click jumps after ordinary mid-route door opens. + if (dGoal > finishTh + FINAL_ADJACENT_CANVAS_NUDGE_CHEBYSHEV) { + return; + } + if (dGoal > DOOR_OPEN_CANVAS_NUDGE_MAX_GOAL_DIST) { + return; + } + LocalPoint goalLocal = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), goal); + if (goalLocal == null || !Rs2Camera.isTileOnScreen(goalLocal)) { + return; + } + Map around = Rs2Tile.getReachableTilesFromTile(goal, DOOR_OPEN_CANVAS_NUDGE_GOAL_SAMPLE_RADIUS); + if (around == null || around.isEmpty()) { + return; + } + List candidates = new ArrayList<>(); + for (WorldPoint t : around.keySet()) { + if (t == null || !Rs2Tile.isTileReachable(t)) { + continue; + } + if (p.distanceTo2D(t) > DOOR_OPEN_CANVAS_NUDGE_MAX_FROM_PLAYER) { + continue; + } + candidates.add(t); + } + if (candidates.isEmpty()) { + return; + } + // candidates non-empty: index range [0, size-1] is valid for betweenInclusive. + WorldPoint pick = candidates.get(Rs2Random.betweenInclusive(0, candidates.size() - 1)); + if (walkFastCanvas(pick)) { + log.debug("[Walker] door nudge: canvas -> {} (goal={} dGoal={})", pick, goal, dGoal); + waitUntilIdleAfterSceneWalk(goal, POST_SCENE_WALK_IDLE_WAIT_MS_MAX, goal, finishTh); + lastMovedTimeMs = System.currentTimeMillis(); + stuckCount = 0; + } + } + + private static void traceProcessWalkExit(String reason, WorldPoint target, int processWalkTail) { + WorldPoint activeTarget = currentTarget; + WebWalkLog.exitDetailDebug( + "trace={} target={} currentTarget={} interim={} stuck={} tailIdx={}/{} intr={} player={}", + reason, + target, + activeTarget, + interimTargetWp, + stuckCount, + processWalkTail, + MAX_PROCESS_WALK_TAIL_ITERATIONS, + Thread.currentThread().isInterrupted(), + Rs2Player.getWorldLocation()); + boolean nullCurrent = activeTarget == null; + boolean mismatch = target != null && activeTarget != null && !target.equals(activeTarget); + WebWalkLog.exitWarn( + reason, + nullCurrent, + mismatch, + Thread.currentThread().isInterrupted(), + target, + activeTarget, + processWalkTail, + MAX_PROCESS_WALK_TAIL_ITERATIONS, + Rs2Player.getWorldLocation()); + } + + private static boolean walkCancelledDiag(WorldPoint target, String where, int processWalkTail) { + if (!isWalkCancelled(target)) { + return false; + } + traceProcessWalkExit("cancel:" + where, target, processWalkTail); + return true; + } + + /** + * Clears walker goal and ShortestPath artifacts. Prefer over {@code setTarget(null)} so logs show why. + */ + public static void clearWalkingRoute(String reason) { + setTarget(null, reason != null && !reason.isBlank() ? reason : "unspecified"); + } + + @Getter + private static volatile String lastRouteClearReason = ""; + + @Getter + private static volatile long lastRouteClearAtMs = 0L; + + private static void logRouteClear(String reason) { + lastRouteClearReason = reason == null ? "" : reason; + lastRouteClearAtMs = System.currentTimeMillis(); + if (reason == null || reason.isBlank()) { + WebWalkLog.routeClearMissingReason(Thread.currentThread().getName()); + } else { + WebWalkLog.routeClear(reason); + } + } + + /** Substrings for game-object names treated like doors (pathing heuristics). */ + private static final String[] DOOR_LIKE_NAME_FRAGMENTS = { + "door", "gate", "barrier", "stile", "portcullis", "archway", "cattlegate", "fence" + }; + + /** {@code fence} must be whole-word — substring matches {@code defence} ("fence" inside) otherwise. */ + private static final Pattern FENCE_AS_WORD = Pattern.compile("\\bfence\\b", Pattern.CASE_INSENSITIVE); + + /** Lower index = higher priority when multiple actions match (prefix, ASCII lower). */ + private static final List DOOR_ACTION_PRIORITY = List.of( + "pay-toll", "pick-lock", "walk-through", "go-through", "open", "pass", "enter", + "push", "climb-over", "climb-through", "squeeze-through", "cross", "force", "exit" + ); + + /** Max age for {@link Rs2LeaguesTransport#isLeaguesAreaTeleportPending(long)} in stall / stuck gates. */ + private static final long LEAGUES_AREA_PENDING_STALL_MAX_AGE_MS = 60_000L; + @Named("disableWalkerUpdate") static boolean disableWalkerUpdate; @@ -97,10 +655,59 @@ public class Rs2Walker { // stuckCount / lastPosition / lastMovedTimeMs / currentTarget / nextWalkingDistance. // Reentrant: same-thread dispatch (walkWithState -> walkWithBankedTransportsAndState // -> walkWithStateInternal -> recursive processWalk) reacquires freely. - // setTarget() / recalculatePath() stay unlocked — they are the cross-thread cancel - // path and the volatile currentTarget read inside the walker loop picks up nulls. + // setTarget() stays unlocked — cross-thread cancel; volatile currentTarget read in the loop + // can still see null only when setTarget(null) is intended. recalculatePath no longer nulls + // currentTarget between restarts (avoids false cancel during sleepUntil). private static final ReentrantLock walkerLock = new ReentrantLock(); + /** + * First-seen dedupe keys when both seasonal handlers decline (debug-only): packed destination hex (or {@code nodest}), + * then truncated {@code displayInfo} plus {@code |h} + hex {@link String#hashCode()} so long-prefix collisions split by dest. + * At most {@link #SEASONAL_HANDLER_MISS_LOG_CAP} distinct keys ever log — then new misses are silent until JVM restart. + */ + private static final Set SEASONAL_HANDLER_MISS_LOGGED = ConcurrentHashMap.newKeySet(); + private static final AtomicInteger SEASONAL_HANDLER_MISS_LOGGED_COUNT = new AtomicInteger(0); + private static final int SEASONAL_HANDLER_MISS_LOG_CAP = 128; + /** + * One-shot DEBUG when {@link WorldMapPointManager} is null during route clear (shutdown race). + * Later races same JVM stay silent — intentional noise cap. + */ + private static final AtomicBoolean WORLD_MAP_REMOVE_NULL_LOGGED = new AtomicBoolean(); + + /** Same package (e.g. unit tests) only — not part of script API. Resets seasonal miss dedupe + world-map remove-null log token. */ + static void clearWalkerDedupeForTesting() + { + SEASONAL_HANDLER_MISS_LOGGED.clear(); + SEASONAL_HANDLER_MISS_LOGGED_COUNT.set(0); + WORLD_MAP_REMOVE_NULL_LOGGED.set(false); + recentCurrentTileTransportByEdge.clear(); + } + + private static volatile List seasonalTransportHandlers = + SeasonalTransportHandlers.defaultHandlerList(); + + /** + * Replaces the seasonal transport handler chain. Non-null, non-empty list; pass + * {@link SeasonalTransportHandlers#defaultHandlerList()} to restore built-ins. + * {@link net.runelite.client.plugins.microbot.MicrobotPlugin#startUp} resets defaults each session. + */ + public static void setSeasonalTransportHandlers(List handlers) + { + if (handlers == null || handlers.isEmpty()) + { + seasonalTransportHandlers = SeasonalTransportHandlers.defaultHandlerList(); + } + else + { + seasonalTransportHandlers = List.copyOf(handlers); + } + } + + public static List getSeasonalTransportHandlers() + { + return seasonalTransportHandlers; + } + /** * Externally observable counters for walker health checks. The benchmark probe * (or any diagnostic script) reads these to decide whether a walk completed @@ -111,9 +718,51 @@ public static final class Telemetry { public static final AtomicInteger stallRecalcCount = new AtomicInteger(); public static final AtomicInteger partialRetryCount = new AtomicInteger(); public static final AtomicInteger unreachableCount = new AtomicInteger(); + /** Locked-region chat attributed to a recent transport attempt and blacklisted. */ + public static final AtomicInteger leaguesLockAttributedCount = new AtomicInteger(); + /** Locked-region chat with no matching recent attempt or expired attempt snapshot. */ + public static final AtomicInteger leaguesLockStaleCount = new AtomicInteger(); + /** Locked-region chat where region text did not map to {@link LeaguesRegion} (dest-only blacklist path). */ + public static final AtomicInteger leaguesLockParseMissCount = new AtomicInteger(); + /** Neither Leagues Area nor MoA handler accepted a seasonal transport row. */ + public static final AtomicInteger seasonalHandlerMissCount = new AtomicInteger(); public static final AtomicLong lastEventAtMs = new AtomicLong(); public static volatile String lastReason = ""; + private static final ConcurrentHashMap doorRejectByCause = new ConcurrentHashMap<>(); + private static final AtomicInteger doorRejectSummaryLogSeq = new AtomicInteger(0); + private static final int DOOR_REJECT_SUMMARY_LOG_INTERVAL = 40; + + /** + * Rate-limited debug summary of {@link #doorRejectByCause} tallies (noise control on tight door clusters). + */ + public static void recordDoorReject(String cause) { + if (cause == null || cause.isEmpty()) { + cause = "unknown"; + } + doorRejectByCause.computeIfAbsent(cause, k -> new AtomicInteger()).incrementAndGet(); + if (Rs2LogRateLimit.everyN(doorRejectSummaryLogSeq, DOOR_REJECT_SUMMARY_LOG_INTERVAL) + && log.isDebugEnabled()) { + log.debug("[WalkerTelemetry] DOOR_REJECT summary={}", doorRejectByCause); + } + } + + public static void incrementLeaguesLockAttributed() { + leaguesLockAttributedCount.incrementAndGet(); + } + + public static void incrementLeaguesLockStale() { + leaguesLockStaleCount.incrementAndGet(); + } + + public static void incrementLeaguesLockParseMiss() { + leaguesLockParseMissCount.incrementAndGet(); + } + + public static void incrementSeasonalHandlerMiss() { + seasonalHandlerMissCount.incrementAndGet(); + } + public static void recordOffPathRecalc(WorldPoint playerPos, int pathSize) { offPathRecalcCount.incrementAndGet(); lastReason = "off-path"; @@ -156,6 +805,12 @@ public static void reset() { stallRecalcCount.set(0); partialRetryCount.set(0); unreachableCount.set(0); + leaguesLockAttributedCount.set(0); + leaguesLockStaleCount.set(0); + leaguesLockParseMissCount.set(0); + seasonalHandlerMissCount.set(0); + doorRejectByCause.clear(); + doorRejectSummaryLogSeq.set(0); lastEventAtMs.set(0); lastReason = ""; log.info("[WalkerTelemetry] counters reset"); @@ -176,22 +831,73 @@ public static boolean walkTo(int x, int y, int plane) { return walkTo(x, y, plane, config.reachedDistance()); } + /** + * @see #walkTo(WorldPoint) + */ public static boolean walkTo(int x, int y, int plane, int distance) { return walkWithState(new WorldPoint(x, y, plane), distance) == WalkerState.ARRIVED; } + /** + * {@code null} {@code target} is rejected by {@link #walkWithState(WorldPoint, int)} ({@link WalkerState#EXIT}); + * result is {@code false}, same as any non-arrival outcome. + */ public static boolean walkTo(WorldPoint target) { return walkWithState(target, config.reachedDistance()) == WalkerState.ARRIVED; } + /** + * @see #walkTo(WorldPoint) + *

{@code null} {@code target}: {@link #walkWithState(WorldPoint, int)} returns {@link WalkerState#EXIT}; this method returns {@code false}. + */ public static boolean walkTo(WorldPoint target, int distance) { return walkWithState(target, distance) == WalkerState.ARRIVED; } + + /** + * Runs {@code action} while temporarily releasing {@link #walkerLock} for the current thread. + * Used by long-running Leagues teleport wait so a second {@link #walkWithState} can proceed instead of blocking + * on {@link java.util.concurrent.locks.ReentrantLock#lockInterruptibly()} for the full teleport timeout. + *

No-op release path when the current thread does not hold the lock (e.g. calibration daemon). + */ + public static void runWithWalkerLockReleased(Runnable action) + { + if (action == null) + { + throw new NullPointerException("action"); + } + if (!walkerLock.isHeldByCurrentThread()) + { + action.run(); + return; + } + int depth = walkerLock.getHoldCount(); + for (int i = 0; i < depth; i++) + { + walkerLock.unlock(); + } + try + { + action.run(); + } + finally + { + for (int i = 0; i < depth; i++) + { + walkerLock.lock(); + } + } + } + public static WalkerState walkWithState(WorldPoint target, int distance) { if (config == null) { return WalkerState.EXIT; } + if (target == null) { + log.warn("[Walker] walk rejected: null target"); + return WalkerState.EXIT; + } if (!walkerLock.tryLock()) { log.warn("[Walker] concurrent walk request detected, waiting for in-flight walk (held by {}); new target={}", Thread.currentThread().getName(), target); @@ -212,18 +918,83 @@ public static WalkerState walkWithState(WorldPoint target, int distance) { walkerLock.unlock(); } } + + /** + * Like {@link #walkWithState} but bounds how long this thread waits for {@link #walkerLock}. + * Use when another walk may hold the lock during Leagues UI (see {@link Rs2LeaguesTransport#leaguesTeleport}) + * or when a bounded wait is preferable to {@link java.util.concurrent.locks.ReentrantLock#lockInterruptibly()}. + * + * @param lockWaitMs max wait for the lock; {@code 0} = {@link ReentrantLock#tryLock()} only (no blocking) + * @return {@link WalkerState#EXIT} if the lock is not acquired in time or the thread is interrupted + */ + public static WalkerState walkWithStateTry(WorldPoint target, int distance, long lockWaitMs) + { + if (config == null) + { + return WalkerState.EXIT; + } + if (target == null) + { + log.warn("[Walker] walk rejected: null target"); + return WalkerState.EXIT; + } + if (lockWaitMs < 0) + { + throw new IllegalArgumentException("lockWaitMs must be >= 0"); + } + boolean locked; + try + { + if (lockWaitMs == 0) + { + locked = walkerLock.tryLock(); + } + else + { + locked = walkerLock.tryLock(lockWaitMs, TimeUnit.MILLISECONDS); + } + } + catch (InterruptedException ie) + { + Thread.currentThread().interrupt(); + return WalkerState.EXIT; + } + if (!locked) + { + log.warn("[Walker] walkWithStateTry: walkerLock not acquired within {}ms (thread={}) target={}", + lockWaitMs, Thread.currentThread().getName(), target); + return WalkerState.EXIT; + } + try + { + if (config.walkWithBankedTransports()) + { + return walkWithBankedTransportsAndStateLocked(target, distance, false); + } + return walkWithStateInternal(target, distance); + } + finally + { + walkerLock.unlock(); + } + } /** * Replaces the walkTo method * - * @param target + * @param target goal tile — non-null enforced at entry ({@code Objects.requireNonNull}); {@link #walkWithState} exits on null before delegating (same intent as {@link #walkWithStateTry}). * @param distance * @return */ private static WalkerState walkWithStateInternal(WorldPoint target, int distance) { - int distToTarget = Rs2Player.getWorldLocation().distanceTo(target); + Objects.requireNonNull(target, "walk target"); + WorldPoint playerLocWalk = Rs2Player.getWorldLocation(); + if (playerLocWalk == null) { + return WalkerState.MOVING; + } + int distToTarget = playerLocWalk.distanceTo(target); LocalPoint localTarget = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), target); boolean walkableCheck = Rs2Tile.isWalkable(localTarget); - boolean reachableTileCheck = distToTarget <= distance && Rs2Tile.getReachableTilesFromTile(Rs2Player.getWorldLocation(), distance).containsKey(target); + boolean reachableTileCheck = distToTarget <= distance && Rs2Tile.getReachableTilesFromTile(playerLocWalk, distance).containsKey(target); if (reachableTileCheck || (!walkableCheck && distToTarget <= distance)) { return WalkerState.ARRIVED; @@ -244,6 +1015,13 @@ private static WalkerState walkWithStateInternal(WorldPoint target, int distance ShortestPathPlugin.setReachedDistance(distance); stuckCount = 0; lastMovedTimeMs = System.currentTimeMillis(); + interimTargetWp = null; + interimTargetIdx = -1; + interimSetAtMs = 0L; + interimLastProgressAtMs = 0L; + interimLastBestPathIdx = -1; + interimLastRetargetAtMs = 0L; + lastPartialTransRecalcMs = 0L; if (Microbot.getClient().isClientThread()) { log.warn("Please do not call the walker from the main thread"); @@ -251,6 +1029,7 @@ private static WalkerState walkWithStateInternal(WorldPoint target, int distance } closeWorldMap(); + markWalkSessionStart(target); return processWalk(target, distance); } @@ -277,37 +1056,70 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part if (debug) { return WalkerState.EXIT; } + int partialRetriesWorking = partialRetries; + WorldPoint lastAttemptedMinimapClick = null; + boolean lastAttemptedMinimapClickOk = false; + long lastAttemptedMinimapClickAtMs = 0L; + long pathfinderPendingSinceMs = 0L; + for (int processWalkTail = 0; processWalkTail < MAX_PROCESS_WALK_TAIL_ITERATIONS; processWalkTail++) { try { + walkerDiag("tail iteration begin idx=%d/%d target=%s current=%s interim=%s partialRetries=%d", + processWalkTail, + MAX_PROCESS_WALK_TAIL_ITERATIONS, + target, + currentTarget, + interimTargetWp, + partialRetriesWorking); if (!Microbot.isLoggedIn()) { - setTarget(null); + traceProcessWalkExit("not-logged-in", target, processWalkTail); + setTarget(null, "rs2walker:processWalk:not-logged-in"); return WalkerState.EXIT; } - if (isWalkCancelled(target)) { + if (walkCancelledDiag(target, "processWalk:entry", processWalkTail)) { return WalkerState.EXIT; } Pathfinder pathfinder = ShortestPathPlugin.getPathfinder(); if (pathfinder == null) { + markStartupPhase("pf_wait_enter", target, "reason=pathfinder_null"); + walkerDiag("pathfinder null; waiting up to 2000ms"); pathfinder = sleepUntilNotNull(ShortestPathPlugin::getPathfinder, 2_000); - if (isWalkCancelled(target)) { + if (walkCancelledDiag(target, "processWalk:after-wait-pathfinder", processWalkTail)) { return WalkerState.EXIT; } if (pathfinder == null) { - setTarget(null); + traceProcessWalkExit("pathfinder-still-null", target, processWalkTail); + setTarget(null, "rs2walker:processWalk:pathfinder-still-null"); return WalkerState.EXIT; } + markStartupPhase("pf_ready", target, "source=pathfinder_not_null"); } if (!pathfinder.isDone()) { - boolean isDone = sleepUntilTrue(pathfinder::isDone, 100, 10_000); - if (isWalkCancelled(target)) { + markStartupPhase("pf_wait_retry", target, "slice=" + PATHFINDER_DONE_POLL_WAIT_MS); + if (pathfinderPendingSinceMs == 0L) { + pathfinderPendingSinceMs = System.currentTimeMillis(); + } + walkerDiag("pathfinder not done; short-poll max %dms", PATHFINDER_DONE_POLL_WAIT_MS); + boolean isDone = Rs2WalkerRuntimeAwaits.awaitPathfinderDone(pathfinder, PATHFINDER_DONE_POLL_WAIT_MS); + if (walkCancelledDiag(target, "processWalk:after-wait-done", processWalkTail)) { return WalkerState.EXIT; } if (!isDone) { - setTarget(null); - return WalkerState.EXIT; + if (System.currentTimeMillis() - pathfinderPendingSinceMs > 10_000L) { + traceProcessWalkExit("pathfinder-timeout-not-done", target, processWalkTail); + setTarget(null, "rs2walker:processWalk:pathfinder-timeout-not-done"); + return WalkerState.EXIT; + } + // Non-blocking startup: keep polling in short slices so first click can happen + // as soon as pathfinder finishes, instead of one long 10s stall. + processWalkTail--; + sleep(Rs2Random.between(PATHFINDER_DONE_RETRY_SLEEP_MIN_MS, PATHFINDER_DONE_RETRY_SLEEP_MAX_MS)); + continue; } + markStartupPhase("pf_ready", target, "source=pathfinder_done"); } + pathfinderPendingSinceMs = 0L; if (ShortestPathPlugin.getMarker() == null) { restoreTargetMarker(target); @@ -315,6 +1127,9 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part final List rawPath = pathfinder.getPath(); final List path = pathfinder.getWalkablePath(); + int rawSize = rawPath == null ? -1 : rawPath.size(); + int walkSize = path == null ? -1 : path.size(); + markStartupPhase("path_snapshot", target, "raw=" + rawSize + " walk=" + walkSize); final WorldPoint dst; if (path == null || path.isEmpty()) { dst = Rs2Player.getWorldLocation(); @@ -325,13 +1140,12 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part boolean partialPath = false; if (dst == null || dst.distanceTo(target) > distance) { if (path != null && path.size() > 1) { - log.info("[Walker] Path endpoint {} is {} tiles from target {}, walking partial path ({} tiles)", - dst, dst.distanceTo(target), target, path.size()); + WebWalkLog.partialSegment(dst, dst.distanceTo(target), target, path.size()); partialPath = true; } else { Telemetry.recordUnreachable("no-walkable-path", Rs2Player.getWorldLocation(), target, dst, path == null ? 0 : path.size(), distance, pathfinder); - setTarget(null); + setTarget(null, "rs2walker:processWalk:no-walkable-path"); return WalkerState.UNREACHABLE; } } @@ -340,25 +1154,78 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part return WalkerState.ARRIVED; } - if (isNear(dst)) { - setTarget(null); + // Partial segment: before standing on the segment endpoint, refresh routing from current + // position so the continuation is ready (smooth handoff vs dead stop at segment end). + if (partialPath) { + WorldPoint playerPt = Rs2Player.getWorldLocation(); + if (playerPt != null && dst != null) { + int distToDstSeg = playerPt.distanceTo2D(dst); + int distToGoal = playerPt.distanceTo2D(target); + int closestEarly = getClosestTileIndex(path); + int remainingSteps = closestEarly >= 0 ? (path.size() - 1 - closestEarly) : Integer.MAX_VALUE; + final int nearSegmentEndTiles = 12; + final int nearSegmentEndSteps = 10; + boolean approachingSegmentEnd = distToDstSeg <= nearSegmentEndTiles + || (remainingSteps != Integer.MAX_VALUE && remainingSteps <= nearSegmentEndSteps); + if (approachingSegmentEnd && distToGoal > distance) { + long now = System.currentTimeMillis(); + if (now - lastPartialTransRecalcMs >= PARTIAL_TRANS_RECAL_COOLDOWN_MS) { + lastPartialTransRecalcMs = now; + WebWalkLog.partialRecalc( + remainingSteps == Integer.MAX_VALUE ? -1 : remainingSteps, + distToDstSeg, + distToGoal, + dst, + target); + recalculatePath(); + continue; + } + } + } + } + + // Do not clear walk target while a sticky minimap interim is active — breaks + // isWalkCancelled and forces EXIT while the flag is still carrying the player. + // Partial paths end at an intermediate waypoint (dst still far from {@code target}); + // clearing here would drop currentTarget before the partial-path retry/recalc branch. + if (!partialPath && isNear(dst) && interimTargetWp == null) { + setTarget(null, "rs2walker:processWalk:reached-path-endpoint"); + } + + long nowTickGraceMs = System.currentTimeMillis(); + if (lastAttemptedMinimapClickOk && lastAttemptedMinimapClickAtMs > 0L + && nowTickGraceMs - lastAttemptedMinimapClickAtMs < MINIMAP_CLICK_STALL_GRACE_MS) { + lastMovedTimeMs = nowTickGraceMs; } checkIfStuck(); - if (isWalkCancelled(target)) { + if (walkCancelledDiag(target, "processWalk:after-stuck-check", processWalkTail)) { return WalkerState.EXIT; } if (isStuckTooLong()) { + // Leagues area teleports can have long animations. Never trigger stall-recalc + // while the transport is in-flight, or we will interrupt and re-click. + if (Rs2LeaguesTransport.isTeleportInProgress() + || Rs2LeaguesTransport.isLeaguesAreaTeleportPending(LEAGUES_AREA_PENDING_STALL_MAX_AGE_MS)) + { + return WalkerState.MOVING; + } long sinceMoved = System.currentTimeMillis() - lastMovedTimeMs; long threshold = stallThresholdMs(); Telemetry.recordStallRecalc(sinceMoved, Rs2Player.getWorldLocation()); - log.info("[Walker] Stall recalc: sinceMoved={}ms threshold={}ms (inCombat={} animating={} interacting={})", - sinceMoved, threshold, + WebWalkLog.stallRecalc(sinceMoved, threshold, Rs2Player.isInCombat(), Rs2Player.isAnimating(), Rs2Player.isInteracting()); + if (lastAttemptedMinimapClick != null) { + WebWalkLog.stallContextDebug( + lastAttemptedMinimapClick, + lastAttemptedMinimapClickOk, + Math.max(0L, System.currentTimeMillis() - lastAttemptedMinimapClickAtMs), + interimTargetWp); + } lastMovedTimeMs = System.currentTimeMillis(); stuckCount = 0; setTarget(target); - return processWalk(target, distance, partialRetries); + continue; } if (stuckCount > 10) { var reachable = Rs2Tile.getReachableTilesFromTile(Rs2Player.getWorldLocation(), 5).keySet(); @@ -377,15 +1244,30 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part int indexOfStartPoint = getClosestTileIndex(path); if (indexOfStartPoint == -1) { - setTarget(null); + walkerDiag("getClosestTileIndex=-1 pathSize=%d player=%s pathFirst=%s pathLast=%s", + path.size(), + Rs2Player.getWorldLocation(), + path.isEmpty() ? null : path.get(0), + path.isEmpty() ? null : path.get(path.size() - 1)); + traceProcessWalkExit("closest-index-none", target, processWalkTail); + setTarget(null, "rs2walker:processWalk:closest-index-none"); return WalkerState.EXIT; } + primeExpectedTransportDestinations(path, indexOfStartPoint); lastPosition = Rs2Player.getWorldLocation(); - - if (Rs2Player.getWorldLocation().distanceTo(target) == 0 || path.size() <= 1) { - setTarget(null); - return WalkerState.ARRIVED; + WorldPoint plImmediate = lastPosition; + + WorldPoint pathLastForImmediate = path.isEmpty() ? null : path.get(path.size() - 1); + int immediateFinishTh = tightFinishThreshold(target, pathLastForImmediate, distance); + // Exact tile, or degenerate path (≤1 tile) within `immediateFinishTh` (from `tightFinishThreshold`, same as downstream finish). + if (plImmediate != null && plImmediate.getPlane() == target.getPlane()) { + // WorldPoint#distanceTo2D is Chebyshev (max |dx|,|dy|) — same metric as isPlayerWithinChebyshevInclusive. + int d2dToGoal = plImmediate.distanceTo2D(target); + if (d2dToGoal <= 0 || (path.size() <= 1 && d2dToGoal <= immediateFinishTh)) { + setTarget(null, "rs2walker:processWalk:arrived-immediate"); + return WalkerState.ARRIVED; + } } manageRunEnergy(path.size()); @@ -430,15 +1312,44 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part boolean inInstance = Microbot.getClient().getTopLevelWorldView().isInstance(); String exitReason = "end-of-path"; final int HANDLER_RANGE = 13; - - if (rawPath != null && path != null - && handleNearbyRawPathSceneObjects(rawPath, HANDLER_RANGE)) { + Map doorEdgesAttemptedThisTail = new HashMap<>(); + ObstaclePolicy startupPolicy = obstaclePolicyForCurrentPhase(); + + boolean postTransportWindow = lastTransportHandledAtMs > 0 + && System.currentTimeMillis() - lastTransportHandledAtMs <= POST_TRANSPORT_PATH_TMARK_WINDOW_MS; + boolean allowRawSceneScan = startupPolicy.allowBroadRawHandlers() && rawPath != null && path != null; + int rawScanTransportLookaheadStartIdx = postTransportWindow + ? Math.min(path.size() - 1, Math.max(0, indexOfStartPoint + 1)) + : indexOfStartPoint; + if (allowRawSceneScan && isTransportInteractionSettling()) { + allowRawSceneScan = false; + tmarkPostTransport("post_transport_raw_scene_scan_skip", target, + "reason=transport_settling"); + } + if (allowRawSceneScan && postTransportWindow + && !hasUpcomingNearbyTransportStep(path, rawScanTransportLookaheadStartIdx, Rs2Player.getWorldLocation(), + POST_TRANSPORT_RAW_SCAN_TRANSPORT_LOOKAHEAD_EDGES, POST_TRANSPORT_RAW_SCAN_TRANSPORT_MAX_DIST)) { + allowRawSceneScan = false; + tmarkPostTransport("post_transport_raw_scene_scan_skip", target, + "reason=no_nearby_planned_transport"); + } + long rawSceneStartAt = System.currentTimeMillis(); + boolean rawSceneHandled = allowRawSceneScan + && handleNearbyRawPathSceneObjects(rawPath, HANDLER_RANGE, target); + tmarkPostTransport("post_transport_raw_scene_scan", target, + "handled=" + rawSceneHandled + " ms=" + (System.currentTimeMillis() - rawSceneStartAt)); + if (rawSceneHandled) { doorOrTransportResult = true; exitReason = "raw-path-scene-object-handled"; } - if (!doorOrTransportResult - && handleCurrentTileTransportTowardPath(rawPath, path, target)) { + long currentTileTransportStartAt = System.currentTimeMillis(); + boolean currentTileTransportHandled = !doorOrTransportResult + && startupPolicy.allowBroadRawHandlers() + && handleCurrentTileTransportTowardPath(rawPath, path, target); + tmarkPostTransport("post_transport_current_tile_transport", target, + "handled=" + currentTileTransportHandled + " ms=" + (System.currentTimeMillis() - currentTileTransportStartAt)); + if (currentTileTransportHandled) { doorOrTransportResult = true; exitReason = "current-tile-transport-handled"; } @@ -452,48 +1363,119 @@ && handleCurrentTileTransportTowardPath(rawPath, path, target)) { for (int i = indexOfStartPoint; !doorOrTransportResult && i < path.size(); i++) { WorldPoint currentWorldPoint = path.get(i); - if (isWalkCancelled(target)) { + if (walkCancelledDiag(target, "processWalk:path-loop", processWalkTail)) { return WalkerState.EXIT; } if (ShortestPathPlugin.getMarker() == null) { restoreTargetMarker(target); } - - if (!isNearPath()) { + // Marker is a UI/overlay artifact (ShortestPath plugin). Walking must not depend + // on its presence; scripts can clear it mid-walk. + ObstaclePolicy obstaclePolicy = obstaclePolicyForCurrentPhase(); + + boolean recentTransportWindow = lastTransportHandledAtMs > 0 + && System.currentTimeMillis() - lastTransportHandledAtMs <= POST_TRANSPORT_PATH_TMARK_WINDOW_MS; + WorldPoint playerForPathCheck = Rs2Player.getWorldLocation(); + if (isTransportInteractionSettling()) { + tmarkPostTransport("post_transport_settling_yield", target, + "at=" + compactWorldPoint(playerForPathCheck)); + exitReason = "transport-settling-yield"; + break; + } + boolean nearPath = isNearPath(); + boolean nearPathByVariance = !nearPath && isNearPathByVariance(path, playerForPathCheck); + if (recentTransportWindow && !nearPath) { + WebWalkLog.tmark("post_transport_nearpath_gate", + System.currentTimeMillis() - lastTransportHandledAtMs, + target, + playerForPathCheck, + "nearPath=false variance=" + nearPathByVariance); + } + if (!nearPath && !recentTransportWindow && !nearPathByVariance) { // Avoid mid-walk recalculation while the player is still moving. recalculatePath() // cancels the pathfinder and waits up to 10s for a new one — a visible stall. // isStuckTooLong() will trigger a real recalculation if progress actually halts. boolean movingOrRecentlyMoved = Rs2Player.isMoving() || (lastMovedTimeMs > 0 && System.currentTimeMillis() - lastMovedTimeMs < 2000); if (movingOrRecentlyMoved) { + if (lastTransportHandledAtMs > 0 + && System.currentTimeMillis() - lastTransportHandledAtMs <= POST_TRANSPORT_PATH_TMARK_WINDOW_MS) { + WebWalkLog.tmark("post_transport_offpath_moving_yield", + System.currentTimeMillis() - lastTransportHandledAtMs, + target, + playerForPathCheck, + "movingRecent=true"); + } exitReason = "off-path-but-moving"; break; } Telemetry.recordOffPathRecalc(Rs2Player.getWorldLocation(), path.size()); - log.info("[Walker] No longer near path, recalculating"); + WebWalkLog.recalc("no_longer_near_path"); if (config.cancelInstead()) { - setTarget(null); + setTarget(null, "rs2walker:processWalk:off-path-cancel-instead"); } else { recalculatePath(); } exitReason = "not-near-path"; break; } + if (!nearPath && recentTransportWindow) { + walkerDiag("post-transport near-path bypass at=%s target=%s", playerForPathCheck, target); + } else if (nearPathByVariance) { + walkerDiag("near-path variance bypass at=%s target=%s tolerance=%d", + playerForPathCheck, target, PATH_VARIANCE_TOLERANCE_CHEBYSHEV); + } // Gate scene-object handlers to segments near the player. Doors/rockfalls/transports // can only be interacted with when the object is in the loaded scene (near the player), // and these calls do scene-object scans that add up across 100+ segment paths. - int segDistance = currentWorldPoint.distanceTo2D(Rs2Player.getWorldLocation()); + WorldPoint playerNearSeg = Rs2Player.getWorldLocation(); + if (playerNearSeg == null) { + exitReason = "player-location-null"; + break; + } + int segDistance = currentWorldPoint.distanceTo2D(playerNearSeg); if (segDistance <= HANDLER_RANGE) { - doorOrTransportResult = handleDoors(path, i); + boolean skipPostTransportSegmentHandlers = recentTransportWindow + && !hasUpcomingNearbyTransportStep(path, i, playerNearSeg, + POST_TRANSPORT_RAW_SCAN_TRANSPORT_LOOKAHEAD_EDGES, + POST_TRANSPORT_RAW_SCAN_TRANSPORT_MAX_DIST) + && !hasRecentDoorAttemptNearIndex(path, i) + && !isDoorInteractionSettling() + && !isRecoveryMovementInFlight(); + if (skipPostTransportSegmentHandlers) { + tmarkPostTransport("post_transport_segment_handler_skip", + target, + "i=" + i + " reason=no_nearby_planned_transport"); + } else { + long segmentHandlerStartAt = System.currentTimeMillis(); + if (!isDoorInteractionSettling() && !isRecoveryMovementInFlight()) { + doorOrTransportResult = handleDoorsWithTimeout(path, i, + obstaclePolicy.segmentDoorTimeoutMs(), doorEdgesAttemptedThisTail); + } if (doorOrTransportResult) { + tmarkPostTransport("post_transport_segment_handler", target, + "stage=door handled=true i=" + i + " ms=" + (System.currentTimeMillis() - segmentHandlerStartAt)); exitReason = "door-handled"; break; } + // Chain step 2: path-adjacent probes after exact segment-door attempt. + if (!Rs2Player.isMoving() && obstaclePolicy.allowPathAdjacentProbe()) { + if (tryHandleBlockingPathObjectsWithTimeout(path, i, 5, 10, + obstaclePolicy.pathAdjacentProbeTimeoutMs(), doorEdgesAttemptedThisTail)) { + tmarkPostTransport("post_transport_segment_handler", target, + "stage=path_adj handled=true i=" + i + " ms=" + (System.currentTimeMillis() - segmentHandlerStartAt)); + exitReason = "path-blocker-handled"; + break; + } + } + doorOrTransportResult = handleRockfall(path, i); if (doorOrTransportResult) { + tmarkPostTransport("post_transport_segment_handler", target, + "stage=rockfall handled=true i=" + i + " ms=" + (System.currentTimeMillis() - segmentHandlerStartAt)); exitReason = "rockfall-handled"; break; } @@ -503,23 +1485,215 @@ && handleCurrentTileTransportTowardPath(rawPath, path, target)) { } if (doorOrTransportResult) { + tmarkPostTransport("post_transport_segment_handler", target, + "stage=transport handled=true i=" + i + " ms=" + (System.currentTimeMillis() - segmentHandlerStartAt)); exitReason = "transport-handled"; break; } + tmarkPostTransport("post_transport_segment_handler", target, + "stage=none handled=false i=" + i + " ms=" + (System.currentTimeMillis() - segmentHandlerStartAt)); + } } - boolean tileWalkable = inInstance || isKnownWalkableOrUnloaded(currentWorldPoint); - if (!tileWalkable) { + boolean tileReachable = Rs2Tile.isTileReachable(currentWorldPoint); + if (!tileReachable && !inInstance) { + // Common stall case: path steps beyond a closed door are unreachable, so the + // loop would otherwise "continue" without ever issuing a minimap click and + // without triggering door logic (if the unreachable tile is further than the + // handler range). Treat the first unreachable tile as a blocker signal: + // try door handling on the edge that leads into it, then break so the outer + // loop can re-evaluate. + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (playerLoc != null) { + int unreachableDist = currentWorldPoint.distanceTo2D(playerLoc); + if (unreachableDist <= HANDLER_RANGE + 2) { + log.info("[Walker] unreachable path tile near player: tile={} idx={}/{} player={} target={}", + currentWorldPoint, i, path.size(), playerLoc, target); + + int edgeIdx = Math.max(indexOfStartPoint, i - 1); + WorldPoint edgeFrom = edgeIdx >= 0 && edgeIdx < path.size() ? path.get(edgeIdx) : null; + WorldPoint edgeTo = edgeIdx + 1 >= 0 && edgeIdx + 1 < path.size() ? path.get(edgeIdx + 1) : null; + if (hasRecentDoorAttemptOnEdge(edgeFrom, edgeTo)) { + boolean resolvedAfterWait = waitForDoorEdgeResolution(edgeFrom, edgeTo, + obstaclePolicy.edgeResolutionWaitTimeoutMs()); + if (resolvedAfterWait && tryPostDoorFastMinimapClick(path, edgeIdx, playerLoc, target)) { + exitReason = "door-edge-resolved-fast-click"; + } else { + exitReason = resolvedAfterWait ? "door-edge-resolved-after-wait" : "door-edge-waiting-retry"; + } + break; + } + if (hasRecentDoorAttemptNearIndex(path, edgeIdx)) { + boolean resolvedAfterNearbyWait = waitForRecentDoorEdgeResolutionNearIndex(path, edgeIdx, + obstaclePolicy.edgeResolutionWaitTimeoutMs()); + WorldPoint afterNearbyWait = Rs2Player.getWorldLocation(); + boolean progressedAfterNearbyWait = afterNearbyWait != null + && !afterNearbyWait.equals(playerLoc); + if (resolvedAfterNearbyWait && progressedAfterNearbyWait) { + if (tryPostDoorFastMinimapClick(path, edgeIdx, afterNearbyWait, target)) { + exitReason = "door-edge-resolved-fast-click"; + } else { + exitReason = "door-edge-resolved-after-nearby-wait"; + } + break; + } + if (!resolvedAfterNearbyWait) { + exitReason = "door-edge-nearby-waiting-retry"; + break; + } + } + if (handleDoorsWithTimeout(path, edgeIdx, + obstaclePolicy.unreachableDoorTimeoutMs(), doorEdgesAttemptedThisTail)) { + exitReason = "door-handled-unreachable"; + break; + } + if (isRecoveryMovementInFlight()) { + exitReason = "recovery-move-in-flight"; + break; + } + boolean gateDoorInteraction = isDoorInteractionSettling() || isDoorEdgePassSkipCoolingDown(); + long recentDoorAgeMs = recentDoorAttemptAgeNearIndex(path, edgeIdx); + boolean pendingDoorTraversal = recentDoorAgeMs >= 0 + && recentDoorAgeMs <= DOOR_TRAVERSAL_RECOVERY_BLOCK_MS + && !Rs2Player.isMoving(); + if (gateDoorInteraction) { + // Avoid any follow-up door probing right after an interaction; + // resolver is still settling and re-probes can loop. + exitReason = "door-settling-yield"; + break; + } + if (pendingDoorTraversal) { + // Keep one-shot behavior after door open: let traversal finish + // before issuing fallback path-adj/recovery actions. + exitReason = "door-traversal-pending-yield"; + break; + } + // Fallback: only interact with objects on/adjacent to blocked path edges + // within ~15 tiles. Prevents clicking already-open / unrelated doors. + final long nowMs = System.currentTimeMillis(); + if (!gateDoorInteraction + && obstaclePolicy.allowNearbyFallback() + && nowMs - lastDoorPathAdjAttemptAtMs > 1200) { + lastDoorPathAdjAttemptAtMs = nowMs; + if (tryResolvePathAdjacentBlocker(playerLoc, path, edgeIdx, 3, 15)) { + exitReason = "door-handled-path-adj-scan"; + break; + } + } + + // If we still can't resolve a blocker by interaction, do not stall. + // Click a reachable "progress" tile that advances toward the target/path. + // This keeps the walker responsive and usually moves us into the door's + // interaction range. + Set reachable = Rs2Tile.getReachableTilesFromTile(playerLoc, 5).keySet(); + if (reachable != null && !reachable.isEmpty()) { + WorldPoint best = null; + int bestDist = Integer.MAX_VALUE; + for (WorldPoint wp : reachable) { + if (wp == null) continue; + // Prefer tiles that move closer to ultimate target. + int d = wp.distanceTo2D(target); + if (best == null || d < bestDist) { + best = wp; + bestDist = d; + } + } + if (best != null && !best.equals(playerLoc)) { + WorldPoint navTarget = getPointWithWallDistance(best); + boolean clicked = false; + // Prefer minimap for normal recovery movement. Scene-click fallback only on + // final-adjacent approach to prevent post-door canvas jumps mid-route. + clicked = Rs2Walker.walkMiniMap(navTarget); + if (!clicked + && target != null + && playerLoc.distanceTo2D(target) <= Math.max(2, distance + FINAL_ADJACENT_CANVAS_NUDGE_CHEBYSHEV) + && playerLoc.distanceTo2D(navTarget) <= DOOR_OPEN_CANVAS_NUDGE_MAX_FROM_PLAYER + && Rs2Tile.isTileReachable(navTarget) + && walkFastCanvas(navTarget)) { + clicked = true; + log.debug("[Walker] unreachable recovery: scene click -> {}", navTarget); + } + log.info("[Walker] unreachable recovery click: clicked={} to={} distToTarget={}", + clicked, best, bestDist); + if (clicked) { + markFirstMovementClick("first_recovery_click", target, playerLoc, + "to=" + compactWorldPoint(navTarget)); + lastUnreachableRecoveryClickAtMs = System.currentTimeMillis(); + WorldPoint pathLastRecovery = path.get(path.size() - 1); + int finishThRecovery = tightFinishThreshold(target, pathLastRecovery, distance); + waitUntilIdleAfterSceneWalk(target, POST_SCENE_WALK_IDLE_WAIT_MS_MAX, target, + finishThRecovery); + // Next outer iteration runs checkIfStuck/isStuckTooLong before tile delta — avoid + // spurious stall-recalc right after issuing recovery movement (door still closed). + lastMovedTimeMs = System.currentTimeMillis(); + stuckCount = 0; + } + exitReason = "unreachable-recovery-click"; + break; + } + } + exitReason = "tile-unreachable-near-player"; + break; + } + } continue; } nextWalkingDistance = Rs2Random.between(9, 12); int dist2d = currentWorldPoint.distanceTo2D(Rs2Player.getWorldLocation()); if (dist2d > nextWalkingDistance) { + tmarkPostTransport("post_transport_click_eligibility", target, + "i=" + i + " dist2d=" + dist2d + " threshold=" + nextWalkingDistance); // Minimap clickable area is a circle, so reach is a Euclidean radius — // cardinal tiles reach ~13, diagonals ~9. Empirically 14 was too // optimistic (clicks at 13.5–13.9 Euclidean missed the clip). final int MINIMAP_REACH_EUCLIDEAN = 13; WorldPoint playerLoc = Rs2Player.getWorldLocation(); + + // Checkpoint-style walking: once we set a minimap flag, let the player actually + // travel toward it. Do not keep recalculating/clicking new targets mid-run. + WorldPoint interim = interimTargetWp; + if (interim != null && interim.getPlane() == playerLoc.getPlane()) { + int interimDist = interim.distanceTo2D(playerLoc); + if (interimDist > INTERIM_CLOSE_TILES) { + final WorldPoint interimFinal = interim; + // If we're already moving toward the interim checkpoint, just wait until + // we get close. If we've stopped (no movement), re-click the same interim + // rather than spinning without issuing movement commands. + if (Rs2Player.isMoving()) { + final WorldPoint posBeforeWait = playerLoc; + sleepUntil(() -> + interimFinal.distanceTo2D(Rs2Player.getWorldLocation()) <= INTERIM_CLOSE_TILES + || !Rs2Player.isMoving(), + 2000); + if (posBeforeWait.distanceTo2D(Rs2Player.getWorldLocation()) > 0 || Rs2Player.isMoving()) { + lastMovedTimeMs = System.currentTimeMillis(); + stuckCount = 0; + } + exitReason = "interim-in-flight"; + walkerDiag("interim-in-flight interim=%s interimDist=%d player=%s moving=true", + interimFinal, interimDist, playerLoc); + break; + } else { + // Not moving but still far from the interim checkpoint. Treat the interim + // as stale and pick a fresh checkpoint below (could still resolve to the + // same tile, but ensures we actually issue a new click). + interimTargetWp = null; + interimTargetIdx = -1; + interimSetAtMs = 0L; + interimLastProgressAtMs = 0L; + interimLastBestPathIdx = -1; + interimLastRetargetAtMs = 0L; + } + } + // Close enough: allow selecting a new checkpoint. + interimTargetWp = null; + interimTargetIdx = -1; + interimSetAtMs = 0L; + interimLastProgressAtMs = 0L; + interimLastBestPathIdx = -1; + interimLastRetargetAtMs = 0L; + } + int targetIdx = findFurthestClickableIndex(path, i, playerLoc, wp -> { Set ts = ShortestPathPlugin.getTransports().get(wp); @@ -554,6 +1728,46 @@ && handleCurrentTileTransportTowardPath(rawPath, path, target)) { wp -> inInstance || isKnownWalkableOrUnloaded(wp)); } + // Sticky interim target: if we recently clicked a minimap point and are still + // moving/progressing toward it, don't switch to a different waypoint just because + // path smoothing/minimap flag visibility changed. + final long nowMs = System.currentTimeMillis(); + WorldPoint sticky = interimTargetWp; + if (sticky != null && sticky.getPlane() == playerLoc.getPlane()) { + int stickyDist = sticky.distanceTo2D(playerLoc); + if (stickyDist <= INTERIM_CLOSE_TILES || nowMs - interimSetAtMs > INTERIM_MAX_AGE_MS) { + interimTargetWp = null; + interimTargetIdx = -1; + interimSetAtMs = 0L; + interimLastProgressAtMs = 0L; + interimLastBestPathIdx = -1; + interimLastRetargetAtMs = 0L; + } else { + // U-turn safe progress: track progress along the path index, not Euclidean + // distance-to-target (which can increase on U-shaped routes). + int bestIdxNow = getClosestTileIndex(path); + if (bestIdxNow > interimLastBestPathIdx) { + interimLastBestPathIdx = bestIdxNow; + interimLastProgressAtMs = nowMs; + } + boolean movingOrRecentlyMoved = Rs2Player.isMoving() + || (lastMovedTimeMs > 0 && nowMs - lastMovedTimeMs < 1500); + boolean makingRecentProgress = interimLastProgressAtMs > 0 + && nowMs - interimLastProgressAtMs < INTERIM_PROGRESS_TIMEOUT_MS; + boolean retargetCoolingDown = interimLastRetargetAtMs > 0 + && nowMs - interimLastRetargetAtMs < INTERIM_RETARGET_COOLDOWN_MS; + + // While moving and making progress, keep the existing interim target. + // Cooldown prevents thrash when the route bends and the minimap flag drops. + if ((movingOrRecentlyMoved && makingRecentProgress) || retargetCoolingDown) { + targetWp = sticky; + // Keep the loop index conservative: the sticky point might be interpolated + // and not exist in the path. + targetIdx = Math.max(targetIdx, i); + } + } + } + WorldPoint posBefore = playerLoc; WorldPoint clickTarget = inInstance ? targetWp : getPointWithWallDistance(targetWp); if (!inInstance && !Rs2Tile.isTileReachable(clickTarget)) { @@ -564,14 +1778,36 @@ && handleCurrentTileTransportTowardPath(rawPath, path, target)) { clickTarget = rawReachableTarget; } } + long nowBeforeClick = System.currentTimeMillis(); + if (lastTransportHandledAtMs > 0 + && nowBeforeClick - lastTransportHandledAtMs <= POST_TRANSPORT_PATH_TMARK_WINDOW_MS) { + WebWalkLog.tmark("post_transport_path_selected", + nowBeforeClick - lastTransportHandledAtMs, + target, + posBefore, + "to=" + compactWorldPoint(clickTarget)); + } + markStartupPhase("click_candidate_found", target, "to=" + compactWorldPoint(clickTarget)); boolean clicked = Rs2Walker.walkMiniMap(clickTarget); if (!clicked) { clicked = walkMiniMapToward(clickTarget, playerLoc, MINIMAP_REACH_EUCLIDEAN - 1); } - if (isWalkCancelled(target)) { + if (walkCancelledDiag(target, "processWalk:after-minimap-click", processWalkTail)) { return WalkerState.EXIT; } + lastAttemptedMinimapClick = targetWp; + lastAttemptedMinimapClickOk = clicked; + lastAttemptedMinimapClickAtMs = nowMs; if (clicked) { + markFirstMovementClick("first_minimap_click", target, posBefore, + "to=" + compactWorldPoint(clickTarget)); + interimTargetWp = targetWp; + interimTargetIdx = targetIdx; + interimSetAtMs = nowMs; + interimLastProgressAtMs = nowMs; + interimLastBestPathIdx = getClosestTileIndex(path); + interimLastRetargetAtMs = nowMs; + final WorldPoint b = targetWp; final WorldPoint before = posBefore; // Proximity-primary wake: let each click cover most of its distance @@ -599,12 +1835,12 @@ && walkReachableMiniMapToward(b, before, MINIMAP_REACH_EUCLIDEAN - 1)) { return now != null && (b.distanceTo2D(now) <= proximityWake || !now.equals(before) || Rs2Player.isMoving()); }, 2000); } - if (isWalkCancelled(target)) { + if (walkCancelledDiag(target, "processWalk:after-click-wait", processWalkTail)) { return WalkerState.EXIT; } if (!Rs2Player.isMoving()) { - if (handleNearbyRawPathSceneObjects(rawPath, HANDLER_RANGE)) { + if (handleNearbyRawPathSceneObjects(rawPath, HANDLER_RANGE, target)) { doorOrTransportResult = true; exitReason = "post-click-raw-path-scene-object-handled"; break; @@ -628,8 +1864,14 @@ && walkReachableMiniMapToward(b, before, MINIMAP_REACH_EUCLIDEAN - 1)) { // loop wait for the player to walk closer before re-evaluating. if (!clicked) { exitReason = "click-failed-off-minimap"; + interimTargetWp = null; + interimTargetIdx = -1; + interimSetAtMs = 0L; + interimLastProgressAtMs = 0L; + interimLastBestPathIdx = -1; + interimLastRetargetAtMs = 0L; sleepUntil(() -> isWalkCancelled(target) || !Rs2Player.isMoving(), 2000); - if (isWalkCancelled(target)) { + if (walkCancelledDiag(target, "processWalk:after-click-failed-wait", processWalkTail)) { return WalkerState.EXIT; } break; @@ -641,68 +1883,160 @@ && walkReachableMiniMapToward(b, before, MINIMAP_REACH_EUCLIDEAN - 1)) { } } + if (doorOrTransportResult && shouldCanvasNudgeAfterDoorLikeExit(exitReason)) { + maybeCanvasNudgeAfterDoor(target, distance, path); + // Arm after nudge returns so the window does not expire during in-nudge waits; covers path-adj + // door opens even when canvas nudge had no candidates / failed (still defer tryDirectShortWalk minimap). + suppressTryDirectShortWalkUntilMs = System.currentTimeMillis() + POST_DOOR_NUDGE_SUPPRESS_TRY_DIRECT_MS; + WorldPoint plAfterDoor = Rs2Player.getWorldLocation(); + if (!path.isEmpty() && plAfterDoor != null && target != null) { + WorldPoint pathLastDoor = path.get(path.size() - 1); + int finishAfterDoor = tightFinishThreshold(target, pathLastDoor, distance); + if (plAfterDoor.distanceTo(target) <= finishAfterDoor) { + setTarget(null, "rs2walker:processWalk:arrived-after-door-canvas-nudge"); + return WalkerState.ARRIVED; + } + } + } + + if (!"end-of-path".equals(exitReason)) { + WebWalkLog.earlyExit(exitReason, + Rs2Player.getWorldLocation(), + target, + path.get(path.size() - 1), + indexOfStartPoint, + path.size()); + walkerDiag("early-exit detail reason=%s interim=%s doorOrTransport=%s partialPath=%s", + exitReason, + interimTargetWp, + doorOrTransportResult, + partialPath); + } // Only do the final-tile canvas click if we iterated the whole path cleanly. // Exiting because the player left the path ("off-path-but-moving"/"not-near-path") // means the player is still walking somewhere else — don't clobber that destination. if (!doorOrTransportResult && "end-of-path".equals(exitReason)) { - if (isWalkCancelled(target)) { + if (walkCancelledDiag(target, "processWalk:before-final-canvas", processWalkTail)) { return WalkerState.EXIT; } if (!path.isEmpty()) { - var moveableTiles = Rs2Tile.getReachableTilesFromTile(path.get(path.size() - 1), Math.min(3, distance)).keySet().toArray(new WorldPoint[0]); - var finalTile = (config.randomizeFinalTile() && moveableTiles.length > 0) ? moveableTiles[Rs2Random.between(0, moveableTiles.length)] : path.get(path.size() - 1); + WorldPoint pathLast = path.get(path.size() - 1); + int finishTh = tightFinishThreshold(target, pathLast, distance); + WorldPoint finalTile = pathLast; + boolean pinGoal = target != null && pathLast.getPlane() == target.getPlane() + && pathLast.distanceTo2D(target) <= TIGHT_PATH_GOAL_GAP + && Rs2Tile.isTileReachable(target); + if (pinGoal) { + finalTile = target; + } else if (config.randomizeFinalTile()) { + var moveableTiles = Rs2Tile.getReachableTilesFromTile(pathLast, Math.min(3, distance)).keySet().toArray(new WorldPoint[0]); + if (moveableTiles.length > 0) { + finalTile = moveableTiles[Rs2Random.between(0, moveableTiles.length)]; + } + } - if (Rs2Tile.isTileReachable(finalTile) && Rs2Player.getWorldLocation().distanceTo(finalTile) >= distance) { - if (Rs2Walker.walkFastCanvas(finalTile)) { - sleepUntil(() -> isWalkCancelled(target) || Rs2Player.getWorldLocation().distanceTo(finalTile) < 2, 3000); - if (isWalkCancelled(target)) { + if (Rs2Tile.isTileReachable(finalTile) && Rs2Player.getWorldLocation().distanceTo(finalTile) >= finishTh) { + final WorldPoint canvasClickWp = finalTile; + if (Rs2Walker.walkFastCanvas(canvasClickWp)) { + waitUntilIdleAfterSceneWalk(target, POST_SCENE_WALK_IDLE_WAIT_MS_MAX, target, finishTh); + if (walkCancelledDiag(target, "processWalk:after-final-canvas-wait", processWalkTail)) { return WalkerState.EXIT; } } } } } + WorldPoint pathLastForFinish = path.get(path.size() - 1); + int finishThreshold = tightFinishThreshold(target, pathLastForFinish, distance); int finalDist = Rs2Player.getWorldLocation().distanceTo(target); - if (finalDist <= distance) { - setTarget(null); + if (finalDist <= finishThreshold) { + setTarget(null, "rs2walker:processWalk:arrived-within-distance"); return WalkerState.ARRIVED; } else if (partialPath) { - if (isWalkCancelled(target)) { + if (walkCancelledDiag(target, "processWalk:partial-path-branch", processWalkTail)) { return WalkerState.EXIT; } - if (partialRetries < 3) { - Telemetry.recordPartialRetry(partialRetries + 1, finalDist); - log.info("[Walker] Walked partial path ({} tiles remaining), retrying from current position (attempt {}/3)", - finalDist, partialRetries + 1); + if (partialRetriesWorking < 3) { + Telemetry.recordPartialRetry(partialRetriesWorking + 1, finalDist); + WebWalkLog.partialRetry(finalDist, partialRetriesWorking + 1, 3); recalculatePath(); - return processWalk(target, distance, partialRetries + 1); + partialRetriesWorking++; + continue; } - log.info("[Walker] Walked partial path, exhausted retries. final distance to target: {}", finalDist); + WebWalkLog.partialExhausted(finalDist); Telemetry.recordUnreachable("partial-retries-exhausted", Rs2Player.getWorldLocation(), target, Rs2Player.getWorldLocation(), 0, distance, ShortestPathPlugin.getPathfinder()); - setTarget(null); + setTarget(null, "rs2walker:processWalk:partial-retries-exhausted"); return WalkerState.UNREACHABLE; } else { if ("off-path-but-moving".equals(exitReason)) { // Wait for the player to re-enter the path or to stop moving. Prevents a tight // recursion loop that would spin on isNearPath() while the player is walking. - sleepUntil(() -> isWalkCancelled(target) || isNearPath() || !Rs2Player.isMoving(), 2000); - if (isWalkCancelled(target)) { + long offPathWaitMs = 2000L; + long now = System.currentTimeMillis(); + if (lastTransportHandledAtMs > 0 + && now - lastTransportHandledAtMs <= POST_TRANSPORT_PATH_TMARK_WINDOW_MS) { + long elapsedSinceTransport = Math.max(0L, now - lastTransportHandledAtMs); + long remainingBudget = POST_TRANSPORT_OFFPATH_WAIT_BUDGET_MS - elapsedSinceTransport; + if (remainingBudget <= 0) { + offPathWaitMs = 0L; + } else { + offPathWaitMs = Math.min((long) POST_TRANSPORT_OFFPATH_WAIT_SLICE_MS, remainingBudget); + } + } + if (offPathWaitMs > 0) { + if (lastTransportHandledAtMs > 0 + && System.currentTimeMillis() - lastTransportHandledAtMs <= POST_TRANSPORT_PATH_TMARK_WINDOW_MS) { + WebWalkLog.tmark("post_transport_offpath_sleep", + System.currentTimeMillis() - lastTransportHandledAtMs, + target, + Rs2Player.getWorldLocation(), + "ms=" + offPathWaitMs); + } + sleepUntil(() -> isWalkCancelled(target) || isNearPath() || !Rs2Player.isMoving(), + (int) offPathWaitMs); + } + if (walkCancelledDiag(target, "processWalk:after-off-path-wait", processWalkTail)) { return WalkerState.EXIT; } } - return processWalk(target, distance, partialRetries); + // Benign yields: outer for-loop increments processWalkTail each iteration; exempt so + // long minimap interim waits cannot exhaust MAX_PROCESS_WALK_TAIL_ITERATIONS and EXIT. + if ("interim-in-flight".equals(exitReason) || "off-path-but-moving".equals(exitReason)) { + walkerDiag("tail exempt exitReason=%s tailBefore=%d", exitReason, processWalkTail); + processWalkTail--; + } + walkerDiag("continue outer tail nextIdx=%d exitReason=%s finalDist=%d partialPath=%s", + processWalkTail + 1, + exitReason, + Rs2Player.getWorldLocation().distanceTo(target), + partialPath); + continue; } } catch (Exception ex) { if (ex instanceof InterruptedException || ex.getCause() instanceof InterruptedException) { - log.info("Pathfinder was interrupted, exiting: 397"); - setTarget(null); + WebWalkLog.interruptedExit("pathfinder interrupted (397)"); + traceProcessWalkExit("interrupted-exception", target, MAX_PROCESS_WALK_TAIL_ITERATIONS - 1); + setTarget(null, "rs2walker:processWalk:interrupted-exception"); return WalkerState.EXIT; } log.error("Exception in Rs2Walker:", ex); + WebWalkLog.interruptedExit("walker exception exit (403)"); + traceProcessWalkExit("exception-" + ex.getClass().getSimpleName(), target, MAX_PROCESS_WALK_TAIL_ITERATIONS - 1); + return WalkerState.EXIT; + } } - log.info("Exiting walker: 403"); + Microbot.log(Level.WARN, + "[WalkerDiag] exceeded MAX_PROCESS_WALK_TAIL_ITERATIONS (%d) target=%s currentTarget=%s interim=%s stuck=%d player=%s — enable DEBUG for per-iteration traces", + MAX_PROCESS_WALK_TAIL_ITERATIONS, + target, + currentTarget, + interimTargetWp, + stuckCount, + Rs2Player.getWorldLocation()); + WebWalkLog.tailExceeded(MAX_PROCESS_WALK_TAIL_ITERATIONS, target, currentTarget, interimTargetWp, stuckCount, + Rs2Player.getWorldLocation()); return WalkerState.EXIT; } @@ -844,6 +2178,11 @@ static boolean hasMinimapRelevantMovementFlag(LocalPoint point, int[][] flagMap) private static volatile String staminaSeedName = null; private static volatile int staminaThresholdCached = STAMINA_THRESHOLD_FALLBACK; + // Cooldown to avoid spamming expensive door fallback scans on unreachable tiles. + private static long lastDoorFallbackAttemptAtMs = 0L; + private static long lastDoorLosAttemptAtMs = 0L; + private static long lastDoorPathAdjAttemptAtMs = 0L; + static int computeStaminaThreshold(String playerName, long installSeed) { if (playerName == null || playerName.isEmpty()) { return STAMINA_THRESHOLD_FALLBACK; @@ -1356,9 +2695,16 @@ public static List getTransportsForPath(List path, int in // Iterate over each available transport for (Transport transport : transportsAtPoint) { - // Special handling for teleportation transports - if (transport.getType() == TransportType.TELEPORTATION_ITEM || - transport.getType() == TransportType.TELEPORTATION_SPELL) + // Special handling for teleportation-like transports (originless) + // NOTE: Leagues "Area" teleports are injected as SEASONAL_TRANSPORT with null origin. + String di = transport.getDisplayInfo(); + boolean isLeaguesAreaTeleport = transport.getType() == TransportType.SEASONAL_TRANSPORT + && di != null + && di.toLowerCase().startsWith("leagues area:"); + + if (transport.getType() == TransportType.TELEPORTATION_ITEM + || transport.getType() == TransportType.TELEPORTATION_SPELL + || isLeaguesAreaTeleport) { // For teleportation, we assume origin is null and simply check if the destination exists in the path. int destIndex = path.indexOf(transport.getDestination()); @@ -1382,16 +2728,18 @@ public static List getTransportsForPath(List path, int in for (WorldPoint origin : originPoints) { // If an origin is defined but the player's plane doesn't match, skip it. - if (transport.getOrigin() != null && - Rs2Player.getWorldLocation().getPlane() != transport.getOrigin().getPlane()) { + WorldPoint plTransportFilter = Rs2Player.getWorldLocation(); + if (transport.getOrigin() != null && plTransportFilter != null + && plTransportFilter.getPlane() != transport.getOrigin().getPlane()) { continue; } // For non-teleportation transports, ensure both origin and destination exist in the path // and that the destination comes after the origin. int indexOfDestination = path.indexOf(transport.getDestination()); - if (transport.getType() != TransportType.TELEPORTATION_ITEM && - transport.getType() != TransportType.TELEPORTATION_SPELL) { + if (transport.getType() != TransportType.TELEPORTATION_ITEM + && transport.getType() != TransportType.TELEPORTATION_SPELL + && !isLeaguesAreaTeleport) { int indexOfOrigin = path.indexOf(transport.getOrigin()); if (indexOfOrigin == -1 || indexOfDestination == -1 || indexOfDestination < indexOfOrigin) { continue; @@ -1416,7 +2764,7 @@ public static List getTransportsForPath(List path, int in } } - log.info("\n\nFound {} transports for path from {} to {}", transportList.size(), path.get(0), path.get(path.size() - 1)); + WebWalkLog.bankPathTransportsDebug(transportList.size(), path.get(0), path.get(path.size() - 1)); // Apply filtering and requirement setup if requested if (applyFiltering) { @@ -1440,7 +2788,11 @@ private static List applyTransportFiltering(List transport t.getType() == TransportType.TELEPORTATION_SPELL || t.getType() == TransportType.CANOE || t.getType() == TransportType.BOAT || t.getType() == TransportType.CHARTER_SHIP || t.getType() == TransportType.SHIP || t.getType() == TransportType.MINECART || - t.getType() == TransportType.MAGIC_CARPET) + t.getType() == TransportType.MAGIC_CARPET || t.getType() == TransportType.SPIRIT_TREE || + (t.getType() == TransportType.SEASONAL_TRANSPORT + && Rs2LeaguesTransport.isLeaguesActive() + && t.getDisplayInfo() != null + && t.getDisplayInfo().toLowerCase().startsWith("leagues area:"))) .peek(t -> { // Set fairy ring requirements if not already set if (t.getType() == TransportType.FAIRY_RING && @@ -1502,7 +2854,7 @@ private static boolean handleRockfall(List path, int index) { if (!Rs2Inventory.hasItem("pickaxe")) { if (!Rs2Equipment.isWearing("pickaxe")) { log.error("Unable to find pickaxe to mine rockfall"); - setTarget(null); + setTarget(null, "rs2walker:motherlode-rockfall-no-pickaxe"); return false; } } @@ -1540,9 +2892,12 @@ private static WalkerState tryDirectShortWalk(WorldPoint target, return WalkerState.MOVING; } + WorldPoint end = path.get(path.size() - 1); + int finishTh = tightFinishThreshold(target, end, distance); + int initialDist = playerLoc.distanceTo(target); - if (initialDist <= distance) { - setTarget(null); + if (initialDist <= finishTh) { + setTarget(null, "rs2walker:tryDirectShortWalk:already-within-distance"); return WalkerState.ARRIVED; } @@ -1551,7 +2906,6 @@ private static WalkerState tryDirectShortWalk(WorldPoint target, return WalkerState.MOVING; } - WorldPoint end = path.get(path.size() - 1); if (end == null || end.getPlane() != target.getPlane() || end.distanceTo(target) > distance) { return WalkerState.MOVING; } @@ -1571,6 +2925,13 @@ private static WalkerState tryDirectShortWalk(WorldPoint target, return WalkerState.MOVING; } + long suppressUntil = suppressTryDirectShortWalkUntilMs; + if (suppressUntil != 0L && System.currentTimeMillis() < suppressUntil) { + log.debug("[Walker] defer tryDirectShortWalk minimap (post door canvas nudge, {}ms window)", + POST_DOOR_NUDGE_SUPPRESS_TRY_DIRECT_MS); + return WalkerState.MOVING; + } + boolean clicked = walkMiniMap(end); if (!clicked) { clicked = walkMiniMapToward(end, playerLoc, directClickMaxDistance - 1); @@ -1585,7 +2946,7 @@ private static WalkerState tryDirectShortWalk(WorldPoint target, final WorldPoint before = playerLoc; boolean moved = sleepUntil(() -> { WorldPoint now = Rs2Player.getWorldLocation(); - return now != null && (now.distanceTo(target) <= distance || !now.equals(before) || Rs2Player.isMoving()); + return now != null && (now.distanceTo(target) <= finishTh || !now.equals(before) || Rs2Player.isMoving()); }, 800); if (!moved) { @@ -1595,24 +2956,24 @@ private static WalkerState tryDirectShortWalk(WorldPoint target, } sleepUntil(() -> { WorldPoint now = Rs2Player.getWorldLocation(); - return now != null && (now.distanceTo(target) <= distance || !now.equals(before) || Rs2Player.isMoving()); + return now != null && (now.distanceTo(target) <= finishTh || !now.equals(before) || Rs2Player.isMoving()); }, 800); } WorldPoint afterClick = Rs2Player.getWorldLocation(); - if (afterClick != null && afterClick.distanceTo(target) <= distance) { - setTarget(null); + if (afterClick != null && afterClick.distanceTo(target) <= finishTh) { + setTarget(null, "rs2walker:tryDirectShortWalk:arrived-after-click"); return WalkerState.ARRIVED; } sleepUntil(() -> { WorldPoint now = Rs2Player.getWorldLocation(); - return now != null && (now.distanceTo(target) <= distance || !Rs2Player.isMoving()); + return now != null && (now.distanceTo(target) <= finishTh || !Rs2Player.isMoving()); }, 4000); WorldPoint afterWalk = Rs2Player.getWorldLocation(); - if (afterWalk != null && afterWalk.distanceTo(target) <= distance) { - setTarget(null); + if (afterWalk != null && afterWalk.distanceTo(target) <= finishTh) { + setTarget(null, "rs2walker:tryDirectShortWalk:arrived-after-walk"); return WalkerState.ARRIVED; } @@ -1688,11 +3049,20 @@ private static boolean localRouteDetoursFromComputedRoute(List rawPa return localSteps == null || localSteps > computedSteps + detourSlackTiles; } - private static boolean handleNearbyRawPathSceneObjects(List rawPath, int handlerRange) { + private static boolean handleNearbyRawPathSceneObjects(List rawPath, int handlerRange, WorldPoint target) { if (rawPath == null || rawPath.size() < 2) { return false; } + if (isRecoveryMovementInFlight()) { + return false; + } + + if (interimTargetWp != null) { + clearRawScanDoorFocus("interim-active"); + return false; + } + if (Rs2Player.isMoving()) { return false; } @@ -1704,8 +3074,20 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, int rawStart = getClosestTileIndex(rawPath); if (rawStart < 0) { + clearRawScanDoorFocus("raw-start-missing"); + return false; + } + + if (shouldUseFocusedRawDoorIndex(rawPath, rawStart)) { + int idx = rawScanFocusedDoorIdx; + rawScanFocusedDoorAttempts++; + if (handleDoors(rawPath, idx, true)) { + log.info("[Walker] Raw path focused door handler resolved obstacle near {}", playerLoc); + return true; + } return false; } + clearRawScanDoorFocus("focus-invalid"); int start = Math.max(0, rawStart - 1); int endExclusive = Math.min(rawPath.size() - 1, rawStart + 12); @@ -1717,15 +3099,29 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, continue; } - if (hasExplicitTransportStep(rawPath, i) && handleTransports(rawPath, i)) { - log.info("[Walker] Raw path transport handler resolved obstacle near {}", playerLoc); - return true; + if (hasExplicitTransportStep(rawPath, i)) { + WorldPoint before = Rs2Player.getWorldLocation(); + WorldPoint expectedDestination = i + 1 < rawPath.size() ? rawPath.get(i + 1) : null; + if (handleTransports(rawPath, i)) { + if (!didCurrentTileTransportProgress(before, expectedDestination, target)) { + WebWalkLog.spInfo("raw_path_transport_no_progress", + "at=%s expected=%s target=%s", + before, expectedDestination, target); + } else { + log.info("[Walker] Raw path transport handler resolved obstacle near {}", playerLoc); + return true; + } + } } if (handleDoors(rawPath, i, true)) { log.info("[Walker] Raw path door handler resolved obstacle near {}", playerLoc); return true; } + if (hasDoorCandidateOnRawSegment(rawPath, i)) { + setRawScanDoorFocus(i); + return false; + } if (handleRockfall(rawPath, i)) { log.info("[Walker] Raw path rockfall handler resolved obstacle near {}", playerLoc); @@ -1736,10 +3132,73 @@ private static boolean handleNearbyRawPathSceneObjects(List rawPath, return false; } + private static boolean hasDoorCandidateOnRawSegment(List rawPath, int index) { + if (rawPath == null || index < 0 || index >= rawPath.size() - 1) { + return false; + } + if (hasExplicitTransportStep(rawPath, index)) { + return false; + } + boolean isInstance = Microbot.getClient() + .getTopLevelWorldView() + .getScene() + .isInstance(); + WorldPoint rawFrom = rawPath.get(index); + WorldPoint rawTo = rawPath.get(index + 1); + WorldPoint fromWp = isInstance ? Rs2WorldPoint.convertInstancedWorldPoint(rawFrom) : rawFrom; + WorldPoint toWp = isInstance ? Rs2WorldPoint.convertInstancedWorldPoint(rawTo) : rawTo; + if (fromWp == null || toWp == null || fromWp.getPlane() != toWp.getPlane()) { + return false; + } + List doorActions = List.of("pay-toll", "pick-lock", "walk-through", "go-through", "open", "pass"); + return findDoorNearSegment(fromWp, toWp, doorActions) != null; + } + + private static void setRawScanDoorFocus(int index) { + rawScanFocusedDoorIdx = index; + rawScanFocusedDoorSetAtMs = System.currentTimeMillis(); + rawScanFocusedDoorAttempts = 0; + } + + private static boolean shouldUseFocusedRawDoorIndex(List rawPath, int rawStartIdx) { + Integer idx = rawScanFocusedDoorIdx; + if (idx == null) { + return false; + } + if (interimTargetWp != null) { + return false; + } + if (System.currentTimeMillis() - rawScanFocusedDoorSetAtMs > RAW_SCAN_DOOR_FOCUS_MAX_MS) { + return false; + } + if (rawScanFocusedDoorAttempts >= RAW_SCAN_DOOR_FOCUS_MAX_ATTEMPTS) { + return false; + } + if (idx < 0 || idx >= rawPath.size() - 1) { + return false; + } + if (rawStartIdx > idx + 1) { + return false; + } + return Math.abs(rawStartIdx - idx) <= 2; + } + + private static void clearRawScanDoorFocus(String reason) { + if (rawScanFocusedDoorIdx != null && debug) { + walkerDiag("clear raw door focus: %s", reason); + } + rawScanFocusedDoorIdx = null; + rawScanFocusedDoorSetAtMs = 0L; + rawScanFocusedDoorAttempts = 0; + } + private static boolean handleCurrentTileTransportTowardPath(List rawPath, List path, WorldPoint target) { if (Rs2Player.isMoving()) { return false; } + if (isDoorEdgePassSkipCoolingDown() || isDoorInteractionSettling()) { + return false; + } WorldPoint playerLoc = Rs2Player.getWorldLocation(); if (playerLoc == null) { @@ -1757,6 +3216,9 @@ private static boolean handleCurrentTileTransportTowardPath(List raw List candidates = transports.stream() .filter(t -> t.getDestination() != null) + // Local adjacent same-plane edges (doors/gates) are handled by segment door/object + // logic; current-tile transport probing can bounce on these and create loops. + .filter(t -> !isAdjacentSamePlaneTransport(t)) .filter(t -> target == null || playerLoc.getPlane() != target.getPlane() || t.getDestination().getPlane() == target.getPlane()) @@ -1768,15 +3230,33 @@ private static boolean handleCurrentTileTransportTowardPath(List raw .collect(Collectors.toList()); for (Transport transport : candidates) { + if (shouldThrottleCurrentTileTransportAttempt(playerLoc, transport.getDestination())) { + continue; + } + markCurrentTileTransportAttempt(playerLoc, transport.getDestination()); + WorldPoint before = Rs2Player.getWorldLocation(); if (handleTransports(Arrays.asList(playerLoc, transport.getDestination()), 0)) { - log.info("[Walker] Current-tile transport handler resolved obstacle near {}", playerLoc); - return true; + if (didCurrentTileTransportProgress(before, transport.getDestination(), target)) { + log.info("[Walker] Current-tile transport handler resolved obstacle near {}", playerLoc); + return true; + } + WebWalkLog.spInfo( + "current_tile_transport_no_progress | origin={} dest={} before={} after={} goal={}", + compactWorldPoint(playerLoc), + compactWorldPoint(transport.getDestination()), + compactWorldPoint(before), + compactWorldPoint(Rs2Player.getWorldLocation()), + compactWorldPoint(target)); } } return false; } + private static boolean didCurrentTileTransportProgress(WorldPoint before, WorldPoint expectedDestination, WorldPoint target) { + return Rs2WalkerTransportAwaits.didCurrentTileTransportProgress(before, expectedDestination, target); + } + private static void addForwardPathPoints(Set pathPoints, List path, WorldPoint playerLoc) { if (path == null || path.isEmpty() || playerLoc == null) { return; @@ -1797,6 +3277,12 @@ private static void addForwardPathPoints(Set pathPoints, List sessionBlacklistedDoors = ConcurrentHashMap.newKeySet(); private static final Map recentlyOpenedStationaryDoors = new ConcurrentHashMap<>(); private static final long STATIONARY_DOOR_SUPPRESS_MS = 10_000; + private static final Map recentDoorAttemptByEdge = new ConcurrentHashMap<>(); + private static final long DOOR_ATTEMPT_EDGE_COOLDOWN_MS = 2_500; + private static final Map recentCurrentTileTransportByEdge = new ConcurrentHashMap<>(); + private static final long CURRENT_TILE_TRANSPORT_EDGE_COOLDOWN_MS = 2_200; + private static final long DOOR_INTERACTION_GLOBAL_COOLDOWN_MS = 1_800; + private static volatile long nextDoorInteractionAllowedAtMs = 0L; static boolean hasQuestLockKeywords(String text) { if (text == null || text.isEmpty()) return false; @@ -1912,6 +3398,100 @@ private static int euclideanSq(WorldPoint a, WorldPoint b) { return dx * dx + dy * dy; } + private static ObjectComposition resolveCompositionForDoorProbe(TileObject object) { + ObjectComposition comp = Rs2GameObject.convertToObjectComposition(object); + if (comp == null) { + return null; + } + return Microbot.getClientThread().runOnClientThreadOptional(() -> { + ObjectComposition c = comp; + for (int depth = 0; depth < 4 && c != null && c.getImpostorIds() != null; depth++) { + c = c.getImpostor(); + } + return c; + }).orElse(comp); + } + + private static boolean isNullOrPlaceholderObjectName(String name) { + if (name == null) { + return true; + } + String t = name.trim(); + return t.isEmpty() || "null".equalsIgnoreCase(t); + } + + private static int doorActionPriorityIndex(String action) { + if (action == null) { + return Integer.MAX_VALUE; + } + String al = action.toLowerCase(Locale.ROOT); + for (int i = 0; i < DOOR_ACTION_PRIORITY.size(); i++) { + if (al.startsWith(DOOR_ACTION_PRIORITY.get(i))) { + return i; + } + } + return Integer.MAX_VALUE; + } + + /** Walker must never choose menu actions that close an open door/gate. */ + private static boolean isDoorCloseOrShutAction(String action) { + if (action == null) { + return false; + } + String al = action.toLowerCase(Locale.ROOT).trim(); + return al.startsWith("close") || al.startsWith("shut"); + } + + /** True when every non-null action is Close/Shut (typical open-door state). */ + private static boolean doorCompositionSpecifiesOnlyCloseOrShut(ObjectComposition comp) { + if (comp == null || comp.getActions() == null) { + return false; + } + boolean sawNonNull = false; + for (String a : comp.getActions()) { + if (a == null) { + continue; + } + sawNonNull = true; + if (!isDoorCloseOrShutAction(a)) { + return false; + } + } + return sawNonNull; + } + + /** + * Best door action for walking through, excluding close/shut. {@code null} if none + * (empty defs or only close/shut). + */ + private static String pickWalkDoorAction(ObjectComposition comp) { + if (comp == null || comp.getActions() == null) { + return null; + } + return Arrays.stream(comp.getActions()) + .filter(Objects::nonNull) + .filter(a -> !isDoorCloseOrShutAction(a)) + .min(Comparator.comparingInt(Rs2Walker::doorActionPriorityIndex)) + .orElse(null); + } + + private static boolean isDoorLikeGameObjectName(String name) { + if (name == null) { + return false; + } + String n = name.toLowerCase(Locale.ROOT); + for (String f : DOOR_LIKE_NAME_FRAGMENTS) { + if ("fence".equals(f)) { + if (FENCE_AS_WORD.matcher(n).find()) { + return true; + } + } else if (n.contains(f)) { + return true; + } + } + return false; + } + private static boolean handleDoors(List path, int index) { return handleDoors(path, index, false); } @@ -1928,7 +3508,7 @@ private static boolean handleDoors(List path, int index, boolean all return false; } - List doorActions = List.of("pay-toll", "pick-lock", "walk-through", "go-through", "open"); + List doorActions = List.of("pay-toll", "pick-lock", "walk-through", "go-through", "open", "pass"); boolean isInstance = Microbot.getClient() .getTopLevelWorldView() .getScene() @@ -1969,9 +3549,6 @@ private static boolean handleDoors(List path, int index, boolean all return false; } - boolean diagonal = Math.abs(fromWp.getX() - toWp.getX()) > 0 - && Math.abs(fromWp.getY() - toWp.getY()) > 0; - for (int offset = 0; offset <= 1; offset++) { int doorIdx = index + offset; if (doorIdx >= path.size()) continue; @@ -1981,12 +3558,7 @@ private static boolean handleDoors(List path, int index, boolean all ? Rs2WorldPoint.convertInstancedWorldPoint(rawDoorWp) : rawDoorWp; - List probes = new ArrayList<>(); - probes.add(doorWp); - if (diagonal) { - probes.add(new WorldPoint(toWp.getX(), fromWp.getY(), doorWp.getPlane())); - probes.add(new WorldPoint(fromWp.getX(), toWp.getY(), doorWp.getPlane())); - } + List probes = Rs2DoorAheadResolver.buildSegmentProbes(fromWp, toWp, doorWp); for (WorldPoint probe : probes) { if (recentlyOpenedStationaryDoorOnSegment(fromWp, toWp)) { @@ -1996,12 +3568,153 @@ private static boolean handleDoors(List path, int index, boolean all WorldPoint playerLoc = Rs2Player.getWorldLocation(); if (!adjacentToPath || playerLoc == null || !Objects.equals(probe.getPlane(), playerLoc.getPlane())) continue; + // WallObjects can report their world location as an adjacent tile depending on + // orientation / scene representation. Use exact match first, then allow a small + // adjacency fallback so door handling triggers reliably. WallObject wall = Rs2GameObject.getWallObject(o -> o.getWorldLocation().equals(probe), probe, 3); + if (wall == null) { + wall = Rs2GameObject.getWallObject(o -> o.getWorldLocation().distanceTo2D(probe) <= 1, probe, 3); + } TileObject object = (wall != null) ? wall : Rs2GameObject.getGameObject(o -> o.getWorldLocation().equals(probe), probe, 3); - if (tryHandleDoorObject(object, probe, fromWp, toWp, doorActions, false)) { + if (object == null) { + object = Rs2GameObject.getGameObject(o -> o.getWorldLocation().distanceTo2D(probe) <= 1, probe, 3); + } + if (object == null) continue; + + ObjectComposition baseComp = Rs2GameObject.convertToObjectComposition(object); + ObjectComposition comp = resolveCompositionForDoorProbe(object); + if (comp == null) { + Telemetry.recordDoorReject("composition-null"); + continue; + } + if (baseComp != null && baseComp.getImpostorIds() != null + && !isNullOrPlaceholderObjectName(baseComp.getName()) + && isNullOrPlaceholderObjectName(comp.getName())) { + Telemetry.recordDoorReject("impostor-rejected"); + continue; + } + if (isNullOrPlaceholderObjectName(comp.getName())) { + Telemetry.recordDoorReject("name-not-door"); + continue; + } + + if (doorCompositionSpecifiesOnlyCloseOrShut(comp)) { + Telemetry.recordDoorReject("skip-close-only-open"); + continue; + } + + String action = pickWalkDoorAction(comp); + if (action == null) { + Telemetry.recordDoorReject("no-walk-action"); + continue; + } + if (doorActionPriorityIndex(action) == Integer.MAX_VALUE) { + Telemetry.recordDoorReject("non-standard-door-action"); + continue; + } + + boolean found = false; + + final String name = comp.getName(); + + if (object instanceof WallObject) { + WallObject wallObj = (WallObject) object; + int orientationA = wallObj.getOrientationA(); + int orientationB = wallObj.getOrientationB(); + boolean pathTouchesBothEnds = probe.distanceTo(fromWp) <= 1 && probe.distanceTo(toWp) <= 1 + && fromWp.distanceTo(toWp) >= 1 && fromWp.distanceTo(toWp) <= 2; + boolean orientOk = false; + if (orientationA != 0) { + orientOk = searchNeighborPoint(orientationA, probe, fromWp) + || searchNeighborPoint(orientationA, probe, toWp); + } + if (!orientOk && orientationB != 0) { + orientOk = searchNeighborPoint(orientationB, probe, fromWp) + || searchNeighborPoint(orientationB, probe, toWp); + } + if (!orientOk && pathTouchesBothEnds) { + orientOk = true; + } + if (orientOk) { + log.info("Found WallObject door - name {} with action {} at {} - from {} to {}", name, action, probe, fromWp, toWp); + found = true; + } else { + Telemetry.recordDoorReject("orient-mismatch"); + } + } else { + if (isDoorOnSegment(object, fromWp, toWp)) { + log.info("Found GameObject door - name {} with action {} at {} - from {} to {}", name, action, probe, fromWp, toWp); + found = true; + } else { + Telemetry.recordDoorReject("gameobject-segment-mismatch"); + } + } + + if (found) { + if (!handleDoorException(object, action)) { + if (shouldThrottleDoorAttempt(probe, fromWp, toWp)) { + WebWalkLog.spInfo("door_attempt_throttled | mode=segment-door probe={} from={} to={}", + compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); + markStationaryDoorOpened(probe); + return false; + } + if (shouldThrottleGlobalDoorInteraction()) { + WebWalkLog.spInfo("door_global_await | mode=segment-door probe={} from={} to={}", + compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); + return false; + } + markDoorAttempt(probe, fromWp, toWp); + markGlobalDoorInteractionCooldown(); + WorldPoint posBefore = Rs2Player.getWorldLocation(); + boolean interacted; + try { + interacted = Rs2GameObject.interact(object, action); + } catch (Exception ex) { + WebWalkLog.spInfo("door_interact_exception | mode=segment-door probe={} from={} to={} ex={}", + compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp), ex.getClass().getSimpleName()); + markStationaryDoorOpened(probe); + return false; + } + if (!interacted) { + WebWalkLog.spInfo("door_interact_failed | mode=segment-door probe={} from={} to={}", + compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); + markStationaryDoorOpened(probe); + return false; + } + markDoorInteractionSettling(); + waitForDoorInteractionProgress(fromWp, toWp); + WorldPoint posAfter = Rs2Player.getWorldLocation(); + boolean traversed = didTraverseInteractedDoor(posBefore, posAfter, probe, fromWp, toWp); + if (!traversed && isQuestLockedDoorDialogue()) { + String dialogue = Rs2Dialogue.getDialogueText(); + log.warn("[Walker] Door at {} ({} action={}) appears quest/stat-locked — dialogue=\"{}\" — blacklisting tile, refreshing restrictions, recalculating", + probe, name, action, dialogue); + sessionBlacklistedDoors.add(probe); + Rs2Dialogue.clickContinue(); + if (ShortestPathPlugin.pathfinderConfig != null) { + ShortestPathPlugin.pathfinderConfig.refresh(); + } + recalculatePath(); + } + if (!traversed) { + if (shouldBlacklistDoorAfterWrongTraversal(posBefore, posAfter, fromWp, toWp)) { + sessionBlacklistedDoors.add(probe); + log.warn("[Walker] Blacklisting door after wrong traversal: door={} from={} to={} before={} after={}", + probe, fromWp, toWp, posBefore, posAfter); + } + if (doorStillHasAction(probe, doorActions, action)) { + log.debug("[Walker] Door interaction did not traverse; action still present at {} ({} -> {})", + probe, fromWp, toWp); + } + markStationaryDoorOpened(probe); + return false; + } + markStationaryDoorOpened(probe); + markNearbyDoorFamilyOpened(object, probe, action, SEGMENT_DOOR_FAMILY_MARK_RADIUS); + } return true; } } @@ -2064,8 +3777,10 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, found = true; } } else if (name != null && name.toLowerCase().contains("door")) { - log.info("Found GameObject door - name {} with action {} at {} - from {} to {}", name, action, probe, fromWp, toWp); - found = true; + if (isDoorOnSegment(object, fromWp, toWp)) { + log.info("Found GameObject door - name {} with action {} at {} - from {} to {}", name, action, probe, fromWp, toWp); + found = true; + } } if (!found) return false; @@ -2074,14 +3789,49 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, return true; } + if (shouldThrottleDoorAttempt(probe, fromWp, toWp)) { + WebWalkLog.spInfo("door_attempt_throttled | mode=segment-probe probe={} from={} to={}", + compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); + markStationaryDoorOpened(probe); + return false; + } + if (shouldThrottleGlobalDoorInteraction()) { + WebWalkLog.spInfo("door_global_await | mode=segment-probe probe={} from={} to={}", + compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); + return false; + } + markDoorAttempt(probe, fromWp, toWp); + markGlobalDoorInteractionCooldown(); WorldPoint posBefore = Rs2Player.getWorldLocation(); - Rs2GameObject.interact(object, action); + boolean interacted; + try { + interacted = Rs2GameObject.interact(object, action); + } catch (Exception ex) { + WebWalkLog.spInfo("door_interact_exception | mode=segment-probe probe={} from={} to={} ex={}", + compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp), ex.getClass().getSimpleName()); + markStationaryDoorOpened(probe); + return false; + } + if (!interacted) { + WebWalkLog.spInfo("door_interact_failed | mode=segment-probe probe={} from={} to={}", + compactWorldPoint(probe), compactWorldPoint(fromWp), compactWorldPoint(toWp)); + markStationaryDoorOpened(probe); + return false; + } + markDoorInteractionSettling(); waitForDoorInteractionProgress(fromWp, toWp); WorldPoint posAfter = Rs2Player.getWorldLocation(); - boolean moved = posBefore != null && posAfter != null && !posBefore.equals(posAfter); - if (moved) { + boolean traversed = didTraverseInteractedDoor(posBefore, posAfter, probe, fromWp, toWp); + if (traversed) { + markStationaryDoorOpened(probe); + markNearbyDoorFamilyOpened(object, probe, action, SEGMENT_DOOR_FAMILY_MARK_RADIUS); return true; } + if (shouldBlacklistDoorAfterWrongTraversal(posBefore, posAfter, fromWp, toWp)) { + sessionBlacklistedDoors.add(probe); + log.warn("[Walker] Blacklisting door after wrong traversal: door={} from={} to={} before={} after={}", + probe, fromWp, toWp, posBefore, posAfter); + } if (isQuestLockedDoorDialogue()) { String dialogue = Rs2Dialogue.getDialogueText(); log.warn("[Walker] Door at {} ({} action={}) appears quest/stat-locked — dialogue=\"{}\" — blacklisting tile, refreshing restrictions, recalculating", @@ -2096,9 +3846,9 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, } if (doorStillHasAction(probe, doorActions, action)) { - return true; + log.debug("[Walker] Segment door interaction did not traverse; action still present at {} ({} -> {})", + probe, fromWp, toWp); } - markStationaryDoorOpened(probe); return false; } @@ -2121,21 +3871,229 @@ private static boolean doorStillHasAction(WorldPoint probe, List doorAct } private static void markStationaryDoorOpened(WorldPoint doorTile) { - if (doorTile != null) { - recentlyOpenedStationaryDoors.put(doorTile, System.currentTimeMillis()); + Rs2DoorHandler.markStationaryDoorOpened(recentlyOpenedStationaryDoors, doorTile); + } + + private static String doorAttemptKey(WorldPoint doorTile, WorldPoint fromWp, WorldPoint toWp) { + return Rs2DoorHandler.doorAttemptKey(doorTile, fromWp, toWp); + } + + private static boolean shouldThrottleDoorAttempt(WorldPoint doorTile, WorldPoint fromWp, WorldPoint toWp) { + return Rs2DoorHandler.shouldThrottleDoorAttempt( + recentDoorAttemptByEdge, + DOOR_ATTEMPT_EDGE_COOLDOWN_MS, + doorTile, + fromWp, + toWp); + } + + private static boolean hasRecentDoorAttemptOnEdge(WorldPoint fromWp, WorldPoint toWp) { + return shouldThrottleDoorAttempt(null, fromWp, toWp); + } + + private static boolean hasRecentDoorAttemptNearIndex(List path, int edgeIdx) { + if (path == null || path.size() < 2 || edgeIdx < 0) { + return false; + } + int start = Math.max(0, edgeIdx - 1); + int end = Math.min(path.size() - 2, edgeIdx + 1); + for (int i = start; i <= end; i++) { + WorldPoint from = path.get(i); + WorldPoint to = path.get(i + 1); + if (!isLikelyDoorEdgeTransition(from, to)) { + continue; + } + if (hasRecentDoorAttemptOnEdge(from, to)) { + return true; + } } + return false; } - private static boolean recentlyOpenedStationaryDoorOnSegment(WorldPoint fromWp, WorldPoint toWp) { + private static boolean waitForRecentDoorEdgeResolutionNearIndex(List path, int edgeIdx, int timeoutMs) { + if (path == null || path.size() < 2 || edgeIdx < 0) { + return false; + } + int start = Math.max(0, edgeIdx - 1); + int end = Math.min(path.size() - 2, edgeIdx + 1); + for (int i = start; i <= end; i++) { + WorldPoint from = path.get(i); + WorldPoint to = path.get(i + 1); + if (!isLikelyDoorEdgeTransition(from, to)) { + continue; + } + if (hasRecentDoorAttemptOnEdge(from, to)) { + return waitForDoorEdgeResolution(from, to, timeoutMs); + } + } + return false; + } + + private static long recentDoorAttemptAgeNearIndex(List path, int edgeIdx) { + if (path == null || path.size() < 2 || edgeIdx < 0) { + return -1L; + } + long now = System.currentTimeMillis(); + long newestAttemptAt = -1L; + int start = Math.max(0, edgeIdx - 1); + int end = Math.min(path.size() - 2, edgeIdx + 1); + for (int i = start; i <= end; i++) { + WorldPoint from = path.get(i); + WorldPoint to = path.get(i + 1); + if (!isLikelyDoorEdgeTransition(from, to)) { + continue; + } + Long attemptedAt = recentDoorAttemptByEdge.get(doorAttemptKey(null, from, to)); + if (attemptedAt != null) { + newestAttemptAt = Math.max(newestAttemptAt, attemptedAt); + } + } + return newestAttemptAt < 0 ? -1L : Math.max(0L, now - newestAttemptAt); + } + + private static boolean isLikelyDoorEdgeTransition(WorldPoint from, WorldPoint to) { + if (from == null || to == null || from.getPlane() != to.getPlane()) { + return false; + } + // Door crossings are local transitions. Ignore long smoothed hops that can + // accidentally reuse old door attempt keys and stall nearby-wait logic. + return from.distanceTo2D(to) >= 1 && from.distanceTo2D(to) <= 2; + } + + private static boolean tryPostDoorFastMinimapClick(List path, int edgeIdx, WorldPoint playerLoc, WorldPoint target) { + if (path == null || path.size() < 2 || playerLoc == null) { + return false; + } + int from = Math.max(0, edgeIdx + 1); + int to = Math.min(path.size() - 1, from + 8); + WorldPoint candidate = null; + int bestDistToTarget = Integer.MAX_VALUE; + for (int i = from; i <= to; i++) { + WorldPoint wp = path.get(i); + if (wp == null || wp.getPlane() != playerLoc.getPlane()) { + break; + } + if (euclideanSq(wp, playerLoc) > POST_DOOR_FAST_CLICK_MAX_EUCLIDEAN * POST_DOOR_FAST_CLICK_MAX_EUCLIDEAN) { + break; + } + if (!Rs2Tile.isTileReachable(wp)) { + continue; + } + int d = target == null ? 0 : wp.distanceTo2D(target); + if (candidate == null || d < bestDistToTarget) { + candidate = wp; + bestDistToTarget = d; + } + } + if (candidate == null || candidate.equals(playerLoc)) { + return false; + } + // Do not issue an immediate fast click while the player is still traversing + // (moving/animation in flight) from the just-handled door edge. + if (Rs2Player.isMoving() || Rs2Player.isAnimating()) { + return false; + } + boolean clicked = walkMiniMap(candidate); + if (!clicked) { + clicked = walkMiniMapToward(candidate, playerLoc, POST_DOOR_FAST_CLICK_MAX_EUCLIDEAN - 1); + } + if (clicked) { + markFirstMovementClick("post_door_fast_click", target, playerLoc, + "to=" + compactWorldPoint(candidate)); + } + return clicked; + } + + private static boolean shouldThrottleGlobalDoorInteraction() { + return Rs2DoorHandler.shouldThrottleGlobalDoorInteraction(nextDoorInteractionAllowedAtMs); + } + + private static boolean isDoorInteractionSettling() { + return System.currentTimeMillis() < doorInteractionSettleUntilMs; + } + + private static boolean isTransportInteractionSettling() { + long handledAt = lastTransportHandledAtMs; + if (handledAt <= 0L) { + return false; + } + long ageMs = System.currentTimeMillis() - handledAt; + if (ageMs < 0L || ageMs > TRANSPORT_POST_INTERACT_SETTLE_MS) { + return false; + } + WorldPoint now = Rs2Player.getWorldLocation(); + WorldPoint landedAt = lastTransportHandledAtLocation; + if (now == null || landedAt == null) { + return ageMs <= TRANSPORT_POST_INTERACT_SETTLE_MS / 2; + } + return now.getPlane() == landedAt.getPlane() + && now.distanceTo2D(landedAt) <= 1 + && !Rs2Player.isMoving(); + } + + private static boolean isDoorEdgePassSkipCoolingDown() { + return System.currentTimeMillis() - lastDoorEdgePassSkipAtMs < DOOR_EDGE_SKIP_COOLDOWN_MS; + } + + private static boolean isRecoveryMovementInFlight() { + return System.currentTimeMillis() - lastUnreachableRecoveryClickAtMs < RECOVERY_MOVEMENT_IN_FLIGHT_MS; + } + + private static void markDoorInteractionSettling() { + doorInteractionSettleUntilMs = System.currentTimeMillis() + DOOR_POST_INTERACT_SETTLE_MS; + } + + private static void markGlobalDoorInteractionCooldown() { + nextDoorInteractionAllowedAtMs = Rs2DoorHandler.markGlobalDoorInteractionCooldown(DOOR_INTERACTION_GLOBAL_COOLDOWN_MS); + } + + private static void markDoorAttempt(WorldPoint doorTile, WorldPoint fromWp, WorldPoint toWp) { + Rs2DoorHandler.markDoorAttempt(recentDoorAttemptByEdge, doorTile, fromWp, toWp); + } + + private static boolean shouldThrottleCurrentTileTransportAttempt(WorldPoint fromWp, WorldPoint toWp) { if (fromWp == null || toWp == null) { return false; } + String edgeKey = doorAttemptKey(null, fromWp, toWp); long now = System.currentTimeMillis(); - recentlyOpenedStationaryDoors.entrySet().removeIf(entry -> now - entry.getValue() > STATIONARY_DOOR_SUPPRESS_MS); - return recentlyOpenedStationaryDoors.keySet().stream() - .anyMatch(door -> door != null - && door.getPlane() == fromWp.getPlane() - && (door.distanceTo2D(fromWp) <= 1 || door.distanceTo2D(toWp) <= 1)); + recentCurrentTileTransportByEdge.entrySet() + .removeIf(entry -> now - entry.getValue() > CURRENT_TILE_TRANSPORT_EDGE_COOLDOWN_MS); + Long last = recentCurrentTileTransportByEdge.get(edgeKey); + return last != null && now - last < CURRENT_TILE_TRANSPORT_EDGE_COOLDOWN_MS; + } + + private static void markCurrentTileTransportAttempt(WorldPoint fromWp, WorldPoint toWp) { + if (fromWp == null || toWp == null) { + return; + } + recentCurrentTileTransportByEdge.put( + doorAttemptKey(null, fromWp, toWp), + System.currentTimeMillis()); + } + + private static boolean recentlyOpenedStationaryDoorOnSegment(WorldPoint fromWp, WorldPoint toWp) { + return Rs2DoorHandler.recentlyOpenedStationaryDoorOnSegment( + recentlyOpenedStationaryDoors, + STATIONARY_DOOR_SUPPRESS_MS, + fromWp, + toWp); + } + + private static boolean wasStationaryDoorOpenedRecently(WorldPoint doorTile) { + if (doorTile == null) { + return false; + } + Long openedAt = recentlyOpenedStationaryDoors.get(doorTile); + if (openedAt == null) { + return false; + } + long ageMs = System.currentTimeMillis() - openedAt; + if (ageMs > STATIONARY_DOOR_SUPPRESS_MS) { + recentlyOpenedStationaryDoors.remove(doorTile); + return false; + } + return true; } private static boolean hasExplicitTransportStep(List path, int index) { @@ -2151,18 +4109,79 @@ private static boolean hasExplicitTransportStep(List path, int index } private static void waitForDoorInteractionProgress(WorldPoint fromWp, WorldPoint toWp) { - final long startedAt = System.currentTimeMillis(); - Rs2Player.waitForWalking(); - sleepUntil(() -> { - WorldPoint now = Rs2Player.getWorldLocation(); - if (now == null) { - return false; - } - if (toWp != null && now.distanceTo2D(toWp) <= 1) { - return true; - } - return !Rs2Player.isMoving() && System.currentTimeMillis() - startedAt > 1_200; - }, 5000); + AwaitTicket ticket = Rs2WalkerAwaits.beginTicket(); + Rs2WalkerAwaits.awaitDoorInteractionProgress(ticket, fromWp, toWp); + } + + private static boolean waitForDoorEdgeResolution(WorldPoint fromWp, WorldPoint toWp, int timeoutMs) { + long startedAt = System.currentTimeMillis(); + DoorResolution resolution = Rs2WalkerAwaits.awaitDoorEdgeResolution(fromWp, toWp, timeoutMs); + WebWalkLog.tmark("door_edge_wait_done", System.currentTimeMillis() - startedAt, currentTarget, + Rs2Player.getWorldLocation(), + "result=" + resolution + " from=" + compactWorldPoint(fromWp) + " to=" + compactWorldPoint(toWp)); + return resolution == DoorResolution.RESOLVED; + } + + private static boolean isDoorEdgeResolved(WorldPoint fromWp, WorldPoint toWp) { + return Rs2WalkerAwaits.isDoorEdgeResolved(fromWp, toWp); + } + + static boolean didTraverseInteractedDoor(WorldPoint start, WorldPoint end, WorldPoint objectLoc, + WorldPoint fromWp, WorldPoint toWp) { + if (start == null || end == null || objectLoc == null || toWp == null) { + return false; + } + if (start.getPlane() != end.getPlane() || end.getPlane() != objectLoc.getPlane() || end.getPlane() != toWp.getPlane()) { + return false; + } + if (start.equals(end)) { + return false; + } + if (!movedAcrossInteractedObject(start, end, objectLoc)) { + return false; + } + int beforeTo = start.distanceTo2D(toWp); + int afterTo = end.distanceTo2D(toWp); + if (afterTo >= beforeTo) { + return false; + } + // Keep the traversal check anchored to the active segment. + return fromWp == null || fromWp.getPlane() == end.getPlane(); + } + + static boolean shouldBlacklistDoorAfterWrongTraversal(WorldPoint start, WorldPoint end, WorldPoint fromWp, WorldPoint toWp) { + if (start == null || end == null || toWp == null) { + return false; + } + if (start.equals(end)) { + return false; + } + if (start.getPlane() != end.getPlane()) { + return true; + } + int moved = start.distanceTo2D(end); + if (moved < 3) { + return false; + } + int startTo = start.distanceTo2D(toWp); + int endTo = end.distanceTo2D(toWp); + if (endTo <= startTo + 1) { + return false; + } + if (fromWp == null || fromWp.getPlane() != end.getPlane()) { + return true; + } + int startFrom = start.distanceTo2D(fromWp); + int endFrom = end.distanceTo2D(fromWp); + return endFrom >= startFrom + 2; + } + + private static boolean movedAcrossInteractedObject(WorldPoint start, WorldPoint end, WorldPoint objectLoc) { + int startRelX = Integer.compare(start.getX(), objectLoc.getX()); + int endRelX = Integer.compare(end.getX(), objectLoc.getX()); + int startRelY = Integer.compare(start.getY(), objectLoc.getY()); + int endRelY = Integer.compare(end.getY(), objectLoc.getY()); + return startRelX != endRelX || startRelY != endRelY; } private static boolean isDoorComposition(ObjectComposition comp, List doorActions) { @@ -2253,23 +4272,858 @@ private static boolean isPointNearSegment(WorldPoint point, WorldPoint fromWp, W if (point == null || fromWp == null || toWp == null || point.getPlane() != fromWp.getPlane() || fromWp.getPlane() != toWp.getPlane()) { return false; } - - int x = fromWp.getX(); - int y = fromWp.getY(); - int steps = 0; - while (steps++ <= 64) { - if (point.distanceTo2D(new WorldPoint(x, y, fromWp.getPlane())) <= distance) { - return true; + + int x = fromWp.getX(); + int y = fromWp.getY(); + int steps = 0; + while (steps++ <= 64) { + if (point.distanceTo2D(new WorldPoint(x, y, fromWp.getPlane())) <= distance) { + return true; + } + if (x == toWp.getX() && y == toWp.getY()) { + return false; + } + x += Integer.signum(toWp.getX() - x); + y += Integer.signum(toWp.getY() - y); + } + return false; + } + + /** + * Door handling can include dialogue and waits; bound it so the walker cannot hang + * indefinitely on a bad interact. If the timeout elapses, return false so the main + * loop can continue (stall detection / replans). + */ + private static boolean handleDoorsWithTimeout(List path, int index, long timeoutMs) { + return handleDoorsWithTimeout(path, index, timeoutMs, null); + } + + private static boolean handleDoorsWithTimeout(List path, int index, long timeoutMs, + Map attemptedDoorEdgesThisPass) { + long start = System.currentTimeMillis(); + WorldPoint[] segment = resolveDoorSegment(path, index); + String edgeKey = segment != null && segment.length >= 2 && segment[0] != null && segment[1] != null + ? doorAttemptKey(null, segment[0], segment[1]) + : null; + WorldPoint playerBeforeAttempt = Rs2Player.getWorldLocation(); + if (!markDoorEdgeAttemptThisPass(attemptedDoorEdgesThisPass, segment, playerBeforeAttempt)) { + lastDoorEdgePassSkipAtMs = System.currentTimeMillis(); + WebWalkLog.spInfo("door_edge_pass_skip | idx={}", index); + return false; + } + boolean handled = handleDoors(path, index); + if (!handled) { + // Do not consume one-shot budget when no interaction happened; allow + // a later resolver in the same pass to attempt this edge. + if (attemptedDoorEdgesThisPass != null && edgeKey != null) { + attemptedDoorEdgesThisPass.remove(edgeKey); + } + return false; + } + WebWalkLog.tmark("door_interaction_done", System.currentTimeMillis() - start, currentTarget, playerBeforeAttempt, + "idx=" + index); + long remaining = timeoutMs - (System.currentTimeMillis() - start); + if (remaining <= 0) { + return true; + } + WorldPoint before = Rs2Player.getWorldLocation(); + int remainingInt = (int) Math.min(Integer.MAX_VALUE, remaining); + sleepUntil(() -> { + WorldPoint now = Rs2Player.getWorldLocation(); + if (before != null && now != null && !before.equals(now)) return true; + return Rs2Player.isMoving() || Rs2Dialogue.isInDialogue(); + }, remainingInt); + + if (segment != null && !isDoorEdgeResolved(segment[0], segment[1])) { + WebWalkLog.spInfo("door_edge_post_unresolved | idx={} from={} to={}", + index, compactWorldPoint(segment[0]), compactWorldPoint(segment[1])); + } else if (segment != null) { + WebWalkLog.tmark("door_edge_resolved", System.currentTimeMillis() - start, currentTarget, + Rs2Player.getWorldLocation(), + "from=" + compactWorldPoint(segment[0]) + " to=" + compactWorldPoint(segment[1])); + } + return true; + } + + private static WorldPoint[] resolveDoorSegment(List path, int index) { + if (path == null || index < 0 || index >= path.size() - 1) { + return null; + } + WorldPoint fromWp = path.get(index); + WorldPoint toWp = path.get(index + 1); + if (fromWp == null || toWp == null) { + return null; + } + boolean isInstance = Microbot.getClient() + .getTopLevelWorldView() + .getScene() + .isInstance(); + if (!isInstance) { + return new WorldPoint[] {fromWp, toWp}; + } + WorldPoint convertedFrom = Rs2WorldPoint.convertInstancedWorldPoint(fromWp); + WorldPoint convertedTo = Rs2WorldPoint.convertInstancedWorldPoint(toWp); + if (convertedFrom == null || convertedTo == null) { + return null; + } + return new WorldPoint[] {convertedFrom, convertedTo}; + } + + static boolean markDoorEdgeAttemptThisPass(Map attemptedDoorEdgesThisPass, + WorldPoint[] segment, + WorldPoint playerBeforeAttempt) { + if (attemptedDoorEdgesThisPass == null || segment == null || segment.length < 2 + || segment[0] == null || segment[1] == null) { + return true; + } + String edgeKey = doorAttemptKey(null, segment[0], segment[1]); + WorldPoint previousAttemptPos = attemptedDoorEdgesThisPass.get(edgeKey); + if (previousAttemptPos != null && playerBeforeAttempt != null + && previousAttemptPos.getPlane() == playerBeforeAttempt.getPlane() + && previousAttemptPos.distanceTo2D(playerBeforeAttempt) <= 1) { + return false; + } + attemptedDoorEdgesThisPass.put(edgeKey, playerBeforeAttempt); + return true; + } + + /** + * Last-resort door resolver for "tile unreachable near player" stalls. + * Scans a very small radius around the player for door-like wall/game objects + * and interacts with the best candidate action. + */ + private static boolean tryResolveNearbyDoorBlocker(WorldPoint playerLoc, int radiusTiles) { + if (playerLoc == null || radiusTiles <= 0) return false; + + TileObject best = null; + String bestAction = null; + int bestActionPri = Integer.MAX_VALUE; + int bestDist = Integer.MAX_VALUE; + int scannedWalls = 0; + int scannedGames = 0; + int candidates = 0; + + for (WallObject w : Rs2GameObject.getWallObjects(o -> true, playerLoc, radiusTiles)) { + if (w == null) continue; + scannedWalls++; + ObjectComposition comp = resolveCompositionForDoorProbe(w); + if (comp == null || isNullOrPlaceholderObjectName(comp.getName())) continue; + if (doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; + + String action = pickWalkDoorAction(comp); + boolean doorLike = isDoorLikeGameObjectName(comp.getName()) + || (action != null && doorActionPriorityIndex(action) < Integer.MAX_VALUE); + if (!doorLike) continue; + candidates++; + + // Allow empty-action doors: use default interact. + String actionFinal = action == null ? "" : action; + int dist = w.getWorldLocation() == null ? Integer.MAX_VALUE : w.getWorldLocation().distanceTo2D(playerLoc); + int pri = actionFinal.isEmpty() ? Integer.MAX_VALUE : doorActionPriorityIndex(actionFinal); + if (best == null || pri < bestActionPri || (pri == bestActionPri && dist < bestDist)) { + best = w; + bestAction = actionFinal; + bestActionPri = pri; + bestDist = dist; + } + } + + for (GameObject g : Rs2GameObject.getGameObjects(o -> true, playerLoc, radiusTiles)) { + if (g == null) continue; + scannedGames++; + ObjectComposition comp = resolveCompositionForDoorProbe(g); + if (comp == null || isNullOrPlaceholderObjectName(comp.getName())) continue; + if (doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; + + String action = pickWalkDoorAction(comp); + boolean doorLike = isDoorLikeGameObjectName(comp.getName()) + || (action != null && doorActionPriorityIndex(action) < Integer.MAX_VALUE); + if (!doorLike) continue; + candidates++; + + String actionFinal = action == null ? "" : action; + int dist = g.getWorldLocation() == null ? Integer.MAX_VALUE : g.getWorldLocation().distanceTo2D(playerLoc); + int pri = actionFinal.isEmpty() ? Integer.MAX_VALUE : doorActionPriorityIndex(actionFinal); + if (best == null || pri < bestActionPri || (pri == bestActionPri && dist < bestDist)) { + best = g; + bestAction = actionFinal; + bestActionPri = pri; + bestDist = dist; + } + } + + if (best == null || bestAction == null) { + log.info("[Walker] fallback door-scan: no candidates (radius={} player={} scannedWalls={} scannedGames={} candidates={})", + radiusTiles, playerLoc, scannedWalls, scannedGames, candidates); + return false; + } + + WorldPoint before = Rs2Player.getWorldLocation(); + log.info("[Walker] fallback door-scan: action={} at {}", bestAction.isEmpty() ? "" : bestAction, best.getWorldLocation()); + if (bestAction.isEmpty()) { + Rs2GameObject.interact(best); + } else { + Rs2GameObject.interact(best, bestAction); + } + Rs2Player.waitForWalking(); + sleepUntil(() -> { + WorldPoint now = Rs2Player.getWorldLocation(); + if (before != null && now != null && !before.equals(now)) return true; + return Rs2Player.isMoving() || Rs2Dialogue.isInDialogue(); + }, 1500); + return true; + } + + /** + * LOS-based door resolution: when a path says "go through that door" but local reachability + * says "unreachable", we may be a few tiles away from the actual door object. Scan door-like + * objects in a wider radius and require line-of-sight from the player, then interact with the + * best candidate (closest to the upcoming path tiles). + */ + private static boolean tryResolveDoorBlockerLineOfSight(WorldPoint playerLoc, List path, int startIdx, int radiusTiles) { + if (playerLoc == null || path == null || path.size() < 2) return false; + if (startIdx < 0 || startIdx >= path.size()) return false; + + TileObject best = null; + String bestAction = null; + int bestScore = Integer.MAX_VALUE; + + // Look a little ahead along the path to bias toward the intended door edge. + int endIdx = Math.min(path.size() - 1, startIdx + 10); + + for (WallObject w : Rs2GameObject.getWallObjects(o -> true, playerLoc, radiusTiles)) { + if (w == null) continue; + if (!Rs2GameObject.hasLineOfSight(playerLoc, w)) continue; + + ObjectComposition comp = resolveCompositionForDoorProbe(w); + if (comp == null || isNullOrPlaceholderObjectName(comp.getName())) continue; + if (doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; + + String action = pickWalkDoorAction(comp); + + boolean doorLike = isDoorLikeGameObjectName(comp.getName()) + || (action != null && doorActionPriorityIndex(action) < Integer.MAX_VALUE); + if (!doorLike) continue; + + String actionFinal = action == null ? "" : action; + + // Score by proximity to upcoming path tiles (lower is better). + int score = Integer.MAX_VALUE; + WorldPoint objWp = w.getWorldLocation(); + if (objWp != null) { + for (int j = startIdx; j <= endIdx; j++) { + WorldPoint pj = path.get(j); + if (pj == null) continue; + score = Math.min(score, objWp.distanceTo2D(pj)); + } + // Tie-break toward closer objects. + score = score * 10 + objWp.distanceTo2D(playerLoc); + } + + if (best == null || score < bestScore) { + best = w; + bestAction = actionFinal; + bestScore = score; + } + } + + for (GameObject g : Rs2GameObject.getGameObjects(o -> true, playerLoc, radiusTiles)) { + if (g == null) continue; + if (!Rs2GameObject.hasLineOfSight(playerLoc, g)) continue; + + ObjectComposition comp = resolveCompositionForDoorProbe(g); + if (comp == null || isNullOrPlaceholderObjectName(comp.getName())) continue; + if (doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; + + String action = pickWalkDoorAction(comp); + + boolean doorLike = isDoorLikeGameObjectName(comp.getName()) + || (action != null && doorActionPriorityIndex(action) < Integer.MAX_VALUE); + if (!doorLike) continue; + + String actionFinal = action == null ? "" : action; + + int score = Integer.MAX_VALUE; + WorldPoint objWp = g.getWorldLocation(); + if (objWp != null) { + for (int j = startIdx; j <= endIdx; j++) { + WorldPoint pj = path.get(j); + if (pj == null) continue; + score = Math.min(score, objWp.distanceTo2D(pj)); + } + score = score * 10 + objWp.distanceTo2D(playerLoc); + } + + if (best == null || score < bestScore) { + best = g; + bestAction = actionFinal; + bestScore = score; + } + } + + if (best == null) { + log.info("[Walker] LOS door-scan: no candidates (radius={} player={} idx={}/{})", radiusTiles, playerLoc, startIdx, path.size()); + return false; + } + + log.info("[Walker] LOS door-scan: score={} action={} at {}", bestScore, (bestAction == null || bestAction.isEmpty()) ? "" : bestAction, best.getWorldLocation()); + if (bestAction == null || bestAction.isEmpty()) { + Rs2GameObject.interact(best); + } else { + Rs2GameObject.interact(best, bestAction); + } + Rs2Player.waitForWalking(); + return true; + } + + private static boolean hasLineOfSightBetween(WorldPoint a, WorldPoint b) { + if (a == null || b == null) return false; + return a.toWorldArea().hasLineOfSightTo( + Microbot.getClient().getTopLevelWorldView(), + b.toWorldArea()); + } + + /** + * Path-adjacent door resolver: only interact with objects that are on/adjacent to + * a blocked path edge near the player. Prevents clicking random "door-like" junk + * that isn't the blocker. + */ + private static boolean tryResolvePathAdjacentBlocker(WorldPoint playerLoc, List path, int startIdx, int scanAheadEdges, int radiusTiles) { + if (playerLoc == null || path == null || path.size() < 2) return false; + if (startIdx < 0) startIdx = 0; + if (startIdx >= path.size() - 1) return false; + + int endEdgeIdx = Math.min(path.size() - 2, startIdx + Math.max(0, scanAheadEdges)); + final int pathEdgeDoorMaxDist = 2; + Map byIdentity = new LinkedHashMap<>(); + + for (int edgeIdx = startIdx; edgeIdx <= endEdgeIdx; edgeIdx++) { + WorldPoint from = path.get(edgeIdx); + WorldPoint to = path.get(edgeIdx + 1); + if (from == null || to == null) continue; + + // Only edges "near enough" to matter. + int dFrom = from.distanceTo2D(playerLoc); + int dTo = to.distanceTo2D(playerLoc); + if (Math.min(dFrom, dTo) > radiusTiles) continue; + + // Only treat as blocker if the next tile is unreachable OR the edge has no LOS. + boolean blocked = Rs2DoorAheadResolver.isPathEdgeBlocked(from, to); + if (!blocked) continue; + + // Scan candidates in loaded scene radius around player. + for (WallObject w : Rs2GameObject.getWallObjects(o -> true, playerLoc, radiusTiles)) { + if (w == null) continue; + WorldPoint objWp = w.getWorldLocation(); + if (objWp == null) continue; + if (!Rs2GameObject.hasLineOfSight(playerLoc, w)) continue; + if (wasStationaryDoorOpenedRecently(objWp)) continue; + + if (objWp.distanceTo2D(from) > pathEdgeDoorMaxDist && objWp.distanceTo2D(to) > pathEdgeDoorMaxDist) { + continue; + } + if (!isDoorOnSegment(w, from, to)) { + continue; + } + + ObjectComposition comp = resolveCompositionForDoorProbe(w); + if (comp == null || isNullOrPlaceholderObjectName(comp.getName())) continue; + if (doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; + + String action = pickWalkDoorAction(comp); + boolean doorLike = isDoorLikeGameObjectName(comp.getName()) + || (action != null && doorActionPriorityIndex(action) < Integer.MAX_VALUE); + if (!doorLike) continue; + + String actionFinal = action == null ? "" : action; + + int edgeDist = Math.min(objWp.distanceTo2D(from), objWp.distanceTo2D(to)); + int pri = actionFinal.isEmpty() ? Integer.MAX_VALUE : doorActionPriorityIndex(actionFinal); + mergePathAdjCandidate( + byIdentity, + w, + objWp, + actionFinal, + pri, + edgeIdx, + from, + to, + edgeDist); + } + + for (GameObject g : Rs2GameObject.getGameObjects(o -> true, playerLoc, radiusTiles)) { + if (g == null) continue; + WorldPoint objWp = g.getWorldLocation(); + if (objWp == null) continue; + if (!Rs2GameObject.hasLineOfSight(playerLoc, g)) continue; + if (wasStationaryDoorOpenedRecently(objWp)) continue; + if (objWp.distanceTo2D(from) > pathEdgeDoorMaxDist && objWp.distanceTo2D(to) > pathEdgeDoorMaxDist) { + continue; + } + if (!isDoorOnSegment(g, from, to)) { + continue; + } + + ObjectComposition comp = resolveCompositionForDoorProbe(g); + if (comp == null || isNullOrPlaceholderObjectName(comp.getName())) continue; + if (doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; + + String action = pickWalkDoorAction(comp); + boolean doorLike = isDoorLikeGameObjectName(comp.getName()) + || (action != null && doorActionPriorityIndex(action) < Integer.MAX_VALUE); + if (!doorLike) continue; + + String actionFinal = action == null ? "" : action; + + int edgeDist = Math.min(objWp.distanceTo2D(from), objWp.distanceTo2D(to)); + int pri = actionFinal.isEmpty() ? Integer.MAX_VALUE : doorActionPriorityIndex(actionFinal); + mergePathAdjCandidate( + byIdentity, + g, + objWp, + actionFinal, + pri, + edgeIdx, + from, + to, + edgeDist); + } + } + if (byIdentity.isEmpty()) { + log.info("[Walker] path-adj blocker-scan: no candidates (radius={} idx={}/{})", radiusTiles, startIdx, path.size()); + return false; + } + + List components = buildPathAdjDoorComponents(byIdentity.values(), startIdx, playerLoc); + if (components.isEmpty()) { + log.info("[Walker] path-adj blocker-scan: no components (radius={} idx={}/{})", radiusTiles, startIdx, path.size()); + return false; + } + PathAdjDoorComponent bestComponent = components.stream() + .min(Comparator.comparingInt(c -> c.score)) + .orElse(null); + if (bestComponent == null || bestComponent.best == null) { + log.info("[Walker] path-adj blocker-scan: no component winner (radius={} idx={}/{})", radiusTiles, startIdx, path.size()); + return false; + } + PathAdjDoorCandidate chosen = bestComponent.best; + TileObject best = chosen.object; + String bestAction = chosen.action; + int bestScore = bestComponent.score; + WorldPoint bestFrom = chosen.from; + WorldPoint bestTo = chosen.to; + log.info("[Walker] path-adj blocker-scan: score={} action={} at {}", bestScore, (bestAction == null || bestAction.isEmpty()) ? "" : bestAction, chosen.location); + WorldPoint bestLoc = chosen.location; + if (shouldThrottleDoorAttempt(bestLoc, bestFrom, bestTo)) { + WebWalkLog.spInfo("door_attempt_throttled | mode=path-adj probe={} from={} to={}", + compactWorldPoint(bestLoc), compactWorldPoint(bestFrom), compactWorldPoint(bestTo)); + for (WorldPoint loc : bestComponent.locations) { + if (loc != null) { + markStationaryDoorOpened(loc); + } + } + return false; + } + if (shouldThrottleGlobalDoorInteraction()) { + WebWalkLog.spInfo("door_global_await | mode=path-adj probe={} from={} to={}", + compactWorldPoint(bestLoc), compactWorldPoint(bestFrom), compactWorldPoint(bestTo)); + return false; + } + markDoorAttempt(bestLoc, bestFrom, bestTo); + markGlobalDoorInteractionCooldown(); + WorldPoint posBefore = Rs2Player.getWorldLocation(); + boolean interacted; + try { + if (bestAction == null || bestAction.isEmpty()) { + interacted = Rs2GameObject.interact(best); + } else { + interacted = Rs2GameObject.interact(best, bestAction); + } + } catch (Exception ex) { + WebWalkLog.spInfo("door_interact_exception | mode=path-adj probe={} from={} to={} ex={}", + compactWorldPoint(bestLoc), compactWorldPoint(bestFrom), compactWorldPoint(bestTo), ex.getClass().getSimpleName()); + for (WorldPoint loc : bestComponent.locations) { + if (loc != null) { + markStationaryDoorOpened(loc); + } + } + return false; + } + if (!interacted) { + WebWalkLog.spInfo("door_interact_failed | mode=path-adj probe={} from={} to={}", + compactWorldPoint(bestLoc), compactWorldPoint(bestFrom), compactWorldPoint(bestTo)); + for (WorldPoint loc : bestComponent.locations) { + if (loc != null) { + markStationaryDoorOpened(loc); + } + } + return false; + } + markDoorInteractionSettling(); + waitForDoorInteractionProgress(bestFrom, bestTo); + WorldPoint posAfter = Rs2Player.getWorldLocation(); + boolean traversed = didTraverseInteractedDoor(posBefore, posAfter, bestLoc, bestFrom, bestTo); + if (traversed) { + for (WorldPoint loc : bestComponent.locations) { + if (loc != null) { + markStationaryDoorOpened(loc); + } + } + return true; + } + if (bestLoc != null && shouldBlacklistDoorAfterWrongTraversal(posBefore, posAfter, bestFrom, bestTo)) { + sessionBlacklistedDoors.add(bestLoc); + log.warn("[Walker] Blacklisting door after wrong traversal: door={} from={} to={} before={} after={}", + bestLoc, bestFrom, bestTo, posBefore, posAfter); + } + for (WorldPoint loc : bestComponent.locations) { + if (loc != null) { + markStationaryDoorOpened(loc); + } + } + log.debug("[Walker] path-adj blocker-scan interact did not traverse (at={} from={} to={} before={} after={})", + bestLoc, bestFrom, bestTo, posBefore, posAfter); + // Interaction was sent and awaited; yield this pass so unreachable recovery + // does not immediately fire a minimap click while door traversal settles. + return true; + } + + private static void mergePathAdjCandidate( + Map byIdentity, + TileObject object, + WorldPoint location, + String action, + int actionPriority, + int edgeIdx, + WorldPoint from, + WorldPoint to, + int edgeDist) { + if (object == null || location == null) { + return; + } + String identity = object.getClass().getSimpleName() + "|" + object.getId() + "|" + + location.getX() + "," + location.getY() + "," + location.getPlane(); + String familyKey = normalizePathAdjFamilyKey(object, action); + PathAdjDoorCandidate incoming = new PathAdjDoorCandidate( + object, + location, + action == null ? "" : action, + actionPriority, + edgeIdx, + from, + to, + edgeDist, + familyKey); + PathAdjDoorCandidate existing = byIdentity.get(identity); + if (existing == null) { + byIdentity.put(identity, incoming); + return; + } + if (incoming.edgeIdx < existing.edgeIdx + || (incoming.edgeIdx == existing.edgeIdx && incoming.edgeDist < existing.edgeDist)) { + byIdentity.put(identity, incoming); + } + } + + private static String normalizePathAdjFamilyKey(TileObject object, String action) { + ObjectComposition comp = resolveCompositionForDoorProbe(object); + String name = comp != null && comp.getName() != null ? comp.getName().toLowerCase(Locale.ROOT).trim() : "unknown"; + String act = action == null ? "" : action.toLowerCase(Locale.ROOT).trim(); + WorldPoint loc = object != null ? object.getWorldLocation() : null; + int plane = loc != null ? loc.getPlane() : -1; + int objectId = object != null ? object.getId() : -1; + int idRangeLow = objectId >= 0 ? objectId - 1 : -1; + int idRangeHigh = objectId >= 0 ? objectId + 1 : -1; + return name + "|" + act + "|p" + plane + "|id=" + idRangeLow + "-" + idRangeHigh; + } + + private static boolean arePathAdjFamiliesCompatible(String a, String b) { + if (Objects.equals(a, b)) { + return true; + } + if (a == null || b == null) { + return false; + } + int aIdTag = a.indexOf("|id="); + int bIdTag = b.indexOf("|id="); + if (aIdTag <= 0 || bIdTag <= 0) { + return false; + } + String aBase = a.substring(0, aIdTag); + String bBase = b.substring(0, bIdTag); + if (!Objects.equals(aBase, bBase)) { + return false; + } + int[] aRange = parsePathAdjIdRange(a.substring(aIdTag + 4)); + int[] bRange = parsePathAdjIdRange(b.substring(bIdTag + 4)); + if (aRange == null || bRange == null) { + return false; + } + return Math.max(aRange[0], bRange[0]) <= Math.min(aRange[1], bRange[1]); + } + + private static int[] parsePathAdjIdRange(String range) { + if (range == null || range.isEmpty()) { + return null; + } + int sep = range.indexOf('-'); + if (sep <= 0 || sep >= range.length() - 1) { + return null; + } + try { + int low = Integer.parseInt(range.substring(0, sep)); + int high = Integer.parseInt(range.substring(sep + 1)); + if (high < low) { + return null; + } + return new int[] {low, high}; + } catch (NumberFormatException ignored) { + return null; + } + } + + private static void markNearbyDoorFamilyOpened(TileObject originObject, WorldPoint originLocation, String action, int radiusTiles) { + if (originObject == null || originLocation == null || radiusTiles <= 0) { + return; + } + String familyKey = normalizePathAdjFamilyKey(originObject, action); + if (familyKey == null || familyKey.isEmpty()) { + markStationaryDoorOpened(originLocation); + return; + } + markStationaryDoorOpened(originLocation); + for (WallObject wall : Rs2GameObject.getWallObjects(o -> true, originLocation, radiusTiles)) { + if (wall == null || wall.getWorldLocation() == null) { + continue; + } + if (wall.getWorldLocation().getPlane() != originLocation.getPlane()) { + continue; + } + ObjectComposition comp = resolveCompositionForDoorProbe(wall); + String neighborFamily = normalizePathAdjFamilyKey(wall, comp == null ? null : pickWalkDoorAction(comp)); + if (arePathAdjFamiliesCompatible(familyKey, neighborFamily)) { + markStationaryDoorOpened(wall.getWorldLocation()); + } + } + for (GameObject game : Rs2GameObject.getGameObjects(o -> true, originLocation, radiusTiles)) { + if (game == null || game.getWorldLocation() == null) { + continue; } - if (x == toWp.getX() && y == toWp.getY()) { - return false; + if (game.getWorldLocation().getPlane() != originLocation.getPlane()) { + continue; + } + ObjectComposition comp = resolveCompositionForDoorProbe(game); + String neighborFamily = normalizePathAdjFamilyKey(game, comp == null ? null : pickWalkDoorAction(comp)); + if (arePathAdjFamiliesCompatible(familyKey, neighborFamily)) { + markStationaryDoorOpened(game.getWorldLocation()); } - x += Integer.signum(toWp.getX() - x); - y += Integer.signum(toWp.getY() - y); } - return false; } + private static List buildPathAdjDoorComponents( + Collection candidates, + int startIdx, + WorldPoint playerLoc) { + if (candidates == null || candidates.isEmpty()) { + return Collections.emptyList(); + } + List list = new ArrayList<>(candidates); + boolean[] visited = new boolean[list.size()]; + List components = new ArrayList<>(); + for (int i = 0; i < list.size(); i++) { + if (visited[i]) { + continue; + } + PathAdjDoorCandidate seed = list.get(i); + visited[i] = true; + java.util.Deque queue = new ArrayDeque<>(); + queue.add(i); + List members = new ArrayList<>(); + members.add(seed); + while (!queue.isEmpty()) { + int idx = queue.removeFirst(); + PathAdjDoorCandidate a = list.get(idx); + for (int j = 0; j < list.size(); j++) { + if (visited[j]) { + continue; + } + PathAdjDoorCandidate b = list.get(j); + if (!arePathAdjFamiliesCompatible(a.familyKey, b.familyKey)) { + continue; + } + if (a.location == null || b.location == null) { + continue; + } + int tileGap = a.location.distanceTo2D(b.location); + int edgeGap = Math.abs(a.edgeIdx - b.edgeIdx); + if (tileGap > PATH_ADJ_COMPONENT_LINK_MAX_TILE_GAP + && edgeGap > PATH_ADJ_COMPONENT_LINK_MAX_EDGE_GAP) { + continue; + } + visited[j] = true; + queue.addLast(j); + members.add(b); + } + } + PathAdjDoorCandidate best = null; + int earliestEdge = Integer.MAX_VALUE; + int bestLocalScore = Integer.MAX_VALUE; + Set locs = new LinkedHashSet<>(); + for (PathAdjDoorCandidate c : members) { + locs.add(c.location); + earliestEdge = Math.min(earliestEdge, c.edgeIdx); + int pri = c.actionPriority == Integer.MAX_VALUE ? 100 : c.actionPriority; + int localScore = c.edgeDist * 100 + pri * 10 + + (playerLoc != null && c.location != null ? c.location.distanceTo2D(playerLoc) : 0); + if (best == null || localScore < bestLocalScore) { + best = c; + bestLocalScore = localScore; + } + } + int edgeOffset = Math.max(0, earliestEdge - startIdx); + int componentScore = edgeOffset * 1000 + bestLocalScore; + components.add(new PathAdjDoorComponent(best, componentScore, locs)); + } + return components; + } + + private static final class PathAdjDoorCandidate { + private final TileObject object; + private final WorldPoint location; + private final String action; + private final int actionPriority; + private final int edgeIdx; + private final WorldPoint from; + private final WorldPoint to; + private final int edgeDist; + private final String familyKey; + + private PathAdjDoorCandidate(TileObject object, WorldPoint location, String action, int actionPriority, + int edgeIdx, WorldPoint from, WorldPoint to, int edgeDist, String familyKey) { + this.object = object; + this.location = location; + this.action = action; + this.actionPriority = actionPriority; + this.edgeIdx = edgeIdx; + this.from = from; + this.to = to; + this.edgeDist = edgeDist; + this.familyKey = familyKey; + } + } + + private static final class PathAdjDoorComponent { + private final PathAdjDoorCandidate best; + private final int score; + private final Set locations; + + private PathAdjDoorComponent(PathAdjDoorCandidate best, int score, Set locations) { + this.best = best; + this.score = score; + this.locations = locations; + } + } + + /** + * Scan a few path indices near the player (<= radius tiles) and attempt to resolve + * any door/gate blocks before issuing further minimap clicks. + */ + private static boolean tryHandleNearbyDoorsWithTimeout(List path, int startIdx, int radiusTiles, long timeoutMs) { + if (path == null || path.isEmpty() || startIdx < 0) return false; + final WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (playerLoc == null) return false; + + int start = Math.min(startIdx, path.size() - 2); + for (int j = start; j < path.size() - 1; j++) { + WorldPoint wp = path.get(j); + if (wp == null) continue; + if (wp.getPlane() != playerLoc.getPlane()) break; + if (wp.distanceTo2D(playerLoc) > radiusTiles) { + // Path is ordered; once we're beyond radius, later indices will likely be further. + break; + } + if (handleDoorsWithTimeout(path, j, timeoutMs)) { + return true; + } + } + return false; + } + + /** + * Predict blockers on the path by probing the next few path edges for door/gate-like + * objects (including diagonal corners). If any probe tile contains a door-like object + * within {@code radiusTiles} of the player, run door handling with a bounded wait. + */ + private static boolean tryHandleBlockingPathObjectsWithTimeout( + List path, + int startIdx, + int radiusTiles, + int maxEdges, + long timeoutMs, + Map attemptedDoorEdgesThisPass) + { + if (path == null || path.size() < 2) return false; + if (startIdx < 0) return false; + final WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (playerLoc == null) return false; + + int start = Math.min(startIdx, path.size() - 2); + int edgesChecked = 0; + for (int j = start; j < path.size() - 1 && edgesChecked < maxEdges; j++, edgesChecked++) { + WorldPoint from = path.get(j); + WorldPoint to = path.get(j + 1); + if (from == null || to == null) continue; + if (from.getPlane() != playerLoc.getPlane() || to.getPlane() != playerLoc.getPlane()) break; + + // Only bother probing edges near the player; far edges are not loaded in scene. + if (from.distanceTo2D(playerLoc) > radiusTiles && to.distanceTo2D(playerLoc) > radiusTiles) { + break; + } + + boolean diagonal = from.getX() != to.getX() && from.getY() != to.getY(); + List probes = new ArrayList<>(); + probes.add(from); + probes.add(to); + if (diagonal) { + probes.add(new WorldPoint(to.getX(), from.getY(), from.getPlane())); + probes.add(new WorldPoint(from.getX(), to.getY(), from.getPlane())); + } + + for (WorldPoint probe : probes) { + if (probe == null) continue; + if (probe.getPlane() != playerLoc.getPlane()) continue; + if (probe.distanceTo2D(playerLoc) > radiusTiles) continue; + + WallObject wall = Rs2GameObject.getWallObject(o -> o.getWorldLocation().equals(probe), probe, 3); + TileObject object = (wall != null) + ? wall + : Rs2GameObject.getGameObject(o -> o.getWorldLocation().equals(probe), probe, 3); + if (object == null) continue; + + ObjectComposition comp = resolveCompositionForDoorProbe(object); + if (comp == null || isNullOrPlaceholderObjectName(comp.getName())) continue; + if (doorCompositionSpecifiesOnlyCloseOrShut(comp)) continue; + + // Gate by "door-like" name or by having a known door-like action. + String action = Arrays.stream(comp.getActions()) + .filter(Objects::nonNull) + .filter(act -> !isDoorCloseOrShutAction(act)) + .filter(act -> doorActionPriorityIndex(act) < Integer.MAX_VALUE) + .min(Comparator.comparingInt(Rs2Walker::doorActionPriorityIndex)) + .orElse(null); + boolean doorLike = isDoorLikeGameObjectName(comp.getName()) || action != null; + if (!doorLike) continue; + + // Found a likely blocker on-path: hand off to existing door handler (which + // includes quest-lock detection, blacklisting, and recalculation). + if (handleDoorsWithTimeout(path, j, timeoutMs, attemptedDoorEdgesThisPass)) { + return true; + } + } + } + return false; + } + private static boolean handleDoorException(TileObject object, String action) { if (isInStrongholdOfSecurity()) { return handleStrongholdOfSecurityAnswer(object, action); @@ -2415,28 +5269,57 @@ public static int getClosestTileIndex(List path) { * Force the walker to recalculate path */ public static void recalculatePath() { - WorldPoint _currentTarget = currentTarget; - Rs2Walker.setTarget(null); - Rs2Walker.setTarget(_currentTarget); + WorldPoint goal = currentTarget; + if (goal == null) { + return; + } + // Must not call setTarget(null)+setTarget(goal): that briefly clears {@link #currentTarget}, + // and processWalk on another thread treats null as cancel (isWalkCancelled). + Rs2WalkerLifecycleRuntime.applyWalkerDestination(goal); } /** - * @param target + * Updates world-map marker and restarts pathfinding for {@code target}. Does not assign + * {@link #currentTarget}; callers set it when appropriate. + */ + private static void applyWalkerDestination(WorldPoint target) { + Rs2WalkerLifecycleRuntime.applyWalkerDestination(target); + } + + /** + * @param target destination, or {@code null} to clear (prefer {@link #clearWalkingRoute(String)} for observability) */ public static void setTarget(WorldPoint target) { + setTarget(target, null); + } + + /** + * @param clearReasonWhenNull logged when {@code target} is {@code null}; omit only from tests or legacy paths. + * Clearing ({@code target == null}) runs without a {@link net.runelite.client.Client} + * (teardown-safe). Non-null destinations still require a live client and login/player checks. + */ + public static void setTarget(WorldPoint target, String clearReasonWhenNull) { if (target != null && !Microbot.isLoggedIn()) { log.warn("Unable to set target: not logged in"); return; } - Player localPlayer = Microbot.getClient().getLocalPlayer(); - if (!ShortestPathPlugin.isStartPointSet() && localPlayer == null) { - log.warn("Start point is not set and player is null"); - return; + if (target != null) { + Client client = Microbot.getClient(); + if (client == null) { + log.warn("Unable to set target: client unavailable"); + return; + } + Player localPlayer = client.getLocalPlayer(); + if (!ShortestPathPlugin.isStartPointSet() && localPlayer == null) { + log.warn("Start point is not set and player is null"); + return; + } } currentTarget = target; if (target == null) { + logRouteClear(clearReasonWhenNull); synchronized (ShortestPathPlugin.getPathfinderMutex()) { final Pathfinder pathfinder = ShortestPathPlugin.getPathfinder(); if (pathfinder != null) { @@ -2450,54 +5333,16 @@ public static void setTarget(WorldPoint target) { ShortestPathPlugin.setPathfinder(null); } - Microbot.getWorldMapPointManager().remove(ShortestPathPlugin.getMarker()); + WorldMapPointManager wmm = Microbot.getWorldMapPointManager(); + if (wmm != null) { + wmm.remove(ShortestPathPlugin.getMarker()); + } else if (Rs2LogRateLimit.once(WORLD_MAP_REMOVE_NULL_LOGGED)) { + log.debug("[Walker] WorldMapPointManager null during route clear — marker may linger until teardown"); + } ShortestPathPlugin.setMarker(null); ShortestPathPlugin.setStartPointSet(false); } else { - Microbot.getWorldMapPointManager().removeIf(x -> x == ShortestPathPlugin.getMarker()); - ShortestPathPlugin.setMarker(new WorldMapPoint(target, ShortestPathPlugin.MARKER_IMAGE)); - ShortestPathPlugin.getMarker().setName("Target"); - ShortestPathPlugin.getMarker().setTarget(ShortestPathPlugin.getMarker().getWorldPoint()); - ShortestPathPlugin.getMarker().setJumpOnClick(true); - Microbot.getWorldMapPointManager().add(ShortestPathPlugin.getMarker()); - - WorldPoint start; - if (Microbot.getClient().getTopLevelWorldView().isInstance()) { - LocalPoint localLoc = Rs2Player.getLocalLocation(); - start = localLoc != null - ? WorldPoint.fromLocalInstance(Microbot.getClient(), localLoc) - : null; - if (start == null) { - log.warn("[Walker] setTarget: instance localPoint conversion returned null (localLoc={} target={}) — falling back to raw world location", - localLoc, target); - start = Rs2Player.getWorldLocation(); - } - } else { - start = Rs2Player.getWorldLocation(); - } - // POH fix: when inside a POH instance, the raw instance-template tile doesn't match - // any registered POH transport origin (PohPanel registers them keyed to the exit - // portal tile). Remap the pathfinder start to the configured exit portal so the - // pathfinder can consider all POH teleports as step 0. - if (Microbot.getClient().getTopLevelWorldView().isInstance()) { - WorldPoint exitPortal = net.runelite.client.plugins.microbot.shortestpath.PohPanel.getExitPortalTile(); - if (exitPortal != null) { - Microbot.log("[Walker] In POH instance — remapping pathfinder start " + start - + " -> exit portal " + exitPortal); - start = exitPortal; - } - } - ShortestPathPlugin.setLastLocation(start); - final Pathfinder pathfinder = ShortestPathPlugin.getPathfinder(); - if (ShortestPathPlugin.isStartPointSet() && pathfinder != null) { - start = pathfinder.getStart(); - } - if (Microbot.getClient().isClientThread()) { - final WorldPoint _start = start; - Microbot.getClientThread().runOnSeperateThread(() -> restartPathfinding(_start, target)); - } else { - restartPathfinding(start, target); - } + applyWalkerDestination(target); } } @@ -2507,11 +5352,16 @@ private static void restoreTargetMarker(WorldPoint target) { } try { + WorldMapPointManager wmm = Microbot.getWorldMapPointManager(); + if (wmm == null) { + log.debug("[Walker] Cannot restore marker: WorldMapPointManager unavailable"); + return; + } ShortestPathPlugin.setMarker(new WorldMapPoint(target, ShortestPathPlugin.MARKER_IMAGE)); ShortestPathPlugin.getMarker().setName("Target"); ShortestPathPlugin.getMarker().setTarget(ShortestPathPlugin.getMarker().getWorldPoint()); ShortestPathPlugin.getMarker().setJumpOnClick(true); - Microbot.getWorldMapPointManager().add(ShortestPathPlugin.getMarker()); + wmm.add(ShortestPathPlugin.getMarker()); log.info("[Walker] Restored missing path target marker at {}", target); } catch (Exception ex) { log.debug("[Walker] Failed to restore target marker at {}", target, ex); @@ -2523,47 +5373,11 @@ private static void restoreTargetMarker(WorldPoint target) { * @param end */ public static boolean restartPathfinding(WorldPoint start, WorldPoint end) { - return restartPathfinding(start, Set.of(end)); + return Rs2WalkerLifecycleRuntime.restartPathfinding(start, end); } public static boolean restartPathfinding(WorldPoint start, Set ends) { - if (Microbot.getClient().isClientThread()) { - return false; - } - - Pathfinder pathfinder = ShortestPathPlugin.getPathfinder(); - if (pathfinder != null) { - pathfinder.cancel(); - if (ShortestPathPlugin.getPathfinderFuture() != null) { - ShortestPathPlugin.getPathfinderFuture().cancel(true); - } - } - - if (ShortestPathPlugin.getPathfindingExecutor() == null) { - ThreadFactory shortestPathNaming = new ThreadFactoryBuilder().setNameFormat("shortest-path-%d").build(); - ShortestPathPlugin.setPathfindingExecutor(Executors.newSingleThreadExecutor(shortestPathNaming)); - } - - ShortestPathPlugin.getPathfinderConfig().refresh(); - if (Rs2Player.isInCave()) { - pathfinder = new Pathfinder(ShortestPathPlugin.getPathfinderConfig(), start, ends); - pathfinder.run(); - ShortestPathPlugin.getPathfinderConfig().setIgnoreTeleportAndItems(true); - Pathfinder pathfinderWithoutTeleports = new Pathfinder(ShortestPathPlugin.getPathfinderConfig(), start, ends); - pathfinderWithoutTeleports.run(); - var lastPath = pathfinderWithoutTeleports.getPath().get(pathfinderWithoutTeleports.getPath().size()-1); - var pathWithoutTeleportsIsReachable = lastPath.distanceTo(ends.stream().findFirst().orElse(lastPath)) <= config.reachedDistance(); - if (pathWithoutTeleportsIsReachable && pathfinder.getPath().size() >= pathfinderWithoutTeleports.getPath().size()) { - ShortestPathPlugin.setPathfinder(pathfinderWithoutTeleports); - } else { - ShortestPathPlugin.setPathfinder(pathfinder); - } - ShortestPathPlugin.getPathfinderConfig().setIgnoreTeleportAndItems(false); - } else { - ShortestPathPlugin.setPathfinder(new Pathfinder(ShortestPathPlugin.getPathfinderConfig(), start, ends)); - ShortestPathPlugin.setPathfinderFuture(ShortestPathPlugin.getPathfindingExecutor().submit(ShortestPathPlugin.getPathfinder())); - } - return true; + return Rs2WalkerLifecycleRuntime.restartPathfinding(start, ends); } /** @@ -2636,7 +5450,9 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i log.debug("[Walker] Considering transport: {} (type={}, origin={}, wpCount={})", transport.getDisplayInfo(), transport.getType(), transport.getOrigin(), worldPointCollections.size()); for (WorldPoint origin : worldPointCollections) { - if (!inPohInstance && transport.getOrigin() != null && Rs2Player.getWorldLocation().getPlane() != transport.getOrigin().getPlane()) { + WorldPoint plOriginLoop = Rs2Player.getWorldLocation(); + if (!inPohInstance && transport.getOrigin() != null && plOriginLoop != null + && plOriginLoop.getPlane() != transport.getOrigin().getPlane()) { continue; } @@ -2645,15 +5461,26 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i log.debug("[Walker] skip {}: destination {} not in path", transport.getDisplayInfo(), transport.getDestination()); continue; } - if (TransportType.isTeleport(transport.getType()) && Rs2Player.getWorldLocation().distanceTo(transport.getDestination()) < 3) { - log.debug("[Walker] skip {}: already near destination", transport.getDisplayInfo()); - continue; + // QUETZAL is not {@link TransportType#isTeleport} — without this, stall/off-path recalc can re-open the map and + // click the same landing repeatedly while already there (no movement → infinite stall loop). + if (transport.getType() == TransportType.QUETZAL) { + if (isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET)) { + log.debug("[Walker] skip {}: already within {} tiles of Quetzal destination {}", + transport.getDisplayInfo(), OFFSET, transport.getDestination()); + continue; + } + } + if (TransportType.isTeleport(transport.getType(), transport.getOrigin())) { + if (isPlayerWithinChebyshevOf(transport.getDestination(), TELEPORT_NEAR_SKIP_CHEBYSHEV)) { + log.debug("[Walker] skip {}: already near destination", transport.getDisplayInfo()); + continue; + } } // Pre-compute origin/destination indices once per transport (not per inner iteration) int precomputedIndexOfOrigin = -1; int precomputedIndexOfDest = -1; - if (!TransportType.isTeleport(transport.getType())) { + if (!TransportType.isTeleport(transport.getType(), transport.getOrigin())) { Integer originIdx = pathFirstIndex.get(transport.getOrigin()); Integer destIdx = pathFirstIndex.get(transport.getDestination()); precomputedIndexOfOrigin = originIdx != null ? originIdx : -1; @@ -2669,7 +5496,12 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i } for (int i = indexOfStartPoint; i < path.size(); i++) { - if (!inPohInstance && origin != null && origin.getPlane() != Rs2Player.getWorldLocation().getPlane()) { + WorldPoint plPathLoop = Rs2Player.getWorldLocation(); + if (plPathLoop == null) { + // Cannot verify plane / dispatch — do not burn remaining path indices this tick. + break; + } + if (!inPohInstance && origin != null && origin.getPlane() != plPathLoop.getPlane()) { log.debug("[Walker] skip {} (i={}): plane mismatch", transport.getDisplayInfo(), i); break; // plane won't change across iterations, so break instead of continue } @@ -2684,7 +5516,8 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i Rs2NpcModel npc = Rs2Npc.getNpc(transport.getName()); - if (Rs2Npc.canWalkTo(npc, 20) && Rs2Npc.interact(npc, transport.getAction())) { + // Wrap with observation so Leagues blocked-region chat can attribute this attempt. + if (attemptObserved(transport, () -> npc != null && Rs2Npc.canWalkTo(npc, 20) && Rs2Npc.interact(npc, transport.getAction()))) { Rs2Player.waitForWalking(); sleepUntil(Rs2Dialogue::isInDialogue,600*2); @@ -2709,7 +5542,17 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i Rs2Dialogue.clickOption(transport.getDisplayInfo()); } sleepUntil(() -> !Rs2Player.isAnimating()); - boolean reachedDestination = sleepUntil(() -> Rs2Player.getWorldLocation().distanceTo(transport.getDestination()) < 10); + boolean shipNearDest = sleepUntil( + () -> isPlayerWithinChebyshevOf(transport.getDestination(), TRANSPORT_NEAR_LANDING_CHEBYSHEV), + SHIP_NPC_BOAT_LANDING_WAIT_MS); + if (!shipNearDest) { + WebWalkLog.spWarn( + "ship/npc/boat post-travel wait timed out ({}ms) dest={} at={}", + SHIP_NPC_BOAT_LANDING_WAIT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } + boolean reachedDestination = shipNearDest; sleepTickJitter(6); if (reachedDestination) { return finishHandledTransport(transport); @@ -2721,9 +5564,19 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i } if (transport.getType() == TransportType.CHARTER_SHIP) { - if (handleCharterShip(transport)) { + if (attemptObserved(transport, () -> handleCharterShip(transport))) { sleepUntil(() -> !Rs2Player.isAnimating()); - sleepUntilTrue(() -> Rs2Player.getWorldLocation().distanceTo(transport.getDestination()) < 10); + boolean charterLanded = Rs2WalkerRuntimeAwaits.awaitCondition( + () -> isPlayerWithinChebyshevOf(transport.getDestination(), TRANSPORT_NEAR_LANDING_CHEBYSHEV), + TRANSPORT_LANDING_WAIT_POLL_MS, + TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + if (!charterLanded) { + WebWalkLog.spWarn( + "charter ship post-travel wait timed out ({}ms) dest={} at={}", + TRANSPORT_LANDING_WAIT_TIMEOUT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } sleepTickJitter(4); // wait 4 extra ticks before walking return finishHandledTransport(transport); } @@ -2733,88 +5586,132 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i log.debug("[Walker] Handling {} transport: {} (i={}, path[i]={}, origin={})", transport.getType(), transport.getDisplayInfo(), i, path.get(i), origin); if (transport.getType() == TransportType.POH) { - boolean pohResult = handlePohTransport(transport); + boolean pohResult = attemptObserved(transport, () -> handlePohTransport(transport)); log.debug("[Walker] handlePohTransport({}) returned {}", transport.getDisplayInfo(), pohResult); if (pohResult) { - sleepUntil(() -> Rs2Player.getWorldLocation().distanceTo(transport.getDestination()) < OFFSET, 10000); - return finishHandledTransport(transport); + // Shares ship/NPC/boat 10s landing budget — intentional single timeout constant. + boolean pohNearDest = sleepUntil( + () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + SHIP_NPC_BOAT_LANDING_WAIT_MS); + if (!pohNearDest) { + WebWalkLog.spWarn( + "POH post-travel wait timed out ({}ms) dest={} at={}", + SHIP_NPC_BOAT_LANDING_WAIT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } + if (pohNearDest) { + return finishHandledTransport(transport); + } } } if (transport.getType() == TransportType.CANOE) { - if (handleCanoe(transport)) { + if (attemptObserved(transport, () -> handleCanoe(transport))) { sleepTickJitter(2); return finishHandledTransport(transport); } } if (transport.getType() == TransportType.SPIRIT_TREE) { - if (handleSpiritTree(transport)) { + if (attemptObserved(transport, () -> handleSpiritTree(transport))) { sleepUntil(() -> !Rs2Player.isAnimating()); - sleepUntilTrue(() -> Rs2Player.getWorldLocation().distanceTo(transport.getDestination()) < 10); - return finishHandledTransport(transport); + boolean spiritLanded = Rs2WalkerRuntimeAwaits.awaitCondition( + () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, + TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + if (!spiritLanded) { + WebWalkLog.spWarn( + "spirit tree post-travel wait timed out ({}ms) dest={} at={}", + TRANSPORT_LANDING_WAIT_TIMEOUT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } + if (spiritLanded) { + return finishHandledTransport(transport); + } } } if (transport.getType() == TransportType.QUETZAL) { - if (handleQuetzal(transport)) { + if (attemptObserved(transport, () -> handleQuetzal(transport))) { + boolean landedNearDest = Rs2WalkerRuntimeAwaits.awaitCondition( + () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, + TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + if (!landedNearDest) { + WebWalkLog.spWarn( + "quetzal post-travel wait timed out ({}ms) dest={} at={}", + TRANSPORT_LANDING_WAIT_TIMEOUT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } sleepTickJitter(2); return finishHandledTransport(transport); } } if (transport.getType() == TransportType.MAGIC_CARPET) { - if (handleMagicCarpet(transport)) { + if (attemptObserved(transport, () -> handleMagicCarpet(transport))) { sleepTickJitter(2); return finishHandledTransport(transport); } } if (transport.getType() == TransportType.WILDERNESS_OBELISK) { - if (handleWildernessObelisk(transport)) { + if (attemptObserved(transport, () -> handleWildernessObelisk(transport))) { sleepTickJitter(2); return finishHandledTransport(transport); } } if (transport.getType() == TransportType.GNOME_GLIDER) { - if (handleGlider(transport)) { + if (attemptObserved(transport, () -> handleGlider(transport))) { sleepUntil(() -> !Rs2Player.isAnimating()); - sleepUntilTrue(() -> Rs2Player.getWorldLocation().distanceTo(transport.getDestination()) < 10); + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), + TRANSPORT_NEAR_LANDING_CHEBYSHEV), + TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); sleepTickJitter(3); return finishHandledTransport(transport); } } - if (transport.getType() == TransportType.FAIRY_RING && !Rs2Player.getWorldLocation().equals(transport.getDestination())) { - if (handleFairyRing(transport)) { - sleepUntilTrue(() -> Rs2Player.getWorldLocation().distanceTo(transport.getDestination()) < OFFSET); + if (transport.getType() == TransportType.FAIRY_RING) { + WorldPoint plFairy = Rs2Player.getWorldLocation(); + WorldPoint tdFairy = transport.getDestination(); + boolean alreadyAtFairyDest = plFairy != null && tdFairy != null && plFairy.equals(tdFairy); + if (!alreadyAtFairyDest && attemptObserved(transport, () -> handleFairyRing(transport))) { + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); return finishHandledTransport(transport); } } if (transport.getType() == TransportType.TELEPORTATION_MINIGAME) { - if (handleMinigameTeleport(transport)) { - sleepUntilTrue(() -> Rs2Player.getWorldLocation().distanceTo(transport.getDestination()) < (OFFSET * 2)); + if (attemptObserved(transport, () -> handleMinigameTeleport(transport))) { + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET * 2), + TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); return finishHandledTransport(transport); } } if (transport.getType() == TransportType.TELEPORTATION_ITEM) { - if (handleTeleportItem(transport)) { + if (attemptObserved(transport, () -> handleTeleportItem(transport))) { sleepUntil(() -> !Rs2Player.isAnimating()); - sleepUntilTrue(() -> Rs2Player.getWorldLocation().distanceTo(transport.getDestination()) < OFFSET); + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); return finishHandledTransport(transport); } } if (transport.getType() == TransportType.TELEPORTATION_SPELL) { - if (handleTeleportSpell(transport)) { + if (attemptObserved(transport, () -> handleTeleportSpell(transport))) { if (isLumbridgeHomeTeleport(transport)) { - sleepUntilTrue(() -> Rs2Player.getWorldLocation().distanceTo(transport.getDestination()) < OFFSET, 600, 35000); + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), 600, 35000); } else { sleepUntil(() -> !Rs2Player.isAnimating()); - sleepUntilTrue(() -> Rs2Player.getWorldLocation().distanceTo(transport.getDestination()) < OFFSET); + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); } Rs2Tab.switchTo(InterfaceTab.INVENTORY); return finishHandledTransport(transport); @@ -2822,9 +5719,10 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i } if (transport.getType() == TransportType.SEASONAL_TRANSPORT) { - if (handleSeasonalTransport(transport)) { + if (attemptObservedWithoutAttemptRecord(transport, () -> handleSeasonalTransport(transport))) { sleepUntil(() -> !Rs2Player.isAnimating()); - sleepUntilTrue(() -> Rs2Player.getWorldLocation().distanceTo(transport.getDestination()) < OFFSET); + sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); return finishHandledTransport(transport); } } @@ -2845,31 +5743,22 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i List objects = Rs2GameObject.getAll(o -> { if (o.getId() == transportObjectId) return true; Integer legacyClosed = OPEN_TO_CLOSED_MAPPINGS.get(transportObjectId); - return legacyClosed != null && o.getId() == legacyClosed; + if (legacyClosed != null && o.getId() == legacyClosed) return true; + if (!allowClosedVariant) return false; + ObjectComposition comp = Rs2GameObject.convertToObjectComposition(o); + if (comp == null || comp.getActions() == null) return false; + String nm = comp.getName() == null ? "" : comp.getName().toLowerCase(); + boolean nameMatches = nm.contains("trapdoor") || nm.contains("manhole") + || nm.contains("grate") || nm.contains("hatch"); + if (!nameMatches) return false; + return Arrays.stream(comp.getActions()).filter(Objects::nonNull) + .anyMatch(a -> a.equalsIgnoreCase("Open")); }, transport.getOrigin(), 10).stream() .sorted(Comparator .comparingInt((TileObject o) -> resolveTransportObjectAction(o, transportActions).isPresent() ? 0 : 1) .thenComparingInt(o -> o.getWorldLocation().distanceTo(transport.getOrigin()))) .collect(Collectors.toList()); - if (objects.isEmpty() && allowClosedVariant) { - // The closed-variant fallback needs object composition lookups for name/action - // matching. Keep it off the common exact-id path; doing this for every - // climb-down object was the Varrock staircase delay. - objects = Rs2GameObject.getAll(o -> { - ObjectComposition comp = Rs2GameObject.convertToObjectComposition(o); - if (comp == null || comp.getActions() == null) return false; - String nm = comp.getName() == null ? "" : comp.getName().toLowerCase(); - boolean nameMatches = nm.contains("trapdoor") || nm.contains("manhole") - || nm.contains("grate") || nm.contains("hatch"); - if (!nameMatches) return false; - return Arrays.stream(comp.getActions()).filter(Objects::nonNull) - .anyMatch(a -> a.equalsIgnoreCase("Open")); - }, transport.getOrigin(), 10).stream() - .sorted(Comparator.comparingInt(o -> o.getWorldLocation().distanceTo(transport.getOrigin()))) - .collect(Collectors.toList()); - } - TileObject object = objects.stream().findFirst().orElse(null); if (object instanceof GroundObject) { object = objects.stream() @@ -2924,9 +5813,21 @@ && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(i return false; } sleepUntil(() -> !Rs2Player.isAnimating()); - int destinationTolerance = isAdjacentSamePlaneTransport(transport) ? 0 : OFFSET; - boolean reachedDestination = sleepUntil(() -> Rs2Player.getWorldLocation().distanceTo(transport.getDestination()) <= destinationTolerance, 5000); - if (reachedDestination) { + WorldPoint destWait = transport.getDestination(); + int maxInclusive = isAdjacentSamePlaneTransport(transport) ? 0 : OFFSET; + if (destWait == null) { + return false; + } + boolean landedAfterObject = sleepUntil(() -> isPlayerWithinChebyshevInclusive(destWait, maxInclusive), + POST_HANDLE_OBJECT_LANDING_WAIT_MS); + if (!landedAfterObject) { + WebWalkLog.spWarn( + "post-handleObject landing wait timed out ({}ms) dest={} at={}", + POST_HANDLE_OBJECT_LANDING_WAIT_MS, + compactWorldPoint(destWait), + compactWorldPoint(Rs2Player.getWorldLocation())); + } + if (landedAfterObject) { markAdjacentSamePlaneTransportHandled(transport, object); return finishHandledTransport(transport); } @@ -3010,10 +5911,15 @@ private static boolean handleObject(Transport transport, TileObject tileObject, WorldPoint before = Rs2Player.getWorldLocation(); Rs2GameObject.interact(tileObject, action); if (handleObjectExceptions(transport, tileObject)) return true; - if (transport.getDestination().getPlane() == Rs2Player.getWorldLocation().getPlane()) { + WorldPoint tdObj = transport.getDestination(); + WorldPoint plObj = Rs2Player.getWorldLocation(); + if (tdObj == null || plObj == null) { + return false; + } + if (tdObj.getPlane() == plObj.getPlane()) { if (transport.getType() == TransportType.AGILITY_SHORTCUT) { Rs2Player.waitForAnimation(); - sleepUntil(() -> Rs2Player.getWorldLocation().distanceTo(transport.getDestination()) <= 2, 10000); + sleepUntil(() -> isPlayerWithinChebyshevInclusive(tdObj, 2), 10000); } else if (transport.getType() == TransportType.MINECART) { if (interactWithAdventureLog(transport)) { sleepTickJitter(2); // wait extra 2 game ticks before moving @@ -3040,24 +5946,37 @@ private static boolean handleObject(Transport transport, TileObject tileObject, clicked = walkFastCanvas(transport.getDestination()); } if (clicked) { - sleepUntil(() -> Rs2Player.getWorldLocation().equals(transport.getDestination()), 3000); + sleepUntil(() -> { + WorldPoint now = Rs2Player.getWorldLocation(); + WorldPoint td = transport.getDestination(); + return now != null && td != null && now.equals(td); + }, 3000); } } } } return true; } else { - int z = Rs2Player.getWorldLocation().getPlane(); - boolean started = sleepUntil(() -> Rs2Player.getWorldLocation().getPlane() != z - || Rs2Player.isMoving() - || Rs2Player.isAnimating(), 1800); + WorldPoint plZ = Rs2Player.getWorldLocation(); + if (plZ == null) { + return false; + } + int z = plZ.getPlane(); + boolean started = sleepUntil(() -> { + WorldPoint p = Rs2Player.getWorldLocation(); + return p != null && (p.getPlane() != z || Rs2Player.isMoving() || Rs2Player.isAnimating()); + }, 1800); if (!started) { log.debug("[Walker] {} transport click on {} produced no movement/animation; retrying", transport.getAction(), tileObject.getId()); return false; } - boolean planeChanged = Rs2Player.getWorldLocation().getPlane() != z - || sleepUntil(() -> Rs2Player.getWorldLocation().getPlane() != z, 5000); + WorldPoint plAfterStart = Rs2Player.getWorldLocation(); + boolean planeChanged = plAfterStart != null && plAfterStart.getPlane() != z + || sleepUntil(() -> { + WorldPoint p = Rs2Player.getWorldLocation(); + return p != null && p.getPlane() != z; + }, 5000); if (planeChanged) { sleep((int) Rs2Random.gaussRand(300.0, 120.0)); } @@ -3074,12 +5993,141 @@ private static boolean isAdjacentSamePlaneTransport(Transport transport) { } private static boolean finishHandledTransport(Transport transport) { - if (currentTarget != null && shouldRecalculatePathAfterTransport(transport)) { + long handoffStartedAt = System.currentTimeMillis(); + lastTransportHandledAtMs = handoffStartedAt; + lastTransportHandledAtLocation = Rs2Player.getWorldLocation(); + WorldPoint goal = currentTarget; + WorldPoint transportDest = transport != null ? transport.getDestination() : null; + boolean expectedTransport = consumeExpectedTransportDestination(transportDest); + boolean hasPrecomputedContinuation = hasPrecomputedContinuationFromTransport(transport); + if (goal != null) { + WebWalkLog.tmark("transport_handoff_enter", + 0L, + goal, + Rs2Player.getWorldLocation(), + "dest=" + compactWorldPoint(transportDest) + + " expected=" + expectedTransport + + " precomputed=" + hasPrecomputedContinuation + + " type=" + (transport != null ? transport.getType() : "null")); + } + if ((expectedTransport || hasPrecomputedContinuation) && goal != null) { + WebWalkLog.tmark(expectedTransport ? "transport_handoff_expected_hit" : "transport_handoff_precomputed_hit", + System.currentTimeMillis() - handoffStartedAt, + goal, + Rs2Player.getWorldLocation(), + "dest=" + compactWorldPoint(transportDest)); + return true; + } + if (goal != null && transportDest != null) { + // Destination-aware handoff: prepare next path from known landing tile. + boolean queued = restartPathfinding(transportDest, goal); + WebWalkLog.tmark("transport_handoff_restart", + System.currentTimeMillis() - handoffStartedAt, + goal, + Rs2Player.getWorldLocation(), + "queued=" + queued + " dest=" + compactWorldPoint(transportDest)); + if (!queued && shouldRecalculatePathAfterTransport(transport)) { + recalculatePath(); + WebWalkLog.tmark("transport_handoff_recalc_fallback", + System.currentTimeMillis() - handoffStartedAt, + goal, + Rs2Player.getWorldLocation(), + "dest=" + compactWorldPoint(transportDest)); + } + } else if (goal != null && shouldRecalculatePathAfterTransport(transport)) { recalculatePath(); + WebWalkLog.tmark("transport_handoff_recalc_goal_only", + System.currentTimeMillis() - handoffStartedAt, + goal, + Rs2Player.getWorldLocation(), + "dest=" + compactWorldPoint(transportDest)); } return true; } + private static void primeExpectedTransportDestinations(List path, int startIdx) { + if (path == null || path.size() < 2) { + synchronized (expectedTransportDestinations) { + expectedTransportDestinations.clear(); + } + return; + } + int start = Math.max(0, startIdx); + java.util.Deque next = new ArrayDeque<>(); + WorldPoint lastAdded = null; + for (int i = start; i < path.size() - 1; i++) { + if (!hasExplicitTransportStep(path, i)) { + continue; + } + WorldPoint destination = path.get(i + 1); + if (destination == null) { + continue; + } + if (lastAdded == null || !lastAdded.equals(destination)) { + next.addLast(destination); + lastAdded = destination; + } + } + synchronized (expectedTransportDestinations) { + expectedTransportDestinations.clear(); + expectedTransportDestinations.addAll(next); + } + } + + private static boolean consumeExpectedTransportDestination(WorldPoint destination) { + if (destination == null) { + return false; + } + synchronized (expectedTransportDestinations) { + while (!expectedTransportDestinations.isEmpty()) { + WorldPoint expected = expectedTransportDestinations.peekFirst(); + if (expected == null) { + expectedTransportDestinations.pollFirst(); + continue; + } + if (sameOrNearTransportDestination(expected, destination)) { + expectedTransportDestinations.pollFirst(); + return true; + } + break; + } + return false; + } + } + + private static boolean sameOrNearTransportDestination(WorldPoint a, WorldPoint b) { + return a != null + && b != null + && a.getPlane() == b.getPlane() + && a.distanceTo2D(b) <= TRANSPORT_DEST_MATCH_CHEBYSHEV; + } + + private static boolean hasPrecomputedContinuationFromTransport(Transport transport) { + if (transport == null || transport.getDestination() == null) { + return false; + } + Pathfinder pathfinder = ShortestPathPlugin.getPathfinder(); + if (pathfinder == null || !pathfinder.isDone()) { + return false; + } + List walkPath = pathfinder.getWalkablePath(); + if (walkPath == null || walkPath.size() < 2) { + return false; + } + int closest = getClosestTileIndex(walkPath); + if (closest < 0) { + return false; + } + WorldPoint destination = transport.getDestination(); + for (int i = Math.max(0, closest - 2); i < walkPath.size(); i++) { + WorldPoint point = walkPath.get(i); + if (sameOrNearTransportDestination(point, destination)) { + return i < walkPath.size() - 1; + } + } + return false; + } + static boolean shouldRecalculatePathAfterTransport(Transport transport) { if (transport == null || transport.getDestination() == null) { return false; @@ -3131,7 +6179,16 @@ private static boolean handleObjectExceptions(Transport transport, TileObject ti Rs2GameObject.interact(tileObject, transport.getAction()); } sleepUntil(() -> !Rs2Player.isAnimating()); - sleepUntilTrue(() -> Rs2Player.getWorldLocation().distanceTo(transport.getDestination()) < OFFSET); + boolean trapdoorLanded = sleepUntilTrue( + () -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), + TRANSPORT_LANDING_WAIT_POLL_MS, TRANSPORT_LANDING_WAIT_TIMEOUT_MS); + if (!trapdoorLanded) { + WebWalkLog.spWarn( + "trapdoor post-travel wait timed out ({}ms) dest={} at={}", + TRANSPORT_LANDING_WAIT_TIMEOUT_MS, + compactWorldPoint(transport.getDestination()), + compactWorldPoint(Rs2Player.getWorldLocation())); + } return true; } } @@ -3189,7 +6246,11 @@ private static boolean handleObjectExceptions(Transport transport, TileObject ti } Rs2Dialogue.sleepUntilHasQuestion("Pay 875 coins to enter?"); Rs2Dialogue.clickOption("Yes"); - sleepUntil(() -> Rs2Player.getWorldLocation().equals(transport.getDestination())); + sleepUntil(() -> { + WorldPoint now = Rs2Player.getWorldLocation(); + WorldPoint td = transport.getDestination(); + return now != null && td != null && now.equals(td); + }); return true; } // Handle Brimhaven Dungeon Stepping Stones @@ -3245,7 +6306,12 @@ private static boolean handleObjectExceptions(Transport transport, TileObject ti char option = transport.getDisplayInfo().charAt(0); Rs2Dialogue.sleepUntilSelectAnOption(); Rs2Keyboard.keyPress(option); - sleepUntil(() -> Rs2Player.getWorldLocation().distanceTo2D(transport.getDestination()) < OFFSET, 10000); + sleepUntil(() -> { + WorldPoint pl = Rs2Player.getWorldLocation(); + WorldPoint td = transport.getDestination(); + return pl != null && td != null && pl.getPlane() == td.getPlane() + && pl.distanceTo2D(td) < OFFSET; + }, 10000); return true; } @@ -3258,7 +6324,12 @@ private static boolean handleObjectExceptions(Transport transport, TileObject ti if (tileObject.getId() == ObjectID.AERIAL_FISHING_BOAT) { Rs2Dialogue.sleepUntilSelectAnOption(); Rs2Dialogue.clickOption(transport.getDisplayInfo(), true); - sleepUntil(() -> Rs2Player.getWorldLocation().distanceTo2D(transport.getDestination()) < OFFSET, 10000); + sleepUntil(() -> { + WorldPoint pl = Rs2Player.getWorldLocation(); + WorldPoint td = transport.getDestination(); + return pl != null && td != null && pl.getPlane() == td.getPlane() + && pl.distanceTo2D(td) < OFFSET; + }, 10000); return true; } @@ -3276,7 +6347,12 @@ private static boolean handleWildernessObelisk(Transport transport) { Rs2GameObject.interact(obelisk, transport.getAction()); sleepUntil(() -> Rs2GameObject.getGameObject(obj -> obj.getId() == transport.getObjectId(), transport.getOrigin()) != null); walkFastCanvas(transport.getOrigin()); - return sleepUntilTrue(() -> Rs2Player.getWorldLocation().distanceTo2D(transport.getDestination()) < OFFSET, 100, 10000); + return sleepUntilTrue(() -> { + WorldPoint pl = Rs2Player.getWorldLocation(); + WorldPoint td = transport.getDestination(); + return pl != null && td != null && pl.getPlane() == td.getPlane() + && pl.distanceTo2D(td) < OFFSET; + }, 100, 10000); } return false; } @@ -3313,16 +6389,22 @@ private static boolean isLumbridgeHomeTeleport(Transport transport) { } private static boolean handleTeleportItem(Transport transport) { - if (Rs2Pvp.isInWilderness() && (Rs2Pvp.getWildernessLevelFrom(Rs2Player.getWorldLocation()) > (transport.getMaxWildernessLevel() + 1))) + WorldPoint plWild = Rs2Player.getWorldLocation(); + if (Rs2Pvp.isInWilderness() && plWild != null + && Rs2Pvp.getWildernessLevelFrom(plWild) > (transport.getMaxWildernessLevel() + 1)) { return false; + } boolean succesfullAction = false; for (Set itemIds : transport.getItemIdRequirements()) { if (succesfullAction) break; for (Integer itemId : itemIds) { if (Rs2Walker.currentTarget == null) break; - if (Rs2Player.getWorldLocation().distanceTo2D(transport.getDestination()) < config.reachedDistance()) + // reachedDistance <= 0: do not treat as "already at destination" (legacy: raw distance < 0 never true). + int reachRd = config.reachedDistance(); + if (reachRd > 0 && isPlayerWithinChebyshevOf(transport.getDestination(), reachRd)) { break; + } if (succesfullAction) break; //If an action is succesfully we break out of the loop @@ -3338,7 +6420,7 @@ private static boolean handleInventoryTeleports(Transport transport, int itemId) // A list of generic teleports that can be used if no parsable destination action is found List genericKeyWords = Arrays.asList( - "invoke", "empty", "consume", "open", "teleport", "rub", "break", "reminisce", "signal", "play", "commune", "squash" + "invoke", "empty", "consume", "open", "teleport", "rub", "break", "reminisce", "signal", "play", "commune", "squash", "blow" ); // Return true when the item does not use a generic keyword to teleport to its destination @@ -3371,6 +6453,8 @@ private static boolean handleInventoryTeleports(Transport transport, int itemId) } else if (wildernessTransport) { Rs2Dialogue.sleepUntilInDialogue(); return Rs2Dialogue.clickOption("Yes", "Okay"); + } else if (isQuetzalWhistleItemId(itemId)) { + return finishQuetzalWhistleTransport(transport); } return true; } @@ -3388,6 +6472,8 @@ private static boolean handleInventoryTeleports(Transport transport, int itemId) if (itemAction.equalsIgnoreCase("open") && itemId == ItemID.BOOKOFSCROLLS_CHARGED) { return handleMasterScrollBook(destination); + } else if (isQuetzalWhistleItemId(itemId)) { + return finishQuetzalWhistleTransport(transport); } else if (isDialogueBasedTeleportItem(transport.getDisplayInfo())) { // Multi-destination teleport items: wait for destination selection dialogue Rs2Dialogue.sleepUntilSelectAnOption(); @@ -3505,8 +6591,15 @@ public static boolean isNear() { if (path == null) return false; WorldPoint playerLocation = Rs2Player.getWorldLocation(); - int index = IntStream.range(0, pathfinder.getPath().size()) - .filter(f -> path.get(f).distanceTo2D(playerLocation) < 3) + if (playerLocation == null) { + return false; + } + int index = IntStream.range(0, path.size()) + .filter(f -> { + WorldPoint wp = path.get(f); + return wp.getPlane() == playerLocation.getPlane() + && wp.distanceTo2D(playerLocation) < 3; + }) .findFirst().orElse(-1); return index >= Math.max(path.size() - 10, 0); } @@ -3516,7 +6609,8 @@ public static boolean isNear() { * @return */ public static boolean isNear(WorldPoint target) { - return Rs2Player.getWorldLocation().equals(target); + WorldPoint pl = Rs2Player.getWorldLocation(); + return pl != null && pl.equals(target); } public static boolean isNearPath() { @@ -3529,7 +6623,7 @@ public static boolean isNearPath() { final WorldPoint loc = Rs2Player.getWorldLocation(); if (loc == null) return true; - if (config.recalculateDistance() < 0) { + if (config.recalculateDistance() < 0 || lastPosition.equals(lastPosition = loc)) { return true; } @@ -3548,32 +6642,136 @@ public static boolean isNearPath() { return false; } + private static boolean isNearPathByVariance(List path, WorldPoint playerLoc) { + if (path == null || path.isEmpty() || playerLoc == null) { + return false; + } + int closestIdx = getClosestTileIndex(path); + if (closestIdx < 0 || closestIdx >= path.size()) { + return false; + } + WorldPoint closest = path.get(closestIdx); + return closest != null + && closest.getPlane() == playerLoc.getPlane() + && closest.distanceTo2D(playerLoc) <= PATH_VARIANCE_TOLERANCE_CHEBYSHEV; + } + + private static boolean hasUpcomingNearbyTransportStep(List path, + int startIdx, + WorldPoint playerLoc, + int lookaheadEdges, + int maxDist) { + if (path == null || path.size() < 2 || startIdx < 0 || playerLoc == null) { + return false; + } + int from = Math.max(0, startIdx); + int to = Math.min(path.size() - 2, from + Math.max(0, lookaheadEdges)); + for (int i = from; i <= to; i++) { + if (!hasExplicitTransportStep(path, i)) { + continue; + } + WorldPoint origin = path.get(i); + if (origin != null + && origin.getPlane() == playerLoc.getPlane() + && origin.distanceTo2D(playerLoc) <= Math.max(1, maxDist)) { + return true; + } + } + return false; + } + private static void checkIfStuck() { - if (Rs2Player.getWorldLocation().equals(lastPosition)) { - stuckCount++; + // Leagues pending teleports, dialogue, and fairy ring widget should not burn stall budget. + if (Rs2WalkerStallPolicy.shouldSkipStallAccounting(LEAGUES_AREA_PENDING_STALL_MAX_AGE_MS)) { + lastMovedTimeMs = System.currentTimeMillis(); + stuckCount = 0; + prevAnimatingForStuckCheck = Rs2Player.isAnimating(); + return; + } + + WorldPoint now = Rs2Player.getWorldLocation(); + boolean anim = Rs2Player.isAnimating(); + if (now != null && now.equals(lastPosition)) { + boolean nearPath = isNearPath(); + boolean poseWalkingNearPath = Rs2Player.isMoving() && nearPath; + boolean animProgressNearPath = anim && !prevAnimatingForStuckCheck && nearPath; + if (animProgressNearPath || poseWalkingNearPath) { + lastMovedTimeMs = System.currentTimeMillis(); + stuckCount = 0; + } else { + stuckCount++; + } } else { stuckCount = 0; lastMovedTimeMs = System.currentTimeMillis(); } + prevAnimatingForStuckCheck = anim; } // Base stall threshold. See stallThresholdMs() for activity-aware scaling. // RuneLite exposes no real-time ping, so we skip pure latency scaling and rely on // observable activity states that also correlate with legitimately-stuck players. - private static final long STALL_BASE_MS = 10_000; + private static final long STALL_BASE_MS = 12_000; private static final double STALL_COMBAT_MULTIPLIER = 2.0; private static final double STALL_ANIMATING_MULTIPLIER = 1.5; + private static final double STALL_MOVING_MULTIPLIER = 1.35; + /** While a sticky minimap interim waypoint is active, path segments can exceed base stall easily. */ + private static final double STALL_INTERIM_MINIMAP_MULTIPLIER = 1.75; private static final double STALL_INTERACTING_MULTIPLIER = 1.5; + /** + * After a successful minimap walk click, refresh the stall clock this long — blocked tiles / long + * segments sometimes delay tile deltas without {@link Rs2Player#isMoving()} flipping immediately. + */ + private static final long MINIMAP_CLICK_STALL_GRACE_MS = 12_000L; + + private static boolean interactingActorNearWalkablePath() { + Pathfinder pf = ShortestPathPlugin.getPathfinder(); + if (pf == null) { + return false; + } + List path = pf.getWalkablePath(); + if (path == null || path.isEmpty()) { + return false; + } + Actor actor = Rs2Player.getInteracting(); + if (actor == null) { + return false; + } + WorldPoint loc = actor.getWorldLocation(); + if (loc == null) { + return false; + } + for (WorldPoint p : path) { + if (p == null || p.getPlane() != loc.getPlane()) { + continue; + } + if (p.distanceTo2D(loc) <= 2) { + return true; + } + } + return false; + } private static long stallThresholdMs() { - double multiplier = 1.0; - if (Rs2Player.isInCombat()) multiplier = Math.max(multiplier, STALL_COMBAT_MULTIPLIER); - if (Rs2Player.isAnimating()) multiplier = Math.max(multiplier, STALL_ANIMATING_MULTIPLIER); - if (Rs2Player.isInteracting()) multiplier = Math.max(multiplier, STALL_INTERACTING_MULTIPLIER); - return Math.round(STALL_BASE_MS * multiplier); + return Rs2WalkerStallPolicy.computeThresholdMs( + STALL_BASE_MS, + STALL_COMBAT_MULTIPLIER, + STALL_ANIMATING_MULTIPLIER, + STALL_MOVING_MULTIPLIER, + STALL_INTERIM_MINIMAP_MULTIPLIER, + STALL_INTERACTING_MULTIPLIER, + Rs2Player.isInCombat(), + Rs2Player.isAnimating(), + Rs2Player.isMoving(), + interimTargetWp != null, + Rs2Player.isInteracting() && interactingActorNearWalkablePath()); } private static boolean isStuckTooLong() { + if (Rs2WalkerStallPolicy.shouldSkipStallAccounting(LEAGUES_AREA_PENDING_STALL_MAX_AGE_MS)) { + return false; + } + return lastMovedTimeMs > 0 && System.currentTimeMillis() - lastMovedTimeMs > stallThresholdMs(); } @@ -3606,323 +6804,198 @@ public static int getDistanceBetween(WorldPoint startpoint, WorldPoint endpoint) return pathfinder.getPath().size(); } - // Map of Alacrity (League 6 / Demonic Pacts tier 3 relic — teleports to agility shortcuts). - // Item not in ItemID enum yet; widget group 187 is a two-step picker: - // Step 1: click a region (LJ_LAYER1 children 0-9). Locked regions are visible but not - // clickable. After clicking, the same widget repopulates with destinations. - // Step 2: click the destination in the same LJ_LAYER1. - private static final int MAP_OF_ALACRITY_ITEM_ID = 33233; - private static final int MAP_OF_ALACRITY_WIDGET_GROUP = 187; - private static final int MAP_OF_ALACRITY_LIST_CHILD = 3; - // Strikethrough markup the client wraps around locked (unselectable) menu rows. - private static final String MOA_LOCKED_MARKUP = ""; - - // Session blacklist of MoA destinations whose region or row is locked for this player, - // or whose display info doesn't resolve to any widget child. Prevents the pathfinder from - // re-picking the same doomed edge every tick. - public static final java.util.Set blacklistedMoaDestinations = - java.util.concurrent.ConcurrentHashMap.newKeySet(); - - // Session cache of MoA regions detected as locked. Short-circuits every destination in - // that region without re-opening the widget each attempt. Key is lowercased region name. - public static final java.util.Set lockedMoaRegions = - java.util.concurrent.ConcurrentHashMap.newKeySet(); - - private static boolean handleSeasonalTransport(Transport transport) { - String displayInfo = transport.getDisplayInfo(); - log.debug("[MoA] entry: displayInfo='{}'", displayInfo); - if (displayInfo == null) return false; - - if (!displayInfo.toLowerCase().contains("map of alacrity")) { - log.debug("[MoA] not Map of Alacrity, skipping"); - return false; - } - - int packedDest = WorldPointUtil.packWorldPoint(transport.getDestination()); - if (blacklistedMoaDestinations.contains(packedDest)) { - log.debug("[MoA] destination {} previously blacklisted this session — skipping", - transport.getDestination()); - return false; - } - - Rs2ItemModel relic = Rs2Inventory.get(MAP_OF_ALACRITY_ITEM_ID); - if (relic == null) { - log.debug("[MoA] item {} not in inventory — abort", MAP_OF_ALACRITY_ITEM_ID); - return false; - } - - // Display info format: "Map of Alacrity: - " - String rest = displayInfo.contains(":") ? displayInfo.split(":", 2)[1].trim() : displayInfo.trim(); - int dashIdx = rest.indexOf(" - "); - if (dashIdx < 0) { - log.warn("[MoA] cannot split region/shortcut from '{}'", rest); - return false; - } - String region = rest.substring(0, dashIdx).trim(); - String shortName = rest.substring(dashIdx + 3).trim(); - log.debug("[MoA] region='{}' shortName='{}'", region, shortName); - - if (lockedMoaRegions.contains(region.toLowerCase())) { - log.debug("[MoA] region '{}' already known-locked — skipping '{}'", region, shortName); - blacklistedMoaDestinations.add(packedDest); - return false; - } - - String action = relic.getAction("Read"); - if (action == null) action = relic.getActionFromList(Arrays.asList("Read", "Open", "Teleport", "Invoke")); - if (action == null) { - log.warn("[MoA] no usable action; available={}", Arrays.toString(relic.getInventoryActions())); - return false; - } - if (!Rs2Inventory.interact(relic, action)) { - log.warn("[MoA] Rs2Inventory.interact returned false for action '{}'", action); - return false; - } - - // Step 1: wait for the region picker to render, then click the matching region. - if (!sleepUntil(() -> Rs2Widget.isWidgetVisible(MAP_OF_ALACRITY_WIDGET_GROUP, MAP_OF_ALACRITY_LIST_CHILD), 3000)) { - log.warn("[MoA] region widget {}.{} did not open", MAP_OF_ALACRITY_WIDGET_GROUP, MAP_OF_ALACRITY_LIST_CHILD); - return false; - } - - Widget regionRoot = Rs2Widget.getWidget(MAP_OF_ALACRITY_WIDGET_GROUP, MAP_OF_ALACRITY_LIST_CHILD); - if (regionRoot == null) { - log.warn("[MoA] region widget lookup returned null"); - return false; - } - dumpMapOfAlacrityWidget(regionRoot); + /** + * Forwards to {@link Rs2LeaguesTransport#recordTransportAttempt} for Leagues locked-region chat correlation. + * Delegate records only teleport-like transports while Leagues is active (seasonal + spells/items, e.g. ectophial). + */ + public static void recordTransportAttempt(Transport transport) + { + Rs2LeaguesTransport.recordTransportAttempt(transport); + } - Widget regionMatch = findMoaWidget(regionRoot, region); - if (regionMatch == null) { - log.warn("[MoA] region '{}' not found in picker — check dump", region); - return false; - } - // Locked regions render with ... strikethrough markup. Don't waste a press. - String regionText = Microbot.getClientThread().runOnClientThreadOptional(regionMatch::getText).orElse(""); - if (regionText != null && regionText.contains(MOA_LOCKED_MARKUP)) { - log.warn("[MoA] region '{}' is locked (text='{}') — caching + blacklisting destination {}", - region, regionText, transport.getDestination()); - lockedMoaRegions.add(region.toLowerCase()); - blacklistedMoaDestinations.add(packedDest); - return false; + /** + * Writes {@code phase="result"} for {@link Rs2LeaguesTransport#appendTransportObservation} (seasonal rows only). + */ + private static void recordTransportResult(Transport transport, boolean success) + { + if (transport == null || transport.getType() != TransportType.SEASONAL_TRANSPORT) + { + return; } - log.debug("[MoA] selecting region '{}'", region); - Character regionHotkey = extractMoaHotkey(regionText); - if (regionHotkey == null) regionHotkey = computeMoaHotkeyByIndex(regionRoot, regionMatch); - if (regionHotkey != null) { - Rs2Keyboard.keyPress(regionHotkey); - } else { - log.warn("[MoA] no hotkey resolved for region '{}' — falling back to clickWidget", region); - if (!Rs2Widget.clickWidget(regionMatch)) { - log.warn("[MoA] region click returned false"); - return false; - } + if (!Rs2LeaguesTransport.isLeaguesActive()) + { + return; } + Rs2LeaguesTransport.appendTransportObservation("result", transport, success, success ? "ok" : "fail"); + } - // Step 2: wait for the destination to appear in the (same) widget. If the region was - // locked or otherwise non-clickable, this poll will time out with shortName never - // showing, and we return false. - Widget destMatch = sleepUntilNotNull(() -> { - Widget root = Rs2Widget.getWidget(MAP_OF_ALACRITY_WIDGET_GROUP, MAP_OF_ALACRITY_LIST_CHILD); - if (root == null) return null; - return findMoaWidget(root, shortName); - }, 3000); - - if (destMatch == null) { - // Don't blacklist here: a missing destination widget is ambiguous. Combat, - // lag, or the widget being closed by another handler can all manifest as - // "never appeared". Blacklisting on ambiguity permanently poisons legitimate - // destinations mid-session (e.g. player gets attacked during teleport, widget - // closes, we'd blacklist Nemus forever). Just return false and let the - // pathfinder/walker retry. Positive-evidence blacklisting ( markup on - // region or destination) below still applies. - log.warn("[MoA] destination '{}' never appeared after clicking region '{}' — retrying later", - shortName, region); - Widget root = Rs2Widget.getWidget(MAP_OF_ALACRITY_WIDGET_GROUP, MAP_OF_ALACRITY_LIST_CHILD); - if (root != null) dumpMapOfAlacrityWidget(root); + /** Wraps an action with {@link #recordTransportAttempt} + {@link #recordTransportResult} (seasonal JSONL, Leagues snapshot for teleports). + * @see net.runelite.client.plugins.microbot.util.leaguetransport.Rs2LeaguesTransport + */ + private static boolean attemptObserved(Transport transport, BooleanSupplier action) + { + if (transport == null || action == null) + { return false; } + boolean leaguesActive = Rs2LeaguesTransport.isLeaguesActive(); + // Snapshot attempt for Leagues locked-region chat correlation (avoid churn outside leagues). + if (leaguesActive) + { + recordTransportAttempt(transport); + } + boolean ok = action.getAsBoolean(); + if (leaguesActive) + { + recordTransportResult(transport, ok); + } + return ok; + } - // Individual destinations can also be locked inside an unlocked region. - String destText = Microbot.getClientThread().runOnClientThreadOptional(destMatch::getText).orElse(""); - if (destText != null && destText.contains(MOA_LOCKED_MARKUP)) { - log.warn("[MoA] destination '{}' is locked (text='{}') — blacklisting", shortName, destText); - blacklistedMoaDestinations.add(packedDest); + /** + * Like {@link #attemptObserved} but does not call {@link #recordTransportAttempt} before the action. + * Seasonal handlers record attempts at their click sites so {@link Rs2LeaguesTransport#getLastTransportAttemptSnapshot} + * matches the handler that actually ran (Leagues Area vs MoA). + */ + private static boolean attemptObservedWithoutAttemptRecord(Transport transport, BooleanSupplier action) + { + if (transport == null || action == null) + { return false; } - - // Select via the row's in-game hotkey (1-9 then A-Z). Keybinds work even when the row - // is scrolled off-screen, which clickWidget cannot handle. - log.debug("[MoA] selecting destination '{}' (text='{}')", shortName, destText); - Character hotkey = extractMoaHotkey(destText); - if (hotkey == null) { - Widget destRoot = Rs2Widget.getWidget(MAP_OF_ALACRITY_WIDGET_GROUP, MAP_OF_ALACRITY_LIST_CHILD); - hotkey = computeMoaHotkeyByIndex(destRoot, destMatch); - } - if (hotkey != null) { - Rs2Keyboard.keyPress(hotkey); - log.debug("[MoA] pressed hotkey '{}' for '{}'", hotkey, shortName); - // Wait for the MoA widget to close before returning. Without this, the caller's - // !isAnimating check in the walker loop passes instantly (animation hasn't - // started yet), and the walker races into the next transport step — e.g. - // clicking Royal seed pod mid-teleport, which then teleports the player - // back to Grand Tree and kicks off a MoA↔seed-pod loop. - sleepUntil(() -> !Rs2Widget.isWidgetVisible(MAP_OF_ALACRITY_WIDGET_GROUP, MAP_OF_ALACRITY_LIST_CHILD), 2000); - return true; + boolean leaguesActive = Rs2LeaguesTransport.isLeaguesActive(); + boolean ok = action.getAsBoolean(); + if (leaguesActive) + { + recordTransportResult(transport, ok); } - - log.warn("[MoA] no hotkey resolved for '{}' — falling back to clickWidget", shortName); - return Rs2Widget.clickWidget(destMatch); + return ok; } - // Matches the OSRS menu-row hotkey prefix, e.g. "[1] ..." or "1: ..." or "A. ...". - private static final Pattern MOA_HOTKEY_PATTERN = - Pattern.compile("^\\s*(?:\\[([0-9A-Za-z])\\]|([0-9A-Za-z])\\s*[:.])"); - private static final Pattern MOA_MARKUP_PATTERN = Pattern.compile("<[^>]+>"); - private static final Pattern MOA_PUNCT_PATTERN = Pattern.compile("[^a-zA-Z0-9 ]"); - private static final Pattern MOA_WHITESPACE_PATTERN = Pattern.compile("\\s+"); + /** + * Tries Leagues Area UI then Map of Alacrity for the same {@link Transport} row. + * Attempt recording is done inside each handler ({@link Rs2LeaguesTransport#tryHandleLeaguesAreaTransportResult}, + * {@link Rs2MapOfAlacrityTransport#tryUse}) — use {@link #attemptObservedWithoutAttemptRecord} at the call site. + */ + private static boolean handleSeasonalTransport(Transport transport) { + if (transport == null) { + return false; + } + String displayInfo = transport.getDisplayInfo(); + if (displayInfo == null) return false; - // Token-contains match tolerant of punctuation, / markup, and case. Used for - // both the region picker and the destination picker. Fixes the colon mismatch between TSV - // short names (e.g. "Chaos Temple Stepping Stone") and in-game labels ("Chaos Temple: - // Stepping Stone") without per-row data curation. - private static Widget findMoaWidget(Widget root, String shortName) { - String normalised = normaliseMoaText(shortName); - if (normalised.isEmpty()) return null; - String[] tokens = normalised.split(" "); - return Microbot.getClientThread().runOnClientThreadOptional(() -> { - Widget[][] groups = { root.getDynamicChildren(), root.getNestedChildren(), root.getStaticChildren() }; - for (Widget[] g : groups) { - if (g == null) continue; - for (Widget w : g) { - if (w == null) continue; - String hay = normaliseMoaText(w.getText()); - if (hay.isEmpty()) continue; - // Token-set membership avoids substring false positives (e.g. "log" matching "logstrum"). - java.util.Set haySet = new java.util.HashSet<>(java.util.Arrays.asList(hay.split(" "))); - boolean all = true; - for (String t : tokens) { - if (t.isEmpty()) continue; - if (!haySet.contains(t)) { all = false; break; } - } - if (all) return w; - } + List handlers = seasonalTransportHandlers; + for (SeasonalTransportHandler h : handlers) + { + if (h == null) + { + continue; } - return null; - }).orElse(null); - } - - private static String normaliseMoaText(String s) { - if (s == null) return ""; - s = MOA_MARKUP_PATTERN.matcher(s).replaceAll(" "); - s = MOA_PUNCT_PATTERN.matcher(s).replaceAll(" "); - return MOA_WHITESPACE_PATTERN.matcher(s.toLowerCase()).replaceAll(" ").trim(); - } - - private static Character extractMoaHotkey(String rawText) { - if (rawText == null) return null; - String stripped = rawText.replaceAll("<[^>]+>", "").trim(); - Matcher m = MOA_HOTKEY_PATTERN.matcher(stripped); - if (!m.find()) return null; - String g = m.group(1) != null ? m.group(1) : m.group(2); - if (g == null || g.isEmpty()) return null; - char c = g.charAt(0); - return Character.isLetter(c) ? Character.toUpperCase(c) : c; - } - - // Fallback when the row text has no bracketed/colon-prefixed key we can parse. - // OSRS numbers unlocked rows 1-9 then A-Z; locked () rows are skipped. - private static Character computeMoaHotkeyByIndex(Widget root, Widget destMatch) { - if (root == null) return null; - return Microbot.getClientThread().runOnClientThreadOptional(() -> { - int idx = 0; - Widget[][] groups = { root.getDynamicChildren(), root.getNestedChildren(), root.getStaticChildren() }; - for (Widget[] g : groups) { - if (g == null) continue; - for (Widget sibling : g) { - if (sibling == null) continue; - String t = sibling.getText(); - if (t == null || t.isEmpty()) continue; - if (t.contains(MOA_LOCKED_MARKUP)) continue; - if (sibling == destMatch) return indexToHotkey(idx); - idx++; - } + if (!h.matches(transport)) + { + continue; } - return null; - }).orElse(null); - } - - private static Character indexToHotkey(int i) { - if (i < 9) return (char) ('1' + i); - int letter = i - 9; - if (letter >= 26) return null; - return (char) ('A' + letter); - } - - // Verbose one-shot dump of MoA destination widget children to the log. Helps us figure out - // the real in-game label format on the first invocation; can be trimmed once execution is - // known to work end-to-end. Widget accessors must run on the client thread. - private static void dumpMapOfAlacrityWidget(Widget listRoot) { - Microbot.getClientThread().runOnClientThreadOptional(() -> { - try { - Widget[] dyn = listRoot.getDynamicChildren(); - Widget[] stc = listRoot.getStaticChildren(); - Widget[] nst = listRoot.getNestedChildren(); - log.debug("[MoA] widget dump: listRoot id={} text='{}' name='{}' dyn={} static={} nested={}", - listRoot.getId(), - listRoot.getText(), - listRoot.getName(), - dyn == null ? "null" : dyn.length, - stc == null ? "null" : stc.length, - nst == null ? "null" : nst.length); - Widget[] toDump = dyn != null ? dyn : (stc != null ? stc : nst); - if (toDump == null) return true; - for (int i = 0; i < toDump.length; i++) { - Widget c = toDump[i]; - if (c == null) continue; - log.debug("[MoA] child[{}] id={} hidden={} text='{}' name='{}' actions={}", - i, c.getId(), c.isHidden(), c.getText(), c.getName(), - Arrays.toString(c.getActions())); + if (h.tryUse(transport)) + { + return true; + } + } + Telemetry.incrementSeasonalHandlerMiss(); + if (log.isDebugEnabled() && SEASONAL_HANDLER_MISS_LOGGED_COUNT.get() < SEASONAL_HANDLER_MISS_LOG_CAP) + { + WorldPoint destWp = transport.getDestination(); + String hash = Integer.toHexString(displayInfo.hashCode()); + String tail = displayInfo.length() > 160 + ? displayInfo.substring(0, 160) + "|h" + hash + : displayInfo + "|h" + hash; + final String missKey; + Integer packedTileOrNull = null; + if (destWp != null) + { + packedTileOrNull = WorldPointUtil.packWorldPoint(destWp); + missKey = Integer.toHexString(packedTileOrNull) + "|" + tail; + } + else + { + missKey = "nodest|" + tail; + } + if (SEASONAL_HANDLER_MISS_LOGGED.add(missKey)) + { + // Best-effort cap: only increment while below cap; duplicates and races are fine for debug-only logs. + for (;;) + { + int prev = SEASONAL_HANDLER_MISS_LOGGED_COUNT.get(); + if (prev >= SEASONAL_HANDLER_MISS_LOG_CAP) + { + break; + } + if (SEASONAL_HANDLER_MISS_LOGGED_COUNT.compareAndSet(prev, prev + 1)) + { + break; + } + } + String sample = displayInfo.length() > 160 ? displayInfo.substring(0, 160) + "…" : displayInfo; + if (packedTileOrNull != null) + { + sample = sample + " destPacked=" + Integer.toHexString(packedTileOrNull); } - } catch (Exception e) { - log.warn("[MoA] widget dump threw", e); + log.debug("[Walker] seasonal transport unmatched by Leagues Area + MoA (expect pathfinder-only matching rows); key={} sample={}", + missKey, sample); } - return true; - }); + } + return false; } private static boolean handleSpiritTree(Transport transport) { // Get Transport Information String displayInfo = transport.getDisplayInfo(); int objectId = transport.getObjectId(); - log.info("[Walker] handleSpiritTree: displayInfo={}, objectId={}", displayInfo, objectId); + if (log.isDebugEnabled()) + { + log.debug("[Walker] handleSpiritTree: displayInfo={}, objectId={}", displayInfo, objectId); + } if (displayInfo == null || displayInfo.isEmpty()) { - log.info("[Walker] handleSpiritTree: displayInfo empty, returning false"); + if (log.isDebugEnabled()) + { + log.debug("[Walker] handleSpiritTree: displayInfo empty, returning false"); + } return false; } if (!Rs2Widget.isWidgetVisible(ComponentID.ADVENTURE_LOG_CONTAINER)) { TileObject spiritTree = Rs2GameObject.findObjectById(objectId); - log.info("[Walker] handleSpiritTree: findObjectById({}) returned {}", - objectId, spiritTree != null ? "non-null @ " + spiritTree.getWorldLocation() : "NULL"); + if (log.isDebugEnabled()) + { + log.debug("[Walker] handleSpiritTree: findObjectById({}) returned {}", + objectId, spiritTree != null ? "non-null @ " + spiritTree.getWorldLocation() : "NULL"); + } if (spiritTree == null) { // POH fix: handleSpiritTree's findObjectById uses the transport's objectId // which is keyed from the TSV. Inside a POH the spirit tree is a different // object id than the overworld TSV expects. Fall back to the PohTeleports // helper which knows the full set of POH spirit-tree ids. spiritTree = PohTeleports.getSpiritTree(); - log.info("[Walker] handleSpiritTree: POH fallback getSpiritTree() returned {}", - spiritTree != null ? "non-null @ " + spiritTree.getWorldLocation() : "NULL"); + if (log.isDebugEnabled()) + { + log.debug("[Walker] handleSpiritTree: POH fallback getSpiritTree() returned {}", + spiritTree != null ? "non-null @ " + spiritTree.getWorldLocation() : "NULL"); + } } boolean interactResult = Rs2GameObject.interact(spiritTree, "Travel"); - log.info("[Walker] handleSpiritTree: interact(spiritTree, Travel) returned {}", interactResult); + if (log.isDebugEnabled()) + { + log.debug("[Walker] handleSpiritTree: interact(spiritTree, Travel) returned {}", interactResult); + } if (!interactResult) { return false; } } boolean result = interactWithAdventureLog(transport); - log.info("[Walker] handleSpiritTree: interactWithAdventureLog returned {}", result); + if (log.isDebugEnabled()) + { + log.debug("[Walker] handleSpiritTree: interactWithAdventureLog returned {}", result); + } return result; } @@ -4114,41 +7187,226 @@ private static boolean handleCanoe(Transport transport) { Rs2Widget.clickWidget(destination); Rs2Dialogue.waitForCutScene(100, 15000); - return sleepUntilTrue(() -> Rs2Player.getWorldLocation().distanceTo2D(transport.getDestination()) < (OFFSET * 2), 100, 5000); + return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET * 2), 100, 5000); } return false; } + private static boolean isQuetzalWhistleItemId(int itemId) { + return itemId == ItemID.HG_QUETZALWHISTLE_BASIC + || itemId == ItemID.HG_QUETZALWHISTLE_ENHANCED + || itemId == ItemID.HG_QUETZALWHISTLE_PERFECTED + || itemId == ItemID.HG_QUETZALWHISTLE_PERFECTED_INFINITE; + } + + /** + * Inventory menu action order for opening the Quetzal map from the whistle. + * Generic teleport keyword lists put {@code invoke} before {@code blow}; matching Invoke first often does not open the map. + */ + private static final List QUETZAL_WHISTLE_OPEN_ACTION_PRIORITY = Arrays.asList( + "blow", "use", "invoke", "open", "teleport", "rub", "commune", "play"); + + private static String pickQuetzalWhistleInventoryMenuAction(Rs2ItemModel rs2Item) { + assert rs2Item != null; + String primary = rs2Item.getActionFromList(QUETZAL_WHISTLE_OPEN_ACTION_PRIORITY); + if (primary != null) { + return primary; + } + return rs2Item.getActionFromList(Arrays.asList( + "invoke", "empty", "consume", "reminisce", "signal", "squash")); + } + + /** + * Labels match {@code quetzals.tsv} destination rows (map icon text). + */ + private static String quetzalMapLabelForDestination(WorldPoint dest) { + assert dest != null; + final int[][] coords = { + {1389, 2901, 0}, {1697, 3140, 0}, {1585, 3053, 0}, {1510, 3221, 0}, {1548, 2995, 0}, + {1437, 3171, 0}, {1779, 3111, 0}, {1700, 3037, 0}, {1670, 2933, 0}, {1446, 3108, 0}, + {1613, 3300, 0}, {1226, 3091, 0}, {1344, 3022, 0}, {1411, 3361, 0}, + }; + final String[] labels = { + "Aldarin", "Civitas illa Fortis", "Hunter Guild", "Quetzacalli Gorge", "Sunset Coast", + "The Teomat", "Fortis Colosseum", "Outer Fortis", "Colossal Wyrm Remains", "Cam Torum Entrance", + "Salvager Overlook", "Tal Teklan", "Kastori", "Auburnvale", + }; + assert coords.length == labels.length; + // Bank / script targets often sit several tiles off quetzals.tsv landing coords. + final int matchTiles = 15; + for (int i = 0; i < coords.length; i++) { + WorldPoint p = new WorldPoint(coords[i][0], coords[i][1], coords[i][2]); + if (dest.distanceTo2D(p) <= matchTiles && dest.getPlane() == p.getPlane()) { + return labels[i]; + } + } + return null; + } + + /** + * Option text on the Quetzal map — Renu uses {@link InterfaceID.QuetzalMenu}, whistle uses {@link InterfaceID.QuetzalwhistleMenu} + * (same icon labels). Prefers resolving from {@link Transport#getDestination()} so bank/custom tiles match. + */ + private static String resolveQuetzalMapOptionLabel(Transport transport) { + assert transport != null; + WorldPoint dest = transport.getDestination(); + if (dest != null) { + String byCoords = quetzalMapLabelForDestination(dest); + if (byCoords != null && !byCoords.isEmpty()) { + return byCoords; + } + } + String di = transport.getDisplayInfo(); + if (di != null && di.contains(":")) { + String[] parts = di.split(":", 2); + if (parts.length >= 2) { + String loc = parts[1].trim(); + if (!loc.isEmpty()) { + return loc; + } + } + } + return dest != null ? quetzalMapLabelForDestination(dest) : null; + } + + /** True when any Quetzal or whistle-map layer is visible (CONTENTS alone can stay hidden while MAP/ICONS show). */ + private static boolean isQuetzalMapInterfaceVisible() { + return Rs2Widget.isWidgetVisible(InterfaceID.QuetzalMenu.UNIVERSE) + || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalMenu.MAP) + || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalMenu.ICONS) + || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalMenu.CONTENTS) + || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalwhistleMenu.UNIVERSE) + || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalwhistleMenu.MAP) + || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalwhistleMenu.ICONS) + || Rs2Widget.isWidgetVisible(InterfaceID.QuetzalwhistleMenu.CONTENTS); + } + + private static boolean finishQuetzalWhistleTransport(Transport transport) { + assert transport != null; + WorldPoint dest = transport.getDestination(); + assert dest != null; + WorldPoint pl = Rs2Player.getWorldLocation(); + if (pl != null && pl.getPlane() == dest.getPlane() && pl.distanceTo2D(dest) < OFFSET) { + log.debug("Quetzal whistle: already within {} tiles of {}, skipping map", OFFSET, dest); + return true; + } + String mapLabel = resolveQuetzalMapOptionLabel(transport); + if (mapLabel == null || mapLabel.isEmpty()) { + log.warn("Quetzal whistle: could not resolve map label (displayInfo={}, destination={})", + transport.getDisplayInfo(), dest); + return false; + } + Rs2Player.waitForAnimation(1800); + sleepUntil(() -> isQuetzalMapInterfaceVisible() || !Rs2Player.isAnimating(), 1400); + sleep(Rs2Random.between(120, 260)); + return clickQuetzalMapDestination(mapLabel, dest); + } + + /** + * Finds destination row/icon; map can open before icon layer is built — search full subtree from several roots, + * not only {@link Widget#getDynamicChildren()} of {@link InterfaceID.QuetzalMenu#ICONS}. + */ + private static Widget findQuetzalMapDestinationWidget(String mapOptionLabel) { + assert mapOptionLabel != null && !mapOptionLabel.isEmpty(); + int[] roots = { + InterfaceID.QuetzalMenu.ICONS, + InterfaceID.QuetzalMenu.MAP, + InterfaceID.QuetzalMenu.SCROLL, + InterfaceID.QuetzalMenu.CONTENTS, + InterfaceID.QuetzalMenu.UNIVERSE, + InterfaceID.QuetzalwhistleMenu.ICONS, + InterfaceID.QuetzalwhistleMenu.MAP, + InterfaceID.QuetzalwhistleMenu.SCROLL, + InterfaceID.QuetzalwhistleMenu.CONTENTS, + InterfaceID.QuetzalwhistleMenu.UNIVERSE, + }; + for (int rootId : roots) { + // Widget#getDynamicChildren / isHidden must not run off the client thread — use marshalled helpers. + if (Rs2Widget.isHidden(rootId)) { + continue; + } + Widget root = Rs2Widget.getWidget(rootId); + if (root == null) { + continue; + } + Widget hit = Rs2Widget.findWidget(mapOptionLabel, List.of(root), false); + if (hit != null) { + return hit; + } + } + return null; + } + + /** + * Opens no NPC — caller must already have opened the Quetzal map (whistle or Renu). + */ + private static boolean clickQuetzalMapDestination(String mapOptionLabel, WorldPoint expectedDestination) { + assert mapOptionLabel != null && !mapOptionLabel.isEmpty(); + assert expectedDestination != null; + long quetzalStartAt = System.currentTimeMillis(); + + WorldPoint here = Rs2Player.getWorldLocation(); + if (here != null && here.getPlane() == expectedDestination.getPlane() + && here.distanceTo2D(expectedDestination) < OFFSET) { + log.debug("Quetzal map: already within {} tiles of {}, skipping map click", OFFSET, expectedDestination); + return true; + } + + boolean mapVisible = sleepUntilTrue(() -> isQuetzalMapInterfaceVisible(), 100, QUETZAL_MAP_VISIBLE_WAIT_MS); + if (!mapVisible) { + log.error("Quetzal map UI not visible within timeout (label={}, checked UNIVERSE/MAP/ICONS/CONTENTS)", + mapOptionLabel); + return false; + } + WebWalkLog.tmark("quetzal_ui_opened", System.currentTimeMillis() - quetzalStartAt, expectedDestination, Rs2Player.getWorldLocation(), + "label=" + mapOptionLabel); + + // ICONS subtree can attach shortly after the shell — brief pause before walking widget tree from walker thread. + sleep(Rs2Random.between(80, 160)); + + AtomicReference destRef = new AtomicReference<>(); + boolean iconReady = sleepUntilTrue(() -> { + Widget w = findQuetzalMapDestinationWidget(mapOptionLabel); + destRef.set(w); + return w != null; + }, 120, QUETZAL_ICON_READY_WAIT_MS); + Widget actionWidget = destRef.get(); + if (!iconReady || actionWidget == null) { + log.error("Could not find Quetzal map icon for: {} (waited for widget tree after map visible)", mapOptionLabel); + return false; + } + WebWalkLog.tmark("quetzal_option_found", System.currentTimeMillis() - quetzalStartAt, expectedDestination, Rs2Player.getWorldLocation(), + "label=" + mapOptionLabel); + + Rs2Widget.clickWidget(actionWidget); + log.info("Quetzal map: traveling to {} -> {}", mapOptionLabel, expectedDestination); + WebWalkLog.tmark("quetzal_click_sent", System.currentTimeMillis() - quetzalStartAt, expectedDestination, Rs2Player.getWorldLocation(), + "label=" + mapOptionLabel); + return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(expectedDestination, OFFSET), 100, 8000); + } + private static boolean handleQuetzal(Transport transport) { - @Component - int VARLAMORE_QUETZAL_MAP = InterfaceID.QuetzalMenu.CONTENTS; - @Component - int VARLAMORE_QUETZAL_OPTIONS = InterfaceID.QuetzalMenu.ICONS; String displayInfo = transport.getDisplayInfo(); if (displayInfo == null || displayInfo.isEmpty()) return false; + WorldPoint destCheck = transport.getDestination(); + WorldPoint plCheck = Rs2Player.getWorldLocation(); + if (destCheck != null && plCheck != null && plCheck.getPlane() == destCheck.getPlane() + && plCheck.distanceTo2D(destCheck) < OFFSET) { + log.debug("Quetzal Renu: already within {} tiles of {}, skip travel UI", OFFSET, destCheck); + return true; + } + Rs2NpcModel renu = Rs2Npc.getNpc(NpcID.QUETZAL_CHILD_GREEN); if (Rs2Tile.isTileReachable(transport.getOrigin()) && Rs2Npc.interact(renu, "travel")) { Rs2Player.waitForWalking(); - boolean isVarlamoreMapVisible = sleepUntilTrue(() -> Rs2Widget.isWidgetVisible(VARLAMORE_QUETZAL_MAP), 100, 10000); - - if (!isVarlamoreMapVisible) { - log.error("Varlamore Map Widget not visable within timeout"); + WorldPoint dest = transport.getDestination(); + String mapLabel = resolveQuetzalMapOptionLabel(transport); + if (mapLabel == null || mapLabel.isEmpty() || dest == null) { return false; } - - Widget quetzalMapWidget = Rs2Widget.getWidget(VARLAMORE_QUETZAL_OPTIONS); - List quetzalMapChildren = Arrays.stream(quetzalMapWidget.getDynamicChildren()) - .filter(Objects::nonNull) - .collect(Collectors.toList()); - - Widget actionWidget = Rs2Widget.findWidget(displayInfo, quetzalMapChildren, false); - if (actionWidget != null) { - Rs2Widget.clickWidget(actionWidget); - log.info("Traveling to {} - ({})", transport.getDisplayInfo(), transport.getDestination()); - return sleepUntilTrue(() -> Rs2Player.getWorldLocation().distanceTo2D(transport.getDestination()) < OFFSET, 100, 5000); - } + return clickQuetzalMapDestination(mapLabel, dest); } return false; } @@ -4244,7 +7502,7 @@ private static boolean interactWithAdventureLog(Transport transport) { Rs2Widget.clickWidget(destinationWidget); log.info("Traveling to {} - ({})", transport.getDisplayInfo(), transport.getDestination()); - return sleepUntilTrue(() -> Rs2Player.getWorldLocation().distanceTo2D(transport.getDestination()) < OFFSET, 100, 5000); + return sleepUntilTrue(() -> isPlayerWithinChebyshevOf(transport.getDestination(), OFFSET), 100, 5000); } private static boolean handleGlider(Transport transport) { @@ -4490,7 +7748,7 @@ public static boolean isTeleportItem(int itemId) { Set teleportItemIds = ShortestPathPlugin.getPathfinderConfig().getAllTransports().values() .stream() .flatMap(Set::stream) - .filter(t -> TransportType.isTeleport(t.getType())) + .filter(t -> TransportType.isTeleport(t.getType(), t.getOrigin())) .map(Transport::getItemIdRequirements) .flatMap(Set::stream) .flatMap(Set::stream) @@ -4627,38 +7885,7 @@ public static List getTransportsForDestination(WorldPoint destination * @return List of Transport objects that are missing required items */ public static List getTransportsForDestination(WorldPoint destination, boolean useBankItems, TransportType prefTransportType) { - if (destination == null) { - return new ArrayList<>(); - } - - boolean originalUseBankItems = ShortestPathPlugin.getPathfinderConfig().isUseBankItems(); - try { - // Store and configure pathfinder settings - ShortestPathPlugin.getPathfinderConfig().setUseBankItems(useBankItems); - ShortestPathPlugin.getPathfinderConfig().refresh(); - // Run pathfinder - Pathfinder pf = new Pathfinder(ShortestPathPlugin.getPathfinderConfig(), Rs2Player.getWorldLocation(), destination); - pf.run(); - - List path = pf.getPath(); - if (path.isEmpty()) { - log.debug("Unable to find path to destination: " + destination); - return new ArrayList<>(); - } - - // Get transports along the path - List transports = getTransportsForPath(path, 0, prefTransportType, true); - - // Log found transports for debugging - transports.forEach(t -> log.debug("Transport found: " + t)); - - return transports; - - } finally { - // Always restore original configuration - ShortestPathPlugin.getPathfinderConfig().setUseBankItems(originalUseBankItems); - ShortestPathPlugin.getPathfinderConfig().refresh(); - } + return Rs2WalkerBankingPlanner.getTransportsForDestination(destination, useBankItems, prefTransportType); } /** @@ -4680,52 +7907,7 @@ public static List prepareTransportsForDestination(WorldPoint destina * @return true if the player has all required items, false otherwise */ public static boolean hasRequiredTransportItems(Transport transport) { - if (transport == null) { - return false; - } - - if (transport.getType() == TransportType.FAIRY_RING) { - return Rs2Inventory.hasItem(ItemID.DRAMEN_STAFF) || - Rs2Equipment.isWearing(ItemID.DRAMEN_STAFF) || - Rs2Inventory.hasItem(ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF) || - Rs2Equipment.isWearing(ItemID.LUNAR_MOONCLAN_LIMINAL_STAFF) || Microbot.getVarbitValue(VarbitID.LUMBRIDGE_DIARY_ELITE_COMPLETE) == 1; - } else if (transport.getType() == TransportType.TELEPORTATION_ITEM || - transport.getType() == TransportType.TELEPORTATION_SPELL || transport.getType() == TransportType.CANOE || - transport.getType() == TransportType.BOAT || transport.getType() == TransportType.CHARTER_SHIP || - transport.getType() == TransportType.SHIP || transport.getType() == TransportType.MINECART || - transport.getType() == TransportType.MAGIC_CARPET - ) { - if (transport.getType() == TransportType.TELEPORTATION_SPELL && transport.getDisplayInfo() != null) { - // Extract spell name from displayInfo (handle potential format "spellname:option") - String spellName = transport.getDisplayInfo().contains(":") - ? transport.getDisplayInfo().split(":")[0].trim() - : transport.getDisplayInfo().trim(); - // Find matching Rs2Spells enum by name (case-insensitive partial match) - boolean hasMultipleDestination = transport.getDisplayInfo().contains(":"); - String displayInfo = hasMultipleDestination - ? transport.getDisplayInfo().split(":")[0].trim().toLowerCase() - : transport.getDisplayInfo(); - log.debug("Looking for spell rune requirements for: '{}' - display info {}", spellName, displayInfo); - Rs2Spells rs2Spell = Rs2Magic.getRs2Spell(displayInfo); - return Rs2Magic.hasRequiredRunes(rs2Spell); - } - if (isCurrencyBasedTransport(transport.getType()) && - (transport.getItemIdRequirements() == null || transport.getItemIdRequirements().isEmpty()) && - transport.getCurrencyName() != null && !transport.getCurrencyName().isEmpty() && transport.getCurrencyAmount() > 0) { - int currencyItemId = getCurrencyItemId(transport.getCurrencyName()); - return Rs2Inventory.count(currencyItemId) >= transport.getCurrencyAmount(); - } - if (transport.getItemIdRequirements() == null || transport.getItemIdRequirements().isEmpty()) { - return true; // No requirements specified - } - - return transport.getItemIdRequirements() - .stream() - .flatMap(Collection::stream) - .anyMatch(itemId -> Rs2Equipment.isWearing(itemId) || Rs2Inventory.hasItem(itemId)); - } - - return true; // For other transport types, assume available for now -> we need to think about later + return Rs2WalkerBankingPlanner.hasRequiredTransportItems(transport); } /** @@ -4736,13 +7918,7 @@ public static boolean hasRequiredTransportItems(Transport transport) { * @return List of transports that are missing required items */ public static List getMissingTransports(List transports) { - if (transports == null) { - return new ArrayList<>(); - } - - return transports.stream() - .filter(t -> !hasRequiredTransportItems(t)) - .collect(Collectors.toList()); + return Rs2WalkerBankingPlanner.getMissingTransports(transports); } /** @@ -4753,166 +7929,29 @@ public static List getMissingTransports(List transports) { * @return Map where key=itemId and value=quantity needed (actual quantities for teleportation spells) */ public static Map getMissingTransportItemIdsWithQuantities(List transports) { - if (transports == null) { - return new HashMap<>(); - } - - Map itemQuantityMap = new HashMap<>(); - - transports.stream() - .forEach(transport -> { - // Special handling for teleportation spells - use actual rune requirements - if (transport.getType() == TransportType.TELEPORTATION_SPELL) { - Map spellRuneRequirements = getSpellRuneRequirements(transport); - if (!spellRuneRequirements.isEmpty()) { - // Check if any of the required runes are available in bank - spellRuneRequirements.forEach((runeItemId, requiredQuantity) -> { - try { - int bankQuantity = Rs2Bank.count(runeItemId); - if (bankQuantity >= requiredQuantity) { - int currentQuantity = itemQuantityMap.getOrDefault(runeItemId, 0); - itemQuantityMap.put(runeItemId, currentQuantity + requiredQuantity); - log.debug("Added teleportation spell rune requirement: {} (ID: {}) x{} (bank has: {})", - runeItemId, runeItemId, requiredQuantity, bankQuantity); - } - } catch (Exception e) { - log.debug("Could not check bank for rune " + runeItemId + ": " + e.getMessage()); - } - }); - } - return; // Skip normal item requirement processing for spell transports - } - - // Normal processing for non-spell transports - if (transport.getItemIdRequirements() != null) { - for (Set alternativeItems : transport.getItemIdRequirements()) { - // For each alternative set, we need ANY one of these items - // Check if we have any of the alternatives in bank - boolean hasAnyAlternative = alternativeItems.stream() - .anyMatch(itemId -> { - try { - if (isCurrencyBasedTransport(transport.getType()) && transport.getCurrencyAmount() > 0) { - // For currency-based transports, check if we have enough currency - return Rs2Bank.count(itemId) >= transport.getCurrencyAmount(); - } else { - // For regular items, just check if we have the item - return Rs2Bank.hasItem(itemId); - } - } catch (Exception e) { - log.debug("Could not check bank for item " + itemId + ": " + e.getMessage()); - return false; - } - }); - - if (hasAnyAlternative) { - // Find the first available alternative in bank and add it to our map - alternativeItems.stream() - .filter(itemId -> { - try { - if (isCurrencyBasedTransport(transport.getType()) && transport.getCurrencyAmount() > 0) { - // For currency-based transports, check if we have enough currency - return Rs2Bank.count(itemId) >= transport.getCurrencyAmount(); - } else { - // For regular items, just check if we have the item - return Rs2Bank.hasItem(itemId); - } - } catch (Exception e) { - log.debug("Could not check bank for item " + itemId + ": " + e.getMessage()); - return false; - } - }) - .findFirst() - .ifPresent(itemId -> { - // Determine required quantity based on transport type - int requiredQuantity; - if (isCurrencyBasedTransport(transport.getType()) && transport.getCurrencyAmount() > 0) { - // For currency-based transports, use the actual currency amount - requiredQuantity = transport.getCurrencyAmount(); - log.debug("Currency-based transport {} requires {} x{}", - transport.getType(), transport.getCurrencyName(), requiredQuantity); - } else { - // For regular items (teleportation items, fairy rings, etc.), assume 1 is needed - requiredQuantity = 1; - } - - int currentQuantity = itemQuantityMap.getOrDefault(itemId, 0); - itemQuantityMap.put(itemId, currentQuantity + requiredQuantity); - }); - break; // Only need one item from this alternative set - } - } - } - }); - - return itemQuantityMap; + return Rs2WalkerBankingPlanner.getMissingTransportItemIdsWithQuantities(transports); } /** - * Gets the actual rune requirements for a teleportation spell transport. - * Maps spell names to Rs2Spells enum and extracts rune quantities. + * Extracts item IDs that are missing for the given transports and available in bank. + * Legacy method maintained for backward compatibility. + * Similar to Rs2Slayer.getMissingItemIds() but accessible in Rs2Walker. * - * @param transport The teleportation spell transport - * @return Map of item IDs to required quantities for the spell's runes + * @param transports List of transports to check for missing items + * @return List of item IDs that are needed and available in bank */ - private static Map getSpellRuneRequirements(Transport transport) { - Map runeRequirements = new HashMap<>(); - if (transport.getType() != TransportType.TELEPORTATION_SPELL || transport.getDisplayInfo() == null) { - return runeRequirements; - } - try { - // Extract spell name from displayInfo (handle potential format "spellname:option") - String spellName = transport.getDisplayInfo().contains(":") - ? transport.getDisplayInfo().split(":")[0].trim() - : transport.getDisplayInfo().trim(); - // Find matching Rs2Spells enum by name (case-insensitive partial match) - boolean hasMultipleDestination = transport.getDisplayInfo().contains(":"); - String displayInfo = hasMultipleDestination - ? transport.getDisplayInfo().split(":")[0].trim().toLowerCase() - : transport.getDisplayInfo(); - log.debug("Looking for spell rune requirements for: '{}' - display info {}", spellName, displayInfo); - Rs2Spells rs2Spell = Rs2Magic.getRs2Spell(displayInfo); - if (rs2Spell == null) return runeRequirements; - // Get rune requirements and check for elemental runes that might be provided by staves - Map requiredRunes = Rs2Magic.getRequiredRunes(rs2Spell,1,true); - List elementalRunes = rs2Spell.getElementalRunes(); - log.debug("Spell '{}' requires {} runes, including {} elemental runes", - spellName, requiredRunes.size(), elementalRunes.size()); - // Convert rune requirements to item IDs with quantities - requiredRunes.forEach((rune, quantity) -> { - int runeItemId = rune.getItemId(); - runeRequirements.put(runeItemId, quantity); - log.debug("Spell '{}' requires {} x {} (ID: {})", - spellName, quantity, rune.name(), runeItemId); - }); - - } catch (Exception e) { - log.warn("Error getting spell rune requirements for transport '{}': {}", - transport.getDisplayInfo(), e.getMessage()); - } - - return runeRequirements; + public static List getMissingTransportItemIds(List transports) { + return Rs2WalkerBankingPlanner.getMissingTransportItemIds(transports); } - /** - * Checks if a transport type is currency-based (requires coins or other currency). - * - * @param transportType The transport type to check - * @return true if the transport type requires currency - */ private static boolean isCurrencyBasedTransport(TransportType transportType) { - return transportType == TransportType.BOAT || - transportType == TransportType.CHARTER_SHIP || - transportType == TransportType.SHIP || - transportType == TransportType.MINECART || - transportType == TransportType.MAGIC_CARPET; + return transportType == TransportType.BOAT + || transportType == TransportType.CHARTER_SHIP + || transportType == TransportType.SHIP + || transportType == TransportType.MINECART + || transportType == TransportType.MAGIC_CARPET; } - /** - * Maps currency name to item ID from RuneLite API. - * - * @param currencyName The name of the currency (e.g., "Coins") - * @return The item ID for the currency, or -1 if not found - */ private static int getCurrencyItemId(String currencyName) { if (currencyName == null || currencyName.trim().isEmpty()) { return -1; @@ -4924,25 +7963,12 @@ private static int getCurrencyItemId(String currencyName) { return ItemID.COINS; case "ecto-token": return ItemID.ECTOTOKEN; - // Add more currencies as needed default: log.warn("Unknown currency type: {}", currencyName); return -1; } } - /** - * Extracts item IDs that are missing for the given transports and available in bank. - * Legacy method maintained for backward compatibility. - * Similar to Rs2Slayer.getMissingItemIds() but accessible in Rs2Walker. - * - * @param transports List of transports to check for missing items - * @return List of item IDs that are needed and available in bank - */ - public static List getMissingTransportItemIds(List transports) { - return new ArrayList<>(getMissingTransportItemIdsWithQuantities(transports).keySet()); - } - /** * Compares the efficiency of traveling directly to a target versus going via bank first. * This is useful when transport items may be needed from the bank. @@ -4952,128 +7978,7 @@ public static List getMissingTransportItemIds(List transport * @return TransportRouteAnalysis containing the analysis of both routes */ public static TransportRouteAnalysis compareRoutes(WorldPoint startPoint,WorldPoint target) { - long totalStartTime = System.nanoTime(); - StringBuilder performanceLog = new StringBuilder(); - performanceLog.append("\n\t=== compareRoutes Performance Analysis ===\n"); - if (target == null) { - return new TransportRouteAnalysis(new ArrayList<>(), null, null,new ArrayList<>(),new ArrayList<>(), "Target location is null"); - } - - if (startPoint == null) { - startPoint = Rs2Player.getWorldLocation(); - } - - if (startPoint == null) { - return new TransportRouteAnalysis(new ArrayList<>(), null, null, new ArrayList<>(),new ArrayList<>(),"Cannot determine starting location"); - } - - try { - // Get direct path distance with timing - performanceLog.append("\tStart Point: ").append(startPoint).append(", Target: ").append(target).append("\n"); - long directPathStartTime = System.nanoTime(); - List directPath = getWalkPath(startPoint, target); - long directPathEndTime = System.nanoTime(); - double directPathTimeMs = (directPathEndTime - directPathStartTime) / 1_000_000.0; - - int directDistance = getTotalTilesFromPath(directPath, target); - performanceLog.append("\t-Direct path calculation: ").append(String.format("%.2f ms", directPathTimeMs)) - .append(" (").append(directPath.size()).append(" waypoints, ").append(directDistance).append(" tiles)\n"); - - // Find nearest bank and calculate banking route distance - BankLocation nearestBank = null; - List pathToBank = new ArrayList<>(); - List pathWithBankedItemsToTarget = new ArrayList<>(); - int bankingRouteDistance = -1; - - try { - - - - - boolean originalUseBankItems = ShortestPathPlugin.getPathfinderConfig().isUseBankItems(); - try { - ShortestPathPlugin.getPathfinderConfig().setUseBankItems(true); - ShortestPathPlugin.getPathfinderConfig().refresh(target); - - performanceLog.append("\t-Bank items available: ").append(Rs2Bank.bankItems().size()).append("\n"); - - long pathWithBankedItemsStartTime = System.nanoTime(); - pathWithBankedItemsToTarget = getWalkPath(startPoint, target); - long pathWithBankedItemsEndTime = System.nanoTime(); - double pathWithBankedItemsTimeMs = (pathWithBankedItemsEndTime - pathWithBankedItemsStartTime) / 1_000_000.0; - - int distanceWithBankedItemsToTarget = getTotalTilesFromPath(pathWithBankedItemsToTarget, target); - bankingRouteDistance = distanceWithBankedItemsToTarget; - - performanceLog.append("\t-Path from start to target with banked items: ").append(String.format("%.2f ms", pathWithBankedItemsTimeMs)) - .append(" (").append(pathWithBankedItemsToTarget.size()).append(" waypoints, ").append(distanceWithBankedItemsToTarget).append(" tiles)\n"); - performanceLog.append("\t-Total banking route distance: ").append(bankingRouteDistance).append(" tiles\n"); - - } finally { - // Always restore original configuration - ShortestPathPlugin.getPathfinderConfig().setUseBankItems(false); - ShortestPathPlugin.getPathfinderConfig().refresh(); - } - if (bankingRouteDistance Found: ").append(nearestBank).append(" at ").append(bankLocation).append("\n"); - - // Calculate distance from start point to bank - long pathToBankStartTime = System.nanoTime(); - pathToBank = getWalkPath(startPoint, bankLocation); - long pathToBankEndTime = System.nanoTime(); - double pathToBankTimeMs = (pathToBankEndTime - pathToBankStartTime) / 1_000_000.0; - - int distanceToBank = getTotalTilesFromPath(pathToBank, bankLocation); - performanceLog.append("\t-Path to bank calculation: ").append(String.format("%.2f ms", pathToBankTimeMs)) - .append(" (").append(pathToBank.size()).append(" waypoints, ").append(distanceToBank).append(" tiles)\n"); - bankingRouteDistance += distanceToBank; - } else { - performanceLog.append("\t -> No accessible bank found\n"); - } - } - - } catch (Exception e) { - performanceLog.append("Banking route calculation failed: ").append(e.getMessage()).append("\n"); - log.debug("Could not calculate banking route: " + e.getMessage()); - } - - long totalEndTime = System.nanoTime(); - double totalTimeMs = (totalEndTime - totalStartTime) / 1_000_000.0; - performanceLog.append("\t=== Total compareRoutes time: ").append(String.format("%.2f ms", totalTimeMs)).append(" ===\n"); - - if (bankingRouteDistance == -1) { - performanceLog.append("\tResult: Direct route only (banking route unavailable)\n"); - log.info(performanceLog.toString()); - return new TransportRouteAnalysis(directPath, null, null, new ArrayList<>(),new ArrayList<>(), - "Direct route only (banking route unavailable)"); - } - - boolean directIsFaster = directDistance <= bankingRouteDistance; - String recommendation = directIsFaster ? - String.format("\tDirect route is faster (%d vs %d tiles)", directDistance, bankingRouteDistance) : - String.format("\tBanking route is faster (%d vs %d tiles)", bankingRouteDistance, directDistance); - - performanceLog.append("\tResult:\n\t\t ").append(recommendation).append("\n"); - log.info(performanceLog.toString()); - - return new TransportRouteAnalysis(directPath, - nearestBank, nearestBank != null ? nearestBank.getWorldPoint() : null,pathToBank,pathWithBankedItemsToTarget, recommendation); - - } catch (Exception e) { - long totalEndTime = System.nanoTime(); - double totalTimeMs = (totalEndTime - totalStartTime) / 1_000_000.0; - performanceLog.append("ERROR after ").append(String.format("%.2f ms", totalTimeMs)).append(": ").append(e.getMessage()).append("\n"); - log.warn(performanceLog.toString()); - log.warn("Error comparing routes to {}: {}", target, e.getMessage()); - return new TransportRouteAnalysis(new ArrayList<>(), null, null,new ArrayList<>(),new ArrayList<>(), "Error calculating routes: " + e.getMessage()); - } + return Rs2WalkerBankingPlanner.compareRoutes(startPoint, target); } /** @@ -5094,7 +7999,8 @@ public static boolean walkWithBankedTransports(WorldPoint target) { return walkWithBankedTransports(target, false); } public static boolean walkWithBankedTransports(WorldPoint target, boolean forceBanking) { - return walkWithBankedTransportsAndState(target, 10, forceBanking) == WalkerState.ARRIVED; + int d = config != null ? config.reachedDistance() : 10; + return walkWithBankedTransportsAndState(target, d, forceBanking) == WalkerState.ARRIVED; } public static boolean walkWithBankedTransports(WorldPoint target, int distance, boolean forceBanking){ WalkerState state = walkWithBankedTransportsAndState(target, distance, forceBanking); @@ -5136,31 +8042,53 @@ public static WalkerState walkWithBankedTransportsAndState(WorldPoint target, in } private static WalkerState walkWithBankedTransportsAndStateLocked(WorldPoint target, int distance, boolean forceBanking) { - if (Rs2Tile.getReachableTilesFromTile(Rs2Player.getWorldLocation(), distance).containsKey(target) - || !Rs2Tile.isWalkable(LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), target)) && Rs2Player.getWorldLocation().distanceTo(target) <= distance) { + WorldPoint pl = Rs2Player.getWorldLocation(); + if (pl == null) { + // Transient snapshot; main walk / `processWalk` exits when not logged in — MOVING retries next beat. + return WalkerState.MOVING; + } + Client rlClient = Microbot.getClient(); + WorldView wv = rlClient != null ? rlClient.getTopLevelWorldView() : null; + LocalPoint targetLocal = wv != null ? LocalPoint.fromWorld(wv, target) : null; + boolean nearUnwalkableGoal = targetLocal != null + && !Rs2Tile.isWalkable(targetLocal) + && pl.distanceTo(target) <= distance; + if (Rs2Tile.getReachableTilesFromTile(pl, distance).containsKey(target) || nearUnwalkableGoal) { return WalkerState.ARRIVED; } final Pathfinder pathfinder = ShortestPathPlugin.getPathfinder(); if (pathfinder != null && !pathfinder.isDone()) return WalkerState.MOVING; + + if (!forceBanking && Rs2Bank.getBankLiveEpoch() <= 0) { + WalkerState bootstrapState = bootstrapBankMirrorForBankedPathing(distance); + if (bootstrapState == WalkerState.EXIT || bootstrapState == WalkerState.UNREACHABLE) { + return bootstrapState; + } + } // Check what transport items are needed + long compareStartedAt = System.currentTimeMillis(); + long compareFromWalkStart = walkSessionStartedAtMs > 0 ? compareStartedAt - walkSessionStartedAtMs : 0L; + WebWalkLog.tmark("compare_start", compareFromWalkStart, target, pl, "bank_vs_direct"); TransportRouteAnalysis comparison = compareRoutes(target); + WebWalkLog.tmark("compare_done", System.currentTimeMillis() - compareStartedAt, target, pl, + "direct=" + comparison.getDirectDistance() + " bank=" + comparison.getBankingRouteDistance()); List missingTransports = getMissingTransports(getTransportsForDestination(target, true, TransportType.TELEPORTATION_SPELL)); Map missingItemsWithQuantities = getMissingTransportItemIdsWithQuantities(missingTransports); - if(!missingTransports.isEmpty()){ - log.info("\n\tFor {} transports to destination in the bank to target {} we found {} missing items", + if (!missingTransports.isEmpty()) { + WebWalkLog.bankWalkDebug("missing_items nTrans={} to={} missingKinds={}", missingTransports.size(), target, missingItemsWithQuantities.size()); } // If no missing transport items, go directly if (missingItemsWithQuantities.isEmpty() && !forceBanking) { - log.info("\n\tNo missing transport items, traveling directly to: \n\t" + target); + WebWalkLog.bankWalkDebug("direct_no_missing_items goal={}", target); WalkerState state = walkWithStateInternal(target, distance); if (state == WalkerState.ARRIVED) { - log.info("\n\tArrived directly at target: " + target); + WebWalkLog.bankWalkDebug("arrived goal={}", target); } else { - log.warn("\n\tFailed to arrive directly at target: " + target + ", state: " + state); - setTarget(null); + WebWalkLog.bankWalkFailed(target, state); + setTarget(null, "rs2walker:walkWithBankedTransports:direct-walk-failed"); return state; } @@ -5169,8 +8097,11 @@ private static WalkerState walkWithBankedTransportsAndStateLocked(WorldPoint tar // Compare routes if we have missing items that could be obtained from bank // Use config for minimum bank route savings int minBankRouteSavings = config != null ? config.minBankRouteSavings() : 0; + boolean preferTransportToTarget = config != null && config.preferTransportToTarget(); int tileSavings = comparison.getTileSavings(); - boolean bankRouteIsBetter = !comparison.isDirectIsFaster() && tileSavings >= minBankRouteSavings; + boolean tieAndPreferBank = comparison.isTie() && preferTransportToTarget; + boolean bankRouteIsBetter = (!comparison.isDirectIsFaster() && tileSavings >= minBankRouteSavings) + || (tieAndPreferBank && tileSavings >= minBankRouteSavings); // If forced banking or banking route is more efficient (with min savings), go via bank if (forceBanking || bankRouteIsBetter) { if (comparison.getNearestBank() != null) { @@ -5191,6 +8122,43 @@ private static WalkerState walkWithBankedTransportsAndStateLocked(WorldPoint tar } + private static WalkerState bootstrapBankMirrorForBankedPathing(int distance) { + WorldPoint start = Rs2Player.getWorldLocation(); + if (start == null) { + return WalkerState.MOVING; + } + BankLocation nearestBank = Rs2Bank.getNearestBank(start); + if (nearestBank == null || nearestBank.getWorldPoint() == null) { + WebWalkLog.spWarn("bank_cache_bootstrap | no_nearest_bank start={}", start); + return WalkerState.EXIT; + } + + WorldPoint bankLocation = nearestBank.getWorldPoint(); + WebWalkLog.spInfo("bank_cache_bootstrap | epoch={} start={} bank={}", + Rs2Bank.getBankLiveEpoch(), start, bankLocation); + + WalkerState walkToBank = walkWithStateInternal(bankLocation, distance); + if (walkToBank != WalkerState.ARRIVED) { + WebWalkLog.spWarn("bank_cache_bootstrap | walk_to_bank_failed state={} bank={}", + walkToBank, bankLocation); + return walkToBank; + } + + int epochBefore = Rs2Bank.getBankLiveEpoch(); + boolean wasOpen = Rs2Bank.isOpen(); + if (!Rs2Bank.openBank()) { + WebWalkLog.spWarn("bank_cache_bootstrap | open_bank_failed bank={}", bankLocation); + return WalkerState.EXIT; + } + boolean mirrorReady = Rs2Bank.verifyBankMirrorAfterOpen(wasOpen, epochBefore); + WebWalkLog.spInfo("bank_cache_bootstrap_done | ready={} epochBefore={} epochAfter={} bank={}", + mirrorReady, epochBefore, Rs2Bank.getBankLiveEpoch(), bankLocation); + + Rs2Bank.closeBank(); + sleepUntil(() -> !Rs2Bank.isOpen(), 3_000); + return WalkerState.ARRIVED; + } + @@ -5308,3 +8276,4 @@ public static boolean closeWorldMap() { return sleepUntil(() -> !Rs2Widget.isWidgetVisible(InterfaceID.Worldmap.CLOSE), 3000); } } + diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysis.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysis.java index 2ea793fcbe4..ca153e7f97a 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysis.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysis.java @@ -32,6 +32,12 @@ public class TransportRouteAnalysis { /** Path of WorldPoints from bank to destination, accounting for items available in bank */ private final List pathFromBank; + + /** Explicit direct distance captured at analysis time (tiles), or -1 if unavailable */ + private final int directDistance; + + /** Explicit banking distance captured at analysis time (tiles), or -1 if unavailable */ + private final int bankingRouteDistance; /** Summary text describing the analysis results and recommendation */ private final String analysis; @@ -49,12 +55,23 @@ public class TransportRouteAnalysis { public TransportRouteAnalysis(List directPath, BankLocation nearestBank, WorldPoint bankLocation,List pathToBank, List pathFromBank,String analysis) { + this(directPath, nearestBank, bankLocation, pathToBank, pathFromBank, analysis, + directPath == null || directPath.isEmpty() ? -1 : directPath.size(), + deriveBankingRouteDistance(pathToBank, pathFromBank)); + } + + public TransportRouteAnalysis(List directPath, + BankLocation nearestBank, WorldPoint bankLocation, List pathToBank, + List pathFromBank, String analysis, + int directDistance, int bankingRouteDistance) { this.directPath = directPath; this.nearestBank = nearestBank; this.bankLocation = bankLocation; this.pathToBank = pathToBank; this.pathFromBank = pathFromBank; this.analysis = analysis; + this.directDistance = directDistance; + this.bankingRouteDistance = bankingRouteDistance; } /** @@ -62,8 +79,7 @@ public TransportRouteAnalysis(List directPath, * @return The direct route distance, or -1 if path is empty or invalid */ public int getDirectDistance() { - if (directPath == null || directPath.isEmpty()) return -1; - return directPath.size(); + return directDistance; } /** @@ -71,9 +87,7 @@ public int getDirectDistance() { * @return The total banking route distance (to bank + from bank), or -1 if paths are invalid */ public int getBankingRouteDistance() { - if (pathToBank == null || pathFromBank == null || - pathToBank.isEmpty() || pathFromBank.isEmpty()) return -1; - return pathToBank.size() + pathFromBank.size(); + return bankingRouteDistance; } public int getTileSavings() { @@ -82,6 +96,12 @@ public int getTileSavings() { if (directDist == -1 || bankingDist == -1) return 0; return Math.abs(directDist - bankingDist); } + + public boolean isTie() { + int directDist = getDirectDistance(); + int bankingDist = getBankingRouteDistance(); + return directDist != -1 && bankingDist != -1 && directDist == bankingDist; + } /** * Determines if the direct route is faster than the banking route. @@ -100,6 +120,12 @@ public boolean isDirectIsFaster() { // When equal, direct is considered faster (maintaining existing logic) return directDist <= bankingDist; } + + private static int deriveBankingRouteDistance(List pathToBank, List pathFromBank) { + if (pathToBank == null || pathFromBank == null || + pathToBank.isEmpty() || pathFromBank.isEmpty()) return -1; + return pathToBank.size() + pathFromBank.size(); + } @Override public String toString() { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/WebWalkLog.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/WebWalkLog.java new file mode 100644 index 00000000000..cec154d8129 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/WebWalkLog.java @@ -0,0 +1,151 @@ +package net.runelite.client.plugins.microbot.util.walker; + +import net.runelite.api.coords.WorldPoint; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Single tag {@code [WebWalk]} for walker + ShortestPath + pathfinder observability: + * INFO/WARN for outcomes and decisions; DEBUG for per-run volume (pf/cfg/leagues/yields). + */ +public final class WebWalkLog { + private static final Logger LOG = LoggerFactory.getLogger(WebWalkLog.class); + + private WebWalkLog() { + } + + public static void routeClear(String reason) { + LOG.info("[WebWalk] clear | {}", reason); + } + + public static void routeClearMissingReason(String threadName) { + LOG.warn("[WebWalk] clear | reason= thread={}", threadName); + } + + /** Cancel / EXIT / traceProcessWalkExit — compact WARN for production diagnosis. */ + public static void exitWarn(String reason, boolean nullCurrent, boolean targetMismatch, boolean interrupted, + WorldPoint goal, WorldPoint current, int tailIdx, int tailMax, WorldPoint player) { + LOG.warn("[WebWalk] exit | r={} nullCur={} mismatch={} intr={} goal={} cur={} tail={}/{} at={}", + reason, nullCurrent, targetMismatch, interrupted, goal, current, tailIdx, tailMax, player); + } + + public static void exitDetailDebug(String fmt, Object... args) { + LOG.debug("[WebWalk] exit_dbg | " + fmt, args); + } + + /** interim-in-flight and similar yields — DEBUG to avoid tick spam. */ + public static void yieldDebug(String reason, WorldPoint player, WorldPoint goal, WorldPoint pathEnd, int idxStart, int pathLen) { + LOG.debug("[WebWalk] yield | r={} player={} goal={} end={} idx={}/{}", + reason, player, goal, pathEnd, idxStart, pathLen); + } + + public static void earlyExit(String reason, WorldPoint player, WorldPoint goal, WorldPoint pathEnd, int idxStart, int pathLen) { + LOG.info("[WebWalk] early_exit | r={} at={} goal={} end={} idx={}/{}", + reason, player, goal, pathEnd, idxStart, pathLen); + } + + /** Path ends far from goal — walking multi-hop segment. */ + public static void partialSegment(WorldPoint pathEnd, int distToGoal, WorldPoint goal, int waypointCount) { + LOG.info("[WebWalk] partial_seg | end={} dGoal={} goal={} nWp={}", pathEnd, distToGoal, goal, waypointCount); + } + + public static void partialRetry(int finalDist, int attempt, int maxAttempts) { + LOG.info("[WebWalk] partial_retry | dist={} attempt={}/{}", finalDist, attempt, maxAttempts); + } + + public static void partialExhausted(int finalDist) { + LOG.info("[WebWalk] partial_exhausted | finalDist={}", finalDist); + } + + public static void interruptedExit(String detail) { + LOG.info("[WebWalk] interrupt | {}", detail); + } + + public static void stallContextDebug(WorldPoint lastClick, boolean clickOk, long clickAgeMs, WorldPoint interim) { + LOG.debug("[WebWalk] stall_ctx | lastClick={} ok={} ageMs={} interim={}", lastClick, clickOk, clickAgeMs, interim); + } + + /** Off-path / unreachable recovery / generic replan — single INFO line. */ + public static void recalc(String reason) { + LOG.info("[WebWalk] recalc | {}", reason); + } + + public static void partialRecalc(int remainingSteps, int distToSeg, int distToGoal, WorldPoint segEnd, WorldPoint goal) { + LOG.info("[WebWalk] partial_recalc | remSteps={} dSeg={} dGoal={} segEnd={} goal={}", + remainingSteps, distToSeg, distToGoal, segEnd, goal); + } + + public static void stallRecalc(long sinceMovedMs, long thresholdMs, boolean combat, boolean anim, boolean interact) { + LOG.info("[WebWalk] stall_recalc | sinceMs={} thrMs={} combat={} anim={} interact={}", + sinceMovedMs, thresholdMs, combat, anim, interact); + } + + public static void tailExceeded(int maxTail, WorldPoint target, WorldPoint current, WorldPoint interim, int stuck, + WorldPoint player) { + LOG.warn("[WebWalk] tail_max | max={} goal={} cur={} interim={} stuck={} at={}", + maxTail, target, current, interim, stuck, player); + } + + /** Pathfinder {@link net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder} — DEBUG volume. */ + public static void pf(String fmt, Object... args) { + LOG.debug("[WebWalk] pf | " + fmt, args); + } + + /** {@link net.runelite.client.plugins.microbot.shortestpath.pathfinder.PathfinderConfig} refresh — DEBUG volume. */ + public static void cfg(String fmt, Object... args) { + LOG.debug("[WebWalk] cfg | " + fmt, args); + } + + public static void leagues(String fmt, Object... args) { + LOG.debug("[WebWalk] leagues | " + fmt, args); + } + + /** Leagues calibration, region unlock, explicit no-op — INFO (not tick-spam paths). */ + public static void leaguesInfo(String fmt, Object... args) { + LOG.info("[WebWalk] leagues | " + fmt, args); + } + + public static void spWarn(String fmt, Object... args) { + LOG.warn("[WebWalk] sp | " + fmt, args); + } + + public static void spInfo(String fmt, Object... args) { + LOG.info("[WebWalk] sp | " + fmt, args); + } + + public static void spDebug(String fmt, Object... args) { + LOG.debug("[WebWalk] sp | " + fmt, args); + } + + /** One INFO line; full blob only at DEBUG. */ + public static void compareSummary(double totalMs, int directTiles, int bankTiles, String verdictOneLine) { + LOG.info("[WebWalk] compare | {}ms direct={}t bank={}t | {}", + String.format("%.1f", totalMs), directTiles, bankTiles, verdictOneLine); + } + + public static void compareDetail(String multiline) { + LOG.debug("[WebWalk] compare_detail\n{}", multiline); + } + + public static void compareError(double totalMs, WorldPoint target, String err) { + LOG.warn("[WebWalk] compare_err | {}ms target={} err={}", String.format("%.1f", totalMs), target, err); + } + + /** Banked-route helper: per-path transport scan — DEBUG volume. */ + public static void bankPathTransportsDebug(int count, WorldPoint from, WorldPoint to) { + LOG.debug("[WebWalk] bank_path | transports={} {} -> {}", count, from, to); + } + + public static void bankWalkDebug(String fmt, Object... args) { + LOG.debug("[WebWalk] bank_walk | " + fmt, args); + } + + public static void bankWalkFailed(WorldPoint goal, Object walkerState) { + LOG.warn("[WebWalk] bank_walk | failed goal={} state={}", goal, walkerState); + } + + public static void tmark(String phase, long elapsedMs, WorldPoint goal, WorldPoint at, String detail) { + LOG.info("[WebWalk] tmark | phase={} elapsed={}ms goal={} at={} detail={}", + phase, elapsedMs, goal, at, detail == null ? "-" : detail); + } +} From 98c83c543d7992d73602530df09b043d8a54a1cd Mon Sep 17 00:00:00 2001 From: Haliax <18099301+TheHaliax@users.noreply.github.com> Date: Mon, 11 May 2026 08:43:47 -0500 Subject: [PATCH 6/9] test(shortestpath): refresh walker regressions and baseline Update walker regression coverage and regenerate client-thread guardrail baseline. --- runelite-client/build.gradle.kts | 1 + .../ShortestPathTier1RegressionTest.java | 4 +- .../util/walker/Rs2WalkerIntegrationTest.java | 9 +- .../util/walker/Rs2WalkerUnitTest.java | 93 ++++++++ .../util/walker/door/Rs2WalkerAwaitsTest.java | 26 +++ .../client-thread-guardrail-baseline.txt | 206 +++++++++++------- 6 files changed, 250 insertions(+), 89 deletions(-) create mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java diff --git a/runelite-client/build.gradle.kts b/runelite-client/build.gradle.kts index e653ebf4072..c88a92dafcb 100644 --- a/runelite-client/build.gradle.kts +++ b/runelite-client/build.gradle.kts @@ -186,6 +186,7 @@ tasks.register("runUnitTests") { exclude("**/Rs2WalkerIntegrationTest.class") exclude("**/Rs2ReflectionGroundItemActionsIntegrationTest.class") exclude("**/threadsafety/ClientThreadScannerTest.class") + exclude("**/ScreenshotHandlerTest.class") useJUnit() diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathTier1RegressionTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathTier1RegressionTest.java index 36477ab1715..56382d46d42 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathTier1RegressionTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathTier1RegressionTest.java @@ -275,7 +275,7 @@ public void bug5_nullConfigLeavesFlagSoRefreshHappensWhenConfigArrives() { } @Test - public void bug5_refreshExceptionIsSwallowedAndFlagStillCleared() { + public void bug5_refreshExceptionLeavesFlagSetForNextTickRetry() { ShortestPathPlugin plugin = new ShortestPathPlugin(); PathfinderConfig cfg = mock(PathfinderConfig.class); org.mockito.Mockito.doThrow(new RuntimeException("boom")).when(cfg).refresh(); @@ -284,7 +284,7 @@ public void bug5_refreshExceptionIsSwallowedAndFlagStillCleared() { plugin.handlePendingLoginRefresh(); - assertFalse("failing refresh must still clear flag to avoid busy-looping", + assertTrue("failing refresh keeps flag so handlePendingLoginRefresh retries after login/hydrate stabilizes", plugin.pendingLoginRefresh); verify(cfg, times(1)).refresh(); } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerIntegrationTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerIntegrationTest.java index 6fb2f57b55d..30da8c13b2c 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerIntegrationTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerIntegrationTest.java @@ -134,8 +134,11 @@ public void testPathfinderCreationAndCompletion() throws Exception { WorldPoint nearbyTarget = new WorldPoint(playerLoc.getX() + 10, playerLoc.getY() + 10, playerLoc.getPlane()); log.info("Player at: {}, target: {}", playerLoc, nearbyTarget); - Rs2Walker.setTarget(null); - Thread.sleep(500); + Rs2Walker.clearWalkingRoute("test:cleanup"); + long clearDeadline = System.currentTimeMillis() + 2000; + while (ShortestPathPlugin.getPathfinder() != null && System.currentTimeMillis() < clearDeadline) { + Thread.sleep(100); + } log.info("Setting target..."); Rs2Walker.setTarget(nearbyTarget); @@ -167,7 +170,7 @@ public void testPathfinderCreationAndCompletion() throws Exception { log.error("Pathfinder did NOT complete within 15 seconds!"); } - Rs2Walker.setTarget(null); + Rs2Walker.clearWalkingRoute("test:cleanup"); assertTrue("Pathfinder should complete within 15 seconds", done); } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java index 3cf267f366a..10363aed350 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerUnitTest.java @@ -41,12 +41,14 @@ public class Rs2WalkerUnitTest { @Before public void resetTelemetry() { + Rs2Walker.clearWalkerDedupeForTesting(); Rs2Walker.Telemetry.reset(); Rs2Walker.sessionBlacklistedDoors.clear(); } @After public void tearDown() { + Rs2Walker.clearWalkerDedupeForTesting(); Rs2Walker.Telemetry.reset(); Rs2Walker.sessionBlacklistedDoors.clear(); } @@ -426,6 +428,97 @@ public void wallDoorTouchesSegment_startingBesideDoorAndMovingAway_returnsFalse( new WorldPoint(3122, 3359, 0))); } + @Test + public void didTraverseInteractedDoor_crossesDoorTowardSegmentDestination_returnsTrue() { + assertTrue(Rs2Walker.didTraverseInteractedDoor( + new WorldPoint(2465, 3494, 0), + new WorldPoint(2465, 3493, 0), + new WorldPoint(2465, 3493, 0), + new WorldPoint(2465, 3494, 0), + new WorldPoint(2465, 3493, 0))); + } + + @Test + public void didTraverseInteractedDoor_movesWithoutCrossingObject_returnsFalse() { + assertFalse(Rs2Walker.didTraverseInteractedDoor( + new WorldPoint(2465, 3494, 0), + new WorldPoint(2465, 3495, 0), + new WorldPoint(2465, 3493, 0), + new WorldPoint(2465, 3494, 0), + new WorldPoint(2465, 3493, 0))); + } + + @Test + public void didTraverseInteractedDoor_crossesObjectButMovesAwayFromDestination_returnsFalse() { + assertFalse(Rs2Walker.didTraverseInteractedDoor( + new WorldPoint(1987, 5568, 0), + new WorldPoint(1986, 5568, 0), + new WorldPoint(1987, 5568, 0), + new WorldPoint(1987, 5568, 0), + new WorldPoint(1988, 5568, 0))); + } + + @Test + public void shouldBlacklistDoorAfterWrongTraversal_teleportAway_returnsTrue() { + assertTrue(Rs2Walker.shouldBlacklistDoorAfterWrongTraversal( + new WorldPoint(1987, 5568, 0), + new WorldPoint(2435, 3519, 0), + new WorldPoint(1987, 5568, 0), + new WorldPoint(1988, 5569, 0))); + } + + @Test + public void shouldBlacklistDoorAfterWrongTraversal_progressTowardEdge_returnsFalse() { + assertFalse(Rs2Walker.shouldBlacklistDoorAfterWrongTraversal( + new WorldPoint(2465, 3494, 0), + new WorldPoint(2465, 3493, 0), + new WorldPoint(2465, 3494, 0), + new WorldPoint(2465, 3493, 0))); + } + + @Test + public void markDoorEdgeAttemptThisPass_allowsFirstAttemptOnly() { + java.util.Map attempted = new java.util.HashMap<>(); + WorldPoint[] segment = new WorldPoint[] { + new WorldPoint(2465, 3494, 0), + new WorldPoint(2465, 3493, 0) + }; + + WorldPoint playerPos = new WorldPoint(2465, 3494, 0); + assertTrue(Rs2Walker.markDoorEdgeAttemptThisPass(attempted, segment, playerPos)); + assertFalse(Rs2Walker.markDoorEdgeAttemptThisPass(attempted, segment, playerPos)); + } + + @Test + public void markDoorEdgeAttemptThisPass_treatsReverseEdgeAsDuplicate() { + java.util.Map attempted = new java.util.HashMap<>(); + WorldPoint[] forward = new WorldPoint[] { + new WorldPoint(2465, 3494, 0), + new WorldPoint(2465, 3493, 0) + }; + WorldPoint[] reverse = new WorldPoint[] { + new WorldPoint(2465, 3493, 0), + new WorldPoint(2465, 3494, 0) + }; + + WorldPoint playerPos = new WorldPoint(2465, 3494, 0); + assertTrue(Rs2Walker.markDoorEdgeAttemptThisPass(attempted, forward, playerPos)); + assertFalse(Rs2Walker.markDoorEdgeAttemptThisPass(attempted, reverse, playerPos)); + } + + @Test + public void markDoorEdgeAttemptThisPass_allowsRetryAfterPlayerProgress() { + java.util.Map attempted = new java.util.HashMap<>(); + WorldPoint[] segment = new WorldPoint[] { + new WorldPoint(2465, 3494, 0), + new WorldPoint(2465, 3493, 0) + }; + + assertTrue(Rs2Walker.markDoorEdgeAttemptThisPass(attempted, segment, new WorldPoint(2465, 3494, 0))); + assertTrue("retry should be allowed after moving away from same-edge attempt tile", + Rs2Walker.markDoorEdgeAttemptThisPass(attempted, segment, new WorldPoint(2462, 3491, 0))); + } + // --------------------------------------------------------------------------- // #19 — Quest-lock dialogue heuristic // --------------------------------------------------------------------------- diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java new file mode 100644 index 00000000000..5a666220478 --- /dev/null +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java @@ -0,0 +1,26 @@ +package net.runelite.client.plugins.microbot.util.walker.door; + +import org.junit.Test; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class Rs2WalkerAwaitsTest { + @Test + public void shouldAcceptIdleDoorAwait_requiresResolvedEdge() { + assertFalse(Rs2WalkerAwaits.shouldAcceptIdleDoorAwait(false, false, 1300L, false)); + assertTrue(Rs2WalkerAwaits.shouldAcceptIdleDoorAwait(false, false, 1301L, true)); + } + + @Test + public void shouldAcceptIdleDoorAwait_rejectsMovingOrAnimating() { + assertFalse(Rs2WalkerAwaits.shouldAcceptIdleDoorAwait(true, false, 5000L, true)); + assertFalse(Rs2WalkerAwaits.shouldAcceptIdleDoorAwait(false, true, 5000L, true)); + } + + @Test + public void shouldAcceptIdleDoorAwait_rejectsBeforeMinimumElapsed() { + assertFalse(Rs2WalkerAwaits.shouldAcceptIdleDoorAwait(false, false, 1200L, true)); + assertFalse(Rs2WalkerAwaits.shouldAcceptIdleDoorAwait(false, false, 800L, true)); + } +} diff --git a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt index cf4dd21500d..5f7d367bb46 100644 --- a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt +++ b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt @@ -83,26 +83,38 @@ net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#ge net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#getWorldLocation(): WorldPoint -> net.runelite.api.WorldView#getPlane(): int net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#getWorldLocation(): WorldPoint -> net.runelite.api.coords.WorldPoint#fromScene(WorldView, int, int, int): WorldPoint net.runelite.client.plugins.microbot.api.tileobject.models.Rs2TileObjectModel#isReachable(): boolean -> net.runelite.api.WorldView#getId(): int +net.runelite.client.plugins.microbot.util.Rs2InventorySetup#computeSetupRetainItemIds(): Set -> net.runelite.api.ItemComposition#getLinkedNoteId(): int +net.runelite.client.plugins.microbot.util.Rs2InventorySetup#firstNonEmptyCompositionName(ItemComposition): String -> net.runelite.api.ItemComposition#getMembersName(): String +net.runelite.client.plugins.microbot.util.Rs2InventorySetup#firstNonEmptyCompositionName(ItemComposition): String -> net.runelite.api.ItemComposition#getName(): String +net.runelite.client.plugins.microbot.util.Rs2InventorySetup#getItemDefinitionThreadSafe(int): ItemComposition -> net.runelite.api.Client#getItemDefinition(int): ItemComposition +net.runelite.client.plugins.microbot.util.Rs2InventorySetup#getItemDefinitionThreadSafe(int): ItemComposition -> net.runelite.api.Client#isClientThread(): boolean +net.runelite.client.plugins.microbot.util.Rs2InventorySetup#setupRowIsStackableByDefinition(InventorySetupsItem): boolean -> net.runelite.api.ItemComposition#isStackable(): boolean +net.runelite.client.plugins.microbot.util.Rs2InventorySetup#validateInventorySetupAgainstDefsIfEnabled(): void -> net.runelite.api.ItemComposition#isStackable(): boolean net.runelite.client.plugins.microbot.util.antiban.Rs2Antiban#checkForCookingEvent(ChatMessage): boolean -> net.runelite.api.events.ChatMessage#getMessage(): String net.runelite.client.plugins.microbot.util.antiban.Rs2Antiban#checkForCookingEvent(ChatMessage): boolean -> net.runelite.api.events.ChatMessage#getType(): ChatMessageType net.runelite.client.plugins.microbot.util.bank.Rs2Bank#calculateScrollYFromSlotId(int): int -> net.runelite.api.Client#getWidget(int): Widget net.runelite.client.plugins.microbot.util.bank.Rs2Bank#calculateScrollYFromSlotId(int): int -> net.runelite.api.widgets.Widget#getHeight(): int net.runelite.client.plugins.microbot.util.bank.Rs2Bank#closeCollectionBox(): boolean -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] net.runelite.client.plugins.microbot.util.bank.Rs2Bank#depositEquipment(): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle +net.runelite.client.plugins.microbot.util.bank.Rs2Bank#findBankStackRowForSavedId(int): Rs2ItemModel -> net.runelite.api.ItemComposition#getLinkedNoteId(): int +net.runelite.client.plugins.microbot.util.bank.Rs2Bank#findBankStackRowForSavedId(int): Rs2ItemModel -> net.runelite.api.ItemComposition#getMembersName(): String +net.runelite.client.plugins.microbot.util.bank.Rs2Bank#findBankStackRowForSavedId(int): Rs2ItemModel -> net.runelite.api.ItemComposition#getName(): String net.runelite.client.plugins.microbot.util.bank.Rs2Bank#findLockedSlots(): List -> net.runelite.api.Client#getWidget(int): Widget net.runelite.client.plugins.microbot.util.bank.Rs2Bank#findLockedSlots(): List -> net.runelite.api.widgets.Widget#getActions(): String[] net.runelite.client.plugins.microbot.util.bank.Rs2Bank#findLockedSlots(): List -> net.runelite.api.widgets.Widget#getChildren(): Widget[] net.runelite.client.plugins.microbot.util.bank.Rs2Bank#getBankItemCount(): int -> net.runelite.api.widgets.Widget#getText(): String net.runelite.client.plugins.microbot.util.bank.Rs2Bank#getItemBounds(int): Rectangle -> net.runelite.api.widgets.Widget#getBounds(): Rectangle +net.runelite.client.plugins.microbot.util.bank.Rs2Bank#getItemDefinitionThreadSafe(int): ItemComposition -> net.runelite.api.Client#getItemDefinition(int): ItemComposition +net.runelite.client.plugins.microbot.util.bank.Rs2Bank#getItemDefinitionThreadSafe(int): ItemComposition -> net.runelite.api.Client#isClientThread(): boolean net.runelite.client.plugins.microbot.util.bank.Rs2Bank#isLockedSlot(int): boolean -> net.runelite.api.Client#getWidget(int): Widget net.runelite.client.plugins.microbot.util.bank.Rs2Bank#isLockedSlot(int): boolean -> net.runelite.api.widgets.Widget#getChild(int): Widget net.runelite.client.plugins.microbot.util.bank.Rs2Bank#isLockedSlot(int): boolean -> net.runelite.api.widgets.Widget#getChildren(): Widget[] net.runelite.client.plugins.microbot.util.bank.Rs2Bank#isWidgetLocked(Widget): boolean -> net.runelite.api.widgets.Widget#getActions(): String[] net.runelite.client.plugins.microbot.util.bank.Rs2Bank#itemBounds(Rs2ItemModel): Rectangle -> net.runelite.api.widgets.Widget#getBounds(): Rectangle -net.runelite.client.plugins.microbot.util.bank.Rs2Bank#lambda$depositLootingBag$46(): boolean -> net.runelite.api.widgets.Widget#getChildren(): Widget[] -net.runelite.client.plugins.microbot.util.bank.Rs2Bank#lambda$getPathAndBankToNearestBank$36(TileObject, BankLocation): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.bank.Rs2Bank#lambda$getPathAndBankToNearestBank$37(Set, TileObject): AbstractMap$SimpleEntry -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.bank.Rs2Bank#lambda$handleAmount$15(): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.bank.Rs2Bank#lambda$depositLootingBag$49(): boolean -> net.runelite.api.widgets.Widget#getChildren(): Widget[] +net.runelite.client.plugins.microbot.util.bank.Rs2Bank#lambda$getPathAndBankToNearestBank$39(TileObject, BankLocation): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.bank.Rs2Bank#lambda$getPathAndBankToNearestBank$40(Set, TileObject): AbstractMap$SimpleEntry -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.bank.Rs2Bank#lambda$handleAmount$17(): boolean -> net.runelite.api.widgets.Widget#getText(): String net.runelite.client.plugins.microbot.util.bank.Rs2Bank#openBank(): boolean -> net.runelite.api.Client#isWidgetSelected(): boolean net.runelite.client.plugins.microbot.util.bank.Rs2Bank#openCollectionBox(): boolean -> net.runelite.api.Client#isWidgetSelected(): boolean net.runelite.client.plugins.microbot.util.bank.Rs2Bank#scrollBankToSlot(int): boolean -> net.runelite.api.Client#getWidget(int): Widget @@ -471,6 +483,9 @@ net.runelite.client.plugins.microbot.util.item.Rs2ExplorersRing#interact(Rs2Item net.runelite.client.plugins.microbot.util.item.Rs2ExplorersRing#interact(Rs2ItemModel): boolean -> net.runelite.api.widgets.Widget#getItemId(): int net.runelite.client.plugins.microbot.util.item.Rs2ItemManager#getPrice(int): int -> net.runelite.api.ItemComposition#getPrice(): int net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard#getCanvas(): Canvas -> net.runelite.api.Client#getCanvas(): Canvas +net.runelite.client.plugins.microbot.util.leaguetransport.Rs2MapOfAlacrityTransport#collectMoaChildren(Widget): List -> net.runelite.api.widgets.Widget#getChildren(): Widget[] +net.runelite.client.plugins.microbot.util.leaguetransport.Rs2MapOfAlacrityTransport#collectMoaChildren(Widget): List -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] +net.runelite.client.plugins.microbot.util.leaguetransport.Rs2MapOfAlacrityTransport#collectMoaChildren(Widget): List -> net.runelite.api.widgets.Widget#getText(): String net.runelite.client.plugins.microbot.util.magic.Rs2Magic#alch(MagicAction, Rs2ItemModel, int, int): void -> net.runelite.api.widgets.Widget#getBounds(): Rectangle net.runelite.client.plugins.microbot.util.magic.Rs2Magic#alch(Rs2ItemModel, int, int): void -> net.runelite.api.Client#getRealSkillLevel(Skill): int net.runelite.client.plugins.microbot.util.magic.Rs2Magic#canCast(MagicAction): boolean -> net.runelite.api.widgets.Widget#getStaticChildren(): Widget[] @@ -640,52 +655,53 @@ net.runelite.client.plugins.microbot.util.tile.Rs2Tile#addDangerousGraphicsObjec net.runelite.client.plugins.microbot.util.tile.Rs2Tile#addDangerousGraphicsObjectTile(GraphicsObject, int): void -> net.runelite.api.coords.WorldPoint#fromLocalInstance(Client, LocalPoint): WorldPoint net.runelite.client.plugins.microbot.util.tile.Rs2Tile#addDangerousGraphicsObjectTileForInstances(GraphicsObject, int): void -> net.runelite.api.GraphicsObject#getLocation(): LocalPoint net.runelite.client.plugins.microbot.util.tile.Rs2Tile#addDangerousGraphicsObjectTileForInstances(GraphicsObject, int): void -> net.runelite.api.coords.WorldPoint#fromLocalInstance(Client, LocalPoint): WorldPoint -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#fullPathTo(Tile, Tile): List -> net.runelite.api.Client#getScene(): Scene -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#fullPathTo(Tile, Tile): List -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#fullPathTo(Tile, Tile): List -> net.runelite.api.CollisionData#getFlags(): int[][] -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#fullPathTo(Tile, Tile): List -> net.runelite.api.Scene#getTiles(): Tile[][][] -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#fullPathTo(Tile, Tile): List -> net.runelite.api.WorldView#getCollisionMaps(): CollisionData[] -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getFlags(): int[][] -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getFlags(): int[][] -> net.runelite.api.CollisionData#getFlags(): int[][] -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getFlags(): int[][] -> net.runelite.api.WorldView#getCollisionMaps(): CollisionData[] -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getFlags(): int[][] -> net.runelite.api.WorldView#getPlane(): int -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getNearestWalkableTile(GameObject): Rs2WorldPoint -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getNearestWalkableTile(GameObject): Rs2WorldPoint -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getNearestWalkableTileWithLineOfSight(WorldPoint): WorldPoint -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getReachableTilesFromTile(WorldPoint, int, boolean): HashMap -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getReachableTilesFromTile(WorldPoint, int, boolean): HashMap -> net.runelite.api.CollisionData#getFlags(): int[][] -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getReachableTilesFromTile(WorldPoint, int, boolean): HashMap -> net.runelite.api.WorldView#getCollisionMaps(): CollisionData[] -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getReachableTilesFromTile(WorldPoint, int, boolean): HashMap -> net.runelite.api.WorldView#getPlane(): int -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getReachableTilesFromTile(WorldPoint, int, boolean): HashMap -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getTile(int, int): Tile -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getTile(int, int): Tile -> net.runelite.api.Scene#getTiles(): Tile[][][] -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getTile(int, int): Tile -> net.runelite.api.Scene#isInstance(): boolean -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getTile(int, int): Tile -> net.runelite.api.WorldView#getPlane(): int -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getTile(int, int): Tile -> net.runelite.api.WorldView#getScene(): Scene -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getTile(int, int): Tile -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getWalkableTilesAroundTile(WorldPoint, int): List -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getWalkableTilesAroundTile(WorldPoint, int): List -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isBankBooth(WorldPoint): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isTileReachable(WorldPoint): boolean -> net.runelite.api.Client#getBaseX(): int -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isTileReachable(WorldPoint): boolean -> net.runelite.api.Client#getBaseY(): int -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isTileReachable(WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isTileReachable(WorldPoint): boolean -> net.runelite.api.Scene#isInstance(): boolean -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isTileReachable(WorldPoint): boolean -> net.runelite.api.WorldView#getScene(): Scene -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isValidTile(Tile): boolean -> net.runelite.api.Client#getPlane(): int -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isValidTile(Tile): boolean -> net.runelite.api.CollisionData#getFlags(): int[][] +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#fullPathToInternal(Tile, Tile): List -> net.runelite.api.Client#getScene(): Scene +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#fullPathToInternal(Tile, Tile): List -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#fullPathToInternal(Tile, Tile): List -> net.runelite.api.CollisionData#getFlags(): int[][] +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#fullPathToInternal(Tile, Tile): List -> net.runelite.api.Scene#getTiles(): Tile[][][] +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#fullPathToInternal(Tile, Tile): List -> net.runelite.api.WorldView#getCollisionMaps(): CollisionData[] +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getFlagsInternal(): int[][] -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getFlagsInternal(): int[][] -> net.runelite.api.CollisionData#getFlags(): int[][] +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getFlagsInternal(): int[][] -> net.runelite.api.WorldView#getCollisionMaps(): CollisionData[] +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getFlagsInternal(): int[][] -> net.runelite.api.WorldView#getPlane(): int +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getNearestWalkableTileForObjectInternal(GameObject): Rs2WorldPoint -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getNearestWalkableTileForObjectInternal(GameObject): Rs2WorldPoint -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getNearestWalkableTileWithLineOfSightInternal(WorldPoint): WorldPoint -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getReachableTilesFromTileInternal(WorldPoint, int, boolean): HashMap -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getReachableTilesFromTileInternal(WorldPoint, int, boolean): HashMap -> net.runelite.api.CollisionData#getFlags(): int[][] +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getReachableTilesFromTileInternal(WorldPoint, int, boolean): HashMap -> net.runelite.api.WorldView#getCollisionMaps(): CollisionData[] +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getReachableTilesFromTileInternal(WorldPoint, int, boolean): HashMap -> net.runelite.api.WorldView#getPlane(): int +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getReachableTilesFromTileInternal(WorldPoint, int, boolean): HashMap -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getTileInternal(int, int): Tile -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getTileInternal(int, int): Tile -> net.runelite.api.Scene#getTiles(): Tile[][][] +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getTileInternal(int, int): Tile -> net.runelite.api.Scene#isInstance(): boolean +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getTileInternal(int, int): Tile -> net.runelite.api.WorldView#getPlane(): int +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getTileInternal(int, int): Tile -> net.runelite.api.WorldView#getScene(): Scene +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getTileInternal(int, int): Tile -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getWalkableTilesAroundTileInternal(WorldPoint, int): List -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getWalkableTilesAroundTileInternal(WorldPoint, int): List -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isBankBoothInternal(WorldPoint): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isTileReachableInternal(WorldPoint): boolean -> net.runelite.api.Client#getBaseX(): int +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isTileReachableInternal(WorldPoint): boolean -> net.runelite.api.Client#getBaseY(): int +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isTileReachableInternal(WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isTileReachableInternal(WorldPoint): boolean -> net.runelite.api.Scene#isInstance(): boolean +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isTileReachableInternal(WorldPoint): boolean -> net.runelite.api.WorldView#getScene(): Scene +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isValidTileInternal(Tile): boolean -> net.runelite.api.Client#getPlane(): int +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isValidTileInternal(Tile): boolean -> net.runelite.api.CollisionData#getFlags(): int[][] net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isVisited(WorldPoint, boolean[][]): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isVisited(WorldPoint, boolean[][]): boolean -> net.runelite.api.Scene#isInstance(): boolean net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isVisited(WorldPoint, boolean[][]): boolean -> net.runelite.api.WorldView#getBaseX(): int net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isVisited(WorldPoint, boolean[][]): boolean -> net.runelite.api.WorldView#getBaseY(): int net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isVisited(WorldPoint, boolean[][]): boolean -> net.runelite.api.WorldView#getScene(): Scene -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isWalkable(WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isWalkable(WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#lambda$isBankBooth$20(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#pathTo(Tile, Tile): List -> net.runelite.api.Client#getScene(): Scene -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#pathTo(Tile, Tile): List -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#pathTo(Tile, Tile): List -> net.runelite.api.CollisionData#getFlags(): int[][] -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#pathTo(Tile, Tile): List -> net.runelite.api.Scene#getTiles(): Tile[][][] -net.runelite.client.plugins.microbot.util.tile.Rs2Tile#pathTo(Tile, Tile): List -> net.runelite.api.WorldView#getCollisionMaps(): CollisionData[] +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isWalkableWorldPointInternal(WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#isWalkableWorldPointInternal(WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#lambda$isBankBoothInternal$33(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#pathToInternal(Tile, Tile): List -> net.runelite.api.Client#getScene(): Scene +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#pathToInternal(Tile, Tile): List -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#pathToInternal(Tile, Tile): List -> net.runelite.api.CollisionData#getFlags(): int[][] +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#pathToInternal(Tile, Tile): List -> net.runelite.api.Scene#getTiles(): Tile[][][] +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#pathToInternal(Tile, Tile): List -> net.runelite.api.WorldView#getCollisionMaps(): CollisionData[] +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#runClientRead(Supplier, Object): Object -> net.runelite.api.Client#isClientThread(): boolean net.runelite.client.plugins.microbot.util.tileobject.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.Client#getLocalPlayer(): Player net.runelite.client.plugins.microbot.util.tileobject.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.Client#isWidgetSelected(): boolean net.runelite.client.plugins.microbot.util.tileobject.Rs2TileObjectModel#click(String): boolean -> net.runelite.api.GameObject#sizeX(): int @@ -722,6 +738,8 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleCharterShip(Tra net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleCharterShip(Transport): boolean -> net.runelite.api.widgets.Widget#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleCharterShip(Transport): boolean -> net.runelite.api.widgets.Widget#getIndex(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.ObjectComposition#getImpostorIds(): int[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.ObjectComposition#getName(): String net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.Scene#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleDoors(List, int, boolean): boolean -> net.runelite.api.WorldView#getScene(): Scene @@ -737,7 +755,6 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleObject(Transpor net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleObjectExceptions(Transport, TileObject): boolean -> net.runelite.api.TileObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleObjectExceptions(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleObjectExceptions(Transport, TileObject): boolean -> net.runelite.api.widgets.Widget#getItemId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleQuetzal(Transport): boolean -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleRockfall(List, int): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleRockfall(List, int): boolean -> net.runelite.api.TileObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSpiritTree(Transport): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint @@ -747,6 +764,11 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleTransports(List net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleTransports(List, int): boolean -> net.runelite.api.TileObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleTransports(List, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleTransports(List, int): boolean -> net.runelite.api.WorldView#getScene(): Scene +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasDoorCandidateOnRawSegment(List, int): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasDoorCandidateOnRawSegment(List, int): boolean -> net.runelite.api.Scene#isInstance(): boolean +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasDoorCandidateOnRawSegment(List, int): boolean -> net.runelite.api.WorldView#getScene(): Scene +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasLineOfSightBetween(WorldPoint, WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#interactingActorNearWalkablePath(): boolean -> net.runelite.api.Actor#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isCloseToRegion(int, int, int): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isCloseToRegion(int, int, int): boolean -> net.runelite.api.WorldView#getPlane(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isDoorComposition(ObjectComposition, List): boolean -> net.runelite.api.ObjectComposition#getImpostorIds(): int[] @@ -754,43 +776,59 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isDoorComposition(Obj net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isDoorOnSegment(TileObject, WorldPoint, WorldPoint): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isKnownWalkableOrUnloaded(WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isKnownWalkableOrUnloaded(WorldPoint): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$doorStillHasAction$35(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$findDoorNearSegment$32(WorldPoint, WorldPoint, WorldPoint, List, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$findDoorNearSegment$33(WorldPoint, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCharterShip$146(Widget): boolean -> net.runelite.api.widgets.Widget#getActions(): String[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleDoors$31(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$152(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$125(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$127(String): boolean -> net.runelite.api.widgets.Widget#getText(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$103(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$103(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$104(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$104(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$97(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleRockfall$17(WorldPoint, Tile): boolean -> net.runelite.api.Tile#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$74(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$76(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$78(TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$79(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$80(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$81(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$82(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$84(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$85(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$111(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$112(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$0(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$1(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$doorStillHasAction$42(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$findDoorNearSegment$39(WorldPoint, WorldPoint, WorldPoint, List, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$findDoorNearSegment$40(WorldPoint, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleCharterShip$173(Widget): boolean -> net.runelite.api.widgets.Widget#getActions(): String[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleDoors$37(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleDoors$38(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleFairyRing$179(Transport, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$150(Widget, Object[]): boolean -> net.runelite.api.widgets.Widget#getOnOpListener(): Object[] +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleMinigameTeleport$152(String): boolean -> net.runelite.api.widgets.Widget#getText(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$128(int, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$134(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$134(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$135(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleObjectExceptions$135(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleRockfall$19(WorldPoint, Tile): boolean -> net.runelite.api.Tile#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$108(int, boolean, TileObject): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$108(int, boolean, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$110(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$111(TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$112(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$113(Transport, Object): Integer -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$115(int, List, TileObject): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleTransports$116(Transport, TileObject): int -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$142(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$handleWildernessObelisk$143(Transport, GameObject): boolean -> net.runelite.api.GameObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$2(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$processWalk$3(): boolean -> net.runelite.api.widgets.Widget#getSpriteId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#lambda$tryHandleBlockingPathObjectsWithTimeout$61(WorldPoint, GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#markNearbyDoorFamilyOpened(TileObject, WorldPoint, String, int): void -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#maybeCanvasNudgeAfterDoor(WorldPoint, int, List): void -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#maybeCanvasNudgeAfterDoor(WorldPoint, int, List): void -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#mergePathAdjCandidate(Map, TileObject, WorldPoint, String, int, int, WorldPoint, WorldPoint, int): void -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#normalizePathAdjFamilyKey(TileObject, String): String -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#normalizePathAdjFamilyKey(TileObject, String): String -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#normalizePathAdjFamilyKey(TileObject, String): String -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#processWalk(WorldPoint, int, int): WalkerState -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#restartPathfinding(WorldPoint, Set): boolean -> net.runelite.api.Client#isClientThread(): boolean +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#resolveDoorSegment(List, int): WorldPoint[] -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#resolveDoorSegment(List, int): WorldPoint[] -> net.runelite.api.Scene#isInstance(): boolean +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#resolveDoorSegment(List, int): WorldPoint[] -> net.runelite.api.WorldView#getScene(): Scene net.runelite.client.plugins.microbot.util.walker.Rs2Walker#setStart(WorldPoint): void -> net.runelite.api.Client#isClientThread(): boolean -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#setTarget(WorldPoint): void -> net.runelite.api.Client#getLocalPlayer(): Player -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#setTarget(WorldPoint): void -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#setTarget(WorldPoint): void -> net.runelite.api.Client#isClientThread(): boolean -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#setTarget(WorldPoint): void -> net.runelite.api.coords.WorldPoint#fromLocalInstance(Client, LocalPoint): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#setTarget(WorldPoint, String): void -> net.runelite.api.Client#getLocalPlayer(): Player net.runelite.client.plugins.microbot.util.walker.Rs2Walker#staminaThreshold(): int -> net.runelite.api.Client#getLocalPlayer(): Player net.runelite.client.plugins.microbot.util.walker.Rs2Walker#staminaThreshold(): int -> net.runelite.api.Player#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryHandleBlockingPathObjectsWithTimeout(List, int, int, int, long, Map): boolean -> net.runelite.api.ObjectComposition#getName(): String net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryHandleDoorObject(TileObject, WorldPoint, WorldPoint, WorldPoint, List, boolean): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveDoorBlockerLineOfSight(WorldPoint, List, int, int): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveDoorBlockerLineOfSight(WorldPoint, List, int, int): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveDoorBlockerLineOfSight(WorldPoint, List, int, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveNearbyDoorBlocker(WorldPoint, int): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveNearbyDoorBlocker(WorldPoint, int): boolean -> net.runelite.api.ObjectComposition#getName(): String +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolveNearbyDoorBlocker(WorldPoint, int): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolvePathAdjacentBlocker(WorldPoint, List, int, int, int): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#tryResolvePathAdjacentBlocker(WorldPoint, List, int, int, int): boolean -> net.runelite.api.ObjectComposition#getName(): String net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkCanvas(WorldPoint): WorldPoint -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkCanvas(WorldPoint): WorldPoint -> net.runelite.api.WorldView#getPlane(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkCanvas(WorldPoint): WorldPoint -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint @@ -807,12 +845,17 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithBankedTranspo net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithStateInternal(WorldPoint, int): WalkerState -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithStateInternal(WorldPoint, int): WalkerState -> net.runelite.api.Client#isClientThread(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithStateInternal(WorldPoint, int): WalkerState -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint +net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorAheadResolver#hasLineOfSightBetween(WorldPoint, WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.lifecycle.Rs2WalkerLifecycleRuntime#applyWalkerDestination(WorldPoint): void -> net.runelite.api.Client#getLocalPlayer(): Player +net.runelite.client.plugins.microbot.util.walker.lifecycle.Rs2WalkerLifecycleRuntime#applyWalkerDestination(WorldPoint): void -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.lifecycle.Rs2WalkerLifecycleRuntime#applyWalkerDestination(WorldPoint): void -> net.runelite.api.Client#isClientThread(): boolean +net.runelite.client.plugins.microbot.util.walker.lifecycle.Rs2WalkerLifecycleRuntime#applyWalkerDestination(WorldPoint): void -> net.runelite.api.coords.WorldPoint#fromLocalInstance(Client, LocalPoint): WorldPoint +net.runelite.client.plugins.microbot.util.walker.lifecycle.Rs2WalkerLifecycleRuntime#restartPathfinding(WorldPoint, Set): boolean -> net.runelite.api.Client#isClientThread(): boolean net.runelite.client.plugins.microbot.util.widget.Rs2Widget#checkBoundsOverlapWidgetInMainModal(Rectangle, int, int): boolean -> net.runelite.api.widgets.Widget#isHidden(): boolean net.runelite.client.plugins.microbot.util.widget.Rs2Widget#checkWidgetAndDescendantsForOverlapCanvas(Widget, Rectangle, int, int): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle net.runelite.client.plugins.microbot.util.widget.Rs2Widget#checkWidgetAndDescendantsForOverlapCanvas(Widget, Rectangle, int, int): boolean -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] net.runelite.client.plugins.microbot.util.widget.Rs2Widget#checkWidgetAndDescendantsForOverlapCanvas(Widget, Rectangle, int, int): boolean -> net.runelite.api.widgets.Widget#getId(): int net.runelite.client.plugins.microbot.util.widget.Rs2Widget#checkWidgetAndDescendantsForOverlapCanvas(Widget, Rectangle, int, int): boolean -> net.runelite.api.widgets.Widget#getName(): String -net.runelite.client.plugins.microbot.util.widget.Rs2Widget#checkWidgetAndDescendantsForOverlapCanvas(Widget, Rectangle, int, int): boolean -> net.runelite.api.widgets.Widget#getNestedChildren(): Widget[] net.runelite.client.plugins.microbot.util.widget.Rs2Widget#checkWidgetAndDescendantsForOverlapCanvas(Widget, Rectangle, int, int): boolean -> net.runelite.api.widgets.Widget#getParent(): Widget net.runelite.client.plugins.microbot.util.widget.Rs2Widget#checkWidgetAndDescendantsForOverlapCanvas(Widget, Rectangle, int, int): boolean -> net.runelite.api.widgets.Widget#getStaticChildren(): Widget[] net.runelite.client.plugins.microbot.util.widget.Rs2Widget#checkWidgetAndDescendantsForOverlapCanvas(Widget, Rectangle, int, int): boolean -> net.runelite.api.widgets.Widget#isHidden(): boolean @@ -828,7 +871,6 @@ net.runelite.client.plugins.microbot.util.widget.Rs2Widget#findBestMatchingWidge net.runelite.client.plugins.microbot.util.widget.Rs2Widget#findBestWordSimilarityMatch(List, String): Widget -> net.runelite.api.widgets.Widget#getText(): String net.runelite.client.plugins.microbot.util.widget.Rs2Widget#findWidgetsWithAction(String, int, int, boolean): Map -> net.runelite.api.widgets.Widget#getChildren(): Widget[] net.runelite.client.plugins.microbot.util.widget.Rs2Widget#findWidgetsWithAction(String, int, int, boolean): Map -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] -net.runelite.client.plugins.microbot.util.widget.Rs2Widget#findWidgetsWithAction(String, int, int, boolean): Map -> net.runelite.api.widgets.Widget#getNestedChildren(): Widget[] net.runelite.client.plugins.microbot.util.widget.Rs2Widget#findWidgetsWithAction(String, int, int, boolean): Map -> net.runelite.api.widgets.Widget#getStaticChildren(): Widget[] net.runelite.client.plugins.microbot.util.widget.Rs2Widget#getChildWidgetText(int, int): String -> net.runelite.api.widgets.Widget#getText(): String net.runelite.client.plugins.microbot.util.widget.Rs2Widget#getProcessingWidgetKeyCode(Widget): Integer -> net.runelite.api.widgets.Widget#getActions(): String[] @@ -858,20 +900,16 @@ net.runelite.client.plugins.microbot.util.widget.Rs2Widget#matchesWildCardText(W net.runelite.client.plugins.microbot.util.widget.Rs2Widget#matchesWildCardText(Widget, String, boolean, boolean): boolean -> net.runelite.api.widgets.Widget#getText(): String net.runelite.client.plugins.microbot.util.widget.Rs2Widget#searchChildren(String, Widget, boolean): Widget -> net.runelite.api.widgets.Widget#getChildren(): Widget[] net.runelite.client.plugins.microbot.util.widget.Rs2Widget#searchChildren(String, Widget, boolean): Widget -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] -net.runelite.client.plugins.microbot.util.widget.Rs2Widget#searchChildren(String, Widget, boolean): Widget -> net.runelite.api.widgets.Widget#getNestedChildren(): Widget[] net.runelite.client.plugins.microbot.util.widget.Rs2Widget#searchChildren(String, Widget, boolean): Widget -> net.runelite.api.widgets.Widget#getStaticChildren(): Widget[] net.runelite.client.plugins.microbot.util.widget.Rs2Widget#searchChildren(String, Widget, boolean): Widget -> net.runelite.api.widgets.Widget#isHidden(): boolean net.runelite.client.plugins.microbot.util.widget.Rs2Widget#searchChildren(int, Widget): Widget -> net.runelite.api.widgets.Widget#getChildren(): Widget[] net.runelite.client.plugins.microbot.util.widget.Rs2Widget#searchChildren(int, Widget): Widget -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] -net.runelite.client.plugins.microbot.util.widget.Rs2Widget#searchChildren(int, Widget): Widget -> net.runelite.api.widgets.Widget#getNestedChildren(): Widget[] net.runelite.client.plugins.microbot.util.widget.Rs2Widget#searchChildren(int, Widget): Widget -> net.runelite.api.widgets.Widget#getStaticChildren(): Widget[] net.runelite.client.plugins.microbot.util.widget.Rs2Widget#searchChildren(int, Widget): Widget -> net.runelite.api.widgets.Widget#isHidden(): boolean net.runelite.client.plugins.microbot.util.widget.Rs2WidgetInspector#countVisible(Widget[]): int -> net.runelite.api.widgets.Widget#isHidden(): boolean net.runelite.client.plugins.microbot.util.widget.Rs2WidgetInspector#countVisibleChildren(Widget): int -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] -net.runelite.client.plugins.microbot.util.widget.Rs2WidgetInspector#countVisibleChildren(Widget): int -> net.runelite.api.widgets.Widget#getNestedChildren(): Widget[] net.runelite.client.plugins.microbot.util.widget.Rs2WidgetInspector#countVisibleChildren(Widget): int -> net.runelite.api.widgets.Widget#getStaticChildren(): Widget[] net.runelite.client.plugins.microbot.util.widget.Rs2WidgetInspector#getAllChildren(Widget): List -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] -net.runelite.client.plugins.microbot.util.widget.Rs2WidgetInspector#getAllChildren(Widget): List -> net.runelite.api.widgets.Widget#getNestedChildren(): Widget[] net.runelite.client.plugins.microbot.util.widget.Rs2WidgetInspector#getAllChildren(Widget): List -> net.runelite.api.widgets.Widget#getStaticChildren(): Widget[] net.runelite.client.plugins.microbot.util.widget.Rs2WidgetInspector#getLabel(Widget): String -> net.runelite.api.widgets.Widget#getName(): String net.runelite.client.plugins.microbot.util.widget.Rs2WidgetInspector#getLabel(Widget): String -> net.runelite.api.widgets.Widget#getText(): String From bf42478978d545c18a6aaa8adfbea8b36afeb093 Mon Sep 17 00:00:00 2001 From: Haliax <18099301+TheHaliax@users.noreply.github.com> Date: Mon, 11 May 2026 09:55:58 -0500 Subject: [PATCH 7/9] chore(shortestpath): finish stability and cache profile touches Force immediate reroute after locked-region blacklist/catalog updates so nearest flows do not loop. Invalidate bank mirror when world-type profile class changes, while treating members/free and normal combat-variant tags as same profile. --- .../plugins/microbot/MicrobotPlugin.java | 23 + .../quests/ghostsahoy/DyeShipSteps.java | 6 + .../shortestpath/ShortestPathConfig.java | 11 + .../shortestpath/ShortestPathPlugin.java | 1 + .../shortestpath/ShortestPathScript.java | 5 + .../microbot/shortestpath/Transport.java | 4 +- .../shortestpath/pathfinder/CollisionMap.java | 7 +- .../pathfinder/PathfinderConfig.java | 63 +- .../policy/TransportRequirementPolicy.java | 3 + .../microbot/util/Rs2InventorySetup.java | 4 +- .../plugins/microbot/util/bank/Rs2Bank.java | 15 + .../leaguetransport/LeaguesTransportChat.java | 6 +- .../LeaguesTransportObservations.java | 12 +- .../LeaguesTransportPersistence.java | 55 +- .../LeaguesTransportTeleport.java | 34 +- .../Rs2MapOfAlacrityTransport.java | 583 ------------------ .../SeasonalTransportHandlers.java | 19 +- .../microbot/util/misc/Rs2UiHelper.java | 12 +- .../microbot/util/text/Rs2TextSanitizer.java | 16 +- .../microbot/util/walker/Rs2Walker.java | 12 +- .../walker/door/Rs2DoorAheadResolver.java | 16 +- .../walker/door/model/DoorFailureReason.java | 10 + .../lifecycle/Rs2WalkerLifecycleRuntime.java | 86 +-- .../walker/stall/Rs2WalkerStallPolicy.java | 25 + ...hfinderConfigTransportRefreshHashTest.java | 2 +- .../LeaguesTransportChatTest.java | 10 +- .../LeaguesTransportLockCatalogueTest.java | 14 + .../Rs2MapOfAlacrityTransportTest.java | 45 -- .../SeasonalTransportHandlersTest.java | 8 +- .../util/text/Rs2TextSanitizerTest.java | 4 +- .../util/walker/Rs2WalkerIntegrationTest.java | 6 + .../util/walker/door/Rs2WalkerAwaitsTest.java | 4 +- .../client-thread-guardrail-baseline.txt | 19 +- 33 files changed, 302 insertions(+), 838 deletions(-) delete mode 100644 runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2MapOfAlacrityTransport.java delete mode 100644 runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2MapOfAlacrityTransportTest.java diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java index d3f48ce38f7..c69bfe5e180 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java @@ -58,6 +58,7 @@ import java.lang.reflect.InvocationTargetException; import java.util.ArrayList; import java.util.Arrays; +import java.util.EnumSet; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -79,6 +80,7 @@ public class MicrobotPlugin extends Plugin * @apiNote Treat as stable external API: renames or semantic changes break scripts — note in changelog when modifying. */ public static final long LEAGUES_LOCK_CHAT_MAX_ATTEMPT_AGE_MS = Rs2LeaguesTransport.LEAGUES_LOCK_CHAT_MAX_ATTEMPT_AGE_MS; + private EnumSet lastWorldTypeProfile = null; @Inject private Provider pluginListPanelProvider; @@ -305,6 +307,12 @@ public void onGameStateChanged(GameStateChanged gameStateChanged) // Region-based login detection logic final Client client = Microbot.getClient(); if (client != null) { + EnumSet worldTypeProfile = normalizeWorldTypesForProfileComparison(client.getWorldType()); + if (lastWorldTypeProfile != null && !lastWorldTypeProfile.equals(worldTypeProfile)) + { + Rs2Bank.invalidateBankMirrorCache("world-type-profile-transition"); + } + lastWorldTypeProfile = worldTypeProfile; int[] currentRegions = client.getTopLevelWorldView().getMapRegions(); boolean wasLoggedIn = LoginManager.getLastKnownGameState() == GameState.LOGGED_IN; if (!wasLoggedIn) { @@ -329,6 +337,21 @@ public void onGameStateChanged(GameStateChanged gameStateChanged) LoginManager.setLastKnownGameState(gameStateChanged.getGameState()); } + private static EnumSet normalizeWorldTypesForProfileComparison(EnumSet rawTypes) + { + EnumSet normalized = rawTypes == null + ? EnumSet.noneOf(WorldType.class) + : rawTypes.clone(); + // Profile compare should ignore normal-world and combat-variant flags. + normalized.remove(WorldType.MEMBERS); + normalized.remove(WorldType.PVP); + normalized.remove(WorldType.BOUNTY); + normalized.remove(WorldType.SKILL_TOTAL); + normalized.remove(WorldType.HIGH_RISK); + normalized.remove(WorldType.LAST_MAN_STANDING); + return normalized; + } + @Subscribe public void onVarClientIntChanged(VarClientIntChanged event) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/ghostsahoy/DyeShipSteps.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/ghostsahoy/DyeShipSteps.java index 1f6aa8cd1e3..fa08dd6026d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/ghostsahoy/DyeShipSteps.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/ghostsahoy/DyeShipSteps.java @@ -101,13 +101,19 @@ private void updateCurrentColours() } String normalized = Rs2TextSanitizer.normalizeGameText(text); String[] splitOnNewLines = normalized.split("
"); + boolean handledLines = false; if (splitOnNewLines.length > 1) { for (String splitOnNewLine : splitOnNewLines) { updateCurrentColoursFromString(Rs2TextSanitizer.stripTagsToSpace(splitOnNewLine)); + handledLines = true; } } + if (handledLines) + { + return; + } String plain = Rs2TextSanitizer.stripTagsToSpace(normalized); String[] splitText = plain.split("dye the "); 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 0e176154796..8f53d20259a 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 @@ -764,6 +764,17 @@ default int maxSimilarTransportDistance() { return 0; } + @ConfigItem( + keyName = "bankTripWhenCacheUnavailable", + name = "Bank trip when cache unavailable", + description = "When enabled, walker will visit/open nearest bank to bootstrap bank mirror cache before evaluating banked routes.", + position = 6, + section = sectionAdvanced + ) + default boolean bankTripWhenCacheUnavailable() { + return true; + } + @ConfigSection( name = "Spirit tree teleports", description = "Toggle which spirit tree destinations to use", 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 d7711bdb55a..9361aefd29d 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 @@ -382,6 +382,7 @@ public boolean isNearPath(WorldPoint location) { "calculationCutoff", "walkWithBankedTransports", "minBankRouteSavings", + "bankTripWhenCacheUnavailable", "preferNonConsumableTeleportAndSpells", "preferTransportToTarget", "maxSimilarTransportDistance" diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathScript.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathScript.java index 4ddb99959f9..ac4333a257f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathScript.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathScript.java @@ -61,6 +61,7 @@ public void setTriggerWalker(WorldPoint point) { public void setTriggerWalker(WorldPoint point, String stopReason) { if (point == null) { + resetExitRetryState(); String r = stopReason != null && !stopReason.isBlank() ? stopReason : "shortest-path-script:trigger-null"; @@ -72,6 +73,10 @@ public void setTriggerWalker(WorldPoint point, String stopReason) { } walkTaskRunning.set(false); } else { + if (!point.equals(triggerWalker)) + { + resetExitRetryState(); + } triggerWalker = point; startWalkTask(); } 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 b9bc49d3417..db3d2900553 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 @@ -255,7 +255,7 @@ public Transport(WorldPoint destination, String displayInfo, TransportType trans name = matcher.group(2).trim(); // Second group: menuTarget (name) objectId = Integer.parseInt(matcher.group(3).trim()); // Third group: objectID } else { - System.out.println("Skipped invalid value: " + value); + log.debug("Skipped invalid menuOption/menuTarget/objectID value: {}", value); } } @@ -615,7 +615,7 @@ private static void appendStandardTransportFiles(HashMap> loadAllFromResources() { HashMap> transports = new HashMap<>(); appendStandardTransportFiles(transports); - System.out.println("Loaded " + transports.size() + " transports"); + log.info("Loaded {} transports", transports.size()); return transports; } 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 65d4ba3a061..08f4b2d2cdf 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 @@ -358,17 +358,12 @@ public List getReverseNeighbors(Node node, VisitedTiles visitedBackward, P } else if (Math.abs(d.x + d.y) == 1 && isBlocked(x, y, z)) { int wx = x - d.x; int wy = y - d.y; - int bpx = wx + d.x; - int bpy = wy + d.y; - if (bpx != x || bpy != y) { - continue; - } Set ts = config.getTransportsPacked().getOrDefault(node.packedPosition, Collections.emptySet()); for (Transport transport : ts) { if (transport.getOrigin() == null) { continue; } - if (WorldPointUtil.packWorldPoint(transport.getOrigin()) != node.packedPosition) { + if (WorldPointUtil.packWorldPoint(transport.getOrigin()) != prevPacked) { continue; } neighbors.add(new Node(WorldPointUtil.packWorldPoint(wx, wy, z), node)); 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 69f1333c35e..713be9cfc1f 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 @@ -20,7 +20,6 @@ import net.runelite.client.plugins.microbot.util.magic.RuneFilter; import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.leaguetransport.Rs2LeaguesTransport; -import net.runelite.client.plugins.microbot.util.leaguetransport.Rs2MapOfAlacrityTransport; import net.runelite.client.plugins.microbot.util.poh.PohTeleports; import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; import net.runelite.client.plugins.microbot.util.walker.WebWalkLog; @@ -168,7 +167,7 @@ public PathfinderConfig(SplitFlagMap mapData, Map> tr Client client, ShortestPathConfig config) { this.mapData = mapData; this.map = ThreadLocal.withInitial(() -> new CollisionMap(this.mapData)); - this.allTransports = new ConcurrentHashMap<>(); + this.allTransports = Collections.synchronizedMap(new HashMap<>()); replaceAllTransports(transports); this.usableTeleports = ConcurrentHashMap.newKeySet(allTransports.size() / 20); this.transports = new ConcurrentHashMap<>(allTransports.size() / 2); @@ -384,9 +383,6 @@ private void refreshTransports(WorldPoint target) { long useTransportTimeNanos = 0; Map typeStats = new java.util.EnumMap<>(TransportType.class); - int moaSeen = 0; - int moaKept = 0; - // One snapshot for this refreshTransports pass (avoid re-querying unlocked regions per transport). // Trade-off: unlock mid-refresh is picked up on next refresh — acceptable vs client-thread churn per edge. // Scripts that must path immediately after unlock should trigger an explicit transport refresh / recalc. @@ -399,9 +395,6 @@ private void refreshTransports(WorldPoint target) { totalTransports++; updateActionBasedOnQuestState(transport); - boolean isMoa = isMapOfAlacritySeasonalRow(transport); - if (isMoa) moaSeen++; - long t0 = System.nanoTime(); boolean usable = useTransport(transport); long elapsed = System.nanoTime() - t0; @@ -414,7 +407,6 @@ private void refreshTransports(WorldPoint target) { if (usable) stats[1]++; // stats[1] is incremented when useTransport() is true; isTransportAllowed may still reject below. - // moaKept reflects the final kept set; per-type stats[1] can exceed checkedTransports when Leagues filters. if (!usable) { addBlockedTransportEdgeIfNeeded(transport); continue; @@ -429,7 +421,6 @@ private void refreshTransports(WorldPoint target) { } else { usableTransports.add(transport); } - if (isMoa) moaKept++; } if (point != null && !usableTransports.isEmpty()) { @@ -469,9 +460,9 @@ private void refreshTransports(WorldPoint target) { refreshCurrencyCache = 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={} moaS={} moaK={} vb={} vp={}", + 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, - totalTransports, checkedTransports, usableTeleports.size(), moaSeen, moaKept, varbitIds.size(), varplayerIds.size()); + totalTransports, checkedTransports, usableTeleports.size(), varbitIds.size(), varplayerIds.size()); typeStats.entrySet().stream() .sorted((a, b) -> Integer.compare(b.getValue()[2], a.getValue()[2])) @@ -479,9 +470,6 @@ private void refreshTransports(WorldPoint target) { .forEach(e -> WebWalkLog.cfg("refresh_transports type {} cnt={} passed={} timeMs={}", e.getKey(), e.getValue()[0], e.getValue()[1], e.getValue()[2] / 1000)); - log.debug("[MoA] refreshTransports: seen={} kept={} (useSeasonalTransports={}, VarbitID.LEAGUE_TYPE={})", - moaSeen, moaKept, useSeasonalTransports, - Microbot.getVarbitValue(VarbitID.LEAGUE_TYPE)); } public boolean isBlockedTransportEdge(int originPacked, int destinationPacked) { @@ -807,50 +795,10 @@ private int getLiveVarplayerValue(int varplayerId) { .orElse(0); } - /** - * MoA seasonal row: same predicate for refresh stats and {@link #useTransport}. - * MoA TSV rows are expected to be {@link TransportType#SEASONAL_TRANSPORT} with a non-null destination. - */ - private static boolean isMapOfAlacritySeasonalRow(Transport transport) { - return transport != null - && transport.getType() == TransportType.SEASONAL_TRANSPORT - && transport.getDestination() != null - && Rs2MapOfAlacrityTransport.isMapOfAlacrityTransport(transport); - } - private boolean useTransport(Transport transport) { - boolean traceMoa = isMapOfAlacritySeasonalRow(transport); - - // MoA: fail once -> block same dest this session (avoid reroute spam). - if (traceMoa && Rs2MapOfAlacrityTransport.isMoaDestinationBlacklisted( - WorldPointUtil.packWorldPoint(transport.getDestination()))) { - return false; - } - - // Region-level lock: once handleSeasonalTransport sees a region render with - // (locked), reject every destination in that region. Without this, - // the pathfinder keeps picking a different Asgarnia/Desert/etc. destination on - // each re-path — walker fails, blacklists one, re-path picks the next, infinite - // "running around" loop. Display info format: "Map of Alacrity: - ". - if (traceMoa) { - String disp = transport.getDisplayInfo(); - if (disp != null) { - int colon = disp.indexOf(':'); - int dash = colon >= 0 ? disp.indexOf(" - ", colon) : -1; - if (colon >= 0 && dash > colon) { - String region = disp.substring(colon + 1, dash).trim(); - if (Rs2MapOfAlacrityTransport.isMoaRegionLocked(region)) { - return false; - } - } - } - } - // Check if the feature flag is disabled if (!isFeatureEnabled(transport)) { log.debug("Transport Type {} is disabled by feature flag", transport.getType()); - if (traceMoa) log.debug("[MoA] rejected '{}' — feature flag disabled (useSeasonalTransports={})", - transport.getDisplayInfo(), useSeasonalTransports); return false; } // If the transport requires you to be in a members world (used for more granular member requirements) @@ -876,8 +824,6 @@ private boolean useTransport(Transport transport) { // 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()); - if (traceMoa) log.debug("[MoA] rejected '{}' — varbit check failed (varbits={}, LEAGUE_TYPE={})", - transport.getDisplayInfo(), transport.getVarbits(), Microbot.getVarbitValue(VarbitID.LEAGUE_TYPE)); return false; } @@ -934,8 +880,6 @@ private boolean useTransport(Transport transport) { 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())); - if (traceMoa) log.debug("[MoA] rejected '{}' — missing required items {}", - transport.getDisplayInfo(), transport.getItemIdRequirements()); } return hasRequiredItems; } @@ -1486,7 +1430,6 @@ private int computeTransportRefreshCacheKeyHash(WorldPoint target, Rs2LeaguesTra invFp, members, Rs2Walker.disableTeleports, - Rs2MapOfAlacrityTransport.moaTransportCacheFingerprint(), Microbot.getVarbitValue(VarbitID.LEAGUE_TYPE), leaguesCtx.isActive(), leaguesCtx.getUnlockedRegions().hashCode(), diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/policy/TransportRequirementPolicy.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/policy/TransportRequirementPolicy.java index 7f16287629c..0f24f6c1b62 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/policy/TransportRequirementPolicy.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/policy/TransportRequirementPolicy.java @@ -18,6 +18,9 @@ public static boolean completedQuests(Transport transport, List ques QuestState requiredState = entry.getValue(); int playerIndex = questStateOrder.indexOf(playerState); int requiredIndex = questStateOrder.indexOf(requiredState); + if (requiredIndex < 0 || playerIndex < 0) { + return false; + } return playerIndex >= requiredIndex; }); } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java index a2f04ab66fc..5d156edaab7 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java @@ -46,6 +46,8 @@ public class Rs2InventorySetup { * ids, id/name drift, and impossible non-stackable quantities on a single row. */ private static final String PROP_VALIDATE_INVENTORY_SETUP = "microbot.bank.validateInventorySetup"; + /** Inventory setup value meaning any spellbook is acceptable. */ + private static final int SPELLBOOK_ANY = 4; /** * {@link Client#getItemDefinition(int)} is client-thread-only; inventory setup runs from script executor threads. @@ -1108,7 +1110,7 @@ public Map itemsToNotDeposit() { */ public boolean hasSpellBook() { int setupBook = inventorySetup.getSpellBook(); - if (setupBook == 4) { + if (setupBook == SPELLBOOK_ANY) { return true; } return setupBook == Microbot.getVarbitValue(VarbitID.SPELLBOOK); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java index f6b95a7c76a..bf521dd03bc 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java @@ -101,6 +101,21 @@ public static int getBankLiveEpoch() { return BANK_LIVE_EPOCH.get(); } + /** + * Clears mirrored bank state when game-mode world context changes (for example seasonal <-> non-seasonal). + * This prevents stale bank mirror data from prior world context leaking into routing and setup checks. + */ + public static void invalidateBankMirrorCache(String reason) + { + rs2BankData.setEmpty(); + BANK_LIVE_EPOCH.set(0); + if (log.isInfoEnabled()) + { + String suffix = (reason == null || reason.isBlank()) ? "" : " reason=" + reason; + log.info("[Rs2Bank] bank mirror cache invalidated{}", suffix); + } + } + /** * Tier C gate: after {@link #openBank()}, {@link #bankItems()} is trustworthy only if a snapshot arrived. * If the bank was already open before {@code openBank()}, require {@code getBankLiveEpoch() > 0}; otherwise require diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportChat.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportChat.java index 0326891a2bf..bb422327bab 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportChat.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportChat.java @@ -136,10 +136,8 @@ private static void handleLeaguesLockedRegionMatch(String region, String rawForM log.info("[Leagues] reroute: locked region='{}' method='{}' destPacked={} (summary every {} msgs)", region, methodSafe, packedDest, LEAGUES_LOCK_REROUTE_INFO_INTERVAL); } - if (!Rs2LeaguesTransport.shouldRecalculatePathAfterLock(region, packedDest)) - { - return; - } + // Recalculate immediately after lock persistence/catalog update so caller flows + // (nearest-bank and any other "nearest" routing entry point) reroute in-place. Client client = Microbot.getClient(); if (client == null) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportObservations.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportObservations.java index 507c82a7ea3..784e53bd3b0 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportObservations.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportObservations.java @@ -25,6 +25,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -75,7 +76,16 @@ private static Path catalogDir() private static final Gson GSON = new GsonBuilder().disableHtmlEscaping().create(); - private static final Map CATALOG_FILE_PARSE_CACHE = new ConcurrentHashMap<>(); + private static final int CATALOG_FILE_PARSE_CACHE_MAX_ENTRIES = 256; + private static final Map CATALOG_FILE_PARSE_CACHE = Collections.synchronizedMap( + new LinkedHashMap(128, 0.75f, true) + { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) + { + return size() > CATALOG_FILE_PARSE_CACHE_MAX_ENTRIES; + } + }); private static final AtomicBoolean LOGGED_OLD_CATALOG_SCHEMA = new AtomicBoolean(false); private static final class CatalogFileSnapshot diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportPersistence.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportPersistence.java index 774f5189690..1593988014b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportPersistence.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportPersistence.java @@ -61,19 +61,28 @@ private static int leaguesMajor() private static void ensurePathSnapshot() { - if (cachedShareFile != null) + int major = leaguesMajor(); + if (major <= 0) + { + return; + } + if (cachedShareFile != null && cachedCatalogVersion != null + && cachedCatalogVersion.equals(resolveCatalogVersion(major))) { return; } synchronized (PATH_SNAPSHOT_LOCK) { - if (cachedShareFile != null) + major = leaguesMajor(); + if (major <= 0) + { + return; + } + String version = resolveCatalogVersion(major); + if (cachedShareFile != null && version.equals(cachedCatalogVersion)) { return; } - int major = leaguesMajor(); - String curated = CATALOG_VERSION_BY_MAJOR.get(major); - String version = curated != null ? curated : major + ".0.0"; Path dir = Path.of( System.getProperty("user.home"), ".runelite", @@ -86,22 +95,46 @@ private static void ensurePathSnapshot() } } + private static String resolveCatalogVersion(int major) + { + String curated = CATALOG_VERSION_BY_MAJOR.get(major); + return curated != null ? curated : major + ".0.0"; + } + static String leaguesCatalogVersion() { ensurePathSnapshot(); - return cachedCatalogVersion; + if (cachedCatalogVersion != null) + { + return cachedCatalogVersion; + } + return resolveCatalogVersion(Math.max(0, leaguesMajor())); } static Path leaguesVersionDir() { ensurePathSnapshot(); - return cachedVersionDir; + if (cachedVersionDir != null) + { + return cachedVersionDir; + } + String version = resolveCatalogVersion(Math.max(0, leaguesMajor())); + return Path.of( + System.getProperty("user.home"), + ".runelite", + "microbot", + "leagues-transport", + "v" + version); } private static Path shareFile() { ensurePathSnapshot(); - return cachedShareFile; + if (cachedShareFile != null) + { + return cachedShareFile; + } + return leaguesVersionDir().resolve("leagues-transport-cache.properties"); } private static final Set PERSIST_BLOCKED_DESTS = ConcurrentHashMap.newKeySet(); @@ -137,11 +170,13 @@ static void ensureLoaded() static boolean isCalibrationConsentAllowed() { + ensureLoaded(); return CALIBRATION_CONSENT_ALLOWED.get(); } static boolean isCalibrationConsentDenied() { + ensureLoaded(); return CALIBRATION_CONSENT_DENIED.get(); } @@ -162,12 +197,16 @@ static long getCalibrationConsentRetryAfterMs() static void setCalibrationConsentAllowed(boolean allowed) { + ensureLoaded(); CALIBRATION_CONSENT_ALLOWED.set(allowed); + CALIBRATION_CONSENT_DENIED.set(!allowed); } static void setCalibrationConsentDenied(boolean denied) { + ensureLoaded(); CALIBRATION_CONSENT_DENIED.set(denied); + CALIBRATION_CONSENT_ALLOWED.set(!denied); } static void setCalibrationConsentPromptQueued(boolean queued) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportTeleport.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportTeleport.java index 3fec30d9b04..76525f998ce 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportTeleport.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportTeleport.java @@ -53,7 +53,6 @@ private LeaguesTransportTeleport() private static final AtomicBoolean CALIBRATION_COMPLETE_PROMPT_SHOWN = new AtomicBoolean(false); private static final AtomicLong CALIBRATION_COMPLETE_RETRY_AFTER_MS = new AtomicLong(0L); private static final AtomicBoolean TELEPORT_IN_PROGRESS = new AtomicBoolean(false); - private static final AtomicLong WIDGET_VISIBILITY_CAP_HIT_LOG_MS = new AtomicLong(0L); private static final AtomicLong WIDGET_VISIBILITY_CHECK_TIMEOUT_LOG_MS = new AtomicLong(0L); private static final AtomicLong CALIBRATION_COMPLETE_DIALOG_FAIL_LOG_MS = new AtomicLong(0L); private static final AtomicBoolean LOGGED_TELEPORT_ROW_NAME_MISMATCH = new AtomicBoolean(false); @@ -87,7 +86,6 @@ static void onLogout() CALIBRATION_COMPLETE_RETRY_AFTER_MS.set(0L); CALIBRATION_PROBE_MS.set(0L); TELEPORT_IN_PROGRESS.set(false); - WIDGET_VISIBILITY_CAP_HIT_LOG_MS.set(0L); WIDGET_VISIBILITY_CHECK_TIMEOUT_LOG_MS.set(0L); } @@ -178,12 +176,10 @@ private static boolean isWidgetEffectivelyVisible(Widget w) { return false; } - Widget slow = w; - Widget fast = w; + Widget cur = w; final int cap = 20; - for (int i = 0; i < cap && slow != null; i++) + for (int i = 0; i < cap && cur != null; i++) { - Widget cur = slow; if (cur.isHidden()) { return false; @@ -193,30 +189,12 @@ private static boolean isWidgetEffectivelyVisible(Widget w) { return false; } - slow = parent; - fast = fast != null ? fast.getParent() : null; - fast = fast != null ? fast.getParent() : null; - if (slow != null && slow == fast) - { - return false; - } + cur = parent; } - if (slow == null) + if (cur == null) { return true; } - if (log.isDebugEnabled()) - { - long now = System.currentTimeMillis(); - long prev = WIDGET_VISIBILITY_CAP_HIT_LOG_MS.get(); - if (prev == 0L || (now - prev) >= 3_600_000L) - { - if (WIDGET_VISIBILITY_CAP_HIT_LOG_MS.compareAndSet(prev, now)) - { - log.debug("[Leagues] widget visibility parent chain exceeded cap={}", cap); - } - } - } return false; } @@ -694,7 +672,7 @@ private static boolean waitForTeleportArrival(LeaguesRegion region, WorldPoint b final long startedAtMs = System.currentTimeMillis(); final int teleportDistanceThreshold = 20; - final boolean animatingStarted = sleepUntilTrue(Rs2Player::isAnimating, POLL_MS, remainingMs(startedAtMs, timeoutMs)); + sleepUntilTrue(Rs2Player::isAnimating, POLL_MS, remainingMs(startedAtMs, timeoutMs)); final long moveWaitStartedAtMs = System.currentTimeMillis(); return sleepUntilTrue(() -> { @@ -712,7 +690,7 @@ private static boolean waitForTeleportArrival(LeaguesRegion region, WorldPoint b return true; } return now.distanceTo(before) > teleportDistanceThreshold; - }, POLL_MS, remainingMs(moveWaitStartedAtMs, animatingStarted ? remainingMs(startedAtMs, timeoutMs) : remainingMs(startedAtMs, timeoutMs))); + }, POLL_MS, remainingMs(moveWaitStartedAtMs, remainingMs(startedAtMs, timeoutMs))); } private static boolean performTeleportSequence(LeaguesRegion region, int timeoutMs) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2MapOfAlacrityTransport.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2MapOfAlacrityTransport.java deleted file mode 100644 index 2b050d8aaab..00000000000 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2MapOfAlacrityTransport.java +++ /dev/null @@ -1,583 +0,0 @@ -package net.runelite.client.plugins.microbot.util.leaguetransport; - -import lombok.extern.slf4j.Slf4j; -import net.runelite.api.widgets.Widget; -import net.runelite.client.plugins.microbot.Microbot; -import net.runelite.client.plugins.microbot.shortestpath.Transport; -import net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil; -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.keyboard.Rs2Keyboard; -import net.runelite.client.plugins.microbot.util.widget.Rs2Widget; -import net.runelite.client.plugins.microbot.util.text.Rs2TextSanitizer; -import net.runelite.client.plugins.microbot.util.logging.Rs2LogRateLimit; - -import java.util.Locale; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.Objects; -import java.util.Optional; - -import static net.runelite.client.plugins.microbot.util.Global.sleepUntil; -import static net.runelite.client.plugins.microbot.util.Global.sleepUntilNotNull; - -/** - * Map of Alacrity seasonal transport (Leagues). - * - * Used by {@link net.runelite.client.plugins.microbot.util.walker.Rs2Walker} via - * {@code TransportType.SEASONAL_TRANSPORT} displayInfo rows. - * - * State: keeps session blacklist (bad/locked rows) to stop reroute spam. - */ -@Slf4j -public final class Rs2MapOfAlacrityTransport -{ - /** - * Fallback when {@link Rs2Inventory#get(String, boolean)} cannot find the relic by name (league id drift). - * Prefer {@link #resolveMapOfAlacrityRelic()} in {@link #tryUse}. - */ - private static final int MAP_OF_ALACRITY_ITEM_ID_FALLBACK = 33233; - private static final AtomicInteger MOA_RELIC_ID_MISMATCH_LOG = new AtomicInteger(0); - // From client widget dump (Leagues MoA interface); update if Jagex changes group/child IDs. - private static final int MAP_OF_ALACRITY_WIDGET_GROUP = 187; - private static final int MAP_OF_ALACRITY_LIST_CHILD = 3; - private static final String MOA_LOCKED_MARKUP = ""; - - /** - * Session-only mutable sets: intended for walker / MoA handler coordination only. - * Scripts must not clear or mutate these; doing so fights MoA blacklist/lock state. - */ - private static final Set blacklistedMoaDestinations = ConcurrentHashMap.newKeySet(); - private static final Set lockedMoaRegions = ConcurrentHashMap.newKeySet(); - - public static boolean isMoaDestinationBlacklisted(int packedDest) - { - return blacklistedMoaDestinations.contains(packedDest); - } - - public static boolean isMoaRegionLocked(String region) - { - return region != null && lockedMoaRegions.contains(region.toLowerCase(Locale.ROOT)); - } - - /** - * Changes when session MoA blacklist / lock sets mutate — included in {@code PathfinderConfig} transport refresh memo key. - */ - public static int moaTransportCacheFingerprint() - { - int h = blacklistedMoaDestinations.hashCode(); - h = 31 * h + lockedMoaRegions.hashCode(); - return h; - } - - private static void addBlacklistedMoaDestination(int packedDest) - { - if (packedDest == 0) - { - return; - } - if (blacklistedMoaDestinations.add(packedDest)) - { - Rs2LeaguesTransport.persistBlacklistDestination(packedDest, null, "MoA"); - } - } - - /** - * Resolves relic by exact name first, then {@value #MAP_OF_ALACRITY_ITEM_ID_FALLBACK}. Logs once per session when name id - * differs from fallback (update constant after league settles). - */ - private static Rs2ItemModel resolveMapOfAlacrityRelic() - { - Rs2ItemModel byName = Rs2Inventory.get("Map of Alacrity", true); - if (byName != null) - { - int id = byName.getId(); - if (id != MAP_OF_ALACRITY_ITEM_ID_FALLBACK && MOA_RELIC_ID_MISMATCH_LOG.compareAndSet(0, 1)) - { - log.warn("[MoA] Map of Alacrity resolved by name id={} != fallback={}; update fallback when ids stable.", - id, MAP_OF_ALACRITY_ITEM_ID_FALLBACK); - } - return byName; - } - return Rs2Inventory.get(MAP_OF_ALACRITY_ITEM_ID_FALLBACK); - } - - private static int moaListPageSignature(Widget listRoot) - { - if (listRoot == null) - { - return 0; - } - return Microbot.getClientThread().runOnClientThreadOptional(() -> - { - Widget[] d = listRoot.getDynamicChildren(); - int n = d == null ? 0 : d.length; - int h = n * 31; - if (d != null) - { - int cap = Math.min(4, d.length); - for (int i = 0; i < cap; i++) - { - Widget w = d[i]; - String t = w != null ? w.getText() : ""; - h = h * 31 + (t != null ? t.hashCode() : 0); - } - } - return h; - }).orElse(0); - } - - private static void addLockedMoaRegion(String region) - { - if (region != null && !region.isEmpty()) - { - lockedMoaRegions.add(region.toLowerCase(Locale.ROOT)); - } - } - - // Matches the OSRS menu-row hotkey prefix, e.g. "[1] ..." or "1: ..." or "A. ...". - private static final Pattern MOA_HOTKEY_PATTERN = - Pattern.compile("^\\s*(?:\\[([0-9A-Za-z])\\]|([0-9A-Za-z])\\s*[:.])"); - private static final Pattern MOA_MARKUP_PATTERN = Pattern.compile("<[^>]*>"); - private static final Pattern MOA_PUNCT_PATTERN = Pattern.compile("[^a-zA-Z0-9 ]"); - private static final Pattern MOA_WHITESPACE_PATTERN = Pattern.compile("\\s+"); - /** When {@code " - "} absent after {@link #normalizeMoaRegionShortcutSeparator}, split on hyphen/en-dash/em-dash/minus (allows hyphen-glued titles). */ - private static final Pattern MOA_REGION_SHORTCUT_FALLBACK_SPLIT = Pattern.compile("\\s*[-\\u2013\\u2014\\u2212]\\s*"); - private static final List MOA_RELIC_ACTION_FALLBACK = List.of("Read", "Open", "Teleport", "Invoke"); - - private Rs2MapOfAlacrityTransport() - { - } - - /** Fullwidth / compatibility colon forms → ASCII {@code ':'} for title parsing. */ - private static String normalizeMoaTitleColons(String displayInfo) - { - if (displayInfo == null) - { - return ""; - } - return Rs2TextSanitizer.normalizeAsciiColons(displayInfo); - } - - /** - * Normalizes dashes so {@link #tryUse} can find {@code " - "} or ASCII hyphens: space-en-dash / space-em-dash → {@code " - "}; - * remaining en/em/minus signs → ASCII {@code '-'} (so {@code lastIndexOf(" - ")} and the fallback hyphen regex both see a delimiter). - */ - private static String normalizeMoaRegionShortcutSeparator(String rest) - { - if (rest == null || rest.isEmpty()) - { - return ""; - } - String s = rest.replace(" \u2013 ", " - ").replace(" \u2014 ", " - "); - s = s.replace('\u2013', '-').replace('\u2014', '-').replace('\u2212', '-'); - return s; - } - - // TSV / menu: {@code Map of Alacrity: - } (ASCII {@code :} after {@link #normalizeMoaTitleColons}); {@link #tryUse} splits on first {@code :}. - private static final String MOA_DISPLAY_TITLE_PREFIX = "Map of Alacrity"; - /** - * Strips Unicode format characters ({@code \p{Cf}}) only in the substring before the first {@code ':'} (title part) so ZWJ - * / variation selectors do not break the {@value #MOA_DISPLAY_TITLE_PREFIX} prefix check. Region/shortcut text after - * {@code ':'} is left unchanged. - */ - private static final Pattern MOA_FORMAT_CHARS = Pattern.compile("\\p{Cf}"); - - /** Same pipeline as {@link #isMapOfAlacrityTransport} prefix check: colons + strip format chars + trim. */ - private static String normalizeMoaDisplayInfoForParsing(String raw) - { - if (raw == null) - { - return ""; - } - String colons = normalizeMoaTitleColons(raw); - int colon = colons.indexOf(':'); - if (colon < 0) - { - return MOA_FORMAT_CHARS.matcher(colons).replaceAll("").trim(); - } - String title = colons.substring(0, colon); - String restText = colons.substring(colon + 1); - String cleanTitle = MOA_FORMAT_CHARS.matcher(title).replaceAll("").trim(); - String cleanRest = restText.trim(); - return cleanRest.isEmpty() ? (cleanTitle + ":") : (cleanTitle + ": " + cleanRest); - } - - /** - * True when {@code displayInfo} begins with the Map of Alacrity title (trimmed, case-insensitive). - * Callers that gate pathfinding should also require {@link net.runelite.client.plugins.microbot.shortestpath.TransportType#SEASONAL_TRANSPORT}; - * this method intentionally ignores type so tests/helpers can pass partial mocks. - */ - public static boolean isMapOfAlacrityTransport(Transport transport) - { - if (transport == null || transport.getDisplayInfo() == null) - { - return false; - } - String t = normalizeMoaDisplayInfoForParsing(transport.getDisplayInfo()); - return t.regionMatches(true, 0, MOA_DISPLAY_TITLE_PREFIX, 0, MOA_DISPLAY_TITLE_PREFIX.length()); - } - - public static boolean tryUse(Transport transport) - { - if (transport == null || transport.getDisplayInfo() == null || transport.getDestination() == null) - { - return false; - } - - if (!isMapOfAlacrityTransport(transport)) - { - return false; - } - - final String displayInfo = normalizeMoaDisplayInfoForParsing(transport.getDisplayInfo()); - if (log.isDebugEnabled()) - { - int first = displayInfo.indexOf(':'); - int second = first < 0 ? -1 : displayInfo.indexOf(':', first + 1); - if (second >= 0) - { - String sample = displayInfo.length() > 120 ? displayInfo.substring(0, 120) + "…" : displayInfo; - log.debug("[MoA] multiple ':' in normalized displayInfo — split uses first only; sample='{}'", sample); - } - } - - int packedDest = WorldPointUtil.packWorldPoint(transport.getDestination()); - if (isMoaDestinationBlacklisted(packedDest)) - { - return false; - } - - Rs2ItemModel relic = resolveMapOfAlacrityRelic(); - if (relic == null) - { - return false; - } - - Optional parsedOpt = parseMoaDisplayInfo(displayInfo); - if (!parsedOpt.isPresent()) - { - return false; - } - MoaParsedRow parsed = parsedOpt.get(); - String region = parsed.region; - String shortName = parsed.shortcutName; - - if (isMoaRegionLocked(region)) - { - addBlacklistedMoaDestination(packedDest); - return false; - } - - String action = relic.getAction("Read"); - if (action == null) action = relic.getActionFromList(MOA_RELIC_ACTION_FALLBACK); - if (action == null) - { - return false; - } - if (!Rs2Inventory.interact(relic, action)) - { - return false; - } - Rs2LeaguesTransport.recordTransportAttempt(transport, "MoA"); - - if (!sleepUntil(() -> Rs2Widget.isWidgetVisible(MAP_OF_ALACRITY_WIDGET_GROUP, MAP_OF_ALACRITY_LIST_CHILD), 3000)) - { - return false; - } - - Widget regionRoot = Rs2Widget.getWidget(MAP_OF_ALACRITY_WIDGET_GROUP, MAP_OF_ALACRITY_LIST_CHILD); - if (regionRoot == null) - { - return false; - } - - Widget regionMatch = findMoaWidget(regionRoot, region); - if (regionMatch == null) - { - return false; - } - - String regionText = Microbot.getClientThread().runOnClientThreadOptional(regionMatch::getText).orElse(""); - if (regionText != null && regionText.contains(MOA_LOCKED_MARKUP)) - { - addLockedMoaRegion(region); - addBlacklistedMoaDestination(packedDest); - return false; - } - - Character regionHotkey = extractMoaHotkey(regionText); - if (regionHotkey == null) regionHotkey = computeMoaHotkeyByIndex(regionRoot, regionMatch); - final int sigBefore = moaListPageSignature(regionRoot); - if (regionHotkey != null) - { - Rs2Keyboard.keyPress(regionHotkey); - if (sigBefore != 0) - { - sleepUntil(() -> - { - Widget r = Rs2Widget.getWidget(MAP_OF_ALACRITY_WIDGET_GROUP, MAP_OF_ALACRITY_LIST_CHILD); - return r != null && moaListPageSignature(r) != sigBefore; - }, 3000); - } - } - else - { - if (!Rs2Widget.clickWidget(regionMatch)) - { - return false; - } - } - - Widget destMatch = sleepUntilNotNull(() -> - { - Widget root = Rs2Widget.getWidget(MAP_OF_ALACRITY_WIDGET_GROUP, MAP_OF_ALACRITY_LIST_CHILD); - if (root == null) return null; - return findMoaWidget(root, shortName); - }, 3000); - - if (destMatch == null) - { - addBlacklistedMoaDestination(packedDest); - return false; - } - - String destText = Microbot.getClientThread().runOnClientThreadOptional(destMatch::getText).orElse(""); - if (destText != null && destText.contains(MOA_LOCKED_MARKUP)) - { - addBlacklistedMoaDestination(packedDest); - return false; - } - - Character destHotkey = extractMoaHotkey(destText); - if (destHotkey == null) - { - Widget destRoot = Rs2Widget.getWidget(MAP_OF_ALACRITY_WIDGET_GROUP, MAP_OF_ALACRITY_LIST_CHILD); - destHotkey = computeMoaHotkeyByIndex(destRoot, destMatch); - } - if (destHotkey != null) - { - Rs2Keyboard.keyPress(destHotkey); - } - else - { - if (!Rs2Widget.clickWidget(destMatch)) - { - return false; - } - } - - return true; - } - - static final class MoaParsedRow - { - private final String region; - private final String shortcutName; - - private MoaParsedRow(String region, String shortcutName) - { - this.region = Objects.requireNonNull(region, "region"); - this.shortcutName = Objects.requireNonNull(shortcutName, "shortcutName"); - } - - String getRegion() - { - return region; - } - - String getShortcutName() - { - return shortcutName; - } - } - - /** - * Parse "Map of Alacrity: <Region> - <Shortcut>" (after {@link #normalizeMoaDisplayInfoForParsing}). - * Returns empty if format is unexpected. - */ - static Optional parseMoaDisplayInfo(String normalizedDisplayInfo) - { - if (normalizedDisplayInfo == null || normalizedDisplayInfo.isEmpty()) - { - return Optional.empty(); - } - - int colon = normalizedDisplayInfo.indexOf(':'); - if (colon >= 0 && colon < MOA_DISPLAY_TITLE_PREFIX.length()) - { - if (log.isDebugEnabled()) - { - String sample = normalizedDisplayInfo.length() > 80 ? normalizedDisplayInfo.substring(0, 80) + "…" : normalizedDisplayInfo; - log.debug("[MoA] ':' appears before end of title prefix (minIndex={}); sample='{}'", - MOA_DISPLAY_TITLE_PREFIX.length(), sample); - } - return Optional.empty(); - } - - String rest = colon >= 0 ? normalizedDisplayInfo.substring(colon + 1).trim() : normalizedDisplayInfo.trim(); - rest = normalizeMoaRegionShortcutSeparator(rest); - String region; - String shortName; - // Last " - " so region may contain that substring; shortcut may contain " - " (e.g. "Place - Wing"). - int spacedDash = rest.lastIndexOf(" - "); - if (spacedDash >= 0) - { - region = rest.substring(0, spacedDash).trim(); - shortName = rest.substring(spacedDash + 3).trim(); - } - else - { - // Last dash match: shortcut may be unhyphenated while region contains several (game-dependent). - Matcher dashSplit = MOA_REGION_SHORTCUT_FALLBACK_SPLIT.matcher(rest); - int lastStart = -1; - int lastEnd = -1; - while (dashSplit.find()) - { - lastStart = dashSplit.start(); - lastEnd = dashSplit.end(); - } - if (lastStart < 0) - { - return Optional.empty(); - } - region = rest.substring(0, lastStart).trim(); - shortName = rest.substring(lastEnd).trim(); - } - if (region.isEmpty() || shortName.isEmpty()) - { - return Optional.empty(); - } - return Optional.of(new MoaParsedRow(region, shortName)); - } - - private static Widget findMoaWidget(Widget root, String needle) - { - String normalised = normaliseMoaText(needle); - if (normalised.isEmpty()) return null; - String[] tokens = normalised.split(" "); - return Microbot.getClientThread().runOnClientThreadOptional(() -> - { - Widget exact = null; - Widget tokenMatch = null; - int bestTokenHayLen = Integer.MAX_VALUE; - String bestTokenHay = null; - for (Widget w : collectMoaChildren(root)) - { - String hay = normaliseMoaText(w.getText()); - if (hay.isEmpty()) - { - continue; - } - if (hay.equals(normalised)) - { - exact = w; - break; - } - boolean all = true; - for (String t : tokens) - { - if (t.isEmpty()) - { - continue; - } - if (!hay.contains(t)) - { - all = false; - break; - } - } - if (all) - { - // Tie on length: lexical order — deterministic, not semantic “best” if two rows normalize the same. - int lenCmp = Integer.compare(hay.length(), bestTokenHayLen); - boolean better = lenCmp < 0 - || (lenCmp == 0 && (bestTokenHay == null || hay.compareTo(bestTokenHay) < 0)); - if (better) - { - tokenMatch = w; - bestTokenHayLen = hay.length(); - bestTokenHay = hay; - } - } - } - return exact != null ? exact : tokenMatch; - }).orElse(null); - } - - private static List collectMoaChildren(Widget root) - { - List out = new ArrayList<>(); - if (root == null) return out; - Widget[] dyn = root.getDynamicChildren(); - Widget[] kids = dyn != null && dyn.length > 0 ? dyn : root.getChildren(); - if (kids == null) return out; - for (Widget w : kids) - { - if (w == null) continue; - String txt = w.getText(); - if (txt == null || txt.isEmpty()) continue; - out.add(w); - } - return out; - } - - private static String normaliseMoaText(String raw) - { - if (raw == null) return ""; - String s = raw.toLowerCase(Locale.ROOT); - s = Rs2TextSanitizer.stripTagsToSpace(s); - s = s.replace('’', '\''); - s = MOA_PUNCT_PATTERN.matcher(s).replaceAll(" "); - s = MOA_WHITESPACE_PATTERN.matcher(s).replaceAll(" ").trim(); - return s; - } - - private static Character extractMoaHotkey(String rawText) - { - if (rawText == null) return null; - String plain = MOA_MARKUP_PATTERN.matcher(rawText).replaceAll(""); - java.util.regex.Matcher m = MOA_HOTKEY_PATTERN.matcher(plain); - if (!m.find()) return null; - String c = m.group(1) != null ? m.group(1) : m.group(2); - if (c == null || c.isEmpty()) return null; - return c.charAt(0); - } - - /** - * Fallback hotkey when markup parsing fails. Assumes {@code root}'s child array order matches - * on-screen row order (1–9, then A–Z); padding or hidden rows can skew keys — {@link Rs2Widget#clickWidget} - * remains the fallback when this returns null. - */ - private static Character computeMoaHotkeyByIndex(Widget root, Widget match) - { - if (root == null || match == null) - { - return null; - } - return Microbot.getClientThread().runOnClientThreadOptional(() -> - { - Widget[] dyn = root.getDynamicChildren(); - Widget[] kids = dyn != null && dyn.length > 0 ? dyn : root.getChildren(); - if (kids == null) return null; - for (int i = 0; i < kids.length; i++) - { - if (kids[i] == match) - { - int idx = i + 1; - if (idx >= 1 && idx <= 9) return (char) ('0' + idx); - int alpha = idx - 10; - if (alpha >= 0 && alpha < 26) return (char) ('A' + alpha); - return null; - } - } - return null; - }).orElse(null); - } -} - diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/SeasonalTransportHandlers.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/SeasonalTransportHandlers.java index 4ac7265d05d..1b48a998b5b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/SeasonalTransportHandlers.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/SeasonalTransportHandlers.java @@ -5,7 +5,7 @@ import java.util.List; /** - * Built-in {@link SeasonalTransportHandler} instances and default ordering (Leagues Area, then Map of Alacrity). + * Built-in {@link SeasonalTransportHandler} instances and default ordering. */ public final class SeasonalTransportHandlers { @@ -28,23 +28,8 @@ public boolean tryUse(Transport transport) } }; - public static final SeasonalTransportHandler MAP_OF_ALACRITY = new SeasonalTransportHandler() - { - @Override - public boolean matches(Transport transport) - { - return Rs2MapOfAlacrityTransport.isMapOfAlacrityTransport(transport); - } - - @Override - public boolean tryUse(Transport transport) - { - return Rs2MapOfAlacrityTransport.tryUse(transport); - } - }; - public static List defaultHandlerList() { - return List.of(LEAGUES_AREA, MAP_OF_ALACRITY); + return List.of(LEAGUES_AREA); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelper.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelper.java index 0e305528652..157a12a9cde 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelper.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/misc/Rs2UiHelper.java @@ -136,17 +136,17 @@ public static boolean isGameObject(NewMenuEntry entry) { } /** - * Strips RuneLite/Jagex markup tags from the provided text. + * Strips RuneLite/Jagex color tags from the provided text. * - * @param text the text from which to strip tags. - * @return the text without tags. + * @param text the text from which to strip color tags. + * @return the text without color tags. * - * @deprecated Use {@link #stripTags(String)} or {@link net.runelite.client.plugins.microbot.util.text.Rs2TextSanitizer#stripTags(String)}. + * @deprecated Use {@link #stripTags(String)} or {@link net.runelite.client.plugins.microbot.util.text.Rs2TextSanitizer#stripTags(String)} + * for full tag removal. */ @Deprecated public static String stripColTags(String text) { - // Historic API name; in practice callers want RuneLite/Jagex markup removed, not only . - return Rs2TextSanitizer.stripTags(text); + return Rs2TextSanitizer.stripColorTags(text); } /** Strip RuneLite/Jagex markup tags from text. */ diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/text/Rs2TextSanitizer.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/text/Rs2TextSanitizer.java index 2f56478556d..a9708000991 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/text/Rs2TextSanitizer.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/text/Rs2TextSanitizer.java @@ -24,12 +24,12 @@ private Rs2TextSanitizer() /** Allows empty {@code <>} as well as non-empty tags (chat sometimes emits zero-length markup). */ private static final Pattern TAG_STRIP = Pattern.compile("<[^>]*>"); + private static final Pattern COLOR_TAG_STRIP = Pattern.compile("(?i)]*?)?>"); private static final Pattern DEC_ENTITY = Pattern.compile("&#(\\d{1,7});"); private static final Pattern HEX_ENTITY = Pattern.compile("&#(?i)x([0-9a-fA-F]{1,6});"); // Extract base name and numeric suffix, e.g. "Super attack (4)" -> "Super attack", 4 private static final Pattern ITEM_NAME_SUFFIX_PATTERN = Pattern.compile("^(.*?)(?:\\s*\\((\\d+)\\))?$"); - /** Strip markup tags, repeatedly, and drop dangling {@code <} with no {@code >}. */ /** * Fullwidth / compatibility Unicode colons → ASCII {@code ':'} for prefix parsing (Leagues Area titles, MoA). */ @@ -42,6 +42,7 @@ public static String normalizeAsciiColons(String raw) return raw.replace('\uFF1A', ':').replace('\uFE55', ':').replace('\u2236', ':'); } + /** Strip markup tags, repeatedly, and drop dangling {@code <} with no {@code >}. */ public static String stripTags(String raw) { if (raw == null || raw.isEmpty()) @@ -74,6 +75,16 @@ public static String stripTags(String raw) return s; } + /** Strip only RuneLite/Jagex color tags ({@code } and {@code }). */ + public static String stripColorTags(String raw) + { + if (raw == null || raw.isEmpty()) + { + return ""; + } + return COLOR_TAG_STRIP.matcher(raw).replaceAll(""); + } + /** Strip tags and replace them with a single space (useful for tokenization/matching). */ public static String stripTagsToSpace(String raw) { @@ -225,7 +236,8 @@ private static String sanitizeCore(String raw) { return ""; } - return normalizeApostrophes(stripTags(decodeKnownEntities(normalizeGameText(raw)))).trim(); + String decoded = stripTags(decodeKnownEntities(normalizeGameText(raw))); + return normalizeApostrophes(normalizeGameText(decoded)).trim(); } public static final class ItemNameWithSuffix diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java index cba148fdf03..9eeb64291ff 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/Rs2Walker.java @@ -44,7 +44,6 @@ import net.runelite.client.plugins.microbot.util.player.Rs2Player; import net.runelite.client.plugins.microbot.util.player.Rs2Pvp; import net.runelite.client.plugins.microbot.util.leaguetransport.Rs2LeaguesTransport; -import net.runelite.client.plugins.microbot.util.leaguetransport.Rs2MapOfAlacrityTransport; import net.runelite.client.plugins.microbot.util.leaguetransport.SeasonalTransportHandler; import net.runelite.client.plugins.microbot.util.leaguetransport.SeasonalTransportHandlers; import net.runelite.client.plugins.microbot.util.logging.Rs2LogRateLimit; @@ -6873,9 +6872,9 @@ private static boolean attemptObservedWithoutAttemptRecord(Transport transport, } /** - * Tries Leagues Area UI then Map of Alacrity for the same {@link Transport} row. - * Attempt recording is done inside each handler ({@link Rs2LeaguesTransport#tryHandleLeaguesAreaTransportResult}, - * {@link Rs2MapOfAlacrityTransport#tryUse}) — use {@link #attemptObservedWithoutAttemptRecord} at the call site. + * Tries configured seasonal transport handlers for the same {@link Transport} row. + * Attempt recording is done inside each handler (for built-ins, {@link Rs2LeaguesTransport#tryHandleLeaguesAreaTransportResult}) + * — use {@link #attemptObservedWithoutAttemptRecord} at the call site. */ private static boolean handleSeasonalTransport(Transport transport) { if (transport == null) { @@ -6939,7 +6938,7 @@ private static boolean handleSeasonalTransport(Transport transport) { { sample = sample + " destPacked=" + Integer.toHexString(packedTileOrNull); } - log.debug("[Walker] seasonal transport unmatched by Leagues Area + MoA (expect pathfinder-only matching rows); key={} sample={}", + log.debug("[Walker] seasonal transport unmatched by configured handlers (expect pathfinder-only matching rows); key={} sample={}", missKey, sample); } } @@ -8060,7 +8059,8 @@ private static WalkerState walkWithBankedTransportsAndStateLocked(WorldPoint tar if (pathfinder != null && !pathfinder.isDone()) return WalkerState.MOVING; - if (!forceBanking && Rs2Bank.getBankLiveEpoch() <= 0) { + boolean bankTripWhenCacheUnavailable = config == null || config.bankTripWhenCacheUnavailable(); + if (!forceBanking && bankTripWhenCacheUnavailable && Rs2Bank.getBankLiveEpoch() <= 0) { WalkerState bootstrapState = bootstrapBankMirrorForBankedPathing(distance); if (bootstrapState == WalkerState.EXIT || bootstrapState == WalkerState.UNREACHABLE) { return bootstrapState; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorAheadResolver.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorAheadResolver.java index 54828c47f5b..20b62a91d8f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorAheadResolver.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2DoorAheadResolver.java @@ -12,11 +12,11 @@ private Rs2DoorAheadResolver() { } public static List buildSegmentProbes(WorldPoint fromWp, WorldPoint toWp, WorldPoint doorWp) { - List probes = new ArrayList<>(); - probes.add(doorWp); if (fromWp == null || toWp == null || doorWp == null) { - return probes; + return List.of(); } + List probes = new ArrayList<>(); + probes.add(doorWp); boolean diagonal = Math.abs(fromWp.getX() - toWp.getX()) > 0 && Math.abs(fromWp.getY() - toWp.getY()) > 0; @@ -38,8 +38,12 @@ private static boolean hasLineOfSightBetween(WorldPoint a, WorldPoint b) { if (a == null || b == null) { return false; } - return a.toWorldArea().hasLineOfSightTo( - Microbot.getClient().getTopLevelWorldView(), - b.toWorldArea()); + WorldPoint from = a; + WorldPoint to = b; + return Microbot.getClientThread().runOnClientThreadOptional(() -> + from.toWorldArea().hasLineOfSightTo( + Microbot.getClient().getTopLevelWorldView(), + to.toWorldArea())) + .orElse(false); } } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/DoorFailureReason.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/DoorFailureReason.java index 8d36c703e6b..8a8c8863807 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/DoorFailureReason.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/door/model/DoorFailureReason.java @@ -1,10 +1,20 @@ package net.runelite.client.plugins.microbot.util.walker.door.model; +/** + * Failure outcomes returned by door interaction/traversal helpers. + * Callers can use these values to decide between retrying, backing off, or surfacing a user-facing failure cause. + */ public enum DoorFailureReason { + /** Per-edge throttle active (same door edge recently attempted); short retry after edge cooldown. */ THROTTLED_EDGE, + /** Global throttle active (all door interactions cooled down); retry after global cooldown. */ THROTTLED_GLOBAL, + /** Interaction threw unexpectedly; retry only after validating target object/client state. */ INTERACT_EXCEPTION, + /** Interaction call returned failure/no click acknowledgement. */ INTERACT_FAILED, + /** Click happened but traversal was not observed within await window. */ NOT_TRAVERSED, + /** Door blocked by quest progression or unmet quest requirement. */ QUEST_LOCKED } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java index 6ebff447716..3c3a29abc50 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java @@ -37,7 +37,7 @@ public static void applyWalkerDestination(WorldPoint target) { log.warn("Unable to apply walker destination: client unavailable"); return; } - Player localPlayer = client.getLocalPlayer(); + Player localPlayer = Microbot.getClientThread().invoke(() -> client.getLocalPlayer()); if (!ShortestPathPlugin.isStartPointSet() && localPlayer == null) { log.warn("Start point is not set and player is null"); return; @@ -55,37 +55,32 @@ public static void applyWalkerDestination(WorldPoint target) { ShortestPathPlugin.getMarker().setJumpOnClick(true); wmm.add(ShortestPathPlugin.getMarker()); - WorldPoint start; - if (client.getTopLevelWorldView().isInstance()) { - LocalPoint localLoc = Rs2Player.getLocalLocation(); - start = localLoc != null ? WorldPoint.fromLocalInstance(client, localLoc) : null; - if (start == null) { - log.warn("[Walker] setTarget: instance localPoint conversion returned null (localLoc={} target={}) — falling back to raw world location", - localLoc, target); - start = Rs2Player.getWorldLocation(); + WorldPoint start = Microbot.getClientThread().invoke(() -> { + if (client.getTopLevelWorldView().isInstance()) { + LocalPoint localLoc = Rs2Player.getLocalLocation(); + WorldPoint computed = localLoc != null ? WorldPoint.fromLocalInstance(client, localLoc) : null; + if (computed == null) { + log.warn("[Walker] setTarget: instance localPoint conversion returned null (localLoc={} target={}) — falling back to raw world location", + localLoc, target); + computed = Rs2Player.getWorldLocation(); + } + WorldPoint exitPortal = net.runelite.client.plugins.microbot.shortestpath.PohPanel.getExitPortalTile(); + if (exitPortal != null) { + Microbot.log("[Walker] In POH instance — remapping pathfinder start " + computed + + " -> exit portal " + exitPortal); + computed = exitPortal; + } + return computed; } - } else { - start = Rs2Player.getWorldLocation(); - } - if (client.getTopLevelWorldView().isInstance()) { - WorldPoint exitPortal = net.runelite.client.plugins.microbot.shortestpath.PohPanel.getExitPortalTile(); - if (exitPortal != null) { - Microbot.log("[Walker] In POH instance — remapping pathfinder start " + start - + " -> exit portal " + exitPortal); - start = exitPortal; - } - } + return Rs2Player.getWorldLocation(); + }); ShortestPathPlugin.setLastLocation(start); final Pathfinder pathfinder = ShortestPathPlugin.getPathfinder(); if (ShortestPathPlugin.isStartPointSet() && pathfinder != null) { start = pathfinder.getStart(); } - if (client.isClientThread()) { - final WorldPoint startPoint = start; - Microbot.getClientThread().runOnSeperateThread(() -> restartPathfinding(startPoint, target)); - } else { - restartPathfinding(start, target); - } + final WorldPoint startPoint = start; + Microbot.getClientThread().runOnSeperateThread(() -> restartPathfinding(startPoint, target)); } public static boolean restartPathfinding(WorldPoint start, WorldPoint end) { @@ -93,10 +88,6 @@ public static boolean restartPathfinding(WorldPoint start, WorldPoint end) { } public static boolean restartPathfinding(WorldPoint start, Set ends) { - if (Microbot.getClient().isClientThread()) { - return false; - } - Pathfinder pathfinder = ShortestPathPlugin.getPathfinder(); if (pathfinder != null) { pathfinder.cancel(); @@ -115,18 +106,31 @@ public static boolean restartPathfinding(WorldPoint start, Set ends) if (Rs2Player.isInCave()) { pathfinder = new Pathfinder(ShortestPathPlugin.getPathfinderConfig(), start, ends); pathfinder.run(); - ShortestPathPlugin.getPathfinderConfig().setIgnoreTeleportAndItems(true); - Pathfinder pathfinderWithoutTeleports = new Pathfinder(ShortestPathPlugin.getPathfinderConfig(), start, ends); - pathfinderWithoutTeleports.run(); - var lastPath = pathfinderWithoutTeleports.getPath().get(pathfinderWithoutTeleports.getPath().size() - 1); - int reachedDistance = Rs2Walker.config != null ? Rs2Walker.config.reachedDistance() : 10; - var pathWithoutTeleportsIsReachable = lastPath.distanceTo(ends.stream().findFirst().orElse(lastPath)) <= reachedDistance; - if (pathWithoutTeleportsIsReachable && pathfinder.getPath().size() >= pathfinderWithoutTeleports.getPath().size()) { - ShortestPathPlugin.setPathfinder(pathfinderWithoutTeleports); - } else { - ShortestPathPlugin.setPathfinder(pathfinder); + try { + ShortestPathPlugin.getPathfinderConfig().setIgnoreTeleportAndItems(true); + Pathfinder pathfinderWithoutTeleports = new Pathfinder(ShortestPathPlugin.getPathfinderConfig(), start, ends); + pathfinderWithoutTeleports.run(); + + boolean noTeleportPathAvailable = !pathfinderWithoutTeleports.getPath().isEmpty(); + boolean basePathAvailable = pathfinder != null && !pathfinder.getPath().isEmpty(); + if (!noTeleportPathAvailable) { + ShortestPathPlugin.setPathfinder(basePathAvailable ? pathfinder : pathfinderWithoutTeleports); + return true; + } + + WorldPoint lastPath = pathfinderWithoutTeleports.getPath().get(pathfinderWithoutTeleports.getPath().size() - 1); + int reachedDistance = Rs2Walker.config != null ? Rs2Walker.config.reachedDistance() : 10; + boolean pathWithoutTeleportsIsReachable = lastPath.distanceTo(ends.stream().findFirst().orElse(lastPath)) <= reachedDistance; + if (pathWithoutTeleportsIsReachable + && basePathAvailable + && pathfinder.getPath().size() >= pathfinderWithoutTeleports.getPath().size()) { + ShortestPathPlugin.setPathfinder(pathfinderWithoutTeleports); + } else { + ShortestPathPlugin.setPathfinder(basePathAvailable ? pathfinder : pathfinderWithoutTeleports); + } + } finally { + ShortestPathPlugin.getPathfinderConfig().setIgnoreTeleportAndItems(false); } - ShortestPathPlugin.getPathfinderConfig().setIgnoreTeleportAndItems(false); } else { ShortestPathPlugin.setPathfinder(new Pathfinder(ShortestPathPlugin.getPathfinderConfig(), start, ends)); ShortestPathPlugin.setPathfinderFuture(ShortestPathPlugin.getPathfindingExecutor().submit(ShortestPathPlugin.getPathfinder())); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicy.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicy.java index 05e2b2b9313..b0bb74bbe78 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicy.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/stall/Rs2WalkerStallPolicy.java @@ -9,6 +9,14 @@ public final class Rs2WalkerStallPolicy { private Rs2WalkerStallPolicy() { } + /** + * Determines whether stall accounting should be bypassed for the current tick. + * Bypasses when a leagues teleport is running, a leagues area teleport is still pending within the provided age window, + * a dialogue is open, or the fairy-ring teleport widget is visible. + * + * @param leaguesPendingMaxAgeMs max age in milliseconds for treating a leagues teleport as still pending + * @return true when stall accounting should be skipped + */ public static boolean shouldSkipStallAccounting(long leaguesPendingMaxAgeMs) { if (Rs2LeaguesTransport.isTeleportInProgress()) { return true; @@ -22,6 +30,23 @@ public static boolean shouldSkipStallAccounting(long leaguesPendingMaxAgeMs) { return !Rs2Widget.isHidden(ComponentID.FAIRY_RING_TELEPORT_BUTTON); } + /** + * Computes the stall threshold by multiplying {@code baseMs} by the maximum applicable multiplier. + * Result uses {@link Math#round(double)}. + * + * @param baseMs base stall threshold in milliseconds + * @param combatMultiplier multiplier applied when {@code inCombat} is true + * @param animatingMultiplier multiplier applied when {@code animating} is true + * @param movingMultiplier multiplier applied when {@code moving} is true + * @param interimMultiplier multiplier applied when {@code hasInterimTarget} is true + * @param interactingMultiplier multiplier applied when {@code interactingNearPath} is true + * @param inCombat whether player is currently in combat + * @param animating whether player is currently animating + * @param moving whether player is currently moving + * @param hasInterimTarget whether walker currently tracks an interim target + * @param interactingNearPath whether interacting with an entity near path progression + * @return rounded threshold in milliseconds + */ public static long computeThresholdMs(long baseMs, double combatMultiplier, double animatingMultiplier, diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigTransportRefreshHashTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigTransportRefreshHashTest.java index b5f9c829e39..d974eed41aa 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigTransportRefreshHashTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/PathfinderConfigTransportRefreshHashTest.java @@ -14,8 +14,8 @@ public void verificationHashDiffersForNotStartedVsInProgressQuestState() { int[] sortedVarbits = new int[0]; int[] sortedVarplayers = new int[0]; int trackedQuestId = 987654; - int[] sortedQuestIds = new int[]{trackedQuestId}; int clientOfKourendId = Quest.CLIENT_OF_KOUREND.getId(); + int[] sortedQuestIds = new int[]{trackedQuestId, clientOfKourendId}; int hashNotStarted = PathfinderConfig.computeTransportRefreshVerificationHash( boostedLevels, diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportChatTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportChatTest.java index 13bba8474a0..6196aa00120 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportChatTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportChatTest.java @@ -3,7 +3,9 @@ import org.junit.Test; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; /** * Locked-region gametext: regex capture must yield phrases that {@link LeaguesTransportRegions#parseRegionNameNormalized} @@ -88,21 +90,21 @@ public void noMatchWrongCopy() @Test public void gateMatchesExpectedCopy() { - assertEquals(true, LeaguesTransportChat.isLeaguesLockedAccessMessage( + assertTrue(LeaguesTransportChat.isLeaguesLockedAccessMessage( "You haven't unlocked access to the Misthalin area.")); } @Test public void gateMatchesGliderCopy() { - assertEquals(true, LeaguesTransportChat.isLeaguesLockedAccessMessage( + assertTrue(LeaguesTransportChat.isLeaguesLockedAccessMessage( "You cannot take a glider to that destination as you don't have access to the Kharidian Desert area.")); } @Test public void gateMatchesBlockedTeleportPrefixCopy() { - assertEquals(true, LeaguesTransportChat.isLeaguesLockedAccessMessage( + assertTrue(LeaguesTransportChat.isLeaguesLockedAccessMessage( "Your teleport is blocked as you haven't unlocked access to the Asgarnia area.")); } @@ -117,7 +119,7 @@ public void capturesRegionBlockedTeleportPrefixCopy() @Test public void gateRejectsWrongOrder() { - assertEquals(false, LeaguesTransportChat.isLeaguesLockedAccessMessage( + assertFalse(LeaguesTransportChat.isLeaguesLockedAccessMessage( "Area unlocked access to the Misthalin.")); } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportLockCatalogueTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportLockCatalogueTest.java index 0a6649de011..086f6649d5f 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportLockCatalogueTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportLockCatalogueTest.java @@ -18,4 +18,18 @@ public void buildDedupeKeyJoinsPackedDestAndMethod() { assertEquals("12345|SPELL:Foo", LeaguesTransportLockCatalogue.buildDedupeKey(12345, "SPELL:Foo")); } + + @Test + public void normalizeMethodHandlesNullAndEmpty() + { + assertEquals("", LeaguesTransportLockCatalogue.normalizeLockCatalogueMethod(null)); + assertEquals("", LeaguesTransportLockCatalogue.normalizeLockCatalogueMethod("")); + } + + @Test + public void buildDedupeKeyHandlesNullAndEmptyMethod() + { + assertEquals("12345|", LeaguesTransportLockCatalogue.buildDedupeKey(12345, null)); + assertEquals("12345|", LeaguesTransportLockCatalogue.buildDedupeKey(12345, "")); + } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2MapOfAlacrityTransportTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2MapOfAlacrityTransportTest.java deleted file mode 100644 index 9e682e0e668..00000000000 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/Rs2MapOfAlacrityTransportTest.java +++ /dev/null @@ -1,45 +0,0 @@ -package net.runelite.client.plugins.microbot.util.leaguetransport; - -import org.junit.Test; - -import java.util.Optional; - -import static org.junit.Assert.*; - -public class Rs2MapOfAlacrityTransportTest -{ - @Test - public void parsesSpacedDashFormat() - { - Optional opt = Rs2MapOfAlacrityTransport.parseMoaDisplayInfo("Map of Alacrity: Kourend - Castle"); - assertTrue(opt.isPresent()); - assertEquals("Kourend", opt.get().getRegion()); - assertEquals("Castle", opt.get().getShortcutName()); - } - - @Test - public void parsesHyphenFallbackFormat() - { - Optional opt = Rs2MapOfAlacrityTransport.parseMoaDisplayInfo("Map of Alacrity: Kourend-Castle"); - assertTrue(opt.isPresent()); - assertEquals("Kourend", opt.get().getRegion()); - assertEquals("Castle", opt.get().getShortcutName()); - } - - @Test - public void parsesRegionContainingSpacedDash() - { - Optional opt = Rs2MapOfAlacrityTransport.parseMoaDisplayInfo("Map of Alacrity: Kourend - Kingdom - Castle"); - assertTrue(opt.isPresent()); - assertEquals("Kourend - Kingdom", opt.get().getRegion()); - assertEquals("Castle", opt.get().getShortcutName()); - } - - @Test - public void rejectsColonBeforeTitleEnd() - { - Optional opt = Rs2MapOfAlacrityTransport.parseMoaDisplayInfo("Map: of Alacrity: Kourend - Castle"); - assertFalse(opt.isPresent()); - } -} - diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/SeasonalTransportHandlersTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/SeasonalTransportHandlersTest.java index 7f24588aeb4..183d56d9eaf 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/SeasonalTransportHandlersTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/leaguetransport/SeasonalTransportHandlersTest.java @@ -2,16 +2,16 @@ import org.junit.Test; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; public class SeasonalTransportHandlersTest { @Test public void defaultHandlerList_orderAndSize() { - assertEquals(2, SeasonalTransportHandlers.defaultHandlerList().size()); - assertSame(SeasonalTransportHandlers.LEAGUES_AREA, SeasonalTransportHandlers.defaultHandlerList().get(0)); - assertSame(SeasonalTransportHandlers.MAP_OF_ALACRITY, SeasonalTransportHandlers.defaultHandlerList().get(1)); + var handlers = SeasonalTransportHandlers.defaultHandlerList(); + assertTrue(handlers.size() >= 1); + assertSame(SeasonalTransportHandlers.LEAGUES_AREA, handlers.get(0)); } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/text/Rs2TextSanitizerTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/text/Rs2TextSanitizerTest.java index 2a0a0af33a5..a3d3c5c3dc9 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/text/Rs2TextSanitizerTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/text/Rs2TextSanitizerTest.java @@ -21,7 +21,7 @@ public void normalizeAsciiColonsMapsFullwidthAndCompatibilityForms() public void stripsTagsAndDecodesEntities() { String raw = "Zeah ('Test') and’more"; - String s = Rs2TextSanitizer.sanitizeLeaguesLockedRegionName(raw).replace('\u00A0', ' '); + String s = Rs2TextSanitizer.sanitizeLeaguesLockedRegionName(raw); assertEquals("Zeah ('Test') and'more", s); } @@ -57,7 +57,7 @@ public void parsesItemNameSuffix() public void sanitizeWidgetMultilineTextRemovesTagsAndBr() { String raw = "Hello
World !"; - assertEquals("Hello World !", Rs2TextSanitizer.sanitizeWidgetMultilineText(raw).replaceAll("\\s+", " ").trim()); + assertEquals("Hello World !", Rs2TextSanitizer.sanitizeWidgetMultilineText(raw)); } } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerIntegrationTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerIntegrationTest.java index 30da8c13b2c..e317d5f19a6 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerIntegrationTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/Rs2WalkerIntegrationTest.java @@ -139,6 +139,7 @@ public void testPathfinderCreationAndCompletion() throws Exception { while (ShortestPathPlugin.getPathfinder() != null && System.currentTimeMillis() < clearDeadline) { Thread.sleep(100); } + assertNull("Pathfinder should clear during cleanup", ShortestPathPlugin.getPathfinder()); log.info("Setting target..."); Rs2Walker.setTarget(nearbyTarget); @@ -171,6 +172,11 @@ public void testPathfinderCreationAndCompletion() throws Exception { } Rs2Walker.clearWalkingRoute("test:cleanup"); + clearDeadline = System.currentTimeMillis() + 2000; + while (ShortestPathPlugin.getPathfinder() != null && System.currentTimeMillis() < clearDeadline) { + Thread.sleep(100); + } + assertNull("Pathfinder should clear during final cleanup", ShortestPathPlugin.getPathfinder()); assertTrue("Pathfinder should complete within 15 seconds", done); } diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java index 5a666220478..8bc82fc8018 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/util/walker/door/Rs2WalkerAwaitsTest.java @@ -8,8 +8,8 @@ public class Rs2WalkerAwaitsTest { @Test public void shouldAcceptIdleDoorAwait_requiresResolvedEdge() { - assertFalse(Rs2WalkerAwaits.shouldAcceptIdleDoorAwait(false, false, 1300L, false)); - assertTrue(Rs2WalkerAwaits.shouldAcceptIdleDoorAwait(false, false, 1301L, true)); + assertFalse(Rs2WalkerAwaits.shouldAcceptIdleDoorAwait(false, false, 1200L, false)); + assertTrue(Rs2WalkerAwaits.shouldAcceptIdleDoorAwait(false, false, 1201L, true)); } @Test diff --git a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt index 5f7d367bb46..4ff40b2a81c 100644 --- a/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt +++ b/runelite-client/src/test/resources/threadsafety/client-thread-guardrail-baseline.txt @@ -271,6 +271,7 @@ net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#hasLineOfSigh net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#hasLineOfSight(WorldPoint, TileObject): boolean -> net.runelite.api.GameObject#sizeX(): int net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#hasLineOfSight(WorldPoint, TileObject): boolean -> net.runelite.api.GameObject#sizeY(): int net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#hasLineOfSight(WorldPoint, TileObject): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#hasLineOfSight(WorldPoint, TileObject): boolean -> net.runelite.api.coords.WorldArea#hasLineOfSightTo(WorldView, WorldArea): boolean net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#hasLineOfSight(WorldPoint, TileObject): boolean -> net.runelite.api.coords.WorldPoint#fromScene(Client, int, int, int): WorldPoint net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#interact(TileObject, String, boolean): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.gameobject.Rs2GameObject#lambda$findBank$17(GameObject): boolean -> net.runelite.api.GameObject#getWorldLocation(): WorldPoint @@ -388,6 +389,7 @@ net.runelite.client.plugins.microbot.util.grandexchange.Rs2GrandExchange#viewOff net.runelite.client.plugins.microbot.util.grandexchange.Rs2GrandExchange#viewOffer(Widget): void -> net.runelite.api.widgets.Widget#getId(): int net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#hasLineOfSight(Tile): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#hasLineOfSight(Tile): boolean -> net.runelite.api.Tile#getWorldLocation(): WorldPoint +net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#hasLineOfSight(Tile): boolean -> net.runelite.api.coords.WorldArea#hasLineOfSightTo(WorldView, WorldArea): boolean net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#interact(InteractModel, String): boolean -> net.runelite.api.Client#isWidgetSelected(): boolean net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#interact(InteractModel, String): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(Client, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.grounditem.Rs2GroundItem#interact(RS2Item, String): boolean -> net.runelite.api.ItemComposition#getName(): String @@ -483,9 +485,6 @@ net.runelite.client.plugins.microbot.util.item.Rs2ExplorersRing#interact(Rs2Item net.runelite.client.plugins.microbot.util.item.Rs2ExplorersRing#interact(Rs2ItemModel): boolean -> net.runelite.api.widgets.Widget#getItemId(): int net.runelite.client.plugins.microbot.util.item.Rs2ItemManager#getPrice(int): int -> net.runelite.api.ItemComposition#getPrice(): int net.runelite.client.plugins.microbot.util.keyboard.Rs2Keyboard#getCanvas(): Canvas -> net.runelite.api.Client#getCanvas(): Canvas -net.runelite.client.plugins.microbot.util.leaguetransport.Rs2MapOfAlacrityTransport#collectMoaChildren(Widget): List -> net.runelite.api.widgets.Widget#getChildren(): Widget[] -net.runelite.client.plugins.microbot.util.leaguetransport.Rs2MapOfAlacrityTransport#collectMoaChildren(Widget): List -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] -net.runelite.client.plugins.microbot.util.leaguetransport.Rs2MapOfAlacrityTransport#collectMoaChildren(Widget): List -> net.runelite.api.widgets.Widget#getText(): String net.runelite.client.plugins.microbot.util.magic.Rs2Magic#alch(MagicAction, Rs2ItemModel, int, int): void -> net.runelite.api.widgets.Widget#getBounds(): Rectangle net.runelite.client.plugins.microbot.util.magic.Rs2Magic#alch(Rs2ItemModel, int, int): void -> net.runelite.api.Client#getRealSkillLevel(Skill): int net.runelite.client.plugins.microbot.util.magic.Rs2Magic#canCast(MagicAction): boolean -> net.runelite.api.widgets.Widget#getStaticChildren(): Widget[] @@ -571,6 +570,7 @@ net.runelite.client.plugins.microbot.util.player.Rs2Player#handleTeleblockTimer( net.runelite.client.plugins.microbot.util.player.Rs2Player#handleTeleblockTimer(VarbitChanged): void -> net.runelite.api.events.VarbitChanged#getVarbitId(): int net.runelite.client.plugins.microbot.util.player.Rs2Player#hasSpotAnimation(int): boolean -> net.runelite.api.Client#getLocalPlayer(): Player net.runelite.client.plugins.microbot.util.player.Rs2Player#isInCave(): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.player.Rs2Player#isInCave(): boolean -> net.runelite.api.WorldView#isInstance(): boolean net.runelite.client.plugins.microbot.util.player.Rs2Player#isInPoh(): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.player.Rs2Player#isInPoh(): boolean -> net.runelite.api.Scene#isInstance(): boolean net.runelite.client.plugins.microbot.util.player.Rs2Player#isInPoh(): boolean -> net.runelite.api.WorldView#getScene(): Scene @@ -671,6 +671,7 @@ net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getReachableTilesFromTile net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getReachableTilesFromTileInternal(WorldPoint, int, boolean): HashMap -> net.runelite.api.CollisionData#getFlags(): int[][] net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getReachableTilesFromTileInternal(WorldPoint, int, boolean): HashMap -> net.runelite.api.WorldView#getCollisionMaps(): CollisionData[] net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getReachableTilesFromTileInternal(WorldPoint, int, boolean): HashMap -> net.runelite.api.WorldView#getPlane(): int +net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getReachableTilesFromTileInternal(WorldPoint, int, boolean): HashMap -> net.runelite.api.WorldView#isInstance(): boolean net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getReachableTilesFromTileInternal(WorldPoint, int, boolean): HashMap -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getTileInternal(int, int): Tile -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.tile.Rs2Tile#getTileInternal(int, int): Tile -> net.runelite.api.Scene#getTiles(): Tile[][][] @@ -719,6 +720,7 @@ net.runelite.client.plugins.microbot.util.walker.Rs2MiniMap#getMinimapClipArea(d net.runelite.client.plugins.microbot.util.walker.Rs2MiniMap#getMinimapClipAreaSimple(): Shape -> net.runelite.api.widgets.Widget#getBounds(): Rectangle net.runelite.client.plugins.microbot.util.walker.Rs2MiniMap#getMinimapDrawWidget(): Widget -> net.runelite.api.Client#isResized(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2MiniMap#worldToMinimap(WorldPoint): Point -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.Rs2MiniMap#worldToMinimap(WorldPoint): Point -> net.runelite.api.WorldView#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2MiniMap#worldToMinimap(WorldPoint): Point -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#adjacentSamePlaneTransportSuppressionPoints(Transport, TileObject): Set -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#closeWorldMap(): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle @@ -732,6 +734,7 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getPointWithWallDista net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTile(WorldPoint): Tile -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTile(WorldPoint): Tile -> net.runelite.api.Scene#getTiles(): Tile[][][] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTile(WorldPoint): Tile -> net.runelite.api.WorldView#getScene(): Scene +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTile(WorldPoint): Tile -> net.runelite.api.WorldView#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTile(WorldPoint): Tile -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getTransportsForPath(List, int, TransportType, boolean): List -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleCharterShip(Transport): boolean -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] @@ -757,6 +760,7 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleObjectException net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleObjectExceptions(Transport, TileObject): boolean -> net.runelite.api.widgets.Widget#getItemId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleRockfall(List, int): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleRockfall(List, int): boolean -> net.runelite.api.TileObject#getId(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleRockfall(List, int): boolean -> net.runelite.api.WorldView#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleSpiritTree(Transport): boolean -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleTransports(List, int): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#handleTransports(List, int): boolean -> net.runelite.api.ObjectComposition#getName(): String @@ -768,6 +772,7 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasDoorCandidateOnRaw net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasDoorCandidateOnRawSegment(List, int): boolean -> net.runelite.api.Scene#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasDoorCandidateOnRawSegment(List, int): boolean -> net.runelite.api.WorldView#getScene(): Scene net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasLineOfSightBetween(WorldPoint, WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#hasLineOfSightBetween(WorldPoint, WorldPoint): boolean -> net.runelite.api.coords.WorldArea#hasLineOfSightTo(WorldView, WorldArea): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#interactingActorNearWalkablePath(): boolean -> net.runelite.api.Actor#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isCloseToRegion(int, int, int): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#isCloseToRegion(int, int, int): boolean -> net.runelite.api.WorldView#getPlane(): int @@ -812,6 +817,7 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#normalizePathAdjFamil net.runelite.client.plugins.microbot.util.walker.Rs2Walker#normalizePathAdjFamilyKey(TileObject, String): String -> net.runelite.api.TileObject#getId(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#normalizePathAdjFamilyKey(TileObject, String): String -> net.runelite.api.TileObject#getWorldLocation(): WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#processWalk(WorldPoint, int, int): WalkerState -> net.runelite.api.Client#getTopLevelWorldView(): WorldView +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#processWalk(WorldPoint, int, int): WalkerState -> net.runelite.api.WorldView#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#resolveDoorSegment(List, int): WorldPoint[] -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#resolveDoorSegment(List, int): WorldPoint[] -> net.runelite.api.Scene#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#resolveDoorSegment(List, int): WorldPoint[] -> net.runelite.api.WorldView#getScene(): Scene @@ -834,6 +840,7 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkCanvas(WorldPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkCanvas(WorldPoint): WorldPoint -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkFastCanvas(WorldPoint, boolean): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkFastCanvas(WorldPoint, boolean): boolean -> net.runelite.api.WorldView#getPlane(): int +net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkFastCanvas(WorldPoint, boolean): boolean -> net.runelite.api.WorldView#isInstance(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkFastCanvas(WorldPoint, boolean): boolean -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkFastLocal(LocalPoint): void -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkFastLocal(LocalPoint): void -> net.runelite.api.WorldView#getPlane(): int @@ -845,12 +852,6 @@ net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithBankedTranspo net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithStateInternal(WorldPoint, int): WalkerState -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithStateInternal(WorldPoint, int): WalkerState -> net.runelite.api.Client#isClientThread(): boolean net.runelite.client.plugins.microbot.util.walker.Rs2Walker#walkWithStateInternal(WorldPoint, int): WalkerState -> net.runelite.api.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint -net.runelite.client.plugins.microbot.util.walker.door.Rs2DoorAheadResolver#hasLineOfSightBetween(WorldPoint, WorldPoint): boolean -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.walker.lifecycle.Rs2WalkerLifecycleRuntime#applyWalkerDestination(WorldPoint): void -> net.runelite.api.Client#getLocalPlayer(): Player -net.runelite.client.plugins.microbot.util.walker.lifecycle.Rs2WalkerLifecycleRuntime#applyWalkerDestination(WorldPoint): void -> net.runelite.api.Client#getTopLevelWorldView(): WorldView -net.runelite.client.plugins.microbot.util.walker.lifecycle.Rs2WalkerLifecycleRuntime#applyWalkerDestination(WorldPoint): void -> net.runelite.api.Client#isClientThread(): boolean -net.runelite.client.plugins.microbot.util.walker.lifecycle.Rs2WalkerLifecycleRuntime#applyWalkerDestination(WorldPoint): void -> net.runelite.api.coords.WorldPoint#fromLocalInstance(Client, LocalPoint): WorldPoint -net.runelite.client.plugins.microbot.util.walker.lifecycle.Rs2WalkerLifecycleRuntime#restartPathfinding(WorldPoint, Set): boolean -> net.runelite.api.Client#isClientThread(): boolean net.runelite.client.plugins.microbot.util.widget.Rs2Widget#checkBoundsOverlapWidgetInMainModal(Rectangle, int, int): boolean -> net.runelite.api.widgets.Widget#isHidden(): boolean net.runelite.client.plugins.microbot.util.widget.Rs2Widget#checkWidgetAndDescendantsForOverlapCanvas(Widget, Rectangle, int, int): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle net.runelite.client.plugins.microbot.util.widget.Rs2Widget#checkWidgetAndDescendantsForOverlapCanvas(Widget, Rectangle, int, int): boolean -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] From f64850384e6355098ffeeb3b5abdf6c1effb3efb Mon Sep 17 00:00:00 2001 From: Haliax <18099301+TheHaliax@users.noreply.github.com> Date: Mon, 11 May 2026 11:08:01 -0500 Subject: [PATCH 8/9] chore(microbot): tighten guard checks in walker flow - fix null path in game msg leagues hook - keep dye parse fallback alive when line parse fail - use exact bank name fallback, avoid wrong stack bind - gate reroute spam, block impossible distance math - sync deposit path hard fail; tolerance per item only --- .../plugins/microbot/MicrobotPlugin.java | 5 +- .../quests/ghostsahoy/DyeShipSteps.java | 11 +-- .../shortestpath/pathfinder/CollisionMap.java | 9 +-- .../microbot/util/Rs2InventorySetup.java | 64 ++++++++++++----- .../plugins/microbot/util/bank/Rs2Bank.java | 4 +- .../util/events/WelcomeScreenEvent.java | 3 +- .../leaguetransport/LeaguesTransportChat.java | 11 ++- .../util/walker/TransportRouteAnalysis.java | 16 ++++- .../banking/Rs2WalkerBankingPlanner.java | 70 ++++++++----------- .../lifecycle/Rs2WalkerLifecycleRuntime.java | 11 ++- 10 files changed, 119 insertions(+), 85 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java index c69bfe5e180..01e43bc0178 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/MicrobotPlugin.java @@ -438,7 +438,10 @@ private void onChatMessage(ChatMessage event) } // Leagues: "haven't unlocked access to X area" -> blacklist last transport dest. - Rs2LeaguesTransport.onLockedRegionGameMessage(msg); + if (msg != null) + { + Rs2LeaguesTransport.onLockedRegionGameMessage(msg); + } } Microbot.getPouchScript().onChatMessage(event); Rs2Gembag.onChatMessage(event); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/ghostsahoy/DyeShipSteps.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/ghostsahoy/DyeShipSteps.java index fa08dd6026d..8208a588653 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/ghostsahoy/DyeShipSteps.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/ghostsahoy/DyeShipSteps.java @@ -106,8 +106,10 @@ private void updateCurrentColours() { for (String splitOnNewLine : splitOnNewLines) { - updateCurrentColoursFromString(Rs2TextSanitizer.stripTagsToSpace(splitOnNewLine)); - handledLines = true; + if (updateCurrentColoursFromString(Rs2TextSanitizer.stripTagsToSpace(splitOnNewLine))) + { + handledLines = true; + } } } if (handledLines) @@ -132,18 +134,19 @@ private void updateCurrentColours() updateCurrentColoursFromString(splitText[1]); } - private void updateCurrentColoursFromString(String text) + private boolean updateCurrentColoursFromString(String text) { String[] shapeAndColour = text.split(" (emblem|of the flag) "); if (shapeAndColour.length < 2) { - return; + return false; } String shape = shapeAndColour[0]; String colour = shapeAndColour[1]; shape = shape.replace("The ", ""); colour = colour.replace("is ", ""); currentColours.put(shape, FlagColour.findByKey(colour)); + return true; } public void updateSteps() 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 08f4b2d2cdf..b35f09514a7 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 @@ -355,10 +355,11 @@ public List getReverseNeighbors(Node node, VisitedTiles visitedBackward, P if (traversable[i]) { neighbors.add(new Node(prevPacked, node)); - } else if (Math.abs(d.x + d.y) == 1 && isBlocked(x, y, z)) { - int wx = x - d.x; - int wy = y - d.y; - Set ts = config.getTransportsPacked().getOrDefault(node.packedPosition, Collections.emptySet()); + } else if (Math.abs(d.x + d.y) == 1 + && isBlocked(WorldPointUtil.unpackWorldX(prevPacked), WorldPointUtil.unpackWorldY(prevPacked), z)) { + int wx = WorldPointUtil.unpackWorldX(prevPacked); + int wy = WorldPointUtil.unpackWorldY(prevPacked); + Set ts = config.getTransportsPacked().getOrDefault(prevPacked, Collections.emptySet()); for (Transport transport : ts) { if (transport.getOrigin() == null) { continue; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java index 5d156edaab7..4de721260de 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/Rs2InventorySetup.java @@ -318,18 +318,28 @@ public boolean loadInventory(boolean skipIfAlreadyMatching) { if (Rs2Inventory.isFull()) { int epochBeforeDeposit = Rs2Bank.getBankLiveEpoch(); - if (Rs2Bank.depositAll()) { - Rs2Bank.syncBankInventoryAfterChange(epochBeforeDeposit); + if (!Rs2Bank.depositAll()) { + logSetup(Level.WARN, "depositAll failed while preparing inventory load"); + return false; + } + if (!Rs2Bank.syncBankInventoryAfterChange(epochBeforeDeposit)) { + logSetup(Level.WARN, "bank sync timeout after depositAll while preparing inventory load"); + return false; } } else if (needsDepositCleanupBeforeBanking(retainIds, fuzzy)) { int epochBeforeDeposit = Rs2Bank.getBankLiveEpoch(); - if (Rs2Bank.depositAllExcept(retainIds, fuzzy)) { - Rs2Bank.syncBankInventoryAfterChange(epochBeforeDeposit); + if (!Rs2Bank.depositAllExcept(retainIds, fuzzy)) { + logSetup(Level.WARN, "depositAllExcept failed while preparing inventory load"); + return false; + } + if (!Rs2Bank.syncBankInventoryAfterChange(epochBeforeDeposit)) { + logSetup(Level.WARN, "bank sync timeout after depositAllExcept while preparing inventory load"); + return false; } } List setupItems = inventorySetup.getInventory(); - boolean toleratedShortfallWithExistingInventory = false; + Set toleratedShortfallKeys = new HashSet<>(); Set withdrewInventoryGroups = new HashSet<>(); for (InventorySetupsItem item : setupItems) { @@ -370,7 +380,7 @@ public boolean loadInventory(boolean skipIfAlreadyMatching) { logSetup(Level.INFO, "bank short but continuing %s: bank=%d inv=%d setup_total=%d missing=%d (partial stack mode)", item.getName(), bankAvail, invQty, setupTotal, desiredWithdraw); - toleratedShortfallWithExistingInventory = true; + toleratedShortfallKeys.add(inventoryShortfallKey(item)); continue; } Microbot.pauseAllScripts.compareAndSet(false, true); @@ -401,7 +411,7 @@ public boolean loadInventory(boolean skipIfAlreadyMatching) { logSetup(Level.INFO, "bank verify short but continuing %s: bank=%d inv=%d setup_total=%d missing=%d (partial stack mode)", item.getName(), bankAvail, invQty, setupTotal, withdrawQuantity); - toleratedShortfallWithExistingInventory = true; + toleratedShortfallKeys.add(inventoryShortfallKey(item)); continue; } Microbot.pauseAllScripts.compareAndSet(false, true); @@ -438,12 +448,7 @@ public boolean loadInventory(boolean skipIfAlreadyMatching) { sleep(800, 1200); lockLockedItemsFromSetup(inventorySetup); - boolean inventoryMatches = doesInventoryMatch(); - if (!inventoryMatches && toleratedShortfallWithExistingInventory) { - logSetup(Level.INFO, "continuing with partial stack shortfall because inventory already has required stack item(s)"); - return true; - } - return inventoryMatches; + return doesInventoryMatch(toleratedShortfallKeys); } private static String firstNonEmptyCompositionName(ItemComposition comp) @@ -792,13 +797,23 @@ public boolean loadEquipment(boolean skipIfAlreadyMatching) { //Clear inventory if full if (Rs2Inventory.isFull()) { int epochBeforeDeposit = Rs2Bank.getBankLiveEpoch(); - if (Rs2Bank.depositAll()) { - Rs2Bank.syncBankInventoryAfterChange(epochBeforeDeposit); + if (!Rs2Bank.depositAll()) { + logSetup(Level.WARN, "depositAll failed while preparing equipment load"); + return false; + } + if (!Rs2Bank.syncBankInventoryAfterChange(epochBeforeDeposit)) { + logSetup(Level.WARN, "bank sync timeout after depositAll while preparing equipment load"); + return false; } } else if (needsDepositCleanupBeforeBanking(retainIds, fuzzy)) { int epochBeforeDeposit = Rs2Bank.getBankLiveEpoch(); - if (Rs2Bank.depositAllExcept(retainIds, fuzzy)) { - Rs2Bank.syncBankInventoryAfterChange(epochBeforeDeposit); + if (!Rs2Bank.depositAllExcept(retainIds, fuzzy)) { + logSetup(Level.WARN, "depositAllExcept failed while preparing equipment load"); + return false; + } + if (!Rs2Bank.syncBankInventoryAfterChange(epochBeforeDeposit)) { + logSetup(Level.WARN, "bank sync timeout after depositAllExcept while preparing equipment load"); + return false; } } @@ -934,6 +949,18 @@ private static boolean slotItemMatchesPreset(InventorySetupsItem setupItem, Rs2I * @return true if the inventory matches the setup, false otherwise. */ public boolean doesInventoryMatch() { + return doesInventoryMatch(Collections.emptySet()); + } + + private static String inventoryShortfallKey(InventorySetupsItem setupItem) { + assert setupItem != null; + if (setupItem.isFuzzy()) { + return "f:" + setupItem.getName().toLowerCase(Locale.ROOT); + } + return "i:" + setupItem.getId(); + } + + private boolean doesInventoryMatch(Set toleratedShortfallKeys) { if (inventorySetup == null || inventorySetup.getInventory() == null) { return false; } @@ -986,6 +1013,9 @@ public boolean doesInventoryMatch() { } } else { if (!unslottedInventorySatisfiesPreset(setupItem, withdrawQuantity, useStackQuantity)) { + if (toleratedShortfallKeys.contains(inventoryShortfallKey(setupItem))) { + continue; + } int invHave = useStackQuantity ? Rs2Inventory.itemQuantity(setupItem.getName()) : Rs2Inventory.count(setupItem.getName(), false); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java index bf521dd03bc..e93e6ebe136 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/bank/Rs2Bank.java @@ -253,10 +253,10 @@ private static Rs2ItemModel findBankStackRowForSavedId(int id) return null; } - Rs2ItemModel byName = findBankItem(lookupName, false, 1); + Rs2ItemModel byName = findBankItem(lookupName, true, 1); if (byName != null && byName.getId() != id) { - logBankIdDriftDebug(id, byName, "name-fuzzy"); + logBankIdDriftDebug(id, byName, "name-exact"); } return byName; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/events/WelcomeScreenEvent.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/events/WelcomeScreenEvent.java index 14ce48a48af..498875577b7 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/events/WelcomeScreenEvent.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/events/WelcomeScreenEvent.java @@ -53,7 +53,8 @@ public boolean execute() { Rs2Widget.clickWidget(playWidget); return true; } - log.info("WelcomeScreenEvent execute: Play button is null"); + log.info("WelcomeScreenEvent execute: required UI not ready (playWidgetNull={} playWidgetVisible={} bannerHandled={} ribbonHandled={})", + playWidget == null, isPlayWidgetVisible, wasNewsBannerHandled, wasUpdateRibbonHandled); return false; }); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportChat.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportChat.java index bb422327bab..b60537c8056 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportChat.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/leaguetransport/LeaguesTransportChat.java @@ -136,6 +136,10 @@ private static void handleLeaguesLockedRegionMatch(String region, String rawForM log.info("[Leagues] reroute: locked region='{}' method='{}' destPacked={} (summary every {} msgs)", region, methodSafe, packedDest, LEAGUES_LOCK_REROUTE_INFO_INTERVAL); } + if (!LeaguesTransportRegions.shouldRecalculatePathAfterLock(region, packedDest)) + { + return; + } // Recalculate immediately after lock persistence/catalog update so caller flows // (nearest-bank and any other "nearest" routing entry point) reroute in-place. Client client = Microbot.getClient(); @@ -164,12 +168,7 @@ private static boolean isLeaguesLockedAccessMessageLower(String lower) { return false; } - int accessIdx = lower.indexOf("access to the "); - if (accessIdx < 0) - { - return false; - } - if (lower.indexOf(LEAGUES_AREA_TOKEN, accessIdx) < 0) + if (lower.indexOf(LEAGUES_AREA_TOKEN) < 0) { return false; } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysis.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysis.java index ca153e7f97a..e7d53f334df 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysis.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/TransportRouteAnalysis.java @@ -56,7 +56,7 @@ public TransportRouteAnalysis(List directPath, BankLocation nearestBank, WorldPoint bankLocation,List pathToBank, List pathFromBank,String analysis) { this(directPath, nearestBank, bankLocation, pathToBank, pathFromBank, analysis, - directPath == null || directPath.isEmpty() ? -1 : directPath.size(), + deriveRouteDistance(directPath), deriveBankingRouteDistance(pathToBank, pathFromBank)); } @@ -124,7 +124,19 @@ public boolean isDirectIsFaster() { private static int deriveBankingRouteDistance(List pathToBank, List pathFromBank) { if (pathToBank == null || pathFromBank == null || pathToBank.isEmpty() || pathFromBank.isEmpty()) return -1; - return pathToBank.size() + pathFromBank.size(); + int toBank = deriveRouteDistance(pathToBank); + int fromBank = deriveRouteDistance(pathFromBank); + if (toBank < 0 || fromBank < 0) { + return -1; + } + return toBank + fromBank; + } + + private static int deriveRouteDistance(List path) { + if (path == null || path.isEmpty()) { + return -1; + } + return Math.max(path.size() - 1, 0); } @Override diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/banking/Rs2WalkerBankingPlanner.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/banking/Rs2WalkerBankingPlanner.java index 82465beaf98..64f3c61a2ed 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/banking/Rs2WalkerBankingPlanner.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/banking/Rs2WalkerBankingPlanner.java @@ -138,12 +138,10 @@ public static Map getMissingTransportItemIdsWithQuantities(Lis spellRuneRequirements.forEach((runeItemId, requiredQuantity) -> { try { int bankQuantity = Rs2Bank.count(runeItemId); - if (bankQuantity >= requiredQuantity) { - int currentQuantity = itemQuantityMap.getOrDefault(runeItemId, 0); - itemQuantityMap.put(runeItemId, currentQuantity + requiredQuantity); - log.debug("Added teleportation spell rune requirement: {} (ID: {}) x{} (bank has: {})", - runeItemId, runeItemId, requiredQuantity, bankQuantity); - } + int currentQuantity = itemQuantityMap.getOrDefault(runeItemId, 0); + itemQuantityMap.put(runeItemId, currentQuantity + requiredQuantity); + log.debug("Added teleportation spell rune requirement: {} (ID: {}) x{} (bank has: {} short={})", + runeItemId, runeItemId, requiredQuantity, bankQuantity, bankQuantity < requiredQuantity); } catch (Exception e) { log.debug("Could not check bank for rune " + runeItemId + ": " + e.getMessage()); } @@ -154,47 +152,32 @@ public static Map getMissingTransportItemIdsWithQuantities(Lis if (transport.getItemIdRequirements() != null) { for (Set alternativeItems : transport.getItemIdRequirements()) { - boolean hasAnyAlternative = alternativeItems.stream().anyMatch(itemId -> { + int requiredQuantity = (isCurrencyBasedTransport(transport.getType()) && transport.getCurrencyAmount() > 0) + ? transport.getCurrencyAmount() + : 1; + + Integer preferredItemId = null; + int preferredBankQuantity = 0; + for (Integer itemId : alternativeItems) { + int bankQuantity = 0; try { - if (isCurrencyBasedTransport(transport.getType()) && transport.getCurrencyAmount() > 0) { - return Rs2Bank.count(itemId) >= transport.getCurrencyAmount(); - } - return Rs2Bank.hasItem(itemId); + bankQuantity = Rs2Bank.count(itemId); } catch (Exception e) { log.debug("Could not check bank for item " + itemId + ": " + e.getMessage()); - return false; } - }); + if (preferredItemId == null || bankQuantity > preferredBankQuantity) { + preferredItemId = itemId; + preferredBankQuantity = bankQuantity; + } + } - if (hasAnyAlternative) { - alternativeItems.stream() - .filter(itemId -> { - try { - if (isCurrencyBasedTransport(transport.getType()) && transport.getCurrencyAmount() > 0) { - return Rs2Bank.count(itemId) >= transport.getCurrencyAmount(); - } - return Rs2Bank.hasItem(itemId); - } catch (Exception e) { - log.debug("Could not check bank for item " + itemId + ": " + e.getMessage()); - return false; - } - }) - .findFirst() - .ifPresent(itemId -> { - int requiredQuantity; - if (isCurrencyBasedTransport(transport.getType()) && transport.getCurrencyAmount() > 0) { - requiredQuantity = transport.getCurrencyAmount(); - log.debug("Currency-based transport {} requires {} x{}", - transport.getType(), transport.getCurrencyName(), requiredQuantity); - } else { - requiredQuantity = 1; - } - - int currentQuantity = itemQuantityMap.getOrDefault(itemId, 0); - itemQuantityMap.put(itemId, currentQuantity + requiredQuantity); - }); - break; + if (preferredItemId != null) { + int currentQuantity = itemQuantityMap.getOrDefault(preferredItemId, 0); + itemQuantityMap.put(preferredItemId, currentQuantity + requiredQuantity); + log.debug("Added transport item requirement: itemId={} x{} (bank has: {} short={})", + preferredItemId, requiredQuantity, preferredBankQuantity, preferredBankQuantity < requiredQuantity); } + break; } } }); @@ -311,7 +294,10 @@ public static TransportRouteAnalysis compareRoutes(WorldPoint startPoint, WorldP .append(" tiles\n"); } - if (distanceToBank != -1 && distanceFromBank != -1) { + if (distanceToBank != -1 + && distanceFromBank != -1 + && distanceToBank != Integer.MAX_VALUE + && distanceFromBank != Integer.MAX_VALUE) { bankingRouteDistance = distanceToBank + distanceFromBank; } performanceLog.append("\t-Total banking route distance: ").append(bankingRouteDistance).append(" tiles\n"); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java index 3c3a29abc50..81cad073dfd 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/walker/lifecycle/Rs2WalkerLifecycleRuntime.java @@ -74,13 +74,12 @@ public static void applyWalkerDestination(WorldPoint target) { } return Rs2Player.getWorldLocation(); }); - ShortestPathPlugin.setLastLocation(start); final Pathfinder pathfinder = ShortestPathPlugin.getPathfinder(); - if (ShortestPathPlugin.isStartPointSet() && pathfinder != null) { - start = pathfinder.getStart(); - } - final WorldPoint startPoint = start; - Microbot.getClientThread().runOnSeperateThread(() -> restartPathfinding(startPoint, target)); + final WorldPoint effectiveStart = (ShortestPathPlugin.isStartPointSet() && pathfinder != null) + ? pathfinder.getStart() + : start; + ShortestPathPlugin.setLastLocation(effectiveStart); + Microbot.getClientThread().runOnSeperateThread(() -> restartPathfinding(effectiveStart, target)); } public static boolean restartPathfinding(WorldPoint start, WorldPoint end) { From fc519651579c10532f0314df8bfe9b587b0ef6ef Mon Sep 17 00:00:00 2001 From: Sami Date: Mon, 11 May 2026 21:14:34 +0200 Subject: [PATCH 9/9] chore(gradle): update microbot version to 2.5.8 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 08802b10fb4..a55e70d7088 100644 --- a/gradle.properties +++ b/gradle.properties @@ -31,7 +31,7 @@ project.build.group=net.runelite project.build.version=1.12.26.2 glslang.path= -microbot.version=2.5.8 +microbot.version=2.5.9 microbot.commit.sha=nogit microbot.repo.url=http://138.201.81.246:8081/repository/microbot-snapshot/ microbot.repo.username=