文件
BotStore/test/botPool.test.js
T
lyizai 42a9dd6cf0 feat: 新增机器人上/下线按钮,识别「账号已在别处登录」与「等待验证码」
下线按钮
- 顶栏新增「下线 / 上线」切换:下线会断开连接、停止自动重连,并停掉定时消息与
  扩地任务;需要在游戏客户端登同一账号时必须先下线,否则会被服务端以
  「您已经连接到此代理服务器了」反复踢掉
- 接口 POST /api/bots/:id/offline 与 /online;上线时会清掉「已在别处登录」标记
  并把重连计数归零,否则重连会一直退避到最大间隔

识别两类此前「看起来像启动不了」的情况
- 「您已经连接到此代理服务器了」:服务端踢人时发的是聊天组件对象,
  原来 JSON.stringify 之后没法看,关键信息被淹没。新增 extractKickReason()
  提取纯文本,命中后给出「请先在客户端退出」的可操作提示,
  并直接把重连退避到最大间隔,不再频繁硬撞服务器
- 「请直接输入验证码进行验证」:账号绑定过之后服务器仍可能每次登录都要求验证码,
  而原来的正则只认「完成验证 / 身份验证」,漏掉了这句话,用户只看到机器人卡在大厅。
  现在补上匹配,并在控制台弹出醒目提示 + 把输入框 placeholder 换成「输入验证码后回车发送」

测试 343 -> 355,全部通过。
2026-09-28 14:21:14 +08:00

459 行
18 KiB
JavaScript

