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` 全绿。
这个提交包含在:
@@ -218,3 +218,65 @@ test('配置里 zoom 大于 maxZoom 时被钳制(避免爬到源站不存在
|
||||
assert.strictEqual(normalized.bigMap.zoom, 3);
|
||||
assert.strictEqual(service.maxZoom, 3);
|
||||
});
|
||||
|
||||
// ==================== 后台预热(机器人移动后顺带补高层级瓦片)====================
|
||||
|
||||
test('prewarmAround 只预热比「启动预爬取层级」更高的层级', async () => {
|
||||
const { service, calls } = setup({ bigMap: { zoom: 0, prewarmLevels: 2, prewarmRangeBlocks: 512 } });
|
||||
const result = await service.prewarmAround(0, 0);
|
||||
assert.strictEqual(result.skipped, undefined, '首次调用应真正预热');
|
||||
assert.strictEqual(result.minZoom, 1);
|
||||
assert.strictEqual(result.maxZoom, 2, '默认只预热上面两层');
|
||||
assert.ok(result.done > 0);
|
||||
for (const url of calls) {
|
||||
assert.ok(/\/tiles\/minecraft_overworld\/[12]\//.test(url), '不应预热层级 0(批量爬取负责):' + url);
|
||||
}
|
||||
});
|
||||
|
||||
test('prewarmAround 在机器人没怎么移动时跳过(避免频繁请求源站)', async () => {
|
||||
const { service, calls } = setup({ bigMap: { prewarmMinMoveBlocks: 512, prewarmIntervalMs: 600000 } });
|
||||
await service.prewarmAround(1000, 1000);
|
||||
const firstCalls = calls.length;
|
||||
const again = await service.prewarmAround(1000, 1000);
|
||||
assert.strictEqual(again.skipped, 'recent');
|
||||
assert.strictEqual(calls.length, firstCalls, '跳过时不应再发起任何下载');
|
||||
|
||||
// 移动够远之后应该重新预热
|
||||
const moved = await service.prewarmAround(2000, 2000);
|
||||
assert.strictEqual(moved.skipped, undefined);
|
||||
assert.ok(calls.length > firstCalls);
|
||||
});
|
||||
|
||||
test('prewarmAround 可关闭,且批量爬取在途时让路', async () => {
|
||||
const { service } = setup({ bigMap: { prewarm: false } });
|
||||
assert.strictEqual((await service.prewarmAround(0, 0)).skipped, 'disabled');
|
||||
|
||||
const second = setup().service;
|
||||
second.crawl.running = true;
|
||||
assert.strictEqual((await second.prewarmAround(0, 0)).skipped, 'busy');
|
||||
});
|
||||
|
||||
test('prewarmAround 覆盖半径越大、层级越多,需要的瓦片越多', async () => {
|
||||
const { service: small } = setup({ bigMap: { prewarmLevels: 1, prewarmRangeBlocks: 256 } });
|
||||
const smallResult = await small.prewarmAround(0, 0);
|
||||
const { service: big } = setup({ bigMap: { prewarmLevels: 2, prewarmRangeBlocks: 2048 } });
|
||||
const bigResult = await big.prewarmAround(0, 0);
|
||||
assert.ok(bigResult.total > smallResult.total, `大图幅应预热更多瓦片:${bigResult.total} vs ${smallResult.total}`);
|
||||
});
|
||||
|
||||
test('snapshot 暴露预热状态供控制台展示', async () => {
|
||||
const { service } = setup();
|
||||
await service.prewarmAround(0, 0);
|
||||
const snapshot = service.snapshot();
|
||||
assert.ok(snapshot.prewarm);
|
||||
assert.strictEqual(snapshot.prewarm.running, false);
|
||||
assert.ok(snapshot.prewarm.done > 0);
|
||||
assert.strictEqual(snapshot.prewarm.lastCenterX, 0);
|
||||
});
|
||||
|
||||
test('prewarmLevels 为 0 时不预热(只做启动批量爬取)', async () => {
|
||||
const { service, calls } = setup({ bigMap: { prewarmLevels: 0 } });
|
||||
const result = await service.prewarmAround(0, 0);
|
||||
assert.strictEqual(result.skipped, 'no-levels');
|
||||
assert.strictEqual(calls.length, 0);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
+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);
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
'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), '取消后的在途结果必须被忽略');
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
const { Notifier } = require('../src/core/notifier');
|
||||
const { defaultConfig } = require('../src/config/expansionConfig');
|
||||
|
||||
/** 造一个记录请求的假 fetch */
|
||||
function setup(options) {
|
||||
const opts = options || {};
|
||||
const config = defaultConfig();
|
||||
Object.assign(config.notify, opts.notify || {});
|
||||
const calls = [];
|
||||
const fetchImpl = opts.fetchImpl || (async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return { ok: true, status: 200 };
|
||||
});
|
||||
const notifier = new Notifier(config, null, { fetchImpl, minIntervalMs: opts.minIntervalMs });
|
||||
return { notifier, config, calls };
|
||||
}
|
||||
|
||||
test('默认关闭:没启用或没配地址时什么都不发', async () => {
|
||||
const off = setup();
|
||||
assert.strictEqual(off.notifier.isEnabled(), false);
|
||||
assert.strictEqual(await off.notifier.notify('hi'), false);
|
||||
assert.strictEqual(off.calls.length, 0);
|
||||
|
||||
const noUrl = setup({ notify: { enabled: true } });
|
||||
assert.strictEqual(noUrl.notifier.isEnabled(), false, '没有地址等同于没启用');
|
||||
assert.strictEqual(await noUrl.notifier.notify('hi'), false);
|
||||
});
|
||||
|
||||
test('启用后按飞书自定义机器人的文本格式 POST', async () => {
|
||||
const { notifier, calls } = setup({
|
||||
notify: { enabled: true, webhookUrl: 'https://open.feishu.cn/open-apis/bot/v2/hook/abc' }
|
||||
});
|
||||
assert.strictEqual(notifier.isEnabled(), true);
|
||||
assert.strictEqual(await notifier.notify('需要验证码'), true);
|
||||
assert.strictEqual(calls.length, 1);
|
||||
assert.strictEqual(calls[0].url, 'https://open.feishu.cn/open-apis/bot/v2/hook/abc');
|
||||
const body = JSON.parse(calls[0].init.body);
|
||||
assert.strictEqual(body.msgtype, 'text');
|
||||
assert.strictEqual(body.content.text, '需要验证码');
|
||||
assert.strictEqual(notifier.sent, 1);
|
||||
});
|
||||
|
||||
test('同一个去重 key 在最小间隔内只发一次(避免反复打扰)', async () => {
|
||||
const { notifier, calls } = setup({
|
||||
notify: { enabled: true, webhookUrl: 'https://example.com/hook' },
|
||||
minIntervalMs: 60000
|
||||
});
|
||||
assert.strictEqual(await notifier.notify('a', { key: 'verification:BotA' }), true);
|
||||
assert.strictEqual(await notifier.notify('a', { key: 'verification:BotA' }), false, '同一问题不重复提醒');
|
||||
assert.strictEqual(calls.length, 1);
|
||||
// 不同 key 互不影响
|
||||
assert.strictEqual(await notifier.notify('b', { key: 'rescue-failed:BotA' }), true);
|
||||
assert.strictEqual(calls.length, 2);
|
||||
// force 可以跳过去重(手动重发用)
|
||||
assert.strictEqual(await notifier.notify('a', { key: 'verification:BotA', force: true }), true);
|
||||
assert.strictEqual(calls.length, 3);
|
||||
});
|
||||
|
||||
test('Webhook 地址可回退环境变量 NOTIFY_WEBHOOK_URL', async () => {
|
||||
const previous = process.env.NOTIFY_WEBHOOK_URL;
|
||||
process.env.NOTIFY_WEBHOOK_URL = 'https://example.com/from-env';
|
||||
try {
|
||||
const { notifier, calls } = setup({ notify: { enabled: true } });
|
||||
assert.strictEqual(notifier.isEnabled(), true, '配置里没填地址时应回退环境变量');
|
||||
await notifier.notify('hi');
|
||||
assert.strictEqual(calls[0].url, 'https://example.com/from-env');
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.NOTIFY_WEBHOOK_URL;
|
||||
else process.env.NOTIFY_WEBHOOK_URL = previous;
|
||||
}
|
||||
});
|
||||
|
||||
test('发送失败与异常都被记下来,不会抛出', async () => {
|
||||
const { notifier } = setup({
|
||||
notify: { enabled: true, webhookUrl: 'https://example.com/hook' },
|
||||
fetchImpl: async () => ({ ok: false, status: 500 })
|
||||
});
|
||||
assert.strictEqual(await notifier.notify('hi'), false);
|
||||
assert.strictEqual(notifier.failed, 1);
|
||||
assert.strictEqual(notifier.lastError, 'HTTP 500');
|
||||
|
||||
const throwing = setup({
|
||||
notify: { enabled: true, webhookUrl: 'https://example.com/hook' },
|
||||
fetchImpl: async () => {
|
||||
throw new Error('网络不通');
|
||||
}
|
||||
});
|
||||
assert.strictEqual(await throwing.notifier.notify('hi'), false, '异常必须被吞掉');
|
||||
assert.strictEqual(throwing.notifier.failed, 1);
|
||||
assert.ok(throwing.notifier.lastError.includes('网络不通'));
|
||||
});
|
||||
|
||||
test('snapshot 只暴露域名,不泄露带凭证的完整地址', async () => {
|
||||
const { notifier } = setup({
|
||||
notify: { enabled: true, webhookUrl: 'https://open.feishu.cn/open-apis/bot/v2/hook/secret-token' }
|
||||
});
|
||||
await notifier.notify('hi');
|
||||
const snap = notifier.snapshot();
|
||||
assert.strictEqual(snap.enabled, true);
|
||||
assert.strictEqual(snap.configured, true);
|
||||
assert.strictEqual(snap.host, 'open.feishu.cn');
|
||||
assert.strictEqual(snap.sent, 1);
|
||||
assert.ok(!JSON.stringify(snap).includes('secret-token'), '完整地址不能出现在接口响应里');
|
||||
});
|
||||
+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();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
const { TargetLeaseTable, DEFAULT_TTL_MS } = require('../src/core/targetLease');
|
||||
|
||||
test('tryAcquire:先抢到的机器人独占,其他机器人拿不到', () => {
|
||||
const table = new TargetLeaseTable();
|
||||
const chunk = { x: 10, z: 20 };
|
||||
assert.strictEqual(table.tryAcquire(chunk, 'A'), true);
|
||||
assert.strictEqual(table.tryAcquire(chunk, 'B'), false, '被别人占用时应失败');
|
||||
assert.strictEqual(table.isLeasedByOther(chunk, 'B'), true);
|
||||
assert.strictEqual(table.isLeasedByOther(chunk, 'A'), false, '自己占用不算「被别人占用」');
|
||||
});
|
||||
|
||||
test('tryAcquire:同一机器人可重复占用(视为续租)', () => {
|
||||
const table = new TargetLeaseTable({ ttlMs: 1000 });
|
||||
const chunk = { x: 1, z: 1 };
|
||||
assert.strictEqual(table.tryAcquire(chunk, 'A'), true);
|
||||
assert.strictEqual(table.tryAcquire(chunk, 'A'), true, '自己再抢一次不应失败');
|
||||
assert.strictEqual(table.size, 1, '不应产生第二条记录');
|
||||
});
|
||||
|
||||
test('租约到期后自动失效,其他机器人可接手(不会永久锁死区块)', async () => {
|
||||
const table = new TargetLeaseTable({ ttlMs: 30 });
|
||||
const chunk = { x: 5, z: 5 };
|
||||
assert.strictEqual(table.tryAcquire(chunk, 'A'), true);
|
||||
assert.strictEqual(table.tryAcquire(chunk, 'B'), false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 45));
|
||||
assert.strictEqual(table.isLeasedByOther(chunk, 'B'), false, '过期后应视为空闲');
|
||||
assert.strictEqual(table.tryAcquire(chunk, 'B'), true, '过期后其他机器人可接手');
|
||||
});
|
||||
|
||||
test('release:只有持有者能释放,释放后别人立刻可用', () => {
|
||||
const table = new TargetLeaseTable();
|
||||
const chunk = { x: 2, z: 3 };
|
||||
table.tryAcquire(chunk, 'A');
|
||||
assert.strictEqual(table.release(chunk, 'B'), false, '非持有者不能释放');
|
||||
assert.strictEqual(table.tryAcquire(chunk, 'B'), false);
|
||||
assert.strictEqual(table.release(chunk, 'A'), true);
|
||||
assert.strictEqual(table.tryAcquire(chunk, 'B'), true);
|
||||
});
|
||||
|
||||
test('releaseAll 释放某机器人的全部占用(停止任务 / 断线时用)', () => {
|
||||
const table = new TargetLeaseTable();
|
||||
table.tryAcquire({ x: 0, z: 0 }, 'A');
|
||||
table.tryAcquire({ x: 1, z: 0 }, 'A');
|
||||
table.tryAcquire({ x: 0, z: 1 }, 'B');
|
||||
assert.strictEqual(table.releaseAll('A'), 2);
|
||||
assert.strictEqual(table.size, 1, '只应剩下 B 的占用');
|
||||
assert.strictEqual(table.isLeasedByOther({ x: 0, z: 0 }, 'B'), false);
|
||||
});
|
||||
|
||||
test('renew 延长租约,防止长距离寻路中途被抢', async () => {
|
||||
const table = new TargetLeaseTable({ ttlMs: 40 });
|
||||
const chunk = { x: 7, z: 7 };
|
||||
table.tryAcquire(chunk, 'A');
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
assert.strictEqual(table.renew(chunk, 'A'), true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
// 已超过原始 TTL,但续租过 → 仍是 A 的
|
||||
assert.strictEqual(table.tryAcquire(chunk, 'B'), false, '续租后不应被抢走');
|
||||
});
|
||||
|
||||
test('renew 对非持有者无效', () => {
|
||||
const table = new TargetLeaseTable();
|
||||
table.tryAcquire({ x: 9, z: 9 }, 'A');
|
||||
assert.strictEqual(table.renew({ x: 9, z: 9 }, 'B'), false);
|
||||
});
|
||||
|
||||
test('sweep 清理过期租约,snapshot 只列出有效占用', async () => {
|
||||
const table = new TargetLeaseTable({ ttlMs: 20 });
|
||||
table.tryAcquire({ x: 1, z: 1 }, 'A');
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
assert.strictEqual(table.sweep(), 1);
|
||||
assert.deepStrictEqual(table.snapshot(), []);
|
||||
});
|
||||
|
||||
test('默认租约时长与配置常量一致', () => {
|
||||
assert.strictEqual(new TargetLeaseTable().ttlMs, DEFAULT_TTL_MS);
|
||||
assert.ok(DEFAULT_TTL_MS >= 60000, '默认不应太短,否则长距离寻路会被抢');
|
||||
});
|
||||
+18
-1
@@ -33,7 +33,8 @@ test('timingSafeEqual 只接受等长且完全相同的字符串', () => {
|
||||
});
|
||||
|
||||
/** 搭建一个使用桩依赖的控制台实例 */
|
||||
function setupConsole() {
|
||||
function setupConsole(options) {
|
||||
const opts = options || {};
|
||||
const map = new Map([[
|
||||
'A',
|
||||
{
|
||||
@@ -73,6 +74,8 @@ function setupConsole() {
|
||||
controller,
|
||||
dataService,
|
||||
logger,
|
||||
bigMap: opts.bigMap,
|
||||
notifier: opts.notifier,
|
||||
getConfig: () => config,
|
||||
applyConfig: (next) => next
|
||||
}, { host: '127.0.0.1', port: 0 });
|
||||
@@ -116,6 +119,20 @@ test('statusPayload 汇总 bot / 任务 / 数据源状态', () => {
|
||||
assert.strictEqual(payload.data.stateCount, 1);
|
||||
assert.strictEqual(payload.claimTime.start, 11);
|
||||
assert.strictEqual(payload.claimTime.end, 22);
|
||||
assert.strictEqual(payload.notify, null, '未注入通知器时为 null(状态接口不应报错)');
|
||||
});
|
||||
|
||||
test('注入通知器时状态接口只暴露域名,不泄露完整 Webhook 地址', () => {
|
||||
const { Notifier } = require('../src/core/notifier');
|
||||
const config = defaultConfig();
|
||||
config.notify.enabled = true;
|
||||
config.notify.webhookUrl = 'https://open.feishu.cn/open-apis/bot/v2/hook/secret-token';
|
||||
const notifier = new Notifier(config, null, { fetchImpl: async () => ({ ok: true, status: 200 }) });
|
||||
const { webConsole } = setupConsole({ notifier });
|
||||
const payload = webConsole.statusPayload();
|
||||
assert.strictEqual(payload.notify.enabled, true);
|
||||
assert.strictEqual(payload.notify.host, 'open.feishu.cn');
|
||||
assert.ok(!JSON.stringify(payload).includes('secret-token'), '完整地址不能出现在状态响应里');
|
||||
});
|
||||
|
||||
test('HTTP 接口:/api/status、/api/config、/api/map 可访问', async () => {
|
||||
|
||||
在新工单中引用
屏蔽一个用户