'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(); // 不应抛异常 });