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,全部通过。
这个提交包含在:
+78
-2
@@ -26,8 +26,84 @@ function setup(options) {
|
||||
return { bot, grim, config };
|
||||
}
|
||||
|
||||
test('shortestAngleDelta 走最短路径并处理环绕', () => {
|
||||
assert.ok(Math.abs(shortestAngleDelta(0, 0.5) - 0.5) < 1e-9);
|
||||
// ==================== 协议补包(线上服务器「走不动」的根因)====================
|
||||
|
||||
/** 等一轮宏任务,让 process.nextTick 里发出的 tick_end 落地 */
|
||||
function flushTick() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
test('每 tick 补发 player_input,内容反映当前控制键', async () => {
|
||||
const { bot } = setup();
|
||||
bot.setControlState('forward', true);
|
||||
bot.setControlState('sprint', true);
|
||||
bot.clearWritten();
|
||||
|
||||
bot.tick();
|
||||
const packets = bot.writtenPackets('player_input');
|
||||
assert.strictEqual(packets.length, 1, '每个 physicsTick 都应补发一次 player_input');
|
||||
assert.strictEqual(packets[0].params.inputs.forward, true);
|
||||
assert.strictEqual(packets[0].params.inputs.sprint, true);
|
||||
assert.strictEqual(packets[0].params.inputs.jump, false);
|
||||
assert.strictEqual(packets[0].params.inputs.backward, false);
|
||||
assert.strictEqual(packets[0].params.inputs.shift, false);
|
||||
});
|
||||
|
||||
test('每 tick 补发 tick_end,且排在本帧位置包之后', async () => {
|
||||
const { bot } = setup();
|
||||
bot.clearWritten();
|
||||
bot.tick();
|
||||
assert.strictEqual(bot.writtenPackets('tick_end').length, 0, 'tick_end 应延后到下一轮微任务/宏任务');
|
||||
await flushTick();
|
||||
assert.strictEqual(bot.writtenPackets('tick_end').length, 1);
|
||||
});
|
||||
|
||||
test('版本不支持 player_input 时只补 tick_end,不报错', async () => {
|
||||
const { bot } = setup({ bot: { features: { newPlayerInputPacket: false } } });
|
||||
bot.clearWritten();
|
||||
bot.tick();
|
||||
await flushTick();
|
||||
assert.strictEqual(bot.writtenPackets('player_input').length, 0);
|
||||
assert.strictEqual(bot.writtenPackets('tick_end').length, 1);
|
||||
});
|
||||
|
||||
test('关闭开关后不再补发对应包', async () => {
|
||||
const { bot } = setup({ config: { grim: { sendPlayerInput: false, sendTickEnd: false } } });
|
||||
bot.clearWritten();
|
||||
bot.tick();
|
||||
await flushTick();
|
||||
assert.strictEqual(bot._client.written.length, 0);
|
||||
});
|
||||
|
||||
test('关掉 Grim 合规层后协议补包仍然生效(否则机器人根本走不动)', async () => {
|
||||
const { bot } = setup({ config: { grim: { enabled: false } } });
|
||||
bot.clearWritten();
|
||||
bot.tick();
|
||||
await flushTick();
|
||||
assert.strictEqual(bot.writtenPackets('player_input').length, 1);
|
||||
assert.strictEqual(bot.writtenPackets('tick_end').length, 1);
|
||||
});
|
||||
|
||||
test('uninstall 后不再补包', async () => {
|
||||
const { bot, grim } = setup();
|
||||
grim.uninstall();
|
||||
bot.clearWritten();
|
||||
bot.tick();
|
||||
await flushTick();
|
||||
assert.strictEqual(bot._client.written.length, 0);
|
||||
});
|
||||
|
||||
test('snapshot 暴露协议补包状态与计数', () => {
|
||||
const { bot, grim } = setup();
|
||||
bot.tick();
|
||||
const snap = grim.snapshot();
|
||||
assert.strictEqual(snap.protocolPackets.playerInputSupported, true);
|
||||
assert.strictEqual(snap.protocolPackets.playerInputEnabled, true);
|
||||
assert.strictEqual(snap.protocolPackets.playerInputSent, 1);
|
||||
assert.strictEqual(snap.protocolPackets.tickEndEnabled, true);
|
||||
});
|
||||
|
||||
test('shortestAngleDelta 走最短路径并处理环绕', () => { assert.ok(Math.abs(shortestAngleDelta(0, 0.5) - 0.5) < 1e-9);
|
||||
// 从 170° 到 -170° 应走 +20° 而不是 -340°
|
||||
const from = (170 * Math.PI) / 180;
|
||||
const to = (-170 * Math.PI) / 180;
|
||||
|
||||
@@ -57,7 +57,37 @@ class FakeBot extends EventEmitter {
|
||||
this.registry = opts.registry || defaultRegistry();
|
||||
this.pathfinder = null;
|
||||
this.blockMap = opts.blockMap || new Map();
|
||||
/**
|
||||
* 模拟「已加载区块范围」:设置后,超出该半径(方块)的位置 blockAt 返回 null,
|
||||
* 用于测试寻路器只能规划已加载范围、需要分段寻路的行为。
|
||||
* 圆心始终跟随实体当前位置,模拟「人走到哪、区块加载到哪」。
|
||||
*/
|
||||
this.loadedRadius = opts.loadedRadius === undefined ? null : opts.loadedRadius;
|
||||
this.creative = { flying: false };
|
||||
/** 版本能力探测:默认支持 1.21.3+ 的 player_input 包 */
|
||||
this.features = Object.assign({ newPlayerInputPacket: true }, opts.features || {});
|
||||
/** 假的协议客户端:记录写出的包,便于断言「有没有补发 player_input / tick_end」 */
|
||||
this._client = opts.client || {
|
||||
state: 'play',
|
||||
written: [],
|
||||
write(name, params) {
|
||||
this.written.push({ name, params });
|
||||
},
|
||||
on() {}
|
||||
};
|
||||
}
|
||||
|
||||
supportFeature(name) {
|
||||
return this.features[name] === true;
|
||||
}
|
||||
|
||||
/** 取出写出的指定包 */
|
||||
writtenPackets(name) {
|
||||
return this._client.written.filter((p) => p.name === name);
|
||||
}
|
||||
|
||||
clearWritten() {
|
||||
this._client.written.length = 0;
|
||||
}
|
||||
|
||||
getControlState(control) {
|
||||
@@ -72,6 +102,10 @@ class FakeBot extends EventEmitter {
|
||||
blockAt(pos) {
|
||||
const key = `${Math.floor(pos.x)},${Math.floor(pos.y)},${Math.floor(pos.z)}`;
|
||||
if (this.blockMap.has(key)) return this.blockMap.get(key);
|
||||
if (this.loadedRadius !== null) {
|
||||
const center = this.entity.position;
|
||||
if (Math.hypot(pos.x - center.x, pos.z - center.z) > this.loadedRadius) return null;
|
||||
}
|
||||
return { name: 'air', boundingBox: 'empty' };
|
||||
}
|
||||
|
||||
@@ -136,6 +170,11 @@ class FakeBot extends EventEmitter {
|
||||
tickTimes(n) {
|
||||
for (let i = 0; i < n; i++) this.emit('physicsTick');
|
||||
}
|
||||
|
||||
/** 接受资源包(记录调用,供测试断言) */
|
||||
acceptResourcePack() {
|
||||
this.resourcePackAccepted = true;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { FakeBot };
|
||||
|
||||
+137
-2
@@ -35,8 +35,143 @@ test('AltitudeGoal 使用 GoalNearXZ 时保留其到达范围', () => {
|
||||
assert.strictEqual(goal.isEnd({ x: 100, y: 70, z: 201 }), true, '范围内算到达');
|
||||
});
|
||||
|
||||
test('configure 施加 Grim 合规约束(默认禁止挖 / 放 / 跑酷 / 搭塔)', () => {
|
||||
const bot = new FakeBot();
|
||||
// ==================== 长距离分段寻路 ====================
|
||||
//
|
||||
// mineflayer-pathfinder 只能规划已加载区块范围(实测线上约 8 区块 ≈ 130 格),
|
||||
// 而扩地目标常在几十个区块外,A* 会瞬间 noPath。分段寻路就是为这个场景写的。
|
||||
|
||||
/** 造一个「已加载范围」有限的 bot 与寻路服务 */
|
||||
function setupSegment(options) {
|
||||
const opts = options || {};
|
||||
const bot = new FakeBot({ x: opts.x === undefined ? 0.5 : opts.x, y: 64, z: opts.z === undefined ? 0.5 : opts.z, loadedRadius: opts.loadedRadius === undefined ? 128 : opts.loadedRadius });
|
||||
const goals = [];
|
||||
bot.pathfinder = {
|
||||
setMovements: () => {},
|
||||
setGoal: (goal) => goals.push(goal),
|
||||
thinkTimeout: 0,
|
||||
tickTimeout: 0,
|
||||
searchRadius: 0
|
||||
};
|
||||
const config = defaultConfig();
|
||||
config.enforceClaimTime = false;
|
||||
Object.assign(config.pathing, opts.config || {});
|
||||
const service = new PathingService(bot, config, null);
|
||||
service.configure();
|
||||
return { bot, service, goals, config };
|
||||
}
|
||||
|
||||
test('_probeLoadedDistance 沿方向探测到已加载范围的边缘', () => {
|
||||
const { service } = setupSegment({ loadedRadius: 128 });
|
||||
assert.ok(Math.abs(service._probeLoadedDistance(1, 0, 512) - 128) < 2,
|
||||
'向右探测应停在已加载半径处');
|
||||
assert.ok(Math.abs(service._probeLoadedDistance(0, -1, 512) - 128) < 2);
|
||||
});
|
||||
|
||||
test('_segmentGoal 把远超已加载范围的目标裁剪到边缘附近', () => {
|
||||
const { service } = setupSegment({
|
||||
loadedRadius: 128,
|
||||
config: { segmentMarginBlocks: 24, maxSegmentBlocks: 4096 }
|
||||
});
|
||||
const far = { x: 0.5 + 800, z: 0.5 };
|
||||
const waypoint = service._segmentGoal(far);
|
||||
assert.ok(waypoint.x < far.x, '必须裁剪,否则寻路器会瞬间 noPath');
|
||||
assert.ok(waypoint.x > 0.5, '不能原地不动');
|
||||
assert.ok(Math.abs(waypoint.x - (0.5 + 128 - 24)) < 2, `段长应约为 已加载半径 - 边距,实际 ${waypoint.x}`);
|
||||
assert.ok(Math.abs(waypoint.z - 0.5) < 1, '方向应保持指向目标');
|
||||
});
|
||||
|
||||
test('_segmentGoal 的段长同时受「单段上限」约束', () => {
|
||||
const { service } = setupSegment({
|
||||
loadedRadius: 512,
|
||||
config: { segmentMarginBlocks: 24, maxSegmentBlocks: 64 }
|
||||
});
|
||||
const waypoint = service._segmentGoal({ x: 0.5 + 800, z: 0.5 });
|
||||
assert.ok(Math.abs(waypoint.x - (0.5 + 64)) < 3,
|
||||
`即使已加载范围很大,单段也不该超过 maxSegmentBlocks,实际 ${waypoint.x}`);
|
||||
});
|
||||
|
||||
test('_segmentGoal 目标在已加载范围内时不做裁剪', () => {
|
||||
const { service } = setupSegment({ loadedRadius: 128 });
|
||||
const near = { x: 0.5 + 40, z: 0.5 };
|
||||
assert.deepStrictEqual(service._segmentGoal(near), near);
|
||||
});
|
||||
|
||||
test('pathTo 长距离目标时下发的是分段中途点而不是最终目标', () => {
|
||||
const { service, goals } = setupSegment({ loadedRadius: 128 });
|
||||
// 目标区块中心约在 (800, 0)
|
||||
assert.strictEqual(service.pathTo({ x: 50, z: 0 }), true);
|
||||
assert.strictEqual(goals.length, 1);
|
||||
assert.strictEqual(service._waypointIsFinal, false, '这一段不是最终目标');
|
||||
assert.ok(service.currentWaypoint.x < 800);
|
||||
});
|
||||
|
||||
test('pathTo 近距离目标时直接下发最终目标', () => {
|
||||
const { service, goals } = setupSegment({ loadedRadius: 512 });
|
||||
assert.strictEqual(service.pathTo({ x: 5, z: 0 }), true);
|
||||
assert.strictEqual(goals.length, 1);
|
||||
assert.strictEqual(service._waypointIsFinal, true);
|
||||
});
|
||||
|
||||
test('走到中途点后自动规划下一段,而不是当作已到达', () => {
|
||||
const { bot, service, goals } = setupSegment({ loadedRadius: 128 });
|
||||
const events = [];
|
||||
service.onPathEvent = (event) => events.push(event);
|
||||
|
||||
service.pathTo({ x: 50, z: 0 });
|
||||
const before = goals.length;
|
||||
bot.emit('goal_reached');
|
||||
|
||||
assert.strictEqual(goals.length, before + 1, '应继续规划下一段');
|
||||
assert.strictEqual(service._segmentCount, 1);
|
||||
assert.ok(events.includes(PathEvent.SEGMENT_REACHED), '应发出 SEGMENT_REACHED 供控制器重置超时');
|
||||
assert.ok(!events.includes(PathEvent.AT_GOAL), '还没到最终目标,不能报到达');
|
||||
});
|
||||
|
||||
test('走到最终目标时发出 AT_GOAL 且不再规划', () => {
|
||||
const { bot, service, goals } = setupSegment({ loadedRadius: 512 });
|
||||
const events = [];
|
||||
service.onPathEvent = (event) => events.push(event);
|
||||
|
||||
service.pathTo({ x: 5, z: 0 });
|
||||
const before = goals.length;
|
||||
bot.emit('goal_reached');
|
||||
|
||||
assert.strictEqual(goals.length, before, '已到最终目标,不应再规划');
|
||||
assert.ok(events.includes(PathEvent.AT_GOAL));
|
||||
});
|
||||
|
||||
test('本段算出 noPath 时先缩短段长重试', () => {
|
||||
const { service } = setupSegment({ loadedRadius: 128 });
|
||||
service.pathTo({ x: 50, z: 0 });
|
||||
const firstWaypoint = service.currentWaypoint.x;
|
||||
|
||||
// 模拟寻路器报告本段不可达
|
||||
service.bot.emit('path_update', { status: 'noPath' });
|
||||
assert.strictEqual(service.calcFailed, false, '第一次应重试而不是直接失败');
|
||||
assert.ok(service.currentWaypoint.x < firstWaypoint, '段长应缩短');
|
||||
});
|
||||
|
||||
test('反复 noPath 最终会如实报失败,不会无限重试', () => {
|
||||
const { service } = setupSegment({ loadedRadius: 128 });
|
||||
const events = [];
|
||||
service.onPathEvent = (event) => events.push(event);
|
||||
service.pathTo({ x: 50, z: 0 });
|
||||
for (let i = 0; i < 10; i++) service.bot.emit('path_update', { status: 'noPath' });
|
||||
assert.strictEqual(service.calcFailed, true);
|
||||
assert.ok(events.includes(PathEvent.CALC_FAILED));
|
||||
});
|
||||
|
||||
test('cancel 清空分段状态', () => {
|
||||
const { service } = setupSegment({ loadedRadius: 128 });
|
||||
service.pathTo({ x: 50, z: 0 });
|
||||
service.cancel();
|
||||
assert.strictEqual(service.finalTarget, null);
|
||||
assert.strictEqual(service.currentWaypoint, null);
|
||||
assert.strictEqual(service._segmentCount, 0);
|
||||
assert.strictEqual(service.isPathing(), false);
|
||||
});
|
||||
|
||||
test('configure 施加 Grim 合规约束(默认禁止挖 / 放 / 跑酷 / 搭塔)', () => { const bot = new FakeBot();
|
||||
const movements = {};
|
||||
bot.pathfinder = {
|
||||
setMovements: (m) => { movements.value = m; },
|
||||
|
||||
在新工单中引用
屏蔽一个用户