Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions runelite-client/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,51 @@ tasks.register<Test>("runTests") {
}
}

tasks.register<JavaExec>("exportLocalPlannerComparison") {
group = "verification"
description = "Export deterministic local planner results for the opt-in upstream comparison harness"

dependsOn(":client:compileJava", ":client:compileTestJava")
classpath = sourceSets.test.get().runtimeClasspath
mainClass.set("net.runelite.client.plugins.microbot.util.walker.LocalPlannerComparisonMain")

val corpus = providers.gradleProperty("plannerCorpus")
val output = providers.gradleProperty("plannerOutput")
doFirst {
require(corpus.isPresent && output.isPresent) {
"exportLocalPlannerComparison requires -PplannerCorpus=<path> and -PplannerOutput=<path>"
}
args(rootProject.file(corpus.get()).absolutePath, rootProject.file(output.get()).absolutePath)
systemProperty("microbot.planner.revision",
providers.gradleProperty("plannerRevision").getOrElse("unknown"))
}

outputs.upToDateWhen { false }
}

tasks.register<JavaExec>("exportEmbeddedUpstreamPlannerComparison") {
group = "verification"
description = "Export results from the production-packaged pinned upstream planner adapter"

dependsOn(":client:compileJava", ":client:compileTestJava")
classpath = sourceSets.test.get().runtimeClasspath
mainClass.set("net.runelite.client.plugins.microbot.util.walker.LocalPlannerComparisonMain")

val corpus = providers.gradleProperty("plannerCorpus")
val output = providers.gradleProperty("plannerOutput")
doFirst {
require(corpus.isPresent && output.isPresent) {
"exportEmbeddedUpstreamPlannerComparison requires -PplannerCorpus=<path> and -PplannerOutput=<path>"
}
args(rootProject.file(corpus.get()).absolutePath, rootProject.file(output.get()).absolutePath)
systemProperty("microbot.planner.revision",
providers.gradleProperty("plannerRevision").getOrElse("unknown"))
systemProperty("microbot.planner.embedded-upstream", "true")
}

outputs.upToDateWhen { false }
}

