Files
rs-sdk/sdk/pathfinding.ts
max bittkerandClaude Fable 5 d80066c78a fix(sdk,clients): bug-report harvest 2026-08-16 - dialog choice regression, false failures, Windows paths
78 new reports since 8/12 triaged; the tradeoff-free fixes:

- dismissBlockingUI no longer clicks multi-option choice dialogs. The 8/12
  fix (9afd6d961) made it click options[0].index, which ANSWERS a p_choice
  as option 1 before every bot action - the wrong quest branch ran and the
  caller's intended click was silently refused as stale. Root-caused via
  live repro; the published index/componentId mapping was never wrong.
  Blocks-quests class (Restless Ghost, Rune Mysteries, Al Kharid gate).
- interactLoc/useItemOnLoc/useItemOnNpc/equipItem accept real evidence:
  same-level teleports (dungeon trapdoors, z +/-6400), inventory deltas
  (cow milking, flour bin), late equipment arrival. No more false failures
  on actions that worked.
- 'interactLoc froze the event loop' root cause was lazy synchronous
  initPathfinding() (~30s on slow hosts) killing the socket mid-action:
  runner + MCP server now warm it before connecting. Runner also exits
  non-zero on signals (128+n), bounds disconnect, force-exits a wedged
  event loop, and honors a second signal immediately.
- ground items on blocked tiles: browser client now mirrors the authentic
  OP_OBJ handler (retry route with 1x1 footprint, dispatch regardless);
  lite ground-item op dispatches unconditionally too. Unblocks Monk's
  Friend / Plague City / Witch's House pickups.
- interactLoc/useItemOnLoc re-find an entity target by (id,x,z) identity,
  not name - same-named neighbours (Varrock cart crates) can't win.
- lite runner/swarm repoRoot via fileURLToPath - Windows and
  space-containing clones can run headless again (4 reports).
- smithAtAnvil: longest-key product match ('mithril longsword' no longer
  smiths a sword), metal-prefix bar selection, honest smithed-product
  report; castSpell resolves known spell names, rejects unknown ones, and
  no longer reports a never-dispatched cast as a splash; buyFromShop fails
  fast with out_of_stock/no_inventory_space; depositItem -1 with an
  alternation pattern deposits every matching id; openShop falls back to
  the nearest Trade-option NPC when called with no target; cli.ts honors
  blank SERVER= as local instead of diagnosing against the demo gateway.
- NearbyNpc publishes tileX/tileZ (authoritative route tile) and size;
  x/z are interpolated render coords, off by 1-3 tiles on movers and
  floor(size/2) on multi-tile NPCs. Also lands the isSkulled/
  autoRetaliateEnabled collector for the already-published type decls.
- Melzar's Maze funexit doors added to ONE_WAY_DOORS (verified in
  collision data) so walkTo/attack stop stranding bots.

Checked: imp red-bead drop table is fair (5/128, reachable branch) - the
report was 1-in-18 variance, no fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 23:51:08 -04:00

280 lines
10 KiB
TypeScript

