文件
BotStore/src/core/bigMapService.js
T
lyizai 51664c55b6 feat: 1.7.0 落地 TODO 全部待办(地面修正 / 手动执行器 / 自救 / 回城 / 租约 / 通知)
按 TODO.md 的优先级把待办全部落地为代码,详见 CHANGELOG 的 [1.7.0]。

- 「走不动」攻关:地面状态修正(syncGroundToPhysics / trustSetbackOnGround)、
  出站包类型分布、备选执行器 manualMover(只借 A* 规划、自己按键执行)
- 原点自救分两轮并统计成败;回城前评估「原地是否更差」并记录前后坐标
- 新增目标区块租约表(targetLease)避免多机器人撞同一块
- 定时消息支持 cron 与「期望回复」确认;新增人工介入 Webhook 通知(notifier)
- 大地图后台预热与视野裁剪;控制台日志按帧批量写入、配置面板支持搜索

顺带修掉两个影响部署的细节:
- 部署脚本(deploy.js / ssh-helper.js)改为也能从 .env 读 SSH 凭据 ——
  在命令行传密码会留在 shell 历史与进程列表里,写进已被忽略的 .env 更安全;
  显式环境变量仍然优先。.env.example 补上占位符并说明这些只在本机用。
- package.json / package-lock.json 版本对齐到 1.7.0(此前停留在 1.1.0,
  与 CHANGELOG 的语义化版本声明脱节)。

测试:368 → 436 个用例,`npm test` 与 `npm run check` 全绿。
2026-09-28 22:46:39 +08:00

