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 行删除
+220
查看文件
@@ -7,6 +7,7 @@ const os = require('os');
const path = require('path');
const { BotManager, kindOfPosition, computeReconnectDelay } = require('../src/bot/botManager');
const { FakeBot } = require('./helpers/fakeBot');
const { ChatLog, ChatKind } = require('../src/core/chatLog');
const { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig');
const { Logger } = require('../src/util/logger');
@@ -349,3 +350,222 @@ test('preLoginCommands 配置被归一化(去斜杠、去空项、转小写)
const { config } = setup({ config: { server: { preLoginCommands: ['/Login', ' AUTH ', '', ' ', 'reg'] } } });
assert.deepStrictEqual(config.server.preLoginCommands, ['login', 'auth', 'reg']);
});
// ==================== 没有密码 / 不需要密码的登录流程 ====================
test('配置了登录指令模板但没密码时,超时后按「无需登录」放行', async () => {
const { manager } = setup({ config: { server: { loginConfirmTimeoutMs: 20 } } });
manager.config.server.loginCommand = 'login {password}';
manager.connection.password = '';
manager.state = 'online';
manager.bot = { entity: {}, pathfinder: {} };
manager._handleLogin();
assert.strictEqual(manager.loggedIn, false, '初始仍视为未验证');
await new Promise((resolve) => setTimeout(resolve, 60));
assert.strictEqual(manager.loggedIn, true, '超时后必须放行,否则控制台完全无法操作');
assert.strictEqual(manager.canOperate(), true);
});
test('服务器不需要登录指令时直接视为已通过验证', () => {
const { manager } = setup();
manager.config.server.loginCommand = '';
manager._handleLogin();
assert.strictEqual(manager.loggedIn, true);
});
test('服务器要登录但没配密码时给出明确错误提示(只提示一次)', () => {
const { manager, logger } = setup();
manager.connection.password = '';
manager.loggedIn = false;
manager._handleAuthPrompt('请先输入登录密码');
manager._handleAuthPrompt('请先输入登录密码');
const errors = logger.list().filter((e) => e.level === 'error' && e.message.includes('没有该账号的密码'));
assert.strictEqual(errors.length, 1);
});
// ==================== 验证码是聊天内容而非指令 ====================
test('服务器要求验证时放行公屏聊天(验证码常直接当聊天发出)', () => {
const { manager, sent } = setup();
manager.state = 'online';
manager.loggedIn = false;
manager.bot = { chat: (text) => sent.push(text) };
assert.strictEqual(manager.canSendChat(), false, '没有验证要求时默认不放行');
manager._handleAuthPrompt('您需要绑定您的简幻通账号');
assert.strictEqual(manager.canSendChat(), true, '服务器要求验证后应放行');
manager.sendChat('123456', { internal: true });
manager._chatQueue.length = 0;
manager._writeChat('123456');
assert.deepStrictEqual(sent, ['123456']);
});
test('allowChatBeforeLogin 配置可无条件放行登录前聊天', () => {
const { manager } = setup({ config: { server: { allowChatBeforeLogin: true } } });
manager.state = 'online';
manager.loggedIn = false;
manager.bot = { chat: () => {} };
assert.strictEqual(manager.canSendChat(), true);
});
test('未连接时公屏聊天一律不放行', () => {
const { manager } = setup({ config: { server: { allowChatBeforeLogin: true } } });
manager.state = 'offline';
assert.strictEqual(manager.canSendChat(), false);
});
// ==================== 出站位置包的 onGround 纠正 ====================
/** 造一个站在实心方块上的假 bot */
function botStandingOnStone() {
return new FakeBot({
x: 0.5,
y: 72,
z: 0.5,
blockMap: new Map([['0,71,0', { name: 'stone', boundingBox: 'block' }]])
});
}
test('_isStandingOnGround 用脚下方块判断是否站在地面上', () => {
const { manager } = setup();
manager.bot = botStandingOnStone();
assert.strictEqual(manager._isStandingOnGround(), true);
manager.bot = new FakeBot({ x: 0.5, y: 72, z: 0.5 });
assert.strictEqual(manager._isStandingOnGround(), false, '脚下是空气时应为 false');
manager.bot = { entity: {} };
assert.strictEqual(manager._isStandingOnGround(), null, '拿不到世界数据时返回 null');
});
test('出站位置包的 onGround 被世界数据纠正(修复「站地上却报空中」)', () => {
const { manager } = setup();
manager.bot = botStandingOnStone();
const params = { flags: { onGround: false } };
manager._fixOutboundOnGround(params);
assert.strictEqual(params.flags.onGround, true, '站在石头上必须上报 onGround=true');
assert.strictEqual(manager.packetStats.onGroundFixed, 1);
});
test('onGround 已一致时不改写、不计数', () => {
const { manager } = setup();
manager.bot = botStandingOnStone();
const params = { flags: { onGround: true } };
manager._fixOutboundOnGround(params);
assert.strictEqual(manager.packetStats.onGroundFixed, 0);
});
test('关闭 reportOnGroundFromWorld 后完全不干预出站包', () => {
const { manager } = setup({ config: { grim: { reportOnGroundFromWorld: false } } });
manager.bot = botStandingOnStone();
const params = { flags: { onGround: false } };
manager._fixOutboundOnGround(params);
assert.strictEqual(params.flags.onGround, false);
assert.strictEqual(manager.packetStats.onGroundFixed, 0);
});
test('出站包结构异常时不抛异常', () => {
const { manager } = setup();
manager.bot = botStandingOnStone();
assert.doesNotThrow(() => manager._fixOutboundOnGround(null));
assert.doesNotThrow(() => manager._fixOutboundOnGround({}));
assert.doesNotThrow(() => manager._fixOutboundOnGround({ flags: {} }));
});
test('位置包统计暴露在 snapshot 中,便于排查走不动的问题', () => {
const { manager } = setup();
const snap = manager.snapshot();
assert.ok(snap.packetStats);
assert.strictEqual(snap.packetStats.inboundPosition, 0);
assert.strictEqual(snap.packetStats.outboundPosition, 0);
assert.strictEqual(snap.onGround, false);
assert.strictEqual(snap.world, null, '未进入世界时没有世界诊断信息');
});
// ==================== 手动移动测试 ====================
test('walkTest 走控制键而不是寻路器,能测出真实位移', async () => {
const { manager, sent } = setup();
const bot = botStandingOnStone();
manager.bot = bot;
manager.state = 'online';
const controls = [];
manager.grim = { setControlState: (c, on) => controls.push([c, on]) };
// 模拟按住前进键后位置发生变化
setTimeout(() => { bot.entity.position.z -= 8; }, 30);
const result = await manager.walkTest('forward', 0.5);
assert.strictEqual(result.ok, true);
assert.ok(result.message.includes('位移'));
assert.deepStrictEqual(controls, [['forward', true], ['forward', false]], '按下后必须松开');
assert.ok(Math.abs(result.to.z - result.from.z) > 0.5);
});
test('walkTest 在原地不动时返回失败并记警告', async () => {
const { manager, logger } = setup();
manager.bot = botStandingOnStone();
manager.state = 'online';
manager.grim = { setControlState: () => {} };
const result = await manager.walkTest('forward', 0.5);
assert.strictEqual(result.ok, false, '没有位移必须如实返回失败,而不是假装成功');
assert.ok(logger.list().some((e) => e.level === 'warn' && e.message.includes('几乎没有移动')));
});
test('walkTest 拒绝非法方向与未连接状态', async () => {
const { manager } = setup();
manager.state = 'online';
const bad = await manager.walkTest('teleport', 1);
assert.strictEqual(bad.ok, false);
assert.ok(bad.message.includes('方向'));
manager.state = 'offline';
const offline = await manager.walkTest('forward', 1);
assert.strictEqual(offline.ok, false);
assert.ok(offline.message.includes('未连接'));
});
// ==================== 插件加载 ====================
test('connect 时显式加载 mineflayer-pathfinder 插件', () => {
const mineflayer = require('mineflayer');
const originalCreateBot = mineflayer.createBot;
const loaded = [];
mineflayer.createBot = () => {
const fake = new (require('events').EventEmitter)();
fake.loadPlugin = (plugin) => loaded.push(plugin);
return fake;
};
try {
const { manager } = setup();
manager.bot = null; // setup 预置了 bot 桩,这里要真正走一次 connect 流程
manager.connect();
assert.strictEqual(loaded.length, 1, '必须调用 loadPlugin,否则 bot.pathfinder 永远为 undefined');
assert.ok(loaded[0], '加载的应是 pathfinder 插件');
// 插件加载后 bot.pathfinder 才会有值,isReady() 才可能为真
assert.strictEqual(manager.isReady(), false, '仅加载插件、未进入世界时仍不算就绪');
} finally {
mineflayer.createBot = originalCreateBot;
}
});
test('加载插件失败时不抛异常,但会记错误日志', () => {
const mineflayer = require('mineflayer');
const originalCreateBot = mineflayer.createBot;
mineflayer.createBot = () => {
const fake = new (require('events').EventEmitter)();
fake.loadPlugin = () => { throw new Error('插件加载炸了'); };
return fake;
};
try {
const { manager, logger } = setup();
manager.bot = null;
assert.doesNotThrow(() => manager.connect());
assert.ok(logger.list().some((e) => e.level === 'error' && e.message.includes('pathfinder')));
} finally {
mineflayer.createBot = originalCreateBot;
}
});
+119 -3
查看文件
@@ -7,7 +7,7 @@ const os = require('os');
const path = require('path');
const { BotPool } = require('../src/bot/botPool');
const { BotStore } = require('../src/config/botStore');
const { BotStore, BotCredentialStore } = require('../src/config/botStore');
const { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig');
const { Logger } = require('../src/util/logger');
const { TerritoryModel } = require('../src/core/territoryModel');
@@ -50,6 +50,7 @@ function setup(options) {
const opts = options || {};
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-pool-'));
const storePath = path.join(dir, 'bots.json');
const credPath = path.join(dir, 'bots.credentials.json');
if (opts.settings) fs.writeFileSync(storePath, JSON.stringify({ bots: opts.settings }), 'utf8');
const config = normalizeConfig(Object.assign(defaultConfig(), opts.config || {}));
const pool = new BotPool({
@@ -57,9 +58,10 @@ function setup(options) {
config,
logger: new Logger({ echo: false }),
dataService: fakeDataService(),
botStore: new BotStore(storePath)
botStore: new BotStore(storePath),
credentialStore: opts.noCredentialStore ? null : new BotCredentialStore(credPath)
});
return { pool, storePath, config, dir };
return { pool, storePath, credPath, config, dir };
}
test('BotPool 为每个凭据建立一个机器人条目', () => {
@@ -281,3 +283,117 @@ test('未启用(enabled=false)的机器人不参与连接', () => {
pool.start();
assert.deepStrictEqual(connected, ['BotA']);
});
// ==================== 控制台新增 / 移除玩家 ====================
test('addBot 新增玩家、落盘凭据,默认立即连接', () => {
const { pool, credPath } = setup();
const entry = pool.addBot({ username: 'NewBot', password: 'secret-pw', label: '新号' }, { connect: false });
assert.strictEqual(entry.id, 'NewBot');
assert.strictEqual(entry.source, 'store');
assert.strictEqual(pool.size, 3);
assert.strictEqual(entry.manager.label, '新号');
const saved = JSON.parse(fs.readFileSync(credPath, 'utf8'));
assert.strictEqual(saved.bots.NewBot.username, 'NewBot');
assert.strictEqual(saved.bots.NewBot.password, 'secret-pw');
});
test('addBot 在 enabled 时真的发起连接', () => {
const { pool } = setup();
const connected = [];
// addBot 内部会新建 BotManager,这里通过覆盖原型方法拦截真实网络连接
const originalConnect = Object.getPrototypeOf(pool.get('BotA').manager).connect;
Object.getPrototypeOf(pool.get('BotA').manager).connect = function stubConnect() {
connected.push(this.connection.username);
};
try {
pool.addBot({ username: 'NewBot', password: 'p1' });
assert.deepStrictEqual(connected, ['NewBot']);
} finally {
Object.getPrototypeOf(pool.get('BotA').manager).connect = originalConnect;
}
});
test('addBot 拒绝空用户名、非法用户名与重名', () => {
const { pool } = setup();
assert.throws(() => pool.addBot({ username: '' }), (err) => err.statusCode === 400);
assert.throws(() => pool.addBot({ username: '名字含中文且超长' }), (err) => err.statusCode === 400);
assert.throws(() => pool.addBot({ username: 'BotA' }), (err) => err.statusCode === 400);
assert.ok(pool.addBot({ username: 'ok_name' }, { connect: false }));
});
test('凭据文件里的账号在重启后自动恢复,且 .env 同名时以 .env 为准', () => {
const { pool, credPath, storePath, config } = setup();
pool.addBot({ username: 'NewBot', password: 'p1' }, { connect: false });
const rebuilt = new BotPool({
connections: [connection('BotA')],
config,
dataService: fakeDataService(),
botStore: new BotStore(storePath),
credentialStore: new BotCredentialStore(credPath)
});
assert.deepStrictEqual(rebuilt.ids(), ['BotA', 'NewBot']);
assert.strictEqual(rebuilt.get('NewBot').source, 'store');
// .env 里也有同名账号时,以 .env 为准(避免「改了 .env 不生效」)
const both = new BotPool({
connections: [connection('NewBot')],
config,
dataService: fakeDataService(),
credentialStore: new BotCredentialStore(credPath)
});
assert.strictEqual(both.size, 1);
assert.strictEqual(both.get('NewBot').source, 'env');
});
test('removeBot 移除控制台新增的玩家并删除凭据', () => {
const { pool, credPath } = setup();
pool.addBot({ username: 'NewBot', password: 'p1' }, { connect: false });
let disconnected = false;
pool.get('NewBot').manager.disconnect = () => { disconnected = true; };
pool.removeBot('NewBot');
assert.strictEqual(disconnected, true, '移除时应断开连接');
assert.strictEqual(pool.get('NewBot'), null);
assert.strictEqual(pool.size, 2);
assert.deepStrictEqual(JSON.parse(fs.readFileSync(credPath, 'utf8')).bots, {});
});
test('removeBot 拒绝移除 .env 里配置的账号并说明原因', () => {
const { pool } = setup();
assert.throws(() => pool.removeBot('BotA'), (err) => {
assert.strictEqual(err.statusCode, 400);
assert.ok(err.message.includes('.env'));
return true;
});
assert.strictEqual(pool.size, 2, '拒绝后不应改变列表');
});
test('未启用凭据存储时不允许在控制台新增玩家', () => {
const { pool } = setup({ noCredentialStore: true });
assert.throws(() => pool.addBot({ username: 'X' }, { connect: false }), (err) => err.statusCode === 400);
});
test('snapshot 标注账号来源,供前端决定能否移除', () => {
const { pool } = setup();
pool.addBot({ username: 'NewBot', password: 'p1' }, { connect: false });
const snap = pool.snapshot();
assert.strictEqual(snap.find((b) => b.id === 'BotA').source, 'env');
assert.strictEqual(snap.find((b) => b.id === 'NewBot').source, 'store');
});
test('凭据文件不含任何非密码字段之外的敏感内容,且权限被收紧', () => {
const { pool, credPath } = setup();
pool.addBot({ username: 'NewBot', password: 'p1' }, { connect: false });
const parsed = JSON.parse(fs.readFileSync(credPath, 'utf8'));
assert.deepStrictEqual(Object.keys(parsed), ['bots']);
assert.deepStrictEqual(Object.keys(parsed.bots.NewBot).sort(),
['auth', 'host', 'password', 'port', 'username', 'version']);
if (process.platform !== 'win32') {
const mode = fs.statSync(credPath).mode & 0o777;
assert.strictEqual(mode, 0o600, '含密码的文件必须是 0600');
}
});
+50 -2
查看文件
@@ -8,7 +8,7 @@ const path = require('path');
const { WebConsole } = require('../src/web/server');
const { BotPool } = require('../src/bot/botPool');
const { BotStore } = require('../src/config/botStore');
const { BotStore, BotCredentialStore } = require('../src/config/botStore');
const { ExpansionLog } = require('../src/core/expansionLog');
const { ChatLog, ChatKind } = require('../src/core/chatLog');
const { BigMapService } = require('../src/core/bigMapService');
@@ -81,7 +81,8 @@ function setup(options) {
dataService,
expansionLog,
chatLog,
botStore: new BotStore(path.join(dir, 'bots.json'))
botStore: new BotStore(path.join(dir, 'bots.json')),
credentialStore: new BotCredentialStore(path.join(dir, 'bots.credentials.json'))
});
const webConsole = new WebConsole({
@@ -387,6 +388,53 @@ test('HTTP:配置了控制台密码时未登录一律 401,登录后可访问
}
});
test('HTTP:可在控制台新增与移除玩家,响应中不含密码', async () => {
const { webConsole, pool } = setup();
// 避免测试真的发起网络连接
const proto = Object.getPrototypeOf(pool.get('BotA').manager);
const originalConnect = proto.connect;
proto.connect = function stub() {};
await webConsole.start();
const base = webConsole.baseUrl();
try {
const added = await fetch(base + '/api/bots', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'NewBot', password: 'super-secret', label: '新号' })
}).then((r) => r.json());
assert.strictEqual(added.ok, true);
assert.strictEqual(added.data.id, 'NewBot');
assert.ok(!JSON.stringify(added).includes('super-secret'), '响应中不得回显密码');
assert.deepStrictEqual(added.data.bots.map((b) => b.id), ['BotA', 'BotB', 'NewBot']);
assert.strictEqual(added.data.bots[2].source, 'store');
const duplicate = await fetch(base + '/api/bots', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'NewBot' })
});
assert.strictEqual(duplicate.status, 400);
const invalid = await fetch(base + '/api/bots', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: '带中文的名字' })
});
assert.strictEqual(invalid.status, 400);
// .env 里配置的账号不允许从控制台移除
const envBot = await fetch(base + '/api/bots/BotA', { method: 'DELETE' });
assert.strictEqual(envBot.status, 400);
const removed = await fetch(base + '/api/bots/NewBot', { method: 'DELETE' }).then((r) => r.json());
assert.strictEqual(removed.ok, true);
assert.deepStrictEqual(removed.data.bots.map((b) => b.id), ['BotA', 'BotB']);
} finally {
proto.connect = originalConnect;
await webConsole.stop();
}
});
test('HTTP:未配置密码时免登录(本机开发模式)', async () => {
const { webConsole } = setup();
await webConsole.start();