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,371 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* 大地图瓦片服务:爬取 squaremap 站点(bgjq.simpfun.cn)的地图图片并本地缓存,
|
||||
* 供控制台在一张大地图上展示所有机器人的坐标与领土标记。
|
||||
*
|
||||
* ## 坐标系(已对 source 站点实测标定)
|
||||
*
|
||||
* squaremap 前端用 `L.CRS.Simple` + `tileSize: 512`,瓦片路径为
|
||||
* `tiles/<world>/<z>/<x>_<y>.png`,并且:
|
||||
*
|
||||
* - 瓦片图片固定 512×512 像素;
|
||||
* - 最大原生层级 `zoom.max`(本站为 3),在该层级 1 方块 = 1 像素;
|
||||
* - 因此层级 z 的每张瓦片覆盖 `512 * 2^(maxZoom - z)` 个方块,
|
||||
* 瓦片 (x, y) 覆盖方块区间 `[x*B, (x+1)*B) × [y*B, (y+1)*B)`(B 即上式)。
|
||||
*
|
||||
* 本站实测:王城「现代大陆」(10722, 1547) 落在 z=3 的瓦片 (20, 3) 内,
|
||||
* 与上述换算一致(20*512=10240 ≤ 10722 < 10752,3*512=1536 ≤ 1547 < 2048)。
|
||||
*
|
||||
* ## 缓存策略
|
||||
*
|
||||
* - 磁盘缓存 `data/mapcache/<world>/<z>/<x>_<y>.png`,命中即直接返回,不再回源;
|
||||
* - 低层级(默认 z=0,全世界 10×10=100 张)在启动时批量爬取,保证大地图秒开;
|
||||
* - 高层级按需穿透拉取(浏览器缩放时才请求),拉过就永久缓存。
|
||||
*/
|
||||
|
||||
/** 瓦片图片边长(像素) */
|
||||
const TILE_PIXELS = 512;
|
||||
/** 本站 squaremap 的最大原生层级(settings.json 的 zoom.max) */
|
||||
const DEFAULT_MAX_ZOOM = 3;
|
||||
/** 默认覆盖半径(方块):与服务器世界边界 ±20480 一致 */
|
||||
const DEFAULT_RANGE_BLOCKS = 20480;
|
||||
/** 单张瓦片下载超时(毫秒) */
|
||||
const TILE_TIMEOUT_MS = 20000;
|
||||
|
||||
class BigMapService {
|
||||
/**
|
||||
* @param {object} config 完整配置对象
|
||||
* @param {import('../util/logger').Logger} [logger]
|
||||
* @param {{baseUrl?: string, world?: string, cacheDir?: string, fetchImpl?: Function}} [options]
|
||||
*/
|
||||
constructor(config, logger, options) {
|
||||
const opts = options || {};
|
||||
this.config = config;
|
||||
this.logger = logger || null;
|
||||
this.baseUrl = String(opts.baseUrl || process.env.DYNMAP_BASE || 'http://bgjq.simpfun.cn').replace(/\/+$/, '');
|
||||
this.world = opts.world || (config && config.bigMap && config.bigMap.world) || process.env.DYNMAP_WORLD || 'minecraft_overworld';
|
||||
this.cacheDir = opts.cacheDir || path.join(__dirname, '..', '..', 'data', 'mapcache');
|
||||
this.fetchImpl = opts.fetchImpl || globalThis.fetch;
|
||||
|
||||
/** @type {Map<string, Buffer>} 内存 LRU(瓦片体积小,按层级分目录缓存到磁盘为主) */
|
||||
this._memory = new Map();
|
||||
this._memoryLimit = 256;
|
||||
/** 爬取进度 */
|
||||
this.crawl = {
|
||||
running: false,
|
||||
zoom: 0,
|
||||
total: 0,
|
||||
done: 0,
|
||||
failed: 0,
|
||||
startedAt: 0,
|
||||
finishedAt: 0,
|
||||
lastError: ''
|
||||
};
|
||||
this._crawlAbort = false;
|
||||
}
|
||||
|
||||
// ==================== 坐标系 ====================
|
||||
|
||||
/** @returns {number} 当前允许浏览的最大层级 */
|
||||
get maxZoom() {
|
||||
const configured = this.config && this.config.bigMap ? this.config.bigMap.maxZoom : undefined;
|
||||
return Number.isFinite(configured) ? configured : DEFAULT_MAX_ZOOM;
|
||||
}
|
||||
|
||||
/**
|
||||
* 层级 z 下每张瓦片覆盖的方块数。
|
||||
* @param {number} zoom
|
||||
* @returns {number}
|
||||
*/
|
||||
blocksPerTile(zoom) {
|
||||
return TILE_PIXELS * Math.pow(2, this.maxZoom - clampZoom(zoom, this.maxZoom));
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算覆盖 `[-rangeBlocks, rangeBlocks)` 区间所需的瓦片下标范围(闭区间)。
|
||||
*
|
||||
* @param {number} zoom 层级
|
||||
* @param {number} [rangeBlocks] 覆盖半径(方块),缺省取配置
|
||||
* @returns {{minX: number, maxX: number, minZ: number, maxZ: number, count: number}}
|
||||
*/
|
||||
tileRange(zoom, rangeBlocks) {
|
||||
const radius = Number.isFinite(rangeBlocks)
|
||||
? Math.abs(rangeBlocks)
|
||||
: (this.config && this.config.bigMap ? this.config.bigMap.rangeBlocks : DEFAULT_RANGE_BLOCKS);
|
||||
const perTile = this.blocksPerTile(zoom);
|
||||
const minX = Math.floor(-radius / perTile);
|
||||
const maxX = Math.ceil(radius / perTile) - 1;
|
||||
const minZ = minX;
|
||||
const maxZ = maxX;
|
||||
return { minX, maxX, minZ, maxZ, count: (maxX - minX + 1) * (maxZ - minZ + 1) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 瓦片覆盖的方块区间(闭开区间 `[min, max)`)。
|
||||
* @param {number} zoom
|
||||
* @param {number} tileX
|
||||
* @param {number} tileZ
|
||||
* @returns {{minBlockX: number, maxBlockX: number, minBlockZ: number, maxBlockZ: number}}
|
||||
*/
|
||||
tileBlockBounds(zoom, tileX, tileZ) {
|
||||
const perTile = this.blocksPerTile(zoom);
|
||||
return {
|
||||
minBlockX: tileX * perTile,
|
||||
maxBlockX: (tileX + 1) * perTile,
|
||||
minBlockZ: tileZ * perTile,
|
||||
maxBlockZ: (tileZ + 1) * perTile
|
||||
};
|
||||
}
|
||||
|
||||
/** @returns {string} 某个瓦片的回源地址 */
|
||||
tileUrl(zoom, tileX, tileZ) {
|
||||
return `${this.baseUrl}/tiles/${encodeURIComponent(this.world)}/${clampZoom(zoom, this.maxZoom)}/${tileX}_${tileZ}.png`;
|
||||
}
|
||||
|
||||
/** @returns {string} 某个瓦片的磁盘缓存路径 */
|
||||
tilePath(zoom, tileX, tileZ) {
|
||||
return path.join(this.cacheDir, sanitize(this.world), String(clampZoom(zoom, this.maxZoom)), `${tileX}_${tileZ}.png`);
|
||||
}
|
||||
|
||||
// ==================== 瓦片读取 ====================
|
||||
|
||||
/**
|
||||
* 读取一张瓦片:内存 → 磁盘 → 回源。
|
||||
*
|
||||
* @param {number} zoom
|
||||
* @param {number} tileX
|
||||
* @param {number} tileZ
|
||||
* @returns {Promise<Buffer|null>} 瓦片 PNG 数据;失败返回 null
|
||||
*/
|
||||
async getTile(zoom, tileX, tileZ) {
|
||||
const z = clampZoom(zoom, this.maxZoom);
|
||||
const key = `${z}/${tileX}_${tileZ}`;
|
||||
const cached = this._memory.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const file = this.tilePath(z, tileX, tileZ);
|
||||
try {
|
||||
const data = fs.readFileSync(file);
|
||||
if (data.length > 0) {
|
||||
this._remember(key, data);
|
||||
return data;
|
||||
}
|
||||
} catch {
|
||||
// 未缓存,继续回源
|
||||
}
|
||||
return this._download(z, tileX, tileZ);
|
||||
}
|
||||
|
||||
/** 从源站下载并落盘;失败返回 null(控制台会画占位色块) */
|
||||
async _download(zoom, tileX, tileZ) {
|
||||
const url = this.tileUrl(zoom, tileX, tileZ);
|
||||
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
const timer = controller ? setTimeout(() => controller.abort(), TILE_TIMEOUT_MS) : null;
|
||||
try {
|
||||
const response = await this.fetchImpl(url, {
|
||||
signal: controller ? controller.signal : undefined,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 autostore-node/1.0',
|
||||
Accept: 'image/png,image/*,*/*'
|
||||
}
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.length === 0) return null;
|
||||
this._remember(`${zoom}/${tileX}_${tileZ}`, buffer);
|
||||
this._writeCache(zoom, tileX, tileZ, buffer);
|
||||
return buffer;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** 落盘缓存(失败静默:缓存只是加速手段,不影响功能) */
|
||||
_writeCache(zoom, tileX, tileZ, buffer) {
|
||||
try {
|
||||
const file = this.tilePath(zoom, tileX, tileZ);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, buffer);
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** 写入内存 LRU(简单实现:超限时丢掉最早插入的若干项) */
|
||||
_remember(key, buffer) {
|
||||
this._memory.set(key, buffer);
|
||||
while (this._memory.size > this._memoryLimit) {
|
||||
const oldest = this._memory.keys().next();
|
||||
if (oldest.done) break;
|
||||
this._memory.delete(oldest.value);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 批量爬取 ====================
|
||||
|
||||
/**
|
||||
* 批量爬取某一层级覆盖范围内的全部瓦片(已缓存则跳过)。
|
||||
*
|
||||
* 重复调用时若已有爬取在途则直接返回当前进度,不会并发起多轮。
|
||||
*
|
||||
* @param {number} [zoom] 层级,缺省取配置里的 bigMap.zoom
|
||||
* @param {{force?: boolean}} [options] force=true 时忽略磁盘缓存重新下载
|
||||
* @returns {Promise<object>} 爬取结果统计
|
||||
*/
|
||||
async crawlAll(zoom, options) {
|
||||
if (this.crawl.running) return this.snapshot().crawl;
|
||||
const z = clampZoom(Number.isFinite(zoom) ? zoom : (this.config.bigMap.zoom || 0), this.maxZoom);
|
||||
const opts = options || {};
|
||||
const range = this.tileRange(z);
|
||||
this._crawlAbort = false;
|
||||
this.crawl = {
|
||||
running: true,
|
||||
zoom: z,
|
||||
total: range.count,
|
||||
done: 0,
|
||||
failed: 0,
|
||||
startedAt: Date.now(),
|
||||
finishedAt: 0,
|
||||
lastError: ''
|
||||
};
|
||||
if (this.logger) {
|
||||
this.logger.log(`开始爬取大地图瓦片:层级 ${z},范围 ${range.minX}..${range.maxX} × ${range.minZ}..${range.maxZ},共 ${range.count} 张`);
|
||||
}
|
||||
|
||||
const tasks = [];
|
||||
for (let x = range.minX; x <= range.maxX; x++) {
|
||||
for (let y = range.minZ; y <= range.maxZ; y++) tasks.push([x, y]);
|
||||
}
|
||||
const concurrency = Math.max(1, Math.min(16, (this.config.bigMap && this.config.bigMap.concurrency) || 4));
|
||||
let cursor = 0;
|
||||
const worker = async () => {
|
||||
while (cursor < tasks.length && !this._crawlAbort) {
|
||||
const task = tasks[cursor++];
|
||||
const buffer = await this._fetchForCrawl(z, task[0], task[1], opts.force === true);
|
||||
if (buffer) this.crawl.done++;
|
||||
else this.crawl.failed++;
|
||||
}
|
||||
};
|
||||
const workers = [];
|
||||
for (let i = 0; i < concurrency; i++) workers.push(worker());
|
||||
await Promise.all(workers);
|
||||
|
||||
this.crawl.running = false;
|
||||
this.crawl.finishedAt = Date.now();
|
||||
if (this.logger) {
|
||||
const seconds = ((this.crawl.finishedAt - this.crawl.startedAt) / 1000).toFixed(1);
|
||||
this.logger.log(`大地图瓦片爬取完成:成功 ${this.crawl.done} / 失败 ${this.crawl.failed},用时 ${seconds} 秒`, 'success');
|
||||
}
|
||||
return this.snapshot().crawl;
|
||||
}
|
||||
|
||||
/** 爬取单张瓦片:非强制模式下已缓存则直接计入成功 */
|
||||
async _fetchForCrawl(zoom, tileX, tileZ, force) {
|
||||
if (!force) {
|
||||
try {
|
||||
if (fs.statSync(this.tilePath(zoom, tileX, tileZ)).size > 0) return true;
|
||||
} catch {
|
||||
// 未缓存
|
||||
}
|
||||
}
|
||||
return (await this._download(zoom, tileX, tileZ)) !== null;
|
||||
}
|
||||
|
||||
/** 中断进行中的爬取 */
|
||||
abortCrawl() {
|
||||
this._crawlAbort = true;
|
||||
}
|
||||
|
||||
// ==================== 状态 ====================
|
||||
|
||||
/**
|
||||
* @returns {object} 大地图状态快照(供控制台与前端渲染使用)
|
||||
*/
|
||||
snapshot() {
|
||||
const zoom = this.config && this.config.bigMap ? this.config.bigMap.zoom : 0;
|
||||
const range = this.tileRange(zoom);
|
||||
return {
|
||||
enabled: !!(this.config && this.config.bigMap && this.config.bigMap.enabled),
|
||||
baseUrl: this.baseUrl,
|
||||
world: this.world,
|
||||
tilePixels: TILE_PIXELS,
|
||||
maxZoom: this.maxZoom,
|
||||
zoom,
|
||||
blocksPerTile: this.blocksPerTile(zoom),
|
||||
rangeBlocks: this.config && this.config.bigMap ? this.config.bigMap.rangeBlocks : DEFAULT_RANGE_BLOCKS,
|
||||
tiles: range,
|
||||
cached: this.countCached(zoom),
|
||||
source: {
|
||||
tileUrlTemplate: `${this.baseUrl}/tiles/${this.world}/{z}/{x}_{y}.png`,
|
||||
markersUrl: `${this.baseUrl}/tiles/${this.world}/markers.json`
|
||||
},
|
||||
crawl: Object.assign({}, this.crawl)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计某层级已缓存的瓦片数量。
|
||||
* @param {number} zoom
|
||||
* @returns {number}
|
||||
*/
|
||||
countCached(zoom) {
|
||||
const dir = path.join(this.cacheDir, sanitize(this.world), String(clampZoom(zoom, this.maxZoom)));
|
||||
try {
|
||||
return fs.readdirSync(dir).filter((name) => name.endsWith('.png')).length;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空磁盘与内存缓存。
|
||||
* @returns {number} 删除的瓦片数
|
||||
*/
|
||||
clearCache() {
|
||||
let removed = 0;
|
||||
const root = path.join(this.cacheDir, sanitize(this.world));
|
||||
try {
|
||||
removed = countFiles(root);
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
this._memory.clear();
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
/** 递归统计目录下的文件数 */
|
||||
function countFiles(dir) {
|
||||
let total = 0;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) total += countFiles(path.join(dir, entry.name));
|
||||
else total++;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** 世界名可能含路径分隔符,落盘前统一安全化 */
|
||||
function sanitize(name) {
|
||||
return String(name).replace(/[\\/:*?"<>|]/g, '_') || 'world';
|
||||
}
|
||||
|
||||
function clampZoom(zoom, max) {
|
||||
const n = Number(zoom);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
return Math.min(max, Math.max(0, Math.trunc(n)));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BigMapService,
|
||||
TILE_PIXELS,
|
||||
DEFAULT_MAX_ZOOM,
|
||||
DEFAULT_RANGE_BLOCKS,
|
||||
TILE_TIMEOUT_MS
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户