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,全部通过。
这个提交包含在:
@@ -0,0 +1,54 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* 机器人注册表持久化:保存「非敏感」的机器人设置。
|
||||
*
|
||||
* 存的是:是否启用、备注名、启动后是否自动开始扩地、以及每个机器人自己的扩地配置覆盖。
|
||||
* **绝不存账号密码** —— 密码只从 `.env` 读取(见 {@link module:config/botsConfig})。
|
||||
*
|
||||
* 默认路径 `data/bots.json`(`data/` 已在 .gitignore 中忽略)。
|
||||
*/
|
||||
class BotStore {
|
||||
/**
|
||||
* @param {string} [filePath] 配置文件绝对路径
|
||||
*/
|
||||
constructor(filePath) {
|
||||
this.filePath = filePath || path.join(__dirname, '..', '..', 'data', 'bots.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取注册表;文件不存在或损坏时返回空表。
|
||||
* @returns {{bots: Record<string, object>}}
|
||||
*/
|
||||
load() {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
|
||||
if (parsed && typeof parsed === 'object' && parsed.bots && typeof parsed.bots === 'object') {
|
||||
return { bots: parsed.bots };
|
||||
}
|
||||
} catch {
|
||||
// 首次运行或文件损坏:回退到空表
|
||||
}
|
||||
return { bots: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入注册表。
|
||||
* @param {{bots: Record<string, object>}} data
|
||||
* @returns {boolean} 是否写入成功
|
||||
*/
|
||||
save(data) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
||||
fs.writeFileSync(this.filePath, JSON.stringify({ bots: data.bots || {} }, null, 2), 'utf8');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { BotStore };
|
||||
@@ -0,0 +1,113 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 机器人凭据解析。
|
||||
*
|
||||
* 设计原则(与项目其它部分一致):**账号密码只从环境变量读取,绝不落代码、绝不落配置文件**。
|
||||
* 因此这里只负责把 `.env` 里的描述解析成内存中的连接参数列表,
|
||||
* 机器人列表本身(哪个启用、备注名、各自的扩地配置)才存到 `data/bots.json`。
|
||||
*
|
||||
* 支持的写法(按优先级):
|
||||
*
|
||||
* 1. JSON 数组(推荐,用户名/密码含逗号冒号时也安全)
|
||||
* ```
|
||||
* MC_BOTS=[{"username":"BotA","password":"p1"},{"username":"BotB","password":"p2"}]
|
||||
* ```
|
||||
* 2. `用户名:密码` 逗号分隔
|
||||
* ```
|
||||
* MC_BOTS=BotA:p1,BotB:p2
|
||||
* ```
|
||||
* 3. 单机器人兜底:`MC_USERNAME` + `MC_PASSWORD`
|
||||
*/
|
||||
|
||||
/**
|
||||
* 从环境变量解析机器人连接参数列表。
|
||||
*
|
||||
* @param {NodeJS.ProcessEnv|object} [env] 环境变量(默认取 process.env,便于测试注入)
|
||||
* @returns {Array<{id: string, username: string, password: string, auth: string, host: string, port: number, version: string, label: string}>}
|
||||
*/
|
||||
function parseBotsFromEnv(env) {
|
||||
const source = env || process.env;
|
||||
const defaults = {
|
||||
host: source.MC_HOST || 'bgjq.simpfun.cn',
|
||||
port: normalizePort(source.MC_PORT),
|
||||
version: source.MC_VERSION || '1.21.4',
|
||||
auth: source.MC_AUTH || 'offline'
|
||||
};
|
||||
|
||||
const raw = typeof source.MC_BOTS === 'string' ? source.MC_BOTS.trim() : '';
|
||||
let parsed = [];
|
||||
if (raw) {
|
||||
parsed = raw.startsWith('[') ? parseJsonList(raw) : parseCsvList(raw);
|
||||
}
|
||||
if (parsed.length === 0) {
|
||||
const username = (source.MC_USERNAME || '').trim();
|
||||
if (username) {
|
||||
parsed = [{ username, password: source.MC_PASSWORD || '' }];
|
||||
}
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const bots = [];
|
||||
for (const item of parsed) {
|
||||
const username = String(item.username || '').trim();
|
||||
if (!username) continue;
|
||||
const id = sanitizeId(username);
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
bots.push({
|
||||
id,
|
||||
username,
|
||||
password: item.password === undefined || item.password === null ? '' : String(item.password),
|
||||
auth: String(item.auth || defaults.auth),
|
||||
host: String(item.host || defaults.host),
|
||||
port: normalizePort(item.port === undefined ? defaults.port : item.port),
|
||||
version: String(item.version || defaults.version),
|
||||
label: String(item.label || username)
|
||||
});
|
||||
}
|
||||
return bots;
|
||||
}
|
||||
|
||||
/** 解析 JSON 数组写法;格式错误时返回空数组(由兜底逻辑接管) */
|
||||
function parseJsonList(raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((item) => item && typeof item === 'object');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析 `用户名:密码` 逗号分隔写法;没有冒号时视为「只有用户名」 */
|
||||
function parseCsvList(raw) {
|
||||
const result = [];
|
||||
for (const part of raw.split(',')) {
|
||||
const text = part.trim();
|
||||
if (!text) continue;
|
||||
const index = text.indexOf(':');
|
||||
if (index < 0) {
|
||||
result.push({ username: text, password: '' });
|
||||
} else {
|
||||
result.push({ username: text.slice(0, index).trim(), password: text.slice(index + 1).trim() });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成机器人 id:用于 URL、文件名与日志归属,必须稳定且不含特殊字符。
|
||||
* @param {string} username
|
||||
* @returns {string}
|
||||
*/
|
||||
function sanitizeId(username) {
|
||||
return String(username).trim().replace(/[^\w\u4e00-\u9fa5.-]/g, '_').slice(0, 48);
|
||||
}
|
||||
|
||||
function normalizePort(value) {
|
||||
const n = Number.parseInt(value, 10);
|
||||
return Number.isFinite(n) && n > 0 && n < 65536 ? n : 25565;
|
||||
}
|
||||
|
||||
module.exports = { parseBotsFromEnv, sanitizeId, normalizePort };
|
||||
@@ -173,6 +173,24 @@ function defaultConfig() {
|
||||
blockActionsWhileRiding: true
|
||||
},
|
||||
|
||||
// ==================== 大地图(新增) ====================
|
||||
bigMap: {
|
||||
/** 是否启用大地图(爬取服务器地图站点的瓦片图片与领土标记) */
|
||||
enabled: true,
|
||||
/** 地图站点上的世界目录名;留空则回退环境变量 DYNMAP_WORLD */
|
||||
world: '',
|
||||
/** 启动时预爬取的层级(0 = 全世界一张大图,本站为 10×10=100 张瓦片) */
|
||||
zoom: 0,
|
||||
/** 允许浏览的最大层级(本站 squaremap 的 zoom.max 为 3) */
|
||||
maxZoom: 3,
|
||||
/** 以原点为中心的覆盖半径(方块),默认与服务器世界边界 ±20480 一致 */
|
||||
rangeBlocks: 20480,
|
||||
/** 启动时是否自动预爬取 */
|
||||
crawlOnStart: true,
|
||||
/** 瓦片下载并发数(对源站友好,默认 4) */
|
||||
concurrency: 4
|
||||
},
|
||||
|
||||
// ==================== 服务器交互(新增) ====================
|
||||
server: {
|
||||
/** 启用开垦模式的指令(不带斜杠),留空表示服务器自动开启 */
|
||||
@@ -181,6 +199,12 @@ function defaultConfig() {
|
||||
loginCommand: 'login {password}',
|
||||
/** 注册指令模板,`{password}` 会被替换;留空则不发送 */
|
||||
registerCommand: '',
|
||||
/**
|
||||
* 登录结果确认超时(毫秒)。
|
||||
* 部分离线服登录成功后不回明确提示,超时后按「已登录」放行,
|
||||
* 否则机器人会永远卡在未登录状态而无法接受控制台操作;设为 0 表示禁用兜底。
|
||||
*/
|
||||
loginConfirmTimeoutMs: 10000,
|
||||
/** 聊天指令最小间隔(毫秒),防刷屏 */
|
||||
commandCooldownMs: 1200,
|
||||
/** 断线后是否自动重连 */
|
||||
@@ -277,8 +301,17 @@ function validateConfig(config) {
|
||||
config.grim.maxRotationPerTickDeg = clampNumber(config.grim.maxRotationPerTickDeg, 1, 180, 8.6);
|
||||
config.grim.attackReach = clampNumber(config.grim.attackReach, 1, 6, 3.0);
|
||||
|
||||
config.bigMap.zoom = clampInt(config.bigMap.zoom, 0, 8, 0);
|
||||
config.bigMap.maxZoom = clampInt(config.bigMap.maxZoom, 0, 8, 3);
|
||||
// 预爬取层级不可能超过可浏览的最大层级,否则会爬到源站不存在的瓦片
|
||||
if (config.bigMap.zoom > config.bigMap.maxZoom) config.bigMap.zoom = config.bigMap.maxZoom;
|
||||
config.bigMap.rangeBlocks = clampInt(config.bigMap.rangeBlocks, 256, 200000, 20480);
|
||||
config.bigMap.concurrency = clampInt(config.bigMap.concurrency, 1, 16, 4);
|
||||
if (typeof config.bigMap.world !== 'string') config.bigMap.world = '';
|
||||
|
||||
config.server.commandCooldownMs = clampInt(config.server.commandCooldownMs, 0, 60000, 1200);
|
||||
config.server.reconnectDelayMs = clampInt(config.server.reconnectDelayMs, 1000, 600000, 10000);
|
||||
config.server.loginConfirmTimeoutMs = clampInt(config.server.loginConfirmTimeoutMs, 0, 300000, 10000);
|
||||
|
||||
if (config.waterMode !== WATER_MODE.SWIM && config.waterMode !== WATER_MODE.BOAT) {
|
||||
config.waterMode = WATER_MODE.AVOID;
|
||||
|
||||
在新工单中引用
屏蔽一个用户