diff --git a/CHANGELOG.md b/CHANGELOG.md index cf7b5e5..dceb6cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ 本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [1.8.0] - 2026-09-29 + +把「走不动」的排查手段补成可复用的诊断能力:能按需录下机器人**真正发出的协议包内容**, +与「能走」时的序列逐包对照(TODO.md 问题 1 · 假设 5)。 + +### 新增 + +- **出站包抓取**(`src/core/packetCapture.js` + 控制台接口): + - `POST /api/bots/:id/capture/start`(可带 `seconds` 到点自动停止)、 + `POST /api/bots/:id/capture/stop`、`GET /api/bots/:id/capture`(支持 `?types=` 与 `?limit=`); + - **只在录制期间挂钩 `client.write`**,其余时间零开销 —— 这个钩子每个出站包都要过一遍; + - 录到的内容做降深 + 截断的 JSON 化处理,向量对象只留坐标(`move_minecart` 这类包 + 能带上万个浮点数,不处理会把缓冲区撑爆); + - 只放内存、有上限(默认 4000 包),不写盘。 + +### 修复 + +- **`scripts/check-protocol.js` 其实一直跑不通**:`minecraft-data` 的 + `types.packet[1][1].type[1].fields[包名]` 给出的是**类型名字符串**(如 `packet_position_look`), + 必须再去 `types[名字]` 取 `['container', [字段...]]` 才能展开字段;原实现直接对它 `.map()`, + 必然抛 `def.map is not a function`。顺带修掉读 `node_modules` 时少了一层 `..` 的路径。 +- 修完即可确认:1.21.4 上 `position` / `position_look` / `look` 的地面标志是 + `flags: MovementFlags`(`u8` 位标志 `[onGround, hasHorizontalCollision]`), + 没有独立的 `onGround` 字段;`_fixOutboundOnGround` 改的正是正确的 `flags.onGround`。 +- **两条租约用例的时序余量过窄,会偶发失败**:`ttlMs: 40` 配两次 `setTimeout(25)`, + 只剩 15ms 调度余量,全量并行跑测试时会被压垮。改为 `ttlMs: 400` 配 280ms 睡眠, + 两侧各留 120ms 余量;断言内容不变(仍是「总耗时 > 原始 TTL、距续租 < TTL」)。 + +### 测试 + +新增 15 个用例(436 → 451):抓包器的录制 / 摘钩子 / 上限丢弃 / 按包名过滤 / +序列化边界,其中包含一条回归用例 —— `position_look` 的参数本身就是 +`{x, y, z, yaw, pitch, onGround, flags}`,**不能**被「有 x/y/z 就当向量」的规则吞掉; +另有控制台抓包接口的 HTTP 用例。 + ## [1.7.0] - 2026-09-29 按 `TODO.md` 的优先级把待办全部落地:先是「走不动」的攻关与备选方案, diff --git a/README.md b/README.md index ef837a4..6af3005 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ npm start # 打开 http://127.0.0.1:3081 | **登录验证门禁** | ✅ 新增 | 游戏内登录 + 控制台登录双重校验,通过后才允许操作机器人与发送消息 | | **定时消息(间隔 / cron)** | ✅ 新增 | 固定间隔或 cron 表达式定时发送,可选「期望回复」确认发送是否真的生效 | | **人工介入提醒(Webhook)** | ✅ 新增 | 等待验证码 / 原点自救失败时推送到飞书等自定义机器人,不必一直盯控制台 | +| **出站包抓取(诊断)** | ✅ 新增 | 按需录下机器人真正发出的协议包(含内容),用于与「能走」时的序列逐包对照,定位反作弊拒绝移动的原因 | --- diff --git a/TODO.md b/TODO.md index 57c830a..6b4bad7 100644 --- a/TODO.md +++ b/TODO.md @@ -322,6 +322,23 @@ grep '被服务器传送另有' /opt/autostore-bot/data/pm2-error.log | tail -5 # 多机器人协调:谁在占哪块(日志) grep '已被其他机器人预定' /opt/autostore-bot/data/pm2-out.log | tail -10 + +# 抓出站包(诊断「走不动」的主力工具,见问题 1 · 假设 5): +# 先在「能走」时抓 5 秒,再在「走不动」时抓 5 秒,然后逐包 diff +BOT=<机器人id> +curl -s -X POST -H "X-Auth-Token: $PW" -H 'Content-Type: application/json' \ + -d '{"seconds":5}' http://103.236.99.177:78/api/bots/$BOT/capture/start +# 取包类型分布(先看「发了哪几种、各多少次」) +curl -s -H "X-Auth-Token: $PW" http://103.236.99.177:78/api/bots/$BOT/capture | jq '.data.byType' +# 只取移动相关的原始包内容(看 onGround / hasHorizontalCollision / player_input 位标志 / teleportId) +curl -s -H "X-Auth-Token: $PW" \ + 'http://103.236.99.177:78/api/bots/$BOT/capture?types=position_look,look,position,flying,player_input,teleport_confirm&limit=60' \ + | jq '.data.packets' +# 手动停止(给 seconds 时可省略) +curl -s -X POST -H "X-Auth-Token: $PW" http://103.236.99.177:78/api/bots/$BOT/capture/stop + +# 协议字段定义自检(核对 mineflayer 写出的字段名与 1.21.4 是否一致) +node scripts/check-protocol.js ``` ### 判断「机器人是不是卡在登录服」 diff --git a/package-lock.json b/package-lock.json index 612e8a8..99b28db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "autostore-bot", - "version": "1.7.0", + "version": "1.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "autostore-bot", - "version": "1.7.0", + "version": "1.8.0", "license": "MIT", "dependencies": { "chalk": "^4.1.2", diff --git a/package.json b/package.json index 380736c..78392c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "autostore-bot", - "version": "1.7.0", + "version": "1.8.0", "description": "AutoStore Bot —— Minecraft 自动扩地机器人(mineflayer 重写版,多机器人挂机,Web 控制台,开拓日志,大地图,Grim 反作弊适配)", "main": "src/index.js", "scripts": { diff --git a/scripts/check-protocol.js b/scripts/check-protocol.js index f1adfe6..e040816 100644 --- a/scripts/check-protocol.js +++ b/scripts/check-protocol.js @@ -1,71 +1,92 @@ 'use strict'; /** - * 协议自检:核对 mineflayer 写出的 `teleport_confirm` 包在 1.21.4 上是否存在、 - * 字段名是否匹配。 + * 协议自检:核对 mineflayer 写出的移动相关包在目标版本上的字段名与结构。 * - * 背景:部分服务端在收到 `player_position` 后会每 tick 重发位置包, - * 直到客户端回一个 teleport_confirm 确认。若 mineflayer 因为版本字段名不一致 - * 而写不出这个包,就会形成「服务端一直传送、客户端一直走不动」的死循环。 + * 背景:部分服务端在收到位置包后会持续重发 `position`,直到客户端回一个 + * `teleport_confirm` 确认;若 mineflayer 因为版本字段名不一致而写不出这个包, + * 就会形成「服务端一直传送、客户端一直走不动」的死循环。 + * + * 注意 `minecraft-data` 的结构:`types.packet[1][1].type[1].fields[包名]` 拿到的是 + * **类型名字符串**(如 `packet_position_look`),要再去 `types[名字]` 取 + * `['container', [字段...]]` 才能看到字段。早先的版本直接对它 `.map()`, + * 所以这个脚本一直跑不通。 */ +const fs = require('fs'); +const path = require('path'); + const mcData = require('minecraft-data')('1.21.4'); +const VERSION = '1.21.4'; const toServer = mcData.protocol.play.toServer; -const mappings = toServer.types.packet[1][0].type[1].mappings; +const toClient = mcData.protocol.play.toClient; -console.log('=== 1.21.4 客户端 → 服务端 中与传送/位置相关的包名 ==='); -for (const [id, name] of Object.entries(mappings)) { - if (/teleport|position|confirm/i.test(name)) { - console.log(` ${id.padStart(3)} ${name}`); - } +/** 取某一方向「包名 → 类型名」的映射 */ +function packetNameMap(direction) { + const paramsField = direction.types.packet[1][1]; + return paramsField.type[1].fields; } -console.log('\n=== 客户端 ← 服务端 中与传送/位置相关的包名 ==='); -const toClientMappings = mcData.protocol.play.toClient.types.packet[1][0].type[1].mappings; -for (const [id, name] of Object.entries(toClientMappings)) { - if (/teleport|position/i.test(name)) { - console.log(` ${id.padStart(3)} ${name}`); - } +/** 把类型名解析成字段列表;解析不到返回 null */ +function resolveFields(direction, typeName) { + const type = direction.types[typeName]; + if (!Array.isArray(type) || type[0] !== 'container') return null; + return type[1]; } -console.log('\n=== teleport_confirm / position_look 的字段定义(客户端 → 服务端)==='); -function dumpPacketFields(packetName) { - // protocol 结构:types.packet = ['container', [nameField, paramsField]] - // paramsField.type = ['switch', { compareTo: 'name', fields: { 包名: [字段...] } }] - const paramsField = toServer.types.packet[1][1]; - const fields = paramsField.type[1].fields; - const def = fields[packetName]; - if (!def) { - console.log(` (没有名为 ${packetName} 的包)`); +/** 打印一个包的字段定义 */ +function dumpFields(direction, label, packetName) { + const typeName = packetNameMap(direction)[packetName]; + if (!typeName) { + console.log(` ${packetName.padEnd(18)} (该版本没有这个包)`); return; } - console.log(` ${packetName}: ` + def.map((f) => `${f.name}:${JSON.stringify(f.type)}`).join(', ')); -} - -dumpPacketFields('teleport_confirm'); -dumpPacketFields('position'); -dumpPacketFields('position_look'); - -console.log('\n=== 客户端 ← 服务端 position 包的字段定义 ==='); -function dumpToClientFields(packetName) { - const paramsField = mcData.protocol.play.toClient.types.packet[1][1]; - const def = paramsField.type[1].fields[packetName]; - if (!def) { - console.log(` (没有名为 ${packetName} 的包)`); + const fields = resolveFields(direction, typeName); + if (!fields) { + console.log(` ${packetName.padEnd(18)} ${typeName}(不是 container,无法展开)`); return; } - console.log(` ${packetName}: ` + def.map((f) => `${f.name}:${JSON.stringify(f.type)}`).join(', ')); + const text = fields.map((f) => `${f.name}:${JSON.stringify(f.type)}`).join(', '); + console.log(` ${packetName.padEnd(18)} ${text}`); } -dumpToClientFields('position'); -console.log('\n=== mineflayer 实际使用的包名 / 字段名 ==='); -const fs = require('fs'); -const path = require('path'); -const physics = fs.readFileSync(path.join(__dirname, 'node_modules', 'mineflayer', 'lib', 'plugins', 'physics.js'), 'utf8'); -const lines = physics.split('\n'); -lines.forEach((line, index) => { - if (/teleport_confirm|teleportId|teleport_id/.test(line)) { - console.log(` physics.js:${index + 1} ${line.trim()}`); +console.log(`=== ${VERSION} 客户端 → 服务端:移动 / 传送相关包 ===`); +const serverNames = packetNameMap(toServer); +for (const name of Object.keys(serverNames).sort()) { + if (/teleport|position|input|flying|^look$/i.test(name)) console.log(` ${name}`); +} + +console.log(`\n=== ${VERSION} 客户端 → 服务端:字段定义 ===`); +for (const name of ['position', 'position_look', 'look', 'flying', 'player_input', 'teleport_confirm', 'tick_end']) { + dumpFields(toServer, 'toServer', name); +} + +console.log('\n=== 关键位标志类型的展开 ==='); +for (const [name, def] of Object.entries(toServer.types)) { + if (Array.isArray(def) && def[0] === 'bitflags' && /MovementFlags|Input/i.test(name)) { + console.log(` ${name} = ${JSON.stringify(def)}`); } -}); +} + +console.log(`\n=== ${VERSION} 服务端 → 客户端:position 包字段 ===`); +dumpFields(toClient, 'toClient', 'position'); +for (const [name, def] of Object.entries(toClient.types)) { + if (Array.isArray(def) && def[0] === 'bitflags' && /PositionUpdateRelatives/i.test(name)) { + console.log(` ${name} = ${JSON.stringify(def)}`); + } +} + +console.log('\n=== mineflayer 实际写出的包 / 用到的字段名 ==='); +const physicsPath = path.join(__dirname, '..', 'node_modules', 'mineflayer', 'lib', 'plugins', 'physics.js'); +try { + const lines = fs.readFileSync(physicsPath, 'utf8').split('\n'); + lines.forEach((line, index) => { + if (/teleport_confirm|teleportId|_client\.write\(/.test(line)) { + console.log(` physics.js:${index + 1} ${line.trim()}`); + } + }); +} catch (err) { + console.error(' 读不到 mineflayer/physics.js:' + (err && err.message ? err.message : err)); + process.exitCode = 1; +} diff --git a/src/bot/botManager.js b/src/bot/botManager.js index dc0727a..00cb1a6 100644 --- a/src/bot/botManager.js +++ b/src/bot/botManager.js @@ -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); + } + /** * 手动移动测试:按住某个方向键若干秒,然后松开。 * diff --git a/src/bot/botPool.js b/src/bot/botPool.js index b5d4fff..696d76c 100644 --- a/src/bot/botPool.js +++ b/src/bot/botPool.js @@ -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); + } + // ==================== 状态快照 ==================== /** diff --git a/src/core/packetCapture.js b/src/core/packetCapture.js new file mode 100644 index 0000000..04e2bd3 --- /dev/null +++ b/src/core/packetCapture.js @@ -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 }; diff --git a/src/web/server.js b/src/web/server.js index 59d60cb..6cf666c 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -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); + } } /** diff --git a/test/expansionController.test.js b/test/expansionController.test.js index e2f7c0a..6a5ef2b 100644 --- a/test/expansionController.test.js +++ b/test/expansionController.test.js @@ -399,15 +399,21 @@ test('停止任务时归还租约,其他机器人立刻可以接手', async () }); test('运行中持续续租:租约不会在长距离寻路中途过期', async () => { - const table = new TargetLeaseTable({ ttlMs: 40 }); + // 时序余量刻意放宽:这条用例要证明的是「续租把到期时间往后推了」, + // 即「总耗时 > 原始 TTL」但「距续租的耗时 < TTL」。 + // 睡眠时长取 0.7 × TTL,两侧各留 0.3 × TTL(120ms)的调度余量 —— + // 早先用 TTL=40ms / 睡眠 25ms 只剩 15ms 余量,全量并行跑测试时必然偶发失败。 + const ttlMs = 400; + const sleepMs = 280; + const table = new TargetLeaseTable({ ttlMs }); const { controller, config } = setup({ targetLeases: table, botId: 'BotA' }); await controller.start(config); const target = controller.target; - await new Promise((resolve) => setTimeout(resolve, 25)); + await new Promise((resolve) => setTimeout(resolve, sleepMs)); controller.tick(); // 续租 - await new Promise((resolve) => setTimeout(resolve, 25)); - // 已经超过原始 TTL(40ms),但续租过,仍应归 A + await new Promise((resolve) => setTimeout(resolve, sleepMs)); + // 总耗时已超过原始 TTL(400ms),但续租过,仍应归 A assert.strictEqual(table.tryAcquire(target, 'BotB'), false, '续租后不应被抢走'); }); diff --git a/test/packetCapture.test.js b/test/packetCapture.test.js new file mode 100644 index 0000000..0285d26 --- /dev/null +++ b/test/packetCapture.test.js @@ -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); +}); diff --git a/test/targetLease.test.js b/test/targetLease.test.js index 5c7cb9c..f06f13a 100644 --- a/test/targetLease.test.js +++ b/test/targetLease.test.js @@ -53,12 +53,18 @@ test('releaseAll 释放某机器人的全部占用(停止任务 / 断线时用 }); test('renew 延长租约,防止长距离寻路中途被抢', async () => { - const table = new TargetLeaseTable({ ttlMs: 40 }); + // 时序余量刻意放宽:要证明的是「续租把到期时间往后推了」, + // 即「总耗时 > 原始 TTL」但「距续租的耗时 < TTL」。 + // 睡眠取 0.7 × TTL,两侧各留 0.3 × TTL(120ms)调度余量 —— + // 早先用 TTL=40ms / 睡眠 25ms 只剩 15ms,全量并行跑测试时会偶发失败。 + const ttlMs = 400; + const sleepMs = 280; + const table = new TargetLeaseTable({ ttlMs }); const chunk = { x: 7, z: 7 }; table.tryAcquire(chunk, 'A'); - await new Promise((resolve) => setTimeout(resolve, 25)); + await new Promise((resolve) => setTimeout(resolve, sleepMs)); assert.strictEqual(table.renew(chunk, 'A'), true); - await new Promise((resolve) => setTimeout(resolve, 25)); + await new Promise((resolve) => setTimeout(resolve, sleepMs)); // 已超过原始 TTL,但续租过 → 仍是 A 的 assert.strictEqual(table.tryAcquire(chunk, 'B'), false, '续租后不应被抢走'); }); diff --git a/test/webServer.test.js b/test/webServer.test.js index 328dc7a..da30780 100644 --- a/test/webServer.test.js +++ b/test/webServer.test.js @@ -5,6 +5,7 @@ const assert = require('node:assert'); const { flattenChunks, boundsOf, timingSafeEqual, WebConsole } = require('../src/web/server'); const { TerritoryModel } = require('../src/core/territoryModel'); +const { PacketCapture } = require('../src/core/packetCapture'); const { defaultConfig } = require('../src/config/expansionConfig'); const { Logger } = require('../src/util/logger'); const chunkPos = require('../src/core/chunkPos'); @@ -49,9 +50,21 @@ function setupConsole(options) { model.addSessionClaim({ x: 1, z: 0 }); const logger = new Logger({ echo: false }); + // 抓包接口的桩:挂一个真的 PacketCapture + 假 client,接口测到的是真实逻辑 + const captureClient = { + sent: [], + write(name, params) { + this.sent.push({ name, params }); + } + }; + const packetCapture = new PacketCapture(); + packetCapture.setClient(captureClient); const botManager = { snapshot: () => ({ state: 'online', username: 'TestBot' }), - sendCommand: () => {} + sendCommand: () => {}, + startPacketCapture: (seconds) => packetCapture.start(seconds), + stopPacketCapture: () => packetCapture.stop(), + packetCaptureSnapshot: (options) => packetCapture.snapshot(options) }; const controller = { target: { x: 2, z: 0 }, @@ -79,7 +92,7 @@ function setupConsole(options) { getConfig: () => config, applyConfig: (next) => next }, { host: '127.0.0.1', port: 0 }); - return { webConsole, model, logger, controller }; + return { webConsole, model, logger, controller, captureClient, packetCapture }; } test('mapPayload 返回己方 / 会话 / 飞地 / 目标与各邦国摘要', () => { @@ -221,3 +234,42 @@ test('HTTP 接口:POST /api/command 缺参数时返回 400', async () => { await webConsole.stop(); } }); + +test('HTTP 接口:抓包可开始、取结果(含按包名过滤)、停止', async () => { + const { webConsole, captureClient } = setupConsole(); + await webConsole.start(); + const base = webConsole.baseUrl(); + try { + const started = await fetch(base + '/api/bots/bot/capture/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ seconds: 5 }) + }).then((r) => r.json()); + assert.strictEqual(started.ok, true); + assert.match(started.message, /已开始抓包/); + + captureClient.write('position_look', { x: 1, y: 64, z: 2, yaw: 90, onGround: true }); + captureClient.write('look', { yaw: 91 }); + + const snap = await fetch(base + '/api/bots/bot/capture').then((r) => r.json()); + assert.strictEqual(snap.ok, true); + assert.strictEqual(snap.data.running, true); + assert.strictEqual(snap.data.count, 2); + assert.deepStrictEqual(snap.data.byType, { position_look: 1, look: 1 }); + assert.strictEqual(snap.data.packets[0].params.onGround, true); + + const filtered = await fetch(base + '/api/bots/bot/capture?types=look').then((r) => r.json()); + assert.strictEqual(filtered.data.count, 2, 'count 是过滤前的总数'); + assert.strictEqual(filtered.data.returned, 1); + assert.strictEqual(filtered.data.packets[0].name, 'look'); + + const stopped = await fetch(base + '/api/bots/bot/capture/stop', { method: 'POST' }).then((r) => r.json()); + assert.strictEqual(stopped.ok, true); + + const after = await fetch(base + '/api/bots/bot/capture').then((r) => r.json()); + assert.strictEqual(after.data.running, false); + assert.strictEqual(after.data.count, 2, '停止后已录内容仍可读'); + } finally { + await webConsole.stop(); + } +});