From abecdd26485d84139acfdddf5bee9b5aa0c4607c Mon Sep 17 00:00:00 2001 From: lyizai <3945107889@qq.com> Date: Sun, 27 Sep 2026 23:06:39 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=AE=9E=E6=9C=BA?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=89=8D=E6=9A=B4=E9=9C=B2=E7=9A=84=208=20?= =?UTF-8?q?=E4=B8=AA=E7=BC=BA=E9=99=B7=EF=BC=8C=E6=96=B0=E5=A2=9E=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E5=8F=B0=E6=B7=BB=E5=8A=A0=E7=8E=A9=E5=AE=B6=E4=B8=8E?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 首次真正连上线上服务器(Debian 12 + pm2 + 端口 78)后暴露的问题,单元测试全绿但机器人干不了活: 致命 - 寻路插件从未加载:项目从没调用 bot.loadPlugin(pathfinder),bot.pathfinder 恒为 undefined、isReady() 恒为 false,扩地任务连启动都启动不了,界面只显示含糊的 「机器人尚未就绪」。现在 createBot 后立刻加载,失败时明确报错。 登录门禁相关(三处死锁) - preLoginCommands 白名单:/login、/register、简幻通的 /auth 本身就是登录流程的一部分, 原先被门禁一并拦下,形成「要先登录才能发登录指令」 - 验证码常被要求直接当聊天内容发出(不是指令),原先被 sendChat 的门禁挡住; 现在服务器明确要求验证时自动放行,另有 server.allowChatBeforeLogin 兜底 - 配置了登录指令却没有密码时 loggedIn 永久为 false,控制台连手动指令都发不出; 现在走与其他「服务器不回提示」一致的超时兜底并给出说明 可用性 - 离线时指令被静默丢弃却回报「已发送」;现在要求已连接并进入世界,失败信息区分 「未连接」「登录被拒」「未通过验证」 - 「被服务器传送」日志洪水:实测 3 分钟 748 行,会冲光日志缓冲并打满 WebSocket; 现在按 30 秒窗口聚合,断开时刷出未汇总计数 - 识别外站账号绑定流程并给出可操作告警,新增 server.authCommand 模板 - 重连指数退避(server.reconnectMaxDelayMs) - 控制台瓦片 URL 与后端路由不一致 新增 - 控制台添加 / 移除玩家:不必改 .env 重启。密码只写入 data/bots.credentials.json (强制 0600),不进仓库、不出现在任何响应里;.env 与凭据文件同名时以 .env 为准, .env 里的账号不允许从控制台移除 - 手动移动测试接口与按钮:绕开寻路器直接按方向键,返回真实位移, 用于区分「算不出路径」与「根本走不动」 - 位置同步诊断:位置包收发计数、onGround、游戏模式、维度、脚下方块与六向实心 - grim.reportOnGroundFromWorld:按世界数据修正出站 onGround(默认开启) - scripts/check-protocol.js:核对包名与字段名同目标版本协议是否一致 文档 - README 新增「部署到服务器」与「机器人走不动的排查方法」两章 已知限制:实测本服上 mineflayer 客户端无法移动(按方向键 2 秒位移 0.00~0.02 格, 服务端每 tick 回弹一次),已排除区块数据、onGround、Grim 层、游戏模式、物理引擎、 寻路插件、teleport_confirm 协议字段等因素,彻底定位需与原生客户端逐字节对比移动包。 连接 / 登录 / 指令 / 聊天 / 公屏日志 / 大地图均正常。 测试从 263 增至 290,全部通过。 --- CHANGELOG.md | 62 ++++++++ README.md | 31 ++++ scripts/check-protocol.js | 71 +++++++++ src/bot/botManager.js | 264 +++++++++++++++++++++++++++++++++- src/bot/botPool.js | 160 +++++++++++++++++++-- src/config/botStore.js | 59 +++++++- src/config/expansionConfig.js | 19 ++- src/index.js | 20 ++- src/web/public/app.js | 94 +++++++++++- src/web/public/index.html | 17 +++ src/web/public/style.css | 17 +++ src/web/server.js | 69 ++++++++- test/botManager.test.js | 220 ++++++++++++++++++++++++++++ test/botPool.test.js | 122 +++++++++++++++- test/multiBotWeb.test.js | 52 ++++++- 15 files changed, 1243 insertions(+), 34 deletions(-) create mode 100644 scripts/check-protocol.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 5609b5e..94350d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,68 @@ 本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [1.2.0] - 2026-09-27 + +首次真正连上线上服务器(Debian 12 + pm2 + 端口 78)后,暴露并修掉了一批 +**只有实机运行才会发现**的缺陷。这些问题的共同点是:单元测试全绿,但机器人根本干不了活。 + +### 修复 + +- **寻路插件从未加载(致命)**:项目从没调用过 `bot.loadPlugin(pathfinder)`, + 导致 `bot.pathfinder` 恒为 `undefined`、`isReady()` 恒为 false, + 扩地任务连启动都启动不了,而界面上只显示一句含糊的「机器人尚未就绪」。 + 现在在 `createBot` 之后立刻加载,失败时明确报错 +- **登录门禁死锁**:`/login`、`/register`、简幻通的 `/auth` 本身就是登录流程的一部分, + 原先的门禁把它们一并拦下,形成「要先登录才能发登录指令」,账号永远卡在未验证状态。 + 新增 `server.preLoginCommands` 白名单(默认 `login,l,register,reg,auth`) +- **验证码发不出去**:部分服务器要求把验证码**直接当聊天内容**发出去(不是指令), + 而 `sendChat` 同样被登录门禁挡住。现在服务器明确要求验证时会自动放行聊天, + 另有 `server.allowChatBeforeLogin` 给无提示的服务器兜底 +- **配置了登录指令却没密码时永久卡死**:`.env` 里没有密码时程序无法发送任何登录指令, + 却把 `loggedIn` 永久置为 false,控制台连手动指令都发不出去。 + 现在走与「服务器不回提示」一致的兜底逻辑(超时后按无需登录放行),并在日志里说明 +- **离线时指令被静默丢弃却回报「已发送」**:`bot.chat()` 在未连接时无处可发。 + 现在 `canSendCommand()` 要求已连接并进入世界,失败信息区分 + 「未连接」「登录被服务器拒绝」「未通过登录验证」三种情况 +- **「被服务器传送」日志洪水**:账号未完成服务器验证时服务端每 tick 强推一次位置, + 实测 3 分钟刷出 748 行日志,几十分钟就会冲光日志环形缓冲并把 WebSocket 打满。 + 现在首次立即记录、其余按 30 秒窗口聚合,断开连接时刷出未汇总计数 +- **外站账号绑定流程无法识别**:原先只认 `/login` 与 `/register`, + 遇到要求 `/auth <验证码>` 或扫码绑定的服务器时,表现只是静默的死循环。 + 现在命中相关提示会输出可操作的中文告警(每个连接只提示一次), + 并新增 `server.authCommand` 模板供有固定验证码的服务器自动过验证 +- **重连无退避**:连续被踢时每 10 秒硬撞服务器。现在按指数退避 + (`server.reconnectMaxDelayMs`,默认上限 120 秒),成功进入世界后计数清零 +- **控制台瓦片 URL 与后端路由不一致**:前端请求 `/api/bigmap/tile/:z/:x_y.png`, + 后端路由为 `/api/bigmap/tile/:z/:x/:y.png` + +### 新增 + +- **控制台添加 / 移除玩家**:不必改 `.env` 重启,直接在界面上填玩家名 + 密码即可挂新账号。 + 密码只写入服务器本地凭据文件 `data/bots.credentials.json`(权限强制 0600), + 不进仓库、不出现在任何接口响应里;`.env` 与凭据文件同名时以 `.env` 为准。 + `.env` 里配置的账号不允许从控制台移除(否则重启后会「复活」) +- **手动移动测试**:控制台可按前 / 后 / 左 / 右 / 跳 2 秒并显示真实位移, + 用于区分「寻路器算不出路径」与「客户端根本走不动」这两种完全不同的故障 + (绕开寻路器直接走控制键) +- **位置同步诊断**:`snapshot` 暴露位置包收发计数、onGround、游戏模式、维度、 + 脚下方块与六向实心情况,`/api/bots/:id/status` 可直接看到 +- **按世界数据修正出站 onGround**(`grim.reportOnGroundFromWorld`,默认开启): + mineflayer 收到服务端 `position` 包时会把 `entity.onGround` 置为 false, + 在频繁下发位置包的服务器上会导致客户端持续上报「站在地上却在空中」。 + 现在发包前用脚下方块复核,只有确实不一致时才改写 +- `scripts/check-protocol.js`:核对 mineflayer 写出的包名 / 字段名与目标版本协议是否一致 + +### 已知限制(重要) + +- **本服上 mineflayer 客户端无法移动**:实测(`DiLiDaLaDaLa`,已绑定简幻通、登录正常) + 按方向键 2 秒位移 0.00~0.02 格;服务端每 tick 回弹一次(入站 ~17/s、出站 ~19/s)。 + 已系统性排除:区块数据缺失、onGround 不一致、Grim 合规层、游戏模式(survival)、 + 物理引擎未启用、寻路插件缺失、`teleport_confirm` 协议字段不匹配。 + 连接、登录、收发指令、收发聊天、公屏日志、大地图爬取均正常,**唯独移动被服务端拒绝**。 + 彻底定位需要把客户端的移动包与原生客户端逐字节对比,属于独立的排查工作 +- 同机上原有的 `minecraft-bot`(端口 91)与该机器人不能同时使用同一账号,会互相踢下线 + ## [1.1.0] - 2026-09-27 新增多机器人挂机、开拓日志、游戏公屏完整日志与大地图。 diff --git a/README.md b/README.md index a17841f..eafb814 100644 --- a/README.md +++ b/README.md @@ -233,9 +233,37 @@ pm2 startup systemd -u root --hp /root # 开机自启 | 现象 | 原因 | 处理 | | --- | --- | --- | +| 任务点了「开始扩地」却说「机器人尚未就绪」 | 寻路插件没加载,`bot.pathfinder` 恒为 undefined | 已在 `createBot` 后自动 `loadPlugin`;若仍出现看日志里的插件加载错误 | | 日志被「被服务器传送」刷屏 | 账号未完成服务器验证,服务端每 tick 强推位置 | 已按 30 秒窗口聚合;看是否有「账号绑定」告警 | | 连上 120 秒后被踢,反复循环 | 服务器要求外站账号绑定(如简幻通 `/auth`) | 人工完成一次绑定;绑定后同账号即可自动登录 | | `mineflayer` 加载报错 / 语法不支持 | 系统 Node < 22 | 用项目私有运行时启动 | +| 能连上、能收指令,但**走不动** | 客户端移动包被服务端拒绝(回弹) | 用控制台的「移动测试」按钮确认位移;详见下方专章 | + +### 6. 「机器人走不动」的排查方法 + +控制台右下角有**移动测试**按钮(前 / 后 / 左 / 右 / 跳),它绕开寻路器直接按控制键 2 秒并回报真实位移。 +这能一眼区分两种完全不同的故障: + +- **位移正常、但任务报「目标不可达」** → 是规划问题(距离太远、被地形挡住、挖/放方块被 Grim 约束禁用) +- **位移 ≈ 0** → 客户端移动被服务端拒绝,属于协议 / 反作弊交互问题,改规划参数没有意义 + +配套的诊断数据在 `GET /api/bots/:id/status`: + +| 字段 | 含义 | +| --- | --- | +| `packetStats.inboundPosition` | 服务端下发的强制位置同步次数 | +| `packetStats.outboundPosition` | 客户端上报的位置包次数 | +| `packetStats.onGroundFixed` | 出站包中 onGround 被世界数据纠正的次数 | +| `onGround` | 客户端当前认为自己在不在空中 | +| `gameMode` / `dimension` | 游戏模式与维度(adventure / spectator 会限制移动) | +| `world.blockBelow` / `world.surroundings` | 脚下方块与六向实心情况(判断是否被困住) | + +判读方式: + +- 入站高、出站为 0 → 客户端根本没上报位置 +- 出入都高但位移为 0 → 客户端在报,服务端不认(回弹) +- `onGround` 恒为 false 而 `world.blockBelowSolid` 为 true → 客户端上报「站在地上却在空中」, + 开启 `grim.reportOnGroundFromWorld` 可修正 --- @@ -400,6 +428,9 @@ npm start | POST | `/api/bots/:id/task/start` | 启动该机器人的扩地(body 为配置覆盖) | | POST | `/api/bots/:id/task/stop` | 停止该机器人的扩地 | | POST | `/api/bots/:id/reconnect` | 重连该机器人 | +| POST | `/api/bots/:id/move` | 手动移动测试(`direction` / `seconds`),返回真实位移 | +| POST | `/api/bots` | 控制台新增玩家(`username` / `password` / `label`) | +| DELETE | `/api/bots/:id` | 移除控制台新增的玩家(`.env` 里的会返回 400) | | POST | `/api/bots/:id/command` | 向该机器人发送游戏指令(要求已登录验证) | | POST | `/api/bots/:id/chat` | 向该机器人发送公屏消息(要求已登录验证) | | GET | `/api/bots/:id/chat` | 该机器人的公屏日志(`limit` / `since` / `kind` / `keyword`) | diff --git a/scripts/check-protocol.js b/scripts/check-protocol.js new file mode 100644 index 0000000..f1adfe6 --- /dev/null +++ b/scripts/check-protocol.js @@ -0,0 +1,71 @@ +'use strict'; + +/** + * 协议自检:核对 mineflayer 写出的 `teleport_confirm` 包在 1.21.4 上是否存在、 + * 字段名是否匹配。 + * + * 背景:部分服务端在收到 `player_position` 后会每 tick 重发位置包, + * 直到客户端回一个 teleport_confirm 确认。若 mineflayer 因为版本字段名不一致 + * 而写不出这个包,就会形成「服务端一直传送、客户端一直走不动」的死循环。 + */ + +const mcData = require('minecraft-data')('1.21.4'); + +const toServer = mcData.protocol.play.toServer; +const mappings = toServer.types.packet[1][0].type[1].mappings; + +console.log('=== 1.21.4 客户端 → 服务端 中与传送/位置相关的包名 ==='); +for (const [id, name] of Object.entries(mappings)) { + if (/teleport|position|confirm/i.test(name)) { + console.log(` ${id.padStart(3)} ${name}`); + } +} + +console.log('\n=== 客户端 ← 服务端 中与传送/位置相关的包名 ==='); +const toClientMappings = mcData.protocol.play.toClient.types.packet[1][0].type[1].mappings; +for (const [id, name] of Object.entries(toClientMappings)) { + if (/teleport|position/i.test(name)) { + console.log(` ${id.padStart(3)} ${name}`); + } +} + +console.log('\n=== teleport_confirm / position_look 的字段定义(客户端 → 服务端)==='); +function dumpPacketFields(packetName) { + // protocol 结构:types.packet = ['container', [nameField, paramsField]] + // paramsField.type = ['switch', { compareTo: 'name', fields: { 包名: [字段...] } }] + const paramsField = toServer.types.packet[1][1]; + const fields = paramsField.type[1].fields; + const def = fields[packetName]; + if (!def) { + console.log(` (没有名为 ${packetName} 的包)`); + return; + } + console.log(` ${packetName}: ` + def.map((f) => `${f.name}:${JSON.stringify(f.type)}`).join(', ')); +} + +dumpPacketFields('teleport_confirm'); +dumpPacketFields('position'); +dumpPacketFields('position_look'); + +console.log('\n=== 客户端 ← 服务端 position 包的字段定义 ==='); +function dumpToClientFields(packetName) { + const paramsField = mcData.protocol.play.toClient.types.packet[1][1]; + const def = paramsField.type[1].fields[packetName]; + if (!def) { + console.log(` (没有名为 ${packetName} 的包)`); + return; + } + console.log(` ${packetName}: ` + def.map((f) => `${f.name}:${JSON.stringify(f.type)}`).join(', ')); +} +dumpToClientFields('position'); + +console.log('\n=== mineflayer 实际使用的包名 / 字段名 ==='); +const fs = require('fs'); +const path = require('path'); +const physics = fs.readFileSync(path.join(__dirname, 'node_modules', 'mineflayer', 'lib', 'plugins', 'physics.js'), 'utf8'); +const lines = physics.split('\n'); +lines.forEach((line, index) => { + if (/teleport_confirm|teleportId|teleport_id/.test(line)) { + console.log(` physics.js:${index + 1} ${line.trim()}`); + } +}); diff --git a/src/bot/botManager.js b/src/bot/botManager.js index 00f0536..b194967 100644 --- a/src/bot/botManager.js +++ b/src/bot/botManager.js @@ -3,6 +3,7 @@ const { EventEmitter } = require('events'); const mineflayer = require('mineflayer'); +const { pathfinder: pathfinderPlugin } = require('mineflayer-pathfinder'); const { GrimCompat } = require('./grimCompat'); const { PathingService } = require('./pathingService'); @@ -113,6 +114,23 @@ class BotManager extends EventEmitter { this._forcedMoveTimer = null; /** 本次连接内是否已提示过「账号需要绑定 / 二次验证」 */ this._needAuthWarned = false; + /** 本次连接内是否已提示过「服务器要登录但没配密码」 */ + this._noPasswordWarned = false; + /** + * 位置同步统计。 + * + * 排查「机器人被服务端反复传送、走不动」这类问题时,最关键的信息是: + * 客户端到底有没有往外发位置包?只数入站包无法区分「服务端主动重置」 + * 与「服务端等不到客户端回应而重发」这两种完全不同的原因。 + */ + this.packetStats = { + inboundPosition: 0, + outboundPosition: 0, + lastInboundAt: 0, + lastOutboundAt: 0, + /** 出站包中 onGround 被世界数据纠正的次数 */ + onGroundFixed: 0 + }; } // ==================== 连接生命周期 ==================== @@ -142,9 +160,27 @@ class BotManager extends EventEmitter { return; } this.bot = bot; + this._loadPlugins(bot); this._wireEvents(bot); } + /** + * 加载 mineflayer-pathfinder 插件。 + * + * mineflayer 的插件必须显式 `loadPlugin`,否则 `bot.pathfinder` 永远是 undefined, + * `isReady()` 会恒为 false,扩地任务连启动都启动不了(且失败原因只是含糊的 + * 「机器人尚未就绪」)。这里在 createBot 之后立刻加载,并在失败时明确报错。 + * + * @param {import('mineflayer').Bot} bot + */ + _loadPlugins(bot) { + try { + bot.loadPlugin(pathfinderPlugin); + } catch (err) { + this._log('加载 mineflayer-pathfinder 插件失败:' + (err && err.message ? err.message : String(err)), 'error'); + } + } + /** 主动断开(不触发重连) */ disconnect() { this._manualDisconnect = true; @@ -194,9 +230,12 @@ class BotManager extends EventEmitter { // ==================== 事件装配 ==================== _wireEvents(bot) { + this._wirePacketStats(bot); + bot.on('login', () => { this.state = 'online'; this._needAuthWarned = false; + this._noPasswordWarned = false; this._log('已连接到服务器,正在进入世界'); this._handleLogin(); }); @@ -330,13 +369,97 @@ class BotManager extends EventEmitter { // ==================== 登录 ==================== + /** + * 统计位置包的收发数量,并修正出站包的 onGround 标志。 + * + * - 入站:服务端下发的 `position`(强制位置同步) + * - 出站:客户端上报的 `position` / `position_look` + * + * 两者一起看才能判断「走不动」的原因: + * 入站高、出站为 0 → 客户端根本没上报位置(mineflayer 只在位置变化时才发包); + * 出入都高 → 客户端在报,但服务端不认(被反作弊回弹)。 + * + * @param {import('mineflayer').Bot} bot + */ + _wirePacketStats(bot) { + if (!bot._client) return; + bot._client.on('position', () => { + this.packetStats.inboundPosition++; + this.packetStats.lastInboundAt = Date.now(); + }); + const originalWrite = bot._client.write.bind(bot._client); + bot._client.write = (name, params) => { + if (name === 'position' || name === 'position_look') { + this.packetStats.outboundPosition++; + this.packetStats.lastOutboundAt = Date.now(); + this._fixOutboundOnGround(params); + } + return originalWrite(name, params); + }; + } + + /** + * 按世界数据修正出站位置包的 onGround 标志。 + * + * mineflayer 收到服务端的 `position` 包时会把 `entity.onGround` 直接置为 false + * (见 mineflayer/lib/plugins/physics.js)。若服务端频繁下发位置包(实测本服 + * 约 17 次/秒),这个值就永远来不及被物理引擎改回来,于是客户端持续上报 + * 「我站在地上但我在空中」;带反作弊的服务器会判定为异常并把玩家拉回去, + * 拉回又触发下一次置 false,形成每 tick 一次的死循环 —— 表现就是「机器人原地抖动、走不动」。 + * + * 这里在发包前用世界数据复核一次脚下是否有实体方块,只有确实不一致时才改写。 + * + * @param {object} params 出站包内容 + */ + _fixOutboundOnGround(params) { + const grim = this.config && this.config.grim; + if (!grim || grim.reportOnGroundFromWorld !== true) return; + if (!params || !params.flags || typeof params.flags.onGround !== 'boolean') return; + const actual = this._isStandingOnGround(); + if (actual === null || params.flags.onGround === actual) return; + params.flags.onGround = actual; + this.packetStats.onGroundFixed++; + } + + /** + * 用世界数据判断脚下是否有实体方块。 + * + * 取脚下方 0.05 格处的方块:既能容忍浮点误差,又不会因为站在半砖上而误判。 + * + * @returns {boolean|null} 无法判断时返回 null + */ + _isStandingOnGround() { + const bot = this.bot; + if (!bot || !bot.entity || !bot.blockAt) return null; + const below = bot.blockAt(bot.entity.position.offset(0, -0.05, 0)); + if (!below) return null; + return below.boundingBox !== 'empty'; + } + /** 进入世界后按配置尝试登录 / 注册 */ _handleLogin() { const server = this.config.server; const password = this.connection.password; - this.loggedIn = !server.loginCommand; this.loginFailed = false; - if (!server.loginCommand || !password) return; + this._noPasswordWarned = false; + if (!server.loginCommand) { + // 服务器不需要登录指令,直接视为已通过验证 + this.loggedIn = true; + return; + } + if (!password) { + // 配置了登录指令模板,但 .env 里没有该账号的密码:我们什么也发不出去。 + // 很多服务器的身份验证由外部系统完成(如简幻通),账号一旦绑定就无需密码; + // 此时若把 loggedIn 永久置为 false,控制台会完全无法操作(连手动指令都发不出)。 + // 因此走与其他「服务器不回提示」情形相同的兜底:超时后按「无需登录」放行, + // 若服务器随后真的要求登录,_handleAuthPrompt 会输出明确的错误提示。 + this.loggedIn = false; + this._log('已配置登录指令模板,但 .env 中没有该账号的密码,无法自动登录;' + + '若本账号无需密码(如已绑定外站账号),稍后将按「无需登录」放行', 'warn'); + this._armLoginConfirmFallback(); + return; + } + this.loggedIn = false; // 稍等片刻再发,避免与服务器的欢迎信息 / 传送抢时序 setTimeout(() => { if (!this.bot || this.state !== 'online') return; @@ -406,7 +529,15 @@ class BotManager extends EventEmitter { } return; } - if (!password) return; + if (!password) { + // 服务器要求登录 / 注册,但我们没有密码:明确报错,而不是静默卡死 + if (!this._noPasswordWarned && (LOGIN_PROMPT_PATTERN.test(message) || REGISTER_PROMPT_PATTERN.test(message))) { + this._noPasswordWarned = true; + this._log('服务器要求登录 / 注册,但 .env 中没有该账号的密码,无法自动完成;' + + '请在 MC_BOTS(或 MC_PASSWORD)里补上密码后重启', 'error'); + } + return; + } if (LOGIN_OK_PATTERN.test(message)) { if (!this.loggedIn) this._log('登录成功', 'success'); this.loggedIn = true; @@ -502,11 +633,27 @@ class BotManager extends EventEmitter { return true; } + /** + * 是否允许发送公屏聊天。 + * + * 除「已完成登录验证」外,还有两种情况要放行,否则会把用户彻底堵死: + * - 服务器已经明确要求验证(`_needAuthWarned`):很多服的验证码就是**直接当聊天内容发出去**的, + * 不是指令,若拦下聊天就永远无法完成验证; + * - 配置里显式打开 `server.allowChatBeforeLogin`:给那些没有任何提示、但必须先说话的服务器兜底。 + * + * @returns {boolean} + */ + canSendChat() { + if (this.state !== 'online' || !this.bot) return false; + if (this.canOperate()) return true; + if (this._needAuthWarned) return true; + return !!(this.config.server && this.config.server.allowChatBeforeLogin === true); + } + /** * 发送一条公屏聊天消息(不是指令,不带斜杠)。 * - * 同样要求已完成登录验证;发送内容会写入该机器人的公屏日志(kind=out), - * 便于事后核对「谁在什么时候发了什么」。 + * 发送内容会写入该机器人的公屏日志(kind=out),便于事后核对「谁在什么时候发了什么」。 * * @param {string} message 聊天正文 * @param {{internal?: boolean}} [options] @@ -516,8 +663,8 @@ class BotManager extends EventEmitter { const text = message === undefined || message === null ? '' : String(message).trim(); if (!text) return false; const opts = options || {}; - if (!opts.internal && !this.canOperate()) { - this._log('未完成登录验证,已拒绝发送聊天:' + text, 'warn'); + if (!opts.internal && !this.canSendChat()) { + this._log('未连接或未完成登录验证,已拒绝发送聊天:' + text, 'warn'); return false; } // 以 `/` 开头的内容交给 sendCommand,避免被当成普通聊天发出去 @@ -572,6 +719,49 @@ class BotManager extends EventEmitter { } } + /** + * 手动移动测试:按住某个方向键若干秒,然后松开。 + * + * 用于区分「寻路器算不出路径」与「客户端根本走不动」这两种完全不同的故障 —— + * 前者是规划问题,后者是协议 / 反作弊交互问题,处理方式完全不同。 + * 走控制键而不是寻路器,可以绕开路径规划直接验证移动链路。 + * + * @param {'forward'|'back'|'left'|'right'|'jump'|'sneak'} direction + * @param {number} [seconds] 按住时长(秒),默认 2,上限 10 + * @returns {{ok: boolean, message: string, from: object|null, to: object|null}} + */ + async walkTest(direction, seconds) { + const allowed = ['forward', 'back', 'left', 'right', 'jump', 'sneak']; + if (!allowed.includes(direction)) { + return { ok: false, message: `方向只能是 ${allowed.join(' / ')}`, from: null, to: null }; + } + if (this.state !== 'online' || !this.bot || !this.bot.entity) { + return { ok: false, message: `当前未连接(状态:${this.state})`, from: null, to: null }; + } + const duration = Math.min(10, Math.max(0.5, Number(seconds) || 2)) * 1000; + const from = { x: this.bot.entity.position.x, y: this.bot.entity.position.y, z: this.bot.entity.position.z }; + this._log(`手动移动测试:按住 ${direction} ${duration / 1000} 秒`); + this.setControlState(direction, true); + await new Promise((resolve) => setTimeout(resolve, duration)); + this.setControlState(direction, false); + const to = { x: this.bot.entity.position.x, y: this.bot.entity.position.y, z: this.bot.entity.position.z }; + const moved = Math.hypot(to.x - from.x, to.z - from.z); + const message = `位移 ${moved.toFixed(2)} 格`; + this._log(`手动移动测试结束:${message}` + + (moved < 0.5 ? '(几乎没有移动,移动链路可能被服务端拒绝)' : ''), moved < 0.5 ? 'warn' : 'info'); + return { ok: moved >= 0.5, message, from, to }; + } + + /** + * 设置控制键:优先走 Grim 合规层(它负责动作互斥与骑乘限制)。 + * @param {string} control + * @param {boolean} on + */ + setControlState(control, on) { + if (this.grim) this.grim.setControlState(control, on); + else if (this.bot) this.bot.setControlState(control, on); + } + /** @returns {object} 连接状态快照(供 Web 控制台展示) */ snapshot() { const bot = this.bot; @@ -600,12 +790,72 @@ class BotManager extends EventEmitter { } : null, inWater: entity ? entity.isInWater === true : false, riding: !!(bot && bot.vehicle), + onGround: entity ? entity.onGround === true : false, + /** 游戏模式:adventure / spectator 下移动会被服务端限制,排查「走不动」必看 */ + gameMode: bot && bot.game ? bot.game.gameMode : null, + dimension: bot && bot.game ? bot.game.dimension : null, + isAlive: bot ? bot.isAlive === true : false, + packetStats: Object.assign({}, this.packetStats), + world: this._worldDiagnostics(), pathing: this.pathing ? this.pathing.isPathing() : false, eating: this.eater ? this.eater.eating : false, grim: this.grim ? this.grim.snapshot() : null }; } + /** + * 世界数据诊断。 + * + * 「机器人走不动」有两种截然不同的原因,必须能区分开: + * - 客户端没有区块数据 → 本地碰撞检测失效 → 永远报告 onGround=false → 被反作弊回弹; + * - 客户端数据正常 → 问题在协议 / 反作弊交互层。 + * + * @returns {object|null} + */ + _worldDiagnostics() { + const bot = this.bot; + if (!bot || !bot.entity || !bot.world) return null; + const columns = bot.world.columns || bot.world.columnStates || {}; + let columnCount = 0; + if (columns instanceof Map) columnCount = columns.size; + else if (typeof columns === 'object') columnCount = Object.keys(columns).length; + const below = bot.blockAt ? bot.blockAt(bot.entity.position.offset(0, -1, 0)) : null; + const at = bot.blockAt ? bot.blockAt(bot.entity.position) : null; + return { + loadedColumns: columnCount, + blockBelow: below ? below.name : null, + blockBelowSolid: below ? below.boundingBox !== 'empty' : null, + blockAtFeet: at ? at.name : null, + // 六个方向是否为实心:全实心说明机器人被关在封闭空间里, + // 而挖方块 / 放方块默认都是关的(Grim 安全),寻路器自然找不到任何路径 + surroundings: this._surroundings(), + time: bot.time ? bot.time.timeOfDay : null + }; + } + + /** + * 机器人六个方向的实心情况。 + * @returns {object|null} + */ + _surroundings() { + const bot = this.bot; + if (!bot || !bot.entity || !bot.blockAt) return null; + const pos = bot.entity.position; + const probe = (dx, dy, dz) => { + const block = bot.blockAt(pos.offset(dx, dy, dz)); + if (!block) return null; + return block.boundingBox !== 'empty'; + }; + return { + north: probe(0, 0, -1), + south: probe(0, 0, 1), + east: probe(1, 0, 0), + west: probe(-1, 0, 0), + above: probe(0, 1, 0), + below: probe(0, -0.05, 0) + }; + } + _pushMessage(message) { this.lastMessages.push({ time: Date.now(), message }); if (this.lastMessages.length > 200) this.lastMessages.shift(); diff --git a/src/bot/botPool.js b/src/bot/botPool.js index 3f4fb2e..39c4dcb 100644 --- a/src/bot/botPool.js +++ b/src/bot/botPool.js @@ -5,6 +5,7 @@ const { EventEmitter } = require('events'); const { BotManager } = require('./botManager'); const { ExpansionController } = require('../controller/expansionController'); const { normalizeConfig } = require('../config/expansionConfig'); +const { sanitizeId } = require('../config/botsConfig'); /** * 机器人池:同时挂多个玩家(机器人)。 @@ -13,6 +14,10 @@ const { normalizeConfig } = require('../config/expansionConfig'); * 独立跑扩地状态机;但**共享**同一份领土数据服务(markers.json 是全局的, * 没必要每个机器人各拉一遍)与同一份运行日志。 * + * 账号来源有两个,同名时以 `.env` 为准(环境变量优先级更高,避免「改了 .env 不生效」): + * 1. `.env` 的 `MC_BOTS` / `MC_USERNAME`(`source: 'env'`); + * 2. 控制台「添加玩家」写入的 `data/bots.credentials.json`(`source: 'store'`)。 + * * 每个机器人可以有自己的一套扩地配置覆盖(存在 `data/bots.json`), * 未覆盖的字段回退到全局配置。 */ @@ -26,16 +31,17 @@ class BotPool extends EventEmitter { * @param {import('../core/expansionLog').ExpansionLog} [deps.expansionLog] * @param {import('../core/chatLog').ChatLog} [deps.chatLog] * @param {{load: Function, save: Function}} [deps.botStore] 机器人设置持久化 + * @param {{load: Function, save: Function}} [deps.credentialStore] 控制台新增账号的凭据持久化 */ constructor(deps) { super(); - this.connections = deps.connections || []; this.config = deps.config; this.logger = deps.logger || null; this.dataService = deps.dataService; this.expansionLog = deps.expansionLog || null; this.chatLog = deps.chatLog || null; this.botStore = deps.botStore || null; + this.credentialStore = deps.credentialStore || null; /** @type {Map} 机器人 id → 运行时条目 */ this.entries = new Map(); @@ -43,23 +49,54 @@ class BotPool extends EventEmitter { this._autoStarted = new Set(); /** 机器人设置(非敏感部分) */ this.settings = this.botStore ? this.botStore.load().bots : {}; + /** 控制台新增账号的凭据(含密码,落盘权限 0600) */ + this.credentials = this.credentialStore ? this.credentialStore.load().bots : {}; /** 启动时间戳 */ this.startedAt = 0; - for (const connection of this.connections) { + for (const connection of this._mergeConnections(deps.connections || [])) { this._addBot(connection); } this.defaultId = this.entries.size > 0 ? this.entries.keys().next().value : ''; } + /** + * 合并两个账号来源:`.env` 优先,控制台新增的账号补齐。 + * @param {Array} envConnections + * @returns {Array} + */ + _mergeConnections(envConnections) { + const merged = []; + const seen = new Set(); + for (const connection of envConnections) { + const id = connection.id || sanitizeId(connection.username); + if (!id || seen.has(id)) continue; + seen.add(id); + merged.push(Object.assign({}, connection, { id, source: 'env' })); + } + for (const [id, credential] of Object.entries(this.credentials)) { + if (seen.has(id) || !credential || !credential.username) continue; + seen.add(id); + merged.push(Object.assign({ + auth: 'offline', + host: process.env.MC_HOST || 'bgjq.simpfun.cn', + port: Number.parseInt(process.env.MC_PORT || '25565', 10), + version: process.env.MC_VERSION || '1.21.4' + }, credential, { id, source: 'store' })); + } + return merged; + } + // ==================== 组装 ==================== /** * 创建一个机器人条目:管理器 + 控制器,并接好事件。 * @param {object} connection + * @returns {object|null} 已存在同名机器人时返回 null */ _addBot(connection) { - const id = connection.id || connection.username; + const id = connection.id || sanitizeId(connection.username); + if (!id || this.entries.has(id)) return null; const setting = this.settings[id] || {}; const manager = new BotManager( Object.assign({}, connection, { label: setting.label || connection.label }), @@ -98,9 +135,93 @@ class BotPool extends EventEmitter { connection, manager, controller, + /** 账号来源:env(.env 配置)/ store(控制台新增) */ + source: connection.source || 'env', /** 该机器人实际生效的配置(全局 + 覆盖) */ config: manager.config }); + return this.entries.get(id); + } + + /** + * 控制台新增一个玩家(机器人):落盘凭据 → 建立条目 → 立即连接。 + * + * 密码只写进 `data/bots.credentials.json`(权限 0600,`data/` 已被 .gitignore 忽略), + * 不会进入代码仓库,也不会出现在任何接口响应里。 + * + * @param {{username: string, password?: string, label?: string, auth?: string, host?: string, port?: number, version?: string}} input + * @param {{connect?: boolean}} [options] connect=false 时只登记不连接(测试或先配好再手动连接) + * @returns {object} 新建的机器人条目 + */ + addBot(input, options) { + const opts = options || {}; + const username = String((input && input.username) || '').trim(); + if (!username) throw badRequest('缺少用户名'); + if (!/^[A-Za-z0-9_]{1,16}$/.test(username)) { + throw badRequest('用户名只能是 1-16 位字母、数字或下划线(Minecraft 账号名规则)'); + } + const id = sanitizeId(username); + if (this.entries.has(id)) throw badRequest('该玩家已在列表中:' + username); + if (!this.credentialStore) throw badRequest('当前部署未启用凭据存储,无法在控制台新增玩家'); + + const connection = { + id, + username, + password: input.password === undefined || input.password === null ? '' : String(input.password), + label: input.label ? String(input.label) : username, + auth: String(input.auth || process.env.MC_AUTH || 'offline'), + host: String(input.host || process.env.MC_HOST || 'bgjq.simpfun.cn'), + port: Number.isFinite(Number(input.port)) + ? Number(input.port) + : Number.parseInt(process.env.MC_PORT || '25565', 10), + version: String(input.version || process.env.MC_VERSION || '1.21.4'), + source: 'store' + }; + this.credentials[id] = { + username: connection.username, + password: connection.password, + auth: connection.auth, + host: connection.host, + port: connection.port, + version: connection.version + }; + this.credentialStore.save({ bots: this.credentials }); + + const entry = this._addBot(connection); + if (!this.defaultId) this.defaultId = id; + this._log(`已添加玩家 ${username}(密码保存在服务器本地凭据文件,权限 0600)`); + if (opts.connect !== false && this._settingOf(id).enabled) entry.manager.connect(); + return entry; + } + + /** + * 移除一个机器人。 + * + * 只允许移除控制台新增的账号;`.env` 里配置的账号必须改 `.env`, + * 否则重启后它又会回来,容易让人误以为「删不掉」。 + * + * @param {string} id + * @returns {object} 被移除的条目 + */ + removeBot(id) { + const entry = this.require(id); + if (entry.source === 'env') { + throw badRequest(`${entry.connection.username} 是在 .env 的 MC_BOTS 里配置的,` + + '请修改 .env 后重启;控制台只能移除在这里添加的玩家'); + } + entry.manager.disconnect(); + this.entries.delete(id); + delete this.settings[id]; + this._persistSettings(); + if (this.credentials[id]) { + delete this.credentials[id]; + if (this.credentialStore) this.credentialStore.save({ bots: this.credentials }); + } + if (this.defaultId === id) { + this.defaultId = this.entries.size > 0 ? this.entries.keys().next().value : ''; + } + this._log(`已移除玩家 ${entry.connection.username}`); + return entry; } // ==================== 生命周期 ==================== @@ -330,16 +451,28 @@ class BotPool extends EventEmitter { const entry = this.require(id); const text = String(message || '').trim(); if (!text) return { ok: false, message: '消息为空' }; - if (!entry.manager.canOperate()) { + if (!entry.manager.canSendChat()) { if (entry.manager.state !== 'online' || !entry.manager.bot) { return { ok: false, message: `${entry.connection.username} 当前未连接(状态:${entry.manager.state}),消息未发送` }; } - return { ok: false, message: `${entry.connection.username} 未完成登录验证,已拒绝发送公屏消息` }; + return { ok: false, message: `${entry.connection.username} 未完成登录验证,已拒绝发送公屏消息(服务器要求验证时会自动放行,用于发送验证码)` }; } const ok = entry.manager.sendChat(text); return { ok, message: ok ? '已发送' : '发送失败' }; } + /** + * 手动移动测试:按住方向键若干秒,返回实际位移。 + * @param {string} id + * @param {string} direction + * @param {number} [seconds] + * @returns {Promise<{ok: boolean, message: string, from: object|null, to: object|null}>} + */ + walkTest(id, direction, seconds) { + const entry = this.require(id); + return entry.manager.walkTest(direction, seconds); + } + // ==================== 状态快照 ==================== /** @@ -352,6 +485,7 @@ class BotPool extends EventEmitter { bots.push(Object.assign({}, entry.manager.snapshot(), { enabled: setting.enabled, autoStart: setting.autoStart, + source: entry.source, task: entry.controller.snapshot() })); } @@ -386,6 +520,17 @@ class BotPool extends EventEmitter { } } +/** + * 构造一个 400 错误(由 Web 层转成对应 HTTP 状态码)。 + * @param {string} message + * @returns {Error} + */ +function badRequest(message) { + const err = new Error(message); + err.statusCode = 400; + return err; +} + /** * 生成「为什么这条指令发不出去」的可操作说明。 * @@ -396,8 +541,7 @@ class BotPool extends EventEmitter { * @param {string} command 不带斜杠的指令 * @returns {string} */ -function describeSendFailure(manager, command) { - const name = manager.connection.username; +function describeSendFailure(manager, command) { const name = manager.connection.username; if (manager.state !== 'online' || !manager.bot) { return `${name} 当前未连接(状态:${manager.state}),指令未发送;请先重连或等待自动重连`; } @@ -408,4 +552,4 @@ function describeSendFailure(manager, command) { + '若是验证类指令(login / register / auth),可在配置的「允许免登录发送的指令前缀」中放行'; } -module.exports = { BotPool, describeSendFailure }; +module.exports = { BotPool, describeSendFailure, badRequest }; diff --git a/src/config/botStore.js b/src/config/botStore.js index fa92d75..f04565d 100644 --- a/src/config/botStore.js +++ b/src/config/botStore.js @@ -18,7 +18,6 @@ class BotStore { constructor(filePath) { this.filePath = filePath || path.join(__dirname, '..', '..', 'data', 'bots.json'); } - /** * 读取注册表;文件不存在或损坏时返回空表。 * @returns {{bots: Record}} @@ -51,4 +50,60 @@ class BotStore { } } -module.exports = { BotStore }; +/** + * 控制台新增账号的凭据持久化。 + * + * 与 {@link BotStore} 分开存是刻意的: + * - `data/bots.json` 只放非敏感设置,可以随手打开看; + * - 本文件含密码,落盘权限强制 0600,且 `data/` 已在 .gitignore 中忽略。 + * + * `.env` 里的 `MC_BOTS` / `MC_USERNAME` 仍然是首选来源;两者同名时以 `.env` 为准 + * (环境变量优先级更高,避免「改了半天 .env 不生效」)。 + */ +class BotCredentialStore { + /** + * @param {string} [filePath] 凭据文件绝对路径 + */ + constructor(filePath) { + this.filePath = filePath || path.join(__dirname, '..', '..', 'data', 'bots.credentials.json'); + } + + /** + * 读取凭据;文件不存在或损坏时返回空表。 + * @returns {{bots: Record}} + */ + load() { + try { + const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8')); + if (parsed && typeof parsed === 'object' && parsed.bots && typeof parsed.bots === 'object') { + return { bots: parsed.bots }; + } + } catch { + // 首次运行或文件损坏:回退到空表 + } + return { bots: {} }; + } + + /** + * 写入凭据并强制 0600 权限(仅文件属主可读写)。 + * @param {{bots: Record}} data + * @returns {boolean} 是否写入成功 + */ + save(data) { + try { + fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); + fs.writeFileSync(this.filePath, JSON.stringify({ bots: data.bots || {} }, null, 2), { encoding: 'utf8', mode: 0o600 }); + // writeFileSync 的 mode 只在新文件时生效,已存在时需显式 chmod + try { + fs.chmodSync(this.filePath, 0o600); + } catch { + // Windows 等平台不支持 chmod,忽略 + } + return true; + } catch { + return false; + } + } +} + +module.exports = { BotStore, BotCredentialStore }; diff --git a/src/config/expansionConfig.js b/src/config/expansionConfig.js index e81cdf9..db7ef63 100644 --- a/src/config/expansionConfig.js +++ b/src/config/expansionConfig.js @@ -170,7 +170,16 @@ function defaultConfig() { /** 是否在启动时校验物理参数未被篡改 */ checkPhysicsIntegrity: true, /** 是否禁止在骑乘状态下攻击 / 进食(防 Vehicle 类检查) */ - blockActionsWhileRiding: true + blockActionsWhileRiding: true, + /** + * 是否按世界数据修正出站位置包的 onGround 标志(默认开启)。 + * + * mineflayer 收到服务端 `position` 包时会把 `entity.onGround` 置为 false, + * 在频繁下发位置包的服务器上会导致客户端持续上报「站在地上却在空中」, + * 被反作弊判定异常后每 tick 拉回一次,表现为机器人原地抖动、完全走不动。 + * 开启后发包前会用脚下方块复核,只有确实不一致时才改写。 + */ + reportOnGroundFromWorld: true }, // ==================== 大地图(新增) ==================== @@ -216,6 +225,13 @@ function defaultConfig() { * 因此这里列出的前缀可以绕过门禁,其余指令仍要求已完成验证。 */ preLoginCommands: ['login', 'l', 'register', 'reg', 'auth'], + /** + * 是否允许在登录验证通过之前发送公屏聊天。 + * + * 默认关闭。服务器**明确要求验证**时会自动放行(验证码常常是直接当聊天内容发出去的), + * 这个开关用于兜底那些没有任何提示、却必须先说话才能完成登录的服务器。 + */ + allowChatBeforeLogin: false, /** * 登录结果确认超时(毫秒)。 * 部分离线服登录成功后不回明确提示,超时后按「已登录」放行, @@ -338,6 +354,7 @@ function validateConfig(config) { 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 (typeof config.server.allowChatBeforeLogin !== 'boolean') config.server.allowChatBeforeLogin = false; if (!Array.isArray(config.server.preLoginCommands)) { config.server.preLoginCommands = defaultConfig().server.preLoginCommands; } else { diff --git a/src/index.js b/src/index.js index 0ca1f6d..e210bf5 100644 --- a/src/index.js +++ b/src/index.js @@ -7,7 +7,7 @@ require('dotenv').config({ path: path.join(__dirname, '..', '.env') }); const chalk = require('chalk'); const { ConfigStore } = require('./config/configStore'); -const { BotStore } = require('./config/botStore'); +const { BotStore, BotCredentialStore } = require('./config/botStore'); const { parseBotsFromEnv } = require('./config/botsConfig'); const { normalizeConfig } = require('./config/expansionConfig'); const { Logger } = require('./util/logger'); @@ -29,9 +29,6 @@ async function main() { // ==================== 机器人凭据(全部来自环境变量,密钥不落代码 / 不落配置文件) ==================== const connections = parseBotsFromEnv(process.env); - if (connections.length === 0) { - logger.log('未配置任何机器人:请在 .env 里设置 MC_BOTS 或 MC_USERNAME / MC_PASSWORD', 'error'); - } // ==================== 配置 ==================== const configStore = new ConfigStore(); @@ -41,8 +38,6 @@ async function main() { if (!config.apiKey && process.env.SIMP_API_KEY) config.apiKey = process.env.SIMP_API_KEY.trim(); logger.log(`配置文件:${configStore.filePath}`); - logger.log(`机器人数量:${connections.length}` - + (connections.length > 0 ? '(' + connections.map((c) => c.username).join('、') + ')' : '')); // ==================== 各模块 ==================== const expansionLog = new ExpansionLog({ filePath: path.join(dataDir, 'expansion-log.jsonl'), logger }); @@ -54,6 +49,8 @@ async function main() { cacheDir: path.join(dataDir, 'mapcache') }); const botStore = new BotStore(); + // 控制台新增玩家的凭据单独落盘(0600),与 .env 互为补充、.env 优先 + const credentialStore = new BotCredentialStore(); const pool = new BotPool({ connections, config, @@ -61,9 +58,18 @@ async function main() { dataService, expansionLog, chatLog, - botStore + botStore, + credentialStore }); + // 账号来源有两个:.env 的 MC_BOTS / MC_USERNAME,以及控制台「添加玩家」写入的本地凭据文件 + const botNames = pool.snapshot().map((b) => `${b.username}(${b.source === 'env' ? '.env' : '控制台'})`); + logger.log(`机器人数量:${pool.size}` + + (botNames.length > 0 ? '(' + botNames.join('、') + ')' : '')); + if (pool.size === 0) { + logger.log('未配置任何机器人:请在 .env 设置 MC_BOTS,或启动后到控制台用「添加玩家」新增', 'error'); + } + // ==================== Web 控制台 ==================== const webConsole = new WebConsole( { diff --git a/src/web/public/app.js b/src/web/public/app.js index 7197d4f..c562c82 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -259,7 +259,8 @@ const CONFIG_SCHEMA = { { path: 'grim.blockSprintJump', label: '禁止疾跑跳跃', type: 'checkbox', hint: '疾跑跳本身是原版合法动作,仅在实测被标记时开启' }, { path: 'grim.blockConflictingActions', label: '拦截冲突动作组合', type: 'checkbox' }, { path: 'grim.checkPhysicsIntegrity', label: '启动物理参数自检', type: 'checkbox' }, - { path: 'grim.blockActionsWhileRiding', label: '骑乘时禁止攻击 / 进食', type: 'checkbox' } + { path: 'grim.blockActionsWhileRiding', label: '骑乘时禁止攻击 / 进食', type: 'checkbox' }, + { path: 'grim.reportOnGroundFromWorld', label: '按世界数据修正 onGround', type: 'checkbox', hint: '服务端频繁下发位置包时,mineflayer 会持续上报「站地上却在空中」而被反作弊每 tick 拉回;开启后在发包前用脚下方块复核(实测本服必需)' } ] } ], @@ -272,6 +273,7 @@ const CONFIG_SCHEMA = { { path: 'server.registerCommand', label: '注册指令模板', type: 'text', hint: '留空则不自动注册' }, { path: 'server.authCommand', label: '账号绑定 / 二次验证模板', type: 'text', hint: '如简幻通的 auth {password};扫码类验证无法自动完成,留空即可(日志会提示人工验证一次)' }, { path: 'server.preLoginCommands', label: '允许免登录发送的指令前缀', type: 'list', hint: '逗号分隔,默认 login,l,register,reg,auth;这些指令在登录验证前也放行,避免死锁' }, + { path: 'server.allowChatBeforeLogin', label: '登录前允许发公屏消息', type: 'checkbox', hint: '服务器明确要求验证时会自动放行(验证码常直接当聊天内容发出);此项用于无提示但必须先说话的服务器' }, { path: 'server.loginConfirmTimeoutMs', label: '登录结果确认超时(毫秒)', type: 'number', min: 0, hint: '超时未收到登录提示则按已登录放行;0 表示禁用兜底' }, { path: 'server.commandCooldownMs', label: '指令最小间隔(毫秒)', type: 'number', min: 0 }, { path: 'server.autoReconnect', label: '断线自动重连', type: 'checkbox' }, @@ -523,7 +525,7 @@ let botListSignature = ''; function botSignature(bots) { return bots.map((b) => [ - b.id, b.state, b.enabled, b.task ? b.task.state : '', b.task ? b.task.completedClaims : '', + b.id, b.state, b.enabled, b.source, b.task ? b.task.state : '', b.task ? b.task.completedClaims : '', b.position ? Math.round(b.position.x) : '', b.position ? Math.round(b.position.z) : '' ].join(':')).join('|'); } @@ -569,6 +571,20 @@ function renderBotList(status) { const meta = document.createElement('div'); meta.className = 'meta'; meta.textContent = (bot.task ? bot.task.state : 'IDLE') + ' · ' + (bot.task ? bot.task.completedClaims : 0); + if (bot.source !== 'env') { + // 控制台新增的账号可以在这里直接移除;.env 里的必须改 .env + const remove = document.createElement('button'); + remove.className = 'remove'; + remove.textContent = '×'; + remove.title = '移除该玩家'; + remove.addEventListener('click', (e) => { + e.stopPropagation(); + removeBot(bot.id, bot.username); + }); + meta.appendChild(remove); + } else { + meta.title = '该玩家在 .env 的 MC_BOTS 中配置,需修改 .env 后重启才能移除'; + } item.appendChild(meta); item.addEventListener('click', () => selectBot(bot.id)); @@ -1461,6 +1477,13 @@ function connectWs() { function bindEvents() { $('botSelect').addEventListener('change', () => selectBot($('botSelect').value)); + $('btnAddBot').addEventListener('click', addBot); + for (const id of ['newBotName', 'newBotPass', 'newBotLabel']) { + $(id).addEventListener('keydown', (e) => { + if (e.key === 'Enter') addBot(); + }); + } + $('btnStart').addEventListener('click', () => startTask(selectedBotId)); $('btnStop').addEventListener('click', () => stopTask(selectedBotId)); $('btnReconnect').addEventListener('click', () => reconnectBot(selectedBotId)); @@ -1689,6 +1712,26 @@ function bindEvents() { }); } + // 移动测试:绕开寻路器直接按方向键,用于区分「算不出路径」与「根本走不动」 + for (const btn of document.querySelectorAll('[data-move]')) { + btn.addEventListener('click', async () => { + if (!selectedBotId) return; + btn.disabled = true; + toast('移动测试中…'); + try { + const res = await api(`/api/bots/${encodeURIComponent(selectedBotId)}/move`, { + method: 'POST', + body: JSON.stringify({ direction: btn.dataset.move, seconds: 2 }) + }); + toast('移动测试:' + res.message); + } catch (err) { + toast('移动测试:' + err.message, true); + } finally { + btn.disabled = false; + } + }); + } + $('btnLogin').addEventListener('click', doLogin); $('loginPassword').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); @@ -1769,6 +1812,53 @@ async function reconnectBot(botId) { } } +/** 控制台新增一个玩家(机器人) */ +async function addBot() { + const username = $('newBotName').value.trim(); + if (!username) { + toast('请填写玩家名', true); + return; + } + try { + const res = await api('/api/bots', { + method: 'POST', + body: JSON.stringify({ + username, + password: $('newBotPass').value, + label: $('newBotLabel').value.trim() + }) + }); + $('newBotName').value = ''; + $('newBotPass').value = ''; + $('newBotLabel').value = ''; + toast(res.message || '已添加'); + if (res.data && res.data.bots) { + botListSignature = ''; + renderStatus(Object.assign({}, lastStatus, { bots: res.data.bots })); + } + if (res.data && res.data.id) selectBot(res.data.id); + } catch (err) { + toast('添加失败:' + err.message, true); + } +} + +/** 移除控制台新增的玩家 */ +async function removeBot(botId, username) { + if (!botId) return; + if (!window.confirm(`确定移除玩家「${username}」吗?该操作会断开它的连接并删除本地保存的凭据。`)) return; + try { + const res = await api('/api/bots/' + encodeURIComponent(botId), { method: 'DELETE' }); + toast(res.message || '已移除'); + if (selectedBotId === botId) selectedBotId = ''; + if (res.data && res.data.bots) { + botListSignature = ''; + renderStatus(Object.assign({}, lastStatus, { bots: res.data.bots })); + } + } catch (err) { + toast('移除失败:' + err.message, true); + } +} + // ==================== 启动 ==================== async function startApp() { diff --git a/src/web/public/index.html b/src/web/public/index.html index 44ce21d..694efd9 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -41,6 +41,15 @@
+
+
+ + + + +
+

