feat: 支持多机器人挂机、开拓日志、公屏完整日志与大地图
多机器人(src/bot/botPool.js、src/config/botsConfig.js) - .env 用 MC_BOTS 一次配置多个账号(JSON 数组或 用户名:密码 逗号分隔),未配置时回退单机器人 - 每个机器人独立连接 / 登录 / 扩地状态机,共享一份领土数据服务与运行日志 - 密码只从 .env 读取;data/bots.json 只存启用状态、备注名、自动开始与各自的配置覆盖 - 每个机器人可覆盖全局配置(策略 / 目标邦国 / 目标数量等),未覆盖字段回退全局默认 开拓日志(src/core/expansionLog.js) - 逐条记录开拓玩家、邦国、区块、方块坐标、时间与判定来源,追加写入 data/expansion-log.jsonl - 支持按机器人 / 邦国 / 时间筛选、统计、导出带 BOM 的 CSV,超过 16MB 自动轮转 游戏公屏完整日志与聊天框(src/core/chatLog.js) - 每个机器人的全部公屏 / 系统 / 动作栏消息按机器人分文件留档,自身发出的聊天与指令同样留档 - 控制台新增公屏聊天页签与消息发送框 登录验证门禁 - 游戏侧:未完成登录验证时拒绝发送指令 / 聊天,isReady() 也不放行扩地任务; 新增 server.loginConfirmTimeoutMs 兜底,避免服务器不回提示导致永远卡在未登录 - 控制台侧:新增 WEB_PASSWORD 与 /api/login、/api/session,页面登录框验证通过才允许操作 大地图(src/core/bigMapService.js) - 爬取服务器地图站点(squaremap)的瓦片图片与领土标记,一张图上看全部机器人坐标 - 坐标系对源站实测标定并用 27 个王城交叉验证;瓦片磁盘缓存,层级 0 启动预爬取、更高层级按需穿透 - 领土按 markers.json 原始多边形绘制(预编译 Path2D,even-odd 保留孔洞),叠加王城与机器人标签 配套改造 - TerritoryModel 新增 flyingChunksOf / ownChunksOf,邦国归属改为按名称显式传入, 不再依赖全局 selfStateName(多机器人会互相覆盖) - Web 接口以 /api/bots/:id/... 为主,原单机器人接口保留为默认机器人的等价别名 - 新增 scripts/check-web-ids.js 校验前端元素引用一致性 测试从 154 增至 242,全部通过。
这个提交包含在:
+194
-18
@@ -9,12 +9,15 @@ 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;
|
||||
|
||||
/**
|
||||
* 机器人管理器:负责连接、登录、断线重连,并把底层 mineflayer 事件
|
||||
@@ -22,6 +25,9 @@ const ALREADY_LOGGED_IN_PATTERN = /已登录|无需登录|你已经登录/;
|
||||
*
|
||||
* 同时在这里装配 Grim 合规层、寻路服务、自动进食、自动用船、耐久保护等子模块,
|
||||
* 使它们与 bot 实例的生命周期保持一致。
|
||||
*
|
||||
* 一个 BotManager 实例对应**一个**游戏账号;同时挂多个玩家时由
|
||||
* {@link module:bot/botPool.BotPool} 创建并持有多个实例。
|
||||
*/
|
||||
class BotManager extends EventEmitter {
|
||||
/**
|
||||
@@ -30,16 +36,26 @@ class BotManager extends EventEmitter {
|
||||
* @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) {
|
||||
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;
|
||||
@@ -58,6 +74,10 @@ class BotManager extends EventEmitter {
|
||||
this.state = 'idle';
|
||||
/** 是否已完成登录(离线服密码登录) */
|
||||
this.loggedIn = false;
|
||||
/** 登录 / 注册是否被服务器明确拒绝 */
|
||||
this.loginFailed = false;
|
||||
/** 登录确认兜底定时器(部分服务器不回成功提示) */
|
||||
this._loginConfirmTimer = null;
|
||||
/** 断线重连计数 */
|
||||
this.reconnectAttempts = 0;
|
||||
/** 是否由用户主动断开(主动断开不重连) */
|
||||
@@ -66,6 +86,8 @@ class BotManager extends EventEmitter {
|
||||
this._lastCommandAt = 0;
|
||||
/** 待发送的指令队列(保证最小间隔) */
|
||||
this._commandQueue = [];
|
||||
/** 待发送的聊天队列(与指令共用同一个节流器,避免刷屏) */
|
||||
this._chatQueue = [];
|
||||
this._commandTimer = null;
|
||||
/** 最近一次收到过的聊天消息(供 Web 控制台展示) */
|
||||
this.lastMessages = [];
|
||||
@@ -108,7 +130,9 @@ class BotManager extends EventEmitter {
|
||||
clearTimeout(this._reconnectTimer);
|
||||
this._reconnectTimer = null;
|
||||
}
|
||||
this._clearLoginConfirmTimer();
|
||||
this._commandQueue.length = 0;
|
||||
this._chatQueue.length = 0;
|
||||
if (this.bot) {
|
||||
try {
|
||||
this.bot.quit('AutoStore 主动断开');
|
||||
@@ -121,7 +145,8 @@ class BotManager extends EventEmitter {
|
||||
|
||||
/** @returns {boolean} 是否已进入世界并完成登录,可以执行任务 */
|
||||
isReady() {
|
||||
return this.state === 'online' && !!this.bot && !!this.bot.entity && !!this.bot.pathfinder;
|
||||
return this.state === 'online' && !!this.bot && !!this.bot.entity
|
||||
&& !!this.bot.pathfinder && this.loggedIn;
|
||||
}
|
||||
|
||||
/** @returns {boolean} 是否已进入世界(未强制要求登录完成) */
|
||||
@@ -162,6 +187,7 @@ class BotManager extends EventEmitter {
|
||||
bot.on('end', (reason) => {
|
||||
this.state = 'offline';
|
||||
this.loggedIn = false;
|
||||
this._clearLoginConfirmTimer();
|
||||
this._log('连接已断开:' + (reason || '未知原因'));
|
||||
this.emit('end', reason);
|
||||
this._scheduleReconnect();
|
||||
@@ -184,11 +210,14 @@ class BotManager extends EventEmitter {
|
||||
});
|
||||
|
||||
// ===== 聊天 / 字幕 / 动作栏:占领判定与登录提示的数据源 =====
|
||||
bot.on('messagestr', (message) => {
|
||||
// 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);
|
||||
this.emit('chat', message, position);
|
||||
});
|
||||
|
||||
bot.on('title', (text, type) => {
|
||||
@@ -254,55 +283,137 @@ class BotManager extends EventEmitter {
|
||||
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));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从聊天消息里识别服务器的登录 / 注册提示 */
|
||||
_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 (!password) return;
|
||||
if (LOGIN_OK_PATTERN.test(message)) {
|
||||
if (!this.loggedIn) this._log('登录成功');
|
||||
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));
|
||||
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));
|
||||
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}} [options]
|
||||
* @param {{immediate?: boolean, internal?: boolean}} [options] internal 为登录 / 注册流程自身使用,绕过门禁
|
||||
* @returns {boolean} 是否已入队
|
||||
*/
|
||||
sendCommand(command, options) {
|
||||
if (!command) return;
|
||||
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;
|
||||
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() {
|
||||
@@ -311,10 +422,14 @@ class BotManager extends EventEmitter {
|
||||
const wait = Math.max(0, this.config.server.commandCooldownMs - (now - this._lastCommandAt));
|
||||
this._commandTimer = setTimeout(() => {
|
||||
this._commandTimer = null;
|
||||
const next = this._commandQueue.shift();
|
||||
if (next === undefined) return;
|
||||
this._writeCommand(next);
|
||||
if (this._commandQueue.length > 0) this._drainCommandQueue();
|
||||
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();
|
||||
}
|
||||
@@ -324,6 +439,8 @@ class BotManager extends EventEmitter {
|
||||
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) {
|
||||
@@ -331,13 +448,30 @@ class BotManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/** 发送普通聊天内容(不带斜杠) */
|
||||
_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,
|
||||
@@ -366,9 +500,51 @@ class BotManager extends EventEmitter {
|
||||
if (this.lastMessages.length > 200) this.lastMessages.shift();
|
||||
}
|
||||
|
||||
_log(message, level) {
|
||||
if (this.logger) this.logger.log(message, level);
|
||||
/**
|
||||
* 把收到的消息写入游戏公屏完整日志。
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,4 +557,4 @@ function fillTemplate(template, password) {
|
||||
return String(template).replace(/\{password\}/g, password);
|
||||
}
|
||||
|
||||
module.exports = { BotManager, fillTemplate };
|
||||
module.exports = { BotManager, fillTemplate, kindOfPosition };
|
||||
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
'use strict';
|
||||
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
const { BotManager } = require('./botManager');
|
||||
const { ExpansionController } = require('../controller/expansionController');
|
||||
const { normalizeConfig } = require('../config/expansionConfig');
|
||||
|
||||
/**
|
||||
* 机器人池:同时挂多个玩家(机器人)。
|
||||
*
|
||||
* 每个机器人由一对 `BotManager + ExpansionController` 组成,各自独立连接、独立登录、
|
||||
* 独立跑扩地状态机;但**共享**同一份领土数据服务(markers.json 是全局的,
|
||||
* 没必要每个机器人各拉一遍)与同一份运行日志。
|
||||
*
|
||||
* 每个机器人可以有自己的一套扩地配置覆盖(存在 `data/bots.json`),
|
||||
* 未覆盖的字段回退到全局配置。
|
||||
*/
|
||||
class BotPool extends EventEmitter {
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {Array<object>} 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] 机器人设置持久化
|
||||
*/
|
||||
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;
|
||||
|
||||
/** @type {Map<string, object>} 机器人 id → 运行时条目 */
|
||||
this.entries = new Map();
|
||||
/** 已执行过「启动后自动开始扩地」的机器人(避免重连后把手动停止的任务又拉起来) */
|
||||
this._autoStarted = new Set();
|
||||
/** 机器人设置(非敏感部分) */
|
||||
this.settings = this.botStore ? this.botStore.load().bots : {};
|
||||
/** 启动时间戳 */
|
||||
this.startedAt = 0;
|
||||
|
||||
for (const connection of this.connections) {
|
||||
this._addBot(connection);
|
||||
}
|
||||
this.defaultId = this.entries.size > 0 ? this.entries.keys().next().value : '';
|
||||
}
|
||||
|
||||
// ==================== 组装 ====================
|
||||
|
||||
/**
|
||||
* 创建一个机器人条目:管理器 + 控制器,并接好事件。
|
||||
* @param {object} connection
|
||||
*/
|
||||
_addBot(connection) {
|
||||
const id = connection.id || connection.username;
|
||||
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));
|
||||
|
||||
this.entries.set(id, {
|
||||
id,
|
||||
connection,
|
||||
manager,
|
||||
controller,
|
||||
/** 该机器人实际生效的配置(全局 + 覆盖) */
|
||||
config: manager.config
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
/** 连接所有已启用的机器人 */
|
||||
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.manager.connect();
|
||||
}
|
||||
}
|
||||
|
||||
/** 断开所有机器人 */
|
||||
stop() {
|
||||
for (const entry of this.entries.values()) {
|
||||
try {
|
||||
entry.controller.stop();
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
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<string>} 全部机器人 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 || '',
|
||||
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;
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
_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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 向某机器人发送游戏指令。
|
||||
* @param {string} id
|
||||
* @param {string} command 不带斜杠的指令
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
sendCommand(id, command) {
|
||||
const entry = this.require(id);
|
||||
if (!entry.manager.canOperate()) {
|
||||
return { ok: false, message: `${entry.connection.username} 未完成登录验证,已拒绝发送指令` };
|
||||
}
|
||||
const ok = entry.manager.sendCommand(command.replace(/^\//, ''));
|
||||
return { ok, message: ok ? '已发送:' + command : '发送失败' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 向某机器人发送公屏聊天消息。
|
||||
* @param {string} id
|
||||
* @param {string} message 聊天正文(以 `/` 开头时按指令发送)
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
sendChat(id, message) {
|
||||
const entry = this.require(id);
|
||||
if (!entry.manager.canOperate()) {
|
||||
return { ok: false, message: `${entry.connection.username} 未完成登录验证,已拒绝发送消息` };
|
||||
}
|
||||
const text = String(message || '').trim();
|
||||
if (!text) return { ok: false, message: '消息为空' };
|
||||
const ok = entry.manager.sendChat(text);
|
||||
return { ok, message: ok ? '已发送' : '发送失败' };
|
||||
}
|
||||
|
||||
// ==================== 状态快照 ====================
|
||||
|
||||
/**
|
||||
* @returns {Array<object>} 所有机器人的状态快照(含连接、任务与位置)
|
||||
*/
|
||||
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,
|
||||
task: entry.controller.snapshot()
|
||||
}));
|
||||
}
|
||||
return bots;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Array<object>} 所有机器人的位置(大地图打点用)
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { BotPool };
|
||||
在新工单中引用
屏蔽一个用户