fix(client): dispatch NPC/player/loc ops even when client routing fails

The client-side reachability precheck in talkToNpc/interactNpc/
interactPlayer/useItemOnNpc (and the lite interactLoc/opNpc/interactPlayer/
useItemOnNpc/useItemOnLoc) returned cant_reach WITHOUT sending a packet,
which made approach-range interactions impossible from the SDK: Wormbrain's
talk-through-the-jail-bars ([apnpc1] + p_aprange(3) + lineofsight) worked
for a human click but every SDK variant refused to send (bug report
2026-08-09); the Mage Arena slashable web hit the same wall on the lite
fleet (2026-08-11).

interactLoc/useItemOnLoc/spellOn* in the browser client already dispatch
unconditionally for exactly this reason - the server owns the reach check,
and its worst-case answer is an observable "I can't reach that!" or op
rejection instead of client-side silence. This aligns every NPC/player/loc
op in both clients with that precedent and keeps the route result as
routed on the ClientActionResult.

wrapClientAction now surfaces routed:false as data.{routed:false} plus the
rate-limited unreachable warning, so loops that blacklisted targets on
cant_reach still have a signal.

Verified live: sendInteractNpc on a client-unreachable rat now dispatches
(previously silent no-op) and the engine's opRejectedCount advances -
server-side feedback where there was none. Reachable talkTo unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Fable 5 committed 2026-08-11 11:22:28 -04:00
1 parent da424c7ab8
commit ea11070dcd
5 files changed
+97 -39

No files matched your search