'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { BotPool } = require('../src/bot/botPool');
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');
const chunkPos = require('../src/core/chunkPos');
/** 造一个只提供 getModel 的数据服务桩 */
function fakeDataService() {
const map = new Map([[
'A',
{ name: 'A', capital: { x: 0, z: 0 }, chunks: new Set([chunkPos.key(0, 0)]), color: 0x75bd20 }
]]);
const model = new TerritoryModel(map);
model.setSelfStateName('A');
return {
model,
getModel: () => model,
setSelectedStateName: (name) => model.setSelfStateName(name),
addSessionClaim: () => {},
refreshAsync: () => {},
detectStateByPlayer: async () => null,
snapshot: () => ({ stateCount: 1 })
};
}
function connection(username) {
return {
id: username,
username,
password: 'pw',
auth: 'offline',
host: 'mc.example.com',
port: 25565,
version: '1.21.4',
label: username
};
}
/** 建一个含 2 个机器人、使用临时 bots.json 的池 */
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({
connections: opts.connections || [connection('BotA'), connection('BotB')],
config,
logger: new Logger({ echo: false }),
dataService: fakeDataService(),
botStore: new BotStore(storePath),
credentialStore: opts.noCredentialStore ? null : new BotCredentialStore(credPath)
});
return { pool, storePath, credPath, config, dir };
}
test('BotPool 为每个凭据建立一个机器人条目', () => {
const { pool } = setup();
assert.strictEqual(pool.size, 2);
assert.deepStrictEqual(pool.ids(), ['BotA', 'BotB']);
assert.strictEqual(pool.defaultId, 'BotA');
assert.strictEqual(pool.get('BotA').manager.connection.username, 'BotA');
assert.strictEqual(pool.get().id, 'BotA', '不传 id 时返回默认机器人');
});
test('require 找不到机器人时抛出 404', () => {
const { pool } = setup();
assert.throws(() => pool.require('不存在'), (err) => err.statusCode === 404);
});
test('getConfigFor 未设置覆盖时等于全局配置', () => {
const { pool } = setup({ config: { strategy: 4, targetCount: 20 } });
const config = pool.getConfigFor('BotA');
assert.strictEqual(config.strategy, 4);
assert.strictEqual(config.targetCount, 20);
});
test('每个机器人可以有独立的配置覆盖,互不影响', () => {
const { pool } = setup({ config: { strategy: 1, targetCount: 10 } });
pool.setBotSetting('BotB', { config: { strategy: 2, targetCount: 3, stateName: 'B国' } });
const a = pool.getConfigFor('BotA');
const b = pool.getConfigFor('BotB');
assert.strictEqual(a.strategy, 1);
assert.strictEqual(a.targetCount, 10);
assert.strictEqual(b.strategy, 2);
assert.strictEqual(b.targetCount, 3);
assert.strictEqual(b.stateName, 'B国');
// 未覆盖的字段回退到全局
assert.strictEqual(b.waitTimeoutSeconds, a.waitTimeoutSeconds);
});
test('setBotSetting 持久化到 bots.json,重建池后仍生效', () => {
const { pool, storePath } = setup();
pool.setBotSetting('BotB', { enabled: false, autoStart: true, label: '二号机', config: { targetCount: 7 } });
const saved = JSON.parse(fs.readFileSync(storePath, 'utf8'));
assert.strictEqual(saved.bots.BotB.enabled, false);
assert.strictEqual(saved.bots.BotB.autoStart, true);
assert.strictEqual(saved.bots.BotB.label, '二号机');
const rebuilt = new BotPool({
connections: [connection('BotA'), connection('BotB')],
config: defaultConfig(),
dataService: fakeDataService(),
botStore: new BotStore(storePath)
});
assert.strictEqual(rebuilt.get('BotB').manager.label, '二号机');
assert.strictEqual(rebuilt.getConfigFor('BotB').targetCount, 7);
assert.strictEqual(rebuilt.settingSnapshot('BotB').enabled, false);
});
test('设置里绝不写入密码(密码只来自环境变量)', () => {
const { pool, storePath } = setup();
pool.setBotSetting('BotA', { label: '主号' });
const text = fs.readFileSync(storePath, 'utf8');
assert.ok(!text.includes('pw'), 'bots.json 中不得出现密码');
assert.ok(!/password/i.test(text), 'bots.json 中不得出现 password 字段');
});
test('snapshot 汇总各机器人的连接状态与任务状态', () => {
const { pool } = setup();
const snap = pool.snapshot();
assert.strictEqual(snap.length, 2);
assert.strictEqual(snap[0].id, 'BotA');
assert.strictEqual(snap[0].state, 'idle');
assert.strictEqual(snap[0].enabled, true);
assert.strictEqual(snap[0].task.state, 'IDLE');
assert.strictEqual(snap[0].task.botId, 'BotA');
});
test('positions 输出各机器人坐标供大地图打点', () => {
const { pool } = setup();
const positions = pool.positions();
assert.strictEqual(positions.length, 2);
assert.strictEqual(positions[0].id, 'BotA');
assert.strictEqual(positions[0].position, null, '未进入世界时没有坐标');
assert.strictEqual(positions[0].stateName, '');
});
test('未连接时拒绝发送指令与聊天,并说明是「未连接」', () => {
const { pool } = setup();
const command = pool.sendCommand('BotA', 'u claimmode');
assert.strictEqual(command.ok, false);
assert.ok(command.message.includes('未连接'), '离线时应明确说明未连接,而不是含糊的失败');
const chat = pool.sendChat('BotA', '大家好');
assert.strictEqual(chat.ok, false);
assert.ok(chat.message.includes('未连接'));
});
test('已连接但未完成登录验证时拒绝发送指令与聊天', () => {
const { pool } = setup();
const entry = pool.get('BotA');
entry.manager.bot = { chat: () => {} };
entry.manager.state = 'online';
entry.manager.loggedIn = false;
const command = pool.sendCommand('BotA', 'u claimmode');
assert.strictEqual(command.ok, false);
assert.ok(command.message.includes('未完成登录验证'));
const chat = pool.sendChat('BotA', '大家好');
assert.strictEqual(chat.ok, false);
assert.ok(chat.message.includes('未完成登录验证'));
});
test('未登录时验证类指令仍可下发(否则无法完成登录)', () => {
const { pool } = setup();
const entry = pool.get('BotA');
const sent = [];
entry.manager.bot = { chat: (text) => sent.push(text) };
entry.manager.state = 'online';
entry.manager.loggedIn = false;
const result = pool.sendCommand('BotA', '/auth 12345 6789');
assert.strictEqual(result.ok, true);
// 指令有节流,这里直接驱动写入验证内容
entry.manager._commandQueue.length = 0;
entry.manager._writeCommand('auth 12345 6789');
assert.deepStrictEqual(sent, ['/auth 12345 6789']);
});
test('空指令被拒绝', () => {
const { pool } = setup();
const result = pool.sendCommand('BotA', ' ');
assert.strictEqual(result.ok, false);
assert.ok(result.message.includes('指令为空'));
});
test('空聊天消息被拒绝', () => {
const { pool } = setup();
const result = pool.sendChat('BotA', ' ');
assert.strictEqual(result.ok, false);
assert.ok(result.message.includes('内容为空'));
});
test('聊天与指令共用同一入口:以 / 开头按指令发送', () => {
const { pool } = setup();
const entry = pool.get('BotA');
entry.manager.bot = { chat: () => {} };
entry.manager.state = 'online';
entry.manager.loggedIn = true;
const chat = pool.sendChat('BotA', '大家好');
assert.strictEqual(chat.kind, 'chat');
assert.ok(chat.message.includes('消息已发送'));
const command = pool.sendChat('BotA', '/u restore confirm');
assert.strictEqual(command.kind, 'command', '以 / 开头应识别为指令');
assert.ok(command.message.includes('指令已发送'));
});
test('完成登录验证后允许发送指令与聊天', () => {
const { pool } = setup();
const entry = pool.get('BotA');
const sent = [];
entry.manager.bot = { chat: (text) => sent.push(text) };
entry.manager.state = 'online';
entry.manager.loggedIn = true;
assert.strictEqual(entry.manager.canOperate(), true);
assert.strictEqual(pool.sendChat('BotA', '大家好').ok, true);
assert.strictEqual(pool.sendCommand('BotA', '/spawn').ok, true);
// 指令队列有节流,这里直接驱动写入验证内容
entry.manager._commandQueue.length = 0;
entry.manager._chatQueue.length = 0;
entry.manager.sendChat('直接发送', { internal: true });
entry.manager._chatQueue.length = 0;
entry.manager._writeChat('直接发送');
assert.deepStrictEqual(sent, ['直接发送']);
});
test('startTask 在机器人未就绪时返回失败且不抛异常', async () => {
const { pool } = setup();
const result = await pool.startTask('BotA');
assert.strictEqual(result.ok, false);
assert.ok(result.message.includes('尚未就绪'));
});
test('startTask 带配置覆盖时写入该机器人的专属配置', async () => {
const { pool } = setup({ config: { strategy: 1 } });
await pool.startTask('BotB', { strategy: 3, targetCount: 5 });
const config = pool.getConfigFor('BotB');
assert.strictEqual(config.strategy, 3);
assert.strictEqual(config.targetCount, 5);
// 另一个机器人不受影响
assert.strictEqual(pool.getConfigFor('BotA').strategy, 1);
});
test('applyGlobalConfig 下发到全部机器人并保留各自的覆盖', () => {
const { pool } = setup({ config: { strategy: 1, targetCount: 10 } });
pool.setBotSetting('BotB', { config: { targetCount: 3 } });
const next = normalizeConfig({ strategy: 4, targetCount: 99 }, pool.config);
pool.applyGlobalConfig(next);
assert.strictEqual(pool.get('BotA').manager.config.strategy, 4);
assert.strictEqual(pool.getConfigFor('BotA').targetCount, 99);
assert.strictEqual(pool.getConfigFor('BotB').targetCount, 3, '机器人专属覆盖优先于全局');
});
test('stop 会停止全部机器人的任务并断开连接', () => {
const { pool } = setup();
let disconnected = 0;
for (const entry of pool.entries.values()) {
entry.manager.disconnect = () => { disconnected++; };
}
pool.stop();
assert.strictEqual(disconnected, 2);
});
test('tick 驱动全部机器人且单个异常不影响其它', () => {
const { pool } = setup();
const ticks = [];
pool.get('BotA').controller.tick = () => { throw new Error('boom'); };
pool.get('BotB').controller.tick = () => { ticks.push('B'); };
assert.doesNotThrow(() => pool.tick());
assert.deepStrictEqual(ticks, ['B']);
});
// ==================== 上线 / 下线 ====================
test('disconnectBot 断线并停止自动重连、定时消息与扩地任务', () => {
const { pool } = setup();
const entry = pool.get('BotA');
let disconnected = false;
let taskStopped = false;
let messengerStopped = false;
entry.manager.disconnect = () => { disconnected = true; };
entry.controller.stop = () => { taskStopped = true; };
entry.messenger.stop = () => { messengerStopped = true; };
pool.disconnectBot('BotA');
assert.strictEqual(disconnected, true, '应断开连接');
assert.strictEqual(taskStopped, true, '应停掉扩地任务');
assert.strictEqual(messengerStopped, true, '应停掉定时消息');
});
test('connectBot 清掉「已在别处登录」标记并恢复连接', () => {
const { pool } = setup();
const entry = pool.get('BotA');
entry.manager.alreadyConnected = true;
entry.manager.reconnectAttempts = 5;
const connected = [];
entry.manager.connect = () => connected.push('BotA');
let messengerStarted = false;
entry.messenger.start = () => { messengerStarted = true; };
pool.connectBot('BotA');
assert.strictEqual(entry.manager.alreadyConnected, false, '不清掉标记会一直退避到最大间隔');
assert.strictEqual(entry.manager.reconnectAttempts, 0);
assert.strictEqual(messengerStarted, true);
assert.deepStrictEqual(connected, ['BotA']);
});
test('上线 / 下线对不存在的机器人抛 404', () => {
const { pool } = setup();
assert.throws(() => pool.disconnectBot('不存在'), (err) => err.statusCode === 404);
assert.throws(() => pool.connectBot('不存在'), (err) => err.statusCode === 404);
});
test('未启用(enabled=false)的机器人不参与连接', () => {
const { pool } = setup({ settings: { BotB: { enabled: false } } });
const connected = [];
for (const entry of pool.entries.values()) {
entry.manager.connect = () => { connected.push(entry.id); };
}
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');
}
});