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 行删除
+221
查看文件
@@ -0,0 +1,221 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { PhysicsRecorder, DEFAULT_MAX_TICKS } = require('../src/core/physicsRecorder');
const { FakeBot } = require('./helpers/fakeBot');
/** 造一个站在石头上的假 bot(脚下是 stone,便于断言方块名) */
function setup(options) {
const opts = options || {};
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
bot.blockMap.set('0,63,0', { name: 'stone', boundingBox: 'block' });
bot.blockMap.set('0,64,0', { name: 'air', boundingBox: 'empty' });
bot.blockMap.set('0,65,0', { name: 'air', boundingBox: 'empty' });
const recorder = new PhysicsRecorder(opts);
recorder.setBot(bot);
return { bot, recorder };
}
/** 模拟一 tick:先改位置/速度/碰撞状态,再触发 physicsTick */
function tick(bot, patch) {
Object.assign(bot.entity, patch || {});
bot.emit('physicsTick');
}
test('没有 bot 时拒绝开始录制', () => {
const recorder = new PhysicsRecorder();
const result = recorder.start(1);
assert.strictEqual(result.ok, false);
assert.match(result.message, /没有可录制的连接/);
assert.strictEqual(recorder.running, false);
});
test('逐 tick 记录输入、速度、位移与脚下方块', () => {
const { bot, recorder } = setup();
assert.strictEqual(recorder.start(1).ok, true);
assert.strictEqual(recorder.running, true);
bot.setControlState('forward', true);
bot.entity.velocity.set(0.1, -0.078, 0);
tick(bot, { position: bot.entity.position.offset(0, 0, 0.1) });
tick(bot, { position: bot.entity.position.offset(0, 0, 0.1) });
const snap = recorder.snapshot();
assert.strictEqual(snap.count, 2);
assert.strictEqual(snap.physicsEnabled, true);
const first = snap.ticks[0];
assert.strictEqual(first.control.forward, true);
assert.strictEqual(first.control.back, false);
assert.strictEqual(first.vx, 0.1);
assert.strictEqual(first.onGround, true);
assert.strictEqual(first.blockBelow, 'stone');
assert.strictEqual(first.blockAtFeet, 'air');
assert.strictEqual(first.blockAtHead, 'air');
// 录制开始时会先取一次当前位置当基线,所以第一个 tick 也有有效位移
assert.strictEqual(first.dz, 0.1);
assert.strictEqual(snap.ticks[1].dz, 0.1);
assert.strictEqual(snap.inputTicks, 2);
assert.strictEqual(snap.inputMovedTicks, 2);
assert.strictEqual(snap.maxHorizDelta, 0.1);
});
test('有输入但没位移且物理报告水平碰撞 → 记为「被方块挡住」', () => {
const { bot, recorder } = setup();
recorder.start();
bot.setControlState('forward', true);
// 位置不变、物理报告水平碰撞:典型的「撞墙」
tick(bot, { isCollidedHorizontally: true });
tick(bot, { isCollidedHorizontally: true });
const snap = recorder.snapshot();
assert.strictEqual(snap.inputTicks, 2);
assert.strictEqual(snap.inputMovedTicks, 0);
assert.strictEqual(snap.blockedTicks, 2);
assert.strictEqual(snap.frozenTicks, 0, '报告了碰撞就不算「输入没生效」');
});
test('有输入但没位移且物理没报告碰撞 → 记为「输入没进物理」', () => {
const { bot, recorder } = setup();
recorder.start();
bot.setControlState('forward', true);
// 位置不变、速度恒为 0、也没有碰撞报告:输入根本没生效
tick(bot, { isCollidedHorizontally: false });
tick(bot, { isCollidedHorizontally: false });
const snap = recorder.snapshot();
assert.strictEqual(snap.inputTicks, 2);
assert.strictEqual(snap.inputMovedTicks, 0);
assert.strictEqual(snap.frozenTicks, 2);
assert.strictEqual(snap.blockedTicks, 0, '没报告碰撞就不算「被挡住」');
});
test('没有输入时不计入 inputTicks', () => {
const { bot, recorder } = setup();
recorder.start();
tick(bot);
tick(bot);
const snap = recorder.snapshot();
assert.strictEqual(snap.count, 2);
assert.strictEqual(snap.inputTicks, 0);
assert.strictEqual(snap.movedTicks, 0);
});
test('停止后摘掉监听并不再记录', () => {
const { bot, recorder } = setup();
recorder.start();
tick(bot);
const result = recorder.stop();
assert.strictEqual(result.ok, true);
assert.strictEqual(recorder.running, false);
assert.match(result.message, /共 1 个 tick/);
tick(bot);
assert.strictEqual(recorder.snapshot().count, 1, '停止后不应再记录');
assert.strictEqual(bot.listenerCount('physicsTick'), 0, '监听应已摘掉');
});
test('重复 stop 不报错', () => {
const { recorder } = setup();
recorder.start();
assert.strictEqual(recorder.stop().ok, true);
assert.match(recorder.stop().message, /没有在录制/);
});
test('滚动缓冲只保留最近 maxTicks 个 tick', () => {
const { bot, recorder } = setup({ maxTicks: 3 });
recorder.start();
for (let i = 0; i < 10; i++) {
tick(bot, { position: bot.entity.position.offset(0, 0, 1) });
}
const snap = recorder.snapshot();
assert.strictEqual(snap.count, 3);
assert.strictEqual(snap.ticks.length, 3);
});
test('snapshot 的 limit 只影响返回条数,不影响统计', () => {
const { bot, recorder } = setup();
recorder.start();
bot.setControlState('forward', true);
for (let i = 0; i < 5; i++) tick(bot, { position: bot.entity.position.offset(0, 0, 1) });
const snap = recorder.snapshot({ limit: 2 });
assert.strictEqual(snap.count, 5);
assert.strictEqual(snap.returned, 2);
assert.strictEqual(snap.ticks.length, 2);
assert.strictEqual(snap.inputMovedTicks, 5);
});
test('给出时长后到点自动停止并摘监听', async () => {
const { bot, recorder } = setup();
recorder.start(0.05);
assert.strictEqual(recorder.running, true);
await new Promise((resolve) => setTimeout(resolve, 150));
assert.strictEqual(recorder.running, false);
assert.strictEqual(bot.listenerCount('physicsTick'), 0);
assert.ok(recorder.snapshot().stoppedAt > 0);
});
test('录制时长被上限截断', () => {
const { recorder } = setup();
const result = recorder.start(9999);
assert.match(result.message, /120 秒/);
recorder.stop();
});
test('换 bot 时先收尾旧录制', () => {
const { bot, recorder } = setup();
recorder.start();
tick(bot);
const other = new FakeBot();
recorder.setBot(other);
assert.strictEqual(recorder.running, false, '换 bot 应停止录制');
assert.strictEqual(recorder.snapshot().count, 1, '旧 bot 录到的内容保留');
tick(bot);
assert.strictEqual(recorder.snapshot().count, 1, '旧 bot 不再被记录');
});
test('clear 清空已录内容且不影响录制状态', () => {
const { bot, recorder } = setup();
recorder.start();
tick(bot);
assert.strictEqual(recorder.snapshot().count, 1);
recorder.clear();
const snap = recorder.snapshot();
assert.strictEqual(snap.count, 0);
assert.strictEqual(snap.running, true);
});
test('方块读不到(区块未加载)时记 null 而不是抛错', () => {
const { bot, recorder } = setup();
recorder.start();
bot.blockAt = () => null;
tick(bot);
const row = recorder.snapshot().ticks[0];
assert.strictEqual(row.blockBelow, null);
assert.strictEqual(row.blockAtFeet, null);
});
test('录制自身出异常也不影响物理循环', () => {
const { bot, recorder } = setup();
recorder.start();
// 位置是 NaN 时直接跳过,不抛错
tick(bot, { position: { x: NaN, y: 0, z: 0 } });
assert.strictEqual(recorder.snapshot().count, 0);
// blockAt 抛错也不能让 physicsTick 崩
bot.entity.position = { x: 1, y: 64, z: 1, offset: () => ({ x: 1, y: 63, z: 1 }) };
bot.blockAt = () => {
throw new Error('炸了');
};
assert.doesNotThrow(() => bot.emit('physicsTick'));
const snap = recorder.snapshot();
assert.strictEqual(snap.count, 1);
assert.strictEqual(snap.ticks[0].blockBelow, null);
});
test('默认上限是一个合理的正数', () => {
assert.ok(DEFAULT_MAX_TICKS >= 20);
assert.strictEqual(new PhysicsRecorder().maxTicks, DEFAULT_MAX_TICKS);
});