fix: 修复登录按钮死锁,移植旧版 player_input 补包解决「机器人走不动」

控制台登录按钮点不动(致命)
- 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_end(原版每 tick 末尾发送,反作弊用它判定 tick 边界,mineflayer 完全不发)
- 资源包未自动接受会卡在加载界面进不了世界,现自动接受
- configure() 每次配置变更都重复注册寻路事件监听,导致同一事件被处理多次

长距离分段寻路
- mineflayer-pathfinder 只能规划已加载区块范围(实测约 8 区块 ≈ 130 格),
  扩地目标常在几十个区块外,A* 瞬间 noPath。现在自动拆段:走到已加载范围边缘 → 新区块加载 → 再规划。
  段长用 bot.blockAt 对未加载区块返回 null 的特性二分探测,不依赖世界内部结构。

新增
- pathing.thinkTimeoutMs / tickTimeoutMs / searchRadius(负值表示不限制剪枝)
- 位置包收发计数、player_input/tick_end 计数、游戏模式、维度、脚下方块、
  六向「脚部/头部/地面」与可通行判定等诊断字段
- 手动移动测试按钮(绕开寻路器直接按方向键并回报真实位移)

已知限制
- 机器人当前所在位置 (6759,72,11926) 无法寻路:A* 恒定只展开 47 个节点就 noPath。
  已逐项排除挖方块/放方块/跑酷/搭塔/开门/最大下落(试到 64 格)/高度偏好/水域代价/
  搜索半径(试到不限制)/时间预算/依赖版本(与能正常移动的旧机器人完全一致)。
  旧机器人日志证明寻路可用(2 次 goal_reached、2 秒走 8.7 格),但它当时在 y=-59,
  本机器人被 /spawn 放到了 y=72。下一步需把机器人挪到开阔地再试。

