文件
BotStore/test/expansionLog.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

149 行
5.6 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 { 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);
});