部署到 Debian 服务器时暴露的三个真实缺陷: 1. 「被服务器传送」日志洪水 账号未完成服务器验证时服务端每 tick 强推一次位置,实测 3 分钟刷出 748 行日志, 几十分钟就会冲光 2000 条的日志环形缓冲并把 WebSocket 打满。 现改为首次立即记录、其余按 30 秒窗口聚合成一条,断开连接时刷出未汇总计数。 2. 外站账号绑定流程无法识别 原先只认 /login 与 /register,遇到简幻通这类要求 /auth <UID> <验证码> 或扫码绑定的 服务器时,表现只是「连上→被传送→120 秒后被踢→重连」的静默死循环。 现新增 NEED_AUTH_PATTERN 识别并输出可操作的中文告警(每连接一次), 新增 server.authCommand 模板供有固定验证码的服务器自动过验证。 3. 重连无退避 连续被踢时每 10 秒硬撞服务器。现按指数退避拉长(server.reconnectMaxDelayMs, 默认上限 120 秒),成功进入世界后清零;抽出 computeReconnectDelay 便于测试。 顺带补充 README 的「部署到服务器」章节(Node ≥ 22 要求、私有运行时、pm2、日志分文件、排障表)。 测试从 242 增至 252,全部通过。
655 行
24 KiB
JavaScript
655 行
24 KiB
JavaScript
'use strict';
|
||
|
||
const { EventEmitter } = require('events');
|
||
|
||
const mineflayer = require('mineflayer');
|
||
|
||
const { GrimCompat } = require('./grimCompat');
|
||
const { PathingService } = require('./pathingService');
|
||
const { AutoEater } = require('./autoEat');
|
||
const { AutoBoatController } = require('./autoBoat');
|
||
const { DurabilityGuard } = require('./durabilityGuard');
|
||
const { ChatKind } = require('../core/chatLog');
|
||
|
||
/** 服务器要求登录 / 注册时的提示关键词 */
|
||
const LOGIN_PROMPT_PATTERN = /请(先)?(输入)?登录|请登录|请输入密码|login|登入/i;
|
||
const REGISTER_PROMPT_PATTERN = /请(先)?(输入)?注册|请注册|register/i;
|
||
const LOGIN_OK_PATTERN = /登录成功|登陆成功|注册成功|欢迎回来|成功登录/;
|
||
const ALREADY_LOGGED_IN_PATTERN = /已登录|无需登录|你已经登录/;
|
||
/** 登录 / 注册明确失败的关键词(命中后不再自动重试,也不放行操作) */
|
||
const LOGIN_FAIL_PATTERN = /密码(错误|不正确|有误)|登录失败|注册失败|wrong password|invalid password/i;
|
||
/**
|
||
* 服务器要求完成账号绑定 / 二次验证时的提示关键词。
|
||
*
|
||
* 典型场景:服务器用「简幻通」等外站账号体系,新账号必须先用 `/auth <UID> <验证码>`
|
||
* 或扫码完成绑定,否则会被反复传送、并在倒计时结束后踢出。
|
||
* 这种流程无法靠密码自动完成,因此只做「明确告知 + 可选模板」处理。
|
||
*/
|
||
const NEED_AUTH_PATTERN = /需要绑定|绑定您的|绑定你的|请使用命令:\s*\/?auth|扫描.{0,6}二维码|扫码|完成验证|身份验证/i;
|
||
|
||
/**
|
||
* 「被服务器传送」事件的日志聚合窗口(毫秒)。
|
||
*
|
||
* 账号未通过验证时,服务器会每 tick 强推一次位置,逐条记日志会在几分钟内
|
||
* 刷掉整个日志环形缓冲并把 WebSocket 打满,因此这里按窗口聚合。
|
||
*/
|
||
const FORCED_MOVE_WINDOW_MS = 30000;
|
||
|
||
/**
|
||
* 机器人管理器:负责连接、登录、断线重连,并把底层 mineflayer 事件
|
||
* 归一化成上层(控制器 / Web 控制台)可直接消费的事件。
|
||
*
|
||
* 同时在这里装配 Grim 合规层、寻路服务、自动进食、自动用船、耐久保护等子模块,
|
||
* 使它们与 bot 实例的生命周期保持一致。
|
||
*
|
||
* 一个 BotManager 实例对应**一个**游戏账号;同时挂多个玩家时由
|
||
* {@link module:bot/botPool.BotPool} 创建并持有多个实例。
|
||
*/
|
||
class BotManager extends EventEmitter {
|
||
/**
|
||
* @param {object} connection 连接参数
|
||
* @param {string} connection.host
|
||
* @param {number} connection.port
|
||
* @param {string} connection.username
|
||
* @param {string} connection.version
|
||
* @param {string} [connection.id] 机器人 id(缺省取游戏名)
|
||
* @param {string} [connection.label] 备注名
|
||
* @param {string} [connection.auth]
|
||
* @param {string} [connection.password]
|
||
* @param {object} config 完整配置对象
|
||
* @param {import('../util/logger').Logger} [logger]
|
||
* @param {{chatLog?: import('../core/chatLog').ChatLog}} [options]
|
||
*/
|
||
constructor(connection, config, logger, options) {
|
||
super();
|
||
const opts = options || {};
|
||
this.connection = connection;
|
||
this.config = config;
|
||
this.logger = logger || null;
|
||
/** 机器人 id:用于 URL、文件名与日志归属 */
|
||
this.id = connection.id || connection.username;
|
||
/** 备注名(控制台展示) */
|
||
this.label = connection.label || connection.username;
|
||
/** @type {import('../core/chatLog').ChatLog|null} 游戏公屏完整日志 */
|
||
this.chatLog = opts.chatLog || null;
|
||
|
||
/** @type {import('mineflayer').Bot|null} */
|
||
this.bot = null;
|
||
/** @type {GrimCompat|null} */
|
||
this.grim = null;
|
||
/** @type {PathingService|null} */
|
||
this.pathing = null;
|
||
/** @type {AutoEater|null} */
|
||
this.eater = null;
|
||
/** @type {AutoBoatController|null} */
|
||
this.boat = null;
|
||
/** @type {DurabilityGuard|null} */
|
||
this.guard = null;
|
||
|
||
/** 连接状态:idle | connecting | online | offline */
|
||
this.state = 'idle';
|
||
/** 是否已完成登录(离线服密码登录) */
|
||
this.loggedIn = false;
|
||
/** 登录 / 注册是否被服务器明确拒绝 */
|
||
this.loginFailed = false;
|
||
/** 登录确认兜底定时器(部分服务器不回成功提示) */
|
||
this._loginConfirmTimer = null;
|
||
/** 断线重连计数 */
|
||
this.reconnectAttempts = 0;
|
||
/** 是否由用户主动断开(主动断开不重连) */
|
||
this._manualDisconnect = false;
|
||
this._reconnectTimer = null;
|
||
this._lastCommandAt = 0;
|
||
/** 待发送的指令队列(保证最小间隔) */
|
||
this._commandQueue = [];
|
||
/** 待发送的聊天队列(与指令共用同一个节流器,避免刷屏) */
|
||
this._chatQueue = [];
|
||
this._commandTimer = null;
|
||
/** 最近一次收到过的聊天消息(供 Web 控制台展示) */
|
||
this.lastMessages = [];
|
||
/** 本次连接内「被服务器传送」的计数与聚合定时器 */
|
||
this._forcedMoveCount = 0;
|
||
this._forcedMoveFirstAt = 0;
|
||
this._forcedMoveTimer = null;
|
||
/** 本次连接内是否已提示过「账号需要绑定 / 二次验证」 */
|
||
this._needAuthWarned = false;
|
||
}
|
||
|
||
// ==================== 连接生命周期 ====================
|
||
|
||
/** 建立连接(会自动重连) */
|
||
connect() {
|
||
if (this.bot) return;
|
||
this._manualDisconnect = false;
|
||
this.state = 'connecting';
|
||
const options = {
|
||
host: this.connection.host,
|
||
port: this.connection.port,
|
||
username: this.connection.username,
|
||
version: this.connection.version,
|
||
auth: this.connection.auth || 'offline',
|
||
// 关掉 mineflayer 自带的聊天模式匹配,避免无谓的解析开销
|
||
chatLengthLimit: 256
|
||
};
|
||
this._log(`正在连接 ${options.host}:${options.port}(${options.username} / ${options.version} / ${options.auth})`);
|
||
let bot;
|
||
try {
|
||
bot = mineflayer.createBot(options);
|
||
} catch (err) {
|
||
this.state = 'offline';
|
||
this._log('创建 bot 失败:' + (err && err.message ? err.message : String(err)));
|
||
this._scheduleReconnect();
|
||
return;
|
||
}
|
||
this.bot = bot;
|
||
this._wireEvents(bot);
|
||
}
|
||
|
||
/** 主动断开(不触发重连) */
|
||
disconnect() {
|
||
this._manualDisconnect = true;
|
||
if (this._reconnectTimer) {
|
||
clearTimeout(this._reconnectTimer);
|
||
this._reconnectTimer = null;
|
||
}
|
||
this._clearLoginConfirmTimer();
|
||
this._clearForcedMoveTimer();
|
||
this._commandQueue.length = 0;
|
||
this._chatQueue.length = 0;
|
||
if (this.bot) {
|
||
try {
|
||
this.bot.quit('AutoStore 主动断开');
|
||
} catch {
|
||
// 忽略
|
||
}
|
||
}
|
||
this.state = 'idle';
|
||
}
|
||
|
||
/** @returns {boolean} 是否已进入世界并完成登录,可以执行任务 */
|
||
isReady() {
|
||
return this.state === 'online' && !!this.bot && !!this.bot.entity
|
||
&& !!this.bot.pathfinder && this.loggedIn;
|
||
}
|
||
|
||
/** @returns {boolean} 是否已进入世界(未强制要求登录完成) */
|
||
isSpawned() {
|
||
return !!this.bot && !!this.bot.entity && this.state === 'online';
|
||
}
|
||
|
||
_scheduleReconnect() {
|
||
if (this._manualDisconnect || !this.config.server.autoReconnect) return;
|
||
if (this._reconnectTimer) return;
|
||
this.reconnectAttempts++;
|
||
const delay = computeReconnectDelay(this.config.server, this.reconnectAttempts);
|
||
this._log(`${Math.round(delay / 1000)} 秒后尝试第 ${this.reconnectAttempts} 次重连`);
|
||
this._reconnectTimer = setTimeout(() => {
|
||
this._reconnectTimer = null;
|
||
this.bot = null;
|
||
this.connect();
|
||
}, delay);
|
||
if (this._reconnectTimer.unref) this._reconnectTimer.unref();
|
||
}
|
||
|
||
// ==================== 事件装配 ====================
|
||
|
||
_wireEvents(bot) {
|
||
bot.on('login', () => {
|
||
this.state = 'online';
|
||
this._needAuthWarned = false;
|
||
this._log('已连接到服务器,正在进入世界');
|
||
this._handleLogin();
|
||
});
|
||
|
||
bot.on('spawn', () => {
|
||
this._log('已进入世界');
|
||
this.reconnectAttempts = 0;
|
||
this._installModules();
|
||
this.emit('spawn');
|
||
});
|
||
|
||
bot.on('end', (reason) => {
|
||
this.state = 'offline';
|
||
this.loggedIn = false;
|
||
this._clearLoginConfirmTimer();
|
||
this._clearForcedMoveTimer();
|
||
this._log('连接已断开:' + (reason || '未知原因'));
|
||
this.emit('end', reason);
|
||
this._scheduleReconnect();
|
||
});
|
||
|
||
bot.on('kicked', (reason) => {
|
||
const text = typeof reason === 'string' ? reason : JSON.stringify(reason);
|
||
this._log('被服务器踢出:' + text, 'warn');
|
||
this.emit('kicked', text);
|
||
});
|
||
|
||
bot.on('error', (err) => {
|
||
this._log('连接错误:' + (err && err.message ? err.message : String(err)), 'error');
|
||
this.emit('botError', err);
|
||
});
|
||
|
||
bot.on('death', () => {
|
||
this._log('机器人死亡', 'warn');
|
||
this.emit('death');
|
||
});
|
||
|
||
// ===== 聊天 / 字幕 / 动作栏:占领判定与登录提示的数据源 =====
|
||
// messagestr 的第二参为消息通道(chat / system / game_info),第四参为发言者(正版签名聊天才有)。
|
||
bot.on('messagestr', (message, position, _jsonMsg, sender) => {
|
||
if (!message) return;
|
||
this._pushMessage(message);
|
||
// 游戏公屏完整日志:所有消息(含系统公告)都留档,不只留开垦相关
|
||
this._recordChat(message, position, sender);
|
||
this._handleAuthPrompt(message);
|
||
this.emit('chat', message, position);
|
||
});
|
||
|
||
bot.on('title', (text, type) => {
|
||
if (!text) return;
|
||
if (type === 'subtitle') this.emit('subtitle', text);
|
||
else this.emit('title', text);
|
||
});
|
||
|
||
bot.on('actionBar', (message) => {
|
||
const text = message && message.toString ? message.toString() : String(message || '');
|
||
if (text) this.emit('subtitle', text);
|
||
});
|
||
|
||
bot.on('bossBarUpdated', (bar) => {
|
||
const text = bar && bar.title ? String(bar.title) : '';
|
||
if (text) this.emit('subtitle', text);
|
||
});
|
||
bot.on('bossBarCreated', (bar) => {
|
||
const text = bar && bar.title ? String(bar.title) : '';
|
||
if (text) this.emit('subtitle', text);
|
||
});
|
||
|
||
bot.on('forcedMove', () => {
|
||
this.emit('forcedMove');
|
||
this._countForcedMove();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 聚合「被服务器传送」事件:首次立即记一条,其余按 {@link FORCED_MOVE_WINDOW_MS}
|
||
* 窗口汇总成一条,避免账号未验证时把日志刷爆。
|
||
*/
|
||
_countForcedMove() {
|
||
this._forcedMoveCount++;
|
||
if (this._forcedMoveCount === 1) {
|
||
this._forcedMoveFirstAt = Date.now();
|
||
this._log('被服务器传送(位置强制同步)');
|
||
}
|
||
if (!this._forcedMoveTimer) {
|
||
this._forcedMoveTimer = setTimeout(() => this._flushForcedMove(), FORCED_MOVE_WINDOW_MS);
|
||
if (this._forcedMoveTimer.unref) this._forcedMoveTimer.unref();
|
||
}
|
||
}
|
||
|
||
/** 输出传送事件汇总并重置计数 */
|
||
_flushForcedMove() {
|
||
this._forcedMoveTimer = null;
|
||
const extra = this._forcedMoveCount - 1;
|
||
this._forcedMoveCount = 0;
|
||
if (extra <= 0) return;
|
||
const seconds = Math.max(1, Math.round((Date.now() - this._forcedMoveFirstAt) / 1000));
|
||
this._log(`被服务器传送另有 ${extra} 次(最近 ${seconds} 秒内):通常是账号未完成服务器验证`
|
||
+ `(如简幻通绑定)被反复拉回,或被 /spawn 类指令重置位置`, 'warn');
|
||
}
|
||
|
||
/** 在世界里装配各子模块(Grim 层、寻路、进食、用船、耐久) */
|
||
_installModules() {
|
||
const bot = this.bot;
|
||
this.grim = new GrimCompat(bot, this.config, this.logger);
|
||
this.grim.install();
|
||
const audit = this.grim.audit();
|
||
if (!audit.ok) {
|
||
for (const issue of audit.issues) this._log('合规自检:' + issue, 'warn');
|
||
}
|
||
this.pathing = new PathingService(bot, this.config, this.logger);
|
||
this.pathing.configure();
|
||
this.eater = new AutoEater(bot, this.config, this.logger, this.grim);
|
||
this.boat = new AutoBoatController(bot, this.config, this.logger, this.grim);
|
||
this.guard = new DurabilityGuard(bot, this.config, this.logger);
|
||
this.emit('modulesReady');
|
||
}
|
||
|
||
/**
|
||
* 配置变更后重新应用到子模块(寻路器、合规层等)。
|
||
* @param {object} config
|
||
*/
|
||
applyConfig(config) {
|
||
this.config = config;
|
||
if (this.pathing) this.pathing.applyConfig(config);
|
||
if (this.grim) this.grim.config = config;
|
||
if (this.eater) this.eater.config = config;
|
||
if (this.boat) this.boat.config = config;
|
||
if (this.guard) this.guard.config = config;
|
||
}
|
||
|
||
// ==================== 登录 ====================
|
||
|
||
/** 进入世界后按配置尝试登录 / 注册 */
|
||
_handleLogin() {
|
||
const server = this.config.server;
|
||
const password = this.connection.password;
|
||
this.loggedIn = !server.loginCommand;
|
||
this.loginFailed = false;
|
||
if (!server.loginCommand || !password) return;
|
||
// 稍等片刻再发,避免与服务器的欢迎信息 / 传送抢时序
|
||
setTimeout(() => {
|
||
if (!this.bot || this.state !== 'online') return;
|
||
this.sendCommand(fillTemplate(server.loginCommand, password), { internal: true });
|
||
this._armLoginConfirmFallback();
|
||
}, 1500).unref?.();
|
||
}
|
||
|
||
/**
|
||
* 登录确认兜底:部分离线服登录成功后只发一句普通提示,甚至什么都不发。
|
||
* 若在超时内既没收到成功提示也没收到失败提示,就按「已登录」放行,
|
||
* 否则机器人会永远卡在「未登录」而无法接受控制台操作。
|
||
*/
|
||
_armLoginConfirmFallback() {
|
||
const timeout = this.config.server.loginConfirmTimeoutMs;
|
||
if (!Number.isFinite(timeout) || timeout <= 0) return;
|
||
this._clearLoginConfirmTimer();
|
||
this._loginConfirmTimer = setTimeout(() => {
|
||
this._loginConfirmTimer = null;
|
||
if (this.loggedIn || this.loginFailed) return;
|
||
this.loggedIn = true;
|
||
this._log(`未在 ${timeout} 毫秒内收到登录结果提示,按已登录处理(可调小 server.loginConfirmTimeoutMs 观察)`, 'warn');
|
||
}, timeout);
|
||
if (this._loginConfirmTimer.unref) this._loginConfirmTimer.unref();
|
||
}
|
||
|
||
_clearLoginConfirmTimer() {
|
||
if (this._loginConfirmTimer) {
|
||
clearTimeout(this._loginConfirmTimer);
|
||
this._loginConfirmTimer = null;
|
||
}
|
||
}
|
||
|
||
/** 断开前把未汇总的传送计数刷出去并清掉定时器 */
|
||
_clearForcedMoveTimer() {
|
||
if (this._forcedMoveTimer) {
|
||
clearTimeout(this._forcedMoveTimer);
|
||
this._forcedMoveTimer = null;
|
||
const extra = this._forcedMoveCount - 1;
|
||
this._forcedMoveCount = 0;
|
||
if (extra > 0) this._log(`被服务器传送另有 ${extra} 次(连接断开前)`, 'warn');
|
||
}
|
||
}
|
||
|
||
/** 从聊天消息里识别服务器的登录 / 注册提示 */
|
||
_handleAuthPrompt(message) {
|
||
const server = this.config.server;
|
||
const password = this.connection.password;
|
||
if (LOGIN_FAIL_PATTERN.test(message)) {
|
||
this.loginFailed = true;
|
||
this.loggedIn = false;
|
||
this._clearLoginConfirmTimer();
|
||
this._log('登录 / 注册被服务器拒绝:' + message, 'error');
|
||
return;
|
||
}
|
||
// 外站账号绑定 / 二次验证(如简幻通):密码无法自动完成,只能明确告知用户
|
||
if (NEED_AUTH_PATTERN.test(message)) {
|
||
if (!this._needAuthWarned) {
|
||
this._needAuthWarned = true;
|
||
this._log('服务器要求先完成账号绑定 / 二次验证(本服为「简幻通」体系):' + message, 'error');
|
||
this._log('该验证需要人工完成一次(游戏内扫码或 /auth <UID> <验证码>);'
|
||
+ '绑定成功后同一账号即可自动登录。若服务器提供固定验证码,可在配置的「账号绑定指令模板」里填写。', 'warn');
|
||
}
|
||
if (server.authCommand) {
|
||
this.sendCommand(fillTemplate(server.authCommand, password), { internal: true, immediate: true });
|
||
this._armLoginConfirmFallback();
|
||
}
|
||
return;
|
||
}
|
||
if (!password) return;
|
||
if (LOGIN_OK_PATTERN.test(message)) {
|
||
if (!this.loggedIn) this._log('登录成功', 'success');
|
||
this.loggedIn = true;
|
||
this.loginFailed = false;
|
||
this._clearLoginConfirmTimer();
|
||
return;
|
||
}
|
||
if (ALREADY_LOGGED_IN_PATTERN.test(message)) {
|
||
this.loggedIn = true;
|
||
this._clearLoginConfirmTimer();
|
||
return;
|
||
}
|
||
if (server.registerCommand && REGISTER_PROMPT_PATTERN.test(message)) {
|
||
this._log('服务器要求注册,正在注册');
|
||
this.sendCommand(fillTemplate(server.registerCommand, password), { internal: true });
|
||
this._armLoginConfirmFallback();
|
||
return;
|
||
}
|
||
if (server.loginCommand && LOGIN_PROMPT_PATTERN.test(message)) {
|
||
this.sendCommand(fillTemplate(server.loginCommand, password), { internal: true });
|
||
this._armLoginConfirmFallback();
|
||
}
|
||
}
|
||
|
||
// ==================== 指令发送 ====================
|
||
|
||
/**
|
||
* 是否已通过登录验证、允许操作该机器人的控制台 / 聊天框。
|
||
* 这是「登录验证后才允许操作」的统一判定入口。
|
||
* @returns {boolean}
|
||
*/
|
||
canOperate() {
|
||
return this.state === 'online' && !!this.bot && this.loggedIn;
|
||
}
|
||
|
||
/**
|
||
* 发送一条聊天指令(带最小间隔节流,防刷屏被服务器或 Grim 判定异常)。
|
||
*
|
||
* 未完成登录验证时拒绝发送:离线服的 /login 是硬门槛,
|
||
* 未登录就发 /u claimmode 只会被服务器静默丢弃。
|
||
*
|
||
* @param {string} command 不带斜杠的指令,例如 `u claimmode`
|
||
* @param {{immediate?: boolean, internal?: boolean}} [options] internal 为登录 / 注册流程自身使用,绕过门禁
|
||
* @returns {boolean} 是否已入队
|
||
*/
|
||
sendCommand(command, options) {
|
||
if (!command) return false;
|
||
const opts = options || {};
|
||
if (!opts.internal && !this.canOperate()) {
|
||
this._log('未完成登录验证,已拒绝发送指令:/' + command, 'warn');
|
||
return false;
|
||
}
|
||
if (opts.immediate) {
|
||
this._writeCommand(command);
|
||
return true;
|
||
}
|
||
this._commandQueue.push(command);
|
||
this._drainCommandQueue();
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 发送一条公屏聊天消息(不是指令,不带斜杠)。
|
||
*
|
||
* 同样要求已完成登录验证;发送内容会写入该机器人的公屏日志(kind=out),
|
||
* 便于事后核对「谁在什么时候发了什么」。
|
||
*
|
||
* @param {string} message 聊天正文
|
||
* @param {{internal?: boolean}} [options]
|
||
* @returns {boolean} 是否已入队
|
||
*/
|
||
sendChat(message, options) {
|
||
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');
|
||
return false;
|
||
}
|
||
// 以 `/` 开头的内容交给 sendCommand,避免被当成普通聊天发出去
|
||
if (text.startsWith('/')) return this.sendCommand(text.replace(/^\//, ''), opts);
|
||
this._chatQueue.push(text);
|
||
this._drainCommandQueue();
|
||
return true;
|
||
}
|
||
|
||
_drainCommandQueue() {
|
||
if (this._commandTimer) return;
|
||
const now = Date.now();
|
||
const wait = Math.max(0, this.config.server.commandCooldownMs - (now - this._lastCommandAt));
|
||
this._commandTimer = setTimeout(() => {
|
||
this._commandTimer = null;
|
||
const nextCommand = this._commandQueue.shift();
|
||
if (nextCommand !== undefined) {
|
||
this._writeCommand(nextCommand);
|
||
} else {
|
||
const nextChat = this._chatQueue.shift();
|
||
if (nextChat !== undefined) this._writeChat(nextChat);
|
||
}
|
||
if (this._commandQueue.length > 0 || this._chatQueue.length > 0) this._drainCommandQueue();
|
||
}, wait);
|
||
if (this._commandTimer.unref) this._commandTimer.unref();
|
||
}
|
||
|
||
_writeCommand(command) {
|
||
if (!this.bot) return;
|
||
this._lastCommandAt = Date.now();
|
||
const text = command.startsWith('/') ? command : '/' + command;
|
||
this._log('发送指令:' + text);
|
||
// 指令也留档:出问题时能对照「指令发出后服务器回了什么」
|
||
this._recordOutgoing(text, ChatKind.COMMAND);
|
||
try {
|
||
this.bot.chat(text);
|
||
} catch (err) {
|
||
this._log('发送指令失败:' + (err && err.message ? err.message : String(err)));
|
||
}
|
||
}
|
||
|
||
/** 发送普通聊天内容(不带斜杠) */
|
||
_writeChat(message) {
|
||
if (!this.bot) return;
|
||
this._lastCommandAt = Date.now();
|
||
this._log('发送聊天:' + message);
|
||
this._recordOutgoing(message, ChatKind.OUT);
|
||
try {
|
||
this.bot.chat(message);
|
||
} catch (err) {
|
||
this._log('发送聊天失败:' + (err && err.message ? err.message : String(err)));
|
||
}
|
||
}
|
||
|
||
/** @returns {object} 连接状态快照(供 Web 控制台展示) */
|
||
snapshot() {
|
||
const bot = this.bot;
|
||
const entity = bot ? bot.entity : null;
|
||
return {
|
||
id: this.id,
|
||
label: this.label,
|
||
state: this.state,
|
||
loggedIn: this.loggedIn,
|
||
loginFailed: this.loginFailed,
|
||
canOperate: this.canOperate(),
|
||
username: this.connection.username,
|
||
host: `${this.connection.host}:${this.connection.port}`,
|
||
version: this.connection.version,
|
||
reconnectAttempts: this.reconnectAttempts,
|
||
health: bot ? bot.health : null,
|
||
food: bot ? bot.food : null,
|
||
position: entity ? {
|
||
x: Number(entity.position.x.toFixed(2)),
|
||
y: Number(entity.position.y.toFixed(2)),
|
||
z: Number(entity.position.z.toFixed(2))
|
||
} : null,
|
||
chunk: entity ? {
|
||
x: Math.floor(entity.position.x / 16),
|
||
z: Math.floor(entity.position.z / 16)
|
||
} : null,
|
||
inWater: entity ? entity.isInWater === true : false,
|
||
riding: !!(bot && bot.vehicle),
|
||
pathing: this.pathing ? this.pathing.isPathing() : false,
|
||
eating: this.eater ? this.eater.eating : false,
|
||
grim: this.grim ? this.grim.snapshot() : null
|
||
};
|
||
}
|
||
|
||
_pushMessage(message) {
|
||
this.lastMessages.push({ time: Date.now(), message });
|
||
if (this.lastMessages.length > 200) this.lastMessages.shift();
|
||
}
|
||
|
||
/**
|
||
* 把收到的消息写入游戏公屏完整日志。
|
||
*
|
||
* @param {string} message 消息正文
|
||
* @param {string} [position] mineflayer 的消息通道:chat / system / game_info
|
||
* @param {string} [sender] 发言人(正版签名聊天才有)
|
||
*/
|
||
_recordChat(message, position, sender) {
|
||
if (!this.chatLog) return;
|
||
this.chatLog.append({
|
||
botId: this.id,
|
||
bot: this.connection.username,
|
||
message,
|
||
kind: kindOfPosition(position),
|
||
username: typeof sender === 'string' ? sender : (sender && sender.name ? sender.name : '')
|
||
});
|
||
}
|
||
|
||
/** 记录本机器人主动发出的内容 */
|
||
_recordOutgoing(message, kind) {
|
||
if (!this.chatLog) return;
|
||
this.chatLog.append({
|
||
botId: this.id,
|
||
bot: this.connection.username,
|
||
message,
|
||
kind
|
||
});
|
||
}
|
||
|
||
_log(message, level) {
|
||
if (!this.logger) return;
|
||
// 多机器人共用一份运行日志:统一加机器人名前缀,便于区分来源
|
||
this.logger.log(`[${this.connection.username}] ${message}`, level);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* mineflayer 的消息通道 → 公屏日志类型。
|
||
* @param {string} [position]
|
||
* @returns {string}
|
||
*/
|
||
function kindOfPosition(position) {
|
||
if (position === 'system') return ChatKind.SYSTEM;
|
||
if (position === 'game_info') return ChatKind.ACTION;
|
||
return ChatKind.CHAT;
|
||
}
|
||
|
||
/**
|
||
* 把指令模板里的 `{password}` 占位符替换为真实密码。
|
||
* @param {string} template
|
||
* @param {string} password
|
||
* @returns {string}
|
||
*/
|
||
function fillTemplate(template, password) {
|
||
return String(template).replace(/\{password\}/g, password);
|
||
}
|
||
|
||
/**
|
||
* 计算第 n 次重连的等待时长:按指数退避逐步拉长,并钳制在上限内。
|
||
*
|
||
* 连续重连失败(例如账号未完成服务器验证被反复踢出)时,固定 10 秒一次地硬撞服务器
|
||
* 既无意义又可能触发风控;成功进入世界后 `reconnectAttempts` 会清零,间隔回到基准值。
|
||
*
|
||
* @param {{reconnectDelayMs: number, reconnectMaxDelayMs?: number}} serverConfig
|
||
* @param {number} attempts 本次是第几次重连(从 1 开始)
|
||
* @returns {number} 等待毫秒数
|
||
*/
|
||
function computeReconnectDelay(serverConfig, attempts) {
|
||
const base = Math.max(1, Number(serverConfig.reconnectDelayMs) || 10000);
|
||
const max = Math.max(base, Number(serverConfig.reconnectMaxDelayMs) || base);
|
||
const step = Math.max(1, Math.trunc(attempts) || 1);
|
||
return Math.min(max, base * Math.pow(2, Math.min(6, step - 1)));
|
||
}
|
||
|
||
module.exports = { BotManager, fillTemplate, kindOfPosition, computeReconnectDelay, FORCED_MOVE_WINDOW_MS };
|