fix(sdk): walkTo fails fast on dead-end partial paths instead of looping

findPath computes reachedDestination and walkTo ignored it: a partial path
(route stops short, e.g. indoors where the local mask routes through a door
into a room the destination is not reachable from) was walked to its dead
end, re-queried from there with the identical endpoint, and walked again -
the reported Varrock-house-to-bank case measured 6/23 indoor walk successes
with every failure ending on the same tile (bug report 2026-08-10).

A partial path is still walked once - doors open and zones allocate along
the way - but when the re-query dead-ends at the same tile and no door can
be opened, walkTo now returns failure naming the shortfall and where the
path stops.

Iteration progress is now measured as closing distance-to-destination
(monotone best) instead of distance moved, so an oscillating route can no
longer reset the stuck counter forever by pacing back and forth.

Covered by sdk/test/walkto-partial-path.test.ts; normal outdoor walks
verified live against the local engine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Fable 5 committed 2026-08-11 11:25:53 -04:00
1 parent ea11070dcd
commit b3c19c5abf
2 files changed
+145 -5

No files matched your search

+36 -5
View File
@@ -977,6 +977,13 @@ export class BotActions {
let doorRetryCount = 0;
let poorProgressCount = 0;
const doorFailCounts = new Map<string, number>(); // Tracks reach failures per door
// Where the last partial path (reachedDestination=false) stopped.
// Walking the same dead end twice cannot succeed - fail fast instead.
let lastPartialEnd: { x: number; z: number } | null = null;
// Best distance-to-destination seen at any iteration end. Progress is
// *closing on the destination*, not distance walked: an oscillating
// route can move 5+ tiles every iteration while never getting closer.
let bestRemaining = distTo(pos);
// Try to open a blocking door. Returns true if door was opened.
const tryOpenDoor = async (): Promise<boolean> => {
@@ -1026,12 +1033,34 @@ export class BotActions {
}
}
// A partial path (reachedDestination=false) stopping short of the
// tolerance is worth walking ONCE - doors open and zones allocate
// along the way. But when a re-query from the dead end produces the
// same endpoint, walking it again just loops: surface the shortfall
// instead (Varrock-house-to-bank dead-end loop, bug report
// 2026-08-10).
const endWp = path.waypoints[path.waypoints.length - 1];
const partial = path.reachedDestination === false
&& endWp !== undefined
&& this.helpers.distance(endWp.x, endWp.z, x, z) > tolerance;
if (partial && lastPartialEnd && endWp.x === lastPartialEnd.x && endWp.z === lastPartialEnd.z) {
if (!await tryOpenDoor()) {
const short = Math.round(this.helpers.distance(endWp.x, endWp.z, x, z));
return {
success: false,
message: `Path to (${x}, ${z}) stops ${short} tiles short at (${endWp.x}, ${endWp.z}) - the rest is unroutable from here (locked door or separate room?)`
};
}
lastPartialEnd = null;
continue; // A door opened - re-query the path
}
lastPartialEnd = partial ? { x: endWp.x, z: endWp.z } : null;
// Identify doors the path crosses through so we can open them proactively
const requiredDoors = findDoorsAlongPath(path.waypoints);
const requiredDoorKeys = new Set(requiredDoors.map(d => `${d.x},${d.z}`));
// Walk waypoints
const startPos = { ...pos };
let consecutiveStuck = 0;
for (const wp of path.waypoints) {
@@ -1106,10 +1135,12 @@ export class BotActions {
}
}
// Check progress since path query started
const distMoved = this.helpers.distance(startPos.x, startPos.z, pos.x, pos.z);
if (distMoved >= 5) {
// Progress = closing on the destination. bestRemaining is a
// monotone minimum, so an oscillating route (walk out, walk back)
// cannot keep resetting the counter the way distance-moved did.
const remaining = distTo(pos);
if (remaining < bestRemaining) {
bestRemaining = remaining;
poorProgressCount = 0;
} else if (++poorProgressCount >= 3) {
if (!await tryOpenDoor()) {
+109
View File
@@ -0,0 +1,109 @@
/**
* Logic test for walkTo()'s handling of partial paths - no bot required.
*
* findPath reports reachedDestination=false when the route stops short (e.g.
* indoors, when the local mask lets the pathfinder route through a door into a
* room the destination isn't reachable from). walkTo used to ignore the flag,
* walk the partial path, re-query the identical partial path from the dead end
* and walk it again - forever. It must walk a partial path once (doors open and
* zones allocate along the way), then fail fast with the shortfall when a
* re-query ends at the same tile.
*/
import { describe, expect, test } from 'bun:test';
import { BotSDK } from '../index';
import { BotActions } from '../actions';
type PathResult = {
success: boolean;
waypoints: Array<{ x: number; z: number; level: number }>;
reachedDestination?: boolean;
error?: string;
};
function makeBot(pathForPos: (pos: { x: number; z: number }) => PathResult) {
const sdk = Object.create(BotSDK.prototype) as BotSDK;
const pos = { x: 3153, z: 3427 };
let pathQueries = 0;
(sdk as any).getState = () => ({
tick: 0,
player: { worldX: pos.x, worldZ: pos.z, x: pos.x, z: pos.z, level: 0, animId: -1 },
dialog: { isOpen: false, options: [], isWaiting: false },
interface: { isOpen: false },
shop: { isOpen: false },
bank: { isOpen: false },
nearbyLocs: [],
gameMessages: [],
inventory: [],
});
(sdk as any).sendFindPath = async (): Promise<PathResult> => {
pathQueries++;
return pathForPos({ ...pos });
};
(sdk as any).waitForTicks = async () => {};
(sdk as any).getNearbyLocs = () => [];
(sdk as any).isDoorTemporarilyBlocked = () => false;
const bot = new BotActions(sdk);
// Teleport-style step: every waypoint walk lands exactly on the waypoint.
(bot as any).helpers.walkStepToward = async (targetX: number, targetZ: number) => {
pos.x = targetX;
pos.z = targetZ;
return { status: 'progress', pos: { ...pos } };
};
(bot as any).helpers.tryOpenBlockingDoor = async () => false;
return { bot, pos, queries: () => pathQueries };
}
describe('walkTo() partial paths', () => {
test('fails fast with the shortfall when re-queries dead-end at the same tile', async () => {
// The reported shape: from inside a Varrock house, every query to the
// bank returns success=true with waypoints that stop at (3164, 3435),
// 21 tiles short, reachedDestination=false.
const deadEnd = { x: 3164, z: 3435, level: 0 };
const { bot, queries } = makeBot(pos => ({
success: true,
reachedDestination: false,
waypoints: pos.x === deadEnd.x && pos.z === deadEnd.z
? [deadEnd] // re-query from the dead end: same endpoint
: [{ x: 3160, z: 3432, level: 0 }, deadEnd],
}));
const result = await bot.walkTo(3185, 3436, 2);
expect(result.success).toBe(false);
expect(result.message).toContain('stops');
expect(result.message).toContain('(3164, 3435)');
// One walked partial path + one re-query that detects the repeat -
// not 50 iterations of re-walking the same route.
expect(queries()).toBe(2);
});
test('a partial path that reaches within tolerance still succeeds', async () => {
// Stopping 2 tiles from the destination with tolerance 3 is arrival,
// reachedDestination flag or not.
const { bot } = makeBot(() => ({
success: true,
reachedDestination: false,
waypoints: [{ x: 3183, z: 3436, level: 0 }],
}));
const result = await bot.walkTo(3185, 3436, 3);
expect(result.success).toBe(true);
});
test('full paths still arrive', async () => {
const { bot } = makeBot(() => ({
success: true,
reachedDestination: true,
waypoints: [{ x: 3170, z: 3430, level: 0 }, { x: 3185, z: 3436, level: 0 }],
}));
const result = await bot.walkTo(3185, 3436, 2);
expect(result.success).toBe(true);
});
});