文件
BotStore/test/helpers/fakeBot.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

181 行
4.7 KiB
JavaScript

'use strict';
const { EventEmitter } = require('events');
const { Vec3 } = require('vec3');
/** 真实的 1.21.4 注册表(mineflayer-pathfinder 的 Movements 依赖它) */
let cachedRegistry = null;
function defaultRegistry() {
if (!cachedRegistry) {
cachedRegistry = require('minecraft-data')('1.21.4');
}
return cachedRegistry;
}
/**
* 测试用的假 bot:只实现被测模块真正用到的接口,
* 避免测试依赖真实 Minecraft 服务器。
*/
class FakeBot extends EventEmitter {
constructor(options) {
super();
const opts = options || {};
this.username = opts.username || 'TestBot';
this.physicsEnabled = true;
this.usingHeldItem = false;
this.vehicle = null;
this.food = 20;
this.health = 20;
this.game = { gameMode: opts.gameMode || 'survival' };
this.physics = Object.assign({
gravity: 0.08,
playerSpeed: 0.1,
sprintSpeed: 0.3,
sneakSpeed: 0.3,
stepHeight: 0.6,
airborneInertia: 0.91,
waterInertia: 0.8,
yawSpeed: 3.0,
pitchSpeed: 3.0
}, opts.physics || {});
this.entity = {
position: new Vec3(opts.x || 0, opts.y || 64, opts.z || 0),
velocity: new Vec3(0, 0, 0),
yaw: 0,
pitch: 0,
eyeHeight: 1.62,
isInWater: false,
isInLava: false,
onGround: true
};
/** @type {Record<string, boolean>} */
this.controls = {};
this.heldItem = opts.heldItem || null;
this.inventory = { slots: opts.slots || [] };
this.entities = {};
this.world = opts.world || { raycast: () => null };
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) {
return this.controls[control] === true;
}
setControlState(control, state) {
this.controls[control] = state === true;
this.emit('controlState', control, state);
}
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' };
}
look(yaw, pitch) {
this.entity.yaw = yaw;
this.entity.pitch = pitch === undefined ? this.entity.pitch : pitch;
return Promise.resolve();
}
lookAt(point) {
return this.look(this.entity.yaw, this.entity.pitch);
}
attack(entity, swing) {
this.lastAttack = { entity, swing };
}
activateItem() {
this.usingHeldItem = true;
}
deactivateItem() {
this.usingHeldItem = false;
}
async waitForTicks(ticks) {
for (let i = 0; i < (ticks || 1); i++) this.emit('physicsTick');
}
chat(message) {
this.lastChat = message;
}
equip() {
return Promise.resolve();
}
consume() {
this.usingHeldItem = false;
this.food = 20;
return Promise.resolve();
}
mount() {
this.vehicle = { id: 99, position: this.entity.position.clone(), name: 'oak_boat' };
}
dismount() {
this.vehicle = null;
}
moveVehicle() {
this.lastVehicleMove = Array.from(arguments);
}
/** 推进一个物理 tick(触发合规层的旋转推进) */
tick() {
this.emit('physicsTick');
}
/** 推进 n 个物理 tick */
tickTimes(n) {
for (let i = 0; i < n; i++) this.emit('physicsTick');
}
/** 接受资源包(记录调用,供测试断言) */
acceptResourcePack() {
this.resourcePackAccepted = true;
}
}
module.exports = { FakeBot };