diff --git a/CHANGELOG.md b/CHANGELOG.md index dceb6cc..040842a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 把「走不动」的排查手段补成可复用的诊断能力:能按需录下机器人**真正发出的协议包内容**, diff --git a/README.md b/README.md index 6af3005..83c70a4 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ npm start # 打开 http://127.0.0.1:3081 | **定时消息(间隔 / cron)** | ✅ 新增 | 固定间隔或 cron 表达式定时发送,可选「期望回复」确认发送是否真的生效 | | **人工介入提醒(Webhook)** | ✅ 新增 | 等待验证码 / 原点自救失败时推送到飞书等自定义机器人,不必一直盯控制台 | | **出站包抓取(诊断)** | ✅ 新增 | 按需录下机器人真正发出的协议包(含内容),用于与「能走」时的序列逐包对照,定位反作弊拒绝移动的原因 | +| **物理 tick 录制(诊断)** | ✅ 新增 | 逐 tick 记录物理引擎读到的输入、算出的速度、有无位移与碰撞,把「输入没进物理 / 被方块挡住 / 服务端回弹」三种原因切开 | --- diff --git a/TODO.md b/TODO.md index dea97ab..f4a032a 100644 --- a/TODO.md +++ b/TODO.md @@ -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}' ``` ### 判断「机器人是不是卡在登录服」 diff --git a/package-lock.json b/package-lock.json index 99b28db..44b177c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 78392c7..f37973d 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/bot/botManager.js b/src/bot/botManager.js index 00cb1a6..ce8c3d0 100644 --- a/src/bot/botManager.js +++ b/src/bot/botManager.js @@ -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); + } + /** * 手动移动测试:按住某个方向键若干秒,然后松开。 * diff --git a/src/bot/botPool.js b/src/bot/botPool.js index 696d76c..12f45d7 100644 --- a/src/bot/botPool.js +++ b/src/bot/botPool.js @@ -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); + } + // ==================== 状态快照 ==================== /** diff --git a/src/core/physicsRecorder.js b/src/core/physicsRecorder.js new file mode 100644 index 0000000..add1169 --- /dev/null +++ b/src/core/physicsRecorder.js @@ -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} 滚动缓冲:只保留最近 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 }; diff --git a/src/web/server.js b/src/web/server.js index 6cf666c..1b8c767 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -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); + } } /** diff --git a/test/helpers/fakeBot.js b/test/helpers/fakeBot.js index 04d7976..4d8474e 100644 --- a/test/helpers/fakeBot.js +++ b/test/helpers/fakeBot.js @@ -50,6 +50,16 @@ class FakeBot extends EventEmitter { }; /** @type {Record} */ 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 = {}; diff --git a/test/physicsRecorder.test.js b/test/physicsRecorder.test.js new file mode 100644 index 0000000..59fe741 --- /dev/null +++ b/test/physicsRecorder.test.js @@ -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); +}); diff --git a/test/webServer.test.js b/test/webServer.test.js index da30780..76cebe1 100644 --- a/test/webServer.test.js +++ b/test/webServer.test.js @@ -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(); + } +});