Ship item nicknames, Tiny fear override, and ensure-match API.
Add 冰眼/蛇矛 aliases, correct Tiny fears to Hydra's Breath, and fix production match detail POST 405. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,6 +17,7 @@ import {
|
||||
} from "./db.js";
|
||||
import {
|
||||
loadHeroMap,
|
||||
normalizeMatch,
|
||||
odFetch,
|
||||
steamMatchHistoryStatus,
|
||||
summaryFromRecentRow,
|
||||
@@ -255,13 +256,21 @@ async function syncAccount(env, msg) {
|
||||
await replaceHeroes(env.DB, accountId, topHeroes);
|
||||
await replacePeers(env.DB, accountId, peerRows);
|
||||
|
||||
// Optional: store a published match detail into R2 (deduped by match_id).
|
||||
if (msg.kind === "publish_match" && msg.match_id && env.MATCHES) {
|
||||
// Optional: store published/ensured match detail (normalized) into R2.
|
||||
if (
|
||||
(msg.kind === "publish_match" || msg.kind === "ensure_match") &&
|
||||
msg.match_id &&
|
||||
env.MATCHES
|
||||
) {
|
||||
const matchId = Number(msg.match_id);
|
||||
const match = await odFetch(`/matches/${matchId}`, env);
|
||||
if (match && Array.isArray(match.players)) {
|
||||
const detail =
|
||||
match && Array.isArray(match.players)
|
||||
? normalizeMatch(match, heroMap, accountId)
|
||||
: null;
|
||||
if (detail) {
|
||||
const key = `matches/${matchId}.json`;
|
||||
await env.MATCHES.put(key, JSON.stringify(match), {
|
||||
await env.MATCHES.put(key, JSON.stringify(detail), {
|
||||
httpMetadata: { contentType: "application/json; charset=utf-8" },
|
||||
});
|
||||
await env.DB.prepare(
|
||||
|
||||
@@ -91,3 +91,157 @@ export async function loadHeroMap(env) {
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** Climperor match-detail JSON (same shape as pc/player_pages.normalize_match). */
|
||||
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",
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user