文件
BotStore/test/packetCapture.test.js
T
lyizai 8ab2d59eb0 feat: 新增出站包抓取,供「走不动」逐包对照(TODO 假设 5)
线上实测确认手动执行器(1.7.0 备选方案)不能解决「走不动」:前 20 秒走 13 格后
即卡在「执行中断(stuck)→重规划→缩短段长→跳过目标→重新规划同一目标」的死循环。
只靠 packetStats.outboundTypes 的计数看不出「发出去的内容对不对」,所以补一个
能录原始出站序列、并能在「能走」与「走不动」两种状态下 diff 的诊断能力。

新增 src/core/packetCapture.js:
- 只在录制期间挂钩 client.write,其余时间零开销(该钩子每个出站包都要过一遍);
- 录到的内容做降深 + 截断的 JSON 化处理,向量对象只留坐标
  (move_minecart 这类包能带上万个浮点数,不处理会把缓冲区撑爆);
- 只放内存、有上限(默认 4000 包),不写盘。

接口(控制台):
- POST /api/bots/:id/capture/start(可带 seconds 到点自动停止)
- POST /api/bots/:id/capture/stop
- GET  /api/bots/:id/capture(支持 ?types= 与 ?limit=)

顺带修掉两个先前就存在的问题:
- scripts/check-protocol.js 其实一直跑不通:minecraft-data 的
  types.packet[1][1].type[1].fields[包名] 给的是类型名字符串,
  要再去 types[名字] 取 ['container', [字段...]] 才能展开,原实现直接 .map() 必然抛错;
  另有读 node_modules 少一层 '..' 的路径问题。
- 两条租约用例(expansionController / targetLease)的时序余量只有 15ms,
  全量并行跑测试时偶发失败;改为 TTL 400ms 配 280ms 睡眠,各留 120ms 余量,
  断言内容不变。

测试:436 → 451 个用例,连续 16 轮全量运行全绿。
2026-09-29 22:01:02 +08:00

252 行
8.1 KiB
JavaScript

