fix: 按实测响应修正 SimpCore API-V2 的字段名
接入 V2 密钥后实跑发现字段名与文档描述对不上,全部按真实响应修正:
- /players 返回的是对象 {online,max,players:[...]} 而不是裸数组,
原解析会拿到空列表;且里面没有坐标与延迟,只有所在世界
- /dragon 的字段是 dragonAlive,不是 alive,也没有 health / phase
- /time 直接给算好的时刻 {world,tick,phase,time12,time24},
原实现还在用 world_time/day_time/is_day 自己换算,全部取不到
- /players/{name}/equipment 的物品是 {type,key,amount,damage,maxDurability,unbreakable}
且挂在 equipment 子对象下;damage 是已损耗值,原来会把「损耗 157」当成
「剩余 157」显示,现在换算成 remainingDurability 并显示「剩余/上限」+ 百分比
- 429 限流时服务端带 retryAfterSeconds,现在提示里直接写出还要等多少秒
(末影龙 1 次/分钟,实测提示「还需等待 16 秒」)
另外给 SimpApiV2Client 加了可注入的 fetchImpl,便于测试替换网络层。
实测(DiLiDaLaDaLa):在线 8 人、游戏时间 19:45 NIGHT、
下界合金剑 剩余 1992/2031、头盔 剩余 198/407。
测试 336 -> 343,全部通过。
这个提交包含在:
+8
-1
@@ -37,5 +37,12 @@ DYNMAP_BASE=http://bgjq.simpfun.cn
|
||||
DYNMAP_WORLD=minecraft_overworld
|
||||
|
||||
# ==================== SimpCore 开放 API(可选) ====================
|
||||
# https://open.bgjq.top 申请
|
||||
# 文档:https://open.bgjq.top/api-docs.html
|
||||
# 注意:API 与 API-V2 是两套**独立、互不通用**的密钥体系,需要在密钥中心分别申请
|
||||
#
|
||||
# 第一套 API:邦国 / 领土 / 玩家 / 全服数据(https://bgjq.simpfun.cn/api/)
|
||||
SIMP_API_KEY=<your-simp-api-key>
|
||||
# 第二套 API-V2(ServerOpenAPI):在线玩家 / 装备 / 游戏时间 / 末影龙
|
||||
# https://bgjq.simpfun.cn/api-v2/
|
||||
# 配额很小(末影龙仅 1 次/分钟),程序只按需查询、不做轮询
|
||||
SIMP_API_V2_KEY=<your-simp-api-v2-key>
|
||||
|
||||
+22
@@ -2,6 +2,28 @@
|
||||
|
||||
本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
## [1.5.1] - 2026-09-28
|
||||
|
||||
接入 API-V2 密钥后按实测响应修正字段名(之前是按文档猜的,跑起来全不对)。
|
||||
|
||||
### 修复
|
||||
|
||||
- **API-V2 `/players` 返回的是对象不是数组**:`{"online":8,"max":5000,"players":[...]}`,
|
||||
原解析按裸数组处理会拿到空列表;且里面**没有坐标与延迟**,只有所在世界
|
||||
- **API-V2 `/dragon` 的字段是 `dragonAlive`**,不是 `alive`;也没有 health / phase
|
||||
- **API-V2 `/time` 直接给算好的时刻**:`{world, tick, phase, time12, time24}`,
|
||||
原实现还在用 `world_time` / `day_time` / `is_day` 自己换算,全部取不到
|
||||
- **API-V2 `/players/{name}/equipment` 的物品结构**:
|
||||
`{type, key, amount, damage, maxDurability, unbreakable}`,装备在 `equipment` 子对象里。
|
||||
`damage` 是**已损耗值**,原来会把「损耗 157」当成「剩余 157」显示;
|
||||
现在换算成 `remainingDurability` 并在界面显示「剩余 / 上限」+ 百分比
|
||||
- **限流提示**:V2 触发 429 时服务端带 `retryAfterSeconds`,现在直接告诉用户还要等多少秒
|
||||
(末影龙接口只有 1 次/分钟,实测超限时提示「还需等待 16 秒」)
|
||||
|
||||
### 新增
|
||||
|
||||
- `SimpApiV2Client` 支持注入 `fetchImpl`,便于测试替换网络层
|
||||
|
||||
## [1.5.0] - 2026-09-28
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -207,6 +207,11 @@ V2 的接口级配额很小(末影龙只有 **1 次/分钟**),因此 V2
|
||||
| 公告 | `message` 是**聊天组件 JSON 字符串** | `componentToText()` 压平成纯文本 |
|
||||
| 王城 | 服务端可能返回 `null` | 显式判空,避免 `Number(null)` 算成 0 伪装成 (0,0) |
|
||||
| 封禁 / 日志 | 字段名是 `banned_player_name` / `timestamp` 等 | 按实测字段解析 |
|
||||
| V2 `/players` | 返回**对象** `{online, max, players[]}` 而不是数组,且没有坐标 | 按对象解析 |
|
||||
| V2 `/dragon` | 字段是 `dragonAlive`,没有 health / phase | 按实测字段解析 |
|
||||
| V2 `/time` | 服务端已算好 `time24` / `time12` / `phase` | 直接用,不再自己用 tick 换算 |
|
||||
| V2 `/equipment` | 物品的 `damage` 是**已损耗值** | 换算成 `remainingDurability` 再显示 |
|
||||
| V2 限流 | 429 带 `retryAfterSeconds` | 提示里直接写出还要等多少秒 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+87
-40
@@ -564,13 +564,15 @@ class SimpApiClient {
|
||||
class SimpApiV2Client {
|
||||
/**
|
||||
* @param {import('../util/logger').Logger} [logger]
|
||||
* @param {{baseUrl?: string, cacheTtlMs?: number}} [options]
|
||||
* @param {{baseUrl?: string, cacheTtlMs?: number, fetchImpl?: Function}} [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;
|
||||
/** 可注入的 fetch,便于测试替换 */
|
||||
this.fetchImpl = opts.fetchImpl || globalThis.fetch;
|
||||
this.apiKey = '';
|
||||
this.cache = new Map();
|
||||
this._lastHttpStatus = 0;
|
||||
@@ -601,23 +603,40 @@ class SimpApiV2Client {
|
||||
return this._lastError;
|
||||
}
|
||||
|
||||
/** @returns {Promise<Array<object>>} 在线玩家列表(含位置、世界、延迟等) */
|
||||
/**
|
||||
* 在线玩家:`GET /players`。
|
||||
*
|
||||
* 实测响应是**对象**而不是数组:
|
||||
* `{"online":8,"max":5000,"players":[{"name","displayName","uuid","world"}]}`
|
||||
* 注意里面**没有坐标与延迟**,只有所在世界。
|
||||
*
|
||||
* @returns {Promise<{online: number, max: number, players: 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);
|
||||
const object = body === null ? null : parseObject(body);
|
||||
if (!object) return { online: 0, max: 0, players: [] };
|
||||
const list = Array.isArray(object.players) ? object.players : [];
|
||||
return {
|
||||
online: intOr(object, 'online', list.length),
|
||||
max: intOr(object, 'max', 0),
|
||||
players: list.map((element) => ({
|
||||
name: stringOr(element, 'name', stringOr(element, 'displayName', '')),
|
||||
displayName: stringOr(element, 'displayName', ''),
|
||||
uuid: stringOr(element, 'uuid', ''),
|
||||
world: stringOr(element, 'world', '')
|
||||
})).filter((player) => player.name)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家装备与手持物品:`GET /players/{name}/equipment`。
|
||||
*
|
||||
* 实测响应:
|
||||
* `{"name","displayName","uuid","equipment":{"mainHand":{...},"offHand":null,"helmet":{...},...}}`
|
||||
* 物品结构为 `{type, key, amount, damage, maxDurability, unbreakable}` ——
|
||||
* 注意 `damage` 是**已损耗值**,剩余耐久要自己算 `maxDurability - damage`。
|
||||
*
|
||||
* @param {string} name
|
||||
* @returns {Promise<object|null>}
|
||||
*/
|
||||
@@ -625,42 +644,56 @@ class SimpApiV2Client {
|
||||
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 || {};
|
||||
const equipment = object.equipment || {};
|
||||
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)
|
||||
displayName: stringOr(object, 'displayName', ''),
|
||||
uuid: stringOr(object, 'uuid', ''),
|
||||
mainHand: v2ItemOf(equipment.mainHand),
|
||||
offHand: v2ItemOf(equipment.offHand),
|
||||
helmet: v2ItemOf(equipment.helmet),
|
||||
chestplate: v2ItemOf(equipment.chestplate),
|
||||
leggings: v2ItemOf(equipment.leggings),
|
||||
boots: v2ItemOf(equipment.boots)
|
||||
};
|
||||
}
|
||||
|
||||
/** @returns {Promise<object|null>} 游戏内时间 */
|
||||
/**
|
||||
* 游戏内时间:`GET /time`。
|
||||
*
|
||||
* 实测响应:`{"world":"world","tick":10085,"phase":"DAY","time12":"04:05 PM","time24":"16:05"}`
|
||||
* —— 服务端已经把时刻算好了,不需要客户端再用 tick 换算。
|
||||
*
|
||||
* @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
|
||||
world: stringOr(object, 'world', ''),
|
||||
tick: longOr(object, 'tick', 0),
|
||||
phase: stringOr(object, 'phase', ''),
|
||||
time12: stringOr(object, 'time12', ''),
|
||||
time24: stringOr(object, 'time24', '')
|
||||
};
|
||||
}
|
||||
|
||||
/** @returns {Promise<object|null>} 末影龙状态(接口级限速 1 QPM,务必按需调用) */
|
||||
/**
|
||||
* 末影龙状态:`GET /dragon`。
|
||||
*
|
||||
* 实测响应:`{"dragonAlive":false,"world":"world_the_end"}`。
|
||||
* **该接口限速 1 次/分钟**,超限返回 429 并带 `retryAfterSeconds`。
|
||||
*
|
||||
* @returns {Promise<object|null>}
|
||||
*/
|
||||
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', '')
|
||||
alive: object.dragonAlive === true,
|
||||
world: stringOr(object, 'world', '')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -694,7 +727,7 @@ class SimpApiV2Client {
|
||||
const timer = setTimeout(() => controller.abort(), 20000);
|
||||
try {
|
||||
this._requestCount++;
|
||||
const response = await fetch(this.baseUrl + path, {
|
||||
const response = await this.fetchImpl(this.baseUrl + path, {
|
||||
headers: { 'User-Agent': 'AutoStore-Node/1.0', Authorization: 'Bearer ' + this.apiKey },
|
||||
signal: controller.signal
|
||||
});
|
||||
@@ -709,9 +742,14 @@ class SimpApiV2Client {
|
||||
this._lastError = 'API-V2 Key 无效(401)——注意 V2 需要单独申请密钥';
|
||||
this.cache.clear();
|
||||
} else if (response.status === 429) {
|
||||
this._lastError = '触发限流(429):V2 接口配额很小(末影龙仅 1 次/分钟),请稍后重试';
|
||||
// 服务端会带 retryAfterSeconds,直接告诉用户还要等多久,比「稍后重试」有用得多
|
||||
const wait = intOr(parseObject(text) || {}, 'retryAfterSeconds', -1);
|
||||
this._lastError = '触发限流(429)'
|
||||
+ (wait > 0 ? `:还需等待 ${wait} 秒` : '')
|
||||
+ '。V2 接口配额很小(末影龙仅 1 次/分钟)';
|
||||
} else {
|
||||
this._lastError = 'HTTP ' + response.status;
|
||||
const detail = parseObject(text);
|
||||
this._lastError = detail && detail.error ? String(detail.error) : 'HTTP ' + response.status;
|
||||
}
|
||||
} catch (err) {
|
||||
this._lastError = err && err.message ? err.message : String(err);
|
||||
@@ -723,18 +761,27 @@ class SimpApiV2Client {
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析装备/物品对象为统一结构。
|
||||
* 解析 API-V2 的物品对象。
|
||||
*
|
||||
* 实测结构:`{type, key, amount, damage, maxDurability, unbreakable}`。
|
||||
* `damage` 是**已损耗值**而不是剩余耐久,所以这里同时给出两者,
|
||||
* 免得界面上把「损耗 157」显示成「剩余 157」。
|
||||
*
|
||||
* @param {object|null} item
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function itemOf(item) {
|
||||
function v2ItemOf(item) {
|
||||
if (!item || typeof item !== 'object') return null;
|
||||
const maxDurability = intOr(item, 'maxDurability', 0);
|
||||
const damage = intOr(item, 'damage', 0);
|
||||
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)
|
||||
type: stringOr(item, 'type', ''),
|
||||
key: stringOr(item, 'key', ''),
|
||||
amount: intOr(item, 'amount', 1),
|
||||
damage,
|
||||
maxDurability,
|
||||
remainingDurability: maxDurability > 0 ? Math.max(0, maxDurability - damage) : -1,
|
||||
unbreakable: item.unbreakable === true
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+57
-54
@@ -1123,6 +1123,41 @@ function onEnter(input, handler) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染装备表(API-V2 的 `equipment` 结构)。
|
||||
*
|
||||
* 服务端给的 `damage` 是**已损耗值**,所以这里显示「剩余 / 上限」,
|
||||
* 并把耐久百分比做成颜色提示 —— 挂机时最关心的就是装备还剩多少。
|
||||
*
|
||||
* @param {HTMLElement} box
|
||||
* @param {object} data `/api/simp/v2/equipment/:name` 的 data
|
||||
*/
|
||||
function renderEquipment(box, data) {
|
||||
const slots = [
|
||||
['主手', data.mainHand], ['副手', data.offHand], ['头盔', data.helmet],
|
||||
['胸甲', data.chestplate], ['护腿', data.leggings], ['靴子', data.boots]
|
||||
];
|
||||
box.appendChild(simpTable([
|
||||
['部位', (r) => r.slot],
|
||||
['物品', (r) => r.name],
|
||||
['数量', (r) => r.amount],
|
||||
['耐久', (r) => r.durability],
|
||||
['剩余', (r) => r.percent]
|
||||
], slots.map(([slot, item]) => {
|
||||
if (!item) return { slot, name: '空', amount: '-', durability: '-', percent: '-' };
|
||||
const hasDurability = item.maxDurability > 0 && !item.unbreakable;
|
||||
const remaining = hasDurability ? item.remainingDurability : -1;
|
||||
const percent = hasDurability ? Math.round((remaining / item.maxDurability) * 100) : null;
|
||||
return {
|
||||
slot,
|
||||
name: item.key || item.type,
|
||||
amount: item.amount,
|
||||
durability: hasDurability ? `${remaining} / ${item.maxDurability}` : (item.unbreakable ? '不可破坏' : '-'),
|
||||
percent: percent === null ? '-' : `${percent}%`
|
||||
};
|
||||
}), null, [2, 3]));
|
||||
}
|
||||
|
||||
// ---------- 概览 ----------
|
||||
|
||||
async function renderSimpOverview(body) {
|
||||
@@ -1392,24 +1427,7 @@ async function renderSimpPlayers(body, force) {
|
||||
|
||||
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]));
|
||||
renderEquipment(wrap, v2.data);
|
||||
} else {
|
||||
wrap.appendChild(simpEmpty(v2.ok ? '没有装备数据' : v2.message));
|
||||
}
|
||||
@@ -1481,33 +1499,35 @@ async function renderSimpV2(body, force) {
|
||||
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('在线玩家', 'v2online', '/api/simp/v2/online', (box, data) => {
|
||||
box.appendChild(simpStats([
|
||||
['在线人数', `${data.online} / ${data.max || '?'}`],
|
||||
['世界', data.players.length > 0 ? [...new Set(data.players.map((p) => p.world))].join(', ') : '-']
|
||||
]));
|
||||
if (data.players.length > 0) {
|
||||
box.appendChild(simpTable([
|
||||
['玩家', (r) => r.name],
|
||||
['显示名', (r) => r.displayName],
|
||||
['世界', (r) => r.world],
|
||||
['UUID', (r) => r.uuid]
|
||||
], data.players));
|
||||
}
|
||||
}));
|
||||
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]
|
||||
['游戏内时刻', t.time24 || '-'],
|
||||
['12 小时制', t.time12 || '-'],
|
||||
['时段', t.phase || '-'],
|
||||
['世界', t.world || '-'],
|
||||
['tick', t.tick]
|
||||
]));
|
||||
}));
|
||||
actions.appendChild(make('末影龙', 'v2dragon', '/api/simp/v2/dragon', (box, d) => {
|
||||
box.appendChild(simpStats([
|
||||
['存活', d.alive ? '是' : '否'],
|
||||
['生命', Number.isFinite(d.health) ? d.health : '-'],
|
||||
['阶段', d.phase || '-']
|
||||
['所在世界', d.world || '-']
|
||||
]));
|
||||
box.appendChild(el('p', 'hint', '该接口限速 1 次/分钟,请勿频繁点击。'));
|
||||
box.appendChild(el('p', 'hint', '该接口限速 1 次/分钟,超限会返回还需等待多少秒。'));
|
||||
}));
|
||||
body.appendChild(actions);
|
||||
body.appendChild(el('p', 'hint', 'API-V2 是独立于 API 的另一套密钥体系(需在密钥中心单独申请),接口级配额很小,因此不做自动轮询。'));
|
||||
@@ -1529,24 +1549,7 @@ async function renderSimpV2(body, force) {
|
||||
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]));
|
||||
renderEquipment(box, result.data);
|
||||
});
|
||||
onEnter(equipInput, () => equipBtn.click());
|
||||
equipRow.appendChild(equipInput);
|
||||
|
||||
+125
-2
@@ -96,12 +96,135 @@ test('客户端在未配置 Key 时把错误写清楚,而不是静默失败',
|
||||
assert.strictEqual(client.lastError(), '未配置 API Key');
|
||||
|
||||
const v2 = new SimpApiV2Client(null);
|
||||
// 列表类接口在无 Key 时返回空列表,但 lastError 必须说明原因
|
||||
assert.deepStrictEqual(await v2.getOnlinePlayers(), []);
|
||||
// 在线列表在无 Key 时返回空结构,但 lastError 必须说明原因
|
||||
assert.deepStrictEqual(await v2.getOnlinePlayers(), { online: 0, max: 0, players: [] });
|
||||
assert.strictEqual(v2.lastError(), '未配置 API-V2 Key');
|
||||
assert.strictEqual(await v2.getDragon(), null);
|
||||
});
|
||||
|
||||
// ==================== API-V2(字段名以实测响应为准)====================
|
||||
|
||||
/** 造一个返回固定响应的假 fetch */
|
||||
function stubV2(responses) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
fetchImpl: async (url) => {
|
||||
calls.push(url);
|
||||
const path = url.split('/api-v2/')[1] || '';
|
||||
const body = responses[path];
|
||||
if (body === undefined) return { status: 404, text: async () => '{"error":"not found"}' };
|
||||
if (typeof body === 'object' && body.__status) {
|
||||
return { status: body.__status, text: async () => JSON.stringify(body.body) };
|
||||
}
|
||||
return { status: 200, text: async () => JSON.stringify(body) };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 用桩 fetch 建一个 V2 客户端 */
|
||||
function v2With(responses) {
|
||||
const stub = stubV2(responses);
|
||||
const client = new SimpApiV2Client(null);
|
||||
client.setApiKey('test-key');
|
||||
client.fetchImpl = stub.fetchImpl;
|
||||
return { client, stub };
|
||||
}
|
||||
|
||||
test('getOnlinePlayers 解析 {online,max,players} 结构而不是裸数组', async () => {
|
||||
const { client } = v2With({
|
||||
players: {
|
||||
online: 2,
|
||||
max: 5000,
|
||||
players: [
|
||||
{ name: 'wans2023', displayName: 'wans2023', uuid: 'u1', world: 'world' },
|
||||
{ name: 'xiaofan', displayName: 'xiaofan', uuid: 'u2', world: 'world' }
|
||||
]
|
||||
}
|
||||
});
|
||||
const result = await client.getOnlinePlayers();
|
||||
assert.strictEqual(result.online, 2);
|
||||
assert.strictEqual(result.max, 5000);
|
||||
assert.strictEqual(result.players.length, 2);
|
||||
assert.strictEqual(result.players[0].name, 'wans2023');
|
||||
assert.strictEqual(result.players[0].world, 'world');
|
||||
});
|
||||
|
||||
test('getGameTime 直接使用服务端算好的时刻,不再用 tick 换算', async () => {
|
||||
const { client } = v2With({
|
||||
time: { world: 'world', tick: 10085, phase: 'DAY', time12: '04:05 PM', time24: '16:05' }
|
||||
});
|
||||
const time = await client.getGameTime();
|
||||
assert.strictEqual(time.time24, '16:05');
|
||||
assert.strictEqual(time.time12, '04:05 PM');
|
||||
assert.strictEqual(time.phase, 'DAY');
|
||||
assert.strictEqual(time.tick, 10085);
|
||||
});
|
||||
|
||||
test('getDragon 读的是 dragonAlive 而不是 alive', async () => {
|
||||
const { client } = v2With({ dragon: { dragonAlive: false, world: 'world_the_end' } });
|
||||
const dragon = await client.getDragon();
|
||||
assert.strictEqual(dragon.alive, false);
|
||||
assert.strictEqual(dragon.world, 'world_the_end');
|
||||
|
||||
const alive = v2With({ dragon: { dragonAlive: true, world: 'world_the_end' } });
|
||||
assert.strictEqual((await alive.client.getDragon()).alive, true);
|
||||
});
|
||||
|
||||
test('getPlayerEquipment 解析 equipment 子对象,并把 damage 换算成剩余耐久', async () => {
|
||||
const { client } = v2With({
|
||||
'players/wans2023/equipment': {
|
||||
name: 'wans2023',
|
||||
displayName: 'wans2023',
|
||||
uuid: 'u1',
|
||||
equipment: {
|
||||
mainHand: { type: 'BAMBOO_PLANKS', key: 'minecraft:bamboo_planks', amount: 9, damage: 0, maxDurability: 0, unbreakable: false },
|
||||
offHand: null,
|
||||
helmet: { type: 'IRON_HELMET', key: 'minecraft:iron_helmet', amount: 1, damage: 157, maxDurability: 165, unbreakable: false }
|
||||
}
|
||||
}
|
||||
});
|
||||
const equipment = await client.getPlayerEquipment('wans2023');
|
||||
assert.strictEqual(equipment.player, 'wans2023');
|
||||
assert.strictEqual(equipment.mainHand.key, 'minecraft:bamboo_planks');
|
||||
assert.strictEqual(equipment.mainHand.amount, 9);
|
||||
assert.strictEqual(equipment.mainHand.maxDurability, 0, '方块类物品没有耐久');
|
||||
assert.strictEqual(equipment.offHand, null);
|
||||
// damage=157 / max=165 -> 剩余 8,而不是把 157 当成剩余耐久
|
||||
assert.strictEqual(equipment.helmet.damage, 157);
|
||||
assert.strictEqual(equipment.helmet.maxDurability, 165);
|
||||
assert.strictEqual(equipment.helmet.remainingDurability, 8);
|
||||
assert.strictEqual(equipment.chestplate, null);
|
||||
});
|
||||
|
||||
test('V2 触发限流时把 retryAfterSeconds 写进错误信息', async () => {
|
||||
const { client } = v2With({
|
||||
dragon: { __status: 429, body: { error: '请求过于频繁,已超出限速', limit: 1, retryAfterSeconds: 16 } }
|
||||
});
|
||||
assert.strictEqual(await client.getDragon(), null);
|
||||
assert.ok(client.lastError().includes('还需等待 16 秒'), '实际:' + client.lastError());
|
||||
assert.strictEqual(client.lastHttpStatus(), 429);
|
||||
});
|
||||
|
||||
test('V2 的 401 提示要点明「需要单独申请密钥」', async () => {
|
||||
const { client } = v2With({
|
||||
time: { __status: 401, body: { error: 'missing or invalid api key' } }
|
||||
});
|
||||
assert.strictEqual(await client.getGameTime(), null);
|
||||
assert.ok(client.lastError().includes('单独申请'), '实际:' + client.lastError());
|
||||
});
|
||||
|
||||
test('V2 响应走本地缓存,不会因为反复点击把配额打满', async () => {
|
||||
const { client, stub } = v2With({
|
||||
time: { world: 'world', tick: 1, phase: 'DAY', time12: 'x', time24: '06:00' }
|
||||
});
|
||||
await client.getGameTime();
|
||||
await client.getGameTime();
|
||||
await client.getGameTime();
|
||||
assert.strictEqual(stub.calls.length, 1, '三次查询只应打一次接口');
|
||||
assert.strictEqual(client.snapshot().cacheHits, 2);
|
||||
});
|
||||
|
||||
test('setApiKey 变更时清空缓存,避免旧 Key 的响应串味', () => {
|
||||
const client = new SimpApiClient(null);
|
||||
client.cache.set('info/unions', { body: '[]', expiresAt: Date.now() + 10000 });
|
||||
|
||||
在新工单中引用
屏蔽一个用户