文件
BotStore/test/pathingService.test.js
T
lyizai 6ff45a48ae fix: 给「寻路状态」进度日志加节流(1.11.1)
mineflayer-pathfinder 每用满一个 tick 的计算预算(tickTimeout,默认 40ms)就发一次
path_update,而原来的处理函数每次都记一条 —— 实测 4 分钟写了 3893 条(约 16 条/秒)。
既淹没了真正有用的日志(按 120 字节/行算一天能写上百 MB,而本服磁盘只剩 2.4G),
也让「寻路到底卡在哪」更难看清。

现在只在三种情况下记:
1. 状态发生变化(partial → success / noPath / timeout)—— 最要紧的信号;
2. 距上一条超过 2 秒;
3. 已探索节点数比上一条多出 1000 以上 —— 长规划里仍能看出推进速度,
   不丢定位卡死所需的信息。

测试 480 → 481。
2026-10-02 01:28:03 +08:00

321 行
14 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, '范围内算到达');
});
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},正是问题所在`);
});
test('寻路状态日志被节流:进度刷屏不会把日志写爆', () => {
// mineflayer-pathfinder 每用满一个 tick 的计算预算(tickTimeout,默认 40ms)
// 就发一次 path_update。不节流时实测 4 分钟写了 3893 条「寻路状态」,
// 既淹没真正有用的日志(一天上百 MB,本服磁盘本就只剩 2.4G),
// 也让「寻路卡在哪」更难看清。
const bot = new FakeBot({ x: 0.5, y: 64, z: 0.5 });
bot.pathfinder = {
setMovements: () => {},
setGoal: () => {},
thinkTimeout: 0,
tickTimeout: 0,
searchRadius: 0
};
const logs = [];
const logger = { log: (msg) => logs.push(String(msg)), error: () => {}, list: () => [], clear: () => {} };
const service = new PathingService(bot, defaultConfig(), logger);
service.configure();
// 模拟一次长规划:状态一直是 partial、节点数快速增长
for (let i = 0; i < 300; i++) {
bot.emit('path_update', { status: 'partial', visitedNodes: i * 20, cost: 90 });
}
const progressLogs = logs.filter((m) => m.includes('寻路状态'));
assert.ok(progressLogs.length >= 2, `不应把进度信息完全丢掉,实际 ${progressLogs.length} 条`);
assert.ok(progressLogs.length <= 12, `300 次 partial 应被节流到十几条以内,实际 ${progressLogs.length} 条`);
// 状态变化是最要紧的信号,必须立刻记一条
logs.length = 0;
bot.emit('path_update', { status: 'success', visitedNodes: 6100, cost: 90 });
assert.strictEqual(logs.filter((m) => m.includes('寻路状态')).length, 1, '状态变化要立刻记录');
});
// ==================== 长距离分段寻路 ====================
//
// 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(); // 不应抛异常
});