抓包已经证明「手动按键时客户端坐标一字未变、服务端入站位置包为 0」, 问题在 prismarine-physics 这一层;但抓包只能看到「发出去的包」, 看不到物理引擎内部读到了什么输入、算出了什么、有没有真的移动。 本提交补上这最后一块拼图。 新增 src/core/physicsRecorder.js + 控制台接口 (POST /api/bots/:id/physics/start|stop、GET /api/bots/:id/physics): 逐 tick 记录位置增量、速度、onGround、isCollidedHorizontally、控制键快照、 脚下/脚部/头部方块名、physicsEnabled;汇总给出四个判定计数: - inputTicks 有移动输入的 tick 数 - inputMovedTicks 其中真的产生了水平位移的 - blockedTicks 有输入、没位移、物理报告碰撞 → 被方块挡住(地形) - frozenTicks 有输入、没位移、物理没报告碰撞 → 输入根本没进物理 即:抓包看「发了什么」,本录制器看「物理读到了什么、动没动」,两者配合即可定位。 细节: - 录制开始时先取一次当前位置作基线,避免第一个 tick 因「没有上一 tick 可比」 被误判成「有输入但没动」; - 滚动缓冲只保留最近 240 个 tick(约 12 秒),只放内存、不写盘; - 录制全程 try 包裹,诊断自身出异常绝不影响物理循环; - FakeBot 补上与 mineflayer 一致的 controlState(带 getter 的实时控制键代理)。 测试:451 → 468 个用例(含三种故障判定的回归用例与控制台接口用例),全绿。
191 行
5.2 KiB
JavaScript
191 行
5.2 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 = {};
|
|
/**
|
|
* 与 mineflayer 保持一致:用带 getter 的 `controlState` 暴露实时控制键。
|
|
* mineflayer 传给物理引擎的就是这个对象,所以诊断代码按它读输入。
|
|
*/
|
|
this.controlState = {};
|
|
for (const control of ['forward', 'back', 'left', 'right', 'jump', 'sprint', 'sneak']) {
|
|
Object.defineProperty(this.controlState, control, {
|
|
get: () => this.controls[control] === true
|
|
});
|
|
}
|
|
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 };
|