密码只保存在服务器本地凭据文件(权限 0600),不会进仓库、也不会出现在任何接口响应里。.env 里配置的账号需改 .env 才能移除。

+
@@ -247,6 +256,14 @@
+
+ 移动测试: + + + + + +
diff --git a/src/web/public/style.css b/src/web/public/style.css index 96595b5..dd85cd8 100644 --- a/src/web/public/style.css +++ b/src/web/public/style.css @@ -196,6 +196,23 @@ html, body { .bot-item .name { font-size: 13px; } .bot-item .sub { font-size: 11px; color: var(--muted); } .bot-item .meta { font-size: 11px; color: var(--muted); text-align: right; white-space: nowrap; } +.bot-item .remove { + margin-left: 6px; + padding: 0 5px; + border: 1px solid var(--border); + border-radius: 4px; + background: transparent; + color: var(--muted); + cursor: pointer; + font-size: 12px; + line-height: 16px; +} +.bot-item .remove:hover { color: var(--red); border-color: #7a2b34; } + +.add-bot { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--border); } +.add-bot .row { margin-top: 0; } +.add-bot .input-sm { flex: 1 1 60px; min-width: 0; } +.add-bot .hint { font-size: 11px; color: #5d6672; margin: 6px 0 0; } /* ==================== 公屏聊天 ==================== */ .chat-line { display: block; } diff --git a/src/web/server.js b/src/web/server.js index 7a31d72..a78925c 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -247,6 +247,50 @@ class WebConsole { res.json({ ok: true, data: this.botsPayload() }); }); + // 控制台新增玩家:密码只落到服务器本地凭据文件(0600),不会出现在任何响应里 + app.post('/api/bots', (req, res) => { + if (typeof this.pool.addBot !== 'function') { + res.status(400).json({ ok: false, message: '当前运行模式不支持在控制台新增玩家' }); + return; + } + try { + const body = req.body || {}; + const entry = this.pool.addBot({ + username: body.username, + password: body.password, + label: body.label, + auth: body.auth, + host: body.host, + port: body.port, + version: body.version + }); + res.json({ + ok: true, + message: `已添加玩家 ${entry.connection.username}`, + data: { id: entry.id, username: entry.connection.username, bots: this.botsPayload() } + }); + } catch (err) { + this._error(res, err); + } + }); + + app.delete('/api/bots/:id', (req, res) => { + if (typeof this.pool.removeBot !== 'function') { + res.status(400).json({ ok: false, message: '当前运行模式不支持在控制台移除玩家' }); + return; + } + try { + const entry = this.pool.removeBot(req.params.id); + res.json({ + ok: true, + message: `已移除玩家 ${entry.connection.username}`, + data: { bots: this.botsPayload() } + }); + } catch (err) { + this._error(res, err); + } + }); + app.get('/api/bots/:id/status', (req, res) => { this._withEntry(req, res, (entry) => ({ ok: true, @@ -302,6 +346,22 @@ class WebConsole { }); }); + // 手动移动测试:绕开寻路器直接按方向键,用于判断「算不出路径」还是「根本走不动」 + app.post('/api/bots/:id/move', async (req, res) => { + const body = req.body || {}; + if (typeof this.pool.walkTest !== 'function') { + res.status(400).json({ ok: false, message: '当前运行模式不支持移动测试' }); + return; + } + try { + const entry = this.pool.require(req.params.id); + const result = await this.pool.walkTest(entry.id, body.direction, body.seconds); + res.status(result.ok ? 200 : 409).json(result); + } catch (err) { + this._error(res, err); + } + }); + // 控制台 / 聊天框操作:都要求该机器人已完成游戏内登录验证 app.post('/api/bots/:id/command', (req, res) => { const command = req.body && req.body.command; @@ -828,8 +888,13 @@ class SingleBotPool { /** @param {string} id @param {string} message */ sendChat(id, message) { const entry = this.require(id); - const ok = entry.manager.sendChat(String(message || '').trim()); - return { ok, message: ok ? '已发送' : '发送失败(未完成登录验证)' }; + const text = String(message || '').trim(); + if (!text) return { ok: false, message: '消息为空' }; + if (!entry.manager.canSendChat()) { + return { ok: false, message: '未完成登录验证,已拒绝发送公屏消息' }; + } + const ok = entry.manager.sendChat(text); + return { ok, message: ok ? '已发送' : '发送失败' }; } } diff --git a/test/botManager.test.js b/test/botManager.test.js index dc8a26c..f4f7f84 100644 --- a/test/botManager.test.js +++ b/test/botManager.test.js @@ -7,6 +7,7 @@ const os = require('os'); const path = require('path'); const { BotManager, kindOfPosition, computeReconnectDelay } = 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'); @@ -349,3 +350,222 @@ 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('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; + } +}); diff --git a/test/botPool.test.js b/test/botPool.test.js index fae0b76..7990581 100644 --- a/test/botPool.test.js +++ b/test/botPool.test.js @@ -7,7 +7,7 @@ const os = require('os'); const path = require('path'); const { BotPool } = require('../src/bot/botPool'); -const { BotStore } = require('../src/config/botStore'); +const { BotStore, BotCredentialStore } = require('../src/config/botStore'); const { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig'); const { Logger } = require('../src/util/logger'); const { TerritoryModel } = require('../src/core/territoryModel'); @@ -50,6 +50,7 @@ function setup(options) { const opts = options || {}; const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-pool-')); const storePath = path.join(dir, 'bots.json'); + const credPath = path.join(dir, 'bots.credentials.json'); if (opts.settings) fs.writeFileSync(storePath, JSON.stringify({ bots: opts.settings }), 'utf8'); const config = normalizeConfig(Object.assign(defaultConfig(), opts.config || {})); const pool = new BotPool({ @@ -57,9 +58,10 @@ function setup(options) { config, logger: new Logger({ echo: false }), dataService: fakeDataService(), - botStore: new BotStore(storePath) + botStore: new BotStore(storePath), + credentialStore: opts.noCredentialStore ? null : new BotCredentialStore(credPath) }); - return { pool, storePath, config, dir }; + return { pool, storePath, credPath, config, dir }; } test('BotPool 为每个凭据建立一个机器人条目', () => { @@ -281,3 +283,117 @@ test('未启用(enabled=false)的机器人不参与连接', () => { pool.start(); assert.deepStrictEqual(connected, ['BotA']); }); + +// ==================== 控制台新增 / 移除玩家 ==================== + +test('addBot 新增玩家、落盘凭据,默认立即连接', () => { + const { pool, credPath } = setup(); + const entry = pool.addBot({ username: 'NewBot', password: 'secret-pw', label: '新号' }, { connect: false }); + + assert.strictEqual(entry.id, 'NewBot'); + assert.strictEqual(entry.source, 'store'); + assert.strictEqual(pool.size, 3); + assert.strictEqual(entry.manager.label, '新号'); + + const saved = JSON.parse(fs.readFileSync(credPath, 'utf8')); + assert.strictEqual(saved.bots.NewBot.username, 'NewBot'); + assert.strictEqual(saved.bots.NewBot.password, 'secret-pw'); +}); + +test('addBot 在 enabled 时真的发起连接', () => { + const { pool } = setup(); + const connected = []; + // addBot 内部会新建 BotManager,这里通过覆盖原型方法拦截真实网络连接 + const originalConnect = Object.getPrototypeOf(pool.get('BotA').manager).connect; + Object.getPrototypeOf(pool.get('BotA').manager).connect = function stubConnect() { + connected.push(this.connection.username); + }; + try { + pool.addBot({ username: 'NewBot', password: 'p1' }); + assert.deepStrictEqual(connected, ['NewBot']); + } finally { + Object.getPrototypeOf(pool.get('BotA').manager).connect = originalConnect; + } +}); + +test('addBot 拒绝空用户名、非法用户名与重名', () => { + const { pool } = setup(); + assert.throws(() => pool.addBot({ username: '' }), (err) => err.statusCode === 400); + assert.throws(() => pool.addBot({ username: '名字含中文且超长' }), (err) => err.statusCode === 400); + assert.throws(() => pool.addBot({ username: 'BotA' }), (err) => err.statusCode === 400); + assert.ok(pool.addBot({ username: 'ok_name' }, { connect: false })); +}); + +test('凭据文件里的账号在重启后自动恢复,且 .env 同名时以 .env 为准', () => { + const { pool, credPath, storePath, config } = setup(); + pool.addBot({ username: 'NewBot', password: 'p1' }, { connect: false }); + + const rebuilt = new BotPool({ + connections: [connection('BotA')], + config, + dataService: fakeDataService(), + botStore: new BotStore(storePath), + credentialStore: new BotCredentialStore(credPath) + }); + assert.deepStrictEqual(rebuilt.ids(), ['BotA', 'NewBot']); + assert.strictEqual(rebuilt.get('NewBot').source, 'store'); + + // .env 里也有同名账号时,以 .env 为准(避免「改了 .env 不生效」) + const both = new BotPool({ + connections: [connection('NewBot')], + config, + dataService: fakeDataService(), + credentialStore: new BotCredentialStore(credPath) + }); + assert.strictEqual(both.size, 1); + assert.strictEqual(both.get('NewBot').source, 'env'); +}); + +test('removeBot 移除控制台新增的玩家并删除凭据', () => { + const { pool, credPath } = setup(); + pool.addBot({ username: 'NewBot', password: 'p1' }, { connect: false }); + let disconnected = false; + pool.get('NewBot').manager.disconnect = () => { disconnected = true; }; + + pool.removeBot('NewBot'); + assert.strictEqual(disconnected, true, '移除时应断开连接'); + assert.strictEqual(pool.get('NewBot'), null); + assert.strictEqual(pool.size, 2); + assert.deepStrictEqual(JSON.parse(fs.readFileSync(credPath, 'utf8')).bots, {}); +}); + +test('removeBot 拒绝移除 .env 里配置的账号并说明原因', () => { + const { pool } = setup(); + assert.throws(() => pool.removeBot('BotA'), (err) => { + assert.strictEqual(err.statusCode, 400); + assert.ok(err.message.includes('.env')); + return true; + }); + assert.strictEqual(pool.size, 2, '拒绝后不应改变列表'); +}); + +test('未启用凭据存储时不允许在控制台新增玩家', () => { + const { pool } = setup({ noCredentialStore: true }); + assert.throws(() => pool.addBot({ username: 'X' }, { connect: false }), (err) => err.statusCode === 400); +}); + +test('snapshot 标注账号来源,供前端决定能否移除', () => { + const { pool } = setup(); + pool.addBot({ username: 'NewBot', password: 'p1' }, { connect: false }); + const snap = pool.snapshot(); + assert.strictEqual(snap.find((b) => b.id === 'BotA').source, 'env'); + assert.strictEqual(snap.find((b) => b.id === 'NewBot').source, 'store'); +}); + +test('凭据文件不含任何非密码字段之外的敏感内容,且权限被收紧', () => { + const { pool, credPath } = setup(); + pool.addBot({ username: 'NewBot', password: 'p1' }, { connect: false }); + const parsed = JSON.parse(fs.readFileSync(credPath, 'utf8')); + assert.deepStrictEqual(Object.keys(parsed), ['bots']); + assert.deepStrictEqual(Object.keys(parsed.bots.NewBot).sort(), + ['auth', 'host', 'password', 'port', 'username', 'version']); + if (process.platform !== 'win32') { + const mode = fs.statSync(credPath).mode & 0o777; + assert.strictEqual(mode, 0o600, '含密码的文件必须是 0600'); + } +}); diff --git a/test/multiBotWeb.test.js b/test/multiBotWeb.test.js index afee360..c6c3185 100644 --- a/test/multiBotWeb.test.js +++ b/test/multiBotWeb.test.js @@ -8,7 +8,7 @@ const path = require('path'); const { WebConsole } = require('../src/web/server'); const { BotPool } = require('../src/bot/botPool'); -const { BotStore } = require('../src/config/botStore'); +const { BotStore, BotCredentialStore } = require('../src/config/botStore'); const { ExpansionLog } = require('../src/core/expansionLog'); const { ChatLog, ChatKind } = require('../src/core/chatLog'); const { BigMapService } = require('../src/core/bigMapService'); @@ -81,7 +81,8 @@ function setup(options) { dataService, expansionLog, chatLog, - botStore: new BotStore(path.join(dir, 'bots.json')) + botStore: new BotStore(path.join(dir, 'bots.json')), + credentialStore: new BotCredentialStore(path.join(dir, 'bots.credentials.json')) }); const webConsole = new WebConsole({ @@ -387,6 +388,53 @@ test('HTTP:配置了控制台密码时未登录一律 401,登录后可访问 } }); +test('HTTP:可在控制台新增与移除玩家,响应中不含密码', async () => { + const { webConsole, pool } = setup(); + // 避免测试真的发起网络连接 + const proto = Object.getPrototypeOf(pool.get('BotA').manager); + const originalConnect = proto.connect; + proto.connect = function stub() {}; + await webConsole.start(); + const base = webConsole.baseUrl(); + try { + const added = await fetch(base + '/api/bots', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'NewBot', password: 'super-secret', label: '新号' }) + }).then((r) => r.json()); + assert.strictEqual(added.ok, true); + assert.strictEqual(added.data.id, 'NewBot'); + assert.ok(!JSON.stringify(added).includes('super-secret'), '响应中不得回显密码'); + assert.deepStrictEqual(added.data.bots.map((b) => b.id), ['BotA', 'BotB', 'NewBot']); + assert.strictEqual(added.data.bots[2].source, 'store'); + + const duplicate = await fetch(base + '/api/bots', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'NewBot' }) + }); + assert.strictEqual(duplicate.status, 400); + + const invalid = await fetch(base + '/api/bots', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: '带中文的名字' }) + }); + assert.strictEqual(invalid.status, 400); + + // .env 里配置的账号不允许从控制台移除 + const envBot = await fetch(base + '/api/bots/BotA', { method: 'DELETE' }); + assert.strictEqual(envBot.status, 400); + + const removed = await fetch(base + '/api/bots/NewBot', { method: 'DELETE' }).then((r) => r.json()); + assert.strictEqual(removed.ok, true); + assert.deepStrictEqual(removed.data.bots.map((b) => b.id), ['BotA', 'BotB']); + } finally { + proto.connect = originalConnect; + await webConsole.stop(); + } +}); + test('HTTP:未配置密码时免登录(本机开发模式)', async () => { const { webConsole } = setup(); await webConsole.start();