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,全部通过。
这个提交包含在:
lyizai
2026-09-27 21:22:57 +08:00
父节点 223cf719b6
当前提交 39624da2d5
共修改 30 个文件,包含 5480 行新增和 362 行删除
+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 };