fix: 修正服务器反复传送刷屏日志、识别外站账号绑定流程、重连指数退避

部署到 Debian 服务器时暴露的三个真实缺陷:

1. 「被服务器传送」日志洪水
   账号未完成服务器验证时服务端每 tick 强推一次位置,实测 3 分钟刷出 748 行日志,
   几十分钟就会冲光 2000 条的日志环形缓冲并把 WebSocket 打满。
   现改为首次立即记录、其余按 30 秒窗口聚合成一条,断开连接时刷出未汇总计数。

2. 外站账号绑定流程无法识别
   原先只认 /login 与 /register,遇到简幻通这类要求 /auth <UID> <验证码> 或扫码绑定的
   服务器时,表现只是「连上→被传送→120 秒后被踢→重连」的静默死循环。
   现新增 NEED_AUTH_PATTERN 识别并输出可操作的中文告警(每连接一次),
   新增 server.authCommand 模板供有固定验证码的服务器自动过验证。

3. 重连无退避
   连续被踢时每 10 秒硬撞服务器。现按指数退避拉长(server.reconnectMaxDelayMs,
   默认上限 120 秒),成功进入世界后清零;抽出 computeReconnectDelay 便于测试。

顺带补充 README 的「部署到服务器」章节(Node ≥ 22 要求、私有运行时、pm2、日志分文件、排障表)。

