'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'); 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']); });