文件
BotStore/src/web/server.js
T
lyizai b9cae1aac2 feat: 接齐 SimpCore 开放 API 全量接口并新增 API 数据浏览弹层
以前只用了「邦国领土」这一类接口,控制台也只能看到一行「已启用/未启用」,
大量服务端公开信息拿得到却看不到。现在按官方文档 https://open.bgjq.top/api-docs.html 补齐。

新增
- SimpApiClient 从 7 个接口补到 16 个:/info/players、/info/unions/{key}/members、
  /tech、/contributions、/info/buffs、/info/diplomacy、/info/bans、/info/logs、
  /info/announcements
- SimpApiV2Client(新):/players、/players/{name}/equipment、/time、/dragon。
  文档明确 API 与 API-V2 是两套独立、互不通用的密钥体系,因此单独实例与配置
  (apiV2Enabled / apiV2Key,或环境变量 SIMP_API_V2_KEY);V2 配额很小
  (末影龙 1 次/分钟),一律按需查询、不做轮询
- 控制台「查看 API 数据」弹层,五个页签:
  概览(两套密钥状态/请求数/缓存/健康检查)· 邦国(27 个,点开看成员/科技/捐献/领土统计)
  · 玩家(1600+ 可搜索,点开看详情与所属邦国)· 全服(加成/外交/封禁/日志/公告)
  · 实时 V2(在线玩家/游戏时间/末影龙/装备)
- /api/simp/* 一整套只读代理接口,统一处理「未配 Key / 上游错误」并给出可读提示

修复(解析字段名以实测响应为准)
- 捐献:响应只有 player_uuid 没有玩家名 -> 先用成员表补全、查不到再退到全服玩家表;
  时间字段是 contributed_at;补充 reason(用途)列;amount 可为负(邦国支出)
- 公告:message 是 Minecraft 聊天组件 JSON 字符串 -> 新增 componentToText() 压平为纯文本
- 封禁:实际字段 banned_player_name / banned_by_player_name / banned_at / unbanned
- 日志:实际字段 username / action / timestamp
- 玩家表:上下线时间是 last_join_time / last_quit_time
- 王城坐标:服务端可能返回 null,原来 Number(null) 会算成 0,
  伪装成「王城在世界原点 (0,0)」;现在显式判空返回 null

变更
- 快捷发送去掉 /spawn 与 /list,只留 /u return、/u claimmode、/u restore confirm

测试 326 -> 336,全部通过。
2026-09-28 13:45:23 +08:00

1230 行
45 KiB
JavaScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
'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<import('ws').WebSocket>} */
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<object>} 机器人列表快照 */
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<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 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<number>} 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<number>} 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
};