From b9cae1aac2349c8d0bf7854a33dac7311483c32c Mon Sep 17 00:00:00 2001 From: lyizai <3945107889@qq.com> Date: Mon, 28 Sep 2026 13:45:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E9=BD=90=20SimpCore=20?= =?UTF-8?q?=E5=BC=80=E6=94=BE=20API=20=E5=85=A8=E9=87=8F=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=B9=B6=E6=96=B0=E5=A2=9E=20API=20=E6=95=B0=E6=8D=AE=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=BC=B9=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以前只用了「邦国领土」这一类接口,控制台也只能看到一行「已启用/未启用」, 大量服务端公开信息拿得到却看不到。现在按官方文档 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,全部通过。 --- CHANGELOG.md | 34 ++ README.md | 84 ++-- src/config/expansionConfig.js | 11 + src/core/simpApiClient.js | 583 ++++++++++++++++++++++++++- src/core/territoryDataService.js | 54 ++- src/index.js | 7 + src/web/public/app.js | 656 ++++++++++++++++++++++++++++++- src/web/public/index.html | 25 +- src/web/public/style.css | 68 ++++ src/web/server.js | 180 +++++++++ test/simpApiClient.test.js | 138 +++++++ 11 files changed, 1799 insertions(+), 41 deletions(-) create mode 100644 test/simpApiClient.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e413d5c..688484a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,40 @@ 本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [1.5.0] - 2026-09-28 + +### 新增 + +- **SimpCore 开放 API 数据浏览**:以前只用了「邦国领土」这一类接口,控制台也只能看到一行 + 「已启用 / 未启用」。现在把官方文档里的公开接口都接进来,并做成「查看 API 数据」弹层: + - **概览**:两套密钥的配置状态、请求数、缓存命中、最近 HTTP 状态与错误、健康检查按钮 + - **邦国**:27 个邦国的君主 / 成员数 / 区块数 / 领土面积 / 特产 / 王城区块; + 点开看成员(职位、职业)、已激活科技、捐献收支、领土统计(忠诚度、最早/最近占领) + - **玩家**:1600+ 名玩家可搜索(邦国 / 职位 / 职业 / 称号),点开看详情与所属邦国 + - **全服**:生效加成 / 外交关系 / 封禁记录 / 上下线日志 / 公告历史 + - **实时 V2**:在线玩家、游戏时间、末影龙、指定玩家的装备与手持物品 +- **`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 是两套独立、互不通用的密钥体系**,因此单独一个实例与配置; + V2 配额很小(末影龙仅 1 次/分钟),一律按需查询、不做轮询 + +### 变更 + +- 快捷发送去掉 `/spawn` 与 `/list`,只留 `/u return` `/u claimmode` `/u restore confirm` + +### 修复(解析字段名以实测响应为准) + +- 捐献记录:响应里**只有 `player_uuid` 没有玩家名**,现在先用邦国成员表、查不到再退到全服 + 玩家表补全;时间字段是 `contributed_at` 不是 `created_at`;补充 `reason`(用途)列 +- 公告:`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 + ## [1.4.1] - 2026-09-27 ### 变更 diff --git a/README.md b/README.md index fdb36d5..46371ae 100644 --- a/README.md +++ b/README.md @@ -137,11 +137,27 @@ squaremap 前端用 `L.CRS.Simple` + `tileSize: 512`,瓦片路径为 而错误假设(256 / 1024 方块每瓦片、整体偏移半张瓦片)分别为 76.0% / 47.3% / 41.6%, 即只有正确公式能让王城稳定落在陆地上。 +### 以机器人为中心 + 可调图幅 + +大地图的用途是看**自己机器人周边**,所以默认以机器人当前位置为中心(`bigMap.followBot`)。 +界面上的「图幅」下拉框决定半径: + +| 图幅 | 层级 0 需要的瓦片数 | +| --- | --- | +| ±512 格 | 1 | +| ±1024 格 | 1~4 | +| ±4096 格(默认) | 9 | +| ±8192 格 | 25 | +| 全世界(±20480) | 100 | + +层级 0 每张瓦片覆盖 4096 格,所以「只看周边」比「拉全世界」省一到两个数量级。 +点「爬取周边」只会爬当前图幅内的瓦片;「回到机器人」把视图拉回机器人所在位置。 + ### 缓存与爬取 | 层级 | 每瓦片方块数 | 全世界瓦片数 | 策略 | | --- | --- | --- | --- | -| 0 | 4096 | 10×10 = 100 | 启动时自动预爬取 | +| 0 | 4096 | 10×10 = 100 | 启动时自动预爬取(以机器人位置为中心,只爬当前图幅内) | | 1 | 2048 | 20×20 = 400 | 按需拉取 + 永久缓存 | | 2 | 1024 | 40×40 = 1600 | 按需拉取 + 永久缓存 | | 3 | 512 | 80×80 = 6400 | 按需拉取 + 永久缓存 | @@ -150,7 +166,47 @@ squaremap 前端用 `L.CRS.Simple` + `tileSize: 512`,瓦片路径为 - 瓦片由后端代理(`/api/bigmap/tile/:z/:x/:y`),避免浏览器直连地图站点的跨域问题 - 领土以 `markers.json` 的**原始多边形**绘制(预编译成 `Path2D`,按 even-odd 填充保留国中孔洞), 叠加本次会话新占领区块、王城与**全部机器人坐标(含名字标签)** -- 操作:滚轮缩放、拖拽平移、「全世界」/「回到机器人」一键定位、「爬取地图」手动触发预爬取 +- 操作:滚轮缩放、拖拽平移、图幅切换、「爬取周边」/「回到机器人」一键定位 + +--- + +## SimpCore 开放 API + +`src/core/simpApiClient.js` 对接 [SimpCore 开放 API](https://open.bgjq.top/api-docs.html) +(邦国崛起服务器的只读查询服务)。它既是占领判定的第四数据源,也把服务端的公开信息 +完整暴露到控制台。 + +### 文档里的接口基本都接了 + +| 分类 | 接口 | +| --- | --- | +| 健康 | `GET /health`(免鉴权) | +| 玩家 | `/info/players`、`/info/players/{key}`、`/info/players/{key}/union` | +| 邦国 | `/info/unions`、`/info/unions/{key}`、`/members`、`/territory`、`/tech`、`/contributions` | +| 全服 | `/info/territory`、`/info/buffs`、`/info/diplomacy`、`/info/bans`、`/info/logs`、`/info/announcements` | + +在左栏「领土数据源」点 **查看 API 数据** 打开浏览弹层,分五个页签:概览 / 邦国 / 玩家 / 全服 / 实时 V2。 +限流按「Key × 接口」每分钟计算,所以**全部按需点击加载 + 会话内缓存 + 服务端 5 分钟缓存**,绝不做轮询。 + +### API 与 API-V2 是两套独立的密钥 + +文档明确说明两者**互不通用的**: + +- **API**:`https://bgjq.simpfun.cn/api/` —— 邦国、领土、玩家、全服数据,配置 `apiKey`(或环境变量 `SIMP_API_KEY`) +- **API-V2**:`https://bgjq.simpfun.cn/api-v2/` —— 服务器实时状态(在线玩家 / 装备 / 游戏时间 / 末影龙), + 需要在密钥中心**另外申请**,配置 `apiV2Key`(或环境变量 `SIMP_API_V2_KEY`) + +V2 的接口级配额很小(末影龙只有 **1 次/分钟**),因此 V2 一律按需查询、不做自动轮询。 + +### 几个响应字段的坑(已按实测处理) + +| 接口 | 坑 | 处理 | +| --- | --- | --- | +| 捐献 | 只有 `player_uuid`,**没有玩家名** | 先用邦国成员表补全,查不到再退到全服玩家表 | +| 捐献 | `amount` 可为负 | 负数是邦国支出(如「恢复民心」消耗金锭/钻石),UI 注明 | +| 公告 | `message` 是**聊天组件 JSON 字符串** | `componentToText()` 压平成纯文本 | +| 王城 | 服务端可能返回 `null` | 显式判空,避免 `Number(null)` 算成 0 伪装成 (0,0) | +| 封禁 / 日志 | 字段名是 `banned_player_name` / `timestamp` 等 | 按实测字段解析 | --- @@ -479,29 +535,9 @@ npm start --- -## 大地图 +--- -`src/core/bigMapService.js` 爬取服务器地图站点(本项目部署的是 -[squaremap](https://github.com/jpenilla/squaremap))的瓦片图片与领土标记, -把所有机器人画在同一张图上。 - -### 以机器人为中心 + 可调图幅 - -大地图的用途是看**自己机器人周边**,所以默认以机器人当前位置为中心(`bigMap.followBot`)。 -界面上的「图幅」下拉框决定半径: - -| 图幅 | 层级 0 需要的瓦片数 | -| --- | --- | -| ±512 格 | 1 | -| ±1024 格 | 1~4 | -| ±4096 格(默认) | 9 | -| ±8192 格 | 25 | -| 全世界(±20480) | 100 | - -层级 0 每张瓦片覆盖 4096 格,所以「只看周边」比「拉全世界」省一到两个数量级。 -点「爬取周边」只会爬当前图幅内的瓦片;「回到机器人」把视图拉回机器人所在位置。 - -### REST / WebSocket 接口 +## REST / WebSocket 接口 | 方法 | 路径 | 说明 | | --- | --- | --- | diff --git a/src/config/expansionConfig.js b/src/config/expansionConfig.js index 6e3b684..0ea887a 100644 --- a/src/config/expansionConfig.js +++ b/src/config/expansionConfig.js @@ -116,6 +116,16 @@ function defaultConfig() { apiKey: '', /** API 领土数据轮询间隔(秒),最小 60 */ apiRefreshIntervalSeconds: 180, + /** + * 是否启用 SimpCore API-V2(ServerOpenAPI 插件)。 + * + * 注意:**API 与 API-V2 是两套独立、互不通用的密钥体系**,需要在密钥中心分别申请。 + * V2 用于查询服务器实时状态(在线玩家 / 装备 / 游戏时间 / 末影龙), + * 接口级配额很小(末影龙仅 1 次/分钟),因此不做自动轮询,只在控制台按需查询。 + */ + apiV2Enabled: false, + /** API-V2 Key(留空时回退读环境变量 SIMP_API_V2_KEY) */ + apiV2Key: '', // ==================== 寻路器专属(新增) ==================== pathing: { @@ -426,6 +436,7 @@ function validateConfig(config) { } if (typeof config.stateName !== 'string') config.stateName = ''; if (typeof config.apiKey !== 'string') config.apiKey = ''; + if (typeof config.apiV2Key !== 'string') config.apiV2Key = ''; if (typeof config.server.claimCommand !== 'string') config.server.claimCommand = ''; if (typeof config.server.loginCommand !== 'string') config.server.loginCommand = ''; if (typeof config.server.registerCommand !== 'string') config.server.registerCommand = ''; diff --git a/src/core/simpApiClient.js b/src/core/simpApiClient.js index 7858ca8..61107a4 100644 --- a/src/core/simpApiClient.js +++ b/src/core/simpApiClient.js @@ -6,24 +6,48 @@ const chunkPos = require('./chunkPos'); * SimpCore 开放 API 客户端(文档:https://open.bgjq.top/api-docs.html)。 * * 邦国崛起服务器的只读查询服务,作为占领判定的第四数据源 - * (游戏字幕、游戏消息、Dynmap JSON 之外的补充),提供: - * - 己方邦国领土区块明细(区块坐标、忠诚度、占领时间)——修正本地领土模型 - * - 全服领土区块(手动触发)——交叉修正他人区块归属 - * - 玩家所属邦国查询——辅助识别玩家邦国 - * - 邦国列表(含王城坐标)——王城数据兜底 + * (游戏字幕、游戏消息、Dynmap JSON 之外的补充),同时把服务端的公开信息 + * 完整暴露到控制台供人工查阅。 * - * 鉴权:`Authorization: Bearer `;限流:按 Key × 接口每分钟限流。 + * 已实现的接口(与官方文档一一对应): + * + * | 分类 | 接口 | 用途 | + * | --- | --- | --- | + * | 健康 | `GET /health` | 连通性测试(免鉴权) | + * | 玩家 | `GET /info/players` | 全部玩家(含邦国、职位、职业、称号) | + * | 玩家 | `GET /info/players/{key}` | 单个玩家详情 | + * | 玩家 | `GET /info/players/{key}/union` | 玩家所属邦国 | + * | 邦国 | `GET /info/unions` | 全部邦国(君主、成员数、区块数、面积、特产) | + * | 邦国 | `GET /info/unions/{key}` | 单个邦国详情 | + * | 邦国 | `GET /info/unions/{key}/members` | 成员列表(职位 / 职业) | + * | 邦国 | `GET /info/unions/{key}/territory` | 领土区块明细(忠诚度、占领时间) | + * | 邦国 | `GET /info/unions/{key}/tech` | 已激活科技 | + * | 邦国 | `GET /info/unions/{key}/contributions` | 捐献记录 | + * | 全服 | `GET /info/territory` | 全服领土(可按 union_id 过滤) | + * | 全服 | `GET /info/buffs` | 全服生效加成 | + * | 全服 | `GET /info/diplomacy` | 邦国外交关系 | + * | 全服 | `GET /info/bans` | 封禁记录 | + * | 全服 | `GET /info/logs` | 玩家上下线日志 | + * | 全服 | `GET /info/announcements` | 公告历史 | + * + * 鉴权:`Authorization: Bearer `(也支持 `X-API-Key`);限流:按 Key × 接口每分钟限流。 * 全服数据类接口限流较严且数据量大(实测全服 territory 单次请求可超 2 分钟), - * 因此本客户端**只适合低频调用**:自动轮询仅用单邦国轻接口,全服接口仅手动触发。 + * 因此本客户端**只适合低频调用**:自动轮询仅用单邦国轻接口,其余全部按需手动触发。 * 服务端响应带缓存,数据最多有 1 小时延迟。 */ /** API 服务基础地址 */ const BASE_URL = 'https://bgjq.simpfun.cn/api/'; +/** API-V2(ServerOpenAPI 插件,独立密钥体系)服务基础地址 */ +const BASE_URL_V2 = 'https://bgjq.simpfun.cn/api-v2/'; + /** 默认缓存 TTL:5 分钟。服务端数据本身最多延迟 1 小时,更短的 TTL 意义不大 */ const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000; +/** API-V2 的接口级配额很小(最低 1 QPM),缓存给长一点 */ +const DEFAULT_CACHE_TTL_V2_MS = 30 * 1000; + class SimpApiClient { /** * @param {import('../util/logger').Logger} [logger] @@ -42,6 +66,10 @@ class SimpApiClient { this._lastHttpStatus = 0; /** 最近一次请求失败的错误描述(空串表示无错误) */ this._lastError = ''; + /** 累计请求数(含缓存命中统计,供控制台展示用量) */ + this._requestCount = 0; + /** 缓存命中次数 */ + this._cacheHits = 0; } /** @@ -187,6 +215,265 @@ class SimpApiClient { }; } + // ==================== 查询接口(补充:官方文档里的其余接口) ==================== + + /** + * 全部玩家:`GET /info/players`。 + * 实测字段:`uuid, username, union_id, union_name, role, profession, profession_level, + * title, manual_claiming, first_join_time, last_join_time, last_quit_time` + * + * @returns {Promise>} + */ + async getPlayers() { + const body = await this._getCachedOrFetch('info/players?limit=1000'); + if (body === null) return []; + return parseArray(body).map((element) => ({ + name: stringOr(element, 'username', ''), + uuid: stringOr(element, 'uuid', ''), + unionId: intOr(element, 'union_id', -1), + unionName: stringOr(element, 'union_name', ''), + role: stringOr(element, 'role', ''), + job: stringOr(element, 'profession', ''), + jobLevel: intOr(element, 'profession_level', 0), + title: stringOr(element, 'title', ''), + firstJoinAt: stringOr(element, 'first_join_time', ''), + lastJoinAt: stringOr(element, 'last_join_time', ''), + lastQuitAt: stringOr(element, 'last_quit_time', '') + })).filter((player) => player.name); + } + + /** + * 邦国成员列表:`GET /info/unions/{key}/members`。 + * + * 实测字段:`uuid, username, union_id, union_name, role, profession, profession_level, + * title, manual_claiming, union_action_cooldown, first_join_time, last_join_time, last_quit_time` + * + * @param {string|number} key + * @returns {Promise>} + */ + async getUnionMembers(key) { + const body = await this._getCachedOrFetch(`info/unions/${encodeURIComponent(String(key))}/members`); + if (body === null) return []; + return parseArray(body).map((element) => ({ + name: stringOr(element, 'username', ''), + uuid: stringOr(element, 'uuid', ''), + role: stringOr(element, 'role', ''), + job: stringOr(element, 'profession', ''), + jobLevel: intOr(element, 'profession_level', 0), + title: stringOr(element, 'title', ''), + firstJoinAt: stringOr(element, 'first_join_time', ''), + lastJoinAt: stringOr(element, 'last_join_time', ''), + lastQuitAt: stringOr(element, 'last_quit_time', '') + })).filter((member) => member.name); + } + + /** + * 邦国已激活(未过期)科技:`GET /info/unions/{key}/tech`。 + * 实测多数邦国返回空数组(当前没有生效中的科技)。 + * @param {string|number} key + * @returns {Promise>} + */ + async getUnionTech(key) { + const body = await this._getCachedOrFetch(`info/unions/${encodeURIComponent(String(key))}/tech`); + if (body === null) return []; + return parseArray(body).map((element) => ({ + name: stringOr(element, 'name', stringOr(element, 'buff_name', stringOr(element, 'tech_name', ''))), + effect: stringOr(element, 'effect', stringOr(element, 'description', '')), + level: intOr(element, 'level', intOr(element, 'buff_level', 0)), + expiresAt: stringOr(element, 'expires_at', stringOr(element, 'expire_at', '')) + })); + } + + /** + * 邦国捐献记录:`GET /info/unions/{key}/contributions?limit=N`。 + * + * **实测响应里只有 `player_uuid`,没有玩家名**;数量可以为负(负数代表支出, + * 例如「恢复民心」消耗金锭 / 钻石),并带 `reason` 说明用途。 + * + * @param {string|number} key + * @param {number} [limit] + * @returns {Promise>} + */ + async getUnionContributions(key, limit) { + const path = `info/unions/${encodeURIComponent(String(key))}/contributions?limit=${normalizeLimit(limit)}`; + const body = await this._getCachedOrFetch(path); + if (body === null) return []; + return parseArray(body).map((element) => ({ + uuid: stringOr(element, 'player_uuid', stringOr(element, 'uuid', '')), + material: stringOr(element, 'material', ''), + amount: round4(doubleOr(element, 'amount', 0)), + reason: stringOr(element, 'reason', ''), + createdAt: stringOr(element, 'contributed_at', stringOr(element, 'created_at', '')) + })); + } + + /** + * 全服玩家表(uuid → 玩家名)。 + * + * 捐献记录只有 UUID,要显示玩家名得查这张表;它数据量大(实测 1600+ 行)但有 5 分钟缓存, + * 因此只在确实有未知 UUID 时才懒加载一次。 + * + * @returns {Promise>} + */ + async getPlayerNameMap() { + if (this._nameMap) return this._nameMap; + const body = await this._getCachedOrFetch('info/players?limit=1000'); + const map = new Map(); + if (body !== null) { + for (const element of parseArray(body)) { + const uuid = stringOr(element, 'uuid', ''); + const name = stringOr(element, 'username', ''); + if (uuid && name) map.set(uuid, name); + } + } + this._nameMap = map; + return map; + } + + /** + * 全服生效加成:`GET /info/buffs`。 + * @returns {Promise>} + */ + async getBuffs() { + const body = await this._getCachedOrFetch('info/buffs'); + if (body === null) return []; + return parseArray(body).map((element) => ({ + unionId: intOr(element, 'union_id', -1), + unionName: stringOr(element, 'union_name', ''), + name: stringOr(element, 'name', stringOr(element, 'buff_name', '')), + effect: stringOr(element, 'effect', ''), + level: intOr(element, 'level', 0), + expiresAt: stringOr(element, 'expires_at', '') + })); + } + + /** + * 邦国外交关系:`GET /info/diplomacy`。 + * 实测字段:`grantor_union_id, grantor_name, grantee_union_id, grantee_name, permission_level` + * + * @returns {Promise>} + */ + async getDiplomacy() { + const body = await this._getCachedOrFetch('info/diplomacy'); + if (body === null) return []; + return parseArray(body).map((element) => ({ + grantor: stringOr(element, 'grantor_name', ''), + grantee: stringOr(element, 'grantee_name', ''), + level: stringOr(element, 'permission_level', stringOr(element, 'level', '')) + })); + } + + /** + * 封禁记录:`GET /info/bans?limit=N`。 + * 实测字段:`banned_player_name, banned_by_player_name, reason, banned_at, unbanned, unbanned_at` + * + * @param {number} [limit] + * @returns {Promise>} + */ + async getBans(limit) { + const body = await this._getCachedOrFetch(`info/bans?limit=${normalizeLimit(limit)}`); + if (body === null) return []; + return parseArray(body).map((element) => ({ + name: stringOr(element, 'banned_player_name', ''), + reason: stringOr(element, 'reason', ''), + operator: stringOr(element, 'banned_by_player_name', ''), + createdAt: stringOr(element, 'banned_at', ''), + unbanned: element.unbanned === true, + unbannedAt: stringOr(element, 'unbanned_at', '') + })); + } + + /** + * 玩家上下线等日志:`GET /info/logs?limit=N`。 + * 实测字段:`username, action(JOIN/QUIT), timestamp` + * + * @param {number} [limit] + * @returns {Promise>} + */ + async getLogs(limit) { + const body = await this._getCachedOrFetch(`info/logs?limit=${normalizeLimit(limit)}`); + if (body === null) return []; + return parseArray(body).map((element) => ({ + name: stringOr(element, 'username', ''), + action: stringOr(element, 'action', ''), + detail: stringOr(element, 'detail', ''), + createdAt: stringOr(element, 'timestamp', stringOr(element, 'created_at', '')) + })); + } + + /** + * 公告历史:`GET /info/announcements?limit=N`。 + * + * **实测 `message` 是 Minecraft 聊天组件的 JSON 字符串**(带 color / extra 等), + * 这里统一提取成纯文本再返回,免得前端拿到一串乱码。 + * + * @param {number} [limit] + * @returns {Promise>} + */ + async getAnnouncements(limit) { + const body = await this._getCachedOrFetch(`info/announcements?limit=${normalizeLimit(limit)}`); + if (body === null) return []; + return parseArray(body).map((element) => ({ + content: componentToText(stringOr(element, 'message', stringOr(element, 'content', ''))), + author: stringOr(element, 'author_name', ''), + createdAt: stringOr(element, 'created_at', '') + })); + } + + /** + * 邦国领土的统计摘要(不把几万个区块全丢给前端)。 + * @param {string|number} key + * @returns {Promise} + */ + async getUnionTerritoryStats(key) { + const chunks = await this.getUnionTerritory(key); + if (chunks.size === 0) return null; + let minX = Infinity; + let maxX = -Infinity; + let minZ = Infinity; + let maxZ = -Infinity; + let loyaltySum = 0; + let loyaltyCount = 0; + let earliest = ''; + let latest = ''; + for (const chunk of chunks.values()) { + if (chunk.x < minX) minX = chunk.x; + if (chunk.x > maxX) maxX = chunk.x; + if (chunk.z < minZ) minZ = chunk.z; + if (chunk.z > maxZ) maxZ = chunk.z; + if (chunk.loyalty >= 0) { + loyaltySum += chunk.loyalty; + loyaltyCount++; + } + if (chunk.lastClaimedAt) { + if (!earliest || chunk.lastClaimedAt < earliest) earliest = chunk.lastClaimedAt; + if (!latest || chunk.lastClaimedAt > latest) latest = chunk.lastClaimedAt; + } + } + return { + chunkCount: chunks.size, + blockArea: chunks.size * 256, + bounds: { minX, maxX, minZ, maxZ }, + avgLoyalty: loyaltyCount > 0 ? Math.round((loyaltySum / loyaltyCount) * 10) / 10 : null, + earliestClaimAt: earliest, + latestClaimAt: latest + }; + } + + /** @returns {object} 客户端状态快照(供控制台「API 数据」面板展示) */ + snapshot() { + return { + baseUrl: this.baseUrl, + hasKey: this.hasApiKey(), + lastHttpStatus: this._lastHttpStatus, + lastError: this._lastError, + requestCount: this._requestCount, + cacheHits: this._cacheHits, + cachedPaths: this.cache.size, + cacheTtlMs: this.cacheTtlMs + }; + } + // ==================== 网络与缓存底层 ==================== /** @@ -201,12 +488,16 @@ class SimpApiClient { const opts = options || {}; const entry = this.cache.get(path); const now = Date.now(); - if (entry && entry.expiresAt > now) return entry.body; + if (entry && entry.expiresAt > now) { + this._cacheHits++; + return entry.body; + } if (!this.hasApiKey()) { this._lastError = '未配置 API Key'; return null; } try { + this._requestCount++; const response = await this._request(path, { auth: true, timeoutMs: opts.timeoutMs || 30000 }); this._lastHttpStatus = response.status; if (response.status === 200) { @@ -252,6 +543,201 @@ class SimpApiClient { } } +// ==================== API-V2(ServerOpenAPI 插件,独立密钥体系) ==================== + +/** + * SimpCore API-V2 客户端。 + * + * 官方文档明确说明:**API 与 API-V2 是两套相互独立、互不通用的接口系统**, + * 需要在密钥中心分别申请。V2 由 ServerOpenAPI 插件提供,用于查询服务器**实时**状态: + * + * | 接口 | 说明 | 接口级限速 | + * | --- | --- | --- | + * | `GET /players` | 在线玩家列表 | 10 QPM | + * | `GET /players/{name}/equipment` | 玩家装备与手持物品 | 30 QPM | + * | `GET /time` | 游戏内时间 | 10 QPM | + * | `GET /dragon` | 末影龙是否存活 | **1 QPM** | + * + * 因为配额很小(末影龙接口每分钟只能问一次),这里**一律不做自动轮询**, + * 只在控制台里按需手动查询,并带 30 秒本地缓存兜底。 + */ +class SimpApiV2Client { + /** + * @param {import('../util/logger').Logger} [logger] + * @param {{baseUrl?: string, cacheTtlMs?: number}} [options] + */ + constructor(logger, options) { + const opts = options || {}; + this.logger = logger || null; + this.baseUrl = opts.baseUrl || BASE_URL_V2; + this.cacheTtlMs = opts.cacheTtlMs || DEFAULT_CACHE_TTL_V2_MS; + this.apiKey = ''; + this.cache = new Map(); + this._lastHttpStatus = 0; + this._lastError = ''; + this._requestCount = 0; + this._cacheHits = 0; + } + + /** @param {string} key */ + setApiKey(key) { + const fresh = key ? key.trim() : ''; + if (fresh !== this.apiKey) this.cache.clear(); + this.apiKey = fresh; + } + + /** @returns {boolean} */ + hasApiKey() { + return this.apiKey.length > 0; + } + + /** @returns {number} */ + lastHttpStatus() { + return this._lastHttpStatus; + } + + /** @returns {string} */ + lastError() { + return this._lastError; + } + + /** @returns {Promise>} 在线玩家列表(含位置、世界、延迟等) */ + async getOnlinePlayers() { + const body = await this._getCachedOrFetch('players'); + if (body === null) return []; + return parseArray(body).map((element) => ({ + name: stringOr(element, 'name', stringOr(element, 'username', '')), + world: stringOr(element, 'world', ''), + x: doubleOr(element, 'x', Number.NaN), + y: doubleOr(element, 'y', Number.NaN), + z: doubleOr(element, 'z', Number.NaN), + ping: intOr(element, 'ping', -1), + gameMode: stringOr(element, 'gamemode', stringOr(element, 'game_mode', '')) + })).filter((player) => player.name); + } + + /** + * 玩家装备与手持物品:`GET /players/{name}/equipment`。 + * @param {string} name + * @returns {Promise} + */ + async getPlayerEquipment(name) { + const body = await this._getCachedOrFetch(`players/${encodeURIComponent(String(name))}/equipment`); + const object = body === null ? null : parseObject(body); + if (!object) return null; + const mainHand = object.main_hand || object.mainHand || null; + const offHand = object.off_hand || object.offHand || null; + const armor = object.armor || {}; + return { + player: stringOr(object, 'name', String(name)), + mainHand: itemOf(mainHand), + offHand: itemOf(offHand), + helmet: itemOf(armor.helmet), + chestplate: itemOf(armor.chestplate), + leggings: itemOf(armor.leggings), + boots: itemOf(armor.boots) + }; + } + + /** @returns {Promise} 游戏内时间 */ + async getGameTime() { + const body = await this._getCachedOrFetch('time'); + const object = body === null ? null : parseObject(body); + if (!object) return null; + return { + worldTime: longOr(object, 'world_time', longOr(object, 'time', 0)), + dayTime: longOr(object, 'day_time', longOr(object, 'daytime', 0)), + day: intOr(object, 'day', -1), + isDay: object.is_day === undefined ? null : object.is_day === true + }; + } + + /** @returns {Promise} 末影龙状态(接口级限速 1 QPM,务必按需调用) */ + async getDragon() { + const body = await this._getCachedOrFetch('dragon'); + const object = body === null ? null : parseObject(body); + if (!object) return null; + return { + alive: object.alive === true, + health: doubleOr(object, 'health', Number.NaN), + phase: stringOr(object, 'phase', '') + }; + } + + /** @returns {object} 状态快照 */ + snapshot() { + return { + baseUrl: this.baseUrl, + hasKey: this.hasApiKey(), + lastHttpStatus: this._lastHttpStatus, + lastError: this._lastError, + requestCount: this._requestCount, + cacheHits: this._cacheHits, + cachedPaths: this.cache.size, + cacheTtlMs: this.cacheTtlMs + }; + } + + /** 与 v1 相同的「缓存优先 + 鉴权 + 错误归类」逻辑 */ + async _getCachedOrFetch(path) { + const entry = this.cache.get(path); + const now = Date.now(); + if (entry && entry.expiresAt > now) { + this._cacheHits++; + return entry.body; + } + if (!this.hasApiKey()) { + this._lastError = '未配置 API-V2 Key'; + return null; + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 20000); + try { + this._requestCount++; + const response = await fetch(this.baseUrl + path, { + headers: { 'User-Agent': 'AutoStore-Node/1.0', Authorization: 'Bearer ' + this.apiKey }, + signal: controller.signal + }); + const text = await response.text(); + this._lastHttpStatus = response.status; + if (response.status === 200) { + this.cache.set(path, { body: text, expiresAt: now + this.cacheTtlMs }); + this._lastError = ''; + return text; + } + if (response.status === 401) { + this._lastError = 'API-V2 Key 无效(401)——注意 V2 需要单独申请密钥'; + this.cache.clear(); + } else if (response.status === 429) { + this._lastError = '触发限流(429):V2 接口配额很小(末影龙仅 1 次/分钟),请稍后重试'; + } else { + this._lastError = 'HTTP ' + response.status; + } + } catch (err) { + this._lastError = err && err.message ? err.message : String(err); + } finally { + clearTimeout(timer); + } + return null; + } +} + +/** + * 解析装备/物品对象为统一结构。 + * @param {object|null} item + * @returns {object|null} + */ +function itemOf(item) { + if (!item || typeof item !== 'object') return null; + return { + name: stringOr(item, 'name', stringOr(item, 'type', '')), + displayName: stringOr(item, 'display_name', ''), + count: intOr(item, 'count', 1), + durability: intOr(item, 'durability', -1), + maxDurability: intOr(item, 'max_durability', -1) + }; +} + // ==================== JSON 解析(字段名以实测响应为准) ==================== /** @@ -263,8 +749,8 @@ function parseUnionSummary(element) { if (!element || typeof element !== 'object') return null; const name = stringOr(element, 'name', ''); if (!name) return null; - const capitalX = doubleOr(element, 'capital_x', Number.NaN); - const capitalZ = doubleOr(element, 'capital_z', Number.NaN); + const capitalX = optionalNumber(element, 'capital_x'); + const capitalZ = optionalNumber(element, 'capital_z'); return { id: intOr(element, 'id', -1), name, @@ -273,12 +759,30 @@ function parseUnionSummary(element) { chunkCount: intOr(element, 'territory_chunk_count', 0), blockArea: longOr(element, 'territory_block_area', 0), specialOutput: stringOr(element, 'special_output_material', ''), - capital: Number.isNaN(capitalX) || Number.isNaN(capitalZ) + // 王城坐标可能为 null(文档明确「空值输出 null」),此时不能换算成 (0,0) 假装有效 + capital: capitalX === null || capitalZ === null ? null : chunkPos.fromBlock(Math.floor(capitalX), Math.floor(capitalZ)) }; } +/** + * 取一个可能为 null 的数值字段。 + * + * 直接用 `Number(null)` 会得到 0,把「没有王城」误判成「王城在 (0,0)」—— + * 而 (0,0) 正好是世界原点,看起来完全像个合法坐标,排查起来很坑。 + * + * @param {object} object + * @param {string} key + * @returns {number|null} + */ +function optionalNumber(object, key) { + const value = object[key]; + if (value === undefined || value === null || value === '') return null; + const n = Number(value); + return Number.isFinite(n) ? n : null; +} + /** * 解析领土区块。实测字段:`id, x, z, union_id, union_name, last_claimed_at, loyalty` */ @@ -338,10 +842,65 @@ function doubleOr(object, key, fallback) { return Number.isFinite(value) ? value : fallback; } +/** + * 把 Minecraft 聊天组件提取成纯文本。 + * + * 公告接口返回的 `message` 是组件 JSON(形如 `{"color":"gold","extra":[{"text":"公告: "}, ...]}`), + * 直接显示会是一串带转义符的 JSON,必须先压平。 + * + * @param {string} raw + * @returns {string} + */ +function componentToText(raw) { + const text = String(raw || ''); + if (!text) return ''; + const walk = (value) => { + if (typeof value === 'string') return value; + if (Array.isArray(value)) return value.map(walk).join(''); + if (value && typeof value === 'object') { + const parts = []; + if (typeof value.text === 'string') parts.push(value.text); + if (value.translate && typeof value.translate === 'string') parts.push(value.translate); + for (const key of ['extra', 'with', 'children']) { + if (value[key] !== undefined) parts.push(walk(value[key])); + } + return parts.join(''); + } + return ''; + }; + // 先尝试按 JSON 解析(公告字段是 JSON 字符串,不是对象) + try { + return walk(JSON.parse(text)); + } catch { + return text; + } +} + +/** + * 把 limit 参数钳制到 1..1000(文档默认 100)。 + * @param {*} value + * @returns {number} + */ +function normalizeLimit(value) { + const n = Number(value); + if (!Number.isFinite(n)) return 100; + return Math.min(1000, Math.max(1, Math.trunc(n))); +} + +/** 保留 4 位小数,避免捐献数量出现 -0.5520000000000004 这种浮点尾巴 */ +function round4(value) { + return Math.round(value * 10000) / 10000; +} + module.exports = { SimpApiClient, + SimpApiV2Client, BASE_URL, + BASE_URL_V2, DEFAULT_CACHE_TTL_MS, + DEFAULT_CACHE_TTL_V2_MS, parseUnionSummary, - parseTerritoryChunk + parseTerritoryChunk, + normalizeLimit, + componentToText }; diff --git a/src/core/territoryDataService.js b/src/core/territoryDataService.js index 5f4bb68..f37a54b 100644 --- a/src/core/territoryDataService.js +++ b/src/core/territoryDataService.js @@ -4,7 +4,7 @@ const crypto = require('crypto'); const chunkPos = require('./chunkPos'); const { TerritoryModel } = require('./territoryModel'); -const { SimpApiClient } = require('./simpApiClient'); +const { SimpApiClient, SimpApiV2Client } = require('./simpApiClient'); /** * 领土数据服务:定时从远程地图服务拉取邦国领土数据(Dynmap markers.json), @@ -71,6 +71,13 @@ class TerritoryDataService { /** @type {SimpApiClient} SimpCore 开放 API 客户端(第四占领判定数据源) */ this.apiClient = new SimpApiClient(logger); + /** + * @type {SimpApiV2Client} SimpCore API-V2 客户端(实时状态查询) + * + * 与上面那套是**互相独立的两套密钥体系**,所以单独一个实例、单独一个 Key。 + * 只在控制台按需查询,不参与自动轮询。 + */ + this.apiV2Client = new SimpApiV2Client(logger); /** API 最近一次成功拉取的 邦国名 → 区块键集合 */ this.apiTerritory = new Map(); /** API 最近一次成功拉取的 邦国名 → 王城区块 */ @@ -79,6 +86,8 @@ class TerritoryDataService { this.lastApiSuccessMillis = 0; /** API 轮询间隔(秒);0 表示未启用 */ this._apiPollIntervalSeconds = 0; + /** API-V2 是否启用 */ + this.apiV2Enabled = false; this._refreshTimer = null; this._apiTimer = null; @@ -134,6 +143,25 @@ class TerritoryDataService { this._rescheduleApi(); } + /** + * 配置 API-V2(ServerOpenAPI)。 + * + * V2 的接口级配额极小(末影龙 1 次/分钟),因此**只设置 Key,不做任何自动轮询**, + * 全部由控制台按需触发。 + * + * @param {boolean} enabled + * @param {string} apiKey + */ + applyApiV2Config(enabled, apiKey) { + this.apiV2Enabled = !!enabled && !!(apiKey && apiKey.trim()); + this.apiV2Client.setApiKey(this.apiV2Enabled ? apiKey : ''); + } + + /** @returns {boolean} API-V2 是否已启用(开关打开且配了 Key) */ + isApiV2Enabled() { + return this.apiV2Enabled === true && this.apiV2Client.hasApiKey(); + } + /** 按 _apiPollIntervalSeconds 重新调度 API 轮询(≤0 表示停用) */ _rescheduleApi() { if (this._apiTimer) clearInterval(this._apiTimer); @@ -390,7 +418,29 @@ class TerritoryDataService { apiLastSuccessMillis: this.lastApiSuccessMillis, apiError: this.apiClient.lastError(), apiHttpStatus: this.apiClient.lastHttpStatus(), - apiTerritoryCount: this.apiTerritory.size + apiTerritoryCount: this.apiTerritory.size, + apiV2Enabled: this.isApiV2Enabled(), + apiV2Error: this.apiV2Client.lastError(), + apiV2HttpStatus: this.apiV2Client.lastHttpStatus() + }; + } + + /** + * API 相关状态快照(供控制台「API 数据」面板展示用量与配额情况)。 + * @returns {object} + */ + apiSnapshot() { + return { + v1: Object.assign(this.apiClient.snapshot(), { + enabled: this.isApiEnabled(), + pollIntervalSeconds: this._apiPollIntervalSeconds, + lastSuccessMillis: this.lastApiSuccessMillis, + territoryUnions: this.apiTerritory.size, + capitalUnions: this.apiCapitals.size + }), + v2: Object.assign(this.apiV2Client.snapshot(), { enabled: this.isApiV2Enabled() }), + /** 官方文档说明:API 与 API-V2 是两套独立、互不通用的密钥体系 */ + note: 'API 与 API-V2 是两套独立密钥体系,需在密钥中心分别申请;列表类接口支持 limit,响应带最多 1 小时缓存延迟' }; } } diff --git a/src/index.js b/src/index.js index f58627b..4dee78c 100644 --- a/src/index.js +++ b/src/index.js @@ -38,6 +38,11 @@ async function main() { config = normalizeConfig(config, config); // 环境变量里的 API Key 作为兜底(配置文件为空时) if (!config.apiKey && process.env.SIMP_API_KEY) config.apiKey = process.env.SIMP_API_KEY.trim(); + // API-V2 是独立的一套密钥体系,单独兜底 + if (!config.apiV2Key && process.env.SIMP_API_V2_KEY) { + config.apiV2Key = process.env.SIMP_API_V2_KEY.trim(); + config.apiV2Enabled = true; + } logger.log(`配置文件:${configStore.filePath}`); @@ -104,6 +109,7 @@ async function main() { pool.applyGlobalConfig(config); dataService.config = config; dataService.applyApiConfig(config.apiEnabled, config.apiKey, config.apiRefreshIntervalSeconds); + dataService.applyApiV2Config(config.apiV2Enabled, config.apiV2Key || process.env.SIMP_API_V2_KEY); dataService.reschedule(config.refreshIntervalSeconds); if (bigMap) { bigMap.config = config; @@ -115,6 +121,7 @@ async function main() { // ==================== 启动 ==================== dataService.applyApiConfig(config.apiEnabled, config.apiKey, config.apiRefreshIntervalSeconds); + dataService.applyApiV2Config(config.apiV2Enabled, config.apiV2Key || process.env.SIMP_API_V2_KEY); dataService.start(config.refreshIntervalSeconds); pool.start(); diff --git a/src/web/public/app.js b/src/web/public/app.js index ec02f1f..cdfd8a9 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -252,7 +252,9 @@ const CONFIG_SCHEMA = { fields: [ { path: 'apiEnabled', label: '启用 API 数据源', type: 'checkbox' }, { path: 'apiKey', label: 'API Key', type: 'password', hint: '留空则回退读环境变量 SIMP_API_KEY' }, - { path: 'apiRefreshIntervalSeconds', label: 'API 轮询间隔(秒)', type: 'number', min: 60 } + { path: 'apiRefreshIntervalSeconds', label: 'API 轮询间隔(秒)', type: 'number', min: 60 }, + { path: 'apiV2Enabled', label: '启用 API-V2(实时状态)', type: 'checkbox', hint: 'V2 是独立的一套密钥体系,需在密钥中心单独申请' }, + { path: 'apiV2Key', label: 'API-V2 Key', type: 'password', hint: '留空则回退读环境变量 SIMP_API_V2_KEY;V2 配额很小,不做自动轮询' } ] } ], @@ -746,6 +748,11 @@ function renderStatus(status) { $('btnExportExp').title = `累计 ${status.expansionLog.total} 条,今日 ${status.expansionLog.today} 条`; } + // ===== SimpCore API 状态(弹层标题栏一行概览)===== + if (status.data) { + updateSimpStatus(status.data); + } + // ===== 大地图爬取进度 ===== if (status.bigMap && status.bigMap.crawl) { const crawl = status.bigMap.crawl; @@ -917,6 +924,641 @@ function bindMobileNav() { }); } +// ==================== SimpCore 开放 API 数据浏览 ==================== +// +// 官方文档里的接口远不止「邦国领土」那一类:玩家 / 邦国成员 / 科技 / 捐献 / +// 全服加成 / 外交 / 封禁 / 上下线日志 / 公告,以及另一套独立密钥的 API-V2 +// (在线玩家 / 装备 / 游戏时间 / 末影龙)。 +// +// 限流是按「Key × 接口」每分钟算的,V2 里末影龙甚至只有 1 次/分钟, +// 所以这里一律**按需点击加载 + 会话内缓存**,绝不做轮询。 + +const simpView = { + tab: 'overview', + globalKind: 'buffs', + detail: null, + /** tab → 已加载的数据,避免反复点同一个页签时重复打接口 */ + cache: {}, + loading: false +}; + +/** + * 更新 API 数据弹层标题栏的一行状态。 + * @param {object} data `/api/status` 里的 `data` 段 + */ +function updateSimpStatus(data) { + const el2 = $('simpStatus'); + if (!el2) return; + const parts = [`API ${data.apiEnabled ? '已启用' : '未启用'}`]; + if (data.apiError) parts.push(data.apiError); + parts.push(`V2 ${data.apiV2Enabled ? '已启用' : '未启用'}`); + if (data.apiV2Error) parts.push(data.apiV2Error); + el2.textContent = parts.join(' · '); + el2.style.color = (data.apiError || data.apiV2Error) ? 'var(--red)' : 'var(--muted)'; +} + +/** 打开 API 数据弹层 */ +function openSimp() { + $('simpOverlay').classList.remove('hidden'); + renderSimp(); +} + +function closeSimp() { + $('simpOverlay').classList.add('hidden'); +} + +/** 清掉会话缓存并重新加载当前页签 */ +function reloadSimp() { + simpView.cache = {}; + simpView.detail = null; + renderSimp(true); +} + +/** 切到某个页签 */ +function setSimpTab(tab) { + simpView.tab = tab; + simpView.detail = null; + for (const el of $('simpTabs').querySelectorAll('.tab')) { + el.classList.toggle('active', el.dataset.simp === tab); + } + renderSimp(); +} + +/** + * 请求 API 数据并做会话内缓存。 + * + * @param {string} key 缓存键 + * @param {string} path 接口路径 + * @param {boolean} [force] 忽略缓存 + * @returns {Promise} `{ok, data, message}` + */ +async function simpFetch(key, path, force) { + if (!force && simpView.cache[key]) return simpView.cache[key]; + try { + const res = await api(path); + const value = { ok: true, data: res.data, status: res.status }; + simpView.cache[key] = value; + return value; + } catch (err) { + return { ok: false, message: err.message }; + } +} + +/** 渲染当前页签 */ +async function renderSimp(force) { + const body = $('simpBody'); + if (!body) return; + const renderers = { + overview: renderSimpOverview, + unions: renderSimpUnions, + players: renderSimpPlayers, + global: renderSimpGlobal, + v2: renderSimpV2 + }; + const render = renderers[simpView.tab] || renderers.overview; + if (simpView.loading) return; + simpView.loading = true; + body.innerHTML = '
正在加载…
'; + try { + await render(body, force); + } catch (err) { + body.innerHTML = ''; + body.appendChild(el('div', 'simp-error', '加载失败:' + err.message)); + } finally { + simpView.loading = false; + } +} + +// ---------- 通用小工具 ---------- + +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined && text !== null) node.textContent = String(text); + return node; +} + +function simpSection(title) { + const section = el('div', 'simp-section'); + if (title) section.appendChild(el('h3', null, title)); + return section; +} + +function simpEmpty(text) { + return el('div', 'simp-empty', text); +} + +/** 统计卡片网格 */ +function simpStats(pairs) { + const grid = el('div', 'simp-grid'); + for (const [label, value] of pairs) { + const card = el('div', 'simp-stat'); + card.appendChild(el('div', 'label', label)); + card.appendChild(el('div', 'value', value === null || value === undefined || value === '' ? '-' : value)); + grid.appendChild(card); + } + return grid; +} + +/** + * 通用表格。 + * + * @param {Array<[string, (row: object) => *]>} columns 列定义(表头 + 取值函数) + * @param {Array} rows 数据行 + * @param {(row: object) => void} [onClick] 行点击回调 + * @param {string[]} [numericColumns] 需要右对齐的列下标 + */ +function simpTable(columns, rows, onClick, numericColumns) { + const numeric = new Set(numericColumns || []); + const table = el('table', 'simp-table'); + const thead = document.createElement('thead'); + const headRow = document.createElement('tr'); + columns.forEach(([label], index) => { + const th = el('th', numeric.has(index) ? 'num' : null, label); + headRow.appendChild(th); + }); + thead.appendChild(headRow); + table.appendChild(thead); + + const tbody = document.createElement('tbody'); + for (const row of rows) { + const tr = document.createElement('tr'); + if (onClick) { + tr.className = 'clickable'; + tr.addEventListener('click', () => onClick(row)); + } + columns.forEach(([, getter], index) => { + const value = getter(row); + const td = el('td', numeric.has(index) ? 'num' : null, + value === null || value === undefined || value === '' ? '-' : value); + tr.appendChild(td); + }); + tbody.appendChild(tr); + } + table.appendChild(tbody); + return table; +} + +function simpResult(box, result, emptyText) { + if (!result.ok) { + box.appendChild(el('div', 'simp-error', result.message || '查询失败')); + return false; + } + const data = result.data; + if (Array.isArray(data) && data.length === 0) { + box.appendChild(simpEmpty(emptyText || '没有数据')); + return false; + } + if (data === null || data === undefined) { + box.appendChild(simpEmpty(emptyText || '没有数据')); + return false; + } + return true; +} + +/** 让输入框回车触发回调 */ +function onEnter(input, handler) { + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') handler(); + }); +} + +// ---------- 概览 ---------- + +async function renderSimpOverview(body) { + const status = await simpFetch('status', '/api/simp/status', true); + if (status.ok) { + const { v1, v2, note } = status.data; + const s1 = simpSection('API(领土 / 邦国 / 玩家 查询)'); + s1.appendChild(simpStats([ + ['Key', v1.hasKey ? '已配置' : '未配置'], + ['轮询', v1.enabled ? `已启用(每 ${v1.pollIntervalSeconds} 秒)` : '未启用'], + ['接口地址', v1.baseUrl], + ['最近 HTTP', v1.lastHttpStatus || '-'], + ['累计请求', v1.requestCount], + ['缓存命中', v1.cacheHits], + ['缓存条目', v1.cachedPaths], + ['最近错误', v1.lastError || '无'] + ])); + body.appendChild(s1); + + const s2 = simpSection('API-V2(实时状态,独立密钥)'); + s2.appendChild(simpStats([ + ['Key', v2.hasKey ? '已配置' : '未配置'], + ['启用', v2.enabled ? '是' : '否'], + ['接口地址', v2.baseUrl], + ['最近 HTTP', v2.lastHttpStatus || '-'], + ['累计请求', v2.requestCount], + ['最近错误', v2.lastError || '无'] + ])); + body.appendChild(s2); + + body.appendChild(el('p', 'hint', note || '')); + } else { + body.appendChild(el('div', 'simp-error', status.message || '读取状态失败')); + } + + const actions = simpSection('连通性'); + const row = el('div', 'row row-compact'); + const healthBtn = el('button', 'btn btn-sm', '健康检查(免鉴权)'); + healthBtn.addEventListener('click', async () => { + healthBtn.disabled = true; + try { + const res = await api('/api/simp/health'); + toast(res.data.reachable ? 'API 服务可达' : 'API 服务不可达'); + } catch (err) { + toast('健康检查失败:' + err.message, true); + } finally { + healthBtn.disabled = false; + } + }); + const reloadBtn = el('button', 'btn btn-sm', '清空缓存并重载'); + reloadBtn.addEventListener('click', reloadSimp); + row.appendChild(healthBtn); + row.appendChild(reloadBtn); + actions.appendChild(row); + body.appendChild(actions); +} + +// ---------- 邦国 ---------- + +async function renderSimpUnions(body, force) { + const bar = el('div', 'row row-compact'); + const search = el('input', 'input input-grow'); + search.type = 'text'; + search.placeholder = '搜索邦国名 / 君主'; + search.value = simpView.unionSearch || ''; + const searchBtn = el('button', 'btn btn-sm', '搜索'); + bar.appendChild(search); + bar.appendChild(searchBtn); + body.appendChild(bar); + + const box = el('div'); + body.appendChild(box); + + const load = async () => { + simpView.unionSearch = search.value.trim(); + box.innerHTML = '
正在加载…
'; + const query = simpView.unionSearch ? '?search=' + encodeURIComponent(simpView.unionSearch) : ''; + const result = await simpFetch('unions' + query, '/api/simp/unions' + query, force); + box.innerHTML = ''; + if (!simpResult(box, result, '没有匹配的邦国')) return; + const rows = result.data; + const summary = el('div', 'simp-empty', `共 ${rows.length} 个邦国(点击行查看成员 / 科技 / 捐献 / 领土统计)`); + box.appendChild(summary); + box.appendChild(simpTable([ + ['邦国', (r) => r.name], + ['君主', (r) => r.leader], + ['成员', (r) => r.memberCount], + ['区块数', (r) => r.chunkCount], + ['领土面积(方块²)', (r) => r.blockArea], + ['特产', (r) => r.specialOutput], + ['王城区块', (r) => (r.capital ? `${r.capital.x}, ${r.capital.z}` : '-')] + ], rows, (row) => showUnionDetail(row), [2, 3, 4, 6])); + }; + searchBtn.addEventListener('click', () => load()); + onEnter(search, () => load()); + await load(); + + if (simpView.detail && simpView.detail.kind === 'union') { + body.appendChild(await buildUnionDetail(simpView.detail.key)); + } +} + +async function showUnionDetail(row) { + simpView.detail = { kind: 'union', key: row.name }; + // 不 force:详情用缓存里的邦国列表,只按需拉成员 / 科技 / 捐献 + renderSimp(); +} + +async function buildUnionDetail(key) { + const wrap = el('div', 'simp-detail'); + const head = el('div', 'close-row'); + head.appendChild(el('h4', null, '邦国详情:' + key)); + const close = el('button', 'btn btn-sm', '收起'); + close.addEventListener('click', () => { + simpView.detail = null; + renderSimp(); + }); + head.appendChild(close); + wrap.appendChild(head); + + const [detail, members, tech, contributions] = await Promise.all([ + simpFetch(`union:${key}`, '/api/simp/unions/' + encodeURIComponent(key)), + simpFetch(`unionMembers:${key}`, '/api/simp/unions/' + encodeURIComponent(key) + '/members'), + simpFetch(`unionTech:${key}`, '/api/simp/unions/' + encodeURIComponent(key) + '/tech'), + simpFetch(`unionContrib:${key}`, '/api/simp/unions/' + encodeURIComponent(key) + '/contributions?limit=50') + ]); + + if (detail.ok && detail.data && detail.data.territory) { + const t = detail.data.territory; + wrap.appendChild(el('h3', null, '领土统计')); + wrap.appendChild(simpStats([ + ['区块数', t.chunkCount], + ['领土面积(方块²)', t.blockArea], + ['外接矩形', `${t.bounds.minX},${t.bounds.minZ} ~ ${t.bounds.maxX},${t.bounds.maxZ}`], + ['平均忠诚度', t.avgLoyalty === null ? '-' : t.avgLoyalty], + ['最早占领', t.earliestClaimAt || '-'], + ['最近占领', t.latestClaimAt || '-'] + ])); + } + + wrap.appendChild(el('h3', null, `成员(${members.ok ? members.data.length : 0})`)); + if (members.ok && members.data.length > 0) { + wrap.appendChild(simpTable([ + ['玩家', (r) => r.name], + ['职位', (r) => r.role], + ['职业', (r) => r.job], + ['称号', (r) => r.title], + ['加入时间', (r) => r.joinedAt] + ], members.data)); + } else { + wrap.appendChild(simpEmpty(members.ok ? '无成员数据' : members.message)); + } + + wrap.appendChild(el('h3', null, `已激活科技(${tech.ok ? tech.data.length : 0})`)); + if (tech.ok && tech.data.length > 0) { + wrap.appendChild(simpTable([ + ['科技', (r) => r.name], + ['效果', (r) => r.effect], + ['等级', (r) => r.level], + ['到期', (r) => r.expiresAt] + ], tech.data, null, [2])); + } else { + wrap.appendChild(simpEmpty(tech.ok ? '当前没有生效中的科技' : tech.message)); + } + + wrap.appendChild(el('h3', null, `最近捐献 / 收支(${contributions.ok ? contributions.data.length : 0})`)); + if (contributions.ok && contributions.data.length > 0) { + wrap.appendChild(simpTable([ + ['玩家', (r) => r.name || (r.uuid ? r.uuid.slice(0, 8) : '-')], + ['用途', (r) => r.reason], + ['材料', (r) => r.material], + ['数量', (r) => r.amount], + ['时间', (r) => r.createdAt] + ], contributions.data, null, [3])); + wrap.appendChild(el('p', 'hint', '正数为捐献,负数为邦国支出(例如「恢复民心」消耗金锭 / 钻石)。')); + } else { + wrap.appendChild(simpEmpty(contributions.ok ? '没有捐献记录' : contributions.message)); + } + + return wrap; +} + +// ---------- 玩家 ---------- + +async function renderSimpPlayers(body, force) { + const bar = el('div', 'row row-compact'); + const search = el('input', 'input input-grow'); + search.type = 'text'; + search.placeholder = '搜索玩家名 / 邦国'; + search.value = simpView.playerSearch || ''; + const searchBtn = el('button', 'btn btn-sm', '搜索'); + const meBtn = el('button', 'btn btn-sm', '查当前机器人'); + bar.appendChild(search); + bar.appendChild(searchBtn); + bar.appendChild(meBtn); + body.appendChild(bar); + + const box = el('div'); + body.appendChild(box); + + const load = async (keyword) => { + simpView.playerSearch = keyword === undefined ? search.value.trim() : keyword; + search.value = simpView.playerSearch; + box.innerHTML = '
正在加载…
'; + const query = simpView.playerSearch ? '?search=' + encodeURIComponent(simpView.playerSearch) : ''; + const result = await simpFetch('players' + query, '/api/simp/players' + query, force); + box.innerHTML = ''; + if (!simpResult(box, result, '没有匹配的玩家')) return; + const rows = result.data; + box.appendChild(el('div', 'simp-empty', `共 ${rows.length} 名玩家(点击行查看详情)`)); + box.appendChild(simpTable([ + ['玩家', (r) => r.name], + ['邦国', (r) => r.unionName], + ['职位', (r) => r.role], + ['职业', (r) => r.job], + ['称号', (r) => r.title] + ], rows, (row) => { + simpView.detail = { kind: 'player', key: row.name }; + renderSimp(); + })); + }; + searchBtn.addEventListener('click', () => load()); + onEnter(search, () => load()); + meBtn.addEventListener('click', () => load(selectedBotId || '')); + await load(); + + if (simpView.detail && simpView.detail.kind === 'player') { + const key = simpView.detail.key; + const wrap = el('div', 'simp-detail'); + const head = el('div', 'close-row'); + head.appendChild(el('h4', null, '玩家详情:' + key)); + const close = el('button', 'btn btn-sm', '收起'); + close.addEventListener('click', () => { + simpView.detail = null; + renderSimp(); + }); + head.appendChild(close); + wrap.appendChild(head); + + const [player, v2] = await Promise.all([ + simpFetch(`player:${key}`, '/api/simp/players/' + encodeURIComponent(key)), + simpFetch(`equip:${key}`, '/api/simp/v2/equipment/' + encodeURIComponent(key)) + ]); + if (player.ok && player.data) { + const p = player.data; + wrap.appendChild(simpStats([ + ['玩家', p.name], + ['UUID', p.uuid], + ['邦国', p.unionName || '无'], + ['职位', p.role], + ['职业', p.job], + ['称号', p.title] + ])); + if (p.union) { + wrap.appendChild(el('h3', null, '所属邦国')); + wrap.appendChild(simpStats([ + ['名称', p.union.name], + ['君主', p.union.leader], + ['成员数', p.union.memberCount], + ['区块数', p.union.chunkCount], + ['特产', p.union.specialOutput] + ])); + } + } else { + wrap.appendChild(el('div', 'simp-error', player.message || '没有该玩家')); + } + + wrap.appendChild(el('h3', null, '装备与手持(API-V2)')); + if (v2.ok && v2.data) { + const slots = [ + ['主手', v2.data.mainHand], ['副手', v2.data.offHand], + ['头盔', v2.data.helmet], ['胸甲', v2.data.chestplate], + ['护腿', v2.data.leggings], ['靴子', v2.data.boots] + ]; + wrap.appendChild(simpTable([ + ['部位', (r) => r.slot], + ['物品', (r) => r.name], + ['数量', (r) => r.count], + ['耐久', (r) => r.durability] + ], slots.map(([slot, item]) => ({ + slot, + name: item ? (item.displayName || item.name) : '空', + count: item ? item.count : '-', + durability: item && item.durability >= 0 + ? `${item.durability}${item.maxDurability > 0 ? ' / ' + item.maxDurability : ''}` + : '-' + })), null, [2, 3])); + } else { + wrap.appendChild(simpEmpty(v2.ok ? '没有装备数据' : v2.message)); + } + body.appendChild(wrap); + } +} + +// ---------- 全服 ---------- + +const GLOBAL_KINDS = [ + ['buffs', '生效加成'], + ['diplomacy', '外交关系'], + ['bans', '封禁记录'], + ['logs', '上下线日志'], + ['announcements', '公告历史'] +]; + +async function renderSimpGlobal(body, force) { + const sub = el('div', 'simp-sub'); + for (const [kind, label] of GLOBAL_KINDS) { + const btn = el('button', 'tab' + (simpView.globalKind === kind ? ' active' : ''), label); + btn.addEventListener('click', () => { + simpView.globalKind = kind; + renderSimp(); + }); + sub.appendChild(btn); + } + body.appendChild(sub); + + const kind = simpView.globalKind; + const box = el('div'); + body.appendChild(box); + const limitSuffix = ['bans', 'logs', 'announcements'].includes(kind) ? '?limit=100' : ''; + const result = await simpFetch('global:' + kind, '/api/simp/global/' + kind + limitSuffix, force); + if (!simpResult(box, result, '没有数据')) return; + + const rows = result.data; + const columns = { + buffs: [['邦国', (r) => r.unionName], ['科技', (r) => r.name], ['效果', (r) => r.effect], ['等级', (r) => r.level], ['到期', (r) => r.expiresAt]], + diplomacy: [['授予方', (r) => r.grantor], ['接收方', (r) => r.grantee], ['权限级别', (r) => r.level]], + bans: [['玩家', (r) => r.name], ['原因', (r) => r.reason], ['操作者', (r) => r.operator], ['时间', (r) => r.createdAt], ['到期', (r) => r.expiresAt]], + logs: [['玩家', (r) => r.name], ['动作', (r) => r.action], ['详情', (r) => r.detail], ['时间', (r) => r.createdAt]], + announcements: [['内容', (r) => r.content], ['发布者', (r) => r.author], ['时间', (r) => r.createdAt]] + }[kind]; + box.appendChild(el('div', 'simp-empty', `共 ${rows.length} 条(服务端有缓存,数据最多延迟 1 小时)`)); + box.appendChild(simpTable(columns, rows)); +} + +// ---------- 实时 V2 ---------- + +async function renderSimpV2(body, force) { + const actions = el('div', 'row row-compact'); + const make = (label, key, path, renderer) => { + const btn = el('button', 'btn btn-sm', label); + btn.addEventListener('click', async () => { + btn.disabled = true; + const box = $('simpV2Result'); + box.classList.remove('hidden'); + box.innerHTML = '
正在查询…
'; + const result = await simpFetch(key, path, force); + box.innerHTML = ''; + if (!simpResult(box, result, '没有数据')) { + btn.disabled = false; + return; + } + renderer(box, result.data); + btn.disabled = false; + }); + return btn; + }; + + actions.appendChild(make('在线玩家', 'v2online', '/api/simp/v2/online', (box, rows) => { + box.appendChild(simpTable([ + ['玩家', (r) => r.name], + ['世界', (r) => r.world], + ['坐标', (r) => (Number.isFinite(r.x) ? `${r.x.toFixed(0)}, ${r.y.toFixed(0)}, ${r.z.toFixed(0)}` : '-')], + ['延迟', (r) => (r.ping >= 0 ? r.ping + ' ms' : '-')], + ['模式', (r) => r.gameMode] + ], rows, null, [3])); + })); + actions.appendChild(make('游戏时间', 'v2time', '/api/simp/v2/time', (box, t) => { + const ticks = t.dayTime % 24000; + const hours = Math.floor(((ticks / 1000) + 6) % 24); + const minutes = Math.floor(((ticks % 1000) / 1000) * 60); + box.appendChild(simpStats([ + ['游戏内时刻', `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`], + ['天数', t.day >= 0 ? t.day : '-'], + ['白天', t.isDay === null ? '-' : (t.isDay ? '是' : '否')], + ['world_time', t.worldTime] + ])); + })); + actions.appendChild(make('末影龙', 'v2dragon', '/api/simp/v2/dragon', (box, d) => { + box.appendChild(simpStats([ + ['存活', d.alive ? '是' : '否'], + ['生命', Number.isFinite(d.health) ? d.health : '-'], + ['阶段', d.phase || '-'] + ])); + box.appendChild(el('p', 'hint', '该接口限速 1 次/分钟,请勿频繁点击。')); + })); + body.appendChild(actions); + body.appendChild(el('p', 'hint', 'API-V2 是独立于 API 的另一套密钥体系(需在密钥中心单独申请),接口级配额很小,因此不做自动轮询。')); + + const equipRow = el('div', 'row row-compact'); + const equipInput = el('input', 'input input-grow'); + equipInput.type = 'text'; + equipInput.placeholder = '输入玩家名查装备与手持物品'; + equipInput.value = selectedBotId || ''; + const equipBtn = el('button', 'btn btn-sm', '查询装备'); + equipBtn.addEventListener('click', async () => { + const name = equipInput.value.trim(); + if (!name) { + toast('请先输入玩家名', true); + return; + } + const box = $('simpV2Result'); + box.innerHTML = '
正在查询…
'; + const result = await simpFetch('v2equip:' + name, '/api/simp/v2/equipment/' + encodeURIComponent(name), force); + box.innerHTML = ''; + if (!simpResult(box, result, '没有装备数据')) return; + const d = result.data; + const slots = [ + ['主手', d.mainHand], ['副手', d.offHand], ['头盔', d.helmet], + ['胸甲', d.chestplate], ['护腿', d.leggings], ['靴子', d.boots] + ]; + box.appendChild(simpTable([ + ['部位', (r) => r.slot], + ['物品', (r) => r.name], + ['数量', (r) => r.count], + ['耐久', (r) => r.durability] + ], slots.map(([slot, item]) => ({ + slot, + name: item ? (item.displayName || item.name) : '空', + count: item ? item.count : '-', + durability: item && item.durability >= 0 + ? `${item.durability}${item.maxDurability > 0 ? ' / ' + item.maxDurability : ''}` + : '-' + })), null, [2, 3])); + }); + onEnter(equipInput, () => equipBtn.click()); + equipRow.appendChild(equipInput); + equipRow.appendChild(equipBtn); + body.appendChild(equipRow); + + body.appendChild(el('div', 'simp-empty', '查询结果:')); + const resultBox = el('div', 'simp-detail hidden'); + resultBox.id = 'simpV2Result'; + body.appendChild(resultBox); +} + // ==================== 运行日志 ==================== const MAX_LOG_LINES = 800; @@ -1756,6 +2398,18 @@ function bindEvents() { } }); + // ===== SimpCore API 数据浏览 ===== + $('btnOpenSimp').addEventListener('click', openSimp); + $('btnCloseSimp').addEventListener('click', closeSimp); + $('simpTabs').addEventListener('click', (e) => { + const tab = e.target.closest('.tab'); + if (tab) setSimpTab(tab.dataset.simp); + }); + $('simpOverlay').addEventListener('click', (e) => { + // 点遮罩空白处关闭(点弹层内部不关) + if (e.target === $('simpOverlay')) closeSimp(); + }); + // ===== 定时消息编辑器 ===== $('btnAddSchedule').addEventListener('click', () => { $('schedContent').value = ''; diff --git a/src/web/public/index.html b/src/web/public/index.html index 9405318..576baae 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -150,6 +150,7 @@
+
@@ -280,8 +281,6 @@ - -
移动测试 @@ -320,6 +319,28 @@ + + +