文件
BotStore/test/grimCompat.test.js
T
lyizai fcd5fb2bd9 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,全部通过。
2026-09-27 23:52:29 +08:00

303 行
12 KiB
JavaScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { Vec3 } = require('vec3');
const { FakeBot } = require('./helpers/fakeBot');
const {
GrimCompat,
shortestAngleDelta,
eyeDistanceToEntity,
VANILLA_PHYSICS_BASELINE
} = require('../src/bot/grimCompat');
const { defaultConfig } = require('../src/config/expansionConfig');
/**
* 构造一个已安装合规层的假 bot。
* @param {object} [options]
*/
function setup(options) {
const opts = options || {};
const config = Object.assign(defaultConfig(), opts.config || {});
const bot = new FakeBot(opts.bot);
const grim = new GrimCompat(bot, config, null);
grim.install();
return { bot, grim, config };
}
// ==================== 协议补包(线上服务器「走不动」的根因)====================
/** 等一轮宏任务,让 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;
assert.ok(Math.abs(shortestAngleDelta(from, to) - (20 * Math.PI) / 180) < 1e-9);
assert.ok(Math.abs(shortestAngleDelta(0, Math.PI * 2)) < 1e-9);
});
test('eyeDistanceToEntity 计算眼睛到碰撞箱的最近距离', () => {
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
// 目标在正前方 2 格,碰撞箱中心高度与眼睛同高
const entity = { position: new Vec3(2, 64, 0), width: 0.6, height: 1.8 };
const distance = eyeDistanceToEntity(bot, entity);
assert.ok(Math.abs(distance - (2 - 0.3)) < 1e-6, `距离应为 1.7,实际 ${distance}`);
});
test('旋转被限制在每 tick 的最大步长内,且不会超前于服务端', async () => {
const { bot, grim } = setup();
const targetYaw = Math.PI / 2; // 90°
const promise = grim.setLook(targetYaw, 0);
assert.strictEqual(grim.isRotationSettled(), false, '刚设置目标时不应认为已同步');
bot.tick();
const step = grim.maxStepRad;
assert.ok(Math.abs(bot.entity.yaw - step) < 1e-9, `单 tick 只应前进 ${step} 弧度`);
assert.ok(bot.entity.yaw < targetYaw, '本地朝向不得一次跳到目标值(否则与服务端预测分歧)');
// 推进足够多 tick 后应到位
for (let i = 0; i < 60; i++) bot.tick();
await promise;
assert.ok(Math.abs(bot.entity.yaw - targetYaw) < 1e-4);
assert.strictEqual(grim.isRotationSettled(), true);
});
test('每 tick 旋转步长不超过 mineflayer 物理限速', () => {
const { grim } = setup();
// 物理限速 = 0.05s × 3.0 rad/s = 0.15 rad ≈ 8.59°
assert.ok(grim.maxStepRad <= 0.15 + 1e-9);
assert.ok(grim.maxStepRad > 0.14);
});
test('pitch 被钳制在 ±90° 内', async () => {
const { bot, grim } = setup();
grim.setLook(0, Math.PI); // 超出范围
for (let i = 0; i < 60; i++) bot.tick();
assert.ok(bot.entity.pitch <= Math.PI / 2 + 1e-9);
assert.ok(bot.entity.pitch >= -Math.PI / 2 - 1e-9);
});
test('拦截疾跑 + 潜行的非法组合(MultiActions)', () => {
const { bot, grim } = setup();
grim.setControlState('sprint', true);
assert.strictEqual(bot.getControlState('sprint'), true);
grim.setControlState('sneak', true);
assert.strictEqual(bot.getControlState('sneak'), false, '疾跑时不允许潜行');
assert.strictEqual(grim.blockedActions, 1);
});
test('拦截疾跑 + 使用物品的非法组合', () => {
const { bot, grim } = setup();
grim.setControlState('sprint', true);
bot.usingHeldItem = true;
grim.setControlState('forward', true);
assert.strictEqual(bot.getControlState('forward'), false, '进食时不允许前进(NoSlow)');
});
test('默认放行疾跑跳跃(原版合法动作)', () => {
const { bot, grim } = setup();
grim.setControlState('jump', true);
grim.setControlState('sprint', true);
assert.strictEqual(bot.getControlState('sprint'), true);
});
test('blockSprintJump 开启时拦下疾跑跳跃', () => {
const config = defaultConfig();
config.grim.blockSprintJump = true;
const { bot, grim } = setup({ config });
grim.setControlState('jump', true);
grim.setControlState('sprint', true);
assert.strictEqual(bot.getControlState('sprint'), false);
});
test('攻击冷却按手持武器攻速计算', () => {
const config = defaultConfig();
const swordBot = new FakeBot({ heldItem: { name: 'diamond_sword' } });
const swordGrim = new GrimCompat(swordBot, config, null);
assert.strictEqual(swordGrim.attackCooldownMs(), 625, '钻石剑 1.6 次/秒 → 625ms');
const axeBot = new FakeBot({ heldItem: { name: 'netherite_axe' } });
const axeGrim = new GrimCompat(axeBot, config, null);
assert.strictEqual(axeGrim.attackCooldownMs(), 1000, '下界合金斧 1.0 次/秒 → 1000ms');
const handBot = new FakeBot({ heldItem: null });
const handGrim = new GrimCompat(handBot, config, null);
assert.strictEqual(handGrim.attackCooldownMs(), 250, '空手 4 次/秒 → 250ms');
});
test('canAttack 校验距离、视线与冷却', async () => {
const { bot, grim } = setup();
bot.entity.position = new Vec3(0, 64, 0);
const far = { position: new Vec3(10, 64, 0), width: 0.6, height: 1.8, isValid: true };
assert.deepStrictEqual(grim.canAttack(far), { ok: false, reason: 'out_of_reach' });
const near = { position: new Vec3(2, 64, 0), width: 0.6, height: 1.8, isValid: true };
assert.deepStrictEqual(grim.canAttack(near), { ok: true });
// 攻击后进入冷却
const attacked = await grim.attackEntity(near, { skipRotation: true });
assert.strictEqual(attacked, true);
assert.strictEqual(bot.lastAttack.entity, near);
assert.deepStrictEqual(grim.canAttack(near), { ok: false, reason: 'cooldown' });
});
test('canAttack 拒绝视线被遮挡的目标', () => {
const config = defaultConfig();
const bot = new FakeBot({
world: { raycast: () => ({ position: new Vec3(1, 64, 0) }) } // 总是命中方块
});
const grim = new GrimCompat(bot, config, null);
const target = { position: new Vec3(2, 64, 0), width: 0.6, height: 1.8, isValid: true };
assert.deepStrictEqual(grim.canAttack(target), { ok: false, reason: 'no_line_of_sight' });
config.grim.requireLineOfSight = false;
assert.deepStrictEqual(grim.canAttack(target), { ok: true });
});
test('canAttack 拒绝骑乘状态下的攻击', () => {
const { bot, grim } = setup();
bot.vehicle = { id: 1 };
const target = { position: new Vec3(2, 64, 0), width: 0.6, height: 1.8, isValid: true };
assert.deepStrictEqual(grim.canAttack(target), { ok: false, reason: 'riding' });
});
test('attackEntity 会先同步朝向再出手', async () => {
const { bot, grim } = setup();
const target = { position: new Vec3(2, 64, 0), width: 0.6, height: 1.8, isValid: true, id: 7 };
const promise = grim.attackEntity(target);
// 旋转尚未完成时不应出手
bot.tick();
assert.strictEqual(bot.lastAttack, undefined, '朝向未同步前不得攻击');
for (let i = 0; i < 60; i++) bot.tick();
await promise;
assert.ok(bot.lastAttack, '朝向同步后应完成攻击');
});
test('关闭合规层后不做任何限速与拦截', () => {
const config = defaultConfig();
config.grim.enabled = false;
const bot = new FakeBot();
const grim = new GrimCompat(bot, config, null);
grim.install();
bot.setControlState('sprint', true);
bot.setControlState('sneak', true);
assert.strictEqual(bot.getControlState('sneak'), true, '关闭后不拦截');
assert.strictEqual(grim.blockedActions, 0);
});
test('audit 检出被篡改的物理常量与创造模式', () => {
const { bot, grim } = setup();
assert.deepStrictEqual(grim.audit(), { ok: true, issues: [] });
bot.physics.gravity = 0.02;
const tampered = grim.audit();
assert.strictEqual(tampered.ok, false);
assert.ok(tampered.issues.some((issue) => issue.includes('gravity')));
bot.physics.gravity = VANILLA_PHYSICS_BASELINE.gravity;
bot.game.gameMode = 'creative';
assert.ok(grim.audit().issues.some((issue) => issue.includes('创造模式')));
});
test('audit 检出被关闭的本地物理预测', () => {
const { bot, grim } = setup();
bot.physicsEnabled = false;
assert.ok(grim.audit().issues.some((issue) => issue.includes('physicsEnabled')));
});
test('snapshot 暴露合规状态供控制台展示', () => {
const { grim } = setup();
const snapshot = grim.snapshot();
assert.strictEqual(snapshot.enabled, true);
assert.strictEqual(snapshot.attackReach, 3.0);
assert.strictEqual(snapshot.physicsOk, true);
assert.ok(snapshot.effectiveStepDeg > 0 && snapshot.effectiveStepDeg <= 9);
assert.strictEqual(snapshot.blockedActions, 0);
});
test('releaseAllControls 松开全部控制键', () => {
const { bot, grim } = setup();
grim.setControlState('forward', true);
grim.setControlState('sprint', true);
grim.releaseAllControls();
for (const control of ['forward', 'back', 'left', 'right', 'jump', 'sprint', 'sneak']) {
assert.strictEqual(bot.getControlState(control), false, control + ' 应被松开');
}
});