diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fb1813..32f35eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,12 +55,25 @@ - 修正控制台地图瓦片 URL 与后端路由不一致的问题(前端请求 `/api/bigmap/tile/:z/:x_y.png`, 后端路由为 `/api/bigmap/tile/:z/:x/:y.png`) +- **「被服务器传送」日志洪水**:账号未完成服务器验证时,服务端会每 tick 强推一次位置, + 实测 3 分钟刷出 748 行日志,几十分钟就会把 2000 条的日志环形缓冲冲光、并把 WebSocket 打满。 + 现改为首次立即记录、其余按 30 秒窗口聚合成一条(`被服务器传送另有 N 次`), + 并在连接断开时把未汇总的计数刷出去 +- **外站账号绑定流程无法识别**:本项目原先只认 `/login` 与 `/register`, + 遇到「简幻通」这类要求 `/auth <验证码>` 或扫码绑定的服务器时, + 表现只是「连上 → 被传送 → 120 秒后被踢 → 重连」的静默死循环,失败原因对用户完全不可见。 + 现新增 `NEED_AUTH_PATTERN` 识别,命中后输出可操作的中文告警(每个连接只提示一次), + 并新增 `server.authCommand` 模板供配置固定验证码的服务器自动过验证 +- **重连无退避**:连续被踢时每 10 秒硬撞一次服务器。现按指数退避拉长间隔 + (`server.reconnectMaxDelayMs`,默认上限 120 秒),成功进入世界后计数清零 ### 已知限制 - **多个机器人共享一份领土模型**:`markers.json` 是全服数据,没必要每个机器人各拉一遍; 因此「会话新占领」区块是全局共享的,控制台地图的己方邦国显示的是最近启动的那个机器人的邦国 - **大地图层级 3 不预爬取**:全图 80×80=6400 张瓦片约 200MB,改为按需拉取 + 永久缓存 +- **扫码类账号绑定无法自动化**:`/auth <验证码>` 的验证码是动态的、扫码更是需要人工, + 程序只能明确报错提示;绑定是一次性的,绑定成功后同一账号即可自动登录 ## [1.0.0] - 2026-02-14 diff --git a/README.md b/README.md index f733da6..5a7619a 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,9 @@ squaremap 前端用 `L.CRS.Simple` + `tileSize: 512`,瓦片路径为 - **登录确认兜底**:部分离线服登录成功后不回明确提示,`server.loginConfirmTimeoutMs` (默认 10 秒)内既没收到成功也没收到失败提示时按「已登录」放行,避免机器人永远卡死; 收到「密码错误 / 登录失败」则标记 `loginFailed` 且不放行。设为 `0` 可禁用兜底 +- **外站账号绑定(如简幻通)**:服务器要求 `/auth <验证码>` 或扫码绑定时, + 密码无法自动完成,程序会输出明确的中文告警(每个连接只提示一次)而不是静默死循环。 + 若该服务器提供固定验证码,可在配置的「账号绑定 / 二次验证模板」(`server.authCommand`)里填写 **控制台侧**(`src/web/server.js`) @@ -176,6 +179,61 @@ squaremap 前端用 `L.CRS.Simple` + `tileSize: 512`,瓦片路径为 --- +## 部署到服务器 + +本项目已在 Debian 12 上以 pm2 常驻运行验证过,关键点: + +### 1. Node.js 版本要求 ≥ 22 + +`mineflayer 4.39` 的 `engines` 声明为 `>=22`,用 Node 18 会在运行时出问题。 +若服务器上已有别的服务依赖旧版 Node,**不要升级系统 Node**,装一份项目私有运行时即可: + +```bash +mkdir -p /opt/autostore-bot/runtime && cd /opt/autostore-bot/runtime +curl -fsSL -o node.tar.xz https://nodejs.org/dist/v24.16.0/node-v24.16.0-linux-x64.tar.xz +tar -xJf node.tar.xz && rm node.tar.xz +ln -sfn /opt/autostore-bot/runtime/node-v24.16.0-linux-x64 /opt/autostore-bot/runtime/current +``` + +### 2. 环境变量 + +对外提供控制台时,`WEB_HOST` 必须是 `0.0.0.0`,并且**必须**设置 `WEB_PASSWORD` +(否则任何人访问该端口都能操作你的游戏账号): + +```bash +WEB_HOST=0.0.0.0 +WEB_PORT=78 +WEB_PASSWORD=<强密码> +``` + +### 3. pm2 常驻 + +```bash +export PATH=/opt/autostore-bot/runtime/current/bin:$PATH +npm install --omit=dev +pm2 start ecosystem.config.js # interpreter 指向项目私有 Node +pm2 save +pm2 startup systemd -u root --hp /root # 开机自启 +``` + +`ecosystem.config.js` 里把 `interpreter` 显式指向私有运行时,避免 pm2 用系统 Node 启动。 + +### 4. 日志分两个文件 + +`data/pm2-out.log` 是 stdout(info 级),`data/pm2-error.log` 是 stderr +(**warn 与 error 级都在这里**)。排查问题时两个都要看; +控制台页面上的运行日志是内存环形缓冲,包含全部级别,不受这个拆分影响。 + +### 5. 排障经验 + +| 现象 | 原因 | 处理 | +| --- | --- | --- | +| 日志被「被服务器传送」刷屏 | 账号未完成服务器验证,服务端每 tick 强推位置 | 已按 30 秒窗口聚合;看是否有「账号绑定」告警 | +| 连上 120 秒后被踢,反复循环 | 服务器要求外站账号绑定(如简幻通 `/auth`) | 人工完成一次绑定;绑定后同账号即可自动登录 | +| `mineflayer` 加载报错 / 语法不支持 | 系统 Node < 22 | 用项目私有运行时启动 | + +--- + ## Grim 反作弊适配 这是本次重构最核心的工程问题。mineflayer 是第三方协议实现,**它的物理引擎(prismarine-physics) diff --git a/src/bot/botManager.js b/src/bot/botManager.js index 6d1d97f..5bc5259 100644 --- a/src/bot/botManager.js +++ b/src/bot/botManager.js @@ -18,6 +18,22 @@ const LOGIN_OK_PATTERN = /登录成功|登陆成功|注册成功|欢迎回来| const ALREADY_LOGGED_IN_PATTERN = /已登录|无需登录|你已经登录/; /** 登录 / 注册明确失败的关键词(命中后不再自动重试,也不放行操作) */ const LOGIN_FAIL_PATTERN = /密码(错误|不正确|有误)|登录失败|注册失败|wrong password|invalid password/i; +/** + * 服务器要求完成账号绑定 / 二次验证时的提示关键词。 + * + * 典型场景:服务器用「简幻通」等外站账号体系,新账号必须先用 `/auth <验证码>` + * 或扫码完成绑定,否则会被反复传送、并在倒计时结束后踢出。 + * 这种流程无法靠密码自动完成,因此只做「明确告知 + 可选模板」处理。 + */ +const NEED_AUTH_PATTERN = /需要绑定|绑定您的|绑定你的|请使用命令:\s*\/?auth|扫描.{0,6}二维码|扫码|完成验证|身份验证/i; + +/** + * 「被服务器传送」事件的日志聚合窗口(毫秒)。 + * + * 账号未通过验证时,服务器会每 tick 强推一次位置,逐条记日志会在几分钟内 + * 刷掉整个日志环形缓冲并把 WebSocket 打满,因此这里按窗口聚合。 + */ +const FORCED_MOVE_WINDOW_MS = 30000; /** * 机器人管理器:负责连接、登录、断线重连,并把底层 mineflayer 事件 @@ -91,6 +107,12 @@ class BotManager extends EventEmitter { this._commandTimer = null; /** 最近一次收到过的聊天消息(供 Web 控制台展示) */ this.lastMessages = []; + /** 本次连接内「被服务器传送」的计数与聚合定时器 */ + this._forcedMoveCount = 0; + this._forcedMoveFirstAt = 0; + this._forcedMoveTimer = null; + /** 本次连接内是否已提示过「账号需要绑定 / 二次验证」 */ + this._needAuthWarned = false; } // ==================== 连接生命周期 ==================== @@ -131,6 +153,7 @@ class BotManager extends EventEmitter { this._reconnectTimer = null; } this._clearLoginConfirmTimer(); + this._clearForcedMoveTimer(); this._commandQueue.length = 0; this._chatQueue.length = 0; if (this.bot) { @@ -158,8 +181,8 @@ class BotManager extends EventEmitter { if (this._manualDisconnect || !this.config.server.autoReconnect) return; if (this._reconnectTimer) return; this.reconnectAttempts++; - const delay = this.config.server.reconnectDelayMs; - this._log(`${delay / 1000} 秒后尝试第 ${this.reconnectAttempts} 次重连`); + const delay = computeReconnectDelay(this.config.server, this.reconnectAttempts); + this._log(`${Math.round(delay / 1000)} 秒后尝试第 ${this.reconnectAttempts} 次重连`); this._reconnectTimer = setTimeout(() => { this._reconnectTimer = null; this.bot = null; @@ -173,6 +196,7 @@ class BotManager extends EventEmitter { _wireEvents(bot) { bot.on('login', () => { this.state = 'online'; + this._needAuthWarned = false; this._log('已连接到服务器,正在进入世界'); this._handleLogin(); }); @@ -188,6 +212,7 @@ class BotManager extends EventEmitter { this.state = 'offline'; this.loggedIn = false; this._clearLoginConfirmTimer(); + this._clearForcedMoveTimer(); this._log('连接已断开:' + (reason || '未知原因')); this.emit('end', reason); this._scheduleReconnect(); @@ -241,11 +266,38 @@ class BotManager extends EventEmitter { }); bot.on('forcedMove', () => { - this._log('被服务器传送(位置强制同步)'); this.emit('forcedMove'); + this._countForcedMove(); }); } + /** + * 聚合「被服务器传送」事件:首次立即记一条,其余按 {@link FORCED_MOVE_WINDOW_MS} + * 窗口汇总成一条,避免账号未验证时把日志刷爆。 + */ + _countForcedMove() { + this._forcedMoveCount++; + if (this._forcedMoveCount === 1) { + this._forcedMoveFirstAt = Date.now(); + this._log('被服务器传送(位置强制同步)'); + } + if (!this._forcedMoveTimer) { + this._forcedMoveTimer = setTimeout(() => this._flushForcedMove(), FORCED_MOVE_WINDOW_MS); + if (this._forcedMoveTimer.unref) this._forcedMoveTimer.unref(); + } + } + + /** 输出传送事件汇总并重置计数 */ + _flushForcedMove() { + this._forcedMoveTimer = null; + const extra = this._forcedMoveCount - 1; + this._forcedMoveCount = 0; + if (extra <= 0) return; + const seconds = Math.max(1, Math.round((Date.now() - this._forcedMoveFirstAt) / 1000)); + this._log(`被服务器传送另有 ${extra} 次(最近 ${seconds} 秒内):通常是账号未完成服务器验证` + + `(如简幻通绑定)被反复拉回,或被 /spawn 类指令重置位置`, 'warn'); + } + /** 在世界里装配各子模块(Grim 层、寻路、进食、用船、耐久) */ _installModules() { const bot = this.bot; @@ -318,6 +370,17 @@ class BotManager extends EventEmitter { } } + /** 断开前把未汇总的传送计数刷出去并清掉定时器 */ + _clearForcedMoveTimer() { + if (this._forcedMoveTimer) { + clearTimeout(this._forcedMoveTimer); + this._forcedMoveTimer = null; + const extra = this._forcedMoveCount - 1; + this._forcedMoveCount = 0; + if (extra > 0) this._log(`被服务器传送另有 ${extra} 次(连接断开前)`, 'warn'); + } + } + /** 从聊天消息里识别服务器的登录 / 注册提示 */ _handleAuthPrompt(message) { const server = this.config.server; @@ -329,6 +392,20 @@ class BotManager extends EventEmitter { this._log('登录 / 注册被服务器拒绝:' + message, 'error'); return; } + // 外站账号绑定 / 二次验证(如简幻通):密码无法自动完成,只能明确告知用户 + if (NEED_AUTH_PATTERN.test(message)) { + if (!this._needAuthWarned) { + this._needAuthWarned = true; + this._log('服务器要求先完成账号绑定 / 二次验证(本服为「简幻通」体系):' + message, 'error'); + this._log('该验证需要人工完成一次(游戏内扫码或 /auth <验证码>);' + + '绑定成功后同一账号即可自动登录。若服务器提供固定验证码,可在配置的「账号绑定指令模板」里填写。', 'warn'); + } + if (server.authCommand) { + this.sendCommand(fillTemplate(server.authCommand, password), { internal: true, immediate: true }); + this._armLoginConfirmFallback(); + } + return; + } if (!password) return; if (LOGIN_OK_PATTERN.test(message)) { if (!this.loggedIn) this._log('登录成功', 'success'); @@ -557,4 +634,21 @@ function fillTemplate(template, password) { return String(template).replace(/\{password\}/g, password); } -module.exports = { BotManager, fillTemplate, kindOfPosition }; +/** + * 计算第 n 次重连的等待时长:按指数退避逐步拉长,并钳制在上限内。 + * + * 连续重连失败(例如账号未完成服务器验证被反复踢出)时,固定 10 秒一次地硬撞服务器 + * 既无意义又可能触发风控;成功进入世界后 `reconnectAttempts` 会清零,间隔回到基准值。 + * + * @param {{reconnectDelayMs: number, reconnectMaxDelayMs?: number}} serverConfig + * @param {number} attempts 本次是第几次重连(从 1 开始) + * @returns {number} 等待毫秒数 + */ +function computeReconnectDelay(serverConfig, attempts) { + const base = Math.max(1, Number(serverConfig.reconnectDelayMs) || 10000); + const max = Math.max(base, Number(serverConfig.reconnectMaxDelayMs) || base); + const step = Math.max(1, Math.trunc(attempts) || 1); + return Math.min(max, base * Math.pow(2, Math.min(6, step - 1))); +} + +module.exports = { BotManager, fillTemplate, kindOfPosition, computeReconnectDelay, FORCED_MOVE_WINDOW_MS }; diff --git a/src/config/expansionConfig.js b/src/config/expansionConfig.js index 5f73a39..b300e52 100644 --- a/src/config/expansionConfig.js +++ b/src/config/expansionConfig.js @@ -199,6 +199,14 @@ function defaultConfig() { loginCommand: 'login {password}', /** 注册指令模板,`{password}` 会被替换;留空则不发送 */ registerCommand: '', + /** + * 账号绑定 / 二次验证指令模板(`{password}` 会被替换),留空则不发送。 + * + * 用于「简幻通」这类外站账号体系:新账号必须先用 `/auth <验证码>` 或扫码绑定。 + * 扫码类验证无法自动完成,此时保持留空即可——程序会在日志里明确提示需要人工验证一次; + * 若该服务器提供固定验证码,把 `auth {password}` 之类填进来就能自动过。 + */ + authCommand: '', /** * 登录结果确认超时(毫秒)。 * 部分离线服登录成功后不回明确提示,超时后按「已登录」放行, @@ -210,7 +218,14 @@ function defaultConfig() { /** 断线后是否自动重连 */ autoReconnect: true, /** 重连间隔(毫秒) */ - reconnectDelayMs: 10000 + reconnectDelayMs: 10000, + /** + * 重连间隔上限(毫秒)。 + * + * 连续重连失败(例如账号未完成服务器验证被反复踢出)时按指数退避拉长间隔, + * 避免每 10 秒一次地硬撞服务器;成功进入世界后计数清零、间隔回到 reconnectDelayMs。 + */ + reconnectMaxDelayMs: 120000 } }; } @@ -311,7 +326,9 @@ function validateConfig(config) { config.server.commandCooldownMs = clampInt(config.server.commandCooldownMs, 0, 60000, 1200); config.server.reconnectDelayMs = clampInt(config.server.reconnectDelayMs, 1000, 600000, 10000); + config.server.reconnectMaxDelayMs = clampInt(config.server.reconnectMaxDelayMs, config.server.reconnectDelayMs, 3600000, 120000); config.server.loginConfirmTimeoutMs = clampInt(config.server.loginConfirmTimeoutMs, 0, 300000, 10000); + if (typeof config.server.authCommand !== 'string') config.server.authCommand = ''; if (config.waterMode !== WATER_MODE.SWIM && config.waterMode !== WATER_MODE.BOAT) { config.waterMode = WATER_MODE.AVOID; diff --git a/src/web/public/app.js b/src/web/public/app.js index 75308a4..9b5f727 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -270,10 +270,12 @@ const CONFIG_SCHEMA = { { path: 'server.claimCommand', label: '开垦模式指令', type: 'text', hint: '不带斜杠;留空表示服务器自动开启' }, { path: 'server.loginCommand', label: '登录指令模板', type: 'text', hint: '用 {password} 占位,密码取自 .env 的 MC_BOTS / MC_PASSWORD' }, { path: 'server.registerCommand', label: '注册指令模板', type: 'text', hint: '留空则不自动注册' }, + { path: 'server.authCommand', label: '账号绑定 / 二次验证模板', type: 'text', hint: '如简幻通的 auth {password};扫码类验证无法自动完成,留空即可(日志会提示人工验证一次)' }, { path: 'server.loginConfirmTimeoutMs', label: '登录结果确认超时(毫秒)', type: 'number', min: 0, hint: '超时未收到登录提示则按已登录放行;0 表示禁用兜底' }, { path: 'server.commandCooldownMs', label: '指令最小间隔(毫秒)', type: 'number', min: 0 }, { path: 'server.autoReconnect', label: '断线自动重连', type: 'checkbox' }, - { path: 'server.reconnectDelayMs', label: '重连间隔(毫秒)', type: 'number', min: 1000 } + { path: 'server.reconnectDelayMs', label: '重连间隔(毫秒)', type: 'number', min: 1000 }, + { path: 'server.reconnectMaxDelayMs', label: '重连间隔上限(毫秒)', type: 'number', min: 1000, hint: '连续重连失败时按指数退避,最多退到这个间隔' } ] } ] diff --git a/test/botManager.test.js b/test/botManager.test.js index 50df9e3..dd69ecf 100644 --- a/test/botManager.test.js +++ b/test/botManager.test.js @@ -6,7 +6,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -const { BotManager, kindOfPosition } = require('../src/bot/botManager'); +const { BotManager, kindOfPosition, computeReconnectDelay } = require('../src/bot/botManager'); const { ChatLog, ChatKind } = require('../src/core/chatLog'); const { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig'); const { Logger } = require('../src/util/logger'); @@ -20,6 +20,7 @@ function setup(options) { if (opts.config && opts.config.server) { Object.assign(config.server, opts.config.server); } + const logger = new Logger({ echo: false }); const manager = new BotManager({ id: 'BotA', username: 'BotA', @@ -28,12 +29,11 @@ function setup(options) { port: 25565, version: '1.21.4', label: '主号' - }, config, new Logger({ echo: false }), { chatLog }); + }, config, logger, { chatLog }); const sent = []; manager.bot = { chat: (text) => sent.push(text) }; - return { manager, chatLog, config, sent }; + return { manager, chatLog, config, sent, logger }; } - /** 等一轮宏任务,让指令节流定时器真正把内容写出 */ function drainQueue() { return new Promise((resolve) => setTimeout(resolve, 5)); @@ -191,3 +191,106 @@ test('snapshot 暴露多机器人需要的 id / 备注 / 登录与操作许可', 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); +});