'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); });