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,全部通过。
这个提交包含在:
+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
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户