Add 冰眼/蛇矛 aliases, correct Tiny fears to Hydra's Breath, and fix production match detail POST 405. Co-authored-by: Cursor <cursoragent@cursor.com>
269 lines
8.1 KiB
JavaScript
269 lines
8.1 KiB
JavaScript
/** OpenDota match → Climperor detail JSON (+ R2 helpers). */
|
|
|
|
const OPENDOTA = "https://api.opendota.com/api";
|
|
|
|
function asInt(v, fallback = 0) {
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? Math.trunc(n) : fallback;
|
|
}
|
|
|
|
function itemIds(player) {
|
|
const out = [];
|
|
for (let i = 0; i < 6; i++) {
|
|
const iid = asInt(player[`item_${i}`], 0);
|
|
if (iid > 0) out.push(iid);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function kda(kills, deaths, assists) {
|
|
return Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10;
|
|
}
|
|
|
|
function mvpScore(p) {
|
|
const k = asInt(p.kills);
|
|
const d = asInt(p.deaths);
|
|
const a = asInt(p.assists);
|
|
const dmg = asInt(p.hero_damage);
|
|
const nw = asInt(p.net_worth);
|
|
return (k * 1.5 + a + dmg / 1000.0 + nw / 2000.0) / Math.max(d, 1);
|
|
}
|
|
|
|
export function isNormalizedMatch(data) {
|
|
return !!(
|
|
data &&
|
|
typeof data === "object" &&
|
|
Array.isArray(data.players) &&
|
|
data.radiant &&
|
|
data.dire
|
|
);
|
|
}
|
|
|
|
export async function odFetch(path, env, query = {}) {
|
|
const url = new URL(`${OPENDOTA}${path}`);
|
|
for (const [k, v] of Object.entries(query)) {
|
|
if (v != null) url.searchParams.set(k, String(v));
|
|
}
|
|
const key = ((env && env.OPENDOTA_API_KEY) || "").trim();
|
|
if (key) url.searchParams.set("api_key", key);
|
|
const headers = {
|
|
Accept: "application/json",
|
|
"User-Agent": "climperor-pages-players",
|
|
};
|
|
let lastStatus = 0;
|
|
for (let attempt = 0; attempt < 4; attempt++) {
|
|
if (attempt > 0) {
|
|
await new Promise((r) => setTimeout(r, 400 * 2 ** (attempt - 1)));
|
|
}
|
|
const res = await fetch(url.toString(), { headers });
|
|
lastStatus = res.status;
|
|
if (res.status === 403 || res.status === 404) return null;
|
|
if (res.status === 429 || res.status >= 500) continue;
|
|
if (!res.ok) throw new Error(`OpenDota ${res.status} ${path}`);
|
|
return res.json();
|
|
}
|
|
if (lastStatus === 429 || lastStatus >= 500) return null;
|
|
throw new Error(`OpenDota ${lastStatus} ${path}`);
|
|
}
|
|
|
|
export async function loadHeroMap(env) {
|
|
const rows = await odFetch("/heroes", env);
|
|
const map = new Map();
|
|
if (!Array.isArray(rows)) return map;
|
|
for (const h of rows) {
|
|
if (!h || h.id == null) continue;
|
|
const key = String(h.name || "").replace(/^npc_dota_hero_/, "") || null;
|
|
map.set(Number(h.id), { key, name_loc: h.localized_name || key });
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/** Build Climperor match-detail JSON from OpenDota /matches/{id}. */
|
|
export function normalizeMatch(match, heroMap, focusAccountId = null) {
|
|
const playersRaw = match && match.players;
|
|
if (!Array.isArray(playersRaw) || !playersRaw.length) return null;
|
|
const matchId = asInt(match.match_id, 0);
|
|
if (matchId <= 0) return null;
|
|
|
|
const radiantWin = !!match.radiant_win;
|
|
const duration = asInt(match.duration);
|
|
let startTime = null;
|
|
if (match.start_time != null) {
|
|
const t = asInt(match.start_time, NaN);
|
|
startTime = Number.isFinite(t) ? t : null;
|
|
}
|
|
|
|
const teamKills = [0, 0];
|
|
const teamNw = [0, 0];
|
|
const teamDmg = [0, 0];
|
|
const slim = [];
|
|
|
|
for (const p of playersRaw) {
|
|
if (!p || typeof p !== "object") continue;
|
|
const slot = asInt(p.player_slot);
|
|
const isRadiant = slot < 128;
|
|
const side = isRadiant ? 0 : 1;
|
|
const kills = asInt(p.kills);
|
|
const deaths = asInt(p.deaths);
|
|
const assists = asInt(p.assists);
|
|
const heroDamage = asInt(p.hero_damage);
|
|
let netWorth = asInt(p.net_worth);
|
|
if (netWorth <= 0) netWorth = asInt(p.gold) + asInt(p.gold_spent);
|
|
teamKills[side] += kills;
|
|
teamNw[side] += netWorth;
|
|
teamDmg[side] += heroDamage;
|
|
|
|
const heroId = asInt(p.hero_id);
|
|
const hero = (heroMap && heroMap.get(heroId)) || {};
|
|
let accountId = null;
|
|
if (p.account_id != null) {
|
|
const a = asInt(p.account_id, NaN);
|
|
accountId = Number.isFinite(a) ? a : null;
|
|
}
|
|
let personaname = p.personaname;
|
|
if (typeof personaname === "string") {
|
|
personaname = personaname.trim() || null;
|
|
} else {
|
|
personaname = null;
|
|
}
|
|
let partyId = null;
|
|
if (p.party_id != null) {
|
|
const pid = asInt(p.party_id, NaN);
|
|
if (Number.isFinite(pid) && pid > 0) partyId = pid;
|
|
}
|
|
|
|
slim.push({
|
|
account_id: accountId,
|
|
personaname,
|
|
hero_id: heroId,
|
|
hero_key: hero.key || null,
|
|
hero_name_loc: hero.name_loc || hero.key || null,
|
|
level: asInt(p.level),
|
|
kills,
|
|
deaths,
|
|
assists,
|
|
kda: kda(kills, deaths, assists),
|
|
hero_damage: heroDamage,
|
|
net_worth: netWorth,
|
|
party_id: partyId,
|
|
party_label: null,
|
|
items: itemIds(p),
|
|
is_radiant: isRadiant,
|
|
won: isRadiant ? radiantWin : !radiantWin,
|
|
_mvp: mvpScore(p),
|
|
_side: side,
|
|
_slot: slot,
|
|
});
|
|
}
|
|
|
|
if (slim.length < 2) return null;
|
|
|
|
const partyCounts = new Map();
|
|
for (const p of slim) {
|
|
if (typeof p.party_id === "number" && p.party_id > 0) {
|
|
partyCounts.set(p.party_id, (partyCounts.get(p.party_id) || 0) + 1);
|
|
}
|
|
}
|
|
const partyLabels = new Map();
|
|
for (const [pid, n] of [...partyCounts.entries()].sort((a, b) => a[0] - b[0])) {
|
|
if (n >= 2) partyLabels.set(pid, String.fromCharCode(65 + partyLabels.size));
|
|
}
|
|
|
|
for (const p of slim) {
|
|
const side = p._side;
|
|
const tk = teamKills[side] || 1;
|
|
const td = teamDmg[side] || 1;
|
|
p.participation = Math.round(((p.kills + p.assists) / tk) * 1000) / 1000;
|
|
p.damage_share = Math.round((p.hero_damage / td) * 1000) / 1000;
|
|
p.party_label =
|
|
typeof p.party_id === "number" ? partyLabels.get(p.party_id) || null : null;
|
|
}
|
|
|
|
const mvp = slim.reduce((best, p) => (p._mvp > best._mvp ? p : best), slim[0]);
|
|
const mvpAccount = mvp.account_id;
|
|
const mvpSlot = mvp._slot;
|
|
for (const p of slim) {
|
|
p.is_mvp =
|
|
mvpAccount != null ? p.account_id === mvpAccount : p._slot === mvpSlot;
|
|
delete p._mvp;
|
|
delete p._side;
|
|
delete p._slot;
|
|
}
|
|
|
|
return {
|
|
match_id: matchId,
|
|
start_time: startTime,
|
|
duration,
|
|
radiant_win: radiantWin,
|
|
radiant: { kills: teamKills[0], net_worth: teamNw[0] },
|
|
dire: { kills: teamKills[1], net_worth: teamNw[1] },
|
|
mvp_account_id: mvpAccount,
|
|
players: slim,
|
|
focus_account_id: focusAccountId,
|
|
fetched_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
source: "opendota",
|
|
};
|
|
}
|
|
|
|
export function r2MatchKey(matchId) {
|
|
return `matches/${matchId}.json`;
|
|
}
|
|
|
|
export async function readMatchFromR2(env, matchId) {
|
|
if (!env.MATCHES) return null;
|
|
const key = r2MatchKey(matchId);
|
|
const obj = await env.MATCHES.get(key);
|
|
if (!obj) return null;
|
|
try {
|
|
return JSON.parse(await obj.text());
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export async function writeMatchToR2(env, detail) {
|
|
if (!env.MATCHES || !detail || !detail.match_id) return null;
|
|
const key = r2MatchKey(detail.match_id);
|
|
await env.MATCHES.put(key, JSON.stringify(detail), {
|
|
httpMetadata: { contentType: "application/json; charset=utf-8" },
|
|
});
|
|
return key;
|
|
}
|
|
|
|
export async function ensureNormalizedMatch(env, accountId, matchId) {
|
|
let data = await readMatchFromR2(env, matchId);
|
|
if (isNormalizedMatch(data)) {
|
|
if (accountId && !data.focus_account_id) {
|
|
data = { ...data, focus_account_id: accountId };
|
|
}
|
|
return { match: data, error: "" };
|
|
}
|
|
|
|
const raw =
|
|
data && Array.isArray(data.players) ? data : await odFetch(`/matches/${matchId}`, env);
|
|
if (!raw || !Array.isArray(raw.players)) {
|
|
return { match: null, error: "match not ready on OpenDota" };
|
|
}
|
|
if (accountId) {
|
|
const inMatch = raw.players.some(
|
|
(p) => p && Number(p.account_id) === Number(accountId)
|
|
);
|
|
if (!inMatch) {
|
|
return { match: null, error: "account not in match (private or wrong id)" };
|
|
}
|
|
}
|
|
const heroMap = await loadHeroMap(env);
|
|
const detail = normalizeMatch(raw, heroMap, accountId || null);
|
|
if (!detail) return { match: null, error: "normalize failed" };
|
|
const key = await writeMatchToR2(env, detail);
|
|
if (env.DB && accountId && key) {
|
|
const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
await env.DB.prepare(
|
|
`UPDATE player_matches SET r2_key = ?, updated_at = ? WHERE account_id = ? AND match_id = ?`
|
|
)
|
|
.bind(key, now, accountId, matchId)
|
|
.run();
|
|
}
|
|
return { match: detail, error: "" };
|
|
}
|