文件
BotStore/test/pathingService.test.js
T
lyizai fcd5fb2bd9 fix: 修复登录按钮死锁,移植旧版 player_input 补包解决「机器人走不动」
控制台登录按钮点不动(致命)
- bindEvents() 只在 startApp() 里执行,而 startApp() 只在登录成功后才跑,
  未登录时登录按钮根本没绑上事件,形成死锁。现在在 main() 里无条件绑定。

机器人走不动(致命,根因定位并修复)
- mineflayer 不发 player_input 包:1.21.2+ 服务端的移动预测依赖它,原版客户端每 tick 都发,
  而 mineflayer 只在骑乘/跳下/潜行时发,地面移动的 forward/jump/sprint 完全不发。
  带反作弊的服务器判定「没有任何输入却发生位移」,每 tick 把玩家拉回原位置。
  实测:修复前入站强制位置同步约 17 次/秒、按方向键 2 秒位移 0.00 格;
  修复后入站降到 0 次、位移 0.53~1.40 格。该修复来自旧版机器人(感谢原作者)。
- 同时补发 tick_end(原版每 tick 末尾发送,反作弊用它判定 tick 边界,mineflayer 完全不发)
- 资源包未自动接受会卡在加载界面进不了世界,现自动接受
- configure() 每次配置变更都重复注册寻路事件监听,导致同一事件被处理多次

长距离分段寻路
- mineflayer-pathfinder 只能规划已加载区块范围(实测约 8 区块 ≈ 130 格),
  扩地目标常在几十个区块外,A* 瞬间 noPath。现在自动拆段:走到已加载范围边缘 → 新区块加载 → 再规划。
  段长用 bot.blockAt 对未加载区块返回 null 的特性二分探测,不依赖世界内部结构。

新增
- pathing.thinkTimeoutMs / tickTimeoutMs / searchRadius(负值表示不限制剪枝)
- 位置包收发计数、player_input/tick_end 计数、游戏模式、维度、脚下方块、
  六向「脚部/头部/地面」与可通行判定等诊断字段
- 手动移动测试按钮(绕开寻路器直接按方向键并回报真实位移)

已知限制
- 机器人当前所在位置 (6759,72,11926) 无法寻路:A* 恒定只展开 47 个节点就 noPath。
  已逐项排除挖方块/放方块/跑酷/搭塔/开门/最大下落(试到 64 格)/高度偏好/水域代价/
  搜索半径(试到不限制)/时间预算/依赖版本(与能正常移动的旧机器人完全一致)。
  旧机器人日志证明寻路可用(2 次 goal_reached、2 秒走 8.7 格),但它当时在 y=-59,
  本机器人被 /spawn 放到了 y=72。下一步需把机器人挪到开阔地再试。

测试 290 -> 308,全部通过。
2026-09-27 23:52:29 +08:00

