文件
BotStore/test/botManager.test.js
T
lyizai 39624da2d5 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,全部通过。
2026-09-27 21:22:57 +08:00

194 行
7.4 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 { 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);
});