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` 全绿。
这个提交包含在:
+114
-1
@@ -66,6 +66,26 @@ class BigMapService {
|
||||
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;
|
||||
}
|
||||
|
||||
// ==================== 坐标系 ====================
|
||||
@@ -307,6 +327,97 @@ class BigMapService {
|
||||
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;
|
||||
}
|
||||
|
||||
// ==================== 状态 ====================
|
||||
|
||||
/**
|
||||
@@ -335,7 +446,9 @@ class BigMapService {
|
||||
tileUrlTemplate: `${this.baseUrl}/tiles/${this.world}/{z}/{x}_{y}.png`,
|
||||
markersUrl: `${this.baseUrl}/tiles/${this.world}/markers.json`
|
||||
},
|
||||
crawl: Object.assign({}, this.crawl)
|
||||
crawl: Object.assign({}, this.crawl),
|
||||
/** 后台预热状态:最近一次覆盖的层级、成功 / 失败数与上次位置 */
|
||||
prewarm: Object.assign({}, this.prewarm)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户