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` 全绿。
这个提交包含在:
+312
-25
@@ -46,6 +46,23 @@ const NEED_AUTH_PATTERN = /需要绑定|绑定您的|绑定你的|请使用命
|
||||
*/
|
||||
const FORCED_MOVE_WINDOW_MS = 30000;
|
||||
|
||||
/**
|
||||
* 原点自救的方向组合(按「动作从轻到重」排序)。
|
||||
*
|
||||
* 服务器对左右移动最不敏感(不会走远),先试;前后移动 + 跳对「卡在方块里」
|
||||
* 这类情况更有效,放第二轮。每轮结束后都会实际判定位置是否恢复。
|
||||
*/
|
||||
const ORIGIN_RESCUE_ROUNDS = [
|
||||
{ name: '左右移动', directions: ['left', 'right', 'left', 'right'] },
|
||||
{ name: '前后移动 + 跳', directions: ['forward', 'back', 'forward', 'back', 'jump'] }
|
||||
];
|
||||
/** 原点自救单次按键时长(毫秒) */
|
||||
const ORIGIN_RESCUE_PRESS_MS = 500;
|
||||
/** 原点自救两次按键之间的间隔(毫秒) */
|
||||
const ORIGIN_RESCUE_GAP_MS = 150;
|
||||
/** 每轮结束后等待服务器传送落地的时间(毫秒),避免把「还没传送」误判成失败 */
|
||||
const ORIGIN_RESCUE_SETTLE_MS = 800;
|
||||
|
||||
/**
|
||||
* 机器人管理器:负责连接、登录、断线重连,并把底层 mineflayer 事件
|
||||
* 归一化成上层(控制器 / Web 控制台)可直接消费的事件。
|
||||
@@ -69,7 +86,8 @@ class BotManager extends EventEmitter {
|
||||
* @param {string} [connection.password]
|
||||
* @param {object} config 完整配置对象
|
||||
* @param {import('../util/logger').Logger} [logger]
|
||||
* @param {{chatLog?: import('../core/chatLog').ChatLog}} [options]
|
||||
* @param {{chatLog?: import('../core/chatLog').ChatLog,
|
||||
* notifier?: import('../core/notifier').Notifier}} [options]
|
||||
*/
|
||||
constructor(connection, config, logger, options) {
|
||||
super();
|
||||
@@ -83,6 +101,12 @@ class BotManager extends EventEmitter {
|
||||
this.label = connection.label || connection.username;
|
||||
/** @type {import('../core/chatLog').ChatLog|null} 游戏公屏完整日志 */
|
||||
this.chatLog = opts.chatLog || null;
|
||||
/**
|
||||
* 通知提醒(Webhook):等待登录验证码 / 原点自救失败时推送到飞书等渠道。
|
||||
* 验证码只有 120 秒时限,是少数必须人工介入的时刻。
|
||||
* @type {import('../core/notifier').Notifier|null}
|
||||
*/
|
||||
this.notifier = opts.notifier || null;
|
||||
|
||||
/** @type {import('mineflayer').Bot|null} */
|
||||
this.bot = null;
|
||||
@@ -132,6 +156,13 @@ class BotManager extends EventEmitter {
|
||||
this._lastForcedMove = null;
|
||||
/** 是否处于「原点自救」过程中,避免重复触发 */
|
||||
this._escaping = false;
|
||||
/**
|
||||
* 原点自救的统计(以前只能从日志里看到「尝试过」,看不出有没有用)。
|
||||
*
|
||||
* 记录尝试次数、成功次数与最近一次结果;成功 = 自救结束后已不在原点附近。
|
||||
* 失败会升级为明确提示(可能是被切到了登录服,移动无效)。
|
||||
*/
|
||||
this.originRescue = { attempts: 0, successes: 0, lastResult: null };
|
||||
/** 是否已提示过「当前在登录服」 */
|
||||
this.needsVerificationHint = false;
|
||||
/**
|
||||
@@ -148,6 +179,20 @@ class BotManager extends EventEmitter {
|
||||
lastOutboundAt: 0,
|
||||
/** 出站包中 onGround 被世界数据纠正的次数 */
|
||||
onGroundFixed: 0,
|
||||
/** 【方案 A】物理引擎的 onGround 被世界数据强制同步为 true 的次数 */
|
||||
physicsGroundSync: 0,
|
||||
/** 【方案 B】保住本地 onGround、不采纳例行同步的位置包次数 */
|
||||
setbackOnGroundKept: 0,
|
||||
/** 【方案 B】最近一次位置包与本地的位置差(用来调整判定阈值) */
|
||||
lastSetbackDelta: null,
|
||||
/**
|
||||
* 出站包类型分布(包名 → 次数)。
|
||||
*
|
||||
* 用于与旧版 Fabric 模组移动时的包序列逐项对照(TODO.md 假设 4):
|
||||
* 若发现我们从未发送某个原版必备的包(如 `position_look`),
|
||||
* 就能直接锁定「发包类型 / 顺序不一致」这类被反作弊拒绝的原因。
|
||||
*/
|
||||
outboundTypes: {},
|
||||
/** 每 tick 补发的 player_input 次数(1.21.2+ 移动预测必需) */
|
||||
playerInput: 0,
|
||||
/** 每 tick 补发的 tick_end 次数 */
|
||||
@@ -243,11 +288,8 @@ class BotManager extends EventEmitter {
|
||||
_handleZeroPosition() {
|
||||
const bot = this.bot;
|
||||
if (!bot || !bot.entity) return;
|
||||
if (!this._isNearOrigin()) return;
|
||||
const pos = bot.entity.position;
|
||||
// 原点判定:水平方向很近且高度很低(正常地形不会在 y<16 的世界原点附近活动)
|
||||
const nearOrigin = Math.abs(pos.x) < 48 && Math.abs(pos.z) < 48 && pos.y < 16;
|
||||
if (!nearOrigin) return;
|
||||
|
||||
const where = `(${pos.x.toFixed(0)}, ${pos.y.toFixed(0)}, ${pos.z.toFixed(0)})`;
|
||||
|
||||
if (this._needAuthWarned) {
|
||||
@@ -266,35 +308,116 @@ class BotManager extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* 左右移动若干次以触发服务器的「重新传送」。
|
||||
* 是否位于世界原点附近(服务器把玩家丢进大厅的位置)。
|
||||
*
|
||||
* 「死亡后被卡住」与「被切到登录服」两种情况的位置特征完全一样,
|
||||
* 因此这里只负责位置判定,区分处理见 {@link _handleZeroPosition}。
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isNearOrigin() {
|
||||
const bot = this.bot;
|
||||
if (!bot || !bot.entity) return false;
|
||||
const pos = bot.entity.position;
|
||||
// 原点判定:水平方向很近且高度很低(正常地形不会在 y<16 的世界原点附近活动)
|
||||
return Math.abs(pos.x) < 48 && Math.abs(pos.z) < 48 && pos.y < 16;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试从原点自救:分两轮按方向键,每轮结束后判定位置是否恢复。
|
||||
*
|
||||
* 死亡后被卡在原点时,服务器只要收到有效的移动输入就会把玩家传送回正常位置,
|
||||
* 因此这里交替按左右方向键一小会儿。失败也不影响后续逻辑(寻路器会照常重试)。
|
||||
* 因此这里交替按键一小会儿。与以前「按完就算」的做法相比,现在会:
|
||||
* 1. 每轮结束后等传送落地,再判定「是否还在原点」;
|
||||
* 2. 第一轮(左右)无效就换更重的第二轮(前后 + 跳);
|
||||
* 3. 两轮都失败时给出明确提示,并把成功率记入 `originRescue` 统计。
|
||||
* 自救本身无害(在登录服里按键也不会有什么后果),失败也不阻塞后续逻辑。
|
||||
*/
|
||||
_escapeFromOrigin() {
|
||||
const bot = this.bot;
|
||||
if (!bot || this._escaping) return;
|
||||
this._escaping = true;
|
||||
const directions = ['left', 'right', 'left', 'right'];
|
||||
let index = 0;
|
||||
const step = () => {
|
||||
if (!this.bot || this.bot !== bot) {
|
||||
this._escaping = false;
|
||||
return;
|
||||
this.originRescue.attempts++;
|
||||
const from = capturePosition(bot);
|
||||
let roundIndex = 0;
|
||||
|
||||
const finish = (success, roundsDone) => {
|
||||
this._escaping = false;
|
||||
const to = capturePosition(this.bot);
|
||||
this.originRescue.lastResult = {
|
||||
at: Date.now(),
|
||||
success,
|
||||
rounds: roundsDone,
|
||||
from,
|
||||
to
|
||||
};
|
||||
if (success) {
|
||||
this.originRescue.successes++;
|
||||
this._log(`原点自救成功:第 ${roundsDone} 轮后位置已恢复`
|
||||
+ `(${formatPosition(from)} → ${formatPosition(to)})`, 'success');
|
||||
} else {
|
||||
this._log('原点自救失败:换过两组方向后仍在原点附近。'
|
||||
+ '若服务器其实要求输入验证码(被切到了登录服),移动是无效的 —— 需要在聊天框里把验证码发出去;'
|
||||
+ '请对照上方是否有验证码提示,或查看 /api/status 的 originRescue 统计', 'error');
|
||||
if (this._shouldNotifyRescueFailed()) {
|
||||
this._notify(
|
||||
`[AutoStore] 机器人「${this.connection.username}」原地自救失败:`
|
||||
+ `两次移动后仍卡在世界原点附近 ${formatPosition(to)}。\n`
|
||||
+ '多半是被切到了登录服(需要人工输入验证码),请到控制台查看提示并处理。',
|
||||
`rescue-failed:${this.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (index >= directions.length) {
|
||||
this._escaping = false;
|
||||
this._log('左右移动自救结束(若位置已恢复则属正常)');
|
||||
return;
|
||||
}
|
||||
const dir = directions[index++];
|
||||
this.setControlState(dir, true);
|
||||
setTimeout(() => {
|
||||
this.setControlState(dir, false);
|
||||
setTimeout(step, 150).unref?.();
|
||||
}, 500).unref?.();
|
||||
};
|
||||
step();
|
||||
|
||||
const runRound = () => {
|
||||
if (roundIndex >= ORIGIN_RESCUE_ROUNDS.length) {
|
||||
finish(false, ORIGIN_RESCUE_ROUNDS.length);
|
||||
return;
|
||||
}
|
||||
const round = ORIGIN_RESCUE_ROUNDS[roundIndex];
|
||||
this._log(`原点自救第 ${roundIndex + 1} 轮:${round.name}(共 ${round.directions.length} 次按键)`);
|
||||
let index = 0;
|
||||
const step = () => {
|
||||
if (this.bot !== bot) {
|
||||
this._escaping = false;
|
||||
return;
|
||||
}
|
||||
if (index >= round.directions.length) {
|
||||
// 等服务器的传送落地再判定,否则会把「还没传来」误判成失败
|
||||
const settle = setTimeout(() => {
|
||||
if (this.bot !== bot) {
|
||||
this._escaping = false;
|
||||
return;
|
||||
}
|
||||
if (this._needAuthWarned) {
|
||||
this._log('服务器要求输入验证码 —— 原点自救提前结束(登录服里移动没有意义)', 'warn');
|
||||
finish(false, roundIndex + 1);
|
||||
return;
|
||||
}
|
||||
if (!this._isNearOrigin()) {
|
||||
finish(true, roundIndex + 1);
|
||||
return;
|
||||
}
|
||||
this._log(`第 ${roundIndex + 1} 轮(${round.name})后仍在原点附近,换下一组方向重试`, 'warn');
|
||||
roundIndex++;
|
||||
runRound();
|
||||
}, ORIGIN_RESCUE_SETTLE_MS);
|
||||
settle.unref?.();
|
||||
return;
|
||||
}
|
||||
const dir = round.directions[index++];
|
||||
this.setControlState(dir, true);
|
||||
const release = setTimeout(() => {
|
||||
this.setControlState(dir, false);
|
||||
const next = setTimeout(step, ORIGIN_RESCUE_GAP_MS);
|
||||
next.unref?.();
|
||||
}, ORIGIN_RESCUE_PRESS_MS);
|
||||
release.unref?.();
|
||||
};
|
||||
step();
|
||||
};
|
||||
runRound();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -385,6 +508,7 @@ class BotManager extends EventEmitter {
|
||||
// ==================== 事件装配 ====================
|
||||
|
||||
_wireEvents(bot) {
|
||||
this._installGroundStateFix(bot);
|
||||
this._wirePacketStats(bot);
|
||||
|
||||
bot.on('login', () => {
|
||||
@@ -578,6 +702,8 @@ class BotManager extends EventEmitter {
|
||||
});
|
||||
const originalWrite = bot._client.write.bind(bot._client);
|
||||
bot._client.write = (name, params) => {
|
||||
const types = this.packetStats.outboundTypes;
|
||||
types[name] = (types[name] || 0) + 1;
|
||||
if (name === 'position' || name === 'position_look') {
|
||||
this.packetStats.outboundPosition++;
|
||||
this.packetStats.lastOutboundAt = Date.now();
|
||||
@@ -591,6 +717,92 @@ class BotManager extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 「地面状态修正」:让本地物理引擎与服务端对「是否站在地上」的判断保持一致。
|
||||
*
|
||||
* 背景(详见 TODO.md 问题 1):本服在机器人尝试移动时会以 5~9 次/秒的频率下发
|
||||
* `position` 强制同步,而 mineflayer 的 `physics.js` 每次都会把 `entity.onGround`
|
||||
* 置为 false 并清零速度。结果是物理引擎一直按「空中」分支计算
|
||||
* (水平加速只有地面的 1/5),出站包也持续上报「站在地上却在空中」——
|
||||
* 表现就是「按着前进键,水平速度恒为 0」。
|
||||
*
|
||||
* 两个可独立开关的修正:
|
||||
*
|
||||
* - **方案 A**(`grim.syncGroundToPhysics`):每个物理 tick 里,物理引擎算完、
|
||||
* 位置包发出之前,用世界数据复核脚下是否有实体方块;是则强制把
|
||||
* `bot.entity.onGround` 同步为 true。执行时机对齐 mineflayer 的 tickPhysics 顺序
|
||||
* (simulatePlayer → emit('physicsTick') → updatePosition 读 entity.onGround 发包),
|
||||
* 因此这一次修正同时影响「下一 tick 的物理分支」与「本 tick 的出站包」。
|
||||
* - **方案 B**(`grim.trustSetbackOnGround`):收到服务端 `position` 包时,
|
||||
* 如果它的目标位置与本地位置几乎一致(只差浮点误差 / 例行同步),就不采纳
|
||||
* 它带来的 `onGround=false`,只同步坐标 ——「我们是对的,服务端只是话多」。
|
||||
* 位置差超过 `grim.setbackSyncEpsilon` 时按真正的传送处理,不做任何干预。
|
||||
*
|
||||
* 两项修正都与 `grim.enabled` 无关:它们是「让客户端状态正确」的协议级修复,
|
||||
* 而不是反作弊调优(实测关闭合规层后回弹依旧)。
|
||||
*
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
*/
|
||||
_installGroundStateFix(bot) {
|
||||
if (!bot) return;
|
||||
const grim = (this.config && this.config.grim) || {};
|
||||
const usePhysicsSync = grim.syncGroundToPhysics !== false;
|
||||
const useSetbackGuard = grim.trustSetbackOnGround !== false;
|
||||
if (!usePhysicsSync && !useSetbackGuard) return;
|
||||
|
||||
if (usePhysicsSync) {
|
||||
bot.on('physicsTick', () => {
|
||||
if (!bot.entity || bot.entity.onGround === true) return;
|
||||
if (this._isStandingOnGround() !== true) return;
|
||||
bot.entity.onGround = true;
|
||||
this.packetStats.physicsGroundSync++;
|
||||
});
|
||||
}
|
||||
|
||||
if (useSetbackGuard && bot._client && typeof bot._client.on === 'function') {
|
||||
/**
|
||||
* 位置包的本地快照。
|
||||
*
|
||||
* 必须在 mineflayer 自己的处理器**之前**取样:它会在同一次事件里把速度清零、
|
||||
* 把 onGround 置为 false,之后再取就看不到「我们自己的判断」了。
|
||||
* mineflayer 的处理器注册得更早,因此这里用 prependListener 插到最前面。
|
||||
*/
|
||||
let before = null;
|
||||
if (typeof bot._client.prependListener === 'function') {
|
||||
bot._client.prependListener('position', () => {
|
||||
before = bot.entity
|
||||
? {
|
||||
x: bot.entity.position.x,
|
||||
y: bot.entity.position.y,
|
||||
z: bot.entity.position.z,
|
||||
onGround: bot.entity.onGround === true
|
||||
}
|
||||
: null;
|
||||
});
|
||||
}
|
||||
bot._client.on('position', () => {
|
||||
if (!before || !bot.entity) return;
|
||||
const pos = bot.entity.position;
|
||||
const dx = pos.x - before.x;
|
||||
const dy = pos.y - before.y;
|
||||
const dz = pos.z - before.z;
|
||||
const distance = Math.hypot(dx, dy, dz);
|
||||
this.packetStats.lastSetbackDelta = {
|
||||
x: round3(dx),
|
||||
y: round3(dy),
|
||||
z: round3(dz),
|
||||
distance: round3(distance),
|
||||
at: Date.now()
|
||||
};
|
||||
const epsilon = Number(grim.setbackSyncEpsilon);
|
||||
if (distance > (Number.isFinite(epsilon) ? epsilon : 0.5)) return; // 真正的传送:按服务端的来
|
||||
if (bot.entity.onGround === before.onGround) return;
|
||||
bot.entity.onGround = before.onGround;
|
||||
this.packetStats.setbackOnGroundKept++;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按世界数据修正出站位置包的 onGround 标志。
|
||||
*
|
||||
@@ -614,6 +826,34 @@ class BotManager extends EventEmitter {
|
||||
this.packetStats.onGroundFixed++;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送一条「需要人工介入」的通知(验证码 / 自救失败)。
|
||||
*
|
||||
* 异步且静默失败:通知只是提醒手段,网络不通不能影响机器人本身的运行。
|
||||
*
|
||||
* @param {string} text 正文
|
||||
* @param {string} key 去重键(同一个问题在通知器的最小间隔内只发一次)
|
||||
*/
|
||||
_notify(text, key) {
|
||||
if (!this.notifier || typeof this.notifier.notify !== 'function') return;
|
||||
// 是否启用、地址是否配好,由通知器自己判断(含 NOTIFY_WEBHOOK_URL 环境变量兜底)
|
||||
if (this.notifier.isEnabled() !== true) return;
|
||||
Promise.resolve(this.notifier.notify(text, { key }))
|
||||
.catch((err) => this._log('发送通知提醒失败:' + (err && err.message ? err.message : String(err)), 'warn'));
|
||||
}
|
||||
|
||||
/** @returns {boolean} 是否允许发送「等待验证码」提醒(配置 notify.onVerification) */
|
||||
_shouldNotifyVerification() {
|
||||
const cfg = (this.config && this.config.notify) || {};
|
||||
return cfg.onVerification !== false;
|
||||
}
|
||||
|
||||
/** @returns {boolean} 是否允许发送「原点自救失败」提醒(配置 notify.onOriginRescueFailed) */
|
||||
_shouldNotifyRescueFailed() {
|
||||
const cfg = (this.config && this.config.notify) || {};
|
||||
return cfg.onOriginRescueFailed !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用世界数据判断脚下是否有实体方块。
|
||||
*
|
||||
@@ -741,6 +981,13 @@ class BotManager extends EventEmitter {
|
||||
this._log('服务器要求先完成账号绑定 / 二次验证(本服为「简幻通」体系):' + message, 'error');
|
||||
this._log('该验证需要人工完成一次(游戏内扫码,或把验证码当聊天内容直接发出去);'
|
||||
+ '绑定成功后同一账号即可自动登录。若服务器提供固定验证码,可在配置的「账号绑定指令模板」里填写。', 'warn');
|
||||
// 验证码只有 120 秒时限,错过就得等下次重登 —— 这是少数必须人工介入的时刻,
|
||||
// 因此值得走通知渠道(飞书机器人等)提醒,而不是只写进日志
|
||||
if (this._shouldNotifyVerification()) {
|
||||
this._notify(`[AutoStore] 机器人「${this.connection.username}」需要输入登录验证码。\n`
|
||||
+ '服务器只给 120 秒,请立刻到控制台的聊天框把验证码发出去(当聊天内容发送,不用加斜杠)。\n'
|
||||
+ `服务器提示:${message}`, `verification:${this.id}`);
|
||||
}
|
||||
// 如果此刻人在原点附近,那就是被切到了登录服 —— 把这条因果关系讲清楚,
|
||||
// 否则用户只看到「机器人被反复传送」而不知道真正原因
|
||||
const pos = this.bot && this.bot.entity ? this.bot.entity.position : null;
|
||||
@@ -1009,6 +1256,17 @@ class BotManager extends EventEmitter {
|
||||
* 控制台据此弹醒目提示并引导用户在输入框里填验证码。
|
||||
*/
|
||||
needsVerification: this._needAuthWarned === true,
|
||||
/**
|
||||
* 原点自救统计:尝试次数、成功次数、最近一次结果(含前后坐标)。
|
||||
*
|
||||
* 以前只能从日志里看到「尝试过」,看不出自救到底有没有用;
|
||||
* 控制台与 /api/status 可以直接读这里的成功率。
|
||||
*/
|
||||
originRescue: {
|
||||
attempts: this.originRescue.attempts,
|
||||
successes: this.originRescue.successes,
|
||||
lastResult: this.originRescue.lastResult
|
||||
},
|
||||
username: this.connection.username,
|
||||
host: `${this.connection.host}:${this.connection.port}`,
|
||||
version: this.connection.version,
|
||||
@@ -1224,6 +1482,35 @@ function boundsOfColumns(columns) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前位置的快照(三位小数),用于「回城 / 自救前后对比」这类排查信息。
|
||||
* @param {import('mineflayer').Bot|null} bot
|
||||
* @returns {{x: number, y: number, z: number}|null}
|
||||
*/
|
||||
function capturePosition(bot) {
|
||||
const pos = bot && bot.entity ? bot.entity.position : null;
|
||||
if (!pos) return null;
|
||||
return { x: round3(pos.x), y: round3(pos.y), z: round3(pos.z) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把位置快照格式化成日志里可读的形式。
|
||||
* @param {{x: number, y: number, z: number}|null} pos
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatPosition(pos) {
|
||||
return pos ? `(${pos.x}, ${pos.y}, ${pos.z})` : '(未知)';
|
||||
}
|
||||
|
||||
/**
|
||||
* 保留三位小数(诊断数据里足够,避免控制台被长浮点刷屏)。
|
||||
* @param {number} value
|
||||
* @returns {number}
|
||||
*/
|
||||
function round3(value) {
|
||||
return Math.round(value * 1000) / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* mineflayer 的消息通道 → 公屏日志类型。
|
||||
* @param {string} [position]
|
||||
|
||||
+17
-2
@@ -5,6 +5,7 @@ const { EventEmitter } = require('events');
|
||||
const { BotManager } = require('./botManager');
|
||||
const { ScheduledMessenger } = require('./scheduledMessenger');
|
||||
const { ExpansionController } = require('../controller/expansionController');
|
||||
const { TargetLeaseTable } = require('../core/targetLease');
|
||||
const { normalizeConfig } = require('../config/expansionConfig');
|
||||
const { sanitizeId } = require('../config/botsConfig');
|
||||
|
||||
@@ -43,6 +44,8 @@ class BotPool extends EventEmitter {
|
||||
this.chatLog = deps.chatLog || null;
|
||||
this.botStore = deps.botStore || null;
|
||||
this.credentialStore = deps.credentialStore || null;
|
||||
/** @type {import('../core/notifier').Notifier|null} 人工介入提醒(验证码 / 自救失败) */
|
||||
this.notifier = deps.notifier || null;
|
||||
|
||||
/** @type {Map<string, object>} 机器人 id → 运行时条目 */
|
||||
this.entries = new Map();
|
||||
@@ -54,6 +57,13 @@ class BotPool extends EventEmitter {
|
||||
this.credentials = this.credentialStore ? this.credentialStore.load().bots : {};
|
||||
/** 启动时间戳 */
|
||||
this.startedAt = 0;
|
||||
/**
|
||||
* 目标区块租约表:多机器人共享,避免同时扩地时撞在同一批候选区块上
|
||||
* (详见 core/targetLease.js)。
|
||||
*/
|
||||
this.targetLeases = new TargetLeaseTable({
|
||||
ttlMs: (this.config.coordination ? this.config.coordination.targetLeaseTtlSeconds : 180) * 1000
|
||||
});
|
||||
|
||||
for (const connection of this._mergeConnections(deps.connections || [])) {
|
||||
this._addBot(connection);
|
||||
@@ -103,7 +113,7 @@ class BotPool extends EventEmitter {
|
||||
Object.assign({}, connection, { label: setting.label || connection.label }),
|
||||
this.getConfigFor(id),
|
||||
this.logger,
|
||||
{ chatLog: this.chatLog }
|
||||
{ chatLog: this.chatLog, notifier: this.notifier }
|
||||
);
|
||||
const controller = new ExpansionController({
|
||||
botManager: manager,
|
||||
@@ -111,7 +121,8 @@ class BotPool extends EventEmitter {
|
||||
logger: this.logger,
|
||||
botId: id,
|
||||
botName: connection.username,
|
||||
expansionLog: this.expansionLog
|
||||
expansionLog: this.expansionLog,
|
||||
targetLeases: this.targetLeases
|
||||
});
|
||||
// 寻路事件转发给控制器(与 Baritone 的 PathEvent 语义一致)
|
||||
manager.on('modulesReady', () => {
|
||||
@@ -341,6 +352,10 @@ class BotPool extends EventEmitter {
|
||||
*/
|
||||
applyGlobalConfig(config) {
|
||||
this.config = config;
|
||||
// 租约时长可以热改(新配置对新租约生效;已发出的租约仍按原时长到期)
|
||||
if (this.targetLeases && config.coordination) {
|
||||
this.targetLeases.ttlMs = Math.max(1, Number(config.coordination.targetLeaseTtlSeconds) || 180) * 1000;
|
||||
}
|
||||
for (const entry of this.entries.values()) {
|
||||
entry.config = this.getConfigFor(entry.id);
|
||||
entry.manager.applyConfig(entry.config);
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
'use strict';
|
||||
|
||||
const { goals } = require('mineflayer-pathfinder');
|
||||
const { shortestAngleDelta } = require('./grimCompat');
|
||||
|
||||
const { GoalNearXZ } = goals;
|
||||
|
||||
/**
|
||||
* 手动执行器:只借用 mineflayer-pathfinder 的**规划**(A* 算路径),执行自己做。
|
||||
*
|
||||
* 背景(TODO.md 问题 1 的备选方案):
|
||||
* 在邦国崛起这台服务器上,**手动按住方向键的移动完全正常**,而 pathfinder 的执行层
|
||||
* 会被服务端反复回弹。因此留一条「只算路径、自己走」的路:
|
||||
*
|
||||
* 1. 用 `bot.pathfinder.getPathFromTo()` 拿到路径点数组(不执行);
|
||||
* 2. 朝下一个路径点转(旋转仍走 Grim 合规层的逐 tick 限速);
|
||||
* 3. 朝向对上后按住 `forward`,到位后切下一个点;
|
||||
* 4. 遇到一格高的坎按一下 `jump`;长时间没有位移则重新规划。
|
||||
*
|
||||
* 控制键时序与「移动测试」的手动按键完全一致,因此出问题时可以逐 tick 对照排查。
|
||||
* 默认不启用(`pathing.executor` 仍为 `pathfinder`),实测需要时再切换。
|
||||
*/
|
||||
|
||||
/** 判定到达路径点的水平距离(格) */
|
||||
const WAYPOINT_RADIUS = 0.6;
|
||||
/**
|
||||
* 判定到达路径点的垂直容差(格)。
|
||||
*
|
||||
* 必须小于 1:站在坎下(y 比路径点低 1 格)时不能算「已到达」,
|
||||
* 否则会漏掉该跳的那一步,机器人会站在坎边「假装到了」。
|
||||
*/
|
||||
const WAYPOINT_Y_TOLERANCE = 0.5;
|
||||
/** 朝向误差小于该值(弧度)才允许按住前进键,避免「斜着走」撞墙 */
|
||||
const MAX_HEADING_ERROR = Math.PI / 6;
|
||||
/** 判定需要跳的「目标高出脚下」阈值(格) */
|
||||
const STEP_UP_THRESHOLD = 0.5;
|
||||
/** 判定需要跳的水平距离上限(格):离坎太远就跳没有意义 */
|
||||
const JUMP_RANGE = 1.6;
|
||||
/** 跳一下按住多少 tick(与手动按键一致,避免长按连跳) */
|
||||
const JUMP_HOLD_TICKS = 2;
|
||||
/** 连续多少个物理 tick 没有位移判定为卡住(约 2 秒) */
|
||||
const STUCK_TICKS = 40;
|
||||
/** 单次 moveTo 最多重规划几次 */
|
||||
const MAX_REPLANS = 3;
|
||||
|
||||
class ManualMover {
|
||||
/**
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
* @param {object} config 完整配置对象
|
||||
* @param {import('../util/logger').Logger} [logger]
|
||||
* @param {{plan?: Function, timeoutMs?: number}} [options]
|
||||
* plan 可注入自定义规划函数(测试用),默认走 mineflayer-pathfinder 的 A*
|
||||
*/
|
||||
constructor(bot, config, logger, options) {
|
||||
const opts = options || {};
|
||||
this.bot = bot;
|
||||
this.config = config;
|
||||
this.logger = logger || null;
|
||||
/** @type {(goalBlock: object) => Promise<Array<object>|null>} 规划函数 */
|
||||
this.plan = opts.plan || ((goalBlock, runId) => this._planWithPathfinder(goalBlock, runId));
|
||||
this.timeoutMs = opts.timeoutMs === undefined
|
||||
? ((config.pathing && config.pathing.manualTimeoutMs) || 180000)
|
||||
: opts.timeoutMs;
|
||||
this._cancelled = true;
|
||||
this._active = false;
|
||||
this._jumpHoldTicks = 0;
|
||||
/**
|
||||
* 本次执行代次。
|
||||
*
|
||||
* stop() 与新一次 moveTo() 都会把它 +1,因此「上一代」的循环醒来后
|
||||
* 会发现代次不匹配而立刻退出 —— 避免两次执行同时按键。
|
||||
*/
|
||||
this._runId = 0;
|
||||
/** 最近一次规划的状态(success / noPath / timeout / noPathfinder) */
|
||||
this._planStatus = null;
|
||||
}
|
||||
|
||||
/** @returns {boolean} 是否正在执行 */
|
||||
get active() {
|
||||
return this._active;
|
||||
}
|
||||
|
||||
/** @returns {string|null} 最近一次规划的状态 */
|
||||
lastStatus() {
|
||||
return this._planStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 走到目标方块附近(水平 2 格内即可,与 GoalNearXZ 的到达判定一致)。
|
||||
*
|
||||
* @param {{x: number, z: number}} goalBlock 目标(方块坐标)
|
||||
* @returns {Promise<{ok: boolean, reason: string, waypoints: number, ticks: number}>}
|
||||
*/
|
||||
async moveTo(goalBlock) {
|
||||
const deadline = Date.now() + this.timeoutMs;
|
||||
const runId = ++this._runId;
|
||||
this._cancelled = false;
|
||||
this._active = true;
|
||||
let waypointCount = 0;
|
||||
let ticks = 0;
|
||||
this._log(`手动执行开始:目标 ${Math.round(goalBlock.x)},${Math.round(goalBlock.z)}`);
|
||||
try {
|
||||
for (let attempt = 0; attempt <= MAX_REPLANS; attempt++) {
|
||||
const path = await this.plan(goalBlock, runId);
|
||||
if (this._isStale(runId)) return { ok: false, reason: 'cancelled', waypoints: waypointCount, ticks };
|
||||
if (!path || path.length === 0) {
|
||||
const reason = this._planStatus || 'noPath';
|
||||
this._log(`手动执行:规划失败(${reason})`);
|
||||
return { ok: false, reason, waypoints: waypointCount, ticks };
|
||||
}
|
||||
waypointCount += path.length;
|
||||
const walked = await this._follow(path, deadline, runId);
|
||||
ticks += walked.ticks;
|
||||
if (walked.ok) {
|
||||
this._log(`手动执行完成:走过 ${waypointCount} 个路径点、${ticks} tick`);
|
||||
return { ok: true, reason: 'arrived', waypoints: waypointCount, ticks };
|
||||
}
|
||||
if (walked.reason === 'cancelled' || walked.reason === 'timeout') {
|
||||
return { ok: false, reason: walked.reason, waypoints: waypointCount, ticks };
|
||||
}
|
||||
this._log(`第 ${attempt + 1} 次执行中断(${walked.reason}),重新规划`);
|
||||
}
|
||||
return { ok: false, reason: 'stuck', waypoints: waypointCount, ticks };
|
||||
} finally {
|
||||
this._active = false;
|
||||
this._releaseControls();
|
||||
}
|
||||
}
|
||||
|
||||
/** 取消当前执行(松开所有由本执行器按下的键) */
|
||||
stop() {
|
||||
this._cancelled = true;
|
||||
// 代次 +1:让正在跑的循环在下一个 tick 立刻退出,避免与新执行抢控制键
|
||||
this._runId++;
|
||||
this._releaseControls();
|
||||
}
|
||||
|
||||
/**
|
||||
* 本次执行的循环是否应当退出(被取消,或已被更新的一代取代)。
|
||||
* @param {number} runId
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isStale(runId) {
|
||||
return this._cancelled || runId !== this._runId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 mineflayer-pathfinder 只算路径。
|
||||
*
|
||||
* `getPathFromTo` 是生成器:每次只算 `tickTimeout` 毫秒(与 pathfinder 自身一致),
|
||||
* 因此这里逐个推进、在切片之间让出事件循环,避免长搜索卡住整个进程。
|
||||
*
|
||||
* @param {{x: number, z: number}} goalBlock
|
||||
* @returns {Promise<Array<object>|null>} 路径点数组(失败返回 null)
|
||||
*/
|
||||
async _planWithPathfinder(goalBlock, runId) {
|
||||
const bot = this.bot;
|
||||
const pf = bot.pathfinder;
|
||||
this._planStatus = null;
|
||||
if (!pf || typeof pf.getPathFromTo !== 'function' || !bot.entity) {
|
||||
this._planStatus = 'noPathfinder';
|
||||
return null;
|
||||
}
|
||||
const goal = new GoalNearXZ(goalBlock.x, goalBlock.z, 2);
|
||||
const generator = pf.getPathFromTo(pf.movements, bot.entity.position, goal, {
|
||||
timeout: pf.thinkTimeout,
|
||||
tickTimeout: pf.tickTimeout,
|
||||
searchRadius: pf.searchRadius
|
||||
});
|
||||
let result = null;
|
||||
for (;;) {
|
||||
const next = generator.next();
|
||||
if (next.done) break;
|
||||
result = next.value.result;
|
||||
if (result.status !== 'partial') break;
|
||||
if (this._isStale(runId)) return null;
|
||||
await bot.waitForTicks(1);
|
||||
}
|
||||
this._planStatus = result ? result.status : 'noPath';
|
||||
if (!result || result.status !== 'success') return null;
|
||||
return result.path;
|
||||
}
|
||||
|
||||
/**
|
||||
* 依次走完所有路径点。
|
||||
* @param {Array<object>} path
|
||||
* @param {number} deadline
|
||||
* @param {number} runId
|
||||
*/
|
||||
async _follow(path, deadline, runId) {
|
||||
let ticks = 0;
|
||||
for (const node of path) {
|
||||
const result = await this._walkToNode(node, deadline, runId);
|
||||
ticks += result.ticks;
|
||||
if (!result.ok) return { ok: false, reason: result.reason, ticks };
|
||||
}
|
||||
return { ok: true, reason: 'arrived', ticks };
|
||||
}
|
||||
|
||||
/**
|
||||
* 走向单个路径点:转朝向 → 按住前进 → 需要时跳一下。
|
||||
* @param {{x: number, y: number, z: number}} node 路径点(方块坐标)
|
||||
* @param {number} deadline
|
||||
* @param {number} runId
|
||||
*/
|
||||
async _walkToNode(node, deadline, runId) {
|
||||
const target = { x: node.x + 0.5, y: node.y, z: node.z + 0.5 };
|
||||
let ticks = 0;
|
||||
let stuckTicks = 0;
|
||||
let lastX = this.bot.entity.position.x;
|
||||
let lastZ = this.bot.entity.position.z;
|
||||
while (!this._isStale(runId)) {
|
||||
const pos = this.bot.entity.position;
|
||||
const dx = target.x - pos.x;
|
||||
const dz = target.z - pos.z;
|
||||
const horizontal = Math.hypot(dx, dz);
|
||||
if (horizontal <= WAYPOINT_RADIUS && Math.abs(target.y - pos.y) <= WAYPOINT_Y_TOLERANCE) {
|
||||
return { ok: true, ticks };
|
||||
}
|
||||
if (Date.now() > deadline) return { ok: false, reason: 'timeout', ticks };
|
||||
|
||||
const yaw = Math.atan2(-dx, -dz);
|
||||
this._look(yaw);
|
||||
const headingError = Math.abs(shortestAngleDelta(this.bot.entity.yaw, yaw));
|
||||
this._setControl('forward', headingError <= MAX_HEADING_ERROR);
|
||||
|
||||
// 目标比脚下高一格且已经靠近 → 跳一下(对应手动按键时的「卡坎就跳」)
|
||||
const needJump = (target.y - pos.y) > STEP_UP_THRESHOLD && horizontal <= JUMP_RANGE;
|
||||
if (needJump) {
|
||||
this._setControl('jump', true);
|
||||
this._jumpHoldTicks = JUMP_HOLD_TICKS;
|
||||
} else if (this._jumpHoldTicks > 0) {
|
||||
this._jumpHoldTicks--;
|
||||
if (this._jumpHoldTicks === 0) this._setControl('jump', false);
|
||||
}
|
||||
|
||||
// 卡住判定:水平位移长时间接近 0 就中断,交给上层重新规划
|
||||
const moved = Math.hypot(pos.x - lastX, pos.z - lastZ);
|
||||
if (moved < 0.02) {
|
||||
stuckTicks++;
|
||||
} else {
|
||||
stuckTicks = 0;
|
||||
lastX = pos.x;
|
||||
lastZ = pos.z;
|
||||
}
|
||||
if (stuckTicks >= STUCK_TICKS) return { ok: false, reason: 'stuck', ticks };
|
||||
|
||||
ticks++;
|
||||
await this.bot.waitForTicks(1);
|
||||
}
|
||||
return { ok: false, reason: 'cancelled', ticks };
|
||||
}
|
||||
|
||||
/** 设置朝向(走 bot.look,即 Grim 合规层的逐 tick 限速路径) */
|
||||
_look(yaw) {
|
||||
const bot = this.bot;
|
||||
try {
|
||||
const pitch = bot.entity.pitch;
|
||||
if (typeof bot.look === 'function') {
|
||||
const result = bot.look(yaw, pitch);
|
||||
// 限速推进的 Promise 不需要等待;避免未处理的 rejection
|
||||
if (result && typeof result.catch === 'function') result.catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
// 朝向失败不影响主循环,下一 tick 会重试
|
||||
}
|
||||
}
|
||||
|
||||
/** 设置控制键(走 bot.setControlState,即 Grim 合规层的互斥规则) */
|
||||
_setControl(control, state) {
|
||||
try {
|
||||
if (this.bot.getControlState && this.bot.getControlState(control) === state) return;
|
||||
this.bot.setControlState(control, state);
|
||||
} catch {
|
||||
// 忽略:控制键设置在极端状态下可能失败(如骑乘),下一 tick 会重试
|
||||
}
|
||||
}
|
||||
|
||||
/** 松开本次执行按下的控制键 */
|
||||
_releaseControls() {
|
||||
this._jumpHoldTicks = 0;
|
||||
for (const control of ['forward', 'jump']) {
|
||||
try {
|
||||
if (this.bot.getControlState && this.bot.getControlState(control) === true) {
|
||||
this.bot.setControlState(control, false);
|
||||
}
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_log(message) {
|
||||
if (this.logger) this.logger.log(message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ManualMover,
|
||||
WAYPOINT_RADIUS,
|
||||
MAX_HEADING_ERROR,
|
||||
STUCK_TICKS,
|
||||
MAX_REPLANS
|
||||
};
|
||||
+107
-21
@@ -2,6 +2,8 @@
|
||||
|
||||
const pathfinderPlugin = require('mineflayer-pathfinder');
|
||||
|
||||
const { ManualMover } = require('./manualMover');
|
||||
|
||||
const { GoalXZ, GoalNearXZ, Goal } = pathfinderPlugin.goals;
|
||||
const { Movements } = pathfinderPlugin;
|
||||
|
||||
@@ -75,6 +77,16 @@ class PathingService {
|
||||
/** 段长收缩次数与系数(本段算出 noPath 时用来缩短段长重试) */
|
||||
this._segmentShrink = 0;
|
||||
this._segmentScale = 1;
|
||||
/**
|
||||
* 手动执行器(`pathing.executor === 'manual'` 时创建)。
|
||||
*
|
||||
* 只借用 pathfinder 的 A* 规划,执行改为自己按键(详见 manualMover.js 的说明);
|
||||
* 默认不启用,实测确认寻路器执行层被服务端回弹时才切换。
|
||||
* @type {ManualMover|null}
|
||||
*/
|
||||
this.mover = null;
|
||||
/** 手动执行的代次:新一次执行会让旧的一次自行退出 */
|
||||
this._manualRunId = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,25 +131,7 @@ class PathingService {
|
||||
// 重复注册会让同一个事件被处理多次(日志里同一秒出现多条相同事件就是这么来的)
|
||||
if (!this._listenersBound) {
|
||||
this._listenersBound = true;
|
||||
bot.on('goal_reached', () => {
|
||||
this._pathing = false;
|
||||
if (this._waypointIsFinal || !this.finalTarget) {
|
||||
this._emit(PathEvent.AT_GOAL, { target: this.currentTarget });
|
||||
return;
|
||||
}
|
||||
// 只走到了中途点:新区块已加载,继续朝最终目标规划下一段
|
||||
this._segmentCount++;
|
||||
if (this._segmentCount > MAX_SEGMENTS) {
|
||||
this._log(`分段寻路超过 ${MAX_SEGMENTS} 段仍未到达目标,判定为不可达`);
|
||||
this.calcFailed = true;
|
||||
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, reason: 'too-many-segments' });
|
||||
return;
|
||||
}
|
||||
this._log(`已到达中途点 ${Math.round(this.currentWaypoint.x)},${Math.round(this.currentWaypoint.z)},`
|
||||
+ `继续朝目标 ${this.finalTarget.x},${this.finalTarget.z} 前进(第 ${this._segmentCount} 段)`);
|
||||
this._emit(PathEvent.SEGMENT_REACHED, { segment: this._segmentCount, target: this.currentTarget });
|
||||
this._planSegment();
|
||||
});
|
||||
bot.on('goal_reached', () => this._onSegmentReached());
|
||||
bot.on('path_update', (r) => {
|
||||
this._lastStatus = r.status;
|
||||
// 完整记录寻路器状态:长距离寻路卡住时,只看成功 / 失败是查不出原因的
|
||||
@@ -175,6 +169,13 @@ class PathingService {
|
||||
}
|
||||
this.ready = true;
|
||||
this._configured = true;
|
||||
// 手动执行模式:只保留 A* 规划能力,执行交给 ManualMover(备选方案,默认不启用)
|
||||
if (this.config.pathing.executor === 'manual') {
|
||||
this.mover = new ManualMover(bot, this.config, this.logger);
|
||||
this._log('寻路执行器:手动模式(只借用 A* 算路径,自己按键执行)');
|
||||
} else {
|
||||
this.mover = null;
|
||||
}
|
||||
this._log(`寻路器就绪(挖方块 ${this.config.allowBreak ? '开' : '关'},`
|
||||
+ `放方块 ${this.config.allowPlace ? '开' : '关'},`
|
||||
+ `跑酷 ${this.config.pathing.allowParkour ? '开' : '关'},`
|
||||
@@ -235,14 +236,17 @@ class PathingService {
|
||||
const goalBlock = this._segmentGoal(this.finalTarget);
|
||||
this.currentWaypoint = goalBlock;
|
||||
this._waypointIsFinal = goalBlock.x === this.finalTarget.x && goalBlock.z === this.finalTarget.z;
|
||||
this.calcFailed = false;
|
||||
this._pathing = true;
|
||||
|
||||
// 手动执行模式:同样只规划「这一段」,执行交给 ManualMover(不阻塞调用方)
|
||||
if (this.mover) return this._startManualRun(goalBlock);
|
||||
|
||||
let goal = new GoalNearXZ(goalBlock.x, goalBlock.z, 2);
|
||||
if (this.config.altitudePreference && this.config.altitudeWeight > 0) {
|
||||
const startY = Math.floor(bot.entity.position.y);
|
||||
goal = new AltitudeGoal(goal, this.config.altitudeWeight, startY);
|
||||
}
|
||||
this.calcFailed = false;
|
||||
this._pathing = true;
|
||||
try {
|
||||
bot.pathfinder.setGoal(goal);
|
||||
} catch (err) {
|
||||
@@ -255,6 +259,81 @@ class PathingService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动一次手动执行(备选方案)。
|
||||
*
|
||||
* 与 pathfinder 的执行保持同一套对外语义:走完一段 → `_onSegmentReached()`(会继续
|
||||
* 规划下一段或报到达);走不动 → 与 noPath 一样先缩短段长重试,再如实报失败。
|
||||
*
|
||||
* @param {{x: number, z: number}} goalBlock
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_startManualRun(goalBlock) {
|
||||
if (!this.mover) return false;
|
||||
const runId = ++this._manualRunId;
|
||||
this.mover.stop();
|
||||
this.mover.moveTo(goalBlock)
|
||||
.then((result) => this._onManualOutcome(result, runId))
|
||||
.catch((err) => {
|
||||
if (runId !== this._manualRunId) return;
|
||||
this.calcFailed = true;
|
||||
this._pathing = false;
|
||||
this._log('手动执行异常:' + (err && err.message ? err.message : String(err)));
|
||||
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, error: err && err.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动执行的结果处理。
|
||||
* @param {{ok: boolean, reason: string, waypoints: number, ticks: number}} result
|
||||
* @param {number} runId
|
||||
*/
|
||||
_onManualOutcome(result, runId) {
|
||||
if (runId !== this._manualRunId) return; // 已被取消或被新的一段取代
|
||||
if (result.ok) {
|
||||
this._onSegmentReached();
|
||||
return;
|
||||
}
|
||||
if (result.reason === 'cancelled') return;
|
||||
if (!this._waypointIsFinal && this.finalTarget && this._segmentShrink < MAX_SEGMENT_SHRINK) {
|
||||
this._segmentShrink++;
|
||||
this._segmentScale *= 0.6;
|
||||
this._log(`手动执行中断(${result.reason}),缩短段长后重试(第 ${this._segmentShrink}/${MAX_SEGMENT_SHRINK} 次)`);
|
||||
this._planSegment();
|
||||
return;
|
||||
}
|
||||
this.calcFailed = true;
|
||||
this._pathing = false;
|
||||
this._log(`手动执行失败:${result.reason}`
|
||||
+ `(走过 ${result.waypoints} 个路径点、${result.ticks} tick)`);
|
||||
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, reason: result.reason });
|
||||
}
|
||||
|
||||
/**
|
||||
* 到达当前段的终点:是最终目标就报到达,否则继续规划下一段。
|
||||
* pathfinder 的 `goal_reached` 与手动执行共用这一套逻辑。
|
||||
*/
|
||||
_onSegmentReached() {
|
||||
this._pathing = false;
|
||||
if (this._waypointIsFinal || !this.finalTarget) {
|
||||
this._emit(PathEvent.AT_GOAL, { target: this.currentTarget });
|
||||
return;
|
||||
}
|
||||
// 只走到了中途点:新区块已加载,继续朝最终目标规划下一段
|
||||
this._segmentCount++;
|
||||
if (this._segmentCount > MAX_SEGMENTS) {
|
||||
this._log(`分段寻路超过 ${MAX_SEGMENTS} 段仍未到达目标,判定为不可达`);
|
||||
this.calcFailed = true;
|
||||
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, reason: 'too-many-segments' });
|
||||
return;
|
||||
}
|
||||
this._log(`已到达中途点 ${Math.round(this.currentWaypoint.x)},${Math.round(this.currentWaypoint.z)},`
|
||||
+ `继续朝目标 ${this.finalTarget.x},${this.finalTarget.z} 前进(第 ${this._segmentCount} 段)`);
|
||||
this._emit(PathEvent.SEGMENT_REACHED, { segment: this._segmentCount, target: this.currentTarget });
|
||||
this._planSegment();
|
||||
}
|
||||
|
||||
/**
|
||||
* 把最终目标裁剪到「当前已加载的范围内」。
|
||||
*
|
||||
@@ -377,6 +456,10 @@ class PathingService {
|
||||
worldApi,
|
||||
blockViaWorld,
|
||||
blockViaBlockAt: bot.blockAt ? (bot.blockAt(bot.entity.position.offset(0, -1, 0)) || {}).name : null,
|
||||
/** 当前使用的执行器:pathfinder(默认)或 manual(备选方案) */
|
||||
executor: this.mover ? 'manual' : 'pathfinder',
|
||||
/** 手动执行器的最近一次规划状态(仅在 manual 模式下有意义) */
|
||||
manualPlanStatus: this.mover ? this.mover.lastStatus() : null,
|
||||
/** 四个方向能「看到」多远(方块):反映已加载区块范围 */
|
||||
visibleDistance: {
|
||||
north: Math.round(this._probeLoadedDistance(0, -1, 256)),
|
||||
@@ -397,6 +480,9 @@ class PathingService {
|
||||
this._segmentCount = 0;
|
||||
this._segmentShrink = 0;
|
||||
this._segmentScale = 1;
|
||||
// 手动执行模式:停掉执行器并让在途的结果失效(否则它会继续按键)
|
||||
this._manualRunId++;
|
||||
if (this.mover) this.mover.stop();
|
||||
try {
|
||||
this.bot.pathfinder.setGoal(null);
|
||||
} catch {
|
||||
|
||||
+307
-18
@@ -6,18 +6,24 @@ const MIN_INTERVAL_SECONDS = 5;
|
||||
const MAX_TASKS = 20;
|
||||
/** 调度器的心跳间隔(毫秒):只做「到点了吗」的判断,开销可忽略 */
|
||||
const TICK_INTERVAL_MS = 1000;
|
||||
/** 发送结果确认的默认超时(秒) */
|
||||
const DEFAULT_CONFIRM_TIMEOUT_SECONDS = 10;
|
||||
|
||||
/**
|
||||
* 定时消息器:按固定间隔重复发送同一条内容(公屏消息或游戏指令)。
|
||||
* 定时消息器:按固定间隔或 cron 表达式重复发送同一条内容(公屏消息或游戏指令)。
|
||||
*
|
||||
* 典型用法是服务器要求周期性执行的指令,例如每 300 秒发一次 `/u restore confirm`
|
||||
* 来维持疆土民心。这类「心跳式」指令是挂机机器人的刚需,
|
||||
* 来维持疆土民心,或者每天 09:00 发一次公告。这类「心跳式」指令是挂机机器人的刚需,
|
||||
* 但绝不能简单地 setInterval —— 断线、未登录、正在回城时都不该发,
|
||||
* 否则要么白发(服务端丢弃),要么在错误的时机打断状态机。
|
||||
*
|
||||
* 因此这里做成:
|
||||
* - 每个任务独立计时,按「上次成功发送时间 + 间隔」判断是否到点;
|
||||
* - 只在 `canOperate()`(已连接 + 已通过登录验证)时才发;
|
||||
* - **两种调度**:`intervalSeconds`(固定间隔)或 `cron`(5 段 cron 表达式,本地时间)。
|
||||
* 例如「0 9 * * *」是每天 09:00,「0/30 * * * *」是每 30 分钟(步长写法见 parseCron);
|
||||
* - **发送结果确认**(可选):配了 `expectPattern`(正则)的任务在发出后会等待服务器的
|
||||
* 回复,超时未回复记为「未确认」并写警告 —— 否则 `/u restore confirm` 到底成没成,
|
||||
* 只能靠人翻日志;
|
||||
* - 只在 `canSendChat()`(已连接 + 已通过登录验证)时才发;
|
||||
* - 未就绪时跳过本次并顺延,不补发(避免重连后一次性连发多条);
|
||||
* - 与机器人共用指令节流队列,不会绕过 `server.commandCooldownMs` 刷屏。
|
||||
*/
|
||||
@@ -31,9 +37,15 @@ class ScheduledMessenger {
|
||||
this.logger = logger || null;
|
||||
/** @type {Array<object>} 定时任务列表 */
|
||||
this.tasks = [];
|
||||
/** @type {Map<string, number>} 任务 id → 下次应发送的时间戳 */
|
||||
/** @type {Map<string, number>} 任务 id → 下次应发送的时间戳(cron 任务可能是 0=待计算) */
|
||||
this._nextDueAt = new Map();
|
||||
/** @type {Map<string, object>} 任务 id → 预编译的 cron 表达式 */
|
||||
this._parsedCron = new Map();
|
||||
/** @type {Map<string, object>} 任务 id → 待确认的发送(等服务器的回复) */
|
||||
this._pending = new Map();
|
||||
this._timer = null;
|
||||
this._onChatBound = (message) => this._onChatMessage(message);
|
||||
this._chatBound = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,18 +56,38 @@ class ScheduledMessenger {
|
||||
* @returns {Array<object>} 归一化后的任务列表
|
||||
*/
|
||||
setTasks(tasks, now) {
|
||||
const current = Number.isFinite(now) ? now : Date.now();
|
||||
this.tasks = normalizeTasks(tasks);
|
||||
this._nextDueAt.clear();
|
||||
const current = Number.isFinite(now) ? now : Date.now();
|
||||
this._parsedCron.clear();
|
||||
this._pending.clear();
|
||||
for (const task of this.tasks) {
|
||||
// 首次不立即发送:给机器人留出进入世界与登录的时间
|
||||
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
|
||||
if (task.mode === 'cron') {
|
||||
const parsed = parseCron(task.cron);
|
||||
this._parsedCron.set(task.id, parsed);
|
||||
// cron 任务按「下一次匹配时刻」调度;表达式一年内无匹配时退回间隔模式
|
||||
const next = parsed ? nextCronTime(parsed, current) : null;
|
||||
if (next === null) {
|
||||
task.mode = 'interval';
|
||||
task.cronError = '表达式一年内没有匹配时刻,已按固定间隔发送';
|
||||
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
|
||||
} else {
|
||||
this._nextDueAt.set(task.id, next);
|
||||
}
|
||||
} else {
|
||||
// 首次不立即发送:给机器人留出进入世界与登录的时间
|
||||
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
|
||||
}
|
||||
}
|
||||
return this.tasks;
|
||||
}
|
||||
|
||||
/** 启动调度心跳 */
|
||||
/** 启动调度心跳(并开始监听服务器回复,用于发送结果确认) */
|
||||
start() {
|
||||
if (!this._chatBound && typeof this.manager.on === 'function') {
|
||||
this.manager.on('chat', this._onChatBound);
|
||||
this._chatBound = true;
|
||||
}
|
||||
if (this._timer) return;
|
||||
this._timer = setInterval(() => this.tick(), TICK_INTERVAL_MS);
|
||||
if (this._timer.unref) this._timer.unref();
|
||||
@@ -65,10 +97,14 @@ class ScheduledMessenger {
|
||||
stop() {
|
||||
if (this._timer) clearInterval(this._timer);
|
||||
this._timer = null;
|
||||
if (this._chatBound && typeof this.manager.removeListener === 'function') {
|
||||
this.manager.removeListener('chat', this._onChatBound);
|
||||
this._chatBound = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 心跳:把所有到点的任务发出去。
|
||||
* 心跳:把所有到点的任务发出去,并检查待确认的发送是否超时。
|
||||
*
|
||||
* 抽成公开方法是为了可测 —— 测试里直接调 `tick()` 并注入当前时间,
|
||||
* 不必真的等 300 秒。
|
||||
@@ -78,31 +114,105 @@ class ScheduledMessenger {
|
||||
*/
|
||||
tick(now) {
|
||||
const current = Number.isFinite(now) ? now : Date.now();
|
||||
this._checkPendingConfirmations(current);
|
||||
let sent = 0;
|
||||
for (const task of this.tasks) {
|
||||
if (!task.enabled) continue;
|
||||
const due = this._nextDueAt.get(task.id);
|
||||
if (due === undefined) {
|
||||
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
|
||||
this._reschedule(task, current);
|
||||
continue;
|
||||
}
|
||||
if (current < due) continue;
|
||||
|
||||
// 未就绪就顺延到下一个周期,不补发
|
||||
if (!this.manager.canSendChat()) {
|
||||
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
|
||||
this._reschedule(task, current);
|
||||
continue;
|
||||
}
|
||||
const ok = this.manager.sendChat(task.content);
|
||||
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
|
||||
this._reschedule(task, current);
|
||||
if (ok) {
|
||||
sent++;
|
||||
task.sentCount = (task.sentCount || 0) + 1;
|
||||
this._armConfirmation(task, current);
|
||||
this._log(`定时任务「${task.label || task.id}」已发送:${task.content}`);
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 排下一次发送时间:cron 任务取下一个匹配时刻,间隔任务取 now + interval。
|
||||
* @param {object} task
|
||||
* @param {number} now
|
||||
*/
|
||||
_reschedule(task, now) {
|
||||
if (task.mode === 'cron') {
|
||||
const parsed = this._parsedCron.get(task.id);
|
||||
const next = parsed ? nextCronTime(parsed, now) : null;
|
||||
if (next !== null) {
|
||||
this._nextDueAt.set(task.id, next);
|
||||
return;
|
||||
}
|
||||
// 理论上不会发生(setTasks 时已校验),兜底退回间隔模式
|
||||
task.mode = 'interval';
|
||||
}
|
||||
this._nextDueAt.set(task.id, now + task.intervalSeconds * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登记一条「待确认」:只有配了 expectPattern 的任务才需要确认服务器是否响应。
|
||||
* @param {object} task
|
||||
* @param {number} now
|
||||
*/
|
||||
_armConfirmation(task, now) {
|
||||
if (!task.expectPattern) return;
|
||||
this._pending.set(task.id, {
|
||||
taskId: task.id,
|
||||
pattern: buildPattern(task.expectPattern),
|
||||
sentAt: now,
|
||||
deadline: now + task.confirmTimeoutSeconds * 1000
|
||||
});
|
||||
}
|
||||
|
||||
/** 待确认超时的任务记为「未确认」并写警告 */
|
||||
_checkPendingConfirmations(now) {
|
||||
if (this._pending.size === 0) return;
|
||||
for (const [taskId, pending] of Array.from(this._pending)) {
|
||||
if (now < pending.deadline) continue;
|
||||
this._pending.delete(taskId);
|
||||
const task = this.tasks.find((t) => t.id === taskId);
|
||||
if (!task) continue;
|
||||
task.lastResult = {
|
||||
at: pending.sentAt,
|
||||
ok: false,
|
||||
detail: `${task.confirmTimeoutSeconds} 秒内没有收到预期回复`
|
||||
};
|
||||
this._log(`定时任务「${task.label || task.id}」发送后 ${task.confirmTimeoutSeconds} 秒内`
|
||||
+ `没有收到预期回复(${task.expectPattern})—— 指令可能没有生效`, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到游戏消息:命中待确认任务的模式就记为「已确认生效」。
|
||||
* @param {string} message
|
||||
*/
|
||||
_onChatMessage(message) {
|
||||
if (!message || this._pending.size === 0) return;
|
||||
const text = String(message);
|
||||
for (const [taskId, pending] of Array.from(this._pending)) {
|
||||
if (!pending.pattern.test(text)) continue;
|
||||
this._pending.delete(taskId);
|
||||
const task = this.tasks.find((t) => t.id === taskId);
|
||||
if (task) {
|
||||
task.lastResult = { at: Date.now(), ok: true, detail: text.slice(0, 120) };
|
||||
this._log(`定时任务「${task.label || task.id}」已确认生效:${text.slice(0, 80)}`, 'success');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {object} 供控制台展示的状态快照 */
|
||||
snapshot() {
|
||||
const now = Date.now();
|
||||
@@ -115,14 +225,20 @@ class ScheduledMessenger {
|
||||
});
|
||||
}
|
||||
|
||||
_log(message) {
|
||||
if (this.logger) this.logger.log(`[${this.manager.connection.username}] ${message}`);
|
||||
_log(message, level) {
|
||||
if (this.logger) this.logger.log(`[${this.manager.connection.username}] ${message}`, level);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 任务归一化 ====================
|
||||
|
||||
/**
|
||||
* 归一化任务列表:丢弃非法项、补齐默认值、限制数量。
|
||||
*
|
||||
* 调度方式二选一:
|
||||
* - 写了合法的 `cron`(5 段表达式)→ cron 模式;
|
||||
* - 否则按 `intervalSeconds` 固定间隔。
|
||||
*
|
||||
* @param {Array<object>} tasks
|
||||
* @returns {Array<object>}
|
||||
*/
|
||||
@@ -139,15 +255,30 @@ function normalizeTasks(tasks) {
|
||||
if (!id || seen.has(id)) id = `task-${i + 1}`;
|
||||
while (seen.has(id)) id += '_';
|
||||
seen.add(id);
|
||||
result.push({
|
||||
const cronText = String(raw.cron || '').trim();
|
||||
const cronValid = cronText !== '' && parseCron(cronText) !== null;
|
||||
const task = {
|
||||
id,
|
||||
label: String(raw.label || '').trim(),
|
||||
content,
|
||||
enabled: raw.enabled !== false,
|
||||
/** 调度模式:cron(cron 表达式)| interval(固定间隔) */
|
||||
mode: cronValid ? 'cron' : 'interval',
|
||||
intervalSeconds: clampInterval(raw.intervalSeconds),
|
||||
cron: cronValid ? cronText : '',
|
||||
/** 期望的服务器回复(正则):用于确认发送结果,留空表示不确认 */
|
||||
expectPattern: String(raw.expectPattern || '').trim(),
|
||||
confirmTimeoutSeconds: clampConfirmTimeout(raw.confirmTimeoutSeconds),
|
||||
/** 累计发送次数与最近一次发送结果(确认成功 / 超时未确认) */
|
||||
sentCount: Number.isFinite(raw.sentCount) ? raw.sentCount : 0,
|
||||
lastResult: raw.lastResult || null,
|
||||
/** 以 `/` 开头的内容会被 sendChat 自动转成指令 */
|
||||
kind: content.startsWith('/') ? 'command' : 'chat'
|
||||
});
|
||||
};
|
||||
if (cronText !== '' && !cronValid) {
|
||||
task.cronError = `cron 表达式无效:${cronText}(已按固定间隔发送)`;
|
||||
}
|
||||
result.push(task);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -163,4 +294,162 @@ function clampInterval(value) {
|
||||
return Math.min(86400, Math.max(MIN_INTERVAL_SECONDS, Math.trunc(n)));
|
||||
}
|
||||
|
||||
module.exports = { ScheduledMessenger, normalizeTasks, clampInterval, MIN_INTERVAL_SECONDS, MAX_TASKS };
|
||||
/**
|
||||
* 把确认超时钳制到合理范围(秒)。
|
||||
* @param {*} value
|
||||
* @returns {number}
|
||||
*/
|
||||
function clampConfirmTimeout(value) {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return DEFAULT_CONFIRM_TIMEOUT_SECONDS;
|
||||
return Math.min(120, Math.max(1, Math.trunc(n)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「期望回复」编译成正则;非法正则退化为「包含该文本」。
|
||||
* 用户在这里通常写的是服务器提示语片段,两种都能命中。
|
||||
*
|
||||
* @param {string} expect
|
||||
* @returns {RegExp}
|
||||
*/
|
||||
function buildPattern(expect) {
|
||||
try {
|
||||
return new RegExp(expect);
|
||||
} catch {
|
||||
return new RegExp(escapeRegExp(expect));
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegExp(text) {
|
||||
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// ==================== cron 表达式 ====================
|
||||
|
||||
/**
|
||||
* 解析 5 段 cron 表达式:`分 时 日 月 周`(本地时间)。
|
||||
*
|
||||
* 支持 `*`、`星号/步长`、`a-b`、`a,b,c` 与数字;周为 0-6(0 = 周日)。
|
||||
* 刻意不支持月份 / 星期的英文名,也不支持秒级与 @daily 这类别名 ——
|
||||
* 面板上的输入框写不下更多,够用即可。
|
||||
*
|
||||
* @param {string} expression
|
||||
* @returns {{minutes: number[], hours: number[], daysOfMonth: number[], months: number[], daysOfWeek: number[]}|null}
|
||||
*/
|
||||
function parseCron(expression) {
|
||||
const parts = String(expression || '').trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
const minutes = parseCronField(parts[0], 0, 59);
|
||||
const hours = parseCronField(parts[1], 0, 23);
|
||||
const daysOfMonth = parseCronField(parts[2], 1, 31);
|
||||
const months = parseCronField(parts[3], 1, 12);
|
||||
const daysOfWeek = parseCronField(parts[4], 0, 6);
|
||||
if (!minutes || !hours || !daysOfMonth || !months || !daysOfWeek) return null;
|
||||
return { minutes, hours, daysOfMonth, months, daysOfWeek };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析单个字段。
|
||||
* @param {string} field
|
||||
* @param {number} min
|
||||
* @param {number} max
|
||||
* @returns {number[]|null} 升序的取值列表;非法返回 null
|
||||
*/
|
||||
function parseCronField(field, min, max) {
|
||||
const values = new Set();
|
||||
for (const part of String(field).split(',')) {
|
||||
const piece = part.trim();
|
||||
if (!piece) return null;
|
||||
let range = piece;
|
||||
let step = 1;
|
||||
const stepMatch = /^(.+)\/(\d+)$/.exec(piece);
|
||||
if (stepMatch) {
|
||||
range = stepMatch[1];
|
||||
step = Number(stepMatch[2]);
|
||||
if (!Number.isInteger(step) || step < 1) return null;
|
||||
}
|
||||
let from;
|
||||
let to;
|
||||
if (range === '*') {
|
||||
from = min;
|
||||
to = max;
|
||||
} else if (range.includes('-')) {
|
||||
const bounds = range.split('-').map((value) => Number(value));
|
||||
from = bounds[0];
|
||||
to = bounds[1];
|
||||
if (!Number.isInteger(from) || !Number.isInteger(to) || from < min || to > max || from > to) return null;
|
||||
} else {
|
||||
const value = Number(range);
|
||||
if (!Number.isInteger(value) || value < min || value > max) return null;
|
||||
from = value;
|
||||
// 「起点/步长」写法(如分钟位 0/30 = 每 30 分钟)按「从起点到字段上限」展开,
|
||||
// 与主流 cron 实现一致;不带步长时就是个单值。
|
||||
to = step > 1 ? max : value;
|
||||
}
|
||||
for (let value = from; value <= to; value += step) values.add(value);
|
||||
}
|
||||
return values.size > 0 ? Array.from(values).sort((a, b) => a - b) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 某一天是否匹配「日 / 月 / 周」三个字段。
|
||||
*
|
||||
* 与标准 cron 一致:日与周都不是 `*` 时取**并集**(任一匹配即算匹配)。
|
||||
*
|
||||
* @param {object} parsed
|
||||
* @param {Date} date
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function cronDayMatches(parsed, date) {
|
||||
if (!parsed.months.includes(date.getMonth() + 1)) return false;
|
||||
const domAll = parsed.daysOfMonth.length === 31;
|
||||
const dowAll = parsed.daysOfWeek.length === 7;
|
||||
const domMatch = parsed.daysOfMonth.includes(date.getDate());
|
||||
const dowMatch = parsed.daysOfWeek.includes(date.getDay());
|
||||
if (domAll && dowAll) return true;
|
||||
if (domAll) return dowMatch;
|
||||
if (dowAll) return domMatch;
|
||||
return domMatch || dowMatch;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算下一次匹配时刻。
|
||||
*
|
||||
* 按天向前扫描(最多 4 年,覆盖 2 月 29 日这类稀疏表达式),
|
||||
* 命中后再取当天第一个晚于 `fromMillis` 的「时:分」。
|
||||
*
|
||||
* @param {object} parsed parseCron 的结果
|
||||
* @param {number} fromMillis
|
||||
* @returns {number|null} 下一次匹配的时间戳;找不到返回 null
|
||||
*/
|
||||
function nextCronTime(parsed, fromMillis) {
|
||||
if (!parsed) return null;
|
||||
const start = new Date(fromMillis);
|
||||
const day = new Date(start.getFullYear(), start.getMonth(), start.getDate(), 0, 0, 0, 0);
|
||||
for (let i = 0; i < 366 * 4; i++) {
|
||||
if (cronDayMatches(parsed, day)) {
|
||||
for (const hour of parsed.hours) {
|
||||
for (const minute of parsed.minutes) {
|
||||
const candidate = new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour, minute, 0, 0);
|
||||
if (candidate.getTime() > fromMillis) return candidate.getTime();
|
||||
}
|
||||
}
|
||||
}
|
||||
day.setDate(day.getDate() + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ScheduledMessenger,
|
||||
normalizeTasks,
|
||||
clampInterval,
|
||||
clampConfirmTimeout,
|
||||
parseCron,
|
||||
nextCronTime,
|
||||
cronDayMatches,
|
||||
buildPattern,
|
||||
MIN_INTERVAL_SECONDS,
|
||||
MAX_TASKS,
|
||||
DEFAULT_CONFIRM_TIMEOUT_SECONDS
|
||||
};
|
||||
@@ -174,7 +174,17 @@ function defaultConfig() {
|
||||
* A* 的代价搜索半径:节点代价超过「起点到目标的启发值 + 该值」就被剪枝。
|
||||
* 负数表示不限制(复杂地形里最稳,但搜索更慢)。
|
||||
*/
|
||||
searchRadius: 256
|
||||
searchRadius: 256,
|
||||
/**
|
||||
* 寻路执行器:
|
||||
* - `pathfinder`(默认):由 mineflayer-pathfinder 自己执行;
|
||||
* - `manual`:**备选方案**(TODO 问题 1)。只用 A* 算路径,执行改为自己按键
|
||||
* (朝路径点转 → 按住前进 → 遇坎跳),控制键时序与手动移动测试完全一致。
|
||||
* 仅在实测确认 pathfinder 的执行层被服务端回弹时才需要切换。
|
||||
*/
|
||||
executor: 'pathfinder',
|
||||
/** 手动执行器单段执行的超时(毫秒),防止卡死时无限按键 */
|
||||
manualTimeoutMs: 180000
|
||||
},
|
||||
|
||||
// ==================== 战斗辅助(新增) ====================
|
||||
@@ -226,6 +236,30 @@ function defaultConfig() {
|
||||
* 开启后发包前会用脚下方块复核,只有确实不一致时才改写。
|
||||
*/
|
||||
reportOnGroundFromWorld: true,
|
||||
/**
|
||||
* 【方案 A】是否把物理引擎的 onGround 与本地世界数据对齐(默认开启)。
|
||||
*
|
||||
* 服务端频繁下发 `position` 包时,mineflayer 会把 `entity.onGround` 置为 false,
|
||||
* 物理引擎因此一直走「空中」分支(水平加速只有地面的 1/5 且每 tick 被清零),
|
||||
* 表现就是「按着前进键、水平速度恒为 0」。开启后每个物理 tick 都会在
|
||||
* 「物理引擎算完、位置包发出之前」用脚下方块复核,必要时强制同步 onGround,
|
||||
* 让移动回到地面分支。与 `grim.enabled` 无关(实测关闭合规层后回弹依旧)。
|
||||
*/
|
||||
syncGroundToPhysics: true,
|
||||
/**
|
||||
* 【方案 B】是否忽略「例行同步」位置包带来的 onGround=false(默认开启)。
|
||||
*
|
||||
* 服务端下发的目标位置与本地位置几乎一致时,说明它只是例行同步坐标,
|
||||
* 并不代表我们真的在空中;此时只采纳坐标、保留我们自己的 onGround,
|
||||
* 避免物理引擎的地面状态被服务端反复打断。
|
||||
* 位置差超过 `setbackSyncEpsilon` 时按真正的传送处理,不做任何干预。
|
||||
*/
|
||||
trustSetbackOnGround: true,
|
||||
/**
|
||||
* 方案 B 的判定阈值(格):服务端目标位置与本地位置之差不超过它时视为「例行同步」。
|
||||
* 实测可在控制台观察 `packetStats.lastSetbackDelta.distance` 后再调整。
|
||||
*/
|
||||
setbackSyncEpsilon: 0.5,
|
||||
/**
|
||||
* 是否每 tick 补发 `player_input` 包(默认开启,**线上服务器必需**)。
|
||||
*
|
||||
@@ -271,7 +305,51 @@ function defaultConfig() {
|
||||
/** 启动时是否自动预爬取 */
|
||||
crawlOnStart: true,
|
||||
/** 瓦片下载并发数(对源站友好,默认 4) */
|
||||
concurrency: 4
|
||||
concurrency: 4,
|
||||
/**
|
||||
* 是否后台预热高层级瓦片(默认开启)。
|
||||
*
|
||||
* 只有「启动预爬取层级」(默认 0)会批量爬,层级 1~3 原本要靠用户缩放时按需拉取,
|
||||
* 首次浏览要等好几秒。开启后机器人移动到新区域时会顺带把周边高层级瓦片拉下来。
|
||||
*/
|
||||
prewarm: true,
|
||||
/** 预热覆盖半径(方块),以机器人为中心 */
|
||||
prewarmRangeBlocks: 1024,
|
||||
/** 从「启动预爬取层级」往上预热几层(受「最大可浏览层级」限制) */
|
||||
prewarmLevels: 3,
|
||||
/** 机器人移动超过该距离(方块)才重新预热,避免频繁请求源站 */
|
||||
prewarmMinMoveBlocks: 512,
|
||||
/** 距上次预热超过该时间(毫秒)也重新预热一次(默认 10 分钟) */
|
||||
prewarmIntervalMs: 600000
|
||||
},
|
||||
|
||||
// ==================== 多机器人协调(新增) ====================
|
||||
coordination: {
|
||||
/**
|
||||
* 目标区块租约时长(秒)。
|
||||
*
|
||||
* 多个机器人同时扩地时,都用同一份领土模型挑候选区块,很容易撞在同一块上。
|
||||
* 租约表让「谁要去占哪一块」互相可见:抢到的机器人独占该区块,其余机器人跳过;
|
||||
* 持有者持续续租,机器人掉线 / 卡死后租约到期自动释放,不会永久锁死区块。
|
||||
*/
|
||||
targetLeaseTtlSeconds: 180
|
||||
},
|
||||
|
||||
// ==================== 通知提醒(新增) ====================
|
||||
notify: {
|
||||
/**
|
||||
* 是否启用 Webhook 通知(默认关闭)。
|
||||
*
|
||||
* 需要人工介入时(等待登录验证码、原点自救失败)把消息推到飞书 / Lark 自定义机器人,
|
||||
* 不用一直盯着控制台 —— 验证码只有 120 秒时限,错过就得等下次重登。
|
||||
*/
|
||||
enabled: false,
|
||||
/** Webhook 地址(留空时回退环境变量 NOTIFY_WEBHOOK_URL) */
|
||||
webhookUrl: '',
|
||||
/** 服务器要求输入验证码时提醒 */
|
||||
onVerification: true,
|
||||
/** 原点自救失败时提醒(说明可能其实是登录服,需要人工发验证码) */
|
||||
onOriginRescueFailed: true
|
||||
},
|
||||
|
||||
// ==================== 服务器交互(新增) ====================
|
||||
@@ -420,11 +498,15 @@ function validateConfig(config) {
|
||||
config.pathing.tickTimeoutMs = clampInt(config.pathing.tickTimeoutMs, 5, 5000, 40);
|
||||
// searchRadius 允许为负(不限制),因此单独钳制下限
|
||||
config.pathing.searchRadius = clampInt(config.pathing.searchRadius, -1, 100000, 256);
|
||||
if (config.pathing.executor !== 'manual') config.pathing.executor = 'pathfinder';
|
||||
config.pathing.manualTimeoutMs = clampInt(config.pathing.manualTimeoutMs, 10000, 3600000, 180000);
|
||||
|
||||
config.combat.scanRange = clampNumber(config.combat.scanRange, 1, 32, 4.5);
|
||||
config.coordination.targetLeaseTtlSeconds = clampInt(config.coordination.targetLeaseTtlSeconds, 30, 3600, 180);
|
||||
|
||||
config.grim.maxRotationPerTickDeg = clampNumber(config.grim.maxRotationPerTickDeg, 1, 180, 8.6);
|
||||
config.grim.attackReach = clampNumber(config.grim.attackReach, 1, 6, 3.0);
|
||||
config.grim.setbackSyncEpsilon = clampNumber(config.grim.setbackSyncEpsilon, 0, 5, 0.5);
|
||||
|
||||
config.bigMap.zoom = clampInt(config.bigMap.zoom, 0, 8, 0);
|
||||
config.bigMap.maxZoom = clampInt(config.bigMap.maxZoom, 0, 8, 3);
|
||||
@@ -432,6 +514,10 @@ function validateConfig(config) {
|
||||
if (config.bigMap.zoom > config.bigMap.maxZoom) config.bigMap.zoom = config.bigMap.maxZoom;
|
||||
config.bigMap.rangeBlocks = clampInt(config.bigMap.rangeBlocks, 256, 200000, 4096);
|
||||
config.bigMap.concurrency = clampInt(config.bigMap.concurrency, 1, 16, 4);
|
||||
config.bigMap.prewarmRangeBlocks = clampInt(config.bigMap.prewarmRangeBlocks, 256, 200000, 1024);
|
||||
config.bigMap.prewarmLevels = clampInt(config.bigMap.prewarmLevels, 0, 8, 3);
|
||||
config.bigMap.prewarmMinMoveBlocks = clampInt(config.bigMap.prewarmMinMoveBlocks, 0, 100000, 512);
|
||||
config.bigMap.prewarmIntervalMs = clampInt(config.bigMap.prewarmIntervalMs, 60000, 86400000, 600000);
|
||||
if (typeof config.bigMap.world !== 'string') config.bigMap.world = '';
|
||||
if (typeof config.bigMap.followBot !== 'boolean') config.bigMap.followBot = true;
|
||||
|
||||
@@ -454,10 +540,11 @@ function validateConfig(config) {
|
||||
}
|
||||
if (typeof config.stateName !== 'string') config.stateName = '';
|
||||
if (typeof config.apiKey !== 'string') config.apiKey = '';
|
||||
if (typeof config.apiV2Key !== 'string') config.apiV2Key = '';
|
||||
if (typeof config.server.claimCommand !== 'string') config.server.claimCommand = '';
|
||||
if (typeof config.server.loginCommand !== 'string') config.server.loginCommand = '';
|
||||
if (typeof config.server.registerCommand !== 'string') config.server.registerCommand = '';
|
||||
if (typeof config.server.authCommand !== 'string') config.server.authCommand = '';
|
||||
if (typeof config.notify.webhookUrl !== 'string') config.notify.webhookUrl = '';
|
||||
return config;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+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)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 通知提醒(Webhook):需要人工介入时把消息推到飞书机器人等渠道。
|
||||
*
|
||||
* 服务器只给 120 秒输入登录验证码,错过就要等下一次重登 —— 这是整个机器人流程里
|
||||
* 几乎唯一必须人工介入的环节(另一个是「原点自救失败」说明可能被切到了登录服),
|
||||
* 因此值得在手机上也收到提醒,而不是只写在日志里等人翻。
|
||||
*
|
||||
* 实现刻意做到最小:POST 一个 JSON,默认按飞书 / Lark 自定义机器人的文本消息格式
|
||||
* (`{"msgtype":"text","content":{"text":"…"}}`)—— 在群聊里加一个自定义机器人
|
||||
* 就能拿到 Webhook 地址,零依赖。
|
||||
*/
|
||||
|
||||
/** 同一 key 的最小提醒间隔(毫秒):重连循环里同一个问题只提醒一次 */
|
||||
const DEFAULT_MIN_INTERVAL_MS = 60000;
|
||||
/** 单次请求超时(毫秒) */
|
||||
const REQUEST_TIMEOUT_MS = 10000;
|
||||
|
||||
class Notifier {
|
||||
/**
|
||||
* @param {object} config 完整配置对象
|
||||
* @param {import('../util/logger').Logger} [logger]
|
||||
* @param {{fetchImpl?: Function, minIntervalMs?: number}} [options]
|
||||
*/
|
||||
constructor(config, logger, options) {
|
||||
const opts = options || {};
|
||||
this.config = config;
|
||||
this.logger = logger || null;
|
||||
this.fetchImpl = opts.fetchImpl || globalThis.fetch;
|
||||
this.minIntervalMs = Number.isFinite(opts.minIntervalMs) ? opts.minIntervalMs : DEFAULT_MIN_INTERVAL_MS;
|
||||
/** @type {Map<string, number>} 去重 key → 上次发送时间 */
|
||||
this._lastSentAt = new Map();
|
||||
this.sent = 0;
|
||||
this.failed = 0;
|
||||
this.lastError = '';
|
||||
this.lastSentAt = 0;
|
||||
}
|
||||
|
||||
/** @returns {string} Webhook 地址(配置优先,其次环境变量 NOTIFY_WEBHOOK_URL) */
|
||||
get webhookUrl() {
|
||||
const cfg = (this.config && this.config.notify) || {};
|
||||
const configured = typeof cfg.webhookUrl === 'string' ? cfg.webhookUrl.trim() : '';
|
||||
if (configured) return configured;
|
||||
return String(process.env.NOTIFY_WEBHOOK_URL || '').trim();
|
||||
}
|
||||
|
||||
/** @returns {boolean} 是否已开启且配了地址 */
|
||||
isEnabled() {
|
||||
const cfg = (this.config && this.config.notify) || {};
|
||||
return cfg.enabled === true && this.webhookUrl !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送一条提醒。
|
||||
*
|
||||
* @param {string} text 正文
|
||||
* @param {{key?: string, force?: boolean}} [options]
|
||||
* key 用于去重:同一个 key 在 `minIntervalMs` 内只会发一次(force 可跳过去重)
|
||||
* @returns {Promise<boolean>} 是否成功发出
|
||||
*/
|
||||
async notify(text, options) {
|
||||
const opts = options || {};
|
||||
if (!this.isEnabled()) return false;
|
||||
const key = opts.key || 'default';
|
||||
const now = Date.now();
|
||||
const last = this._lastSentAt.get(key);
|
||||
if (opts.force !== true && Number.isFinite(last) && now - last < this.minIntervalMs) {
|
||||
return false; // 刚提醒过同一个问题,不重复打扰
|
||||
}
|
||||
this._lastSentAt.set(key, now);
|
||||
const payload = {
|
||||
msgtype: 'text',
|
||||
content: { text: String(text) }
|
||||
};
|
||||
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
const timer = controller ? setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) : null;
|
||||
try {
|
||||
const response = await this.fetchImpl(this.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller ? controller.signal : undefined
|
||||
});
|
||||
if (!response || response.ok !== true) {
|
||||
this.failed++;
|
||||
this.lastError = `HTTP ${response ? response.status : '???'}`;
|
||||
this._log('通知发送失败:' + this.lastError, 'warn');
|
||||
return false;
|
||||
}
|
||||
this.sent++;
|
||||
this.lastSentAt = Date.now();
|
||||
this.lastError = '';
|
||||
this._log('已发送通知提醒:' + firstLine(text));
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.failed++;
|
||||
this.lastError = err && err.message ? err.message : String(err);
|
||||
this._log('通知发送异常:' + this.lastError, 'warn');
|
||||
return false;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {object} 供控制台展示的状态(不暴露完整 Webhook 地址) */
|
||||
snapshot() {
|
||||
const url = this.webhookUrl;
|
||||
return {
|
||||
enabled: this.isEnabled(),
|
||||
configured: url !== '',
|
||||
/** 只显示域名部分,避免把带凭证的完整 Webhook 打进接口响应 */
|
||||
host: url ? safeHost(url) : '',
|
||||
sent: this.sent,
|
||||
failed: this.failed,
|
||||
lastError: this.lastError,
|
||||
lastSentAt: this.lastSentAt
|
||||
};
|
||||
}
|
||||
|
||||
_log(message, level) {
|
||||
if (this.logger) this.logger.log(message, level);
|
||||
}
|
||||
}
|
||||
|
||||
/** 取 URL 的域名(拿不到就返回空串) */
|
||||
function safeHost(url) {
|
||||
try {
|
||||
return new URL(url).host;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** 取多行文本的第一行(日志里只记一行摘要) */
|
||||
function firstLine(text) {
|
||||
return String(text).split('\n')[0].slice(0, 120);
|
||||
}
|
||||
|
||||
module.exports = { Notifier, DEFAULT_MIN_INTERVAL_MS };
|
||||
@@ -0,0 +1,160 @@
|
||||
'use strict';
|
||||
|
||||
const chunkPos = require('./chunkPos');
|
||||
|
||||
/**
|
||||
* 目标区块租约表(多机器人协调用)。
|
||||
*
|
||||
* 背景(TODO.md 4.3):领土模型是全局共享的,但「谁要去占哪一块」只存在各机器人
|
||||
* 自己的队列里,彼此看不见 —— 多个机器人同时扩地时很容易撞在同一批候选区块上:
|
||||
* 两个机器人同时跑到同一区块、只有一个能开垦成功,另一半时间全白花。
|
||||
*
|
||||
* 这里给「目标区块」加一张带租约的占用表:
|
||||
* - **抢占**:`tryAcquire` 是「检查 + 写入」的原子操作,谁先抢到谁去;
|
||||
* - **租约**:占用有有效期(默认 3 分钟),持有者持续续租;机器人掉线 / 卡死时
|
||||
* 租约到期自动失效,区块不会被永久锁死;
|
||||
* - **释放**:开垦成功 / 放弃目标 / 停止任务时主动释放,其他机器人立刻可用。
|
||||
*
|
||||
* 同一 Node 进程内管理所有机器人,因此不需要真正的分布式锁,
|
||||
* 一张内存表就足以覆盖「同进程多机器人」这个实际场景。
|
||||
*/
|
||||
|
||||
/** 默认租约时长(毫秒) */
|
||||
const DEFAULT_TTL_MS = 180000;
|
||||
|
||||
class TargetLeaseTable {
|
||||
/**
|
||||
* @param {{ttlMs?: number}} [options]
|
||||
*/
|
||||
constructor(options) {
|
||||
const opts = options || {};
|
||||
this.ttlMs = Number.isFinite(opts.ttlMs) && opts.ttlMs > 0 ? opts.ttlMs : DEFAULT_TTL_MS;
|
||||
/** @type {Map<string, {botId: string, expiresAt: number, acquiredAt: number}>} */
|
||||
this._leases = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试占用一个区块(同一个机器人可重复占用 / 续租)。
|
||||
*
|
||||
* @param {{x: number, z: number}} chunk
|
||||
* @param {string} botId
|
||||
* @param {number} [ttlMs] 本次租约时长,缺省取表上的 ttlMs
|
||||
* @returns {boolean} 是否占用成功(已被其他机器人占用时返回 false)
|
||||
*/
|
||||
tryAcquire(chunk, botId, ttlMs) {
|
||||
if (!chunk) return false;
|
||||
const key = chunkPos.key(chunk.x, chunk.z);
|
||||
const now = Date.now();
|
||||
const existing = this._leases.get(key);
|
||||
if (existing && existing.expiresAt > now && existing.botId !== botId) return false;
|
||||
const ttl = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : this.ttlMs;
|
||||
this._leases.set(key, {
|
||||
botId,
|
||||
expiresAt: now + ttl,
|
||||
acquiredAt: existing && existing.botId === botId ? existing.acquiredAt : now
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 续租(只有当前持有者能续)。
|
||||
* @param {{x: number, z: number}} chunk
|
||||
* @param {string} botId
|
||||
* @param {number} [ttlMs]
|
||||
* @returns {boolean} 是否续租成功
|
||||
*/
|
||||
renew(chunk, botId, ttlMs) {
|
||||
if (!chunk) return false;
|
||||
const key = chunkPos.key(chunk.x, chunk.z);
|
||||
const existing = this._leases.get(key);
|
||||
if (!existing || existing.botId !== botId) return false;
|
||||
const ttl = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : this.ttlMs;
|
||||
existing.expiresAt = Date.now() + ttl;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放占用(只有当前持有者能释放;已过期的占用直接清掉)。
|
||||
* @param {{x: number, z: number}} chunk
|
||||
* @param {string} botId
|
||||
* @returns {boolean} 是否释放了一个占用
|
||||
*/
|
||||
release(chunk, botId) {
|
||||
if (!chunk) return false;
|
||||
const key = chunkPos.key(chunk.x, chunk.z);
|
||||
const existing = this._leases.get(key);
|
||||
if (!existing) return false;
|
||||
if (existing.botId !== botId) return false;
|
||||
this._leases.delete(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放某个机器人的全部占用(停止任务 / 断线 / 退出时调用)。
|
||||
* @param {string} botId
|
||||
* @returns {number} 释放的数量
|
||||
*/
|
||||
releaseAll(botId) {
|
||||
let released = 0;
|
||||
for (const [key, lease] of this._leases) {
|
||||
if (lease.botId === botId) {
|
||||
this._leases.delete(key);
|
||||
released++;
|
||||
}
|
||||
}
|
||||
return released;
|
||||
}
|
||||
|
||||
/**
|
||||
* 区块是否被**其他**机器人占用(已过期的占用视为空闲,顺手清理)。
|
||||
* @param {{x: number, z: number}} chunk
|
||||
* @param {string} botId 自己
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isLeasedByOther(chunk, botId) {
|
||||
if (!chunk) return false;
|
||||
const key = chunkPos.key(chunk.x, chunk.z);
|
||||
const lease = this._leases.get(key);
|
||||
if (!lease) return false;
|
||||
if (lease.expiresAt <= Date.now()) {
|
||||
this._leases.delete(key);
|
||||
return false;
|
||||
}
|
||||
return lease.botId !== botId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期租约。
|
||||
* @returns {number} 清理掉的数量
|
||||
*/
|
||||
sweep() {
|
||||
const now = Date.now();
|
||||
let removed = 0;
|
||||
for (const [key, lease] of this._leases) {
|
||||
if (lease.expiresAt <= now) {
|
||||
this._leases.delete(key);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
/** @returns {number} 当前有效占用数量 */
|
||||
get size() {
|
||||
this.sweep();
|
||||
return this._leases.size;
|
||||
}
|
||||
|
||||
/** @returns {Array<{chunk: string, botId: string, remainingMs: number}>} 供控制台展示 */
|
||||
snapshot() {
|
||||
const now = Date.now();
|
||||
const rows = [];
|
||||
for (const [key, lease] of this._leases) {
|
||||
if (lease.expiresAt <= now) continue;
|
||||
rows.push({ chunk: key, botId: lease.botId, remainingMs: lease.expiresAt - now });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { TargetLeaseTable, DEFAULT_TTL_MS };
|
||||
+7
-1
@@ -16,6 +16,7 @@ const { TerritoryDataService } = require('./core/territoryDataService');
|
||||
const { ExpansionLog } = require('./core/expansionLog');
|
||||
const { ChatLog } = require('./core/chatLog');
|
||||
const { BigMapService } = require('./core/bigMapService');
|
||||
const { Notifier } = require('./core/notifier');
|
||||
const { WebConsole } = require('./web/server');
|
||||
|
||||
/** 控制器 tick 间隔(毫秒),50ms ≈ 20 TPS */
|
||||
@@ -58,6 +59,8 @@ async function main() {
|
||||
const botStore = new BotStore();
|
||||
// 控制台新增玩家的凭据单独落盘(0600),与 .env 互为补充、.env 优先
|
||||
const credentialStore = new BotCredentialStore();
|
||||
// 通知提醒(Webhook):等待验证码 / 原点自救失败时把消息推到飞书等渠道
|
||||
const notifier = new Notifier(config, logger);
|
||||
const pool = new BotPool({
|
||||
connections,
|
||||
config,
|
||||
@@ -66,7 +69,8 @@ async function main() {
|
||||
expansionLog,
|
||||
chatLog,
|
||||
botStore,
|
||||
credentialStore
|
||||
credentialStore,
|
||||
notifier
|
||||
});
|
||||
|
||||
// 账号来源有两个:.env 的 MC_BOTS / MC_USERNAME,以及控制台「添加玩家」写入的本地凭据文件
|
||||
@@ -86,6 +90,7 @@ async function main() {
|
||||
expansionLog,
|
||||
chatLog,
|
||||
bigMap,
|
||||
notifier,
|
||||
getConfig: () => config,
|
||||
applyConfig: (next) => applyConfig(next)
|
||||
},
|
||||
@@ -115,6 +120,7 @@ async function main() {
|
||||
bigMap.config = config;
|
||||
bigMap.world = config.bigMap.world || process.env.DYNMAP_WORLD || bigMap.world;
|
||||
}
|
||||
notifier.config = config;
|
||||
logger.log('配置已更新并应用');
|
||||
return config;
|
||||
}
|
||||
|
||||
+200
-11
@@ -160,6 +160,18 @@ const CONFIG_SCHEMA = {
|
||||
{ path: 'regionCenterZ', label: '中心 Z(区块)', type: 'number' },
|
||||
{ path: 'regionRadius', label: '半径(区块)', type: 'number', min: 1 }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '多机器人协调',
|
||||
fields: [
|
||||
{
|
||||
path: 'coordination.targetLeaseTtlSeconds',
|
||||
label: '目标区块租约时长(秒)',
|
||||
type: 'number',
|
||||
min: 30,
|
||||
hint: '多个机器人同时扩地时用租约避免撞在同一区块上;持有者持续续租,掉线 / 卡死后租约到期自动释放'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
timing: [
|
||||
@@ -218,7 +230,12 @@ const CONFIG_SCHEMA = {
|
||||
{ path: 'pathing.liquidCost', label: '液体格额外代价', type: 'number', min: 1 },
|
||||
{ path: 'pathing.canOpenDoors', label: '允许开门', type: 'checkbox' },
|
||||
{ path: 'pathing.maxSegmentBlocks', label: '单段寻路最长距离(格)', type: 'number', min: 32, hint: '寻路器只能规划已加载区块范围(本服约 130 格),长距离会自动拆段;此项只是单段上限' },
|
||||
{ path: 'pathing.segmentMarginBlocks', label: '分段安全边距(格)', type: 'number', min: 0, hint: '分段时距已加载范围边缘留出的余量,避免踩在未加载边界上' }
|
||||
{ path: 'pathing.segmentMarginBlocks', label: '分段安全边距(格)', type: 'number', min: 0, hint: '分段时距已加载范围边缘留出的余量,避免踩在未加载边界上' },
|
||||
{ path: 'pathing.executor', label: '寻路执行器', type: 'select', options: [
|
||||
{ value: 'pathfinder', label: 'pathfinder(默认)' },
|
||||
{ value: 'manual', label: '手动执行(备选方案)' }
|
||||
], hint: '手动执行:只用 A* 算路径,执行改为自己按键(与手动移动测试一致的时序);仅在实测确认寻路器执行层被服务端回弹时才切换' },
|
||||
{ path: 'pathing.manualTimeoutMs', label: '手动执行超时(毫秒)', type: 'number', min: 10000, hint: '单段手动执行的最长时间,防止卡死时无限按键' }
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -282,7 +299,12 @@ const CONFIG_SCHEMA = {
|
||||
{ path: 'bigMap.maxZoom', label: '最大可浏览层级', type: 'number', min: 0, max: 8, hint: '本站 squaremap 为 3(该层级 1 方块 = 1 像素)' },
|
||||
{ path: 'bigMap.rangeBlocks', label: '覆盖半径(方块)', type: 'number', min: 256, hint: '默认 20480,与服务器世界边界一致' },
|
||||
{ path: 'bigMap.crawlOnStart', label: '启动时自动预爬取', type: 'checkbox' },
|
||||
{ path: 'bigMap.concurrency', label: '瓦片下载并发数', type: 'number', min: 1, max: 16 }
|
||||
{ path: 'bigMap.concurrency', label: '瓦片下载并发数', type: 'number', min: 1, max: 16 },
|
||||
{ path: 'bigMap.prewarm', label: '后台预热高层级瓦片', type: 'checkbox', hint: '机器人移动到新区域时低优先级拉取周边高层级瓦片,避免首次缩放时干等;已缓存的不重复下载' },
|
||||
{ path: 'bigMap.prewarmRangeBlocks', label: '预热半径(方块)', type: 'number', min: 256 },
|
||||
{ path: 'bigMap.prewarmLevels', label: '预热层级数', type: 'number', min: 0, max: 8, hint: '从「启动预爬取层级」往上数几层(受最大可浏览层级限制)' },
|
||||
{ path: 'bigMap.prewarmMinMoveBlocks', label: '移动多少格后重新预热', type: 'number', min: 0 },
|
||||
{ path: 'bigMap.prewarmIntervalMs', label: '预热最小间隔(毫秒)', type: 'number', min: 60000, hint: '即便没怎么移动,超过这个时间也会再预热一次' }
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -301,6 +323,9 @@ const CONFIG_SCHEMA = {
|
||||
{ path: 'grim.checkPhysicsIntegrity', label: '启动物理参数自检', type: 'checkbox' },
|
||||
{ path: 'grim.blockActionsWhileRiding', label: '骑乘时禁止攻击 / 进食', type: 'checkbox' },
|
||||
{ path: 'grim.reportOnGroundFromWorld', label: '按世界数据修正 onGround', type: 'checkbox', hint: '服务端频繁下发位置包时,mineflayer 会持续上报「站地上却在空中」而被反作弊每 tick 拉回;开启后在发包前用脚下方块复核(实测本服必需)' },
|
||||
{ path: 'grim.syncGroundToPhysics', label: '物理引擎 onGround 对齐(方案 A)', type: 'checkbox', hint: '每个物理 tick 用世界数据复核脚下是否有方块,是则把物理引擎的 onGround 同步为 true,让移动走地面分支(TODO 问题 1 的疑似根因)' },
|
||||
{ path: 'grim.trustSetbackOnGround', label: '不采纳例行同步的 onGround(方案 B)', type: 'checkbox', hint: '服务端下发的位置与本地几乎一致时,只同步坐标、不采纳它的「在空中」,避免物理引擎的地面状态被反复打断' },
|
||||
{ path: 'grim.setbackSyncEpsilon', label: '方案 B 判定阈值(格)', type: 'number', step: 0.05, min: 0, max: 5, hint: '位置差不超过它时视为例行同步;超过则按真正的传送处理。先用默认值实测,再看状态卡里的「最近同步差」调整' },
|
||||
{ path: 'grim.sendPlayerInput', label: '每 tick 补发 player_input', type: 'checkbox', hint: '1.21.2+ 移动预测依赖它;mineflayer 地面移动时完全不发,导致反作弊判定「无输入却位移」并把机器人每 tick 拉回(实测本服必需)' },
|
||||
{ path: 'grim.sendTickEnd', label: '每 tick 补发 tick_end', type: 'checkbox', hint: '原版 1.21.2+ 客户端每 tick 末尾发送,反作弊用它判定 tick 边界' }
|
||||
]
|
||||
@@ -322,6 +347,15 @@ const CONFIG_SCHEMA = {
|
||||
{ path: 'server.reconnectDelayMs', label: '重连间隔(毫秒)', type: 'number', min: 1000 },
|
||||
{ path: 'server.reconnectMaxDelayMs', label: '重连间隔上限(毫秒)', type: 'number', min: 1000, hint: '连续重连失败时按指数退避,最多退到这个间隔' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '通知提醒(Webhook)',
|
||||
fields: [
|
||||
{ path: 'notify.enabled', label: '启用通知', type: 'checkbox', hint: '需要人工介入时(等待验证码 / 原点自救失败)推送到飞书等自定义机器人,不用一直盯控制台' },
|
||||
{ path: 'notify.webhookUrl', label: 'Webhook 地址', type: 'password', hint: '飞书 / Lark 自定义机器人的 Webhook;留空回退环境变量 NOTIFY_WEBHOOK_URL' },
|
||||
{ path: 'notify.onVerification', label: '等待验证码时提醒', type: 'checkbox', hint: '验证码只有 120 秒时限,错过要等下次重登' },
|
||||
{ path: 'notify.onOriginRescueFailed', label: '原点自救失败时提醒', type: 'checkbox', hint: '自救失败多半说明被切到了登录服,需要人工发验证码' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
@@ -442,11 +476,41 @@ function renderConfig() {
|
||||
body.appendChild(buildField(field));
|
||||
}
|
||||
}
|
||||
filterConfigFields($('configSearch') ? $('configSearch').value : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 按关键词过滤配置项(8 个标签页、字段很多,逐个找很费劲)。
|
||||
*
|
||||
* 只隐藏不删除:隐藏的输入仍然留在 DOM 里,保存时 `collectConfig` 依旧能读到它们的值。
|
||||
*
|
||||
* @param {string} query 搜索词(名称 / 字段名 / 说明,大小写不敏感)
|
||||
*/
|
||||
function filterConfigFields(query) {
|
||||
const body = $('configBody');
|
||||
if (!body) return;
|
||||
const keyword = String(query || '').trim().toLowerCase();
|
||||
let lastTitle = null;
|
||||
let titleHasVisible = false;
|
||||
for (const child of Array.from(body.children)) {
|
||||
if (child.classList.contains('group-title')) {
|
||||
if (lastTitle) lastTitle.classList.toggle('hidden', !titleHasVisible);
|
||||
lastTitle = child;
|
||||
titleHasVisible = false;
|
||||
continue;
|
||||
}
|
||||
const match = !keyword || (child.dataset.search || '').includes(keyword);
|
||||
child.classList.toggle('hidden', !match);
|
||||
if (match) titleHasVisible = true;
|
||||
}
|
||||
if (lastTitle) lastTitle.classList.toggle('hidden', !titleHasVisible);
|
||||
}
|
||||
|
||||
function buildField(field) {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'field';
|
||||
// 供配置搜索使用:名称 + 字段路径 + 说明一起匹配,输入「回城」「returnOnTimeout」都能找到
|
||||
wrap.dataset.search = `${field.label} ${field.path} ${field.hint || ''}`.toLowerCase();
|
||||
const label = document.createElement('label');
|
||||
label.textContent = field.label;
|
||||
wrap.appendChild(label);
|
||||
@@ -751,6 +815,18 @@ function renderStatus(status) {
|
||||
loginEl.title = '';
|
||||
}
|
||||
$('kReconnect').textContent = String(bot.reconnectAttempts || 0);
|
||||
// 原点自救统计:成功率与最近一次结果(失败说明可能其实是登录服,需要人工发验证码)
|
||||
const rescue = bot.originRescue || {};
|
||||
const rescueEl = $('kOriginRescue');
|
||||
if ((rescue.attempts || 0) > 0) {
|
||||
const last = rescue.lastResult;
|
||||
rescueEl.textContent = `成功 ${rescue.successes || 0}/${rescue.attempts}`
|
||||
+ (last ? `(最近${last.success ? '成功' : '失败'},第 ${last.rounds} 轮)` : '');
|
||||
rescueEl.style.color = last && last.success ? 'var(--green)' : 'var(--yellow)';
|
||||
} else {
|
||||
rescueEl.textContent = '未触发';
|
||||
rescueEl.style.color = '';
|
||||
}
|
||||
|
||||
// 上线 / 下线按钮:在线时是「下线」,离线时是「上线」
|
||||
const offlineBtn = $('btnOffline');
|
||||
@@ -789,6 +865,21 @@ function renderStatus(status) {
|
||||
$('kRetry').textContent = String(task.retryCount || 0);
|
||||
$('kProgress').textContent = (task.lastProgress || 0) + '%';
|
||||
$('kTimeout').textContent = Math.round((task.consecutiveTimeoutMs || 0) / 1000) + 's';
|
||||
// 最近一次回城把机器人从哪挪到了哪(回城是破坏性操作,必须能看出是否值得)
|
||||
const returnEl = $('kLastReturn');
|
||||
const lastReturn = task.lastReturn;
|
||||
if (lastReturn && lastReturn.from && lastReturn.to) {
|
||||
returnEl.textContent = `(${lastReturn.from.x}, ${lastReturn.from.z}) → `
|
||||
+ `(${lastReturn.to.x}, ${lastReturn.to.z}),直线 ${lastReturn.distance} 格`;
|
||||
returnEl.title = lastReturn.reason || '';
|
||||
returnEl.style.color = lastReturn.distance >= 8 ? 'var(--green)' : 'var(--yellow)';
|
||||
} else if ((task.returnSkippedNotStuck || 0) > 0) {
|
||||
returnEl.textContent = `未回城(原地还能走,已放弃 ${task.returnSkippedNotStuck} 次)`;
|
||||
returnEl.style.color = 'var(--green)';
|
||||
} else {
|
||||
returnEl.textContent = '-';
|
||||
returnEl.style.color = '';
|
||||
}
|
||||
$('kClaimTime').textContent = task.withinClaimTime ? '时段内' : '时段外';
|
||||
$('progressBar').style.width = (task.lastProgress || 0) + '%';
|
||||
|
||||
@@ -850,6 +941,13 @@ function renderStatus(status) {
|
||||
$('kGrimPackets').style.color = parts.length > 0 ? 'var(--green)' : 'var(--yellow)';
|
||||
}
|
||||
$('kGrimBlocked').textContent = String(grim.blockedActions || 0);
|
||||
// 地面状态修正(TODO 问题 1):方案 A 对齐次数 / 方案 B 保住次数 / 最近一次同步差
|
||||
const stats = bot.packetStats || {};
|
||||
const groundParts = [`物理对齐 ${stats.physicsGroundSync || 0}`, `保住 ${stats.setbackOnGroundKept || 0}`];
|
||||
if (stats.lastSetbackDelta) groundParts.push(`最近同步差 ${stats.lastSetbackDelta.distance} 格`);
|
||||
$('kGrimGround').textContent = groundParts.join(' · ');
|
||||
$('kGrimGround').style.color = (stats.physicsGroundSync || stats.setbackOnGroundKept)
|
||||
? 'var(--green)' : 'var(--yellow)';
|
||||
$('kGrimAudit').textContent = grim.physicsOk ? '通过' : '异常';
|
||||
$('kGrimAudit').style.color = grim.physicsOk ? 'var(--green)' : 'var(--red)';
|
||||
const issues = $('grimIssues');
|
||||
@@ -878,7 +976,10 @@ function renderSchedules(schedules) {
|
||||
const list = $('scheduleList');
|
||||
if (!list) return;
|
||||
const signature = (schedules || [])
|
||||
.map((t) => [t.id, t.enabled, t.content, t.intervalSeconds, t.nextInSeconds].join(':'))
|
||||
.map((t) => [
|
||||
t.id, t.enabled, t.content, t.mode, t.cron, t.intervalSeconds, t.nextInSeconds,
|
||||
t.expectPattern, t.lastResult ? `${t.lastResult.ok ? 1 : 0}@${t.lastResult.at}` : ''
|
||||
].join(':'))
|
||||
.join('|');
|
||||
if (signature === scheduleSignature) return;
|
||||
scheduleSignature = signature;
|
||||
@@ -887,7 +988,7 @@ function renderSchedules(schedules) {
|
||||
if (!schedules || schedules.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'schedule-empty';
|
||||
empty.textContent = '暂无定时任务。点「+ 新增」可让机器人每隔一段时间重复发送同一条消息或指令。';
|
||||
empty.textContent = '暂无定时任务。点「+ 新增」可让机器人每隔一段时间(或按 cron 表达式)重复发送同一条消息或指令。';
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
@@ -911,8 +1012,22 @@ function renderSchedules(schedules) {
|
||||
content.appendChild(code);
|
||||
const sub = document.createElement('div');
|
||||
sub.className = 'sub';
|
||||
sub.textContent = `${task.kind === 'command' ? '指令' : '公屏'} · 每 ${task.intervalSeconds} 秒`
|
||||
+ (task.label ? ` · ${task.label}` : '');
|
||||
// 调度描述:cron 任务显示表达式,间隔任务显示秒数;无效表达式会带出错误说明
|
||||
const scheduleText = task.mode === 'cron'
|
||||
? `cron ${task.cron}`
|
||||
: `每 ${task.intervalSeconds} 秒`;
|
||||
const parts = [task.kind === 'command' ? '指令' : '公屏', scheduleText];
|
||||
if (task.label) parts.push(task.label);
|
||||
// 发送结果确认:成功显示绿色对勾,超时未确认显示黄色感叹号
|
||||
if (task.expectPattern) {
|
||||
if (task.lastResult && task.lastResult.ok) parts.push('✓ 已确认');
|
||||
else if (task.lastResult) parts.push('⚠ 未确认');
|
||||
else parts.push('待确认');
|
||||
}
|
||||
if (task.cronError) parts.push(task.cronError);
|
||||
sub.textContent = parts.join(' · ');
|
||||
if (task.cronError) sub.style.color = 'var(--red)';
|
||||
else if (task.lastResult && task.lastResult.ok === false) sub.style.color = 'var(--yellow)';
|
||||
content.appendChild(sub);
|
||||
item.appendChild(content);
|
||||
|
||||
@@ -949,14 +1064,18 @@ async function saveSchedules(schedules) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 取当前列表的原始定义(去掉服务端附加的剩余秒数字段) */
|
||||
/** 取当前列表的原始定义(去掉服务端附加的剩余秒数 / 发送结果等运行时字段) */
|
||||
function currentSchedules() {
|
||||
const bot = selectedBot(lastStatus || {});
|
||||
return ((bot && bot.schedules) || []).map((t) => ({
|
||||
id: t.id,
|
||||
label: t.label,
|
||||
content: t.content,
|
||||
mode: t.mode === 'cron' ? 'cron' : 'interval',
|
||||
cron: t.cron || '',
|
||||
intervalSeconds: t.intervalSeconds,
|
||||
expectPattern: t.expectPattern || '',
|
||||
confirmTimeoutSeconds: t.confirmTimeoutSeconds,
|
||||
enabled: t.enabled
|
||||
}));
|
||||
}
|
||||
@@ -1629,9 +1748,46 @@ async function renderSimpV2(body, force) {
|
||||
// ==================== 运行日志 ====================
|
||||
|
||||
const MAX_LOG_LINES = 800;
|
||||
/**
|
||||
* 待渲染的日志队列。
|
||||
*
|
||||
* 服务端是「一条日志一次推送」,而机器人卡住时日志会成串到达(例如「被服务器传送」
|
||||
* 的爆发),逐条改 DOM + 逐条写 scrollTop 会让日志区明显卡顿(手机上尤其明显)。
|
||||
* 这里把同一帧内到达的日志合并成一次 DOM 更新。
|
||||
*/
|
||||
let pendingLogs = [];
|
||||
let logFlushScheduled = false;
|
||||
|
||||
function appendLog(entry) {
|
||||
pendingLogs.push(entry);
|
||||
// 后台标签页里 rAF 不触发,这里做个上限,避免队列无限增长
|
||||
if (pendingLogs.length > MAX_LOG_LINES * 2) pendingLogs.splice(0, pendingLogs.length - MAX_LOG_LINES * 2);
|
||||
scheduleLogFlush();
|
||||
}
|
||||
|
||||
function scheduleLogFlush() {
|
||||
if (logFlushScheduled) return;
|
||||
logFlushScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
logFlushScheduled = false;
|
||||
flushLogs();
|
||||
});
|
||||
}
|
||||
|
||||
/** 把队列里的日志一次性写入 DOM(一次布局、一次滚动) */
|
||||
function flushLogs() {
|
||||
if (pendingLogs.length === 0) return;
|
||||
const view = $('logView');
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const entry of pendingLogs) fragment.appendChild(buildLogLine(entry));
|
||||
pendingLogs = [];
|
||||
view.appendChild(fragment);
|
||||
while (view.childNodes.length > MAX_LOG_LINES) view.removeChild(view.firstChild);
|
||||
if ($('chkAutoScroll').checked) view.scrollTop = view.scrollHeight;
|
||||
}
|
||||
|
||||
/** 构造一行日志的 DOM */
|
||||
function buildLogLine(entry) {
|
||||
const line = document.createElement('span');
|
||||
line.className = 'log-line log-' + (entry.level || 'info');
|
||||
const time = document.createElement('span');
|
||||
@@ -1639,14 +1795,14 @@ function appendLog(entry) {
|
||||
time.textContent = '[' + entry.time + ']';
|
||||
line.appendChild(time);
|
||||
line.appendChild(document.createTextNode(entry.message));
|
||||
view.appendChild(line);
|
||||
while (view.childNodes.length > MAX_LOG_LINES) view.removeChild(view.firstChild);
|
||||
if ($('chkAutoScroll').checked) view.scrollTop = view.scrollHeight;
|
||||
return line;
|
||||
}
|
||||
|
||||
function renderLogs(entries) {
|
||||
pendingLogs = [];
|
||||
$('logView').innerHTML = '';
|
||||
for (const entry of entries) appendLog(entry);
|
||||
for (const entry of entries) pendingLogs.push(entry);
|
||||
flushLogs();
|
||||
}
|
||||
|
||||
// ==================== 公屏聊天 ====================
|
||||
@@ -1991,6 +2147,9 @@ function drawBigMap() {
|
||||
ctx.lineWidth = 1 / px;
|
||||
for (const state of bigMapView.markers.states) {
|
||||
if (!state.path) continue;
|
||||
// 按视野裁剪:全世界视角下邦国多边形很多,视野外的直接跳过(超大地图防掉帧)
|
||||
if (state.bounds && (state.bounds.maxX < minBlockX || state.bounds.minX > maxBlockX
|
||||
|| state.bounds.maxZ < minBlockZ || state.bounds.minZ > maxBlockZ)) continue;
|
||||
ctx.fillStyle = hexToRgba(state.color, 0.26);
|
||||
ctx.fill(state.path, 'evenodd');
|
||||
ctx.strokeStyle = hexToRgba(state.color, 0.85);
|
||||
@@ -2304,14 +2463,26 @@ function setBigMapMarkers(markers) {
|
||||
for (const state of states) {
|
||||
const path = new Path2D();
|
||||
let has = false;
|
||||
// 方块坐标包围盒:全世界视角下用来按视野裁剪多边形(视野外的不必绘制,避免掉帧)
|
||||
let minX = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let minZ = Infinity;
|
||||
let maxZ = -Infinity;
|
||||
for (const flat of state.polygons || []) {
|
||||
if (flat.length < 6) continue;
|
||||
path.moveTo(flat[0], flat[1]);
|
||||
for (let i = 2; i < flat.length; i += 2) path.lineTo(flat[i], flat[i + 1]);
|
||||
path.closePath();
|
||||
for (let i = 0; i < flat.length; i += 2) {
|
||||
if (flat[i] < minX) minX = flat[i];
|
||||
if (flat[i] > maxX) maxX = flat[i];
|
||||
if (flat[i + 1] < minZ) minZ = flat[i + 1];
|
||||
if (flat[i + 1] > maxZ) maxZ = flat[i + 1];
|
||||
}
|
||||
has = true;
|
||||
}
|
||||
state.path = has ? path : null;
|
||||
state.bounds = Number.isFinite(minX) ? { minX, maxX, minZ, maxZ } : null;
|
||||
}
|
||||
bigMapView.markers = { world: markers.world, updatedAt: markers.updatedAt, states };
|
||||
}
|
||||
@@ -2495,11 +2666,21 @@ function bindEvents() {
|
||||
toast('请填写要发送的内容', true);
|
||||
return;
|
||||
}
|
||||
const mode = $('schedMode').value === 'cron' ? 'cron' : 'interval';
|
||||
const cron = $('schedCron').value.trim();
|
||||
if (mode === 'cron' && !cron) {
|
||||
toast('cron 模式下必须填写表达式,例如 0 9 * * *', true);
|
||||
return;
|
||||
}
|
||||
const next = currentSchedules();
|
||||
next.push({
|
||||
id: 'task-' + Date.now().toString(36),
|
||||
content,
|
||||
mode,
|
||||
cron: mode === 'cron' ? cron : '',
|
||||
intervalSeconds: Number($('schedInterval').value) || 300,
|
||||
expectPattern: $('schedExpect').value.trim(),
|
||||
confirmTimeoutSeconds: Number($('schedConfirm').value) || 10,
|
||||
label: $('schedLabel').value.trim(),
|
||||
enabled: true
|
||||
});
|
||||
@@ -2507,6 +2688,11 @@ function bindEvents() {
|
||||
await saveSchedules(next);
|
||||
});
|
||||
|
||||
// 调度方式切换:选 cron 时显示表达式输入框(固定间隔输入框保留,切回来不用重填)
|
||||
$('schedMode').addEventListener('change', () => {
|
||||
$('schedCronField').classList.toggle('hidden', $('schedMode').value !== 'cron');
|
||||
});
|
||||
|
||||
$('btnRefreshMap').addEventListener('click', async () => {
|
||||
try {
|
||||
await api('/api/map/refresh', { method: 'POST' });
|
||||
@@ -2555,6 +2741,9 @@ function bindEvents() {
|
||||
renderConfig();
|
||||
});
|
||||
|
||||
// 配置搜索:只隐藏不删除,保存时仍能读到隐藏字段的值
|
||||
$('configSearch').addEventListener('input', (e) => filterConfigFields(e.target.value));
|
||||
|
||||
// ===== 地图视图切换 =====
|
||||
$('mapTabs').addEventListener('click', (e) => {
|
||||
const tab = e.target.closest('.tab');
|
||||
|
||||
+31
-3
@@ -81,6 +81,7 @@
|
||||
<dt>生命 / 饥饿</dt><dd id="kHealth">-</dd>
|
||||
<dt>状态</dt><dd id="kBotState">-</dd>
|
||||
<dt>登录验证</dt><dd id="kLogin">-</dd>
|
||||
<dt>原点自救</dt><dd id="kOriginRescue">-</dd>
|
||||
<dt>重连次数</dt><dd id="kReconnect">0</dd>
|
||||
</dl>
|
||||
<div class="row">
|
||||
@@ -102,6 +103,7 @@
|
||||
<dt>重试</dt><dd id="kRetry">0</dd>
|
||||
<dt>开垦进度</dt><dd id="kProgress">0%</dd>
|
||||
<dt>连续寻路超时</dt><dd id="kTimeout">0s</dd>
|
||||
<dt>最近回城</dt><dd id="kLastReturn">-</dd>
|
||||
<dt>开垦时段</dt><dd id="kClaimTime">-</dd>
|
||||
</dl>
|
||||
<div class="progress"><div id="progressBar" class="progress-fill"></div></div>
|
||||
@@ -119,20 +121,42 @@
|
||||
<input id="schedContent" class="input" type="text" placeholder="/u restore confirm">
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>调度方式</label>
|
||||
<select id="schedMode" class="input">
|
||||
<option value="interval">固定间隔</option>
|
||||
<option value="cron">cron 表达式</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>间隔(秒)</label>
|
||||
<input id="schedInterval" class="input" type="number" min="5" value="300">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field hidden" id="schedCronField">
|
||||
<label>cron 表达式(分 时 日 月 周,本地时间)</label>
|
||||
<input id="schedCron" class="input" type="text" placeholder="0 9 * * *">
|
||||
<div class="hint">例:<code>*/30 * * * *</code> 每 30 分钟 · <code>0 9 * * *</code> 每天 09:00 · <code>0 12 * * 1-5</code> 工作日 12:00</div>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>备注</label>
|
||||
<input id="schedLabel" class="input" type="text" placeholder="恢复民心">
|
||||
<label>期望回复(正则,可留空)</label>
|
||||
<input id="schedExpect" class="input" type="text" placeholder="民心.*恢复">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>确认超时(秒)</label>
|
||||
<input id="schedConfirm" class="input" type="number" min="1" max="120" value="10">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>备注</label>
|
||||
<input id="schedLabel" class="input" type="text" placeholder="恢复民心">
|
||||
</div>
|
||||
<div class="row row-compact">
|
||||
<button id="btnSaveSchedule" class="btn btn-sm btn-primary">保存</button>
|
||||
<button id="btnCancelSchedule" class="btn btn-sm">取消</button>
|
||||
</div>
|
||||
<p class="hint">只会在机器人已连接且通过登录验证时发送;未就绪的周期会跳过,不会补发。</p>
|
||||
<p class="hint">只会在机器人已连接且通过登录验证时发送;未就绪的周期会跳过,不会补发。填了「期望回复」时,发出后会等待服务器回复,超时未回复会在日志里给出警告。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -164,6 +188,7 @@
|
||||
<dt>攻击距离</dt><dd id="kGrimReach">-</dd>
|
||||
<dt>攻击冷却</dt><dd id="kGrimCooldown">-</dd>
|
||||
<dt>协议补包</dt><dd id="kGrimPackets">-</dd>
|
||||
<dt>地面修正</dt><dd id="kGrimGround">-</dd>
|
||||
<dt>已拦下动作</dt><dd id="kGrimBlocked">0</dd>
|
||||
<dt>物理自检</dt><dd id="kGrimAudit">-</dd>
|
||||
</dl>
|
||||
@@ -323,6 +348,9 @@
|
||||
<button class="tab" data-tab="grim">Grim</button>
|
||||
<button class="tab" data-tab="server">服务器</button>
|
||||
</div>
|
||||
<div class="field config-search">
|
||||
<input id="configSearch" class="input" type="search" placeholder="搜索配置项(名称 / 字段名 / 说明)" autocomplete="off">
|
||||
</div>
|
||||
<div id="configBody" class="config-body"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -314,6 +314,17 @@ html, body {
|
||||
.log-error { color: var(--red); }
|
||||
.log-success { color: var(--green); }
|
||||
|
||||
/*
|
||||
* 日志 / 公屏行很多时(移动端尤其明显):让浏览器跳过「视口外」行的渲染与布局。
|
||||
* 相当于浏览器自带的虚拟列表 —— 不改变内容与滚动位置,只是不再为看不见的行做绘制。
|
||||
* 不支持的浏览器会忽略这两行,行为与原来一致。
|
||||
*/
|
||||
.log-line,
|
||||
.chat-line {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 20px;
|
||||
}
|
||||
|
||||
.chat-line { display: block; }
|
||||
.chat-time { color: #55606f; margin-right: 6px; }
|
||||
.chat-bot { color: var(--accent-2); margin-right: 4px; }
|
||||
|
||||
@@ -11,6 +11,13 @@ const { defaultConfig, normalizeConfig } = require('../config/expansionConfig');
|
||||
|
||||
/** 状态推送间隔(毫秒) */
|
||||
const STATUS_PUSH_INTERVAL_MS = 1000;
|
||||
/**
|
||||
* 大地图预热检查间隔(毫秒)。
|
||||
*
|
||||
* 这里只是「低频问一句」,真正的节流(移动够远 / 距上次够久)由
|
||||
* BigMapService.prewarmAround 自己判断,所以这个间隔可以设得很保守。
|
||||
*/
|
||||
const MAP_PREWARM_CHECK_INTERVAL_MS = 60000;
|
||||
/** 登录接口路径(免令牌,否则无法登录) */
|
||||
const LOGIN_PATH = '/api/login';
|
||||
/** 会话探测接口路径(免令牌) */
|
||||
@@ -52,6 +59,8 @@ class WebConsole {
|
||||
this.expansionLog = deps.expansionLog || null;
|
||||
this.chatLog = deps.chatLog || null;
|
||||
this.bigMap = deps.bigMap || null;
|
||||
/** @type {import('../core/notifier').Notifier|null} 人工介入提醒(状态只暴露域名,不含完整地址) */
|
||||
this.notifier = deps.notifier || null;
|
||||
this.getConfig = deps.getConfig;
|
||||
this.applyConfig = deps.applyConfig;
|
||||
this.exportDir = opts.exportDir || path.join(__dirname, '..', '..', 'data');
|
||||
@@ -120,6 +129,10 @@ class WebConsole {
|
||||
}, STATUS_PUSH_INTERVAL_MS);
|
||||
if (this._pushTimer.unref) this._pushTimer.unref();
|
||||
|
||||
// 大地图后台预热:低频检查机器人位置,移动够远就顺带拉取周边高层级瓦片(低优先级)
|
||||
this._mapPrewarmTimer = setInterval(() => this._maybePrewarmMap(), MAP_PREWARM_CHECK_INTERVAL_MS);
|
||||
if (this._mapPrewarmTimer.unref) this._mapPrewarmTimer.unref();
|
||||
|
||||
// 新日志 / 新公屏消息 / 新开拓记录实时推送
|
||||
this.logger.onEntry = (entry) => this._broadcast('log', entry);
|
||||
if (this.chatLog) this.chatLog.onEntry = (entry) => this._broadcast('chat', entry);
|
||||
@@ -148,6 +161,7 @@ class WebConsole {
|
||||
/** 停止服务 */
|
||||
async stop() {
|
||||
if (this._pushTimer) clearInterval(this._pushTimer);
|
||||
if (this._mapPrewarmTimer) clearInterval(this._mapPrewarmTimer);
|
||||
this.logger.onEntry = null;
|
||||
if (this.chatLog) this.chatLog.onEntry = null;
|
||||
if (this.expansionLog) this.expansionLog.onEntry = null;
|
||||
@@ -949,6 +963,8 @@ class WebConsole {
|
||||
expansionLog: this.expansionLog ? this.expansionLog.stats() : null,
|
||||
chatLog: this.chatLog ? this.chatLog.snapshot() : null,
|
||||
bigMap: this.bigMap ? this.bigMapPayload() : null,
|
||||
/** 通知提醒状态:是否启用 / 是否配好地址 / 已发与失败条数 */
|
||||
notify: this.notifier ? this.notifier.snapshot() : null,
|
||||
serverTime: Date.now(),
|
||||
claimTime: {
|
||||
start: this.getConfig().claimStartHour,
|
||||
@@ -1053,6 +1069,26 @@ class WebConsole {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 大地图后台预热(低频调用):以「第一个有坐标的机器人」为中心,
|
||||
* 交给 BigMapService 判断是否值得预热(移动够远 / 距上次够久)。
|
||||
*
|
||||
* 预热是低优先级任务:失败只记一条日志,不影响任何业务功能。
|
||||
*/
|
||||
_maybePrewarmMap() {
|
||||
if (!this.bigMap) return;
|
||||
let target = null;
|
||||
try {
|
||||
target = this.pool.positions().find((bot) => bot.position) || null;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!target) return;
|
||||
this.bigMap.prewarmAround(target.position.x, target.position.z).catch((err) => {
|
||||
this.logger.log('大地图预热失败:' + (err && err.message ? err.message : String(err)), 'warn');
|
||||
});
|
||||
}
|
||||
|
||||
/** @returns {object} 大地图上的领土标记(多边形 + 王城) */
|
||||
bigMapMarkersPayload() {
|
||||
const model = this.dataService.getModel();
|
||||
|
||||
在新工单中引用
屏蔽一个用户