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,全部通过。
这个提交包含在:
+62
@@ -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 <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
|
||||
|
||||
新增多机器人挂机、开拓日志、游戏公屏完整日志与大地图。
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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 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();
|
||||
|
||||
+152
-8
@@ -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<string, object>} 机器人 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<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
|
||||
* @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 };
|
||||
|
||||
+57
-2
@@ -18,7 +18,6 @@ class BotStore {
|
||||
constructor(filePath) {
|
||||
this.filePath = filePath || path.join(__dirname, '..', '..', 'data', 'bots.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取注册表;文件不存在或损坏时返回空表。
|
||||
* @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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+13
-7
@@ -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(
|
||||
{
|
||||
|
||||
+92
-2
@@ -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() {
|
||||
|
||||
@@ -41,6 +41,15 @@
|
||||
</div>
|
||||
</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 class="card">
|
||||
@@ -247,6 +256,14 @@
|
||||
<input id="quickChatInput" class="input" type="text" placeholder="快捷公屏消息(如:大家好)">
|
||||
<button id="btnQuickChat" class="btn btn-sm">公屏发送</button>
|
||||
</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>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+67
-2
@@ -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 ? '已发送' : '发送失败' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
+119
-3
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
+50
-2
@@ -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();
|
||||
|
||||
在新工单中引用
屏蔽一个用户