feat(client): walkTo reports out-of-range destinations explicitly

A destination beyond the 104x104 build area used to be silently clamped
to the scene edge and reported as a plain success, so a caller could not
tell a full route from a partial leg - the lite fleet worked around it
with a hand-rolled Traveller (bug report msjgy9cl). Both clients'
walkTo now return {moved, outOfRange}, and the ActionExecutor surfaces
reason 'out_of_range' / a leg-to-edge message instead of pretending the
route was complete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Fable 5 committed 2026-08-12 12:46:53 -04:00
1 parent 035c64a9ad
commit 697b43933d
5 files changed
+53 -19

No files matched your search

+15 -6
View File
@@ -48,13 +48,22 @@ export class ActionExecutor {
return { success: true, message: `Waiting ${action.ticks || 1} ticks` };
case 'walkTo': {
const result = this.wrapBool(
this.client.walkTo(action.x, action.z, action.running ?? true),
`Walking to (${action.x}, ${action.z})`,
'Failed to walk'
);
// walkTo already sets its own cross visual via projectTileToScreen
return result;
const walk = this.client.walkTo(action.x, action.z, action.running ?? true);
if (!walk.moved) {
return walk.outOfRange
? { success: false, message: `(${action.x}, ${action.z}) is outside the current build area and no path leads toward its edge`, phase: 'validation', reason: 'out_of_range' }
: { success: false, message: 'Failed to walk', phase: 'validation', reason: 'client_rejected' };
}
if (walk.outOfRange) {
return {
success: true,
message: `Walking toward (${action.x}, ${action.z}) - outside the current build area, this is a leg to the scene edge`,
phase: 'dispatch',
data: { outOfRange: true }
};
}
return { success: true, message: `Walking to (${action.x}, ${action.z})`, phase: 'dispatch' };
}
case 'talkToNpc': {
+12
View File
@@ -158,6 +158,18 @@ export interface NearbyLoc {
reachable?: boolean;
}
/**
* Result of Client/LiteClient.walkTo. `moved` reports whether a MOVE packet
* was emitted. `outOfRange` reports that the destination lies outside the
* current 104x104 build area - the emitted path (if any) is then only a leg
* toward the scene edge, not a route to the destination; crossing the edge
* rebuilds the scene and the caller must dispatch the next leg itself.
*/
export interface WalkResult {
moved: boolean;
outOfRange: boolean;
}
export interface MenuAction {
option: string;
actionCode: number;
+10 -6
View File
@@ -1,6 +1,8 @@
import { playWave, setWaveVolume } from '#3rdparty/audio.js';
import { stopMidi, setMidiVolume, playMidi } from '#3rdparty/tinymidipcm.js';
import type { WalkResult } from '#/bot/types.js';
import ClientBuild from '#/client/ClientBuild.js';
import { ClientCode } from '#/client/ClientCode.js';
import GameShell from '#/client/GameShell.js';
@@ -2131,11 +2133,13 @@ export class Client extends GameShell {
}
/**
* Walk to a world coordinate using proper client-side pathfinding
* Walk to a world coordinate using proper client-side pathfinding.
* An out-of-scene destination is clamped to the edge and walked as a leg;
* the result says so instead of pretending the whole route was found.
*/
walkTo(worldX: number, worldZ: number, running: boolean = false): boolean {
walkTo(worldX: number, worldZ: number, running: boolean = false): WalkResult {
if (!this.ingame || !this.out || !this.localPlayer) {
return false;
return { moved: false, outOfRange: false };
}
let destSceneX = worldX - this.mapBuildBaseX;
@@ -2182,15 +2186,15 @@ export class Client extends GameShell {
this.crossCycle = 0;
}
return success;
return { moved: success, outOfRange: wasClamped };
}
/**
* Walk relative to current position
*/
walkRelative(deltaX: number, deltaZ: number, running: boolean = false): boolean {
walkRelative(deltaX: number, deltaZ: number, running: boolean = false): WalkResult {
if (!this.ingame || !this.localPlayer) {
return false;
return { moved: false, outOfRange: false };
}
const currentTileX = (this.localPlayer.x >> 7);
+12 -6
View File
@@ -39,7 +39,7 @@ import { TypedArray1d, TypedArray3d } from '#/util/Arrays.js';
import { BotStateCollector } from '#/bot/StateCollector.js';
import { ActionExecutor, type ActionResultOrPromise } from '#/bot/ActionExecutor.js';
import type { BotAction, BotWorldState } from '#/bot/types.js';
import type { BotAction, BotWorldState, WalkResult } from '#/bot/types.js';
import type { Client } from '#/client/Client.js';
import { GameConnection } from './net/GameConnection.js';
@@ -633,9 +633,9 @@ export class LiteClient {
}
/** Send a MOVE_GAMECLICK with a client-computed path. See movement.ts. */
walkTo(worldX: number, worldZ: number, running = false): boolean {
walkTo(worldX: number, worldZ: number, running = false): WalkResult {
if (!this.localPlayer) {
return false;
return { moved: false, outOfRange: false };
}
// Match Client.walkTo: a long-distance BotActions route can hand the
@@ -644,11 +644,17 @@ export class LiteClient {
// after which BotActions observes progress and dispatches the next leg.
// Keeping the one-tile border excluded also matches the browser's
// collision bounds (tiles 0 and 103 are the blocked border ring).
// `outOfRange` tells the caller this was only a leg, not a full route -
// the silent-no-op era where a far destination looked like a success
// with no signal is what bug report msjgy9cl was about.
const minBound = 1;
const maxBound = BuildArea.SIZE - 2;
const destX = Math.max(minBound, Math.min(maxBound, worldX - this.mapBuildBaseX));
const destZ = Math.max(minBound, Math.min(maxBound, worldZ - this.mapBuildBaseZ));
return findPathToTile(this, destX, destZ, running);
const rawX = worldX - this.mapBuildBaseX;
const rawZ = worldZ - this.mapBuildBaseZ;
const outOfRange = rawX < minBound || rawX > maxBound || rawZ < minBound || rawZ > maxBound;
const destX = Math.max(minBound, Math.min(maxBound, rawX));
const destZ = Math.max(minBound, Math.min(maxBound, rawZ));
return { moved: findPathToTile(this, destX, destZ, running), outOfRange };
}
// ------------------------------------------------------ outgoing plumbing
@@ -267,7 +267,7 @@ describe('outgoing action encoding', () => {
c.localPlayer!.routeZ[0] = 50;
const start = c.out.pos;
expect(c.walkTo(4000, 3250, true)).toBe(true);
expect(c.walkTo(4000, 3250, true)).toEqual({ moved: true, outOfRange: true });
const r = new Capture(c.out, start);
expect(r.g1()).toBe(ClientProt.MOVE_GAMECLICK);
@@ -276,6 +276,9 @@ describe('outgoing action encoding', () => {
expect(r.g2()).toBe(3302); // baseX + build-area max interior tile
expect(r.g2()).toBe(3250);
expect(r.at).toBe(c.out.pos);
// An in-area destination reports outOfRange false.
expect(c.walkTo(3251, 3251, true)).toEqual({ moved: true, outOfRange: false });
});
test('an actor already inside an NPC footprint needs no movement packet', () => {