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 个用例(含三种故障判定的回归用例与控制台接口用例),全绿。
这个提交包含在:
lyizai
2026-09-30 21:05:38 +08:00
父节点 0c6700642e
当前提交 b2b0317af7
共修改 12 个文件,包含 764 行新增和 5 行删除
+47
查看文件
@@ -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);
}
/**
* 手动移动测试:按住某个方向键若干秒,然后松开。
*
+29
查看文件
@@ -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);
}
// ==================== 状态快照 ====================
/**
+286
查看文件
@@ -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 };
+72
查看文件
@@ -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);
}
}
/**