'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 <验证码>` * 或扫码完成绑定,否则会被反复传送、并在倒计时结束后踢出。 * 这种流程无法靠密码自动完成,因此只做「明确告知 + 可选模板」处理。 */ 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 <验证码>);' + '绑定成功后同一账号即可自动登录。若服务器提供固定验证码,可在配置的「账号绑定指令模板」里填写。', '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 };