// Local pathfinding using bundled collision data
import * as rsmod from '../server/vendor/rsmod-pathfinder';
import { CollisionType, CollisionFlag } from '../server/vendor/rsmod-pathfinder';
import collisionData from './collision-data.json';
let initialized = false;
interface CollisionData {
tiles: Array<[number, number, number, number]>;
zones: Array<[number, number, number]>;
doors?: Array<[number, number, number, number, number, number]>; // [level, x, z, shape, angle, blockrange]
}
export interface DoorInfo {
level: number;
x: number;
z: number;
shape: number;
angle: number;
blockrange: boolean;
}
interface TemporaryDoorBlock {
door: DoorInfo;
expiresAt: number;
}
/**
* Session-owned evidence that a door is temporarily impassable.
*
* Entries expire automatically so a transient route failure cannot poison the
* process-wide collision map for every bot until restart.
*/
export class TemporaryDoorBlocklist {
private entries = new Map<string, TemporaryDoorBlock>();
constructor(private readonly now: () => number = Date.now) {}
block(door: DoorInfo, ttlMs: number = 30_000): void {
this.entries.set(doorKey(door.level, door.x, door.z), {
door,
expiresAt: this.now() + Math.max(0, ttlMs)
});
}
active(): DoorInfo[] {
const now = this.now();
for (const [key, entry] of this.entries) {
if (entry.expiresAt <= now) this.entries.delete(key);
}
return [...this.entries.values()].map(entry => entry.door);
}
has(level: number, x: number, z: number): boolean {
this.active();
return this.entries.has(doorKey(level, x, z));
}
clear(): void {
this.entries.clear();
}
}
// Spatial index of all known door positions, keyed by "level,x,z"
const doorIndex = new Map<string, DoorInfo>();
// Zones that have at least one collision tile — zones with zero collision data
// 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 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
'0,2924,9654', // Melzar's Maze basement 'funexit' door — only opens from the maze side
'0,2927,9649', // Melzar's Maze basement 'funexit' door — only opens from the maze side
'0,2931,9640', // Melzar's Maze basement 'funexit' door — only opens from the maze side
]);
function doorKey(level: number, x: number, z: number): string {
return `${level},${x},${z}`;
}
export function initPathfinding(): void {
if (initialized) return;
const data = collisionData as CollisionData;
const start = Date.now();
// Allocate all zones first (includes walkable areas with no collision tiles)
for (const [level, zoneX, zoneZ] of data.zones) {
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);
// Track which zones have at least one collision tile (likely land, not ocean)
populatedZones.add(`${level},${x & ~7},${z & ~7}`);
}
// Remove wall collision at door/gate positions so the pathfinder
// routes through doorways while still respecting permanent walls.
// Uses rsmod.changeWall(add=false) — the same method the server uses
// when doors are opened at runtime.
let doorCount = 0;
let skippedOneWay = 0;
if (data.doors) {
for (const [level, x, z, shape, angle, blockrange] of data.doors) {
const key = doorKey(level, x, z);
// Skip one-way doors — keep their wall collision so the pathfinder
// won't route through them (entering traps the bot).
if (ONE_WAY_DOORS.has(key)) {
skippedOneWay++;
continue;
}
rsmod.changeWall(x, z, level, angle, shape, !!blockrange, false, false);
doorIndex.set(key, {
level, x, z, shape, angle, blockrange: !!blockrange
});
doorCount++;
}
}
initialized = true;
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
export function isZoneAllocated(level: number, x: number, z: number): boolean {
if (!initialized) {
initPathfinding();
}
return rsmod.isZoneAllocated(x, z, level);
}
// Find long-distance path (2048x2048 search grid, ±1024 tile reach)
export function findLongPath(
level: number,
srcX: number,
srcZ: number,
destX: number,
destZ: number,
maxWaypoints: number = 500,
blockedDoors: Iterable<DoorInfo> = []
): Array<{ x: number; z: number; level: number }> {
if (!initialized) {
initPathfinding();
}
// Re-apply only this SDK session's temporary door walls for the duration of
// this synchronous query. The shared collision snapshot is restored before
// another bot can pathfind.
const applied: DoorInfo[] = [];
try {
for (const door of blockedDoors) {
rsmod.changeWall(
door.x, door.z, door.level,
door.angle, door.shape, door.blockrange,
false, true
);
applied.push(door);
}
const waypointsRaw = rsmod.findLongPath(
level, srcX, srcZ, destX, destZ,
1, 1, 1, 0, -1, true, 0, maxWaypoints, CollisionType.NORMAL
);
return unpackWaypoints(waypointsRaw);
} finally {
for (const door of applied) {
rsmod.changeWall(
door.x, door.z, door.level,
door.angle, door.shape, door.blockrange,
false, false
);
}
}
}
// ═══════════════════════════════════════════════════════════════════════════════
// UTILITIES
// ═══════════════════════════════════════════════════════════════════════════════
/** Check if a tile is walkable (no blocking flags). */
export function isTileWalkable(level: number, x: number, z: number): boolean {
if (!initialized) initPathfinding();
return !rsmod.isFlagged(x, z, level, CollisionFlag.WALK_BLOCKED);
}
/** Check if a tile has specific collision flags set. */
export function isFlagged(x: number, z: number, level: number, masks: number): boolean {
if (!initialized) initPathfinding();
return rsmod.isFlagged(x, z, level, masks);
}
/**
* Check if the zone containing (x, z) has any collision tiles.
* Zones with zero collision data are likely open ocean/void — real walkable
* land always has some collision data (objects, walls, floor flags nearby).
*/
export function isZoneLikelyLand(level: number, x: number, z: number): boolean {
return populatedZones.has(`${level},${x & ~7},${z & ~7}`);
}
// ═══════════════════════════════════════════════════════════════════════════════
// DOOR PATH ANALYSIS — identify doors a computed path crosses through
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Given a list of waypoints, return the doors the path passes through or
* steps adjacent to (wall collision is directional so the path may step
* beside a door tile rather than onto it). Results are in path order.
*/
export function findDoorsAlongPath(
waypoints: Array<{ x: number; z: number; level: number }>
): DoorInfo[] {
const doors: DoorInfo[] = [];
const seen = new Set<string>();
for (const wp of waypoints) {
// Check the waypoint tile and its 4 cardinal neighbours
const candidates = [
doorKey(wp.level, wp.x, wp.z),
doorKey(wp.level, wp.x, wp.z + 1),
doorKey(wp.level, wp.x, wp.z - 1),
doorKey(wp.level, wp.x + 1, wp.z),
doorKey(wp.level, wp.x - 1, wp.z),
];
for (const key of candidates) {
if (!seen.has(key) && doorIndex.has(key)) {
seen.add(key);
doors.push(doorIndex.get(key)!);
}
}
}
return doors;
}
/** Look up a door at an exact position. */
export function getDoorAt(level: number, x: number, z: number): DoorInfo | undefined {
if (!initialized) initPathfinding();
return doorIndex.get(doorKey(level, x, z));
}
// Unpack waypoints from rsmod format
function unpackWaypoints(waypointsRaw: Uint32Array): Array<{ x: number; z: number; level: number }> {
const waypoints: Array<{ x: number; z: number; level: number }> = [];
for (let i = 0; i < waypointsRaw.length; i++) {
const packed = waypointsRaw[i]!;
waypoints.push({
z: packed & 0x3FFF,
x: (packed >> 14) & 0x3FFF,
level: (packed >> 28) & 0x3
});
}
return waypoints;
}