From 820c3fb1f1099496f8d115ebf5434e387b332eab Mon Sep 17 00:00:00 2001 From: voson Date: Sat, 1 Aug 2026 04:05:48 +0800 Subject: [PATCH] Ship item nicknames, Tiny fear override, and ensure-match API. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 冰眼/蛇矛 aliases, correct Tiny fears to Hydra's Breath, and fix production match detail POST 405. Co-authored-by: Cursor --- AGENTS.md | 4 +- CHANGELOG.md | 16 ++ web/cloudflare/player-sync/src/index.js | 17 +- web/cloudflare/player-sync/src/opendota.js | 154 ++++++++++ web/data/hero_fear_overrides.json | 6 + web/data/hero_item_fears.json | 23 +- web/data/item_alias_overrides.json | 9 + web/data/items_meta.json | 12 +- web/export_relations_site.py | 2 +- web/fetch_items_meta.py | 37 +++ web/frontend/app.js | 35 ++- web/frontend/config.js | 2 +- .../api/players/[account_id]/[match_id].js | 25 +- web/frontend/functions/api/players/_match.js | 268 ++++++++++++++++++ .../functions/api/players/ensure-match.js | 72 +++++ web/frontend/index.html | 10 +- web/serve_relations.py | 17 +- 17 files changed, 656 insertions(+), 53 deletions(-) create mode 100644 web/data/item_alias_overrides.json create mode 100644 web/frontend/functions/api/players/_match.js create mode 100644 web/frontend/functions/api/players/ensure-match.js diff --git a/AGENTS.md b/AGENTS.md index ec98df5..40dd853 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,10 +100,11 @@ climperor/ | `web/frontend/functions/api/mobile-demand.js` | Pages Function `GET\|POST /api/mobile-demand`:移动端「催更」需求计数(Cache API 存 `count`,**无 KV**;边缘竞态/驱逐可能少计或重置);本机 `serve_relations.py` 写 `web/.refresh/mobile_demand.json`;前端 `mobile-gate.js` 用 UA 识别手机/平板并拦截,`localStorage` 同设备只 POST 一次 | | `web/frontend/functions/api/players/publish.js` | Pages Function `POST /api/players/publish`:校验后投递 Queue(D1/R2 由 Worker 写入);可选 `PLAYER_PAGES_PUBLISH_SECRET`;**不**进主 `data.json` / recommend | | `web/frontend/functions/api/players/me.js` | `GET /api/players/me`:登录本人 D1 档案;过期则 Queue 刷新并标 `stale` | +| `web/frontend/functions/api/players/ensure-match.js` | `POST /api/players/ensure-match`:本人或 `public_share` 懒加载比赛详情(OpenDota → 规范化 → R2);静态路由须优先于 `[account_id]`,否则 POST 会 405 | | `web/cloudflare/` | 多用户数据层:D1 migrations、`player-sync` Worker、`provision.py` / `deploy_worker.py`;资源名见 `web/cloudflare/README.md` | | `web/frontend/mobile-gate.js` | 移动端门禁(`` 早载):`html.mobile-client` + 催更按钮;设 `window.__CLIMPEROR_MOBILE__` 供 `app.js` 跳过桌面 boot | | `web/fetch_item_shop.py` | 官网商店 11 列目录(dota2.com.cn/itemscategory)+ 合成图 → `web/data/item_shop.json` + 图标 | -| `web/fetch_items_meta.py` | Valve/OpenDota 装备描述 → 机制标签 → `web/data/items_meta.json`(`%token%` 用 special_values 填数;查询类 tags 共用 `mechanic_tags.py`) | +| `web/fetch_items_meta.py` | Valve/OpenDota 装备描述 → 机制标签 → `web/data/items_meta.json`(`%token%` 用 special_values 填数;查询类 tags 共用 `mechanic_tags.py`;合并 `item_alias_overrides.json` → `aliases`) | | `web/mechanic_tags.py` | 上分帝 Web「机制」页共用标签(弱/强驱散、缠绕/缴械/沉默/锁闭/眩晕/妖术/破坏、睡眠/恐惧/嘲讽/致盲/束缚、隐身/虚无/吹风)+ 中文 labels;语义为「施加该效果」 | | `web/loc_format.py` | Valve 文案共用:去 HTML、填充 `%token%` / `{s:token}`(键 casefold;魔晶/神杖 `%bonus_%` 走 `values_shard`/`values_scepter`) | | `web/fetch_hero_abilities.py` | Valve herodata 技能/魔晶/神杖/天赋 + 驱散汇总 → `web/data/hero_abilities.json`;每条 ability 另有「施加」类 `tags`(与 `dispellable` 区分;`--tags-only` 可只重算);`has_scepter`/`has_shard` 只信 Valve 显式 flag,Valve 移除升级后残留的 `scepter_loc`/`shard_loc` 文案会被清空;逐级相同的 `cast_points`/`channel_times` 合并为单值(施法前摇/吟唱时间);可选 `--icons` / `--icons-only` 缓存 Steam CDN 技能图标 → `web/assets/ability_icons/`(先天用共用 `innate.png`,不拉 CDN) | @@ -125,6 +126,7 @@ climperor/ | `web/data/item_shop.json` | 商店 11 列目录(官网 basic/upgrade;由 `fetch_item_shop.py` 生成;物品页只读) | | `web/data/items_meta.json` | 成品装备描述与机制标签(由 `fetch_items_meta.py` 生成) | | `web/data/item_tag_overrides.json` | 装备标签手工加减(合并进 items_meta) | +| `web/data/item_alias_overrides.json` | 装备中文简称/俗称(合并进 `items_meta.aliases`;物品页搜索与悬停;勿与 `name_loc` 重复) | | `web/data/ability_tag_overrides.json` | 技能「施加」类 tags 手工加减(合并进 hero_abilities;仅 Web 机制页) | | `web/data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears) | | `web/data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成;含 ability `tags`) | diff --git a/CHANGELOG.md b/CHANGELOG.md index a000fa1..26ef02b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ - PC 本人锁定英雄后,按阵容推荐装备:核心装(`hero_items` 相对热度)+ 敌方应对装(定性规则),overlay 以图标条展示;结果写入 `recommendations.items`。 - 定性克制:远古冰魂 → 瘟疫法师(冰晶爆轰禁疗)。 +## [0.6.57] - 2026-08-01 + +### Added + +- 物品中文简称:`item_alias_overrides.json`(斯嘉蒂之眼→冰眼,怪蛇之息→蛇矛);物品页可搜简称,悬停/详情显示。 + +### Changed + +- 小小「怕的装备」:手工改为含怪蛇之息(`hydras_breath`),去掉清莲宝珠。 + +## [0.6.56] - 2026-08-01 + +### Fixed + +- 生产「比赛详情」:补齐 `POST /api/players/ensure-match`(此前落入动态路由仅支持 GET → HTTP 405);详情规范化写入 R2,与本机 `player_pages` 同形。 + ## [0.6.55] - 2026-07-31 ### Fixed diff --git a/web/cloudflare/player-sync/src/index.js b/web/cloudflare/player-sync/src/index.js index f8424eb..da0ae15 100644 --- a/web/cloudflare/player-sync/src/index.js +++ b/web/cloudflare/player-sync/src/index.js @@ -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( diff --git a/web/cloudflare/player-sync/src/opendota.js b/web/cloudflare/player-sync/src/opendota.js index b5e3a76..a222ae8 100644 --- a/web/cloudflare/player-sync/src/opendota.js +++ b/web/cloudflare/player-sync/src/opendota.js @@ -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", + }; +} diff --git a/web/data/hero_fear_overrides.json b/web/data/hero_fear_overrides.json index bd13ae9..680b560 100644 --- a/web/data/hero_fear_overrides.json +++ b/web/data/hero_fear_overrides.json @@ -91,6 +91,12 @@ { "item": "sheepstick", "reason": "妖术限制技能连段与逃生", "tags": ["hex"] } ], "remove": [] + }, + "tiny": { + "add": [ + { "item": "hydras_breath", "reason": "瘴毒按最大生命百分比持续消耗高血量小小", "tags": [] } + ], + "remove": ["lotus_orb"] } } } diff --git a/web/data/hero_item_fears.json b/web/data/hero_item_fears.json index 606d6ec..08271da 100644 --- a/web/data/hero_item_fears.json +++ b/web/data/hero_item_fears.json @@ -2,7 +2,7 @@ "meta": { "source": "rules+valve+opendota", "attribution": "derived from items_meta.json + hero_abilities.json", - "fetched_at": "2026-07-28T16:06:06.680383+00:00", + "fetched_at": "2026-07-31T19:14:17.668808+00:00", "top_n": 8, "heroes": 127, "overrides": "data/hero_fear_overrides.json", @@ -1485,21 +1485,6 @@ } ], "tiny": [ - { - "item": "lotus_orb", - "name_loc": "清莲宝珠", - "tags": [ - "spell_reflect" - ], - "reason": "反射点目标技能", - "stats": { - "games": 541, - "purchase_rate": 0.164738, - "win_rate": 0.57671, - "purchase_lift": -0.013412, - "win_delta": 0.00486 - } - }, { "item": "black_king_bar", "name_loc": "黑皇杖", @@ -1515,6 +1500,12 @@ "win_delta": 0.00552 } }, + { + "item": "hydras_breath", + "name_loc": "怪蛇之息", + "tags": [], + "reason": "瘴毒按最大生命百分比持续消耗高血量小小" + }, { "item": "sphere", "name_loc": "林肯法球", diff --git a/web/data/item_alias_overrides.json b/web/data/item_alias_overrides.json new file mode 100644 index 0000000..1191667 --- /dev/null +++ b/web/data/item_alias_overrides.json @@ -0,0 +1,9 @@ +{ + "meta": { + "note": "Manual Chinese short names / nicknames merged into items_meta.aliases (search + tooltips). Do not duplicate name_loc." + }, + "items": { + "skadi": ["冰眼"], + "hydras_breath": ["蛇矛"] + } +} diff --git a/web/data/items_meta.json b/web/data/items_meta.json index a504788..ce78937 100644 --- a/web/data/items_meta.json +++ b/web/data/items_meta.json @@ -2,7 +2,7 @@ "meta": { "source": "valve+opendota", "attribution": "https://www.dota2.com ; https://www.opendota.com", - "fetched_at": "2026-07-31T01:54:18.841099+00:00", + "fetched_at": "2026-07-31T19:15:55.325814+00:00", "tag_order": [ "basic_dispel", "strong_dispel", @@ -876,7 +876,10 @@ "ability_kinds": [ "passive" ], - "tags": [] + "tags": [], + "aliases": [ + "冰眼" + ] }, "162": { "id": 162, @@ -2087,7 +2090,10 @@ "ability_kinds": [ "passive" ], - "tags": [] + "tags": [], + "aliases": [ + "蛇矛" + ] } } } diff --git a/web/export_relations_site.py b/web/export_relations_site.py index a1b4089..30bd97d 100644 --- a/web/export_relations_site.py +++ b/web/export_relations_site.py @@ -55,7 +55,7 @@ from shared.paths import ( from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle from serve_relations import WEB_DIR, build_payload -SITE_VERSION = "0.6.55" +SITE_VERSION = "0.6.57" DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com" diff --git a/web/fetch_items_meta.py b/web/fetch_items_meta.py index 501077e..4b1b41b 100644 --- a/web/fetch_items_meta.py +++ b/web/fetch_items_meta.py @@ -1,5 +1,7 @@ """Fetch shop item descriptions and mechanism tags into data/items_meta.json. +Also merges Chinese nicknames from data/item_alias_overrides.json → aliases. + Sources: - OpenDota items.json (structure, EN ability text) - Valve itemlist / itemdata (schinese names + descriptions) @@ -43,6 +45,7 @@ ICON_URL = ( ) OUT = DATA / "items_meta.json" OVERRIDES = DATA / "item_tag_overrides.json" +ALIAS_OVERRIDES = DATA / "item_alias_overrides.json" MIN_CREATED_COST = 1400 ALWAYS_CORE = frozenset({"blink", "aghanims_shard", "gem", "dust", "ghost"}) @@ -262,6 +265,38 @@ def apply_overrides(key: str, tags: list[str], overrides: dict[str, dict]) -> li return merge_tag_overrides(tags, overrides.get(key), TAG_ORDER) +def load_alias_overrides() -> dict[str, list[str]]: + """key → Chinese nicknames (冰眼 / 蛇矛); empty if file missing.""" + if not ALIAS_OVERRIDES.is_file(): + return {} + try: + raw = json.loads(ALIAS_OVERRIDES.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + out: dict[str, list[str]] = {} + for key, aliases in (raw.get("items") or {}).items(): + if not isinstance(aliases, list): + continue + clean = [str(a).strip() for a in aliases if str(a).strip()] + if clean: + out[str(key)] = list(dict.fromkeys(clean)) + return out + + +def apply_alias_overrides(items_out: dict[str, dict], aliases: dict[str, list[str]]) -> None: + for row in items_out.values(): + if not isinstance(row, dict): + continue + key = str(row.get("key") or "") + if not key: + continue + nick = aliases.get(key) + if nick: + row["aliases"] = list(nick) + else: + row.pop("aliases", None) + + def main() -> None: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--delay", type=float, default=0.15) @@ -301,6 +336,7 @@ def main() -> None: pass overrides = load_overrides() + alias_overrides = load_alias_overrides() items_out: dict[str, dict] = {} pending = [] for iid in sorted(candidates): @@ -408,6 +444,7 @@ def main() -> None: } write_json_atomic(args.out, payload) + apply_alias_overrides(items_out, alias_overrides) payload = { "meta": { "source": "valve+opendota", diff --git a/web/frontend/app.js b/web/frontend/app.js index 7eb2535..8ce81d0 100644 --- a/web/frontend/app.js +++ b/web/frontend/app.js @@ -1657,8 +1657,14 @@ function selectInspectItem(key) { function resolveItemDetail(key) { if (!key) return null; const shop = shopItem(key); - if (shop) return shop; const metaExtra = (state.data.items_meta || {})[key] || null; + const aliases = + (shop?.aliases?.length && shop.aliases) || + (metaExtra?.aliases?.length && metaExtra.aliases) || + undefined; + if (shop) { + return aliases && !shop.aliases?.length ? { ...shop, aliases } : shop; + } const items = state.data.hero_items?.items || {}; for (const row of Object.values(items)) { if (row && row.key === key) { @@ -1670,6 +1676,7 @@ function resolveItemDetail(key) { components: metaExtra?.components || [], builds_into: metaExtra?.builds_into || [], desc_loc: metaExtra?.desc_loc || row.desc_loc || "", + ...(aliases ? { aliases } : {}), }; } } @@ -1681,6 +1688,7 @@ function resolveItemDetail(key) { components: metaExtra.components || [], builds_into: metaExtra.builds_into || [], desc_loc: metaExtra.desc_loc || "", + ...(aliases ? { aliases } : {}), }; } return { key, name_loc: key, components: [], builds_into: [] }; @@ -2992,7 +3000,8 @@ function buildFearItemsPanel(heroKey) { const purchaseRate = hasPurchaseRate ? formatCounterRate(stats.purchase_rate) : null; - const tipParts = [name, reason].filter(Boolean); + const aliasBit = itemAliasHint(key, (state.data.items_meta || {})[key]); + const tipParts = [`${name}${aliasBit}`, reason].filter(Boolean); if (hasPurchaseRate) { tipParts.push(`对阵购买率 ${purchaseRate}`); if (stats.games) tipParts.push(`样本 ${stats.games} 场`); @@ -3177,7 +3186,10 @@ function mountItemInspect(root, meta, { onPickItem, hideIcon = false } = {}) { nameRow.className = "item-detail-name-row"; const title = document.createElement("h3"); title.className = "item-detail-name"; - title.textContent = meta.name_loc || meta.key; + const aliasBit = itemAliasHint(meta.key, meta).replace(/^ \/ /, ""); + title.textContent = aliasBit + ? `${meta.name_loc || meta.key}(${aliasBit})` + : meta.name_loc || meta.key; nameRow.appendChild(title); if (meta.cost != null) { const cost = document.createElement("span"); @@ -3758,9 +3770,24 @@ function matchesItemQuery(key, meta) { if (name.includes(q)) return true; if (key.toLowerCase().includes(qLower)) return true; if ((meta?.name || "").toLowerCase().includes(qLower)) return true; + for (const a of meta?.aliases || []) { + if (String(a).includes(q)) return true; + } + // Fall back to items_meta when shop row has no aliases yet. + const extra = (state.data.items_meta || {})[key]; + for (const a of extra?.aliases || []) { + if (String(a).includes(q)) return true; + } return false; } +function itemAliasHint(key, meta) { + const fromMeta = meta?.aliases; + const fromIndex = (state.data.items_meta || {})[key]?.aliases; + const aliases = (fromMeta?.length ? fromMeta : fromIndex) || []; + return aliases.length ? " / " + aliases.join("、") : ""; +} + function onShopItemClick(key) { state.selectedItemKey = state.selectedItemKey === key ? null : key; state.selectedKey = null; @@ -3774,7 +3801,7 @@ function makeShopItemButton(key) { btn.type = "button"; btn.className = "shop-item"; const cost = meta.cost != null ? ` ${meta.cost}` : ""; - btn.title = `${meta.name_loc || key}${cost}`; + btn.title = `${meta.name_loc || key}${itemAliasHint(key, meta)}${cost}`; if (key === state.selectedItemKey) btn.classList.add("selected"); if (!matchesItemQuery(key, meta)) btn.style.display = "none"; diff --git a/web/frontend/config.js b/web/frontend/config.js index 014a66f..fb2fdc7 100644 --- a/web/frontend/config.js +++ b/web/frontend/config.js @@ -1,5 +1,5 @@ /* Local defaults; production export overwrites via export_relations_site.py. */ -var SITE_VERSION = "0.6.55"; +var SITE_VERSION = "0.6.57"; var SITE_ORIGIN = ""; var ABILITY_VIDEO_BASE = ""; var STATIC_ASSET_BASE = ""; diff --git a/web/frontend/functions/api/players/[account_id]/[match_id].js b/web/frontend/functions/api/players/[account_id]/[match_id].js index 869b932..25835a2 100644 --- a/web/frontend/functions/api/players/[account_id]/[match_id].js +++ b/web/frontend/functions/api/players/[account_id]/[match_id].js @@ -1,8 +1,10 @@ /** * GET /api/players/:account_id/:match_id — match detail from R2 (authz via D1). + * If R2 missing/raw, fetch+normalize from OpenDota (same shape as local ensure-match). */ import { sessionFromRequest } from "../../auth/_steam_common.js"; import { jsonResponse } from "../_db.js"; +import { ensureNormalizedMatch } from "../_match.js"; export async function onRequestGet(context) { try { @@ -23,27 +25,16 @@ export async function onRequestGet(context) { if (!user) return jsonResponse({ error: "not found" }, 404); if (!self && !user.public_share) return jsonResponse({ error: "private" }, 404); - const row = await env.DB.prepare( - `SELECT r2_key FROM player_matches WHERE account_id = ? AND match_id = ?` - ) - .bind(accountId, matchId) - .first(); - const key = (row && row.r2_key) || `matches/${matchId}.json`; if (!env.MATCHES) return jsonResponse({ error: "storage not configured" }, 503); - const obj = await env.MATCHES.get(key); - if (!obj) return jsonResponse({ error: "match not found" }, 404); - const text = await obj.text(); - return new Response(text, { - status: 200, - headers: { - "Content-Type": "application/json; charset=utf-8", - "Cache-Control": "no-store", - }, - }); + const { match, error } = await ensureNormalizedMatch(env, accountId, matchId); + if (!match) { + return jsonResponse({ error: error || "match not found" }, 404); + } + return jsonResponse(match); } catch (e) { return jsonResponse( { error: "match get failed", detail: String((e && e.message) || e) }, 500 ); } -} \ No newline at end of file +} diff --git a/web/frontend/functions/api/players/_match.js b/web/frontend/functions/api/players/_match.js new file mode 100644 index 0000000..bacee4b --- /dev/null +++ b/web/frontend/functions/api/players/_match.js @@ -0,0 +1,268 @@ +/** 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: "" }; +} diff --git a/web/frontend/functions/api/players/ensure-match.js b/web/frontend/functions/api/players/ensure-match.js new file mode 100644 index 0000000..7a4c038 --- /dev/null +++ b/web/frontend/functions/api/players/ensure-match.js @@ -0,0 +1,72 @@ +/** + * POST /api/players/ensure-match + * Body: { account_id, match_id } + * Auth: logged-in self, or target user has public_share. + * Loads Climperor match detail from R2, or fetches+normalizes from OpenDota. + */ +import { sessionFromRequest } from "../auth/_steam_common.js"; +import { jsonResponse } from "./_db.js"; +import { ensureNormalizedMatch } from "./_match.js"; + +function intField(v, fallback = 0) { + const n = Number(v); + return Number.isFinite(n) ? Math.trunc(n) : fallback; +} + +export async function onRequestPost(context) { + try { + const env = context.env || {}; + if (!env.DB) return jsonResponse({ error: "database not configured" }, 503); + if (!env.MATCHES) return jsonResponse({ error: "storage not configured" }, 503); + + let body; + try { + body = await context.request.json(); + } catch { + return jsonResponse({ error: "invalid json" }, 400); + } + const accountId = intField(body && body.account_id, -1); + const matchId = intField(body && body.match_id, -1); + if (accountId <= 0 || matchId <= 0) { + return jsonResponse({ error: "account_id and match_id required" }, 400); + } + + const session = await sessionFromRequest(context); + const self = session && Number(session.account_id) === accountId; + const user = await env.DB.prepare( + `SELECT public_share FROM users WHERE account_id = ?` + ) + .bind(accountId) + .first(); + if (!user) return jsonResponse({ error: "not found" }, 404); + if (!self && !user.public_share) { + return jsonResponse({ error: "private" }, 404); + } + + const { match, error } = await ensureNormalizedMatch(env, accountId, matchId); + if (!match) { + return jsonResponse( + { error: error || "unknown", account_id: accountId, match_id: matchId }, + 404 + ); + } + return jsonResponse({ ok: true, match }); + } catch (e) { + return jsonResponse( + { error: "ensure-match failed", detail: String((e && e.message) || e) }, + 500 + ); + } +} + +export async function onRequestOptions() { + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Max-Age": "86400", + }, + }); +} diff --git a/web/frontend/index.html b/web/frontend/index.html index 21316c0..4c1a04f 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -43,8 +43,8 @@ } - - + +

DOTA2 上分帝

@@ -256,8 +256,8 @@
- - - + + + diff --git a/web/serve_relations.py b/web/serve_relations.py index a274a78..a5ad28e 100644 --- a/web/serve_relations.py +++ b/web/serve_relations.py @@ -300,13 +300,21 @@ def load_items_meta_index() -> dict: if not isinstance(row, dict) or not row.get("key"): continue key = str(row["key"]) - out[key] = { + cell = { "key": key, "name_loc": row.get("name_loc") or row.get("dname") or key, "cost": row.get("cost"), "desc_loc": row.get("desc_loc") or "", "tags": list(row.get("tags") or []), } + aliases = [ + str(a).strip() + for a in (row.get("aliases") or []) + if str(a).strip() + ] + if aliases: + cell["aliases"] = aliases + out[key] = cell return out @@ -340,6 +348,13 @@ def load_item_shop() -> dict: row = dict(row) row["tags"] = list(m.get("tags") or []) row["desc_loc"] = m.get("desc_loc") or "" + aliases = [ + str(a).strip() + for a in (m.get("aliases") or []) + if str(a).strip() + ] + if aliases: + row["aliases"] = aliases items[key] = row except (OSError, json.JSONDecodeError): pass