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 个用例(含三种故障判定的回归用例与控制台接口用例),全绿。
这个提交包含在:
lyizai
2026-09-30 21:05:38 +08:00
父节点 0c6700642e
当前提交 b2b0317af7
共修改 12 个文件,包含 764 行新增和 5 行删除
+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
把「走不动」的排查手段补成可复用的诊断能力:能按需录下机器人**真正发出的协议包内容**,
+1
查看文件
@@ -44,6 +44,7 @@ npm start # 打开 http://127.0.0.1:3081
| **定时消息(间隔 / cron)** | ✅ 新增 | 固定间隔或 cron 表达式定时发送,可选「期望回复」确认发送是否真的生效 |
| **人工介入提醒(Webhook)** | ✅ 新增 | 等待验证码 / 原点自救失败时推送到飞书等自定义机器人,不必一直盯控制台 |
| **出站包抓取(诊断)** | ✅ 新增 | 按需录下机器人真正发出的协议包(含内容),用于与「能走」时的序列逐包对照,定位反作弊拒绝移动的原因 |
| **物理 tick 录制(诊断)** | ✅ 新增 | 逐 tick 记录物理引擎读到的输入、算出的速度、有无位移与碰撞,把「输入没进物理 / 被方块挡住 / 服务端回弹」三种原因切开 |
---
+15
查看文件
@@ -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": {
+47
查看文件
@@ -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);
}
/**
* 手动移动测试:按住某个方向键若干秒,然后松开。
*
+29
查看文件
@@ -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);
}
// ==================== 状态快照 ====================
/**
+286
查看文件
@@ -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 };
+72
查看文件
@@ -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);
}
}
/**
+10
查看文件
@@ -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 = {};
+221
查看文件
@@ -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();
}
});