文件
BotStore/test/webServer.test.js
T
lyizai b2b0317af7 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 个用例(含三种故障判定的回归用例与控制台接口用例),全绿。
2026-09-30 21:05:38 +08:00

322 行
13 KiB
JavaScript

'use strict';
const test = require('node:test');
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');
test('flattenChunks 压平为 [x1,z1,x2,z2,...]', () => {
const keys = new Set([chunkPos.key(1, 2), chunkPos.key(-3, 4)]);
const flat = flattenChunks(keys);
assert.strictEqual(flat.length, 4);
const pairs = [];
for (let i = 0; i < flat.length; i += 2) pairs.push([flat[i], flat[i + 1]]);
pairs.sort((a, b) => a[0] - b[0]);
assert.deepStrictEqual(pairs, [[-3, 4], [1, 2]]);
});
test('boundsOf 计算外接矩形并对空集合返回 null', () => {
const keys = new Set([chunkPos.key(1, 2), chunkPos.key(-3, 4), chunkPos.key(5, -6)]);
assert.deepStrictEqual(boundsOf(keys), { minX: -3, maxX: 5, minZ: -6, maxZ: 4 });
assert.strictEqual(boundsOf(new Set()), null);
});
test('timingSafeEqual 只接受等长且完全相同的字符串', () => {
assert.strictEqual(timingSafeEqual('abc', 'abc'), true);
assert.strictEqual(timingSafeEqual('abc', 'abd'), false);
assert.strictEqual(timingSafeEqual('abc', 'abcd'), false);
assert.strictEqual(timingSafeEqual(undefined, 'abc'), false);
});
/** 搭建一个使用桩依赖的控制台实例 */
function setupConsole(options) {
const opts = options || {};
const map = new Map([[
'A',
{
name: 'A',
capital: { x: 0, z: 0 },
chunks: new Set([chunkPos.key(0, 0), chunkPos.key(1, 0)]),
color: 0x2f81f7
}
]]);
const model = new TerritoryModel(map);
model.setSelfStateName('A');
model.addSessionClaim({ x: 1, z: 0 });
const logger = new Logger({ echo: false });
// 抓包接口的桩:挂一个真的 PacketCapture + 假 client,接口测到的是真实逻辑
const captureClient = {
sent: [],
write(name, params) {
this.sent.push({ name, params });
}
};
const packetCapture = new PacketCapture();
packetCapture.setClient(captureClient);
// 物理录制接口的桩:同样挂真的 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),
startPhysicsRecorder: (seconds) => physicsRecorder.start(seconds),
stopPhysicsRecorder: () => physicsRecorder.stop(),
physicsSnapshot: (options) => physicsRecorder.snapshot(options)
};
const controller = {
target: { x: 2, z: 0 },
playerChunk: { x: 0, z: 0 },
snapshot: () => ({ state: 'PATHING', running: true }),
exportLogs: () => 'data/debug.log',
stop: () => {},
start: async () => true,
status: '测试中'
};
const dataService = {
getModel: () => model,
snapshot: () => ({ stateCount: 1, claimedChunks: 2 }),
refreshAsync: () => {},
fetchAllTerritoryOnce: async () => ({ ok: true, message: '已拉取 0 个区块' })
};
const config = defaultConfig();
const webConsole = new WebConsole({
botManager,
controller,
dataService,
logger,
bigMap: opts.bigMap,
notifier: opts.notifier,
getConfig: () => config,
applyConfig: (next) => next
}, { host: '127.0.0.1', port: 0 });
return { webConsole, model, logger, controller, captureClient, packetCapture, physicsBot, physicsRecorder };
}
test('mapPayload 返回己方 / 会话 / 飞地 / 目标与各邦国摘要', () => {
const { webConsole } = setupConsole();
const payload = webConsole.mapPayload(false);
assert.strictEqual(payload.selfState, 'A');
assert.strictEqual(payload.self.length, 4, '2 个区块 → 4 个数字');
assert.strictEqual(payload.session.length, 2);
assert.deepStrictEqual(payload.target, { x: 2, z: 0 });
assert.strictEqual(payload.states.length, 1);
assert.strictEqual(payload.states[0].name, 'A');
assert.strictEqual(payload.states[0].color, '#2f81f7');
assert.strictEqual(payload.states[0].count, 2);
assert.strictEqual(payload.otherChunks, undefined, '默认不返回全量他国区块');
});
test('mapPayload(full) 额外返回他国区块', () => {
const { webConsole, model } = setupConsole();
model.states.set('B', {
name: 'B',
capital: { x: 9, z: 9 },
chunks: new Set([chunkPos.key(9, 9)]),
color: 0xff43de
});
const payload = webConsole.mapPayload(true);
assert.strictEqual(payload.full, true);
assert.strictEqual(payload.otherChunks.length, 1);
assert.strictEqual(payload.otherChunks[0].name, 'B');
assert.strictEqual(payload.otherChunks[0].color, '#ff43de');
});
test('statusPayload 汇总 bot / 任务 / 数据源状态', () => {
const { webConsole } = setupConsole();
const payload = webConsole.statusPayload();
assert.strictEqual(payload.bot.state, 'online');
assert.strictEqual(payload.task.state, 'PATHING');
assert.strictEqual(payload.data.stateCount, 1);
assert.strictEqual(payload.claimTime.start, 11);
assert.strictEqual(payload.claimTime.end, 22);
assert.strictEqual(payload.notify, null, '未注入通知器时为 null(状态接口不应报错)');
});
test('注入通知器时状态接口只暴露域名,不泄露完整 Webhook 地址', () => {
const { Notifier } = require('../src/core/notifier');
const config = defaultConfig();
config.notify.enabled = true;
config.notify.webhookUrl = 'https://open.feishu.cn/open-apis/bot/v2/hook/secret-token';
const notifier = new Notifier(config, null, { fetchImpl: async () => ({ ok: true, status: 200 }) });
const { webConsole } = setupConsole({ notifier });
const payload = webConsole.statusPayload();
assert.strictEqual(payload.notify.enabled, true);
assert.strictEqual(payload.notify.host, 'open.feishu.cn');
assert.ok(!JSON.stringify(payload).includes('secret-token'), '完整地址不能出现在状态响应里');
});
test('HTTP 接口:/api/status、/api/config、/api/map 可访问', async () => {
const { webConsole } = setupConsole();
await webConsole.start();
const base = webConsole.baseUrl();
try {
const status = await fetch(base + '/api/status').then((r) => r.json());
assert.strictEqual(status.ok, true);
assert.strictEqual(status.data.bot.state, 'online');
const config = await fetch(base + '/api/config').then((r) => r.json());
assert.strictEqual(config.ok, true);
assert.strictEqual(config.data.strategy, 1);
const map = await fetch(base + '/api/map?full=1').then((r) => r.json());
assert.strictEqual(map.ok, true);
assert.strictEqual(map.data.full, true);
const html = await fetch(base + '/').then((r) => r.text());
assert.ok(html.includes('AutoStore Bot'), '首页应返回控制台 HTML');
} finally {
await webConsole.stop();
}
});
test('HTTP 接口:配置令牌后拒绝无令牌的 API 请求,但静态资源仍可加载', async () => {
const { webConsole } = setupConsole();
webConsole.token = 'secret-token';
await webConsole.start();
const base = webConsole.baseUrl();
try {
const unauthorized = await fetch(base + '/api/status');
assert.strictEqual(unauthorized.status, 401);
const authorized = await fetch(base + '/api/status?token=secret-token');
assert.strictEqual(authorized.status, 200);
const viaHeader = await fetch(base + '/api/status', { headers: { 'X-Auth-Token': 'secret-token' } });
assert.strictEqual(viaHeader.status, 200);
// 静态资源不带令牌也必须能加载,否则浏览器里控制台页面会缺样式 / 缺脚本
const html = await fetch(base + '/');
assert.strictEqual(html.status, 200);
const css = await fetch(base + '/style.css');
assert.strictEqual(css.status, 200);
const js = await fetch(base + '/app.js');
assert.strictEqual(js.status, 200);
} finally {
await webConsole.stop();
}
});
test('HTTP 接口:POST /api/config 会走 applyConfig 并返回生效配置', async () => {
const { webConsole } = setupConsole();
await webConsole.start();
const base = webConsole.baseUrl();
try {
const response = await fetch(base + '/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ strategy: 4, targetCount: 12 })
}).then((r) => r.json());
assert.strictEqual(response.ok, true);
assert.strictEqual(response.data.strategy, 4);
assert.strictEqual(response.data.targetCount, 12);
// 未提供的字段保持默认
assert.strictEqual(response.data.retries, 2);
} finally {
await webConsole.stop();
}
});
test('HTTP 接口:POST /api/command 缺参数时返回 400', async () => {
const { webConsole } = setupConsole();
await webConsole.start();
const base = webConsole.baseUrl();
try {
const response = await fetch(base + '/api/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
assert.strictEqual(response.status, 400);
} finally {
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();
}
});
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();
}
});