fix: 修复实机运行才暴露的 8 个缺陷,新增控制台添加玩家与移动测试

首次真正连上线上服务器(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,全部通过。
这个提交包含在:
lyizai
2026-09-27 23:06:39 +08:00
父节点 2671da95d5
当前提交 abecdd2648
共修改 15 个文件,包含 1243 行新增和 34 行删除
+62
查看文件
@@ -2,6 +2,68 @@
本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。 本项目遵循 [约定式提交](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 <UID> <验证码>` 或扫码绑定的服务器时,表现只是静默的死循环。
现在命中相关提示会输出可操作的中文告警(每个连接只提示一次),
并新增 `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 ## [1.1.0] - 2026-09-27
新增多机器人挂机、开拓日志、游戏公屏完整日志与大地图。 新增多机器人挂机、开拓日志、游戏公屏完整日志与大地图。
+31
查看文件
@@ -233,9 +233,37 @@ pm2 startup systemd -u root --hp /root # 开机自启
| 现象 | 原因 | 处理 | | 现象 | 原因 | 处理 |
| --- | --- | --- | | --- | --- | --- |
| 任务点了「开始扩地」却说「机器人尚未就绪」 | 寻路插件没加载,`bot.pathfinder` 恒为 undefined | 已在 `createBot` 后自动 `loadPlugin`;若仍出现看日志里的插件加载错误 |
| 日志被「被服务器传送」刷屏 | 账号未完成服务器验证,服务端每 tick 强推位置 | 已按 30 秒窗口聚合;看是否有「账号绑定」告警 | | 日志被「被服务器传送」刷屏 | 账号未完成服务器验证,服务端每 tick 强推位置 | 已按 30 秒窗口聚合;看是否有「账号绑定」告警 |
| 连上 120 秒后被踢,反复循环 | 服务器要求外站账号绑定(如简幻通 `/auth`) | 人工完成一次绑定;绑定后同账号即可自动登录 | | 连上 120 秒后被踢,反复循环 | 服务器要求外站账号绑定(如简幻通 `/auth`) | 人工完成一次绑定;绑定后同账号即可自动登录 |
| `mineflayer` 加载报错 / 语法不支持 | 系统 Node < 22 | 用项目私有运行时启动 | | `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/start` | 启动该机器人的扩地(body 为配置覆盖) |
| POST | `/api/bots/:id/task/stop` | 停止该机器人的扩地 | | POST | `/api/bots/:id/task/stop` | 停止该机器人的扩地 |
| POST | `/api/bots/:id/reconnect` | 重连该机器人 | | 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/command` | 向该机器人发送游戏指令(要求已登录验证) |
| POST | `/api/bots/:id/chat` | 向该机器人发送公屏消息(要求已登录验证) | | POST | `/api/bots/:id/chat` | 向该机器人发送公屏消息(要求已登录验证) |
| GET | `/api/bots/:id/chat` | 该机器人的公屏日志(`limit` / `since` / `kind` / `keyword`) | | GET | `/api/bots/:id/chat` | 该机器人的公屏日志(`limit` / `since` / `kind` / `keyword`) |
+71
查看文件
@@ -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()}`);
}
});
+257 -7
查看文件
@@ -3,6 +3,7 @@
const { EventEmitter } = require('events'); const { EventEmitter } = require('events');
const mineflayer = require('mineflayer'); const mineflayer = require('mineflayer');
const { pathfinder: pathfinderPlugin } = require('mineflayer-pathfinder');
const { GrimCompat } = require('./grimCompat'); const { GrimCompat } = require('./grimCompat');
const { PathingService } = require('./pathingService'); const { PathingService } = require('./pathingService');
@@ -113,6 +114,23 @@ class BotManager extends EventEmitter {
this._forcedMoveTimer = null; this._forcedMoveTimer = null;
/** 本次连接内是否已提示过「账号需要绑定 / 二次验证」 */ /** 本次连接内是否已提示过「账号需要绑定 / 二次验证」 */
this._needAuthWarned = false; 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; return;
} }
this.bot = bot; this.bot = bot;
this._loadPlugins(bot);
this._wireEvents(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() { disconnect() {
this._manualDisconnect = true; this._manualDisconnect = true;
@@ -194,9 +230,12 @@ class BotManager extends EventEmitter {
// ==================== 事件装配 ==================== // ==================== 事件装配 ====================
_wireEvents(bot) { _wireEvents(bot) {
this._wirePacketStats(bot);
bot.on('login', () => { bot.on('login', () => {
this.state = 'online'; this.state = 'online';
this._needAuthWarned = false; this._needAuthWarned = false;
this._noPasswordWarned = false;
this._log('已连接到服务器,正在进入世界'); this._log('已连接到服务器,正在进入世界');
this._handleLogin(); 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() { _handleLogin() {
const server = this.config.server; const server = this.config.server;
const password = this.connection.password; const password = this.connection.password;
this.loggedIn = !server.loginCommand;
this.loginFailed = false; 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(() => { setTimeout(() => {
if (!this.bot || this.state !== 'online') return; if (!this.bot || this.state !== 'online') return;
@@ -406,7 +529,15 @@ class BotManager extends EventEmitter {
} }
return; 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 (LOGIN_OK_PATTERN.test(message)) {
if (!this.loggedIn) this._log('登录成功', 'success'); if (!this.loggedIn) this._log('登录成功', 'success');
this.loggedIn = true; this.loggedIn = true;
@@ -502,11 +633,27 @@ class BotManager extends EventEmitter {
return true; 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 {string} message 聊天正文
* @param {{internal?: boolean}} [options] * @param {{internal?: boolean}} [options]
@@ -516,8 +663,8 @@ class BotManager extends EventEmitter {
const text = message === undefined || message === null ? '' : String(message).trim(); const text = message === undefined || message === null ? '' : String(message).trim();
if (!text) return false; if (!text) return false;
const opts = options || {}; const opts = options || {};
if (!opts.internal && !this.canOperate()) { if (!opts.internal && !this.canSendChat()) {
this._log('未完成登录验证,已拒绝发送聊天:' + text, 'warn'); this._log('未连接或未完成登录验证,已拒绝发送聊天:' + text, 'warn');
return false; return false;
} }
// 以 `/` 开头的内容交给 sendCommand,避免被当成普通聊天发出去 // 以 `/` 开头的内容交给 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 控制台展示) */ /** @returns {object} 连接状态快照(供 Web 控制台展示) */
snapshot() { snapshot() {
const bot = this.bot; const bot = this.bot;
@@ -600,12 +790,72 @@ class BotManager extends EventEmitter {
} : null, } : null,
inWater: entity ? entity.isInWater === true : false, inWater: entity ? entity.isInWater === true : false,
riding: !!(bot && bot.vehicle), 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, pathing: this.pathing ? this.pathing.isPathing() : false,
eating: this.eater ? this.eater.eating : false, eating: this.eater ? this.eater.eating : false,
grim: this.grim ? this.grim.snapshot() : null 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) { _pushMessage(message) {
this.lastMessages.push({ time: Date.now(), message }); this.lastMessages.push({ time: Date.now(), message });
if (this.lastMessages.length > 200) this.lastMessages.shift(); if (this.lastMessages.length > 200) this.lastMessages.shift();
+152 -8
查看文件
@@ -5,6 +5,7 @@ const { EventEmitter } = require('events');
const { BotManager } = require('./botManager'); const { BotManager } = require('./botManager');
const { ExpansionController } = require('../controller/expansionController'); const { ExpansionController } = require('../controller/expansionController');
const { normalizeConfig } = require('../config/expansionConfig'); const { normalizeConfig } = require('../config/expansionConfig');
const { sanitizeId } = require('../config/botsConfig');
/** /**
* 机器人池:同时挂多个玩家(机器人)。 * 机器人池:同时挂多个玩家(机器人)。
@@ -13,6 +14,10 @@ const { normalizeConfig } = require('../config/expansionConfig');
* 独立跑扩地状态机;但**共享**同一份领土数据服务(markers.json 是全局的, * 独立跑扩地状态机;但**共享**同一份领土数据服务(markers.json 是全局的,
* 没必要每个机器人各拉一遍)与同一份运行日志。 * 没必要每个机器人各拉一遍)与同一份运行日志。
* *
* 账号来源有两个,同名时以 `.env` 为准(环境变量优先级更高,避免「改了 .env 不生效」):
* 1. `.env` 的 `MC_BOTS` / `MC_USERNAME`(`source: 'env'`);
* 2. 控制台「添加玩家」写入的 `data/bots.credentials.json`(`source: 'store'`)。
*
* 每个机器人可以有自己的一套扩地配置覆盖(存在 `data/bots.json`), * 每个机器人可以有自己的一套扩地配置覆盖(存在 `data/bots.json`),
* 未覆盖的字段回退到全局配置。 * 未覆盖的字段回退到全局配置。
*/ */
@@ -26,16 +31,17 @@ class BotPool extends EventEmitter {
* @param {import('../core/expansionLog').ExpansionLog} [deps.expansionLog] * @param {import('../core/expansionLog').ExpansionLog} [deps.expansionLog]
* @param {import('../core/chatLog').ChatLog} [deps.chatLog] * @param {import('../core/chatLog').ChatLog} [deps.chatLog]
* @param {{load: Function, save: Function}} [deps.botStore] 机器人设置持久化 * @param {{load: Function, save: Function}} [deps.botStore] 机器人设置持久化
* @param {{load: Function, save: Function}} [deps.credentialStore] 控制台新增账号的凭据持久化
*/ */
constructor(deps) { constructor(deps) {
super(); super();
this.connections = deps.connections || [];
this.config = deps.config; this.config = deps.config;
this.logger = deps.logger || null; this.logger = deps.logger || null;
this.dataService = deps.dataService; this.dataService = deps.dataService;
this.expansionLog = deps.expansionLog || null; this.expansionLog = deps.expansionLog || null;
this.chatLog = deps.chatLog || null; this.chatLog = deps.chatLog || null;
this.botStore = deps.botStore || null; this.botStore = deps.botStore || null;
this.credentialStore = deps.credentialStore || null;
/** @type {Map<string, object>} 机器人 id → 运行时条目 */ /** @type {Map<string, object>} 机器人 id → 运行时条目 */
this.entries = new Map(); this.entries = new Map();
@@ -43,23 +49,54 @@ class BotPool extends EventEmitter {
this._autoStarted = new Set(); this._autoStarted = new Set();
/** 机器人设置(非敏感部分) */ /** 机器人设置(非敏感部分) */
this.settings = this.botStore ? this.botStore.load().bots : {}; this.settings = this.botStore ? this.botStore.load().bots : {};
/** 控制台新增账号的凭据(含密码,落盘权限 0600) */
this.credentials = this.credentialStore ? this.credentialStore.load().bots : {};
/** 启动时间戳 */ /** 启动时间戳 */
this.startedAt = 0; this.startedAt = 0;
for (const connection of this.connections) { for (const connection of this._mergeConnections(deps.connections || [])) {
this._addBot(connection); this._addBot(connection);
} }
this.defaultId = this.entries.size > 0 ? this.entries.keys().next().value : ''; this.defaultId = this.entries.size > 0 ? this.entries.keys().next().value : '';
} }
/**
* 合并两个账号来源:`.env` 优先,控制台新增的账号补齐。
* @param {Array<object>} envConnections
* @returns {Array<object>}
*/
_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 * @param {object} connection
* @returns {object|null} 已存在同名机器人时返回 null
*/ */
_addBot(connection) { _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 setting = this.settings[id] || {};
const manager = new BotManager( const manager = new BotManager(
Object.assign({}, connection, { label: setting.label || connection.label }), Object.assign({}, connection, { label: setting.label || connection.label }),
@@ -98,9 +135,93 @@ class BotPool extends EventEmitter {
connection, connection,
manager, manager,
controller, controller,
/** 账号来源:env(.env 配置)/ store(控制台新增) */
source: connection.source || 'env',
/** 该机器人实际生效的配置(全局 + 覆盖) */ /** 该机器人实际生效的配置(全局 + 覆盖) */
config: manager.config 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 entry = this.require(id);
const text = String(message || '').trim(); const text = String(message || '').trim();
if (!text) return { ok: false, message: '消息为空' }; if (!text) return { ok: false, message: '消息为空' };
if (!entry.manager.canOperate()) { if (!entry.manager.canSendChat()) {
if (entry.manager.state !== 'online' || !entry.manager.bot) { 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} 当前未连接(状态:${entry.manager.state}),消息未发送` };
} }
return { ok: false, message: `${entry.connection.username} 未完成登录验证,已拒绝发送公屏消息` }; return { ok: false, message: `${entry.connection.username} 未完成登录验证,已拒绝发送公屏消息(服务器要求验证时会自动放行,用于发送验证码)` };
} }
const ok = entry.manager.sendChat(text); const ok = entry.manager.sendChat(text);
return { ok, message: ok ? '已发送' : '发送失败' }; 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(), { bots.push(Object.assign({}, entry.manager.snapshot(), {
enabled: setting.enabled, enabled: setting.enabled,
autoStart: setting.autoStart, autoStart: setting.autoStart,
source: entry.source,
task: entry.controller.snapshot() 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 不带斜杠的指令 * @param {string} command 不带斜杠的指令
* @returns {string} * @returns {string}
*/ */
function describeSendFailure(manager, command) { function describeSendFailure(manager, command) { const name = manager.connection.username;
const name = manager.connection.username;
if (manager.state !== 'online' || !manager.bot) { if (manager.state !== 'online' || !manager.bot) {
return `${name} 当前未连接(状态:${manager.state}),指令未发送;请先重连或等待自动重连`; return `${name} 当前未连接(状态:${manager.state}),指令未发送;请先重连或等待自动重连`;
} }
@@ -408,4 +552,4 @@ function describeSendFailure(manager, command) {
+ '若是验证类指令(login / register / auth),可在配置的「允许免登录发送的指令前缀」中放行'; + '若是验证类指令(login / register / auth),可在配置的「允许免登录发送的指令前缀」中放行';
} }
module.exports = { BotPool, describeSendFailure }; module.exports = { BotPool, describeSendFailure, badRequest };
+57 -2
查看文件
@@ -18,7 +18,6 @@ class BotStore {
constructor(filePath) { constructor(filePath) {
this.filePath = filePath || path.join(__dirname, '..', '..', 'data', 'bots.json'); this.filePath = filePath || path.join(__dirname, '..', '..', 'data', 'bots.json');
} }
/** /**
* 读取注册表;文件不存在或损坏时返回空表。 * 读取注册表;文件不存在或损坏时返回空表。
* @returns {{bots: Record<string, object>}} * @returns {{bots: Record<string, object>}}
@@ -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<string, object>}}
*/
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<string, object>}} 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 };
+18 -1
查看文件
@@ -170,7 +170,16 @@ function defaultConfig() {
/** 是否在启动时校验物理参数未被篡改 */ /** 是否在启动时校验物理参数未被篡改 */
checkPhysicsIntegrity: true, checkPhysicsIntegrity: true,
/** 是否禁止在骑乘状态下攻击 / 进食(防 Vehicle 类检查) */ /** 是否禁止在骑乘状态下攻击 / 进食(防 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'], 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.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 (typeof config.server.authCommand !== 'string') config.server.authCommand = '';
if (typeof config.server.allowChatBeforeLogin !== 'boolean') config.server.allowChatBeforeLogin = false;
if (!Array.isArray(config.server.preLoginCommands)) { if (!Array.isArray(config.server.preLoginCommands)) {
config.server.preLoginCommands = defaultConfig().server.preLoginCommands; config.server.preLoginCommands = defaultConfig().server.preLoginCommands;
} else { } else {
+13 -7
查看文件
@@ -7,7 +7,7 @@ require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const chalk = require('chalk'); const chalk = require('chalk');
const { ConfigStore } = require('./config/configStore'); const { ConfigStore } = require('./config/configStore');
const { BotStore } = require('./config/botStore'); const { BotStore, BotCredentialStore } = require('./config/botStore');
const { parseBotsFromEnv } = require('./config/botsConfig'); const { parseBotsFromEnv } = require('./config/botsConfig');
const { normalizeConfig } = require('./config/expansionConfig'); const { normalizeConfig } = require('./config/expansionConfig');
const { Logger } = require('./util/logger'); const { Logger } = require('./util/logger');
@@ -29,9 +29,6 @@ async function main() {
// ==================== 机器人凭据(全部来自环境变量,密钥不落代码 / 不落配置文件) ==================== // ==================== 机器人凭据(全部来自环境变量,密钥不落代码 / 不落配置文件) ====================
const connections = parseBotsFromEnv(process.env); const connections = parseBotsFromEnv(process.env);
if (connections.length === 0) {
logger.log('未配置任何机器人:请在 .env 里设置 MC_BOTS 或 MC_USERNAME / MC_PASSWORD', 'error');
}
// ==================== 配置 ==================== // ==================== 配置 ====================
const configStore = new ConfigStore(); 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(); if (!config.apiKey && process.env.SIMP_API_KEY) config.apiKey = process.env.SIMP_API_KEY.trim();
logger.log(`配置文件:${configStore.filePath}`); 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 }); const expansionLog = new ExpansionLog({ filePath: path.join(dataDir, 'expansion-log.jsonl'), logger });
@@ -54,6 +49,8 @@ async function main() {
cacheDir: path.join(dataDir, 'mapcache') cacheDir: path.join(dataDir, 'mapcache')
}); });
const botStore = new BotStore(); const botStore = new BotStore();
// 控制台新增玩家的凭据单独落盘(0600),与 .env 互为补充、.env 优先
const credentialStore = new BotCredentialStore();
const pool = new BotPool({ const pool = new BotPool({
connections, connections,
config, config,
@@ -61,9 +58,18 @@ async function main() {
dataService, dataService,
expansionLog, expansionLog,
chatLog, 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 控制台 ==================== // ==================== Web 控制台 ====================
const webConsole = new WebConsole( const webConsole = new WebConsole(
{ {
+92 -2
查看文件
@@ -259,7 +259,8 @@ const CONFIG_SCHEMA = {
{ path: 'grim.blockSprintJump', label: '禁止疾跑跳跃', type: 'checkbox', hint: '疾跑跳本身是原版合法动作,仅在实测被标记时开启' }, { path: 'grim.blockSprintJump', label: '禁止疾跑跳跃', type: 'checkbox', hint: '疾跑跳本身是原版合法动作,仅在实测被标记时开启' },
{ path: 'grim.blockConflictingActions', label: '拦截冲突动作组合', type: 'checkbox' }, { path: 'grim.blockConflictingActions', label: '拦截冲突动作组合', type: 'checkbox' },
{ path: 'grim.checkPhysicsIntegrity', 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.registerCommand', label: '注册指令模板', type: 'text', hint: '留空则不自动注册' },
{ path: 'server.authCommand', label: '账号绑定 / 二次验证模板', type: 'text', hint: '如简幻通的 auth {password};扫码类验证无法自动完成,留空即可(日志会提示人工验证一次)' }, { path: 'server.authCommand', label: '账号绑定 / 二次验证模板', type: 'text', hint: '如简幻通的 auth {password};扫码类验证无法自动完成,留空即可(日志会提示人工验证一次)' },
{ path: 'server.preLoginCommands', label: '允许免登录发送的指令前缀', type: 'list', hint: '逗号分隔,默认 login,l,register,reg,auth;这些指令在登录验证前也放行,避免死锁' }, { 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.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' },
@@ -523,7 +525,7 @@ let botListSignature = '';
function botSignature(bots) { function botSignature(bots) {
return bots.map((b) => [ 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) : '' b.position ? Math.round(b.position.x) : '', b.position ? Math.round(b.position.z) : ''
].join(':')).join('|'); ].join(':')).join('|');
} }
@@ -569,6 +571,20 @@ function renderBotList(status) {
const meta = document.createElement('div'); const meta = document.createElement('div');
meta.className = 'meta'; meta.className = 'meta';
meta.textContent = (bot.task ? bot.task.state : 'IDLE') + ' · ' + (bot.task ? bot.task.completedClaims : 0); 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.appendChild(meta);
item.addEventListener('click', () => selectBot(bot.id)); item.addEventListener('click', () => selectBot(bot.id));
@@ -1461,6 +1477,13 @@ function connectWs() {
function bindEvents() { function bindEvents() {
$('botSelect').addEventListener('change', () => selectBot($('botSelect').value)); $('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)); $('btnStart').addEventListener('click', () => startTask(selectedBotId));
$('btnStop').addEventListener('click', () => stopTask(selectedBotId)); $('btnStop').addEventListener('click', () => stopTask(selectedBotId));
$('btnReconnect').addEventListener('click', () => reconnectBot(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); $('btnLogin').addEventListener('click', doLogin);
$('loginPassword').addEventListener('keydown', (e) => { $('loginPassword').addEventListener('keydown', (e) => {
if (e.key === 'Enter') doLogin(); 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() { async function startApp() {
+17
查看文件
@@ -41,6 +41,15 @@
</div> </div>
</div> </div>
<div id="botList" class="bot-list"></div> <div id="botList" class="bot-list"></div>
<div class="add-bot">
<div class="row">
<input id="newBotName" class="input input-sm" type="text" placeholder="玩家名" maxlength="16">
<input id="newBotPass" class="input input-sm" type="password" placeholder="服务器密码(可留空)">
<input id="newBotLabel" class="input input-sm" type="text" placeholder="备注(可留空)">
<button id="btnAddBot" class="btn btn-sm btn-primary">添加玩家</button>
</div>
<p class="hint">密码只保存在服务器本地凭据文件(权限 0600),不会进仓库、也不会出现在任何接口响应里。.env 里配置的账号需改 .env 才能移除。</p>
</div>
</div> </div>
<div class="card"> <div class="card">
@@ -247,6 +256,14 @@
<input id="quickChatInput" class="input" type="text" placeholder="快捷公屏消息(如:大家好)"> <input id="quickChatInput" class="input" type="text" placeholder="快捷公屏消息(如:大家好)">
<button id="btnQuickChat" class="btn btn-sm">公屏发送</button> <button id="btnQuickChat" class="btn btn-sm">公屏发送</button>
</div> </div>
<div class="row">
<span class="hint">移动测试:</span>
<button class="btn btn-sm" data-move="forward">前</button>
<button class="btn btn-sm" data-move="back">后</button>
<button class="btn btn-sm" data-move="left">左</button>
<button class="btn btn-sm" data-move="right">右</button>
<button class="btn btn-sm" data-move="jump">跳</button>
</div>
</div> </div>
</section> </section>
</main> </main>
+17
查看文件
@@ -196,6 +196,23 @@ html, body {
.bot-item .name { font-size: 13px; } .bot-item .name { font-size: 13px; }
.bot-item .sub { font-size: 11px; color: var(--muted); } .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 .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; } .chat-line { display: block; }
+67 -2
查看文件
@@ -247,6 +247,50 @@ class WebConsole {
res.json({ ok: true, data: this.botsPayload() }); 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) => { app.get('/api/bots/:id/status', (req, res) => {
this._withEntry(req, res, (entry) => ({ this._withEntry(req, res, (entry) => ({
ok: true, 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) => { app.post('/api/bots/:id/command', (req, res) => {
const command = req.body && req.body.command; const command = req.body && req.body.command;
@@ -828,8 +888,13 @@ class SingleBotPool {
/** @param {string} id @param {string} message */ /** @param {string} id @param {string} message */
sendChat(id, message) { sendChat(id, message) {
const entry = this.require(id); const entry = this.require(id);
const ok = entry.manager.sendChat(String(message || '').trim()); const text = String(message || '').trim();
return { ok, message: ok ? '已发送' : '发送失败(未完成登录验证)' }; if (!text) return { ok: false, message: '消息为空' };
if (!entry.manager.canSendChat()) {
return { ok: false, message: '未完成登录验证,已拒绝发送公屏消息' };
}
const ok = entry.manager.sendChat(text);
return { ok, message: ok ? '已发送' : '发送失败' };
} }
} }
+220
查看文件
@@ -7,6 +7,7 @@ const os = require('os');
const path = require('path'); const path = require('path');
const { BotManager, kindOfPosition, computeReconnectDelay } = require('../src/bot/botManager'); const { BotManager, kindOfPosition, computeReconnectDelay } = require('../src/bot/botManager');
const { FakeBot } = require('./helpers/fakeBot');
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');
@@ -349,3 +350,222 @@ test('preLoginCommands 配置被归一化(去斜杠、去空项、转小写)
const { config } = setup({ config: { server: { preLoginCommands: ['/Login', ' AUTH ', '', ' ', 'reg'] } } }); const { config } = setup({ config: { server: { preLoginCommands: ['/Login', ' AUTH ', '', ' ', 'reg'] } } });
assert.deepStrictEqual(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;
}
});
+119 -3
查看文件
@@ -7,7 +7,7 @@ const os = require('os');
const path = require('path'); const path = require('path');
const { BotPool } = require('../src/bot/botPool'); 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 { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig');
const { Logger } = require('../src/util/logger'); const { Logger } = require('../src/util/logger');
const { TerritoryModel } = require('../src/core/territoryModel'); const { TerritoryModel } = require('../src/core/territoryModel');
@@ -50,6 +50,7 @@ function setup(options) {
const opts = options || {}; const opts = options || {};
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-pool-')); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-pool-'));
const storePath = path.join(dir, 'bots.json'); 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'); if (opts.settings) fs.writeFileSync(storePath, JSON.stringify({ bots: opts.settings }), 'utf8');
const config = normalizeConfig(Object.assign(defaultConfig(), opts.config || {})); const config = normalizeConfig(Object.assign(defaultConfig(), opts.config || {}));
const pool = new BotPool({ const pool = new BotPool({
@@ -57,9 +58,10 @@ function setup(options) {
config, config,
logger: new Logger({ echo: false }), logger: new Logger({ echo: false }),
dataService: fakeDataService(), 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 为每个凭据建立一个机器人条目', () => { test('BotPool 为每个凭据建立一个机器人条目', () => {
@@ -281,3 +283,117 @@ test('未启用(enabled=false)的机器人不参与连接', () => {
pool.start(); pool.start();
assert.deepStrictEqual(connected, ['BotA']); 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');
}
});
+50 -2
查看文件
@@ -8,7 +8,7 @@ const path = require('path');
const { WebConsole } = require('../src/web/server'); const { WebConsole } = require('../src/web/server');
const { BotPool } = require('../src/bot/botPool'); 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 { ExpansionLog } = require('../src/core/expansionLog');
const { ChatLog, ChatKind } = require('../src/core/chatLog'); const { ChatLog, ChatKind } = require('../src/core/chatLog');
const { BigMapService } = require('../src/core/bigMapService'); const { BigMapService } = require('../src/core/bigMapService');
@@ -81,7 +81,8 @@ function setup(options) {
dataService, dataService,
expansionLog, expansionLog,
chatLog, 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({ 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 () => { test('HTTP:未配置密码时免登录(本机开发模式)', async () => {
const { webConsole } = setup(); const { webConsole } = setup();
await webConsole.start(); await webConsole.start();