'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, '未进入世界时没有世界诊断信息'); }); // ==================== 手动移动测试 ==================== 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; } 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('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; } });