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 行删除
+168 -1
查看文件
@@ -8,6 +8,7 @@ const path = require('path');
const { ExpansionController, State } = require('../src/controller/expansionController');
const { TerritoryModel } = require('../src/core/territoryModel');
const { TargetLeaseTable } = require('../src/core/targetLease');
const { defaultConfig } = require('../src/config/expansionConfig');
const { Logger } = require('../src/util/logger');
const { FakeBot } = require('./helpers/fakeBot');
@@ -126,7 +127,14 @@ function setup(options) {
const botManager = new FakeBotManager(bot, model);
const dataService = new FakeDataService(model);
const logger = new Logger({ echo: false });
const controller = new ExpansionController({ botManager, dataService, logger });
const controller = new ExpansionController({
botManager,
dataService,
logger,
botId: opts.botId,
botName: opts.botName,
targetLeases: opts.targetLeases
});
const config = defaultConfig();
config.enforceClaimTime = false;
@@ -362,6 +370,165 @@ test('耐久保护触发回城并在传送后停止任务', async () => {
assert.strictEqual(controller.state, State.STOPPED, '耐久保护回城后应停止任务');
});
// ==================== 多机器人目标租约(4.3)====================
test('多机器人共享租约表:不会挑到同一个目标区块', async () => {
const table = new TargetLeaseTable();
const first = setup({ targetLeases: table, botId: 'BotA' });
const second = setup({ targetLeases: table, botId: 'BotB' });
await first.controller.start(first.config);
await second.controller.start(second.config);
assert.ok(first.controller.target, 'A 应有目标');
assert.ok(second.controller.target, 'B 也应有目标');
assert.notDeepStrictEqual(second.controller.target, first.controller.target,
'两个机器人不应挑到同一区块');
assert.strictEqual(table.isLeasedByOther(first.controller.target, 'BotB'), true,
'A 的目标应对外可见(B 不会再挑它)');
});
test('停止任务时归还租约,其他机器人立刻可以接手', async () => {
const table = new TargetLeaseTable();
const { controller, config } = setup({ targetLeases: table, botId: 'BotA' });
await controller.start(config);
const target = controller.target;
assert.ok(target);
controller.stop();
assert.strictEqual(table.isLeasedByOther(target, 'BotB'), false, '停止后应释放租约');
assert.strictEqual(table.size, 0, 'A 不应留下任何占用');
});
test('运行中持续续租:租约不会在长距离寻路中途过期', async () => {
const table = new TargetLeaseTable({ ttlMs: 40 });
const { controller, config } = setup({ targetLeases: table, botId: 'BotA' });
await controller.start(config);
const target = controller.target;
await new Promise((resolve) => setTimeout(resolve, 25));
controller.tick(); // 续租
await new Promise((resolve) => setTimeout(resolve, 25));
// 已经超过原始 TTL(40ms),但续租过,仍应归 A
assert.strictEqual(table.tryAcquire(target, 'BotB'), false, '续租后不应被抢走');
});
test('候选区块全被其他机器人预定时等待释放,而不是判「没有可用区块」', async () => {
const table = new TargetLeaseTable();
const first = setup({ targetLeases: table, botId: 'BotA' });
const second = setup({ targetLeases: table, botId: 'BotB' });
// A 先把 B 的所有候选区块都占了(模拟 A 的队列很长)
await first.controller.start(first.config);
const model = second.model;
for (const chunk of [...model.state('A').chunks, ...first.controller.queue, first.controller.target]
.filter(Boolean)) {
const c = typeof chunk === 'string'
? { x: chunkPos.decode(chunk).x, z: chunkPos.decode(chunk).z }
: chunk;
table.tryAcquire(c, 'BotA');
}
await second.controller.start(second.config);
assert.strictEqual(second.controller.state, State.NEXT, '应进入等待而不是 COMPLETE');
assert.ok(second.controller._nextReplanAt > Date.now(), '应登记稍后重新规划');
const lines = second.logger.list().map((e) => e.message).join('\n');
assert.ok(lines.includes('正被其他机器人预定'), '应说明为什么等待:' + lines);
});
test('连续超时达阈值但原地还能走 → 不回城,改为跳过目标', async () => {
const { controller, botManager, config } = setup({
config: {
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0
}
});
botManager.pathing.diagnostics = () => ({ neighborCount: 3 });
await controller.start(config);
controller.stageStartedMillis = Date.now() - 10000;
controller.tick();
assert.notStrictEqual(controller.state, State.RETURNING, '原地还能走时不应回城');
assert.ok(!botManager.commands.includes('u return'), '不应发送回城指令');
assert.strictEqual(controller.returnSkippedNotStuck, 1, '应记下「放弃回城」的次数');
assert.strictEqual(controller.lastReturn, null, '没有回城就不该有回城记录');
});
test('连续超时达阈值且原地无路可走 → 回城', async () => {
const { controller, botManager, config } = setup({
config: {
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0
}
});
botManager.pathing.diagnostics = () => ({ neighborCount: 0 });
await controller.start(config);
controller.stageStartedMillis = Date.now() - 10000;
controller.tick();
assert.strictEqual(controller.state, State.RETURNING, '确实卡死时应回城');
assert.ok(botManager.commands.includes('u return'));
assert.strictEqual(controller.returnSkippedNotStuck, 0);
});
test('拿不到寻路诊断时保守按卡死处理(仍会回城)', async () => {
const { controller, botManager, config } = setup({
config: {
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0
}
});
// FakePathing 本来就没有 diagnostics 方法:模拟「寻路器未就绪,判断不了」
await controller.start(config);
controller.stageStartedMillis = Date.now() - 10000;
controller.tick();
assert.strictEqual(controller.state, State.RETURNING);
});
test('回城完成后记录前后坐标与直线距离(控制台展示「从 A 挪到了 B」)', async () => {
const { controller, botManager, bot, logger, config } = setup({
config: {
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0, returnWaitSeconds: 1
}
});
botManager.pathing.diagnostics = () => ({ neighborCount: 0 });
await controller.start(config);
controller.stageStartedMillis = Date.now() - 10000;
controller.tick();
assert.strictEqual(controller.state, State.RETURNING);
// 模拟服务器把玩家传送到安全点
bot.entity.position.x = 1000;
bot.entity.position.y = 70;
bot.entity.position.z = 2000;
controller.returnStartMillis = Date.now() - 5000;
controller.tick();
const last = controller.lastReturn;
assert.ok(last, '应记录回城结果');
assert.deepStrictEqual(last.from, { x: 8, y: 64, z: 8 }, '应记录回城前坐标');
assert.deepStrictEqual(last.to, { x: 1000, y: 70, z: 2000 }, '应记录回城后坐标');
assert.ok(last.distance > 2000, `应记录直线距离,实际 ${last.distance}`);
assert.ok(logger.list().some((e) => e.message.includes('回城结果')), '应输出「从 A 挪到了 B」日志');
assert.ok(controller.snapshot().lastReturn, 'snapshot 应暴露给控制台');
});
test('回城几乎没有改变位置时给出明确提示', async () => {
const { controller, botManager, logger, config } = setup({
config: {
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0, returnWaitSeconds: 1
}
});
botManager.pathing.diagnostics = () => ({ neighborCount: 0 });
await controller.start(config);
controller.stageStartedMillis = Date.now() - 10000;
controller.tick();
controller.returnStartMillis = Date.now() - 5000;
controller.tick(); // 位置没变(模拟安全点就是原地)
assert.strictEqual(controller.lastReturn.distance, 0);
assert.ok(logger.list().some((e) => e.message.includes('几乎没有改变位置')), '应提示回城没有帮助');
});
test('达成目标数量后进入 COMPLETE', async () => {
const { controller, config, bot } = setup({ config: { targetCount: 1 } });
await controller.start(config);