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 行删除
+386
查看文件
@@ -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();
}
});