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 行删除
+146 -1
查看文件
@@ -3,7 +3,24 @@
const test = require('node:test');
const assert = require('node:assert');
const { ScheduledMessenger, normalizeTasks, clampInterval, MIN_INTERVAL_SECONDS, MAX_TASKS } = require('../src/bot/scheduledMessenger');
const { ScheduledMessenger, normalizeTasks, clampInterval, parseCron, nextCronTime,
MIN_INTERVAL_SECONDS, MAX_TASKS } = require('../src/bot/scheduledMessenger');
const { Logger } = require('../src/util/logger');
const { EventEmitter } = require('events');
/** 造一个会发事件、可控的假 BotManager(用于「发送结果确认」测试) */
function emittableManager(options) {
const opts = options || {};
const manager = new EventEmitter();
manager.connection = { username: 'BotA' };
manager.sent = [];
manager.canSendChat = () => opts.canSend !== false;
manager.sendChat = (content) => {
manager.sent.push(content);
return true;
};
return manager;
}
/** 造一个可控的假 BotManager */
function fakeManager(options) {
@@ -141,3 +158,131 @@ test('start / stop 控制心跳定时器', () => {
messenger.stop();
assert.strictEqual(messenger._timer, null);
});
// ==================== cron 式调度 ====================
test('parseCron 接受常用写法,拒绝非法表达式', () => {
assert.ok(parseCron('0 9 * * *'), '每天 09:00');
assert.ok(parseCron('0/30 * * * *'), '每 30 分钟');
assert.ok(parseCron('0 12 * * 1-5'), '工作日 12:00');
assert.ok(parseCron('5,35 8-18 * * *'), '区间 + 列表');
assert.strictEqual(parseCron('0 9 * *'), null, '段数不足应拒绝');
assert.strictEqual(parseCron('99 9 * * *'), null, '越界应拒绝');
assert.strictEqual(parseCron('0 9 * * 9'), null, '周只能是 0-6');
assert.strictEqual(parseCron(''), null);
});
test('cron 任务只在匹配时刻发送,且不会同一天重复发', () => {
const manager = fakeManager();
const messenger = new ScheduledMessenger(manager, null);
const now = new Date(2026, 8, 28, 8, 0, 0); // 2026-09-28 08:00(本地时间)
messenger.setTasks([{ id: 'daily', content: '早安公告', cron: '0 9 * * *' }], now.getTime());
messenger.tick(new Date(2026, 8, 28, 8, 59, 0).getTime());
assert.strictEqual(manager.sent.length, 0, '09:00 之前不该发');
messenger.tick(new Date(2026, 8, 28, 9, 0, 10).getTime());
assert.strictEqual(manager.sent.length, 1, '09:00 到点应发送');
messenger.tick(new Date(2026, 8, 28, 9, 1, 0).getTime());
assert.strictEqual(manager.sent.length, 1, '同一天不应重复发');
const snap = messenger.snapshot()[0];
assert.strictEqual(snap.mode, 'cron');
assert.strictEqual(snap.cron, '0 9 * * *');
assert.strictEqual(new Date(snap.nextDueAt).getDate(), 29, '下一次应是明天 09:00');
assert.strictEqual(new Date(snap.nextDueAt).getHours(), 9);
});
test('cron 支持星期范围:工作日表达式会跳过周末', () => {
const manager = fakeManager();
const messenger = new ScheduledMessenger(manager, null);
const saturday = new Date(2026, 8, 26, 13, 0, 0); // 2026-09-26 周六
assert.strictEqual(saturday.getDay(), 6);
messenger.setTasks([{ id: 'workday', content: 'x', cron: '0 12 * * 1-5' }], saturday.getTime());
const due = new Date(messenger.snapshot()[0].nextDueAt);
assert.strictEqual(due.getDay(), 1, '应跳到周一');
assert.strictEqual(due.getDate(), 28);
});
test('cron 步长写法:每 30 分钟', () => {
const manager = fakeManager();
const messenger = new ScheduledMessenger(manager, null);
const now = new Date(2026, 8, 28, 8, 1, 0);
messenger.setTasks([{ id: 'half', content: 'x', cron: '0/30 * * * *' }], now.getTime());
const due = new Date(messenger.snapshot()[0].nextDueAt);
assert.strictEqual(due.getHours(), 8);
assert.strictEqual(due.getMinutes(), 30, '下一次应是 08:30');
});
test('无效 cron 表达式回退到固定间隔,并说明原因', () => {
const tasks = normalizeTasks([{ content: 'x', cron: '99 99 * * *', intervalSeconds: 60 }]);
assert.strictEqual(tasks[0].mode, 'interval', '非法表达式不能静默吞掉,应按间隔发送');
assert.ok(tasks[0].cronError.includes('无效'), '要给出可见的原因:' + tasks[0].cronError);
assert.strictEqual(tasks[0].cron, '', '非法表达式不应被保留');
});
test('nextCronTime 能跳到下一个月(稀缺表达式)', () => {
const parsed = parseCron('0 0 1 * *'); // 每月 1 日 00:00
const from = new Date(2026, 8, 28, 12, 0, 0).getTime();
const next = new Date(nextCronTime(parsed, from));
assert.strictEqual(next.getMonth(), 9, '应跳到 10 月');
assert.strictEqual(next.getDate(), 1);
});
// ==================== 发送结果确认 ====================
test('发送结果确认:命中服务器回复记为「已确认」', () => {
const manager = emittableManager();
const messenger = new ScheduledMessenger(manager, null);
const t0 = 1_000_000;
messenger.setTasks([{
id: 'restore',
content: '/u restore confirm',
intervalSeconds: 300,
expectPattern: '民心.*恢复',
confirmTimeoutSeconds: 10
}], t0);
messenger.start(); // 注册 chat 监听
assert.strictEqual(messenger.tick(t0 + 300_000), 1);
manager.emit('chat', '你的疆土民心已经恢复', 'system');
const result = messenger.snapshot()[0].lastResult;
assert.strictEqual(result.ok, true);
assert.ok(result.detail.includes('民心'));
messenger.stop();
});
test('发送结果确认:超时未回复记为「未确认」并写警告', () => {
const manager = emittableManager();
const logger = new Logger({ echo: false });
const messenger = new ScheduledMessenger(manager, logger);
const t0 = 1_000_000;
messenger.setTasks([{
id: 'restore',
content: '/u restore confirm',
intervalSeconds: 300,
expectPattern: '民心.*恢复',
confirmTimeoutSeconds: 10
}], t0);
messenger.start();
assert.strictEqual(messenger.tick(t0 + 300_000), 1);
messenger.tick(t0 + 300_000 + 11_000); // 超过确认超时
const result = messenger.snapshot()[0].lastResult;
assert.strictEqual(result.ok, false);
assert.ok(result.detail.includes('没有收到预期回复'));
assert.ok(logger.list().some((e) => e.level === 'warn' && e.message.includes('没有收到预期回复')),
'日志里必须能看到「没生效」这件事');
messenger.stop();
});
test('没配「期望回复」的任务不做确认,也不会误报未确认', () => {
const manager = emittableManager();
const messenger = new ScheduledMessenger(manager, null);
const t0 = 1_000_000;
messenger.setTasks([{ id: 'plain', content: 'hi', intervalSeconds: 60 }], t0);
messenger.start();
assert.strictEqual(messenger.tick(t0 + 60_000), 1);
messenger.tick(t0 + 120_000);
assert.strictEqual(messenger.snapshot()[0].lastResult, null, '未配置确认就不该有结果状态');
messenger.stop();
});