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 轮全量运行全绿。
这个提交包含在:
lyizai
2026-09-29 22:01:02 +08:00
父节点 51664c55b6
当前提交 8ab2d59eb0
共修改 14 个文件,包含 867 行新增和 62 行删除
+72
查看文件
@@ -445,6 +445,37 @@ class WebConsole {
}
});
// 出站包抓取:与「移动测试」配合使用 —— 先抓「能走」时的 5 秒,
// 再抓「走不动」时的 5 秒,两次 diff 才能看出反作弊到底在拒绝什么。
app.post('/api/bots/:id/capture/start', (req, res) => {
this._withEntry(req, res, (entry) => {
const body = req.body || {};
const result = this.pool.startCapture(entry.id, body.seconds);
res.status(result.ok ? 200 : 409).json(result);
return null;
});
});
app.post('/api/bots/:id/capture/stop', (req, res) => {
this._withEntry(req, res, (entry) => {
const result = this.pool.stopCapture(entry.id);
res.json({ ok: true, message: result.message });
return null;
});
});
app.get('/api/bots/:id/capture', (req, res) => {
this._withEntry(req, res, (entry) => {
const limit = Number.parseInt(req.query.limit, 10);
const data = this.pool.captureSnapshot(entry.id, {
types: req.query.types,
limit: Number.isFinite(limit) ? limit : undefined
});
res.json({ ok: true, data });
return null;
});
});
// 控制台 / 聊天框操作:都要求该机器人已完成游戏内登录验证
app.post('/api/bots/:id/command', (req, res) => {
const command = req.body && req.body.command;
@@ -1229,6 +1260,47 @@ class SingleBotPool {
const ok = entry.manager.sendChat(text);
return { ok, kind, message: ok ? (isCommand ? '指令已发送:' + text : '消息已发送') : '发送失败' };
}
/**
* 开始抓出站包。
* @param {string} id
* @param {number} [seconds]
* @returns {{ok: boolean, message: string}}
*/
startCapture(id, seconds) {
const entry = this.require(id);
if (typeof entry.manager.startPacketCapture !== 'function') {
return { ok: false, message: '当前运行模式不支持抓包' };
}
return entry.manager.startPacketCapture(seconds);
}
/**
* 停止抓包。
* @param {string} id
* @returns {{ok: boolean, message: string}}
*/
stopCapture(id) {
const entry = this.require(id);
if (typeof entry.manager.stopPacketCapture !== 'function') {
return { ok: true, message: '当前运行模式不支持抓包' };
}
return entry.manager.stopPacketCapture();
}
/**
* 取出抓包结果。
* @param {string} id
* @param {{types?: string|string[], limit?: number}} [options]
* @returns {object}
*/
captureSnapshot(id, options) {
const entry = this.require(id);
if (typeof entry.manager.packetCaptureSnapshot !== 'function') {
return { running: false, count: 0, dropped: 0, byType: {}, returned: 0, packets: [] };
}
return entry.manager.packetCaptureSnapshot(options);
}
}
/**