测试 290 -> 308,全部通过。
这个提交包含在:
lyizai
2026-09-27 23:52:29 +08:00
父节点 abecdd2648
当前提交 fcd5fb2bd9
共修改 11 个文件,包含 783 行新增和 49 行删除
+45
查看文件
@@ -2,6 +2,51 @@
本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。 本项目遵循 [约定式提交](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 ## [1.2.0] - 2026-09-27
首次真正连上线上服务器(Debian 12 + pm2 + 端口 78)后,暴露并修掉了一批 首次真正连上线上服务器(Debian 12 + pm2 + 端口 78)后,暴露并修掉了一批
+36 -5
查看文件
@@ -247,23 +247,54 @@ pm2 startup systemd -u root --hp /root # 开机自启
- **位移正常、但任务报「目标不可达」** → 是规划问题(距离太远、被地形挡住、挖/放方块被 Grim 约束禁用) - **位移正常、但任务报「目标不可达」** → 是规划问题(距离太远、被地形挡住、挖/放方块被 Grim 约束禁用)
- **位移 ≈ 0** → 客户端移动被服务端拒绝,属于协议 / 反作弊交互问题,改规划参数没有意义 - **位移 ≈ 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.outboundPosition` | 客户端上报的位置包次数 |
| `packetStats.playerInput` / `tickEnd` | 每 tick 补发的协议包次数(应约等于 20/秒) |
| `packetStats.onGroundFixed` | 出站包中 onGround 被世界数据纠正的次数 | | `packetStats.onGroundFixed` | 出站包中 onGround 被世界数据纠正的次数 |
| `onGround` | 客户端当前认为自己在不在空中 | | `onGround` | 客户端当前认为自己在不在空中 |
| `gameMode` / `dimension` | 游戏模式与维度(adventure / spectator 会限制移动) | | `gameMode` / `dimension` | 游戏模式与维度(adventure / spectator 会限制移动) |
| `world.blockBelow` / `world.surroundings` | 脚下方块与六向实心情况(判断是否被困住) | | `world.blockBelow` / `world.surroundings.exits` | 脚下方块与各方向「脚部 / 头部 / 地面」方块名 + 可通行判定 |
| `grim.protocolPackets` | 协议补包是否生效及发送计数 |
判读方式: 判读方式:
- 入站高、出站为 0 → 客户端根本没上报位置 - 入站高、出站为 0 → 客户端根本没上报位置
- 出入都高但位移为 0 → 客户端在报,服务端不认(回弹) - 出入都高但位移为 0 → 客户端在报,服务端不认(回弹)
- `onGround` 恒为 false 而 `world.blockBelowSolid` 为 true → 客户端上报「站在地上却在空中」, - `onGround` 恒为 false 而 `world.blockBelowSolid` 为 true → 开启 `grim.reportOnGroundFromWorld`
开启 `grim.reportOnGroundFromWorld` 可修正 - **寻路日志里「已探索节点数」极小(如几十个)就 noPath** → 不是距离问题,是移动生成器
从起点就找不到分支:检查 `world.surroundings.exits` 的 `walkable`,或把机器人挪到开阔地
--- ---
+114 -13
查看文件
@@ -129,7 +129,11 @@ class BotManager extends EventEmitter {
lastInboundAt: 0, lastInboundAt: 0,
lastOutboundAt: 0, lastOutboundAt: 0,
/** 出站包中 onGround 被世界数据纠正的次数 */ /** 出站包中 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.emit('forcedMove');
this._countForcedMove(); 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.outboundPosition++;
this.packetStats.lastOutboundAt = Date.now(); this.packetStats.lastOutboundAt = Date.now();
this._fixOutboundOnGround(params); this._fixOutboundOnGround(params);
} else if (name === 'player_input') {
this.packetStats.playerInput++;
} else if (name === 'tick_end') {
this.packetStats.tickEnd++;
} }
return originalWrite(name, params); return originalWrite(name, params);
}; };
@@ -815,14 +836,13 @@ class BotManager extends EventEmitter {
_worldDiagnostics() { _worldDiagnostics() {
const bot = this.bot; const bot = this.bot;
if (!bot || !bot.entity || !bot.world) return null; if (!bot || !bot.entity || !bot.world) return null;
const columns = bot.world.columns || bot.world.columnStates || {}; const columns = this._loadedColumns();
let columnCount = 0;
if (columns instanceof Map) columnCount = columns.size;
else if (typeof columns === 'object') columnCount = Object.keys(columns).length;
const below = bot.blockAt ? bot.blockAt(bot.entity.position.offset(0, -1, 0)) : null; const below = bot.blockAt ? bot.blockAt(bot.entity.position.offset(0, -1, 0)) : null;
const at = bot.blockAt ? bot.blockAt(bot.entity.position) : null; const at = bot.blockAt ? bot.blockAt(bot.entity.position) : null;
return { return {
loadedColumns: columnCount, loadedColumns: columns.length,
/** 已加载区块的包围盒:寻路器只能规划到已加载的范围内,这是「目标不可达」的常见原因 */
loadedBounds: boundsOfColumns(columns),
blockBelow: below ? below.name : null, blockBelow: below ? below.name : null,
blockBelowSolid: below ? below.boundingBox !== 'empty' : null, blockBelowSolid: below ? below.boundingBox !== 'empty' : null,
blockAtFeet: at ? at.name : null, blockAtFeet: at ? at.name : null,
@@ -833,6 +853,31 @@ class BotManager extends EventEmitter {
}; };
} }
/**
* 取出当前已加载的全部区块列。
*
* prismarine-world 的列容器在不同版本里叫 `columns` / `columnStates`,
* 也可能是 Map 或普通对象,这里统一处理,避免诊断本身报错。
*
* @returns {Array<object>}
*/
_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} * @returns {object|null}
@@ -841,18 +886,43 @@ class BotManager extends EventEmitter {
const bot = this.bot; const bot = this.bot;
if (!bot || !bot.entity || !bot.blockAt) return null; if (!bot || !bot.entity || !bot.blockAt) return null;
const pos = bot.entity.position; const pos = bot.entity.position;
const probe = (dx, dy, dz) => { const solid = (dx, dy, dz) => {
const block = bot.blockAt(pos.offset(dx, dy, dz)); const block = bot.blockAt(pos.offset(dx, dy, dz));
if (!block) return null; if (!block) return null;
return block.boundingBox !== 'empty'; 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 { return {
north: probe(0, 0, -1), north: solid(0, 0, -1),
south: probe(0, 0, 1), south: solid(0, 0, 1),
east: probe(1, 0, 0), east: solid(1, 0, 0),
west: probe(-1, 0, 0), west: solid(-1, 0, 0),
above: probe(0, 1, 0), above: solid(0, 1, 0),
below: probe(0, -0.05, 0) below: solid(0, -0.05, 0),
exits
}; };
} }
@@ -897,6 +967,37 @@ class BotManager extends EventEmitter {
} }
} }
/**
* 计算一组区块列的外接矩形(区块坐标)。
* @param {Array<object>} 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 的消息通道 → 公屏日志类型。 * mineflayer 的消息通道 → 公屏日志类型。
* @param {string} [position] * @param {string} [position]
+83 -1
查看文件
@@ -108,6 +108,9 @@ class GrimCompat {
install() { install() {
if (this._installed) return; if (this._installed) return;
const bot = this.bot; const bot = this.bot;
// 协议补包与「合规层开关」无关:缺了它们机器人根本无法移动,
// 属于「让客户端表现得像原版客户端」的必要动作,而不是可选的反作弊调优。
this._installProtocolPackets(bot);
if (!this.config.grim.enabled) { if (!this.config.grim.enabled) {
this._installed = true; this._installed = true;
return; return;
@@ -137,12 +140,83 @@ class GrimCompat {
if (!this._installed) return; if (!this._installed) return;
const bot = this.bot; const bot = this.bot;
bot.removeListener('physicsTick', this._onPhysicsTick); bot.removeListener('physicsTick', this._onPhysicsTick);
if (this._onProtocolTick) bot.removeListener('physicsTick', this._onProtocolTick);
if (this._originalLook) bot.look = this._originalLook; if (this._originalLook) bot.look = this._originalLook;
if (this._originalLookAt) bot.lookAt = this._originalLookAt; if (this._originalLookAt) bot.lookAt = this._originalLookAt;
if (this._originalSetControlState) bot.setControlState = this._originalSetControlState; if (this._originalSetControlState) bot.setControlState = this._originalSetControlState;
this._installed = false; 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(), attackCooldownMs: this.attackCooldownRemainingMs(),
blockedActions: this.blockedActions, blockedActions: this.blockedActions,
physicsOk: audit.ok, 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
}
}; };
} }
+170 -15
查看文件
@@ -5,6 +5,11 @@ const pathfinderPlugin = require('mineflayer-pathfinder');
const { GoalXZ, GoalNearXZ, Goal } = pathfinderPlugin.goals; const { GoalXZ, GoalNearXZ, Goal } = pathfinderPlugin.goals;
const { Movements } = pathfinderPlugin; const { Movements } = pathfinderPlugin;
/** 单次寻路最多允许分成多少段(防止「不可达」目标导致无限重规划) */
const MAX_SEGMENTS = 200;
/** 分段路径算出 noPath 时最多收缩几次段长 */
const MAX_SEGMENT_SHRINK = 3;
/** /**
* 寻路服务:替代原 Java 版的 BaritoneBridge。 * 寻路服务:替代原 Java 版的 BaritoneBridge。
* *
@@ -24,7 +29,9 @@ const PathEvent = {
CALC_FINISHED_NOW_EXECUTING: 'CALC_FINISHED_NOW_EXECUTING', CALC_FINISHED_NOW_EXECUTING: 'CALC_FINISHED_NOW_EXECUTING',
CALC_FAILED: 'CALC_FAILED', CALC_FAILED: 'CALC_FAILED',
AT_GOAL: 'AT_GOAL', AT_GOAL: 'AT_GOAL',
CANCELED: 'CANCELED' CANCELED: 'CANCELED',
/** 走完一段中途路径(长距离分段寻路时用,调用方应据此重置超时计时) */
SEGMENT_REACHED: 'SEGMENT_REACHED'
}; };
class PathingService { class PathingService {
@@ -48,6 +55,26 @@ class PathingService {
this._pathing = false; this._pathing = false;
this._lastStatus = null; this._lastStatus = null;
this._configured = false; 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); movements.liquidCost = Math.max(movements.liquidCost, 200);
} }
bot.pathfinder.setMovements(movements); bot.pathfinder.setMovements(movements);
// 搜索超时与半径:默认值偏保守,山区大地图容易 noPath // 搜索预算:默认值偏保守,山区 / 建筑密集区容易在起点附近就剪枝掉所有分支
bot.pathfinder.thinkTimeout = 10000; // (表现为 noPath 且「已探索节点数」极小)。searchRadius 为负表示不限制。
bot.pathfinder.tickTimeout = 40; bot.pathfinder.thinkTimeout = this.config.pathing.thinkTimeoutMs;
bot.pathfinder.searchRadius = 256; bot.pathfinder.tickTimeout = this.config.pathing.tickTimeoutMs;
bot.pathfinder.searchRadius = this.config.pathing.searchRadius;
// 事件监听只注册一次:configure() 在每次配置变更时都会重跑,
// 重复注册会让同一个事件被处理多次(日志里同一秒出现多条相同事件就是这么来的)
if (!this._listenersBound) {
this._listenersBound = true;
bot.on('goal_reached', () => { bot.on('goal_reached', () => {
this._pathing = false; this._pathing = false;
if (this._waypointIsFinal || !this.finalTarget) {
this._emit(PathEvent.AT_GOAL, { target: this.currentTarget }); 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) => { bot.on('path_update', (r) => {
this._lastStatus = r.status; 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') { if (r.status === 'success') {
this.calcFailed = false; this.calcFailed = false;
this._emit(PathEvent.CALC_FINISHED_NOW_EXECUTING, { cost: r.cost, nodes: r.visitedNodes }); this._emit(PathEvent.CALC_FINISHED_NOW_EXECUTING, { cost: r.cost, nodes: r.visitedNodes });
} else if (r.status === 'noPath') { } 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.calcFailed = true;
this._pathing = false; this._pathing = false;
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget }); this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget });
@@ -111,6 +172,7 @@ class PathingService {
this._emit(PathEvent.CANCELED, { reason }); this._emit(PathEvent.CANCELED, { reason });
} }
}); });
}
this.ready = true; this.ready = true;
this._configured = true; this._configured = true;
this._log(`寻路器就绪(挖方块 ${this.config.allowBreak ? '开' : '关'},` this._log(`寻路器就绪(挖方块 ${this.config.allowBreak ? '开' : '关'},`
@@ -150,20 +212,37 @@ class PathingService {
const bot = this.bot; const bot = this.bot;
if (!this.isAvailable()) return false; if (!this.isAvailable()) return false;
const opts = options || {}; 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.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.calcFailed = false;
this._pathing = true; this._pathing = true;
this._emit(PathEvent.CALC_STARTED, { target: this.currentTarget }); 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 { try {
bot.pathfinder.setGoal(goal); bot.pathfinder.setGoal(goal);
} catch (err) { } catch (err) {
@@ -176,11 +255,87 @@ class PathingService {
return true; 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() { cancel() {
if (!this.isAvailable()) return; if (!this.isAvailable()) return;
this._pathing = false; this._pathing = false;
this.currentTarget = null; this.currentTarget = null;
this.finalTarget = null;
this.currentWaypoint = null;
this._waypointIsFinal = false;
this._segmentCount = 0;
this._segmentShrink = 0;
this._segmentScale = 1;
try { try {
this.bot.pathfinder.setGoal(null); this.bot.pathfinder.setGoal(null);
} catch { } catch {
+47 -2
查看文件
@@ -128,7 +128,30 @@ function defaultConfig() {
/** 进入液体格的额外代价(越大越倾向绕开水域) */ /** 进入液体格的额外代价(越大越倾向绕开水域) */
liquidCost: 40, 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 拉回一次,表现为机器人原地抖动、完全走不动。 * 被反作弊判定异常后每 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.maxDropDown = clampInt(config.pathing.maxDropDown, 0, 64, 3);
config.pathing.liquidCost = clampInt(config.pathing.liquidCost, 1, 1000, 40); 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); config.combat.scanRange = clampNumber(config.combat.scanRange, 1, 32, 4.5);
+4
查看文件
@@ -747,6 +747,10 @@ class ExpansionController {
this._log('寻路事件:AT_GOAL'); this._log('寻路事件:AT_GOAL');
} else if (event === PathEvent.CALC_FINISHED_NOW_EXECUTING) { } else if (event === PathEvent.CALC_FINISHED_NOW_EXECUTING) {
this._log(`寻路事件:路径已生成(代价 ${detail ? detail.cost : '?'})`); this._log(`寻路事件:路径已生成(代价 ${detail ? detail.cost : '?'})`);
} else if (event === PathEvent.SEGMENT_REACHED) {
// 长距离分段寻路走完一段:这是「有进展」而不是卡住,
// 必须重置超时计时,否则走几十段的长途会被 waitTimeoutSeconds 误判为寻路超时
this.stageStartedMillis = Date.now();
} }
} }
+23 -2
查看文件
@@ -180,7 +180,9 @@ const CONFIG_SCHEMA = {
{ path: 'pathing.allow1by1towers', label: '允许 1×1 搭塔', type: 'checkbox' }, { path: 'pathing.allow1by1towers', label: '允许 1×1 搭塔', type: 'checkbox' },
{ path: 'pathing.maxDropDown', label: '最大下落高度(格)', type: 'number', min: 0 }, { path: 'pathing.maxDropDown', label: '最大下落高度(格)', type: 'number', min: 0 },
{ path: 'pathing.liquidCost', label: '液体格额外代价', type: 'number', min: 1 }, { 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.blockConflictingActions', label: '拦截冲突动作组合', type: 'checkbox' },
{ path: 'grim.checkPhysicsIntegrity', label: '启动物理参数自检', type: 'checkbox' }, { path: 'grim.checkPhysicsIntegrity', label: '启动物理参数自检', type: 'checkbox' },
{ path: 'grim.blockActionsWhileRiding', 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() { function bindEvents() {
$('botSelect').addEventListener('change', () => selectBot($('botSelect').value)); $('botSelect').addEventListener('change', () => selectBot($('botSelect').value));
@@ -1881,6 +1900,8 @@ async function startApp() {
} }
async function main() { async function main() {
// 登录按钮必须最先绑定:未登录时 startApp() 不会执行,绑在里面就点不动
bindAuthEvents();
const ok = await checkSession(); const ok = await checkSession();
if (ok) await startApp(); if (ok) await startApp();
else $('loginPassword').focus(); else $('loginPassword').focus();
+78 -2
查看文件
@@ -26,8 +26,84 @@ function setup(options) {
return { bot, grim, config }; 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° // 从 170° 到 -170° 应走 +20° 而不是 -340°
const from = (170 * Math.PI) / 180; const from = (170 * Math.PI) / 180;
const to = (-170 * Math.PI) / 180; const to = (-170 * Math.PI) / 180;
+39
查看文件
@@ -57,7 +57,37 @@ class FakeBot extends EventEmitter {
this.registry = opts.registry || defaultRegistry(); this.registry = opts.registry || defaultRegistry();
this.pathfinder = null; this.pathfinder = null;
this.blockMap = opts.blockMap || new Map(); this.blockMap = opts.blockMap || new Map();
/**
* 模拟「已加载区块范围」:设置后,超出该半径(方块)的位置 blockAt 返回 null,
* 用于测试寻路器只能规划已加载范围、需要分段寻路的行为。
* 圆心始终跟随实体当前位置,模拟「人走到哪、区块加载到哪」。
*/
this.loadedRadius = opts.loadedRadius === undefined ? null : opts.loadedRadius;
this.creative = { flying: false }; 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) { getControlState(control) {
@@ -72,6 +102,10 @@ class FakeBot extends EventEmitter {
blockAt(pos) { blockAt(pos) {
const key = `${Math.floor(pos.x)},${Math.floor(pos.y)},${Math.floor(pos.z)}`; 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.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' }; return { name: 'air', boundingBox: 'empty' };
} }
@@ -136,6 +170,11 @@ class FakeBot extends EventEmitter {
tickTimes(n) { tickTimes(n) {
for (let i = 0; i < n; i++) this.emit('physicsTick'); for (let i = 0; i < n; i++) this.emit('physicsTick');
} }
/** 接受资源包(记录调用,供测试断言) */
acceptResourcePack() {
this.resourcePackAccepted = true;
}
} }
module.exports = { FakeBot }; module.exports = { FakeBot };
+137 -2
查看文件
@@ -35,8 +35,143 @@ test('AltitudeGoal 使用 GoalNearXZ 时保留其到达范围', () => {
assert.strictEqual(goal.isEnd({ x: 100, y: 70, z: 201 }), true, '范围内算到达'); 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 = {}; const movements = {};
bot.pathfinder = { bot.pathfinder = {
setMovements: (m) => { movements.value = m; }, setMovements: (m) => { movements.value = m; },