diff --git a/docs/F2P_WEBWALKER_HARNESS.md b/docs/F2P_WEBWALKER_HARNESS.md new file mode 100644 index 00000000000..694da873f2a --- /dev/null +++ b/docs/F2P_WEBWALKER_HARNESS.md @@ -0,0 +1,66 @@ +# F2P Web Walker Harness + +This harness runs live in-game webwalker regression routes for a fresh F2P account after Tutorial Island. It uses `Rs2Walker.walkWithState(...)` for both setup movement and the route under test. + +The Microbot CLI may be used while investigating a failure to read state, nearby objects, nearby NPCs, screenshots, or logs. Do not use `./microbot-cli walk` or any other manual movement command to place the player at the destination. + +## Run + +Full fail-fast suite: + +```bash +scripts/run-f2p-webwalker-harness.sh +``` + +Specific route: + +```bash +scripts/run-f2p-webwalker-harness.sh F2P-15 +``` + +The script compiles the client, starts RuneLite in test mode with AutoLogin enabled by `TestRunnerPlugin`, runs the hidden `F2P Web Walker Harness` plugin, and writes: + +```text +~/.runelite/test-results/f2p-webwalker/result.json +``` + +Override the timeout or output directory: + +```bash +MICROBOT_WEBWALKER_TIMEOUT_MS=2400000 \ +MICROBOT_WEBWALKER_LEG_TIMEOUT_MS=300000 \ +MICROBOT_WEBWALKER_OUTPUT_DIR=/tmp/f2p-webwalker \ +scripts/run-f2p-webwalker-harness.sh F2P-15 +``` + +The runner forwards route settings through `microbot.test.webwalker.*` system properties because the Gradle `runTest` task only propagates `microbot.test.*` properties into the launched client JVM. + +## Agent Loop + +1. Run the full suite. +2. If a route fails, inspect `result.json`, `~/.runelite/logs/client.log`, and optional observational CLI output such as `./microbot-cli state`, `objects`, `npcs`, or screenshots. +3. Understand whether the failure was setup movement or the route itself. +4. Patch the walker or supporting path data. +5. Rebuild and rerun only the failed route, for example `scripts/run-f2p-webwalker-harness.sh F2P-15`. +6. Once the failed route passes, rerun the full suite. + +## Required Routes + +| ID | From | To | Coverage | +|---|---:|---:|---| +| F2P-01 | `3222,3218,0` | `3208,3220,2` | Lumbridge castle stairs and plane change | +| F2P-02 | `3208,3220,2` | `3253,3266,0` | Castle exit to local outdoor area | +| F2P-03 | `3253,3266,0` | `3092,3245,0` | Lumbridge/Draynor open-world routing | +| F2P-04 | `3092,3245,0` | `3109,3168,0` | Draynor to Wizards' Tower bridge | +| F2P-05 | `3109,3168,0` | `3029,3217,0` | Bridge to Port Sarim docks | +| F2P-06 | `3029,3217,0` | `2957,3214,0` | Port Sarim to Rimmington | +| F2P-07 | `2957,3214,0` | `2946,3368,0` | Rimmington to Falador | +| F2P-08 | `2946,3368,0` | `3082,3420,0` | Falador to Barbarian Village | +| F2P-09 | `3082,3420,0` | `3093,3493,0` | Barbarian Village to Edgeville | +| F2P-10 | `3093,3493,0` | `3164,3486,0` | Edgeville to Grand Exchange | +| F2P-11 | `3164,3486,0` | `3185,3441,0` | Grand Exchange to Varrock west bank | +| F2P-12 | `3185,3441,0` | `3253,3420,0` | Varrock west-to-east city routing | +| F2P-13 | `3253,3420,0` | `3222,3218,0` | Varrock to Lumbridge long return | +| F2P-14 | `3092,3245,0` | `3109,3341,0` | Draynor Manor approach | +| F2P-15 | `3109,3341,0` | `3106,3363,0` | Draynor Manor door/object handling | +| F2P-16 | `3106,3363,0` | `3092,3245,0` | Reverse manor exit behavior | diff --git a/docs/entity-guides/README.md b/docs/entity-guides/README.md index 3b1d882a5aa..09f9b68668b 100644 --- a/docs/entity-guides/README.md +++ b/docs/entity-guides/README.md @@ -9,6 +9,7 @@ Each guide lists known pitfalls when working with one specific game entity type. | Entity | File | When to read | |--------|------|--------------| | Items (inventory, bank, ground, equipment, shops) | [items.md](items.md) | Any code calling `Rs2Inventory`, `Rs2Bank`, `Rs2Equipment`, `Rs2GroundItem`, `Rs2Shop`, or `Rs2DepositBox` interaction helpers, or any helper that takes a list of item names and applies a single action to all of them | +| Movement (walker, minimap, pathing) | [movement.md](movement.md) | Any code calling or modifying `Rs2Walker`, `Rs2MiniMap`, shortest-path marker handling, or minimap/canvas walk-click logic | ## Format diff --git a/docs/entity-guides/movement.md b/docs/entity-guides/movement.md new file mode 100644 index 00000000000..23252886d8a --- /dev/null +++ b/docs/entity-guides/movement.md @@ -0,0 +1,78 @@ +# Movement Gotchas + +## 1. Do not recurse on failed minimap clicks without changing the click target + +`Rs2Walker.processWalk` holds the walker lock while processing a path. If a minimap click is rejected because the calculated point is outside the minimap clip, immediately recursing with the same target can spin forever while still holding the lock. Shrink the click target toward the player or otherwise change the condition before retrying. + +**Why this matters:** Quest steps that walk to a nearby object can repeatedly calculate a valid path but never move, starving other walk requests because the walker lock is never released. + +**Pattern to follow:** + +```java +WorldPoint clickTarget = getPointWithWallDistance(targetWp); +boolean clicked = Rs2Walker.walkMiniMap(clickTarget); +if (!clicked) +{ + clicked = walkMiniMapToward(clickTarget, playerLoc, MINIMAP_REACH_EUCLIDEAN - 1); +} +``` + +**Where this applies:** `Rs2Walker`, `Rs2MiniMap`, and shortest-path walking loops. + +**Defensive check:** When debugging stalls, compare pathfinder logs with `./microbot-cli state`. A repeating valid path with an unchanged player position usually means the click layer failed after pathing succeeded. + +## 2. Probe raw path obstacles before declaring the walker stuck + +Path smoothing can collapse many adjacent raw path tiles into one minimap waypoint. Some doors and gates are not represented as blocking collision in the pathfinder map, so the smoothed segment may legally cross them while hiding the exact tile the object handler needs to inspect. Run nearby raw-path door/object checks as soon as the raw path is longer than the smoothed path and the obstacle is in scene range; do not wait for `stuckCount` to increment first. + +**Why this matters:** A walk from Varrock castle's upper floors toward Varrock fountain can descend correctly, then stall at the plane-1 castle door because the smoothed waypoint skips over the door tile and the normal per-segment door check never sees it. + +**Pattern to follow:** + +```java +if (rawPath != null && path != null && rawPath.size() > path.size() + && handleNearbyRawPathSceneObjects(rawPath, HANDLER_RANGE)) { + doorOrTransportResult = true; +} +``` + +**Where this applies:** `Rs2Walker`, `PathSmoother`, and shortest-path obstacle handling. + +**Defensive check:** When a path stalls beside a visible door while the pathfinder reports a complete route, compare raw and smoothed path lengths; if the raw path is longer, verify nearby raw-path obstacle probing happens before stall recovery. + +## 3. Match wall doors by crossed edge, not nearby tile + +Wall-object doors block the edge between the wall object's tile and the neighboring tile indicated by its orientation. Raw-path segment probes must only treat a wall door as relevant when the path segment actually transitions across that edge. Do not match a wall door merely because the path starts on, ends on, or passes near one side of the door. + +**Why this matters:** At Draynor Manor's east/back door, the player can stand on the south-side door tile and need to walk southwest into the room. A broad "door near segment" match repeatedly re-opens the back door instead of allowing the next minimap walk step to run. + +**Pattern to follow:** + +```java +WorldPoint doorTile = wall.getWorldLocation(); +WorldPoint blockedNeighbor = getWallDoorNeighborPoint(wall.getOrientationA(), doorTile); +return isDoorEdgeTransition(previousPathTile, nextPathTile, doorTile, blockedNeighbor); +``` + +**Where this applies:** `Rs2Walker.handleNearbyRawPathSceneObjects`, `Rs2Walker.findDoorNearSegment`, and any wall-door probe that uses `WallObject.getOrientationA()`. + +**Defensive check:** Add a unit test for a path starting on the door's blocked-neighbor tile and moving away from the door; it must return false. + +## 4. Do not raw-probe doors while the player is already moving + +Raw-path scene-object probing is a recovery aid for smoothed paths that hide nearby obstacles. Once a door interaction has started movement, let that movement settle or reach the door edge before probing again. Re-running raw probes while the player is still moving can repeatedly interact with the same door and prevent the normal minimap/path step from taking over. + +**Why this matters:** When leaving Draynor Manor through the east/back door, the walker can click the door, start moving toward it, then immediately re-enter raw-path probing and click the same door again instead of continuing through the path outside. + +**Pattern to follow:** + +```java +if (Rs2Player.isMoving()) { + return false; +} +waitForDoorInteractionProgress(fromWp, toWp); +``` + +**Where this applies:** `Rs2Walker.handleNearbyRawPathSceneObjects`, door handlers that call `Rs2GameObject.interact`, and any recovery logic that recurses into `processWalk`. + +**Defensive check:** In live testing, a door should produce one interaction followed by movement/path progress, not repeated `Raw path door handler resolved obstacle` messages every tick while the player is moving. diff --git a/gradle.properties b/gradle.properties index 3ead54f47b4..ccebfca0e08 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.6 +microbot.version=2.5.7 microbot.commit.sha=nogit microbot.repo.url=http://138.201.81.246:8081/repository/microbot-snapshot/ microbot.repo.username= diff --git a/microbot-cli b/microbot-cli index 3ca7406eaef..1831dbfe23c 100755 --- a/microbot-cli +++ b/microbot-cli @@ -69,9 +69,10 @@ Ground Items: ground-items pickup Pick up nearest ground item Movement: - walk [plane] [--wait] [--timeout SECONDS] + walk [plane] [--wait] [--timeout SECONDS] [--distance TILES] Walk to coordinates (non-blocking by default; - --wait blocks until arrival or timeout, default 30s) + --wait blocks until arrival or timeout, default 30s; + --distance defaults to 0 for exact coordinates) Banking: bank Bank status (open/closed, items) @@ -104,6 +105,10 @@ Scripts: scripts results --class Get test results scripts results post --class --data Submit test results +Quest Helper: + quest-helper status Current selected quest helper state + quest-helper start --name Select/start a quest helper + Keyboard: keyboard type Type text into the game keyboard enter Press Enter key @@ -364,12 +369,13 @@ case "$1" in ;; walk) - [[ $# -lt 3 ]] && { echo '{"error":"Usage: microbot-cli walk [plane] [--wait] [--timeout SECONDS]"}'; exit 1; } + [[ $# -lt 3 ]] && { echo '{"error":"Usage: microbot-cli walk [plane] [--wait] [--timeout SECONDS] [--distance TILES]"}'; exit 1; } walk_x="$2" walk_y="$3" walk_plane=0 walk_wait="false" walk_timeout=30 + walk_distance=0 shift 3 if [[ $# -gt 0 ]] && [[ "$1" != --* ]]; then walk_plane="$1" @@ -379,10 +385,11 @@ case "$1" in case "$1" in --wait) walk_wait="true"; shift ;; --timeout) walk_timeout="$2"; shift 2 ;; + --distance) walk_distance="$2"; shift 2 ;; *) shift ;; esac done - walk_body="{\"x\":${walk_x},\"y\":${walk_y},\"plane\":${walk_plane},\"wait\":${walk_wait},\"timeout\":${walk_timeout}}" + walk_body="{\"x\":${walk_x},\"y\":${walk_y},\"plane\":${walk_plane},\"wait\":${walk_wait},\"timeout\":${walk_timeout},\"distance\":${walk_distance}}" if [[ "$walk_wait" == "true" ]]; then curl_timeout=$((walk_timeout + 5)) curl -sf --max-time "$curl_timeout" "${AUTH_HEADER_ARGS[@]}" -X POST -H "Content-Type: application/json" -d "$walk_body" "${BASE}/walk" 2>/dev/null \ @@ -600,6 +607,32 @@ case "$1" in esac ;; + quest-helper) + [[ $# -lt 2 ]] && { echo '{"error":"Usage: microbot-cli quest-helper status | start --name "}'; exit 1; } + case "$2" in + status) + do_get "${BASE}/quest-helper/status" + ;; + start) + shift 2 + name="" + while [[ $# -gt 0 ]]; do + case "$1" in + --name) name="$2"; shift 2 ;; + *) shift ;; + esac + done + if [[ -z "$name" ]]; then + echo '{"error":"Usage: microbot-cli quest-helper start --name "}' + exit 1 + fi + escaped=$(json_escape "$name") + do_post "${BASE}/quest-helper/start" "{\"name\":\"${escaped}\"}" + ;; + *) echo '{"error":"Usage: microbot-cli quest-helper status | start --name "}'; exit 1 ;; + esac + ;; + keyboard) [[ $# -lt 2 ]] && { echo '{"error":"Usage: microbot-cli keyboard type | enter | escape | backspace"}'; exit 1; } case "$2" in diff --git a/runelite-client/build.gradle.kts b/runelite-client/build.gradle.kts index 137aa96966e..e653ebf4072 100644 --- a/runelite-client/build.gradle.kts +++ b/runelite-client/build.gradle.kts @@ -60,7 +60,8 @@ tasks.register("run") { mainClass.set("net.runelite.client.RuneLite") jvmArgs( - "-Dfile.encoding=UTF-8" + "-Dfile.encoding=UTF-8", + "-ea" ) } @@ -91,6 +92,7 @@ tasks.register("runDebug") { // same JVM args you need normally jvmArgs( "-Dfile.encoding=UTF-8", + "-ea", // JDWP agent for debugger "-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005" ) @@ -103,7 +105,10 @@ tasks.register("runTest") { classpath = sourceSets.main.get().runtimeClasspath mainClass.set("net.runelite.client.RuneLite") - jvmArgs("-Dfile.encoding=UTF-8") + jvmArgs( + "-Dfile.encoding=UTF-8", + "-ea" + ) System.getProperties() .filter { it.key.toString().startsWith("microbot.test.") } diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/BlockingEventManager.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/BlockingEventManager.java index a4c545aa39c..8a738ccaee2 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/BlockingEventManager.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/BlockingEventManager.java @@ -51,9 +51,6 @@ public BlockingEventManager() // single-threaded executor for running event.execute() this.blockingExecutor = Executors.newSingleThreadExecutor(threadFactory); - // scheduler for periodic validate() calls - startLoop(); - // pre-register core events blockingEvents.add(new WelcomeScreenEvent()); blockingEvents.add(new DisableLevelUpInterfaceEvent()); @@ -68,6 +65,13 @@ public BlockingEventManager() sortBlockingEvents(); } + public synchronized void start() { + if (loopFuture != null && !loopFuture.isCancelled() && !loopFuture.isDone()) { + return; + } + startLoop(); + } + public void shutdown() { if (loopFuture != null) loopFuture.cancel(true); scheduler.shutdownNow(); 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 bc480d0ef00..604625a81d8 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 @@ -149,6 +149,7 @@ protected void startUp() throws AWTException ); Microbot.pauseAllScripts.set(false); + Microbot.getBlockingEventManager().start(); MicrobotPluginListPanel pluginListPanel = pluginListPanelProvider.get(); pluginListPanel.addFakePlugin(new MicrobotPluginConfigurationDescriptor( diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java index a0ca28dcda5..5f8ffc60408 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/AgentServerPlugin.java @@ -148,6 +148,7 @@ private List buildHandlers(int maxResults) { new WidgetInvokeHandler(gson), new SettingsHandler(gson), new KeyboardHandler(gson), + new QuestHelperHandler(gson), new StateMachineDebugHandler(gson), new ProfileHandler(gson), new DynamicScriptDeployHandler(gson) diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandler.java new file mode 100644 index 00000000000..86cedde0b2e --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/QuestHelperHandler.java @@ -0,0 +1,140 @@ +package net.runelite.client.plugins.microbot.agentserver.handler; + +import com.google.gson.Gson; +import com.sun.net.httpserver.HttpExchange; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import net.runelite.client.plugins.PluginManager; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.questhelper.QuestHelperConfig; +import net.runelite.client.plugins.microbot.questhelper.QuestHelperPlugin; +import net.runelite.client.plugins.microbot.questhelper.questhelpers.QuestHelper; +import net.runelite.client.plugins.microbot.questhelper.steps.QuestStep; + +public class QuestHelperHandler extends AgentHandler +{ + private static final String BASE_PATH = "/quest-helper"; + + public QuestHelperHandler(Gson gson) + { + super(gson); + } + + @Override + public String getPath() + { + return BASE_PATH; + } + + @Override + protected void handleRequest(HttpExchange exchange) throws IOException + { + String sub = getSubPath(exchange, BASE_PATH); + + switch (sub) + { + case "/status": + handleStatus(exchange); + break; + case "/start": + handleStart(exchange); + break; + default: + sendJson(exchange, 404, errorResponse("Unknown endpoint: " + BASE_PATH + sub)); + } + } + + private void handleStatus(HttpExchange exchange) throws IOException + { + try + { + requireGet(exchange); + } + catch (HttpMethodException e) + { + sendJson(exchange, 405, errorResponse(e.getMessage())); + return; + } + + QuestHelperPlugin plugin = Microbot.getPlugin(QuestHelperPlugin.class); + if (plugin == null) + { + sendJson(exchange, 404, errorResponse("Quest Helper plugin not found")); + return; + } + + Map result = new LinkedHashMap<>(); + PluginManager pluginManager = Microbot.getPluginManager(); + result.put("active", pluginManager != null && pluginManager.isActive(plugin)); + result.put("enabled", pluginManager != null && pluginManager.isPluginEnabled(plugin)); + result.put("turnOn", Microbot.getConfigManager() + .getConfiguration(QuestHelperConfig.QUEST_HELPER_GROUP, "TurnOn")); + + QuestHelper selectedQuest = plugin.getSelectedQuest(); + result.put("selected", selectedQuest != null); + if (selectedQuest != null) + { + result.put("questName", selectedQuest.getQuest().getName()); + result.put("completed", Microbot.getClientThread() + .runOnClientThreadOptional(selectedQuest::isCompleted) + .orElse(false)); + + QuestStep currentStep = selectedQuest.getCurrentStep(); + if (currentStep != null) + { + QuestStep activeStep = currentStep.getActiveStep(); + result.put("stepText", activeStep != null ? activeStep.getText() : currentStep.getText()); + result.put("stepClass", activeStep != null + ? activeStep.getClass().getSimpleName() + : currentStep.getClass().getSimpleName()); + } + } + + sendJson(exchange, 200, result); + } + + private void handleStart(HttpExchange exchange) throws IOException + { + try + { + requirePost(exchange); + } + catch (HttpMethodException e) + { + sendJson(exchange, 405, errorResponse(e.getMessage())); + return; + } + + Map body = readJsonBody(exchange); + String name = (String) body.get("name"); + if (name == null || name.isEmpty()) + { + sendJson(exchange, 400, errorResponse("name is required")); + return; + } + + QuestHelperPlugin plugin = Microbot.getPlugin(QuestHelperPlugin.class); + if (plugin == null) + { + sendJson(exchange, 404, errorResponse("Quest Helper plugin not found")); + return; + } + + if (!plugin.startQuestHelper(name)) + { + sendJson(exchange, 404, errorResponse("Quest helper not found: " + name)); + return; + } + + Microbot.getConfigManager().setRSProfileConfiguration( + QuestHelperConfig.QUEST_HELPER_GROUP, + "TurnOn", + true); + + Map result = new LinkedHashMap<>(); + result.put("success", true); + result.put("questName", name); + sendJson(exchange, 200, result); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WalkHandler.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WalkHandler.java index 7cff3002ce7..2f71c081bb1 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WalkHandler.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/agentserver/handler/WalkHandler.java @@ -15,7 +15,6 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; @Slf4j @@ -23,6 +22,8 @@ public class WalkHandler extends AgentHandler { private static final int DEFAULT_TIMEOUT_SECONDS = 30; private static final int MAX_TIMEOUT_SECONDS = 600; + private static final int DEFAULT_REACHED_DISTANCE = 0; + private static final int MAX_REACHED_DISTANCE = 20; private static final AtomicInteger WALK_THREAD_COUNT = new AtomicInteger(1); private static final ExecutorService WALK_EXECUTOR = Executors.newSingleThreadExecutor(r -> { @@ -33,6 +34,7 @@ public class WalkHandler extends AgentHandler { private static volatile Future activeWalk; private static volatile WorldPoint activeWalkTarget; + private static volatile int activeWalkReachedDistance; public WalkHandler(Gson gson) { super(gson); @@ -78,9 +80,16 @@ protected void handleRequest(HttpExchange exchange) throws IOException { if (timeoutSeconds > MAX_TIMEOUT_SECONDS) timeoutSeconds = MAX_TIMEOUT_SECONDS; } + int reachedDistance = DEFAULT_REACHED_DISTANCE; + if (body.get("distance") instanceof Number) { + reachedDistance = ((Number) body.get("distance")).intValue(); + if (reachedDistance < 0) reachedDistance = DEFAULT_REACHED_DISTANCE; + if (reachedDistance > MAX_REACHED_DISTANCE) reachedDistance = MAX_REACHED_DISTANCE; + } + WorldPoint destination = new WorldPoint(xNum.intValue(), yNum.intValue(), plane); - Future walkFuture = submitWalk(destination); + Future walkFuture = submitWalk(destination, reachedDistance); Map response = new LinkedHashMap<>(); Map dest = new LinkedHashMap<>(); @@ -88,6 +97,7 @@ protected void handleRequest(HttpExchange exchange) throws IOException { dest.put("y", destination.getY()); dest.put("plane", destination.getPlane()); response.put("destination", dest); + response.put("reachedDistance", reachedDistance); if (!wait) { response.put("success", true); @@ -98,13 +108,43 @@ protected void handleRequest(HttpExchange exchange) throws IOException { return; } - WalkerState state; - boolean timedOut = false; + WalkerState state = null; + boolean timedOut; + boolean arrivedByPosition = false; try { - state = walkFuture.get(timeoutSeconds, TimeUnit.SECONDS); - } catch (TimeoutException te) { - state = null; - timedOut = true; + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds); + while (true) { + if (walkFuture.isDone()) { + state = walkFuture.get(); + timedOut = false; + break; + } + + int distanceToDestination = distanceTo(destination); + if (distanceToDestination >= 0 && distanceToDestination <= reachedDistance) { + state = WalkerState.ARRIVED; + arrivedByPosition = true; + timedOut = false; + walkFuture.cancel(true); + clearActiveWalk(walkFuture); + break; + } + + long remainingNanos = deadline - System.nanoTime(); + if (remainingNanos <= 0) { + timedOut = true; + break; + } + + Thread.sleep(Math.min(100, TimeUnit.NANOSECONDS.toMillis(remainingNanos))); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + response.put("success", false); + response.put("error", "Walk interrupted"); + addPlayerPosition(response); + sendJson(exchange, 500, response); + return; } catch (Exception e) { log.warn("Walk failed: {}", e.toString()); response.put("success", false); @@ -115,28 +155,55 @@ protected void handleRequest(HttpExchange exchange) throws IOException { } if (timedOut) { + int distanceToDestination = distanceTo(destination); response.put("success", false); response.put("walking", true); response.put("timedOut", true); response.put("message", "Walk did not complete within " + timeoutSeconds + "s; still in progress"); + if (distanceToDestination >= 0) { + response.put("distanceToDestination", distanceToDestination); + } } else { - response.put("success", state == WalkerState.ARRIVED); + int distanceToDestination = distanceTo(destination); + response.put("success", state == WalkerState.ARRIVED && distanceToDestination <= reachedDistance); response.put("walking", false); response.put("state", state == null ? "UNKNOWN" : state.name()); + if (arrivedByPosition) { + response.put("arrivedByPosition", true); + } + if (distanceToDestination >= 0) { + response.put("distanceToDestination", distanceToDestination); + } } addPlayerPosition(response); sendJson(exchange, 200, response); } - private static synchronized Future submitWalk(WorldPoint destination) { - if (activeWalk != null && !activeWalk.isDone() && destination.equals(activeWalkTarget)) { + private static synchronized Future submitWalk(WorldPoint destination, int reachedDistance) { + if (activeWalk != null && !activeWalk.isDone() + && destination.equals(activeWalkTarget) + && reachedDistance == activeWalkReachedDistance) { return activeWalk; } activeWalkTarget = destination; - activeWalk = WALK_EXECUTOR.submit(() -> Rs2Walker.walkWithState(destination)); + activeWalkReachedDistance = reachedDistance; + activeWalk = WALK_EXECUTOR.submit(() -> Rs2Walker.walkWithState(destination, reachedDistance)); return activeWalk; } + private static synchronized void clearActiveWalk(Future walkFuture) { + if (activeWalk == walkFuture) { + activeWalk = null; + activeWalkTarget = null; + activeWalkReachedDistance = DEFAULT_REACHED_DISTANCE; + } + } + + private static int distanceTo(WorldPoint destination) { + WorldPoint playerPos = Microbot.getRs2PlayerStateCache().getLocalPlayerPosition(); + return playerPos == null ? -1 : playerPos.distanceTo(destination); + } + private static void addPlayerPosition(Map response) { WorldPoint playerPos = Microbot.getRs2PlayerStateCache().getLocalPlayerPosition(); if (playerPos != null) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/moaaudit/MoaAuditPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/moaaudit/MoaAuditPlugin.java deleted file mode 100644 index 03345e71e82..00000000000 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/moaaudit/MoaAuditPlugin.java +++ /dev/null @@ -1,32 +0,0 @@ -package net.runelite.client.plugins.microbot.moaaudit; - -import lombok.extern.slf4j.Slf4j; -import net.runelite.client.plugins.Plugin; -import net.runelite.client.plugins.PluginDescriptor; -import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; - -// TEMP debug plugin: on enable, iterates every Map of Alacrity seasonal transport, -// attempts to teleport, and logs actual landing vs expected coord for each. Used to -// catch bad destination coords in seasonal_transports.tsv. Delete when done. -@PluginDescriptor( - name = PluginDescriptor.Default + "MoA Audit", - description = "[TEMP] Record Map of Alacrity teleport landing tiles", - tags = {"temp", "debug", "league", "microbot"}, - enabledByDefault = false -) -@Slf4j -public class MoaAuditPlugin extends Plugin { - private Thread worker; - - @Override - protected void startUp() { - worker = new Thread(Rs2Walker::runMoaAudit, "moa-audit"); - worker.setDaemon(true); - worker.start(); - } - - @Override - protected void shutDown() { - if (worker != null) worker.interrupt(); - } -} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java index 3544c5ceaf6..3f272b847cf 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/QuestHelperPlugin.java @@ -475,6 +475,18 @@ public List getPluginBankTagItemsForSections() return questManager.getSelectedQuest(); } + public boolean startQuestHelper(String questName) + { + QuestHelper questHelper = QuestHelperQuest.getByName(questName); + if (questHelper == null) + { + return false; + } + + questManager.startUpQuest(questHelper, true); + return true; + } + public Map getBackgroundHelpers() { return questManager.backgroundHelpers; 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 89472ed084d..1f87a277a8f 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 @@ -768,18 +768,35 @@ private boolean isItemRequirementTradable(ItemRequirement itemRequirement) { } private int tradablePrimaryId(ItemRequirement itemRequirement) { - return Microbot.getClientThread().runOnClientThreadOptional(() -> { + List tradableIds = Microbot.getClientThread().runOnClientThreadOptional(() -> { + List ids = new ArrayList<>(); for (Integer id : itemRequirement.getAllIds()) { if (id == null || id <= 0) { continue; } ItemComposition def = Microbot.getClient().getItemDefinition(id); if (def != null && def.isTradeable()) { - return id; + ids.add(id); } } + return ids; + }).orElse(new ArrayList<>()); + + if (tradableIds.isEmpty()) { return -1; - }).orElse(-1); + } + + // Pick the cheapest tradable variant to avoid league/cosmetic items priced at MAX_VALUE + int bestId = tradableIds.get(0); + int bestPrice = Integer.MAX_VALUE; + for (int id : tradableIds) { + int price = fetchInstabuyReferencePrice(id); + if (price > 0 && price < bestPrice) { + bestPrice = price; + bestId = id; + } + } + return bestId; } private int remainingQuantityNeeded(ItemRequirement itemRequirement) { @@ -1018,10 +1035,11 @@ private String canonicalItemName(int itemId) { private int fetchInstabuyReferencePrice(int itemId) { WikiPrice priceData = Rs2GrandExchange.getRealTimePrices(itemId); - if (priceData != null && priceData.buyPrice > 0) { + if (priceData != null && priceData.buyPrice > 0 && priceData.buyPrice < Integer.MAX_VALUE) { return priceData.buyPrice; } - return Rs2GrandExchange.getPrice(itemId); + int price = Rs2GrandExchange.getPrice(itemId); + return (price > 0 && price < Integer.MAX_VALUE) ? price : -1; } private int notedVariantId(int unnotedId) { diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/romeoandjuliet/RomeoAndJuliet.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/romeoandjuliet/RomeoAndJuliet.java index b2628343eb1..8ef44238f4f 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/romeoandjuliet/RomeoAndJuliet.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/helpers/quests/romeoandjuliet/RomeoAndJuliet.java @@ -151,7 +151,7 @@ public Map loadSteps() steps.put(50, bringPotionToJuliet); var cFinishQuest = new ConditionalStep(this, finishQuest); - giveLetterToRomeo.addStep(inJulietRoom, goDownstairsToFinishQuest); + cFinishQuest.addStep(inJulietRoom, goDownstairsToFinishQuest); steps.put(60, cFinishQuest); return steps; diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/RomeoAndJuliet.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/RomeoAndJuliet.java index 911d59dd778..e27958d0d8a 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/RomeoAndJuliet.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/questhelper/logic/RomeoAndJuliet.java @@ -1,6 +1,7 @@ package net.runelite.client.plugins.microbot.questhelper.logic; import net.runelite.api.Quest; +import net.runelite.api.coords.WorldPoint; import net.runelite.api.gameval.ItemID; import net.runelite.api.gameval.ObjectID; import net.runelite.client.plugins.microbot.questhelper.steps.QuestStep; @@ -14,6 +15,10 @@ * Romeo and Juliet quest custom logic */ public class RomeoAndJuliet extends BaseQuest { + private static final WorldPoint JULIET_STAIR_ORIGIN = new WorldPoint(3159, 3436, 0); + private static final WorldPoint JULIET_TOP_STAIR = new WorldPoint(3156, 3435, 1); + private static final WorldPoint ROMEO_LOCATION = new WorldPoint(3211, 3422, 0); + @Override public boolean executeCustomLogic() { QuestStep questStep = getQuestHelperPlugin().getSelectedQuest().getCurrentStep().getActiveStep(); @@ -28,10 +33,72 @@ public boolean executeCustomLogic() { return false; } } + if (questStep.getText().contains("Bring the potion to Juliet in the house west of Varrock.")) { + return climbToJulietWithPotion(); + } + if (questStep.getText().contains("Talk to Romeo in Varrock Square to finish the quest.")) { + return leaveJulietRoomForRomeo(); + } } return true; } + private boolean climbToJulietWithPotion() { + if (!Rs2Inventory.hasItem(ItemID.CADAVA)) { + return true; + } + + WorldPoint playerLocation = Rs2Player.getWorldLocation(); + if (playerLocation == null || playerLocation.getPlane() != 0) { + return true; + } + + if (playerLocation.distanceTo2D(JULIET_STAIR_ORIGIN) > 0) { + Rs2Walker.walkFastCanvas(JULIET_STAIR_ORIGIN); + return false; + } + + Rs2GameObject.interact(ObjectID.FAI_VARROCK_STAIRS_TALLER, "Climb-up"); + Rs2Player.waitForWalking(); + return false; + } + + private boolean leaveJulietRoomForRomeo() { + WorldPoint playerLocation = Rs2Player.getWorldLocation(); + if (playerLocation == null) { + return true; + } + + if (playerLocation.getPlane() == 0) { + return walkToRomeo(); + } + + if (playerLocation.getPlane() != 1) { + return true; + } + + if (playerLocation.distanceTo2D(JULIET_TOP_STAIR) > 4) { + Rs2Walker.walkFastCanvas(JULIET_TOP_STAIR); + return false; + } + + Rs2GameObject.interact(ObjectID.FAI_VARROCK_STAIRS_TOP, "Climb-down"); + Rs2Player.waitForWalking(); + return false; + } + + private boolean walkToRomeo() { + WorldPoint playerLocation = Rs2Player.getWorldLocation(); + if (playerLocation == null || playerLocation.distanceTo2D(ROMEO_LOCATION) <= 12) { + return true; + } + if (playerLocation.distanceTo2D(ROMEO_LOCATION) > 120) { + return true; + } + + return Rs2Walker.walkTo(ROMEO_LOCATION, 12); + } + private boolean fetchCadavaBerries() { if (Rs2Inventory.hasItem(ItemID.CADAVABERRIES)) { return true; 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 48995663b96..b5f93e949c5 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 @@ -10,6 +10,8 @@ import net.runelite.client.plugins.microbot.util.walker.WalkerState; import java.util.concurrent.TimeUnit; +import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicBoolean; @Slf4j public class ShortestPathScript extends Script { @@ -18,29 +20,24 @@ public class ShortestPathScript extends Script { // used for calling the walker from a mainthread // running the walker on a seperate thread is a lot easier for debugging private volatile WorldPoint triggerWalker; + private volatile ShortestPathConfig config; + private volatile Future walkTaskFuture; + private final AtomicBoolean walkTaskRunning = new AtomicBoolean(false); public boolean run(ShortestPathConfig config) { + this.config = config; mainScheduledFuture = scheduledExecutorService.scheduleWithFixedDelay(() -> { try { if (!Microbot.isLoggedIn()) return; if (getTriggerWalker() != null) { - WalkerState state = WalkerState.UNREACHABLE; - - if (config.walkWithBankedTransports()){ - state = Rs2Walker.walkWithBankedTransportsAndState(getTriggerWalker(),10,false); - } else { - state = Rs2Walker.walkWithState(getTriggerWalker()); - } - if (state == WalkerState.ARRIVED || state == WalkerState.UNREACHABLE) { - setTriggerWalker(null); - } + startWalkTask(); } } catch (Exception ex) { log.error("Exception in ShortestPathScript: {} - ", ex.getMessage(), ex); } - }, 0, 1000, TimeUnit.MILLISECONDS); + }, 0, 100, TimeUnit.MILLISECONDS); return true; } @@ -55,8 +52,46 @@ public void setTriggerWalker(WorldPoint point) { log.debug("ShortestPathScript: setTriggerWalker called with null point"); triggerWalker = null; Rs2Walker.setTarget(null); + Future future = walkTaskFuture; + if (future != null && !future.isDone()) { + future.cancel(true); + } + walkTaskRunning.set(false); } else { triggerWalker = point; + startWalkTask(); } } + + private void startWalkTask() { + if (!walkTaskRunning.compareAndSet(false, true)) { + return; + } + + walkTaskFuture = scheduledExecutorService.submit(() -> { + try { + WorldPoint target = getTriggerWalker(); + if (target == null || config == null || !Microbot.isLoggedIn()) { + return; + } + + WalkerState state; + if (config.walkWithBankedTransports()) { + state = Rs2Walker.walkWithBankedTransportsAndState(target, 10, false); + } else { + state = Rs2Walker.walkWithState(target); + } + + if (target.equals(getTriggerWalker()) + && (state == WalkerState.ARRIVED || state == WalkerState.UNREACHABLE || state == WalkerState.EXIT)) { + triggerWalker = null; + Rs2Walker.setTarget(null); + } + } catch (Exception ex) { + log.error("Exception in ShortestPathScript walk task: {} - ", ex.getMessage(), ex); + } finally { + walkTaskRunning.set(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 af2da686228..74a88396e6b 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 @@ -157,6 +157,7 @@ public List getNeighbors(Node node, VisitedTiles visited, PathfinderConfig int moaAddedHere = 0; int moaVisited = 0; int moaIgnored = 0; + List moaCosts = null; // Transports are pre-filtered by PathfinderConfig.refreshTransports // Thus any transports in the list are guaranteed to be valid per the user's settings @@ -177,8 +178,13 @@ public List getNeighbors(Node node, VisitedTiles visited, PathfinderConfig if (isMoa) moaIgnored++; continue; } - neighbors.add(new TransportNode(transport.getDestination(), node, config.getDistanceBeforeUsingTeleport() + transport.getDuration())); - if (isMoa) moaAddedHere++; + int cost = config.getDistanceBeforeUsingTeleport() + transport.getDuration(); + neighbors.add(new TransportNode(transport.getDestination(), node, cost)); + if (isMoa) { + moaAddedHere++; + if (moaCosts == null) moaCosts = new ArrayList<>(); + moaCosts.add(cost); + } } else { neighbors.add(new TransportNode(transport.getDestination(), node, transport.getDuration())); } @@ -186,10 +192,10 @@ public List getNeighbors(Node node, VisitedTiles visited, PathfinderConfig } if (moaSeenHere > 0) { - log.debug("[MoA] getNeighbors @ ({},{},{}): seen={} added={} visited={} ignored={} (distanceBeforeUsingTeleport={}, cost={})", + log.debug("[MoA] getNeighbors @ ({},{},{}): seen={} added={} visited={} ignored={} (distanceBeforeUsingTeleport={}, costs={})", x, y, z, moaSeenHere, moaAddedHere, moaVisited, moaIgnored, config.getDistanceBeforeUsingTeleport(), - config.getDistanceBeforeUsingTeleport() + 4); + moaCosts == null ? "[]" : moaCosts); } if (isBlocked(x, y, z)) { 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 60ec2969837..ef68324dfbf 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 @@ -577,6 +577,23 @@ private boolean useTransport(Transport transport) { 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 && !Rs2Walker.lockedMoaRegions.isEmpty()) { + 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; + } + } + } + // Check if the feature flag is disabled if (!isFeatureEnabled(transport)) { log.debug("Transport Type {} is disabled by feature flag", transport.getType()); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportNode.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportNode.java index b54b549375e..28b048e6c0b 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportNode.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/shortestpath/pathfinder/TransportNode.java @@ -4,11 +4,14 @@ public class TransportNode extends Node implements Comparable { public TransportNode(WorldPoint point, Node previous, int travelTime) { - super(point, previous, cost(previous, travelTime)); - } - - private static int cost(Node previous, int travelTime) { - return (previous != null ? previous.cost : 0) + travelTime; + // Use Node(int, Node, int cost) which assigns cost directly. The WorldPoint + // Node constructor re-adds previous.cost via its cost(previous, wait) method, + // which caused (a) double-counting when we passed prev.cost + travelTime as + // wait and (b) integer overflow for plane-crossing transports with travelTime=0 + // because its distance fallback returns Integer.MAX_VALUE across planes. + super(net.runelite.client.plugins.microbot.shortestpath.WorldPointUtil.packWorldPoint(point), + previous, + (previous != null ? previous.cost : 0) + travelTime); } @Override diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/webwalker/F2PWebWalkerHarnessPlugin.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/webwalker/F2PWebWalkerHarnessPlugin.java new file mode 100644 index 00000000000..3bc1a6d7c44 --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/webwalker/F2PWebWalkerHarnessPlugin.java @@ -0,0 +1,335 @@ +package net.runelite.client.plugins.microbot.testing.webwalker; + +import com.google.common.util.concurrent.ThreadFactoryBuilder; +import com.google.inject.Inject; +import lombok.extern.slf4j.Slf4j; +import net.runelite.api.Player; +import net.runelite.api.coords.WorldPoint; +import net.runelite.api.events.GameTick; +import net.runelite.client.eventbus.EventBus; +import net.runelite.client.eventbus.Subscribe; +import net.runelite.client.events.PluginMessage; +import net.runelite.client.plugins.Plugin; +import net.runelite.client.plugins.PluginDescriptor; +import net.runelite.client.plugins.microbot.Microbot; +import net.runelite.client.plugins.microbot.testing.TestResult; +import net.runelite.client.plugins.microbot.testing.TestResultWriter; +import net.runelite.client.plugins.microbot.util.walker.Rs2Walker; +import net.runelite.client.plugins.microbot.util.walker.WalkerState; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.stream.Collectors; + +import static net.runelite.client.plugins.microbot.util.Global.sleepUntil; + +@PluginDescriptor( + name = "F2P Web Walker Harness", + description = "Runs F2P-only in-game webwalker regression routes in test mode", + tags = {"microbot", "test", "webwalker", "f2p"}, + hidden = true +) +@Slf4j +public class F2PWebWalkerHarnessPlugin extends Plugin { + private static final String ROUTE_FILTER_PROPERTY = "microbot.webwalker.case"; + private static final String TEST_ROUTE_FILTER_PROPERTY = "microbot.test.webwalker.case"; + private static final String STOP_ON_FAILURE_PROPERTY = "microbot.webwalker.stopOnFailure"; + private static final String TEST_STOP_ON_FAILURE_PROPERTY = "microbot.test.webwalker.stopOnFailure"; + private static final String WALK_TIMEOUT_PROPERTY = "microbot.webwalker.walkTimeoutMs"; + private static final String TEST_WALK_TIMEOUT_PROPERTY = "microbot.test.webwalker.walkTimeoutMs"; + private static final String USE_TELEPORTATION_SPELLS_PROPERTY = "microbot.webwalker.useTeleportationSpells"; + private static final String TEST_USE_TELEPORTATION_SPELLS_PROPERTY = "microbot.test.webwalker.useTeleportationSpells"; + private static final String TEST_SCRIPT_PROPERTY = "microbot.test.script"; + private static final String SCRIPT_NAME = "F2P Web Walker Harness"; + private static final int DEFAULT_WALK_TIMEOUT_MS = 240000; + + @Inject + private EventBus eventBus; + + private ExecutorService executor; + private volatile WorldPoint lastLocation; + + @Override + protected void startUp() { + if (!isHarnessTarget()) { + return; + } + + executor = Executors.newSingleThreadExecutor(new ThreadFactoryBuilder() + .setNameFormat("F2PWebWalkerHarness-%d") + .build()); + executor.submit(this::runHarness); + } + + @Override + protected void shutDown() { + if (executor != null) { + executor.shutdownNow(); + } + } + + @Subscribe + public void onGameTick(GameTick event) { + if (!isHarnessTarget()) { + return; + } + + Player player = Microbot.getClient().getLocalPlayer(); + if (player != null) { + lastLocation = player.getWorldLocation(); + } + } + + private void runHarness() { + WebWalkerTestResult result = new WebWalkerTestResult(SCRIPT_NAME); + result.routeFilter = property(TEST_ROUTE_FILTER_PROPERTY, ROUTE_FILTER_PROPERTY, "all"); + result.stopOnFailure = Boolean.parseBoolean(property(TEST_STOP_ON_FAILURE_PROPERTY, STOP_ON_FAILURE_PROPERTY, "true")); + result.walkTimeoutMs = intProperty(TEST_WALK_TIMEOUT_PROPERTY, WALK_TIMEOUT_PROPERTY, DEFAULT_WALK_TIMEOUT_MS); + + int exitCode = 0; + try { + List routes = F2PWebWalkerRoute.selected(result.routeFilter); + result.selectedRoutes = routes.stream().map(route -> route.id).collect(Collectors.toList()); + + if (!sleepUntil(() -> safeLocation() != null, 60000)) { + result.addError("Timed out waiting for local player location before starting webwalker routes"); + result.complete("login_failure"); + writeAndExit(result, result.exitCode); + return; + } + + applyShortestPathOverrides(); + + log.info("[F2PWebWalkerHarness] Starting {} route(s): {}", routes.size(), result.selectedRoutes); + for (F2PWebWalkerRoute route : routes) { + if (Thread.currentThread().isInterrupted()) { + result.addError("Harness interrupted before route " + route.id); + exitCode = 1; + break; + } + + RouteOutcome outcome = runRoute(route, result.walkTimeoutMs); + result.routes.add(outcome); + + result.addCheck(route.id + " setup", outcome.setupPassed, outcome.setupError); + result.addCheck(route.id + " route", outcome.passed, outcome.error); + + if (!outcome.passed) { + exitCode = 1; + if (result.stopOnFailure) { + log.warn("[F2PWebWalkerHarness] Stopping on first failed route: {}", route.id); + break; + } + } + } + + result.complete("completed"); + writeAndExit(result, exitCode == 0 ? result.exitCode : exitCode); + } catch (Throwable t) { + log.error("[F2PWebWalkerHarness] Harness crashed", t); + result.addError(t.getClass().getSimpleName() + ": " + t.getMessage()); + result.complete("crash"); + writeAndExit(result, result.exitCode); + } + } + + private RouteOutcome runRoute(F2PWebWalkerRoute route, int walkTimeoutMs) { + RouteOutcome outcome = new RouteOutcome(); + outcome.id = route.id; + outcome.name = route.name; + outcome.start = format(route.start); + outcome.destination = format(route.destination); + outcome.startTolerance = route.startTolerance; + outcome.destinationTolerance = route.destinationTolerance; + outcome.startedAt = Instant.now().toString(); + + log.info("[F2PWebWalkerHarness] Running {}: {} -> {}", route.id, route.start, route.destination); + + WorldPoint current = safeLocation(); + outcome.initialLocation = format(current); + if (current == null) { + outcome.setupError = "Player location unavailable before setup"; + outcome.error = outcome.setupError; + return outcome; + } + + int initialDistance = distance(current, route.start); + outcome.initialDistanceToStart = initialDistance; + if (initialDistance > route.startTolerance) { + long setupStart = System.currentTimeMillis(); + outcome.setupState = walk(route.start, route.startTolerance, walkTimeoutMs); + outcome.setupDurationMs = System.currentTimeMillis() - setupStart; + } else { + outcome.setupState = WalkerState.ARRIVED.name(); + outcome.setupDurationMs = 0; + } + + WorldPoint beforeRoute = safeLocation(); + outcome.routeStartLocation = format(beforeRoute); + outcome.distanceToStartAfterSetup = distance(beforeRoute, route.start); + outcome.setupPassed = WalkerState.ARRIVED.name().equals(outcome.setupState) + && outcome.distanceToStartAfterSetup <= route.startTolerance; + if (!outcome.setupPassed) { + outcome.setupError = "Setup webwalk failed for " + route.id + + ": state=" + outcome.setupState + + ", location=" + outcome.routeStartLocation + + ", distanceToStart=" + outcome.distanceToStartAfterSetup; + outcome.error = outcome.setupError; + outcome.finishedAt = Instant.now().toString(); + return outcome; + } + + long walkStart = System.currentTimeMillis(); + outcome.walkerState = walk(route.destination, route.destinationTolerance, walkTimeoutMs); + outcome.walkDurationMs = System.currentTimeMillis() - walkStart; + + WorldPoint end = safeLocation(); + outcome.endLocation = format(end); + outcome.distanceToDestination = distance(end, route.destination); + outcome.passed = WalkerState.ARRIVED.name().equals(outcome.walkerState) + && outcome.distanceToDestination <= route.destinationTolerance; + if (!outcome.passed) { + outcome.error = "Route webwalk failed for " + route.id + + ": state=" + outcome.walkerState + + ", location=" + outcome.endLocation + + ", distanceToDestination=" + outcome.distanceToDestination; + } + + outcome.finishedAt = Instant.now().toString(); + log.info("[F2PWebWalkerHarness] {} finished: state={}, end={}, distance={}, duration={}ms", + route.id, outcome.walkerState, outcome.endLocation, outcome.distanceToDestination, outcome.walkDurationMs); + return outcome; + } + + private String walk(WorldPoint destination, int tolerance, int timeoutMs) { + ExecutorService walkExecutor = Executors.newSingleThreadExecutor(new ThreadFactoryBuilder() + .setNameFormat("F2PWebWalkerLeg-%d") + .build()); + Future future = walkExecutor.submit(() -> Rs2Walker.walkWithState(destination, tolerance)); + + try { + return future.get(timeoutMs, TimeUnit.MILLISECONDS).name(); + } catch (TimeoutException e) { + future.cancel(true); + return "TIMEOUT"; + } catch (Exception e) { + log.warn("[F2PWebWalkerHarness] Webwalker leg failed for destination {}", destination, e); + return "ERROR:" + e.getClass().getSimpleName(); + } finally { + walkExecutor.shutdownNow(); + } + } + + private WorldPoint safeLocation() { + return lastLocation; + } + + private void applyShortestPathOverrides() { + String value = property(TEST_USE_TELEPORTATION_SPELLS_PROPERTY, USE_TELEPORTATION_SPELLS_PROPERTY, ""); + if (value.isBlank()) { + return; + } + + boolean useTeleportationSpells = Boolean.parseBoolean(value); + Map config = new HashMap<>(); + config.put("useTeleportationSpells", useTeleportationSpells); + + Map data = new HashMap<>(); + data.put("config", config); + + eventBus.post(new PluginMessage("shortestpath", "path", data)); + log.info("[F2PWebWalkerHarness] Applied shortest path override: useTeleportationSpells={}", useTeleportationSpells); + } + + private static boolean isHarnessTarget() { + return "true".equals(System.getProperty("microbot.test.mode")) + && System.getProperty(TEST_SCRIPT_PROPERTY, "").contains(SCRIPT_NAME); + } + + private static String property(String preferred, String legacy, String defaultValue) { + String value = System.getProperty(preferred); + if (value != null && !value.isBlank()) { + return value; + } + + value = System.getProperty(legacy); + if (value != null && !value.isBlank()) { + return value; + } + + return defaultValue; + } + + private static int intProperty(String preferred, String legacy, int defaultValue) { + String value = property(preferred, legacy, String.valueOf(defaultValue)); + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + private static int distance(WorldPoint from, WorldPoint to) { + if (from == null || to == null) { + return Integer.MAX_VALUE; + } + return from.distanceTo(to); + } + + private static String format(WorldPoint point) { + if (point == null) { + return null; + } + return point.getX() + "," + point.getY() + "," + point.getPlane(); + } + + private static void writeAndExit(WebWalkerTestResult result, int exitCode) { + TestResultWriter.write(result); + System.exit(exitCode); + } + + public static class WebWalkerTestResult extends TestResult { + public String routeFilter; + public boolean stopOnFailure; + public int walkTimeoutMs; + public List selectedRoutes = new ArrayList<>(); + public List routes = new ArrayList<>(); + + public WebWalkerTestResult(String script) { + super(script); + } + } + + public static class RouteOutcome { + public String id; + public String name; + public String start; + public String destination; + public int startTolerance; + public int destinationTolerance; + public String startedAt; + public String finishedAt; + public String initialLocation; + public int initialDistanceToStart; + public String setupState; + public boolean setupPassed; + public String setupError; + public long setupDurationMs; + public String routeStartLocation; + public int distanceToStartAfterSetup; + public String walkerState; + public boolean passed; + public String error; + public long walkDurationMs; + public String endLocation; + public int distanceToDestination; + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/webwalker/F2PWebWalkerRoute.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/webwalker/F2PWebWalkerRoute.java new file mode 100644 index 00000000000..fde91fa2b3b --- /dev/null +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/testing/webwalker/F2PWebWalkerRoute.java @@ -0,0 +1,105 @@ +package net.runelite.client.plugins.microbot.testing.webwalker; + +import net.runelite.api.coords.WorldPoint; + +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +final class F2PWebWalkerRoute { + static final List ROUTES = List.of( + route("F2P-01", "Lumbridge courtyard to Lumbridge bank", + point(3222, 3218, 0), point(3208, 3220, 2), 1, 1), + route("F2P-02", "Lumbridge bank to cow pen", + point(3208, 3220, 2), point(3253, 3266, 0), 1, 2), + route("F2P-03", "Cow pen to Draynor bank", + point(3253, 3266, 0), point(3092, 3245, 0), 2, 2), + route("F2P-04", "Draynor bank to Wizards' Tower bridge", + point(3092, 3245, 0), point(3109, 3168, 0), 2, 2), + route("F2P-05", "Wizards' Tower bridge to Port Sarim docks", + point(3109, 3168, 0), point(3029, 3217, 0), 2, 2), + route("F2P-06", "Port Sarim docks to Rimmington", + point(3029, 3217, 0), point(2957, 3214, 0), 2, 2), + route("F2P-07", "Rimmington to Falador west bank", + point(2957, 3214, 0), point(2946, 3368, 0), 2, 2), + route("F2P-08", "Falador west bank to Barbarian Village", + point(2946, 3368, 0), point(3082, 3420, 0), 2, 2), + route("F2P-09", "Barbarian Village to Edgeville bank", + point(3082, 3420, 0), point(3093, 3493, 0), 2, 2), + route("F2P-10", "Edgeville bank to Grand Exchange", + point(3093, 3493, 0), point(3164, 3486, 0), 2, 2), + route("F2P-11", "Grand Exchange to Varrock west bank", + point(3164, 3486, 0), point(3185, 3441, 0), 2, 2), + route("F2P-12", "Varrock west bank to Varrock east bank", + point(3185, 3441, 0), point(3253, 3420, 0), 2, 2), + route("F2P-13", "Varrock east bank to Lumbridge courtyard", + point(3253, 3420, 0), point(3222, 3218, 0), 2, 2), + route("F2P-14", "Draynor bank to Draynor Manor outside", + point(3092, 3245, 0), point(3109, 3341, 0), 2, 1), + route("F2P-15", "Draynor Manor outside to Draynor Manor interior", + point(3109, 3341, 0), point(3106, 3363, 0), 1, 1), + route("F2P-16", "Draynor Manor interior to Draynor bank", + point(3106, 3363, 0), point(3092, 3245, 0), 1, 2) + ); + + final String id; + final String name; + final WorldPoint start; + final WorldPoint destination; + final int startTolerance; + final int destinationTolerance; + + private F2PWebWalkerRoute( + String id, + String name, + WorldPoint start, + WorldPoint destination, + int startTolerance, + int destinationTolerance + ) { + this.id = id; + this.name = name; + this.start = start; + this.destination = destination; + this.startTolerance = startTolerance; + this.destinationTolerance = destinationTolerance; + } + + static List selected(String routeFilter) { + if (routeFilter == null || routeFilter.isBlank() || "all".equalsIgnoreCase(routeFilter)) { + return ROUTES; + } + + List requested = Arrays.stream(routeFilter.split(",")) + .map(id -> id.trim().toUpperCase(Locale.ROOT)) + .filter(id -> !id.isEmpty()) + .collect(Collectors.toList()); + + List routes = ROUTES.stream() + .filter(route -> requested.contains(route.id.toUpperCase(Locale.ROOT))) + .collect(Collectors.toList()); + + if (routes.size() != requested.size()) { + List known = ROUTES.stream().map(route -> route.id).collect(Collectors.toList()); + throw new IllegalArgumentException("Unknown webwalker route filter '" + routeFilter + "'. Known routes: " + known); + } + + return routes; + } + + private static F2PWebWalkerRoute route( + String id, + String name, + WorldPoint start, + WorldPoint destination, + int startTolerance, + int destinationTolerance + ) { + return new F2PWebWalkerRoute(id, name, start, destination, startTolerance, destinationTolerance); + } + + private static WorldPoint point(int x, int y, int plane) { + return new WorldPoint(x, y, plane); + } +} diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/keyboard/Rs2Keyboard.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/keyboard/Rs2Keyboard.java index bb2409caba4..fb6bbe016ab 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/keyboard/Rs2Keyboard.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/keyboard/Rs2Keyboard.java @@ -6,6 +6,7 @@ import java.awt.*; import java.awt.event.KeyEvent; +import java.awt.event.KeyListener; import static java.awt.event.KeyEvent.CHAR_UNDEFINED; @@ -26,29 +27,23 @@ private static Canvas getCanvas() } /** - * Executes a given action with the canvas temporarily made focusable if it wasn't already. - * This ensures key events are properly dispatched to the game client. - * - * @param action the code to run while the canvas is focusable + * Kept as a no-op wrapper so existing call sites still compile / read naturally. + * The previous implementation toggled {@code Canvas.setFocusable(true)} around + * dispatch; on many window managers that call nudges the OS to grant focus to the + * game window, stealing it from whatever app the user was actually typing in. + * Direct-listener dispatch (see {@link #dispatchKeyEvent}) makes the toggle + * unnecessary, so this wrapper just runs the action. */ private static void withFocusCanvas(Runnable action) { - Canvas canvas = getCanvas(); - boolean originalFocus = canvas.isFocusable(); - if (!originalFocus) canvas.setFocusable(true); - - try - { - action.run(); - } - finally - { - if (!originalFocus) canvas.setFocusable(false); - } + action.run(); } /** - * Dispatches a low-level KeyEvent to the canvas after a specified delay. + * Delivers a synthetic KeyEvent to the canvas's registered listeners directly, + * bypassing AWT's focus-aware dispatch pipeline. This is what eliminates the + * focus-steal that {@code Canvas.dispatchEvent} combined with a focusable flip + * used to cause. * * @param id the KeyEvent type (e.g. KEY_TYPED, KEY_PRESSED, etc.) * @param keyCode the key code from {@link KeyEvent} @@ -59,7 +54,22 @@ private static void dispatchKeyEvent(int id, int keyCode, char keyChar, int dela { Canvas canvas = getCanvas(); KeyEvent event = new KeyEvent(canvas, id, System.currentTimeMillis() + delay, 0, keyCode, keyChar); - canvas.dispatchEvent(event); + KeyListener[] listeners = canvas.getKeyListeners(); + for (KeyListener l : listeners) + { + switch (id) + { + case KeyEvent.KEY_TYPED: + l.keyTyped(event); + break; + case KeyEvent.KEY_PRESSED: + l.keyPressed(event); + break; + case KeyEvent.KEY_RELEASED: + l.keyReleased(event); + break; + } + } } /** diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Spells.java b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Spells.java index 145d6443fb3..3c92282f98d 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Spells.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/microbot/util/magic/Rs2Spells.java @@ -65,6 +65,7 @@ public enum Rs2Spells implements Spell { Runes.DEATH, 1, Runes.LAW, 1 ), Rs2Spellbook.MODERN), + LUMBRIDGE_HOME_TELEPORT(MagicAction.LUMBRIDGE_HOME_TELEPORT, Map.of(), Rs2Spellbook.MODERN), VARROCK_TELEPORT(MagicAction.VARROCK_TELEPORT, Map.of( Runes.FIRE, 1, Runes.AIR, 3, @@ -661,4 +662,4 @@ public List getElementalRunes() { public HashMap getRequiredRunes() { return new HashMap<>(requiredRunes); } -} \ No newline at end of file +} 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 9a2ecb36a13..84b62873e28 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 @@ -232,7 +232,14 @@ private static WalkerState walkWithStateInternal(WorldPoint target, int distance if (pathfinder != null && !pathfinder.isDone()) { return WalkerState.MOVING; } - setTarget(target); + boolean hasCurrentPath = pathfinder != null + && pathfinder.isDone() + && pathfinder.getTargets().contains(target); + if (!hasCurrentPath) { + setTarget(target); + } else { + currentTarget = target; + } ShortestPathPlugin.setReachedDistance(distance); stuckCount = 0; lastMovedTimeMs = System.currentTimeMillis(); @@ -243,6 +250,7 @@ private static WalkerState walkWithStateInternal(WorldPoint target, int distance } closeWorldMap(); + kickStartShortLocalWalk(target, distance); return processWalk(target, distance); } @@ -272,14 +280,18 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part try { if (!Microbot.isLoggedIn()) { setTarget(null); + return WalkerState.EXIT; + } + if (isWalkCancelled(target)) { + return WalkerState.EXIT; } Pathfinder pathfinder = ShortestPathPlugin.getPathfinder(); if (pathfinder == null) { - if (ShortestPathPlugin.getMarker() == null) { - setTarget(null); - } pathfinder = sleepUntilNotNull(ShortestPathPlugin::getPathfinder, 2_000); + if (isWalkCancelled(target)) { + return WalkerState.EXIT; + } if (pathfinder == null) { setTarget(null); return WalkerState.EXIT; @@ -288,6 +300,9 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part if (!pathfinder.isDone()) { boolean isDone = sleepUntilTrue(pathfinder::isDone, 100, 10_000); + if (isWalkCancelled(target)) { + return WalkerState.EXIT; + } if (!isDone) { setTarget(null); return WalkerState.EXIT; @@ -295,10 +310,10 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } if (ShortestPathPlugin.getMarker() == null) { - setTarget(null); - return WalkerState.EXIT; + restoreTargetMarker(target); } + final List rawPath = pathfinder.getPath(); final List path = pathfinder.getWalkablePath(); final WorldPoint dst; if (path == null || path.isEmpty()) { @@ -330,6 +345,9 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } checkIfStuck(); + if (isWalkCancelled(target)) { + return WalkerState.EXIT; + } if (isStuckTooLong()) { long sinceMoved = System.currentTimeMillis() - lastMovedTimeMs; long threshold = stallThresholdMs(); @@ -384,7 +402,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } // entering desert warning - if (Rs2Widget.clickWidget(565, 20)) { + if (Rs2Widget.isWidgetVisible(565, 20) && Rs2Widget.clickWidget(565, 20)) { sleepUntil(() -> { Widget checkBoxWidget = Rs2Widget.getWidget(565, 20); if (checkBoxWidget == null) return false; @@ -394,7 +412,7 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } // entering down ladder strong hold of security - if (Rs2Widget.clickWidget(579, 20)) { + if (Rs2Widget.isWidgetVisible(579, 20) && Rs2Widget.clickWidget(579, 20)) { sleepUntil(() -> { Widget checkBoxWidget = Rs2Widget.getWidget(579, 20); if (checkBoxWidget == null) return false; @@ -412,12 +430,31 @@ 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 = 8; - for (int i = indexOfStartPoint; i < path.size(); i++) { + WalkerState directShortWalk = tryDirectShortWalk(target, distance, path, inInstance); + if (directShortWalk != WalkerState.MOVING) { + return directShortWalk; + } + + if (rawPath != null && path != null + && handleNearbyRawPathSceneObjects(rawPath, HANDLER_RANGE)) { + doorOrTransportResult = true; + exitReason = "raw-path-scene-object-handled"; + } + + if (!doorOrTransportResult + && handleCurrentTileTransportTowardPath(rawPath, path, target)) { + doorOrTransportResult = true; + exitReason = "current-tile-transport-handled"; + } + + for (int i = indexOfStartPoint; !doorOrTransportResult && i < path.size(); i++) { WorldPoint currentWorldPoint = path.get(i); + if (isWalkCancelled(target)) { + return WalkerState.EXIT; + } if (ShortestPathPlugin.getMarker() == null) { - exitReason = "marker-null"; - break; + restoreTargetMarker(target); } if (!isNearPath()) { @@ -468,8 +505,8 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part } } - boolean tileReachable = Rs2Tile.isTileReachable(currentWorldPoint); - if (!tileReachable && !inInstance) { + boolean tileWalkable = inInstance || isKnownWalkableOrUnloaded(currentWorldPoint); + if (!tileWalkable) { continue; } nextWalkingDistance = Rs2Random.between(9, 12); @@ -496,30 +533,40 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part // (door, gate, diagonal offset). Each interpolated click covers // ~12 tiles, matching a human clicking the furthest visible tile. if (targetIdx < i) { - int backwardDistSq = euclideanSq(targetWp, playerLoc); - int interpTarget = MINIMAP_REACH_EUCLIDEAN - 1; - if (backwardDistSq < interpTarget * interpTarget) { - WorldPoint beyond = path.get(i); - int dxB = beyond.getX() - playerLoc.getX(); - int dyB = beyond.getY() - playerLoc.getY(); - double distB = Math.sqrt(dxB * dxB + dyB * dyB); - if (distB > 1) { - double scale = interpTarget / distB; - targetWp = new WorldPoint( - playerLoc.getX() + (int) Math.round(dxB * scale), - playerLoc.getY() + (int) Math.round(dyB * scale), - playerLoc.getPlane()); - targetIdx = Math.max(indexOfStartPoint, i - 1); - } - } + targetWp = interpolateClickableTarget( + path, + i, + playerLoc, + targetWp, + MINIMAP_REACH_EUCLIDEAN - 1, + wp -> inInstance || isKnownWalkableOrUnloaded(wp)); + targetIdx = Math.max(indexOfStartPoint, i - 1); + } else if (euclideanSq(targetWp, playerLoc) > MINIMAP_REACH_EUCLIDEAN * MINIMAP_REACH_EUCLIDEAN) { + targetWp = interpolateClickableTarget( + path, + i, + playerLoc, + targetWp, + MINIMAP_REACH_EUCLIDEAN - 1, + wp -> inInstance || isKnownWalkableOrUnloaded(wp)); } WorldPoint posBefore = playerLoc; - boolean clicked; - if (inInstance) { - clicked = Rs2Walker.walkMiniMap(targetWp); - } else { - clicked = Rs2Walker.walkMiniMap(getPointWithWallDistance(targetWp)); + WorldPoint clickTarget = inInstance ? targetWp : getPointWithWallDistance(targetWp); + if (!inInstance && !Rs2Tile.isTileReachable(clickTarget)) { + WorldPoint rawReachableTarget = findFurthestReachableRawPathPoint(rawPath, playerLoc, + MINIMAP_REACH_EUCLIDEAN - 1); + if (rawReachableTarget != null) { + targetWp = rawReachableTarget; + clickTarget = rawReachableTarget; + } + } + boolean clicked = Rs2Walker.walkMiniMap(clickTarget); + if (!clicked) { + clicked = walkMiniMapToward(clickTarget, playerLoc, MINIMAP_REACH_EUCLIDEAN - 1); + } + if (isWalkCancelled(target)) { + return WalkerState.EXIT; } if (clicked) { final WorldPoint b = targetWp; @@ -533,12 +580,38 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part final int progressCap = 16; final long clickedAt = System.currentTimeMillis(); sleepUntil(() -> { + if (isWalkCancelled(target)) return true; long elapsed = System.currentTimeMillis() - clickedAt; if (elapsed < 600) return false; WorldPoint now = Rs2Player.getWorldLocation(); if (b.distanceTo2D(now) <= proximityWake) return true; return before.distanceTo2D(now) >= progressCap; }, 2000); + WorldPoint afterClickWait = Rs2Player.getWorldLocation(); + if (afterClickWait != null && afterClickWait.equals(before) && !Rs2Player.isMoving() + && walkReachableMiniMapToward(b, before, MINIMAP_REACH_EUCLIDEAN - 1)) { + sleepUntil(() -> { + if (isWalkCancelled(target)) return true; + WorldPoint now = Rs2Player.getWorldLocation(); + return now != null && (b.distanceTo2D(now) <= proximityWake || !now.equals(before) || Rs2Player.isMoving()); + }, 2000); + } + if (isWalkCancelled(target)) { + return WalkerState.EXIT; + } + + if (!Rs2Player.isMoving()) { + if (handleNearbyRawPathSceneObjects(rawPath, HANDLER_RANGE)) { + doorOrTransportResult = true; + exitReason = "post-click-raw-path-scene-object-handled"; + break; + } + if (handleCurrentTileTransportTowardPath(rawPath, path, target)) { + doorOrTransportResult = true; + exitReason = "post-click-current-tile-transport-handled"; + break; + } + } } // Keep stuck-detection honest: observed movement resets the movement timer. // Without this, isStuckTooLong() fires after long successful walks because @@ -552,7 +625,10 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part // loop wait for the player to walk closer before re-evaluating. if (!clicked) { exitReason = "click-failed-off-minimap"; - sleepUntil(() -> !Rs2Player.isMoving(), 2000); + sleepUntil(() -> isWalkCancelled(target) || !Rs2Player.isMoving(), 2000); + if (isWalkCancelled(target)) { + return WalkerState.EXIT; + } break; } // Advance past intermediate tiles we've effectively walked over so the @@ -567,22 +643,31 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part // 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)) { + 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); if (Rs2Tile.isTileReachable(finalTile) && Rs2Player.getWorldLocation().distanceTo(finalTile) >= distance) { if (Rs2Walker.walkFastCanvas(finalTile)) { - sleepUntil(() -> Rs2Player.getWorldLocation().distanceTo(finalTile) < 2, 3000); + sleepUntil(() -> isWalkCancelled(target) || Rs2Player.getWorldLocation().distanceTo(finalTile) < 2, 3000); + if (isWalkCancelled(target)) { + return WalkerState.EXIT; + } } } } } int finalDist = Rs2Player.getWorldLocation().distanceTo(target); - if (finalDist < distance) { + if (finalDist <= distance) { setTarget(null); return WalkerState.ARRIVED; } else if (partialPath) { + if (isWalkCancelled(target)) { + 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)", @@ -599,12 +684,15 @@ private static WalkerState processWalk(WorldPoint target, int distance, int part 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(() -> isNearPath() || !Rs2Player.isMoving(), 2000); + sleepUntil(() -> isWalkCancelled(target) || isNearPath() || !Rs2Player.isMoving(), 2000); + if (isWalkCancelled(target)) { + return WalkerState.EXIT; + } } return processWalk(target, distance, partialRetries); } } catch (Exception ex) { - if (ex instanceof InterruptedException) { + if (ex instanceof InterruptedException || ex.getCause() instanceof InterruptedException) { log.info("Pathfinder was interrupted, exiting: 397"); setTarget(null); return WalkerState.EXIT; @@ -701,6 +789,21 @@ public static WorldPoint getPointWithWallDistance(WorldPoint target) { return target; } + private static boolean isKnownWalkableOrUnloaded(WorldPoint target) { + if (target == null) { + return false; + } + + LocalPoint localTarget = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), target); + return localTarget == null || Rs2Tile.isWalkable(localTarget); + } + + private static boolean isWalkCancelled(WorldPoint target) { + WorldPoint activeTarget = currentTarget; + return target == null || activeTarget == null || !target.equals(activeTarget) + || Thread.currentThread().isInterrupted(); + } + static boolean hasMinimapRelevantMovementFlag(LocalPoint point, int[][] flagMap) { int data = flagMap[point.getSceneX()][point.getSceneY()]; Set movementFlags = MovementFlag.getSetFlags(data); @@ -818,6 +921,95 @@ public static boolean walkMiniMap(WorldPoint worldPoint) { return walkMiniMap(worldPoint, 5); } + static boolean walkMiniMapToward(WorldPoint target, WorldPoint playerLoc, int maxEuclidean) { + if (target == null || playerLoc == null || target.getPlane() != playerLoc.getPlane()) { + return false; + } + + int dx = target.getX() - playerLoc.getX(); + int dy = target.getY() - playerLoc.getY(); + double distance = Math.sqrt(dx * dx + dy * dy); + if (distance <= 1) { + return false; + } + + if (walkReachableMiniMapToward(target, playerLoc, maxEuclidean)) { + return true; + } + + int cappedRadius = Math.max(2, maxEuclidean); + int[] radii = new int[] {cappedRadius, 10, 8, 6, 4}; + for (int radius : radii) { + if (radius >= distance) { + continue; + } + + double scale = radius / distance; + WorldPoint fallback = new WorldPoint( + playerLoc.getX() + (int) Math.round(dx * scale), + playerLoc.getY() + (int) Math.round(dy * scale), + playerLoc.getPlane()); + if (fallback.equals(playerLoc)) { + continue; + } + if (Rs2Walker.walkMiniMap(fallback)) { + log.info("[Walker] Minimap click target {} was outside clip; used fallback {}", target, fallback); + return true; + } + } + + return false; + } + + private static boolean walkReachableMiniMapToward(WorldPoint target, WorldPoint playerLoc, int maxEuclidean) { + int currentDistance = euclideanSq(playerLoc, target); + return Rs2Tile.getReachableTilesFromTile(playerLoc, Math.max(2, maxEuclidean)).keySet().stream() + .filter(tile -> tile != null + && tile.getPlane() == playerLoc.getPlane() + && !tile.equals(playerLoc) + && euclideanSq(playerLoc, tile) <= maxEuclidean * maxEuclidean + && euclideanSq(tile, target) < currentDistance) + .sorted(Comparator + .comparingInt((WorldPoint tile) -> euclideanSq(tile, target)) + .thenComparing(Comparator.comparingInt((WorldPoint tile) -> euclideanSq(playerLoc, tile)).reversed())) + .filter(Rs2Walker::walkMiniMap) + .findFirst() + .map(tile -> { + log.info("[Walker] Minimap click target {} was outside clip; used reachable fallback {}", target, tile); + return true; + }) + .orElse(false); + } + + private static WorldPoint findFurthestReachableRawPathPoint(List rawPath, + WorldPoint playerLoc, + int maxEuclidean) { + if (rawPath == null || rawPath.isEmpty() || playerLoc == null) { + return null; + } + int closestRawIndex = getClosestTileIndex(rawPath); + if (closestRawIndex < 0) { + return null; + } + + int maxSq = maxEuclidean * maxEuclidean; + Set reachable = Rs2Tile.getReachableTilesFromTile(playerLoc, Math.max(2, maxEuclidean)).keySet(); + WorldPoint best = null; + for (int rawIndex = closestRawIndex; rawIndex < rawPath.size(); rawIndex++) { + WorldPoint candidate = rawPath.get(rawIndex); + if (candidate == null || candidate.getPlane() != playerLoc.getPlane()) { + break; + } + if (euclideanSq(candidate, playerLoc) > maxSq) { + break; + } + if (reachable.contains(candidate)) { + best = candidate; + } + } + return best; + } + /** * Used in instances like vorkath, jad, nmz * @@ -1335,10 +1527,221 @@ private static boolean handleRockfall(List path, int index) { return false; } + private static WalkerState tryDirectShortWalk(WorldPoint target, + int distance, + List path, + boolean inInstance) { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (target == null || playerLoc == null || path == null || path.isEmpty()) { + return WalkerState.MOVING; + } + + int initialDist = playerLoc.distanceTo(target); + if (initialDist <= distance) { + setTarget(null); + return WalkerState.ARRIVED; + } + + final int directClickMaxDistance = 13; + if (playerLoc.getPlane() != target.getPlane() || initialDist > directClickMaxDistance) { + return WalkerState.MOVING; + } + + WorldPoint end = path.get(path.size() - 1); + if (end == null || end.getPlane() != target.getPlane() || end.distanceTo(target) > distance) { + return WalkerState.MOVING; + } + + if (!inInstance && !Rs2Tile.isWalkable(end)) { + return WalkerState.MOVING; + } + if (!inInstance && !Rs2Tile.isTileReachable(end)) { + return WalkerState.MOVING; + } + + boolean clicked = walkMiniMap(end); + if (!clicked) { + clicked = walkMiniMapToward(end, playerLoc, directClickMaxDistance - 1); + } + if (!clicked) { + clicked = walkFastCanvas(end); + } + if (!clicked) { + return WalkerState.MOVING; + } + + final WorldPoint before = playerLoc; + boolean moved = sleepUntil(() -> { + WorldPoint now = Rs2Player.getWorldLocation(); + return now != null && (now.distanceTo(target) <= distance || !now.equals(before) || Rs2Player.isMoving()); + }, 800); + + if (!moved) { + clicked = walkFastCanvas(end); + if (!clicked) { + return WalkerState.MOVING; + } + sleepUntil(() -> { + WorldPoint now = Rs2Player.getWorldLocation(); + return now != null && (now.distanceTo(target) <= distance || !now.equals(before) || Rs2Player.isMoving()); + }, 800); + } + + WorldPoint afterClick = Rs2Player.getWorldLocation(); + if (afterClick != null && afterClick.distanceTo(target) <= distance) { + setTarget(null); + return WalkerState.ARRIVED; + } + + sleepUntil(() -> { + WorldPoint now = Rs2Player.getWorldLocation(); + return now != null && (now.distanceTo(target) <= distance || !Rs2Player.isMoving()); + }, 4000); + + WorldPoint afterWalk = Rs2Player.getWorldLocation(); + if (afterWalk != null && afterWalk.distanceTo(target) <= distance) { + setTarget(null); + return WalkerState.ARRIVED; + } + + return WalkerState.MOVING; + } + + private static void kickStartShortLocalWalk(WorldPoint target, int distance) { + try { + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (target == null || playerLoc == null || playerLoc.getPlane() != target.getPlane()) { + return; + } + + int initialDist = playerLoc.distanceTo(target); + if (initialDist <= distance || initialDist > 13) { + return; + } + + LocalPoint localTarget = LocalPoint.fromWorld(Microbot.getClient().getTopLevelWorldView(), target); + if (localTarget == null || !Rs2Tile.isWalkable(localTarget)) { + return; + } + + boolean clicked = walkMiniMap(target); + if (!clicked) { + walkMiniMapToward(target, playerLoc, 12); + } + } catch (Exception ex) { + log.debug("[Walker] short local kick-start failed: {}", ex.getMessage()); + } + } + + private static boolean handleNearbyRawPathSceneObjects(List rawPath, int handlerRange) { + if (rawPath == null || rawPath.size() < 2) { + return false; + } + + if (Rs2Player.isMoving()) { + return false; + } + + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (playerLoc == null) { + return false; + } + + int rawStart = getClosestTileIndex(rawPath); + if (rawStart < 0) { + return false; + } + + int start = Math.max(0, rawStart - 1); + int endExclusive = Math.min(rawPath.size() - 1, rawStart + 12); + for (int i = start; i < endExclusive; i++) { + WorldPoint currentWorldPoint = rawPath.get(i); + if (currentWorldPoint == null + || currentWorldPoint.getPlane() != playerLoc.getPlane() + || currentWorldPoint.distanceTo2D(playerLoc) > handlerRange) { + continue; + } + + if (hasExplicitTransportStep(rawPath, i) && handleTransports(rawPath, i)) { + 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 (handleRockfall(rawPath, i)) { + log.info("[Walker] Raw path rockfall handler resolved obstacle near {}", playerLoc); + return true; + } + } + + return false; + } + + private static boolean handleCurrentTileTransportTowardPath(List rawPath, List path, WorldPoint target) { + if (Rs2Player.isMoving()) { + return false; + } + + WorldPoint playerLoc = Rs2Player.getWorldLocation(); + if (playerLoc == null) { + return false; + } + + Set transports = ShortestPathPlugin.getTransports().get(playerLoc); + if (transports == null || transports.isEmpty()) { + return false; + } + + Set pathPoints = new HashSet<>(); + addForwardPathPoints(pathPoints, rawPath, playerLoc); + addForwardPathPoints(pathPoints, path, playerLoc); + + List candidates = transports.stream() + .filter(t -> t.getDestination() != null) + .filter(t -> target == null + || playerLoc.getPlane() != target.getPlane() + || t.getDestination().getPlane() == target.getPlane()) + .filter(t -> pathPoints.contains(t.getDestination()) + || (target != null && t.getDestination().distanceTo(target) < playerLoc.distanceTo(target))) + .sorted(Comparator + .comparingInt((Transport t) -> pathPoints.contains(t.getDestination()) ? 0 : 1) + .thenComparingInt(t -> target == null ? 0 : t.getDestination().distanceTo(target))) + .collect(Collectors.toList()); + + for (Transport transport : candidates) { + if (handleTransports(Arrays.asList(playerLoc, transport.getDestination()), 0)) { + log.info("[Walker] Current-tile transport handler resolved obstacle near {}", playerLoc); + return true; + } + } + + return false; + } + + private static void addForwardPathPoints(Set pathPoints, List path, WorldPoint playerLoc) { + if (path == null || path.isEmpty() || playerLoc == null) { + return; + } + + int closestIndex = IntStream.range(0, path.size()) + .boxed() + .min(Comparator.comparingInt(i -> playerLoc.distanceTo(path.get(i)))) + .orElse(0); + for (int i = closestIndex; i < path.size(); i++) { + pathPoints.add(path.get(i)); + } + } + // Session-local set of door tiles the walker detected as quest/stat-locked after a // failed interact. Cleared when the client restarts. Prevents infinite retry loops // through the same restricted door when the restriction isn't in restrictions.tsv. static final Set sessionBlacklistedDoors = ConcurrentHashMap.newKeySet(); + private static final Map recentlyOpenedStationaryDoors = new ConcurrentHashMap<>(); + private static final long STATIONARY_DOOR_SUPPRESS_MS = 10_000; static boolean hasQuestLockKeywords(String text) { if (text == null || text.isEmpty()) return false; @@ -1411,6 +1814,43 @@ static int findFurthestClickableIndex(List path, int startIdx, World return bestIdx; } + static WorldPoint interpolateClickableTarget(List path, + int forwardIdx, + WorldPoint playerLoc, + WorldPoint fallbackWp, + int targetEuclidean, + java.util.function.Predicate isUsableClickTarget) { + if (path == null || playerLoc == null || fallbackWp == null + || forwardIdx < 0 || forwardIdx >= path.size()) { + return fallbackWp; + } + + int fallbackDistSq = euclideanSq(fallbackWp, playerLoc); + if (fallbackDistSq == targetEuclidean * targetEuclidean) { + return fallbackWp; + } + + WorldPoint beyond = path.get(forwardIdx); + int dxB = beyond.getX() - playerLoc.getX(); + int dyB = beyond.getY() - playerLoc.getY(); + double distB = Math.sqrt(dxB * dxB + dyB * dyB); + if (distB <= 1) { + return fallbackWp; + } + + double scale = targetEuclidean / distB; + WorldPoint interpolated = new WorldPoint( + playerLoc.getX() + (int) Math.round(dxB * scale), + playerLoc.getY() + (int) Math.round(dyB * scale), + playerLoc.getPlane()); + + if (isUsableClickTarget == null || isUsableClickTarget.test(interpolated)) { + return interpolated; + } + + return fallbackWp; + } + private static int euclideanSq(WorldPoint a, WorldPoint b) { int dx = a.getX() - b.getX(); int dy = a.getY() - b.getY(); @@ -1418,6 +1858,10 @@ private static int euclideanSq(WorldPoint a, WorldPoint b) { } private static boolean handleDoors(List path, int index) { + return handleDoors(path, index, false); + } + + private static boolean handleDoors(List path, int index, boolean allowSegmentProbe) { if (ShortestPathPlugin.getPathfinder() == null || index >= path.size() - 1) return false; // Skip any door whose tile was blacklisted after a prior quest-lock detection — @@ -1462,6 +1906,14 @@ private static boolean handleDoors(List path, int index) { return false; } + if (hasExplicitTransportStep(path, index)) { + return false; + } + + if (recentlyOpenedStationaryDoorOnSegment(fromWp, toWp)) { + return false; + } + boolean diagonal = Math.abs(fromWp.getX() - toWp.getX()) > 0 && Math.abs(fromWp.getY() - toWp.getY()) > 0; @@ -1482,6 +1934,9 @@ private static boolean handleDoors(List path, int index) { } for (WorldPoint probe : probes) { + if (recentlyOpenedStationaryDoorOnSegment(fromWp, toWp)) { + return false; + } boolean adjacentToPath = probe.distanceTo(fromWp) <= 1 || probe.distanceTo(toWp) <= 1; WorldPoint playerLoc = Rs2Player.getWorldLocation(); if (!adjacentToPath || playerLoc == null || !Objects.equals(probe.getPlane(), playerLoc.getPlane())) continue; @@ -1497,7 +1952,7 @@ private static boolean handleDoors(List path, int index) { } } - TileObject nearbyDoor = findDoorNearSegment(fromWp, toWp, doorActions); + TileObject nearbyDoor = allowSegmentProbe ? findDoorNearSegment(fromWp, toWp, doorActions) : null; if (nearbyDoor != null && tryHandleDoorObject(nearbyDoor, nearbyDoor.getWorldLocation(), fromWp, toWp, doorActions, true)) { return true; } @@ -1510,6 +1965,9 @@ private static TileObject findDoorNearSegment(WorldPoint fromWp, WorldPoint toWp if (playerLoc == null || fromWp == null || toWp == null || fromWp.getPlane() != toWp.getPlane()) { return null; } + if (recentlyOpenedStationaryDoorOnSegment(fromWp, toWp)) { + return null; + } final int searchDistance = 10; return Rs2GameObject.getAll(o -> { @@ -1519,9 +1977,10 @@ private static TileObject findDoorNearSegment(WorldPoint fromWp, WorldPoint toWp if (loc.distanceTo2D(playerLoc) > searchDistance) return false; if (sessionBlacklistedDoors.contains(loc)) return false; if (!(o instanceof WallObject) && !(o instanceof GameObject)) return false; + if (!isDoorOnSegment(o, fromWp, toWp)) return false; ObjectComposition comp = Rs2GameObject.convertToObjectComposition(o); if (!isDoorComposition(comp, doorActions)) return false; - return isDoorOnSegment(o, fromWp, toWp); + return true; }, playerLoc, searchDistance).stream() .min(Comparator.comparingInt(o -> o.getWorldLocation().distanceTo2D(playerLoc))) .orElse(null); @@ -1556,25 +2015,99 @@ private static boolean tryHandleDoorObject(TileObject object, WorldPoint probe, if (!found) return false; - if (!handleDoorException(object, action)) { - WorldPoint posBefore = Rs2Player.getWorldLocation(); - Rs2GameObject.interact(object, action); - Rs2Player.waitForWalking(); - WorldPoint posAfter = Rs2Player.getWorldLocation(); - boolean moved = posBefore != null && posAfter != null && !posBefore.equals(posAfter); - if (!moved && 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 (handleDoorException(object, action)) { + return true; + } + + WorldPoint posBefore = Rs2Player.getWorldLocation(); + Rs2GameObject.interact(object, action); + waitForDoorInteractionProgress(fromWp, toWp); + WorldPoint posAfter = Rs2Player.getWorldLocation(); + boolean moved = posBefore != null && posAfter != null && !posBefore.equals(posAfter); + if (moved) { + return true; + } + if (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(); + return true; } - return true; + + if (doorStillHasAction(probe, doorActions, action)) { + return true; + } + + markStationaryDoorOpened(probe); + return false; + } + + private static boolean doorStillHasAction(WorldPoint probe, List doorActions, String action) { + if (probe == null || action == null) { + return false; + } + + 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) { + return false; + } + ObjectComposition composition = Rs2GameObject.convertToObjectComposition(object); + String currentAction = getDoorAction(composition, doorActions); + return currentAction != null && currentAction.equalsIgnoreCase(action); + } + + private static void markStationaryDoorOpened(WorldPoint doorTile) { + if (doorTile != null) { + recentlyOpenedStationaryDoors.put(doorTile, System.currentTimeMillis()); + } + } + + private static boolean recentlyOpenedStationaryDoorOnSegment(WorldPoint fromWp, WorldPoint toWp) { + if (fromWp == null || toWp == null) { + return false; + } + 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)); + } + + private static boolean hasExplicitTransportStep(List path, int index) { + if (path == null || index < 0 || index >= path.size() - 1) { + return false; + } + Set transports = ShortestPathPlugin.getTransports().get(path.get(index)); + if (transports == null || transports.isEmpty()) { + return false; + } + WorldPoint destination = path.get(index + 1); + return transports.stream().anyMatch(t -> Objects.equals(t.getDestination(), destination)); + } + + 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); } private static boolean isDoorComposition(ObjectComposition comp, List doorActions) { @@ -1601,34 +2134,66 @@ private static String getDoorAction(ObjectComposition comp, List doorAct private static boolean isDoorOnSegment(TileObject object, WorldPoint fromWp, WorldPoint toWp) { if (object == null || object.getWorldLocation() == null) return false; if (object instanceof WallObject) { - return wallDoorTouchesSegment((WallObject) object, fromWp, toWp) - || isPointNearSegment(object.getWorldLocation(), fromWp, toWp, 1); + return wallDoorTouchesSegment((WallObject) object, fromWp, toWp); } return isPointNearSegment(object.getWorldLocation(), fromWp, toWp, 1); } - private static boolean wallDoorTouchesSegment(WallObject wall, WorldPoint fromWp, WorldPoint toWp) { + static boolean wallDoorTouchesSegment(WallObject wall, WorldPoint fromWp, WorldPoint toWp) { if (wall == null || wall.getWorldLocation() == null || fromWp == null || toWp == null) return false; if (wall.getWorldLocation().getPlane() != fromWp.getPlane() || fromWp.getPlane() != toWp.getPlane()) return false; - int orientation = wall.getOrientationA(); + WorldPoint doorTile = wall.getWorldLocation(); + WorldPoint blockedNeighbor = getWallDoorNeighborPoint(wall.getOrientationA(), doorTile); + if (blockedNeighbor == null) return false; + int x = fromWp.getX(); int y = fromWp.getY(); int steps = 0; + WorldPoint previous = new WorldPoint(x, y, fromWp.getPlane()); while (steps++ <= 64) { - WorldPoint point = new WorldPoint(x, y, fromWp.getPlane()); - if (searchNeighborPoint(orientation, wall.getWorldLocation(), point)) { - return true; - } if (x == toWp.getX() && y == toWp.getY()) { return false; } x += Integer.signum(toWp.getX() - x); y += Integer.signum(toWp.getY() - y); + WorldPoint next = new WorldPoint(x, y, fromWp.getPlane()); + if (isDoorEdgeTransition(previous, next, doorTile, blockedNeighbor)) { + return true; + } + previous = next; } return false; } + private static WorldPoint getWallDoorNeighborPoint(int orientation, WorldPoint point) { + switch (orientation) { + case 1: // west + return point.dx(-1); + case 2: // north + return point.dy(1); + case 4: // east + return point.dx(1); + case 8: // south + return point.dy(-1); + case 16: // northwest + return point.dx(-1).dy(1); + case 32: // northeast + return point.dx(1).dy(1); + case 64: // southeast + return point.dx(1).dy(-1); + case 128: // southwest + return point.dx(-1).dy(-1); + default: + return null; + } + } + + private static boolean isDoorEdgeTransition(WorldPoint a, WorldPoint b, WorldPoint doorTile, WorldPoint blockedNeighbor) { + return (a.equals(doorTile) && b.equals(blockedNeighbor)) + || (a.equals(blockedNeighbor) && b.equals(doorTile)); + } + private static boolean isPointNearSegment(WorldPoint point, WorldPoint fromWp, WorldPoint toWp, int distance) { if (point == null || fromWp == null || toWp == null || point.getPlane() != fromWp.getPlane() || fromWp.getPlane() != toWp.getPlane()) { return false; @@ -1876,6 +2441,23 @@ public static void setTarget(WorldPoint target) { } } + private static void restoreTargetMarker(WorldPoint target) { + if (target == null || ShortestPathPlugin.getMarker() != null) { + return; + } + + try { + 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()); + log.info("[Walker] Restored missing path target marker at {}", target); + } catch (Exception ex) { + log.debug("[Walker] Failed to restore target marker at {}", target, ex); + } + } + /** * @param start * @param end @@ -1952,6 +2534,10 @@ public static Tile getTile(WorldPoint point) { * @return */ private static boolean handleTransports(List path, int indexOfStartPoint) { + if (path != null && indexOfStartPoint >= 0 && indexOfStartPoint < path.size() - 1 + && recentlyOpenedStationaryDoorOnSegment(path.get(indexOfStartPoint), path.get(indexOfStartPoint + 1))) { + return false; + } Set transports = ShortestPathPlugin.getTransports().get(path.get(indexOfStartPoint)); if (transports == null || transports.isEmpty()) { return false; @@ -2161,8 +2747,12 @@ private static boolean handleTransports(List path, int indexOfStartP if (transport.getType() == TransportType.TELEPORTATION_SPELL) { if (handleTeleportSpell(transport)) { - sleepUntil(() -> !Rs2Player.isAnimating()); - sleepUntilTrue(() -> Rs2Player.getWorldLocation().distanceTo(transport.getDestination()) < OFFSET); + if (isLumbridgeHomeTeleport(transport)) { + sleepUntilTrue(() -> Rs2Player.getWorldLocation().distanceTo(transport.getDestination()) < OFFSET, 600, 35000); + } else { + sleepUntil(() -> !Rs2Player.isAnimating()); + sleepUntilTrue(() -> Rs2Player.getWorldLocation().distanceTo(transport.getDestination()) < OFFSET); + } Rs2Tab.switchTo(InterfaceTab.INVENTORY); break; } @@ -2180,6 +2770,7 @@ private static boolean handleTransports(List path, int indexOfStartP final int transportObjectId = transport.getObjectId(); final String transportAction = transport.getAction(); + final List transportActions = getTransportActionOptions(transportAction); // Climb-down transports have a closed-variant (trapdoor/manhole/grate/hatch) // that shares the same tile but a different object ID. Infer the closed // variant from ObjectComposition (any nearby object with an "Open" action @@ -2193,7 +2784,9 @@ private static boolean handleTransports(List path, int indexOfStartP Integer legacyClosed = OPEN_TO_CLOSED_MAPPINGS.get(transportObjectId); return legacyClosed != null && o.getId() == legacyClosed; }, transport.getOrigin(), 10).stream() - .sorted(Comparator.comparingInt(o -> o.getWorldLocation().distanceTo(transport.getOrigin()))) + .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) { @@ -2236,8 +2829,7 @@ private static boolean handleTransports(List path, int indexOfStartP ObjectComposition comp = Rs2GameObject.convertToObjectComposition(object); if (comp != null && comp.getActions() != null) { String[] actions = comp.getActions(); - boolean hasTransportAction = Arrays.stream(actions).filter(Objects::nonNull) - .anyMatch(a -> a.equalsIgnoreCase(transportAction)); + boolean hasTransportAction = resolveTransportObjectAction(actions, transportActions).isPresent(); boolean hasOpen = Arrays.stream(actions).filter(Objects::nonNull) .anyMatch(a -> a.equalsIgnoreCase("Open")); if (!hasTransportAction && hasOpen) { @@ -2250,8 +2842,7 @@ private static boolean handleTransports(List path, int indexOfStartP if (o.getId() == closedId) return false; ObjectComposition c = Rs2GameObject.convertToObjectComposition(o); if (c == null || c.getActions() == null) return false; - return Arrays.stream(c.getActions()).filter(Objects::nonNull) - .anyMatch(a -> a.equalsIgnoreCase(transportAction)); + return resolveTransportObjectAction(c.getActions(), transportActions).isPresent(); }, transport.getOrigin(), 3).stream() .min(Comparator.comparingInt(o -> o.getWorldLocation().distanceTo(transport.getOrigin()))) .orElse(null); @@ -2259,12 +2850,19 @@ private static boolean handleTransports(List path, int indexOfStartP } } + String interactionAction = resolveTransportObjectAction(object, transportActions) + .orElse(transportAction); + if (!Objects.equals(interactionAction, transportAction)) { + log.debug("[Walker] Using object action '{}' for transport action '{}' at {} (id={})", + interactionAction, transportAction, object.getWorldLocation(), object.getId()); + } prepareTransportObjectForInteraction(object); - if (!handleObject(transport, object)) { + if (!handleObject(transport, object, interactionAction)) { return false; } sleepUntil(() -> !Rs2Player.isAnimating()); - return sleepUntilTrue(() -> Rs2Player.getWorldLocation().distanceTo(transport.getDestination()) < OFFSET); + int destinationTolerance = isAdjacentSamePlaneTransport(transport) ? 0 : OFFSET; + return sleepUntil(() -> Rs2Player.getWorldLocation().distanceTo(transport.getDestination()) <= destinationTolerance, 5000); } } } @@ -2286,6 +2884,46 @@ private static boolean handlePohTransport(Transport transport) { return ((PohTransport)transport).execute(); } + private static List getTransportActionOptions(String action) { + if (action == null || action.isBlank()) { + return Collections.emptyList(); + } + + List actions = new ArrayList<>(); + actions.add(action); + if ("Bottom-floor".equalsIgnoreCase(action)) { + actions.add("Climb-down"); + actions.add("Climb down"); + } else if ("Top-floor".equalsIgnoreCase(action)) { + actions.add("Climb-up"); + actions.add("Climb up"); + } + return actions; + } + + private static Optional resolveTransportObjectAction(TileObject object, List actionOptions) { + ObjectComposition comp = Rs2GameObject.convertToObjectComposition(object); + if (comp == null || comp.getActions() == null) { + return Optional.empty(); + } + return resolveTransportObjectAction(comp.getActions(), actionOptions); + } + + private static Optional resolveTransportObjectAction(String[] objectActions, List actionOptions) { + if (objectActions == null || actionOptions == null || actionOptions.isEmpty()) { + return Optional.empty(); + } + + for (String desired : actionOptions) { + for (String actual : objectActions) { + if (actual != null && desired.equalsIgnoreCase(Rs2UiHelper.stripColTags(actual))) { + return Optional.of(actual); + } + } + } + return Optional.empty(); + } + private static void prepareTransportObjectForInteraction(TileObject tileObject) { if (tileObject == null || tileObject.getLocalLocation() == null) { return; @@ -2297,7 +2935,12 @@ private static void prepareTransportObjectForInteraction(TileObject tileObject) } private static boolean handleObject(Transport transport, TileObject tileObject) { - Rs2GameObject.interact(tileObject, transport.getAction()); + return handleObject(transport, tileObject, transport.getAction()); + } + + private static boolean handleObject(Transport transport, TileObject tileObject, String action) { + WorldPoint before = Rs2Player.getWorldLocation(); + Rs2GameObject.interact(tileObject, action); if (handleObjectExceptions(transport, tileObject)) return true; if (transport.getDestination().getPlane() == Rs2Player.getWorldLocation().getPlane()) { if (transport.getType() == TransportType.AGILITY_SHORTCUT) { @@ -2315,6 +2958,24 @@ private static boolean handleObject(Transport transport, TileObject tileObject) } else { Rs2Player.waitForWalking(); Rs2Dialogue.clickOption("Yes please"); //shillo village cart + if (isAdjacentSamePlaneTransport(transport)) { + sleepUntil(() -> { + WorldPoint now = Rs2Player.getWorldLocation(); + return now != null && (now.equals(transport.getDestination()) + || !now.equals(before) + || !Rs2Player.isMoving()); + }, 2000); + WorldPoint afterOpen = Rs2Player.getWorldLocation(); + if (afterOpen != null && !afterOpen.equals(transport.getDestination())) { + boolean clicked = walkMiniMap(transport.getDestination()); + if (!clicked) { + clicked = walkFastCanvas(transport.getDestination()); + } + if (clicked) { + sleepUntil(() -> Rs2Player.getWorldLocation().equals(transport.getDestination()), 3000); + } + } + } } return true; } else { @@ -2336,6 +2997,14 @@ private static boolean handleObject(Transport transport, TileObject tileObject) } } + private static boolean isAdjacentSamePlaneTransport(Transport transport) { + return transport != null + && transport.getOrigin() != null + && transport.getDestination() != null + && transport.getOrigin().getPlane() == transport.getDestination().getPlane() + && transport.getOrigin().distanceTo(transport.getDestination()) <= 1; + } + private static boolean handleObjectExceptions(Transport transport, TileObject tileObject) { for (Map.Entry entry : OPEN_TO_CLOSED_MAPPINGS.entrySet()) { final int closedTrapdoorId = entry.getKey(); @@ -2521,11 +3190,19 @@ private static boolean handleTeleportSpell(Transport transport) { MagicAction magicSpell = Arrays.stream(MagicAction.values()).filter(x -> x.getName().toLowerCase().contains(spellName)).findFirst().orElse(null); if (magicSpell != null) { + if (magicSpell == MagicAction.LUMBRIDGE_HOME_TELEPORT) { + return Rs2Magic.quickCast(magicSpell); + } return Rs2Magic.cast(magicSpell, option, identifier); } return false; } + private static boolean isLumbridgeHomeTeleport(Transport transport) { + return transport.getDisplayInfo() != null + && transport.getDisplayInfo().toLowerCase().startsWith("lumbridge home teleport"); + } + private static boolean handleTeleportItem(Transport transport) { if (Rs2Pvp.isInWilderness() && (Rs2Pvp.getWildernessLevelFrom(Rs2Player.getWorldLocation()) > (transport.getMaxWildernessLevel() + 1))) return false; @@ -2844,7 +3521,7 @@ public static int getDistanceBetween(WorldPoint startpoint, WorldPoint endpoint) private static boolean handleSeasonalTransport(Transport transport) { String displayInfo = transport.getDisplayInfo(); - log.info("[MoA] entry: displayInfo='{}'", displayInfo); + log.debug("[MoA] entry: displayInfo='{}'", displayInfo); if (displayInfo == null) return false; if (!displayInfo.toLowerCase().contains("map of alacrity")) { @@ -2943,11 +3620,17 @@ private static boolean handleSeasonalTransport(Transport transport) { }, 3000); if (destMatch == null) { - log.warn("[MoA] destination '{}' never appeared after clicking region '{}' — name mismatch or locked; blacklisting", + // 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); - blacklistedMoaDestinations.add(packedDest); return false; } @@ -2961,7 +3644,7 @@ private static boolean handleSeasonalTransport(Transport transport) { // 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.info("[MoA] selecting destination '{}' (text='{}')", shortName, destText); + 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); @@ -2970,6 +3653,12 @@ private static boolean handleSeasonalTransport(Transport transport) { 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; } @@ -2993,30 +3682,27 @@ private static Widget findMoaWidget(Widget root, String shortName) { if (normalised.isEmpty()) return null; String[] tokens = normalised.split(" "); return Microbot.getClientThread().runOnClientThreadOptional(() -> { - for (Widget w : collectMoaChildren(root)) { - String hay = normaliseMoaText(w.getText()); - if (hay.isEmpty()) continue; - boolean all = true; - for (String t : tokens) { - if (t.isEmpty()) continue; - if (!hay.contains(t)) { all = false; break; } + 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; } - if (all) return w; } return null; }).orElse(null); } - private static java.util.List collectMoaChildren(Widget root) { - java.util.List out = new java.util.ArrayList<>(); - Widget[][] groups = { root.getDynamicChildren(), root.getNestedChildren(), root.getStaticChildren() }; - for (Widget[] g : groups) { - if (g == null) continue; - for (Widget w : g) if (w != null) out.add(w); - } - return out; - } - private static String normaliseMoaText(String s) { if (s == null) return ""; s = MOA_MARKUP_PATTERN.matcher(s).replaceAll(" "); @@ -3041,12 +3727,17 @@ private static Character computeMoaHotkeyByIndex(Widget root, Widget destMatch) if (root == null) return null; return Microbot.getClientThread().runOnClientThreadOptional(() -> { int idx = 0; - for (Widget sibling : collectMoaChildren(root)) { - String t = sibling.getText(); - if (t == null || t.isEmpty()) continue; - if (t.contains(MOA_LOCKED_MARKUP)) continue; - if (sibling == destMatch) return indexToHotkey(idx); - idx++; + 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++; + } } return null; }).orElse(null); @@ -3091,96 +3782,6 @@ private static void dumpMapOfAlacrityWidget(Widget listRoot) { }); } - // TEMP: iterate every MoA seasonal transport, attempt it, log landing vs expected. - // Run from a dedicated worker thread (blocks). Requires Map of Alacrity in inventory; - // locked regions/destinations are reported and skipped via the existing handler's guards. - public static void runMoaAudit() { - try { - while (!Microbot.isLoggedIn()) { - if (Thread.currentThread().isInterrupted()) return; - sleep(1000); - } - if (Rs2Inventory.get(MAP_OF_ALACRITY_ITEM_ID) == null) { - log.warn("[MoA-AUDIT] Map of Alacrity not in inventory — aborting"); - return; - } - - HashMap> all = Transport.loadAllFromResources(); - List moa = new ArrayList<>(); - for (Set set : all.values()) { - for (Transport t : set) { - if (t.getType() == TransportType.SEASONAL_TRANSPORT - && t.getDisplayInfo() != null - && t.getDisplayInfo().toLowerCase().contains("map of alacrity")) { - moa.add(t); - } - } - } - moa.sort(Comparator.comparing(Transport::getDisplayInfo)); - log.info("[MoA-AUDIT] {} MoA transports queued", moa.size()); - blacklistedMoaDestinations.clear(); - lockedMoaRegions.clear(); - - int landed = 0, skipped = 0; - for (int i = 0; i < moa.size(); i++) { - if (Thread.currentThread().isInterrupted()) break; - if (!Microbot.isLoggedIn()) { log.warn("[MoA-AUDIT] logged out — stopping"); break; } - - Transport t = moa.get(i); - String disp = t.getDisplayInfo(); - WorldPoint expected = t.getDestination(); - WorldPoint before = Rs2Player.getWorldLocation(); - if (before == null) { sleep(500); continue; } - - log.info("[MoA-AUDIT] {}/{}: {} (expected {},{},{})", - i + 1, moa.size(), disp, - expected.getX(), expected.getY(), expected.getPlane()); - - if (!handleSeasonalTransport(t)) { - log.info("[MoA-AUDIT] handler returned false"); - closeMoaWidgetIfOpen(); - skipped++; - sleep(600); - continue; - } - - boolean moved = sleepUntil(() -> { - WorldPoint now = Rs2Player.getWorldLocation(); - return now != null && (now.distanceTo(before) > 5 || now.getPlane() != before.getPlane()); - }, 8000); - - if (!moved) { - log.info("[MoA-AUDIT] no teleport detected"); - closeMoaWidgetIfOpen(); - skipped++; - continue; - } - - sleep(1500); // settle - WorldPoint after = Rs2Player.getWorldLocation(); - int dist = after.getPlane() == expected.getPlane() ? after.distanceTo(expected) : -1; - String marker = dist == 0 ? "EXACT" : (dist > 0 && dist <= 2 ? "close" : (dist > 0 && dist <= 10 ? "NEAR" : "FAR")); - log.info("[MoA-AUDIT] LAND {} | actual={},{},{} expected={},{},{} dist={} | {}", - marker, - after.getX(), after.getY(), after.getPlane(), - expected.getX(), expected.getY(), expected.getPlane(), - dist, disp); - landed++; - sleep(1500); - } - log.info("[MoA-AUDIT] complete: landed={}/{} skipped={}", landed, moa.size(), skipped); - } catch (Exception e) { - log.error("[MoA-AUDIT] crashed", e); - } - } - - private static void closeMoaWidgetIfOpen() { - if (Rs2Widget.isWidgetVisible(MAP_OF_ALACRITY_WIDGET_GROUP, MAP_OF_ALACRITY_LIST_CHILD)) { - Rs2Keyboard.keyPress(27); // ESC - sleep(400); - } - } - private static boolean handleSpiritTree(Transport transport) { // Get Transport Information String displayInfo = transport.getDisplayInfo(); diff --git a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/teleportation_spells.tsv b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/teleportation_spells.tsv index 6c7588084a5..c9c97952b1a 100644 --- a/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/teleportation_spells.tsv +++ b/runelite-client/src/main/resources/net/runelite/client/plugins/microbot/shortestpath/teleportation_spells.tsv @@ -1,5 +1,8 @@ # Destination Skills Quests Wilderness level Varbits Varplayers isMembers Duration Display info # Standard Spellbook - Varbit 4070=0 +# Lumbridge Home Teleport. Varbit 12353 is BUFF_HOME_TELEPORT_DISABLED. +3221 3218 0 19 4070=0;12353=0 20 Lumbridge Home Teleport + # Varrock Teleport 3213 3424 0 25 Magic 19 4070=0;4585=0 4 Varrock Teleport # Varbit 4480 is DIARY_VARROCK_MEDIUM diff --git a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathCoreTest.java b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathCoreTest.java index 6c54c77f5e7..dcfe2e557c0 100644 --- a/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathCoreTest.java +++ b/runelite-client/src/test/java/net/runelite/client/plugins/microbot/shortestpath/ShortestPathCoreTest.java @@ -213,6 +213,20 @@ public void testNewTransportTypesLoaded() { assertTrue("Seasonal transports should be loaded", hasSeasonalTransport); } + @Test + public void testLumbridgeHomeTeleportTransportLoaded() { + HashMap> transports = Transport.loadAllFromResources(); + + boolean hasLumbridgeHomeTeleport = transports.values().stream() + .flatMap(Set::stream) + .anyMatch(t -> t.getType() == TransportType.TELEPORTATION_SPELL + && "Lumbridge Home Teleport".equals(t.getDisplayInfo()) + && new WorldPoint(3221, 3218, 0).equals(t.getDestination()) + && t.getVarbits().stream().anyMatch(v -> v.getVarbitId() == 12353 && v.getValue() == 0)); + + assertTrue("Lumbridge Home Teleport should be loaded and gated by cooldown varbit", hasLumbridgeHomeTeleport); + } + @Test public void testFairyRingTransportsExist() { HashMap> transports = Transport.loadAllFromResources(); 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 0c25c5f5bde..6399fd9aee5 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 @@ -1,5 +1,6 @@ package net.runelite.client.plugins.microbot.util.walker; +import net.runelite.api.WallObject; import net.runelite.api.coords.WorldPoint; import net.runelite.client.plugins.microbot.shortestpath.pathfinder.Pathfinder; import org.junit.After; @@ -229,6 +230,79 @@ public void findFurthest_nullPredicate_treatsAsNoTransport() { assertEquals("null predicate must not NPE and must allow full scan", 2, idx); } + @Test + public void interpolateClickableTarget_usesInterpolatedPointWhenUsable() { + WorldPoint player = new WorldPoint(3200, 3200, 0); + WorldPoint fallback = new WorldPoint(3206, 3200, 0); + List path = Arrays.asList( + new WorldPoint(3200, 3200, 0), + fallback, + new WorldPoint(3220, 3200, 0)); + + WorldPoint target = Rs2Walker.interpolateClickableTarget(path, 2, player, fallback, 12, wp -> true); + + assertEquals(new WorldPoint(3212, 3200, 0), target); + } + + @Test + public void interpolateClickableTarget_fallsBackWhenInterpolatedPointUnusable() { + WorldPoint player = new WorldPoint(3200, 3200, 0); + WorldPoint fallback = new WorldPoint(3206, 3200, 0); + List path = Arrays.asList( + new WorldPoint(3200, 3200, 0), + fallback, + new WorldPoint(3220, 3200, 0)); + + WorldPoint target = Rs2Walker.interpolateClickableTarget(path, 2, player, fallback, 12, wp -> false); + + assertEquals("unusable interpolated tiles must not replace the known path waypoint", + fallback, target); + } + + @Test + public void interpolateClickableTarget_shortensOutOfReachForwardWaypoint() { + WorldPoint player = new WorldPoint(3200, 3200, 0); + WorldPoint forward = new WorldPoint(3220, 3200, 0); + List path = Arrays.asList( + new WorldPoint(3200, 3200, 0), + forward); + + WorldPoint target = Rs2Walker.interpolateClickableTarget(path, 1, player, forward, 12, wp -> true); + + assertEquals("out-of-minimap forward waypoints should be shortened to a clickable tile", + new WorldPoint(3212, 3200, 0), target); + } + + // --------------------------------------------------------------------------- + // Raw-path wall-door segment probing + // --------------------------------------------------------------------------- + + @Test + public void wallDoorTouchesSegment_crossingDoorEdge_returnsTrue() { + WallObject door = mock(WallObject.class); + when(door.getWorldLocation()).thenReturn(new WorldPoint(3123, 3361, 0)); + when(door.getOrientationA()).thenReturn(8); // south-facing door edge + + assertTrue(Rs2Walker.wallDoorTouchesSegment(door, + new WorldPoint(3123, 3361, 0), + new WorldPoint(3123, 3360, 0))); + assertTrue(Rs2Walker.wallDoorTouchesSegment(door, + new WorldPoint(3123, 3360, 0), + new WorldPoint(3123, 3361, 0))); + } + + @Test + public void wallDoorTouchesSegment_startingBesideDoorAndMovingAway_returnsFalse() { + WallObject door = mock(WallObject.class); + when(door.getWorldLocation()).thenReturn(new WorldPoint(3123, 3361, 0)); + when(door.getOrientationA()).thenReturn(8); // door blocks 3123,3361 <-> 3123,3360 + + assertFalse("standing on the door's south neighbor and walking southwest must not re-open the door", + Rs2Walker.wallDoorTouchesSegment(door, + new WorldPoint(3123, 3360, 0), + new WorldPoint(3122, 3359, 0))); + } + // --------------------------------------------------------------------------- // #19 — Quest-lock dialogue heuristic // --------------------------------------------------------------------------- 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 6a932817bc7..ef1c6441ab2 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 @@ -705,9 +705,6 @@ net.runelite.client.plugins.microbot.util.walker.Rs2MiniMap#getMinimapDrawWidget 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.coords.LocalPoint#fromWorld(WorldView, WorldPoint): LocalPoint net.runelite.client.plugins.microbot.util.walker.Rs2Walker#closeWorldMap(): boolean -> net.runelite.api.widgets.Widget#getBounds(): Rectangle -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#collectMoaChildren(Widget): List -> net.runelite.api.widgets.Widget#getDynamicChildren(): Widget[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#collectMoaChildren(Widget): List -> net.runelite.api.widgets.Widget#getNestedChildren(): Widget[] -net.runelite.client.plugins.microbot.util.walker.Rs2Walker#collectMoaChildren(Widget): List -> net.runelite.api.widgets.Widget#getStaticChildren(): Widget[] net.runelite.client.plugins.microbot.util.walker.Rs2Walker#distanceToRegion(int, int): int -> net.runelite.api.Client#getTopLevelWorldView(): WorldView net.runelite.client.plugins.microbot.util.walker.Rs2Walker#distanceToRegion(int, int): int -> net.runelite.api.WorldView#getPlane(): int net.runelite.client.plugins.microbot.util.walker.Rs2Walker#getPointWithWallDistance(WorldPoint): WorldPoint -> net.runelite.api.Client#getTopLevelWorldView(): WorldView diff --git a/scripts/run-f2p-webwalker-harness.sh b/scripts/run-f2p-webwalker-harness.sh new file mode 100755 index 00000000000..2873748c947 --- /dev/null +++ b/scripts/run-f2p-webwalker-harness.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +CASE_ID="${1:-all}" +TIMEOUT_MS="${MICROBOT_WEBWALKER_TIMEOUT_MS:-1800000}" +LEG_TIMEOUT_MS="${MICROBOT_WEBWALKER_LEG_TIMEOUT_MS:-240000}" +OUTPUT_DIR="${MICROBOT_WEBWALKER_OUTPUT_DIR:-$HOME/.runelite/test-results/f2p-webwalker}" +USE_TELEPORTATION_SPELLS="${MICROBOT_WEBWALKER_USE_TELEPORTATION_SPELLS:-}" + +cd "$(dirname "$0")/.." + +rm -rf "$OUTPUT_DIR" +mkdir -p "$OUTPUT_DIR" + +./gradlew :client:compileJava + +CMD=( + ./gradlew :client:runTest + -Dmicrobot.test.mode=true + "-Dmicrobot.test.script=F2P Web Walker Harness" + "-Dmicrobot.test.timeout=$TIMEOUT_MS" + "-Dmicrobot.test.output=$OUTPUT_DIR" + -Dmicrobot.test.webwalker.stopOnFailure=true + "-Dmicrobot.test.webwalker.walkTimeoutMs=$LEG_TIMEOUT_MS" +) + +if [[ "$CASE_ID" != "all" ]]; then + CMD+=("-Dmicrobot.test.webwalker.case=$CASE_ID") +fi + +if [[ -n "$USE_TELEPORTATION_SPELLS" ]]; then + CMD+=("-Dmicrobot.test.webwalker.useTeleportationSpells=$USE_TELEPORTATION_SPELLS") +fi + +set +e +"${CMD[@]}" +STATUS=$? +set -e + +RESULT_FILE="$OUTPUT_DIR/result.json" +if [[ -f "$RESULT_FILE" ]]; then + echo "Result written to $RESULT_FILE" +else + echo "No result file was written at $RESULT_FILE" >&2 +fi + +exit "$STATUS"