feat: 回城护栏 + 原点自救 + 移动诊断,并留下 TODO 交接文档

先纠正上一轮的误判:那个「寻路恒定 47 节点」是机器人卡在百米深坑里造成的,
我把它归因成配置问题并反复触发 /u return 回城,把玩家手动摆好的位置又拉走了 —— 抱歉。

回城护栏(破坏性操作不再自动执行)
- returnOnTimeout 默认关闭;即便打开,returnGraceSeconds(默认 300 秒)内也不执行,
  避免把玩家刚在客户端摆好的位置清掉
- returnCommand 可配置(默认 u return);每次回城都明确记日志说明会丢失当前位置
- 因未启用而跳过时也记一条说明,避免用户以为「怎么不回城了」

区分「被传送到原点」的两种原因(以前日志里长得一样,常被误判成机器人坏了)
- 脚下是 bedrock -> 在登录服,必须人工输入验证码,给出明确提示
- 脚下不是基岩 -> 死亡后被卡住,自动左右移动触发服务器重新传送

新增两个诊断字段(排查「走不动」的关键手段)
- moveDiag:当前按下的控制键、速度、寻路器 isMoving/goal/剩余路径点数。
  能一刀切开「寻路器没执行」与「执行了但服务端不认」
- pathingDiag:起点可走邻居数、寻路器能否读到方块、四向可见距离

_isStandingOnGround() 增加「服务端是否正把我们按在原地」的判断

排查结论(详见 TODO.md)
- 排除:GrimCompat 朝向限速(A/B 实测关闭后回弹 6.0 -> 5.5 次/秒,无变化)
- 排除:疾跑(关闭后仍 4.8 次/秒)
- 排除:世界数据错误(实测脚下 stone)
- 关键线索:寻路时 forward/sprint 确实按下、isMoving=true,但水平速度恒为 0
  (velocity.y=-0.078 恰是自由落体第一 tick 的值,说明速度每 tick 被清零),
  且 bot.entity.onGround 被服务端位置包反复置为 false,物理引擎一直按空中处理。
  下一步应从「让 entity.onGround 与出站包一致」入手。

TODO.md 面向下一位开发者:包含项目现状与部署、完整证据链、按优先级排好的假设与
验证命令、备选方案(绕开 pathfinder 自己写转向控制器)、以及其它待办。