测试从 242 增至 252,全部通过。
这个提交包含在:
lyizai
2026-09-27 22:02:24 +08:00
父节点 39624da2d5
当前提交 d6b1339867
共修改 6 个文件,包含 297 行新增和 10 行删除
+13
查看文件
@@ -55,12 +55,25 @@
- 修正控制台地图瓦片 URL 与后端路由不一致的问题(前端请求 `/api/bigmap/tile/:z/:x_y.png`, - 修正控制台地图瓦片 URL 与后端路由不一致的问题(前端请求 `/api/bigmap/tile/:z/:x_y.png`,
后端路由为 `/api/bigmap/tile/:z/:x/:y.png`) 后端路由为 `/api/bigmap/tile/:z/:x/:y.png`)
- **「被服务器传送」日志洪水**:账号未完成服务器验证时,服务端会每 tick 强推一次位置,
实测 3 分钟刷出 748 行日志,几十分钟就会把 2000 条的日志环形缓冲冲光、并把 WebSocket 打满。
现改为首次立即记录、其余按 30 秒窗口聚合成一条(`被服务器传送另有 N 次`),
并在连接断开时把未汇总的计数刷出去
- **外站账号绑定流程无法识别**:本项目原先只认 `/login` 与 `/register`,
遇到「简幻通」这类要求 `/auth <UID> <验证码>` 或扫码绑定的服务器时,
表现只是「连上 → 被传送 → 120 秒后被踢 → 重连」的静默死循环,失败原因对用户完全不可见。
现新增 `NEED_AUTH_PATTERN` 识别,命中后输出可操作的中文告警(每个连接只提示一次),
并新增 `server.authCommand` 模板供配置固定验证码的服务器自动过验证
- **重连无退避**:连续被踢时每 10 秒硬撞一次服务器。现按指数退避拉长间隔
(`server.reconnectMaxDelayMs`,默认上限 120 秒),成功进入世界后计数清零
### 已知限制 ### 已知限制
- **多个机器人共享一份领土模型**:`markers.json` 是全服数据,没必要每个机器人各拉一遍; - **多个机器人共享一份领土模型**:`markers.json` 是全服数据,没必要每个机器人各拉一遍;
因此「会话新占领」区块是全局共享的,控制台地图的己方邦国显示的是最近启动的那个机器人的邦国 因此「会话新占领」区块是全局共享的,控制台地图的己方邦国显示的是最近启动的那个机器人的邦国
- **大地图层级 3 不预爬取**:全图 80×80=6400 张瓦片约 200MB,改为按需拉取 + 永久缓存 - **大地图层级 3 不预爬取**:全图 80×80=6400 张瓦片约 200MB,改为按需拉取 + 永久缓存
- **扫码类账号绑定无法自动化**:`/auth <UID> <验证码>` 的验证码是动态的、扫码更是需要人工,
程序只能明确报错提示;绑定是一次性的,绑定成功后同一账号即可自动登录
## [1.0.0] - 2026-02-14 ## [1.0.0] - 2026-02-14
+58
查看文件
@@ -166,6 +166,9 @@ squaremap 前端用 `L.CRS.Simple` + `tileSize: 512`,瓦片路径为
- **登录确认兜底**:部分离线服登录成功后不回明确提示,`server.loginConfirmTimeoutMs` - **登录确认兜底**:部分离线服登录成功后不回明确提示,`server.loginConfirmTimeoutMs`
(默认 10 秒)内既没收到成功也没收到失败提示时按「已登录」放行,避免机器人永远卡死; (默认 10 秒)内既没收到成功也没收到失败提示时按「已登录」放行,避免机器人永远卡死;
收到「密码错误 / 登录失败」则标记 `loginFailed` 且不放行。设为 `0` 可禁用兜底 收到「密码错误 / 登录失败」则标记 `loginFailed` 且不放行。设为 `0` 可禁用兜底
- **外站账号绑定(如简幻通)**:服务器要求 `/auth <UID> <验证码>` 或扫码绑定时,
密码无法自动完成,程序会输出明确的中文告警(每个连接只提示一次)而不是静默死循环。
若该服务器提供固定验证码,可在配置的「账号绑定 / 二次验证模板」(`server.authCommand`)里填写
**控制台侧**(`src/web/server.js`) **控制台侧**(`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 反作弊适配 ## Grim 反作弊适配
这是本次重构最核心的工程问题。mineflayer 是第三方协议实现,**它的物理引擎(prismarine-physics) 这是本次重构最核心的工程问题。mineflayer 是第三方协议实现,**它的物理引擎(prismarine-physics)
+98 -4
查看文件
@@ -18,6 +18,22 @@ const LOGIN_OK_PATTERN = /登录成功|登陆成功|注册成功|欢迎回来|
const ALREADY_LOGGED_IN_PATTERN = /已登录|无需登录|你已经登录/; const ALREADY_LOGGED_IN_PATTERN = /已登录|无需登录|你已经登录/;
/** 登录 / 注册明确失败的关键词(命中后不再自动重试,也不放行操作) */ /** 登录 / 注册明确失败的关键词(命中后不再自动重试,也不放行操作) */
const LOGIN_FAIL_PATTERN = /密码(错误|不正确|有误)|登录失败|注册失败|wrong password|invalid password/i; const LOGIN_FAIL_PATTERN = /密码(错误|不正确|有误)|登录失败|注册失败|wrong password|invalid password/i;
/**
* 服务器要求完成账号绑定 / 二次验证时的提示关键词。
*
* 典型场景:服务器用「简幻通」等外站账号体系,新账号必须先用 `/auth <UID> <验证码>`
* 或扫码完成绑定,否则会被反复传送、并在倒计时结束后踢出。
* 这种流程无法靠密码自动完成,因此只做「明确告知 + 可选模板」处理。
*/
const NEED_AUTH_PATTERN = /需要绑定|绑定您的|绑定你的|请使用命令:\s*\/?auth|扫描.{0,6}二维码|扫码|完成验证|身份验证/i;
/**
* 「被服务器传送」事件的日志聚合窗口(毫秒)。
*
* 账号未通过验证时,服务器会每 tick 强推一次位置,逐条记日志会在几分钟内
* 刷掉整个日志环形缓冲并把 WebSocket 打满,因此这里按窗口聚合。
*/
const FORCED_MOVE_WINDOW_MS = 30000;
/** /**
* 机器人管理器:负责连接、登录、断线重连,并把底层 mineflayer 事件 * 机器人管理器:负责连接、登录、断线重连,并把底层 mineflayer 事件
@@ -91,6 +107,12 @@ class BotManager extends EventEmitter {
this._commandTimer = null; this._commandTimer = null;
/** 最近一次收到过的聊天消息(供 Web 控制台展示) */ /** 最近一次收到过的聊天消息(供 Web 控制台展示) */
this.lastMessages = []; 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._reconnectTimer = null;
} }
this._clearLoginConfirmTimer(); this._clearLoginConfirmTimer();
this._clearForcedMoveTimer();
this._commandQueue.length = 0; this._commandQueue.length = 0;
this._chatQueue.length = 0; this._chatQueue.length = 0;
if (this.bot) { if (this.bot) {
@@ -158,8 +181,8 @@ class BotManager extends EventEmitter {
if (this._manualDisconnect || !this.config.server.autoReconnect) return; if (this._manualDisconnect || !this.config.server.autoReconnect) return;
if (this._reconnectTimer) return; if (this._reconnectTimer) return;
this.reconnectAttempts++; this.reconnectAttempts++;
const delay = this.config.server.reconnectDelayMs; const delay = computeReconnectDelay(this.config.server, this.reconnectAttempts);
this._log(`${delay / 1000} 秒后尝试第 ${this.reconnectAttempts} 次重连`); this._log(`${Math.round(delay / 1000)} 秒后尝试第 ${this.reconnectAttempts} 次重连`);
this._reconnectTimer = setTimeout(() => { this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null; this._reconnectTimer = null;
this.bot = null; this.bot = null;
@@ -173,6 +196,7 @@ class BotManager extends EventEmitter {
_wireEvents(bot) { _wireEvents(bot) {
bot.on('login', () => { bot.on('login', () => {
this.state = 'online'; this.state = 'online';
this._needAuthWarned = false;
this._log('已连接到服务器,正在进入世界'); this._log('已连接到服务器,正在进入世界');
this._handleLogin(); this._handleLogin();
}); });
@@ -188,6 +212,7 @@ class BotManager extends EventEmitter {
this.state = 'offline'; this.state = 'offline';
this.loggedIn = false; this.loggedIn = false;
this._clearLoginConfirmTimer(); this._clearLoginConfirmTimer();
this._clearForcedMoveTimer();
this._log('连接已断开:' + (reason || '未知原因')); this._log('连接已断开:' + (reason || '未知原因'));
this.emit('end', reason); this.emit('end', reason);
this._scheduleReconnect(); this._scheduleReconnect();
@@ -241,11 +266,38 @@ class BotManager extends EventEmitter {
}); });
bot.on('forcedMove', () => { bot.on('forcedMove', () => {
this._log('被服务器传送(位置强制同步)');
this.emit('forcedMove'); 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 层、寻路、进食、用船、耐久) */ /** 在世界里装配各子模块(Grim 层、寻路、进食、用船、耐久) */
_installModules() { _installModules() {
const bot = this.bot; 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) { _handleAuthPrompt(message) {
const server = this.config.server; const server = this.config.server;
@@ -329,6 +392,20 @@ class BotManager extends EventEmitter {
this._log('登录 / 注册被服务器拒绝:' + message, 'error'); this._log('登录 / 注册被服务器拒绝:' + message, 'error');
return; return;
} }
// 外站账号绑定 / 二次验证(如简幻通):密码无法自动完成,只能明确告知用户
if (NEED_AUTH_PATTERN.test(message)) {
if (!this._needAuthWarned) {
this._needAuthWarned = true;
this._log('服务器要求先完成账号绑定 / 二次验证(本服为「简幻通」体系):' + message, 'error');
this._log('该验证需要人工完成一次(游戏内扫码或 /auth <UID> <验证码>);'
+ '绑定成功后同一账号即可自动登录。若服务器提供固定验证码,可在配置的「账号绑定指令模板」里填写。', 'warn');
}
if (server.authCommand) {
this.sendCommand(fillTemplate(server.authCommand, password), { internal: true, immediate: true });
this._armLoginConfirmFallback();
}
return;
}
if (!password) return; if (!password) return;
if (LOGIN_OK_PATTERN.test(message)) { if (LOGIN_OK_PATTERN.test(message)) {
if (!this.loggedIn) this._log('登录成功', 'success'); if (!this.loggedIn) this._log('登录成功', 'success');
@@ -557,4 +634,21 @@ function fillTemplate(template, password) {
return String(template).replace(/\{password\}/g, 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 };
+18 -1
查看文件
@@ -199,6 +199,14 @@ function defaultConfig() {
loginCommand: 'login {password}', loginCommand: 'login {password}',
/** 注册指令模板,`{password}` 会被替换;留空则不发送 */ /** 注册指令模板,`{password}` 会被替换;留空则不发送 */
registerCommand: '', registerCommand: '',
/**
* 账号绑定 / 二次验证指令模板(`{password}` 会被替换),留空则不发送。
*
* 用于「简幻通」这类外站账号体系:新账号必须先用 `/auth <UID> <验证码>` 或扫码绑定。
* 扫码类验证无法自动完成,此时保持留空即可——程序会在日志里明确提示需要人工验证一次;
* 若该服务器提供固定验证码,把 `auth {password}` 之类填进来就能自动过。
*/
authCommand: '',
/** /**
* 登录结果确认超时(毫秒)。 * 登录结果确认超时(毫秒)。
* 部分离线服登录成功后不回明确提示,超时后按「已登录」放行, * 部分离线服登录成功后不回明确提示,超时后按「已登录」放行,
@@ -210,7 +218,14 @@ function defaultConfig() {
/** 断线后是否自动重连 */ /** 断线后是否自动重连 */
autoReconnect: true, 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.commandCooldownMs = clampInt(config.server.commandCooldownMs, 0, 60000, 1200);
config.server.reconnectDelayMs = clampInt(config.server.reconnectDelayMs, 1000, 600000, 10000); 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); 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) { if (config.waterMode !== WATER_MODE.SWIM && config.waterMode !== WATER_MODE.BOAT) {
config.waterMode = WATER_MODE.AVOID; config.waterMode = WATER_MODE.AVOID;
+3 -1
查看文件
@@ -270,10 +270,12 @@ const CONFIG_SCHEMA = {
{ path: 'server.claimCommand', label: '开垦模式指令', type: 'text', hint: '不带斜杠;留空表示服务器自动开启' }, { path: 'server.claimCommand', label: '开垦模式指令', type: 'text', hint: '不带斜杠;留空表示服务器自动开启' },
{ path: 'server.loginCommand', label: '登录指令模板', type: 'text', hint: '用 {password} 占位,密码取自 .env 的 MC_BOTS / MC_PASSWORD' }, { path: 'server.loginCommand', label: '登录指令模板', type: 'text', hint: '用 {password} 占位,密码取自 .env 的 MC_BOTS / MC_PASSWORD' },
{ path: 'server.registerCommand', label: '注册指令模板', type: 'text', hint: '留空则不自动注册' }, { 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.loginConfirmTimeoutMs', label: '登录结果确认超时(毫秒)', type: 'number', min: 0, hint: '超时未收到登录提示则按已登录放行;0 表示禁用兜底' },
{ path: 'server.commandCooldownMs', label: '指令最小间隔(毫秒)', type: 'number', min: 0 }, { path: 'server.commandCooldownMs', label: '指令最小间隔(毫秒)', type: 'number', min: 0 },
{ path: 'server.autoReconnect', label: '断线自动重连', type: 'checkbox' }, { 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: '连续重连失败时按指数退避,最多退到这个间隔' }
] ]
} }
] ]
+107 -4
查看文件
@@ -6,7 +6,7 @@ const fs = require('fs');
const os = require('os'); const os = require('os');
const path = require('path'); 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 { ChatLog, ChatKind } = require('../src/core/chatLog');
const { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig'); const { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig');
const { Logger } = require('../src/util/logger'); const { Logger } = require('../src/util/logger');
@@ -20,6 +20,7 @@ function setup(options) {
if (opts.config && opts.config.server) { if (opts.config && opts.config.server) {
Object.assign(config.server, opts.config.server); Object.assign(config.server, opts.config.server);
} }
const logger = new Logger({ echo: false });
const manager = new BotManager({ const manager = new BotManager({
id: 'BotA', id: 'BotA',
username: 'BotA', username: 'BotA',
@@ -28,12 +29,11 @@ function setup(options) {
port: 25565, port: 25565,
version: '1.21.4', version: '1.21.4',
label: '主号' label: '主号'
}, config, new Logger({ echo: false }), { chatLog }); }, config, logger, { chatLog });
const sent = []; const sent = [];
manager.bot = { chat: (text) => sent.push(text) }; manager.bot = { chat: (text) => sent.push(text) };
return { manager, chatLog, config, sent }; return { manager, chatLog, config, sent, logger };
} }
/** 等一轮宏任务,让指令节流定时器真正把内容写出 */ /** 等一轮宏任务,让指令节流定时器真正把内容写出 */
function drainQueue() { function drainQueue() {
return new Promise((resolve) => setTimeout(resolve, 5)); return new Promise((resolve) => setTimeout(resolve, 5));
@@ -191,3 +191,106 @@ test('snapshot 暴露多机器人需要的 id / 备注 / 登录与操作许可',
assert.strictEqual(snap.canOperate, false); assert.strictEqual(snap.canOperate, false);
assert.strictEqual(snap.loginFailed, 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);
});