文件
BotStore/test/physicsRecorder.test.js
T
lyizai 5513ef9f5f fix: 物理录制器区分「被服务端推回」与「输入没进物理」
原来 frozenTicks 只看「有输入 + tick 级无位移 + 物理没报告碰撞」,
但服务端推回发生在 tick 之间,tick 级位移同样是 0 —— 实测大厅里就是这个形态:
collidedHorizontally = false、frozenTicks = 194,而物理其实每 tick 都在动。

现在再按水平速度是否非零细分:
- pushedBackTicks:速度非零 → 物理确实动了,位移被服务端位置包抹掉;
- frozenTicks:速度也是 0 → 这才是「输入根本没进物理」。

类注释里的判定表也同步改成三分法(被挡住 / 被推回 / 输入没生效)。

测试 469 → 470。
2026-10-01 16:28:14 +08:00

240 行
8.6 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('有输入、没位移、没碰撞,但水平速度非零 → 记为「被服务端推回」', () => {
// 实测形态:大厅里物理每 tick 都在动,位移却被服务端的位置包抹掉
// (推回发生在 tick 之间,所以 tick 级位移为 0、也没有方块碰撞)
const { bot, recorder } = setup();
recorder.start();
bot.setControlState('forward', true);
bot.entity.velocity.set(0, -0.0784, 0.02319);
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.pushedBackTicks, 2);
assert.strictEqual(snap.frozenTicks, 0, '速度非零就不该算「输入没进物理」');
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);
});