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,全部通过。
这个提交包含在:
+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);
|
||||
|
||||
在新工单中引用
屏蔽一个用户