实测定位:高度偏好的启发值实现与 mineflayer-pathfinder 的内部约定冲突。 该库每次 compute() 返回 partial 时,用 h 最小的节点当 partial 路径的终点 (astar.js: if (neighborNode.h < this.bestNode.h) this.bestNode = neighborNode), 而 AltitudeGoal 把 h 减去了 权重 × y,于是「又高又偏」的节点 h 反而更小, partial 路径被引向高处而不是目标附近 —— A* 一直在算、永不收敛, 机器人一次移动指令都发不出来。 王城平台上的对照实测(其余配置完全相同): - altitudePreference = true → inputTicks = 0,任务永远停在「寻路超时,重新寻路」 - altitudePreference = false → inputTicks = 237,立刻开始移动 顺带排除:searchRadius 从 -1 改到 128 对结果没有任何影响, 所以不是「搜索空间无界」导致的;pathfinder 与 manual 两种执行器表现一致。 改动: - expansionConfig 默认 altitudePreference 改为 false,并在注释里写清原因与 「想重新启用应该怎么做」(把偏置改成低处加移动代价,保持启发值只反映真实距离); - AltitudeGoal 的类注释补上完整推导与实测数据,避免以后有人又把它打开; - 新增回归用例:断言默认关闭,并用启发值差值把「为什么必然出问题」固化下来。 测试 468 → 469,全绿。
289 行
12 KiB
JavaScript
289 行
12 KiB
JavaScript
'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, '范围内算到达');
|
||
});
|
||
|
||
test('高度偏好默认关闭:它的启发值会破坏 partial 路径的终点选择', () => {
|
||
// 回归:开启时实测 inputTicks = 0(寻路器一次移动指令都发不出),
|
||
// 关掉后 inputTicks = 237(立刻开始移动)。详见 AltitudeGoal 的注释。
|
||
const config = defaultConfig();
|
||
assert.strictEqual(config.altitudePreference, false, '默认必须是关闭的');
|
||
// 上面的差值断言同时说明了「为什么」:h 被减去了 weight × y,
|
||
// 而 mineflayer-pathfinder 用 h 最小来挑 partial 路径终点,
|
||
// 于是又高又偏的节点会胜出,路径永远到不了目标附近。
|
||
const goal = new AltitudeGoal(new GoalXZ(0, 0), 0.1, 64);
|
||
const farButHigh = goal.heuristic({ x: 0, y: 300, z: 0 }); // 就在目标正上方
|
||
const nearButLow = goal.heuristic({ x: 40, y: 64, z: 0 }); // 离目标 40 格
|
||
assert.ok(farButHigh <= nearButLow,
|
||
`高处节点的启发值 ${farButHigh} 不大于低处近节点的 ${nearButLow},正是问题所在`);
|
||
});
|
||
|
||
// ==================== 长距离分段寻路 ====================
|
||
//
|
||
// 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(); // 不应抛异常
|
||
});
|