@@ -379,6 +379,13 @@ export class ActionExecutor {
private wrapClientAction(result: ClientActionResult, successMsg: string, failMsg: string): ActionResult {
if (result.success) {
// routed:false = the op dispatched without a client route (ap-range
// attempt); the server owns the reach check from here. Surface it
// so callers can still blacklist genuinely unreachable targets.
if (result.routed === false) {
this.warnUnreachable(successMsg);
return { success: true, message: `${successMsg} (unrouted - ap-range attempt)`, phase: 'dispatch', data: { routed: false } };
}
return { success: true, message: successMsg, phase: 'dispatch' };
}
if (result.reason === 'cant_reach') {
@@ -181,7 +181,7 @@ describe('interactPlayer', () => {
const payload: number[] = [];
expect(Client.prototype.interactPlayer.call(playerClient(opcodes, payload), 5, 2))
.toEqual({ success: true });
.toEqual({ success: true, routed: true });
expect(opcodes).toEqual([166]); // ClientProt.OPPLAYER2
expect(payload).toEqual([5]);
});
@@ -191,11 +191,56 @@ describe('interactPlayer', () => {
const payload: number[] = [];
expect(Client.prototype.interactPlayer.call(playerClient(opcodes, payload), 5, 5))
.toEqual({ success: true });
.toEqual({ success: true, routed: true });
expect(opcodes).toEqual([174]); // ClientProt.OPPLAYER5
});
});
describe('npc ap-range dispatch', () => {
function npcClient(opcodes: number[], payload: number[], routable: boolean) {
return {
ingame: true,
out: { p2: (value: number) => payload.push(value) },
localPlayer: { routeX: [1], routeZ: [1] },
npc: { 42: { routeX: [5], routeZ: [6], type: { size: 1 } } },
tryMove: () => routable,
writePacketOpcode: (opcode: number) => opcodes.push(opcode)
};
}
test('interactNpc dispatches OPNPC even when routing fails', () => {
// Wormbrain behind the Port Sarim jail bars: apnpc1 + p_aprange(3) is
// the intended talk-through-the-bars mechanic, but the tile is never
// routable. Aborting on the failed route made the interaction
// impossible from the SDK while a human click worked.
const opcodes: number[] = [];
const payload: number[] = [];
expect(Client.prototype.interactNpc.call(npcClient(opcodes, payload, false), 42, 1))
.toEqual({ success: true, routed: false });
expect(opcodes).toEqual([236]); // ClientProt.OPNPC1
expect(payload).toEqual([42]);
});
test('talkToNpc dispatches OPNPC1 even when routing fails', () => {
const opcodes: number[] = [];
const payload: number[] = [];
expect(Client.prototype.talkToNpc.call(npcClient(opcodes, payload, false), 42))
.toEqual({ success: true, routed: false });
expect(opcodes).toEqual([236]); // ClientProt.OPNPC1
});
test('routed stays true when the route succeeds', () => {
const opcodes: number[] = [];
const payload: number[] = [];
expect(Client.prototype.interactNpc.call(npcClient(opcodes, payload, true), 42, 2))
.toEqual({ success: true, routed: true });
expect(opcodes).toEqual([233]); // ClientProt.OPNPC2
});
});
describe('clickComponent', () => {
test('closes the modal for a close-type button instead of sending IF_BUTTON', () => {
// shop_template:com_77 ("Close Window"). The server has no if_button
+16 -17
View File
@@ -1072,17 +1072,19 @@ export class Client extends GameShell {
// Route with the NPC's real footprint so a 2x2 NPC is approachable from
// any of its sides; StateCollector's reach probe asks the same question
// with the same size, and the two must agree (reach.ts invariant 1).
// Attempt the route, but always dispatch - mirrors interactLoc. Approach
// ops (apnpc scripts like Wormbrain's talk-through-the-bars, p_aprange +
// lineofsight) trigger at tiles the routefinder can never reach, and the
// server owns the reach check anyway - worst case it answers "I can't
// reach that!" instead of us aborting silently.
const size = n.type?.size ?? 1;
const routed = this.tryMove(this.localPlayer.routeX[0], this.localPlayer.routeZ[0], n.routeX[0], n.routeZ[0], false, size, size, 0, 0, 0, 2);
if (!routed) {
return { success: false, reason: 'cant_reach' };
}
// Send OPNPC1 packet (Talk-to)
this.writePacketOpcode(ClientProt.OPNPC1);
this.out.p2(npcIndex);
return { success: true };
return { success: true, routed };
}
/**
@@ -1110,11 +1112,10 @@ export class Client extends GameShell {
// client to send the route to an interaction target; without it the player never
// moves and the server replies "I can't reach that!". Mirrors spellOnNpc/useItemOnNpc.
// Real footprint, same as talkToNpc - the reach probe must agree.
// Attempt the route, but always dispatch (see talkToNpc): apnpc ops are
// legitimately interactable at range through walls.
const size = n.type?.size ?? 1;
const routed = this.tryMove(this.localPlayer.routeX[0], this.localPlayer.routeZ[0], n.routeX[0], n.routeZ[0], false, size, size, 0, 0, 0, 2);
if (!routed) {
return { success: false, reason: 'cant_reach' };
}
// Send the appropriate OPNPC packet based on option index
const opcodes = [
@@ -1127,7 +1128,7 @@ export class Client extends GameShell {
this.writePacketOpcode(opcodes[optionIndex - 1]);
this.out.p2(npcIndex);
return { success: true };
return { success: true, routed };
}
/**
@@ -1153,10 +1154,9 @@ export class Client extends GameShell {
// Walk to the player first. In 274's client-routefinder mode the server expects the
// client to send the route to an interaction target; without it the player never
// moves and the server replies "I can't reach that!". Mirrors spellOnNpc/useItemOnNpc.
// Attempt the route, but always dispatch (see talkToNpc): applayer ops
// trigger at range and the server owns the reach check.
const routed = this.tryMove(this.localPlayer.routeX[0], this.localPlayer.routeZ[0], p.routeX[0], p.routeZ[0], false, 1, 1, 0, 0, 0, 2);
if (!routed) {
return { success: false, reason: 'cant_reach' };
}
const opcodes = [
ClientProt.OPPLAYER1,
@@ -1168,7 +1168,7 @@ export class Client extends GameShell {
this.writePacketOpcode(opcodes[optionIndex - 1]);
this.out.p2(playerIndex);
return { success: true };
return { success: true, routed };
}
/**
@@ -2054,6 +2054,8 @@ export class Client extends GameShell {
return { success: false, reason: 'target_not_found' };
}
// Attempt the route, but always dispatch (see talkToNpc/useItemOnLoc):
// apnpcu ops trigger at range and the server owns the reach check.
const npcSize = n.type?.size ?? 1;
const routed = this.tryMove(
this.localPlayer.routeX[0],
@@ -2062,11 +2064,8 @@ export class Client extends GameShell {
n.routeZ[0],
false, npcSize, npcSize, 0, 0, 0, 2
);
if (!routed) {
return { success: false, reason: 'cant_reach' };
}
console.log(`[Client] Using item ${itemObj.name} (id:${itemId}, slot ${itemSlot}) on NPC ${npcIndex}`);
console.log(`[Client] Using item ${itemObj.name} (id:${itemId}, slot ${itemSlot}) on NPC ${npcIndex}${routed ? '' : ' (unrouted)'}`);
this.writePacketOpcode(ClientProt.OPNPCU);
this.out.p2(npcIndex);
@@ -2074,7 +2073,7 @@ export class Client extends GameShell {
this.out.p2(itemSlot);
this.out.p2(interfaceId);
return { success: true };
return { success: true, routed };
}
/**
+2
View File
@@ -69,6 +69,8 @@ const DISPATCH_ERROR_WINDOW = 500;
export interface ClientActionResult {
success: boolean;
reason?: string;
/** False when the op dispatched without a client route (ap-range attempt). */
routed?: boolean;
}
export interface LiteClientOptions {
+25 -20
View File
@@ -73,15 +73,18 @@ export function interactLoc(c: LiteClient, worldX: number, worldZ: number, locId
return { success: false, reason: 'target_not_found' };
}
if (!c.routeToLoc(dest.x, dest.z, locId)) {
return { success: false, reason: 'cant_reach' };
}
// Attempt a route, but always dispatch - mirrors the browser client.
// Approach ops (aploc scripts like Mage Arena's slashable web) trigger at
// tiles the routefinder can never reach, and the server owns the reach
// check anyway - worst case it answers "I can't reach that!" instead of
// us aborting silently.
const routed = c.routeToLoc(dest.x, dest.z, locId);
c.writeOpcode([ClientProt.OPLOC1, ClientProt.OPLOC2, ClientProt.OPLOC3, ClientProt.OPLOC4, ClientProt.OPLOC5][optionIndex - 1]);
c.out.p2(worldX);
c.out.p2(worldZ);
c.out.p2(locId);
return { success: true };
return { success: true, routed };
}
export function talkToNpc(c: LiteClient, npcIndex: number): ClientActionResult {
@@ -113,14 +116,16 @@ function opNpc(c: LiteClient, npcIndex: number, optionIndex: number): ClientActi
// reports cant_reach when the other three sides are open. StateCollector's
// reach probe uses the same size - the two have to agree or `reachable`
// starts lying.
// Attempt the route, but always dispatch - mirrors interactLoc. Approach
// ops (apnpc scripts like Wormbrain's talk-through-the-bars, p_aprange +
// lineofsight) trigger at tiles the routefinder can never reach, and the
// server owns the reach check anyway.
const size = n.type?.size ?? 1;
if (!tryMoveTo(c, n.routeX[0], n.routeZ[0], size, size, 0, 0, 0)) {
return { success: false, reason: 'cant_reach' };
}
const routed = tryMoveTo(c, n.routeX[0], n.routeZ[0], size, size, 0, 0, 0);
c.writeOpcode([ClientProt.OPNPC1, ClientProt.OPNPC2, ClientProt.OPNPC3, ClientProt.OPNPC4, ClientProt.OPNPC5][optionIndex - 1]);
c.out.p2(npcIndex);
return { success: true };
return { success: true, routed };
}
export function interactPlayer(c: LiteClient, playerIndex: number, optionIndex: number): ClientActionResult {
@@ -139,13 +144,13 @@ export function interactPlayer(c: LiteClient, playerIndex: number, optionIndex:
return { success: false, reason: 'target_not_found' };
}
if (!tryMoveTo(c, p.routeX[0], p.routeZ[0], 1, 1, 0, 0, 0)) {
return { success: false, reason: 'cant_reach' };
}
// Attempt the route, but always dispatch (see opNpc): applayer ops trigger
// at range and the server owns the reach check.
const routed = tryMoveTo(c, p.routeX[0], p.routeZ[0], 1, 1, 0, 0, 0);
c.writeOpcode([ClientProt.OPPLAYER1, ClientProt.OPPLAYER2, ClientProt.OPPLAYER3, ClientProt.OPPLAYER4, ClientProt.OPPLAYER5][optionIndex - 1]);
c.out.p2(playerIndex);
return { success: true };
return { success: true, routed };
}
export function pickupGroundItem(c: LiteClient, worldX: number, worldZ: number, itemId: number): ClientActionResult {
@@ -264,16 +269,16 @@ export function useItemOnNpc(c: LiteClient, itemSlot: number, npcIndex: number,
if (!n) {
return { success: false, reason: 'target_not_found' };
}
if (!tryMoveTo(c, n.routeX[0], n.routeZ[0], 1, 1, 0, 0, 0)) {
return { success: false, reason: 'cant_reach' };
}
// Attempt the route, but always dispatch (see opNpc): apnpcu ops trigger
// at range and the server owns the reach check.
const routed = tryMoveTo(c, n.routeX[0], n.routeZ[0], 1, 1, 0, 0, 0);
c.writeOpcode(ClientProt.OPNPCU);
c.out.p2(npcIndex);
c.out.p2(itemId);
c.out.p2(itemSlot);
c.out.p2(interfaceId);
return { success: true };
return { success: true, routed };
}
export function useItemOnLoc(c: LiteClient, itemSlot: number, worldX: number, worldZ: number, locId: number, interfaceId: number = INVENTORY_INTERFACE_ID): ClientActionResult {
@@ -291,9 +296,9 @@ export function useItemOnLoc(c: LiteClient, itemSlot: number, worldX: number, wo
if (!LocType.list(locId)) {
return { success: false, reason: 'target_not_found' };
}
if (!c.routeToLoc(dest.x, dest.z, locId)) {
return { success: false, reason: 'cant_reach' };
}
// Attempt the route, but always dispatch (see interactLoc): aplocu ops
// trigger at range and the server owns the reach check.
const routed = c.routeToLoc(dest.x, dest.z, locId);
c.writeOpcode(ClientProt.OPLOCU);
c.out.p2(worldX);
@@ -302,7 +307,7 @@ export function useItemOnLoc(c: LiteClient, itemSlot: number, worldX: number, wo
c.out.p2(itemId);
c.out.p2(itemSlot);
c.out.p2(interfaceId);
return { success: true };
return { success: true, routed };
}
export function spellOnNpc(c: LiteClient, npcIndex: number, spellComponent: number): ClientActionResult {