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,全部通过。
这个提交包含在:
+34
@@ -2,6 +2,40 @@
|
|||||||
|
|
||||||
本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。
|
本项目遵循 [约定式提交](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
|
## [1.4.1] - 2026-09-27
|
||||||
|
|
||||||
### 变更
|
### 变更
|
||||||
|
|||||||
@@ -137,11 +137,27 @@ squaremap 前端用 `L.CRS.Simple` + `tileSize: 512`,瓦片路径为
|
|||||||
而错误假设(256 / 1024 方块每瓦片、整体偏移半张瓦片)分别为 76.0% / 47.3% / 41.6%,
|
而错误假设(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 | 按需拉取 + 永久缓存 |
|
| 1 | 2048 | 20×20 = 400 | 按需拉取 + 永久缓存 |
|
||||||
| 2 | 1024 | 40×40 = 1600 | 按需拉取 + 永久缓存 |
|
| 2 | 1024 | 40×40 = 1600 | 按需拉取 + 永久缓存 |
|
||||||
| 3 | 512 | 80×80 = 6400 | 按需拉取 + 永久缓存 |
|
| 3 | 512 | 80×80 = 6400 | 按需拉取 + 永久缓存 |
|
||||||
@@ -150,7 +166,47 @@ squaremap 前端用 `L.CRS.Simple` + `tileSize: 512`,瓦片路径为
|
|||||||
- 瓦片由后端代理(`/api/bigmap/tile/:z/:x/:y`),避免浏览器直连地图站点的跨域问题
|
- 瓦片由后端代理(`/api/bigmap/tile/:z/:x/:y`),避免浏览器直连地图站点的跨域问题
|
||||||
- 领土以 `markers.json` 的**原始多边形**绘制(预编译成 `Path2D`,按 even-odd 填充保留国中孔洞),
|
- 领土以 `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` 爬取服务器地图站点(本项目部署的是
|
## REST / WebSocket 接口
|
||||||
[squaremap](https://github.com/jpenilla/squaremap))的瓦片图片与领土标记,
|
|
||||||
把所有机器人画在同一张图上。
|
|
||||||
|
|
||||||
### 以机器人为中心 + 可调图幅
|
|
||||||
|
|
||||||
大地图的用途是看**自己机器人周边**,所以默认以机器人当前位置为中心(`bigMap.followBot`)。
|
|
||||||
界面上的「图幅」下拉框决定半径:
|
|
||||||
|
|
||||||
| 图幅 | 层级 0 需要的瓦片数 |
|
|
||||||
| --- | --- |
|
|
||||||
| ±512 格 | 1 |
|
|
||||||
| ±1024 格 | 1~4 |
|
|
||||||
| ±4096 格(默认) | 9 |
|
|
||||||
| ±8192 格 | 25 |
|
|
||||||
| 全世界(±20480) | 100 |
|
|
||||||
|
|
||||||
层级 0 每张瓦片覆盖 4096 格,所以「只看周边」比「拉全世界」省一到两个数量级。
|
|
||||||
点「爬取周边」只会爬当前图幅内的瓦片;「回到机器人」把视图拉回机器人所在位置。
|
|
||||||
|
|
||||||
### REST / WebSocket 接口
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|||||||
@@ -116,6 +116,16 @@ function defaultConfig() {
|
|||||||
apiKey: '',
|
apiKey: '',
|
||||||
/** API 领土数据轮询间隔(秒),最小 60 */
|
/** API 领土数据轮询间隔(秒),最小 60 */
|
||||||
apiRefreshIntervalSeconds: 180,
|
apiRefreshIntervalSeconds: 180,
|
||||||
|
/**
|
||||||
|
* 是否启用 SimpCore API-V2(ServerOpenAPI 插件)。
|
||||||
|
*
|
||||||
|
* 注意:**API 与 API-V2 是两套独立、互不通用的密钥体系**,需要在密钥中心分别申请。
|
||||||
|
* V2 用于查询服务器实时状态(在线玩家 / 装备 / 游戏时间 / 末影龙),
|
||||||
|
* 接口级配额很小(末影龙仅 1 次/分钟),因此不做自动轮询,只在控制台按需查询。
|
||||||
|
*/
|
||||||
|
apiV2Enabled: false,
|
||||||
|
/** API-V2 Key(留空时回退读环境变量 SIMP_API_V2_KEY) */
|
||||||
|
apiV2Key: '',
|
||||||
|
|
||||||
// ==================== 寻路器专属(新增) ====================
|
// ==================== 寻路器专属(新增) ====================
|
||||||
pathing: {
|
pathing: {
|
||||||
@@ -426,6 +436,7 @@ function validateConfig(config) {
|
|||||||
}
|
}
|
||||||
if (typeof config.stateName !== 'string') config.stateName = '';
|
if (typeof config.stateName !== 'string') config.stateName = '';
|
||||||
if (typeof config.apiKey !== 'string') config.apiKey = '';
|
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.claimCommand !== 'string') config.server.claimCommand = '';
|
||||||
if (typeof config.server.loginCommand !== 'string') config.server.loginCommand = '';
|
if (typeof config.server.loginCommand !== 'string') config.server.loginCommand = '';
|
||||||
if (typeof config.server.registerCommand !== 'string') config.server.registerCommand = '';
|
if (typeof config.server.registerCommand !== 'string') config.server.registerCommand = '';
|
||||||
|
|||||||
+571
-12
@@ -6,24 +6,48 @@ const chunkPos = require('./chunkPos');
|
|||||||
* SimpCore 开放 API 客户端(文档:https://open.bgjq.top/api-docs.html)。
|
* SimpCore 开放 API 客户端(文档:https://open.bgjq.top/api-docs.html)。
|
||||||
*
|
*
|
||||||
* 邦国崛起服务器的只读查询服务,作为占领判定的第四数据源
|
* 邦国崛起服务器的只读查询服务,作为占领判定的第四数据源
|
||||||
* (游戏字幕、游戏消息、Dynmap JSON 之外的补充),提供:
|
* (游戏字幕、游戏消息、Dynmap JSON 之外的补充),同时把服务端的公开信息
|
||||||
* - 己方邦国领土区块明细(区块坐标、忠诚度、占领时间)——修正本地领土模型
|
* 完整暴露到控制台供人工查阅。
|
||||||
* - 全服领土区块(手动触发)——交叉修正他人区块归属
|
|
||||||
* - 玩家所属邦国查询——辅助识别玩家邦国
|
|
||||||
* - 邦国列表(含王城坐标)——王城数据兜底
|
|
||||||
*
|
*
|
||||||
* 鉴权:`Authorization: Bearer <KEY>`;限流:按 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 <KEY>`(也支持 `X-API-Key`);限流:按 Key × 接口每分钟限流。
|
||||||
* 全服数据类接口限流较严且数据量大(实测全服 territory 单次请求可超 2 分钟),
|
* 全服数据类接口限流较严且数据量大(实测全服 territory 单次请求可超 2 分钟),
|
||||||
* 因此本客户端**只适合低频调用**:自动轮询仅用单邦国轻接口,全服接口仅手动触发。
|
* 因此本客户端**只适合低频调用**:自动轮询仅用单邦国轻接口,其余全部按需手动触发。
|
||||||
* 服务端响应带缓存,数据最多有 1 小时延迟。
|
* 服务端响应带缓存,数据最多有 1 小时延迟。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** API 服务基础地址 */
|
/** API 服务基础地址 */
|
||||||
const BASE_URL = 'https://bgjq.simpfun.cn/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 意义不大 */
|
/** 默认缓存 TTL:5 分钟。服务端数据本身最多延迟 1 小时,更短的 TTL 意义不大 */
|
||||||
const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
|
const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/** API-V2 的接口级配额很小(最低 1 QPM),缓存给长一点 */
|
||||||
|
const DEFAULT_CACHE_TTL_V2_MS = 30 * 1000;
|
||||||
|
|
||||||
class SimpApiClient {
|
class SimpApiClient {
|
||||||
/**
|
/**
|
||||||
* @param {import('../util/logger').Logger} [logger]
|
* @param {import('../util/logger').Logger} [logger]
|
||||||
@@ -42,6 +66,10 @@ class SimpApiClient {
|
|||||||
this._lastHttpStatus = 0;
|
this._lastHttpStatus = 0;
|
||||||
/** 最近一次请求失败的错误描述(空串表示无错误) */
|
/** 最近一次请求失败的错误描述(空串表示无错误) */
|
||||||
this._lastError = '';
|
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<Array<object>>}
|
||||||
|
*/
|
||||||
|
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<Array<object>>}
|
||||||
|
*/
|
||||||
|
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<Array<object>>}
|
||||||
|
*/
|
||||||
|
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<Array<object>>}
|
||||||
|
*/
|
||||||
|
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<Map<string, string>>}
|
||||||
|
*/
|
||||||
|
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<Array<object>>}
|
||||||
|
*/
|
||||||
|
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<Array<object>>}
|
||||||
|
*/
|
||||||
|
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<Array<object>>}
|
||||||
|
*/
|
||||||
|
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<Array<object>>}
|
||||||
|
*/
|
||||||
|
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<Array<object>>}
|
||||||
|
*/
|
||||||
|
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<object|null>}
|
||||||
|
*/
|
||||||
|
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 opts = options || {};
|
||||||
const entry = this.cache.get(path);
|
const entry = this.cache.get(path);
|
||||||
const now = Date.now();
|
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()) {
|
if (!this.hasApiKey()) {
|
||||||
this._lastError = '未配置 API Key';
|
this._lastError = '未配置 API Key';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
this._requestCount++;
|
||||||
const response = await this._request(path, { auth: true, timeoutMs: opts.timeoutMs || 30000 });
|
const response = await this._request(path, { auth: true, timeoutMs: opts.timeoutMs || 30000 });
|
||||||
this._lastHttpStatus = response.status;
|
this._lastHttpStatus = response.status;
|
||||||
if (response.status === 200) {
|
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<Array<object>>} 在线玩家列表(含位置、世界、延迟等) */
|
||||||
|
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<object|null>}
|
||||||
|
*/
|
||||||
|
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<object|null>} 游戏内时间 */
|
||||||
|
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<object|null>} 末影龙状态(接口级限速 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 解析(字段名以实测响应为准) ====================
|
// ==================== JSON 解析(字段名以实测响应为准) ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -263,8 +749,8 @@ function parseUnionSummary(element) {
|
|||||||
if (!element || typeof element !== 'object') return null;
|
if (!element || typeof element !== 'object') return null;
|
||||||
const name = stringOr(element, 'name', '');
|
const name = stringOr(element, 'name', '');
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
const capitalX = doubleOr(element, 'capital_x', Number.NaN);
|
const capitalX = optionalNumber(element, 'capital_x');
|
||||||
const capitalZ = doubleOr(element, 'capital_z', Number.NaN);
|
const capitalZ = optionalNumber(element, 'capital_z');
|
||||||
return {
|
return {
|
||||||
id: intOr(element, 'id', -1),
|
id: intOr(element, 'id', -1),
|
||||||
name,
|
name,
|
||||||
@@ -273,12 +759,30 @@ function parseUnionSummary(element) {
|
|||||||
chunkCount: intOr(element, 'territory_chunk_count', 0),
|
chunkCount: intOr(element, 'territory_chunk_count', 0),
|
||||||
blockArea: longOr(element, 'territory_block_area', 0),
|
blockArea: longOr(element, 'territory_block_area', 0),
|
||||||
specialOutput: stringOr(element, 'special_output_material', ''),
|
specialOutput: stringOr(element, 'special_output_material', ''),
|
||||||
capital: Number.isNaN(capitalX) || Number.isNaN(capitalZ)
|
// 王城坐标可能为 null(文档明确「空值输出 null」),此时不能换算成 (0,0) 假装有效
|
||||||
|
capital: capitalX === null || capitalZ === null
|
||||||
? null
|
? null
|
||||||
: chunkPos.fromBlock(Math.floor(capitalX), Math.floor(capitalZ))
|
: 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`
|
* 解析领土区块。实测字段:`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;
|
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 = {
|
module.exports = {
|
||||||
SimpApiClient,
|
SimpApiClient,
|
||||||
|
SimpApiV2Client,
|
||||||
BASE_URL,
|
BASE_URL,
|
||||||
|
BASE_URL_V2,
|
||||||
DEFAULT_CACHE_TTL_MS,
|
DEFAULT_CACHE_TTL_MS,
|
||||||
|
DEFAULT_CACHE_TTL_V2_MS,
|
||||||
parseUnionSummary,
|
parseUnionSummary,
|
||||||
parseTerritoryChunk
|
parseTerritoryChunk,
|
||||||
|
normalizeLimit,
|
||||||
|
componentToText
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const crypto = require('crypto');
|
|||||||
|
|
||||||
const chunkPos = require('./chunkPos');
|
const chunkPos = require('./chunkPos');
|
||||||
const { TerritoryModel } = require('./territoryModel');
|
const { TerritoryModel } = require('./territoryModel');
|
||||||
const { SimpApiClient } = require('./simpApiClient');
|
const { SimpApiClient, SimpApiV2Client } = require('./simpApiClient');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 领土数据服务:定时从远程地图服务拉取邦国领土数据(Dynmap markers.json),
|
* 领土数据服务:定时从远程地图服务拉取邦国领土数据(Dynmap markers.json),
|
||||||
@@ -71,6 +71,13 @@ class TerritoryDataService {
|
|||||||
|
|
||||||
/** @type {SimpApiClient} SimpCore 开放 API 客户端(第四占领判定数据源) */
|
/** @type {SimpApiClient} SimpCore 开放 API 客户端(第四占领判定数据源) */
|
||||||
this.apiClient = new SimpApiClient(logger);
|
this.apiClient = new SimpApiClient(logger);
|
||||||
|
/**
|
||||||
|
* @type {SimpApiV2Client} SimpCore API-V2 客户端(实时状态查询)
|
||||||
|
*
|
||||||
|
* 与上面那套是**互相独立的两套密钥体系**,所以单独一个实例、单独一个 Key。
|
||||||
|
* 只在控制台按需查询,不参与自动轮询。
|
||||||
|
*/
|
||||||
|
this.apiV2Client = new SimpApiV2Client(logger);
|
||||||
/** API 最近一次成功拉取的 邦国名 → 区块键集合 */
|
/** API 最近一次成功拉取的 邦国名 → 区块键集合 */
|
||||||
this.apiTerritory = new Map();
|
this.apiTerritory = new Map();
|
||||||
/** API 最近一次成功拉取的 邦国名 → 王城区块 */
|
/** API 最近一次成功拉取的 邦国名 → 王城区块 */
|
||||||
@@ -79,6 +86,8 @@ class TerritoryDataService {
|
|||||||
this.lastApiSuccessMillis = 0;
|
this.lastApiSuccessMillis = 0;
|
||||||
/** API 轮询间隔(秒);0 表示未启用 */
|
/** API 轮询间隔(秒);0 表示未启用 */
|
||||||
this._apiPollIntervalSeconds = 0;
|
this._apiPollIntervalSeconds = 0;
|
||||||
|
/** API-V2 是否启用 */
|
||||||
|
this.apiV2Enabled = false;
|
||||||
|
|
||||||
this._refreshTimer = null;
|
this._refreshTimer = null;
|
||||||
this._apiTimer = null;
|
this._apiTimer = null;
|
||||||
@@ -134,6 +143,25 @@ class TerritoryDataService {
|
|||||||
this._rescheduleApi();
|
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 表示停用) */
|
/** 按 _apiPollIntervalSeconds 重新调度 API 轮询(≤0 表示停用) */
|
||||||
_rescheduleApi() {
|
_rescheduleApi() {
|
||||||
if (this._apiTimer) clearInterval(this._apiTimer);
|
if (this._apiTimer) clearInterval(this._apiTimer);
|
||||||
@@ -390,7 +418,29 @@ class TerritoryDataService {
|
|||||||
apiLastSuccessMillis: this.lastApiSuccessMillis,
|
apiLastSuccessMillis: this.lastApiSuccessMillis,
|
||||||
apiError: this.apiClient.lastError(),
|
apiError: this.apiClient.lastError(),
|
||||||
apiHttpStatus: this.apiClient.lastHttpStatus(),
|
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 小时缓存延迟'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -38,6 +38,11 @@ async function main() {
|
|||||||
config = normalizeConfig(config, config);
|
config = normalizeConfig(config, config);
|
||||||
// 环境变量里的 API Key 作为兜底(配置文件为空时)
|
// 环境变量里的 API Key 作为兜底(配置文件为空时)
|
||||||
if (!config.apiKey && process.env.SIMP_API_KEY) config.apiKey = process.env.SIMP_API_KEY.trim();
|
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}`);
|
logger.log(`配置文件:${configStore.filePath}`);
|
||||||
|
|
||||||
@@ -104,6 +109,7 @@ async function main() {
|
|||||||
pool.applyGlobalConfig(config);
|
pool.applyGlobalConfig(config);
|
||||||
dataService.config = config;
|
dataService.config = config;
|
||||||
dataService.applyApiConfig(config.apiEnabled, config.apiKey, config.apiRefreshIntervalSeconds);
|
dataService.applyApiConfig(config.apiEnabled, config.apiKey, config.apiRefreshIntervalSeconds);
|
||||||
|
dataService.applyApiV2Config(config.apiV2Enabled, config.apiV2Key || process.env.SIMP_API_V2_KEY);
|
||||||
dataService.reschedule(config.refreshIntervalSeconds);
|
dataService.reschedule(config.refreshIntervalSeconds);
|
||||||
if (bigMap) {
|
if (bigMap) {
|
||||||
bigMap.config = config;
|
bigMap.config = config;
|
||||||
@@ -115,6 +121,7 @@ async function main() {
|
|||||||
|
|
||||||
// ==================== 启动 ====================
|
// ==================== 启动 ====================
|
||||||
dataService.applyApiConfig(config.apiEnabled, config.apiKey, config.apiRefreshIntervalSeconds);
|
dataService.applyApiConfig(config.apiEnabled, config.apiKey, config.apiRefreshIntervalSeconds);
|
||||||
|
dataService.applyApiV2Config(config.apiV2Enabled, config.apiV2Key || process.env.SIMP_API_V2_KEY);
|
||||||
dataService.start(config.refreshIntervalSeconds);
|
dataService.start(config.refreshIntervalSeconds);
|
||||||
pool.start();
|
pool.start();
|
||||||
|
|
||||||
|
|||||||
+655
-1
@@ -252,7 +252,9 @@ const CONFIG_SCHEMA = {
|
|||||||
fields: [
|
fields: [
|
||||||
{ path: 'apiEnabled', label: '启用 API 数据源', type: 'checkbox' },
|
{ path: 'apiEnabled', label: '启用 API 数据源', type: 'checkbox' },
|
||||||
{ path: 'apiKey', label: 'API Key', type: 'password', hint: '留空则回退读环境变量 SIMP_API_KEY' },
|
{ 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} 条`;
|
$('btnExportExp').title = `累计 ${status.expansionLog.total} 条,今日 ${status.expansionLog.today} 条`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== SimpCore API 状态(弹层标题栏一行概览)=====
|
||||||
|
if (status.data) {
|
||||||
|
updateSimpStatus(status.data);
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 大地图爬取进度 =====
|
// ===== 大地图爬取进度 =====
|
||||||
if (status.bigMap && status.bigMap.crawl) {
|
if (status.bigMap && status.bigMap.crawl) {
|
||||||
const crawl = 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<object>} `{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 = '<div class="simp-empty">正在加载…</div>';
|
||||||
|
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<object>} 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 = '<div class="simp-empty">正在加载…</div>';
|
||||||
|
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 = '<div class="simp-empty">正在加载…</div>';
|
||||||
|
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 = '<div class="simp-empty">正在查询…</div>';
|
||||||
|
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 = '<div class="simp-empty">正在查询…</div>';
|
||||||
|
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;
|
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', () => {
|
$('btnAddSchedule').addEventListener('click', () => {
|
||||||
$('schedContent').value = '';
|
$('schedContent').value = '';
|
||||||
|
|||||||
+23
-2
@@ -150,6 +150,7 @@
|
|||||||
<div class="row row-compact">
|
<div class="row row-compact">
|
||||||
<button id="btnRefreshMap" class="btn btn-sm">刷新地图</button>
|
<button id="btnRefreshMap" class="btn btn-sm">刷新地图</button>
|
||||||
<button id="btnFetchAll" class="btn btn-sm">拉取全服领土</button>
|
<button id="btnFetchAll" class="btn btn-sm">拉取全服领土</button>
|
||||||
|
<button id="btnOpenSimp" class="btn btn-sm btn-primary">查看 API 数据</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -280,8 +281,6 @@
|
|||||||
<button class="btn btn-sm" data-cmd="/u return">/u return</button>
|
<button class="btn btn-sm" data-cmd="/u return">/u return</button>
|
||||||
<button class="btn btn-sm" data-cmd="/u claimmode">/u claimmode</button>
|
<button class="btn btn-sm" data-cmd="/u claimmode">/u claimmode</button>
|
||||||
<button class="btn btn-sm" data-cmd="/u restore confirm">/u restore confirm</button>
|
<button class="btn btn-sm" data-cmd="/u restore confirm">/u restore confirm</button>
|
||||||
<button class="btn btn-sm" data-cmd="/spawn">/spawn</button>
|
|
||||||
<button class="btn btn-sm" data-cmd="/list">/list</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="row row-compact">
|
<div class="row row-compact">
|
||||||
<span class="field-label">移动测试</span>
|
<span class="field-label">移动测试</span>
|
||||||
@@ -320,6 +319,28 @@
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- SimpCore 开放 API 数据浏览:数据量大且限流严格,做成按需打开的弹层而不是常驻面板 -->
|
||||||
|
<div id="simpOverlay" class="overlay hidden">
|
||||||
|
<div class="overlay-box overlay-box-lg">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>SimpCore 开放 API</h2>
|
||||||
|
<div class="row row-compact">
|
||||||
|
<span id="simpStatus" class="mono small"></span>
|
||||||
|
<button id="btnCloseSimp" class="btn btn-sm">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tabs" id="simpTabs">
|
||||||
|
<button class="tab active" data-simp="overview">概览</button>
|
||||||
|
<button class="tab" data-simp="unions">邦国</button>
|
||||||
|
<button class="tab" data-simp="players">玩家</button>
|
||||||
|
<button class="tab" data-simp="global">全服</button>
|
||||||
|
<button class="tab" data-simp="v2">实时 V2</button>
|
||||||
|
</div>
|
||||||
|
<div id="simpBody" class="simp-body"></div>
|
||||||
|
<div id="simpV2Result" class="simp-detail hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 登录遮罩:控制台配置了 WEB_TOKEN / WEB_PASSWORD 时显示 -->
|
<!-- 登录遮罩:控制台配置了 WEB_TOKEN / WEB_PASSWORD 时显示 -->
|
||||||
<div id="loginOverlay" class="overlay hidden">
|
<div id="loginOverlay" class="overlay hidden">
|
||||||
<div class="overlay-box">
|
<div class="overlay-box">
|
||||||
|
|||||||
@@ -414,6 +414,74 @@ html, body {
|
|||||||
.overlay-box h2 { font-size: 16px; margin: 0 0 6px; color: var(--text); text-transform: none; letter-spacing: 0; }
|
.overlay-box h2 { font-size: 16px; margin: 0 0 6px; color: var(--text); text-transform: none; letter-spacing: 0; }
|
||||||
.overlay-box .input { margin: 12px 0; }
|
.overlay-box .input { margin: 12px 0; }
|
||||||
|
|
||||||
|
/* ==================== SimpCore API 数据弹层 ==================== */
|
||||||
|
.overlay-box-lg {
|
||||||
|
width: min(1100px, 100%);
|
||||||
|
max-height: min(86vh, 900px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.overlay-box-lg .card-head h2 { text-transform: none; letter-spacing: 0; color: var(--text); font-size: 15px; }
|
||||||
|
.simp-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
.simp-section { margin-bottom: 14px; }
|
||||||
|
.simp-section h3 {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--accent-2);
|
||||||
|
margin: 0 0 7px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.simp-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.simp-stat {
|
||||||
|
padding: 9px 11px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
.simp-stat .label { font-size: 11px; color: var(--muted); }
|
||||||
|
.simp-stat .value { font-size: 15px; margin-top: 2px; word-break: break-word; }
|
||||||
|
.simp-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||||
|
.simp-table th {
|
||||||
|
position: sticky; top: 0; z-index: 1;
|
||||||
|
text-align: left; color: var(--text-dim); font-weight: 600;
|
||||||
|
background: var(--panel-2); padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.simp-table td { padding: 5px 8px; border-bottom: 1px solid #10151d; }
|
||||||
|
.simp-table tbody tr:hover { background: #0d1219; }
|
||||||
|
.simp-table tbody tr.clickable { cursor: pointer; }
|
||||||
|
.simp-table .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.simp-table .mono { font-size: 11.5px; }
|
||||||
|
.simp-empty { color: var(--muted); padding: 10px 2px; font-size: 12.5px; }
|
||||||
|
.simp-error { color: var(--red); font-size: 12.5px; padding: 8px 2px; }
|
||||||
|
.simp-detail {
|
||||||
|
margin-top: 10px; padding: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #0d1219;
|
||||||
|
}
|
||||||
|
.simp-detail h4 { margin: 0 0 8px; font-size: 13px; }
|
||||||
|
.simp-detail .close-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||||
|
.simp-sub { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 9px; }
|
||||||
|
.tag {
|
||||||
|
display: inline-block; padding: 1px 7px; border-radius: 999px;
|
||||||
|
font-size: 11px; border: 1px solid var(--border); color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.tag-owner { color: var(--yellow); border-color: #6b5518; }
|
||||||
|
.tag-admin { color: var(--accent-2); border-color: #1f4f8f; }
|
||||||
|
|
||||||
/* ==================== 移动端底部导航 ==================== */
|
/* ==================== 移动端底部导航 ==================== */
|
||||||
.mobile-nav { display: none; }
|
.mobile-nav { display: none; }
|
||||||
|
|
||||||
|
|||||||
+180
@@ -574,6 +574,133 @@ class WebConsole {
|
|||||||
res.json({ ok: result.ok, message: result.message });
|
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/...)--------
|
// -------- 单机器人兼容接口(等价于默认机器人的 /api/bots/:id/...)--------
|
||||||
app.post('/api/task/start', async (req, res) => {
|
app.post('/api/task/start', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -641,6 +768,59 @@ class WebConsole {
|
|||||||
res.status(status).json({ ok: false, message: err && err.message ? err.message : String(err) });
|
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) {
|
_settingSnapshot(entry) {
|
||||||
if (typeof this.pool.settingSnapshot === 'function') return this.pool.settingSnapshot(entry.id);
|
if (typeof this.pool.settingSnapshot === 'function') return this.pool.settingSnapshot(entry.id);
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
|
||||||
|
const {
|
||||||
|
SimpApiClient,
|
||||||
|
SimpApiV2Client,
|
||||||
|
BASE_URL,
|
||||||
|
BASE_URL_V2,
|
||||||
|
componentToText,
|
||||||
|
normalizeLimit,
|
||||||
|
parseUnionSummary,
|
||||||
|
parseTerritoryChunk
|
||||||
|
} = require('../src/core/simpApiClient');
|
||||||
|
|
||||||
|
test('componentToText 把公告的组件 JSON 压平成纯文本', () => {
|
||||||
|
const raw = '{"color":"gold","extra":[{"color":"aqua","text":"wans7891"},'
|
||||||
|
+ '{"color":"yellow","text":" 在坐标 (-8109, -8976)"},'
|
||||||
|
+ '{"color":"gold","text":" 创立了新的邦国 "},'
|
||||||
|
+ '{"color":"aqua","text":"「雅典维亚城邦」"}]}';
|
||||||
|
assert.strictEqual(componentToText(raw), 'wans7891 在坐标 (-8109, -8976) 创立了新的邦国 「雅典维亚城邦」');
|
||||||
|
// 嵌套 extra
|
||||||
|
const nested = '{"text":"公告: ","extra":[{"text":"外层","extra":[{"text":"内层"}]}]}';
|
||||||
|
assert.strictEqual(componentToText(nested), '公告: 外层内层');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('componentToText 对非 JSON 文本原样返回', () => {
|
||||||
|
assert.strictEqual(componentToText('普通公告'), '普通公告');
|
||||||
|
assert.strictEqual(componentToText(''), '');
|
||||||
|
assert.strictEqual(componentToText(null), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeLimit 钳制到 1..1000', () => {
|
||||||
|
assert.strictEqual(normalizeLimit(0), 1);
|
||||||
|
assert.strictEqual(normalizeLimit(50), 50);
|
||||||
|
assert.strictEqual(normalizeLimit(99999), 1000);
|
||||||
|
assert.strictEqual(normalizeLimit('abc'), 100, '非法值回退到文档默认值 100');
|
||||||
|
assert.strictEqual(normalizeLimit(undefined), 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseUnionSummary 解析邦国摘要并按方块坐标换算王城区块', () => {
|
||||||
|
const union = parseUnionSummary({
|
||||||
|
id: 12,
|
||||||
|
name: '养老院',
|
||||||
|
leader_name: 'LouieMAIN',
|
||||||
|
member_count: 6,
|
||||||
|
territory_chunk_count: 28853,
|
||||||
|
territory_block_area: 7386368,
|
||||||
|
special_output_material: 'TERRACOTTA',
|
||||||
|
capital_x: 6763.4,
|
||||||
|
capital_z: 11929.2
|
||||||
|
});
|
||||||
|
assert.strictEqual(union.name, '养老院');
|
||||||
|
assert.strictEqual(union.leader, 'LouieMAIN');
|
||||||
|
assert.strictEqual(union.memberCount, 6);
|
||||||
|
assert.strictEqual(union.chunkCount, 28853);
|
||||||
|
assert.strictEqual(union.specialOutput, 'TERRACOTTA');
|
||||||
|
// 方块坐标 ÷ 16 才是区块坐标
|
||||||
|
assert.deepStrictEqual(union.capital, { x: 422, z: 745 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseUnionSummary 缺王城坐标时返回 null 而不是 NaN', () => {
|
||||||
|
const union = parseUnionSummary({ name: '没有王城的邦国', capital_x: null, capital_z: null });
|
||||||
|
assert.strictEqual(union.capital, null);
|
||||||
|
assert.strictEqual(parseUnionSummary({}), null, '没有邦国名应视为无效');
|
||||||
|
assert.strictEqual(parseUnionSummary(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseTerritoryChunk 解析区块并拒绝坐标缺失的行', () => {
|
||||||
|
const chunk = parseTerritoryChunk({
|
||||||
|
x: 1,
|
||||||
|
z: -2,
|
||||||
|
union_id: 12,
|
||||||
|
union_name: '养老院',
|
||||||
|
loyalty: 100,
|
||||||
|
last_claimed_at: '2025-08-08T13:43:21.323+08:00'
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(chunk, {
|
||||||
|
x: 1,
|
||||||
|
z: -2,
|
||||||
|
unionId: 12,
|
||||||
|
unionName: '养老院',
|
||||||
|
loyalty: 100,
|
||||||
|
lastClaimedAt: '2025-08-08T13:43:21.323+08:00'
|
||||||
|
});
|
||||||
|
assert.strictEqual(parseTerritoryChunk({ x: null, z: 1 }), null);
|
||||||
|
assert.strictEqual(parseTerritoryChunk({}), null);
|
||||||
|
assert.strictEqual(parseTerritoryChunk(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('客户端在未配置 Key 时把错误写清楚,而不是静默失败', async () => {
|
||||||
|
const client = new SimpApiClient(null);
|
||||||
|
assert.strictEqual(client.hasApiKey(), false);
|
||||||
|
assert.strictEqual(await client.getUnionTerritoryStats('x'), null);
|
||||||
|
assert.strictEqual(client.lastError(), '未配置 API Key');
|
||||||
|
|
||||||
|
const v2 = new SimpApiV2Client(null);
|
||||||
|
// 列表类接口在无 Key 时返回空列表,但 lastError 必须说明原因
|
||||||
|
assert.deepStrictEqual(await v2.getOnlinePlayers(), []);
|
||||||
|
assert.strictEqual(v2.lastError(), '未配置 API-V2 Key');
|
||||||
|
assert.strictEqual(await v2.getDragon(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setApiKey 变更时清空缓存,避免旧 Key 的响应串味', () => {
|
||||||
|
const client = new SimpApiClient(null);
|
||||||
|
client.cache.set('info/unions', { body: '[]', expiresAt: Date.now() + 10000 });
|
||||||
|
client.setApiKey('first');
|
||||||
|
assert.strictEqual(client.cache.size, 0, '第一次设置也应从空 Key 状态切换');
|
||||||
|
client.cache.set('info/unions', { body: '[]', expiresAt: Date.now() + 10000 });
|
||||||
|
client.setApiKey('second');
|
||||||
|
assert.strictEqual(client.cache.size, 0, '换 Key 必须清缓存');
|
||||||
|
// 相同 Key 重复设置不应清缓存(否则每次配置下发都会白打接口)
|
||||||
|
client.cache.set('info/unions', { body: '[]', expiresAt: Date.now() + 10000 });
|
||||||
|
client.setApiKey('second');
|
||||||
|
assert.strictEqual(client.cache.size, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('API 与 API-V2 是两个独立的基础地址与密钥', () => {
|
||||||
|
const v1 = new SimpApiClient(null, {});
|
||||||
|
const v2 = new SimpApiV2Client(null, {});
|
||||||
|
assert.strictEqual(v1.baseUrl, BASE_URL);
|
||||||
|
assert.strictEqual(v2.baseUrl, BASE_URL_V2);
|
||||||
|
assert.notStrictEqual(v1.baseUrl, v2.baseUrl);
|
||||||
|
v1.setApiKey('key-v1');
|
||||||
|
assert.strictEqual(v1.hasApiKey(), true);
|
||||||
|
assert.strictEqual(v2.hasApiKey(), false, 'V1 的 Key 不应影响 V2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot 暴露用量与缓存统计供控制台展示', () => {
|
||||||
|
const client = new SimpApiClient(null);
|
||||||
|
const snap = client.snapshot();
|
||||||
|
assert.strictEqual(snap.hasKey, false);
|
||||||
|
assert.strictEqual(snap.requestCount, 0);
|
||||||
|
assert.strictEqual(snap.cacheHits, 0);
|
||||||
|
assert.strictEqual(snap.cachedPaths, 0);
|
||||||
|
assert.strictEqual(snap.baseUrl, BASE_URL);
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户