'use strict'; const http = require('http'); const path = require('path'); const express = require('express'); const { WebSocketServer } = require('ws'); const chunkPos = require('../core/chunkPos'); 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` / `WEB_PASSWORD` 后所有 `/api` 请求与 WebSocket 握手都必须带正确凭证; * - 控制台页面提供登录框:验证通过后把凭证存在浏览器本地,用于操作各机器人的控制台 / 聊天框; * - 令牌比较使用定时安全比较,避免时序侧信道。 * * 多机器人:依赖注入的 `pool`({@link module:bot/botPool.BotPool})负责管理多个机器人, * 每个机器人的控制台 / 聊天框 / 任务接口都以 `/api/bots/:id/...` 暴露。 * 为兼容既有单机器人用法,未传 `pool` 时会用 `botManager` + `controller` 包装出一个单机器人池。 */ class WebConsole { /** * @param {object} deps * @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 {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.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; this.wss = null; this._pushTimer = null; /** @type {Set} */ this.clients = new Set(); 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); this.wss = new WebSocketServer({ noServer: true }); this.server.on('upgrade', (request, socket, head) => { if (!this._authorized(request)) { socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); socket.destroy(); return; } this.wss.handleUpgrade(request, socket, head, (ws) => { this.wss.emit('connection', ws, request); }); }); this.wss.on('connection', (ws) => { this.clients.add(ws); ws.on('close', () => this.clients.delete(ws)); ws.on('error', () => this.clients.delete(ws)); this._send(ws, { type: 'status', data: this.statusPayload() }); this._send(ws, { type: 'logs', data: this.logger.list() }); }); this._pushTimer = setInterval(() => { if (this.clients.size === 0) return; const payload = JSON.stringify({ type: 'status', data: this.statusPayload() }); for (const ws of this.clients) { if (ws.readyState === 1) ws.send(payload); } }, STATUS_PUSH_INTERVAL_MS); if (this._pushTimer.unref) this._pushTimer.unref(); // 新日志 / 新公屏消息 / 新开拓记录实时推送 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, () => { const address = this.server.address(); this.actualPort = address && typeof address === 'object' ? address.port : this.port; resolve(this.url()); }); }); } /** @returns {string} 控制台访问地址(带令牌,便于直接点击打开) */ url() { const tokenSuffix = this.token ? `/?token=${encodeURIComponent(this.token)}` : '/'; return this.baseUrl() + tokenSuffix; } /** @returns {string} 控制台根地址(不含令牌),供 API 调用方拼接路径 */ baseUrl() { return `http://${this.host}:${this.actualPort || this.port}`; } /** 停止服务 */ 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(); } catch { // 忽略 } } this.clients.clear(); if (this.wss) await new Promise((resolve) => this.wss.close(resolve)); if (this.server) await new Promise((resolve) => this.server.close(resolve)); } // ==================== 路由 ==================== _setupRoutes() { const app = this.app; app.disable('x-powered-by'); 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: '未授权:请先登录控制台或提供 X-Auth-Token 头' }); return; } next(); }); // -------- 全局状态 -------- app.get('/api/status', (req, res) => { res.json({ ok: true, data: this.statusPayload() }); }); app.get('/api/config', (req, res) => { res.json({ ok: true, data: this.getConfig() }); }); app.post('/api/config', (req, res) => { try { const merged = normalizeConfig(req.body, this.getConfig()); const applied = this.applyConfig(merged); res.json({ ok: true, data: applied }); } catch (err) { res.status(400).json({ ok: false, message: err.message }); } }); app.get('/api/config/defaults', (req, res) => { res.json({ ok: true, data: defaultConfig() }); }); app.get('/api/logs', (req, res) => { res.json({ ok: true, data: this.logger.list() }); }); app.post('/api/logs/export', (req, res) => { const file = path.join(this.exportDir, `autostore-debug-${Date.now()}.log`); 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() }); }); // 控制台新增玩家:密码只落到服务器本地凭据文件(0600),不会出现在任何响应里 app.post('/api/bots', (req, res) => { if (typeof this.pool.addBot !== 'function') { res.status(400).json({ ok: false, message: '当前运行模式不支持在控制台新增玩家' }); return; } try { const body = req.body || {}; const entry = this.pool.addBot({ username: body.username, password: body.password, label: body.label, auth: body.auth, host: body.host, port: body.port, version: body.version }); res.json({ ok: true, message: `已添加玩家 ${entry.connection.username}`, data: { id: entry.id, username: entry.connection.username, bots: this.botsPayload() } }); } catch (err) { this._error(res, err); } }); app.delete('/api/bots/:id', (req, res) => { if (typeof this.pool.removeBot !== 'function') { res.status(400).json({ ok: false, message: '当前运行模式不支持在控制台移除玩家' }); return; } try { const entry = this.pool.removeBot(req.params.id); res.json({ ok: true, message: `已移除玩家 ${entry.connection.username}`, data: { bots: this.botsPayload() } }); } catch (err) { this._error(res, err); } }); 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; if (body.schedules !== undefined) patch.schedules = body.schedules; const saved = this._setBotSetting(entry, patch); res.json({ ok: true, data: saved }); return null; }); }); // 定时消息:整体替换某机器人的定时发送列表 app.post('/api/bots/:id/schedules', (req, res) => { this._withEntry(req, res, (entry) => { const schedules = (req.body && req.body.schedules) || []; this._setBotSetting(entry, { schedules }); // 返回带「剩余秒数」的快照,前端可直接渲染倒计时 res.json({ ok: true, data: this._settingSnapshot(entry).schedules || [], message: '定时任务已保存' }); return null; }); }); app.get('/api/bots/:id/schedules', (req, res) => { this._withEntry(req, res, (entry) => { res.json({ ok: true, data: this._settingSnapshot(entry).schedules || [] }); 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/move', async (req, res) => { const body = req.body || {}; if (typeof this.pool.walkTest !== 'function') { res.status(400).json({ ok: false, message: '当前运行模式不支持移动测试' }); return; } try { const entry = this.pool.require(req.params.id); const result = await this.pool.walkTest(entry.id, body.direction, body.seconds); res.status(result.ok ? 200 : 409).json(result); } catch (err) { this._error(res, err); } }); // 控制台 / 聊天框操作:都要求该机器人已完成游戏内登录验证 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(req.query || {}) }); }); 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; const center = this._bigMapCenter({ query: { bot: body.bot } }, body); const rangeBlocks = Number.isFinite(Number(body.rangeBlocks)) ? Number(body.rangeBlocks) : undefined; // 爬取可能持续数十秒:立刻返回,进度通过 /api/bigmap 轮询 this.bigMap.crawlAll(zoom, { force: body.force === true, centerBlockX: center.x, centerBlockZ: center.z, rangeBlocks }).catch((err) => { this.logger.log('大地图爬取失败:' + (err && err.message ? err.message : String(err)), 'error'); }); res.json({ ok: true, message: `已开始爬取 ${Math.round(center.x)},${Math.round(center.z)} 周边的瓦片`, data: this.bigMap.snapshot({ centerBlockX: center.x, centerBlockZ: center.z, rangeBlocks }) }); }); 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', req.query.bot) }); } catch (err) { res.status(500).json({ ok: false, message: err.message }); } }); app.post('/api/map/refresh', (req, res) => { this.dataService.refreshAsync(); res.json({ ok: true, message: '已触发地图刷新' }); }); app.post('/api/api/fetch-all', async (req, res) => { const result = await this.dataService.fetchAllTerritoryOnce(); res.json({ ok: result.ok, message: result.message }); }); // -------- SimpCore 开放 API(只读查询,全部按需手动触发)-------- // // 官方文档里的接口远不止「邦国领土」那一类,这里把公开信息完整暴露给控制台。 // 注意限流是按 Key × 接口算的,因此客户端侧有 5 分钟缓存,前端也不做轮询。 app.get('/api/simp/status', (req, res) => { res.json({ ok: true, data: this.dataService.apiSnapshot() }); }); app.get('/api/simp/health', (req, res) => { this._simp(res, async () => ({ reachable: await this.dataService.apiClient.healthCheck() })); }); app.get('/api/simp/unions', (req, res) => { this._simp(res, async () => { const unions = await this.dataService.apiClient.getAllUnions(); const search = String((req.query && req.query.search) || '').trim().toLowerCase(); let list = Array.from(unions.values()); if (search) { list = list.filter((union) => union.name.toLowerCase().includes(search) || (union.leader || '').toLowerCase().includes(search)); } list.sort((a, b) => b.chunkCount - a.chunkCount); return list; }); }); app.get('/api/simp/unions/:key', (req, res) => { this._simp(res, async () => { const client = this.dataService.apiClient; const union = await client.getUnion(req.params.key); if (!union) return null; return Object.assign({}, union, { territory: await client.getUnionTerritoryStats(req.params.key) }); }); }); app.get('/api/simp/unions/:key/members', (req, res) => { this._simp(res, () => this.dataService.apiClient.getUnionMembers(req.params.key)); }); app.get('/api/simp/unions/:key/tech', (req, res) => { this._simp(res, () => this.dataService.apiClient.getUnionTech(req.params.key)); }); // 捐献记录只有 player_uuid,这里补上玩家名(先用成员表,查不到再退到全服玩家表) app.get('/api/simp/unions/:key/contributions', (req, res) => { this._simp(res, async () => { const client = this.dataService.apiClient; const limit = toPositiveInt(req.query && req.query.limit, 100); const contributions = await client.getUnionContributions(req.params.key, limit); if (contributions.length === 0) return contributions; const names = new Map(); const members = await client.getUnionMembers(req.params.key); for (const member of members) if (member.uuid) names.set(member.uuid, member.name); const unknown = [...new Set(contributions.map((c) => c.uuid).filter((uuid) => uuid && !names.has(uuid)))]; if (unknown.length > 0) { const all = await client.getPlayerNameMap(); for (const [uuid, name] of all) if (!names.has(uuid)) names.set(uuid, name); } return contributions.map((c) => Object.assign({}, c, { name: names.get(c.uuid) || '' })); }); }); app.get('/api/simp/unions/:key/territory', (req, res) => { this._simp(res, () => this.dataService.apiClient.getUnionTerritoryStats(req.params.key)); }); app.get('/api/simp/players', (req, res) => { this._simp(res, async () => { const players = await this.dataService.apiClient.getPlayers(); const search = String((req.query && req.query.search) || '').trim().toLowerCase(); const filtered = search ? players.filter((p) => p.name.toLowerCase().includes(search) || (p.unionName || '').toLowerCase().includes(search)) : players; const limit = toPositiveInt(req.query && req.query.limit, 300); return filtered.slice(0, limit); }); }); app.get('/api/simp/players/:key', (req, res) => { this._simp(res, async () => { const client = this.dataService.apiClient; const player = await client.getPlayer(req.params.key); if (!player) return null; return Object.assign({}, player, { union: await client.getPlayerUnion(req.params.key) }); }); }); // 全服数据:buffs / diplomacy / bans / logs / announcements app.get('/api/simp/global/:kind', (req, res) => { const kind = String(req.params.kind || ''); const limit = toPositiveInt(req.query && req.query.limit, 100); const handlers = { buffs: () => this.dataService.apiClient.getBuffs(), diplomacy: () => this.dataService.apiClient.getDiplomacy(), bans: () => this.dataService.apiClient.getBans(limit), logs: () => this.dataService.apiClient.getLogs(limit), announcements: () => this.dataService.apiClient.getAnnouncements(limit) }; const handler = handlers[kind]; if (!handler) { res.status(400).json({ ok: false, message: '不支持的全服数据类型:' + kind }); return; } this._simp(res, handler); }); // -------- SimpCore API-V2(ServerOpenAPI,独立密钥体系,配额很小)-------- app.get('/api/simp/v2/online', (req, res) => { this._simpV2(res, () => this.dataService.apiV2Client.getOnlinePlayers()); }); app.get('/api/simp/v2/equipment/:name', (req, res) => { this._simpV2(res, () => this.dataService.apiV2Client.getPlayerEquipment(req.params.name)); }); app.get('/api/simp/v2/time', (req, res) => { this._simpV2(res, () => this.dataService.apiV2Client.getGameTime()); }); app.get('/api/simp/v2/dragon', (req, res) => { this._simpV2(res, () => this.dataService.apiV2Client.getDragon()); }); // -------- 单机器人兼容接口(等价于默认机器人的 /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; } 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) => { 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) }); } /** * 统一包装 SimpCore API 查询:把「未启用 / 未配 Key / 上游错误」翻译成可读提示。 * * @param {import('express').Response} res * @param {() => Promise<*>} handler */ _simp(res, handler) { if (!this.dataService.apiClient.hasApiKey()) { res.status(400).json({ ok: false, message: '未配置 SimpCore API Key(配置 → 数据 → API Key,或在 .env 设置 SIMP_API_KEY)' }); return; } Promise.resolve() .then(handler) .then((data) => { const error = this.dataService.apiClient.lastError(); if (data === null || (Array.isArray(data) && data.length === 0 && error)) { res.status(502).json({ ok: false, message: error || '查询无结果', data: data === null ? null : data }); return; } res.json({ ok: true, data, status: this.dataService.apiClient.snapshot() }); }) .catch((err) => this._error(res, err)); } /** * 统一包装 API-V2 查询。V2 需要单独申请的密钥,未配置时给出明确提示, * 避免用户以为是同一个 Key 没生效。 * * @param {import('express').Response} res * @param {() => Promise<*>} handler */ _simpV2(res, handler) { if (!this.dataService.apiV2Client.hasApiKey()) { res.status(400).json({ ok: false, message: '未配置 SimpCore API-V2 Key。注意 API 与 API-V2 是两套独立密钥体系,' + '需要在密钥中心分别申请(配置 → 数据 → API-V2 Key,或在 .env 设置 SIMP_API_V2_KEY)' }); return; } Promise.resolve() .then(handler) .then((data) => { const error = this.dataService.apiV2Client.lastError(); if (data === null || (Array.isArray(data) && data.length === 0 && error)) { res.status(502).json({ ok: false, message: error || '查询无结果', data: data === null ? null : data }); return; } res.json({ ok: true, data, status: this.dataService.apiV2Client.snapshot() }); }) .catch((err) => this._error(res, 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.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' && header) return header; if (req.url) { const query = req.url.includes('?') ? req.url.slice(req.url.indexOf('?') + 1) : ''; const params = new URLSearchParams(query); const fromQuery = params.get('token'); if (fromQuery) return fromQuery; } 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: 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.bigMapPayload() : null, serverTime: Date.now(), claimTime: { start: this.getConfig().claimStartHour, end: this.getConfig().claimEndHour, enforce: this.getConfig().enforceClaimTime } }; } /** @returns {Array} 机器人列表快照 */ botsPayload() { return this.pool.snapshot(); } /** * 组装地图数据。 * * 区块一律编码为扁平的 `[x1, z1, x2, z2, ...]` 数组,减少 JSON 体积。 * * @param {boolean} full 是否包含全部邦国的区块(体积大,按需请求) * @param {string} [botId] 以哪个机器人为视角(缺省取默认机器人) * @returns {object} */ mapPayload(full, botId) { const model = this.dataService.getModel(); 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: self ? flattenChunks(model.flyingChunksOf(selfName)) : [], target: controller ? controller.target : null, playerChunk: controller ? controller.playerChunk : null, states: [], full }; for (const state of model.statesIter()) { const bounds = boundsOf(state.chunks); result.states.push({ name: state.name, color: '#' + (state.color & 0xffffff).toString(16).padStart(6, '0'), capital: state.capital, count: state.chunks.size, bounds }); } result.states.sort((a, b) => b.count - a.count); if (full) { result.otherChunks = []; for (const state of model.statesIter()) { if (state.name === selfName) continue; const flat = flattenChunks(state.chunks); result.otherChunks.push({ name: state.name, color: '#' + (state.color & 0xffffff).toString(16).padStart(6, '0'), chunks: flat }); } } return result; } /** * 计算大地图的关注中心。 * * 默认以「当前选中的机器人」所在位置为中心 —— 大地图的意义就是看自己机器人周边, * 而不是永远盯着世界原点 (0,0)。没有可用坐标时回退到原点。 * * @param {import('express').Request} req * @param {{centerX?: number, centerZ?: number}} [override] 显式指定的中心优先 * @returns {{x: number, z: number, source: string}} */ _bigMapCenter(req, override) { const opts = override || {}; if (Number.isFinite(Number(opts.centerX)) && Number.isFinite(Number(opts.centerZ))) { return { x: Number(opts.centerX), z: Number(opts.centerZ), source: 'explicit' }; } const follow = !this.bigMap || !this.bigMap.config || !this.bigMap.config.bigMap || this.bigMap.config.bigMap.followBot !== false; if (follow) { const requested = req && req.query ? req.query.bot : undefined; const entry = this.pool.get(requested) || this.defaultEntry; const position = entry ? entry.manager.snapshot().position : null; if (position) return { x: position.x, z: position.z, source: 'bot' }; } return { x: 0, z: 0, source: 'origin' }; } /** @returns {object} 大地图元信息(坐标系、瓦片范围、缓存进度、机器人与领土) */ bigMapPayload(query) { const params = query || {}; const center = this._bigMapCenter({ query: params }); const rangeBlocks = Number.isFinite(Number(params.rangeBlocks)) ? Number(params.rangeBlocks) : undefined; const snapshot = this.bigMap.snapshot({ centerBlockX: center.x, centerBlockZ: center.z, rangeBlocks }); return Object.assign(snapshot, { center: { x: center.x, z: center.z, source: center.source }, 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} */ snapshot() { const entry = this.get(); if (!entry) return []; return [Object.assign({}, entry.manager.snapshot(), { task: entry.controller.snapshot() })]; } /** @returns {Array} */ 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 text = String(command || '').replace(/^\//, '').trim(); if (!text) return { ok: false, message: '指令为空' }; if (!entry.manager.canSendCommand(text)) { const manager = entry.manager; if (manager.state !== 'online' || !manager.bot) { return { ok: false, message: `当前未连接(状态:${manager.state}),指令未发送` }; } return { ok: false, message: `未完成登录验证,已拒绝发送 /${text}` }; } const ok = entry.manager.sendCommand(text); return { ok, message: ok ? '已发送:' + command : '发送失败' }; } /** @param {string} id @param {string} message */ sendChat(id, message) { const entry = this.require(id); const text = String(message || '').trim(); if (!text) return { ok: false, kind: 'chat', message: '内容为空' }; const isCommand = text.startsWith('/'); const kind = isCommand ? 'command' : 'chat'; if (!entry.manager.canSendChat()) { return { ok: false, kind, message: '未完成登录验证,已拒绝发送' }; } const ok = entry.manager.sendChat(text); return { ok, kind, message: ok ? (isCommand ? '指令已发送:' + text : '消息已发送') : '发送失败' }; } } /** * 把区块键集合压平成 `[x1, z1, x2, z2, ...]`。 * @param {Set} keys * @returns {number[]} */ function flattenChunks(keys) { const out = new Array(keys.size * 2); let i = 0; for (const k of keys) { const pos = chunkPos.decode(k); out[i++] = pos.x; out[i++] = pos.z; } return out; } /** * 计算区块集合的外接矩形。 * @param {Set} keys * @returns {{minX: number, maxX: number, minZ: number, maxZ: number}|null} */ function boundsOf(keys) { if (keys.size === 0) return null; let minX = Infinity; let maxX = -Infinity; let minZ = Infinity; let maxZ = -Infinity; for (const k of keys) { const pos = chunkPos.decode(k); if (pos.x < minX) minX = pos.x; if (pos.x > maxX) maxX = pos.x; if (pos.z < minZ) minZ = pos.z; if (pos.z > maxZ) maxZ = pos.z; } return { minX, maxX, minZ, maxZ }; } /** * 定时安全字符串比较,避免令牌比较的时序侧信道。 * @param {string} a * @param {string} b * @returns {boolean} */ function timingSafeEqual(a, b) { if (typeof a !== 'string' || typeof b !== 'string') return false; if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); return diff === 0; } /** 查询参数转正整数 */ 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 };