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 轮全量运行全绿。
这个提交包含在:
@@ -0,0 +1,251 @@
|
||||
'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);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户