继续修复部署时暴露的门禁设计问题: - 登录门禁死锁:/login、/register、简幻通的 /auth 本身就是登录流程的一部分, 原先门禁把它们一并拦下,形成「要先登录才能发登录指令」,账号永远卡在未验证状态。 新增 server.preLoginCommands 白名单(默认 login,l,register,reg,auth), 这些前缀在未验证时也放行,其余指令仍要求已完成验证。 - 离线时指令被静默丢弃却回报「已发送」:bot.chat() 在未连接时无处可发。 canSendCommand() 现在要求已连接并进入世界,失败信息区分 「未连接」「登录被服务器拒绝」「未通过登录验证」三种情况并给出对应建议。 - 前端配置表单支持 list 类型字段(逗号分隔的字符串数组)。 测试从 252 增至 263,全部通过。
284 行
10 KiB
JavaScript
284 行
10 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 } = 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');
|
|
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)
|
|
});
|
|
return { pool, storePath, 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');
|
|
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('未启用(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']);
|
|
});
|