From 71e78f06dc6b494ea0984e569e4ce16dd8d4fd79 Mon Sep 17 00:00:00 2001 From: lyizai <3945107889@qq.com> Date: Mon, 28 Sep 2026 13:56:41 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8C=89=E5=AE=9E=E6=B5=8B=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E4=BF=AE=E6=AD=A3=20SimpCore=20API-V2=20=E7=9A=84?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接入 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,全部通过。 --- .env.example | 9 ++- CHANGELOG.md | 22 +++++++ README.md | 5 ++ src/core/simpApiClient.js | 127 +++++++++++++++++++++++++------------ src/web/public/app.js | 111 ++++++++++++++++---------------- test/simpApiClient.test.js | 127 ++++++++++++++++++++++++++++++++++++- 6 files changed, 304 insertions(+), 97 deletions(-) diff --git a/.env.example b/.env.example index 63a1209..f12316a 100644 --- a/.env.example +++ b/.env.example @@ -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= +# 第二套 API-V2(ServerOpenAPI):在线玩家 / 装备 / 游戏时间 / 末影龙 +# https://bgjq.simpfun.cn/api-v2/ +# 配额很小(末影龙仅 1 次/分钟),程序只按需查询、不做轮询 +SIMP_API_V2_KEY= diff --git a/CHANGELOG.md b/CHANGELOG.md index 688484a..a348385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### 新增 diff --git a/README.md b/README.md index 46371ae..3963151 100644 --- a/README.md +++ b/README.md @@ -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` | 提示里直接写出还要等多少秒 | --- diff --git a/src/core/simpApiClient.js b/src/core/simpApiClient.js index 61107a4..b4e6a0f 100644 --- a/src/core/simpApiClient.js +++ b/src/core/simpApiClient.js @@ -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>} 在线玩家列表(含位置、世界、延迟等) */ + /** + * 在线玩家:`GET /players`。 + * + * 实测响应是**对象**而不是数组: + * `{"online":8,"max":5000,"players":[{"name","displayName","uuid","world"}]}` + * 注意里面**没有坐标与延迟**,只有所在世界。 + * + * @returns {Promise<{online: number, max: number, players: Array}>} + */ 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} */ @@ -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} 游戏内时间 */ + /** + * 游戏内时间:`GET /time`。 + * + * 实测响应:`{"world":"world","tick":10085,"phase":"DAY","time12":"04:05 PM","time24":"16:05"}` + * —— 服务端已经把时刻算好了,不需要客户端再用 tick 换算。 + * + * @returns {Promise} + */ async getGameTime() { const body = await this._getCachedOrFetch('time'); const object = body === null ? null : parseObject(body); if (!object) return null; return { - worldTime: longOr(object, 'world_time', longOr(object, 'time', 0)), - dayTime: longOr(object, 'day_time', longOr(object, 'daytime', 0)), - day: intOr(object, 'day', -1), - isDay: object.is_day === undefined ? null : object.is_day === true + world: stringOr(object, 'world', ''), + tick: longOr(object, 'tick', 0), + phase: stringOr(object, 'phase', ''), + time12: stringOr(object, 'time12', ''), + time24: stringOr(object, 'time24', '') }; } - /** @returns {Promise} 末影龙状态(接口级限速 1 QPM,务必按需调用) */ + /** + * 末影龙状态:`GET /dragon`。 + * + * 实测响应:`{"dragonAlive":false,"world":"world_the_end"}`。 + * **该接口限速 1 次/分钟**,超限返回 429 并带 `retryAfterSeconds`。 + * + * @returns {Promise} + */ 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 }; } diff --git a/src/web/public/app.js b/src/web/public/app.js index cdfd8a9..57dbbb2 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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); diff --git a/test/simpApiClient.test.js b/test/simpApiClient.test.js index 4bf44ce..eab7647 100644 --- a/test/simpApiClient.test.js +++ b/test/simpApiClient.test.js @@ -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 });