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 轮全量运行全绿。
这个提交包含在:
@@ -11,6 +11,7 @@ const { AutoEater } = require('./autoEat');
|
||||
const { AutoBoatController } = require('./autoBoat');
|
||||
const { DurabilityGuard } = require('./durabilityGuard');
|
||||
const { ChatKind } = require('../core/chatLog');
|
||||
const { PacketCapture } = require('../core/packetCapture');
|
||||
|
||||
/** 服务器要求登录 / 注册时的提示关键词 */
|
||||
const LOGIN_PROMPT_PATTERN = /请(先)?(输入)?登录|请登录|请输入密码|login|登入/i;
|
||||
@@ -198,6 +199,14 @@ class BotManager extends EventEmitter {
|
||||
/** 每 tick 补发的 tick_end 次数 */
|
||||
tickEnd: 0
|
||||
};
|
||||
/**
|
||||
* 出站包抓取器(诊断用,默认不录制)。
|
||||
*
|
||||
* `packetStats.outboundTypes` 只统计「发了多少」,而反作弊拒绝移动时
|
||||
* 真正要看的是「发出去的内容对不对」,所以需要能录原始序列的工具,
|
||||
* 与「能走」时的序列逐包 diff(TODO.md 假设 5)。
|
||||
*/
|
||||
this.packetCapture = new PacketCapture();
|
||||
}
|
||||
|
||||
// ==================== 连接生命周期 ====================
|
||||
@@ -510,6 +519,8 @@ class BotManager extends EventEmitter {
|
||||
_wireEvents(bot) {
|
||||
this._installGroundStateFix(bot);
|
||||
this._wirePacketStats(bot);
|
||||
// 重连会换新的 client,这里每次都重新登记(只在录制期间才会真正挂钩 write)
|
||||
this.packetCapture.setClient(bot._client);
|
||||
|
||||
bot.on('login', () => {
|
||||
this.state = 'online';
|
||||
@@ -1193,6 +1204,43 @@ class BotManager extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始抓出站包(诊断用)。
|
||||
*
|
||||
* 典型用法:先在「机器人能走」时抓 5 秒,再在「走不动」时抓 5 秒,两次逐包 diff ——
|
||||
* 出站包内容上的差异(onGround / hasHorizontalCollision / player_input 位标志、
|
||||
* 位置与朝向是否合并成一次更新)比任何猜测都更能定位反作弊为什么拒绝移动。
|
||||
*
|
||||
* @param {number} [seconds] 录制时长(秒),省略则需手动停止
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
startPacketCapture(seconds) {
|
||||
const result = this.packetCapture.start(seconds);
|
||||
if (result.ok) this._log('抓包:' + result.message);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止抓包(已录到的内容保留,可继续用 `packetCaptureSnapshot()` 取)。
|
||||
*
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
stopPacketCapture() {
|
||||
const result = this.packetCapture.stop();
|
||||
this._log('抓包:' + result.message);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出抓包结果。
|
||||
*
|
||||
* @param {{types?: string|string[], limit?: number}} [options]
|
||||
* @returns {object}
|
||||
*/
|
||||
packetCaptureSnapshot(options) {
|
||||
return this.packetCapture.snapshot(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动移动测试:按住某个方向键若干秒,然后松开。
|
||||
*
|
||||
|
||||
@@ -596,6 +596,35 @@ class BotPool extends EventEmitter {
|
||||
return entry.manager.walkTest(direction, seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始抓出站包(诊断用)。
|
||||
* @param {string} id
|
||||
* @param {number} [seconds] 录制时长(秒),省略则需手动停止
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
startCapture(id, seconds) {
|
||||
return this.require(id).manager.startPacketCapture(seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止抓包。
|
||||
* @param {string} id
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
stopCapture(id) {
|
||||
return this.require(id).manager.stopPacketCapture();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出抓包结果。
|
||||
* @param {string} id
|
||||
* @param {{types?: string|string[], limit?: number}} [options]
|
||||
* @returns {object}
|
||||
*/
|
||||
captureSnapshot(id, options) {
|
||||
return this.require(id).manager.packetCaptureSnapshot(options);
|
||||
}
|
||||
|
||||
// ==================== 状态快照 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 出站包抓取:把机器人真正写出的协议包按时间顺序录下来,供逐包对照。
|
||||
*
|
||||
* 为什么需要它(TODO.md 问题 1 · 假设 5):
|
||||
* 现有的 `packetStats.outboundTypes` 只能回答「发了哪几种包、各多少次」,
|
||||
* 回答不了「发出去的内容对不对」—— 而反作弊拒绝移动的典型原因恰恰在内容里:
|
||||
* 上报的 `onGround` / `hasHorizontalCollision` 与服务端预测不一致、
|
||||
* `player_input` 的位标志不对、位置与朝向没放在同一次更新里……
|
||||
* 所以需要一个能录下原始出站序列、并能在「能走」与「走不动」两种状态下 diff 的工具。
|
||||
*
|
||||
* 设计取舍:
|
||||
* - **只在录制期间挂钩 `client.write`**:这个钩子每个出站包都要过一遍,
|
||||
* 常驻会平白给正常发包加开销,所以录制结束立刻摘掉;
|
||||
* - 录到的内容做**降深 + 截断**的 JSON 化处理,向量对象只留坐标 ——
|
||||
* 否则一个 `move_minecart` 包就能把缓冲区撑爆(实测该包能带上万个浮点数);
|
||||
* - 只放内存、有上限(默认 4000 包),不写盘,进程重启即清空。
|
||||
*/
|
||||
|
||||
/** 默认最多录多少个包(超出后丢弃并计数,避免长时间录制吃内存) */
|
||||
const DEFAULT_MAX_PACKETS = 4000;
|
||||
/** 单个字符串字段截断长度 */
|
||||
const MAX_STRING_LENGTH = 120;
|
||||
/** 数组字段最多保留多少项 */
|
||||
const MAX_ARRAY_ITEMS = 40;
|
||||
/** 对象展开的最大深度 */
|
||||
const MAX_DEPTH = 4;
|
||||
/** 浮点保留位数:够 diff,又不至于把接口响应撑大 */
|
||||
const ROUND_DIGITS = 5;
|
||||
/** 单次录制时长上限(秒),防止忘了停 */
|
||||
const MAX_SECONDS = 60;
|
||||
|
||||
class PacketCapture {
|
||||
/**
|
||||
* @param {{maxPackets?: number}} [options]
|
||||
*/
|
||||
constructor(options) {
|
||||
const opts = options || {};
|
||||
this.maxPackets = Number.isFinite(opts.maxPackets) && opts.maxPackets > 0
|
||||
? Math.floor(opts.maxPackets)
|
||||
: DEFAULT_MAX_PACKETS;
|
||||
/** @type {object|null} 当前要录制的 mineflayer client(重连后会换成新连接) */
|
||||
this._client = null;
|
||||
/** @type {Function|null} 被替换掉的原始 write */
|
||||
this._originalWrite = null;
|
||||
/** @type {Array<{t: number, name: string, params: object}>} 按时间顺序录到的出站包 */
|
||||
this.packets = [];
|
||||
this.running = false;
|
||||
this.startedAt = 0;
|
||||
this.stoppedAt = 0;
|
||||
/** 因超出上限被丢弃的包数(>0 说明这段录制不完整) */
|
||||
this.dropped = 0;
|
||||
this._timer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记住当前连接的 client。
|
||||
*
|
||||
* 每次装配事件时都会调用,重连后自动换成新连接;
|
||||
* 正在录制时换连接说明旧连接已经没了,先把录制收尾。
|
||||
*
|
||||
* @param {object|null} client
|
||||
*/
|
||||
setClient(client) {
|
||||
if (this._client === client) return;
|
||||
if (this.running) this.stop();
|
||||
this._client = client || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始录制。
|
||||
*
|
||||
* @param {number} [seconds] 录制时长(秒);给定时到点自动停止
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
start(seconds) {
|
||||
if (!this._client || typeof this._client.write !== 'function') {
|
||||
return { ok: false, message: '当前没有可录制的连接' };
|
||||
}
|
||||
if (this.running) this.stop();
|
||||
this.packets = [];
|
||||
this.dropped = 0;
|
||||
this.startedAt = Date.now();
|
||||
this.stoppedAt = 0;
|
||||
this.running = true;
|
||||
this._patch();
|
||||
|
||||
const duration = Number(seconds);
|
||||
if (Number.isFinite(duration) && duration > 0) {
|
||||
const bounded = Math.min(MAX_SECONDS, duration);
|
||||
this._timer = setTimeout(() => this.stop(), bounded * 1000);
|
||||
if (this._timer.unref) this._timer.unref();
|
||||
return { ok: true, message: `已开始抓包(${bounded} 秒后自动停止)` };
|
||||
}
|
||||
return { ok: true, message: '已开始抓包(需手动停止)' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止录制并摘掉钩子。已录到的内容保留,供 `snapshot()` 读取。
|
||||
*
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
stop() {
|
||||
if (this._timer) {
|
||||
clearTimeout(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
if (!this.running) return { ok: true, message: '当前没有在抓包' };
|
||||
this.running = false;
|
||||
this.stoppedAt = Date.now();
|
||||
this._unpatch();
|
||||
return { ok: true, message: `抓包已停止,共录到 ${this.packets.length} 个包` };
|
||||
}
|
||||
|
||||
/** 清空已录内容(不影响是否正在录制) */
|
||||
clear() {
|
||||
this.packets = [];
|
||||
this.dropped = 0;
|
||||
this.startedAt = this.running ? Date.now() : 0;
|
||||
this.stoppedAt = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出录制结果。
|
||||
*
|
||||
* @param {{types?: string|string[], limit?: number}} [options]
|
||||
* types 只看这些包(逗号分隔或数组);limit 最多返回多少个包
|
||||
* @returns {object}
|
||||
*/
|
||||
snapshot(options) {
|
||||
const opts = options || {};
|
||||
const byType = {};
|
||||
for (const packet of this.packets) {
|
||||
byType[packet.name] = (byType[packet.name] || 0) + 1;
|
||||
}
|
||||
|
||||
const wanted = normalizeTypes(opts.types);
|
||||
let packets = wanted ? this.packets.filter((p) => wanted.includes(p.name)) : this.packets;
|
||||
const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.floor(opts.limit) : packets.length;
|
||||
if (packets.length > limit) packets = packets.slice(0, limit);
|
||||
|
||||
return {
|
||||
running: this.running,
|
||||
startedAt: this.startedAt,
|
||||
stoppedAt: this.stoppedAt,
|
||||
elapsedMs: this.startedAt ? (this.stoppedAt || Date.now()) - this.startedAt : 0,
|
||||
/** 录到的总包数(未按 types 过滤前) */
|
||||
count: this.packets.length,
|
||||
dropped: this.dropped,
|
||||
byType,
|
||||
/** 本次实际返回的包数 */
|
||||
returned: packets.length,
|
||||
packets
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 内部 ====================
|
||||
|
||||
/** 替换 client.write:先记录,再原样发出去 */
|
||||
_patch() {
|
||||
const client = this._client;
|
||||
const original = client.write;
|
||||
this._originalWrite = original;
|
||||
const capture = this;
|
||||
client.write = function patchedWrite(name, params) {
|
||||
capture._record(name, params);
|
||||
return original.apply(this, arguments);
|
||||
};
|
||||
}
|
||||
|
||||
/** 还原 client.write */
|
||||
_unpatch() {
|
||||
const client = this._client;
|
||||
if (client && this._originalWrite && client.write !== this._originalWrite) {
|
||||
client.write = this._originalWrite;
|
||||
}
|
||||
this._originalWrite = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一个出站包。
|
||||
*
|
||||
* 整个过程包在 try 里:抓包是诊断手段,绝不能因为它自己的异常把发包搞挂。
|
||||
*
|
||||
* @param {string} name 包名
|
||||
* @param {object} params 包内容
|
||||
*/
|
||||
_record(name, params) {
|
||||
if (!this.running) return;
|
||||
try {
|
||||
if (this.packets.length >= this.maxPackets) {
|
||||
this.dropped++;
|
||||
return;
|
||||
}
|
||||
this.packets.push({
|
||||
t: Date.now() - this.startedAt,
|
||||
name: String(name),
|
||||
params: toPlain(params, MAX_DEPTH)
|
||||
});
|
||||
} catch {
|
||||
// 忽略:不能让诊断影响正常发包
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把包内容转成「能进 JSON、又保留可比对信息」的普通对象。
|
||||
*
|
||||
* @param {*} value
|
||||
* @param {number} depth 还能往下展开几层
|
||||
* @returns {*}
|
||||
*/
|
||||
function toPlain(value, depth) {
|
||||
// undefined 显式转成 null 而不是删掉键:字段「没传」本身就是重要线索
|
||||
// (例如 mineflayer 把 hasHorizontalCollision 置为 undefined)
|
||||
if (value === undefined || value === null) return null;
|
||||
const type = typeof value;
|
||||
if (type === 'number') return roundNumber(value);
|
||||
if (type === 'boolean') return value;
|
||||
if (type === 'string') {
|
||||
return value.length > MAX_STRING_LENGTH ? value.slice(0, MAX_STRING_LENGTH) + '…' : value;
|
||||
}
|
||||
if (type === 'bigint') return Number(value);
|
||||
if (type === 'function') return '[function]';
|
||||
if (type !== 'object') return String(value);
|
||||
if (depth <= 0) return '[depth]';
|
||||
if (Array.isArray(value)) {
|
||||
return value.slice(0, MAX_ARRAY_ITEMS).map((item) => toPlain(item, depth - 1));
|
||||
}
|
||||
// 向量对象(Vec3 之类的类实例)只留坐标。
|
||||
// 注意这里必须排除「普通对象」:`position_look` 的参数本身就是
|
||||
// `{x, y, z, yaw, pitch, onGround, flags}`,若按向量处理会把
|
||||
// yaw / pitch / onGround / flags 全部丢掉 —— 而那正是最需要对照的字段。
|
||||
if (!isPlainObject(value)
|
||||
&& typeof value.x === 'number' && typeof value.y === 'number' && typeof value.z === 'number') {
|
||||
return { x: roundNumber(value.x), y: roundNumber(value.y), z: roundNumber(value.z) };
|
||||
}
|
||||
const out = {};
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
out[key] = toPlain(item, depth - 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 是否是「普通对象」(对象字面量 / Object.create(null)),而不是类实例 */
|
||||
function isPlainObject(value) {
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === Object.prototype || proto === null;
|
||||
}
|
||||
|
||||
/** 浮点取整;NaN / Infinity 转成字符串,免得 JSON 里变成 null 看不出区别 */
|
||||
function roundNumber(value) {
|
||||
if (!Number.isFinite(value)) return String(value);
|
||||
const factor = 10 ** ROUND_DIGITS;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
/** 把 types 参数统一成数组;没给就返回 null(表示不过滤) */
|
||||
function normalizeTypes(types) {
|
||||
if (types === undefined || types === null || types === '') return null;
|
||||
const list = Array.isArray(types) ? types : String(types).split(',');
|
||||
const cleaned = list.map((item) => String(item).trim()).filter(Boolean);
|
||||
return cleaned.length > 0 ? cleaned : null;
|
||||
}
|
||||
|
||||
module.exports = { PacketCapture, DEFAULT_MAX_PACKETS, MAX_SECONDS };
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
在新工单中引用
屏蔽一个用户