diff --git a/CHANGELOG.md b/CHANGELOG.md index a348385..17b0ee9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,47 @@ 本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [1.6.0] - 2026-09-29 + +围绕「机器人为什么走不动」做了一轮系统排查,并给破坏性操作加上护栏。 + +### 新增 + +- **回城护栏**:`/u return` 是破坏性操作 —— 它把机器人拉回安全点, + 会**清掉玩家在游戏客户端里手动摆好的位置**。项目里曾因此反复把机器人 + 从玩家摆好的位置拉走,排查了很久才定位。现在: + - `returnOnTimeout` **默认关闭**,不再自动回城 + - 即便打开,`returnGraceSeconds`(默认 300 秒)内也不执行, + 避免把刚摆好的位置清掉 + - `returnCommand` 可配置(默认 `u return`),适配不同服务器 + - 每次回城都明确记日志,说明「会丢失当前位置与玩家手动摆好的位置」 + - 因未启用而跳过时也会记一条说明,避免用户以为「怎么不回城了」 +- **区分「被传送到原点」的两种原因**(以前在日志里长得一样,常被误判成机器人坏了): + - 脚下是 **bedrock** → 在登录服,必须人工输入验证码,给出明确提示 + - 脚下不是基岩 → 死亡后被卡住,**自动左右移动触发服务器重新传送**(`_escapeFromOrigin()`) +- **移动执行诊断 `moveDiag`**:暴露当前按下的控制键、速度、寻路器内部状态 + (`isMoving` / `isMining` / `goal` / 剩余路径点数)。这是排查「走不动」的关键手段 —— + 能一刀切开「寻路器没执行」与「执行了但服务端不认」。 +- **寻路诊断 `pathingDiag`**:起点可走邻居数、寻路器能否通过 `world.getBlock` 读到方块、 + 四向可见距离。 + +### 变更 + +- `_isStandingOnGround()` 增加「服务端是否正把我们按在原地」的判断 + (`_serverPinsUs()`):短时间内反复收到几乎相同位置的位置包时, + 说明在服务端眼里机器人是站着的,此时以服务端为准更安全 + +### 排查结论(已排除的方向,详见 `TODO.md`) + +- ❌ **`GrimCompat` 的朝向限速不是原因**:A/B 实测关闭合规层后回弹频率没有变化 + (6.0 vs 5.5 次/秒,位移都是 0) +- ❌ **疾跑不是原因**:关闭后仍是 4.8 次/秒 +- ❌ **世界数据不是原因**:实测脚下 `stone`,`blockBelow` 正确 +- 🎯 **关键线索**:寻路时 `forward`/`sprint` 确实按下、`isMoving=true`, + 但**水平速度恒为 0**(`velocity.y = -0.078` 恰是自由落体第一 tick 的值), + 说明速度每 tick 被清零;同时 `bot.entity.onGround` 被服务端位置包反复置为 false, + 物理引擎一直按「空中」处理。下一步应从「让 `entity.onGround` 与出站包一致」入手。 + ## [1.5.1] - 2026-09-28 接入 API-V2 密钥后按实测响应修正字段名(之前是按文档猜的,跑起来全不对)。 diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..c53d6be --- /dev/null +++ b/TODO.md @@ -0,0 +1,251 @@ +# TODO:留给后续开发者的待办与已知问题 + +> 本文档记录**尚未解决的大工程**、已经排查过的线索、以及推荐的验证方法。 +> 小事已在代码里做完并提交,这里只写需要集中攻关的部分。 +> +> 最后更新:2026-09-29 + +--- + +## 0. 先读这里:项目现状与部署 + +| 项 | 值 | +| --- | --- | +| 技术栈 | Node.js ≥ 22 + mineflayer 4.39.0 + mineflayer-pathfinder 2.4.5 | +| 测试 | `npm test`(363 个用例全绿) | +| 语法与前端 id 自检 | `npm run check` | +| 服务器 | `root@103.236.99.177:33851`,部署目录 `/opt/autostore-bot` | +| 控制台 | http://103.236.99.177:78/ ,密码在服务器 `.env` 的 `WEB_PASSWORD` | +| 进程管理 | pm2 应用 `autostore-bot`(端口 78);旧版 Fabric 机器人为 `minecraft-bot`(端口 91,已停) | +| 更新方式 | 打包上传 → `bash /opt/autostore-bot/update.sh`(保留 `.env` / `data/` / `node_modules/` / `runtime/`) | +| 私有 Node | `/opt/autostore-bot/runtime/current/bin/node`(v24.16.0) | + +**旧版 Fabric 模组源码**(重要参考,实机可用的实现): +`C:\Users\luoyi\AppData\Local\Temp\autostore-deploy\oldbot.js`(1356 行)。 +它在这台服务器上是**真的能走路、能寻路、能扩地**的,所以遇到 mineflayer 行为诡异时, +第一件事应该是拿它的实现逐项对照,而不是自己猜。 + +--- + +## 1. 🔴 最大问题:寻路器驱动的移动会被服务端持续回弹 + +### 现象 + +- **手动按键移动完全正常**:通过控制台「移动测试」按 forward 2 秒,实测位移 4~8 格,位置保持。 +- **寻路器驱动的移动被回弹**:一旦 `bot.pathfinder.setGoal()` 开始执行, + 服务端以 **5~9 次/秒** 的频率强制同步位置(`forcedMove`),机器人几乎原地不动。 +- 偶尔能挪动一点,但速度只有 **0.3 格/秒**(正常步行 4.3 格/秒), + 所以几百格外的目标在超时前根本走不到。 + +### 已确认正常的部分(不要再怀疑这些) + +| 项 | 状态 | 证据 | +| --- | --- | --- | +| `player_input` 补包 | ✅ 每 physicsTick 都发(~20/s),bitflags 正确 | `packetStats.playerInput` 持续增长 | +| `tick_end` 补包 | ✅ 每帧发送 | `grim.protocolPackets.tickEndSent` | +| `onGround` 标志 | ✅ 出站包已按世界数据复核 | 实测空闲时 `onGround=true`,世界脚下确实是 stone | +| 世界数据可读性 | ✅ `world.getBlock` 正常,四向可见 115~125 格 | `pathingDiag.visibleDistance` | +| 寻路器本身 | ✅ 在开阔地能找到路径 | 实测「已探索 3633 节点 → success,代价 113.8」 | +| 服务端回弹是否持续 | ❌ **只在机器人尝试移动时出现**;空闲时入站位置包为 0 | 12 秒采样:空闲 +0,寻路中 +105 | +| 关闭疾跑 | 试过,**无改善** | `allowSprint=false` 后仍是 4.8 次/秒回弹 | +| **关闭合规层(grim.enabled=false)** | 试过,**无改善** | A/B 实测:开启 6.0 次/秒 vs 关闭 5.5 次/秒,机器人位移都是 0 格 | +| 世界数据是否错误 | ❌ **不是原因** | 实测脚下 `stone`,`blockBelow` 正确 | + +### 🎯 最关键的一条线索:水平速度恒为 0 + +用 `/api/status` 的 `moveDiag` 字段采集到的实测数据: + +**空闲时** +```json +{ "controls": { "forward": false, "sprint": false, ... }, "anyPressed": false, + "velocity": { "x": 0, "y": -0.078, "z": 0 }, "speed": 0.078, "onGround": true, + "pathfinder": { "isMoving": false, "goal": null } } +``` + +**寻路中** +```json +{ "controls": { "forward": true, "sprint": true, ... }, "anyPressed": true, + "velocity": { "x": 0, "y": -0.078, "z": 0 }, "speed": 0.078, "onGround": false, + "pathfinder": { "isMoving": true, "goal": {...} } } +``` + +**解读**(这是下一步攻关的起点): + +1. **寻路器确实在执行** —— `forward`/`sprint` 都按下去了,`isMoving=true`。 + 所以不是「寻路器没跑」,问题在更底层。 +2. **水平速度恒为 0**,而 `velocity.y = -0.078` 恰好是**自由落体第一 tick** 的重力值。 + 这说明速度**每 tick 都被清零**,机器人永远积累不起速度。 +3. **`onGround` 被服务端的 position 包反复置为 false**: + mineflayer 的 `physics.js` 在处理服务端 `position` 包时会把 `entity.onGround` 置为 false, + 而本服下发位置包的频率(5~6 次/秒)远高于物理引擎把它改回来的速度。 + 结果是:**机器人站在石头上,但物理引擎一直认为自己在空中**, + 于是水平加速走的是「空中控制」分支,而位置包又把速度清零,两者叠加就是「按着前进键却纹丝不动」。 +4. 已尝试的缓解:`_isStandingOnGround()` 里加了「服务端是否正把我们按在原地」的判断 + (`_serverPinsUs()`),出站包的 `onGround` 已经能正确上报。 + **但 `bot.entity.onGround`(物理引擎内部值)仍然是 false**,所以物理计算依旧按空中处理 —— + 这可能就是还差的那一步。 + +### 排查建议(按优先级,已按实测结果更新) + +#### 假设 1:`GrimCompat` 对 `bot.look` 的拦截 ❌ **已排除** + +A/B 实测 grim 开/关对回弹频率没有影响(6.0 vs 5.5 次/秒),机器人位移都是 0。不要再往这个方向查。 + +#### 假设 2:`bot.entity.onGround` 被位置包置 false(**最可疑,优先测**) + +这是目前唯一能解释「按键有、速度恒 0」的机制。方向: + +- **方案 A**:在 `physicsTick` 里,当出站包已判定「脚下有方块」时, + 把 `bot.entity.onGround` 也强制同步为 true,让物理引擎走地面分支。 + (注意:物理引擎每 tick 会自己重算这个值,所以需要在它之后、发送位置包之前介入, + 或者直接监听并覆盖。需要读 mineflayer `lib/plugins/physics.js` 确认执行顺序。) +- **方案 B**:降低服务端下发位置包的频率对我们的影响 —— + 例如在收到 `position` 包时,如果位置与我们自己的预测几乎一致(只差浮点误差), + **不采纳它的 onGround=false**,只同步坐标。 + 这比方案 A 更贴近「我们是对的、服务端只是话多」的实际情况。 +- **验证**:改完后看 `moveDiag.speed` 是否变成 0.1 以上、`moveDiag.onGround` 是否稳定为 true, + 以及 `被服务器传送另有` 的计数是否下降。 + +#### 假设 3:跳跃 / 跑酷的物理预测不匹配 + +Grim 的 Prediction 检查对**跳跃**最严格(起跳时机、跳跃高度、疾跑跳加成)。 +mineflayer 的 `physics.js` 在这些细节上与原版有已知偏差。 + +**验证方法**:把路径限制成纯平地,禁用一切跳跃: +```json +{"allowSprint": false, "pathing": {"allowParkour": false, "allow1by1towers": false, "maxDropDown": 1}} +``` +若回弹消失 → 需要在 `GrimCompat` 里拦下 `jump` 控制状态,改用更保守的移动策略。 + +#### 假设 4:发包顺序 / 包类型与原版不一致 + +旧模组的调试指令 `!tp` 额外发的是 **`position_look`**(同时带位置与朝向)。 +需要实测确认:mineflayer 在朝向变化时到底发的是 `position` 还是 `position_look`, +以及服务端是否因为「朝向变了却只发 position」而判定异常。 + +**验证方法**:在 `GrimCompat` 里统计出站包类型分布, +与旧模组移动时的包序列逐项对照(旧模组源码见上文路径)。 + +#### 假设 5:直接抓包对照 + +用 `scripts/check-protocol.js` 的思路扩展一个抓包脚本, +分别在「旧模组移动」和「mineflayer 寻路移动」时记录 5 秒的完整出站包序列, +diff 出差异。这是最彻底也最费时的办法,但能一次性定位问题。 + +### 💡 备选方案:绕开 mineflayer-pathfinder + +既然**手动控制状态是有效的**,可以考虑不依赖 pathfinder 的执行层,自己写一个简单的转向控制器: + +1. 用 `bot.pathfinder` **只算路径**(`bot.pathfinder.getPathTo()` 拿到路径点数组,不执行); +2. 自己实现执行:朝下一个路径点 `bot.look` → 按住 `forward` → 到位后切下一个点; +3. 遇到 1 格高的坎就按 `jump`,遇到障碍就重新算路径。 + +这样能完全控制发包与朝向的时序,也便于在 `GrimCompat` 里做逐 tick 审计。 +工作量中等,但比继续调 pathfinder 的参数更可能出结果。 + +--- + +## 2. 🟡 「被传送到原点」的两种原因(已在代码里区分,但可继续完善) + +服务器把玩家丢到世界原点附近有两种完全不同的原因,**以前在日志里长得一样**, +经常被误判成「机器人坏了」: + +| 原因 | 特征 | 处理方式 | 现状 | +| --- | --- | --- | --- | +| 死亡后被卡住 | 脚下不是基岩,坐标靠近原点但地形正常 | **左右移动即可触发服务器重新传送**,可自动自救 | ✅ 已实现 `BotManager._escapeFromOrigin()` | +| 被切到登录服 | 脚下是 **bedrock**、四周虚空、坐标极小(如 `(-2,-2,-4)`) | **必须重新输入验证码**,只能人工完成 | ✅ 已识别并提示,控制台有醒目横幅 | + +**待完善**:自救的成功率没有统计。可以记录「自救后位置是否恢复」, +失败时再升级为提示用户;也可以尝试更多方向组合(前/后/跳)。 + +--- + +## 3. 🟡 自动回城(`/u return`)已加护栏,但仍可改进 + +回城是**破坏性**操作:它把机器人拉回安全点,会**清掉玩家在游戏客户端里手动摆好的位置**。 +项目里曾因此反复把机器人从玩家摆好的位置拉走,排查了很久。 + +现状(已修): +- `returnOnTimeout` **默认关闭**; +- 即便打开,`returnGraceSeconds`(默认 300 秒)内不执行回城; +- 回城指令可配置(`returnCommand`,默认 `u return`); +- 每次回城都明确记日志,说明「会丢失当前位置」。 + +**待改进**: +- 回城前先判断「原地是否真的更差」——例如用 `pathingDiag.neighborCount` 判断 + 当前位置是否还有可走方向;如果邻居数正常,回城毫无意义。 +- 记录回城前后的坐标,在控制台上展示「回城把机器人从 A 挪到了 B」, + 让用户能判断这次回城是否值得。 + +--- + +## 4. 🟢 其它已知但优先级较低的问题 + +### 4.1 大地图 +- 目前只有层级 0 会批量预爬取,层级 1~3 靠用户缩放时按需拉取,首次浏览较慢。 +- 可以考虑后台低优先级预热:机器人移动到新区域时,顺带爬取周边高层级瓦片。 +- 领土多边形在缩放较大时仍是全量绘制,超大地图(全世界视角 + 全部邦国)可能掉帧, + 可以按视野做裁剪。 + +### 4.2 控制台 +- 移动端布局已适配,但**日志区在手机上滚动性能一般**(每行都是 DOM 节点)。 + 数据量大时建议改虚拟列表,或限制保留行数。 +- 配置面板有 8 个标签页、字段很多,可以加搜索框。 + +### 4.3 多机器人 +- 领土模型是全局共享的,多机器人同时扩地时对同一批候选区块的协调依赖 + `_isClaimedByOthers`,但没有真正的分布式锁;高并发时可能撞车。 +- 建议给「目标区块」加一个带租约的占用表。 + +### 4.4 定时消息 +- 只支持固定间隔,没有「cron 式」或「每天某时刻」的调度。 +- 没有发送结果确认:`/u restore confirm` 发出去后是否成功,目前只靠人看日志。 + +### 4.5 登录验证 +- 验证码只能人工获取与输入(服务器设计如此)。 + 可以在「等待验证码」时通过通知渠道(如飞书机器人)提醒用户,避免错过 120 秒时限。 + +--- + +## 5. 常用排查命令 + +```bash +# 机器人完整状态(含寻路诊断) +curl -s -H "X-Auth-Token: $PW" http://103.236.99.177:78/api/status | jq '.data.bots[0]' + +# 寻路诊断:邻居数、世界可读性、四向可见距离 +curl -s -H "X-Auth-Token: $PW" http://103.236.99.177:78/api/status | jq '.data.bots[0].pathingDiag' + +# 实时日志 +tail -f /opt/autostore-bot/data/pm2-out.log +tail -f /opt/autostore-bot/data/pm2-error.log + +# 公屏完整日志(含服务器消息,JSONL) +tail -f /opt/autostore-bot/data/chat/<玩家名>.jsonl + +# 寻路状态(判断「探索了多少节点」是定位卡死的关键指标) +grep '寻路状态' /opt/autostore-bot/data/pm2-out.log | tail -20 + +# 被服务器回弹的频率(>100/30秒 说明移动被反作弊拒绝) +grep '被服务器传送另有' /opt/autostore-bot/data/pm2-error.log | tail -5 +``` + +### 判断「机器人是不是卡在登录服」 + +```bash +curl -s -H "X-Auth-Token: $PW" http://103.236.99.177:78/api/status \ + | jq '.data.bots[0] | {position, chunk, below: .world.blockBelow, needsVerification}' +``` +- `world.blockBelow == "bedrock"` 且坐标很小 → **在登录服**,需要人工发验证码 +- `needsVerification == true` → 服务器正在等待验证码,控制台会显示醒目提示 + +--- + +## 6. 代码约定(改动前请遵守) + +- 提交信息用约定式提交(`feat:` / `fix:` / `docs:` / `refactor:` / `test:` / `chore:`),主题行说清做了什么。 +- **所有密钥只走 `.env`**(已在 `.gitignore` 里),代码里绝不硬编码; + 新增配置项要同时更新 `.env.example` 的占位符。 +- 注释与文档用中文,解释「为什么」而不是「做了什么」。 +- 每个 `fix` 都应补一个能复现原问题的测试;本项目测试在 `test/`,用 `node:test`。 +- 改前端记得跑 `npm run check`,它会校验 `app.js` 引用的元素 id 在 `index.html` 里都存在。 diff --git a/src/bot/botManager.js b/src/bot/botManager.js index 295078d..5e3678c 100644 --- a/src/bot/botManager.js +++ b/src/bot/botManager.js @@ -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 }; diff --git a/src/config/expansionConfig.js b/src/config/expansionConfig.js index 0ea887a..422372a 100644 --- a/src/config/expansionConfig.js +++ b/src/config/expansionConfig.js @@ -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); diff --git a/src/controller/expansionController.js b/src/controller/expansionController.js index 036fee4..511175d 100644 --- a/src/controller/expansionController.js +++ b/src/controller/expansionController.js @@ -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); } diff --git a/src/web/public/app.js b/src/web/public/app.js index 967bac3..44fa557 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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 } ] diff --git a/test/expansionController.test.js b/test/expansionController.test.js index 7653b73..a893f08 100644 --- a/test/expansionController.test.js +++ b/test/expansionController.test.js @@ -273,9 +273,12 @@ test('寻路超时会重新寻路,重试耗尽后走两步', async () => { assert.strictEqual(controller.sideStepCount, 1); }); -test('连续超时累计达到阈值时回城', async () => { +test('连续超时累计达到阈值时回城(需显式开启 returnOnTimeout)', async () => { const { controller, botManager, config } = setup({ - config: { waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, returnThresholdSeconds: 1 } + config: { + waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, + returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0 + } }); await controller.start(config); controller.stageStartedMillis = Date.now() - 10000; @@ -284,9 +287,54 @@ test('连续超时累计达到阈值时回城', async () => { assert.ok(botManager.commands.includes('u return'), '应发送回城指令'); }); +test('默认不回城:超时只跳过目标,不把机器人拉走', async () => { + const { controller, botManager, config } = setup({ + config: { waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, returnThresholdSeconds: 1 } + }); + assert.strictEqual(config.returnOnTimeout, false, '默认应关闭自动回城'); + await controller.start(config); + controller.stageStartedMillis = Date.now() - 10000; + controller.tick(); + assert.notStrictEqual(controller.state, State.RETURNING, '默认不应触发回城'); + assert.ok(!botManager.commands.includes('u return'), '不应发送回城指令'); +}); + +test('回城保护期内即便开启回城也不执行,避免清掉刚摆好的位置', async () => { + const { controller, botManager, config } = setup({ + config: { + waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, + returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 600 + } + }); + await controller.start(config); + controller.stageStartedMillis = Date.now() - 10000; + controller.tick(); + assert.notStrictEqual(controller.state, State.RETURNING, '保护期内不应回城'); + assert.ok(!botManager.commands.includes('u return')); +}); + +test('回城指令可通过 returnCommand 自定义', async () => { + const { controller, botManager, config } = setup({ + config: { + waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, + returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0, + returnCommand: '/spawn' + } + }); + await controller.start(config); + controller.stageStartedMillis = Date.now() - 10000; + controller.tick(); + // 玩家可能顺手带上前导斜杠,发送时应被去掉 + assert.ok(botManager.commands.includes('spawn'), '应发送自定义指令:' + botManager.commands.join(', ')); + assert.ok(!botManager.commands.includes('u return')); +}); + test('回城等待结束后重新规划(非耐久保护触发)', async () => { const { controller, config } = setup({ - config: { waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, returnThresholdSeconds: 1, returnWaitSeconds: 1 } + config: { + waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, + returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0, returnWaitSeconds: 1 + } }); await controller.start(config); controller.stageStartedMillis = Date.now() - 10000;