测试 360 -> 363,全部通过。
这个提交包含在:
lyizai
2026-09-28 20:21:41 +08:00
父节点 0783249d1e
当前提交 9ca80d2f6e
共修改 7 个文件,包含 585 行新增和 10 行删除
+179 -1
查看文件
@@ -128,6 +128,12 @@ class BotManager extends EventEmitter {
this._noPasswordWarned = false;
/** 上次断开是否因为「该账号已在别处登录」(决定重连退避策略) */
this.alreadyConnected = false;
/** 最近一次被服务端强制同步的位置与时间(判断服务端是否把我们按在原地) */
this._lastForcedMove = null;
/** 是否处于「原点自救」过程中,避免重复触发 */
this._escaping = false;
/** 是否已提示过「当前在登录服」 */
this.needsVerificationHint = false;
/**
* 位置同步统计。
*
@@ -219,6 +225,133 @@ class BotManager extends EventEmitter {
}
/** @returns {boolean} 是否已进入世界并完成登录,可以执行任务 */
/**
* 处理「被丢到世界原点附近」的情况。
*
* 邦国崛起这台服务器有两种截然不同的原因会把玩家送到原点附近:
*
* 1. **死亡后被卡在原点** —— 此时左右移动一下就会触发服务器重新传送回正常位置,
* 机器人可以自救,不需要人工介入;
* 2. **被切到登录服** —— 特征是脚下是基岩、四周是虚空、坐标极小(如 `(-2, -2, -4)`),
* 必须重新输入验证码,而验证码只能人工获取,程序无法代劳。
*
* 这两种情况以前在日志里长得一样(都是「被服务器传送」),
* 所以经常误判成「机器人坏了」。现在区分开并给出对应的处置建议。
*/
_handleZeroPosition() {
const bot = this.bot;
if (!bot || !bot.entity) return;
const pos = bot.entity.position;
// 原点判定:水平方向很近且高度很低(正常地形不会在 y<10 的世界原点附近活动)
const nearOrigin = Math.abs(pos.x) < 48 && Math.abs(pos.z) < 48 && pos.y < 16;
if (!nearOrigin) return;
const below = bot.blockAt ? bot.blockAt(pos.offset(0, -1, 0)) : null;
const isLoginLobby = below && below.name === 'bedrock';
if (isLoginLobby) {
this._log(`机器人位于世界原点附近 (${pos.x.toFixed(0)}, ${pos.y}, ${pos.z.toFixed(0)}),`
+ '脚下是基岩 —— 这是服务器的登录服/验证大厅,不是真实世界', 'warn');
this._log('请在控制台的聊天框里把验证码发出去(验证码当聊天内容发送即可,不用加斜杠)。'
+ '待在大厅里还会不断触发「被服务器传送」,看起来像机器人坏了', 'warn');
this.needsVerificationHint = true;
return;
}
this._log(`机器人被卡在世界原点附近 (${pos.x.toFixed(0)}, ${pos.y}, ${pos.z.toFixed(0)}),`
+ '疑似死亡后被卡住 —— 尝试左右移动触发服务器重新传送', 'warn');
this._escapeFromOrigin();
}
/**
* 左右移动若干次以触发服务器的「重新传送」。
*
* 死亡后被卡在原点时,服务器只要收到有效的移动输入就会把玩家传送回正常位置,
* 因此这里交替按左右方向键一小会儿。失败也不影响后续逻辑(寻路器会照常重试)。
*/
_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;
}
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();
}
/**
* 采集移动执行状态(控制键 / 速度 / 寻路器内部状态)。
*
* 用来一刀切开「寻路器根本没在执行」和「执行了但服务端不认」这两类问题:
* 前者表现为控制键全为 false,后者表现为按键有、速度却接近 0 且位置包被回弹。
*
* @returns {object|null}
*/
_moveDiagnostics() {
const bot = this.bot;
if (!bot || !bot.entity) return null;
const readControl = (name) => {
try {
return typeof bot.getControlState === 'function' ? bot.getControlState(name) === true : null;
} catch {
return null;
}
};
const controls = {};
for (const name of ['forward', 'back', 'left', 'right', 'jump', 'sprint', 'sneak']) {
controls[name] = readControl(name);
}
const velocity = bot.entity.velocity || {};
const speed = Math.sqrt(
(velocity.x || 0) ** 2 + (velocity.y || 0) ** 2 + (velocity.z || 0) ** 2
);
let pathfinder = null;
if (bot.pathfinder) {
const pf = bot.pathfinder;
let goal = null;
try {
goal = pf.goal ? { x: pf.goal.x, y: pf.goal.y, z: pf.goal.z } : null;
} catch {
goal = null;
}
pathfinder = {
isMoving: typeof pf.isMoving === 'function' ? pf.isMoving() : null,
isMining: typeof pf.isMining === 'function' ? pf.isMining() : null,
isBuilding: typeof pf.isBuilding === 'function' ? pf.isBuilding() : null,
goal,
/** 剩余路径点数量:为 0 说明路径已走完或根本没算出路径 */
remainingPath: pf.path ? pf.path.length : null
};
}
return {
controls,
anyPressed: Object.values(controls).some((v) => v === true),
velocity: {
x: Math.round((velocity.x || 0) * 1000) / 1000,
y: Math.round((velocity.y || 0) * 1000) / 1000,
z: Math.round((velocity.z || 0) * 1000) / 1000
},
speed: Math.round(speed * 1000) / 1000,
onGround: bot.entity.onGround === true,
pathfinder
};
}
isReady() {
return this.state === 'online' && !!this.bot && !!this.bot.entity
&& !!this.bot.pathfinder && this.loggedIn;
@@ -265,6 +398,8 @@ class BotManager extends EventEmitter {
this.reconnectAttempts = 0;
this._installModules();
this.emit('spawn');
// 服务器会把玩家丢到世界原点附近,两种原因的处理方式完全不同,需要区分
setTimeout(() => this._handleZeroPosition(), 1500).unref?.();
});
bot.on('end', (reason) => {
@@ -333,6 +468,16 @@ class BotManager extends EventEmitter {
});
bot.on('forcedMove', () => {
// 记录最近一次被服务端同步的位置与时间:用来判断服务端是否正把我们按在原地
// (见 _serverPinsUs),这是「客户端认为在空中、服务端认为站着」的关键线索
if (bot.entity) {
this._lastForcedMove = {
x: bot.entity.position.x,
y: bot.entity.position.y,
z: bot.entity.position.z,
at: Date.now()
};
}
this.emit('forcedMove');
this._countForcedMove();
});
@@ -471,6 +616,12 @@ class BotManager extends EventEmitter {
*
* 取脚下方 0.05 格处的方块:既能容忍浮点误差,又不会因为站在半砖上而误判。
*
* **额外考虑服务端的判定**:如果服务端刚刚把机器人按在同一个位置不动
* (短时间内反复下发相同的 position 包),那在服务端眼里它显然是站着的。
* 这种「客户端认为自己在空中、服务端认为它站着」的不一致会让服务端每 tick
* 判一次异常并回弹,而回弹又会清零速度、让物理引擎永远走不出空中状态 ——
* 表现就是「按着前进键但水平速度恒为 0」。此时以服务端为准更安全。
*
* @returns {boolean|null} 无法判断时返回 null
*/
_isStandingOnGround() {
@@ -478,7 +629,27 @@ class BotManager extends EventEmitter {
if (!bot || !bot.entity || !bot.blockAt) return null;
const below = bot.blockAt(bot.entity.position.offset(0, -0.05, 0));
if (!below) return null;
return below.boundingBox !== 'empty';
if (below.boundingBox !== 'empty') return true;
// 世界说脚下是空的 —— 再看服务端是不是正把我们按在原地
return this._serverPinsUs() ? true : false;
}
/**
* 服务端是否正在把机器人「按」在原地(短时间内反复同步到几乎相同的位置)。
*
* @returns {boolean}
*/
_serverPinsUs() {
const last = this._lastForcedMove;
if (!last) return false;
const age = Date.now() - last.at;
if (age > 500) return false;
const pos = this.bot && this.bot.entity ? this.bot.entity.position : null;
if (!pos) return false;
// 位置几乎没变(服务器认可当前位置),说明它认为我们稳稳站着
return Math.abs(pos.y - last.y) < 0.05
&& Math.abs(pos.x - last.x) < 0.5
&& Math.abs(pos.z - last.z) < 0.5;
}
/** 进入世界后按配置尝试登录 / 注册 */
@@ -856,6 +1027,13 @@ class BotManager extends EventEmitter {
pathingDiag: this.pathing && typeof this.pathing.diagnostics === 'function'
? this.pathing.diagnostics()
: null,
/**
* 移动执行诊断:当前按下了哪些控制键、速度、寻路器内部状态。
*
* 用来一刀切开「寻路器根本没在执行」和「执行了但服务端不认」这两类问题:
* 前者表现为控制键全为 false,后者表现为按键有、速度却接近 0 且位置包被回弹。
*/
moveDiag: this._moveDiagnostics(),
eating: this.eater ? this.eater.eating : false,
grim: this.grim ? this.grim.snapshot() : null
};
+18
查看文件
@@ -65,6 +65,19 @@ function defaultConfig() {
retries: 2,
/** 连续寻路超时累计达到该秒数后,发送回城指令并重新开始 */
returnThresholdSeconds: 60,
/**
* 是否允许「连续超时后自动回城」。
*
* **默认关闭**,因为回城是破坏性的:它会把机器人从当前位置传送回安全点,
* 而这个安全点往往并不比原地更好(可能同样是卡死点),
* 更麻烦的是**会把玩家在游戏客户端里手动摆好的位置一并清掉**。
* 需要用它来逃离卡死状态时再打开。
*/
returnOnTimeout: false,
/** 回城指令(不带前导斜杠),默认服务器自带的 `/u return` */
returnCommand: 'u return',
/** 建立连接后多久内不自动回城(秒):刚被玩家摆好位置的机器人不该被立刻拉走 */
returnGraceSeconds: 300,
/** 发送回城指令后等待传送完成的时间(秒) */
returnWaitSeconds: 12,
/** 单个目标最多触发「往边上走两步」自救的轮数 */
@@ -382,6 +395,11 @@ function validateConfig(config) {
config.waitTimeoutSeconds = clampInt(config.waitTimeoutSeconds, 5, 3600, 60);
config.retries = clampInt(config.retries, 1, 20, 2);
config.returnThresholdSeconds = clampInt(config.returnThresholdSeconds, 0, 3600, 60);
config.returnOnTimeout = config.returnOnTimeout === true;
if (typeof config.returnCommand !== 'string') config.returnCommand = 'u return';
// 去掉玩家可能顺手写上的前导斜杠,发送时会统一补
config.returnCommand = config.returnCommand.trim().replace(/^\/+/, '');
config.returnGraceSeconds = clampInt(config.returnGraceSeconds, 0, 3600, 300);
config.returnWaitSeconds = clampInt(config.returnWaitSeconds, 1, 120, 12);
config.sideStepMaxRounds = clampInt(config.sideStepMaxRounds, 0, 10, 3);
config.claimStartHour = clampInt(config.claimStartHour, 0, 23, 11);
+31 -6
查看文件
@@ -99,6 +99,8 @@ class ExpansionController {
this.retryCount = 0;
/** 当前阶段(寻路 / 等待)的开始时间戳 */
this.stageStartedMillis = 0;
/** 本次连接/本次任务的开始时间戳,用于「刚连上时不许回城」的保护期 */
this.sessionStartedMillis = Date.now();
/** 上一次记录的「与目标的曼哈顿距离」,用于判断寻路是否在稳步推进 */
this._lastTargetDistance = null;
/** 最近一次开垦进度(0-100) */
@@ -241,6 +243,9 @@ class ExpansionController {
this.completedClaims = 0;
this.retryCount = 0;
this.consecutiveTimeoutMillis = 0;
// 保护期从「本次任务开始」算起:玩家常趁机器人掉线时在客户端把它摆到开阔地,
// 刚启动就被回城拉走等于白摆
this.sessionStartedMillis = Date.now();
this.sideStepCount = 0;
this.sideStepping = false;
this.returnThenStop = false;
@@ -480,16 +485,20 @@ class ExpansionController {
}
/**
* 发送回城指令:取消当前寻路,向服务器发送 `/u return`(约 10 秒后自动传送回安全位置),
* 随后进入 RETURNING 状态等待传送完成。
* 发送回城指令:取消当前寻路,向服务器发送回城指令(默认 `/u return`,
* 约 10 秒后自动传送回安全位置),随后进入 RETURNING 状态等待传送完成。
*
* 指令内容取自 `returnCommand`,方便不同服务器替换成自己的回城命令。
*
* @param {string} reason 触发回城的原因
*/
_sendReturnCommand(reason) {
const bm = this.botManager;
const command = (this.config.returnCommand || 'u return').replace(/^\/+/, '');
bm.pathing.cancel();
this._log(reason + ',发送 /u return 回城,10 秒后自动传送');
bm.sendCommand('u return');
this._log(`${reason},发送 /${command} 回城,约 10 秒后自动传送`
+ '(注意:这会把机器人拉回安全点,当前位置与玩家手动摆好的位置都会丢失)');
bm.sendCommand(command);
this.returnStartMillis = Date.now();
this.setState(State.RETURNING, '等待回城传送(10 秒后自动传送)');
}
@@ -591,12 +600,28 @@ class ExpansionController {
/**
* 放弃当前目标(重试与走两步均已尝试)的统一出口:
* 连续寻路超时累计达到阈值则回城传送并重新规划;否则跳过当前目标。
*
* 回城是**破坏性**操作,因此默认关闭(`returnOnTimeout`),且即便打开也有两道护栏:
* - 刚建立连接的一段时间内不回城:玩家常常是趁机器人掉线时在客户端把它摆到开阔地,
* 一连上就被拉回安全点等于白摆;
* - 每次真正回城都明确记日志,避免事后只看到位置莫名其妙变了。
*/
_giveUpTarget(reason) {
if (this.consecutiveTimeoutMillis >= this.config.returnThresholdSeconds * 1000) {
this._sendReturnCommand(`连续寻路超时超过 ${this.config.returnThresholdSeconds} 秒`);
const config = this.config;
if (config.returnOnTimeout && this.consecutiveTimeoutMillis >= config.returnThresholdSeconds * 1000) {
const sessionSeconds = Math.floor((Date.now() - this.sessionStartedMillis) / 1000);
if (sessionSeconds < config.returnGraceSeconds) {
this._log(`连续超时达阈值,但本次连接仅 ${sessionSeconds} 秒(保护期 ${config.returnGraceSeconds} 秒),`
+ '不回城以免清掉刚摆好的位置;改为跳过当前目标');
this.setState(State.FAIL, reason + '(保护期内不回城)');
return;
}
this._sendReturnCommand(`连续寻路超时超过 ${config.returnThresholdSeconds} 秒`);
return;
}
if (this.consecutiveTimeoutMillis >= config.returnThresholdSeconds * 1000 && !config.returnOnTimeout) {
this._log('连续寻路超时已达回城阈值,但「超时自动回城」未启用,跳过当前目标(如需自动逃离卡死状态请在配置里打开)');
}
this._log(this.target ? `${reason},跳过 ${this.target.x},${this.target.z}` : reason);
this.setState(State.FAIL, reason);
}
+14
查看文件
@@ -169,6 +169,20 @@ const CONFIG_SCHEMA = {
{ path: 'waitTimeoutSeconds', label: '等待 / 寻路超时(秒)', type: 'number', min: 5 },
{ path: 'retries', label: '超时后最大重试次数', type: 'number', min: 1 },
{ path: 'returnThresholdSeconds', label: '连续超时回城阈值(秒)', type: 'number', min: 0 },
{
path: 'returnOnTimeout',
label: '超时后自动回城',
type: 'checkbox',
hint: '默认关闭:回城会把机器人拉回安全点,当前位置与你在客户端手动摆好的位置都会丢失'
},
{ path: 'returnCommand', label: '回城指令', type: 'text', hint: '不带前导斜杠,默认 u return' },
{
path: 'returnGraceSeconds',
label: '回城保护期(秒)',
type: 'number',
min: 0,
hint: '刚启动这段时间内不自动回城,避免把刚摆好的位置清掉'
},
{ path: 'returnWaitSeconds', label: '回城等待传送(秒)', type: 'number', min: 1 },
{ path: 'sideStepMaxRounds', label: '往边上走两步最大轮数', type: 'number', min: 0 }
]