按 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` 全绿。
1052 行
42 KiB
JavaScript
1052 行
42 KiB
JavaScript
'use strict';
|
||
|
||
const test = require('node:test');
|
||
const assert = require('node:assert');
|
||
const fs = require('fs');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
|
||
const { BotManager, kindOfPosition, computeReconnectDelay, extractKickReason } = require('../src/bot/botManager');
|
||
const { FakeBot } = require('./helpers/fakeBot');
|
||
const { ChatLog, ChatKind } = require('../src/core/chatLog');
|
||
const { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig');
|
||
const { Logger } = require('../src/util/logger');
|
||
|
||
/** 建一个不连接的机器人管理器(含公屏日志) */
|
||
function setup(options) {
|
||
const opts = options || {};
|
||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-botmgr-'));
|
||
const chatLog = new ChatLog({ dir });
|
||
const config = normalizeConfig(Object.assign(defaultConfig(), opts.config || {}));
|
||
const logger = new Logger({ echo: false });
|
||
const manager = new BotManager({
|
||
id: 'BotA',
|
||
username: 'BotA',
|
||
password: 'pw',
|
||
host: 'mc.example.com',
|
||
port: 25565,
|
||
version: '1.21.4',
|
||
label: '主号'
|
||
}, config, logger, { chatLog });
|
||
const sent = [];
|
||
manager.bot = { chat: (text) => sent.push(text) };
|
||
return { manager, chatLog, config, sent, logger };
|
||
}
|
||
/** 等一轮宏任务,让指令节流定时器真正把内容写出 */
|
||
function drainQueue() {
|
||
return new Promise((resolve) => setTimeout(resolve, 5));
|
||
}
|
||
|
||
test('kindOfPosition 把消息通道映射为公屏日志类型', () => {
|
||
assert.strictEqual(kindOfPosition('chat'), ChatKind.CHAT);
|
||
assert.strictEqual(kindOfPosition('system'), ChatKind.SYSTEM);
|
||
assert.strictEqual(kindOfPosition('game_info'), ChatKind.ACTION);
|
||
assert.strictEqual(kindOfPosition(undefined), ChatKind.CHAT);
|
||
});
|
||
|
||
test('isReady 要求已连接、已进入世界、寻路器就绪且完成登录验证', () => {
|
||
const { manager } = setup();
|
||
manager.state = 'online';
|
||
manager.bot = { entity: {}, pathfinder: {} };
|
||
manager.loggedIn = false;
|
||
assert.strictEqual(manager.isReady(), false, '未完成登录验证不算就绪');
|
||
|
||
manager.loggedIn = true;
|
||
assert.strictEqual(manager.isReady(), true);
|
||
|
||
manager.state = 'connecting';
|
||
assert.strictEqual(manager.isReady(), false);
|
||
});
|
||
|
||
test('未完成登录验证时拒绝发送指令与聊天', () => {
|
||
const { manager, sent } = setup();
|
||
manager.state = 'online';
|
||
manager.loggedIn = false;
|
||
assert.strictEqual(manager.canOperate(), false);
|
||
assert.strictEqual(manager.sendCommand('u claimmode'), false);
|
||
assert.strictEqual(manager.sendChat('大家好'), false);
|
||
assert.deepStrictEqual(sent, [], '不应真的写出任何内容');
|
||
});
|
||
|
||
test('登录验证通过后指令与聊天都会被写出并留档', () => {
|
||
const { manager, chatLog, sent } = setup({ config: { server: { commandCooldownMs: 0 } } });
|
||
manager.state = 'online';
|
||
manager.loggedIn = true;
|
||
|
||
manager.sendCommand('u claimmode', { immediate: true });
|
||
manager.sendChat('大家好', { internal: true });
|
||
manager._chatQueue.length = 0;
|
||
manager._writeChat('大家好');
|
||
|
||
assert.deepStrictEqual(sent, ['/u claimmode', '大家好']);
|
||
const rows = chatLog.list('BotA');
|
||
assert.strictEqual(rows.length, 2);
|
||
assert.strictEqual(rows[0].kind, ChatKind.COMMAND);
|
||
assert.strictEqual(rows[0].message, '/u claimmode');
|
||
assert.strictEqual(rows[1].kind, ChatKind.OUT);
|
||
assert.strictEqual(rows[1].message, '大家好');
|
||
});
|
||
|
||
test('以斜杠开头的聊天内容被当作指令发送', () => {
|
||
const { manager, sent } = setup();
|
||
manager.state = 'online';
|
||
manager.loggedIn = true;
|
||
manager.sendChat('/spawn', { internal: true });
|
||
manager._chatQueue.length = 0;
|
||
// 指令走队列,直接驱动写入
|
||
manager._writeCommand(manager._commandQueue.pop() || 'spawn');
|
||
assert.deepStrictEqual(sent, ['/spawn']);
|
||
});
|
||
|
||
test('internal 选项绕过登录门禁(登录 / 注册流程自身使用)', () => {
|
||
const { manager, sent } = setup();
|
||
manager.state = 'online';
|
||
manager.loggedIn = false;
|
||
assert.strictEqual(manager.sendCommand('login pw', { internal: true, immediate: true }), true);
|
||
assert.deepStrictEqual(sent, ['/login pw']);
|
||
});
|
||
|
||
test('收到「登录成功」提示后置为已登录', () => {
|
||
const { manager } = setup();
|
||
manager.config.server.loginCommand = 'login {password}';
|
||
manager.loggedIn = false;
|
||
manager._handleAuthPrompt('登录成功,欢迎回来');
|
||
assert.strictEqual(manager.loggedIn, true);
|
||
assert.strictEqual(manager.loginFailed, false);
|
||
});
|
||
|
||
test('收到「已登录」提示同样视为通过验证', () => {
|
||
const { manager } = setup();
|
||
manager.loggedIn = false;
|
||
manager._handleAuthPrompt('你已登录,无需重复登录');
|
||
assert.strictEqual(manager.loggedIn, true);
|
||
});
|
||
|
||
test('收到密码错误提示时标记登录失败且不放行操作', () => {
|
||
const { manager } = setup();
|
||
manager.loggedIn = false;
|
||
manager._handleAuthPrompt('密码错误,请重新输入');
|
||
assert.strictEqual(manager.loginFailed, true);
|
||
assert.strictEqual(manager.loggedIn, false);
|
||
assert.strictEqual(manager.canOperate(), false);
|
||
});
|
||
|
||
test('服务器提示注册时自动发送注册指令(绕过门禁)', async () => {
|
||
const { manager, sent } = setup({ config: { server: { commandCooldownMs: 0 } } });
|
||
manager.config.server.registerCommand = 'register {password}';
|
||
manager.config.server.loginCommand = 'login {password}';
|
||
manager.loggedIn = false;
|
||
manager._handleAuthPrompt('请先注册');
|
||
await drainQueue();
|
||
assert.deepStrictEqual(sent, ['/register pw']);
|
||
});
|
||
|
||
test('服务器提示登录时自动发送登录指令(绕过门禁)', async () => {
|
||
const { manager, sent } = setup({ config: { server: { commandCooldownMs: 0 } } });
|
||
manager.config.server.loginCommand = 'login {password}';
|
||
manager.loggedIn = false;
|
||
manager._handleAuthPrompt('请先输入登录密码');
|
||
await drainQueue();
|
||
assert.deepStrictEqual(sent, ['/login pw']);
|
||
});
|
||
|
||
test('登录确认兜底:超时未收到任何提示时按已登录放行', async () => {
|
||
const { manager } = setup({ config: { server: { loginConfirmTimeoutMs: 20 } } });
|
||
manager.loggedIn = false;
|
||
manager.loginFailed = false;
|
||
manager._armLoginConfirmFallback();
|
||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||
assert.strictEqual(manager.loggedIn, true);
|
||
});
|
||
|
||
test('登录确认兜底不会覆盖已明确失败的登录', async () => {
|
||
const { manager } = setup({ config: { server: { loginConfirmTimeoutMs: 20 } } });
|
||
manager.loggedIn = false;
|
||
manager._armLoginConfirmFallback();
|
||
manager.loginFailed = true;
|
||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||
assert.strictEqual(manager.loggedIn, false);
|
||
});
|
||
|
||
test('收到消息时写入公屏日志(含系统消息与发言者)', () => {
|
||
const { manager, chatLog } = setup();
|
||
manager._recordChat('服务器将在 5 分钟后重启', 'system', null);
|
||
manager._recordChat('你好', 'chat', 'Steve');
|
||
const rows = chatLog.list('BotA');
|
||
assert.strictEqual(rows.length, 2);
|
||
assert.strictEqual(rows[0].kind, ChatKind.SYSTEM);
|
||
assert.strictEqual(rows[1].kind, ChatKind.CHAT);
|
||
assert.strictEqual(rows[1].username, 'Steve');
|
||
});
|
||
|
||
test('snapshot 暴露多机器人需要的 id / 备注 / 登录与操作许可', () => {
|
||
const { manager } = setup();
|
||
const snap = manager.snapshot();
|
||
assert.strictEqual(snap.id, 'BotA');
|
||
assert.strictEqual(snap.label, '主号');
|
||
assert.strictEqual(snap.username, 'BotA');
|
||
assert.strictEqual(snap.loggedIn, false);
|
||
assert.strictEqual(snap.canOperate, false);
|
||
assert.strictEqual(snap.loginFailed, false);
|
||
});
|
||
|
||
// ==================== 被服务器反复传送时的日志聚合 ====================
|
||
|
||
test('被服务器传送:首次立即记录,其余聚合为一条汇总', () => {
|
||
const { manager, logger } = setup();
|
||
manager._countForcedMove();
|
||
const afterFirst = logger.list().filter((e) => e.message.includes('被服务器传送'));
|
||
assert.strictEqual(afterFirst.length, 1, '首次传送应立即记一条');
|
||
|
||
// 模拟账号未验证时被每 tick 强推位置:再触发 200 次不应继续刷日志
|
||
for (let i = 0; i < 200; i++) manager._countForcedMove();
|
||
assert.strictEqual(logger.list().filter((e) => e.message.includes('被服务器传送')).length, 1,
|
||
'同一窗口内的重复传送不得逐条记录');
|
||
|
||
manager._flushForcedMove();
|
||
const lines = logger.list().filter((e) => e.message.includes('被服务器传送'));
|
||
assert.strictEqual(lines.length, 2, '窗口结束时汇总成一条');
|
||
assert.ok(lines[1].message.includes('另有 200 次'), '汇总应包含次数');
|
||
assert.strictEqual(lines[1].level, 'warn');
|
||
});
|
||
|
||
test('被服务器传送:窗口内只有一次时不输出多余汇总', () => {
|
||
const { manager, logger } = setup();
|
||
manager._countForcedMove();
|
||
manager._flushForcedMove();
|
||
assert.strictEqual(logger.list().filter((e) => e.message.includes('被服务器传送')).length, 1);
|
||
});
|
||
|
||
test('被服务器传送:断开连接时把未汇总的计数刷出去并清掉定时器', () => {
|
||
const { manager, logger } = setup();
|
||
manager._countForcedMove();
|
||
for (let i = 0; i < 9; i++) manager._countForcedMove();
|
||
manager._clearForcedMoveTimer();
|
||
assert.strictEqual(manager._forcedMoveTimer, null);
|
||
assert.strictEqual(manager._forcedMoveCount, 0);
|
||
const lines = logger.list().filter((e) => e.message.includes('被服务器传送'));
|
||
assert.ok(lines.some((e) => e.message.includes('另有 9 次')));
|
||
});
|
||
|
||
// ==================== 账号绑定 / 二次验证 ====================
|
||
|
||
test('服务器要求账号绑定时给出可操作的中文告警(且只提示一次)', () => {
|
||
const { manager, logger } = setup();
|
||
manager.config.server.loginCommand = 'login {password}';
|
||
manager.loggedIn = false;
|
||
manager._handleAuthPrompt('为了保障服务器安全,您需要绑定您的简幻通账号。');
|
||
manager._handleAuthPrompt('请使用命令: /auth <简幻通UID> <验证码>');
|
||
manager._handleAuthPrompt('请在 120 秒内完成验证,否则将被踢出服务器。');
|
||
|
||
const errors = logger.list().filter((e) => e.level === 'error' && e.message.includes('账号绑定'));
|
||
assert.strictEqual(errors.length, 1, '同一连接内只提示一次,避免刷屏');
|
||
assert.ok(logger.list().some((e) => e.message.includes('需要人工完成一次')));
|
||
assert.strictEqual(manager.loggedIn, false, '未完成验证时不得放行操作');
|
||
});
|
||
|
||
test('配置了账号绑定指令模板时自动发送(绕过门禁)', () => {
|
||
const { manager, sent } = setup();
|
||
manager.config.server.authCommand = 'auth {password}';
|
||
manager.loggedIn = false;
|
||
manager._handleAuthPrompt('请使用命令: /auth <简幻通UID> <验证码>');
|
||
assert.deepStrictEqual(sent, ['/auth pw']);
|
||
});
|
||
|
||
test('未配置账号绑定模板时不乱发指令', () => {
|
||
const { manager, sent } = setup();
|
||
manager.config.server.authCommand = '';
|
||
manager._handleAuthPrompt('请使用命令: /auth <简幻通UID> <验证码>');
|
||
assert.deepStrictEqual(sent, []);
|
||
});
|
||
|
||
test('重新登录后绑定告警标记被复位', () => {
|
||
const { manager } = setup();
|
||
manager._handleAuthPrompt('您需要绑定您的简幻通账号');
|
||
assert.strictEqual(manager._needAuthWarned, true);
|
||
// 模拟 login 事件的处理
|
||
manager._needAuthWarned = false;
|
||
assert.strictEqual(manager._needAuthWarned, false);
|
||
});
|
||
|
||
// ==================== 重连退避 ====================
|
||
|
||
test('computeReconnectDelay 按指数退避并在上限处钳制', () => {
|
||
const server = { reconnectDelayMs: 10000, reconnectMaxDelayMs: 120000 };
|
||
assert.strictEqual(computeReconnectDelay(server, 1), 10000);
|
||
assert.strictEqual(computeReconnectDelay(server, 2), 20000);
|
||
assert.strictEqual(computeReconnectDelay(server, 3), 40000);
|
||
assert.strictEqual(computeReconnectDelay(server, 4), 80000);
|
||
assert.strictEqual(computeReconnectDelay(server, 5), 120000, '超过上限后钳制');
|
||
assert.strictEqual(computeReconnectDelay(server, 30), 120000);
|
||
});
|
||
|
||
test('computeReconnectDelay 缺省上限时退化为固定间隔', () => {
|
||
assert.strictEqual(computeReconnectDelay({ reconnectDelayMs: 5000 }, 1), 5000);
|
||
// 未配置上限时 max 取基准值,等价于固定间隔(保持旧行为,不突然拉长重连)
|
||
assert.strictEqual(computeReconnectDelay({ reconnectDelayMs: 5000 }, 4), 5000);
|
||
assert.strictEqual(computeReconnectDelay({ reconnectDelayMs: 5000, reconnectMaxDelayMs: 5000 }, 4), 5000);
|
||
});
|
||
|
||
test('config 校验补全 authCommand 与 reconnectMaxDelayMs', () => {
|
||
const { config } = setup();
|
||
assert.strictEqual(config.server.authCommand, '');
|
||
assert.strictEqual(config.server.reconnectMaxDelayMs, 120000);
|
||
});
|
||
|
||
// ==================== 免登录指令白名单(避免「先登录才能发登录指令」死锁)====================
|
||
|
||
test('验证类指令在未登录时也放行(login / register / auth)', () => {
|
||
const { manager, sent } = setup({ config: { server: { commandCooldownMs: 0 } } });
|
||
manager.state = 'online';
|
||
manager.loggedIn = false;
|
||
|
||
assert.strictEqual(manager.canSendCommand('login mypass'), true);
|
||
assert.strictEqual(manager.canSendCommand('/auth 12345 6789'), true);
|
||
assert.strictEqual(manager.canSendCommand('register mypass'), true);
|
||
assert.strictEqual(manager.canSendCommand('l mypass'), true);
|
||
|
||
assert.strictEqual(manager.sendCommand('auth 12345 6789', { immediate: true }), true);
|
||
assert.deepStrictEqual(sent, ['/auth 12345 6789']);
|
||
});
|
||
|
||
test('任务类指令在未登录时仍被拒绝', () => {
|
||
const { manager, sent } = setup();
|
||
manager.state = 'online';
|
||
manager.loggedIn = false;
|
||
|
||
assert.strictEqual(manager.canSendCommand('u claimmode'), false);
|
||
assert.strictEqual(manager.canSendCommand('u return'), false);
|
||
assert.strictEqual(manager.canSendCommand('spawn'), false);
|
||
assert.strictEqual(manager.sendCommand('u claimmode'), false);
|
||
assert.deepStrictEqual(sent, []);
|
||
});
|
||
|
||
test('前缀匹配不会误放行同前缀的其它指令', () => {
|
||
const { manager } = setup();
|
||
manager.state = 'online';
|
||
manager.loggedIn = false;
|
||
// auth 放行,但 authx / authenticate 不属于白名单里的精确前缀
|
||
assert.strictEqual(manager.canSendCommand('auth 1 2'), true);
|
||
assert.strictEqual(manager.canSendCommand('authx 1 2'), false);
|
||
assert.strictEqual(manager.canSendCommand('login'), true);
|
||
assert.strictEqual(manager.canSendCommand('loginx'), false);
|
||
});
|
||
|
||
test('白名单可通过配置清空(全部指令都要求登录验证)', () => {
|
||
const { manager } = setup({ config: { server: { preLoginCommands: [] } } });
|
||
manager.state = 'online';
|
||
manager.loggedIn = false;
|
||
assert.strictEqual(manager.canSendCommand('login mypass'), false);
|
||
});
|
||
|
||
test('登录验证通过后白名单不再是唯一通道', () => {
|
||
const { manager } = setup();
|
||
manager.state = 'online';
|
||
manager.loggedIn = true;
|
||
assert.strictEqual(manager.canSendCommand('u claimmode'), true);
|
||
});
|
||
|
||
test('preLoginCommands 配置被归一化(去斜杠、去空项、转小写)', () => {
|
||
const { config } = setup({ config: { server: { preLoginCommands: ['/Login', ' AUTH ', '', ' ', 'reg'] } } });
|
||
assert.deepStrictEqual(config.server.preLoginCommands, ['login', 'auth', 'reg']);
|
||
});
|
||
|
||
// ==================== 没有密码 / 不需要密码的登录流程 ====================
|
||
|
||
test('配置了登录指令模板但没密码时,超时后按「无需登录」放行', async () => {
|
||
const { manager } = setup({ config: { server: { loginConfirmTimeoutMs: 20 } } });
|
||
manager.config.server.loginCommand = 'login {password}';
|
||
manager.connection.password = '';
|
||
manager.state = 'online';
|
||
manager.bot = { entity: {}, pathfinder: {} };
|
||
|
||
manager._handleLogin();
|
||
assert.strictEqual(manager.loggedIn, false, '初始仍视为未验证');
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||
assert.strictEqual(manager.loggedIn, true, '超时后必须放行,否则控制台完全无法操作');
|
||
assert.strictEqual(manager.canOperate(), true);
|
||
});
|
||
|
||
test('服务器不需要登录指令时直接视为已通过验证', () => {
|
||
const { manager } = setup();
|
||
manager.config.server.loginCommand = '';
|
||
manager._handleLogin();
|
||
assert.strictEqual(manager.loggedIn, true);
|
||
});
|
||
|
||
test('服务器要登录但没配密码时给出明确错误提示(只提示一次)', () => {
|
||
const { manager, logger } = setup();
|
||
manager.connection.password = '';
|
||
manager.loggedIn = false;
|
||
manager._handleAuthPrompt('请先输入登录密码');
|
||
manager._handleAuthPrompt('请先输入登录密码');
|
||
const errors = logger.list().filter((e) => e.level === 'error' && e.message.includes('没有该账号的密码'));
|
||
assert.strictEqual(errors.length, 1);
|
||
});
|
||
|
||
// ==================== 验证码是聊天内容而非指令 ====================
|
||
|
||
test('服务器要求验证时放行公屏聊天(验证码常直接当聊天发出)', () => {
|
||
const { manager, sent } = setup();
|
||
manager.state = 'online';
|
||
manager.loggedIn = false;
|
||
manager.bot = { chat: (text) => sent.push(text) };
|
||
|
||
assert.strictEqual(manager.canSendChat(), false, '没有验证要求时默认不放行');
|
||
manager._handleAuthPrompt('您需要绑定您的简幻通账号');
|
||
assert.strictEqual(manager.canSendChat(), true, '服务器要求验证后应放行');
|
||
|
||
manager.sendChat('123456', { internal: true });
|
||
manager._chatQueue.length = 0;
|
||
manager._writeChat('123456');
|
||
assert.deepStrictEqual(sent, ['123456']);
|
||
});
|
||
|
||
test('allowChatBeforeLogin 配置可无条件放行登录前聊天', () => {
|
||
const { manager } = setup({ config: { server: { allowChatBeforeLogin: true } } });
|
||
manager.state = 'online';
|
||
manager.loggedIn = false;
|
||
manager.bot = { chat: () => {} };
|
||
assert.strictEqual(manager.canSendChat(), true);
|
||
});
|
||
|
||
test('未连接时公屏聊天一律不放行', () => {
|
||
const { manager } = setup({ config: { server: { allowChatBeforeLogin: true } } });
|
||
manager.state = 'offline';
|
||
assert.strictEqual(manager.canSendChat(), false);
|
||
});
|
||
|
||
// ==================== 出站位置包的 onGround 纠正 ====================
|
||
|
||
/** 造一个站在实心方块上的假 bot */
|
||
function botStandingOnStone() {
|
||
return new FakeBot({
|
||
x: 0.5,
|
||
y: 72,
|
||
z: 0.5,
|
||
blockMap: new Map([['0,71,0', { name: 'stone', boundingBox: 'block' }]])
|
||
});
|
||
}
|
||
|
||
test('_isStandingOnGround 用脚下方块判断是否站在地面上', () => {
|
||
const { manager } = setup();
|
||
manager.bot = botStandingOnStone();
|
||
assert.strictEqual(manager._isStandingOnGround(), true);
|
||
|
||
manager.bot = new FakeBot({ x: 0.5, y: 72, z: 0.5 });
|
||
assert.strictEqual(manager._isStandingOnGround(), false, '脚下是空气时应为 false');
|
||
|
||
manager.bot = { entity: {} };
|
||
assert.strictEqual(manager._isStandingOnGround(), null, '拿不到世界数据时返回 null');
|
||
});
|
||
|
||
test('出站位置包的 onGround 被世界数据纠正(修复「站地上却报空中」)', () => {
|
||
const { manager } = setup();
|
||
manager.bot = botStandingOnStone();
|
||
|
||
const params = { flags: { onGround: false } };
|
||
manager._fixOutboundOnGround(params);
|
||
assert.strictEqual(params.flags.onGround, true, '站在石头上必须上报 onGround=true');
|
||
assert.strictEqual(manager.packetStats.onGroundFixed, 1);
|
||
});
|
||
|
||
test('onGround 已一致时不改写、不计数', () => {
|
||
const { manager } = setup();
|
||
manager.bot = botStandingOnStone();
|
||
const params = { flags: { onGround: true } };
|
||
manager._fixOutboundOnGround(params);
|
||
assert.strictEqual(manager.packetStats.onGroundFixed, 0);
|
||
});
|
||
|
||
test('关闭 reportOnGroundFromWorld 后完全不干预出站包', () => {
|
||
const { manager } = setup({ config: { grim: { reportOnGroundFromWorld: false } } });
|
||
manager.bot = botStandingOnStone();
|
||
const params = { flags: { onGround: false } };
|
||
manager._fixOutboundOnGround(params);
|
||
assert.strictEqual(params.flags.onGround, false);
|
||
assert.strictEqual(manager.packetStats.onGroundFixed, 0);
|
||
});
|
||
|
||
test('出站包结构异常时不抛异常', () => {
|
||
const { manager } = setup();
|
||
manager.bot = botStandingOnStone();
|
||
assert.doesNotThrow(() => manager._fixOutboundOnGround(null));
|
||
assert.doesNotThrow(() => manager._fixOutboundOnGround({}));
|
||
assert.doesNotThrow(() => manager._fixOutboundOnGround({ flags: {} }));
|
||
});
|
||
|
||
test('位置包统计暴露在 snapshot 中,便于排查走不动的问题', () => {
|
||
const { manager } = setup();
|
||
const snap = manager.snapshot();
|
||
assert.ok(snap.packetStats);
|
||
assert.strictEqual(snap.packetStats.inboundPosition, 0);
|
||
assert.strictEqual(snap.packetStats.outboundPosition, 0);
|
||
assert.strictEqual(snap.onGround, false);
|
||
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 () => {
|
||
const { manager, sent } = setup();
|
||
const bot = botStandingOnStone();
|
||
manager.bot = bot;
|
||
manager.state = 'online';
|
||
const controls = [];
|
||
manager.grim = { setControlState: (c, on) => controls.push([c, on]) };
|
||
// 模拟按住前进键后位置发生变化
|
||
setTimeout(() => { bot.entity.position.z -= 8; }, 30);
|
||
|
||
const result = await manager.walkTest('forward', 0.5);
|
||
assert.strictEqual(result.ok, true);
|
||
assert.ok(result.message.includes('位移'));
|
||
assert.deepStrictEqual(controls, [['forward', true], ['forward', false]], '按下后必须松开');
|
||
assert.ok(Math.abs(result.to.z - result.from.z) > 0.5);
|
||
});
|
||
|
||
test('walkTest 在原地不动时返回失败并记警告', async () => {
|
||
const { manager, logger } = setup();
|
||
manager.bot = botStandingOnStone();
|
||
manager.state = 'online';
|
||
manager.grim = { setControlState: () => {} };
|
||
|
||
const result = await manager.walkTest('forward', 0.5);
|
||
assert.strictEqual(result.ok, false, '没有位移必须如实返回失败,而不是假装成功');
|
||
assert.ok(logger.list().some((e) => e.level === 'warn' && e.message.includes('几乎没有移动')));
|
||
});
|
||
|
||
test('walkTest 拒绝非法方向与未连接状态', async () => {
|
||
const { manager } = setup();
|
||
manager.state = 'online';
|
||
const bad = await manager.walkTest('teleport', 1);
|
||
assert.strictEqual(bad.ok, false);
|
||
assert.ok(bad.message.includes('方向'));
|
||
|
||
manager.state = 'offline';
|
||
const offline = await manager.walkTest('forward', 1);
|
||
assert.strictEqual(offline.ok, false);
|
||
assert.ok(offline.message.includes('未连接'));
|
||
});
|
||
|
||
// ==================== 踢出原因解析 ====================
|
||
|
||
test('extractKickReason 把组件对象提取成纯文本', () => {
|
||
// 服务端踢人时发的是组件对象,直接 JSON.stringify 会得到没法看的一串
|
||
const component = { color: 'red', text: '您已经连接到此代理服务器了!' };
|
||
assert.strictEqual(extractKickReason(component), '您已经连接到此代理服务器了!');
|
||
assert.strictEqual(extractKickReason('普通字符串'), '普通字符串');
|
||
assert.strictEqual(
|
||
extractKickReason({ text: 'You were kicked: ', extra: [{ text: '验证超时' }] }),
|
||
'You were kicked: 验证超时'
|
||
);
|
||
assert.strictEqual(extractKickReason(null), '未知原因');
|
||
assert.strictEqual(extractKickReason(undefined), '未知原因');
|
||
});
|
||
|
||
test('「账号已在别处登录」被识别并给出可操作提示', () => {
|
||
const { manager, logger } = setup();
|
||
const bot = new FakeBot();
|
||
manager.bot = bot;
|
||
manager._wireEvents(bot);
|
||
|
||
bot.emit('kicked', { color: 'red', text: '您已经连接到此代理服务器了!' });
|
||
|
||
assert.strictEqual(manager.alreadyConnected, true, '应标记为「已在别处登录」');
|
||
const lines = logger.list();
|
||
assert.ok(lines.some((e) => e.level === 'error' && e.message.includes('您已经连接到此代理服务器了')),
|
||
'应把组件里的纯文本记进日志');
|
||
assert.ok(lines.some((e) => e.message.includes('该账号已在别处登录') && e.message.includes('客户端')),
|
||
'应提示用户去关掉另一个客户端');
|
||
});
|
||
|
||
test('其它踢出原因不触发「已在别处登录」提示', () => {
|
||
const { manager, logger } = setup();
|
||
const bot = new FakeBot();
|
||
manager.bot = bot;
|
||
manager._wireEvents(bot);
|
||
|
||
bot.emit('kicked', '服务器重启中');
|
||
assert.strictEqual(manager.alreadyConnected, false);
|
||
assert.ok(logger.list().some((e) => e.message.includes('服务器重启中')));
|
||
assert.ok(!logger.list().some((e) => e.message.includes('该账号已在别处登录')));
|
||
});
|
||
|
||
test('账号已在别处登录时重连退避到最大间隔,不频繁硬撞服务器', () => {
|
||
const { manager } = setup();
|
||
manager.config.server.reconnectDelayMs = 10000;
|
||
manager.config.server.reconnectMaxDelayMs = 120000;
|
||
manager.alreadyConnected = true;
|
||
manager._scheduleReconnect();
|
||
assert.ok(manager._reconnectTimer, '应安排重连');
|
||
// 正常退避第 1 次是 10 秒,被踢时应直接跳到上限 120 秒
|
||
const delay = manager._reconnectTimer._idleTimeout;
|
||
assert.strictEqual(delay, 120000, `实际 ${delay}`);
|
||
manager._manualDisconnect = true;
|
||
clearTimeout(manager._reconnectTimer);
|
||
});
|
||
|
||
test('重新登录成功后清掉「已在别处登录」标记', () => {
|
||
const { manager } = setup();
|
||
manager.alreadyConnected = true;
|
||
// 模拟 login 事件的处理
|
||
manager.alreadyConnected = false;
|
||
assert.strictEqual(manager.alreadyConnected, false);
|
||
});
|
||
|
||
test('识别「请直接输入验证码进行验证」(绑定过的账号每次登录仍可能要验证码)', () => {
|
||
const { manager, logger } = setup();
|
||
manager.loggedIn = false;
|
||
manager._handleAuthPrompt('欢迎回来!您绑定的简幻通UID是: 102017');
|
||
manager._handleAuthPrompt('请直接输入验证码进行验证');
|
||
|
||
assert.strictEqual(manager._needAuthWarned, true, '必须识别这句话,否则用户只看到机器人卡在大厅');
|
||
assert.ok(logger.list().some((e) => e.level === 'error' && e.message.includes('二次验证')));
|
||
assert.ok(manager.snapshot().needsVerification === true, 'snapshot 应暴露给控制台弹提示');
|
||
});
|
||
|
||
test('识别「请使用微信扫描手中的二维码进行身份验证」', () => {
|
||
const { manager } = setup();
|
||
manager._handleAuthPrompt('请使用微信扫描手中的二维码进行身份验证');
|
||
assert.strictEqual(manager._needAuthWarned, true);
|
||
});
|
||
|
||
// ==================== 原点(00)自救 ====================
|
||
|
||
/** 把机器人放到世界原点附近(脚下是基岩 —— 实测两种情况都是基岩) */
|
||
function placeAtOrigin(manager, bot, y) {
|
||
bot.entity.position.x = -1.5;
|
||
bot.entity.position.y = y === undefined ? -2 : y;
|
||
bot.entity.position.z = -4.5;
|
||
bot.blockAt = () => ({ name: 'bedrock', boundingBox: 'block' });
|
||
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();
|
||
placeAtOrigin(manager, bot);
|
||
manager._needAuthWarned = false;
|
||
|
||
manager._handleZeroPosition();
|
||
|
||
assert.strictEqual(manager.needsVerificationHint, false, '不应误判成登录服');
|
||
assert.strictEqual(manager._escaping, true, '应开始左右移动自救');
|
||
const lines = logger.list().map((e) => e.message).join('\n');
|
||
assert.ok(lines.includes('疑似死亡后卡住'), '应说明是死亡卡住:' + lines);
|
||
assert.strictEqual(manager.needsVerificationHint, false, '不应把标记置成登录服');
|
||
assert.ok(lines.includes('左右移动'), '应说明自救手段');
|
||
});
|
||
|
||
test('原点附近且服务器要求验证码 → 判定为登录服,提示人工发验证码(不做自救)', () => {
|
||
const { manager, logger } = setup();
|
||
const bot = new FakeBot();
|
||
placeAtOrigin(manager, bot);
|
||
manager._needAuthWarned = true;
|
||
|
||
manager._handleZeroPosition();
|
||
|
||
assert.strictEqual(manager.needsVerificationHint, true);
|
||
assert.notStrictEqual(manager._escaping, true, '登录服里左右移动没有意义');
|
||
const lines = logger.list().map((e) => e.message).join('\n');
|
||
assert.ok(lines.includes('被切到了登录服'), '应明确指出是登录服:' + lines);
|
||
assert.ok(lines.includes('验证码'), '应引导用户发验证码');
|
||
});
|
||
|
||
test('脚下是基岩不能作为区分依据(两种情况都是基岩)', () => {
|
||
const { manager } = setup();
|
||
const bot = new FakeBot();
|
||
placeAtOrigin(manager, bot);
|
||
// 同一个基岩地形,仅凭「服务器要不要验证码」得出不同结论
|
||
manager._needAuthWarned = false;
|
||
manager._handleZeroPosition();
|
||
const asDeath = manager.needsVerificationHint;
|
||
|
||
const second = setup();
|
||
const bot2 = new FakeBot();
|
||
placeAtOrigin(second.manager, bot2);
|
||
second.manager._needAuthWarned = true;
|
||
second.manager._handleZeroPosition();
|
||
|
||
assert.strictEqual(asDeath, false);
|
||
assert.strictEqual(second.manager.needsVerificationHint, true);
|
||
});
|
||
|
||
test('验证码提示到达时若人在原点附近,点明「被反复传送」的因果关系', () => {
|
||
const { manager, logger } = setup();
|
||
const bot = new FakeBot();
|
||
placeAtOrigin(manager, bot);
|
||
manager._needAuthWarned = false;
|
||
|
||
manager._handleAuthPrompt('请直接输入验证码进行验证');
|
||
|
||
assert.strictEqual(manager.needsVerificationHint, true);
|
||
const lines = logger.list().map((e) => e.message).join('\n');
|
||
assert.ok(lines.includes('已被切到登录服'), '应把传送刷屏与登录服关联起来:' + lines);
|
||
});
|
||
|
||
test('不在原点附近时不触发任何原点逻辑', () => {
|
||
const { manager } = setup();
|
||
const bot = new FakeBot();
|
||
bot.entity.position.x = 6760;
|
||
bot.entity.position.y = 148;
|
||
bot.entity.position.z = 11934;
|
||
manager.bot = bot;
|
||
|
||
manager._handleZeroPosition();
|
||
|
||
assert.notStrictEqual(manager._escaping, true);
|
||
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);
|
||
manager.alreadyConnected = true;
|
||
assert.strictEqual(manager.snapshot().alreadyConnected, true);
|
||
});
|
||
|
||
// ==================== 插件加载 ====================
|
||
test('connect 时显式加载 mineflayer-pathfinder 插件', () => {
|
||
const mineflayer = require('mineflayer');
|
||
const originalCreateBot = mineflayer.createBot;
|
||
const loaded = [];
|
||
mineflayer.createBot = () => {
|
||
const fake = new (require('events').EventEmitter)();
|
||
fake.loadPlugin = (plugin) => loaded.push(plugin);
|
||
return fake;
|
||
};
|
||
try {
|
||
const { manager } = setup();
|
||
manager.bot = null; // setup 预置了 bot 桩,这里要真正走一次 connect 流程
|
||
manager.connect();
|
||
assert.strictEqual(loaded.length, 1, '必须调用 loadPlugin,否则 bot.pathfinder 永远为 undefined');
|
||
assert.ok(loaded[0], '加载的应是 pathfinder 插件');
|
||
// 插件加载后 bot.pathfinder 才会有值,isReady() 才可能为真
|
||
assert.strictEqual(manager.isReady(), false, '仅加载插件、未进入世界时仍不算就绪');
|
||
} finally {
|
||
mineflayer.createBot = originalCreateBot;
|
||
}
|
||
});
|
||
|
||
test('加载插件失败时不抛异常,但会记错误日志', () => {
|
||
const mineflayer = require('mineflayer');
|
||
const originalCreateBot = mineflayer.createBot;
|
||
mineflayer.createBot = () => {
|
||
const fake = new (require('events').EventEmitter)();
|
||
fake.loadPlugin = () => { throw new Error('插件加载炸了'); };
|
||
return fake;
|
||
};
|
||
try {
|
||
const { manager, logger } = setup();
|
||
manager.bot = null;
|
||
assert.doesNotThrow(() => manager.connect());
|
||
assert.ok(logger.list().some((e) => e.level === 'error' && e.message.includes('pathfinder')));
|
||
} finally {
|
||
mineflayer.createBot = originalCreateBot;
|
||
}
|
||
});
|