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 行删除
+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 });