mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
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:
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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user