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` 全绿。
这个提交包含在:
lyizai
2026-09-28 22:46:39 +08:00
父节点 8d1b8d5122
当前提交 51664c55b6
共修改 31 个文件,包含 3535 行新增和 251 行删除
+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);
+304
查看文件
@@ -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
};