diff --git a/.env.example b/.env.example index 09a5bb3..a40cf2d 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,14 @@ # Copy to .env (gitignored). Get a free token at https://stratz.com/api +# Or inject via keyzoo asset `refining/stratz` (api_key → KEYZOO_ASSET_API_KEY). +# Gitea Actions web-weekly uses the same name as a repo Actions secret. STRATZ_API_TOKEN= + +# Optional OpenDota API key (higher rate limit for fetch_hero_matches.py --workers). +# OPENDOTA_API_KEY= + +# Optional local deploy / OSS (or keyzoo inject). Gitea web-* workflows use +# CLOUDFLARE_* + OSS_* as Actions secrets (see README「定时刷新」). +# CLOUDFLARE_EMAIL= +# CLOUDFLARE_API_KEY= +# OSS_ACCESS_KEY_ID= +# OSS_ACCESS_KEY_SECRET= diff --git a/.gitignore b/.gitignore index 25a2a33..28212e2 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,13 @@ plans/ data/matchups.json data/synergies.json data/stratz_matchups.json +data/stratz_hero_meta.json +data/stratz_matchup_tops.json +data/item_counter_stats.json +data/hero_stats.json +data/hero_matches.json +data/pro_matches.json +data/pro_builds.json data/relations_audit.json data/relations_import_report.json data/relations_patch_* @@ -36,6 +43,8 @@ assets/ability_videos/ assets/ability_icons/ assets/hero_portraits/ assets/item_icons/ +# Curated streamer highlight clips (large; sync via OSS, not git) +assets/streamer_videos/ # Static site export (export_relations_site.py) dist/ diff --git a/AGENTS.md b/AGENTS.md index ba28f20..66c949f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,7 +56,8 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段 | `loc_format.py` | Valve 文案共用:去 HTML、填充 `%token%` / `{s:token}` | | `fetch_hero_abilities.py` | Valve herodata 技能/魔晶/神杖/天赋 + 驱散汇总 → `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 技能图标 → `assets/ability_icons/`(先天用共用 `innate.png`,不拉 CDN) | | `fetch_ability_videos.py` | 官网技能演示 webm/mp4(Steam CDN;限速)→ `assets/ability_videos/`;A 杖/魔晶赋予技能(`granted_by_scepter`/`granted_by_shard`)CDN 名用 `_aghanims_scepter`/`_aghanims_shard`,本地文件名仍用 ability key | -| `item_fears.py` | 规则推导「英雄怕的装备」→ `data/hero_item_fears.json` | +| `fetch_item_counter_stats.py` | OpenDota Explorer 近场次聚合:对阵英雄时敌方终局装备购买率/胜率,与同窗口该装备全局队伍基线作差 → `data/item_counter_stats.json`(可再生成缓存;观测证据,不作因果结论;weekly 软失败) | +| `item_fears.py` | 规则推导「英雄怕的装备」→ `data/hero_item_fears.json`;可选读取 `item_counter_stats.json`,对全部英雄的已有机制候选小幅调序;统计发现的新组合须经机制复核后写入 overrides | | `autocalibrate.py` / `calibrate.py` | ROI 自动 / 手动标定 | | `capture.py` | 屏幕捕获 | | `overlay.py` | 顶栏角色标签 + 网格「克/搭/补」方标 + 阵容分析横条(点击穿透) | @@ -80,6 +81,7 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段 | `data/ability_tag_overrides.json` | 技能「施加」类 tags 手工加减(合并进 hero_abilities;仅 Web 机制页) | | `data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears) | | `data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成;含 ability `tags`) | +| `data/item_counter_stats.json` | OpenDota 对阵装备观测证据缓存(对阵购买率/条件胜率减同装备全局基线;可再生成;不进 recommend / relations) | | `data/hero_item_fears.json` | 英雄怕的装备(规则推导;Web「怕」行) | | `web/relations/` | 上分帝 Web 前端静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js`);`config.js` 含 `SITE_VERSION`、`ABILITY_VIDEO_BASE`、`STATIC_ASSET_BASE`;版本页底部显示 `v{SITE_VERSION}` | | `web/relations/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 `heroes\|rankings\|streamers\|trends\|mechanics\|items\|patches` / 英雄 + 子标签 `skills\|core\|fears\|trends\|matchups\|matches\|streamers\|patches` / Immortal 地区 `#/rankings[/region]` / 主播目录 `#/streamers` / 近 8 周走势榜 `#/trends[/bracket][?sort=pr]`(默认按胜率) / 机制查询 `#/mechanics[/{effect}]`(默认 `basic_dispel`) / 物品 / 版本 / 标签筛选 / 搜索;旧 `stats` / `#/rankings/meta` 别名兼容) | @@ -126,7 +128,7 @@ Dota 2 GSI → gsi_watch.py (:3223) - **合规**:仅 GSI + 屏幕截图;**禁止**读进程内存、注入、绕过 VAC。 - **GSI 范围**:普通玩家视角拿不到双方 pick;GSI 只作阶段触发、`team_slot`、本人 `hero`、本机 `accountid`/`steamid`。本人顶栏槽位**只信** GSI,不用截屏名字亮度猜测。 - **克制 / 搭档数据**:机制克制/搭档只用定性边存 `data/relations.json`(克制有向 + 搭档无向 + 理由),**不要**用胜率/场次表达机制克制,也**不要**写入 `data/heroes.json`。Web 英雄页三视图:克制 / 被克制 / 搭档。OpenDota 段位胜率/场次单独存 `data/hero_stats.json`(近约 7 天窗;上场率 = 出场/(Σ出场/10);冠绝样本过小时与超凡合并),**仅**上分帝 Web 英雄详情「走势」**无 STRATZ 时兜底**。STRATZ 周胜率/分路/`meta_board` 存 `data/stratz_hero_meta.json`(三卡与分路均取 **当前选中勋章 · 最近 1 周** `winWeek`;近 8 周列表从新到旧;分路在 8 周列表上方),对位 Top 存 `data/stratz_matchup_tops.json`(英雄详情「走势」与顶层「走势」榜 `#/trends` 优先用 STRATZ;「对位」Tab 只读),**禁止**合并进 relations/heroes,禁止 `recommend.py` 读取。同英雄近期比赛出装/加点单独存 `data/hero_matches.json`,**仅** Web「近期比赛」Tab,禁止进 recommend。Valve Immortal 四区榜单独存 `data/leaderboards.json`,**仅** Web「排行」页选手榜,禁止进 recommend。主播目录单独存 `data/streamers.json`(手工名单 + 抖音 profile 补全;首版仅抖音),**仅** Web 顶层「主播」与英雄详情「主播」Tab,禁止进 recommend;由 `refresh_web` daily 软失败刷新粉丝等字段(不阻断整档)。 -- **装备机制 / 怕的装备**:标签与技能汇总来自 Valve/OpenDota 自动抽取 + `item_tag_overrides.json`;`item_fears.py` 规则映射到英雄弱点。大段技能文案只进 `data/hero_abilities.json` / `items_meta.json`,**不要**塞进 `heroes.json`。本阶段仅上分帝 Web 展示,**不对局内出装推荐**。核心装「使用率」为 `hero_items` 列表内相对热度归一化,非绝对出场率、无段位维度。技能/物品「施加」类 tags(机制查询页)与技能 `dispellable`(效果能否被驱散)严格区分;**禁止**写入 recommend / relations。 +- **装备机制 / 怕的装备**:标签与技能汇总来自 Valve/OpenDota 自动抽取 + `item_tag_overrides.json`;`item_fears.py` 规则映射到英雄弱点。`fetch_item_counter_stats.py` 的对阵购买率/胜率差仅作为观测证据:须减去同装备全局基线,对全部英雄的已有机制候选只允许小幅调序;统计发现的新组合即使同时满足 `games≥100`、购买率提升 `≥3pp`、条件胜率差 `≥1.5pp`、两项双比例检验 `z≥1.96`,仍须确认机制成立后手工写入 overrides,禁止直接把高相关当因果克制。大段技能文案只进 `data/hero_abilities.json` / `items_meta.json`,**不要**塞进 `heroes.json`。本阶段仅上分帝 Web 展示,**不对局内出装推荐**。核心装「使用率」为 `hero_items` 列表内相对热度归一化,非绝对出场率、无段位维度。技能/物品「施加」类 tags(机制查询页)与技能 `dispellable`(效果能否被驱散)严格区分;**禁止**写入 recommend / relations。 - **机制查询页**:顶层 `#/mechanics[/{effect}]`;侧栏弱/强驱散 + 核心控制;结果为施加该效果的技能与物品。数据边界:仅 Web。 - **模板策略**:只维护 CDN 层。皮肤问题用会话策略(选人可改判、决策 `allow_revise=False`、best 帧偏 HERO_SELECTION),不要为皮肤加模板库,也不要复活 real 双层库。 - **顶栏时机**:皮肤在**全员选完后**才上顶栏;本机进决策时别人可能还在选——须保持 `strategy_tail` 视觉,禁止「一进 STRATEGY 就永久停读」。 @@ -155,6 +157,7 @@ python fetch_hero_abilities.py # 英雄技能 / 驱散汇总 python fetch_hero_abilities.py --tags-only # 只重算技能「施加」类 tags python fetch_hero_abilities.py --icons-only # 缓存技能图标到 assets/ability_icons/ python fetch_ability_videos.py # 官网技能演示(限速;默认 webm) +python fetch_item_counter_stats.py # OpenDota 对阵装备相对全局基线(可再生成缓存) python item_fears.py # 英雄怕的装备 python fetch_patches.py # 近一年版本日志 + 详情(版本页) python fetch_patches.py --check # 只比对列表与本地 details,stdout JSON(has_new) diff --git a/CHANGELOG.md b/CHANGELOG.md index d08afae..c826d3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,38 +4,50 @@ ## [Unreleased] +## [0.5.59] - 2026-07-29 + ### Added +- 上分帝 Web「被克装备」观测证据:`fetch_item_counter_stats.py`(OpenDota Explorer + 对阵购买率/条件胜率减同装备全局队伍基线)→ `data/item_counter_stats.json`; + `item_fears.py` 对全部英雄已有机制候选小幅调序;显著新组合须机制复核后写入 overrides。 - 上分帝 Web 日活飞书推送:`notify_site_traffic.py` + Gitea Actions `site-traffic-notify`(每日 09:00 CST;真实访问按已知浏览器 UA 打开 `/` 估算; 卡片含自动刷新:`web-daily`/`weekly`/`patch` 结论 + 当日 Pages 生产部署)。 -- 上分帝 Web 顶层「走势」`#/trends[/bracket]`:近 8 周高胜率/上场率榜(初期 / 迷你走势 / 末期 / 变化;STRATZ;无禁用率;`0.5.41`)。 +- 上分帝 Web 顶层「走势」`#/trends[/bracket]`:近 8 周高胜率/上场率榜(初期 / 迷你走势 / 末期 / 变化;STRATZ;无禁用率)。 - 上分帝 Web 接入 STRATZ(`fetch_stratz_meta.py`,token 经 `.env` / keyzoo): - 英雄详情「走势」优先 STRATZ:勋章条 + **最近 1 周**三卡 + 分路 + **近 8 周**逐周列表(OpenDota 作兜底) - 新子 Tab「对位」`#/heroes//matchups`:克制 / 被克 / 搭档三列 STRATZ Top(与网格机制「克/怕/搭」分开展示) - - 顶层「走势」`#/trends[/bracket]`:近 8 周高胜率/上场率榜 - 缓存:`data/stratz_hero_meta.json` / `data/stratz_matchup_tops.json`(gitignore;仅 Web,不进 recommend / relations) - 上分帝 Web「近期比赛」Tab(`#/heroes//matches`):同英雄近期局终局出装 + - 加点顺序;展示选手昵称/段位勋章与 OpenDota 选手·比赛链接; + 加点顺序;展示选手昵称/段位勋章与外链; `fetch_hero_matches.py` → `data/hero_matches.json` - (`--source league|public|both`;`--enrich-players` 可补昵称段位; - `--enrich-item-times` 可补购买时间;仅 Web 站点,不进局内推荐)。 + (`--source league|public|both`;`--workers`;`--enrich-item-times`;仅 Web,不进局内推荐)。 - 选将规则阵容画像(不接 AI):识别敌方推进/全球流、敌我缺口(缺控制/爆发/输出/先手); 全网格标 **克 / 搭 / 补**;叠加层显示一句阵容分析;每英雄短推荐原因写入日志与 `results` JSON(`draft_archetypes.py` + `recommend.suggest_marks`)。 ### Changed -- 上分帝 Web 英雄详情「走势」(`0.5.30`–`0.5.37`): - - 三卡 / 分路统一 **当前选中勋章 · 最近 1 周**(STRATZ `winWeek`;分路改 `positionIds`,不再用合并档 `stats`) - - 布局:勋章条 →「最近 1 周」提示 → 三卡 → 分路 → 近 8 周列表;Tab 内容区可滚动 - - 近 8 周:逐周列表(新→旧),含胜率、周环比、按本英雄 8 周区间缩放的横条、场次;去掉 50% 中线与底部刻度文案 +- 上分帝 Web「被克装备」卡片有对阵证据时显示购买率角标;显著新组合补入 overrides + (帕克紫怨、幻影刺客/编织者/石鳞剑士鬼杖、编织者 Eul、石鳞剑士/虚无之灵羊刀); + 剑圣移除林肯法球并补入幽魂权杖。 +- 上分帝 Web 英雄详情「走势」:三卡 / 分路统一 **当前选中勋章 · 最近 1 周**; + 布局为勋章条 → 提示 → 三卡 → 分路 → 近 8 周列表。 - 文档与注释统一称呼:`web/relations/` 子项目改称 **上分帝 Web**(不再称「关系预览」);路径与脚本名(如 `serve_relations.py`)暂保持不变。 -- 「近期比赛」公开对局默认优先国服(Perfect World cluster;`--public-region china`),联赛+天梯可用 `--source both`;卡片来源显示「国服 / 天梯 / 联赛」。 -- 「近期比赛」改为展示最近 10 场胜局(传奇及以上天梯 + 联赛/职业,按时间从新到旧);公开列表过滤机器人/Turbo,仅保留 ranked lobby(`0.5.40`)。 -- 「近期比赛」外链改 Dotabuff(选手/详情);`fetch_hero_matches.py` 支持 `--workers` 详情并发与不足 N 场增量补拉(`0.5.49`)。 +- 「近期比赛」公开对局默认优先国服;展示最近 10 场胜局;外链改 Dotabuff。 - 选将推荐不再限 Top-3 名次青标;非定位局也可推克/搭/补(无分路字则不过滤)。 +## [0.5.58] - 2026-07-28 + +### Added + +- 上分帝 Web「主播」新增 B 站沙王主播,收录主页、直播间、关注与粉丝数。 + +### Changed + +- 主播卡片元信息按平台显示:抖音显示获赞/粉丝,B 站显示 UID/关注/粉丝。 + ## [0.5.57] - 2026-07-28 ### Fixed diff --git a/DESIGN.md b/DESIGN.md index 3c8880e..7b48627 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -167,15 +167,17 @@ climperor/ ├── fetch_cdn_templates.py / fetch_hero_portraits.py / fetch_hero_items.py ├── fetch_item_shop.py / fetch_items_meta.py / fetch_hero_abilities.py ├── fetch_ability_videos.py / fetch_stratz.py / audit_relations.py -├── fetch_patches.py / import_relations_xlsx.py / serve_relations.py +├── fetch_patches.py / refresh_web.py / import_relations_xlsx.py / serve_relations.py +├── export_relations_site.py / deploy_relations.py +├── .gitea/workflows/ # site-traffic-notify + web-daily/weekly/patch ├── autocalibrate.py / calibrate.py / capture.py / evaluate.py -├── web/relations/ # 关系 / 物品 / 版本只读预览页 +├── web/relations/ # 上分帝 Web(英雄/排行/物品/版本) ├── templates/cdn|roles|modes/ ├── assets/ # portraits / icons / videos(部分 gitignore) └── samples/labels.json # 评测真值;raw// 见 .gitignore ``` -运行时目录 `preview/`、`results/`、`failures/`、`samples/raw/`(含按对局子目录)不入库。 +运行时目录 `preview/`、`results/`、`failures/`、`samples/raw/`(含按对局子目录)不入库。上分帝 Web 易变数据(STRATZ meta、hero_stats 等)可由 Gitea Actions 定时拉取后直部 Pages,不必入库。 ### 主要配置 @@ -190,14 +192,18 @@ climperor/ | `gsi.strategy_gsi_wait` | 视觉结束后等待本人 GSI 英雄 | 3.0 | | `gsi.require_foreground` | 仅当前台为 `dota2.exe` 时截屏识别 | true | | `gsi.dump_payloads` | 全量 GSI JSONL 落盘 | true | -| `recommend.enabled` | 选将 Top-3 推荐 | true | -| `recommend.top_n` | 推荐数量 | 3 | +| `recommend.enabled` | 选将全网格克/搭/补推荐 | true | +| `recommend.top_n` | 标记数量上限;`≤0` 不截断 | 0 | +| `recommend.min_enemies` | 开始推荐所需敌方锁人数 | 1 | +| `recommend.min_heroes_for_gaps` | 缺口/补位注入所需锁人数 | 2 | +| `recommend.archetypes` | 推进/全球流/缺口规则画像 | true | | `recommend.relations_path` | 定性关系文件 | `data/relations.json` | -| `recommend.role_tags` | 分路→角色标签过滤(1–5 号位) | 见 config.json | +| `recommend.role_tags` | 分路→角色标签过滤(1–5 号位;非定位局不过滤) | 见 config.json | | `overlay.enabled` | 选将顶栏角色标签悬浮层 | true | | `overlay.y_gap_rel` / `icon_h_rel` / `icon_gap_rel` | 标签相对屏幕高的间距与尺寸 | 0.008 / 0.016 / 0.002 | -| `overlay.mark_size_rel` / `mark_pad_rel` | 网格青标方标尺寸与内边距 | 0.018 / 0.004 | -| `overlay.rec_color` / `rec_text_color` | 青标底色 / 名次文字色 | `#2ec4b6` / `#0b1220` | +| `overlay.mark_size_rel` / `mark_pad_rel` / `mark_gap_rel` | 网格克/搭/补方标尺寸、内边距、间距 | 0.018 / 0.004 / 0.002 | +| `overlay.counter_color` / `synergy_color` / `fill_color` / `mark_text_color` | 克 / 搭 / 补 / 文字色 | `#2ec4b6` / `#e9a825` / `#9b7ebd` / `#0b1220` | +| `overlay.analysis_*` | 阵容分析横条位置/高度/字号/底色字色 | 见 config.json | --- @@ -220,24 +226,30 @@ Dota 2 (-gamestateintegration) --- -## 6. 选将 Top-3 推荐 +## 6. 选将全网格「克 / 搭 / 补」推荐 -- **分路**:定位匹配画面字(`roles.py`)+ GSI `team_slot` 定位本人;不做截屏认自己。 -- **关系模型**:机制克制/搭档(`data/relations.json`),不用胜率/场次——版本会变,机制边相对稳。 -- **三视图**:克制(我克谁)/ 被克制(谁克我)/ 搭档;被克制由有向克制边反查。 -- **打分**:候选 +1×克到的敌 / −1×被敌克 / +1×己方搭档;再按分路角色标签过滤。 -- **展示**:网格左上角青色方标 + 名次 1/2/3(区别于 Plus 右上三角)。 -- **预览**:`serve_relations.py` 仿选将网格只读查看;数据手改 `data/relations.json` 或从 xlsx 导入。 - 另有顶级 **物品** 页(基础/升级,网格↔竖式布局),目录见 `data/item_shop.json`。 -- **预览 Hash 路由**:状态(顶层标签 / 选中英雄 + 详情子标签 / 选中物品 / 选中版本 / - 标签筛选 / 搜索)双向同步进 URL(`#/heroes/axe/core?tags=核心&q=axe` 等)。选 Hash +- **触发**:敌方至少锁定 1 人(`min_enemies`)后开始;本人锁人后清空标记。 +- **分路**:定位匹配画面字(`roles.py`)+ GSI `team_slot` 定位本人;不做截屏认自己。有分路则按 `role_tags` 过滤候选;非定位局(读不到分路字)则全英雄表。 +- **关系模型**:机制克制/搭档(`data/relations.json`),不用胜率/场次——版本会变,机制边相对稳。Web 英雄详情「走势」Tab 优先 STRATZ(`data/stratz_hero_meta.json`):勋章条切换 8 档 → **最近 1 周**三卡(胜率/上场率/场次)→ 1~5 号位分路胜率 → **近 8 周**逐周列表(日期、胜率、周环比、区间缩放横条、场次;新→旧);OpenDota(`data/hero_stats.json`,近约 7 天)作兜底。「对位」Tab 展示 STRATZ 数值 Top(`data/stratz_matchup_tops.json`),与网格定性「克/怕/搭」分开展示;均不进局内推荐。 +- **阵容画像(规则,不接 AI)**:[`draft_archetypes.py`](draft_archetypes.py) 识别敌方推进/全球流、敌我 tag 缺口(缺控制/爆发/输出/先手);输出一句 `analysis`(如 `敌:缺控制·偏推进 | 我:缺爆发`)与每英雄短 `reasons`。 +- **纳入**: + - **克**:关系克制边;推进/全球流应对表(如美杜莎对推进);惩戒敌方缺口 + - **搭**:与已锁己有搭档边 + - **补**:填我方缺口(`min_heroes_for_gaps` 默认 2 才注入缺口类标记) +- **打分**:关系边为主;阵容应对 / 缺口小幅加分;敌方 tags 软加分仅排序。 +- **展示**:网格左上角全量相关格标「克」(青)/「搭」(琥珀)/「补」(紫灰);叠加层横条显示阵容分析;原因进日志与 `results` JSON,不写在每个格子旁。 +- **上分帝 Web**:`serve_relations.py` 本地开发服务;仿选将网格查看克制/被克制/搭档;数据手改 `data/relations.json` 或从 xlsx 导入。 + 另有顶级 **排行**、**走势**(`#/trends[/bracket]`,近 8 周高胜率/上场率榜)、**物品**、**版本** 页;目录见 `data/item_shop.json` / `data/patches.json` / `data/leaderboards.json`。 + 英雄详情子标签含 **走势**(见上;`#/heroes//trends`)、**对位**(克制/被克/搭档三列 STRATZ Top;`#/heroes//matchups`)、**近期比赛**(`#/heroes//matches`)。排行页为 Immortal 四区选手榜。 +- **Web Hash 路由**:状态(顶层标签 / 选中英雄 + 详情子标签 / 选中物品 / 选中版本 / + 标签筛选 / 搜索)双向同步进 URL(`#/heroes/axe/core?tags=核心&q=axe`、`#/heroes/axe/trends` 等)。选 Hash 而非 Path 路由:静态导出(GitHub Pages)无需 rewrite、`serve_relations.py` 零改动、 相对路径子站亦可用。写 URL 用 `history.pushState`/`replaceState`(静默,不触发 `hashchange`,无回环);前进/后退靠 `hashchange` 监听重 parse + 校验 + render。 搜索 debounce 300ms + `replaceState` 防刷历史栈;坏链接丢弃该项不崩。路由逻辑集中在 `web/relations/router.js`,`app.js` 只在 `main()` 装 `installRouter` + 各 state 变更点 调 `syncStateToUrl`。 -- **不做**:避用标、Steam 登录页、爬 Dotabuff;关系**不**写入 `data/heroes.json`。 +- **不做**:避用标、Steam 登录页、爬 Dotabuff;关系**不**写入 `data/heroes.json`;不接实时 AI。 --- diff --git a/README.md b/README.md index 73c7d1e..a0606f5 100644 --- a/README.md +++ b/README.md @@ -27,21 +27,30 @@ python fetch_cdn_templates.py # 一次性:data/heroes.json(含基础属性 # python import_relations_xlsx.py path\to\dota2.xlsx ``` -## 克制 / 被克制 / 搭档(游戏外) +## 上分帝 Web(游戏外) + +上分帝 Web 是独立子项目(`web/relations/`):英雄克制/搭档、排行、主播、物品、版本等内容站点,与局内选将识别解耦。 ```powershell python fetch_hero_portraits.py # 官网竖版头像 → assets/hero_portraits/ python fetch_hero_items.py # OpenDota 热度 + Valve 中文名 + Steam 图标 → data/hero_items.json +python fetch_hero_stats.py # OpenDota 各段位胜率/场次 → data/hero_stats.json(Web 走势兜底) +python fetch_stratz_meta.py # STRATZ 周胜率/分路/对位 Top → stratz_hero_meta.json(需 token;见 .env.example) +python fetch_hero_matches.py # 同英雄近期比赛出装/加点(含购买时间)→ data/hero_matches.json +# 既有行补时间:python fetch_hero_matches.py --enrich-item-times [--heroes juggernaut] python fetch_item_shop.py # 官网商店 11 列目录 → data/item_shop.json python fetch_items_meta.py # 装备描述 + 机制标签 → data/items_meta.json python fetch_hero_abilities.py # 英雄技能 / 驱散汇总 → data/hero_abilities.json python fetch_ability_videos.py # 官网技能演示 webm(限速)→ assets/ability_videos/ +python fetch_item_counter_stats.py # OpenDota 对阵购买率/胜率相对全局基线 → 可再生成缓存 python item_fears.py # 规则推导「怕的装备」→ data/hero_item_fears.json python fetch_patches.py # 近一年版本日志 + 详情 → data/patches.json(版本页) -python serve_relations.py # 只读预览 http://127.0.0.1:8765(改 JSON 后刷新) +python fetch_leaderboards.py # Valve Immortal 四区 Top100 → data/leaderboards.json(排行页) +python fetch_streamers.py # 抖音主播主页补全 → data/streamers.json(主播页;手工名单) +python serve_relations.py # 本地开发服务 http://127.0.0.1:8765(改 JSON 后刷新) ``` -预览页带 **Hash 路由**:`#/heroes/axe/core`、`#/items/black_king_bar`、`#/patches/7.41`(`#/patches` = 最新);英雄页的定位标签筛选与搜索框也进 URL(`?tags=核心&q=axe`)。刷新保状态、可分享 / 深度链接、浏览器前进后退还原。`python export_relations_site.py` 导出的静态站点同样支持深度链接(可部署 GitHub Pages 或 Cloudflare Pages)。 +Web 站点带 **Hash 路由**:`#/heroes/axe/core`、`#/heroes/axe/trends`、`#/heroes/axe/matchups`、`#/heroes/axe/matches`、`#/trends/legend`、`#/rankings`、`#/items/black_king_bar`、`#/patches/7.41`(`#/patches` = 最新,`#/rankings` = 中国区);英雄页的定位标签筛选与搜索框也进 URL(`?tags=核心&q=axe`)。刷新保状态、可分享 / 深度链接、浏览器前进后退还原。`python export_relations_site.py` 导出的静态站点同样支持深度链接(可部署 GitHub Pages 或 Cloudflare Pages)。 ### 部署到 Cloudflare Pages @@ -56,12 +65,50 @@ python _oss_static_assets.py upload # patch/新英雄后同步图标到 OSS python _cf_status.py # 部署后只读核对(项目 / 部署 / 域名状态) ``` -仿局内选将四列网格(图标来自 [dota2.com/heroes](https://www.dota2.com/heroes) 竖版裁切):点英雄高亮克制 / 被克制 / 搭档,下方显示定位,并以标签页切换 **技能**(可点选魔晶/神杖升级与天赋树)、**核心装备**、**被克装备**、**版本变更**(该英雄近一年各版本改动)。 +### 定时刷新(Gitea Actions) + +仓库 [`.gitea/workflows/`](.gitea/workflows/) 在 **self-hosted** runner(与 `site-traffic-notify` 同一 Mac mini)上分层拉数;`refresh_web.py` 比对 JSON/图标摘要,**有变更才** `_oss_static_assets.py upload` + `deploy_relations.py`。数据-only 刷新**不** bump `SITE_VERSION`(`data.json` / `index.html` 已不缓存)。 + +| Workflow | 时间(CST) | 档位 | 拉取内容 | +|----------|-------------|------|----------| +| `web-daily` | 每天 06:00 | `daily` | `hero_stats`、`leaderboards`、`hero_matches`(league)、`pro_matches`、`streamers`(抖音粉丝等,失败保留旧值);再做 patch check | +| `web-weekly` | 周二 07:00 | `weekly` | `stratz_meta`、`hero_items`、`items_meta`、OpenDota 对阵装备证据、`item_fears` | +| `web-patch` | 每 6 小时 | `patch` | `fetch_patches.py --check`;仅 `has_new` 时拉详情 + abilities/商店/items_meta/fears | + +**版本检测:** `--check` 只请求 Valve 版本列表,与本地 `data/patches.json` 的 `details` 比对,stdout 一行 JSON(`has_new` / `new_versions` / `latest`)。无新版本则跳过详情与部署。 + +**本机试跑:** + +```powershell +pip install -r requirements.txt -r requirements-web.txt +python fetch_patches.py --check +python refresh_web.py --tier patch --dry-run +python refresh_web.py --tier patch --skip-deploy --skip-oss +# 真实拉数+部署(需 CF / OSS env 或 keyzoo): +# python refresh_web.py --tier daily +``` + +**Runner 要求:** `python3`(或 `python`)、`node`/`npx`(wrangler)、出网访问 OpenDota / STRATZ / Valve / Cloudflare / 阿里云 OSS。 + +**Gitea Secrets**(仓库 Settings → Actions → Secrets): + +| Secret | 用途 | +|--------|------| +| `CLOUDFLARE_EMAIL` / `CLOUDFLARE_API_KEY` | 部署 Pages(与 `site-traffic-notify` 共用;可用 `_gitea_actions_secrets.py` 写入) | +| `STRATZ_API_TOKEN` | `web-weekly`(及 daily 内若触发连带时不需要) | +| `OSS_ACCESS_KEY_ID` / `OSS_ACCESS_KEY_SECRET` | 图标变更时上传 OSS | + +手工数据(`relations.json`、overrides)与技能视频(GB 级)**不**进定时;新英雄仍需本机 `fetch_cdn_templates.py` + `fetch_hero_portraits.py`。冒烟:Gitea Actions 对 `web-patch` 点 **Run workflow**。 + +仿局内选将四列网格(图标来自 [dota2.com/heroes](https://www.dota2.com/heroes) 竖版裁切):点英雄高亮克制 / 被克制 / 搭档,下方显示定位,并以标签页切换 **技能**(可点选魔晶/神杖升级与天赋树)、**核心装备**、**被克装备**、**走势**(STRATZ 优先:勋章条 + 最近 1 周三卡 + 分路 + 近 8 周列表;OpenDota 兜底)、**对位**(STRATZ 克制/被克/搭档 Top)、**近期比赛**(终局出装/加点)、**主播**(常玩该英雄的收录主播)、**版本变更**(该英雄近一年各版本改动)。 英雄页上方网格固定高度;点选技能 / 核心装备 / 被克装备后,详情显示在下方(装备含合成,非弹窗)。 +顶部另有 **排行** 页:Valve Immortal 四区榜各 Top 100(中国 / 欧洲 / 美洲 / 东南亚);数据来自 `data/leaderboards.json`(`python fetch_leaderboards.py`)。 +顶部另有 **主播** 页:手工收录直播间/主页(首版仅抖音),抖音式卡片展示昵称、签名、关注/粉丝/获赞、常用英雄与可选精选高光视频(滚入视口中部自动播放;有 `live_url` 时点头像进直播间并显示直播动效;**角标不代表真实在播**,待接入 `is_live`);数据来自 `data/streamers.json`(`python fetch_streamers.py` 补全;亦由 `refresh_web --tier daily` 每日软失败刷新)。视频文件放 `assets/streamer_videos/`(gitignore),本地开发经 `/streamer-video/` 提供,部署前用 `_oss_static_assets.py upload` 同步至 OSS `streamer-video/`。 +顶部另有 **走势** 页:近 8 周各段位高胜率 / 上场率榜(`#/trends[/bracket]`;STRATZ 周胜率数据)。 顶部另有 **物品** 页:对齐 [官网商店物品](https://www.dota2.com.cn/items/index.htm) 的 **11 列竖排**(基础分类 5 列 + 合成分类 6 列),点选后详情显示在右侧(含合成组件)。目录来自 `data/item_shop.json`(`python fetch_item_shop.py`)。 顶部另有 **版本** 页:默认展示最新版本完整改动(综合 / 物品 / 中立物品 / 英雄),下拉切换近一年其它版本;数据来自 `data/patches.json`(`python fetch_patches.py`,近一年窗口可用 `--days` / `--since` 调)。 -数据在 `data/relations.json`(英雄关系,不用胜率);选将 Top-3 推荐读同一份文件。热门装备来自 OpenDota 统计;被克装备由描述标签 + 规则推导(可改 `data/item_tag_overrides.json` 后重跑 `fetch_items_meta.py` 与 `item_fears.py`)。**尚无对局内出装推荐**。 +数据在 `data/relations.json`(英雄关系,不用胜率);选将全网格「克/搭/补」推荐读同一份文件,并用 `draft_archetypes.py` 规则识别推进/全球流与敌我缺口(不接 AI)。热门装备来自 OpenDota 统计。**走势**优先 STRATZ(`data/stratz_hero_meta.json`,按勋章近 8 周 + 最近 1 周分路;`python fetch_stratz_meta.py`,需 token),OpenDota 各段位胜率/上场率/场次在 `data/hero_stats.json`(近约 7 天兜底;不进推荐)。**对位**数值 Top 在 `data/stratz_matchup_tops.json`(与网格定性克/搭分开展示)。被克装备以描述标签 + 规则为主;`fetch_item_counter_stats.py` 另算敌方终局装备相对同装备全局基线的购买率提升/胜率差,对全部英雄的已有候选小幅调序;装备卡有数据时显示对阵该英雄的敌方队伍终局装备出现率。统计显著的新组合仍须确认机制成立后写入 `hero_fear_overrides.json`,避免“高相关但不克制”的误报。可改 `data/item_tag_overrides.json` 后重跑 `fetch_items_meta.py` 与 `item_fears.py`。**尚无对局内出装推荐**。 ## 快速开始(GSI 自动跟踪) @@ -71,7 +118,7 @@ python gsi_setup.py # 写入 Dota GSI 配置 python gsi_watch.py ``` -进入英雄选择后自动跟踪;定位匹配下会按本局分路 + 敌方/己方关系在英雄网格左上角标出 **Top 3** 青色名次方标(需 `data/relations.json`)。凑齐 10 人或超时后写出 `results/draft_<时间戳>.json`(该目录已 gitignore)。 +进入英雄选择后自动跟踪;敌方锁 ≥1 人后按关系与阵容画像在英雄网格标出 **克 / 搭 / 补**,并显示一句阵容分析(需 `data/relations.json`;定位局按分路过滤)。凑齐 10 人或超时后写出 `results/draft_<时间戳>.json`(该目录已 gitignore)。 会话截图按对局落在 `samples/raw//`;默认同时把每包 GSI JSON 追加到同目录 `gsi.jsonl`(`--no-dump-gsi` 可关)。 > 主菜单收不到 GSI 是正常的:客户端**第一次载入对局后**才开始推送。看到 `[gsi] connected` 才算链路通。 @@ -116,7 +163,7 @@ python evaluate.py # 按 samples/labels.json 批量评测 - 宽高比差异大(如 21:9)时可能需重标定 - 客户端改网格排序后 `grid.py` 会 `ok=False`,不会瞎报禁用名单 -- 新英雄上线后重跑 `fetch_cdn_templates.py`;版本更新后重跑 `fetch_patches.py` +- 新英雄上线后重跑 `fetch_cdn_templates.py`(不在定时任务内);版本列表由 Gitea `web-patch` 自动检测,有更新才拉 `fetch_patches.py` 详情 - 位置文字模板取自简体中文客户端 - 决策界面 3D 模型挡住顶栏时单帧会很弱,依赖会话多帧回填 - 普通玩家 GSI 只有自己的英雄 id,不能代替读顶栏 diff --git a/_gitea_actions_secrets.py b/_gitea_actions_secrets.py new file mode 100644 index 0000000..dee2fee --- /dev/null +++ b/_gitea_actions_secrets.py @@ -0,0 +1,179 @@ +"""Stage / apply Gitea Actions secrets for site-traffic-notify. + +Pass 1 (feishu): python _gitea_actions_secrets.py stash-feishu +Pass 2 (cloudflare): python _gitea_actions_secrets.py stash-cf +Pass 3 (gitea PAT): python _gitea_actions_secrets.py apply + +Staging file is user-only and deleted after apply. Never prints secret values. +""" + +from __future__ import annotations + +import json +import os +import stat +import sys +import urllib.error +import urllib.request +from pathlib import Path + +STASH = Path(os.environ.get("TEMP") or os.environ.get("TMP") or ".") / "climperor_actions_secrets.json" +GITEA_URL = os.environ.get("GITEA_URL") or os.environ.get("KEYZOO_ASSET_META_URL") or "https://gitea.refining.dev" +OWNER = "refining" +REPO = "climperor" +SECRET_NAMES = ( + "CLOUDFLARE_EMAIL", + "CLOUDFLARE_API_KEY", + "FEISHU_WEBHOOK_URL", +) + + +def _load() -> dict: + if not STASH.exists(): + return {} + return json.loads(STASH.read_text(encoding="utf-8")) + + +def _save(data: dict) -> None: + STASH.write_text(json.dumps(data), encoding="utf-8") + try: + os.chmod(STASH, stat.S_IRUSR | stat.S_IWUSR) + except OSError: + pass + + +def stash_feishu() -> None: + url = os.environ.get("KEYZOO_ASSET_SECRET_FEISHU_WEBHOOK_URL") or os.environ.get( + "FEISHU_WEBHOOK_URL" + ) + if not url: + raise SystemExit("missing FEISHU webhook in env") + data = _load() + data["FEISHU_WEBHOOK_URL"] = url + _save(data) + print(f"stashed FEISHU_WEBHOOK_URL ({STASH.name})") + + +def stash_cf() -> None: + email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get( + "KEYZOO_ASSET_META_USERNAME" + ) + key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get( + "KEYZOO_ASSET_SECRET_GLOBAL_API_KEY" + ) + if not email or not key: + raise SystemExit("missing Cloudflare email/key in env") + data = _load() + data["CLOUDFLARE_EMAIL"] = email + data["CLOUDFLARE_API_KEY"] = key + _save(data) + print(f"stashed CLOUDFLARE_* ({STASH.name})") + + +def _token() -> str: + # Prefer keyzoo-injected secrets over a possibly stale GITEA_TOKEN in the shell. + candidates = ( + "KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN_GITEA_1", + "KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN__GITEA_1", + "KEYZOO_ASSET_TOKEN", + "KEYZOO_ASSET_API_KEY", + "GITEA_TOKEN", + ) + for name in candidates: + tok = os.environ.get(name) + if tok: + print(f"using token from {name} (len={len(tok)})") + return tok + raise SystemExit("missing Gitea token in env") + + +def _put_secret(name: str, value: str, token: str) -> None: + # Gitea Actions: PUT /api/v1/repos/{owner}/{repo}/actions/secrets/{secretname} + url = f"{GITEA_URL.rstrip('/')}/api/v1/repos/{OWNER}/{REPO}/actions/secrets/{name}" + body = json.dumps({"data": value}).encode() + req = urllib.request.Request( + url, + data=body, + method="PUT", + headers={ + "Authorization": f"token {token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=60) as r: + code = r.status + _ = r.read() + except urllib.error.HTTPError as e: + raw = e.read().decode(errors="replace") + raise SystemExit(f"PUT {name} HTTP {e.code}: {raw[:500]}") from e + if code not in (201, 204, 200): + raise SystemExit(f"PUT {name} unexpected status {code}") + print(f"ok: {name}") + + +def apply() -> None: + data = _load() + present = [n for n in SECRET_NAMES if data.get(n)] + missing = [n for n in SECRET_NAMES if n not in present] + if not present: + raise SystemExit(f"stash empty; expected one of {SECRET_NAMES}") + if missing: + print(f"note: not in stash (left unchanged on server): {missing}") + token = _token() + base = GITEA_URL.rstrip("/") + # Sanity: can we see the repo? + req = urllib.request.Request( + f"{base}/api/v1/repos/{OWNER}/{REPO}", + headers={"Authorization": f"token {token}", "Accept": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=60) as r: + repo = json.loads(r.read().decode()) + print(f"repo: {repo.get('full_name')} (id={repo.get('id')})") + + for name in present: + _put_secret(name, data[name], token) + + try: + STASH.unlink() + except OSError: + pass + print("stash deleted; secrets applied") + + +def list_secrets() -> None: + token = _token() + url = f"{GITEA_URL.rstrip('/')}/api/v1/repos/{OWNER}/{REPO}/actions/secrets" + req = urllib.request.Request( + url, + headers={"Authorization": f"token {token}", "Accept": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=60) as r: + rows = json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + raise SystemExit(f"list secrets HTTP {e.code}: {e.read().decode()[:500]}") from e + names = sorted(x.get("name") for x in (rows or [])) + print("secrets:", names) + + +def main() -> int: + if len(sys.argv) < 2: + raise SystemExit("usage: stash-feishu | stash-cf | apply | list") + cmd = sys.argv[1] + if cmd == "stash-feishu": + stash_feishu() + elif cmd == "stash-cf": + stash_cf() + elif cmd == "apply": + apply() + elif cmd == "list": + list_secrets() + else: + raise SystemExit(f"unknown cmd: {cmd}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/_gitea_dispatch_notify.py b/_gitea_dispatch_notify.py new file mode 100644 index 0000000..ab6ba34 --- /dev/null +++ b/_gitea_dispatch_notify.py @@ -0,0 +1,153 @@ +"""Push climperor and dispatch site-traffic-notify via Gitea API. + +Env: KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN_GITEA_1 (or GITEA_TOKEN), +optional KEYZOO_ASSET_META_URL. +Never prints the token. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import urllib.error +import urllib.request + +OWNER = "refining" +REPO = "climperor" +WORKFLOW_FILE = "site-traffic-notify.yml" + + +def token() -> str: + for name in ( + "KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN_GITEA_1", + "KEYZOO_ASSET_SECRET_PERSONAL_ACCESS_TOKEN__GITEA_1", + "KEYZOO_ASSET_TOKEN", + "GITEA_TOKEN", + ): + v = os.environ.get(name) + if v: + return v + raise SystemExit("missing Gitea token") + + +def base() -> str: + return ( + os.environ.get("GITEA_URL") + or os.environ.get("KEYZOO_ASSET_META_URL") + or "https://gitea.refining.dev" + ).rstrip("/") + + +def api(method: str, path: str, body: dict | None = None) -> tuple[int, dict | list | str]: + url = base() + path + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request( + url, + data=data, + method=method, + headers={ + "Authorization": f"token {token()}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(req, timeout=120) as r: + raw = r.read().decode() + if not raw: + return r.status, {} + try: + return r.status, json.loads(raw) + except json.JSONDecodeError: + return r.status, raw + except urllib.error.HTTPError as e: + raw = e.read().decode(errors="replace") + try: + return e.code, json.loads(raw) + except json.JSONDecodeError: + return e.code, raw + + +def push() -> None: + # Prefer HTTPS with token in URL for non-interactive push. + tok = token() + remote = f"https://oauth2:{tok}@gitea.refining.dev/{OWNER}/{REPO}.git" + env = os.environ.copy() + # Avoid leaking via git trace + env.pop("GIT_TRACE", None) + r = subprocess.run( + ["git", "push", remote, "HEAD:main"], + cwd=os.path.dirname(os.path.abspath(__file__)) or ".", + capture_output=True, + text=True, + env=env, + timeout=180, + ) + # Redact token if it somehow appears + out = (r.stdout or "") + (r.stderr or "") + out = out.replace(tok, "***") + print(out) + if r.returncode != 0: + raise SystemExit(f"git push failed: {r.returncode}") + + +def dispatch(day: str = "") -> None: + # Gitea: POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches + # workflow_id can be filename + body: dict = {"ref": "main"} + if day: + body["inputs"] = {"day": day} + code, resp = api( + "POST", + f"/api/v1/repos/{OWNER}/{REPO}/actions/workflows/{WORKFLOW_FILE}/dispatches", + body, + ) + print(f"dispatch HTTP {code}: {resp}") + if code not in (200, 201, 204): + # Fallback: list workflows to find id + c2, workflows = api("GET", f"/api/v1/repos/{OWNER}/{REPO}/actions/workflows") + print(f"workflows HTTP {c2}: {workflows}") + raise SystemExit(1) + + +def latest_runs() -> None: + code, resp = api("GET", f"/api/v1/repos/{OWNER}/{REPO}/actions/runs?limit=5") + print(f"runs HTTP {code}") + if isinstance(resp, dict): + rows = resp.get("workflow_runs") or resp.get("runs") or [] + elif isinstance(resp, list): + rows = resp + else: + print(resp) + return + for r in rows[:5]: + if isinstance(r, dict): + print( + f" - id={r.get('id')} name={r.get('name') or r.get('workflow_id')} " + f"status={r.get('status')} conclusion={r.get('conclusion')} " + f"event={r.get('event')} created={r.get('created_at')}" + ) + + +def main() -> int: + cmd = sys.argv[1] if len(sys.argv) > 1 else "all" + if cmd == "push": + push() + elif cmd == "dispatch": + day = sys.argv[2] if len(sys.argv) > 2 else "" + dispatch(day) + elif cmd == "runs": + latest_runs() + elif cmd == "all": + push() + dispatch("2026-07-27") + latest_runs() + else: + raise SystemExit("usage: push | dispatch [day] | runs | all") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/_oss_static_assets.py b/_oss_static_assets.py index 6019506..96d35ba 100644 --- a/_oss_static_assets.py +++ b/_oss_static_assets.py @@ -1,4 +1,4 @@ -"""Sync relations preview static images to Aliyun OSS (keyzoo inject). +"""Sync Climperor web site static images to Aliyun OSS (keyzoo inject). Builds the same portrait/item/ability/... tree as export_relations_site and uploads under oss:///{attr,item,portrait,...}/. @@ -32,7 +32,17 @@ from serve_relations import build_payload ENDPOINT = "https://oss-cn-shanghai.aliyuncs.com" REGION = "cn-shanghai" -ASSET_DIRS = ("attr", "item", "item-cat", "ability", "ui-icon", "portrait") +ASSET_DIRS = ( + "attr", + "rank", + "item", + "item-cat", + "ability", + "ui-icon", + "portrait", + "streamer-avatar", + "streamer-video", +) def _creds() -> tuple[str, str]: diff --git a/assets/rank_icons/rank_icon_1.png b/assets/rank_icons/rank_icon_1.png new file mode 100644 index 0000000..74190a0 Binary files /dev/null and b/assets/rank_icons/rank_icon_1.png differ diff --git a/assets/rank_icons/rank_icon_2.png b/assets/rank_icons/rank_icon_2.png new file mode 100644 index 0000000..520a5ab Binary files /dev/null and b/assets/rank_icons/rank_icon_2.png differ diff --git a/assets/rank_icons/rank_icon_3.png b/assets/rank_icons/rank_icon_3.png new file mode 100644 index 0000000..ccb75a8 Binary files /dev/null and b/assets/rank_icons/rank_icon_3.png differ diff --git a/assets/rank_icons/rank_icon_4.png b/assets/rank_icons/rank_icon_4.png new file mode 100644 index 0000000..b12bc98 Binary files /dev/null and b/assets/rank_icons/rank_icon_4.png differ diff --git a/assets/rank_icons/rank_icon_5.png b/assets/rank_icons/rank_icon_5.png new file mode 100644 index 0000000..921951a Binary files /dev/null and b/assets/rank_icons/rank_icon_5.png differ diff --git a/assets/rank_icons/rank_icon_6.png b/assets/rank_icons/rank_icon_6.png new file mode 100644 index 0000000..ff8fac9 Binary files /dev/null and b/assets/rank_icons/rank_icon_6.png differ diff --git a/assets/rank_icons/rank_icon_7.png b/assets/rank_icons/rank_icon_7.png new file mode 100644 index 0000000..7b5b4de Binary files /dev/null and b/assets/rank_icons/rank_icon_7.png differ diff --git a/assets/rank_icons/rank_icon_8.png b/assets/rank_icons/rank_icon_8.png new file mode 100644 index 0000000..9970e44 Binary files /dev/null and b/assets/rank_icons/rank_icon_8.png differ diff --git a/assets/streamer_avatars/.gitkeep b/assets/streamer_avatars/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/assets/streamer_avatars/dadigua.jpg b/assets/streamer_avatars/dadigua.jpg new file mode 100644 index 0000000..6eb8d3d Binary files /dev/null and b/assets/streamer_avatars/dadigua.jpg differ diff --git a/assets/streamer_avatars/dota25830.jpg b/assets/streamer_avatars/dota25830.jpg new file mode 100644 index 0000000..880bd0c Binary files /dev/null and b/assets/streamer_avatars/dota25830.jpg differ diff --git a/assets/streamer_avatars/gudu.jpg b/assets/streamer_avatars/gudu.jpg new file mode 100644 index 0000000..edfa296 Binary files /dev/null and b/assets/streamer_avatars/gudu.jpg differ diff --git a/assets/streamer_avatars/gugong.jpg b/assets/streamer_avatars/gugong.jpg new file mode 100644 index 0000000..3957567 Binary files /dev/null and b/assets/streamer_avatars/gugong.jpg differ diff --git a/assets/streamer_avatars/huomao.jpg b/assets/streamer_avatars/huomao.jpg new file mode 100644 index 0000000..fef6cdc Binary files /dev/null and b/assets/streamer_avatars/huomao.jpg differ diff --git a/assets/streamer_avatars/jianxin.jpg b/assets/streamer_avatars/jianxin.jpg new file mode 100644 index 0000000..cbdd18d Binary files /dev/null and b/assets/streamer_avatars/jianxin.jpg differ diff --git a/assets/streamer_avatars/mayi.jpg b/assets/streamer_avatars/mayi.jpg new file mode 100644 index 0000000..0c5222c Binary files /dev/null and b/assets/streamer_avatars/mayi.jpg differ diff --git a/assets/streamer_avatars/shawang.jpg b/assets/streamer_avatars/shawang.jpg new file mode 100644 index 0000000..aad96ac Binary files /dev/null and b/assets/streamer_avatars/shawang.jpg differ diff --git a/assets/streamer_avatars/xiaowang.jpg b/assets/streamer_avatars/xiaowang.jpg new file mode 100644 index 0000000..21b1ef5 Binary files /dev/null and b/assets/streamer_avatars/xiaowang.jpg differ diff --git a/assets/streamer_avatars/yangfanqiguo.jpg b/assets/streamer_avatars/yangfanqiguo.jpg new file mode 100644 index 0000000..29c0c35 Binary files /dev/null and b/assets/streamer_avatars/yangfanqiguo.jpg differ diff --git a/assets/streamer_avatars/yaseguilai.jpg b/assets/streamer_avatars/yaseguilai.jpg new file mode 100644 index 0000000..af2295f Binary files /dev/null and b/assets/streamer_avatars/yaseguilai.jpg differ diff --git a/assets/ui_icons/dota2_logo_wordmark.png b/assets/ui_icons/dota2_logo_wordmark.png new file mode 100644 index 0000000..c0ee973 Binary files /dev/null and b/assets/ui_icons/dota2_logo_wordmark.png differ diff --git a/assets/ui_icons/platform_bilibili.png b/assets/ui_icons/platform_bilibili.png new file mode 100644 index 0000000..0b61ab2 Binary files /dev/null and b/assets/ui_icons/platform_bilibili.png differ diff --git a/assets/ui_icons/platform_douyin.png b/assets/ui_icons/platform_douyin.png new file mode 100644 index 0000000..ac9b372 Binary files /dev/null and b/assets/ui_icons/platform_douyin.png differ diff --git a/common.py b/common.py index db7af36..5feebdd 100644 --- a/common.py +++ b/common.py @@ -15,14 +15,20 @@ TEMPLATES_CDN = ROOT / "templates" / "cdn" HERO_PORTRAITS = ROOT / "assets" / "hero_portraits" # Primary-attribute icons from dota2.com (dota_react/icons/hero_*.png); UI only. ATTR_ICONS = ROOT / "assets" / "attr_icons" -# Item icons from Steam CDN (dota_react/items/{key}.png); relations preview only. +# Item icons from Steam CDN (dota_react/items/{key}.png); Climperor web site only. ITEM_ICONS = ROOT / "assets" / "item_icons" # Shop category header icons from dota2.com.cn/items/images/itemcat_*.png. ITEM_CAT_ICONS = ROOT / "assets" / "item_cat_icons" -# Ability icons from Steam CDN (dota_react/abilities/{key}.png); relations preview only. +# Ability icons from Steam CDN (dota_react/abilities/{key}.png); Climperor web site only. ABILITY_ICONS = ROOT / "assets" / "ability_icons" -# Generic UI glyphs from dota2.com.cn (herostatic/icons/*.png); relations preview only. +# Generic UI glyphs from dota2.com.cn (herostatic/icons/*.png); Climperor web site only. UI_ICONS = ROOT / "assets" / "ui_icons" +# Rank medal icons (OpenDota rank_icon_0..8); Climperor web trends tab only. +RANK_ICONS = ROOT / "assets" / "rank_icons" +# Streamer avatars cached by fetch_streamers.py; Climperor web「主播」only. +STREAMER_AVATARS = ROOT / "assets" / "streamer_avatars" +# Curated Douyin highlight clips for streamer cards; Climperor web「主播」only. +STREAMER_VIDEOS = ROOT / "assets" / "streamer_videos" # Official ability demo clips from dota2.com (dota_react/abilities/{hero}/{ability}.webm). ABILITY_VIDEOS = ROOT / "assets" / "ability_videos" diff --git a/config.json b/config.json index 4e1963f..f0cd245 100644 --- a/config.json +++ b/config.json @@ -102,9 +102,12 @@ "min_iou": 0.55 }, "recommend": { - "comment": "Top-3 from qualitative relations.json: +counter / -countered / +synergy.", + "comment": "Full-grid 克/搭/补 from relations + draft_archetypes (push/global/gaps). top_n<=0 = no cap. No AI.", "enabled": true, - "top_n": 3, + "top_n": 0, + "min_enemies": 1, + "min_heroes_for_gaps": 2, + "archetypes": true, "relations_path": "data/relations.json", "role_tags": { "1": ["Carry"], @@ -135,15 +138,23 @@ "dump_payloads": true }, "overlay": { - "comment": "Role tags under top-bar + cyan Top-3 recommend marks on the hero grid.", + "comment": "Role tags under top-bar + 克/搭/补 marks + lineup analysis banner.", "enabled": true, "y_gap_rel": 0.008, "icon_h_rel": 0.016, "icon_gap_rel": 0.002, "mark_size_rel": 0.018, "mark_pad_rel": 0.004, - "rec_color": "#2ec4b6", - "rec_text_color": "#0b1220" + "mark_gap_rel": 0.002, + "counter_color": "#2ec4b6", + "synergy_color": "#e9a825", + "fill_color": "#9b7ebd", + "mark_text_color": "#0b1220", + "analysis_y_rel": 0.12, + "analysis_h_rel": 0.028, + "analysis_font_rel": 0.014, + "analysis_bg": "#1a2332", + "analysis_fg": "#e8eef7" }, "calibrated_from": "draft_141704.png" } \ No newline at end of file diff --git a/data/ability_tag_overrides.json b/data/ability_tag_overrides.json new file mode 100644 index 0000000..0622d6c --- /dev/null +++ b/data/ability_tag_overrides.json @@ -0,0 +1,832 @@ +{ + "meta": { + "note": "Manual add/remove tags merged after auto-tagging in fetch_hero_abilities.py. Tags mean the ability applies the effect (not that it is dispellable)." + }, + "abilities": { + "abaddon_aphotic_shield": { + "add": [ + "basic_dispel" + ], + "remove": [] + }, + "abaddon_borrowed_time": { + "add": [ + "strong_dispel" + ], + "remove": [] + }, + "legion_commander_press_the_attack": { + "add": [ + "basic_dispel" + ], + "remove": [] + }, + "oracle_false_promise": { + "add": [ + "strong_dispel" + ], + "remove": [] + }, + "oracle_fates_edict": { + "add": [ + "disarm" + ], + "remove": [] + }, + "keeper_of_the_light_chakra_magic": { + "add": [ + "basic_dispel" + ], + "remove": [] + }, + "shadow_demon_demonic_purge": { + "add": [ + "basic_dispel" + ], + "remove": [] + }, + "shadow_demon_demonic_cleanse": { + "add": [ + "strong_dispel" + ], + "remove": [] + }, + "brewmaster_storm_dispel_magic": { + "add": [ + "basic_dispel" + ], + "remove": [] + }, + "tidehunter_kraken_shell": { + "add": [ + "strong_dispel" + ], + "remove": [] + }, + "enchantress_enchant": { + "add": [ + "basic_dispel" + ], + "remove": [] + }, + "phoenix_supernova": { + "add": [ + "strong_dispel" + ], + "remove": [] + }, + "viper_nethertoxin": { + "add": [ + "break" + ], + "remove": [] + }, + "huskar_inner_fire": { + "add": [ + "disarm" + ], + "remove": [] + }, + "troll_warlord_whirling_axes_melee": { + "add": [ + "disarm" + ], + "remove": [] + }, + "doom_bringer_doom": { + "add": [ + "mute", + "break" + ], + "remove": [ + "silence" + ] + }, + "lion_voodoo": { + "add": [ + "hex" + ], + "remove": [ + "silence", + "disarm", + "mute" + ] + }, + "shadow_shaman_voodoo": { + "add": [ + "hex" + ], + "remove": [ + "silence", + "disarm", + "mute" + ] + }, + "omniknight_purification": { + "add": [], + "remove": [ + "basic_dispel" + ] + }, + "silencer_glaives_of_wisdom": { + "add": [], + "remove": [ + "silence" + ] + }, + "silencer_brain_drain": { + "add": [], + "remove": [ + "silence" + ] + }, + "silencer_curse_of_the_silent": { + "add": [], + "remove": [ + "silence" + ] + }, + "spirit_breaker_bull_rush": { + "add": [], + "remove": [ + "stun" + ] + }, + "lone_druid_spirit_bear": { + "add": [], + "remove": [ + "root" + ] + }, + "ringmaster_the_box": { + "add": [], + "remove": [ + "disarm", + "silence", + "mute" + ] + }, + "largo_amphibian_rhapsody": { + "add": [], + "remove": [ + "disarm" + ] + }, + "kez_shodo_sai": { + "add": [], + "remove": [ + "disarm" + ] + }, + "bane_enfeeble": { + "add": [], + "remove": [ + "silence" + ] + }, + "axe_culling_blade": { + "add": [], + "remove": [ + "basic_dispel", + "strong_dispel" + ] + }, + "chen_hand_of_god": { + "add": [], + "remove": [ + "basic_dispel", + "strong_dispel" + ] + }, + "omniknight_martyr": { + "add": [], + "remove": [ + "basic_dispel", + "strong_dispel" + ] + }, + "winter_wyvern_cold_embrace": { + "add": [], + "remove": [ + "basic_dispel" + ] + }, + "disruptor_glimpse": { + "add": [], + "remove": [ + "basic_dispel" + ] + }, + "legion_commander_duel": { + "add": [ + "taunt" + ], + "remove": [ + "stun", + "disarm" + ] + }, + "axe_berserkers_call": { + "add": [ + "taunt" + ], + "remove": [ + "stun" + ] + }, + "doom_bringer_infernal_blade": { + "add": [], + "remove": [ + "stun" + ] + }, + "elder_titan_earth_splitter": { + "add": [], + "remove": [ + "stun" + ] + }, + "ursa_earthshock": { + "add": [], + "remove": [ + "stun" + ] + }, + "brewmaster_thunder_clap": { + "add": [], + "remove": [ + "stun" + ] + }, + "batrider_sticky_napalm": { + "add": [], + "remove": [ + "root" + ] + }, + "templar_assassin_trap": { + "add": [], + "remove": [ + "root" + ] + }, + "enchantress_untouchable": { + "add": [], + "remove": [ + "disarm", + "blind" + ] + }, + "keeper_of_the_light_blinding_light": { + "add": [ + "blind" + ], + "remove": [ + "disarm" + ] + }, + "muerta_pierce_the_veil": { + "add": [], + "remove": [ + "disarm", + "ethereal" + ] + }, + "mars_gods_rebuke": { + "add": [], + "remove": [ + "disarm" + ] + }, + "pangolier_shield_crash": { + "add": [], + "remove": [ + "disarm" + ] + }, + "bristleback_bristleback": { + "add": [], + "remove": [ + "disarm" + ] + }, + "centaur_hoof_stomp": { + "add": [ + "stun" + ], + "remove": [ + "disarm" + ] + }, + "queenofpain_blink": { + "add": [], + "remove": [ + "silence" + ] + }, + "dazzle_nothl_projection": { + "add": [], + "remove": [ + "hex", + "ethereal" + ] + }, + "naga_siren_ensnare": { + "add": [ + "root" + ], + "remove": [ + "break" + ] + }, + "sven_storm_bolt": { + "add": [ + "stun" + ], + "remove": [ + "basic_dispel" + ] + }, + "juggernaut_omni_slash": { + "add": [], + "remove": [ + "basic_dispel", + "strong_dispel" + ] + }, + "juggernaut_blade_fury": { + "add": [], + "remove": [ + "basic_dispel", + "strong_dispel" + ] + }, + "bounty_hunter_wind_walk": { + "add": [ + "invis" + ], + "remove": [] + }, + "riki_blink_strike": { + "add": [], + "remove": [ + "invis" + ] + }, + "clinkz_wind_walk": { + "add": [ + "invis" + ], + "remove": [] + }, + "clinkz_skeleton_walk": { + "add": [ + "invis" + ], + "remove": [] + }, + "weaver_shukuchi": { + "add": [ + "invis" + ], + "remove": [] + }, + "mirana_invis": { + "add": [ + "invis" + ], + "remove": [] + }, + "invoker_ghost_walk": { + "add": [ + "invis" + ], + "remove": [] + }, + "nyx_assassin_vendetta": { + "add": [ + "invis" + ], + "remove": [] + }, + "sandking_sand_storm": { + "add": [ + "invis" + ], + "remove": [] + }, + "templar_assassin_meld": { + "add": [ + "invis" + ], + "remove": [] + }, + "treant_natures_guise": { + "add": [ + "invis" + ], + "remove": [] + }, + "windrunner_windrun": { + "add": [], + "remove": [ + "invis" + ] + }, + "sniper_assassinate": { + "add": [], + "remove": [ + "invis" + ] + }, + "drow_ranger_wave_of_silence": { + "add": [ + "silence" + ], + "remove": [ + "invis" + ] + }, + "phantom_lancer_juxtapose": { + "add": [], + "remove": [ + "invis" + ] + }, + "slardar_amplify_damage": { + "add": [], + "remove": [ + "basic_dispel", + "strong_dispel", + "invis" + ] + }, + "riki_backstab": { + "add": [], + "remove": [ + "invis" + ] + }, + "riki_smoke_screen": { + "add": [ + "silence" + ], + "remove": [ + "invis" + ] + }, + "clinkz_strafe": { + "add": [], + "remove": [ + "invis" + ] + }, + "bounty_hunter_track": { + "add": [], + "remove": [ + "invis" + ] + }, + "lycan_summon_wolves": { + "add": [], + "remove": [ + "invis" + ] + }, + "treant_overgrowth": { + "add": [ + "root" + ], + "remove": [ + "invis" + ] + }, + "nyx_assassin_spiked_carapace": { + "add": [], + "remove": [ + "invis" + ] + }, + "nyx_assassin_burrow": { + "add": [], + "remove": [ + "invis" + ] + }, + "arc_warden_tempest_double": { + "add": [], + "remove": [ + "invis" + ] + }, + "monkey_king_wukongs_command": { + "add": [], + "remove": [ + "invis" + ] + }, + "hoodwink_decoy": { + "add": [], + "remove": [ + "invis" + ] + }, + "ringmaster_spotlight": { + "add": [], + "remove": [ + "invis" + ] + }, + "kez_ravens_veil": { + "add": [ + "invis" + ], + "remove": [] + }, + "slark_shadow_dance": { + "add": [ + "invis" + ], + "remove": [] + }, + "visage_silent_as_the_grave": { + "add": [ + "invis" + ], + "remove": [] + }, + "bounty_hunter_wind_walk_ally": { + "add": [ + "invis" + ], + "remove": [] + }, + "bane_nightmare": { + "add": [ + "sleep" + ], + "remove": [] + }, + "naga_siren_song_of_the_siren": { + "add": [ + "sleep" + ], + "remove": [] + }, + "dark_willow_terrorize": { + "add": [ + "fear" + ], + "remove": [] + }, + "lone_druid_savage_roar": { + "add": [ + "fear" + ], + "remove": [ + "stun" + ] + }, + "nevermore_requiem": { + "add": [ + "fear" + ], + "remove": [] + }, + "brewmaster_cinder_brew": { + "add": [ + "blind" + ], + "remove": [] + }, + "brewmaster_drunken_haze": { + "add": [ + "blind" + ], + "remove": [] + }, + "tinker_laser": { + "add": [ + "blind" + ], + "remove": [] + }, + "troll_warlord_whirling_axes_ranged": { + "add": [ + "blind" + ], + "remove": [ + "disarm" + ] + }, + "disruptor_kinetic_field": { + "add": [ + "leash" + ], + "remove": [] + }, + "grimstroke_soul_chain": { + "add": [ + "leash" + ], + "remove": [] + }, + "puck_dream_coil": { + "add": [ + "leash", + "stun" + ], + "remove": [] + }, + "mars_arena_of_blood": { + "add": [ + "leash" + ], + "remove": [] + }, + "pugna_decrepify": { + "add": [ + "ethereal" + ], + "remove": [] + }, + "necrolyte_ghost_shroud": { + "add": [ + "ethereal" + ], + "remove": [] + }, + "obsidian_destroyer_astral_imprisonment": { + "add": [ + "cyclone" + ], + "remove": [] + }, + "invoker_tornado": { + "add": [ + "cyclone" + ], + "remove": [] + }, + "brewmaster_storm_cyclone": { + "add": [ + "cyclone" + ], + "remove": [] + }, + "shadow_demon_disruption": { + "add": [ + "cyclone" + ], + "remove": [] + }, + "bane_fiends_grip": { + "add": [ + "stun" + ], + "remove": [ + "sleep" + ] + }, + "bane_ichor_of_nyctasha": { + "add": [], + "remove": [ + "fear" + ] + }, + "death_prophet_spirit_siphon": { + "add": [], + "remove": [ + "fear" + ] + }, + "muerta_dead_shot": { + "add": [], + "remove": [ + "fear" + ] + }, + "night_stalker_crippling_fear": { + "add": [ + "silence" + ], + "remove": [ + "fear" + ] + }, + "ringmaster_tame_the_beasts": { + "add": [], + "remove": [ + "fear" + ] + }, + "spectre_haunt": { + "add": [], + "remove": [ + "fear" + ] + }, + "meepo_earthbind": { + "add": [ + "root" + ], + "remove": [ + "leash" + ] + }, + "meepo_petrify": { + "add": [], + "remove": [ + "root" + ] + }, + "meepo_megameepo": { + "add": [], + "remove": [ + "leash" + ] + }, + "nevermore_necromastery": { + "add": [], + "remove": [ + "leash" + ] + }, + "windrunner_shackleshot": { + "add": [ + "stun" + ], + "remove": [ + "leash" + ] + }, + "leshrac_greater_lightning_storm": { + "add": [], + "remove": [ + "ethereal" + ] + }, + "muerta_spectral_slug": { + "add": [], + "remove": [ + "ethereal" + ] + }, + "void_spirit_aether_remnant": { + "add": [ + "stun" + ], + "remove": [ + "ethereal" + ] + }, + "void_spirit_astral_step": { + "add": [], + "remove": [ + "ethereal" + ] + }, + "void_spirit_dissimilate": { + "add": [], + "remove": [ + "ethereal" + ] + }, + "void_spirit_intrinsic_edge": { + "add": [], + "remove": [ + "ethereal" + ] + }, + "void_spirit_resonant_pulse": { + "add": [], + "remove": [ + "ethereal" + ] + }, + "marci_special_delivery": { + "add": [], + "remove": [ + "cyclone" + ] + }, + "slark_pounce": { + "add": [ + "leash" + ], + "remove": [] + }, + "tidehunter_dead_in_the_water": { + "add": [ + "leash" + ], + "remove": [] + }, + "terrorblade_terror_wave": { + "add": [ + "fear" + ], + "remove": [] + } + } +} diff --git a/data/hero_abilities.json b/data/hero_abilities.json index 5d68231..c1cb90e 100644 --- a/data/hero_abilities.json +++ b/data/hero_abilities.json @@ -2,8 +2,28 @@ "meta": { "source": "valve+opendota", "attribution": "https://www.dota2.com ; https://www.opendota.com", - "fetched_at": "2026-07-27T03:20:03.485414+00:00", - "heroes": 127 + "fetched_at": "2026-07-28T13:11:32.471806+00:00", + "heroes": 127, + "tags_only": true, + "mechanic_tag_order": [ + "basic_dispel", + "strong_dispel", + "root", + "disarm", + "silence", + "mute", + "stun", + "hex", + "break", + "sleep", + "fear", + "taunt", + "blind", + "leash", + "invis", + "ethereal", + "cyclone" + ] }, "by_hero": { "antimage": { @@ -46,7 +66,8 @@ "value": "1.8 / 2.7 / 3.6 / 4.5" } ], - "lore_loc": "一种格斗技巧,改良自星隐寺僧侣们的温和招式,可以让魔法能量反噬其拥有者。" + "lore_loc": "一种格斗技巧,改良自星隐寺僧侣们的温和招式,可以让魔法能量反噬其拥有者。", + "tags": [] }, { "key": "antimage_blink", @@ -77,7 +98,8 @@ "value": "0" } ], - "lore_loc": "在与死亡之神遭遇时,敌法师明白了让人捉摸不定是多么重要。" + "lore_loc": "在与死亡之神遭遇时,敌法师明白了让人捉摸不定是多么重要。", + "tags": [] }, { "key": "antimage_counterspell", @@ -112,7 +134,8 @@ "value": "1.3" } ], - "lore_loc": "经过潜心的专注后,敌法师将内在的韧性转为精心的报复。" + "lore_loc": "经过潜心的专注后,敌法师将内在的韧性转为精心的报复。", + "tags": [] }, { "key": "antimage_persectur", @@ -151,7 +174,8 @@ "value": "0.75" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "antimage_mana_void", @@ -190,7 +214,10 @@ "value": "400 / 450 / 500" } ], - "lore_loc": "敌人倒下之后,将因使用奥术而受到敌法师的惩戒。" + "lore_loc": "敌人倒下之后,将因使用奥术而受到敌法师的惩戒。", + "tags": [ + "stun" + ] } ], "talents": [ @@ -295,7 +322,10 @@ "value": "2.1 / 2.4 / 2.7 / 3" } ], - "lore_loc": "蒙哥可汗的狂吼嘲讽使敌人不得不与他作战。" + "lore_loc": "蒙哥可汗的狂吼嘲讽使敌人不得不与他作战。", + "tags": [ + "taunt" + ] }, { "key": "axe_battle_hunger", @@ -334,7 +364,8 @@ "value": "12 / 18 / 24 / 30" } ], - "lore_loc": "通常英雄都无法抵挡蒙哥可汗的战斗狂怒,他们会一直受伤,直到蒙哥可汗的狂怒平息。" + "lore_loc": "通常英雄都无法抵挡蒙哥可汗的战斗狂怒,他们会一直受伤,直到蒙哥可汗的狂怒平息。", + "tags": [] }, { "key": "axe_counter_helix", @@ -385,7 +416,8 @@ "value": "0" } ], - "lore_loc": "这支军队只要有斧王就不需要援军了。" + "lore_loc": "这支军队只要有斧王就不需要援军了。", + "tags": [] }, { "key": "axe_culling_blade", @@ -436,7 +468,8 @@ "value": "1 / 1.5 / 2" } ], - "lore_loc": "蒙哥可汗是战斗与狂怒的化身,胆敢与斧王为敌者只有死路一条。" + "lore_loc": "蒙哥可汗是战斗与狂怒的化身,胆敢与斧王为敌者只有死路一条。", + "tags": [] }, { "key": "axe_one_man_army", @@ -467,7 +500,8 @@ "value": "0" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -576,7 +610,8 @@ "value": "12 / 18 / 24 / 30" } ], - "lore_loc": "阿特洛波斯编织的每一丝恐惧都使他的受难者承受更多的奥术和未知技艺伤害。" + "lore_loc": "阿特洛波斯编织的每一丝恐惧都使他的受难者承受更多的奥术和未知技艺伤害。", + "tags": [] }, { "key": "bane_brain_sap", @@ -607,7 +642,8 @@ "value": "90 / 160 / 230 / 300" } ], - "lore_loc": "阿特洛波斯认为收割被他亲自制造的恐惧弄崩溃的生命是至高的享受。" + "lore_loc": "阿特洛波斯认为收割被他亲自制造的恐惧弄崩溃的生命是至高的享受。", + "tags": [] }, { "key": "bane_nightmare", @@ -642,7 +678,10 @@ "value": "3.5 / 4.5 / 5.5 / 6.5" } ], - "lore_loc": "从夜之女神妮塔莎那里偷来的能力,让猎物一觉不醒。" + "lore_loc": "从夜之女神妮塔莎那里偷来的能力,让猎物一觉不醒。", + "tags": [ + "sleep" + ] }, { "key": "bane_fiends_grip", @@ -681,7 +720,10 @@ "value": "4.75 / 5.25 / 5.75" } ], - "lore_loc": "阿特洛波斯的受害者经常因为噩梦中逼真的幻象痛不欲生。" + "lore_loc": "阿特洛波斯的受害者经常因为噩梦中逼真的幻象痛不欲生。", + "tags": [ + "stun" + ] }, { "key": "bane_ichor_of_nyctasha", @@ -716,7 +758,8 @@ "value": "5%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -825,7 +868,8 @@ "value": "1.2%" } ], - "lore_loc": "史德利古尔与人分享他那野兽般的嗜血饥渴。" + "lore_loc": "史德利古尔与人分享他那野兽般的嗜血饥渴。", + "tags": [] }, { "key": "bloodseeker_blood_bath", @@ -864,7 +908,10 @@ "value": "100 / 155 / 210 / 265" } ], - "lore_loc": "只要有人在战场上遍洒鲜血,那么剥皮双子就乐于向他施以援手。" + "lore_loc": "只要有人在战场上遍洒鲜血,那么剥皮双子就乐于向他施以援手。", + "tags": [ + "silence" + ] }, { "key": "bloodseeker_thirst", @@ -911,7 +958,8 @@ "value": "25%" } ], - "lore_loc": "史德利古尔闻到血腥味就会变得无比狂热。" + "lore_loc": "史德利古尔闻到血腥味就会变得无比狂热。", + "tags": [] }, { "key": "bloodseeker_sanguivore", @@ -946,7 +994,8 @@ "value": "300" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "bloodseeker_rupture", @@ -989,7 +1038,8 @@ "value": "0" } ], - "lore_loc": "当血魔猎杀你时,受伤就意味着死亡。" + "lore_loc": "当血魔猎杀你时,受伤就意味着死亡。", + "tags": [] } ], "talents": [ @@ -1102,7 +1152,8 @@ "value": "110 / 160 / 210 / 260" } ], - "lore_loc": "莉莱周围的温度急剧下降,让附近的一切被急速冻结。" + "lore_loc": "莉莱周围的温度急剧下降,让附近的一切被急速冻结。", + "tags": [] }, { "key": "crystal_maiden_frostbite", @@ -1137,7 +1188,8 @@ "value": "1.5 / 2 / 2.5 / 3" } ], - "lore_loc": "莉莱引来蓝心冰川的寒风,将动手的敌人封锁在冰块里。" + "lore_loc": "莉莱引来蓝心冰川的寒风,将动手的敌人封锁在冰块里。", + "tags": [] }, { "key": "crystal_maiden_brilliance_aura", @@ -1176,7 +1228,8 @@ "value": "20 / 40 / 60 / 80%" } ], - "lore_loc": "低温提升了魔法精华,莉莱的出现能够让盟友更频繁的使用魔法而不必担心消耗。" + "lore_loc": "低温提升了魔法精华,莉莱的出现能够让盟友更频繁的使用魔法而不必担心消耗。", + "tags": [] }, { "key": "crystal_maiden_crystal_clone", @@ -1215,7 +1268,8 @@ "value": "150" } ], - "lore_loc": "非常逼真,有些盟友和克隆体聊了几分钟才发现她没有回应。" + "lore_loc": "非常逼真,有些盟友和克隆体聊了几分钟才发现她没有回应。", + "tags": [] }, { "key": "crystal_maiden_glacial_guard", @@ -1250,7 +1304,8 @@ "value": "8" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "crystal_maiden_freezing_field", @@ -1293,7 +1348,8 @@ "value": "110 / 180 / 250" } ], - "lore_loc": "冰川残骸,莉莱曾经的流放之地,早已变成她修炼寒冰魔法的场所。" + "lore_loc": "冰川残骸,莉莱曾经的流放之地,早已变成她修炼寒冰魔法的场所。", + "tags": [] } ], "talents": [ @@ -1426,7 +1482,8 @@ "value": "0" } ], - "lore_loc": "覆盖着寒冰的箭矢破空而出,将敌人彻底冻结。" + "lore_loc": "覆盖着寒冰的箭矢破空而出,将敌人彻底冻结。", + "tags": [] }, { "key": "drow_ranger_wave_of_silence", @@ -1469,7 +1526,10 @@ "value": "0.6 / 0.7 / 0.8 / 0.9" } ], - "lore_loc": "崔希丝特别中意肉搏中蕴含的静谧,因此她运用卓尔人的天赋终结敌人的魔法。" + "lore_loc": "崔希丝特别中意肉搏中蕴含的静谧,因此她运用卓尔人的天赋终结敌人的魔法。", + "tags": [ + "silence" + ] }, { "key": "drow_ranger_multishot", @@ -1516,7 +1576,8 @@ "value": "35%" } ], - "lore_loc": "在游侠的童年,学习同时射出多支箭矢被视为不够专心的轻浮行为。而在遗迹的大战中,这样的技巧奇特地成为了必要。" + "lore_loc": "在游侠的童年,学习同时射出多支箭矢被视为不够专心的轻浮行为。而在遗迹的大战中,这样的技巧奇特地成为了必要。", + "tags": [] }, { "key": "drow_ranger_glacier", @@ -1555,7 +1616,8 @@ "value": "25%" } ], - "lore_loc": "崔希丝不仅是被卓尔人收养,她还是被他们居住的大地所接纳。" + "lore_loc": "崔希丝不仅是被卓尔人收养,她还是被他们居住的大地所接纳。", + "tags": [] }, { "key": "drow_ranger_trueshot", @@ -1590,7 +1652,8 @@ "value": "10%" } ], - "lore_loc": "崔希丝在卓尔族森林中独处的时光让她有时间教导其他弓箭手如何提高弓技。" + "lore_loc": "崔希丝在卓尔族森林中独处的时光让她有时间教导其他弓箭手如何提高弓技。", + "tags": [] }, { "key": "drow_ranger_marksmanship", @@ -1625,7 +1688,8 @@ "value": "50 / 70 / 90" } ], - "lore_loc": "卓尔游侠是弓箭手登峰造极的典型。" + "lore_loc": "卓尔游侠是弓箭手登峰造极的典型。", + "tags": [] } ], "talents": [ @@ -1738,7 +1802,10 @@ "value": "18 / 17 / 16 / 15" } ], - "lore_loc": "尼西埃图腾能以惊世之力使大地裂开。" + "lore_loc": "尼西埃图腾能以惊世之力使大地裂开。", + "tags": [ + "stun" + ] }, { "key": "earthshaker_enchant_totem", @@ -1785,7 +1852,8 @@ "value": "14" } ], - "lore_loc": "瑞格勇猛的的蛮力能摧毁山脉。" + "lore_loc": "瑞格勇猛的的蛮力能摧毁山脉。", + "tags": [] }, { "key": "earthshaker_aftershock", @@ -1824,7 +1892,10 @@ "value": "1 / 1.1 / 1.2 / 1.3" } ], - "lore_loc": "大地在瑞格强有力的步伐下颤抖。" + "lore_loc": "大地在瑞格强有力的步伐下颤抖。", + "tags": [ + "stun" + ] }, { "key": "earthshaker_echo_slam", @@ -1863,7 +1934,8 @@ "value": "100 / 140 / 180" } ], - "lore_loc": "地壳开裂,山岳折叠,敌人遭回音击尽数碾压。" + "lore_loc": "地壳开裂,山岳折叠,敌人遭回音击尽数碾压。", + "tags": [] }, { "key": "earthshaker_slugger", @@ -1910,7 +1982,8 @@ "value": "7%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -2019,7 +2092,8 @@ "value": "5" } ], - "lore_loc": "无论是战士还是法师,都害怕尤涅若的武士刀剑技。" + "lore_loc": "无论是战士还是法师,都害怕尤涅若的武士刀剑技。", + "tags": [] }, { "key": "juggernaut_healing_ward", @@ -2058,7 +2132,8 @@ "value": "1" } ], - "lore_loc": "在遮面之岛习得的仪式之一,治疗伤口时加上一点巫毒法术往往会很有效。" + "lore_loc": "在遮面之岛习得的仪式之一,治疗伤口时加上一点巫毒法术往往会很有效。", + "tags": [] }, { "key": "juggernaut_blade_dance", @@ -2093,7 +2168,8 @@ "value": "140 / 160 / 180 / 200%" } ], - "lore_loc": "作为他对剑技传承的最后承诺,尤涅若立誓将使此剑技为世人所铭记。" + "lore_loc": "作为他对剑技传承的最后承诺,尤涅若立誓将使此剑技为世人所铭记。", + "tags": [] }, { "key": "juggernaut_swift_slash", @@ -2124,7 +2200,8 @@ "value": "1" } ], - "lore_loc": "为了不配获得荣耀的人准备的短暂猛攻。" + "lore_loc": "为了不配获得荣耀的人准备的短暂猛攻。", + "tags": [] }, { "key": "juggernaut_bladeform", @@ -2167,7 +2244,8 @@ "value": "2" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "juggernaut_omni_slash", @@ -2214,7 +2292,8 @@ "value": "425" } ], - "lore_loc": "“自律已有成果;勤练带来力量。”" + "lore_loc": "“自律已有成果;勤练带来力量。”", + "tags": [] } ], "talents": [ @@ -2327,7 +2406,8 @@ "value": "0" } ], - "lore_loc": "月之女神打破月亮的一部分地表,让她的公主在需要之时获得力量。" + "lore_loc": "月之女神打破月亮的一部分地表,让她的公主在需要之时获得力量。", + "tags": [] }, { "key": "mirana_arrow", @@ -2378,7 +2458,10 @@ "value": "0%" } ], - "lore_loc": "明月的能量让月之公主的箭矢变得愈发致命。" + "lore_loc": "明月的能量让月之公主的箭矢变得愈发致命。", + "tags": [ + "stun" + ] }, { "key": "mirana_leap", @@ -2417,7 +2500,8 @@ "value": "25 / 50 / 75 / 100" } ], - "lore_loc": "在战斗最激烈的时候,没人会注意不到米拉娜标志性的白色野兽。" + "lore_loc": "在战斗最激烈的时候,没人会注意不到米拉娜标志性的白色野兽。", + "tags": [] }, { "key": "mirana_celestial_quiver", @@ -2456,7 +2540,8 @@ "value": "6" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "mirana_invis", @@ -2499,7 +2584,10 @@ "value": "9 / 12 / 15%" } ], - "lore_loc": "作为赛莉蒙妮的代言人,米拉娜能将月蚀之暗投向大地,将她和她的友军隐藏在阴影中。" + "lore_loc": "作为赛莉蒙妮的代言人,米拉娜能将月蚀之暗投向大地,将她和她的友军隐藏在阴影中。", + "tags": [ + "invis" + ] } ], "talents": [ @@ -2591,7 +2679,8 @@ "cooldown": "21 / 18 / 15 / 12", "mana_cost": "115", "specials": [], - "lore_loc": "轻松卷走敌人的滔滔洪水。" + "lore_loc": "轻松卷走敌人的滔滔洪水。", + "tags": [] }, { "key": "morphling_adaptive_strike_agi", @@ -2646,7 +2735,10 @@ "value": "350" } ], - "lore_loc": "变体精灵唤出自身的元素,构成波浪碾碎对手。" + "lore_loc": "变体精灵唤出自身的元素,构成波浪碾碎对手。", + "tags": [ + "stun" + ] }, { "key": "morphling_morph_agi", @@ -2677,7 +2769,10 @@ "value": "10" } ], - "lore_loc": "不断变化的流水让变体精灵的本质很难被辨别。" + "lore_loc": "不断变化的流水让变体精灵的本质很难被辨别。", + "tags": [ + "stun" + ] }, { "key": "morphling_morph_str", @@ -2708,7 +2803,10 @@ "value": "10" } ], - "lore_loc": "不断变化的流水让变体精灵的本质很难被辨别。" + "lore_loc": "不断变化的流水让变体精灵的本质很难被辨别。", + "tags": [ + "stun" + ] }, { "key": "morphling_replicate", @@ -2747,7 +2845,8 @@ "value": "0%" } ], - "lore_loc": "凝视变体精灵会映出一个能够模仿本体的倒影。" + "lore_loc": "凝视变体精灵会映出一个能够模仿本体的倒影。", + "tags": [] }, { "key": "morphling_ebb_and_flow", @@ -2790,7 +2889,8 @@ "value": "25%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -2903,7 +3003,8 @@ "value": "3" } ], - "lore_loc": "奈文摩尔标志性的灵魂收割手段。" + "lore_loc": "奈文摩尔标志性的灵魂收割手段。", + "tags": [] }, { "key": "nevermore_frenzy", @@ -2950,7 +3051,8 @@ "value": "1" } ], - "lore_loc": "在奈文摩尔看来,收获和盛宴一样令人愉悦。" + "lore_loc": "在奈文摩尔看来,收获和盛宴一样令人愉悦。", + "tags": [] }, { "key": "nevermore_dark_lord", @@ -2985,7 +3087,8 @@ "value": "1200" } ], - "lore_loc": "即使只是在影魔周围游荡,灵魂也会被侵蚀。" + "lore_loc": "即使只是在影魔周围游荡,灵魂也会被侵蚀。", + "tags": [] }, { "key": "nevermore_requiem", @@ -3040,7 +3143,10 @@ "value": "120 / 110 / 100" } ], - "lore_loc": "那些死者的灵魂被捕获后尽数释放,毁灭他们生前的盟友。" + "lore_loc": "那些死者的灵魂被捕获后尽数释放,毁灭他们生前的盟友。", + "tags": [ + "fear" + ] }, { "key": "nevermore_necromastery", @@ -3079,7 +3185,8 @@ "value": "0" } ], - "lore_loc": "被收割的亡魂在魂渊内外盘旋,增加了影魔的力量,也方便他扩大自己的灵魂收藏。" + "lore_loc": "被收割的亡魂在魂渊内外盘旋,增加了影魔的力量,也方便他扩大自己的灵魂收藏。", + "tags": [] } ], "talents": [ @@ -3204,7 +3311,8 @@ "value": "0%" } ], - "lore_loc": "阿兹瑞斯对于用长矛捕鱼养家的精通被证明在战场上非常有用。" + "lore_loc": "阿兹瑞斯对于用长矛捕鱼养家的精通被证明在战场上非常有用。", + "tags": [] }, { "key": "phantom_lancer_doppelwalk", @@ -3251,7 +3359,8 @@ "value": "2" } ], - "lore_loc": "恐怖巫师沃恩的死亡让幻影长矛手获得了将自身在所有光线之中弯折破碎的能力。" + "lore_loc": "恐怖巫师沃恩的死亡让幻影长矛手获得了将自身在所有光线之中弯折破碎的能力。", + "tags": [] }, { "key": "phantom_lancer_phantom_edge", @@ -3294,7 +3403,8 @@ "value": "20 / 30 / 40 / 50%" } ], - "lore_loc": "阿兹瑞斯明白,瞬息万变的战场上,速度就是一切。" + "lore_loc": "阿兹瑞斯明白,瞬息万变的战场上,速度就是一切。", + "tags": [] }, { "key": "phantom_lancer_illusory_armaments", @@ -3325,7 +3435,8 @@ "value": "17%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "phantom_lancer_juxtapose", @@ -3376,7 +3487,8 @@ "value": "0" } ], - "lore_loc": "阿兹瑞斯的每次刺击感觉就像一个普通战士的两次,或三次,或四次……" + "lore_loc": "阿兹瑞斯的每次刺击感觉就像一个普通战士的两次,或三次,或四次……", + "tags": [] } ], "talents": [ @@ -3489,7 +3601,8 @@ "value": "5.2" } ], - "lore_loc": "顽皮的仙女龙很喜欢捉弄他人,突然消失再意想不到地重现。" + "lore_loc": "顽皮的仙女龙很喜欢捉弄他人,突然消失再意想不到地重现。", + "tags": [] }, { "key": "puck_waning_rift", @@ -3540,7 +3653,10 @@ "value": "0" } ], - "lore_loc": "帕克淘气地一笑,洒出中断魔法流动的附魔粉尘。" + "lore_loc": "帕克淘气地一笑,洒出中断魔法流动的附魔粉尘。", + "tags": [ + "silence" + ] }, { "key": "puck_phase_shift", @@ -3575,7 +3691,8 @@ "value": "0" } ], - "lore_loc": "带着嘲笑,帕克瞬间回到了她的异维度故乡。" + "lore_loc": "带着嘲笑,帕克瞬间回到了她的异维度故乡。", + "tags": [] }, { "key": "puck_puckish", @@ -3601,7 +3718,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "puck_dream_coil", @@ -3648,7 +3766,12 @@ "value": "200 / 300 / 400" } ], - "lore_loc": "仙女龙散播混乱,让敌人陷入鲜活的梦境,见证自己死亡的命运。" + "lore_loc": "仙女龙散播混乱,让敌人陷入鲜活的梦境,见证自己死亡的命运。", + "tags": [ + "root", + "stun", + "leash" + ] } ], "talents": [ @@ -3753,7 +3876,8 @@ "value": "1300" } ], - "lore_loc": "屠夫的肉钩是噩梦的象征,锋利的倒钩暗示了他嗜血的本性。" + "lore_loc": "屠夫的肉钩是噩梦的象征,锋利的倒钩暗示了他嗜血的本性。", + "tags": [] }, { "key": "pudge_rot", @@ -3796,7 +3920,8 @@ "value": "0%" } ], - "lore_loc": "有毒的气体从屠夫腐烂肿胀的肉体中放出,味道恶心得令人窒息。" + "lore_loc": "有毒的气体从屠夫腐烂肿胀的肉体中放出,味道恶心得令人窒息。", + "tags": [] }, { "key": "pudge_flesh_heap", @@ -3831,7 +3956,8 @@ "value": "4 / 5 / 6 / 7" } ], - "lore_loc": "屠夫赋予“肉盾”这个词新的含义。" + "lore_loc": "屠夫赋予“肉盾”这个词新的含义。", + "tags": [] }, { "key": "pudge_innate_graft_flesh", @@ -3857,7 +3983,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "pudge_dismember", @@ -3900,7 +4027,8 @@ "value": "30 / 25 / 20" } ], - "lore_loc": "“等我干掉这些蠢货以后,就可以把它们做成肉饼了!”" + "lore_loc": "“等我干掉这些蠢货以后,就可以把它们做成肉饼了!”", + "tags": [] } ], "talents": [ @@ -4017,7 +4145,8 @@ "value": "1.5" } ], - "lore_loc": "闪电幽魂用等离子的力量统治着永劫之墟。" + "lore_loc": "闪电幽魂用等离子的力量统治着永劫之墟。", + "tags": [] }, { "key": "razor_static_link", @@ -4056,7 +4185,8 @@ "value": "6 / 12 / 18 / 24" } ], - "lore_loc": "雷泽的极性吸引电流通向自身,以汲取他对手的力量。" + "lore_loc": "雷泽的极性吸引电流通向自身,以汲取他对手的力量。", + "tags": [] }, { "key": "razor_storm_surge", @@ -4111,7 +4241,8 @@ "value": "2.5" } ], - "lore_loc": "仅仅是靠近闪电幽魂就会体验免费的电击疗法。" + "lore_loc": "仅仅是靠近闪电幽魂就会体验免费的电击疗法。", + "tags": [] }, { "key": "razor_unstable_current", @@ -4142,7 +4273,8 @@ "value": "0%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "razor_eye_of_the_storm", @@ -4189,7 +4321,8 @@ "value": "60 / 75 / 90" } ], - "lore_loc": "掌控闪电。" + "lore_loc": "掌控闪电。", + "tags": [] } ], "talents": [ @@ -4286,7 +4419,10 @@ "value": "1.2 / 1.4 / 1.6 / 1.8" } ], - "lore_loc": "克里瑟历斯经常钻入地下埋伏,等待伏击敌人。" + "lore_loc": "克里瑟历斯经常钻入地下埋伏,等待伏击敌人。", + "tags": [ + "stun" + ] }, { "key": "sandking_sand_storm", @@ -4325,7 +4461,10 @@ "value": "16 / 20 / 24 / 28" } ], - "lore_loc": "有人认为克里瑟历斯只是传说中的幻影,因为他的甲壳在闪烁荒原的沙尘中总是若隐若现。" + "lore_loc": "有人认为克里瑟历斯只是传说中的幻影,因为他的甲壳在闪烁荒原的沙尘中总是若隐若现。", + "tags": [ + "invis" + ] }, { "key": "sandking_scorpion_strike", @@ -4368,7 +4507,8 @@ "value": "10 / 12 / 14 / 16%" } ], - "lore_loc": "克里瑟历斯当然有怜悯之心,但是沙之魂并没有发现有多少敌人值得他的饶恕。" + "lore_loc": "克里瑟历斯当然有怜悯之心,但是沙之魂并没有发现有多少敌人值得他的饶恕。", + "tags": [] }, { "key": "sandking_caustic_finale", @@ -4411,7 +4551,8 @@ "value": "4.5" } ], - "lore_loc": "克里瑟历斯的毒液让受害者变得脆弱并且干涸如干旱的荒地,最后爆炸而死。" + "lore_loc": "克里瑟历斯的毒液让受害者变得脆弱并且干涸如干旱的荒地,最后爆炸而死。", + "tags": [] }, { "key": "sandking_epicenter", @@ -4474,7 +4615,8 @@ "value": "6" } ], - "lore_loc": "许多探险家丧生于闪烁荒原的流沙。" + "lore_loc": "许多探险家丧生于闪烁荒原的流沙。", + "tags": [] } ], "talents": [ @@ -4583,7 +4725,8 @@ "value": "100" } ], - "lore_loc": "雷神·风暴烈酒的二象性让他可以欣赏自己被电时的百态。" + "lore_loc": "雷神·风暴烈酒的二象性让他可以欣赏自己被电时的百态。", + "tags": [] }, { "key": "storm_spirit_electric_vortex", @@ -4622,7 +4765,8 @@ "value": "1.1 / 1.4 / 1.7 / 2" } ], - "lore_loc": "雷神狂暴的雷霆力量总是让其他人为之一电。" + "lore_loc": "雷神狂暴的雷霆力量总是让其他人为之一电。", + "tags": [] }, { "key": "storm_spirit_overload", @@ -4685,7 +4829,8 @@ "value": "0.8" } ], - "lore_loc": "啪!嘶!咝!" + "lore_loc": "啪!嘶!咝!", + "tags": [] }, { "key": "storm_spirit_ball_lightning", @@ -4724,7 +4869,8 @@ "value": "0.65%" } ], - "lore_loc": "风暴来袭。" + "lore_loc": "风暴来袭。", + "tags": [] }, { "key": "storm_spirit_galvanized", @@ -4755,7 +4901,8 @@ "value": "1200" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -4856,7 +5003,10 @@ "value": "250 / 270 / 290 / 310" } ], - "lore_loc": "流浪剑客从他父亲的学校里取得的精铁护手能对敌人造成致命打击。" + "lore_loc": "流浪剑客从他父亲的学校里取得的精铁护手能对敌人造成致命打击。", + "tags": [ + "stun" + ] }, { "key": "sven_great_cleave", @@ -4891,7 +5041,8 @@ "value": "60 / 70 / 80 / 90%" } ], - "lore_loc": "守夜骑士团仍然在试图从斯温那里取回被偷走的弃誓之刃,一把能在低阶战士中杀出条大路的利剑。" + "lore_loc": "守夜骑士团仍然在试图从斯温那里取回被偷走的弃誓之刃,一把能在低阶战士中杀出条大路的利剑。", + "tags": [] }, { "key": "sven_warcry", @@ -4946,7 +5097,8 @@ "value": "0" } ], - "lore_loc": "吟诵守夜人法典的句子能够强化斯温对其流浪法则的遵从。太有诗意了!" + "lore_loc": "吟诵守夜人法典的句子能够强化斯温对其流浪法则的遵从。太有诗意了!", + "tags": [] }, { "key": "sven_gods_strength", @@ -4981,7 +5133,8 @@ "value": "40%" } ], - "lore_loc": "凭借能够击碎神圣之盔的力量,流浪剑客在近身肉搏中未尝一败。" + "lore_loc": "凭借能够击碎神圣之盔的力量,流浪剑客在近身肉搏中未尝一败。", + "tags": [] }, { "key": "sven_wrath_of_god", @@ -5016,7 +5169,8 @@ "value": "0" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -5121,7 +5275,10 @@ "value": "90 / 180 / 270 / 360" } ], - "lore_loc": "当受到山岭巨人召唤时,毫无生气的石头也会很有杀伤力。" + "lore_loc": "当受到山岭巨人召唤时,毫无生气的石头也会很有杀伤力。", + "tags": [ + "stun" + ] }, { "key": "tiny_toss", @@ -5160,7 +5317,8 @@ "value": "0%" } ], - "lore_loc": "小小那巨大的身形让他能够随意抛起最强壮的勇士。" + "lore_loc": "小小那巨大的身形让他能够随意抛起最强壮的勇士。", + "tags": [] }, { "key": "tiny_tree_grab", @@ -5207,7 +5365,8 @@ "value": "70 / 80 / 90 / 100%" } ], - "lore_loc": "木棍和石头就能让骨头散架,而建筑也将马上步其后尘。" + "lore_loc": "木棍和石头就能让骨头散架,而建筑也将马上步其后尘。", + "tags": [] }, { "key": "tiny_tree_channel", @@ -5250,7 +5409,8 @@ "value": "2.5" } ], - "lore_loc": "从石头上扔的木棍能打断很多骨头。" + "lore_loc": "从石头上扔的木棍能打断很多骨头。", + "tags": [] }, { "key": "tiny_insurmountable", @@ -5285,7 +5445,8 @@ "value": "10%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "tiny_grow", @@ -5356,7 +5517,8 @@ "value": "0" } ], - "lore_loc": "亲眼看着丘陵变成山峦是震撼的——特别是这座山将他的敌人毁灭的时候。" + "lore_loc": "亲眼看着丘陵变成山峦是震撼的——特别是这座山将他的敌人毁灭的时候。", + "tags": [] }, { "key": "tiny_toss_tree", @@ -5395,7 +5557,8 @@ "value": "275" } ], - "lore_loc": "就算是敌人也能分享小小对树木的热情。" + "lore_loc": "就算是敌人也能分享小小对树木的热情。", + "tags": [] } ], "talents": [ @@ -5496,7 +5659,10 @@ "value": "100 / 180 / 260 / 340" } ], - "lore_loc": "魔法箭是天怒一族的简朴战技,现在也是仙德尔莎主要的复仇工具。" + "lore_loc": "魔法箭是天怒一族的简朴战技,现在也是仙德尔莎主要的复仇工具。", + "tags": [ + "stun" + ] }, { "key": "vengefulspirit_wave_of_terror", @@ -5543,7 +5709,8 @@ "value": "8" } ], - "lore_loc": "仙德尔莎那挥之不去的低语暗示着她的裁决将至。" + "lore_loc": "仙德尔莎那挥之不去的低语暗示着她的裁决将至。", + "tags": [] }, { "key": "vengefulspirit_command_aura", @@ -5586,7 +5753,8 @@ "value": "0%" } ], - "lore_loc": "尽管盟友可能不会理解她对于复仇那无休止的热爱,但作战时他们还是会利用她的狂热。" + "lore_loc": "尽管盟友可能不会理解她对于复仇那无休止的热爱,但作战时他们还是会利用她的狂热。", + "tags": [] }, { "key": "vengefulspirit_retribution", @@ -5612,7 +5780,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "vengefulspirit_nether_swap", @@ -5647,7 +5816,8 @@ "value": "150 / 300 / 450" } ], - "lore_loc": "牺牲只是为了复仇付出的小小代价。" + "lore_loc": "牺牲只是为了复仇付出的小小代价。", + "tags": [] } ], "talents": [ @@ -5756,7 +5926,10 @@ "value": "0 / 0 / 0 / 0" } ], - "lore_loc": "风行者的诸多弓技中,有一种用密集的绳索来束缚目标,让其难以逃脱的箭矢。" + "lore_loc": "风行者的诸多弓技中,有一种用密集的绳索来束缚目标,让其难以逃脱的箭矢。", + "tags": [ + "stun" + ] }, { "key": "windrunner_powershot", @@ -5803,7 +5976,8 @@ "value": "3000" } ], - "lore_loc": "在早年的训练生涯,莱瑞蕾学会了射出足以破开树木击中目标的强力箭矢。" + "lore_loc": "在早年的训练生涯,莱瑞蕾学会了射出足以破开树木击中目标的强力箭矢。", + "tags": [] }, { "key": "windrunner_windrun", @@ -5842,7 +6016,8 @@ "value": "3 / 4 / 5 / 6" } ], - "lore_loc": "莱瑞蕾运用疾风之力抵御来临的攻击。" + "lore_loc": "莱瑞蕾运用疾风之力抵御来临的攻击。", + "tags": [] }, { "key": "windrunner_gale_force", @@ -5881,7 +6056,8 @@ "value": "900" } ], - "lore_loc": "莱瑞蕾终于学会召唤她真正本源的力量。" + "lore_loc": "莱瑞蕾终于学会召唤她真正本源的力量。", + "tags": [] }, { "key": "windrunner_focusfire", @@ -5928,7 +6104,8 @@ "value": "20" } ], - "lore_loc": "莱瑞蕾具备狂风般地向敌人轰射出无数箭矢的无双本领——尽管牺牲了不少威力。" + "lore_loc": "莱瑞蕾具备狂风般地向敌人轰射出无数箭矢的无双本领——尽管牺牲了不少威力。", + "tags": [] }, { "key": "windrunner_tailwind", @@ -5967,7 +6144,8 @@ "value": "600" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -6072,7 +6250,8 @@ "value": "5 / 7 / 9 / 11" } ], - "lore_loc": "弧形闪电是宙斯在对付渺小的凡人时最喜欢用的法术。" + "lore_loc": "弧形闪电是宙斯在对付渺小的凡人时最喜欢用的法术。", + "tags": [] }, { "key": "zuus_lightning_bolt", @@ -6115,7 +6294,10 @@ "value": "0.35" } ], - "lore_loc": "对那些反叛的异教徒最震慑的天罚。" + "lore_loc": "对那些反叛的异教徒最震慑的天罚。", + "tags": [ + "stun" + ] }, { "key": "zuus_heavenly_jump", @@ -6174,7 +6356,8 @@ "value": "1" } ], - "lore_loc": "尽管宙斯暂时被禁止进入天界,但他还是可以进行一定程度上的神圣飞行。" + "lore_loc": "尽管宙斯暂时被禁止进入天界,但他还是可以进行一定程度上的神圣飞行。", + "tags": [] }, { "key": "zuus_cloud", @@ -6221,7 +6404,8 @@ "value": "16" } ], - "lore_loc": "雷神的怒气滋长之地,雷云也会很快聚集。" + "lore_loc": "雷神的怒气滋长之地,雷云也会很快聚集。", + "tags": [] }, { "key": "zuus_lightning_hands", @@ -6256,7 +6440,8 @@ "value": "50" } ], - "lore_loc": "来自阿哈利姆的援手让跌落神坛的天界之王重新发挥出他昔日的部分力量。" + "lore_loc": "来自阿哈利姆的援手让跌落神坛的天界之王重新发挥出他昔日的部分力量。", + "tags": [] }, { "key": "zuus_thundergods_wrath", @@ -6295,7 +6480,8 @@ "value": "300 / 475 / 650" } ], - "lore_loc": "神王毁灭一切胆敢反抗他的敌人,他们无所遁形。" + "lore_loc": "神王毁灭一切胆敢反抗他的敌人,他们无所遁形。", + "tags": [] }, { "key": "zuus_static_field", @@ -6326,7 +6512,8 @@ "value": "3.45" } ], - "lore_loc": "当天神行于世间,空气中满是静电爆裂。" + "lore_loc": "当天神行于世间,空气中满是静电爆裂。", + "tags": [] } ], "talents": [ @@ -6443,7 +6630,10 @@ "value": "110 / 180 / 250 / 320" } ], - "lore_loc": "世界中心喷涌出的上古之洪流,应舰队统帅的召唤而来。" + "lore_loc": "世界中心喷涌出的上古之洪流,应舰队统帅的召唤而来。", + "tags": [ + "stun" + ] }, { "key": "kunkka_tidebringer", @@ -6482,7 +6672,8 @@ "value": "150%" } ], - "lore_loc": "迷失的铁帆海军灵魂依附在昆卡信赖的宝剑——潮汐使者之中,助它消灭洪流恶魔。" + "lore_loc": "迷失的铁帆海军灵魂依附在昆卡信赖的宝剑——潮汐使者之中,助它消灭洪流恶魔。", + "tags": [] }, { "key": "kunkka_x_marks_the_spot", @@ -6525,7 +6716,8 @@ "value": "0" } ], - "lore_loc": "舰队统帅诸多神秘能力中的一种,能用于战斗或娱乐。" + "lore_loc": "舰队统帅诸多神秘能力中的一种,能用于战斗或娱乐。", + "tags": [] }, { "key": "kunkka_admirals_rum", @@ -6560,7 +6752,8 @@ "value": "50.5" } ], - "lore_loc": "朗姆酒对于旱鸭子来说就是毒药,他们不像水手,喝两口就醉了。" + "lore_loc": "朗姆酒对于旱鸭子来说就是毒药,他们不像水手,喝两口就醉了。", + "tags": [] }, { "key": "kunkka_tidal_wave", @@ -6611,7 +6804,8 @@ "value": "600" } ], - "lore_loc": "优秀的水手对潮汐的时间了如指掌。" + "lore_loc": "优秀的水手对潮汐的时间了如指掌。", + "tags": [] }, { "key": "kunkka_ghostship", @@ -6654,7 +6848,10 @@ "value": "1.2" } ], - "lore_loc": "铁帆舰队最后的战舰虽然只是个幻影,但它给昆卡的敌人带来的死亡却是完全真实的。" + "lore_loc": "铁帆舰队最后的战舰虽然只是个幻影,但它给昆卡的敌人带来的死亡却是完全真实的。", + "tags": [ + "stun" + ] } ], "talents": [ @@ -6751,7 +6948,8 @@ "value": "65 / 125 / 185 / 245" } ], - "lore_loc": "在纷争之国的荒焦之地,为了娱乐,莉娜学会了操纵沙漠飞龙的火焰吐息。" + "lore_loc": "在纷争之国的荒焦之地,为了娱乐,莉娜学会了操纵沙漠飞龙的火焰吐息。", + "tags": [] }, { "key": "lina_light_strike_array", @@ -6794,7 +6992,10 @@ "value": "80 / 125 / 170 / 215" } ], - "lore_loc": "莉娜的精华让她能够集中太阳的能量,随时让空气燃烧。" + "lore_loc": "莉娜的精华让她能够集中太阳的能量,随时让空气燃烧。", + "tags": [ + "stun" + ] }, { "key": "lina_fiery_soul", @@ -6837,7 +7038,8 @@ "value": "18" } ], - "lore_loc": "舞动的火焰体现了莉娜爱玩的天性,展示了她真实炙热的自我。" + "lore_loc": "舞动的火焰体现了莉娜爱玩的天性,展示了她真实炙热的自我。", + "tags": [] }, { "key": "lina_flame_cloak", @@ -6876,7 +7078,8 @@ "value": "35%" } ], - "lore_loc": "一道天然的屏障,不论是拦住讨厌的姐妹还是可怕的野兽都能轻松做到。" + "lore_loc": "一道天然的屏障,不论是拦住讨厌的姐妹还是可怕的野兽都能轻松做到。", + "tags": [] }, { "key": "lina_slow_burn", @@ -6902,7 +7105,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "lina_laguna_blade", @@ -6941,7 +7145,8 @@ "value": "0" } ], - "lore_loc": "莉娜身边的空气变得极度炙热,产生白热的闪电来灼烧过于接近的敌人。" + "lore_loc": "莉娜身边的空气变得极度炙热,产生白热的闪电来灼烧过于接近的敌人。", + "tags": [] } ], "talents": [ @@ -7042,7 +7247,10 @@ "value": "1.3 / 1.6 / 1.9 / 2.2" } ], - "lore_loc": "恶魔巫师使用他的恶魔契约,打开一条来自地狱的裂缝。" + "lore_loc": "恶魔巫师使用他的恶魔契约,打开一条来自地狱的裂缝。", + "tags": [ + "stun" + ] }, { "key": "lion_voodoo", @@ -7073,7 +7281,10 @@ "value": "2 / 2.4 / 2.8 / 3.2" } ], - "lore_loc": "莱恩被迫分享他的变形术,让反抗他的人扭曲了他们的精华。" + "lore_loc": "莱恩被迫分享他的变形术,让反抗他的人扭曲了他们的精华。", + "tags": [ + "hex" + ] }, { "key": "lion_mana_drain", @@ -7116,7 +7327,8 @@ "value": "15 / 20 / 25 / 30%" } ], - "lore_loc": "对恶魔巫师来说,较为弱小的法师只不过是他回复魔力的来源。" + "lore_loc": "对恶魔巫师来说,较为弱小的法师只不过是他回复魔力的来源。", + "tags": [] }, { "key": "lion_to_hell_and_back", @@ -7142,7 +7354,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "lion_finger_of_death", @@ -7209,7 +7422,8 @@ "value": "0" } ], - "lore_loc": "莱恩丑陋的手正是他最强大力量的来源,能够用恶魔的力量让受害者完全崩溃。" + "lore_loc": "莱恩丑陋的手正是他最强大力量的来源,能够用恶魔的力量让受害者完全崩溃。", + "tags": [] } ], "talents": [ @@ -7310,7 +7524,8 @@ "value": "125 / 190 / 255 / 320" } ], - "lore_loc": "最初是给流浪艺人作开场秀用的,罗斯塔的闪电之炫能分成数叉来震击不止一个对手。" + "lore_loc": "最初是给流浪艺人作开场秀用的,罗斯塔的闪电之炫能分成数叉来震击不止一个对手。", + "tags": [] }, { "key": "shadow_shaman_voodoo", @@ -7341,7 +7556,10 @@ "value": "2 / 2.3 / 2.6 / 2.9" } ], - "lore_loc": "罗斯塔通常将自己变成一个小鸡来结束表演——现在他把这个屈辱分享给别人了。" + "lore_loc": "罗斯塔通常将自己变成一个小鸡来结束表演——现在他把这个屈辱分享给别人了。", + "tags": [ + "hex" + ] }, { "key": "shadow_shaman_shackles", @@ -7380,7 +7598,8 @@ "value": "450" } ], - "lore_loc": "一个用于自卫的咒语,是罗斯塔的师傅在流血丘陵罹难后,他所开发的枷锁咒。" + "lore_loc": "一个用于自卫的咒语,是罗斯塔的师傅在流血丘陵罹难后,他所开发的枷锁咒。", + "tags": [] }, { "key": "shadow_shaman_fowl_play", @@ -7423,7 +7642,8 @@ "value": "1" } ], - "lore_loc": "罗斯塔必须尽早学会如何能快人一步,抢到哪些想(正当或不正当地)伤害他的人前头。" + "lore_loc": "罗斯塔必须尽早学会如何能快人一步,抢到哪些想(正当或不正当地)伤害他的人前头。", + "tags": [] }, { "key": "shadow_shaman_urnaconda", @@ -7458,7 +7678,8 @@ "value": "275" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "shadow_shaman_mass_serpent_ward", @@ -7505,7 +7726,8 @@ "value": "200 / 340 / 480" } ], - "lore_loc": "耍蛇曾是演艺中的重头戏;现在罗斯塔可以授予蛇力量,以服从他自己的吩咐。" + "lore_loc": "耍蛇曾是演艺中的重头戏;现在罗斯塔可以授予蛇力量,以服从他自己的吩咐。", + "tags": [] } ], "talents": [ @@ -7606,7 +7828,8 @@ "value": "10" } ], - "lore_loc": "斯拉达从深海来到陆地后,发现有必要用强有力的尾巴冲刺来代替游泳。" + "lore_loc": "斯拉达从深海来到陆地后,发现有必要用强有力的尾巴冲刺来代替游泳。", + "tags": [] }, { "key": "slardar_slithereen_crush", @@ -7669,7 +7892,10 @@ "value": "0" } ], - "lore_loc": "强力的快速碎击,溅起的水浪打破最坚固的防御。" + "lore_loc": "强力的快速碎击,溅起的水浪打破最坚固的防御。", + "tags": [ + "stun" + ] }, { "key": "slardar_bash", @@ -7704,7 +7930,8 @@ "value": "1" } ], - "lore_loc": "偷窃深海一族宝藏的盗贼在近身战斗中遇到了鱼人守卫的残暴之力。" + "lore_loc": "偷窃深海一族宝藏的盗贼在近身战斗中遇到了鱼人守卫的残暴之力。", + "tags": [] }, { "key": "slardar_seaborn_sentinel", @@ -7751,7 +7978,8 @@ "value": "11.4%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "slardar_amplify_damage", @@ -7794,7 +8022,8 @@ "value": "7" } ], - "lore_loc": "即使是最坚硬的铠甲,一旦遇到海水也是不堪一击。" + "lore_loc": "即使是最坚硬的铠甲,一旦遇到海水也是不堪一击。", + "tags": [] } ], "talents": [ @@ -7915,7 +8144,8 @@ "value": "4.5" } ], - "lore_loc": "在沉没之岛的竞赛中,利维坦赢得了对公海的统治。" + "lore_loc": "在沉没之岛的竞赛中,利维坦赢得了对公海的统治。", + "tags": [] }, { "key": "tidehunter_kraken_shell", @@ -7962,7 +8192,10 @@ "value": "7" } ], - "lore_loc": "铁帆海军里有一个传言,说是有只强大的海兽,明明被矛刺中被剑砍伤依然能践踏舰队。" + "lore_loc": "铁帆海军里有一个传言,说是有只强大的海兽,明明被矛刺中被剑砍伤依然能践踏舰队。", + "tags": [ + "strong_dispel" + ] }, { "key": "tidehunter_anchor_smash", @@ -8001,7 +8234,8 @@ "value": "6" } ], - "lore_loc": "从舰队统帅昆卡的一艘旗舰上偷来的重锚,被利维坦用作有效的近战武器。" + "lore_loc": "从舰队统帅昆卡的一艘旗舰上偷来的重锚,被利维坦用作有效的近战武器。", + "tags": [] }, { "key": "tidehunter_dead_in_the_water", @@ -8044,7 +8278,10 @@ "value": "10" } ], - "lore_loc": "起锚!" + "lore_loc": "起锚!", + "tags": [ + "leash" + ] }, { "key": "tidehunter_ravage", @@ -8079,7 +8316,10 @@ "value": "2 / 2.2 / 2.4" } ], - "lore_loc": "呼唤深海之神麦尔朗恩的举动导致了整支武装舰队在海上失踪。" + "lore_loc": "呼唤深海之神麦尔朗恩的举动导致了整支武装舰队在海上失踪。", + "tags": [ + "stun" + ] }, { "key": "tidehunter_leviathans_catch", @@ -8118,7 +8358,8 @@ "value": "0" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -8227,7 +8468,10 @@ "value": "20" } ], - "lore_loc": "巫医回收倒下的友军和敌人的尸骨,磨成粉末用作他的符咒和炼丹术的材料。" + "lore_loc": "巫医回收倒下的友军和敌人的尸骨,磨成粉末用作他的符咒和炼丹术的材料。", + "tags": [ + "stun" + ] }, { "key": "witch_doctor_voodoo_restoration", @@ -8270,7 +8514,8 @@ "value": "0 / 0 / 0 / 0%" } ], - "lore_loc": "扎瓦克的药酒不仅可以用来戏弄对手,用来治疗小伤也不错。" + "lore_loc": "扎瓦克的药酒不仅可以用来戏弄对手,用来治疗小伤也不错。", + "tags": [] }, { "key": "witch_doctor_maledict", @@ -8309,7 +8554,8 @@ "value": "12" } ], - "lore_loc": "用对了巫毒法术会让敌人后悔招惹巫医。" + "lore_loc": "用对了巫毒法术会让敌人后悔招惹巫医。", + "tags": [] }, { "key": "witch_doctor_voodoo_switcheroo", @@ -8344,7 +8590,8 @@ "value": "45" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "witch_doctor_gris_gris", @@ -8370,7 +8617,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "witch_doctor_death_ward", @@ -8409,7 +8657,8 @@ "value": "0" } ], - "lore_loc": "巫医跳起他的传统巫毒舞蹈,在生者的梦中作祟。" + "lore_loc": "巫医跳起他的传统巫毒舞蹈,在生者的梦中作祟。", + "tags": [] } ], "talents": [ @@ -8522,7 +8771,8 @@ "value": "80 / 120 / 160 / 200" } ], - "lore_loc": "冰霜法师艾斯瑞安从没忘记哪怕最简单的冰系操控咒。" + "lore_loc": "冰霜法师艾斯瑞安从没忘记哪怕最简单的冰系操控咒。", + "tags": [] }, { "key": "lich_frost_shield", @@ -8569,7 +8819,8 @@ "value": "4 / 5 / 6 / 7" } ], - "lore_loc": "在人世的那些年最初学会的护身法术,而艾斯瑞安现在编织在周围的冰霜魔法随着时间愈发冰冷。" + "lore_loc": "在人世的那些年最初学会的护身法术,而艾斯瑞安现在编织在周围的冰霜魔法随着时间愈发冰冷。", + "tags": [] }, { "key": "lich_sinister_gaze", @@ -8608,7 +8859,8 @@ "value": "1.1 / 1.4 / 1.7 / 2" } ], - "lore_loc": "当心寒意……" + "lore_loc": "当心寒意……", + "tags": [] }, { "key": "lich_ice_spire", @@ -8647,7 +8899,8 @@ "value": "15" } ], - "lore_loc": "暗黑之湖的水远在冰点之下,但仍处于液态,取走以后立刻结晶。" + "lore_loc": "暗黑之湖的水远在冰点之下,但仍处于液态,取走以后立刻结晶。", + "tags": [] }, { "key": "lich_death_charge", @@ -8682,7 +8935,8 @@ "value": "69%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "lich_chain_frost", @@ -8737,7 +8991,8 @@ "value": "2" } ], - "lore_loc": "几乎所有人都认为是冰霜法术中的终极咒语,艾斯瑞安的霜死之珠能用恐惧击打那些胆敢站出来反对他的人们。" + "lore_loc": "几乎所有人都认为是冰霜法术中的终极咒语,艾斯瑞安的霜死之珠能用恐惧击打那些胆敢站出来反对他的人们。", + "tags": [] } ], "talents": [ @@ -8842,7 +9097,10 @@ "value": "6" } ], - "lore_loc": "自从力丸在背叛之夜逃脱后,他就很看重利用朴素的烟幕来迷惑对手。" + "lore_loc": "自从力丸在背叛之夜逃脱后,他就很看重利用朴素的烟幕来迷惑对手。", + "tags": [ + "silence" + ] }, { "key": "riki_blink_strike", @@ -8877,7 +9135,8 @@ "value": "0.75" } ], - "lore_loc": "隐形刺客迅捷的动作让他的目标几乎不可能逃脱。" + "lore_loc": "隐形刺客迅捷的动作让他的目标几乎不可能逃脱。", + "tags": [] }, { "key": "riki_tricks_of_the_trade", @@ -8920,7 +9179,8 @@ "value": "2" } ], - "lore_loc": "隐形刺客从不忌讳使用肮脏的战斗手法,尤其擅长从对手的背后发动攻击。" + "lore_loc": "隐形刺客从不忌讳使用肮脏的战斗手法,尤其擅长从对手的背后发动攻击。", + "tags": [] }, { "key": "riki_innate_backstab", @@ -8963,7 +9223,8 @@ "value": "0 / 0 / 0 / 0" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "riki_backstab", @@ -9002,7 +9263,8 @@ "value": "100" } ], - "lore_loc": "力丸来找你了。" + "lore_loc": "力丸来找你了。", + "tags": [] } ], "talents": [ @@ -9107,7 +9369,10 @@ "value": "3" } ], - "lore_loc": "奇特的重力将敌人的重心进行拖拽,让他们动弹不得。" + "lore_loc": "奇特的重力将敌人的重心进行拖拽,让他们动弹不得。", + "tags": [ + "stun" + ] }, { "key": "enigma_demonic_conversion", @@ -9162,7 +9427,8 @@ "value": "40" } ], - "lore_loc": "谜团具备将自身的形体吸入另一个维度的能力——随之产生的就是三个追猎肉体位面的暗黑虚灵。" + "lore_loc": "谜团具备将自身的形体吸入另一个维度的能力——随之产生的就是三个追猎肉体位面的暗黑虚灵。", + "tags": [] }, { "key": "enigma_midnight_pulse", @@ -9201,7 +9467,8 @@ "value": "9 / 10 / 11 / 12" } ], - "lore_loc": "世界的一部分缓缓跌入虚空。" + "lore_loc": "世界的一部分缓缓跌入虚空。", + "tags": [] }, { "key": "enigma_event_horizon", @@ -9232,7 +9499,8 @@ "value": "5%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "enigma_black_hole", @@ -9271,7 +9539,8 @@ "value": "4" } ], - "lore_loc": "谜团将毁灭的终极漩涡,具有毁天灭地力量的奇点进行引导,大地因此颤抖。" + "lore_loc": "谜团将毁灭的终极漩涡,具有毁天灭地力量的奇点进行引导,大地因此颤抖。", + "tags": [] } ], "talents": [ @@ -9376,7 +9645,10 @@ "value": "75 / 150 / 225 / 300" } ], - "lore_loc": "鲍什在他的地下实验室完善了这把激光岩石切割器,但并未考虑到它的作战效用。" + "lore_loc": "鲍什在他的地下实验室完善了这把激光岩石切割器,但并未考虑到它的作战效用。", + "tags": [ + "blind" + ] }, { "key": "tinker_march_of_the_machines", @@ -9423,7 +9695,8 @@ "value": "0" } ], - "lore_loc": "就算实验室最后被封锁了,呼叫机械无人机的无线电还是畅通的。" + "lore_loc": "就算实验室最后被封锁了,呼叫机械无人机的无线电还是畅通的。", + "tags": [] }, { "key": "tinker_deploy_turrets", @@ -9494,7 +9767,8 @@ "value": "50%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "tinker_warp_grenade", @@ -9529,7 +9803,10 @@ "value": "40%" } ], - "lore_loc": "修补匠最致命的武器能瞬间将敌人化为太空尘埃。研究如何解决令人讨厌的 “完美复制” 副作用... 还在进行中。" + "lore_loc": "修补匠最致命的武器能瞬间将敌人化为太空尘埃。研究如何解决令人讨厌的 “完美复制” 副作用... 还在进行中。", + "tags": [ + "root" + ] }, { "key": "tinker_rearm", @@ -9560,7 +9837,8 @@ "value": "2.75 / 2 / 1.25" } ], - "lore_loc": "这儿换块新电池,那儿塞几枚火箭弹,鲍什不断改进现有的技术。" + "lore_loc": "这儿换块新电池,那儿塞几枚火箭弹,鲍什不断改进现有的技术。", + "tags": [] }, { "key": "tinker_eureka", @@ -9591,7 +9869,8 @@ "value": "0%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -9700,7 +9979,8 @@ "value": "10" } ], - "lore_loc": "卡德尔的模块化步枪还能发射燃烧弹,对攻击战壕中的敌人很有用。" + "lore_loc": "卡德尔的模块化步枪还能发射燃烧弹,对攻击战壕中的敌人很有用。", + "tags": [] }, { "key": "sniper_headshot", @@ -9743,7 +10023,8 @@ "value": "0.2 / 0.3 / 0.4 / 0.5" } ], - "lore_loc": "童年时对潜行兽的胡乱射击现在被练就成攻击敌人的技术。" + "lore_loc": "童年时对潜行兽的胡乱射击现在被练就成攻击敌人的技术。", + "tags": [] }, { "key": "sniper_take_aim", @@ -9786,7 +10067,8 @@ "value": "500 / 750 / 1000 / 1250" } ], - "lore_loc": "卡德尔总是保持自己待在远处的安全距离,同时他仍然可以作为狙击手进行完美的射击。" + "lore_loc": "卡德尔总是保持自己待在远处的安全距离,同时他仍然可以作为狙击手进行完美的射击。", + "tags": [] }, { "key": "sniper_concussive_grenade", @@ -9833,7 +10115,10 @@ "value": "475" } ], - "lore_loc": "注意:如有危险,请拔出插销" + "lore_loc": "注意:如有危险,请拔出插销", + "tags": [ + "disarm" + ] }, { "key": "sniper_keen_scope", @@ -9864,7 +10149,8 @@ "value": "1.5%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "sniper_assassinate", @@ -9907,7 +10193,10 @@ "value": "2" } ], - "lore_loc": "为了完成预言然后返回家乡,卡德尔必须和参加古老考验那天一样,做出完美的射击。" + "lore_loc": "为了完成预言然后返回家乡,卡德尔必须和参加古老考验那天一样,做出完美的射击。", + "tags": [ + "stun" + ] } ], "talents": [ @@ -10008,7 +10297,8 @@ "value": "70 / 90 / 110 / 130" } ], - "lore_loc": "可怜的灵魂们,屈从于罗坦德吉利的瘟疫后遭到回收,沦为他用。" + "lore_loc": "可怜的灵魂们,屈从于罗坦德吉利的瘟疫后遭到回收,沦为他用。", + "tags": [] }, { "key": "necrolyte_ghost_shroud", @@ -10055,7 +10345,10 @@ "value": "-20%" } ], - "lore_loc": "罗坦德吉利利用手中的亡者灵魂搭建出从生到死的桥梁。" + "lore_loc": "罗坦德吉利利用手中的亡者灵魂搭建出从生到死的桥梁。", + "tags": [ + "ethereal" + ] }, { "key": "necrolyte_heartstopper_aura", @@ -10090,7 +10383,8 @@ "value": "0.8 / 1.3 / 1.8 / 2.3%" } ], - "lore_loc": "接近瘟疫法师就会感受到在他周围的空气中存在的瘟疫和灾祸。" + "lore_loc": "接近瘟疫法师就会感受到在他周围的空气中存在的瘟疫和灾祸。", + "tags": [] }, { "key": "necrolyte_death_seeker", @@ -10116,7 +10410,8 @@ "cooldown": "19", "mana_cost": "160", "specials": [], - "lore_loc": "鲁姆斯克的信徒认为瘟疫法师比起人更像是瘟疫。" + "lore_loc": "鲁姆斯克的信徒认为瘟疫法师比起人更像是瘟疫。", + "tags": [] }, { "key": "necrolyte_sadist", @@ -10155,7 +10450,8 @@ "value": "8" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "necrolyte_reapers_scythe", @@ -10198,7 +10494,10 @@ "value": "1 / 2 / 3" } ], - "lore_loc": "空气中充斥的死亡和苦难增强了瘟疫法师的瘟疫魔法。" + "lore_loc": "空气中充斥的死亡和苦难增强了瘟疫法师的瘟疫魔法。", + "tags": [ + "stun" + ] } ], "talents": [ @@ -10307,7 +10606,8 @@ "value": "700" } ], - "lore_loc": "一个远古咒语,能将多个生命体的生命能量连接成一个集合体。" + "lore_loc": "一个远古咒语,能将多个生命体的生命能量连接成一个集合体。", + "tags": [] }, { "key": "warlock_shadow_word", @@ -10346,7 +10646,8 @@ "value": "225" } ], - "lore_loc": "戴蒙的奥术有无穷的用途,可以用来强化友军也可以用作诅咒伤害敌人。" + "lore_loc": "戴蒙的奥术有无穷的用途,可以用来强化友军也可以用作诅咒伤害敌人。", + "tags": [] }, { "key": "warlock_upheaval", @@ -10397,7 +10698,8 @@ "value": "10 / 12 / 14 / 16" } ], - "lore_loc": "戴蒙操纵时空,削弱所有敌军。" + "lore_loc": "戴蒙操纵时空,削弱所有敌军。", + "tags": [] }, { "key": "warlock_eldritch_summoning", @@ -10432,7 +10734,8 @@ "value": "20" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "warlock_rain_of_chaos", @@ -10491,7 +10794,10 @@ "value": "60%" } ], - "lore_loc": "戴蒙释放被他关在恐惧之木法杖中的异界灵魂,在敌阵中引发毁灭。" + "lore_loc": "戴蒙释放被他关在恐惧之木法杖中的异界灵魂,在敌阵中引发毁灭。", + "tags": [ + "stun" + ] } ], "talents": [ @@ -10600,7 +10906,8 @@ "value": "5 / 6 / 7 / 8%" } ], - "lore_loc": "在独自学习野外生存时,兽王也掌握了飞斧的运用技巧,使他能够熟练地砍下树木,或者敌人的脑袋。" + "lore_loc": "在独自学习野外生存时,兽王也掌握了飞斧的运用技巧,使他能够熟练地砍下树木,或者敌人的脑袋。", + "tags": [] }, { "key": "beastmaster_summon_razorback", @@ -10647,7 +10954,8 @@ "value": "3" } ], - "lore_loc": "度过了与异兽互惠互助的孩童时期,兽王学会了如何随时召唤野兽。" + "lore_loc": "度过了与异兽互惠互助的孩童时期,兽王学会了如何随时召唤野兽。", + "tags": [] }, { "key": "beastmaster_summon_raptor", @@ -10694,7 +11002,10 @@ "value": "0.4 / 0.6 / 0.8 / 1" } ], - "lore_loc": "度过了与异兽互惠互助的孩童时期,兽王学会了如何随时召唤野兽。" + "lore_loc": "度过了与异兽互惠互助的孩童时期,兽王学会了如何随时召唤野兽。", + "tags": [ + "root" + ] }, { "key": "beastmaster_inner_beast", @@ -10729,7 +11040,8 @@ "value": "7" } ], - "lore_loc": "兽王激起野兽先天力量的能力,在他袭击斯洛姆国王时得到了展现。" + "lore_loc": "兽王激起野兽先天力量的能力,在他袭击斯洛姆国王时得到了展现。", + "tags": [] }, { "key": "beastmaster_drums_of_slom", @@ -10784,7 +11096,8 @@ "value": "5%" } ], - "lore_loc": "自然的愤怒是永恒的,而知晓这个秘密的人就能非常轻松地重新激发在斯洛姆衰亡之际雀跃的怒火。" + "lore_loc": "自然的愤怒是永恒的,而知晓这个秘密的人就能非常轻松地重新激发在斯洛姆衰亡之际雀跃的怒火。", + "tags": [] }, { "key": "beastmaster_primal_roar", @@ -10835,7 +11148,10 @@ "value": "3 / 3.5 / 4" } ], - "lore_loc": "兽王学会将他的原始本能凝聚为猛兽般的怒吼,在敌阵之中引发巨大的破坏。" + "lore_loc": "兽王学会将他的原始本能凝聚为猛兽般的怒吼,在敌阵之中引发巨大的破坏。", + "tags": [ + "stun" + ] } ], "talents": [ @@ -10944,7 +11260,8 @@ "value": "16" } ], - "lore_loc": "阿卡莎的淬毒匕首让她从受害者的痛苦中得到愉悦。" + "lore_loc": "阿卡莎的淬毒匕首让她从受害者的痛苦中得到愉悦。", + "tags": [] }, { "key": "queenofpain_blink", @@ -10970,7 +11287,8 @@ "cooldown": "12 / 10 / 8 / 6", "mana_cost": "65", "specials": [], - "lore_loc": "秘密中的女王名副其实,你无法从她的痛苦逃脱。" + "lore_loc": "秘密中的女王名副其实,你无法从她的痛苦逃脱。", + "tags": [] }, { "key": "queenofpain_scream_of_pain", @@ -11005,7 +11323,8 @@ "value": "90 / 175 / 260 / 345" } ], - "lore_loc": "阿卡莎那魅惑的声音吸引着敌人的同时也在偷取他们的灵魂。" + "lore_loc": "阿卡莎那魅惑的声音吸引着敌人的同时也在偷取他们的灵魂。", + "tags": [] }, { "key": "queenofpain_succubus", @@ -11040,7 +11359,8 @@ "value": "14%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "queenofpain_sonic_wave", @@ -11079,7 +11399,8 @@ "value": "350" } ], - "lore_loc": "阿卡莎最为精致的折磨,她的音波让可怜的敌人从痛苦中得到解脱。" + "lore_loc": "阿卡莎最为精致的折磨,她的音波让可怜的敌人从痛苦中得到解脱。", + "tags": [] } ], "talents": [ @@ -11192,7 +11513,8 @@ "value": "1200" } ], - "lore_loc": "一种由基迪岛丛林里搜集的各种毒针、毒液和毒素混合而成的药剂,几乎没有人能在沾到它后存活下来。" + "lore_loc": "一种由基迪岛丛林里搜集的各种毒针、毒液和毒素混合而成的药剂,几乎没有人能在沾到它后存活下来。", + "tags": [] }, { "key": "venomancer_snakebite", @@ -11231,7 +11553,8 @@ "value": "6" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "venomancer_plague_ward", @@ -11270,7 +11593,8 @@ "value": "16 / 24 / 32 / 40" } ], - "lore_loc": "他通过将早先的草药学天赋,和后天获得的毒素专精混合起来,创造出一个活体化瘟疫。" + "lore_loc": "他通过将早先的草药学天赋,和后天获得的毒素专精混合起来,创造出一个活体化瘟疫。", + "tags": [] }, { "key": "venomancer_poison_sting", @@ -11309,7 +11633,8 @@ "value": "8%" } ], - "lore_loc": "造成他变异的生物身上的麻痹毒刺,现在属于剧毒术士了。" + "lore_loc": "造成他变异的生物身上的麻痹毒刺,现在属于剧毒术士了。", + "tags": [] }, { "key": "venomancer_noxious_plague", @@ -11368,7 +11693,8 @@ "value": "1200" } ], - "lore_loc": "在基迪岛丛林中心的深处,灭绝的种子自由地生长。" + "lore_loc": "在基迪岛丛林中心的深处,灭绝的种子自由地生长。", + "tags": [] } ], "talents": [ @@ -11473,7 +11799,8 @@ "value": "0" } ], - "lore_loc": "暗惧者将时间撕裂,穿过超维视界并瞬间重现。" + "lore_loc": "暗惧者将时间撕裂,穿过超维视界并瞬间重现。", + "tags": [] }, { "key": "faceless_void_time_dilation", @@ -11532,7 +11859,8 @@ "value": "0" } ], - "lore_loc": "对于挑战暗惧者的人,每个时刻都像是延展成了永恒。" + "lore_loc": "对于挑战暗惧者的人,每个时刻都像是延展成了永恒。", + "tags": [] }, { "key": "faceless_void_time_lock", @@ -11571,7 +11899,8 @@ "value": "18 / 22 / 26 / 30" } ], - "lore_loc": "虚空假面的突袭伴随着维度转移之力。" + "lore_loc": "虚空假面的突袭伴随着维度转移之力。", + "tags": [] }, { "key": "faceless_void_time_walk_reverse", @@ -11597,7 +11926,8 @@ "cooldown": "0.5", "mana_cost": "0", "specials": [], - "lore_loc": "一切都是相对的。" + "lore_loc": "一切都是相对的。", + "tags": [] }, { "key": "faceless_void_distortion_field", @@ -11623,7 +11953,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "faceless_void_chronosphere", @@ -11658,7 +11989,8 @@ "value": "3.75 / 4.25 / 4.75" } ], - "lore_loc": "当超维视界的裂隙突现之时,所有被其困住的可怜灵魂都将万劫不复。" + "lore_loc": "当超维视界的裂隙突现之时,所有被其困住的可怜灵魂都将万劫不复。", + "tags": [] } ], "talents": [ @@ -11771,7 +12103,10 @@ "value": "20 / 40 / 60 / 80" } ], - "lore_loc": "奥斯塔里昂激活他的冥魂怒意,毁灭他的敌人。" + "lore_loc": "奥斯塔里昂激活他的冥魂怒意,毁灭他的敌人。", + "tags": [ + "stun" + ] }, { "key": "skeleton_king_bone_guard", @@ -11818,7 +12153,8 @@ "value": "25%" } ], - "lore_loc": "冥魂大帝的剑刃汲取敌人的精华,填补自身的元气。" + "lore_loc": "冥魂大帝的剑刃汲取敌人的精华,填补自身的元气。", + "tags": [] }, { "key": "skeleton_king_mortal_strike", @@ -11849,7 +12185,8 @@ "value": "160 / 200 / 240 / 280%" } ], - "lore_loc": "单凭一击,斩敌无匹。" + "lore_loc": "单凭一击,斩敌无匹。", + "tags": [] }, { "key": "skeleton_king_vampiric_spirit", @@ -11896,7 +12233,8 @@ "value": "0" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "skeleton_king_reincarnation", @@ -11947,7 +12285,8 @@ "value": "180 / 150 / 120" } ], - "lore_loc": "冥魂大帝的对头们一直很疑惑,为什么他就是不会死透。" + "lore_loc": "冥魂大帝的对头们一直很疑惑,为什么他就是不会死透。", + "tags": [] } ], "talents": [ @@ -12048,7 +12387,8 @@ "value": "900" } ], - "lore_loc": "克萝贝露丝在鬼门关的来来回回聚集了一大群被诅咒的生物。" + "lore_loc": "克萝贝露丝在鬼门关的来来回回聚集了一大群被诅咒的生物。", + "tags": [] }, { "key": "death_prophet_silence", @@ -12083,7 +12423,10 @@ "value": "3.5 / 4 / 4.5 / 5" } ], - "lore_loc": "透过她敌人的命运之纱,克萝贝露丝看到的是沉默的未来。" + "lore_loc": "透过她敌人的命运之纱,克萝贝露丝看到的是沉默的未来。", + "tags": [ + "silence" + ] }, { "key": "death_prophet_spirit_siphon", @@ -12118,7 +12461,8 @@ "value": "6" } ], - "lore_loc": "克萝贝露丝乐意向他人展示她独自无法走上的道路。" + "lore_loc": "克萝贝露丝乐意向他人展示她独自无法走上的道路。", + "tags": [] }, { "key": "death_prophet_witchcraft", @@ -12153,7 +12497,8 @@ "value": "0%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "death_prophet_exorcism", @@ -12200,7 +12545,8 @@ "value": "0%" } ], - "lore_loc": "伴随着无数次的死亡,她前世死亡幻化出的女妖们总是萦绕于现世。" + "lore_loc": "伴随着无数次的死亡,她前世死亡幻化出的女妖们总是萦绕于现世。", + "tags": [] } ], "talents": [ @@ -12309,7 +12655,8 @@ "value": "30 / 45 / 60 / 75%" } ], - "lore_loc": "魅影之纱成员学习到的第一个技能,中招就意味着下一次攻击要来了。" + "lore_loc": "魅影之纱成员学习到的第一个技能,中招就意味着下一次攻击要来了。", + "tags": [] }, { "key": "phantom_assassin_phantom_strike", @@ -12344,7 +12691,8 @@ "value": "3" } ], - "lore_loc": "茉崔蒂的真丝面纱是她倒霉的目标临终所见之物。" + "lore_loc": "茉崔蒂的真丝面纱是她倒霉的目标临终所见之物。", + "tags": [] }, { "key": "phantom_assassin_blur", @@ -12387,7 +12735,8 @@ "value": "9.5%" } ], - "lore_loc": "长期的冥想使得魅影之纱的成员能够谨慎地预知到敌人的到来。" + "lore_loc": "长期的冥想使得魅影之纱的成员能够谨慎地预知到敌人的到来。", + "tags": [] }, { "key": "phantom_assassin_fan_of_knives", @@ -12426,7 +12775,10 @@ "value": "550" } ], - "lore_loc": "通常魅影之纱只会赋予一个名字,但是多个强大的名字意味着众多的盟友。" + "lore_loc": "通常魅影之纱只会赋予一个名字,但是多个强大的名字意味着众多的盟友。", + "tags": [ + "break" + ] }, { "key": "phantom_assassin_immaterial", @@ -12457,7 +12809,8 @@ "value": "25 / 35 / 45 / 55%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "phantom_assassin_coup_de_grace", @@ -12504,7 +12857,8 @@ "value": "6 / 8 / 10" } ], - "lore_loc": "神圣的一击,茉崔蒂通过赐予对手死亡来向他们致以敬意。" + "lore_loc": "神圣的一击,茉崔蒂通过赐予对手死亡来向他们致以敬意。", + "tags": [] } ], "talents": [ @@ -12609,7 +12963,8 @@ "value": "95 / 170 / 245 / 320" } ], - "lore_loc": "湮灭术中有一招是来自三途川的碧绿轰击。" + "lore_loc": "湮灭术中有一招是来自三途川的碧绿轰击。", + "tags": [] }, { "key": "pugna_decrepify", @@ -12652,7 +13007,10 @@ "value": "3.5" } ], - "lore_loc": "作为童年时期遗留下来的癖好,现在帕格纳精通于把人放逐到幽冥之境,而不管那人是善还是恶,或者仅仅是他的一时兴起。" + "lore_loc": "作为童年时期遗留下来的癖好,现在帕格纳精通于把人放逐到幽冥之境,而不管那人是善还是恶,或者仅仅是他的一时兴起。", + "tags": [ + "ethereal" + ] }, { "key": "pugna_nether_ward", @@ -12699,7 +13057,8 @@ "value": "21 / 24 / 27 / 30" } ], - "lore_loc": "还在喇嘛寺里时,帕格纳就学会了操控富有幽冥魔力的守卫来支配他的同学。" + "lore_loc": "还在喇嘛寺里时,帕格纳就学会了操控富有幽冥魔力的守卫来支配他的同学。", + "tags": [] }, { "key": "pugna_oblivion_savant", @@ -12730,7 +13089,8 @@ "value": "0%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "pugna_life_drain", @@ -12769,7 +13129,8 @@ "value": "10" } ], - "lore_loc": "帕格纳的力量甚至已经超越湮灭术前任的上师。" + "lore_loc": "帕格纳的力量甚至已经超越湮灭术前任的上师。", + "tags": [] } ], "talents": [ @@ -12878,7 +13239,8 @@ "value": "17" } ], - "lore_loc": "通过操控她的灵能面纱,拉娜娅让自然法则为她所用。" + "lore_loc": "通过操控她的灵能面纱,拉娜娅让自然法则为她所用。", + "tags": [] }, { "key": "templar_assassin_meld", @@ -12917,7 +13279,10 @@ "value": "6" } ], - "lore_loc": "拉娜娅本人就和她与隐之圣堂的契约一样神秘。" + "lore_loc": "拉娜娅本人就和她与隐之圣堂的契约一样神秘。", + "tags": [ + "invis" + ] }, { "key": "templar_assassin_psi_blades", @@ -12956,7 +13321,8 @@ "value": "70 / 80 / 90 / 100%" } ], - "lore_loc": "她的灵刃刺进了他人的皮肤,也刺进了他们的心智,使她对现实这一奥秘有了更深的理解。" + "lore_loc": "她的灵刃刺进了他人的皮肤,也刺进了他们的心智,使她对现实这一奥秘有了更深的理解。", + "tags": [] }, { "key": "templar_assassin_trap_teleport", @@ -12982,7 +13348,8 @@ "cooldown": "10", "mana_cost": "50", "specials": [], - "lore_loc": "作为她效忠的报酬,拉娜娅得以查探秘密,使她的灵能天赋解开了新的技巧。" + "lore_loc": "作为她效忠的报酬,拉娜娅得以查探秘密,使她的灵能天赋解开了新的技巧。", + "tags": [] }, { "key": "templar_assassin_psionic_trap", @@ -13033,7 +13400,10 @@ "value": "0" } ], - "lore_loc": "召来隐之圣堂的力量后,无人能逃脱圣堂刺客的追杀。" + "lore_loc": "召来隐之圣堂的力量后,无人能逃脱圣堂刺客的追杀。", + "tags": [ + "silence" + ] }, { "key": "templar_assassin_inner_peace", @@ -13072,7 +13442,8 @@ "value": "1.85" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -13193,7 +13564,8 @@ "value": "0%" } ], - "lore_loc": "冥界亚龙天生带有剧毒的吐息能迅速抽干敌人的生命力。" + "lore_loc": "冥界亚龙天生带有剧毒的吐息能迅速抽干敌人的生命力。", + "tags": [] }, { "key": "viper_nethertoxin", @@ -13244,7 +13616,10 @@ "value": "6.5 / 7 / 7.5 / 8" } ], - "lore_loc": "三途川的剧毒让小伤也能致死,而疲乏可以送命。" + "lore_loc": "三途川的剧毒让小伤也能致死,而疲乏可以送命。", + "tags": [ + "break" + ] }, { "key": "viper_corrosive_skin", @@ -13291,7 +13666,8 @@ "value": "1200" } ], - "lore_loc": "对冥界亚龙出击后,手中的刀剑和盾牌会遭到腐蚀并不稀奇。" + "lore_loc": "对冥界亚龙出击后,手中的刀剑和盾牌会遭到腐蚀并不稀奇。", + "tags": [] }, { "key": "viper_nose_dive", @@ -13326,7 +13702,8 @@ "value": "4" } ], - "lore_loc": "世人认为冥界亚龙的翅膀只有在死亡时才会碰到地面,不过也有揣测是有人见证了更为刻意的着陆,只是没能活着说出他的故事。" + "lore_loc": "世人认为冥界亚龙的翅膀只有在死亡时才会碰到地面,不过也有揣测是有人见证了更为刻意的着陆,只是没能活着说出他的故事。", + "tags": [] }, { "key": "viper_predator", @@ -13357,7 +13734,8 @@ "value": "0.25" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "viper_viper_strike", @@ -13400,7 +13778,8 @@ "value": "100 / 140 / 180" } ], - "lore_loc": "冥界亚龙身上最具威力的毒素,曾用于屠戮它残酷的主人。不论是谁,只要愚蠢到与蝮蛇作对就会被毒杀。" + "lore_loc": "冥界亚龙身上最具威力的毒素,曾用于屠戮它残酷的主人。不论是谁,只要愚蠢到与蝮蛇作对就会被毒杀。", + "tags": [] } ], "talents": [ @@ -13509,7 +13888,10 @@ "value": "0 / 0 / 0 / 0" } ], - "lore_loc": "那些侵犯银夜森林的家伙会受到赛莉蒙妮的毁灭打击。" + "lore_loc": "那些侵犯银夜森林的家伙会受到赛莉蒙妮的毁灭打击。", + "tags": [ + "stun" + ] }, { "key": "luna_lunar_orbit", @@ -13552,7 +13934,8 @@ "value": "8 / 12 / 16 / 20%" } ], - "lore_loc": "没有赛莉蒙妮本人的青睐,谁也不能接近女神钦定之人。" + "lore_loc": "没有赛莉蒙妮本人的青睐,谁也不能接近女神钦定之人。", + "tags": [] }, { "key": "luna_moon_glaive", @@ -13591,7 +13974,8 @@ "value": "50 / 45 / 40 / 35%" } ], - "lore_loc": "露娜精心打磨过的回旋镖型武器可以从大群敌人中杀出一条血路。" + "lore_loc": "露娜精心打磨过的回旋镖型武器可以从大群敌人中杀出一条血路。", + "tags": [] }, { "key": "luna_lunar_blessing", @@ -13630,7 +14014,8 @@ "value": "225" } ], - "lore_loc": "月之女神对她的族人微笑。" + "lore_loc": "月之女神对她的族人微笑。", + "tags": [] }, { "key": "luna_eclipse", @@ -13677,7 +14062,10 @@ "value": "2.4 / 4.2 / 6" } ], - "lore_loc": "在急需其帮助的时候,赛莉蒙妮亲自降临世间,遮蔽照向敌人的光芒,以及他们的希望。" + "lore_loc": "在急需其帮助的时候,赛莉蒙妮亲自降临世间,遮蔽照向敌人的光芒,以及他们的希望。", + "tags": [ + "stun" + ] } ], "talents": [ @@ -13790,7 +14178,8 @@ "value": "11" } ], - "lore_loc": "龙骑士达维安的气息已经变成了斯莱瑞克的古龙之息,人们还记得它将无数骑士烧成了焦炭。" + "lore_loc": "龙骑士达维安的气息已经变成了斯莱瑞克的古龙之息,人们还记得它将无数骑士烧成了焦炭。", + "tags": [] }, { "key": "dragon_knight_dragon_tail", @@ -13829,7 +14218,10 @@ "value": "50" } ], - "lore_loc": "达维安将神龙的天赋和骑士的技艺完美结合,使他在近身肉搏中无比勇猛。" + "lore_loc": "达维安将神龙的天赋和骑士的技艺完美结合,使他在近身肉搏中无比勇猛。", + "tags": [ + "stun" + ] }, { "key": "dragon_knight_wyrms_wrath", @@ -13864,7 +14256,8 @@ "value": "25 / 50 / 75 / 100" } ], - "lore_loc": "斯莱瑞克的血液流遍达维安的全身,他获得了寻常骑士身上双倍的活力。" + "lore_loc": "斯莱瑞克的血液流遍达维安的全身,他获得了寻常骑士身上双倍的活力。", + "tags": [] }, { "key": "dragon_knight_fireball", @@ -13907,7 +14300,8 @@ "value": "2" } ], - "lore_loc": "大多数龙骑士声称的恐怖莫过于让生灵涂炭的龙火。" + "lore_loc": "大多数龙骑士声称的恐怖莫过于让生灵涂炭的龙火。", + "tags": [] }, { "key": "dragon_knight_dragon_blood", @@ -13942,7 +14336,8 @@ "value": "2" } ], - "lore_loc": "斯莱瑞克的血液流遍达维安的全身,他获得了寻常骑士身上双倍的活力。" + "lore_loc": "斯莱瑞克的血液流遍达维安的全身,他获得了寻常骑士身上双倍的活力。", + "tags": [] }, { "key": "dragon_knight_elder_dragon_form", @@ -14013,7 +14408,8 @@ "value": "0 / 0 / 50 / 65" } ], - "lore_loc": "潜藏的神龙力量从达维安体内涌出,骑士之力和古龙之威完美地合二为一。" + "lore_loc": "潜藏的神龙力量从达维安体内涌出,骑士之力和古龙之威完美地合二为一。", + "tags": [] } ], "talents": [ @@ -14126,7 +14522,8 @@ "value": "-2.5 / -3 / -3.5 / -4%" } ], - "lore_loc": "少数进攻性的德尊仪式之一,麻痹魔法经常显示出它的有用之处。" + "lore_loc": "少数进攻性的德尊仪式之一,麻痹魔法经常显示出它的有用之处。", + "tags": [] }, { "key": "dazzle_shallow_grave", @@ -14161,7 +14558,8 @@ "value": "3 / 5 / 7 / 9%" } ], - "lore_loc": "只有经验老道的暗影侍僧才能正确做出免死之仪式。" + "lore_loc": "只有经验老道的暗影侍僧才能正确做出免死之仪式。", + "tags": [] }, { "key": "dazzle_shadow_wave", @@ -14204,7 +14602,8 @@ "value": "85 / 105 / 125 / 145" } ], - "lore_loc": "虽然对暗影牧师来说,暗影波只是很简单也很日常的仪式,不过它确实是取胜的关键所在。" + "lore_loc": "虽然对暗影牧师来说,暗影波只是很简单也很日常的仪式,不过它确实是取胜的关键所在。", + "tags": [] }, { "key": "dazzle_innate_weave", @@ -14239,7 +14638,8 @@ "value": "6.9" } ], - "lore_loc": "戴泽在虚无之境的以太之旅让他能将光与暗的力量融在一起,创造出有两种力量的魔力波动。" + "lore_loc": "戴泽在虚无之境的以太之旅让他能将光与暗的力量融在一起,创造出有两种力量的魔力波动。", + "tags": [] }, { "key": "dazzle_nothl_projection", @@ -14286,7 +14686,8 @@ "value": "30 / 40 / 50%" } ], - "lore_loc": "虚无之境的魅力令人沉醉,但戴泽从没忘记过他前去的目的。" + "lore_loc": "虚无之境的魅力令人沉醉,但戴泽从没忘记过他前去的目的。", + "tags": [] } ], "talents": [ @@ -14395,7 +14796,10 @@ "value": "20 / 45 / 70 / 95" } ], - "lore_loc": "瑞托崔普挂载的部分装置运作不良,因此将其引爆也是种有效的进攻手段。" + "lore_loc": "瑞托崔普挂载的部分装置运作不良,因此将其引爆也是种有效的进攻手段。", + "tags": [ + "stun" + ] }, { "key": "rattletrap_power_cogs", @@ -14442,7 +14846,8 @@ "value": "215" } ], - "lore_loc": "发条技师最骄傲的发明之一就是他的能量齿轮——虽然有时会被他的盟友轻视。" + "lore_loc": "发条技师最骄傲的发明之一就是他的能量齿轮——虽然有时会被他的盟友轻视。", + "tags": [] }, { "key": "rattletrap_rocket_flare", @@ -14485,7 +14890,8 @@ "value": "0.4" } ], - "lore_loc": "最初用作节日的焰火,现在成了侦察和轰炸敌人的工具。" + "lore_loc": "最初用作节日的焰火,现在成了侦察和轰炸敌人的工具。", + "tags": [] }, { "key": "rattletrap_overclocking", @@ -14556,7 +14962,10 @@ "value": "40%" } ], - "lore_loc": "瑞托崔普明白他装甲的局限性。就像他明白在必要的情况下越界的风险。" + "lore_loc": "瑞托崔普明白他装甲的局限性。就像他明白在必要的情况下越界的风险。", + "tags": [ + "stun" + ] }, { "key": "rattletrap_jetpack", @@ -14591,7 +15000,8 @@ "value": "6" } ], - "lore_loc": "的确是炸掉了十四个测试的假人,但至少他们都飞起来了!" + "lore_loc": "的确是炸掉了十四个测试的假人,但至少他们都飞起来了!", + "tags": [] }, { "key": "rattletrap_hookshot", @@ -14626,7 +15036,10 @@ "value": "75 / 175 / 275" } ], - "lore_loc": "钩爪虽然是有点笨重的设备,但能让更加厚重的发条装甲在半空中来去如风。" + "lore_loc": "钩爪虽然是有点笨重的设备,但能让更加厚重的发条装甲在半空中来去如风。", + "tags": [ + "stun" + ] }, { "key": "rattletrap_armor_power", @@ -14657,7 +15070,8 @@ "value": "0%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -14762,7 +15176,10 @@ "value": "1.7" } ], - "lore_loc": "拉席克用他恶毒的意志扭曲大自然,撕裂大地摧毁那些挡他路的倒霉鬼。" + "lore_loc": "拉席克用他恶毒的意志扭曲大自然,撕裂大地摧毁那些挡他路的倒霉鬼。", + "tags": [ + "stun" + ] }, { "key": "leshrac_diabolic_edict", @@ -14809,7 +15226,8 @@ "value": "8" } ], - "lore_loc": "古老的能量从一个位面向另一个位面爆发,将触碰到的一切化为灰烬。" + "lore_loc": "古老的能量从一个位面向另一个位面爆发,将触碰到的一切化为灰烬。", + "tags": [] }, { "key": "leshrac_lightning_storm", @@ -14856,7 +15274,8 @@ "value": "0.45 / 0.7 / 0.95 / 1.2" } ], - "lore_loc": "受折磨的灵魂用来击倒敌人的闪电风暴展现出他对操控元素的精通。" + "lore_loc": "受折磨的灵魂用来击倒敌人的闪电风暴展现出他对操控元素的精通。", + "tags": [] }, { "key": "leshrac_greater_lightning_storm", @@ -14899,7 +15318,8 @@ "value": "500" } ], - "lore_loc": "拉席克可以暂时减少他在尘世位面的存在,只要这能助他达成目的。" + "lore_loc": "拉席克可以暂时减少他在尘世位面的存在,只要这能助他达成目的。", + "tags": [] }, { "key": "leshrac_defilement", @@ -14930,7 +15350,8 @@ "value": "0" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "leshrac_pulse_nova", @@ -14969,7 +15390,8 @@ "value": "80 / 135 / 180" } ], - "lore_loc": "必要时,受折磨的灵魂能操纵时空本身,毁灭弱小的存在。" + "lore_loc": "必要时,受折磨的灵魂能操纵时空本身,毁灭弱小的存在。", + "tags": [] } ], "talents": [ @@ -15070,7 +15492,8 @@ "value": "70 / 130 / 190 / 250" } ], - "lore_loc": "疯长的绿苗是那些攻击自然先知的人报告的常见病症。" + "lore_loc": "疯长的绿苗是那些攻击自然先知的人报告的常见病症。", + "tags": [] }, { "key": "furion_teleportation", @@ -15105,7 +15528,8 @@ "value": "15" } ], - "lore_loc": "先知保护着整片森林,当森林需要他的时候立刻出现。" + "lore_loc": "先知保护着整片森林,当森林需要他的时候立刻出现。", + "tags": [] }, { "key": "furion_force_of_nature", @@ -15164,7 +15588,8 @@ "value": "500" } ], - "lore_loc": "维萝迪希娅赐予先知召唤树人守卫的能力,这是大自然的神话军队。" + "lore_loc": "维萝迪希娅赐予先知召唤树人守卫的能力,这是大自然的神话军队。", + "tags": [] }, { "key": "furion_curse_of_the_forest", @@ -15203,7 +15628,8 @@ "value": "7%" } ], - "lore_loc": "自然先知虽然天赋异禀,但是并非无所不见,他必须严重依赖忠实的朋友组成的天网为他提供侦查和援助。" + "lore_loc": "自然先知虽然天赋异禀,但是并非无所不见,他必须严重依赖忠实的朋友组成的天网为他提供侦查和援助。", + "tags": [] }, { "key": "furion_spirit_of_the_forest", @@ -15238,7 +15664,8 @@ "value": "2" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "furion_wrath_of_nature", @@ -15273,7 +15700,10 @@ "value": "10%" } ], - "lore_loc": "自然先知召唤维萝迪希娅的狂怒,消灭那些想要毁坏自然的敌人。" + "lore_loc": "自然先知召唤维萝迪希娅的狂怒,消灭那些想要毁坏自然的敌人。", + "tags": [ + "root" + ] } ], "talents": [ @@ -15378,7 +15808,8 @@ "value": "6 / 9 / 12 / 15%" } ], - "lore_loc": "奈克斯的疯狂和残暴使得他在战斗中异常凶狠无情。" + "lore_loc": "奈克斯的疯狂和残暴使得他在战斗中异常凶狠无情。", + "tags": [] }, { "key": "life_stealer_open_wounds", @@ -15417,7 +15848,8 @@ "value": "50%" } ], - "lore_loc": "新鲜的伤口和鲜血的芳香通常能引来清道夫清理善后。" + "lore_loc": "新鲜的伤口和鲜血的芳香通常能引来清道夫清理善后。", + "tags": [] }, { "key": "life_stealer_feast", @@ -15464,7 +15896,8 @@ "value": "0" } ], - "lore_loc": "从德瓦克的监狱中逃离后,奈克斯就靠着吞噬那些挡路的倒霉蛋的血肉维持生命。" + "lore_loc": "从德瓦克的监狱中逃离后,奈克斯就靠着吞噬那些挡路的倒霉蛋的血肉维持生命。", + "tags": [] }, { "key": "life_stealer_ghoul_frenzy", @@ -15495,7 +15928,8 @@ "value": "0" } ], - "lore_loc": "对邪恶议会的遥远记忆让一个意识开始策划,而另一个则是马上实施暗黑的手段。" + "lore_loc": "对邪恶议会的遥远记忆让一个意识开始策划,而另一个则是马上实施暗黑的手段。", + "tags": [] }, { "key": "life_stealer_infest", @@ -15562,7 +15996,8 @@ "value": "0" } ], - "lore_loc": "奈克斯通过寄生吞噬宿主的生命之血,使他的不洁之力恢复如初。" + "lore_loc": "奈克斯通过寄生吞噬宿主的生命之血,使他的不洁之力恢复如初。", + "tags": [] } ], "talents": [ @@ -15663,7 +16098,8 @@ "value": "70 / 130 / 190 / 250" } ], - "lore_loc": "依什卡菲尔能把重心改为他选定的地点。" + "lore_loc": "依什卡菲尔能把重心改为他选定的地点。", + "tags": [] }, { "key": "dark_seer_ion_shell", @@ -15702,7 +16138,8 @@ "value": "20 / 22 / 24 / 26" } ], - "lore_loc": "黑暗贤者打通“幻墙之末”,让其中的光棱力量渗透出来。" + "lore_loc": "黑暗贤者打通“幻墙之末”,让其中的光棱力量渗透出来。", + "tags": [] }, { "key": "dark_seer_surge", @@ -15733,7 +16170,8 @@ "value": "3 / 4 / 5 / 6" } ], - "lore_loc": "依什卡菲尔曾经用他迅捷的神智走出幻墙之间的迷宫。" + "lore_loc": "依什卡菲尔曾经用他迅捷的神智走出幻墙之间的迷宫。", + "tags": [] }, { "key": "dark_seer_normal_punch", @@ -15776,7 +16214,10 @@ "value": "400" } ], - "lore_loc": "早在依什卡菲尔成为族人的领袖之前,他身为年轻的士兵在战友中就被称为拳击手。" + "lore_loc": "早在依什卡菲尔成为族人的领袖之前,他身为年轻的士兵在战友中就被称为拳击手。", + "tags": [ + "stun" + ] }, { "key": "dark_seer_aggrandize", @@ -15811,7 +16252,8 @@ "value": "8.5%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "dark_seer_wall_of_replica", @@ -15862,7 +16304,8 @@ "value": "25 / 40 / 55" } ], - "lore_loc": "尽管黑暗贤者无法重返原来的国度,但他仍然能运用强大的心智力量召唤出幻墙的一部分,在敌阵中散播混乱。" + "lore_loc": "尽管黑暗贤者无法重返原来的国度,但他仍然能运用强大的心智力量召唤出幻墙的一部分,在敌阵中散播混乱。", + "tags": [] } ], "talents": [ @@ -15967,7 +16410,8 @@ "value": "200" } ], - "lore_loc": "克林克兹感到最接近平静的时刻,就是知道这一幕是莫拉格斯临终之所见。" + "lore_loc": "克林克兹感到最接近平静的时刻,就是知道这一幕是莫拉格斯临终之所见。", + "tags": [] }, { "key": "clinkz_searing_arrows", @@ -15998,7 +16442,8 @@ "value": "20 / 35 / 50 / 65" } ], - "lore_loc": "克林克兹用他的灼热精华点燃弓箭,造成毁灭性打击。" + "lore_loc": "克林克兹用他的灼热精华点燃弓箭,造成毁灭性打击。", + "tags": [] }, { "key": "clinkz_death_pact", @@ -16041,7 +16486,8 @@ "value": "40" } ], - "lore_loc": "萨瑟雷克斯的死亡契约已经成了克林克兹的一部分,他通过杀死那些低等生物来使自己的枯骨获得新生。" + "lore_loc": "萨瑟雷克斯的死亡契约已经成了克林克兹的一部分,他通过杀死那些低等生物来使自己的枯骨获得新生。", + "tags": [] }, { "key": "clinkz_burning_barrage", @@ -16084,7 +16530,8 @@ "value": "2" } ], - "lore_loc": "其他人很难分辨骷髅弓箭手身边缭绕的劲风到底是来自他心中的火焰还是他出箭的速度。" + "lore_loc": "其他人很难分辨骷髅弓箭手身边缭绕的劲风到底是来自他心中的火焰还是他出箭的速度。", + "tags": [] }, { "key": "clinkz_burning_army", @@ -16123,7 +16570,8 @@ "value": "0.1" } ], - "lore_loc": "克林克兹存在于生与死的边缘,他能自如地在这两者之间架起短暂的桥梁。" + "lore_loc": "克林克兹存在于生与死的边缘,他能自如地在这两者之间架起短暂的桥梁。", + "tags": [] }, { "key": "clinkz_wind_walk", @@ -16178,7 +16626,10 @@ "value": "20%" } ], - "lore_loc": "随着火焰的爆裂和烟雾的升起,骷髅弓箭手的身形遁于无形。" + "lore_loc": "随着火焰的爆裂和烟雾的升起,骷髅弓箭手的身形遁于无形。", + "tags": [ + "invis" + ] }, { "key": "clinkz_infernal_shred", @@ -16204,7 +16655,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -16305,7 +16757,8 @@ "value": "260" } ], - "lore_loc": "全知的牧师们善于清除己方战士们身心中的污杂。" + "lore_loc": "全知的牧师们善于清除己方战士们身心中的污杂。", + "tags": [] }, { "key": "omniknight_martyr", @@ -16344,7 +16797,8 @@ "value": "60%" } ], - "lore_loc": "全知之主对那些被普利斯特称为“朋友”的人降下保护结界。" + "lore_loc": "全知之主对那些被普利斯特称为“朋友”的人降下保护结界。", + "tags": [] }, { "key": "omniknight_hammer_of_purity", @@ -16391,7 +16845,8 @@ "value": "75%" } ], - "lore_loc": "骑士仅仅是让锤子动起来的一种手段。" + "lore_loc": "骑士仅仅是让锤子动起来的一种手段。", + "tags": [] }, { "key": "omniknight_degen_aura", @@ -16426,7 +16881,8 @@ "value": "325" } ], - "lore_loc": "作为神圣的化身,普利斯特·雷霆之怒足以削弱那些不忠之徒。" + "lore_loc": "作为神圣的化身,普利斯特·雷霆之怒足以削弱那些不忠之徒。", + "tags": [] }, { "key": "omniknight_guardian_angel", @@ -16465,7 +16921,8 @@ "value": "0%" } ], - "lore_loc": "全知之主对那些被普利斯特称为“朋友”的人降下保护结界。" + "lore_loc": "全知之主对那些被普利斯特称为“朋友”的人降下保护结界。", + "tags": [] } ], "talents": [ @@ -16562,7 +17019,8 @@ "value": "5 / 10 / 15 / 20%" } ], - "lore_loc": "虽然爱由莎更喜欢和平的处事方式,但她还是会用远距离的魔法突击来解决一些问题,她扔出的长矛会从掠过的空气中获得能量。" + "lore_loc": "虽然爱由莎更喜欢和平的处事方式,但她还是会用远距离的魔法突击来解决一些问题,她扔出的长矛会从掠过的空气中获得能量。", + "tags": [] }, { "key": "enchantress_enchant", @@ -16613,7 +17071,10 @@ "value": "1" } ], - "lore_loc": "作为森林真正的管家,无论是卑贱的巨魔还是强大的半人马,爱由莎都会和他们成为朋友。" + "lore_loc": "作为森林真正的管家,无论是卑贱的巨魔还是强大的半人马,爱由莎都会和他们成为朋友。", + "tags": [ + "basic_dispel" + ] }, { "key": "enchantress_natures_attendants", @@ -16660,7 +17121,8 @@ "value": "0" } ], - "lore_loc": "古怪的森林小精灵拥有的疗伤之力虽然微弱,但可以用数量弥补。" + "lore_loc": "古怪的森林小精灵拥有的疗伤之力虽然微弱,但可以用数量弥补。", + "tags": [] }, { "key": "enchantress_bunny_hop", @@ -16699,7 +17161,8 @@ "value": "100" } ], - "lore_loc": "跃动就是活跃灵动。" + "lore_loc": "跃动就是活跃灵动。", + "tags": [] }, { "key": "enchantress_little_friends", @@ -16746,7 +17209,10 @@ "value": "0.5" } ], - "lore_loc": "森林的法则可能有点模糊,但违反法则的惩罚会来得非常迅速,而且迷人。" + "lore_loc": "森林的法则可能有点模糊,但违反法则的惩罚会来得非常迅速,而且迷人。", + "tags": [ + "root" + ] }, { "key": "enchantress_untouchable", @@ -16777,7 +17243,8 @@ "value": "-110 / -160 / -210" } ], - "lore_loc": "在攻击爱由莎之后,大多数敌人都会由于攻击了这样一个天然的美人而不知所措。" + "lore_loc": "在攻击爱由莎之后,大多数敌人都会由于攻击了这样一个天然的美人而不知所措。", + "tags": [] }, { "key": "enchantress_rabblerouser", @@ -16808,7 +17275,8 @@ "value": "0%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -16921,7 +17389,11 @@ "value": "400" } ], - "lore_loc": "就连哈斯卡自己的肉体都容纳不了他不断膨胀的生命力。" + "lore_loc": "就连哈斯卡自己的肉体都容纳不了他不断膨胀的生命力。", + "tags": [ + "disarm", + "silence" + ] }, { "key": "huskar_burning_spear", @@ -16964,7 +17436,8 @@ "value": "9" } ], - "lore_loc": "神灵武士的武器经过自身鲜血的沾染,燃起了烈火——敌人将领受远超己痛的苦楚。" + "lore_loc": "神灵武士的武器经过自身鲜血的沾染,燃起了烈火——敌人将领受远超己痛的苦楚。", + "tags": [] }, { "key": "huskar_berserkers_blood", @@ -17023,7 +17496,10 @@ "value": "0%" } ], - "lore_loc": "从虚无之境逃出后,哈斯卡的力量会随着血液的损失而增强,使他能够造成致命的物理性破坏。" + "lore_loc": "从虚无之境逃出后,哈斯卡的力量会随着血液的损失而增强,使他能够造成致命的物理性破坏。", + "tags": [ + "basic_dispel" + ] }, { "key": "huskar_blood_magic", @@ -17049,7 +17525,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "huskar_life_break", @@ -17104,7 +17581,8 @@ "value": "0" } ], - "lore_loc": "哈斯卡不顾危险,冲入只有他能幸存的激烈近身战。" + "lore_loc": "哈斯卡不顾危险,冲入只有他能幸存的激烈近身战。", + "tags": [] } ], "talents": [ @@ -17225,7 +17703,10 @@ "value": "0" } ], - "lore_loc": "巴拉那制造一个无限之夜的漩涡,将敌人猛烈地撕扯进永恒的黑暗。" + "lore_loc": "巴拉那制造一个无限之夜的漩涡,将敌人猛烈地撕扯进永恒的黑暗。", + "tags": [ + "stun" + ] }, { "key": "night_stalker_crippling_fear", @@ -17268,7 +17749,10 @@ "value": "25 / 30 / 35 / 40" } ], - "lore_loc": "对于那些不幸挡住他前路的可怜虫,巴拉那扭曲的喉咙在他们的脑海里挥之不去。" + "lore_loc": "对于那些不幸挡住他前路的可怜虫,巴拉那扭曲的喉咙在他们的脑海里挥之不去。", + "tags": [ + "silence" + ] }, { "key": "night_stalker_midnight_feast", @@ -17307,7 +17791,8 @@ "value": "10 / 12 / 14 / 16%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "night_stalker_hunter_in_the_night", @@ -17342,7 +17827,8 @@ "value": "38" } ], - "lore_loc": "巴拉那的狩猎技术随着夜晚降临大幅精进。" + "lore_loc": "巴拉那的狩猎技术随着夜晚降临大幅精进。", + "tags": [] }, { "key": "night_stalker_darkness", @@ -17377,7 +17863,8 @@ "value": "50 / 100 / 150" } ], - "lore_loc": "最强大的战士都变得害怕黑暗,真是个令人感到羞耻的场景。" + "lore_loc": "最强大的战士都变得害怕黑暗,真是个令人感到羞耻的场景。", + "tags": [] } ], "talents": [ @@ -17486,7 +17973,8 @@ "value": "1200" } ], - "lore_loc": "虽然大多数猎物都被她用丝茧包裹,作为饲料留给后代,但育母蜘蛛对那些迷路的英雄情有独钟。" + "lore_loc": "虽然大多数猎物都被她用丝茧包裹,作为饲料留给后代,但育母蜘蛛对那些迷路的英雄情有独钟。", + "tags": [] }, { "key": "broodmother_spin_web", @@ -17529,7 +18017,8 @@ "value": "0 / 0 / 0 / 0%" } ], - "lore_loc": "编制出一张蛛丝之床,艾拉齐娜的网保护她和她的后代,同时给予前进的敌人警告。" + "lore_loc": "编制出一张蛛丝之床,艾拉齐娜的网保护她和她的后代,同时给予前进的敌人警告。", + "tags": [] }, { "key": "broodmother_incapacitating_bite", @@ -17572,7 +18061,8 @@ "value": "3 / 6 / 9 / 12" } ], - "lore_loc": "布蕾克·艾拉齐娜的尖牙中布满麻痹毒素,对侵入蛛网的敌人造成缓慢而痛苦的死亡。" + "lore_loc": "布蕾克·艾拉齐娜的尖牙中布满麻痹毒素,对侵入蛛网的敌人造成缓慢而痛苦的死亡。", + "tags": [] }, { "key": "broodmother_sticky_snare", @@ -17615,7 +18105,10 @@ "value": "900" } ], - "lore_loc": "蛛网上细微的颤动就是艾拉齐娜找到猎物所需要的一切。" + "lore_loc": "蛛网上细微的颤动就是艾拉齐娜找到猎物所需要的一切。", + "tags": [ + "root" + ] }, { "key": "broodmother_spiders_milk", @@ -17646,7 +18139,8 @@ "value": "1.9%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "broodmother_spawn_spiderlings", @@ -17693,7 +18187,8 @@ "value": "25 / 35 / 45%" } ], - "lore_loc": "布蕾克·艾拉齐娜不断抚育她的后代,即使是在战场上。新孵化的小蜘蛛很快就学会如何支援她们的母亲。" + "lore_loc": "布蕾克·艾拉齐娜不断抚育她的后代,即使是在战场上。新孵化的小蜘蛛很快就学会如何支援她们的母亲。", + "tags": [] } ], "talents": [ @@ -17802,7 +18297,8 @@ "value": "375 / 425 / 475 / 525" } ], - "lore_loc": "尽管飞镖很小,但刚铎精确的瞄准可以带来致命伤害。" + "lore_loc": "尽管飞镖很小,但刚铎精确的瞄准可以带来致命伤害。", + "tags": [] }, { "key": "bounty_hunter_jinada", @@ -17837,7 +18333,8 @@ "value": "15 / 22 / 29 / 36" } ], - "lore_loc": "刚铎庞杂的经验能确保他在交易后不会空手而归。" + "lore_loc": "刚铎庞杂的经验能确保他在交易后不会空手而归。", + "tags": [] }, { "key": "bounty_hunter_wind_walk", @@ -17880,7 +18377,11 @@ "value": "11 / 14 / 17 / 20%" } ], - "lore_loc": "当回忆起暴君伽夫被刺杀时的那个宫廷小丑,人们脑海中只能想起那舞动的暗影。" + "lore_loc": "当回忆起暴君伽夫被刺杀时的那个宫廷小丑,人们脑海中只能想起那舞动的暗影。", + "tags": [ + "stun", + "invis" + ] }, { "key": "bounty_hunter_wind_walk_ally", @@ -17906,7 +18407,10 @@ "cooldown": "15", "mana_cost": "50", "specials": [], - "lore_loc": "在极少数情况下,当悬赏对象需要活捉的时候,这就派上了用场。" + "lore_loc": "在极少数情况下,当悬赏对象需要活捉的时候,这就派上了用场。", + "tags": [ + "invis" + ] }, { "key": "bounty_hunter_big_game_hunter", @@ -17932,7 +18436,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "bounty_hunter_track", @@ -17975,7 +18480,8 @@ "value": "25" } ], - "lore_loc": "利用他高度敏锐的视觉和嗅觉,刚铎的袭击成功率很高。" + "lore_loc": "利用他高度敏锐的视觉和嗅觉,刚铎的袭击成功率很高。", + "tags": [] } ], "talents": [ @@ -18092,7 +18598,8 @@ "value": "6 / 7 / 8 / 9" } ], - "lore_loc": "斯吉茨格尔打开时空之纱的缺口,让编织者幼虫穿越而出,帮助他战斗。" + "lore_loc": "斯吉茨格尔打开时空之纱的缺口,让编织者幼虫穿越而出,帮助他战斗。", + "tags": [] }, { "key": "weaver_shukuchi", @@ -18139,7 +18646,10 @@ "value": "550" } ], - "lore_loc": "编织者曾为创世之纱工作,小小的虫洞让他们可以穿越时间来更好的发挥他们的手艺。" + "lore_loc": "编织者曾为创世之纱工作,小小的虫洞让他们可以穿越时间来更好的发挥他们的手艺。", + "tags": [ + "invis" + ] }, { "key": "weaver_geminate_attack", @@ -18170,7 +18680,8 @@ "value": "20 / 35 / 50 / 65" } ], - "lore_loc": "斯吉茨格尔与时间有着多变的联系,使他的动作被看到和感受到,不止一次。" + "lore_loc": "斯吉茨格尔与时间有着多变的联系,使他的动作被看到和感受到,不止一次。", + "tags": [] }, { "key": "weaver_time_lapse", @@ -18196,7 +18707,8 @@ "cooldown": "70 / 55 / 40", "mana_cost": "150 / 75 / 0", "specials": [], - "lore_loc": "如果斯吉茨格尔认为世界当前的现实与他所渴望的不合,他就爬回过去的时间点来纠正错误。" + "lore_loc": "如果斯吉茨格尔认为世界当前的现实与他所渴望的不合,他就爬回过去的时间点来纠正错误。", + "tags": [] }, { "key": "weaver_threads_of_fate", @@ -18243,7 +18755,8 @@ "value": "100%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -18360,7 +18873,8 @@ "value": "5" } ], - "lore_loc": "灼霜龙族的各个物种分支都拥有极端危险的气息;这些气息相结合后场面难以想象。" + "lore_loc": "灼霜龙族的各个物种分支都拥有极端危险的气息;这些气息相结合后场面难以想象。", + "tags": [] }, { "key": "jakiro_ice_path", @@ -18407,7 +18921,10 @@ "value": "50" } ], - "lore_loc": "杰奇洛狡猾的右首使出它的冰封天赋,用骇人的酷寒冻结大地。" + "lore_loc": "杰奇洛狡猾的右首使出它的冰封天赋,用骇人的酷寒冻结大地。", + "tags": [ + "stun" + ] }, { "key": "jakiro_liquid_fire", @@ -18454,7 +18971,8 @@ "value": "5" } ], - "lore_loc": "杰奇洛的左喉大开,使前方生灵涂炭。" + "lore_loc": "杰奇洛的左喉大开,使前方生灵涂炭。", + "tags": [] }, { "key": "jakiro_liquid_ice", @@ -18497,7 +19015,8 @@ "value": "5" } ], - "lore_loc": "杰奇洛的右喉大开,用严霜惩治一切。" + "lore_loc": "杰奇洛的右喉大开,用严霜惩治一切。", + "tags": [] }, { "key": "jakiro_double_trouble", @@ -18528,7 +19047,8 @@ "value": "51%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "jakiro_macropyre", @@ -18567,7 +19087,8 @@ "value": "10" } ], - "lore_loc": "冰与火融合而来的极端温度将使战场百孔千疮。" + "lore_loc": "冰与火融合而来的极端温度将使战场百孔千疮。", + "tags": [] } ], "talents": [ @@ -18692,7 +19213,8 @@ "value": "0%" } ], - "lore_loc": "蝙蝠骑士给他的对手泼燃油时,经常能听见他在喋喋不休。" + "lore_loc": "蝙蝠骑士给他的对手泼燃油时,经常能听见他在喋喋不休。", + "tags": [] }, { "key": "batrider_flamebreak", @@ -18743,7 +19265,8 @@ "value": "6 / 12 / 18 / 24%" } ], - "lore_loc": "蝙蝠骑士选择用燃烧弹作为武器。" + "lore_loc": "蝙蝠骑士选择用燃烧弹作为武器。", + "tags": [] }, { "key": "batrider_firefly", @@ -18786,7 +19309,8 @@ "value": "12 / 18 / 24 / 30%" } ], - "lore_loc": "当敌人逃进荒狱丛林时,大部分人都不得不先砍开挡道的树丛。而蝙蝠骑士却选择直接将他的敌人,连同丛林一起破坏殆尽。" + "lore_loc": "当敌人逃进荒狱丛林时,大部分人都不得不先砍开挡道的树丛。而蝙蝠骑士却选择直接将他的敌人,连同丛林一起破坏殆尽。", + "tags": [] }, { "key": "batrider_smoldering_resin", @@ -18812,7 +19336,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "batrider_flaming_lasso", @@ -18847,7 +19372,8 @@ "value": "125 / 250 / 375" } ], - "lore_loc": "就算蝙蝠骑士说免费,也几乎没人敢去骑他的蝙蝠。" + "lore_loc": "就算蝙蝠骑士说免费,也几乎没人敢去骑他的蝙蝠。", + "tags": [] } ], "talents": [ @@ -18956,7 +19482,8 @@ "value": "50 / 100 / 150 / 200" } ], - "lore_loc": "尽管陈控制动物的能力还没有强到可以控制敌方英雄,不过还是可以用来考验他们的战斗意志。" + "lore_loc": "尽管陈控制动物的能力还没有强到可以控制敌方英雄,不过还是可以用来考验他们的战斗意志。", + "tags": [] }, { "key": "chen_holy_persuasion", @@ -19007,7 +19534,8 @@ "value": "5 / 10 / 15 / 20%" } ], - "lore_loc": "尽管他们可能当不了骑士,但陈还是将野兽们纳入教会,就像他自己当年被改变信仰一样。" + "lore_loc": "尽管他们可能当不了骑士,但陈还是将野兽们纳入教会,就像他自己当年被改变信仰一样。", + "tags": [] }, { "key": "chen_divine_favor", @@ -19050,7 +19578,8 @@ "value": "5 / 10 / 15 / 20%" } ], - "lore_loc": "陈的狂热极具感染力,他的盟友得到了生命力的祝福,还被迫在他身旁战斗。" + "lore_loc": "陈的狂热极具感染力,他的盟友得到了生命力的祝福,还被迫在他身旁战斗。", + "tags": [] }, { "key": "chen_zealot", @@ -19081,7 +19610,8 @@ "value": "0" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "chen_hand_of_god", @@ -19124,7 +19654,8 @@ "value": "0" } ], - "lore_loc": "陈利用他的奴隶与他之间的精神连接,给拥有其狂热的盟军带来康复和幸福。" + "lore_loc": "陈利用他的奴隶与他之间的精神连接,给拥有其狂热的盟军带来康复和幸福。", + "tags": [] } ], "talents": [ @@ -19229,7 +19760,8 @@ "value": "7" } ], - "lore_loc": "墨丘利的匕首将物质位面遮蔽于暗影中,凡人在其中畏缩不前,但幽鬼们相当强势。" + "lore_loc": "墨丘利的匕首将物质位面遮蔽于暗影中,凡人在其中畏缩不前,但幽鬼们相当强势。", + "tags": [] }, { "key": "spectre_shadow_step", @@ -19268,7 +19800,8 @@ "value": "35 / 40 / 45 / 50%" } ], - "lore_loc": "在强大的专注力下,墨丘利有时可以限制自己分裂的限度。" + "lore_loc": "在强大的专注力下,墨丘利有时可以限制自己分裂的限度。", + "tags": [] }, { "key": "spectre_dispersion", @@ -19307,7 +19840,8 @@ "value": "700" } ], - "lore_loc": "一个令人却步的任务出现在墨丘利的敌人面前——用刀剑和魔法消灭一个影子。" + "lore_loc": "一个令人却步的任务出现在墨丘利的敌人面前——用刀剑和魔法消灭一个影子。", + "tags": [] }, { "key": "spectre_desolate", @@ -19338,7 +19872,8 @@ "value": "23" } ], - "lore_loc": "战士们常常发现自己落单后还能在身边看到墨丘利——不得不质疑,这是不是真正的幽鬼。" + "lore_loc": "战士们常常发现自己落单后还能在身边看到墨丘利——不得不质疑,这是不是真正的幽鬼。", + "tags": [] }, { "key": "spectre_haunt", @@ -19377,7 +19912,8 @@ "value": "200%" } ], - "lore_loc": "在战斗最焦灼的时候,墨丘利的实体形态破裂,幽影的碎片在生者身边作祟。" + "lore_loc": "在战斗最焦灼的时候,墨丘利的实体形态破裂,幽影的碎片在生者身边作祟。", + "tags": [] } ], "talents": [ @@ -19482,7 +20018,10 @@ "value": "1.8 / 2.2 / 2.6 / 3" } ], - "lore_loc": "卡尔德的存在将他周围的敌人吸入冰封的虚空,甚至能将他们永远关进寒冰的监牢。" + "lore_loc": "卡尔德的存在将他周围的敌人吸入冰封的虚空,甚至能将他们永远关进寒冰的监牢。", + "tags": [ + "stun" + ] }, { "key": "ancient_apparition_ice_vortex", @@ -19529,7 +20068,8 @@ "value": "6 / 8 / 10 / 12" } ], - "lore_loc": "卡尔德恣意带来腐蚀性的冰风,将战场变寒冷。" + "lore_loc": "卡尔德恣意带来腐蚀性的冰风,将战场变寒冷。", + "tags": [] }, { "key": "ancient_apparition_chilling_touch", @@ -19572,7 +20112,8 @@ "value": "60 / 90 / 120 / 150" } ], - "lore_loc": "远古冰魄的永恒知识为他的出击赋予了冰冷的魔咒。" + "lore_loc": "远古冰魄的永恒知识为他的出击赋予了冰冷的魔咒。", + "tags": [] }, { "key": "ancient_apparition_bone_chill", @@ -19611,7 +20152,8 @@ "value": "2" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "ancient_apparition_ice_blast", @@ -19654,7 +20196,10 @@ "value": "12 / 24 / 36" } ], - "lore_loc": "卡尔德的寒霜之体中流出远古的寒冰风暴,将这个世界的居民冻成他永恒力量的纪念碑。" + "lore_loc": "卡尔德的寒霜之体中流出远古的寒冰风暴,将这个世界的居民冻成他永恒力量的纪念碑。", + "tags": [ + "stun" + ] } ], "talents": [ @@ -19759,7 +20304,8 @@ "value": "0" } ], - "lore_loc": "路西法的食欲和贪婪永远不会得到满足。" + "lore_loc": "路西法的食欲和贪婪永远不会得到满足。", + "tags": [] }, { "key": "doom_bringer_scorched_earth", @@ -19806,7 +20352,8 @@ "value": "10 / 12 / 14 / 16" } ], - "lore_loc": "堕落者在他所及之处散布毁灭,支撑他的火焰不会宽恕任何人。" + "lore_loc": "堕落者在他所及之处散布毁灭,支撑他的火焰不会宽恕任何人。", + "tags": [] }, { "key": "doom_bringer_infernal_blade", @@ -19849,7 +20396,8 @@ "value": "0.66" } ], - "lore_loc": "路西法很乐意与他人分享自己被放逐时身受的火焰烙印。" + "lore_loc": "路西法很乐意与他人分享自己被放逐时身受的火焰烙印。", + "tags": [] }, { "key": "doom_bringer_doom", @@ -19884,7 +20432,12 @@ "value": "22 / 44 / 66" } ], - "lore_loc": "每当佤什昂都的丧钟传出一个名字,这个名字的主人就必将迎来末日。" + "lore_loc": "每当佤什昂都的丧钟传出一个名字,这个名字的主人就必将迎来末日。", + "tags": [ + "basic_dispel", + "mute", + "break" + ] }, { "key": "doom_bringer_lvl_pain", @@ -19910,7 +20463,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -20011,7 +20565,8 @@ "value": "-22 / -28 / -34 / -40%" } ], - "lore_loc": "雄性熊怪有力的步伐撼动着大地,展示了他们坚定的战士之心。" + "lore_loc": "雄性熊怪有力的步伐撼动着大地,展示了他们坚定的战士之心。", + "tags": [] }, { "key": "ursa_overpower", @@ -20054,7 +20609,8 @@ "value": "20" } ], - "lore_loc": "乌尔萨有着让人难以和他巨大的体型联想到一起的敏捷。" + "lore_loc": "乌尔萨有着让人难以和他巨大的体型联想到一起的敏捷。", + "tags": [] }, { "key": "ursa_fury_swipes", @@ -20093,7 +20649,8 @@ "value": "12 / 20 / 28 / 36" } ], - "lore_loc": "在大自然中,很少有受伤的动物能在饥饿的捕食者攻击下幸存。" + "lore_loc": "在大自然中,很少有受伤的动物能在饥饿的捕食者攻击下幸存。", + "tags": [] }, { "key": "ursa_maul", @@ -20124,7 +20681,8 @@ "value": "1.25%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "ursa_enrage", @@ -20175,7 +20733,8 @@ "value": "60 / 45 / 30" } ], - "lore_loc": "乌尔萨放纵他的远古之魂,成为战场上最凶残的生物。" + "lore_loc": "乌尔萨放纵他的远古之魂,成为战场上最凶残的生物。", + "tags": [] } ], "talents": [ @@ -20276,7 +20835,8 @@ "value": "1.3 / 1.6 / 1.9 / 2.2" } ], - "lore_loc": "巴拉森带着一股狂蛮之力从黑暗中冲出。" + "lore_loc": "巴拉森带着一股狂蛮之力从黑暗中冲出。", + "tags": [] }, { "key": "spirit_breaker_bulldoze", @@ -20315,7 +20875,8 @@ "value": "8" } ], - "lore_loc": "巴拉森扭曲了他周遭现实的结构,减轻他的肉体形态受到的影响。" + "lore_loc": "巴拉森扭曲了他周遭现实的结构,减轻他的肉体形态受到的影响。", + "tags": [] }, { "key": "spirit_breaker_greater_bash", @@ -20362,7 +20923,10 @@ "value": "143 / 152 / 158 / 162" } ], - "lore_loc": "巴拉森幽灵般链球的标志性一击。" + "lore_loc": "巴拉森幽灵般链球的标志性一击。", + "tags": [ + "stun" + ] }, { "key": "spirit_breaker_planar_pocket", @@ -20397,7 +20961,8 @@ "value": "40%" } ], - "lore_loc": "巴拉森顽强的专注力形成了位面汇聚点的空洞,就连最强大的奥术能量也无法逃逸。" + "lore_loc": "巴拉森顽强的专注力形成了位面汇聚点的空洞,就连最强大的奥术能量也无法逃逸。", + "tags": [] }, { "key": "spirit_breaker_bull_rush", @@ -20440,7 +21005,8 @@ "value": "2%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "spirit_breaker_nether_strike", @@ -20479,7 +21045,8 @@ "value": "0" } ], - "lore_loc": "巴拉森暂时回到他原来所在的元素领域,然后带回来自两个世界的回击。" + "lore_loc": "巴拉森暂时回到他原来所在的元素领域,然后带回来自两个世界的回击。", + "tags": [] } ], "talents": [ @@ -20584,7 +21151,8 @@ "value": "8 / 14 / 20 / 26" } ], - "lore_loc": "奥雷尔的新飞行器有更大的火箭载弹量,增强了速射能力。" + "lore_loc": "奥雷尔的新飞行器有更大的火箭载弹量,增强了速射能力。", + "tags": [] }, { "key": "gyrocopter_homing_missile", @@ -20627,7 +21195,10 @@ "value": "90 / 180 / 270 / 360" } ], - "lore_loc": "奥雷尔的飞行器上所能装载的最大的导弹,为了实现最佳效果,他对空气动力学和爆炸物进行精心计算才得到了炸蛋™。" + "lore_loc": "奥雷尔的飞行器上所能装载的最大的导弹,为了实现最佳效果,他对空气动力学和爆炸物进行精心计算才得到了炸蛋™。", + "tags": [ + "stun" + ] }, { "key": "gyrocopter_flak_cannon", @@ -20662,7 +21233,8 @@ "value": "4 / 5 / 6 / 7" } ], - "lore_loc": "这架新近改造的旋翼机装备了足够的弹药,可以攻击360度全范围内的敌人。" + "lore_loc": "这架新近改造的旋翼机装备了足够的弹药,可以攻击360度全范围内的敌人。", + "tags": [] }, { "key": "gyrocopter_afterburner", @@ -20701,7 +21273,8 @@ "value": "0.5" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "gyrocopter_side_gunner_spawn_ability", @@ -20727,7 +21300,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "gyrocopter_call_down", @@ -20774,7 +21348,8 @@ "value": "1" } ], - "lore_loc": "“开始轰炸!”" + "lore_loc": "“开始轰炸!”", + "tags": [] } ], "talents": [ @@ -20883,7 +21458,8 @@ "value": "3 / 4 / 5 / 6" } ], - "lore_loc": "用黑酿家族的祖传化学知识,拉泽尔配出一种能溶解最坚硬金属的强酸。" + "lore_loc": "用黑酿家族的祖传化学知识,拉泽尔配出一种能溶解最坚硬金属的强酸。", + "tags": [] }, { "key": "alchemist_unstable_concoction", @@ -20926,7 +21502,10 @@ "value": "4 / 8 / 12 / 16" } ], - "lore_loc": "将整座山转变成黄金的实验失败后带来的副产品,这种易挥发的药剂有着破坏的潜力。" + "lore_loc": "将整座山转变成黄金的实验失败后带来的副产品,这种易挥发的药剂有着破坏的潜力。", + "tags": [ + "stun" + ] }, { "key": "alchemist_corrosive_weaponry", @@ -20969,7 +21548,8 @@ "value": "2.5 / 3 / 3.5 / 4%" } ], - "lore_loc": "一种强大的溶剂,事实证明效果实在是太好了。" + "lore_loc": "一种强大的溶剂,事实证明效果实在是太好了。", + "tags": [] }, { "key": "alchemist_berserk_potion", @@ -21012,7 +21592,10 @@ "value": "30" } ], - "lore_loc": "拉泽尔的曾祖父在他自己身上试验了这种药水的早期版本,之后一名幸存的家族成员马上看到了潜在的商机。" + "lore_loc": "拉泽尔的曾祖父在他自己身上试验了这种药水的早期版本,之后一名幸存的家族成员马上看到了潜在的商机。", + "tags": [ + "basic_dispel" + ] }, { "key": "alchemist_goblins_greed", @@ -21059,7 +21642,8 @@ "value": "16" } ], - "lore_loc": "尽管对付不了一座山,但拉泽尔还是掌握着技术,能转化一些较小的化合物来填满他的荷包。" + "lore_loc": "尽管对付不了一座山,但拉泽尔还是掌握着技术,能转化一些较小的化合物来填满他的荷包。", + "tags": [] }, { "key": "alchemist_chemical_rage", @@ -21102,7 +21686,8 @@ "value": "20 / 30 / 40" } ], - "lore_loc": "拉泽尔在越狱时给食人魔喝的酿造物成了激战中的有效药剂。" + "lore_loc": "拉泽尔在越狱时给食人魔喝的酿造物成了激战中的有效药剂。", + "tags": [] } ], "talents": [ @@ -21207,7 +21792,8 @@ "value": "1" } ], - "lore_loc": "刺骨的寒意。" + "lore_loc": "刺骨的寒意。", + "tags": [] }, { "key": "invoker_wex", @@ -21250,7 +21836,8 @@ "value": "1" } ], - "lore_loc": "凛冽的暴风。" + "lore_loc": "凛冽的暴风。", + "tags": [] }, { "key": "invoker_exort", @@ -21289,7 +21876,8 @@ "value": "1" } ], - "lore_loc": "严酷的炽焰。" + "lore_loc": "严酷的炽焰。", + "tags": [] }, { "key": "invoker_invoke", @@ -21320,7 +21908,8 @@ "value": "2" } ], - "lore_loc": "知识的新纪元就此开启。" + "lore_loc": "知识的新纪元就此开启。", + "tags": [] }, { "key": "invoker_cold_snap", @@ -21363,7 +21952,8 @@ "value": "28 / 36 / 44 / 52 / 60 / 68 / 76 / 84 / 92" } ], - "lore_loc": "塞卓昂的无尽战栗。" + "lore_loc": "塞卓昂的无尽战栗。", + "tags": [] }, { "key": "invoker_ghost_walk", @@ -21414,7 +22004,10 @@ "value": "2 / 4 / 6 / 8 / 10 / 12 / 14 / 16 / 18" } ], - "lore_loc": "米瑞特之阻碍。" + "lore_loc": "米瑞特之阻碍。", + "tags": [ + "invis" + ] }, { "key": "invoker_tornado", @@ -21469,7 +22062,10 @@ "value": "0" } ], - "lore_loc": "托纳鲁斯之爪。" + "lore_loc": "托纳鲁斯之爪。", + "tags": [ + "cyclone" + ] }, { "key": "invoker_emp", @@ -21516,7 +22112,8 @@ "value": "0" } ], - "lore_loc": "恩多利昂的恶之混动。" + "lore_loc": "恩多利昂的恶之混动。", + "tags": [] }, { "key": "invoker_alacrity", @@ -21555,7 +22152,8 @@ "value": "9" } ], - "lore_loc": "盖斯特的猛战号令。" + "lore_loc": "盖斯特的猛战号令。", + "tags": [] }, { "key": "invoker_chaos_meteor", @@ -21610,7 +22208,8 @@ "value": "10 / 15 / 20 / 25 / 30 / 35 / 40 / 45 / 50" } ], - "lore_loc": "塔拉克的天坠之火。" + "lore_loc": "塔拉克的天坠之火。", + "tags": [] }, { "key": "invoker_sun_strike", @@ -21649,7 +22248,8 @@ "value": "175 / 225 / 275 / 325 / 375 / 425 / 475 / 525 / 575" } ], - "lore_loc": "哈雷克之火葬魔咒。" + "lore_loc": "哈雷克之火葬魔咒。", + "tags": [] }, { "key": "invoker_forge_spirit", @@ -21704,7 +22304,8 @@ "value": "1 / 1 / 1 / 2 / 2 / 2 / 2 / 3 / 3" } ], - "lore_loc": "卡尔维因的至邪造物。" + "lore_loc": "卡尔维因的至邪造物。", + "tags": [] }, { "key": "invoker_ice_wall", @@ -21763,7 +22364,10 @@ "value": "0" } ], - "lore_loc": "科瑞克斯的杀戮之墙。" + "lore_loc": "科瑞克斯的杀戮之墙。", + "tags": [ + "root" + ] }, { "key": "invoker_deafening_blast", @@ -21806,7 +22410,8 @@ "value": "1.5 / 2 / 2.5 / 3 / 3.5 / 4 / 4.5 / 5 / 5.5" } ], - "lore_loc": "布鲁冯特之无力声波。" + "lore_loc": "布鲁冯特之无力声波。", + "tags": [] } ], "talents": [ @@ -21923,7 +22528,8 @@ "value": "2" } ], - "lore_loc": "无法使用咒语对诺崇来说不是什么问题,但对他的敌人来说,这可是个大问题。" + "lore_loc": "无法使用咒语对诺崇来说不是什么问题,但对他的敌人来说,这可是个大问题。", + "tags": [] }, { "key": "silencer_glaives_of_wisdom", @@ -21962,7 +22568,8 @@ "value": "10 / 20 / 30 / 40" } ], - "lore_loc": "尽管无法使用传统咒语,但诺崇的风蚀之寒血统赋予他一种诡异的智慧,在肉搏中助他一臂之力。" + "lore_loc": "尽管无法使用传统咒语,但诺崇的风蚀之寒血统赋予他一种诡异的智慧,在肉搏中助他一臂之力。", + "tags": [] }, { "key": "silencer_last_word", @@ -22005,7 +22612,10 @@ "value": "3 / 4 / 5 / 6" } ], - "lore_loc": "诺崇的敌人念出的咒语往往将成为他们的遗言。" + "lore_loc": "诺崇的敌人念出的咒语往往将成为他们的遗言。", + "tags": [ + "silence" + ] }, { "key": "silencer_brain_drain", @@ -22036,7 +22646,8 @@ "value": "5%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "silencer_global_silence", @@ -22075,7 +22686,8 @@ "value": "4.5 / 5.25 / 6" } ], - "lore_loc": "随着大地一震,所有的魔法和声音都停止了,诺崇实现了他的预言。" + "lore_loc": "随着大地一震,所有的魔法和声音都停止了,诺崇实现了他的预言。", + "tags": [] } ], "talents": [ @@ -22176,7 +22788,8 @@ "value": "20%" } ], - "lore_loc": "先兆者的殁境学识使他可以洞悉所有精神能量的涨落,并将其融入自身。" + "lore_loc": "先兆者的殁境学识使他可以洞悉所有精神能量的涨落,并将其融入自身。", + "tags": [] }, { "key": "obsidian_destroyer_astral_imprisonment", @@ -22223,7 +22836,10 @@ "value": "0" } ], - "lore_loc": "被关在现世和殁境之间的空间中,受害者认识到自己如孩童般的学识和血肉之躯。" + "lore_loc": "被关在现世和殁境之间的空间中,受害者认识到自己如孩童般的学识和血肉之躯。", + "tags": [ + "cyclone" + ] }, { "key": "obsidian_destroyer_objurgation", @@ -22266,7 +22882,8 @@ "value": "10" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "obsidian_destroyer_equilibrium", @@ -22305,7 +22922,8 @@ "value": "25%" } ], - "lore_loc": "殁境充斥着奥术能量,将穿过先兆者的裂隙进行扩散。" + "lore_loc": "殁境充斥着奥术能量,将穿过先兆者的裂隙进行扩散。", + "tags": [] }, { "key": "obsidian_destroyer_sanity_eclipse", @@ -22352,7 +22970,8 @@ "value": "0%" } ], - "lore_loc": "当一块殁境的水晶破裂之时,灾难性的能量被释放出来,甚至在其他的空间也能感受到这股能量的回响。" + "lore_loc": "当一块殁境的水晶破裂之时,灾难性的能量被释放出来,甚至在其他的空间也能感受到这股能量的回响。", + "tags": [] } ], "talents": [ @@ -22465,7 +23084,8 @@ "value": "2" } ], - "lore_loc": "扭曲他生命本质的魔法还能召唤出他的犬科伙伴。" + "lore_loc": "扭曲他生命本质的魔法还能召唤出他的犬科伙伴。", + "tags": [] }, { "key": "lycan_howl", @@ -22504,7 +23124,8 @@ "value": "4 / 5 / 6 / 7" } ], - "lore_loc": "毛骨悚然的狼嚎让敌人明白贝恩霍勒就在他们之中。" + "lore_loc": "毛骨悚然的狼嚎让敌人明白贝恩霍勒就在他们之中。", + "tags": [] }, { "key": "lycan_feral_impulse", @@ -22539,7 +23160,8 @@ "value": "2 / 4 / 6 / 8" } ], - "lore_loc": "贝恩霍勒与犬类野兽的共生强化了他的反应和狩猎能力。" + "lore_loc": "贝恩霍勒与犬类野兽的共生强化了他的反应和狩猎能力。", + "tags": [] }, { "key": "lycan_wolf_bite", @@ -22565,7 +23187,8 @@ "cooldown": "110 / 100 / 90", "mana_cost": "150", "specials": [], - "lore_loc": "咬的这一口像是被传染了吗?" + "lore_loc": "咬的这一口像是被传染了吗?", + "tags": [] }, { "key": "lycan_apex_predator", @@ -22596,7 +23219,8 @@ "value": "18%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "lycan_shapeshift", @@ -22651,7 +23275,8 @@ "value": "225 / 325 / 425" } ], - "lore_loc": "贝恩霍勒接受了永恒的狼人诅咒,拥抱了他的野性,也永远成为了他狼性的奴仆。" + "lore_loc": "贝恩霍勒接受了永恒的狼人诅咒,拥抱了他的野性,也永远成为了他狼性的奴仆。", + "tags": [] } ], "talents": [ @@ -22764,7 +23389,8 @@ "value": "4 / 4.25 / 4.5 / 4.75" } ], - "lore_loc": "曼吉克斯巨大酒桶的一击标志着庆典的开始。" + "lore_loc": "曼吉克斯巨大酒桶的一击标志着庆典的开始。", + "tags": [] }, { "key": "brewmaster_cinder_brew", @@ -22815,7 +23441,10 @@ "value": "40 / 70 / 100 / 130" } ], - "lore_loc": "奥尤社解决所有问题的方法——再喝一轮!" + "lore_loc": "奥尤社解决所有问题的方法——再喝一轮!", + "tags": [ + "blind" + ] }, { "key": "brewmaster_drunken_brawler", @@ -22870,7 +23499,8 @@ "value": "10 / 20 / 30 / 40" } ], - "lore_loc": "曼吉克斯赢得奥尤社的酒仙称号的同时,也确立了他在精通醉拳方面的地位。" + "lore_loc": "曼吉克斯赢得奥尤社的酒仙称号的同时,也确立了他在精通醉拳方面的地位。", + "tags": [] }, { "key": "brewmaster_liquid_courage", @@ -22909,7 +23539,8 @@ "value": "30%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "brewmaster_primal_split", @@ -22964,7 +23595,8 @@ "value": "70 / 120 / 170 / 220" } ], - "lore_loc": "没有人清楚曼吉克斯是否注意到自己和自然之间强大的连结,因为这总是发生在他醉得不省人事之时。" + "lore_loc": "没有人清楚曼吉克斯是否注意到自己和自然之间强大的连结,因为这总是发生在他醉得不省人事之时。", + "tags": [] } ], "talents": [ @@ -23077,7 +23709,10 @@ "value": "300%" } ], - "lore_loc": "欺骗的痛苦不是在其发生之时,而是在其被揭露之时。" + "lore_loc": "欺骗的痛苦不是在其发生之时,而是在其被揭露之时。", + "tags": [ + "cyclone" + ] }, { "key": "shadow_demon_disseminate", @@ -23112,7 +23747,8 @@ "value": "16 / 24 / 32 / 40%" } ], - "lore_loc": "当暗影恶魔找到新的受害者,受害者的亲友也会遭受同样的痛苦。" + "lore_loc": "当暗影恶魔找到新的受害者,受害者的亲友也会遭受同样的痛苦。", + "tags": [] }, { "key": "shadow_demon_shadow_poison", @@ -23159,7 +23795,8 @@ "value": "10" } ], - "lore_loc": "暗影恶魔不断增长的影响力能够玷污最勇敢最纯洁的英雄。" + "lore_loc": "暗影恶魔不断增长的影响力能够玷污最勇敢最纯洁的英雄。", + "tags": [] }, { "key": "shadow_demon_demonic_cleanse", @@ -23194,7 +23831,10 @@ "value": "5" } ], - "lore_loc": "唯一容许伤害他的追随者的,就是暗影恶魔本人。" + "lore_loc": "唯一容许伤害他的追随者的,就是暗影恶魔本人。", + "tags": [ + "strong_dispel" + ] }, { "key": "shadow_demon_demonic_purge", @@ -23233,7 +23873,10 @@ "value": "5" } ], - "lore_loc": "一旦暗影恶魔不再需要他的信徒,他会抛弃对它的奴役——还有它的生命。" + "lore_loc": "一旦暗影恶魔不再需要他的信徒,他会抛弃对它的奴役——还有它的生命。", + "tags": [ + "basic_dispel" + ] }, { "key": "shadow_demon_menace", @@ -23264,7 +23907,8 @@ "value": "1.9%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -23373,7 +24017,10 @@ "value": "90" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [ + "root" + ] }, { "key": "lone_druid_spirit_link", @@ -23412,7 +24059,8 @@ "value": "15 / 30 / 45 / 60%" } ], - "lore_loc": "悉拉在他伙伴身旁倍感鼓舞,熊灵每次挥爪都让他的生命力得到补充。" + "lore_loc": "悉拉在他伙伴身旁倍感鼓舞,熊灵每次挥爪都让他的生命力得到补充。", + "tags": [] }, { "key": "lone_druid_savage_roar", @@ -23467,7 +24115,11 @@ "value": "0" } ], - "lore_loc": "悉拉研习并精通失落的熊族技艺,他的行动也因此与自然高度协调。" + "lore_loc": "悉拉研习并精通失落的熊族技艺,他的行动也因此与自然高度协调。", + "tags": [ + "basic_dispel", + "fear" + ] }, { "key": "lone_druid_spirit_bear", @@ -23522,7 +24174,8 @@ "value": "0.5" } ], - "lore_loc": "悉拉终生的伙伴与他的魂和心共生,在他需要时随时前来相助。" + "lore_loc": "悉拉终生的伙伴与他的魂和心共生,在他需要时随时前来相助。", + "tags": [] }, { "key": "lone_druid_true_form", @@ -23569,7 +24222,8 @@ "value": "1.933" } ], - "lore_loc": "强大的熊是森林之王,它们单纯地依靠力量就获得了这一位置。" + "lore_loc": "强大的熊是森林之王,它们单纯地依靠力量就获得了这一位置。", + "tags": [] } ], "talents": [ @@ -23686,7 +24340,10 @@ "value": "0%" } ], - "lore_loc": "甚至混沌骑士自己都无法预测这不洁之力会有何种效果。" + "lore_loc": "甚至混沌骑士自己都无法预测这不洁之力会有何种效果。", + "tags": [ + "stun" + ] }, { "key": "chaos_knight_reality_rift", @@ -23721,7 +24378,8 @@ "value": "4 / 5 / 6 / 7" } ], - "lore_loc": "天劫的铁蹄跨过不同位面的世界,让混沌骑士的敌人无处可藏。" + "lore_loc": "天劫的铁蹄跨过不同位面的世界,让混沌骑士的敌人无处可藏。", + "tags": [] }, { "key": "chaos_knight_chaos_strike", @@ -23764,7 +24422,8 @@ "value": "30 / 40 / 50 / 60%" } ], - "lore_loc": "混沌骑士用巨大的蛮力砸向面前的敌人,击碎最厚的盔甲。" + "lore_loc": "混沌骑士用巨大的蛮力砸向面前的敌人,击碎最厚的盔甲。", + "tags": [] }, { "key": "chaos_knight_fundamental_forging", @@ -23790,7 +24449,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "chaos_knight_phantasm", @@ -23833,7 +24493,8 @@ "value": "350%" } ], - "lore_loc": "凭借混沌骑士在各个世界丰富的战斗经验,他的幻象足以压制所有敢于反抗他的人。" + "lore_loc": "凭借混沌骑士在各个世界丰富的战斗经验,他的幻象足以压制所有敢于反抗他的人。", + "tags": [] } ], "talents": [ @@ -23934,7 +24595,10 @@ "value": "220" } ], - "lore_loc": "在裂影荒墟抓住晚饭可是件体力活。" + "lore_loc": "在裂影荒墟抓住晚饭可是件体力活。", + "tags": [ + "root" + ] }, { "key": "meepo_poof", @@ -23969,7 +24633,8 @@ "value": "50 / 80 / 110 / 140" } ], - "lore_loc": "有时候打碎一块影墟水晶可以作为自己摆脱陷阱的方法。或是另外一个自己。" + "lore_loc": "有时候打碎一块影墟水晶可以作为自己摆脱陷阱的方法。或是另外一个自己。", + "tags": [] }, { "key": "meepo_ransack", @@ -24000,7 +24665,8 @@ "value": "7 / 10 / 13 / 16" } ], - "lore_loc": "所以你要吗,还是……?" + "lore_loc": "所以你要吗,还是……?", + "tags": [] }, { "key": "meepo_petrify", @@ -24035,7 +24701,10 @@ "value": "25%" } ], - "lore_loc": "我在这挖呀挖呀挖!" + "lore_loc": "我在这挖呀挖呀挖!", + "tags": [ + "basic_dispel" + ] }, { "key": "meepo_megameepo", @@ -24066,7 +24735,8 @@ "value": "20" } ], - "lore_loc": "嘿,米波们!" + "lore_loc": "嘿,米波们!", + "tags": [] }, { "key": "meepo_divided_we_stand", @@ -24097,7 +24767,8 @@ "value": "1 / 2 / 3 / 4" } ], - "lore_loc": "我是不是在哪见过你?" + "lore_loc": "我是不是在哪见过你?", + "tags": [] }, { "key": "meepo_geomancy", @@ -24123,7 +24794,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "meepo_megameepo_fling", @@ -24158,7 +24830,8 @@ "value": "-60%" } ], - "lore_loc": "你先上!" + "lore_loc": "你先上!", + "tags": [] } ], "talents": [ @@ -24263,7 +24936,8 @@ "value": "9 / 10 / 11 / 12" } ], - "lore_loc": "带有鲁夫特伦影响力的卷须延伸到了森林的各个角落。" + "lore_loc": "带有鲁夫特伦影响力的卷须延伸到了森林的各个角落。", + "tags": [] }, { "key": "treant_leech_seed", @@ -24306,7 +24980,11 @@ "value": "650" } ], - "lore_loc": "鲁夫特伦悉心培育的这些生命完全靠闯入他圣地的入侵者的生命力维持。" + "lore_loc": "鲁夫特伦悉心培育的这些生命完全靠闯入他圣地的入侵者的生命力维持。", + "tags": [ + "root", + "disarm" + ] }, { "key": "treant_living_armor", @@ -24349,7 +25027,8 @@ "value": "20" } ], - "lore_loc": "树精卫士的根须广泛蔓延,促进自然万物的生长和恢复。" + "lore_loc": "树精卫士的根须广泛蔓延,促进自然万物的生长和恢复。", + "tags": [] }, { "key": "treant_eyes_in_the_forest", @@ -24375,7 +25054,8 @@ "cooldown": "0", "mana_cost": "30", "specials": [], - "lore_loc": "对于需要守卫的,谁也不能打包票究竟是什么人,或什么东西。" + "lore_loc": "对于需要守卫的,谁也不能打包票究竟是什么人,或什么东西。", + "tags": [] }, { "key": "treant_natures_guise", @@ -24410,7 +25090,10 @@ "value": "36" } ], - "lore_loc": "树精卫士不常出现在人们的视线中;他们舒适地隐蔽于枝繁叶茂的同胞当中并保持自然的形态。" + "lore_loc": "树精卫士不常出现在人们的视线中;他们舒适地隐蔽于枝繁叶茂的同胞当中并保持自然的形态。", + "tags": [ + "invis" + ] }, { "key": "treant_overgrowth", @@ -24449,7 +25132,10 @@ "value": "95" } ], - "lore_loc": "鲁夫特伦召唤自然的先祖之魂,将其力量施予他的同胞。" + "lore_loc": "鲁夫特伦召唤自然的先祖之魂,将其力量施予他的同胞。", + "tags": [ + "root" + ] } ], "talents": [ @@ -24550,7 +25236,10 @@ "value": "70 / 130 / 190 / 250" } ], - "lore_loc": "食人魔魔法师很容易就能被逗乐,玩火就能玩上几个小时。" + "lore_loc": "食人魔魔法师很容易就能被逗乐,玩火就能玩上几个小时。", + "tags": [ + "stun" + ] }, { "key": "ogre_magi_ignite", @@ -24589,7 +25278,8 @@ "value": "-25%" } ], - "lore_loc": "准备好!" + "lore_loc": "准备好!", + "tags": [] }, { "key": "ogre_magi_bloodlust", @@ -24632,7 +25322,8 @@ "value": "30" } ], - "lore_loc": "“跑来跑去可没有打人爽……太无聊了。”" + "lore_loc": "“跑来跑去可没有打人爽……太无聊了。”", + "tags": [] }, { "key": "ogre_magi_unrefined_fireblast", @@ -24663,7 +25354,10 @@ "value": "1.2" } ], - "lore_loc": "食人魔魔法师关于“谁先施法”的自我争论最后用同时施法解决了。" + "lore_loc": "食人魔魔法师关于“谁先施法”的自我争论最后用同时施法解决了。", + "tags": [ + "stun" + ] }, { "key": "ogre_magi_smash", @@ -24702,7 +25396,8 @@ "value": "85%" } ], - "lore_loc": "阿格隆没把火放到朋友身上就已经是走了大运了。" + "lore_loc": "阿格隆没把火放到朋友身上就已经是走了大运了。", + "tags": [] }, { "key": "ogre_magi_dumb_luck", @@ -24737,7 +25432,8 @@ "value": "0.02" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "ogre_magi_multicast", @@ -24776,7 +25472,8 @@ "value": "0 / 0 / 15%" } ], - "lore_loc": "尽管很大程度上受到智商的制约,但食人魔魔法师仍能依靠纯熟的技巧在战斗中取胜。" + "lore_loc": "尽管很大程度上受到智商的制约,但食人魔魔法师仍能依靠纯熟的技巧在战斗中取胜。", + "tags": [] } ], "talents": [ @@ -24885,7 +25582,8 @@ "value": "325" } ], - "lore_loc": "生者的力量只不过是借用自死者。" + "lore_loc": "生者的力量只不过是借用自死者。", + "tags": [] }, { "key": "undying_soul_rip", @@ -24936,7 +25634,8 @@ "value": "0" } ], - "lore_loc": "即使是友军也能从不朽尸王的存在中感受到绝望。" + "lore_loc": "即使是友军也能从不朽尸王的存在中感受到绝望。", + "tags": [] }, { "key": "undying_tombstone", @@ -24983,7 +25682,10 @@ "value": "34" } ], - "lore_loc": "挽歌召唤他倒下的战友为死亡之神作战。" + "lore_loc": "挽歌召唤他倒下的战友为死亡之神作战。", + "tags": [ + "stun" + ] }, { "key": "undying_ceaseless_dirge", @@ -25009,7 +25711,8 @@ "cooldown": "480", "mana_cost": "0", "specials": [], - "lore_loc": "只有死亡之神的挽歌停息,这场死士的行军才会最终结束。" + "lore_loc": "只有死亡之神的挽歌停息,这场死士的行军才会最终结束。", + "tags": [] }, { "key": "undying_flesh_golem", @@ -25060,7 +25763,8 @@ "value": "20" } ], - "lore_loc": "新近死者的血肉增强了挽歌瘟疫的力量。" + "lore_loc": "新近死者的血肉增强了挽歌瘟疫的力量。", + "tags": [] } ], "talents": [ @@ -25169,7 +25873,8 @@ "value": "375" } ], - "lore_loc": "即使是最伟大的魔导师也会将他的力量用来娱乐。" + "lore_loc": "即使是最伟大的魔导师也会将他的力量用来娱乐。", + "tags": [] }, { "key": "rubick_fade_bolt", @@ -25212,7 +25917,8 @@ "value": "9" } ], - "lore_loc": "在处决刺杀他的人时,拉比克最爱用的法术其实是一个相当简单的咒语。" + "lore_loc": "在处决刺杀他的人时,拉比克最爱用的法术其实是一个相当简单的咒语。", + "tags": [] }, { "key": "rubick_arcane_supremacy", @@ -25247,7 +25953,8 @@ "value": "11 / 16 / 21 / 26%" } ], - "lore_loc": "不是每一个魔导师都能成为大魔导师……" + "lore_loc": "不是每一个魔导师都能成为大魔导师……", + "tags": [] }, { "key": "rubick_spell_steal", @@ -25286,7 +25993,8 @@ "value": "180 / 240 / 300" } ], - "lore_loc": "……不过即使是他们那些低级魔法也能变得非常实用。" + "lore_loc": "……不过即使是他们那些低级魔法也能变得非常实用。", + "tags": [] }, { "key": "rubick_curiosity", @@ -25325,7 +26033,8 @@ "value": "0" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -25434,7 +26143,8 @@ "value": "30 / 60 / 90 / 120" } ], - "lore_loc": "干扰者的充能线圈有时会超负荷,然后他的敌人就只剩一块烤焦的盔甲碎片或者一簇毛皮了。" + "lore_loc": "干扰者的充能线圈有时会超负荷,然后他的敌人就只剩一块烤焦的盔甲碎片或者一簇毛皮了。", + "tags": [] }, { "key": "disruptor_glimpse", @@ -25473,7 +26183,8 @@ "value": "20 / 25 / 30 / 35%" } ], - "lore_loc": "对电流的把玩能产生出人意料的结果。" + "lore_loc": "对电流的把玩能产生出人意料的结果。", + "tags": [] }, { "key": "disruptor_kinetic_field", @@ -25512,7 +26223,10 @@ "value": "2.6 / 3.2 / 3.8 / 4.4" } ], - "lore_loc": "干扰者的坐骑不会受到那摧残敌人的狂风的影响。" + "lore_loc": "干扰者的坐骑不会受到那摧残敌人的狂风的影响。", + "tags": [ + "leash" + ] }, { "key": "disruptor_kinetic_fence", @@ -25551,7 +26265,8 @@ "value": "4.4" } ], - "lore_loc": "有一瞬间,就好像风本身都凝固了。" + "lore_loc": "有一瞬间,就好像风本身都凝固了。", + "tags": [] }, { "key": "disruptor_electromagnetic_repulsion", @@ -25577,7 +26292,8 @@ "cooldown": "5", "mana_cost": "0", "specials": [], - "lore_loc": "风暴中找到的避风港。" + "lore_loc": "风暴中找到的避风港。", + "tags": [] }, { "key": "disruptor_static_storm", @@ -25620,7 +26336,11 @@ "value": "6" } ], - "lore_loc": "只有高地人能从杜鲁德的夏日风暴中幸存。" + "lore_loc": "只有高地人能从杜鲁德的夏日风暴中幸存。", + "tags": [ + "silence", + "mute" + ] } ], "talents": [ @@ -25721,7 +26441,10 @@ "value": "100 / 160 / 220 / 280" } ], - "lore_loc": "所有狂热的圣甲虫都拥有地底通道的秘密学识,并用其为自己带来优势。" + "lore_loc": "所有狂热的圣甲虫都拥有地底通道的秘密学识,并用其为自己带来优势。", + "tags": [ + "stun" + ] }, { "key": "nyx_assassin_jolt", @@ -25760,7 +26483,8 @@ "value": "15%" } ], - "lore_loc": "只有虫后养育的才能使用他们的想法去触碰周围的世界。" + "lore_loc": "只有虫后养育的才能使用他们的想法去触碰周围的世界。", + "tags": [] }, { "key": "nyx_assassin_spiked_carapace", @@ -25799,7 +26523,10 @@ "value": "140%" } ], - "lore_loc": "虽然甲壳相对较薄,不过保护身体的是一圈伸缩自如,剃刀般锋利的尖刺。" + "lore_loc": "虽然甲壳相对较薄,不过保护身体的是一圈伸缩自如,剃刀般锋利的尖刺。", + "tags": [ + "stun" + ] }, { "key": "nyx_assassin_burrow", @@ -25850,7 +26577,10 @@ "value": "400" } ], - "lore_loc": "狂热圣甲虫中某些层级的成名绝技是挖掘小型洞穴,守株待兔,做好伏击猎物的准备。" + "lore_loc": "狂热圣甲虫中某些层级的成名绝技是挖掘小型洞穴,守株待兔,做好伏击猎物的准备。", + "tags": [ + "stun" + ] }, { "key": "nyx_assassin_neuro_sting", @@ -25881,7 +26611,8 @@ "value": "12%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "nyx_assassin_vendetta", @@ -25924,7 +26655,11 @@ "value": "4" } ], - "lore_loc": "圣甲虫为女王的荣耀而杀戮。" + "lore_loc": "圣甲虫为女王的荣耀而杀戮。", + "tags": [ + "break", + "invis" + ] } ], "talents": [ @@ -26033,7 +26768,8 @@ "value": "3" } ], - "lore_loc": "司里希丝天生就很强大,并且众人皆知其难以追踪。" + "lore_loc": "司里希丝天生就很强大,并且众人皆知其难以追踪。", + "tags": [] }, { "key": "naga_siren_ensnare", @@ -26064,7 +26800,10 @@ "value": "2.75 / 3.5 / 4.25 / 5" } ], - "lore_loc": "想要逃脱鱼人的追杀,唯一的办法就是一开始就不要与之作对。" + "lore_loc": "想要逃脱鱼人的追杀,唯一的办法就是一开始就不要与之作对。", + "tags": [ + "root" + ] }, { "key": "naga_siren_rip_tide", @@ -26099,7 +26838,8 @@ "value": "30 / 40 / 50 / 60" } ], - "lore_loc": "海妖的敌人被深海涌出的洪流冲刷,防御遭到粉碎。" + "lore_loc": "海妖的敌人被深海涌出的洪流冲刷,防御遭到粉碎。", + "tags": [] }, { "key": "naga_siren_eelskin", @@ -26130,7 +26870,8 @@ "value": "4%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "naga_siren_song_of_the_siren", @@ -26173,7 +26914,10 @@ "value": "0%" } ], - "lore_loc": "司里希丝具有魔力的声音使敌人不自觉地沉醉其中,同时在需要的时候召唤她的族人前来助战。" + "lore_loc": "司里希丝具有魔力的声音使敌人不自觉地沉醉其中,同时在需要的时候召唤她的族人前来助战。", + "tags": [ + "sleep" + ] } ], "talents": [ @@ -26282,7 +27026,8 @@ "value": "1550" } ], - "lore_loc": "伊扎洛深藏的光芒显现之时璀璨夺目。" + "lore_loc": "伊扎洛深藏的光芒显现之时璀璨夺目。", + "tags": [] }, { "key": "keeper_of_the_light_blinding_light", @@ -26325,7 +27070,10 @@ "value": "90 / 140 / 190 / 240" } ], - "lore_loc": "原初的光芒将战局的优势转向伊扎洛和他的盟友。" + "lore_loc": "原初的光芒将战局的优势转向伊扎洛和他的盟友。", + "tags": [ + "blind" + ] }, { "key": "keeper_of_the_light_chakra_magic", @@ -26360,7 +27108,10 @@ "value": "3 / 4 / 5 / 6" } ], - "lore_loc": "通过同样的渠道,伊扎洛将他的协律授予他人。" + "lore_loc": "通过同样的渠道,伊扎洛将他的协律授予他人。", + "tags": [ + "basic_dispel" + ] }, { "key": "keeper_of_the_light_radiant_bind", @@ -26399,7 +27150,8 @@ "value": "20 / 30 / 40%" } ], - "lore_loc": "就算只是瞥了一眼原初之光,敌人的理智也会被剥夺。" + "lore_loc": "就算只是瞥了一眼原初之光,敌人的理智也会被剥夺。", + "tags": [] }, { "key": "keeper_of_the_light_will_o_wisp", @@ -26438,7 +27190,8 @@ "value": "85" } ], - "lore_loc": "虽然伊扎洛不会宣扬自己“认识”他们,但他离奇地影响着很多愿意援助他的强大生物。" + "lore_loc": "虽然伊扎洛不会宣扬自己“认识”他们,但他离奇地影响着很多愿意援助他的强大生物。", + "tags": [] }, { "key": "keeper_of_the_light_spirit_form", @@ -26481,7 +27234,8 @@ "value": "70%" } ], - "lore_loc": "在一闪而逝的光芒中,伊扎洛展现他真实而原本的形态。" + "lore_loc": "在一闪而逝的光芒中,伊扎洛展现他真实而原本的形态。", + "tags": [] }, { "key": "keeper_of_the_light_bright_speed", @@ -26512,7 +27266,8 @@ "value": "0" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -26625,7 +27380,8 @@ "value": "0" } ], - "lore_loc": "艾欧善意的接触从不同位面之间带来力量。" + "lore_loc": "艾欧善意的接触从不同位面之间带来力量。", + "tags": [] }, { "key": "wisp_spirits", @@ -26680,7 +27436,8 @@ "value": "0" } ], - "lore_loc": "艾欧用它不可思议的力量扭曲了宇宙中的粒子。" + "lore_loc": "艾欧用它不可思议的力量扭曲了宇宙中的粒子。", + "tags": [] }, { "key": "wisp_overcharge", @@ -26723,7 +27480,8 @@ "value": "8" } ], - "lore_loc": "从所有位面的世界吸取一切物质的能量,艾欧开始展示时间的真相。" + "lore_loc": "从所有位面的世界吸取一切物质的能量,艾欧开始展示时间的真相。", + "tags": [] }, { "key": "wisp_spirits_in", @@ -26749,7 +27507,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "wisp_spirits_out", @@ -26775,7 +27534,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "wisp_relocate", @@ -26810,7 +27570,8 @@ "value": "12" } ], - "lore_loc": "艾欧本身就是宇宙奥秘的体现。" + "lore_loc": "艾欧本身就是宇宙奥秘的体现。", + "tags": [] }, { "key": "wisp_equilibrium", @@ -26853,7 +27614,8 @@ "value": "0%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -26958,7 +27720,8 @@ "value": "35 / 45 / 55 / 65" } ], - "lore_loc": "温暖的阳光到了黑暗的狭窄迷宫里只剩下寒意。" + "lore_loc": "温暖的阳光到了黑暗的狭窄迷宫里只剩下寒意。", + "tags": [] }, { "key": "visage_soul_assumption", @@ -27009,7 +27772,8 @@ "value": "1800" } ], - "lore_loc": "收集的死者亡魂四散而去带回生魂。" + "lore_loc": "收集的死者亡魂四散而去带回生魂。", + "tags": [] }, { "key": "visage_gravekeepers_cloak", @@ -27064,7 +27828,10 @@ "value": "0" } ], - "lore_loc": "维萨吉坚硬的鳞片同时具有鬼魂和石像的属性,刀剑和魔法的突袭对他来说不痛不痒。" + "lore_loc": "维萨吉坚硬的鳞片同时具有鬼魂和石像的属性,刀剑和魔法的突袭对他来说不痛不痒。", + "tags": [ + "stun" + ] }, { "key": "visage_silent_as_the_grave", @@ -27111,7 +27878,10 @@ "value": "45.75" } ], - "lore_loc": "来世的媒介在尘世间四处扰动,但世人视而不见。" + "lore_loc": "来世的媒介在尘世间四处扰动,但世人视而不见。", + "tags": [ + "invis" + ] }, { "key": "visage_summon_familiars", @@ -27162,7 +27932,10 @@ "value": "25 / 50 / 75" } ], - "lore_loc": "死灵飞龙的佣兽,也是狭窄迷宫的守卫,俯视着他的领地。" + "lore_loc": "死灵飞龙的佣兽,也是狭窄迷宫的守卫,俯视着他的领地。", + "tags": [ + "stun" + ] } ], "talents": [ @@ -27267,7 +28040,8 @@ "value": "75 / 150 / 225 / 300" } ], - "lore_loc": "鱼人具有使鳍快速重新生长的能力,这样在重伤下也能救回自己的命。" + "lore_loc": "鱼人具有使鳍快速重新生长的能力,这样在重伤下也能救回自己的命。", + "tags": [] }, { "key": "slark_pounce", @@ -27322,7 +28096,10 @@ "value": "1 / 2 / 3 / 4" } ], - "lore_loc": "在暗黑之礁的服刑期间,斯拉克成长为危险的刺客;举止凶残,无所畏惧。" + "lore_loc": "在暗黑之礁的服刑期间,斯拉克成长为危险的刺客;举止凶残,无所畏惧。", + "tags": [ + "leash" + ] }, { "key": "slark_saltwater_shiv", @@ -27365,7 +28142,8 @@ "value": "4 / 8 / 12 / 16" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "slark_depth_shroud", @@ -27400,7 +28178,8 @@ "value": "225" } ], - "lore_loc": "没有比搞砸更难受的事了,全怪某个笨手笨脚的朋友。" + "lore_loc": "没有比搞砸更难受的事了,全怪某个笨手笨脚的朋友。", + "tags": [] }, { "key": "slark_essence_shift", @@ -27435,7 +28214,8 @@ "value": "300" } ], - "lore_loc": "每攻击一次敌人,斯拉克对他们的弱点就多了解一分。" + "lore_loc": "每攻击一次敌人,斯拉克对他们的弱点就多了解一分。", + "tags": [] }, { "key": "slark_shadow_dance", @@ -27474,7 +28254,10 @@ "value": "4 / 4.25 / 4.5" } ], - "lore_loc": "秘而不宣的第十三个逃犯是个浑身滑溜的敌人。" + "lore_loc": "秘而不宣的第十三个逃犯是个浑身滑溜的敌人。", + "tags": [ + "invis" + ] } ], "talents": [ @@ -27575,7 +28358,8 @@ "value": "4" } ], - "lore_loc": "遇到了被诅咒的蛇发女妖,那就会领教到她的愤怒。" + "lore_loc": "遇到了被诅咒的蛇发女妖,那就会领教到她的愤怒。", + "tags": [] }, { "key": "medusa_mystic_snake", @@ -27622,7 +28406,8 @@ "value": "25%" } ], - "lore_loc": "蛇发女妖所受的诅咒也是她最强大的力量。" + "lore_loc": "蛇发女妖所受的诅咒也是她最强大的力量。", + "tags": [] }, { "key": "medusa_gorgon_grasp", @@ -27673,7 +28458,10 @@ "value": "100" } ], - "lore_loc": "顶尖的掠食者根本不用扑上去。" + "lore_loc": "顶尖的掠食者根本不用扑上去。", + "tags": [ + "root" + ] }, { "key": "medusa_cold_blooded", @@ -27699,7 +28487,8 @@ "cooldown": "12", "mana_cost": "0", "specials": [], - "lore_loc": "要是有胆就把目光放她身上。" + "lore_loc": "要是有胆就把目光放她身上。", + "tags": [] }, { "key": "medusa_mana_shield", @@ -27730,7 +28519,8 @@ "value": "2" } ], - "lore_loc": "尽管美杜莎生来没被赐以永恒的生命,但她所受的诅咒赋予了她独一无二的防御力量。" + "lore_loc": "尽管美杜莎生来没被赐以永恒的生命,但她所受的诅咒赋予了她独一无二的防御力量。", + "tags": [] }, { "key": "medusa_stone_gaze", @@ -27781,7 +28571,10 @@ "value": "50%" } ], - "lore_loc": "美杜莎的美貌感天动地。" + "lore_loc": "美杜莎的美貌感天动地。", + "tags": [ + "stun" + ] } ], "talents": [ @@ -27878,7 +28671,8 @@ "value": "0" } ], - "lore_loc": "杰拉考有很多办法激怒敌人。" + "lore_loc": "杰拉考有很多办法激怒敌人。", + "tags": [] }, { "key": "troll_warlord_whirling_axes_ranged", @@ -27925,7 +28719,10 @@ "value": "9" } ], - "lore_loc": "只有注入了憎恨的斧头旋转起来才会如此致命。" + "lore_loc": "只有注入了憎恨的斧头旋转起来才会如此致命。", + "tags": [ + "blind" + ] }, { "key": "troll_warlord_whirling_axes_melee", @@ -27980,7 +28777,10 @@ "value": "9" } ], - "lore_loc": "要靠近你的敌人。" + "lore_loc": "要靠近你的敌人。", + "tags": [ + "disarm" + ] }, { "key": "troll_warlord_fervor", @@ -28019,7 +28819,8 @@ "value": "0" } ], - "lore_loc": "如果第一下没成功,那就接着出招呗。" + "lore_loc": "如果第一下没成功,那就接着出招呗。", + "tags": [] }, { "key": "troll_warlord_berserkers_rage", @@ -28078,7 +28879,8 @@ "value": "10 / 15 / 20 / 25" } ], - "lore_loc": "跟巨魔战将的怒火一样,他的斧头也是源源不断。" + "lore_loc": "跟巨魔战将的怒火一样,他的斧头也是源源不断。", + "tags": [] }, { "key": "troll_warlord_battle_trance", @@ -28125,7 +28927,10 @@ "value": "0" } ], - "lore_loc": "纯粹的仇恨让气血迅速上涌,刀剑也随之飞舞。" + "lore_loc": "纯粹的仇恨让气血迅速上涌,刀剑也随之飞舞。", + "tags": [ + "basic_dispel" + ] } ], "talents": [ @@ -28234,7 +29039,10 @@ "value": "0.5" } ], - "lore_loc": "当战行者健壮的马蹄碰到了土壤,大地的震颤各处都能感受到。" + "lore_loc": "当战行者健壮的马蹄碰到了土壤,大地的震颤各处都能感受到。", + "tags": [ + "stun" + ] }, { "key": "centaur_double_edge", @@ -28285,7 +29093,8 @@ "value": "0" } ], - "lore_loc": "在激烈的战斗中,布拉德瓦登凶猛的撞击有时候会对自己造成伤害。" + "lore_loc": "在激烈的战斗中,布拉德瓦登凶猛的撞击有时候会对自己造成伤害。", + "tags": [] }, { "key": "centaur_return", @@ -28320,7 +29129,8 @@ "value": "16 / 24 / 32 / 40%" } ], - "lore_loc": "布拉德瓦登无需回避对手的攻击;这由他那盔甲般的马皮负责。" + "lore_loc": "布拉德瓦登无需回避对手的攻击;这由他那盔甲般的马皮负责。", + "tags": [] }, { "key": "centaur_work_horse", @@ -28351,7 +29161,8 @@ "value": "6" } ], - "lore_loc": "能够得到半人马战行者的搭乘邀请是莫大的荣幸,而拒绝则是严重的错误。" + "lore_loc": "能够得到半人马战行者的搭乘邀请是莫大的荣幸,而拒绝则是严重的错误。", + "tags": [] }, { "key": "centaur_stampede", @@ -28394,7 +29205,8 @@ "value": "105" } ], - "lore_loc": "欧梅克斯的巨大腰带,彰显着布拉德瓦登是同族中最伟大的战士,激励着他的格斗士同伴在野蛮的战斗中跟随他。" + "lore_loc": "欧梅克斯的巨大腰带,彰显着布拉德瓦登是同族中最伟大的战士,激励着他的格斗士同伴在野蛮的战斗中跟随他。", + "tags": [] }, { "key": "centaur_horsepower", @@ -28425,7 +29237,8 @@ "value": "0" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -28534,7 +29347,8 @@ "value": "1200" } ], - "lore_loc": "杰尔拉克山体并不稳定,马格纳斯已经学会如何将山体里的回响引导出来。" + "lore_loc": "杰尔拉克山体并不稳定,马格纳斯已经学会如何将山体里的回响引导出来。", + "tags": [] }, { "key": "magnataur_empower", @@ -28573,7 +29387,8 @@ "value": "16 / 24 / 32 / 40%" } ], - "lore_loc": "随着一声怒吼,马格纳斯展示了他真正的力量。" + "lore_loc": "随着一声怒吼,马格纳斯展示了他真正的力量。", + "tags": [] }, { "key": "magnataur_skewer", @@ -28632,7 +29447,8 @@ "value": "0" } ], - "lore_loc": "对潜在的商人来说,半人犀牛的犀角价值与犀角能够造成的破坏成正比。" + "lore_loc": "对潜在的商人来说,半人犀牛的犀角价值与犀角能够造成的破坏成正比。", + "tags": [] }, { "key": "magnataur_horn_toss", @@ -28671,7 +29487,10 @@ "value": "0.75" } ], - "lore_loc": "杰尔拉克山的幸存者不会忘记那一幕,马格纳斯把无数的敌人抛进火焰的河流和灰烬的云朵。" + "lore_loc": "杰尔拉克山的幸存者不会忘记那一幕,马格纳斯把无数的敌人抛进火焰的河流和灰烬的云朵。", + "tags": [ + "stun" + ] }, { "key": "magnataur_solid_core", @@ -28706,7 +29525,8 @@ "value": "24%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "magnataur_reverse_polarity", @@ -28745,7 +29565,10 @@ "value": "2.5 / 3 / 3.5" } ], - "lore_loc": "作战时愤怒的马格纳斯如同喷发的杰尔拉克山。" + "lore_loc": "作战时愤怒的马格纳斯如同喷发的杰尔拉克山。", + "tags": [ + "stun" + ] } ], "talents": [ @@ -28862,7 +29685,8 @@ "value": "7 / 9 / 11 / 13" } ], - "lore_loc": "在瑞兹拉克被梦魇般的藤蔓和植物包围的情况下,他马上操起链锯进行自卫。" + "lore_loc": "在瑞兹拉克被梦魇般的藤蔓和植物包围的情况下,他马上操起链锯进行自卫。", + "tags": [] }, { "key": "shredder_timber_chain", @@ -28901,7 +29725,8 @@ "value": "45 / 105 / 165 / 225" } ], - "lore_loc": "你可猜不到自己什么时候需要逃离邪恶的树苗。" + "lore_loc": "你可猜不到自己什么时候需要逃离邪恶的树苗。", + "tags": [] }, { "key": "shredder_reactive_armor", @@ -28944,7 +29769,8 @@ "value": "10 / 12 / 14 / 16" } ], - "lore_loc": "锯齿机器上的设备可以用强化防御来应对轻微的触碰。" + "lore_loc": "锯齿机器上的设备可以用强化防御来应对轻微的触碰。", + "tags": [] }, { "key": "shredder_flamethrower", @@ -28999,7 +29825,8 @@ "value": "2" } ], - "lore_loc": "一把锯子只能砍那么点东西。一个火花可以永远燃烧下去。" + "lore_loc": "一把锯子只能砍那么点东西。一个火花可以永远燃烧下去。", + "tags": [] }, { "key": "shredder_chakram", @@ -29046,7 +29873,8 @@ "value": "10 / 15 / 20" } ], - "lore_loc": "消灭植物的终极武器。" + "lore_loc": "消灭植物的终极武器。", + "tags": [] }, { "key": "shredder_exposure_therapy", @@ -29077,7 +29905,8 @@ "value": "4" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -29190,7 +30019,8 @@ "value": "6" } ], - "lore_loc": "被大雪困住的时候着了凉,但是钢背兽让这为他所用。" + "lore_loc": "被大雪困住的时候着了凉,但是钢背兽让这为他所用。", + "tags": [] }, { "key": "bristleback_quill_spray", @@ -29237,7 +30067,8 @@ "value": "500" } ], - "lore_loc": "执法者的荣誉维护起来非常棘手。他的刺针也是。" + "lore_loc": "执法者的荣誉维护起来非常棘手。他的刺针也是。", + "tags": [] }, { "key": "bristleback_bristleback", @@ -29280,7 +30111,8 @@ "value": "0" } ], - "lore_loc": "在战斗中背部迎人或许正好合适。" + "lore_loc": "在战斗中背部迎人或许正好合适。", + "tags": [] }, { "key": "bristleback_hairball", @@ -29319,7 +30151,8 @@ "value": "2" } ], - "lore_loc": "钢背兽身体的每个部位都是武器——就连他不小心吞下的也是。" + "lore_loc": "钢背兽身体的每个部位都是武器——就连他不小心吞下的也是。", + "tags": [] }, { "key": "bristleback_warpath", @@ -29362,7 +30195,8 @@ "value": "8 / 10 / 12" } ], - "lore_loc": "“别没事就发火”,他母亲总是这样训斥他。但是在战斗中,火冒三丈将有非凡效果。" + "lore_loc": "“别没事就发火”,他母亲总是这样训斥他。但是在战斗中,火冒三丈将有非凡效果。", + "tags": [] }, { "key": "bristleback_prickly", @@ -29393,7 +30227,8 @@ "value": "4.5%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -29498,7 +30333,8 @@ "value": "4 / 5 / 6 / 7" } ], - "lore_loc": "在冰盾附近的冻原上,秋天最后一个日落后,结冰的速度非常惊人。" + "lore_loc": "在冰盾附近的冻原上,秋天最后一个日落后,结冰的速度非常惊人。", + "tags": [] }, { "key": "tusk_snowball", @@ -29553,7 +30389,10 @@ "value": "325" } ], - "lore_loc": "至今还有故事在讲述白色荒原上终结那场空前格斗的狂野之举。" + "lore_loc": "至今还有故事在讲述白色荒原上终结那场空前格斗的狂野之举。", + "tags": [ + "stun" + ] }, { "key": "tusk_tag_team", @@ -29600,7 +30439,8 @@ "value": "350" } ], - "lore_loc": "就算远离钴蓝冰原,巨牙海民骨子里还是有着“寒战屏障”。" + "lore_loc": "就算远离钴蓝冰原,巨牙海民骨子里还是有着“寒战屏障”。", + "tags": [] }, { "key": "tusk_drinking_buddies", @@ -29639,7 +30479,8 @@ "value": "7" } ], - "lore_loc": "比找到一个好酒友更好的只有唯一一件事,就是找到和你们俩势均力敌的劲敌。" + "lore_loc": "比找到一个好酒友更好的只有唯一一件事,就是找到和你们俩势均力敌的劲敌。", + "tags": [] }, { "key": "tusk_walrus_kick", @@ -29682,7 +30523,10 @@ "value": "550" } ], - "lore_loc": "巨牙海民的拳头大名鼎鼎,但聪明的敌人会提防所有肢体动作。" + "lore_loc": "巨牙海民的拳头大名鼎鼎,但聪明的敌人会提防所有肢体动作。", + "tags": [ + "stun" + ] }, { "key": "tusk_walrus_punch", @@ -29721,7 +30565,8 @@ "value": "1" } ], - "lore_loc": "谁打出第一拳往往不重要,看的可是最后一拳。" + "lore_loc": "谁打出第一拳往往不重要,看的可是最后一拳。", + "tags": [] }, { "key": "tusk_bitter_chill", @@ -29752,7 +30597,8 @@ "value": "12" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -29853,7 +30699,8 @@ "value": "1.5" } ], - "lore_loc": "面对苍白之巢内无穷的诡计,只有沉稳的聪明人才能幸存。" + "lore_loc": "面对苍白之巢内无穷的诡计,只有沉稳的聪明人才能幸存。", + "tags": [] }, { "key": "skywrath_mage_concussive_shot", @@ -29900,7 +30747,8 @@ "value": "40%" } ], - "lore_loc": "侍奉苍白之巢王庭就会对身边的暗流涌动不加关注。必须要时刻明白周围最近的危险所在。" + "lore_loc": "侍奉苍白之巢王庭就会对身边的暗流涌动不加关注。必须要时刻明白周围最近的危险所在。", + "tags": [] }, { "key": "skywrath_mage_ancient_seal", @@ -29935,7 +30783,10 @@ "value": "3 / 4 / 5 / 6" } ], - "lore_loc": "一个神圣的咒语,只要被亚未里亚的符印所包围就必然在无声的忏悔中受难。" + "lore_loc": "一个神圣的咒语,只要被亚未里亚的符印所包围就必然在无声的忏悔中受难。", + "tags": [ + "silence" + ] }, { "key": "skywrath_mage_shield_of_the_scion", @@ -29970,7 +30821,8 @@ "value": "12" } ], - "lore_loc": "天怒王国的当朝法师只能使用他们的法力来保卫王国和王座。" + "lore_loc": "天怒王国的当朝法师只能使用他们的法力来保卫王国和王座。", + "tags": [] }, { "key": "skywrath_mage_mystic_flare", @@ -30009,7 +30861,8 @@ "value": "800 / 1200 / 1600" } ], - "lore_loc": "天怒一族中只有勤加修炼的法师才能将苍天塑造成这样的风暴。" + "lore_loc": "天怒一族中只有勤加修炼的法师才能将苍天塑造成这样的风暴。", + "tags": [] } ], "talents": [ @@ -30110,7 +30963,10 @@ "value": "95 / 170 / 245 / 320" } ], - "lore_loc": "亚巴顿的气息中充盈着来自魔霭圣池的神秘之汽,他能随意将其释放。" + "lore_loc": "亚巴顿的气息中充盈着来自魔霭圣池的神秘之汽,他能随意将其释放。", + "tags": [ + "root" + ] }, { "key": "abaddon_aphotic_shield", @@ -30153,7 +31009,10 @@ "value": "0" } ], - "lore_loc": "升腾的黑雾将敌人的招数一一吞噬,如同吞噬日光。" + "lore_loc": "升腾的黑雾将敌人的招数一一吞噬,如同吞噬日光。", + "tags": [ + "basic_dispel" + ] }, { "key": "abaddon_frostmourne", @@ -30196,7 +31055,8 @@ "value": "15 / 25 / 35 / 45" } ], - "lore_loc": "受洗之人能从圣池中汲取能量,而魔霭的恩赐会让一无所知的人逐渐衰弱。" + "lore_loc": "受洗之人能从圣池中汲取能量,而魔霭的恩赐会让一无所知的人逐渐衰弱。", + "tags": [] }, { "key": "abaddon_withering_mist", @@ -30227,7 +31087,8 @@ "value": "29.5%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "abaddon_borrowed_time", @@ -30266,7 +31127,11 @@ "value": "0" } ], - "lore_loc": "魔霭圣池中最为扭曲的馈赠,凡人对死亡的认知在此力量前显得异常可笑。敌之尖刀,我之伤药;死地后生,回光返照。" + "lore_loc": "魔霭圣池中最为扭曲的馈赠,凡人对死亡的认知在此力量前显得异常可笑。敌之尖刀,我之伤药;死地后生,回光返照。", + "tags": [ + "strong_dispel", + "root" + ] } ], "talents": [ @@ -30383,7 +31248,8 @@ "value": "14 / 13 / 12 / 11" } ], - "lore_loc": "创世之力依然在巨神的重踏下回响。" + "lore_loc": "创世之力依然在巨神的重踏下回响。", + "tags": [] }, { "key": "elder_titan_ancestral_spirit", @@ -30442,7 +31308,8 @@ "value": "17 / 38 / 59 / 80" } ], - "lore_loc": "就像四大基本法则,上古巨神在所有位面同时存在,可以在需要协助的时候将其他位面的自己拖入。" + "lore_loc": "就像四大基本法则,上古巨神在所有位面同时存在,可以在需要协助的时候将其他位面的自己拖入。", + "tags": [] }, { "key": "elder_titan_natural_order", @@ -30481,7 +31348,8 @@ "value": "40 / 60 / 80 / 100%" } ], - "lore_loc": "上古巨神又让创世之初重现。" + "lore_loc": "上古巨神又让创世之初重现。", + "tags": [] }, { "key": "elder_titan_momentum", @@ -30512,7 +31380,8 @@ "value": "5%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "elder_titan_earth_splitter", @@ -30563,7 +31432,8 @@ "value": "34 / 42 / 50%" } ], - "lore_loc": "巨神创造之世,他亲手将其撕裂。" + "lore_loc": "巨神创造之世,他亲手将其撕裂。", + "tags": [] } ], "talents": [ @@ -30688,7 +31558,8 @@ "value": "0" } ], - "lore_loc": "石堂城的弓箭手就等特蕾丝汀一声令下。" + "lore_loc": "石堂城的弓箭手就等特蕾丝汀一声令下。", + "tags": [] }, { "key": "legion_commander_press_the_attack", @@ -30727,7 +31598,10 @@ "value": "24 / 36 / 48 / 60" } ], - "lore_loc": "古铜军团的重振号角可以鼓舞人心。" + "lore_loc": "古铜军团的重振号角可以鼓舞人心。", + "tags": [ + "basic_dispel" + ] }, { "key": "legion_commander_moment_of_courage", @@ -30762,7 +31636,8 @@ "value": "55 / 65 / 75 / 85%" } ], - "lore_loc": "特蕾丝汀知道,敌人出招最狠的时候也是他们最脆弱的时候。" + "lore_loc": "特蕾丝汀知道,敌人出招最狠的时候也是他们最脆弱的时候。", + "tags": [] }, { "key": "legion_commander_outfight_them", @@ -30793,7 +31668,8 @@ "value": "1" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "legion_commander_duel", @@ -30828,7 +31704,10 @@ "value": "10 / 20 / 30" } ], - "lore_loc": "与石堂城的士兵进行单挑是令人无法抗拒的挑战。" + "lore_loc": "与石堂城的士兵进行单挑是令人无法抗拒的挑战。", + "tags": [ + "taunt" + ] } ], "talents": [ @@ -30945,7 +31824,8 @@ "value": "350" } ], - "lore_loc": "这样革命性的才华本应与他人分享。" + "lore_loc": "这样革命性的才华本应与他人分享。", + "tags": [] }, { "key": "techies_reactive_tazer", @@ -30992,7 +31872,10 @@ "value": "60 / 110 / 160 / 210" } ], - "lore_loc": "想要双手稳稳地握住炸弹更需要大量个人空间。" + "lore_loc": "想要双手稳稳地握住炸弹更需要大量个人空间。", + "tags": [ + "disarm" + ] }, { "key": "techies_suicide", @@ -31035,7 +31918,10 @@ "value": "20%" } ], - "lore_loc": "我们是要去哪?" + "lore_loc": "我们是要去哪?", + "tags": [ + "stun" + ] }, { "key": "techies_mutually_assured_destruction", @@ -31086,7 +31972,8 @@ "value": "0" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "techies_minefield_sign", @@ -31129,7 +32016,8 @@ "value": "200" } ], - "lore_loc": "别四处乱跑!" + "lore_loc": "别四处乱跑!", + "tags": [] }, { "key": "techies_land_mines", @@ -31180,7 +32068,8 @@ "value": "4" } ], - "lore_loc": "起重港的噩梦!" + "lore_loc": "起重港的噩梦!", + "tags": [] } ], "talents": [ @@ -31289,7 +32178,10 @@ "value": "2" } ], - "lore_loc": "炘之严训,炽链相伴。" + "lore_loc": "炘之严训,炽链相伴。", + "tags": [ + "root" + ] }, { "key": "ember_spirit_sleight_of_fist", @@ -31328,7 +32220,8 @@ "value": "0.25" } ], - "lore_loc": "顷刻之间,鞭策众敌。" + "lore_loc": "顷刻之间,鞭策众敌。", + "tags": [] }, { "key": "ember_spirit_flame_guard", @@ -31375,7 +32268,8 @@ "value": "60 / 140 / 220 / 300" } ], - "lore_loc": "近身之敌,自讨苦吃。" + "lore_loc": "近身之敌,自讨苦吃。", + "tags": [] }, { "key": "ember_spirit_activate_fire_remnant", @@ -31401,7 +32295,8 @@ "cooldown": "0", "mana_cost": "100 / 125 / 150", "specials": [], - "lore_loc": "燎原之火,瞬息将至!" + "lore_loc": "燎原之火,瞬息将至!", + "tags": [] }, { "key": "ember_spirit_immolation", @@ -31436,7 +32331,8 @@ "value": "10" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "ember_spirit_fire_remnant", @@ -31491,7 +32387,8 @@ "value": "0" } ], - "lore_loc": "身怀灵力,重焕教义。" + "lore_loc": "身怀灵力,重焕教义。", + "tags": [] } ], "talents": [ @@ -31612,7 +32509,8 @@ "value": "2000" } ], - "lore_loc": "大地之灵的一招一式都蕴含着移山之力。" + "lore_loc": "大地之灵的一招一式都蕴含着移山之力。", + "tags": [] }, { "key": "earth_spirit_rolling_boulder", @@ -31667,7 +32565,10 @@ "value": "0.3 / 0.5 / 0.7 / 0.9" } ], - "lore_loc": "大地之灵将自己与大地紧密结合,使松散的石块吸附到身上,从而组成一个防护圆球向前滚动。" + "lore_loc": "大地之灵将自己与大地紧密结合,使松散的石块吸附到身上,从而组成一个防护圆球向前滚动。", + "tags": [ + "stun" + ] }, { "key": "earth_spirit_geomagnetic_grip", @@ -31714,7 +32615,10 @@ "value": "550 / 600 / 650 / 700" } ], - "lore_loc": "同性相吸。即使是深藏于鲜血和骨头中的矿物都不能免疫大地之灵的呼唤。" + "lore_loc": "同性相吸。即使是深藏于鲜血和骨头中的矿物都不能免疫大地之灵的呼唤。", + "tags": [ + "silence" + ] }, { "key": "earth_spirit_stone_caller", @@ -31749,7 +32653,8 @@ "value": "7" } ], - "lore_loc": "大地之灵唤出的残岩源自与他一同在大地黑暗的怀抱下埋藏多年的兵马俑。" + "lore_loc": "大地之灵唤出的残岩源自与他一同在大地黑暗的怀抱下埋藏多年的兵马俑。", + "tags": [] }, { "key": "earth_spirit_petrify", @@ -31792,7 +32697,8 @@ "value": "500" } ], - "lore_loc": "考嶙操控大地的元素之力,使生命体暂时石化,将其收编于他的兵马俑大军中。" + "lore_loc": "考嶙操控大地的元素之力,使生命体暂时石化,将其收编于他的兵马俑大军中。", + "tags": [] }, { "key": "earth_spirit_magnetize", @@ -31835,7 +32741,8 @@ "value": "600" } ], - "lore_loc": "在大地之灵的呼唤下,敌人体内的鲜血和骨头中所含的矿物都开始倒戈。" + "lore_loc": "在大地之灵的呼唤下,敌人体内的鲜血和骨头中所含的矿物都开始倒戈。", + "tags": [] } ], "talents": [ @@ -31960,7 +32867,8 @@ "value": "0%" } ], - "lore_loc": "征战的烈焰从深渊最黑暗的深处向外迸发。" + "lore_loc": "征战的烈焰从深渊最黑暗的深处向外迸发。", + "tags": [] }, { "key": "abyssal_underlord_pit_of_malice", @@ -32007,7 +32915,10 @@ "value": "1.5 / 1.6 / 1.7 / 1.8" } ], - "lore_loc": "扭曲成现实本身的接缝,维洛格罗斯昭然的仇恨让蔑视他意志的人动弹不得。" + "lore_loc": "扭曲成现实本身的接缝,维洛格罗斯昭然的仇恨让蔑视他意志的人动弹不得。", + "tags": [ + "root" + ] }, { "key": "abyssal_underlord_atrophy_aura", @@ -32054,7 +32965,8 @@ "value": "30 / 40 / 50 / 60" } ], - "lore_loc": "光是站在孽主面前就能感受到自己的战意从灵魂里被榨干。" + "lore_loc": "光是站在孽主面前就能感受到自己的战意从灵魂里被榨干。", + "tags": [] }, { "key": "abyssal_underlord_raid_boss", @@ -32093,7 +33005,8 @@ "value": "5" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "abyssal_underlord_dark_portal", @@ -32128,7 +33041,8 @@ "value": "110 / 105 / 100" } ], - "lore_loc": "他们的到来全无征兆,王国曾经繁荣的地方只剩下烈火和血海。" + "lore_loc": "他们的到来全无征兆,王国曾经繁荣的地方只剩下烈火和血海。", + "tags": [] } ], "talents": [ @@ -32241,7 +33155,8 @@ "value": "400" } ], - "lore_loc": "荒邪之狱的分形之墙中,恐怖利刃洞悉了古老的寓言:你就是自己最可怕的敌人。现在他也要让别人明白这个道理。" + "lore_loc": "荒邪之狱的分形之墙中,恐怖利刃洞悉了古老的寓言:你就是自己最可怕的敌人。现在他也要让别人明白这个道理。", + "tags": [] }, { "key": "terrorblade_conjure_image", @@ -32284,7 +33199,8 @@ "value": "0%" } ], - "lore_loc": "比面对恐怖利刃更可怕的只有这么种情况:面对成倍的恐怖利刃!" + "lore_loc": "比面对恐怖利刃更可怕的只有这么种情况:面对成倍的恐怖利刃!", + "tags": [] }, { "key": "terrorblade_metamorphosis", @@ -32335,7 +33251,8 @@ "value": "1200" } ], - "lore_loc": "暴躁生气,渐起怒意,激变形体。见识一下恐怖利刃最为恐怖的一面吧。" + "lore_loc": "暴躁生气,渐起怒意,激变形体。见识一下恐怖利刃最为恐怖的一面吧。", + "tags": [] }, { "key": "terrorblade_demon_zeal", @@ -32382,7 +33299,8 @@ "value": "20%" } ], - "lore_loc": "当恐怖利刃的敌人发现他砍出的第一刀是为了给自己壮胆,他们的困惑加剧了恐惧。" + "lore_loc": "当恐怖利刃的敌人发现他砍出的第一刀是为了给自己壮胆,他们的困惑加剧了恐惧。", + "tags": [] }, { "key": "terrorblade_terror_wave", @@ -32425,7 +33343,10 @@ "value": "15" } ], - "lore_loc": "一道威力十足的冲击波,可以冲破荒邪之狱的障壁。" + "lore_loc": "一道威力十足的冲击波,可以冲破荒邪之狱的障壁。", + "tags": [ + "fear" + ] }, { "key": "terrorblade_sunder", @@ -32456,7 +33377,8 @@ "value": "35 / 30 / 25%" } ], - "lore_loc": "你是不是不想要这条命?恶魔掠夺者偷走的正是你最为珍视的。" + "lore_loc": "你是不是不想要这条命?恶魔掠夺者偷走的正是你最为珍视的。", + "tags": [] }, { "key": "terrorblade_dark_unity", @@ -32491,7 +33413,8 @@ "value": "60%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -32604,7 +33527,8 @@ "value": "10 / 15 / 20 / 25%" } ], - "lore_loc": "尽管没少在宇宙虚空之间遨游,但在大气里振翅高飞还是有不同的快感。" + "lore_loc": "尽管没少在宇宙虚空之间遨游,但在大气里振翅高飞还是有不同的快感。", + "tags": [] }, { "key": "phoenix_fire_spirits", @@ -32655,7 +33579,8 @@ "value": "20 / 40 / 60 / 80" } ], - "lore_loc": "凤凰那不断坍塌重生的内核经常释放出具有意识的霎那光流。" + "lore_loc": "凤凰那不断坍塌重生的内核经常释放出具有意识的霎那光流。", + "tags": [] }, { "key": "phoenix_sun_ray", @@ -32706,7 +33631,8 @@ "value": "6" } ], - "lore_loc": "星体能量的这般释放对凤凰来说仅仅是打开了原子大小的豁口。" + "lore_loc": "星体能量的这般释放对凤凰来说仅仅是打开了原子大小的豁口。", + "tags": [] }, { "key": "phoenix_dying_light", @@ -32732,7 +33658,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "phoenix_supernova", @@ -32787,7 +33714,11 @@ "value": "6" } ], - "lore_loc": "超新星的烈日试炼致命非常,而从烈焰中,永生的凤吟将再一次升起。" + "lore_loc": "超新星的烈日试炼致命非常,而从烈焰中,永生的凤吟将再一次升起。", + "tags": [ + "strong_dispel", + "stun" + ] } ], "talents": [ @@ -32900,7 +33831,11 @@ "value": "350" } ], - "lore_loc": "星体天球充沛的力量让四周充斥着静电爆裂声,而生能射向八方,使敌人暂时魂魄出窍。" + "lore_loc": "星体天球充沛的力量让四周充斥着静电爆裂声,而生能射向八方,使敌人暂时魂魄出窍。", + "tags": [ + "basic_dispel", + "root" + ] }, { "key": "oracle_fates_edict", @@ -32931,7 +33866,10 @@ "value": "3.5 / 4 / 4.5 / 5" } ], - "lore_loc": "无法逾越的预言四处回响:神选之友将暂时不受魔法的折磨。但是折磨还有很多种……" + "lore_loc": "无法逾越的预言四处回响:神选之友将暂时不受魔法的折磨。但是折磨还有很多种……", + "tags": [ + "disarm" + ] }, { "key": "oracle_purifying_flames", @@ -32974,7 +33912,8 @@ "value": "10" } ], - "lore_loc": "一支蜡烛的微光在挂满镜子的厅堂中异常耀眼,无独有偶,破碎的宇宙之壁可以将预言的微光变成炫目的火炬。" + "lore_loc": "一支蜡烛的微光在挂满镜子的厅堂中异常耀眼,无独有偶,破碎的宇宙之壁可以将预言的微光变成炫目的火炬。", + "tags": [] }, { "key": "oracle_rain_of_destiny", @@ -33017,7 +33956,8 @@ "value": "15%" } ], - "lore_loc": "通过将精神与大气相混合,奈里夫把单一的现实扭曲成矛盾的结合。" + "lore_loc": "通过将精神与大气相混合,奈里夫把单一的现实扭曲成矛盾的结合。", + "tags": [] }, { "key": "oracle_false_promise", @@ -33052,7 +33992,10 @@ "value": "100%" } ], - "lore_loc": "敌人和虚妄的先知一样常常会编造他人的命运。" + "lore_loc": "敌人和虚妄的先知一样常常会编造他人的命运。", + "tags": [ + "strong_dispel" + ] }, { "key": "oracle_prognosticate", @@ -33078,7 +34021,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -33199,7 +34143,8 @@ "value": "0%" } ], - "lore_loc": "其他龙点燃龙炎的器官在傲洛斯体内熔炼的是难以想象的寒气。从她咽喉的深处呼出的气息会将敌人送入烧灼的痛苦中。" + "lore_loc": "其他龙点燃龙炎的器官在傲洛斯体内熔炼的是难以想象的寒气。从她咽喉的深处呼出的气息会将敌人送入烧灼的痛苦中。", + "tags": [] }, { "key": "winter_wyvern_splinter_blast", @@ -33242,7 +34187,8 @@ "value": "80 / 160 / 240 / 320" } ], - "lore_loc": "这些冰渣由她冰冻的咽喉中特殊的囊泡慢慢生成,寒冬飞龙正借此轰击她的敌人。" + "lore_loc": "这些冰渣由她冰冻的咽喉中特殊的囊泡慢慢生成,寒冬飞龙正借此轰击她的敌人。", + "tags": [] }, { "key": "winter_wyvern_cold_embrace", @@ -33289,7 +34235,8 @@ "value": "0" } ], - "lore_loc": "寒冬之心中有着寒冷而治愈的拥抱。坚冰减缓了血液的流动,使治疗的魔力发挥得更加彻底。" + "lore_loc": "寒冬之心中有着寒冷而治愈的拥抱。坚冰减缓了血液的流动,使治疗的魔力发挥得更加彻底。", + "tags": [] }, { "key": "winter_wyvern_eldwurms_edda", @@ -33315,7 +34262,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "winter_wyvern_winters_curse", @@ -33370,7 +34318,8 @@ "value": "0.5" } ], - "lore_loc": "这个诅咒在书龙咒语中最为古老,也是那句老话的真相:有时候我的敌人的敌人还是我的敌人。" + "lore_loc": "这个诅咒在书龙咒语中最为古老,也是那句老话的真相:有时候我的敌人的敌人还是我的敌人。", + "tags": [] } ], "talents": [ @@ -33479,7 +34428,10 @@ "value": "20 / 25 / 30 / 35%" } ], - "lore_loc": "囚禁遗迹之力的无穷小碎片。" + "lore_loc": "囚禁遗迹之力的无穷小碎片。", + "tags": [ + "silence" + ] }, { "key": "arc_warden_magnetic_field", @@ -33530,7 +34482,8 @@ "value": "800 / 1200 / 1600 / 2000" } ], - "lore_loc": "时间与空间对于古老如泽特之人实在是无足轻重。" + "lore_loc": "时间与空间对于古老如泽特之人实在是无足轻重。", + "tags": [] }, { "key": "arc_warden_spark_wraith", @@ -33585,7 +34538,8 @@ "value": "0.7 / 0.8 / 0.9 / 1" } ], - "lore_loc": "泽特原始自我的低级碎片。" + "lore_loc": "泽特原始自我的低级碎片。", + "tags": [] }, { "key": "arc_warden_runic_infusion", @@ -33611,7 +34565,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "arc_warden_tempest_double", @@ -33654,7 +34609,8 @@ "value": "35%" } ], - "lore_loc": "被同类的力量扭曲后,天穹守望者变成了自己最为不齿的:大分裂。" + "lore_loc": "被同类的力量扭曲后,天穹守望者变成了自己最为不齿的:大分裂。", + "tags": [] } ], "talents": [ @@ -33763,7 +34719,10 @@ "value": "1100" } ], - "lore_loc": "传说中的金箍棒根据主人的意愿变大变长,确保敌人都逃不出齐天大圣的手心。" + "lore_loc": "传说中的金箍棒根据主人的意愿变大变长,确保敌人都逃不出齐天大圣的手心。", + "tags": [ + "stun" + ] }, { "key": "monkey_king_tree_dance", @@ -33798,7 +34757,10 @@ "value": "3" } ], - "lore_loc": "孙悟空在树顶上灵活地跳跃,总是一跃而至。" + "lore_loc": "孙悟空在树顶上灵活地跳跃,总是一跃而至。", + "tags": [ + "stun" + ] }, { "key": "monkey_king_jingu_mastery", @@ -33845,7 +34807,8 @@ "value": "35" } ], - "lore_loc": "金箍棒能敏锐感知主人的情绪,在孙悟空展现战意时散发力量。" + "lore_loc": "金箍棒能敏锐感知主人的情绪,在孙悟空展现战意时散发力量。", + "tags": [] }, { "key": "monkey_king_mischief", @@ -33880,7 +34843,8 @@ "value": "24.5" } ], - "lore_loc": "要小心那棵树。" + "lore_loc": "要小心那棵树。", + "tags": [] }, { "key": "monkey_king_wukongs_command", @@ -33923,7 +34887,8 @@ "value": "0" } ], - "lore_loc": "齐天大圣将一簇猴毛吹向敌人,每根毛都变成了自己的复制体。" + "lore_loc": "齐天大圣将一簇猴毛吹向敌人,每根毛都变成了自己的复制体。", + "tags": [] }, { "key": "monkey_king_transfiguration", @@ -33949,7 +34914,8 @@ "cooldown": "5", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -34054,7 +35020,10 @@ "value": "50 / 55 / 60 / 65" } ], - "lore_loc": "仙灵之境将你环绕,只是在等着让你深陷……" + "lore_loc": "仙灵之境将你环绕,只是在等着让你深陷……", + "tags": [ + "root" + ] }, { "key": "dark_willow_shadow_realm", @@ -34089,7 +35058,8 @@ "value": "120 / 200 / 280 / 360" } ], - "lore_loc": "你看不见的东西肯定会害你。" + "lore_loc": "你看不见的东西肯定会害你。", + "tags": [] }, { "key": "dark_willow_cursed_crown", @@ -34128,7 +35098,10 @@ "value": "360" } ], - "lore_loc": "没人告诉过你吗?千万别和仙女打交道……" + "lore_loc": "没人告诉过你吗?千万别和仙女打交道……", + "tags": [ + "stun" + ] }, { "key": "dark_willow_bedlam", @@ -34171,7 +35144,8 @@ "value": "5.5" } ], - "lore_loc": "你得原谅我的朋友杰克斯。他是有点杀红了眼。" + "lore_loc": "你得原谅我的朋友杰克斯。他是有点杀红了眼。", + "tags": [] }, { "key": "dark_willow_pixie_dust", @@ -34197,7 +35171,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "dark_willow_terrorize", @@ -34232,7 +35207,10 @@ "value": "2.8 / 3 / 3.2" } ], - "lore_loc": "你觉得你知道恐惧的意思?杰克斯会让你见识这词的真谛……" + "lore_loc": "你觉得你知道恐惧的意思?杰克斯会让你见识这词的真谛……", + "tags": [ + "fear" + ] } ], "talents": [ @@ -34353,7 +35331,8 @@ "value": "100%" } ], - "lore_loc": "石鳞剑士的剑比舌头还要灵活。" + "lore_loc": "石鳞剑士的剑比舌头还要灵活。", + "tags": [] }, { "key": "pangolier_shield_crash", @@ -34408,7 +35387,8 @@ "value": "0%" } ], - "lore_loc": "只有被敌人层层包围,石鳞剑士才能真正地一展雄风。" + "lore_loc": "只有被敌人层层包围,石鳞剑士才能真正地一展雄风。", + "tags": [] }, { "key": "pangolier_lucky_shot", @@ -34451,7 +35431,8 @@ "value": "2 / 4 / 6 / 8" } ], - "lore_loc": "石鳞剑士出剑更多时候是因心血来潮,而非理智。" + "lore_loc": "石鳞剑士出剑更多时候是因心血来潮,而非理智。", + "tags": [] }, { "key": "pangolier_rollup", @@ -34486,7 +35467,10 @@ "value": "0.75" } ], - "lore_loc": "在战场的中心,需要灵巧的尾巴和极强的平衡感才能抵挡住各方的来势。" + "lore_loc": "在战场的中心,需要灵巧的尾巴和极强的平衡感才能抵挡住各方的来势。", + "tags": [ + "stun" + ] }, { "key": "pangolier_gyroshell", @@ -34541,7 +35525,10 @@ "value": "10 / 11 / 12" } ], - "lore_loc": "他的祖先只能找到安全感的地方,石鳞剑士却看见了机遇……" + "lore_loc": "他的祖先只能找到安全感的地方,石鳞剑士却看见了机遇……", + "tags": [ + "stun" + ] }, { "key": "pangolier_fortune_favors_the_bold", @@ -34576,7 +35563,8 @@ "value": "-50%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -34689,7 +35677,8 @@ "value": "1.75" } ], - "lore_loc": "天涯墨客的墨迹带有腐化的力量,吸取自他死去的族人,将吞噬抓住的任何敌人。" + "lore_loc": "天涯墨客的墨迹带有腐化的力量,吸取自他死去的族人,将吞噬抓住的任何敌人。", + "tags": [] }, { "key": "grimstroke_ink_creature", @@ -34732,7 +35721,10 @@ "value": "120 / 200 / 280 / 360" } ], - "lore_loc": "天涯墨客并不为族人的毁灭感到悲伤。只有对瑶薇,他最亲近的女子,他才稍稍感觉到思念。" + "lore_loc": "天涯墨客并不为族人的毁灭感到悲伤。只有对瑶薇,他最亲近的女子,他才稍稍感觉到思念。", + "tags": [ + "silence" + ] }, { "key": "grimstroke_spirit_walk", @@ -34787,7 +35779,10 @@ "value": "2.5" } ], - "lore_loc": "既然高涨的墨潮不再对天涯墨客构成威胁,那么力量的墨须就不难为他所用。" + "lore_loc": "既然高涨的墨潮不再对天涯墨客构成威胁,那么力量的墨须就不难为他所用。", + "tags": [ + "stun" + ] }, { "key": "grimstroke_dark_portrait", @@ -34830,7 +35825,8 @@ "value": "30%" } ], - "lore_loc": "没有暗黑的创造能逃得过大师之手的技艺。" + "lore_loc": "没有暗黑的创造能逃得过大师之手的技艺。", + "tags": [] }, { "key": "grimstroke_soul_chain", @@ -34865,7 +35861,10 @@ "value": "600" } ], - "lore_loc": "飞升之人皆为烬州城的选召之子,通过自己的灵魂和他们应守护的族人之间创造的束缚获得了巨大的力量。在同胞毁灭的多年之后,天涯墨客发现束缚仪式更为凶险的用途……" + "lore_loc": "飞升之人皆为烬州城的选召之子,通过自己的灵魂和他们应守护的族人之间创造的束缚获得了巨大的力量。在同胞毁灭的多年之后,天涯墨客发现束缚仪式更为凶险的用途……", + "tags": [ + "leash" + ] }, { "key": "grimstroke_ink_trail", @@ -34896,7 +35895,8 @@ "value": "5%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -35009,7 +36009,8 @@ "value": "100%" } ], - "lore_loc": "虽然她几乎什么都能凑合着用,不过橡树和铁树之木的栗子是最有效的炮弹——也是森海飞霞大部分的存货来源。" + "lore_loc": "虽然她几乎什么都能凑合着用,不过橡树和铁树之木的栗子是最有效的炮弹——也是森海飞霞大部分的存货来源。", + "tags": [] }, { "key": "hoodwink_bushwhack", @@ -35048,7 +36049,10 @@ "value": "90 / 180 / 270 / 360" } ], - "lore_loc": "森海飞霞喜欢利用铁树之木的树丛来伏击,但机警的人在永雾林渊的所有地方都会小心行走。" + "lore_loc": "森海飞霞喜欢利用铁树之木的树丛来伏击,但机警的人在永雾林渊的所有地方都会小心行走。", + "tags": [ + "stun" + ] }, { "key": "hoodwink_scurry", @@ -35083,7 +36087,8 @@ "value": "3.5 / 4 / 4.5 / 5" } ], - "lore_loc": "托莫干森林里没有一处角落或者裂缝是森海飞霞的爪子够不着的。" + "lore_loc": "托莫干森林里没有一处角落或者裂缝是森海飞霞的爪子够不着的。", + "tags": [] }, { "key": "hoodwink_decoy", @@ -35130,7 +36135,8 @@ "value": "60%" } ], - "lore_loc": "最好的陷阱是不论有没有碰到都会触发。" + "lore_loc": "最好的陷阱是不论有没有碰到都会触发。", + "tags": [] }, { "key": "hoodwink_hunters_boomerang", @@ -35177,7 +36183,8 @@ "value": "20%" } ], - "lore_loc": "森海飞霞童年的宝贵残存变成了致命的消遣。" + "lore_loc": "森海飞霞童年的宝贵残存变成了致命的消遣。", + "tags": [] }, { "key": "hoodwink_sharpshooter", @@ -35228,7 +36235,10 @@ "value": "45" } ], - "lore_loc": "虽然森海飞霞的第一把弩机是碰巧发现的,但现在她非常精心地雕刻她自己的劲弩,并且做好防护工作 - 当然,某些必要的部件是顺手牵羊得来的,不然就是通过机智的手段获得。" + "lore_loc": "虽然森海飞霞的第一把弩机是碰巧发现的,但现在她非常精心地雕刻她自己的劲弩,并且做好防护工作 - 当然,某些必要的部件是顺手牵羊得来的,不然就是通过机智的手段获得。", + "tags": [ + "break" + ] }, { "key": "hoodwink_mistwoods_wayfarer", @@ -35263,7 +36273,8 @@ "value": "14%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -35376,7 +36387,10 @@ "value": "17" } ], - "lore_loc": "无玄的存在远超时间和空间中的一个点。" + "lore_loc": "无玄的存在远超时间和空间中的一个点。", + "tags": [ + "stun" + ] }, { "key": "void_spirit_dissimilate", @@ -35415,7 +36429,8 @@ "value": "0%" } ], - "lore_loc": "无玄更愿意脱离肉体的限制,自由地观察我们的位面。" + "lore_loc": "无玄更愿意脱离肉体的限制,自由地观察我们的位面。", + "tags": [] }, { "key": "void_spirit_resonant_pulse", @@ -35470,7 +36485,10 @@ "value": "0" } ], - "lore_loc": "被迫与物质位面互动时,无玄从不犹豫将自己包裹在本真的精华中。" + "lore_loc": "被迫与物质位面互动时,无玄从不犹豫将自己包裹在本真的精华中。", + "tags": [ + "silence" + ] }, { "key": "void_spirit_intrinsic_edge", @@ -35513,7 +36531,8 @@ "value": "0" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "void_spirit_astral_step", @@ -35552,7 +36571,8 @@ "value": "40 / 60 / 80%" } ], - "lore_loc": "无玄的一小步就能在凡间引发极大的毁灭。" + "lore_loc": "无玄的一小步就能在凡间引发极大的毁灭。", + "tags": [] } ], "talents": [ @@ -35665,7 +36685,8 @@ "value": "450" } ], - "lore_loc": "碧婆婆珍藏中的最爱,她打造这把“老坏蛋”作为自己闯入世外之地的首道防线。" + "lore_loc": "碧婆婆珍藏中的最爱,她打造这把“老坏蛋”作为自己闯入世外之地的首道防线。", + "tags": [] }, { "key": "snapfire_firesnap_cookie", @@ -35712,7 +36733,10 @@ "value": "0" } ], - "lore_loc": "在神秘配方的帮助下,碧婆婆的烘焙技艺和她对黑火药的热情相得益彰,这些战斗饼干相当猛烈。" + "lore_loc": "在神秘配方的帮助下,碧婆婆的烘焙技艺和她对黑火药的热情相得益彰,这些战斗饼干相当猛烈。", + "tags": [ + "stun" + ] }, { "key": "snapfire_lil_shredder", @@ -35759,7 +36783,8 @@ "value": "5" } ], - "lore_loc": "碧婆婆一直在修补她原来为秘密计划离开劫匪帮所设计的作品。" + "lore_loc": "碧婆婆一直在修补她原来为秘密计划离开劫匪帮所设计的作品。", + "tags": [] }, { "key": "snapfire_gobble_up", @@ -35785,7 +36810,8 @@ "cooldown": "40", "mana_cost": "120", "specials": [], - "lore_loc": "哧!" + "lore_loc": "哧!", + "tags": [] }, { "key": "snapfire_spit_creep", @@ -35820,7 +36846,10 @@ "value": "1.2" } ], - "lore_loc": "噗!" + "lore_loc": "噗!", + "tags": [ + "stun" + ] }, { "key": "snapfire_mortimer_kisses", @@ -35871,7 +36900,8 @@ "value": "3.5" } ], - "lore_loc": "碧婆婆收养小莫迪时她一无所知,龙蜥——根本都不是蜥蜴——和他们遥远的龙族表亲并非只是名字一样而已。" + "lore_loc": "碧婆婆收养小莫迪时她一无所知,龙蜥——根本都不是蜥蜴——和他们遥远的龙族表亲并非只是名字一样而已。", + "tags": [] }, { "key": "snapfire_boomstick", @@ -35914,7 +36944,8 @@ "value": "50" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -36039,7 +37070,10 @@ "value": "0" } ], - "lore_loc": "玛尔斯的传奇战矛上依然闪耀着熔铸之神烈焰的热力。" + "lore_loc": "玛尔斯的传奇战矛上依然闪耀着熔铸之神烈焰的热力。", + "tags": [ + "stun" + ] }, { "key": "mars_gods_rebuke", @@ -36086,7 +37120,8 @@ "value": "5 / 10 / 15 / 20" } ], - "lore_loc": "在玛尔斯谴戒的刺痛下世上还没有战士能纹丝不动。" + "lore_loc": "在玛尔斯谴戒的刺痛下世上还没有战士能纹丝不动。", + "tags": [] }, { "key": "mars_bulwark", @@ -36129,7 +37164,8 @@ "value": "18%" } ], - "lore_loc": "只有蠢到家了才胆敢与战神公平作战。" + "lore_loc": "只有蠢到家了才胆敢与战神公平作战。", + "tags": [] }, { "key": "mars_dauntless", @@ -36160,7 +37196,8 @@ "value": "50%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "mars_arena_of_blood", @@ -36203,7 +37240,10 @@ "value": "0.1" } ], - "lore_loc": "余烬军团死去已久的成员,从考验玛尔斯力量的顶尖战士中集结而来,他们还依然陶醉在战斗的刺激之中,只要他下达命令,他们就欣然应战。" + "lore_loc": "余烬军团死去已久的成员,从考验玛尔斯力量的顶尖战士中集结而来,他们还依然陶醉在战斗的刺激之中,只要他下达命令,他们就欣然应战。", + "tags": [ + "leash" + ] } ], "talents": [ @@ -36320,7 +37360,8 @@ "value": "0.6 / 0.8 / 1 / 1.2" } ], - "lore_loc": "从难以取悦的观众到难以驾驭的表演,对于一群动物,百戏大王手中的长鞭最具有说服力。" + "lore_loc": "从难以取悦的观众到难以驾驭的表演,对于一群动物,百戏大王手中的长鞭最具有说服力。", + "tags": [] }, { "key": "ringmaster_the_box", @@ -36375,7 +37416,8 @@ "value": "0" } ], - "lore_loc": "熟练的助手在道具箱内不必心惊胆战。而业余的助手……前景就黯淡多了。" + "lore_loc": "熟练的助手在道具箱内不必心惊胆战。而业余的助手……前景就黯淡多了。", + "tags": [] }, { "key": "ringmaster_impalement", @@ -36426,7 +37468,8 @@ "value": "0.5 / 0.6 / 0.7 / 0.8" } ], - "lore_loc": "代代相传的表演,少了伟大的科里奥斯特罗的招牌戏法,那就不能称之为卡特尔马戏团。" + "lore_loc": "代代相传的表演,少了伟大的科里奥斯特罗的招牌戏法,那就不能称之为卡特尔马戏团。", + "tags": [] }, { "key": "ringmaster_spotlight", @@ -36469,7 +37512,8 @@ "value": "8" } ], - "lore_loc": "上台的十五秒你准备好了吗?聚光灯下不必在意自己的名声。" + "lore_loc": "上台的十五秒你准备好了吗?聚光灯下不必在意自己的名声。", + "tags": [] }, { "key": "ringmaster_wheel", @@ -36516,7 +37560,8 @@ "value": "300 / 450 / 600" } ], - "lore_loc": "不,勇敢的朋友们,幻术是骗子和赌徒的把戏。今天你见证的是魔法的真正力量。但前提是要敢于凝视……迷人的转轮。" + "lore_loc": "不,勇敢的朋友们,幻术是骗子和赌徒的把戏。今天你见证的是魔法的真正力量。但前提是要敢于凝视……迷人的转轮。", + "tags": [] }, { "key": "ringmaster_dark_carnival_souvenirs", @@ -36542,7 +37587,8 @@ "cooldown": "0", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "ringmaster_funhouse_mirror", @@ -36568,7 +37614,8 @@ "cooldown": "3", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "ringmaster_strongman_tonic", @@ -36594,7 +37641,8 @@ "cooldown": "3", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "ringmaster_whoopee_cushion", @@ -36620,7 +37668,8 @@ "cooldown": "3", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "ringmaster_summon_unicycle", @@ -36646,7 +37695,8 @@ "cooldown": "3", "mana_cost": "0", "specials": [], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -36763,7 +37813,10 @@ "value": "0 / 0 / 0 / 0" } ], - "lore_loc": "在星辰后裔的庞大血脉中,有许多不愿追随光之子。对于胆敢挑战的星宿,光之子就会派出维罗拉和她的重锤,展示出他们的力量——让反对者粉身碎骨或幡然醒悟。" + "lore_loc": "在星辰后裔的庞大血脉中,有许多不愿追随光之子。对于胆敢挑战的星宿,光之子就会派出维罗拉和她的重锤,展示出他们的力量——让反对者粉身碎骨或幡然醒悟。", + "tags": [ + "stun" + ] }, { "key": "dawnbreaker_celestial_hammer", @@ -36814,7 +37867,8 @@ "value": "700 / 900 / 1100 / 1300" } ], - "lore_loc": "光明之锤是维罗拉最长久的伙伴,也是她与创造者们的光芒之间仅存的维系。" + "lore_loc": "光明之锤是维罗拉最长久的伙伴,也是她与创造者们的光芒之间仅存的维系。", + "tags": [] }, { "key": "dawnbreaker_luminosity", @@ -36861,7 +37915,8 @@ "value": "0" } ], - "lore_loc": "由于接触不到光之子的光芒,力量无法恢复而不断衰退,但维罗拉还是从每场战斗中存在的混沌里创造了秩序,使自身和盟友得到休整。" + "lore_loc": "由于接触不到光之子的光芒,力量无法恢复而不断衰退,但维罗拉还是从每场战斗中存在的混沌里创造了秩序,使自身和盟友得到休整。", + "tags": [] }, { "key": "dawnbreaker_break_of_dawn", @@ -36904,7 +37959,8 @@ "value": "0%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "dawnbreaker_solar_guardian", @@ -36955,7 +38011,10 @@ "value": "0.8" } ], - "lore_loc": "只有为了保护盟友,维罗拉才会愿意召唤最后一点她原先的力量来完成穿越宇宙的飞行。" + "lore_loc": "只有为了保护盟友,维罗拉才会愿意召唤最后一点她原先的力量来完成穿越宇宙的飞行。", + "tags": [ + "stun" + ] } ], "talents": [ @@ -37064,7 +38123,8 @@ "value": "20 / 30 / 40 / 50%" } ], - "lore_loc": "玛西擅于扫除米拉娜公主在路上碰到的障碍。" + "lore_loc": "玛西擅于扫除米拉娜公主在路上碰到的障碍。", + "tags": [] }, { "key": "marci_companion_run", @@ -37103,7 +38163,10 @@ "value": "0.8 / 1.1 / 1.4 / 1.7" } ], - "lore_loc": "有了他人亲切或粗暴的托垫,玛西可以直入任何焦灼的战斗中。" + "lore_loc": "有了他人亲切或粗暴的托垫,玛西可以直入任何焦灼的战斗中。", + "tags": [ + "stun" + ] }, { "key": "marci_bodyguard", @@ -37146,7 +38209,8 @@ "value": "90 / 160 / 230 / 300" } ], - "lore_loc": "玛西小时候就已经懂得,乱世之中最好的财富是可靠的伙伴。" + "lore_loc": "玛西小时候就已经懂得,乱世之中最好的财富是可靠的伙伴。", + "tags": [] }, { "key": "marci_special_delivery", @@ -37177,7 +38241,8 @@ "value": "215" } ], - "lore_loc": "从一个信使到另一个,都体现着专业的礼仪。" + "lore_loc": "从一个信使到另一个,都体现着专业的礼仪。", + "tags": [] }, { "key": "marci_unleash", @@ -37232,7 +38297,11 @@ "value": "60 / 80 / 100" } ], - "lore_loc": "虽然有不少人在探究玛西神力的源头,但玛西对他们全不理睬。" + "lore_loc": "虽然有不少人在探究玛西神力的源头,但玛西对他们全不理睬。", + "tags": [ + "basic_dispel", + "silence" + ] } ], "talents": [ @@ -37345,7 +38414,10 @@ "value": "0.7 / 1 / 1.3 / 1.6" } ], - "lore_loc": "獸对眼中猎物的专一让其他犹豫不决的人在他毁灭性的冲击下四散而逃。" + "lore_loc": "獸对眼中猎物的专一让其他犹豫不决的人在他毁灭性的冲击下四散而逃。", + "tags": [ + "stun" + ] }, { "key": "primal_beast_trample", @@ -37388,7 +38460,10 @@ "value": "35%" } ], - "lore_loc": "由于獸是他的种族中唯一一个来到这个世上,当地人只能通过他的亲戚来推测獸的尺寸。要不是观测的目标会吃掉看到的一切,又消灭了大部分看不到的,那这样的观测肯定会容易很多。" + "lore_loc": "由于獸是他的种族中唯一一个来到这个世上,当地人只能通过他的亲戚来推测獸的尺寸。要不是观测的目标会吃掉看到的一切,又消灭了大部分看不到的,那这样的观测肯定会容易很多。", + "tags": [ + "disarm" + ] }, { "key": "primal_beast_uproar", @@ -37451,7 +38526,10 @@ "value": "0" } ], - "lore_loc": "在永雾林渊现身时獸就已身披破碎的锁链,如今他不会容许任何征服的企图。" + "lore_loc": "在永雾林渊现身时獸就已身披破碎的锁链,如今他不会容许任何征服的企图。", + "tags": [ + "break" + ] }, { "key": "primal_beast_rock_throw", @@ -37510,7 +38588,10 @@ "value": "185" } ], - "lore_loc": "又是岩石!" + "lore_loc": "又是岩石!", + "tags": [ + "stun" + ] }, { "key": "primal_beast_pulverize", @@ -37557,7 +38638,10 @@ "value": "20 / 40 / 60" } ], - "lore_loc": "虽然没人能肯定獸的嘟哝声是否具有真正的含义,但大多数都会同意,獸强劲的握力还有将一切粉碎为肉酱的倾向充分表达了他的意图。" + "lore_loc": "虽然没人能肯定獸的嘟哝声是否具有真正的含义,但大多数都会同意,獸强劲的握力还有将一切粉碎为肉酱的倾向充分表达了他的意图。", + "tags": [ + "stun" + ] }, { "key": "primal_beast_colossal", @@ -37592,7 +38676,8 @@ "value": "0%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -37701,7 +38786,8 @@ "value": "1" } ], - "lore_loc": "握枪的手能纹丝不动,再加上永世的练习,产生了让凡人的头脑难以经受的花式射击。" + "lore_loc": "握枪的手能纹丝不动,再加上永世的练习,产生了让凡人的头脑难以经受的花式射击。", + "tags": [] }, { "key": "muerta_the_calling", @@ -37744,7 +38830,10 @@ "value": "1.5 / 2 / 2.5 / 3" } ], - "lore_loc": "琼英碧灵的家人永远都在生界之外,但是她与死神的交易让她可以在需要的时候看望他们片刻。" + "lore_loc": "琼英碧灵的家人永远都在生界之外,但是她与死神的交易让她可以在需要的时候看望他们片刻。", + "tags": [ + "silence" + ] }, { "key": "muerta_gunslinger", @@ -37775,7 +38864,8 @@ "value": "18 / 27 / 36 / 45%" } ], - "lore_loc": "琼英碧灵会给予她所有目标仁慈。部分交上了好运还会获得恩泽。" + "lore_loc": "琼英碧灵会给予她所有目标仁慈。部分交上了好运还会获得恩泽。", + "tags": [] }, { "key": "muerta_spectral_slug", @@ -37822,7 +38912,8 @@ "value": "-20%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "muerta_pierce_the_veil", @@ -37861,7 +38952,8 @@ "value": "70 / 85 / 100%" } ], - "lore_loc": "琼英碧灵脱去她的实体形态,穿过生死之界,利用恶灵的愤怒打击她的敌人。" + "lore_loc": "琼英碧灵脱去她的实体形态,穿过生死之界,利用恶灵的愤怒打击她的敌人。", + "tags": [] }, { "key": "muerta_supernatural", @@ -37900,7 +38992,8 @@ "value": "5%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] } ], "talents": [ @@ -38013,7 +39106,8 @@ "value": "200" } ], - "lore_loc": "凯和他的师父克洛孜孜不倦地训练影武团,改进古代的技艺让空中的敌人失去平衡。" + "lore_loc": "凯和他的师父克洛孜孜不倦地训练影武团,改进古代的技艺让空中的敌人失去平衡。", + "tags": [] }, { "key": "kez_grappling_claw", @@ -38048,7 +39142,8 @@ "value": "70 / 80 / 90 / 100%" } ], - "lore_loc": "影武者拥有极快的速度和强大的力量,在空中飞得再高也会被拽下来。" + "lore_loc": "影武者拥有极快的速度和强大的力量,在空中飞得再高也会被拽下来。", + "tags": [] }, { "key": "kez_kazurai_katana", @@ -38095,7 +39190,10 @@ "value": "500" } ], - "lore_loc": "为了反击英佩瑞雅和她的羽林卫,影武者必须熟练掌握消耗战的技巧。" + "lore_loc": "为了反击英佩瑞雅和她的羽林卫,影武者必须熟练掌握消耗战的技巧。", + "tags": [ + "stun" + ] }, { "key": "kez_switch_weapons", @@ -38158,7 +39256,8 @@ "value": "8" } ], - "lore_loc": "影武团的领袖,曾经也是翔影教团的追随者,他的武技可以在长刀和双钗这两种流派之间自如地切换。" + "lore_loc": "影武团的领袖,曾经也是翔影教团的追随者,他的武技可以在长刀和双钗这两种流派之间自如地切换。", + "tags": [] }, { "key": "kez_raptor_dance", @@ -38205,7 +39304,8 @@ "value": "100%" } ], - "lore_loc": "陆行族内心的愤怒尽数释放,将敌人的精元吸收殆尽。" + "lore_loc": "陆行族内心的愤怒尽数释放,将敌人的精元吸收殆尽。", + "tags": [] }, { "key": "kez_falcon_rush", @@ -38256,7 +39356,8 @@ "value": "50%" } ], - "lore_loc": "古老的翔影战技,用来猎杀空中速度更快的敌人,窃取他们的优势,并在猎物惊慌失措地扇动一次翅膀的时间内发动多次攻击。" + "lore_loc": "古老的翔影战技,用来猎杀空中速度更快的敌人,窃取他们的优势,并在猎物惊慌失措地扇动一次翅膀的时间内发动多次攻击。", + "tags": [] }, { "key": "kez_talon_toss", @@ -38291,7 +39392,10 @@ "value": "2 / 2.25 / 2.5 / 2.75" } ], - "lore_loc": "翔影教团学会了依靠距离和分神来对抗更强大的对手。" + "lore_loc": "翔影教团学会了依靠距离和分神来对抗更强大的对手。", + "tags": [ + "silence" + ] }, { "key": "kez_shodo_sai", @@ -38342,7 +39446,10 @@ "value": "0" } ], - "lore_loc": "也许翔影教团已经迷失了方向,但是凯和影武团还是能利用翔影的知识造福人民。" + "lore_loc": "也许翔影教团已经迷失了方向,但是凯和影武团还是能利用翔影的知识造福人民。", + "tags": [ + "stun" + ] }, { "key": "kez_ravens_veil", @@ -38389,7 +39496,10 @@ "value": "15 / 25 / 35%" } ], - "lore_loc": "不论是见到威胁还是机遇,翔影的大师都会立刻进入暗影,使作战的条件重新对自己有利。" + "lore_loc": "不论是见到威胁还是机遇,翔影的大师都会立刻进入暗影,使作战的条件重新对自己有利。", + "tags": [ + "invis" + ] } ], "talents": [ @@ -38502,7 +39612,10 @@ "value": "8" } ], - "lore_loc": "在古老的岛屿泽鲁玛上生活了数个世代后,流亡暗根族的后代更具适应力,他们乐于尝试让他们祖先丧命的毒素。" + "lore_loc": "在古老的岛屿泽鲁玛上生活了数个世代后,流亡暗根族的后代更具适应力,他们乐于尝试让他们祖先丧命的毒素。", + "tags": [ + "basic_dispel" + ] }, { "key": "largo_frogstomp", @@ -38545,7 +39658,10 @@ "value": "350" } ], - "lore_loc": "乐手团的大多数用了数十年苦练音乐技艺,但就算是顶尖的朗戈也要承认,他的年轻弟子已经惊人地擅长数节拍了。" + "lore_loc": "乐手团的大多数用了数十年苦练音乐技艺,但就算是顶尖的朗戈也要承认,他的年轻弟子已经惊人地擅长数节拍了。", + "tags": [ + "stun" + ] }, { "key": "largo_croak_of_genius", @@ -38584,7 +39700,8 @@ "value": "15 / 20 / 25 / 30" } ], - "lore_loc": "朗戈喜欢通过音乐的力量帮助他的朋友找到荣耀,从未错过任何一次上演精彩二重唱的机会。" + "lore_loc": "朗戈喜欢通过音乐的力量帮助他的朋友找到荣耀,从未错过任何一次上演精彩二重唱的机会。", + "tags": [] }, { "key": "largo_encore", @@ -38615,7 +39732,8 @@ "value": "15%" } ], - "lore_loc": "" + "lore_loc": "", + "tags": [] }, { "key": "largo_amphibian_rhapsody", @@ -38666,7 +39784,8 @@ "value": "5" } ], - "lore_loc": "乐手团的第一人弹了一首歌,召唤出一座有生命的岛屿,在海水的侵袭下即将灭亡的流亡暗根族因此得救。" + "lore_loc": "乐手团的第一人弹了一首歌,召唤出一座有生命的岛屿,在海水的侵袭下即将灭亡的流亡暗根族因此得救。", + "tags": [] }, { "key": "largo_song_fight_song", @@ -38713,7 +39832,8 @@ "value": "1" } ], - "lore_loc": "“嘿,沙滩上突然有人打架的时候,你要学会用上你的音乐实力,不然派对很快就结束咯。”" + "lore_loc": "“嘿,沙滩上突然有人打架的时候,你要学会用上你的音乐实力,不然派对很快就结束咯。”", + "tags": [] }, { "key": "largo_song_double_time", @@ -38756,7 +39876,8 @@ "value": "0.3" } ], - "lore_loc": "“这就像那个沼泽女巫的吓人老故事。要是跳的时候想再快点,最好看清楚往哪跳。”" + "lore_loc": "“这就像那个沼泽女巫的吓人老故事。要是跳的时候想再快点,最好看清楚往哪跳。”", + "tags": [] }, { "key": "largo_song_good_vibrations", @@ -38787,7 +39908,8 @@ "value": "34 / 56 / 78" } ], - "lore_loc": "“假如这首歌能治愈永恒的孤岛,它就能处理你身上的擦伤和瘀青。”" + "lore_loc": "“假如这首歌能治愈永恒的孤岛,它就能处理你身上的擦伤和瘀青。”", + "tags": [] } ], "talents": [ diff --git a/data/hero_fear_overrides.json b/data/hero_fear_overrides.json index 865e7a6..bd13ae9 100644 --- a/data/hero_fear_overrides.json +++ b/data/hero_fear_overrides.json @@ -6,14 +6,16 @@ "juggernaut": { "add": [ { "item": "lotus_orb", "reason": "反射无敌斩/迅捷斩", "tags": ["spell_reflect"] }, + { "item": "ghost", "reason": "虚无免疫无敌斩的物理攻击", "tags": ["ethereal"] }, { "item": "abyssal_blade", "reason": "打断输出与无敌斩", "tags": [] }, { "item": "butterfly", "reason": "闪避克制普攻与斩击", "tags": [] } ], - "remove": [] + "remove": ["sphere"] }, "phantom_assassin": { "add": [ - { "item": "monkey_king_bar", "reason": "无视模糊闪避", "tags": ["true_strike"] } + { "item": "monkey_king_bar", "reason": "无视模糊闪避", "tags": ["true_strike"] }, + { "item": "ghost", "reason": "虚无规避幻影刺客的物理爆发", "tags": ["ethereal"] } ], "remove": [] }, @@ -63,6 +65,32 @@ { "item": "lotus_orb", "reason": "反射末日", "tags": ["spell_reflect"] } ], "remove": [] + }, + "puck": { + "add": [ + { "item": "orchid", "reason": "沉默限制技能逃生与反打", "tags": ["silence"] } + ], + "remove": [] + }, + "weaver": { + "add": [ + { "item": "ghost", "reason": "虚无规避连击与普攻爆发", "tags": ["ethereal"] }, + { "item": "cyclone", "reason": "驱散缩地状态并限制逃生", "tags": ["basic_dispel", "cyclone"] } + ], + "remove": [] + }, + "pangolier": { + "add": [ + { "item": "ghost", "reason": "虚无规避剑舞与普攻伤害", "tags": ["ethereal"] }, + { "item": "sheepstick", "reason": "妖术限制技能连段与逃生", "tags": ["hex"] } + ], + "remove": [] + }, + "void_spirit": { + "add": [ + { "item": "sheepstick", "reason": "妖术限制技能连段与逃生", "tags": ["hex"] } + ], + "remove": [] } } } diff --git a/data/hero_item_fears.json b/data/hero_item_fears.json index a0ca085..606d6ec 100644 --- a/data/hero_item_fears.json +++ b/data/hero_item_fears.json @@ -2,10 +2,17 @@ "meta": { "source": "rules+valve+opendota", "attribution": "derived from items_meta.json + hero_abilities.json", - "fetched_at": "2026-07-26T12:26:16.482539+00:00", + "fetched_at": "2026-07-28T16:06:06.680383+00:00", "top_n": 8, "heroes": 127, - "overrides": "data/hero_fear_overrides.json" + "overrides": "data/hero_fear_overrides.json", + "counter_stats": { + "path": "data/item_counter_stats.json", + "source": "opendota_explorer", + "fetched_at": "2026-07-28T15:55:48.079821+00:00", + "method": "enemy final inventory vs same-item global team baseline", + "caveat": "observational; adjusted for item baseline, not duration, rank, role, or economy" + } }, "by_hero": { "antimage": [ @@ -57,7 +64,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 181, + "purchase_rate": 0.280186, + "win_rate": 0.563536, + "purchase_lift": -0.019739, + "win_delta": 0.026068 + } }, { "item": "wind_waker", @@ -75,7 +89,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射淘汰之刃;反射点目标技能" + "reason": "反射淘汰之刃;反射点目标技能", + "stats": { + "games": 455, + "purchase_rate": 0.167959, + "win_rate": 0.602198, + "purchase_lift": -0.010191, + "win_delta": 0.030348 + } }, { "item": "diffusal_blade", @@ -83,7 +104,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 271, + "purchase_rate": 0.100037, + "win_rate": 0.483395, + "purchase_lift": 0.001387, + "win_delta": 0.018114 + } }, { "item": "eternal_shroud", @@ -97,7 +125,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 101, + "purchase_rate": 0.037283, + "win_rate": 0.663366, + "purchase_lift": -0.001292, + "win_delta": 0.004261 + } }, { "item": "nullifier", @@ -105,7 +140,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 190, + "purchase_rate": 0.070137, + "win_rate": 0.621053, + "purchase_lift": -0.010388, + "win_delta": 0.042661 + } }, { "item": "sphere", @@ -113,7 +155,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" + "reason": "格挡点目标技能", + "stats": { + "games": 126, + "purchase_rate": 0.046512, + "win_rate": 0.650794, + "purchase_lift": -0.048813, + "win_delta": 0.015336 + } }, { "item": "cyclone", @@ -121,7 +170,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 1031, + "purchase_rate": 0.380583, + "win_rate": 0.529583, + "purchase_lift": 0.080658, + "win_delta": -0.007885 + } }, { "item": "wind_waker", @@ -129,7 +185,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 136, + "purchase_rate": 0.050203, + "win_rate": 0.632353, + "purchase_lift": 0.020578, + "win_delta": -0.035158 + } } ], "bane": [ @@ -139,7 +202,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射虚弱/噩梦/蚀脑;反射点目标技能" + "reason": "反射虚弱/噩梦/蚀脑;反射点目标技能", + "stats": { + "games": 349, + "purchase_rate": 0.374866, + "win_rate": 0.532951, + "purchase_lift": 0.196716, + "win_delta": -0.038898 + } }, { "item": "diffusal_blade", @@ -147,7 +217,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 128, + "purchase_rate": 0.137487, + "win_rate": 0.367188, + "purchase_lift": 0.038837, + "win_delta": -0.098094 + } }, { "item": "disperser", @@ -171,7 +248,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 670, + "purchase_rate": 0.719656, + "win_rate": 0.438806, + "purchase_lift": 0.122706, + "win_delta": -0.10077 + } }, { "item": "sphere", @@ -179,15 +263,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能" - }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "法术格挡关键技能;格挡点目标技能", + "stats": { + "games": 188, + "purchase_rate": 0.201933, + "win_rate": 0.579787, + "purchase_lift": 0.106608, + "win_delta": -0.05567 + } }, { "item": "wind_waker", @@ -196,6 +279,21 @@ "basic_dispel" ], "reason": "可驱散技能增益" + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 251, + "purchase_rate": 0.269603, + "win_rate": 0.458167, + "purchase_lift": -0.030322, + "win_delta": -0.0793 + } } ], "bloodseeker": [ @@ -264,7 +362,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 154, + "purchase_rate": 0.083924, + "win_rate": 0.487013, + "purchase_lift": -0.014726, + "win_delta": 0.021732 + } }, { "item": "disperser", @@ -281,7 +386,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 181, + "purchase_rate": 0.098638, + "win_rate": 0.58011, + "purchase_lift": -0.079512, + "win_delta": 0.008261 + } }, { "item": "nullifier", @@ -297,7 +409,14 @@ "tags": [ "break" ], - "reason": "破坏关键被动" + "reason": "破坏关键被动", + "stats": { + "games": 163, + "purchase_rate": 0.088828, + "win_rate": 0.668712, + "purchase_lift": -0.014122, + "win_delta": 0.063078 + } }, { "item": "sphere", @@ -313,7 +432,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 486, + "purchase_rate": 0.26485, + "win_rate": 0.567901, + "purchase_lift": -0.035075, + "win_delta": 0.030434 + } }, { "item": "wind_waker", @@ -331,7 +457,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 227, + "purchase_rate": 0.217225, + "win_rate": 0.54185, + "purchase_lift": 0.039075, + "win_delta": -0.029999 + } }, { "item": "diffusal_blade", @@ -363,7 +496,14 @@ "tags": [ "invis_break" ], - "reason": "破隐" + "reason": "破隐", + "stats": { + "games": 131, + "purchase_rate": 0.125359, + "win_rate": 0.641221, + "purchase_lift": 0.022409, + "win_delta": 0.035588 + } }, { "item": "sphere", @@ -421,7 +561,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能;魔免抵消控制" + "reason": "魔免削弱技能;魔免抵消控制", + "stats": { + "games": 464, + "purchase_rate": 0.59335, + "win_rate": 0.584052, + "purchase_lift": -0.0036, + "win_delta": 0.044476 + } }, { "item": "sphere", @@ -437,7 +584,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 223, + "purchase_rate": 0.285166, + "win_rate": 0.600897, + "purchase_lift": -0.014759, + "win_delta": 0.063429 + } }, { "item": "wind_waker", @@ -455,27 +609,55 @@ "tags": [ "spell_reflect" ], - "reason": "反射无敌斩/迅捷斩;反射点目标技能" + "reason": "反射无敌斩/迅捷斩;反射点目标技能", + "stats": { + "games": 501, + "purchase_rate": 0.307929, + "win_rate": 0.562874, + "purchase_lift": 0.129779, + "win_delta": -0.008975 + } + }, + { + "item": "ghost", + "name_loc": "幽魂权杖", + "tags": [ + "ethereal" + ], + "reason": "虚无免疫无敌斩的物理攻击", + "stats": { + "games": 429, + "purchase_rate": 0.263675, + "win_rate": 0.540793, + "purchase_lift": 0.0634, + "win_delta": 0.018636 + } }, { "item": "abyssal_blade", "name_loc": "深渊之刃", "tags": [], - "reason": "打断输出与无敌斩" + "reason": "打断输出与无敌斩", + "stats": { + "games": 102, + "purchase_rate": 0.062692, + "win_rate": 0.745098, + "purchase_lift": -0.003508, + "win_delta": 0.060053 + } }, { "item": "butterfly", "name_loc": "蝴蝶", "tags": [], - "reason": "闪避克制普攻与斩击" - }, - { - "item": "sphere", - "name_loc": "林肯法球", - "tags": [ - "spell_block" - ], - "reason": "格挡点目标技能" + "reason": "闪避克制普攻与斩击", + "stats": { + "games": 299, + "purchase_rate": 0.183774, + "win_rate": 0.615385, + "purchase_lift": -0.003551, + "win_delta": -0.021476 + } } ], "mirana": [ @@ -511,14 +693,6 @@ ], "reason": "破隐" }, - { - "item": "black_king_bar", - "name_loc": "黑皇杖", - "tags": [ - "magic_immune" - ], - "reason": "魔免削弱技能;魔免削弱魔法输出" - }, { "item": "sphere", "name_loc": "林肯法球", @@ -527,6 +701,21 @@ ], "reason": "法术格挡关键技能" }, + { + "item": "black_king_bar", + "name_loc": "黑皇杖", + "tags": [ + "magic_immune" + ], + "reason": "魔免削弱技能;魔免削弱魔法输出", + "stats": { + "games": 312, + "purchase_rate": 0.566243, + "win_rate": 0.490385, + "purchase_lift": -0.030707, + "win_delta": -0.049192 + } + }, { "item": "gem", "name_loc": "真视宝石", @@ -551,7 +740,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 160, + "purchase_rate": 0.127796, + "win_rate": 0.5875, + "purchase_lift": -0.050354, + "win_delta": 0.01565 + } }, { "item": "sphere", @@ -559,7 +755,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" + "reason": "格挡点目标技能", + "stats": { + "games": 101, + "purchase_rate": 0.080671, + "win_rate": 0.564356, + "purchase_lift": -0.014654, + "win_delta": -0.071101 + } } ], "nevermore": [ @@ -569,7 +772,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 414, + "purchase_rate": 0.088405, + "win_rate": 0.475845, + "purchase_lift": -0.010245, + "win_delta": 0.010564 + } }, { "item": "disperser", @@ -577,7 +787,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 101, + "purchase_rate": 0.021567, + "win_rate": 0.673267, + "purchase_lift": -0.017008, + "win_delta": 0.014162 + } }, { "item": "nullifier", @@ -585,7 +802,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 222, + "purchase_rate": 0.047406, + "win_rate": 0.612613, + "purchase_lift": -0.033119, + "win_delta": 0.034221 + } }, { "item": "cyclone", @@ -593,7 +817,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 1247, + "purchase_rate": 0.266282, + "win_rate": 0.564555, + "purchase_lift": -0.033643, + "win_delta": 0.027087 + } }, { "item": "wind_waker", @@ -601,7 +832,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 102, + "purchase_rate": 0.021781, + "win_rate": 0.686275, + "purchase_lift": -0.007844, + "win_delta": 0.018764 + } } ], "phantom_lancer": [ @@ -621,6 +859,21 @@ ], "reason": "可驱散技能增益" }, + { + "item": "bfury", + "name_loc": "狂战斧", + "tags": [ + "illusion_clear" + ], + "reason": "克制幻象", + "stats": { + "games": 108, + "purchase_rate": 0.1728, + "win_rate": 0.592593, + "purchase_lift": 0.01225, + "win_delta": 0.103337 + } + }, { "item": "disperser", "name_loc": "散魂剑", @@ -629,6 +882,21 @@ ], "reason": "可驱散技能增益" }, + { + "item": "mjollnir", + "name_loc": "雷神之锤", + "tags": [ + "illusion_clear" + ], + "reason": "克制幻象", + "stats": { + "games": 173, + "purchase_rate": 0.2768, + "win_rate": 0.572254, + "purchase_lift": 0.114975, + "win_delta": 0.022123 + } + }, { "item": "nullifier", "name_loc": "否决坠饰", @@ -638,12 +906,19 @@ "reason": "可驱散技能增益" }, { - "item": "bfury", - "name_loc": "狂战斧", + "item": "radiance", + "name_loc": "辉耀", "tags": [ "illusion_clear" ], - "reason": "克制幻象" + "reason": "克制幻象", + "stats": { + "games": 157, + "purchase_rate": 0.2512, + "win_rate": 0.611465, + "purchase_lift": 0.102025, + "win_delta": 0.08155 + } }, { "item": "monkey_king_bar", @@ -652,25 +927,24 @@ "true_strike" ], "reason": "无视闪避" - }, - { - "item": "silver_edge", - "name_loc": "白银之锋", - "tags": [ - "break" - ], - "reason": "破坏关键被动" - }, - { - "item": "mjollnir", - "name_loc": "雷神之锤", - "tags": [ - "illusion_clear" - ], - "reason": "克制幻象" } ], "puck": [ + { + "item": "orchid", + "name_loc": "紫怨", + "tags": [ + "silence" + ], + "reason": "沉默限制技能逃生与反打", + "stats": { + "games": 453, + "purchase_rate": 0.193012, + "win_rate": 0.578366, + "purchase_lift": 0.116487, + "win_delta": 0.053375 + } + }, { "item": "diffusal_blade", "name_loc": "净魂之刃", @@ -678,7 +952,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 217, + "purchase_rate": 0.092458, + "win_rate": 0.447005, + "purchase_lift": -0.006192, + "win_delta": -0.018277 + } }, { "item": "disperser", @@ -695,7 +976,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 282, + "purchase_rate": 0.120153, + "win_rate": 0.535461, + "purchase_lift": 0.039628, + "win_delta": -0.042931 + } }, { "item": "black_king_bar", @@ -703,7 +991,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免抵消控制" + "reason": "魔免抵消控制", + "stats": { + "games": 1804, + "purchase_rate": 0.768641, + "win_rate": 0.52051, + "purchase_lift": 0.171691, + "win_delta": -0.019066 + } }, { "item": "sphere", @@ -711,7 +1006,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡" + "reason": "法术格挡", + "stats": { + "games": 189, + "purchase_rate": 0.080528, + "win_rate": 0.624339, + "purchase_lift": -0.014797, + "win_delta": -0.011119 + } }, { "item": "cyclone", @@ -719,7 +1021,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 784, + "purchase_rate": 0.334043, + "win_rate": 0.538265, + "purchase_lift": 0.034118, + "win_delta": 0.000798 + } }, { "item": "wind_waker", @@ -727,7 +1036,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 170, + "purchase_rate": 0.072433, + "win_rate": 0.576471, + "purchase_lift": 0.042808, + "win_delta": -0.09104 + } } ], "pudge": [ @@ -737,7 +1053,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 339, + "purchase_rate": 0.129985, + "win_rate": 0.59292, + "purchase_lift": -0.048165, + "win_delta": 0.021071 + } }, { "item": "black_king_bar", @@ -745,7 +1068,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 1140, + "purchase_rate": 0.437117, + "win_rate": 0.572807, + "purchase_lift": -0.159833, + "win_delta": 0.033231 + } }, { "item": "sphere", @@ -753,7 +1083,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能" + "reason": "法术格挡关键技能;格挡点目标技能", + "stats": { + "games": 166, + "purchase_rate": 0.06365, + "win_rate": 0.753012, + "purchase_lift": -0.031675, + "win_delta": 0.117554 + } } ], "razor": [ @@ -763,7 +1100,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 329, + "purchase_rate": 0.219333, + "win_rate": 0.595745, + "purchase_lift": 0.041183, + "win_delta": 0.023895 + } }, { "item": "diffusal_blade", @@ -771,7 +1115,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 121, + "purchase_rate": 0.080667, + "win_rate": 0.454545, + "purchase_lift": -0.017983, + "win_delta": -0.010736 + } }, { "item": "disperser", @@ -795,7 +1146,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" + "reason": "格挡点目标技能", + "stats": { + "games": 202, + "purchase_rate": 0.134667, + "win_rate": 0.673267, + "purchase_lift": 0.039342, + "win_delta": 0.03781 + } }, { "item": "cyclone", @@ -803,7 +1161,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 474, + "purchase_rate": 0.316, + "win_rate": 0.529536, + "purchase_lift": 0.016075, + "win_delta": -0.007932 + } }, { "item": "wind_waker", @@ -821,7 +1186,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 136, + "purchase_rate": 0.126277, + "win_rate": 0.463235, + "purchase_lift": 0.027627, + "win_delta": -0.002046 + } }, { "item": "disperser", @@ -837,7 +1209,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 125, + "purchase_rate": 0.116063, + "win_rate": 0.504, + "purchase_lift": 0.035538, + "win_delta": -0.074392 + } }, { "item": "silver_edge", @@ -845,7 +1224,14 @@ "tags": [ "invis_break" ], - "reason": "破隐" + "reason": "破隐", + "stats": { + "games": 103, + "purchase_rate": 0.095636, + "win_rate": 0.621359, + "purchase_lift": -0.007314, + "win_delta": 0.015725 + } }, { "item": "black_king_bar", @@ -853,7 +1239,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 827, + "purchase_rate": 0.767874, + "win_rate": 0.539299, + "purchase_lift": 0.170924, + "win_delta": -0.000278 + } }, { "item": "sphere", @@ -888,7 +1281,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 254, + "purchase_rate": 0.094144, + "win_rate": 0.531496, + "purchase_lift": -0.004506, + "win_delta": 0.066215 + } }, { "item": "disperser", @@ -897,7 +1297,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 119, + "purchase_rate": 0.044107, + "win_rate": 0.588235, + "purchase_lift": 0.005532, + "win_delta": -0.07087 + } }, { "item": "lotus_orb", @@ -905,7 +1312,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 452, + "purchase_rate": 0.167532, + "win_rate": 0.581858, + "purchase_lift": -0.010618, + "win_delta": 0.010009 + } }, { "item": "nullifier", @@ -913,15 +1327,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" - }, - { - "item": "black_king_bar", - "name_loc": "黑皇杖", - "tags": [ - "magic_immune" - ], - "reason": "魔免削弱技能" + "reason": "可驱散技能增益", + "stats": { + "games": 211, + "purchase_rate": 0.078206, + "win_rate": 0.516588, + "purchase_lift": -0.002319, + "win_delta": -0.061804 + } }, { "item": "sphere", @@ -929,7 +1342,29 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能" + "reason": "法术格挡关键技能;格挡点目标技能", + "stats": { + "games": 274, + "purchase_rate": 0.101557, + "win_rate": 0.667883, + "purchase_lift": 0.006232, + "win_delta": 0.032426 + } + }, + { + "item": "black_king_bar", + "name_loc": "黑皇杖", + "tags": [ + "magic_immune" + ], + "reason": "魔免削弱技能", + "stats": { + "games": 1749, + "purchase_rate": 0.648258, + "win_rate": 0.525443, + "purchase_lift": 0.051308, + "win_delta": -0.014133 + } }, { "item": "cyclone", @@ -937,7 +1372,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 837, + "purchase_rate": 0.31023, + "win_rate": 0.53644, + "purchase_lift": 0.010305, + "win_delta": -0.001028 + } }, { "item": "wind_waker", @@ -955,7 +1397,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 156, + "purchase_rate": 0.1625, + "win_rate": 0.608974, + "purchase_lift": -0.01565, + "win_delta": 0.037125 + } }, { "item": "diffusal_blade", @@ -981,21 +1430,35 @@ ], "reason": "可驱散技能增益" }, - { - "item": "black_king_bar", - "name_loc": "黑皇杖", - "tags": [ - "magic_immune" - ], - "reason": "魔免削弱技能" - }, { "item": "sphere", "name_loc": "林肯法球", "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能" + "reason": "法术格挡关键技能;格挡点目标技能", + "stats": { + "games": 100, + "purchase_rate": 0.104167, + "win_rate": 0.74, + "purchase_lift": 0.008842, + "win_delta": 0.104542 + } + }, + { + "item": "black_king_bar", + "name_loc": "黑皇杖", + "tags": [ + "magic_immune" + ], + "reason": "魔免削弱技能", + "stats": { + "games": 481, + "purchase_rate": 0.501042, + "win_rate": 0.600832, + "purchase_lift": -0.095908, + "win_delta": 0.061255 + } }, { "item": "cyclone", @@ -1003,7 +1466,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 267, + "purchase_rate": 0.278125, + "win_rate": 0.606742, + "purchase_lift": -0.0218, + "win_delta": 0.069274 + } }, { "item": "wind_waker", @@ -1021,7 +1491,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 541, + "purchase_rate": 0.164738, + "win_rate": 0.57671, + "purchase_lift": -0.013412, + "win_delta": 0.00486 + } }, { "item": "black_king_bar", @@ -1029,7 +1506,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 2284, + "purchase_rate": 0.695493, + "win_rate": 0.545096, + "purchase_lift": 0.098543, + "win_delta": 0.00552 + } }, { "item": "sphere", @@ -1037,7 +1521,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能" + "reason": "法术格挡关键技能;格挡点目标技能", + "stats": { + "games": 316, + "purchase_rate": 0.096224, + "win_rate": 0.664557, + "purchase_lift": 0.000899, + "win_delta": 0.029099 + } } ], "vengefulspirit": [ @@ -1047,7 +1538,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 141, + "purchase_rate": 0.130074, + "win_rate": 0.609929, + "purchase_lift": -0.048076, + "win_delta": 0.03808 + } }, { "item": "diffusal_blade", @@ -1055,7 +1553,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 103, + "purchase_rate": 0.095018, + "win_rate": 0.436893, + "purchase_lift": -0.003632, + "win_delta": -0.028388 + } }, { "item": "disperser", @@ -1079,7 +1584,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 430, + "purchase_rate": 0.396679, + "win_rate": 0.574419, + "purchase_lift": -0.200271, + "win_delta": 0.034842 + } }, { "item": "sphere", @@ -1095,7 +1607,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 295, + "purchase_rate": 0.27214, + "win_rate": 0.572881, + "purchase_lift": -0.027785, + "win_delta": 0.035414 + } }, { "item": "wind_waker", @@ -1107,13 +1626,35 @@ } ], "windrunner": [ + { + "item": "monkey_king_bar", + "name_loc": "金箍棒", + "tags": [ + "true_strike" + ], + "reason": "无视闪避", + "stats": { + "games": 752, + "purchase_rate": 0.247776, + "win_rate": 0.571809, + "purchase_lift": 0.156876, + "win_delta": 0.001676 + } + }, { "item": "lotus_orb", "name_loc": "清莲宝珠", "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 551, + "purchase_rate": 0.181549, + "win_rate": 0.571688, + "purchase_lift": 0.003399, + "win_delta": -0.000162 + } }, { "item": "diffusal_blade", @@ -1121,7 +1662,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 228, + "purchase_rate": 0.075124, + "win_rate": 0.460526, + "purchase_lift": -0.023526, + "win_delta": -0.004755 + } }, { "item": "disperser", @@ -1129,7 +1677,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 106, + "purchase_rate": 0.034926, + "win_rate": 0.716981, + "purchase_lift": -0.003649, + "win_delta": 0.057875 + } }, { "item": "nullifier", @@ -1137,15 +1692,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" - }, - { - "item": "monkey_king_bar", - "name_loc": "金箍棒", - "tags": [ - "true_strike" - ], - "reason": "无视闪避" + "reason": "可驱散技能增益", + "stats": { + "games": 292, + "purchase_rate": 0.096211, + "win_rate": 0.571918, + "purchase_lift": 0.015686, + "win_delta": -0.006474 + } }, { "item": "bloodthorn", @@ -1153,7 +1707,14 @@ "tags": [ "true_strike" ], - "reason": "无视闪避" + "reason": "无视闪避", + "stats": { + "games": 198, + "purchase_rate": 0.065239, + "win_rate": 0.676768, + "purchase_lift": 0.004089, + "win_delta": 0.04676 + } }, { "item": "black_king_bar", @@ -1161,7 +1722,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 1814, + "purchase_rate": 0.597694, + "win_rate": 0.522051, + "purchase_lift": 0.000744, + "win_delta": -0.017525 + } }, { "item": "sphere", @@ -1169,25 +1737,46 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能" + "reason": "法术格挡关键技能;格挡点目标技能", + "stats": { + "games": 329, + "purchase_rate": 0.108402, + "win_rate": 0.620061, + "purchase_lift": 0.013077, + "win_delta": -0.015397 + } } ], "zuus": [ - { - "item": "lotus_orb", - "name_loc": "清莲宝珠", - "tags": [ - "spell_reflect" - ], - "reason": "反射点目标技能" - }, { "item": "diffusal_blade", "name_loc": "净魂之刃", "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 128, + "purchase_rate": 0.084993, + "win_rate": 0.460938, + "purchase_lift": -0.013657, + "win_delta": -0.004344 + } + }, + { + "item": "lotus_orb", + "name_loc": "清莲宝珠", + "tags": [ + "spell_reflect" + ], + "reason": "反射点目标技能", + "stats": { + "games": 129, + "purchase_rate": 0.085657, + "win_rate": 0.542636, + "purchase_lift": -0.092493, + "win_delta": -0.029214 + } }, { "item": "disperser", @@ -1213,14 +1802,6 @@ ], "reason": "格挡点目标技能" }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" - }, { "item": "wind_waker", "name_loc": "风之杖", @@ -1228,17 +1809,24 @@ "basic_dispel" ], "reason": "可驱散技能增益" + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 360, + "purchase_rate": 0.239044, + "win_rate": 0.522222, + "purchase_lift": -0.060881, + "win_delta": -0.015245 + } } ], "kunkka": [ - { - "item": "lotus_orb", - "name_loc": "清莲宝珠", - "tags": [ - "spell_reflect" - ], - "reason": "反射点目标技能" - }, { "item": "diffusal_blade", "name_loc": "净魂之刃", @@ -1247,6 +1835,21 @@ ], "reason": "可驱散技能增益" }, + { + "item": "lotus_orb", + "name_loc": "清莲宝珠", + "tags": [ + "spell_reflect" + ], + "reason": "反射点目标技能", + "stats": { + "games": 136, + "purchase_rate": 0.136821, + "win_rate": 0.455882, + "purchase_lift": -0.041329, + "win_delta": -0.115967 + } + }, { "item": "disperser", "name_loc": "散魂剑", @@ -1263,14 +1866,6 @@ ], "reason": "可驱散技能增益" }, - { - "item": "black_king_bar", - "name_loc": "黑皇杖", - "tags": [ - "magic_immune" - ], - "reason": "魔免抵消控制" - }, { "item": "sphere", "name_loc": "林肯法球", @@ -1279,13 +1874,35 @@ ], "reason": "格挡点目标技能;法术格挡" }, + { + "item": "black_king_bar", + "name_loc": "黑皇杖", + "tags": [ + "magic_immune" + ], + "reason": "魔免抵消控制", + "stats": { + "games": 582, + "purchase_rate": 0.585513, + "win_rate": 0.522337, + "purchase_lift": -0.011437, + "win_delta": -0.017239 + } + }, { "item": "cyclone", "name_loc": "Eul的神圣法杖", "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 417, + "purchase_rate": 0.419517, + "win_rate": 0.508393, + "purchase_lift": 0.119592, + "win_delta": -0.029074 + } }, { "item": "wind_waker", @@ -1303,7 +1920,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射神灭斩;反射点目标技能" + "reason": "反射神灭斩;反射点目标技能", + "stats": { + "games": 139, + "purchase_rate": 0.090613, + "win_rate": 0.604317, + "purchase_lift": -0.087537, + "win_delta": 0.032467 + } }, { "item": "black_king_bar", @@ -1311,7 +1935,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免克制爆发;魔免削弱技能" + "reason": "魔免克制爆发;魔免削弱技能", + "stats": { + "games": 604, + "purchase_rate": 0.393742, + "win_rate": 0.61755, + "purchase_lift": -0.203208, + "win_delta": 0.077973 + } }, { "item": "sphere", @@ -1329,7 +1960,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射死亡一指;反射点目标技能" + "reason": "反射死亡一指;反射点目标技能", + "stats": { + "games": 499, + "purchase_rate": 0.153303, + "win_rate": 0.561122, + "purchase_lift": -0.024847, + "win_delta": -0.010727 + } }, { "item": "black_king_bar", @@ -1337,7 +1975,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能;魔免抵消控制" + "reason": "魔免削弱技能;魔免抵消控制", + "stats": { + "games": 1786, + "purchase_rate": 0.548694, + "win_rate": 0.541433, + "purchase_lift": -0.048256, + "win_delta": 0.001857 + } }, { "item": "sphere", @@ -1345,7 +1990,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能;法术格挡" + "reason": "法术格挡关键技能;格挡点目标技能;法术格挡", + "stats": { + "games": 223, + "purchase_rate": 0.06851, + "win_rate": 0.61435, + "purchase_lift": -0.026815, + "win_delta": -0.021108 + } } ], "shadow_shaman": [ @@ -1355,7 +2007,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 393, + "purchase_rate": 0.190684, + "win_rate": 0.56743, + "purchase_lift": 0.012534, + "win_delta": -0.00442 + } }, { "item": "diffusal_blade", @@ -1363,7 +2022,14 @@ "tags": [ "mana_burn" ], - "reason": "烧蓝克制" + "reason": "烧蓝克制", + "stats": { + "games": 165, + "purchase_rate": 0.080058, + "win_rate": 0.466667, + "purchase_lift": -0.018592, + "win_delta": 0.001385 + } }, { "item": "disperser", @@ -1379,7 +2045,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 1095, + "purchase_rate": 0.531295, + "win_rate": 0.541553, + "purchase_lift": -0.065655, + "win_delta": 0.001976 + } }, { "item": "sphere", @@ -1387,7 +2060,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能" + "reason": "法术格挡关键技能;格挡点目标技能", + "stats": { + "games": 226, + "purchase_rate": 0.109656, + "win_rate": 0.632743, + "purchase_lift": 0.014331, + "win_delta": -0.002714 + } } ], "slardar": [ @@ -1398,7 +2078,14 @@ "invis_break", "break" ], - "reason": "破隐;破坏关键被动" + "reason": "破隐;破坏关键被动", + "stats": { + "games": 154, + "purchase_rate": 0.057873, + "win_rate": 0.577922, + "purchase_lift": -0.045077, + "win_delta": -0.027712 + } }, { "item": "lotus_orb", @@ -1406,7 +2093,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 781, + "purchase_rate": 0.293499, + "win_rate": 0.559539, + "purchase_lift": 0.115349, + "win_delta": -0.012311 + } }, { "item": "diffusal_blade", @@ -1414,7 +2108,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 302, + "purchase_rate": 0.113491, + "win_rate": 0.456954, + "purchase_lift": 0.014841, + "win_delta": -0.008328 + } }, { "item": "disperser", @@ -1422,7 +2123,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 107, + "purchase_rate": 0.04021, + "win_rate": 0.64486, + "purchase_lift": 0.001635, + "win_delta": -0.014246 + } }, { "item": "nullifier", @@ -1430,15 +2138,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" - }, - { - "item": "black_king_bar", - "name_loc": "黑皇杖", - "tags": [ - "magic_immune" - ], - "reason": "魔免削弱技能" + "reason": "可驱散技能增益", + "stats": { + "games": 197, + "purchase_rate": 0.074032, + "win_rate": 0.497462, + "purchase_lift": -0.006493, + "win_delta": -0.08093 + } }, { "item": "sphere", @@ -1446,7 +2153,29 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能" + "reason": "法术格挡关键技能;格挡点目标技能", + "stats": { + "games": 194, + "purchase_rate": 0.072905, + "win_rate": 0.618557, + "purchase_lift": -0.02242, + "win_delta": -0.016901 + } + }, + { + "item": "black_king_bar", + "name_loc": "黑皇杖", + "tags": [ + "magic_immune" + ], + "reason": "魔免削弱技能", + "stats": { + "games": 1549, + "purchase_rate": 0.582112, + "win_rate": 0.507424, + "purchase_lift": -0.014838, + "win_delta": -0.032152 + } }, { "item": "gem", @@ -1464,7 +2193,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 406, + "purchase_rate": 0.148718, + "win_rate": 0.583744, + "purchase_lift": -0.029432, + "win_delta": 0.011894 + } }, { "item": "diffusal_blade", @@ -1472,7 +2208,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 280, + "purchase_rate": 0.102564, + "win_rate": 0.525, + "purchase_lift": 0.003914, + "win_delta": 0.059719 + } }, { "item": "disperser", @@ -1480,7 +2223,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 106, + "purchase_rate": 0.038828, + "win_rate": 0.735849, + "purchase_lift": 0.000253, + "win_delta": 0.076743 + } }, { "item": "nullifier", @@ -1488,7 +2238,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 238, + "purchase_rate": 0.087179, + "win_rate": 0.613445, + "purchase_lift": 0.006654, + "win_delta": 0.035054 + } }, { "item": "black_king_bar", @@ -1496,7 +2253,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 1754, + "purchase_rate": 0.642491, + "win_rate": 0.549601, + "purchase_lift": 0.045541, + "win_delta": 0.010025 + } }, { "item": "sphere", @@ -1504,7 +2268,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能" + "reason": "法术格挡关键技能;格挡点目标技能", + "stats": { + "games": 180, + "purchase_rate": 0.065934, + "win_rate": 0.672222, + "purchase_lift": -0.029391, + "win_delta": 0.036765 + } }, { "item": "cyclone", @@ -1512,7 +2283,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 710, + "purchase_rate": 0.260073, + "win_rate": 0.560563, + "purchase_lift": -0.039852, + "win_delta": 0.023096 + } }, { "item": "wind_waker", @@ -1530,7 +2308,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 109, + "purchase_rate": 0.123443, + "win_rate": 0.623853, + "purchase_lift": -0.054707, + "win_delta": 0.052004 + } }, { "item": "black_king_bar", @@ -1538,7 +2323,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 454, + "purchase_rate": 0.514156, + "win_rate": 0.572687, + "purchase_lift": -0.082794, + "win_delta": 0.033111 + } }, { "item": "sphere", @@ -1557,7 +2349,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 162, + "purchase_rate": 0.087757, + "win_rate": 0.5, + "purchase_lift": -0.010893, + "win_delta": 0.034719 + } }, { "item": "disperser", @@ -1574,7 +2373,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 274, + "purchase_rate": 0.148429, + "win_rate": 0.558394, + "purchase_lift": -0.029721, + "win_delta": -0.013455 + } }, { "item": "nullifier", @@ -1582,7 +2388,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 199, + "purchase_rate": 0.107801, + "win_rate": 0.567839, + "purchase_lift": 0.027276, + "win_delta": -0.010553 + } }, { "item": "sphere", @@ -1590,15 +2403,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" - }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "格挡点目标技能", + "stats": { + "games": 153, + "purchase_rate": 0.082882, + "win_rate": 0.607843, + "purchase_lift": -0.012443, + "win_delta": -0.027615 + } }, { "item": "wind_waker", @@ -1607,6 +2419,21 @@ "basic_dispel" ], "reason": "可驱散技能增益" + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 519, + "purchase_rate": 0.281148, + "win_rate": 0.527938, + "purchase_lift": -0.018777, + "win_delta": -0.009529 + } } ], "riki": [ @@ -1716,14 +2543,6 @@ ], "reason": "格挡点目标技能" }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" - }, { "item": "wind_waker", "name_loc": "风之杖", @@ -1731,6 +2550,21 @@ "basic_dispel" ], "reason": "可驱散技能增益" + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 121, + "purchase_rate": 0.276256, + "win_rate": 0.504132, + "purchase_lift": -0.023669, + "win_delta": -0.033335 + } } ], "tinker": [ @@ -1740,7 +2574,29 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 125, + "purchase_rate": 0.204248, + "win_rate": 0.64, + "purchase_lift": 0.026098, + "win_delta": 0.06815 + } + }, + { + "item": "nullifier", + "name_loc": "否决坠饰", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 149, + "purchase_rate": 0.243464, + "win_rate": 0.630872, + "purchase_lift": 0.162939, + "win_delta": 0.052481 + } }, { "item": "diffusal_blade", @@ -1758,14 +2614,6 @@ ], "reason": "可驱散技能增益" }, - { - "item": "nullifier", - "name_loc": "否决坠饰", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" - }, { "item": "monkey_king_bar", "name_loc": "金箍棒", @@ -1796,25 +2644,46 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 226, + "purchase_rate": 0.369281, + "win_rate": 0.619469, + "purchase_lift": 0.069356, + "win_delta": 0.082001 + } } ], "sniper": [ - { - "item": "lotus_orb", - "name_loc": "清莲宝珠", - "tags": [ - "spell_reflect" - ], - "reason": "反射点目标技能" - }, { "item": "diffusal_blade", "name_loc": "净魂之刃", "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 169, + "purchase_rate": 0.09912, + "win_rate": 0.461538, + "purchase_lift": 0.00047, + "win_delta": -0.003743 + } + }, + { + "item": "lotus_orb", + "name_loc": "清莲宝珠", + "tags": [ + "spell_reflect" + ], + "reason": "反射点目标技能", + "stats": { + "games": 160, + "purchase_rate": 0.093842, + "win_rate": 0.54375, + "purchase_lift": -0.084308, + "win_delta": -0.0281 + } }, { "item": "disperser", @@ -1846,7 +2715,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 435, + "purchase_rate": 0.255132, + "win_rate": 0.56092, + "purchase_lift": -0.044793, + "win_delta": 0.023452 + } }, { "item": "wind_waker", @@ -1864,7 +2740,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射死神镰刀;反射点目标技能" + "reason": "反射死神镰刀;反射点目标技能", + "stats": { + "games": 162, + "purchase_rate": 0.14349, + "win_rate": 0.580247, + "purchase_lift": -0.03466, + "win_delta": 0.008397 + } }, { "item": "diffusal_blade", @@ -1890,7 +2773,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 250, + "purchase_rate": 0.221435, + "win_rate": 0.584, + "purchase_lift": 0.14091, + "win_delta": 0.005608 + } }, { "item": "black_king_bar", @@ -1898,7 +2788,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱魔法输出" + "reason": "魔免削弱魔法输出", + "stats": { + "games": 592, + "purchase_rate": 0.524358, + "win_rate": 0.560811, + "purchase_lift": -0.072592, + "win_delta": 0.021235 + } }, { "item": "sphere", @@ -1908,14 +2805,6 @@ ], "reason": "格挡点目标技能" }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" - }, { "item": "wind_waker", "name_loc": "风之杖", @@ -1923,6 +2812,21 @@ "basic_dispel" ], "reason": "可驱散技能增益" + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 254, + "purchase_rate": 0.224978, + "win_rate": 0.519685, + "purchase_lift": -0.074947, + "win_delta": -0.017783 + } } ], "warlock": [ @@ -1932,7 +2836,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 533, + "purchase_rate": 0.194171, + "win_rate": 0.624765, + "purchase_lift": 0.016021, + "win_delta": 0.052916 + } }, { "item": "diffusal_blade", @@ -1940,15 +2851,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" - }, - { - "item": "disperser", - "name_loc": "散魂剑", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 227, + "purchase_rate": 0.082696, + "win_rate": 0.506608, + "purchase_lift": -0.015954, + "win_delta": 0.041327 + } }, { "item": "nullifier", @@ -1956,7 +2866,29 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 246, + "purchase_rate": 0.089617, + "win_rate": 0.634146, + "purchase_lift": 0.009092, + "win_delta": 0.055755 + } + }, + { + "item": "disperser", + "name_loc": "散魂剑", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 103, + "purchase_rate": 0.037523, + "win_rate": 0.650485, + "purchase_lift": -0.001052, + "win_delta": -0.00862 + } }, { "item": "black_king_bar", @@ -1964,7 +2896,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 1659, + "purchase_rate": 0.604372, + "win_rate": 0.553948, + "purchase_lift": 0.007422, + "win_delta": 0.014372 + } }, { "item": "sphere", @@ -1972,7 +2911,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能" + "reason": "法术格挡关键技能;格挡点目标技能", + "stats": { + "games": 280, + "purchase_rate": 0.102004, + "win_rate": 0.65, + "purchase_lift": 0.006679, + "win_delta": 0.014542 + } }, { "item": "cyclone", @@ -1980,7 +2926,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 895, + "purchase_rate": 0.326047, + "win_rate": 0.58324, + "purchase_lift": 0.026122, + "win_delta": 0.045773 + } }, { "item": "wind_waker", @@ -1998,7 +2951,29 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 776, + "purchase_rate": 0.330917, + "win_rate": 0.582474, + "purchase_lift": 0.152767, + "win_delta": 0.010625 + } + }, + { + "item": "sphere", + "name_loc": "林肯法球", + "tags": [ + "spell_block" + ], + "reason": "法术格挡关键技能;格挡点目标技能;法术格挡", + "stats": { + "games": 654, + "purchase_rate": 0.278891, + "win_rate": 0.64526, + "purchase_lift": 0.183566, + "win_delta": 0.009802 + } }, { "item": "diffusal_blade", @@ -2006,7 +2981,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 217, + "purchase_rate": 0.092537, + "win_rate": 0.488479, + "purchase_lift": -0.006113, + "win_delta": 0.023198 + } }, { "item": "disperser", @@ -2022,7 +3004,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 174, + "purchase_rate": 0.0742, + "win_rate": 0.655172, + "purchase_lift": -0.006325, + "win_delta": 0.076781 + } }, { "item": "black_king_bar", @@ -2030,23 +3019,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能;魔免抵消控制" - }, - { - "item": "sphere", - "name_loc": "林肯法球", - "tags": [ - "spell_block" - ], - "reason": "法术格挡关键技能;格挡点目标技能;法术格挡" - }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "魔免削弱技能;魔免抵消控制", + "stats": { + "games": 1358, + "purchase_rate": 0.579104, + "win_rate": 0.536082, + "purchase_lift": -0.017846, + "win_delta": -0.003494 + } }, { "item": "wind_waker", @@ -2055,48 +3035,6 @@ "basic_dispel" ], "reason": "可驱散技能增益" - } - ], - "queenofpain": [ - { - "item": "lotus_orb", - "name_loc": "清莲宝珠", - "tags": [ - "spell_reflect" - ], - "reason": "反射点目标技能" - }, - { - "item": "diffusal_blade", - "name_loc": "净魂之刃", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" - }, - { - "item": "disperser", - "name_loc": "散魂剑", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" - }, - { - "item": "nullifier", - "name_loc": "否决坠饰", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" - }, - { - "item": "sphere", - "name_loc": "林肯法球", - "tags": [ - "spell_block" - ], - "reason": "格挡点目标技能" }, { "item": "cyclone", @@ -2104,7 +3042,106 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 573, + "purchase_rate": 0.24435, + "win_rate": 0.534031, + "purchase_lift": -0.055575, + "win_delta": -0.003436 + } + } + ], + "queenofpain": [ + { + "item": "diffusal_blade", + "name_loc": "净魂之刃", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 331, + "purchase_rate": 0.104252, + "win_rate": 0.522659, + "purchase_lift": 0.005602, + "win_delta": 0.057377 + } + }, + { + "item": "lotus_orb", + "name_loc": "清莲宝珠", + "tags": [ + "spell_reflect" + ], + "reason": "反射点目标技能", + "stats": { + "games": 546, + "purchase_rate": 0.171969, + "win_rate": 0.569597, + "purchase_lift": -0.006181, + "win_delta": -0.002252 + } + }, + { + "item": "disperser", + "name_loc": "散魂剑", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 136, + "purchase_rate": 0.042835, + "win_rate": 0.669118, + "purchase_lift": 0.00426, + "win_delta": 0.010012 + } + }, + { + "item": "nullifier", + "name_loc": "否决坠饰", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 286, + "purchase_rate": 0.090079, + "win_rate": 0.573427, + "purchase_lift": 0.009554, + "win_delta": -0.004965 + } + }, + { + "item": "sphere", + "name_loc": "林肯法球", + "tags": [ + "spell_block" + ], + "reason": "格挡点目标技能", + "stats": { + "games": 266, + "purchase_rate": 0.08378, + "win_rate": 0.665414, + "purchase_lift": -0.011545, + "win_delta": 0.029956 + } + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 1017, + "purchase_rate": 0.320315, + "win_rate": 0.543756, + "purchase_lift": 0.02039, + "win_delta": 0.006288 + } }, { "item": "wind_waker", @@ -2123,7 +3160,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 120, + "purchase_rate": 0.104712, + "win_rate": 0.5, + "purchase_lift": 0.006062, + "win_delta": 0.034719 + } }, { "item": "disperser", @@ -2140,7 +3184,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 219, + "purchase_rate": 0.191099, + "win_rate": 0.598174, + "purchase_lift": 0.012949, + "win_delta": 0.026324 + } }, { "item": "nullifier", @@ -2150,21 +3201,35 @@ ], "reason": "可驱散技能增益" }, - { - "item": "black_king_bar", - "name_loc": "黑皇杖", - "tags": [ - "magic_immune" - ], - "reason": "魔免削弱魔法输出" - }, { "item": "sphere", "name_loc": "林肯法球", "tags": [ "spell_block" ], - "reason": "格挡点目标技能" + "reason": "格挡点目标技能", + "stats": { + "games": 121, + "purchase_rate": 0.105585, + "win_rate": 0.694215, + "purchase_lift": 0.01026, + "win_delta": 0.058757 + } + }, + { + "item": "black_king_bar", + "name_loc": "黑皇杖", + "tags": [ + "magic_immune" + ], + "reason": "魔免削弱魔法输出", + "stats": { + "games": 711, + "purchase_rate": 0.620419, + "win_rate": 0.538678, + "purchase_lift": 0.023469, + "win_delta": -0.000898 + } }, { "item": "cyclone", @@ -2172,7 +3237,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 349, + "purchase_rate": 0.304538, + "win_rate": 0.555874, + "purchase_lift": 0.004613, + "win_delta": 0.018406 + } }, { "item": "wind_waker", @@ -2190,7 +3262,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 127, + "purchase_rate": 0.125992, + "win_rate": 0.551181, + "purchase_lift": 0.027342, + "win_delta": 0.0859 + } }, { "item": "disperser", @@ -2214,7 +3293,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 652, + "purchase_rate": 0.646825, + "win_rate": 0.51227, + "purchase_lift": 0.049875, + "win_delta": -0.027306 + } }, { "item": "sphere", @@ -2230,7 +3316,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 337, + "purchase_rate": 0.334325, + "win_rate": 0.510386, + "purchase_lift": 0.0344, + "win_delta": -0.027082 + } }, { "item": "wind_waker", @@ -2280,7 +3373,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 275, + "purchase_rate": 0.385154, + "win_rate": 0.625455, + "purchase_lift": -0.211796, + "win_delta": 0.085878 + } }, { "item": "sphere", @@ -2290,14 +3390,6 @@ ], "reason": "法术格挡关键技能;格挡点目标技能" }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" - }, { "item": "wind_waker", "name_loc": "风之杖", @@ -2305,6 +3397,21 @@ "basic_dispel" ], "reason": "可驱散技能增益" + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 168, + "purchase_rate": 0.235294, + "win_rate": 0.517857, + "purchase_lift": -0.064631, + "win_delta": -0.019611 + } } ], "death_prophet": [ @@ -2314,7 +3421,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 101, + "purchase_rate": 0.139118, + "win_rate": 0.594059, + "purchase_lift": -0.039032, + "win_delta": 0.02221 + } }, { "item": "diffusal_blade", @@ -2354,7 +3468,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 212, + "purchase_rate": 0.292011, + "win_rate": 0.589623, + "purchase_lift": -0.007914, + "win_delta": 0.052155 + } }, { "item": "wind_waker", @@ -2372,7 +3493,29 @@ "tags": [ "true_strike" ], - "reason": "无视模糊闪避;无视闪避" + "reason": "无视模糊闪避;无视闪避", + "stats": { + "games": 180, + "purchase_rate": 0.294118, + "win_rate": 0.666667, + "purchase_lift": 0.203218, + "win_delta": 0.096535 + } + }, + { + "item": "ghost", + "name_loc": "幽魂权杖", + "tags": [ + "ethereal" + ], + "reason": "虚无规避幻影刺客的物理爆发", + "stats": { + "games": 180, + "purchase_rate": 0.294118, + "win_rate": 0.6, + "purchase_lift": 0.093843, + "win_delta": 0.077843 + } }, { "item": "lotus_orb", @@ -2421,14 +3564,6 @@ "true_strike" ], "reason": "无视闪避" - }, - { - "item": "sphere", - "name_loc": "林肯法球", - "tags": [ - "spell_block" - ], - "reason": "格挡点目标技能" } ], "pugna": [ @@ -2439,7 +3574,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 105, + "purchase_rate": 0.10224, + "win_rate": 0.371429, + "purchase_lift": 0.00359, + "win_delta": -0.093853 + } }, { "item": "disperser", @@ -2456,7 +3598,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 187, + "purchase_rate": 0.182084, + "win_rate": 0.566845, + "purchase_lift": 0.003934, + "win_delta": -0.005005 + } }, { "item": "nullifier", @@ -2464,7 +3613,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 233, + "purchase_rate": 0.226874, + "win_rate": 0.562232, + "purchase_lift": 0.146349, + "win_delta": -0.01616 + } }, { "item": "black_king_bar", @@ -2472,7 +3628,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱魔法输出" + "reason": "魔免削弱魔法输出", + "stats": { + "games": 634, + "purchase_rate": 0.617332, + "win_rate": 0.515773, + "purchase_lift": 0.020382, + "win_delta": -0.023803 + } }, { "item": "sphere", @@ -2488,7 +3651,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 301, + "purchase_rate": 0.293087, + "win_rate": 0.538206, + "purchase_lift": -0.006838, + "win_delta": 0.000738 + } }, { "item": "wind_waker", @@ -2506,7 +3676,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 186, + "purchase_rate": 0.08263, + "win_rate": 0.516129, + "purchase_lift": -0.01602, + "win_delta": 0.050848 + } }, { "item": "disperser", @@ -2522,7 +3699,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 118, + "purchase_rate": 0.052421, + "win_rate": 0.59322, + "purchase_lift": -0.028104, + "win_delta": 0.014829 + } }, { "item": "silver_edge", @@ -2530,7 +3714,14 @@ "tags": [ "invis_break" ], - "reason": "破隐" + "reason": "破隐", + "stats": { + "games": 196, + "purchase_rate": 0.087072, + "win_rate": 0.632653, + "purchase_lift": -0.015878, + "win_delta": 0.027019 + } }, { "item": "gem", @@ -2548,21 +3739,28 @@ ], "reason": "显影" }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" - }, { "item": "invis_sword", "name_loc": "影刃", "tags": [ "invis_break" ], - "reason": "破隐" + "reason": "破隐", + "stats": { + "games": 142, + "purchase_rate": 0.063083, + "win_rate": 0.528169, + "purchase_lift": -0.007667, + "win_delta": 0.033823 + } + }, + { + "item": "necronomicon", + "name_loc": "死灵书", + "tags": [ + "invis_detect" + ], + "reason": "显影" } ], "viper": [ @@ -2573,7 +3771,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 136, + "purchase_rate": 0.080378, + "win_rate": 0.492647, + "purchase_lift": -0.018272, + "win_delta": 0.027366 + } }, { "item": "disperser", @@ -2590,7 +3795,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 357, + "purchase_rate": 0.210993, + "win_rate": 0.616246, + "purchase_lift": 0.032843, + "win_delta": 0.044397 + } }, { "item": "nullifier", @@ -2598,7 +3810,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 151, + "purchase_rate": 0.089243, + "win_rate": 0.516556, + "purchase_lift": 0.008718, + "win_delta": -0.061836 + } }, { "item": "sphere", @@ -2606,7 +3825,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" + "reason": "格挡点目标技能", + "stats": { + "games": 253, + "purchase_rate": 0.149527, + "win_rate": 0.588933, + "purchase_lift": 0.054202, + "win_delta": -0.046525 + } }, { "item": "cyclone", @@ -2614,7 +3840,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 574, + "purchase_rate": 0.339243, + "win_rate": 0.545296, + "purchase_lift": 0.039318, + "win_delta": 0.007828 + } }, { "item": "wind_waker", @@ -2632,7 +3865,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 260, + "purchase_rate": 0.110922, + "win_rate": 0.546154, + "purchase_lift": -0.067228, + "win_delta": -0.025696 + } }, { "item": "black_king_bar", @@ -2640,7 +3880,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能;魔免抵消控制" + "reason": "魔免削弱技能;魔免抵消控制", + "stats": { + "games": 1273, + "purchase_rate": 0.543089, + "win_rate": 0.560094, + "purchase_lift": -0.053861, + "win_delta": 0.020518 + } }, { "item": "sphere", @@ -2648,7 +3895,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能;法术格挡" + "reason": "法术格挡关键技能;格挡点目标技能;法术格挡", + "stats": { + "games": 141, + "purchase_rate": 0.060154, + "win_rate": 0.659574, + "purchase_lift": -0.035171, + "win_delta": 0.024117 + } } ], "dragon_knight": [ @@ -2658,7 +3912,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 546, + "purchase_rate": 0.193343, + "win_rate": 0.619048, + "purchase_lift": 0.015193, + "win_delta": 0.047198 + } }, { "item": "diffusal_blade", @@ -2666,7 +3927,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 285, + "purchase_rate": 0.100921, + "win_rate": 0.449123, + "purchase_lift": 0.002271, + "win_delta": -0.016158 + } }, { "item": "disperser", @@ -2682,7 +3950,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 208, + "purchase_rate": 0.073654, + "win_rate": 0.572115, + "purchase_lift": -0.006871, + "win_delta": -0.006276 + } }, { "item": "silver_edge", @@ -2690,15 +3965,14 @@ "tags": [ "break" ], - "reason": "破坏关键被动" - }, - { - "item": "black_king_bar", - "name_loc": "黑皇杖", - "tags": [ - "magic_immune" - ], - "reason": "魔免削弱技能;魔免削弱魔法输出" + "reason": "破坏关键被动", + "stats": { + "games": 222, + "purchase_rate": 0.078612, + "win_rate": 0.657658, + "purchase_lift": -0.024338, + "win_delta": 0.052024 + } }, { "item": "sphere", @@ -2706,7 +3980,29 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能" + "reason": "法术格挡关键技能;格挡点目标技能", + "stats": { + "games": 311, + "purchase_rate": 0.110127, + "win_rate": 0.691318, + "purchase_lift": 0.014802, + "win_delta": 0.055861 + } + }, + { + "item": "black_king_bar", + "name_loc": "黑皇杖", + "tags": [ + "magic_immune" + ], + "reason": "魔免削弱技能;魔免削弱魔法输出", + "stats": { + "games": 1517, + "purchase_rate": 0.537181, + "win_rate": 0.555043, + "purchase_lift": -0.059769, + "win_delta": 0.015467 + } }, { "item": "cyclone", @@ -2714,7 +4010,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 766, + "purchase_rate": 0.271246, + "win_rate": 0.557441, + "purchase_lift": -0.028679, + "win_delta": 0.019974 + } } ], "dazzle": [ @@ -2724,7 +4027,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 159, + "purchase_rate": 0.180477, + "win_rate": 0.553459, + "purchase_lift": 0.002327, + "win_delta": -0.01839 + } }, { "item": "diffusal_blade", @@ -2758,14 +4068,6 @@ ], "reason": "格挡点目标技能" }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" - }, { "item": "wind_waker", "name_loc": "风之杖", @@ -2773,6 +4075,21 @@ "basic_dispel" ], "reason": "可驱散技能增益" + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 236, + "purchase_rate": 0.267877, + "win_rate": 0.5, + "purchase_lift": -0.032048, + "win_delta": -0.037468 + } } ], "rattletrap": [ @@ -2783,7 +4100,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 164, + "purchase_rate": 0.097794, + "win_rate": 0.402439, + "purchase_lift": -0.000856, + "win_delta": -0.062842 + } }, { "item": "disperser", @@ -2800,7 +4124,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 166, + "purchase_rate": 0.098986, + "win_rate": 0.590361, + "purchase_lift": 0.018461, + "win_delta": 0.01197 + } }, { "item": "black_king_bar", @@ -2808,7 +4139,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能;魔免抵消控制" + "reason": "魔免削弱技能;魔免抵消控制", + "stats": { + "games": 1162, + "purchase_rate": 0.692904, + "win_rate": 0.530981, + "purchase_lift": 0.095954, + "win_delta": -0.008595 + } }, { "item": "sphere", @@ -2816,15 +4154,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;法术格挡" - }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "法术格挡关键技能;法术格挡", + "stats": { + "games": 174, + "purchase_rate": 0.103757, + "win_rate": 0.649425, + "purchase_lift": 0.008432, + "win_delta": 0.013968 + } }, { "item": "wind_waker", @@ -2833,6 +4170,21 @@ "basic_dispel" ], "reason": "可驱散技能增益" + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 446, + "purchase_rate": 0.265951, + "win_rate": 0.535874, + "purchase_lift": -0.033974, + "win_delta": -0.001593 + } } ], "leshrac": [ @@ -2843,7 +4195,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 134, + "purchase_rate": 0.119964, + "win_rate": 0.440299, + "purchase_lift": 0.021314, + "win_delta": -0.024983 + } }, { "item": "disperser", @@ -2860,7 +4219,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 149, + "purchase_rate": 0.133393, + "win_rate": 0.604027, + "purchase_lift": -0.044757, + "win_delta": 0.032177 + } }, { "item": "nullifier", @@ -2868,7 +4234,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 122, + "purchase_rate": 0.109221, + "win_rate": 0.581967, + "purchase_lift": 0.028696, + "win_delta": 0.003575 + } }, { "item": "black_king_bar", @@ -2876,7 +4249,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能;魔免削弱魔法输出" + "reason": "魔免削弱技能;魔免削弱魔法输出", + "stats": { + "games": 713, + "purchase_rate": 0.638317, + "win_rate": 0.527349, + "purchase_lift": 0.041367, + "win_delta": -0.012227 + } }, { "item": "sphere", @@ -2884,15 +4264,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能" - }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "法术格挡关键技能;格挡点目标技能", + "stats": { + "games": 119, + "purchase_rate": 0.106535, + "win_rate": 0.563025, + "purchase_lift": 0.01121, + "win_delta": -0.072432 + } }, { "item": "wind_waker", @@ -2901,6 +4280,21 @@ "basic_dispel" ], "reason": "可驱散技能增益" + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 311, + "purchase_rate": 0.278424, + "win_rate": 0.530547, + "purchase_lift": -0.021501, + "win_delta": -0.006921 + } } ], "furion": [ @@ -2910,7 +4304,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 276, + "purchase_rate": 0.176358, + "win_rate": 0.561594, + "purchase_lift": -0.001792, + "win_delta": -0.010255 + } }, { "item": "diffusal_blade", @@ -2918,7 +4319,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 176, + "purchase_rate": 0.11246, + "win_rate": 0.392045, + "purchase_lift": 0.01381, + "win_delta": -0.073236 + } }, { "item": "disperser", @@ -2934,7 +4342,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 104, + "purchase_rate": 0.066454, + "win_rate": 0.653846, + "purchase_lift": -0.014071, + "win_delta": 0.075454 + } }, { "item": "sphere", @@ -2942,15 +4357,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" - }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "格挡点目标技能", + "stats": { + "games": 114, + "purchase_rate": 0.072843, + "win_rate": 0.640351, + "purchase_lift": -0.022482, + "win_delta": 0.004893 + } }, { "item": "wind_waker", @@ -2959,6 +4373,21 @@ "basic_dispel" ], "reason": "可驱散技能增益" + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 469, + "purchase_rate": 0.299681, + "win_rate": 0.501066, + "purchase_lift": -0.000244, + "win_delta": -0.036402 + } } ], "life_stealer": [ @@ -2968,7 +4397,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 231, + "purchase_rate": 0.149128, + "win_rate": 0.541126, + "purchase_lift": -0.029022, + "win_delta": -0.030724 + } }, { "item": "diffusal_blade", @@ -2976,7 +4412,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 138, + "purchase_rate": 0.08909, + "win_rate": 0.514493, + "purchase_lift": -0.00956, + "win_delta": 0.049211 + } }, { "item": "disperser", @@ -2992,7 +4435,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 102, + "purchase_rate": 0.065849, + "win_rate": 0.5, + "purchase_lift": -0.014676, + "win_delta": -0.078392 + } }, { "item": "sphere", @@ -3000,15 +4450,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" - }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "格挡点目标技能", + "stats": { + "games": 121, + "purchase_rate": 0.078115, + "win_rate": 0.619835, + "purchase_lift": -0.01721, + "win_delta": -0.015623 + } }, { "item": "wind_waker", @@ -3017,6 +4466,21 @@ "basic_dispel" ], "reason": "可驱散技能增益" + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 440, + "purchase_rate": 0.284054, + "win_rate": 0.520455, + "purchase_lift": -0.015871, + "win_delta": -0.017013 + } } ], "dark_seer": [ @@ -3050,7 +4514,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 382, + "purchase_rate": 0.667832, + "win_rate": 0.528796, + "purchase_lift": 0.070882, + "win_delta": -0.01078 + } }, { "item": "sphere", @@ -3066,7 +4537,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 179, + "purchase_rate": 0.312937, + "win_rate": 0.519553, + "purchase_lift": 0.013012, + "win_delta": -0.017915 + } }, { "item": "wind_waker", @@ -3084,7 +4562,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 191, + "purchase_rate": 0.169027, + "win_rate": 0.544503, + "purchase_lift": -0.009123, + "win_delta": -0.027347 + } }, { "item": "diffusal_blade", @@ -3116,7 +4601,14 @@ "tags": [ "invis_break" ], - "reason": "破隐" + "reason": "破隐", + "stats": { + "games": 124, + "purchase_rate": 0.109735, + "win_rate": 0.620968, + "purchase_lift": 0.006785, + "win_delta": 0.015334 + } }, { "item": "sphere", @@ -3124,7 +4616,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" + "reason": "格挡点目标技能", + "stats": { + "games": 118, + "purchase_rate": 0.104425, + "win_rate": 0.559322, + "purchase_lift": 0.0091, + "win_delta": -0.076136 + } }, { "item": "gem", @@ -3190,7 +4689,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 121, + "purchase_rate": 0.254737, + "win_rate": 0.553719, + "purchase_lift": -0.045188, + "win_delta": 0.016251 + } }, { "item": "wind_waker", @@ -3208,7 +4714,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 233, + "purchase_rate": 0.214747, + "win_rate": 0.545064, + "purchase_lift": 0.036597, + "win_delta": -0.026785 + } }, { "item": "diffusal_blade", @@ -3216,7 +4729,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 118, + "purchase_rate": 0.108756, + "win_rate": 0.440678, + "purchase_lift": 0.010106, + "win_delta": -0.024603 + } }, { "item": "disperser", @@ -3240,15 +4760,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" - }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "格挡点目标技能", + "stats": { + "games": 133, + "purchase_rate": 0.122581, + "win_rate": 0.609023, + "purchase_lift": 0.027256, + "win_delta": -0.026435 + } }, { "item": "wind_waker", @@ -3257,6 +4776,21 @@ "basic_dispel" ], "reason": "可驱散技能增益" + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 288, + "purchase_rate": 0.265438, + "win_rate": 0.486111, + "purchase_lift": -0.034487, + "win_delta": -0.051357 + } } ], "huskar": [ @@ -3284,7 +4818,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 125, + "purchase_rate": 0.121714, + "win_rate": 0.624, + "purchase_lift": -0.056436, + "win_delta": 0.05215 + } }, { "item": "nullifier", @@ -3294,21 +4835,35 @@ ], "reason": "可驱散技能增益" }, - { - "item": "black_king_bar", - "name_loc": "黑皇杖", - "tags": [ - "magic_immune" - ], - "reason": "魔免削弱魔法输出" - }, { "item": "sphere", "name_loc": "林肯法球", "tags": [ "spell_block" ], - "reason": "格挡点目标技能" + "reason": "格挡点目标技能", + "stats": { + "games": 107, + "purchase_rate": 0.104187, + "win_rate": 0.635514, + "purchase_lift": 0.008862, + "win_delta": 5.6e-05 + } + }, + { + "item": "black_king_bar", + "name_loc": "黑皇杖", + "tags": [ + "magic_immune" + ], + "reason": "魔免削弱魔法输出", + "stats": { + "games": 607, + "purchase_rate": 0.591042, + "win_rate": 0.522241, + "purchase_lift": -0.005908, + "win_delta": -0.017336 + } }, { "item": "cyclone", @@ -3316,7 +4871,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 306, + "purchase_rate": 0.297955, + "win_rate": 0.542484, + "purchase_lift": -0.00197, + "win_delta": 0.005016 + } }, { "item": "wind_waker", @@ -3334,7 +4896,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 118, + "purchase_rate": 0.127844, + "win_rate": 0.59322, + "purchase_lift": -0.050306, + "win_delta": 0.021371 + } }, { "item": "diffusal_blade", @@ -3374,7 +4943,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免抵消控制" + "reason": "魔免抵消控制", + "stats": { + "games": 611, + "purchase_rate": 0.661972, + "win_rate": 0.535188, + "purchase_lift": 0.065022, + "win_delta": -0.004388 + } }, { "item": "sphere", @@ -3390,7 +4966,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 271, + "purchase_rate": 0.293608, + "win_rate": 0.523985, + "purchase_lift": -0.006317, + "win_delta": -0.013482 + } } ], "broodmother": [ @@ -3456,7 +5039,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 147, + "purchase_rate": 0.337156, + "win_rate": 0.517007, + "purchase_lift": 0.037231, + "win_delta": -0.020461 + } } ], "bounty_hunter": [ @@ -3466,7 +5056,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 165, + "purchase_rate": 0.2657, + "win_rate": 0.527273, + "purchase_lift": 0.08755, + "win_delta": -0.044577 + } }, { "item": "diffusal_blade", @@ -3526,13 +5123,51 @@ } ], "weaver": [ + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel", + "cyclone" + ], + "reason": "驱散缩地状态并限制逃生", + "stats": { + "games": 453, + "purchase_rate": 0.345538, + "win_rate": 0.593819, + "purchase_lift": 0.045613, + "win_delta": 0.056351 + } + }, + { + "item": "ghost", + "name_loc": "幽魂权杖", + "tags": [ + "ethereal" + ], + "reason": "虚无规避连击与普攻爆发", + "stats": { + "games": 404, + "purchase_rate": 0.308162, + "win_rate": 0.591584, + "purchase_lift": 0.107887, + "win_delta": 0.069427 + } + }, { "item": "lotus_orb", "name_loc": "清莲宝珠", "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 196, + "purchase_rate": 0.149504, + "win_rate": 0.612245, + "purchase_lift": -0.028646, + "win_delta": 0.040395 + } }, { "item": "silver_edge", @@ -3540,7 +5175,14 @@ "tags": [ "invis_break" ], - "reason": "破隐" + "reason": "破隐", + "stats": { + "games": 101, + "purchase_rate": 0.07704, + "win_rate": 0.70297, + "purchase_lift": -0.02591, + "win_delta": 0.097336 + } }, { "item": "sphere", @@ -3548,7 +5190,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" + "reason": "格挡点目标技能", + "stats": { + "games": 132, + "purchase_rate": 0.100686, + "win_rate": 0.719697, + "purchase_lift": 0.005361, + "win_delta": 0.084239 + } }, { "item": "gem", @@ -3573,22 +5222,6 @@ "invis_break" ], "reason": "破隐" - }, - { - "item": "necronomicon", - "name_loc": "死灵书", - "tags": [ - "invis_detect" - ], - "reason": "显影" - }, - { - "item": "necronomicon_2", - "name_loc": "死灵书", - "tags": [ - "invis_detect" - ], - "reason": "显影" } ], "jakiro": [ @@ -3598,7 +5231,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 754, + "purchase_rate": 0.179866, + "win_rate": 0.575597, + "purchase_lift": 0.001716, + "win_delta": 0.003747 + } }, { "item": "diffusal_blade", @@ -3606,23 +5246,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" - }, - { - "item": "disperser", - "name_loc": "散魂剑", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" - }, - { - "item": "nullifier", - "name_loc": "否决坠饰", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 369, + "purchase_rate": 0.088025, + "win_rate": 0.476965, + "purchase_lift": -0.010625, + "win_delta": 0.011683 + } }, { "item": "black_king_bar", @@ -3630,7 +5261,44 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 2733, + "purchase_rate": 0.651956, + "win_rate": 0.545554, + "purchase_lift": 0.055006, + "win_delta": 0.005978 + } + }, + { + "item": "disperser", + "name_loc": "散魂剑", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 161, + "purchase_rate": 0.038406, + "win_rate": 0.63354, + "purchase_lift": -0.000169, + "win_delta": -0.025565 + } + }, + { + "item": "nullifier", + "name_loc": "否决坠饰", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 380, + "purchase_rate": 0.090649, + "win_rate": 0.552632, + "purchase_lift": 0.010124, + "win_delta": -0.02576 + } }, { "item": "sphere", @@ -3638,7 +5306,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能" + "reason": "法术格挡关键技能;格挡点目标技能", + "stats": { + "games": 425, + "purchase_rate": 0.101384, + "win_rate": 0.588235, + "purchase_lift": 0.006059, + "win_delta": -0.047222 + } }, { "item": "cyclone", @@ -3646,7 +5321,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 1270, + "purchase_rate": 0.302958, + "win_rate": 0.551181, + "purchase_lift": 0.003033, + "win_delta": 0.013713 + } }, { "item": "wind_waker", @@ -3654,7 +5336,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 115, + "purchase_rate": 0.027433, + "win_rate": 0.721739, + "purchase_lift": -0.002192, + "win_delta": 0.054229 + } } ], "batrider": [ @@ -3664,7 +5353,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 612, + "purchase_rate": 0.347333, + "win_rate": 0.563725, + "purchase_lift": 0.169183, + "win_delta": -0.008124 + } }, { "item": "diffusal_blade", @@ -3672,7 +5368,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 195, + "purchase_rate": 0.11067, + "win_rate": 0.374359, + "purchase_lift": 0.01202, + "win_delta": -0.090922 + } }, { "item": "disperser", @@ -3688,7 +5391,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 114, + "purchase_rate": 0.064699, + "win_rate": 0.552632, + "purchase_lift": -0.015826, + "win_delta": -0.02576 + } }, { "item": "black_king_bar", @@ -3696,7 +5406,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 1233, + "purchase_rate": 0.699773, + "win_rate": 0.493917, + "purchase_lift": 0.102823, + "win_delta": -0.045659 + } }, { "item": "sphere", @@ -3704,7 +5421,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能" + "reason": "法术格挡关键技能;格挡点目标技能", + "stats": { + "games": 477, + "purchase_rate": 0.270715, + "win_rate": 0.603774, + "purchase_lift": 0.17539, + "win_delta": -0.031684 + } }, { "item": "cyclone", @@ -3712,7 +5436,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 558, + "purchase_rate": 0.316686, + "win_rate": 0.510753, + "purchase_lift": 0.016761, + "win_delta": -0.026715 + } }, { "item": "wind_waker", @@ -3730,7 +5461,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 160, + "purchase_rate": 0.20025, + "win_rate": 0.56875, + "purchase_lift": 0.0221, + "win_delta": -0.0031 + } }, { "item": "diffusal_blade", @@ -3764,14 +5502,6 @@ ], "reason": "格挡点目标技能" }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" - }, { "item": "wind_waker", "name_loc": "风之杖", @@ -3779,6 +5509,21 @@ "basic_dispel" ], "reason": "可驱散技能增益" + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 231, + "purchase_rate": 0.289111, + "win_rate": 0.489177, + "purchase_lift": -0.010814, + "win_delta": -0.04829 + } } ], "spectre": [ @@ -3872,7 +5617,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 141, + "purchase_rate": 0.139604, + "win_rate": 0.631206, + "purchase_lift": -0.038546, + "win_delta": 0.059356 + } }, { "item": "nullifier", @@ -3888,7 +5640,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱魔法输出" + "reason": "魔免削弱魔法输出", + "stats": { + "games": 592, + "purchase_rate": 0.586139, + "win_rate": 0.554054, + "purchase_lift": -0.010811, + "win_delta": 0.014478 + } }, { "item": "sphere", @@ -3896,7 +5655,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" + "reason": "格挡点目标技能", + "stats": { + "games": 110, + "purchase_rate": 0.108911, + "win_rate": 0.6, + "purchase_lift": 0.013586, + "win_delta": -0.035458 + } }, { "item": "cyclone", @@ -3904,7 +5670,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 332, + "purchase_rate": 0.328713, + "win_rate": 0.53012, + "purchase_lift": 0.028788, + "win_delta": -0.007347 + } }, { "item": "wind_waker", @@ -3922,7 +5695,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射末日;反射点目标技能" + "reason": "反射末日;反射点目标技能", + "stats": { + "games": 484, + "purchase_rate": 0.308477, + "win_rate": 0.504132, + "purchase_lift": 0.130327, + "win_delta": -0.067717 + } }, { "item": "diffusal_blade", @@ -3930,7 +5710,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 167, + "purchase_rate": 0.106437, + "win_rate": 0.39521, + "purchase_lift": 0.007787, + "win_delta": -0.070072 + } }, { "item": "disperser", @@ -3946,7 +5733,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 130, + "purchase_rate": 0.082855, + "win_rate": 0.530769, + "purchase_lift": 0.00233, + "win_delta": -0.047623 + } }, { "item": "sphere", @@ -3954,15 +5748,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" - }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "格挡点目标技能", + "stats": { + "games": 480, + "purchase_rate": 0.305927, + "win_rate": 0.6125, + "purchase_lift": 0.210602, + "win_delta": -0.022958 + } }, { "item": "wind_waker", @@ -3971,6 +5764,21 @@ "basic_dispel" ], "reason": "可驱散技能增益" + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 431, + "purchase_rate": 0.274697, + "win_rate": 0.4942, + "purchase_lift": -0.025228, + "win_delta": -0.043268 + } } ], "ursa": [ @@ -3980,15 +5788,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" - }, - { - "item": "disperser", - "name_loc": "散魂剑", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 219, + "purchase_rate": 0.091364, + "win_rate": 0.465753, + "purchase_lift": -0.007286, + "win_delta": 0.000472 + } }, { "item": "nullifier", @@ -3996,6 +5803,21 @@ "tags": [ "basic_dispel" ], + "reason": "可驱散技能增益", + "stats": { + "games": 269, + "purchase_rate": 0.112224, + "win_rate": 0.587361, + "purchase_lift": 0.031699, + "win_delta": 0.008969 + } + }, + { + "item": "disperser", + "name_loc": "散魂剑", + "tags": [ + "basic_dispel" + ], "reason": "可驱散技能增益" }, { @@ -4004,7 +5826,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 1276, + "purchase_rate": 0.532332, + "win_rate": 0.541536, + "purchase_lift": 0.232407, + "win_delta": 0.004068 + } }, { "item": "wind_waker", @@ -4012,17 +5841,31 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 100, + "purchase_rate": 0.041719, + "win_rate": 0.69, + "purchase_lift": 0.012094, + "win_delta": 0.022489 + } } ], "spirit_breaker": [ { - "item": "lotus_orb", - "name_loc": "清莲宝珠", + "item": "sphere", + "name_loc": "林肯法球", "tags": [ - "spell_reflect" + "spell_block" ], - "reason": "反射点目标技能" + "reason": "法术格挡关键技能;格挡点目标技能;法术格挡", + "stats": { + "games": 464, + "purchase_rate": 0.223292, + "win_rate": 0.637931, + "purchase_lift": 0.127967, + "win_delta": 0.002473 + } }, { "item": "diffusal_blade", @@ -4030,7 +5873,29 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 225, + "purchase_rate": 0.108277, + "win_rate": 0.444444, + "purchase_lift": 0.009627, + "win_delta": -0.020837 + } + }, + { + "item": "lotus_orb", + "name_loc": "清莲宝珠", + "tags": [ + "spell_reflect" + ], + "reason": "反射点目标技能", + "stats": { + "games": 251, + "purchase_rate": 0.120789, + "win_rate": 0.545817, + "purchase_lift": -0.057361, + "win_delta": -0.026033 + } }, { "item": "disperser", @@ -4046,7 +5911,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 115, + "purchase_rate": 0.055342, + "win_rate": 0.513043, + "purchase_lift": -0.025183, + "win_delta": -0.065348 + } }, { "item": "silver_edge", @@ -4054,23 +5926,14 @@ "tags": [ "break" ], - "reason": "破坏关键被动" - }, - { - "item": "black_king_bar", - "name_loc": "黑皇杖", - "tags": [ - "magic_immune" - ], - "reason": "魔免削弱技能;魔免抵消控制" - }, - { - "item": "sphere", - "name_loc": "林肯法球", - "tags": [ - "spell_block" - ], - "reason": "法术格挡关键技能;格挡点目标技能;法术格挡" + "reason": "破坏关键被动", + "stats": { + "games": 190, + "purchase_rate": 0.091434, + "win_rate": 0.642105, + "purchase_lift": -0.011516, + "win_delta": 0.036471 + } }, { "item": "cyclone", @@ -4078,6 +5941,21 @@ "tags": [ "basic_dispel" ], + "reason": "可驱散技能增益", + "stats": { + "games": 1001, + "purchase_rate": 0.481713, + "win_rate": 0.501499, + "purchase_lift": 0.181788, + "win_delta": -0.035969 + } + }, + { + "item": "wind_waker", + "name_loc": "风之杖", + "tags": [ + "basic_dispel" + ], "reason": "可驱散技能增益" } ], @@ -4088,7 +5966,29 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 359, + "purchase_rate": 0.17995, + "win_rate": 0.610028, + "purchase_lift": 0.0018, + "win_delta": 0.038178 + } + }, + { + "item": "black_king_bar", + "name_loc": "黑皇杖", + "tags": [ + "magic_immune" + ], + "reason": "魔免削弱技能", + "stats": { + "games": 1330, + "purchase_rate": 0.666667, + "win_rate": 0.549624, + "purchase_lift": 0.069717, + "win_delta": 0.010048 + } }, { "item": "diffusal_blade", @@ -4096,7 +5996,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 176, + "purchase_rate": 0.088221, + "win_rate": 0.517045, + "purchase_lift": -0.010429, + "win_delta": 0.051764 + } }, { "item": "disperser", @@ -4112,15 +6019,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" - }, - { - "item": "black_king_bar", - "name_loc": "黑皇杖", - "tags": [ - "magic_immune" - ], - "reason": "魔免削弱技能" + "reason": "可驱散技能增益", + "stats": { + "games": 188, + "purchase_rate": 0.094236, + "win_rate": 0.553191, + "purchase_lift": 0.013711, + "win_delta": -0.0252 + } }, { "item": "sphere", @@ -4128,7 +6034,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能" + "reason": "法术格挡关键技能;格挡点目标技能", + "stats": { + "games": 170, + "purchase_rate": 0.085213, + "win_rate": 0.641176, + "purchase_lift": -0.010112, + "win_delta": 0.005719 + } }, { "item": "cyclone", @@ -4136,7 +6049,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 537, + "purchase_rate": 0.269173, + "win_rate": 0.543762, + "purchase_lift": -0.030752, + "win_delta": 0.006294 + } }, { "item": "wind_waker", @@ -4154,7 +6074,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 115, + "purchase_rate": 0.124864, + "win_rate": 0.408696, + "purchase_lift": 0.026214, + "win_delta": -0.056586 + } }, { "item": "disperser", @@ -4178,7 +6105,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 596, + "purchase_rate": 0.647123, + "win_rate": 0.508389, + "purchase_lift": 0.050173, + "win_delta": -0.031187 + } }, { "item": "sphere", @@ -4186,15 +6120,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能" - }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "法术格挡关键技能", + "stats": { + "games": 122, + "purchase_rate": 0.132465, + "win_rate": 0.557377, + "purchase_lift": 0.03714, + "win_delta": -0.078081 + } }, { "item": "wind_waker", @@ -4203,6 +6136,21 @@ "basic_dispel" ], "reason": "可驱散技能增益" + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 245, + "purchase_rate": 0.266015, + "win_rate": 0.481633, + "purchase_lift": -0.03391, + "win_delta": -0.055835 + } } ], "invoker": [ @@ -4213,7 +6161,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 252, + "purchase_rate": 0.105044, + "win_rate": 0.43254, + "purchase_lift": 0.006394, + "win_delta": -0.032742 + } }, { "item": "disperser", @@ -4230,7 +6185,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 338, + "purchase_rate": 0.140892, + "win_rate": 0.568047, + "purchase_lift": -0.037258, + "win_delta": -0.003802 + } }, { "item": "nullifier", @@ -4238,7 +6200,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 125, + "purchase_rate": 0.052105, + "win_rate": 0.496, + "purchase_lift": -0.02842, + "win_delta": -0.082392 + } }, { "item": "sphere", @@ -4246,7 +6215,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" + "reason": "格挡点目标技能", + "stats": { + "games": 199, + "purchase_rate": 0.082951, + "win_rate": 0.61809, + "purchase_lift": -0.012374, + "win_delta": -0.017367 + } }, { "item": "cyclone", @@ -4254,7 +6230,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 734, + "purchase_rate": 0.305961, + "win_rate": 0.53406, + "purchase_lift": 0.006036, + "win_delta": -0.003408 + } }, { "item": "wind_waker", @@ -4272,7 +6255,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 522, + "purchase_rate": 0.319461, + "win_rate": 0.60728, + "purchase_lift": 0.141311, + "win_delta": 0.03543 + } }, { "item": "diffusal_blade", @@ -4280,7 +6270,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 138, + "purchase_rate": 0.084455, + "win_rate": 0.471014, + "purchase_lift": -0.014195, + "win_delta": 0.005733 + } }, { "item": "disperser", @@ -4296,7 +6293,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 106, + "purchase_rate": 0.064871, + "win_rate": 0.669811, + "purchase_lift": -0.015654, + "win_delta": 0.09142 + } }, { "item": "black_king_bar", @@ -4304,15 +6308,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免抵消控制" - }, - { - "item": "sphere", - "name_loc": "林肯法球", - "tags": [ - "spell_block" - ], - "reason": "格挡点目标技能;法术格挡" + "reason": "魔免抵消控制", + "stats": { + "games": 863, + "purchase_rate": 0.528152, + "win_rate": 0.604867, + "purchase_lift": -0.068798, + "win_delta": 0.065291 + } }, { "item": "cyclone", @@ -4320,7 +6323,29 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 595, + "purchase_rate": 0.364137, + "win_rate": 0.571429, + "purchase_lift": 0.064212, + "win_delta": 0.033961 + } + }, + { + "item": "sphere", + "name_loc": "林肯法球", + "tags": [ + "spell_block" + ], + "reason": "格挡点目标技能;法术格挡", + "stats": { + "games": 105, + "purchase_rate": 0.064259, + "win_rate": 0.695238, + "purchase_lift": -0.031066, + "win_delta": 0.05978 + } }, { "item": "wind_waker", @@ -4380,7 +6405,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 116, + "purchase_rate": 0.274232, + "win_rate": 0.586207, + "purchase_lift": -0.025693, + "win_delta": 0.048739 + } }, { "item": "wind_waker", @@ -4447,7 +6479,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 134, + "purchase_rate": 0.260194, + "win_rate": 0.559701, + "purchase_lift": -0.039731, + "win_delta": 0.022234 + } }, { "item": "invis_sword", @@ -4491,7 +6530,14 @@ "tags": [ "true_strike" ], - "reason": "无视闪避" + "reason": "无视闪避", + "stats": { + "games": 141, + "purchase_rate": 0.167658, + "win_rate": 0.51773, + "purchase_lift": 0.076758, + "win_delta": -0.052402 + } }, { "item": "bloodthorn", @@ -4507,7 +6553,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 268, + "purchase_rate": 0.318668, + "win_rate": 0.533582, + "purchase_lift": 0.018743, + "win_delta": -0.003886 + } }, { "item": "wind_waker", @@ -4525,7 +6578,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 594, + "purchase_rate": 0.224745, + "win_rate": 0.563973, + "purchase_lift": 0.046595, + "win_delta": -0.007877 + } }, { "item": "bfury", @@ -4533,23 +6593,14 @@ "tags": [ "illusion_clear" ], - "reason": "克制幻象" - }, - { - "item": "mjollnir", - "name_loc": "雷神之锤", - "tags": [ - "illusion_clear" - ], - "reason": "克制幻象" - }, - { - "item": "radiance", - "name_loc": "辉耀", - "tags": [ - "illusion_clear" - ], - "reason": "克制幻象" + "reason": "克制幻象", + "stats": { + "games": 391, + "purchase_rate": 0.147938, + "win_rate": 0.485934, + "purchase_lift": -0.012612, + "win_delta": -0.003322 + } }, { "item": "gungir", @@ -4557,7 +6608,29 @@ "tags": [ "illusion_clear" ], - "reason": "克制幻象" + "reason": "克制幻象", + "stats": { + "games": 153, + "purchase_rate": 0.057889, + "win_rate": 0.673203, + "purchase_lift": 0.006089, + "win_delta": 0.024554 + } + }, + { + "item": "radiance", + "name_loc": "辉耀", + "tags": [ + "illusion_clear" + ], + "reason": "克制幻象", + "stats": { + "games": 319, + "purchase_rate": 0.120696, + "win_rate": 0.526646, + "purchase_lift": -0.028479, + "win_delta": -0.003269 + } }, { "item": "maelstrom", @@ -4565,7 +6638,29 @@ "tags": [ "illusion_clear" ], - "reason": "克制幻象" + "reason": "克制幻象", + "stats": { + "games": 115, + "purchase_rate": 0.043511, + "win_rate": 0.304348, + "purchase_lift": -0.016289, + "win_delta": -0.068144 + } + }, + { + "item": "mjollnir", + "name_loc": "雷神之锤", + "tags": [ + "illusion_clear" + ], + "reason": "克制幻象", + "stats": { + "games": 386, + "purchase_rate": 0.146046, + "win_rate": 0.497409, + "purchase_lift": -0.015779, + "win_delta": -0.052722 + } }, { "item": "sphere", @@ -4573,7 +6668,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" + "reason": "格挡点目标技能", + "stats": { + "games": 365, + "purchase_rate": 0.138101, + "win_rate": 0.6, + "purchase_lift": 0.042776, + "win_delta": -0.035458 + } } ], "lone_druid": [ @@ -4607,7 +6709,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免抵消控制" + "reason": "魔免抵消控制", + "stats": { + "games": 300, + "purchase_rate": 0.613497, + "win_rate": 0.426667, + "purchase_lift": 0.016547, + "win_delta": -0.11291 + } }, { "item": "sphere", @@ -4617,14 +6726,6 @@ ], "reason": "法术格挡" }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" - }, { "item": "wind_waker", "name_loc": "风之杖", @@ -4632,6 +6733,21 @@ "basic_dispel" ], "reason": "可驱散技能增益" + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 128, + "purchase_rate": 0.261759, + "win_rate": 0.460938, + "purchase_lift": -0.038166, + "win_delta": -0.07653 + } } ], "chaos_knight": [ @@ -4667,13 +6783,35 @@ ], "reason": "可驱散技能增益" }, + { + "item": "radiance", + "name_loc": "辉耀", + "tags": [ + "illusion_clear" + ], + "reason": "克制幻象", + "stats": { + "games": 197, + "purchase_rate": 0.255844, + "win_rate": 0.604061, + "purchase_lift": 0.106669, + "win_delta": 0.074146 + } + }, { "item": "bfury", "name_loc": "狂战斧", "tags": [ "illusion_clear" ], - "reason": "克制幻象" + "reason": "克制幻象", + "stats": { + "games": 150, + "purchase_rate": 0.194805, + "win_rate": 0.433333, + "purchase_lift": 0.034255, + "win_delta": -0.055922 + } }, { "item": "mjollnir", @@ -4681,15 +6819,14 @@ "tags": [ "illusion_clear" ], - "reason": "克制幻象" - }, - { - "item": "radiance", - "name_loc": "辉耀", - "tags": [ - "illusion_clear" - ], - "reason": "克制幻象" + "reason": "克制幻象", + "stats": { + "games": 167, + "purchase_rate": 0.216883, + "win_rate": 0.568862, + "purchase_lift": 0.055058, + "win_delta": 0.018731 + } }, { "item": "gungir", @@ -4739,7 +6876,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免抵消控制" + "reason": "魔免抵消控制", + "stats": { + "games": 130, + "purchase_rate": 0.467626, + "win_rate": 0.546154, + "purchase_lift": -0.129324, + "win_delta": 0.006578 + } }, { "item": "sphere", @@ -4773,7 +6917,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 393, + "purchase_rate": 0.281116, + "win_rate": 0.496183, + "purchase_lift": 0.102966, + "win_delta": -0.075666 + } }, { "item": "diffusal_blade", @@ -4781,7 +6932,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 119, + "purchase_rate": 0.085122, + "win_rate": 0.336134, + "purchase_lift": -0.013528, + "win_delta": -0.129147 + } }, { "item": "disperser", @@ -4789,7 +6947,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 101, + "purchase_rate": 0.072246, + "win_rate": 0.60396, + "purchase_lift": 0.033671, + "win_delta": -0.055145 + } }, { "item": "nullifier", @@ -4805,7 +6970,14 @@ "tags": [ "invis_break" ], - "reason": "破隐" + "reason": "破隐", + "stats": { + "games": 167, + "purchase_rate": 0.119456, + "win_rate": 0.610778, + "purchase_lift": 0.016506, + "win_delta": 0.005145 + } }, { "item": "black_king_bar", @@ -4813,7 +6985,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免抵消控制" + "reason": "魔免抵消控制", + "stats": { + "games": 898, + "purchase_rate": 0.642346, + "win_rate": 0.443207, + "purchase_lift": 0.045396, + "win_delta": -0.096369 + } }, { "item": "sphere", @@ -4821,7 +7000,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能;法术格挡" + "reason": "格挡点目标技能;法术格挡", + "stats": { + "games": 133, + "purchase_rate": 0.095136, + "win_rate": 0.556391, + "purchase_lift": -0.000189, + "win_delta": -0.079067 + } }, { "item": "gem", @@ -4840,7 +7026,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 300, + "purchase_rate": 0.098652, + "win_rate": 0.506667, + "purchase_lift": 2e-06, + "win_delta": 0.041385 + } }, { "item": "disperser", @@ -4857,7 +7050,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 378, + "purchase_rate": 0.124301, + "win_rate": 0.560847, + "purchase_lift": -0.053849, + "win_delta": -0.011003 + } }, { "item": "nullifier", @@ -4873,7 +7073,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能;魔免抵消控制" + "reason": "魔免削弱技能;魔免抵消控制", + "stats": { + "games": 1165, + "purchase_rate": 0.383098, + "win_rate": 0.567382, + "purchase_lift": -0.213852, + "win_delta": 0.027806 + } }, { "item": "sphere", @@ -4881,7 +7088,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能;法术格挡" + "reason": "法术格挡关键技能;格挡点目标技能;法术格挡", + "stats": { + "games": 106, + "purchase_rate": 0.034857, + "win_rate": 0.650943, + "purchase_lift": -0.060468, + "win_delta": 0.015486 + } }, { "item": "cyclone", @@ -4889,7 +7103,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 877, + "purchase_rate": 0.288392, + "win_rate": 0.566705, + "purchase_lift": -0.011533, + "win_delta": 0.029237 + } }, { "item": "wind_waker", @@ -4907,7 +7128,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 186, + "purchase_rate": 0.176806, + "win_rate": 0.543011, + "purchase_lift": -0.001344, + "win_delta": -0.028839 + } }, { "item": "sphere", @@ -4919,21 +7147,20 @@ } ], "rubick": [ - { - "item": "lotus_orb", - "name_loc": "清莲宝珠", - "tags": [ - "spell_reflect" - ], - "reason": "反射点目标技能" - }, { "item": "diffusal_blade", "name_loc": "净魂之刃", "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 494, + "purchase_rate": 0.108049, + "win_rate": 0.459514, + "purchase_lift": 0.009399, + "win_delta": -0.005767 + } }, { "item": "disperser", @@ -4941,7 +7168,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 196, + "purchase_rate": 0.04287, + "win_rate": 0.617347, + "purchase_lift": 0.004295, + "win_delta": -0.041759 + } }, { "item": "nullifier", @@ -4949,7 +7183,29 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 312, + "purchase_rate": 0.068241, + "win_rate": 0.634615, + "purchase_lift": -0.012284, + "win_delta": 0.056224 + } + }, + { + "item": "lotus_orb", + "name_loc": "清莲宝珠", + "tags": [ + "spell_reflect" + ], + "reason": "反射点目标技能", + "stats": { + "games": 620, + "purchase_rate": 0.135608, + "win_rate": 0.546774, + "purchase_lift": -0.042542, + "win_delta": -0.025075 + } }, { "item": "black_king_bar", @@ -4957,7 +7213,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 2416, + "purchase_rate": 0.528434, + "win_rate": 0.550083, + "purchase_lift": -0.068516, + "win_delta": 0.010507 + } }, { "item": "sphere", @@ -4965,15 +7228,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能" - }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "法术格挡关键技能;格挡点目标技能", + "stats": { + "games": 294, + "purchase_rate": 0.064304, + "win_rate": 0.636054, + "purchase_lift": -0.031021, + "win_delta": 0.000597 + } }, { "item": "wind_waker", @@ -4981,7 +7243,29 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 130, + "purchase_rate": 0.028434, + "win_rate": 0.607692, + "purchase_lift": -0.001191, + "win_delta": -0.059818 + } + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 1199, + "purchase_rate": 0.262248, + "win_rate": 0.522936, + "purchase_lift": -0.037677, + "win_delta": -0.014532 + } } ], "disruptor": [ @@ -4991,7 +7275,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 490, + "purchase_rate": 0.133261, + "win_rate": 0.589796, + "purchase_lift": -0.044889, + "win_delta": 0.017946 + } }, { "item": "diffusal_blade", @@ -4999,7 +7290,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 393, + "purchase_rate": 0.106881, + "win_rate": 0.465649, + "purchase_lift": 0.008231, + "win_delta": 0.000368 + } }, { "item": "disperser", @@ -5007,7 +7305,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 116, + "purchase_rate": 0.031547, + "win_rate": 0.655172, + "purchase_lift": -0.007028, + "win_delta": -0.003933 + } }, { "item": "nullifier", @@ -5015,7 +7320,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 240, + "purchase_rate": 0.065271, + "win_rate": 0.575, + "purchase_lift": -0.015254, + "win_delta": -0.003392 + } }, { "item": "sphere", @@ -5023,7 +7335,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" + "reason": "格挡点目标技能", + "stats": { + "games": 252, + "purchase_rate": 0.068534, + "win_rate": 0.698413, + "purchase_lift": -0.026791, + "win_delta": 0.062955 + } }, { "item": "cyclone", @@ -5031,7 +7350,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 1140, + "purchase_rate": 0.310035, + "win_rate": 0.537719, + "purchase_lift": 0.01011, + "win_delta": 0.000252 + } }, { "item": "wind_waker", @@ -5039,7 +7365,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 100, + "purchase_rate": 0.027196, + "win_rate": 0.76, + "purchase_lift": -0.002429, + "win_delta": 0.092489 + } } ], "nyx_assassin": [ @@ -5049,7 +7382,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 166, + "purchase_rate": 0.152995, + "win_rate": 0.5, + "purchase_lift": -0.025155, + "win_delta": -0.07185 + } }, { "item": "diffusal_blade", @@ -5057,7 +7397,14 @@ "tags": [ "mana_burn" ], - "reason": "烧蓝克制" + "reason": "烧蓝克制", + "stats": { + "games": 103, + "purchase_rate": 0.094931, + "win_rate": 0.417476, + "purchase_lift": -0.003719, + "win_delta": -0.047806 + } }, { "item": "disperser", @@ -5073,7 +7420,14 @@ "tags": [ "invis_break" ], - "reason": "破隐" + "reason": "破隐", + "stats": { + "games": 117, + "purchase_rate": 0.107834, + "win_rate": 0.615385, + "purchase_lift": 0.004884, + "win_delta": 0.009751 + } }, { "item": "black_king_bar", @@ -5081,7 +7435,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能;魔免抵消控制" + "reason": "魔免削弱技能;魔免抵消控制", + "stats": { + "games": 776, + "purchase_rate": 0.715207, + "win_rate": 0.485825, + "purchase_lift": 0.118257, + "win_delta": -0.053751 + } }, { "item": "sphere", @@ -5089,7 +7450,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能;法术格挡" + "reason": "法术格挡关键技能;格挡点目标技能;法术格挡", + "stats": { + "games": 111, + "purchase_rate": 0.102304, + "win_rate": 0.576577, + "purchase_lift": 0.006979, + "win_delta": -0.058881 + } }, { "item": "gem", @@ -5115,7 +7483,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 194, + "purchase_rate": 0.378906, + "win_rate": 0.530928, + "purchase_lift": 0.200756, + "win_delta": -0.040922 + } }, { "item": "diffusal_blade", @@ -5163,7 +7538,14 @@ "tags": [ "illusion_clear" ], - "reason": "克制幻象" + "reason": "克制幻象", + "stats": { + "games": 102, + "purchase_rate": 0.199219, + "win_rate": 0.519608, + "purchase_lift": 0.037394, + "win_delta": -0.030523 + } }, { "item": "radiance", @@ -5182,7 +7564,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 100, + "purchase_rate": 0.080064, + "win_rate": 0.43, + "purchase_lift": -0.018586, + "win_delta": -0.035281 + } }, { "item": "disperser", @@ -5199,7 +7588,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 349, + "purchase_rate": 0.279424, + "win_rate": 0.541547, + "purchase_lift": 0.101274, + "win_delta": -0.030302 + } }, { "item": "nullifier", @@ -5207,7 +7603,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 137, + "purchase_rate": 0.109688, + "win_rate": 0.627737, + "purchase_lift": 0.029163, + "win_delta": 0.049345 + } }, { "item": "sphere", @@ -5215,15 +7618,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" - }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "格挡点目标技能", + "stats": { + "games": 124, + "purchase_rate": 0.099279, + "win_rate": 0.685484, + "purchase_lift": 0.003954, + "win_delta": 0.050026 + } }, { "item": "wind_waker", @@ -5232,6 +7634,21 @@ "basic_dispel" ], "reason": "可驱散技能增益" + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 341, + "purchase_rate": 0.273018, + "win_rate": 0.519062, + "purchase_lift": -0.026907, + "win_delta": -0.018406 + } } ], "wisp": [], @@ -5308,7 +7725,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 139, + "purchase_rate": 0.149142, + "win_rate": 0.546763, + "purchase_lift": -0.029008, + "win_delta": -0.025087 + } }, { "item": "diffusal_blade", @@ -5375,7 +7799,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 273, + "purchase_rate": 0.279141, + "win_rate": 0.571429, + "purchase_lift": 0.180491, + "win_delta": 0.106147 + } }, { "item": "disperser", @@ -5384,7 +7815,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 128, + "purchase_rate": 0.130879, + "win_rate": 0.703125, + "purchase_lift": 0.092304, + "win_delta": 0.044019 + } }, { "item": "lotus_orb", @@ -5392,7 +7830,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 155, + "purchase_rate": 0.158487, + "win_rate": 0.619355, + "purchase_lift": -0.019663, + "win_delta": 0.047505 + } }, { "item": "nullifier", @@ -5416,7 +7861,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免抵消控制" + "reason": "魔免抵消控制", + "stats": { + "games": 536, + "purchase_rate": 0.548057, + "win_rate": 0.561567, + "purchase_lift": -0.048893, + "win_delta": 0.021991 + } }, { "item": "sphere", @@ -5432,7 +7884,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 307, + "purchase_rate": 0.313906, + "win_rate": 0.57329, + "purchase_lift": 0.013981, + "win_delta": 0.035822 + } } ], "troll_warlord": [ @@ -5450,7 +7909,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 100, + "purchase_rate": 0.111857, + "win_rate": 0.5, + "purchase_lift": 0.013207, + "win_delta": 0.034719 + } }, { "item": "disperser", @@ -5482,7 +7948,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 367, + "purchase_rate": 0.410515, + "win_rate": 0.569482, + "purchase_lift": 0.11059, + "win_delta": 0.032015 + } }, { "item": "wind_waker", @@ -5494,21 +7967,35 @@ } ], "centaur": [ - { - "item": "lotus_orb", - "name_loc": "清莲宝珠", - "tags": [ - "spell_reflect" - ], - "reason": "反射点目标技能" - }, { "item": "diffusal_blade", "name_loc": "净魂之刃", "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 247, + "purchase_rate": 0.099798, + "win_rate": 0.441296, + "purchase_lift": 0.001148, + "win_delta": -0.023986 + } + }, + { + "item": "lotus_orb", + "name_loc": "清莲宝珠", + "tags": [ + "spell_reflect" + ], + "reason": "反射点目标技能", + "stats": { + "games": 410, + "purchase_rate": 0.165657, + "win_rate": 0.534146, + "purchase_lift": -0.012493, + "win_delta": -0.037703 + } }, { "item": "disperser", @@ -5524,7 +8011,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 270, + "purchase_rate": 0.109091, + "win_rate": 0.548148, + "purchase_lift": 0.028566, + "win_delta": -0.030244 + } }, { "item": "black_king_bar", @@ -5532,7 +8026,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 1693, + "purchase_rate": 0.68404, + "win_rate": 0.520378, + "purchase_lift": 0.08709, + "win_delta": -0.019198 + } }, { "item": "sphere", @@ -5540,7 +8041,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能" + "reason": "法术格挡关键技能;格挡点目标技能", + "stats": { + "games": 207, + "purchase_rate": 0.083636, + "win_rate": 0.565217, + "purchase_lift": -0.011689, + "win_delta": -0.07024 + } }, { "item": "cyclone", @@ -5548,7 +8056,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 774, + "purchase_rate": 0.312727, + "win_rate": 0.52584, + "purchase_lift": 0.012802, + "win_delta": -0.011628 + } }, { "item": "wind_waker", @@ -5566,7 +8081,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 105, + "purchase_rate": 0.135834, + "win_rate": 0.485714, + "purchase_lift": -0.042316, + "win_delta": -0.086135 + } }, { "item": "diffusal_blade", @@ -5592,14 +8114,6 @@ ], "reason": "可驱散技能增益" }, - { - "item": "black_king_bar", - "name_loc": "黑皇杖", - "tags": [ - "magic_immune" - ], - "reason": "魔免削弱技能;魔免抵消控制" - }, { "item": "sphere", "name_loc": "林肯法球", @@ -5608,13 +8122,35 @@ ], "reason": "法术格挡关键技能;格挡点目标技能;法术格挡" }, + { + "item": "black_king_bar", + "name_loc": "黑皇杖", + "tags": [ + "magic_immune" + ], + "reason": "魔免削弱技能;魔免抵消控制", + "stats": { + "games": 369, + "purchase_rate": 0.477361, + "win_rate": 0.509485, + "purchase_lift": -0.119589, + "win_delta": -0.030091 + } + }, { "item": "cyclone", "name_loc": "Eul的神圣法杖", "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 273, + "purchase_rate": 0.353169, + "win_rate": 0.520147, + "purchase_lift": 0.053244, + "win_delta": -0.017321 + } }, { "item": "wind_waker", @@ -5633,7 +8169,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 247, + "purchase_rate": 0.117787, + "win_rate": 0.465587, + "purchase_lift": 0.019137, + "win_delta": 0.000306 + } }, { "item": "disperser", @@ -5650,7 +8193,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 252, + "purchase_rate": 0.120172, + "win_rate": 0.555556, + "purchase_lift": 0.039647, + "win_delta": -0.022836 + } }, { "item": "silver_edge", @@ -5658,7 +8208,14 @@ "tags": [ "break" ], - "reason": "破坏关键被动" + "reason": "破坏关键被动", + "stats": { + "games": 246, + "purchase_rate": 0.11731, + "win_rate": 0.577236, + "purchase_lift": 0.01436, + "win_delta": -0.028398 + } }, { "item": "cyclone", @@ -5666,7 +8223,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 765, + "purchase_rate": 0.364807, + "win_rate": 0.543791, + "purchase_lift": 0.064882, + "win_delta": 0.006323 + } }, { "item": "wind_waker", @@ -5684,7 +8248,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 306, + "purchase_rate": 0.177804, + "win_rate": 0.604575, + "purchase_lift": -0.000346, + "win_delta": 0.032726 + } }, { "item": "diffusal_blade", @@ -5692,15 +8263,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" - }, - { - "item": "disperser", - "name_loc": "散魂剑", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 203, + "purchase_rate": 0.117955, + "win_rate": 0.517241, + "purchase_lift": 0.019305, + "win_delta": 0.05196 + } }, { "item": "nullifier", @@ -5708,6 +8278,21 @@ "tags": [ "basic_dispel" ], + "reason": "可驱散技能增益", + "stats": { + "games": 146, + "purchase_rate": 0.084834, + "win_rate": 0.636986, + "purchase_lift": 0.004309, + "win_delta": 0.058594 + } + }, + { + "item": "disperser", + "name_loc": "散魂剑", + "tags": [ + "basic_dispel" + ], "reason": "可驱散技能增益" }, { @@ -5716,7 +8301,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" + "reason": "格挡点目标技能", + "stats": { + "games": 133, + "purchase_rate": 0.077281, + "win_rate": 0.646617, + "purchase_lift": -0.018044, + "win_delta": 0.011159 + } }, { "item": "cyclone", @@ -5724,7 +8316,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 441, + "purchase_rate": 0.256246, + "win_rate": 0.582766, + "purchase_lift": -0.043679, + "win_delta": 0.045299 + } }, { "item": "wind_waker", @@ -5742,7 +8341,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 760, + "purchase_rate": 0.200687, + "win_rate": 0.593421, + "purchase_lift": 0.022537, + "win_delta": 0.021571 + } }, { "item": "diffusal_blade", @@ -5750,7 +8356,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 400, + "purchase_rate": 0.105625, + "win_rate": 0.4725, + "purchase_lift": 0.006975, + "win_delta": 0.007219 + } }, { "item": "disperser", @@ -5758,7 +8371,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 149, + "purchase_rate": 0.039345, + "win_rate": 0.677852, + "purchase_lift": 0.00077, + "win_delta": 0.018747 + } }, { "item": "nullifier", @@ -5766,7 +8386,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 262, + "purchase_rate": 0.069184, + "win_rate": 0.572519, + "purchase_lift": -0.011341, + "win_delta": -0.005873 + } }, { "item": "black_king_bar", @@ -5774,7 +8401,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免抵消控制" + "reason": "魔免抵消控制", + "stats": { + "games": 2560, + "purchase_rate": 0.675997, + "win_rate": 0.534375, + "purchase_lift": 0.079047, + "win_delta": -0.005201 + } }, { "item": "sphere", @@ -5782,7 +8416,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能;法术格挡" + "reason": "格挡点目标技能;法术格挡", + "stats": { + "games": 431, + "purchase_rate": 0.11381, + "win_rate": 0.603248, + "purchase_lift": 0.018485, + "win_delta": -0.032209 + } }, { "item": "cyclone", @@ -5790,7 +8431,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 1208, + "purchase_rate": 0.318986, + "win_rate": 0.539735, + "purchase_lift": 0.019061, + "win_delta": 0.002267 + } }, { "item": "wind_waker", @@ -5798,7 +8446,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 133, + "purchase_rate": 0.03512, + "win_rate": 0.781955, + "purchase_lift": 0.005495, + "win_delta": 0.114444 + } } ], "skywrath_mage": [ @@ -5809,7 +8464,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 257, + "purchase_rate": 0.100391, + "win_rate": 0.490272, + "purchase_lift": 0.001741, + "win_delta": 0.024991 + } }, { "item": "disperser", @@ -5818,7 +8480,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 124, + "purchase_rate": 0.048438, + "win_rate": 0.669355, + "purchase_lift": 0.009863, + "win_delta": 0.010249 + } }, { "item": "lotus_orb", @@ -5826,7 +8495,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 519, + "purchase_rate": 0.202734, + "win_rate": 0.597303, + "purchase_lift": 0.024584, + "win_delta": 0.025453 + } }, { "item": "nullifier", @@ -5834,7 +8510,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 212, + "purchase_rate": 0.082812, + "win_rate": 0.646226, + "purchase_lift": 0.002287, + "win_delta": 0.067835 + } }, { "item": "black_king_bar", @@ -5842,7 +8525,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱魔法输出" + "reason": "魔免削弱魔法输出", + "stats": { + "games": 1450, + "purchase_rate": 0.566406, + "win_rate": 0.577931, + "purchase_lift": -0.030544, + "win_delta": 0.038355 + } }, { "item": "sphere", @@ -5850,7 +8540,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" + "reason": "格挡点目标技能", + "stats": { + "games": 177, + "purchase_rate": 0.069141, + "win_rate": 0.683616, + "purchase_lift": -0.026184, + "win_delta": 0.048158 + } }, { "item": "cyclone", @@ -5858,7 +8555,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 849, + "purchase_rate": 0.331641, + "win_rate": 0.566549, + "purchase_lift": 0.031716, + "win_delta": 0.029081 + } }, { "item": "wind_waker", @@ -5876,15 +8580,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" - }, - { - "item": "disperser", - "name_loc": "散魂剑", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 175, + "purchase_rate": 0.114081, + "win_rate": 0.417143, + "purchase_lift": 0.015431, + "win_delta": -0.048138 + } }, { "item": "nullifier", @@ -5892,6 +8595,21 @@ "tags": [ "basic_dispel" ], + "reason": "可驱散技能增益", + "stats": { + "games": 171, + "purchase_rate": 0.111473, + "win_rate": 0.602339, + "purchase_lift": 0.030948, + "win_delta": 0.023947 + } + }, + { + "item": "disperser", + "name_loc": "散魂剑", + "tags": [ + "basic_dispel" + ], "reason": "可驱散技能增益" }, { @@ -5900,7 +8618,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 527, + "purchase_rate": 0.343546, + "win_rate": 0.538899, + "purchase_lift": 0.043621, + "win_delta": 0.001432 + } }, { "item": "wind_waker", @@ -5998,7 +8723,14 @@ "item": "ghost", "name_loc": "幽魂权杖", "tags": [], - "reason": "虚无无法被决斗普攻" + "reason": "虚无无法被决斗普攻", + "stats": { + "games": 160, + "purchase_rate": 0.206186, + "win_rate": 0.5375, + "purchase_lift": 0.005911, + "win_delta": 0.015343 + } }, { "item": "diffusal_blade", @@ -6030,7 +8762,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" + "reason": "格挡点目标技能", + "stats": { + "games": 189, + "purchase_rate": 0.243557, + "win_rate": 0.650794, + "purchase_lift": 0.148232, + "win_delta": 0.015336 + } }, { "item": "cyclone", @@ -6038,7 +8777,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 255, + "purchase_rate": 0.328608, + "win_rate": 0.501961, + "purchase_lift": 0.028683, + "win_delta": -0.035507 + } } ], "techies": [ @@ -6049,7 +8795,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 108, + "purchase_rate": 0.084772, + "win_rate": 0.453704, + "purchase_lift": -0.013878, + "win_delta": -0.011578 + } }, { "item": "disperser", @@ -6066,7 +8819,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 143, + "purchase_rate": 0.112245, + "win_rate": 0.566434, + "purchase_lift": 0.03172, + "win_delta": -0.011958 + } }, { "item": "cyclone", @@ -6074,7 +8834,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 304, + "purchase_rate": 0.238619, + "win_rate": 0.585526, + "purchase_lift": -0.061306, + "win_delta": 0.048059 + } }, { "item": "wind_waker", @@ -6092,7 +8859,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 311, + "purchase_rate": 0.104997, + "win_rate": 0.472669, + "purchase_lift": 0.006347, + "win_delta": 0.007388 + } }, { "item": "disperser", @@ -6100,7 +8874,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 175, + "purchase_rate": 0.059082, + "win_rate": 0.64, + "purchase_lift": 0.020507, + "win_delta": -0.019106 + } }, { "item": "nullifier", @@ -6108,7 +8889,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 258, + "purchase_rate": 0.087103, + "win_rate": 0.55814, + "purchase_lift": 0.006578, + "win_delta": -0.020252 + } }, { "item": "black_king_bar", @@ -6116,7 +8904,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱魔法输出" + "reason": "魔免削弱魔法输出", + "stats": { + "games": 2034, + "purchase_rate": 0.686698, + "win_rate": 0.5, + "purchase_lift": 0.089748, + "win_delta": -0.039576 + } }, { "item": "cyclone", @@ -6124,7 +8919,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 1005, + "purchase_rate": 0.339298, + "win_rate": 0.506468, + "purchase_lift": 0.039373, + "win_delta": -0.031 + } }, { "item": "wind_waker", @@ -6142,7 +8944,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 157, + "purchase_rate": 0.175419, + "win_rate": 0.605096, + "purchase_lift": -0.002731, + "win_delta": 0.033246 + } }, { "item": "diffusal_blade", @@ -6168,14 +8977,6 @@ ], "reason": "可驱散技能增益" }, - { - "item": "black_king_bar", - "name_loc": "黑皇杖", - "tags": [ - "magic_immune" - ], - "reason": "魔免削弱技能;魔免抵消控制" - }, { "item": "sphere", "name_loc": "林肯法球", @@ -6185,12 +8986,19 @@ "reason": "法术格挡关键技能;格挡点目标技能;法术格挡" }, { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", + "item": "black_king_bar", + "name_loc": "黑皇杖", "tags": [ - "basic_dispel" + "magic_immune" ], - "reason": "可驱散技能增益" + "reason": "魔免削弱技能;魔免抵消控制", + "stats": { + "games": 521, + "purchase_rate": 0.582123, + "win_rate": 0.53167, + "purchase_lift": -0.014827, + "win_delta": -0.007906 + } }, { "item": "wind_waker", @@ -6199,6 +9007,21 @@ "basic_dispel" ], "reason": "可驱散技能增益" + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 244, + "purchase_rate": 0.272626, + "win_rate": 0.528689, + "purchase_lift": -0.027299, + "win_delta": -0.008779 + } } ], "abyssal_underlord": [ @@ -6208,15 +9031,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" - }, - { - "item": "disperser", - "name_loc": "散魂剑", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 146, + "purchase_rate": 0.075884, + "win_rate": 0.513699, + "purchase_lift": -0.022766, + "win_delta": 0.048417 + } }, { "item": "nullifier", @@ -6224,7 +9046,29 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 198, + "purchase_rate": 0.102911, + "win_rate": 0.621212, + "purchase_lift": 0.022386, + "win_delta": 0.04282 + } + }, + { + "item": "disperser", + "name_loc": "散魂剑", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 119, + "purchase_rate": 0.06185, + "win_rate": 0.630252, + "purchase_lift": 0.023275, + "win_delta": -0.028854 + } }, { "item": "cyclone", @@ -6232,7 +9076,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 507, + "purchase_rate": 0.263514, + "win_rate": 0.556213, + "purchase_lift": -0.036411, + "win_delta": 0.018745 + } }, { "item": "wind_waker", @@ -6250,7 +9101,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 164, + "purchase_rate": 0.120147, + "win_rate": 0.445122, + "purchase_lift": 0.021497, + "win_delta": -0.020159 + } }, { "item": "disperser", @@ -6266,7 +9124,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 159, + "purchase_rate": 0.116484, + "win_rate": 0.522013, + "purchase_lift": 0.035959, + "win_delta": -0.056379 + } }, { "item": "bfury", @@ -6274,7 +9139,14 @@ "tags": [ "illusion_clear" ], - "reason": "克制幻象" + "reason": "克制幻象", + "stats": { + "games": 212, + "purchase_rate": 0.155311, + "win_rate": 0.476415, + "purchase_lift": -0.005239, + "win_delta": -0.012841 + } }, { "item": "mjollnir", @@ -6282,7 +9154,14 @@ "tags": [ "illusion_clear" ], - "reason": "克制幻象" + "reason": "克制幻象", + "stats": { + "games": 240, + "purchase_rate": 0.175824, + "win_rate": 0.575, + "purchase_lift": 0.013999, + "win_delta": 0.024869 + } }, { "item": "radiance", @@ -6290,7 +9169,14 @@ "tags": [ "illusion_clear" ], - "reason": "克制幻象" + "reason": "克制幻象", + "stats": { + "games": 221, + "purchase_rate": 0.161905, + "win_rate": 0.561086, + "purchase_lift": 0.01273, + "win_delta": 0.031171 + } }, { "item": "gungir", @@ -6317,7 +9203,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 220, + "purchase_rate": 0.090275, + "win_rate": 0.409091, + "purchase_lift": -0.008375, + "win_delta": -0.05619 + } }, { "item": "disperser", @@ -6334,7 +9227,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 203, + "purchase_rate": 0.083299, + "win_rate": 0.571429, + "purchase_lift": 0.002774, + "win_delta": -0.006963 + } }, { "item": "black_king_bar", @@ -6342,15 +9242,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱魔法输出" - }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "魔免削弱魔法输出", + "stats": { + "games": 1719, + "purchase_rate": 0.705375, + "win_rate": 0.502618, + "purchase_lift": 0.108425, + "win_delta": -0.036958 + } }, { "item": "wind_waker", @@ -6359,6 +9258,21 @@ "basic_dispel" ], "reason": "可驱散技能增益" + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 729, + "purchase_rate": 0.299138, + "win_rate": 0.513032, + "purchase_lift": -0.000787, + "win_delta": -0.024436 + } } ], "oracle": [ @@ -6402,7 +9316,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱魔法输出" + "reason": "魔免削弱魔法输出", + "stats": { + "games": 285, + "purchase_rate": 0.56213, + "win_rate": 0.557895, + "purchase_lift": -0.03482, + "win_delta": 0.018319 + } }, { "item": "sphere", @@ -6418,7 +9339,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 143, + "purchase_rate": 0.282051, + "win_rate": 0.538462, + "purchase_lift": -0.017874, + "win_delta": 0.000994 + } }, { "item": "wind_waker", @@ -6436,7 +9364,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 105, + "purchase_rate": 0.144828, + "win_rate": 0.638095, + "purchase_lift": -0.033322, + "win_delta": 0.066246 + } }, { "item": "diffusal_blade", @@ -6476,7 +9411,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 167, + "purchase_rate": 0.230345, + "win_rate": 0.54491, + "purchase_lift": -0.06958, + "win_delta": 0.007442 + } }, { "item": "wind_waker", @@ -6536,14 +9478,6 @@ ], "reason": "无视闪避" }, - { - "item": "black_king_bar", - "name_loc": "黑皇杖", - "tags": [ - "magic_immune" - ], - "reason": "魔免削弱魔法输出" - }, { "item": "sphere", "name_loc": "林肯法球", @@ -6551,6 +9485,21 @@ "spell_block" ], "reason": "格挡点目标技能" + }, + { + "item": "black_king_bar", + "name_loc": "黑皇杖", + "tags": [ + "magic_immune" + ], + "reason": "魔免削弱魔法输出", + "stats": { + "games": 103, + "purchase_rate": 0.492823, + "win_rate": 0.533981, + "purchase_lift": -0.104127, + "win_delta": -0.005596 + } } ], "monkey_king": [ @@ -6560,15 +9509,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" - }, - { - "item": "black_king_bar", - "name_loc": "黑皇杖", - "tags": [ - "magic_immune" - ], - "reason": "魔免削弱技能;魔免抵消控制" + "reason": "反射点目标技能", + "stats": { + "games": 209, + "purchase_rate": 0.1547, + "win_rate": 0.507177, + "purchase_lift": -0.02345, + "win_delta": -0.064673 + } }, { "item": "sphere", @@ -6576,7 +9524,29 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能;法术格挡" + "reason": "法术格挡关键技能;格挡点目标技能;法术格挡", + "stats": { + "games": 123, + "purchase_rate": 0.091044, + "win_rate": 0.617886, + "purchase_lift": -0.004281, + "win_delta": -0.017571 + } + }, + { + "item": "black_king_bar", + "name_loc": "黑皇杖", + "tags": [ + "magic_immune" + ], + "reason": "魔免削弱技能;魔免抵消控制", + "stats": { + "games": 795, + "purchase_rate": 0.588453, + "win_rate": 0.500629, + "purchase_lift": -0.008497, + "win_delta": -0.038947 + } } ], "dark_willow": [ @@ -6587,7 +9557,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 175, + "purchase_rate": 0.105996, + "win_rate": 0.462857, + "purchase_lift": 0.007346, + "win_delta": -0.002424 + } }, { "item": "disperser", @@ -6604,15 +9581,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" - }, - { - "item": "nullifier", - "name_loc": "否决坠饰", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "反射点目标技能", + "stats": { + "games": 327, + "purchase_rate": 0.198062, + "win_rate": 0.608563, + "purchase_lift": 0.019912, + "win_delta": 0.036713 + } }, { "item": "black_king_bar", @@ -6620,7 +9596,29 @@ "tags": [ "magic_immune" ], - "reason": "魔免抵消控制" + "reason": "魔免抵消控制", + "stats": { + "games": 1054, + "purchase_rate": 0.638401, + "win_rate": 0.571157, + "purchase_lift": 0.041451, + "win_delta": 0.031581 + } + }, + { + "item": "nullifier", + "name_loc": "否决坠饰", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 113, + "purchase_rate": 0.068443, + "win_rate": 0.619469, + "purchase_lift": -0.012082, + "win_delta": 0.041077 + } }, { "item": "sphere", @@ -6628,7 +9626,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能;法术格挡" + "reason": "格挡点目标技能;法术格挡", + "stats": { + "games": 142, + "purchase_rate": 0.086008, + "win_rate": 0.619718, + "purchase_lift": -0.009317, + "win_delta": -0.015739 + } }, { "item": "cyclone", @@ -6636,7 +9641,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 515, + "purchase_rate": 0.311932, + "win_rate": 0.582524, + "purchase_lift": 0.012007, + "win_delta": 0.045057 + } }, { "item": "wind_waker", @@ -6648,6 +9660,36 @@ } ], "pangolier": [ + { + "item": "sheepstick", + "name_loc": "邪恶镰刀", + "tags": [ + "hex" + ], + "reason": "妖术限制技能连段与逃生", + "stats": { + "games": 386, + "purchase_rate": 0.155582, + "win_rate": 0.686528, + "purchase_lift": 0.040082, + "win_delta": 0.050598 + } + }, + { + "item": "ghost", + "name_loc": "幽魂权杖", + "tags": [ + "ethereal" + ], + "reason": "虚无规避剑舞与普攻伤害", + "stats": { + "games": 835, + "purchase_rate": 0.336558, + "win_rate": 0.559281, + "purchase_lift": 0.136283, + "win_delta": 0.037124 + } + }, { "item": "diffusal_blade", "name_loc": "净魂之刃", @@ -6655,7 +9697,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 123, + "purchase_rate": 0.049577, + "win_rate": 0.422764, + "purchase_lift": -0.049073, + "win_delta": -0.042517 + } }, { "item": "disperser", @@ -6666,21 +9715,35 @@ ], "reason": "可驱散技能增益;烧蓝克制" }, - { - "item": "nullifier", - "name_loc": "否决坠饰", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" - }, { "item": "black_king_bar", "name_loc": "黑皇杖", "tags": [ "magic_immune" ], - "reason": "魔免抵消控制" + "reason": "魔免抵消控制", + "stats": { + "games": 1766, + "purchase_rate": 0.71181, + "win_rate": 0.547565, + "purchase_lift": 0.11486, + "win_delta": 0.007989 + } + }, + { + "item": "nullifier", + "name_loc": "否决坠饰", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 252, + "purchase_rate": 0.101572, + "win_rate": 0.603175, + "purchase_lift": 0.021047, + "win_delta": 0.024783 + } }, { "item": "sphere", @@ -6688,7 +9751,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡" + "reason": "法术格挡", + "stats": { + "games": 250, + "purchase_rate": 0.100766, + "win_rate": 0.668, + "purchase_lift": 0.005441, + "win_delta": 0.032542 + } }, { "item": "cyclone", @@ -6696,15 +9766,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" - }, - { - "item": "wind_waker", - "name_loc": "风之杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 643, + "purchase_rate": 0.25917, + "win_rate": 0.553655, + "purchase_lift": -0.040755, + "win_delta": 0.016187 + } } ], "grimstroke": [ @@ -6714,7 +9783,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 218, + "purchase_rate": 0.200552, + "win_rate": 0.527523, + "purchase_lift": 0.022402, + "win_delta": -0.044327 + } }, { "item": "diffusal_blade", @@ -6722,15 +9798,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" - }, - { - "item": "disperser", - "name_loc": "散魂剑", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 167, + "purchase_rate": 0.153634, + "win_rate": 0.419162, + "purchase_lift": 0.054984, + "win_delta": -0.04612 + } }, { "item": "nullifier", @@ -6738,6 +9813,21 @@ "tags": [ "basic_dispel" ], + "reason": "可驱散技能增益", + "stats": { + "games": 116, + "purchase_rate": 0.106716, + "win_rate": 0.594828, + "purchase_lift": 0.026191, + "win_delta": 0.016436 + } + }, + { + "item": "disperser", + "name_loc": "散魂剑", + "tags": [ + "basic_dispel" + ], "reason": "可驱散技能增益" }, { @@ -6746,7 +9836,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免抵消控制" + "reason": "魔免抵消控制", + "stats": { + "games": 661, + "purchase_rate": 0.608096, + "win_rate": 0.526475, + "purchase_lift": 0.011146, + "win_delta": -0.013101 + } }, { "item": "sphere", @@ -6754,7 +9851,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能;法术格挡" + "reason": "格挡点目标技能;法术格挡", + "stats": { + "games": 156, + "purchase_rate": 0.143514, + "win_rate": 0.557692, + "purchase_lift": 0.048189, + "win_delta": -0.077765 + } }, { "item": "cyclone", @@ -6762,7 +9866,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 351, + "purchase_rate": 0.322907, + "win_rate": 0.507123, + "purchase_lift": 0.022982, + "win_delta": -0.030345 + } }, { "item": "wind_waker", @@ -6780,7 +9891,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 698, + "purchase_rate": 0.204153, + "win_rate": 0.547278, + "purchase_lift": 0.026003, + "win_delta": -0.024572 + } }, { "item": "diffusal_blade", @@ -6788,7 +9906,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 384, + "purchase_rate": 0.112314, + "win_rate": 0.429688, + "purchase_lift": 0.013664, + "win_delta": -0.035594 + } }, { "item": "disperser", @@ -6796,7 +9921,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 164, + "purchase_rate": 0.047967, + "win_rate": 0.658537, + "purchase_lift": 0.009392, + "win_delta": -0.000569 + } }, { "item": "nullifier", @@ -6804,7 +9936,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 378, + "purchase_rate": 0.110559, + "win_rate": 0.518519, + "purchase_lift": 0.030034, + "win_delta": -0.059873 + } }, { "item": "silver_edge", @@ -6812,7 +9951,14 @@ "tags": [ "invis_break" ], - "reason": "破隐" + "reason": "破隐", + "stats": { + "games": 481, + "purchase_rate": 0.140684, + "win_rate": 0.553015, + "purchase_lift": 0.037734, + "win_delta": -0.052619 + } }, { "item": "black_king_bar", @@ -6820,7 +9966,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 2437, + "purchase_rate": 0.712782, + "win_rate": 0.517439, + "purchase_lift": 0.115832, + "win_delta": -0.022137 + } }, { "item": "sphere", @@ -6828,7 +9981,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能" + "reason": "法术格挡关键技能;格挡点目标技能", + "stats": { + "games": 377, + "purchase_rate": 0.110266, + "win_rate": 0.612732, + "purchase_lift": 0.014941, + "win_delta": -0.022726 + } }, { "item": "gem", @@ -6840,21 +10000,35 @@ } ], "void_spirit": [ + { + "item": "sheepstick", + "name_loc": "邪恶镰刀", + "tags": [ + "hex" + ], + "reason": "妖术限制技能连段与逃生", + "stats": { + "games": 323, + "purchase_rate": 0.159664, + "win_rate": 0.724458, + "purchase_lift": 0.044164, + "win_delta": 0.088527 + } + }, { "item": "diffusal_blade", "name_loc": "净魂之刃", "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" - }, - { - "item": "disperser", - "name_loc": "散魂剑", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 226, + "purchase_rate": 0.111715, + "win_rate": 0.469027, + "purchase_lift": 0.013065, + "win_delta": 0.003745 + } }, { "item": "nullifier", @@ -6862,6 +10036,21 @@ "tags": [ "basic_dispel" ], + "reason": "可驱散技能增益", + "stats": { + "games": 194, + "purchase_rate": 0.095897, + "win_rate": 0.618557, + "purchase_lift": 0.015372, + "win_delta": 0.040165 + } + }, + { + "item": "disperser", + "name_loc": "散魂剑", + "tags": [ + "basic_dispel" + ], "reason": "可驱散技能增益" }, { @@ -6870,7 +10059,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 604, + "purchase_rate": 0.298566, + "win_rate": 0.59106, + "purchase_lift": -0.001359, + "win_delta": 0.053592 + } }, { "item": "wind_waker", @@ -6882,29 +10078,35 @@ } ], "snapfire": [ - { - "item": "lotus_orb", - "name_loc": "清莲宝珠", - "tags": [ - "spell_reflect" - ], - "reason": "反射点目标技能" - }, { "item": "diffusal_blade", "name_loc": "净魂之刃", "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 435, + "purchase_rate": 0.113046, + "win_rate": 0.427586, + "purchase_lift": 0.014396, + "win_delta": -0.037695 + } }, { - "item": "disperser", - "name_loc": "散魂剑", + "item": "lotus_orb", + "name_loc": "清莲宝珠", "tags": [ - "basic_dispel" + "spell_reflect" ], - "reason": "可驱散技能增益" + "reason": "反射点目标技能", + "stats": { + "games": 600, + "purchase_rate": 0.155925, + "win_rate": 0.561667, + "purchase_lift": -0.022225, + "win_delta": -0.010183 + } }, { "item": "nullifier", @@ -6912,15 +10114,29 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 319, + "purchase_rate": 0.0829, + "win_rate": 0.598746, + "purchase_lift": 0.002375, + "win_delta": 0.020354 + } }, { - "item": "black_king_bar", - "name_loc": "黑皇杖", + "item": "disperser", + "name_loc": "散魂剑", "tags": [ - "magic_immune" + "basic_dispel" ], - "reason": "魔免削弱技能;魔免抵消控制" + "reason": "可驱散技能增益", + "stats": { + "games": 118, + "purchase_rate": 0.030665, + "win_rate": 0.771186, + "purchase_lift": -0.00791, + "win_delta": 0.112081 + } }, { "item": "sphere", @@ -6928,7 +10144,29 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能;法术格挡" + "reason": "法术格挡关键技能;格挡点目标技能;法术格挡", + "stats": { + "games": 255, + "purchase_rate": 0.066268, + "win_rate": 0.654902, + "purchase_lift": -0.029057, + "win_delta": 0.019444 + } + }, + { + "item": "black_king_bar", + "name_loc": "黑皇杖", + "tags": [ + "magic_immune" + ], + "reason": "魔免削弱技能;魔免抵消控制", + "stats": { + "games": 2265, + "purchase_rate": 0.588617, + "win_rate": 0.537307, + "purchase_lift": -0.008333, + "win_delta": -0.002269 + } }, { "item": "cyclone", @@ -6936,7 +10174,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 1169, + "purchase_rate": 0.303794, + "win_rate": 0.508982, + "purchase_lift": 0.003869, + "win_delta": -0.028486 + } }, { "item": "wind_waker", @@ -6944,7 +10189,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 114, + "purchase_rate": 0.029626, + "win_rate": 0.649123, + "purchase_lift": 1e-06, + "win_delta": -0.018388 + } } ], "mars": [ @@ -6954,7 +10206,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 2258, + "purchase_rate": 0.655633, + "win_rate": 0.575288, + "purchase_lift": 0.058683, + "win_delta": 0.035712 + } }, { "item": "sphere", @@ -6962,7 +10221,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能" + "reason": "法术格挡关键技能", + "stats": { + "games": 270, + "purchase_rate": 0.078397, + "win_rate": 0.62963, + "purchase_lift": -0.016928, + "win_delta": -0.005828 + } } ], "ringmaster": [ @@ -6972,7 +10238,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 231, + "purchase_rate": 0.107292, + "win_rate": 0.497835, + "purchase_lift": 0.008642, + "win_delta": 0.032554 + } }, { "item": "disperser", @@ -6988,7 +10261,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 391, + "purchase_rate": 0.181607, + "win_rate": 0.539642, + "purchase_lift": 0.101082, + "win_delta": -0.03875 + } }, { "item": "silver_edge", @@ -6996,7 +10276,14 @@ "tags": [ "invis_break" ], - "reason": "破隐" + "reason": "破隐", + "stats": { + "games": 229, + "purchase_rate": 0.106363, + "win_rate": 0.572052, + "purchase_lift": 0.003413, + "win_delta": -0.033581 + } }, { "item": "gem", @@ -7020,7 +10307,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 677, + "purchase_rate": 0.314445, + "win_rate": 0.531758, + "purchase_lift": 0.01452, + "win_delta": -0.00571 + } }, { "item": "invis_sword", @@ -7028,7 +10322,14 @@ "tags": [ "invis_break" ], - "reason": "破隐" + "reason": "破隐", + "stats": { + "games": 156, + "purchase_rate": 0.072457, + "win_rate": 0.474359, + "purchase_lift": 0.001707, + "win_delta": -0.019987 + } } ], "dawnbreaker": [ @@ -7038,7 +10339,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 201, + "purchase_rate": 0.110379, + "win_rate": 0.472637, + "purchase_lift": 0.011729, + "win_delta": 0.007356 + } }, { "item": "disperser", @@ -7054,7 +10362,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 115, + "purchase_rate": 0.063152, + "win_rate": 0.565217, + "purchase_lift": -0.017373, + "win_delta": -0.013174 + } }, { "item": "black_king_bar", @@ -7062,7 +10377,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免抵消控制" + "reason": "魔免抵消控制", + "stats": { + "games": 1053, + "purchase_rate": 0.578254, + "win_rate": 0.560304, + "purchase_lift": -0.018696, + "win_delta": 0.020728 + } }, { "item": "sphere", @@ -7070,7 +10392,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡" + "reason": "法术格挡", + "stats": { + "games": 125, + "purchase_rate": 0.068644, + "win_rate": 0.664, + "purchase_lift": -0.026681, + "win_delta": 0.028542 + } }, { "item": "cyclone", @@ -7078,7 +10407,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 492, + "purchase_rate": 0.270181, + "win_rate": 0.538618, + "purchase_lift": -0.029744, + "win_delta": 0.00115 + } }, { "item": "wind_waker", @@ -7096,7 +10432,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 147, + "purchase_rate": 0.110278, + "win_rate": 0.367347, + "purchase_lift": 0.011628, + "win_delta": -0.097934 + } }, { "item": "disperser", @@ -7120,7 +10463,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能" + "reason": "魔免削弱技能", + "stats": { + "games": 796, + "purchase_rate": 0.597149, + "win_rate": 0.507538, + "purchase_lift": 0.000199, + "win_delta": -0.032038 + } }, { "item": "sphere", @@ -7128,7 +10478,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能" + "reason": "法术格挡关键技能", + "stats": { + "games": 120, + "purchase_rate": 0.090023, + "win_rate": 0.683333, + "purchase_lift": -0.005302, + "win_delta": 0.047876 + } }, { "item": "cyclone", @@ -7136,7 +10493,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 411, + "purchase_rate": 0.308327, + "win_rate": 0.53528, + "purchase_lift": 0.008402, + "win_delta": -0.002188 + } }, { "item": "wind_waker", @@ -7154,7 +10518,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 518, + "purchase_rate": 0.379487, + "win_rate": 0.523166, + "purchase_lift": 0.201337, + "win_delta": -0.048684 + } }, { "item": "diffusal_blade", @@ -7162,7 +10533,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 173, + "purchase_rate": 0.12674, + "win_rate": 0.427746, + "purchase_lift": 0.02809, + "win_delta": -0.037536 + } }, { "item": "disperser", @@ -7178,7 +10556,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 117, + "purchase_rate": 0.085714, + "win_rate": 0.42735, + "purchase_lift": 0.005189, + "win_delta": -0.151041 + } }, { "item": "black_king_bar", @@ -7186,7 +10571,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免抵消控制" + "reason": "魔免抵消控制", + "stats": { + "games": 914, + "purchase_rate": 0.669597, + "win_rate": 0.496718, + "purchase_lift": 0.072647, + "win_delta": -0.042858 + } }, { "item": "sphere", @@ -7194,15 +10586,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能;法术格挡" - }, - { - "item": "cyclone", - "name_loc": "Eul的神圣法杖", - "tags": [ - "basic_dispel" - ], - "reason": "可驱散技能增益" + "reason": "格挡点目标技能;法术格挡", + "stats": { + "games": 353, + "purchase_rate": 0.258608, + "win_rate": 0.580737, + "purchase_lift": 0.163283, + "win_delta": -0.054721 + } }, { "item": "wind_waker", @@ -7211,6 +10602,21 @@ "basic_dispel" ], "reason": "可驱散技能增益" + }, + { + "item": "cyclone", + "name_loc": "Eul的神圣法杖", + "tags": [ + "basic_dispel" + ], + "reason": "可驱散技能增益", + "stats": { + "games": 397, + "purchase_rate": 0.290842, + "win_rate": 0.493703, + "purchase_lift": -0.009083, + "win_delta": -0.043765 + } } ], "muerta": [ @@ -7221,7 +10627,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 145, + "purchase_rate": 0.088039, + "win_rate": 0.462069, + "purchase_lift": -0.010611, + "win_delta": -0.003212 + } }, { "item": "disperser", @@ -7230,7 +10643,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 100, + "purchase_rate": 0.060716, + "win_rate": 0.66, + "purchase_lift": 0.022141, + "win_delta": 0.000894 + } }, { "item": "lotus_orb", @@ -7238,7 +10658,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 274, + "purchase_rate": 0.166363, + "win_rate": 0.536496, + "purchase_lift": -0.011787, + "win_delta": -0.035353 + } }, { "item": "nullifier", @@ -7246,7 +10673,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 144, + "purchase_rate": 0.087432, + "win_rate": 0.5625, + "purchase_lift": 0.006907, + "win_delta": -0.015892 + } }, { "item": "black_king_bar", @@ -7254,7 +10688,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱魔法输出" + "reason": "魔免削弱魔法输出", + "stats": { + "games": 1180, + "purchase_rate": 0.716454, + "win_rate": 0.534746, + "purchase_lift": 0.119504, + "win_delta": -0.00483 + } }, { "item": "sphere", @@ -7262,7 +10703,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" + "reason": "格挡点目标技能", + "stats": { + "games": 172, + "purchase_rate": 0.104432, + "win_rate": 0.575581, + "purchase_lift": 0.009107, + "win_delta": -0.059876 + } }, { "item": "cyclone", @@ -7270,7 +10718,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 462, + "purchase_rate": 0.28051, + "win_rate": 0.54329, + "purchase_lift": -0.019415, + "win_delta": 0.005822 + } }, { "item": "wind_waker", @@ -7288,7 +10743,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 323, + "purchase_rate": 0.189221, + "win_rate": 0.572755, + "purchase_lift": 0.011071, + "win_delta": 0.000906 + } }, { "item": "diffusal_blade", @@ -7296,7 +10758,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 183, + "purchase_rate": 0.107206, + "win_rate": 0.387978, + "purchase_lift": 0.008556, + "win_delta": -0.077303 + } }, { "item": "disperser", @@ -7312,7 +10781,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 138, + "purchase_rate": 0.080844, + "win_rate": 0.543478, + "purchase_lift": 0.000319, + "win_delta": -0.034914 + } }, { "item": "silver_edge", @@ -7320,7 +10796,14 @@ "tags": [ "invis_break" ], - "reason": "破隐" + "reason": "破隐", + "stats": { + "games": 183, + "purchase_rate": 0.107206, + "win_rate": 0.52459, + "purchase_lift": 0.004256, + "win_delta": -0.081044 + } }, { "item": "black_king_bar", @@ -7328,7 +10811,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱技能;魔免抵消控制" + "reason": "魔免削弱技能;魔免抵消控制", + "stats": { + "games": 1080, + "purchase_rate": 0.632689, + "win_rate": 0.515741, + "purchase_lift": 0.035739, + "win_delta": -0.023835 + } }, { "item": "sphere", @@ -7336,7 +10826,14 @@ "tags": [ "spell_block" ], - "reason": "法术格挡关键技能;格挡点目标技能;法术格挡" + "reason": "法术格挡关键技能;格挡点目标技能;法术格挡", + "stats": { + "games": 170, + "purchase_rate": 0.09959, + "win_rate": 0.617647, + "purchase_lift": 0.004265, + "win_delta": -0.017811 + } }, { "item": "gem", @@ -7355,7 +10852,14 @@ "basic_dispel", "mana_burn" ], - "reason": "可驱散技能增益;烧蓝克制" + "reason": "可驱散技能增益;烧蓝克制", + "stats": { + "games": 107, + "purchase_rate": 0.079554, + "win_rate": 0.383178, + "purchase_lift": -0.019096, + "win_delta": -0.082104 + } }, { "item": "disperser", @@ -7372,7 +10876,14 @@ "tags": [ "spell_reflect" ], - "reason": "反射点目标技能" + "reason": "反射点目标技能", + "stats": { + "games": 234, + "purchase_rate": 0.173978, + "win_rate": 0.474359, + "purchase_lift": -0.004172, + "win_delta": -0.097491 + } }, { "item": "nullifier", @@ -7380,7 +10891,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 131, + "purchase_rate": 0.097398, + "win_rate": 0.503817, + "purchase_lift": 0.016873, + "win_delta": -0.074575 + } }, { "item": "black_king_bar", @@ -7388,7 +10906,14 @@ "tags": [ "magic_immune" ], - "reason": "魔免削弱魔法输出" + "reason": "魔免削弱魔法输出", + "stats": { + "games": 914, + "purchase_rate": 0.679554, + "win_rate": 0.480306, + "purchase_lift": 0.082604, + "win_delta": -0.05927 + } }, { "item": "sphere", @@ -7396,7 +10921,14 @@ "tags": [ "spell_block" ], - "reason": "格挡点目标技能" + "reason": "格挡点目标技能", + "stats": { + "games": 135, + "purchase_rate": 0.100372, + "win_rate": 0.592593, + "purchase_lift": 0.005047, + "win_delta": -0.042865 + } }, { "item": "cyclone", @@ -7404,7 +10936,14 @@ "tags": [ "basic_dispel" ], - "reason": "可驱散技能增益" + "reason": "可驱散技能增益", + "stats": { + "games": 432, + "purchase_rate": 0.32119, + "win_rate": 0.532407, + "purchase_lift": 0.021265, + "win_delta": -0.00506 + } }, { "item": "wind_waker", @@ -7432,7 +10971,9 @@ "ghost": { "key": "ghost", "name_loc": "幽魂权杖", - "tags": [] + "tags": [ + "ethereal" + ] }, "dust": { "key": "dust", @@ -7504,7 +11045,8 @@ "key": "cyclone", "name_loc": "Eul的神圣法杖", "tags": [ - "basic_dispel" + "basic_dispel", + "cyclone" ] }, "force_staff": { @@ -7615,7 +11157,9 @@ "basher": { "key": "basher", "name_loc": "碎颅锤", - "tags": [] + "tags": [ + "stun" + ] }, "bfury": { "key": "bfury", @@ -7643,7 +11187,8 @@ "key": "invis_sword", "name_loc": "影刃", "tags": [ - "invis_break" + "invis_break", + "invis" ] }, "sange_and_yasha": { @@ -7713,7 +11258,9 @@ "ethereal_blade": { "key": "ethereal_blade", "name_loc": "虚灵之刃", - "tags": [] + "tags": [ + "ethereal" + ] }, "arcane_boots": { "key": "arcane_boots", @@ -7779,12 +11326,16 @@ "abyssal_blade": { "key": "abyssal_blade", "name_loc": "深渊之刃", - "tags": [] + "tags": [ + "stun" + ] }, "heavens_halberd": { "key": "heavens_halberd", "name_loc": "天堂之戟", - "tags": [] + "tags": [ + "disarm" + ] }, "travel_boots_2": { "key": "travel_boots_2", @@ -7794,7 +11345,9 @@ "meteor_hammer": { "key": "meteor_hammer", "name_loc": "陨星锤", - "tags": [] + "tags": [ + "stun" + ] }, "nullifier": { "key": "nullifier", @@ -7847,7 +11400,8 @@ "name_loc": "白银之锋", "tags": [ "break", - "invis_break" + "invis_break", + "invis" ] }, "bloodthorn": { @@ -7866,7 +11420,9 @@ "glimmer_cape": { "key": "glimmer_cape", "name_loc": "微光披风", - "tags": [] + "tags": [ + "invis" + ] }, "aeon_disk": { "key": "aeon_disk", @@ -7949,7 +11505,8 @@ "key": "wind_waker", "name_loc": "风之杖", "tags": [ - "basic_dispel" + "basic_dispel", + "cyclone" ] }, "helm_of_the_overlord": { diff --git a/data/item_tag_overrides.json b/data/item_tag_overrides.json index 7cb16a1..75df425 100644 --- a/data/item_tag_overrides.json +++ b/data/item_tag_overrides.json @@ -3,40 +3,311 @@ "note": "Manual add/remove tags merged after auto-tagging in fetch_items_meta.py. Prefer offensive mechanics for fear mapping." }, "items": { - "diffusal_blade": { "add": ["basic_dispel", "mana_burn"], "remove": ["illusion_clear"] }, - "disperser": { "add": ["basic_dispel", "mana_burn"], "remove": ["illusion_clear"] }, - "nullifier": { "add": ["basic_dispel"], "remove": [] }, - "cyclone": { "add": ["basic_dispel"], "remove": [] }, - "wind_waker": { "add": ["basic_dispel"], "remove": [] }, - "satanic": { "add": [], "remove": ["basic_dispel"] }, - "lotus_orb": { "add": ["spell_reflect"], "remove": ["basic_dispel", "mana_burn"] }, - "guardian_greaves": { "add": [], "remove": ["basic_dispel"] }, - "manta": { "add": [], "remove": ["basic_dispel", "illusion_clear"] }, - "black_king_bar": { "add": ["magic_immune"], "remove": ["basic_dispel", "strong_dispel"] }, - "aeon_disk": { "add": [], "remove": ["strong_dispel", "basic_dispel"] }, - "sphere": { "add": ["spell_block"], "remove": [] }, - "monkey_king_bar": { "add": ["true_strike"], "remove": [] }, - "silver_edge": { "add": ["break", "invis_break"], "remove": [] }, - "spirit_vessel": { "add": ["heal_reduce"], "remove": [] }, - "bfury": { "add": ["illusion_clear"], "remove": [] }, - "radiance": { "add": ["illusion_clear"], "remove": [] }, - "maelstrom": { "add": ["illusion_clear"], "remove": ["true_strike"] }, - "mjollnir": { "add": ["illusion_clear"], "remove": ["true_strike"] }, - "gungir": { "add": ["root", "illusion_clear"], "remove": [] }, - "gem": { "add": ["invis_detect"], "remove": [] }, - "dust": { "add": ["invis_detect"], "remove": [] }, - "desolator": { "add": ["armor_reduce"], "remove": [] }, - "solar_crest": { "add": ["armor_reduce"], "remove": [] }, - "orchid": { "add": ["silence"], "remove": ["mana_burn"] }, - "bloodthorn": { "add": ["silence", "true_strike"], "remove": [] }, - "sheepstick": { "add": ["hex"], "remove": ["illusion_clear", "silence"] }, - "rod_of_atos": { "add": ["root"], "remove": [] }, - "mask_of_madness": { "add": [], "remove": ["silence"] }, - "necronomicon": { "add": ["invis_detect"], "remove": ["basic_dispel", "mana_burn", "break"] }, - "necronomicon_2": { "add": ["invis_detect"], "remove": ["basic_dispel", "mana_burn", "break"] }, - "necronomicon_3": { "add": ["invis_detect"], "remove": ["basic_dispel", "mana_burn", "break"] }, - "witch_blade": { "add": [], "remove": ["true_strike"] }, - "devastator": { "add": ["armor_reduce"], "remove": ["true_strike"] }, - "essence_distiller": { "add": [], "remove": ["invis_detect"] } + "diffusal_blade": { + "add": [ + "basic_dispel", + "mana_burn" + ], + "remove": [ + "illusion_clear" + ] + }, + "disperser": { + "add": [ + "basic_dispel", + "mana_burn" + ], + "remove": [ + "illusion_clear" + ] + }, + "nullifier": { + "add": [ + "basic_dispel" + ], + "remove": [] + }, + "cyclone": { + "add": [ + "basic_dispel", + "cyclone" + ], + "remove": [] + }, + "wind_waker": { + "add": [ + "basic_dispel", + "cyclone" + ], + "remove": [] + }, + "satanic": { + "add": [], + "remove": [ + "basic_dispel" + ] + }, + "lotus_orb": { + "add": [ + "spell_reflect" + ], + "remove": [ + "basic_dispel", + "mana_burn" + ] + }, + "guardian_greaves": { + "add": [], + "remove": [ + "basic_dispel" + ] + }, + "manta": { + "add": [], + "remove": [ + "basic_dispel", + "illusion_clear" + ] + }, + "black_king_bar": { + "add": [ + "magic_immune" + ], + "remove": [ + "basic_dispel", + "strong_dispel" + ] + }, + "aeon_disk": { + "add": [], + "remove": [ + "strong_dispel", + "basic_dispel" + ] + }, + "sphere": { + "add": [ + "spell_block" + ], + "remove": [] + }, + "monkey_king_bar": { + "add": [ + "true_strike" + ], + "remove": [] + }, + "silver_edge": { + "add": [ + "break", + "invis_break", + "invis" + ], + "remove": [] + }, + "invis_sword": { + "add": [ + "invis" + ], + "remove": [] + }, + "spirit_vessel": { + "add": [ + "heal_reduce" + ], + "remove": [] + }, + "bfury": { + "add": [ + "illusion_clear" + ], + "remove": [] + }, + "radiance": { + "add": [ + "illusion_clear" + ], + "remove": [] + }, + "maelstrom": { + "add": [ + "illusion_clear" + ], + "remove": [ + "true_strike" + ] + }, + "mjollnir": { + "add": [ + "illusion_clear" + ], + "remove": [ + "true_strike" + ] + }, + "gungir": { + "add": [ + "root", + "illusion_clear" + ], + "remove": [] + }, + "gem": { + "add": [ + "invis_detect" + ], + "remove": [ + "invis" + ] + }, + "dust": { + "add": [ + "invis_detect" + ], + "remove": [ + "invis" + ] + }, + "glimmer_cape": { + "add": [ + "invis" + ], + "remove": [] + }, + "desolator": { + "add": [ + "armor_reduce" + ], + "remove": [] + }, + "solar_crest": { + "add": [ + "armor_reduce" + ], + "remove": [] + }, + "orchid": { + "add": [ + "silence" + ], + "remove": [ + "mana_burn" + ] + }, + "bloodthorn": { + "add": [ + "silence", + "true_strike" + ], + "remove": [] + }, + "sheepstick": { + "add": [ + "hex" + ], + "remove": [ + "illusion_clear", + "silence", + "disarm", + "mute" + ] + }, + "abyssal_blade": { + "add": [ + "stun" + ], + "remove": [] + }, + "basher": { + "add": [ + "stun" + ], + "remove": [] + }, + "heavens_halberd": { + "add": [ + "disarm" + ], + "remove": [] + }, + "ethereal_blade": { + "add": [ + "ethereal" + ], + "remove": [ + "disarm" + ] + }, + "rod_of_atos": { + "add": [ + "root" + ], + "remove": [] + }, + "mask_of_madness": { + "add": [], + "remove": [ + "silence" + ] + }, + "necronomicon": { + "add": [ + "invis_detect" + ], + "remove": [ + "basic_dispel", + "mana_burn", + "break", + "invis" + ] + }, + "necronomicon_2": { + "add": [ + "invis_detect" + ], + "remove": [ + "basic_dispel", + "mana_burn", + "break", + "invis" + ] + }, + "necronomicon_3": { + "add": [ + "invis_detect" + ], + "remove": [ + "basic_dispel", + "mana_burn", + "break", + "invis" + ] + }, + "witch_blade": { + "add": [], + "remove": [ + "true_strike" + ] + }, + "devastator": { + "add": [ + "armor_reduce" + ], + "remove": [ + "true_strike" + ] + }, + "essence_distiller": { + "add": [], + "remove": [ + "invis_detect" + ] + }, + "ghost": { + "add": [ + "ethereal" + ], + "remove": [] + } } } diff --git a/data/items_meta.json b/data/items_meta.json index 36703a3..8e3428b 100644 --- a/data/items_meta.json +++ b/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-26T14:47:19.634527+00:00", + "fetched_at": "2026-07-28T07:51:14.752604+00:00", "tag_order": [ "basic_dispel", "strong_dispel", @@ -19,7 +19,18 @@ "armor_reduce", "silence", "hex", - "root" + "root", + "stun", + "disarm", + "mute", + "invis", + "sleep", + "fear", + "taunt", + "blind", + "leash", + "ethereal", + "cyclone" ], "count": 112 }, @@ -80,7 +91,9 @@ "ability_kinds": [ "active" ], - "tags": [] + "tags": [ + "ethereal" + ] }, "40": { "id": 40, @@ -315,7 +328,8 @@ "active" ], "tags": [ - "basic_dispel" + "basic_dispel", + "cyclone" ] }, "102": { @@ -683,7 +697,9 @@ "ability_kinds": [ "passive" ], - "tags": [] + "tags": [ + "stun" + ] }, "145": { "id": 145, @@ -783,7 +799,8 @@ "active" ], "tags": [ - "invis_break" + "invis_break", + "invis" ] }, "154": { @@ -1002,7 +1019,9 @@ "ability_kinds": [ "active" ], - "tags": [] + "tags": [ + "ethereal" + ] }, "180": { "id": 180, @@ -1222,7 +1241,9 @@ "active", "passive" ], - "tags": [] + "tags": [ + "stun" + ] }, "210": { "id": 210, @@ -1238,7 +1259,9 @@ "ability_kinds": [ "active" ], - "tags": [] + "tags": [ + "disarm" + ] }, "220": { "id": 220, @@ -1270,7 +1293,9 @@ "ability_kinds": [ "active" ], - "tags": [] + "tags": [ + "stun" + ] }, "225": { "id": 225, @@ -1434,7 +1459,8 @@ ], "tags": [ "break", - "invis_break" + "invis_break", + "invis" ] }, "250": { @@ -1487,7 +1513,9 @@ "ability_kinds": [ "active" ], - "tags": [] + "tags": [ + "invis" + ] }, "256": { "id": 256, @@ -1761,7 +1789,8 @@ "active" ], "tags": [ - "basic_dispel" + "basic_dispel", + "cyclone" ] }, "635": { diff --git a/data/leaderboards.json b/data/leaderboards.json new file mode 100644 index 0000000..2d80415 --- /dev/null +++ b/data/leaderboards.json @@ -0,0 +1,1977 @@ +{ + "fetched_at": "2026-07-28T07:31:37.587703+00:00", + "source": "valve", + "attribution": "https://www.dota2.com/leaderboards", + "note": "Official Immortal division boards; no MMR/account_id in payload; region MMR scales are not comparable across divisions", + "default_region": "china", + "region_order": [ + "china", + "europe", + "americas", + "se_asia" + ], + "regions": { + "china": { + "division": "china", + "label_zh": "中国", + "time_posted": 1785222781, + "count": 5001, + "top100": [ + { + "rank": 1, + "name": "Zap初西" + }, + { + "rank": 2, + "name": "留恋cc" + }, + { + "rank": 3, + "name": "谢幕", + "country": "cn" + }, + { + "rank": 4, + "name": "Seed" + }, + { + "rank": 5, + "name": "我是奶龙" + }, + { + "rank": 6, + "name": "猫大侠" + }, + { + "rank": 7, + "name": "333" + }, + { + "rank": 8, + "name": "瞬雷天闪" + }, + { + "rank": 9, + "name": "玩伐木机狼人船长的s马", + "country": "cn" + }, + { + "rank": 10, + "name": "Xi" + }, + { + "rank": 11, + "name": "cym", + "country": "cn" + }, + { + "rank": 12, + "name": "早起辟谷的入口 痛痛" + }, + { + "rank": 13, + "name": "End" + }, + { + "rank": 14, + "name": "狗" + }, + { + "rank": 15, + "name": "很牛逼的ID" + }, + { + "rank": 16, + "name": "Murasame" + }, + { + "rank": 17, + "name": "慕木" + }, + { + "rank": 18, + "name": "lit to me", + "country": "cn" + }, + { + "rank": 19, + "name": "柿子椒熊" + }, + { + "rank": 20, + "name": "池小橙" + }, + { + "rank": 21, + "name": "911", + "team_tag": "Se.An" + }, + { + "rank": 22, + "name": "不牛逼的ID" + }, + { + "rank": 23, + "name": "恶鬼缠身", + "country": "cn" + }, + { + "rank": 24, + "name": "花开见我" + }, + { + "rank": 25, + "name": "爆眼怪 -) 80出礼" + }, + { + "rank": 26, + "name": "塔塔塔", + "country": "cn" + }, + { + "rank": 27, + "name": "theli", + "country": "cn" + }, + { + "rank": 28, + "name": "柿子椒熊", + "country": "cn" + }, + { + "rank": 29, + "name": "Dud" + }, + { + "rank": 30, + "name": "TI冠军" + }, + { + "rank": 31, + "name": "Chen" + }, + { + "rank": 32, + "name": "shElly" + }, + { + "rank": 33, + "name": "Lee" + }, + { + "rank": 34, + "name": "时无英雄,使竖子成名" + }, + { + "rank": 35, + "name": "兔八孩的眼泪" + }, + { + "rank": 36, + "name": "嘉定吴彦祖" + }, + { + "rank": 37, + "name": "320" + }, + { + "rank": 38, + "name": "byebye" + }, + { + "rank": 39, + "name": "月下の兄贵" + }, + { + "rank": 40, + "name": "KOTK" + }, + { + "rank": 41, + "name": "O.o" + }, + { + "rank": 42, + "name": "DD" + }, + { + "rank": 43, + "name": "0736" + }, + { + "rank": 44, + "name": "跳刀跳刀" + }, + { + "rank": 45, + "name": "牧之" + }, + { + "rank": 45, + "name": "E-permit" + }, + { + "rank": 47, + "name": "final" + }, + { + "rank": 48, + "name": "西风之歌", + "country": "cn" + }, + { + "rank": 49, + "name": "🔪", + "team_tag": "HYJ" + }, + { + "rank": 50, + "name": "肾做丶" + }, + { + "rank": 51, + "name": "Peanut" + }, + { + "rank": 52, + "name": "XXXXXXXXXXXXXXXXXXXX", + "country": "cn" + }, + { + "rank": 53, + "name": "KING", + "country": "cn" + }, + { + "rank": 54, + "name": "XxX" + }, + { + "rank": 55, + "name": "护卫", + "team_tag": "US", + "country": "cn" + }, + { + "rank": 55, + "name": "[whispers indistinctly]" + }, + { + "rank": 57, + "name": "简简单单的~" + }, + { + "rank": 58, + "name": "Kobe" + }, + { + "rank": 59, + "name": "刘蛋松鼠" + }, + { + "rank": 60, + "name": "459 Friday Spirit" + }, + { + "rank": 61, + "name": "精灵淡淡", + "country": "cn" + }, + { + "rank": 62, + "name": "拿来吧你" + }, + { + "rank": 63, + "name": "1", + "country": "cn" + }, + { + "rank": 64, + "name": "世界渐冷清" + }, + { + "rank": 65, + "name": "International cat", + "team_tag": "Cat" + }, + { + "rank": 66, + "name": "109" + }, + { + "rank": 67, + "name": "!" + }, + { + "rank": 67, + "name": "知无涯者♡Yun" + }, + { + "rank": 69, + "name": "有种打死我" + }, + { + "rank": 70, + "name": "X" + }, + { + "rank": 71, + "name": "ailee" + }, + { + "rank": 72, + "name": "柳智敏" + }, + { + "rank": 73, + "name": "高斯", + "country": "cn" + }, + { + "rank": 74, + "name": "夏日D", + "team_tag": "SO" + }, + { + "rank": 75, + "name": "Pavement玲" + }, + { + "rank": 76, + "name": "♠ jdhhhhhh", + "country": "cn" + }, + { + "rank": 77, + "name": "♂", + "country": "ag" + }, + { + "rank": 78, + "name": "Lamine" + }, + { + "rank": 79, + "name": "DJDJ" + }, + { + "rank": 80, + "name": "亲爱滴沉尘", + "team_tag": "Yuiii", + "country": "cn" + }, + { + "rank": 81, + "name": "劣单猛男" + }, + { + "rank": 82, + "name": "慕留生" + }, + { + "rank": 83, + "name": "zh." + }, + { + "rank": 84, + "name": "剧毒术士" + }, + { + "rank": 85, + "name": "C.Y.K" + }, + { + "rank": 86, + "name": "先买眼后买鞋" + }, + { + "rank": 87, + "name": "179" + }, + { + "rank": 88, + "name": "神圣懒鬼的伟大冒险" + }, + { + "rank": 89, + "name": "猪巴子" + }, + { + "rank": 90, + "name": "我出点金手" + }, + { + "rank": 91, + "name": "冰空花束" + }, + { + "rank": 92, + "name": "没座" + }, + { + "rank": 93, + "name": "十七" + }, + { + "rank": 93, + "name": "TOTORO" + }, + { + "rank": 95, + "name": "转眼" + }, + { + "rank": 96, + "name": "梦龙" + }, + { + "rank": 97, + "name": "可乐没气" + }, + { + "rank": 97, + "name": "BB一句就开送" + }, + { + "rank": 99, + "name": "Qwer" + }, + { + "rank": 100, + "name": "WP.Zxc", + "team_tag": "B.D.B" + } + ] + }, + "europe": { + "division": "europe", + "label_zh": "欧洲", + "time_posted": 1785222781, + "count": 5000, + "top100": [ + { + "rank": 1, + "name": "Satanic", + "team_tag": "VSN", + "country": "al" + }, + { + "rank": 2, + "name": "医者watson`", + "team_tag": "TY", + "country": "kz" + }, + { + "rank": 3, + "name": "Jankos", + "country": "pl" + }, + { + "rank": 4, + "name": "Yatoro", + "team_tag": "TSpirit" + }, + { + "rank": 5, + "name": "Niku", + "team_tag": "NAVI" + }, + { + "rank": 6, + "name": "bzm", + "team_tag": "1w", + "country": "ax" + }, + { + "rank": 7, + "name": "Saberlight", + "team_tag": "VP", + "country": "cz" + }, + { + "rank": 8, + "name": "Nightfall", + "team_tag": "Aurora" + }, + { + "rank": 9, + "name": "Saksa", + "team_tag": "TY", + "country": "va" + }, + { + "rank": 10, + "name": "OneJey ", + "country": "by" + }, + { + "rank": 11, + "name": "Save-", + "team_tag": "BB", + "country": "md" + }, + { + "rank": 12, + "name": "Ari", + "team_tag": "1w" + }, + { + "rank": 13, + "name": "Pure", + "team_tag": "1w", + "country": "jp" + }, + { + "rank": 14, + "name": "NothingToSay", + "team_tag": "XG" + }, + { + "rank": 15, + "name": "GH", + "team_tag": "NGX", + "country": "lb" + }, + { + "rank": 16, + "name": "RCY", + "team_tag": "GL", + "country": "us" + }, + { + "rank": 17, + "name": "oOoO" + }, + { + "rank": 18, + "name": "Xm", + "team_tag": "VG", + "country": "cn" + }, + { + "rank": 19, + "name": "Noticed", + "team_tag": "VSN", + "country": "ax" + }, + { + "rank": 20, + "name": "Kiritych~", + "team_tag": "BB", + "country": "ru" + }, + { + "rank": 21, + "name": "tOfu", + "team_tag": "Liquid", + "country": "de" + }, + { + "rank": 21, + "name": "rincyq", + "country": "ua" + }, + { + "rank": 23, + "name": "No[o]ne-", + "team_tag": "VSN", + "country": "au" + }, + { + "rank": 24, + "name": "emptiness死", + "country": "kz" + }, + { + "rank": 25, + "name": "lorenof", + "team_tag": "NGX" + }, + { + "rank": 26, + "name": "rue", + "team_tag": "TSpirit", + "country": "af" + }, + { + "rank": 27, + "name": "Scofield", + "team_tag": "PT" + }, + { + "rank": 28, + "name": "bottega", + "country": "ru" + }, + { + "rank": 29, + "name": "daze", + "team_tag": "NAVI", + "country": "kz" + }, + { + "rank": 30, + "name": "Malr1ne", + "team_tag": "FLCN", + "country": "ru" + }, + { + "rank": 31, + "name": "Aidar", + "team_tag": "TSA" + }, + { + "rank": 32, + "name": "CHIRA_JUNIOR", + "team_tag": "TY" + }, + { + "rank": 33, + "name": "Quinn", + "team_tag": "NA" + }, + { + "rank": 34, + "name": "Ghost", + "team_tag": "GL", + "country": "my" + }, + { + "rank": 35, + "name": "Mikoto", + "team_tag": "Aurora" + }, + { + "rank": 36, + "name": "jikroy", + "team_tag": "RKX", + "country": "ax" + }, + { + "rank": 37, + "name": "AMMAR_THE_F", + "team_tag": "FLCN", + "country": "jo" + }, + { + "rank": 38, + "name": "Mikey" + }, + { + "rank": 39, + "name": "Griezmann" + }, + { + "rank": 40, + "name": "RESPECT", + "team_tag": "L1GA", + "country": "af" + }, + { + "rank": 41, + "name": "MieRo", + "team_tag": "BB", + "country": "af" + }, + { + "rank": 42, + "name": "squad1x", + "team_tag": "enj" + }, + { + "rank": 43, + "name": "Rein", + "team_tag": "enj", + "country": "ax" + }, + { + "rank": 44, + "name": "young G", + "team_tag": "Nemiga" + }, + { + "rank": 45, + "name": "INFERNAL" + }, + { + "rank": 46, + "name": "Difference", + "country": "ru" + }, + { + "rank": 47, + "name": "shigetsu", + "team_tag": "YeS", + "country": "ua" + }, + { + "rank": 48, + "name": "Shad", + "team_tag": "BLD", + "country": "nl" + }, + { + "rank": 49, + "name": "Kataomi`", + "team_tag": "BB" + }, + { + "rank": 50, + "name": "PaSTiL", + "country": "ru" + }, + { + "rank": 51, + "name": "Emo", + "team_tag": "GLYPH" + }, + { + "rank": 52, + "name": "fy", + "team_tag": "XG" + }, + { + "rank": 53, + "name": "Thiolicor", + "team_tag": "LGD", + "country": "br" + }, + { + "rank": 54, + "name": "DM", + "team_tag": "TY" + }, + { + "rank": 55, + "name": "Tobi", + "country": "at" + }, + { + "rank": 56, + "name": "gotthejuice", + "team_tag": "NAVI", + "country": "af" + }, + { + "rank": 57, + "name": "skiter", + "team_tag": "FLCN", + "country": "sk" + }, + { + "rank": 58, + "name": "Elmisho", + "team_tag": "PT", + "country": "af" + }, + { + "rank": 59, + "name": "naive-", + "team_tag": "PR", + "country": "kz" + }, + { + "rank": 60, + "name": "Topson", + "country": "fi" + }, + { + "rank": 61, + "name": "bb3px", + "team_tag": "LevelUP", + "country": "ru" + }, + { + "rank": 62, + "name": "Fayde", + "team_tag": "GL", + "country": "us" + }, + { + "rank": 63, + "name": "KingJungles", + "team_tag": "LGD", + "country": "br" + }, + { + "rank": 64, + "name": "kreker", + "team_tag": "LYNX", + "country": "ua" + }, + { + "rank": 65, + "name": "No!ob™", + "country": "dz" + }, + { + "rank": 66, + "name": "Mo13ei", + "team_tag": "DL" + }, + { + "rank": 67, + "name": "niche", + "country": "ua" + }, + { + "rank": 68, + "name": "Dukalis", + "team_tag": "VSN", + "country": "ru" + }, + { + "rank": 68, + "name": "BOOM", + "team_tag": "MOUZ", + "country": "cz" + }, + { + "rank": 70, + "name": "Mirage`雨", + "team_tag": "L1GA", + "country": "af" + }, + { + "rank": 71, + "name": "MikSa`", + "team_tag": "Z10", + "country": "rs" + }, + { + "rank": 72, + "name": "SSS", + "country": "ax" + }, + { + "rank": 73, + "name": "Water" + }, + { + "rank": 74, + "name": "rubikon155" + }, + { + "rank": 75, + "name": "мистер мораль", + "team_tag": "enj", + "country": "ru" + }, + { + "rank": 76, + "name": "Ekki", + "country": "pl" + }, + { + "rank": 77, + "name": "Xxs", + "team_tag": "XG" + }, + { + "rank": 78, + "name": "Batyuk", + "team_tag": "YeS", + "country": "ua" + }, + { + "rank": 79, + "name": "9Class", + "team_tag": "VSN" + }, + { + "rank": 80, + "name": "Ethereal" + }, + { + "rank": 81, + "name": "Matson", + "country": "rs" + }, + { + "rank": 82, + "name": "Darklord,,`", + "team_tag": "RE", + "country": "ua" + }, + { + "rank": 83, + "name": "Hellscream", + "team_tag": "VP", + "country": "by" + }, + { + "rank": 84, + "name": "Nyx", + "country": "az" + }, + { + "rank": 85, + "name": "dream`", + "country": "dz" + }, + { + "rank": 86, + "name": "Htrd", + "team_tag": "LevelUP" + }, + { + "rank": 87, + "name": "Till The End", + "team_tag": "PR", + "country": "af" + }, + { + "rank": 88, + "name": "Supream^" + }, + { + "rank": 89, + "name": "Immersion", + "team_tag": "PR", + "country": "ru" + }, + { + "rank": 90, + "name": "Davai", + "team_tag": "NGX", + "country": "ax" + }, + { + "rank": 91, + "name": "saillyn", + "country": "af" + }, + { + "rank": 92, + "name": "Rain", + "team_tag": "YeS", + "country": "af" + }, + { + "rank": 92, + "name": "kaori", + "team_tag": "Aurora", + "country": "al" + }, + { + "rank": 94, + "name": "not_me", + "team_tag": "TSpirit" + }, + { + "rank": 95, + "name": "bashka" + }, + { + "rank": 96, + "name": "Fame", + "country": "cn" + }, + { + "rank": 97, + "name": "Smiling Knight", + "country": "by" + }, + { + "rank": 98, + "name": "Mirele`", + "country": "al" + }, + { + "rank": 99, + "name": "Ame", + "team_tag": "XG", + "country": "cn" + }, + { + "rank": 100, + "name": "queezy", + "team_tag": "LevelUP", + "country": "af" + } + ] + }, + "americas": { + "division": "americas", + "label_zh": "美洲", + "time_posted": 1785222781, + "count": 5002, + "top100": [ + { + "rank": 1, + "name": "AlienManaBanana", + "country": "us" + }, + { + "rank": 2, + "name": "Karl", + "team_tag": "XG", + "country": "bo" + }, + { + "rank": 3, + "name": "111111", + "team_tag": "GP", + "country": "pe" + }, + { + "rank": 4, + "name": "md2" + }, + { + "rank": 5, + "name": "DNG.MIR", + "country": "pe" + }, + { + "rank": 6, + "name": "Moosley" + }, + { + "rank": 7, + "name": "Life" + }, + { + "rank": 8, + "name": "KIRIOS" + }, + { + "rank": 9, + "name": "blccck" + }, + { + "rank": 10, + "name": "sanity is gone", + "team_tag": "NALH" + }, + { + "rank": 11, + "name": "Difff" + }, + { + "rank": 12, + "name": "a", + "country": "pe" + }, + { + "rank": 13, + "name": "Sani" + }, + { + "rank": 14, + "name": "HaixeL", + "country": "pe" + }, + { + "rank": 15, + "name": "EndLess", + "team_tag": "l4r1dv<", + "country": "ax" + }, + { + "rank": 16, + "name": "©@LÏ©Ø" + }, + { + "rank": 17, + "name": "Mang Kanor", + "country": "ph" + }, + { + "rank": 18, + "name": "G-mon€y" + }, + { + "rank": 19, + "name": "Manchego", + "team_tag": "DDS", + "country": "bo" + }, + { + "rank": 20, + "name": "CEMEN_BAKIN", + "country": "br" + }, + { + "rank": 21, + "name": "Deaccon" + }, + { + "rank": 22, + "name": "THANATOS", + "country": "al" + }, + { + "rank": 23, + "name": "Lodka", + "country": "pe" + }, + { + "rank": 24, + "name": "Dangerboy", + "country": "pe" + }, + { + "rank": 25, + "name": " 나이트 퓨리 1" + }, + { + "rank": 26, + "name": "Fynez" + }, + { + "rank": 27, + "name": "好好休息", + "country": "cn" + }, + { + "rank": 28, + "name": "KE" + }, + { + "rank": 29, + "name": "Miracle ", + "team_tag": "E.V.G" + }, + { + "rank": 30, + "name": "Ryu", + "team_tag": "CRI", + "country": "be" + }, + { + "rank": 31, + "name": "Mr. Lens", + "country": "us" + }, + { + "rank": 32, + "name": "FerrazL", + "country": "br" + }, + { + "rank": 33, + "name": "Filosoft", + "country": "pe" + }, + { + "rank": 34, + "name": "Egocentrick1d" + }, + { + "rank": 35, + "name": "CHACARRON" + }, + { + "rank": 36, + "name": "CLAU", + "country": "gd" + }, + { + "rank": 37, + "name": "humblegod", + "country": "pe" + }, + { + "rank": 38, + "name": "Franco", + "country": "pe" + }, + { + "rank": 39, + "name": "Ray" + }, + { + "rank": 40, + "name": "Alpha", + "team_tag": "TS5", + "country": "us" + }, + { + "rank": 41, + "name": "Canadian_Serge", + "country": "ca" + }, + { + "rank": 42, + "name": "Barsa", + "country": "us" + }, + { + "rank": 43, + "name": "winwinwinwin" + }, + { + "rank": 44, + "name": "lods!", + "country": "ph" + }, + { + "rank": 45, + "name": "Katol", + "country": "af" + }, + { + "rank": 46, + "name": "tv/tedious247" + }, + { + "rank": 47, + "name": "JunS", + "country": "cl" + }, + { + "rank": 47, + "name": "~~~", + "country": "fk" + }, + { + "rank": 49, + "name": "kubikdanon", + "country": "ua" + }, + { + "rank": 50, + "name": "Pusa", + "country": "us" + }, + { + "rank": 51, + "name": "Kuroky", + "team_tag": "TMD", + "country": "ni" + }, + { + "rank": 52, + "name": "(((Frenchie)))" + }, + { + "rank": 53, + "name": "蛋清", + "country": "cn" + }, + { + "rank": 54, + "name": "George.GP", + "team_tag": "TBG-" + }, + { + "rank": 55, + "name": "Splinter", + "country": "pe" + }, + { + "rank": 56, + "name": "DarkShadow", + "country": "pe" + }, + { + "rank": 57, + "name": "xaxa" + }, + { + "rank": 58, + "name": "Mxwll" + }, + { + "rank": 58, + "name": "喬斯琳ilia", + "team_tag": "njyrs" + }, + { + "rank": 60, + "name": "KING" + }, + { + "rank": 61, + "name": "Jerah", + "country": "ph" + }, + { + "rank": 62, + "name": "DID YOU MIKU ", + "team_tag": "NOG", + "country": "pe" + }, + { + "rank": 63, + "name": "Love me not", + "country": "pe" + }, + { + "rank": 64, + "name": "Ratachú" + }, + { + "rank": 65, + "name": "ZI.YU.", + "country": "pe" + }, + { + "rank": 66, + "name": "Enol4" + }, + { + "rank": 66, + "name": "Popeye" + }, + { + "rank": 68, + "name": "Pan Doge", + "country": "ai" + }, + { + "rank": 69, + "name": "0101010101010101", + "country": "pe" + }, + { + "rank": 70, + "name": "MG-5", + "team_tag": "KXL", + "country": "pe" + }, + { + "rank": 71, + "name": "CALIGULAS", + "team_tag": "DKBD" + }, + { + "rank": 71, + "name": "POWER", + "team_tag": "UT" + }, + { + "rank": 73, + "name": "KaeL" + }, + { + "rank": 74, + "name": "S" + }, + { + "rank": 75, + "name": "Jah", + "country": "pe" + }, + { + "rank": 76, + "name": "GaBO.-", + "country": "ar" + }, + { + "rank": 77, + "name": "Natsumi ><" + }, + { + "rank": 78, + "name": "kaleb200", + "country": "us" + }, + { + "rank": 79, + "name": "b" + }, + { + "rank": 80, + "name": "P A I N" + }, + { + "rank": 81, + "name": "Daisu" + }, + { + "rank": 82, + "name": "☼17P-♪♫♥" + }, + { + "rank": 83, + "name": "Mixterio" + }, + { + "rank": 84, + "name": "1" + }, + { + "rank": 85, + "name": "Falcon", + "country": "mn" + }, + { + "rank": 85, + "name": "rUoKIE", + "country": "gr" + }, + { + "rank": 87, + "name": "Thependex32", + "country": "pe" + }, + { + "rank": 87, + "name": "ελτεράκι" + }, + { + "rank": 89, + "name": "KoKo" + }, + { + "rank": 90, + "name": "FlameTurtle" + }, + { + "rank": 91, + "name": "Thaliita", + "country": "ua" + }, + { + "rank": 92, + "name": "FIGARA" + }, + { + "rank": 93, + "name": "Sunken" + }, + { + "rank": 93, + "name": "DABSITW" + }, + { + "rank": 95, + "name": "Paradise ", + "country": "us" + }, + { + "rank": 96, + "name": "Hidr4_Ryota-" + }, + { + "rank": 96, + "name": "Madarita" + }, + { + "rank": 96, + "name": "KerZel_Kz!" + }, + { + "rank": 99, + "name": "B-ok", + "country": "ca" + }, + { + "rank": 100, + "name": "Gambito" + } + ] + }, + "se_asia": { + "division": "se_asia", + "label_zh": "东南亚", + "time_posted": 1785222781, + "count": 5000, + "top100": [ + { + "rank": 1, + "name": "JACKBOYS", + "team_tag": "GLYPH", + "country": "la" + }, + { + "rank": 2, + "name": "skem", + "team_tag": "OG", + "country": "as" + }, + { + "rank": 3, + "name": "无限", + "country": "cn" + }, + { + "rank": 4, + "name": "23savage", + "team_tag": "GrindBack", + "country": "th" + }, + { + "rank": 5, + "name": "Mireska" + }, + { + "rank": 6, + "name": "Natsumi", + "team_tag": "OG", + "country": "ph" + }, + { + "rank": 7, + "name": "Ksh" + }, + { + "rank": 8, + "name": "zeal", + "team_tag": "GLYPH" + }, + { + "rank": 9, + "name": "inYourdreaM", + "team_tag": "RKX" + }, + { + "rank": 10, + "name": "Armel" + }, + { + "rank": 11, + "name": "Mac", + "country": "ph" + }, + { + "rank": 12, + "name": "TIMS", + "team_tag": "OG", + "country": "ao" + }, + { + "rank": 13, + "name": "Anya", + "country": "my" + }, + { + "rank": 14, + "name": "Fbz", + "team_tag": "RKX", + "country": "id" + }, + { + "rank": 15, + "name": "GARBAGE", + "country": "cn" + }, + { + "rank": 16, + "name": "Q", + "team_tag": "GrindBack", + "country": "th" + }, + { + "rank": 17, + "name": "Echozz", + "team_tag": "Resilience", + "country": "cn" + }, + { + "rank": 18, + "name": "Denzsanity" + }, + { + "rank": 19, + "name": "dbta2", + "team_tag": "Dire", + "country": "id" + }, + { + "rank": 20, + "name": "Yowe", + "team_tag": "TNC" + }, + { + "rank": 21, + "name": "Raven", + "team_tag": "OG", + "country": "af" + }, + { + "rank": 22, + "name": "barlo" + }, + { + "rank": 23, + "name": "Azura", + "country": "id" + }, + { + "rank": 24, + "name": "lewis", + "team_tag": "IAP" + }, + { + "rank": 25, + "name": "Rupido", + "country": "ph" + }, + { + "rank": 26, + "name": "dalul", + "team_tag": "RKX", + "country": "id" + }, + { + "rank": 27, + "name": "Palos", + "country": "af" + }, + { + "rank": 28, + "name": "JT-", + "country": "my" + }, + { + "rank": 29, + "name": "bop", + "country": "us" + }, + { + "rank": 30, + "name": "do6", + "team_tag": "Six Cats", + "country": "th" + }, + { + "rank": 31, + "name": "BuryTheHatchet", + "country": "ph" + }, + { + "rank": 32, + "name": "RA1NCLOUD", + "country": "cn" + }, + { + "rank": 33, + "name": "BEEBIE", + "country": "la" + }, + { + "rank": 34, + "name": "ponyo", + "country": "my" + }, + { + "rank": 35, + "name": "dixon", + "team_tag": "Dire", + "country": "id" + }, + { + "rank": 36, + "name": "Spider-Man", + "team_tag": "D_F" + }, + { + "rank": 37, + "name": "Dric~", + "country": "ph" + }, + { + "rank": 38, + "name": "Nevermine", + "country": "la" + }, + { + "rank": 39, + "name": "k", + "country": "ax" + }, + { + "rank": 40, + "name": "Gabbi", + "country": "ph" + }, + { + "rank": 41, + "name": "斩杀线" + }, + { + "rank": 42, + "name": "MamangDaya", + "country": "id" + }, + { + "rank": 43, + "name": "Straight Edge", + "country": "id" + }, + { + "rank": 44, + "name": "JFP" + }, + { + "rank": 45, + "name": "Jaunuel" + }, + { + "rank": 46, + "name": "PinkMaN-" + }, + { + "rank": 47, + "name": "Aomqu", + "country": "ax" + }, + { + "rank": 48, + "name": "wota2", + "team_tag": "Dire", + "country": "id" + }, + { + "rank": 49, + "name": "sick my duck", + "country": "ax" + }, + { + "rank": 50, + "name": "PlayHard-", + "team_tag": "Dire", + "country": "ph" + }, + { + "rank": 51, + "name": "Ackerman" + }, + { + "rank": 52, + "name": "monkey trainer", + "country": "cn" + }, + { + "rank": 53, + "name": "z3bobo", + "country": "ph" + }, + { + "rank": 54, + "name": "Yona", + "team_tag": "WS", + "country": "ph" + }, + { + "rank": 55, + "name": "-", + "country": "my" + }, + { + "rank": 56, + "name": "Cije", + "team_tag": "M-Monsters", + "country": "id" + }, + { + "rank": 57, + "name": "Myut", + "country": "ph" + }, + { + "rank": 58, + "name": "KNP", + "country": "th" + }, + { + "rank": 59, + "name": "Shanks", + "team_tag": "XctN", + "country": "ph" + }, + { + "rank": 60, + "name": ")" + }, + { + "rank": 61, + "name": "Erice", + "team_tag": "IAP", + "country": "ph" + }, + { + "rank": 62, + "name": "Myn", + "country": "la" + }, + { + "rank": 63, + "name": "FAKEDREW" + }, + { + "rank": 64, + "name": "Scz", + "team_tag": "Nova" + }, + { + "rank": 65, + "name": "JG real", + "country": "af" + }, + { + "rank": 66, + "name": "...", + "country": "uz" + }, + { + "rank": 67, + "name": "Rusman-", + "team_tag": "WOLF", + "country": "id" + }, + { + "rank": 68, + "name": "iu.✿҉✿♥҉" + }, + { + "rank": 69, + "name": "JG", + "country": "ph" + }, + { + "rank": 70, + "name": "万劫道体" + }, + { + "rank": 71, + "name": "Gold Ship", + "team_tag": "D_F" + }, + { + "rank": 72, + "name": "顶梁柱" + }, + { + "rank": 73, + "name": "iceiceice", + "country": "sg" + }, + { + "rank": 74, + "name": "Bob", + "team_tag": "XctN", + "country": "mp" + }, + { + "rank": 75, + "name": "这就是我退出DOTA II的原", + "country": "vn" + }, + { + "rank": 76, + "name": "水色", + "country": "as" + }, + { + "rank": 77, + "name": "SSR" + }, + { + "rank": 78, + "name": "jwl" + }, + { + "rank": 79, + "name": "Ako2siWampips", + "team_tag": "OHji" + }, + { + "rank": 80, + "name": "K" + }, + { + "rank": 81, + "name": " " + }, + { + "rank": 82, + "name": "IYO", + "country": "af" + }, + { + "rank": 83, + "name": "V", + "team_tag": "IAP", + "country": "ph" + }, + { + "rank": 84, + "name": "areuhappy?", + "country": "id" + }, + { + "rank": 85, + "name": "Juju", + "team_tag": "M-Monsters", + "country": "id" + }, + { + "rank": 86, + "name": "Franz", + "country": "ph" + }, + { + "rank": 87, + "name": "Tokyo", + "country": "ph" + }, + { + "rank": 88, + "name": "姜明子" + }, + { + "rank": 89, + "name": "Zest Hero", + "country": "cn" + }, + { + "rank": 90, + "name": "艾斯" + }, + { + "rank": 91, + "name": "Erling HAALAND" + }, + { + "rank": 92, + "name": "Tino", + "team_tag": "XctN", + "country": "gf" + }, + { + "rank": 93, + "name": "Abhi`", + "country": "in" + }, + { + "rank": 94, + "name": "抖音.Ms" + }, + { + "rank": 95, + "name": "Konnichiwa" + }, + { + "rank": 96, + "name": "Faith", + "country": "cn" + }, + { + "rank": 96, + "name": "ivan", + "country": "ph" + }, + { + "rank": 98, + "name": "Kuku^", + "country": "ph" + }, + { + "rank": 99, + "name": "Nvd-", + "country": "th" + }, + { + "rank": 100, + "name": "Abeng-" + } + ] + } + }, + "meta": {} +} \ No newline at end of file diff --git a/data/streamers.json b/data/streamers.json new file mode 100644 index 0000000..9b7114a --- /dev/null +++ b/data/streamers.json @@ -0,0 +1,228 @@ +{ + "fetched_at": "2026-07-28T14:20:00+00:00", + "source": "manual+douyin+bilibili", + "platform_meta": { + "douyin": { + "label_zh": "抖音", + "icon": "ui-icon/platform_douyin.png" + }, + "bilibili": { + "label_zh": "哔哩哔哩", + "icon": "ui-icon/platform_bilibili.png" + } + }, + "streamers": [ + { + "id": "xiaowang", + "platform": "douyin", + "live_url": "https://live.douyin.com/343871812758", + "profile_url": "https://v.douyin.com/MM3U-EpqI2E/", + "tagline": "天怒绝活哥", + "heroes": [ + "skywrath_mage" + ], + "nickname": "DOTA2小王", + "unique_id": "chenshiweidota", + "signature": "有事请私信,最高东南亚前100,上车玩加同名绿泡泡抖音号", + "following_count": 87, + "follower_count": 14000, + "total_favorited": 591, + "avatar": "streamer_avatars/xiaowang.jpg", + "video": "streamer_videos/xiaowang.mp4", + "video_title": "7000分单排天怒如何乱杀", + "profile_fetched_at": "2026-07-28T08:30:00+00:00" + }, + { + "id": "jianxin", + "platform": "douyin", + "live_url": "https://live.douyin.com/892762012034", + "profile_url": "https://v.douyin.com/FcUd6qHXYwc/", + "heroes": [ + "juggernaut" + ], + "nickname": "Dota2剑心犹在", + "unique_id": "33552682609", + "signature": "万分选手,前国服第一,绝活剑圣。土木双灵根,先天打灰圣体。", + "following_count": 353, + "follower_count": 35000, + "total_favorited": 4255, + "avatar": "streamer_avatars/jianxin.jpg", + "video": "streamer_videos/jianxin.mp4", + "video_title": "落后四十人头的惊天大翻盘!剑心犹在!!", + "profile_fetched_at": "2026-07-28T10:10:00+00:00" + }, + { + "id": "yaseguilai", + "platform": "douyin", + "profile_url": "https://v.douyin.com/gQDc0B9yPv4/", + "heroes": [ + "slark" + ], + "nickname": "亚瑟归来(Dota2)", + "unique_id": "yaseguilai", + "signature": "dota2主播,万分小鱼王! 上车+v:375874361", + "following_count": 1024, + "follower_count": 23000, + "total_favorited": 5302, + "avatar": "streamer_avatars/yaseguilai.jpg", + "video": "streamer_videos/yaseguilai.mp4", + "video_aspect": "1920/2317", + "video_fit": "cover", + "video_title": "狂战小鱼6连暴走", + "profile_fetched_at": "2026-07-28T10:12:00+00:00" + }, + { + "id": "gudu", + "platform": "douyin", + "profile_url": "https://v.douyin.com/411MHPZgDeY/", + "heroes": [ + "kez" + ], + "nickname": "Dota2孤独", + "unique_id": "71129362227", + "signature": "和dota2结婚的小哥一枚 12点左右直播,最高东南亚200", + "following_count": 7, + "follower_count": 2147, + "total_favorited": 4016, + "avatar": "streamer_avatars/gudu.jpg", + "video": "streamer_videos/gudu.mp4", + "video_title": "场均杀20个,双倍了一晚上没输过的神秘中单出现在对面? 看来只有我来终结你的无解连胜了", + "profile_fetched_at": "2026-07-28T10:14:00+00:00" + }, + { + "id": "yangfanqiguo", + "platform": "douyin", + "live_url": "https://live.douyin.com/546982489107", + "profile_url": "https://v.douyin.com/bodeOPLFFuo/", + "heroes": [ + "riki" + ], + "nickname": "Dota2杨帆起锅", + "unique_id": "26535099603", + "signature": "完美官方主播 每天早9点到12点,晚6点半到10点", + "following_count": 14, + "follower_count": 8259, + "total_favorited": 270, + "avatar": "streamer_avatars/yangfanqiguo.jpg", + "video": "streamer_videos/yangfanqiguo.mp4", + "video_title": "冠绝中单力丸对线保姆级思路", + "profile_fetched_at": "2026-07-28T10:16:00+00:00" + }, + { + "id": "dadigua", + "platform": "douyin", + "profile_url": "https://v.douyin.com/t1E-4M1xrJ4/", + "heroes": [ + "axe" + ], + "nickname": "DOTA2 大地瓜", + "unique_id": "189764720", + "signature": "6月10号播", + "following_count": 805, + "follower_count": 8006, + "total_favorited": 8015, + "avatar": "streamer_avatars/dadigua.jpg", + "video": "streamer_videos/dadigua.mp4", + "video_aspect": "1080/1440", + "video_fit": "cover", + "video_title": "电风扇斧王10秒肉山", + "profile_fetched_at": "2026-07-28T10:28:00+00:00" + }, + { + "id": "dota25830", + "platform": "douyin", + "live_url": "https://live.douyin.com/336796244386", + "profile_url": "https://v.douyin.com/Bk6PZIs7kCE/", + "heroes": [ + "lion" + ], + "nickname": "5830 Dota2", + "unique_id": "583093455jj", + "signature": "dota2 国服第一lion 上牢车:dota258", + "following_count": 172, + "follower_count": 8466, + "total_favorited": 7074, + "avatar": "streamer_avatars/dota25830.jpg", + "video": "streamer_videos/dota25830.mp4", + "video_title": "Lion亡养成班,中单lion新手教学", + "profile_fetched_at": "2026-07-28T10:30:00+00:00" + }, + { + "id": "mayi", + "platform": "douyin", + "profile_url": "https://v.douyin.com/9KwWjZPjB3U/", + "heroes": [ + "weaver" + ], + "nickname": "橙子dota2", + "unique_id": "72388849434", + "signature": "泡泡 : chengzidota (不只是带你赢,更是教会你赢)", + "following_count": 1424, + "follower_count": 2049, + "total_favorited": 300, + "avatar": "streamer_avatars/mayi.jpg", + "video": "streamer_videos/mayi.mp4", + "video_title": "蚂蚁暴走", + "profile_fetched_at": "2026-07-28T10:50:00+00:00" + }, + { + "id": "huomao", + "platform": "douyin", + "live_url": "https://live.douyin.com/401878837924", + "profile_url": "https://v.douyin.com/GG79CCGREhc/", + "heroes": [ + "ember_spirit" + ], + "nickname": "dota2火影猫", + "unique_id": "44772013956", + "signature": "打号、上车、陪后台踢踢", + "following_count": 188, + "follower_count": 1781, + "total_favorited": 524, + "avatar": "streamer_avatars/huomao.jpg", + "video": "streamer_videos/huomao.mp4", + "video_title": "7500单排局火猫如何应对哈斯卡", + "profile_fetched_at": "2026-07-28T10:52:00+00:00" + }, + { + "id": "gugong", + "platform": "douyin", + "live_url": "https://live.douyin.com/517090873115", + "profile_url": "https://v.douyin.com/T0eNOgDLB1g/", + "heroes": [ + "clinkz" + ], + "nickname": "爪爪熊想遇见樱岛麻衣", + "unique_id": "21811462417", + "signature": "dota2天梯最高9500分", + "following_count": 30, + "follower_count": 1499, + "total_favorited": 637, + "avatar": "streamer_avatars/gugong.jpg", + "video": "streamer_videos/gugong.mp4", + "video_title": "小骷髅后期教科书,大吹风魅力时刻", + "profile_fetched_at": "2026-07-28T10:55:00+00:00" + }, + { + "id": "shawang", + "platform": "bilibili", + "live_url": "https://live.bilibili.com/1865937232", + "profile_url": "https://space.bilibili.com/1439688338", + "tagline": "三号位 · 天梯8500分", + "heroes": [ + "doom_bringer", + "axe", + "sand_king", + "spirit_breaker" + ], + "nickname": "沙王", + "unique_id": "1439688338", + "signature": "主播擅长三号位,天梯8500分。今年36岁,20年刀龄,真三dota,dota2 四泰斗末日斧王沙王白牛 直播时间 10.00-18.00", + "following_count": 231, + "follower_count": 7081, + "avatar": "streamer_avatars/shawang.jpg", + "video": "streamer_videos/shawang.mp4", + "video_title": "Dota2沙王直播录像" + } + ] +} diff --git a/deploy_relations.py b/deploy_relations.py index dde476d..d7a12d5 100644 --- a/deploy_relations.py +++ b/deploy_relations.py @@ -1,4 +1,4 @@ -"""Deploy the relations preview static site to Cloudflare Pages. +"""Deploy the Climperor web site static bundle to Cloudflare Pages. Bundles export + integrity check + wrangler direct upload + custom domain binding into one local command. Designed to run with credentials injected @@ -336,7 +336,7 @@ def bind_domain( def main() -> None: - ap = argparse.ArgumentParser(description="Deploy relations site to Cloudflare Pages") + ap = argparse.ArgumentParser(description="Deploy Climperor web site to Cloudflare Pages") ap.add_argument("--no-export", action="store_true", help="skip re-export, deploy existing dist") ap.add_argument("--project-name", default=DEFAULT_PROJECT) ap.add_argument("--domain", default=DEFAULT_DOMAIN) diff --git a/draft_archetypes.py b/draft_archetypes.py new file mode 100644 index 0000000..d9da143 --- /dev/null +++ b/draft_archetypes.py @@ -0,0 +1,260 @@ +"""Rule-based draft lineup archetypes and gap analysis (no AI). + +Detects push / global enemy shapes, enemy & ally tag gaps, answer heroes, +and short Chinese analysis / reason strings for recommend marks. +""" + +from __future__ import annotations + +from collections import Counter +from typing import Iterable + +from hero_tags import TAG_ORDER + +# Strong push cores: one hit can flag push even before 2+ Pusher tags. +PUSH_CORE = frozenset({ + "lycan", + "furion", + "broodmother", + "chen", + "enchantress", + "visage", + "beastmaster", + "naga_siren", + "lone_druid", + "undying", +}) + +GLOBAL_SET = frozenset({ + "furion", + "spectre", + "wisp", + "abyssal_underlord", + "zuus", + "ancient_apparition", + "spirit_breaker", + "storm_spirit", + "rattletrap", +}) + +HARD_GLOBAL = frozenset({ + "furion", + "spectre", + "wisp", +}) + +# Archetype -> answer hero keys (marked 克 with reason 对推进 / 对全球流). +ARCHETYPE_ANSWERS: dict[str, tuple[str, ...]] = { + "push": ( + "medusa", + "terrorblade", + "naga_siren", + "jakiro", + "gyrocopter", + "dragon_knight", + "shredder", + ), + "global": ( + "storm_spirit", + "anti_mage", + "riki", + "bounty_hunter", + "queenofpain", + "ember_spirit", + ), +} + +ARCHETYPE_REASON = { + "push": "对推进", + "global": "对全球流", +} + +ARCHETYPE_LABEL = { + "push": "偏推进", + "global": "全球流", +} + +# Gaps we report (user-facing). 输出 is proxied by 核心. +GAP_TAGS = ("控制", "爆发", "核心", "先手") + +GAP_DISPLAY = { + "控制": "控制", + "爆发": "爆发", + "核心": "输出", + "先手": "先手", +} + +# Enemy gap -> candidate tags that punish it (marked 克). +ENEMY_GAP_PUNISH: dict[str, tuple[str, ...]] = { + "控制": ("控制", "先手"), + "爆发": ("耐久", "核心"), + "核心": ("爆发", "控制"), + "先手": ("先手", "爆发"), +} + +MAX_REASONS = 3 +MAX_REASON_LEN = 12 + + +def tag_profile(keys: Iterable[str], tags_by_key: dict[str, list[str]]) -> dict[str, int]: + counts: Counter[str] = Counter() + for key in keys: + for tag in tags_by_key.get(key) or []: + if tag in TAG_ORDER: + counts[tag] += 1 + return {t: counts[t] for t in TAG_ORDER if counts.get(t)} + + +def detect_archetypes( + enemies: list[str], + tags_by_key: dict[str, list[str]], + *, + push_tag_min: int = 2, +) -> list[str]: + """Return ordered archetype ids present in the enemy lineup.""" + out: list[str] = [] + push_n = sum(1 for e in enemies if "推进" in (tags_by_key.get(e) or [])) + if push_n >= push_tag_min or any(e in PUSH_CORE for e in enemies): + out.append("push") + global_hits = [e for e in enemies if e in GLOBAL_SET] + if len(global_hits) >= 2 or any(e in HARD_GLOBAL for e in enemies): + out.append("global") + return out + + +def detect_gaps( + profile: dict[str, int], + *, + hero_count: int, + min_heroes: int = 2, +) -> list[str]: + """Return missing GAP_TAGS when enough heroes are locked.""" + if hero_count < max(1, int(min_heroes)): + return [] + missing = [] + for tag in GAP_TAGS: + if int(profile.get(tag) or 0) <= 0: + missing.append(tag) + return missing + + +def format_analysis( + *, + archetypes: list[str], + enemy_gaps: list[str], + ally_gaps: list[str], + ally_count: int, +) -> str: + """One short Chinese lineup summary (may be empty).""" + enemy_bits: list[str] = [] + for arch in archetypes: + lab = ARCHETYPE_LABEL.get(arch) + if lab and lab not in enemy_bits: + enemy_bits.append(lab) + for gap in enemy_gaps: + disp = GAP_DISPLAY.get(gap, gap) + bit = f"缺{disp}" + if bit not in enemy_bits: + enemy_bits.append(bit) + + ally_bits: list[str] = [] + if ally_count <= 0: + if enemy_bits: + ally_bits.append("缺口尚不明") + else: + for gap in ally_gaps: + disp = GAP_DISPLAY.get(gap, gap) + bit = f"缺{disp}" + if bit not in ally_bits: + ally_bits.append(bit) + + parts: list[str] = [] + if enemy_bits: + parts.append("敌:" + "·".join(enemy_bits)) + if ally_bits: + parts.append("我:" + "·".join(ally_bits)) + text = " | ".join(parts) + if len(text) > 40: + text = text[:39] + "…" + return text + + +def _trim_reason(s: str) -> str: + s = (s or "").strip() + if len(s) <= MAX_REASON_LEN: + return s + return s[: MAX_REASON_LEN - 1] + "…" + + +def collect_reasons( + *, + names: dict[str, str], + beats: list[dict], + with_allies: list[dict], + archetype_hits: list[str], + punish_gaps: list[str], + fill_gaps: list[str], +) -> list[str]: + """Build up to MAX_REASONS short reason phrases for one candidate.""" + reasons: list[str] = [] + + def add(phrase: str) -> None: + p = _trim_reason(phrase) + if p and p not in reasons and len(reasons) < MAX_REASONS: + reasons.append(p) + + # Prefer one signal per mark type (克 / 补 / 搭) before extras. + for edge in beats[:1]: + add(f"克{names.get(edge['enemy'], edge['enemy'])}") + for gap in fill_gaps[:1]: + add(f"补{GAP_DISPLAY.get(gap, gap)}") + for edge in with_allies[:1]: + add(f"搭{names.get(edge['ally'], edge['ally'])}") + for arch in archetype_hits: + add(ARCHETYPE_REASON.get(arch, arch)) + for gap in punish_gaps: + add(f"打缺{GAP_DISPLAY.get(gap, gap)}") + for edge in beats[1:]: + add(f"克{names.get(edge['enemy'], edge['enemy'])}") + for gap in fill_gaps[1:]: + add(f"补{GAP_DISPLAY.get(gap, gap)}") + for edge in with_allies[1:]: + add(f"搭{names.get(edge['ally'], edge['ally'])}") + return reasons + + +def answer_for_candidate( + key: str, + cand_tags: list[str], + *, + archetypes: list[str], + enemy_gaps: list[str], + ally_gaps: list[str], +) -> tuple[list[str], list[str], list[str]]: + """Return (archetype_hits, punish_gaps, fill_gaps) that apply to this hero.""" + tag_set = set(cand_tags or []) + arch_hits = [a for a in archetypes if key in ARCHETYPE_ANSWERS.get(a, ())] + punish = [] + for gap in enemy_gaps: + wanted = ENEMY_GAP_PUNISH.get(gap) or () + if tag_set.intersection(wanted): + punish.append(gap) + fill = [g for g in ally_gaps if g in tag_set] + # 核心 gap displays as 输出; candidate must have 核心 tag to fill. + return arch_hits, punish, fill + + +__all__ = [ + "ARCHETYPE_ANSWERS", + "ARCHETYPE_LABEL", + "ARCHETYPE_REASON", + "ENEMY_GAP_PUNISH", + "GAP_DISPLAY", + "GAP_TAGS", + "answer_for_candidate", + "collect_reasons", + "detect_archetypes", + "detect_gaps", + "format_analysis", + "tag_profile", +] diff --git a/draft_session.py b/draft_session.py index 6197ff6..4f0eb46 100644 --- a/draft_session.py +++ b/draft_session.py @@ -38,7 +38,7 @@ from capture import grab_frame, is_dota_foreground, raw_dir_for_match, save_fram from grid import bans, hero_table, read_grid from modes import detect_mode, load_mode_templates from recognize import recognize_image -from recommend import ally_keys, enemy_keys, load_relations, suggest_top +from recommend import ally_keys, enemy_keys, load_relations, suggest_marks from roles import ROLES, detect_roles, load_role_templates HERO_SELECTION = "DOTA_GAMERULES_STATE_HERO_SELECTION" @@ -84,11 +84,16 @@ class DraftSession: self.frame_dir = raw_dir_for_match(None) rec = cfg.get("recommend") or {} self.recommend_enabled = bool(rec.get("enabled", True)) - self.recommend_top_n = int(rec.get("top_n", 3)) + self.recommend_top_n = int(rec.get("top_n", 0)) + self.recommend_min_enemies = int(rec.get("min_enemies", 1)) + self.recommend_min_heroes_for_gaps = int(rec.get("min_heroes_for_gaps", 2)) + self.recommend_archetypes = bool(rec.get("archetypes", True)) self.recommend_role_tags = rec.get("role_tags") self.relations = load_relations(rec.get("relations_path")) if self.recommend_enabled else None self._rec_warned = False self._last_rec_sig: tuple | None = None + self._last_enemy_profile: dict = {} + self._last_rec_meta: dict = {} def _push_overlay(self, confirmed: dict[int, str]) -> None: if self.overlay is None: @@ -98,12 +103,19 @@ class DraftSession: except Exception as e: # noqa: BLE001 self.log(f"[draft] overlay update failed: {e}") - def _push_rec_overlay(self, cells: dict | None, picks: list[dict]) -> None: + def _push_rec_overlay( + self, + cells: dict | None, + picks: list[dict], + analysis: str = "", + ) -> None: if self.overlay is None: return try: - marks = {p["key"]: p["rank"] for p in picks} + marks = {p["key"]: list(p.get("labels") or []) for p in picks if p.get("key")} self.overlay.set_grid_marks(cells or {}, marks) + if hasattr(self.overlay, "set_analysis"): + self.overlay.set_analysis(analysis or "") except Exception as e: # noqa: BLE001 self.log(f"[draft] rec overlay update failed: {e}") @@ -115,6 +127,8 @@ class DraftSession: try: self.overlay.set_roster({}) self.overlay.set_grid_marks({}, {}) + if hasattr(self.overlay, "set_analysis"): + self.overlay.set_analysis("") self.overlay.show() except Exception as e: # noqa: BLE001 self.log(f"[draft] overlay show failed: {e}") @@ -255,6 +269,8 @@ class DraftSession: if self.overlay is not None: try: self.overlay.set_grid_marks({}, {}) + if hasattr(self.overlay, "set_analysis"): + self.overlay.set_analysis("") self.overlay.hide() except Exception as e: # noqa: BLE001 self.log(f"[draft] overlay hide failed: {e}") @@ -365,7 +381,7 @@ class DraftSession: def _refresh_recommendations(self, confirmed: dict, info: dict, gsi_fn, *, force: bool = False) -> None: if not self.recommend_enabled: return - if not self.relations: + if not self.relations and not self.recommend_archetypes: return gsi = (gsi_fn() or {}) if gsi_fn else {} self_slot = gsi_slot(gsi) or info.get("self_slot") @@ -376,8 +392,10 @@ class DraftSession: if self_slot and confirmed.get(self_slot): if info.get("recommendations") or self._last_rec_sig is not None: info["recommendations"] = [] + self._last_enemy_profile = {} + self._last_rec_meta = {} self._last_rec_sig = ("locked",) - self._push_rec_overlay(info.get("cells"), []) + self._push_rec_overlay(info.get("cells"), [], "") return enemies = enemy_keys(confirmed, self_team) allies = ally_keys(confirmed, self_team, self_slot) @@ -388,11 +406,13 @@ class DraftSession: if not force and sig == self._last_rec_sig: return self._last_rec_sig = sig - if position is None or (not enemies and not allies): + if len(enemies) < self.recommend_min_enemies: info["recommendations"] = [] - self._push_rec_overlay(info.get("cells"), []) + self._last_enemy_profile = {} + self._last_rec_meta = {} + self._push_rec_overlay(info.get("cells"), [], "") return - picks = suggest_top( + result = suggest_marks( position=position, enemies=enemies, allies=allies, @@ -400,14 +420,38 @@ class DraftSession: relations=self.relations, top_n=self.recommend_top_n, role_tags=self.recommend_role_tags, + min_enemies=self.recommend_min_enemies, + min_heroes_for_gaps=self.recommend_min_heroes_for_gaps, + archetypes_enabled=self.recommend_archetypes, ) + picks = result.get("marks") or [] + profile = result.get("enemy_profile") or {} + analysis = result.get("analysis") or "" info["recommendations"] = picks - self._push_rec_overlay(info.get("cells"), picks) - if picks: - names = ", ".join(f"{p['rank']}.{p['name_loc']}({p['score']:+d})" for p in picks) + self._last_enemy_profile = profile + self._last_rec_meta = { + "analysis": analysis, + "enemy_archetypes": list(result.get("enemy_archetypes") or []), + "enemy_gaps": list(result.get("enemy_gaps") or []), + "ally_gaps": list(result.get("ally_gaps") or []), + "ally_profile": dict(result.get("ally_profile") or {}), + } + self._push_rec_overlay(info.get("cells"), picks, analysis) + if picks or analysis: + bits = [] + for p in picks[:12]: + labs = "".join(p.get("labels") or []) + why = "、".join(p.get("reasons") or []) + extra = f":{why}" if why else "" + bits.append(f"{p['name_loc']}[{labs}]{extra}") + pos_s = f"pos{position}" if position is not None else "all" ally_n = [self.hero_names.get(a, a) for a in allies] enemy_n = [self.hero_names.get(e, e) for e in enemies] - self.log(f"[rec] pos{position} with {ally_n} vs {enemy_n}: {names}") + analysis_s = analysis or "-" + self.log( + f"[rec] {pos_s} {analysis_s} | with {ally_n} vs {enemy_n}: " + f"{len(picks)} marks — {', '.join(bits)}" + ) def _absorb_picks(self, result: dict, pending: dict, confirmed: dict, scores: dict, *, allow_revise: bool = True, @@ -541,6 +585,12 @@ class DraftSession: "position": role["position"] if role else None, "enemies": enemies, "allies": allies, + "enemy_profile": dict(self._last_enemy_profile or {}), + "ally_profile": dict((self._last_rec_meta or {}).get("ally_profile") or {}), + "enemy_archetypes": list((self._last_rec_meta or {}).get("enemy_archetypes") or []), + "enemy_gaps": list((self._last_rec_meta or {}).get("enemy_gaps") or []), + "ally_gaps": list((self._last_rec_meta or {}).get("ally_gaps") or []), + "analysis": (self._last_rec_meta or {}).get("analysis") or "", "picks": info.get("recommendations") or [], }, } @@ -613,10 +663,18 @@ def describe(summary: dict) -> list[str]: if summary.get("bans_loc"): lines.append(f"bans : {len(summary['bans_loc'])} - {', '.join(summary['bans_loc'])}") rec = summary.get("recommendations") or {} + analysis = rec.get("analysis") or "" + if analysis: + lines.append(f"draft : {analysis}") picks = rec.get("picks") or [] if picks: - bits = ", ".join(f"{p['rank']}.{p.get('name_loc') or loc(p['key'], names)}" for p in picks) - lines.append(f"rec : {bits}") + bits = [] + for p in picks[:15]: + labs = "".join(p.get("labels") or []) or "?" + why = "、".join(p.get("reasons") or []) + name = p.get("name_loc") or loc(p["key"], names) + bits.append(f"{name}[{labs}]" + (f"({why})" if why else "")) + lines.append(f"rec : {len(picks)} — {', '.join(bits)}") return lines diff --git a/export_relations_site.py b/export_relations_site.py index b0858d2..5933153 100644 --- a/export_relations_site.py +++ b/export_relations_site.py @@ -1,4 +1,4 @@ -"""Export the relations preview as a static site (no server needed). +"""Export the Climperor web site as a static bundle (no server needed). Usage: python export_relations_site.py [--out dist/relations] [--with-videos] @@ -35,13 +35,16 @@ from common import ( HERO_PORTRAITS, ITEM_CAT_ICONS, ITEM_ICONS, + RANK_ICONS, ROOT, + STREAMER_AVATARS, + STREAMER_VIDEOS, TEMPLATES_CDN, UI_ICONS, ) from serve_relations import WEB_DIR, build_payload -SITE_VERSION = "0.4.2" +SITE_VERSION = "0.5.59" DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com" @@ -58,14 +61,32 @@ def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int: def populate_static_assets(out: Path, payload: dict) -> dict[str, int]: - """Copy preview image dirs into ``out`` (portrait/item/ability/...).""" + """Copy web site image dirs into ``out`` (portrait/item/ability/...).""" counts = { "attr": copy_glob(ATTR_ICONS, out / "attr"), + "rank": copy_glob(RANK_ICONS, out / "rank", "rank*.png"), "item": copy_glob(ITEM_ICONS, out / "item"), "item-cat": copy_glob(ITEM_CAT_ICONS, out / "item-cat", "itemcat_*.png"), "ability": copy_glob(ABILITY_ICONS, out / "ability"), "ui-icon": copy_glob(UI_ICONS, out / "ui-icon"), + "streamer-avatar": 0, + "streamer-video": 0, } + if STREAMER_AVATARS.is_dir(): + n_av = 0 + for pattern in ("*.jpg", "*.jpeg", "*.png", "*.webp"): + n_av += copy_glob(STREAMER_AVATARS, out / "streamer-avatar", pattern) + counts["streamer-avatar"] = n_av + if STREAMER_VIDEOS.is_dir(): + n_vid = 0 + dst = out / "streamer-video" + dst.mkdir(parents=True, exist_ok=True) + for f in sorted(STREAMER_VIDEOS.glob("*.mp4")): + if not f.is_file() or f.name.startswith("_"): + continue + shutil.copy2(f, dst / f.name) + n_vid += 1 + counts["streamer-video"] = n_vid portrait_dst = out / "portrait" portrait_dst.mkdir(exist_ok=True) @@ -114,7 +135,7 @@ def write_config_js( def main() -> None: ap = argparse.ArgumentParser( - description="Export relations preview as a static site" + description="Export Climperor web site as a static bundle" ) ap.add_argument("--out", default=str(ROOT / "dist" / "relations")) ap.add_argument( @@ -173,7 +194,19 @@ def main() -> None: ) if static_base: - counts = {name: 0 for name in ("attr", "item", "item-cat", "ability", "ui-icon", "portrait")} + counts = { + name: 0 + for name in ( + "attr", + "item", + "item-cat", + "ability", + "ui-icon", + "portrait", + "streamer-avatar", + "streamer-video", + ) + } print(" static assets: omitted (STATIC_ASSET_BASE set — served from OSS)") else: counts = populate_static_assets(out, payload) diff --git a/fetch_dota2_logo.py b/fetch_dota2_logo.py index 0e34af6..32059fa 100644 --- a/fetch_dota2_logo.py +++ b/fetch_dota2_logo.py @@ -1,4 +1,4 @@ -"""Fetch the official Dota 2 emblem icon (no wordmark) for the preview header. +"""Fetch the official Dota 2 emblem icon (no wordmark) for the web site header. Source (discovered, not guessed) from the dota2.com.cn site HTML: diff --git a/fetch_hero_abilities.py b/fetch_hero_abilities.py index 2c47cf5..3455f95 100644 --- a/fetch_hero_abilities.py +++ b/fetch_hero_abilities.py @@ -10,6 +10,7 @@ Usage: python fetch_hero_abilities.py --delay 0.2 python fetch_hero_abilities.py --icons # also cache ability PNGs python fetch_hero_abilities.py --icons-only # icons from existing JSON + python fetch_hero_abilities.py --tags-only # recompute mechanic tags only """ from __future__ import annotations @@ -27,6 +28,11 @@ from grid import hero_table from hero_tags import ILLUSION_KEYS from http_utils import download_icons, http_json from loc_format import format_loc, strip_html +from mechanic_tags import ( + QUERY_MECHANIC_ORDER, + apply_mechanic_tags, + merge_tag_overrides, +) HERODATA_URL = ( "https://www.dota2.com/datafeed/herodata?language=schinese&hero_id={hero_id}" @@ -38,6 +44,7 @@ ABILITY_ICON_URL = ( "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/abilities/{key}.png" ) OUT = DATA / "hero_abilities.json" +ABILITY_TAG_OVERRIDES = DATA / "ability_tag_overrides.json" DESC_MAX = 600 UPGRADE_MAX = 500 TALENT_LEVELS = (10, 10, 15, 15, 20, 20, 25, 25) @@ -45,6 +52,50 @@ TALENT_LEVELS = (10, 10, 15, 15, 20, 20, 25, 25) SKIP_ICON_KEYS = frozenset({"innate", "talent_tree"}) +def _load_ability_tag_overrides() -> dict[str, dict]: + if not ABILITY_TAG_OVERRIDES.is_file(): + return {} + try: + raw = json.loads(ABILITY_TAG_OVERRIDES.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + items = raw.get("abilities") or {} + return {str(k): v for k, v in items.items() if isinstance(v, dict)} + + +def ability_mechanic_tags(ab: dict, overrides: dict[str, dict] | None = None) -> list[str]: + """Compute applies-* mechanic tags for one ability row.""" + key = str(ab.get("key") or "") + blob = " ".join( + [ + str(ab.get("name_loc") or ""), + str(ab.get("desc_loc") or ""), + str(ab.get("shard_loc") or ""), + str(ab.get("scepter_loc") or ""), + ] + ) + auto = apply_mechanic_tags(blob, key=key) + ov = (overrides or {}).get(key) + return merge_tag_overrides(auto, ov, QUERY_MECHANIC_ORDER) + + +def retag_abilities(by_hero: dict, overrides: dict[str, dict] | None = None) -> int: + """Write ``tags`` onto every ability; return number of abilities with tags.""" + ov = overrides if overrides is not None else _load_ability_tag_overrides() + tagged = 0 + for cell in by_hero.values(): + if not isinstance(cell, dict): + continue + for ab in cell.get("abilities") or []: + if not isinstance(ab, dict) or not ab.get("key"): + continue + tags = ability_mechanic_tags(ab, ov) + ab["tags"] = tags + if tags: + tagged += 1 + return tagged + + def _load_talent_overrides() -> dict[str, str]: """Load manual name_loc overrides for talents Valve omits bonus data for.""" p = DATA / "talent_overrides.json" @@ -64,6 +115,7 @@ def _load_talent_overrides() -> dict[str, str]: TALENT_OVERRIDES = _load_talent_overrides() +ABILITY_TAG_OVERRIDES_MAP = _load_ability_tag_overrides() # Valve dispellable int → label DISPEL_MAP = { @@ -452,6 +504,7 @@ def ability_row(raw: dict, odota: dict[str, dict]) -> dict | None: "mana_cost": mana_cost, "specials": special_rows(sv), "lore_loc": strip_html(raw.get("lore_loc") or "").strip(), + "tags": [], } @@ -580,11 +633,38 @@ def main() -> None: action="store_true", help="Re-download ability icons even when a local PNG already exists", ) + ap.add_argument( + "--tags-only", + action="store_true", + help="Recompute ability mechanic tags from cached JSON without refetching", + ) args = ap.parse_args() heroes = hero_table() heroes_by_key = {h["key"]: h for h in heroes} + if args.tags_only: + if not args.out.is_file(): + raise SystemExit(f"missing {args.out}; run without --tags-only first") + prev = json.loads(args.out.read_text(encoding="utf-8")) + by_hero = dict(prev.get("by_hero") or {}) + overrides = _load_ability_tag_overrides() + tagged = retag_abilities(by_hero, overrides) + meta = dict(prev.get("meta") or {}) + meta["fetched_at"] = datetime.now(timezone.utc).isoformat() + meta["tags_only"] = True + meta["mechanic_tag_order"] = list(QUERY_MECHANIC_ORDER) + payload = {"meta": meta, "by_hero": by_hero} + args.out.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print( + f"retag done: {len(by_hero)} heroes, {tagged} abilities with tags -> {args.out}", + flush=True, + ) + return + if args.icons_only: if not args.out.is_file(): raise SystemExit(f"missing {args.out}; run without --icons-only first") @@ -693,6 +773,7 @@ def main() -> None: continue ab = ability_row(row, odota) if ab: + ab["tags"] = ability_mechanic_tags(ab, ABILITY_TAG_OVERRIDES_MAP) abilities.append(ab) talents = talent_rows(data.get("talents"), odota, abs_raw) @@ -742,12 +823,15 @@ def main() -> None: ) time.sleep(args.delay) + tagged = retag_abilities(by_hero, ABILITY_TAG_OVERRIDES_MAP) payload = { "meta": { "source": "valve+opendota", "attribution": "https://www.dota2.com ; https://www.opendota.com", "fetched_at": datetime.now(timezone.utc).isoformat(), "heroes": len(by_hero), + "mechanic_tag_order": list(QUERY_MECHANIC_ORDER), + "abilities_with_tags": tagged, }, "by_hero": by_hero, } diff --git a/fetch_hero_matches.py b/fetch_hero_matches.py new file mode 100644 index 0000000..1ceb9a8 --- /dev/null +++ b/fetch_hero_matches.py @@ -0,0 +1,1461 @@ +"""Fetch recent OpenDota matches per hero with final items + skill builds. + +Sources (switch with --source): + league GET /heroes/{id}/matches (pro/league-biased; fewer requests) + public Scan GET /publicMatches for the hero, then match details + both Merge both (dedupe by match_id); each row tagged origin + +Kept rows (after merge, newest first, up to --limit): + - wins only + - public/ladder: Legend+ only (avg_rank_tier / rank_tier >= 50) + - league/pro: wins only (no medal filter) + +Public region (--public-region, default china): + china Prefer Perfect World / 国服 clusters when scanning publicMatches; + fill --limit with China pubs first, then backfill other regions if short + any No cluster filter (global pubs) + +China clusters: classic PW ids from odota cluster→region map (regions +12/13/17/18/20/25) plus modern CJK-heavy clusters observed in publicMatches +(413/414/415/417). Each row stores `cluster` when available. + +Per match: final inventory + ability_upgrades_arr resolved to ability keys. +When OpenDota has parsed the match (`purchase_log`), also store per-slot +purchase times (seconds from game start; may be negative pre-game): + item_times / backpack_times / item_neutral_time. +Unparsed matches omit the log → times stay null (icons still shown). + +Speed: --workers N concurrent match-detail fetches; stop once enough +accepted wins are collected. Optional OPENDOTA_API_KEY raises rate limits. +Heroes are still written sequentially (one JSON writer). + +Incremental (default): refetch heroes missing from by_hero, or with fewer +than --limit kept matches. Use --force to refetch all. + +Output: data/hero_matches.json (Climperor web site only; not used by recommend). + +Usage: + python fetch_hero_matches.py --heroes antimage + python fetch_hero_matches.py --source both --public-region china --limit 10 --workers 6 --delay 0.05 + python fetch_hero_matches.py --force + python fetch_hero_matches.py --enrich-item-times --heroes juggernaut +""" + +from __future__ import annotations + +import argparse +import json +import os +import time +import urllib.error +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime, timezone +from pathlib import Path + +from common import DATA, ITEM_ICONS +from grid import hero_table +from http_utils import download_icons, http_json + +OPENDOTA = "https://api.opendota.com/api" +ABILITY_IDS_URL = ( + "https://raw.githubusercontent.com/odota/dotaconstants/master/build/ability_ids.json" +) +ITEMS_URL = ( + "https://raw.githubusercontent.com/odota/dotaconstants/master/build/items.json" +) +ICON_URL = ( + "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/items/{key}.png" +) +OUT = DATA / "hero_matches.json" +# Max recent matches kept per hero after merge (league + public), newest first. +DEFAULT_LIMIT = 10 +DEFAULT_WORKERS = 6 +PUBLIC_PAGES_CAP = 80 +# Prefer deeper scan when filtering to China (hero+cluster coincidence is rarer). +PUBLIC_PAGES_CAP_CHINA = 160 + +# Perfect World region ids (odota region.json / Valve regions.txt). +CHINA_REGIONS = frozenset({12, 13, 17, 18, 20, 25}) +# Classic PW clusters (odota cluster.json → CHINA_REGIONS) + modern observed +# 国服 clusters in publicMatches (CJK-heavy; absent from outdated cluster.json). +# Exclude 410/412 (still mapped to US East) and other non-CJK 41x samples. +CHINA_CLUSTERS = frozenset( + { + 221, + 222, + 223, + 224, + 225, + 227, + 231, + 232, + 235, + 236, + 413, + 414, + 415, + 417, + } +) + +# OpenDota lobby_type / game_mode filters for public ranked pubs (skip bots / turbo). +BOT_LOBBY_TYPES = frozenset({3, 4, 10}) # tutorial, coop_bots, local_bots +RANKED_LOBBY_TYPES = frozenset({5, 6, 7}) # ranked solo / team / legacy ranked +EXCLUDED_GAME_MODES = frozenset({23}) # Turbo +MIN_HUMAN_PLAYERS = 10 +# Legend+ (传奇及以上): rank_tier = medal*10 + stars → legend floor is 50. +MIN_RANK_TIER = 50 +# Over-fetch list candidates so detail+win/rank filters can still fill --limit. +CANDIDATE_MULTIPLIER = 8 + + +def _int_field(row: dict, key: str) -> int | None: + raw = row.get(key) + if raw is None: + return None + try: + return int(raw) + except (TypeError, ValueError): + return None + + +def is_legend_plus_tier(tier: int | None) -> bool: + """True if OpenDota rank_tier / avg_rank_tier is Legend medal or higher.""" + if tier is None: + return False + try: + t = int(tier) + except (TypeError, ValueError): + return False + return t >= MIN_RANK_TIER + + +def hero_won_on_list_row(row: dict, hero_id: int | None = None) -> bool | None: + """Infer win from list meta when radiant_win (+ slot or team) is present.""" + if "radiant_win" not in row: + return None + radiant_win = bool(row.get("radiant_win")) + slot = _int_field(row, "player_slot") + if slot is not None: + radiant = slot < 128 + return radiant_win if radiant else (not radiant_win) + if hero_id is None: + return None + radiant_team = row.get("radiant_team") or [] + dire_team = row.get("dire_team") or [] + on_radiant = False + on_dire = False + if isinstance(radiant_team, list): + for hid in radiant_team: + try: + if int(hid) == hero_id: + on_radiant = True + break + except (TypeError, ValueError): + continue + if isinstance(dire_team, list): + for hid in dire_team: + try: + if int(hid) == hero_id: + on_dire = True + break + except (TypeError, ValueError): + continue + if on_radiant and not on_dire: + return radiant_win + if on_dire and not on_radiant: + return not radiant_win + return None + + +def public_list_row_ok(row: dict, hero_id: int) -> bool: + """True if a publicMatches list row is ranked Legend+ win for hero_id.""" + lobby = _int_field(row, "lobby_type") + game_mode = _int_field(row, "game_mode") + if lobby is not None and lobby in BOT_LOBBY_TYPES: + return False + if game_mode is not None and game_mode in EXCLUDED_GAME_MODES: + return False + if lobby is not None and lobby not in RANKED_LOBBY_TYPES: + return False + avg_rank = _int_field(row, "avg_rank_tier") + if not is_legend_plus_tier(avg_rank): + return False + won = hero_won_on_list_row(row, hero_id) + if won is False: + return False + return True + + +def match_detail_ok(match: dict, list_meta: dict | None = None) -> bool: + """Drop bot / partial-human matches after detail fetch.""" + lobby = _int_field(match, "lobby_type") + if lobby is None and list_meta: + lobby = _int_field(list_meta, "lobby_type") + game_mode = _int_field(match, "game_mode") + if game_mode is None and list_meta: + game_mode = _int_field(list_meta, "game_mode") + if lobby is not None and lobby in BOT_LOBBY_TYPES: + return False + if game_mode is not None and game_mode in EXCLUDED_GAME_MODES: + return False + human = _int_field(match, "human_players") + if human is not None and human < MIN_HUMAN_PLAYERS: + return False + return True + + +def slim_match_ok(slim: dict) -> bool: + """Keep wins only; public/ladder also requires Legend+ rank.""" + if not isinstance(slim, dict): + return False + if not slim.get("won"): + return False + origin = str(slim.get("origin") or "") + if origin == "public": + tier = slim.get("rank_tier") + if tier is None: + tier = slim.get("avg_rank_tier") + try: + t_i = int(tier) if tier is not None else None + except (TypeError, ValueError): + t_i = None + if not is_legend_plus_tier(t_i): + return False + return True + + +def is_china_cluster(cluster: int | None, region: int | None = None) -> bool: + """True if cluster/region looks like Perfect World 国服.""" + if region is not None and region in CHINA_REGIONS: + return True + if cluster is not None and cluster in CHINA_CLUSTERS: + return True + return False + + +def load_ability_id_map() -> dict[int, str]: + raw = http_json(ABILITY_IDS_URL) + out: dict[int, str] = {} + if not isinstance(raw, dict): + return out + for sid, key in raw.items(): + try: + aid = int(sid) + except (TypeError, ValueError): + continue + if isinstance(key, str) and key.strip(): + out[aid] = key.strip() + return out + + +def load_item_id_catalog() -> dict[int, dict]: + """id → {key, dname} from dotaconstants (English dname; UI may localize via shop).""" + raw = http_json(ITEMS_URL) + out: dict[int, dict] = {} + if not isinstance(raw, dict): + return out + for key, row in raw.items(): + if not isinstance(row, dict) or row.get("id") is None: + continue + key_s = str(key) + if key_s.startswith("recipe_"): + continue + try: + iid = int(row["id"]) + except (TypeError, ValueError): + continue + out[iid] = {"key": key_s, "dname": str(row.get("dname") or key_s)} + return out + + +def resolve_upgrades(arr: list | None, id_map: dict[int, str]) -> list[str]: + if not isinstance(arr, list): + return [] + keys: list[str] = [] + for raw in arr: + try: + aid = int(raw) + except (TypeError, ValueError): + continue + key = id_map.get(aid) + if key: + keys.append(key) + return keys + + +def item_slot_ids(player: dict, prefix: str, n: int) -> list[int]: + out: list[int] = [] + for i in range(n): + raw = player.get(f"{prefix}_{i}") + try: + iid = int(raw or 0) + except (TypeError, ValueError): + iid = 0 + if iid > 0: + out.append(iid) + return out + + +def _purchase_log_entries(raw) -> list[tuple[str, int]]: + """Chronological (key, time_sec) from OpenDota purchase_log.""" + if not isinstance(raw, list): + return [] + out: list[tuple[str, int]] = [] + for row in raw: + if not isinstance(row, dict): + continue + key = row.get("key") + if not isinstance(key, str) or not key.strip(): + continue + try: + t = int(row["time"]) + except (TypeError, ValueError, KeyError): + continue + out.append((key.strip(), t)) + return out + + +def slot_purchase_times( + item_ids: list[int], + purchase_log, + item_catalog: dict[int, dict], +) -> list[int | None]: + """Map each final inventory id → last matching purchase time (sec). + + Consumes from the end of the log so duplicate keys get distinct times. + Falls back to recipe_ when the finished item key is absent. + Missing purchase_log → all nulls. + """ + n = len(item_ids) + if n == 0: + return [] + entries = _purchase_log_entries(purchase_log) + if not entries: + return [None] * n + used = [False] * len(entries) + times: list[int | None] = [] + for iid in item_ids: + meta = item_catalog.get(int(iid)) if iid else None + if not meta: + times.append(None) + continue + key = str(meta["key"]) + recipe_key = f"recipe_{key}" + found: int | None = None + for i in range(len(entries) - 1, -1, -1): + if used[i]: + continue + if entries[i][0] == key: + found = i + break + if found is None: + for i in range(len(entries) - 1, -1, -1): + if used[i]: + continue + if entries[i][0] == recipe_key: + found = i + break + if found is None: + times.append(None) + else: + used[found] = True + times.append(entries[found][1]) + return times + + +def neutral_purchase_time( + neutral_id: int | None, + player: dict, + item_catalog: dict[int, dict], +) -> int | None: + """Seconds when the final neutral was acquired, if parse data exists.""" + if not neutral_id: + return None + meta = item_catalog.get(int(neutral_id)) + if not meta: + return None + key = str(meta["key"]) + history = player.get("neutral_item_history") + if isinstance(history, list): + last: int | None = None + for row in history: + if not isinstance(row, dict): + continue + if row.get("item_neutral") != key: + continue + try: + last = int(row["time"]) + except (TypeError, ValueError, KeyError): + continue + if last is not None: + return last + # Rare fallback: some neutrals also appear in purchase_log. + for k, t in reversed(_purchase_log_entries(player.get("purchase_log"))): + if k == key: + return t + return None + + +def extract_player_row( + match: dict, + hero_id: int, + *, + origin: str, + id_map: dict[int, str], + item_catalog: dict[int, dict] | None = None, + list_meta: dict | None = None, +) -> dict | None: + """Build one slim match row for the player on hero_id, or None.""" + players = match.get("players") + if not isinstance(players, list): + return None + player = None + for p in players: + if not isinstance(p, dict): + continue + try: + if int(p.get("hero_id") or 0) == hero_id: + player = p + break + except (TypeError, ValueError): + continue + if player is None: + return None + + try: + match_id = int(match.get("match_id") or 0) + except (TypeError, ValueError): + return None + if match_id <= 0: + return None + + slot = int(player.get("player_slot") or 0) + radiant = slot < 128 + radiant_win = bool(match.get("radiant_win")) + won = radiant_win if radiant else not radiant_win + + start_time = match.get("start_time") + if start_time is None and list_meta: + start_time = list_meta.get("start_time") + try: + start_time_i = int(start_time) if start_time is not None else None + except (TypeError, ValueError): + start_time_i = None + + try: + duration = int(match.get("duration") or 0) + except (TypeError, ValueError): + duration = 0 + + league_name = None + if list_meta and list_meta.get("league_name"): + league_name = str(list_meta["league_name"]) + league = match.get("league") + if not league_name and isinstance(league, dict) and league.get("name"): + league_name = str(league["name"]) + + avg_rank = None + if list_meta and list_meta.get("avg_rank_tier") is not None: + try: + avg_rank = int(list_meta["avg_rank_tier"]) + except (TypeError, ValueError): + avg_rank = None + + account_id = player.get("account_id") + try: + account_id_i = int(account_id) if account_id is not None else None + except (TypeError, ValueError): + account_id_i = None + + personaname = player.get("personaname") + if isinstance(personaname, str): + personaname = personaname.strip() or None + else: + personaname = None + # Pro/registered name when present (league matches). + pro_name = player.get("name") + if isinstance(pro_name, str): + pro_name = pro_name.strip() or None + else: + pro_name = None + + rank_tier = player.get("rank_tier") + try: + rank_tier_i = int(rank_tier) if rank_tier is not None else None + except (TypeError, ValueError): + rank_tier_i = None + if rank_tier_i is not None and rank_tier_i <= 0: + rank_tier_i = None + + leaderboard_rank = player.get("leaderboard_rank") + try: + leaderboard_rank_i = ( + int(leaderboard_rank) if leaderboard_rank is not None else None + ) + except (TypeError, ValueError): + leaderboard_rank_i = None + if leaderboard_rank_i is not None and leaderboard_rank_i <= 0: + leaderboard_rank_i = None + + neutral = player.get("item_neutral") + try: + neutral_i = int(neutral) if neutral else None + except (TypeError, ValueError): + neutral_i = None + if neutral_i is not None and neutral_i <= 0: + neutral_i = None + + def _stat(key: str) -> int: + try: + return int(player.get(key) or 0) + except (TypeError, ValueError): + return 0 + + cluster = match.get("cluster") + if cluster is None and list_meta: + cluster = list_meta.get("cluster") + try: + cluster_i = int(cluster) if cluster is not None else None + except (TypeError, ValueError): + cluster_i = None + + region = match.get("region") + try: + region_i = int(region) if region is not None else None + except (TypeError, ValueError): + region_i = None + + lobby_type = _int_field(match, "lobby_type") + if lobby_type is None and list_meta: + lobby_type = _int_field(list_meta, "lobby_type") + game_mode = _int_field(match, "game_mode") + if game_mode is None and list_meta: + game_mode = _int_field(list_meta, "game_mode") + human_players = _int_field(match, "human_players") + + items = item_slot_ids(player, "item", 6) + backpack = item_slot_ids(player, "backpack", 3) + catalog = item_catalog or {} + # Shared consume so a duplicated key across inv+backpack does not + # double-claim the same purchase_log entry. + shared_times = slot_purchase_times( + items + backpack, player.get("purchase_log"), catalog + ) + item_times = shared_times[: len(items)] + backpack_times = shared_times[len(items) :] + neut_time = neutral_purchase_time(neutral_i, player, catalog) + + out = { + "match_id": match_id, + "origin": origin, + "start_time": start_time_i, + "duration": duration, + "won": won, + "kills": _stat("kills"), + "deaths": _stat("deaths"), + "assists": _stat("assists"), + "account_id": account_id_i, + "personaname": personaname, + "name": pro_name, + "rank_tier": rank_tier_i, + "leaderboard_rank": leaderboard_rank_i, + "league_name": league_name, + "avg_rank_tier": avg_rank, + "cluster": cluster_i, + "region": region_i, + "lobby_type": lobby_type, + "game_mode": game_mode, + "human_players": human_players, + "items": items, + "backpack": backpack, + "item_neutral": neutral_i, + "item_times": item_times, + "backpack_times": backpack_times, + "item_neutral_time": neut_time, + "ability_upgrades": resolve_upgrades( + player.get("ability_upgrades_arr"), id_map + ), + } + # Convenience label for UI (pro name > persona); never an account id. + display = pro_name or personaname + if display: + out["display_name"] = display + return out + + +def opendota_api_key() -> str: + """Optional key raises OpenDota rate limits (env OPENDOTA_API_KEY).""" + return (os.environ.get("OPENDOTA_API_KEY") or "").strip() + + +def opendota_url(path: str) -> str: + """Build api.opendota.com URL; append api_key query when configured.""" + base = f"{OPENDOTA}{path}" + key = opendota_api_key() + if not key: + return base + sep = "&" if "?" in base else "?" + return f"{base}{sep}api_key={key}" + + +def fetch_match(match_id: int) -> dict | None: + try: + raw = http_json(opendota_url(f"/matches/{match_id}")) + except ( + urllib.error.HTTPError, + urllib.error.URLError, + TimeoutError, + json.JSONDecodeError, + OSError, + ): + return None + return raw if isinstance(raw, dict) else None + + +def league_match_ids(hero_id: int, limit: int) -> list[dict]: + """Return win list metas from /heroes/{id}/matches (up to candidate cap).""" + try: + raw = http_json(opendota_url(f"/heroes/{hero_id}/matches")) + except ( + urllib.error.HTTPError, + urllib.error.URLError, + TimeoutError, + json.JSONDecodeError, + OSError, + ): + return [] + if not isinstance(raw, list): + return [] + want = max(limit * CANDIDATE_MULTIPLIER, limit) + out: list[dict] = [] + for row in raw: + if not isinstance(row, dict): + continue + try: + mid = int(row.get("match_id") or 0) + except (TypeError, ValueError): + continue + if mid <= 0: + continue + won = hero_won_on_list_row(row, hero_id) + if won is False: + continue + out.append(row) + if len(out) >= want: + break + return out + + +def _hero_in_public_row(row: dict, hero_id: int) -> bool: + radiant = row.get("radiant_team") or [] + dire = row.get("dire_team") or [] + for side in (radiant, dire): + if not isinstance(side, list): + continue + for hid in side: + try: + if int(hid) == hero_id: + return True + except (TypeError, ValueError): + continue + return False + + +def public_match_ids( + hero_id: int, + limit: int, + *, + delay: float, + public_region: str = "china", +) -> list[dict]: + """Scan publicMatches for Legend+ ranked wins containing hero_id. + + When public_region=china, collect China-cluster hits first; if fewer than + the candidate cap, backfill with other-region pubs from the same scan. + """ + prefer_china = public_region == "china" + pages_cap = PUBLIC_PAGES_CAP_CHINA if prefer_china else PUBLIC_PAGES_CAP + want = max(limit * CANDIDATE_MULTIPLIER, limit) + china_out: list[dict] = [] + other_out: list[dict] = [] + seen: set[int] = set() + less_than: int | None = None + + def _enough() -> bool: + if prefer_china: + return len(china_out) >= want + return len(china_out) + len(other_out) >= want + + for _page in range(pages_cap): + if _enough(): + break + url = opendota_url("/publicMatches") + if less_than is not None: + # opendota_url may already have ?api_key= + sep = "&" if "?" in url else "?" + url = f"{url}{sep}less_than_match_id={less_than}" + try: + raw = http_json(url) + except ( + urllib.error.HTTPError, + urllib.error.URLError, + TimeoutError, + json.JSONDecodeError, + OSError, + ): + break + if not isinstance(raw, list) or not raw: + break + page_min: int | None = None + for row in raw: + if not isinstance(row, dict): + continue + try: + mid = int(row.get("match_id") or 0) + duration = int(row.get("duration") or 0) + except (TypeError, ValueError): + continue + if mid <= 0: + continue + if page_min is None or mid < page_min: + page_min = mid + if duration <= 0: + continue + if mid in seen: + continue + if not _hero_in_public_row(row, hero_id): + continue + if not public_list_row_ok(row, hero_id): + continue + try: + cluster_i = int(row["cluster"]) if row.get("cluster") is not None else None + except (TypeError, ValueError): + cluster_i = None + seen.add(mid) + if is_china_cluster(cluster_i): + china_out.append(row) + else: + other_out.append(row) + if _enough(): + break + if page_min is None: + break + if less_than is not None and page_min >= less_than: + break + less_than = page_min + if delay > 0: + time.sleep(delay) + + if prefer_china: + out = china_out[:want] + if len(out) < want: + need = want - len(out) + out.extend(other_out[:need]) + return out + merged = china_out + other_out + return merged[:want] + + +def fill_matches( + hero_id: int, + list_rows: list[dict], + *, + origin: str, + id_map: dict[int, str], + item_catalog: dict[int, dict], + delay: float, + workers: int = 1, + need: int | None = None, +) -> list[dict]: + """Fetch details; keep only slim_match_ok rows; stop once `need` accepted.""" + candidates: list[tuple[int, dict]] = [] + for row in list_rows: + try: + mid = int(row.get("match_id") or 0) + except (TypeError, ValueError): + continue + if mid <= 0: + continue + candidates.append((mid, row)) + + if not candidates: + return [] + + target = need if need is not None and need > 0 else len(candidates) + workers_n = max(1, int(workers)) + matches: list[dict] = [] + + def _process(mid: int, row: dict) -> dict | None: + detail = fetch_match(mid) + if not detail: + return None + if origin == "public" and not match_detail_ok(detail, row): + return None + slim = extract_player_row( + detail, + hero_id, + origin=origin, + id_map=id_map, + item_catalog=item_catalog, + list_meta=row, + ) + if not slim or not slim_match_ok(slim): + return None + return slim + + if workers_n <= 1: + for mid, row in candidates: + if len(matches) >= target: + break + slim = _process(mid, row) + if slim: + matches.append(slim) + if delay > 0: + time.sleep(delay) + return matches + + chunk = max(workers_n * 2, workers_n) + idx = 0 + while idx < len(candidates) and len(matches) < target: + batch = candidates[idx : idx + chunk] + idx += len(batch) + with ThreadPoolExecutor(max_workers=workers_n) as pool: + futs = [pool.submit(_process, mid, row) for mid, row in batch] + for fut in as_completed(futs): + try: + slim = fut.result() + except Exception: + slim = None + if slim: + matches.append(slim) + if delay > 0: + time.sleep(delay) + return matches + + +def fetch_hero_matches( + hero_id: int, + *, + source: str, + limit: int, + id_map: dict[int, str], + item_catalog: dict[int, dict], + delay: float, + public_region: str = "china", + workers: int = 1, +) -> list[dict]: + by_id: dict[int, dict] = {} + workers_n = max(1, int(workers)) + + if source in ("league", "both"): + league_rows = league_match_ids(hero_id, limit) + if delay > 0: + time.sleep(delay) + for slim in fill_matches( + hero_id, + league_rows, + origin="league", + id_map=id_map, + item_catalog=item_catalog, + delay=delay, + workers=workers_n, + need=limit, + ): + by_id[slim["match_id"]] = slim + + if source in ("public", "both") and len(by_id) < limit: + pub_rows = public_match_ids( + hero_id, limit, delay=delay, public_region=public_region + ) + need_more = limit - len(by_id) + for slim in fill_matches( + hero_id, + pub_rows, + origin="public", + id_map=id_map, + item_catalog=item_catalog, + delay=delay, + workers=workers_n, + need=need_more, + ): + mid = slim["match_id"] + if mid not in by_id: + by_id[mid] = slim + + rows = list(by_id.values()) + rows.sort(key=lambda r: int(r.get("start_time") or 0), reverse=True) + return rows[:limit] + + +def hero_cell_needs_fetch(cell, limit: int) -> bool: + """True if hero is missing, empty, or has fewer than limit kept matches.""" + if cell is None: + return True + if isinstance(cell, list): + matches = cell + elif isinstance(cell, dict): + matches = cell.get("matches") + if not isinstance(matches, list): + return True + else: + return True + return len(matches) < limit + + +def load_existing(path: Path) -> dict: + empty = {"meta": {}, "by_hero": {}} + if not path.is_file(): + return empty + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return empty + if not isinstance(raw, dict): + return empty + by_hero = raw.get("by_hero") + if not isinstance(by_hero, dict): + by_hero = {} + return {"meta": dict(raw.get("meta") or {}), "by_hero": by_hero} + + +def collect_item_ids(by_hero: dict) -> set[int]: + used: set[int] = set() + for cell in by_hero.values(): + matches = [] + if isinstance(cell, dict): + matches = cell.get("matches") or [] + elif isinstance(cell, list): + matches = cell + if not isinstance(matches, list): + continue + for row in matches: + if not isinstance(row, dict): + continue + for key in ("items", "backpack"): + for raw in row.get(key) or []: + try: + iid = int(raw) + except (TypeError, ValueError): + continue + if iid > 0: + used.add(iid) + neut = row.get("item_neutral") + if neut is not None: + try: + nid = int(neut) + except (TypeError, ValueError): + continue + if nid > 0: + used.add(nid) + return used + + +def write_out( + path: Path, + by_hero: dict, + *, + source: str, + limit: int, + item_catalog: dict[int, dict], + public_region: str = "china", +) -> None: + used = collect_item_ids(by_hero) + items_out = { + str(iid): { + "key": item_catalog[iid]["key"], + "dname": item_catalog[iid]["dname"], + "name_loc": item_catalog[iid]["dname"], + } + for iid in sorted(used) + if iid in item_catalog + } + payload = { + "meta": { + "source": "opendota", + "attribution": "https://www.opendota.com", + "fetched_at": datetime.now(timezone.utc).isoformat(), + "match_source": source, + "public_region": public_region, + "limit": limit, + "wins_only": True, + "min_rank_tier": MIN_RANK_TIER, + "min_bracket": "legend", + }, + "items": items_out, + "by_hero": by_hero, + } + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + + +def needs_player_enrich(row: dict) -> bool: + """True if row is missing nickname / rank fields from newer schema.""" + if not isinstance(row, dict): + return False + if "personaname" not in row and "rank_tier" not in row: + return True + return False + + +def needs_item_times_enrich(row: dict) -> bool: + """True if row lacks item_times schema (nulls still count as enriched).""" + if not isinstance(row, dict): + return False + return "item_times" not in row + + +def enrich_player_fields( + path: Path, + *, + hero_keys: list[str], + by_key: dict, + id_map: dict[int, str], + item_catalog: dict[int, dict], + delay: float, + source: str, + limit: int, +) -> None: + """Re-fetch match details to fill personaname / rank_tier on existing rows.""" + existing = load_existing(path) + by_hero: dict = dict(existing.get("by_hero") or {}) + meta = existing.get("meta") or {} + match_source = str(meta.get("match_source") or source) + try: + limit_i = int(meta.get("limit") or limit) + except (TypeError, ValueError): + limit_i = limit + + total = 0 + updated = 0 + for hi, key in enumerate(hero_keys, 1): + cell = by_hero.get(key) + if not isinstance(cell, dict): + continue + matches = cell.get("matches") + if not isinstance(matches, list) or not matches: + continue + hero = by_key.get(key) + if not hero: + continue + hid = int(hero["id"]) + dirty = False + for i, row in enumerate(matches): + if not isinstance(row, dict): + continue + if not needs_player_enrich(row): + continue + mid = row.get("match_id") + try: + mid_i = int(mid) + except (TypeError, ValueError): + continue + total += 1 + detail = fetch_match(mid_i) + if delay > 0: + time.sleep(delay) + if not detail: + # Still mark schema keys so we do not retry forever. + row["personaname"] = row.get("personaname") + row["name"] = row.get("name") + row["rank_tier"] = row.get("rank_tier") + row["leaderboard_rank"] = row.get("leaderboard_rank") + dirty = True + continue + slim = extract_player_row( + detail, + hid, + origin=str(row.get("origin") or "league"), + id_map=id_map, + item_catalog=item_catalog, + list_meta=row, + ) + if not slim: + row["personaname"] = None + row["name"] = None + row["rank_tier"] = None + row["leaderboard_rank"] = None + dirty = True + continue + for field in ( + "personaname", + "name", + "rank_tier", + "leaderboard_rank", + "account_id", + ): + row[field] = slim.get(field) + display = slim.get("display_name") or slim.get("name") or slim.get( + "personaname" + ) + if display: + row["display_name"] = display + else: + row.pop("display_name", None) + matches[i] = row + updated += 1 + dirty = True + if dirty: + by_hero[key] = {"matches": matches} + write_out( + path, + by_hero, + source=match_source, + limit=limit_i, + item_catalog=item_catalog, + public_region=str(meta.get("public_region") or "china"), + ) + print(f"[{hi}/{len(hero_keys)}] {key} enriched (saved)", flush=True) + else: + print(f"[{hi}/{len(hero_keys)}] {key} ok", flush=True) + print(f"enrich done checked={total} updated={updated} → {path}") + + +def enrich_item_times( + path: Path, + *, + hero_keys: list[str], + by_key: dict, + id_map: dict[int, str], + item_catalog: dict[int, dict], + delay: float, + source: str, + limit: int, +) -> None: + """Re-fetch match details to fill item_times / backpack_times / item_neutral_time.""" + existing = load_existing(path) + by_hero: dict = dict(existing.get("by_hero") or {}) + meta = existing.get("meta") or {} + match_source = str(meta.get("match_source") or source) + try: + limit_i = int(meta.get("limit") or limit) + except (TypeError, ValueError): + limit_i = limit + public_region = str(meta.get("public_region") or "china") + + total = 0 + updated = 0 + with_times = 0 + for hi, key in enumerate(hero_keys, 1): + cell = by_hero.get(key) + if not isinstance(cell, dict): + continue + matches = cell.get("matches") + if not isinstance(matches, list) or not matches: + continue + hero = by_key.get(key) + if not hero: + continue + hid = int(hero["id"]) + dirty = False + for i, row in enumerate(matches): + if not isinstance(row, dict): + continue + if not needs_item_times_enrich(row): + continue + mid = row.get("match_id") + try: + mid_i = int(mid) + except (TypeError, ValueError): + continue + total += 1 + detail = fetch_match(mid_i) + if delay > 0: + time.sleep(delay) + + def _null_times(target: dict) -> None: + n_items = ( + len(target.get("items") or []) + if isinstance(target.get("items"), list) + else 0 + ) + n_bp = ( + len(target.get("backpack") or []) + if isinstance(target.get("backpack"), list) + else 0 + ) + target["item_times"] = [None] * n_items + target["backpack_times"] = [None] * n_bp + target["item_neutral_time"] = None + + if not detail: + _null_times(row) + dirty = True + continue + slim = extract_player_row( + detail, + hid, + origin=str(row.get("origin") or "league"), + id_map=id_map, + item_catalog=item_catalog, + list_meta=row, + ) + if not slim: + _null_times(row) + dirty = True + continue + for field in ( + "items", + "backpack", + "item_neutral", + "item_times", + "backpack_times", + "item_neutral_time", + ): + row[field] = slim.get(field) + times = slim.get("item_times") or [] + bp_times = slim.get("backpack_times") or [] + if ( + any(t is not None for t in times) + or any(t is not None for t in bp_times) + or slim.get("item_neutral_time") is not None + ): + with_times += 1 + matches[i] = row + updated += 1 + dirty = True + if dirty: + by_hero[key] = {"matches": matches} + write_out( + path, + by_hero, + source=match_source, + limit=limit_i, + item_catalog=item_catalog, + public_region=public_region, + ) + print( + f"[{hi}/{len(hero_keys)}] {key} item-times enriched (saved)", + flush=True, + ) + else: + print(f"[{hi}/{len(hero_keys)}] {key} ok", flush=True) + print( + f"enrich-item-times done checked={total} updated={updated} " + f"with_times={with_times} → {path}" + ) + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "--source", + choices=("league", "public", "both"), + default="league", + help="Match list source (default: league)", + ) + ap.add_argument( + "--limit", + type=int, + default=DEFAULT_LIMIT, + help=f"Recent matches kept per hero after merge, newest first (default: {DEFAULT_LIMIT})", + ) + ap.add_argument( + "--heroes", + default="", + help="Comma-separated hero keys to fetch (default: all)", + ) + ap.add_argument( + "--delay", + type=float, + default=None, + help="Sleep between list pages / detail batches (default: 0.05 with " + "--workers>1, else 0.35)", + ) + ap.add_argument( + "--workers", + type=int, + default=DEFAULT_WORKERS, + help=f"Concurrent match-detail fetches per hero (default: {DEFAULT_WORKERS})", + ) + ap.add_argument("--out", type=Path, default=OUT) + ap.add_argument( + "--force", + action="store_true", + help="Refetch heroes even if already present with enough matches", + ) + ap.add_argument( + "--enrich-players", + action="store_true", + help="Re-fetch match details to fill personaname/rank_tier on existing rows", + ) + ap.add_argument( + "--enrich-item-times", + action="store_true", + help="Re-fetch match details to fill item_times/backpack_times on existing rows", + ) + ap.add_argument( + "--public-region", + choices=("china", "any"), + default="china", + help=( + "Public match region preference (default: china). " + "china = Perfect World clusters first, backfill others if short; " + "any = no cluster filter" + ), + ) + ap.add_argument("--skip-icons", action="store_true") + ap.add_argument("--force-icons", action="store_true") + args = ap.parse_args() + limit = max(1, int(args.limit)) + public_region = str(args.public_region) + workers = max(1, int(args.workers)) + if args.delay is None: + delay = 0.05 if workers > 1 else 0.35 + else: + delay = max(0.0, float(args.delay)) + + heroes = hero_table() + by_key = {h["key"]: h for h in heroes} + want_keys: list[str] + if args.heroes.strip(): + want_keys = [] + for part in args.heroes.split(","): + key = part.strip() + if not key: + continue + if key not in by_key: + raise SystemExit(f"unknown hero key: {key}") + want_keys.append(key) + else: + want_keys = sorted(by_key.keys()) + + if args.enrich_players or args.enrich_item_times: + print("loading ability_ids + items catalog…") + id_map = load_ability_id_map() + item_catalog = load_item_id_catalog() + print(f"ability_ids={len(id_map)} items={len(item_catalog)}") + if args.enrich_item_times: + enrich_item_times( + args.out, + hero_keys=want_keys, + by_key=by_key, + id_map=id_map, + item_catalog=item_catalog, + delay=delay, + source=args.source, + limit=limit, + ) + if args.enrich_players: + enrich_player_fields( + args.out, + hero_keys=want_keys, + by_key=by_key, + id_map=id_map, + item_catalog=item_catalog, + delay=delay, + source=args.source, + limit=limit, + ) + return + + existing = load_existing(args.out) + by_hero: dict = dict(existing.get("by_hero") or {}) + if args.force: + for key in want_keys: + by_hero.pop(key, None) + + pending = [ + k + for k in want_keys + if args.force or hero_cell_needs_fetch(by_hero.get(k), limit) + ] + # Drop stale short cells so refetch replaces them. + for key in pending: + by_hero.pop(key, None) + + api_key_note = "yes" if opendota_api_key() else "no" + print( + f"heroes={len(want_keys)} pending={len(pending)} " + f"source={args.source} public_region={public_region} " + f"limit={limit} workers={workers} delay={delay} " + f"api_key={api_key_note} out={args.out}" + ) + if not pending: + print( + "nothing to do (use --force to refetch, " + "--enrich-players, or --enrich-item-times)" + ) + return + + print("loading ability_ids + items catalog…") + id_map = load_ability_id_map() + item_catalog = load_item_id_catalog() + print(f"ability_ids={len(id_map)} items={len(item_catalog)}") + + for i, key in enumerate(pending, 1): + hero = by_key[key] + hid = int(hero["id"]) + print(f"[{i}/{len(pending)}] {key} id={hid} …", flush=True) + try: + matches = fetch_hero_matches( + hid, + source=args.source, + limit=limit, + id_map=id_map, + item_catalog=item_catalog, + delay=delay, + public_region=public_region, + workers=workers, + ) + except ( + urllib.error.HTTPError, + urllib.error.URLError, + TimeoutError, + OSError, + json.JSONDecodeError, + ) as exc: + print(f" FAIL {exc}") + continue + by_hero[key] = {"matches": matches} + write_out( + args.out, + by_hero, + source=args.source, + limit=limit, + item_catalog=item_catalog, + public_region=public_region, + ) + n_pub = sum(1 for m in matches if m.get("origin") == "public") + n_china = sum( + 1 + for m in matches + if m.get("origin") == "public" + and is_china_cluster(m.get("cluster"), m.get("region")) + ) + print( + f" matches={len(matches)} public={n_pub} china={n_china} (saved)", + flush=True, + ) + + if not args.skip_icons: + keys: list[str] = [] + try: + written = json.loads(args.out.read_text(encoding="utf-8")) + keys = sorted( + { + str(r["key"]) + for r in (written.get("items") or {}).values() + if isinstance(r, dict) and r.get("key") + } + ) + except (OSError, json.JSONDecodeError): + keys = [] + if keys: + print(f"downloading item icons ({len(keys)})…") + saved, skipped, fail = download_icons( + keys, + ICON_URL, + ITEM_ICONS, + force=args.force_icons, + delay=min(0.15, max(0.0, delay)), + ) + print(f"icons saved={saved} skipped={skipped} fail={fail}") + + print(f"done → {args.out} heroes={len(by_hero)}") + + +if __name__ == "__main__": + main() diff --git a/fetch_hero_stats.py b/fetch_hero_stats.py new file mode 100644 index 0000000..6db80f2 --- /dev/null +++ b/fetch_hero_stats.py @@ -0,0 +1,205 @@ +"""Fetch OpenDota hero pick/win stats (all brackets) into data/hero_stats.json. + +One GET /api/heroStats call covers every hero. OpenDota aggregates *recent* +matches: pub_pick equals the sum of pub_pick_trend (typically 7 daily buckets), +and bracket fields share that same recent window — not all-time / full patch. + +Output is for the Climperor web site only — never merge into relations.json / +heroes.json or recommend. + +Usage: + python fetch_hero_stats.py + python fetch_hero_stats.py --out data/hero_stats.json +""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from pathlib import Path + +from common import DATA +from grid import hero_table +from http_utils import http_json + +OPENDOTA_HERO_STATS = "https://api.opendota.com/api/heroStats" +OUT = DATA / "hero_stats.json" + +# OpenDota skill brackets: 1=Herald … 8=Immortal +BRACKET_ORDER = ( + "herald", + "guardian", + "crusader", + "archon", + "legend", + "ancient", + "divine", + "immortal", +) +BRACKET_NUM = {name: i for i, name in enumerate(BRACKET_ORDER, start=1)} +# Fallback when a heroStats row has no pub_*_trend arrays. +DEFAULT_WINDOW_DAYS = 7 + + +def _pw(pick: object, win: object) -> dict[str, int]: + try: + p = int(pick or 0) + except (TypeError, ValueError): + p = 0 + try: + w = int(win or 0) + except (TypeError, ValueError): + w = 0 + return {"pick": max(0, p), "win": max(0, w)} + + +def row_to_hero_stats(row: dict) -> dict: + brackets: dict[str, dict[str, int]] = {} + for name, num in BRACKET_NUM.items(): + brackets[name] = _pw(row.get(f"{num}_pick"), row.get(f"{num}_win")) + pro = _pw(row.get("pro_pick"), row.get("pro_win")) + try: + ban = int(row.get("pro_ban") or 0) + except (TypeError, ValueError): + ban = 0 + pro["ban"] = max(0, ban) + return { + "pub": _pw(row.get("pub_pick"), row.get("pub_win")), + "brackets": brackets, + "pro": pro, + "turbo": _pw(row.get("turbo_picks"), row.get("turbo_wins")), + } + + +def detect_window_days(rows: list) -> int: + """Infer recent-window length from OpenDota pub_*_trend bucket counts.""" + for row in rows: + if not isinstance(row, dict): + continue + for key in ("pub_pick_trend", "pub_win_trend"): + trend = row.get(key) + if isinstance(trend, list) and trend: + return len(trend) + return DEFAULT_WINDOW_DAYS + + +def sum_picks(by_hero: dict[str, dict], path: tuple[str, ...]) -> int: + total = 0 + for cell in by_hero.values(): + cur: object = cell + for key in path: + if not isinstance(cur, dict): + cur = None + break + cur = cur.get(key) + if isinstance(cur, dict): + try: + total += int(cur.get("pick") or 0) + except (TypeError, ValueError): + pass + return total + + +def sum_bans(by_hero: dict[str, dict], path: tuple[str, ...]) -> int: + total = 0 + for cell in by_hero.values(): + cur: object = cell + for key in path: + if not isinstance(cur, dict): + cur = None + break + cur = cur.get(key) + if isinstance(cur, dict): + try: + total += int(cur.get("ban") or 0) + except (TypeError, ValueError): + pass + return total + + +def build_totals(by_hero: dict[str, dict]) -> dict: + """Aggregate pick (and pro ban) counts so the UI can derive pick/ban rates. + + Pick rate ≈ hero_pick / (sum_picks / 10) because each match contributes 10 picks. + OpenDota exposes public ranked bans only for the pro scene (pro_ban). + """ + brackets = { + name: {"pick": sum_picks(by_hero, ("brackets", name))} for name in BRACKET_ORDER + } + return { + "pub": {"pick": sum_picks(by_hero, ("pub",))}, + "brackets": brackets, + "pro": { + "pick": sum_picks(by_hero, ("pro",)), + "ban": sum_bans(by_hero, ("pro",)), + }, + "turbo": {"pick": sum_picks(by_hero, ("turbo",))}, + } + + +def build_payload(by_hero: dict[str, dict], *, window_days: int) -> dict: + days = max(1, int(window_days)) + return { + "fetched_at": datetime.now(timezone.utc).isoformat(), + "source": "opendota", + "attribution": "https://www.opendota.com", + "window_days": days, + "window_note": ( + f"OpenDota recent matches (~{days} days); " + "pub_pick ≈ sum(pub_pick_trend); brackets share the same window; " + "pick_rate = pick / (sum_picks/10); ranked ban rates not in heroStats" + ), + "window_label_zh": f"近约 {days} 天公开对局", + "brackets": list(BRACKET_ORDER), + "totals": build_totals(by_hero), + "by_hero": by_hero, + } + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--out", type=Path, default=OUT) + args = ap.parse_args() + + heroes = hero_table() + id_to_key = {int(h["id"]): h["key"] for h in heroes} + + print(f"fetching {OPENDOTA_HERO_STATS} ...", flush=True) + raw = http_json(OPENDOTA_HERO_STATS) + if not isinstance(raw, list): + raise SystemExit(f"unexpected heroStats payload type: {type(raw).__name__}") + + window_days = detect_window_days(raw) + by_hero: dict[str, dict] = {} + unknown = 0 + for row in raw: + if not isinstance(row, dict): + continue + try: + hid = int(row.get("id")) + except (TypeError, ValueError): + continue + key = id_to_key.get(hid) + if key is None: + unknown += 1 + continue + by_hero[key] = row_to_hero_stats(row) + + payload = build_payload(by_hero, window_days=window_days) + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + missing = [h["key"] for h in heroes if h["key"] not in by_hero] + print( + f"done: {len(by_hero)} heroes -> {args.out}" + + (f", {unknown} unknown ids" if unknown else "") + + (f", {len(missing)} roster keys missing" if missing else ""), + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/fetch_item_counter_stats.py b/fetch_item_counter_stats.py new file mode 100644 index 0000000..8abd82c --- /dev/null +++ b/fetch_item_counter_stats.py @@ -0,0 +1,299 @@ +"""Fetch adjusted enemy-item evidence for each hero from OpenDota Explorer. + +For every hero, this compares enemy-team final-inventory item presence against +the same item's baseline across all teams in the same recent match window: + + buy_lift = P(item | against hero) - P(item | any team) + win_delta = P(win | item, against hero) - P(win | item, any team) + +These are observational signals, not causal counter claims. The output is an +optional cache consumed by item_fears.py to corroborate and gently rerank its +mechanism-based candidates. + +Usage: + python fetch_item_counter_stats.py + python fetch_item_counter_stats.py --matches 20000 --min-games 100 + python fetch_item_counter_stats.py --print-sql + python fetch_item_counter_stats.py --soft-fail +""" + +from __future__ import annotations + +import argparse +import json +import os +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +from common import DATA +from grid import hero_table + +API = "https://api.opendota.com/api/explorer" +ITEMS_META = DATA / "items_meta.json" +OUT = DATA / "item_counter_stats.json" + + +def build_sql(matches: int, min_games: int) -> str: + """Build one bounded aggregate query; integer args are caller-validated.""" + return f""" +WITH recent_ids AS ( + SELECT DISTINCT match_id + FROM player_matches + ORDER BY match_id DESC + LIMIT {matches} +), +recent AS ( + SELECT m.match_id, m.radiant_win + FROM matches m + JOIN recent_ids r ON r.match_id = m.match_id +), +players AS ( + SELECT + p.match_id, + p.hero_id, + (p.player_slot < 128) AS is_radiant, + p.item_0, p.item_1, p.item_2, + p.item_3, p.item_4, p.item_5 + FROM player_matches p + JOIN recent r ON r.match_id = p.match_id + WHERE p.hero_id IS NOT NULL +), +teams AS ( + SELECT DISTINCT match_id, is_radiant + FROM players +), +team_outcomes AS ( + SELECT + t.match_id, + t.is_radiant, + CASE WHEN t.is_radiant THEN r.radiant_win ELSE NOT r.radiant_win END AS won + FROM teams t + JOIN recent r ON r.match_id = t.match_id +), +team_items AS ( + SELECT DISTINCT + p.match_id, + p.is_radiant, + x.item_id + FROM players p + CROSS JOIN LATERAL ( + VALUES (p.item_0), (p.item_1), (p.item_2), + (p.item_3), (p.item_4), (p.item_5) + ) AS x(item_id) + WHERE x.item_id IS NOT NULL AND x.item_id > 0 +), +hero_totals AS ( + SELECT hero_id, COUNT(*) AS target_games + FROM players + GROUP BY hero_id +), +global_total AS ( + SELECT COUNT(*) AS team_games + FROM team_outcomes +), +global_items AS ( + SELECT + ti.item_id, + COUNT(*) AS item_games, + SUM(CASE WHEN o.won THEN 1 ELSE 0 END) AS item_wins + FROM team_items ti + JOIN team_outcomes o + ON o.match_id = ti.match_id + AND o.is_radiant = ti.is_radiant + GROUP BY ti.item_id +), +target_items AS ( + SELECT + p.hero_id, + ti.item_id, + COUNT(*) AS item_games, + SUM(CASE WHEN o.won THEN 1 ELSE 0 END) AS item_wins + FROM players p + JOIN team_items ti + ON ti.match_id = p.match_id + AND ti.is_radiant <> p.is_radiant + JOIN team_outcomes o + ON o.match_id = ti.match_id + AND o.is_radiant = ti.is_radiant + GROUP BY p.hero_id, ti.item_id + HAVING COUNT(*) >= {min_games} +) +SELECT + t.hero_id, + t.item_id, + h.target_games, + t.item_games, + t.item_wins, + g.team_games AS global_team_games, + gi.item_games AS global_item_games, + gi.item_wins AS global_item_wins +FROM target_items t +JOIN hero_totals h ON h.hero_id = t.hero_id +JOIN global_items gi ON gi.item_id = t.item_id +CROSS JOIN global_total g +ORDER BY t.hero_id, t.item_games DESC +""".strip() + + +def explorer(sql: str, *, timeout: int = 240) -> dict: + params = {"sql": sql} + api_key = os.environ.get("OPENDOTA_API_KEY", "").strip() + if api_key: + params["api_key"] = api_key + url = API + "?" + urllib.parse.urlencode(params) + req = urllib.request.Request(url, headers={"User-Agent": "climperor"}) + with urllib.request.urlopen(req, timeout=timeout) as response: + payload = json.loads(response.read().decode()) + if not isinstance(payload, dict): + raise RuntimeError("OpenDota Explorer returned a non-object payload") + if payload.get("error") or payload.get("err"): + raise RuntimeError(str(payload.get("error") or payload.get("err"))) + return payload + + +def item_catalog() -> dict[int, dict]: + raw = json.loads(ITEMS_META.read_text(encoding="utf-8")) + out: dict[int, dict] = {} + for raw_id, row in (raw.get("items") or {}).items(): + if not isinstance(row, dict) or not row.get("key"): + continue + item_id = int(row.get("id") or raw_id) + out[item_id] = { + "key": str(row["key"]), + "name_loc": str(row.get("name_loc") or row.get("dname") or row["key"]), + "cost": int(row.get("cost") or 0), + } + return out + + +def build_payload( + rows: list[dict], + *, + matches: int, + min_games: int, + min_cost: int, +) -> dict: + heroes = {int(row["id"]): row["key"] for row in hero_table()} + items = item_catalog() + by_hero: dict[str, list[dict]] = {key: [] for key in heroes.values()} + + for row in rows: + hero_key = heroes.get(int(row["hero_id"])) + item = items.get(int(row["item_id"])) + if not hero_key or not item or item["cost"] < min_cost: + continue + + target_games = int(row["target_games"]) + item_games = int(row["item_games"]) + item_wins = int(row["item_wins"]) + global_team_games = int(row["global_team_games"]) + global_item_games = int(row["global_item_games"]) + global_item_wins = int(row["global_item_wins"]) + if not all((target_games, item_games, global_team_games, global_item_games)): + continue + + buy_rate = item_games / target_games + global_buy_rate = global_item_games / global_team_games + win_rate = item_wins / item_games + global_win_rate = global_item_wins / global_item_games + by_hero[hero_key].append( + { + "item": item["key"], + "name_loc": item["name_loc"], + "games": item_games, + "wins": item_wins, + "target_games": target_games, + "global_team_games": global_team_games, + "global_item_games": global_item_games, + "global_item_wins": global_item_wins, + "buy_rate": round(buy_rate, 6), + "global_buy_rate": round(global_buy_rate, 6), + "buy_lift": round(buy_rate - global_buy_rate, 6), + "win_rate": round(win_rate, 6), + "global_win_rate": round(global_win_rate, 6), + "win_delta": round(win_rate - global_win_rate, 6), + } + ) + + for entries in by_hero.values(): + entries.sort( + key=lambda row: ( + -float(row["buy_lift"]), + -float(row["win_delta"]), + -int(row["games"]), + str(row["item"]), + ) + ) + + return { + "meta": { + "source": "opendota_explorer", + "attribution": "https://www.opendota.com", + "fetched_at": datetime.now(timezone.utc).isoformat(), + "window_matches": matches, + "min_games": min_games, + "min_cost": min_cost, + "method": "enemy final inventory vs same-item global team baseline", + "caveat": "observational; adjusted for item baseline, not duration, rank, role, or economy", + }, + "by_hero": by_hero, + } + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--matches", type=int, default=20_000) + ap.add_argument("--min-games", type=int, default=100) + ap.add_argument("--min-cost", type=int, default=1_400) + ap.add_argument("--out", type=Path, default=OUT) + ap.add_argument("--print-sql", action="store_true") + ap.add_argument( + "--soft-fail", + action="store_true", + help="keep an existing cache and exit 0 when OpenDota is unavailable", + ) + args = ap.parse_args() + + if args.matches < 1 or args.min_games < 1 or args.min_cost < 0: + raise SystemExit("matches/min-games must be positive and min-cost non-negative") + sql = build_sql(args.matches, args.min_games) + if args.print_sql: + print(sql) + return + if not ITEMS_META.is_file(): + raise SystemExit(f"missing {ITEMS_META}; run: python fetch_items_meta.py") + + try: + payload = explorer(sql) + rows = payload.get("rows") or [] + if not isinstance(rows, list) or not rows: + raise RuntimeError("OpenDota Explorer returned no rows") + output = build_payload( + rows, + matches=args.matches, + min_games=args.min_games, + min_cost=args.min_cost, + ) + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text( + json.dumps(output, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + nonempty = sum(bool(v) for v in output["by_hero"].values()) + print( + f"done: {len(rows)} rows, {nonempty} heroes -> {args.out}", + flush=True, + ) + except (OSError, TimeoutError, urllib.error.URLError, RuntimeError) as exc: + if args.soft_fail: + state = "keeping existing cache" if args.out.is_file() else "no cache available" + print(f"warn: item counter stats unavailable ({exc}); {state}", flush=True) + return + raise + + +if __name__ == "__main__": + main() diff --git a/fetch_items_meta.py b/fetch_items_meta.py index 4116bb6..c54b8ac 100644 --- a/fetch_items_meta.py +++ b/fetch_items_meta.py @@ -25,6 +25,7 @@ from pathlib import Path from common import DATA, ITEM_ICONS from http_utils import download_icons, http_json, load_itemlist from loc_format import HAS_PLACEHOLDER, format_loc +from mechanic_tags import apply_mechanic_tags, merge_tag_overrides ITEMS_URL = ( "https://raw.githubusercontent.com/odota/dotaconstants/master/build/items.json" @@ -58,6 +59,17 @@ TAG_ORDER = [ "silence", "hex", "root", + "stun", + "disarm", + "mute", + "invis", + "sleep", + "fear", + "taunt", + "blind", + "leash", + "ethereal", + "cyclone", ] # key -> extra tags from curated whitelist (also covered by overrides) @@ -73,12 +85,6 @@ H1_RE = re.compile( r"

\s*(主动|被动|使用|开关|升级|Active|Passive|Use|Toggle|Upgrade)\s*[::]?\s*([^<]*)

", re.I, ) -DISPEL_TYPE_RE = re.compile( - r"(?:驱散类型|Dispel\s*Type)\s*[::]\s*([^<\n]+)", - re.I, -) - - def is_core_finished(meta: dict) -> bool: key = meta.get("key") or "" if meta.get("tier") is not None: @@ -174,42 +180,11 @@ def parse_ability_kinds(desc_loc: str, od_abilities: list) -> list[str]: return sorted(kinds) -def extract_dispel_from_text(text: str) -> set[str]: - tags: set[str] = set() - if not text: - return tags - for m in DISPEL_TYPE_RE.finditer(text): - chunk = m.group(1).lower() - if "强" in chunk or "strong" in chunk: - tags.add("strong_dispel") - if "基础" in chunk or "basic" in chunk: - tags.add("basic_dispel") - if "强" not in chunk and "strong" not in chunk and ("驱散" in chunk or "dispel" in chunk): - tags.add("basic_dispel") - low = text.lower() - if "strong dispel" in low or "强驱散" in text: - tags.add("strong_dispel") - if "basic dispel" in low or "基础驱散" in text: - tags.add("basic_dispel") - # Explicit purge / 阻止 (diffusal) — avoid bare 「幻象不会」lines - if re.search( - r"(?:施加|应用|造成)?.{0,8}(?:基础)?驱散|applies?\s+(?:a\s+)?(?:basic\s+)?dispel|" - r"\bpurge\b|阻止敌|Inhibit", - text, - re.I, - ): - if "strong" in low or "强驱散" in text: - tags.add("strong_dispel") - else: - tags.add("basic_dispel") - return tags - - def auto_tags(key: str, desc_zh: str, desc_en: str, en_od: str, od_dispellable: object) -> list[str]: blob = " ".join([desc_zh or "", desc_en or "", en_od or "", key]) - low = blob.lower() tags: set[str] = set() - tags |= extract_dispel_from_text(blob) + # Query-facing applies-* tags (dispel / CC); shared with ability tagging. + tags |= apply_mechanic_tags(blob, key=key) if od_dispellable and str(od_dispellable).lower() in ("yes", "both"): # Item itself being dispellable is not "applies dispel" @@ -221,8 +196,6 @@ def auto_tags(key: str, desc_zh: str, desc_en: str, en_od: str, od_dispellable: re.I, ): tags.add("mana_burn") - if re.search(r"(?:施加|造成|应用).{0,6}破坏|\bbreaks?\b(?:\s+passives?)?|Break\b", blob): - tags.add("break") if key == "monkey_king_bar" or re.search( r"true strike|无视闪避|必定命中|attacks? cannot miss", blob, @@ -265,12 +238,6 @@ def auto_tags(key: str, desc_zh: str, desc_en: str, en_od: str, od_dispellable: tags.add("heal_reduce") if re.search(r"armor reduction|reduce(?:s)? armor|减甲|降低护甲", blob, re.I): tags.add("armor_reduce") - if re.search(r"\bsilence[sd]?\b|沉默", blob, re.I): - tags.add("silence") - if re.search(r"\bhex(?:es|ed)?\b|变羊|妖术", blob, re.I): - tags.add("hex") - if re.search(r"\broot(?:s|ed)?\b|缠绕|定身", blob, re.I): - tags.add("root") return [t for t in TAG_ORDER if t in tags] @@ -287,18 +254,7 @@ def load_overrides() -> dict[str, dict]: def apply_overrides(key: str, tags: list[str], overrides: dict[str, dict]) -> list[str]: - row = overrides.get(key) or {} - s = set(tags) - for t in row.get("add") or []: - if isinstance(t, str): - s.add(t) - for t in row.get("remove") or []: - if isinstance(t, str): - s.discard(t) - # Keep known order, then any extras - ordered = [t for t in TAG_ORDER if t in s] - extras = sorted(s - set(TAG_ORDER)) - return ordered + extras + return merge_tag_overrides(tags, overrides.get(key), TAG_ORDER) def main() -> None: diff --git a/fetch_leaderboards.py b/fetch_leaderboards.py new file mode 100644 index 0000000..0d245c9 --- /dev/null +++ b/fetch_leaderboards.py @@ -0,0 +1,113 @@ +"""Fetch Valve Immortal regional leaderboards into data/leaderboards.json. + +Pulls the official division boards (americas / europe / se_asia / china) and +keeps Top 100 per region for the Climperor web「排行」page. + +Valve does not expose MMR or account_id on this endpoint — only rank, name, +and optional team/country. Divisions use separate MMR scales (no global board). + +Preview only — do not merge into relations/heroes or recommend. + +Usage: + python fetch_leaderboards.py + python fetch_leaderboards.py --out data/leaderboards.json +""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from pathlib import Path + +from common import DATA +from http_utils import http_json + +OUT = DATA / "leaderboards.json" +API = ( + "https://www.dota2.com/webapi/ILeaderboard/GetDivisionLeaderboard/v0001" + "?division={division}&leaderboard=0" +) + +# Display order for the web rankings tab (China first for CN audience). +REGION_ORDER = ("china", "europe", "americas", "se_asia") +REGION_LABELS = { + "china": "中国", + "europe": "欧洲", + "americas": "美洲", + "se_asia": "东南亚", +} +TOP_N = 100 + + +def slim_entry(row: dict) -> dict: + out: dict = { + "rank": int(row.get("rank") or 0), + "name": str(row.get("name") or ""), + } + team = row.get("team_tag") + if isinstance(team, str) and team.strip(): + out["team_tag"] = team.strip() + country = row.get("country") + if isinstance(country, str) and country.strip(): + out["country"] = country.strip().lower() + return out + + +def fetch_division(division: str) -> dict: + url = API.format(division=division) + print(f"fetching {division} ...", flush=True) + raw = http_json(url) + if not isinstance(raw, dict): + raise SystemExit(f"{division}: unexpected payload type {type(raw).__name__}") + lb = raw.get("leaderboard") + if not isinstance(lb, list): + raise SystemExit(f"{division}: missing leaderboard array") + top = [slim_entry(e) for e in lb[:TOP_N] if isinstance(e, dict)] + return { + "division": division, + "label_zh": REGION_LABELS.get(division, division), + "time_posted": raw.get("time_posted"), + "count": len(lb), + "top100": top, + } + + +def build_payload(regions: dict[str, dict]) -> dict: + return { + "fetched_at": datetime.now(timezone.utc).isoformat(), + "source": "valve", + "attribution": "https://www.dota2.com/leaderboards", + "note": ( + "Official Immortal division boards; no MMR/account_id in payload; " + "region MMR scales are not comparable across divisions" + ), + "default_region": "china", + "region_order": list(REGION_ORDER), + "regions": regions, + } + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--out", type=Path, default=OUT) + args = ap.parse_args() + + regions: dict[str, dict] = {} + for div in REGION_ORDER: + regions[div] = fetch_division(div) + + payload = build_payload(regions) + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + parts = [ + f"{div}={len((regions[div].get('top100') or []))}" for div in REGION_ORDER + ] + print(f"done: {', '.join(parts)} -> {args.out}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/fetch_patches.py b/fetch_patches.py index faf5be3..98c6d9d 100644 --- a/fetch_patches.py +++ b/fetch_patches.py @@ -11,7 +11,7 @@ Sources: Only patches within the past year (default 365 days from today) are kept, descending. Per-patch detail keeps the raw ability_id / hero_id values; a shared `lookup` resolves only the referenced ids to {key, name_loc} so the -read-only preview can render names + icons with no runtime network calls. +read-only Climperor web site can render names + icons with no runtime network calls. Referenced item / ability icons are downloaded into assets/item_icons and assets/ability_icons so the static export stays self-contained. @@ -22,6 +22,7 @@ Usage: python fetch_patches.py --since 2025-07-27 python fetch_patches.py --no-icons python fetch_patches.py --force # refetch every patch detail + python fetch_patches.py --check # list vs cache; JSON on stdout """ from __future__ import annotations @@ -58,7 +59,7 @@ DEFAULT_WINDOW_DAYS = 365 # Bundled shared badges — never fetched from CDN (many innate keys 404). SKIP_ABILITY_ICON_KEYS = frozenset({"innate", "talent_tree"}) # Non-hero units that Valve places in patchnotes heroes[] under a pseudo hero_id -# absent from herolist. Resolve to a Chinese name + portrait key so the preview +# absent from herolist. Resolve to a Chinese name + portrait key so the web site # never shows #1961. Portrait files live in assets/hero_portraits/.png. UNIT_NAMES = {1961: {"key": "spirit_bear", "name_loc": "熊灵"}} @@ -372,6 +373,30 @@ def load_existing_details() -> dict[str, dict]: return out +def check_for_new_patches(*, days: int, since: str | None) -> dict: + """Compare Valve patch list to local details cache; do not write files. + + Returns a JSON-serializable dict with has_new / new_versions / latest / … + """ + if since: + since_dt = datetime.strptime(since, "%Y-%m-%d").replace(tzinfo=timezone.utc) + else: + since_dt = datetime.now(tz=timezone.utc) - timedelta(days=days) + since_ts = int(since_dt.timestamp()) + patches = fetch_patch_list(since_ts) + cached = load_existing_details() + new_versions = [p["version"] for p in patches if p["version"] not in cached] + latest = patches[0]["version"] if patches else None + return { + "has_new": bool(new_versions), + "new_versions": new_versions, + "latest": latest, + "window_count": len(patches), + "cached_details": len(cached), + "since": since_dt.date().isoformat(), + } + + def main() -> None: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--days", type=int, default=DEFAULT_WINDOW_DAYS, help="window in days from today") @@ -379,8 +404,18 @@ def main() -> None: ap.add_argument("--delay", type=float, default=0.3, help="seconds between detail/icon requests") ap.add_argument("--no-icons", action="store_true", help="skip icon downloads") ap.add_argument("--force", action="store_true", help="refetch every patch detail") + ap.add_argument( + "--check", + action="store_true", + help="only compare patch list to local details; print JSON to stdout", + ) args = ap.parse_args() + if args.check: + result = check_for_new_patches(days=args.days, since=args.since) + print(json.dumps(result, ensure_ascii=False), flush=True) + return + if args.since: since_dt = datetime.strptime(args.since, "%Y-%m-%d").replace(tzinfo=timezone.utc) else: diff --git a/fetch_pro_builds.py b/fetch_pro_builds.py new file mode 100644 index 0000000..40c622b --- /dev/null +++ b/fetch_pro_builds.py @@ -0,0 +1,544 @@ +"""Aggregate pro-player item + skill builds per hero (Climperor web only). + +Uses OpenDota registered pros (/proPlayers) and league match rows from +data/hero_matches.json (run fetch_hero_matches.py --source league first). +Optionally backfill more league samples with --fetch. + +Output: data/pro_builds.json — not used by recommend. + +Usage: + python fetch_hero_matches.py --source league --limit 12 + python fetch_pro_builds.py + python fetch_pro_builds.py --fetch --heroes juggernaut --limit 20 +""" + +from __future__ import annotations + +import argparse +import json +import time +import urllib.error +from collections import Counter, defaultdict +from datetime import datetime, timezone +from pathlib import Path + +from common import DATA +from fetch_hero_items import ( + BLESSING_TO_SCEPTER, + TOP_N, + is_core_finished, + load_item_catalog, +) +from fetch_hero_matches import ( + extract_player_row, + fetch_match, + league_match_ids, + load_ability_id_map, +) +from grid import hero_table +from http_utils import http_json + +OPENDOTA = "https://api.opendota.com/api" +OUT = DATA / "pro_builds.json" +MATCHES_IN = DATA / "hero_matches.json" +PRO_MATCHES_IN = DATA / "pro_matches.json" +TOP_SKILL_ORDERS = 3 + + +def fetch_pro_index() -> dict[int, dict]: + """account_id → slim pro profile from OpenDota.""" + try: + raw = http_json(f"{OPENDOTA}/proPlayers") + except ( + urllib.error.HTTPError, + urllib.error.URLError, + TimeoutError, + json.JSONDecodeError, + OSError, + ) as e: + raise SystemExit(f"proPlayers fetch failed: {e}") from e + if not isinstance(raw, list): + raise SystemExit("proPlayers: unexpected payload") + out: dict[int, dict] = {} + for row in raw: + if not isinstance(row, dict): + continue + try: + aid = int(row.get("account_id") or 0) + except (TypeError, ValueError): + continue + if aid <= 0: + continue + name = row.get("name") + if isinstance(name, str): + name = name.strip() or None + else: + name = None + team_tag = row.get("team_tag") + if isinstance(team_tag, str): + team_tag = team_tag.strip() or None + else: + team_tag = None + team_name = row.get("team_name") + if isinstance(team_name, str): + team_name = team_name.strip() or None + else: + team_name = None + country = row.get("country_code") or row.get("loccountrycode") + if isinstance(country, str): + country = country.strip().lower() or None + else: + country = None + last_match = row.get("last_match_time") + if isinstance(last_match, str): + last_match = last_match.strip() or None + else: + last_match = None + out[aid] = { + "account_id": aid, + "name": name, + "team_tag": team_tag, + "team_name": team_name, + "country_code": country, + "last_match_time": last_match, + } + return out + + +def normalize_item_id(iid: int) -> int: + return BLESSING_TO_SCEPTER.get(iid, iid) + + +def match_item_ids(row: dict) -> list[int]: + ids: list[int] = [] + for key in ("items", "backpack"): + part = row.get(key) + if not isinstance(part, list): + continue + for raw in part: + try: + iid = int(raw or 0) + except (TypeError, ValueError): + continue + if iid > 0: + ids.append(normalize_item_id(iid)) + neut = row.get("item_neutral") + try: + ni = int(neut or 0) + except (TypeError, ValueError): + ni = 0 + if ni > 0: + ids.append(ni) + return ids + + +def is_pro_match(row: dict, pro_ids: set[int]) -> bool: + origin = str(row.get("origin") or "") + if origin not in ("league", "pro"): + return False + aid = row.get("account_id") + try: + aid_i = int(aid) if aid is not None else 0 + except (TypeError, ValueError): + aid_i = 0 + if aid_i in pro_ids: + return True + # Registered pro name on league row (OpenDota match detail). + name = row.get("name") + return isinstance(name, str) and bool(name.strip()) + + +def aggregate_items( + rows: list[dict], catalog: dict[int, dict], n: int = TOP_N +) -> list[dict]: + counts: Counter[int] = Counter() + for row in rows: + seen: set[int] = set() + for iid in match_item_ids(row): + if iid in seen: + continue + meta = catalog.get(iid) + if meta is None or not is_core_finished(meta): + continue + seen.add(iid) + counts[iid] += 1 + total = len(rows) + ranked = sorted(counts.items(), key=lambda t: (-t[1], t[0])) + out: list[dict] = [] + for iid, c in ranked[:n]: + cell: dict = {"id": iid, "count": c} + if total > 0: + cell["pct"] = round(c / total * 1000) / 10 + out.append(cell) + return out + + +def aggregate_skill_orders( + rows: list[dict], *, top: int = TOP_SKILL_ORDERS +) -> list[dict]: + counts: Counter[tuple[str, ...]] = Counter() + for row in rows: + ups = row.get("ability_upgrades") + if not isinstance(ups, list) or not ups: + continue + seq = tuple(str(k) for k in ups if isinstance(k, str) and k.strip()) + if not seq: + continue + counts[seq] += 1 + total = sum(counts.values()) + ranked = sorted(counts.items(), key=lambda t: (-t[1], t[0])) + out: list[dict] = [] + for seq, c in ranked[:top]: + cell: dict = {"sequence": list(seq), "count": c} + if total > 0: + cell["pct"] = round(c / total * 1000) / 10 + out.append(cell) + return out + + +def aggregate_pros( + rows: list[dict], pro_index: dict[int, dict] +) -> list[dict]: + by_aid: dict[int, dict] = {} + for row in rows: + aid = row.get("account_id") + try: + aid_i = int(aid) if aid is not None else 0 + except (TypeError, ValueError): + aid_i = 0 + prof = pro_index.get(aid_i) if aid_i else None + name = (prof or {}).get("name") or row.get("name") or row.get("display_name") + if isinstance(name, str): + name = name.strip() or None + else: + name = None + if not name and not aid_i: + continue + key = aid_i if aid_i else hash(name or "") + cell = by_aid.setdefault( + key, + { + "account_id": aid_i or None, + "name": name, + "team_tag": (prof or {}).get("team_tag"), + "games": 0, + "wins": 0, + "last_match_id": None, + "last_start_time": None, + }, + ) + cell["games"] += 1 + if row.get("won"): + cell["wins"] += 1 + mid = row.get("match_id") + st = row.get("start_time") + try: + st_i = int(st) if st is not None else 0 + except (TypeError, ValueError): + st_i = 0 + prev = cell.get("last_start_time") or 0 + if st_i >= prev: + cell["last_start_time"] = st_i or None + cell["last_match_id"] = mid + ranked = sorted( + by_aid.values(), + key=lambda r: ( + -(r.get("games") or 0), + -(r.get("last_start_time") or 0), + str(r.get("name") or ""), + ), + ) + for r in ranked: + r.pop("last_start_time", None) + return ranked[:12] + + +def load_pro_league_rows(path: Path) -> dict[str, list[dict]]: + """League/pro rows from pro_matches.json by_hero or by_pro.""" + if not path.is_file(): + return {} + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + out: dict[str, list[dict]] = {} + + by_hero = raw.get("by_hero") + if isinstance(by_hero, dict): + for key, cell in by_hero.items(): + if not isinstance(cell, dict): + continue + matches = cell.get("matches") + if isinstance(matches, list) and matches: + out[str(key)] = [m for m in matches if isinstance(m, dict)] + + by_pro = raw.get("by_pro") + if isinstance(by_pro, dict): + for cell in by_pro.values(): + if not isinstance(cell, dict): + continue + for row in cell.get("matches") or []: + if not isinstance(row, dict): + continue + key = row.get("hero_key") + if not key: + continue + out.setdefault(str(key), []).append(row) + + deduped: dict[str, list[dict]] = {} + for key, rows in out.items(): + seen: set[int] = set() + merged: list[dict] = [] + for row in rows: + try: + mid = int(row.get("match_id") or 0) + except (TypeError, ValueError): + continue + if mid in seen: + continue + seen.add(mid) + merged.append(row) + if merged: + deduped[key] = merged + return deduped + + +def load_league_rows(path: Path) -> dict[str, list[dict]]: + if not path.is_file(): + return {} + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + by_hero = raw.get("by_hero") + if not isinstance(by_hero, dict): + return {} + out: dict[str, list[dict]] = {} + for key, cell in by_hero.items(): + if not isinstance(cell, dict): + continue + matches = cell.get("matches") + if not isinstance(matches, list): + continue + league = [m for m in matches if isinstance(m, dict) and m.get("origin") == "league"] + if league: + out[str(key)] = league + return out + + +def fetch_league_rows_for_hero( + hero_id: int, + *, + limit: int, + id_map: dict[int, str], + catalog: dict[int, dict], + delay: float, +) -> list[dict]: + metas = league_match_ids(hero_id, limit) + rows: list[dict] = [] + for meta in metas: + try: + mid = int(meta.get("match_id") or 0) + except (TypeError, ValueError): + continue + if mid <= 0: + continue + detail = fetch_match(mid) + if delay > 0: + time.sleep(delay) + if not detail: + continue + slim = extract_player_row( + detail, + hero_id, + origin="league", + id_map=id_map, + item_catalog=catalog, + list_meta=meta, + ) + if slim: + rows.append(slim) + return rows + + +def build_payload( + *, + by_hero_rows: dict[str, list[dict]], + pro_index: dict[int, dict], + catalog: dict[int, dict], + match_source: str, +) -> dict: + pro_ids = set(pro_index) + by_hero: dict[str, dict] = {} + used_item_ids: set[int] = set() + total_matches = 0 + + for key, all_rows in sorted(by_hero_rows.items()): + pro_rows = [r for r in all_rows if is_pro_match(r, pro_ids)] + if not pro_rows: + continue + total_matches += len(pro_rows) + items = aggregate_items(pro_rows, catalog) + for ent in items: + used_item_ids.add(int(ent["id"])) + by_hero[key] = { + "sample_count": len(pro_rows), + "items": items, + "skill_orders": aggregate_skill_orders(pro_rows), + "pros": aggregate_pros(pro_rows, pro_index), + } + + items_out = { + str(iid): { + "key": catalog[iid]["key"], + "dname": catalog[iid]["dname"], + "name_loc": catalog[iid].get("name_loc") or catalog[iid]["dname"], + } + for iid in sorted(used_item_ids) + if iid in catalog + } + + pros_out = { + str(aid): prof + for aid, prof in sorted(pro_index.items(), key=lambda t: t[0]) + } + + return { + "meta": { + "fetched_at": datetime.now(timezone.utc).isoformat(), + "source": "opendota", + "attribution": "https://www.opendota.com", + "match_source": match_source, + "pro_count": len(pro_index), + "hero_count": len(by_hero), + "match_count": total_matches, + "note_zh": ( + "职业选手样本:OpenDota 注册选手 + 联赛对局终局出装/加点;" + "出装频率 = 该装备出现在样本终局栏位的比例;" + "加点为完整升级顺序的最常见方案。" + ), + }, + "pros": pros_out, + "items": items_out, + "by_hero": by_hero, + } + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--out", type=Path, default=OUT) + ap.add_argument( + "--matches", + type=Path, + default=MATCHES_IN, + help="Input hero_matches.json league rows (default: data/hero_matches.json)", + ) + ap.add_argument( + "--pro-matches", + type=Path, + default=PRO_MATCHES_IN, + help="Merge pro player rows from this file (default: data/pro_matches.json)", + ) + ap.add_argument( + "--fetch", + action="store_true", + help="Fetch league matches from OpenDota instead of only reading --matches", + ) + ap.add_argument( + "--limit", + type=int, + default=12, + help="League matches per hero when --fetch (default: 12)", + ) + ap.add_argument( + "--heroes", + default="", + help="Comma-separated hero keys (default: all when --fetch; else keys in matches file)", + ) + ap.add_argument("--delay", type=float, default=0.35) + args = ap.parse_args() + + print("loading pro players ...", flush=True) + pro_index = fetch_pro_index() + print(f" {len(pro_index)} registered pros", flush=True) + + catalog, _ = load_item_catalog() + id_map = load_ability_id_map() + heroes = hero_table() + by_key = {h["key"]: h for h in heroes} + hero_filter = [k.strip() for k in args.heroes.split(",") if k.strip()] + + by_hero_rows: dict[str, list[dict]] = {} + + if args.fetch: + keys = hero_filter or sorted(by_key) + limit = max(1, int(args.limit)) + for i, key in enumerate(keys, 1): + hero = by_key.get(key) + if not hero: + print(f" skip unknown hero {key}", flush=True) + continue + hid = int(hero["id"]) + print(f"[{i}/{len(keys)}] fetch league {key} ...", flush=True) + rows = fetch_league_rows_for_hero( + hid, + limit=limit, + id_map=id_map, + catalog=catalog, + delay=args.delay, + ) + if rows: + by_hero_rows[key] = rows + else: + by_hero_rows = load_league_rows(args.matches) + pro_path = args.pro_matches + if pro_path and pro_path.is_file(): + pro_rows = load_pro_league_rows(pro_path) + for key, rows in pro_rows.items(): + bucket = by_hero_rows.setdefault(key, []) + seen = {int(r.get("match_id") or 0) for r in bucket if isinstance(r, dict)} + for row in rows: + try: + mid = int(row.get("match_id") or 0) + except (TypeError, ValueError): + continue + if mid in seen: + continue + seen.add(mid) + bucket.append(row) + if hero_filter: + by_hero_rows = {k: v for k, v in by_hero_rows.items() if k in hero_filter} + if not by_hero_rows: + raise SystemExit( + f"No league/pro rows in {args.matches} or {args.pro_matches}. " + "Run: python fetch_pro_matches.py or fetch_hero_matches.py --source league" + ) + + sources = [] + if args.fetch: + sources.append("fetch") + else: + sources.append(str(args.matches.name)) + if args.pro_matches and args.pro_matches.is_file(): + sources.append(str(args.pro_matches.name)) + match_source = "+".join(sources) + payload = build_payload( + by_hero_rows=by_hero_rows, + pro_index=pro_index, + catalog=catalog, + match_source=match_source, + ) + + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text( + json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8" + ) + meta = payload["meta"] + print( + f"done heroes={meta['hero_count']} matches={meta['match_count']} → {args.out}", + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/fetch_pro_matches.py b/fetch_pro_matches.py new file mode 100644 index 0000000..5937c82 --- /dev/null +++ b/fetch_pro_matches.py @@ -0,0 +1,434 @@ +"""Fetch recent league/tournament matches for OpenDota registered pros. + +Pulls /proPlayers, then per player /players/{id}/matches (lobby practice + +tournament), enriches with /matches/{id} for final items + skill builds. + +Output: data/pro_matches.json (Climperor web only; not used by recommend). + +Usage: + python fetch_pro_matches.py --limit-pros 20 --limit 8 + python fetch_pro_matches.py --players 1296625,117421467 + python fetch_pro_matches.py --with-team --active-days 45 +""" + +from __future__ import annotations + +import argparse +import json +import time +import urllib.error +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from common import DATA +from fetch_hero_matches import ( + collect_item_ids, + extract_player_row, + fetch_match, + load_ability_id_map, +) +from fetch_hero_items import load_item_catalog +from fetch_pro_builds import fetch_pro_index +from grid import hero_table +from http_utils import http_json + +OPENDOTA = "https://api.opendota.com/api" +OUT = DATA / "pro_matches.json" +DEFAULT_LIMIT = 8 +DEFAULT_LIMIT_PROS = 40 +# OpenDota lobby_type: 1=practice, 2=tournament (pro/league biased). +LOBBY_LEAGUE = (1, 2) + + +def parse_pro_filter(raw: str, pro_index: dict[int, dict]) -> list[int]: + """Comma-separated account ids or registered pro names.""" + if not raw.strip(): + return [] + name_to_id: dict[str, int] = {} + for aid, prof in pro_index.items(): + for key in ("name",): + val = prof.get(key) + if isinstance(val, str) and val.strip(): + name_to_id[val.strip().lower()] = aid + out: list[int] = [] + for part in raw.split(","): + token = part.strip() + if not token: + continue + if token.isdigit(): + out.append(int(token)) + continue + aid = name_to_id.get(token.lower()) + if aid: + out.append(aid) + else: + print(f" warn: unknown pro {token!r}", flush=True) + return out + + +def filter_pros( + pro_index: dict[int, dict], + *, + with_team: bool, + active_days: int | None, + limit_pros: int, + player_ids: list[int], +) -> list[tuple[int, dict]]: + if player_ids: + rows: list[tuple[int, dict]] = [] + for aid in player_ids: + prof = pro_index.get(aid) + if prof: + rows.append((aid, prof)) + return rows + + cutoff = None + if active_days is not None and active_days > 0: + cutoff = datetime.now(timezone.utc) - timedelta(days=active_days) + + candidates: list[tuple[int, dict, float]] = [] + for aid, prof in pro_index.items(): + if with_team and not prof.get("team_tag") and not prof.get("team_name"): + continue + last = prof.get("last_match_time") + score = 0.0 + if isinstance(last, str) and last.strip(): + try: + ts = datetime.fromisoformat(last.replace("Z", "+00:00")) + if cutoff and ts < cutoff: + continue + score = ts.timestamp() + except ValueError: + if cutoff: + continue + candidates.append((aid, prof, score)) + + candidates.sort(key=lambda t: (-t[2], str(t[1].get("name") or ""), t[0])) + picked = candidates[: max(1, limit_pros)] + return [(aid, prof) for aid, prof, _ in picked] + + +def player_match_metas( + account_id: int, + limit: int, + *, + lobby_types: tuple[int, ...] = LOBBY_LEAGUE, +) -> list[dict]: + """Recent match list rows for a pro (deduped, newest first).""" + per_lt = max(limit, limit // max(1, len(lobby_types)) + 2) + by_id: dict[int, dict] = {} + for lt in lobby_types: + url = f"{OPENDOTA}/players/{account_id}/matches?limit={per_lt}&lobby_type={lt}" + try: + raw = http_json(url) + except ( + urllib.error.HTTPError, + urllib.error.URLError, + TimeoutError, + json.JSONDecodeError, + OSError, + ): + continue + if not isinstance(raw, list): + continue + for row in raw: + if not isinstance(row, dict): + continue + try: + mid = int(row.get("match_id") or 0) + except (TypeError, ValueError): + continue + if mid <= 0: + continue + prev = by_id.get(mid) + if prev is None: + by_id[mid] = row + continue + try: + st_new = int(row.get("start_time") or 0) + st_old = int(prev.get("start_time") or 0) + except (TypeError, ValueError): + st_new = st_old = 0 + if st_new >= st_old: + by_id[mid] = row + + ranked = sorted( + by_id.values(), + key=lambda r: (-int(r.get("start_time") or 0), -int(r.get("match_id") or 0)), + ) + return ranked[:limit] + + +def fetch_player_matches( + account_id: int, + *, + limit: int, + id_map: dict[int, str], + catalog: dict[int, dict], + id_to_key: dict[int, str], + delay: float, + lobby_types: tuple[int, ...], +) -> list[dict]: + metas = player_match_metas(account_id, limit, lobby_types=lobby_types) + rows: list[dict] = [] + for meta in metas: + try: + mid = int(meta.get("match_id") or 0) + hid = int(meta.get("hero_id") or 0) + except (TypeError, ValueError): + continue + if mid <= 0 or hid <= 0: + continue + detail = fetch_match(mid) + if delay > 0: + time.sleep(delay) + if not detail: + continue + slim = extract_player_row( + detail, + hid, + origin="pro", + id_map=id_map, + item_catalog=catalog, + list_meta=meta, + ) + if not slim: + continue + row_aid = slim.get("account_id") + try: + row_aid_i = int(row_aid) if row_aid is not None else 0 + except (TypeError, ValueError): + row_aid_i = 0 + if row_aid_i and row_aid_i != account_id: + continue + slim["hero_id"] = hid + slim["hero_key"] = id_to_key.get(hid) + lt = meta.get("lobby_type") + try: + slim["lobby_type"] = int(lt) if lt is not None else None + except (TypeError, ValueError): + slim["lobby_type"] = None + rows.append(slim) + return rows + + +def build_indexes( + by_pro: dict[str, dict], + id_to_key: dict[int, str], +) -> dict[str, dict]: + by_hero: dict[str, list[dict]] = {} + for cell in by_pro.values(): + for row in cell.get("matches") or []: + if not isinstance(row, dict): + continue + key = row.get("hero_key") + if not key: + hid = row.get("hero_id") + try: + key = id_to_key.get(int(hid)) if hid is not None else None + except (TypeError, ValueError): + key = None + if not key: + continue + by_hero.setdefault(str(key), []).append(row) + out: dict[str, dict] = {} + for key, rows in by_hero.items(): + seen: set[int] = set() + deduped: list[dict] = [] + for row in sorted( + rows, + key=lambda r: (-int(r.get("start_time") or 0), -int(r.get("match_id") or 0)), + ): + try: + mid = int(row.get("match_id") or 0) + except (TypeError, ValueError): + continue + if mid in seen: + continue + seen.add(mid) + deduped.append(row) + out[key] = {"matches": deduped} + return out + + +def write_out( + path: Path, + *, + by_pro: dict[str, dict], + by_hero: dict[str, dict], + pros_meta: dict[str, dict], + item_catalog: dict[int, dict], + limit: int, + limit_pros: int, + lobby_types: tuple[int, ...], +) -> None: + used = collect_item_ids(by_hero) + items_out = { + str(iid): { + "key": item_catalog[iid]["key"], + "dname": item_catalog[iid]["dname"], + "name_loc": item_catalog[iid].get("name_loc") or item_catalog[iid]["dname"], + } + for iid in sorted(used) + if iid in item_catalog + } + match_count = sum( + len(cell.get("matches") or []) + for cell in by_pro.values() + if isinstance(cell, dict) + ) + payload = { + "meta": { + "source": "opendota", + "attribution": "https://www.opendota.com", + "fetched_at": datetime.now(timezone.utc).isoformat(), + "limit_per_pro": limit, + "limit_pros": limit_pros, + "lobby_types": list(lobby_types), + "pro_count": len(by_pro), + "match_count": match_count, + "hero_count": len(by_hero), + "note_zh": ( + "OpenDota 注册职业选手近期联赛/锦标赛对局;" + "lobby_type 1=训练/practice、2=tournament;" + "含终局出装、加点与联赛名(若有)。" + ), + }, + "items": items_out, + "pros": pros_meta, + "by_pro": by_pro, + "by_hero": by_hero, + } + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" + ) + + +def _log(msg: str) -> None: + try: + print(msg, flush=True) + except UnicodeEncodeError: + print(msg.encode("ascii", "backslashreplace").decode("ascii"), flush=True) + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--out", type=Path, default=OUT) + ap.add_argument( + "--limit", + type=int, + default=DEFAULT_LIMIT, + help=f"Matches per pro (default: {DEFAULT_LIMIT})", + ) + ap.add_argument( + "--limit-pros", + type=int, + default=DEFAULT_LIMIT_PROS, + help=f"Max pros when --players omitted (default: {DEFAULT_LIMIT_PROS})", + ) + ap.add_argument( + "--players", + default="", + help="Comma-separated account_id or registered pro name (overrides --limit-pros)", + ) + ap.add_argument( + "--with-team", + action="store_true", + help="Only pros with a team_tag/team_name when picking from /proPlayers", + ) + ap.add_argument( + "--active-days", + type=int, + default=60, + help="Skip pros with no last_match_time within N days (0=disable; default: 60)", + ) + ap.add_argument("--delay", type=float, default=0.35) + ap.add_argument( + "--include-pubs", + action="store_true", + help="Also include ranked pub lobby_type=7 (high-MMR scrims)", + ) + args = ap.parse_args() + + limit = max(1, int(args.limit)) + limit_pros = max(1, int(args.limit_pros)) + active_days = int(args.active_days) if args.active_days > 0 else None + lobby_types: tuple[int, ...] = LOBBY_LEAGUE + if args.include_pubs: + lobby_types = LOBBY_LEAGUE + (7,) + + _log("loading pro players ...") + pro_index = fetch_pro_index() + _log(f" {len(pro_index)} registered pros") + + player_ids = parse_pro_filter(args.players, pro_index) + picked = filter_pros( + pro_index, + with_team=args.with_team, + active_days=active_days, + limit_pros=limit_pros, + player_ids=player_ids, + ) + if not picked: + raise SystemExit("No pros matched filters") + + heroes = hero_table() + id_to_key = {int(h["id"]): h["key"] for h in heroes} + catalog, _ = load_item_catalog() + id_map = load_ability_id_map() + + by_pro: dict[str, dict] = {} + pros_meta: dict[str, dict] = {} + total_matches = 0 + + for i, (aid, prof) in enumerate(picked, 1): + label = prof.get("name") or prof.get("team_tag") or str(aid) + _log(f"[{i}/{len(picked)}] {label} ({aid}) ...") + matches = fetch_player_matches( + aid, + limit=limit, + id_map=id_map, + catalog=catalog, + id_to_key=id_to_key, + delay=args.delay, + lobby_types=lobby_types, + ) + sid = str(aid) + by_pro[sid] = { + "account_id": aid, + "name": prof.get("name"), + "team_tag": prof.get("team_tag"), + "team_name": prof.get("team_name"), + "country_code": prof.get("country_code"), + "match_count": len(matches), + "matches": matches, + } + pros_meta[sid] = { + "account_id": aid, + "name": prof.get("name"), + "team_tag": prof.get("team_tag"), + "team_name": prof.get("team_name"), + "country_code": prof.get("country_code"), + } + total_matches += len(matches) + _log(f" {len(matches)} matches") + + by_hero = build_indexes(by_pro, id_to_key) + write_out( + args.out, + by_pro=by_pro, + by_hero=by_hero, + pros_meta=pros_meta, + item_catalog=catalog, + limit=limit, + limit_pros=len(picked), + lobby_types=lobby_types, + ) + _log( + f"done pros={len(by_pro)} matches={total_matches} heroes={len(by_hero)} → {args.out}" + ) + + +if __name__ == "__main__": + main() diff --git a/fetch_stratz.py b/fetch_stratz.py index c053c7a..383a61c 100644 --- a/fetch_stratz.py +++ b/fetch_stratz.py @@ -65,9 +65,15 @@ query($id: Short!) {{ def load_token() -> str: - env = os.environ.get("STRATZ_API_TOKEN", "").strip() - if env: - return env + for key in ( + "STRATZ_API_TOKEN", + "KEYZOO_ASSET_API_KEY", + "KEYZOO_ASSET_SECRET_API_KEY", + "KEYZOO_ASSET_TOKEN", + ): + env = os.environ.get(key, "").strip() + if env: + return env path = ROOT / ".env" if path.is_file(): for line in path.read_text(encoding="utf-8").splitlines(): @@ -78,7 +84,8 @@ def load_token() -> str: if k.strip() == "STRATZ_API_TOKEN": return v.strip().strip('"').strip("'") raise SystemExit( - "STRATZ_API_TOKEN missing. Set env or add it to .env (see .env.example)." + "STRATZ_API_TOKEN missing. Set env / keyzoo inject or add to .env " + "(see .env.example)." ) diff --git a/fetch_stratz_meta.py b/fetch_stratz_meta.py new file mode 100644 index 0000000..d41161a --- /dev/null +++ b/fetch_stratz_meta.py @@ -0,0 +1,500 @@ +"""Fetch STRATZ hero meta (weekly WR/pick by bracket + position) and matchup tops. + +Requires STRATZ_API_TOKEN (or KEYZOO_ASSET_API_KEY) in the environment / .env. +Output is for the Climperor web site only — never merge into relations.json +or recommend. + +Data sources (heroStats GraphQL): + - winWeek(take=N, bracketIds): per-medal weekly pick/win → `weeks` / `latest` + - winWeek(take=1, bracketIds, positionIds): per-medal per-position latest week + → `positions` (same time window as headline cards; exact medal, not basic merge) + - matchUp: counter / countered / synergy tops → stratz_matchup_tops.json + +Usage: + python fetch_stratz_meta.py + python fetch_stratz_meta.py --weeks 8 --delay 0.25 + python fetch_stratz_meta.py --skip-matchups +""" + +from __future__ import annotations + +import argparse +import json +import os +import time +import urllib.error +import urllib.request +from datetime import datetime, timezone +from pathlib import Path + +from common import DATA, ROOT +from grid import hero_table + +API = "https://api.stratz.com/graphql" +OUT_META = DATA / "stratz_hero_meta.json" +OUT_MATCHUPS = DATA / "stratz_matchup_tops.json" + +# UI medal keys ↔ STRATZ RankBracket enum +BRACKET_ORDER = ( + "herald", + "guardian", + "crusader", + "archon", + "legend", + "ancient", + "divine", + "immortal", +) +BRACKET_ENUM = { + "herald": "HERALD", + "guardian": "GUARDIAN", + "crusader": "CRUSADER", + "archon": "ARCHON", + "legend": "LEGEND", + "ancient": "ANCIENT", + "divine": "DIVINE", + "immortal": "IMMORTAL", +} + +# Medal → RankBracketBasicEnum (legacy; position stats now use exact medal via winWeek) +BRACKET_TO_BASIC = { + "herald": "HERALD_GUARDIAN", + "guardian": "HERALD_GUARDIAN", + "crusader": "CRUSADER_ARCHON", + "archon": "CRUSADER_ARCHON", + "legend": "LEGEND_ANCIENT", + "ancient": "LEGEND_ANCIENT", + "divine": "DIVINE_IMMORTAL", + "immortal": "DIVINE_IMMORTAL", +} + +POSITION_ORDER = ( + "POSITION_1", + "POSITION_2", + "POSITION_3", + "POSITION_4", + "POSITION_5", +) + +ATTRIBUTION = "https://stratz.com" + + +def load_token() -> str: + for key in ( + "STRATZ_API_TOKEN", + "KEYZOO_ASSET_API_KEY", + "KEYZOO_ASSET_SECRET_API_KEY", + "KEYZOO_ASSET_TOKEN", + ): + env = os.environ.get(key, "").strip() + if env: + return env + path = ROOT / ".env" + if path.is_file(): + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + if k.strip() == "STRATZ_API_TOKEN": + return v.strip().strip('"').strip("'") + raise SystemExit( + "STRATZ_API_TOKEN missing. Set env / keyzoo inject or add to .env " + "(see .env.example)." + ) + + +def gql(token: str, query: str, variables: dict | None = None) -> dict: + body: dict = {"query": query} + if variables: + body["variables"] = variables + req = urllib.request.Request( + API, + data=json.dumps(body).encode(), + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "User-Agent": "climperor", + }, + method="POST", + ) + with urllib.request.urlopen(req, timeout=120) as resp: + payload = json.loads(resp.read().decode()) + if payload.get("errors"): + raise RuntimeError(str(payload["errors"][:2])) + return payload["data"] + + +def _pw(pick: int, win: int) -> dict: + pick = max(0, int(pick or 0)) + win = max(0, int(win or 0)) + wr = (win / pick) if pick > 0 else None + return {"pick": pick, "win": win, "wr": wr} + + +def fetch_weeks_for_bracket( + token: str, hero_ids: list[int], bracket: str, take: int +) -> list[dict]: + enum = BRACKET_ENUM[bracket] + # GraphQL list variable as inline enums (Short! list for heroIds). + ids_lit = ", ".join(str(i) for i in hero_ids) + query = f""" + query {{ + heroStats {{ + winWeek(heroIds: [{ids_lit}], take: {int(take)}, bracketIds: [{enum}]) {{ + heroId + week + matchCount + winCount + }} + }} + }} + """ + data = gql(token, query) + return (((data or {}).get("heroStats") or {}).get("winWeek")) or [] + + +def fetch_latest_positions_for_bracket( + token: str, hero_ids: list[int], bracket: str +) -> list[dict]: + """Latest-week winWeek per position; same bracket + week as headline cards.""" + enum = BRACKET_ENUM[bracket] + ids_lit = ", ".join(str(i) for i in hero_ids) + out: list[dict] = [] + for pos in POSITION_ORDER: + query = f""" + query {{ + heroStats {{ + winWeek( + heroIds: [{ids_lit}] + take: 1 + bracketIds: [{enum}] + positionIds: [{pos}] + ) {{ + heroId + week + matchCount + winCount + }} + }} + }} + """ + data = gql(token, query) + for row in (((data or {}).get("heroStats") or {}).get("winWeek")) or []: + out.append({**row, "position": pos, "bracket": bracket}) + return out + + +MATCHUP_QUERY = """ +query($id: Short!, $take: Int!, $limit: Int!) { + heroStats { + matchUp(heroId: $id, take: $take, matchLimit: $limit) { + heroId + matchCountVs + matchCountWith + vs { + heroId2 + matchCount + winCount + synergy + winsAverage + } + with { + heroId2 + matchCount + winCount + synergy + winsAverage + } + } + } +} +""" + + +def fetch_matchup_tops( + token: str, hero_id: int, *, take: int, match_limit: int +) -> dict: + data = gql( + token, + MATCHUP_QUERY, + {"id": hero_id, "take": take, "limit": match_limit}, + ) + rows = (((data or {}).get("heroStats") or {}).get("matchUp")) or [] + row = rows[0] if rows else {} + vs_out = [] + with_out = [] + for pair in row.get("vs") or []: + other = pair.get("heroId2") + games = int(pair.get("matchCount") or 0) + if other is None or games <= 0: + continue + wins = int(pair.get("winCount") or 0) + vs_out.append( + { + "hero_id": int(other), + "games": games, + "wins": wins, + "advantage": float(pair.get("synergy") or 0.0), + "wr": float(pair.get("winsAverage") or (wins / games)), + } + ) + for pair in row.get("with") or []: + other = pair.get("heroId2") + games = int(pair.get("matchCount") or 0) + if other is None or games <= 0: + continue + wins = int(pair.get("winCount") or 0) + with_out.append( + { + "hero_id": int(other), + "games": games, + "wins": wins, + "synergy": float(pair.get("synergy") or 0.0), + "wr": float(pair.get("winsAverage") or (wins / games)), + } + ) + # vs advantage: positive = hero wins more vs other → counters other. + # Also derive "disadvantage" as others with most negative advantage for hero. + disadvantage = [ + { + "hero_id": e["hero_id"], + "games": e["games"], + "wins": e["wins"], + "advantage": -float(e["advantage"]), + "wr": 1.0 - float(e["wr"]) if e["wr"] is not None else None, + } + for e in sorted(vs_out, key=lambda x: x["advantage"])[:take] + ] + counters = sorted(vs_out, key=lambda x: -x["advantage"])[:take] + synergies = sorted(with_out, key=lambda x: -x["synergy"])[:take] + return { + "counters": counters, + "countered": disadvantage, + "synergies": synergies, + } + + +def build_meta( + weeks_rows_by_bracket: dict[str, list[dict]], + position_rows_by_bracket: dict[str, list[dict]], + id_to_key: dict[int, str], + weeks_take: int, +) -> dict: + by_hero: dict[str, dict] = {} + for hid, key in id_to_key.items(): + by_hero[key] = { + "id": hid, + "weeks": {b: [] for b in BRACKET_ORDER}, + "latest": {}, + "positions": {b: {} for b in BRACKET_ORDER}, + } + + for bracket, rows in weeks_rows_by_bracket.items(): + # Group by hero, sort weeks desc, keep take + by_id: dict[int, list[dict]] = {} + for row in rows: + hid = int(row.get("heroId") or 0) + if hid not in id_to_key: + continue + by_id.setdefault(hid, []).append(row) + for hid, rows_h in by_id.items(): + key = id_to_key[hid] + rows_h.sort(key=lambda r: int(r.get("week") or 0), reverse=True) + weeks = [] + for r in rows_h[:weeks_take]: + pick = int(r.get("matchCount") or 0) + win = int(r.get("winCount") or 0) + weeks.append( + { + "week": int(r.get("week") or 0), + "pick": pick, + "win": win, + "wr": (win / pick) if pick > 0 else None, + } + ) + by_hero[key]["weeks"][bracket] = weeks + if weeks: + latest = weeks[0] + by_hero[key]["latest"][bracket] = _pw(latest["pick"], latest["win"]) + + for bracket, pos_rows in position_rows_by_bracket.items(): + for row in pos_rows: + hid = int(row.get("heroId") or 0) + key = id_to_key.get(hid) + if not key: + continue + pos = str(row.get("position") or "") + if pos not in POSITION_ORDER: + continue + cell = _pw(int(row.get("matchCount") or 0), int(row.get("winCount") or 0)) + by_hero[key]["positions"][bracket][pos] = cell + + # Totals (latest week pick sum per bracket) for pick-rate denominator + totals: dict[str, dict] = {} + for bracket in BRACKET_ORDER: + total_pick = 0 + for cell in by_hero.values(): + latest = (cell.get("latest") or {}).get(bracket) or {} + total_pick += int(latest.get("pick") or 0) + totals[bracket] = {"pick": total_pick} + + # Meta board: top heroes by pick (latest week) per bracket + meta_board: dict[str, list[dict]] = {} + for bracket in BRACKET_ORDER: + denom = max(1, int((totals.get(bracket) or {}).get("pick") or 0)) + rows_board = [] + for key, cell in by_hero.items(): + latest = (cell.get("latest") or {}).get(bracket) + if not latest or int(latest.get("pick") or 0) <= 0: + continue + pick = int(latest["pick"]) + win = int(latest["win"]) + wr = win / pick if pick else None + # Same convention as OpenDota web: pick / (Σpick / 10) + pr = pick / (denom / 10.0) if denom else None + rows_board.append( + { + "key": key, + "id": cell["id"], + "pick": pick, + "win": win, + "wr": wr, + "pr": pr, + } + ) + rows_board.sort(key=lambda r: (-r["pick"], -(r["wr"] or 0), r["key"])) + meta_board[bracket] = rows_board + + return { + "fetched_at": datetime.now(timezone.utc).isoformat(), + "source": "stratz", + "attribution": ATTRIBUTION, + "weeks_take": weeks_take, + "window_label_zh": f"近 {weeks_take} 周天梯(按段位)", + "latest_window_label_zh": "最近 1 周", + "brackets": list(BRACKET_ORDER), + "positions": list(POSITION_ORDER), + "bracket_position_note": None, + "totals": totals, + "by_hero": by_hero, + "meta_board": meta_board, + } + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--weeks", type=int, default=8, help="weekly buckets to keep") + ap.add_argument("--delay", type=float, default=0.25) + ap.add_argument("--out-meta", type=Path, default=OUT_META) + ap.add_argument("--out-matchups", type=Path, default=OUT_MATCHUPS) + ap.add_argument("--skip-matchups", action="store_true") + ap.add_argument("--matchup-take", type=int, default=12) + ap.add_argument("--matchup-min-games", type=int, default=50) + args = ap.parse_args() + + token = load_token() + heroes = hero_table() + id_to_key = {int(h["id"]): h["key"] for h in heroes} + hero_ids = sorted(id_to_key.keys()) + print( + f"fetching STRATZ meta for {len(hero_ids)} heroes, " + f"{args.weeks} weeks × {len(BRACKET_ORDER)} brackets", + flush=True, + ) + + weeks_by_bracket: dict[str, list[dict]] = {} + position_rows_by_bracket: dict[str, list[dict]] = {} + for i, bracket in enumerate(BRACKET_ORDER, start=1): + try: + rows = fetch_weeks_for_bracket(token, hero_ids, bracket, args.weeks) + except (urllib.error.URLError, TimeoutError, RuntimeError, json.JSONDecodeError) as e: + print(f" [{i}/{len(BRACKET_ORDER)}] {bracket} failed: {e}", flush=True) + rows = [] + weeks_by_bracket[bracket] = rows + try: + pos_rows = fetch_latest_positions_for_bracket(token, hero_ids, bracket) + except (urllib.error.URLError, TimeoutError, RuntimeError, json.JSONDecodeError) as e: + print(f" [{i}/{len(BRACKET_ORDER)}] {bracket} positions failed: {e}", flush=True) + pos_rows = [] + position_rows_by_bracket[bracket] = pos_rows + print( + f" [{i}/{len(BRACKET_ORDER)}] {bracket}: {len(rows)} week rows, " + f"{len(pos_rows)} position rows", + flush=True, + ) + time.sleep(args.delay) + + meta = build_meta( + weeks_by_bracket, position_rows_by_bracket, id_to_key, args.weeks + ) + args.out_meta.parent.mkdir(parents=True, exist_ok=True) + args.out_meta.write_text( + json.dumps(meta, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + print(f"wrote {args.out_meta}", flush=True) + + if args.skip_matchups: + print("skip matchup tops", flush=True) + return + + by_hero_mu: dict[str, dict] = {} + if args.out_matchups.is_file(): + try: + prev = json.loads(args.out_matchups.read_text(encoding="utf-8")) + by_hero_mu = dict(prev.get("by_hero") or {}) + print(f"resuming matchups with {len(by_hero_mu)} heroes", flush=True) + except (OSError, json.JSONDecodeError): + pass + + pending = [hid for hid in hero_ids if id_to_key[hid] not in by_hero_mu] + print( + f"fetching matchup tops {len(pending)}/{len(hero_ids)} " + f"(take={args.matchup_take}, min_games={args.matchup_min_games})", + flush=True, + ) + for n, hid in enumerate(pending, start=1): + key = id_to_key[hid] + try: + cell = fetch_matchup_tops( + token, + hid, + take=args.matchup_take, + match_limit=args.matchup_min_games, + ) + except (urllib.error.URLError, TimeoutError, RuntimeError, json.JSONDecodeError) as e: + print(f" [{n}/{len(pending)}] {key} failed: {e}", flush=True) + time.sleep(args.delay * 2) + continue + by_hero_mu[key] = cell + print( + f" [{n}/{len(pending)}] {key}: " + f"vs={len(cell['counters'])} fear={len(cell['countered'])} " + f"with={len(cell['synergies'])}", + flush=True, + ) + payload = { + "fetched_at": datetime.now(timezone.utc).isoformat(), + "source": "stratz", + "attribution": ATTRIBUTION, + "take": args.matchup_take, + "match_limit": args.matchup_min_games, + "note": ( + "counters = positive vs advantage; countered = heroes this hero " + "loses to (negated advantage); synergies = with synergy. " + "Web-only; do not merge into relations.json." + ), + "by_hero": by_hero_mu, + } + args.out_matchups.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + time.sleep(args.delay) + + print(f"done: matchups {len(by_hero_mu)} heroes → {args.out_matchups}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/fetch_streamers.py b/fetch_streamers.py new file mode 100644 index 0000000..b4a84b1 --- /dev/null +++ b/fetch_streamers.py @@ -0,0 +1,469 @@ +"""Fetch Douyin profile fields into data/streamers.json. + +Manual seed fields (id / platform / live_url / profile_url / heroes / tagline) +are preserved. Profile enrichment (nickname, signature, counts, avatar) is +best-effort via Douyin HTML RENDER_DATA + text fallback; failures keep the +previous values. + +Preview only — do not merge into relations/heroes or recommend. +Part of refresh_web ``daily`` (soft-fail: never aborts the tier). + +Usage: + python fetch_streamers.py + python fetch_streamers.py --ids xiaowang + python fetch_streamers.py --out data/streamers.json +""" + +from __future__ import annotations + +import argparse +import http.cookiejar +import json +import re +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from common import DATA, ROOT + +OUT = DATA / "streamers.json" +AVATAR_DIR = ROOT / "assets" / "streamer_avatars" +BROWSER_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36" +) +RENDER_DATA_RE = re.compile( + r']+id=["\']RENDER_DATA["\'][^>]*>([^<]+)', + re.IGNORECASE, +) +HTML_COUNTS_RE = re.compile( + r"关注\s*([\d.]+万?亿?)\s*粉丝\s*([\d.]+万?亿?)\s*获赞\s*([\d.]+万?亿?)", + re.DOTALL, +) +HTML_UNIQUE_RE = re.compile(r"抖音号[::]\s*([A-Za-z0-9_.-]+)") +HTML_TITLE_RE = re.compile(r"\s*([^<]+?)\s*的抖音", re.IGNORECASE) +HTML_AVATAR_RE = re.compile( + r'<img[^>]+alt="[^"]*头像"[^>]+src="([^"]+)"|' + r'<img[^>]+src="([^"]+)"[^>]+alt="[^"]*头像"', + re.IGNORECASE, +) +SEC_UID_RE = re.compile(r"/user/(MS4wLjABAAAA[A-Za-z0-9_-]+)") +# Fields fetch may overwrite; manual seed keys are never removed. +PROFILE_KEYS = ( + "nickname", + "unique_id", + "signature", + "following_count", + "follower_count", + "total_favorited", + "avatar", + "profile_fetched_at", +) + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _load(path: Path) -> dict: + if not path.is_file(): + return { + "fetched_at": None, + "source": "manual+douyin", + "platform_meta": { + "douyin": { + "label_zh": "抖音", + "icon": "ui-icon/platform_douyin.png", + } + }, + "streamers": [], + } + raw = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise SystemExit(f"{path}: expected object") + return raw + + +def _save(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + + +def _opener() -> urllib.request.OpenerDirector: + jar = http.cookiejar.CookieJar() + return urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) + + +def _get( + opener: urllib.request.OpenerDirector, url: str, *, timeout: int = 30 +) -> tuple[str, str]: + """Return (final_url, html).""" + req = urllib.request.Request( + url, + headers={ + "User-Agent": BROWSER_UA, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Referer": "https://www.douyin.com/", + }, + ) + with opener.open(req, timeout=timeout) as resp: + final = resp.geturl() + html = resp.read().decode("utf-8", "replace") + return final, html + + +def parse_cn_count(raw: str) -> int | None: + s = (raw or "").strip().replace(",", "").replace("\n", "") + if not s: + return None + try: + if s.endswith("亿"): + return int(float(s[:-1]) * 100_000_000) + if s.endswith("万"): + return int(float(s[:-1]) * 10_000) + return int(float(s)) + except ValueError: + return None + + +def _parse_render_data(html: str) -> dict | None: + m = RENDER_DATA_RE.search(html) + if not m: + return None + try: + decoded = urllib.parse.unquote(m.group(1)) + data = json.loads(decoded) + except (ValueError, json.JSONDecodeError): + return None + return data if isinstance(data, dict) else None + + +def _walk_user_nodes(obj: Any, found: list[dict]) -> None: + if isinstance(obj, dict): + if "nickname" in obj and ( + "follower_count" in obj + or "followerCount" in obj + or "mplatform_followers_count" in obj + ): + found.append(obj) + for v in obj.values(): + _walk_user_nodes(v, found) + elif isinstance(obj, list): + for v in obj: + _walk_user_nodes(v, found) + + +def _first_int(*vals: Any) -> int | None: + for v in vals: + if isinstance(v, bool): + continue + if isinstance(v, int): + return v + if isinstance(v, float): + return int(v) + if isinstance(v, str): + n = parse_cn_count(v) + if n is not None: + return n + return None + + +def _first_str(*vals: Any) -> str | None: + for v in vals: + if isinstance(v, str) and v.strip(): + return v.strip() + return None + + +def _avatar_url(user: dict) -> str | None: + for key in ( + "avatar_larger", + "avatar_medium", + "avatar_thumb", + "avatarUrl", + "avatar_url", + ): + cell = user.get(key) + if isinstance(cell, str) and cell.startswith("http"): + return cell + if isinstance(cell, dict): + urls = cell.get("url_list") or cell.get("urlList") or [] + if isinstance(urls, list): + for u in urls: + if isinstance(u, str) and u.startswith("http"): + return u + return None + + +def extract_profile_from_render(render: dict) -> dict | None: + candidates: list[dict] = [] + _walk_user_nodes(render, candidates) + if not candidates: + return None + + def score(u: dict) -> int: + n = _first_int( + u.get("follower_count"), + u.get("followerCount"), + u.get("mplatform_followers_count"), + ) + return n if n is not None else -1 + + user = max(candidates, key=score) + return { + "nickname": _first_str(user.get("nickname"), user.get("nickName")), + "unique_id": _first_str( + user.get("unique_id"), + user.get("uniqueId"), + user.get("short_id"), + user.get("shortId"), + ), + "signature": _first_str(user.get("signature"), user.get("desc")), + "following_count": _first_int( + user.get("following_count"), user.get("followingCount") + ), + "follower_count": _first_int( + user.get("follower_count"), + user.get("followerCount"), + user.get("mplatform_followers_count"), + ), + "total_favorited": _first_int( + user.get("total_favorited"), + user.get("totalFavorited"), + user.get("favoriting_count"), + ), + "avatar_url": _avatar_url(user), + } + + +def extract_profile_from_html(html: str) -> dict: + """Best-effort parse when RENDER_DATA user blob is missing/blocked.""" + out: dict[str, Any] = {} + m = HTML_COUNTS_RE.search(html) + if m: + out["following_count"] = parse_cn_count(m.group(1)) + out["follower_count"] = parse_cn_count(m.group(2)) + out["total_favorited"] = parse_cn_count(m.group(3)) + uid = HTML_UNIQUE_RE.search(html) + if uid: + out["unique_id"] = uid.group(1) + title = HTML_TITLE_RE.search(html) + if title: + out["nickname"] = title.group(1).strip() + av = HTML_AVATAR_RE.search(html) + if av: + url = av.group(1) or av.group(2) + if url and url.startswith("http"): + out["avatar_url"] = url.replace("&", "&") + return out + + +def merge_profile_dicts(primary: dict | None, fallback: dict) -> dict: + out = dict(fallback) + if primary: + for k, v in primary.items(): + if v is None or v == "": + continue + out[k] = v + return out + + +def canonicalize_douyin_user_url(url: str) -> str: + """Map share / short-link destinations to www.douyin.com/user/<sec_uid>.""" + m = SEC_UID_RE.search(url) + if m: + return f"https://www.douyin.com/user/{m.group(1)}" + return url + + +def resolve_profile_url( + opener: urllib.request.OpenerDirector, profile_url: str +) -> tuple[str, str | None]: + """Follow redirects; return (canonical_url, html_or_None if not yet fetched).""" + url = profile_url.strip() + host = urllib.parse.urlparse(url).netloc.lower() + if "v.douyin.com" in host or "iesdouyin.com" in host: + final, html = _get(opener, url) + canon = canonicalize_douyin_user_url(final) + if canon != final and "douyin.com/user/" in canon: + # Re-fetch canonical profile for RENDER_DATA when possible. + try: + _, html2 = _get(opener, canon) + return canon, html2 + except (urllib.error.URLError, TimeoutError, OSError): + return canon, html + return canon, html + return url, None + + +def download_avatar(url: str, dest: Path) -> bool: + dest.parent.mkdir(parents=True, exist_ok=True) + # Prefer a larger CDN variant when the URL embeds a size token. + candidates = [url] + if "/100x100/" in url: + candidates.insert(0, url.replace("/100x100/", "/720x720/")) + if "300x300" in url: + candidates.insert(0, url.replace("300x300", "720x720")) + for candidate in candidates: + req = urllib.request.Request( + candidate, + headers={ + "User-Agent": BROWSER_UA, + "Referer": "https://www.douyin.com/", + }, + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + data = resp.read() + except (urllib.error.URLError, TimeoutError, OSError) as e: + print(f" avatar download failed: {e}", flush=True) + continue + if not data or len(data) < 64: + continue + dest.write_bytes(data) + return True + print(" avatar download empty", flush=True) + return False + + +def fetch_douyin_profile( + opener: urllib.request.OpenerDirector, profile_url: str +) -> dict: + try: + _get(opener, "https://www.douyin.com/") + except (urllib.error.URLError, TimeoutError, OSError) as e: + print(f" douyin homepage warm-up failed: {e}", flush=True) + + canon, html = resolve_profile_url(opener, profile_url) + if html is None: + _, html = _get(opener, canon) + + primary = None + render = _parse_render_data(html) + if render is not None: + primary = extract_profile_from_render(render) + fallback = extract_profile_from_html(html) + profile = merge_profile_dicts(primary, fallback) + if not any( + profile.get(k) is not None + for k in ( + "nickname", + "follower_count", + "following_count", + "total_favorited", + "unique_id", + "avatar_url", + ) + ): + raise ValueError("no profile fields parsed (blocked or layout changed)") + return profile + + +def merge_profile(row: dict, profile: dict, *, streamer_id: str) -> None: + for key in ( + "nickname", + "unique_id", + "signature", + "following_count", + "follower_count", + "total_favorited", + ): + val = profile.get(key) + if val is None or val == "": + continue + row[key] = val + avatar_url = profile.get("avatar_url") + if isinstance(avatar_url, str) and avatar_url: + dest = AVATAR_DIR / f"{streamer_id}.jpg" + if download_avatar(avatar_url, dest): + row["avatar"] = f"streamer_avatars/{streamer_id}.jpg" + print(f" avatar saved {dest.relative_to(ROOT)}", flush=True) + row["profile_fetched_at"] = _now_iso() + + +def enrich_streamers( + payload: dict, *, ids: set[str] | None = None +) -> tuple[int, int, int]: + rows = payload.get("streamers") + if not isinstance(rows, list): + raise SystemExit("streamers.json: missing streamers array") + opener = _opener() + ok = skip = fail = 0 + for row in rows: + if not isinstance(row, dict): + continue + sid = str(row.get("id") or "").strip() + if not sid: + continue + if ids is not None and sid not in ids: + continue + platform = str(row.get("platform") or "").strip().lower() + if platform != "douyin": + print(f"skip {sid}: platform={platform!r} (only douyin supported)", flush=True) + skip += 1 + continue + profile_url = str(row.get("profile_url") or "").strip() + if not profile_url: + print(f"skip {sid}: missing profile_url", flush=True) + skip += 1 + continue + print(f"fetching {sid} ...", flush=True) + try: + profile = fetch_douyin_profile(opener, profile_url) + merge_profile(row, profile, streamer_id=sid) + nick = row.get("nickname") or "?" + print( + f" ok {nick} followers={row.get('follower_count')} " + f"likes={row.get('total_favorited')}", + flush=True, + ) + ok += 1 + except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e: + print(f" FAIL {sid}: {e} (keeping previous values)", flush=True) + fail += 1 + time.sleep(0.8) + if ok > 0: + payload["fetched_at"] = _now_iso() + payload["source"] = payload.get("source") or "manual+douyin" + meta = payload.get("platform_meta") + if not isinstance(meta, dict): + meta = {} + meta.setdefault( + "douyin", + {"label_zh": "抖音", "icon": "ui-icon/platform_douyin.png"}, + ) + payload["platform_meta"] = meta + _ = PROFILE_KEYS + return ok, skip, fail + + +def main() -> int: + ap = argparse.ArgumentParser(description="Enrich streamers.json from Douyin profiles") + ap.add_argument("--out", type=Path, default=OUT) + ap.add_argument( + "--ids", + nargs="+", + default=None, + help="only refresh these streamer ids", + ) + args = ap.parse_args() + payload = _load(args.out) + id_set = set(args.ids) if args.ids else None + ok, skip, fail = enrich_streamers(payload, ids=id_set) + _save(args.out, payload) + print(f"wrote {args.out} ok={ok} skip={skip} fail={fail}", flush=True) + # Soft-fail for CI/refresh_web: always exit 0 after writing (keep old values). + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/hero_tags.py b/hero_tags.py index 640716f..fefbff5 100644 --- a/hero_tags.py +++ b/hero_tags.py @@ -1,4 +1,4 @@ -"""Chinese draft tags (定位) for relation preview filter/display. +"""Chinese draft tags (定位) for Climperor web filter/display. Mapped from OpenDota English roles + a curated illusion set. Not used by recommend scoring. @@ -6,7 +6,7 @@ Not used by recommend scoring. from __future__ import annotations -# Display / filter order on the preview page. +# Display / filter order on the web heroes page. TAG_ORDER = [ "核心", "辅助", diff --git a/http_utils.py b/http_utils.py index 2f7c77d..047a85f 100644 --- a/http_utils.py +++ b/http_utils.py @@ -14,20 +14,70 @@ from pathlib import Path UA = "climperor" DEFAULT_TIMEOUT = 60 +# OpenDota and similar APIs occasionally 429; back off before failing CI/refresh. +DEFAULT_RETRIES = 4 +DEFAULT_RETRY_BACKOFF = 5.0 HEROES_URL = "https://www.dota2.com/datafeed/herolist?language={lang}" ITEMLIST_URL = "https://www.dota2.com/datafeed/itemlist?language={lang}" -def http_json(url: str, *, timeout: int = DEFAULT_TIMEOUT) -> dict | list: - req = urllib.request.Request(url, headers={"User-Agent": UA}) - with urllib.request.urlopen(req, timeout=timeout) as resp: +def _http_open(url: str, *, timeout: int, retries: int, backoff: float): + """urlopen with retries on 429 / 5xx / transient network errors.""" + last_err: BaseException | None = None + for attempt in range(retries + 1): + req = urllib.request.Request(url, headers={"User-Agent": UA}) + try: + return urllib.request.urlopen(req, timeout=timeout) + except urllib.error.HTTPError as e: + last_err = e + if e.code not in (429, 500, 502, 503, 504) or attempt >= retries: + raise + sleep_s = backoff * (2**attempt) + retry_after = e.headers.get("Retry-After") if e.headers else None + if retry_after: + try: + sleep_s = max(sleep_s, float(retry_after)) + except ValueError: + pass + print( + f"HTTP {e.code} {url} — retry {attempt + 1}/{retries} in {sleep_s:.0f}s", + flush=True, + ) + time.sleep(sleep_s) + except (urllib.error.URLError, TimeoutError, OSError) as e: + last_err = e + if attempt >= retries: + raise + sleep_s = backoff * (2**attempt) + print( + f"HTTP error {e} {url} — retry {attempt + 1}/{retries} in {sleep_s:.0f}s", + flush=True, + ) + time.sleep(sleep_s) + assert last_err is not None + raise last_err + + +def http_json( + url: str, + *, + timeout: int = DEFAULT_TIMEOUT, + retries: int = DEFAULT_RETRIES, + backoff: float = DEFAULT_RETRY_BACKOFF, +) -> dict | list: + with _http_open(url, timeout=timeout, retries=retries, backoff=backoff) as resp: return json.loads(resp.read().decode()) -def http_bytes(url: str, *, timeout: int = DEFAULT_TIMEOUT) -> bytes: - req = urllib.request.Request(url, headers={"User-Agent": UA}) - with urllib.request.urlopen(req, timeout=timeout) as resp: +def http_bytes( + url: str, + *, + timeout: int = DEFAULT_TIMEOUT, + retries: int = DEFAULT_RETRIES, + backoff: float = DEFAULT_RETRY_BACKOFF, +) -> bytes: + with _http_open(url, timeout=timeout, retries=retries, backoff=backoff) as resp: return resp.read() diff --git a/item_fears.py b/item_fears.py index 56ef886..10dee44 100644 --- a/item_fears.py +++ b/item_fears.py @@ -20,6 +20,7 @@ from http_utils import http_json ITEMS_META = DATA / "items_meta.json" HERO_ABILITIES = DATA / "hero_abilities.json" HERO_FEAR_OVERRIDES = DATA / "hero_fear_overrides.json" +ITEM_COUNTER_STATS = DATA / "item_counter_stats.json" OUT = DATA / "hero_item_fears.json" ABILITIES_URL = ( "https://raw.githubusercontent.com/odota/dotaconstants/master/build/abilities.json" @@ -243,12 +244,51 @@ def load_hero_overrides() -> dict[str, dict]: return {str(k): v for k, v in heroes.items() if isinstance(v, dict)} +def load_counter_stats() -> tuple[dict, dict[str, dict[str, dict]]]: + """Load optional adjusted OpenDota evidence indexed by hero and item.""" + if not ITEM_COUNTER_STATS.is_file(): + return {}, {} + try: + raw = load_json(ITEM_COUNTER_STATS) + except (OSError, json.JSONDecodeError): + return {}, {} + indexed: dict[str, dict[str, dict]] = {} + for hero_key, rows in (raw.get("by_hero") or {}).items(): + if not isinstance(rows, list): + continue + indexed[str(hero_key)] = { + str(row["item"]): row + for row in rows + if isinstance(row, dict) and row.get("item") + } + return dict(raw.get("meta") or {}), indexed + + +def evidence_adjustment(evidence: dict | None) -> int: + """Return a deliberately small corroboration bonus/penalty.""" + if not evidence: + return 0 + games = int(evidence.get("games") or 0) + if games < 100: + return 0 + buy_lift_pp = float(evidence.get("buy_lift") or 0) * 100 + win_delta_pp = float(evidence.get("win_delta") or 0) * 100 + # Mixed signs are inconclusive. Requiring agreement avoids promoting generic + # expensive winner items based on conditional win rate alone. + if buy_lift_pp * win_delta_pp <= 0: + return 0 + raw = 0.6 * buy_lift_pp + 0.4 * win_delta_pp + reliability = min(1.0, games / 500) + return round(max(-6.0, min(6.0, raw)) * reliability) + + def fears_for_hero( hero_key: str, summary: dict, by_tag: dict[str, list[dict]], items_by_key: dict[str, dict], overrides: dict[str, dict], + counter_stats: dict[str, dict[str, dict]], top_n: int, ) -> list[dict]: need = needed_tags(summary) @@ -304,12 +344,21 @@ def fears_for_hero( ranked = [] for row in acc.values(): + evidence = (counter_stats.get(hero_key) or {}).get(row["item"]["key"]) sc, entry = score_item( row["item"], row["matched"], row["reasons"], - bonus=int(row.get("bonus") or 0), + bonus=int(row.get("bonus") or 0) + evidence_adjustment(evidence), ) + if evidence: + entry["stats"] = { + "games": int(evidence.get("games") or 0), + "purchase_rate": float(evidence.get("buy_rate") or 0), + "win_rate": float(evidence.get("win_rate") or 0), + "purchase_lift": float(evidence.get("buy_lift") or 0), + "win_delta": float(evidence.get("win_delta") or 0), + } ranked.append((sc, entry)) ranked.sort(key=lambda t: (-t[0], t[1]["item"])) @@ -333,6 +382,7 @@ def main() -> None: items_meta = load_json(ITEMS_META) abilities_db = load_json(HERO_ABILITIES) + counter_meta, counter_stats = load_counter_stats() items = items_meta.get("items") or {} items_by_key = index_items_by_key(items) overrides = load_hero_overrides() @@ -350,7 +400,7 @@ def main() -> None: tags = list(h.get("tags") or []) summary = hero_summary(key, abilities_db, tags, unit_target_keys) by_hero[key] = fears_for_hero( - key, summary, by_tag, items_by_key, overrides, args.top + key, summary, by_tag, items_by_key, overrides, counter_stats, args.top ) items_out = { @@ -369,6 +419,17 @@ def main() -> None: "top_n": args.top, "heroes": len(by_hero), "overrides": str(HERO_FEAR_OVERRIDES.relative_to(DATA.parent)).replace("\\", "/"), + "counter_stats": ( + { + "path": str(ITEM_COUNTER_STATS.relative_to(DATA.parent)).replace("\\", "/"), + "source": counter_meta.get("source"), + "fetched_at": counter_meta.get("fetched_at"), + "method": counter_meta.get("method"), + "caveat": counter_meta.get("caveat"), + } + if counter_meta + else None + ), }, "by_hero": by_hero, "items": items_out, diff --git a/mechanic_tags.py b/mechanic_tags.py new file mode 100644 index 0000000..23033fe --- /dev/null +++ b/mechanic_tags.py @@ -0,0 +1,284 @@ +"""Shared "applies effect" mechanic tags for items and abilities (Web query page). + +These tags mean the skill/item *applies* the effect (e.g. applies silence), +NOT that the effect itself is dispellable (see ability ``dispellable`` field). +""" + +from __future__ import annotations + +import re + +# Sidebar / query page order (驱散 first, then control, then other). +QUERY_MECHANIC_ORDER: list[str] = [ + "basic_dispel", + "strong_dispel", + "root", + "disarm", + "silence", + "mute", + "stun", + "hex", + "break", + "sleep", + "fear", + "taunt", + "blind", + "leash", + "invis", + "ethereal", + "cyclone", +] + +QUERY_MECHANIC_LABELS: dict[str, str] = { + "basic_dispel": "弱驱散", + "strong_dispel": "强驱散", + "root": "缠绕", + "disarm": "缴械", + "silence": "沉默", + "mute": "锁闭", + "stun": "眩晕", + "hex": "妖术", + "break": "破坏", + "sleep": "睡眠", + "fear": "恐惧", + "taunt": "嘲讽", + "blind": "致盲", + "leash": "束缚", + "invis": "隐身", + "ethereal": "虚无", + "cyclone": "吹风", +} + +# Short gameplay blurbs for the mechanics query page (ZH). +QUERY_MECHANIC_BLURBS: dict[str, str] = { + "basic_dispel": "基础驱散(弱驱散)。可移除多数可用弱驱散清除的增益/减益;无法驱散标注为「仅强驱散」的效果。", + "strong_dispel": "强驱散。可移除弱驱散清不掉、但可被强驱散清除的效果(如部分强控、特殊增益)。", + "root": "缠绕 / 定身。目标无法移动,通常仍可攻击与施法;多数情况下也无法使用位移技能。", + "disarm": "缴械。目标无法进行普通攻击,仍可移动与施法。", + "silence": "沉默。目标无法施放技能,仍可移动与普通攻击;物品主动一般不受影响。", + "mute": "锁闭。目标无法使用物品主动技能;通常仍可移动、攻击与施放英雄技能。", + "stun": "眩晕。目标完全无法行动(不能移动、攻击或施法),是最直接的硬控之一。", + "hex": "妖术(变羊等)。强制变形并大幅限制行动,通常伴随沉默、锁闭与缴械等组合限制。", + "break": "破坏。使目标的被动技能暂时失效(不移除身上已有 buff,但被动效果不再生效)。", + "sleep": "睡眠。目标陷入沉睡,无法行动;受到伤害时通常会醒来(具体以技能为准)。", + "fear": "恐惧。目标被迫失控移动(通常远离来源),期间难以自主操作。", + "taunt": "嘲讽。强制目标攻击施法者(或指定单位),暂时无法按自己的意愿选目标。", + "blind": "致盲。普通攻击有几率(或必定)落空,不直接禁止攻击指令本身。", + "leash": "束缚。限制目标使用位移/传送类技能(如闪烁、强制位移道具),与缠绕的「不能走路」不同。", + "invis": "隐身。对无真实视域的敌人不可见;攻击或部分行为常会显形(以技能/物品为准)。", + "ethereal": "虚无(灵体)。通常大幅降低魔法抗性、无法普通攻击,且自身常变为对物理攻击免疫或特殊交互。", + "cyclone": "吹风 / 驱逐。目标被卷入气旋或移出战场一段时间,期间通常无法行动,落地后恢复。", +} + +QUERY_MECHANIC_GROUPS: list[tuple[str, list[str]]] = [ + ("驱散", ["basic_dispel", "strong_dispel"]), + ( + "控制", + [ + "root", + "disarm", + "silence", + "mute", + "stun", + "hex", + "break", + "sleep", + "fear", + "taunt", + "blind", + "leash", + ], + ), + ("其他", ["invis", "ethereal", "cyclone"]), +] + +DISPEL_TYPE_RE = re.compile( + r"(?:驱散类型|Dispel\s*Type)\s*[::]\s*([^<\n]+)", + re.I, +) + +# Phrases that describe "this buff/debuff can be dispelled" — not "applies dispel". +DISPELLABLE_NOISE_RE = re.compile( + r"可被.{0,8}驱散|可以被.{0,8}驱散|无法被驱散|不可驱散|" + r"dispellable|undispellable|cannot be dispelled|can be dispelled|" + r"strong dispels? only|仅强驱散|只有强驱散", + re.I, +) + +APPLIES_BASIC_DISPEL_RE = re.compile( + r"(?:施加|应用|造成|提供|进行|立即).{0,12}(?:基础)?驱散|" + r"applies?\s+(?:a\s+)?(?:basic\s+)?dispel|" + r"\bpurge\b|阻止敌方|Inhibit", + re.I, +) +APPLIES_STRONG_DISPEL_RE = re.compile( + r"(?:施加|应用|造成|提供|进行|立即).{0,12}强驱散|" + r"applies?\s+(?:a\s+)?strong\s+dispel", + re.I, +) + +# Phrases that mention an effect without applying it to enemies. +SILENCE_INTERACTION_RE = re.compile( + r"沉默术士|" + r"目标被沉默时|被沉默的目标|被沉默单位|被沉默时|" + r"while silenced|when silenced|silenced target|silenced unit", + re.I, +) +STUN_INTERACTION_RE = re.compile( + r"只要眩晕敌人|自身被眩晕|多个眩晕的效果|when you stun an enemy", + re.I, +) +DISARM_SELF_RE = re.compile( + r"他被缴械|自身被缴械|被缴械,但可以", + re.I, +) +ALLY_SELF_DEBUFF_RE = re.compile( + r"被锁闭、沉默和缴械|无法成为目标,被锁闭", + re.I, +) +ABILITY_CATALOG_RE = re.compile( + r"拥有[^。]{0,120}技能", + re.I, +) + + +def _strip_interaction_noise(text: str) -> str: + if not text: + return "" + t = text + t = SILENCE_INTERACTION_RE.sub(" ", t) + t = STUN_INTERACTION_RE.sub(" ", t) + t = DISARM_SELF_RE.sub(" ", t) + t = ALLY_SELF_DEBUFF_RE.sub(" ", t) + return t + + +def _strip_dispellable_noise(text: str) -> str: + """Remove 'is dispellable' clauses and dispel-type metadata lines.""" + if not text: + return "" + t = DISPEL_TYPE_RE.sub(" ", text) + return DISPELLABLE_NOISE_RE.sub(" ", t) + + +def extract_dispel_tags(text: str) -> set[str]: + """Tags for abilities/items that *apply* basic or strong dispel.""" + tags: set[str] = set() + if not text: + return tags + cleaned = _strip_dispellable_noise(text) + if APPLIES_STRONG_DISPEL_RE.search(cleaned): + tags.add("strong_dispel") + if APPLIES_BASIC_DISPEL_RE.search(cleaned): + # Strong takes precedence when both fire on the same "强驱散" phrase. + if "strong_dispel" not in tags and "强驱散" not in cleaned and "strong dispel" not in cleaned.lower(): + tags.add("basic_dispel") + elif "strong_dispel" not in tags: + # "施加驱散" without 强 → basic; with 强 already handled above. + if not re.search(r"强驱散|strong\s+dispel", cleaned, re.I): + tags.add("basic_dispel") + return tags + + +def apply_mechanic_tags(blob: str, *, key: str = "") -> set[str]: + """Extract QUERY mechanic tags from Chinese/English description text.""" + del key # reserved for key-specific whitelist callers + tags: set[str] = set() + if not blob: + return tags + cleaned = _strip_interaction_noise(blob) + tags |= extract_dispel_tags(cleaned) + + root_blob = ABILITY_CATALOG_RE.sub(" ", cleaned) + + if re.search(r"\broot(?:s|ed)?\b|缠绕|定身", root_blob, re.I): + tags.add("root") + if re.search(r"\bdisarm(?:s|ed)?\b|缴械", cleaned, re.I): + tags.add("disarm") + if re.search(r"\bsilence[sd]?\b|沉默", cleaned, re.I): + tags.add("silence") + if re.search(r"\bmute[sd]?\b|锁闭", cleaned, re.I): + tags.add("mute") + if re.search(r"\bstun(?:s|ned)?\b|眩晕", cleaned, re.I): + tags.add("stun") + if re.search(r"\bhex(?:es|ed)?\b|变羊|妖术", cleaned, re.I): + tags.add("hex") + if re.search( + r"(?:施加|造成|应用).{0,6}破坏|" + r"\bbreaks?\s+passives?\b|" + r"(?<![A-Za-z_])Break(?![A-Za-z_])|" + r"破坏会禁用|禁用被动", + cleaned, + ): + tags.add("break") + if re.search(r"\bsleep(?:s|ing|t)?\b|睡眠|催眠|梦魇", cleaned, re.I): + tags.add("sleep") + if re.search(r"\bfear(?:s|ed|ing)?\b|恐惧|恐慌", cleaned, re.I): + tags.add("fear") + if re.search(r"\btaunt(?:s|ed|ing)?\b|嘲讽", cleaned, re.I): + tags.add("taunt") + if re.search(r"\bblind(?:s|ed|ing)?\b|致盲|失明", cleaned, re.I): + tags.add("blind") + if re.search(r"\bleash(?:es|ed|ing)?\b|束缚|拴住|无法使用移动类", cleaned, re.I): + tags.add("leash") + if re.search( + r"\bethereal\b|虚无|灵体化|变为虚无|幽魂形态|幽灵形态", + cleaned, + re.I, + ): + tags.add("ethereal") + if re.search( + r"\bcyclone[sd]?\b|\bbanish(?:es|ed|ing)?\b|吹起|吹风|卷入气流|" + r"龙卷风|气旋|驱逐出", + cleaned, + re.I, + ): + tags.add("cyclone") + # Applies invisibility (not detect/break invis). + invis_blob = re.sub( + r"破隐|反隐|真实视域|看见隐身|对隐身|true\s*sight|" + r"break(?:s|ing)?\s+invis|detect(?:s|ing)?\s+invis|" + r"reveal(?:s|ed)?\s+invis", + " ", + cleaned, + flags=re.I, + ) + if re.search( + r"隐身|invisible|invisibility|渐隐|shadow\s*walk|fade\s*time", + invis_blob, + re.I, + ): + tags.add("invis") + return tags + + +def merge_tag_overrides( + auto: list[str] | set[str], + override_row: dict | None, + order: list[str], +) -> list[str]: + """Merge add/remove override onto auto tags; keep ``order`` then extras.""" + s = set(auto) + row = override_row or {} + for t in row.get("add") or []: + if isinstance(t, str): + s.add(t) + for t in row.get("remove") or []: + if isinstance(t, str): + s.discard(t) + ordered = [t for t in order if t in s] + extras = sorted(s - set(order)) + return ordered + extras + + +def mechanic_query_payload() -> dict: + """Slim dict for serve_relations / static data.json.""" + return { + "order": list(QUERY_MECHANIC_ORDER), + "labels": dict(QUERY_MECHANIC_LABELS), + "blurbs": dict(QUERY_MECHANIC_BLURBS), + "groups": [ + {"label": label, "keys": list(keys)} + for label, keys in QUERY_MECHANIC_GROUPS + ], + } diff --git a/overlay.py b/overlay.py index 58f0496..fc78580 100644 --- a/overlay.py +++ b/overlay.py @@ -1,7 +1,7 @@ -"""Transparent click-through overlay: role tags + Top-3 recommend badges. +"""Transparent click-through overlay: role tags + 克/搭/补 marks + analysis bar. -Runs a Tk root on a background thread. DraftSession calls set_roster() and -set_grid_marks(); geometry uses the same relative coords as recognition. +Runs a Tk root on a background thread. DraftSession calls set_roster(), +set_grid_marks(), and set_analysis(); geometry uses relative coords. """ from __future__ import annotations @@ -10,7 +10,6 @@ import json import sys import threading import tkinter as tk -from pathlib import Path import mss @@ -30,8 +29,18 @@ ROLE_ORDER = [ ] CHROMA = "#ff00ff" -DEFAULT_REC_COLOR = "#2ec4b6" -DEFAULT_REC_TEXT = "#0b1220" +DEFAULT_COUNTER_COLOR = "#2ec4b6" +DEFAULT_SYNERGY_COLOR = "#e9a825" +DEFAULT_FILL_COLOR = "#9b7ebd" +DEFAULT_MARK_TEXT = "#0b1220" +DEFAULT_ANALYSIS_BG = "#1a2332" +DEFAULT_ANALYSIS_FG = "#e8eef7" +LABEL_ORDER = ("克", "搭", "补") +LABEL_COLORS = { + "克": "counter", + "搭": "synergy", + "补": "fill", +} def _load_roles_by_key() -> dict[str, list[str]]: @@ -75,12 +84,21 @@ class DraftOverlay: self.icon_gap_rel = float(o.get("icon_gap_rel", 0.002)) self.mark_size_rel = float(o.get("mark_size_rel", 0.018)) self.mark_pad_rel = float(o.get("mark_pad_rel", 0.004)) - self.rec_color = str(o.get("rec_color", DEFAULT_REC_COLOR)) - self.rec_text = str(o.get("rec_text_color", DEFAULT_REC_TEXT)) + self.mark_gap_rel = float(o.get("mark_gap_rel", 0.002)) + self.counter_color = str(o.get("counter_color", o.get("rec_color", DEFAULT_COUNTER_COLOR))) + self.synergy_color = str(o.get("synergy_color", DEFAULT_SYNERGY_COLOR)) + self.fill_color = str(o.get("fill_color", DEFAULT_FILL_COLOR)) + self.mark_text = str(o.get("mark_text_color", o.get("rec_text_color", DEFAULT_MARK_TEXT))) + self.analysis_y_rel = float(o.get("analysis_y_rel", 0.12)) + self.analysis_h_rel = float(o.get("analysis_h_rel", 0.028)) + self.analysis_font_rel = float(o.get("analysis_font_rel", 0.014)) + self.analysis_bg = str(o.get("analysis_bg", DEFAULT_ANALYSIS_BG)) + self.analysis_fg = str(o.get("analysis_fg", DEFAULT_ANALYSIS_FG)) self.roles_by_key = _load_roles_by_key() self._roster: dict[int, str] = {} self._cells: dict[str, dict] = {} - self._marks: dict[str, int] = {} + self._marks: dict[str, list[str]] = {} + self._analysis = "" self._ready = threading.Event() self._closed = False self._root: tk.Tk | None = None @@ -91,6 +109,14 @@ class DraftOverlay: self._thread.start() self._ready.wait(timeout=5.0) + def _label_fill(self, label: str) -> str: + kind = LABEL_COLORS.get(label) + if kind == "synergy": + return self.synergy_color + if kind == "fill": + return self.fill_color + return self.counter_color + def _run(self) -> None: sw, sh = _primary_monitor_size() root = tk.Tk() @@ -160,23 +186,47 @@ class DraftOverlay: self._roster = dict(roster) self._schedule_redraw() + def set_analysis(self, text: str | None) -> None: + """Short lineup analysis banner (empty clears).""" + value = (text or "").strip() + if value == self._analysis: + return + self._analysis = value + self._schedule_redraw() + def set_grid_marks( self, cells: dict[str, dict] | None, - marks: dict[str, int] | list[dict] | None, + marks: dict[str, list[str] | int] | list[dict] | None, ) -> None: - """Cyan Top-3 badges on hero-grid cells. marks: key->rank or suggest_top list.""" + """克/搭/补 badges on hero-grid cells. + + marks: key->labels list, key->legacy rank int, or suggest_marks list. + """ cells = {str(k): dict(v) for k, v in (cells or {}).items()} - parsed: dict[str, int] = {} + parsed: dict[str, list[str]] = {} if isinstance(marks, list): for item in marks: key = item.get("key") - rank = item.get("rank") - if key and rank: - parsed[str(key)] = int(rank) + if not key: + continue + labels = item.get("labels") + if labels: + parsed[str(key)] = [str(x) for x in labels if x in LABEL_ORDER] + elif item.get("rank"): + parsed[str(key)] = ["克"] elif marks: - for key, rank in marks.items(): - parsed[str(key)] = int(rank) + for key, val in marks.items(): + if isinstance(val, (list, tuple)): + labs = [str(x) for x in val if x in LABEL_ORDER] + elif isinstance(val, int) and val > 0: + labs = ["克"] + elif isinstance(val, str) and val in LABEL_ORDER: + labs = [val] + else: + labs = [] + if labs: + parsed[str(key)] = labs if cells == self._cells and parsed == self._marks: return self._cells = cells @@ -210,6 +260,7 @@ class DraftOverlay: if self._canvas is not None: self._canvas.delete("all") self._photos.clear() + self._analysis = "" root.withdraw() try: @@ -252,6 +303,26 @@ class DraftOverlay: gap = max(0, int(round(self.icon_gap_rel * sh))) y_gap = int(round(self.y_gap_rel * sh)) + if self._analysis: + bar_h = max(18, int(round(self.analysis_h_rel * sh))) + bar_y = int(round(self.analysis_y_rel * sh)) + font_size = max(10, int(round(self.analysis_font_rel * sh))) + pad_x = max(12, int(round(0.01 * sw))) + # Estimate text width roughly; keep banner centered and readable. + approx_w = min(sw - 2 * pad_x, max(200, int(len(self._analysis) * font_size * 0.95) + 2 * pad_x)) + x0 = (sw - approx_w) // 2 + y0 = bar_y + x1 = x0 + approx_w + y1 = y0 + bar_h + canvas.create_rectangle(x0, y0, x1, y1, fill=self.analysis_bg, outline=self.analysis_bg) + canvas.create_text( + (x0 + x1) / 2, + (y0 + y1) / 2, + text=self._analysis, + fill=self.analysis_fg, + font=("Microsoft YaHei UI", font_size, "bold"), + ) + for slot in self.cfg.get("slots") or []: idx = int(slot["index"]) hero = self._roster.get(idx) @@ -275,22 +346,30 @@ class DraftOverlay: mark = max(12, int(round(self.mark_size_rel * sh))) pad = max(2, int(round(self.mark_pad_rel * sh))) + mark_gap = max(1, int(round(self.mark_gap_rel * sh))) font_size = max(8, int(round(mark * 0.55))) - for key, rank in self._marks.items(): + for key, labels in self._marks.items(): cell = self._cells.get(key) if not cell: continue + ordered = [lab for lab in LABEL_ORDER if lab in labels] + if not ordered: + continue x0 = int(cell["x0"]) y0 = int(cell["y0"]) x1 = x0 + pad y1 = y0 + pad - x2 = x1 + mark - y2 = y1 + mark - canvas.create_rectangle(x1, y1, x2, y2, fill=self.rec_color, outline=self.rec_color) - canvas.create_text( - (x1 + x2) / 2, - (y1 + y2) / 2, - text=str(rank), - fill=self.rec_text, - font=("Segoe UI", font_size, "bold"), - ) + for i, lab in enumerate(ordered): + bx1 = x1 + i * (mark + mark_gap) + by1 = y1 + bx2 = bx1 + mark + by2 = by1 + mark + fill = self._label_fill(lab) + canvas.create_rectangle(bx1, by1, bx2, by2, fill=fill, outline=fill) + canvas.create_text( + (bx1 + bx2) / 2, + (by1 + by2) / 2, + text=lab, + fill=self.mark_text, + font=("Microsoft YaHei UI", font_size, "bold"), + ) diff --git a/recommend.py b/recommend.py index 24d4560..d2b3f5b 100644 --- a/recommend.py +++ b/recommend.py @@ -1,19 +1,28 @@ -"""Draft suggestions from qualitative hero relations. +"""Draft suggestions from qualitative hero relations + lineup archetypes. -Score candidates for the player's role-queue position using: - +1 per enemy the candidate counters - -1 per enemy that counters the candidate - +1 per already-locked ally synergy +Mark available heroes with 克 / 搭 / 补: + 克 — relation counters, push/global answers, punish enemy gaps + 搭 — synergy with locked allies + 补 — fill ally tag gaps -Data: data/relations.json (not OpenDota winrates). +Role-queue filters by position tags; otherwise all heroes are candidates. +Also returns a short analysis string and per-mark reasons (no AI). + +Data: data/relations.json + draft_archetypes rules. """ from __future__ import annotations -from pathlib import Path - -from common import ROOT +from draft_archetypes import ( + answer_for_candidate, + collect_reasons, + detect_archetypes, + detect_gaps, + format_analysis, + tag_profile, +) from grid import hero_table +from hero_tags import tags_for_hero from relations import DEFAULT_RELATIONS, indexes, load_relations DEFAULT_ROLE_TAGS = { @@ -24,12 +33,31 @@ DEFAULT_ROLE_TAGS = { "5": ["Support"], } +# Soft boosts when candidate tags address a prominent enemy profile face. +# Never creates marks alone. +_PROFILE_BOOSTS: dict[str, tuple[str, ...]] = { + "爆发": ("耐久", "逃生"), + "推进": ("控制", "先手"), + "先手": ("逃生", "控制"), + "控制": ("逃生", "耐久"), + "核心": ("控制", "先手"), + "辅助": ("核心", "先手"), +} -def _maps() -> tuple[dict[str, str], dict[str, list[str]]]: +_SOFT_BOOST = 0.25 +_ARCH_BOOST = 0.5 +_GAP_BOOST = 0.35 + + +def _maps() -> tuple[dict[str, str], dict[str, list[str]], dict[str, list[str]]]: table = hero_table() names = {h["key"]: h["name_loc"] for h in table} roles = {h["key"]: list(h.get("roles") or []) for h in table} - return names, roles + tags = { + h["key"]: list(h.get("tags") or []) or tags_for_hero(h["key"], h.get("roles")) + for h in table + } + return names, roles, tags def enemy_keys(confirmed: dict[int, str], self_team: str | None) -> list[str]: @@ -65,9 +93,10 @@ def candidates_for_position( roles_by_key: dict[str, list[str]], role_tags: dict | None = None, ) -> list[str]: - tags_map = role_tags or DEFAULT_ROLE_TAGS + """Role-queue filter. position=None means all heroes (non-role queue).""" if position is None: - return [] + return list(roles_by_key.keys()) + tags_map = role_tags or DEFAULT_ROLE_TAGS wanted = set(tags_map.get(str(position)) or tags_map.get(position) or []) if not wanted: return [] @@ -78,40 +107,101 @@ def candidates_for_position( return out -def suggest_top( +def enemy_profile(enemies: list[str], tags_by_key: dict[str, list[str]] | None = None) -> dict[str, int]: + """Count Chinese draft tags across locked enemies.""" + if tags_by_key is None: + _, _, tags_by_key = _maps() + return tag_profile(enemies, tags_by_key) + + +def _profile_soft_boost(cand_tags: list[str], profile: dict[str, int]) -> float: + if not profile or not cand_tags: + return 0.0 + cand = set(cand_tags) + boost = 0.0 + for face, n in sorted(profile.items(), key=lambda kv: (-kv[1], kv[0])): + if n <= 0: + continue + wanted = _PROFILE_BOOSTS.get(face) + if not wanted: + continue + if cand.intersection(wanted): + boost += _SOFT_BOOST * n + return boost + + +def _empty_result() -> dict: + return { + "enemy_profile": {}, + "ally_profile": {}, + "enemy_archetypes": [], + "enemy_gaps": [], + "ally_gaps": [], + "analysis": "", + "marks": [], + } + + +def suggest_marks( *, position: int | None, enemies: list[str], allies: list[str] | None = None, exclude: set[str] | list[str], relations: dict | None = None, - top_n: int = 3, + top_n: int | None = 0, role_tags: dict | None = None, + min_enemies: int = 1, + min_heroes_for_gaps: int = 2, + archetypes_enabled: bool = True, **_ignored, -) -> list[dict]: - """Return Top-N picks from qualitative counters / synergies. +) -> dict: + """Return 克/搭/补 marks plus lineup analysis. - Extra kwargs (matchups, synergies, min_games) accepted and ignored for - backward compatibility with older call sites. + Requires at least ``min_enemies`` locked enemies. ``top_n`` None/<=0 means + no truncation. ``position`` None = non-role queue (all heroes). """ - if position is None: - return [] allies = list(allies or []) enemies = list(enemies or []) - if not enemies and not allies: - return [] - rel = relations if relations is not None else load_relations() - if not rel.get("counters") and not rel.get("synergies"): - return [] + if len(enemies) < max(1, int(min_enemies)): + return _empty_result() - names, roles_by_key = _maps() - counters_of, countered_by, synergies_of = indexes(rel) + rel = relations if relations is not None else load_relations() + has_rel = bool(rel.get("counters") or rel.get("synergies")) + if not has_rel and not archetypes_enabled: + return _empty_result() + + names, roles_by_key, tags_by_key = _maps() + counters_of, countered_by, synergies_of = indexes(rel) if has_rel else ({}, {}, {}) exclude_set = {e for e in exclude if e} - scored: list[tuple[int, str, dict]] = [] + e_profile = tag_profile(enemies, tags_by_key) + a_profile = tag_profile(allies, tags_by_key) + + archetypes: list[str] = [] + enemy_gaps: list[str] = [] + ally_gaps: list[str] = [] + if archetypes_enabled: + archetypes = detect_archetypes(enemies, tags_by_key) + enemy_gaps = detect_gaps( + e_profile, hero_count=len(enemies), min_heroes=min_heroes_for_gaps + ) + ally_gaps = detect_gaps( + a_profile, hero_count=len(allies), min_heroes=min_heroes_for_gaps + ) + + analysis = format_analysis( + archetypes=archetypes, + enemy_gaps=enemy_gaps, + ally_gaps=ally_gaps, + ally_count=len(allies), + ) + + scored: list[tuple[float, str, dict]] = [] for cand in candidates_for_position(position, roles_by_key=roles_by_key, role_tags=role_tags): if cand in exclude_set: continue + cand_tags = tags_by_key.get(cand) or [] beats = [] beaten_by = [] with_allies = [] @@ -126,26 +216,99 @@ def suggest_top( for edge in synergies_of.get(cand) or []: if edge["key"] == ak: with_allies.append({"ally": ak, "reason": edge.get("reason") or ""}) - score = len(beats) - len(beaten_by) + len(with_allies) - if score == 0 and not beats and not beaten_by and not with_allies: + + arch_hits, punish_gaps, fill_gaps = answer_for_candidate( + cand, + cand_tags, + archetypes=archetypes, + enemy_gaps=enemy_gaps, + ally_gaps=ally_gaps, + ) + + labels: list[str] = [] + if beats or arch_hits or punish_gaps: + labels.append("克") + if with_allies: + labels.append("搭") + if fill_gaps: + labels.append("补") + if not labels: continue + + reasons = collect_reasons( + names=names, + beats=beats, + with_allies=with_allies, + archetype_hits=arch_hits, + punish_gaps=punish_gaps, + fill_gaps=fill_gaps, + ) + soft = _profile_soft_boost(cand_tags, e_profile) + score = ( + float(len(beats) - len(beaten_by) + len(with_allies)) + + soft + + _ARCH_BOOST * len(arch_hits) + + _GAP_BOOST * (len(punish_gaps) + len(fill_gaps)) + ) scored.append((score, cand, { + "labels": labels, "beats": beats, "beaten_by": beaten_by, "with": with_allies, + "reasons": reasons, + "score": round(score, 3), })) scored.sort(key=lambda t: (t[0], t[1]), reverse=True) - out = [] - for rank, (score, key, detail) in enumerate(scored[: max(0, top_n)], start=1): - out.append({ + limit = None if top_n is None or int(top_n) <= 0 else int(top_n) + sliced = scored if limit is None else scored[:limit] + marks = [] + for rank, (_score, key, detail) in enumerate(sliced, start=1): + marks.append({ "key": key, "name_loc": names.get(key, key), "rank": rank, - "score": score, **detail, }) - return out + return { + "enemy_profile": e_profile, + "ally_profile": a_profile, + "enemy_archetypes": archetypes, + "enemy_gaps": enemy_gaps, + "ally_gaps": ally_gaps, + "analysis": analysis, + "marks": marks, + } + + +def suggest_top( + *, + position: int | None, + enemies: list[str], + allies: list[str] | None = None, + exclude: set[str] | list[str], + relations: dict | None = None, + top_n: int | None = 0, + role_tags: dict | None = None, + min_enemies: int = 1, + min_heroes_for_gaps: int = 2, + archetypes_enabled: bool = True, + **_ignored, +) -> list[dict]: + """Compatibility wrapper: return mark list from ``suggest_marks``.""" + return suggest_marks( + position=position, + enemies=enemies, + allies=allies, + exclude=exclude, + relations=relations, + top_n=top_n, + role_tags=role_tags, + min_enemies=min_enemies, + min_heroes_for_gaps=min_heroes_for_gaps, + archetypes_enabled=archetypes_enabled, + **_ignored, + )["marks"] __all__ = [ @@ -154,6 +317,8 @@ __all__ = [ "ally_keys", "candidates_for_position", "enemy_keys", + "enemy_profile", "load_relations", + "suggest_marks", "suggest_top", ] diff --git a/refresh_web.py b/refresh_web.py index 54bba04..36837d3 100644 --- a/refresh_web.py +++ b/refresh_web.py @@ -161,6 +161,7 @@ def run_weekly(*, dry_run: bool = False) -> None: run_script("fetch_stratz_meta.py", dry_run=dry_run) run_script("fetch_hero_items.py", dry_run=dry_run) run_script("fetch_items_meta.py", dry_run=dry_run) + run_script("fetch_item_counter_stats.py", "--soft-fail", dry_run=dry_run) run_script("item_fears.py", dry_run=dry_run) diff --git a/serve_relations.py b/serve_relations.py index 42b01d2..73e2f75 100644 --- a/serve_relations.py +++ b/serve_relations.py @@ -1,11 +1,11 @@ -"""Local read-only preview for qualitative hero relations. +"""Local dev server for the Climperor web site (web/relations/). Usage: python serve_relations.py python serve_relations.py --port 8765 -Select a hero to see 克制 / 被克制 / 搭档 highlights and feared items. -Edit data/relations.json directly, then refresh the page. +Hero relations, rankings, streamers, mechanics, items, patches — read-only browser UI. +Edit data/*.json directly, then refresh the page. """ from __future__ import annotations @@ -27,23 +27,34 @@ from common import ( HERO_PORTRAITS, ITEM_CAT_ICONS, ITEM_ICONS, + RANK_ICONS, ROOT, + STREAMER_AVATARS, + STREAMER_VIDEOS, TEMPLATES_CDN, UI_ICONS, ) from grid import ATTR_ORDER, hero_table from hero_tags import TAG_ORDER, tags_for_hero from http_utils import http_bytes +from mechanic_tags import QUERY_MECHANIC_ORDER, mechanic_query_payload from relations import DEFAULT_RELATIONS, load_relations WEB_DIR = ROOT / "web" / "relations" GRID_ORDER_PATH = ROOT / "data" / "hero_grid_order.json" HERO_ITEMS_PATH = ROOT / "data" / "hero_items.json" +HERO_STATS_PATH = ROOT / "data" / "hero_stats.json" +HERO_MATCHES_PATH = ROOT / "data" / "hero_matches.json" HERO_ITEM_FEARS_PATH = ROOT / "data" / "hero_item_fears.json" HERO_ABILITIES_PATH = ROOT / "data" / "hero_abilities.json" ITEM_SHOP_PATH = ROOT / "data" / "item_shop.json" ITEMS_META_PATH = ROOT / "data" / "items_meta.json" PATCHES_PATH = ROOT / "data" / "patches.json" +LEADERBOARDS_PATH = ROOT / "data" / "leaderboards.json" +PRO_MATCHES_PATH = ROOT / "data" / "pro_matches.json" +STREAMERS_PATH = ROOT / "data" / "streamers.json" +STRATZ_HERO_META_PATH = ROOT / "data" / "stratz_hero_meta.json" +STRATZ_MATCHUP_TOPS_PATH = ROOT / "data" / "stratz_matchup_tops.json" ATTR_COLS = {"str": 6, "agi": 6, "int": 6, "all": 4} ATTR_LABELS = {"str": "力量", "agi": "敏捷", "int": "智力", "all": "全才"} ABILITY_ICON_URL = ( @@ -71,6 +82,63 @@ def load_hero_items() -> dict: } +def load_hero_matches() -> dict: + """Cached recent matches + builds (see fetch_hero_matches.py). Preview only.""" + empty: dict = {"meta": {}, "items": {}, "by_hero": {}} + if not HERO_MATCHES_PATH.is_file(): + return empty + try: + raw = json.loads(HERO_MATCHES_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return empty + by_hero = raw.get("by_hero") + if not isinstance(by_hero, dict): + by_hero = {} + return { + "meta": dict(raw.get("meta") or {}), + "items": dict(raw.get("items") or {}), + "by_hero": by_hero, + } + + +def load_hero_stats() -> dict: + """Cached OpenDota bracket pick/win (see fetch_hero_stats.py). Preview only.""" + empty: dict = { + "fetched_at": None, + "source": "opendota", + "attribution": "https://www.opendota.com", + "window_days": 7, + "window_note": None, + "window_label_zh": "近约 7 天公开对局", + "brackets": [], + "totals": {}, + "by_hero": {}, + } + if not HERO_STATS_PATH.is_file(): + return empty + try: + raw = json.loads(HERO_STATS_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return empty + try: + window_days = int(raw.get("window_days") or 7) + except (TypeError, ValueError): + window_days = 7 + window_days = max(1, window_days) + label = raw.get("window_label_zh") or f"近约 {window_days} 天公开对局" + return { + "fetched_at": raw.get("fetched_at"), + "source": raw.get("source") or "opendota", + "attribution": raw.get("attribution") or "https://www.opendota.com", + "window_days": window_days, + "window_note": raw.get("window_note"), + "window_label_zh": label, + "brackets": list(raw.get("brackets") or []), + "totals": dict(raw.get("totals") or {}), + "by_hero": dict(raw.get("by_hero") or {}), + } + + def load_hero_item_fears() -> dict: """Cached rule-based items that counter each hero (see item_fears.py).""" empty = {"meta": {}, "items": {}, "by_hero": {}} @@ -155,7 +223,7 @@ def load_patches() -> dict: """Patch list + per-patch details + id lookup (see fetch_patches.py). Returns {patches, lookup, details}; empty-shaped when the file is missing - so the preview UI degrades to "no data" instead of crashing. + so the web UI degrades to "no data" instead of crashing. """ empty = {"patches": [], "lookup": {}, "details": {}} if not PATCHES_PATH.is_file(): @@ -171,8 +239,179 @@ def load_patches() -> dict: } +def load_leaderboards() -> dict: + """Valve Immortal division Top 100 (see fetch_leaderboards.py). Preview only.""" + empty: dict = { + "fetched_at": None, + "source": "valve", + "attribution": "https://www.dota2.com/leaderboards", + "note": None, + "default_region": "china", + "region_order": ["china", "europe", "americas", "se_asia"], + "regions": {}, + } + if not LEADERBOARDS_PATH.is_file(): + return empty + try: + raw = json.loads(LEADERBOARDS_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return empty + if not isinstance(raw, dict): + return empty + regions = raw.get("regions") if isinstance(raw.get("regions"), dict) else {} + order = raw.get("region_order") + if not isinstance(order, list) or not order: + order = list(empty["region_order"]) + return { + "fetched_at": raw.get("fetched_at"), + "source": raw.get("source") or "valve", + "attribution": raw.get("attribution") or empty["attribution"], + "note": raw.get("note"), + "default_region": raw.get("default_region") or "china", + "region_order": [str(x) for x in order], + "regions": regions, + } + + +def load_pro_matches() -> dict: + """Pro-player recent matches (see fetch_pro_matches.py). Preview only.""" + empty: dict = { + "meta": {}, + "items": {}, + "pros": {}, + "by_pro": {}, + "by_hero": {}, + } + if not PRO_MATCHES_PATH.is_file(): + return empty + try: + raw = json.loads(PRO_MATCHES_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return empty + if not isinstance(raw, dict): + return empty + return { + "meta": dict(raw.get("meta") or {}), + "items": dict(raw.get("items") or {}), + "pros": dict(raw.get("pros") or {}), + "by_pro": dict(raw.get("by_pro") or {}), + "by_hero": dict(raw.get("by_hero") or {}), + } + + +def load_streamers() -> dict: + """Manual streamer directory + Douyin profile enrichment (fetch_streamers.py). + + Web「主播」only — do not merge into relations/heroes or recommend. + """ + empty: dict = { + "fetched_at": None, + "source": "manual+douyin", + "platform_meta": { + "douyin": { + "label_zh": "抖音", + "icon": "ui-icon/platform_douyin.png", + } + }, + "streamers": [], + } + if not STREAMERS_PATH.is_file(): + return empty + try: + raw = json.loads(STREAMERS_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return empty + if not isinstance(raw, dict): + return empty + rows = raw.get("streamers") + if not isinstance(rows, list): + rows = [] + platform_meta = raw.get("platform_meta") + if not isinstance(platform_meta, dict): + platform_meta = dict(empty["platform_meta"]) + else: + platform_meta = { + **empty["platform_meta"], + **{k: v for k, v in platform_meta.items() if isinstance(v, dict)}, + } + return { + "fetched_at": raw.get("fetched_at"), + "source": raw.get("source") or "manual+douyin", + "platform_meta": platform_meta, + "streamers": [r for r in rows if isinstance(r, dict)], + } + + +def load_stratz_hero_meta() -> dict: + """STRATZ weekly WR/pick by bracket + positions (see fetch_stratz_meta.py). Web only.""" + empty: dict = { + "fetched_at": None, + "source": "stratz", + "attribution": "https://stratz.com", + "weeks_take": 0, + "window_label_zh": None, + "brackets": [], + "positions": [], + "bracket_position_note": None, + "totals": {}, + "by_hero": {}, + "meta_board": {}, + } + if not STRATZ_HERO_META_PATH.is_file(): + return empty + try: + raw = json.loads(STRATZ_HERO_META_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return empty + if not isinstance(raw, dict): + return empty + return { + "fetched_at": raw.get("fetched_at"), + "source": raw.get("source") or "stratz", + "attribution": raw.get("attribution") or empty["attribution"], + "weeks_take": raw.get("weeks_take") or 0, + "window_label_zh": raw.get("window_label_zh"), + "brackets": list(raw.get("brackets") or []), + "positions": list(raw.get("positions") or []), + "bracket_position_note": raw.get("bracket_position_note"), + "totals": dict(raw.get("totals") or {}), + "by_hero": dict(raw.get("by_hero") or {}), + "meta_board": dict(raw.get("meta_board") or {}), + } + + +def load_stratz_matchup_tops() -> dict: + """STRATZ vs/with top lists per hero (see fetch_stratz_meta.py). Web only.""" + empty: dict = { + "fetched_at": None, + "source": "stratz", + "attribution": "https://stratz.com", + "take": 0, + "match_limit": 0, + "note": None, + "by_hero": {}, + } + if not STRATZ_MATCHUP_TOPS_PATH.is_file(): + return empty + try: + raw = json.loads(STRATZ_MATCHUP_TOPS_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return empty + if not isinstance(raw, dict): + return empty + return { + "fetched_at": raw.get("fetched_at"), + "source": raw.get("source") or "stratz", + "attribution": raw.get("attribution") or empty["attribution"], + "take": raw.get("take") or 0, + "match_limit": raw.get("match_limit") or 0, + "note": raw.get("note"), + "by_hero": dict(raw.get("by_hero") or {}), + } + + def load_hero_abilities() -> dict: - """Slim per-hero abilities / Aghs upgrades / talents for the preview pane.""" + """Slim per-hero abilities / Aghs upgrades / talents for the web hero pane.""" empty = {"meta": {}, "by_hero": {}} if not HERO_ABILITIES_PATH.is_file(): return empty @@ -217,6 +456,11 @@ def load_hero_abilities() -> dict: "mana_cost": ab.get("mana_cost") or "", "specials": specials, "lore_loc": ab.get("lore_loc") or "", + "tags": [ + str(t) + for t in (ab.get("tags") or []) + if isinstance(t, str) and t in QUERY_MECHANIC_ORDER + ], } ) talents_out = [] @@ -246,7 +490,7 @@ def ensure_ability_icon(key: str) -> Path | None: ``innate.png`` / ``talent_tree.png`` are bundled shared badges (not fetched from CDN). Many innate ability keys 404 on Steam ``dota_react/abilities``; - the preview UI falls back to ``innate.png`` when a per-ability icon is missing. + the web UI falls back to ``innate.png`` when a per-ability icon is missing. """ if not key or "/" in key or "\\" in key or ".." in key: return None @@ -349,11 +593,18 @@ def build_payload() -> dict: rel = load_relations() items = load_hero_items() + hero_stats = load_hero_stats() + hero_matches = load_hero_matches() fears = load_hero_item_fears() abilities = load_hero_abilities() shop = load_item_shop() items_meta = load_items_meta_index() patches_data = load_patches() + leaderboards = load_leaderboards() + pro_matches = load_pro_matches() + streamers = load_streamers() + stratz_meta = load_stratz_hero_meta() + stratz_matchups = load_stratz_matchup_tops() # Attach craft graph from shop catalog when available. for key, row in (shop.get("items") or {}).items(): if not isinstance(row, dict): @@ -375,6 +626,8 @@ def build_payload() -> dict: "tag_order": list(TAG_ORDER), "relations": rel, "hero_items": items, + "hero_stats": hero_stats, + "hero_matches": hero_matches, "hero_item_fears": fears, "hero_abilities": abilities, "item_shop": shop, @@ -382,10 +635,20 @@ def build_payload() -> dict: "patches": patches_data.get("patches") or [], "patch_lookup": patches_data.get("lookup") or {}, "patch_details": patches_data.get("details") or {}, + "leaderboards": leaderboards, + "pro_matches": pro_matches, + "streamers": streamers, + "stratz_hero_meta": stratz_meta, + "stratz_matchup_tops": stratz_matchups, + "mechanic_query": mechanic_query_payload(), "meta": { "relations_path": str(DEFAULT_RELATIONS.relative_to(ROOT)).replace("\\", "/"), "grid_order_path": str(GRID_ORDER_PATH.relative_to(ROOT)).replace("\\", "/"), "hero_items_path": str(HERO_ITEMS_PATH.relative_to(ROOT)).replace("\\", "/"), + "hero_stats_path": str(HERO_STATS_PATH.relative_to(ROOT)).replace("\\", "/"), + "hero_matches_path": str(HERO_MATCHES_PATH.relative_to(ROOT)).replace( + "\\", "/" + ), "hero_item_fears_path": str(HERO_ITEM_FEARS_PATH.relative_to(ROOT)).replace( "\\", "/" ), @@ -393,15 +656,33 @@ def build_payload() -> dict: "\\", "/" ), "item_shop_path": str(ITEM_SHOP_PATH.relative_to(ROOT)).replace("\\", "/"), + "leaderboards_path": str(LEADERBOARDS_PATH.relative_to(ROOT)).replace( + "\\", "/" + ), + "streamers_path": str(STREAMERS_PATH.relative_to(ROOT)).replace("\\", "/"), + "stratz_hero_meta_path": str(STRATZ_HERO_META_PATH.relative_to(ROOT)).replace( + "\\", "/" + ), + "stratz_matchup_tops_path": str( + STRATZ_MATCHUP_TOPS_PATH.relative_to(ROOT) + ).replace("\\", "/"), "source": (rel.get("meta") or {}).get("source"), "counters": len(rel.get("counters") or []), "synergies": len(rel.get("synergies") or []), "item_heroes": len(items.get("by_hero") or {}), + "stats_heroes": len(hero_stats.get("by_hero") or {}), + "match_heroes": len(hero_matches.get("by_hero") or {}), "fear_heroes": len(fears.get("by_hero") or {}), "ability_heroes": len(abilities.get("by_hero") or {}), "shop_items": len(shop.get("items") or {}), "patches": len(patches_data.get("patches") or []), "patch_details": len(patches_data.get("details") or {}), + "leaderboard_regions": len(leaderboards.get("regions") or {}), + "pro_match_players": len(pro_matches.get("by_pro") or {}), + "pro_match_heroes": len(pro_matches.get("by_hero") or {}), + "streamers": len(streamers.get("streamers") or []), + "stratz_meta_heroes": len(stratz_meta.get("by_hero") or {}), + "stratz_matchup_heroes": len(stratz_matchups.get("by_hero") or {}), }, } @@ -419,12 +700,69 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Cache-Control", "no-store") self.send_header( "Content-Security-Policy", - "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; " - "script-src 'self'; media-src 'self'", + "default-src 'self'; " + "img-src 'self' data: https://climperor.oss-cn-shanghai.aliyuncs.com; " + "style-src 'self' 'unsafe-inline'; " + "script-src 'self'; " + "media-src 'self' https://climperor.oss-cn-shanghai.aliyuncs.com", ) self.end_headers() self.wfile.write(body) + def _send_file(self, fpath: Path, content_type: str) -> None: + """Stream a file with optional HTTP Range (needed for HTML5 video seek).""" + size = fpath.stat().st_size + range_hdr = self.headers.get("Range") or self.headers.get("range") + start, end = 0, size - 1 + code = 200 + if range_hdr and range_hdr.startswith("bytes=") and size > 0: + spec = range_hdr[len("bytes=") :].strip() + if "," not in spec: + left, _, right = spec.partition("-") + try: + if left == "" and right: + # suffix bytes: bytes=-N + suffix = int(right) + start = max(0, size - suffix) + else: + start = int(left) if left else 0 + end = int(right) if right else size - 1 + if start < 0 or end >= size or start > end: + self.send_response(416) + self.send_header("Content-Range", f"bytes */{size}") + self.end_headers() + return + code = 206 + except ValueError: + start, end = 0, size - 1 + code = 200 + length = end - start + 1 + self.send_response(code) + self.send_header("Content-Type", content_type) + self.send_header("Accept-Ranges", "bytes") + self.send_header("Content-Length", str(length)) + if code == 206: + self.send_header("Content-Range", f"bytes {start}-{end}/{size}") + self.send_header("Cache-Control", "no-store") + self.send_header( + "Content-Security-Policy", + "default-src 'self'; " + "img-src 'self' data: https://climperor.oss-cn-shanghai.aliyuncs.com; " + "style-src 'self' 'unsafe-inline'; " + "script-src 'self'; " + "media-src 'self' https://climperor.oss-cn-shanghai.aliyuncs.com", + ) + self.end_headers() + with fpath.open("rb") as fh: + fh.seek(start) + remaining = length + while remaining > 0: + chunk = fh.read(min(1 << 20, remaining)) + if not chunk: + break + self.wfile.write(chunk) + remaining -= len(chunk) + def _json(self, code: int, obj: object) -> None: self._send(code, json.dumps(obj, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8") @@ -452,9 +790,27 @@ class Handler(BaseHTTPRequestHandler): return self._send(200, fpath.read_bytes(), "image/png") return + if path.startswith("/rank/"): + key = path[len("/rank/") :] + allowed = {f"rank_icon_{i}.png" for i in range(1, 9)} + if key not in allowed: + self._json(400, {"error": "bad rank icon"}) + return + fpath = RANK_ICONS / key + if not fpath.is_file(): + self.send_error(404) + return + self._send(200, fpath.read_bytes(), "image/png") + return if path.startswith("/ui-icon/"): key = path[len("/ui-icon/") :] - if key not in ("cooldown.png", "dota2_logo.png"): + allowed_ui = { + "cooldown.png", + "dota2_logo.png", + "dota2_logo_wordmark.png", + "platform_douyin.png", + } + if key not in allowed_ui: self._json(400, {"error": "bad ui icon"}) return fpath = UI_ICONS / key @@ -463,6 +819,41 @@ class Handler(BaseHTTPRequestHandler): return self._send(200, fpath.read_bytes(), "image/png") return + if path.startswith("/streamer-avatar/"): + key = path[len("/streamer-avatar/") :] + if "/" in key or "\\" in key or ".." in key: + self._json(400, {"error": "bad path"}) + return + if not (key.endswith(".jpg") or key.endswith(".jpeg") or key.endswith(".png") or key.endswith(".webp")): + self._json(400, {"error": "bad path"}) + return + fpath = STREAMER_AVATARS / key + if not fpath.is_file(): + self.send_error(404) + return + ctype = mimetypes.guess_type(key)[0] or "image/jpeg" + self._send(200, fpath.read_bytes(), ctype) + return + if path.startswith("/streamer-video/"): + key = path[len("/streamer-video/") :] + if "/" in key or "\\" in key or ".." in key: + self._json(400, {"error": "bad path"}) + return + if not (key.endswith(".mp4") or key.endswith(".webm")): + self._json(400, {"error": "bad path"}) + return + if key.startswith("_"): + self.send_error(404) + return + fpath = STREAMER_VIDEOS / key + if not fpath.is_file(): + self.send_error(404) + return + ctype = ( + "video/webm" if key.endswith(".webm") else "video/mp4" + ) + self._send_file(fpath, ctype) + return if path.startswith("/portrait/") or path.startswith("/cdn/"): prefix = "/portrait/" if path.startswith("/portrait/") else "/cdn/" key = path[len(prefix) :] @@ -570,7 +961,7 @@ def main() -> None: httpd = ThreadingHTTPServer((args.host, args.port), Handler) url = f"http://{args.host}:{args.port}/" - print(f"relations preview: {url}") + print(f"Climperor web: {url}") print(f"edit JSON then refresh: {DEFAULT_RELATIONS}") if not args.no_browser: try: diff --git a/web/relations/app.js b/web/relations/app.js index 91de84a..aa2419a 100644 --- a/web/relations/app.js +++ b/web/relations/app.js @@ -23,7 +23,7 @@ function staticAssetBase() { function assetUrl(rel) { const clean = String(rel || "").replace(/^\//, ""); const base = staticAssetBase(); - return base ? `${base}/${clean}` : clean; + return base ? `${base}/${clean}` : `/${clean}`; } function attrIconSrc(key) { @@ -50,6 +50,12 @@ function itemCatIconSrc(icon) { return assetUrl(`item-cat/${encodeURIComponent(icon)}`); } +/** Rank medal / filter icon under /rank/<file>. */ +function rankIconSrc(file) { + const name = String(file || "rank_icon_0.png").replace(/^.*[\\/]/, ""); + return assetUrl(`rank/${name}`); +} + function siteVersionLabel() { const v = typeof SITE_VERSION === "string" ? SITE_VERSION.trim() : ""; return v ? `v${v}` : ""; @@ -65,17 +71,29 @@ function renderPatchesSiteVersion() { const state = { data: null, - page: "heroes", // heroes | items | patches + page: "heroes", // heroes | rankings | streamers | trends | mechanics | items | patches selectedKey: null, selectedItemKey: null, /** Hero-page inspect pane: { type:'skill', id } | { type:'item', key } | null */ inspect: null, - detailTab: "skills", // skills | core | fears + detailTab: "skills", // skills | core | fears | trends | matchups | matches | streamers | patches + /** OpenDota / STRATZ bracket key for hero-detail trends: herald…immortal */ + statsBracket: "legend", tagFilters: new Set(), query: "", itemQuery: "", /** Currently selected patch version on the 版本 page; null -> latest. */ selectedPatch: null, + /** Immortal leaderboard region: china | europe | americas | se_asia */ + rankingRegion: "china", + /** Top-level 走势 page medal bracket */ + trendsBracket: "legend", + /** Sort key for trends board: wr_end | pr_end */ + trendsSort: "wr_end", + /** Sort direction for trends board */ + trendsSortDir: "desc", + /** Mechanics page: applies-effect key (basic_dispel, stun, …) */ + mechanicEffect: "basic_dispel", }; const $ = (sel) => document.querySelector(sel); @@ -253,12 +271,270 @@ function itemMeta(id) { return items[String(id)] || null; } +/** Resolve item id → { key, name_loc, … } via match catalog, hero_items, shop. */ +let _itemIdIndex = null; +function itemMetaFromId(id) { + if (id == null) return null; + const sid = String(id); + const fromMatches = (state.data.hero_matches?.items || {})[sid]; + const fromProMatches = (state.data.pro_matches?.items || {})[sid]; + const fromCore = itemMeta(id); + if (!_itemIdIndex) { + _itemIdIndex = new Map(); + const shop = state.data.item_shop?.items || {}; + for (const [key, row] of Object.entries(shop)) { + if (!row || row.id == null) continue; + _itemIdIndex.set(String(row.id), { + key, + dname: row.dname || key, + name_loc: row.name_loc || row.dname || key, + }); + } + } + const fromShop = _itemIdIndex.get(sid) || null; + const base = fromShop || fromCore || fromProMatches || fromMatches; + if (!base) return null; + const key = base.key || fromMatches?.key || fromCore?.key; + return { + key, + dname: base.dname || fromMatches?.dname || key, + name_loc: + fromShop?.name_loc || + fromCore?.name_loc || + fromMatches?.name_loc || + fromMatches?.dname || + key, + }; +} + +function abilityLabelForKey(heroKey, abilityKey) { + if (!abilityKey) return ""; + const pack = skillEntriesFor(heroKey); + if (pack?.entries) { + const hit = pack.entries.find((e) => e.ability_key === abilityKey); + if (hit?.name_loc) return hit.name_loc; + } + const cell = (state.data.hero_abilities?.by_hero || {})[heroKey]; + const abilities = cell?.abilities; + if (Array.isArray(abilities)) { + for (const ab of abilities) { + if (ab?.key === abilityKey) return ab.name_loc || abilityKey; + } + } + const talents = cell?.talents; + if (Array.isArray(talents)) { + for (const row of talents) { + if (row?.key === abilityKey) return row.name_loc || abilityKey; + } + } + return abilityKey.replace(/^special_bonus_/, "").replace(/_/g, " "); +} + function coreItemsFor(heroKey) { const cell = (state.data.hero_items?.by_hero || {})[heroKey]; if (cell == null) return null; return Array.isArray(cell) ? cell : []; } +/** Relative usage % within the hero's Top-N core list (sum of counts = 100%). */ +function coreItemUsagePcts(entries) { + if (!Array.isArray(entries) || !entries.length) return []; + const sum = entries.reduce((acc, e) => acc + (Number(e.count) || 0), 0); + if (sum <= 0) return entries.map(() => null); + return entries.map((e) => { + const c = Number(e.count) || 0; + return Math.round((c / sum) * 1000) / 10; + }); +} + +const BRACKET_LABELS = { + herald: "先锋", + guardian: "卫士", + crusader: "中军", + archon: "统帅", + legend: "传奇", + ancient: "万古", + divine: "超凡", + immortal: "冠绝", +}; + +/** OpenDota / client: Immortal sample is folded into Divine when too small. */ +const IMMORTAL_MERGE_TIP = + "由于样本过小,冠绝一世玩家的相关数据已与超凡入圣玩家合并。"; + +/** Valve regional Immortal leaderboard; scores are not comparable across regions. */ +const IMMORTAL_LEADERBOARD_TIP = + "官方 Immortal 地区榜 · 各区分数不可比"; + +const POSITION_LABELS = { + POSITION_1: "一号位", + POSITION_2: "二号位", + POSITION_3: "三号位", + POSITION_4: "四号位", + POSITION_5: "五号位", +}; + +/** Icon file under assets/rank_icons/ for each medal bracket. */ +const BRACKET_RANK_ICON = { + herald: "rank_icon_1.png", + guardian: "rank_icon_2.png", + crusader: "rank_icon_3.png", + archon: "rank_icon_4.png", + legend: "rank_icon_5.png", + ancient: "rank_icon_6.png", + divine: "rank_icon_7.png", + immortal: "rank_icon_8.png", +}; + +/** Recent matches tab: newest N ladder+league games. */ +const MATCHES_DISPLAY_LIMIT = 10; + +function heroStatsPack() { + return ( + state.data?.hero_stats || { + brackets: [], + by_hero: {}, + totals: {}, + fetched_at: null, + window_days: 7, + window_label_zh: "近约 7 天公开对局", + } + ); +} + +/** Short Chinese label for the OpenDota recent-match window. */ +function heroStatsWindowLabel() { + const pack = heroStatsPack(); + if (pack.window_label_zh) return String(pack.window_label_zh); + const days = Number(pack.window_days) || 7; + return `近约 ${days} 天公开对局`; +} + +function heroStatsFor(heroKey) { + const cell = (heroStatsPack().by_hero || {})[heroKey]; + return cell && typeof cell === "object" ? cell : null; +} + +function pickWinForBracket(cell, bracket) { + if (!cell) return null; + if (bracket === "pub") return cell.pub || null; + if (bracket === "pro") return cell.pro || null; + if (bracket === "turbo") return cell.turbo || null; + return (cell.brackets && cell.brackets[bracket]) || null; +} + +/** Immortal is merged into Divine when the Immortal sample is empty/tiny. */ +function pickWinForTrends(cell, bracket) { + if (bracket !== "immortal") return pickWinForBracket(cell, bracket); + const divine = pickWinForBracket(cell, "divine") || { pick: 0, win: 0 }; + const immortal = pickWinForBracket(cell, "immortal") || { pick: 0, win: 0 }; + return { + pick: (Number(divine.pick) || 0) + (Number(immortal.pick) || 0), + win: (Number(divine.win) || 0) + (Number(immortal.win) || 0), + }; +} + +function totalsForBracket(bracket) { + ensureHeroStatsTotals(); + const totals = heroStatsPack().totals || {}; + if (bracket === "pub") return totals.pub || null; + if (bracket === "pro") return totals.pro || null; + if (bracket === "turbo") return totals.turbo || null; + return (totals.brackets && totals.brackets[bracket]) || null; +} + +function totalsForTrends(bracket) { + if (bracket !== "immortal") return totalsForBracket(bracket); + const divine = totalsForBracket("divine") || { pick: 0 }; + const immortal = totalsForBracket("immortal") || { pick: 0 }; + return { + pick: (Number(divine.pick) || 0) + (Number(immortal.pick) || 0), + }; +} + +/** If payload lacks totals (old cache), derive pick sums from by_hero. */ +function ensureHeroStatsTotals() { + const pack = heroStatsPack(); + if (!pack || !pack.by_hero) return; + const totals = pack.totals; + if (totals && totals.pub && Number(totals.pub.pick) > 0) return; + const brackets = Array.isArray(pack.brackets) && pack.brackets.length + ? pack.brackets + : Object.keys(BRACKET_RANK_ICON).filter((k) => k !== "pub"); + const out = { + pub: { pick: 0 }, + brackets: {}, + pro: { pick: 0, ban: 0 }, + turbo: { pick: 0 }, + }; + for (const name of brackets) out.brackets[name] = { pick: 0 }; + for (const cell of Object.values(pack.by_hero)) { + if (!cell || typeof cell !== "object") continue; + out.pub.pick += Number(cell.pub?.pick) || 0; + out.pro.pick += Number(cell.pro?.pick) || 0; + out.pro.ban += Number(cell.pro?.ban) || 0; + out.turbo.pick += Number(cell.turbo?.pick) || 0; + for (const name of brackets) { + out.brackets[name].pick += Number(cell.brackets?.[name]?.pick) || 0; + } + } + pack.totals = out; +} + +/** Matches ≈ sum of hero picks / 10 (each game contributes 10 picks). */ +function approxMatches(totalPickSum) { + const s = Number(totalPickSum) || 0; + return s > 0 ? s / 10 : 0; +} + +function winratePct(pw) { + if (!pw) return null; + const pick = Number(pw.pick) || 0; + const win = Number(pw.win) || 0; + if (pick <= 0) return null; + return Math.round((win / pick) * 1000) / 10; +} + +/** Pick rate % of matches that included this hero in the sample window. */ +function pickratePct(pw, bracket) { + if (!pw) return null; + const pick = Number(pw.pick) || 0; + if (pick <= 0) return null; + const matches = approxMatches(totalsForTrends(bracket)?.pick); + if (matches <= 0) return null; + return Math.round((pick / matches) * 1000) / 10; +} + +/** + * Ban rate % — OpenDota heroStats only has pro_ban for the pro scene. + * Ranked / pub / turbo return null. + */ +function banratePct(pw, bracket) { + if (bracket !== "pro" || !pw) return null; + const ban = Number(pw.ban) || 0; + if (ban <= 0) return null; + const matches = approxMatches(totalsForBracket("pro")?.pick); + if (matches <= 0) return null; + return Math.round((ban / matches) * 1000) / 10; +} + +function formatPickCount(n) { + const v = Number(n) || 0; + if (v >= 10000) return `${(v / 10000).toFixed(v >= 100000 ? 0 : 1)}万`; + return String(v); +} + +function formatRatePct(v) { + if (v == null || Number.isNaN(v)) return "—"; + return `${v}%`; +} + +function formatCounterRate(v) { + const n = Number(v); + if (!Number.isFinite(n)) return "—"; + return `${(n * 100).toFixed(1)}%`; +} + function fearedItemsFor(heroKey) { const fears = state.data.hero_item_fears; const byHero = fears && fears.by_hero; @@ -591,11 +867,12 @@ function resolveItemDetail(key) { return { key, name_loc: key, components: [], builds_into: [] }; } -function appendItemIcon(list, { key, name, title }) { +function appendItemIcon(list, { key, name, title, badge }) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "item"; btn.title = title || name || key || ""; + btn.setAttribute("aria-label", title || name || key || ""); if ( key && state.inspect && @@ -606,16 +883,23 @@ function appendItemIcon(list, { key, name, title }) { } if (key) { const img = document.createElement("img"); - img.src = itemIconSrc(key); img.alt = name || key; img.loading = "lazy"; + setItemIcon(img, key); btn.appendChild(img); btn.addEventListener("click", () => selectInspectItem(key)); } else { btn.textContent = name || "?"; btn.disabled = true; } + if (badge != null && badge !== "") { + const mark = document.createElement("span"); + mark.className = "item-usage-badge"; + mark.textContent = typeof badge === "number" ? `${badge}%` : String(badge); + btn.appendChild(mark); + } list.appendChild(btn); + return btn; } function buildSkillsPanel(heroKey) { @@ -627,7 +911,7 @@ function buildSkillsPanel(heroKey) { if (skillPack == null) { const miss = document.createElement("div"); miss.className = "detail-muted"; - miss.textContent = "暂无技能数据(请运行 python fetch_hero_abilities.py)"; + miss.textContent = "暂无技能数据"; absBlock.appendChild(miss); return absBlock; } @@ -698,7 +982,7 @@ function buildCoreItemsPanel(heroKey) { if (entries == null) { const miss = document.createElement("div"); miss.className = "detail-muted"; - miss.textContent = "暂无装备数据(请运行 python fetch_hero_items.py)"; + miss.textContent = "暂无装备数据"; block.appendChild(miss); return block; } @@ -710,14 +994,22 @@ function buildCoreItemsPanel(heroKey) { empty.textContent = "暂无"; list.appendChild(empty); } else { - for (const entry of entries) { + const pcts = coreItemUsagePcts(entries); + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]; + const pct = pcts[i]; const meta = itemMeta(entry.id); const key = meta?.key; const name = meta?.name_loc || meta?.dname || (key ? key : `#${entry.id}`); + const pctLabel = pct != null ? `${pct}%` : null; + const tipParts = [name]; + if (pctLabel) tipParts.push(`相对热度 ${pctLabel}`); + if (entry.count != null) tipParts.push(`count ${entry.count}`); appendItemIcon(list, { key, name, - title: `${name}${entry.count != null ? ` (${entry.count})` : ""}`, + title: tipParts.join(" · "), + badge: pctLabel, }); } } @@ -725,6 +1017,899 @@ function buildCoreItemsPanel(heroKey) { return block; } +function matchRowDisplayOk(row) { + if (!row || typeof row !== "object") return false; + if (!row.won) return false; + if (row.origin === "public") { + const tier = Number(row.rank_tier ?? row.avg_rank_tier); + // Legend+ : OpenDota rank_tier >= 50 + if (!Number.isFinite(tier) || tier < 50) return false; + } + return true; +} + +function heroMatchesFor(heroKey) { + const cell = (state.data.hero_matches?.by_hero || {})[heroKey]; + let rows = null; + if (cell != null) { + if (Array.isArray(cell)) rows = cell; + else if (Array.isArray(cell.matches)) rows = cell.matches; + } + const proCell = (state.data.pro_matches?.by_hero || {})[heroKey]; + const proRows = Array.isArray(proCell?.matches) ? proCell.matches : []; + if (!rows && !proRows.length) return null; + const merged = [...(rows || []), ...proRows]; + const seen = new Set(); + const out = []; + for (const row of merged.sort( + (a, b) => (Number(b.start_time) || 0) - (Number(a.start_time) || 0) + )) { + const mid = Number(row?.match_id); + if (!mid || seen.has(mid)) continue; + if (!matchRowDisplayOk(row)) continue; + seen.add(mid); + out.push(row); + } + return out; +} + +function formatMatchDuration(sec) { + const n = Number(sec) || 0; + if (n <= 0) return "—"; + const m = Math.floor(n / 60); + const s = n % 60; + return `${m}:${String(s).padStart(2, "0")}`; +} + +function formatMatchTime(ts) { + const n = Number(ts); + if (!Number.isFinite(n) || n <= 0) return ""; + try { + return new Date(n * 1000).toLocaleString("zh-CN", { + month: "numeric", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return ""; + } +} + +const RANK_TIER_MEDALS = [ + null, + "herald", + "guardian", + "crusader", + "archon", + "legend", + "ancient", + "divine", + "immortal", +]; + +/** OpenDota rank_tier → { medalKey, stars, label, iconFile }. */ +function parseRankTier(tier) { + const t = Number(tier); + if (!Number.isFinite(t) || t < 10) return null; + const medal = Math.min(8, Math.max(1, Math.floor(t / 10))); + const stars = Math.max(0, t % 10); + const medalKey = RANK_TIER_MEDALS[medal]; + if (!medalKey) return null; + const base = BRACKET_LABELS[medalKey] || medalKey; + let label = base; + if (medalKey === "immortal") { + label = base; + } else if (stars > 0) { + label = `${base} ${stars}`; + } + return { + medalKey, + stars, + label, + iconFile: BRACKET_RANK_ICON[medalKey] || null, + }; +} + +function matchPlayerDisplayName(row) { + // Never show account id. Prefer display_name / pro name (jikroy) / Steam persona. + for (const key of ["display_name", "name", "personaname"]) { + const raw = row?.[key]; + if (typeof raw === "string" && raw.trim()) return raw.trim(); + } + return "匿名玩家"; +} + +function appendMatchItemIcon(list, id, timeSec) { + const meta = itemMetaFromId(id); + const key = meta?.key || null; + const name = meta?.name_loc || meta?.dname || (key ? key : `#${id}`); + if (!key) { + // Skip unknown ids — do not leave empty placeholder slots. + return; + } + const el = document.createElement("span"); + el.className = "match-icon"; + const timeLabel = formatMatchItemTime(timeSec); + el.title = timeLabel ? `${name} · ${timeLabel}` : name; + const img = document.createElement("img"); + img.alt = name; + img.loading = "lazy"; + setItemIcon(img, key, () => { + // Drop the slot entirely when neither local nor OSS has the icon. + el.remove(); + }); + el.appendChild(img); + if (timeLabel) { + const t = document.createElement("span"); + t.className = "match-icon-time"; + t.textContent = timeLabel; + el.appendChild(t); + } + list.appendChild(el); +} + +/** OpenDota-style MM:SS (supports negative pre-game seconds). */ +function formatMatchItemTime(sec) { + if (sec == null || sec === "") return null; + const n = Number(sec); + if (!Number.isFinite(n)) return null; + const neg = n < 0; + const abs = Math.abs(Math.trunc(n)); + const m = Math.floor(abs / 60); + const s = abs % 60; + const body = `${m}:${String(s).padStart(2, "0")}`; + return neg ? `-${body}` : body; +} + +function appendMatchBackpackGlyph(row) { + const glyph = document.createElement("span"); + glyph.className = "match-backpack-glyph"; + glyph.setAttribute("aria-hidden", "true"); + glyph.title = "背包"; + glyph.innerHTML = + '<svg viewBox="0 0 300 300" width="20" height="36" focusable="false" aria-hidden="true">' + + '<path fill="currentColor" d="M224.9,192.1c0.1-8.7,0.1-27.8-1.5-46.8c0.1,8,0.3,15.3,0.6,19.1C224.2,166.6,224.5,178,224.9,192.1z"/>' + + '<path fill="currentColor" d="M152,28.1c0.3-6.1-0.1-12.7,5.1-12.3c2.5,0.2,6.8,0.8,12.1,1.9c5.9,1.2,5.1,33.1,5.1,33.1l21.3,8.3c0.7-23.1-5.1-49.9-10.8-51.6C175.1,4.7,161-1,146.9,2.1C137.5,4.1,135,38.7,135,38.7l16.5,4.3C151.4,43,151.6,37.9,152,28.1z"/>' + + '<path fill="currentColor" d="M52.7,144.8c3.5,1.3,9.2,3.2,15.9,5.3c5.1-14,13.6-24.7,13.6-24.7s2-5.2,11.9-3.1c9.9,2.1,10.4,6.6,10.4,6.6s-6.4,10.4-12.7,24.5c-0.4,0.9-0.9,1.9-1.3,3.3c15.8,4.6,31,8.7,35.4,9.1c8.6,0.8,30.3,1.7,34.4-19.1c4.1-20.6,8.1-33.1,11.5-40.9c0.7-1.7,1.3-3.1,1.8-4.2c0.9-2.1,3.8-6.3,7.9-10.5c0,0,0,0,0,0c0,0,0,0,0.1-0.1c5.5-5.5,13.3-10.8,22-10.8c0.5,0,1,0.1,1.6,0.2c-5.1-5.7-11-10.5-17.2-12.9c-1.4-0.5-52.7-20.8-68.1-18.7c-0.4,0.1-0.9,0.1-1.2,0.2c0.7-0.1,1.2-0.2,1.2-0.2s-35.2,0.9-58.4,24.1c-12.1,12.1-22.8,33.9-29.5,54.8C32,133,38.8,139.7,52.7,144.8z"/>' + + '<path fill="currentColor" d="M121.3,181.4c-6.9-1.3-19.7-4.8-33.4-8.8c-2.1,8.2-0.3,22.4-0.3,22.4s-2.7,6.4-13.3,4.1c-9-2-8.8-8.5-8.8-8.5s-2.3-13.2-0.3-24.9c-13-4.1-24.1-7.8-28.2-9.3c-4.9-1.9-8.4-4.5-11-7.4c-0.2,1.2-0.4,5.8-0.5,6.9c-9.2,65.8,9.9,94.1,11.3,100c1.8,8,36.8,24.9,70.7,37.2c19.6,7.2,41.5,5.8,50.2,5.1l0.6,0c1.4-25.4,5.2-91.6,7-132.1C162.4,181,137.9,184.4,121.3,181.4z"/>' + + '<path fill="currentColor" d="M224.9,192.1c-0.4-14-0.7-25.5-0.9-27.7c-0.3-3.8-0.5-11.1-0.6-19.1c-1.4-16.8-5.7-34.9-9.4-44.3c-6.2-15.8-24.4,4-29.5,16.5c-5.3,12.9-10.3,162-10.8,176.7c12.6-3.3,30.8-8,33-11.5c4.7-7.4,7.3-14.5,8.4-20.6c0.8-4,11.2-7.8,11.2-7.8s-0.7-32.1-1.4-58.1l-0.2,0.7C224.8,196.9,224.9,195.1,224.9,192.1z"/>' + + '<path fill="currentColor" d="M271.3,127.2c0,0-2.7-31.9-3.3-38.1c-1.5-15-15-63.2-61.1-26.3l12.2,13.3c0,0,31.2-28.1,32.4,38.8c0.1,5.7,0.5,13.6,3.9,61.2c3.8,52.6-16.8,52.9-16.8,52.9l0.9,22.4c0,0,7.6-2,15.5-5.3c11.2-4.7,21.9-24.4,21.9-53.3C276.9,181.4,271.3,127.2,271.3,127.2z"/>' + + '<path fill="currentColor" d="M109.9,37.9l10.2-1.6v-8.6l2-7.1c0,0-9.4,4.3-12.2,8.6c-2.7,4.3-3.9,6.7-3.9,8.6C106,39.9,109.9,37.9,109.9,37.9z"/>' + + "</svg>"; + row.appendChild(glyph); +} + +function appendMatchAbilityIcon(list, heroKey, abilityKey, level) { + const name = abilityLabelForKey(heroKey, abilityKey); + const el = document.createElement("span"); + el.className = "match-icon match-ability"; + el.title = `Lv${level} ${name}`; + const img = document.createElement("img"); + const isTalent = String(abilityKey || "").startsWith("special_bonus_"); + if (isTalent) { + img.src = talentTreeIconSrc(); + } else { + setAbilityIcon(img, abilityKey, false); + } + img.alt = name; + img.loading = "lazy"; + el.appendChild(img); + const lv = document.createElement("span"); + lv.className = "match-ability-lv"; + lv.textContent = String(level); + el.appendChild(lv); + list.appendChild(el); +} + +function buildMatchCard(heroKey, row) { + const card = document.createElement("article"); + card.className = "match-card" + (row.won ? " is-win" : " is-loss"); + + const head = document.createElement("div"); + head.className = "match-card-head"; + + const result = document.createElement("span"); + result.className = "match-result"; + result.textContent = row.won ? "胜" : "负"; + head.appendChild(result); + + const player = document.createElement("div"); + player.className = "match-player"; + + const rankTier = row.rank_tier ?? row.avg_rank_tier; + const rank = parseRankTier(rankTier); + if (rank?.iconFile) { + const medal = document.createElement("img"); + medal.className = "match-rank-icon"; + medal.src = rankIconSrc(rank.iconFile); + let tip = rank.label; + if (row.leaderboard_rank) tip += ` #${row.leaderboard_rank}`; + if (!row.rank_tier && row.avg_rank_tier) tip += " · 局均段位"; + medal.alt = tip; + medal.title = tip; + medal.loading = "lazy"; + player.appendChild(medal); + } + + const nameEl = document.createElement(row.account_id ? "a" : "span"); + nameEl.className = "match-player-name"; + nameEl.textContent = matchPlayerDisplayName(row); + if (row.account_id) { + nameEl.href = `https://www.dotabuff.com/players/${row.account_id}`; + nameEl.target = "_blank"; + nameEl.rel = "noopener noreferrer"; + nameEl.title = "在 Dotabuff 查看选手"; + } + player.appendChild(nameEl); + + if (!rank?.iconFile && row.leaderboard_rank) { + const rankLab = document.createElement("span"); + rankLab.className = "match-rank-label"; + rankLab.textContent = `#${row.leaderboard_rank}`; + player.appendChild(rankLab); + } + head.appendChild(player); + + const kda = document.createElement("span"); + kda.className = "match-kda"; + kda.textContent = `${row.kills ?? 0}/${row.deaths ?? 0}/${row.assists ?? 0}`; + head.appendChild(kda); + + const dur = document.createElement("span"); + dur.className = "match-duration"; + dur.textContent = formatMatchDuration(row.duration); + head.appendChild(dur); + + const origin = document.createElement("span"); + origin.className = "match-origin"; + if (row.origin === "public") { + const cluster = Number(row.cluster); + const region = Number(row.region); + const chinaRegions = new Set([12, 13, 17, 18, 20, 25]); + const chinaClusters = new Set([ + 221, 222, 223, 224, 225, 227, 231, 232, 235, 236, 413, 414, 415, 417, + ]); + const isChina = + (Number.isFinite(region) && chinaRegions.has(region)) || + (Number.isFinite(cluster) && chinaClusters.has(cluster)); + origin.textContent = isChina ? "国服" : "天梯"; + if (isChina) origin.classList.add("match-origin-china"); + } else if (row.origin === "pro") { + origin.textContent = "职业"; + origin.classList.add("match-origin-pro"); + } else { + origin.textContent = "联赛"; + } + head.appendChild(origin); + + if (row.league_name) { + const league = document.createElement("span"); + league.className = "match-league"; + league.textContent = row.league_name; + league.title = row.league_name; + head.appendChild(league); + } + + const when = formatMatchTime(row.start_time); + if (when) { + const timeEl = document.createElement("span"); + timeEl.className = "match-time"; + timeEl.textContent = when; + head.appendChild(timeEl); + } + + const links = document.createElement("span"); + links.className = "match-links"; + if (row.match_id) { + const matchLink = document.createElement("a"); + matchLink.className = "match-opendota"; + matchLink.href = `https://www.dotabuff.com/matches/${row.match_id}`; + matchLink.target = "_blank"; + matchLink.rel = "noopener noreferrer"; + matchLink.textContent = "详情"; + matchLink.title = "在 Dotabuff 查看本场比赛"; + links.appendChild(matchLink); + } + if (links.childNodes.length) head.appendChild(links); + card.appendChild(head); + + const inv = document.createElement("div"); + inv.className = "match-inv"; + inv.setAttribute("aria-label", "出装"); + + const itemsRow = document.createElement("div"); + itemsRow.className = "match-items"; + const itemIds = Array.isArray(row.items) ? row.items : []; + const itemTimes = Array.isArray(row.item_times) ? row.item_times : []; + itemIds.forEach((id, i) => appendMatchItemIcon(itemsRow, id, itemTimes[i])); + if (row.item_neutral) { + const neut = document.createElement("span"); + neut.className = "match-neutral-slot"; + appendMatchItemIcon(neut, row.item_neutral, row.item_neutral_time); + if (neut.childNodes.length) itemsRow.appendChild(neut); + } + inv.appendChild(itemsRow); + + const backpack = Array.isArray(row.backpack) ? row.backpack : []; + if (backpack.length) { + const bpRow = document.createElement("div"); + bpRow.className = "match-backpack"; + bpRow.setAttribute("aria-label", "背包"); + appendMatchBackpackGlyph(bpRow); + const bpTimes = Array.isArray(row.backpack_times) ? row.backpack_times : []; + backpack.forEach((id, i) => appendMatchItemIcon(bpRow, id, bpTimes[i])); + inv.appendChild(bpRow); + } + card.appendChild(inv); + + const ups = Array.isArray(row.ability_upgrades) ? row.ability_upgrades : []; + const skillsRow = document.createElement("div"); + skillsRow.className = "match-skills"; + skillsRow.setAttribute("aria-label", "加点"); + if (!ups.length) { + const missSkill = document.createElement("span"); + missSkill.className = "detail-muted"; + missSkill.textContent = "无加点数据"; + skillsRow.appendChild(missSkill); + } else { + ups.forEach((key, i) => + appendMatchAbilityIcon(skillsRow, heroKey, key, i + 1) + ); + } + card.appendChild(skillsRow); + return card; +} + +function buildMatchList(heroKey, rows) { + const list = document.createElement("div"); + list.className = "match-list"; + for (const row of rows) { + list.appendChild(buildMatchCard(heroKey, row)); + } + return list; +} + +function buildMatchesPanel(heroKey) { + const block = document.createElement("div"); + block.className = "detail-matches-panel detail-tab-panel"; + block.setAttribute("aria-label", "近期比赛"); + const rows = heroMatchesFor(heroKey); + if (rows == null) { + const miss = document.createElement("div"); + miss.className = "detail-muted"; + miss.textContent = + "暂无比赛数据"; + block.appendChild(miss); + return block; + } + const displayRows = (rows || []).slice(0, MATCHES_DISPLAY_LIMIT); + if (!displayRows.length) { + const empty = document.createElement("div"); + empty.className = "detail-muted"; + empty.textContent = "暂无近期比赛"; + block.appendChild(empty); + return block; + } + + block.appendChild(buildMatchList(heroKey, displayRows)); + + return block; +} + +function bracketSelectOptions() { + const pack = heroStatsPack(); + const brackets = Array.isArray(pack.brackets) && pack.brackets.length + ? pack.brackets + : [ + "herald", + "guardian", + "crusader", + "archon", + "legend", + "ancient", + "divine", + "immortal", + ]; + // Trends UI mirrors the in-game medal row (no pub / pro / turbo chips). + return brackets.filter((k) => BRACKET_RANK_ICON[k]); +} + +function buildInfoTipButton(tipText, extraClass = "") { + const tipBtn = document.createElement("button"); + tipBtn.type = "button"; + tipBtn.className = extraClass + ? `bracket-info-btn ${extraClass}` + : "bracket-info-btn"; + tipBtn.setAttribute("aria-label", tipText); + tipBtn.title = tipText; + tipBtn.textContent = "i"; + tipBtn.addEventListener("click", (e) => { + e.stopPropagation(); + tipBtn.classList.toggle("open"); + }); + const bubble = document.createElement("div"); + bubble.className = "bracket-info-bubble"; + bubble.setAttribute("role", "tooltip"); + bubble.textContent = tipText; + tipBtn.appendChild(bubble); + return tipBtn; +} + +/** Horizontal rank-medal picker (images only; label via title/aria). */ +function buildBracketIconPicker({ + selectedKey = null, + onChange = null, + ariaLabel = "统计段位", + showImmortalTip = true, +} = {}) { + const current = + selectedKey != null ? selectedKey : state.statsBracket; + const row = document.createElement("div"); + row.className = "bracket-icon-picker"; + row.setAttribute("role", "radiogroup"); + row.setAttribute("aria-label", ariaLabel); + for (const key of bracketSelectOptions()) { + const wrap = document.createElement("div"); + wrap.className = + "bracket-icon-wrap" + (key === "immortal" ? " has-tip" : ""); + + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = + "bracket-icon-btn" + (key === current ? " active" : ""); + btn.setAttribute("role", "radio"); + btn.setAttribute("aria-checked", key === current ? "true" : "false"); + const label = BRACKET_LABELS[key] || key; + btn.title = label; + btn.setAttribute("aria-label", label); + const file = BRACKET_RANK_ICON[key]; + const img = document.createElement("img"); + img.src = rankIconSrc(file || "rank_icon_1.png"); + img.alt = label; + img.loading = "lazy"; + img.decoding = "async"; + img.draggable = false; + btn.appendChild(img); + btn.addEventListener("click", () => { + if (current === key) return; + if (typeof onChange === "function") { + onChange(key); + } else { + state.statsBracket = key; + renderDetail(); + } + }); + wrap.appendChild(btn); + + if (showImmortalTip && key === "immortal") { + wrap.appendChild(buildInfoTipButton(IMMORTAL_MERGE_TIP)); + } + + row.appendChild(wrap); + } + return row; +} + +function stratzMetaPack() { + return ( + state.data?.stratz_hero_meta || { + by_hero: {}, + totals: {}, + meta_board: {}, + brackets: [], + fetched_at: null, + window_label_zh: null, + latest_window_label_zh: "最近 1 周", + attribution: "https://stratz.com", + } + ); +} + +function stratzMetaFor(heroKey) { + const cell = (stratzMetaPack().by_hero || {})[heroKey]; + return cell && typeof cell === "object" ? cell : null; +} + +function stratzMatchupPack() { + return ( + state.data?.stratz_matchup_tops || { + by_hero: {}, + fetched_at: null, + attribution: "https://stratz.com", + } + ); +} + +function stratzMatchupsFor(heroKey) { + const cell = (stratzMatchupPack().by_hero || {})[heroKey]; + return cell && typeof cell === "object" ? cell : null; +} + +function heroKeyById(id) { + const n = Number(id); + if (!Number.isFinite(n)) return null; + for (const h of state.data?.heroes || []) { + if (Number(h.id) === n) return h.key; + } + return null; +} + +function stratzLatestFor(cell, bracket) { + if (!cell) return null; + return (cell.latest && cell.latest[bracket]) || null; +} + +function stratzPickratePct(pw, bracket) { + if (!pw) return null; + const pick = Number(pw.pick) || 0; + if (pick <= 0) return null; + const total = Number((stratzMetaPack().totals || {})[bracket]?.pick) || 0; + const matches = approxMatches(total); + if (matches <= 0) return null; + return Math.round((pick / matches) * 1000) / 10; +} + +function formatAdvantage(v) { + if (v == null || Number.isNaN(Number(v))) return "—"; + const n = Number(v); + return n >= 0 ? `+${n.toFixed(1)}` : n.toFixed(1); +} + +function formatWeekTrendLabel(weekUnix, index) { + if (index === 0) return "本周"; + if (index === 1) return "上周"; + const ts = Number(weekUnix); + if (!Number.isFinite(ts) || ts <= 0) return `${index}周前`; + const d = new Date(ts * 1000); + return `${d.getMonth() + 1}/${d.getDate()}`; +} + +function buildWeekSparkline(weeks) { + const wrap = document.createElement("div"); + wrap.className = "trends-week-chart"; + wrap.setAttribute("aria-label", "近 8 周胜率"); + if (!Array.isArray(weeks) || weeks.length < 2) { + const miss = document.createElement("div"); + miss.className = "detail-muted"; + miss.textContent = "暂无周走势"; + wrap.appendChild(miss); + return wrap; + } + + const head = document.createElement("div"); + head.className = "trends-section-label trends-week-head"; + head.textContent = "近 8 周胜率"; + wrap.appendChild(head); + + // Newest-first (same order as stored latest→oldest). + const ordered = weeks.slice(); + const wrs = ordered.map((w) => { + if (w.wr != null) return Number(w.wr) * 100; + const pick = Number(w.pick) || 0; + const win = Number(w.win) || 0; + return pick > 0 ? (win / pick) * 100 : null; + }); + const known = wrs.filter((x) => x != null); + // Zoom to this hero's 8-week range so 52.1% vs 52.4% still reads as length. + let scaleMin = known.length ? Math.min(...known) : 48; + let scaleMax = known.length ? Math.max(...known) : 52; + const pad = Math.max(0.4, (scaleMax - scaleMin) * 0.35); + scaleMin -= pad; + scaleMax += pad; + if (scaleMax - scaleMin < 1.2) { + const mid = (scaleMax + scaleMin) / 2; + scaleMin = mid - 0.6; + scaleMax = mid + 0.6; + } + const span = scaleMax - scaleMin; + const best = known.length ? Math.max(...known) : null; + const worst = known.length ? Math.min(...known) : null; + + const list = document.createElement("div"); + list.className = "trends-week-list"; + + for (let i = 0; i < ordered.length; i++) { + const w = ordered[i]; + const wr = wrs[i]; + const older = i + 1 < wrs.length ? wrs[i + 1] : null; + const delta = + wr != null && older != null ? Math.round((wr - older) * 10) / 10 : null; + + const row = document.createElement("div"); + row.className = "trends-week-row" + (i === 0 ? " is-latest" : ""); + if (wr != null && best != null && wr === best && best !== worst) { + row.classList.add("is-best"); + } + + const lab = document.createElement("span"); + lab.className = "trends-week-label"; + lab.textContent = formatWeekTrendLabel(w.week, i); + + const val = document.createElement("span"); + val.className = + "trends-week-wr" + (wr == null ? "" : wr >= 50 ? " is-up" : " is-down"); + val.textContent = wr == null ? "—" : `${wr.toFixed(1)}%`; + + const dlt = document.createElement("span"); + dlt.className = "trends-week-delta"; + if (delta == null) { + dlt.textContent = ""; + dlt.classList.add("is-empty"); + } else if (delta === 0) { + dlt.textContent = "0"; + dlt.classList.add("is-flat"); + } else if (delta > 0) { + dlt.textContent = `+${delta.toFixed(1)}`; + dlt.classList.add("is-up"); + } else { + dlt.textContent = delta.toFixed(1); + dlt.classList.add("is-down"); + } + + const track = document.createElement("div"); + track.className = "trends-week-track"; + track.setAttribute("aria-hidden", "true"); + if (wr != null) { + // Left-aligned fill within zoomed range (not anchored at 50%). + const pct = Math.max(8, Math.min(100, ((wr - scaleMin) / span) * 100)); + const fill = document.createElement("div"); + fill.className = + "trends-week-fill" + (wr >= 50 ? " is-up" : " is-down"); + fill.style.width = `${pct}%`; + track.appendChild(fill); + } + + const pick = document.createElement("span"); + pick.className = "trends-week-pick detail-muted"; + pick.textContent = formatPickCount(w.pick); + + row.appendChild(lab); + row.appendChild(val); + row.appendChild(dlt); + row.appendChild(track); + row.appendChild(pick); + const deltaTip = + delta == null + ? "" + : delta === 0 + ? " · 环比持平" + : ` · 环比 ${delta > 0 ? "+" : ""}${delta.toFixed(1)}pp`; + row.title = + wr == null + ? "无数据" + : `胜率 ${wr.toFixed(1)}% · ${formatPickCount(w.pick)} 场${deltaTip}`; + list.appendChild(row); + } + + wrap.appendChild(list); + return wrap; +} + +function buildPositionRow(positions, bracket) { + const row = document.createElement("div"); + row.className = "trends-positions"; + row.setAttribute("aria-label", "分路胜率"); + const map = (positions && positions[bracket]) || {}; + let any = false; + for (const pos of Object.keys(POSITION_LABELS)) { + const cell = map[pos]; + const pick = Number(cell?.pick) || 0; + if (pick <= 0) continue; + any = true; + const wr = winratePct(cell); + const chip = document.createElement("div"); + chip.className = "trends-pos-chip"; + chip.title = `${POSITION_LABELS[pos]} · ${formatPickCount(pick)} 场`; + const lab = document.createElement("span"); + lab.className = "trends-pos-label"; + lab.textContent = POSITION_LABELS[pos]; + const val = document.createElement("span"); + val.className = "trends-pos-value"; + val.textContent = formatRatePct(wr); + chip.appendChild(lab); + chip.appendChild(val); + row.appendChild(chip); + } + if (!any) { + const miss = document.createElement("div"); + miss.className = "detail-muted"; + miss.textContent = "暂无分路样本(该段位最近一周场次不足)"; + row.appendChild(miss); + } + return row; +} + +function buildStatsPanel(heroKey) { + const block = document.createElement("div"); + block.className = "detail-stats-panel detail-tab-panel trends-panel"; + block.setAttribute("aria-label", "走势"); + + const stratzCell = stratzMetaFor(heroKey); + const odotaCell = heroStatsFor(heroKey); + if (!stratzCell && !odotaCell) { + const miss = document.createElement("div"); + miss.className = "detail-muted"; + miss.textContent = + "暂无走势数据"; + block.appendChild(miss); + return block; + } + + if (!bracketSelectOptions().includes(state.statsBracket)) { + state.statsBracket = "legend"; + } + const bracket = state.statsBracket; + const useStratz = !!stratzCell; + + const rankBar = document.createElement("div"); + rankBar.className = "trends-rank-bar"; + rankBar.appendChild(buildBracketIconPicker()); + block.appendChild(rankBar); + + if (useStratz) { + const hint = document.createElement("div"); + hint.className = "trends-window-hint detail-muted"; + hint.textContent = stratzMetaPack().latest_window_label_zh || "最近 1 周"; + block.appendChild(hint); + } + + let wr; + let pr; + let games; + if (useStratz) { + const pw = stratzLatestFor(stratzCell, bracket); + wr = winratePct(pw); + pr = stratzPickratePct(pw, bracket); + games = Number(pw?.pick) || 0; + } else { + const pw = pickWinForTrends(odotaCell, bracket); + wr = winratePct(pw); + pr = pickratePct(pw, bracket); + games = Number(pw?.pick) || 0; + } + + const cards = document.createElement("div"); + cards.className = "trends-metric-cards"; + for (const def of [ + { key: "wr", label: "胜率", value: formatRatePct(wr), tone: "wr" }, + { key: "pr", label: "上场率", value: formatRatePct(pr), tone: "pr" }, + { + key: "games", + label: "场次", + value: games > 0 ? formatPickCount(games) : "—", + tone: "games", + }, + ]) { + const card = document.createElement("div"); + card.className = `trends-metric-card tone-${def.tone}`; + const lab = document.createElement("div"); + lab.className = "trends-metric-label"; + lab.textContent = def.label; + const val = document.createElement("div"); + val.className = "trends-metric-value"; + val.textContent = def.value; + card.appendChild(lab); + card.appendChild(val); + cards.appendChild(card); + } + block.appendChild(cards); + + if (useStratz) { + const weeks = (stratzCell.weeks && stratzCell.weeks[bracket]) || []; + block.appendChild(buildPositionRow(stratzCell.positions, bracket)); + block.appendChild(buildWeekSparkline(weeks)); + } + + if (!useStratz) { + const foot = document.createElement("div"); + foot.className = "detail-muted detail-stats-note"; + const pack = heroStatsPack(); + const fetched = pack.fetched_at + ? `拉取于 ${String(pack.fetched_at).slice(0, 10)}` + : ""; + foot.textContent = [ + `OpenDota ${heroStatsWindowLabel()}`, + BRACKET_LABELS[bracket] || bracket, + fetched, + ] + .filter(Boolean) + .join(" · "); + block.appendChild(foot); + } + return block; +} + +function buildMatchupColumn(title, entries, scoreKey, scoreLabel) { + const col = document.createElement("div"); + col.className = "matchup-col"; + const h = document.createElement("h3"); + h.className = "matchup-col-title"; + h.textContent = title; + col.appendChild(h); + const list = document.createElement("div"); + list.className = "matchup-list"; + if (!entries || !entries.length) { + const empty = document.createElement("div"); + empty.className = "detail-muted"; + empty.textContent = "暂无"; + list.appendChild(empty); + col.appendChild(list); + return col; + } + for (const row of entries) { + const peerKey = heroKeyById(row.hero_id); + const hero = peerKey ? heroByKey(peerKey) : null; + const item = document.createElement("button"); + item.type = "button"; + item.className = "matchup-row"; + if (peerKey) { + item.addEventListener("click", () => { + state.selectedKey = peerKey; + state.detailTab = "matchups"; + state.inspect = null; + syncStateToUrl(); + render(); + }); + } else { + item.disabled = true; + } + const img = document.createElement("img"); + img.className = "matchup-portrait"; + img.src = peerKey ? portraitSrc(peerKey) : ""; + img.alt = hero?.name_loc || peerKey || ""; + img.loading = "lazy"; + img.decoding = "async"; + const name = document.createElement("span"); + name.className = "matchup-name"; + name.textContent = hero?.name_loc || peerKey || `#${row.hero_id}`; + const score = document.createElement("span"); + score.className = "matchup-score"; + score.textContent = formatAdvantage(row[scoreKey]); + score.title = `${scoreLabel} ${formatAdvantage(row[scoreKey])} · ${formatPickCount(row.games)} 场`; + const games = document.createElement("span"); + games.className = "matchup-games detail-muted"; + games.textContent = formatPickCount(row.games); + item.appendChild(img); + item.appendChild(name); + item.appendChild(score); + item.appendChild(games); + list.appendChild(item); + } + col.appendChild(list); + return col; +} + +function buildMatchupsPanel(heroKey) { + const block = document.createElement("div"); + block.className = "detail-matchups-panel detail-tab-panel"; + block.setAttribute("aria-label", "数据对位"); + + const cell = stratzMatchupsFor(heroKey); + if (!cell) { + const miss = document.createElement("div"); + miss.className = "detail-muted"; + miss.textContent = "暂无对位数据"; + block.appendChild(miss); + return block; + } + + const grid = document.createElement("div"); + grid.className = "matchup-grid"; + grid.appendChild( + buildMatchupColumn("克制", cell.counters, "advantage", "advantage") + ); + grid.appendChild( + buildMatchupColumn("被克", cell.countered, "advantage", "劣势") + ); + grid.appendChild( + buildMatchupColumn("搭档", cell.synergies, "synergy", "synergy") + ); + block.appendChild(grid); + + return block; +} + function buildFearItemsPanel(heroKey) { const block = document.createElement("div"); block.className = "detail-items detail-tab-panel"; @@ -736,7 +1921,7 @@ function buildFearItemsPanel(heroKey) { const miss = document.createElement("span"); miss.className = "detail-muted"; miss.textContent = - "暂无克制装备数据(请运行 python fetch_items_meta.py && python fetch_hero_abilities.py && python item_fears.py)"; + "暂无克制装备数据"; list.appendChild(miss); } else if (!fears.length) { const empty = document.createElement("span"); @@ -748,8 +1933,30 @@ function buildFearItemsPanel(heroKey) { const key = entry.item; const name = entry.name_loc || key; const reason = entry.reason || ""; - const tip = [name, reason].filter(Boolean).join(" — "); - appendItemIcon(list, { key, name, title: tip }); + const stats = entry.stats || null; + const rawPurchaseRate = stats?.purchase_rate; + const hasPurchaseRate = + rawPurchaseRate != null && + rawPurchaseRate !== "" && + Number.isFinite(Number(rawPurchaseRate)); + const purchaseRate = hasPurchaseRate + ? formatCounterRate(stats.purchase_rate) + : null; + const tipParts = [name, reason].filter(Boolean); + if (hasPurchaseRate) { + tipParts.push(`对阵购买率 ${purchaseRate}`); + if (stats.games) tipParts.push(`样本 ${stats.games} 场`); + } + + const item = document.createElement("div"); + item.className = "fear-item"; + appendItemIcon(item, { + key, + name, + title: tipParts.join(" · "), + badge: purchaseRate, + }); + list.appendChild(item); } } block.appendChild(list); @@ -1134,6 +2341,28 @@ function appendVitalBar(parent, kind, label, value, regen, scale) { parent.appendChild(bar); } +/** Hero role tags (skills tab center column, above skill icons). */ +function buildHeroTagsRow(hero) { + const tags = document.createElement("div"); + tags.className = "detail-tags"; + tags.setAttribute("aria-label", "英雄定位"); + const tagList = hero.tags || []; + if (!tagList.length) { + const none = document.createElement("span"); + none.className = "detail-muted"; + none.textContent = "暂无定位"; + tags.appendChild(none); + return tags; + } + for (const t of tagList) { + const chip = document.createElement("span"); + chip.className = "tag-chip"; + chip.textContent = t; + tags.appendChild(chip); + } + return tags; +} + /** Left column: HP/Mana + STR/AGI/INT (skills tab only). */ function buildHeroVitalsAttrs(hero) { if (hero.base_str == null && hero.health == null) return null; @@ -1294,7 +2523,7 @@ function renderDetail() { if (!state.selectedKey) { root.classList.add("empty"); - root.textContent = "点击英雄查看定位、技能、核心装备与被克装备"; + root.textContent = "点击英雄查看定位、技能、核心装备、公开数据与被克装备"; return; } @@ -1312,33 +2541,19 @@ function renderDetail() { title.className = "detail-name"; title.textContent = hero.name_loc || hero.key; head.appendChild(title); - - const tags = document.createElement("div"); - tags.className = "detail-tags"; - tags.setAttribute("aria-label", "英雄定位"); - const tagList = hero.tags || []; - if (!tagList.length) { - const none = document.createElement("span"); - none.className = "detail-muted"; - none.textContent = "暂无定位"; - tags.appendChild(none); - } else { - for (const t of tagList) { - const chip = document.createElement("span"); - chip.className = "tag-chip"; - chip.textContent = t; - tags.appendChild(chip); - } - } - head.appendChild(tags); root.appendChild(head); const tabDefs = [ { id: "skills", label: "技能" }, { id: "core", label: "核心装备" }, { id: "fears", label: "被克装备" }, - { id: "patches", label: "版本变更" }, + { id: "trends", label: "走势" }, + { id: "matchups", label: "对位" }, + { id: "matches", label: "近期比赛" }, + { id: "streamers", label: "主播" }, + { id: "patches", label: "改动" }, ]; + if (state.detailTab === "stats") state.detailTab = "trends"; if (!tabDefs.some((t) => t.id === state.detailTab)) { state.detailTab = "skills"; } @@ -1370,6 +2585,7 @@ function renderDetail() { ensureHeroInspectDefault(state.selectedKey); // Skills: 血/蓝+三维 | 技能 | 攻击距离等. Items tabs: icons + inspect only. + // Trends: WR / pick / ban cards + bracket table. Patches: changelog list. if (state.detailTab === "patches") { const layout = document.createElement("div"); layout.className = "detail-patches-layout"; @@ -1378,6 +2594,38 @@ function renderDetail() { body.appendChild(buildPatchChangesPanel(state.selectedKey)); layout.appendChild(body); root.appendChild(layout); + } else if (state.detailTab === "trends" || state.detailTab === "stats") { + const layout = document.createElement("div"); + layout.className = "detail-stats-layout"; + const body = document.createElement("div"); + body.className = "detail-tab-body"; + body.appendChild(buildStatsPanel(state.selectedKey)); + layout.appendChild(body); + root.appendChild(layout); + } else if (state.detailTab === "matchups") { + const layout = document.createElement("div"); + layout.className = "detail-matchups-layout"; + const body = document.createElement("div"); + body.className = "detail-tab-body"; + body.appendChild(buildMatchupsPanel(state.selectedKey)); + layout.appendChild(body); + root.appendChild(layout); + } else if (state.detailTab === "matches") { + const layout = document.createElement("div"); + layout.className = "detail-matches-layout"; + const body = document.createElement("div"); + body.className = "detail-tab-body"; + body.appendChild(buildMatchesPanel(state.selectedKey)); + layout.appendChild(body); + root.appendChild(layout); + } else if (state.detailTab === "streamers") { + const layout = document.createElement("div"); + layout.className = "detail-streamers-layout"; + const body = document.createElement("div"); + body.className = "detail-tab-body"; + body.appendChild(buildHeroStreamersPanel(state.selectedKey)); + layout.appendChild(body); + root.appendChild(layout); } else if (state.detailTab === "skills") { const layout = document.createElement("div"); layout.className = "detail-skills-layout"; @@ -1390,6 +2638,7 @@ function renderDetail() { const center = document.createElement("div"); center.className = "detail-skills-main"; + center.appendChild(buildHeroTagsRow(hero)); center.appendChild(buildSkillsPanel(state.selectedKey)); center.appendChild(renderHeroInspect(state.selectedKey)); layout.appendChild(center); @@ -1509,7 +2758,7 @@ function renderItemShop() { const upSecs = shop.upgraded?.sections || []; if (!basicSecs.length && !upSecs.length) { root.classList.add("empty"); - root.textContent = "暂无商店数据,请运行 python fetch_item_shop.py"; + root.textContent = "暂无商店数据"; return; } @@ -1530,6 +2779,39 @@ function itemIconSrc(key) { return assetUrl(`item/${encodeURIComponent(stem)}.png`); } +/** Project OSS base for static icons (same bucket as deploy). */ +const PROJECT_OSS_ASSET_BASE = + "https://climperor.oss-cn-shanghai.aliyuncs.com"; + +function itemIconOssSrc(key) { + const stem = key.startsWith("recipe_") ? "recipe" : key; + return `${PROJECT_OSS_ASSET_BASE}/item/${encodeURIComponent(stem)}.png`; +} + +/** + * Prefer configured STATIC_ASSET_BASE / same-origin, then fall back to project OSS + * so match builds still show neutrals / aegis after local cache misses. + */ +function setItemIcon(img, key, onMiss) { + if (!key) { + if (typeof onMiss === "function") onMiss(); + return; + } + const primary = itemIconSrc(key); + const oss = itemIconOssSrc(key); + img.src = primary; + img.onerror = () => { + if (img.dataset.ossTried === "1" || primary === oss) { + img.onerror = null; + img.removeAttribute("src"); + if (typeof onMiss === "function") onMiss(); + return; + } + img.dataset.ossTried = "1"; + img.src = oss; + }; +} + function makeCraftChip(key, { clickable = true, onPick = null } = {}) { const meta = resolveItemDetail(key) || @@ -1784,7 +3066,7 @@ function renderPatches() { if (!patches.length) { if (select) select.innerHTML = ""; if (verEl) verEl.textContent = ""; - root.innerHTML = '<div class="patches-empty">暂无版本数据,请运行 python fetch_patches.py</div>'; + root.innerHTML = '<div class="patches-empty">暂无版本数据</div>'; renderPatchesSiteVersion(); return; } @@ -1815,7 +3097,7 @@ function renderPatches() { const details = state.data.patch_details || {}; const det = details[state.selectedPatch]; if (!det) { - root.innerHTML = '<div class="patches-empty">该版本详情暂未抓取,请重跑 python fetch_patches.py</div>'; + root.innerHTML = '<div class="patches-empty">该版本详情暂未收录</div>'; renderPatchesSiteVersion(); return; } @@ -1856,15 +3138,1356 @@ function escapeHtml(s) { return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); } +const RANKING_REGION_LABELS = { + china: "中国", + europe: "欧洲", + americas: "美洲", + se_asia: "东南亚", +}; + +/** ISO 3166-1 alpha-2 → 中文国名(Valve 榜 country 字段)。 */ +const COUNTRY_ZH = { + af: "阿富汗", + ag: "安提瓜和巴布达", + ai: "安圭拉", + al: "阿尔巴尼亚", + ao: "安哥拉", + ar: "阿根廷", + as: "美属萨摩亚", + at: "奥地利", + au: "澳大利亚", + ax: "奥兰", + az: "阿塞拜疆", + be: "比利时", + bo: "玻利维亚", + br: "巴西", + by: "白俄罗斯", + ca: "加拿大", + ch: "瑞士", + cl: "智利", + cn: "中国", + cz: "捷克", + de: "德国", + dz: "阿尔及利亚", + fi: "芬兰", + fk: "福克兰群岛", + gd: "格林纳达", + gf: "法属圭亚那", + gr: "希腊", + id: "印度尼西亚", + in: "印度", + jo: "约旦", + jp: "日本", + kz: "哈萨克斯坦", + la: "老挝", + lb: "黎巴嫩", + md: "摩尔多瓦", + mn: "蒙古", + mp: "北马里亚纳", + my: "马来西亚", + ni: "尼加拉瓜", + nl: "荷兰", + pe: "秘鲁", + ph: "菲律宾", + pl: "波兰", + rs: "塞尔维亚", + ru: "俄罗斯", + sg: "新加坡", + sk: "斯洛伐克", + th: "泰国", + ua: "乌克兰", + us: "美国", + uz: "乌兹别克斯坦", + va: "梵蒂冈", + vn: "越南", + // Common extras + ae: "阿联酋", + bd: "孟加拉国", + bg: "保加利亚", + dk: "丹麦", + eg: "埃及", + es: "西班牙", + fr: "法国", + gb: "英国", + hk: "中国香港", + hr: "克罗地亚", + hu: "匈牙利", + ie: "爱尔兰", + il: "以色列", + iq: "伊拉克", + ir: "伊朗", + it: "意大利", + kh: "柬埔寨", + kr: "韩国", + lt: "立陶宛", + lv: "拉脱维亚", + mm: "缅甸", + mx: "墨西哥", + no: "挪威", + np: "尼泊尔", + nz: "新西兰", + pk: "巴基斯坦", + pt: "葡萄牙", + ro: "罗马尼亚", + sa: "沙特阿拉伯", + se: "瑞典", + si: "斯洛文尼亚", + tr: "土耳其", + tw: "中国台湾", + uk: "英国", +}; + +function countryLabelZh(code) { + if (!code) return ""; + const key = String(code).trim().toLowerCase(); + if (!key) return ""; + return COUNTRY_ZH[key] || key.toUpperCase(); +} + +function leaderboardsData() { + return state.data?.leaderboards || { + default_region: "china", + region_order: ["china", "europe", "americas", "se_asia"], + regions: {}, + }; +} + +function ensureDefaultRankingRegion() { + const lb = leaderboardsData(); + const order = lb.region_order || Object.keys(lb.regions || {}); + const valid = new Set(order); + Object.keys(lb.regions || {}).forEach((k) => valid.add(k)); + if (!state.rankingRegion || !valid.has(state.rankingRegion)) { + state.rankingRegion = lb.default_region || order[0] || "china"; + } +} + +function formatLeaderboardPosted(ts) { + if (ts == null || ts === "") return ""; + const n = Number(ts); + if (!Number.isFinite(n) || n <= 0) return ""; + // Valve posts unix seconds; guard ms accidentally. + const ms = n > 1e12 ? n : n * 1000; + try { + return new Date(ms).toLocaleString("zh-CN", { + hour12: false, + timeZone: "Asia/Shanghai", + }); + } catch { + return ""; + } +} + +function renderRankings() { + if (!state.data) return; + const regionsEl = $("#rankings-regions"); + const body = $("#rankings-body"); + const sub = $("#rankings-sub"); + const titleKicker = $("#rankings-kicker"); + const tipSlot = $("#rankings-tip-slot"); + if (!body) return; + + if (tipSlot) { + tipSlot.innerHTML = ""; + tipSlot.appendChild( + buildInfoTipButton(IMMORTAL_LEADERBOARD_TIP, "rankings-info-btn") + ); + } + if (titleKicker) { + titleKicker.textContent = "Immortal 排行榜"; + } + + const lb = leaderboardsData(); + const regions = lb.regions || {}; + const order = (lb.region_order || []).filter((k) => regions[k]); + const keys = order.length ? order : Object.keys(regions); + + if (!keys.length) { + if (regionsEl) regionsEl.innerHTML = ""; + if (sub) sub.textContent = ""; + body.innerHTML = + '<div class="rankings-empty">暂无排行数据</div>'; + return; + } + + ensureDefaultRankingRegion(); + if (!regions[state.rankingRegion]) { + state.rankingRegion = keys[0]; + } + + if (regionsEl) { + regionsEl.innerHTML = keys + .map((key) => { + const label = + (regions[key] && regions[key].label_zh) || + RANKING_REGION_LABELS[key] || + key; + const active = key === state.rankingRegion ? " active" : ""; + return `<button type="button" class="rankings-region-btn${active}" data-region="${escapeHtml(key)}" role="tab" aria-selected="${key === state.rankingRegion}">${escapeHtml(label)}</button>`; + }) + .join(""); + regionsEl.querySelectorAll(".rankings-region-btn").forEach((btn) => { + btn.addEventListener("click", () => { + const region = btn.dataset.region; + if (!region || region === state.rankingRegion) return; + state.rankingRegion = region; + syncStateToUrl(); + renderRankings(); + const board = $("#rankings-view"); + if (board) board.scrollTo(0, 0); + }); + }); + } + + const cur = regions[state.rankingRegion] || {}; + const posted = formatLeaderboardPosted(cur.time_posted); + if (sub) { + sub.textContent = posted ? `更新于 ${posted}` : ""; + } + + const rows = Array.isArray(cur.top100) ? cur.top100 : []; + if (!rows.length) { + body.innerHTML = '<div class="rankings-empty">该地区暂无榜单数据</div>'; + return; + } + + const trs = rows + .map((row) => { + const rank = row.rank != null ? String(row.rank) : ""; + const name = escapeHtml(row.name || ""); + const team = row.team_tag ? escapeHtml(row.team_tag) : ""; + const country = escapeHtml(countryLabelZh(row.country)); + return `<tr><td class="rankings-rank">${escapeHtml(rank)}</td><td class="rankings-name">${name}</td><td class="rankings-team">${team}</td><td class="rankings-country">${country}</td></tr>`; + }) + .join(""); + + body.innerHTML = ` + <table class="rankings-table"> + <thead><tr><th class="rankings-rank">排名</th><th>选手</th><th class="rankings-team">战队</th><th class="rankings-country">国籍</th></tr></thead> + <tbody>${trs}</tbody> + </table>`; +} + +function streamersData() { + return ( + (state.data && state.data.streamers) || { + fetched_at: null, + source: "manual+douyin", + platform_meta: {}, + streamers: [], + } + ); +} + +function streamerList() { + const rows = streamersData().streamers; + if (!Array.isArray(rows)) return []; + return [...rows].sort((a, b) => { + const fa = Number(a.follower_count); + const fb = Number(b.follower_count); + const na = Number.isFinite(fa) && fa >= 0 ? fa : -1; + const nb = Number.isFinite(fb) && fb >= 0 ? fb : -1; + return nb - na; + }); +} + +function streamerAvatarSrc(row) { + const av = row && row.avatar; + if (!av || typeof av !== "string") return ""; + const base = av.replace(/^.*[\\/]/, ""); + if (!base) return ""; + return assetUrl(`streamer-avatar/${encodeURIComponent(base)}`); +} + +function streamerVideoSrc(row) { + const v = row && row.video; + if (!v || typeof v !== "string") return ""; + const base = v.replace(/^.*[\\/]/, ""); + if (!base || base.startsWith("_")) return ""; + return assetUrl(`streamer-video/${encodeURIComponent(base)}`); +} + +/** Parse optional streamers.json `video_aspect` ("1916/2317" or [w, h]). */ +function parseStreamerVideoAspect(row) { + const raw = row && row.video_aspect; + if (Array.isArray(raw) && raw.length >= 2) { + const w = Number(raw[0]); + const h = Number(raw[1]); + if (w > 0 && h > 0) return { w, h }; + } + if (typeof raw === "string") { + const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*[/:\s]\s*(\d+(?:\.\d+)?)$/); + if (m) { + const w = Number(m[1]); + const h = Number(m[2]); + if (w > 0 && h > 0) return { w, h }; + } + } + return null; +} + +function applyStreamerVideoCrop(frame, video, bounds) { + if (!bounds) return false; + frame.style.aspectRatio = `${bounds.contentW} / ${bounds.contentH}`; + frame.classList.add("is-letterbox-crop"); + // object-position % aligns that point of the video with the same point of the + // box; padTop/(padTop+padBottom) places the content band in frame (not top/bottom). + const padY = (bounds.padTop || 0) + (bounds.padBottom || 0); + if (padY > 0) { + const yPct = ((bounds.padTop || 0) / padY) * 100; + video.style.objectPosition = `center ${yPct}%`; + } else { + video.style.objectPosition = "center center"; + } + frame.dataset.letterboxCrop = "1"; + return true; +} + +/** Douyin-style portrait: shorter crop frame + more pad below gameplay band. */ +function estimatePortraitLetterbox(w, h) { + const contentH = Math.max(Math.round(w * 9 / 16), Math.round(h * 0.68)); + const padTotal = Math.max(0, h - contentH); + const padTop = Math.round(padTotal * 0.38); + return { + contentW: w, + contentH, + padTop, + padBottom: padTotal - padTop, + padLeft: 0, + padRight: 0, + }; +} + +/** Build crop bounds from explicit content size; Douyin bias when height is shorter. */ +function boundsFromContentAspect(w, h, contentW, contentH) { + const cw = Math.min(contentW, w); + const ch = Math.min(contentH, h); + const padTotal = Math.max(0, h - ch); + const padTop = Math.round(padTotal * 0.38); + return { + contentW: cw, + contentH: ch, + padTop, + padBottom: padTotal - padTop, + padLeft: 0, + padRight: 0, + }; +} + +/** Fit streamer clip frame; landscape stays contain; portrait uses cover crop frame. */ +function fitStreamerVideoFrame(video, frame, row) { + const w = video.videoWidth; + const h = video.videoHeight; + if (!w || !h) return; + const fitRaw = row && row.video_fit; + const forceContain = fitRaw === "contain"; + const forceCrop = fitRaw === "cover" || row?.video_crop === true; + frame.classList.remove("is-letterbox-crop"); + delete frame.dataset.letterboxCrop; + video.style.objectPosition = ""; + frame.style.aspectRatio = `${w} / ${h}`; + frame.classList.toggle("is-landscape", w >= h); + if (forceContain) return; + + const manualAspect = parseStreamerVideoAspect(row); + if (manualAspect) { + applyStreamerVideoCrop( + frame, + video, + boundsFromContentAspect(w, h, manualAspect.w, manualAspect.h), + ); + return; + } + + const portraitish = h > w * (forceCrop ? 1.02 : 1.15); + if (!portraitish) return; + + // Prefer heuristic over canvas scan (CORS / dark frames are unreliable). + applyStreamerVideoCrop(frame, video, estimatePortraitLetterbox(w, h)); +} + +function pauseOtherStreamerVideos(current) { + document.querySelectorAll("video.streamer-video").forEach((el) => { + if (el !== current && !el.paused) el.pause(); + }); +} + +/** Per-clip opt-in: user unmutes via native video controls only. */ +function streamerVideoUserSoundOn(video) { + return video instanceof HTMLVideoElement && video.dataset.streamerSoundOn === "1"; +} + +function bindStreamerVideoSoundToggle(video) { + if (!(video instanceof HTMLVideoElement) || video.dataset.streamerSoundBound) return; + video.dataset.streamerSoundBound = "1"; + video.addEventListener("volumechange", () => { + if (video.muted) delete video.dataset.streamerSoundOn; + else video.dataset.streamerSoundOn = "1"; + }); +} + +function playStreamerVideo(video) { + if (!(video instanceof HTMLVideoElement)) return; + pauseOtherStreamerVideos(video); + // Scroll autoplay stays muted unless the user explicitly unmuted this clip. + if (!streamerVideoUserSoundOn(video)) { + video.muted = true; + video.setAttribute("muted", ""); + } + const p = video.play(); + if (p && typeof p.catch === "function") { + p.catch(() => { + video.muted = true; + video.setAttribute("muted", ""); + delete video.dataset.streamerSoundOn; + const p2 = video.play(); + if (p2 && typeof p2.catch === "function") p2.catch(() => {}); + }); + } +} + +/** Play streamer clips when scrolled near viewport center (tall cards). */ +let streamerVideoObserver = null; +/** @type {Map<HTMLVideoElement, number>} */ +const streamerVideoVisibility = new Map(); + +function resetStreamerVideoObserver() { + if (streamerVideoObserver) { + streamerVideoObserver.disconnect(); + streamerVideoObserver = null; + } + streamerVideoVisibility.clear(); +} + +function pickActiveStreamerVideo() { + const vh = window.innerHeight || document.documentElement.clientHeight || 1; + const mid = vh * 0.45; + let best = null; + let bestScore = -Infinity; + for (const [video, ratio] of streamerVideoVisibility) { + if (!(video instanceof HTMLVideoElement) || !video.isConnected) continue; + if (!(ratio > 0)) continue; + const rect = video.getBoundingClientRect(); + if (rect.height <= 0 || rect.width <= 0) continue; + const videoMid = (rect.top + rect.bottom) / 2; + const dist = Math.abs(videoMid - mid); + // Prefer more visible + closer to the visual center band. + const score = ratio * vh - dist; + if (score > bestScore) { + bestScore = score; + best = video; + } + } + return best; +} + +function syncStreamerVideoPlayback() { + const active = pickActiveStreamerVideo(); + document.querySelectorAll("video.streamer-video").forEach((v) => { + if (v === active) { + if (v.paused) playStreamerVideo(v); + } else if (!v.paused) { + v.pause(); + } + }); +} + +function ensureStreamerVideoObserver() { + if (streamerVideoObserver || typeof IntersectionObserver !== "function") { + return streamerVideoObserver; + } + streamerVideoObserver = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const video = entry.target; + if (!(video instanceof HTMLVideoElement)) continue; + streamerVideoVisibility.set( + video, + entry.isIntersecting ? entry.intersectionRatio : 0 + ); + } + syncStreamerVideoPlayback(); + }, + { + // Shrink root to a mid-screen band so tall cards don't need 45% of video on screen. + root: null, + rootMargin: "-12% 0px -32% 0px", + threshold: [0, 0.05, 0.1, 0.2, 0.35, 0.5, 0.75, 1], + } + ); + return streamerVideoObserver; +} + +function observeStreamerVideo(video) { + bindStreamerVideoSoundToggle(video); + const obs = ensureStreamerVideoObserver(); + if (obs) obs.observe(video); +} + +/** Compact Chinese count: 1234 / 1.2万 / 1.2亿 */ +function formatStreamerCount(n) { + if (n == null || n === "" || Number.isNaN(Number(n))) return "—"; + const v = Number(n); + if (!Number.isFinite(v) || v < 0) return "—"; + if (v < 10000) return String(Math.round(v)); + if (v < 100000000) { + const wan = v / 10000; + const text = wan >= 100 ? String(Math.round(wan)) : wan.toFixed(1).replace(/\.0$/, ""); + return `${text}万`; + } + const yi = v / 100000000; + const text = yi >= 100 ? String(Math.round(yi)) : yi.toFixed(1).replace(/\.0$/, ""); + return `${text}亿`; +} + +function formatStreamerFetched(iso) { + if (!iso || typeof iso !== "string") return ""; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +function buildStreamerMetaLine(row) { + const platform = String(row.platform || "").toLowerCase(); + const uniqueId = row.unique_id || ""; + const parts = []; + if (uniqueId) { + parts.push( + platform === "douyin" + ? `抖音号:${uniqueId}` + : platform === "bilibili" + ? `UID:${uniqueId}` + : String(uniqueId), + ); + } + if (platform === "bilibili") { + if (Number.isFinite(Number(row.following_count))) { + parts.push(`${formatStreamerCount(row.following_count)}关注`); + } + } else if (Number.isFinite(Number(row.total_favorited))) { + parts.push(`${formatStreamerCount(row.total_favorited)}获赞`); + } + if (Number.isFinite(Number(row.follower_count))) { + parts.push(`${formatStreamerCount(row.follower_count)}粉丝`); + } + if (!parts.length) return null; + + // Douyin user-search card: item spans + 1px sep with ~8px side margin (not raw "|") + const meta = document.createElement("div"); + meta.className = "streamer-meta"; + parts.forEach((text, i) => { + if (i > 0) { + const sep = document.createElement("span"); + sep.className = "streamer-meta-sep"; + sep.setAttribute("aria-hidden", "true"); + meta.appendChild(sep); + } + const item = document.createElement("span"); + item.className = "streamer-meta-item"; + item.textContent = text; + meta.appendChild(item); + }); + return meta; +} + +function buildStreamerHeroTags(heroes) { + const heroRow = document.createElement("div"); + heroRow.className = "streamer-heroes"; + for (const key of heroes.slice(0, 8)) { + const h = heroByKey(key); + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = "streamer-hero-chip"; + chip.title = (h && h.name_loc) || key; + const himg = document.createElement("img"); + himg.src = portraitSrc(key); + himg.alt = (h && h.name_loc) || key; + himg.loading = "lazy"; + chip.appendChild(himg); + const label = document.createElement("span"); + label.textContent = (h && h.name_loc) || key; + chip.appendChild(label); + chip.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + state.page = "heroes"; + state.selectedKey = key; + state.detailTab = "streamers"; + state.inspect = null; + syncStateToUrl(); + render(); + }); + heroRow.appendChild(chip); + } + return heroRow; +} + +function buildStreamerCard(row) { + const card = document.createElement("article"); + card.className = "streamer-card"; + const sid = row.id || ""; + if (sid) card.dataset.streamerId = sid; + + const nick = row.nickname || sid || "未命名主播"; + const signature = row.signature || ""; + + // Row 1: avatar | nickname (flex) | follow + const head = document.createElement("div"); + head.className = "streamer-card-head"; + + // Prefer explicit is_live when present; otherwise treat live_url as live proxy + const isLive = + typeof row.is_live === "boolean" ? row.is_live : Boolean(row.live_url); + const liveHref = isLive && row.live_url ? String(row.live_url) : ""; + const avatarWrap = liveHref + ? document.createElement("a") + : document.createElement("div"); + avatarWrap.className = liveHref + ? "streamer-avatar-wrap is-live" + : "streamer-avatar-wrap"; + if (liveHref) { + avatarWrap.href = liveHref; + avatarWrap.target = "_blank"; + avatarWrap.rel = "noopener noreferrer"; + avatarWrap.title = "进入直播间"; + avatarWrap.setAttribute("aria-label", `${nick} 直播中,点击进入直播间`); + // Douyin: static ring + pulsing ring (expand/fade) beside shrinking avatar. + const ringBase = document.createElement("span"); + ringBase.className = "streamer-live-ring"; + ringBase.setAttribute("aria-hidden", "true"); + const ringPulse = document.createElement("span"); + ringPulse.className = "streamer-live-ring streamer-live-ring-pulse"; + ringPulse.setAttribute("aria-hidden", "true"); + avatarWrap.appendChild(ringBase); + avatarWrap.appendChild(ringPulse); + } + const avatarInner = document.createElement("span"); + avatarInner.className = "streamer-avatar-inner"; + const avatarSrc = streamerAvatarSrc(row); + if (avatarSrc) { + const img = document.createElement("img"); + img.className = "streamer-avatar"; + img.src = avatarSrc; + img.alt = nick; + img.loading = "lazy"; + avatarInner.appendChild(img); + } else { + const ph = document.createElement("div"); + ph.className = "streamer-avatar streamer-avatar-fallback"; + ph.textContent = (nick || "?").slice(0, 1); + avatarInner.appendChild(ph); + } + avatarWrap.appendChild(avatarInner); + if (liveHref) { + const liveBadge = document.createElement("span"); + liveBadge.className = "streamer-live-badge"; + liveBadge.textContent = "直播"; + avatarWrap.appendChild(liveBadge); + } + head.appendChild(avatarWrap); + + const nameEl = document.createElement("h3"); + nameEl.className = "streamer-name"; + nameEl.textContent = nick; + head.appendChild(nameEl); + + const actions = document.createElement("div"); + actions.className = "streamer-actions"; + if (row.profile_url) { + const profile = document.createElement("a"); + profile.className = "streamer-btn streamer-btn-follow"; + profile.href = row.profile_url; + profile.target = "_blank"; + profile.rel = "noopener noreferrer"; + profile.textContent = "关注"; + actions.appendChild(profile); + } + if (actions.childNodes.length) head.appendChild(actions); + card.appendChild(head); + + // Meta + signature span full card width below the avatar row (Douyin-style) + const metaEl = buildStreamerMetaLine(row); + if (metaEl) card.appendChild(metaEl); + + if (signature) { + const sig = document.createElement("p"); + sig.className = "streamer-signature"; + sig.textContent = signature; + sig.title = signature; + card.appendChild(sig); + } + + const heroes = Array.isArray(row.heroes) ? row.heroes.filter(Boolean) : []; + if (heroes.length) { + card.appendChild(buildStreamerHeroTags(heroes)); + } + + const videoSrc = streamerVideoSrc(row); + if (videoSrc) { + const vwrap = document.createElement("div"); + vwrap.className = "streamer-video-wrap"; + const frame = document.createElement("div"); + frame.className = "streamer-video-frame"; + const video = document.createElement("video"); + video.className = "streamer-video"; + video.src = videoSrc; + video.loop = true; + video.controls = true; + video.playsInline = true; + video.preload = "auto"; + video.setAttribute("controls", ""); + video.setAttribute("playsinline", ""); + // Autoplay muted; user unmutes via native controls when they want sound. + video.muted = true; + video.setAttribute("muted", ""); + if (row.video_title) video.setAttribute("aria-label", String(row.video_title)); + // Canvas letterbox scan needs CORS when clips are served from OSS. + if (staticAssetBase()) video.crossOrigin = "anonymous"; + const fitFrameToVideo = () => fitStreamerVideoFrame(video, frame, row); + video.addEventListener("loadedmetadata", fitFrameToVideo); + video.addEventListener("loadeddata", fitFrameToVideo); + video.addEventListener("canplay", fitFrameToVideo); + if (video.readyState >= 2) fitFrameToVideo(); + else if (video.readyState >= 1) fitFrameToVideo(); + video.addEventListener("error", () => vwrap.remove()); + frame.appendChild(video); + vwrap.appendChild(frame); + observeStreamerVideo(video); + card.appendChild(vwrap); + } + + return card; +} + +function buildStreamerCards(rows, { emptyText } = {}) { + resetStreamerVideoObserver(); + const wrap = document.createElement("div"); + wrap.className = "streamers-grid"; + if (!rows.length) { + const empty = document.createElement("div"); + empty.className = "streamers-empty"; + empty.textContent = emptyText || "暂无主播数据"; + wrap.appendChild(empty); + return wrap; + } + for (const row of rows) { + wrap.appendChild(buildStreamerCard(row)); + } + return wrap; +} + +function buildHeroStreamersPanel(heroKey) { + const panel = document.createElement("div"); + panel.className = "detail-streamers-panel streamers-center-wrap"; + const rows = streamerList().filter((row) => { + const heroes = row.heroes; + return Array.isArray(heroes) && heroes.includes(heroKey); + }); + panel.appendChild( + buildStreamerCards(rows, { + emptyText: "暂无收录常玩该英雄的主播", + }) + ); + return panel; +} + +function renderStreamers() { + if (!state.data) return; + const body = $("#streamers-body"); + const foot = $("#streamers-foot"); + if (!body) return; + const data = streamersData(); + const rows = streamerList(); + const fetched = formatStreamerFetched(data.fetched_at); + body.innerHTML = ""; + body.appendChild( + buildStreamerCards(rows, { emptyText: "暂无主播数据" }) + ); + if (foot) { + foot.textContent = fetched ? `更新于 ${fetched}` : ""; + foot.setAttribute("aria-hidden", fetched ? "false" : "true"); + } +} + +const TRENDS_MIN_END_PICK = 200; +const TRENDS_TOP_N = 100; + +function weekWinrateFrac(w) { + if (!w) return null; + if (w.wr != null && Number.isFinite(Number(w.wr))) return Number(w.wr); + const pick = Number(w.pick) || 0; + const win = Number(w.win) || 0; + return pick > 0 ? win / pick : null; +} + +function formatFracAsPct(frac) { + if (frac == null || Number.isNaN(frac)) return "—"; + return formatRatePct(Math.round(Number(frac) * 1000) / 10); +} + +function formatDeltaPp(deltaFrac) { + if (deltaFrac == null || Number.isNaN(deltaFrac)) { + return { text: "—", cls: "" }; + } + const pp = Math.round(Number(deltaFrac) * 1000) / 10; + if (pp === 0) return { text: "0%", cls: "is-flat" }; + if (pp > 0) return { text: `+${pp}%`, cls: "is-up" }; + return { text: `${pp}%`, cls: "is-down" }; +} + +/** Compact SVG sparkline (values oldest→newest, left→right). */ +function buildMiniSparkline(values, colorClass) { + const known = (values || []).filter((v) => v != null && Number.isFinite(v)); + if (known.length < 2) { + return '<span class="trends-spark-empty">—</span>'; + } + const w = 64; + const h = 20; + const padX = 4; + const padY = 3; + let lo = Math.min(...known); + let hi = Math.max(...known); + if (hi - lo < 1e-6) { + lo -= 0.01; + hi += 0.01; + } + const n = values.length; + const pts = []; + for (let i = 0; i < n; i++) { + const v = values[i]; + if (v == null || !Number.isFinite(v)) continue; + const x = padX + (n <= 1 ? 0 : (i / (n - 1)) * (w - padX * 2)); + const y = padY + (1 - (v - lo) / (hi - lo)) * (h - padY * 2); + pts.push({ x, y }); + } + if (pts.length < 2) { + return '<span class="trends-spark-empty">—</span>'; + } + const line = pts.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" "); + const dots = pts + .map( + (p) => + `<rect class="trends-spark-dot" x="${(p.x - 1.5).toFixed(1)}" y="${(p.y - 1.5).toFixed(1)}" width="3" height="3" rx="0.4" />` + ) + .join(""); + return `<svg class="trends-spark ${escapeHtml(colorClass || "")}" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true"><polyline class="trends-spark-line" fill="none" points="${line}" />${dots}</svg>`; +} + +function buildTrendsBoardRows(bracket) { + const pack = stratzMetaPack(); + const byHero = pack.by_hero || {}; + const totalsByWeek = new Map(); + for (const cell of Object.values(byHero)) { + const weeks = (cell && cell.weeks && cell.weeks[bracket]) || []; + for (const w of weeks) { + const wk = w.week; + if (wk == null) continue; + totalsByWeek.set(wk, (totalsByWeek.get(wk) || 0) + (Number(w.pick) || 0)); + } + } + + const rows = []; + for (const [key, cell] of Object.entries(byHero)) { + const weeks = (cell && cell.weeks && cell.weeks[bracket]) || []; + if (!Array.isArray(weeks) || weeks.length < 2) continue; + const latest = weeks[0]; + const oldest = weeks[weeks.length - 1]; + const endPick = Number(latest.pick) || 0; + if (endPick < TRENDS_MIN_END_PICK) continue; + + const chron = weeks.slice().reverse(); + const wrSeries = chron.map(weekWinrateFrac); + const prSeries = chron.map((w) => { + const tot = totalsByWeek.get(w.week) || 0; + if (tot <= 0) return null; + return (Number(w.pick) || 0) / (tot / 10); + }); + const wrStart = weekWinrateFrac(oldest); + const wrEnd = weekWinrateFrac(latest); + const prStart = prSeries[0]; + const prEnd = prSeries[prSeries.length - 1]; + rows.push({ + key, + wrStart, + wrEnd, + wrDelta: + wrStart != null && wrEnd != null ? wrEnd - wrStart : null, + wrSeries, + prStart, + prEnd, + prDelta: + prStart != null && prEnd != null ? prEnd - prStart : null, + prSeries, + endPick, + }); + } + + const dir = state.trendsSortDir === "asc" ? 1 : -1; + const field = state.trendsSort === "pr_end" ? "prEnd" : "wrEnd"; + rows.sort((a, b) => { + const av = a[field]; + const bv = b[field]; + if (av == null && bv == null) return a.key.localeCompare(b.key); + if (av == null) return 1; + if (bv == null) return -1; + if (av !== bv) return (av - bv) * dir; + return a.key.localeCompare(b.key); + }); + return rows.slice(0, TRENDS_TOP_N); +} + +function renderTrends() { + const body = $("#trends-body"); + const bracketsEl = $("#trends-brackets"); + const sortEl = $("#trends-sort"); + const sub = $("#trends-sub"); + const kicker = $("#trends-kicker"); + if (!body) return; + + const pack = stratzMetaPack(); + const byHero = pack.by_hero || {}; + const hasWeeks = Object.values(byHero).some( + (c) => c && c.weeks && Object.keys(c.weeks).length + ); + if (!hasWeeks) { + if (bracketsEl) bracketsEl.innerHTML = ""; + if (sortEl) sortEl.innerHTML = ""; + if (sub) sub.textContent = ""; + body.innerHTML = + '<div class="rankings-empty">暂无走势数据</div>'; + return; + } + + const brackets = (pack.brackets || Object.keys(BRACKET_LABELS)).filter((b) => + Object.prototype.hasOwnProperty.call(BRACKET_LABELS, b) + ); + if (!brackets.includes(state.trendsBracket)) { + state.trendsBracket = brackets.includes("legend") + ? "legend" + : brackets[0]; + } + + const weeksTake = Number(pack.weeks_take) || 8; + if (kicker) kicker.textContent = `过去 ${weeksTake} 周走势`; + + if (bracketsEl) { + bracketsEl.innerHTML = ""; + bracketsEl.appendChild( + buildBracketIconPicker({ + selectedKey: state.trendsBracket, + ariaLabel: "走势段位", + showImmortalTip: false, + onChange: (key) => { + state.trendsBracket = key; + syncStateToUrl(); + renderTrends(); + const board = $("#trends-view"); + if (board) board.scrollTo(0, 0); + }, + }) + ); + } + + if (sortEl) { + sortEl.innerHTML = [ + { id: "wr_end", label: "按胜率" }, + { id: "pr_end", label: "按上场率" }, + ] + .map((m) => { + const active = state.trendsSort === m.id ? " active" : ""; + return `<button type="button" class="trends-sort-btn${active}" data-sort="${m.id}" role="tab" aria-selected="${state.trendsSort === m.id}">${m.label}</button>`; + }) + .join(""); + sortEl.querySelectorAll(".trends-sort-btn").forEach((btn) => { + btn.addEventListener("click", () => { + const key = btn.dataset.sort; + if (!key || key === state.trendsSort) return; + state.trendsSort = key; + state.trendsSortDir = "desc"; + syncStateToUrl(); + renderTrends(); + const board = $("#trends-view"); + if (board) board.scrollTo(0, 0); + }); + }); + } + + const fetched = pack.fetched_at + ? `更新于 ${String(pack.fetched_at).slice(0, 10)}` + : ""; + if (sub) sub.textContent = fetched; + + const rows = buildTrendsBoardRows(state.trendsBracket); + if (!rows.length) { + body.innerHTML = '<div class="rankings-empty">该段位暂无走势数据</div>'; + return; + } + + const sortArrow = (key) => { + if (state.trendsSort !== key) return ""; + return state.trendsSortDir === "asc" ? " ↑" : " ↓"; + }; + const sortCls = (key) => + "trends-sort-th" + (state.trendsSort === key ? " is-active" : ""); + + const trs = rows + .map((row, i) => { + const hero = heroByKey(row.key); + const name = escapeHtml(hero?.name_loc || row.key); + const portrait = escapeHtml(portraitSrc(row.key)); + const wrDelta = formatDeltaPp(row.wrDelta); + const prDelta = formatDeltaPp(row.prDelta); + return `<tr class="trends-row" data-hero="${escapeHtml(row.key)}"> + <td class="rankings-rank">${i + 1}</td> + <td class="meta-hero"><img class="meta-portrait" src="${portrait}" alt="" loading="lazy" decoding="async" /><span>${name}</span></td> + <td class="trends-num">${formatFracAsPct(row.wrStart)}</td> + <td class="trends-spark-cell">${buildMiniSparkline(row.wrSeries, "is-wr")}</td> + <td class="trends-num trends-end">${formatFracAsPct(row.wrEnd)}</td> + <td class="trends-delta ${wrDelta.cls}">${wrDelta.text}</td> + <td class="trends-num">${formatFracAsPct(row.prStart)}</td> + <td class="trends-spark-cell">${buildMiniSparkline(row.prSeries, "is-pr")}</td> + <td class="trends-num trends-end">${formatFracAsPct(row.prEnd)}</td> + <td class="trends-delta ${prDelta.cls}">${prDelta.text}</td> + </tr>`; + }) + .join(""); + + body.innerHTML = ` + <table class="rankings-table trends-table"> + <thead> + <tr> + <th rowspan="2" class="rankings-rank">#</th> + <th rowspan="2">英雄</th> + <th colspan="4" class="trends-group trends-group-wr ${sortCls("wr_end")}" data-sort="wr_end" title="按末期胜率排序">胜率${sortArrow("wr_end")}</th> + <th colspan="4" class="trends-group trends-group-pr ${sortCls("pr_end")}" data-sort="pr_end" title="按末期上场率排序">上场率${sortArrow("pr_end")}</th> + </tr> + <tr> + <th>初期</th> + <th>走势</th> + <th class="${sortCls("wr_end")}" data-sort="wr_end" title="按末期胜率排序">末期${sortArrow("wr_end")}</th> + <th>变化</th> + <th>初期</th> + <th>走势</th> + <th class="${sortCls("pr_end")}" data-sort="pr_end" title="按末期上场率排序">末期${sortArrow("pr_end")}</th> + <th>变化</th> + </tr> + </thead> + <tbody>${trs}</tbody> + </table>`; + + const applyTrendsSortClick = (key) => { + if (!key) return; + if (state.trendsSort === key) { + state.trendsSortDir = + state.trendsSortDir === "desc" ? "asc" : "desc"; + } else { + state.trendsSort = key; + state.trendsSortDir = "desc"; + } + syncStateToUrl(); + renderTrends(); + }; + + body.querySelectorAll("th.trends-sort-th").forEach((th) => { + th.addEventListener("click", () => applyTrendsSortClick(th.dataset.sort)); + }); + + body.querySelectorAll("tr.trends-row").forEach((tr) => { + tr.addEventListener("click", () => { + const key = tr.dataset.hero; + if (!key || !heroByKey(key)) return; + state.page = "heroes"; + state.selectedKey = key; + state.detailTab = "trends"; + state.statsBracket = state.trendsBracket; + state.inspect = null; + syncStateToUrl(); + render(); + }); + }); +} + + +function mechanicQueryMeta() { + return state.data.mechanic_query || { order: [], labels: {}, blurbs: {}, groups: [] }; +} + +function mechanicBlurb(effect) { + const blurbs = (mechanicQueryMeta().blurbs) || {}; + return blurbs[effect] || ""; +} + +function collectMechanicAbilities(effect) { + const byHero = (state.data.hero_abilities && state.data.hero_abilities.by_hero) || {}; + const out = []; + for (const hero of state.data.heroes || []) { + const cell = byHero[hero.key]; + if (!cell) continue; + for (const ab of cell.abilities || []) { + if (!ab || !ab.key) continue; + if (!(ab.tags || []).includes(effect)) continue; + out.push({ hero, ability: ab }); + } + } + out.sort((a, b) => { + const an = a.hero.name_loc || a.hero.key; + const bn = b.hero.name_loc || b.hero.key; + if (an !== bn) return an.localeCompare(bn, "zh"); + return (a.ability.name_loc || a.ability.key).localeCompare( + b.ability.name_loc || b.ability.key, + "zh" + ); + }); + return out; +} + +function collectMechanicItems(effect) { + const meta = state.data.items_meta || {}; + const out = []; + const seen = new Set(); + for (const row of Object.values(meta)) { + if (!row || !row.key || seen.has(row.key)) continue; + if (!(row.tags || []).includes(effect)) continue; + seen.add(row.key); + out.push(row); + } + out.sort((a, b) => { + const ac = a.cost == null ? 1e9 : Number(a.cost); + const bc = b.cost == null ? 1e9 : Number(b.cost); + if (ac !== bc) return ac - bc; + return (a.name_loc || a.key).localeCompare(b.name_loc || b.key, "zh"); + }); + return out; +} + +function renderMechanicsAside() { + const root = $("#mechanics-effects"); + if (!root) return; + const mq = mechanicQueryMeta(); + const labels = mq.labels || {}; + const groups = mq.groups && mq.groups.length + ? mq.groups + : [{ label: "机制", keys: mq.order || [] }]; + root.innerHTML = ""; + for (const g of groups) { + const wrap = document.createElement("div"); + wrap.className = "mechanics-group"; + const title = document.createElement("div"); + title.className = "mechanics-group-label"; + title.textContent = g.label || ""; + wrap.appendChild(title); + for (const key of g.keys || []) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "rankings-region-btn"; + if (key === state.mechanicEffect) btn.classList.add("active"); + btn.textContent = labels[key] || key; + btn.dataset.effect = key; + btn.addEventListener("click", () => { + if (state.mechanicEffect === key) return; + state.mechanicEffect = key; + syncStateToUrl(); + renderMechanics(); + }); + wrap.appendChild(btn); + } + root.appendChild(wrap); + } +} + +function renderMechanics() { + renderMechanicsAside(); + const body = $("#mechanics-body"); + if (!body) return; + const mq = mechanicQueryMeta(); + const labels = mq.labels || {}; + const effect = state.mechanicEffect || (mq.order && mq.order[0]) || "basic_dispel"; + state.mechanicEffect = effect; + const label = labels[effect] || effect; + + const abilities = collectMechanicAbilities(effect); + const items = collectMechanicItems(effect); + const blurb = mechanicBlurb(effect); + + body.innerHTML = ""; + const head = document.createElement("header"); + head.className = "mechanics-blurb"; + const headTitle = document.createElement("h3"); + headTitle.className = "mechanics-blurb-title"; + headTitle.textContent = label; + head.appendChild(headTitle); + const headText = document.createElement("p"); + headText.className = "mechanics-blurb-text"; + headText.textContent = blurb || ("列出施加「" + label + "」的技能与物品。"); + head.appendChild(headText); + body.appendChild(head); + + const skillsSec = document.createElement("section"); + skillsSec.className = "mechanics-section"; + const skillsTitle = document.createElement("h3"); + skillsTitle.className = "mechanics-section-title"; + skillsTitle.innerHTML = + "技能 <span class=\"mechanics-count\">" + abilities.length + "</span>"; + skillsSec.appendChild(skillsTitle); + const skillsList = document.createElement("div"); + skillsList.className = "mechanics-list"; + if (!abilities.length) { + const empty = document.createElement("p"); + empty.className = "mechanics-empty"; + empty.textContent = "暂无匹配技能"; + skillsList.appendChild(empty); + } else { + skillsList.classList.add("mechanics-skill-grid"); + for (const row of abilities) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "mechanics-skill-card"; + const portrait = document.createElement("span"); + portrait.className = "mechanics-skill-portrait"; + const heroImg = document.createElement("img"); + heroImg.className = "mechanics-hero-wide"; + heroImg.src = portraitSrc(row.hero.key); + heroImg.alt = row.hero.name_loc || row.hero.key; + heroImg.loading = "lazy"; + portrait.appendChild(heroImg); + const foot = document.createElement("span"); + foot.className = "mechanics-skill-foot"; + const abImg = document.createElement("img"); + abImg.className = "mechanics-ability"; + abImg.src = abilityIconSrc(row.ability.key); + abImg.alt = ""; + abImg.loading = "lazy"; + abImg.onerror = function () { + this.onerror = null; + this.src = assetUrl("ability/innate.png"); + }; + const textWrap = document.createElement("span"); + textWrap.className = "mechanics-text"; + const primary = document.createElement("span"); + primary.className = "mechanics-primary"; + primary.textContent = row.ability.name_loc || row.ability.key; + const secondary = document.createElement("span"); + secondary.className = "mechanics-secondary"; + secondary.textContent = row.hero.name_loc || row.hero.key; + textWrap.appendChild(primary); + textWrap.appendChild(secondary); + foot.appendChild(abImg); + foot.appendChild(textWrap); + btn.appendChild(portrait); + btn.appendChild(foot); + btn.title = `${row.hero.name_loc || row.hero.key} · ${row.ability.name_loc || row.ability.key}`; + btn.addEventListener("click", () => { + state.page = "heroes"; + state.selectedKey = row.hero.key; + state.detailTab = "skills"; + state.inspect = { type: "skill", id: "ability:" + row.ability.key }; + syncStateToUrl(); + render(); + }); + skillsList.appendChild(btn); + } + } + skillsSec.appendChild(skillsList); + body.appendChild(skillsSec); + + if (items.length) { + const itemsSec = document.createElement("section"); + itemsSec.className = "mechanics-section"; + const itemsTitle = document.createElement("h3"); + itemsTitle.className = "mechanics-section-title"; + itemsTitle.innerHTML = + "物品 <span class=\"mechanics-count\">" + items.length + "</span>"; + itemsSec.appendChild(itemsTitle); + const itemsList = document.createElement("div"); + itemsList.className = "mechanics-list"; + for (const row of items) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "mechanics-row"; + const itemImg = document.createElement("img"); + itemImg.className = "mechanics-item"; + itemImg.src = itemIconSrc(row.key); + itemImg.alt = ""; + itemImg.loading = "lazy"; + itemImg.onerror = function () { + this.onerror = null; + this.src = itemIconOssSrc(row.key); + }; + const textWrap = document.createElement("span"); + textWrap.className = "mechanics-text"; + const primary = document.createElement("span"); + primary.className = "mechanics-primary"; + primary.textContent = row.name_loc || row.key; + const secondary = document.createElement("span"); + secondary.className = "mechanics-secondary"; + secondary.textContent = row.key; + textWrap.appendChild(primary); + textWrap.appendChild(secondary); + btn.appendChild(itemImg); + btn.appendChild(textWrap); + if (row.cost != null && row.cost !== "") { + const cost = document.createElement("span"); + cost.className = "mechanics-cost"; + cost.textContent = String(row.cost); + btn.appendChild(cost); + } + btn.addEventListener("click", () => { + state.page = "items"; + state.selectedItemKey = row.key; + state.selectedKey = null; + state.inspect = null; + syncStateToUrl(); + render(); + }); + itemsList.appendChild(btn); + } + itemsSec.appendChild(itemsList); + body.appendChild(itemsSec); + } +} + + function setPage(page) { - if (page !== "heroes" && page !== "items" && page !== "patches") return; + if ( + page !== "heroes" && + page !== "rankings" && + page !== "streamers" && + page !== "trends" && + page !== "mechanics" && + page !== "items" && + page !== "patches" + ) + return; closeTalentPopover(); state.page = page; if (page === "heroes") { state.selectedItemKey = null; + } else if (page === "rankings") { + state.selectedKey = null; + state.selectedItemKey = null; + state.inspect = null; + } else if (page === "streamers") { + state.selectedKey = null; + state.selectedItemKey = null; + state.inspect = null; + } else if (page === "trends") { + state.selectedKey = null; + state.selectedItemKey = null; + state.inspect = null; + } else if (page === "mechanics") { + state.selectedKey = null; + state.selectedItemKey = null; + state.inspect = null; } else if (page === "items") { state.selectedKey = null; state.inspect = null; + } else if (page === "patches") { + state.selectedKey = null; + state.selectedItemKey = null; + state.inspect = null; } syncStateToUrl(); render(); @@ -1877,12 +4500,20 @@ function syncChrome() { const heroesTb = $("#heroes-toolbar"); const itemsTb = $("#items-toolbar"); const heroesView = $("#heroes-view"); + const rankingsView = $("#rankings-view"); + const streamersView = $("#streamers-view"); + const trendsView = $("#trends-view"); + const mechanicsView = $("#mechanics-view"); const itemsView = $("#items-view"); const patchesView = $("#patches-view"); const detail = $("#detail"); if (heroesTb) heroesTb.classList.toggle("hidden", state.page !== "heroes"); if (itemsTb) itemsTb.classList.toggle("hidden", state.page !== "items"); if (heroesView) heroesView.classList.toggle("hidden", state.page !== "heroes"); + if (rankingsView) rankingsView.classList.toggle("hidden", state.page !== "rankings"); + if (streamersView) streamersView.classList.toggle("hidden", state.page !== "streamers"); + if (trendsView) trendsView.classList.toggle("hidden", state.page !== "trends"); + if (mechanicsView) mechanicsView.classList.toggle("hidden", state.page !== "mechanics"); if (itemsView) itemsView.classList.toggle("hidden", state.page !== "items"); if (patchesView) patchesView.classList.toggle("hidden", state.page !== "patches"); if (detail) detail.classList.toggle("hidden", state.page !== "heroes"); @@ -1894,6 +4525,14 @@ function render() { renderTagbar(); renderBoard(); renderDetail(); + } else if (state.page === "rankings") { + renderRankings(); + } else if (state.page === "streamers") { + renderStreamers(); + } else if (state.page === "trends") { + renderTrends(); + } else if (state.page === "mechanics") { + renderMechanics(); } else if (state.page === "items") { renderItemDetail(); renderItemShop(); @@ -1918,7 +4557,10 @@ function applyPatch(patch) { return; } // Page (default heroes on bad/missing). - if (patch.page && ["heroes", "items", "patches"].includes(patch.page)) { + if ( + patch.page && + ["heroes", "rankings", "streamers", "trends", "mechanics", "items", "patches"].includes(patch.page) + ) { state.page = patch.page; } else { state.page = "heroes"; @@ -1928,9 +4570,9 @@ function applyPatch(patch) { state.selectedKey = patch.heroKey; if ( patch.detailTab && - ["skills", "core", "fears", "patches"].includes(patch.detailTab) + ["skills", "core", "fears", "trends", "stats", "matchups", "matches", "streamers", "patches"].includes(patch.detailTab) ) { - state.detailTab = patch.detailTab; + state.detailTab = patch.detailTab === "stats" ? "trends" : patch.detailTab; } else { state.detailTab = "skills"; } @@ -1940,6 +4582,41 @@ function applyPatch(patch) { } // Reset inspect so ensureHeroInspectDefault re-picks per tab (inspect is URL-less). state.inspect = null; + // Trends board bracket + sort (top-level 走势 page). + if ( + patch.trendsBracket && + Object.prototype.hasOwnProperty.call(BRACKET_LABELS, patch.trendsBracket) + ) { + state.trendsBracket = patch.trendsBracket; + } else if (state.page === "trends" && !state.trendsBracket) { + state.trendsBracket = "legend"; + } + if (patch.trendsSort === "wr_end" || patch.trendsSort === "pr_end") { + state.trendsSort = patch.trendsSort; + } else if (state.page === "trends" && patch.page) { + state.trendsSort = "wr_end"; + } + // Mechanics page effect key. + { + const mechOrder = (state.data.mechanic_query && state.data.mechanic_query.order) || []; + if (patch.mechanicEffect && mechOrder.includes(patch.mechanicEffect)) { + state.mechanicEffect = patch.mechanicEffect; + } else if (state.page === "mechanics") { + state.mechanicEffect = mechOrder[0] || "basic_dispel"; + } + } + // Immortal region (rankings page). + { + const lb = leaderboardsData(); + const regions = lb.regions || {}; + const order = lb.region_order || []; + const valid = new Set([...order, ...Object.keys(regions)]); + if (patch.rankingRegion && valid.has(patch.rankingRegion)) { + state.rankingRegion = patch.rankingRegion; + } else if (state.page === "rankings") { + state.rankingRegion = lb.default_region || order[0] || "china"; + } + } // Item (items page) — must exist in the shop catalog. if (patch.itemKey && shopItem(patch.itemKey)) { state.selectedItemKey = patch.itemKey; @@ -2067,12 +4744,27 @@ async function main() { bindSearch(); bindPageChrome(); const brandLogo = document.querySelector(".brand-logo"); - if (brandLogo) brandLogo.src = assetUrl("ui-icon/dota2_logo.png"); + if (brandLogo) brandLogo.src = assetUrl("/ui-icon/dota2_logo_wordmark.png"); + const favicon = document.querySelector('link[rel="icon"]'); + if (favicon) favicon.href = assetUrl("/ui-icon/dota2_logo.png"); const res = await fetch("data.json"); if (!res.ok) throw new Error(`data.json: HTTP ${res.status}`); state.data = await res.json(); state.data.relations = state.data.relations || { counters: [], synergies: [] }; state.data.hero_items = state.data.hero_items || { items: {}, by_hero: {} }; + state.data.hero_stats = state.data.hero_stats || { + brackets: [], + by_hero: {}, + totals: {}, + fetched_at: null, + window_days: 7, + window_label_zh: "近约 7 天公开对局", + }; + state.data.hero_matches = state.data.hero_matches || { + meta: {}, + items: {}, + by_hero: {}, + }; state.data.hero_item_fears = state.data.hero_item_fears || { items: {}, by_hero: {} }; state.data.hero_abilities = state.data.hero_abilities || { by_hero: {} }; state.data.item_shop = state.data.item_shop || { @@ -2084,7 +4776,30 @@ async function main() { state.data.patches = state.data.patches || []; state.data.patch_lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} }; state.data.patch_details = state.data.patch_details || {}; + state.data.leaderboards = state.data.leaderboards || { + fetched_at: null, + source: "valve", + default_region: "china", + region_order: ["china", "europe", "americas", "se_asia"], + regions: {}, + }; + state.data.pro_matches = state.data.pro_matches || { + meta: {}, + items: {}, + pros: {}, + by_pro: {}, + by_hero: {}, + }; + state.data.streamers = state.data.streamers || { + fetched_at: null, + source: "manual+douyin", + platform_meta: { + douyin: { label_zh: "抖音", icon: "ui-icon/platform_douyin.png" }, + }, + streamers: [], + }; _skillEntriesCache.clear(); + _itemIdIndex = null; _resourceBarScaleCache = null; _innateAbilityKeys = null; installRouter({ getState: () => state, applyPatch }); diff --git a/web/relations/config.js b/web/relations/config.js index c5b9ee3..ec6a883 100644 --- a/web/relations/config.js +++ b/web/relations/config.js @@ -1,4 +1,4 @@ /* Local defaults; production export overwrites via export_relations_site.py. */ -var SITE_VERSION = "0.4.2"; +var SITE_VERSION = "0.5.59"; var ABILITY_VIDEO_BASE = ""; var STATIC_ASSET_BASE = ""; diff --git a/web/relations/index.html b/web/relations/index.html index 209172e..3aa2ac3 100644 --- a/web/relations/index.html +++ b/web/relations/index.html @@ -3,13 +3,14 @@ <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> - <title>上分帝 - + DOTA2 上分帝 + +
-
- +
+ 上分帝
@@ -44,6 +49,67 @@
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+ +
+
+
+
@@ -70,8 +136,8 @@
- - - + + + diff --git a/web/relations/router.js b/web/relations/router.js index 91b5ca1..d8e9012 100644 --- a/web/relations/router.js +++ b/web/relations/router.js @@ -1,11 +1,20 @@ /* global window, history, location */ /** - * Hash-based router for the relations preview (web/relations). + * Hash-based router for the Climperor web site (web/relations). * * Synchronizes the browser URL with app state across these dimensions: - * - page: heroes | items | patches (top-level tab) - * - hero: selected hero key + detail sub-tab (skills|core|fears|patches) + * - page: heroes | rankings | streamers | trends | mechanics | items | patches (top-level tab) + * - hero: selected hero key + detail sub-tab + * (skills|core|fears|trends|matchups|matches|streamers|patches; legacy stats → trends) + * - rankings: Immortal leaderboard region + * #/rankings[/region] (legacy #/rankings/meta[/bracket] → players / china) + * - streamers: curated Douyin streamer directory + * #/streamers + * - trends: medal bracket for the 8-week win/pick board + * #/trends[/bracket][?sort=pr] (default bracket legend, sort wr omitted) + * - mechanics: applies-effect query (dispel / CC) + * #/mechanics[/{effect}] (default effect basic_dispel omitted) * - item: selected shop item key (items page) * - patch: selected version string (patches page; latest when absent) * - query params: tags=csv (heroes), q=search text (heroes + items) @@ -21,8 +30,44 @@ */ const ROUTE_DEFAULT = "#/heroes"; -const VALID_PAGES = ["heroes", "items", "patches"]; -const VALID_DETAIL_TABS = ["skills", "core", "fears", "patches"]; +const VALID_PAGES = ["heroes", "rankings", "streamers", "trends", "mechanics", "items", "patches"]; +const VALID_DETAIL_TABS = [ + "skills", + "core", + "fears", + "trends", + "stats", + "matchups", + "matches", + "streamers", + "patches", +]; +const DEFAULT_RANKING_REGION = "china"; +const DEFAULT_TRENDS_BRACKET = "legend"; +const DEFAULT_TRENDS_SORT = "wr_end"; +const VALID_TRENDS_SORTS = ["wr_end", "pr_end"]; +const TRENDS_SORT_URL = { wr_end: "wr", pr_end: "pr" }; +const TRENDS_SORT_FROM_URL = { wr: "wr_end", pr: "pr_end", wr_end: "wr_end", pr_end: "pr_end" }; +const DEFAULT_MECHANIC_EFFECT = "basic_dispel"; +const VALID_MECHANIC_EFFECTS = [ + "basic_dispel", + "strong_dispel", + "root", + "disarm", + "silence", + "mute", + "stun", + "hex", + "break", + "sleep", + "fear", + "taunt", + "blind", + "leash", + "invis", + "ethereal", + "cyclone", +]; let _deps = null; @@ -46,6 +91,10 @@ function parseHash(hash) { detailTab: null, itemKey: null, patchVersion: null, + rankingRegion: null, + trendsBracket: null, + trendsSort: null, + mechanicEffect: null, tags: null, query: null, itemQuery: null, @@ -64,6 +113,15 @@ function parseHash(hash) { if (page === "heroes") { if (segs[1]) out.heroKey = safeDecode(segs[1]); if (segs[2]) out.detailTab = safeDecode(segs[2]); + } else if (page === "rankings") { + // Legacy #/rankings/meta[/bracket] → Immortal players board (default region). + if (segs[1] && segs[1] !== "meta") { + out.rankingRegion = safeDecode(segs[1]); + } + } else if (page === "trends") { + if (segs[1]) out.trendsBracket = safeDecode(segs[1]); + } else if (page === "mechanics") { + if (segs[1]) out.mechanicEffect = safeDecode(segs[1]); } else if (page === "items") { if (segs[1]) out.itemKey = safeDecode(segs[1]); } else if (page === "patches") { @@ -81,6 +139,12 @@ function parseHash(hash) { if (page === "items") out.itemQuery = qParam; else out.query = qParam; } + if (page === "trends") { + const sortParam = params.get("sort"); + if (sortParam && TRENDS_SORT_FROM_URL[sortParam]) { + out.trendsSort = TRENDS_SORT_FROM_URL[sortParam]; + } + } return out; } @@ -99,10 +163,33 @@ function serializeHash(state) { if (state.page === "heroes") { if (state.selectedKey) { hash += "/" + encodeURIComponent(state.selectedKey); - if (state.detailTab && VALID_DETAIL_TABS.includes(state.detailTab)) { - hash += "/" + encodeURIComponent(state.detailTab); + let tab = state.detailTab; + if (tab === "stats") tab = "trends"; // normalize legacy alias in URL + if (tab && VALID_DETAIL_TABS.includes(tab) && tab !== "stats") { + hash += "/" + encodeURIComponent(tab); } } + } else if (state.page === "rankings") { + // Omit region when it is the default (china) — bare #/rankings means China. + const region = state.rankingRegion || DEFAULT_RANKING_REGION; + if (region && region !== DEFAULT_RANKING_REGION) { + hash += "/" + encodeURIComponent(region); + } + } else if (state.page === "trends") { + // Omit bracket when it is the default (legend) — bare #/trends means legend. + const bracket = state.trendsBracket || DEFAULT_TRENDS_BRACKET; + if (bracket && bracket !== DEFAULT_TRENDS_BRACKET) { + hash += "/" + encodeURIComponent(bracket); + } + const sort = state.trendsSort || DEFAULT_TRENDS_SORT; + if (VALID_TRENDS_SORTS.includes(sort) && sort !== DEFAULT_TRENDS_SORT) { + hash += "?sort=" + encodeURIComponent(TRENDS_SORT_URL[sort] || sort); + } + } else if (state.page === "mechanics") { + const effect = state.mechanicEffect || DEFAULT_MECHANIC_EFFECT; + if (effect && effect !== DEFAULT_MECHANIC_EFFECT) { + hash += "/" + encodeURIComponent(effect); + } } else if (state.page === "items") { if (state.selectedItemKey) { hash += "/" + encodeURIComponent(state.selectedItemKey); diff --git a/web/relations/style.css b/web/relations/style.css index 9908cfe..31efb4f 100644 --- a/web/relations/style.css +++ b/web/relations/style.css @@ -42,7 +42,7 @@ body { background: rgba(8, 12, 20, 0.45); flex: 0 0 auto; } -/* Brand mark: official Dota 2 logo + 上分帝 title, pinned to the top-left +/* Brand mark: Dota 2 full logo + 上分帝 title, pinned to the top-left corner of the topbar. Absolutely positioned so the centered tab row and toolbar layout are left untouched. */ .brand { @@ -57,7 +57,7 @@ body { z-index: 2; } .brand-logo { - height: 34px; + height: 28px; width: auto; display: block; } @@ -263,6 +263,526 @@ body { margin-bottom: 12px; text-align: center; } +.detail-stats-strip { + display: none; +} +.detail-stats-layout { + flex: 1 1 auto; + min-height: 0; + width: 100%; + overflow: hidden; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: flex-start; +} +.detail-stats-layout .detail-tab-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + scrollbar-width: thin; + padding-right: 6px; + width: 100%; +} +.detail-stats-panel, +.trends-panel { + width: min(920px, 100%); + margin: 8px auto 0; + padding: 8px 12px 20px; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + overflow: visible; + box-sizing: border-box; +} +.trends-rank-bar { + width: 100%; + box-sizing: border-box; + display: flex; + justify-content: center; + align-items: center; + padding: 8px 10px; + margin-bottom: 6px; + border-radius: 8px; + overflow: visible; + background: linear-gradient( + 180deg, + rgba(92, 72, 48, 0.55) 0%, + rgba(48, 38, 28, 0.72) 100% + ); + border: 1px solid rgba(180, 140, 80, 0.22); +} +.trends-window-hint { + width: 100%; + text-align: center; + font-size: 12px; + margin-bottom: 10px; + opacity: 0.82; +} +.bracket-icon-picker { + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + max-width: 100%; + overflow: visible; + scrollbar-width: none; +} +.bracket-icon-picker::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} +.bracket-icon-wrap { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 1 1 0; + min-width: 0; + max-width: calc((100% - 56px) / 8); +} +.bracket-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 96px; + height: 96px; + max-width: 100%; + padding: 2px; + border-radius: 10px; + border: 1px solid transparent; + background: transparent; + cursor: pointer; + opacity: 0.55; + flex: 1 1 auto; + min-width: 0; + box-sizing: border-box; + transition: opacity 0.12s ease, border-color 0.12s ease, background 0.12s ease; +} +.bracket-icon-btn img { + width: min(90px, 100%); + height: min(90px, 100%); + object-fit: contain; + pointer-events: none; + filter: grayscale(0.15); +} +.bracket-icon-btn:hover { + opacity: 0.9; +} +.bracket-icon-btn.active { + opacity: 1; + border-color: rgba(232, 200, 120, 0.75); + background: rgba(0, 0, 0, 0.25); +} +.bracket-icon-btn.active img { + filter: none; +} +.bracket-info-btn { + position: absolute; + right: 2px; + bottom: 6px; + z-index: 2; + width: 18px; + height: 18px; + padding: 0; + border-radius: 50%; + border: 1px solid rgba(180, 190, 210, 0.55); + background: rgba(48, 54, 66, 0.95); + color: #c8d0dc; + font-size: 11px; + font-weight: 700; + font-style: italic; + font-family: Georgia, "Times New Roman", serif; + line-height: 16px; + cursor: help; + opacity: 0.95; +} +.bracket-info-btn:hover, +.bracket-info-btn.open { + border-color: rgba(232, 200, 120, 0.8); + color: #f0e6c8; +} +.bracket-info-bubble { + display: none; + position: absolute; + right: 0; + bottom: calc(100% + 10px); + width: max(220px, 28vw); + max-width: 280px; + padding: 10px 12px; + border-radius: 6px; + background: rgba(36, 40, 48, 0.96); + border: 1px solid rgba(140, 150, 170, 0.35); + color: #e8eef8; + font-size: 12px; + font-style: normal; + font-weight: 450; + font-family: inherit; + line-height: 1.45; + text-align: left; + white-space: normal; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); + pointer-events: none; +} +.bracket-info-bubble::after { + content: ""; + position: absolute; + right: 6px; + top: 100%; + border: 6px solid transparent; + border-top-color: rgba(36, 40, 48, 0.96); +} +.bracket-info-btn:hover .bracket-info-bubble, +.bracket-info-btn.open .bracket-info-bubble, +.bracket-info-btn:focus-visible .bracket-info-bubble { + display: block; +} +.trends-metric-cards { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + width: 100%; + margin: 0 0 12px; +} +.trends-metric-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 16px 10px 18px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.32); + border: 1px solid rgba(140, 170, 210, 0.14); + text-align: center; + min-height: 96px; +} +.trends-metric-label { + font-size: 13px; + color: #9aa8c4; + letter-spacing: 0.08em; +} +.trends-metric-value { + font-size: 28px; + font-weight: 650; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; + color: #f2f5fa; +} +.trends-metric-card.tone-wr .trends-metric-value { + color: #f2f5fa; +} +.trends-metric-card.tone-pr .trends-metric-value { + color: #f2f5fa; +} +.trends-metric-card.tone-games .trends-metric-value { + color: #f2f5fa; +} +.trends-section-label { + margin: 16px 0 10px; + font-size: 13px; + letter-spacing: 0.08em; + color: #9aa8c4; + text-align: center; + width: 100%; +} +.trends-week-chart { + margin-top: 16px; + width: 100%; + text-align: left; +} +.trends-week-head { + margin-top: 8px; + margin-bottom: 8px; + text-align: center; +} +.trends-week-list { + display: flex; + flex-direction: column; + gap: 5px; + width: 100%; +} +.trends-week-row { + display: grid; + grid-template-columns: 40px 48px 36px minmax(0, 1fr) 48px; + align-items: center; + gap: 6px 8px; + padding: 7px 10px; + border-radius: 6px; + background: rgba(0, 0, 0, 0.2); + border: 1px solid rgba(140, 170, 210, 0.08); +} +.trends-week-row.is-latest { + border-color: rgba(180, 140, 80, 0.32); + background: rgba(92, 72, 48, 0.2); +} +.trends-week-row.is-best:not(.is-latest) { + border-color: rgba(61, 154, 106, 0.22); +} +.trends-week-label { + font-size: 12px; + color: #9aa8c4; + letter-spacing: 0.02em; + white-space: nowrap; +} +.trends-week-row.is-latest .trends-week-label { + color: #d6c4a0; + font-weight: 600; +} +.trends-week-wr { + font-size: 13px; + font-weight: 650; + font-variant-numeric: tabular-nums; + color: #f2f5fa; + text-align: right; +} +.trends-week-wr.is-up { + color: #6ecf9a; +} +.trends-week-wr.is-down { + color: #e88888; +} +.trends-week-delta { + font-size: 11px; + font-variant-numeric: tabular-nums; + text-align: right; + white-space: nowrap; + color: #7a8494; + min-height: 1em; +} +.trends-week-delta.is-up { + color: #6ecf9a; +} +.trends-week-delta.is-down { + color: #e88888; +} +.trends-week-delta.is-flat, +.trends-week-delta.is-empty { + color: #5c6674; +} +.trends-week-track { + position: relative; + height: 8px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); + overflow: hidden; +} +.trends-week-fill { + position: absolute; + left: 0; + top: 0; + bottom: 0; + border-radius: 4px; + min-width: 3px; + transition: width 0.15s ease; +} +.trends-week-fill.is-up { + background: linear-gradient(90deg, rgba(61, 154, 106, 0.35), #3d9a6a); +} +.trends-week-fill.is-down { + background: linear-gradient(90deg, rgba(196, 92, 92, 0.35), #c45c5c); +} +.trends-week-pick { + font-size: 11px; + text-align: right; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +@media (max-width: 560px) { + .trends-week-row { + grid-template-columns: 36px 44px 32px minmax(0, 1fr); + grid-template-rows: auto auto; + } + .trends-week-pick { + grid-column: 4; + grid-row: 2; + text-align: left; + } +} +.trends-positions { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + width: 100%; + max-width: min(920px, 100%); + margin: 4px auto 0; +} +@media (max-width: 720px) { + .trends-positions { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} +@media (max-width: 480px) { + .trends-positions { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +.trends-pos-chip { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 14px 10px 16px; + min-height: 84px; + border-radius: 6px; + border: 1px solid rgba(140, 170, 210, 0.14); + background: rgba(0, 0, 0, 0.32); + text-align: center; +} +.trends-pos-label { + font-size: 13px; + color: #9aa8c4; + letter-spacing: 0.06em; +} +.trends-pos-value { + font-size: 22px; + font-weight: 650; + font-variant-numeric: tabular-nums; + color: #f2f5fa; +} +.detail-matchups-layout { + flex: 1 1 auto; + min-height: 0; + width: 100%; + overflow: hidden; + display: flex; + flex-direction: column; +} +.detail-matchups-layout .detail-tab-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + scrollbar-width: thin; + padding-right: 6px; +} +.detail-matchups-panel { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + padding: 4px 4px 12px; + box-sizing: border-box; +} +.matchup-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + align-items: start; +} +@media (max-width: 900px) { + .matchup-grid { + grid-template-columns: 1fr; + } +} +.matchup-col { + display: flex; + flex-direction: column; + min-width: 0; +} +.matchup-col-title { + margin: 0 0 8px; + font-size: 13px; + font-weight: 600; +} +.matchup-list { + display: flex; + flex-direction: column; + gap: 6px; +} +.matchup-row { + display: grid; + grid-template-columns: 64px 1fr auto auto; + align-items: center; + gap: 10px; + width: 100%; + padding: 6px 8px; + border: 0; + border-radius: 4px; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} +.matchup-row:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.06); +} +.matchup-row:disabled { + cursor: default; + opacity: 0.7; +} +.matchup-portrait { + width: 64px; + height: 36px; + object-fit: cover; + border-radius: 3px; + flex-shrink: 0; +} +.matchup-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; +} +.matchup-score { + font-variant-numeric: tabular-nums; + font-size: 14px; + color: #7eb6ff; + white-space: nowrap; +} +.matchup-games { + font-size: 12px; + min-width: 2.75rem; + text-align: right; + white-space: nowrap; +} +.meta-hero { + display: flex; + align-items: center; + gap: 8px; +} +.meta-portrait { + width: 48px; + height: 27px; + object-fit: cover; + border-radius: 2px; +} +.detail-stats-note { + margin-top: 4px; + font-size: 12px; + line-height: 1.45; + text-align: center; + max-width: 36em; +} +.detail-items-note { + margin-top: 12px; + font-size: 12px; + line-height: 1.45; + text-align: center; +} +.item-usage-badge { + position: absolute; + right: 2px; + bottom: 2px; + z-index: 2; + padding: 1px 4px; + border-radius: 3px; + background: rgba(0, 0, 0, 0.72); + color: #d7e6ff; + font-size: 10px; + font-weight: 650; + font-variant-numeric: tabular-nums; + line-height: 1.2; + pointer-events: none; +} .detail-name { font-size: 18px; font-weight: 650; @@ -273,6 +793,8 @@ body { flex-wrap: wrap; justify-content: center; gap: 6px; + flex: 0 0 auto; + margin-bottom: 4px; } /* Skills / items tab bodies fill remaining #detail height under head+tabs. */ .detail-skills-layout, @@ -1116,6 +1638,262 @@ body { /* Headroom so the scaled-up selected item is not clipped. */ padding: 6px; } +.fear-item { + display: block; + flex: 0 0 88px; + min-width: 0; +} +.fear-item .item { + width: 88px; + height: 66px; +} + +/* Matches tab: scrollable recent-match list within fixed detail height. */ +.detail-matches-layout { + flex: 1 1 auto; + min-height: 0; + width: 100%; + overflow: hidden; + display: flex; + flex-direction: column; +} +.detail-matches-layout .detail-tab-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + scrollbar-width: thin; + padding-right: 6px; +} +.detail-matches-panel { + display: flex; + flex-direction: column; + gap: 14px; + width: 100%; + min-width: 0; + max-width: none; + margin: 0; + padding: 4px 4px 12px; + box-sizing: border-box; +} +.match-list { + display: flex; + flex-direction: column; + gap: 14px; + width: 100%; +} +.match-card { + display: flex; + flex-direction: column; + gap: 12px; + padding: 14px 16px 16px; + border-radius: 8px; + background: rgba(0, 0, 0, 0.28); + border: 1px solid rgba(140, 170, 210, 0.18); + border-left: 4px solid rgba(160, 170, 190, 0.55); +} +.match-card.is-win { + border-left-color: rgba(90, 190, 120, 0.9); +} +.match-card.is-loss { + border-left-color: rgba(210, 90, 90, 0.9); +} +.match-card-head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 14px; + font-size: 15px; + color: rgba(220, 230, 245, 0.88); +} +.match-result { + font-weight: 700; + font-size: 16px; + min-width: 1.6em; +} +.match-card.is-win .match-result { + color: rgba(120, 220, 150, 0.95); +} +.match-card.is-loss .match-result { + color: rgba(230, 130, 130, 0.95); +} +.match-kda { + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; + font-weight: 600; +} +.match-duration, +.match-time { + color: rgba(180, 195, 215, 0.75); + font-variant-numeric: tabular-nums; +} +.match-origin { + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + background: rgba(100, 130, 180, 0.25); + color: rgba(200, 215, 235, 0.9); +} +.match-origin-china { + background: rgba(180, 90, 70, 0.28); + color: rgba(255, 210, 190, 0.95); +} +.match-origin-pro { + background: rgba(180, 130, 40, 0.28); + color: rgba(255, 220, 160, 0.95); +} +.match-league { + max-width: min(360px, 40vw); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: rgba(190, 205, 225, 0.8); +} +.match-opendota { + color: rgba(140, 180, 230, 0.95); + text-decoration: none; + font-size: 14px; +} +.match-opendota:hover { + text-decoration: underline; +} +.match-links { + margin-left: auto; + display: inline-flex; + gap: 12px; + align-items: center; +} +.match-player { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + max-width: min(360px, 45vw); +} +.match-rank-icon { + width: 28px; + height: 28px; + object-fit: contain; + flex: 0 0 auto; +} +.match-player-name { + color: rgba(230, 235, 245, 0.95); + font-weight: 650; + font-size: 15px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-decoration: none; +} +a.match-player-name:hover { + color: rgba(160, 200, 255, 0.98); + text-decoration: underline; +} +.match-rank-label { + flex: 0 0 auto; + font-size: 13px; + color: rgba(180, 195, 215, 0.8); +} +.match-items, +.match-skills, +.match-backpack { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} +.match-inv { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; +} +.match-items { + gap: 8px; +} +.match-backpack { + gap: 8px; + padding-left: 2px; +} +.match-backpack-glyph { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 42px; + color: rgba(200, 210, 225, 0.82); + flex: 0 0 auto; + opacity: 0.92; +} +.match-backpack-glyph svg { + display: block; + width: 20px; + height: 36px; +} +.match-neutral-slot { + display: inline-flex; + margin-left: 12px; +} +.match-icon { + position: relative; + display: inline-flex; + width: 56px; + height: 42px; + border-radius: 4px; + overflow: hidden; + background: rgba(0, 0, 0, 0.35); + border: 1px solid rgba(140, 170, 210, 0.22); + flex: 0 0 auto; + align-items: center; + justify-content: center; + font-size: 11px; + color: rgba(200, 210, 230, 0.7); +} +.match-icon-time { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 6px 1px 1px; + background: linear-gradient( + to top, + rgba(0, 0, 0, 0.88) 35%, + rgba(0, 0, 0, 0) + ); + color: rgba(245, 248, 255, 0.96); + font-size: 10px; + line-height: 1.15; + text-align: center; + font-variant-numeric: tabular-nums; + pointer-events: none; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.85); +} +.match-icon img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.match-icon.missing { + opacity: 0.55; +} +.match-ability { + width: 40px; + height: 40px; + border-radius: 5px; +} +.match-ability-lv { + position: absolute; + right: 0; + bottom: 0; + min-width: 14px; + padding: 0 3px; + font-size: 10px; + line-height: 1.25; + text-align: center; + background: rgba(0, 0, 0, 0.75); + color: rgba(240, 245, 255, 0.95); + font-variant-numeric: tabular-nums; +} .item { position: relative; display: inline-flex; @@ -1648,6 +2426,348 @@ body:has(#items-view:not(.hidden)) { opacity: 0.55; user-select: none; } + +.rankings-board { + overflow-x: hidden; + overflow-y: auto; +} +.rankings-cluster { + display: flex; + justify-content: center; + width: 100%; + box-sizing: border-box; + padding: 28px 0 64px; +} +.rankings-center-wrap { + position: relative; + width: min(640px, calc(100% - 48px)); + flex: 0 0 auto; +} +.rankings-body { + width: 100%; + min-width: 0; +} +.rankings-aside { + position: absolute; + left: calc(100% + 20px); + top: 4px; + width: max-content; + min-width: 200px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 14px; +} +.rankings-title { + margin: 0; + width: max-content; +} +.rankings-title-row { + display: inline-flex; + align-items: center; + gap: 8px; + position: relative; + flex-wrap: nowrap; + white-space: nowrap; +} +.rankings-kicker { + font-size: 22px; + font-weight: 800; + color: var(--text); + letter-spacing: 0.02em; + line-height: 1.1; + white-space: nowrap; +} +.rankings-info-btn { + position: relative; + right: auto; + bottom: auto; + flex-shrink: 0; +} +.rankings-sub { + margin: 0; + font-size: 13px; + font-weight: 500; + color: var(--muted); + line-height: 1.4; +} +.rankings-regions { + display: flex; + flex-direction: column; + flex-wrap: nowrap; + gap: 8px; + width: 100%; +} +.rankings-region-btn { + padding: 7px 14px; + border-radius: 6px; + border: 1px solid var(--line); + background: var(--panel); + color: var(--text); + font-size: 13px; + font-family: var(--font); + font-weight: 600; + cursor: pointer; + text-align: left; +} +.rankings-region-btn:hover { + border-color: var(--sel); +} +.rankings-region-btn.active { + background: var(--sel); + border-color: var(--sel); + color: #0b0d10; +} +.rankings-region-btn:focus-visible { + outline: 2px solid var(--sel); + outline-offset: 1px; +} +.rankings-empty { + padding: 48px 0; + text-align: center; + color: var(--muted); + font-size: 14px; +} +.rankings-table { + width: 100%; + border-collapse: collapse; + border-spacing: 0; + font-size: 14px; +} +.rankings-table th, +.rankings-table td { + padding: 9px 10px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: middle; + line-height: 1.4; +} +.rankings-table th { + font-size: 12px; + font-weight: 600; + color: var(--muted); + letter-spacing: 0.06em; +} +.rankings-table tbody tr:hover td { + background: rgba(255, 255, 255, 0.03); +} +.rankings-rank { + width: 64px; + text-align: right; + font-variant-numeric: tabular-nums; + font-weight: 700; + color: var(--muted); +} +.rankings-table td.rankings-rank { + color: var(--text); +} +.rankings-name { + font-weight: 600; + color: var(--text); +} +.rankings-table th.rankings-team, +.rankings-table td.rankings-team { + width: 88px; + text-align: center; + font-size: 12px; + font-weight: 600; + color: var(--sel); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.rankings-table th.rankings-team { + color: var(--muted); +} +.rankings-table th.rankings-country, +.rankings-table td.rankings-country { + width: 96px; + text-align: center; + font-size: 12px; + font-weight: 600; + color: var(--muted); + letter-spacing: 0; + white-space: nowrap; +} +@media (max-width: 900px) { + .rankings-cluster { + padding: 20px 0 64px; + } + .rankings-center-wrap { + display: flex; + flex-direction: column-reverse; + gap: 18px; + width: min(640px, 94%); + } + .rankings-aside { + position: static; + width: 100%; + } + .rankings-regions { + flex-direction: row; + flex-wrap: wrap; + } + .rankings-region-btn { + text-align: center; + } +} + +/* Top-level 走势 board (8-week win/pick trends) */ +.trends-board .trends-center-wrap { + width: min(1080px, calc(100% - 48px)); +} +.trends-brackets { + width: 100%; +} +.trends-brackets .bracket-icon-picker { + flex-wrap: wrap; + justify-content: flex-start; + gap: 8px; + max-width: 340px; +} +.trends-brackets .bracket-icon-wrap { + flex: 0 0 auto; + max-width: none; +} +.trends-brackets .bracket-icon-btn { + width: 72px; + height: 72px; + border-radius: 8px; + padding: 3px; +} +.trends-brackets .bracket-icon-btn img { + width: 64px; + height: 64px; +} +.trends-sort { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 10px 0 0; +} +.trends-sort-btn { + border: 1px solid rgba(255, 255, 255, 0.12); + background: transparent; + color: inherit; + padding: 4px 10px; + font-size: 12px; + cursor: pointer; +} +.trends-sort-btn:hover { + border-color: rgba(255, 255, 255, 0.28); +} +.trends-sort-btn.active { + border-color: #7eb6ff; + color: #7eb6ff; +} +.trends-table { + font-size: 13px; +} +.trends-table thead th { + text-align: center; + white-space: nowrap; +} +.trends-table thead th.rankings-rank, +.trends-table tbody td.rankings-rank { + text-align: right; +} +.trends-table thead th:nth-child(2), +.trends-table tbody td.meta-hero { + text-align: left; +} +.trends-group { + letter-spacing: 0.08em; + border-bottom: 1px solid var(--line); +} +.trends-group.trends-sort-th { + cursor: pointer; + user-select: none; +} +.trends-group-wr { + color: #7dcea0; +} +.trends-group-pr { + color: #7ec8ff; +} +.trends-table tbody tr.trends-row { + cursor: pointer; +} +.trends-num, +.trends-delta { + font-variant-numeric: tabular-nums; + text-align: center; + white-space: nowrap; +} +.trends-end { + font-weight: 700; + color: var(--sel); +} +.trends-delta.is-up { + color: var(--good); +} +.trends-delta.is-down { + color: #e86a6a; +} +.trends-delta.is-flat { + color: var(--muted); +} +.trends-sort-th { + cursor: pointer; + user-select: none; +} +.trends-sort-th:hover { + color: var(--text); +} +.trends-sort-th.is-active { + color: var(--sel); +} +.trends-spark-cell { + text-align: center; + padding-left: 4px; + padding-right: 4px; + vertical-align: middle; +} +.trends-spark { + display: inline-block; + vertical-align: middle; + overflow: visible; +} +.trends-spark .trends-spark-line { + stroke-width: 1.2; + stroke-linejoin: round; + stroke-linecap: round; +} +.trends-spark.is-wr .trends-spark-line, +.trends-spark.is-wr .trends-spark-dot { + stroke: #3dce7a; + fill: #3dce7a; +} +.trends-spark.is-pr .trends-spark-line, +.trends-spark.is-pr .trends-spark-dot { + stroke: #7ec8ff; + fill: #7ec8ff; +} +.trends-spark-empty { + color: var(--muted); + font-size: 12px; +} +@media (max-width: 1100px) { + .trends-board .trends-center-wrap { + width: min(1080px, 96%); + } +} +@media (max-width: 900px) { + .trends-board .rankings-body { + overflow-x: auto; + } + .trends-table { + min-width: 860px; + } + .trends-brackets .bracket-icon-picker { + max-width: 100%; + } +} + .patch-section { margin-top: 26px; } @@ -1785,3 +2905,592 @@ body:has(#items-view:not(.hidden)) { object-fit: contain; } +/* Top-level 机制 query (applies-effect skills + items) */ +.mechanics-board .mechanics-center-wrap { + width: min(860px, calc(100% - 48px)); +} +.mechanics-effects { + display: flex; + flex-direction: column; + gap: 14px; + width: 100%; +} +.mechanics-group { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} +.mechanics-group-label { + font-size: 12px; + font-weight: 700; + color: var(--muted); + letter-spacing: 0.04em; +} +.mechanics-body { + display: flex; + flex-direction: column; + gap: 22px; +} +.mechanics-blurb { + margin: 0; + padding: 14px 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); +} +.mechanics-blurb-title { + margin: 0 0 8px; + font-size: 18px; + font-weight: 800; + color: var(--text); + letter-spacing: 0.02em; +} +.mechanics-blurb-text { + margin: 0; + font-size: 13.5px; + line-height: 1.55; + color: var(--muted); + font-weight: 500; +} +.mechanics-section-title { + margin: 0 0 10px; + font-size: 16px; + font-weight: 800; + color: var(--text); +} +.mechanics-count { + font-size: 13px; + font-weight: 600; + color: var(--muted); +} +.mechanics-list { + display: flex; + flex-direction: column; + gap: 6px; +} +.mechanics-skill-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); + gap: 10px; +} +.mechanics-skill-card { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0; + width: 100%; + padding: 0; + overflow: hidden; + border-radius: 8px; + border: 1px solid var(--line); + background: var(--panel); + color: var(--text); + font-family: var(--font); + cursor: pointer; + text-align: left; +} +.mechanics-skill-card:hover { + border-color: var(--sel); +} +.mechanics-skill-card:hover .mechanics-hero-wide { + transform: scale(1.05); +} +.mechanics-skill-portrait { + display: block; + width: 100%; + aspect-ratio: 16 / 9; + background: #0a0e14; + overflow: hidden; +} +.mechanics-hero-wide { + width: 100%; + height: 100%; + object-fit: contain; + object-position: center center; + display: block; + filter: saturate(1.02) contrast(1.04); + transition: transform 0.15s ease; +} +.mechanics-skill-foot { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 9px; + border-top: 1px solid var(--line); + min-width: 0; +} +.mechanics-row { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--line); + background: var(--panel); + color: var(--text); + font-family: var(--font); + cursor: pointer; + text-align: left; +} +.mechanics-row:hover { + border-color: var(--sel); +} +.mechanics-ability, +.mechanics-item { + width: 36px; + height: 36px; + border-radius: 4px; + object-fit: contain; + flex-shrink: 0; + background: rgba(0, 0, 0, 0.25); +} +.mechanics-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} +.mechanics-primary { + font-size: 13px; + font-weight: 700; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mechanics-secondary { + font-size: 11.5px; + color: var(--muted); + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.mechanics-cost { + font-size: 13px; + font-weight: 700; + color: #e7c56a; + flex-shrink: 0; +} +.mechanics-empty { + margin: 0; + padding: 10px 4px; + color: var(--muted); + font-size: 13px; +} +@media (max-width: 900px) { + .mechanics-board .mechanics-center-wrap { + width: min(640px, 94%); + } + .mechanics-effects { + flex-direction: row; + flex-wrap: wrap; + align-items: flex-start; + } + .mechanics-group { + flex: 1 1 160px; + } + .mechanics-skill-grid { + grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); + } +} + +/* Streamers directory (top-level + hero detail tab). */ +.streamers-center-wrap { + width: min(880px, calc(100% - 48px)); +} +.streamers-foot { + width: 100%; + padding: 12px 0 24px; + text-align: center; + font-size: 11px; + font-weight: 500; + color: var(--muted); + letter-spacing: 0.08em; + opacity: 0.55; + user-select: none; +} +.streamers-grid { + display: flex; + flex-direction: column; + gap: 14px; + width: 100%; +} +.streamers-empty { + padding: 28px 12px; + text-align: center; + color: var(--muted); + font-size: 14px; +} +.streamer-card { + display: flex; + flex-direction: column; + gap: 14px; + padding: 24px 22px 22px; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 16px; + background: rgba(16, 24, 36, 0.92); + box-sizing: border-box; +} +/* Row 1 only: avatar | nickname | actions — meta/signature live below as siblings */ +.streamer-card-head { + display: flex; + align-items: center; + gap: 16px; + min-width: 0; +} +.streamer-avatar-wrap { + position: relative; + flex: 0 0 auto; + overflow: visible; + display: block; + width: 88px; + height: 88px; + text-decoration: none; + color: inherit; +} +a.streamer-avatar-wrap.is-live { + cursor: pointer; +} +a.streamer-avatar-wrap.is-live:hover .streamer-avatar { + filter: brightness(1.06); +} +.streamer-avatar-inner { + position: relative; + z-index: 1; + display: block; + width: 88px; + height: 88px; + border-radius: 50%; + overflow: hidden; +} +.streamer-avatar { + width: 88px; + height: 88px; + object-fit: cover; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.06); + display: block; + box-sizing: border-box; +} +.streamer-avatar-fallback { + display: flex; + align-items: center; + justify-content: center; + width: 88px; + height: 88px; + font-size: 28px; + font-weight: 700; + color: var(--muted); +} +/* Douyin search live: outer ring expands+fades; avatar shrinks (1s ease). */ +.streamer-avatar-wrap.is-live { + padding-bottom: 10px; + margin-bottom: -10px; +} +.streamer-avatar-wrap.is-live .streamer-avatar { + border-color: rgb(69, 71, 79); +} +.streamer-avatar-wrap.is-live .streamer-avatar-inner { + animation: streamer-live-avatar 1s ease infinite; +} +.streamer-live-ring { + position: absolute; + top: -5.5px; + left: -5.5px; + width: 99px; + height: 99px; + border-radius: 50%; + border: 1px solid rgb(254, 26, 104); + box-sizing: border-box; + pointer-events: none; + z-index: 0; +} +.streamer-live-ring-pulse { + animation: streamer-live-ring 1s ease infinite; +} +@keyframes streamer-live-ring { + 0%, + 10% { + opacity: 1; + transform: scale(1); + } + 70%, + 100% { + opacity: 0; + transform: scale(1.15); + } +} +@keyframes streamer-live-avatar { + 0%, + 100% { + transform: scale(1); + } + 50% { + transform: scale(0.9); + } +} +.streamer-live-badge { + position: absolute; + left: 50%; + bottom: 0; + transform: translateX(-50%); + z-index: 2; + padding: 1px 6px; + font-size: 10px; + font-weight: 700; + line-height: 1.3; + letter-spacing: 0.02em; + color: #fff; + background: rgb(254, 26, 104); + border-radius: 4px; + white-space: nowrap; + pointer-events: none; +} +.streamer-name { + flex: 1 1 auto; + min-width: 0; + margin: 0; + font-size: 20px; + font-weight: 700; + color: #fff; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.streamer-meta { + margin: 0; + font-size: 13px; + line-height: 20px; + letter-spacing: 0.6px; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +/* Douyin search card: 1×10px divider, 8px side margin */ +.streamer-meta-sep { + display: inline-block; + width: 1px; + height: 10px; + margin: 0 8px; + vertical-align: baseline; + background: rgba(255, 255, 255, 0.16); +} +.streamer-meta-item { + display: inline; +} +.streamer-signature { + margin: 0; + font-size: 13px; + line-height: 20px; + letter-spacing: 0.4px; + color: var(--muted); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.streamer-actions { + flex: 0 0 auto; + display: flex; + flex-direction: column; + align-items: stretch; + justify-content: center; + gap: 8px; + min-width: 88px; + margin-left: 4px; + align-self: center; +} +.streamer-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 20px; + font-size: 14px; + font-weight: 700; + line-height: 1.2; + text-decoration: none; + text-align: center; + white-space: nowrap; + border-radius: 999px; + box-sizing: border-box; + transition: filter 0.15s ease; +} +.streamer-btn:hover { + filter: brightness(1.08); +} +.streamer-btn-follow { + background: #fe2c55; + border: 1px solid #fe2c55; + color: #fff; +} +.streamer-heroes { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 12px 14px; + margin-top: 4px; + padding-top: 4px; +} +.streamer-hero-chip { + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 0; + border: none; + background: transparent; + color: var(--muted); + cursor: pointer; + font-size: 12px; + line-height: 1.25; + font-family: inherit; + max-width: 112px; +} +.streamer-hero-chip:hover { + color: var(--text); +} +.streamer-hero-chip img { + width: 112px; + height: 63px; + object-fit: cover; + object-position: center top; + display: block; + border-radius: 4px; + flex-shrink: 0; + opacity: 0.95; +} +.streamer-hero-chip:hover img { + opacity: 1; +} +.streamer-hero-chip span { + display: block; + width: 100%; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + font-weight: 500; +} +.streamer-video-wrap { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; + padding-top: 2px; +} +.streamer-video-frame { + /* Default 9:16; JS may set aspect-ratio from video metadata */ + width: 100%; + aspect-ratio: 9 / 16; + overflow: hidden; + border-radius: 8px; + background: #0a0a0a; + box-sizing: border-box; +} +.streamer-video-frame.is-landscape { + width: 100%; +} +.streamer-video-frame.is-letterbox-crop .streamer-video { + object-fit: cover; +} +.streamer-video { + width: 100%; + height: 100%; + object-fit: contain; + object-position: center center; + display: block; + vertical-align: top; + background: #000; +} +@media (max-width: 480px) { + .streamers-grid { + gap: 12px; + } + .streamer-card { + padding: 18px 14px 16px; + gap: 10px; + border-radius: 12px; + } + .streamer-card-head { + gap: 12px; + } + .streamer-avatar-wrap, + .streamer-avatar-inner { + width: 76px; + height: 76px; + } + .streamer-avatar, + .streamer-avatar-fallback { + width: 76px; + height: 76px; + } + .streamer-live-ring { + top: -4.75px; + left: -4.75px; + width: 85.5px; + height: 85.5px; + } + .streamer-avatar-fallback { + font-size: 24px; + } + .streamer-name { + font-size: 18px; + } + .streamer-actions { + min-width: 76px; + gap: 8px; + margin-left: 0; + } + .streamer-btn { + padding: 7px 14px; + font-size: 13px; + } + .streamer-heroes { + gap: 10px 12px; + } + .streamer-hero-chip { + max-width: 96px; + } + .streamer-hero-chip img { + width: 96px; + height: 54px; + } +} +.detail-streamers-layout { + flex: 1 1 auto; + min-height: 0; + width: 100%; + overflow: hidden; + display: flex; + flex-direction: column; +} +.detail-streamers-layout .detail-tab-body { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + scrollbar-width: thin; + padding-right: 6px; +} +.detail-streamers-panel { + min-width: 0; + margin-inline: auto; + padding: 4px 0 12px; + box-sizing: border-box; +} +@media (max-width: 900px) { + .streamers-center-wrap { + width: min(880px, 94%); + } +} +