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,全部通过。
这个提交包含在:
+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');
|
||||
}
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户