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>
@@ -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
|
||||||
|
|||||||
@@ -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` | 面向用户的版本变更 |
|
||||||
|
|
||||||
|
|||||||
@@ -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 约 8–21 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`
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 约 8–21 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 / 气泡),模糊约 24–32px、黑色 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`)。最小点击高度 36–40px。
|
||||||
|
|
||||||
|
### 业务标记(不可通用化)
|
||||||
|
|
||||||
|
- 克 / 怕 / 搭 → `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=` 缓存戳。
|
||||||
|
|||||||
@@ -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 自动跟踪)
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
result["turn_rate"] = _round1(float(turn)) if turn is not None else 0.6
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
result["turn_rate"] = 0.6
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,8 @@
|
|||||||
"projectile_speed": 0,
|
"projectile_speed": 0,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
@@ -81,7 +82,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 3,
|
"id": 3,
|
||||||
@@ -122,7 +124,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 1200
|
"vision_night": 1200,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 4,
|
"id": 4,
|
||||||
@@ -165,7 +168,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 5,
|
"id": 5,
|
||||||
@@ -208,7 +212,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 6,
|
"id": 6,
|
||||||
@@ -341,7 +346,8 @@
|
|||||||
"projectile_speed": 0,
|
"projectile_speed": 0,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 9,
|
"id": 9,
|
||||||
@@ -388,7 +394,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 10,
|
"id": 10,
|
||||||
@@ -522,7 +529,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 13,
|
"id": 13,
|
||||||
@@ -651,7 +659,8 @@
|
|||||||
"projectile_speed": 2000,
|
"projectile_speed": 2000,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 16,
|
"id": 16,
|
||||||
@@ -696,7 +705,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 17,
|
"id": 17,
|
||||||
@@ -790,7 +800,8 @@
|
|||||||
"projectile_speed": 0,
|
"projectile_speed": 0,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 19,
|
"id": 19,
|
||||||
@@ -835,7 +846,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 20,
|
"id": 20,
|
||||||
@@ -880,7 +892,8 @@
|
|||||||
"projectile_speed": 1500,
|
"projectile_speed": 1500,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 21,
|
"id": 21,
|
||||||
@@ -967,7 +980,8 @@
|
|||||||
"projectile_speed": 1100,
|
"projectile_speed": 1100,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 23,
|
"id": 23,
|
||||||
@@ -1014,7 +1028,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 25,
|
"id": 25,
|
||||||
@@ -1057,7 +1072,8 @@
|
|||||||
"projectile_speed": 1000,
|
"projectile_speed": 1000,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 26,
|
"id": 26,
|
||||||
@@ -1098,7 +1114,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 27,
|
"id": 27,
|
||||||
@@ -1145,7 +1162,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 28,
|
"id": 28,
|
||||||
@@ -1191,7 +1209,8 @@
|
|||||||
"projectile_speed": 0,
|
"projectile_speed": 0,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 29,
|
"id": 29,
|
||||||
@@ -1239,7 +1258,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 30,
|
"id": 30,
|
||||||
@@ -1280,7 +1300,8 @@
|
|||||||
"projectile_speed": 1200,
|
"projectile_speed": 1200,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 31,
|
"id": 31,
|
||||||
@@ -1359,7 +1380,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 33,
|
"id": 33,
|
||||||
@@ -1398,7 +1420,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 34,
|
"id": 34,
|
||||||
@@ -1437,7 +1460,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 35,
|
"id": 35,
|
||||||
@@ -1520,7 +1544,8 @@
|
|||||||
"projectile_speed": 1200,
|
"projectile_speed": 1200,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 37,
|
"id": 37,
|
||||||
@@ -1559,7 +1584,8 @@
|
|||||||
"projectile_speed": 1200,
|
"projectile_speed": 1200,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 38,
|
"id": 38,
|
||||||
@@ -1602,7 +1628,8 @@
|
|||||||
"projectile_speed": 0,
|
"projectile_speed": 0,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 39,
|
"id": 39,
|
||||||
@@ -1645,7 +1672,8 @@
|
|||||||
"projectile_speed": 1500,
|
"projectile_speed": 1500,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 40,
|
"id": 40,
|
||||||
@@ -1692,7 +1720,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 41,
|
"id": 41,
|
||||||
@@ -1788,7 +1817,8 @@
|
|||||||
"projectile_speed": 0,
|
"projectile_speed": 0,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 43,
|
"id": 43,
|
||||||
@@ -1913,7 +1943,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 46,
|
"id": 46,
|
||||||
@@ -1998,7 +2029,8 @@
|
|||||||
"projectile_speed": 1200,
|
"projectile_speed": 1200,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 48,
|
"id": 48,
|
||||||
@@ -2039,7 +2071,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 49,
|
"id": 49,
|
||||||
@@ -2088,7 +2121,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 50,
|
"id": 50,
|
||||||
@@ -2176,7 +2210,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 52,
|
"id": 52,
|
||||||
@@ -2221,7 +2256,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 53,
|
"id": 53,
|
||||||
@@ -2357,7 +2393,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 56,
|
"id": 56,
|
||||||
@@ -2399,7 +2436,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 57,
|
"id": 57,
|
||||||
@@ -2442,7 +2480,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 58,
|
"id": 58,
|
||||||
@@ -2487,7 +2526,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 59,
|
"id": 59,
|
||||||
@@ -2528,7 +2568,8 @@
|
|||||||
"projectile_speed": 1400,
|
"projectile_speed": 1400,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 60,
|
"id": 60,
|
||||||
@@ -2575,7 +2616,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 800,
|
"vision_day": 800,
|
||||||
"vision_night": 1800
|
"vision_night": 1800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 61,
|
"id": 61,
|
||||||
@@ -2620,7 +2662,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 62,
|
"id": 62,
|
||||||
@@ -2661,7 +2704,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 1000
|
"vision_night": 1000,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 63,
|
"id": 63,
|
||||||
@@ -2700,7 +2744,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 64,
|
"id": 64,
|
||||||
@@ -2823,7 +2868,8 @@
|
|||||||
"projectile_speed": 1100,
|
"projectile_speed": 1100,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 67,
|
"id": 67,
|
||||||
@@ -2864,7 +2910,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 68,
|
"id": 68,
|
||||||
@@ -2907,7 +2954,8 @@
|
|||||||
"projectile_speed": 1250,
|
"projectile_speed": 1250,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 69,
|
"id": 69,
|
||||||
@@ -2954,7 +3002,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 70,
|
"id": 70,
|
||||||
@@ -2996,7 +3045,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 71,
|
"id": 71,
|
||||||
@@ -3044,7 +3094,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 72,
|
"id": 72,
|
||||||
@@ -3087,7 +3138,8 @@
|
|||||||
"projectile_speed": 3000,
|
"projectile_speed": 3000,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 73,
|
"id": 73,
|
||||||
@@ -3136,7 +3188,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 74,
|
"id": 74,
|
||||||
@@ -3183,7 +3236,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 75,
|
"id": 75,
|
||||||
@@ -3228,7 +3282,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 76,
|
"id": 76,
|
||||||
@@ -3271,7 +3326,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 77,
|
"id": 77,
|
||||||
@@ -3312,7 +3368,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 78,
|
"id": 78,
|
||||||
@@ -3360,7 +3417,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 79,
|
"id": 79,
|
||||||
@@ -3406,7 +3464,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 80,
|
"id": 80,
|
||||||
@@ -3450,7 +3509,8 @@
|
|||||||
"projectile_speed": 1125,
|
"projectile_speed": 1125,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 81,
|
"id": 81,
|
||||||
@@ -3498,7 +3558,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 82,
|
"id": 82,
|
||||||
@@ -3591,7 +3652,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 84,
|
"id": 84,
|
||||||
@@ -3638,7 +3700,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 85,
|
"id": 85,
|
||||||
@@ -3683,7 +3746,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 86,
|
"id": 86,
|
||||||
@@ -3769,7 +3833,8 @@
|
|||||||
"projectile_speed": 1200,
|
"projectile_speed": 1200,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 88,
|
"id": 88,
|
||||||
@@ -3814,7 +3879,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 89,
|
"id": 89,
|
||||||
@@ -3864,7 +3930,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 90,
|
"id": 90,
|
||||||
@@ -3997,7 +4064,8 @@
|
|||||||
"projectile_speed": 1400,
|
"projectile_speed": 1400,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 93,
|
"id": 93,
|
||||||
@@ -4086,7 +4154,8 @@
|
|||||||
"projectile_speed": 1200,
|
"projectile_speed": 1200,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 95,
|
"id": 95,
|
||||||
@@ -4131,7 +4200,8 @@
|
|||||||
"projectile_speed": 1200,
|
"projectile_speed": 1200,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 96,
|
"id": 96,
|
||||||
@@ -4179,7 +4249,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 97,
|
"id": 97,
|
||||||
@@ -4266,7 +4337,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 99,
|
"id": 99,
|
||||||
@@ -4398,7 +4470,8 @@
|
|||||||
"projectile_speed": 1000,
|
"projectile_speed": 1000,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 102,
|
"id": 102,
|
||||||
@@ -4485,7 +4558,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 104,
|
"id": 104,
|
||||||
@@ -4532,7 +4606,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 105,
|
"id": 105,
|
||||||
@@ -4571,7 +4646,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 106,
|
"id": 106,
|
||||||
@@ -4666,7 +4742,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 108,
|
"id": 108,
|
||||||
@@ -4713,7 +4790,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 109,
|
"id": 109,
|
||||||
@@ -4885,7 +4963,8 @@
|
|||||||
"projectile_speed": 700,
|
"projectile_speed": 700,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 1200
|
"vision_night": 1200,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 113,
|
"id": 113,
|
||||||
@@ -4975,7 +5054,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 119,
|
"id": 119,
|
||||||
@@ -5116,7 +5196,8 @@
|
|||||||
"projectile_speed": 900,
|
"projectile_speed": 900,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 123,
|
"id": 123,
|
||||||
@@ -5254,7 +5335,8 @@
|
|||||||
"projectile_speed": 1800,
|
"projectile_speed": 1800,
|
||||||
"magic_resist": 25,
|
"magic_resist": 25,
|
||||||
"vision_day": 1800,
|
"vision_day": 1800,
|
||||||
"vision_night": 800
|
"vision_night": 800,
|
||||||
|
"turn_rate": 0.6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 129,
|
"id": 129,
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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],
|
|
||||||
}
|
|
||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")) {
|
|
||||||
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;
|
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");
|
||||||
|
|||||||
@@ -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 = "";
|
||||||
|
|
||||||
|
|||||||
@@ -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,20 +26,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
|
<div class="topbar-primary">
|
||||||
<div class="brand" aria-label="Dota 2 上分帝">
|
<div class="brand" aria-label="Dota 2 上分帝">
|
||||||
<img class="brand-logo" src="/ui-icon/dota2_logo_wordmark.png" alt="Dota 2" />
|
<img class="brand-logo" src="/ui-icon/dota2_logo_wordmark.png" alt="Dota 2" />
|
||||||
<span class="brand-title">上分帝</span>
|
<span class="brand-title">上分帝</span>
|
||||||
</div>
|
</div>
|
||||||
<a
|
|
||||||
class="contact-mail"
|
|
||||||
href="mailto:c9mhs8vfmuyv@outlook.com"
|
|
||||||
aria-label="邮件联系"
|
|
||||||
title="c9mhs8vfmuyv@outlook.com"
|
|
||||||
>
|
|
||||||
<svg class="contact-mail-icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
|
|
||||||
<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"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
<nav class="main-tabs" aria-label="主分页">
|
<nav class="main-tabs" aria-label="主分页">
|
||||||
<button type="button" class="main-tab active" data-page="heroes">英雄</button>
|
<button type="button" class="main-tab active" data-page="heroes">英雄</button>
|
||||||
<button type="button" class="main-tab" data-page="rankings">排行</button>
|
<button type="button" class="main-tab" data-page="rankings">排行</button>
|
||||||
@@ -49,36 +41,44 @@
|
|||||||
<button type="button" class="main-tab" data-page="items">物品</button>
|
<button type="button" class="main-tab" data-page="items">物品</button>
|
||||||
<button type="button" class="main-tab" data-page="patches">版本</button>
|
<button type="button" class="main-tab" data-page="patches">版本</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="topbar-tools">
|
||||||
<div id="heroes-toolbar" class="toolbar">
|
|
||||||
<div class="tagbar" id="tagbar" aria-label="定位筛选"></div>
|
|
||||||
<input id="q" class="search" type="search" placeholder="搜索英雄或别名" autocomplete="off" spellcheck="false" aria-label="搜索英雄" />
|
<input id="q" class="search" type="search" placeholder="搜索英雄或别名" autocomplete="off" spellcheck="false" aria-label="搜索英雄" />
|
||||||
|
<input id="q-item" class="search search-inline hidden" type="search" placeholder="搜索物品" autocomplete="off" spellcheck="false" aria-label="搜索物品" />
|
||||||
|
<a
|
||||||
|
class="contact-mail"
|
||||||
|
href="mailto:c9mhs8vfmuyv@outlook.com"
|
||||||
|
aria-label="邮件联系"
|
||||||
|
title="c9mhs8vfmuyv@outlook.com"
|
||||||
|
>
|
||||||
|
<svg class="contact-mail-icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
|
||||||
|
<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"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="items-toolbar" class="toolbar hidden">
|
|
||||||
<div class="item-tools">
|
|
||||||
<input id="q-item" class="search search-inline" type="search" placeholder="搜索物品" autocomplete="off" spellcheck="false" aria-label="搜索物品" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</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>
|
||||||
|
|||||||
@@ -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": (
|
|
||||||
"Dota2ProTracker(7k+ / 职业)仅作人工复核「来源分歧」条目的高分段参考,"
|
|
||||||
"不进入自动流水线。"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
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"})
|
||||||
|
|||||||