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)
@@ -0,0 +1,2 @@
|
||||
# Copy to .env (gitignored). Get a free token at https://stratz.com/api
|
||||
STRATZ_API_TOKEN=
|
||||
@@ -13,3 +13,25 @@ preview/
|
||||
results/
|
||||
failures/
|
||||
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/
|
||||
|
||||
@@ -19,21 +19,63 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
|
||||
| `recognize.py` | 单帧识别;`recognize_image()` 供会话复用 |
|
||||
| `draft_session.py` | 整局选将跟踪、改判、皮肤规避策略 |
|
||||
| `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 显式 flag,Valve 移除升级后残留的 `scepter_loc`/`shard_loc` 文案会被清空;逐级相同的 `cast_points`/`channel_times` 合并为单值(施法前摇/吟唱时间);可选 `--icons` / `--icons-only` 缓存 Steam CDN 技能图标 → `assets/ability_icons/`(先天用共用 `innate.png`,不拉 CDN) |
|
||||
| `fetch_ability_videos.py` | 官网技能演示 webm/mp4(Steam CDN;限速)→ `assets/ability_videos/`;A 杖/魔晶赋予技能(`granted_by_scepter`/`granted_by_shard`)CDN 名用 `<hero>_aghanims_scepter`/`<hero>_aghanims_shard`,本地文件名仍用 ability key |
|
||||
| `item_fears.py` | 规则推导「英雄怕的装备」→ `data/hero_item_fears.json` |
|
||||
| `autocalibrate.py` / `calibrate.py` | ROI 自动 / 手动标定 |
|
||||
| `capture.py` | 屏幕捕获 |
|
||||
| `overlay.py` | 顶栏角色标签 + 选将网格左上角青标 Top-3(点击穿透) |
|
||||
| `evaluate.py` | 按 `samples/labels.json` 批量评测 |
|
||||
| `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/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`):
|
||||
|
||||
- `samples/raw/<matchid>/` — GSI 会话截图与 `gsi.jsonl`;手动 `capture.py` 可写在 `raw/` 根下
|
||||
- `preview/` — 标定 / sheet 预览
|
||||
- `dist/` — 静态站点导出(`export_relations_site.py`)
|
||||
- `results/` — 每局 JSON
|
||||
- `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(可关)
|
||||
→ DraftSession 轮询截屏 → samples/raw/<matchid>/
|
||||
→ recognize_image (CDN 模板 + 可选天梯遮罩)
|
||||
→ roles / grid / modes(辅助)
|
||||
→ roles(分路字)/ grid(禁用 + 单元格)/ modes
|
||||
→ recommend Top-3(data/relations.json 定性边,本人槽位只信 GSI)
|
||||
→ overlay 角色标签 + 网格青标(可选)
|
||||
→ results/draft_*.json + 终端时间线
|
||||
```
|
||||
|
||||
## 技术约束(修改前必读)
|
||||
|
||||
- **合规**:仅 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 双层库。
|
||||
- **顶栏时机**:皮肤在**全员选完后**才上顶栏;本机进决策时别人可能还在选——须保持 `strategy_tail` 视觉,禁止「一进 STRATEGY 就永久停读」。
|
||||
- **坐标**:一律相对坐标(相对屏幕高 / 相对中心),勿写死像素分辨率。
|
||||
@@ -61,6 +107,18 @@ Dota 2 GSI → gsi_watch.py (:3223)
|
||||
```powershell
|
||||
pip install -r requirements.txt
|
||||
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_watch.py --once
|
||||
python recognize.py samples/raw/<帧>.png --sheet
|
||||
@@ -73,7 +131,7 @@ python evaluate.py
|
||||
- 代码标识符、日志、错误信息、注释用**英文**;用户可见摘要可用中文。
|
||||
- 改匹配阈值或裁切时,用 `evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `DESIGN.md`。
|
||||
- 改 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`。
|
||||
|
||||
## 文档分工
|
||||
|
||||
@@ -2,17 +2,171 @@
|
||||
|
||||
本文件记录 **上分帝(Climperor)** 的用户可见变更。格式参考 [Keep a Changelog](https://keepachangelog.com/)。
|
||||
|
||||
## [Unreleased]
|
||||
## [0.3.0] - 2026-07-27
|
||||
|
||||
### 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.dump_payloads` / `--dump-gsi` / `--no-dump-gsi`)。
|
||||
- 前台窗口检测:`gsi.require_foreground`(默认 true)。选将跟踪时若前台不是
|
||||
`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
|
||||
|
||||
- 关系预览英雄详情(`#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` 迁出独立仓库。
|
||||
- **放弃 real 实拍模板库**:删除 `build_library.py`、`templates/real/`;匹配仅使用 CDN 层。
|
||||
- 移除 `match.cdn_penalty` 与 `evaluate.py --cdn-only`(双层库对比已无意义)。
|
||||
@@ -22,14 +176,38 @@
|
||||
- 文档拆分为 `README`(操作)、`DESIGN`(决策)、`AGENTS`(协作)、`CHANGELOG`(变更)。
|
||||
- `DESIGN.md` / `AGENTS.md` 补录原型会话结论:顶栏皮肤时机、决策禁改判原因、
|
||||
real 库审计结论、无法靠 GSI 卡「皮肤前」单帧等。
|
||||
- 本人顶栏槽位只使用 GSI `team_slot`;移除截屏名字亮度识别「自己是第几格」。
|
||||
|
||||
### Removed
|
||||
|
||||
- real 库补充流水线与相关验收指标。
|
||||
- 仓库内历史 `preview/`、`results/` 调试产物。
|
||||
- `roles._detect_self` 视觉认槽位备用路径。
|
||||
|
||||
### 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 即可高准确率。
|
||||
- (继承自原型)天梯段位条 / 勋章遮罩匹配;选人多帧 + 决策禁改判规避皮肤;
|
||||
best 帧优先选人阶段;本人英雄以 GSI 覆盖。
|
||||
|
||||
@@ -122,7 +122,8 @@ GSI 不含可靠 lobby 类型时,用右下金色勋章检测(`has_ranked_ove
|
||||
### 发现五:禁用名单不靠模板
|
||||
|
||||
网格按主属性分块 + 客户端本地化名行优先排布;禁用/已选卡片对比度塌陷
|
||||
(std 约 8–21 vs 正常 ≥33)。`heroes.json` 需含 `attr` / `name_loc`。
|
||||
(std 约 8–21 vs 正常 ≥33)。`data/heroes.json` 需含 `attr` / `name_loc`;
|
||||
另含手工维护的中文口语 `aliases`(重拉 CDN 表时合并保留,暂未接入推荐展示)。
|
||||
格数对不上时 `ok=False`,不返回残缺名单。
|
||||
|
||||
### 发现六:位置与「我」
|
||||
@@ -157,13 +158,20 @@ GSI 不含可靠 lobby 类型时,用右下金色勋章检测(`has_ranked_ove
|
||||
|
||||
```
|
||||
climperor/
|
||||
├── config.json / heroes.json
|
||||
├── config.json
|
||||
├── data/ # heroes / relations / items / abilities / fears …
|
||||
├── common.py / recognize.py / draft_session.py
|
||||
├── gsi_watch.py / gsi_setup.py
|
||||
├── fetch_cdn_templates.py
|
||||
├── autocalibrate.py / calibrate.py / capture.py
|
||||
├── evaluate.py / roles.py / grid.py / modes.py
|
||||
├── gsi_watch.py / gsi_setup.py / overlay.py
|
||||
├── recommend.py / relations.py / roles.py / grid.py / modes.py
|
||||
├── http_utils.py / loc_format.py / hero_tags.py / item_fears.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/
|
||||
├── assets/ # portraits / icons / videos(部分 gitignore)
|
||||
└── samples/labels.json # 评测真值;raw/<matchid>/ 见 .gitignore
|
||||
```
|
||||
|
||||
@@ -182,6 +190,14 @@ climperor/
|
||||
| `gsi.strategy_gsi_wait` | 视觉结束后等待本人 GSI 英雄 | 3.0 |
|
||||
| `gsi.require_foreground` | 仅当前台为 `dota2.exe` 时截屏识别 | true |
|
||||
| `gsi.dump_payloads` | 全量 GSI JSONL 落盘 | true |
|
||||
| `recommend.enabled` | 选将 Top-3 推荐 | true |
|
||||
| `recommend.top_n` | 推荐数量 | 3 |
|
||||
| `recommend.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 回传。
|
||||
合规同 `dota2-hex`:**屏幕可见信息 only**。
|
||||
|
||||
---
|
||||
|
||||
## 7. 参考
|
||||
## 8. 参考
|
||||
|
||||
- [Valve #9562](https://github.com/ValveSoftware/Dota2-Gameplay/issues/9562)
|
||||
- [Valve #14915](https://github.com/ValveSoftware/Dota2-Gameplay/issues/14915)(开发者 OCR ≈85%)
|
||||
|
||||
@@ -22,9 +22,32 @@ Dota 2 **天梯选将识别**:从选人 / 决策时间截图中识别双方 10
|
||||
|
||||
```powershell
|
||||
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 自动跟踪)
|
||||
|
||||
```powershell
|
||||
@@ -33,7 +56,7 @@ python gsi_setup.py # 写入 Dota GSI 配置
|
||||
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` 可关)。
|
||||
|
||||
> 主菜单收不到 GSI 是正常的:客户端**第一次载入对局后**才开始推送。看到 `[gsi] connected` 才算链路通。
|
||||
@@ -78,7 +101,7 @@ python evaluate.py # 按 samples/labels.json 批量评测
|
||||
|
||||
- 宽高比差异大(如 21:9)时可能需重标定
|
||||
- 客户端改网格排序后 `grid.py` 会 `ok=False`,不会瞎报禁用名单
|
||||
- 新英雄上线后重跑 `fetch_cdn_templates.py`
|
||||
- 新英雄上线后重跑 `fetch_cdn_templates.py`;版本更新后重跑 `fetch_patches.py`
|
||||
- 位置文字模板取自简体中文客户端
|
||||
- 决策界面 3D 模型挡住顶栏时单帧会很弱,依赖会话多帧回填
|
||||
- 普通玩家 GSI 只有自己的英雄 id,不能代替读顶栏
|
||||
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 444 B |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -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()
|
||||
@@ -8,7 +8,23 @@ import numpy as np
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
CONFIG_PATH = ROOT / "config.json"
|
||||
DATA = ROOT / "data"
|
||||
HEROES_JSON = DATA / "heroes.json"
|
||||
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:
|
||||
|
||||
@@ -98,10 +98,21 @@
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"comment": "min_iou gates role-label matching; self_* detect your own white name.",
|
||||
"min_iou": 0.55,
|
||||
"self_min_value": 195.0,
|
||||
"self_min_gap": 25.0
|
||||
"comment": "min_iou gates role-label matching. Own slot comes from GSI team_slot.",
|
||||
"min_iou": 0.55
|
||||
},
|
||||
"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": {
|
||||
"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,
|
||||
"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"
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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"] }
|
||||
}
|
||||
}
|
||||
@@ -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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 捕捞速度"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 modes import detect_mode, load_mode_templates
|
||||
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
|
||||
|
||||
HERO_SELECTION = "DOTA_GAMERULES_STATE_HERO_SELECTION"
|
||||
@@ -55,10 +56,11 @@ def pick_round(per_team_max: int) -> int:
|
||||
|
||||
|
||||
class DraftSession:
|
||||
def __init__(self, cfg: dict, library, log=print):
|
||||
def __init__(self, cfg: dict, library, log=print, overlay=None):
|
||||
self.cfg = cfg
|
||||
self.library = library
|
||||
self.log = log
|
||||
self.overlay = overlay
|
||||
g = cfg.get("gsi", {})
|
||||
self.poll_interval = g.get("poll_interval", 1.0)
|
||||
self.confirm_polls = g.get("confirm_polls", 2)
|
||||
@@ -80,11 +82,48 @@ class DraftSession:
|
||||
self.mode_templates = load_mode_templates()
|
||||
self.hero_names = {h["key"]: h["name_loc"] for h in hero_table()}
|
||||
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:
|
||||
"""Poll until the draft is over. state_fn returns the live GSI state."""
|
||||
self.frame_dir = raw_dir_for_match(match_id)
|
||||
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()
|
||||
pending: dict[int, tuple[str, int]] = {}
|
||||
confirmed: dict[int, str] = {}
|
||||
@@ -96,7 +135,9 @@ class DraftSession:
|
||||
"self_team": None,
|
||||
"roles": {},
|
||||
"unavailable": None,
|
||||
"cells": None,
|
||||
"mode": None,
|
||||
"recommendations": [],
|
||||
}
|
||||
polls = 0
|
||||
last_frame = None
|
||||
@@ -142,6 +183,7 @@ class DraftSession:
|
||||
frame = grab_frame(sct)
|
||||
last_frame = frame
|
||||
polls += 1
|
||||
self._sync_self_from_gsi(info, gsi_fn)
|
||||
|
||||
# Keep reading in strategy until the roster is full - the last
|
||||
# pick is often revealed on the same tick selection ends, and
|
||||
@@ -179,12 +221,16 @@ class DraftSession:
|
||||
rev["t"] = round(elapsed, 1)
|
||||
revisions.append(rev)
|
||||
self._log_revision(rev)
|
||||
if added or revised:
|
||||
self._push_overlay(confirmed)
|
||||
if added:
|
||||
event = self._event(added, confirmed, state, elapsed)
|
||||
if self.keep_frames:
|
||||
event["frame"] = save_frame(frame, self.frame_dir, prefix="draft")
|
||||
timeline.append(event)
|
||||
self._log_event(event, info)
|
||||
if state == HERO_SELECTION:
|
||||
self._refresh_recommendations(confirmed, info, gsi_fn)
|
||||
|
||||
n = len(confirmed)
|
||||
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:
|
||||
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
|
||||
return self._summary(match_id, timeline, confirmed, info, polls, started, keep,
|
||||
gsi_fn, revisions, best)
|
||||
@@ -216,6 +270,7 @@ class DraftSession:
|
||||
self.log(f"[draft] best lineup frame: {best['recognized']}/10 "
|
||||
f"at t={best['t']:.1f}s ({best['state']})")
|
||||
filled = self._backfill(confirmed, scores, best)
|
||||
self._push_overlay(confirmed)
|
||||
if filled:
|
||||
self.log(f"[draft] backfilled {filled} slots from best frame")
|
||||
if self.keep_frames:
|
||||
@@ -267,35 +322,93 @@ class DraftSession:
|
||||
return cand
|
||||
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:
|
||||
"""Roles and your own slot never change, so stop looking once found."""
|
||||
if info["self_slot"] is not None and info["roles"]:
|
||||
"""Lane labels never change mid-draft; stop scanning once found."""
|
||||
if info["roles"]:
|
||||
return
|
||||
found = detect_roles(frame, self.cfg, self.role_templates)
|
||||
if found["self_slot"] is not None and info["self_slot"] is None:
|
||||
info["self_slot"] = found["self_slot"]
|
||||
if found["roles"] and not info["roles"]:
|
||||
if 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:
|
||||
"""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
|
||||
earliest readable frame is also the cleanest: nothing has been taken
|
||||
yet, so everything greyed out is a ban. Frames where a hover tooltip
|
||||
covers the grid fail the layout check and are simply skipped.
|
||||
earliest readable frame is also the cleanest. Cell geometry is
|
||||
refreshed so recommend badges stay aligned while the grid is up.
|
||||
"""
|
||||
if info["unavailable"] is not None:
|
||||
return
|
||||
res = read_grid(frame, self.cfg)
|
||||
if not res["ok"]:
|
||||
return
|
||||
if res.get("cells"):
|
||||
info["cells"] = res["cells"]
|
||||
if info["unavailable"] is not None:
|
||||
return
|
||||
info["unavailable"] = 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 "
|
||||
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,
|
||||
scores: dict, *, allow_revise: bool = True,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
@@ -381,8 +494,6 @@ class DraftSession:
|
||||
revisions=None, best=None) -> dict:
|
||||
gsi = gsi_fn() if gsi_fn else {}
|
||||
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_hero = gsi.get("hero")
|
||||
@@ -395,6 +506,9 @@ class DraftSession:
|
||||
)
|
||||
|
||||
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 = {
|
||||
"match_id": match_id,
|
||||
"captured_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
@@ -409,6 +523,8 @@ class DraftSession:
|
||||
"role_label": role["label"] if role else None,
|
||||
"position": role["position"] if role else None,
|
||||
"gsi_name": gsi.get("name"),
|
||||
"accountid": gsi.get("accountid"),
|
||||
"steamid": gsi.get("steamid"),
|
||||
},
|
||||
"team_roles": {
|
||||
str(s): {"position": r["position"], "label": r["label"], "hero": confirmed.get(s)}
|
||||
@@ -421,6 +537,12 @@ class DraftSession:
|
||||
"recognized": len(confirmed),
|
||||
"revisions": revisions or [],
|
||||
"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:
|
||||
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")
|
||||
if summary.get("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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -4,8 +4,10 @@ Usage:
|
||||
python fetch_cdn_templates.py
|
||||
|
||||
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
|
||||
|
||||
Preserves manually curated `aliases` / `abbr` from an existing heroes.json on rewrite.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -13,17 +15,24 @@ from pathlib import Path
|
||||
|
||||
import cv2
|
||||
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
|
||||
# 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}"
|
||||
# Steam CDN landscape hero art; cropped to a face window for top-bar matching.
|
||||
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"}
|
||||
|
||||
# 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
|
||||
# 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
|
||||
@@ -31,12 +40,114 @@ ATTRS = {0: "str", 1: "agi", 2: "int", 3: "all"}
|
||||
CROP_X0, CROP_X1 = 38 / 256, (38 + 182) / 256
|
||||
|
||||
|
||||
def fetch_heroes(lang: str = "schinese") -> list[dict]:
|
||||
data = requests.get(HEROES_URL.format(lang=lang), timeout=30).json()
|
||||
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 _num(raw: dict, key: str, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(raw.get(key, default) or default)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
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:
|
||||
@@ -45,25 +156,38 @@ def main() -> None:
|
||||
TEMPLATES_CDN.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
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 = []
|
||||
ok, fail = 0, 0
|
||||
for h in heroes:
|
||||
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"],
|
||||
"key": key,
|
||||
"name": h["name_english_loc"],
|
||||
"attr": ATTRS.get(h["primary_attr"], "all"),
|
||||
"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"
|
||||
if out.exists():
|
||||
ok += 1
|
||||
continue
|
||||
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)
|
||||
if img is None:
|
||||
raise ValueError("decode failed")
|
||||
@@ -75,9 +199,11 @@ def main() -> None:
|
||||
print(f" FAILED {key}: {e}")
|
||||
fail += 1
|
||||
|
||||
with open(ROOT / "heroes.json", "w", encoding="utf-8") as f:
|
||||
json.dump(sorted(table, key=lambda t: t["id"]), f, ensure_ascii=False, indent=1)
|
||||
print(f"done: {ok} templates, {fail} failures, {len(table)} heroes in heroes.json")
|
||||
HEROES_JSON.write_text(
|
||||
json.dumps(sorted(table, key=lambda t: t["id"]), ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"done: {ok} templates, {fail} failures, {len(table)} heroes in {HEROES_JSON}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -22,15 +22,15 @@ import json
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from common import ROOT
|
||||
from common import HEROES_JSON
|
||||
|
||||
ATTR_ORDER = ("str", "agi", "int", "all")
|
||||
|
||||
|
||||
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]:
|
||||
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
|
||||
|
||||
|
||||
@@ -151,24 +151,30 @@ def read_grid(img: np.ndarray, cfg: dict | None = None) -> dict:
|
||||
table = hero_table()
|
||||
grid = detect_grid(img, cfg)
|
||||
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)
|
||||
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):
|
||||
return {"ok": False,
|
||||
"reason": f"placed {len(layout)} of {len(table)} heroes",
|
||||
"unavailable": []}
|
||||
"unavailable": [], "cells": {}}
|
||||
|
||||
cut = cfg.get("grid", {}).get("unavailable_std", 26.0)
|
||||
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])
|
||||
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 {
|
||||
"ok": True,
|
||||
"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,
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import mss
|
||||
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 draft_session import HERO_SELECTION, DraftSession, describe, gsi_slot, loc
|
||||
from overlay import DraftOverlay
|
||||
from recognize import recognize_image
|
||||
from roles import detect_roles
|
||||
|
||||
@@ -68,6 +69,15 @@ class Watcher:
|
||||
self.dump_lock = threading.Lock()
|
||||
self.self_info: dict = {}
|
||||
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:
|
||||
if not self.connected:
|
||||
@@ -82,6 +92,8 @@ class Watcher:
|
||||
"team": p.get("team_name"),
|
||||
"team_slot": p.get("team_slot"),
|
||||
"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")
|
||||
@@ -142,9 +154,9 @@ class Watcher:
|
||||
|
||||
late = " (joined late)" if state != HERO_SELECTION else ""
|
||||
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)
|
||||
if summary["recognized"] == 0:
|
||||
if not summary or summary["recognized"] == 0:
|
||||
print("[draft] nothing recognized - no result written", flush=True)
|
||||
return None
|
||||
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"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)
|
||||
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:
|
||||
role = found["roles"].get(slot)
|
||||
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
|
||||
return result
|
||||
|
||||
@@ -306,6 +321,8 @@ def main() -> None:
|
||||
except KeyboardInterrupt:
|
||||
print("\nstopped.")
|
||||
finally:
|
||||
if watcher.overlay is not None:
|
||||
watcher.overlay.close()
|
||||
server.server_close()
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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": "朗戈"
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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_]+\}")
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,4 +1,4 @@
|
||||
opencv-python>=4.10
|
||||
numpy>=2.0
|
||||
requests>=2.32
|
||||
mss>=9.0
|
||||
openpyxl>=3.1
|
||||
|
||||
@@ -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 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: 优势路 / 中路 / 劣势路 / 辅助 / 纯辅助.
|
||||
Row under each portrait (role-queue only, own team): 优势路 / 中路 / 劣势路 /
|
||||
辅助 / 纯辅助. Your own slot index comes from GSI team_slot, not from name tint.
|
||||
|
||||
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
|
||||
@@ -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:
|
||||
"""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)
|
||||
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),
|
||||
}
|
||||
|
||||
self_slot = _detect_self(img, cfg)
|
||||
self_team = None
|
||||
if roles:
|
||||
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}
|
||||
|
||||
|
||||
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]
|
||||
return {"self_team": self_team, "roles": roles}
|
||||
|
||||
|
||||
def _main() -> None:
|
||||
|
||||
@@ -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()
|
||||
@@ -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>
|
||||