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` 全绿。
这个提交包含在:
+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);
|
||||
|
||||
在新工单中引用
屏蔽一个用户