'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 { WebConsole } = require('../src/web/server'); const { BotPool } = require('../src/bot/botPool'); 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'); const { TerritoryModel } = require('../src/core/territoryModel'); const { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig'); const { Logger } = require('../src/util/logger'); const chunkPos = require('../src/core/chunkPos'); /** 造一个含 2 个机器人、完整依赖的控制台 */ function setup(options) { const opts = options || {}; const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-webmulti-')); const map = new Map([ ['A', { name: 'A', capital: { x: 1, z: 2 }, chunks: new Set([chunkPos.key(0, 0), chunkPos.key(1, 0)]), color: 0x2f81f7, polygons: [[0, 0, 16, 0, 16, 16, 0, 16]] }], ['B', { name: 'B', capital: { x: 9, z: 9 }, chunks: new Set([chunkPos.key(9, 9)]), color: 0xff43de, polygons: [] }] ]); const model = new TerritoryModel(map); model.setSelfStateName('A'); model.addSessionClaim({ x: 5, z: 5 }, 'A'); const dataService = { model, getModel: () => model, snapshot: () => ({ stateCount: 2, claimedChunks: 4, markersUrl: 'http://x/markers.json' }), refreshAsync: () => {}, fetchAllTerritoryOnce: async () => ({ ok: true, message: 'ok' }), detectStateByPlayer: async () => null, setSelectedStateName: (name) => model.setSelfStateName(name), addSessionClaim: (chunk, name) => model.addSessionClaim(chunk, name), applyApiConfig: () => {}, reschedule: () => {}, start: () => {}, stop: () => {} }; const config = normalizeConfig(Object.assign(defaultConfig(), opts.config || {})); const logger = new Logger({ echo: false }); const expansionLog = new ExpansionLog({ filePath: path.join(dir, 'expansion-log.jsonl') }); const chatLog = new ChatLog({ dir: path.join(dir, 'chat') }); const bigMap = new BigMapService(config, logger, { baseUrl: 'http://bgjq.simpfun.cn', world: 'minecraft_overworld', cacheDir: path.join(dir, 'mapcache'), fetchImpl: async () => ({ ok: true, status: 200, arrayBuffer: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer }) }); const pool = new BotPool({ connections: [ { id: 'BotA', username: 'BotA', password: 'pw', host: 'h', port: 25565, version: '1.21.4' }, { id: 'BotB', username: 'BotB', password: 'pw', host: 'h', port: 25565, version: '1.21.4' } ], config, logger, dataService, expansionLog, chatLog, botStore: new BotStore(path.join(dir, 'bots.json')), credentialStore: new BotCredentialStore(path.join(dir, 'bots.credentials.json')) }); const webConsole = new WebConsole({ pool, dataService, logger, expansionLog, chatLog, bigMap, getConfig: () => config, applyConfig: (next) => Object.assign(config, next) }, { host: '127.0.0.1', port: 0, exportDir: dir, password: opts.password }); return { webConsole, pool, model, chatLog, expansionLog, bigMap, dir, config, logger }; } test('statusPayload 汇总全部机器人与新增日志 / 大地图状态', () => { const { webConsole, pool } = setup(); const payload = webConsole.statusPayload(); assert.strictEqual(payload.bots.length, 2); assert.strictEqual(payload.bot.id, 'BotA', '单机器人兼容字段指向默认机器人'); assert.strictEqual(payload.task.state, 'IDLE'); assert.strictEqual(payload.defaultBotId, 'BotA'); assert.ok(payload.expansionLog); assert.ok(payload.chatLog); assert.ok(payload.bigMap); // 默认图幅 4096 格半径、层级 0 每张 4096 格 → 4 张瓦片(不再是把全世界拉下来) assert.strictEqual(payload.bigMap.tiles.count, 4); assert.strictEqual(payload.bigMap.followBot, true); assert.strictEqual(pool.size, 2); }); test('HTTP:/api/bots 返回机器人列表,/api/bots/:id/status 返回单个机器人', async () => { const { webConsole } = setup(); await webConsole.start(); const base = webConsole.baseUrl(); try { const bots = await fetch(base + '/api/bots').then((r) => r.json()); assert.strictEqual(bots.ok, true); assert.deepStrictEqual(bots.data.map((b) => b.id), ['BotA', 'BotB']); const single = await fetch(base + '/api/bots/BotB/status').then((r) => r.json()); assert.strictEqual(single.data.id, 'BotB'); assert.strictEqual(single.data.task.botId, 'BotB'); const missing = await fetch(base + '/api/bots/没有这个/status'); assert.strictEqual(missing.status, 404); } finally { await webConsole.stop(); } }); test('HTTP:机器人设置可读可写,且不会泄露密码', async () => { const { webConsole } = setup(); await webConsole.start(); const base = webConsole.baseUrl(); try { const saved = await fetch(base + '/api/bots/BotB/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label: '二号机', enabled: true, autoStart: false, config: { targetCount: 6 } }) }).then((r) => r.json()); assert.strictEqual(saved.ok, true); assert.strictEqual(saved.data.label, '二号机'); assert.strictEqual(saved.data.config.targetCount, 6); assert.ok(!JSON.stringify(saved.data).includes('pw'), '响应中不得含密码'); const read = await fetch(base + '/api/bots/BotB/settings').then((r) => r.json()); assert.strictEqual(read.data.label, '二号机'); } finally { await webConsole.stop(); } }); test('HTTP:机器人未就绪时启动任务返回 ok=false 并给出原因', async () => { const { webConsole } = setup(); await webConsole.start(); const base = webConsole.baseUrl(); try { const res = await fetch(base + '/api/bots/BotA/task/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }).then((r) => r.json()); assert.strictEqual(res.ok, false); assert.ok(res.message.includes('尚未就绪')); } finally { await webConsole.stop(); } }); test('HTTP:聊天接口在未完成登录验证时返回 409,缺参数返回 400', async () => { const { webConsole, pool } = setup(); await webConsole.start(); const base = webConsole.baseUrl(); try { const missing = await fetch(base + '/api/bots/BotA/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); assert.strictEqual(missing.status, 400); // 未连接:提示应指向「没连上」而不是含糊的失败 const offline = await fetch(base + '/api/bots/BotA/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: '大家好' }) }); assert.strictEqual(offline.status, 409); const offlineBody = await offline.json(); assert.ok(offlineBody.message.includes('未连接'), '离线时应说明未连接'); // 已连接但未通过登录验证:提示应指向登录门禁 const manager = pool.get('BotA').manager; manager.state = 'online'; manager.bot = { chat: () => {} }; manager.loggedIn = false; const denied = await fetch(base + '/api/bots/BotA/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: '大家好' }) }); assert.strictEqual(denied.status, 409); const body = await denied.json(); assert.ok(body.message.includes('未完成登录验证')); } finally { await webConsole.stop(); } }); test('HTTP:指令接口区分「未连接」与「未通过登录验证」,并放行验证类指令', async () => { const { webConsole, pool } = setup(); await webConsole.start(); const base = webConsole.baseUrl(); const post = (command) => fetch(base + '/api/bots/BotA/command', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command }) }); try { const offline = await post('u claimmode'); assert.strictEqual(offline.status, 409); assert.ok((await offline.json()).message.includes('未连接')); const manager = pool.get('BotA').manager; const sent = []; manager.bot = { chat: (text) => sent.push(text) }; manager.state = 'online'; manager.loggedIn = false; // 任务类指令仍被拦下 const blocked = await post('u claimmode'); assert.strictEqual(blocked.status, 409); assert.ok((await blocked.json()).message.includes('未完成登录验证')); // 验证类指令放行(否则无法完成登录,形成死锁) const allowed = await post('/auth 12345 6789'); assert.strictEqual(allowed.status, 200); assert.strictEqual((await allowed.json()).ok, true); } finally { await webConsole.stop(); } }); test('HTTP:公屏日志可按机器人查询与导出', async () => { const { webConsole, chatLog } = setup(); chatLog.append({ botId: 'BotA', bot: 'BotA', message: '你好', kind: ChatKind.CHAT, username: 'Steve' }); chatLog.append({ botId: 'BotB', bot: 'BotB', message: '别的机器人的消息' }); await webConsole.start(); const base = webConsole.baseUrl(); try { const rows = await fetch(base + '/api/bots/BotA/chat').then((r) => r.json()); assert.strictEqual(rows.data.length, 1); assert.strictEqual(rows.data[0].message, '你好'); const exported = await fetch(base + '/api/bots/BotA/chat/export', { method: 'POST' }).then((r) => r.json()); assert.strictEqual(exported.ok, true); assert.ok(fs.existsSync(exported.data.file)); } finally { await webConsole.stop(); } }); test('HTTP:开拓日志查询与 CSV 导出', async () => { const { webConsole, expansionLog } = setup(); expansionLog.record({ botId: 'BotA', bot: 'BotA', state: 'A', chunk: { x: 3, z: 4 }, block: { x: 48, z: 64 }, source: 'chat' }); expansionLog.record({ botId: 'BotB', bot: 'BotB', state: 'B', chunk: { x: 9, z: 9 } }); await webConsole.start(); const base = webConsole.baseUrl(); try { const all = await fetch(base + '/api/expansion-log').then((r) => r.json()); assert.strictEqual(all.data.entries.length, 2); assert.strictEqual(all.data.stats.total, 2); assert.strictEqual(all.data.stats.byBot.length, 2); const filtered = await fetch(base + '/api/expansion-log?botId=BotA').then((r) => r.json()); assert.strictEqual(filtered.data.entries.length, 1); assert.strictEqual(filtered.data.entries[0].cx, 3); const exported = await fetch(base + '/api/expansion-log/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) }).then((r) => r.json()); assert.strictEqual(exported.ok, true); const text = fs.readFileSync(exported.data.file, 'utf8'); assert.ok(text.includes('机器人'), 'CSV 应含表头'); assert.ok(text.includes('BotA')); } finally { await webConsole.stop(); } }); test('HTTP:大地图元信息、领土标记与瓦片代理', async () => { const { webConsole } = setup(); await webConsole.start(); const base = webConsole.baseUrl(); try { const meta = await fetch(base + '/api/bigmap').then((r) => r.json()); assert.strictEqual(meta.data.world, 'minecraft_overworld'); assert.strictEqual(meta.data.tilePixels, 512); assert.strictEqual(meta.data.tiles.count, 4); assert.strictEqual(meta.data.rangeBlocks, 4096); // 机器人还没进世界 → 中心回退到世界原点 assert.strictEqual(meta.data.center.source, 'origin'); assert.strictEqual(meta.data.bots.length, 2, '大地图应带上全部机器人坐标'); const markers = await fetch(base + '/api/bigmap/markers').then((r) => r.json()); const a = markers.data.states.find((s) => s.name === 'A'); assert.strictEqual(a.color, '#2f81f7'); assert.strictEqual(a.polygons.length, 1); assert.deepStrictEqual(a.polygons[0], [0, 0, 16, 0, 16, 16, 0, 16]); const tile = await fetch(base + '/api/bigmap/tile/0/0/0.png'); assert.strictEqual(tile.status, 200); assert.strictEqual(tile.headers.get('content-type'), 'image/png'); const badTile = await fetch(base + '/api/bigmap/tile/x/0/0.png'); assert.strictEqual(badTile.status, 400); } finally { await webConsole.stop(); } }); test('HTTP:地图数据可按机器人视角返回各自的邦国与目标', async () => { const { webConsole, pool } = setup(); pool.get('BotB').controller.stateName = 'B'; await webConsole.start(); const base = webConsole.baseUrl(); try { const a = await fetch(base + '/api/map?bot=BotA').then((r) => r.json()); assert.strictEqual(a.data.selfState, 'A'); // 邦国 A 原有 2 个区块 + 1 个会话占领区块 = 3 个区块 → 6 个数字 assert.strictEqual(a.data.self.length, 6); const b = await fetch(base + '/api/map?bot=BotB').then((r) => r.json()); assert.strictEqual(b.data.selfState, 'B'); assert.strictEqual(b.data.self.length, 2); } finally { await webConsole.stop(); } }); test('HTTP:配置了控制台密码时未登录一律 401,登录后可访问', async () => { const { webConsole } = setup({ password: 'console-pw' }); await webConsole.start(); const base = webConsole.baseUrl(); try { // 会话探测接口本身免鉴权,用于前端决定是否弹登录框 const session = await fetch(base + '/api/session').then((r) => r.json()); assert.strictEqual(session.data.required, true); assert.strictEqual(session.data.authenticated, false); const denied = await fetch(base + '/api/bots'); assert.strictEqual(denied.status, 401); const wrong = await fetch(base + '/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: '错的' }) }); assert.strictEqual(wrong.status, 401); const login = await fetch(base + '/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: 'console-pw' }) }).then((r) => r.json()); assert.strictEqual(login.ok, true); assert.strictEqual(login.data.authenticated, true); const allowed = await fetch(base + '/api/bots', { headers: { 'X-Auth-Token': 'console-pw' } }); assert.strictEqual(allowed.status, 200); // 静态页面本身仍需可加载,否则用户看不到登录框 const html = await fetch(base + '/'); assert.strictEqual(html.status, 200); } finally { await webConsole.stop(); } }); 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, pool } = setup(); await webConsole.start(); const base = webConsole.baseUrl(); try { const initial = await fetch(base + '/api/bots/BotA/schedules').then((r) => r.json()); assert.deepStrictEqual(initial.data, []); const saved = await fetch(base + '/api/bots/BotA/schedules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ schedules: [ { id: 'restore', label: '恢复民心', content: '/u restore confirm', intervalSeconds: 300 }, { content: '大家好', intervalSeconds: 600, enabled: false }, { content: ' ' } ] }) }).then((r) => r.json()); assert.strictEqual(saved.ok, true); assert.strictEqual(saved.data.length, 2, '空内容应被丢弃'); assert.strictEqual(saved.data[0].kind, 'command', '以 / 开头识别为指令'); assert.strictEqual(saved.data[1].kind, 'chat'); assert.strictEqual(saved.data[1].enabled, false); // 定时任务也应出现在状态负载里,前端据此渲染 const status = await fetch(base + '/api/status').then((r) => r.json()); const bot = status.data.bots.find((b) => b.id === 'BotA'); assert.strictEqual(bot.schedules.length, 2); assert.ok(bot.schedules[0].nextDueAt > 0); // 间隔过小会被钳制,防止刷屏 const clamped = await fetch(base + '/api/bots/BotA/schedules', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ schedules: [{ content: 'x', intervalSeconds: 1 }] }) }).then((r) => r.json()); assert.strictEqual(clamped.data[0].intervalSeconds, 5); // 保存定时任务不应清掉别的设置 assert.strictEqual(pool.settingSnapshot('BotA').enabled, true); } finally { await webConsole.stop(); } }); test('HTTP:大地图图幅可按机器人位置与半径计算瓦片范围', async () => { const { webConsole, pool } = setup(); // 给机器人一个真实坐标,验证中心会跟着它走 const manager = pool.get('BotB').manager; manager.bot = { entity: { position: { x: 18432, y: 72, z: 18432 } } }; manager.state = 'online'; await webConsole.start(); const base = webConsole.baseUrl(); try { const meta = await fetch(base + '/api/bigmap?rangeBlocks=1024&bot=BotB').then((r) => r.json()); assert.strictEqual(meta.data.center.source, 'bot'); assert.strictEqual(meta.data.center.x, 18432); assert.strictEqual(meta.data.rangeBlocks, 1024); assert.strictEqual(meta.data.tiles.count, 1, '1024 格半径且中心落在瓦片正中时只要 1 张瓦片'); assert.strictEqual(meta.data.tiles.minX, 4); // 换成大图幅后瓦片数随之增加 const wide = await fetch(base + '/api/bigmap?rangeBlocks=8192&bot=BotB').then((r) => r.json()); assert.ok(wide.data.tiles.count > meta.data.tiles.count); assert.strictEqual(wide.data.rangeBlocks, 8192); } finally { await webConsole.stop(); } }); test('HTTP:未配置密码时免登录(本机开发模式)', async () => { const { webConsole } = setup(); await webConsole.start(); const base = webConsole.baseUrl(); try { const session = await fetch(base + '/api/session').then((r) => r.json()); assert.strictEqual(session.data.required, false); assert.strictEqual(session.data.authenticated, true); const bots = await fetch(base + '/api/bots'); assert.strictEqual(bots.status, 200); } finally { await webConsole.stop(); } }); test('WebSocket:握手鉴权失败时被拒绝,成功后可收到状态推送', async () => { const { webConsole } = setup({ password: 'console-pw' }); await webConsole.start(); const base = webConsole.baseUrl(); const WebSocket = require('ws'); try { const rejected = await new Promise((resolve) => { const socket = new WebSocket(base.replace('http', 'ws') + '/ws'); socket.on('error', () => resolve('error')); socket.on('open', () => { socket.close(); resolve('open'); }); }); assert.strictEqual(rejected, 'error'); const payload = await new Promise((resolve, reject) => { const socket = new WebSocket(base.replace('http', 'ws') + '/ws?token=console-pw'); const timer = setTimeout(() => reject(new Error('未收到状态推送')), 3000); socket.on('message', (data) => { const parsed = JSON.parse(data.toString()); if (parsed.type === 'status') { clearTimeout(timer); socket.close(); resolve(parsed.data); } }); socket.on('error', reject); }); assert.strictEqual(payload.bots.length, 2); } finally { await webConsole.stop(); } });