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` 全绿。
这个提交包含在:
@@ -44,6 +44,12 @@ const EXPORT_SUMMARY_KEYS = ['状态', '目标区块', '玩家区块', '队列',
|
||||
*/
|
||||
const PATH_PROGRESS_MIN_CHUNKS = 1;
|
||||
|
||||
/**
|
||||
* 候选区块全被其他机器人预定时,隔多久重新规划(毫秒)。
|
||||
* 取 15 秒:既不会忙等,也不会在别人刚释放时错过太久。
|
||||
*/
|
||||
const LEASE_RETRY_DELAY_MS = 15000;
|
||||
|
||||
/**
|
||||
* 自动扩地控制器:扩地任务的核心状态机。
|
||||
*
|
||||
@@ -62,6 +68,7 @@ class ExpansionController {
|
||||
* @param {string} [deps.botId] 所属机器人 id(多机器人场景,用于日志归属)
|
||||
* @param {string} [deps.botName] 所属机器人游戏名
|
||||
* @param {import('../core/expansionLog').ExpansionLog} [deps.expansionLog] 开拓日志
|
||||
* @param {import('../core/targetLease').TargetLeaseTable} [deps.targetLeases] 目标区块租约表(多机器人共享)
|
||||
*/
|
||||
constructor(deps) {
|
||||
this.botManager = deps.botManager;
|
||||
@@ -75,6 +82,14 @@ class ExpansionController {
|
||||
? deps.botManager.connection.username : this.botId);
|
||||
/** @type {import('../core/expansionLog').ExpansionLog|null} */
|
||||
this.expansionLog = deps.expansionLog || null;
|
||||
/**
|
||||
* 目标区块租约表(多机器人共享,可空 —— 单机器人场景不需要协调)。
|
||||
* 详见 core/targetLease.js:抢到的机器人独占区块,其余机器人跳过。
|
||||
* @type {import('../core/targetLease').TargetLeaseTable|null}
|
||||
*/
|
||||
this.targetLeases = deps.targetLeases || null;
|
||||
/** 候选区块全被其他机器人预定时,下一次重新规划的时间戳(避免忙等) */
|
||||
this._nextReplanAt = 0;
|
||||
this.planner = new ExpansionPlanner();
|
||||
/** @type {import('../core/subtitleChatMonitor').SubtitleChatMonitor} */
|
||||
this.monitor = new SubtitleChatMonitor({
|
||||
@@ -131,6 +146,24 @@ class ExpansionController {
|
||||
this.sideStepStartPos = null;
|
||||
/** 本次回城是否由耐久保护触发:RETURNING 结束后停止任务而非重新规划 */
|
||||
this.returnThenStop = false;
|
||||
/**
|
||||
* 最近一次回城的前后坐标与距离。
|
||||
*
|
||||
* 回城是破坏性操作(会把机器人拉回安全点、清掉玩家手动摆好的位置),
|
||||
* 用户必须能在控制台上一眼看出「这次回城把机器人从哪挪到了哪」,
|
||||
* 从而判断回城是否值得。
|
||||
* @type {{at: number, reason: string, from: object|null, to: object|null, distance: number|null}|null}
|
||||
*/
|
||||
this.lastReturn = null;
|
||||
/** 待回城时记录的「回城前坐标」(传送完成后与当前位置组成前后对比) */
|
||||
this.pendingReturn = null;
|
||||
/**
|
||||
* 因「原地还能走」而放弃回城的次数。
|
||||
*
|
||||
* 连续超时达到回城阈值并不代表卡死 —— 位置还能走时回城毫无意义,
|
||||
* 只会白白丢掉当前位置,因此这种情况下改为跳过目标并记入这里。
|
||||
*/
|
||||
this.returnSkippedNotStuck = 0;
|
||||
/** 自动攻击当前目标的实体 ID(仅用于避免重复记录战斗日志) */
|
||||
this.combatTargetEntityId = -1;
|
||||
/** 当前是否正持续按住跳跃键(自动游泳用) */
|
||||
@@ -286,6 +319,9 @@ class ExpansionController {
|
||||
if (this.sideStepping) this.stopSideStep();
|
||||
this.releaseJump();
|
||||
if (bm.boat) bm.boat.reset(true);
|
||||
// 停止任务时归还全部目标租约,别的机器人可以立刻接手这些区块
|
||||
if (this.targetLeases) this.targetLeases.releaseAll(this.botId);
|
||||
this._nextReplanAt = 0;
|
||||
this.setState(State.STOPPED, '已手动停止');
|
||||
this._log('已手动停止');
|
||||
}
|
||||
@@ -305,6 +341,8 @@ class ExpansionController {
|
||||
return;
|
||||
}
|
||||
this.playerChunk = this._playerChunk();
|
||||
// 续租当前目标:长距离寻路可能要走几分钟,不能让租约中途过期被别人抢走
|
||||
if (this.isRunning()) this._renewTargetLease();
|
||||
|
||||
// 等待开垦时段自动开始:进入时段后自动启动已登记的任务
|
||||
if (this.pendingStart && this.isWithinClaimTime()) {
|
||||
@@ -369,21 +407,39 @@ class ExpansionController {
|
||||
this.queue = [];
|
||||
const model = this.dataService.getModel();
|
||||
const planned = this.planner.generate(model, this.config, this.playerChunk);
|
||||
let leasedByOthers = 0;
|
||||
for (const chunk of planned) {
|
||||
if (!this._isClaimedByOthers(chunk)) this.queue.push(chunk);
|
||||
if (this._isClaimedByOthers(chunk)) continue;
|
||||
// 被其他机器人预定(租约未到期)的区块先不排队,等下轮规划再看
|
||||
if (this.targetLeases && this.targetLeases.isLeasedByOther(chunk, this.botId)) {
|
||||
leasedByOthers++;
|
||||
continue;
|
||||
}
|
||||
this.queue.push(chunk);
|
||||
}
|
||||
// 矩形区域扩地时不受扩圈上限限制(玩家圈选的矩形即为明确边界,需完整填充)
|
||||
const rectangleMode = this.config.strategy === 2 && this.config.regionMaxX >= this.config.regionMinX;
|
||||
if (this.config.maxRings > 0 && !rectangleMode) {
|
||||
this.queue = this.queue.filter((chunk) => this._ringDistance(chunk, model) <= this.config.maxRings);
|
||||
}
|
||||
this._log(`规划完成,共 ${this.queue.length} 个候选区块`);
|
||||
this._log(`规划完成,共 ${this.queue.length} 个候选区块`
|
||||
+ (leasedByOthers > 0 ? `(另有 ${leasedByOthers} 个被其他机器人预定)` : ''));
|
||||
if (this.queue.length === 0) {
|
||||
if (leasedByOthers > 0) {
|
||||
this._waitForLeaseRelease(leasedByOthers);
|
||||
return;
|
||||
}
|
||||
this.setState(State.COMPLETE, '没有可用的相邻无主区块');
|
||||
this._log('没有可用的相邻无主区块');
|
||||
return;
|
||||
}
|
||||
this.target = this.queue.shift();
|
||||
const picked = this._pickNextTarget();
|
||||
if (!picked.target) {
|
||||
this.setState(State.COMPLETE, '没有可用的相邻无主区块');
|
||||
this._log('没有可用的相邻无主区块');
|
||||
return;
|
||||
}
|
||||
this.target = picked.target;
|
||||
this._startPathing();
|
||||
}
|
||||
|
||||
@@ -500,15 +556,21 @@ class ExpansionController {
|
||||
+ '(注意:这会把机器人拉回安全点,当前位置与玩家手动摆好的位置都会丢失)');
|
||||
bm.sendCommand(command);
|
||||
this.returnStartMillis = Date.now();
|
||||
// 记下回城前坐标:传送完成后与当前位置组成「从 A 挪到了 B」的对比
|
||||
this.pendingReturn = { reason, from: this._precisePos(), at: Date.now() };
|
||||
this.setState(State.RETURNING, '等待回城传送(10 秒后自动传送)');
|
||||
}
|
||||
|
||||
/**
|
||||
* 回城传送等待阶段每 tick 逻辑:等待 returnWaitSeconds 秒后,
|
||||
* 耐久保护触发的回城 → 停止任务;寻路超时触发的回城 → 重新规划扩地计划。
|
||||
*
|
||||
* 传送完成后记录回城前后坐标(控制台据此展示「回城把机器人从 A 挪到了 B」),
|
||||
* 位置几乎没变时明确提示 —— 说明这个卡死点同时也是安全点,回城没有帮助。
|
||||
*/
|
||||
_tickReturning() {
|
||||
if (Date.now() - this.returnStartMillis < this.config.returnWaitSeconds * 1000) return;
|
||||
this._recordReturnResult();
|
||||
if (this.returnThenStop) {
|
||||
this.returnThenStop = false;
|
||||
this._log('耐久保护回城完成,任务已停止;请修复 / 更换装备后重新开始扩地');
|
||||
@@ -520,6 +582,28 @@ class ExpansionController {
|
||||
this.plan();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录回城结果:前后坐标与直线距离,并写入明确日志。
|
||||
* 没有待记录的回城(`pendingReturn` 为空)时什么也不做。
|
||||
*/
|
||||
_recordReturnResult() {
|
||||
const pending = this.pendingReturn;
|
||||
if (!pending) return;
|
||||
this.pendingReturn = null;
|
||||
const to = this._precisePos();
|
||||
const from = pending.from;
|
||||
const distance = from && to
|
||||
? Math.round(Math.hypot(to.x - from.x, to.z - from.z) * 10) / 10
|
||||
: null;
|
||||
this.lastReturn = { at: Date.now(), reason: pending.reason, from, to, distance };
|
||||
if (distance === null) return;
|
||||
this._log(`回城结果:机器人从 ${formatPosition(from)} 挪到了 ${formatPosition(to)}(直线 ${distance} 格)`);
|
||||
if (distance < 1) {
|
||||
this._log('本次回城几乎没有改变位置 —— 说明卡死点同时也是安全点,回城没有帮助;'
|
||||
+ '可考虑关闭「超时自动回城」,避免白白丢掉手动摆好的位置', 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 目标寻路重试耗尽时的脱困策略入口:取消当前寻路,改为朝「目标连线垂直方向」
|
||||
* 走开约 2.5 格(左右各试一侧),走开后自动重新寻路原目标。
|
||||
@@ -616,6 +700,16 @@ class ExpansionController {
|
||||
this.setState(State.FAIL, reason + '(保护期内不回城)');
|
||||
return;
|
||||
}
|
||||
// 回城前先判断「原地是否真的更差」:位置还能走时回城毫无意义,只会白白丢位置
|
||||
const assess = this._assessReturnWorth();
|
||||
if (!assess.worth) {
|
||||
this.returnSkippedNotStuck++;
|
||||
this._log(`连续超时达阈值,但${assess.reason} —— 放弃回城,改为跳过当前目标`
|
||||
+ '(回城会把机器人拉回安全点,可能丢掉玩家摆好的位置)', 'warn');
|
||||
this.setState(State.FAIL, reason + '(原地还能走,未回城)');
|
||||
return;
|
||||
}
|
||||
this._log(`连续超时达阈值,${assess.reason}`, 'warn');
|
||||
this._sendReturnCommand(`连续寻路超时超过 ${config.returnThresholdSeconds} 秒`);
|
||||
return;
|
||||
}
|
||||
@@ -626,6 +720,44 @@ class ExpansionController {
|
||||
this.setState(State.FAIL, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* 回城前的「原地是否真的更差」评估。
|
||||
*
|
||||
* 回城是破坏性操作:它把机器人拉回安全点,会清掉玩家手动摆好的位置。
|
||||
* 如果当前位置还能走(寻路器能从脚下节点生成邻居),说明只是走得慢 / 目标不可达,
|
||||
* 回城不会带来任何好处 —— 这种情况应当跳过目标,而不是传送。
|
||||
*
|
||||
* 判据取自 `pathing.diagnostics().neighborCount`(寻路器从脚下节点能生成的可走方向数):
|
||||
* - `0`:完全无路可走,确实卡死,回城值得一试;
|
||||
* - `≥1`:还能走,回城没有意义;
|
||||
* - 拿不到诊断(未连接 / 寻路器未就绪):保守起见仍按卡死处理。
|
||||
*
|
||||
* @returns {{worth: boolean, neighborCount: number|null, reason: string}}
|
||||
*/
|
||||
_assessReturnWorth() {
|
||||
const bm = this.botManager;
|
||||
let neighborCount = null;
|
||||
try {
|
||||
if (bm.pathing && typeof bm.pathing.diagnostics === 'function') {
|
||||
const diag = bm.pathing.diagnostics();
|
||||
if (diag && typeof diag.neighborCount === 'number') neighborCount = diag.neighborCount;
|
||||
}
|
||||
} catch {
|
||||
neighborCount = null;
|
||||
}
|
||||
if (neighborCount === null) {
|
||||
return { worth: true, neighborCount, reason: '拿不到寻路诊断(无法判断原地是否能走),按卡死处理' };
|
||||
}
|
||||
if (neighborCount >= 1) {
|
||||
return {
|
||||
worth: false,
|
||||
neighborCount,
|
||||
reason: `当前位置还有 ${neighborCount} 个可走方向(不是卡死),回城不会更好`
|
||||
};
|
||||
}
|
||||
return { worth: true, neighborCount, reason: `当前位置 0 个可走方向(疑似卡死),回城值得一试` };
|
||||
}
|
||||
|
||||
/** 等待开垦阶段每 tick 逻辑:玩家离开目标区块则重新寻路;等待超时则重试或跳过 */
|
||||
_tickWaiting() {
|
||||
if (!this.target) {
|
||||
@@ -675,16 +807,26 @@ class ExpansionController {
|
||||
|
||||
/** 处理下一个目标:达成目标数量则完成;队列为空时重新规划,否则取队首开始寻路 */
|
||||
_tickNext() {
|
||||
this._releaseTargetLease();
|
||||
this.target = null;
|
||||
// 候选区块正被其他机器人预定:等一会儿再重新规划(避免忙等,见 _waitForLeaseRelease)
|
||||
if (this._nextReplanAt && Date.now() < this._nextReplanAt) return;
|
||||
this._nextReplanAt = 0;
|
||||
if (this.config.targetCount > 0 && this.completedClaims >= this.config.targetCount) {
|
||||
this.finish();
|
||||
return;
|
||||
}
|
||||
if (this.queue.length === 0) {
|
||||
const picked = this._pickNextTarget();
|
||||
if (!picked.target) {
|
||||
if (picked.skippedLease > 0) {
|
||||
this._waitForLeaseRelease(picked.skippedLease);
|
||||
return;
|
||||
}
|
||||
// 队列真的空了:重新规划
|
||||
this.plan();
|
||||
return;
|
||||
}
|
||||
this.target = this.queue.shift();
|
||||
this.target = picked.target;
|
||||
if (this._isClaimedByOthers(this.target)) {
|
||||
this._log(`目标已被占领,跳过 ${this.target.x},${this.target.z}`);
|
||||
this.setState(State.NEXT, '跳过已占领区块');
|
||||
@@ -695,6 +837,7 @@ class ExpansionController {
|
||||
|
||||
/** 完成任务 */
|
||||
finish() {
|
||||
this._releaseTargetLease();
|
||||
this.botManager.pathing.cancel();
|
||||
this.setState(State.COMPLETE, this.config.targetCount > 0
|
||||
? `已完成目标数量 ${this.completedClaims}/${this.config.targetCount}`
|
||||
@@ -762,6 +905,55 @@ class ExpansionController {
|
||||
return this.dataService.getModel().allClaimedChunks().has(chunkPos.key(chunk.x, chunk.z));
|
||||
}
|
||||
|
||||
// ==================== 目标区块租约(多机器人协调) ====================
|
||||
|
||||
/**
|
||||
* 从队列里取出下一个可用目标:
|
||||
* 跳过已被占领的区块,以及被其他机器人租约占用的区块(谁先抢到谁去)。
|
||||
*
|
||||
* @returns {{target: {x: number, z: number}|null, skippedLease: number}}
|
||||
*/
|
||||
_pickNextTarget() {
|
||||
let skippedLease = 0;
|
||||
while (this.queue.length > 0) {
|
||||
const chunk = this.queue.shift();
|
||||
if (this._isClaimedByOthers(chunk)) continue;
|
||||
if (this.targetLeases && !this.targetLeases.tryAcquire(chunk, this.botId)) {
|
||||
skippedLease++;
|
||||
this._log(`区块 ${chunk.x},${chunk.z} 已被其他机器人预定,跳过`);
|
||||
continue;
|
||||
}
|
||||
return { target: chunk, skippedLease };
|
||||
}
|
||||
return { target: null, skippedLease };
|
||||
}
|
||||
|
||||
/** 续租当前目标(每个 tick 调用,保证长时间寻路时租约不过期) */
|
||||
_renewTargetLease() {
|
||||
if (!this.targetLeases || !this.target) return;
|
||||
this.targetLeases.renew(this.target, this.botId);
|
||||
}
|
||||
|
||||
/** 释放当前目标的租约(幂等:没有目标或没有租约表时什么都不做) */
|
||||
_releaseTargetLease() {
|
||||
if (!this.targetLeases || !this.target) return;
|
||||
this.targetLeases.release(this.target, this.botId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 候选区块暂时都被其他机器人预定时的处理:等一会儿再重新规划。
|
||||
*
|
||||
* 直接判 COMPLETE 会让机器人在别人还在开垦时提前收工 —— 那些区块可能是
|
||||
* 别人的目标失败了才释放的,稍后再看更合理。这里只设一个时间戳,
|
||||
* 由 {@link _tickNext} 的时间闸门控制,不做定时器,避免并发状态。
|
||||
*/
|
||||
_waitForLeaseRelease(skippedLease) {
|
||||
this._nextReplanAt = Date.now() + LEASE_RETRY_DELAY_MS;
|
||||
this._log(`${skippedLease} 个候选区块正被其他机器人预定,`
|
||||
+ `${Math.round(LEASE_RETRY_DELAY_MS / 1000)} 秒后重新规划`, 'warn');
|
||||
this.setState(State.NEXT, '候选区块被其他机器人预定,等待释放');
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算区块到己方领土的「圈数」(切比雪夫距离的最小值),用于过滤扩圈上限。
|
||||
* @param {{x: number, z: number}} chunk
|
||||
@@ -1027,6 +1219,23 @@ class ExpansionController {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{x: number, y: number, z: number}|null} 机器人当前位置(两位小数)
|
||||
*
|
||||
* 与 `_blockPos` 的区别:这里是精确坐标,用于「回城前后挪了多少」这种对比展示,
|
||||
* 取整会把几格的位移抹掉。
|
||||
*/
|
||||
_precisePos() {
|
||||
const bot = this.botManager.bot;
|
||||
if (!bot || !bot.entity) return null;
|
||||
const pos = bot.entity.position;
|
||||
return {
|
||||
x: Math.round(pos.x * 100) / 100,
|
||||
y: Math.round(pos.y * 100) / 100,
|
||||
z: Math.round(pos.z * 100) / 100
|
||||
};
|
||||
}
|
||||
|
||||
_describeChunk(chunk) {
|
||||
return chunk ? `${chunk.x},${chunk.z}` : '无';
|
||||
}
|
||||
@@ -1072,6 +1281,10 @@ class ExpansionController {
|
||||
consecutiveTimeoutMs: this.consecutiveTimeoutMillis,
|
||||
sideStepping: this.sideStepping,
|
||||
sideStepCount: this.sideStepCount,
|
||||
/** 因「原地还能走」而放弃回城的次数(回城前评估的结果) */
|
||||
returnSkippedNotStuck: this.returnSkippedNotStuck,
|
||||
/** 最近一次回城的前后坐标与直线距离(控制台展示「从 A 挪到了 B」) */
|
||||
lastReturn: this.lastReturn,
|
||||
autoBoatActive: !!(this.botManager.boat && this.botManager.boat.isActive()),
|
||||
withinClaimTime: this.isWithinClaimTime(),
|
||||
claimModeSent: this._claimModeSent,
|
||||
@@ -1097,6 +1310,15 @@ class ExpansionController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置快照 → 日志里可读的文本。
|
||||
* @param {{x: number, y: number, z: number}|null} pos
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatPosition(pos) {
|
||||
return pos ? `(${pos.x}, ${pos.y}, ${pos.z})` : '(未知)';
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化为 HH:mm:ss。
|
||||
* @param {Date} date
|
||||
|
||||
在新工单中引用
屏蔽一个用户