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:
@@ -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/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/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/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/cloudflare/` | 多用户数据层:D1 migrations、`player-sync` Worker、`provision.py` / `deploy_worker.py`;资源名见 `web/cloudflare/README.md` |
|
||||||
| `web/frontend/mobile-gate.js` | 移动端门禁(`<head>` 早载):`html.mobile-client` + 催更按钮;设 `window.__CLIMPEROR_MOBILE__` 供 `app.js` 跳过桌面 boot |
|
| `web/frontend/mobile-gate.js` | 移动端门禁(`<head>` 早载):`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_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/mechanic_tags.py` | 上分帝 Web「机制」页共用标签(弱/强驱散、缠绕/缴械/沉默/锁闭/眩晕/妖术/破坏、睡眠/恐惧/嘲讽/致盲/束缚、隐身/虚无/吹风)+ 中文 labels;语义为「施加该效果」 |
|
||||||
| `web/loc_format.py` | Valve 文案共用:去 HTML、填充 `%token%` / `{s:token}`(键 casefold;魔晶/神杖 `%bonus_<sv>%` 走 `values_shard`/`values_scepter`) |
|
| `web/loc_format.py` | Valve 文案共用:去 HTML、填充 `%token%` / `{s:token}`(键 casefold;魔晶/神杖 `%bonus_<sv>%` 走 `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) |
|
| `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/item_shop.json` | 商店 11 列目录(官网 basic/upgrade;由 `fetch_item_shop.py` 生成;物品页只读) |
|
||||||
| `web/data/items_meta.json` | 成品装备描述与机制标签(由 `fetch_items_meta.py` 生成) |
|
| `web/data/items_meta.json` | 成品装备描述与机制标签(由 `fetch_items_meta.py` 生成) |
|
||||||
| `web/data/item_tag_overrides.json` | 装备标签手工加减(合并进 items_meta) |
|
| `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/ability_tag_overrides.json` | 技能「施加」类 tags 手工加减(合并进 hero_abilities;仅 Web 机制页) |
|
||||||
| `web/data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears) |
|
| `web/data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears) |
|
||||||
| `web/data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成;含 ability `tags`) |
|
| `web/data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成;含 ability `tags`) |
|
||||||
|
|||||||
@@ -9,6 +9,22 @@
|
|||||||
- PC 本人锁定英雄后,按阵容推荐装备:核心装(`hero_items` 相对热度)+ 敌方应对装(定性规则),overlay 以图标条展示;结果写入 `recommendations.items`。
|
- 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
|
## [0.6.55] - 2026-07-31
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "./db.js";
|
} from "./db.js";
|
||||||
import {
|
import {
|
||||||
loadHeroMap,
|
loadHeroMap,
|
||||||
|
normalizeMatch,
|
||||||
odFetch,
|
odFetch,
|
||||||
steamMatchHistoryStatus,
|
steamMatchHistoryStatus,
|
||||||
summaryFromRecentRow,
|
summaryFromRecentRow,
|
||||||
@@ -255,13 +256,21 @@ async function syncAccount(env, msg) {
|
|||||||
await replaceHeroes(env.DB, accountId, topHeroes);
|
await replaceHeroes(env.DB, accountId, topHeroes);
|
||||||
await replacePeers(env.DB, accountId, peerRows);
|
await replacePeers(env.DB, accountId, peerRows);
|
||||||
|
|
||||||
// Optional: store a published match detail into R2 (deduped by match_id).
|
// Optional: store published/ensured match detail (normalized) into R2.
|
||||||
if (msg.kind === "publish_match" && msg.match_id && env.MATCHES) {
|
if (
|
||||||
|
(msg.kind === "publish_match" || msg.kind === "ensure_match") &&
|
||||||
|
msg.match_id &&
|
||||||
|
env.MATCHES
|
||||||
|
) {
|
||||||
const matchId = Number(msg.match_id);
|
const matchId = Number(msg.match_id);
|
||||||
const match = await odFetch(`/matches/${matchId}`, env);
|
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`;
|
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" },
|
httpMetadata: { contentType: "application/json; charset=utf-8" },
|
||||||
});
|
});
|
||||||
await env.DB.prepare(
|
await env.DB.prepare(
|
||||||
|
|||||||
@@ -91,3 +91,157 @@ export async function loadHeroMap(env) {
|
|||||||
}
|
}
|
||||||
return map;
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -91,6 +91,12 @@
|
|||||||
{ "item": "sheepstick", "reason": "妖术限制技能连段与逃生", "tags": ["hex"] }
|
{ "item": "sheepstick", "reason": "妖术限制技能连段与逃生", "tags": ["hex"] }
|
||||||
],
|
],
|
||||||
"remove": []
|
"remove": []
|
||||||
|
},
|
||||||
|
"tiny": {
|
||||||
|
"add": [
|
||||||
|
{ "item": "hydras_breath", "reason": "瘴毒按最大生命百分比持续消耗高血量小小", "tags": [] }
|
||||||
|
],
|
||||||
|
"remove": ["lotus_orb"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"meta": {
|
"meta": {
|
||||||
"source": "rules+valve+opendota",
|
"source": "rules+valve+opendota",
|
||||||
"attribution": "derived from items_meta.json + hero_abilities.json",
|
"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,
|
"top_n": 8,
|
||||||
"heroes": 127,
|
"heroes": 127,
|
||||||
"overrides": "data/hero_fear_overrides.json",
|
"overrides": "data/hero_fear_overrides.json",
|
||||||
@@ -1485,21 +1485,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tiny": [
|
"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",
|
"item": "black_king_bar",
|
||||||
"name_loc": "黑皇杖",
|
"name_loc": "黑皇杖",
|
||||||
@@ -1515,6 +1500,12 @@
|
|||||||
"win_delta": 0.00552
|
"win_delta": 0.00552
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"item": "hydras_breath",
|
||||||
|
"name_loc": "怪蛇之息",
|
||||||
|
"tags": [],
|
||||||
|
"reason": "瘴毒按最大生命百分比持续消耗高血量小小"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"item": "sphere",
|
"item": "sphere",
|
||||||
"name_loc": "林肯法球",
|
"name_loc": "林肯法球",
|
||||||
|
|||||||
@@ -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": ["蛇矛"]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"meta": {
|
"meta": {
|
||||||
"source": "valve+opendota",
|
"source": "valve+opendota",
|
||||||
"attribution": "https://www.dota2.com ; https://www.opendota.com",
|
"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": [
|
"tag_order": [
|
||||||
"basic_dispel",
|
"basic_dispel",
|
||||||
"strong_dispel",
|
"strong_dispel",
|
||||||
@@ -876,7 +876,10 @@
|
|||||||
"ability_kinds": [
|
"ability_kinds": [
|
||||||
"passive"
|
"passive"
|
||||||
],
|
],
|
||||||
"tags": []
|
"tags": [],
|
||||||
|
"aliases": [
|
||||||
|
"冰眼"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"162": {
|
"162": {
|
||||||
"id": 162,
|
"id": 162,
|
||||||
@@ -2087,7 +2090,10 @@
|
|||||||
"ability_kinds": [
|
"ability_kinds": [
|
||||||
"passive"
|
"passive"
|
||||||
],
|
],
|
||||||
"tags": []
|
"tags": [],
|
||||||
|
"aliases": [
|
||||||
|
"蛇矛"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ from shared.paths import (
|
|||||||
from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle
|
from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle
|
||||||
from serve_relations import WEB_DIR, build_payload
|
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"
|
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Fetch shop item descriptions and mechanism tags into data/items_meta.json.
|
"""Fetch shop item descriptions and mechanism tags into data/items_meta.json.
|
||||||
|
|
||||||
|
Also merges Chinese nicknames from data/item_alias_overrides.json → aliases.
|
||||||
|
|
||||||
Sources:
|
Sources:
|
||||||
- OpenDota items.json (structure, EN ability text)
|
- OpenDota items.json (structure, EN ability text)
|
||||||
- Valve itemlist / itemdata (schinese names + descriptions)
|
- Valve itemlist / itemdata (schinese names + descriptions)
|
||||||
@@ -43,6 +45,7 @@ ICON_URL = (
|
|||||||
)
|
)
|
||||||
OUT = DATA / "items_meta.json"
|
OUT = DATA / "items_meta.json"
|
||||||
OVERRIDES = DATA / "item_tag_overrides.json"
|
OVERRIDES = DATA / "item_tag_overrides.json"
|
||||||
|
ALIAS_OVERRIDES = DATA / "item_alias_overrides.json"
|
||||||
|
|
||||||
MIN_CREATED_COST = 1400
|
MIN_CREATED_COST = 1400
|
||||||
ALWAYS_CORE = frozenset({"blink", "aghanims_shard", "gem", "dust", "ghost"})
|
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)
|
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:
|
def main() -> None:
|
||||||
ap = argparse.ArgumentParser(description=__doc__)
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
ap.add_argument("--delay", type=float, default=0.15)
|
ap.add_argument("--delay", type=float, default=0.15)
|
||||||
@@ -301,6 +336,7 @@ def main() -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
overrides = load_overrides()
|
overrides = load_overrides()
|
||||||
|
alias_overrides = load_alias_overrides()
|
||||||
items_out: dict[str, dict] = {}
|
items_out: dict[str, dict] = {}
|
||||||
pending = []
|
pending = []
|
||||||
for iid in sorted(candidates):
|
for iid in sorted(candidates):
|
||||||
@@ -408,6 +444,7 @@ def main() -> None:
|
|||||||
}
|
}
|
||||||
write_json_atomic(args.out, payload)
|
write_json_atomic(args.out, payload)
|
||||||
|
|
||||||
|
apply_alias_overrides(items_out, alias_overrides)
|
||||||
payload = {
|
payload = {
|
||||||
"meta": {
|
"meta": {
|
||||||
"source": "valve+opendota",
|
"source": "valve+opendota",
|
||||||
|
|||||||
+31
-4
@@ -1657,8 +1657,14 @@ function selectInspectItem(key) {
|
|||||||
function resolveItemDetail(key) {
|
function resolveItemDetail(key) {
|
||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
const shop = shopItem(key);
|
const shop = shopItem(key);
|
||||||
if (shop) return shop;
|
|
||||||
const metaExtra = (state.data.items_meta || {})[key] || null;
|
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 || {};
|
const items = state.data.hero_items?.items || {};
|
||||||
for (const row of Object.values(items)) {
|
for (const row of Object.values(items)) {
|
||||||
if (row && row.key === key) {
|
if (row && row.key === key) {
|
||||||
@@ -1670,6 +1676,7 @@ function resolveItemDetail(key) {
|
|||||||
components: metaExtra?.components || [],
|
components: metaExtra?.components || [],
|
||||||
builds_into: metaExtra?.builds_into || [],
|
builds_into: metaExtra?.builds_into || [],
|
||||||
desc_loc: metaExtra?.desc_loc || row.desc_loc || "",
|
desc_loc: metaExtra?.desc_loc || row.desc_loc || "",
|
||||||
|
...(aliases ? { aliases } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1681,6 +1688,7 @@ function resolveItemDetail(key) {
|
|||||||
components: metaExtra.components || [],
|
components: metaExtra.components || [],
|
||||||
builds_into: metaExtra.builds_into || [],
|
builds_into: metaExtra.builds_into || [],
|
||||||
desc_loc: metaExtra.desc_loc || "",
|
desc_loc: metaExtra.desc_loc || "",
|
||||||
|
...(aliases ? { aliases } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { key, name_loc: key, components: [], builds_into: [] };
|
return { key, name_loc: key, components: [], builds_into: [] };
|
||||||
@@ -2992,7 +3000,8 @@ function buildFearItemsPanel(heroKey) {
|
|||||||
const purchaseRate = hasPurchaseRate
|
const purchaseRate = hasPurchaseRate
|
||||||
? formatCounterRate(stats.purchase_rate)
|
? formatCounterRate(stats.purchase_rate)
|
||||||
: null;
|
: 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) {
|
if (hasPurchaseRate) {
|
||||||
tipParts.push(`对阵购买率 ${purchaseRate}`);
|
tipParts.push(`对阵购买率 ${purchaseRate}`);
|
||||||
if (stats.games) tipParts.push(`样本 ${stats.games} 场`);
|
if (stats.games) tipParts.push(`样本 ${stats.games} 场`);
|
||||||
@@ -3177,7 +3186,10 @@ function mountItemInspect(root, meta, { onPickItem, hideIcon = false } = {}) {
|
|||||||
nameRow.className = "item-detail-name-row";
|
nameRow.className = "item-detail-name-row";
|
||||||
const title = document.createElement("h3");
|
const title = document.createElement("h3");
|
||||||
title.className = "item-detail-name";
|
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);
|
nameRow.appendChild(title);
|
||||||
if (meta.cost != null) {
|
if (meta.cost != null) {
|
||||||
const cost = document.createElement("span");
|
const cost = document.createElement("span");
|
||||||
@@ -3758,9 +3770,24 @@ function matchesItemQuery(key, meta) {
|
|||||||
if (name.includes(q)) return true;
|
if (name.includes(q)) return true;
|
||||||
if (key.toLowerCase().includes(qLower)) return true;
|
if (key.toLowerCase().includes(qLower)) return true;
|
||||||
if ((meta?.name || "").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;
|
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) {
|
function onShopItemClick(key) {
|
||||||
state.selectedItemKey = state.selectedItemKey === key ? null : key;
|
state.selectedItemKey = state.selectedItemKey === key ? null : key;
|
||||||
state.selectedKey = null;
|
state.selectedKey = null;
|
||||||
@@ -3774,7 +3801,7 @@ function makeShopItemButton(key) {
|
|||||||
btn.type = "button";
|
btn.type = "button";
|
||||||
btn.className = "shop-item";
|
btn.className = "shop-item";
|
||||||
const cost = meta.cost != null ? ` ${meta.cost}` : "";
|
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 (key === state.selectedItemKey) btn.classList.add("selected");
|
||||||
if (!matchesItemQuery(key, meta)) btn.style.display = "none";
|
if (!matchesItemQuery(key, meta)) btn.style.display = "none";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* Local defaults; production export overwrites via export_relations_site.py. */
|
/* 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 SITE_ORIGIN = "";
|
||||||
var ABILITY_VIDEO_BASE = "";
|
var ABILITY_VIDEO_BASE = "";
|
||||||
var STATIC_ASSET_BASE = "";
|
var STATIC_ASSET_BASE = "";
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* GET /api/players/:account_id/:match_id — match detail from R2 (authz via D1).
|
* 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 { sessionFromRequest } from "../../auth/_steam_common.js";
|
||||||
import { jsonResponse } from "../_db.js";
|
import { jsonResponse } from "../_db.js";
|
||||||
|
import { ensureNormalizedMatch } from "../_match.js";
|
||||||
|
|
||||||
export async function onRequestGet(context) {
|
export async function onRequestGet(context) {
|
||||||
try {
|
try {
|
||||||
@@ -23,23 +25,12 @@ export async function onRequestGet(context) {
|
|||||||
if (!user) return jsonResponse({ error: "not found" }, 404);
|
if (!user) return jsonResponse({ error: "not found" }, 404);
|
||||||
if (!self && !user.public_share) return jsonResponse({ error: "private" }, 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);
|
if (!env.MATCHES) return jsonResponse({ error: "storage not configured" }, 503);
|
||||||
const obj = await env.MATCHES.get(key);
|
const { match, error } = await ensureNormalizedMatch(env, accountId, matchId);
|
||||||
if (!obj) return jsonResponse({ error: "match not found" }, 404);
|
if (!match) {
|
||||||
const text = await obj.text();
|
return jsonResponse({ error: error || "match not found" }, 404);
|
||||||
return new Response(text, {
|
}
|
||||||
status: 200,
|
return jsonResponse(match);
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
|
||||||
"Cache-Control": "no-store",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{ error: "match get failed", detail: String((e && e.message) || e) },
|
{ error: "match get failed", detail: String((e && e.message) || e) },
|
||||||
|
|||||||
@@ -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: "" };
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -43,8 +43,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
|
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
|
||||||
<link rel="stylesheet" href="/style.css?v=0.6.55" />
|
<link rel="stylesheet" href="/style.css?v=0.6.57" />
|
||||||
<script src="/mobile-gate.js?v=0.6.55"></script>
|
<script src="/mobile-gate.js?v=0.6.57"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1 class="sr-only">DOTA2 上分帝</h1>
|
<h1 class="sr-only">DOTA2 上分帝</h1>
|
||||||
@@ -256,8 +256,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<footer class="heroes-site-foot" id="heroes-site-foot" aria-hidden="true"></footer>
|
<footer class="heroes-site-foot" id="heroes-site-foot" aria-hidden="true"></footer>
|
||||||
|
|
||||||
<script src="/config.js?v=0.6.55"></script>
|
<script src="/config.js?v=0.6.57"></script>
|
||||||
<script src="/router.js?v=0.6.55"></script>
|
<script src="/router.js?v=0.6.57"></script>
|
||||||
<script src="/app.js?v=0.6.55"></script>
|
<script src="/app.js?v=0.6.57"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+16
-1
@@ -300,13 +300,21 @@ def load_items_meta_index() -> dict:
|
|||||||
if not isinstance(row, dict) or not row.get("key"):
|
if not isinstance(row, dict) or not row.get("key"):
|
||||||
continue
|
continue
|
||||||
key = str(row["key"])
|
key = str(row["key"])
|
||||||
out[key] = {
|
cell = {
|
||||||
"key": key,
|
"key": key,
|
||||||
"name_loc": row.get("name_loc") or row.get("dname") or key,
|
"name_loc": row.get("name_loc") or row.get("dname") or key,
|
||||||
"cost": row.get("cost"),
|
"cost": row.get("cost"),
|
||||||
"desc_loc": row.get("desc_loc") or "",
|
"desc_loc": row.get("desc_loc") or "",
|
||||||
"tags": list(row.get("tags") 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
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -340,6 +348,13 @@ def load_item_shop() -> dict:
|
|||||||
row = dict(row)
|
row = dict(row)
|
||||||
row["tags"] = list(m.get("tags") or [])
|
row["tags"] = list(m.get("tags") or [])
|
||||||
row["desc_loc"] = m.get("desc_loc") 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
|
items[key] = row
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
pass
|
pass
|
||||||
|
|||||||
Reference in New Issue
Block a user