fix: 修复实机运行才暴露的 8 个缺陷,新增控制台添加玩家与移动测试

首次真正连上线上服务器(Debian 12 + pm2 + 端口 78)后暴露的问题,单元测试全绿但机器人干不了活:

致命
- 寻路插件从未加载:项目从没调用 bot.loadPlugin(pathfinder),bot.pathfinder 恒为
  undefined、isReady() 恒为 false,扩地任务连启动都启动不了,界面只显示含糊的
  「机器人尚未就绪」。现在 createBot 后立刻加载,失败时明确报错。

登录门禁相关(三处死锁)
- preLoginCommands 白名单:/login、/register、简幻通的 /auth 本身就是登录流程的一部分,
  原先被门禁一并拦下,形成「要先登录才能发登录指令」
- 验证码常被要求直接当聊天内容发出(不是指令),原先被 sendChat 的门禁挡住;
  现在服务器明确要求验证时自动放行,另有 server.allowChatBeforeLogin 兜底
- 配置了登录指令却没有密码时 loggedIn 永久为 false,控制台连手动指令都发不出;
  现在走与其他「服务器不回提示」一致的超时兜底并给出说明

可用性
- 离线时指令被静默丢弃却回报「已发送」;现在要求已连接并进入世界,失败信息区分
  「未连接」「登录被拒」「未通过验证」
- 「被服务器传送」日志洪水:实测 3 分钟 748 行,会冲光日志缓冲并打满 WebSocket;
  现在按 30 秒窗口聚合,断开时刷出未汇总计数
- 识别外站账号绑定流程并给出可操作告警,新增 server.authCommand 模板
- 重连指数退避(server.reconnectMaxDelayMs)
- 控制台瓦片 URL 与后端路由不一致

新增
- 控制台添加 / 移除玩家:不必改 .env 重启。密码只写入 data/bots.credentials.json
  (强制 0600),不进仓库、不出现在任何响应里;.env 与凭据文件同名时以 .env 为准,
  .env 里的账号不允许从控制台移除
- 手动移动测试接口与按钮:绕开寻路器直接按方向键,返回真实位移,
  用于区分「算不出路径」与「根本走不动」
- 位置同步诊断:位置包收发计数、onGround、游戏模式、维度、脚下方块与六向实心
- grim.reportOnGroundFromWorld:按世界数据修正出站 onGround(默认开启)
- scripts/check-protocol.js:核对包名与字段名同目标版本协议是否一致

文档
- README 新增「部署到服务器」与「机器人走不动的排查方法」两章

已知限制:实测本服上 mineflayer 客户端无法移动(按方向键 2 秒位移 0.00~0.02 格,
服务端每 tick 回弹一次),已排除区块数据、onGround、Grim 层、游戏模式、物理引擎、
寻路插件、teleport_confirm 协议字段等因素,彻底定位需与原生客户端逐字节对比移动包。
连接 / 登录 / 指令 / 聊天 / 公屏日志 / 大地图均正常。

测试从 263 增至 290,全部通过。
这个提交包含在:
lyizai
2026-09-27 23:06:39 +08:00
父节点 2671da95d5
当前提交 abecdd2648
共修改 15 个文件,包含 1243 行新增和 34 行删除
+67 -2
查看文件
@@ -247,6 +247,50 @@ class WebConsole {
res.json({ ok: true, data: this.botsPayload() });
});
// 控制台新增玩家:密码只落到服务器本地凭据文件(0600),不会出现在任何响应里
app.post('/api/bots', (req, res) => {
if (typeof this.pool.addBot !== 'function') {
res.status(400).json({ ok: false, message: '当前运行模式不支持在控制台新增玩家' });
return;
}
try {
const body = req.body || {};
const entry = this.pool.addBot({
username: body.username,
password: body.password,
label: body.label,
auth: body.auth,
host: body.host,
port: body.port,
version: body.version
});
res.json({
ok: true,
message: `已添加玩家 ${entry.connection.username}`,
data: { id: entry.id, username: entry.connection.username, bots: this.botsPayload() }
});
} catch (err) {
this._error(res, err);
}
});
app.delete('/api/bots/:id', (req, res) => {
if (typeof this.pool.removeBot !== 'function') {
res.status(400).json({ ok: false, message: '当前运行模式不支持在控制台移除玩家' });
return;
}
try {
const entry = this.pool.removeBot(req.params.id);
res.json({
ok: true,
message: `已移除玩家 ${entry.connection.username}`,
data: { bots: this.botsPayload() }
});
} catch (err) {
this._error(res, err);
}
});
app.get('/api/bots/:id/status', (req, res) => {
this._withEntry(req, res, (entry) => ({
ok: true,
@@ -302,6 +346,22 @@ class WebConsole {
});
});
// 手动移动测试:绕开寻路器直接按方向键,用于判断「算不出路径」还是「根本走不动」
app.post('/api/bots/:id/move', async (req, res) => {
const body = req.body || {};
if (typeof this.pool.walkTest !== 'function') {
res.status(400).json({ ok: false, message: '当前运行模式不支持移动测试' });
return;
}
try {
const entry = this.pool.require(req.params.id);
const result = await this.pool.walkTest(entry.id, body.direction, body.seconds);
res.status(result.ok ? 200 : 409).json(result);
} catch (err) {
this._error(res, err);
}
});
// 控制台 / 聊天框操作:都要求该机器人已完成游戏内登录验证
app.post('/api/bots/:id/command', (req, res) => {
const command = req.body && req.body.command;
@@ -828,8 +888,13 @@ class SingleBotPool {
/** @param {string} id @param {string} message */
sendChat(id, message) {
const entry = this.require(id);
const ok = entry.manager.sendChat(String(message || '').trim());
return { ok, message: ok ? '已发送' : '发送失败(未完成登录验证)' };
const text = String(message || '').trim();
if (!text) return { ok: false, message: '消息为空' };
if (!entry.manager.canSendChat()) {
return { ok: false, message: '未完成登录验证,已拒绝发送公屏消息' };
}
const ok = entry.manager.sendChat(text);
return { ok, message: ok ? '已发送' : '发送失败' };
}
}