/** 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: "" }; }