'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { PacketCapture, DEFAULT_MAX_PACKETS } = require('../src/core/packetCapture');
/** 造一个假的 mineflayer client:只记下 write 被调用了什么 */
function fakeClient() {
const sent = [];
return {
sent,
write(name, params) {
sent.push({ name, params });
return true;
}
};
}
/** 一个类实例形式的向量(用来验证「类实例只留坐标」) */
class FakeVec3 {
constructor(x, y, z) {
this.x = x;
this.y = y;
this.z = z;
}
clone() {
return new FakeVec3(this.x, this.y, this.z);
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
test('没有连接时拒绝开始抓包', () => {
const capture = new PacketCapture();
const result = capture.start(1);
assert.strictEqual(result.ok, false);
assert.match(result.message, /没有可录制的连接/);
assert.strictEqual(capture.running, false);
});
test('录制期间记录出站包,同时原样发包', () => {
const client = fakeClient();
const capture = new PacketCapture();
capture.setClient(client);
const original = client.write;
assert.strictEqual(capture.start(1).ok, true);
assert.strictEqual(capture.running, true);
assert.notStrictEqual(client.write, original, '录制期间 write 应被挂钩');
client.write('position_look', { x: 1, y: 2, z: 3, onGround: true });
client.write('player_input', { inputs: { forward: true } });
// 发包链路不受影响:原 write 照样被调用
assert.strictEqual(client.sent.length, 2);
assert.deepStrictEqual(client.sent[0], {
name: 'position_look',
params: { x: 1, y: 2, z: 3, onGround: true }
});
const snap = capture.snapshot();
assert.strictEqual(snap.count, 2);
assert.deepStrictEqual(snap.byType, { position_look: 1, player_input: 1 });
assert.strictEqual(snap.packets[0].name, 'position_look');
assert.strictEqual(snap.packets[0].params.onGround, true);
assert.ok(snap.packets[1].t >= snap.packets[0].t, 't 应为相对开始时间的非递减偏移');
});
test('position_look 的参数是普通对象,yaw / pitch / onGround / flags 都必须保留', () => {
// 回归:曾按「有 x/y/z 就当向量」处理,会把 position_look 的
// yaw / pitch / onGround / flags 全部吞掉 —— 而那正是最需要对照的字段
const client = fakeClient();
const capture = new PacketCapture();
capture.setClient(client);
capture.start();
client.write('position_look', {
x: 1.23456789,
y: 64,
z: -3.5,
yaw: 90.5,
pitch: -12.25,
onGround: true,
flags: { onGround: true, hasHorizontalCollision: undefined }
});
const params = capture.snapshot().packets[0].params;
assert.strictEqual(params.yaw, 90.5);
assert.strictEqual(params.pitch, -12.25);
assert.strictEqual(params.onGround, true);
assert.deepStrictEqual(params.flags, { onGround: true, hasHorizontalCollision: null });
assert.strictEqual(params.x, 1.23457, '浮点保留 5 位');
});
test('类实例形式的向量只留坐标', () => {
const client = fakeClient();
const capture = new PacketCapture();
capture.setClient(client);
capture.start();
client.write('custom_payload', { pos: new FakeVec3(1, 2, 3), name: 'x' });
const params = capture.snapshot().packets[0].params;
assert.deepStrictEqual(params.pos, { x: 1, y: 2, z: 3 });
assert.strictEqual(params.name, 'x');
});
test('停止后摘掉钩子并不再记录', () => {
const client = fakeClient();
const capture = new PacketCapture();
capture.setClient(client);
const original = client.write;
capture.start();
client.write('position', { x: 0 });
const result = capture.stop();
assert.strictEqual(result.ok, true);
assert.strictEqual(capture.running, false);
assert.strictEqual(client.write, original, '停止后 write 应恢复原函数');
client.write('position', { x: 1 });
assert.strictEqual(capture.snapshot().count, 1, '停止后不应再记录');
assert.strictEqual(client.sent.length, 2, '停止后仍然正常发包');
});
test('重复 stop 不会报错', () => {
const client = fakeClient();
const capture = new PacketCapture();
capture.setClient(client);
capture.start();
assert.strictEqual(capture.stop().ok, true);
assert.strictEqual(capture.stop().ok, true);
assert.match(capture.stop().message, /没有在抓包/);
});
test('到达上限后丢弃并计数,不无限吃内存', () => {
const client = fakeClient();
const capture = new PacketCapture({ maxPackets: 3 });
capture.setClient(client);
capture.start();
for (let i = 0; i < 10; i++) client.write('position', { x: i });
const snap = capture.snapshot();
assert.strictEqual(snap.count, 3);
assert.strictEqual(snap.dropped, 7);
assert.strictEqual(snap.packets[2].params.x, 2, '保留的是最早的那几个包');
});
test('snapshot 支持按包名过滤与 limit', () => {
const client = fakeClient();
const capture = new PacketCapture();
capture.setClient(client);
capture.start();
client.write('position_look', { x: 1 });
client.write('look', { yaw: 1 });
client.write('position_look', { x: 2 });
client.write('position_look', { x: 3 });
const filtered = capture.snapshot({ types: 'position_look,look' });
assert.strictEqual(filtered.count, 4, 'count 是过滤前的总数');
assert.strictEqual(filtered.returned, 4);
const onlyLook = capture.snapshot({ types: ['look'] });
assert.strictEqual(onlyLook.returned, 1);
assert.strictEqual(onlyLook.packets[0].name, 'look');
const limited = capture.snapshot({ limit: 2 });
assert.strictEqual(limited.returned, 2);
assert.strictEqual(limited.count, 4);
});
test('给出时长后到点自动停止并摘钩子', async () => {
const client = fakeClient();
const capture = new PacketCapture();
capture.setClient(client);
const original = client.write;
capture.start(0.05);
assert.strictEqual(capture.running, true);
await sleep(150);
assert.strictEqual(capture.running, false, '到点应自动停止');
assert.strictEqual(client.write, original, '自动停止也要摘钩子');
assert.ok(capture.snapshot().stoppedAt > 0);
});
test('录制时长被上限截断', () => {
const client = fakeClient();
const capture = new PacketCapture();
capture.setClient(client);
const result = capture.start(9999);
assert.match(result.message, /60 秒/);
capture.stop();
});
test('换连接时先收尾旧录制', () => {
const first = fakeClient();
const second = fakeClient();
const capture = new PacketCapture();
capture.setClient(first);
capture.start();
first.write('position', { x: 1 });
const firstOriginal = first.write;
assert.notStrictEqual(firstOriginal, undefined);
capture.setClient(second);
assert.strictEqual(capture.running, false, '换连接应停止录制');
assert.strictEqual(capture.snapshot().count, 1, '旧连接录到的内容保留');
first.write('position', { x: 2 });
assert.strictEqual(capture.snapshot().count, 1, '旧连接不再被记录');
});
test('clear 清空已录内容', () => {
const client = fakeClient();
const capture = new PacketCapture();
capture.setClient(client);
capture.start();
client.write('position', { x: 1 });
assert.strictEqual(capture.snapshot().count, 1);
capture.clear();
const snap = capture.snapshot();
assert.strictEqual(snap.count, 0);
assert.deepStrictEqual(snap.byType, {});
assert.strictEqual(snap.running, true, 'clear 不影响录制状态');
});
test('抓包自身出异常也绝不影响发包', () => {
const client = fakeClient();
const capture = new PacketCapture();
capture.setClient(client);
capture.start();
// 造一个序列化时会抛的对象(getter 抛错)
const evil = {};
Object.defineProperty(evil, 'boom', {
enumerable: true,
get() {
throw new Error('getter 炸了');
}
});
assert.doesNotThrow(() => client.write('custom_payload', evil));
assert.strictEqual(client.sent.length, 1, '异常包仍然要发出去');
});
test('默认上限是一个合理的正数', () => {
assert.ok(DEFAULT_MAX_PACKETS > 0);
assert.strictEqual(new PacketCapture().maxPackets, DEFAULT_MAX_PACKETS);
});