feat: 新增物理 tick 录制,把「走不动」的三种原因切开(1.9.0)
抓包已经证明「手动按键时客户端坐标一字未变、服务端入站位置包为 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 个用例(含三种故障判定的回归用例与控制台接口用例),全绿。
这个提交包含在:
@@ -12,6 +12,7 @@ const { AutoBoatController } = require('./autoBoat');
|
||||
const { DurabilityGuard } = require('./durabilityGuard');
|
||||
const { ChatKind } = require('../core/chatLog');
|
||||
const { PacketCapture } = require('../core/packetCapture');
|
||||
const { PhysicsRecorder } = require('../core/physicsRecorder');
|
||||
|
||||
/** 服务器要求登录 / 注册时的提示关键词 */
|
||||
const LOGIN_PROMPT_PATTERN = /请(先)?(输入)?登录|请登录|请输入密码|login|登入/i;
|
||||
@@ -207,6 +208,14 @@ class BotManager extends EventEmitter {
|
||||
* 与「能走」时的序列逐包 diff(TODO.md 假设 5)。
|
||||
*/
|
||||
this.packetCapture = new PacketCapture();
|
||||
/**
|
||||
* 物理 tick 录制器(诊断用,默认不录制)。
|
||||
*
|
||||
* 抓包只能看到「发出去的包」,看不到物理引擎内部:它这一 tick 读到的 control、
|
||||
* 算出的速度、有没有真的移动、有没有报告水平碰撞。而「走不动」正卡在这一层,
|
||||
* 所以单独再要一个逐 tick 录制器。
|
||||
*/
|
||||
this.physicsRecorder = new PhysicsRecorder();
|
||||
}
|
||||
|
||||
// ==================== 连接生命周期 ====================
|
||||
@@ -521,6 +530,7 @@ class BotManager extends EventEmitter {
|
||||
this._wirePacketStats(bot);
|
||||
// 重连会换新的 client,这里每次都重新登记(只在录制期间才会真正挂钩 write)
|
||||
this.packetCapture.setClient(bot._client);
|
||||
this.physicsRecorder.setBot(bot);
|
||||
|
||||
bot.on('login', () => {
|
||||
this.state = 'online';
|
||||
@@ -1241,6 +1251,43 @@ class BotManager extends EventEmitter {
|
||||
return this.packetCapture.snapshot(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始录制物理 tick(诊断用)。
|
||||
*
|
||||
* 与抓包配合使用:抓包回答「客户端发了什么」,本录制器回答
|
||||
* 「物理引擎这一 tick 读到了什么输入、算出了什么、有没有真的移动」。
|
||||
* 两者一起看,才能把「输入没进物理」「被方块挡住」「服务端回弹」三种原因切开。
|
||||
*
|
||||
* @param {number} [seconds] 录制时长(秒),省略则需手动停止
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
startPhysicsRecorder(seconds) {
|
||||
const result = this.physicsRecorder.start(seconds);
|
||||
if (result.ok) this._log('物理录制:' + result.message);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止物理 tick 录制。
|
||||
*
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
stopPhysicsRecorder() {
|
||||
const result = this.physicsRecorder.stop();
|
||||
this._log('物理录制:' + result.message);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出物理 tick 录制结果。
|
||||
*
|
||||
* @param {{limit?: number}} [options]
|
||||
* @returns {object}
|
||||
*/
|
||||
physicsSnapshot(options) {
|
||||
return this.physicsRecorder.snapshot(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动移动测试:按住某个方向键若干秒,然后松开。
|
||||
*
|
||||
|
||||
@@ -625,6 +625,35 @@ class BotPool extends EventEmitter {
|
||||
return this.require(id).manager.packetCaptureSnapshot(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始录制物理 tick(诊断用)。
|
||||
* @param {string} id
|
||||
* @param {number} [seconds] 录制时长(秒),省略则需手动停止
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
startPhysicsRecorder(id, seconds) {
|
||||
return this.require(id).manager.startPhysicsRecorder(seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止物理 tick 录制。
|
||||
* @param {string} id
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
stopPhysicsRecorder(id) {
|
||||
return this.require(id).manager.stopPhysicsRecorder();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出物理 tick 录制结果。
|
||||
* @param {string} id
|
||||
* @param {{limit?: number}} [options]
|
||||
* @returns {object}
|
||||
*/
|
||||
physicsSnapshot(id, options) {
|
||||
return this.require(id).manager.physicsSnapshot(options);
|
||||
}
|
||||
|
||||
// ==================== 状态快照 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 物理 tick 录制器:逐 tick 记录「输入 → 输出」,用来定位「走不动」到底卡在哪一层。
|
||||
*
|
||||
* 为什么需要它:出站包抓取(`packetCapture`)已经证明 —— 手动按住方向键 3 秒时
|
||||
* 客户端自己的坐标与朝向一个字节都没变、服务端也没有下发任何位置包(入站 0 个),
|
||||
* 而 `player_input` 如实上报了按键。也就是说问题在 prismarine-physics 这一层,
|
||||
* 但抓包只能看到「发出去的包」,看不到物理引擎内部。
|
||||
*
|
||||
* 本录制器补上的正是这一段。逐 tick 记录下面这些量,就能把两种完全不同的故障切开:
|
||||
*
|
||||
* | 观察 | 结论 |
|
||||
* | --- | --- |
|
||||
* | `control.forward = true` 但速度恒为 0、位置不动、`collidedHorizontally = false` | **输入没进物理**(applyHeading 没被调用 / control 快照是空的) |
|
||||
* | 速度非 0 但位置不动、`collidedHorizontally = true` | **被方块挡住**(地形问题,或脚下/身旁方块读错) |
|
||||
* | 速度与位置都在变,但服务端把位置拉回 | **服务端回弹**(此时才轮到反作弊那条线) |
|
||||
*
|
||||
* 所以 `isCollidedHorizontally` 是这里最关键的一个字段。
|
||||
*/
|
||||
|
||||
/** 默认保留多少个 tick(20 TPS 下约 12 秒) */
|
||||
const DEFAULT_MAX_TICKS = 240;
|
||||
/** 判定「这一 tick 真的动了」的阈值(格) */
|
||||
const MOVE_EPSILON = 1e-6;
|
||||
/** 单次录制时长上限(秒) */
|
||||
const MAX_SECONDS = 120;
|
||||
/** 浮点保留位数 */
|
||||
const ROUND_DIGITS = 5;
|
||||
|
||||
class PhysicsRecorder {
|
||||
/**
|
||||
* @param {{maxTicks?: number}} [options]
|
||||
*/
|
||||
constructor(options) {
|
||||
const opts = options || {};
|
||||
this.maxTicks = Number.isFinite(opts.maxTicks) && opts.maxTicks > 0
|
||||
? Math.floor(opts.maxTicks)
|
||||
: DEFAULT_MAX_TICKS;
|
||||
/** @type {object|null} 当前要录制的 bot(重连后会换新的) */
|
||||
this._bot = null;
|
||||
/** @type {Function|null} 挂在 bot 上的 physicsTick 处理器 */
|
||||
this._handler = null;
|
||||
/** @type {Array<object>} 滚动缓冲:只保留最近 maxTicks 个 tick */
|
||||
this.ticks = [];
|
||||
this.running = false;
|
||||
this.startedAt = 0;
|
||||
this.stoppedAt = 0;
|
||||
this._timer = null;
|
||||
/** 上一 tick 的位置,用来算增量 */
|
||||
this._prev = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记住当前连接的 bot。
|
||||
*
|
||||
* 每次装配事件时都会调用,重连后自动换成新 bot;
|
||||
* 正在录制时换连接说明旧连接已经没了,先把录制收尾。
|
||||
*
|
||||
* @param {object|null} bot
|
||||
*/
|
||||
setBot(bot) {
|
||||
if (this._bot === bot) return;
|
||||
if (this.running) this.stop();
|
||||
this._bot = bot || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始录制。
|
||||
*
|
||||
* @param {number} [seconds] 录制时长(秒);给定时到点自动停止
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
start(seconds) {
|
||||
const bot = this._bot;
|
||||
if (!bot || typeof bot.on !== 'function') {
|
||||
return { ok: false, message: '当前没有可录制的连接' };
|
||||
}
|
||||
if (this.running) this.stop();
|
||||
|
||||
this.ticks = [];
|
||||
this.startedAt = Date.now();
|
||||
this.stoppedAt = 0;
|
||||
this.running = true;
|
||||
this._handler = () => this._record();
|
||||
bot.on('physicsTick', this._handler);
|
||||
|
||||
// 先取一次当前位置当基线:否则第一个 tick 没有「上一 tick」可比,
|
||||
// 位移会被记成 0,在统计里白白多出一个「有输入但没动」的假样本。
|
||||
const pos = bot.entity && bot.entity.position;
|
||||
this._prev = pos && Number.isFinite(pos.x) ? { x: pos.x, y: pos.y, z: pos.z } : null;
|
||||
|
||||
const duration = Number(seconds);
|
||||
if (Number.isFinite(duration) && duration > 0) {
|
||||
const bounded = Math.min(MAX_SECONDS, duration);
|
||||
this._timer = setTimeout(() => this.stop(), bounded * 1000);
|
||||
if (this._timer.unref) this._timer.unref();
|
||||
return { ok: true, message: `已开始录制物理 tick(${bounded} 秒后自动停止)` };
|
||||
}
|
||||
return { ok: true, message: '已开始录制物理 tick(需手动停止)' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止录制并摘掉监听。已录到的内容保留,供 `snapshot()` 读取。
|
||||
*
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
stop() {
|
||||
if (this._timer) {
|
||||
clearTimeout(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
if (!this.running) return { ok: true, message: '当前没有在录制物理 tick' };
|
||||
this.running = false;
|
||||
this.stoppedAt = Date.now();
|
||||
if (this._bot && this._handler) {
|
||||
try {
|
||||
this._bot.removeListener('physicsTick', this._handler);
|
||||
} catch {
|
||||
// 忽略:连接可能已经断开
|
||||
}
|
||||
}
|
||||
this._handler = null;
|
||||
return { ok: true, message: `物理 tick 录制已停止,共 ${this.ticks.length} 个 tick` };
|
||||
}
|
||||
|
||||
/** 清空已录内容(不影响是否正在录制) */
|
||||
clear() {
|
||||
this.ticks = [];
|
||||
this._prev = null;
|
||||
this.startedAt = this.running ? Date.now() : 0;
|
||||
this.stoppedAt = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出录制结果。
|
||||
*
|
||||
* @param {{limit?: number}} [options] limit 最多返回最后多少个 tick(默认全给)
|
||||
* @returns {object}
|
||||
*/
|
||||
snapshot(options) {
|
||||
const opts = options || {};
|
||||
const all = this.ticks;
|
||||
const hasInput = (t) => t.control.forward || t.control.back || t.control.left || t.control.right;
|
||||
const hasMoved = (t) => Math.abs(t.dx) > MOVE_EPSILON || Math.abs(t.dz) > MOVE_EPSILON;
|
||||
|
||||
const inputTicks = all.filter(hasInput);
|
||||
const inputMoved = inputTicks.filter(hasMoved);
|
||||
const blocked = inputTicks.filter((t) => !hasMoved(t) && t.collidedHorizontally);
|
||||
const frozen = inputTicks.filter((t) => !hasMoved(t) && !t.collidedHorizontally);
|
||||
|
||||
let ticks = all;
|
||||
const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.floor(opts.limit) : all.length;
|
||||
if (ticks.length > limit) ticks = ticks.slice(ticks.length - limit);
|
||||
|
||||
const maxHoriz = all.reduce((max, t) => Math.max(max, Math.hypot(t.dx, t.dz)), 0);
|
||||
|
||||
return {
|
||||
running: this.running,
|
||||
startedAt: this.startedAt,
|
||||
stoppedAt: this.stoppedAt,
|
||||
elapsedMs: this.startedAt ? (this.stoppedAt || Date.now()) - this.startedAt : 0,
|
||||
count: all.length,
|
||||
/**
|
||||
* 有移动输入的 tick 数,与其中「真的产生了水平位移」的 tick 数。
|
||||
* 前者 > 0 而后者 = 0,就是「按了键但物理没动」—— 一眼看出输入没进物理。
|
||||
*/
|
||||
inputTicks: inputTicks.length,
|
||||
inputMovedTicks: inputMoved.length,
|
||||
/** 有输入、没位移、且物理报告水平碰撞:被方块挡住 */
|
||||
blockedTicks: blocked.length,
|
||||
/** 有输入、没位移、且物理没报告碰撞:输入根本没生效 */
|
||||
frozenTicks: frozen.length,
|
||||
movedTicks: all.filter(hasMoved).length,
|
||||
maxHorizDelta: Number(maxHoriz.toFixed(ROUND_DIGITS)),
|
||||
physicsEnabled: all.length > 0 ? all[all.length - 1].physicsEnabled : null,
|
||||
last: all.length > 0 ? all[all.length - 1] : null,
|
||||
returned: ticks.length,
|
||||
ticks
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 内部 ====================
|
||||
|
||||
/**
|
||||
* 记录一个物理 tick。
|
||||
*
|
||||
* 挂在 `physicsTick` 上,因此这里读到的是**物理引擎算完之后**的状态
|
||||
* (mineflayer 的顺序是 simulatePlayer → emit('physicsTick') → updatePosition)。
|
||||
* 输入侧读 `bot.controlState`(mineflayer 用 getter 暴露的实时控制键)。
|
||||
*
|
||||
* 整个过程包在 try 里:录制是诊断手段,绝不能因为它自己的异常影响物理循环。
|
||||
*/
|
||||
_record() {
|
||||
if (!this.running) return;
|
||||
try {
|
||||
const bot = this._bot;
|
||||
const entity = bot && bot.entity;
|
||||
const pos = entity && entity.position;
|
||||
if (!pos || !Number.isFinite(pos.x)) return;
|
||||
const vel = entity.velocity || { x: 0, y: 0, z: 0 };
|
||||
const prev = this._prev;
|
||||
|
||||
const row = {
|
||||
t: Date.now() - this.startedAt,
|
||||
x: round(pos.x),
|
||||
y: round(pos.y),
|
||||
z: round(pos.z),
|
||||
dx: prev ? round(pos.x - prev.x) : 0,
|
||||
dy: prev ? round(pos.y - prev.y) : 0,
|
||||
dz: prev ? round(pos.z - prev.z) : 0,
|
||||
vx: round(vel.x),
|
||||
vy: round(vel.y),
|
||||
vz: round(vel.z),
|
||||
onGround: entity.onGround === true,
|
||||
/** 物理引擎是否认为撞到了东西 —— 区分「输入没生效」与「被方块挡住」的关键 */
|
||||
collidedHorizontally: entity.isCollidedHorizontally === true,
|
||||
collidedVertically: entity.isCollidedVertically === true,
|
||||
physicsEnabled: bot.physicsEnabled !== false,
|
||||
inWater: entity.isInWater === true,
|
||||
inWeb: entity.isInWeb === true,
|
||||
control: {
|
||||
forward: controlOf(bot, 'forward'),
|
||||
back: controlOf(bot, 'back'),
|
||||
left: controlOf(bot, 'left'),
|
||||
right: controlOf(bot, 'right'),
|
||||
jump: controlOf(bot, 'jump'),
|
||||
sprint: controlOf(bot, 'sprint'),
|
||||
sneak: controlOf(bot, 'sneak')
|
||||
},
|
||||
/** 物理引擎脚下/脚下所读到的方块(null = 该区块未加载) */
|
||||
blockBelow: blockName(bot, pos, 0, -1, 0),
|
||||
blockAtFeet: blockName(bot, pos, 0, 0, 0),
|
||||
blockAtHead: blockName(bot, pos, 0, 1, 0)
|
||||
};
|
||||
|
||||
this._prev = { x: pos.x, y: pos.y, z: pos.z };
|
||||
if (this.ticks.length >= this.maxTicks) this.ticks.shift();
|
||||
this.ticks.push(row);
|
||||
} catch {
|
||||
// 忽略:不能让诊断影响物理循环
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 读某个偏移处的方块名;区块未加载返回 null(与物理引擎看到的一致) */
|
||||
function blockName(bot, pos, dx, dy, dz) {
|
||||
try {
|
||||
const block = bot.blockAt(pos.offset(dx, dy, dz), false);
|
||||
return block ? block.name : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读某个控制键是否按下。
|
||||
*
|
||||
* mineflayer 用带 getter 的 `bot.controlState` 暴露实时控制键(见其 physics.js),
|
||||
* 那正是传给物理引擎的那个对象,所以优先读它;测试替身只有 `getControlState` 时回退。
|
||||
*
|
||||
* @param {object} bot
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function controlOf(bot, name) {
|
||||
const state = bot.controlState;
|
||||
if (state && state[name] !== undefined) return state[name] === true;
|
||||
if (typeof bot.getControlState === 'function') {
|
||||
try {
|
||||
return bot.getControlState(name) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 浮点取整;NaN / Infinity 转字符串,免得 JSON 里变成 null 看不出区别 */
|
||||
function round(value) {
|
||||
if (!Number.isFinite(value)) return String(value);
|
||||
const factor = 10 ** ROUND_DIGITS;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
module.exports = { PhysicsRecorder, DEFAULT_MAX_TICKS, MAX_SECONDS };
|
||||
@@ -476,6 +476,37 @@ class WebConsole {
|
||||
});
|
||||
});
|
||||
|
||||
// 物理 tick 录制:抓包回答「客户端发了什么」,这里回答
|
||||
// 「物理引擎读到了什么输入、算出了什么、有没有真的移动」。
|
||||
// 两者一起看,才能把「输入没进物理 / 被方块挡住 / 服务端回弹」三种原因切开。
|
||||
app.post('/api/bots/:id/physics/start', (req, res) => {
|
||||
this._withEntry(req, res, (entry) => {
|
||||
const body = req.body || {};
|
||||
const result = this.pool.startPhysicsRecorder(entry.id, body.seconds);
|
||||
res.status(result.ok ? 200 : 409).json(result);
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/bots/:id/physics/stop', (req, res) => {
|
||||
this._withEntry(req, res, (entry) => {
|
||||
const result = this.pool.stopPhysicsRecorder(entry.id);
|
||||
res.json({ ok: true, message: result.message });
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/bots/:id/physics', (req, res) => {
|
||||
this._withEntry(req, res, (entry) => {
|
||||
const limit = Number.parseInt(req.query.limit, 10);
|
||||
const data = this.pool.physicsSnapshot(entry.id, {
|
||||
limit: Number.isFinite(limit) ? limit : undefined
|
||||
});
|
||||
res.json({ ok: true, data });
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
// 控制台 / 聊天框操作:都要求该机器人已完成游戏内登录验证
|
||||
app.post('/api/bots/:id/command', (req, res) => {
|
||||
const command = req.body && req.body.command;
|
||||
@@ -1301,6 +1332,47 @@ class SingleBotPool {
|
||||
}
|
||||
return entry.manager.packetCaptureSnapshot(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始录制物理 tick。
|
||||
* @param {string} id
|
||||
* @param {number} [seconds]
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
startPhysicsRecorder(id, seconds) {
|
||||
const entry = this.require(id);
|
||||
if (typeof entry.manager.startPhysicsRecorder !== 'function') {
|
||||
return { ok: false, message: '当前运行模式不支持物理录制' };
|
||||
}
|
||||
return entry.manager.startPhysicsRecorder(seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止物理 tick 录制。
|
||||
* @param {string} id
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
stopPhysicsRecorder(id) {
|
||||
const entry = this.require(id);
|
||||
if (typeof entry.manager.stopPhysicsRecorder !== 'function') {
|
||||
return { ok: true, message: '当前运行模式不支持物理录制' };
|
||||
}
|
||||
return entry.manager.stopPhysicsRecorder();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出物理 tick 录制结果。
|
||||
* @param {string} id
|
||||
* @param {{limit?: number}} [options]
|
||||
* @returns {object}
|
||||
*/
|
||||
physicsSnapshot(id, options) {
|
||||
const entry = this.require(id);
|
||||
if (typeof entry.manager.physicsSnapshot !== 'function') {
|
||||
return { running: false, count: 0, inputTicks: 0, inputMovedTicks: 0, ticks: [] };
|
||||
}
|
||||
return entry.manager.physicsSnapshot(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
在新工单中引用
屏蔽一个用户