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,全部通过。
这个提交包含在:
lyizai
2026-09-27 21:22:57 +08:00
父节点 223cf719b6
当前提交 39624da2d5
共修改 30 个文件,包含 5480 行新增和 362 行删除
+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);
});