v0.2.0: relations preview, item shop, abilities, overlay recommend, GSI enhancements

- Add relations/item/abilities preview (serve_relations.py + web/relations/)
- Add fetch scripts: hero_items, item_shop, items_meta, hero_abilities,
  ability_videos, patches, stratz, matchups, portraits
- Add overlay.py (role tags + Top-3 cyan marks), recommend.py
- Add http_utils.py, loc_format.py, hero_tags.py, item_fears.py
- GSI: full payload JSONL dump, foreground window detection
- Drop real template library; CDN-only matching
- Update docs: CHANGELOG 0.2.0, DESIGN config table, AGENTS module table
- .gitignore: exclude large regenerable assets (icons/portraits/videos)
This commit is contained in:
voson
2026-07-27 11:56:51 +08:00
parent e567a5cdfc
commit a91789b72f
76 changed files with 109860 additions and 996 deletions
+2
View File
@@ -0,0 +1,2 @@
# Copy to .env (gitignored). Get a free token at https://stratz.com/api
STRATZ_API_TOKEN=
+22
View File
@@ -13,3 +13,25 @@ preview/
results/ results/
failures/ failures/
samples/raw/ samples/raw/
_tools/
_tmp_*
_cmp_*
plans/
*_log.txt
# Regenerable data caches / reports (re-fetch or re-run scripts)
data/matchups.json
data/synergies.json
data/stratz_matchups.json
data/relations_audit.json
data/relations_import_report.json
data/relations_patch_*
# Large regenerable CDN media (fetch scripts)
assets/ability_videos/
assets/ability_icons/
assets/hero_portraits/
assets/item_icons/
# Static site export (export_relations_site.py)
dist/
+63 -5
View File
@@ -19,21 +19,63 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
| `recognize.py` | 单帧识别;`recognize_image()` 供会话复用 | | `recognize.py` | 单帧识别;`recognize_image()` 供会话复用 |
| `draft_session.py` | 整局选将跟踪、改判、皮肤规避策略 | | `draft_session.py` | 整局选将跟踪、改判、皮肤规避策略 |
| `gsi_watch.py` / `gsi_setup.py` | GSI 监听与 cfg 安装 | | `gsi_watch.py` / `gsi_setup.py` | GSI 监听与 cfg 安装 |
| `fetch_cdn_templates.py` | 拉取 CDN 头像 + 生成 `heroes.json` | | `http_utils.py` | 所有 fetch 脚本共用的 HTTP 请求、图标下载、Valve datafeed 加载(统一 UA / 超时) |
| `fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量) |
| `fetch_cdn_templates.py` | 拉取 CDN 头像 + 生成 `data/heroes.json`(含基础属性/血蓝;保留已有 `aliases` |
| `relations.py` | 定性克制/搭档边读写与名称解析 |
| `import_relations_xlsx.py` | 从选将笔记 xlsx 种子导入 `data/relations.json` |
| `fetch_stratz.py` | 拉取 STRATZ 对位优势 / 搭档协同 → `data/stratz_matchups.json` / `data/synergies.json`(需 token`--mode matchups\|synergies` |
| `fetch_matchups.py` | 拉取 OpenDota 对位优势 → `data/matchups.json`(供 `audit_relations.py` 交叉审计) |
| `audit_relations.py` | OpenDota+STRATZ 交叉审计 `relations.json`,列出缺失英雄 |
| `recommend.py` | 分路过滤;按克制/被克制/搭档边打分,输出 Top-3 |
| `serve_relations.py` | 游戏外关系/物品只读预览(`web/relations/`;改 `data/relations.json` |
| `export_relations_site.py` | 导出关系预览为纯静态站点 → `dist/relations/`data.json 快照 + 前端 + 图片;`--with-videos` 含视频) |
| `fetch_hero_portraits.py` | 拉取官网横版头像 → `assets/hero_portraits/`(关系预览) |
| `fetch_hero_items.py` | 拉取 OpenDota 热门装备 → `data/hero_items.json` + `assets/item_icons/` |
| `fetch_item_shop.py` | 官网商店 11 列目录(dota2.com.cn/itemscategory+ 合成图 → `data/item_shop.json` + 图标 |
| `fetch_items_meta.py` | Valve/OpenDota 装备描述 → 机制标签 → `data/items_meta.json``%token%` 用 special_values 填数) |
| `loc_format.py` | Valve 文案共用:去 HTML、填充 `%token%` / `{s:token}` |
| `fetch_hero_abilities.py` | Valve herodata 技能/魔晶/神杖/天赋 + 驱散汇总 → `data/hero_abilities.json``has_scepter`/`has_shard` 只信 Valve 显式 flagValve 移除升级后残留的 `scepter_loc`/`shard_loc` 文案会被清空;逐级相同的 `cast_points`/`channel_times` 合并为单值(施法前摇/吟唱时间);可选 `--icons` / `--icons-only` 缓存 Steam CDN 技能图标 → `assets/ability_icons/`(先天用共用 `innate.png`,不拉 CDN |
| `fetch_ability_videos.py` | 官网技能演示 webm/mp4Steam CDN;限速)→ `assets/ability_videos/`A 杖/魔晶赋予技能(`granted_by_scepter`/`granted_by_shard`CDN 名用 `<hero>_aghanims_scepter`/`<hero>_aghanims_shard`,本地文件名仍用 ability key |
| `item_fears.py` | 规则推导「英雄怕的装备」→ `data/hero_item_fears.json` |
| `autocalibrate.py` / `calibrate.py` | ROI 自动 / 手动标定 | | `autocalibrate.py` / `calibrate.py` | ROI 自动 / 手动标定 |
| `capture.py` | 屏幕捕获 | | `capture.py` | 屏幕捕获 |
| `overlay.py` | 顶栏角色标签 + 选将网格左上角青标 Top-3(点击穿透) |
| `evaluate.py` | 按 `samples/labels.json` 批量评测 | | `evaluate.py` | 按 `samples/labels.json` 批量评测 |
| `roles.py` / `grid.py` / `modes.py` | 位置字、禁用网格、模式字 | | `roles.py` / `grid.py` / `modes.py` | 位置字、禁用网格、模式字 |
| `config.json` | 相对坐标、阈值、GSI 参数 | | `config.json` | 相对坐标、阈值、GSI、recommend 参数 |
| `data/heroes.json` | 英雄表(id/key/attr/roles/aliases/abbr/tags + 基础属性/初始血蓝等;由 `fetch_cdn_templates.py` 生成) |
| `data/patches.json` | 近一年版本列表 + 逐版本详情(`patches`/`lookup`/`details`;由 `fetch_patches.py` 生成,预览版本页只读) |
| `data/relations.json` | 定性克制边 + 搭档边(可提交;手改 JSON,预览页只读) |
| `data/hero_grid_order.json` | 预览页四列网格顺序 |
| `data/hero_items.json` | 核心成品装备缓存(预览页只读;由 `fetch_hero_items.py` 生成) |
| `data/item_shop.json` | 商店 11 列目录(官网 basic/upgrade;由 `fetch_item_shop.py` 生成;物品页只读) |
| `data/items_meta.json` | 成品装备描述与机制标签(由 `fetch_items_meta.py` 生成) |
| `data/item_tag_overrides.json` | 装备标签手工加减(合并进 items_meta |
| `data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears |
| `data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成) |
| `data/hero_item_fears.json` | 英雄怕的装备(规则推导;预览「怕」行) |
| `web/relations/` | 关系只读预览页静态资源 |
| `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成) | | `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成) |
| `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 | | `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 |
| `assets/role_icons/` | Valve 选人筛选角色图标(`filter_*`,品红 chroma 去黑边) |
| `assets/hero_portraits/` | 官网横版头像(关系预览;默认 wide 面部构图,非匹配模板) |
| `assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,关系预览用) |
| `assets/item_icons/` | Steam CDN 装备图标(关系预览常用装备) |
| `assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) |
| `assets/ability_icons/` | Steam CDN 技能图标(关系预览;按需缓存);`innate.png` 先天共用图标;`talent_tree.png` 天赋树触发图标 |
| `assets/ui_icons/` | dota2.com.cn 通用 UI 图标(`cooldown.png` 等,技能详情底栏冷却图标) |
| `assets/ability_videos/` | 官网技能演示视频(webm/mp4;勿提交,由脚本拉取) |
运行时产物(**勿提交**,见 `.gitignore`): 运行时产物(**勿提交**,见 `.gitignore`):
- `samples/raw/<matchid>/` — GSI 会话截图与 `gsi.jsonl`;手动 `capture.py` 可写在 `raw/` 根下 - `samples/raw/<matchid>/` — GSI 会话截图与 `gsi.jsonl`;手动 `capture.py` 可写在 `raw/` 根下
- `preview/` — 标定 / sheet 预览 - `preview/` — 标定 / sheet 预览
- `dist/` — 静态站点导出(`export_relations_site.py`
- `results/` — 每局 JSON - `results/` — 每局 JSON
- `failures/``--truth` 调试用错识裁切 - `failures/``--truth` 调试用错识裁切
- `data/matchups.json` / `synergies.json` / `stratz_matchups.json` / `relations_audit.json` 等 — 可再生成缓存与报告
- `assets/ability_videos/` — 官网技能演示(体积大,可再拉取)
## 数据流 ## 数据流
@@ -42,14 +84,18 @@ Dota 2 GSI → gsi_watch.py (:3223)
→ 全量 payload → samples/raw/<matchid>/gsi.jsonl(可关) → 全量 payload → samples/raw/<matchid>/gsi.jsonl(可关)
→ DraftSession 轮询截屏 → samples/raw/<matchid>/ → DraftSession 轮询截屏 → samples/raw/<matchid>/
→ recognize_image (CDN 模板 + 可选天梯遮罩) → recognize_image (CDN 模板 + 可选天梯遮罩)
→ roles / grid / modes(辅助) → roles(分路字)/ grid(禁用 + 单元格)/ modes
→ recommend Top-3data/relations.json 定性边,本人槽位只信 GSI)
→ overlay 角色标签 + 网格青标(可选)
→ results/draft_*.json + 终端时间线 → results/draft_*.json + 终端时间线
``` ```
## 技术约束(修改前必读) ## 技术约束(修改前必读)
- **合规**:仅 GSI + 屏幕截图;**禁止**读进程内存、注入、绕过 VAC。 - **合规**:仅 GSI + 屏幕截图;**禁止**读进程内存、注入、绕过 VAC。
- **GSI 范围**:普通玩家视角拿不到双方 pick;GSI 只作阶段触发、`team_slot`、本人 `hero` - **GSI 范围**:普通玩家视角拿不到双方 pick;GSI 只作阶段触发、`team_slot`、本人 `hero`、本机 `accountid`/`steamid`。本人顶栏槽位**只信** GSI,不用截屏名字亮度猜测
- **克制 / 搭档数据**:定性边存 `data/relations.json`(克制有向 + 搭档无向 + 理由),**不要**用胜率/场次表达机制克制,也**不要**写入 `data/heroes.json`。预览三视图:克制 / 被克制 / 搭档。
- **装备机制 / 怕的装备**:标签与技能汇总来自 Valve/OpenDota 自动抽取 + `item_tag_overrides.json``item_fears.py` 规则映射到英雄弱点。大段技能文案只进 `data/hero_abilities.json` / `items_meta.json`**不要**塞进 `heroes.json`。本阶段仅关系预览展示,**不对局内出装推荐**。
- **模板策略**:只维护 CDN 层。皮肤问题用会话策略(选人可改判、决策 `allow_revise=False`、best 帧偏 HERO_SELECTION),不要为皮肤加模板库,也不要复活 real 双层库。 - **模板策略**:只维护 CDN 层。皮肤问题用会话策略(选人可改判、决策 `allow_revise=False`、best 帧偏 HERO_SELECTION),不要为皮肤加模板库,也不要复活 real 双层库。
- **顶栏时机**:皮肤在**全员选完后**才上顶栏;本机进决策时别人可能还在选——须保持 `strategy_tail` 视觉,禁止「一进 STRATEGY 就永久停读」。 - **顶栏时机**:皮肤在**全员选完后**才上顶栏;本机进决策时别人可能还在选——须保持 `strategy_tail` 视觉,禁止「一进 STRATEGY 就永久停读」。
- **坐标**:一律相对坐标(相对屏幕高 / 相对中心),勿写死像素分辨率。 - **坐标**:一律相对坐标(相对屏幕高 / 相对中心),勿写死像素分辨率。
@@ -61,6 +107,18 @@ Dota 2 GSI → gsi_watch.py (:3223)
```powershell ```powershell
pip install -r requirements.txt pip install -r requirements.txt
python fetch_cdn_templates.py python fetch_cdn_templates.py
python import_relations_xlsx.py # 可选:从选将笔记 xlsx 导入关系
python fetch_hero_portraits.py # 官网横版头像(关系预览)
python fetch_hero_items.py # OpenDota 热门装备缓存(关系预览)
python fetch_item_shop.py # 商店分类目录(物品页)
python fetch_items_meta.py # 装备描述 + 机制标签
python fetch_hero_abilities.py # 英雄技能 / 驱散汇总
python fetch_hero_abilities.py --icons-only # 缓存技能图标到 assets/ability_icons/
python fetch_ability_videos.py # 官网技能演示(限速;默认 webm)
python item_fears.py # 英雄怕的装备
python fetch_patches.py # 近一年版本日志 + 详情(版本页)
python serve_relations.py # 浏览器只读预览 英雄关系 / 物品商店
python export_relations_site.py # 导出静态站点到 dist/relations/(可部署 Pages
python gsi_setup.py --check python gsi_setup.py --check
python gsi_watch.py --once python gsi_watch.py --once
python recognize.py samples/raw/<>.png --sheet python recognize.py samples/raw/<>.png --sheet
@@ -73,7 +131,7 @@ python evaluate.py
- 代码标识符、日志、错误信息、注释用**英文**;用户可见摘要可用中文。 - 代码标识符、日志、错误信息、注释用**英文**;用户可见摘要可用中文。
- 改匹配阈值或裁切时,用 `evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `DESIGN.md` - 改匹配阈值或裁切时,用 `evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `DESIGN.md`
- 改 GSI cfg 时同步核对 `gsi_setup.py` 与 Dota `gamestate_integration` 目录。 - 改 GSI cfg 时同步核对 `gsi_setup.py` 与 Dota `gamestate_integration` 目录。
-`heroes.json` 结构时同步 `grid.py`(依赖 `attr` / `name_loc`)。 -`data/heroes.json` 结构时同步 `grid.py`(依赖 `attr` / `name_loc``roles``overlay.py` 使用;`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),仅关系预览筛选用;基础属性/血蓝等数值字段供关系预览详情条,勿塞机制文案
- 不要重新引入 real 模板双层库、`cdn_penalty``build_library.py` - 不要重新引入 real 模板双层库、`cdn_penalty``build_library.py`
## 文档分工 ## 文档分工
+179 -1
View File
@@ -2,17 +2,171 @@
本文件记录 **上分帝(Climperor** 的用户可见变更。格式参考 [Keep a Changelog](https://keepachangelog.com/)。 本文件记录 **上分帝(Climperor** 的用户可见变更。格式参考 [Keep a Changelog](https://keepachangelog.com/)。
## [Unreleased] ## [0.3.0] - 2026-07-27
### Added ### Added
- 关系预览「版本」页改为默认展示**最新版本**的完整改动详情(综合 / 物品 / 中立物品 /
英雄),顶部下拉可切换近一年内的其它版本;`fetch_patches.py` 重写为抓取近一年
(默认 365 天,`--days` / `--since` 可调)版本列表 + 逐版本 `patchnotes` 详情,
构建 `lookup`ability_id / hero_id → 名称 + 图标 key)并下载引用到的物品 / 技能
图标到 `assets/`,静态导出与本地预览均自包含、不联网。
- 版本页排版对齐官网:左大标题「游戏性更新 {版本}」+ 右选择框,内容居中窄列;
物品图标按 Steam CDN 88×64 比例(66×48)显示(修复正方形框上下黑边);英雄头像
复用英雄页 16:9 宽卡(`?v=wide``object-fit:contain`)不再裁切面部;图标放大
(物品 56、技能 48、头像 112,对齐技能页 68 / 商店 70)。
- 英雄详情区新增 **版本变更** 标签:列出该英雄近一年各版本的改动(版本号 + 日期 +
属性 chip + 各技能改动),内容居中、可滚动;与版本页共用技能块渲染逻辑。
- 版本日志先天技能图标修复:先天技能(如剑心犹在、狙击瞄准)CDN 404,现复用英雄
技能页 bundled `innate.png`(从 `hero_abilities.is_innate` 构建先天 key 集合判断),
覆盖 117 个被引用的先天技能。
- `hero_notes` 属性标签补全 `movement` → 「移速」(修复原始英文 `movement` 漏显示)。
- 新增 `export_relations_site.py`:一键导出关系预览为纯静态站点(`dist/relations/`),
内含 `data.json` 快照 + 前端 + 图片资源,可直接部署到任意静态托管;
`--with-videos` 可选拷贝技能演示视频(体积数 GB)。
- 关系预览前端资源路径改为相对路径(`data.json` / `attr/` / `portrait/` / `item/` 等),
静态托管在子路径下也能正常工作;`serve_relations.py` 同时提供 `/data.json` 路由,
本地预览行为不变。
- 关系预览技能详情:左侧播放官网技能演示视频(`assets/ability_videos/` 本地缓存,
`/ability-video/<英雄>/<技能>.webm` 路由),右侧为技能名与描述(对齐官网左右分栏);
未缓存的技能自动隐藏视频,描述列占满整行。
- 英雄详情的装备详情区不再重复显示大图(上方装备行已有图标)。
- 关系预览技能详情新增「施法前摇」「吟唱时间」两行(取自 Valve `cast_points` /
`channel_times`,逐级相同则合并为单值);`fetch_hero_abilities.py` 全量重抓后生效。
- 关系预览技能详情按 dota2.com.cn 版式重排:移除右上重复的技能大图标(上方技能列表
已高亮选中项);属性值(技能/伤害类型/免疫/驱散/施法距离等)双列网格、特殊数值
自适应多列网格(消除右列大片空白);冷却/蓝量移到带图标的底栏——冷却用官方
`cooldown.png`(新增 `assets/ui_icons/` + `/ui-icon/` 路由,静态导出一并拷贝),
蓝量沿用官方 CSS 渐变方块;`serve_relations.py` 字段投影补齐 `cast_point` /
`channel_time`
- 技能详情布局适配 `.detail` 定值高度链:视频改 `height:100%` + `object-fit:cover`
自适应填满可用高度(`align-self:stretch` 无法压过被替换元素的 16:9 固有高度,曾
导致视频下方留白 / 顶出滚轮),`main` 钉到列高、内容超高时仅在详情列内细滚,视频
不再被牵连出滚轮;压缩参数行距使典型技能在高屏零滚轮(无头 Chrome + CDP 实测
`scrollHeight == clientHeight` 验证)。整块宽度上限 1000→1320px、视频 700→820px、
详情列 720→760px。
### Fixed
- `fetch_hero_abilities.py``has_scepter`/`has_shard` 不再 `or bool(scepter_loc/shard_loc)`
Valve 移除某项升级后**仍保留**旧 `scepter_loc`/`shard_loc` 文案,但
`ability_has_scepter`/`ability_has_shard` 已变为 `False`;旧 fallback 把残留文案
当成「有升级」,导致本地数据与 dota2.com 不一致。改为只信显式 flag 并清空残留
文案,全量重抓后修正 **10 个过期 A 杖 + 20 个过期魔晶**(主宰/剑刃风暴、
影魔/支配死灵、幻影长矛/灵魂之矛、斯拉达/鱼人碎击、术士/暗言术、哈斯卡/沸血之矛、
殁境神蚀者/奥术天球、熊战士/灵魂链接、玛尔斯/护身甲盾、破晓辰星/熠熠生辉;
剃刀/静电连接、沙王/尾刺、暗影萨满/禽戏 等)。
- `fetch_ability_videos.py`A 杖/魔晶**赋予**的技能(`granted_by_scepter`/
`granted_by_shard`CDN 视频名是 `<hero>_aghanims_scepter`/`<hero>_aghanims_shard`
而非技能 key(如 `juggernaut_swift_slash``juggernaut_aghanims_scepter.webm`)。
之前脚本用技能 key 拼 URL 导致 **69 个赋予技能视频全部 404**。新增 `_cdn_name()`
映射,本地文件名仍用 ability key(前端 `app.js` 按 ability key 请求,无需改动)。
重抓后 69 个视频全部到位(迅风斩、水晶克隆、雷云 等)。
## [0.2.0] - 2026-07-27
### Changed
- 代码审查重构:提取 `http_utils.py` 公共模块,统一所有 fetch 脚本的 HTTP 请求、
UA、超时与图标下载逻辑;移除 `requests` 依赖(`requirements.txt`)。
- 合并 `fetch_stratz_matchups.py` + `fetch_synergies.py``fetch_stratz.py --mode matchups|synergies`
- `fetch_cdn_templates.py` / `fetch_hero_portraits.py` 改用 `http_utils`(不再依赖 `requests`)。
- `loc_format.py` 新增导出 `strip_html`,统一 HTML 清理逻辑(`fetch_hero_abilities.py` /
`fetch_items_meta.py` 原各有一份重复实现)。
- fetch 脚本 JSON 输出统一为 `indent=2`(原 `indent=1` / 紧凑 `separators` 混用)。
- `fetch_hero_abilities.py` 主循环改为每 10 个英雄写一次 JSON(原每英雄写一次,O(n²) I/O)。
- `fetch_hero_items.py` 循环内 `collect_used_ids` 改为增量更新(原全量重算)。
- `data/relations.json` meta 中本机路径 `C:\Users\...` 脱敏为 `dota2.xlsx`
### Fixed
- `fetch_items_meta.py`:删除 `strip_html` 死代码(未导入 `unescape`,若被调用会 `NameError`)。
- `audit_relations.py`:删除重复 `missing_cache` 死代码块;JSON 加载加 try/except 防文件损坏崩溃。
- `app.js``renderBoard``state.data` null 守卫(防 fetch pending 期间搜索触发崩溃)。
- `relations.py`:删除 `EXTRA_NAMES``"圣堂/TA"` 重复项(与 `"圣堂/ta"` 归一化后相同)
`"axe":"axe"` 等冗余自映射项。
- `hero_tags.py`:注释「幻想系」→「幻象系」(与代码 `TAG_ORDER` 一致)。
- `item_fears.py``enrich_override_items``not isinstance(row, dict)` 分支的 `cost` 死条件简化。
- `import_relations_xlsx.py``"a仗" in t.lower() or "A仗" in t` 冗余条件简化。
### Removed
- `web/relations/style.css`:删除未引用的 `.item-row` / `.item-phase` / `.item-phase-fear` 规则。
- `web/relations/app.js`:删除死回退函数 `onItemClick`、死类名(`hero-stats-left` /
`shop-group-*` / `shop-column-*`)、合并 `state.selectedSkillId``state.inspect`
### Added
- 关系预览英雄详情:基础属性条(力量/敏捷/智力 + 成长、初始生命/魔法与恢复;
附攻击/护甲/移速/攻击距离)。数据来自 OpenDota(`fetch_cdn_templates.py`
`data/heroes.json`),主属性高亮。
- `fetch_ability_videos.py`:从 Steam CDN 批量拉取官网技能演示(webm/mp4),
默认限速约 1.5s+抖动,支持断点续传与 404 缓存;输出 `assets/ability_videos/`(不入库)。
- 装备说明填充 Valve `%token%` 数值(如飓风长戟「飓风之力」);
`fetch_items_meta.py --reformat-desc`,共用 `loc_format.py`
- 关系预览英雄详情底部分页:**技能** / **核心装备** / **被克装备**
- 关系预览新增顶级 **物品** 页(与英雄同级):对齐 dota2.com.cn 商店物品
**11 列竖排**(基础分类 / 合成分类),详情在右侧;目录来自官网
`itemscategory/json``fetch_item_shop.py``data/item_shop.json`),
并展示合成组件(含卷轴)与「可升级为」。
- 英雄页固定上方英雄网格高度;技能 / 核心装备 / 被克装备均可点击,
详情(含装备合成)展示在下方详情区(非弹窗)。
- 「害怕」装备规则完善:莲花 `spell_reflect`、点目标技能自动关联莲花/林肯;
`data/hero_fear_overrides.json` 补经典克制(如剑圣→莲花)。
- 关系预览技能区对齐官网:可点选技能 / 魔晶 / 神杖升级,右侧显示描述;
下方展示天赋树(25→10)。数据来自 Valve herodata`fetch_hero_abilities.py`)。
- 关系预览选中英雄后展示技能(图标 + 名称;可驱散角标;悬停看描述);
数据来自 `data/hero_abilities.json`,图标按需缓存到 `assets/ability_icons/`
- 关系预览「怕的装备」:`fetch_items_meta.py`Valve/OpenDota 描述 → 机制标签)、
`fetch_hero_abilities.py`(技能驱散等汇总)、`item_fears.py`(规则映射)→
`data/items_meta.json` / `hero_abilities.json` / `hero_item_fears.json`
详情区在「核心」下增加「怕」行。例外标签见 `data/item_tag_overrides.json`
本阶段仅预览,无对局内出装推荐。
- 关系预览选中英雄后,下方展示中文定位 `tags` 与核心成品装备(不再分阶段);
`fetch_hero_items.py`OpenDota 热度合并 + Valve 中文名,Steam CDN 图标 →
`data/hero_items.json` / `assets/item_icons/`(过滤消耗品/中立/廉价件;
坚韧球/夜叉等中间件若该英雄常继续合成则隐藏,停在该件则保留)。
- GSI 全量落盘:默认把每包 POST 追加到 `samples/raw/<matchid>/gsi.jsonl` - GSI 全量落盘:默认把每包 POST 追加到 `samples/raw/<matchid>/gsi.jsonl`
`gsi.dump_payloads` / `--dump-gsi` / `--no-dump-gsi`)。 `gsi.dump_payloads` / `--dump-gsi` / `--no-dump-gsi`)。
- 前台窗口检测:`gsi.require_foreground`(默认 true)。选将跟踪时若前台不是 - 前台窗口检测:`gsi.require_foreground`(默认 true)。选将跟踪时若前台不是
`dota2.exe` 则跳过本帧截屏/识别,且不消耗 `strategy_tail_polls`;切回后继续。 `dota2.exe` 则跳过本帧截屏/识别,且不消耗 `strategy_tail_polls`;切回后继续。
- 选将角色标签悬浮层:`overlay.enabled`(默认 true)。跟踪过程中在顶栏已确认
英雄下方显示角色图标(Carry / Nuker / Initiator 等);点击穿透。
- `data/heroes.json` 增加 `roles` 字段(来自 dotaconstants);`fetch_cdn_templates.py` 同步拉取。
- `data/heroes.json` 增加中文口语别名 `aliases`(如斯拉达→大鱼人、主宰→剑圣);
`fetch_cdn_templates.py` 重拉时按 `key` 合并保留,不覆盖手工收录。
- `data/heroes.json` 增加英文缩写 `abbr`(小写列表,如 nec / vs / pa);
关系预览搜索支持缩写且不区分大小写;重拉模板时保留该字段。
- `fetch_stratz_matchups.py` + `audit_relations.py`:拉取 STRATZ 对位并与 OpenDota
交叉审计定性关系,输出 `data/relations_audit.json`
- 定性关系库 `data/relations.json`(克制有向边 + 搭档 + 理由);
`import_relations_xlsx.py` 可从选将笔记导入;`serve_relations.py` 三视图只读预览(改 JSON 后刷新)。
- `fetch_hero_portraits.py` 下载 dota2.com/heroes 竖版头像到 `assets/hero_portraits/`
供关系预览网格使用(与匹配用 `templates/cdn/` 分离)。
- 英雄中文定位标签 `tags`(核心/辅助/推进/逃生/爆发/先手/控制/耐久/幻象);
关系预览页可筛选,不参与推荐打分。
- 关系预览属性图标改用 dota2.com 官方素材(`assets/attr_icons/`),不再手绘。
- 选将 Top-3 推荐改为读定性关系:+克制敌方 / −被敌方克 / +己方搭档;
叠层左上角青方标显示名次 1/2/3。
- draft JSON / GSI `self` 透传 `accountid``steamid`
### Changed ### Changed
- 关系预览英雄详情(`#detail`):贴视口底部展示(`margin-top: auto`,高度仍按内容收缩);
与上方英雄选择区保留约 16px 间距;标题 / 属性条 / 分页 / 技能与装备行 / 检视区整体水平居中(最大宽约 820px),长描述在居中容器内仍左对齐。物品页布局未改。
- 关系预览技能详情区不再重复放大技能图标;顶栏选中态即可识别,详情只保留名称 / 标签 / 驱散 / 描述(核心/怕装详情仍显示装备大图)。
- 关系预览先天技能统一使用共用金色水滴图标 `assets/ability_icons/innate.png`
(多数 innate 在 Steam `dota_react/abilities` 上 404),技能行与详情区不再破图占位。
- 关系预览英雄详情:技能图标与核心/怕装列表图标加大(技能 68px、装备 72×54),
便于点选;商店页 `.shop-item` 尺寸未改。
- 物品页详情面板改为与 11 列商店并排居中集群(`sticky`),贴在商店右侧约 16px
不再固定贴视口右缘;窄屏仍堆叠置顶。
- 物品页:`#items-view` 限制为视口内滚动容器,避免 `sticky` 详情随窗口滚动盖住商店列;
空状态去掉过大 `min-height`,面板高度随内容收缩。
- 关系预览天赋树改为官网弹层风格(居中「天赋树」、25→10 圆形等级徽标、左右选项行)。
- `heroes.json` 迁至 `data/heroes.json`;可再生成的对位/审计/报告文件改由 `.gitignore` 忽略。
- `assets/role_icons/` 改用 Valve 游戏内选人筛选图标(`filter_*`);灰蓝底板 + 深色
符号,透明角填品红 chroma,叠层不再出现黑边。
- 项目定名:**上分帝** / **Climperor**(目录 `climperor`),由 `dota2-draft-vision` 迁出独立仓库。 - 项目定名:**上分帝** / **Climperor**(目录 `climperor`),由 `dota2-draft-vision` 迁出独立仓库。
- **放弃 real 实拍模板库**:删除 `build_library.py``templates/real/`;匹配仅使用 CDN 层。 - **放弃 real 实拍模板库**:删除 `build_library.py``templates/real/`;匹配仅使用 CDN 层。
- 移除 `match.cdn_penalty``evaluate.py --cdn-only`(双层库对比已无意义)。 - 移除 `match.cdn_penalty``evaluate.py --cdn-only`(双层库对比已无意义)。
@@ -22,14 +176,38 @@
- 文档拆分为 `README`(操作)、`DESIGN`(决策)、`AGENTS`(协作)、`CHANGELOG`(变更)。 - 文档拆分为 `README`(操作)、`DESIGN`(决策)、`AGENTS`(协作)、`CHANGELOG`(变更)。
- `DESIGN.md` / `AGENTS.md` 补录原型会话结论:顶栏皮肤时机、决策禁改判原因、 - `DESIGN.md` / `AGENTS.md` 补录原型会话结论:顶栏皮肤时机、决策禁改判原因、
real 库审计结论、无法靠 GSI 卡「皮肤前」单帧等。 real 库审计结论、无法靠 GSI 卡「皮肤前」单帧等。
- 本人顶栏槽位只使用 GSI `team_slot`;移除截屏名字亮度识别「自己是第几格」。
### Removed ### Removed
- real 库补充流水线与相关验收指标。 - real 库补充流水线与相关验收指标。
- 仓库内历史 `preview/``results/` 调试产物。 - 仓库内历史 `preview/``results/` 调试产物。
- `roles._detect_self` 视觉认槽位备用路径。
### Fixed ### Fixed
- 关系预览英雄卡片 title 垃圾清理:`data/relations.json` 的 counters 被
`relations_patch_draft.json` 注入 100 条 reason 为 `opendota_adv/stratz_adv`
胜率数值的边(违反 `meta.note: no winrate` 与 AGENTS.md「不要用胜率/场次表达
机制克制」),拼进 hover title 显示为「opendota_adv=0.13; stratz_adv=4.47」
之类。删除这 100 条 numeric-reason 边(含水人 axe/hoodwink/windrunner →
morphling 三条),保留 5 条定性 + 149 条空 reason counters + 11 条
synergies(空 reason 经 `.filter(Boolean)` 不进 title)。
- 关系预览英雄页:`#detail` 不再 `flex: 1` 撑满网格下方剩余视口(短内容时大片黑底空白);
改为按内容收缩(`fit-content`),并用 `max-height` 限制长文案在面板内滚动。
- 关系预览技能行对齐官网:左起「天赋树图标 → 先天圆形图标 → 方形技能」;天赋详情改为
悬停/点击浮动弹层(不再内联占高);图标见 `assets/ability_icons/talent_tree.png`
- 关系预览天赋树数值修复:天赋 `name_loc``{s:bonus_<sv>}` token 原全部
显示为 `+?`/`-?``format_loc` 只读 `values_float``talent_rows` 只传天赋
自身空 `special_values`)。改为跨技能按 talent key 索引 `bonuses` + sv 名
/单 token 配对两层兜底填 `bonus.value`;重生成 `data/hero_abilities.json`
1016 条全部恢复。naga_siren「捕捞速度」上游缺 bonus,经
`data/talent_overrides.json` 手工补 `+125`
- 关系预览核心装备「阿哈利姆福佑」→「阿哈利姆神杖」:`ultimate_scepter_2`
(id 271) 是神杖 (id 108) 的激活形态,OpenDota 分开计数导致 37 个英雄
核心装备列表出现「福佑」甚至神杖+福佑并存。`fetch_hero_items.py` 新增
`fold_blessing_into_scepter` 把 blessing 计数按 max 合并入 scepter
重生成 `data/hero_items.json` 统一显示「阿哈利姆神杖」。
- (继承自原型)CDN 头像按顶栏窗口裁切,而非中心正方形;CDN-only 即可高准确率。 - (继承自原型)CDN 头像按顶栏窗口裁切,而非中心正方形;CDN-only 即可高准确率。
- (继承自原型)天梯段位条 / 勋章遮罩匹配;选人多帧 + 决策禁改判规避皮肤; - (继承自原型)天梯段位条 / 勋章遮罩匹配;选人多帧 + 决策禁改判规避皮肤;
best 帧优先选人阶段;本人英雄以 GSI 覆盖。 best 帧优先选人阶段;本人英雄以 GSI 覆盖。
+37 -8
View File
@@ -122,7 +122,8 @@ GSI 不含可靠 lobby 类型时,用右下金色勋章检测(`has_ranked_ove
### 发现五:禁用名单不靠模板 ### 发现五:禁用名单不靠模板
网格按主属性分块 + 客户端本地化名行优先排布;禁用/已选卡片对比度塌陷 网格按主属性分块 + 客户端本地化名行优先排布;禁用/已选卡片对比度塌陷
std 约 821 vs 正常 ≥33)。`heroes.json` 需含 `attr` / `name_loc` std 约 821 vs 正常 ≥33)。`data/heroes.json` 需含 `attr` / `name_loc`
另含手工维护的中文口语 `aliases`(重拉 CDN 表时合并保留,暂未接入推荐展示)。
格数对不上时 `ok=False`,不返回残缺名单。 格数对不上时 `ok=False`,不返回残缺名单。
### 发现六:位置与「我」 ### 发现六:位置与「我」
@@ -157,13 +158,20 @@ GSI 不含可靠 lobby 类型时,用右下金色勋章检测(`has_ranked_ove
``` ```
climperor/ climperor/
├── config.json / heroes.json ├── config.json
├── data/ # heroes / relations / items / abilities / fears …
├── common.py / recognize.py / draft_session.py ├── common.py / recognize.py / draft_session.py
├── gsi_watch.py / gsi_setup.py ├── gsi_watch.py / gsi_setup.py / overlay.py
├── fetch_cdn_templates.py ├── recommend.py / relations.py / roles.py / grid.py / modes.py
├── autocalibrate.py / calibrate.py / capture.py ├── http_utils.py / loc_format.py / hero_tags.py / item_fears.py
├── evaluate.py / roles.py / grid.py / modes.py ├── 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
├── autocalibrate.py / calibrate.py / capture.py / evaluate.py
├── web/relations/ # 关系 / 物品 / 版本只读预览页
├── templates/cdn|roles|modes/ ├── templates/cdn|roles|modes/
├── assets/ # portraits / icons / videos(部分 gitignore
└── samples/labels.json # 评测真值;raw/<matchid>/ 见 .gitignore └── samples/labels.json # 评测真值;raw/<matchid>/ 见 .gitignore
``` ```
@@ -182,6 +190,14 @@ climperor/
| `gsi.strategy_gsi_wait` | 视觉结束后等待本人 GSI 英雄 | 3.0 | | `gsi.strategy_gsi_wait` | 视觉结束后等待本人 GSI 英雄 | 3.0 |
| `gsi.require_foreground` | 仅当前台为 `dota2.exe` 时截屏识别 | true | | `gsi.require_foreground` | 仅当前台为 `dota2.exe` 时截屏识别 | true |
| `gsi.dump_payloads` | 全量 GSI JSONL 落盘 | true | | `gsi.dump_payloads` | 全量 GSI JSONL 落盘 | true |
| `recommend.enabled` | 选将 Top-3 推荐 | true |
| `recommend.top_n` | 推荐数量 | 3 |
| `recommend.relations_path` | 定性关系文件 | `data/relations.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` |
--- ---
@@ -204,14 +220,27 @@ Dota 2 (-gamestateintegration)
--- ---
## 6. 与 dota2-hex ## 6. 选将 Top-3 推荐
- **分路**:定位匹配画面字(`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`
- **不做**:避用标、Steam 登录页、爬 Dotabuff;关系**不**写入 `data/heroes.json`
---
## 7. 与 dota2-hex
独立验证项目。可选:Rust 重写并入,或本地旁路 HTTP 回传。 独立验证项目。可选:Rust 重写并入,或本地旁路 HTTP 回传。
合规同 `dota2-hex`**屏幕可见信息 only**。 合规同 `dota2-hex`**屏幕可见信息 only**。
--- ---
## 7. 参考 ## 8. 参考
- [Valve #9562](https://github.com/ValveSoftware/Dota2-Gameplay/issues/9562) - [Valve #9562](https://github.com/ValveSoftware/Dota2-Gameplay/issues/9562)
- [Valve #14915](https://github.com/ValveSoftware/Dota2-Gameplay/issues/14915)(开发者 OCR ≈85% - [Valve #14915](https://github.com/ValveSoftware/Dota2-Gameplay/issues/14915)(开发者 OCR ≈85%
+26 -3
View File
@@ -22,9 +22,32 @@ Dota 2 **天梯选将识别**:从选人 / 决策时间截图中识别双方 10
```powershell ```powershell
pip install -r requirements.txt pip install -r requirements.txt
python fetch_cdn_templates.py # 一次性:heroes.json + templates/cdn/ python fetch_cdn_templates.py # 一次性:data/heroes.json(含基础属性/血蓝)+ templates/cdn/
# 可选:从选将笔记 xlsx 导入定性克制/搭档
# python import_relations_xlsx.py path\to\dota2.xlsx
``` ```
## 克制 / 被克制 / 搭档(游戏外)
```powershell
python fetch_hero_portraits.py # 官网竖版头像 → assets/hero_portraits/
python fetch_hero_items.py # OpenDota 热度 + Valve 中文名 + Steam 图标 → data/hero_items.json
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 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 后刷新)
```
仿局内选将四列网格(图标来自 [dota2.com/heroes](https://www.dota2.com/heroes) 竖版裁切):点英雄高亮克制 / 被克制 / 搭档,下方显示定位,并以标签页切换 **技能**(可点选魔晶/神杖升级与天赋树)、**核心装备**、**被克装备**、**版本变更**(该英雄近一年各版本改动)。
英雄页上方网格固定高度;点选技能 / 核心装备 / 被克装备后,详情显示在下方(装备含合成,非弹窗)。
顶部另有 **物品** 页:对齐 [官网商店物品](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`)。**尚无对局内出装推荐**。
## 快速开始(GSI 自动跟踪) ## 快速开始(GSI 自动跟踪)
```powershell ```powershell
@@ -33,7 +56,7 @@ python gsi_setup.py # 写入 Dota GSI 配置
python gsi_watch.py python gsi_watch.py
``` ```
进入英雄选择后自动跟踪;凑齐 10 人或超时后写出 `results/draft_<时间戳>.json`(该目录已 gitignore)。 进入英雄选择后自动跟踪;定位匹配下会按本局分路 + 敌方/己方关系在英雄网格左上角标出 **Top 3** 青色名次方标(需 `data/relations.json`)。凑齐 10 人或超时后写出 `results/draft_<时间戳>.json`(该目录已 gitignore)。
会话截图按对局落在 `samples/raw/<matchid>/`;默认同时把每包 GSI JSON 追加到同目录 `gsi.jsonl``--no-dump-gsi` 可关)。 会话截图按对局落在 `samples/raw/<matchid>/`;默认同时把每包 GSI JSON 追加到同目录 `gsi.jsonl``--no-dump-gsi` 可关)。
> 主菜单收不到 GSI 是正常的:客户端**第一次载入对局后**才开始推送。看到 `[gsi] connected` 才算链路通。 > 主菜单收不到 GSI 是正常的:客户端**第一次载入对局后**才开始推送。看到 `[gsi] connected` 才算链路通。
@@ -78,7 +101,7 @@ python evaluate.py # 按 samples/labels.json 批量评测
- 宽高比差异大(如 21:9)时可能需重标定 - 宽高比差异大(如 21:9)时可能需重标定
- 客户端改网格排序后 `grid.py``ok=False`,不会瞎报禁用名单 - 客户端改网格排序后 `grid.py``ok=False`,不会瞎报禁用名单
- 新英雄上线后重跑 `fetch_cdn_templates.py` - 新英雄上线后重跑 `fetch_cdn_templates.py`;版本更新后重跑 `fetch_patches.py`
- 位置文字模板取自简体中文客户端 - 位置文字模板取自简体中文客户端
- 决策界面 3D 模型挡住顶栏时单帧会很弱,依赖会话多帧回填 - 决策界面 3D 模型挡住顶栏时单帧会很弱,依赖会话多帧回填
- 普通玩家 GSI 只有自己的英雄 id,不能代替读顶栏 - 普通玩家 GSI 只有自己的英雄 id,不能代替读顶栏
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+374
View File
@@ -0,0 +1,374 @@
"""Audit qualitative relations.json against OpenDota + STRATZ.
Sources:
- data/matchups.json OpenDota vs winrates
- data/stratz_matchups.json STRATZ vs advantage (fetch_stratz.py --mode matchups)
- data/synergies.json STRATZ teammate synergies
Usage:
python audit_relations.py
python audit_relations.py --min-games 80 --out data/relations_audit.json
"""
from __future__ import annotations
import argparse
import json
from datetime import datetime, timezone
from pathlib import Path
from common import ROOT
from grid import hero_table
from relations import DEFAULT_RELATIONS, load_relations
MATCHUPS = ROOT / "data" / "matchups.json"
STRATZ_MATCHUPS = ROOT / "data" / "stratz_matchups.json"
SYNERGIES = ROOT / "data" / "synergies.json"
def _load_json_safe(path: Path) -> dict:
"""Read a JSON file, returning {} on missing/corrupt data."""
if not path.is_file():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def _pair_key(a: str, b: str) -> tuple[str, str]:
return (a, b) if a <= b else (b, a)
def load_id_maps() -> tuple[dict[int, str], dict[str, int], dict[str, str]]:
table = hero_table()
id_to_key = {int(h["id"]): h["key"] for h in table}
key_to_id = {h["key"]: int(h["id"]) for h in table}
names = {h["key"]: h.get("name_loc") or h["key"] for h in table}
return id_to_key, key_to_id, names
def matchup_wr(by_hero: dict, aid: int, bid: int) -> tuple[float | None, int]:
cell = (by_hero.get(str(aid)) or {}).get(str(bid))
if not cell:
return None, 0
games = int(cell.get("games") or 0)
wins = int(cell.get("wins") or 0)
if games <= 0:
return None, 0
return wins / games, games
def build_baseline(by_hero: dict) -> dict[int, float]:
out: dict[int, float] = {}
for hid_s, opps in by_hero.items():
tw = tg = 0
for cell in opps.values():
g = int(cell.get("games") or 0)
w = int(cell.get("wins") or 0)
tw += w
tg += g
if tg > 0:
out[int(hid_s)] = tw / tg
return out
def odota_adv(by_hero: dict, baseline: dict[int, float], aid: int, bid: int) -> tuple[float | None, int]:
wr, games = matchup_wr(by_hero, aid, bid)
if wr is None:
return None, 0
base = baseline.get(aid)
if base is None:
return None, games
return wr - base, games
def stratz_adv(by_hero: dict, aid: int, bid: int) -> tuple[float | None, int]:
cell = (by_hero.get(str(aid)) or {}).get(str(bid))
if not cell:
return None, 0
games = int(cell.get("games") or 0)
if games <= 0:
return None, 0
return float(cell.get("advantage") or 0.0), games
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--min-games", type=int, default=80)
ap.add_argument("--odota-agree", type=float, default=0.015)
ap.add_argument("--odota-disagree", type=float, default=-0.015)
ap.add_argument("--stratz-agree", type=float, default=1.0)
ap.add_argument("--stratz-disagree", type=float, default=-1.0)
ap.add_argument("--syn-agree", type=float, default=1.0)
ap.add_argument("--top", type=int, default=10)
ap.add_argument("--out", type=Path, default=ROOT / "data" / "relations_audit.json")
args = ap.parse_args()
id_to_key, key_to_id, names = load_id_maps()
all_keys = sorted(key_to_id.keys(), key=lambda k: key_to_id[k])
rel = load_relations()
odota = _load_json_safe(MATCHUPS)
stratz = _load_json_safe(STRATZ_MATCHUPS)
syn_file = _load_json_safe(SYNERGIES)
by_o = odota.get("by_hero") or {}
by_s = stratz.get("by_hero") or {}
by_syn = syn_file.get("by_hero") or {}
baseline = build_baseline(by_o)
counters = [(e["a"], e["b"], e.get("reason") or "") for e in rel.get("counters") or []]
syns = [(e["a"], e["b"], e.get("reason") or "") for e in rel.get("synergies") or []]
known_counters = {(a, b) for a, b, _ in counters}
known_syn = {_pair_key(a, b) for a, b, _ in syns}
counter_ok, counter_conflict, counter_weak, counter_no_data = [], [], [], []
for a, b, reason in counters:
aid, bid = key_to_id.get(a), key_to_id.get(b)
if aid is None or bid is None:
counter_no_data.append({"a": a, "b": b, "why": "unknown_hero"})
continue
o_adv, o_games = odota_adv(by_o, baseline, aid, bid)
s_adv, s_games = stratz_adv(by_s, aid, bid)
r_o, r_og = odota_adv(by_o, baseline, bid, aid)
r_s, r_sg = stratz_adv(by_s, bid, aid)
row = {
"a": a,
"a_loc": names.get(a, a),
"b": b,
"b_loc": names.get(b, b),
"reason": reason,
"opendota_adv": None if o_adv is None else round(o_adv, 4),
"opendota_games": o_games,
"stratz_adv": None if s_adv is None else round(s_adv, 3),
"stratz_games": s_games,
"reverse_opendota_adv": None if r_o is None else round(r_o, 4),
"reverse_stratz_adv": None if r_s is None else round(r_s, 3),
}
o_ok = o_adv is not None and o_games >= args.min_games and o_adv >= args.odota_agree
o_bad = o_adv is not None and o_games >= args.min_games and o_adv <= args.odota_disagree
s_ok = s_adv is not None and s_games >= args.min_games and s_adv >= args.stratz_agree
s_bad = s_adv is not None and s_games >= args.min_games and s_adv <= args.stratz_disagree
if o_adv is None and s_adv is None:
counter_no_data.append({**row, "why": "missing_both"})
elif (o_games < args.min_games and (s_games < args.min_games or s_adv is None)):
counter_no_data.append({**row, "why": "low_games"})
elif o_ok or s_ok:
sources = []
if o_ok:
sources.append("opendota")
if s_ok:
sources.append("stratz")
counter_ok.append({**row, "agree_sources": sources})
elif o_bad or s_bad:
tip = "conflict"
if (
(r_o is not None and r_og >= args.min_games and r_o >= args.odota_agree)
or (r_s is not None and r_sg >= args.min_games and r_s >= args.stratz_agree)
):
tip = "maybe_reverse"
counter_conflict.append({**row, "tip": tip})
else:
counter_weak.append(row)
syn_ok, syn_conflict, syn_missing = [], [], []
for a, b, reason in syns:
aid, bid = key_to_id.get(a), key_to_id.get(b)
if aid is None or bid is None:
syn_missing.append({"a": a, "b": b, "why": "unknown_hero"})
continue
cell = (by_syn.get(str(aid)) or {}).get(str(bid)) or (by_syn.get(str(bid)) or {}).get(str(aid))
if not cell:
syn_missing.append({"a": a, "b": b, "a_loc": names.get(a), "b_loc": names.get(b), "why": "no_stratz"})
continue
score = float(cell.get("synergy") or 0)
games = int(cell.get("games") or 0)
row = {
"a": a, "a_loc": names.get(a, a), "b": b, "b_loc": names.get(b, b),
"reason": reason, "stratz_synergy": round(score, 3), "stratz_games": games,
}
if games < args.min_games:
syn_missing.append({**row, "why": "low_games"})
elif score >= args.syn_agree:
syn_ok.append(row)
elif score <= -args.syn_agree:
syn_conflict.append(row)
else:
syn_missing.append({**row, "why": "weak_synergy"})
# Cross-source suggestions: both OpenDota and STRATZ agree A counters B, missing from relations
suggestions: list[dict] = []
for akey in all_keys:
aid = key_to_id[akey]
for bkey in all_keys:
if akey == bkey or (akey, bkey) in known_counters:
continue
bid = key_to_id[bkey]
o_adv, o_games = odota_adv(by_o, baseline, aid, bid)
s_adv, s_games = stratz_adv(by_s, aid, bid)
if (
o_adv is not None and o_games >= args.min_games and o_adv >= 0.03
and s_adv is not None and s_games >= args.min_games and s_adv >= 2.0
):
suggestions.append({
"a": akey, "a_loc": names[akey],
"b": bkey, "b_loc": names[bkey],
"opendota_adv": round(o_adv, 4), "opendota_games": o_games,
"stratz_adv": round(s_adv, 3), "stratz_games": s_games,
})
suggestions.sort(key=lambda r: (-r["stratz_adv"], -r["opendota_adv"]))
suggestions = suggestions[:100]
# Per-hero coverage in qualitative relations
counters_from: dict[str, int] = {k: 0 for k in all_keys}
counters_to: dict[str, int] = {k: 0 for k in all_keys}
syn_count: dict[str, int] = {k: 0 for k in all_keys}
for a, b, _ in counters:
if a in counters_from:
counters_from[a] += 1
if b in counters_to:
counters_to[b] += 1
for a, b, _ in syns:
if a in syn_count:
syn_count[a] += 1
if b in syn_count:
syn_count[b] += 1
missing_cache = {
"opendota_matchups": [
f"{names[k]}({k})" for k in all_keys if str(key_to_id[k]) not in by_o
],
"stratz_matchups": [
f"{names[k]}({k})" for k in all_keys if str(key_to_id[k]) not in by_s
],
"stratz_synergies": [
f"{names[k]}({k})" for k in all_keys if str(key_to_id[k]) not in by_syn
],
}
# Qualitative gaps: no counter-out, no counter-in, no synergy
no_counter_out = [k for k in all_keys if counters_from[k] == 0]
no_counter_in = [k for k in all_keys if counters_to[k] == 0]
no_synergy = [k for k in all_keys if syn_count[k] == 0]
thin = [
k for k in all_keys
if counters_from[k] + counters_to[k] + syn_count[k] <= 1
]
empty = [
k for k in all_keys
if counters_from[k] == 0 and counters_to[k] == 0 and syn_count[k] == 0
]
# Heroes with strong cross-source suggestions but empty/thin qualitative coverage
suggest_by_hero: dict[str, int] = {}
for row in suggestions:
suggest_by_hero[row["a"]] = suggest_by_hero.get(row["a"], 0) + 1
needs_fill = sorted(
[
{
"key": k,
"name_loc": names[k],
"counters_out": counters_from[k],
"counters_in": counters_to[k],
"synergies": syn_count[k],
"cross_source_suggestions": suggest_by_hero.get(k, 0),
}
for k in empty + [x for x in thin if x not in empty]
],
key=lambda r: (r["counters_out"] + r["counters_in"] + r["synergies"], -r["cross_source_suggestions"], r["key"]),
)
report = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"sources": {
"relations": str(DEFAULT_RELATIONS.relative_to(ROOT)).replace("\\", "/"),
"opendota_matchups": {
"path": str(MATCHUPS.relative_to(ROOT)).replace("\\", "/") if MATCHUPS.is_file() else None,
"source": odota.get("source"),
"fetched_at": odota.get("fetched_at"),
"heroes": len(by_o),
},
"stratz_matchups": {
"path": str(STRATZ_MATCHUPS.relative_to(ROOT)).replace("\\", "/") if STRATZ_MATCHUPS.is_file() else None,
"source": stratz.get("source"),
"fetched_at": stratz.get("fetched_at"),
"heroes": len(by_s),
},
"stratz_synergies": {
"path": str(SYNERGIES.relative_to(ROOT)).replace("\\", "/") if SYNERGIES.is_file() else None,
"source": syn_file.get("source"),
"fetched_at": syn_file.get("fetched_at"),
"heroes": len(by_syn),
},
},
"thresholds": {
"min_games": args.min_games,
"odota_agree": args.odota_agree,
"odota_disagree": args.odota_disagree,
"stratz_agree": args.stratz_agree,
"stratz_disagree": args.stratz_disagree,
"syn_agree": args.syn_agree,
},
"summary": {
"relations_counters": len(counters),
"relations_synergies": len(syns),
"counter_agree": len(counter_ok),
"counter_conflict": len(counter_conflict),
"counter_weak": len(counter_weak),
"counter_no_data": len(counter_no_data),
"synergy_agree": len(syn_ok),
"synergy_conflict": len(syn_conflict),
"synergy_weak_or_missing": len(syn_missing),
"heroes_total": len(all_keys),
"heroes_empty_relations": len(empty),
"heroes_thin_relations": len(thin),
},
"missing_cache": missing_cache,
"missing_qualitative": {
"empty": [{"key": k, "name_loc": names[k]} for k in empty],
"no_counters_out": [{"key": k, "name_loc": names[k]} for k in no_counter_out],
"no_counters_in": [{"key": k, "name_loc": names[k]} for k in no_counter_in],
"no_synergy": [{"key": k, "name_loc": names[k]} for k in no_synergy],
"priority_fill": needs_fill,
},
"counter_conflicts": sorted(
counter_conflict,
key=lambda r: ((r.get("stratz_adv") or 0), (r.get("opendota_adv") or 0)),
),
"counter_agree_both": [r for r in counter_ok if set(r.get("agree_sources") or []) == {"opendota", "stratz"}],
"suggest_add_counters_cross_source": suggestions,
"synergy_conflicts": syn_conflict,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
s = report["summary"]
print("=== relations audit ===")
print(
f"counters: agree={s['counter_agree']} (both={len(report['counter_agree_both'])}) "
f"conflict={s['counter_conflict']} weak={s['counter_weak']} no_data={s['counter_no_data']} "
f"/ total={s['relations_counters']}"
)
print(
f"synergies: agree={s['synergy_agree']} conflict={s['synergy_conflict']} "
f"weak/missing={s['synergy_weak_or_missing']} / total={s['relations_synergies']}"
)
print(
f"qualitative gaps: empty={s['heroes_empty_relations']} thin(<={1} edge)={s['heroes_thin_relations']} "
f"/ heroes={s['heroes_total']}"
)
print("cache gaps:")
for k, v in missing_cache.items():
print(f" {k}: {len(v)}")
print("\nPriority heroes with no qualitative edges:")
for row in needs_fill[:40]:
if row["counters_out"] + row["counters_in"] + row["synergies"] == 0:
print(
f" {row['name_loc']}({row['key']}) "
f"suggest={row['cross_source_suggestions']}"
)
print(f"\nwrote {args.out}")
if __name__ == "__main__":
main()
+16
View File
@@ -8,7 +8,23 @@ import numpy as np
ROOT = Path(__file__).parent ROOT = Path(__file__).parent
CONFIG_PATH = ROOT / "config.json" CONFIG_PATH = ROOT / "config.json"
DATA = ROOT / "data"
HEROES_JSON = DATA / "heroes.json"
TEMPLATES_CDN = ROOT / "templates" / "cdn" TEMPLATES_CDN = ROOT / "templates" / "cdn"
# Landscape cards from dota2.com/heroes (Steam CDN heroes/{key}.png); UI only.
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 = 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 = ROOT / "assets" / "ability_icons"
# Generic UI glyphs from dota2.com.cn (herostatic/icons/*.png); relations preview only.
UI_ICONS = ROOT / "assets" / "ui_icons"
# Official ability demo clips from dota2.com (dota_react/abilities/{hero}/{ability}.webm).
ABILITY_VIDEOS = ROOT / "assets" / "ability_videos"
def load_config() -> dict: def load_config() -> dict:
+26 -4
View File
@@ -98,10 +98,21 @@
} }
}, },
"roles": { "roles": {
"comment": "min_iou gates role-label matching; self_* detect your own white name.", "comment": "min_iou gates role-label matching. Own slot comes from GSI team_slot.",
"min_iou": 0.55, "min_iou": 0.55
"self_min_value": 195.0, },
"self_min_gap": 25.0 "recommend": {
"comment": "Top-3 from qualitative relations.json: +counter / -countered / +synergy.",
"enabled": true,
"top_n": 3,
"relations_path": "data/relations.json",
"role_tags": {
"1": ["Carry"],
"2": ["Carry", "Nuker", "Escape"],
"3": ["Initiator", "Durable", "Carry"],
"4": ["Support"],
"5": ["Support"]
}
}, },
"gsi": { "gsi": {
"comment": "Either trigger state starts one tracking session per match; strategy time is the fallback for joining late.", "comment": "Either trigger state starts one tracking session per match; strategy time is the fallback for joining late.",
@@ -123,5 +134,16 @@
"target_slots": 10, "target_slots": 10,
"dump_payloads": true "dump_payloads": true
}, },
"overlay": {
"comment": "Role tags under top-bar + cyan Top-3 recommend marks on the hero grid.",
"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"
},
"calibrated_from": "draft_141704.png" "calibrated_from": "draft_141704.png"
} }
File diff suppressed because it is too large Load Diff
+68
View File
@@ -0,0 +1,68 @@
{
"meta": {
"note": "Manual hero→item fears merged in item_fears.py (add/remove). Use for iconic counters auto-rules miss."
},
"heroes": {
"juggernaut": {
"add": [
{ "item": "lotus_orb", "reason": "反射无敌斩/迅捷斩", "tags": ["spell_reflect"] },
{ "item": "abyssal_blade", "reason": "打断输出与无敌斩", "tags": [] },
{ "item": "butterfly", "reason": "闪避克制普攻与斩击", "tags": [] }
],
"remove": []
},
"phantom_assassin": {
"add": [
{ "item": "monkey_king_bar", "reason": "无视模糊闪避", "tags": ["true_strike"] }
],
"remove": []
},
"lina": {
"add": [
{ "item": "lotus_orb", "reason": "反射神灭斩", "tags": ["spell_reflect"] },
{ "item": "black_king_bar", "reason": "魔免克制爆发", "tags": ["magic_immune"] },
{ "item": "sphere", "reason": "格挡神灭斩", "tags": ["spell_block"] }
],
"remove": []
},
"lion": {
"add": [
{ "item": "lotus_orb", "reason": "反射死亡一指", "tags": ["spell_reflect"] }
],
"remove": []
},
"necrolyte": {
"add": [
{ "item": "lotus_orb", "reason": "反射死神镰刀", "tags": ["spell_reflect"] }
],
"remove": []
},
"axe": {
"add": [
{ "item": "lotus_orb", "reason": "反射淘汰之刃", "tags": ["spell_reflect"] },
{ "item": "eternal_shroud", "reason": "抵消旋转魔法伤害", "tags": [] }
],
"remove": []
},
"legion_commander": {
"add": [
{ "item": "lotus_orb", "reason": "反射决斗", "tags": ["spell_reflect"] },
{ "item": "ethereal_blade", "reason": "虚无无法被决斗普攻", "tags": [] },
{ "item": "ghost", "reason": "虚无无法被决斗普攻", "tags": [] }
],
"remove": []
},
"bane": {
"add": [
{ "item": "lotus_orb", "reason": "反射虚弱/噩梦/蚀脑", "tags": ["spell_reflect"] }
],
"remove": []
},
"doom_bringer": {
"add": [
{ "item": "lotus_orb", "reason": "反射末日", "tags": ["spell_reflect"] }
],
"remove": []
}
}
}
+138
View File
@@ -0,0 +1,138 @@
{
"_comment": "CN in-client order: Unicode code-point of name_loc within each attr.",
"str": [
"elder_titan",
"undying",
"shredder",
"omniknight",
"legion_commander",
"skeleton_king",
"phoenix",
"centaur",
"rattletrap",
"huskar",
"life_stealer",
"earth_spirit",
"abyssal_underlord",
"tiny",
"tusk",
"pudge",
"earthshaker",
"axe",
"slardar",
"sven",
"kunkka",
"night_stalker",
"largo",
"doom_bringer",
"treant",
"chaos_knight",
"tidehunter",
"alchemist",
"lycan",
"primal_beast",
"mars",
"dawnbreaker",
"spirit_breaker",
"bristleback",
"ogre_magi",
"dragon_knight"
],
"agi": [
"juggernaut",
"clinkz",
"viper",
"kez",
"riki",
"drow_ranger",
"morphling",
"templar_assassin",
"vengefulspirit",
"naga_siren",
"troll_warlord",
"phantom_assassin",
"phantom_lancer",
"spectre",
"nevermore",
"terrorblade",
"antimage",
"slark",
"hoodwink",
"ember_spirit",
"ursa",
"sniper",
"lone_druid",
"gyrocopter",
"mirana",
"meepo",
"weaver",
"medusa",
"broodmother",
"faceless_void",
"bloodseeker",
"bounty_hunter",
"razor",
"luna",
"monkey_king"
],
"int": [
"tinker",
"keeper_of_the_light",
"skywrath_mage",
"grimstroke",
"zuus",
"winter_wyvern",
"witch_doctor",
"lich",
"puck",
"pugna",
"disruptor",
"leshrac",
"rubick",
"shadow_demon",
"shadow_shaman",
"warlock",
"jakiro",
"obsidian_destroyer",
"crystal_maiden",
"silencer",
"muerta",
"queenofpain",
"necrolyte",
"ringmaster",
"invoker",
"oracle",
"lina",
"lion",
"ancient_apparition",
"dark_willow",
"chen",
"storm_spirit",
"enchantress",
"dark_seer"
],
"all": [
"abaddon",
"beastmaster",
"venomancer",
"nyx_assassin",
"arc_warden",
"techies",
"dazzle",
"death_prophet",
"sand_king",
"marci",
"snapfire",
"pangolier",
"bane",
"visage",
"furion",
"wisp",
"void_spirit",
"batrider",
"enigma",
"brewmaster",
"windrunner",
"magnataur"
]
}
File diff suppressed because it is too large Load Diff
+6801
View File
File diff suppressed because it is too large Load Diff
+5608
View File
File diff suppressed because it is too large Load Diff
+4608
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
{
"meta": {
"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"] }
}
}
+2061
View File
File diff suppressed because it is too large Load Diff
+32219
View File
File diff suppressed because it is too large Load Diff
+845
View File
@@ -0,0 +1,845 @@
{
"version": 1,
"meta": {
"source": "dota2.xlsx",
"imported_at": "2026-07-26T07:15:47.770566+00:00",
"note": "qualitative counters/synergies; no winrate",
"patched_at": "2026-07-26T10:15:25.180740+00:00",
"patch_source": "data/relations_patch_draft.json",
"patch_note": "added cross-source counters; kept existing edges; no reverses",
"patch_added": 100,
"patch_skipped_dup": 0,
"cleaned_at": "2026-07-26T18:31:42.825338+00:00",
"cleaned_note": "removed numeric-reason counters (opendota_adv/stratz_adv winrate injected by relations_patch_draft.json; violated no-winrate contract)",
"cleaned_removed": 100
},
"counters": [
{
"a": "abaddon",
"b": "axe",
"reason": ""
},
{
"a": "abaddon",
"b": "legion_commander",
"reason": ""
},
{
"a": "abaddon",
"b": "life_stealer",
"reason": ""
},
{
"a": "abaddon",
"b": "slark",
"reason": ""
},
{
"a": "abyssal_underlord",
"b": "luna",
"reason": ""
},
{
"a": "abyssal_underlord",
"b": "riki",
"reason": ""
},
{
"a": "abyssal_underlord",
"b": "spirit_breaker",
"reason": ""
},
{
"a": "antimage",
"b": "lina",
"reason": ""
},
{
"a": "antimage",
"b": "medusa",
"reason": ""
},
{
"a": "antimage",
"b": "queenofpain",
"reason": ""
},
{
"a": "antimage",
"b": "skeleton_king",
"reason": ""
},
{
"a": "antimage",
"b": "sniper",
"reason": ""
},
{
"a": "antimage",
"b": "storm_spirit",
"reason": ""
},
{
"a": "axe",
"b": "antimage",
"reason": ""
},
{
"a": "axe",
"b": "drow_ranger",
"reason": "反击螺旋克敏核"
},
{
"a": "axe",
"b": "juggernaut",
"reason": "反击螺旋克敏核"
},
{
"a": "axe",
"b": "phantom_assassin",
"reason": "反击螺旋克敏核"
},
{
"a": "axe",
"b": "phantom_lancer",
"reason": ""
},
{
"a": "bloodseeker",
"b": "dark_seer",
"reason": ""
},
{
"a": "bloodseeker",
"b": "night_stalker",
"reason": ""
},
{
"a": "bloodseeker",
"b": "riki",
"reason": ""
},
{
"a": "bloodseeker",
"b": "shredder",
"reason": ""
},
{
"a": "bloodseeker",
"b": "slark",
"reason": ""
},
{
"a": "centaur",
"b": "phantom_lancer",
"reason": ""
},
{
"a": "dark_seer",
"b": "batrider",
"reason": ""
},
{
"a": "dark_seer",
"b": "doom_bringer",
"reason": ""
},
{
"a": "dark_seer",
"b": "luna",
"reason": ""
},
{
"a": "dark_seer",
"b": "templar_assassin",
"reason": ""
},
{
"a": "dark_willow",
"b": "spirit_breaker",
"reason": ""
},
{
"a": "dawnbreaker",
"b": "monkey_king",
"reason": ""
},
{
"a": "dawnbreaker",
"b": "ursa",
"reason": ""
},
{
"a": "death_prophet",
"b": "snapfire",
"reason": ""
},
{
"a": "dragon_knight",
"b": "phantom_assassin",
"reason": ""
},
{
"a": "dragon_knight",
"b": "slark",
"reason": ""
},
{
"a": "drow_ranger",
"b": "juggernaut",
"reason": ""
},
{
"a": "drow_ranger",
"b": "zuus",
"reason": ""
},
{
"a": "earth_spirit",
"b": "antimage",
"reason": ""
},
{
"a": "earth_spirit",
"b": "bloodseeker",
"reason": ""
},
{
"a": "earth_spirit",
"b": "faceless_void",
"reason": ""
},
{
"a": "elder_titan",
"b": "drow_ranger",
"reason": ""
},
{
"a": "ember_spirit",
"b": "centaur",
"reason": ""
},
{
"a": "ember_spirit",
"b": "phantom_assassin",
"reason": ""
},
{
"a": "ember_spirit",
"b": "queenofpain",
"reason": ""
},
{
"a": "faceless_void",
"b": "keeper_of_the_light",
"reason": ""
},
{
"a": "faceless_void",
"b": "kez",
"reason": ""
},
{
"a": "faceless_void",
"b": "lina",
"reason": ""
},
{
"a": "furion",
"b": "shredder",
"reason": ""
},
{
"a": "huskar",
"b": "keeper_of_the_light",
"reason": ""
},
{
"a": "huskar",
"b": "riki",
"reason": ""
},
{
"a": "huskar",
"b": "snapfire",
"reason": ""
},
{
"a": "keeper_of_the_light",
"b": "necrolyte",
"reason": ""
},
{
"a": "keeper_of_the_light",
"b": "skeleton_king",
"reason": ""
},
{
"a": "keeper_of_the_light",
"b": "spectre",
"reason": ""
},
{
"a": "keeper_of_the_light",
"b": "spirit_breaker",
"reason": ""
},
{
"a": "kez",
"b": "ancient_apparition",
"reason": ""
},
{
"a": "kunkka",
"b": "luna",
"reason": ""
},
{
"a": "legion_commander",
"b": "antimage",
"reason": ""
},
{
"a": "legion_commander",
"b": "juggernaut",
"reason": ""
},
{
"a": "legion_commander",
"b": "templar_assassin",
"reason": ""
},
{
"a": "leshrac",
"b": "magnataur",
"reason": ""
},
{
"a": "lich",
"b": "dark_seer",
"reason": ""
},
{
"a": "life_stealer",
"b": "axe",
"reason": ""
},
{
"a": "life_stealer",
"b": "pudge",
"reason": ""
},
{
"a": "life_stealer",
"b": "tiny",
"reason": ""
},
{
"a": "lina",
"b": "treant",
"reason": ""
},
{
"a": "luna",
"b": "necrolyte",
"reason": ""
},
{
"a": "magnataur",
"b": "dawnbreaker",
"reason": ""
},
{
"a": "marci",
"b": "lion",
"reason": ""
},
{
"a": "mars",
"b": "drow_ranger",
"reason": ""
},
{
"a": "medusa",
"b": "bloodseeker",
"reason": ""
},
{
"a": "medusa",
"b": "doom_bringer",
"reason": ""
},
{
"a": "medusa",
"b": "marci",
"reason": ""
},
{
"a": "meepo",
"b": "riki",
"reason": ""
},
{
"a": "monkey_king",
"b": "legion_commander",
"reason": ""
},
{
"a": "naga_siren",
"b": "riki",
"reason": ""
},
{
"a": "naga_siren",
"b": "viper",
"reason": ""
},
{
"a": "necrolyte",
"b": "huskar",
"reason": ""
},
{
"a": "necrolyte",
"b": "tiny",
"reason": ""
},
{
"a": "night_stalker",
"b": "antimage",
"reason": ""
},
{
"a": "night_stalker",
"b": "faceless_void",
"reason": ""
},
{
"a": "night_stalker",
"b": "slark",
"reason": ""
},
{
"a": "nyx_assassin",
"b": "bristleback",
"reason": ""
},
{
"a": "nyx_assassin",
"b": "ember_spirit",
"reason": ""
},
{
"a": "nyx_assassin",
"b": "medusa",
"reason": ""
},
{
"a": "nyx_assassin",
"b": "obsidian_destroyer",
"reason": ""
},
{
"a": "nyx_assassin",
"b": "storm_spirit",
"reason": ""
},
{
"a": "nyx_assassin",
"b": "tidehunter",
"reason": ""
},
{
"a": "obsidian_destroyer",
"b": "faceless_void",
"reason": ""
},
{
"a": "obsidian_destroyer",
"b": "necrolyte",
"reason": ""
},
{
"a": "pangolier",
"b": "life_stealer",
"reason": ""
},
{
"a": "phantom_assassin",
"b": "huskar",
"reason": ""
},
{
"a": "phantom_assassin",
"b": "legion_commander",
"reason": ""
},
{
"a": "phantom_assassin",
"b": "ogre_magi",
"reason": ""
},
{
"a": "phantom_assassin",
"b": "sniper",
"reason": ""
},
{
"a": "phantom_lancer",
"b": "obsidian_destroyer",
"reason": ""
},
{
"a": "phantom_lancer",
"b": "viper",
"reason": ""
},
{
"a": "phantom_lancer",
"b": "windrunner",
"reason": ""
},
{
"a": "primal_beast",
"b": "drow_ranger",
"reason": ""
},
{
"a": "primal_beast",
"b": "riki",
"reason": ""
},
{
"a": "pudge",
"b": "drow_ranger",
"reason": ""
},
{
"a": "pudge",
"b": "riki",
"reason": ""
},
{
"a": "queenofpain",
"b": "phantom_assassin",
"reason": ""
},
{
"a": "rattletrap",
"b": "centaur",
"reason": ""
},
{
"a": "razor",
"b": "dragon_knight",
"reason": ""
},
{
"a": "razor",
"b": "life_stealer",
"reason": ""
},
{
"a": "razor",
"b": "troll_warlord",
"reason": ""
},
{
"a": "riki",
"b": "antimage",
"reason": ""
},
{
"a": "skeleton_king",
"b": "bloodseeker",
"reason": ""
},
{
"a": "skeleton_king",
"b": "doom_bringer",
"reason": ""
},
{
"a": "skeleton_king",
"b": "legion_commander",
"reason": ""
},
{
"a": "slardar",
"b": "antimage",
"reason": ""
},
{
"a": "slardar",
"b": "bounty_hunter",
"reason": ""
},
{
"a": "slark",
"b": "bristleback",
"reason": ""
},
{
"a": "slark",
"b": "ember_spirit",
"reason": ""
},
{
"a": "slark",
"b": "juggernaut",
"reason": ""
},
{
"a": "slark",
"b": "lion",
"reason": ""
},
{
"a": "slark",
"b": "rubick",
"reason": ""
},
{
"a": "slark",
"b": "skywrath_mage",
"reason": ""
},
{
"a": "slark",
"b": "tidehunter",
"reason": ""
},
{
"a": "slark",
"b": "tusk",
"reason": ""
},
{
"a": "slark",
"b": "zuus",
"reason": ""
},
{
"a": "snapfire",
"b": "antimage",
"reason": ""
},
{
"a": "snapfire",
"b": "meepo",
"reason": ""
},
{
"a": "snapfire",
"b": "necrolyte",
"reason": ""
},
{
"a": "snapfire",
"b": "phantom_lancer",
"reason": ""
},
{
"a": "sniper",
"b": "drow_ranger",
"reason": ""
},
{
"a": "sniper",
"b": "necrolyte",
"reason": ""
},
{
"a": "spectre",
"b": "snapfire",
"reason": ""
},
{
"a": "spectre",
"b": "sniper",
"reason": ""
},
{
"a": "storm_spirit",
"b": "keeper_of_the_light",
"reason": ""
},
{
"a": "templar_assassin",
"b": "huskar",
"reason": ""
},
{
"a": "templar_assassin",
"b": "legion_commander",
"reason": "折射克决斗斩杀线"
},
{
"a": "templar_assassin",
"b": "skywrath_mage",
"reason": ""
},
{
"a": "terrorblade",
"b": "ancient_apparition",
"reason": ""
},
{
"a": "terrorblade",
"b": "night_stalker",
"reason": ""
},
{
"a": "terrorblade",
"b": "viper",
"reason": ""
},
{
"a": "tidehunter",
"b": "phantom_lancer",
"reason": ""
},
{
"a": "tidehunter",
"b": "puck",
"reason": ""
},
{
"a": "tidehunter",
"b": "queenofpain",
"reason": ""
},
{
"a": "troll_warlord",
"b": "ursa",
"reason": ""
},
{
"a": "undying",
"b": "juggernaut",
"reason": "魔晶墓碑克剑圣大招"
},
{
"a": "ursa",
"b": "axe",
"reason": ""
},
{
"a": "ursa",
"b": "juggernaut",
"reason": ""
},
{
"a": "ursa",
"b": "pudge",
"reason": ""
},
{
"a": "ursa",
"b": "tidehunter",
"reason": ""
},
{
"a": "viper",
"b": "ember_spirit",
"reason": ""
},
{
"a": "viper",
"b": "huskar",
"reason": ""
},
{
"a": "viper",
"b": "night_stalker",
"reason": ""
},
{
"a": "weaver",
"b": "ancient_apparition",
"reason": ""
},
{
"a": "weaver",
"b": "keeper_of_the_light",
"reason": ""
},
{
"a": "weaver",
"b": "pudge",
"reason": ""
},
{
"a": "zuus",
"b": "snapfire",
"reason": ""
},
{
"a": "zuus",
"b": "treant",
"reason": ""
},
{
"a": "zuus",
"b": "ursa",
"reason": ""
}
],
"synergies": [
{
"a": "beastmaster",
"b": "keeper_of_the_light",
"reason": ""
},
{
"a": "centaur",
"b": "dark_seer",
"reason": ""
},
{
"a": "centaur",
"b": "snapfire",
"reason": ""
},
{
"a": "death_prophet",
"b": "mars",
"reason": ""
},
{
"a": "drow_ranger",
"b": "pudge",
"reason": ""
},
{
"a": "faceless_void",
"b": "invoker",
"reason": ""
},
{
"a": "faceless_void",
"b": "skywrath_mage",
"reason": ""
},
{
"a": "kunkka",
"b": "medusa",
"reason": ""
},
{
"a": "legion_commander",
"b": "skywrath_mage",
"reason": ""
},
{
"a": "magnataur",
"b": "snapfire",
"reason": ""
},
{
"a": "mars",
"b": "snapfire",
"reason": ""
}
]
}
+8
View File
@@ -0,0 +1,8 @@
{
"meta": {
"note": "Manual name_loc overrides for talents Valve omits bonus data for (auto-resolves to '?'). Applied in fetch_hero_abilities.py talent_rows when a talent still shows '?' after bonus lookup. Value is the full final name_loc string."
},
"talents": {
"special_bonus_unique_naga_siren_reel_in_speed": "+125 捕捞速度"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"version": "2.14",
"children": [
{
"type": "frame",
"id": "bi8Au",
"x": 0,
"y": 0,
"name": "Frame",
"clip": true,
"width": 800,
"height": 600,
"fill": "#FFFFFF",
"layout": "none"
}
],
"fileToken": "9ee8cb48-01d0-49e3-8fab-391005f1e905"
}
+598
View File
@@ -0,0 +1,598 @@
{
"version": "2.14",
"children": [
{
"type": "frame",
"id": "bi8Au",
"x": 0,
"y": 0,
"name": "Recommend Overlay Mock",
"width": 1440,
"fill": "$bg",
"layout": "vertical",
"gap": 28,
"padding": [
40,
48
],
"children": [
{
"type": "frame",
"id": "sExuu",
"name": "Header",
"width": "fill_container",
"layout": "vertical",
"gap": 8,
"children": [
{
"type": "text",
"id": "XG5C0",
"name": "Title",
"fill": "$text",
"content": "Climperor 选将推荐叠层 · Top 3",
"fontFamily": "Inter",
"fontSize": 28,
"fontWeight": "700"
},
{
"type": "text",
"id": "bbWgo",
"name": "Subtitle",
"fill": "$muted",
"content": "左上角青色方标 + 名次 1/2/3;本局只推荐 3 个英雄,不做避用标",
"fontFamily": "Inter",
"fontSize": 15,
"fontWeight": "normal"
}
]
},
{
"type": "frame",
"id": "FmRJX",
"name": "Legend",
"width": "fill_container",
"fill": "$panel",
"cornerRadius": 12,
"gap": 24,
"padding": [
16,
20
],
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "BU5qu",
"gap": 10,
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "f6dWX",
"width": 26,
"height": 26,
"fill": "$climperorCyan",
"cornerRadius": 6,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "b2q6TZ",
"fill": "#0B1220",
"content": "1",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "700"
}
]
},
{
"type": "text",
"id": "a97jY",
"fill": "$text",
"content": "Climperor Top 3:左上青方标 + 名次 1 / 2 / 3",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "normal"
}
]
},
{
"type": "frame",
"id": "IZA48",
"gap": 8,
"alignItems": "center",
"children": [
{
"type": "frame",
"id": "QBCeV",
"width": 26,
"height": 26,
"fill": "$climperorCyan",
"cornerRadius": 6,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "jLbkL",
"fill": "#0B1220",
"content": "2",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "700"
}
]
},
{
"type": "frame",
"id": "qSBVg",
"width": 26,
"height": 26,
"fill": "$climperorCyan",
"cornerRadius": 6,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "QhcbJ",
"fill": "#0B1220",
"content": "3",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "700"
}
]
},
{
"type": "text",
"id": "Q1OX0h",
"fill": "$muted",
"content": "仅此三种标记",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "normal"
}
]
}
]
},
{
"type": "frame",
"id": "X9AZ3",
"name": "Grid Panel",
"width": "fill_container",
"fill": "$panel",
"cornerRadius": 16,
"layout": "vertical",
"gap": 16,
"padding": 24,
"children": [
{
"type": "text",
"id": "GrQG6",
"fill": "$text",
"content": "英雄选择网格(示意 · 优势路候选)",
"fontFamily": "Inter",
"fontSize": 16,
"fontWeight": "600"
},
{
"type": "frame",
"id": "QZD02",
"name": "Hero Row",
"width": "fill_container",
"gap": 16,
"children": [
{
"type": "frame",
"id": "rbYlg",
"name": "Card 幻影刺客",
"width": 160,
"height": 220,
"fill": "$card",
"cornerRadius": 10,
"stroke": "$border",
"strokeWidth": 1,
"layout": "none",
"children": [
{
"type": "rectangle",
"id": "xgqyq",
"x": 0,
"y": 0,
"name": "Art",
"fill": "#3D4F6F",
"width": 160,
"height": 150
},
{
"type": "rectangle",
"id": "qaeDN",
"x": 0,
"y": 110,
"name": "Fade",
"fill": {
"type": "gradient",
"gradientType": "linear",
"enabled": true,
"rotation": 180,
"size": {
"height": 1
},
"colors": [
{
"color": "#00000000",
"position": 0
},
{
"color": "#0B1220CC",
"position": 1
}
]
},
"width": 160,
"height": 40
},
{
"type": "text",
"id": "VV5Ui",
"x": 10,
"y": 158,
"name": "HeroName",
"fill": "$text",
"content": "幻影刺客",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "600"
},
{
"type": "text",
"id": "iqSKY",
"x": 10,
"y": 182,
"name": "Note",
"fill": "$muted",
"content": "第 1 推荐",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "normal"
},
{
"type": "frame",
"id": "oednb",
"layoutPosition": "absolute",
"x": 8,
"y": 8,
"name": "ClimperorMark",
"width": 28,
"height": 28,
"fill": "$climperorCyan",
"cornerRadius": 7,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "kgwBF",
"fill": "#0B1220",
"content": "1",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "700"
}
]
}
]
},
{
"type": "frame",
"id": "DH12d",
"name": "Card 敌法师",
"width": 160,
"height": 220,
"fill": "$card",
"cornerRadius": 10,
"stroke": "$border",
"strokeWidth": 1,
"layout": "none",
"children": [
{
"type": "rectangle",
"id": "i5t4a8",
"x": 0,
"y": 0,
"name": "Art",
"fill": "#354866",
"width": 160,
"height": 150
},
{
"type": "rectangle",
"id": "MCGHt",
"x": 0,
"y": 110,
"name": "Fade",
"fill": {
"type": "gradient",
"gradientType": "linear",
"enabled": true,
"rotation": 180,
"size": {
"height": 1
},
"colors": [
{
"color": "#00000000",
"position": 0
},
{
"color": "#0B1220CC",
"position": 1
}
]
},
"width": 160,
"height": 40
},
{
"type": "text",
"id": "vQU4Q",
"x": 10,
"y": 158,
"name": "HeroName",
"fill": "$text",
"content": "敌法师",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "600"
},
{
"type": "frame",
"id": "eyCpo",
"layoutPosition": "absolute",
"x": 8,
"y": 8,
"name": "ClimperorMark",
"width": 28,
"height": 28,
"fill": "$climperorCyan",
"cornerRadius": 7,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "MkueZ",
"fill": "#0B1220",
"content": "2",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "700"
}
]
},
{
"type": "text",
"id": "Z8HgB",
"x": 10,
"y": 182,
"name": "Note",
"fill": "$muted",
"content": "第 2 推荐",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "normal"
}
]
},
{
"type": "frame",
"id": "Ens5N",
"name": "Card 冥魂大帝",
"width": 160,
"height": 220,
"fill": "$card",
"cornerRadius": 10,
"stroke": "$border",
"strokeWidth": 1,
"layout": "none",
"children": [
{
"type": "rectangle",
"id": "s76D2",
"x": 0,
"y": 0,
"name": "Art",
"fill": "#2F415C",
"width": 160,
"height": 150
},
{
"type": "rectangle",
"id": "I2m5Ki",
"x": 0,
"y": 110,
"name": "Fade",
"fill": {
"type": "gradient",
"gradientType": "linear",
"enabled": true,
"rotation": 180,
"size": {
"height": 1
},
"colors": [
{
"color": "#00000000",
"position": 0
},
{
"color": "#0B1220CC",
"position": 1
}
]
},
"width": 160,
"height": 40
},
{
"type": "text",
"id": "UmROf",
"x": 10,
"y": 158,
"name": "HeroName",
"fill": "$text",
"content": "冥魂大帝",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "600"
},
{
"type": "text",
"id": "sKaLN",
"x": 10,
"y": 182,
"name": "Note",
"fill": "$muted",
"content": "第 3 推荐",
"fontFamily": "Inter",
"fontSize": 11,
"fontWeight": "normal"
},
{
"type": "frame",
"id": "LrTjT",
"layoutPosition": "absolute",
"x": 8,
"y": 8,
"name": "ClimperorMark",
"width": 28,
"height": 28,
"fill": "$climperorCyan",
"cornerRadius": 7,
"justifyContent": "center",
"alignItems": "center",
"children": [
{
"type": "text",
"id": "gJtg2",
"fill": "#0B1220",
"content": "3",
"fontFamily": "Inter",
"fontSize": 14,
"fontWeight": "700"
}
]
}
]
}
]
}
]
},
{
"type": "frame",
"id": "t5uLoK",
"name": "Footer Notes",
"width": "fill_container",
"fill": "$panel",
"cornerRadius": 10,
"layout": "vertical",
"gap": 6,
"padding": [
12,
16
],
"children": [
{
"type": "text",
"id": "j40a2",
"fill": "$climperorCyan",
"content": "设计要点(已确认)",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "700"
},
{
"type": "text",
"id": "lDu4I",
"fill": "$muted",
"content": "1. 只推荐 3 个英雄:左上角青方标显示名次 1 / 2 / 3。",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "normal"
},
{
"type": "text",
"id": "z1sDyw",
"fill": "$muted",
"content": "2. 颜色固定青色 #2EC4B6;不做琥珀避用标,也不复刻 Plus 三角。",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "normal"
},
{
"type": "text",
"id": "K2l2Bt",
"fill": "$muted",
"content": "3. 按本局分路过滤候选,再按对位分取 Top 3;已选/禁用不画标。",
"fontFamily": "Inter",
"fontSize": 13,
"fontWeight": "normal"
}
]
}
]
}
],
"variables": {
"bg": {
"type": "color",
"value": "#0B1220"
},
"panel": {
"type": "color",
"value": "#151D2E"
},
"card": {
"type": "color",
"value": "#1E2A40"
},
"cardMuted": {
"type": "color",
"value": "#121826"
},
"text": {
"type": "color",
"value": "#E8EEF7"
},
"muted": {
"type": "color",
"value": "#8B9BB4"
},
"plusGreen": {
"type": "color",
"value": "#22C55E"
},
"plusRed": {
"type": "color",
"value": "#EF4444"
},
"climperorCyan": {
"type": "color",
"value": "#2EC4B6"
},
"climperorAmber": {
"type": "color",
"value": "#F4A261"
},
"border": {
"type": "color",
"value": "#2A3650"
}
},
"fileToken": "7e7a24d3-c148-402b-a09b-4592822f3745"
}
+142 -15
View File
@@ -38,6 +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 grid import bans, hero_table, read_grid
from modes import detect_mode, load_mode_templates from modes import detect_mode, load_mode_templates
from recognize import recognize_image from recognize import recognize_image
from recommend import ally_keys, enemy_keys, load_relations, suggest_top
from roles import ROLES, detect_roles, load_role_templates from roles import ROLES, detect_roles, load_role_templates
HERO_SELECTION = "DOTA_GAMERULES_STATE_HERO_SELECTION" HERO_SELECTION = "DOTA_GAMERULES_STATE_HERO_SELECTION"
@@ -55,10 +56,11 @@ def pick_round(per_team_max: int) -> int:
class DraftSession: class DraftSession:
def __init__(self, cfg: dict, library, log=print): def __init__(self, cfg: dict, library, log=print, overlay=None):
self.cfg = cfg self.cfg = cfg
self.library = library self.library = library
self.log = log self.log = log
self.overlay = overlay
g = cfg.get("gsi", {}) g = cfg.get("gsi", {})
self.poll_interval = g.get("poll_interval", 1.0) self.poll_interval = g.get("poll_interval", 1.0)
self.confirm_polls = g.get("confirm_polls", 2) self.confirm_polls = g.get("confirm_polls", 2)
@@ -80,11 +82,48 @@ class DraftSession:
self.mode_templates = load_mode_templates() self.mode_templates = load_mode_templates()
self.hero_names = {h["key"]: h["name_loc"] for h in hero_table()} self.hero_names = {h["key"]: h["name_loc"] for h in hero_table()}
self.frame_dir = raw_dir_for_match(None) 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_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
def _push_overlay(self, confirmed: dict[int, str]) -> None:
if self.overlay is None:
return
try:
self.overlay.set_roster(confirmed)
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:
if self.overlay is None:
return
try:
marks = {p["key"]: p["rank"] for p in picks}
self.overlay.set_grid_marks(cells or {}, marks)
except Exception as e: # noqa: BLE001
self.log(f"[draft] rec overlay update failed: {e}")
def run(self, match_id: str, state_fn, gsi_fn=None) -> dict: def run(self, match_id: str, state_fn, gsi_fn=None) -> dict:
"""Poll until the draft is over. state_fn returns the live GSI state.""" """Poll until the draft is over. state_fn returns the live GSI state."""
self.frame_dir = raw_dir_for_match(match_id) self.frame_dir = raw_dir_for_match(match_id)
self.log(f"[draft] frames -> {self.frame_dir}") self.log(f"[draft] frames -> {self.frame_dir}")
if self.overlay is not None:
try:
self.overlay.set_roster({})
self.overlay.set_grid_marks({}, {})
self.overlay.show()
except Exception as e: # noqa: BLE001
self.log(f"[draft] overlay show failed: {e}")
if self.recommend_enabled and not self._rec_warned:
rel = self.relations or {}
if not rel.get("counters") and not rel.get("synergies"):
self._rec_warned = True
self.log("[rec] relations empty — edit data/relations.json "
"or run import_relations_xlsx.py")
started = time.monotonic() started = time.monotonic()
pending: dict[int, tuple[str, int]] = {} pending: dict[int, tuple[str, int]] = {}
confirmed: dict[int, str] = {} confirmed: dict[int, str] = {}
@@ -96,7 +135,9 @@ class DraftSession:
"self_team": None, "self_team": None,
"roles": {}, "roles": {},
"unavailable": None, "unavailable": None,
"cells": None,
"mode": None, "mode": None,
"recommendations": [],
} }
polls = 0 polls = 0
last_frame = None last_frame = None
@@ -142,6 +183,7 @@ class DraftSession:
frame = grab_frame(sct) frame = grab_frame(sct)
last_frame = frame last_frame = frame
polls += 1 polls += 1
self._sync_self_from_gsi(info, gsi_fn)
# Keep reading in strategy until the roster is full - the last # Keep reading in strategy until the roster is full - the last
# pick is often revealed on the same tick selection ends, and # pick is often revealed on the same tick selection ends, and
@@ -179,12 +221,16 @@ class DraftSession:
rev["t"] = round(elapsed, 1) rev["t"] = round(elapsed, 1)
revisions.append(rev) revisions.append(rev)
self._log_revision(rev) self._log_revision(rev)
if added or revised:
self._push_overlay(confirmed)
if added: if added:
event = self._event(added, confirmed, state, elapsed) event = self._event(added, confirmed, state, elapsed)
if self.keep_frames: if self.keep_frames:
event["frame"] = save_frame(frame, self.frame_dir, prefix="draft") event["frame"] = save_frame(frame, self.frame_dir, prefix="draft")
timeline.append(event) timeline.append(event)
self._log_event(event, info) self._log_event(event, info)
if state == HERO_SELECTION:
self._refresh_recommendations(confirmed, info, gsi_fn)
n = len(confirmed) n = len(confirmed)
if self.keep_frames and n in (4, 8, 10) and n not in saved_milestones: if self.keep_frames and n in (4, 8, 10) and n not in saved_milestones:
@@ -204,6 +250,14 @@ class DraftSession:
if best and not vision_done: if best and not vision_done:
self._finalize_vision(best, confirmed, scores) self._finalize_vision(best, confirmed, scores)
self._push_overlay(confirmed)
self._refresh_recommendations(confirmed, info, gsi_fn, force=True)
if self.overlay is not None:
try:
self.overlay.set_grid_marks({}, {})
self.overlay.hide()
except Exception as e: # noqa: BLE001
self.log(f"[draft] overlay hide failed: {e}")
keep = best["frame"] if best is not None else last_frame keep = best["frame"] if best is not None else last_frame
return self._summary(match_id, timeline, confirmed, info, polls, started, keep, return self._summary(match_id, timeline, confirmed, info, polls, started, keep,
gsi_fn, revisions, best) gsi_fn, revisions, best)
@@ -216,6 +270,7 @@ class DraftSession:
self.log(f"[draft] best lineup frame: {best['recognized']}/10 " self.log(f"[draft] best lineup frame: {best['recognized']}/10 "
f"at t={best['t']:.1f}s ({best['state']})") f"at t={best['t']:.1f}s ({best['state']})")
filled = self._backfill(confirmed, scores, best) filled = self._backfill(confirmed, scores, best)
self._push_overlay(confirmed)
if filled: if filled:
self.log(f"[draft] backfilled {filled} slots from best frame") self.log(f"[draft] backfilled {filled} slots from best frame")
if self.keep_frames: if self.keep_frames:
@@ -267,35 +322,93 @@ class DraftSession:
return cand return cand
return best return best
def _sync_self_from_gsi(self, info: dict, gsi_fn) -> None:
"""Own top-bar slot comes only from GSI team_slot."""
if not gsi_fn:
return
gsi = gsi_fn() or {}
slot = gsi_slot(gsi)
if slot is not None:
info["self_slot"] = slot
if gsi.get("team") in ("radiant", "dire"):
info["self_team"] = gsi["team"]
def _absorb_roles(self, frame, info: dict) -> None: def _absorb_roles(self, frame, info: dict) -> None:
"""Roles and your own slot never change, so stop looking once found.""" """Lane labels never change mid-draft; stop scanning once found."""
if info["self_slot"] is not None and info["roles"]: if info["roles"]:
return return
found = detect_roles(frame, self.cfg, self.role_templates) found = detect_roles(frame, self.cfg, self.role_templates)
if found["self_slot"] is not None and info["self_slot"] is None: if found["roles"]:
info["self_slot"] = found["self_slot"]
if found["roles"] and not info["roles"]:
info["roles"] = found["roles"] info["roles"] = found["roles"]
info["self_team"] = found["self_team"] or info["self_team"] if found["self_team"] and not info["self_team"]:
info["self_team"] = found["self_team"]
def _absorb_grid(self, frame, info: dict) -> None: def _absorb_grid(self, frame, info: dict) -> None:
"""Read the ban list off the hero grid, once. """Read bans (once) and cell rects (whenever the lattice is readable).
The set of banned heroes is fixed before the first pick, so the The set of banned heroes is fixed before the first pick, so the
earliest readable frame is also the cleanest: nothing has been taken earliest readable frame is also the cleanest. Cell geometry is
yet, so everything greyed out is a ban. Frames where a hover tooltip refreshed so recommend badges stay aligned while the grid is up.
covers the grid fail the layout check and are simply skipped.
""" """
if info["unavailable"] is not None:
return
res = read_grid(frame, self.cfg) res = read_grid(frame, self.cfg)
if not res["ok"]: if not res["ok"]:
return return
if res.get("cells"):
info["cells"] = res["cells"]
if info["unavailable"] is not None:
return
info["unavailable"] = res["unavailable"] info["unavailable"] = res["unavailable"]
names = ", ".join(self.hero_names.get(k, k) for k in res["unavailable"]) names = ", ".join(self.hero_names.get(k, k) for k in res["unavailable"])
self.log(f"[draft] grid: {len(res['unavailable'])} heroes unavailable " self.log(f"[draft] grid: {len(res['unavailable'])} heroes unavailable "
f"(contrast margin {res['margin']}) - {names}") f"(contrast margin {res['margin']}) - {names}")
def _refresh_recommendations(self, confirmed: dict, info: dict, gsi_fn, *, force: bool = False) -> None:
if not self.recommend_enabled:
return
if not self.relations:
return
gsi = (gsi_fn() or {}) if gsi_fn else {}
self_slot = gsi_slot(gsi) or info.get("self_slot")
self_team = gsi.get("team") or info.get("self_team")
role = info["roles"].get(self_slot) if self_slot else None
position = role["position"] if role else None
# Stop suggesting once you have locked a hero.
if self_slot and confirmed.get(self_slot):
if info.get("recommendations") or self._last_rec_sig is not None:
info["recommendations"] = []
self._last_rec_sig = ("locked",)
self._push_rec_overlay(info.get("cells"), [])
return
enemies = enemy_keys(confirmed, self_team)
allies = ally_keys(confirmed, self_team, self_slot)
exclude = set(confirmed.values())
if info.get("unavailable"):
exclude.update(info["unavailable"])
sig = (position, self_team, tuple(enemies), tuple(allies), tuple(sorted(exclude)))
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):
info["recommendations"] = []
self._push_rec_overlay(info.get("cells"), [])
return
picks = suggest_top(
position=position,
enemies=enemies,
allies=allies,
exclude=exclude,
relations=self.relations,
top_n=self.recommend_top_n,
role_tags=self.recommend_role_tags,
)
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)
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}")
def _absorb_picks(self, result: dict, pending: dict, confirmed: dict, def _absorb_picks(self, result: dict, pending: dict, confirmed: dict,
scores: dict, *, allow_revise: bool = True, scores: dict, *, allow_revise: bool = True,
) -> tuple[list[dict], list[dict]]: ) -> tuple[list[dict], list[dict]]:
@@ -381,8 +494,6 @@ class DraftSession:
revisions=None, best=None) -> dict: revisions=None, best=None) -> dict:
gsi = gsi_fn() if gsi_fn else {} gsi = gsi_fn() if gsi_fn else {}
self_slot = gsi_slot(gsi) or info["self_slot"] self_slot = gsi_slot(gsi) or info["self_slot"]
if self_slot and info["self_slot"] and self_slot != info["self_slot"]:
self.log(f"[draft] self slot {info['self_slot']} -> {self_slot} (GSI team_slot)")
# GSI knows your own hero with certainty once it is locked. Prefer it. # GSI knows your own hero with certainty once it is locked. Prefer it.
gsi_hero = gsi.get("hero") gsi_hero = gsi.get("hero")
@@ -395,6 +506,9 @@ class DraftSession:
) )
role = info["roles"].get(self_slot) if self_slot else None role = info["roles"].get(self_slot) if self_slot else None
team = gsi.get("team") or info["self_team"]
enemies = enemy_keys(confirmed, team)
allies = ally_keys(confirmed, team, self_slot)
summary = { summary = {
"match_id": match_id, "match_id": match_id,
"captured_at": time.strftime("%Y-%m-%d %H:%M:%S"), "captured_at": time.strftime("%Y-%m-%d %H:%M:%S"),
@@ -409,6 +523,8 @@ class DraftSession:
"role_label": role["label"] if role else None, "role_label": role["label"] if role else None,
"position": role["position"] if role else None, "position": role["position"] if role else None,
"gsi_name": gsi.get("name"), "gsi_name": gsi.get("name"),
"accountid": gsi.get("accountid"),
"steamid": gsi.get("steamid"),
}, },
"team_roles": { "team_roles": {
str(s): {"position": r["position"], "label": r["label"], "hero": confirmed.get(s)} str(s): {"position": r["position"], "label": r["label"], "hero": confirmed.get(s)}
@@ -421,6 +537,12 @@ class DraftSession:
"recognized": len(confirmed), "recognized": len(confirmed),
"revisions": revisions or [], "revisions": revisions or [],
"timeline": timeline, "timeline": timeline,
"recommendations": {
"position": role["position"] if role else None,
"enemies": enemies,
"allies": allies,
"picks": info.get("recommendations") or [],
},
} }
if info["unavailable"] is not None: if info["unavailable"] is not None:
banned = bans({"unavailable": info["unavailable"]}, list(confirmed.values())) banned = bans({"unavailable": info["unavailable"]}, list(confirmed.values()))
@@ -490,6 +612,11 @@ def describe(summary: dict) -> list[str]:
lines.append(f"rounds : {len(summary['timeline'])} reveal events over {summary['duration_s']}s") lines.append(f"rounds : {len(summary['timeline'])} reveal events over {summary['duration_s']}s")
if summary.get("bans_loc"): if summary.get("bans_loc"):
lines.append(f"bans : {len(summary['bans_loc'])} - {', '.join(summary['bans_loc'])}") lines.append(f"bans : {len(summary['bans_loc'])} - {', '.join(summary['bans_loc'])}")
rec = summary.get("recommendations") or {}
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}")
return lines return lines
+124
View File
@@ -0,0 +1,124 @@
"""Export the relations preview as a static site (no server needed).
Usage:
python export_relations_site.py [--out dist/relations] [--with-videos]
Copies web/relations/ + a snapshot of the /api/data payload (data.json) +
the referenced image assets into one directory, ready for any static host
(GitHub Pages, Cloudflare Pages, nginx, ...).
Notes:
- Only already-cached assets are exported. For full ability-icon coverage
run `python fetch_hero_abilities.py --icons-only` first.
- For patch-notes names/icons on the 版本 page, run `python fetch_patches.py`
first (downloads referenced item + ability icons into assets/).
- Ability videos (several GB locally) are skipped unless --with-videos is
given; the UI degrades quietly when a video is missing.
"""
from __future__ import annotations
import argparse
import json
import shutil
from pathlib import Path
from common import (
ABILITY_ICONS,
ABILITY_VIDEOS,
ATTR_ICONS,
HERO_PORTRAITS,
ITEM_CAT_ICONS,
ITEM_ICONS,
ROOT,
TEMPLATES_CDN,
UI_ICONS,
)
from serve_relations import WEB_DIR, build_payload
def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int:
if not src.is_dir():
return 0
dst.mkdir(parents=True, exist_ok=True)
n = 0
for f in sorted(src.glob(pattern)):
if f.is_file():
shutil.copy2(f, dst / f.name)
n += 1
return n
def main() -> None:
ap = argparse.ArgumentParser(
description="Export relations preview as a static site"
)
ap.add_argument("--out", default=str(ROOT / "dist" / "relations"))
ap.add_argument(
"--with-videos",
action="store_true",
help="also copy assets/ability_videos (several GB)",
)
args = ap.parse_args()
out = Path(args.out).resolve()
if out == ROOT.resolve() or out.parent == out:
raise SystemExit(f"refusing unsafe --out: {out}")
if out.exists():
shutil.rmtree(out)
out.mkdir(parents=True)
# Frontend (uses relative paths — works from any sub-path).
for name in ("index.html", "app.js", "style.css"):
shutil.copy2(WEB_DIR / name, out / name)
# Data snapshot (same payload as serve_relations /api/data).
payload = build_payload()
(out / "data.json").write_text(
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
)
# Assets.
counts = {
"attr": copy_glob(ATTR_ICONS, out / "attr"),
"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"),
}
# Portraits: official wide cards, fall back to CDN match templates.
portrait_dst = out / "portrait"
portrait_dst.mkdir(exist_ok=True)
n_portrait = 0
for hero in payload.get("heroes") or []:
key = hero.get("key")
if not key:
continue
src = HERO_PORTRAITS / f"{key}.png"
if not src.is_file():
src = TEMPLATES_CDN / f"{key}.png"
if src.is_file():
shutil.copy2(src, portrait_dst / f"{key}.png")
n_portrait += 1
counts["portrait"] = n_portrait
n_videos = 0
if args.with_videos and ABILITY_VIDEOS.is_dir():
shutil.copytree(ABILITY_VIDEOS, out / "ability-video")
n_videos = sum(1 for f in (out / "ability-video").rglob("*") if f.is_file())
total = sum(f.stat().st_size for f in out.rglob("*") if f.is_file())
print(f"exported static site -> {out}")
for name, n in counts.items():
print(f" {name}/: {n} files")
if args.with_videos:
print(f" ability-video/: {n_videos} files")
print(f" total: {total / 1e6:.1f} MB")
print(f"local check: python -m http.server -d {out} 8080")
print("deploy: upload the directory to any static host (Pages / nginx / ...)")
if __name__ == "__main__":
main()
+355
View File
@@ -0,0 +1,355 @@
"""Download official ability demo clips from dota2.com / Steam CDN.
Source pattern (not GIF):
https://cdn.steamstatic.com/apps/dota2/videos/dota_react/abilities/{hero}/{file}.webm
https://cdn.steamstatic.com/apps/dota2/videos/dota_react/abilities/{hero}/{file}.mp4
https://cdn.steamstatic.com/apps/dota2/videos/dota_react/abilities/{hero}/{file}.jpg
{file} is normally the ability key (e.g. juggernaut_blade_fury). However,
abilities GRANTED by Aghanim's Scepter / Shard use the hero-prefixed upgrade
name on the CDN, not the ability key:
<hero>_aghanims_scepter (e.g. juggernaut_swift_slash -> juggernaut_aghanims_scepter)
<hero>_aghanims_shard
The local file is always saved as {ability_key}.{ext} so the web frontend
(app.js) can resolve it by ability key without knowing the grant type.
Ability keys come from data/hero_abilities.json (run fetch_hero_abilities.py first).
Many innate / shard / facet abilities have no clip (CDN 404) — those are skipped.
Rate limiting: polite delay + jitter between requests; longer backoff on 429/403.
Usage:
python fetch_ability_videos.py --heroes juggernaut
python fetch_ability_videos.py
python fetch_ability_videos.py --delay 2 --jitter 1
python fetch_ability_videos.py --fmt webm --poster
python fetch_ability_videos.py --force
"""
from __future__ import annotations
import argparse
import json
import random
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from common import ABILITY_VIDEOS, DATA
HERO_ABILITIES = DATA / "hero_abilities.json"
CDN_BASE = "https://cdn.steamstatic.com/apps/dota2/videos/dota_react/abilities"
UA = "climperor-ability-video-fetch/1.0 (+https://github.com/local/climperor; respectful crawl)"
REFERER = "https://www.dota2.com/"
MANIFEST = ABILITY_VIDEOS / "manifest.json"
def polite_sleep(delay: float, jitter: float) -> None:
wait = max(0.0, delay) + random.uniform(0.0, max(0.0, jitter))
if wait > 0:
time.sleep(wait)
def http_get(url: str, *, timeout: float = 120) -> tuple[str, bytes | None, int | None]:
"""Return (status, body, http_code). status: ok|missing|rate_limited|error."""
req = urllib.request.Request(
url,
headers={
"User-Agent": UA,
"Referer": REFERER,
"Accept": "*/*",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = resp.read()
ctype = (resp.headers.get("Content-Type") or "").lower()
if "text/html" in ctype and len(data) < 4096:
return "error", None, getattr(resp, "status", 200)
return "ok", data, getattr(resp, "status", 200)
except urllib.error.HTTPError as e:
if e.code == 404:
return "missing", None, 404
if e.code in (403, 429):
return "rate_limited", None, e.code
return "error", None, e.code
except Exception: # noqa: BLE001
return "error", None, None
def _cdn_name(hero_key: str, ab: dict) -> str:
"""CDN demo-clip filename (without extension) for an ability.
Scepter/shard-granted abilities are published on the CDN under
``<hero>_aghanims_scepter`` / ``<hero>_aghanims_shard``, not the
ability key. The local file still uses the ability key (see app.js).
"""
if ab.get("granted_by_scepter"):
return f"{hero_key}_aghanims_scepter"
if ab.get("granted_by_shard"):
return f"{hero_key}_aghanims_shard"
return str(ab.get("key") or "").strip()
def load_ability_index(*, include_innate: bool = False) -> dict[str, list[dict]]:
"""hero_key -> [{key, cdn_name}] for abilities that may have a demo clip.
Innates are skipped by default (they almost never have a CDN clip);
pass include_innate=True to probe them too.
"""
if not HERO_ABILITIES.is_file():
raise SystemExit(
f"missing {HERO_ABILITIES}; run: python fetch_hero_abilities.py"
)
payload = json.loads(HERO_ABILITIES.read_text(encoding="utf-8"))
out: dict[str, list[dict]] = {}
for hero_key, cell in (payload.get("by_hero") or {}).items():
if not isinstance(cell, dict):
continue
entries: list[dict] = []
for ab in cell.get("abilities") or []:
if not isinstance(ab, dict):
continue
if not include_innate and ab.get("is_innate"):
continue
key = str(ab.get("key") or "").strip()
if not key:
continue
entries.append({"key": key, "cdn_name": _cdn_name(hero_key, ab)})
if entries:
out[str(hero_key)] = entries
return out
def load_manifest() -> dict:
if MANIFEST.is_file():
try:
return json.loads(MANIFEST.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
return {"clips": {}, "missing": [], "meta": {}}
def save_manifest(manifest: dict) -> None:
ABILITY_VIDEOS.mkdir(parents=True, exist_ok=True)
manifest["meta"] = {
"source": "steamcdn/dota_react/abilities",
"attribution": "https://www.dota2.com",
"updated_at": datetime.now(timezone.utc).isoformat(),
"clips": len(manifest.get("clips") or {}),
"missing": len(manifest.get("missing") or []),
}
MANIFEST.write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def fetch_one(
url: str,
dest: Path,
*,
delay: float,
jitter: float,
force: bool,
backoff: float,
) -> str:
"""Download one URL. Returns: ok|skip|missing|fail|rate_limited."""
if dest.is_file() and dest.stat().st_size > 0 and not force:
return "skip"
status, body, code = http_get(url)
if status == "ok" and body:
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(dest.suffix + ".part")
tmp.write_bytes(body)
tmp.replace(dest)
polite_sleep(delay, jitter)
return "ok"
if status == "missing":
polite_sleep(delay * 0.5, jitter * 0.5)
return "missing"
if status == "rate_limited":
print(f" rate limited ({code}); sleeping {backoff:.0f}s...", flush=True)
time.sleep(backoff)
status2, body2, code2 = http_get(url)
if status2 == "ok" and body2:
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(dest.suffix + ".part")
tmp.write_bytes(body2)
tmp.replace(dest)
polite_sleep(delay, jitter)
return "ok"
if status2 == "missing":
polite_sleep(delay * 0.5, jitter * 0.5)
return "missing"
print(f" still blocked ({code2}); aborting batch", flush=True)
return "rate_limited"
polite_sleep(delay, jitter)
return "fail"
def main() -> None:
ap = argparse.ArgumentParser(
description="Download dota2.com ability demo videos (rate-limited)"
)
ap.add_argument(
"--heroes",
default="",
help="comma-separated hero keys (default: all in hero_abilities.json)",
)
ap.add_argument(
"--fmt",
choices=("webm", "mp4", "both"),
default="webm",
help="video container (default webm; smaller than mp4)",
)
ap.add_argument(
"--poster",
action="store_true",
help="also download .jpg poster frames",
)
ap.add_argument(
"--delay",
type=float,
default=1.5,
help="base seconds between requests (default 1.5)",
)
ap.add_argument(
"--jitter",
type=float,
default=0.75,
help="extra random seconds added to delay (default 0.75)",
)
ap.add_argument(
"--backoff",
type=float,
default=60.0,
help="seconds to wait after HTTP 403/429 (default 60)",
)
ap.add_argument("--force", action="store_true", help="re-download existing files")
ap.add_argument(
"--include-innate",
action="store_true",
help="also probe innate abilities (usually 404)",
)
args = ap.parse_args()
index = load_ability_index(include_innate=args.include_innate)
wanted = [h.strip() for h in args.heroes.split(",") if h.strip()]
if wanted:
missing_heroes = [h for h in wanted if h not in index]
if missing_heroes:
raise SystemExit(f"unknown heroes (not in hero_abilities.json): {missing_heroes}")
heroes = wanted
else:
heroes = sorted(index.keys())
exts = []
if args.fmt in ("webm", "both"):
exts.append("webm")
if args.fmt in ("mp4", "both"):
exts.append("mp4")
if args.poster:
exts.append("jpg")
manifest = load_manifest()
clips: dict = manifest.setdefault("clips", {})
missing_set = set(manifest.get("missing") or [])
ok = skip = miss = fail = 0
# (hero, ability_key, cdn_name, ext) — cdn_name differs from ability_key
# for scepter/shard-granted abilities.
jobs: list[tuple[str, str, str, str]] = []
for hero in heroes:
for entry in index[hero]:
for ext in exts:
jobs.append((hero, entry["key"], entry["cdn_name"], ext))
print(
f"heroes={len(heroes)} jobs={len(jobs)} "
f"delay={args.delay}+jitter[0,{args.jitter}] "
f"out={ABILITY_VIDEOS}",
flush=True,
)
for n, (hero, ability, cdn_name, ext) in enumerate(jobs, start=1):
rel = f"{hero}/{ability}.{ext}"
dest = ABILITY_VIDEOS / hero / f"{ability}.{ext}"
url = f"{CDN_BASE}/{hero}/{cdn_name}.{ext}"
clip_key = f"{hero}/{ability}"
if (
not args.force
and ext != "jpg"
and clip_key in missing_set
and not dest.is_file()
):
# Previously probed missing video; skip re-probe unless --force.
# Still allow poster retry independently.
if ext in ("webm", "mp4"):
miss += 1
print(f" [{n}/{len(jobs)}] miss(cached) {rel}", flush=True)
continue
status = fetch_one(
url,
dest,
delay=args.delay,
jitter=args.jitter,
force=args.force,
backoff=args.backoff,
)
if status == "ok":
ok += 1
if ext in ("webm", "mp4"):
cell = clips.setdefault(clip_key, {"hero": hero, "ability": ability})
cell[ext] = rel.replace("\\", "/")
cell["bytes_" + ext] = dest.stat().st_size
missing_set.discard(clip_key)
print(f" [{n}/{len(jobs)}] ok {rel} ({dest.stat().st_size} bytes)", flush=True)
elif status == "skip":
skip += 1
if ext in ("webm", "mp4"):
cell = clips.setdefault(clip_key, {"hero": hero, "ability": ability})
cell[ext] = rel.replace("\\", "/")
cell["bytes_" + ext] = dest.stat().st_size
missing_set.discard(clip_key)
if n == 1 or n % 25 == 0 or n == len(jobs):
print(f" [{n}/{len(jobs)}] skip {rel}", flush=True)
elif status == "missing":
miss += 1
if ext in ("webm", "mp4"):
missing_set.add(clip_key)
print(f" [{n}/{len(jobs)}] miss {rel}", flush=True)
elif status == "rate_limited":
fail += 1
manifest["missing"] = sorted(missing_set)
save_manifest(manifest)
print(
f"stopped early after rate limit: ok={ok} skip={skip} miss={miss} fail={fail}",
flush=True,
)
raise SystemExit(2)
else:
fail += 1
print(f" [{n}/{len(jobs)}] FAIL {rel}", flush=True)
if n % 10 == 0 or n == len(jobs):
manifest["missing"] = sorted(missing_set)
save_manifest(manifest)
manifest["missing"] = sorted(missing_set)
save_manifest(manifest)
print(
f"done: downloaded={ok} skipped={skip} missing={miss} failed={fail} "
f"manifest={MANIFEST}",
flush=True,
)
if __name__ == "__main__":
main()
+146 -20
View File
@@ -4,8 +4,10 @@ Usage:
python fetch_cdn_templates.py python fetch_cdn_templates.py
Writes: Writes:
heroes.json - hero id / key / English name table (from OpenDota constants) data/heroes.json - hero id / key / English name / roles / aliases / base stats
templates/cdn/{key}.png - face-centered square crop resized to canonical size templates/cdn/{key}.png - face-centered square crop resized to canonical size
Preserves manually curated `aliases` / `abbr` from an existing heroes.json on rewrite.
""" """
import json import json
@@ -13,17 +15,24 @@ from pathlib import Path
import cv2 import cv2
import numpy as np import numpy as np
import requests
from common import ROOT, TEMPLATES_CDN, load_config from common import HEROES_JSON, TEMPLATES_CDN, load_config
from hero_tags import tags_for_hero
from http_utils import fetch_hero_list, http_bytes, http_json
# Valve's own feed: ids, localized names, primary attribute. The hero-selection # Steam CDN landscape hero art; cropped to a face window for top-bar matching.
# grid groups by that attribute and sorts by that localized name, so taking both
# from the same source is what lets grid.py place every cell without matching.
HEROES_URL = "https://www.dota2.com/datafeed/herolist?language={lang}"
IMG_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png" IMG_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png"
# OpenDota constants: roles + base combat / attribute stats.
ROLES_URL = "https://raw.githubusercontent.com/odota/dotaconstants/master/build/heroes.json"
ATTRS = {0: "str", 1: "agi", 2: "int", 3: "all"} ATTRS = {0: "str", 1: "agi", 2: "int", 3: "all"}
# Level-1 display vitals (match Valve herodata / dota2.com hero strip).
HP_PER_STR = 22
MANA_PER_INT = 12
HP_REGEN_PER_STR = 0.1
MANA_REGEN_PER_INT = 0.05
ARMOR_PER_AGI = 1.0 / 6.0
# The top bar shows a fixed window of the landscape hero art, not a centred # The top bar shows a fixed window of the landscape hero art, not a centred
# square. These bounds were fitted against 15 portraits captured in game: # square. These bounds were fitted against 15 portraits captured in game:
# they lift the mean match score from 0.65 to 0.94. Stored as fractions of # they lift the mean match score from 0.65 to 0.94. Stored as fractions of
@@ -31,12 +40,114 @@ ATTRS = {0: "str", 1: "agi", 2: "int", 3: "all"}
CROP_X0, CROP_X1 = 38 / 256, (38 + 182) / 256 CROP_X0, CROP_X1 = 38 / 256, (38 + 182) / 256
def fetch_heroes(lang: str = "schinese") -> list[dict]: def _num(raw: dict, key: str, default: float = 0.0) -> float:
data = requests.get(HEROES_URL.format(lang=lang), timeout=30).json() try:
heroes = data.get("result", {}).get("data", {}).get("heroes") or data.get("heroes") return float(raw.get(key, default) or default)
if not heroes: except (TypeError, ValueError):
raise SystemExit("hero list came back empty") return default
return heroes
def _round1(v: float) -> float:
return round(v + 1e-9, 1)
def _round2(v: float) -> float:
return round(v + 1e-9, 2)
def stats_from_opendota(raw: dict) -> dict:
"""Derive level-1 strip + combat fields from an OpenDota heroes.json row."""
base_str = _num(raw, "base_str")
base_agi = _num(raw, "base_agi")
base_int = _num(raw, "base_int")
primary = str(raw.get("primary_attr") or "all")
atk_min = _num(raw, "base_attack_min")
atk_max = _num(raw, "base_attack_max")
if primary == "str":
dmg_bonus = base_str
elif primary == "agi":
dmg_bonus = base_agi
elif primary == "int":
dmg_bonus = base_int
else:
dmg_bonus = 0.7 * (base_str + base_agi + base_int)
result = {
"base_str": int(base_str) if base_str == int(base_str) else base_str,
"str_gain": _round1(_num(raw, "str_gain")),
"base_agi": int(base_agi) if base_agi == int(base_agi) else base_agi,
"agi_gain": _round1(_num(raw, "agi_gain")),
"base_int": int(base_int) if base_int == int(base_int) else base_int,
"int_gain": _round1(_num(raw, "int_gain")),
"health": int(round(_num(raw, "base_health", 120) + base_str * HP_PER_STR)),
"mana": int(round(_num(raw, "base_mana", 75) + base_int * MANA_PER_INT)),
"health_regen": _round2(
_num(raw, "base_health_regen") + base_str * HP_REGEN_PER_STR
),
"mana_regen": _round2(
_num(raw, "base_mana_regen") + base_int * MANA_REGEN_PER_INT
),
"armor": _round2(_num(raw, "base_armor") + base_agi * ARMOR_PER_AGI),
"damage_min": int(round(atk_min + dmg_bonus)),
"damage_max": int(round(atk_max + dmg_bonus)),
"move_speed": int(_num(raw, "move_speed")),
"attack_range": int(_num(raw, "attack_range")),
# OpenDota attack_rate == in-game BAT (base attack time).
"attack_rate": _round1(_num(raw, "attack_rate")),
"projectile_speed": int(_num(raw, "projectile_speed")),
"magic_resist": int(_num(raw, "base_mr", 25)),
"vision_day": int(_num(raw, "day_vision", 1800)),
"vision_night": int(_num(raw, "night_vision", 800)),
}
# turn_rate is null in OpenDota for heroes using the game default — omit
# rather than inventing 0.6 so the UI only shows an explicit value.
turn = raw.get("turn_rate")
if turn is not None:
try:
result["turn_rate"] = _round1(float(turn))
except (TypeError, ValueError):
pass
return result
def fetch_opendota_by_id() -> dict[int, dict]:
"""Map hero id -> OpenDota row (roles + base stats)."""
data = http_json(ROLES_URL, timeout=30)
out: dict[int, dict] = {}
for raw in data.values():
hid = raw.get("id")
if hid is None:
continue
out[int(hid)] = raw
return out
def fetch_roles_by_id() -> dict[int, list[str]]:
"""Map hero id -> OpenDota role tags (Carry, Nuker, Initiator, ...)."""
return {
hid: list(raw.get("roles") or [])
for hid, raw in fetch_opendota_by_id().items()
}
def load_existing_str_lists(field: str) -> dict[str, list[str]]:
"""key -> curated string list for `aliases` or `abbr` (empty if file missing)."""
if not HEROES_JSON.is_file():
return {}
try:
rows = json.loads(HEROES_JSON.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
out: dict[str, list[str]] = {}
for row in rows:
key = row.get("key")
if not key:
continue
vals = row.get(field)
if isinstance(vals, list):
cleaned = [a.strip().lower() if field == "abbr" else a.strip()
for a in vals if isinstance(a, str) and a.strip()]
out[str(key)] = cleaned
return out
def main() -> None: def main() -> None:
@@ -45,25 +156,38 @@ def main() -> None:
TEMPLATES_CDN.mkdir(parents=True, exist_ok=True) TEMPLATES_CDN.mkdir(parents=True, exist_ok=True)
print("fetching hero list from the Dota 2 data feed...") print("fetching hero list from the Dota 2 data feed...")
heroes = fetch_heroes() heroes = fetch_hero_list()
print("fetching roles + base stats from dotaconstants...")
odota_by_id = fetch_opendota_by_id()
aliases_by_key = load_existing_str_lists("aliases")
abbr_by_key = load_existing_str_lists("abbr")
table = [] table = []
ok, fail = 0, 0 ok, fail = 0, 0
for h in heroes: for h in heroes:
key = h["name"].removeprefix("npc_dota_hero_") key = h["name"].removeprefix("npc_dota_hero_")
table.append({ odota = odota_by_id.get(int(h["id"])) or {}
roles = list(odota.get("roles") or [])
row = {
"id": h["id"], "id": h["id"],
"key": key, "key": key,
"name": h["name_english_loc"], "name": h["name_english_loc"],
"attr": ATTRS.get(h["primary_attr"], "all"), "attr": ATTRS.get(h["primary_attr"], "all"),
"name_loc": h["name_loc"], "name_loc": h["name_loc"],
}) "roles": roles,
"tags": tags_for_hero(key, roles),
"aliases": aliases_by_key.get(key, []),
"abbr": abbr_by_key.get(key, []),
}
if odota:
row.update(stats_from_opendota(odota))
table.append(row)
out = TEMPLATES_CDN / f"{key}.png" out = TEMPLATES_CDN / f"{key}.png"
if out.exists(): if out.exists():
ok += 1 ok += 1
continue continue
try: try:
raw = requests.get(IMG_URL.format(key=key), timeout=30).content raw = http_bytes(IMG_URL.format(key=key), timeout=30)
img = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR) img = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
if img is None: if img is None:
raise ValueError("decode failed") raise ValueError("decode failed")
@@ -75,9 +199,11 @@ def main() -> None:
print(f" FAILED {key}: {e}") print(f" FAILED {key}: {e}")
fail += 1 fail += 1
with open(ROOT / "heroes.json", "w", encoding="utf-8") as f: HEROES_JSON.write_text(
json.dump(sorted(table, key=lambda t: t["id"]), f, ensure_ascii=False, indent=1) json.dumps(sorted(table, key=lambda t: t["id"]), ensure_ascii=False, indent=2) + "\n",
print(f"done: {ok} templates, {fail} failures, {len(table)} heroes in heroes.json") encoding="utf-8",
)
print(f"done: {ok} templates, {fail} failures, {len(table)} heroes in {HEROES_JSON}")
if __name__ == "__main__": if __name__ == "__main__":
+777
View File
@@ -0,0 +1,777 @@
"""Fetch hero ability notes into data/hero_abilities.json.
Sources:
- Valve herodata?language=schinese (Chinese desc + dispellable)
- OpenDota abilities.json (bkbpierce, English dispellable cross-check)
Usage:
python fetch_hero_abilities.py
python fetch_hero_abilities.py --force
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
"""
from __future__ import annotations
import argparse
import json
import re
import time
import urllib.error
from datetime import datetime, timezone
from pathlib import Path
from common import ABILITY_ICONS, DATA
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
HERODATA_URL = (
"https://www.dota2.com/datafeed/herodata?language=schinese&hero_id={hero_id}"
)
ABILITIES_URL = (
"https://raw.githubusercontent.com/odota/dotaconstants/master/build/abilities.json"
)
ABILITY_ICON_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/abilities/{key}.png"
)
OUT = DATA / "hero_abilities.json"
DESC_MAX = 600
UPGRADE_MAX = 500
TALENT_LEVELS = (10, 10, 15, 15, 20, 20, 25, 25)
# Bundled badges — never fetched from CDN (many innate keys 404 there).
SKIP_ICON_KEYS = frozenset({"innate", "talent_tree"})
def _load_talent_overrides() -> dict[str, str]:
"""Load manual name_loc overrides for talents Valve omits bonus data for."""
p = DATA / "talent_overrides.json"
if not p.is_file():
return {}
try:
raw = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
out: dict[str, str] = {}
items = (raw.get("talents") if isinstance(raw, dict) else None) or {}
if isinstance(items, dict):
for k, v in items.items():
if isinstance(k, str) and isinstance(v, str):
out[k] = v
return out
TALENT_OVERRIDES = _load_talent_overrides()
# Valve dispellable int → label
DISPEL_MAP = {
0: "none",
1: "strong_only",
2: "yes",
3: "no",
}
# DOTA_ABILITY_BEHAVIOR bits (Valve enum)
B_PASSIVE = 1 << 1
B_NO_TARGET = 1 << 2
B_UNIT_TARGET = 1 << 3
B_POINT = 1 << 4
B_CHANNELLED = 1 << 7
B_TOGGLE = 1 << 9
B_AUTOCAST = 1 << 12
B_AURA = 1 << 16
B_VECTOR = 1 << 30
# Valve damage int → 伤害类型 label
DAMAGE_MAP = {1: "物理", 2: "魔法", 4: "纯粹", 8: "纯粹"}
# Valve spell-immunity int → 无视技能免疫 label
IMMUNITY_MAP = {1: "", 2: "", 3: "", 4: "", 5: "友军是 / 敌军否"}
# Valve target_team int → prefix
TARGET_TEAM_MAP = {1: "友方", 2: "敌方", 3: "双方"}
OD_DISPEL_MAP = {
"yes": "yes",
"no": "no",
"strong dispels only": "strong_only",
}
EVASION_RE = re.compile(r"闪避|evasion|miss chance|落空", re.I)
INVIS_RE = re.compile(r"隐身|invisible|invisibility|渐隐|潜行|shadow walk|fade", re.I)
# Only "creates illusions" style — bare 「幻象不会」mentions are noise.
ILLUSION_CREATE_RE = re.compile(
r"创造幻象|制造幻象|召唤幻象|产生幻象|creates?\s+illusions?|summons?\s+illusions?",
re.I,
)
BREAKABLE_PASSIVE_RE = re.compile(
r"破坏会|Break|被动.*失效|禁用被动|disabled by break",
re.I,
)
MAGIC_NUKE_RE = re.compile(r"魔法伤害|magical damage|魔法伤害", re.I)
DISABLE_RE = re.compile(
r"眩晕|沉默|妖术|缠绕|击飞|定身|stun|silence|hex|root|cyclone",
re.I,
)
def ability_icon_keys(by_hero: dict) -> set[str]:
"""Non-innate ability keys shown in the relations skill row (CDN PNGs).
Innates (and Aghs chips of innates) use bundled ``innate.png`` in the UI —
many of those keys 404 on Steam CDN, so they are skipped here.
"""
keys: set[str] = set()
for cell in by_hero.values():
if not isinstance(cell, dict):
continue
for ab in cell.get("abilities") or []:
if not isinstance(ab, dict):
continue
key = ab.get("key")
if not isinstance(key, str) or not key or key in SKIP_ICON_KEYS:
continue
if ab.get("is_innate"):
continue
keys.add(key)
return keys
def download_ability_icons(
keys: set[str], *, force: bool = False, delay: float = 0.05
) -> tuple[int, int, int]:
"""Cache Steam ability icons. Returns (saved, skipped_existing, fail)."""
return download_icons(
keys, ABILITY_ICON_URL, ABILITY_ICONS,
force=force, delay=delay, skip_keys=SKIP_ICON_KEYS,
)
def truncate(text: str, n: int = DESC_MAX) -> str:
t = re.sub(r"\s+", " ", strip_html(text))
if len(t) <= n:
return t
return t[: n - 1] + ""
def load_odota_abilities() -> dict[str, dict]:
raw = http_json(ABILITIES_URL)
out: dict[str, dict] = {}
if not isinstance(raw, dict):
return out
for key, row in raw.items():
if isinstance(row, dict):
out[str(key)] = row
return out
def map_dispellable(valve_val: object, od_val: object) -> str:
try:
iv = int(valve_val) if valve_val is not None else None
except (TypeError, ValueError):
iv = None
if iv is not None and iv in DISPEL_MAP:
label = DISPEL_MAP[iv]
if label != "none":
return label
if isinstance(od_val, str):
return OD_DISPEL_MAP.get(od_val.strip().lower(), "none")
return "none"
def _int_of(val: object) -> int:
try:
return int(val) # behavior/damage/immunity arrive as str or int
except (TypeError, ValueError):
return 0
def fmt_num_list(vals: object, *, is_pct: bool = False) -> str:
"""[100, 200, 300] → '100 / 200 / 300'; integral floats shown as ints."""
out: list[str] = []
if not isinstance(vals, list):
return ""
for v in vals:
try:
f = float(v)
except (TypeError, ValueError):
continue
out.append(str(int(f)) if f == int(f) else f"{f:g}")
txt = " / ".join(out)
if txt and is_pct:
txt += "%"
return txt
def fmt_time_value(vals: object) -> str:
"""Cast point / channel time rarely vary per level; collapse repeats.
[0.3, 0.3, 0.3, 0.3] -> '0.3'; [1, 2, 3] -> '1 / 2 / 3'.
"""
txt = fmt_num_list(vals)
parts = [p for p in txt.split(" / ") if p]
if parts and len(set(parts)) == 1:
return parts[0]
return txt
def target_label(behavior: int, is_innate: bool) -> str:
"""技能 targeting label matching dota2.com (点目标 / 单位目标 / 无目标 / 被动…)."""
if behavior & B_VECTOR:
return "矢量目标"
if behavior & B_UNIT_TARGET and behavior & B_POINT:
return "单位或点目标"
if behavior & B_UNIT_TARGET:
return "单位目标"
if behavior & B_POINT:
return "点目标"
if behavior & B_NO_TARGET:
return "无目标"
if behavior & B_TOGGLE:
return "开关"
if behavior & B_AUTOCAST:
return "自动施法"
if behavior & B_CHANNELLED:
return "持续施法"
if behavior & B_AURA:
return "光环"
if behavior & B_PASSIVE or is_innate:
return "被动"
return ""
def affects_label(behavior: int, team: int, type_bits: int) -> str:
"""影响 label (e.g. 敌方单位) for unit-target abilities."""
if not (behavior & B_UNIT_TARGET):
return ""
prefix = TARGET_TEAM_MAP.get(team, "")
if not prefix:
return ""
hero = bool(type_bits & 1)
creep = bool(type_bits & 2)
building = bool(type_bits & 4)
if hero and creep:
noun = "单位"
elif hero:
noun = "英雄"
elif creep:
noun = "普通单位"
else:
noun = "单位"
if building:
noun += " / 建筑"
return prefix + noun
def special_rows(sv: list) -> list[dict]:
"""special_values → [{label, value}] rows with a non-empty Chinese heading."""
out: list[dict] = []
for row in sv:
if not isinstance(row, dict):
continue
heading = str(row.get("heading_loc") or "").strip().rstrip(":").strip()
if not heading:
continue
value = fmt_num_list(
row.get("values_float"), is_pct=bool(row.get("is_percentage"))
)
if not value:
continue
out.append({"label": heading, "value": value})
return out
def _any_nonzero(vals: object) -> bool:
if not isinstance(vals, list):
return False
for v in vals:
try:
if float(v) != 0:
return True
except (TypeError, ValueError):
continue
return False
def fetch_herodata(hero_id: int) -> dict | None:
try:
raw = http_json(HERODATA_URL.format(hero_id=hero_id), timeout=90)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
return None
heroes = (((raw or {}).get("result") or {}).get("data") or {}).get("heroes") or []
if not heroes or not isinstance(heroes[0], dict):
return None
return heroes[0]
def summarize_hero(
hero_key: str,
abilities: list[dict],
hero_tags: list[str],
) -> dict:
disp_yes = 0
strong_only = 0
has_illusion = hero_key in ILLUSION_KEYS or "幻象" in (hero_tags or [])
has_evasion = False
has_invis = False
has_passive_breakable = False
magic_nuke = False
disable_heavy = 0
for ab in abilities:
desc = (ab.get("desc_loc") or "") + " " + (ab.get("name_loc") or "")
d = ab.get("dispellable") or "none"
if d == "yes":
disp_yes += 1
elif d == "strong_only":
strong_only += 1
if ILLUSION_CREATE_RE.search(desc):
has_illusion = True
if EVASION_RE.search(desc):
has_evasion = True
if INVIS_RE.search(desc):
has_invis = True
if BREAKABLE_PASSIVE_RE.search(desc):
has_passive_breakable = True
name_key = (ab.get("key") or "").lower()
# Known breakable passives by ability key fragments
if any(
x in name_key
for x in (
"blur",
"juxtapose",
"dispersion",
"brilliance_aura",
"dragon_blood",
"bash",
"crippling_fear",
"hunter_in_the_night",
"essence_aura",
"mana_shield",
"backtrack",
"reactive_armor",
"feral_impulse",
"natural_order",
"gravekeepers_cloak",
)
):
has_passive_breakable = True
if MAGIC_NUKE_RE.search(desc):
magic_nuke = True
if DISABLE_RE.search(desc):
disable_heavy += 1
return {
"dispellable_buff_count": disp_yes,
"has_strong_only_buff": strong_only > 0,
"has_illusion": has_illusion,
"has_evasion": has_evasion,
"has_invis": has_invis,
"has_passive_breakable": has_passive_breakable,
"magic_nuke": magic_nuke,
"disable_heavy": disable_heavy >= 2,
"mana_dependent": False, # filled lightly below
}
def ability_row(raw: dict, odota: dict[str, dict]) -> dict | None:
name = (raw.get("name") or "").strip()
if not name:
return None
# Valve uses ability name without npc prefix; OD keys match
key = name
if key.startswith("item_"):
return None
od = odota.get(key) or {}
sv = raw.get("special_values") or []
if not isinstance(sv, list):
sv = []
desc_loc = format_loc(raw.get("desc_loc") or "", sv)
shard_loc = format_loc(raw.get("shard_loc") or "", sv, prefer="shard")
scepter_loc = format_loc(raw.get("scepter_loc") or "", sv, prefer="scepter")
disp = map_dispellable(raw.get("dispellable"), od.get("dispellable"))
bkb = od.get("bkbpierce")
if isinstance(bkb, str):
bkb_s = bkb
else:
bkb_s = None
# Trust Valve's explicit upgrade flags. Valve keeps residual
# scepter_loc/shard_loc text even after the upgrade is removed
# (ability_has_scepter False); the old `or bool(scepter_loc)` fallback
# turned that stale text into a phantom upgrade. Clear it instead.
has_scepter = bool(raw.get("ability_has_scepter"))
has_shard = bool(raw.get("ability_has_shard"))
if not has_scepter:
scepter_loc = ""
if not has_shard:
shard_loc = ""
behavior = _int_of(raw.get("behavior"))
is_innate = bool(raw.get("ability_is_innate"))
cast_range = fmt_num_list(raw.get("cast_ranges"))
if not _any_nonzero(raw.get("cast_ranges")):
cast_range = ""
cooldown = fmt_num_list(raw.get("cooldowns"))
mana_cost = fmt_num_list(raw.get("mana_costs"))
cast_point = (
fmt_time_value(raw.get("cast_points"))
if _any_nonzero(raw.get("cast_points"))
else ""
)
channel_time = (
fmt_time_value(raw.get("channel_times"))
if _any_nonzero(raw.get("channel_times"))
else ""
)
return {
"key": key,
"name_loc": (raw.get("name_loc") or key).strip(),
"desc_loc": truncate(desc_loc),
"shard_loc": truncate(shard_loc, UPGRADE_MAX),
"scepter_loc": truncate(scepter_loc, UPGRADE_MAX),
"has_shard": has_shard,
"has_scepter": has_scepter,
"granted_by_shard": bool(raw.get("ability_is_granted_by_shard")),
"granted_by_scepter": bool(raw.get("ability_is_granted_by_scepter")),
"dispellable": disp,
"immunity": raw.get("immunity"),
"bkbpierce": bkb_s,
"is_innate": is_innate,
# Detail pane fields (dota2.com ability panel)
"target_label": target_label(behavior, is_innate),
"affects_label": affects_label(
behavior, _int_of(raw.get("target_team")), _int_of(raw.get("target_type"))
),
"damage_label": DAMAGE_MAP.get(_int_of(raw.get("damage")), ""),
"immunity_label": IMMUNITY_MAP.get(_int_of(raw.get("immunity")), ""),
"cast_range": cast_range,
"cast_point": cast_point,
"channel_time": channel_time,
"cooldown": cooldown,
"mana_cost": mana_cost,
"specials": special_rows(sv),
"lore_loc": strip_html(raw.get("lore_loc") or "").strip(),
}
def talent_rows(
raw_list: object,
odota: dict[str, dict],
abilities_raw: list | None = None,
) -> list[dict]:
"""8 talents → level 10/15/20/25 left/right (Valve order).
Talent name_loc templates use {s:bonus_<sv_name>} tokens, but the bonus
values are NOT on the talent (its special_values is empty). They live on
each ability's special_values[].bonuses. Valve's data is inconsistent:
- Normal case: bonus.name == talent key, sv_name == token's sv name.
- tinker_5: bonus.name != talent key (uses '..._rearm_channel_time'),
but token sv name == actual sv name → resolve by sv name fallback.
- invoker forged_spirit: bonus found by talent key, but sv_name
('armor_per_attack') != token sv name ('armor_removed') → pair the
single bonus to the single token regardless of name.
The +/- sign and unit (秒/%) are already in name_loc, so we fill the raw
bonus.value regardless of operation (ADD=0, SUBTRACT=2 dominate).
"""
if not isinstance(raw_list, list):
return []
# talent_key -> [(sv_name, bonus_value), ...]
bonus_by_talent: dict[str, list[tuple[str, float]]] = {}
# sv_name -> [bonus_value, ...] (fallback when bonus.name != talent key)
sv_bonus_by_name: dict[str, list[float]] = {}
for ab in abilities_raw or []:
if not isinstance(ab, dict):
continue
for sv in ab.get("special_values") or []:
if not isinstance(sv, dict):
continue
sv_name = str(sv.get("name") or "").strip()
if not sv_name:
continue
for b in sv.get("bonuses") or []:
if not isinstance(b, dict):
continue
tkey = str(b.get("name") or "").strip()
try:
bv = float(b.get("value"))
except (TypeError, ValueError):
continue
if tkey:
bonus_by_talent.setdefault(tkey, []).append((sv_name, bv))
sv_bonus_by_name.setdefault(sv_name, []).append(bv)
out: list[dict] = []
for i, raw in enumerate(raw_list[:8]):
if not isinstance(raw, dict):
continue
key = (raw.get("name") or "").strip()
if not key:
continue
name_loc_raw = raw.get("name_loc") or ""
# sv names the template expects (from {s:bonus_<X>} tokens).
expected = re.findall(r"\{s:bonus_([A-Za-z0-9_]+)\}", name_loc_raw)
keyed = bonus_by_talent.get(key, [])
# Map each expected sv name to a bonus value, with two fallbacks.
value_by_expected: dict[str, float] = {}
for exp in expected:
# 1. exact sv_name match among this talent's keyed bonuses
val = next((v for sn, v in keyed if sn == exp), None)
# 2. single token + single keyed bonus → pair regardless of name
if val is None and len(keyed) == 1 and len(expected) == 1:
val = keyed[0][1]
# 3. fallback: any bonus on a sv with this name across abilities
if val is None:
cands = sv_bonus_by_name.get(exp, [])
if cands:
val = cands[0]
if val is not None:
value_by_expected[exp] = val
pseudo_sv: list[dict] = [
{"name": f"bonus_{exp}", "values_float": [val]}
for exp, val in value_by_expected.items()
]
own_sv = raw.get("special_values") or []
if isinstance(own_sv, list):
pseudo_sv.extend(s for s in own_sv if isinstance(s, dict))
name_loc = format_loc(name_loc_raw, pseudo_sv)
if not name_loc:
od = odota.get(key) or {}
name_loc = format_loc(str(od.get("dname") or key), [])
# Drop unresolved empty placeholder leftovers like "+ %"
name_loc = re.sub(r"\+\s*%", "+", name_loc).strip()
# Manual override for talents Valve omits bonus data for (still '?').
if key in TALENT_OVERRIDES and "?" in name_loc:
name_loc = TALENT_OVERRIDES[key]
level = TALENT_LEVELS[i] if i < len(TALENT_LEVELS) else 10
side = "left" if i % 2 == 0 else "right"
out.append(
{
"key": key,
"name_loc": truncate(name_loc, 160),
"level": level,
"side": side,
}
)
return out
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--delay", type=float, default=0.2)
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument("--force", action="store_true")
ap.add_argument(
"--resummarize",
action="store_true",
help="Recompute summary from cached abilities without refetching herodata",
)
ap.add_argument(
"--icons",
action="store_true",
help="Also cache Steam CDN ability icons into assets/ability_icons/",
)
ap.add_argument(
"--icons-only",
action="store_true",
help="Only download ability icons from existing hero_abilities.json",
)
ap.add_argument(
"--icons-force",
action="store_true",
help="Re-download ability icons even when a local PNG already exists",
)
args = ap.parse_args()
heroes = hero_table()
heroes_by_key = {h["key"]: h for h in heroes}
if args.icons_only:
if not args.out.is_file():
raise SystemExit(f"missing {args.out}; run without --icons-only first")
prev = json.loads(args.out.read_text(encoding="utf-8"))
by_hero = dict(prev.get("by_hero") or {})
keys = ability_icon_keys(by_hero)
print(
f"downloading {len(keys)} ability icons from Steam CDN -> {ABILITY_ICONS}",
flush=True,
)
saved, skipped, fail = download_ability_icons(
keys, force=args.icons_force, delay=min(args.delay, 0.1)
)
print(
f"icons done: saved={saved} skipped={skipped} fail={fail} -> {ABILITY_ICONS}",
flush=True,
)
return
if args.resummarize:
if not args.out.is_file():
raise SystemExit(f"missing {args.out}; run without --resummarize first")
prev = json.loads(args.out.read_text(encoding="utf-8"))
by_hero = dict(prev.get("by_hero") or {})
for key, cell in by_hero.items():
if not isinstance(cell, dict):
continue
h = heroes_by_key.get(key) or {}
tags = list(h.get("tags") or [])
abilities = list(cell.get("abilities") or [])
summary = summarize_hero(key, abilities, tags)
manaish = sum(
1
for ab in abilities
if re.search(r"魔法|mana", (ab.get("desc_loc") or ""), re.I)
)
summary["mana_dependent"] = manaish >= 2 or key in {
"medusa",
"obsidian_destroyer",
"storm_spirit",
"leshrac",
}
cell["summary"] = summary
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),
"resummarized": True,
},
"by_hero": by_hero,
}
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
illus = sum(1 for c in by_hero.values() if (c.get("summary") or {}).get("has_illusion"))
print(f"resummarized {len(by_hero)} heroes (illusion={illus}) -> {args.out}", flush=True)
return
print("loading OpenDota abilities.json...", flush=True)
odota = load_odota_abilities()
print(f" {len(odota)} abilities", flush=True)
by_hero: dict[str, dict] = {}
if args.out.is_file() and not args.force:
try:
prev = json.loads(args.out.read_text(encoding="utf-8"))
for k, cell in (prev.get("by_hero") or {}).items():
# Require talents + detail-pane fields (post-upgrade schema).
abs_prev = cell.get("abilities") if isinstance(cell, dict) else None
abs_list = [a for a in (abs_prev or []) if isinstance(a, dict)]
# Require every schema generation's fields; missing any of them
# invalidates the cache so the hero is refetched with new data.
if (
isinstance(cell, dict)
and abs_prev is not None
and cell.get("talents") is not None
and any("specials" in a for a in abs_list)
and any("cast_point" in a for a in abs_list)
):
by_hero[str(k)] = cell
print(f"resuming with {len(by_hero)} heroes cached", flush=True)
except (OSError, json.JSONDecodeError):
pass
pending = [h for h in heroes if h["key"] not in by_hero]
print(f"fetching herodata for {len(pending)} / {len(heroes)} heroes...", flush=True)
for n, h in enumerate(pending, start=1):
key = h["key"]
hid = int(h["id"])
tags = list(h.get("tags") or [])
data = fetch_herodata(hid)
if data is None:
print(f" [{n}/{len(pending)}] {key} failed", flush=True)
time.sleep(max(args.delay, 0.1) * 2)
continue
abs_raw = data.get("abilities") or []
abilities: list[dict] = []
if isinstance(abs_raw, list):
for row in abs_raw:
if not isinstance(row, dict):
continue
ab = ability_row(row, odota)
if ab:
abilities.append(ab)
talents = talent_rows(data.get("talents"), odota, abs_raw)
summary = summarize_hero(key, abilities, tags)
manaish = sum(
1
for ab in abilities
if re.search(r"魔法|mana", (ab.get("desc_loc") or ""), re.I)
)
summary["mana_dependent"] = manaish >= 2 or key in {
"medusa",
"obsidian_destroyer",
"storm_spirit",
"leshrac",
}
by_hero[key] = {
"id": hid,
"abilities": abilities,
"talents": talents,
"summary": summary,
}
n_up = sum(1 for a in abilities if a.get("shard_loc") or a.get("scepter_loc"))
print(
f" [{n}/{len(pending)}] {key}: {len(abilities)} abilities "
f"upgrades={n_up} talents={len(talents)} "
f"dispel={summary['dispellable_buff_count']}",
flush=True,
)
# Checkpoint every 10 heroes (or on the last one) so a crash only
# loses a small slice, without writing the full file on every hero.
if n % 10 == 0 or n == len(pending):
args.out.parent.mkdir(parents=True, exist_ok=True)
payload = {
"meta": {
"source": "valve+opendota",
"attribution": "https://www.dota2.com ; https://www.opendota.com",
"fetched_at": datetime.now(timezone.utc).isoformat(),
},
"by_hero": by_hero,
}
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
time.sleep(args.delay)
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),
},
"by_hero": by_hero,
}
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",
)
print(f"done: {len(by_hero)} heroes -> {args.out}", flush=True)
if args.icons:
keys = ability_icon_keys(by_hero)
print(
f"downloading {len(keys)} ability icons from Steam CDN -> {ABILITY_ICONS}",
flush=True,
)
saved, skipped, fail = download_ability_icons(
keys, force=args.icons_force, delay=min(args.delay, 0.1)
)
print(
f"icons done: saved={saved} skipped={skipped} fail={fail} -> {ABILITY_ICONS}",
flush=True,
)
if __name__ == "__main__":
main()
+314
View File
@@ -0,0 +1,314 @@
"""Fetch OpenDota hero item popularity into data/hero_items.json.
Stores a flat Top-N of core finished items per hero (no start/early/mid/late).
Item names (Chinese): Valve dota2.com datafeed (schinese).
Item icons: Steam CDN dota_react/items/{key}.png → assets/item_icons/.
Usage:
python fetch_hero_items.py
python fetch_hero_items.py --force
python fetch_hero_items.py --delay 0.3 --skip-icons
"""
from __future__ import annotations
import argparse
import json
import time
import urllib.error
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, load_itemlist_zh
OPENDOTA = "https://api.opendota.com/api"
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_items.json"
PHASE_API = (
"start_game_items",
"early_game_items",
"mid_game_items",
"late_game_items",
)
# Crafted boots / mid-game cores (phase, treads, vanguard); skip bracer/wand.
MIN_CREATED_COST = 1400
# Non-crafted shop cores that are still finished pickups (blink / 跳刀).
ALWAYS_CORE = frozenset({"blink", "aghanims_shard"})
# Aghanim's Blessing (ultimate_scepter_2, id 271) is the activated form of
# Aghanim's Scepter (ultimate_scepter, id 108). Merge blessing counts into
# scepter so heroes show "阿哈利姆神杖" once, never "阿哈利姆福佑".
BLESSING_TO_SCEPTER = {271: 108}
TOP_N = 12
# If any upgrade's popularity >= this fraction of the intermediate's, hide it
# (e.g. AM yasha→manta). If upgrades are rare (Jugg yasha only), keep it.
UPGRADE_RATIO = 0.35
def load_item_catalog() -> tuple[dict[int, dict], dict[int, list[int]]]:
"""Return (id -> meta, id -> upgrade item ids that list it as a component)."""
raw = http_json(ITEMS_URL)
zh = load_itemlist_zh()
key_to_id: dict[str, int] = {}
out: dict[int, dict] = {}
for key, row in raw.items():
if not isinstance(row, dict):
continue
iid = row.get("id")
if iid is None:
continue
key_s = str(key)
if key_s.startswith("recipe_"):
continue
dname = str(row.get("dname") or key_s)
try:
cost = int(row.get("cost") or 0)
except (TypeError, ValueError):
cost = 0
comps = [str(c) for c in (row.get("components") or []) if c]
out[int(iid)] = {
"key": key_s,
"dname": dname,
"name_loc": zh.get(int(iid)) or dname,
"created": bool(row.get("created")),
"cost": cost,
"tier": row.get("tier"),
"qual": row.get("qual"),
"components": comps,
}
key_to_id[key_s] = int(iid)
upgrades_of: dict[int, list[int]] = {iid: [] for iid in out}
for iid, meta in out.items():
for comp_key in meta["components"]:
cid = key_to_id.get(comp_key)
if cid is None or cid == iid:
continue
upgrades_of.setdefault(cid, []).append(iid)
return out, upgrades_of
def is_core_finished(meta: dict) -> bool:
"""Core finished items — skip consumables, neutrals, secret-shop parts, cheap early."""
key = meta.get("key") or ""
if meta.get("tier") is not None:
return False
if meta.get("qual") == "consumable":
return False
cost = int(meta.get("cost") or 0)
if cost <= 0:
return False
if key in ALWAYS_CORE:
return True
# Only recipe-assembled items (phase boots, bfury, bkb, …).
if not meta.get("created"):
return False
return cost >= MIN_CREATED_COST
def merge_phase_counts(raw: dict) -> dict[int, int]:
"""Max count per item id across OpenDota phases."""
merged: dict[int, int] = {}
for api_key in PHASE_API:
counts = raw.get(api_key) or {}
if not isinstance(counts, dict):
continue
for sid, cnt in counts.items():
try:
iid = int(sid)
c = int(cnt)
except (TypeError, ValueError):
continue
if c <= 0:
continue
prev = merged.get(iid, 0)
if c > prev:
merged[iid] = c
return merged
def fold_blessing_into_scepter(counts: dict[int, int]) -> dict[int, int]:
"""Merge Aghanim's Blessing (id 271) counts into Aghanim's Scepter (id 108).
Blessing is the activated/synthesized form of Scepter; OpenDota records
them under separate ids. Take max (not sum): the same game can register
both the purchase and the activation, which would double-count.
"""
out = dict(counts)
for bid, sid in BLESSING_TO_SCEPTER.items():
b = out.pop(bid, 0)
s = out.get(sid, 0)
if b or s:
out[sid] = max(s, b)
return out
def is_terminal_for_hero(
iid: int,
count: int,
all_counts: dict[int, int],
upgrades_of: dict[int, list[int]],
ratio: float = UPGRADE_RATIO,
) -> bool:
"""True if hero rarely upgrades this item further (keep yasha for Jugg, drop for AM)."""
parents = upgrades_of.get(iid) or []
if not parents:
return True
threshold = max(1, int(count * ratio))
for pid in parents:
if all_counts.get(pid, 0) >= threshold:
return False
return True
def core_from_popularity(
raw: dict,
catalog: dict[int, dict],
upgrades_of: dict[int, list[int]],
n: int = TOP_N,
) -> list[dict]:
"""Merge phases → core items → drop intermediates that this hero upgrades."""
all_counts = fold_blessing_into_scepter(merge_phase_counts(raw))
merged: dict[int, int] = {}
for iid, c in all_counts.items():
meta = catalog.get(iid)
if meta is None or not is_core_finished(meta):
continue
if not is_terminal_for_hero(iid, c, all_counts, upgrades_of):
continue
merged[iid] = c
ranked = sorted(merged.items(), key=lambda t: (-t[1], t[0]))
return [{"id": iid, "count": c} for iid, c in ranked[:n]]
def fetch_popularity(hero_id: int) -> dict:
return http_json(f"{OPENDOTA}/heroes/{hero_id}/itemPopularity") # type: ignore[return-value]
def build_payload(by_hero: dict, catalog: dict[int, dict], used_ids: set[int]) -> dict:
items_out = {
str(iid): {
"key": catalog[iid]["key"],
"dname": catalog[iid]["dname"],
"name_loc": catalog[iid]["name_loc"],
}
for iid in sorted(used_ids)
if iid in catalog
}
return {
"meta": {
"source": "opendota+valve",
"attribution": "https://www.opendota.com ; https://www.dota2.com",
"fetched_at": datetime.now(timezone.utc).isoformat(),
"top_n": TOP_N,
"mode": "core_finished_terminal",
"upgrade_ratio": UPGRADE_RATIO,
"icons": "steam_cdn",
},
"items": items_out,
"by_hero": by_hero,
}
def collect_used_ids(by_hero: dict) -> set[int]:
used: set[int] = set()
for cell in by_hero.values():
if not isinstance(cell, list):
continue
for row in cell:
if isinstance(row, dict) and "id" in row:
used.add(int(row["id"]))
return used
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--delay", type=float, default=0.3)
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument("--skip-icons", action="store_true")
ap.add_argument("--force-icons", action="store_true")
ap.add_argument(
"--force",
action="store_true",
help="Refetch all heroes (ignore existing by_hero cache)",
)
args = ap.parse_args()
print("fetching item catalog (dotaconstants + Valve schinese)...", flush=True)
catalog, upgrades_of = load_item_catalog()
print(f" {len(catalog)} items", flush=True)
heroes = hero_table()
by_key = {h["key"]: int(h["id"]) for h in heroes}
by_hero: dict[str, list] = {}
if args.out.is_file() and not args.force:
try:
prev = json.loads(args.out.read_text(encoding="utf-8"))
for k, cell in (prev.get("by_hero") or {}).items():
if isinstance(cell, list):
by_hero[str(k)] = cell
print(f"resuming with {len(by_hero)} heroes already cached", flush=True)
except (OSError, json.JSONDecodeError):
pass
elif args.force:
print("force: refetching all heroes", flush=True)
pending = [k for k in sorted(by_key) if k not in by_hero]
print(f"fetching {len(pending)} / {len(by_key)} heroes -> {args.out}", flush=True)
used_ids = collect_used_ids(by_hero)
for n, key in enumerate(pending, start=1):
hid = by_key[key]
try:
raw = fetch_popularity(hid)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, TypeError) as e:
print(f" [{n}/{len(pending)}] {key} ({hid}) failed: {e}", flush=True)
time.sleep(max(args.delay, 0.1) * 2)
continue
core = core_from_popularity(raw, catalog, upgrades_of)
by_hero[key] = core
used_ids.update(int(row["id"]) for row in core if isinstance(row, dict) and "id" in row)
print(f" [{n}/{len(pending)}] {key}: {len(core)} core items", flush=True)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(
json.dumps(build_payload(by_hero, catalog, used_ids), ensure_ascii=False, indent=2)
+ "\n",
encoding="utf-8",
)
time.sleep(args.delay)
used_ids = collect_used_ids(by_hero)
payload = build_payload(by_hero, catalog, used_ids)
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",
)
if not args.skip_icons:
keys = {catalog[iid]["key"] for iid in used_ids if iid in catalog}
print(f"downloading {len(keys)} item icons from Steam CDN -> {ITEM_ICONS}", flush=True)
saved, skipped, fail = download_icons(keys, ICON_URL, ITEM_ICONS, force=args.force_icons)
print(f" icons saved={saved} skipped={skipped} fail={fail}", flush=True)
missing = [k for k in by_key if k not in by_hero]
print(
f"done: {len(by_hero)} heroes, {len(payload['items'])} items"
+ (f", {len(missing)} still missing" if missing else ""),
flush=True,
)
if __name__ == "__main__":
main()
+106
View File
@@ -0,0 +1,106 @@
"""Download UI art used by https://www.dota2.com/heroes.
- Landscape hero cards: Steam CDN `heroes/{key}.png`
- Attribute icons: Steam CDN `icons/hero_{strength,agility,...}.png`
Separate from `templates/cdn/` face crops used for top-bar matching.
Usage:
python fetch_hero_portraits.py
python fetch_hero_portraits.py --force
Writes:
assets/hero_portraits/{key}.png
assets/attr_icons/{str,agi,int,all}.png
"""
from __future__ import annotations
import argparse
import json
from common import ATTR_ICONS, HERO_PORTRAITS, HEROES_JSON
from http_utils import fetch_hero_keys, http_bytes
# wide = face headshots; crop = waist-up 3D renders (usually worse in dense grids).
CARD_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png"
)
CROP_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/crops/{key}.png"
)
ATTR_ICON_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/icons/{name}.png"
)
ATTR_FILES = {
"str": "hero_strength",
"agi": "hero_agility",
"int": "hero_intelligence",
"all": "hero_universal",
}
def hero_keys() -> list[str]:
if HEROES_JSON.is_file():
rows = json.loads(HEROES_JSON.read_text(encoding="utf-8"))
return [h["key"] for h in rows if h.get("key")]
return fetch_hero_keys()
def fetch_attr_icons(*, force: bool) -> tuple[int, int, int]:
ATTR_ICONS.mkdir(parents=True, exist_ok=True)
ok = skip = fail = 0
for attr, name in ATTR_FILES.items():
out = ATTR_ICONS / f"{attr}.png"
if out.is_file() and not force:
skip += 1
continue
try:
data = http_bytes(ATTR_ICON_URL.format(name=name), timeout=30)
out.write_bytes(data)
ok += 1
print(f" attr {attr} <- {name} ({len(data)} bytes)")
except Exception as e: # noqa: BLE001
fail += 1
print(f" FAIL attr {attr}: {e}")
return ok, skip, fail
def main() -> None:
ap = argparse.ArgumentParser(description="Download dota2.com/heroes UI art")
ap.add_argument("--force", action="store_true", help="re-download existing files")
ap.add_argument(
"--style",
choices=("crop", "wide"),
default="wide",
help="wide = face headshots for draft grid (default); crop = waist-up renders",
)
args = ap.parse_args()
print("attribute icons...")
a_ok, a_skip, a_fail = fetch_attr_icons(force=args.force)
HERO_PORTRAITS.mkdir(parents=True, exist_ok=True)
keys = hero_keys()
ok = skip = fail = 0
url_t = CROP_URL if args.style == "crop" else CARD_URL
print(f"hero cards ({args.style})...")
for key in keys:
out = HERO_PORTRAITS / f"{key}.png"
if out.is_file() and not args.force:
skip += 1
continue
try:
data = http_bytes(url_t.format(key=key), timeout=30)
out.write_bytes(data)
ok += 1
print(f" ok {key} ({len(data)} bytes)")
except Exception as e: # noqa: BLE001
fail += 1
print(f" FAIL {key}: {e}")
print(f"attrs: downloaded={a_ok} skipped={a_skip} failed={a_fail} -> {ATTR_ICONS}")
print(f"heroes: downloaded={ok} skipped={skip} failed={fail} -> {HERO_PORTRAITS}")
if __name__ == "__main__":
main()
+341
View File
@@ -0,0 +1,341 @@
"""Build data/item_shop.json from the official CN shop layout.
Primary source: https://www.dota2.com.cn/itemscategory/json (basic / upgrade columns).
Names / cost / components: OpenDota items.json + Valve itemlist (schinese).
Icons → assets/item_icons/; category icons → assets/item_cat_icons/.
Usage:
python fetch_item_shop.py
python fetch_item_shop.py --skip-icons
"""
from __future__ import annotations
import argparse
import json
import re
import time
import urllib.error
from datetime import datetime, timezone
from pathlib import Path
from common import DATA, ITEM_CAT_ICONS, ITEM_ICONS
from http_utils import download_icons, http_bytes, http_json, load_itemlist_zh
ITEMS_URL = (
"https://raw.githubusercontent.com/odota/dotaconstants/master/build/items.json"
)
CN_CATEGORY_URL = "https://www.dota2.com.cn/itemscategory/json"
ICON_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/items/{key}.png"
)
CAT_ICON_BASE = "https://www.dota2.com.cn/items/images/"
OUT = DATA / "item_shop.json"
# Official column label → category icon filename (from items_new.js).
CAT_ICON_BY_LABEL = {
"消耗品": "itemcat_consumables.png",
"属性": "itemcat_attributes.png",
"装备": "itemcat_armaments.png",
"其它": "itemcat_arcane.png",
"其他": "itemcat_arcane.png",
"神秘商店": "itemcat_secret.png",
"配件": "itemcat_common.png",
"辅助": "itemcat_support.png",
"法器": "itemcat_caster.png",
"防具": "itemcat_armor.png",
"兵刃": "itemcat_weapons.png",
"宝物": "itemcat_artifacts.png",
"军备": "itemcat_artifacts.png",
}
# Stable section ids for UI / lookups.
SECTION_ID_BY_LABEL = {
"消耗品": "consumables",
"属性": "attributes",
"装备": "equipment",
"其它": "misc",
"其他": "misc",
"神秘商店": "secretshop",
"配件": "basics",
"辅助": "support",
"法器": "magics",
"防具": "defense",
"兵刃": "weapons",
"宝物": "artifacts",
"军备": "artifacts",
}
def slug_section(label: str, fallback: str) -> str:
sid = SECTION_ID_BY_LABEL.get(label)
if sid:
return sid
s = re.sub(r"[^a-z0-9]+", "_", fallback.lower()).strip("_")
return s or "section"
def make_item_row(key: str, row: dict, zh: dict[int, str], *, is_recipe: bool = False) -> dict:
iid = row.get("id")
try:
cost = int(row.get("cost") or 0)
except (TypeError, ValueError):
cost = 0
name_loc = zh.get(int(iid)) if iid is not None else None
if not name_loc:
name_loc = "卷轴" if is_recipe else str(row.get("dname") or key)
return {
"key": key,
"id": int(iid) if iid is not None else None,
"dname": str(row.get("dname") or key),
"name_loc": name_loc,
"cost": cost,
"created": bool(row.get("created")),
"qual": row.get("qual"),
"is_recipe": is_recipe,
}
def download_shop_icons(icon_keys: set[str], *, force: bool = False) -> tuple[int, int, int]:
"""Download item icons (recipe handled separately; recipe_* keys skipped)."""
# recipe.png is the shared icon for all recipe_* items.
recipe_dest = ITEM_ICONS / "recipe.png"
recipe_fail = 0
if force or not recipe_dest.is_file():
try:
recipe_dest.write_bytes(http_bytes(ICON_URL.format(key="recipe")))
except (urllib.error.URLError, TimeoutError, ValueError, OSError) as e:
print(f" icon fail recipe: {e}", flush=True)
recipe_fail = 1
real_keys = {k for k in icon_keys if not k.startswith("recipe_")}
saved, skipped, fail = download_icons(
real_keys, ICON_URL, ITEM_ICONS, force=force, delay=0.02
)
return saved, skipped, fail + recipe_fail
def parse_cn_sections(raw_list: list, kind: str) -> list[dict]:
"""Convert CN basic/upgrade arrays into sections; reverse items like the site JS."""
sections: list[dict] = []
for i, row in enumerate(raw_list or []):
if not isinstance(row, dict):
continue
label = str(row.get("name") or "").strip()
if not label:
continue
keys: list[str] = []
for entry in row.get("items") or []:
if isinstance(entry, dict):
name = entry.get("name")
else:
name = entry
if name:
keys.append(str(name))
# Official site reverses each column for display.
keys.reverse()
sid = slug_section(label, f"{kind}_{i}")
icon = CAT_ICON_BY_LABEL.get(label, "")
sections.append(
{
"id": sid,
"label": label,
"icon": icon,
"items": keys,
}
)
return sections
def attach_craft_graph(
items_out: dict[str, dict],
od: dict,
catalog: dict[str, dict],
zh: dict[int, str],
shop_keys: set[str],
) -> set[str]:
builds_into: dict[str, list[str]] = {}
for key, row in od.items():
if not isinstance(row, dict):
continue
key_s = str(key)
if key_s.startswith("recipe_"):
continue
if row.get("tier") is not None:
continue
for raw in row.get("components") or []:
if not raw:
continue
builds_into.setdefault(str(raw), []).append(key_s)
extra_keys: set[str] = set()
for key, row in list(items_out.items()):
od_row = od.get(key) if isinstance(od.get(key), dict) else {}
comps = [str(c) for c in (od_row.get("components") or []) if c]
# OpenDota often omits recipe_* from components; re-attach when present.
has_recipe = any(c.startswith("recipe_") for c in comps)
recipe_key = f"recipe_{key}"
if not has_recipe and bool(od_row.get("created")):
recipe_row = od.get(recipe_key)
if isinstance(recipe_row, dict):
try:
rcost = int(recipe_row.get("cost") or 0)
except (TypeError, ValueError):
rcost = 0
if rcost > 0:
comps.append(recipe_key)
else:
# Fallback: gold gap between total and plain components.
try:
total = int(od_row.get("cost") or 0)
except (TypeError, ValueError):
total = 0
part = 0
ok = True
for c in comps:
crow = od.get(c) if isinstance(od.get(c), dict) else None
if not crow:
ok = False
break
try:
part += int(crow.get("cost") or 0)
except (TypeError, ValueError):
ok = False
break
gap = total - part if ok else 0
if gap > 0:
comps.append(recipe_key)
# Synthetic stub so UI can show cost even without odota recipe row.
if recipe_key not in od:
od[recipe_key] = {
"id": None,
"dname": f"{od_row.get('dname') or key} Recipe",
"cost": gap,
"components": None,
"created": False,
}
row["components"] = comps
ups = [u for u in builds_into.get(key, []) if u in shop_keys]
ups.sort(key=lambda u: (items_out.get(u, {}).get("cost") or 0, u))
row["builds_into"] = ups
extra_keys.update(comps)
for key in sorted(extra_keys):
if key in items_out:
continue
od_row = od.get(key)
if not isinstance(od_row, dict):
continue
if key.startswith("recipe_"):
items_out[key] = make_item_row(key, od_row, zh, is_recipe=True)
elif key in catalog:
stub = dict(catalog[key])
stub.setdefault("components", [])
stub.setdefault("builds_into", [])
items_out[key] = stub
else:
stub = make_item_row(key, od_row, zh)
stub["components"] = []
stub["builds_into"] = [u for u in builds_into.get(key, []) if u in shop_keys]
items_out[key] = stub
return extra_keys
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument("--skip-icons", action="store_true")
ap.add_argument("--force-icons", action="store_true")
args = ap.parse_args()
print("loading CN shop categories + OpenDota + Valve names...", flush=True)
cn = http_json(CN_CATEGORY_URL)
result = (cn or {}).get("result") or {}
basic_sections = parse_cn_sections(result.get("basic") or [], "basic")
up_sections = parse_cn_sections(result.get("upgrade") or [], "upgraded")
if not basic_sections and not up_sections:
raise SystemExit("CN itemscategory/json returned empty basic/upgrade")
od = http_json(ITEMS_URL)
zh = load_itemlist_zh()
catalog: dict[str, dict] = {}
for key, row in od.items():
if not isinstance(row, dict):
continue
key_s = str(key)
if key_s.startswith("recipe_"):
continue
catalog[key_s] = make_item_row(key_s, row, zh)
shop_keys: set[str] = set()
items_out: dict[str, dict] = {}
missing: list[str] = []
cat_files: set[str] = set()
for kind, sections in (("basic", basic_sections), ("upgraded", up_sections)):
for sec in sections:
if sec.get("icon"):
cat_files.add(sec["icon"])
kept: list[str] = []
for key in sec["items"]:
if key not in catalog:
missing.append(key)
continue
kept.append(key)
shop_keys.add(key)
if key not in items_out:
row = dict(catalog[key])
row["shop_kind"] = kind
row["section"] = sec["id"]
row["section_label"] = sec["label"]
items_out[key] = row
sec["items"] = kept
if missing:
print(
f" warn missing in OpenDota ({len(missing)}): "
f"{', '.join(missing[:24])}{'' if len(missing) > 24 else ''}",
flush=True,
)
craft_extra = attach_craft_graph(items_out, od, catalog, zh, shop_keys)
icon_keys = set(shop_keys) | {k for k in craft_extra if not k.startswith("recipe_")}
payload = {
"meta": {
"source": "dota2.com.cn/itemscategory+opendota+valve",
"layout": "cn_shop_columns",
"fetched_at": datetime.now(timezone.utc).isoformat(),
"basic_count": sum(len(s["items"]) for s in basic_sections),
"upgraded_count": sum(len(s["items"]) for s in up_sections),
"craft_refs": len(craft_extra),
},
"basic": {"sections": basic_sections},
"upgraded": {"sections": up_sections},
"items": items_out,
}
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",
)
print(
f"wrote {args.out}: basic={payload['meta']['basic_count']} "
f"upgraded={payload['meta']['upgraded_count']} "
f"cols={len(basic_sections)+len(up_sections)} "
f"craft_refs={len(craft_extra)}",
flush=True,
)
if not args.skip_icons:
print(f"downloading {len(icon_keys)} item icons (+ recipe)...", flush=True)
saved, skipped, fail = download_shop_icons(icon_keys, force=args.force_icons)
print(f" item icons saved={saved} skipped={skipped} fail={fail}", flush=True)
print(f"downloading {len(cat_files)} category icons...", flush=True)
c_saved, c_skipped, c_fail = download_icons(
cat_files, CAT_ICON_BASE + "{key}", ITEM_CAT_ICONS, force=args.force_icons, delay=0.05
)
print(f" cat icons saved={c_saved} skipped={c_skipped} fail={c_fail}", flush=True)
if __name__ == "__main__":
main()
+480
View File
@@ -0,0 +1,480 @@
"""Fetch shop item descriptions and mechanism tags into data/items_meta.json.
Sources:
- OpenDota items.json (structure, EN ability text)
- Valve itemlist / itemdata (schinese names + descriptions)
- data/item_tag_overrides.json (manual add/remove)
Usage:
python fetch_items_meta.py
python fetch_items_meta.py --force
python fetch_items_meta.py --reformat-desc --skip-icons
python fetch_items_meta.py --skip-icons --delay 0.2
"""
from __future__ import annotations
import argparse
import json
import re
import time
import urllib.error
from datetime import datetime, timezone
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
ITEMS_URL = (
"https://raw.githubusercontent.com/odota/dotaconstants/master/build/items.json"
)
ITEMDATA_URL = (
"https://www.dota2.com/datafeed/itemdata?language={lang}&item_id={item_id}"
)
ICON_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/items/{key}.png"
)
OUT = DATA / "items_meta.json"
OVERRIDES = DATA / "item_tag_overrides.json"
MIN_CREATED_COST = 1400
ALWAYS_CORE = frozenset({"blink", "aghanims_shard", "gem", "dust", "ghost"})
TAG_ORDER = [
"basic_dispel",
"strong_dispel",
"mana_burn",
"break",
"true_strike",
"illusion_clear",
"magic_immune",
"spell_block",
"spell_reflect",
"invis_detect",
"invis_break",
"heal_reduce",
"armor_reduce",
"silence",
"hex",
"root",
]
# key -> extra tags from curated whitelist (also covered by overrides)
ILLUSION_CLEAR_KEYS = frozenset({
"bfury",
"radiance",
"maelstrom",
"mjollnir",
"gungir",
})
H1_RE = re.compile(
r"<h1>\s*(主动|被动|使用|开关|升级|Active|Passive|Use|Toggle|Upgrade)\s*[:]?\s*([^<]*)</h1>",
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:
return False
if meta.get("qual") == "consumable" and key not in ALWAYS_CORE:
return False
cost = int(meta.get("cost") or 0)
if cost <= 0 and key not in ALWAYS_CORE:
return False
if key in ALWAYS_CORE:
return True
if not meta.get("created"):
return False
return cost >= MIN_CREATED_COST
def load_odota_catalog() -> dict[int, dict]:
raw = http_json(ITEMS_URL)
zh_index = load_itemlist()
out: dict[int, dict] = {}
for key, row in raw.items():
if not isinstance(row, dict):
continue
key_s = str(key)
if key_s.startswith("recipe_"):
continue
iid = row.get("id")
if iid is None:
continue
try:
cost = int(row.get("cost") or 0)
except (TypeError, ValueError):
cost = 0
zh = zh_index.get(int(iid)) or {}
abilities = row.get("abilities") or []
if not isinstance(abilities, list):
abilities = []
en_bits = []
for ab in abilities:
if isinstance(ab, dict):
en_bits.append(str(ab.get("description") or ""))
en_bits.append(str(ab.get("title") or ""))
out[int(iid)] = {
"key": key_s,
"dname": str(row.get("dname") or key_s),
"name_loc": zh.get("name_loc") or str(row.get("dname") or key_s),
"created": bool(row.get("created")),
"cost": cost,
"tier": row.get("tier"),
"qual": row.get("qual"),
"od_abilities": abilities,
"en_text": " ".join(en_bits),
"od_dispellable": row.get("dispellable"),
}
return out
def fetch_itemdata(item_id: int, lang: str = "schinese") -> dict | None:
try:
raw = http_json(ITEMDATA_URL.format(lang=lang, item_id=item_id))
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError):
return None
items = (((raw or {}).get("result") or {}).get("data") or {}).get("items") or []
if not items or not isinstance(items[0], dict):
return None
return items[0]
def parse_ability_kinds(desc_loc: str, od_abilities: list) -> list[str]:
kinds: set[str] = set()
for ab in od_abilities or []:
if not isinstance(ab, dict):
continue
t = str(ab.get("type") or "").lower()
if t in ("active", "passive", "use", "toggle", "upgrade"):
kinds.add(t)
for m in H1_RE.finditer(desc_loc or ""):
label = m.group(1).lower()
mapping = {
"主动": "active",
"被动": "passive",
"使用": "use",
"开关": "toggle",
"升级": "upgrade",
"active": "active",
"passive": "passive",
"use": "use",
"toggle": "toggle",
"upgrade": "upgrade",
}
if label in mapping:
kinds.add(mapping[label])
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)
if od_dispellable and str(od_dispellable).lower() in ("yes", "both"):
# Item itself being dispellable is not "applies dispel"
pass
if re.search(
r"mana burn|burn(?:s|ed)?\s+\d*\s*mana|燃烧.{0,6}魔法|破法|mana.?burn|反馈",
blob,
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,
re.I,
):
tags.add("true_strike")
if key in ILLUSION_CLEAR_KEYS or re.search(
r"(?:对|伤害).{0,8}幻象|bonus damage to illusions|cleave|分裂攻击|闪电链|chain lightning",
blob,
re.I,
):
tags.add("illusion_clear")
if key == "black_king_bar" or re.search(
r"magic immunity|spell immunity|魔法免疫",
blob,
re.I,
):
tags.add("magic_immune")
if key == "sphere" or re.search(r"spell block|法术格挡", blob, re.I):
tags.add("spell_block")
if key == "lotus_orb" or re.search(
r"re-?casts? most targeted|echo shell|反射|回到施法者|回施",
blob,
re.I,
):
tags.add("spell_reflect")
if key in ("gem", "dust") or re.search(r"true sight|真实视域", blob, re.I):
tags.add("invis_detect")
if key in ("silver_edge", "invis_sword") or re.search(
r"破隐|break(?:s)? invis",
blob,
re.I,
):
tags.add("invis_break")
if key == "spirit_vessel" or re.search(
r"heal(?:ing)? reduction|减少治疗|治疗降低|回复降低",
blob,
re.I,
):
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]
def load_overrides() -> dict[str, dict]:
if not OVERRIDES.is_file():
return {}
try:
raw = json.loads(OVERRIDES.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
items = raw.get("items") or {}
return {str(k): v for k, v in items.items() if isinstance(v, 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
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--delay", type=float, default=0.15)
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument("--skip-icons", action="store_true")
ap.add_argument("--force-icons", action="store_true")
ap.add_argument(
"--force",
action="store_true",
help="Refetch itemdata even if cached in existing out file",
)
ap.add_argument(
"--reformat-desc",
action="store_true",
help="Refetch special_values and fill %%token%% in cached descriptions",
)
args = ap.parse_args()
print("loading OpenDota items + Valve itemlist...", flush=True)
catalog = load_odota_catalog()
candidates = {
iid: meta
for iid, meta in catalog.items()
if is_core_finished(meta) or meta["key"] in ALWAYS_CORE
}
print(f" {len(candidates)} finished shop items", flush=True)
cached: dict[str, dict] = {}
if args.out.is_file() and not args.force:
try:
prev = json.loads(args.out.read_text(encoding="utf-8"))
for k, row in (prev.get("items") or {}).items():
if isinstance(row, dict) and row.get("desc_loc") is not None:
cached[str(k)] = row
print(f"resuming with {len(cached)} cached itemdata rows", flush=True)
except (OSError, json.JSONDecodeError):
pass
overrides = load_overrides()
items_out: dict[str, dict] = {}
pending = []
for iid in sorted(candidates):
sid = str(iid)
if args.force or sid not in cached:
pending.append(iid)
continue
if args.reformat_desc:
prev = cached[sid]
if HAS_PLACEHOLDER.search(prev.get("desc_loc") or "") or HAS_PLACEHOLDER.search(
prev.get("desc_en") or ""
):
pending.append(iid)
print(f"fetching/reformatting itemdata for {len(pending)} items...", flush=True)
for n, iid in enumerate(sorted(candidates), start=1):
meta = candidates[iid]
key = meta["key"]
sid = str(iid)
need_fetch = args.force or sid not in cached
if not need_fetch and args.reformat_desc and sid in cached:
prev = cached[sid]
if HAS_PLACEHOLDER.search(prev.get("desc_loc") or "") or HAS_PLACEHOLDER.search(
prev.get("desc_en") or ""
):
need_fetch = True
if sid in cached and not need_fetch:
row = dict(cached[sid])
# refresh tags from stored text + overrides
tags = auto_tags(
key,
row.get("desc_loc") or "",
row.get("desc_en") or "",
meta.get("en_text") or "",
meta.get("od_dispellable"),
)
row["tags"] = apply_overrides(key, tags, overrides)
row["ability_kinds"] = parse_ability_kinds(
row.get("desc_loc") or "", meta.get("od_abilities") or []
)
row["name_loc"] = meta["name_loc"] or row.get("name_loc") or key
row["dname"] = meta["dname"]
row["key"] = key
row["cost"] = meta["cost"]
row["desc_loc"] = format_loc(row.get("desc_loc") or "", [])
row["desc_en"] = format_loc(row.get("desc_en") or "", [])
items_out[sid] = row
continue
zh = fetch_itemdata(iid, "schinese")
time.sleep(args.delay)
en = fetch_itemdata(iid, "english")
time.sleep(args.delay)
sv = (zh or {}).get("special_values") or []
if not isinstance(sv, list):
sv = []
desc_zh_raw = (zh or {}).get("desc_loc") or ""
desc_en_raw = (en or {}).get("desc_loc") or ""
desc_zh = format_loc(desc_zh_raw, sv)
desc_en = format_loc(desc_en_raw, sv)
notes = (zh or {}).get("notes_loc") or []
if not isinstance(notes, list):
notes = []
tags = auto_tags(
key,
desc_zh_raw,
desc_en_raw,
meta.get("en_text") or "",
meta.get("od_dispellable"),
)
tags = apply_overrides(key, tags, overrides)
items_out[sid] = {
"id": iid,
"key": key,
"dname": meta["dname"],
"name_loc": meta["name_loc"] or ((zh or {}).get("name_loc") or key),
"cost": meta["cost"],
"desc_loc": desc_zh,
"desc_en": desc_en,
"notes_loc": notes,
"dispellable": (zh or {}).get("dispellable"),
"immunity": (zh or {}).get("immunity"),
"ability_kinds": parse_ability_kinds(
desc_zh_raw, meta.get("od_abilities") or []
),
"tags": tags,
}
print(
f" [{n}/{len(candidates)}] {key}: tags={tags or '-'}",
flush=True,
)
# Incremental save
args.out.parent.mkdir(parents=True, exist_ok=True)
payload = {
"meta": {
"source": "valve+opendota",
"attribution": "https://www.dota2.com ; https://www.opendota.com",
"fetched_at": datetime.now(timezone.utc).isoformat(),
"tag_order": TAG_ORDER,
},
"items": items_out,
}
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
payload = {
"meta": {
"source": "valve+opendota",
"attribution": "https://www.dota2.com ; https://www.opendota.com",
"fetched_at": datetime.now(timezone.utc).isoformat(),
"tag_order": TAG_ORDER,
"count": len(items_out),
},
"items": items_out,
}
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",
)
if not args.skip_icons:
keys = {row["key"] for row in items_out.values()}
print(f"downloading {len(keys)} icons -> {ITEM_ICONS}", flush=True)
saved, skipped, fail = download_icons(keys, ICON_URL, ITEM_ICONS, force=args.force_icons)
print(f" icons saved={saved} skipped={skipped} fail={fail}", flush=True)
tagged = sum(1 for r in items_out.values() if r.get("tags"))
print(f"done: {len(items_out)} items, {tagged} with tags -> {args.out}", flush=True)
if __name__ == "__main__":
main()
+82
View File
@@ -0,0 +1,82 @@
"""Fetch OpenDota hero matchups into data/matchups.json (for audit_relations).
Usage:
python fetch_matchups.py
python fetch_matchups.py --delay 1.0
"""
from __future__ import annotations
import argparse
import json
import time
import urllib.error
from datetime import datetime, timezone
from pathlib import Path
from common import DATA
from grid import hero_table
from http_utils import http_json
API = "https://api.opendota.com/api/heroes/{hero_id}/matchups"
OUT = DATA / "matchups.json"
def fetch_one(hero_id: int, timeout: float = 30.0) -> list[dict]:
return http_json(API.format(hero_id=hero_id), timeout=int(timeout))
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--delay", type=float, default=1.0, help="seconds between requests")
ap.add_argument("--out", type=Path, default=OUT)
args = ap.parse_args()
table = hero_table()
ids = sorted({int(h["id"]) for h in table})
by_hero: dict[str, dict] = {}
if args.out.is_file():
try:
prev = json.loads(args.out.read_text(encoding="utf-8"))
by_hero = dict(prev.get("by_hero") or {})
print(f"resuming with {len(by_hero)} heroes already cached", flush=True)
except (OSError, json.JSONDecodeError):
pass
pending = [i for i in ids if str(i) not in by_hero]
print(f"fetching {len(pending)} / {len(ids)} heroes -> {args.out}", flush=True)
for n, hid in enumerate(pending, start=1):
try:
rows = fetch_one(hid)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as e:
print(f" [{n}/{len(pending)}] hero {hid} failed: {e}", flush=True)
time.sleep(args.delay * 2)
continue
cell: dict[str, dict] = {}
for r in rows:
opp = r.get("hero_id")
games = int(r.get("games_played") or 0)
wins = int(r.get("wins") or 0)
if opp is None or games <= 0:
continue
cell[str(int(opp))] = {"games": games, "wins": wins}
by_hero[str(hid)] = cell
print(f" [{n}/{len(pending)}] hero {hid}: {len(cell)} matchups", flush=True)
args.out.parent.mkdir(parents=True, exist_ok=True)
payload = {
"fetched_at": datetime.now(timezone.utc).isoformat(),
"source": "opendota",
"attribution": "https://opendota.com",
"by_hero": by_hero,
}
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
time.sleep(args.delay)
print(f"done: {len(by_hero)} heroes in {args.out}", flush=True)
if __name__ == "__main__":
main()
+359
View File
@@ -0,0 +1,359 @@
"""Fetch Dota 2 patch list + per-patch details (past year) into data/patches.json.
Sources:
- patchnoteslist (schinese): version / name / timestamp / website
- patchnotes?version={v}&language=schinese: general_notes / items / neutral_items / heroes
- itemlist (schinese): item + neutral ability_id -> {name_loc, icon key}
- odota ability_ids.json: ability_id -> ability key (e.g. 5004 -> antimage_blink)
- data/hero_abilities.json: ability key -> Chinese name_loc (fallback: odota abilities.json dname)
- herolist (schinese): hero_id -> {name_loc, portrait key}
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.
Referenced item / ability icons are downloaded into assets/item_icons and
assets/ability_icons so the static export stays self-contained.
Usage:
python fetch_patches.py
python fetch_patches.py --days 365 --delay 0.3
python fetch_patches.py --since 2025-07-27
python fetch_patches.py --no-icons
python fetch_patches.py --force # refetch every patch detail
"""
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 ABILITY_ICONS, DATA, ITEM_ICONS
from http_utils import http_json
PATCHES_LIST_URL = "https://www.dota2.com/datafeed/patchnoteslist?language=schinese"
PATCH_NOTES_URL = "https://www.dota2.com/datafeed/patchnotes?version={version}&language=schinese"
ITEMLIST_URL = "https://www.dota2.com/datafeed/itemlist?language=schinese"
HEROLIST_URL = "https://www.dota2.com/datafeed/herolist?language=schinese"
ABILITY_IDS_URL = "https://raw.githubusercontent.com/odota/dotaconstants/master/build/ability_ids.json"
ABILITIES_URL = "https://raw.githubusercontent.com/odota/dotaconstants/master/build/abilities.json"
ITEM_ICON_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/items/{key}.png"
ABILITY_ICON_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/abilities/{key}.png"
OUT = DATA / "patches.json"
HERO_ABILITIES_PATH = DATA / "hero_abilities.json"
DEFAULT_WINDOW_DAYS = 365
# Bundled shared badges — never fetched from CDN (many innate keys 404).
SKIP_ABILITY_ICON_KEYS = frozenset({"innate", "talent_tree"})
def _date(ts: int) -> str:
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%d") if ts else ""
def fetch_patch_list(since_ts: int) -> list[dict]:
"""Patch list filtered to timestamp >= since_ts, newest first."""
raw = http_json(PATCHES_LIST_URL)
patches = raw.get("patches") or []
out: list[dict] = []
for p in patches:
if not isinstance(p, dict) or not p.get("patch_number"):
continue
ts = int(p.get("patch_timestamp") or 0)
if ts and ts < since_ts:
continue
row = {
"version": p["patch_number"],
"name": p.get("patch_name") or p["patch_number"],
"timestamp": ts,
"date": _date(ts),
}
if p.get("patch_website"):
row["website"] = p["patch_website"]
out.append(row)
out.sort(key=lambda r: r["timestamp"], reverse=True)
return out
def fetch_patch_detail(version: str) -> dict | None:
"""Raw patch notes content for one version (general/items/neutral/heroes)."""
try:
raw = http_json(PATCH_NOTES_URL.format(version=version))
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError) as e:
print(f" detail {version}: {e}", flush=True)
return None
if not raw or not raw.get("success", True):
print(f" detail {version}: datafeed reported failure", flush=True)
return None
ts = int(raw.get("patch_timestamp") or 0)
return {
"version": raw.get("patch_number") or version,
"name": raw.get("patch_name") or version,
"timestamp": ts,
"general_notes": list(raw.get("general_notes") or []),
"items": list(raw.get("items") or []),
"neutral_items": list(raw.get("neutral_items") or []),
"heroes": list(raw.get("heroes") or []),
}
def load_item_index() -> dict[int, dict]:
"""itemlist id -> {key, name_loc}; key = internal name minus 'item_' prefix."""
raw = http_json(ITEMLIST_URL)
rows = (((raw or {}).get("result") or {}).get("data") or {}).get("itemabilities") or []
out: dict[int, dict] = {}
for row in rows:
if not isinstance(row, dict) or row.get("id") is None:
continue
name = str(row.get("name") or "")
key = name.removeprefix("item_")
out[int(row["id"])] = {
"key": key,
"name_loc": (row.get("name_loc") or "").strip() or key,
}
return out
def load_ability_index() -> dict[int, str]:
"""ability_id -> ability key (e.g. 5004 -> antimage_blink)."""
raw = http_json(ABILITY_IDS_URL)
out: dict[int, str] = {}
for sid, key in raw.items():
if isinstance(key, str) and key:
try:
out[int(sid)] = key
except (TypeError, ValueError):
continue
return out
def load_ability_names() -> dict[str, str]:
"""ability key -> Chinese name_loc (hero_abilities.json first, odota dname fallback)."""
names: dict[str, str] = {}
if HERO_ABILITIES_PATH.is_file():
try:
raw = json.loads(HERO_ABILITIES_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
raw = {}
for cell in (raw.get("by_hero") or {}).values():
if not isinstance(cell, dict):
continue
for ab in cell.get("abilities") or []:
if isinstance(ab, dict) and ab.get("key") and ab.get("name_loc"):
names.setdefault(str(ab["key"]), str(ab["name_loc"]))
for tal in cell.get("talents") or []:
if isinstance(tal, dict) and tal.get("key") and tal.get("name_loc"):
names.setdefault(str(tal["key"]), str(tal["name_loc"]))
# Fallback: odota abilities.json English dname for keys missing a Chinese name.
try:
ab = http_json(ABILITIES_URL)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
ab = {}
for key, row in ab.items():
if isinstance(row, dict) and row.get("dname") and key not in names:
names[key] = str(row["dname"])
return names
def load_hero_index() -> dict[int, dict]:
"""herolist id -> {key, name_loc}; key = name minus 'npc_dota_hero_' prefix."""
raw = http_json(HEROLIST_URL)
heroes = (((raw or {}).get("result") or {}).get("data") or {}).get("heroes") or []
out: dict[int, dict] = {}
for h in heroes:
if not isinstance(h, dict) or h.get("id") is None:
continue
name = str(h.get("name") or "")
out[int(h["id"])] = {
"key": name.removeprefix("npc_dota_hero_"),
"name_loc": (h.get("name_loc") or "").strip() or name,
}
return out
def collect_referenced_ids(details: dict[str, dict]) -> tuple[set[int], set[int], set[int]]:
"""(item_ids, ability_ids, hero_ids) actually referenced across all details."""
item_ids: set[int] = set()
ability_ids: set[int] = set()
hero_ids: set[int] = set()
for det in details.values():
for entry in det.get("items") or []:
if isinstance(entry, dict):
aid = entry.get("ability_id")
if isinstance(aid, int) and aid > 0:
item_ids.add(aid)
for entry in det.get("neutral_items") or []:
if isinstance(entry, dict):
aid = entry.get("ability_id")
if isinstance(aid, int) and aid > 0:
item_ids.add(aid)
for hero in det.get("heroes") or []:
if not isinstance(hero, dict):
continue
hid = hero.get("hero_id")
if isinstance(hid, int):
hero_ids.add(hid)
for ab in hero.get("abilities") or []:
if isinstance(ab, dict):
aid = ab.get("ability_id")
if isinstance(aid, int) and aid > 0:
ability_ids.add(aid)
return item_ids, ability_ids, hero_ids
def build_lookup(
details: dict[str, dict],
item_index: dict[int, dict],
ability_index: dict[int, str],
ability_names: dict[str, str],
hero_index: dict[int, dict],
) -> dict:
"""Resolve only referenced ids to {key, name_loc}."""
item_ids, ability_ids, hero_ids = collect_referenced_ids(details)
items: dict[str, dict] = {}
for iid in sorted(item_ids):
meta = item_index.get(iid)
if meta:
items[str(iid)] = {"key": meta["key"], "name_loc": meta["name_loc"]}
abilities: dict[str, dict] = {}
for aid in sorted(ability_ids):
key = ability_index.get(aid)
if not key:
continue
abilities[str(aid)] = {"key": key, "name_loc": ability_names.get(key, key)}
heroes: dict[str, dict] = {}
for hid in sorted(hero_ids):
meta = hero_index.get(hid)
if meta:
heroes[str(hid)] = {"key": meta["key"], "name_loc": meta["name_loc"]}
return {"items": items, "abilities": abilities, "heroes": heroes}
def download_referenced_icons(lookup: dict, *, delay: float) -> None:
"""Pull referenced item + ability icons into assets/ (skip existing)."""
from http_utils import download_icons
item_keys = {v["key"] for v in (lookup.get("items") or {}).values() if v.get("key")}
# Recipe scrolls share one generic icon on Steam CDN.
recipe_keys = {k for k in item_keys if k.startswith("recipe_")}
item_keys -= recipe_keys
item_keys.add("recipe")
print(f"downloading {len(item_keys)} item icons -> {ITEM_ICONS}", flush=True)
saved, skipped, fail = download_icons(item_keys, ITEM_ICON_URL, ITEM_ICONS, delay=delay)
print(f" item icons saved={saved} skipped={skipped} fail={fail}", flush=True)
ability_keys = {
v["key"]
for v in (lookup.get("abilities") or {}).values()
if v.get("key") and v["key"] not in SKIP_ABILITY_ICON_KEYS
}
print(f"downloading {len(ability_keys)} ability icons -> {ABILITY_ICONS}", flush=True)
saved, skipped, fail = download_icons(
ability_keys, ABILITY_ICON_URL, ABILITY_ICONS, delay=delay, skip_keys=SKIP_ABILITY_ICON_KEYS
)
print(f" ability icons saved={saved} skipped={skipped} fail={fail}", flush=True)
def load_existing_details() -> dict[str, dict]:
if not OUT.is_file():
return {}
try:
raw = json.loads(OUT.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
out: dict[str, dict] = {}
for v, det in (raw.get("details") or {}).items():
if isinstance(det, dict):
out[str(v)] = det
return out
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=DEFAULT_WINDOW_DAYS, help="window in days from today")
ap.add_argument("--since", type=str, default=None, help="override cutoff (YYYY-MM-DD, UTC)")
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")
args = ap.parse_args()
if args.since:
since_dt = datetime.strptime(args.since, "%Y-%m-%d").replace(tzinfo=timezone.utc)
else:
since_dt = datetime.now(tz=timezone.utc) - timedelta(days=args.days)
since_ts = int(since_dt.timestamp())
print(f"fetching patch list (since {since_dt.date()})...", flush=True)
patches = fetch_patch_list(since_ts)
print(f" {len(patches)} patches in window", flush=True)
if not patches:
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(
json.dumps({"meta": {}, "patches": [], "lookup": {}, "details": {}}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print("no patches in window; wrote empty file", flush=True)
return
cached = {} if args.force else load_existing_details()
details: dict[str, dict] = {}
print("fetching per-patch details...", flush=True)
for n, p in enumerate(patches, start=1):
version = p["version"]
det = cached.get(version)
if det is None:
det = fetch_patch_detail(version)
time.sleep(max(args.delay, 0.05))
if det is None:
print(f" [{n}/{len(patches)}] {version}: skipped (no detail)", flush=True)
continue
details[version] = det
print(f" [{n}/{len(patches)}] {version}: {len(det.get('heroes') or [])} heroes", flush=True)
print("building lookup (itemlist + ability_ids + hero_abilities + herolist)...", flush=True)
item_index = load_item_index()
ability_index = load_ability_index()
ability_names = load_ability_names()
hero_index = load_hero_index()
lookup = build_lookup(details, item_index, ability_index, ability_names, hero_index)
print(
f" lookup: items={len(lookup['items'])} abilities={len(lookup['abilities'])} heroes={len(lookup['heroes'])}",
flush=True,
)
if not args.no_icons:
download_referenced_icons(lookup, delay=args.delay)
payload = {
"meta": {
"source": PATCHES_LIST_URL,
"fetched_at": datetime.now(tz=timezone.utc).isoformat(),
"window_days": args.days,
"since": since_dt.date().isoformat(),
"count": len(patches),
"details_count": len(details),
"lookup_counts": {
"items": len(lookup["items"]),
"abilities": len(lookup["abilities"]),
"heroes": len(lookup["heroes"]),
},
},
"patches": patches,
"lookup": lookup,
"details": details,
}
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(
f"saved {len(patches)} patches, {len(details)} details -> {OUT}",
flush=True,
)
if __name__ == "__main__":
main()
+182
View File
@@ -0,0 +1,182 @@
"""Fetch STRATZ hero-vs-hero matchups or teammate synergies.
Requires STRATZ_API_TOKEN in the environment or a local .env file
(gitignored). Attribution: data from https://stratz.com
Usage:
python fetch_stratz.py --mode matchups # -> data/stratz_matchups.json
python fetch_stratz.py --mode synergies # -> data/synergies.json
python fetch_stratz.py --mode matchups --delay 0.45
"""
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"
# GraphQL edge field ("vs" for matchups, "with" for synergies) and the
# score field name used in the output JSON.
MODE_CONFIG = {
"matchups": {
"edge_field": "vs",
"score_field": "advantage",
"label": "matchups",
"default_out": DATA / "stratz_matchups.json",
},
"synergies": {
"edge_field": "with",
"score_field": "synergy",
"label": "synergies",
"default_out": DATA / "synergies.json",
},
}
def _build_query(edge_field: str) -> str:
return f"""
query($id: Short!) {{
heroStats {{
heroVsHeroMatchup(heroId: $id) {{
advantage {{
heroId
{edge_field} {{
heroId2
matchCount
winCount
synergy
winsAverage
}}
}}
}}
}}
}}
"""
def load_token() -> str:
env = os.environ.get("STRATZ_API_TOKEN", "").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 or add it to .env (see .env.example)."
)
def gql(token: str, query: str, variables: dict | None = None) -> dict:
body = {"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=60) as resp:
payload = json.loads(resp.read().decode())
if payload.get("errors"):
raise RuntimeError(str(payload["errors"][:2]))
return payload["data"]
def fetch_one(
token: str, hero_id: int, *, edge_field: str, score_field: str
) -> dict[str, dict]:
query = _build_query(edge_field)
data = gql(token, query, {"id": hero_id})
block = (((data or {}).get("heroStats") or {}).get("heroVsHeroMatchup") or {})
rows = block.get("advantage") or []
cell: dict[str, dict] = {}
for row in rows:
for pair in row.get(edge_field) or []:
other = pair.get("heroId2")
games = int(pair.get("matchCount") or 0)
wins = int(pair.get("winCount") or 0)
if other is None or games <= 0:
continue
cell[str(int(other))] = {
"games": games,
"wins": wins,
score_field: float(pair.get("synergy") or 0.0),
"wr": float(pair.get("winsAverage") or (wins / games)),
}
return cell
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--mode", choices=list(MODE_CONFIG), required=True)
ap.add_argument("--delay", type=float, default=0.45)
ap.add_argument("--out", type=Path, default=None)
args = ap.parse_args()
mc = MODE_CONFIG[args.mode]
out_path = args.out or mc["default_out"]
edge_field = mc["edge_field"]
score_field = mc["score_field"]
label = mc["label"]
token = load_token()
ids = sorted({int(h["id"]) for h in hero_table()})
by_hero: dict[str, dict] = {}
if out_path.is_file():
try:
prev = json.loads(out_path.read_text(encoding="utf-8"))
by_hero = dict(prev.get("by_hero") or {})
print(f"resuming with {len(by_hero)} heroes already cached", flush=True)
except (OSError, json.JSONDecodeError):
pass
pending = [i for i in ids if str(i) not in by_hero]
print(f"fetching {len(pending)} / {len(ids)} heroes -> {out_path}", flush=True)
for n, hid in enumerate(pending, start=1):
try:
cell = fetch_one(token, hid, edge_field=edge_field, score_field=score_field)
except (urllib.error.URLError, TimeoutError, RuntimeError, json.JSONDecodeError) as e:
print(f" [{n}/{len(pending)}] hero {hid} failed: {e}", flush=True)
time.sleep(args.delay * 2)
continue
by_hero[str(hid)] = cell
print(f" [{n}/{len(pending)}] hero {hid}: {len(cell)} {label}", flush=True)
out_path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"fetched_at": datetime.now(timezone.utc).isoformat(),
"source": "stratz",
"attribution": "https://stratz.com",
"by_hero": by_hero,
}
out_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
time.sleep(args.delay)
print(f"done: {len(by_hero)} heroes in {out_path}", flush=True)
if __name__ == "__main__":
main()
+13 -7
View File
@@ -22,15 +22,15 @@ import json
import cv2 import cv2
import numpy as np import numpy as np
from common import ROOT from common import HEROES_JSON
ATTR_ORDER = ("str", "agi", "int", "all") ATTR_ORDER = ("str", "agi", "int", "all")
def hero_table() -> list[dict]: def hero_table() -> list[dict]:
table = json.loads((ROOT / "heroes.json").read_text(encoding="utf-8")) table = json.loads(HEROES_JSON.read_text(encoding="utf-8"))
if table and "attr" not in table[0]: if table and "attr" not in table[0]:
raise SystemExit("heroes.json predates grid support - rerun fetch_cdn_templates.py") raise SystemExit(f"{HEROES_JSON.name} predates grid support - rerun fetch_cdn_templates.py")
return table return table
@@ -151,24 +151,30 @@ def read_grid(img: np.ndarray, cfg: dict | None = None) -> dict:
table = hero_table() table = hero_table()
grid = detect_grid(img, cfg) grid = detect_grid(img, cfg)
if grid is None: if grid is None:
return {"ok": False, "reason": "no grid detected", "unavailable": []} return {"ok": False, "reason": "no grid detected", "unavailable": [], "cells": {}}
layout = build_layout(grid, table) layout = build_layout(grid, table)
if layout is None: if layout is None:
return {"ok": False, "reason": "grid shape does not fit the roster", "unavailable": []} return {"ok": False, "reason": "grid shape does not fit the roster", "unavailable": [], "cells": {}}
if len(layout) != len(table): if len(layout) != len(table):
return {"ok": False, return {"ok": False,
"reason": f"placed {len(layout)} of {len(table)} heroes", "reason": f"placed {len(layout)} of {len(table)} heroes",
"unavailable": []} "unavailable": [], "cells": {}}
cut = cfg.get("grid", {}).get("unavailable_std", 26.0) cut = cfg.get("grid", {}).get("unavailable_std", 26.0)
scored = {key: cell_contrast(img, grid, r, c) for (r, c), key in layout.items()} scored = {key: cell_contrast(img, grid, r, c) for (r, c), key in layout.items()}
unavailable = sorted((k for k, s in scored.items() if s < cut), key=lambda k: scored[k]) unavailable = sorted((k for k, s in scored.items() if s < cut), key=lambda k: scored[k])
live = [s for s in scored.values() if s >= cut] live = [s for s in scored.values() if s >= cut]
cols, rows = grid["cols"], grid["rows"]
cells = {
key: {"x0": cols[c][0], "y0": rows[r][0], "x1": cols[c][1], "y1": rows[r][1]}
for (r, c), key in layout.items()
}
return { return {
"ok": True, "ok": True,
"unavailable": unavailable, "unavailable": unavailable,
"grid": {"cols": len(grid["cols"]), "rows": len(grid["rows"])}, "cells": cells,
"grid": {"cols": len(cols), "rows": len(rows)},
"margin": round(min(live) - max((scored[k] for k in unavailable), default=0.0), 1) if live and unavailable else None, "margin": round(min(live) - max((scored[k] for k in unavailable), default=0.0), 1) if live and unavailable else None,
} }
+21 -4
View File
@@ -33,6 +33,7 @@ import mss
from capture import append_gsi_payload, grab_frame, is_dota_foreground, raw_dir_for_match, save_frame from capture import append_gsi_payload, grab_frame, is_dota_foreground, raw_dir_for_match, save_frame
from common import ROOT, load_config, load_template_library from common import ROOT, load_config, load_template_library
from draft_session import HERO_SELECTION, DraftSession, describe, gsi_slot, loc from draft_session import HERO_SELECTION, DraftSession, describe, gsi_slot, loc
from overlay import DraftOverlay
from recognize import recognize_image from recognize import recognize_image
from roles import detect_roles from roles import detect_roles
@@ -68,6 +69,15 @@ class Watcher:
self.dump_lock = threading.Lock() self.dump_lock = threading.Lock()
self.self_info: dict = {} self.self_info: dict = {}
self._dump_announced: set[str] = set() self._dump_announced: set[str] = set()
# One overlay for the whole process - Tk does not like create/destroy per match.
self.overlay = None
if calibrated and bool((cfg.get("overlay") or {}).get("enabled", True)):
try:
self.overlay = DraftOverlay(cfg)
print("[draft] role-tag overlay ready", flush=True)
except Exception as e: # noqa: BLE001
print(f"[draft] overlay disabled: {e}", flush=True)
self.overlay = None
def on_payload(self, payload: dict) -> None: def on_payload(self, payload: dict) -> None:
if not self.connected: if not self.connected:
@@ -82,6 +92,8 @@ class Watcher:
"team": p.get("team_name"), "team": p.get("team_name"),
"team_slot": p.get("team_slot"), "team_slot": p.get("team_slot"),
"hero": (h.get("name") or "").replace("npc_dota_hero_", "") or None, "hero": (h.get("name") or "").replace("npc_dota_hero_", "") or None,
"accountid": p.get("accountid"),
"steamid": p.get("steamid"),
} }
state = (payload.get("map") or {}).get("game_state") state = (payload.get("map") or {}).get("game_state")
@@ -142,9 +154,9 @@ class Watcher:
late = " (joined late)" if state != HERO_SELECTION else "" late = " (joined late)" if state != HERO_SELECTION else ""
print(f"[draft] tracking match {match_id} from {state}{late}", flush=True) print(f"[draft] tracking match {match_id} from {state}{late}", flush=True)
session = DraftSession(self.cfg, self.library) session = DraftSession(self.cfg, self.library, overlay=self.overlay)
summary = session.run(match_id, lambda: self.last_state, lambda: self.self_info) summary = session.run(match_id, lambda: self.last_state, lambda: self.self_info)
if summary["recognized"] == 0: if not summary or summary["recognized"] == 0:
print("[draft] nothing recognized - no result written", flush=True) print("[draft] nothing recognized - no result written", flush=True)
return None return None
self.report_session(summary) self.report_session(summary)
@@ -207,11 +219,14 @@ class Watcher:
print(f"[run] {result['recognized']}/10 slots, {result['elapsed_ms']}ms", flush=True) print(f"[run] {result['recognized']}/10 slots, {result['elapsed_ms']}ms", flush=True)
print(f"radiant: {', '.join(loc(r['hero']) for r in result['radiant'])}", flush=True) print(f"radiant: {', '.join(loc(r['hero']) for r in result['radiant'])}", flush=True)
print(f"dire : {', '.join(loc(r['hero']) for r in result['dire'])}", flush=True) print(f"dire : {', '.join(loc(r['hero']) for r in result['dire'])}", flush=True)
slot = gsi_slot(self.self_info) or found["self_slot"] slot = gsi_slot(self.self_info)
team = self.self_info.get("team") or found.get("self_team")
if slot: if slot:
role = found["roles"].get(slot) role = found["roles"].get(slot)
pos = f"position {role['position']} ({role['label']})" if role else "position unknown" pos = f"position {role['position']} ({role['label']})" if role else "position unknown"
print(f"you : slot {slot} {found['self_team']} - {pos}", flush=True) print(f"you : slot {slot} {team} - {pos}", flush=True)
elif not slot:
print("you : slot unknown (waiting for GSI team_slot)", flush=True)
result["roles"] = found result["roles"] = found
return result return result
@@ -306,6 +321,8 @@ def main() -> None:
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nstopped.") print("\nstopped.")
finally: finally:
if watcher.overlay is not None:
watcher.overlay.close()
server.server_close() server.server_close()
+52
View File
@@ -0,0 +1,52 @@
"""Chinese draft tags (定位) for relation preview filter/display.
Mapped from OpenDota English roles + a curated illusion set.
Not used by recommend scoring.
"""
from __future__ import annotations
# Display / filter order on the preview page.
TAG_ORDER = [
"核心",
"辅助",
"推进",
"逃生",
"爆发",
"先手",
"控制",
"耐久",
"幻象",
]
ROLE_TO_ZH = {
"Carry": "核心",
"Support": "辅助",
"Pusher": "推进",
"Escape": "逃生",
"Nuker": "爆发",
"Initiator": "先手",
"Disabler": "控制",
"Durable": "耐久",
}
# Heroes commonly called 幻象系 in CN pubs.
ILLUSION_KEYS = frozenset({
"phantom_lancer",
"naga_siren",
"chaos_knight",
"terrorblade",
"shadow_demon",
})
def tags_for_hero(key: str, roles: list[str] | None) -> list[str]:
"""Stable Chinese tags; order follows TAG_ORDER."""
found: set[str] = set()
for role in roles or []:
zh = ROLE_TO_ZH.get(role)
if zh:
found.add(zh)
if key in ILLUSION_KEYS:
found.add("幻象")
return [t for t in TAG_ORDER if t in found]
-891
View File
@@ -1,891 +0,0 @@
[
{
"id": 1,
"key": "antimage",
"name": "Anti-Mage",
"attr": "agi",
"name_loc": "敌法师"
},
{
"id": 2,
"key": "axe",
"name": "Axe",
"attr": "str",
"name_loc": "斧王"
},
{
"id": 3,
"key": "bane",
"name": "Bane",
"attr": "all",
"name_loc": "祸乱之源"
},
{
"id": 4,
"key": "bloodseeker",
"name": "Bloodseeker",
"attr": "agi",
"name_loc": "血魔"
},
{
"id": 5,
"key": "crystal_maiden",
"name": "Crystal Maiden",
"attr": "int",
"name_loc": "水晶室女"
},
{
"id": 6,
"key": "drow_ranger",
"name": "Drow Ranger",
"attr": "agi",
"name_loc": "卓尔游侠"
},
{
"id": 7,
"key": "earthshaker",
"name": "Earthshaker",
"attr": "str",
"name_loc": "撼地者"
},
{
"id": 8,
"key": "juggernaut",
"name": "Juggernaut",
"attr": "agi",
"name_loc": "主宰"
},
{
"id": 9,
"key": "mirana",
"name": "Mirana",
"attr": "agi",
"name_loc": "米拉娜"
},
{
"id": 10,
"key": "morphling",
"name": "Morphling",
"attr": "agi",
"name_loc": "变体精灵"
},
{
"id": 11,
"key": "nevermore",
"name": "Shadow Fiend",
"attr": "agi",
"name_loc": "影魔"
},
{
"id": 12,
"key": "phantom_lancer",
"name": "Phantom Lancer",
"attr": "agi",
"name_loc": "幻影长矛手"
},
{
"id": 13,
"key": "puck",
"name": "Puck",
"attr": "int",
"name_loc": "帕克"
},
{
"id": 14,
"key": "pudge",
"name": "Pudge",
"attr": "str",
"name_loc": "帕吉"
},
{
"id": 15,
"key": "razor",
"name": "Razor",
"attr": "agi",
"name_loc": "雷泽"
},
{
"id": 16,
"key": "sand_king",
"name": "Sand King",
"attr": "all",
"name_loc": "沙王"
},
{
"id": 17,
"key": "storm_spirit",
"name": "Storm Spirit",
"attr": "int",
"name_loc": "风暴之灵"
},
{
"id": 18,
"key": "sven",
"name": "Sven",
"attr": "str",
"name_loc": "斯温"
},
{
"id": 19,
"key": "tiny",
"name": "Tiny",
"attr": "str",
"name_loc": "小小"
},
{
"id": 20,
"key": "vengefulspirit",
"name": "Vengeful Spirit",
"attr": "agi",
"name_loc": "复仇之魂"
},
{
"id": 21,
"key": "windrunner",
"name": "Windranger",
"attr": "all",
"name_loc": "风行者"
},
{
"id": 22,
"key": "zuus",
"name": "Zeus",
"attr": "int",
"name_loc": "宙斯"
},
{
"id": 23,
"key": "kunkka",
"name": "Kunkka",
"attr": "str",
"name_loc": "昆卡"
},
{
"id": 25,
"key": "lina",
"name": "Lina",
"attr": "int",
"name_loc": "莉娜"
},
{
"id": 26,
"key": "lion",
"name": "Lion",
"attr": "int",
"name_loc": "莱恩"
},
{
"id": 27,
"key": "shadow_shaman",
"name": "Shadow Shaman",
"attr": "int",
"name_loc": "暗影萨满"
},
{
"id": 28,
"key": "slardar",
"name": "Slardar",
"attr": "str",
"name_loc": "斯拉达"
},
{
"id": 29,
"key": "tidehunter",
"name": "Tidehunter",
"attr": "str",
"name_loc": "潮汐猎人"
},
{
"id": 30,
"key": "witch_doctor",
"name": "Witch Doctor",
"attr": "int",
"name_loc": "巫医"
},
{
"id": 31,
"key": "lich",
"name": "Lich",
"attr": "int",
"name_loc": "巫妖"
},
{
"id": 32,
"key": "riki",
"name": "Riki",
"attr": "agi",
"name_loc": "力丸"
},
{
"id": 33,
"key": "enigma",
"name": "Enigma",
"attr": "all",
"name_loc": "谜团"
},
{
"id": 34,
"key": "tinker",
"name": "Tinker",
"attr": "int",
"name_loc": "修补匠"
},
{
"id": 35,
"key": "sniper",
"name": "Sniper",
"attr": "agi",
"name_loc": "狙击手"
},
{
"id": 36,
"key": "necrolyte",
"name": "Necrophos",
"attr": "int",
"name_loc": "瘟疫法师"
},
{
"id": 37,
"key": "warlock",
"name": "Warlock",
"attr": "int",
"name_loc": "术士"
},
{
"id": 38,
"key": "beastmaster",
"name": "Beastmaster",
"attr": "all",
"name_loc": "兽王"
},
{
"id": 39,
"key": "queenofpain",
"name": "Queen of Pain",
"attr": "int",
"name_loc": "痛苦女王"
},
{
"id": 40,
"key": "venomancer",
"name": "Venomancer",
"attr": "all",
"name_loc": "剧毒术士"
},
{
"id": 41,
"key": "faceless_void",
"name": "Faceless Void",
"attr": "agi",
"name_loc": "虚空假面"
},
{
"id": 42,
"key": "skeleton_king",
"name": "Wraith King",
"attr": "str",
"name_loc": "冥魂大帝"
},
{
"id": 43,
"key": "death_prophet",
"name": "Death Prophet",
"attr": "all",
"name_loc": "死亡先知"
},
{
"id": 44,
"key": "phantom_assassin",
"name": "Phantom Assassin",
"attr": "agi",
"name_loc": "幻影刺客"
},
{
"id": 45,
"key": "pugna",
"name": "Pugna",
"attr": "int",
"name_loc": "帕格纳"
},
{
"id": 46,
"key": "templar_assassin",
"name": "Templar Assassin",
"attr": "agi",
"name_loc": "圣堂刺客"
},
{
"id": 47,
"key": "viper",
"name": "Viper",
"attr": "agi",
"name_loc": "冥界亚龙"
},
{
"id": 48,
"key": "luna",
"name": "Luna",
"attr": "agi",
"name_loc": "露娜"
},
{
"id": 49,
"key": "dragon_knight",
"name": "Dragon Knight",
"attr": "str",
"name_loc": "龙骑士"
},
{
"id": 50,
"key": "dazzle",
"name": "Dazzle",
"attr": "all",
"name_loc": "戴泽"
},
{
"id": 51,
"key": "rattletrap",
"name": "Clockwerk",
"attr": "str",
"name_loc": "发条技师"
},
{
"id": 52,
"key": "leshrac",
"name": "Leshrac",
"attr": "int",
"name_loc": "拉席克"
},
{
"id": 53,
"key": "furion",
"name": "Nature's Prophet",
"attr": "all",
"name_loc": "自然先知"
},
{
"id": 54,
"key": "life_stealer",
"name": "Lifestealer",
"attr": "str",
"name_loc": "噬魂鬼"
},
{
"id": 55,
"key": "dark_seer",
"name": "Dark Seer",
"attr": "int",
"name_loc": "黑暗贤者"
},
{
"id": 56,
"key": "clinkz",
"name": "Clinkz",
"attr": "agi",
"name_loc": "克林克兹"
},
{
"id": 57,
"key": "omniknight",
"name": "Omniknight",
"attr": "str",
"name_loc": "全能骑士"
},
{
"id": 58,
"key": "enchantress",
"name": "Enchantress",
"attr": "int",
"name_loc": "魅惑魔女"
},
{
"id": 59,
"key": "huskar",
"name": "Huskar",
"attr": "str",
"name_loc": "哈斯卡"
},
{
"id": 60,
"key": "night_stalker",
"name": "Night Stalker",
"attr": "str",
"name_loc": "暗夜魔王"
},
{
"id": 61,
"key": "broodmother",
"name": "Broodmother",
"attr": "agi",
"name_loc": "育母蜘蛛"
},
{
"id": 62,
"key": "bounty_hunter",
"name": "Bounty Hunter",
"attr": "agi",
"name_loc": "赏金猎人"
},
{
"id": 63,
"key": "weaver",
"name": "Weaver",
"attr": "agi",
"name_loc": "编织者"
},
{
"id": 64,
"key": "jakiro",
"name": "Jakiro",
"attr": "int",
"name_loc": "杰奇洛"
},
{
"id": 65,
"key": "batrider",
"name": "Batrider",
"attr": "all",
"name_loc": "蝙蝠骑士"
},
{
"id": 66,
"key": "chen",
"name": "Chen",
"attr": "int",
"name_loc": "陈"
},
{
"id": 67,
"key": "spectre",
"name": "Spectre",
"attr": "agi",
"name_loc": "幽鬼"
},
{
"id": 68,
"key": "ancient_apparition",
"name": "Ancient Apparition",
"attr": "int",
"name_loc": "远古冰魄"
},
{
"id": 69,
"key": "doom_bringer",
"name": "Doom",
"attr": "str",
"name_loc": "末日使者"
},
{
"id": 70,
"key": "ursa",
"name": "Ursa",
"attr": "agi",
"name_loc": "熊战士"
},
{
"id": 71,
"key": "spirit_breaker",
"name": "Spirit Breaker",
"attr": "str",
"name_loc": "裂魂人"
},
{
"id": 72,
"key": "gyrocopter",
"name": "Gyrocopter",
"attr": "agi",
"name_loc": "矮人直升机"
},
{
"id": 73,
"key": "alchemist",
"name": "Alchemist",
"attr": "str",
"name_loc": "炼金术士"
},
{
"id": 74,
"key": "invoker",
"name": "Invoker",
"attr": "int",
"name_loc": "祈求者"
},
{
"id": 75,
"key": "silencer",
"name": "Silencer",
"attr": "int",
"name_loc": "沉默术士"
},
{
"id": 76,
"key": "obsidian_destroyer",
"name": "Outworld Destroyer",
"attr": "int",
"name_loc": "殁境神蚀者"
},
{
"id": 77,
"key": "lycan",
"name": "Lycan",
"attr": "str",
"name_loc": "狼人"
},
{
"id": 78,
"key": "brewmaster",
"name": "Brewmaster",
"attr": "all",
"name_loc": "酒仙"
},
{
"id": 79,
"key": "shadow_demon",
"name": "Shadow Demon",
"attr": "int",
"name_loc": "暗影恶魔"
},
{
"id": 80,
"key": "lone_druid",
"name": "Lone Druid",
"attr": "agi",
"name_loc": "独行德鲁伊"
},
{
"id": 81,
"key": "chaos_knight",
"name": "Chaos Knight",
"attr": "str",
"name_loc": "混沌骑士"
},
{
"id": 82,
"key": "meepo",
"name": "Meepo",
"attr": "agi",
"name_loc": "米波"
},
{
"id": 83,
"key": "treant",
"name": "Treant Protector",
"attr": "str",
"name_loc": "树精卫士"
},
{
"id": 84,
"key": "ogre_magi",
"name": "Ogre Magi",
"attr": "str",
"name_loc": "食人魔魔法师"
},
{
"id": 85,
"key": "undying",
"name": "Undying",
"attr": "str",
"name_loc": "不朽尸王"
},
{
"id": 86,
"key": "rubick",
"name": "Rubick",
"attr": "int",
"name_loc": "拉比克"
},
{
"id": 87,
"key": "disruptor",
"name": "Disruptor",
"attr": "int",
"name_loc": "干扰者"
},
{
"id": 88,
"key": "nyx_assassin",
"name": "Nyx Assassin",
"attr": "all",
"name_loc": "司夜刺客"
},
{
"id": 89,
"key": "naga_siren",
"name": "Naga Siren",
"attr": "agi",
"name_loc": "娜迦海妖"
},
{
"id": 90,
"key": "keeper_of_the_light",
"name": "Keeper of the Light",
"attr": "int",
"name_loc": "光之守卫"
},
{
"id": 91,
"key": "wisp",
"name": "Io",
"attr": "all",
"name_loc": "艾欧"
},
{
"id": 92,
"key": "visage",
"name": "Visage",
"attr": "all",
"name_loc": "维萨吉"
},
{
"id": 93,
"key": "slark",
"name": "Slark",
"attr": "agi",
"name_loc": "斯拉克"
},
{
"id": 94,
"key": "medusa",
"name": "Medusa",
"attr": "agi",
"name_loc": "美杜莎"
},
{
"id": 95,
"key": "troll_warlord",
"name": "Troll Warlord",
"attr": "agi",
"name_loc": "巨魔战将"
},
{
"id": 96,
"key": "centaur",
"name": "Centaur Warrunner",
"attr": "str",
"name_loc": "半人马战行者"
},
{
"id": 97,
"key": "magnataur",
"name": "Magnus",
"attr": "all",
"name_loc": "马格纳斯"
},
{
"id": 98,
"key": "shredder",
"name": "Timbersaw",
"attr": "str",
"name_loc": "伐木机"
},
{
"id": 99,
"key": "bristleback",
"name": "Bristleback",
"attr": "str",
"name_loc": "钢背兽"
},
{
"id": 100,
"key": "tusk",
"name": "Tusk",
"attr": "str",
"name_loc": "巨牙海民"
},
{
"id": 101,
"key": "skywrath_mage",
"name": "Skywrath Mage",
"attr": "int",
"name_loc": "天怒法师"
},
{
"id": 102,
"key": "abaddon",
"name": "Abaddon",
"attr": "all",
"name_loc": "亚巴顿"
},
{
"id": 103,
"key": "elder_titan",
"name": "Elder Titan",
"attr": "str",
"name_loc": "上古巨神"
},
{
"id": 104,
"key": "legion_commander",
"name": "Legion Commander",
"attr": "str",
"name_loc": "军团指挥官"
},
{
"id": 105,
"key": "techies",
"name": "Techies",
"attr": "all",
"name_loc": "工程师"
},
{
"id": 106,
"key": "ember_spirit",
"name": "Ember Spirit",
"attr": "agi",
"name_loc": "灰烬之灵"
},
{
"id": 107,
"key": "earth_spirit",
"name": "Earth Spirit",
"attr": "str",
"name_loc": "大地之灵"
},
{
"id": 108,
"key": "abyssal_underlord",
"name": "Underlord",
"attr": "str",
"name_loc": "孽主"
},
{
"id": 109,
"key": "terrorblade",
"name": "Terrorblade",
"attr": "agi",
"name_loc": "恐怖利刃"
},
{
"id": 110,
"key": "phoenix",
"name": "Phoenix",
"attr": "str",
"name_loc": "凤凰"
},
{
"id": 111,
"key": "oracle",
"name": "Oracle",
"attr": "int",
"name_loc": "神谕者"
},
{
"id": 112,
"key": "winter_wyvern",
"name": "Winter Wyvern",
"attr": "int",
"name_loc": "寒冬飞龙"
},
{
"id": 113,
"key": "arc_warden",
"name": "Arc Warden",
"attr": "all",
"name_loc": "天穹守望者"
},
{
"id": 114,
"key": "monkey_king",
"name": "Monkey King",
"attr": "agi",
"name_loc": "齐天大圣"
},
{
"id": 119,
"key": "dark_willow",
"name": "Dark Willow",
"attr": "int",
"name_loc": "邪影芳灵"
},
{
"id": 120,
"key": "pangolier",
"name": "Pangolier",
"attr": "all",
"name_loc": "石鳞剑士"
},
{
"id": 121,
"key": "grimstroke",
"name": "Grimstroke",
"attr": "int",
"name_loc": "天涯墨客"
},
{
"id": 123,
"key": "hoodwink",
"name": "Hoodwink",
"attr": "agi",
"name_loc": "森海飞霞"
},
{
"id": 126,
"key": "void_spirit",
"name": "Void Spirit",
"attr": "all",
"name_loc": "虚无之灵"
},
{
"id": 128,
"key": "snapfire",
"name": "Snapfire",
"attr": "all",
"name_loc": "电炎绝手"
},
{
"id": 129,
"key": "mars",
"name": "Mars",
"attr": "str",
"name_loc": "玛尔斯"
},
{
"id": 131,
"key": "ringmaster",
"name": "Ringmaster",
"attr": "int",
"name_loc": "百戏大王"
},
{
"id": 135,
"key": "dawnbreaker",
"name": "Dawnbreaker",
"attr": "str",
"name_loc": "破晓辰星"
},
{
"id": 136,
"key": "marci",
"name": "Marci",
"attr": "all",
"name_loc": "玛西"
},
{
"id": 137,
"key": "primal_beast",
"name": "Primal Beast",
"attr": "str",
"name_loc": "獸"
},
{
"id": 138,
"key": "muerta",
"name": "Muerta",
"attr": "int",
"name_loc": "琼英碧灵"
},
{
"id": 145,
"key": "kez",
"name": "Kez",
"attr": "agi",
"name_loc": "凯"
},
{
"id": 155,
"key": "largo",
"name": "Largo",
"attr": "str",
"name_loc": "朗戈"
}
]
+118
View File
@@ -0,0 +1,118 @@
"""Shared HTTP helpers: fetch JSON/bytes, download icons, load Valve datafeeds.
Used by all fetch_*.py scripts and serve_relations.py so HTTP logic, User-Agent,
timeout, and retry conventions live in exactly one place.
"""
from __future__ import annotations
import json
import time
import urllib.error
import urllib.request
from pathlib import Path
UA = "climperor"
DEFAULT_TIMEOUT = 60
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:
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:
return resp.read()
def download_icons(
keys,
url_template: str,
dest_dir: Path,
*,
force: bool = False,
delay: float = 0.0,
min_size: int = 32,
skip_keys: frozenset[str] | set[str] | None = None,
) -> tuple[int, int, int]:
"""Download PNG icons from a CDN. Returns (saved, skipped_existing, fail).
keys iterable of template substitution values (hero/item/ability keys).
url_template e.g. "https://cdn.../abilities/{key}.png".
dest_dir target directory (created if missing).
force re-download even if the file exists.
delay seconds to sleep between requests (rate limiting).
min_size files smaller than this are treated as empty and re-downloaded.
skip_keys keys to ignore entirely (e.g. bundled icons that 404 on CDN).
"""
dest_dir.mkdir(parents=True, exist_ok=True)
skip = set(skip_keys or ())
saved = skipped = fail = 0
for key in sorted(keys):
if key in skip or "/" in key or "\\" in key or ".." in key:
continue
dest = dest_dir / f"{key}.png"
if dest.is_file() and dest.stat().st_size >= min_size and not force:
skipped += 1
continue
try:
data = http_bytes(url_template.format(key=key))
if not data or len(data) < min_size:
raise ValueError("empty icon")
dest.write_bytes(data)
saved += 1
print(f" icon saved {key}.png ({len(data)} bytes)", flush=True)
except (urllib.error.URLError, TimeoutError, ValueError, OSError) as e:
print(f" icon {key}: {e}", flush=True)
fail += 1
if delay > 0:
time.sleep(delay)
return saved, skipped, fail
def load_itemlist(lang: str = "schinese") -> dict[int, dict[str, str]]:
"""Valve datafeed item list: item id -> {name_loc, name}."""
raw = http_json(ITEMLIST_URL.format(lang=lang))
rows = (((raw or {}).get("result") or {}).get("data") or {}).get("itemabilities") or []
out: dict[int, dict[str, str]] = {}
for row in rows:
if not isinstance(row, dict):
continue
iid = row.get("id")
if iid is None:
continue
out[int(iid)] = {
"name_loc": (row.get("name_loc") or "").strip(),
"name": (row.get("name") or "").strip(),
}
return out
def load_itemlist_zh() -> dict[int, str]:
"""Convenience: item id -> Chinese localized name."""
return {iid: v["name_loc"] for iid, v in load_itemlist().items() if v["name_loc"]}
def fetch_hero_list(lang: str = "schinese") -> list[dict]:
"""Hero list from Valve's datafeed (id, key, name_loc, primary_attr)."""
data = http_json(HEROES_URL.format(lang=lang))
heroes = data.get("result", {}).get("data", {}).get("heroes") or data.get("heroes")
if not heroes:
raise SystemExit("hero list came back empty")
return heroes
def fetch_hero_keys() -> list[str]:
"""Hero keys (e.g. antimage, earthshaker) from the English datafeed."""
heroes = fetch_hero_list(lang="english")
keys = []
for h in heroes:
name = h.get("name") or ""
keys.append(name.removeprefix("npc_dota_hero_"))
return keys
+154
View File
@@ -0,0 +1,154 @@
"""Import qualitative relations from a Chinese draft spreadsheet.
Usage:
python import_relations_xlsx.py
python import_relations_xlsx.py "C:\\Users\\Administrator\\Downloads\\dota2.xlsx"
Writes data/relations.json. Skips category phrases (辅助/幻想系/...).
"""
from __future__ import annotations
import argparse
import json
from datetime import datetime, timezone
from pathlib import Path
from common import ROOT
from relations import (
DEFAULT_RELATIONS,
build_name_index,
load_relations,
resolve_name,
save_relations,
set_counter,
set_synergy,
split_names,
)
# Phrases that are roles/tags, not single heroes.
SKIP_TOKENS = {
"辅助",
"推进",
"幻想系",
"幻想",
"所有肉核",
"肉核",
"高爆发",
"奶妈",
"核心",
}
def _should_skip(token: str) -> bool:
t = token.strip()
if not t:
return True
if t in SKIP_TOKENS:
return True
if "" in t and t.endswith(""):
return True
if t.startswith("所有"):
return True
if "魔晶" in t or "a仗" in t.lower():
# item/facet interactions in hero columns — skip for edge seed
return True
return False
def import_xlsx(path: Path) -> tuple[dict, list[str]]:
import openpyxl
wb = openpyxl.load_workbook(path, data_only=True)
index = build_name_index()
data = load_relations()
data["meta"] = {
"source": str(path),
"imported_at": datetime.now(timezone.utc).isoformat(),
"note": "qualitative counters/synergies; no winrate",
}
unresolved: list[str] = []
sheets = ["力量", "敏捷", "智力", "全才"]
def resolve_list(cell, context: str) -> list[str]:
keys = []
for tok in split_names(cell):
if _should_skip(tok):
unresolved.append(f"{context}: skip tag '{tok}'")
continue
key = resolve_name(tok, index)
if key is None:
unresolved.append(f"{context}: unresolved '{tok}'")
continue
keys.append(key)
return keys
for sheet in sheets:
if sheet not in wb.sheetnames:
continue
ws = wb[sheet]
rows = list(ws.iter_rows(values_only=True))
# find header
header_i = None
for i, row in enumerate(rows):
if row and row[0] == "英雄":
header_i = i
break
if header_i is None:
continue
for row in rows[header_i + 1 :]:
if not row or not row[0]:
continue
hero_raw = str(row[0]).strip()
hero = resolve_name(hero_raw, index)
if hero is None:
unresolved.append(f"{sheet}: hero unresolved '{hero_raw}'")
continue
# col1 被克制, col2 克制, col3 搭档
countered_by = resolve_list(row[1] if len(row) > 1 else None, f"{hero_raw}/被克")
counters = resolve_list(row[2] if len(row) > 2 else None, f"{hero_raw}/克制")
partners = resolve_list(row[3] if len(row) > 3 else None, f"{hero_raw}/搭档")
for other in counters:
set_counter(data, hero, other, reason="")
for other in countered_by:
# other counters hero
set_counter(data, other, hero, reason="")
for other in partners:
set_synergy(data, hero, other, reason="")
return data, unresolved
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument(
"xlsx",
nargs="?",
default=str(Path.home() / "Downloads" / "dota2.xlsx"),
help="path to dota2.xlsx",
)
ap.add_argument("--out", default=str(DEFAULT_RELATIONS))
args = ap.parse_args()
path = Path(args.xlsx)
if not path.is_file():
raise SystemExit(f"file not found: {path}")
data, unresolved = import_xlsx(path)
out = save_relations(data, args.out)
print(f"wrote {out}")
print(f"counters={len(data['counters'])} synergies={len(data['synergies'])}")
report = ROOT / "data" / "relations_import_report.json"
report.write_text(
json.dumps({"unresolved": unresolved, "count": len(unresolved)}, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(f"unresolved notes: {len(unresolved)}{report}")
for line in unresolved[:40]:
print(" ", line)
if len(unresolved) > 40:
print(f" ... +{len(unresolved) - 40} more")
if __name__ == "__main__":
main()
+389
View File
@@ -0,0 +1,389 @@
"""Build hero → feared items map from items_meta + hero_abilities.
Usage:
python item_fears.py
python item_fears.py --top 8
"""
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 hero_tags import ILLUSION_KEYS
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"
OUT = DATA / "hero_item_fears.json"
ABILITIES_URL = (
"https://raw.githubusercontent.com/odota/dotaconstants/master/build/abilities.json"
)
# Prefer iconic counter items when scores tie
ITEM_PRIORITY = {
"lotus_orb": 12,
"diffusal_blade": 10,
"disperser": 9,
"nullifier": 9,
"silver_edge": 8,
"monkey_king_bar": 8,
"bfury": 8,
"abyssal_blade": 8,
"mjollnir": 7,
"maelstrom": 6,
"radiance": 7,
"gungir": 6,
"spirit_vessel": 7,
"black_king_bar": 5,
"sphere": 5,
"butterfly": 6,
"ethereal_blade": 6,
"ghost": 4,
"gem": 4,
"dust": 3,
"orchid": 5,
"bloodthorn": 6,
"sheepstick": 6,
}
def load_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def is_unit_target_enemy(od: dict) -> bool:
beh = od.get("behavior")
if isinstance(beh, str):
unit = "Unit Target" in beh
elif isinstance(beh, list):
unit = any("Unit Target" in str(x) for x in beh)
else:
return False
if not unit:
return False
team = od.get("target_team")
if isinstance(team, list):
team_s = " ".join(str(x) for x in team)
else:
team_s = str(team or "")
return "Enemy" in team_s or team_s == ""
def load_odota_unit_target_keys() -> set[str]:
"""Ability keys that are enemy unit-target (Lotus / Linken relevant)."""
try:
raw = http_json(ABILITIES_URL, timeout=90)
except Exception as e: # noqa: BLE001
print(f"warn: could not load OpenDota abilities.json ({e})", flush=True)
return set()
if not isinstance(raw, dict):
return set()
out: set[str] = set()
for key, row in raw.items():
if not isinstance(row, dict) or str(key).startswith("special_bonus"):
continue
if is_unit_target_enemy(row):
out.add(str(key))
return out
def index_items_by_key(items: dict) -> dict[str, dict]:
out: dict[str, dict] = {}
for row in items.values():
if not isinstance(row, dict) or not row.get("key"):
continue
key = row["key"]
out[key] = {
"key": key,
"name_loc": row.get("name_loc") or row.get("dname") or key,
"tags": [t for t in (row.get("tags") or []) if isinstance(t, str)],
"cost": int(row.get("cost") or 0),
}
return out
def enrich_override_items(items_by_key: dict[str, dict], overrides: dict[str, dict]) -> None:
"""Ensure override-only item keys have name_loc (from OpenDota items.json)."""
needed = set()
for ov in overrides.values():
for row in ov.get("add") or []:
if isinstance(row, dict) and row.get("item"):
needed.add(str(row["item"]))
missing = [k for k in needed if k not in items_by_key]
if not missing:
return
try:
raw = http_json(
"https://raw.githubusercontent.com/odota/dotaconstants/master/build/items.json",
timeout=90,
)
except Exception as e: # noqa: BLE001
print(f"warn: could not enrich override items ({e})", flush=True)
return
if not isinstance(raw, dict):
return
for key in missing:
row = raw.get(key)
if not isinstance(row, dict):
items_by_key[key] = {
"key": key,
"name_loc": key,
"tags": [],
"cost": 0,
}
continue
items_by_key[key] = {
"key": key,
"name_loc": str(row.get("dname") or key),
"tags": [],
"cost": int(row.get("cost") or 0),
}
def index_items_by_tag(items_by_key: dict[str, dict]) -> dict[str, list[dict]]:
by_tag: dict[str, list[dict]] = {}
for entry in items_by_key.values():
for t in entry["tags"]:
by_tag.setdefault(t, []).append(entry)
return by_tag
def hero_summary(
hero_key: str,
abilities_db: dict,
hero_tags: list[str],
unit_target_keys: set[str],
) -> dict:
cell = (abilities_db.get("by_hero") or {}).get(hero_key) or {}
summary = dict(cell.get("summary") or {})
if hero_key in ILLUSION_KEYS or "幻象" in (hero_tags or []):
summary["has_illusion"] = True
# Unit-target enemy skills → Lotus / Linken
has_unit = False
for ab in cell.get("abilities") or []:
if not isinstance(ab, dict):
continue
key = ab.get("key") or ""
if ab.get("is_innate"):
continue
if key in unit_target_keys:
has_unit = True
break
summary["has_unit_target"] = has_unit
return summary
def needed_tags(summary: dict) -> list[tuple[str, str]]:
"""Return (item_tag, reason) pairs this hero fears."""
out: list[tuple[str, str]] = []
n_disp = int(summary.get("dispellable_buff_count") or 0)
if n_disp > 0:
out.append(("basic_dispel", "可驱散技能增益"))
out.append(("strong_dispel", "可驱散技能增益"))
if summary.get("has_strong_only_buff"):
out.append(("strong_dispel", "需强驱散才能驱散的增益"))
# Few shop items apply strong dispel offensively; still surface Linken/BKB.
out.append(("spell_block", "法术格挡关键技能"))
out.append(("magic_immune", "魔免削弱技能"))
if summary.get("has_illusion"):
out.append(("illusion_clear", "克制幻象"))
if summary.get("has_evasion"):
out.append(("true_strike", "无视闪避"))
if summary.get("has_invis"):
out.append(("invis_detect", "显影"))
out.append(("invis_break", "破隐"))
if summary.get("has_passive_breakable"):
out.append(("break", "破坏关键被动"))
if summary.get("mana_dependent"):
out.append(("mana_burn", "烧蓝克制"))
if summary.get("has_unit_target"):
out.append(("spell_reflect", "反射点目标技能"))
out.append(("spell_block", "格挡点目标技能"))
if summary.get("disable_heavy"):
out.append(("magic_immune", "魔免抵消控制"))
out.append(("spell_block", "法术格挡"))
elif summary.get("magic_nuke"):
out.append(("magic_immune", "魔免削弱魔法输出"))
return out
def score_item(
item: dict,
matched_tags: list[str],
reasons: list[str],
bonus: int = 0,
) -> tuple[int, dict]:
pri = ITEM_PRIORITY.get(item["key"], 0)
score = len(matched_tags) * 10 + pri + bonus
if item["cost"] >= 2000:
score += 2
return score, {
"item": item["key"],
"name_loc": item["name_loc"],
"tags": matched_tags,
"reason": "".join(dict.fromkeys(reasons)),
"_score": score,
}
def load_hero_overrides() -> dict[str, dict]:
if not HERO_FEAR_OVERRIDES.is_file():
return {}
try:
raw = load_json(HERO_FEAR_OVERRIDES)
except (OSError, json.JSONDecodeError):
return {}
heroes = raw.get("heroes") or {}
return {str(k): v for k, v in heroes.items() if isinstance(v, dict)}
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],
top_n: int,
) -> list[dict]:
need = needed_tags(summary)
acc: dict[str, dict] = {}
for tag, reason in need:
for item in by_tag.get(tag) or []:
key = item["key"]
if key not in acc:
acc[key] = {"item": item, "matched": [], "reasons": [], "bonus": 0}
if tag not in acc[key]["matched"]:
acc[key]["matched"].append(tag)
if reason not in acc[key]["reasons"]:
acc[key]["reasons"].append(reason)
ov = overrides.get(hero_key) or {}
for row in ov.get("add") or []:
if not isinstance(row, dict):
continue
key = row.get("item")
if not key or key not in items_by_key:
# Allow overrides for items not tagged (butterfly, ghost, …)
if not key:
continue
meta = items_by_key.get(key)
if meta is None:
# Synthesize from key alone if missing from finished catalog
meta = {
"key": key,
"name_loc": key,
"tags": list(row.get("tags") or []),
"cost": 0,
}
items_by_key[key] = meta
item = items_by_key[key]
if key not in acc:
acc[key] = {"item": item, "matched": [], "reasons": [], "bonus": 20}
else:
acc[key]["bonus"] = max(int(acc[key].get("bonus") or 0), 20)
for t in row.get("tags") or []:
if isinstance(t, str) and t not in acc[key]["matched"]:
acc[key]["matched"].append(t)
reason = (row.get("reason") or "").strip()
if reason and reason not in acc[key]["reasons"]:
acc[key]["reasons"].insert(0, reason)
# Refresh name_loc if we only had key
if item.get("name_loc") == key and row.get("name_loc"):
item["name_loc"] = row["name_loc"]
for key in ov.get("remove") or []:
if isinstance(key, str):
acc.pop(key, None)
ranked = []
for row in acc.values():
sc, entry = score_item(
row["item"],
row["matched"],
row["reasons"],
bonus=int(row.get("bonus") or 0),
)
ranked.append((sc, entry))
ranked.sort(key=lambda t: (-t[0], t[1]["item"]))
out = []
for _, entry in ranked[:top_n]:
entry.pop("_score", None)
out.append(entry)
return out
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--top", type=int, default=8)
ap.add_argument("--out", type=Path, default=OUT)
args = ap.parse_args()
if not ITEMS_META.is_file():
raise SystemExit(f"missing {ITEMS_META}; run: python fetch_items_meta.py")
if not HERO_ABILITIES.is_file():
raise SystemExit(f"missing {HERO_ABILITIES}; run: python fetch_hero_abilities.py")
items_meta = load_json(ITEMS_META)
abilities_db = load_json(HERO_ABILITIES)
items = items_meta.get("items") or {}
items_by_key = index_items_by_key(items)
overrides = load_hero_overrides()
enrich_override_items(items_by_key, overrides)
by_tag = index_items_by_tag(items_by_key)
print("loading OpenDota abilities for unit-target detection...", flush=True)
unit_target_keys = load_odota_unit_target_keys()
print(f" {len(unit_target_keys)} unit-target enemy abilities", flush=True)
heroes = hero_table()
by_hero: dict[str, list] = {}
for h in heroes:
key = h["key"]
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
)
items_out = {
k: {
"key": v["key"],
"name_loc": v["name_loc"],
"tags": v["tags"],
}
for k, v in items_by_key.items()
}
payload = {
"meta": {
"source": "rules+valve+opendota",
"attribution": "derived from items_meta.json + hero_abilities.json",
"fetched_at": datetime.now(timezone.utc).isoformat(),
"top_n": args.top,
"heroes": len(by_hero),
"overrides": str(HERO_FEAR_OVERRIDES.relative_to(DATA.parent)).replace("\\", "/"),
},
"by_hero": by_hero,
"items": items_out,
}
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",
)
nonempty = sum(1 for v in by_hero.values() if v)
jugg = [x["item"] for x in by_hero.get("juggernaut") or []]
print(f"done: {nonempty}/{len(by_hero)} heroes have feared items -> {args.out}", flush=True)
print(f" juggernaut: {jugg}", flush=True)
if __name__ == "__main__":
main()
+98
View File
@@ -0,0 +1,98 @@
"""Shared Valve loc formatting: strip HTML and fill %token% / {s:token}."""
from __future__ import annotations
import re
from html import unescape
def fmt_num(v: float) -> str:
if abs(v - round(v)) < 1e-6:
return str(int(round(v)))
return f"{v:g}"
def sv_lookup(
special_values: list | None, prefer: str | None = None
) -> dict[str, list[float]]:
"""prefer: None | 'scepter' | 'shard' — choose values_* channel when present."""
out: dict[str, list[float]] = {}
for sv in special_values or []:
if not isinstance(sv, dict):
continue
name = str(sv.get("name") or "").strip()
if not name:
continue
base = sv.get("values_float") or []
if not isinstance(base, list):
base = []
sc = sv.get("values_scepter") or []
sh = sv.get("values_shard") or []
if not isinstance(sc, list):
sc = []
if not isinstance(sh, list):
sh = []
chosen = base
if prefer == "scepter" and sc:
chosen = sc
elif prefer == "shard" and sh:
chosen = sh
out[name] = [float(x) for x in chosen if isinstance(x, (int, float))]
if sc:
out["scepter_" + name] = [
float(x) for x in sc if isinstance(x, (int, float))
]
if sh:
out["shard_" + name] = [
float(x) for x in sh if isinstance(x, (int, float))
]
return out
def strip_html(text: str) -> str:
"""Remove HTML tags and collapse whitespace (no token filling)."""
if not text:
return ""
t = unescape(text)
t = re.sub(r"<br\s*/?>", "\n", t, flags=re.I)
t = re.sub(r"</?h1[^>]*>", "\n", t, flags=re.I)
t = re.sub(r"</?font[^>]*>", "", t, flags=re.I)
t = re.sub(r"<[^>]+>", " ", t)
return re.sub(r"[ \t]+", " ", t).strip()
def format_loc(
text: str,
special_values: list | None = None,
prefer: str | None = None,
) -> str:
"""Strip HTML and fill %token% / {s:token} from special_values when possible."""
if not text:
return ""
t = strip_html(text)
t = re.sub(r"[ \t]+\n", "\n", t)
lookup = sv_lookup(special_values or [], prefer=prefer)
def repl_pct(m: re.Match) -> str:
key = m.group(1)
vals = lookup.get(key)
if not vals and prefer:
vals = lookup.get(f"{prefer}_{key}")
if not vals:
return "?"
if len(vals) == 1:
return fmt_num(vals[0])
if len(vals) <= 4:
return " / ".join(fmt_num(v) for v in vals)
return fmt_num(vals[0])
t = re.sub(r"%([A-Za-z0-9_]+)%", repl_pct, t)
t = re.sub(r"\{s:([A-Za-z0-9_]+)\}", repl_pct, t)
t = t.replace("%%", "%")
t = re.sub(r"[ \t]+\n", "\n", t)
t = re.sub(r"\n{3,}", "\n\n", t)
t = re.sub(r"[ \t]{2,}", " ", t).strip()
return t
HAS_PLACEHOLDER = re.compile(r"%[A-Za-z0-9_]+%|\{s:[A-Za-z0-9_]+\}")
+296
View File
@@ -0,0 +1,296 @@
"""Transparent click-through overlay: role tags + Top-3 recommend badges.
Runs a Tk root on a background thread. DraftSession calls set_roster() and
set_grid_marks(); geometry uses the same relative coords as recognition.
"""
from __future__ import annotations
import json
import sys
import threading
import tkinter as tk
from pathlib import Path
import mss
from common import HEROES_JSON as HEROES_PATH, ROOT, slot_rect_px
ROLE_ICONS_DIR = ROOT / "assets" / "role_icons"
ROLE_ORDER = [
"Carry",
"Support",
"Nuker",
"Disabler",
"Durable",
"Escape",
"Initiator",
"Pusher",
]
CHROMA = "#ff00ff"
DEFAULT_REC_COLOR = "#2ec4b6"
DEFAULT_REC_TEXT = "#0b1220"
def _load_roles_by_key() -> dict[str, list[str]]:
if not HEROES_PATH.exists():
return {}
table = json.loads(HEROES_PATH.read_text(encoding="utf-8"))
return {h["key"]: list(h.get("roles") or []) for h in table}
def _primary_monitor_size() -> tuple[int, int]:
with mss.MSS() as sct:
mon = sct.monitors[1]
return int(mon["width"]), int(mon["height"])
def _enable_click_through(hwnd: int) -> None:
"""Make the window ignore mouse input (Windows)."""
if sys.platform != "win32":
return
import ctypes
user32 = ctypes.windll.user32
GWL_EXSTYLE = -20
WS_EX_LAYERED = 0x00080000
WS_EX_TRANSPARENT = 0x00000020
WS_EX_TOOLWINDOW = 0x00000080
get_long = user32.GetWindowLongW
set_long = user32.SetWindowLongW
style = get_long(hwnd, GWL_EXSTYLE)
set_long(hwnd, GWL_EXSTYLE, style | WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW)
class DraftOverlay:
"""Fullscreen transparent overlay drawn above Dota during draft tracking."""
def __init__(self, cfg: dict):
self.cfg = cfg
o = cfg.get("overlay") or {}
self.y_gap_rel = float(o.get("y_gap_rel", 0.008))
self.icon_h_rel = float(o.get("icon_h_rel", 0.016))
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.roles_by_key = _load_roles_by_key()
self._roster: dict[int, str] = {}
self._cells: dict[str, dict] = {}
self._marks: dict[str, int] = {}
self._ready = threading.Event()
self._closed = False
self._root: tk.Tk | None = None
self._canvas: tk.Canvas | None = None
self._photos: list[tk.PhotoImage] = []
self._icon_src: dict[str, tk.PhotoImage] = {}
self._thread = threading.Thread(target=self._run, name="draft-overlay", daemon=True)
self._thread.start()
self._ready.wait(timeout=5.0)
def _run(self) -> None:
sw, sh = _primary_monitor_size()
root = tk.Tk()
self._root = root
root.overrideredirect(True)
root.attributes("-topmost", True)
root.geometry(f"{sw}x{sh}+0+0")
root.configure(bg=CHROMA)
try:
root.attributes("-transparentcolor", CHROMA)
except tk.TclError:
pass
canvas = tk.Canvas(root, width=sw, height=sh, bg=CHROMA, highlightthickness=0, bd=0)
canvas.pack(fill="both", expand=True)
self._canvas = canvas
self._screen = (sw, sh)
self._load_icon_sources()
root.update_idletasks()
try:
hwnd = int(root.wm_frame(), 16) if root.wm_frame().startswith("0x") else int(root.winfo_id())
if sys.platform == "win32":
import ctypes
hwnd = ctypes.windll.user32.GetParent(root.winfo_id()) or root.winfo_id()
_enable_click_through(int(hwnd))
except Exception:
pass
root.withdraw()
self._ready.set()
root.mainloop()
try:
root.destroy()
except tk.TclError:
pass
self._closed = True
def _load_icon_sources(self) -> None:
assert self._root is not None
for name in ROLE_ORDER:
path = ROLE_ICONS_DIR / f"{name}.png"
if not path.exists():
continue
try:
self._icon_src[name] = tk.PhotoImage(master=self._root, file=str(path))
except tk.TclError:
continue
def _scaled_icon(self, name: str, target_h: int) -> tk.PhotoImage | None:
src = self._icon_src.get(name)
if src is None or target_h <= 0:
return None
h = max(src.height(), 1)
if target_h >= h:
factor = max(1, round(target_h / h))
img = src.zoom(factor, factor)
else:
factor = max(1, round(h / target_h))
img = src.subsample(factor, factor)
self._photos.append(img)
return img
def set_roster(self, confirmed: dict[int, str]) -> None:
"""Update tags for confirmed slots (hero_key by slot index)."""
roster = {int(k): v for k, v in confirmed.items() if v}
if roster == self._roster:
return
self._roster = dict(roster)
self._schedule_redraw()
def set_grid_marks(
self,
cells: dict[str, dict] | None,
marks: dict[str, int] | list[dict] | None,
) -> None:
"""Cyan Top-3 badges on hero-grid cells. marks: key->rank or suggest_top list."""
cells = {str(k): dict(v) for k, v in (cells or {}).items()}
parsed: dict[str, int] = {}
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)
elif marks:
for key, rank in marks.items():
parsed[str(key)] = int(rank)
if cells == self._cells and parsed == self._marks:
return
self._cells = cells
self._marks = parsed
self._schedule_redraw()
def _schedule_redraw(self) -> None:
root = self._root
if root is None or self._closed:
return
try:
root.after(0, self._redraw)
except RuntimeError:
pass
def show(self) -> None:
root = self._root
if root is None or self._closed:
return
try:
root.after(0, root.deiconify)
except RuntimeError:
pass
def hide(self) -> None:
root = self._root
if root is None or self._closed:
return
def _hide() -> None:
if self._canvas is not None:
self._canvas.delete("all")
self._photos.clear()
root.withdraw()
try:
root.after(0, _hide)
except RuntimeError:
pass
def close(self) -> None:
root = self._root
if root is None or self._closed:
return
done = threading.Event()
def _shutdown() -> None:
try:
if self._canvas is not None:
self._canvas.delete("all")
self._photos.clear()
self._icon_src.clear()
root.quit()
finally:
done.set()
try:
root.after(0, _shutdown)
except RuntimeError:
done.set()
done.wait(timeout=2.0)
self._thread.join(timeout=2.0)
self._closed = True
def _redraw(self) -> None:
canvas = self._canvas
if canvas is None:
return
canvas.delete("all")
self._photos.clear()
sw, sh = self._screen
icon_h = max(8, int(round(self.icon_h_rel * sh)))
gap = max(0, int(round(self.icon_gap_rel * sh)))
y_gap = int(round(self.y_gap_rel * sh))
for slot in self.cfg.get("slots") or []:
idx = int(slot["index"])
hero = self._roster.get(idx)
if not hero:
continue
roles = [r for r in ROLE_ORDER if r in set(self.roles_by_key.get(hero, []))]
if not roles:
continue
x, y, w, h = slot_rect_px(slot, self.cfg, sw, sh)
icons = [img for r in roles if (img := self._scaled_icon(r, icon_h)) is not None]
if not icons:
continue
total_w = sum(img.width() for img in icons) + gap * (len(icons) - 1)
cx = x + w / 2
left = int(round(cx - total_w / 2))
top = y + h + y_gap
cursor = left
for img in icons:
canvas.create_image(cursor, top, image=img, anchor="nw")
cursor += img.width() + gap
mark = max(12, int(round(self.mark_size_rel * sh)))
pad = max(2, int(round(self.mark_pad_rel * sh)))
font_size = max(8, int(round(mark * 0.55)))
for key, rank in self._marks.items():
cell = self._cells.get(key)
if not cell:
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"),
)
+159
View File
@@ -0,0 +1,159 @@
"""Draft suggestions from qualitative hero relations.
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
Data: data/relations.json (not OpenDota winrates).
"""
from __future__ import annotations
from pathlib import Path
from common import ROOT
from grid import hero_table
from relations import DEFAULT_RELATIONS, indexes, load_relations
DEFAULT_ROLE_TAGS = {
"1": ["Carry"],
"2": ["Carry", "Nuker", "Escape"],
"3": ["Initiator", "Durable", "Carry"],
"4": ["Support"],
"5": ["Support"],
}
def _maps() -> tuple[dict[str, 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
def enemy_keys(confirmed: dict[int, str], self_team: str | None) -> list[str]:
if self_team == "radiant":
slots = range(6, 11)
elif self_team == "dire":
slots = range(1, 6)
else:
return []
return [confirmed[s] for s in slots if confirmed.get(s)]
def ally_keys(confirmed: dict[int, str], self_team: str | None, self_slot: int | None = None) -> list[str]:
"""Teammates already locked (excludes your own slot)."""
if self_team == "radiant":
slots = range(1, 6)
elif self_team == "dire":
slots = range(6, 11)
else:
return []
out = []
for s in slots:
if self_slot is not None and s == self_slot:
continue
if confirmed.get(s):
out.append(confirmed[s])
return out
def candidates_for_position(
position: int | None,
*,
roles_by_key: dict[str, list[str]],
role_tags: dict | None = None,
) -> list[str]:
tags_map = role_tags or DEFAULT_ROLE_TAGS
if position is None:
return []
wanted = set(tags_map.get(str(position)) or tags_map.get(position) or [])
if not wanted:
return []
out = []
for key, tags in roles_by_key.items():
if wanted.intersection(tags):
out.append(key)
return out
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 = 3,
role_tags: dict | None = None,
**_ignored,
) -> list[dict]:
"""Return Top-N picks from qualitative counters / synergies.
Extra kwargs (matchups, synergies, min_games) accepted and ignored for
backward compatibility with older call sites.
"""
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 []
names, roles_by_key = _maps()
counters_of, countered_by, synergies_of = indexes(rel)
exclude_set = {e for e in exclude if e}
scored: list[tuple[int, 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
beats = []
beaten_by = []
with_allies = []
for ek in enemies:
for edge in counters_of.get(cand) or []:
if edge["key"] == ek:
beats.append({"enemy": ek, "reason": edge.get("reason") or ""})
for edge in countered_by.get(cand) or []:
if edge["key"] == ek:
beaten_by.append({"enemy": ek, "reason": edge.get("reason") or ""})
for ak in allies:
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:
continue
scored.append((score, cand, {
"beats": beats,
"beaten_by": beaten_by,
"with": with_allies,
}))
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({
"key": key,
"name_loc": names.get(key, key),
"rank": rank,
"score": score,
**detail,
})
return out
__all__ = [
"DEFAULT_RELATIONS",
"DEFAULT_ROLE_TAGS",
"ally_keys",
"candidates_for_position",
"enemy_keys",
"load_relations",
"suggest_top",
]
+218
View File
@@ -0,0 +1,218 @@
"""Qualitative hero relations: counters / countered-by / synergies.
Edges are stable draft knowledge (not patch winrates):
counters: a counters b (a b)
synergies: unordered partner pairs
Source of truth: data/relations.json (seeded from spreadsheet / edited in UI).
"""
from __future__ import annotations
import json
from pathlib import Path
from common import ROOT
from grid import hero_table
DEFAULT_RELATIONS = ROOT / "data" / "relations.json"
# Spreadsheet / slang nicknames not covered by data/heroes.json aliases.
EXTRA_NAMES: dict[str, str] = {
"pa": "phantom_assassin",
"ta": "templar_assassin",
"nec": "necrolyte",
"tb": "terrorblade",
"vs": "vengefulspirit",
"sf": "nevermore",
"od": "obsidian_destroyer",
"mk": "monkey_king",
"bh": "bounty_hunter",
"es": "earthshaker",
"ck": "chaos_knight",
"am": "antimage",
"dp": "death_prophet",
"刚被": "bristleback",
"钢背": "bristleback",
"打屁股": "abyssal_underlord",
"大屁股": "abyssal_underlord",
"抄袭": "rubick",
"鸟人": "skywrath_mage",
"破晓星辰": "dawnbreaker",
"破晓辰星": "dawnbreaker",
"小牛": "centaur",
"人马": "centaur",
"骷髅": "skeleton_king",
"骷髅王": "skeleton_king",
"哈斯卡": "huskar",
"神灵": "huskar",
"圣堂": "templar_assassin",
"圣堂/ta": "templar_assassin",
"大圣": "monkey_king",
"猴哥": "monkey_king",
"猴子": "phantom_lancer", # spreadsheet uses 猴子 for PL; MK is 大圣
"黑贤": "dark_seer",
"黑暗贤者": "dark_seer",
"": "primal_beast",
"一霸": "primal_beast",
"奶绿": "treant",
"马尔斯": "mars",
"玛尔斯": "mars",
"马西": "marci",
"玛西": "marci",
"小骷髅": "clinkz",
"骨弓": "clinkz",
}
def _norm(s: str) -> str:
return "".join(str(s).strip().lower().split())
def build_name_index(table: list[dict] | None = None) -> dict[str, str]:
"""Map normalized Chinese/English/alias → hero key. Later entries do not win over EXTRA."""
table = table if table is not None else hero_table()
idx: dict[str, str] = {}
for h in table:
key = h["key"]
for raw in (
key,
h.get("name") or "",
h.get("name_loc") or "",
*(h.get("aliases") or []),
*(h.get("abbr") or []),
):
n = _norm(raw)
if n and n not in idx:
idx[n] = key
for name, key in EXTRA_NAMES.items():
idx[_norm(name)] = key
return idx
def resolve_name(raw: str, index: dict[str, str] | None = None) -> str | None:
if raw is None:
return None
text = str(raw).strip()
if not text:
return None
idx = index or build_name_index()
return idx.get(_norm(text))
def split_names(cell: str | None) -> list[str]:
if cell is None:
return []
text = str(cell).strip()
if not text or text.lower() in ("none", "null"):
return []
# separators: Chinese/ASCII comma,顿号, slash, whitespace
for sep in ("", ",", "", "/", "|", "", ";"):
text = text.replace(sep, "|")
parts = []
for p in text.split("|"):
p = p.strip()
if p:
parts.append(p)
return parts
def empty_relations() -> dict:
return {"version": 1, "counters": [], "synergies": [], "meta": {}}
def load_relations(path: str | Path | None = None) -> dict:
p = Path(path) if path else DEFAULT_RELATIONS
if not p.is_absolute():
p = ROOT / p
if not p.is_file():
return empty_relations()
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return empty_relations()
if not isinstance(data, dict):
return empty_relations()
counters = data.get("counters") if isinstance(data.get("counters"), list) else []
synergies = data.get("synergies") if isinstance(data.get("synergies"), list) else []
return {
"version": int(data.get("version") or 1),
"counters": [c for c in counters if isinstance(c, dict) and c.get("a") and c.get("b")],
"synergies": [c for c in synergies if isinstance(c, dict) and c.get("a") and c.get("b")],
"meta": data.get("meta") if isinstance(data.get("meta"), dict) else {},
}
def save_relations(data: dict, path: str | Path | None = None) -> Path:
p = Path(path) if path else DEFAULT_RELATIONS
if not p.is_absolute():
p = ROOT / p
p.parent.mkdir(parents=True, exist_ok=True)
counters = []
seen_c: set[tuple[str, str]] = set()
for c in data.get("counters") or []:
a, b = c.get("a"), c.get("b")
if not a or not b or a == b:
continue
key = (a, b)
if key in seen_c:
continue
seen_c.add(key)
counters.append({"a": a, "b": b, "reason": (c.get("reason") or "").strip()})
synergies = []
seen_s: set[tuple[str, str]] = set()
for c in data.get("synergies") or []:
a, b = c.get("a"), c.get("b")
if not a or not b or a == b:
continue
x, y = sorted((a, b))
if (x, y) in seen_s:
continue
seen_s.add((x, y))
synergies.append({"a": x, "b": y, "reason": (c.get("reason") or "").strip()})
out = {
"version": 1,
"meta": data.get("meta") if isinstance(data.get("meta"), dict) else {},
"counters": sorted(counters, key=lambda r: (r["a"], r["b"])),
"synergies": sorted(synergies, key=lambda r: (r["a"], r["b"])),
}
p.write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return p
def indexes(data: dict | None = None) -> tuple[dict[str, list[dict]], dict[str, list[dict]], dict[str, list[dict]]]:
"""Return (counters_of, countered_by, synergies_of) keyed by hero key."""
rel = data if data is not None else load_relations()
counters_of: dict[str, list[dict]] = {}
countered_by: dict[str, list[dict]] = {}
synergies_of: dict[str, list[dict]] = {}
for c in rel.get("counters") or []:
a, b, reason = c["a"], c["b"], c.get("reason") or ""
counters_of.setdefault(a, []).append({"key": b, "reason": reason})
countered_by.setdefault(b, []).append({"key": a, "reason": reason})
for c in rel.get("synergies") or []:
a, b, reason = c["a"], c["b"], c.get("reason") or ""
synergies_of.setdefault(a, []).append({"key": b, "reason": reason})
synergies_of.setdefault(b, []).append({"key": a, "reason": reason})
return counters_of, countered_by, synergies_of
def set_counter(data: dict, a: str, b: str, reason: str = "", *, enabled: bool = True) -> dict:
"""Add/update or remove directed counter a→b."""
counters = [c for c in (data.get("counters") or []) if not (c.get("a") == a and c.get("b") == b)]
if enabled:
counters.append({"a": a, "b": b, "reason": reason})
data["counters"] = counters
return data
def set_synergy(data: dict, a: str, b: str, reason: str = "", *, enabled: bool = True) -> dict:
x, y = sorted((a, b))
synergies = [
c for c in (data.get("synergies") or [])
if tuple(sorted((c.get("a"), c.get("b")))) != (x, y)
]
if enabled:
synergies.append({"a": x, "b": y, "reason": reason})
data["synergies"] = synergies
return data
+1 -1
View File
@@ -1,4 +1,4 @@
opencv-python>=4.10 opencv-python>=4.10
numpy>=2.0 numpy>=2.0
requests>=2.32
mss>=9.0 mss>=9.0
openpyxl>=3.1
+8 -37
View File
@@ -1,11 +1,7 @@
"""Read the role-queue labels and find which top-bar slot is you. """Read role-queue lane labels under top-bar portraits.
Two things live in the strip of text under each top-bar portrait: Row under each portrait (role-queue only, own team): 优势路 / 中路 / 劣势路 /
辅助 / 纯辅助. Your own slot index comes from GSI team_slot, not from name tint.
row 1 (name) - your own name renders white and bold, everyone else's is
tinted blue, which is enough to tell which slot is you.
row 2 (role) - only drawn for your own team, and only in role-queue
(定位匹配) matches: 优势路 / 中路 / 劣势路 / 辅助 / 纯辅助.
The role text is flat grey with zero saturation, so a threshold on The role text is flat grey with zero saturation, so a threshold on
value+saturation isolates it cleanly. Matching is done on the binary mask value+saturation isolates it cleanly. Matching is done on the binary mask
@@ -118,9 +114,11 @@ def load_role_templates() -> dict[str, np.ndarray]:
def detect_roles(img: np.ndarray, cfg: dict, templates: dict[str, np.ndarray] | None = None) -> dict: def detect_roles(img: np.ndarray, cfg: dict, templates: dict[str, np.ndarray] | None = None) -> dict:
"""Per-slot role plus which slot is you. """Per-slot role labels from the role-queue text under top-bar portraits.
Returns {"self_slot": int|None, "self_team": str|None, "roles": {slot: {...}}}. Returns {"self_team": str|None, "roles": {slot: {...}}}.
Your own top-bar slot comes from GSI team_slot elsewhere - this helper
does not guess it from name brightness.
Slots without a role label (the enemy team, or any non-role-queue mode) Slots without a role label (the enemy team, or any non-role-queue mode)
are simply absent from "roles". are simply absent from "roles".
""" """
@@ -144,38 +142,11 @@ def detect_roles(img: np.ndarray, cfg: dict, templates: dict[str, np.ndarray] |
"score": round(score, 3), "score": round(score, 3),
} }
self_slot = _detect_self(img, cfg)
self_team = None self_team = None
if roles: if roles:
self_team = "radiant" if min(roles) <= 5 else "dire" self_team = "radiant" if min(roles) <= 5 else "dire"
elif self_slot is not None:
self_team = "radiant" if self_slot <= 5 else "dire"
return {"self_slot": self_slot, "self_team": self_team, "roles": roles} return {"self_team": self_team, "roles": roles}
def _detect_self(img: np.ndarray, cfg: dict) -> int | None:
"""Your own name is drawn bright white, the other nine a dimmer blue-grey.
The gap is ~55 units of brightness, so compare slots against each other
instead of a fixed threshold - that survives HUD skins and any screen
where every row happens to be bright (a menu, a loading overlay), because
there the runner-up is just as bright and the match is rejected.
"""
r = cfg.get("roles", {})
min_value = r.get("self_min_value", 195.0)
min_gap = r.get("self_min_gap", 25.0)
found = []
for slot in cfg.get("slots", []):
tint = name_tint(img, slot, cfg)
if tint is not None:
found.append((tint[0], slot["index"]))
if len(found) < 2:
return None
found.sort(reverse=True)
if found[0][0] < min_value or found[0][0] - found[1][0] < min_gap:
return None
return found[0][1]
def _main() -> None: def _main() -> None:
+582
View File
@@ -0,0 +1,582 @@
"""Local read-only preview for qualitative hero 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.
"""
from __future__ import annotations
import argparse
import json
import mimetypes
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse
import urllib.error
from common import (
ABILITY_ICONS,
ABILITY_VIDEOS,
ATTR_ICONS,
HERO_PORTRAITS,
ITEM_CAT_ICONS,
ITEM_ICONS,
ROOT,
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 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_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"
ATTR_COLS = {"str": 6, "agi": 6, "int": 6, "all": 4}
ATTR_LABELS = {"str": "力量", "agi": "敏捷", "int": "智力", "all": "全才"}
ABILITY_ICON_URL = (
"https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/abilities/{key}.png"
)
# Shared innate badge (official-style gold droplet); UI falls back here when CDN 404s.
INNATE_ICON_NAME = "innate"
# Bundled talent-tree trigger icon (not fetched from CDN).
TALENT_TREE_ICON_NAME = "talent_tree"
def load_hero_items() -> dict:
"""Cached OpenDota item popularity (see fetch_hero_items.py)."""
empty = {"meta": {}, "items": {}, "by_hero": {}}
if not HERO_ITEMS_PATH.is_file():
return empty
try:
raw = json.loads(HERO_ITEMS_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return empty
return {
"meta": dict(raw.get("meta") or {}),
"items": dict(raw.get("items") 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": {}}
if not HERO_ITEM_FEARS_PATH.is_file():
return empty
try:
raw = json.loads(HERO_ITEM_FEARS_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return empty
return {
"meta": dict(raw.get("meta") or {}),
"items": dict(raw.get("items") or {}),
"by_hero": dict(raw.get("by_hero") or {}),
}
def load_items_meta_index() -> dict:
"""key → slim item row (desc/tags/cost) for hero-page inspect lookups."""
out: dict[str, dict] = {}
if not ITEMS_META_PATH.is_file():
return out
try:
raw = json.loads(ITEMS_META_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return out
for row in (raw.get("items") or {}).values():
if not isinstance(row, dict) or not row.get("key"):
continue
key = str(row["key"])
out[key] = {
"key": key,
"name_loc": row.get("name_loc") or row.get("dname") or key,
"cost": row.get("cost"),
"desc_loc": row.get("desc_loc") or "",
"tags": list(row.get("tags") or []),
}
return out
def load_item_shop() -> dict:
"""Full shop catalog for the Items page (see fetch_item_shop.py)."""
empty = {
"meta": {},
"basic": {"sections": []},
"upgraded": {"sections": []},
"items": {},
}
if not ITEM_SHOP_PATH.is_file():
return empty
try:
raw = json.loads(ITEM_SHOP_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return empty
items = dict(raw.get("items") or {})
# Merge mechanism tags / short desc from items_meta when present.
if ITEMS_META_PATH.is_file():
try:
meta = json.loads(ITEMS_META_PATH.read_text(encoding="utf-8"))
by_key = {}
for row in (meta.get("items") or {}).values():
if isinstance(row, dict) and row.get("key"):
by_key[row["key"]] = row
for key, row in items.items():
m = by_key.get(key)
if not m:
continue
row = dict(row)
row["tags"] = list(m.get("tags") or [])
row["desc_loc"] = m.get("desc_loc") or ""
items[key] = row
except (OSError, json.JSONDecodeError):
pass
return {
"meta": dict(raw.get("meta") or {}),
"basic": dict(raw.get("basic") or {"sections": []}),
"upgraded": dict(raw.get("upgraded") or {"sections": []}),
"items": items,
}
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.
"""
empty = {"patches": [], "lookup": {}, "details": {}}
if not PATCHES_PATH.is_file():
return empty
try:
raw = json.loads(PATCHES_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return empty
return {
"patches": list(raw.get("patches") or []),
"lookup": dict(raw.get("lookup") or {}),
"details": dict(raw.get("details") or {}),
}
def load_hero_abilities() -> dict:
"""Slim per-hero abilities / Aghs upgrades / talents for the preview pane."""
empty = {"meta": {}, "by_hero": {}}
if not HERO_ABILITIES_PATH.is_file():
return empty
try:
raw = json.loads(HERO_ABILITIES_PATH.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return empty
by_hero: dict[str, dict] = {}
for key, cell in (raw.get("by_hero") or {}).items():
if not isinstance(cell, dict):
continue
abs_out = []
for ab in cell.get("abilities") or []:
if not isinstance(ab, dict) or not ab.get("key"):
continue
specials = [
{"label": str(s["label"]), "value": str(s["value"])}
for s in (ab.get("specials") or [])
if isinstance(s, dict) and s.get("label") and s.get("value")
]
abs_out.append(
{
"key": ab["key"],
"name_loc": ab.get("name_loc") or ab["key"],
"desc_loc": ab.get("desc_loc") or "",
"shard_loc": ab.get("shard_loc") or "",
"scepter_loc": ab.get("scepter_loc") or "",
"has_shard": bool(ab.get("has_shard")),
"has_scepter": bool(ab.get("has_scepter")),
"granted_by_shard": bool(ab.get("granted_by_shard")),
"granted_by_scepter": bool(ab.get("granted_by_scepter")),
"dispellable": ab.get("dispellable") or "none",
"is_innate": bool(ab.get("is_innate")),
"target_label": ab.get("target_label") or "",
"affects_label": ab.get("affects_label") or "",
"damage_label": ab.get("damage_label") or "",
"immunity_label": ab.get("immunity_label") or "",
"cast_range": ab.get("cast_range") or "",
"cast_point": ab.get("cast_point") or "",
"channel_time": ab.get("channel_time") or "",
"cooldown": ab.get("cooldown") or "",
"mana_cost": ab.get("mana_cost") or "",
"specials": specials,
"lore_loc": ab.get("lore_loc") or "",
}
)
talents_out = []
for tal in cell.get("talents") or []:
if not isinstance(tal, dict) or not tal.get("key"):
continue
talents_out.append(
{
"key": tal["key"],
"name_loc": tal.get("name_loc") or tal["key"],
"level": int(tal.get("level") or 0),
"side": tal.get("side") or "left",
}
)
by_hero[str(key)] = {
"abilities": abs_out,
"talents": talents_out,
}
return {
"meta": dict(raw.get("meta") or {}),
"by_hero": by_hero,
}
def ensure_ability_icon(key: str) -> Path | None:
"""Return local ability icon path, downloading from Steam CDN on miss.
``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.
"""
if not key or "/" in key or "\\" in key or ".." in key:
return None
ABILITY_ICONS.mkdir(parents=True, exist_ok=True)
dest = ABILITY_ICONS / f"{key}.png"
if dest.is_file() and dest.stat().st_size >= 32:
return dest
if key in (INNATE_ICON_NAME, TALENT_TREE_ICON_NAME):
return None
try:
data = http_bytes(ABILITY_ICON_URL.format(key=key), timeout=30)
if not data or len(data) < 32:
return None
dest.write_bytes(data)
return dest
except (urllib.error.URLError, TimeoutError, OSError):
return None
def load_grid_order() -> dict[str, list[str]]:
"""Column membership + order for the pick grid (see data/hero_grid_order.json)."""
if not GRID_ORDER_PATH.is_file():
return {}
raw = json.loads(GRID_ORDER_PATH.read_text(encoding="utf-8"))
out: dict[str, list[str]] = {}
for attr in ATTR_ORDER:
keys = raw.get(attr) or []
if isinstance(keys, list):
out[attr] = [str(k) for k in keys if isinstance(k, str) and not k.startswith("_")]
return out
def build_payload() -> dict:
heroes = hero_table()
by_key = {}
slim = []
for h in heroes:
roles = list(h.get("roles") or [])
tags = list(h.get("tags") or []) or tags_for_hero(h["key"], roles)
row = {
"id": int(h["id"]),
"key": h["key"],
"name": h.get("name") or h["key"],
"name_loc": h.get("name_loc") or h["key"],
"aliases": list(h.get("aliases") or []),
"abbr": [str(a).lower() for a in (h.get("abbr") or []) if str(a).strip()],
"attr": h.get("attr") or "all",
"roles": roles,
"tags": tags,
}
# Level-1 strip + combat stats (from OpenDota via fetch_cdn_templates.py).
for key in (
"base_str",
"str_gain",
"base_agi",
"agi_gain",
"base_int",
"int_gain",
"health",
"mana",
"health_regen",
"mana_regen",
"armor",
"damage_min",
"damage_max",
"move_speed",
"attack_range",
"attack_rate",
"projectile_speed",
"magic_resist",
"turn_rate",
"vision_day",
"vision_night",
):
if key in h and h[key] is not None:
row[key] = h[key]
slim.append(row)
by_key[row["key"]] = row
grid = load_grid_order()
by_attr: dict[str, list[dict]] = {a: [] for a in ATTR_ORDER}
placed: set[str] = set()
for attr in ATTR_ORDER:
for key in grid.get(attr) or []:
row = by_key.get(key)
if row is None or key in placed:
continue
by_attr[attr].append(row)
placed.add(key)
# Heroes missing from the order file → append under their primary attr.
rest = [r for r in slim if r["key"] not in placed]
rest.sort(key=lambda r: r["id"])
for row in rest:
attr = row["attr"] if row["attr"] in by_attr else "all"
by_attr[attr].append(row)
slim_ordered: list[dict] = []
for attr in ATTR_ORDER:
slim_ordered.extend(by_attr[attr])
rel = load_relations()
items = load_hero_items()
fears = load_hero_item_fears()
abilities = load_hero_abilities()
shop = load_item_shop()
items_meta = load_items_meta_index()
patches_data = load_patches()
# Attach craft graph from shop catalog when available.
for key, row in (shop.get("items") or {}).items():
if not isinstance(row, dict):
continue
cell = items_meta.setdefault(key, {"key": key})
cell.setdefault("name_loc", row.get("name_loc") or key)
if row.get("cost") is not None:
cell["cost"] = row.get("cost")
if row.get("desc_loc"):
cell["desc_loc"] = row.get("desc_loc")
cell["components"] = list(row.get("components") or [])
cell["builds_into"] = list(row.get("builds_into") or [])
return {
"heroes": slim_ordered,
"by_attr": by_attr,
"attr_order": list(ATTR_ORDER),
"attr_cols": ATTR_COLS,
"attr_labels": ATTR_LABELS,
"tag_order": list(TAG_ORDER),
"relations": rel,
"hero_items": items,
"hero_item_fears": fears,
"hero_abilities": abilities,
"item_shop": shop,
"items_meta": items_meta,
"patches": patches_data.get("patches") or [],
"patch_lookup": patches_data.get("lookup") or {},
"patch_details": patches_data.get("details") or {},
"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_item_fears_path": str(HERO_ITEM_FEARS_PATH.relative_to(ROOT)).replace(
"\\", "/"
),
"hero_abilities_path": str(HERO_ABILITIES_PATH.relative_to(ROOT)).replace(
"\\", "/"
),
"item_shop_path": str(ITEM_SHOP_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 {}),
"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 {}),
},
}
class Handler(BaseHTTPRequestHandler):
server_version = "ClimperorRelations/2.0"
def log_message(self, fmt: str, *args) -> None:
print(f"[relations] {self.address_string()} {fmt % args}")
def _send(self, code: int, body: bytes, content_type: str) -> None:
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
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")
def do_GET(self) -> None: # noqa: N802
path = urlparse(self.path).path
if path in ("/", "/index.html"):
index = WEB_DIR / "index.html"
if not index.is_file():
self._json(500, {"error": "web/relations/index.html missing"})
return
self._send(200, index.read_bytes(), "text/html; charset=utf-8")
return
if path in ("/api/data", "/data.json"):
self._json(200, build_payload())
return
if path.startswith("/attr/"):
key = path[len("/attr/") :]
if key not in ("str.png", "agi.png", "int.png", "all.png"):
self._json(400, {"error": "bad attr icon"})
return
fpath = ATTR_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",):
self._json(400, {"error": "bad ui icon"})
return
fpath = UI_ICONS / key
if not fpath.is_file():
self.send_error(404)
return
self._send(200, fpath.read_bytes(), "image/png")
return
if path.startswith("/portrait/") or path.startswith("/cdn/"):
prefix = "/portrait/" if path.startswith("/portrait/") else "/cdn/"
key = path[len(prefix) :]
if "/" in key or "\\" in key or not key.endswith(".png"):
self._json(400, {"error": "bad path"})
return
# Prefer official Heroes-page cards; fall back to match templates.
fpath = HERO_PORTRAITS / key
if not fpath.is_file():
fpath = TEMPLATES_CDN / key
if not fpath.is_file():
self.send_error(404)
return
self._send(200, fpath.read_bytes(), "image/png")
return
if path.startswith("/item-cat/"):
key = path[len("/item-cat/") :]
if "/" in key or "\\" in key or not key.endswith(".png"):
self._json(400, {"error": "bad path"})
return
if not key.startswith("itemcat_"):
self._json(400, {"error": "bad path"})
return
fpath = ITEM_CAT_ICONS / key
if not fpath.is_file():
self.send_error(404)
return
self._send(200, fpath.read_bytes(), "image/png")
return
if path.startswith("/item/"):
key = path[len("/item/") :]
if "/" in key or "\\" in key or not key.endswith(".png"):
self._json(400, {"error": "bad path"})
return
fpath = ITEM_ICONS / key
# All recipe scrolls share recipe.png on Steam CDN.
if not fpath.is_file() and key.startswith("recipe_"):
fpath = ITEM_ICONS / "recipe.png"
if not fpath.is_file():
self.send_error(404)
return
self._send(200, fpath.read_bytes(), "image/png")
return
if path.startswith("/ability/"):
key = path[len("/ability/") :]
if "/" in key or "\\" in key or not key.endswith(".png"):
self._json(400, {"error": "bad path"})
return
stem = key[: -len(".png")]
fpath = ensure_ability_icon(stem)
if fpath is None:
self.send_error(404)
return
self._send(200, fpath.read_bytes(), "image/png")
return
if path.startswith("/ability-video/"):
rel_path = path[len("/ability-video/") :]
parts = rel_path.split("/")
if len(parts) != 2:
self._json(400, {"error": "bad path"})
return
hero, fname = parts
if (
not hero
or ".." in hero
or "\\" in hero
or ".." in fname
or "\\" in fname
):
self._json(400, {"error": "bad path"})
return
if fname.endswith(".webm"):
ctype = "video/webm"
elif fname.endswith(".mp4"):
ctype = "video/mp4"
else:
self._json(400, {"error": "bad path"})
return
fpath = ABILITY_VIDEOS / hero / fname
if not fpath.is_file():
self.send_error(404)
return
self._send(200, fpath.read_bytes(), ctype)
return
rel = path.lstrip("/")
candidate = (WEB_DIR / rel).resolve()
if not str(candidate).startswith(str(WEB_DIR.resolve())) or not candidate.is_file():
self.send_error(404)
return
ctype = mimetypes.guess_type(str(candidate))[0] or "application/octet-stream"
self._send(200, candidate.read_bytes(), ctype)
def main() -> None:
ap = argparse.ArgumentParser(description="Preview qualitative hero relations (read-only)")
ap.add_argument("--port", type=int, default=8765)
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--no-browser", action="store_true")
args = ap.parse_args()
if not (WEB_DIR / "index.html").is_file():
raise SystemExit(f"missing UI: {WEB_DIR / 'index.html'}")
if not TEMPLATES_CDN.is_dir():
raise SystemExit("templates/cdn missing — run python fetch_cdn_templates.py")
httpd = ThreadingHTTPServer((args.host, args.port), Handler)
url = f"http://{args.host}:{args.port}/"
print(f"relations preview: {url}")
print(f"edit JSON then refresh: {DEFAULT_RELATIONS}")
if not args.no_browser:
try:
webbrowser.open(url)
except Exception: # noqa: BLE001
pass
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nstopped")
httpd.server_close()
if __name__ == "__main__":
main()
+1918
View File
File diff suppressed because it is too large Load Diff
+60
View File
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>上分帝</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<header class="topbar">
<nav class="main-tabs" aria-label="主分页">
<button type="button" class="main-tab active" data-page="heroes">英雄</button>
<button type="button" class="main-tab" data-page="items">物品</button>
<button type="button" class="main-tab" data-page="patches">版本</button>
</nav>
<div id="heroes-toolbar" class="toolbar">
<div class="tagbar" id="tagbar" aria-label="定位筛选"></div>
<input id="q" class="search" type="search" placeholder="搜索英雄或别名" autocomplete="off" spellcheck="false" aria-label="搜索英雄" />
</div>
<div id="items-toolbar" class="toolbar hidden">
<div class="item-tools">
<input id="q-item" class="search search-inline" type="search" placeholder="搜索物品" autocomplete="off" spellcheck="false" aria-label="搜索物品" />
</div>
</div>
</header>
<main id="heroes-view" class="board">
<div class="columns" id="columns"></div>
</main>
<main id="items-view" class="board items-board hidden">
<div class="items-cluster">
<div class="items-main">
<div class="shop-col-headers" aria-hidden="true">
<span class="shop-col-header basic">基础分类</span>
<span class="shop-col-header upgraded">合成分类</span>
</div>
<div id="item-shop" class="item-shop" aria-live="polite"></div>
</div>
<aside id="item-detail-box" class="item-detail-box empty" aria-live="polite">
点击物品查看详情与合成
</aside>
</div>
</main>
<main id="patches-view" class="board patches-board hidden">
<div class="patches-header">
<h2 class="patches-title">游戏性更新 <span class="patches-ver" id="patch-ver"></span></h2>
<select id="patch-select" aria-label="选择版本"></select>
</div>
<div class="patches-detail" id="patches-detail"></div>
</main>
<section class="detail" id="detail" aria-live="polite"></section>
<script src="/app.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff