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 轮全量运行全绿。
这个提交包含在:
+35
@@ -2,6 +2,41 @@
|
|||||||
|
|
||||||
本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。
|
本项目遵循 [约定式提交](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
|
## [1.7.0] - 2026-09-29
|
||||||
|
|
||||||
按 `TODO.md` 的优先级把待办全部落地:先是「走不动」的攻关与备选方案,
|
按 `TODO.md` 的优先级把待办全部落地:先是「走不动」的攻关与备选方案,
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ npm start # 打开 http://127.0.0.1:3081
|
|||||||
| **登录验证门禁** | ✅ 新增 | 游戏内登录 + 控制台登录双重校验,通过后才允许操作机器人与发送消息 |
|
| **登录验证门禁** | ✅ 新增 | 游戏内登录 + 控制台登录双重校验,通过后才允许操作机器人与发送消息 |
|
||||||
| **定时消息(间隔 / cron)** | ✅ 新增 | 固定间隔或 cron 表达式定时发送,可选「期望回复」确认发送是否真的生效 |
|
| **定时消息(间隔 / cron)** | ✅ 新增 | 固定间隔或 cron 表达式定时发送,可选「期望回复」确认发送是否真的生效 |
|
||||||
| **人工介入提醒(Webhook)** | ✅ 新增 | 等待验证码 / 原点自救失败时推送到飞书等自定义机器人,不必一直盯控制台 |
|
| **人工介入提醒(Webhook)** | ✅ 新增 | 等待验证码 / 原点自救失败时推送到飞书等自定义机器人,不必一直盯控制台 |
|
||||||
|
| **出站包抓取(诊断)** | ✅ 新增 | 按需录下机器人真正发出的协议包(含内容),用于与「能走」时的序列逐包对照,定位反作弊拒绝移动的原因 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -322,6 +322,23 @@ grep '被服务器传送另有' /opt/autostore-bot/data/pm2-error.log | tail -5
|
|||||||
|
|
||||||
# 多机器人协调:谁在占哪块(日志)
|
# 多机器人协调:谁在占哪块(日志)
|
||||||
grep '已被其他机器人预定' /opt/autostore-bot/data/pm2-out.log | tail -10
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
### 判断「机器人是不是卡在登录服」
|
### 判断「机器人是不是卡在登录服」
|
||||||
|
|||||||
自动生成
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "autostore-bot",
|
"name": "autostore-bot",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "autostore-bot",
|
"name": "autostore-bot",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^4.1.2",
|
"chalk": "^4.1.2",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "autostore-bot",
|
"name": "autostore-bot",
|
||||||
"version": "1.7.0",
|
"version": "1.8.0",
|
||||||
"description": "AutoStore Bot —— Minecraft 自动扩地机器人(mineflayer 重写版,多机器人挂机,Web 控制台,开拓日志,大地图,Grim 反作弊适配)",
|
"description": "AutoStore Bot —— Minecraft 自动扩地机器人(mineflayer 重写版,多机器人挂机,Web 控制台,开拓日志,大地图,Grim 反作弊适配)",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+70
-49
@@ -1,71 +1,92 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 协议自检:核对 mineflayer 写出的 `teleport_confirm` 包在 1.21.4 上是否存在、
|
* 协议自检:核对 mineflayer 写出的移动相关包在目标版本上的字段名与结构。
|
||||||
* 字段名是否匹配。
|
|
||||||
*
|
*
|
||||||
* 背景:部分服务端在收到 `player_position` 后会每 tick 重发位置包,
|
* 背景:部分服务端在收到位置包后会持续重发 `position`,直到客户端回一个
|
||||||
* 直到客户端回一个 teleport_confirm 确认。若 mineflayer 因为版本字段名不一致
|
* `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 mcData = require('minecraft-data')('1.21.4');
|
||||||
|
|
||||||
|
const VERSION = '1.21.4';
|
||||||
const toServer = mcData.protocol.play.toServer;
|
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)) {
|
function packetNameMap(direction) {
|
||||||
if (/teleport|position|confirm/i.test(name)) {
|
const paramsField = direction.types.packet[1][1];
|
||||||
console.log(` ${id.padStart(3)} ${name}`);
|
return paramsField.type[1].fields;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n=== 客户端 ← 服务端 中与传送/位置相关的包名 ===');
|
/** 把类型名解析成字段列表;解析不到返回 null */
|
||||||
const toClientMappings = mcData.protocol.play.toClient.types.packet[1][0].type[1].mappings;
|
function resolveFields(direction, typeName) {
|
||||||
for (const [id, name] of Object.entries(toClientMappings)) {
|
const type = direction.types[typeName];
|
||||||
if (/teleport|position/i.test(name)) {
|
if (!Array.isArray(type) || type[0] !== 'container') return null;
|
||||||
console.log(` ${id.padStart(3)} ${name}`);
|
return type[1];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n=== teleport_confirm / position_look 的字段定义(客户端 → 服务端)===');
|
/** 打印一个包的字段定义 */
|
||||||
function dumpPacketFields(packetName) {
|
function dumpFields(direction, label, packetName) {
|
||||||
// protocol 结构:types.packet = ['container', [nameField, paramsField]]
|
const typeName = packetNameMap(direction)[packetName];
|
||||||
// paramsField.type = ['switch', { compareTo: 'name', fields: { 包名: [字段...] } }]
|
if (!typeName) {
|
||||||
const paramsField = toServer.types.packet[1][1];
|
console.log(` ${packetName.padEnd(18)} (该版本没有这个包)`);
|
||||||
const fields = paramsField.type[1].fields;
|
|
||||||
const def = fields[packetName];
|
|
||||||
if (!def) {
|
|
||||||
console.log(` (没有名为 ${packetName} 的包)`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(` ${packetName}: ` + def.map((f) => `${f.name}:${JSON.stringify(f.type)}`).join(', '));
|
const fields = resolveFields(direction, typeName);
|
||||||
}
|
if (!fields) {
|
||||||
|
console.log(` ${packetName.padEnd(18)} ${typeName}(不是 container,无法展开)`);
|
||||||
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} 的包)`);
|
|
||||||
return;
|
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 实际使用的包名 / 字段名 ===');
|
console.log(`=== ${VERSION} 客户端 → 服务端:移动 / 传送相关包 ===`);
|
||||||
const fs = require('fs');
|
const serverNames = packetNameMap(toServer);
|
||||||
const path = require('path');
|
for (const name of Object.keys(serverNames).sort()) {
|
||||||
const physics = fs.readFileSync(path.join(__dirname, 'node_modules', 'mineflayer', 'lib', 'plugins', 'physics.js'), 'utf8');
|
if (/teleport|position|input|flying|^look$/i.test(name)) console.log(` ${name}`);
|
||||||
const lines = physics.split('\n');
|
}
|
||||||
lines.forEach((line, index) => {
|
|
||||||
if (/teleport_confirm|teleportId|teleport_id/.test(line)) {
|
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()}`);
|
console.log(` physics.js:${index + 1} ${line.trim()}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(' 读不到 mineflayer/physics.js:' + (err && err.message ? err.message : err));
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const { AutoEater } = require('./autoEat');
|
|||||||
const { AutoBoatController } = require('./autoBoat');
|
const { AutoBoatController } = require('./autoBoat');
|
||||||
const { DurabilityGuard } = require('./durabilityGuard');
|
const { DurabilityGuard } = require('./durabilityGuard');
|
||||||
const { ChatKind } = require('../core/chatLog');
|
const { ChatKind } = require('../core/chatLog');
|
||||||
|
const { PacketCapture } = require('../core/packetCapture');
|
||||||
|
|
||||||
/** 服务器要求登录 / 注册时的提示关键词 */
|
/** 服务器要求登录 / 注册时的提示关键词 */
|
||||||
const LOGIN_PROMPT_PATTERN = /请(先)?(输入)?登录|请登录|请输入密码|login|登入/i;
|
const LOGIN_PROMPT_PATTERN = /请(先)?(输入)?登录|请登录|请输入密码|login|登入/i;
|
||||||
@@ -198,6 +199,14 @@ class BotManager extends EventEmitter {
|
|||||||
/** 每 tick 补发的 tick_end 次数 */
|
/** 每 tick 补发的 tick_end 次数 */
|
||||||
tickEnd: 0
|
tickEnd: 0
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* 出站包抓取器(诊断用,默认不录制)。
|
||||||
|
*
|
||||||
|
* `packetStats.outboundTypes` 只统计「发了多少」,而反作弊拒绝移动时
|
||||||
|
* 真正要看的是「发出去的内容对不对」,所以需要能录原始序列的工具,
|
||||||
|
* 与「能走」时的序列逐包 diff(TODO.md 假设 5)。
|
||||||
|
*/
|
||||||
|
this.packetCapture = new PacketCapture();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 连接生命周期 ====================
|
// ==================== 连接生命周期 ====================
|
||||||
@@ -510,6 +519,8 @@ class BotManager extends EventEmitter {
|
|||||||
_wireEvents(bot) {
|
_wireEvents(bot) {
|
||||||
this._installGroundStateFix(bot);
|
this._installGroundStateFix(bot);
|
||||||
this._wirePacketStats(bot);
|
this._wirePacketStats(bot);
|
||||||
|
// 重连会换新的 client,这里每次都重新登记(只在录制期间才会真正挂钩 write)
|
||||||
|
this.packetCapture.setClient(bot._client);
|
||||||
|
|
||||||
bot.on('login', () => {
|
bot.on('login', () => {
|
||||||
this.state = 'online';
|
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);
|
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) => {
|
app.post('/api/bots/:id/command', (req, res) => {
|
||||||
const command = req.body && req.body.command;
|
const command = req.body && req.body.command;
|
||||||
@@ -1229,6 +1260,47 @@ class SingleBotPool {
|
|||||||
const ok = entry.manager.sendChat(text);
|
const ok = entry.manager.sendChat(text);
|
||||||
return { ok, kind, message: ok ? (isCommand ? '指令已发送:' + 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -399,15 +399,21 @@ test('停止任务时归还租约,其他机器人立刻可以接手', async ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
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' });
|
const { controller, config } = setup({ targetLeases: table, botId: 'BotA' });
|
||||||
await controller.start(config);
|
await controller.start(config);
|
||||||
const target = controller.target;
|
const target = controller.target;
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
await new Promise((resolve) => setTimeout(resolve, sleepMs));
|
||||||
controller.tick(); // 续租
|
controller.tick(); // 续租
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
await new Promise((resolve) => setTimeout(resolve, sleepMs));
|
||||||
// 已经超过原始 TTL(40ms),但续租过,仍应归 A
|
// 总耗时已超过原始 TTL(400ms),但续租过,仍应归 A
|
||||||
assert.strictEqual(table.tryAcquire(target, 'BotB'), false, '续租后不应被抢走');
|
assert.strictEqual(table.tryAcquire(target, 'BotB'), false, '续租后不应被抢走');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
+9
-3
@@ -53,12 +53,18 @@ test('releaseAll 释放某机器人的全部占用(停止任务 / 断线时用
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('renew 延长租约,防止长距离寻路中途被抢', async () => {
|
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 };
|
const chunk = { x: 7, z: 7 };
|
||||||
table.tryAcquire(chunk, 'A');
|
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);
|
assert.strictEqual(table.renew(chunk, 'A'), true);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
await new Promise((resolve) => setTimeout(resolve, sleepMs));
|
||||||
// 已超过原始 TTL,但续租过 → 仍是 A 的
|
// 已超过原始 TTL,但续租过 → 仍是 A 的
|
||||||
assert.strictEqual(table.tryAcquire(chunk, 'B'), false, '续租后不应被抢走');
|
assert.strictEqual(table.tryAcquire(chunk, 'B'), false, '续租后不应被抢走');
|
||||||
});
|
});
|
||||||
|
|||||||
+54
-2
@@ -5,6 +5,7 @@ const assert = require('node:assert');
|
|||||||
|
|
||||||
const { flattenChunks, boundsOf, timingSafeEqual, WebConsole } = require('../src/web/server');
|
const { flattenChunks, boundsOf, timingSafeEqual, WebConsole } = require('../src/web/server');
|
||||||
const { TerritoryModel } = require('../src/core/territoryModel');
|
const { TerritoryModel } = require('../src/core/territoryModel');
|
||||||
|
const { PacketCapture } = require('../src/core/packetCapture');
|
||||||
const { defaultConfig } = require('../src/config/expansionConfig');
|
const { defaultConfig } = require('../src/config/expansionConfig');
|
||||||
const { Logger } = require('../src/util/logger');
|
const { Logger } = require('../src/util/logger');
|
||||||
const chunkPos = require('../src/core/chunkPos');
|
const chunkPos = require('../src/core/chunkPos');
|
||||||
@@ -49,9 +50,21 @@ function setupConsole(options) {
|
|||||||
model.addSessionClaim({ x: 1, z: 0 });
|
model.addSessionClaim({ x: 1, z: 0 });
|
||||||
|
|
||||||
const logger = new Logger({ echo: false });
|
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 = {
|
const botManager = {
|
||||||
snapshot: () => ({ state: 'online', username: 'TestBot' }),
|
snapshot: () => ({ state: 'online', username: 'TestBot' }),
|
||||||
sendCommand: () => {}
|
sendCommand: () => {},
|
||||||
|
startPacketCapture: (seconds) => packetCapture.start(seconds),
|
||||||
|
stopPacketCapture: () => packetCapture.stop(),
|
||||||
|
packetCaptureSnapshot: (options) => packetCapture.snapshot(options)
|
||||||
};
|
};
|
||||||
const controller = {
|
const controller = {
|
||||||
target: { x: 2, z: 0 },
|
target: { x: 2, z: 0 },
|
||||||
@@ -79,7 +92,7 @@ function setupConsole(options) {
|
|||||||
getConfig: () => config,
|
getConfig: () => config,
|
||||||
applyConfig: (next) => next
|
applyConfig: (next) => next
|
||||||
}, { host: '127.0.0.1', port: 0 });
|
}, { host: '127.0.0.1', port: 0 });
|
||||||
return { webConsole, model, logger, controller };
|
return { webConsole, model, logger, controller, captureClient, packetCapture };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('mapPayload 返回己方 / 会话 / 飞地 / 目标与各邦国摘要', () => {
|
test('mapPayload 返回己方 / 会话 / 飞地 / 目标与各邦国摘要', () => {
|
||||||
@@ -221,3 +234,42 @@ test('HTTP 接口:POST /api/command 缺参数时返回 400', async () => {
|
|||||||
await webConsole.stop();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户