按 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` 全绿。
290 行
11 KiB
JavaScript
290 行
11 KiB
JavaScript
'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), '取消后的在途结果必须被忽略');
|
||
}); |