feat: 新增物理 tick 录制,把「走不动」的三种原因切开(1.9.0)
抓包已经证明「手动按键时客户端坐标一字未变、服务端入站位置包为 0」, 问题在 prismarine-physics 这一层;但抓包只能看到「发出去的包」, 看不到物理引擎内部读到了什么输入、算出了什么、有没有真的移动。 本提交补上这最后一块拼图。 新增 src/core/physicsRecorder.js + 控制台接口 (POST /api/bots/:id/physics/start|stop、GET /api/bots/:id/physics): 逐 tick 记录位置增量、速度、onGround、isCollidedHorizontally、控制键快照、 脚下/脚部/头部方块名、physicsEnabled;汇总给出四个判定计数: - inputTicks 有移动输入的 tick 数 - inputMovedTicks 其中真的产生了水平位移的 - blockedTicks 有输入、没位移、物理报告碰撞 → 被方块挡住(地形) - frozenTicks 有输入、没位移、物理没报告碰撞 → 输入根本没进物理 即:抓包看「发了什么」,本录制器看「物理读到了什么、动没动」,两者配合即可定位。 细节: - 录制开始时先取一次当前位置作基线,避免第一个 tick 因「没有上一 tick 可比」 被误判成「有输入但没动」; - 滚动缓冲只保留最近 240 个 tick(约 12 秒),只放内存、不写盘; - 录制全程 try 包裹,诊断自身出异常绝不影响物理循环; - FakeBot 补上与 mineflayer 一致的 controlState(带 getter 的实时控制键代理)。 测试:451 → 468 个用例(含三种故障判定的回归用例与控制台接口用例),全绿。
这个提交包含在:
+32
@@ -2,6 +2,38 @@
|
||||
|
||||
本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
## [1.9.0] - 2026-09-29
|
||||
|
||||
补上诊断「走不动」的最后一块拼图:抓包只能看到「客户端发了什么」,
|
||||
看不到物理引擎内部读到了什么输入、算出了什么、有没有真的移动。
|
||||
新增**物理 tick 录制器**,与抓包配合就能把三种完全不同的原因一刀切开。
|
||||
|
||||
### 新增
|
||||
|
||||
- **物理 tick 录制**(`src/core/physicsRecorder.js` + 控制台接口):
|
||||
- `POST /api/bots/:id/physics/start`(可带 `seconds`)、`/physics/stop`、
|
||||
`GET /api/bots/:id/physics`(支持 `?limit=`);
|
||||
- 逐 tick 记录:位置增量、速度、`onGround`、**`isCollidedHorizontally`**、
|
||||
控制键快照、脚下 / 脚部 / 头部方块名、`physicsEnabled`;
|
||||
- 汇总给出三个判定用的计数:`inputTicks`(有移动输入的 tick 数)、
|
||||
`inputMovedTicks`(其中真的产生了水平位移的)、
|
||||
`blockedTicks`(有输入、没位移、物理报告碰撞 → **被方块挡住**)、
|
||||
`frozenTicks`(有输入、没位移、物理没报告碰撞 → **输入根本没进物理**);
|
||||
- 录制开始时先取一次当前位置作基线,避免第一个 tick 因「没有上一 tick 可比」
|
||||
被误判成「有输入但没动」;
|
||||
- 滚动缓冲只保留最近 240 个 tick(约 12 秒),只放内存、不写盘。
|
||||
|
||||
### 变更
|
||||
|
||||
- `FakeBot` 补上与 mineflayer 一致的 `controlState`(带 getter 的实时控制键代理)——
|
||||
那正是 mineflayer 传给物理引擎的那个对象。
|
||||
|
||||
### 测试
|
||||
|
||||
新增 17 个用例(451 → 468):物理录制器的逐 tick 记录、三种故障判定
|
||||
(被挡住 / 输入没生效 / 正常移动)、滚动缓冲、方块读不到时的容错,
|
||||
以及控制台物理录制接口的 HTTP 用例。
|
||||
|
||||
## [1.8.0] - 2026-09-29
|
||||
|
||||
把「走不动」的排查手段补成可复用的诊断能力:能按需录下机器人**真正发出的协议包内容**,
|
||||
|
||||
@@ -44,6 +44,7 @@ npm start # 打开 http://127.0.0.1:3081
|
||||
| **定时消息(间隔 / cron)** | ✅ 新增 | 固定间隔或 cron 表达式定时发送,可选「期望回复」确认发送是否真的生效 |
|
||||
| **人工介入提醒(Webhook)** | ✅ 新增 | 等待验证码 / 原点自救失败时推送到飞书等自定义机器人,不必一直盯控制台 |
|
||||
| **出站包抓取(诊断)** | ✅ 新增 | 按需录下机器人真正发出的协议包(含内容),用于与「能走」时的序列逐包对照,定位反作弊拒绝移动的原因 |
|
||||
| **物理 tick 录制(诊断)** | ✅ 新增 | 逐 tick 记录物理引擎读到的输入、算出的速度、有无位移与碰撞,把「输入没进物理 / 被方块挡住 / 服务端回弹」三种原因切开 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -381,6 +381,21 @@ curl -s -X POST -H "X-Auth-Token: $PW" http://103.236.99.177:78/api/bots/$BOT/ca
|
||||
|
||||
# 协议字段定义自检(核对 mineflayer 写出的字段名与 1.21.4 是否一致)
|
||||
node scripts/check-protocol.js
|
||||
|
||||
# 录物理 tick(诊断「走不动」的另一半:抓包看「发了什么」,这里看「物理读到了什么、动没动」)
|
||||
# 一边录一边按方向键,然后看三个计数:
|
||||
# inputTicks>0 且 inputMovedTicks=0 且 frozenTicks>0 → 输入根本没进物理
|
||||
# inputTicks>0 且 inputMovedTicks=0 且 blockedTicks>0 → 被方块挡住(地形)
|
||||
# inputMovedTicks>0 但服务端把位置拉回 → 才是服务端回弹
|
||||
curl -s -X POST -H "X-Auth-Token: $PW" -H 'Content-Type: application/json' \
|
||||
-d '{"seconds":10}' http://103.236.99.177:78/api/bots/$BOT/physics/start
|
||||
curl -s -X POST -H "X-Auth-Token: $PW" -H 'Content-Type: application/json' \
|
||||
-d '{"direction":"forward","seconds":3}' http://103.236.99.177:78/api/bots/$BOT/move
|
||||
curl -s -H "X-Auth-Token: $PW" http://103.236.99.177:78/api/bots/$BOT/physics \
|
||||
| jq '.data | {inputTicks, inputMovedTicks, blockedTicks, frozenTicks, movedTicks, maxHorizDelta, last}'
|
||||
# 逐 tick 明细(看速度、onGround、碰撞、脚下方块)
|
||||
curl -s -H "X-Auth-Token: $PW" 'http://103.236.99.177:78/api/bots/$BOT/physics?limit=40' \
|
||||
| jq '.data.ticks[] | {t, dx, dz, vx, vz, onGround, collidedHorizontally, blockBelow, control}'
|
||||
```
|
||||
|
||||
### 判断「机器人是不是卡在登录服」
|
||||
|
||||
自动生成
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "autostore-bot",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "autostore-bot",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.2",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "autostore-bot",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"description": "AutoStore Bot —— Minecraft 自动扩地机器人(mineflayer 重写版,多机器人挂机,Web 控制台,开拓日志,大地图,Grim 反作弊适配)",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -12,6 +12,7 @@ const { AutoBoatController } = require('./autoBoat');
|
||||
const { DurabilityGuard } = require('./durabilityGuard');
|
||||
const { ChatKind } = require('../core/chatLog');
|
||||
const { PacketCapture } = require('../core/packetCapture');
|
||||
const { PhysicsRecorder } = require('../core/physicsRecorder');
|
||||
|
||||
/** 服务器要求登录 / 注册时的提示关键词 */
|
||||
const LOGIN_PROMPT_PATTERN = /请(先)?(输入)?登录|请登录|请输入密码|login|登入/i;
|
||||
@@ -207,6 +208,14 @@ class BotManager extends EventEmitter {
|
||||
* 与「能走」时的序列逐包 diff(TODO.md 假设 5)。
|
||||
*/
|
||||
this.packetCapture = new PacketCapture();
|
||||
/**
|
||||
* 物理 tick 录制器(诊断用,默认不录制)。
|
||||
*
|
||||
* 抓包只能看到「发出去的包」,看不到物理引擎内部:它这一 tick 读到的 control、
|
||||
* 算出的速度、有没有真的移动、有没有报告水平碰撞。而「走不动」正卡在这一层,
|
||||
* 所以单独再要一个逐 tick 录制器。
|
||||
*/
|
||||
this.physicsRecorder = new PhysicsRecorder();
|
||||
}
|
||||
|
||||
// ==================== 连接生命周期 ====================
|
||||
@@ -521,6 +530,7 @@ class BotManager extends EventEmitter {
|
||||
this._wirePacketStats(bot);
|
||||
// 重连会换新的 client,这里每次都重新登记(只在录制期间才会真正挂钩 write)
|
||||
this.packetCapture.setClient(bot._client);
|
||||
this.physicsRecorder.setBot(bot);
|
||||
|
||||
bot.on('login', () => {
|
||||
this.state = 'online';
|
||||
@@ -1241,6 +1251,43 @@ class BotManager extends EventEmitter {
|
||||
return this.packetCapture.snapshot(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始录制物理 tick(诊断用)。
|
||||
*
|
||||
* 与抓包配合使用:抓包回答「客户端发了什么」,本录制器回答
|
||||
* 「物理引擎这一 tick 读到了什么输入、算出了什么、有没有真的移动」。
|
||||
* 两者一起看,才能把「输入没进物理」「被方块挡住」「服务端回弹」三种原因切开。
|
||||
*
|
||||
* @param {number} [seconds] 录制时长(秒),省略则需手动停止
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
startPhysicsRecorder(seconds) {
|
||||
const result = this.physicsRecorder.start(seconds);
|
||||
if (result.ok) this._log('物理录制:' + result.message);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止物理 tick 录制。
|
||||
*
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
stopPhysicsRecorder() {
|
||||
const result = this.physicsRecorder.stop();
|
||||
this._log('物理录制:' + result.message);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出物理 tick 录制结果。
|
||||
*
|
||||
* @param {{limit?: number}} [options]
|
||||
* @returns {object}
|
||||
*/
|
||||
physicsSnapshot(options) {
|
||||
return this.physicsRecorder.snapshot(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动移动测试:按住某个方向键若干秒,然后松开。
|
||||
*
|
||||
|
||||
@@ -625,6 +625,35 @@ class BotPool extends EventEmitter {
|
||||
return this.require(id).manager.packetCaptureSnapshot(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始录制物理 tick(诊断用)。
|
||||
* @param {string} id
|
||||
* @param {number} [seconds] 录制时长(秒),省略则需手动停止
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
startPhysicsRecorder(id, seconds) {
|
||||
return this.require(id).manager.startPhysicsRecorder(seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止物理 tick 录制。
|
||||
* @param {string} id
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
stopPhysicsRecorder(id) {
|
||||
return this.require(id).manager.stopPhysicsRecorder();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出物理 tick 录制结果。
|
||||
* @param {string} id
|
||||
* @param {{limit?: number}} [options]
|
||||
* @returns {object}
|
||||
*/
|
||||
physicsSnapshot(id, options) {
|
||||
return this.require(id).manager.physicsSnapshot(options);
|
||||
}
|
||||
|
||||
// ==================== 状态快照 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 物理 tick 录制器:逐 tick 记录「输入 → 输出」,用来定位「走不动」到底卡在哪一层。
|
||||
*
|
||||
* 为什么需要它:出站包抓取(`packetCapture`)已经证明 —— 手动按住方向键 3 秒时
|
||||
* 客户端自己的坐标与朝向一个字节都没变、服务端也没有下发任何位置包(入站 0 个),
|
||||
* 而 `player_input` 如实上报了按键。也就是说问题在 prismarine-physics 这一层,
|
||||
* 但抓包只能看到「发出去的包」,看不到物理引擎内部。
|
||||
*
|
||||
* 本录制器补上的正是这一段。逐 tick 记录下面这些量,就能把两种完全不同的故障切开:
|
||||
*
|
||||
* | 观察 | 结论 |
|
||||
* | --- | --- |
|
||||
* | `control.forward = true` 但速度恒为 0、位置不动、`collidedHorizontally = false` | **输入没进物理**(applyHeading 没被调用 / control 快照是空的) |
|
||||
* | 速度非 0 但位置不动、`collidedHorizontally = true` | **被方块挡住**(地形问题,或脚下/身旁方块读错) |
|
||||
* | 速度与位置都在变,但服务端把位置拉回 | **服务端回弹**(此时才轮到反作弊那条线) |
|
||||
*
|
||||
* 所以 `isCollidedHorizontally` 是这里最关键的一个字段。
|
||||
*/
|
||||
|
||||
/** 默认保留多少个 tick(20 TPS 下约 12 秒) */
|
||||
const DEFAULT_MAX_TICKS = 240;
|
||||
/** 判定「这一 tick 真的动了」的阈值(格) */
|
||||
const MOVE_EPSILON = 1e-6;
|
||||
/** 单次录制时长上限(秒) */
|
||||
const MAX_SECONDS = 120;
|
||||
/** 浮点保留位数 */
|
||||
const ROUND_DIGITS = 5;
|
||||
|
||||
class PhysicsRecorder {
|
||||
/**
|
||||
* @param {{maxTicks?: number}} [options]
|
||||
*/
|
||||
constructor(options) {
|
||||
const opts = options || {};
|
||||
this.maxTicks = Number.isFinite(opts.maxTicks) && opts.maxTicks > 0
|
||||
? Math.floor(opts.maxTicks)
|
||||
: DEFAULT_MAX_TICKS;
|
||||
/** @type {object|null} 当前要录制的 bot(重连后会换新的) */
|
||||
this._bot = null;
|
||||
/** @type {Function|null} 挂在 bot 上的 physicsTick 处理器 */
|
||||
this._handler = null;
|
||||
/** @type {Array<object>} 滚动缓冲:只保留最近 maxTicks 个 tick */
|
||||
this.ticks = [];
|
||||
this.running = false;
|
||||
this.startedAt = 0;
|
||||
this.stoppedAt = 0;
|
||||
this._timer = null;
|
||||
/** 上一 tick 的位置,用来算增量 */
|
||||
this._prev = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记住当前连接的 bot。
|
||||
*
|
||||
* 每次装配事件时都会调用,重连后自动换成新 bot;
|
||||
* 正在录制时换连接说明旧连接已经没了,先把录制收尾。
|
||||
*
|
||||
* @param {object|null} bot
|
||||
*/
|
||||
setBot(bot) {
|
||||
if (this._bot === bot) return;
|
||||
if (this.running) this.stop();
|
||||
this._bot = bot || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始录制。
|
||||
*
|
||||
* @param {number} [seconds] 录制时长(秒);给定时到点自动停止
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
start(seconds) {
|
||||
const bot = this._bot;
|
||||
if (!bot || typeof bot.on !== 'function') {
|
||||
return { ok: false, message: '当前没有可录制的连接' };
|
||||
}
|
||||
if (this.running) this.stop();
|
||||
|
||||
this.ticks = [];
|
||||
this.startedAt = Date.now();
|
||||
this.stoppedAt = 0;
|
||||
this.running = true;
|
||||
this._handler = () => this._record();
|
||||
bot.on('physicsTick', this._handler);
|
||||
|
||||
// 先取一次当前位置当基线:否则第一个 tick 没有「上一 tick」可比,
|
||||
// 位移会被记成 0,在统计里白白多出一个「有输入但没动」的假样本。
|
||||
const pos = bot.entity && bot.entity.position;
|
||||
this._prev = pos && Number.isFinite(pos.x) ? { x: pos.x, y: pos.y, z: pos.z } : null;
|
||||
|
||||
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: `已开始录制物理 tick(${bounded} 秒后自动停止)` };
|
||||
}
|
||||
return { ok: true, message: '已开始录制物理 tick(需手动停止)' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止录制并摘掉监听。已录到的内容保留,供 `snapshot()` 读取。
|
||||
*
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
stop() {
|
||||
if (this._timer) {
|
||||
clearTimeout(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
if (!this.running) return { ok: true, message: '当前没有在录制物理 tick' };
|
||||
this.running = false;
|
||||
this.stoppedAt = Date.now();
|
||||
if (this._bot && this._handler) {
|
||||
try {
|
||||
this._bot.removeListener('physicsTick', this._handler);
|
||||
} catch {
|
||||
// 忽略:连接可能已经断开
|
||||
}
|
||||
}
|
||||
this._handler = null;
|
||||
return { ok: true, message: `物理 tick 录制已停止,共 ${this.ticks.length} 个 tick` };
|
||||
}
|
||||
|
||||
/** 清空已录内容(不影响是否正在录制) */
|
||||
clear() {
|
||||
this.ticks = [];
|
||||
this._prev = null;
|
||||
this.startedAt = this.running ? Date.now() : 0;
|
||||
this.stoppedAt = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出录制结果。
|
||||
*
|
||||
* @param {{limit?: number}} [options] limit 最多返回最后多少个 tick(默认全给)
|
||||
* @returns {object}
|
||||
*/
|
||||
snapshot(options) {
|
||||
const opts = options || {};
|
||||
const all = this.ticks;
|
||||
const hasInput = (t) => t.control.forward || t.control.back || t.control.left || t.control.right;
|
||||
const hasMoved = (t) => Math.abs(t.dx) > MOVE_EPSILON || Math.abs(t.dz) > MOVE_EPSILON;
|
||||
|
||||
const inputTicks = all.filter(hasInput);
|
||||
const inputMoved = inputTicks.filter(hasMoved);
|
||||
const blocked = inputTicks.filter((t) => !hasMoved(t) && t.collidedHorizontally);
|
||||
const frozen = inputTicks.filter((t) => !hasMoved(t) && !t.collidedHorizontally);
|
||||
|
||||
let ticks = all;
|
||||
const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.floor(opts.limit) : all.length;
|
||||
if (ticks.length > limit) ticks = ticks.slice(ticks.length - limit);
|
||||
|
||||
const maxHoriz = all.reduce((max, t) => Math.max(max, Math.hypot(t.dx, t.dz)), 0);
|
||||
|
||||
return {
|
||||
running: this.running,
|
||||
startedAt: this.startedAt,
|
||||
stoppedAt: this.stoppedAt,
|
||||
elapsedMs: this.startedAt ? (this.stoppedAt || Date.now()) - this.startedAt : 0,
|
||||
count: all.length,
|
||||
/**
|
||||
* 有移动输入的 tick 数,与其中「真的产生了水平位移」的 tick 数。
|
||||
* 前者 > 0 而后者 = 0,就是「按了键但物理没动」—— 一眼看出输入没进物理。
|
||||
*/
|
||||
inputTicks: inputTicks.length,
|
||||
inputMovedTicks: inputMoved.length,
|
||||
/** 有输入、没位移、且物理报告水平碰撞:被方块挡住 */
|
||||
blockedTicks: blocked.length,
|
||||
/** 有输入、没位移、且物理没报告碰撞:输入根本没生效 */
|
||||
frozenTicks: frozen.length,
|
||||
movedTicks: all.filter(hasMoved).length,
|
||||
maxHorizDelta: Number(maxHoriz.toFixed(ROUND_DIGITS)),
|
||||
physicsEnabled: all.length > 0 ? all[all.length - 1].physicsEnabled : null,
|
||||
last: all.length > 0 ? all[all.length - 1] : null,
|
||||
returned: ticks.length,
|
||||
ticks
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 内部 ====================
|
||||
|
||||
/**
|
||||
* 记录一个物理 tick。
|
||||
*
|
||||
* 挂在 `physicsTick` 上,因此这里读到的是**物理引擎算完之后**的状态
|
||||
* (mineflayer 的顺序是 simulatePlayer → emit('physicsTick') → updatePosition)。
|
||||
* 输入侧读 `bot.controlState`(mineflayer 用 getter 暴露的实时控制键)。
|
||||
*
|
||||
* 整个过程包在 try 里:录制是诊断手段,绝不能因为它自己的异常影响物理循环。
|
||||
*/
|
||||
_record() {
|
||||
if (!this.running) return;
|
||||
try {
|
||||
const bot = this._bot;
|
||||
const entity = bot && bot.entity;
|
||||
const pos = entity && entity.position;
|
||||
if (!pos || !Number.isFinite(pos.x)) return;
|
||||
const vel = entity.velocity || { x: 0, y: 0, z: 0 };
|
||||
const prev = this._prev;
|
||||
|
||||
const row = {
|
||||
t: Date.now() - this.startedAt,
|
||||
x: round(pos.x),
|
||||
y: round(pos.y),
|
||||
z: round(pos.z),
|
||||
dx: prev ? round(pos.x - prev.x) : 0,
|
||||
dy: prev ? round(pos.y - prev.y) : 0,
|
||||
dz: prev ? round(pos.z - prev.z) : 0,
|
||||
vx: round(vel.x),
|
||||
vy: round(vel.y),
|
||||
vz: round(vel.z),
|
||||
onGround: entity.onGround === true,
|
||||
/** 物理引擎是否认为撞到了东西 —— 区分「输入没生效」与「被方块挡住」的关键 */
|
||||
collidedHorizontally: entity.isCollidedHorizontally === true,
|
||||
collidedVertically: entity.isCollidedVertically === true,
|
||||
physicsEnabled: bot.physicsEnabled !== false,
|
||||
inWater: entity.isInWater === true,
|
||||
inWeb: entity.isInWeb === true,
|
||||
control: {
|
||||
forward: controlOf(bot, 'forward'),
|
||||
back: controlOf(bot, 'back'),
|
||||
left: controlOf(bot, 'left'),
|
||||
right: controlOf(bot, 'right'),
|
||||
jump: controlOf(bot, 'jump'),
|
||||
sprint: controlOf(bot, 'sprint'),
|
||||
sneak: controlOf(bot, 'sneak')
|
||||
},
|
||||
/** 物理引擎脚下/脚下所读到的方块(null = 该区块未加载) */
|
||||
blockBelow: blockName(bot, pos, 0, -1, 0),
|
||||
blockAtFeet: blockName(bot, pos, 0, 0, 0),
|
||||
blockAtHead: blockName(bot, pos, 0, 1, 0)
|
||||
};
|
||||
|
||||
this._prev = { x: pos.x, y: pos.y, z: pos.z };
|
||||
if (this.ticks.length >= this.maxTicks) this.ticks.shift();
|
||||
this.ticks.push(row);
|
||||
} catch {
|
||||
// 忽略:不能让诊断影响物理循环
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 读某个偏移处的方块名;区块未加载返回 null(与物理引擎看到的一致) */
|
||||
function blockName(bot, pos, dx, dy, dz) {
|
||||
try {
|
||||
const block = bot.blockAt(pos.offset(dx, dy, dz), false);
|
||||
return block ? block.name : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读某个控制键是否按下。
|
||||
*
|
||||
* mineflayer 用带 getter 的 `bot.controlState` 暴露实时控制键(见其 physics.js),
|
||||
* 那正是传给物理引擎的那个对象,所以优先读它;测试替身只有 `getControlState` 时回退。
|
||||
*
|
||||
* @param {object} bot
|
||||
* @param {string} name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function controlOf(bot, name) {
|
||||
const state = bot.controlState;
|
||||
if (state && state[name] !== undefined) return state[name] === true;
|
||||
if (typeof bot.getControlState === 'function') {
|
||||
try {
|
||||
return bot.getControlState(name) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 浮点取整;NaN / Infinity 转字符串,免得 JSON 里变成 null 看不出区别 */
|
||||
function round(value) {
|
||||
if (!Number.isFinite(value)) return String(value);
|
||||
const factor = 10 ** ROUND_DIGITS;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
|
||||
module.exports = { PhysicsRecorder, DEFAULT_MAX_TICKS, MAX_SECONDS };
|
||||
@@ -476,6 +476,37 @@ class WebConsole {
|
||||
});
|
||||
});
|
||||
|
||||
// 物理 tick 录制:抓包回答「客户端发了什么」,这里回答
|
||||
// 「物理引擎读到了什么输入、算出了什么、有没有真的移动」。
|
||||
// 两者一起看,才能把「输入没进物理 / 被方块挡住 / 服务端回弹」三种原因切开。
|
||||
app.post('/api/bots/:id/physics/start', (req, res) => {
|
||||
this._withEntry(req, res, (entry) => {
|
||||
const body = req.body || {};
|
||||
const result = this.pool.startPhysicsRecorder(entry.id, body.seconds);
|
||||
res.status(result.ok ? 200 : 409).json(result);
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/bots/:id/physics/stop', (req, res) => {
|
||||
this._withEntry(req, res, (entry) => {
|
||||
const result = this.pool.stopPhysicsRecorder(entry.id);
|
||||
res.json({ ok: true, message: result.message });
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/bots/:id/physics', (req, res) => {
|
||||
this._withEntry(req, res, (entry) => {
|
||||
const limit = Number.parseInt(req.query.limit, 10);
|
||||
const data = this.pool.physicsSnapshot(entry.id, {
|
||||
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;
|
||||
@@ -1301,6 +1332,47 @@ class SingleBotPool {
|
||||
}
|
||||
return entry.manager.packetCaptureSnapshot(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始录制物理 tick。
|
||||
* @param {string} id
|
||||
* @param {number} [seconds]
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
startPhysicsRecorder(id, seconds) {
|
||||
const entry = this.require(id);
|
||||
if (typeof entry.manager.startPhysicsRecorder !== 'function') {
|
||||
return { ok: false, message: '当前运行模式不支持物理录制' };
|
||||
}
|
||||
return entry.manager.startPhysicsRecorder(seconds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止物理 tick 录制。
|
||||
* @param {string} id
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
stopPhysicsRecorder(id) {
|
||||
const entry = this.require(id);
|
||||
if (typeof entry.manager.stopPhysicsRecorder !== 'function') {
|
||||
return { ok: true, message: '当前运行模式不支持物理录制' };
|
||||
}
|
||||
return entry.manager.stopPhysicsRecorder();
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出物理 tick 录制结果。
|
||||
* @param {string} id
|
||||
* @param {{limit?: number}} [options]
|
||||
* @returns {object}
|
||||
*/
|
||||
physicsSnapshot(id, options) {
|
||||
const entry = this.require(id);
|
||||
if (typeof entry.manager.physicsSnapshot !== 'function') {
|
||||
return { running: false, count: 0, inputTicks: 0, inputMovedTicks: 0, ticks: [] };
|
||||
}
|
||||
return entry.manager.physicsSnapshot(options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -50,6 +50,16 @@ class FakeBot extends EventEmitter {
|
||||
};
|
||||
/** @type {Record<string, boolean>} */
|
||||
this.controls = {};
|
||||
/**
|
||||
* 与 mineflayer 保持一致:用带 getter 的 `controlState` 暴露实时控制键。
|
||||
* mineflayer 传给物理引擎的就是这个对象,所以诊断代码按它读输入。
|
||||
*/
|
||||
this.controlState = {};
|
||||
for (const control of ['forward', 'back', 'left', 'right', 'jump', 'sprint', 'sneak']) {
|
||||
Object.defineProperty(this.controlState, control, {
|
||||
get: () => this.controls[control] === true
|
||||
});
|
||||
}
|
||||
this.heldItem = opts.heldItem || null;
|
||||
this.inventory = { slots: opts.slots || [] };
|
||||
this.entities = {};
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
const { PhysicsRecorder, DEFAULT_MAX_TICKS } = require('../src/core/physicsRecorder');
|
||||
const { FakeBot } = require('./helpers/fakeBot');
|
||||
|
||||
/** 造一个站在石头上的假 bot(脚下是 stone,便于断言方块名) */
|
||||
function setup(options) {
|
||||
const opts = options || {};
|
||||
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
|
||||
bot.blockMap.set('0,63,0', { name: 'stone', boundingBox: 'block' });
|
||||
bot.blockMap.set('0,64,0', { name: 'air', boundingBox: 'empty' });
|
||||
bot.blockMap.set('0,65,0', { name: 'air', boundingBox: 'empty' });
|
||||
const recorder = new PhysicsRecorder(opts);
|
||||
recorder.setBot(bot);
|
||||
return { bot, recorder };
|
||||
}
|
||||
|
||||
/** 模拟一 tick:先改位置/速度/碰撞状态,再触发 physicsTick */
|
||||
function tick(bot, patch) {
|
||||
Object.assign(bot.entity, patch || {});
|
||||
bot.emit('physicsTick');
|
||||
}
|
||||
|
||||
test('没有 bot 时拒绝开始录制', () => {
|
||||
const recorder = new PhysicsRecorder();
|
||||
const result = recorder.start(1);
|
||||
assert.strictEqual(result.ok, false);
|
||||
assert.match(result.message, /没有可录制的连接/);
|
||||
assert.strictEqual(recorder.running, false);
|
||||
});
|
||||
|
||||
test('逐 tick 记录输入、速度、位移与脚下方块', () => {
|
||||
const { bot, recorder } = setup();
|
||||
assert.strictEqual(recorder.start(1).ok, true);
|
||||
assert.strictEqual(recorder.running, true);
|
||||
|
||||
bot.setControlState('forward', true);
|
||||
bot.entity.velocity.set(0.1, -0.078, 0);
|
||||
tick(bot, { position: bot.entity.position.offset(0, 0, 0.1) });
|
||||
tick(bot, { position: bot.entity.position.offset(0, 0, 0.1) });
|
||||
|
||||
const snap = recorder.snapshot();
|
||||
assert.strictEqual(snap.count, 2);
|
||||
assert.strictEqual(snap.physicsEnabled, true);
|
||||
const first = snap.ticks[0];
|
||||
assert.strictEqual(first.control.forward, true);
|
||||
assert.strictEqual(first.control.back, false);
|
||||
assert.strictEqual(first.vx, 0.1);
|
||||
assert.strictEqual(first.onGround, true);
|
||||
assert.strictEqual(first.blockBelow, 'stone');
|
||||
assert.strictEqual(first.blockAtFeet, 'air');
|
||||
assert.strictEqual(first.blockAtHead, 'air');
|
||||
// 录制开始时会先取一次当前位置当基线,所以第一个 tick 也有有效位移
|
||||
assert.strictEqual(first.dz, 0.1);
|
||||
assert.strictEqual(snap.ticks[1].dz, 0.1);
|
||||
assert.strictEqual(snap.inputTicks, 2);
|
||||
assert.strictEqual(snap.inputMovedTicks, 2);
|
||||
assert.strictEqual(snap.maxHorizDelta, 0.1);
|
||||
});
|
||||
|
||||
test('有输入但没位移且物理报告水平碰撞 → 记为「被方块挡住」', () => {
|
||||
const { bot, recorder } = setup();
|
||||
recorder.start();
|
||||
bot.setControlState('forward', true);
|
||||
// 位置不变、物理报告水平碰撞:典型的「撞墙」
|
||||
tick(bot, { isCollidedHorizontally: true });
|
||||
tick(bot, { isCollidedHorizontally: true });
|
||||
|
||||
const snap = recorder.snapshot();
|
||||
assert.strictEqual(snap.inputTicks, 2);
|
||||
assert.strictEqual(snap.inputMovedTicks, 0);
|
||||
assert.strictEqual(snap.blockedTicks, 2);
|
||||
assert.strictEqual(snap.frozenTicks, 0, '报告了碰撞就不算「输入没生效」');
|
||||
});
|
||||
|
||||
test('有输入但没位移且物理没报告碰撞 → 记为「输入没进物理」', () => {
|
||||
const { bot, recorder } = setup();
|
||||
recorder.start();
|
||||
bot.setControlState('forward', true);
|
||||
// 位置不变、速度恒为 0、也没有碰撞报告:输入根本没生效
|
||||
tick(bot, { isCollidedHorizontally: false });
|
||||
tick(bot, { isCollidedHorizontally: false });
|
||||
|
||||
const snap = recorder.snapshot();
|
||||
assert.strictEqual(snap.inputTicks, 2);
|
||||
assert.strictEqual(snap.inputMovedTicks, 0);
|
||||
assert.strictEqual(snap.frozenTicks, 2);
|
||||
assert.strictEqual(snap.blockedTicks, 0, '没报告碰撞就不算「被挡住」');
|
||||
});
|
||||
|
||||
test('没有输入时不计入 inputTicks', () => {
|
||||
const { bot, recorder } = setup();
|
||||
recorder.start();
|
||||
tick(bot);
|
||||
tick(bot);
|
||||
const snap = recorder.snapshot();
|
||||
assert.strictEqual(snap.count, 2);
|
||||
assert.strictEqual(snap.inputTicks, 0);
|
||||
assert.strictEqual(snap.movedTicks, 0);
|
||||
});
|
||||
|
||||
test('停止后摘掉监听并不再记录', () => {
|
||||
const { bot, recorder } = setup();
|
||||
recorder.start();
|
||||
tick(bot);
|
||||
const result = recorder.stop();
|
||||
assert.strictEqual(result.ok, true);
|
||||
assert.strictEqual(recorder.running, false);
|
||||
assert.match(result.message, /共 1 个 tick/);
|
||||
|
||||
tick(bot);
|
||||
assert.strictEqual(recorder.snapshot().count, 1, '停止后不应再记录');
|
||||
assert.strictEqual(bot.listenerCount('physicsTick'), 0, '监听应已摘掉');
|
||||
});
|
||||
|
||||
test('重复 stop 不报错', () => {
|
||||
const { recorder } = setup();
|
||||
recorder.start();
|
||||
assert.strictEqual(recorder.stop().ok, true);
|
||||
assert.match(recorder.stop().message, /没有在录制/);
|
||||
});
|
||||
|
||||
test('滚动缓冲只保留最近 maxTicks 个 tick', () => {
|
||||
const { bot, recorder } = setup({ maxTicks: 3 });
|
||||
recorder.start();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
tick(bot, { position: bot.entity.position.offset(0, 0, 1) });
|
||||
}
|
||||
const snap = recorder.snapshot();
|
||||
assert.strictEqual(snap.count, 3);
|
||||
assert.strictEqual(snap.ticks.length, 3);
|
||||
});
|
||||
|
||||
test('snapshot 的 limit 只影响返回条数,不影响统计', () => {
|
||||
const { bot, recorder } = setup();
|
||||
recorder.start();
|
||||
bot.setControlState('forward', true);
|
||||
for (let i = 0; i < 5; i++) tick(bot, { position: bot.entity.position.offset(0, 0, 1) });
|
||||
|
||||
const snap = recorder.snapshot({ limit: 2 });
|
||||
assert.strictEqual(snap.count, 5);
|
||||
assert.strictEqual(snap.returned, 2);
|
||||
assert.strictEqual(snap.ticks.length, 2);
|
||||
assert.strictEqual(snap.inputMovedTicks, 5);
|
||||
});
|
||||
|
||||
test('给出时长后到点自动停止并摘监听', async () => {
|
||||
const { bot, recorder } = setup();
|
||||
recorder.start(0.05);
|
||||
assert.strictEqual(recorder.running, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
assert.strictEqual(recorder.running, false);
|
||||
assert.strictEqual(bot.listenerCount('physicsTick'), 0);
|
||||
assert.ok(recorder.snapshot().stoppedAt > 0);
|
||||
});
|
||||
|
||||
test('录制时长被上限截断', () => {
|
||||
const { recorder } = setup();
|
||||
const result = recorder.start(9999);
|
||||
assert.match(result.message, /120 秒/);
|
||||
recorder.stop();
|
||||
});
|
||||
|
||||
test('换 bot 时先收尾旧录制', () => {
|
||||
const { bot, recorder } = setup();
|
||||
recorder.start();
|
||||
tick(bot);
|
||||
const other = new FakeBot();
|
||||
recorder.setBot(other);
|
||||
assert.strictEqual(recorder.running, false, '换 bot 应停止录制');
|
||||
assert.strictEqual(recorder.snapshot().count, 1, '旧 bot 录到的内容保留');
|
||||
tick(bot);
|
||||
assert.strictEqual(recorder.snapshot().count, 1, '旧 bot 不再被记录');
|
||||
});
|
||||
|
||||
test('clear 清空已录内容且不影响录制状态', () => {
|
||||
const { bot, recorder } = setup();
|
||||
recorder.start();
|
||||
tick(bot);
|
||||
assert.strictEqual(recorder.snapshot().count, 1);
|
||||
recorder.clear();
|
||||
const snap = recorder.snapshot();
|
||||
assert.strictEqual(snap.count, 0);
|
||||
assert.strictEqual(snap.running, true);
|
||||
});
|
||||
|
||||
test('方块读不到(区块未加载)时记 null 而不是抛错', () => {
|
||||
const { bot, recorder } = setup();
|
||||
recorder.start();
|
||||
bot.blockAt = () => null;
|
||||
tick(bot);
|
||||
const row = recorder.snapshot().ticks[0];
|
||||
assert.strictEqual(row.blockBelow, null);
|
||||
assert.strictEqual(row.blockAtFeet, null);
|
||||
});
|
||||
|
||||
test('录制自身出异常也不影响物理循环', () => {
|
||||
const { bot, recorder } = setup();
|
||||
recorder.start();
|
||||
// 位置是 NaN 时直接跳过,不抛错
|
||||
tick(bot, { position: { x: NaN, y: 0, z: 0 } });
|
||||
assert.strictEqual(recorder.snapshot().count, 0);
|
||||
|
||||
// blockAt 抛错也不能让 physicsTick 崩
|
||||
bot.entity.position = { x: 1, y: 64, z: 1, offset: () => ({ x: 1, y: 63, z: 1 }) };
|
||||
bot.blockAt = () => {
|
||||
throw new Error('炸了');
|
||||
};
|
||||
assert.doesNotThrow(() => bot.emit('physicsTick'));
|
||||
const snap = recorder.snapshot();
|
||||
assert.strictEqual(snap.count, 1);
|
||||
assert.strictEqual(snap.ticks[0].blockBelow, null);
|
||||
});
|
||||
|
||||
test('默认上限是一个合理的正数', () => {
|
||||
assert.ok(DEFAULT_MAX_TICKS >= 20);
|
||||
assert.strictEqual(new PhysicsRecorder().maxTicks, DEFAULT_MAX_TICKS);
|
||||
});
|
||||
+48
-2
@@ -6,6 +6,8 @@ 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 { PhysicsRecorder } = require('../src/core/physicsRecorder');
|
||||
const { FakeBot } = require('./helpers/fakeBot');
|
||||
const { defaultConfig } = require('../src/config/expansionConfig');
|
||||
const { Logger } = require('../src/util/logger');
|
||||
const chunkPos = require('../src/core/chunkPos');
|
||||
@@ -59,12 +61,19 @@ function setupConsole(options) {
|
||||
};
|
||||
const packetCapture = new PacketCapture();
|
||||
packetCapture.setClient(captureClient);
|
||||
// 物理录制接口的桩:同样挂真的 PhysicsRecorder + FakeBot
|
||||
const physicsBot = new FakeBot({ x: 0, y: 64, z: 0 });
|
||||
const physicsRecorder = new PhysicsRecorder();
|
||||
physicsRecorder.setBot(physicsBot);
|
||||
const botManager = {
|
||||
snapshot: () => ({ state: 'online', username: 'TestBot' }),
|
||||
sendCommand: () => {},
|
||||
startPacketCapture: (seconds) => packetCapture.start(seconds),
|
||||
stopPacketCapture: () => packetCapture.stop(),
|
||||
packetCaptureSnapshot: (options) => packetCapture.snapshot(options)
|
||||
packetCaptureSnapshot: (options) => packetCapture.snapshot(options),
|
||||
startPhysicsRecorder: (seconds) => physicsRecorder.start(seconds),
|
||||
stopPhysicsRecorder: () => physicsRecorder.stop(),
|
||||
physicsSnapshot: (options) => physicsRecorder.snapshot(options)
|
||||
};
|
||||
const controller = {
|
||||
target: { x: 2, z: 0 },
|
||||
@@ -92,7 +101,7 @@ function setupConsole(options) {
|
||||
getConfig: () => config,
|
||||
applyConfig: (next) => next
|
||||
}, { host: '127.0.0.1', port: 0 });
|
||||
return { webConsole, model, logger, controller, captureClient, packetCapture };
|
||||
return { webConsole, model, logger, controller, captureClient, packetCapture, physicsBot, physicsRecorder };
|
||||
}
|
||||
|
||||
test('mapPayload 返回己方 / 会话 / 飞地 / 目标与各邦国摘要', () => {
|
||||
@@ -273,3 +282,40 @@ test('HTTP 接口:抓包可开始、取结果(含按包名过滤)、停止
|
||||
await webConsole.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('HTTP 接口:物理录制可开始、取结果、停止', async () => {
|
||||
const { webConsole, physicsBot } = setupConsole();
|
||||
await webConsole.start();
|
||||
const base = webConsole.baseUrl();
|
||||
try {
|
||||
const started = await fetch(base + '/api/bots/bot/physics/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, /已开始录制物理 tick/);
|
||||
|
||||
// 模拟「按着前进但物理没动」:应当被统计成 frozenTicks
|
||||
physicsBot.setControlState('forward', true);
|
||||
physicsBot.emit('physicsTick');
|
||||
physicsBot.emit('physicsTick');
|
||||
|
||||
const snap = await fetch(base + '/api/bots/bot/physics').then((r) => r.json());
|
||||
assert.strictEqual(snap.ok, true);
|
||||
assert.strictEqual(snap.data.running, true);
|
||||
assert.strictEqual(snap.data.count, 2);
|
||||
assert.strictEqual(snap.data.inputTicks, 2);
|
||||
assert.strictEqual(snap.data.inputMovedTicks, 0);
|
||||
assert.strictEqual(snap.data.frozenTicks, 2);
|
||||
|
||||
const limited = await fetch(base + '/api/bots/bot/physics?limit=1').then((r) => r.json());
|
||||
assert.strictEqual(limited.data.count, 2);
|
||||
assert.strictEqual(limited.data.returned, 1);
|
||||
|
||||
const stopped = await fetch(base + '/api/bots/bot/physics/stop', { method: 'POST' }).then((r) => r.json());
|
||||
assert.strictEqual(stopped.ok, true);
|
||||
} finally {
|
||||
await webConsole.stop();
|
||||
}
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户