feat(sdk): option-aware loc resolution fixes the duplicate-furnace trap

Overlapping duplicate locs are real content: the Lumbridge furnace pairs an
inert, option-less 'Furnace' loc with the one offering Smelt, and plain
name matching returns whichever is nearer - so interactLoc(/furnace/i,
'smelt') dead-ended with 'No matching option on Furnace' while a usable
furnace stood one tile away (bug reports 2026-07-27 and 2026-08-08; the
latter's fork-only bot.smelt helper broke on exactly this).

findNearbyLoc now takes {withOption} to consider only locs publishing a
matching interaction option, and interactLoc resolves its target through
that filter whenever the option is named (falling back to plain matching so
the no-matching-option error still names what it found).

Verified live at the Lumbridge furnace: both duplicate locs confirmed
( (3225,3256) with no options, (3226,3255) with Smelt ), withOption picks
the smeltable one, and interactLoc(/furnace/i, 'smelt') opens the smelting
interface.

The same reports' bot.mine default-target complaint targets a helper that
only exists in a fork, not in this repo - nothing to fix here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Fable 5 committed 2026-08-11 11:29:04 -04:00
1 parent b3c19c5abf
commit d447f76077
5 files changed
+98 -9

No files matched your search

+14 -6
View File
@@ -501,15 +501,23 @@ export class ActionHelpers {
resolveLocation(
target: NearbyLoc | string | RegExp | undefined,
defaultPattern: RegExp
defaultPattern: RegExp,
withOption?: string | RegExp
): NearbyLoc | null {
if (!target) {
return this.sdk.findNearbyLoc(defaultPattern);
}
if (typeof target === 'object' && 'x' in target) {
if (typeof target === 'object' && target !== null && 'x' in target) {
return target;
}
return this.sdk.findNearbyLoc(target);
const pattern = target ?? defaultPattern;
if (withOption !== undefined) {
// Prefer a loc that actually offers the requested option -
// overlapping option-less duplicates (Lumbridge furnace) otherwise
// win on distance and dead-end the interaction. Fall back to plain
// name matching so the "no matching option" error can still name
// the loc it found.
const withOptionMatch = this.sdk.findNearbyLoc(pattern, { withOption });
if (withOptionMatch) return withOptionMatch;
}
return this.sdk.findNearbyLoc(pattern);
}
resolveInventoryItem(
+4 -2
View File
@@ -3428,7 +3428,9 @@ export class BotActions {
): Promise<InteractLocResult> {
await this.dismissBlockingUI();
const loc = this.helpers.resolveLocation(target, /./);
// A named option narrows resolution to locs that actually offer it.
const optionFilter = typeof option === 'number' ? undefined : option;
const loc = this.helpers.resolveLocation(target, /./, optionFilter);
if (!loc) {
return { success: false, message: `Location not found: ${target}`, reason: 'loc_not_found' };
}
@@ -3459,7 +3461,7 @@ export class BotActions {
const locPattern = target instanceof RegExp || typeof target === 'string'
? target
: exactNamePattern(loc.name);
const locNow = this.helpers.resolveLocation(locPattern, /./);
const locNow = this.helpers.resolveLocation(locPattern, /./, optionFilter);
if (!locNow) {
return { success: false, message: `${loc.name} no longer visible`, reason: 'loc_not_found' };
}
+10 -1
View File
@@ -906,7 +906,16 @@ export class BotSDK {
/** Find location by name pattern (shortest matching name wins, then nearest; reachable preferred). */
findNearbyLoc(pattern: string | RegExp, options?: FindOptions): NearbyLoc | null {
if (!this.state) return null;
return applyFindOptions(shortestNameMatch(this.state.nearbyLocs, pattern), options);
let candidates = this.state.nearbyLocs;
if (options?.withOption !== undefined) {
const optionPattern = options.withOption;
const regex = typeof optionPattern === 'string' ? new RegExp(optionPattern, 'i') : optionPattern;
candidates = candidates.filter(loc => loc.optionsWithIndex?.some(o => {
regex.lastIndex = 0;
return regex.test(o.text);
}));
}
return applyFindOptions(shortestNameMatch(candidates, pattern), options);
}
/** Get all nearby locations (trees, rocks, etc). */
+63
View File
@@ -0,0 +1,63 @@
/**
* Logic test for findNearbyLoc's withOption filter - no bot required.
*
* Overlapping duplicate locs are real: the Lumbridge furnace pairs an inert,
* option-less "Furnace" with the one offering Smelt, and the inert one often
* wins plain name matching on distance - dead-ending interactLoc with
* "No matching option on Furnace" while a usable furnace stands 1 tile away.
*/
import { describe, expect, test } from 'bun:test';
import { BotSDK } from '../index';
import type { BotWorldState, NearbyLoc } from '../types';
function makeLoc(partial: Partial<NearbyLoc>): NearbyLoc {
return {
id: 2781,
name: 'Furnace',
x: 0,
z: 0,
distance: 0,
optionsWithIndex: [],
...partial,
} as NearbyLoc;
}
function mountSdk(locs: NearbyLoc[]): BotSDK {
const sdk = new BotSDK({ botUsername: 'test' });
(sdk as any).state = { nearbyLocs: locs } as unknown as BotWorldState;
return sdk;
}
describe('findNearbyLoc({ withOption })', () => {
// Nearest-first list order, inert duplicate first - the reported trap.
const inert = makeLoc({ x: 3225, z: 3256, distance: 1, optionsWithIndex: [] });
const smeltable = makeLoc({ x: 3226, z: 3255, distance: 2, optionsWithIndex: [{ text: 'Smelt', opIndex: 2 }] });
test('plain matching still returns the nearer, inert duplicate', () => {
const sdk = mountSdk([inert, smeltable]);
expect(sdk.findNearbyLoc(/furnace/i)).toBe(inert);
});
test('withOption skips duplicates that do not offer the option', () => {
const sdk = mountSdk([inert, smeltable]);
expect(sdk.findNearbyLoc(/furnace/i, { withOption: /smelt/i })).toBe(smeltable);
});
test('string option filters case-insensitively', () => {
const sdk = mountSdk([inert, smeltable]);
expect(sdk.findNearbyLoc(/furnace/i, { withOption: 'SMELT' })).toBe(smeltable);
});
test('returns null when nothing offers the option', () => {
const sdk = mountSdk([inert]);
expect(sdk.findNearbyLoc(/furnace/i, { withOption: /smelt/i })).toBeNull();
});
test('a stateful /g regex cannot alternate matches across calls', () => {
const sdk = mountSdk([inert, smeltable]);
const sticky = /smelt/gi;
expect(sdk.findNearbyLoc(/furnace/i, { withOption: sticky })).toBe(smeltable);
expect(sdk.findNearbyLoc(/furnace/i, { withOption: sticky })).toBe(smeltable);
});
});
+7
View File
@@ -150,6 +150,13 @@ export interface FindOptions {
* one. Targets whose reachability is unknown always qualify.
*/
reachable?: boolean;
/**
* Loc lookups only: consider only locs that publish a matching interaction
* option. Overlapping duplicates are real - the Lumbridge furnace pairs an
* inert, option-less "Furnace" with the one that offers Smelt, and plain
* name matching happily returns the inert one.
*/
withOption?: string | RegExp;
}
export interface LocOption {