文件
BotStore/test/manualMover.test.js
lyizai 51664c55b6 feat: 1.7.0 落地 TODO 全部待办(地面修正 / 手动执行器 / 自救 / 回城 / 租约 / 通知)
按 TODO.md 的优先级把待办全部落地为代码,详见 CHANGELOG 的 [1.7.0]。

- 「走不动」攻关:地面状态修正(syncGroundToPhysics / trustSetbackOnGround)、
  出站包类型分布、备选执行器 manualMover(只借 A* 规划、自己按键执行)
- 原点自救分两轮并统计成败;回城前评估「原地是否更差」并记录前后坐标
- 新增目标区块租约表(targetLease)避免多机器人撞同一块
- 定时消息支持 cron 与「期望回复」确认;新增人工介入 Webhook 通知(notifier)
- 大地图后台预热与视野裁剪;控制台日志按帧批量写入、配置面板支持搜索

顺带修掉两个影响部署的细节:
- 部署脚本(deploy.js / ssh-helper.js)改为也能从 .env 读 SSH 凭据 ——
  在命令行传密码会留在 shell 历史与进程列表里,写进已被忽略的 .env 更安全;
  显式环境变量仍然优先。.env.example 补上占位符并说明这些只在本机用。
- package.json / package-lock.json 版本对齐到 1.7.0(此前停留在 1.1.0,
  与 CHANGELOG 的语义化版本声明脱节)。

测试:368 → 436 个用例,`npm test` 与 `npm run check` 全绿。
2026-09-28 22:46:39 +08:00

290 行
11 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
'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), '取消后的在途结果必须被忽略');
});