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
# ==================== 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
### 新增
+5
查看文件
@@ -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
};
}
+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) {
@@ -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) => {
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],
['坐标', (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]));
['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 });