tasks.register<Test>("runUnitTests") {
group = "verification"
description = "Run unit tests only (no client, no login) — safe for CI"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,14 @@
import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionConflicts;
import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionOverlay;
import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionPersistence;
import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionView;
import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveCollisionSnapshot;
import net.runelite.client.plugins.microbot.shortestpath.pathfinder.live.LiveRouteValidator;
import net.runelite.client.plugins.microbot.shortestpath.pathfinder.SplitFlagMap;
import net.runelite.client.plugins.microbot.util.player.Rs2Player;
import net.runelite.client.plugins.microbot.util.tile.Rs2Tile;
import net.runelite.client.plugins.microbot.util.walker.Rs2Walker;
import net.runelite.client.plugins.microbot.util.walker.Rs2TransportPlanningPolicy;
import net.runelite.client.ui.ClientToolbar;
import net.runelite.client.ui.JagexColors;
import net.runelite.client.ui.NavigationButton;
Expand Down Expand Up @@ -91,7 +93,7 @@
name = PluginDescriptor.Mocrosoft + "Web Walker",
description = "Draws the shortest path to a chosen destination on the map (right click a spot on the world map to use)",
tags = {"pathfinder", "map", "waypoint", "navigation", "microbot"},
enabledByDefault = true,
enabledByDefault = false,
alwaysOn = true
)
public class ShortestPathPlugin extends Plugin implements KeyListener {
Expand Down Expand Up @@ -227,7 +229,9 @@ protected void startUp() {
Map<WorldPoint, Set<Transport>> transports = Transport.loadAllFromResources();

List<Restriction> restrictions = Restriction.loadAllFromResources();
pathfinderConfig = new PathfinderConfig(map, transports, restrictions, client, config);
pathfinderConfig = new PathfinderConfig(
map, transports, restrictions, client, config,
Rs2TransportPlanningPolicy.INSTANCE);

panel = injector.getInstance(ShortestPathPanel.class);
pohPanel = new PohPanel(config);
Expand Down Expand Up @@ -660,7 +664,7 @@ private void markLiveCollisionDirty() {
* decision with magnitudes instead of anecdotes. Runs off the fresh immutable snapshot, never on
* the pathfinder hot path.
*/
private void logLiveStaticConflicts(LiveCollisionSnapshot snapshot) {
private void logLiveStaticConflicts(LiveCollisionSnapshot snapshot, LiveCollisionView priorOverlayView) {
if (staticCollisionData == null) {
return;
}
Expand All @@ -673,9 +677,14 @@ private void logLiveStaticConflicts(LiveCollisionSnapshot snapshot) {
return;
}
lastCollisionConflictLogAtMs = now;
WebWalkLog.spInfo("collision_conflict | liveOpensStatic={} liveBlocksStatic={} sealedOpens={} base={},{} — live scene disagrees with the shipped map",
LiveCollisionConflicts.Coverage coverage =
LiveCollisionConflicts.coverage(snapshot, staticCollisionData, priorOverlayView);
WebWalkLog.spInfo("collision_conflict | liveOpensStatic={} liveBlocksStatic={} sealedOpens={} base={},{}"
+ " | overlayKnew={}% (known={} new={} changed={}) — live scene disagrees with the shipped map",
tally.liveOpensStatic, tally.liveBlocksStatic, tally.liveOpensSealed,
snapshot.getBaseX(), snapshot.getBaseY());
snapshot.getBaseX(), snapshot.getBaseY(),
coverage.alreadyKnownPercent(), coverage.alreadyKnown,
coverage.newInformation, coverage.changed);
}

private void resetLearnedCollision() {
Expand Down Expand Up @@ -785,8 +794,12 @@ void refreshLiveCollision() {
return;
}

// Pinned BEFORE the merge: this is what we knew on arrival, which is the only way to tell
// whether the persistent store spared us a blind first visit. mergeScene replaces regions
// rather than mutating them, so this view stays a true "before".
final LiveCollisionView priorOverlayView = overlay.current();
overlay.set(snapshot);
logLiveStaticConflicts(snapshot);
logLiveStaticConflicts(snapshot, priorOverlayView);
// Persist the regions this capture just changed so the learned collision survives a restart.
if (liveCollisionPersistence != null) {
liveCollisionPersistence.persist(overlay.drainDirty());
Expand Down Expand Up @@ -834,7 +847,26 @@ private boolean validateRouteAgainstLiveCollision(LiveCollisionOverlay overlay)
final CollisionMap map = pathfinderConfig.getMap();
map.beginSearch(); // pin the freshly captured snapshot for this validation
final int from = LiveRouteValidator.nearestIndex(path, me);
final int blocked = LiveRouteValidator.firstBlockedStep(path, from, LIVE_RECALC_LOOKAHEAD, map);
// A door transport joins two adjacent same-plane tiles, so to the validator its step looks
// like walking — and while the door is SHUT the edge honestly reads blocked. That is its
// normal state, not an obstruction: the walker's executor opens it on contact. Recalculating
// here yanked the route out from under the walker while it stood at the door handling it.
final int blocked = LiveRouteValidator.firstBlockedStep(path, from, LIVE_RECALC_LOOKAHEAD, map,
(a, b) -> {
// The walker's door subsystem has claimed this edge — catalog or not. Quest doors
// (fightarena_door1) are in no catalog, yet the recalc mid-interaction is just as
// wrong there.
if (Rs2Walker.isActiveDoorEdge(a, b)) {
return true;
}
for (Transport t : pathfinderConfig.getTransportsPacked()
.getOrDefault(WorldPointUtil.packWorldPoint(a), java.util.Collections.emptySet())) {
if (b.equals(t.getDestination())) {
return true;
}
}
return false;
});
if (blocked >= 0) {
lastLiveRecalcMs = now;
log.debug("[LiveCollision] route step {} -> {} now blocked; recalculating",
Expand Down Expand Up @@ -970,15 +1002,6 @@ private Color override(String configOverrideKey, Color defaultValue) {
return defaultValue;
}

public static PlannerSelectionMode override(
String configOverrideKey, PlannerSelectionMode defaultValue) {
if (!configOverride.isEmpty()) {
return PlannerSelectionMode.fromConfigValue(
configOverride.get(configOverrideKey), defaultValue);
}
return defaultValue;
}

public static int override(String configOverrideKey, int defaultValue) {
if (!configOverride.isEmpty()) {
Object value = configOverride.get(configOverrideKey);
Expand All @@ -1002,6 +1025,15 @@ public static TeleportationItem override(String configOverrideKey, Teleportation
return defaultValue;
}

public static PlannerSelectionMode override(
String configOverrideKey, PlannerSelectionMode defaultValue) {
if (!configOverride.isEmpty()) {
return PlannerSelectionMode.fromConfigValue(
configOverride.get(configOverrideKey), defaultValue);
}
return defaultValue;
}

private TileCounter override(String configOverrideKey, TileCounter defaultValue) {
if (!configOverride.isEmpty()) {
Object value = configOverride.get(configOverrideKey);
Expand Down Expand Up @@ -1044,7 +1076,7 @@ private void onMenuOptionClicked(MenuEntry entry) {
}

if (entry.getOption().equals(CLEAR) && entry.getTarget().equals(PATH)) {
shortestPathScript.setTriggerWalker(null);
shortestPathScript.setTriggerWalker(null, "menu:clear-path");
}
}

Expand Down Expand Up @@ -1357,7 +1389,7 @@ public void keyPressed(KeyEvent e) {
* Therefor CTRL + X seemed a bit more robust and userfriendly
*/
if (e.getKeyCode() == KeyEvent.VK_X && e.isControlDown()) {
shortestPathScript.setTriggerWalker(null);
shortestPathScript.setTriggerWalker(null, "hotkey:ctrl+x");
e.consume();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,25 @@ public static boolean quickCast(Spell spell) {
return quickCast(spell.getMagicAction());
}

/**
* Click a zero-rune spellbook action by its exact displayed name.
*
* <p>Home teleports exist on every spellbook but only the standard-book variant is represented by
* {@link MagicAction}. The active spellbook and cooldown are planner requirements; at execution time
* the exact visible widget is the authoritative capability check.</p>
*/
public static boolean quickCast(String spellName) {
if (spellName == null || spellName.trim().isEmpty()) return false;

Microbot.status = "Casting " + spellName;
if (Rs2Tab.getCurrentTab() != InterfaceTab.MAGIC) {
Rs2Tab.switchToMagicTab();
if (!sleepUntil(() -> Rs2Tab.getCurrentTab() == InterfaceTab.MAGIC)) return false;
}

return Rs2Widget.clickWidget(spellName, Optional.of(218), 3, true);
}

public static boolean quickCast(MagicAction magicSpell) {
Microbot.status = "Casting " + magicSpell.getName();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,119 @@ public static boolean isTileReachable(WorldPoint targetPoint) {
return runClientReadBoolean(() -> isTileReachableInternal(targetPoint));
}

/**
* Whether a single step from {@code from} to {@code to} is currently permitted by the CLIENT's
* collision data — the live flags the server drives, so a door that has just opened clears its
* blocking flag here on the same tick.
* <p>
* This is the direct answer to "can I walk through that door now", and it is deliberately not
* {@link #isTileReachable}: that runs a BFS, so a shut door with a long way round it still reports
* the far tile as reachable, and it costs a whole scene search. This reads one flag.
* <p>
* Answers {@code false} for anything it cannot decide — off-scene, an instance (raw coordinates
* make the scene conversion unreliable), or a plane other than the one loaded. Callers use it to
* release early, so an unknown must never read as "open".
*
* @return true only when the step is known to be unobstructed
*/
public static boolean isEdgePassable(WorldPoint from, WorldPoint to) {
return runClientReadBoolean(() -> isEdgePassableInternal(from, to));
}

/**
* Why the last {@link #isEdgePassable} call answered as it did. A bare {@code false} is ambiguous
* between "the door is shut" and "this could not be decided", and callers that release a wait on
* {@code true} behave very differently depending on which it was.
*/
private static volatile String lastEdgeDecision = "-";

/** @see #lastEdgeDecision */
public static String lastEdgeDecision() {
return lastEdgeDecision;
}

private static boolean isEdgePassableInternal(WorldPoint from, WorldPoint to) {
if (from == null || to == null || from.getPlane() != to.getPlane()) {
lastEdgeDecision = "bad-args";
return false;
}

final int dx = to.getX() - from.getX();
final int dy = to.getY() - from.getY();
if (dx == 0 && dy == 0) {
lastEdgeDecision = "same-tile";
return true;
}
if (Math.abs(dx) > 1 || Math.abs(dy) > 1) {
lastEdgeDecision = "not-adjacent";
return false;
}

final WorldView wv = Microbot.getClient().getTopLevelWorldView();
if (wv == null) {
lastEdgeDecision = "no-worldview";
return false;
}
if (wv.getPlane() != from.getPlane()) {
lastEdgeDecision = "plane-not-loaded";
return false;
}
// Instance scenes repeat template chunks, so world -> scene by base offset is wrong there.
if (wv.getScene() != null && wv.getScene().isInstance()) {
lastEdgeDecision = "instance";
return false;
}

final int[][] flags = getFlagsInternal();
if (flags == null) {
lastEdgeDecision = "no-flags";
return false;
}

final int fx = from.getX() - Microbot.getClient().getBaseX();
final int fy = from.getY() - Microbot.getClient().getBaseY();
final int tx = fx + dx;
final int ty = fy + dy;
if (!isWithinBounds(fx, fy) || !isWithinBounds(tx, ty)) {
lastEdgeDecision = "off-scene";
return false;
}

boolean allowed = isStepAllowed(flags, fx, fy, dx, dy);
lastEdgeDecision = allowed ? "open" : "blocked";
return allowed;
}

/**
* The collision rule alone, with no client reads: is a single {@code (dx, dy)} step out of
* {@code (fx, fy)} unobstructed by these flags? Split out so the cardinal/diagonal rules are
* covered by a decision table rather than only by a live client.
*/
static boolean isStepAllowed(int[][] flags, int fx, int fy, int dx, int dy) {
if (dx == 0 && dy == 0) return true;
final int tx = fx + dx;
final int ty = fy + dy;

if ((flags[tx][ty] & CollisionDataFlag.BLOCK_MOVEMENT_FULL) != 0) return false;

if (dx == 0 || dy == 0) {
return (flags[fx][fy] & cardinalBlockFlag(dx, dy)) == 0;
}
// Diagonal: both cardinal components must be clear, and so must the two tiles cut through —
// the same rule the reachability search uses for corners.
return (flags[fx][fy] & cardinalBlockFlag(dx, 0)) == 0
&& (flags[fx][fy] & cardinalBlockFlag(0, dy)) == 0
&& (flags[tx][fy] & (CollisionDataFlag.BLOCK_MOVEMENT_FULL | cardinalBlockFlag(0, dy))) == 0
&& (flags[fx][ty] & (CollisionDataFlag.BLOCK_MOVEMENT_FULL | cardinalBlockFlag(dx, 0))) == 0;
}

private static int cardinalBlockFlag(int dx, int dy) {
if (dx > 0) return CollisionDataFlag.BLOCK_MOVEMENT_EAST;
if (dx < 0) return CollisionDataFlag.BLOCK_MOVEMENT_WEST;
if (dy > 0) return CollisionDataFlag.BLOCK_MOVEMENT_NORTH;
return CollisionDataFlag.BLOCK_MOVEMENT_SOUTH;
}

private static boolean isTileReachableInternal(WorldPoint targetPoint) {
if (targetPoint == null) return false;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package net.runelite.client.plugins.microbot.util.walker;

/** Pure decision used after the walker gives a transient logged-out sample a short grace window. */
final class LoginStabilityPolicy {
private LoginStabilityPolicy() {
}

static boolean shouldExit(boolean initiallyLoggedIn,
boolean loggedInAfterGrace,
boolean walkCancelled) {
return !walkCancelled && !initiallyLoggedIn && !loggedInAfterGrace;
}
}
Loading
Loading