抓包已经证明「手动按键时客户端坐标一字未变、服务端入站位置包为 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 个用例(含三种故障判定的回归用例与控制台接口用例),全绿。
222 行
7.8 KiB
JavaScript
222 行
7.8 KiB
JavaScript
'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);
|
|
});
|