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,全部通过。
这个提交包含在:
+114
-13
@@ -129,7 +129,11 @@ class BotManager extends EventEmitter {
|
||||
lastInboundAt: 0,
|
||||
lastOutboundAt: 0,
|
||||
/** 出站包中 onGround 被世界数据纠正的次数 */
|
||||
onGroundFixed: 0
|
||||
onGroundFixed: 0,
|
||||
/** 每 tick 补发的 player_input 次数(1.21.2+ 移动预测必需) */
|
||||
playerInput: 0,
|
||||
/** 每 tick 补发的 tick_end 次数 */
|
||||
tickEnd: 0
|
||||
};
|
||||
}
|
||||
|
||||
@@ -308,6 +312,19 @@ class BotManager extends EventEmitter {
|
||||
this.emit('forcedMove');
|
||||
this._countForcedMove();
|
||||
});
|
||||
|
||||
// 自动接受服务器资源包:不接会一直卡在「加载资源包」而进不了服务器
|
||||
bot.on('resourcePack', (url) => {
|
||||
const text = typeof url === 'string' ? url : '';
|
||||
this._log('收到服务器资源包,自动接受:' + (text.length > 60 ? text.slice(0, 60) + '…' : text));
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (this.bot && typeof this.bot.acceptResourcePack === 'function') this.bot.acceptResourcePack();
|
||||
} catch (err) {
|
||||
this._log('接受资源包失败:' + (err && err.message ? err.message : String(err)), 'warn');
|
||||
}
|
||||
}, 200).unref?.();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -393,6 +410,10 @@ class BotManager extends EventEmitter {
|
||||
this.packetStats.outboundPosition++;
|
||||
this.packetStats.lastOutboundAt = Date.now();
|
||||
this._fixOutboundOnGround(params);
|
||||
} else if (name === 'player_input') {
|
||||
this.packetStats.playerInput++;
|
||||
} else if (name === 'tick_end') {
|
||||
this.packetStats.tickEnd++;
|
||||
}
|
||||
return originalWrite(name, params);
|
||||
};
|
||||
@@ -815,14 +836,13 @@ class BotManager extends EventEmitter {
|
||||
_worldDiagnostics() {
|
||||
const bot = this.bot;
|
||||
if (!bot || !bot.entity || !bot.world) return null;
|
||||
const columns = bot.world.columns || bot.world.columnStates || {};
|
||||
let columnCount = 0;
|
||||
if (columns instanceof Map) columnCount = columns.size;
|
||||
else if (typeof columns === 'object') columnCount = Object.keys(columns).length;
|
||||
const columns = this._loadedColumns();
|
||||
const below = bot.blockAt ? bot.blockAt(bot.entity.position.offset(0, -1, 0)) : null;
|
||||
const at = bot.blockAt ? bot.blockAt(bot.entity.position) : null;
|
||||
return {
|
||||
loadedColumns: columnCount,
|
||||
loadedColumns: columns.length,
|
||||
/** 已加载区块的包围盒:寻路器只能规划到已加载的范围内,这是「目标不可达」的常见原因 */
|
||||
loadedBounds: boundsOfColumns(columns),
|
||||
blockBelow: below ? below.name : null,
|
||||
blockBelowSolid: below ? below.boundingBox !== 'empty' : null,
|
||||
blockAtFeet: at ? at.name : null,
|
||||
@@ -833,6 +853,31 @@ class BotManager extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出当前已加载的全部区块列。
|
||||
*
|
||||
* prismarine-world 的列容器在不同版本里叫 `columns` / `columnStates`,
|
||||
* 也可能是 Map 或普通对象,这里统一处理,避免诊断本身报错。
|
||||
*
|
||||
* @returns {Array<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}
|
||||
@@ -841,18 +886,43 @@ class BotManager extends EventEmitter {
|
||||
const bot = this.bot;
|
||||
if (!bot || !bot.entity || !bot.blockAt) return null;
|
||||
const pos = bot.entity.position;
|
||||
const probe = (dx, dy, dz) => {
|
||||
const solid = (dx, dy, dz) => {
|
||||
const block = bot.blockAt(pos.offset(dx, dy, dz));
|
||||
if (!block) return null;
|
||||
return block.boundingBox !== 'empty';
|
||||
};
|
||||
const nameOf = (dx, dy, dz) => {
|
||||
const block = bot.blockAt(pos.offset(dx, dy, dz));
|
||||
return block ? block.name : null;
|
||||
};
|
||||
const dirs = [
|
||||
['north', 0, -1],
|
||||
['south', 0, 1],
|
||||
['east', 1, 0],
|
||||
['west', -1, 0]
|
||||
];
|
||||
/**
|
||||
* 每个方向同时看脚部、头部、以及该方向脚下的地面:
|
||||
* 只探测脚部无法区分「能走过去」与「一格高的槽里出不去」,
|
||||
* 而后者会让寻路器在起点附近就穷尽搜索(表现为 noPath + 节点数极少)。
|
||||
*/
|
||||
const exits = {};
|
||||
for (const [name, dx, dz] of dirs) {
|
||||
exits[name] = {
|
||||
feet: nameOf(dx, 0, dz),
|
||||
head: nameOf(dx, 1, dz),
|
||||
floor: nameOf(dx, -1, dz),
|
||||
walkable: solid(dx, 0, dz) === false && solid(dx, 1, dz) === false && solid(dx, -1, dz) === true
|
||||
};
|
||||
}
|
||||
return {
|
||||
north: probe(0, 0, -1),
|
||||
south: probe(0, 0, 1),
|
||||
east: probe(1, 0, 0),
|
||||
west: probe(-1, 0, 0),
|
||||
above: probe(0, 1, 0),
|
||||
below: probe(0, -0.05, 0)
|
||||
north: solid(0, 0, -1),
|
||||
south: solid(0, 0, 1),
|
||||
east: solid(1, 0, 0),
|
||||
west: solid(-1, 0, 0),
|
||||
above: solid(0, 1, 0),
|
||||
below: solid(0, -0.05, 0),
|
||||
exits
|
||||
};
|
||||
}
|
||||
|
||||
@@ -897,6 +967,37 @@ class BotManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算一组区块列的外接矩形(区块坐标)。
|
||||
* @param {Array<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 的消息通道 → 公屏日志类型。
|
||||
* @param {string} [position]
|
||||
|
||||
+83
-1
@@ -108,6 +108,9 @@ class GrimCompat {
|
||||
install() {
|
||||
if (this._installed) return;
|
||||
const bot = this.bot;
|
||||
// 协议补包与「合规层开关」无关:缺了它们机器人根本无法移动,
|
||||
// 属于「让客户端表现得像原版客户端」的必要动作,而不是可选的反作弊调优。
|
||||
this._installProtocolPackets(bot);
|
||||
if (!this.config.grim.enabled) {
|
||||
this._installed = true;
|
||||
return;
|
||||
@@ -137,12 +140,83 @@ class GrimCompat {
|
||||
if (!this._installed) return;
|
||||
const bot = this.bot;
|
||||
bot.removeListener('physicsTick', this._onPhysicsTick);
|
||||
if (this._onProtocolTick) bot.removeListener('physicsTick', this._onProtocolTick);
|
||||
if (this._originalLook) bot.look = this._originalLook;
|
||||
if (this._originalLookAt) bot.lookAt = this._originalLookAt;
|
||||
if (this._originalSetControlState) bot.setControlState = this._originalSetControlState;
|
||||
this._installed = false;
|
||||
}
|
||||
|
||||
// ==================== 协议补包 ====================
|
||||
|
||||
/**
|
||||
* 补发原版客户端每 tick 都会发、而 mineflayer 不发的两个包。
|
||||
*
|
||||
* 这是本项目在真实服务器上「机器人完全走不动」的根因(由旧版机器人实测得出):
|
||||
*
|
||||
* 1. **`player_input`**:1.21.2+ 服务端的移动预测依赖它,原版客户端每 tick 都发;
|
||||
* mineflayer 只在骑乘 / 跳下 / 潜行时发送,地面移动的 forward / jump / sprint 完全不发。
|
||||
* 带反作弊的服务器因此判定「没有任何输入却发生了位移」,
|
||||
* 每 tick 把玩家拉回原位置(setback)——表现就是原地抖动、位移恒为 0。
|
||||
* 2. **`tick_end`**:原版 1.21.2+ 客户端在每 tick 末尾发送,反作弊用它判定 tick 边界;
|
||||
* mineflayer 完全不发,会导致 PacketOrder / Timer 类判定异常。
|
||||
*
|
||||
* @param {import('mineflayer').Bot} bot
|
||||
*/
|
||||
_installProtocolPackets(bot) {
|
||||
const grim = this.config.grim;
|
||||
if (grim.sendPlayerInput === false && grim.sendTickEnd === false) return;
|
||||
if (!bot || !bot._client) return;
|
||||
|
||||
// 版本能力探测:newPlayerInputPacket 自 1.21.3 起存在
|
||||
try {
|
||||
this.playerInputSupported = bot.supportFeature('newPlayerInputPacket');
|
||||
} catch {
|
||||
this.playerInputSupported = false;
|
||||
}
|
||||
this.playerInputSent = 0;
|
||||
this.tickEndSent = 0;
|
||||
|
||||
this._onProtocolTick = () => {
|
||||
if (grim.sendPlayerInput !== false && this.playerInputSupported) {
|
||||
try {
|
||||
bot._client.write('player_input', {
|
||||
inputs: {
|
||||
forward: bot.getControlState('forward'),
|
||||
backward: bot.getControlState('back'),
|
||||
left: bot.getControlState('left'),
|
||||
right: bot.getControlState('right'),
|
||||
jump: bot.getControlState('jump'),
|
||||
shift: bot.getControlState('sneak'),
|
||||
sprint: bot.getControlState('sprint')
|
||||
}
|
||||
});
|
||||
this.playerInputSent++;
|
||||
} catch {
|
||||
// 发送失败不影响主流程
|
||||
}
|
||||
}
|
||||
if (grim.sendTickEnd !== false) {
|
||||
// 用 nextTick 保证 tick_end 排在本帧的 position 包之后
|
||||
// (mineflayer 在 physicsTick 处理完才发位置)
|
||||
process.nextTick(() => {
|
||||
try {
|
||||
if (bot._client && bot.entity) {
|
||||
bot._client.write('tick_end', {});
|
||||
this.tickEndSent++;
|
||||
}
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
bot.on('physicsTick', this._onProtocolTick);
|
||||
this._log('协议补包已启用(player_input '
|
||||
+ (this.playerInputSupported ? '已支持' : '当前版本不支持')
|
||||
+ ',tick_end 每帧发送)');
|
||||
}
|
||||
|
||||
// ==================== 旋转 ====================
|
||||
|
||||
/**
|
||||
@@ -508,7 +582,15 @@ class GrimCompat {
|
||||
attackCooldownMs: this.attackCooldownRemainingMs(),
|
||||
blockedActions: this.blockedActions,
|
||||
physicsOk: audit.ok,
|
||||
issues: audit.issues
|
||||
issues: audit.issues,
|
||||
/** 协议补包状态:线上服务器上「走不动」时首先看这里 */
|
||||
protocolPackets: {
|
||||
playerInputSupported: this.playerInputSupported === true,
|
||||
playerInputEnabled: this.config.grim.sendPlayerInput !== false,
|
||||
playerInputSent: this.playerInputSent || 0,
|
||||
tickEndEnabled: this.config.grim.sendTickEnd !== false,
|
||||
tickEndSent: this.tickEndSent || 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+177
-22
@@ -5,6 +5,11 @@ const pathfinderPlugin = require('mineflayer-pathfinder');
|
||||
const { GoalXZ, GoalNearXZ, Goal } = pathfinderPlugin.goals;
|
||||
const { Movements } = pathfinderPlugin;
|
||||
|
||||
/** 单次寻路最多允许分成多少段(防止「不可达」目标导致无限重规划) */
|
||||
const MAX_SEGMENTS = 200;
|
||||
/** 分段路径算出 noPath 时最多收缩几次段长 */
|
||||
const MAX_SEGMENT_SHRINK = 3;
|
||||
|
||||
/**
|
||||
* 寻路服务:替代原 Java 版的 BaritoneBridge。
|
||||
*
|
||||
@@ -24,7 +29,9 @@ const PathEvent = {
|
||||
CALC_FINISHED_NOW_EXECUTING: 'CALC_FINISHED_NOW_EXECUTING',
|
||||
CALC_FAILED: 'CALC_FAILED',
|
||||
AT_GOAL: 'AT_GOAL',
|
||||
CANCELED: 'CANCELED'
|
||||
CANCELED: 'CANCELED',
|
||||
/** 走完一段中途路径(长距离分段寻路时用,调用方应据此重置超时计时) */
|
||||
SEGMENT_REACHED: 'SEGMENT_REACHED'
|
||||
};
|
||||
|
||||
class PathingService {
|
||||
@@ -48,6 +55,26 @@ class PathingService {
|
||||
this._pathing = false;
|
||||
this._lastStatus = null;
|
||||
this._configured = false;
|
||||
/** 事件监听是否已注册(configure 会被反复调用,不能重复注册) */
|
||||
this._listenersBound = false;
|
||||
/**
|
||||
* 长距离分段寻路的状态。
|
||||
*
|
||||
* mineflayer-pathfinder 只能规划**已加载区块**范围内(实测本服约 8 区块 ≈ 130 格),
|
||||
* 而扩地目标常常在几十个区块之外,于是 A* 瞬间返回 noPath(表现为「目标不可达」)。
|
||||
* 解决办法是把整段旅程拆成若干段:先走到已加载范围边缘附近的中途点,
|
||||
* 走完后新区块已加载,再规划下一段,直到抵达真正的目标。
|
||||
*/
|
||||
this.finalTarget = null;
|
||||
/** 当前这一段的中途点(方块坐标) */
|
||||
this.currentWaypoint = null;
|
||||
/** 当前中途点是否就是最终目标 */
|
||||
this._waypointIsFinal = false;
|
||||
/** 本次 pathTo 已走的段数,用于防止无限循环 */
|
||||
this._segmentCount = 0;
|
||||
/** 段长收缩次数与系数(本段算出 noPath 时用来缩短段长重试) */
|
||||
this._segmentShrink = 0;
|
||||
this._segmentScale = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,21 +109,55 @@ class PathingService {
|
||||
movements.liquidCost = Math.max(movements.liquidCost, 200);
|
||||
}
|
||||
bot.pathfinder.setMovements(movements);
|
||||
// 搜索超时与半径:默认值偏保守,山区大地图容易 noPath
|
||||
bot.pathfinder.thinkTimeout = 10000;
|
||||
bot.pathfinder.tickTimeout = 40;
|
||||
bot.pathfinder.searchRadius = 256;
|
||||
// 搜索预算:默认值偏保守,山区 / 建筑密集区容易在起点附近就剪枝掉所有分支
|
||||
// (表现为 noPath 且「已探索节点数」极小)。searchRadius 为负表示不限制。
|
||||
bot.pathfinder.thinkTimeout = this.config.pathing.thinkTimeoutMs;
|
||||
bot.pathfinder.tickTimeout = this.config.pathing.tickTimeoutMs;
|
||||
bot.pathfinder.searchRadius = this.config.pathing.searchRadius;
|
||||
|
||||
bot.on('goal_reached', () => {
|
||||
// 事件监听只注册一次:configure() 在每次配置变更时都会重跑,
|
||||
// 重复注册会让同一个事件被处理多次(日志里同一秒出现多条相同事件就是这么来的)
|
||||
if (!this._listenersBound) {
|
||||
this._listenersBound = true;
|
||||
bot.on('goal_reached', () => {
|
||||
this._pathing = false;
|
||||
this._emit(PathEvent.AT_GOAL, { target: this.currentTarget });
|
||||
if (this._waypointIsFinal || !this.finalTarget) {
|
||||
this._emit(PathEvent.AT_GOAL, { target: this.currentTarget });
|
||||
return;
|
||||
}
|
||||
// 只走到了中途点:新区块已加载,继续朝最终目标规划下一段
|
||||
this._segmentCount++;
|
||||
if (this._segmentCount > MAX_SEGMENTS) {
|
||||
this._log(`分段寻路超过 ${MAX_SEGMENTS} 段仍未到达目标,判定为不可达`);
|
||||
this.calcFailed = true;
|
||||
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, reason: 'too-many-segments' });
|
||||
return;
|
||||
}
|
||||
this._log(`已到达中途点 ${Math.round(this.currentWaypoint.x)},${Math.round(this.currentWaypoint.z)},`
|
||||
+ `继续朝目标 ${this.finalTarget.x},${this.finalTarget.z} 前进(第 ${this._segmentCount} 段)`);
|
||||
this._emit(PathEvent.SEGMENT_REACHED, { segment: this._segmentCount, target: this.currentTarget });
|
||||
this._planSegment();
|
||||
});
|
||||
bot.on('path_update', (r) => {
|
||||
this._lastStatus = r.status;
|
||||
// 完整记录寻路器状态:长距离寻路卡住时,只看成功 / 失败是查不出原因的
|
||||
this._log(`寻路状态:${r.status}`
|
||||
+ (r.visitedNodes !== undefined ? `(已探索 ${r.visitedNodes} 节点)` : '')
|
||||
+ (r.cost !== undefined ? `(代价 ${r.cost})` : '')
|
||||
+ `,本段目标 ${this.currentWaypoint ? Math.round(this.currentWaypoint.x) + ',' + Math.round(this.currentWaypoint.z) : '无'}`
|
||||
+ `,最终目标 ${this.finalTarget ? Math.round(this.finalTarget.x) + ',' + Math.round(this.finalTarget.z) : '无'}`);
|
||||
if (r.status === 'success') {
|
||||
this.calcFailed = false;
|
||||
this._emit(PathEvent.CALC_FINISHED_NOW_EXECUTING, { cost: r.cost, nodes: r.visitedNodes });
|
||||
} else if (r.status === 'noPath') {
|
||||
// 分段后仍不可达:可能是已加载范围的探测偏乐观,缩短段长再试一次
|
||||
if (!this._waypointIsFinal && this.finalTarget && this._segmentShrink < MAX_SEGMENT_SHRINK) {
|
||||
this._segmentShrink++;
|
||||
this._segmentScale *= 0.6;
|
||||
this._log(`本段不可达,缩短段长后重试(第 ${this._segmentShrink}/${MAX_SEGMENT_SHRINK} 次)`);
|
||||
this._planSegment();
|
||||
return;
|
||||
}
|
||||
this.calcFailed = true;
|
||||
this._pathing = false;
|
||||
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget });
|
||||
@@ -106,11 +167,12 @@ class PathingService {
|
||||
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, timeout: true });
|
||||
}
|
||||
});
|
||||
bot.on('path_reset', (reason) => {
|
||||
if (reason !== 'chunk_loaded' && reason !== 'movement_update') {
|
||||
this._emit(PathEvent.CANCELED, { reason });
|
||||
}
|
||||
});
|
||||
bot.on('path_reset', (reason) => {
|
||||
if (reason !== 'chunk_loaded' && reason !== 'movement_update') {
|
||||
this._emit(PathEvent.CANCELED, { reason });
|
||||
}
|
||||
});
|
||||
}
|
||||
this.ready = true;
|
||||
this._configured = true;
|
||||
this._log(`寻路器就绪(挖方块 ${this.config.allowBreak ? '开' : '关'},`
|
||||
@@ -150,20 +212,37 @@ class PathingService {
|
||||
const bot = this.bot;
|
||||
if (!this.isAvailable()) return false;
|
||||
const opts = options || {};
|
||||
const blockX = chunk.x * 16 + 8;
|
||||
const blockZ = chunk.z * 16 + 8;
|
||||
const startY = Math.floor(bot.entity.position.y);
|
||||
let goal = new GoalNearXZ(blockX, blockZ, 2);
|
||||
if (opts.y !== undefined && opts.y !== null) {
|
||||
goal = new GoalNearXZ(blockX, blockZ, 2);
|
||||
}
|
||||
if (this.config.altitudePreference && this.config.altitudeWeight > 0) {
|
||||
goal = new AltitudeGoal(goal, this.config.altitudeWeight, startY);
|
||||
}
|
||||
this.currentTarget = { x: chunk.x, z: chunk.z };
|
||||
this.finalTarget = { x: chunk.x * 16 + 8, z: chunk.z * 16 + 8 };
|
||||
this._segmentCount = 0;
|
||||
this._segmentShrink = 0;
|
||||
this._segmentScale = 1;
|
||||
this.calcFailed = false;
|
||||
this._pathing = true;
|
||||
this._emit(PathEvent.CALC_STARTED, { target: this.currentTarget });
|
||||
return this._planSegment(opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规划「当前这一段」的路径:把最终目标裁剪到已加载范围内。
|
||||
*
|
||||
* @param {{y?: number}} [options]
|
||||
* @returns {boolean} 是否成功下发
|
||||
*/
|
||||
_planSegment(options) {
|
||||
const bot = this.bot;
|
||||
const opts = options || {};
|
||||
const goalBlock = this._segmentGoal(this.finalTarget);
|
||||
this.currentWaypoint = goalBlock;
|
||||
this._waypointIsFinal = goalBlock.x === this.finalTarget.x && goalBlock.z === this.finalTarget.z;
|
||||
|
||||
let goal = new GoalNearXZ(goalBlock.x, goalBlock.z, 2);
|
||||
if (this.config.altitudePreference && this.config.altitudeWeight > 0) {
|
||||
const startY = Math.floor(bot.entity.position.y);
|
||||
goal = new AltitudeGoal(goal, this.config.altitudeWeight, startY);
|
||||
}
|
||||
this.calcFailed = false;
|
||||
this._pathing = true;
|
||||
try {
|
||||
bot.pathfinder.setGoal(goal);
|
||||
} catch (err) {
|
||||
@@ -176,11 +255,87 @@ class PathingService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把最终目标裁剪到「当前已加载的范围内」。
|
||||
*
|
||||
* 不做任何世界内部结构的猜测:沿目标方向直接探测最远的已加载位置
|
||||
* (`bot.blockAt` 对未加载区块返回 null),再留出安全边距。
|
||||
*
|
||||
* @param {{x: number, z: number}} finalBlock 最终目标(方块坐标)
|
||||
* @returns {{x: number, z: number}} 本段实际要去的点
|
||||
*/
|
||||
_segmentGoal(finalBlock) {
|
||||
const bot = this.bot;
|
||||
if (!bot.entity || !finalBlock) return finalBlock;
|
||||
const pos = bot.entity.position;
|
||||
const dx = finalBlock.x - pos.x;
|
||||
const dz = finalBlock.z - pos.z;
|
||||
const distance = Math.hypot(dx, dz);
|
||||
if (distance <= 1) return finalBlock;
|
||||
|
||||
const pathing = this.config.pathing || {};
|
||||
const maxSegment = Math.max(16, pathing.maxSegmentBlocks || 512);
|
||||
const margin = Math.max(0, pathing.segmentMarginBlocks === undefined ? 24 : pathing.segmentMarginBlocks);
|
||||
const unitX = dx / distance;
|
||||
const unitZ = dz / distance;
|
||||
// 只需要探测到「可能用到的最大段长」就够了
|
||||
const loaded = this._probeLoadedDistance(unitX, unitZ, maxSegment + margin);
|
||||
// 段长同时受「已加载范围 - 边距」和「单段上限」两个约束
|
||||
const usable = Math.max(16, Math.min(loaded - margin, maxSegment) * this._segmentScale);
|
||||
if (usable >= distance) return finalBlock;
|
||||
return {
|
||||
x: Math.round(pos.x + unitX * usable),
|
||||
z: Math.round(pos.z + unitZ * usable)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 沿给定方向二分探测最远的「已加载」距离(方块)。
|
||||
*
|
||||
* 依据是已加载区块构成一个以玩家为中心的圆,因此「越远越可能未加载」是单调的。
|
||||
* 同时在脚下与脚上一格取样,避免刚好落在空气层里被误判为未加载。
|
||||
*
|
||||
* @param {number} unitX 单位方向 X
|
||||
* @param {number} unitZ 单位方向 Z
|
||||
* @param {number} maxBlocks 探测上限
|
||||
* @returns {number} 最远的已加载距离
|
||||
*/
|
||||
_probeLoadedDistance(unitX, unitZ, maxBlocks) {
|
||||
const bot = this.bot;
|
||||
if (!bot.blockAt || !bot.entity) return 0;
|
||||
const pos = bot.entity.position;
|
||||
const isLoaded = (d) => {
|
||||
try {
|
||||
const feet = bot.blockAt(pos.offset(unitX * d, 0, unitZ * d));
|
||||
if (feet) return true;
|
||||
return !!bot.blockAt(pos.offset(unitX * d, 1, unitZ * d));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
if (!isLoaded(0)) return 0;
|
||||
let lo = 0;
|
||||
let hi = maxBlocks;
|
||||
// 迭代次数决定精度:maxBlocks / 2^14 ≈ 0.25 格,足够贴合区块加载边界
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
if (isLoaded(mid)) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
/** 取消当前寻路 */
|
||||
cancel() {
|
||||
if (!this.isAvailable()) return;
|
||||
this._pathing = false;
|
||||
this.currentTarget = null;
|
||||
this.finalTarget = null;
|
||||
this.currentWaypoint = null;
|
||||
this._waypointIsFinal = false;
|
||||
this._segmentCount = 0;
|
||||
this._segmentShrink = 0;
|
||||
this._segmentScale = 1;
|
||||
try {
|
||||
this.bot.pathfinder.setGoal(null);
|
||||
} catch {
|
||||
|
||||
@@ -128,7 +128,30 @@ function defaultConfig() {
|
||||
/** 进入液体格的额外代价(越大越倾向绕开水域) */
|
||||
liquidCost: 40,
|
||||
/** 是否允许打开门/栅栏门 */
|
||||
canOpenDoors: false
|
||||
canOpenDoors: false,
|
||||
/**
|
||||
* 单段寻路的最长方块数(默认 512)。
|
||||
*
|
||||
* mineflayer-pathfinder 只能规划**已加载区块**范围内(实测本服约 8 区块 ≈ 130 格),
|
||||
* 而扩地目标常在几十个区块之外,A* 会瞬间返回 noPath。
|
||||
* 因此长距离会被拆成若干段:走到已加载范围边缘附近再重新规划下一段。
|
||||
* 这个值只是单段的上限,实际段长由「已加载范围」动态决定。
|
||||
*/
|
||||
maxSegmentBlocks: 96,
|
||||
/** 分段寻路时距已加载范围边缘留出的安全边距(方块),避免踩在未加载边界上 */
|
||||
segmentMarginBlocks: 24,
|
||||
/**
|
||||
* A* 单次搜索的时间预算(毫秒)。
|
||||
* 太小会在复杂地形里搜不完就放弃,表现为「目标不可达」。
|
||||
*/
|
||||
thinkTimeoutMs: 10000,
|
||||
/** A* 每 tick 的时间片(毫秒),越大单帧越卡但搜得更快 */
|
||||
tickTimeoutMs: 40,
|
||||
/**
|
||||
* A* 的代价搜索半径:节点代价超过「起点到目标的启发值 + 该值」就被剪枝。
|
||||
* 负数表示不限制(复杂地形里最稳,但搜索更慢)。
|
||||
*/
|
||||
searchRadius: 256
|
||||
},
|
||||
|
||||
// ==================== 战斗辅助(新增) ====================
|
||||
@@ -179,7 +202,23 @@ function defaultConfig() {
|
||||
* 被反作弊判定异常后每 tick 拉回一次,表现为机器人原地抖动、完全走不动。
|
||||
* 开启后发包前会用脚下方块复核,只有确实不一致时才改写。
|
||||
*/
|
||||
reportOnGroundFromWorld: true
|
||||
reportOnGroundFromWorld: true,
|
||||
/**
|
||||
* 是否每 tick 补发 `player_input` 包(默认开启,**线上服务器必需**)。
|
||||
*
|
||||
* 1.21.2+ 服务端的移动预测依赖它,原版客户端每 tick 都发,而 mineflayer 只在
|
||||
* 骑乘 / 跳下 / 潜行时发 —— 地面移动的 forward / jump / sprint 完全不发。
|
||||
* 带反作弊的服务器会判定「无输入却位移」并每 tick 把玩家拉回,
|
||||
* 表现就是机器人原地抖动、怎么按方向键都不动。
|
||||
*/
|
||||
sendPlayerInput: true,
|
||||
/**
|
||||
* 是否每 tick 补发 `tick_end` 包(默认开启)。
|
||||
*
|
||||
* 原版 1.21.2+ 客户端在每 tick 末尾发送,反作弊用它判定 tick 边界;
|
||||
* mineflayer 完全不发,会导致 PacketOrder / Timer 类判定异常。
|
||||
*/
|
||||
sendTickEnd: true
|
||||
},
|
||||
|
||||
// ==================== 大地图(新增) ====================
|
||||
@@ -335,6 +374,12 @@ function validateConfig(config) {
|
||||
|
||||
config.pathing.maxDropDown = clampInt(config.pathing.maxDropDown, 0, 64, 3);
|
||||
config.pathing.liquidCost = clampInt(config.pathing.liquidCost, 1, 1000, 40);
|
||||
config.pathing.maxSegmentBlocks = clampInt(config.pathing.maxSegmentBlocks, 32, 4096, 512);
|
||||
config.pathing.segmentMarginBlocks = clampInt(config.pathing.segmentMarginBlocks, 0, 256, 24);
|
||||
config.pathing.thinkTimeoutMs = clampInt(config.pathing.thinkTimeoutMs, 100, 300000, 10000);
|
||||
config.pathing.tickTimeoutMs = clampInt(config.pathing.tickTimeoutMs, 5, 5000, 40);
|
||||
// searchRadius 允许为负(不限制),因此单独钳制下限
|
||||
config.pathing.searchRadius = clampInt(config.pathing.searchRadius, -1, 100000, 256);
|
||||
|
||||
config.combat.scanRange = clampNumber(config.combat.scanRange, 1, 32, 4.5);
|
||||
|
||||
|
||||
@@ -747,6 +747,10 @@ class ExpansionController {
|
||||
this._log('寻路事件:AT_GOAL');
|
||||
} else if (event === PathEvent.CALC_FINISHED_NOW_EXECUTING) {
|
||||
this._log(`寻路事件:路径已生成(代价 ${detail ? detail.cost : '?'})`);
|
||||
} else if (event === PathEvent.SEGMENT_REACHED) {
|
||||
// 长距离分段寻路走完一段:这是「有进展」而不是卡住,
|
||||
// 必须重置超时计时,否则走几十段的长途会被 waitTimeoutSeconds 误判为寻路超时
|
||||
this.stageStartedMillis = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-2
@@ -180,7 +180,9 @@ const CONFIG_SCHEMA = {
|
||||
{ path: 'pathing.allow1by1towers', label: '允许 1×1 搭塔', type: 'checkbox' },
|
||||
{ path: 'pathing.maxDropDown', label: '最大下落高度(格)', type: 'number', min: 0 },
|
||||
{ path: 'pathing.liquidCost', label: '液体格额外代价', type: 'number', min: 1 },
|
||||
{ path: 'pathing.canOpenDoors', label: '允许开门', type: 'checkbox' }
|
||||
{ path: 'pathing.canOpenDoors', label: '允许开门', type: 'checkbox' },
|
||||
{ path: 'pathing.maxSegmentBlocks', label: '单段寻路最长距离(格)', type: 'number', min: 32, hint: '寻路器只能规划已加载区块范围(本服约 130 格),长距离会自动拆段;此项只是单段上限' },
|
||||
{ path: 'pathing.segmentMarginBlocks', label: '分段安全边距(格)', type: 'number', min: 0, hint: '分段时距已加载范围边缘留出的余量,避免踩在未加载边界上' }
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -260,7 +262,9 @@ const CONFIG_SCHEMA = {
|
||||
{ path: 'grim.blockConflictingActions', label: '拦截冲突动作组合', type: 'checkbox' },
|
||||
{ path: 'grim.checkPhysicsIntegrity', label: '启动物理参数自检', type: 'checkbox' },
|
||||
{ path: 'grim.blockActionsWhileRiding', label: '骑乘时禁止攻击 / 进食', type: 'checkbox' },
|
||||
{ path: 'grim.reportOnGroundFromWorld', label: '按世界数据修正 onGround', type: 'checkbox', hint: '服务端频繁下发位置包时,mineflayer 会持续上报「站地上却在空中」而被反作弊每 tick 拉回;开启后在发包前用脚下方块复核(实测本服必需)' }
|
||||
{ path: 'grim.reportOnGroundFromWorld', label: '按世界数据修正 onGround', type: 'checkbox', hint: '服务端频繁下发位置包时,mineflayer 会持续上报「站地上却在空中」而被反作弊每 tick 拉回;开启后在发包前用脚下方块复核(实测本服必需)' },
|
||||
{ path: 'grim.sendPlayerInput', label: '每 tick 补发 player_input', type: 'checkbox', hint: '1.21.2+ 移动预测依赖它;mineflayer 地面移动时完全不发,导致反作弊判定「无输入却位移」并把机器人每 tick 拉回(实测本服必需)' },
|
||||
{ path: 'grim.sendTickEnd', label: '每 tick 补发 tick_end', type: 'checkbox', hint: '原版 1.21.2+ 客户端每 tick 末尾发送,反作弊用它判定 tick 边界' }
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -1474,6 +1478,21 @@ function connectWs() {
|
||||
|
||||
// ==================== 事件绑定 ====================
|
||||
|
||||
/**
|
||||
* 绑定登录相关的按钮。
|
||||
*
|
||||
* 必须在 `main()` 里无条件调用:这些按钮要在「尚未登录」时就能用,
|
||||
* 而 `bindEvents()` 只在 `startApp()` 里执行,`startApp()` 又只在登录成功后才跑 ——
|
||||
* 把登录按钮放进去会形成死锁,表现就是「输入密码点登录没反应」。
|
||||
*/
|
||||
function bindAuthEvents() {
|
||||
$('btnLogin').addEventListener('click', doLogin);
|
||||
$('loginPassword').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') doLogin();
|
||||
});
|
||||
$('btnLogout').addEventListener('click', doLogout);
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
$('botSelect').addEventListener('change', () => selectBot($('botSelect').value));
|
||||
|
||||
@@ -1881,6 +1900,8 @@ async function startApp() {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// 登录按钮必须最先绑定:未登录时 startApp() 不会执行,绑在里面就点不动
|
||||
bindAuthEvents();
|
||||
const ok = await checkSession();
|
||||
if (ok) await startApp();
|
||||
else $('loginPassword').focus();
|
||||
|
||||
在新工单中引用
屏蔽一个用户