v0.5.109: hero role toolbar, skill side-by-side layout, taller detail panel.

Move role filters between grid and detail, fix skill video/text layout and detail height jump, unify item dividers, drop OpenDota matchup cross, sync docs and assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-30 00:48:07 +08:00
co-authored by Cursor
parent 09f0a0b4a0
commit d6212169af
38 changed files with 7225 additions and 6924 deletions
-1
View File
@@ -24,7 +24,6 @@ plans/
*_log.txt *_log.txt
# Regenerable data caches / reports (re-fetch or re-run scripts) # Regenerable data caches / reports (re-fetch or re-run scripts)
shared/data/matchups.json
shared/data/synergies.json shared/data/synergies.json
shared/data/stratz_matchups.json shared/data/stratz_matchups.json
shared/data/relations_audit.json shared/data/relations_audit.json
+8 -7
View File
@@ -59,9 +59,7 @@ climperor/
| `shared/http_utils.py` | 所有 fetch 脚本共用的 HTTP 请求、图标下载、Valve datafeed 加载(统一 UA / 超时;429/5xx 指数退避重试) | | `shared/http_utils.py` | 所有 fetch 脚本共用的 HTTP 请求、图标下载、Valve datafeed 加载(统一 UA / 超时;429/5xx 指数退避重试) |
| `shared/import_relations_xlsx.py` | 从选将笔记 xlsx 种子导入 `shared/data/relations.json` | | `shared/import_relations_xlsx.py` | 从选将笔记 xlsx 种子导入 `shared/data/relations.json` |
| `shared/fetch_stratz.py` | 拉取 STRATZ 对位优势 / 搭档协同 → `shared/data/stratz_matchups.json` / `shared/data/synergies.json`(需 token`--mode matchups\|synergies`;供 `audit_relations.py` | | `shared/fetch_stratz.py` | 拉取 STRATZ 对位优势 / 搭档协同 → `shared/data/stratz_matchups.json` / `shared/data/synergies.json`(需 token`--mode matchups\|synergies`;供 `audit_relations.py` |
| `shared/fetch_matchups.py` | 拉取 OpenDota 对位优势 → `shared/data/matchups.json`(供 `audit_relations.py` 交叉审计 | | `shared/audit_relations.py` | 用 STRATZ 对位/搭档缓存审计 `relations.json`,列出缺失英雄与候选边(不改写 relations;Web 对位页只读 `stratz_matchup_tops.json` |
| `shared/audit_relations.py` | OpenDota+STRATZ 交叉审计 `relations.json`,列出缺失英雄;另含 Web 对位 Top × OpenDota 的 `web_matchup_tops_cross` 质量段(agree/conflict/weak;不改写 relations |
| `shared/matchup_cross.py` | Web 对位 Top 与 OpenDota 的基线校正交叉分类(供 audit / serve 富化;观测证据,不作机制结论) |
| `shared/data/heroes.json` | 英雄表(id/key/attr/roles/aliases/abbr/tags + 基础属性/初始血蓝等;由 `pc/fetch_cdn_templates.py` 生成) | | `shared/data/heroes.json` | 英雄表(id/key/attr/roles/aliases/abbr/tags + 基础属性/初始血蓝等;由 `pc/fetch_cdn_templates.py` 生成) |
| `shared/data/relations.json` | 定性克制边 + 搭档边(可提交;手改 JSON,Web 站点只读) | | `shared/data/relations.json` | 定性克制边 + 搭档边(可提交;手改 JSON,Web 站点只读) |
@@ -126,11 +124,12 @@ climperor/
| `web/frontend/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 `heroes\|rankings\|streamers\|matches\|trends\|mechanics\|items\|patches` / 英雄 + 子标签 `skills\|core\|fears\|trends\|matchups\|matches\|streamers\|patches` / Immortal 地区 `#/rankings[/region]` / 明星比赛 `#/matches[/account_id][?origin=pro\|china][&page=N]`(默认全部类型、每页 20 场) / 主播目录 `#/streamers` / 近 8 周走势榜 `#/trends[/bracket][?sort=pr]`(默认按胜率) / 机制查询 `#/mechanics[/{effect}]`(默认 `basic_dispel`) / 物品 / 版本 / 标签筛选 / 搜索;旧 `stats` / `#/rankings/meta` 别名兼容) | | `web/frontend/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 `heroes\|rankings\|streamers\|matches\|trends\|mechanics\|items\|patches` / 英雄 + 子标签 `skills\|core\|fears\|trends\|matchups\|matches\|streamers\|patches` / Immortal 地区 `#/rankings[/region]` / 明星比赛 `#/matches[/account_id][?origin=pro\|china][&page=N]`(默认全部类型、每页 20 场) / 主播目录 `#/streamers` / 近 8 周走势榜 `#/trends[/bracket][?sort=pr]`(默认按胜率) / 机制查询 `#/mechanics[/{effect}]`(默认 `basic_dispel`) / 物品 / 版本 / 标签筛选 / 搜索;旧 `stats` / `#/rankings/meta` 别名兼容) |
| `web/assets/hero_portraits/` | 官网横版头像(上分帝 Web;默认 wide 面部构图,非匹配模板) | | `web/assets/hero_portraits/` | 官网横版头像(上分帝 Web;默认 wide 面部构图,非匹配模板) |
| `web/assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,上分帝 Web 用) | | `web/assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,上分帝 Web 用) |
| `web/assets/role_icons/` | Valve 选人定位筛选图标(透明 PNG;英雄页定位栏;本地 `/role-icon/`,线上 OSS `role-icon/` |
| `web/assets/rank_icons/` | 天梯勋章图标(OpenDota `rank_icon_1..8` 先锋→冠绝;走势段位选择器) | | `web/assets/rank_icons/` | 天梯勋章图标(OpenDota `rank_icon_1..8` 先锋→冠绝;走势段位选择器) |
| `web/assets/item_icons/` | Steam CDN 装备图标(上分帝 Web常用装备) | | `web/assets/item_icons/` | Steam CDN 装备图标(上分帝 Web常用装备) |
| `web/assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) | | `web/assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) |
| `web/assets/ability_icons/` | Steam CDN 技能图标(上分帝 Web;按需缓存);`innate.png` 先天共用图标;`talent_tree.png` 天赋树触发图标 | | `web/assets/ability_icons/` | Steam CDN 技能图标(上分帝 Web;按需缓存);`innate.png` 先天共用图标;`talent_tree.png` 天赋树触发图标 |
| `web/assets/ui_icons/` | dota2.com.cn 通用 UI 图标(`cooldown.png`,技能详情底栏冷却图标)+ 平台 logo(`platform_douyin.png`,主播页 | | `web/assets/ui_icons/` | dota2.com.cn 通用 UI 图标(`cooldown.png``icon_damage.png` 等战斗属性图标;平台 logo |
| `web/assets/streamer_avatars/` | 主播头像缓存(由 `fetch_streamers.py` 写入;上分帝 Web「主播」) | | `web/assets/streamer_avatars/` | 主播头像缓存(由 `fetch_streamers.py` 写入;上分帝 Web「主播」) |
| `web/assets/streamer_videos/` | 主播精选高光 mp4 + 同名 JPG 封面(手工放入;上分帝 Web「主播」卡片;勿提交,线上走 OSS `streamer-video/` | | `web/assets/streamer_videos/` | 主播精选高光 mp4 + 同名 JPG 封面(手工放入;上分帝 Web「主播」卡片;勿提交,线上走 OSS `streamer-video/` |
| `web/assets/ability_videos/` | 官网技能演示视频(webm/mp4;勿提交,由脚本拉取;线上由阿里云 OSS `climperor``ability-video/` 前缀托管) | | `web/assets/ability_videos/` | 官网技能演示视频(webm/mp4;勿提交,由脚本拉取;线上由阿里云 OSS `climperor``ability-video/` 前缀托管) |
@@ -142,7 +141,7 @@ climperor/
- `web/dist/` — 静态站点导出(`export_relations_site.py` - `web/dist/` — 静态站点导出(`export_relations_site.py`
- `pc/results/` — 每局 JSON - `pc/results/` — 每局 JSON
- `pc/failures/``--truth` 调试用错识裁切 - `pc/failures/``--truth` 调试用错识裁切
- `shared/data/matchups.json` / `synergies.json` / `stratz_matchups.json` / `relations_audit.json` 等 — 可再生成缓存与报告 - `shared/data/synergies.json` / `stratz_matchups.json` / `relations_audit.json` 等 — 可再生成缓存与报告
- `web/data/hero_stats.json` / `hero_matches.json` / `stratz_*.json` 等 — 可再生成缓存 - `web/data/hero_stats.json` / `hero_matches.json` / `stratz_*.json` 等 — 可再生成缓存
- `web/assets/ability_videos/` — 官网技能演示(体积大,可再拉取) - `web/assets/ability_videos/` — 官网技能演示(体积大,可再拉取)
- `web/assets/streamer_videos/` — 主播精选高光(体积大,手工放入后 OSS 同步) - `web/assets/streamer_videos/` — 主播精选高光(体积大,手工放入后 OSS 同步)
@@ -219,7 +218,8 @@ python pc/evaluate.py
- 只改任务所需逻辑,避免无关重构与大范围格式化。 - 只改任务所需逻辑,避免无关重构与大范围格式化。
- 代码标识符、日志、错误信息、注释用**英文**;用户可见摘要可用中文。 - 代码标识符、日志、错误信息、注释用**英文**;用户可见摘要可用中文。
- 改匹配阈值或裁切时,用 `pc/evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `DESIGN.md` - 改匹配阈值或裁切时,用 `pc/evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `ARCHITECTURE.md`
- 改上分帝 Web 视觉(色板、字号、间距、圆角、组件态)时先对齐 `DESIGN.md` 令牌,再改 `web/frontend/style.css`;勿引入未入规范的硬编码尺度。
- 改 GSI cfg 时同步核对 `pc/gsi_setup.py` 与 Dota `gamestate_integration` 目录。 - 改 GSI cfg 时同步核对 `pc/gsi_setup.py` 与 Dota `gamestate_integration` 目录。
- 改 Web 路由形态(URL 段 / query 参数 / 默认值)时同步 `web/frontend/router.js``parseHash` / `serializeHash`)与 `app.js``applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。Hash 路由不命中后端,`web/serve_relations.py` 无需改;`web/export_relations_site.py` 的导出文件列表须含 `router.js` - 改 Web 路由形态(URL 段 / query 参数 / 默认值)时同步 `web/frontend/router.js``parseHash` / `serializeHash`)与 `app.js``applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。Hash 路由不命中后端,`web/serve_relations.py` 无需改;`web/export_relations_site.py` 的导出文件列表须含 `router.js`
-`shared/data/heroes.json` 结构时同步 `shared/grid.py`(依赖 `attr` / `name_loc`);`roles``pc/overlay.py` 使用;`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `pc/fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),上分帝 Web 筛选 + 局内 `draft_archetypes` 缺口/推进画像共用;基础属性/血蓝等数值字段供上分帝 Web 详情条,勿塞机制文案。 -`shared/data/heroes.json` 结构时同步 `shared/grid.py`(依赖 `attr` / `name_loc`);`roles``pc/overlay.py` 使用;`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `pc/fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),上分帝 Web 筛选 + 局内 `draft_archetypes` 缺口/推进画像共用;基础属性/血蓝等数值字段供上分帝 Web 详情条,勿塞机制文案。
@@ -231,7 +231,8 @@ python pc/evaluate.py
| 文件 | 内容 | | 文件 | 内容 |
|------|------| |------|------|
| `README.md` | 安装与操作手册 | | `README.md` | 安装与操作手册 |
| `DESIGN.md` | 背景、选型、关键实测与决策记录 | | `ARCHITECTURE.md` | 背景、选型、关键实测与决策记录 |
| `DESIGN.md` | 上分帝 Web 视觉设计规范(令牌 + 使用规则;[design.md](https://github.com/google-labs-code/design.md) 格式) |
| `AGENTS.md` | 本文件:给协作者 / AI 的约束 | | `AGENTS.md` | 本文件:给协作者 / AI 的约束 |
| `CHANGELOG.md` | 面向用户的版本变更 | | `CHANGELOG.md` | 面向用户的版本变更 |
+269
View File
@@ -0,0 +1,269 @@
# 上分帝(Climperor)—— 方案与实施细节
`README.md` 是操作手册;本文记录背景、选型与关键决策。协作约束见 `AGENTS.md`。上分帝 Web 视觉设计规范见 `DESIGN.md`
---
## 1. 背景与目标
### 要解决的问题
在 Dota 2 选将阶段(以及进入游戏后)**几秒内自动获取双方 10 个英雄**,服务天梯选将辅助或赛后分析。
### 为什么 GSI 做不到
| 场景 | GSI 可获得的阵容数据 |
|------|---------------------|
| 排位 / 普通 All Pick | 仅自己的 `hero.id``draft` 通常为空 |
| Captains Mode | 历史上有部分 pick/ban,不稳定 |
| 观战 / 裁判视角 | 阵容字段较全 |
| 赛后 | 需依赖 OpenDota 等外部 API |
Valve 已明确关闭普通玩家视角的实时 draft
[#9562](https://github.com/ValveSoftware/Dota2-Gameplay/issues/9562)、
[#7193](https://github.com/ValveSoftware/Dota2-Gameplay/issues/7193)not planned)。
### 候选方案
| 方案 | 结论 |
|------|------|
| 官方 GSI | 拿不到双方 pick |
| Overwolf GEP | 备选,偏重 |
| 读游戏内存 | **排除**(合规) |
| 截屏 + 模板匹配 | **选定** |
| 整图问多模态大模型 | 排除为主路径(实测整图几乎全错;裁顶栏后仍不如白名单匹配) |
社区同类工具共同点:**裁固定 ROI + 模板匹配 / 小模型**,输出约束在英雄白名单内。
---
## 2. 技术方案
### 处理流程
```
截图 → 相对坐标裁 10 格 →(天梯则遮罩段位条)→ CDN NCC 匹配
→ score + margin 双门控 → hero_key | null
```
### 关键决策
**① 相对坐标**
横向 `(cx - W/2) / H`,纵向与宽高 `/ H`。同宽高比下分辨率无关;21:9 等需锚点或重标定。
**② 单一 CDN 模板库**
`templates/cdn/{hero}.png` 来自 Steam 官方头像,按顶栏实际窗口裁切后写入。
曾尝试「CDN 兜底 + real 实拍」双层库。实测修正 CDN 裁切后 **CDN-only 即可全对**
继续攒 real 库性价比低,已放弃(见 §3 发现二)。皮肤靠会话策略,不堆变体模板。
**③ 宁可不认,不可乱认**
- `score >= min_score`(默认 0.45
- `margin = Top1 Top2 >= min_margin`(默认 0.04
**④ 会话层处理皮肤与淡入**
- 选人阶段:多帧确认、允许更高分改判(`revise_gain`
- 决策阶段:只补空槽、禁止改判(避免皮肤顶栏覆盖默认脸结论)
- best 帧优先选人阶段默认脸(`recognized` 相同时)
- 本人英雄以 GSI `hero` 覆盖视觉结果(只覆盖自己的槽)
**⑤ 截图按对局归档**
GSI 会话写入 `samples/raw/{matchid}/`;手动 `capture.py` 仍写 `samples/raw/` 根目录。
结果 JSON 仍为 `results/draft_<时间戳>.json`(内含 `match_id`)。
**⑥ GSI 全量落盘**
`gsi.dump_payloads`(默认开)把每包 POST body 追加到 `samples/raw/{matchid}/gsi.jsonl`
行格式 `{"t": <unix>, "payload": <原文>}`。内容上限仍是 cfg 订阅字段 + 普通玩家视角;
不能靠落盘补出双方 pick。CLI`--dump-gsi` / `--no-dump-gsi`
---
## 3. 关键实测(2026-07-25
环境:2560×1440 无边框;人机 + 天梯。
### 发现一:CDN 裁切窗口决定上限
初版按正方形取中心,比例与顶栏 111×88 不符,均分约 0.65。
网格搜索得到正确窗口 **x0=38, w=182, 全高**(源 256×144),均分约 0.94。
写入 `fetch_cdn_templates.py``CROP_X0/CROP_X1` 后,**CDN-only 30/30 / 40/40**。
> 兜底层差时先查素材处理,再考虑堆数据。
### 发现二:不必维护 real 库
CDN-only 开箱即用后,「打几十局攒默认脸」不再是主路径。项目改为只维护 CDN。
后续审计(迁出前原型库)进一步确认:
- 标注集上大量 `score=1.000` 往往是「从同帧裁进 real 再评同帧」的自证,夸大了 real 贡献。
- 剩余 real 与 CDN(天梯遮罩)多数 ≥0.85,近乎 CDN 副本;明显偏离的极少(如军团指挥官一类边缘格)。
- **近期问题局**(宙斯缺槽、斯拉达 CDN 分低、敌法末位、皮肤帧风行者 0.415)主要靠
**多帧会话 + 决策禁改判 + GSI 补自己** 凑齐,不是靠 real。
- 皮肤变体极多,入库皮肤模板不合理;段位条/徽章每人不同,匹配时已用遮罩忽略,
也不该为不同段位重复存图。
### 发现三:自动标定靠玩家颜色条
10 条固定玩家色定位槽位;中心用中位数槽距拟合(剔除被身后头像污染的宽条);
头像下沿用「格内列 vs 格间空隙」亮度差,勿用逐行差分(易误判到名字行)。
### 发现四:天梯段位条用遮罩
GSI 不含可靠 lobby 类型时,用右下金色勋章检测(`has_ranked_overlay`),
匹配时屏蔽底部 32% + 右侧 22%。人机无勋章走全图匹配。
### 发现五:禁用名单不靠模板
网格按主属性分块 + 客户端本地化名行优先排布;禁用/已选卡片对比度塌陷
std 约 821 vs 正常 ≥33)。`data/heroes.json` 需含 `attr` / `name_loc`
另含手工维护的中文口语 `aliases`(重拉 CDN 表时合并保留,暂未接入推荐展示)。
格数对不上时 `ok=False`,不返回残缺名单。
### 发现六:位置与「我」
定位局位置字用二值 IoU(非 OCR)。「我」优先 GSI `team_slot`
否则姓名亮度相对差(最亮比次亮 ≥ 25)兜底。
### 发现七:客户端顶栏时机(会话策略前提)
这些是实战纠正后的事实,改 `DraftSession` 时不要违背:
| 现象 | 含义 |
|------|------|
| 顶栏默认脸 → 皮肤 | **全员选完后**才换皮肤立绘,不是一进决策就换 |
| 本机已进 `STRATEGY_TIME` | 别人可能还在选;**不能**一进决策就永久停视觉 |
| 空槽红旗倒计时 | 只出现在**未选**槽;揭晓后消失,不是挡脸主因 |
| GSI | **没有**「皮肤已加载」字段;无法精确卡「人选完、皮肤前」单帧 |
因此正确节奏是:选人 + 决策前期持续读,直到确认 10/10 或 `strategy_tail_polls` 用尽;
凑齐后停视觉,再短等 GSI 补本人英雄。单帧皮肤弱识别(如决策 3D 模型挡顶栏)属预期,
靠会话回填,不靠降阈值或堆皮肤模板。
### 发现八:揭晓淡入需要可改判
首帧揭晓常半透明 + 段位条,易误认(例:剑圣揭晓瞬间被认成幽鬼)。
`confirm_polls` 后若仍禁止改判会锁死错误。选人阶段允许
`score ≥ was_score + revise_gain` 的稳定新结果覆盖;决策阶段则关闭改判。
---
## 4. 代码结构
```
climperor/
├── pc/ # 局内选将识别(GSI + 截屏 + OpenCV
│ ├── common.py / recognize.py / draft_session.py / gsi_watch.py
│ ├── recommend.py / draft_archetypes.py / roles.py / modes.py / overlay.py
│ ├── config.json / templates/ / assets/role_icons/ / samples/
│ └── requirements.txt
├── web/ # 上分帝 Web(前端、数据流水线、部署)
│ ├── frontend/ # 原 web/relations/
│ ├── data/ / assets/ / dist/
│ ├── fetch_*.py / item_fears.py / mechanic_tags.py / loc_format.py
│ ├── serve_relations.py / export_relations_site.py / deploy_relations.py
│ ├── refresh_web.py / notify_site_traffic.py / _cf_* / _oss_* / _gitea_*
│ └── requirements.txt
├── shared/ # 只被 pc/web 依赖,绝不反向依赖
│ ├── paths.py / grid.py / relations.py / hero_tags.py / http_utils.py
│ ├── import_relations_xlsx.py / audit_relations.py / fetch_stratz.py
│ └── data/heroes.json / data/relations.json
├── .gitea/workflows/ # site-traffic-notify + web-daily/weekly/patch
└── README.md / AGENTS.md / ARCHITECTURE.md / DESIGN.md / CHANGELOG.md
```
运行时目录 `pc/preview/``pc/results/``pc/failures/``pc/samples/raw/`(含按对局子目录)不入库。上分帝 Web 易变数据(STRATZ meta、hero_stats 等)可由 Gitea Actions 定时拉取后直部 Pages,不必入库。
### 主要配置
| 字段 | 含义 | 默认 |
|------|------|------|
| `match.min_score` / `min_margin` | 识别门控 | 0.45 / 0.04 |
| `match.ranked_mask` | 天梯遮罩比例 | bottom 0.32, right 0.22 |
| `gsi.port` | 监听端口 | 3223 |
| `gsi.confirm_polls` | 连续同结果帧数 | 2 |
| `gsi.revise_gain` | 选人阶段改判所需分数增益 | 0.15 |
| `gsi.strategy_tail_polls` | 决策阶段继续视觉轮询 | 8 |
| `gsi.strategy_gsi_wait` | 视觉结束后等待本人 GSI 英雄 | 3.0 |
| `gsi.require_foreground` | 仅当前台为 `dota2.exe` 时截屏识别 | true |
| `gsi.dump_payloads` | 全量 GSI JSONL 落盘 | true |
| `recommend.enabled` | 选将全网格克/搭/补推荐 | true |
| `recommend.top_n` | 标记数量上限;`≤0` 不截断 | 0 |
| `recommend.min_enemies` | 开始推荐所需敌方锁人数 | 1 |
| `recommend.min_heroes_for_gaps` | 缺口/补位注入所需锁人数 | 2 |
| `recommend.archetypes` | 推进/全球流/缺口规则画像 | true |
| `recommend.relations_path` | 定性关系文件 | `shared/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` / `mark_gap_rel` | 网格克/搭/补方标尺寸、内边距、间距 | 0.018 / 0.004 / 0.002 |
| `overlay.counter_color` / `synergy_color` / `fill_color` / `mark_text_color` | 克 / 搭 / 补 / 文字色 | `#2ec4b6` / `#e9a825` / `#9b7ebd` / `#0b1220` |
| `overlay.analysis_*` | 阵容分析横条位置/高度/字号/底色字色 | 见 config.json |
---
## 5. GSI 自动化
```
Dota 2 (-gamestateintegration)
→ POST → pc/gsi_watch.py :3223
→ pc/samples/raw/{matchid}/gsi.jsonl(可选)
→ DraftSession 轮询 recognize + roles/grid/modes
→ pc/results/draft_*.json
```
要点:按 `matchid` 去重;截图进 `pc/samples/raw/{matchid}/`;识别在工作线程;
未标定时降级为只截图。
天梯 AP 选人按 2/2/1 成批揭晓,本轮结束前互不可见——因此必须在
**HERO_SELECTION** 跟踪,不能只在决策时间截一张终局图;决策前期仍读几帧
以吃到最后一人揭晓(见发现七)。
---
## 6. 选将全网格「克 / 搭 / 补」推荐
- **触发**:敌方至少锁定 1 人(`min_enemies`)后开始;本人锁人后清空标记。
- **分路**:定位匹配画面字(`roles.py`+ GSI `team_slot` 定位本人;不做截屏认自己。有分路则按 `role_tags` 过滤候选;非定位局(读不到分路字)则全英雄表。
- **关系模型**:机制克制/搭档(`shared/data/relations.json`),不用胜率/场次——版本会变,机制边相对稳。Web 英雄详情「走势」Tab 优先 STRATZ(`web/data/stratz_hero_meta.json`):勋章条切换 8 档 → **最近 1 周**三卡(胜率/上场率/场次)→ 1~5 号位分路胜率 → **近 8 周**逐周列表(日期、胜率、周环比、区间缩放横条、场次;新→旧);OpenDota(`web/data/hero_stats.json`,近约 7 天)作兜底。「对位」Tab 展示 STRATZ 数值 Top`web/data/stratz_matchup_tops.json`),与网格定性「克/怕/搭」分开展示;均不进局内推荐。
- **阵容画像(规则,不接 AI)**:[`pc/draft_archetypes.py`](pc/draft_archetypes.py) 识别敌方推进/全球流、敌我 tag 缺口(缺控制/爆发/输出/先手);输出一句 `analysis`(如 `敌:缺控制·偏推进 | 我:缺爆发`)与每英雄短 `reasons`
- **纳入**
- **克**:关系克制边;推进/全球流应对表(如美杜莎对推进);惩戒敌方缺口
- **搭**:与已锁己有搭档边
- **补**:填我方缺口(`min_heroes_for_gaps` 默认 2 才注入缺口类标记)
- **打分**:关系边为主;阵容应对 / 缺口小幅加分;敌方 tags 软加分仅排序。
- **展示**:网格左上角全量相关格标「克」(青)/「搭」(琥珀)/「补」(紫灰);叠加层横条显示阵容分析;原因进日志与 `results` JSON,不写在每个格子旁。
- **上分帝 Web**`web/serve_relations.py` 本地开发服务;仿选将网格查看克制/被克制/搭档;数据手改 `shared/data/relations.json` 或从 xlsx 导入。
另有顶级 **排行**、**走势**`#/trends[/bracket]`,近 8 周高胜率/上场率榜)、**物品**、**版本** 页;目录见 `web/data/item_shop.json` / `web/data/patches.json` / `web/data/leaderboards.json`
英雄详情子标签含 **走势**(见上;`#/heroes/<key>/trends`)、**对位**(克制/被克/搭档三列 STRATZ Top`#/heroes/<key>/matchups`)、**近期比赛**`#/heroes/<key>/matches`)。排行页为 Immortal 四区选手榜。
- **Web Hash 路由**:状态(顶层标签 / 选中英雄 + 详情子标签 / 选中物品 / 选中版本 /
标签筛选 / 搜索)双向同步进 URL(`#/heroes/axe/core?tags=核心&q=axe``#/heroes/axe/trends` 等)。选 Hash
而非 Path 路由:静态导出(GitHub Pages)无需 rewrite、`web/serve_relations.py` 零改动、
相对路径子站亦可用。写 URL 用 `history.pushState`/`replaceState`(静默,不触发
`hashchange`,无回环);前进/后退靠 `hashchange` 监听重 parse + 校验 + render。
搜索 debounce 300ms + `replaceState` 防刷历史栈;坏链接丢弃该项不崩。路由逻辑集中在
`web/frontend/router.js``app.js` 只在 `main()``installRouter` + 各 state 变更点
`syncStateToUrl`
- **不做**:避用标、Steam 登录页、爬 Dotabuff;关系**不**写入 `shared/data/heroes.json`;不接实时 AI。
---
## 7. 与 dota2-hex
独立验证项目。可选:Rust 重写并入,或本地旁路 HTTP 回传。
合规同 `dota2-hex`**屏幕可见信息 only**。
---
## 8. 参考
- [Valve #9562](https://github.com/ValveSoftware/Dota2-Gameplay/issues/9562)
- [Valve #14915](https://github.com/ValveSoftware/Dota2-Gameplay/issues/14915)(开发者 OCR ≈85%
- [Overwolf Dota 2 GEP](https://dev.overwolf.com/ow-native/live-game-data-gep/supported-games/dota-2/)
- Steam CDN`https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png`
+145
View File
@@ -4,6 +4,151 @@
## [Unreleased] ## [Unreleased]
## [0.5.109] - 2026-07-30
### Changed
- 英雄页:定位筛选迁至网格与详情之间(Valve 图标斜角按钮);去掉详情 Chip;未选英雄时隐藏详情占位。
- 提高英雄详情固定高度(约至 640px 上限),压缩网格与详情之间的多余空白;Tab 切换高度不跳动。
- 技能 Tab:演示视频与文案强制左右排布;去掉图标与详情之间的中间分割线。
- 核心装备 / 被克装备:详情区上下分割线统一为全宽。
## [0.5.108] - 2026-07-30
### Fixed
- 技能 Tab:演示视频与文案强制左右排布(不再因中间栏偏窄折行上下叠);详情固定高度提至约 560px,常规技能默认可视区内完整展示。
## [0.5.107] - 2026-07-30
### Changed
- 技能 Tab:去掉技能图标与详情之间的中间分割线(装备 Tab 保留)。
## [0.5.106] - 2026-07-30
### Fixed
- 核心装备 / 被克装备:详情区上下分割线统一为全宽(合成线不再缩在 720px 文案栏内)。
## [0.5.105] - 2026-07-30
### Fixed
- 技能 / 核心装备 / 被克装备等 Tab 切换时详情区高度不再跳动(恢复固定高 + 禁止压缩)。
## [0.5.104] - 2026-07-30
### Changed
- 缩小英雄详情固定高度,给网格与定位栏留更多空间;走势 / 对位等长内容仍在详情区内滚动。
## [0.5.103] - 2026-07-30
### Changed
- 未选中英雄时隐藏详情占位条,不再显示提示文案。
## [0.5.102] - 2026-07-30
### Changed
- 战斗属性补全转身速率:OpenDota 缺省时按引擎默认 `0.6` 显示(与官网一致);
`heroes.json` 回填 82 名英雄。
## [0.5.101] - 2026-07-30
### Changed
- 选中英雄时定位栏不再点亮「全部」,只高亮该英雄定位。
## [0.5.100] - 2026-07-30
### Changed
- 去掉英雄定位栏上下分割线。
## [0.5.99] - 2026-07-30
### Changed
- 英雄详情技能区去掉定位 Chip;选中英雄时在上方定位栏高亮对应选项(不影响网格筛选)。
## [0.5.98] - 2026-07-29
### Changed
- 英雄详情战斗属性(攻击 / 防御 / 机动性)改为官网同款图标 + 数值横排;图标取自
dota2.com.cn `herostatic/stats`,视野显示为「白天 / 夜晚」。
## [0.5.97] - 2026-07-29
### Changed
- 英雄定位筛选移至英雄网格与详情面板之间,改用 Valve 风格图标与斜角按钮;保留多选,
暂不展示「幻象」筛选。
## [0.5.96] - 2026-07-29
### Changed
- 加长主导航选中下划线至 44px;hover 改为仅文字提亮,移除与斜切分隔冲突的矩形冷光底。
## [0.5.95] - 2026-07-29
### Changed
- 移除顶栏品牌 Logo 右侧斜线,仅保留暗红内光,减少品牌区与导航之间的装饰噪声。
## [0.5.94] - 2026-07-29
### Changed
- 放大宽屏顶栏比例:主导航改为 16px / 76px 最小宽 / 44px 点击高,品牌 Logo 提升至
32px;≤1200px 自动恢复 15px / 40px 紧凑规格,筛选 Chip 保持次级尺寸。
## [0.5.93] - 2026-07-29
### Changed
- 顶栏轻度借鉴 Dota 2 国服官网:加入近黑金属横梁、暗红品牌内光与斜切细分隔;
保留青色短下划线和定位 Chip,不复刻门户式梯形按钮、厚重贴图与高顶栏。
## [0.5.92] - 2026-07-29
### Changed
- 主导航放弃连续按钮外框,改为轻量文字 Tab:透明背景、低噪 hover 与青色短下划线选中态;定位筛选继续使用独立 Chip,强化两级导航层次。
## [0.5.91] - 2026-07-29
### Changed
- 顶部导航改为紧凑双层:首行品牌 | 居中主导航 | 搜索+邮件;次行仅英雄定位筛选。
去掉品牌/邮件绝对悬浮与搜索贴边,减少横向空洞;窄桌面收紧 Tab/搜索并隐藏品牌标题。
## [0.5.90] - 2026-07-29
### Changed
- 对位页底部文案简化为「数据来自 STRATZ(相对优势,全局聚合)。」
## [0.5.89] - 2026-07-29
### Changed
- 英雄「对位」统一只使用 STRATZ(`stratz_matchup_tops.json`);移除 OpenDota
对位交叉与页面「来源一致 / 分歧」提示;`audit_relations` 改为 STRATZ 单源审计。
## [0.5.88] - 2026-07-29
### Changed
- 根目录 `DESIGN.md` 改为上分帝 Web 视觉设计规范([design.md](https://github.com/google-labs-code/design.md) 格式);
原技术方案迁至 `ARCHITECTURE.md`
- Web 统一设计令牌:色板、字号层级、4/8 间距、圆角、内容宽度档位与焦点态;
排行 / 比赛 / 主播 / 物品补齐页标题;版本号展示降至 32px;断点收敛为 640 / 900 / 1200。
## [0.5.87] - 2026-07-29 ## [0.5.87] - 2026-07-29
### Fixed ### Fixed
+301 -229
View File
@@ -1,269 +1,341 @@
# 上分帝(Climperor)—— 方案与实施细节 ---
version: alpha
`README.md` 是操作手册;本文记录背景、选型与关键决策。协作约束见 `AGENTS.md` name: Climperor Web
description: Dark Dota-inspired information panels for the Climperor Web desktop site.
colors:
primary: "#5EC8FF"
secondary: "#8FA3BC"
tertiary: "#3DCE7A"
neutral: "#0B1018"
surface: "#0E1622"
surface-raised: "#101824"
on-surface: "#E8EEF7"
on-surface-muted: "#8FA3BC"
border: "rgba(140, 170, 210, 0.18)"
accent: "#5EC8FF"
good: "#3DCE7A"
bad: "#E07A2F"
syn: "#7EC8FF"
danger: "#E86A6A"
gold: "#E8C878"
live: "#FE2C55"
panel: "rgba(14, 22, 34, 0.96)"
panel-soft: "rgba(8, 12, 20, 0.55)"
on-accent: "#0B0D10"
typography:
display:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 32px
fontWeight: 800
lineHeight: 1.1
letterSpacing: 0.02em
page-title:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 22px
fontWeight: 800
lineHeight: 1.2
letterSpacing: 0.02em
title-lg:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 18px
fontWeight: 700
lineHeight: 1.3
letterSpacing: 0.04em
title-md:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 16px
fontWeight: 700
lineHeight: 1.35
letterSpacing: 0.02em
body-lg:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 15px
fontWeight: 400
lineHeight: 1.5
body-md:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 14px
fontWeight: 400
lineHeight: 1.5
body-sm:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 13px
fontWeight: 500
lineHeight: 1.45
label-md:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 13px
fontWeight: 600
lineHeight: 1.3
letterSpacing: 0.06em
label-sm:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 12px
fontWeight: 600
lineHeight: 1.3
letterSpacing: 0.04em
numeric-lg:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 28px
fontWeight: 700
lineHeight: 1.1
letterSpacing: 0.02em
numeric-md:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 14px
fontWeight: 700
lineHeight: 1.2
rounded:
sm: 4px
md: 8px
lg: 12px
xl: 16px
full: 9999px
spacing:
xs: 4px
sm: 8px
md: 12px
lg: 16px
xl: 24px
2xl: 32px
3xl: 48px
gutter: 16px
page-y: 28px
content-read: 820px
content-standard: 880px
content-wide: 1080px
content-data: 1680px
components:
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-accent}"
rounded: "{rounded.md}"
padding: 12px
height: 40px
button-primary-hover:
backgroundColor: "#7AD4FF"
button-secondary:
backgroundColor: "{colors.surface}"
textColor: "{colors.on-surface}"
rounded: "{rounded.md}"
padding: 12px
height: 40px
button-secondary-active:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-accent}"
tab-main:
backgroundColor: "{colors.surface}"
textColor: "{colors.secondary}"
typography: "{typography.body-lg}"
height: 42px
padding: 10px
tab-main-active:
backgroundColor: "{colors.surface-raised}"
textColor: "{colors.on-surface}"
input-search:
backgroundColor: "{colors.neutral}"
textColor: "{colors.on-surface}"
rounded: "{rounded.md}"
height: 40px
padding: 12px
panel-card:
backgroundColor: "{colors.surface}"
textColor: "{colors.on-surface}"
rounded: "{rounded.lg}"
padding: 16px
chip-filter:
backgroundColor: "{colors.surface}"
textColor: "{colors.secondary}"
rounded: "{rounded.full}"
padding: 8px
chip-filter-active:
backgroundColor: "{colors.surface-raised}"
textColor: "{colors.on-surface}"
mark-good:
backgroundColor: "{colors.neutral}"
textColor: "{colors.good}"
mark-bad:
backgroundColor: "{colors.neutral}"
textColor: "{colors.bad}"
mark-syn:
backgroundColor: "{colors.neutral}"
textColor: "{colors.syn}"
badge-gold:
backgroundColor: "{colors.neutral}"
textColor: "{colors.gold}"
badge-live:
backgroundColor: "{colors.live}"
textColor: "#FFFFFF"
badge-danger:
backgroundColor: "{colors.neutral}"
textColor: "{colors.danger}"
panel-raised:
backgroundColor: "{colors.panel}"
textColor: "{colors.on-surface}"
panel-soft:
backgroundColor: "{colors.panel-soft}"
textColor: "{colors.on-surface-muted}"
divider:
backgroundColor: "{colors.surface}"
textColor: "{colors.border}"
--- ---
## 1. 背景与目标 # Climperor Web Design System
### 要解决的问题 ## Overview
在 Dota 2 选将阶段(以及进入游戏后)**几秒内自动获取双方 10 个英雄**,服务天梯选将辅助或赛后分析 上分帝 Web 是面向桌面浏览器的 Dota 2 信息站:深色蓝黑底、高信息密度、冷静可读。气质接近官方客户端与选人界面——偏工具面板,不走社交 feed 或明亮营销站
### 为什么 GSI 做不到 目标感受:
| 场景 | GSI 可获得的阵容数据 | - **Dense but calm**:列表、表格、网格可挤满视口,但字号、行高、间距仍保持清晰节奏。
|------|---------------------| - **Semantic color first**:青色强调当前选择与导航;绿 / 橙 / 青蓝分别表示克制 / 被克 / 搭档;粉红仅用于直播态。
| 排位 / 普通 All Pick | 仅自己的 `hero.id``draft` 通常为空 | - **Desktop shell**`100dvh` 外框 + 页内滚动;手机 / 平板由 `mobile-gate` 拦截,不为真移动端另做一套 IA。
| Captains Mode | 历史上有部分 pick/ban,不稳定 |
| 观战 / 裁判视角 | 阵容字段较全 |
| 赛后 | 需依赖 OpenDota 等外部 API |
Valve 已明确关闭普通玩家视角的实时 draft 实现源:`web/frontend/style.css` 的 CSS 变量必须与本文件令牌一一对应。工程决策见 `ARCHITECTURE.md`
[#9562](https://github.com/ValveSoftware/Dota2-Gameplay/issues/9562)、
[#7193](https://github.com/ValveSoftware/Dota2-Gameplay/issues/7193)not planned)。
### 候选方案 ## Colors
| 方案 | 结论 | 调色盘以近黑海军为底,浅冷灰为正文,单一青色作交互主色。
|------|------|
| 官方 GSI | 拿不到双方 pick |
| Overwolf GEP | 备选,偏重 |
| 读游戏内存 | **排除**(合规) |
| 截屏 + 模板匹配 | **选定** |
| 整图问多模态大模型 | 排除为主路径(实测整图几乎全错;裁顶栏后仍不如白名单匹配) |
社区同类工具共同点:**裁固定 ROI + 模板匹配 / 小模型**,输出约束在英雄白名单内 - **Primary (`#5EC8FF`)**:主交互与选中态(主 Tab 底线、侧栏选中填充、焦点环、选中英雄描边)
- **Secondary (`#8FA3BC`)**:次要文案、标签、时间戳、未选中控件。
- **Tertiary (`#3DCE7A`)**:正向语义(克制、胜率上升、胜场)。
- **Neutral (`#0B1018`)**:页面底色;表面层用更深的半透明面板叠在渐变背景上。
- **Bad / Danger / Gold / Live / Syn**:业务辨识色,不得拿来当通用强调色。
--- 对比度要求:正文与底至少 WCAG AA;muted 文案仅用于辅助信息,不用在关键操作标签上。
## 2. 技术方案 ## Typography
### 处理流程 系统栈:`Segoe UI` + `Microsoft YaHei UI` + `PingFang SC`。中文优先清晰,不引入展示性装饰字体。
``` | Token | 用途 |
截图 → 相对坐标裁 10 格 →(天梯则遮罩段位条)→ CDN NCC 匹配 |-------|------|
→ score + margin 双门控 → hero_key | null | `display` | 版本号等少数展示数字(约 32px,勿再放大到 40px) |
``` | `page-title` | 各顶层页标题(排行 / 走势 / 机制 / 主播 / 物品等) |
| `title-lg` / `title-md` | 区段标题、英雄名、物品名 |
| `body-lg` / `body-md` / `body-sm` | 导航、正文、说明 |
| `label-md` / `label-sm` | 表头、分区标签、徽章 |
| `numeric-*` | 表格数字与指标卡,配合 `tabular-nums` |
### 关键决策 字重只用 400 / 500 / 600 / 700 / 800。禁止 `650``13.5px` 等孤立值。默认行高正文 1.5,标题 1.1–1.3;勿留 `line-height: normal` 在可读正文上。
**① 相对坐标** ## Layout
横向 `(cx - W/2) / H`,纵向与宽高 `/ H`。同宽高比下分辨率无关;21:9 等需锚点或重标定。 桌面壳:顶栏固定高度区 + 可滚动 `main.board`;英雄页的英雄网格与固定高 `#detail`
之间放置一行定位筛选。选中英雄后 `#detail` 使用共享固定高度(约 `min(640px, …)`),
Tab 切换不跳动;技能演示与文案左右排布,长 Tab(走势/对位等)在详情区内滚动。
**② 单一 CDN 模板库** ### 顶部壳(紧凑单层)
`templates/cdn/{hero}.png` 来自 Steam 官方头像,按顶栏实际窗口裁切后写入。 顶栏只保留主行,禁止品牌 / 主导航 / 搜索三者各自「漂浮居中」造成大块空洞:
曾尝试「CDN 兜底 + real 实拍」双层库。实测修正 CDN 裁切后 **CDN-only 即可全对** 1. **首行 `PrimaryRow`**`display: grid; grid-template-columns: 1fr auto 1fr`(或等价 flex)。
继续攒 real 库性价比低,已放弃(见 §3 发现二)。皮肤靠会话策略,不堆变体模板。 - 左:品牌(logo + 标题)
- 中:主导航分段控件(真正几何居中)
- 右:工具区(英雄/物品搜索 + 邮件入口),`justify-content: end`
视觉语言轻度借鉴 Dota 2 国服官网导航,但不复刻门户式大顶栏:
**③ 宁可不认,不可乱认** - 顶栏使用近黑金属渐变与上下 1px 冷色高光,形成连续横梁;总高控制在约 60px。
- 品牌区仅用克制的暗红内光形成视觉锚点,不在 Logo 右侧增加分隔线;不使用视频 Logo、金色奖项条或厚重贴图。
- 主导航保持现代无边框文字 Tab;相邻 Tab 仅用低对比斜切细线分隔,不做梯形按钮。
- 主交互色仍为 `primary` 青色;Dota 红只用于品牌区,不替代全站选中态。
- 宽屏主行采用 44px 控件高:主导航 16px / 最小 76px 宽,品牌 Logo 32px。`lg` 断点恢复 40px、15px 的紧凑规格。
- `score >= min_score`(默认 0.45 断点降级:
- `margin = Top1 Top2 >= min_margin`(默认 0.04
**④ 会话层处理皮肤与淡入** - `lg` ≤1200px:Tab 水平内边距收紧;搜索宽约 160–180px
- `md` ≤900px:隐藏品牌标题,仅留 logo;搜索再缩短
- `sm` ≤640px:极窄桌面可隐藏整块品牌
- 选人阶段:多帧确认、允许更高分改判(`revise_gain` 内容宽度档位(命名,勿再散落魔法数):
- 决策阶段:只补空槽、禁止改判(避免皮肤顶栏覆盖默认脸结论)
- best 帧优先选人阶段默认脸(`recognized` 相同时)
- 本人英雄以 GSI `hero` 覆盖视觉结果(只覆盖自己的槽)
**⑤ 截图按对局归档** | 档位 | 宽度 | 页面 |
GSI 会话写入 `samples/raw/{matchid}/`;手动 `capture.py` 仍写 `samples/raw/` 根目录。
结果 JSON 仍为 `results/draft_<时间戳>.json`(内含 `match_id`)。
**⑥ GSI 全量落盘**
`gsi.dump_payloads`(默认开)把每包 POST body 追加到 `samples/raw/{matchid}/gsi.jsonl`
行格式 `{"t": <unix>, "payload": <原文>}`。内容上限仍是 cfg 订阅字段 + 普通玩家视角;
不能靠落盘补出双方 pick。CLI`--dump-gsi` / `--no-dump-gsi`
---
## 3. 关键实测(2026-07-25
环境:2560×1440 无边框;人机 + 天梯。
### 发现一:CDN 裁切窗口决定上限
初版按正方形取中心,比例与顶栏 111×88 不符,均分约 0.65。
网格搜索得到正确窗口 **x0=38, w=182, 全高**(源 256×144),均分约 0.94。
写入 `fetch_cdn_templates.py``CROP_X0/CROP_X1` 后,**CDN-only 30/30 / 40/40**。
> 兜底层差时先查素材处理,再考虑堆数据。
### 发现二:不必维护 real 库
CDN-only 开箱即用后,「打几十局攒默认脸」不再是主路径。项目改为只维护 CDN。
后续审计(迁出前原型库)进一步确认:
- 标注集上大量 `score=1.000` 往往是「从同帧裁进 real 再评同帧」的自证,夸大了 real 贡献。
- 剩余 real 与 CDN(天梯遮罩)多数 ≥0.85,近乎 CDN 副本;明显偏离的极少(如军团指挥官一类边缘格)。
- **近期问题局**(宙斯缺槽、斯拉达 CDN 分低、敌法末位、皮肤帧风行者 0.415)主要靠
**多帧会话 + 决策禁改判 + GSI 补自己** 凑齐,不是靠 real。
- 皮肤变体极多,入库皮肤模板不合理;段位条/徽章每人不同,匹配时已用遮罩忽略,
也不该为不同段位重复存图。
### 发现三:自动标定靠玩家颜色条
10 条固定玩家色定位槽位;中心用中位数槽距拟合(剔除被身后头像污染的宽条);
头像下沿用「格内列 vs 格间空隙」亮度差,勿用逐行差分(易误判到名字行)。
### 发现四:天梯段位条用遮罩
GSI 不含可靠 lobby 类型时,用右下金色勋章检测(`has_ranked_overlay`),
匹配时屏蔽底部 32% + 右侧 22%。人机无勋章走全图匹配。
### 发现五:禁用名单不靠模板
网格按主属性分块 + 客户端本地化名行优先排布;禁用/已选卡片对比度塌陷
std 约 821 vs 正常 ≥33)。`data/heroes.json` 需含 `attr` / `name_loc`
另含手工维护的中文口语 `aliases`(重拉 CDN 表时合并保留,暂未接入推荐展示)。
格数对不上时 `ok=False`,不返回残缺名单。
### 发现六:位置与「我」
定位局位置字用二值 IoU(非 OCR)。「我」优先 GSI `team_slot`
否则姓名亮度相对差(最亮比次亮 ≥ 25)兜底。
### 发现七:客户端顶栏时机(会话策略前提)
这些是实战纠正后的事实,改 `DraftSession` 时不要违背:
| 现象 | 含义 |
|------|------|
| 顶栏默认脸 → 皮肤 | **全员选完后**才换皮肤立绘,不是一进决策就换 |
| 本机已进 `STRATEGY_TIME` | 别人可能还在选;**不能**一进决策就永久停视觉 |
| 空槽红旗倒计时 | 只出现在**未选**槽;揭晓后消失,不是挡脸主因 |
| GSI | **没有**「皮肤已加载」字段;无法精确卡「人选完、皮肤前」单帧 |
因此正确节奏是:选人 + 决策前期持续读,直到确认 10/10 或 `strategy_tail_polls` 用尽;
凑齐后停视觉,再短等 GSI 补本人英雄。单帧皮肤弱识别(如决策 3D 模型挡顶栏)属预期,
靠会话回填,不靠降阈值或堆皮肤模板。
### 发现八:揭晓淡入需要可改判
首帧揭晓常半透明 + 段位条,易误认(例:剑圣揭晓瞬间被认成幽鬼)。
`confirm_polls` 后若仍禁止改判会锁死错误。选人阶段允许
`score ≥ was_score + revise_gain` 的稳定新结果覆盖;决策阶段则关闭改判。
---
## 4. 代码结构
```
climperor/
├── pc/ # 局内选将识别(GSI + 截屏 + OpenCV
│ ├── common.py / recognize.py / draft_session.py / gsi_watch.py
│ ├── recommend.py / draft_archetypes.py / roles.py / modes.py / overlay.py
│ ├── config.json / templates/ / assets/role_icons/ / samples/
│ └── requirements.txt
├── web/ # 上分帝 Web(前端、数据流水线、部署)
│ ├── frontend/ # 原 web/relations/
│ ├── data/ / assets/ / dist/
│ ├── fetch_*.py / item_fears.py / mechanic_tags.py / loc_format.py
│ ├── serve_relations.py / export_relations_site.py / deploy_relations.py
│ ├── refresh_web.py / notify_site_traffic.py / _cf_* / _oss_* / _gitea_*
│ └── requirements.txt
├── shared/ # 只被 pc/web 依赖,绝不反向依赖
│ ├── paths.py / grid.py / relations.py / hero_tags.py / http_utils.py
│ ├── import_relations_xlsx.py / audit_relations.py / fetch_matchups.py / fetch_stratz.py
│ └── data/heroes.json / data/relations.json
├── .gitea/workflows/ # site-traffic-notify + web-daily/weekly/patch
└── README.md / AGENTS.md / DESIGN.md / CHANGELOG.md
```
运行时目录 `pc/preview/``pc/results/``pc/failures/``pc/samples/raw/`(含按对局子目录)不入库。上分帝 Web 易变数据(STRATZ meta、hero_stats 等)可由 Gitea Actions 定时拉取后直部 Pages,不必入库。
### 主要配置
| 字段 | 含义 | 默认 |
|------|------|------| |------|------|------|
| `match.min_score` / `min_margin` | 识别门控 | 0.45 / 0.04 | | full | 100% | 英雄网格 |
| `match.ranked_mask` | 天梯遮罩比例 | bottom 0.32, right 0.22 | | read | `min(820px, 92%)` | 版本 |
| `gsi.port` | 监听端口 | 3223 | | standard | `min(880px, calc(100% - 48px))` | 排行、主播、机制 |
| `gsi.confirm_polls` | 连续同结果帧数 | 2 | | wide | `min(1080px, …)` | 走势 |
| `gsi.revise_gain` | 选人阶段改判所需分数增益 | 0.15 | | data | `min(1680px, …)` | 比赛 |
| `gsi.strategy_tail_polls` | 决策阶段继续视觉轮询 | 8 |
| `gsi.strategy_gsi_wait` | 视觉结束后等待本人 GSI 英雄 | 3.0 |
| `gsi.require_foreground` | 仅当前台为 `dota2.exe` 时截屏识别 | true |
| `gsi.dump_payloads` | 全量 GSI JSONL 落盘 | true |
| `recommend.enabled` | 选将全网格克/搭/补推荐 | true |
| `recommend.top_n` | 标记数量上限;`≤0` 不截断 | 0 |
| `recommend.min_enemies` | 开始推荐所需敌方锁人数 | 1 |
| `recommend.min_heroes_for_gaps` | 缺口/补位注入所需锁人数 | 2 |
| `recommend.archetypes` | 推进/全球流/缺口规则画像 | true |
| `recommend.relations_path` | 定性关系文件 | `shared/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` / `mark_gap_rel` | 网格克/搭/补方标尺寸、内边距、间距 | 0.018 / 0.004 / 0.002 |
| `overlay.counter_color` / `synergy_color` / `fill_color` / `mark_text_color` | 克 / 搭 / 补 / 文字色 | `#2ec4b6` / `#e9a825` / `#9b7ebd` / `#0b1220` |
| `overlay.analysis_*` | 阵容分析横条位置/高度/字号/底色字色 | 见 config.json |
--- 间距尺度:4 / 8 / 12 / 16 / 24 / 32 / 48px。页内 gutter 默认 16px;列表页上下 `page-y` 28px。侧栏与主列间距 20px。
## 5. GSI 自动化 断点收敛为三档:
``` - `sm` ≤640px:极窄桌面(列堆叠、双列网格)
Dota 2 (-gamestateintegration) - `md` ≤900px:aside 改上下堆叠、英雄两列
→ POST → pc/gsi_watch.py :3223 - `lg` ≤1200px:物品详情上移、列间距收紧
→ pc/samples/raw/{matchid}/gsi.jsonl(可选)
→ DraftSession 轮询 recognize + roles/grid/modes
→ pc/results/draft_*.json
```
要点:按 `matchid` 去重;截图进 `pc/samples/raw/{matchid}/`;识别在工作线程; 手机不走上述断点,由 UA 门禁接管。
未标定时降级为只截图。
天梯 AP 选人按 2/2/1 成批揭晓,本轮结束前互不可见——因此必须在 ## Elevation & Depth
**HERO_SELECTION** 跟踪,不能只在决策时间截一张终局图;决策前期仍读几帧
以吃到最后一人揭晓(见发现七)。
--- 深度靠色调分层与细边框,不用厚重投影:
## 6. 选将全网格「克 / 搭 / 补」推荐 1. 页面渐变底
2. 半透明顶栏 / 面板(`panel-soft`
3. 实心一点的浮层(`panel`、物品 sticky 详情、天赋 popover
- **触发**:敌方至少锁定 1 人(`min_enemies`)后开始;本人锁人后清空标记 选中态用 inset 底线、填充主色或 1–2px `accent` 描边;阴影仅用于浮层(popover / 气泡),模糊约 2432px、黑色 45% 透明度
- **分路**:定位匹配画面字(`roles.py`+ GSI `team_slot` 定位本人;不做截屏认自己。有分路则按 `role_tags` 过滤候选;非定位局(读不到分路字)则全英雄表。
- **关系模型**:机制克制/搭档(`shared/data/relations.json`),不用胜率/场次——版本会变,机制边相对稳。Web 英雄详情「走势」Tab 优先 STRATZ(`web/data/stratz_hero_meta.json`):勋章条切换 8 档 → **最近 1 周**三卡(胜率/上场率/场次)→ 1~5 号位分路胜率 → **近 8 周**逐周列表(日期、胜率、周环比、区间缩放横条、场次;新→旧);OpenDota(`web/data/hero_stats.json`,近约 7 天)作兜底。「对位」Tab 展示 STRATZ 数值 Top`web/data/stratz_matchup_tops.json`),与网格定性「克/怕/搭」分开展示;均不进局内推荐。
- **阵容画像(规则,不接 AI)**:[`pc/draft_archetypes.py`](pc/draft_archetypes.py) 识别敌方推进/全球流、敌我 tag 缺口(缺控制/爆发/输出/先手);输出一句 `analysis`(如 `敌:缺控制·偏推进 | 我:缺爆发`)与每英雄短 `reasons`
- **纳入**
- **克**:关系克制边;推进/全球流应对表(如美杜莎对推进);惩戒敌方缺口
- **搭**:与已锁己有搭档边
- **补**:填我方缺口(`min_heroes_for_gaps` 默认 2 才注入缺口类标记)
- **打分**:关系边为主;阵容应对 / 缺口小幅加分;敌方 tags 软加分仅排序。
- **展示**:网格左上角全量相关格标「克」(青)/「搭」(琥珀)/「补」(紫灰);叠加层横条显示阵容分析;原因进日志与 `results` JSON,不写在每个格子旁。
- **上分帝 Web**`web/serve_relations.py` 本地开发服务;仿选将网格查看克制/被克制/搭档;数据手改 `shared/data/relations.json` 或从 xlsx 导入。
另有顶级 **排行**、**走势**`#/trends[/bracket]`,近 8 周高胜率/上场率榜)、**物品**、**版本** 页;目录见 `web/data/item_shop.json` / `web/data/patches.json` / `web/data/leaderboards.json`
英雄详情子标签含 **走势**(见上;`#/heroes/<key>/trends`)、**对位**(克制/被克/搭档三列 STRATZ Top`#/heroes/<key>/matchups`)、**近期比赛**`#/heroes/<key>/matches`)。排行页为 Immortal 四区选手榜。
- **Web Hash 路由**:状态(顶层标签 / 选中英雄 + 详情子标签 / 选中物品 / 选中版本 /
标签筛选 / 搜索)双向同步进 URL(`#/heroes/axe/core?tags=核心&q=axe``#/heroes/axe/trends` 等)。选 Hash
而非 Path 路由:静态导出(GitHub Pages)无需 rewrite、`web/serve_relations.py` 零改动、
相对路径子站亦可用。写 URL 用 `history.pushState`/`replaceState`(静默,不触发
`hashchange`,无回环);前进/后退靠 `hashchange` 监听重 parse + 校验 + render。
搜索 debounce 300ms + `replaceState` 防刷历史栈;坏链接丢弃该项不崩。路由逻辑集中在
`web/frontend/router.js``app.js` 只在 `main()``installRouter` + 各 state 变更点
`syncStateToUrl`
- **不做**:避用标、Steam 登录页、爬 Dotabuff;关系**不**写入 `shared/data/heroes.json`;不接实时 AI。
--- ## Shapes
## 7. 与 dota2-hex 圆角语言偏克制:
独立验证项目。可选:Rust 重写并入,或本地旁路 HTTP 回传。 - `sm` 4px:表格内图标、小徽章
合规同 `dota2-hex`**屏幕可见信息 only**。 - `md` 8px:输入框、多数按钮、指标卡
- `lg` 12px:详情面板、机制卡、主内容卡
- `xl` 16px:仅大卡片(主播卡)需要时使用,勿扩散到所有面板
- `full`:筛选 pill、关注按钮
--- 禁止同一视图混用锐角与大圆角而无语义理由。
## 8. 参考 ## Components
- [Valve #9562](https://github.com/ValveSoftware/Dota2-Gameplay/issues/9562) ### 导航
- [Valve #14915](https://github.com/ValveSoftware/Dota2-Gameplay/issues/14915)(开发者 OCR ≈85%
- [Overwolf Dota 2 GEP](https://dev.overwolf.com/ow-native/live-game-data-gep/supported-games/dota-2/) - **Main tabs**:无边框文字 Tab,透明背景;相邻项以低对比斜切细线分隔。未选 muted,hover 仅提亮文字,禁止施加矩形 / 渐变底以免与斜切线冲突;选中使用高对比文字 + 居中 44px、2px 高的 `primary` 下划线。宽屏点击区高 44px、字号 16px、最小宽 76px,与搜索 / 邮件对齐;`lg` 断点收敛至 40px / 15px。禁止重新包回连续按钮外框或梯形按钮底。
- Steam CDN`https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png` - **Detail tabs**:下划线式,字号 `body-md`
- **Aside filters**(地区 / 机制效果):竖向 filled;选中 `primary` 底 + `on-accent` 字。
- **顶栏邮件**40×40 图标按钮,muted;hover 提亮。放在右侧工具区,不绝对悬浮到视口角。
### 英雄定位筛选
位于英雄网格和详情面板之间,使用 Valve 选人筛选符号与中文标签的紧凑斜角按钮。
「全部」用中性网格符号;核心 / 辅助 / 推进 / 逃生 / 爆发 / 先手 / 控制 / 耐久使用对应
Valve 图标。支持多选筛选(`active`);选中英雄时,其定位以同色选中态(`matched`
高亮,不单独在详情区再放 Chip。当前不展示「幻象」筛选,勿与主导航混排。
### 搜索
高 40px,圆角 `md`,左侧放大镜;focus 时边框与光晕用 `primary`。默认宽约 200px,落在首行右侧工具区(非 absolute 贴边);物品页换物品搜索,其他页隐藏搜索。
### 面板 / 卡片
共享 `.ui-panel`:边框 `border`、背景 `panel-soft`、圆角 `lg`、内边距 16px。空态统一 `.ui-empty`:居中 muted、`body-md`、上下 48px。
### 表格
表头 `label-sm` muted;单元格 `body-md`;数字 `tabular-nums`。行 hover 轻微提亮,不加斑马纹。
### 焦点
所有可交互控件:`outline: 2px solid primary; outline-offset: 2px``.ui-focus`)。最小点击高度 3640px。
### 业务标记(不可通用化)
- 克 / 怕 / 搭 → `good` / `bad` / `syn`
- 直播环与关注按钮 → `live`
- 装备金价 → `gold`
## Do's and Don'ts
- Do 改视觉前先更新本文件令牌,再改 `style.css` 变量与引用。
- Do 用命名宽度档位与三档断点;Don't 再加孤立 `max-width: Npx`
- Do 每个顶层页提供清晰的页标题语义(可见 `h1`/`h2` 或等价 `.page-title`)。
- Don't 用 `font-weight: 650` 或非尺度字号(如 `13.5px`)。
- Don't 把直播粉、胜负红、金价黄当作通用 CTA。
- Don't 为真移动端在现有碎片 `@media` 上硬撑;门禁解除前保持桌面壳。
- Do 保持英雄底栏固定高与列表页整板滚动两种壳模型,勿发明第三种。
- Don't 在 data-only 刷新里 bump `SITE_VERSION`;视觉发版才同步 config / export / `?v=` 缓存戳。
+2 -2
View File
@@ -4,7 +4,7 @@ Dota 2 **天梯选将识别**:从选人 / 决策时间截图中识别双方 10
本地、离线、秒级;模板来自 Steam CDN 官方头像。GSI 只负责触发时机,阵容靠视觉读顶栏。 本地、离线、秒级;模板来自 Steam CDN 官方头像。GSI 只负责触发时机,阵容靠视觉读顶栏。
设计背景见 [DESIGN.md](DESIGN.md);协作约定见 [AGENTS.md](AGENTS.md);变更记录见 [CHANGELOG.md](CHANGELOG.md)。 架构与方案背景见 [ARCHITECTURE.md](ARCHITECTURE.md);上分帝 Web 视觉设计规范见 [DESIGN.md](DESIGN.md);协作约定见 [AGENTS.md](AGENTS.md);变更记录见 [CHANGELOG.md](CHANGELOG.md)。
## 原理 ## 原理
@@ -122,7 +122,7 @@ python web/refresh_web.py --tier patch --skip-deploy --skip-oss
顶部另有 **走势** 页:近 8 周各段位高胜率 / 上场率榜(`#/trends[/bracket]`STRATZ 周胜率数据)。 顶部另有 **走势** 页:近 8 周各段位高胜率 / 上场率榜(`#/trends[/bracket]`STRATZ 周胜率数据)。
顶部另有 **物品** 页:对齐 [官网商店物品](https://www.dota2.com.cn/items/index.htm) 的 **11 列竖排**(基础分类 5 列 + 合成分类 6 列),点选后详情显示在右侧(含合成组件)。目录来自 `web/data/item_shop.json``python web/fetch_item_shop.py`)。 顶部另有 **物品** 页:对齐 [官网商店物品](https://www.dota2.com.cn/items/index.htm) 的 **11 列竖排**(基础分类 5 列 + 合成分类 6 列),点选后详情显示在右侧(含合成组件)。目录来自 `web/data/item_shop.json``python web/fetch_item_shop.py`)。
顶部另有 **版本** 页:默认展示最新版本完整改动(综合 / 物品 / 中立物品 / 英雄),下拉切换近一年其它版本;数据来自 `web/data/patches.json``python web/fetch_patches.py`,近一年窗口可用 `--days` / `--since` 调)。 顶部另有 **版本** 页:默认展示最新版本完整改动(综合 / 物品 / 中立物品 / 英雄),下拉切换近一年其它版本;数据来自 `web/data/patches.json``python web/fetch_patches.py`,近一年窗口可用 `--days` / `--since` 调)。
数据在 `shared/data/relations.json`(英雄关系,不用胜率);选将全网格「克/搭/补」推荐读同一份文件,并用 `pc/draft_archetypes.py` 规则识别推进/全球流与敌我缺口(不接 AI)。热门装备来自 OpenDota 统计。**走势**优先 STRATZ`web/data/stratz_hero_meta.json`,按勋章近 8 周 + 最近 1 周分路;`python web/fetch_stratz_meta.py`,需 token),OpenDota 各段位胜率/上场率/场次在 `web/data/hero_stats.json`(近约 7 天兜底;不进推荐)。**对位**数值 Top `web/data/stratz_matchup_tops.json`(STRATZ 全局聚合相对优势,非走势页段位/周口径;与网格定性克/搭分开展示;有 OpenDota matchups 时页面可提示来源一致/分歧)。被克装备以描述标签 + 规则为主;`web/fetch_item_counter_stats.py` 另算敌方终局装备相对同装备全局基线的购买率提升/胜率差,对全部英雄的已有候选小幅调序;装备卡有数据时显示对阵该英雄的敌方队伍终局装备出现率。统计显著的新组合仍须确认机制成立后写入 `web/data/hero_fear_overrides.json`,避免“高相关但不克制”的误报。可改 `web/data/item_tag_overrides.json` 后重跑 `web/fetch_items_meta.py``web/item_fears.py`。**尚无对局内出装推荐**。 数据在 `shared/data/relations.json`(英雄关系,不用胜率);选将全网格「克/搭/补」推荐读同一份文件,并用 `pc/draft_archetypes.py` 规则识别推进/全球流与敌我缺口(不接 AI)。热门装备来自 OpenDota 统计。**走势**优先 STRATZ`web/data/stratz_hero_meta.json`,按勋章近 8 周 + 最近 1 周分路;`python web/fetch_stratz_meta.py`,需 token),OpenDota 各段位胜率/上场率/场次在 `web/data/hero_stats.json`(近约 7 天兜底;不进推荐)。**对位**数值 Top 仅用 `web/data/stratz_matchup_tops.json`(STRATZ 全局聚合相对优势,非走势页段位/周口径;与网格定性克/搭分开展示)。被克装备以描述标签 + 规则为主;`web/fetch_item_counter_stats.py` 另算敌方终局装备相对同装备全局基线的购买率提升/胜率差,对全部英雄的已有候选小幅调序;装备卡有数据时显示对阵该英雄的敌方队伍终局装备出现率。统计显著的新组合仍须确认机制成立后写入 `web/data/hero_fear_overrides.json`,避免“高相关但不克制”的误报。可改 `web/data/item_tag_overrides.json` 后重跑 `web/fetch_items_meta.py``web/item_fears.py`。**尚无对局内出装推荐**。
## 快速开始(GSI 自动跟踪) ## 快速开始(GSI 自动跟踪)
+6 -7
View File
@@ -102,14 +102,13 @@ def stats_from_opendota(raw: dict) -> dict:
"vision_day": int(_num(raw, "day_vision", 1800)), "vision_day": int(_num(raw, "day_vision", 1800)),
"vision_night": int(_num(raw, "night_vision", 800)), "vision_night": int(_num(raw, "night_vision", 800)),
} }
# turn_rate is null in OpenDota for heroes using the game default — omit # OpenDota leaves turn_rate null for the engine default (0.6); match
# rather than inventing 0.6 so the UI only shows an explicit value. # dota2.com.cn and always surface a value for the combat panel.
turn = raw.get("turn_rate") turn = raw.get("turn_rate")
if turn is not None: try:
try: result["turn_rate"] = _round1(float(turn)) if turn is not None else 0.6
result["turn_rate"] = _round1(float(turn)) except (TypeError, ValueError):
except (TypeError, ValueError): result["turn_rate"] = 0.6
pass
return result return result
+36 -141
View File
@@ -1,10 +1,12 @@
"""Audit qualitative relations.json against OpenDota + STRATZ. """Audit qualitative relations.json against STRATZ matchup / synergy caches.
Sources: Sources:
- shared/data/matchups.json OpenDota vs winrates
- shared/data/stratz_matchups.json STRATZ vs advantage (fetch_stratz.py --mode matchups) - shared/data/stratz_matchups.json STRATZ vs advantage (fetch_stratz.py --mode matchups)
- shared/data/synergies.json STRATZ teammate synergies - shared/data/synergies.json STRATZ teammate synergies
- web/data/stratz_matchup_tops.json Web 对位 Top (optional cross-check section)
Hero matchup numbers for the Web site come only from
web/data/stratz_matchup_tops.json (fetch_stratz_meta.py); this audit does not
cross-check against OpenDota.
Usage: Usage:
python shared/audit_relations.py python shared/audit_relations.py
@@ -23,14 +25,11 @@ import json
from datetime import datetime, timezone from datetime import datetime, timezone
from shared.grid import hero_table from shared.grid import hero_table
from shared.matchup_cross import audit_matchup_tops from shared.paths import ROOT, SHARED_DATA
from shared.paths import DATA, ROOT, SHARED_DATA
from shared.relations import DEFAULT_RELATIONS, load_relations from shared.relations import DEFAULT_RELATIONS, load_relations
MATCHUPS = SHARED_DATA / "matchups.json"
STRATZ_MATCHUPS = SHARED_DATA / "stratz_matchups.json" STRATZ_MATCHUPS = SHARED_DATA / "stratz_matchups.json"
SYNERGIES = SHARED_DATA / "synergies.json" SYNERGIES = SHARED_DATA / "synergies.json"
STRATZ_MATCHUP_TOPS = DATA / "stratz_matchup_tops.json"
# Legacy monorepo-move leftovers (repo-root data/). # Legacy monorepo-move leftovers (repo-root data/).
_LEGACY_DATA = ROOT / "data" _LEGACY_DATA = ROOT / "data"
@@ -52,10 +51,6 @@ def _load_json_safe(path: Path) -> dict:
return {} 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]]: def load_id_maps() -> tuple[dict[int, str], dict[str, int], dict[str, str]]:
table = hero_table() table = hero_table()
id_to_key = {int(h["id"]): h["key"] for h in table} id_to_key = {int(h["id"]): h["key"] for h in table}
@@ -64,41 +59,6 @@ def load_id_maps() -> tuple[dict[int, str], dict[str, int], dict[str, str]]:
return id_to_key, key_to_id, names 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]: def stratz_adv(by_hero: dict, aid: int, bid: int) -> tuple[float | None, int]:
cell = (by_hero.get(str(aid)) or {}).get(str(bid)) cell = (by_hero.get(str(aid)) or {}).get(str(bid))
if not cell: if not cell:
@@ -112,31 +72,23 @@ def stratz_adv(by_hero: dict, aid: int, bid: int) -> tuple[float | None, int]:
def main() -> None: def main() -> None:
ap = argparse.ArgumentParser(description=__doc__) ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--min-games", type=int, default=80) 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-agree", type=float, default=1.0)
ap.add_argument("--stratz-disagree", 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("--syn-agree", type=float, default=1.0)
ap.add_argument("--top", type=int, default=10) ap.add_argument("--suggest-min-adv", type=float, default=2.0)
ap.add_argument("--out", type=Path, default=SHARED_DATA / "relations_audit.json") ap.add_argument("--out", type=Path, default=SHARED_DATA / "relations_audit.json")
args = ap.parse_args() args = ap.parse_args()
matchups_path = _resolve_cache(MATCHUPS, _LEGACY_DATA / "matchups.json")
stratz_path = _resolve_cache(STRATZ_MATCHUPS, _LEGACY_DATA / "stratz_matchups.json") stratz_path = _resolve_cache(STRATZ_MATCHUPS, _LEGACY_DATA / "stratz_matchups.json")
syn_path = _resolve_cache(SYNERGIES, _LEGACY_DATA / "synergies.json") syn_path = _resolve_cache(SYNERGIES, _LEGACY_DATA / "synergies.json")
tops_path = _resolve_cache(STRATZ_MATCHUP_TOPS, _LEGACY_DATA / "stratz_matchup_tops.json")
id_to_key, key_to_id, names = load_id_maps() _id_to_key, key_to_id, names = load_id_maps()
all_keys = sorted(key_to_id.keys(), key=lambda k: key_to_id[k]) all_keys = sorted(key_to_id.keys(), key=lambda k: key_to_id[k])
rel = load_relations() rel = load_relations()
odota = _load_json_safe(matchups_path)
stratz = _load_json_safe(stratz_path) stratz = _load_json_safe(stratz_path)
syn_file = _load_json_safe(syn_path) syn_file = _load_json_safe(syn_path)
tops = _load_json_safe(tops_path)
by_o = odota.get("by_hero") or {}
by_s = stratz.get("by_hero") or {} by_s = stratz.get("by_hero") or {}
by_syn = syn_file.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 []] 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 []] syns = [(e["a"], e["b"], e.get("reason") or "") for e in rel.get("synergies") or []]
@@ -148,9 +100,7 @@ def main() -> None:
if aid is None or bid is None: if aid is None or bid is None:
counter_no_data.append({"a": a, "b": b, "why": "unknown_hero"}) counter_no_data.append({"a": a, "b": b, "why": "unknown_hero"})
continue continue
o_adv, o_games = odota_adv(by_o, baseline, aid, bid)
s_adv, s_games = stratz_adv(by_s, 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) r_s, r_sg = stratz_adv(by_s, bid, aid)
row = { row = {
"a": a, "a": a,
@@ -158,34 +108,23 @@ def main() -> None:
"b": b, "b": b,
"b_loc": names.get(b, b), "b_loc": names.get(b, b),
"reason": reason, "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_adv": None if s_adv is None else round(s_adv, 3),
"stratz_games": s_games, "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), "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_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 s_bad = (
if o_adv is None and s_adv is None: s_adv is not None and s_games >= args.min_games and s_adv <= args.stratz_disagree
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): if s_adv is None:
counter_no_data.append({**row, "why": "missing_stratz"})
elif s_games < args.min_games:
counter_no_data.append({**row, "why": "low_games"}) counter_no_data.append({**row, "why": "low_games"})
elif o_ok or s_ok: elif s_ok:
sources = [] counter_ok.append({**row, "agree_sources": ["stratz"]})
if o_ok: elif s_bad:
sources.append("opendota")
if s_ok:
sources.append("stratz")
counter_ok.append({**row, "agree_sources": sources})
elif o_bad or s_bad:
tip = "conflict" tip = "conflict"
if ( if r_s is not None and r_sg >= args.min_games and r_s >= args.stratz_agree:
(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" tip = "maybe_reverse"
counter_conflict.append({**row, "tip": tip}) counter_conflict.append({**row, "tip": tip})
else: else:
@@ -197,7 +136,9 @@ def main() -> None:
if aid is None or bid is None: if aid is None or bid is None:
syn_missing.append({"a": a, "b": b, "why": "unknown_hero"}) syn_missing.append({"a": a, "b": b, "why": "unknown_hero"})
continue continue
cell = (by_syn.get(str(aid)) or {}).get(str(bid)) or (by_syn.get(str(bid)) or {}).get(str(aid)) cell = (by_syn.get(str(aid)) or {}).get(str(bid)) or (
by_syn.get(str(bid)) or {}
).get(str(aid))
if not cell: if not cell:
syn_missing.append( syn_missing.append(
{ {
@@ -236,15 +177,11 @@ def main() -> None:
if akey == bkey or (akey, bkey) in known_counters: if akey == bkey or (akey, bkey) in known_counters:
continue continue
bid = key_to_id[bkey] 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) s_adv, s_games = stratz_adv(by_s, aid, bid)
if ( if (
o_adv is not None s_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_games >= args.min_games
and s_adv >= 2.0 and s_adv >= args.suggest_min_adv
): ):
suggestions.append( suggestions.append(
{ {
@@ -252,13 +189,11 @@ def main() -> None:
"a_loc": names[akey], "a_loc": names[akey],
"b": bkey, "b": bkey,
"b_loc": names[bkey], "b_loc": names[bkey],
"opendota_adv": round(o_adv, 4),
"opendota_games": o_games,
"stratz_adv": round(s_adv, 3), "stratz_adv": round(s_adv, 3),
"stratz_games": s_games, "stratz_games": s_games,
} }
) )
suggestions.sort(key=lambda r: (-r["stratz_adv"], -r["opendota_adv"])) suggestions.sort(key=lambda r: (-r["stratz_adv"], -r["stratz_games"]))
suggestions = suggestions[:100] suggestions = suggestions[:100]
counters_from: dict[str, int] = {k: 0 for k in all_keys} counters_from: dict[str, int] = {k: 0 for k in all_keys}
@@ -276,9 +211,6 @@ def main() -> None:
syn_count[b] += 1 syn_count[b] += 1
missing_cache = { 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": [ "stratz_matchups": [
f"{names[k]}({k})" for k in all_keys if str(key_to_id[k]) not in by_s f"{names[k]}({k})" for k in all_keys if str(key_to_id[k]) not in by_s
], ],
@@ -310,28 +242,17 @@ def main() -> None:
"counters_out": counters_from[k], "counters_out": counters_from[k],
"counters_in": counters_to[k], "counters_in": counters_to[k],
"synergies": syn_count[k], "synergies": syn_count[k],
"cross_source_suggestions": suggest_by_hero.get(k, 0), "stratz_suggestions": suggest_by_hero.get(k, 0),
} }
for k in empty + [x for x in thin if x not in empty] for k in empty + [x for x in thin if x not in empty]
], ],
key=lambda r: ( key=lambda r: (
r["counters_out"] + r["counters_in"] + r["synergies"], r["counters_out"] + r["counters_in"] + r["synergies"],
-r["cross_source_suggestions"], -r["stratz_suggestions"],
r["key"], r["key"],
), ),
) )
web_tops_audit = None
if tops.get("by_hero"):
web_tops_audit = audit_matchup_tops(
tops,
odota,
id_to_key=id_to_key,
key_to_id=key_to_id,
names=names,
min_games=args.min_games,
)
def _rel(path: Path) -> str | None: def _rel(path: Path) -> str | None:
if not path.is_file(): if not path.is_file():
return None return None
@@ -344,12 +265,6 @@ def main() -> None:
"generated_at": datetime.now(timezone.utc).isoformat(), "generated_at": datetime.now(timezone.utc).isoformat(),
"sources": { "sources": {
"relations": str(DEFAULT_RELATIONS.relative_to(ROOT)).replace("\\", "/"), "relations": str(DEFAULT_RELATIONS.relative_to(ROOT)).replace("\\", "/"),
"opendota_matchups": {
"path": _rel(matchups_path),
"source": odota.get("source"),
"fetched_at": odota.get("fetched_at"),
"heroes": len(by_o),
},
"stratz_matchups": { "stratz_matchups": {
"path": _rel(stratz_path), "path": _rel(stratz_path),
"source": stratz.get("source"), "source": stratz.get("source"),
@@ -362,20 +277,13 @@ def main() -> None:
"fetched_at": syn_file.get("fetched_at"), "fetched_at": syn_file.get("fetched_at"),
"heroes": len(by_syn), "heroes": len(by_syn),
}, },
"stratz_matchup_tops": {
"path": _rel(tops_path),
"source": tops.get("source"),
"fetched_at": tops.get("fetched_at"),
"heroes": len(tops.get("by_hero") or {}),
},
}, },
"thresholds": { "thresholds": {
"min_games": args.min_games, "min_games": args.min_games,
"odota_agree": args.odota_agree,
"odota_disagree": args.odota_disagree,
"stratz_agree": args.stratz_agree, "stratz_agree": args.stratz_agree,
"stratz_disagree": args.stratz_disagree, "stratz_disagree": args.stratz_disagree,
"syn_agree": args.syn_agree, "syn_agree": args.syn_agree,
"suggest_min_adv": args.suggest_min_adv,
}, },
"summary": { "summary": {
"relations_counters": len(counters), "relations_counters": len(counters),
@@ -401,16 +309,11 @@ def main() -> None:
}, },
"counter_conflicts": sorted( "counter_conflicts": sorted(
counter_conflict, counter_conflict,
key=lambda r: ((r.get("stratz_adv") or 0), (r.get("opendota_adv") or 0)), key=lambda r: (r.get("stratz_adv") or 0),
), ),
"counter_agree_both": [ "counter_agree": counter_ok,
r "suggest_add_counters": suggestions,
for r in counter_ok
if set(r.get("agree_sources") or []) == {"opendota", "stratz"}
],
"suggest_add_counters_cross_source": suggestions,
"synergy_conflicts": syn_conflict, "synergy_conflicts": syn_conflict,
"web_matchup_tops_cross": web_tops_audit,
} }
args.out.parent.mkdir(parents=True, exist_ok=True) args.out.parent.mkdir(parents=True, exist_ok=True)
@@ -421,26 +324,18 @@ def main() -> None:
s = report["summary"] s = report["summary"]
print("=== relations audit ===") print("=== relations audit ===")
print( print(
f"counters: agree={s['counter_agree']} (both={len(report['counter_agree_both'])}) " f"counters: agree={s['counter_agree']} "
f"conflict={s['counter_conflict']} weak={s['counter_weak']} no_data={s['counter_no_data']} " f"conflict={s['counter_conflict']} weak={s['counter_weak']} "
f"/ total={s['relations_counters']}" f"no_data={s['counter_no_data']} / total={s['relations_counters']}"
) )
print( print(
f"synergies: agree={s['synergy_agree']} conflict={s['synergy_conflict']} " f"synergies: agree={s['synergy_agree']} conflict={s['synergy_conflict']} "
f"weak/missing={s['synergy_weak_or_missing']} / total={s['relations_synergies']}" f"weak/missing={s['synergy_weak_or_missing']} / total={s['relations_synergies']}"
) )
print( print(
f"qualitative gaps: empty={s['heroes_empty_relations']} thin(<={1} edge)={s['heroes_thin_relations']} " f"qualitative gaps: empty={s['heroes_empty_relations']} "
f"/ heroes={s['heroes_total']}" f"thin(<={1} edge)={s['heroes_thin_relations']} / heroes={s['heroes_total']}"
) )
if web_tops_audit:
ws = web_tops_audit["summary"]
print(
"web matchup tops × OpenDota: "
f"agree={ws['agree']} conflict={ws['conflict']} weak={ws['weak']} "
f"/ rows={ws['counter_rows']} heroes={ws['heroes']}"
)
print(f" note: {web_tops_audit['manual_review_note']}")
print("cache gaps:") print("cache gaps:")
for k, v in missing_cache.items(): for k, v in missing_cache.items():
print(f" {k}: {len(v)}") print(f" {k}: {len(v)}")
@@ -449,7 +344,7 @@ def main() -> None:
if row["counters_out"] + row["counters_in"] + row["synergies"] == 0: if row["counters_out"] + row["counters_in"] + row["synergies"] == 0:
print( print(
f" {row['name_loc']}({row['key']}) " f" {row['name_loc']}({row['key']}) "
f"suggest={row['cross_source_suggestions']}" f"suggest={row['stratz_suggestions']}"
) )
print(f"\nwrote {args.out}") print(f"\nwrote {args.out}")
+5688 -5606
View File
File diff suppressed because it is too large Load Diff
-86
View File
@@ -1,86 +0,0 @@
"""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 sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import time
import urllib.error
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.http_utils import http_json
from shared.paths import SHARED_DATA
API = "https://api.opendota.com/api/heroes/{hero_id}/matchups"
OUT = SHARED_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()
-281
View File
@@ -1,281 +0,0 @@
"""Cross-check STRATZ web matchup tops against OpenDota hero matchups.
Web-only observation evidence — never merge into relations.json or recommend.
Statuses:
agree — OpenDota baseline-adjusted advantage agrees with STRATZ direction
conflict — OpenDota disagrees with STRATZ direction (enough games)
weak — OpenDota sample too small or advantage near zero
"""
from __future__ import annotations
from typing import Any
# Defaults aligned with shared/audit_relations.py thresholds.
DEFAULT_MIN_GAMES = 80
DEFAULT_ODOTA_AGREE = 0.015
DEFAULT_ODOTA_DISAGREE = -0.015
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 classify_cross_source(
*,
stratz_signed: float,
odota_adv_val: float | None,
odota_games: int,
min_games: int = DEFAULT_MIN_GAMES,
odota_agree: float = DEFAULT_ODOTA_AGREE,
odota_disagree: float = DEFAULT_ODOTA_DISAGREE,
) -> str:
"""Classify whether OpenDota agrees with a STRATZ signed advantage.
``stratz_signed`` > 0 means STRATZ says A is favored vs B (counters).
For countered rows the caller should pass the original vs advantage
(positive = A still favored), not the negated display value.
"""
if odota_adv_val is None or odota_games < min_games:
return "weak"
if stratz_signed >= 0:
if odota_adv_val >= odota_agree:
return "agree"
if odota_adv_val <= odota_disagree:
return "conflict"
return "weak"
if odota_adv_val <= -odota_agree:
return "agree"
if odota_adv_val >= -odota_disagree:
return "conflict"
return "weak"
def enrich_entry_cross(
entry: dict,
*,
hero_id: int,
kind: str,
by_odota: dict,
baseline: dict[int, float],
min_games: int = DEFAULT_MIN_GAMES,
) -> dict:
"""Attach ``cross`` quality blob to one counters/countered/synergies row.
For ``countered`` rows, STRATZ stores negated advantage for display; we
restore the original vs-sign for classification (``-advantage``).
Synergies skip OpenDota (no teammate WR in matchups.json).
"""
out = dict(entry)
peer = int(entry.get("hero_id") or 0)
if kind == "synergies" or peer <= 0 or hero_id <= 0:
out["cross"] = {"status": "weak", "reason": "no_odota_synergy"}
return out
o_adv, o_games = odota_adv(by_odota, baseline, hero_id, peer)
raw_adv = float(entry.get("advantage") or 0.0)
# countered display advantage is already negated; restore original vs sign.
stratz_signed = -raw_adv if kind == "countered" else raw_adv
status = classify_cross_source(
stratz_signed=stratz_signed,
odota_adv_val=o_adv,
odota_games=o_games,
min_games=min_games,
)
out["cross"] = {
"status": status,
"opendota_adv": None if o_adv is None else round(o_adv, 4),
"opendota_games": o_games,
"min_games": min_games,
}
return out
def enrich_hero_matchups_cross(
cell: dict,
*,
hero_id: int,
by_odota: dict,
baseline: dict[int, float],
min_games: int = DEFAULT_MIN_GAMES,
) -> dict:
"""Return a shallow-copied hero matchup cell with per-row ``cross`` fields."""
out = {
"counters": [
enrich_entry_cross(
e,
hero_id=hero_id,
kind="counters",
by_odota=by_odota,
baseline=baseline,
min_games=min_games,
)
for e in (cell.get("counters") or [])
if isinstance(e, dict)
],
"countered": [
enrich_entry_cross(
e,
hero_id=hero_id,
kind="countered",
by_odota=by_odota,
baseline=baseline,
min_games=min_games,
)
for e in (cell.get("countered") or [])
if isinstance(e, dict)
],
"synergies": [
enrich_entry_cross(
e,
hero_id=hero_id,
kind="synergies",
by_odota=by_odota,
baseline=baseline,
min_games=min_games,
)
for e in (cell.get("synergies") or [])
if isinstance(e, dict)
],
}
for k in ("fetched_at", "stale"):
if k in cell:
out[k] = cell[k]
return out
def summarize_cross_rows(rows: list[dict]) -> dict[str, int]:
counts = {"agree": 0, "conflict": 0, "weak": 0}
for e in rows:
status = ((e.get("cross") or {}).get("status")) or "weak"
if status not in counts:
status = "weak"
counts[status] += 1
return counts
def audit_matchup_tops(
tops: dict,
odota: dict,
*,
id_to_key: dict[int, str],
key_to_id: dict[str, int],
names: dict[str, str],
min_games: int = DEFAULT_MIN_GAMES,
) -> dict[str, Any]:
"""Audit STRATZ web tops vs OpenDota; return report section (no file IO)."""
by_odota = odota.get("by_hero") or {}
baseline = build_baseline(by_odota)
by_hero = tops.get("by_hero") or {}
pairs: list[dict] = []
summary = {
"heroes": 0,
"counter_rows": 0,
"agree": 0,
"conflict": 0,
"weak": 0,
}
for key, cell in sorted(by_hero.items()):
if not isinstance(cell, dict):
continue
hid = key_to_id.get(key)
if hid is None:
# Prefer embedded id when key map lags new heroes.
hid = int(cell.get("id") or 0) or None
if hid is None:
continue
summary["heroes"] += 1
enriched = enrich_hero_matchups_cross(
cell,
hero_id=hid,
by_odota=by_odota,
baseline=baseline,
min_games=min_games,
)
for kind in ("counters", "countered"):
for e in enriched.get(kind) or []:
summary["counter_rows"] += 1
cross = e.get("cross") or {}
status = cross.get("status") or "weak"
summary[status] = summary.get(status, 0) + 1
peer_id = int(e.get("hero_id") or 0)
peer_key = id_to_key.get(peer_id, str(peer_id))
pairs.append(
{
"hero": key,
"hero_loc": names.get(key, key),
"peer": peer_key,
"peer_loc": names.get(peer_key, peer_key),
"kind": kind,
"stratz_advantage": e.get("advantage"),
"stratz_wr": e.get("wr"),
"stratz_games": e.get("games"),
"status": status,
"opendota_adv": cross.get("opendota_adv"),
"opendota_games": cross.get("opendota_games"),
}
)
conflicts = [p for p in pairs if p["status"] == "conflict"]
conflicts.sort(
key=lambda r: (
abs(float(r.get("stratz_advantage") or 0)),
-(int(r.get("opendota_games") or 0)),
),
reverse=True,
)
agrees = [p for p in pairs if p["status"] == "agree"]
agrees.sort(
key=lambda r: (
abs(float(r.get("stratz_advantage") or 0)),
-(int(r.get("opendota_games") or 0)),
),
reverse=True,
)
return {
"summary": summary,
"thresholds": {"min_games": min_games},
"manual_review_note": (
"Dota2ProTracker (7k+ MMR / pro) is a manual high-MMR reference for "
"conflict rows; not automated (login-gated)."
),
"conflicts": conflicts[:100],
"agrees_sample": agrees[:40],
}
+2
View File
@@ -34,6 +34,8 @@ WEB_ASSETS = WEB_ROOT / "assets"
HERO_PORTRAITS = WEB_ASSETS / "hero_portraits" HERO_PORTRAITS = WEB_ASSETS / "hero_portraits"
# Primary-attribute icons from dota2.com (dota_react/icons/hero_*.png); UI only. # Primary-attribute icons from dota2.com (dota_react/icons/hero_*.png); UI only.
ATTR_ICONS = WEB_ASSETS / "attr_icons" ATTR_ICONS = WEB_ASSETS / "attr_icons"
# Valve hero-selection role filter icons, chroma keyed to transparent PNGs.
ROLE_ICONS = WEB_ASSETS / "role_icons"
# Item icons from Steam CDN (dota_react/items/{key}.png); Climperor web site only. # Item icons from Steam CDN (dota_react/items/{key}.png); Climperor web site only.
ITEM_ICONS = WEB_ASSETS / "item_icons" ITEM_ICONS = WEB_ASSETS / "item_icons"
# Shop category header icons from dota2.com.cn/items/images/itemcat_*.png. # Shop category header icons from dota2.com.cn/items/images/itemcat_*.png.
@@ -1,7 +1,7 @@
"""Unit tests for STRATZ matchup ranking + OpenDota cross classification. """Unit tests for STRATZ matchup ranking + cache helpers.
Run from repo root: Run from repo root:
python -m unittest shared.tests.test_matchup_cross -v python -m unittest shared.tests.test_stratz_matchups -v
""" """
from __future__ import annotations from __future__ import annotations
@@ -13,11 +13,6 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2])) sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from shared.matchup_cross import ( # noqa: E402
classify_cross_source,
enrich_entry_cross,
enrich_hero_matchups_cross,
)
from web.fetch_stratz_meta import ( # noqa: E402 from web.fetch_stratz_meta import ( # noqa: E402
annotate_matchup_cell, annotate_matchup_cell,
build_matchup_file_payload, build_matchup_file_payload,
@@ -125,80 +120,5 @@ class MatchupCacheHelpersTests(unittest.TestCase):
self.assertIn("antimage", loaded) self.assertIn("antimage", loaded)
class CrossClassifyTests(unittest.TestCase):
def test_agree_conflict_weak(self) -> None:
self.assertEqual(
classify_cross_source(
stratz_signed=5.0, odota_adv_val=0.04, odota_games=200
),
"agree",
)
self.assertEqual(
classify_cross_source(
stratz_signed=5.0, odota_adv_val=-0.04, odota_games=200
),
"conflict",
)
self.assertEqual(
classify_cross_source(
stratz_signed=5.0, odota_adv_val=0.04, odota_games=10
),
"weak",
)
def test_countered_uses_restored_sign(self) -> None:
# Display advantage for countered is +8 (negated); original vs was -8.
by_odota = {
"1": {
"2": {"games": 200, "wins": 80}, # AM wr 40% vs peer
}
}
# Baseline for hero 1 ≈ 0.4 from only this matchup.
from shared.matchup_cross import build_baseline
baseline = build_baseline(by_odota)
entry = enrich_entry_cross(
{
"hero_id": 2,
"games": 100,
"wins": 40,
"advantage": 8.0,
"wr": 0.6,
},
hero_id=1,
kind="countered",
by_odota=by_odota,
baseline=baseline,
min_games=80,
)
# odota_adv = 0.4 - 0.4 = 0 → weak
self.assertEqual(entry["cross"]["status"], "weak")
def test_enrich_hero_keeps_stale_flag(self) -> None:
cell = {
"counters": [
{
"hero_id": 94,
"games": 100,
"wins": 60,
"advantage": 3.0,
"wr": 0.6,
}
],
"countered": [],
"synergies": [],
"fetched_at": "t0",
"stale": True,
}
out = enrich_hero_matchups_cross(
cell,
hero_id=1,
by_odota={},
baseline={},
)
self.assertTrue(out["stale"])
self.assertEqual(out["cross"] if False else out["counters"][0]["cross"]["status"], "weak")
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+1
View File
@@ -37,6 +37,7 @@ ENDPOINT = "https://oss-cn-shanghai.aliyuncs.com"
REGION = "cn-shanghai" REGION = "cn-shanghai"
ASSET_DIRS = ( ASSET_DIRS = (
"attr", "attr",
"role-icon",
"rank", "rank",
"item", "item",
"item-cat", "item-cat",
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+4 -1
View File
@@ -41,6 +41,7 @@ from shared.paths import (
ITEM_CAT_ICONS, ITEM_CAT_ICONS,
ITEM_ICONS, ITEM_ICONS,
RANK_ICONS, RANK_ICONS,
ROLE_ICONS,
ROOT, ROOT,
STREAMER_AVATARS, STREAMER_AVATARS,
STREAMER_VIDEOS, STREAMER_VIDEOS,
@@ -51,7 +52,7 @@ from shared.paths import (
from serve_relations import WEB_DIR, build_payload from serve_relations import WEB_DIR, build_payload
SITE_VERSION = "0.5.87" SITE_VERSION = "0.5.109"
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com" DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
@@ -71,6 +72,7 @@ def populate_static_assets(out: Path, payload: dict) -> dict[str, int]:
"""Copy web site image dirs into ``out`` (portrait/item/ability/...).""" """Copy web site image dirs into ``out`` (portrait/item/ability/...)."""
counts = { counts = {
"attr": copy_glob(ATTR_ICONS, out / "attr"), "attr": copy_glob(ATTR_ICONS, out / "attr"),
"role-icon": copy_glob(ROLE_ICONS, out / "role-icon"),
"rank": copy_glob(RANK_ICONS, out / "rank", "rank*.png"), "rank": copy_glob(RANK_ICONS, out / "rank", "rank*.png"),
"item": copy_glob(ITEM_ICONS, out / "item"), "item": copy_glob(ITEM_ICONS, out / "item"),
"item-cat": copy_glob(ITEM_CAT_ICONS, out / "item-cat", "itemcat_*.png"), "item-cat": copy_glob(ITEM_CAT_ICONS, out / "item-cat", "itemcat_*.png"),
@@ -260,3 +262,4 @@ def main() -> None:
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+42 -2
View File
@@ -1,7 +1,8 @@
"""Download UI art used by https://www.dota2.com/heroes. """Download UI art used by https://www.dota2.com/heroes / dota2.com.cn.
- Landscape hero cards: Steam CDN `heroes/{key}.png` - Landscape hero cards: Steam CDN `heroes/{key}.png`
- Attribute icons: Steam CDN `icons/hero_{strength,agility,...}.png` - Attribute icons: Steam CDN `icons/hero_{strength,agility,...}.png`
- Combat stat icons: dota2.com.cn `herostatic/stats/icon_*.png`
Separate from `templates/cdn/` face crops used for top-bar matching. Separate from `templates/cdn/` face crops used for top-bar matching.
@@ -12,6 +13,7 @@ Usage:
Writes: Writes:
assets/hero_portraits/{key}.png assets/hero_portraits/{key}.png
assets/attr_icons/{str,agi,int,all}.png assets/attr_icons/{str,agi,int,all}.png
assets/ui_icons/icon_{damage,armor,...}.png
""" """
from __future__ import annotations from __future__ import annotations
@@ -25,7 +27,7 @@ import argparse
import json import json
from shared.http_utils import fetch_hero_keys, http_bytes from shared.http_utils import fetch_hero_keys, http_bytes
from shared.paths import ATTR_ICONS, HERO_PORTRAITS, HEROES_JSON from shared.paths import ATTR_ICONS, HERO_PORTRAITS, HEROES_JSON, UI_ICONS
# wide = face headshots; crop = waist-up 3D renders (usually worse in dense grids). # wide = face headshots; crop = waist-up 3D renders (usually worse in dense grids).
CARD_URL = ( CARD_URL = (
@@ -43,6 +45,19 @@ ATTR_FILES = {
"int": "hero_intelligence", "int": "hero_intelligence",
"all": "hero_universal", "all": "hero_universal",
} }
# Same filenames as https://www.dota2.com.cn/hero/<key> DetailsStats panel.
COMBAT_STAT_ICON_URL = "https://www.dota2.com.cn/herostatic/stats/{name}.png"
COMBAT_STAT_ICONS = (
"icon_damage",
"icon_attack_time",
"icon_attack_range",
"icon_projectile_speed",
"icon_armor",
"icon_magic_resist",
"icon_movement_speed",
"icon_turn_rate",
"icon_vision",
)
def hero_keys() -> list[str]: def hero_keys() -> list[str]:
@@ -71,6 +86,25 @@ def fetch_attr_icons(*, force: bool) -> tuple[int, int, int]:
return ok, skip, fail return ok, skip, fail
def fetch_combat_stat_icons(*, force: bool) -> tuple[int, int, int]:
UI_ICONS.mkdir(parents=True, exist_ok=True)
ok = skip = fail = 0
for name in COMBAT_STAT_ICONS:
out = UI_ICONS / f"{name}.png"
if out.is_file() and not force:
skip += 1
continue
try:
data = http_bytes(COMBAT_STAT_ICON_URL.format(name=name), timeout=30)
out.write_bytes(data)
ok += 1
print(f" combat {name} ({len(data)} bytes)")
except Exception as e: # noqa: BLE001
fail += 1
print(f" FAIL combat {name}: {e}")
return ok, skip, fail
def main() -> None: def main() -> None:
ap = argparse.ArgumentParser(description="Download dota2.com/heroes UI art") 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("--force", action="store_true", help="re-download existing files")
@@ -85,6 +119,9 @@ def main() -> None:
print("attribute icons...") print("attribute icons...")
a_ok, a_skip, a_fail = fetch_attr_icons(force=args.force) a_ok, a_skip, a_fail = fetch_attr_icons(force=args.force)
print("combat stat icons...")
c_ok, c_skip, c_fail = fetch_combat_stat_icons(force=args.force)
HERO_PORTRAITS.mkdir(parents=True, exist_ok=True) HERO_PORTRAITS.mkdir(parents=True, exist_ok=True)
keys = hero_keys() keys = hero_keys()
ok = skip = fail = 0 ok = skip = fail = 0
@@ -104,6 +141,9 @@ def main() -> None:
fail += 1 fail += 1
print(f" FAIL {key}: {e}") print(f" FAIL {key}: {e}")
print(f"attrs: downloaded={a_ok} skipped={a_skip} failed={a_fail} -> {ATTR_ICONS}") print(f"attrs: downloaded={a_ok} skipped={a_skip} failed={a_fail} -> {ATTR_ICONS}")
print(
f"combat: downloaded={c_ok} skipped={c_skip} failed={c_fail} -> {UI_ICONS}"
)
print(f"heroes: downloaded={ok} skipped={skip} failed={fail} -> {HERO_PORTRAITS}") print(f"heroes: downloaded={ok} skipped={skip} failed={fail} -> {HERO_PORTRAITS}")
+116 -107
View File
@@ -56,6 +56,21 @@ function rankIconSrc(file) {
return assetUrl(`rank/${name}`); return assetUrl(`rank/${name}`);
} }
const ROLE_ICON_FILES = {
核心: "Carry.png",
辅助: "Support.png",
推进: "Pusher.png",
逃生: "Escape.png",
爆发: "Nuker.png",
先手: "Initiator.png",
控制: "Disabler.png",
耐久: "Durable.png",
};
function roleIconSrc(file) {
return assetUrl(`role-icon/${encodeURIComponent(file)}`);
}
function siteVersionLabel() { function siteVersionLabel() {
const v = typeof SITE_VERSION === "string" ? SITE_VERSION.trim() : ""; const v = typeof SITE_VERSION === "string" ? SITE_VERSION.trim() : "";
return v ? `v${v}` : ""; return v ? `v${v}` : "";
@@ -243,10 +258,37 @@ function renderTagbar() {
if (!bar) return; if (!bar) return;
const order = state.data.tag_order || []; const order = state.data.tag_order || [];
bar.innerHTML = ""; bar.innerHTML = "";
const addLabel = (button, text, iconFile) => {
if (iconFile) {
const icon = document.createElement("img");
icon.className = "tagbar-role-icon";
icon.src = roleIconSrc(iconFile);
icon.alt = "";
icon.setAttribute("aria-hidden", "true");
button.appendChild(icon);
} else {
const icon = document.createElement("span");
icon.className = "tagbar-all-icon";
icon.setAttribute("aria-hidden", "true");
icon.innerHTML =
'<svg viewBox="0 0 16 16" focusable="false"><path d="M1.5 1.5h5v5h-5zM9.5 1.5h5v5h-5zM1.5 9.5h5v5h-5zM9.5 9.5h5v5h-5z"/></svg>';
button.appendChild(icon);
}
const label = document.createElement("span");
label.textContent = text;
button.appendChild(label);
};
const selectedHero = state.selectedKey ? heroByKey(state.selectedKey) : null;
const heroTags = new Set(selectedHero?.tags || []);
const allBtn = document.createElement("button"); const allBtn = document.createElement("button");
allBtn.type = "button"; allBtn.type = "button";
allBtn.textContent = "全部"; addLabel(allBtn, "全部");
allBtn.className = state.tagFilters.size ? "" : "active"; // "全部" only when no filter and no hero selected; hero roles use `.matched`.
allBtn.className =
!state.tagFilters.size && !state.selectedKey ? "active" : "";
allBtn.addEventListener("click", () => { allBtn.addEventListener("click", () => {
state.tagFilters.clear(); state.tagFilters.clear();
syncStateToUrl({ replace: true }); syncStateToUrl({ replace: true });
@@ -254,10 +296,16 @@ function renderTagbar() {
}); });
bar.appendChild(allBtn); bar.appendChild(allBtn);
for (const tag of order) { for (const tag of order) {
const iconFile = ROLE_ICON_FILES[tag];
if (!iconFile) continue;
const btn = document.createElement("button"); const btn = document.createElement("button");
btn.type = "button"; btn.type = "button";
btn.textContent = tag; addLabel(btn, tag, iconFile);
if (state.tagFilters.has(tag)) btn.classList.add("active"); if (state.tagFilters.has(tag)) btn.classList.add("active");
if (heroTags.has(tag)) {
btn.classList.add("matched");
btn.title = "该英雄定位";
}
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
if (state.tagFilters.has(tag)) state.tagFilters.delete(tag); if (state.tagFilters.has(tag)) state.tagFilters.delete(tag);
else state.tagFilters.add(tag); else state.tagFilters.add(tag);
@@ -1674,7 +1722,6 @@ function stratzMatchupPack() {
kind: "global_aggregate", kind: "global_aggregate",
label_zh: "全局聚合(未按段位 / 分路 / 周过滤)", label_zh: "全局聚合(未按段位 / 分路 / 周过滤)",
}, },
cross_source: null,
stats: {}, stats: {},
} }
); );
@@ -1985,13 +2032,6 @@ function formatMatchupWr(wr) {
return `${(Number(wr) * 100).toFixed(1)}%`; return `${(Number(wr) * 100).toFixed(1)}%`;
} }
function matchupCrossLabel(cross) {
const status = cross?.status;
if (status === "agree") return "来源一致";
if (status === "conflict") return "来源分歧";
return null;
}
function buildMatchupColumn(title, entries, scoreKey, scoreLabel) { function buildMatchupColumn(title, entries, scoreKey, scoreLabel) {
const col = document.createElement("div"); const col = document.createElement("div");
col.className = "matchup-col"; col.className = "matchup-col";
@@ -2040,40 +2080,20 @@ function buildMatchupColumn(title, entries, scoreKey, scoreLabel) {
const meta = document.createElement("span"); const meta = document.createElement("span");
meta.className = "matchup-meta detail-muted"; meta.className = "matchup-meta detail-muted";
const wrText = formatMatchupWr(row.wr); const wrText = formatMatchupWr(row.wr);
const crossLab = matchupCrossLabel(row.cross); meta.textContent = [`胜率 ${wrText}`, `${formatPickCount(row.games)}`].join(
meta.textContent = [ " · "
`胜率 ${wrText}`, );
`${formatPickCount(row.games)}`,
crossLab,
]
.filter(Boolean)
.join(" · ");
mid.appendChild(name); mid.appendChild(name);
mid.appendChild(meta); mid.appendChild(meta);
const score = document.createElement("span"); const score = document.createElement("span");
score.className = "matchup-score"; score.className = "matchup-score";
score.textContent = formatAdvantage(row[scoreKey]); score.textContent = formatAdvantage(row[scoreKey]);
const crossStatus = row.cross?.status;
if (crossStatus === "agree") score.classList.add("is-agree");
if (crossStatus === "conflict") score.classList.add("is-conflict");
const odotaTip =
row.cross && row.cross.opendota_games != null
? ` · OpenDota 基线差 ${
row.cross.opendota_adv == null
? "—"
: formatAdvantage(row.cross.opendota_adv * 100)
}pp / ${formatPickCount(row.cross.opendota_games)} `
: "";
score.title = [ score.title = [
`STRATZ 相对优势 ${formatAdvantage(row[scoreKey])}(非胜率百分点)`, `STRATZ 相对优势 ${formatAdvantage(row[scoreKey])}(非胜率百分点)`,
`对局胜率 ${wrText}`, `对局胜率 ${wrText}`,
`${formatPickCount(row.games)}`, `${formatPickCount(row.games)}`,
crossLab ? `交叉:${crossLab}` : "", ].join(" · ");
odotaTip,
]
.filter(Boolean)
.join(" · ");
item.appendChild(img); item.appendChild(img);
item.appendChild(mid); item.appendChild(mid);
@@ -2115,20 +2135,9 @@ function buildMatchupsPanel(heroKey) {
const notes = document.createElement("div"); const notes = document.createElement("div");
notes.className = "matchup-notes detail-muted"; notes.className = "matchup-notes detail-muted";
const hintBits = [
"数字是相对表现(综合胜率与自身强弱),不是直接胜率;也不分段位、分路,和「走势」页不是同一套数据。",
];
const cross = pack.cross_source;
if (cross && cross.available === false) {
hintBits.push("暂无其他数据源可对照,交叉结论仅供参考。");
} else if (cross && cross.available) {
hintBits.push(
"「来源一致 / 分歧」表示是否与 OpenDota 公开统计方向相同,仅作对照。"
);
}
const hint = document.createElement("p"); const hint = document.createElement("p");
hint.className = "matchup-note-line"; hint.className = "matchup-note-line";
hint.textContent = hintBits.join(" "); hint.textContent = "数据来自 STRATZ(相对优势,全局聚合)。";
notes.appendChild(hint); notes.appendChild(hint);
const stale = cell.stale === true; const stale = cell.stale === true;
@@ -2369,7 +2378,7 @@ function mountItemInspect(root, meta, { onPickItem, hideIcon = false } = {}) {
const nameRow = document.createElement("div"); const nameRow = document.createElement("div");
nameRow.className = "item-detail-name-row"; nameRow.className = "item-detail-name-row";
const title = document.createElement("div"); const title = document.createElement("h3");
title.className = "item-detail-name"; title.className = "item-detail-name";
title.textContent = meta.name_loc || meta.key; title.textContent = meta.name_loc || meta.key;
nameRow.appendChild(title); nameRow.appendChild(title);
@@ -2590,28 +2599,6 @@ function appendVitalBar(parent, kind, label, value, regen, scale) {
parent.appendChild(bar); parent.appendChild(bar);
} }
/** Hero role tags (skills tab center column, above skill icons). */
function buildHeroTagsRow(hero) {
const tags = document.createElement("div");
tags.className = "detail-tags";
tags.setAttribute("aria-label", "英雄定位");
const tagList = hero.tags || [];
if (!tagList.length) {
const none = document.createElement("span");
none.className = "detail-muted";
none.textContent = "暂无定位";
tags.appendChild(none);
return tags;
}
for (const t of tagList) {
const chip = document.createElement("span");
chip.className = "tag-chip";
chip.textContent = t;
tags.appendChild(chip);
}
return tags;
}
/** Left column: HP/Mana + STR/AGI/INT (skills tab only). */ /** Left column: HP/Mana + STR/AGI/INT (skills tab only). */
function buildHeroVitalsAttrs(hero) { function buildHeroVitalsAttrs(hero) {
if (hero.base_str == null && hero.health == null) return null; if (hero.base_str == null && hero.health == null) return null;
@@ -2672,38 +2659,67 @@ function buildHeroVitalsAttrs(hero) {
return strip.childNodes.length ? strip : null; return strip.childNodes.length ? strip : null;
} }
/** Official dota2.com.cn herostatic/stats icons (served from /ui-icon/). */
function combatStatIconSrc(name) {
return assetUrl(`ui-icon/${name}`);
}
/** Right rail: Attack / Defense / Mobility as 3 side-by-side columns (skills tab). */ /** Right rail: Attack / Defense / Mobility as 3 side-by-side columns (skills tab). */
function buildHeroCombatStats(hero) { function buildHeroCombatStats(hero) {
// Rows: [iconFile, labelZh, value] — icons match dota2.com.cn hero pages.
const combatGroups = [ const combatGroups = [
{ {
title: "攻击", title: "攻击",
rows: [ rows: [
hero.damage_min != null && hero.damage_max != null hero.damage_min != null && hero.damage_max != null
? ["伤害", `${hero.damage_min}${hero.damage_max}`] ? ["icon_damage.png", "伤害", `${hero.damage_min}${hero.damage_max}`]
: null,
hero.attack_rate != null
? ["icon_attack_time.png", "间隔", fmtNum(hero.attack_rate, 1)]
: null,
hero.attack_range != null
? ["icon_attack_range.png", "距离", String(hero.attack_range)]
: null, : null,
hero.attack_rate != null ? ["间隔", fmtNum(hero.attack_rate, 1)] : null,
hero.attack_range != null ? ["距离", String(hero.attack_range)] : null,
// Melee heroes often have projectile_speed 0 in OpenDota — skip those. // Melee heroes often have projectile_speed 0 in OpenDota — skip those.
hero.projectile_speed != null && Number(hero.projectile_speed) > 0 hero.projectile_speed != null && Number(hero.projectile_speed) > 0
? ["弹道", String(hero.projectile_speed)] ? [
"icon_projectile_speed.png",
"弹道",
String(hero.projectile_speed),
]
: null, : null,
].filter(Boolean), ].filter(Boolean),
}, },
{ {
title: "防御", title: "防御",
rows: [ rows: [
hero.armor != null ? ["护甲", fmtNum(hero.armor, 1)] : null, hero.armor != null
hero.magic_resist != null ? ["魔抗", `${hero.magic_resist}%`] : null, ? ["icon_armor.png", "护甲", fmtNum(hero.armor, 1)]
: null,
hero.magic_resist != null
? ["icon_magic_resist.png", "魔抗", `${hero.magic_resist}%`]
: null,
].filter(Boolean), ].filter(Boolean),
}, },
{ {
title: "机动性", title: "机动性",
rows: [ rows: [
hero.move_speed != null ? ["移速", String(hero.move_speed)] : null, hero.move_speed != null
hero.turn_rate != null ? ["转身", fmtNum(hero.turn_rate, 1)] : null, ? ["icon_movement_speed.png", "移速", String(hero.move_speed)]
hero.vision_day != null && hero.vision_night != null
? ["视野", `白天:${hero.vision_day}\n夜晚:${hero.vision_night}`]
: null, : null,
// OpenDota omits the engine default 0.6; show it like dota2.com.cn.
[
"icon_turn_rate.png",
"转身",
fmtNum(hero.turn_rate != null ? hero.turn_rate : 0.6, 1),
],
[
"icon_vision.png",
"视野",
`${hero.vision_day != null ? hero.vision_day : 1800} / ${
hero.vision_night != null ? hero.vision_night : 800
}`,
],
].filter(Boolean), ].filter(Boolean),
}, },
].filter((g) => g.rows.length); ].filter((g) => g.rows.length);
@@ -2730,30 +2746,21 @@ function buildHeroCombatStats(hero) {
section.appendChild(title); section.appendChild(title);
const list = document.createElement("dl"); const list = document.createElement("dl");
list.className = "hero-stats-combat-list"; list.className = "hero-stats-combat-list";
for (const [label, val] of g.rows) { for (const [icon, label, val] of g.rows) {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "hero-stat-row"; row.className = "hero-stat-row";
const dt = document.createElement("dt"); const dt = document.createElement("dt");
dt.textContent = label; const img = document.createElement("img");
img.className = "hero-stat-icon";
img.src = combatStatIconSrc(icon);
img.alt = label;
img.title = label;
img.width = 20;
img.height = 20;
img.decoding = "async";
dt.appendChild(img);
const dd = document.createElement("dd"); const dd = document.createElement("dd");
if (typeof val === "string" && val.includes("\n")) { dd.textContent = val;
for (const line of val.split("\n")) {
const div = document.createElement("div");
const idx = line.indexOf("");
if (idx > 0) {
const sub = document.createElement("span");
sub.className = "hero-stat-row-sub";
sub.textContent = line.slice(0, idx + 1);
div.appendChild(sub);
div.appendChild(document.createTextNode(line.slice(idx + 1)));
} else {
div.textContent = line;
}
dd.appendChild(div);
}
} else {
dd.textContent = val;
}
row.appendChild(dt); row.appendChild(dt);
row.appendChild(dd); row.appendChild(dd);
list.appendChild(row); list.appendChild(row);
@@ -2771,12 +2778,11 @@ function renderDetail() {
root.innerHTML = ""; root.innerHTML = "";
if (!state.selectedKey) { if (!state.selectedKey) {
root.classList.add("empty"); root.classList.add("empty", "is-collapsed");
root.textContent = "点击英雄查看定位、技能、核心装备、公开数据与被克装备";
return; return;
} }
root.classList.remove("empty"); root.classList.remove("is-collapsed", "empty");
const hero = heroByKey(state.selectedKey); const hero = heroByKey(state.selectedKey);
if (!hero) { if (!hero) {
root.classList.add("empty"); root.classList.add("empty");
@@ -2786,7 +2792,7 @@ function renderDetail() {
const head = document.createElement("div"); const head = document.createElement("div");
head.className = "detail-head"; head.className = "detail-head";
const title = document.createElement("div"); const title = document.createElement("h2");
title.className = "detail-name"; title.className = "detail-name";
title.textContent = hero.name_loc || hero.key; title.textContent = hero.name_loc || hero.key;
head.appendChild(title); head.appendChild(title);
@@ -2887,7 +2893,6 @@ function renderDetail() {
const center = document.createElement("div"); const center = document.createElement("div");
center.className = "detail-skills-main"; center.className = "detail-skills-main";
center.appendChild(buildHeroTagsRow(hero));
center.appendChild(buildSkillsPanel(state.selectedKey)); center.appendChild(buildSkillsPanel(state.selectedKey));
center.appendChild(renderHeroInspect(state.selectedKey)); center.appendChild(renderHeroInspect(state.selectedKey));
layout.appendChild(center); layout.appendChild(center);
@@ -3739,6 +3744,7 @@ function renderMatchesPage() {
const body = $("#matches-body"); const body = $("#matches-body");
const originsEl = $("#matches-origins"); const originsEl = $("#matches-origins");
const playersEl = $("#matches-players"); const playersEl = $("#matches-players");
const sub = $("#matches-sub");
if (!body) return; if (!body) return;
const origin = MATCH_ORIGIN_FILTERS.some((o) => o.id === state.matchesOrigin) const origin = MATCH_ORIGIN_FILTERS.some((o) => o.id === state.matchesOrigin)
@@ -3845,6 +3851,7 @@ function renderMatchesPage() {
const summary = total const summary = total
? `${total} 场 · 第 ${page}/${pageCount}` ? `${total} 场 · 第 ${page}/${pageCount}`
: ""; : "";
if (sub) sub.textContent = summary;
if (!rows.length) { if (!rows.length) {
body.innerHTML = state.matchesPlayerId body.innerHTML = state.matchesPlayerId
@@ -5385,7 +5392,8 @@ function syncChrome() {
btn.classList.toggle("active", btn.dataset.page === state.page); btn.classList.toggle("active", btn.dataset.page === state.page);
}); });
const heroesTb = $("#heroes-toolbar"); const heroesTb = $("#heroes-toolbar");
const itemsTb = $("#items-toolbar"); const heroSearch = $("#q");
const itemSearch = $("#q-item");
const heroesView = $("#heroes-view"); const heroesView = $("#heroes-view");
const rankingsView = $("#rankings-view"); const rankingsView = $("#rankings-view");
const matchesView = $("#matches-view"); const matchesView = $("#matches-view");
@@ -5396,7 +5404,8 @@ function syncChrome() {
const patchesView = $("#patches-view"); const patchesView = $("#patches-view");
const detail = $("#detail"); const detail = $("#detail");
if (heroesTb) heroesTb.classList.toggle("hidden", state.page !== "heroes"); if (heroesTb) heroesTb.classList.toggle("hidden", state.page !== "heroes");
if (itemsTb) itemsTb.classList.toggle("hidden", state.page !== "items"); if (heroSearch) heroSearch.classList.toggle("hidden", state.page !== "heroes");
if (itemSearch) itemSearch.classList.toggle("hidden", state.page !== "items");
if (heroesView) heroesView.classList.toggle("hidden", state.page !== "heroes"); if (heroesView) heroesView.classList.toggle("hidden", state.page !== "heroes");
if (rankingsView) rankingsView.classList.toggle("hidden", state.page !== "rankings"); if (rankingsView) rankingsView.classList.toggle("hidden", state.page !== "rankings");
if (matchesView) matchesView.classList.toggle("hidden", state.page !== "matches"); if (matchesView) matchesView.classList.toggle("hidden", state.page !== "matches");
+2 -1
View File
@@ -1,4 +1,5 @@
/* Local defaults; production export overwrites via export_relations_site.py. */ /* Local defaults; production export overwrites via export_relations_site.py. */
var SITE_VERSION = "0.5.87"; var SITE_VERSION = "0.5.109";
var ABILITY_VIDEO_BASE = ""; var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = ""; var STATIC_ASSET_BASE = "";
+58 -45
View File
@@ -5,17 +5,18 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DOTA2 上分帝</title> <title>DOTA2 上分帝</title>
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" /> <link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
<link rel="stylesheet" href="/style.css?v=0.5.87" /> <link rel="stylesheet" href="/style.css?v=0.5.109" />
<script src="/mobile-gate.js?v=0.5.87"></script> <script src="/mobile-gate.js?v=0.5.109"></script>
</head> </head>
<body> <body>
<h1 class="sr-only">DOTA2 上分帝</h1>
<div id="mobile-gate" class="mobile-gate" role="dialog" aria-labelledby="mobile-gate-title" aria-modal="true"> <div id="mobile-gate" class="mobile-gate" role="dialog" aria-labelledby="mobile-gate-title" aria-modal="true">
<div class="mobile-gate-card"> <div class="mobile-gate-card">
<div class="mobile-gate-brand" aria-hidden="true"> <div class="mobile-gate-brand" aria-hidden="true">
<img class="mobile-gate-logo" src="/ui-icon/dota2_logo_wordmark.png" alt="" /> <img class="mobile-gate-logo" src="/ui-icon/dota2_logo_wordmark.png" alt="" />
<span class="mobile-gate-brand-title">上分帝</span> <span class="mobile-gate-brand-title">上分帝</span>
</div> </div>
<h1 id="mobile-gate-title" class="mobile-gate-title">暂不支持移动端</h1> <h2 id="mobile-gate-title" class="mobile-gate-title">暂不支持移动端</h2>
<p class="mobile-gate-desc">请从 PC 端浏览器访问本站。</p> <p class="mobile-gate-desc">请从 PC 端浏览器访问本站。</p>
<button type="button" id="mobile-demand-btn" class="mobile-demand-btn">催更移动端</button> <button type="button" id="mobile-demand-btn" class="mobile-demand-btn">催更移动端</button>
<p id="mobile-demand-count" class="mobile-demand-count" hidden> <p id="mobile-demand-count" class="mobile-demand-count" hidden>
@@ -25,60 +26,59 @@
</div> </div>
<header class="topbar"> <header class="topbar">
<div class="brand" aria-label="Dota 2 上分帝"> <div class="topbar-primary">
<img class="brand-logo" src="/ui-icon/dota2_logo_wordmark.png" alt="Dota 2" /> <div class="brand" aria-label="Dota 2 上分帝">
<span class="brand-title">上分帝</span> <img class="brand-logo" src="/ui-icon/dota2_logo_wordmark.png" alt="Dota 2" />
</div> <span class="brand-title">上分帝</span>
<a </div>
class="contact-mail" <nav class="main-tabs" aria-label="主分页">
href="mailto:c9mhs8vfmuyv@outlook.com" <button type="button" class="main-tab active" data-page="heroes">英雄</button>
aria-label="邮件联系" <button type="button" class="main-tab" data-page="rankings">排行</button>
title="c9mhs8vfmuyv@outlook.com" <button type="button" class="main-tab" data-page="streamers">主播</button>
> <button type="button" class="main-tab" data-page="matches">比赛</button>
<svg class="contact-mail-icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false"> <button type="button" class="main-tab" data-page="trends">走势</button>
<path fill="currentColor" d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4-8 5-8-5V6l8 5 8-5v2z"/> <button type="button" class="main-tab" data-page="mechanics">机制</button>
</svg> <button type="button" class="main-tab" data-page="items">物品</button>
</a> <button type="button" class="main-tab" data-page="patches">版本</button>
<nav class="main-tabs" aria-label="主分页"> </nav>
<button type="button" class="main-tab active" data-page="heroes">英雄</button> <div class="topbar-tools">
<button type="button" class="main-tab" data-page="rankings">排行</button> <input id="q" class="search" type="search" placeholder="搜索英雄或别名" autocomplete="off" spellcheck="false" aria-label="搜索英雄" />
<button type="button" class="main-tab" data-page="streamers">主播</button> <input id="q-item" class="search search-inline hidden" type="search" placeholder="搜索物品" autocomplete="off" spellcheck="false" aria-label="搜索物品" />
<button type="button" class="main-tab" data-page="matches">比赛</button> <a
<button type="button" class="main-tab" data-page="trends">走势</button> class="contact-mail"
<button type="button" class="main-tab" data-page="mechanics">机制</button> href="mailto:c9mhs8vfmuyv@outlook.com"
<button type="button" class="main-tab" data-page="items">物品</button> aria-label="邮件联系"
<button type="button" class="main-tab" data-page="patches">版本</button> title="c9mhs8vfmuyv@outlook.com"
</nav> >
<svg class="contact-mail-icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
<div id="heroes-toolbar" class="toolbar"> <path fill="currentColor" d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4-8 5-8-5V6l8 5 8-5v2z"/>
<div class="tagbar" id="tagbar" aria-label="定位筛选"></div> </svg>
<input id="q" class="search" type="search" placeholder="搜索英雄或别名" autocomplete="off" spellcheck="false" aria-label="搜索英雄" /> </a>
</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>
</div> </div>
</header> </header>
<main id="heroes-view" class="board"> <main id="heroes-view" class="board">
<div class="columns" id="columns"></div> <div class="columns" id="columns"></div>
</main> </main>
<div id="heroes-toolbar" class="toolbar hero-role-toolbar">
<div class="tagbar" id="tagbar" aria-label="英雄定位筛选"></div>
</div>
<main id="rankings-view" class="board rankings-board hidden"> <main id="rankings-view" class="board rankings-board hidden">
<div class="rankings-cluster"> <div class="rankings-cluster">
<div class="rankings-center-wrap"> <div class="rankings-center-wrap">
<div class="rankings-body" id="rankings-body"></div> <div class="rankings-body" id="rankings-body"></div>
<aside class="rankings-aside" aria-label="排行筛选"> <aside class="rankings-aside" aria-label="排行筛选">
<h2 class="rankings-title"> <h2 class="rankings-title page-title">
<span class="rankings-title-row"> <span class="rankings-title-row">
<span class="rankings-kicker" id="rankings-kicker">Immortal 排行榜</span> <span class="rankings-kicker" id="rankings-kicker">Immortal 排行榜</span>
<span id="rankings-tip-slot"></span> <span id="rankings-tip-slot"></span>
</span> </span>
</h2> </h2>
<div class="rankings-regions" id="rankings-regions" role="tablist" aria-label="选择地区"></div> <div class="rankings-regions" id="rankings-regions" role="tablist" aria-label="选择地区"></div>
<p class="rankings-sub" id="rankings-sub"></p> <p class="rankings-sub page-sub" id="rankings-sub"></p>
</aside> </aside>
</div> </div>
</div> </div>
@@ -89,8 +89,14 @@
<div class="rankings-center-wrap matches-center-wrap"> <div class="rankings-center-wrap matches-center-wrap">
<div class="rankings-body matches-body" id="matches-body"></div> <div class="rankings-body matches-body" id="matches-body"></div>
<aside class="rankings-aside" aria-label="比赛筛选"> <aside class="rankings-aside" aria-label="比赛筛选">
<h2 class="rankings-title page-title">
<span class="rankings-title-row">
<span class="rankings-kicker" id="matches-kicker">明星比赛</span>
</span>
</h2>
<div class="matches-origins" id="matches-origins" role="tablist" aria-label="职业或国服"></div> <div class="matches-origins" id="matches-origins" role="tablist" aria-label="职业或国服"></div>
<div class="matches-players" id="matches-players" role="tablist" aria-label="选择选手"></div> <div class="matches-players" id="matches-players" role="tablist" aria-label="选择选手"></div>
<p class="rankings-sub page-sub" id="matches-sub"></p>
</aside> </aside>
</div> </div>
</div> </div>
@@ -110,14 +116,14 @@
<div class="rankings-center-wrap trends-center-wrap"> <div class="rankings-center-wrap trends-center-wrap">
<div class="rankings-body" id="trends-body"></div> <div class="rankings-body" id="trends-body"></div>
<aside class="rankings-aside" aria-label="走势筛选"> <aside class="rankings-aside" aria-label="走势筛选">
<h2 class="rankings-title"> <h2 class="rankings-title page-title">
<span class="rankings-title-row"> <span class="rankings-title-row">
<span class="rankings-kicker" id="trends-kicker">过去 8 周走势</span> <span class="rankings-kicker" id="trends-kicker">过去 8 周走势</span>
</span> </span>
</h2> </h2>
<div class="trends-brackets" id="trends-brackets" aria-label="选择段位"></div> <div class="trends-brackets" id="trends-brackets" aria-label="选择段位"></div>
<div class="trends-sort" id="trends-sort" role="tablist" aria-label="排序"></div> <div class="trends-sort" id="trends-sort" role="tablist" aria-label="排序"></div>
<p class="rankings-sub" id="trends-sub"></p> <p class="rankings-sub page-sub" id="trends-sub"></p>
</aside> </aside>
</div> </div>
</div> </div>
@@ -128,7 +134,7 @@
<div class="rankings-center-wrap mechanics-center-wrap"> <div class="rankings-center-wrap mechanics-center-wrap">
<div class="rankings-body mechanics-body" id="mechanics-body"></div> <div class="rankings-body mechanics-body" id="mechanics-body"></div>
<aside class="rankings-aside" aria-label="机制筛选"> <aside class="rankings-aside" aria-label="机制筛选">
<h2 class="rankings-title"> <h2 class="rankings-title page-title">
<span class="rankings-title-row"> <span class="rankings-title-row">
<span class="rankings-kicker">机制查询</span> <span class="rankings-kicker">机制查询</span>
</span> </span>
@@ -142,6 +148,10 @@
<main id="items-view" class="board items-board hidden"> <main id="items-view" class="board items-board hidden">
<div class="items-cluster"> <div class="items-cluster">
<div class="items-main"> <div class="items-main">
<header class="page-header items-page-header">
<h2 class="page-title">物品商店</h2>
<p class="page-sub">基础 / 合成分类目录</p>
</header>
<div class="shop-col-headers" aria-hidden="true"> <div class="shop-col-headers" aria-hidden="true">
<span class="shop-col-header basic">基础分类</span> <span class="shop-col-header basic">基础分类</span>
<span class="shop-col-header upgraded">合成分类</span> <span class="shop-col-header upgraded">合成分类</span>
@@ -156,7 +166,10 @@
<main id="patches-view" class="board patches-board hidden"> <main id="patches-view" class="board patches-board hidden">
<div class="patches-header"> <div class="patches-header">
<h2 class="patches-title"><span class="patches-kicker">游戏性更新</span><span class="patches-ver" id="patch-ver"></span></h2> <h2 class="patches-title">
<span class="patches-kicker page-kicker">游戏性更新</span>
<span class="patches-ver" id="patch-ver"></span>
</h2>
<select id="patch-select" aria-label="选择版本"></select> <select id="patch-select" aria-label="选择版本"></select>
</div> </div>
<div class="patches-detail" id="patches-detail"></div> <div class="patches-detail" id="patches-detail"></div>
@@ -165,8 +178,8 @@
<section class="detail" id="detail" aria-live="polite"></section> <section class="detail" id="detail" aria-live="polite"></section>
<script src="/config.js?v=0.5.87"></script> <script src="/config.js?v=0.5.109"></script>
<script src="/router.js?v=0.5.87"></script> <script src="/router.js?v=0.5.109"></script>
<script src="/app.js?v=0.5.87"></script> <script src="/app.js?v=0.5.109"></script>
</body> </body>
</html> </html>
+506 -261
View File
File diff suppressed because it is too large Load Diff
+36 -64
View File
@@ -28,7 +28,6 @@ import urllib.error
from shared.grid import ATTR_ORDER, hero_table from shared.grid import ATTR_ORDER, hero_table
from shared.hero_tags import TAG_ORDER, tags_for_hero from shared.hero_tags import TAG_ORDER, tags_for_hero
from shared.http_utils import http_bytes from shared.http_utils import http_bytes
from shared.matchup_cross import build_baseline, enrich_hero_matchups_cross
from shared.paths import ( from shared.paths import (
ABILITY_ICONS, ABILITY_ICONS,
ABILITY_VIDEOS, ABILITY_VIDEOS,
@@ -38,8 +37,8 @@ from shared.paths import (
ITEM_CAT_ICONS, ITEM_CAT_ICONS,
ITEM_ICONS, ITEM_ICONS,
RANK_ICONS, RANK_ICONS,
ROLE_ICONS,
ROOT, ROOT,
SHARED_DATA,
STREAMER_AVATARS, STREAMER_AVATARS,
STREAMER_VIDEOS, STREAMER_VIDEOS,
TEMPLATES_CDN, TEMPLATES_CDN,
@@ -91,7 +90,6 @@ PRO_MATCHES_PATH = DATA / "pro_matches.json"
STREAMERS_PATH = DATA / "streamers.json" STREAMERS_PATH = DATA / "streamers.json"
STRATZ_HERO_META_PATH = DATA / "stratz_hero_meta.json" STRATZ_HERO_META_PATH = DATA / "stratz_hero_meta.json"
STRATZ_MATCHUP_TOPS_PATH = DATA / "stratz_matchup_tops.json" STRATZ_MATCHUP_TOPS_PATH = DATA / "stratz_matchup_tops.json"
OPENDOTA_MATCHUPS_PATH = SHARED_DATA / "matchups.json"
_LEGACY_DATA = ROOT / "data" _LEGACY_DATA = ROOT / "data"
@@ -428,23 +426,8 @@ def load_stratz_hero_meta() -> dict:
} }
def _load_opendota_matchups() -> dict:
path = _first_existing(OPENDOTA_MATCHUPS_PATH, _LEGACY_DATA / "matchups.json")
if path is None:
return {}
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return raw if isinstance(raw, dict) else {}
def load_stratz_matchup_tops() -> dict: def load_stratz_matchup_tops() -> dict:
"""STRATZ vs/with top lists per hero (see fetch_stratz_meta.py). Web only. """STRATZ vs/with top lists per hero (see fetch_stratz_meta.py). Web only."""
When OpenDota matchups.json is present, each counter/countered row gets a
``cross`` quality blob (agree / conflict / weak). Observation only.
"""
empty: dict = { empty: dict = {
"fetched_at": None, "fetched_at": None,
"started_at": None, "started_at": None,
@@ -459,7 +442,6 @@ def load_stratz_matchup_tops() -> dict:
}, },
"note": None, "note": None,
"stats": {}, "stats": {},
"cross_source": None,
"by_hero": {}, "by_hero": {},
} }
path = _first_existing( path = _first_existing(
@@ -474,9 +456,8 @@ def load_stratz_matchup_tops() -> dict:
if not isinstance(raw, dict): if not isinstance(raw, dict):
return empty return empty
by_hero_raw = dict(raw.get("by_hero") or {})
scope = raw.get("scope") if isinstance(raw.get("scope"), dict) else empty["scope"] scope = raw.get("scope") if isinstance(raw.get("scope"), dict) else empty["scope"]
out: dict = { return {
"fetched_at": raw.get("fetched_at"), "fetched_at": raw.get("fetched_at"),
"started_at": raw.get("started_at"), "started_at": raw.get("started_at"),
"finished_at": raw.get("finished_at"), "finished_at": raw.get("finished_at"),
@@ -487,50 +468,9 @@ def load_stratz_matchup_tops() -> dict:
"scope": scope, "scope": scope,
"note": raw.get("note"), "note": raw.get("note"),
"stats": dict(raw.get("stats") or {}), "stats": dict(raw.get("stats") or {}),
"cross_source": None, "by_hero": dict(raw.get("by_hero") or {}),
"by_hero": by_hero_raw,
} }
odota = _load_opendota_matchups()
by_odota = odota.get("by_hero") or {}
if not by_odota:
out["cross_source"] = {
"available": False,
"label_zh": "暂无可靠交叉结论",
"reason": "missing_opendota_matchups",
}
return out
key_to_id = {h["key"]: int(h["id"]) for h in hero_table()}
baseline = build_baseline(by_odota)
enriched: dict[str, dict] = {}
for key, cell in by_hero_raw.items():
if not isinstance(cell, dict):
continue
hid = key_to_id.get(key)
if hid is None:
enriched[key] = cell
continue
enriched[key] = enrich_hero_matchups_cross(
cell,
hero_id=hid,
by_odota=by_odota,
baseline=baseline,
)
out["by_hero"] = enriched
out["cross_source"] = {
"available": True,
"source": "opendota",
"attribution": odota.get("attribution") or "https://opendota.com",
"fetched_at": odota.get("fetched_at"),
"label_zh": "OpenDota 独立交叉(基线校正)",
"manual_review_note": (
"Dota2ProTracker7k+ / 职业)仅作人工复核「来源分歧」条目的高分段参考,"
"不进入自动流水线。"
),
}
return out
def load_hero_abilities() -> dict: def load_hero_abilities() -> dict:
"""Slim per-hero abilities / Aghs upgrades / talents for the web hero pane.""" """Slim per-hero abilities / Aghs upgrades / talents for the web hero pane."""
@@ -932,6 +872,27 @@ class Handler(BaseHTTPRequestHandler):
return return
self._send(200, fpath.read_bytes(), "image/png") self._send(200, fpath.read_bytes(), "image/png")
return return
if path.startswith("/role-icon/"):
key = path[len("/role-icon/") :]
allowed_role_icons = {
"Carry.png",
"Support.png",
"Pusher.png",
"Escape.png",
"Nuker.png",
"Initiator.png",
"Disabler.png",
"Durable.png",
}
if key not in allowed_role_icons:
self._json(400, {"error": "bad role icon"})
return
fpath = ROLE_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/"): if path.startswith("/ui-icon/"):
key = path[len("/ui-icon/") :] key = path[len("/ui-icon/") :]
allowed_ui = { allowed_ui = {
@@ -939,6 +900,17 @@ class Handler(BaseHTTPRequestHandler):
"dota2_logo.png", "dota2_logo.png",
"dota2_logo_wordmark.png", "dota2_logo_wordmark.png",
"platform_douyin.png", "platform_douyin.png",
"platform_bilibili.png",
# Hero combat stats (dota2.com.cn/herostatic/stats)
"icon_damage.png",
"icon_attack_time.png",
"icon_attack_range.png",
"icon_projectile_speed.png",
"icon_armor.png",
"icon_magic_resist.png",
"icon_movement_speed.png",
"icon_turn_rate.png",
"icon_vision.png",
} }
if key not in allowed_ui: if key not in allowed_ui:
self._json(400, {"error": "bad ui icon"}) self._json(400, {"error": "bad ui icon"})