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