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` 全绿。
这个提交包含在:
lyizai
2026-09-28 22:46:39 +08:00
父节点 8d1b8d5122
当前提交 51664c55b6
共修改 31 个文件,包含 3535 行新增和 251 行删除
+303
查看文件
@@ -486,6 +486,149 @@ test('位置包统计暴露在 snapshot 中,便于排查走不动的问题', (
assert.strictEqual(snap.world, null, '未进入世界时没有世界诊断信息');
});
// ==================== 地面状态修正(TODO 问题 1 · 假设 2 的方案 A / B)====================
/** 造一个支持事件与包记录(write)的假协议客户端,模拟 mineflayer 的 _client */
function fakeClient() {
const client = new (require('events').EventEmitter)();
client.written = [];
client.write = (name, params) => client.written.push({ name, params });
return client;
}
/**
* 模拟 mineflayer 的 position 处理器:清零速度、应用服务端坐标、把 onGround 置 false。
* 它注册得比修正层更早,因此方案 B 的快照必须靠 prependListener 抢在它前面。
* @param {object} bot
* @param {object} client
*/
function installMineflayerPositionHandler(bot, client) {
client.on('position', (packet) => {
bot.entity.velocity.set(0, 0, 0);
bot.entity.position.set(packet.x, packet.y, packet.z);
bot.entity.onGround = false;
});
}
test('方案 A:物理 tick 里把物理引擎的 onGround 与世界数据对齐', () => {
const { manager } = setup();
const bot = botStandingOnStone();
bot._client = fakeClient();
manager.bot = bot;
manager._installGroundStateFix(bot);
bot.entity.onGround = false; // 模拟刚被服务端位置包置为 false
bot.tick();
assert.strictEqual(bot.entity.onGround, true, '站在石头上时应把物理引擎的 onGround 同步为 true');
assert.strictEqual(manager.packetStats.physicsGroundSync, 1);
});
test('方案 A:脚下是空气时不干预(真正的下落不能被改判为站立)', () => {
const { manager } = setup();
const bot = new FakeBot({ x: 0.5, y: 72, z: 0.5 });
bot._client = fakeClient();
manager.bot = bot;
manager._installGroundStateFix(bot);
bot.entity.onGround = false;
bot.tick();
assert.strictEqual(bot.entity.onGround, false);
assert.strictEqual(manager.packetStats.physicsGroundSync, 0);
});
test('方案 A:关闭开关后完全不干预', () => {
const { manager } = setup({ config: { grim: { syncGroundToPhysics: false } } });
const bot = botStandingOnStone();
bot._client = fakeClient();
manager.bot = bot;
manager._installGroundStateFix(bot);
bot.entity.onGround = false;
bot.tick();
assert.strictEqual(bot.entity.onGround, false);
assert.strictEqual(manager.packetStats.physicsGroundSync, 0);
});
test('方案 B:例行同步位置包只同步坐标,不采纳它的 onGround=false', () => {
const { manager } = setup();
const client = fakeClient();
const bot = botStandingOnStone();
bot._client = client;
manager.bot = bot;
installMineflayerPositionHandler(bot, client);
manager._installGroundStateFix(bot);
bot.entity.onGround = true; // 本地物理引擎判定站在地上
client.emit('position', { x: 0.5, y: 72, z: 0.5 }); // 服务端位置与本地一致(例行同步)
assert.strictEqual(bot.entity.onGround, true, '位置一致时不应采纳服务端的 onGround=false');
assert.strictEqual(manager.packetStats.setbackOnGroundKept, 1);
assert.strictEqual(manager.packetStats.lastSetbackDelta.distance, 0);
});
test('方案 B:位置差超过阈值时按真正的传送处理,不做干预', () => {
const { manager } = setup();
const client = fakeClient();
const bot = botStandingOnStone();
bot._client = client;
manager.bot = bot;
installMineflayerPositionHandler(bot, client);
manager._installGroundStateFix(bot);
bot.entity.onGround = true;
client.emit('position', { x: 30.5, y: 72, z: 0.5 }); // 服务端把机器人传送到 30 格外
assert.strictEqual(bot.entity.onGround, false, '真正的传送应完全按服务端状态来');
assert.strictEqual(manager.packetStats.setbackOnGroundKept, 0);
assert.ok(manager.packetStats.lastSetbackDelta.distance > 29, '应记录本次位置差');
});
test('方案 B:本地本来就在空中时保持原样(只拒绝「由站变空」的误判)', () => {
const { manager } = setup();
const client = fakeClient();
const bot = botStandingOnStone();
bot._client = client;
manager.bot = bot;
installMineflayerPositionHandler(bot, client);
manager._installGroundStateFix(bot);
bot.entity.onGround = false;
client.emit('position', { x: 0.5, y: 72, z: 0.5 });
assert.strictEqual(bot.entity.onGround, false);
assert.strictEqual(manager.packetStats.setbackOnGroundKept, 0);
});
test('方案 B:关闭开关后不再接手服务端位置包', () => {
const { manager } = setup({ config: { grim: { trustSetbackOnGround: false } } });
const client = fakeClient();
const bot = botStandingOnStone();
bot._client = client;
manager.bot = bot;
installMineflayerPositionHandler(bot, client);
manager._installGroundStateFix(bot);
bot.entity.onGround = true;
client.emit('position', { x: 0.5, y: 72, z: 0.5 });
assert.strictEqual(bot.entity.onGround, false);
assert.strictEqual(manager.packetStats.setbackOnGroundKept, 0);
});
test('出站包类型分布记录在 packetStats 中(假设 4 的对照依据)', () => {
const { manager } = setup();
const client = fakeClient();
const bot = new FakeBot({ client });
manager.bot = bot;
manager._wirePacketStats(bot);
client.write('position', { flags: { onGround: true } });
client.write('position_look', { flags: { onGround: true }, x: 0, y: 0, z: 0 });
client.write('player_input', { inputs: {} });
assert.deepStrictEqual(manager.packetStats.outboundTypes, {
position: 1,
position_look: 1,
player_input: 1
});
assert.strictEqual(manager.packetStats.outboundPosition, 2);
});
// ==================== 手动移动测试 ====================
test('walkTest 走控制键而不是寻路器,能测出真实位移', async () => {
@@ -622,6 +765,24 @@ function placeAtOrigin(manager, bot, y) {
manager.bot = bot;
}
/**
* 反复推进 mock 定时器。
*
* Node 的 mock timers 不会在同一次 tick() 里执行「回调中新注册」的定时器,
* 而原点自救是链式的(按键 → 松开 → 下一次按键 → … → 判定 → 下一轮),
* 因此必须分多次 tick 才能把整条链走完。
*
* @param {object} t 测试上下文(t.mock.timers)
* @param {number} totalMs 需要推进的总时长
*/
function advanceTimers(t, totalMs) {
// 步长要远小于最短的定时器(150ms 的按键间隔),否则每次 tick 的量化误差会累积
const step = 50;
for (let elapsed = 0; elapsed < totalMs; elapsed += step) {
t.mock.timers.tick(step);
}
}
test('原点附近且服务器没要求验证码 → 按「死亡卡住」处理并尝试左右移动自救', () => {
const { manager, logger } = setup();
const bot = new FakeBot();
@@ -699,6 +860,148 @@ test('不在原点附近时不触发任何原点逻辑', () => {
assert.strictEqual(manager.needsVerificationHint, false);
});
test('原点自救会判定结果并统计成功率(第一轮后位置恢复 → 成功)', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { manager, logger } = setup();
const bot = new FakeBot();
placeAtOrigin(manager, bot);
manager._needAuthWarned = false;
// 模拟服务器收到移动输入后把玩家传送回正常位置
bot.on('controlState', (control, state) => {
if (state === true) {
bot.entity.position.x = 6234.5;
bot.entity.position.y = 72;
bot.entity.position.z = 11890.5;
}
});
manager._escapeFromOrigin();
advanceTimers(t, 5000);
assert.strictEqual(manager.originRescue.attempts, 1);
assert.strictEqual(manager.originRescue.successes, 1);
const last = manager.originRescue.lastResult;
assert.strictEqual(last.success, true);
assert.strictEqual(last.rounds, 1, '第一轮就恢复了,不该继续第二轮');
assert.ok(last.from.z < 0, '应记录自救前坐标');
assert.ok(last.to.z > 1000, '应记录自救后坐标');
assert.strictEqual(manager._escaping, false);
assert.strictEqual(bot.getControlState('right'), false, '自救结束后按键必须松开');
assert.ok(logger.list().some((e) => e.message.includes('原点自救成功')));
});
test('第一轮无效时换「前后 + 跳」重试,两轮都失败则统计失败并升级提示', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { manager, logger } = setup();
const bot = new FakeBot();
placeAtOrigin(manager, bot);
manager._needAuthWarned = false;
manager._escapeFromOrigin();
advanceTimers(t, 10000);
assert.strictEqual(manager.originRescue.attempts, 1);
assert.strictEqual(manager.originRescue.successes, 0);
const last = manager.originRescue.lastResult;
assert.strictEqual(last.success, false);
assert.strictEqual(last.rounds, 2, '两轮都要试过才算失败');
assert.strictEqual(manager._escaping, false);
const lines = logger.list().map((e) => e.message).join('\n');
assert.ok(lines.includes('前后移动 + 跳'), '应尝试第二组方向:' + lines);
assert.ok(lines.includes('原点自救失败'), '失败要升级成明确提示:' + lines);
assert.ok(lines.includes('验证码'), '失败提示里要点明「登录服需人工验证」这条可能');
});
test('自救途中服务器要求验证码 → 提前结束(登录服里移动没有意义)', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { manager, logger } = setup();
const bot = new FakeBot();
placeAtOrigin(manager, bot);
manager._needAuthWarned = false;
manager._escapeFromOrigin();
manager._needAuthWarned = true; // 第一轮还没结束时服务器开始要验证码
advanceTimers(t, 10000);
assert.strictEqual(manager._escaping, false);
const last = manager.originRescue.lastResult;
assert.strictEqual(last.success, false);
assert.strictEqual(last.rounds, 1, '不该再继续第二轮');
const lines = logger.list().map((e) => e.message).join('\n');
assert.ok(lines.includes('原点自救提前结束'), '应解释为什么不再自救:' + lines);
});
test('snapshot 暴露原点自救统计(控制台与 /api/status 可读成功率)', () => {
const { manager } = setup();
assert.deepStrictEqual(manager.snapshot().originRescue, {
attempts: 0,
successes: 0,
lastResult: null
});
});
// ==================== 需要人工介入时的通知提醒(4.5)====================
/** 造一个记录调用的假通知器 */
function fakeNotifier(enabled) {
const calls = [];
return {
calls,
isEnabled: () => enabled !== false,
notify: (text, options) => {
calls.push({ text, options });
return Promise.resolve(true);
}
};
}
test('服务器要求验证码时通过通知渠道提醒(验证码只有 120 秒时限)', () => {
const { manager } = setup();
const notifier = fakeNotifier();
manager.notifier = notifier;
manager._handleAuthPrompt('请直接输入验证码进行验证');
assert.strictEqual(notifier.calls.length, 1, '应发出一条提醒');
assert.ok(notifier.calls[0].text.includes('验证码'));
assert.ok(notifier.calls[0].text.includes('120 秒'));
assert.ok(notifier.calls[0].options.key.startsWith('verification'), '应带去重 key');
});
test('关闭 notify.onVerification 后不再提醒验证码', () => {
const { manager } = setup();
const notifier = fakeNotifier();
manager.notifier = notifier;
manager.config.notify.onVerification = false;
manager._handleAuthPrompt('请直接输入验证码进行验证');
assert.strictEqual(notifier.calls.length, 0);
});
test('通知器未启用时不发提醒(也不会误触网络请求)', () => {
const { manager } = setup();
const notifier = fakeNotifier(false);
manager.notifier = notifier;
manager._handleAuthPrompt('请输入验证码进行验证');
assert.strictEqual(notifier.calls.length, 0);
});
test('原点自救失败时也提醒(很可能被切到了登录服)', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { manager } = setup();
const bot = new FakeBot();
placeAtOrigin(manager, bot);
manager._needAuthWarned = false;
const notifier = fakeNotifier();
manager.notifier = notifier;
manager._escapeFromOrigin();
advanceTimers(t, 10000);
assert.strictEqual(manager.originRescue.lastResult.success, false);
assert.strictEqual(notifier.calls.length, 1, '自救失败应发出提醒');
assert.ok(notifier.calls[0].text.includes('自救失败'));
assert.ok(notifier.calls[0].text.includes('验证码'));
});
test('snapshot 暴露 alreadyConnected 供控制台提示用户', () => {
const { manager } = setup();
assert.strictEqual(manager.snapshot().alreadyConnected, false);