feat: 支持多机器人挂机、开拓日志、公屏完整日志与大地图
多机器人(src/bot/botPool.js、src/config/botsConfig.js) - .env 用 MC_BOTS 一次配置多个账号(JSON 数组或 用户名:密码 逗号分隔),未配置时回退单机器人 - 每个机器人独立连接 / 登录 / 扩地状态机,共享一份领土数据服务与运行日志 - 密码只从 .env 读取;data/bots.json 只存启用状态、备注名、自动开始与各自的配置覆盖 - 每个机器人可覆盖全局配置(策略 / 目标邦国 / 目标数量等),未覆盖字段回退全局默认 开拓日志(src/core/expansionLog.js) - 逐条记录开拓玩家、邦国、区块、方块坐标、时间与判定来源,追加写入 data/expansion-log.jsonl - 支持按机器人 / 邦国 / 时间筛选、统计、导出带 BOM 的 CSV,超过 16MB 自动轮转 游戏公屏完整日志与聊天框(src/core/chatLog.js) - 每个机器人的全部公屏 / 系统 / 动作栏消息按机器人分文件留档,自身发出的聊天与指令同样留档 - 控制台新增公屏聊天页签与消息发送框 登录验证门禁 - 游戏侧:未完成登录验证时拒绝发送指令 / 聊天,isReady() 也不放行扩地任务; 新增 server.loginConfirmTimeoutMs 兜底,避免服务器不回提示导致永远卡在未登录 - 控制台侧:新增 WEB_PASSWORD 与 /api/login、/api/session,页面登录框验证通过才允许操作 大地图(src/core/bigMapService.js) - 爬取服务器地图站点(squaremap)的瓦片图片与领土标记,一张图上看全部机器人坐标 - 坐标系对源站实测标定并用 27 个王城交叉验证;瓦片磁盘缓存,层级 0 启动预爬取、更高层级按需穿透 - 领土按 markers.json 原始多边形绘制(预编译 Path2D,even-odd 保留孔洞),叠加王城与机器人标签 配套改造 - TerritoryModel 新增 flyingChunksOf / ownChunksOf,邦国归属改为按名称显式传入, 不再依赖全局 selfStateName(多机器人会互相覆盖) - Web 接口以 /api/bots/:id/... 为主,原单机器人接口保留为默认机器人的等价别名 - 新增 scripts/check-web-ids.js 校验前端元素引用一致性 测试从 154 增至 242,全部通过。
这个提交包含在:
@@ -0,0 +1,151 @@
|
||||
'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 { BigMapService, TILE_PIXELS, DEFAULT_MAX_ZOOM } = require('../src/core/bigMapService');
|
||||
const { defaultConfig } = require('../src/config/expansionConfig');
|
||||
|
||||
/** 造一张假的 PNG 内容(只需非空即可,服务端只做字节透传) */
|
||||
function fakePng(size) {
|
||||
const buffer = Buffer.alloc(size || 64);
|
||||
buffer[0] = 0x89;
|
||||
buffer[1] = 0x50;
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** 建一个使用临时缓存目录、带假 fetch 的大地图服务 */
|
||||
function setup(options) {
|
||||
const opts = options || {};
|
||||
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-bigmap-'));
|
||||
const config = defaultConfig();
|
||||
Object.assign(config.bigMap, opts.bigMap || {});
|
||||
const calls = [];
|
||||
const fetchImpl = opts.fetchImpl || (async (url) => {
|
||||
calls.push(url);
|
||||
if (opts.failAll) return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
|
||||
const data = fakePng(opts.tileSize || 64);
|
||||
return { ok: true, status: 200, arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.length) };
|
||||
});
|
||||
const service = new BigMapService(config, null, {
|
||||
baseUrl: 'http://bgjq.simpfun.cn',
|
||||
world: 'minecraft_overworld',
|
||||
cacheDir,
|
||||
fetchImpl
|
||||
});
|
||||
return { service, config, cacheDir, calls };
|
||||
}
|
||||
|
||||
test('坐标系:最大层级 1 方块 = 1 像素,逐级翻倍', () => {
|
||||
const { service } = setup();
|
||||
assert.strictEqual(service.maxZoom, DEFAULT_MAX_ZOOM);
|
||||
assert.strictEqual(service.blocksPerTile(3), TILE_PIXELS, '最大层级每张瓦片覆盖 512 方块');
|
||||
assert.strictEqual(service.blocksPerTile(2), TILE_PIXELS * 2);
|
||||
assert.strictEqual(service.blocksPerTile(1), TILE_PIXELS * 4);
|
||||
assert.strictEqual(service.blocksPerTile(0), TILE_PIXELS * 8, '层级 0 每张瓦片覆盖 4096 方块');
|
||||
});
|
||||
|
||||
test('坐标系:王城 (10722, 1547) 落在 z=3 的瓦片 (20, 3) 内(对源站实测标定)', () => {
|
||||
const { service } = setup();
|
||||
const bounds = service.tileBlockBounds(3, 20, 3);
|
||||
assert.deepStrictEqual(bounds, { minBlockX: 10240, maxBlockX: 10752, minBlockZ: 1536, maxBlockZ: 2048 });
|
||||
assert.ok(10722 >= bounds.minBlockX && 10722 < bounds.maxBlockX);
|
||||
assert.ok(1547 >= bounds.minBlockZ && 1547 < bounds.maxBlockZ);
|
||||
});
|
||||
|
||||
test('tileRange 覆盖 ±rangeBlocks:层级 0 全世界为 10×10=100 张', () => {
|
||||
const { service } = setup();
|
||||
const range = service.tileRange(0, 20480);
|
||||
assert.deepStrictEqual(range, { minX: -5, maxX: 4, minZ: -5, maxZ: 4, count: 100 });
|
||||
// 瓦片范围必须完整覆盖 -20480..20480
|
||||
assert.strictEqual(range.minX * service.blocksPerTile(0), -20480);
|
||||
assert.strictEqual((range.maxX + 1) * service.blocksPerTile(0), 20480);
|
||||
});
|
||||
|
||||
test('tileUrl / tilePath 拼接源站地址与磁盘缓存路径', () => {
|
||||
const { service } = setup();
|
||||
assert.strictEqual(
|
||||
service.tileUrl(2, -3, 7),
|
||||
'http://bgjq.simpfun.cn/tiles/minecraft_overworld/2/-3_7.png'
|
||||
);
|
||||
assert.ok(service.tilePath(2, -3, 7).endsWith(path.join('minecraft_overworld', '2', '-3_7.png')));
|
||||
});
|
||||
|
||||
test('getTile 首次回源并落盘,第二次命中磁盘不再回源', async () => {
|
||||
const { service, calls } = setup();
|
||||
const first = await service.getTile(1, 2, 3);
|
||||
assert.ok(first && first.length > 0);
|
||||
assert.strictEqual(calls.length, 1);
|
||||
|
||||
// 清掉内存缓存,验证磁盘缓存生效
|
||||
service._memory.clear();
|
||||
const second = await service.getTile(1, 2, 3);
|
||||
assert.ok(second && second.length > 0);
|
||||
assert.strictEqual(calls.length, 1, '第二次不应再回源');
|
||||
});
|
||||
|
||||
test('getTile 回源失败返回 null(控制台画占位色块)', async () => {
|
||||
const { service } = setup({ failAll: true });
|
||||
assert.strictEqual(await service.getTile(0, 0, 0), null);
|
||||
});
|
||||
|
||||
test('crawlAll 批量爬取范围内全部瓦片并统计进度', async () => {
|
||||
const { service, calls } = setup({ bigMap: { rangeBlocks: 4096 } });
|
||||
const result = await service.crawlAll(0);
|
||||
// rangeBlocks=4096、层级 0 每张 4096 方块 → 2×2=4 张
|
||||
assert.strictEqual(result.total, 4);
|
||||
assert.strictEqual(result.done, 4);
|
||||
assert.strictEqual(result.failed, 0);
|
||||
assert.strictEqual(result.running, false);
|
||||
assert.strictEqual(calls.length, 4);
|
||||
assert.strictEqual(service.countCached(0), 4);
|
||||
});
|
||||
|
||||
test('crawlAll 已缓存的瓦片不重复下载', async () => {
|
||||
const { service, calls } = setup({ bigMap: { rangeBlocks: 4096 } });
|
||||
await service.crawlAll(0);
|
||||
assert.strictEqual(calls.length, 4);
|
||||
await service.crawlAll(0);
|
||||
assert.strictEqual(calls.length, 4, '第二次爬取应全部命中缓存');
|
||||
});
|
||||
|
||||
test('crawlAll 统计失败张数', async () => {
|
||||
const { service } = setup({ failAll: true, bigMap: { rangeBlocks: 4096 } });
|
||||
const result = await service.crawlAll(0);
|
||||
assert.strictEqual(result.total, 4);
|
||||
assert.strictEqual(result.done, 0);
|
||||
assert.strictEqual(result.failed, 4);
|
||||
});
|
||||
|
||||
test('snapshot 暴露坐标系、瓦片范围与缓存数量', () => {
|
||||
const { service } = setup({ bigMap: { rangeBlocks: 20480, zoom: 0 } });
|
||||
const snap = service.snapshot();
|
||||
assert.strictEqual(snap.world, 'minecraft_overworld');
|
||||
assert.strictEqual(snap.tilePixels, TILE_PIXELS);
|
||||
assert.strictEqual(snap.blocksPerTile, 4096);
|
||||
assert.strictEqual(snap.tiles.count, 100);
|
||||
assert.strictEqual(snap.cached, 0);
|
||||
assert.ok(snap.source.tileUrlTemplate.includes('{z}/{x}_{y}.png'));
|
||||
});
|
||||
|
||||
test('clearCache 清空磁盘与内存缓存', async () => {
|
||||
const { service } = setup({ bigMap: { rangeBlocks: 4096 } });
|
||||
await service.crawlAll(0);
|
||||
assert.strictEqual(service.countCached(0), 4);
|
||||
const removed = service.clearCache();
|
||||
assert.strictEqual(removed, 4);
|
||||
assert.strictEqual(service.countCached(0), 0);
|
||||
assert.strictEqual(service._memory.size, 0);
|
||||
});
|
||||
|
||||
test('配置里 zoom 大于 maxZoom 时被钳制(避免爬到源站不存在的瓦片)', () => {
|
||||
const { service, config } = setup({ bigMap: { zoom: 7, maxZoom: 3 } });
|
||||
// 直接走一次配置归一化,验证钳制逻辑
|
||||
const { normalizeConfig } = require('../src/config/expansionConfig');
|
||||
const normalized = normalizeConfig(config);
|
||||
assert.strictEqual(normalized.bigMap.zoom, 3);
|
||||
assert.strictEqual(service.maxZoom, 3);
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
'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 { BotManager, kindOfPosition } = require('../src/bot/botManager');
|
||||
const { ChatLog, ChatKind } = require('../src/core/chatLog');
|
||||
const { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig');
|
||||
const { Logger } = require('../src/util/logger');
|
||||
|
||||
/** 建一个不连接的机器人管理器(含公屏日志) */
|
||||
function setup(options) {
|
||||
const opts = options || {};
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-botmgr-'));
|
||||
const chatLog = new ChatLog({ dir });
|
||||
const config = normalizeConfig(Object.assign(defaultConfig(), opts.config || {}));
|
||||
if (opts.config && opts.config.server) {
|
||||
Object.assign(config.server, opts.config.server);
|
||||
}
|
||||
const manager = new BotManager({
|
||||
id: 'BotA',
|
||||
username: 'BotA',
|
||||
password: 'pw',
|
||||
host: 'mc.example.com',
|
||||
port: 25565,
|
||||
version: '1.21.4',
|
||||
label: '主号'
|
||||
}, config, new Logger({ echo: false }), { chatLog });
|
||||
const sent = [];
|
||||
manager.bot = { chat: (text) => sent.push(text) };
|
||||
return { manager, chatLog, config, sent };
|
||||
}
|
||||
|
||||
/** 等一轮宏任务,让指令节流定时器真正把内容写出 */
|
||||
function drainQueue() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
test('kindOfPosition 把消息通道映射为公屏日志类型', () => {
|
||||
assert.strictEqual(kindOfPosition('chat'), ChatKind.CHAT);
|
||||
assert.strictEqual(kindOfPosition('system'), ChatKind.SYSTEM);
|
||||
assert.strictEqual(kindOfPosition('game_info'), ChatKind.ACTION);
|
||||
assert.strictEqual(kindOfPosition(undefined), ChatKind.CHAT);
|
||||
});
|
||||
|
||||
test('isReady 要求已连接、已进入世界、寻路器就绪且完成登录验证', () => {
|
||||
const { manager } = setup();
|
||||
manager.state = 'online';
|
||||
manager.bot = { entity: {}, pathfinder: {} };
|
||||
manager.loggedIn = false;
|
||||
assert.strictEqual(manager.isReady(), false, '未完成登录验证不算就绪');
|
||||
|
||||
manager.loggedIn = true;
|
||||
assert.strictEqual(manager.isReady(), true);
|
||||
|
||||
manager.state = 'connecting';
|
||||
assert.strictEqual(manager.isReady(), false);
|
||||
});
|
||||
|
||||
test('未完成登录验证时拒绝发送指令与聊天', () => {
|
||||
const { manager, sent } = setup();
|
||||
manager.state = 'online';
|
||||
manager.loggedIn = false;
|
||||
assert.strictEqual(manager.canOperate(), false);
|
||||
assert.strictEqual(manager.sendCommand('u claimmode'), false);
|
||||
assert.strictEqual(manager.sendChat('大家好'), false);
|
||||
assert.deepStrictEqual(sent, [], '不应真的写出任何内容');
|
||||
});
|
||||
|
||||
test('登录验证通过后指令与聊天都会被写出并留档', () => {
|
||||
const { manager, chatLog, sent } = setup({ config: { server: { commandCooldownMs: 0 } } });
|
||||
manager.state = 'online';
|
||||
manager.loggedIn = true;
|
||||
|
||||
manager.sendCommand('u claimmode', { immediate: true });
|
||||
manager.sendChat('大家好', { internal: true });
|
||||
manager._chatQueue.length = 0;
|
||||
manager._writeChat('大家好');
|
||||
|
||||
assert.deepStrictEqual(sent, ['/u claimmode', '大家好']);
|
||||
const rows = chatLog.list('BotA');
|
||||
assert.strictEqual(rows.length, 2);
|
||||
assert.strictEqual(rows[0].kind, ChatKind.COMMAND);
|
||||
assert.strictEqual(rows[0].message, '/u claimmode');
|
||||
assert.strictEqual(rows[1].kind, ChatKind.OUT);
|
||||
assert.strictEqual(rows[1].message, '大家好');
|
||||
});
|
||||
|
||||
test('以斜杠开头的聊天内容被当作指令发送', () => {
|
||||
const { manager, sent } = setup();
|
||||
manager.state = 'online';
|
||||
manager.loggedIn = true;
|
||||
manager.sendChat('/spawn', { internal: true });
|
||||
manager._chatQueue.length = 0;
|
||||
// 指令走队列,直接驱动写入
|
||||
manager._writeCommand(manager._commandQueue.pop() || 'spawn');
|
||||
assert.deepStrictEqual(sent, ['/spawn']);
|
||||
});
|
||||
|
||||
test('internal 选项绕过登录门禁(登录 / 注册流程自身使用)', () => {
|
||||
const { manager, sent } = setup();
|
||||
manager.state = 'online';
|
||||
manager.loggedIn = false;
|
||||
assert.strictEqual(manager.sendCommand('login pw', { internal: true, immediate: true }), true);
|
||||
assert.deepStrictEqual(sent, ['/login pw']);
|
||||
});
|
||||
|
||||
test('收到「登录成功」提示后置为已登录', () => {
|
||||
const { manager } = setup();
|
||||
manager.config.server.loginCommand = 'login {password}';
|
||||
manager.loggedIn = false;
|
||||
manager._handleAuthPrompt('登录成功,欢迎回来');
|
||||
assert.strictEqual(manager.loggedIn, true);
|
||||
assert.strictEqual(manager.loginFailed, false);
|
||||
});
|
||||
|
||||
test('收到「已登录」提示同样视为通过验证', () => {
|
||||
const { manager } = setup();
|
||||
manager.loggedIn = false;
|
||||
manager._handleAuthPrompt('你已登录,无需重复登录');
|
||||
assert.strictEqual(manager.loggedIn, true);
|
||||
});
|
||||
|
||||
test('收到密码错误提示时标记登录失败且不放行操作', () => {
|
||||
const { manager } = setup();
|
||||
manager.loggedIn = false;
|
||||
manager._handleAuthPrompt('密码错误,请重新输入');
|
||||
assert.strictEqual(manager.loginFailed, true);
|
||||
assert.strictEqual(manager.loggedIn, false);
|
||||
assert.strictEqual(manager.canOperate(), false);
|
||||
});
|
||||
|
||||
test('服务器提示注册时自动发送注册指令(绕过门禁)', async () => {
|
||||
const { manager, sent } = setup({ config: { server: { commandCooldownMs: 0 } } });
|
||||
manager.config.server.registerCommand = 'register {password}';
|
||||
manager.config.server.loginCommand = 'login {password}';
|
||||
manager.loggedIn = false;
|
||||
manager._handleAuthPrompt('请先注册');
|
||||
await drainQueue();
|
||||
assert.deepStrictEqual(sent, ['/register pw']);
|
||||
});
|
||||
|
||||
test('服务器提示登录时自动发送登录指令(绕过门禁)', async () => {
|
||||
const { manager, sent } = setup({ config: { server: { commandCooldownMs: 0 } } });
|
||||
manager.config.server.loginCommand = 'login {password}';
|
||||
manager.loggedIn = false;
|
||||
manager._handleAuthPrompt('请先输入登录密码');
|
||||
await drainQueue();
|
||||
assert.deepStrictEqual(sent, ['/login pw']);
|
||||
});
|
||||
|
||||
test('登录确认兜底:超时未收到任何提示时按已登录放行', async () => {
|
||||
const { manager } = setup({ config: { server: { loginConfirmTimeoutMs: 20 } } });
|
||||
manager.loggedIn = false;
|
||||
manager.loginFailed = false;
|
||||
manager._armLoginConfirmFallback();
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
assert.strictEqual(manager.loggedIn, true);
|
||||
});
|
||||
|
||||
test('登录确认兜底不会覆盖已明确失败的登录', async () => {
|
||||
const { manager } = setup({ config: { server: { loginConfirmTimeoutMs: 20 } } });
|
||||
manager.loggedIn = false;
|
||||
manager._armLoginConfirmFallback();
|
||||
manager.loginFailed = true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
assert.strictEqual(manager.loggedIn, false);
|
||||
});
|
||||
|
||||
test('收到消息时写入公屏日志(含系统消息与发言者)', () => {
|
||||
const { manager, chatLog } = setup();
|
||||
manager._recordChat('服务器将在 5 分钟后重启', 'system', null);
|
||||
manager._recordChat('你好', 'chat', 'Steve');
|
||||
const rows = chatLog.list('BotA');
|
||||
assert.strictEqual(rows.length, 2);
|
||||
assert.strictEqual(rows[0].kind, ChatKind.SYSTEM);
|
||||
assert.strictEqual(rows[1].kind, ChatKind.CHAT);
|
||||
assert.strictEqual(rows[1].username, 'Steve');
|
||||
});
|
||||
|
||||
test('snapshot 暴露多机器人需要的 id / 备注 / 登录与操作许可', () => {
|
||||
const { manager } = setup();
|
||||
const snap = manager.snapshot();
|
||||
assert.strictEqual(snap.id, 'BotA');
|
||||
assert.strictEqual(snap.label, '主号');
|
||||
assert.strictEqual(snap.username, 'BotA');
|
||||
assert.strictEqual(snap.loggedIn, false);
|
||||
assert.strictEqual(snap.canOperate, false);
|
||||
assert.strictEqual(snap.loginFailed, false);
|
||||
});
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
'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']);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
|
||||
const { parseBotsFromEnv, sanitizeId, normalizePort } = require('../src/config/botsConfig');
|
||||
|
||||
test('MC_BOTS JSON 数组写法解析多个机器人', () => {
|
||||
const bots = parseBotsFromEnv({
|
||||
MC_HOST: 'mc.example.com',
|
||||
MC_PORT: '25566',
|
||||
MC_VERSION: '1.21.1',
|
||||
MC_AUTH: 'offline',
|
||||
MC_BOTS: JSON.stringify([
|
||||
{ username: 'BotA', password: 'p1' },
|
||||
{ username: 'BotB', password: 'p2', label: '二号机' }
|
||||
])
|
||||
});
|
||||
assert.strictEqual(bots.length, 2);
|
||||
assert.deepStrictEqual(bots.map((b) => b.username), ['BotA', 'BotB']);
|
||||
assert.strictEqual(bots[0].host, 'mc.example.com');
|
||||
assert.strictEqual(bots[0].port, 25566);
|
||||
assert.strictEqual(bots[0].version, '1.21.1');
|
||||
assert.strictEqual(bots[1].label, '二号机');
|
||||
assert.strictEqual(bots[1].password, 'p2');
|
||||
});
|
||||
|
||||
test('MC_BOTS 逗号分隔写法解析用户名与密码', () => {
|
||||
const bots = parseBotsFromEnv({ MC_BOTS: 'BotA:p1, BotB:p2' });
|
||||
assert.deepStrictEqual(bots.map((b) => [b.username, b.password]), [['BotA', 'p1'], ['BotB', 'p2']]);
|
||||
});
|
||||
|
||||
test('MC_BOTS 中没有冒号的项视为「只有用户名」', () => {
|
||||
const bots = parseBotsFromEnv({ MC_BOTS: 'BotA' });
|
||||
assert.strictEqual(bots.length, 1);
|
||||
assert.strictEqual(bots[0].password, '');
|
||||
});
|
||||
|
||||
test('未配置 MC_BOTS 时回退到 MC_USERNAME / MC_PASSWORD', () => {
|
||||
const bots = parseBotsFromEnv({ MC_USERNAME: 'SoloBot', MC_PASSWORD: 'pw' });
|
||||
assert.strictEqual(bots.length, 1);
|
||||
assert.strictEqual(bots[0].username, 'SoloBot');
|
||||
assert.strictEqual(bots[0].password, 'pw');
|
||||
});
|
||||
|
||||
test('MC_BOTS 解析失败(非法 JSON)时回退到单机器人兜底', () => {
|
||||
const bots = parseBotsFromEnv({ MC_BOTS: '[{bad json', MC_USERNAME: 'Fallback', MC_PASSWORD: 'p' });
|
||||
assert.strictEqual(bots.length, 1);
|
||||
assert.strictEqual(bots[0].username, 'Fallback');
|
||||
});
|
||||
|
||||
test('重名机器人被去重,空用户名被丢弃', () => {
|
||||
const bots = parseBotsFromEnv({ MC_BOTS: 'BotA:p1,BotA:p2,, ,BotB:p3' });
|
||||
assert.deepStrictEqual(bots.map((b) => b.username), ['BotA', 'BotB']);
|
||||
assert.strictEqual(bots[0].password, 'p1', '应保留第一个同名条目');
|
||||
});
|
||||
|
||||
test('id 由用户名安全化生成,可用于 URL 与文件名', () => {
|
||||
const bots = parseBotsFromEnv({ MC_BOTS: 'a/b:c:p1' });
|
||||
assert.strictEqual(bots.length, 1);
|
||||
assert.strictEqual(bots[0].username, 'a/b');
|
||||
assert.strictEqual(bots[0].id, 'a_b');
|
||||
assert.strictEqual(sanitizeId('正常名字_123'), '正常名字_123');
|
||||
});
|
||||
|
||||
test('端口非法时回退到 25565', () => {
|
||||
assert.strictEqual(normalizePort('abc'), 25565);
|
||||
assert.strictEqual(normalizePort('0'), 25565);
|
||||
assert.strictEqual(normalizePort('70000'), 25565);
|
||||
assert.strictEqual(normalizePort('25565'), 25565);
|
||||
const bots = parseBotsFromEnv({ MC_BOTS: 'BotA:p', MC_PORT: 'x' });
|
||||
assert.strictEqual(bots[0].port, 25565);
|
||||
});
|
||||
|
||||
test('没有任何配置时返回空列表', () => {
|
||||
assert.deepStrictEqual(parseBotsFromEnv({}), []);
|
||||
});
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
'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 { ChatLog, ChatKind, sanitize } = require('../src/core/chatLog');
|
||||
|
||||
/** 建一个使用临时目录的公屏日志 */
|
||||
function setup(options) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-chatlog-'));
|
||||
return { log: new ChatLog(Object.assign({ dir }, options || {})), dir };
|
||||
}
|
||||
|
||||
test('append 记录公屏消息并保留发言人与类型', () => {
|
||||
const { log } = setup();
|
||||
const entry = log.append({
|
||||
botId: 'BotA',
|
||||
bot: 'BotA',
|
||||
message: '大家好',
|
||||
kind: ChatKind.CHAT,
|
||||
username: 'Steve'
|
||||
});
|
||||
assert.strictEqual(entry.botId, 'BotA');
|
||||
assert.strictEqual(entry.message, '大家好');
|
||||
assert.strictEqual(entry.username, 'Steve');
|
||||
assert.strictEqual(entry.kind, 'chat');
|
||||
assert.ok(entry.t > 0);
|
||||
});
|
||||
|
||||
test('空消息不入库,直接返回 null', () => {
|
||||
const { log } = setup();
|
||||
assert.strictEqual(log.append({ botId: 'A', message: '' }), null);
|
||||
assert.strictEqual(log.append({ botId: 'A' }), null);
|
||||
assert.strictEqual(log.snapshot().total, 0);
|
||||
});
|
||||
|
||||
test('list 按机器人隔离,并支持关键字与类型过滤', () => {
|
||||
const { log } = setup();
|
||||
log.append({ botId: 'A', message: '服务器将在 5 分钟后重启', kind: ChatKind.SYSTEM });
|
||||
log.append({ botId: 'A', message: '你好', kind: ChatKind.CHAT, username: 'Steve' });
|
||||
log.append({ botId: 'B', message: '另一个机器人的消息' });
|
||||
|
||||
assert.strictEqual(log.list('A').length, 2);
|
||||
assert.strictEqual(log.list('B').length, 1);
|
||||
assert.strictEqual(log.list('A', { kind: ChatKind.SYSTEM }).length, 1);
|
||||
assert.strictEqual(log.list('A', { keyword: '重启' }).length, 1);
|
||||
assert.strictEqual(log.list('A', { keyword: '不存在' }).length, 0);
|
||||
});
|
||||
|
||||
test('list 限制条数时保留最新的若干条且保持时间升序', () => {
|
||||
const { log } = setup();
|
||||
for (let i = 0; i < 10; i++) log.append({ botId: 'A', message: 'm' + i, time: 1000 + i });
|
||||
const rows = log.list('A', { limit: 3 });
|
||||
assert.deepStrictEqual(rows.map((r) => r.message), ['m7', 'm8', 'm9']);
|
||||
});
|
||||
|
||||
test('每个机器人的内存缓冲独立限长', () => {
|
||||
const { log } = setup({ maxEntriesPerBot: 2 });
|
||||
for (let i = 0; i < 5; i++) log.append({ botId: 'A', message: 'a' + i });
|
||||
log.append({ botId: 'B', message: 'b0' });
|
||||
assert.strictEqual(log.list('A').length, 2);
|
||||
assert.strictEqual(log.list('B').length, 1);
|
||||
const snap = log.snapshot();
|
||||
const a = snap.bots.find((b) => b.botId === 'A');
|
||||
assert.strictEqual(a.count, 5, '计数应统计全部写入量而非缓冲量');
|
||||
assert.strictEqual(a.buffered, 2);
|
||||
});
|
||||
|
||||
test('按机器人分文件落盘(JSONL),重启后可读回文件', () => {
|
||||
const { log, dir } = setup();
|
||||
log.append({ botId: 'BotA', bot: 'BotA', message: 'hello' });
|
||||
log.append({ botId: 'BotA', bot: 'BotA', message: 'world' });
|
||||
log.append({ botId: 'BotB', message: 'other' });
|
||||
|
||||
const fileA = path.join(dir, 'BotA.jsonl');
|
||||
const fileB = path.join(dir, 'BotB.jsonl');
|
||||
assert.strictEqual(fs.readFileSync(fileA, 'utf8').trim().split('\n').length, 2);
|
||||
assert.strictEqual(fs.readFileSync(fileB, 'utf8').trim().split('\n').length, 1);
|
||||
});
|
||||
|
||||
test('机器人名含路径分隔符时落盘文件名被安全化', () => {
|
||||
const { log, dir } = setup();
|
||||
log.append({ botId: 'a/b:c', message: 'x' });
|
||||
const files = fs.readdirSync(dir);
|
||||
assert.strictEqual(files.length, 1);
|
||||
assert.strictEqual(files[0], 'a_b_c.jsonl');
|
||||
assert.strictEqual(sanitize('a/b:c'), 'a_b_c');
|
||||
});
|
||||
|
||||
test('exportTo 导出纯文本,含类型标记与发言人', () => {
|
||||
const { log, dir } = setup();
|
||||
log.append({ botId: 'BotA', message: '你好', kind: ChatKind.CHAT, username: 'Steve' });
|
||||
log.append({ botId: 'BotA', message: 'u claimmode', kind: ChatKind.COMMAND });
|
||||
const file = path.join(dir, 'out.log');
|
||||
assert.strictEqual(log.exportTo('BotA', file), file);
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
assert.ok(text.includes('Steve: 你好'));
|
||||
assert.ok(text.includes('【指令】u claimmode'));
|
||||
});
|
||||
|
||||
test('onEntry 回调收到新消息,回调抛异常不影响写入', () => {
|
||||
const { log } = setup();
|
||||
const seen = [];
|
||||
log.onEntry = (entry) => {
|
||||
seen.push(entry.message);
|
||||
throw new Error('订阅者异常');
|
||||
};
|
||||
assert.doesNotThrow(() => log.append({ botId: 'A', message: 'hi' }));
|
||||
assert.deepStrictEqual(seen, ['hi']);
|
||||
assert.strictEqual(log.list('A').length, 1);
|
||||
});
|
||||
@@ -428,3 +428,132 @@ test('规划队列为空时直接进入 COMPLETE', async () => {
|
||||
assert.strictEqual(controller.state, State.COMPLETE);
|
||||
assert.strictEqual(controller.status, '没有可用的相邻无主区块');
|
||||
});
|
||||
|
||||
// ==================== 多机器人:邦国归属与开拓日志 ====================
|
||||
|
||||
test('start 把识别到的邦国固化到 stateName,不依赖全局 selfStateName', async () => {
|
||||
const { controller, config } = setup({ selfName: 'A' });
|
||||
await controller.start(config);
|
||||
assert.strictEqual(controller.stateName, 'A');
|
||||
assert.strictEqual(controller.config.stateName, 'A', '应写回任务配置供规划器使用');
|
||||
});
|
||||
|
||||
test('两个机器人各自开拓不同邦国时互不覆盖', async () => {
|
||||
const map = new Map([
|
||||
['A', { name: 'A', capital: { x: 0, z: 0 }, chunks: new Set([chunkPos.key(0, 0)]), color: 0x75bd20 }],
|
||||
['B', { name: 'B', capital: { x: 20, z: 20 }, chunks: new Set([chunkPos.key(20, 20)]), color: 0xff43de }]
|
||||
]);
|
||||
const model = new TerritoryModel(map);
|
||||
const logger = new Logger({ echo: false });
|
||||
|
||||
const makeController = (name) => {
|
||||
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
|
||||
const botManager = new FakeBotManager(bot, model);
|
||||
botManager.connection = { username: name };
|
||||
const dataService = new FakeDataService(model);
|
||||
const controller = new ExpansionController({ botManager, dataService, logger, botId: name, botName: name });
|
||||
const config = defaultConfig();
|
||||
config.enforceClaimTime = false;
|
||||
config.autoEat = false;
|
||||
config.autoAttack = false;
|
||||
config.durabilityGuard = false;
|
||||
return { controller, config, model };
|
||||
};
|
||||
|
||||
const first = makeController('BotA');
|
||||
first.config.stateName = 'A';
|
||||
const second = makeController('BotB');
|
||||
second.config.stateName = 'B';
|
||||
|
||||
await first.controller.start(first.config);
|
||||
await second.controller.start(second.config);
|
||||
|
||||
// 全局 selfStateName 会被最后启动的机器人改写,但各控制器仍按自己的邦国工作
|
||||
assert.strictEqual(first.controller.stateName, 'A');
|
||||
assert.strictEqual(second.controller.stateName, 'B');
|
||||
|
||||
first.controller.playerChunk = { x: 0, z: 0 };
|
||||
second.controller.playerChunk = { x: 20, z: 20 };
|
||||
first.controller.monitor.onChat('疆土(0,1) [8,24] 已为你的邦国开拓');
|
||||
second.controller.monitor.onChat('疆土(20,21) [328,344] 已为你的邦国开拓');
|
||||
|
||||
assert.ok(model.state('A').chunks.has(chunkPos.key(0, 1)), 'A 的新占领应记到 A 名下');
|
||||
assert.ok(!model.state('A').chunks.has(chunkPos.key(20, 21)));
|
||||
assert.ok(model.state('B').chunks.has(chunkPos.key(20, 21)), 'B 的新占领应记到 B 名下');
|
||||
});
|
||||
|
||||
test('占领成功时写入开拓日志(含玩家、区块、时间与来源)', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-claimlog-'));
|
||||
const { ExpansionLog } = require('../src/core/expansionLog');
|
||||
const expansionLog = new ExpansionLog({ filePath: path.join(dir, 'expansion-log.jsonl') });
|
||||
|
||||
const { controller, config, bot } = setup();
|
||||
controller.expansionLog = expansionLog;
|
||||
controller.botId = 'BotA';
|
||||
controller.botName = 'BotA';
|
||||
await controller.start(config);
|
||||
const target = controller.target;
|
||||
bot.entity.position.x = target.x * 16 + 8;
|
||||
bot.entity.position.z = target.z * 16 + 8;
|
||||
controller.tick();
|
||||
|
||||
const before = Date.now();
|
||||
controller.monitor.onChat(`疆土(${target.x},${target.z}) [${target.x * 16 + 1},${target.z * 16 + 2}] 已为你的邦国开拓`);
|
||||
|
||||
const rows = expansionLog.list();
|
||||
assert.strictEqual(rows.length, 1);
|
||||
const entry = rows[0];
|
||||
assert.strictEqual(entry.bot, 'BotA');
|
||||
assert.strictEqual(entry.botId, 'BotA');
|
||||
assert.strictEqual(entry.state, 'A');
|
||||
assert.strictEqual(entry.cx, target.x);
|
||||
assert.strictEqual(entry.cz, target.z);
|
||||
assert.strictEqual(entry.bx, target.x * 16 + 1);
|
||||
assert.strictEqual(entry.bz, target.z * 16 + 2);
|
||||
assert.strictEqual(entry.source, 'chat');
|
||||
assert.ok(entry.t >= before && entry.t <= Date.now());
|
||||
assert.strictEqual(entry.sessionCount, 1);
|
||||
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('无坐标的通用成功消息记为 chat-generic 来源', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-claimlog2-'));
|
||||
const { ExpansionLog } = require('../src/core/expansionLog');
|
||||
const expansionLog = new ExpansionLog({ filePath: path.join(dir, 'expansion-log.jsonl') });
|
||||
|
||||
const { controller, config, bot } = setup();
|
||||
controller.expansionLog = expansionLog;
|
||||
await controller.start(config);
|
||||
const target = controller.target;
|
||||
bot.entity.position.x = target.x * 16 + 8;
|
||||
bot.entity.position.z = target.z * 16 + 8;
|
||||
controller.tick();
|
||||
controller.monitor.onChat('你的疆土已开拓');
|
||||
|
||||
const rows = expansionLog.list();
|
||||
assert.strictEqual(rows.length, 1);
|
||||
assert.strictEqual(rows[0].source, 'chat-generic');
|
||||
assert.strictEqual(rows[0].bx, null);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('同一区块重复成功只记一条开拓日志', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-claimlog3-'));
|
||||
const { ExpansionLog } = require('../src/core/expansionLog');
|
||||
const expansionLog = new ExpansionLog({ filePath: path.join(dir, 'expansion-log.jsonl') });
|
||||
|
||||
const { controller, config, bot } = setup();
|
||||
controller.expansionLog = expansionLog;
|
||||
await controller.start(config);
|
||||
const target = controller.target;
|
||||
bot.entity.position.x = target.x * 16 + 8;
|
||||
bot.entity.position.z = target.z * 16 + 8;
|
||||
controller.tick();
|
||||
const message = `疆土(${target.x},${target.z}) [0,0] 已为你的邦国开拓`;
|
||||
controller.monitor.onChat(message);
|
||||
controller.monitor.onChat(message);
|
||||
|
||||
assert.strictEqual(expansionLog.list().length, 1);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
'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 { ExpansionLog } = require('../src/core/expansionLog');
|
||||
|
||||
/** 建一个使用临时目录的日志实例 */
|
||||
function setup(options) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-explog-'));
|
||||
const filePath = path.join(dir, 'expansion-log.jsonl');
|
||||
return { log: new ExpansionLog(Object.assign({ filePath }, options || {})), dir, filePath };
|
||||
}
|
||||
|
||||
test('record 记录开拓玩家、区块与时间', () => {
|
||||
const { log } = setup();
|
||||
const before = Date.now();
|
||||
const entry = log.record({
|
||||
botId: 'BotA',
|
||||
bot: 'BotA',
|
||||
state: '现代大陆',
|
||||
chunk: { x: 12, z: -34 },
|
||||
block: { x: 200, z: -540 },
|
||||
source: 'chat',
|
||||
sessionCount: 7
|
||||
});
|
||||
assert.strictEqual(entry.bot, 'BotA');
|
||||
assert.strictEqual(entry.state, '现代大陆');
|
||||
assert.strictEqual(entry.cx, 12);
|
||||
assert.strictEqual(entry.cz, -34);
|
||||
assert.strictEqual(entry.bx, 200);
|
||||
assert.strictEqual(entry.bz, -540);
|
||||
assert.strictEqual(entry.source, 'chat');
|
||||
assert.strictEqual(entry.sessionCount, 7);
|
||||
assert.ok(entry.t >= before && entry.t <= Date.now(), '时间戳应落在调用区间内');
|
||||
});
|
||||
|
||||
test('record 缺省字段被安全兜底(无方块坐标 / 无邦国)', () => {
|
||||
const { log } = setup();
|
||||
const entry = log.record({ botId: 'B', chunk: { x: 1, z: 2 } });
|
||||
assert.strictEqual(entry.bx, null);
|
||||
assert.strictEqual(entry.bz, null);
|
||||
assert.strictEqual(entry.state, '');
|
||||
assert.strictEqual(entry.source, 'chat');
|
||||
assert.strictEqual(entry.sessionCount, null);
|
||||
});
|
||||
|
||||
test('list 支持按机器人 / 邦国 / 时间过滤并限制条数', () => {
|
||||
const { log } = setup();
|
||||
const base = Date.now();
|
||||
log.record({ botId: 'A', chunk: { x: 0, z: 0 }, state: 'X' });
|
||||
log.record({ botId: 'B', chunk: { x: 1, z: 0 }, state: 'Y' });
|
||||
log.record({ botId: 'A', chunk: { x: 2, z: 0 }, state: 'Y' });
|
||||
|
||||
assert.strictEqual(log.list().length, 3);
|
||||
assert.strictEqual(log.list({ botId: 'A' }).length, 2);
|
||||
assert.strictEqual(log.list({ state: 'Y' }).length, 2);
|
||||
assert.strictEqual(log.list({ since: base + 1e9 }).length, 0);
|
||||
|
||||
const limited = log.list({ limit: 2 });
|
||||
assert.strictEqual(limited.length, 2);
|
||||
// 取最新 2 条但保持时间升序
|
||||
assert.deepStrictEqual(limited.map((e) => e.cx), [1, 2]);
|
||||
});
|
||||
|
||||
test('stats 汇总总数、按机器人分布与今日条数', () => {
|
||||
const { log } = setup();
|
||||
log.record({ botId: 'A', chunk: { x: 0, z: 0 } });
|
||||
log.record({ botId: 'A', chunk: { x: 1, z: 0 } });
|
||||
log.record({ botId: 'B', chunk: { x: 2, z: 0 } });
|
||||
|
||||
const stats = log.stats();
|
||||
assert.strictEqual(stats.total, 3);
|
||||
assert.strictEqual(stats.buffered, 3);
|
||||
assert.strictEqual(stats.today, 3);
|
||||
assert.strictEqual(stats.byBot[0].botId, 'A');
|
||||
assert.strictEqual(stats.byBot[0].count, 2);
|
||||
assert.ok(stats.firstAt > 0 && stats.lastAt >= stats.firstAt);
|
||||
});
|
||||
|
||||
test('落盘为 JSONL,重启后能从磁盘恢复历史', () => {
|
||||
const { log, filePath } = setup();
|
||||
log.record({ botId: 'A', bot: 'A', chunk: { x: 5, z: 6 }, state: 'S' });
|
||||
log.record({ botId: 'B', bot: 'B', chunk: { x: 7, z: 8 } });
|
||||
|
||||
const lines = fs.readFileSync(filePath, 'utf8').trim().split('\n');
|
||||
assert.strictEqual(lines.length, 2);
|
||||
assert.strictEqual(JSON.parse(lines[0]).cx, 5);
|
||||
|
||||
const reloaded = new ExpansionLog({ filePath });
|
||||
assert.strictEqual(reloaded.entries.length, 2);
|
||||
assert.strictEqual(reloaded.totalRecorded, 2);
|
||||
assert.strictEqual(reloaded.stats().byBot.length, 2);
|
||||
});
|
||||
|
||||
test('磁盘上的损坏行(进程被杀留下的半行)被跳过而不是崩溃', () => {
|
||||
const { filePath } = setup();
|
||||
fs.writeFileSync(filePath, '{"t":1,"botId":"A","bot":"A","cx":1,"cz":1}\n{"t":2,"botId"', 'utf8');
|
||||
const log = new ExpansionLog({ filePath });
|
||||
assert.strictEqual(log.entries.length, 1);
|
||||
assert.strictEqual(log.entries[0].cx, 1);
|
||||
});
|
||||
|
||||
test('内存环形缓冲超过上限时丢弃最旧记录,但总数继续累计', () => {
|
||||
const { log } = setup({ maxEntries: 3 });
|
||||
for (let i = 0; i < 5; i++) log.record({ botId: 'A', chunk: { x: i, z: 0 } });
|
||||
assert.strictEqual(log.entries.length, 3);
|
||||
assert.strictEqual(log.entries[0].cx, 2);
|
||||
assert.strictEqual(log.stats().total, 5);
|
||||
});
|
||||
|
||||
test('onEntry 回调收到新记录,回调抛异常不影响写入', () => {
|
||||
const { log } = setup();
|
||||
const seen = [];
|
||||
log.onEntry = (entry) => {
|
||||
seen.push(entry.cx);
|
||||
throw new Error('订阅者异常');
|
||||
};
|
||||
assert.doesNotThrow(() => log.record({ botId: 'A', chunk: { x: 9, z: 9 } }));
|
||||
assert.deepStrictEqual(seen, [9]);
|
||||
assert.strictEqual(log.entries.length, 1);
|
||||
});
|
||||
|
||||
test('exportCsv 输出带 BOM 的表格,含表头与全部记录', () => {
|
||||
const { log, dir } = setup();
|
||||
log.record({ botId: 'A', bot: 'A', state: 'X', chunk: { x: 1, z: 2 }, block: { x: 16, z: 32 } });
|
||||
log.record({ botId: 'B', bot: 'B,带逗号', state: 'Y', chunk: { x: 3, z: 4 } });
|
||||
const file = path.join(dir, 'out.csv');
|
||||
assert.strictEqual(log.exportCsv(file), file);
|
||||
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
assert.ok(text.startsWith('\ufeff'), '应带 BOM 以便 Excel 正确识别中文');
|
||||
const lines = text.replace(/^\ufeff/, '').split('\r\n');
|
||||
assert.ok(lines[0].includes('机器人'));
|
||||
assert.strictEqual(lines.length, 3);
|
||||
assert.ok(lines[2].includes('"B,带逗号"'), '含逗号的字段应被引号包裹');
|
||||
});
|
||||
|
||||
test('clearBuffer 只清内存,磁盘文件保留', () => {
|
||||
const { log, filePath } = setup();
|
||||
log.record({ botId: 'A', chunk: { x: 1, z: 1 } });
|
||||
log.clearBuffer();
|
||||
assert.strictEqual(log.entries.length, 0);
|
||||
assert.ok(fs.readFileSync(filePath, 'utf8').trim().length > 0);
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
'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 } = 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'))
|
||||
});
|
||||
|
||||
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);
|
||||
assert.strictEqual(payload.bigMap.tiles.count, 100);
|
||||
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 } = 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 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, 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, 100);
|
||||
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 } = 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();
|
||||
}
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户