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,全部通过。
这个提交包含在:
+590
-55
@@ -7,42 +7,60 @@ const express = require('express');
|
||||
const { WebSocketServer } = require('ws');
|
||||
|
||||
const chunkPos = require('../core/chunkPos');
|
||||
const { defaultConfig, normalizeConfig, copyConfig } = require('../config/expansionConfig');
|
||||
const { defaultConfig, normalizeConfig } = require('../config/expansionConfig');
|
||||
|
||||
/** 状态推送间隔(毫秒) */
|
||||
const STATUS_PUSH_INTERVAL_MS = 1000;
|
||||
/** 登录接口路径(免令牌,否则无法登录) */
|
||||
const LOGIN_PATH = '/api/login';
|
||||
/** 会话探测接口路径(免令牌) */
|
||||
const SESSION_PATH = '/api/session';
|
||||
|
||||
/**
|
||||
* Web 控制台服务:Express 提供静态页面与 REST 接口,WebSocket 推送实时状态与日志。
|
||||
*
|
||||
* 安全约定:
|
||||
* - 默认只监听 127.0.0.1;
|
||||
* - 配置 `WEB_TOKEN` 后所有 `/api` 请求与 WebSocket 握手都必须带正确令牌;
|
||||
* - 配置 `WEB_TOKEN` / `WEB_PASSWORD` 后所有 `/api` 请求与 WebSocket 握手都必须带正确凭证;
|
||||
* - 控制台页面提供登录框:验证通过后把凭证存在浏览器本地,用于操作各机器人的控制台 / 聊天框;
|
||||
* - 令牌比较使用定时安全比较,避免时序侧信道。
|
||||
*
|
||||
* 多机器人:依赖注入的 `pool`({@link module:bot/botPool.BotPool})负责管理多个机器人,
|
||||
* 每个机器人的控制台 / 聊天框 / 任务接口都以 `/api/bots/:id/...` 暴露。
|
||||
* 为兼容既有单机器人用法,未传 `pool` 时会用 `botManager` + `controller` 包装出一个单机器人池。
|
||||
*/
|
||||
class WebConsole {
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {import('../bot/botManager').BotManager} deps.botManager
|
||||
* @param {import('../controller/expansionController').ExpansionController} deps.controller
|
||||
* @param {import('../bot/botPool').BotPool} [deps.pool] 机器人池
|
||||
* @param {import('../bot/botManager').BotManager} [deps.botManager] 单机器人模式
|
||||
* @param {import('../controller/expansionController').ExpansionController} [deps.controller] 单机器人模式
|
||||
* @param {import('../core/territoryDataService').TerritoryDataService} deps.dataService
|
||||
* @param {import('../util/logger').Logger} deps.logger
|
||||
* @param {() => object} deps.getConfig
|
||||
* @param {(config: object) => object} deps.applyConfig
|
||||
* @param {{host?: string, port?: number, token?: string, exportDir?: string}} [options]
|
||||
* @param {import('../core/expansionLog').ExpansionLog} [deps.expansionLog]
|
||||
* @param {import('../core/chatLog').ChatLog} [deps.chatLog]
|
||||
* @param {import('../core/bigMapService').BigMapService} [deps.bigMap]
|
||||
* @param {{host?: string, port?: number, token?: string, password?: string, exportDir?: string}} [options]
|
||||
*/
|
||||
constructor(deps, options) {
|
||||
const opts = options || {};
|
||||
this.botManager = deps.botManager;
|
||||
this.controller = deps.controller;
|
||||
this.pool = deps.pool || new SingleBotPool(deps.botManager, deps.controller);
|
||||
this.dataService = deps.dataService;
|
||||
this.logger = deps.logger;
|
||||
this.expansionLog = deps.expansionLog || null;
|
||||
this.chatLog = deps.chatLog || null;
|
||||
this.bigMap = deps.bigMap || null;
|
||||
this.getConfig = deps.getConfig;
|
||||
this.applyConfig = deps.applyConfig;
|
||||
this.exportDir = opts.exportDir || path.join(__dirname, '..', '..', 'data');
|
||||
this.host = opts.host || '127.0.0.1';
|
||||
this.port = opts.port === undefined ? 3081 : opts.port;
|
||||
/** 可写进 URL 的令牌(`?token=`) */
|
||||
this.token = opts.token || '';
|
||||
/** 控制台登录密码(`WEB_PASSWORD`);与 token 二选一或同时配置 */
|
||||
this.password = opts.password || '';
|
||||
|
||||
this.app = express();
|
||||
this.server = null;
|
||||
@@ -54,6 +72,23 @@ class WebConsole {
|
||||
this._setupRoutes();
|
||||
}
|
||||
|
||||
/** 默认机器人条目(`pool` 里的第一个) */
|
||||
get defaultEntry() {
|
||||
return this.pool.get(this.pool.defaultId);
|
||||
}
|
||||
|
||||
/** 兼容既有调用:默认机器人的管理器 */
|
||||
get botManager() {
|
||||
const entry = this.defaultEntry;
|
||||
return entry ? entry.manager : null;
|
||||
}
|
||||
|
||||
/** 兼容既有调用:默认机器人的控制器 */
|
||||
get controller() {
|
||||
const entry = this.defaultEntry;
|
||||
return entry ? entry.controller : null;
|
||||
}
|
||||
|
||||
/** 启动 HTTP + WebSocket 服务 */
|
||||
start() {
|
||||
this.server = http.createServer(this.app);
|
||||
@@ -85,13 +120,10 @@ class WebConsole {
|
||||
}, STATUS_PUSH_INTERVAL_MS);
|
||||
if (this._pushTimer.unref) this._pushTimer.unref();
|
||||
|
||||
// 新日志实时推送
|
||||
this.logger.onEntry = (entry) => {
|
||||
const payload = JSON.stringify({ type: 'log', data: entry });
|
||||
for (const ws of this.clients) {
|
||||
if (ws.readyState === 1) ws.send(payload);
|
||||
}
|
||||
};
|
||||
// 新日志 / 新公屏消息 / 新开拓记录实时推送
|
||||
this.logger.onEntry = (entry) => this._broadcast('log', entry);
|
||||
if (this.chatLog) this.chatLog.onEntry = (entry) => this._broadcast('chat', entry);
|
||||
if (this.expansionLog) this.expansionLog.onEntry = (entry) => this._broadcast('expansion', entry);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.server.listen(this.port, this.host, () => {
|
||||
@@ -117,6 +149,8 @@ class WebConsole {
|
||||
async stop() {
|
||||
if (this._pushTimer) clearInterval(this._pushTimer);
|
||||
this.logger.onEntry = null;
|
||||
if (this.chatLog) this.chatLog.onEntry = null;
|
||||
if (this.expansionLog) this.expansionLog.onEntry = null;
|
||||
for (const ws of this.clients) {
|
||||
try {
|
||||
ws.close();
|
||||
@@ -134,19 +168,46 @@ class WebConsole {
|
||||
_setupRoutes() {
|
||||
const app = this.app;
|
||||
app.disable('x-powered-by');
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.json({ limit: '8mb' }));
|
||||
// 静态资源(控制台页面本身)不需要令牌:浏览器加载 index.html 时带的 ?token= 不会
|
||||
// 附加到后续 style.css / app.js 的子请求上,若一并拦截会导致页面样式与脚本 404/401。
|
||||
// 页面本身不含任何敏感信息,真正需要保护的是下面的 /api 接口。
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// ===== 免鉴权:登录与会话探测(否则用户无法登录)=====
|
||||
app.post(LOGIN_PATH, (req, res) => {
|
||||
const provided = String((req.body && req.body.password) || req.body && req.body.token || '');
|
||||
if (!this.authRequired()) {
|
||||
res.json({ ok: true, data: { authenticated: true, required: false, token: '' } });
|
||||
return;
|
||||
}
|
||||
if (!this._matchesSecret(provided)) {
|
||||
res.status(401).json({ ok: false, message: '密码错误' });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true, data: { authenticated: true, required: true, token: provided } });
|
||||
});
|
||||
|
||||
app.get(SESSION_PATH, (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
data: {
|
||||
required: this.authRequired(),
|
||||
authenticated: !this.authRequired() || this._authorized(req)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 其余 /api 一律要求凭证 =====
|
||||
app.use((req, res, next) => {
|
||||
if (!this._authorized(req)) {
|
||||
res.status(401).json({ ok: false, message: '未授权:请在 URL 上带 ?token= 或提供 X-Auth-Token 头' });
|
||||
res.status(401).json({ ok: false, message: '未授权:请先登录控制台或提供 X-Auth-Token 头' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// -------- 全局状态 --------
|
||||
app.get('/api/status', (req, res) => {
|
||||
res.json({ ok: true, data: this.statusPayload() });
|
||||
});
|
||||
@@ -175,36 +236,240 @@ class WebConsole {
|
||||
|
||||
app.post('/api/logs/export', (req, res) => {
|
||||
const file = path.join(this.exportDir, `autostore-debug-${Date.now()}.log`);
|
||||
const result = this.controller.exportLogs(file);
|
||||
const controller = this.controller;
|
||||
const result = controller ? controller.exportLogs(file) : null;
|
||||
if (result) res.json({ ok: true, data: { file: result } });
|
||||
else res.status(500).json({ ok: false, message: '导出失败' });
|
||||
});
|
||||
|
||||
// -------- 机器人池 --------
|
||||
app.get('/api/bots', (req, res) => {
|
||||
res.json({ ok: true, data: this.botsPayload() });
|
||||
});
|
||||
|
||||
app.get('/api/bots/:id/status', (req, res) => {
|
||||
this._withEntry(req, res, (entry) => ({
|
||||
ok: true,
|
||||
data: Object.assign({}, entry.manager.snapshot(), { task: entry.controller.snapshot() })
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/api/bots/:id/settings', (req, res) => {
|
||||
this._withEntry(req, res, (entry) => ({
|
||||
ok: true,
|
||||
data: this._settingSnapshot(entry)
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/api/bots/:id/settings', (req, res) => {
|
||||
this._withEntry(req, res, (entry) => {
|
||||
const body = req.body || {};
|
||||
const patch = {};
|
||||
if (body.label !== undefined) patch.label = String(body.label);
|
||||
if (body.enabled !== undefined) patch.enabled = body.enabled === true;
|
||||
if (body.autoStart !== undefined) patch.autoStart = body.autoStart === true;
|
||||
if (body.config !== undefined) patch.config = body.config;
|
||||
const saved = this._setBotSetting(entry, patch);
|
||||
res.json({ ok: true, data: saved });
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/bots/:id/task/start', async (req, res) => {
|
||||
try {
|
||||
const entry = this.pool.require(req.params.id);
|
||||
const body = req.body && Object.keys(req.body).length > 0 ? req.body : null;
|
||||
const result = await this._startTask(entry, body);
|
||||
res.json({ ok: result.ok, data: this.statusPayload(), message: result.message });
|
||||
} catch (err) {
|
||||
this._error(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/bots/:id/task/stop', (req, res) => {
|
||||
this._withEntry(req, res, (entry) => {
|
||||
this.pool.stopTask(entry.id);
|
||||
res.json({ ok: true, data: this.statusPayload(), message: entry.connection.username + ' 已停止' });
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/bots/:id/reconnect', (req, res) => {
|
||||
this._withEntry(req, res, (entry) => {
|
||||
this.pool.reconnect(entry.id);
|
||||
res.json({ ok: true, message: entry.connection.username + ' 正在重连' });
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
// 控制台 / 聊天框操作:都要求该机器人已完成游戏内登录验证
|
||||
app.post('/api/bots/:id/command', (req, res) => {
|
||||
const command = req.body && req.body.command;
|
||||
if (!command || typeof command !== 'string') {
|
||||
res.status(400).json({ ok: false, message: '缺少 command 字段' });
|
||||
return;
|
||||
}
|
||||
this._withEntry(req, res, (entry) => {
|
||||
const result = this.pool.sendCommand(entry.id, command);
|
||||
res.status(result.ok ? 200 : 409).json(result);
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/bots/:id/chat', (req, res) => {
|
||||
const message = req.body && req.body.message;
|
||||
if (!message || typeof message !== 'string') {
|
||||
res.status(400).json({ ok: false, message: '缺少 message 字段' });
|
||||
return;
|
||||
}
|
||||
this._withEntry(req, res, (entry) => {
|
||||
const result = this.pool.sendChat(entry.id, message);
|
||||
res.status(result.ok ? 200 : 409).json(result);
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/bots/:id/chat', (req, res) => {
|
||||
if (!this.chatLog) {
|
||||
res.json({ ok: true, data: [] });
|
||||
return;
|
||||
}
|
||||
const query = req.query || {};
|
||||
res.json({
|
||||
ok: true,
|
||||
data: this.chatLog.list(req.params.id, {
|
||||
limit: toPositiveInt(query.limit, 300),
|
||||
since: toOptionalInt(query.since),
|
||||
kind: typeof query.kind === 'string' && query.kind ? query.kind : undefined,
|
||||
keyword: typeof query.keyword === 'string' && query.keyword ? query.keyword : undefined
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/bots/:id/chat/export', (req, res) => {
|
||||
if (!this.chatLog) {
|
||||
res.status(500).json({ ok: false, message: '未启用公屏日志' });
|
||||
return;
|
||||
}
|
||||
const file = path.join(this.exportDir, `chat-${sanitizeFile(req.params.id)}-${Date.now()}.log`);
|
||||
const result = this.chatLog.exportTo(req.params.id, file);
|
||||
if (result) res.json({ ok: true, data: { file: result } });
|
||||
else res.status(500).json({ ok: false, message: '导出失败' });
|
||||
});
|
||||
|
||||
// -------- 开拓日志 --------
|
||||
app.get('/api/expansion-log', (req, res) => {
|
||||
if (!this.expansionLog) {
|
||||
res.json({ ok: true, data: { entries: [], stats: null } });
|
||||
return;
|
||||
}
|
||||
const query = req.query || {};
|
||||
res.json({
|
||||
ok: true,
|
||||
data: {
|
||||
entries: this.expansionLog.list({
|
||||
limit: toPositiveInt(query.limit, 200),
|
||||
botId: typeof query.botId === 'string' && query.botId ? query.botId : undefined,
|
||||
state: typeof query.state === 'string' && query.state ? query.state : undefined,
|
||||
since: toOptionalInt(query.since)
|
||||
}),
|
||||
stats: this.expansionLog.stats()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/expansion-log/export', (req, res) => {
|
||||
if (!this.expansionLog) {
|
||||
res.status(500).json({ ok: false, message: '未启用开拓日志' });
|
||||
return;
|
||||
}
|
||||
const body = req.body || {};
|
||||
const file = path.join(this.exportDir, `expansion-log-${Date.now()}.csv`);
|
||||
const result = this.expansionLog.exportCsv(file, {
|
||||
botId: body.botId || undefined,
|
||||
since: toOptionalInt(body.since)
|
||||
});
|
||||
if (result) res.json({ ok: true, data: { file: result } });
|
||||
else res.status(500).json({ ok: false, message: '导出失败' });
|
||||
});
|
||||
|
||||
app.post('/api/expansion-log/clear', (req, res) => {
|
||||
if (this.expansionLog) this.expansionLog.clearBuffer();
|
||||
res.json({ ok: true, message: '已清空内存中的开拓日志(磁盘文件保留)' });
|
||||
});
|
||||
|
||||
// -------- 大地图 --------
|
||||
app.get('/api/bigmap', (req, res) => {
|
||||
if (!this.bigMap) {
|
||||
res.json({ ok: true, data: { enabled: false } });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true, data: this.bigMapPayload() });
|
||||
});
|
||||
|
||||
app.get('/api/bigmap/markers', (req, res) => {
|
||||
res.json({ ok: true, data: this.bigMapMarkersPayload() });
|
||||
});
|
||||
|
||||
app.get('/api/bigmap/tile/:z/:x/:y', async (req, res) => {
|
||||
if (!this.bigMap) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
const zoom = Number.parseInt(req.params.z, 10);
|
||||
const tileX = Number.parseInt(req.params.x, 10);
|
||||
const tileZ = Number.parseInt(String(req.params.y).replace(/\.png$/i, ''), 10);
|
||||
if (![zoom, tileX, tileZ].every(Number.isFinite)) {
|
||||
res.status(400).json({ ok: false, message: '瓦片坐标非法' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await this.bigMap.getTile(zoom, tileX, tileZ);
|
||||
if (!data) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
// 瓦片内容随服务器地图更新,但变更频率很低;缓存 10 分钟足以兼顾新鲜度与带宽
|
||||
res.setHeader('Cache-Control', 'public, max-age=600');
|
||||
res.end(data);
|
||||
} catch (err) {
|
||||
this._error(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/bigmap/crawl', async (req, res) => {
|
||||
if (!this.bigMap) {
|
||||
res.status(500).json({ ok: false, message: '未启用大地图' });
|
||||
return;
|
||||
}
|
||||
const body = req.body || {};
|
||||
const zoom = Number.isFinite(Number(body.zoom)) ? Number(body.zoom) : undefined;
|
||||
// 爬取可能持续数十秒:立刻返回,进度通过 /api/bigmap 轮询
|
||||
this.bigMap.crawlAll(zoom, { force: body.force === true }).catch((err) => {
|
||||
this.logger.log('大地图爬取失败:' + (err && err.message ? err.message : String(err)), 'error');
|
||||
});
|
||||
res.json({ ok: true, message: '已开始爬取地图瓦片', data: this.bigMap.snapshot() });
|
||||
});
|
||||
|
||||
app.post('/api/bigmap/cache/clear', (req, res) => {
|
||||
if (!this.bigMap) {
|
||||
res.status(500).json({ ok: false, message: '未启用大地图' });
|
||||
return;
|
||||
}
|
||||
const removed = this.bigMap.clearCache();
|
||||
res.json({ ok: true, message: `已清空瓦片缓存(${removed} 个文件)` });
|
||||
});
|
||||
|
||||
// -------- 地图数据(领土模型) --------
|
||||
app.get('/api/map', (req, res) => {
|
||||
try {
|
||||
res.json({ ok: true, data: this.mapPayload(req.query.full === '1') });
|
||||
res.json({ ok: true, data: this.mapPayload(req.query.full === '1', req.query.bot) });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/task/start', async (req, res) => {
|
||||
try {
|
||||
const config = req.body && Object.keys(req.body).length > 0
|
||||
? normalizeConfig(req.body, this.getConfig())
|
||||
: this.getConfig();
|
||||
const ok = await this.controller.start(config);
|
||||
res.json({ ok, data: this.statusPayload(), message: ok ? '任务已启动' : this.controller.status });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/task/stop', (req, res) => {
|
||||
this.controller.stop();
|
||||
res.json({ ok: true, data: this.statusPayload() });
|
||||
});
|
||||
|
||||
app.post('/api/map/refresh', (req, res) => {
|
||||
this.dataService.refreshAsync();
|
||||
res.json({ ok: true, message: '已触发地图刷新' });
|
||||
@@ -215,49 +480,154 @@ class WebConsole {
|
||||
res.json({ ok: result.ok, message: result.message });
|
||||
});
|
||||
|
||||
// -------- 单机器人兼容接口(等价于默认机器人的 /api/bots/:id/...)--------
|
||||
app.post('/api/task/start', async (req, res) => {
|
||||
try {
|
||||
const entry = this.defaultEntry;
|
||||
if (!entry) {
|
||||
res.status(500).json({ ok: false, message: '没有可用的机器人' });
|
||||
return;
|
||||
}
|
||||
const body = req.body && Object.keys(req.body).length > 0 ? req.body : null;
|
||||
const result = await this._startTask(entry, body);
|
||||
res.json({ ok: result.ok, data: this.statusPayload(), message: result.message });
|
||||
} catch (err) {
|
||||
this._error(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/task/stop', (req, res) => {
|
||||
const entry = this.defaultEntry;
|
||||
if (entry) this.pool.stopTask(entry.id);
|
||||
res.json({ ok: true, data: this.statusPayload() });
|
||||
});
|
||||
|
||||
app.post('/api/command', (req, res) => {
|
||||
const command = req.body && req.body.command;
|
||||
if (!command || typeof command !== 'string') {
|
||||
res.status(400).json({ ok: false, message: '缺少 command 字段' });
|
||||
return;
|
||||
}
|
||||
this.botManager.sendCommand(command.replace(/^\//, ''));
|
||||
res.json({ ok: true, message: '已发送:' + command });
|
||||
const entry = this.defaultEntry;
|
||||
if (!entry) {
|
||||
res.status(500).json({ ok: false, message: '没有可用的机器人' });
|
||||
return;
|
||||
}
|
||||
const result = this.pool.sendCommand(entry.id, command);
|
||||
res.status(result.ok ? 200 : 409).json(result);
|
||||
});
|
||||
|
||||
app.post('/api/bot/reconnect', (req, res) => {
|
||||
this.botManager.disconnect();
|
||||
setTimeout(() => this.botManager.connect(), 500);
|
||||
const entry = this.defaultEntry;
|
||||
if (entry) this.pool.reconnect(entry.id);
|
||||
res.json({ ok: true, message: '正在重连' });
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 路由辅助 ====================
|
||||
|
||||
/**
|
||||
* 校验请求授权。
|
||||
* 解析机器人条目并把 handler 的返回值作为 JSON 响应;抛错统一转成 HTTP 状态码。
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {(entry: object) => object|null} handler 返回 null 表示已自行响应
|
||||
*/
|
||||
_withEntry(req, res, handler) {
|
||||
try {
|
||||
const entry = this.pool.require(req.params.id);
|
||||
const payload = handler(entry);
|
||||
if (payload !== null && payload !== undefined) res.json(payload);
|
||||
} catch (err) {
|
||||
this._error(res, err);
|
||||
}
|
||||
}
|
||||
|
||||
_error(res, err) {
|
||||
const status = err && err.statusCode ? err.statusCode : 500;
|
||||
res.status(status).json({ ok: false, message: err && err.message ? err.message : String(err) });
|
||||
}
|
||||
|
||||
/** 兼容不同形态的机器人池:取设置快照 */
|
||||
_settingSnapshot(entry) {
|
||||
if (typeof this.pool.settingSnapshot === 'function') return this.pool.settingSnapshot(entry.id);
|
||||
return { id: entry.id, label: entry.manager.label, enabled: true, autoStart: false, config: entry.config };
|
||||
}
|
||||
|
||||
/** 兼容不同形态的机器人池:保存设置 */
|
||||
_setBotSetting(entry, patch) {
|
||||
if (typeof this.pool.setBotSetting === 'function') return this.pool.setBotSetting(entry.id, patch);
|
||||
return this._settingSnapshot(entry);
|
||||
}
|
||||
|
||||
/** 兼容不同形态的机器人池:启动任务 */
|
||||
async _startTask(entry, body) {
|
||||
if (typeof this.pool.startTask === 'function') return this.pool.startTask(entry.id, body);
|
||||
const config = body ? normalizeConfig(body, this.getConfig()) : this.getConfig();
|
||||
entry.controller.config = config;
|
||||
const ok = await entry.controller.start(config);
|
||||
return { ok, message: ok ? '任务已启动' : entry.controller.status, entry };
|
||||
}
|
||||
|
||||
// ==================== 鉴权 ====================
|
||||
|
||||
/** @returns {boolean} 是否配置了访问凭证 */
|
||||
authRequired() {
|
||||
return !!(this.token || this.password);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验请求授权:支持 `X-Auth-Token` 头、`?token=` 查询参数与 Cookie。
|
||||
* @param {import('http').IncomingMessage} req
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_authorized(req) {
|
||||
if (!this.token) return true;
|
||||
let provided = '';
|
||||
if (!this.authRequired()) return true;
|
||||
return this._matchesSecret(this._providedSecret(req));
|
||||
}
|
||||
|
||||
/** @returns {boolean} 给定凭证是否匹配 token 或密码 */
|
||||
_matchesSecret(provided) {
|
||||
if (!provided) return false;
|
||||
if (this.token && timingSafeEqual(provided, this.token)) return true;
|
||||
if (this.password && timingSafeEqual(provided, this.password)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 从请求里提取凭证(头 → 查询参数 → Cookie) */
|
||||
_providedSecret(req) {
|
||||
const header = req.headers['x-auth-token'];
|
||||
if (typeof header === 'string') provided = header;
|
||||
if (!provided && req.url) {
|
||||
if (typeof header === 'string' && header) return header;
|
||||
if (req.url) {
|
||||
const query = req.url.includes('?') ? req.url.slice(req.url.indexOf('?') + 1) : '';
|
||||
const params = new URLSearchParams(query);
|
||||
provided = params.get('token') || '';
|
||||
const fromQuery = params.get('token');
|
||||
if (fromQuery) return fromQuery;
|
||||
}
|
||||
return timingSafeEqual(provided, this.token);
|
||||
const cookie = req.headers.cookie;
|
||||
if (typeof cookie === 'string') {
|
||||
const match = /(?:^|;\s*)autostore_token=([^;]*)/.exec(cookie);
|
||||
if (match) return decodeURIComponent(match[1]);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// ==================== 数据组装 ====================
|
||||
|
||||
/** @returns {object} 完整状态负载 */
|
||||
statusPayload() {
|
||||
const entry = this.defaultEntry;
|
||||
const bots = this.pool.snapshot();
|
||||
const primary = bots.length > 0 ? bots[0] : null;
|
||||
return {
|
||||
bot: this.botManager.snapshot(),
|
||||
task: this.controller.snapshot(),
|
||||
// 单机器人字段保留,避免既有前端 / 调用方失效
|
||||
bot: primary || (entry ? entry.manager.snapshot() : null),
|
||||
task: entry ? entry.controller.snapshot() : null,
|
||||
data: this.dataService.snapshot(),
|
||||
bots,
|
||||
defaultBotId: this.pool.defaultId || '',
|
||||
expansionLog: this.expansionLog ? this.expansionLog.stats() : null,
|
||||
chatLog: this.chatLog ? this.chatLog.snapshot() : null,
|
||||
bigMap: this.bigMap ? this.bigMap.snapshot() : null,
|
||||
serverTime: Date.now(),
|
||||
claimTime: {
|
||||
start: this.getConfig().claimStartHour,
|
||||
@@ -267,25 +637,33 @@ class WebConsole {
|
||||
};
|
||||
}
|
||||
|
||||
/** @returns {Array<object>} 机器人列表快照 */
|
||||
botsPayload() {
|
||||
return this.pool.snapshot();
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装地图数据。
|
||||
*
|
||||
* 区块一律编码为扁平的 `[x1, z1, x2, z2, ...]` 数组,减少 JSON 体积。
|
||||
*
|
||||
* @param {boolean} full 是否包含全部邦国的区块(体积大,按需请求)
|
||||
* @param {string} [botId] 以哪个机器人为视角(缺省取默认机器人)
|
||||
* @returns {object}
|
||||
*/
|
||||
mapPayload(full) {
|
||||
mapPayload(full, botId) {
|
||||
const model = this.dataService.getModel();
|
||||
const selfName = model.selfStateNameValue();
|
||||
const self = model.selfState();
|
||||
const entry = this.pool.get(botId) || this.defaultEntry;
|
||||
const controller = entry ? entry.controller : null;
|
||||
const selfName = (controller && controller.stateName) || model.selfStateNameValue();
|
||||
const self = model.state(selfName);
|
||||
const result = {
|
||||
selfState: selfName,
|
||||
self: self ? flattenChunks(self.chunks) : [],
|
||||
session: flattenChunks(model.sessionClaimsSet()),
|
||||
flying: flattenChunks(model.selfFlyingChunks()),
|
||||
target: this.controller.target,
|
||||
playerChunk: this.controller.playerChunk,
|
||||
flying: self ? flattenChunks(model.flyingChunksOf(selfName)) : [],
|
||||
target: controller ? controller.target : null,
|
||||
playerChunk: controller ? controller.playerChunk : null,
|
||||
states: [],
|
||||
full
|
||||
};
|
||||
@@ -311,11 +689,141 @@ class WebConsole {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @returns {object} 大地图元信息(坐标系、瓦片范围、缓存进度、机器人与领土) */
|
||||
bigMapPayload() {
|
||||
const snapshot = this.bigMap.snapshot();
|
||||
return Object.assign(snapshot, {
|
||||
bots: this.pool.positions(),
|
||||
markers: this.bigMapMarkersPayload()
|
||||
});
|
||||
}
|
||||
|
||||
/** @returns {object} 大地图上的领土标记(多边形 + 王城) */
|
||||
bigMapMarkersPayload() {
|
||||
const model = this.dataService.getModel();
|
||||
const states = [];
|
||||
for (const state of model.statesIter()) {
|
||||
states.push({
|
||||
name: state.name,
|
||||
color: '#' + (state.color & 0xffffff).toString(16).padStart(6, '0'),
|
||||
capital: state.capital,
|
||||
chunkCount: state.chunks.size,
|
||||
polygons: state.polygons || []
|
||||
});
|
||||
}
|
||||
states.sort((a, b) => b.chunkCount - a.chunkCount);
|
||||
return {
|
||||
world: this.bigMap ? this.bigMap.world : '',
|
||||
updatedAt: model.updatedAt || 0,
|
||||
states
|
||||
};
|
||||
}
|
||||
|
||||
_broadcast(type, data) {
|
||||
if (this.clients.size === 0) return;
|
||||
const payload = JSON.stringify({ type, data });
|
||||
for (const ws of this.clients) {
|
||||
if (ws.readyState === 1) ws.send(payload);
|
||||
}
|
||||
}
|
||||
|
||||
_send(ws, payload) {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单机器人池适配器:把 `{botManager, controller}` 包装成与
|
||||
* {@link module:bot/botPool.BotPool} 相同的接口,使 WebConsole 只有一条代码路径。
|
||||
*
|
||||
* 这是为了向后兼容既有的单机器人用法与测试,不承担多机器人职责。
|
||||
*/
|
||||
class SingleBotPool {
|
||||
/**
|
||||
* @param {import('../bot/botManager').BotManager} botManager
|
||||
* @param {import('../controller/expansionController').ExpansionController} controller
|
||||
*/
|
||||
constructor(botManager, controller) {
|
||||
const id = (botManager && botManager.id)
|
||||
|| (botManager && botManager.connection && botManager.connection.username)
|
||||
|| 'bot';
|
||||
this.defaultId = id;
|
||||
this.entries = new Map([[id, { id, manager: botManager, controller, config: null, connection: { username: id } }]]);
|
||||
}
|
||||
|
||||
/** @param {string} [id] */
|
||||
get(id) {
|
||||
if (!id) return this.entries.get(this.defaultId) || null;
|
||||
return this.entries.get(id) || null;
|
||||
}
|
||||
|
||||
/** @param {string} id */
|
||||
require(id) {
|
||||
const entry = this.get(id);
|
||||
if (!entry) {
|
||||
const err = new Error('机器人不存在:' + id);
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** @returns {Array<object>} */
|
||||
snapshot() {
|
||||
const entry = this.get();
|
||||
if (!entry) return [];
|
||||
return [Object.assign({}, entry.manager.snapshot(), { task: entry.controller.snapshot() })];
|
||||
}
|
||||
|
||||
/** @returns {Array<object>} */
|
||||
positions() {
|
||||
const entry = this.get();
|
||||
if (!entry) return [];
|
||||
const snap = entry.manager.snapshot();
|
||||
return [{
|
||||
id: entry.id,
|
||||
label: snap.label || entry.id,
|
||||
username: snap.username,
|
||||
state: snap.state,
|
||||
loggedIn: snap.loggedIn,
|
||||
position: snap.position,
|
||||
chunk: snap.chunk,
|
||||
stateName: entry.controller.stateName,
|
||||
taskState: entry.controller.state,
|
||||
completedClaims: entry.controller.completedClaims
|
||||
}];
|
||||
}
|
||||
|
||||
/** @param {string} id */
|
||||
stopTask(id) {
|
||||
const entry = this.require(id);
|
||||
entry.controller.stop();
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** @param {string} id */
|
||||
reconnect(id) {
|
||||
const entry = this.require(id);
|
||||
entry.manager.disconnect();
|
||||
setTimeout(() => entry.manager.connect(), 500).unref?.();
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** @param {string} id @param {string} command */
|
||||
sendCommand(id, command) {
|
||||
const entry = this.require(id);
|
||||
const ok = entry.manager.sendCommand(String(command).replace(/^\//, ''));
|
||||
return { ok, message: ok ? '已发送:' + command : '发送失败(未完成登录验证)' };
|
||||
}
|
||||
|
||||
/** @param {string} id @param {string} message */
|
||||
sendChat(id, message) {
|
||||
const entry = this.require(id);
|
||||
const ok = entry.manager.sendChat(String(message || '').trim());
|
||||
return { ok, message: ok ? '已发送' : '发送失败(未完成登录验证)' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把区块键集合压平成 `[x1, z1, x2, z2, ...]`。
|
||||
* @param {Set<number>} keys
|
||||
@@ -367,4 +875,31 @@ function timingSafeEqual(a, b) {
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
module.exports = { WebConsole, flattenChunks, boundsOf, timingSafeEqual, STATUS_PUSH_INTERVAL_MS };
|
||||
/** 查询参数转正整数 */
|
||||
function toPositiveInt(value, fallback) {
|
||||
const n = Number.parseInt(value, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
/** 查询参数转可选整数(缺省返回 undefined,便于过滤函数跳过) */
|
||||
function toOptionalInt(value) {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
const n = Number.parseInt(value, 10);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
/** 文件名安全化 */
|
||||
function sanitizeFile(name) {
|
||||
return String(name).replace(/[\\/:*?"<>|]/g, '_');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WebConsole,
|
||||
SingleBotPool,
|
||||
flattenChunks,
|
||||
boundsOf,
|
||||
timingSafeEqual,
|
||||
STATUS_PUSH_INTERVAL_MS,
|
||||
LOGIN_PATH,
|
||||
SESSION_PATH
|
||||
};
|
||||
|
||||
在新工单中引用
屏蔽一个用户