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 行删除
+371
查看文件
@@ -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
};