274 行
11 KiB
JavaScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { AltitudeGoal, PathingService, PathEvent } = require('../src/bot/pathingService');
const { GoalXZ, GoalNearXZ } = require('mineflayer-pathfinder').goals;
const { defaultConfig } = require('../src/config/expansionConfig');
const { FakeBot } = require('./helpers/fakeBot');
test('AltitudeGoal 的到达判定与内层目标一致', () => {
const inner = new GoalXZ(100, 200);
const goal = new AltitudeGoal(inner, 0.1, 64);
assert.strictEqual(goal.isEnd({ x: 100, y: 70, z: 200 }), true, '不约束高度');
assert.strictEqual(goal.isEnd({ x: 101, y: 70, z: 200 }), false);
});
test('AltitudeGoal 对高处给更低启发值、对低处给更高启发值', () => {
const goal = new AltitudeGoal(new GoalXZ(0, 0), 0.1, 64);
const high = goal.heuristic({ x: 10, y: 100, z: 10 });
const low = goal.heuristic({ x: 10, y: 20, z: 10 });
assert.ok(high < low, `高处启发值 ${high} 应小于低处 ${low}`);
// 与 GoalXZ 的差值应等于 weight × y
const base = new GoalXZ(0, 0).heuristic({ x: 10, y: 100, z: 10 });
assert.ok(Math.abs((base - high) - 0.1 * 100) < 1e-9);
});
test('AltitudeGoal 启发值被钳制为非负', () => {
const goal = new AltitudeGoal(new GoalXZ(0, 0), 10, 0);
assert.ok(goal.heuristic({ x: 0, y: 300, z: 0 }) >= 0);
});
test('AltitudeGoal 使用 GoalNearXZ 时保留其到达范围', () => {
const goal = new AltitudeGoal(new GoalNearXZ(100, 200, 2), 0.1, 64);
assert.strictEqual(goal.isEnd({ x: 100, y: 70, z: 201 }), true, '范围内算到达');
});
// ==================== 长距离分段寻路 ====================
//
// mineflayer-pathfinder 只能规划已加载区块范围(实测线上约 8 区块 ≈ 130 格),
// 而扩地目标常在几十个区块外,A* 会瞬间 noPath。分段寻路就是为这个场景写的。
/** 造一个「已加载范围」有限的 bot 与寻路服务 */
function setupSegment(options) {
const opts = options || {};
const bot = new FakeBot({ x: opts.x === undefined ? 0.5 : opts.x, y: 64, z: opts.z === undefined ? 0.5 : opts.z, loadedRadius: opts.loadedRadius === undefined ? 128 : opts.loadedRadius });
const goals = [];
bot.pathfinder = {
setMovements: () => {},
setGoal: (goal) => goals.push(goal),
thinkTimeout: 0,
tickTimeout: 0,
searchRadius: 0
};
const config = defaultConfig();
config.enforceClaimTime = false;
Object.assign(config.pathing, opts.config || {});
const service = new PathingService(bot, config, null);
service.configure();
return { bot, service, goals, config };
}
test('_probeLoadedDistance 沿方向探测到已加载范围的边缘', () => {
const { service } = setupSegment({ loadedRadius: 128 });
assert.ok(Math.abs(service._probeLoadedDistance(1, 0, 512) - 128) < 2,
'向右探测应停在已加载半径处');
assert.ok(Math.abs(service._probeLoadedDistance(0, -1, 512) - 128) < 2);
});
test('_segmentGoal 把远超已加载范围的目标裁剪到边缘附近', () => {
const { service } = setupSegment({
loadedRadius: 128,
config: { segmentMarginBlocks: 24, maxSegmentBlocks: 4096 }
});
const far = { x: 0.5 + 800, z: 0.5 };
const waypoint = service._segmentGoal(far);
assert.ok(waypoint.x < far.x, '必须裁剪,否则寻路器会瞬间 noPath');
assert.ok(waypoint.x > 0.5, '不能原地不动');
assert.ok(Math.abs(waypoint.x - (0.5 + 128 - 24)) < 2, `段长应约为 已加载半径 - 边距,实际 ${waypoint.x}`);
assert.ok(Math.abs(waypoint.z - 0.5) < 1, '方向应保持指向目标');
});
test('_segmentGoal 的段长同时受「单段上限」约束', () => {
const { service } = setupSegment({
loadedRadius: 512,
config: { segmentMarginBlocks: 24, maxSegmentBlocks: 64 }
});
const waypoint = service._segmentGoal({ x: 0.5 + 800, z: 0.5 });
assert.ok(Math.abs(waypoint.x - (0.5 + 64)) < 3,
`即使已加载范围很大,单段也不该超过 maxSegmentBlocks,实际 ${waypoint.x}`);
});
test('_segmentGoal 目标在已加载范围内时不做裁剪', () => {
const { service } = setupSegment({ loadedRadius: 128 });
const near = { x: 0.5 + 40, z: 0.5 };
assert.deepStrictEqual(service._segmentGoal(near), near);
});
test('pathTo 长距离目标时下发的是分段中途点而不是最终目标', () => {
const { service, goals } = setupSegment({ loadedRadius: 128 });
// 目标区块中心约在 (800, 0)
assert.strictEqual(service.pathTo({ x: 50, z: 0 }), true);
assert.strictEqual(goals.length, 1);
assert.strictEqual(service._waypointIsFinal, false, '这一段不是最终目标');
assert.ok(service.currentWaypoint.x < 800);
});
test('pathTo 近距离目标时直接下发最终目标', () => {
const { service, goals } = setupSegment({ loadedRadius: 512 });
assert.strictEqual(service.pathTo({ x: 5, z: 0 }), true);
assert.strictEqual(goals.length, 1);
assert.strictEqual(service._waypointIsFinal, true);
});
test('走到中途点后自动规划下一段,而不是当作已到达', () => {
const { bot, service, goals } = setupSegment({ loadedRadius: 128 });
const events = [];
service.onPathEvent = (event) => events.push(event);
service.pathTo({ x: 50, z: 0 });
const before = goals.length;
bot.emit('goal_reached');
assert.strictEqual(goals.length, before + 1, '应继续规划下一段');
assert.strictEqual(service._segmentCount, 1);
assert.ok(events.includes(PathEvent.SEGMENT_REACHED), '应发出 SEGMENT_REACHED 供控制器重置超时');
assert.ok(!events.includes(PathEvent.AT_GOAL), '还没到最终目标,不能报到达');
});
test('走到最终目标时发出 AT_GOAL 且不再规划', () => {
const { bot, service, goals } = setupSegment({ loadedRadius: 512 });
const events = [];
service.onPathEvent = (event) => events.push(event);
service.pathTo({ x: 5, z: 0 });
const before = goals.length;
bot.emit('goal_reached');
assert.strictEqual(goals.length, before, '已到最终目标,不应再规划');
assert.ok(events.includes(PathEvent.AT_GOAL));
});
test('本段算出 noPath 时先缩短段长重试', () => {
const { service } = setupSegment({ loadedRadius: 128 });
service.pathTo({ x: 50, z: 0 });
const firstWaypoint = service.currentWaypoint.x;
// 模拟寻路器报告本段不可达
service.bot.emit('path_update', { status: 'noPath' });
assert.strictEqual(service.calcFailed, false, '第一次应重试而不是直接失败');
assert.ok(service.currentWaypoint.x < firstWaypoint, '段长应缩短');
});
test('反复 noPath 最终会如实报失败,不会无限重试', () => {
const { service } = setupSegment({ loadedRadius: 128 });
const events = [];
service.onPathEvent = (event) => events.push(event);
service.pathTo({ x: 50, z: 0 });
for (let i = 0; i < 10; i++) service.bot.emit('path_update', { status: 'noPath' });
assert.strictEqual(service.calcFailed, true);
assert.ok(events.includes(PathEvent.CALC_FAILED));
});
test('cancel 清空分段状态', () => {
const { service } = setupSegment({ loadedRadius: 128 });
service.pathTo({ x: 50, z: 0 });
service.cancel();
assert.strictEqual(service.finalTarget, null);
assert.strictEqual(service.currentWaypoint, null);
assert.strictEqual(service._segmentCount, 0);
assert.strictEqual(service.isPathing(), false);
});
test('configure 施加 Grim 合规约束(默认禁止挖 / 放 / 跑酷 / 搭塔)', () => { const bot = new FakeBot();
const movements = {};
bot.pathfinder = {
setMovements: (m) => { movements.value = m; },
thinkTimeout: 0,
tickTimeout: 0,
searchRadius: 0
};
const config = defaultConfig();
const service = new PathingService(bot, config, null);
assert.strictEqual(service.configure(), true);
const m = movements.value;
assert.strictEqual(m.canDig, false, '默认不允许挖方块');
assert.deepStrictEqual(m.scafoldingBlocks, [], '默认不允许放方块');
assert.strictEqual(m.allowParkour, false);
assert.strictEqual(m.allow1by1towers, false);
assert.strictEqual(m.allowFreeMotion, false);
assert.strictEqual(m.maxDropDown, 3);
assert.strictEqual(m.infiniteLiquidDropdownDistance, false);
assert.strictEqual(m.dontMineUnderFallingBlock, true);
// waterMode=avoid 时液体代价被推高
assert.ok(m.liquidCost >= 200, 'avoid 模式下液体代价应被推高');
});
test('configure 在 allowBreak / allowPlace 打开时放行对应能力', () => {
const bot = new FakeBot();
const movements = {};
bot.pathfinder = { setMovements: (m) => { movements.value = m; }, thinkTimeout: 0, tickTimeout: 0, searchRadius: 0 };
const config = defaultConfig();
config.allowBreak = true;
config.allowPlace = true;
config.waterMode = 'swim';
const service = new PathingService(bot, config, null);
service.configure();
assert.strictEqual(movements.value.canDig, true);
assert.ok(movements.value.scafoldingBlocks.length > 0);
assert.strictEqual(movements.value.liquidCost, config.pathing.liquidCost);
});
test('pathTo 下发目标区块中心并触发 CALC_STARTED', () => {
const bot = new FakeBot();
let goal = null;
bot.pathfinder = {
setMovements: () => {},
setGoal: (g) => { goal = g; },
thinkTimeout: 0,
tickTimeout: 0,
searchRadius: 0
};
const events = [];
const service = new PathingService(bot, defaultConfig(), null);
service.configure();
service.onPathEvent = (event, detail) => events.push({ event, detail });
assert.strictEqual(service.pathTo({ x: 3, z: -2 }), true);
assert.ok(goal, '应下发目标');
assert.strictEqual(goal.isEnd({ x: 3 * 16 + 8, y: 64, z: -2 * 16 + 8 }), true, '目标应为区块中心附近');
assert.strictEqual(events[0].event, PathEvent.CALC_STARTED);
assert.strictEqual(service.isPathing(), true);
});
test('关闭高度偏好时不包装目标', () => {
const bot = new FakeBot();
let goal = null;
bot.pathfinder = { setMovements: () => {}, setGoal: (g) => { goal = g; }, thinkTimeout: 0, tickTimeout: 0, searchRadius: 0 };
const config = defaultConfig();
config.altitudePreference = false;
const service = new PathingService(bot, config, null);
service.configure();
service.pathTo({ x: 0, z: 0 });
assert.ok(!(goal instanceof AltitudeGoal), '未启用高度偏好时不应包装');
});
test('cancel 清空目标并停止寻路状态', () => {
const bot = new FakeBot();
let cleared = false;
bot.pathfinder = {
setMovements: () => {},
setGoal: (g) => { if (g === null) cleared = true; },
thinkTimeout: 0,
tickTimeout: 0,
searchRadius: 0
};
const service = new PathingService(bot, defaultConfig(), null);
service.configure();
service.pathTo({ x: 1, z: 1 });
service.cancel();
assert.strictEqual(cleared, true);
assert.strictEqual(service.isPathing(), false);
assert.strictEqual(service.currentTarget, null);
});
test('pathfinder 未加载时 configure 返回 false 且 pathTo 不抛异常', () => {
const bot = new FakeBot();
const service = new PathingService(bot, defaultConfig(), null);
assert.strictEqual(service.configure(), false);
assert.strictEqual(service.isAvailable(), false);
assert.strictEqual(service.pathTo({ x: 0, z: 0 }), false);
service.cancel(); // 不应抛异常
});