remove all the special long path pathfinding, just use a larger search grid in the rust

This commit is contained in:
max bittker committed 2026-02-11 01:37:23 -05:00
1 parent 99f6b8cb5b
commit 019340b12b
11 files changed
+727 -188

No files matched your search

+1 -34
View File
@@ -4,7 +4,7 @@
import { BotSDK } from './index';
import { ActionHelpers } from './actions-helpers';
import { isInsideDraynorManor, getDraynorManorEscape, findDoorsAlongPath } from './pathfinding';
import { findDoorsAlongPath } from './pathfinding';
import type {
ActionResult,
SkillState,
@@ -728,39 +728,6 @@ export class BotActions {
const state = this.sdk.getState();
if (!state?.player) return { success: false, message: 'No player state' };
let pos = { x: state.player.worldX, z: state.player.worldZ };
const distTo = (p: { x: number; z: number }) => this.helpers.distance(p.x, p.z, x, z);
if (distTo(pos) <= tolerance) {
return { success: true, message: 'Already at destination' };
}
// Draynor Manor trap: the front door only opens from outside.
// If we're inside the manor and the destination is outside, escape
// via the east wing doors first, then continue to the real destination.
if (isInsideDraynorManor(pos.x, pos.z, state.player.level) &&
!isInsideDraynorManor(x, z)) {
const escape = getDraynorManorEscape();
console.log(`[walkTo] Inside Draynor Manor — escaping via east wing to (${escape.x}, ${escape.z})`);
const escapeResult = await this._walkToInternal(escape.x, escape.z, 3);
if (!escapeResult.success) {
return { success: false, message: `Trapped in Draynor Manor: ${escapeResult.message}` };
}
const afterEscape = this.sdk.getState()?.player;
if (afterEscape) pos = { x: afterEscape.worldX, z: afterEscape.worldZ };
if (distTo(pos) <= tolerance) {
return { success: true, message: 'Arrived' };
}
}
return this._walkToInternal(x, z, tolerance);
}
/** Core walk loop — used by walkTo and Draynor Manor escape. */
private async _walkToInternal(x: number, z: number, tolerance: number = 3): Promise<ActionResult> {
const state = this.sdk.getState();
if (!state?.player) return { success: false, message: 'No player state' };
const distTo = (pos: { x: number; z: number }) => this.helpers.distance(pos.x, pos.z, x, z);
let pos = { x: state.player.worldX, z: state.player.worldZ };
+21 -147
View File
@@ -27,43 +27,17 @@ const doorIndex = new Map<string, DoorInfo>();
// are likely open ocean/void and should not be treated as walkable land.
const populatedZones = new Set<string>();
// One-way doors that should NOT be masked or included in the door index.
// One-way doors that should NOT be unmasked in the door index.
// These doors can only be opened from one side; routing through them traps the bot.
const ONE_WAY_DOORS = new Set<string>([
'0,3108,3353', // Draynor Manor front door (west tile) — only opens from outside
'0,3109,3353', // Draynor Manor front door (east tile) — only opens from outside
]);
// ── Draynor Manor directional door handling ──
// The front door at (3108-3109, 3353) can only be opened from one side.
// If the bot is inside the manor it must exit via the east wing escape doors.
const DRAYNOR_MANOR = {
// Interior bounding box (level 0) — covers the main building and courtyard.
// Everything within the manor walls past the front door is trapped.
minX: 3097, maxX: 3119,
minZ: 3354, maxZ: 3374,
// Front door position (two tiles wide)
frontDoor: [{ x: 3108, z: 3353 }, { x: 3109, z: 3353 }],
// East wing escape route: walk here to get out
escapeExit: { x: 3125, z: 3370 },
};
function doorKey(level: number, x: number, z: number): string {
return `${level},${x},${z}`;
}
/** Check if a position is inside Draynor Manor's ground floor interior. */
export function isInsideDraynorManor(x: number, z: number, level: number = 0): boolean {
if (level !== 0) return false;
return x >= DRAYNOR_MANOR.minX && x <= DRAYNOR_MANOR.maxX &&
z >= DRAYNOR_MANOR.minZ && z <= DRAYNOR_MANOR.maxZ;
}
/** Get the Draynor Manor escape exit coordinates (outside the manor, east courtyard). */
export function getDraynorManorEscape(): { x: number; z: number } {
return { ...DRAYNOR_MANOR.escapeExit };
}
export function initPathfinding(): void {
if (initialized) return;
@@ -75,6 +49,21 @@ export function initPathfinding(): void {
rsmod.allocateIfAbsent(zoneX, zoneZ, level);
}
// Allocate mainland zones so the 2048x2048 BFS grid can traverse
// open land between cities. Unallocated zones return NULL (blocked),
// so without this the pathfinder can't cross gaps in the collision data.
// Newly-allocated zones default to OPEN (walkable), which is correct
// for grassland/roads. Zones already allocated above keep their flags.
let mainlandZones = 0;
for (let x = 2304; x <= 3392; x += 8) {
for (let z = 2944; z <= 3584; z += 8) {
if (!rsmod.isZoneAllocated(x, z, 0)) {
rsmod.allocateIfAbsent(x, z, 0);
mainlandZones++;
}
}
}
// Set collision flags for tiles that have them (includes wall flags)
for (const [level, x, z, flags] of data.tiles) {
rsmod.__set(x, z, level, flags);
@@ -108,7 +97,7 @@ export function initPathfinding(): void {
}
initialized = true;
console.log(`Pathfinding initialized in ${Date.now() - start}ms (${data.zones.length} zones, ${data.tiles.length} tiles, ${doorCount} doors masked, ${skippedOneWay} one-way doors blocked)`);
console.log(`Pathfinding initialized in ${Date.now() - start}ms (${data.zones.length} zones + ${mainlandZones} mainland fill, ${data.tiles.length} tiles, ${doorCount} doors masked, ${skippedOneWay} one-way doors blocked)`);
}
// Check if a zone has collision data
@@ -139,7 +128,7 @@ export function findPath(
return unpackWaypoints(waypointsRaw);
}
// Find long-distance path (512x512 search grid)
// Find long-distance path (2048x2048 search grid, ±1024 tile reach)
export function findLongPath(
level: number,
srcX: number,
@@ -185,132 +174,17 @@ export function isZoneLikelyLand(level: number, x: number, z: number): boolean {
return populatedZones.has(`${level},${x & ~7},${z & ~7}`);
}
/** Maximum single-segment distance (conservative; 512/2=256 grid half, minus routing headroom). */
const MAX_SINGLE_SEGMENT = 200;
/**
* Find the nearest walkable tile to (x, z) by spiraling outward.
* Returns null if no walkable tile found within maxRadius.
*/
function snapToWalkable(
level: number, x: number, z: number, maxRadius: number = 30
): { x: number; z: number } | null {
if (!initialized) initPathfinding();
if (isTileWalkable(level, x, z) && isZoneAllocated(level, x, z) && isZoneLikelyLand(level, x, z)) {
return { x, z };
}
for (let r = 1; r <= maxRadius; r++) {
for (let dx = -r; dx <= r; dx++) {
for (let dz = -r; dz <= r; dz++) {
if (Math.abs(dx) !== r && Math.abs(dz) !== r) continue; // perimeter only
const tx = x + dx;
const tz = z + dz;
if (isTileWalkable(level, tx, tz) && isZoneAllocated(level, tx, tz) && isZoneLikelyLand(level, tx, tz)) {
return { x: tx, z: tz };
}
}
}
}
return null;
}
/**
* Find a path between two points that may be farther apart than the 512x512
* pathfinder grid allows. Recursively bisects the route into segments that
* each fit within findLongPath's range, then concatenates the results.
*
* When a direct split fails (e.g. coastline blocks the midpoint), offset
* candidates perpendicular to the travel axis are tried so the route can
* swing around geographic obstacles.
* Find a path between two distant points.
* With the 2048x2048 pathfinder grid (±1024 tile reach), this is now
* just a direct call to findLongPath for any in-game distance.
*/
export function findMultiSegmentPath(
level: number,
srcX: number, srcZ: number,
destX: number, destZ: number,
maxWaypoints: number = 500,
_depth: number = 0
): Array<{ x: number; z: number; level: number }> {
if (!initialized) initPathfinding();
const dx = Math.abs(destX - srcX);
const dz = Math.abs(destZ - srcZ);
// Base case: within single pathfinder range
if (dx <= MAX_SINGLE_SEGMENT && dz <= MAX_SINGLE_SEGMENT) {
return findLongPath(level, srcX, srcZ, destX, destZ, maxWaypoints);
}
// Guard against excessive recursion
if (_depth > 6) {
return findLongPath(level, srcX, srcZ, destX, destZ, maxWaypoints);
}
// Build candidate split points: midpoint + 1/3 + 2/3 along the line,
// plus perpendicular offsets at each to route around coastline/mountains.
const midX = Math.round((srcX + destX) / 2);
const midZ = Math.round((srcZ + destZ) / 2);
const thirdX = Math.round(srcX + (destX - srcX) / 3);
const thirdZ = Math.round(srcZ + (destZ - srcZ) / 3);
const twoThirdX = Math.round(srcX + 2 * (destX - srcX) / 3);
const twoThirdZ = Math.round(srcZ + 2 * (destZ - srcZ) / 3);
// Perpendicular offset: rotate the travel vector 90 degrees
const travelDx = destX - srcX;
const travelDz = destZ - srcZ;
const travelLen = Math.sqrt(travelDx * travelDx + travelDz * travelDz);
// Perpendicular unit vector (rotated 90 degrees)
const perpX = -travelDz / travelLen;
const perpZ = travelDx / travelLen;
const splitPoints: Array<{ x: number; z: number }> = [
// Direct line candidates
{ x: midX, z: midZ },
{ x: thirdX, z: thirdZ },
{ x: twoThirdX, z: twoThirdZ },
];
// Perpendicular offsets at increasing distances to swing around obstacles
const perpOffsets = [80, 160, Math.round(travelLen * 0.4), Math.round(travelLen * 0.6)];
for (const offset of perpOffsets) {
splitPoints.push(
{ x: Math.round(midX + perpX * offset), z: Math.round(midZ + perpZ * offset) },
{ x: Math.round(midX - perpX * offset), z: Math.round(midZ - perpZ * offset) },
);
}
let bestPartial: Array<{ x: number; z: number; level: number }> = [];
for (const raw of splitPoints) {
const snapped = snapToWalkable(level, raw.x, raw.z);
if (!snapped) continue;
const firstHalf = findMultiSegmentPath(level, srcX, srcZ, snapped.x, snapped.z, maxWaypoints, _depth + 1);
if (firstHalf.length === 0) continue;
// Verify the first half actually reached the split point (within tolerance).
const lastWp = firstHalf[firstHalf.length - 1]!;
const reachDist = Math.abs(lastWp.x - snapped.x) + Math.abs(lastWp.z - snapped.z);
if (reachDist > 15) {
if (firstHalf.length > bestPartial.length) {
bestPartial = firstHalf;
}
continue;
}
const secondHalf = findMultiSegmentPath(level, lastWp.x, lastWp.z, destX, destZ, maxWaypoints, _depth + 1);
if (secondHalf.length === 0) {
if (firstHalf.length > bestPartial.length) {
bestPartial = firstHalf;
}
continue;
}
// Concatenate, removing duplicate at junction
return firstHalf.concat(secondHalf.slice(1));
}
// Return best partial result, or direct long path as fallback
if (bestPartial.length > 0) return bestPartial;
return findLongPath(level, srcX, srcZ, destX, destZ, maxWaypoints);
}
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bun
/**
* Walk Test: Lumbridge to Yanille
* Tests very long-distance pathfinding across the map.
* Route: Lumbridge -> Draynor -> Rimmington -> Ardougne -> Yanille
*/
import { runTest } from './utils/test-runner';
import { Locations } from './utils/save-generator';
const YANILLE = { x: 2606, z: 3093 };
runTest({
name: 'Lumbridge to Yanille',
saveConfig: {
position: Locations.LUMBRIDGE_CASTLE,
skills: { Agility: 99 },
varps: { 281: 1000 },
},
launchOptions: { skipTutorial: false },
}, async ({ sdk, bot }) => {
await sdk.waitForCondition(s => (s?.player?.worldX ?? 0) > 0, 10000);
const pos = () => {
const s = sdk.getState()?.player;
return { x: s?.worldX ?? 0, z: s?.worldZ ?? 0 };
};
const distTo = (x: number, z: number) => {
const p = pos();
return Math.sqrt((p.x - x) ** 2 + (p.z - z) ** 2);
};
const start = pos();
console.log(`Start: (${start.x}, ${start.z}) - Lumbridge`);
console.log(`Target: (${YANILLE.x}, ${YANILLE.z}) - Yanille`);
console.log(`Distance: ${distTo(YANILLE.x, YANILLE.z).toFixed(0)} tiles\n`);
const t0 = Date.now();
// Walk to Yanille - this is a very long route
const result = await bot.walkTo(YANILLE.x, YANILLE.z, 15);
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
const final = pos();
const dist = distTo(YANILLE.x, YANILLE.z);
console.log(`\n=== RESULT ===`);
console.log(`walkTo success: ${result.success}`);
console.log(`Message: ${result.message}`);
console.log(`Final position: (${final.x}, ${final.z})`);
console.log(`Distance from target: ${dist.toFixed(1)} tiles`);
console.log(`Time: ${elapsed}s`);
const passed = result.success && dist <= 15;
console.log(`Verdict: ${passed ? 'PASS' : 'FAIL'}`);
return passed;
});
+393
View File
@@ -0,0 +1,393 @@
#!/usr/bin/env bun
// Local pathfinding validation - no server or bot required
// Tests that the collision data + door masking produces correct paths
import { findLongPath, findMultiSegmentPath, findDoorsAlongPath, initPathfinding, isTileWalkable, isFlagged, isZoneLikelyLand, isZoneAllocated } from '../pathfinding';
import { CollisionFlag } from '../../server/vendor/rsmod-pathfinder';
console.log('Initializing pathfinding...');
initPathfinding();
let passed = 0;
let failed = 0;
interface TimingEntry {
label: string;
method: 'findLongPath' | 'findMultiSegmentPath';
ms: number;
straightLineDist: number;
waypoints: number;
reached: boolean;
}
const timings: TimingEntry[] = [];
function test(
label: string,
srcX: number, srcZ: number,
destX: number, destZ: number,
opts: {
expectPath?: boolean; // expect a non-empty path (default true)
expectReach?: boolean; // expect path reaches destination (default true)
maxDist?: number; // max distance of last waypoint from dest (default 5)
forbiddenZone?: { minX: number; maxX: number; minZ: number; maxZ: number }; // path must not cross this zone
} = {}
) {
const expectPath = opts.expectPath ?? true;
const expectReach = opts.expectReach ?? true;
const maxDist = opts.maxDist ?? 5;
const straightLineDist = Math.sqrt((destX - srcX) ** 2 + (destZ - srcZ) ** 2);
const t0 = performance.now();
const path = findLongPath(0, srcX, srcZ, destX, destZ);
const ms = performance.now() - t0;
if (!expectPath) {
timings.push({ label, method: 'findLongPath', ms, straightLineDist, waypoints: path.length, reached: path.length === 0 });
if (path.length === 0) {
console.log(` PASS: ${label} (no path as expected) [${ms.toFixed(1)}ms]`);
passed++;
} else {
console.log(` FAIL: ${label} (expected no path but got ${path.length} waypoints) [${ms.toFixed(1)}ms]`);
failed++;
}
return;
}
if (path.length === 0) {
timings.push({ label, method: 'findLongPath', ms, straightLineDist, waypoints: 0, reached: false });
console.log(` FAIL: ${label} (no path found) [${ms.toFixed(1)}ms]`);
failed++;
return;
}
const last = path[path.length - 1]!;
const dist = Math.sqrt(Math.pow(last.x - destX, 2) + Math.pow(last.z - destZ, 2));
const reached = dist <= maxDist;
timings.push({ label, method: 'findLongPath', ms, straightLineDist, waypoints: path.length, reached });
if (expectReach && !reached) {
console.log(` FAIL: ${label} (path ends at (${last.x}, ${last.z}), ${dist.toFixed(0)} tiles away from dest) [${ms.toFixed(1)}ms]`);
failed++;
return;
}
// Check forbidden zone
if (opts.forbiddenZone) {
const fz = opts.forbiddenZone;
for (const wp of path) {
if (wp.x >= fz.minX && wp.x <= fz.maxX && wp.z >= fz.minZ && wp.z <= fz.maxZ) {
console.log(` FAIL: ${label} (path crosses forbidden zone at (${wp.x}, ${wp.z})) [${ms.toFixed(1)}ms]`);
failed++;
return;
}
}
}
console.log(` PASS: ${label} (${path.length} waypoints, ends at (${last.x}, ${last.z}), dist: ${dist.toFixed(0)}) [${ms.toFixed(1)}ms]`);
passed++;
}
console.log('\n--- Wall Avoidance Tests ---');
// Falador is walled. The east wall runs roughly x=2946 from z=3307 to z=3442
// South wall z~3307 from x=2900 to x=2946
// Paths should go through gates, not through walls
test('East approach to Falador center',
3007, 3360, 2964, 3378);
test('Lumbridge to Falador center (long distance)',
3222, 3218, 2964, 3378, { maxDist: 10 });
test('Falador east to Falador west bank',
2946, 3368, 2943, 3368);
test('North of Falador to Falador center',
2965, 3460, 2964, 3378);
console.log('\n--- Door Passthrough Tests ---');
// Doors should be passable (their wall collision is masked)
test('Through Lumbridge castle doors',
3222, 3215, 3205, 3210);
test('Varrock SE gate area',
3270, 3400, 3255, 3400);
test('Lumbridge cow field gate',
3253, 3275, 3253, 3265);
console.log('\n--- Permanent Wall Tests ---');
// These should test that permanent walls (not doors) are still blocked
// Check some tiles on Falador wall are actually blocked
const faladorWallTiles = [
[2944, 3335], // east wall area
[2944, 3360],
[2945, 3370],
[2946, 3365],
];
let wallsBlocked = 0;
for (const [x, z] of faladorWallTiles) {
// Check if any wall flag is set on this tile
const hasWall = isFlagged(x, z, 0,
CollisionFlag.WALL_NORTH | CollisionFlag.WALL_EAST |
CollisionFlag.WALL_SOUTH | CollisionFlag.WALL_WEST);
if (hasWall) wallsBlocked++;
}
if (wallsBlocked > 0) {
console.log(` PASS: Falador east wall detected (${wallsBlocked}/${faladorWallTiles.length} tiles have wall flags)`);
passed++;
} else {
console.log(` FAIL: Falador east wall not detected (0/${faladorWallTiles.length} tiles have wall flags)`);
failed++;
}
// Check Lumbridge castle door tiles are walkable (walls removed by door mask)
const lumbridgeDoorTile = [3217, 3218]; // known door position
const doorWalkable = isTileWalkable(0, lumbridgeDoorTile[0], lumbridgeDoorTile[1]);
if (doorWalkable) {
console.log(` PASS: Lumbridge castle door tile is walkable (door mask working)`);
passed++;
} else {
// Door masking removes wall flags but tile might still have LOC flag
// That's OK - the pathfinder handles LOC collision differently from walls
console.log(` INFO: Lumbridge castle door tile not fully walkable (may have LOC flag, which is normal)`);
passed++; // still a pass - the path test above validates routing works
}
console.log('\n--- Multi-Segment Long Distance Tests ---');
// These destinations are >256 tiles from source, requiring multi-segment routing
function testMultiSegment(
label: string,
srcX: number, srcZ: number,
destX: number, destZ: number,
opts: { maxDist?: number } = {}
) {
const maxDist = opts.maxDist ?? 15;
const straightLineDist = Math.sqrt((destX - srcX) ** 2 + (destZ - srcZ) ** 2);
const t0 = performance.now();
const path = findMultiSegmentPath(0, srcX, srcZ, destX, destZ);
const ms = performance.now() - t0;
if (path.length === 0) {
timings.push({ label, method: 'findMultiSegmentPath', ms, straightLineDist, waypoints: 0, reached: false });
console.log(` FAIL: ${label} (no path found) [${ms.toFixed(1)}ms]`);
failed++;
return;
}
const last = path[path.length - 1]!;
const dist = Math.sqrt(Math.pow(last.x - destX, 2) + Math.pow(last.z - destZ, 2));
const reached = dist <= maxDist;
timings.push({ label, method: 'findMultiSegmentPath', ms, straightLineDist, waypoints: path.length, reached });
if (!reached) {
console.log(` FAIL: ${label} (path ends at (${last.x}, ${last.z}), ${dist.toFixed(0)} tiles away) [${ms.toFixed(1)}ms]`);
failed++;
return;
}
console.log(` PASS: ${label} (${path.length} waypoints, ends at (${last.x}, ${last.z}), dist: ${dist.toFixed(0)}) [${ms.toFixed(1)}ms]`);
passed++;
}
// Melzar's Maze to Yanille (~340 tiles)
testMultiSegment("Melzar's Maze to Yanille",
2923, 3206, 2605, 3090, { maxDist: 20 });
// Lumbridge to Ardougne (~560 tiles)
testMultiSegment('Lumbridge to Ardougne',
3222, 3218, 2662, 3305, { maxDist: 20 });
// Varrock to Falador (~250 tiles, borderline single-segment)
testMultiSegment('Varrock to Falador',
3210, 3428, 2964, 3378, { maxDist: 15 });
// Lumbridge to Draynor (short distance, should still work via multi-segment)
testMultiSegment('Lumbridge to Draynor (short, via multi-segment)',
3222, 3218, 3092, 3243, { maxDist: 10 });
console.log('\n--- Door Detection Along Path Tests ---');
// Verify that doors are detected along paths that cross through buildings
const lumbCastlePath = findLongPath(0, 3222, 3215, 3205, 3210);
const lumbDoors = findDoorsAlongPath(lumbCastlePath);
if (lumbDoors.length > 0) {
console.log(` PASS: Lumbridge castle path detects ${lumbDoors.length} door(s) at: ${lumbDoors.map(d => `(${d.x},${d.z})`).join(', ')}`);
passed++;
} else {
console.log(` FAIL: Lumbridge castle path detected 0 doors (expected >0)`);
failed++;
}
// A path that doesn't cross any doors shouldn't detect any
const openFieldPath = findLongPath(0, 3200, 3200, 3210, 3200);
const openFieldDoors = findDoorsAlongPath(openFieldPath);
if (openFieldDoors.length === 0) {
console.log(` PASS: Open field path detects 0 doors (as expected)`);
passed++;
} else {
console.log(` INFO: Open field path detected ${openFieldDoors.length} door(s) — might be near buildings (non-critical)`);
passed++; // not a hard failure
}
console.log('\n--- Water / Ocean Avoidance Tests ---');
// Known water/ocean tiles should NOT be walkable land
const waterTestTiles: Array<{ label: string; x: number; z: number }> = [
{ label: 'Ocean south of Lumbridge', x: 3200, z: 3100 },
{ label: 'Open ocean far west', x: 2200, z: 3000 },
{ label: 'Ocean south of Rimmington', x: 2880, z: 3130 },
{ label: 'Deep ocean far south', x: 2700, z: 2900 },
];
for (const { label, x, z } of waterTestTiles) {
const allocated = isZoneAllocated(0, x, z);
const walkable = isTileWalkable(0, x, z);
const likelyLand = isZoneLikelyLand(0, x, z);
if (!allocated) {
console.log(` PASS: ${label} (${x}, ${z}) — zone not allocated (unreachable)`);
passed++;
} else if (!walkable) {
console.log(` PASS: ${label} (${x}, ${z}) — tile flagged as blocked`);
passed++;
} else if (!likelyLand) {
console.log(` PASS: ${label} (${x}, ${z}) — empty zone rejected by isZoneLikelyLand`);
passed++;
} else {
console.log(` FAIL: ${label} (${x}, ${z}) — appears walkable (allocated=${allocated}, walkable=${walkable}, likelyLand=${likelyLand})`);
failed++;
}
}
// Known land tiles should still be walkable
const landTestTiles: Array<{ label: string; x: number; z: number }> = [
{ label: 'Lumbridge center', x: 3222, z: 3218 },
{ label: 'Falador center', x: 2964, z: 3378 },
{ label: 'Varrock east road', x: 3220, z: 3430 },
{ label: 'Ardougne market', x: 2662, z: 3305 },
];
for (const { label, x, z } of landTestTiles) {
const walkable = isTileWalkable(0, x, z);
const likelyLand = isZoneLikelyLand(0, x, z);
if (walkable && likelyLand) {
console.log(` PASS: ${label} (${x}, ${z}) — correctly identified as walkable land`);
passed++;
} else {
console.log(` FAIL: ${label} (${x}, ${z}) — not walkable (walkable=${walkable}, likelyLand=${likelyLand})`);
failed++;
}
}
console.log('\n--- Spawn to Varrock Center Tests ---');
// Spawn point (3244, 3395) is SE of Varrock, near the south-east gate.
// Varrock center (fountain/square) is around (3213, 3428).
// This route must navigate through Varrock's SE gate area.
test('Spawn (3244,3395) to Varrock center via findLongPath',
3244, 3395, 3213, 3428, { maxDist: 5 });
testMultiSegment('Spawn (3244,3395) to Varrock center via multi-segment',
3244, 3395, 3213, 3428, { maxDist: 10 });
// Verify doors are detected on this route (Varrock gates)
const spawnToVarrockPath = findLongPath(0, 3244, 3395, 3213, 3428);
if (spawnToVarrockPath.length > 0) {
const doors = findDoorsAlongPath(spawnToVarrockPath);
console.log(` INFO: Spawn→Varrock path: ${spawnToVarrockPath.length} waypoints, ${doors.length} door(s) detected${doors.length > 0 ? ` at: ${doors.map(d => `(${d.x},${d.z})`).join(', ')}` : ''}`);
} else {
console.log(` INFO: Spawn→Varrock path: no path found (covered by test above)`);
}
console.log('\n--- Cross-Continent Multi-Segment Tests ---');
// Wizards Tower to Tree Gnome Stronghold — the original failing route (~700 tiles)
testMultiSegment('Wizards Tower to Tree Gnome Stronghold',
3109, 3162, 2450, 3420, { maxDist: 35 });
// Lumbridge to Yanille (southwest, must avoid ocean)
testMultiSegment('Lumbridge to Yanille',
3222, 3218, 2605, 3090, { maxDist: 25 });
// ── Performance Summary ──────────────────────────────────────────────────────
console.log('\n========== PERFORMANCE ==========');
// Table header
const col = { label: 45, method: 22, ms: 10, dist: 10, wps: 6, rate: 12 };
console.log(
'Route'.padEnd(col.label) +
'Method'.padEnd(col.method) +
'Time'.padStart(col.ms) +
'Dist'.padStart(col.dist) +
'Wps'.padStart(col.wps) +
'Tiles/ms'.padStart(col.rate)
);
console.log('-'.repeat(col.label + col.method + col.ms + col.dist + col.wps + col.rate));
for (const t of timings) {
const rate = t.ms > 0 ? (t.straightLineDist / t.ms).toFixed(1) : '-';
console.log(
t.label.slice(0, col.label - 1).padEnd(col.label) +
t.method.padEnd(col.method) +
`${t.ms.toFixed(1)}ms`.padStart(col.ms) +
`${t.straightLineDist.toFixed(0)}`.padStart(col.dist) +
`${t.waypoints}`.padStart(col.wps) +
rate.padStart(col.rate)
);
}
// Aggregates
const longPathTimings = timings.filter(t => t.method === 'findLongPath');
const multiSegTimings = timings.filter(t => t.method === 'findMultiSegmentPath');
const allMs = timings.map(t => t.ms);
const totalMs = allMs.reduce((a, b) => a + b, 0);
console.log('-'.repeat(col.label + col.method + col.ms + col.dist + col.wps + col.rate));
console.log(`Total pathfinding time: ${totalMs.toFixed(1)}ms across ${timings.length} calls`);
if (longPathTimings.length > 0) {
const lms = longPathTimings.map(t => t.ms);
console.log(` findLongPath: avg ${(lms.reduce((a, b) => a + b, 0) / lms.length).toFixed(1)}ms, max ${Math.max(...lms).toFixed(1)}ms (${longPathTimings.length} calls)`);
}
if (multiSegTimings.length > 0) {
const mms = multiSegTimings.map(t => t.ms);
console.log(` findMultiSegmentPath: avg ${(mms.reduce((a, b) => a + b, 0) / mms.length).toFixed(1)}ms, max ${Math.max(...mms).toFixed(1)}ms (${multiSegTimings.length} calls)`);
}
// Flag anything that seems slow (>100ms for a single call)
const slow = timings.filter(t => t.ms > 100);
if (slow.length > 0) {
console.log(`\nSlow calls (>100ms):`);
for (const t of slow) {
console.log(` ${t.label}: ${t.ms.toFixed(1)}ms (${t.straightLineDist.toFixed(0)} tiles)`);
}
}
// ── Test Results ──────────────────────────────────────────────────────────────
console.log(`\n========== RESULTS ==========`);
console.log(`Passed: ${passed}/${passed + failed}`);
console.log(`Failed: ${failed}/${passed + failed}`);
if (failed > 0) {
console.log('\nFailed tests:');
for (const t of timings.filter(t => !t.reached)) {
console.log(` - ${t.label}`);
}
process.exit(1);
}
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env bun
/**
* Pathfinding Test - Validates walkTo routing through walls, doors, water, and long distances.
* Uses a real bot spawned at Lumbridge to test actual in-game navigation.
*/
import { runTest } from './utils/test-runner';
import { Locations } from './utils/save-generator';
interface TestResult {
name: string;
passed: boolean;
message: string;
}
const results: TestResult[] = [];
function record(name: string, passed: boolean, message: string) {
results.push({ name, passed, message });
console.log(` ${passed ? 'PASS' : 'FAIL'}: ${name} — ${message}`);
}
// Spawn outside Lumbridge castle courtyard to avoid getting trapped by the door
const LUMBRIDGE_OUTSIDE = { x: 3222, z: 3240 };
runTest({
name: 'Pathfinding Test',
saveConfig: {
position: LUMBRIDGE_OUTSIDE,
skills: { Agility: 99 },
varps: { 281: 1000 },
},
launchOptions: { skipTutorial: true },
}, async ({ sdk, bot }) => {
await sdk.waitForCondition(s => (s?.player?.worldX ?? 0) > 0, 10000);
const pos = () => {
const s = sdk.getState()?.player;
return { x: s?.worldX ?? 0, z: s?.worldZ ?? 0 };
};
const distTo = (x: number, z: number) => {
const p = pos();
return Math.sqrt((p.x - x) ** 2 + (p.z - z) ** 2);
};
// ── Short-range navigation ──────────────────────────────────────────
console.log('\n--- Short-Range Navigation ---');
// Lumbridge courtyard to general store (~10 tiles)
{
const dest = { x: 3212, z: 3246 };
const result = await bot.walkTo(dest.x, dest.z, 5);
const dist = distTo(dest.x, dest.z);
record('Lumbridge to general store',
result.success && dist <= 10,
`dist=${dist.toFixed(0)}, ${result.message}`);
}
// ── Door passthrough ────────────────────────────────────────────────
console.log('\n--- Door Passthrough ---');
// Walk into Lumbridge castle (requires going through doors)
{
const dest = { x: 3205, z: 3210 };
const result = await bot.walkTo(dest.x, dest.z, 5);
const dist = distTo(dest.x, dest.z);
record('Through Lumbridge castle doors',
result.success && dist <= 10,
`dist=${dist.toFixed(0)}, ${result.message}`);
}
// Walk back out of the castle
{
const dest = LUMBRIDGE_OUTSIDE;
const result = await bot.walkTo(dest.x, dest.z, 5);
const dist = distTo(dest.x, dest.z);
record('Back out of Lumbridge castle',
result.success && dist <= 10,
`dist=${dist.toFixed(0)}, ${result.message}`);
}
// ── Medium-range: Lumbridge to Draynor (~130 tiles) ─────────────────
console.log('\n--- Medium-Range Navigation ---');
{
const dest = { x: 3092, z: 3243 };
const result = await bot.walkTo(dest.x, dest.z, 15);
const dist = distTo(dest.x, dest.z);
record('Lumbridge to Draynor',
result.success && dist <= 20,
`dist=${dist.toFixed(0)}, ${result.message}`);
}
// ── Long-range: Draynor to Falador (~170 tiles) ─────────────────────
console.log('\n--- Long-Range Navigation ---');
{
const dest = { x: 2964, z: 3378 };
const result = await bot.walkTo(dest.x, dest.z, 20);
const dist = distTo(dest.x, dest.z);
record('Draynor to Falador',
dist <= 30,
`dist=${dist.toFixed(0)}, ${result.message}`);
}
// ── Wall avoidance: move around inside Falador ──────────────────────
console.log('\n--- Wall Avoidance (Falador) ---');
// Falador is walled — walking east from center should stay inside or exit via gate
{
const dest = { x: 2946, z: 3368 };
const result = await bot.walkTo(dest.x, dest.z, 10);
const dist = distTo(dest.x, dest.z);
record('Falador center to east wall area',
dist <= 15,
`dist=${dist.toFixed(0)}, ${result.message}`);
}
// ── Cross-city: Falador to Varrock (~250 tiles) ─────────────────────
console.log('\n--- Cross-City Navigation ---');
{
const dest = { x: 3213, z: 3428 };
const result = await bot.walkTo(dest.x, dest.z, 20);
const dist = distTo(dest.x, dest.z);
record('Falador to Varrock center',
dist <= 30,
`dist=${dist.toFixed(0)}, ${result.message}`);
}
// ── Gate handling: Varrock has gated entries ─────────────────────────
console.log('\n--- Gate Handling (Varrock) ---');
// Walk to Varrock SE gate area and back to center
{
const gate = { x: 3270, z: 3400 };
const result1 = await bot.walkTo(gate.x, gate.z, 10);
const dist1 = distTo(gate.x, gate.z);
const center = { x: 3213, z: 3428 };
const result2 = await bot.walkTo(center.x, center.z, 10);
const dist2 = distTo(center.x, center.z);
record('Varrock gate area round-trip',
dist1 <= 15 && dist2 <= 15,
`to gate: dist=${dist1.toFixed(0)} (${result1.message}), back: dist=${dist2.toFixed(0)} (${result2.message})`);
}
// ── Water avoidance: should not walk into ocean south of Lumbridge ───
console.log('\n--- Water Avoidance ---');
// Walk from Varrock back toward Lumbridge — should not path through water
{
const dest = { x: 3222, z: 3218 };
const result = await bot.walkTo(dest.x, dest.z, 15);
const dist = distTo(dest.x, dest.z);
record('Varrock to Lumbridge (avoid water)',
dist <= 25,
`dist=${dist.toFixed(0)}, ${result.message}`);
}
// ── Summary ─────────────────────────────────────────────────────────
console.log('\n========== RESULTS ==========');
let passed = 0;
let failed = 0;
for (const r of results) {
if (r.passed) passed++; else failed++;
}
console.log(`Passed: ${passed}/${results.length}`);
console.log(`Failed: ${failed}/${results.length}`);
if (failed > 0) {
console.log('\nFailed tests:');
for (const r of results.filter(r => !r.passed)) {
console.log(` - ${r.name}: ${r.message}`);
}
}
return failed === 0;
});
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env bun
/**
* Walk Test: Spawn at (3244, 3395) and walk to Varrock center.
* Tests pathfinding through Varrock's SE gate area with a fresh character.
*/
import { runTest } from './utils/test-runner';
const SPAWN = { x: 3244, z: 3395 };
const VARROCK_CENTER = { x: 3213, z: 3428 };
runTest({
name: 'Walk to Varrock Center',
saveConfig: {
position: SPAWN,
varps: { 281: 1000 },
},
launchOptions: { skipTutorial: true },
}, async ({ sdk, bot }) => {
await sdk.waitForCondition(s => (s?.player?.worldX ?? 0) > 0, 10000);
const pos = () => {
const s = sdk.getState()?.player;
return { x: s?.worldX ?? 0, z: s?.worldZ ?? 0 };
};
const distTo = (x: number, z: number) => {
const p = pos();
return Math.sqrt((p.x - x) ** 2 + (p.z - z) ** 2);
};
const start = pos();
console.log(`Spawned at (${start.x}, ${start.z})`);
console.log(`Target: Varrock center (${VARROCK_CENTER.x}, ${VARROCK_CENTER.z})`);
console.log(`Distance: ${distTo(VARROCK_CENTER.x, VARROCK_CENTER.z).toFixed(0)} tiles\n`);
const t0 = Date.now();
const result = await bot.walkTo(VARROCK_CENTER.x, VARROCK_CENTER.z, 5);
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
const final = pos();
const dist = distTo(VARROCK_CENTER.x, VARROCK_CENTER.z);
console.log(`\n=== RESULT ===`);
console.log(`walkTo success: ${result.success}`);
console.log(`Message: ${result.message}`);
console.log(`Final position: (${final.x}, ${final.z})`);
console.log(`Distance from target: ${dist.toFixed(1)} tiles`);
console.log(`Time: ${elapsed}s`);
const passed = result.success && dist <= 5;
console.log(`Verdict: ${passed ? 'PASS' : 'FAIL'}`);
return passed;
});
+1 -1
View File
@@ -543,7 +543,7 @@ module.exports.__wbindgen_init_externref_table = function() {
;
};
const path = require('path').join(__dirname, 'rsmod-pathfinder_bg.wasm');
const path = require('path').join(__dirname, 'rsmod_bg.wasm');
const bytes = require('fs').readFileSync(path);
const wasmModule = new WebAssembly.Module(bytes);
Binary file not shown.
Binary file not shown.
+7 -6
View File
@@ -13,13 +13,14 @@ git clone --depth 1 "$REPO_URL" "$TMP_DIR"
echo "Building WASM with wasm-pack..."
cd "$TMP_DIR"
wasm-pack build --target nodejs --out-dir dist
wasm-pack build --target nodejs --release
echo "Copying dist files to vendor..."
cp dist/rsmod-pathfinder.js "$SCRIPT_DIR/"
cp dist/rsmod-pathfinder.d.ts "$SCRIPT_DIR/"
cp dist/rsmod-pathfinder_bg.wasm "$SCRIPT_DIR/"
cp dist/rsmod-pathfinder_bg.wasm.d.ts "$SCRIPT_DIR/"
echo "Copying pkg files to vendor..."
# wasm-pack outputs files named after the [lib] name in Cargo.toml ("rsmod")
cp pkg/rsmod.js "$SCRIPT_DIR/rsmod-pathfinder.js"
cp pkg/rsmod.d.ts "$SCRIPT_DIR/rsmod-pathfinder.d.ts"
cp pkg/rsmod_bg.wasm "$SCRIPT_DIR/rsmod_bg.wasm"
cp pkg/rsmod_bg.wasm.d.ts "$SCRIPT_DIR/rsmod_bg.wasm.d.ts"
echo "Cleaning up..."
rm -rf "$TMP_DIR"