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