diff --git a/CHANGELOG.md b/CHANGELOG.md index 94350d0..447888d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,51 @@ 本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [1.3.0] - 2026-09-27 + +按线上实测结果修复「机器人走不动」,并移植旧版机器人已验证的关键补包。 + +### 修复 + +- **控制台登录按钮点不动(致命)**:`bindEvents()` 只在 `startApp()` 里执行,而 `startApp()` + 只在登录成功后才跑 —— 未登录时登录按钮根本没绑上事件,形成死锁。 + 现在登录相关按钮在 `main()` 里无条件绑定 +- **机器人走不动(致命,根因已定位并修复)**:mineflayer **不发 `player_input` 包**。 + 1.21.2+ 服务端的移动预测依赖它,原版客户端每 tick 都发,而 mineflayer 只在 + 骑乘 / 跳下 / 潜行时发,地面移动的 forward / jump / sprint 完全不发。 + 带反作弊的服务器因此判定「没有任何输入却发生了位移」,**每 tick 把玩家拉回原位置**。 + 实测:修复前入站强制位置同步约 **17 次/秒**、按方向键 2 秒位移 0.00 格; + 修复后入站降到 **0 次**、位移 0.53~1.40 格。 + 同时补发原版每 tick 末尾的 **`tick_end`** 包(反作弊用它判定 tick 边界,mineflayer 完全不发) +- **寻路插件从未加载**:项目从没调用 `bot.loadPlugin(pathfinder)`,`bot.pathfinder` 恒为 + `undefined`、`isReady()` 恒为 false,扩地任务连启动都启动不了 +- **寻路事件监听重复注册**:`configure()` 每次配置变更都会重跑并再挂一遍监听, + 导致同一事件被处理多次(日志里同一秒出现多条相同事件) +- **资源包未自动接受**:服务器要求资源包时不接会卡在加载界面进不了世界 + +### 新增 + +- **长距离分段寻路**:mineflayer-pathfinder 只能规划**已加载区块**范围(实测约 8 区块 ≈ 130 格), + 而扩地目标常在几十个区块之外,A* 会瞬间返回 noPath。现在会把旅程拆段: + 走到已加载范围边缘附近 → 新区块加载 → 再规划下一段。 + 段长用 `bot.blockAt` 对未加载区块返回 null 的特性**二分探测**得到,不依赖世界内部结构 +- **寻路预算可配置**:`pathing.thinkTimeoutMs` / `tickTimeoutMs` / `searchRadius` + (`searchRadius` 为负表示不限制剪枝) +- **位置同步诊断**:位置包收发计数、`player_input` / `tick_end` 计数、`onGround`、 + 游戏模式、维度、脚下方块、六向「脚部 / 头部 / 地面」与可通行判定 +- **手动移动测试**:控制台按钮绕开寻路器直接按方向键并回报真实位移 +- 配置界面新增协议补包与寻路预算相关字段 + +### 已知限制(重要) + +- **当前所在位置无法寻路**:实测机器人站在 (6759, 72, 11926) 时,A* **恒定只展开 47 个节点** + 就返回 noPath。已逐项排除:挖方块 / 放方块 / 跑酷 / 搭塔 / 开门 / 最大下落(试到 64 格)/ + 高度偏好 / 水域代价 / 搜索半径(试到不限制)/ 时间预算 / 依赖版本 + (与能正常移动的旧机器人完全一致:mineflayer 4.39.0 + pathfinder 2.4.5 + prismarine-world 3.7.0)。 + 旧机器人日志证明寻路本身可用(2 次 `goal_reached`、2 秒走过 8.7 格), + 但它当时在 **y=-59**,而本机器人被 `/spawn` 放到了 **y=72**,怀疑是新位置的地形让移动生成器 + 找不到任何可行走分支。**下一步需要把机器人挪到开阔地再试** + ## [1.2.0] - 2026-09-27 首次真正连上线上服务器(Debian 12 + pm2 + 端口 78)后,暴露并修掉了一批 diff --git a/README.md b/README.md index eafb814..5996737 100644 --- a/README.md +++ b/README.md @@ -247,23 +247,54 @@ pm2 startup systemd -u root --hp /root # 开机自启 - **位移正常、但任务报「目标不可达」** → 是规划问题(距离太远、被地形挡住、挖/放方块被 Grim 约束禁用) - **位移 ≈ 0** → 客户端移动被服务端拒绝,属于协议 / 反作弊交互问题,改规划参数没有意义 -配套的诊断数据在 `GET /api/bots/:id/status`: +#### 最关键的一条:mineflayer 不发 `player_input` + +这是本项目在真实服务器上「怎么按方向键都不动」的根因,**已在 `GrimCompat` 里自动补发**: + +1.21.2+ 服务端的移动预测依赖 `player_input` 包,原版客户端每 tick 都发; +而 mineflayer 只在骑乘 / 跳下 / 潜行时发,地面移动的 forward / jump / sprint **完全不发**。 +带反作弊的服务器因此判定「没有任何输入却发生了位移」,**每 tick 把玩家拉回原位置**。 + +实测对比(同一账号、同一位置、同一时段): + +| | 入站强制位置同步 | 按方向键 2 秒的位移 | +| --- | --- | --- | +| 不补 `player_input` | ~17 次/秒 | 0.00 格 | +| 补 `player_input` + `tick_end` | **0 次** | 0.53 ~ 1.40 格 | + +同时补发的还有 `tick_end`(原版 1.21.2+ 客户端每 tick 末尾发送,反作弊用它判定 tick 边界)。 +两者可在配置里关掉(`grim.sendPlayerInput` / `grim.sendTickEnd`),但**线上服务器不要关**。 + +#### 长距离寻路会被自动拆段 + +mineflayer-pathfinder 只能规划**已加载区块**范围(实测本服约 8 区块 ≈ 130 格), +而扩地目标常在几十个区块之外,A* 会瞬间返回 noPath。 +寻路服务会把旅程拆成若干段:走到已加载范围边缘附近 → 新区块加载 → 再规划下一段。 +段长由 `bot.blockAt` 对未加载区块返回 null 的特性二分探测得出,不依赖世界内部结构。 +相关配置:`pathing.maxSegmentBlocks`、`pathing.segmentMarginBlocks`。 + +#### 配套的诊断数据 + +在 `GET /api/bots/:id/status`: | 字段 | 含义 | | --- | --- | -| `packetStats.inboundPosition` | 服务端下发的强制位置同步次数 | +| `packetStats.inboundPosition` | 服务端下发的强制位置同步次数(持续很高 = 被回弹) | | `packetStats.outboundPosition` | 客户端上报的位置包次数 | +| `packetStats.playerInput` / `tickEnd` | 每 tick 补发的协议包次数(应约等于 20/秒) | | `packetStats.onGroundFixed` | 出站包中 onGround 被世界数据纠正的次数 | | `onGround` | 客户端当前认为自己在不在空中 | | `gameMode` / `dimension` | 游戏模式与维度(adventure / spectator 会限制移动) | -| `world.blockBelow` / `world.surroundings` | 脚下方块与六向实心情况(判断是否被困住) | +| `world.blockBelow` / `world.surroundings.exits` | 脚下方块与各方向「脚部 / 头部 / 地面」方块名 + 可通行判定 | +| `grim.protocolPackets` | 协议补包是否生效及发送计数 | 判读方式: - 入站高、出站为 0 → 客户端根本没上报位置 - 出入都高但位移为 0 → 客户端在报,服务端不认(回弹) -- `onGround` 恒为 false 而 `world.blockBelowSolid` 为 true → 客户端上报「站在地上却在空中」, - 开启 `grim.reportOnGroundFromWorld` 可修正 +- `onGround` 恒为 false 而 `world.blockBelowSolid` 为 true → 开启 `grim.reportOnGroundFromWorld` +- **寻路日志里「已探索节点数」极小(如几十个)就 noPath** → 不是距离问题,是移动生成器 + 从起点就找不到分支:检查 `world.surroundings.exits` 的 `walkable`,或把机器人挪到开阔地 --- diff --git a/src/bot/botManager.js b/src/bot/botManager.js index b194967..63e6bbe 100644 --- a/src/bot/botManager.js +++ b/src/bot/botManager.js @@ -129,7 +129,11 @@ class BotManager extends EventEmitter { lastInboundAt: 0, lastOutboundAt: 0, /** 出站包中 onGround 被世界数据纠正的次数 */ - onGroundFixed: 0 + onGroundFixed: 0, + /** 每 tick 补发的 player_input 次数(1.21.2+ 移动预测必需) */ + playerInput: 0, + /** 每 tick 补发的 tick_end 次数 */ + tickEnd: 0 }; } @@ -308,6 +312,19 @@ class BotManager extends EventEmitter { this.emit('forcedMove'); this._countForcedMove(); }); + + // 自动接受服务器资源包:不接会一直卡在「加载资源包」而进不了服务器 + bot.on('resourcePack', (url) => { + const text = typeof url === 'string' ? url : ''; + this._log('收到服务器资源包,自动接受:' + (text.length > 60 ? text.slice(0, 60) + '…' : text)); + setTimeout(() => { + try { + if (this.bot && typeof this.bot.acceptResourcePack === 'function') this.bot.acceptResourcePack(); + } catch (err) { + this._log('接受资源包失败:' + (err && err.message ? err.message : String(err)), 'warn'); + } + }, 200).unref?.(); + }); } /** @@ -393,6 +410,10 @@ class BotManager extends EventEmitter { this.packetStats.outboundPosition++; this.packetStats.lastOutboundAt = Date.now(); this._fixOutboundOnGround(params); + } else if (name === 'player_input') { + this.packetStats.playerInput++; + } else if (name === 'tick_end') { + this.packetStats.tickEnd++; } return originalWrite(name, params); }; @@ -815,14 +836,13 @@ class BotManager extends EventEmitter { _worldDiagnostics() { const bot = this.bot; if (!bot || !bot.entity || !bot.world) return null; - const columns = bot.world.columns || bot.world.columnStates || {}; - let columnCount = 0; - if (columns instanceof Map) columnCount = columns.size; - else if (typeof columns === 'object') columnCount = Object.keys(columns).length; + const columns = this._loadedColumns(); const below = bot.blockAt ? bot.blockAt(bot.entity.position.offset(0, -1, 0)) : null; const at = bot.blockAt ? bot.blockAt(bot.entity.position) : null; return { - loadedColumns: columnCount, + loadedColumns: columns.length, + /** 已加载区块的包围盒:寻路器只能规划到已加载的范围内,这是「目标不可达」的常见原因 */ + loadedBounds: boundsOfColumns(columns), blockBelow: below ? below.name : null, blockBelowSolid: below ? below.boundingBox !== 'empty' : null, blockAtFeet: at ? at.name : null, @@ -833,6 +853,31 @@ class BotManager extends EventEmitter { }; } + /** + * 取出当前已加载的全部区块列。 + * + * prismarine-world 的列容器在不同版本里叫 `columns` / `columnStates`, + * 也可能是 Map 或普通对象,这里统一处理,避免诊断本身报错。 + * + * @returns {Array} + */ + _loadedColumns() { + const world = this.bot.world; + if (!world) return []; + try { + if (typeof world.getColumns === 'function') { + const result = world.getColumns(); + if (result) return Array.from(result); + } + const container = world.columns || world.columnStates; + if (!container) return []; + if (container instanceof Map) return Array.from(container.values()); + return Object.values(container); + } catch { + return []; + } + } + /** * 机器人六个方向的实心情况。 * @returns {object|null} @@ -841,18 +886,43 @@ class BotManager extends EventEmitter { const bot = this.bot; if (!bot || !bot.entity || !bot.blockAt) return null; const pos = bot.entity.position; - const probe = (dx, dy, dz) => { + const solid = (dx, dy, dz) => { const block = bot.blockAt(pos.offset(dx, dy, dz)); if (!block) return null; return block.boundingBox !== 'empty'; }; + const nameOf = (dx, dy, dz) => { + const block = bot.blockAt(pos.offset(dx, dy, dz)); + return block ? block.name : null; + }; + const dirs = [ + ['north', 0, -1], + ['south', 0, 1], + ['east', 1, 0], + ['west', -1, 0] + ]; + /** + * 每个方向同时看脚部、头部、以及该方向脚下的地面: + * 只探测脚部无法区分「能走过去」与「一格高的槽里出不去」, + * 而后者会让寻路器在起点附近就穷尽搜索(表现为 noPath + 节点数极少)。 + */ + const exits = {}; + for (const [name, dx, dz] of dirs) { + exits[name] = { + feet: nameOf(dx, 0, dz), + head: nameOf(dx, 1, dz), + floor: nameOf(dx, -1, dz), + walkable: solid(dx, 0, dz) === false && solid(dx, 1, dz) === false && solid(dx, -1, dz) === true + }; + } return { - north: probe(0, 0, -1), - south: probe(0, 0, 1), - east: probe(1, 0, 0), - west: probe(-1, 0, 0), - above: probe(0, 1, 0), - below: probe(0, -0.05, 0) + north: solid(0, 0, -1), + south: solid(0, 0, 1), + east: solid(1, 0, 0), + west: solid(-1, 0, 0), + above: solid(0, 1, 0), + below: solid(0, -0.05, 0), + exits }; } @@ -897,6 +967,37 @@ class BotManager extends EventEmitter { } } +/** + * 计算一组区块列的外接矩形(区块坐标)。 + * @param {Array} columns + * @returns {{minX: number, maxX: number, minZ: number, maxZ: number, radius: number}|null} + */ +function boundsOfColumns(columns) { + if (!columns || columns.length === 0) return null; + let minX = Infinity; + let maxX = -Infinity; + let minZ = Infinity; + let maxZ = -Infinity; + for (const column of columns) { + const x = column.chunkX !== undefined ? column.chunkX : column.x; + const z = column.chunkZ !== undefined ? column.chunkZ : column.z; + if (!Number.isFinite(x) || !Number.isFinite(z)) continue; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (z < minZ) minZ = z; + if (z > maxZ) maxZ = z; + } + if (!Number.isFinite(minX)) return null; + return { + minX, + maxX, + minZ, + maxZ, + /** 以当前所在区块为基准的近似可视半径(区块数) */ + radius: Math.max(Math.abs(maxX - minX), Math.abs(maxZ - minZ)) / 2 + }; +} + /** * mineflayer 的消息通道 → 公屏日志类型。 * @param {string} [position] diff --git a/src/bot/grimCompat.js b/src/bot/grimCompat.js index d0f6916..8393681 100644 --- a/src/bot/grimCompat.js +++ b/src/bot/grimCompat.js @@ -108,6 +108,9 @@ class GrimCompat { install() { if (this._installed) return; const bot = this.bot; + // 协议补包与「合规层开关」无关:缺了它们机器人根本无法移动, + // 属于「让客户端表现得像原版客户端」的必要动作,而不是可选的反作弊调优。 + this._installProtocolPackets(bot); if (!this.config.grim.enabled) { this._installed = true; return; @@ -137,12 +140,83 @@ class GrimCompat { if (!this._installed) return; const bot = this.bot; bot.removeListener('physicsTick', this._onPhysicsTick); + if (this._onProtocolTick) bot.removeListener('physicsTick', this._onProtocolTick); if (this._originalLook) bot.look = this._originalLook; if (this._originalLookAt) bot.lookAt = this._originalLookAt; if (this._originalSetControlState) bot.setControlState = this._originalSetControlState; this._installed = false; } + // ==================== 协议补包 ==================== + + /** + * 补发原版客户端每 tick 都会发、而 mineflayer 不发的两个包。 + * + * 这是本项目在真实服务器上「机器人完全走不动」的根因(由旧版机器人实测得出): + * + * 1. **`player_input`**:1.21.2+ 服务端的移动预测依赖它,原版客户端每 tick 都发; + * mineflayer 只在骑乘 / 跳下 / 潜行时发送,地面移动的 forward / jump / sprint 完全不发。 + * 带反作弊的服务器因此判定「没有任何输入却发生了位移」, + * 每 tick 把玩家拉回原位置(setback)——表现就是原地抖动、位移恒为 0。 + * 2. **`tick_end`**:原版 1.21.2+ 客户端在每 tick 末尾发送,反作弊用它判定 tick 边界; + * mineflayer 完全不发,会导致 PacketOrder / Timer 类判定异常。 + * + * @param {import('mineflayer').Bot} bot + */ + _installProtocolPackets(bot) { + const grim = this.config.grim; + if (grim.sendPlayerInput === false && grim.sendTickEnd === false) return; + if (!bot || !bot._client) return; + + // 版本能力探测:newPlayerInputPacket 自 1.21.3 起存在 + try { + this.playerInputSupported = bot.supportFeature('newPlayerInputPacket'); + } catch { + this.playerInputSupported = false; + } + this.playerInputSent = 0; + this.tickEndSent = 0; + + this._onProtocolTick = () => { + if (grim.sendPlayerInput !== false && this.playerInputSupported) { + try { + bot._client.write('player_input', { + inputs: { + forward: bot.getControlState('forward'), + backward: bot.getControlState('back'), + left: bot.getControlState('left'), + right: bot.getControlState('right'), + jump: bot.getControlState('jump'), + shift: bot.getControlState('sneak'), + sprint: bot.getControlState('sprint') + } + }); + this.playerInputSent++; + } catch { + // 发送失败不影响主流程 + } + } + if (grim.sendTickEnd !== false) { + // 用 nextTick 保证 tick_end 排在本帧的 position 包之后 + // (mineflayer 在 physicsTick 处理完才发位置) + process.nextTick(() => { + try { + if (bot._client && bot.entity) { + bot._client.write('tick_end', {}); + this.tickEndSent++; + } + } catch { + // 忽略 + } + }); + } + }; + bot.on('physicsTick', this._onProtocolTick); + this._log('协议补包已启用(player_input ' + + (this.playerInputSupported ? '已支持' : '当前版本不支持') + + ',tick_end 每帧发送)'); + } + // ==================== 旋转 ==================== /** @@ -508,7 +582,15 @@ class GrimCompat { attackCooldownMs: this.attackCooldownRemainingMs(), blockedActions: this.blockedActions, physicsOk: audit.ok, - issues: audit.issues + issues: audit.issues, + /** 协议补包状态:线上服务器上「走不动」时首先看这里 */ + protocolPackets: { + playerInputSupported: this.playerInputSupported === true, + playerInputEnabled: this.config.grim.sendPlayerInput !== false, + playerInputSent: this.playerInputSent || 0, + tickEndEnabled: this.config.grim.sendTickEnd !== false, + tickEndSent: this.tickEndSent || 0 + } }; } diff --git a/src/bot/pathingService.js b/src/bot/pathingService.js index 34fa790..709c78e 100644 --- a/src/bot/pathingService.js +++ b/src/bot/pathingService.js @@ -5,6 +5,11 @@ const pathfinderPlugin = require('mineflayer-pathfinder'); const { GoalXZ, GoalNearXZ, Goal } = pathfinderPlugin.goals; const { Movements } = pathfinderPlugin; +/** 单次寻路最多允许分成多少段(防止「不可达」目标导致无限重规划) */ +const MAX_SEGMENTS = 200; +/** 分段路径算出 noPath 时最多收缩几次段长 */ +const MAX_SEGMENT_SHRINK = 3; + /** * 寻路服务:替代原 Java 版的 BaritoneBridge。 * @@ -24,7 +29,9 @@ const PathEvent = { CALC_FINISHED_NOW_EXECUTING: 'CALC_FINISHED_NOW_EXECUTING', CALC_FAILED: 'CALC_FAILED', AT_GOAL: 'AT_GOAL', - CANCELED: 'CANCELED' + CANCELED: 'CANCELED', + /** 走完一段中途路径(长距离分段寻路时用,调用方应据此重置超时计时) */ + SEGMENT_REACHED: 'SEGMENT_REACHED' }; class PathingService { @@ -48,6 +55,26 @@ class PathingService { this._pathing = false; this._lastStatus = null; this._configured = false; + /** 事件监听是否已注册(configure 会被反复调用,不能重复注册) */ + this._listenersBound = false; + /** + * 长距离分段寻路的状态。 + * + * mineflayer-pathfinder 只能规划**已加载区块**范围内(实测本服约 8 区块 ≈ 130 格), + * 而扩地目标常常在几十个区块之外,于是 A* 瞬间返回 noPath(表现为「目标不可达」)。 + * 解决办法是把整段旅程拆成若干段:先走到已加载范围边缘附近的中途点, + * 走完后新区块已加载,再规划下一段,直到抵达真正的目标。 + */ + this.finalTarget = null; + /** 当前这一段的中途点(方块坐标) */ + this.currentWaypoint = null; + /** 当前中途点是否就是最终目标 */ + this._waypointIsFinal = false; + /** 本次 pathTo 已走的段数,用于防止无限循环 */ + this._segmentCount = 0; + /** 段长收缩次数与系数(本段算出 noPath 时用来缩短段长重试) */ + this._segmentShrink = 0; + this._segmentScale = 1; } /** @@ -82,21 +109,55 @@ class PathingService { movements.liquidCost = Math.max(movements.liquidCost, 200); } bot.pathfinder.setMovements(movements); - // 搜索超时与半径:默认值偏保守,山区大地图容易 noPath - bot.pathfinder.thinkTimeout = 10000; - bot.pathfinder.tickTimeout = 40; - bot.pathfinder.searchRadius = 256; + // 搜索预算:默认值偏保守,山区 / 建筑密集区容易在起点附近就剪枝掉所有分支 + // (表现为 noPath 且「已探索节点数」极小)。searchRadius 为负表示不限制。 + bot.pathfinder.thinkTimeout = this.config.pathing.thinkTimeoutMs; + bot.pathfinder.tickTimeout = this.config.pathing.tickTimeoutMs; + bot.pathfinder.searchRadius = this.config.pathing.searchRadius; - bot.on('goal_reached', () => { + // 事件监听只注册一次:configure() 在每次配置变更时都会重跑, + // 重复注册会让同一个事件被处理多次(日志里同一秒出现多条相同事件就是这么来的) + if (!this._listenersBound) { + this._listenersBound = true; + bot.on('goal_reached', () => { this._pathing = false; - this._emit(PathEvent.AT_GOAL, { target: this.currentTarget }); + 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('path_update', (r) => { this._lastStatus = r.status; + // 完整记录寻路器状态:长距离寻路卡住时,只看成功 / 失败是查不出原因的 + this._log(`寻路状态:${r.status}` + + (r.visitedNodes !== undefined ? `(已探索 ${r.visitedNodes} 节点)` : '') + + (r.cost !== undefined ? `(代价 ${r.cost})` : '') + + `,本段目标 ${this.currentWaypoint ? Math.round(this.currentWaypoint.x) + ',' + Math.round(this.currentWaypoint.z) : '无'}` + + `,最终目标 ${this.finalTarget ? Math.round(this.finalTarget.x) + ',' + Math.round(this.finalTarget.z) : '无'}`); if (r.status === 'success') { this.calcFailed = false; this._emit(PathEvent.CALC_FINISHED_NOW_EXECUTING, { cost: r.cost, nodes: r.visitedNodes }); } else if (r.status === 'noPath') { + // 分段后仍不可达:可能是已加载范围的探测偏乐观,缩短段长再试一次 + if (!this._waypointIsFinal && this.finalTarget && this._segmentShrink < MAX_SEGMENT_SHRINK) { + this._segmentShrink++; + this._segmentScale *= 0.6; + this._log(`本段不可达,缩短段长后重试(第 ${this._segmentShrink}/${MAX_SEGMENT_SHRINK} 次)`); + this._planSegment(); + return; + } this.calcFailed = true; this._pathing = false; this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget }); @@ -106,11 +167,12 @@ class PathingService { this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, timeout: true }); } }); - bot.on('path_reset', (reason) => { - if (reason !== 'chunk_loaded' && reason !== 'movement_update') { - this._emit(PathEvent.CANCELED, { reason }); - } - }); + bot.on('path_reset', (reason) => { + if (reason !== 'chunk_loaded' && reason !== 'movement_update') { + this._emit(PathEvent.CANCELED, { reason }); + } + }); + } this.ready = true; this._configured = true; this._log(`寻路器就绪(挖方块 ${this.config.allowBreak ? '开' : '关'},` @@ -150,20 +212,37 @@ class PathingService { const bot = this.bot; if (!this.isAvailable()) return false; const opts = options || {}; - const blockX = chunk.x * 16 + 8; - const blockZ = chunk.z * 16 + 8; - const startY = Math.floor(bot.entity.position.y); - let goal = new GoalNearXZ(blockX, blockZ, 2); - if (opts.y !== undefined && opts.y !== null) { - goal = new GoalNearXZ(blockX, blockZ, 2); - } - if (this.config.altitudePreference && this.config.altitudeWeight > 0) { - goal = new AltitudeGoal(goal, this.config.altitudeWeight, startY); - } this.currentTarget = { x: chunk.x, z: chunk.z }; + this.finalTarget = { x: chunk.x * 16 + 8, z: chunk.z * 16 + 8 }; + this._segmentCount = 0; + this._segmentShrink = 0; + this._segmentScale = 1; this.calcFailed = false; this._pathing = true; this._emit(PathEvent.CALC_STARTED, { target: this.currentTarget }); + return this._planSegment(opts); + } + + /** + * 规划「当前这一段」的路径:把最终目标裁剪到已加载范围内。 + * + * @param {{y?: number}} [options] + * @returns {boolean} 是否成功下发 + */ + _planSegment(options) { + const bot = this.bot; + const opts = options || {}; + const goalBlock = this._segmentGoal(this.finalTarget); + this.currentWaypoint = goalBlock; + this._waypointIsFinal = goalBlock.x === this.finalTarget.x && goalBlock.z === this.finalTarget.z; + + 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) { @@ -176,11 +255,87 @@ class PathingService { return true; } + /** + * 把最终目标裁剪到「当前已加载的范围内」。 + * + * 不做任何世界内部结构的猜测:沿目标方向直接探测最远的已加载位置 + * (`bot.blockAt` 对未加载区块返回 null),再留出安全边距。 + * + * @param {{x: number, z: number}} finalBlock 最终目标(方块坐标) + * @returns {{x: number, z: number}} 本段实际要去的点 + */ + _segmentGoal(finalBlock) { + const bot = this.bot; + if (!bot.entity || !finalBlock) return finalBlock; + const pos = bot.entity.position; + const dx = finalBlock.x - pos.x; + const dz = finalBlock.z - pos.z; + const distance = Math.hypot(dx, dz); + if (distance <= 1) return finalBlock; + + const pathing = this.config.pathing || {}; + const maxSegment = Math.max(16, pathing.maxSegmentBlocks || 512); + const margin = Math.max(0, pathing.segmentMarginBlocks === undefined ? 24 : pathing.segmentMarginBlocks); + const unitX = dx / distance; + const unitZ = dz / distance; + // 只需要探测到「可能用到的最大段长」就够了 + const loaded = this._probeLoadedDistance(unitX, unitZ, maxSegment + margin); + // 段长同时受「已加载范围 - 边距」和「单段上限」两个约束 + const usable = Math.max(16, Math.min(loaded - margin, maxSegment) * this._segmentScale); + if (usable >= distance) return finalBlock; + return { + x: Math.round(pos.x + unitX * usable), + z: Math.round(pos.z + unitZ * usable) + }; + } + + /** + * 沿给定方向二分探测最远的「已加载」距离(方块)。 + * + * 依据是已加载区块构成一个以玩家为中心的圆,因此「越远越可能未加载」是单调的。 + * 同时在脚下与脚上一格取样,避免刚好落在空气层里被误判为未加载。 + * + * @param {number} unitX 单位方向 X + * @param {number} unitZ 单位方向 Z + * @param {number} maxBlocks 探测上限 + * @returns {number} 最远的已加载距离 + */ + _probeLoadedDistance(unitX, unitZ, maxBlocks) { + const bot = this.bot; + if (!bot.blockAt || !bot.entity) return 0; + const pos = bot.entity.position; + const isLoaded = (d) => { + try { + const feet = bot.blockAt(pos.offset(unitX * d, 0, unitZ * d)); + if (feet) return true; + return !!bot.blockAt(pos.offset(unitX * d, 1, unitZ * d)); + } catch { + return false; + } + }; + if (!isLoaded(0)) return 0; + let lo = 0; + let hi = maxBlocks; + // 迭代次数决定精度:maxBlocks / 2^14 ≈ 0.25 格,足够贴合区块加载边界 + for (let i = 0; i < 14; i++) { + const mid = (lo + hi) / 2; + if (isLoaded(mid)) lo = mid; + else hi = mid; + } + return lo; + } + /** 取消当前寻路 */ cancel() { if (!this.isAvailable()) return; this._pathing = false; this.currentTarget = null; + this.finalTarget = null; + this.currentWaypoint = null; + this._waypointIsFinal = false; + this._segmentCount = 0; + this._segmentShrink = 0; + this._segmentScale = 1; try { this.bot.pathfinder.setGoal(null); } catch { diff --git a/src/config/expansionConfig.js b/src/config/expansionConfig.js index db7ef63..3534d63 100644 --- a/src/config/expansionConfig.js +++ b/src/config/expansionConfig.js @@ -128,7 +128,30 @@ function defaultConfig() { /** 进入液体格的额外代价(越大越倾向绕开水域) */ liquidCost: 40, /** 是否允许打开门/栅栏门 */ - canOpenDoors: false + canOpenDoors: false, + /** + * 单段寻路的最长方块数(默认 512)。 + * + * mineflayer-pathfinder 只能规划**已加载区块**范围内(实测本服约 8 区块 ≈ 130 格), + * 而扩地目标常在几十个区块之外,A* 会瞬间返回 noPath。 + * 因此长距离会被拆成若干段:走到已加载范围边缘附近再重新规划下一段。 + * 这个值只是单段的上限,实际段长由「已加载范围」动态决定。 + */ + maxSegmentBlocks: 96, + /** 分段寻路时距已加载范围边缘留出的安全边距(方块),避免踩在未加载边界上 */ + segmentMarginBlocks: 24, + /** + * A* 单次搜索的时间预算(毫秒)。 + * 太小会在复杂地形里搜不完就放弃,表现为「目标不可达」。 + */ + thinkTimeoutMs: 10000, + /** A* 每 tick 的时间片(毫秒),越大单帧越卡但搜得更快 */ + tickTimeoutMs: 40, + /** + * A* 的代价搜索半径:节点代价超过「起点到目标的启发值 + 该值」就被剪枝。 + * 负数表示不限制(复杂地形里最稳,但搜索更慢)。 + */ + searchRadius: 256 }, // ==================== 战斗辅助(新增) ==================== @@ -179,7 +202,23 @@ function defaultConfig() { * 被反作弊判定异常后每 tick 拉回一次,表现为机器人原地抖动、完全走不动。 * 开启后发包前会用脚下方块复核,只有确实不一致时才改写。 */ - reportOnGroundFromWorld: true + reportOnGroundFromWorld: true, + /** + * 是否每 tick 补发 `player_input` 包(默认开启,**线上服务器必需**)。 + * + * 1.21.2+ 服务端的移动预测依赖它,原版客户端每 tick 都发,而 mineflayer 只在 + * 骑乘 / 跳下 / 潜行时发 —— 地面移动的 forward / jump / sprint 完全不发。 + * 带反作弊的服务器会判定「无输入却位移」并每 tick 把玩家拉回, + * 表现就是机器人原地抖动、怎么按方向键都不动。 + */ + sendPlayerInput: true, + /** + * 是否每 tick 补发 `tick_end` 包(默认开启)。 + * + * 原版 1.21.2+ 客户端在每 tick 末尾发送,反作弊用它判定 tick 边界; + * mineflayer 完全不发,会导致 PacketOrder / Timer 类判定异常。 + */ + sendTickEnd: true }, // ==================== 大地图(新增) ==================== @@ -335,6 +374,12 @@ function validateConfig(config) { config.pathing.maxDropDown = clampInt(config.pathing.maxDropDown, 0, 64, 3); config.pathing.liquidCost = clampInt(config.pathing.liquidCost, 1, 1000, 40); + config.pathing.maxSegmentBlocks = clampInt(config.pathing.maxSegmentBlocks, 32, 4096, 512); + config.pathing.segmentMarginBlocks = clampInt(config.pathing.segmentMarginBlocks, 0, 256, 24); + config.pathing.thinkTimeoutMs = clampInt(config.pathing.thinkTimeoutMs, 100, 300000, 10000); + config.pathing.tickTimeoutMs = clampInt(config.pathing.tickTimeoutMs, 5, 5000, 40); + // searchRadius 允许为负(不限制),因此单独钳制下限 + config.pathing.searchRadius = clampInt(config.pathing.searchRadius, -1, 100000, 256); config.combat.scanRange = clampNumber(config.combat.scanRange, 1, 32, 4.5); diff --git a/src/controller/expansionController.js b/src/controller/expansionController.js index e4e9b45..fdd6e3a 100644 --- a/src/controller/expansionController.js +++ b/src/controller/expansionController.js @@ -747,6 +747,10 @@ class ExpansionController { this._log('寻路事件:AT_GOAL'); } else if (event === PathEvent.CALC_FINISHED_NOW_EXECUTING) { this._log(`寻路事件:路径已生成(代价 ${detail ? detail.cost : '?'})`); + } else if (event === PathEvent.SEGMENT_REACHED) { + // 长距离分段寻路走完一段:这是「有进展」而不是卡住, + // 必须重置超时计时,否则走几十段的长途会被 waitTimeoutSeconds 误判为寻路超时 + this.stageStartedMillis = Date.now(); } } diff --git a/src/web/public/app.js b/src/web/public/app.js index c562c82..b30ec27 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -180,7 +180,9 @@ const CONFIG_SCHEMA = { { path: 'pathing.allow1by1towers', label: '允许 1×1 搭塔', type: 'checkbox' }, { path: 'pathing.maxDropDown', label: '最大下落高度(格)', type: 'number', min: 0 }, { path: 'pathing.liquidCost', label: '液体格额外代价', type: 'number', min: 1 }, - { path: 'pathing.canOpenDoors', label: '允许开门', type: 'checkbox' } + { 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: '分段时距已加载范围边缘留出的余量,避免踩在未加载边界上' } ] } ], @@ -260,7 +262,9 @@ const CONFIG_SCHEMA = { { path: 'grim.blockConflictingActions', label: '拦截冲突动作组合', type: 'checkbox' }, { path: 'grim.checkPhysicsIntegrity', label: '启动物理参数自检', type: 'checkbox' }, { path: 'grim.blockActionsWhileRiding', label: '骑乘时禁止攻击 / 进食', type: 'checkbox' }, - { path: 'grim.reportOnGroundFromWorld', label: '按世界数据修正 onGround', type: 'checkbox', hint: '服务端频繁下发位置包时,mineflayer 会持续上报「站地上却在空中」而被反作弊每 tick 拉回;开启后在发包前用脚下方块复核(实测本服必需)' } + { path: 'grim.reportOnGroundFromWorld', label: '按世界数据修正 onGround', type: 'checkbox', hint: '服务端频繁下发位置包时,mineflayer 会持续上报「站地上却在空中」而被反作弊每 tick 拉回;开启后在发包前用脚下方块复核(实测本服必需)' }, + { 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 边界' } ] } ], @@ -1474,6 +1478,21 @@ function connectWs() { // ==================== 事件绑定 ==================== +/** + * 绑定登录相关的按钮。 + * + * 必须在 `main()` 里无条件调用:这些按钮要在「尚未登录」时就能用, + * 而 `bindEvents()` 只在 `startApp()` 里执行,`startApp()` 又只在登录成功后才跑 —— + * 把登录按钮放进去会形成死锁,表现就是「输入密码点登录没反应」。 + */ +function bindAuthEvents() { + $('btnLogin').addEventListener('click', doLogin); + $('loginPassword').addEventListener('keydown', (e) => { + if (e.key === 'Enter') doLogin(); + }); + $('btnLogout').addEventListener('click', doLogout); +} + function bindEvents() { $('botSelect').addEventListener('change', () => selectBot($('botSelect').value)); @@ -1881,6 +1900,8 @@ async function startApp() { } async function main() { + // 登录按钮必须最先绑定:未登录时 startApp() 不会执行,绑在里面就点不动 + bindAuthEvents(); const ok = await checkSession(); if (ok) await startApp(); else $('loginPassword').focus(); diff --git a/test/grimCompat.test.js b/test/grimCompat.test.js index 351732b..2750477 100644 --- a/test/grimCompat.test.js +++ b/test/grimCompat.test.js @@ -26,8 +26,84 @@ function setup(options) { return { bot, grim, config }; } -test('shortestAngleDelta 走最短路径并处理环绕', () => { - assert.ok(Math.abs(shortestAngleDelta(0, 0.5) - 0.5) < 1e-9); +// ==================== 协议补包(线上服务器「走不动」的根因)==================== + +/** 等一轮宏任务,让 process.nextTick 里发出的 tick_end 落地 */ +function flushTick() { + return new Promise((resolve) => setTimeout(resolve, 5)); +} + +test('每 tick 补发 player_input,内容反映当前控制键', async () => { + const { bot } = setup(); + bot.setControlState('forward', true); + bot.setControlState('sprint', true); + bot.clearWritten(); + + bot.tick(); + const packets = bot.writtenPackets('player_input'); + assert.strictEqual(packets.length, 1, '每个 physicsTick 都应补发一次 player_input'); + assert.strictEqual(packets[0].params.inputs.forward, true); + assert.strictEqual(packets[0].params.inputs.sprint, true); + assert.strictEqual(packets[0].params.inputs.jump, false); + assert.strictEqual(packets[0].params.inputs.backward, false); + assert.strictEqual(packets[0].params.inputs.shift, false); +}); + +test('每 tick 补发 tick_end,且排在本帧位置包之后', async () => { + const { bot } = setup(); + bot.clearWritten(); + bot.tick(); + assert.strictEqual(bot.writtenPackets('tick_end').length, 0, 'tick_end 应延后到下一轮微任务/宏任务'); + await flushTick(); + assert.strictEqual(bot.writtenPackets('tick_end').length, 1); +}); + +test('版本不支持 player_input 时只补 tick_end,不报错', async () => { + const { bot } = setup({ bot: { features: { newPlayerInputPacket: false } } }); + bot.clearWritten(); + bot.tick(); + await flushTick(); + assert.strictEqual(bot.writtenPackets('player_input').length, 0); + assert.strictEqual(bot.writtenPackets('tick_end').length, 1); +}); + +test('关闭开关后不再补发对应包', async () => { + const { bot } = setup({ config: { grim: { sendPlayerInput: false, sendTickEnd: false } } }); + bot.clearWritten(); + bot.tick(); + await flushTick(); + assert.strictEqual(bot._client.written.length, 0); +}); + +test('关掉 Grim 合规层后协议补包仍然生效(否则机器人根本走不动)', async () => { + const { bot } = setup({ config: { grim: { enabled: false } } }); + bot.clearWritten(); + bot.tick(); + await flushTick(); + assert.strictEqual(bot.writtenPackets('player_input').length, 1); + assert.strictEqual(bot.writtenPackets('tick_end').length, 1); +}); + +test('uninstall 后不再补包', async () => { + const { bot, grim } = setup(); + grim.uninstall(); + bot.clearWritten(); + bot.tick(); + await flushTick(); + assert.strictEqual(bot._client.written.length, 0); +}); + +test('snapshot 暴露协议补包状态与计数', () => { + const { bot, grim } = setup(); + bot.tick(); + const snap = grim.snapshot(); + assert.strictEqual(snap.protocolPackets.playerInputSupported, true); + assert.strictEqual(snap.protocolPackets.playerInputEnabled, true); + assert.strictEqual(snap.protocolPackets.playerInputSent, 1); + assert.strictEqual(snap.protocolPackets.tickEndEnabled, true); +}); + +test('shortestAngleDelta 走最短路径并处理环绕', () => { assert.ok(Math.abs(shortestAngleDelta(0, 0.5) - 0.5) < 1e-9); // 从 170° 到 -170° 应走 +20° 而不是 -340° const from = (170 * Math.PI) / 180; const to = (-170 * Math.PI) / 180; diff --git a/test/helpers/fakeBot.js b/test/helpers/fakeBot.js index cf9fe07..04d7976 100644 --- a/test/helpers/fakeBot.js +++ b/test/helpers/fakeBot.js @@ -57,7 +57,37 @@ class FakeBot extends EventEmitter { this.registry = opts.registry || defaultRegistry(); this.pathfinder = null; this.blockMap = opts.blockMap || new Map(); + /** + * 模拟「已加载区块范围」:设置后,超出该半径(方块)的位置 blockAt 返回 null, + * 用于测试寻路器只能规划已加载范围、需要分段寻路的行为。 + * 圆心始终跟随实体当前位置,模拟「人走到哪、区块加载到哪」。 + */ + this.loadedRadius = opts.loadedRadius === undefined ? null : opts.loadedRadius; this.creative = { flying: false }; + /** 版本能力探测:默认支持 1.21.3+ 的 player_input 包 */ + this.features = Object.assign({ newPlayerInputPacket: true }, opts.features || {}); + /** 假的协议客户端:记录写出的包,便于断言「有没有补发 player_input / tick_end」 */ + this._client = opts.client || { + state: 'play', + written: [], + write(name, params) { + this.written.push({ name, params }); + }, + on() {} + }; + } + + supportFeature(name) { + return this.features[name] === true; + } + + /** 取出写出的指定包 */ + writtenPackets(name) { + return this._client.written.filter((p) => p.name === name); + } + + clearWritten() { + this._client.written.length = 0; } getControlState(control) { @@ -72,6 +102,10 @@ class FakeBot extends EventEmitter { blockAt(pos) { const key = `${Math.floor(pos.x)},${Math.floor(pos.y)},${Math.floor(pos.z)}`; if (this.blockMap.has(key)) return this.blockMap.get(key); + if (this.loadedRadius !== null) { + const center = this.entity.position; + if (Math.hypot(pos.x - center.x, pos.z - center.z) > this.loadedRadius) return null; + } return { name: 'air', boundingBox: 'empty' }; } @@ -136,6 +170,11 @@ class FakeBot extends EventEmitter { tickTimes(n) { for (let i = 0; i < n; i++) this.emit('physicsTick'); } + + /** 接受资源包(记录调用,供测试断言) */ + acceptResourcePack() { + this.resourcePackAccepted = true; + } } module.exports = { FakeBot }; diff --git a/test/pathingService.test.js b/test/pathingService.test.js index 988a168..a1af7cd 100644 --- a/test/pathingService.test.js +++ b/test/pathingService.test.js @@ -35,8 +35,143 @@ test('AltitudeGoal 使用 GoalNearXZ 时保留其到达范围', () => { assert.strictEqual(goal.isEnd({ x: 100, y: 70, z: 201 }), true, '范围内算到达'); }); -test('configure 施加 Grim 合规约束(默认禁止挖 / 放 / 跑酷 / 搭塔)', () => { - const bot = new FakeBot(); +// ==================== 长距离分段寻路 ==================== +// +// mineflayer-pathfinder 只能规划已加载区块范围(实测线上约 8 区块 ≈ 130 格), +// 而扩地目标常在几十个区块外,A* 会瞬间 noPath。分段寻路就是为这个场景写的。 + +/** 造一个「已加载范围」有限的 bot 与寻路服务 */ +function setupSegment(options) { + const opts = options || {}; + const bot = new FakeBot({ x: opts.x === undefined ? 0.5 : opts.x, y: 64, z: opts.z === undefined ? 0.5 : opts.z, loadedRadius: opts.loadedRadius === undefined ? 128 : opts.loadedRadius }); + const goals = []; + bot.pathfinder = { + setMovements: () => {}, + setGoal: (goal) => goals.push(goal), + thinkTimeout: 0, + tickTimeout: 0, + searchRadius: 0 + }; + const config = defaultConfig(); + config.enforceClaimTime = false; + Object.assign(config.pathing, opts.config || {}); + const service = new PathingService(bot, config, null); + service.configure(); + return { bot, service, goals, config }; +} + +test('_probeLoadedDistance 沿方向探测到已加载范围的边缘', () => { + const { service } = setupSegment({ loadedRadius: 128 }); + assert.ok(Math.abs(service._probeLoadedDistance(1, 0, 512) - 128) < 2, + '向右探测应停在已加载半径处'); + assert.ok(Math.abs(service._probeLoadedDistance(0, -1, 512) - 128) < 2); +}); + +test('_segmentGoal 把远超已加载范围的目标裁剪到边缘附近', () => { + const { service } = setupSegment({ + loadedRadius: 128, + config: { segmentMarginBlocks: 24, maxSegmentBlocks: 4096 } + }); + const far = { x: 0.5 + 800, z: 0.5 }; + const waypoint = service._segmentGoal(far); + assert.ok(waypoint.x < far.x, '必须裁剪,否则寻路器会瞬间 noPath'); + assert.ok(waypoint.x > 0.5, '不能原地不动'); + assert.ok(Math.abs(waypoint.x - (0.5 + 128 - 24)) < 2, `段长应约为 已加载半径 - 边距,实际 ${waypoint.x}`); + assert.ok(Math.abs(waypoint.z - 0.5) < 1, '方向应保持指向目标'); +}); + +test('_segmentGoal 的段长同时受「单段上限」约束', () => { + const { service } = setupSegment({ + loadedRadius: 512, + config: { segmentMarginBlocks: 24, maxSegmentBlocks: 64 } + }); + const waypoint = service._segmentGoal({ x: 0.5 + 800, z: 0.5 }); + assert.ok(Math.abs(waypoint.x - (0.5 + 64)) < 3, + `即使已加载范围很大,单段也不该超过 maxSegmentBlocks,实际 ${waypoint.x}`); +}); + +test('_segmentGoal 目标在已加载范围内时不做裁剪', () => { + const { service } = setupSegment({ loadedRadius: 128 }); + const near = { x: 0.5 + 40, z: 0.5 }; + assert.deepStrictEqual(service._segmentGoal(near), near); +}); + +test('pathTo 长距离目标时下发的是分段中途点而不是最终目标', () => { + const { service, goals } = setupSegment({ loadedRadius: 128 }); + // 目标区块中心约在 (800, 0) + assert.strictEqual(service.pathTo({ x: 50, z: 0 }), true); + assert.strictEqual(goals.length, 1); + assert.strictEqual(service._waypointIsFinal, false, '这一段不是最终目标'); + assert.ok(service.currentWaypoint.x < 800); +}); + +test('pathTo 近距离目标时直接下发最终目标', () => { + const { service, goals } = setupSegment({ loadedRadius: 512 }); + assert.strictEqual(service.pathTo({ x: 5, z: 0 }), true); + assert.strictEqual(goals.length, 1); + assert.strictEqual(service._waypointIsFinal, true); +}); + +test('走到中途点后自动规划下一段,而不是当作已到达', () => { + const { bot, service, goals } = setupSegment({ loadedRadius: 128 }); + const events = []; + service.onPathEvent = (event) => events.push(event); + + service.pathTo({ x: 50, z: 0 }); + const before = goals.length; + bot.emit('goal_reached'); + + assert.strictEqual(goals.length, before + 1, '应继续规划下一段'); + assert.strictEqual(service._segmentCount, 1); + assert.ok(events.includes(PathEvent.SEGMENT_REACHED), '应发出 SEGMENT_REACHED 供控制器重置超时'); + assert.ok(!events.includes(PathEvent.AT_GOAL), '还没到最终目标,不能报到达'); +}); + +test('走到最终目标时发出 AT_GOAL 且不再规划', () => { + const { bot, service, goals } = setupSegment({ loadedRadius: 512 }); + const events = []; + service.onPathEvent = (event) => events.push(event); + + service.pathTo({ x: 5, z: 0 }); + const before = goals.length; + bot.emit('goal_reached'); + + assert.strictEqual(goals.length, before, '已到最终目标,不应再规划'); + assert.ok(events.includes(PathEvent.AT_GOAL)); +}); + +test('本段算出 noPath 时先缩短段长重试', () => { + const { service } = setupSegment({ loadedRadius: 128 }); + service.pathTo({ x: 50, z: 0 }); + const firstWaypoint = service.currentWaypoint.x; + + // 模拟寻路器报告本段不可达 + service.bot.emit('path_update', { status: 'noPath' }); + assert.strictEqual(service.calcFailed, false, '第一次应重试而不是直接失败'); + assert.ok(service.currentWaypoint.x < firstWaypoint, '段长应缩短'); +}); + +test('反复 noPath 最终会如实报失败,不会无限重试', () => { + const { service } = setupSegment({ loadedRadius: 128 }); + const events = []; + service.onPathEvent = (event) => events.push(event); + service.pathTo({ x: 50, z: 0 }); + for (let i = 0; i < 10; i++) service.bot.emit('path_update', { status: 'noPath' }); + assert.strictEqual(service.calcFailed, true); + assert.ok(events.includes(PathEvent.CALC_FAILED)); +}); + +test('cancel 清空分段状态', () => { + const { service } = setupSegment({ loadedRadius: 128 }); + service.pathTo({ x: 50, z: 0 }); + service.cancel(); + assert.strictEqual(service.finalTarget, null); + assert.strictEqual(service.currentWaypoint, null); + assert.strictEqual(service._segmentCount, 0); + assert.strictEqual(service.isPathing(), false); +}); + +test('configure 施加 Grim 合规约束(默认禁止挖 / 放 / 跑酷 / 搭塔)', () => { const bot = new FakeBot(); const movements = {}; bot.pathfinder = { setMovements: (m) => { movements.value = m; },