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,全部通过。
这个提交包含在:
lyizai
2026-09-28 13:56:41 +08:00
父节点 b9cae1aac2
当前提交 71e78f06dc
共修改 6 个文件,包含 304 行新增和 97 行删除
+8 -1
查看文件
@@ -37,5 +37,12 @@ DYNMAP_BASE=http://bgjq.simpfun.cn
DYNMAP_WORLD=minecraft_overworld DYNMAP_WORLD=minecraft_overworld
# ==================== SimpCore 开放 API(可选) ==================== # ==================== 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> 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/)。 本项目遵循 [约定式提交](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 ## [1.5.0] - 2026-09-28
### 新增 ### 新增
+5
查看文件
@@ -207,6 +207,11 @@ V2 的接口级配额很小(末影龙只有 **1 次/分钟**),因此 V2
| 公告 | `message` 是**聊天组件 JSON 字符串** | `componentToText()` 压平成纯文本 | | 公告 | `message` 是**聊天组件 JSON 字符串** | `componentToText()` 压平成纯文本 |
| 王城 | 服务端可能返回 `null` | 显式判空,避免 `Number(null)` 算成 0 伪装成 (0,0) | | 王城 | 服务端可能返回 `null` | 显式判空,避免 `Number(null)` 算成 0 伪装成 (0,0) |
| 封禁 / 日志 | 字段名是 `banned_player_name` / `timestamp` 等 | 按实测字段解析 | | 封禁 / 日志 | 字段名是 `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 { class SimpApiV2Client {
/** /**
* @param {import('../util/logger').Logger} [logger] * @param {import('../util/logger').Logger} [logger]
* @param {{baseUrl?: string, cacheTtlMs?: number}} [options] * @param {{baseUrl?: string, cacheTtlMs?: number, fetchImpl?: Function}} [options]
*/ */
constructor(logger, options) { constructor(logger, options) {
const opts = options || {}; const opts = options || {};
this.logger = logger || null; this.logger = logger || null;
this.baseUrl = opts.baseUrl || BASE_URL_V2; this.baseUrl = opts.baseUrl || BASE_URL_V2;
this.cacheTtlMs = opts.cacheTtlMs || DEFAULT_CACHE_TTL_V2_MS; this.cacheTtlMs = opts.cacheTtlMs || DEFAULT_CACHE_TTL_V2_MS;
/** 可注入的 fetch,便于测试替换 */
this.fetchImpl = opts.fetchImpl || globalThis.fetch;
this.apiKey = ''; this.apiKey = '';
this.cache = new Map(); this.cache = new Map();
this._lastHttpStatus = 0; this._lastHttpStatus = 0;
@@ -601,23 +603,40 @@ class SimpApiV2Client {
return this._lastError; 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() { async getOnlinePlayers() {
const body = await this._getCachedOrFetch('players'); const body = await this._getCachedOrFetch('players');
if (body === null) return []; const object = body === null ? null : parseObject(body);
return parseArray(body).map((element) => ({ if (!object) return { online: 0, max: 0, players: [] };
name: stringOr(element, 'name', stringOr(element, 'username', '')), const list = Array.isArray(object.players) ? object.players : [];
world: stringOr(element, 'world', ''), return {
x: doubleOr(element, 'x', Number.NaN), online: intOr(object, 'online', list.length),
y: doubleOr(element, 'y', Number.NaN), max: intOr(object, 'max', 0),
z: doubleOr(element, 'z', Number.NaN), players: list.map((element) => ({
ping: intOr(element, 'ping', -1), name: stringOr(element, 'name', stringOr(element, 'displayName', '')),
gameMode: stringOr(element, 'gamemode', stringOr(element, 'game_mode', '')) displayName: stringOr(element, 'displayName', ''),
})).filter((player) => player.name); uuid: stringOr(element, 'uuid', ''),
world: stringOr(element, 'world', '')
})).filter((player) => player.name)
};
} }
/** /**
* 玩家装备与手持物品:`GET /players/{name}/equipment`。 * 玩家装备与手持物品:`GET /players/{name}/equipment`。
*
* 实测响应:
* `{"name","displayName","uuid","equipment":{"mainHand":{...},"offHand":null,"helmet":{...},...}}`
* 物品结构为 `{type, key, amount, damage, maxDurability, unbreakable}` ——
* 注意 `damage` 是**已损耗值**,剩余耐久要自己算 `maxDurability - damage`。
*
* @param {string} name * @param {string} name
* @returns {Promise<object|null>} * @returns {Promise<object|null>}
*/ */
@@ -625,42 +644,56 @@ class SimpApiV2Client {
const body = await this._getCachedOrFetch(`players/${encodeURIComponent(String(name))}/equipment`); const body = await this._getCachedOrFetch(`players/${encodeURIComponent(String(name))}/equipment`);
const object = body === null ? null : parseObject(body); const object = body === null ? null : parseObject(body);
if (!object) return null; if (!object) return null;
const mainHand = object.main_hand || object.mainHand || null; const equipment = object.equipment || {};
const offHand = object.off_hand || object.offHand || null;
const armor = object.armor || {};
return { return {
player: stringOr(object, 'name', String(name)), player: stringOr(object, 'name', String(name)),
mainHand: itemOf(mainHand), displayName: stringOr(object, 'displayName', ''),
offHand: itemOf(offHand), uuid: stringOr(object, 'uuid', ''),
helmet: itemOf(armor.helmet), mainHand: v2ItemOf(equipment.mainHand),
chestplate: itemOf(armor.chestplate), offHand: v2ItemOf(equipment.offHand),
leggings: itemOf(armor.leggings), helmet: v2ItemOf(equipment.helmet),
boots: itemOf(armor.boots) 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() { async getGameTime() {
const body = await this._getCachedOrFetch('time'); const body = await this._getCachedOrFetch('time');
const object = body === null ? null : parseObject(body); const object = body === null ? null : parseObject(body);
if (!object) return null; if (!object) return null;
return { return {
worldTime: longOr(object, 'world_time', longOr(object, 'time', 0)), world: stringOr(object, 'world', ''),
dayTime: longOr(object, 'day_time', longOr(object, 'daytime', 0)), tick: longOr(object, 'tick', 0),
day: intOr(object, 'day', -1), phase: stringOr(object, 'phase', ''),
isDay: object.is_day === undefined ? null : object.is_day === true 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() { async getDragon() {
const body = await this._getCachedOrFetch('dragon'); const body = await this._getCachedOrFetch('dragon');
const object = body === null ? null : parseObject(body); const object = body === null ? null : parseObject(body);
if (!object) return null; if (!object) return null;
return { return {
alive: object.alive === true, alive: object.dragonAlive === true,
health: doubleOr(object, 'health', Number.NaN), world: stringOr(object, 'world', '')
phase: stringOr(object, 'phase', '')
}; };
} }
@@ -694,7 +727,7 @@ class SimpApiV2Client {
const timer = setTimeout(() => controller.abort(), 20000); const timer = setTimeout(() => controller.abort(), 20000);
try { try {
this._requestCount++; 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 }, headers: { 'User-Agent': 'AutoStore-Node/1.0', Authorization: 'Bearer ' + this.apiKey },
signal: controller.signal signal: controller.signal
}); });
@@ -709,9 +742,14 @@ class SimpApiV2Client {
this._lastError = 'API-V2 Key 无效(401)——注意 V2 需要单独申请密钥'; this._lastError = 'API-V2 Key 无效(401)——注意 V2 需要单独申请密钥';
this.cache.clear(); this.cache.clear();
} else if (response.status === 429) { } 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 { } else {
this._lastError = 'HTTP ' + response.status; const detail = parseObject(text);
this._lastError = detail && detail.error ? String(detail.error) : 'HTTP ' + response.status;
} }
} catch (err) { } catch (err) {
this._lastError = err && err.message ? err.message : String(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 * @param {object|null} item
* @returns {object|null} * @returns {object|null}
*/ */
function itemOf(item) { function v2ItemOf(item) {
if (!item || typeof item !== 'object') return null; if (!item || typeof item !== 'object') return null;
const maxDurability = intOr(item, 'maxDurability', 0);
const damage = intOr(item, 'damage', 0);
return { return {
name: stringOr(item, 'name', stringOr(item, 'type', '')), type: stringOr(item, 'type', ''),
displayName: stringOr(item, 'display_name', ''), key: stringOr(item, 'key', ''),
count: intOr(item, 'count', 1), amount: intOr(item, 'amount', 1),
durability: intOr(item, 'durability', -1), damage,
maxDurability: intOr(item, 'max_durability', -1) maxDurability,
remainingDurability: maxDurability > 0 ? Math.max(0, maxDurability - damage) : -1,
unbreakable: item.unbreakable === true
}; };
} }
+54 -51
查看文件
@@ -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) { async function renderSimpOverview(body) {
@@ -1392,24 +1427,7 @@ async function renderSimpPlayers(body, force) {
wrap.appendChild(el('h3', null, '装备与手持(API-V2)')); wrap.appendChild(el('h3', null, '装备与手持(API-V2)'));
if (v2.ok && v2.data) { if (v2.ok && v2.data) {
const slots = [ renderEquipment(wrap, v2.data);
['主手', 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 { } else {
wrap.appendChild(simpEmpty(v2.ok ? '没有装备数据' : v2.message)); wrap.appendChild(simpEmpty(v2.ok ? '没有装备数据' : v2.message));
} }
@@ -1481,33 +1499,35 @@ async function renderSimpV2(body, force) {
return btn; return btn;
}; };
actions.appendChild(make('在线玩家', 'v2online', '/api/simp/v2/online', (box, rows) => { 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([ box.appendChild(simpTable([
['玩家', (r) => r.name], ['玩家', (r) => r.name],
['显示名', (r) => r.displayName],
['世界', (r) => r.world], ['世界', (r) => r.world],
['坐标', (r) => (Number.isFinite(r.x) ? `${r.x.toFixed(0)}, ${r.y.toFixed(0)}, ${r.z.toFixed(0)}` : '-')], ['UUID', (r) => r.uuid]
['延迟', (r) => (r.ping >= 0 ? r.ping + ' ms' : '-')], ], data.players));
['模式', (r) => r.gameMode] }
], rows, null, [3]));
})); }));
actions.appendChild(make('游戏时间', 'v2time', '/api/simp/v2/time', (box, t) => { 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([ box.appendChild(simpStats([
['游戏内时刻', `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`], ['游戏内时刻', t.time24 || '-'],
['天数', t.day >= 0 ? t.day : '-'], ['12 小时制', t.time12 || '-'],
['白天', t.isDay === null ? '-' : (t.isDay ? '是' : '否')], ['时段', t.phase || '-'],
['world_time', t.worldTime] ['世界', t.world || '-'],
['tick', t.tick]
])); ]));
})); }));
actions.appendChild(make('末影龙', 'v2dragon', '/api/simp/v2/dragon', (box, d) => { actions.appendChild(make('末影龙', 'v2dragon', '/api/simp/v2/dragon', (box, d) => {
box.appendChild(simpStats([ box.appendChild(simpStats([
['存活', d.alive ? '是' : '否'], ['存活', d.alive ? '是' : '否'],
['生命', Number.isFinite(d.health) ? d.health : '-'], ['所在世界', d.world || '-']
['阶段', d.phase || '-']
])); ]));
box.appendChild(el('p', 'hint', '该接口限速 1 次/分钟,请勿频繁点击。')); box.appendChild(el('p', 'hint', '该接口限速 1 次/分钟,超限会返回还需等待多少秒。'));
})); }));
body.appendChild(actions); body.appendChild(actions);
body.appendChild(el('p', 'hint', 'API-V2 是独立于 API 的另一套密钥体系(需在密钥中心单独申请),接口级配额很小,因此不做自动轮询。')); 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); const result = await simpFetch('v2equip:' + name, '/api/simp/v2/equipment/' + encodeURIComponent(name), force);
box.innerHTML = ''; box.innerHTML = '';
if (!simpResult(box, result, '没有装备数据')) return; if (!simpResult(box, result, '没有装备数据')) return;
const d = result.data; renderEquipment(box, 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()); onEnter(equipInput, () => equipBtn.click());
equipRow.appendChild(equipInput); equipRow.appendChild(equipInput);
+125 -2
查看文件
@@ -96,12 +96,135 @@ test('客户端在未配置 Key 时把错误写清楚,而不是静默失败',
assert.strictEqual(client.lastError(), '未配置 API Key'); assert.strictEqual(client.lastError(), '未配置 API Key');
const v2 = new SimpApiV2Client(null); const v2 = new SimpApiV2Client(null);
// 列表类接口在无 Key 时返回空列表,但 lastError 必须说明原因 // 在线列表在无 Key 时返回空结构,但 lastError 必须说明原因
assert.deepStrictEqual(await v2.getOnlinePlayers(), []); assert.deepStrictEqual(await v2.getOnlinePlayers(), { online: 0, max: 0, players: [] });
assert.strictEqual(v2.lastError(), '未配置 API-V2 Key'); assert.strictEqual(v2.lastError(), '未配置 API-V2 Key');
assert.strictEqual(await v2.getDragon(), null); 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 的响应串味', () => { test('setApiKey 变更时清空缓存,避免旧 Key 的响应串味', () => {
const client = new SimpApiClient(null); const client = new SimpApiClient(null);
client.cache.set('info/unions', { body: '[]', expiresAt: Date.now() + 10000 }); client.cache.set('info/unions', { body: '[]', expiresAt: Date.now() + 10000 });