'use strict'; const test = require('node:test'); const assert = require('node:assert'); const { ManualMover, STUCK_TICKS } = require('../src/bot/manualMover'); const { PathingService, PathEvent } = require('../src/bot/pathingService'); const { defaultConfig } = require('../src/config/expansionConfig'); const { FakeBot } = require('./helpers/fakeBot'); /** * 造一个「会走路」的假 bot: * - 每个物理 tick 让出一次事件循环(真实 tick 那样),这样超时能按真实时间生效; * - 按着 forward 就沿朝向走若干格(direction 与 mineflayer 的 forward 一致); * - 按着 jump 且站着且冷却好了就抬升 1 格(模拟跳上一格台阶,含原版 10 tick 冷却, * 避免「按住跳键连升」这种假物理)。 * * @param {{x?: number, y?: number, z?: number, walkSpeed?: number}} [options] */ function setupWalker(options) { const opts = options || {}; const speed = opts.walkSpeed === undefined ? 0.25 : opts.walkSpeed; const bot = new FakeBot({ x: opts.x === undefined ? 0.5 : opts.x, y: opts.y === undefined ? 64 : opts.y, z: opts.z === undefined ? 0.5 : opts.z }); let jumpCooldown = 0; bot.on('physicsTick', () => { if (jumpCooldown > 0) jumpCooldown--; if (bot.getControlState('jump') && bot.entity.onGround && jumpCooldown === 0) { bot.entity.position.y += 1; bot.entity.onGround = false; jumpCooldown = 10; return; } if (!bot.entity.onGround) { // 简化垂直物理:滞空一 tick 后落回整数高度(不模拟抛物线) bot.entity.position.y = Math.round(bot.entity.position.y); bot.entity.onGround = true; return; } if (bot.getControlState('forward')) { const yaw = bot.entity.yaw; bot.entity.position.x -= Math.sin(yaw) * speed; bot.entity.position.z -= Math.cos(yaw) * speed; } }); // 让每个 tick 都让出事件循环:时间型超时(deadline)才能按真实时间生效 bot.waitForTicks = async () => { bot.emit('physicsTick'); await new Promise((resolve) => setImmediate(resolve)); }; return bot; } /** 造一段最简单的直线路径(沿 +z 方向) */ function straightPath(length) { const path = []; for (let i = 0; i <= length; i++) path.push({ x: 0, y: 64, z: i }); return path; } test('手动执行器沿路径点走到目标,结束后松开所有控制键', async () => { const bot = setupWalker(); const mover = new ManualMover(bot, defaultConfig(), null, { plan: () => Promise.resolve(straightPath(2)), timeoutMs: 10000 }); const result = await mover.moveTo({ x: 0, z: 2 }); assert.strictEqual(result.ok, true, `应走完路径,实际 ${JSON.stringify(result)}`); assert.ok(result.waypoints >= 3, '应记录走过的路径点数'); // 最后一段的路径点中心在 z=2.5;走到 2.0 即满足 0.6 格的到达判定 assert.ok(bot.entity.position.z >= 1.9, `应走到目标附近,实际 z=${bot.entity.position.z}`); assert.strictEqual(bot.getControlState('forward'), false, '结束后必须松开前进键'); assert.strictEqual(bot.getControlState('jump'), false, '结束后必须松开跳跃键'); assert.strictEqual(mover.active, false); }); test('目标高出一格时按一下 jump(对应手动按键时的爬坎)', async () => { const bot = setupWalker(); const controls = []; bot.on('controlState', (control, state) => controls.push({ control, state })); const mover = new ManualMover(bot, defaultConfig(), null, { plan: () => Promise.resolve([{ x: 0, y: 64, z: 0 }, { x: 0, y: 65, z: 1 }]), timeoutMs: 10000 }); const result = await mover.moveTo({ x: 0, z: 1 }); assert.strictEqual(result.ok, true, `应跳上台阶并到位,实际 ${JSON.stringify(result)}`); assert.ok(controls.some((c) => c.control === 'jump' && c.state === true), '应按下过 jump'); assert.ok(controls.some((c) => c.control === 'jump' && c.state === false), '按一下之后要松开'); assert.ok(Math.abs(bot.entity.position.y - 65) < 1e-6, '应已站到台阶上'); }); test('长时间没有位移时判定卡住并终止(不会无限按键)', async () => { // 不挂物理模拟:按什么都没用,模拟「服务端把机器人按在原地」 const bot = new FakeBot({ x: 0.5, y: 64, z: 0.5 }); bot.waitForTicks = async () => { bot.emit('physicsTick'); await new Promise((resolve) => setImmediate(resolve)); }; let planCalls = 0; const mover = new ManualMover(bot, defaultConfig(), null, { plan: () => { planCalls++; return Promise.resolve(straightPath(1)); }, timeoutMs: 20000 }); const result = await mover.moveTo({ x: 0, z: 1 }); assert.strictEqual(result.ok, false); assert.strictEqual(result.reason, 'stuck'); assert.ok(planCalls > 1, '卡住后应重新规划再试'); assert.ok(result.ticks >= STUCK_TICKS, `应至少跑满一次卡住判定,实际 ${result.ticks} tick`); assert.strictEqual(bot.getControlState('forward'), false, '终止后必须松开前进键'); }); test('stop() 立即取消执行并松开控制键', async () => { // 走慢一点、路径长一点,保证取消发生在半路上而不是走完之后 const bot = setupWalker({ walkSpeed: 0.05 }); const controls = []; bot.on('controlState', (control, state) => controls.push({ control, state })); const mover = new ManualMover(bot, defaultConfig(), null, { plan: () => Promise.resolve(straightPath(500)), timeoutMs: 20000 }); let ticks = 0; bot.on('physicsTick', () => { if (++ticks === 20) mover.stop(); // 跑了 20 tick(前进键按下中)后取消 }); const result = await mover.moveTo({ x: 0, z: 500 }); assert.strictEqual(result.ok, false); assert.strictEqual(result.reason, 'cancelled'); assert.ok(controls.some((c) => c.control === 'forward' && c.state === true), '取消前应确实按下了前进键'); assert.strictEqual(bot.getControlState('forward'), false, '取消后必须松开前进键'); }); test('默认规划函数逐片推进 A*,success 才返回路径', async () => { const bot = setupWalker(); const nodes = straightPath(2); bot.pathfinder = { movements: {}, thinkTimeout: 100, tickTimeout: 1, searchRadius: 0, getPathFromTo: function * () { yield { result: { status: 'partial', path: [] } }; yield { result: { status: 'success', path: nodes } }; } }; const mover = new ManualMover(bot, defaultConfig(), null, { timeoutMs: 10000 }); const result = await mover.moveTo({ x: 0, z: 2 }); assert.strictEqual(result.ok, true, `应走完路径,实际 ${JSON.stringify(result)}`); assert.strictEqual(mover.lastStatus(), 'success'); }); test('默认规划函数在 A* 报 noPath 时如实返回失败', async () => { const bot = setupWalker(); bot.pathfinder = { movements: {}, thinkTimeout: 100, tickTimeout: 1, searchRadius: 0, getPathFromTo: function * () { yield { result: { status: 'noPath', path: [] } }; } }; const mover = new ManualMover(bot, defaultConfig(), null, { timeoutMs: 10000 }); const result = await mover.moveTo({ x: 500, z: 500 }); assert.strictEqual(result.ok, false); assert.strictEqual(result.reason, 'noPath'); }); test('pathfinder 未加载时返回 noPathfinder,不抛异常', async () => { const bot = setupWalker(); const mover = new ManualMover(bot, defaultConfig(), null, { timeoutMs: 10000 }); const result = await mover.moveTo({ x: 0, z: 0 }); assert.strictEqual(result.ok, false); assert.strictEqual(result.reason, 'noPathfinder'); }); // ==================== PathingService 的手动执行模式 ==================== /** 造一个手动执行模式的寻路服务(并替换掉真实的 mover,便于断言调用) */ function setupManualService(options) { const opts = options || {}; const bot = new FakeBot({ x: 0.5, y: 64, z: 0.5, 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.pathing.executor = 'manual'; Object.assign(config.pathing, opts.config || {}); const service = new PathingService(bot, config, null); service.configure(); return { bot, service, goals, config }; } const flush = () => new Promise((resolve) => setTimeout(resolve, 10)); test('手动模式:configure 会创建手动执行器,且不下发 setGoal', () => { const { service } = setupManualService(); assert.ok(service.mover, '手动模式下应创建 ManualMover'); assert.strictEqual(service.diagnostics().executor, 'manual'); }); test('手动模式:执行成功时按同一套事件逻辑报到达', async () => { const { service, goals } = setupManualService(); const events = []; service.onPathEvent = (event) => events.push(event); service.mover = { stop: () => {}, lastStatus: () => 'success', moveTo: () => Promise.resolve({ ok: true, reason: 'arrived', waypoints: 4, ticks: 20 }) }; assert.strictEqual(service.pathTo({ x: 5, z: 0 }), true); await flush(); assert.deepStrictEqual(goals, [], '手动模式不应把执行交给 pathfinder'); assert.ok(events.includes(PathEvent.CALC_STARTED)); assert.ok(events.includes(PathEvent.AT_GOAL), '走到最终目标应报到达'); assert.strictEqual(service.isPathing(), false); }); test('手动模式:执行失败时先缩短段长重试', async () => { const { service } = setupManualService({ loadedRadius: 128 }); let calls = 0; service.mover = { stop: () => {}, lastStatus: () => 'stuck', moveTo: () => { calls++; return Promise.resolve({ ok: false, reason: 'stuck', waypoints: 3, ticks: 40 }); } }; service.pathTo({ x: 50, z: 0 }); // 远目标 → 走分段逻辑 await flush(); assert.ok(service._segmentShrink >= 1, '应先缩短段长重试'); assert.ok(calls >= 2, '应重新发起执行'); }); test('手动模式:反复失败后如实报 CALC_FAILED,不会无限重试', async () => { const { service } = setupManualService({ loadedRadius: 128 }); const events = []; service.onPathEvent = (event) => events.push(event); service.mover = { stop: () => {}, lastStatus: () => 'stuck', moveTo: () => Promise.resolve({ ok: false, reason: 'stuck', waypoints: 3, ticks: 40 }) }; service.pathTo({ x: 50, z: 0 }); await flush(); assert.strictEqual(service.calcFailed, true); assert.ok(events.includes(PathEvent.CALC_FAILED)); assert.strictEqual(service.isPathing(), false); }); test('手动模式:cancel 会让在途结果失效并停掉执行器', async () => { const { service } = setupManualService(); let stopped = false; let resolveMove = null; service.mover = { stop: () => { stopped = true; }, lastStatus: () => null, moveTo: () => new Promise((resolve) => { resolveMove = resolve; }) }; service.pathTo({ x: 5, z: 0 }); service.cancel(); assert.strictEqual(stopped, true); // 即使旧执行随后返回成功,也不应再被当成到达 const events = []; service.onPathEvent = (event) => events.push(event); resolveMove({ ok: true, reason: 'arrived', waypoints: 1, ticks: 1 }); await flush(); assert.ok(!events.includes(PathEvent.AT_GOAL), '取消后的在途结果必须被忽略'); });