'use strict'; const { EventEmitter } = require('events'); const { BotManager } = require('./botManager'); const { ScheduledMessenger } = require('./scheduledMessenger'); const { ExpansionController } = require('../controller/expansionController'); const { normalizeConfig } = require('../config/expansionConfig'); const { sanitizeId } = require('../config/botsConfig'); /** * 机器人池:同时挂多个玩家(机器人)。 * * 每个机器人由一对 `BotManager + ExpansionController` 组成,各自独立连接、独立登录、 * 独立跑扩地状态机;但**共享**同一份领土数据服务(markers.json 是全局的, * 没必要每个机器人各拉一遍)与同一份运行日志。 * * 账号来源有两个,同名时以 `.env` 为准(环境变量优先级更高,避免「改了 .env 不生效」): * 1. `.env` 的 `MC_BOTS` / `MC_USERNAME`(`source: 'env'`); * 2. 控制台「添加玩家」写入的 `data/bots.credentials.json`(`source: 'store'`)。 * * 每个机器人可以有自己的一套扩地配置覆盖(存在 `data/bots.json`), * 未覆盖的字段回退到全局配置。 */ class BotPool extends EventEmitter { /** * @param {object} deps * @param {Array} deps.connections 连接参数列表(来自 .env,见 botsConfig) * @param {object} deps.config 全局配置 * @param {import('../util/logger').Logger} [deps.logger] * @param {import('../core/territoryDataService').TerritoryDataService} deps.dataService * @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.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} 机器人 id → 运行时条目 */ this.entries = new Map(); /** 已执行过「启动后自动开始扩地」的机器人(避免重连后把手动停止的任务又拉起来) */ 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._mergeConnections(deps.connections || [])) { this._addBot(connection); } this.defaultId = this.entries.size > 0 ? this.entries.keys().next().value : ''; } /** * 合并两个账号来源:`.env` 优先,控制台新增的账号补齐。 * @param {Array} envConnections * @returns {Array} */ _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 || 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 }), this.getConfigFor(id), this.logger, { chatLog: this.chatLog } ); const controller = new ExpansionController({ botManager: manager, dataService: this.dataService, logger: this.logger, botId: id, botName: connection.username, expansionLog: this.expansionLog }); // 寻路事件转发给控制器(与 Baritone 的 PathEvent 语义一致) manager.on('modulesReady', () => { if (manager.pathing) { manager.pathing.onPathEvent = (event, detail) => controller.onPathEvent(event, detail); } // 进入世界并装配完成后按设置决定是否自动开始扩地。 // 只在本次进程内首次进入世界时触发:断线重连后不自动拉起, // 否则用户在控制台手动停止的任务会被重连事件重新启动。 if (this._settingOf(id).autoStart && !this._autoStarted.has(id)) { this._autoStarted.add(id); this.startTask(id).catch((err) => { if (this.logger) this.logger.log(`[${connection.username}] 自动开始扩地失败:${err.message}`, 'error'); }); } }); manager.on('spawn', () => this.emit('botSpawn', id)); manager.on('end', () => this.emit('botEnd', id)); // 定时消息器:按固定间隔重复发送同一条内容(如每 300 秒 /u restore confirm) const messenger = new ScheduledMessenger(manager, this.logger); messenger.setTasks(setting.schedules || []); this.entries.set(id, { id, connection, manager, controller, messenger, /** 账号来源: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.messenger.start(); 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.messenger.stop(); 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; } // ==================== 生命周期 ==================== /** 连接所有已启用的机器人 */ start() { this.startedAt = Date.now(); for (const entry of this.entries.values()) { if (!this._settingOf(entry.id).enabled) { this._log(`${entry.connection.username} 已在控制台禁用,跳过连接`); continue; } entry.messenger.start(); entry.manager.connect(); } } /** 断开所有机器人 */ stop() { for (const entry of this.entries.values()) { try { entry.controller.stop(); } catch { // 忽略 } entry.messenger.stop(); entry.manager.disconnect(); } } /** 驱动所有机器人的扩地状态机(由外部按 tick 调用) */ tick() { for (const entry of this.entries.values()) { try { entry.controller.tick(); } catch (err) { if (this.logger) { this.logger.log(`[${entry.connection.username}] tick 异常:` + (err && err.stack ? err.stack : err), 'error'); } } } } // ==================== 访问 ==================== /** @returns {Array} 全部机器人 id */ ids() { return Array.from(this.entries.keys()); } /** @returns {number} 机器人数量 */ get size() { return this.entries.size; } /** * @param {string} [id] 机器人 id;缺省取第一个 * @returns {object|null} 运行时条目 */ get(id) { if (!id) return this.entries.get(this.defaultId) || null; return this.entries.get(id) || null; } /** * 取机器人条目,找不到时抛错(供 REST 接口返回 404 用)。 * @param {string} id * @returns {object} */ require(id) { const entry = this.get(id); if (!entry) { const err = new Error('机器人不存在:' + id); err.statusCode = 404; throw err; } return entry; } // ==================== 配置 ==================== /** @returns {object} 机器人的非敏感设置(启用 / 备注 / 自动开始 / 定时任务 / 配置覆盖) */ _settingOf(id) { const raw = this.settings[id] || {}; return { enabled: raw.enabled !== false, autoStart: raw.autoStart === true, label: raw.label || '', schedules: Array.isArray(raw.schedules) ? raw.schedules : [], config: raw.config && typeof raw.config === 'object' ? raw.config : {} }; } /** * 计算某机器人实际生效的配置:全局配置 + 该机器人的覆盖。 * @param {string} id * @returns {object} */ getConfigFor(id) { const setting = this._settingOf(id); return normalizeConfig(setting.config, this.config); } /** * 更新全局配置并下发给所有机器人(各自再叠加自己的覆盖)。 * @param {object} config 新的全局配置 */ applyGlobalConfig(config) { this.config = config; for (const entry of this.entries.values()) { entry.config = this.getConfigFor(entry.id); entry.manager.applyConfig(entry.config); entry.controller.config = entry.config; } } /** * 保存某机器人的设置(启用 / 备注 / 自动开始 / 配置覆盖)并立即生效。 * * @param {string} id * @param {object} patch 部分设置;`config` 字段为配置覆盖(整体替换) * @returns {object} 生效后的设置 */ setBotSetting(id, patch) { const entry = this.require(id); const current = this.settings[id] || {}; const next = Object.assign({}, current, patch || {}); if (patch && patch.config !== undefined) { // 配置覆盖只接受已知字段,避免脏数据污染运行状态 next.config = normalizeConfig(patch.config, this.config); } this.settings[id] = next; this._persistSettings(); entry.config = this.getConfigFor(id); entry.manager.applyConfig(entry.config); entry.controller.config = entry.config; // 定时任务列表整体替换:把归一化结果写回设置, // 保证「接口返回值 / 落盘内容 / 运行时状态」三者一致 if (patch && patch.schedules !== undefined) { const normalized = entry.messenger.setTasks(this._settingOf(id).schedules); this.settings[id] = Object.assign({}, this.settings[id], { schedules: normalized }); this._persistSettings(); } return this._settingOf(id); } /** @returns {object} 机器人的设置快照(供控制台表单回填) */ settingSnapshot(id) { const setting = this._settingOf(id); return { id, label: setting.label, enabled: setting.enabled, autoStart: setting.autoStart, hasOverrides: Object.keys(setting.config).length > 0, config: this.getConfigFor(id), schedules: this.entry(id) ? this.entry(id).messenger.snapshot() : [] }; } /** * 取运行时条目(不存在时返回 null,不抛异常)。 * @param {string} id * @returns {object|null} */ entry(id) { return this.entries.get(id) || null; } _persistSettings() { if (!this.botStore) return; this.botStore.save({ bots: this.settings }); } // ==================== 任务与控制 ==================== /** * 启动某机器人的扩地任务。 * @param {string} id * @param {object} [overrideConfig] 本次任务的配置覆盖(来自控制台表单) * @returns {Promise<{ok: boolean, message: string, entry: object}>} */ async startTask(id, overrideConfig) { const entry = this.require(id); const config = overrideConfig ? normalizeConfig(overrideConfig, this.getConfigFor(id)) : this.getConfigFor(id); if (overrideConfig) { // 控制台点「开始扩地」时顺带保存该机器人的配置 this.settings[id] = Object.assign({}, this.settings[id] || {}, { config }); this._persistSettings(); entry.config = config; entry.manager.applyConfig(config); } entry.controller.config = config; const ok = await entry.controller.start(config); return { ok, message: ok ? '任务已启动' : entry.controller.status, entry }; } /** 停止某机器人的扩地任务 */ stopTask(id) { const entry = this.require(id); entry.controller.stop(); return entry; } /** 重连某机器人 */ reconnect(id) { const entry = this.require(id); entry.manager.disconnect(); setTimeout(() => { // 主动断开后 BotManager 不会自动重连,这里显式重建连接 entry.manager.connect(); }, 500).unref?.(); return entry; } /** * 向某机器人发送游戏指令。 * * 登录门禁由 BotManager 判定:验证类指令(login / register / auth 等)即使在 * 未通过验证时也放行,否则会出现「要先登录才能发登录指令」的死锁。 * * @param {string} id * @param {string} command 不带斜杠的指令 * @returns {{ok: boolean, message: string}} */ sendCommand(id, command) { const entry = this.require(id); const text = String(command || '').replace(/^\//, '').trim(); if (!text) return { ok: false, message: '指令为空' }; if (!entry.manager.canSendCommand(text)) { return { ok: false, message: describeSendFailure(entry.manager, text) }; } const ok = entry.manager.sendCommand(text); return { ok, message: ok ? '已发送:' + command : '发送失败' }; } /** * 向某机器人发送公屏聊天消息。 * @param {string} id * @param {string} message 聊天正文(以 `/` 开头时按指令发送) * @returns {{ok: boolean, message: string}} */ /** * 向某机器人发送内容:聊天与指令是同一个入口,**以 `/` 开头按指令发送**,否则按公屏消息发送。 * * @param {string} id * @param {string} message 内容(可以带或不带前导 `/`) * @returns {{ok: boolean, kind: string, message: string}} */ sendChat(id, message) { const entry = this.require(id); const text = String(message || '').trim(); if (!text) return { ok: false, kind: 'chat', message: '内容为空' }; const isCommand = text.startsWith('/'); const kind = isCommand ? 'command' : 'chat'; if (!entry.manager.canSendChat()) { if (entry.manager.state !== 'online' || !entry.manager.bot) { return { ok: false, kind, message: `${entry.connection.username} 当前未连接(状态:${entry.manager.state}),未发送` }; } return { ok: false, kind, message: `${entry.connection.username} 未完成登录验证,已拒绝发送(服务器要求验证时会自动放行,用于发送验证码)` }; } const ok = entry.manager.sendChat(text); return { ok, kind, message: ok ? (isCommand ? '指令已发送:' + text : '消息已发送') : '发送失败' }; } /** * 手动移动测试:按住方向键若干秒,返回实际位移。 * @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); } // ==================== 状态快照 ==================== /** * @returns {Array} 所有机器人的状态快照(含连接、任务与位置) */ snapshot() { const bots = []; for (const entry of this.entries.values()) { const setting = this._settingOf(entry.id); bots.push(Object.assign({}, entry.manager.snapshot(), { enabled: setting.enabled, autoStart: setting.autoStart, source: entry.source, task: entry.controller.snapshot(), schedules: entry.messenger.snapshot() })); } return bots; } /** * @returns {Array} 所有机器人的位置(大地图打点用) */ positions() { const result = []; for (const entry of this.entries.values()) { const snap = entry.manager.snapshot(); result.push({ id: entry.id, label: entry.manager.label, username: entry.connection.username, state: snap.state, loggedIn: snap.loggedIn, position: snap.position, chunk: snap.chunk, stateName: entry.controller.stateName, taskState: entry.controller.state, completedClaims: entry.controller.completedClaims }); } return result; } _log(message, level) { if (this.logger) this.logger.log(message, level); } } /** * 构造一个 400 错误(由 Web 层转成对应 HTTP 状态码)。 * @param {string} message * @returns {Error} */ function badRequest(message) { const err = new Error(message); err.statusCode = 400; return err; } /** * 生成「为什么这条指令发不出去」的可操作说明。 * * 区分「没连上」与「连上了但没过登录验证」——两者的处理方式完全不同, * 笼统报一句「发送失败」会让用户无从下手。 * * @param {import('./botManager').BotManager} manager * @param {string} command 不带斜杠的指令 * @returns {string} */ function describeSendFailure(manager, command) { const name = manager.connection.username; if (manager.state !== 'online' || !manager.bot) { return `${name} 当前未连接(状态:${manager.state}),指令未发送;请先重连或等待自动重连`; } if (manager.loginFailed) { return `${name} 登录被服务器拒绝,请检查 .env 中的账号密码`; } return `${name} 未完成登录验证,已拒绝发送 /${command};` + '若是验证类指令(login / register / auth),可在配置的「允许免登录发送的指令前缀」中放行'; } module.exports = { BotPool, describeSendFailure, badRequest };