515 行
19 KiB
JavaScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
'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;
/**
* 后台预热状态(机器人移动到新区域后顺带拉取周边高层级瓦片)。
*
* `lastCenterX/lastCenterZ` 用来判断「机器人是否移动够远」,
* `lastAt` 用来兜底「很久没预热就再预热一次」。
*/
this.prewarm = {
running: false,
lastAt: 0,
lastCenterX: null,
lastCenterZ: null,
/** 最近一次预热覆盖的层级范围(展示用) */
minZoom: 0,
maxZoom: 0,
total: 0,
done: 0,
failed: 0,
finishedAt: 0
};
this._prewarmAbort = 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));
}
/**
* 计算覆盖以 `center` 为中心、半径 `rangeBlocks` 的方形区域所需的瓦片下标范围(闭区间)。
*
* 默认中心是原点(全世界),但实际使用时通常只关心**机器人所在位置周边**的图幅 ——
* 全世界层级 0 是 100 张瓦片,而 1024 格半径只需 1 张、4096 格半径只需 4 张,
* 爬取和传输的开销差一个数量级。
*
* @param {number} zoom 层级
* @param {number} [rangeBlocks] 覆盖半径(方块),缺省取配置
* @param {number} [centerBlockX] 中心方块 X,缺省 0
* @param {number} [centerBlockZ] 中心方块 Z,缺省 0
* @returns {{minX: number, maxX: number, minZ: number, maxZ: number, count: number,
* centerBlockX: number, centerBlockZ: number, rangeBlocks: number}}
*/
tileRange(zoom, rangeBlocks, centerBlockX, centerBlockZ) {
const radius = Number.isFinite(rangeBlocks)
? Math.abs(rangeBlocks)
: (this.config && this.config.bigMap ? this.config.bigMap.rangeBlocks : DEFAULT_RANGE_BLOCKS);
const centerX = Number.isFinite(centerBlockX) ? centerBlockX : 0;
const centerZ = Number.isFinite(centerBlockZ) ? centerBlockZ : 0;
const perTile = this.blocksPerTile(zoom);
const minX = Math.floor((centerX - radius) / perTile);
const maxX = Math.ceil((centerX + radius) / perTile) - 1;
const minZ = Math.floor((centerZ - radius) / perTile);
const maxZ = Math.ceil((centerZ + radius) / perTile) - 1;
return {
minX,
maxX,
minZ,
maxZ,
count: (maxX - minX + 1) * (maxZ - minZ + 1),
centerBlockX: centerX,
centerBlockZ: centerZ,
rangeBlocks: radius
};
}
/**
* 瓦片覆盖的方块区间(闭开区间 `[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, centerBlockX?: number, centerBlockZ?: number, rangeBlocks?: number}} [options]
* @returns {Promise<object>} 爬取结果统计
*/
async crawlAll(zoom, options) {
if (this.crawl.running) return this.snapshot().crawl;
const opts = options || {};
const z = clampZoom(Number.isFinite(zoom) ? zoom : (this.config.bigMap.zoom || 0), this.maxZoom);
const range = this.tileRange(z, opts.rangeBlocks, opts.centerBlockX, opts.centerBlockZ);
this._crawlAbort = false;
this.crawl = {
running: true,
zoom: z,
total: range.count,
done: 0,
failed: 0,
startedAt: Date.now(),
finishedAt: 0,
lastError: '',
centerBlockX: range.centerBlockX,
centerBlockZ: range.centerBlockZ,
rangeBlocks: range.rangeBlocks
};
if (this.logger) {
this.logger.log(`开始爬取大地图瓦片:层级 ${z},中心 ${range.centerBlockX},${range.centerBlockZ},`
+ `半径 ${range.rangeBlocks} 格,瓦片 ${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;
}
// ==================== 后台预热 ====================
/**
* 后台低优先级预热:机器人移动到新区域后,顺带把周边的高层级瓦片拉下来。
*
* 只有「启动预爬取层级」(默认 0,全世界 10×10 张)会批量爬,层级 1~3 原本
* 要靠用户缩放时按需拉取 —— 首次浏览某个区域要等好几秒。预热就是用空闲带宽
* 把「以机器人为中心、半径 prewarmRangeBlocks」的较高层级补下来。
*
* 节流规则(避免频繁骚扰源站):
* - 已有批量爬取或预热在途 → 直接跳过;
* - 机器人移动不足 `prewarmMinMoveBlocks` 且距上次预热不足 `prewarmIntervalMs` → 跳过;
* - 并发取批量爬取的一半(低优先级),已缓存的瓦片只会做一次 statSync。
*
* @param {number} centerBlockX
* @param {number} centerBlockZ
* @returns {Promise<object>} 预热结果(跳过关时返回 `{skipped: 原因}`)
*/
async prewarmAround(centerBlockX, centerBlockZ) {
const mapConfig = (this.config && this.config.bigMap) || {};
if (mapConfig.prewarm === false) return { skipped: 'disabled' };
if (!Number.isFinite(centerBlockX) || !Number.isFinite(centerBlockZ)) return { skipped: 'no-position' };
if (this.crawl.running || this.prewarm.running) return { skipped: 'busy' };
const now = Date.now();
const minMove = Number.isFinite(mapConfig.prewarmMinMoveBlocks) ? mapConfig.prewarmMinMoveBlocks : 512;
const interval = Number.isFinite(mapConfig.prewarmIntervalMs) ? mapConfig.prewarmIntervalMs : 600000;
const moved = this.prewarm.lastCenterX === null
? Infinity
: Math.hypot(centerBlockX - this.prewarm.lastCenterX, centerBlockZ - this.prewarm.lastCenterZ);
if (moved < minMove && now - this.prewarm.lastAt < interval) return { skipped: 'recent' };
const baseZoom = clampZoom(mapConfig.zoom || 0, this.maxZoom);
const levels = Number.isFinite(mapConfig.prewarmLevels) ? Math.max(0, mapConfig.prewarmLevels) : 3;
const lastZoom = Math.min(this.maxZoom, baseZoom + levels);
if (lastZoom <= baseZoom) return { skipped: 'no-levels' };
const radius = Number.isFinite(mapConfig.prewarmRangeBlocks) ? mapConfig.prewarmRangeBlocks : 1024;
const batchConcurrency = Math.max(1, Math.min(16, mapConfig.concurrency || 4));
const concurrency = Math.max(1, Math.floor(batchConcurrency / 2));
this.prewarm.running = true;
this.prewarm.lastAt = now;
this.prewarm.lastCenterX = centerBlockX;
this.prewarm.lastCenterZ = centerBlockZ;
this.prewarm.minZoom = baseZoom + 1;
this.prewarm.maxZoom = lastZoom;
this._prewarmAbort = false;
let done = 0;
let failed = 0;
let total = 0;
const startedAt = Date.now();
try {
for (let z = baseZoom + 1; z <= lastZoom; z++) {
const range = this.tileRange(z, radius, centerBlockX, centerBlockZ);
total += 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]);
}
let cursor = 0;
const worker = async () => {
while (cursor < tasks.length && !this._prewarmAbort) {
const task = tasks[cursor++];
if (await this._fetchForCrawl(z, task[0], task[1], false)) done++;
else failed++;
}
};
const workers = [];
for (let i = 0; i < concurrency; i++) workers.push(worker());
await Promise.all(workers);
}
} finally {
this.prewarm.running = false;
this.prewarm.total = total;
this.prewarm.done = done;
this.prewarm.failed = failed;
this.prewarm.finishedAt = Date.now();
}
if (this.logger && total > 0) {
const seconds = ((this.prewarm.finishedAt - startedAt) / 1000).toFixed(1);
this.logger.log(`大地图预热完成:层级 ${baseZoom + 1}~${lastZoom} 周边 ${total} 张,`
+ `成功 ${done} / 失败 ${failed},用时 ${seconds} 秒`);
}
return Object.assign({}, this.prewarm);
}
/** 中断进行中的预热 */
abortPrewarm() {
this._prewarmAbort = true;
}
// ==================== 状态 ====================
/**
* @param {{centerBlockX?: number, centerBlockZ?: number, rangeBlocks?: number}} [options]
* @returns {object} 大地图状态快照(供控制台与前端渲染使用)
*/
snapshot(options) {
const opts = options || {};
const zoom = this.config && this.config.bigMap ? this.config.bigMap.zoom : 0;
const centerX = Number.isFinite(opts.centerBlockX) ? opts.centerBlockX : 0;
const centerZ = Number.isFinite(opts.centerBlockZ) ? opts.centerBlockZ : 0;
const range = this.tileRange(zoom, opts.rangeBlocks, centerX, centerZ);
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: range.rangeBlocks,
followBot: !!(this.config && this.config.bigMap && this.config.bigMap.followBot),
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),
/** 后台预热状态:最近一次覆盖的层级、成功 / 失败数与上次位置 */
prewarm: Object.assign({}, this.prewarm)
};
}
/**
* 统计某层级已缓存的瓦片数量。
* @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
};