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,全部通过。
这个提交包含在:
+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)后,暴露并修掉了一批
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+177
-22
@@ -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;
|
||||||
|
|
||||||
bot.on('goal_reached', () => {
|
// 事件监听只注册一次:configure() 在每次配置变更时都会重跑,
|
||||||
|
// 重复注册会让同一个事件被处理多次(日志里同一秒出现多条相同事件就是这么来的)
|
||||||
|
if (!this._listenersBound) {
|
||||||
|
this._listenersBound = true;
|
||||||
|
bot.on('goal_reached', () => {
|
||||||
this._pathing = false;
|
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) => {
|
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 });
|
||||||
@@ -106,11 +167,12 @@ class PathingService {
|
|||||||
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, timeout: true });
|
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, timeout: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
bot.on('path_reset', (reason) => {
|
bot.on('path_reset', (reason) => {
|
||||||
if (reason !== 'chunk_loaded' && reason !== 'movement_update') {
|
if (reason !== 'chunk_loaded' && reason !== 'movement_update') {
|
||||||
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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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; },
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户