From 30218790ce58970bde6827d8dd00c27f672cb2ee Mon Sep 17 00:00:00 2001 From: voson Date: Fri, 31 Jul 2026 10:06:09 +0800 Subject: [PATCH] Add 7.41e patch notes with a hand-authored reading sidebar. Ship Climperor Web 0.6.13: latest-patch summary panel, icon/innate fallbacks, and layout/scroll fixes; keep shared ability badges in git. Co-authored-by: Cursor --- .gitignore | 5 +- AGENTS.md | 3 +- CHANGELOG.md | 25 + DESIGN.md | 2 +- web/assets/ability_icons/innate.png | Bin 0 -> 21513 bytes web/assets/ability_icons/talent_tree.png | Bin 0 -> 4782 bytes web/data/patch_summaries.json | 59 + web/data/patches.json | 1567 +++++++++++++++++++++- web/export_relations_site.py | 2 +- web/fetch_hero_stats.py | 11 +- web/fetch_patches.py | 4 +- web/frontend/app.js | 151 ++- web/frontend/config.js | 2 +- web/frontend/index.html | 31 +- web/frontend/style.css | 190 ++- web/refresh_web.py | 102 +- web/serve_relations.py | 36 +- web/tests/test_refresh_reliability.py | 42 + 18 files changed, 2169 insertions(+), 63 deletions(-) create mode 100644 web/assets/ability_icons/innate.png create mode 100644 web/assets/ability_icons/talent_tree.png create mode 100644 web/data/patch_summaries.json diff --git a/.gitignore b/.gitignore index cad2dce..a04acae 100644 --- a/.gitignore +++ b/.gitignore @@ -42,7 +42,10 @@ web/.refresh-cache/ # Large regenerable CDN media (fetch scripts) web/assets/ability_videos/ -web/assets/ability_icons/ +# Regenerable Steam CDN ability icons; keep bundled shared badges in git. +web/assets/ability_icons/* +!web/assets/ability_icons/innate.png +!web/assets/ability_icons/talent_tree.png web/assets/hero_portraits/ web/assets/item_icons/ # Curated streamer highlight clips (large; sync via OSS, not git) diff --git a/AGENTS.md b/AGENTS.md index 293e95e..974fb7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,6 +104,7 @@ climperor/ | `web/fetch_item_counter_stats.py` | OpenDota Explorer 近场次聚合:对阵英雄时敌方终局装备购买率/胜率,与同窗口该装备全局队伍基线作差 → `web/data/item_counter_stats.json`(可再生成缓存;观测证据,不作因果结论;weekly 软失败) | | `web/item_fears.py` | 规则推导「英雄怕的装备」→ `web/data/hero_item_fears.json`;可选读取 `item_counter_stats.json`,对全部英雄的已有机制候选小幅调序;统计发现的新组合须经机制复核后写入 overrides | | `web/data/patches.json` | 近一年版本列表 + 逐版本详情(`patches`/`lookup`/`details`;由 `fetch_patches.py` 生成,Web 版本页只读) | +| `web/data/patch_summaries.json` | 手写「版本解读」(仅保留最新版条目;Cursor 审阅后写入;进 `INPUT_WATCH`;**不**进定时生成 / recommend) | | `web/data/hero_grid_order.json` | Web 站点四列网格顺序 | | `web/data/hero_items.json` | 核心成品装备缓存(Web + PC 锁定后核心装推荐只读;由 `fetch_hero_items.py` 生成) | | `web/data/hero_stats.json` | 英雄各段位 pick/win + `totals` + `window_*`(OpenDota **近约 7 天**;Web「走势」Tab:胜率/上场率/场次;冠绝与超凡样本合并展示;由 `fetch_hero_stats.py` 生成;**不**参与局内推荐) | @@ -130,7 +131,7 @@ climperor/ | `web/assets/rank_icons/` | 天梯勋章图标(OpenDota `rank_icon_1..8` 先锋→冠绝;走势段位选择器) | | `web/assets/item_icons/` | Steam CDN 装备图标(上分帝 Web常用装备) | | `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;按需缓存,目录 gitignore);**例外提交**共用徽章 `innate.png` / `talent_tree.png`(先天/天赋树;CDN 无独立图标时前端与本地 serve 回退用) | | `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_videos/` | 主播精选高光 mp4 + 同名 JPG 封面(手工放入;上分帝 Web「主播」卡片;勿提交,线上走 OSS `streamer-video/`) | diff --git a/CHANGELOG.md b/CHANGELOG.md index 414505e..2ce8b07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,36 @@ ### Changed - 上分帝 Web 每日刷新明星比赛改为按 `fetched_at` 轮换(默认每次 15 人),遇 OpenDota 连续 429 熔断并保留旧缓存;`hero_stats` / `hero_matches` 限流时 soft-fail 保留缓存,避免整档 daily 被拖死。 +- 上分帝 Web daily:OpenDota 先单次探测,429/失败则跳过 stats/matches/pro;`hero_stats` 遇 429 不再长退避;部署前拒绝空壳 `hero_stats` / STRATZ 覆盖生产。`fetch_patches` 图标下载 import 修复,可正常入库新版本(含 7.41e)。 ### Removed - PC 选将 overlay 不再在顶栏头像下显示定位图标;仍保留网格「克/搭/补」与阵容分析条。 +## [0.6.13] - 2026-07-31 + +### Fixed + +- 版本页「版本解读」:`max-height` 按顶栏留白(`100dvh - 9rem`),sticky 面板不再伸出视口,主栏未滚到底也能独立滚完解读全文。 + +## [0.6.12] - 2026-07-31 + +### Fixed + +- 版本页技能图标:补拉补丁引用的 CDN 图标;先天无 CDN 图时用 `innate.png`;加载失败不再留空框(回退先天徽章或移除 img)。 + +## [0.6.11] - 2026-07-31 + +### Fixed + +- 版本页:正文单独居中,解读贴在主列右侧;解读侧栏视口内可滚动看完全文。 + +## [0.6.10] - 2026-07-31 + +### Added + +- 上分帝 Web 版本页:最新版右侧「版本解读」侧栏(手写静态摘要 `patch_summaries.json`,非官方);入库 **7.41e** 补丁详情与解读。 + ## [0.6.9] - 2026-07-30 ### Fixed diff --git a/DESIGN.md b/DESIGN.md index f0b4805..27609e9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -267,7 +267,7 @@ components: | 档位 | 宽度 | 页面 | |------|------|------| | full | 100% | 英雄网格 | -| read | `min(820px, 92%)` | 版本 | +| read | 主列 `min(820px)` 页内居中;宽屏解读贴主列右侧(不参与居中),sticky + 视口内自滚动 | 版本 | | standard | `min(880px, calc(100% - 48px))` | 排行、主播、机制 | | wide | `min(1080px, …)` | 走势 | | data | `min(1200px, …)`(居中时两侧预留侧栏) | 比赛 | diff --git a/web/assets/ability_icons/innate.png b/web/assets/ability_icons/innate.png new file mode 100644 index 0000000000000000000000000000000000000000..be4d97064bb7c37b5cb81f28ba5ed78cd89f75b4 GIT binary patch literal 21513 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 A0RR91 literal 0 HcmV?d00001 diff --git a/web/assets/ability_icons/talent_tree.png b/web/assets/ability_icons/talent_tree.png new file mode 100644 index 0000000000000000000000000000000000000000..1040e049a13d75ee55da513967fc2e04dc0ca603 GIT binary patch literal 4782 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" + } + ] + }, + { + "ability_id": 1864, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "法术外溢对非英雄单位的爆炸伤害从30点减少至20点 ", + "info": "休眠珍品的伤害从39点减少至26点" + }, + { + "indent_level": 1, + "note": "法术外溢对幻象的伤害临界值现在只考虑增伤前伤害" + } + ] + }, + { + "ability_id": 1862, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "附魔的重新打造加成从40%减少至35%", + "info": "休眠珍品从52%减少至45.5%" + }, + { + "indent_level": 1, + "hide_dot": true, + "note": "
" + } + ] + }, + { + "ability_id": 837, + "postfix_lines": 2, + "ability_notes": [ + { + "indent_level": 1, + "note": "清洗的冷却时间从40秒减少至30秒" + } + ] + }, + { + "ability_id": -1, + "title": "附魔", + "is_general_note": true + }, + { + "ability_id": 1593, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "额外每分钟金钱从+75/100减少至+65/90" + }, + { + "indent_level": 1, + "hide_dot": true, + "note": "
" + } + ] + }, + { + "ability_id": 1587, + "ability_notes": [ + { + "indent_level": 1, + "note": "不再增加7%魔法消耗/损失" + }, + { + "indent_level": 1, + "note": "现在使最大魔法值减少20%" + } + ] + } + ], + "heroes": [ + { + "hero_id": 2, + "hero_notes": [ + { + "indent_level": 1, + "note": "基础敏捷从20减少至18", + "icon": "agility" + } + ], + "abilities": [ + { + "ability_id": 5008, + "ability_notes": [ + { + "indent_level": 1, + "note": "每秒伤害从12/18/24/30点减少至12/16/20/24点" + } + ] + } + ] + }, + { + "hero_id": 3, + "abilities": [ + { + "ability_id": 1202, + "ability_notes": [ + { + "indent_level": 1, + "note": "每个英雄的最高恐惧层数从5提升至6" + }, + { + "indent_level": 1, + "note": "每层恐惧的状态抗性从5%减少至4%" + } + ] + }, + { + "ability_id": 5014, + "ability_notes": [ + { + "indent_level": 1, + "note": "持续时间从3.5/4.5/5.5/6.5秒减少至3/4/5/6秒" + }, + { + "indent_level": 1, + "note": "现在会使目标完全没有视野,而不是覆盖其视野范围" + } + ] + }, + { + "ability_id": 5013, + "ability_notes": [ + { + "indent_level": 1, + "note": "阿哈利姆神杖升级冷却时间减少从45秒减少至40秒", + "aghanims": "scepter" + } + ] + } + ] + }, + { + "hero_id": 6, + "abilities": [ + { + "ability_id": 343, + "ability_notes": [ + { + "indent_level": 1, + "note": "魔法消耗从50/70/90/110点增加至70/85/100/115点" + }, + { + "indent_level": 1, + "note": "箭矢距离从1.75x卓尔游侠攻击距离调整为475 + 1x卓尔游侠攻击距离" + }, + { + "indent_level": 1, + "note": "箭矢基础伤害加成从100/120/140/160%减少至80/100/120/140%" + } + ] + } + ] + }, + { + "hero_id": 10, + "abilities": [ + { + "ability_id": 5057, + "ability_notes": [ + { + "indent_level": 1, + "note": "冷却时间从140/100/60秒降低至125/90/55秒" + }, + { + "indent_level": 1, + "note": "阿哈利姆神杖升级幻象不再拥有幻象视野惩罚", + "aghanims": "scepter" + } + ] + } + ] + }, + { + "hero_id": 11, + "hero_notes": [ + { + "indent_level": 1, + "note": "基础智力从18减少至16", + "icon": "intelligence" + } + ], + "talent_notes": [ + { + "indent_level": 1, + "note": "25级天赋毁灭阴影施加攻击伤害不再施加攻击特效或触发效果" + } + ], + "abilities": [ + { + "ability_id": 5059, + "ability_notes": [ + { + "indent_level": 1, + "note": "叠加持续时间从7秒减少至6秒" + } + ] + } + ] + }, + { + "hero_id": 12, + "abilities": [ + { + "ability_id": 5067, + "ability_notes": [ + { + "indent_level": 1, + "note": "阿哈利姆魔晶升级冷却时间从15秒增加至18秒", + "aghanims": "shard" + } + ] + } + ] + }, + { + "hero_id": 13, + "abilities": [ + { + "ability_id": 5069, + "ability_notes": [ + { + "indent_level": 1, + "note": "冷却时间从11/10/9/8秒增加至12/11/10/9秒" + } + ] + } + ] + }, + { + "hero_id": 19, + "hero_notes": [ + { + "indent_level": 1, + "note": "基础生命恢复减少1.0", + "icon": "health_regen" + } + ] + }, + { + "hero_id": 20, + "abilities": [ + { + "ability_id": 5123, + "ability_notes": [ + { + "indent_level": 1, + "note": "阿哈利姆神杖升级幻象不再拥有幻象视野惩罚", + "aghanims": "scepter" + } + ] + } + ] + }, + { + "hero_id": 22, + "abilities": [ + { + "ability_id": 5113, + "ability_notes": [ + { + "indent_level": 1, + "note": "伤害从300/475/650点减少至275/425/575点" + } + ] + }, + { + "ability_id": 1110, + "ability_notes": [ + { + "indent_level": 1, + "note": "开关技能不再脱离隐身状态,而且被沉默时可以开关" + }, + { + "indent_level": 1, + "note": "攻击速度加成从30减少至20" + } + ] + } + ] + }, + { + "hero_id": 25, + "hero_notes": [ + { + "indent_level": 1, + "note": "基础敏捷从23减少至21", + "icon": "agility" + } + ] + }, + { + "hero_id": 30, + "abilities": [ + { + "ability_id": 5140, + "ability_notes": [ + { + "indent_level": 1, + "note": "20级天赋“巫蛊咒术爆发在800范围内造成75%伤害”现在会把幻象视为非英雄目标,而且来自他们的爆发不会造成伤害" + } + ] + } + ] + }, + { + "hero_id": 35, + "abilities": [ + { + "ability_id": 694, + "ability_notes": [ + { + "indent_level": 1, + "note": "现在被缠绕时可以施放,但是狙击手在缠绕效果下不会被击退" + } + ] + } + ] + }, + { + "hero_id": 36, + "abilities": [ + { + "ability_id": 5160, + "ability_notes": [ + { + "indent_level": 1, + "note": "生命/魔法恢复从3.7 + 0.3 * 等级改为3.8 + 0.2 * 等级" + } + ] + }, + { + "ability_id": 661, + "ability_notes": [ + { + "indent_level": 1, + "note": "施法距离从750减少至600" + } + ] + } + ] + }, + { + "hero_id": 38, + "talent_notes": [ + { + "indent_level": 1, + "note": "20级天赋从+30 兽王及其召唤物攻击力减少至+25" + }, + { + "indent_level": 1, + "note": "25级天赋从-25秒 原始咆哮冷却减少至-20秒" + } + ] + }, + { + "hero_id": 39, + "abilities": [ + { + "ability_id": 5173, + "ability_notes": [ + { + "indent_level": 1, + "note": "魔法消耗从100/110/120/130点降低至100/105/110/115点" + } + ] + }, + { + "ability_id": 5176, + "ability_notes": [ + { + "indent_level": 1, + "note": "现在快速连续施放两个超声冲击波造成的伤害可以叠加" + } + ] + } + ] + }, + { + "hero_id": 40, + "abilities": [ + { + "ability_id": 1749, + "ability_notes": [ + { + "indent_level": 1, + "note": "初始伤害从40/60/80/100点提升至40/70/100/130点" + } + ] + } + ] + }, + { + "hero_id": 43, + "hero_notes": [ + { + "indent_level": 1, + "note": "敏捷成长从2提升至2.3", + "icon": "agility" + }, + { + "indent_level": 2, + "note": "每级攻击力成长从+3.6提升至+3.7", + "icon": "damage" + } + ] + }, + { + "hero_id": 44, + "abilities": [ + { + "ability_id": 5192, + "ability_notes": [ + { + "indent_level": 1, + "note": "现在无法驱散" + } + ] + } + ] + }, + { + "hero_id": 45, + "hero_notes": [ + { + "indent_level": 1, + "note": "基础智力从26提升至27", + "icon": "intelligence" + } + ] + }, + { + "hero_id": 46, + "abilities": [ + { + "ability_id": 7853, + "ability_notes": [ + { + "indent_level": 1, + "note": "现在会被缠绕效果打断" + } + ] + } + ] + }, + { + "hero_id": 49, + "abilities": [ + { + "ability_id": 1752, + "ability_notes": [ + { + "indent_level": 1, + "note": "作用范围加成从25/50/75/100提升至30/60/90/120" + } + ] + } + ] + }, + { + "hero_id": 51, + "abilities": [ + { + "ability_id": 5238, + "ability_notes": [ + { + "indent_level": 1, + "note": "损失魔法值从40/80/120/160点减少至40/75/110/145点" + }, + { + "indent_level": 1, + "note": "齿轮的行进距离从1000减少至850/900/950/1000" + }, + { + "indent_level": 1, + "note": "齿轮的白天/夜间视野从1600/600减少至800/400" + }, + { + "indent_level": 1, + "note": "齿轮不再阻止野怪营地刷新" + } + ] + } + ] + }, + { + "hero_id": 57, + "abilities": [ + { + "ability_id": 656, + "ability_notes": [ + { + "indent_level": 1, + "note": "治疗量从5秒内的35%提升至4秒内的40%" + } + ] + } + ] + }, + { + "hero_id": 60, + "abilities": [ + { + "ability_id": 5276, + "ability_notes": [ + { + "indent_level": 1, + "note": "作用范围从375减少至350" + } + ] + } + ] + }, + { + "hero_id": 63, + "abilities": [ + { + "ability_id": 1754, + "ability_notes": [ + { + "indent_level": 1, + "note": "丝线的断裂距离从900提升至890 + 10 * 英雄等级" + } + ] + } + ] + }, + { + "hero_id": 64, + "hero_notes": [ + { + "indent_level": 1, + "note": "智力成长从3.0提升至3.3", + "icon": "intelligence" + } + ] + }, + { + "hero_id": 65, + "abilities": [ + { + "ability_id": 1319, + "ability_notes": [ + { + "indent_level": 1, + "note": "攻击友军时不再施加" + } + ] + } + ] + }, + { + "hero_id": 67, + "talent_notes": [ + { + "indent_level": 1, + "note": "25级天赋从+15% 所有幽鬼幻象攻击力减少至+12%" + } + ], + "abilities": [ + { + "ability_id": 5337, + "ability_notes": [ + { + "indent_level": 1, + "note": "幻象攻击力从30/50/70%调整为35/50/65%" + } + ] + } + ] + }, + { + "hero_id": 68, + "abilities": [ + { + "ability_id": 5348, + "ability_notes": [ + { + "indent_level": 1, + "note": "冷却时间从60/50/40秒降低至50/45/40秒" + } + ] + } + ] + }, + { + "hero_id": 69, + "hero_notes": [ + { + "indent_level": 1, + "note": "攻击距离从200减少至175", + "icon": "attack_range" + } + ], + "abilities": [ + { + "ability_id": 5341, + "ability_notes": [ + { + "indent_level": 1, + "note": "基础烧灼伤害从15/30/45/60点提升至18/34/50/66点" + }, + { + "indent_level": 1, + "note": "最大生命值伤害从1/2/3/4%调整为0.5/1.75/3/4.25%" + } + ] + } + ] + }, + { + "hero_id": 72, + "abilities": [ + { + "ability_id": 5361, + "ability_notes": [ + { + "indent_level": 1, + "note": "魔法消耗从85点降低至75点" + } + ] + }, + { + "ability_id": 5362, + "ability_notes": [ + { + "indent_level": 1, + "note": "魔法消耗从120/130/140/150点降低至120点" + }, + { + "indent_level": 1, + "note": "冷却时间从30/24/18/12秒降低至26/21/16/11秒" + } + ] + } + ] + }, + { + "hero_id": 74, + "abilities": [ + { + "ability_id": 5381, + "ability_notes": [ + { + "indent_level": 1, + "note": "持续时间从60秒减少至50秒" + }, + { + "indent_level": 1, + "note": "阿哈利姆魔晶不再提升作用范围", + "aghanims": "shard" + } + ] + } + ] + }, + { + "hero_id": 76, + "abilities": [ + { + "ability_id": 1760, + "ability_notes": [ + { + "indent_level": 1, + "note": "现在为瞬时施法,而且不再取消移动状态 ", + "info": "之前拥有0.2秒施法前摇" + }, + { + "indent_level": 1, + "note": "护盾从120/180/240/300点提升至150/200/250/300点" + }, + { + "indent_level": 1, + "note": "冷却时间从36/34/32/30秒降低至36/33/30/27秒" + } + ] + } + ] + }, + { + "hero_id": 80, + "talent_notes": [ + { + "indent_level": 1, + "note": "15级天赋从-5秒 野蛮咆哮冷却减少至-4秒" + }, + { + "indent_level": 1, + "note": "20级天赋从+150 野蛮咆哮范围减少至+125" + } + ] + }, + { + "hero_id": 81, + "abilities": [ + { + "ability_id": 5426, + "ability_notes": [ + { + "indent_level": 1, + "note": "能量箭速度从700提升至900" + } + ] + } + ] + }, + { + "hero_id": 83, + "hero_notes": [ + { + "indent_level": 1, + "note": "基础攻击速度从100减少至90", + "icon": "attack_speed" + } + ], + "abilities": [ + { + "ability_id": 5435, + "ability_notes": [ + { + "indent_level": 1, + "note": "缠绕时间从0.9/1.1/1.3/1.5秒减少至0.75/1.0/1.25/1.5秒" + } + ] + }, + { + "ability_id": 5436, + "ability_notes": [ + { + "indent_level": 1, + "note": "魔法消耗从65/70/75/80点增加至80点" + } + ] + } + ] + }, + { + "hero_id": 85, + "hero_notes": [ + { + "indent_level": 1, + "note": "基础移动速度从300减少至295", + "icon": "movement" + } + ], + "abilities": [ + { + "ability_id": 5447, + "ability_notes": [ + { + "indent_level": 1, + "note": "移动速度加成从20提升至25" + } + ] + } + ] + }, + { + "hero_id": 90, + "abilities": [ + { + "ability_id": 5473, + "ability_notes": [ + { + "indent_level": 1, + "note": "冷却时间从19/16/13/10秒增加至20/17/14/11秒" + } + ] + }, + { + "ability_id": 1372, + "ability_notes": [ + { + "indent_level": 1, + "note": "施法距离从850减少至750" + } + ] + } + ] + }, + { + "hero_id": 92, + "abilities": [ + { + "ability_id": 7116, + "ability_notes": [ + { + "indent_level": 1, + "note": "用于命令佣兽执行指令时不再使维萨吉脱离隐身状态" + } + ] + } + ] + }, + { + "hero_id": 94, + "abilities": [ + { + "ability_id": 5504, + "ability_notes": [ + { + "indent_level": 1, + "note": "开关技能不再脱离隐身状态,而且被沉默时可以开关" + } + ] + } + ] + }, + { + "hero_id": 95, + "hero_notes": [ + { + "indent_level": 1, + "note": "基础敏捷从23提升至24", + "icon": "agility" + }, + { + "indent_level": 2, + "note": "1级攻击力从50-58提升至51-59", + "icon": "damage" + } + ], + "abilities": [ + { + "ability_id": 1478, + "ability_notes": [ + { + "indent_level": 1, + "note": "切换姿态不再脱离隐身状态" + } + ] + }, + { + "ability_id": 5512, + "ability_notes": [ + { + "indent_level": 1, + "note": "现在还提供35%减速抗性" + } + ] + } + ] + }, + { + "hero_id": 96, + "abilities": [ + { + "ability_id": 5515, + "ability_notes": [ + { + "indent_level": 1, + "note": "阿哈利姆魔晶升级状态持续时间从15秒减少至12秒", + "aghanims": "shard" + } + ] + }, + { + "ability_id": 5516, + "ability_notes": [ + { + "indent_level": 1, + "note": "基于力量值的反击伤害从16/24/32/40%减少至14/21/28/35%" + } + ] + } + ] + }, + { + "hero_id": 97, + "abilities": [ + { + "ability_id": 649, + "ability_notes": [ + { + "indent_level": 1, + "note": "伤害从300点提升至325点" + } + ] + } + ] + }, + { + "hero_id": 103, + "abilities": [ + { + "ability_id": 1393, + "ability_notes": [ + { + "indent_level": 1, + "note": "移速加成转为护甲从5% + 0.5% * 等级提升至7% + 0.5% * 等级" + } + ] + }, + { + "ability_id": 1392, + "ability_notes": [ + { + "indent_level": 1, + "note": "灵体游魂回归和灵体游魂移动的子技能使用时不再使上古巨神脱离隐身状态" + } + ] + } + ] + }, + { + "hero_id": 104, + "hero_notes": [ + { + "indent_level": 1, + "note": "基础力量从24提升至25", + "icon": "strength" + }, + { + "indent_level": 2, + "note": "1级攻击力从57-61提升至58-62", + "icon": "damage" + }, + { + "indent_level": 1, + "note": "力量成长从3.1减少至3.0", + "icon": "strength" + }, + { + "indent_level": 1, + "note": "基础攻击速度从100提升至105", + "icon": "attack_speed" + } + ], + "talent_notes": [ + { + "indent_level": 1, + "note": "25级天赋从决斗胜利刷新冷却改为决斗胜利减少30秒冷却" + } + ] + }, + { + "hero_id": 106, + "hero_notes": [ + { + "indent_level": 1, + "note": "基础移动速度从300减少至295", + "icon": "movement" + } + ] + }, + { + "hero_id": 107, + "hero_notes": [ + { + "indent_level": 1, + "note": "基础智力从18减少至17", + "icon": "intelligence" + } + ], + "talent_notes": [ + { + "indent_level": 1, + "note": "20级天赋从+30% 磁化伤害/持续时间减少至+25%" + } + ] + }, + { + "hero_id": 108, + "abilities": [ + { + "ability_id": 865, + "ability_notes": [ + { + "indent_level": 1, + "note": "对恶魔之扉的持续施法现在会被缠绕效果打断" + } + ] + } + ] + }, + { + "hero_id": 111, + "abilities": [ + { + "ability_id": 5638, + "ability_notes": [ + { + "indent_level": 1, + "note": "对神谕者或其友军施放的命运敕令现在可以被敌人驱散" + } + ] + }, + { + "ability_id": 5640, + "ability_notes": [ + { + "indent_level": 1, + "note": "施法距离从700/800/900提升至800/850/900" + } + ] + } + ] + }, + { + "hero_id": 121, + "abilities": [ + { + "ability_id": 7852, + "ability_notes": [ + { + "indent_level": 1, + "note": "不再拥有幻象的视野惩罚" + } + ] + } + ] + }, + { + "hero_id": 123, + "abilities": [ + { + "ability_id": 1423, + "ability_notes": [ + { + "indent_level": 1, + "note": "转向几率从14% + 1% * 等级减少至14.25% + 0.75% * 等级" + } + ] + }, + { + "ability_id": 8106, + "ability_notes": [ + { + "indent_level": 1, + "note": "魔法消耗从100/150/200点增加至150/200/250点" + } + ] + }, + { + "ability_id": 1422, + "ability_notes": [ + { + "indent_level": 1, + "note": "冷却时间从18秒增加至20秒" + } + ] + } + ] + }, + { + "hero_id": 128, + "talent_notes": [ + { + "indent_level": 1, + "note": "10级天赋从+35 蜥蜴绝吻烧灼每秒伤害减少至+30" + }, + { + "indent_level": 1, + "note": "15级天赋从-3秒 龙炎饼干冷却改为+125 施法距离" + }, + { + "indent_level": 1, + "note": "25级天赋从+8 蜥蜴绝吻喷吐减少至+6" + } + ], + "abilities": [ + { + "ability_id": 6480, + "ability_notes": [ + { + "indent_level": 1, + "note": "初始轰击宽度不再因作用范围加成所提升" + }, + { + "indent_level": 1, + "note": "平射额外伤害从30%减少至25%" + } + ] + }, + { + "ability_id": 6483, + "ability_notes": [ + { + "indent_level": 1, + "note": "撞击伤害从75/150/225/300点减少至60/130/200/270点" + } + ] + }, + { + "ability_id": 6482, + "ability_notes": [ + { + "indent_level": 1, + "note": "火团伤害从180/270/360点减少至170/250/330点" + } + ] + } + ] + }, + { + "hero_id": 129, + "hero_notes": [ + { + "indent_level": 1, + "note": "智力成长从2.2提升至2.4", + "icon": "intelligence" + } + ] + }, + { + "hero_id": 131, + "abilities": [ + { + "ability_id": 386, + "ability_notes": [ + { + "indent_level": 1, + "note": "每秒伤害(对非英雄单位)从85/90/95/100点调整为60/75/90/105点" + } + ] + } + ] + }, + { + "hero_id": 138, + "abilities": [ + { + "ability_id": 5753, + "ability_notes": [ + { + "indent_level": 1, + "note": "开关技能不再脱离隐身状态" + } + ] + } + ] + }, + { + "hero_id": 1961, + "hero_notes": [ + { + "indent_level": 1, + "note": "基础护甲减少1点", + "icon": "armor" + } + ], + "abilities": [ + { + "ability_id": 1348, + "ability_notes": [ + { + "indent_level": 1, + "note": "对建筑的额外伤害从30%减少至20%" + } + ] + } + ] + } + ] + }, "7.41d": { "version": "7.41d", "name": "7.41d", @@ -32220,4 +33773,4 @@ ] } } -} \ No newline at end of file +} diff --git a/web/export_relations_site.py b/web/export_relations_site.py index 76172aa..38176fe 100644 --- a/web/export_relations_site.py +++ b/web/export_relations_site.py @@ -55,7 +55,7 @@ from shared.paths import ( from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle from serve_relations import WEB_DIR, build_payload -SITE_VERSION = "0.6.9" +SITE_VERSION = "0.6.13" DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com" diff --git a/web/fetch_hero_stats.py b/web/fetch_hero_stats.py index 4230d61..21bbf70 100644 --- a/web/fetch_hero_stats.py +++ b/web/fetch_hero_stats.py @@ -20,6 +20,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import argparse +import urllib.error from datetime import datetime, timezone from shared.grid import hero_table @@ -169,7 +170,15 @@ def main() -> None: id_to_key = {int(h["id"]): h["key"] for h in heroes} print(f"fetching {OPENDOTA_HERO_STATS} ...", flush=True) - raw = http_json(OPENDOTA_HERO_STATS) + # Fail-fast on 429: do not burn the shared 5/10/20/40s retry budget. + try: + raw = http_json(OPENDOTA_HERO_STATS, retries=0) + except urllib.error.HTTPError as e: + if e.code == 429: + raise SystemExit( + "OpenDota rate-limited (HTTP 429); keeping previous cache" + ) from e + raise if not isinstance(raw, list): raise SystemExit(f"unexpected heroStats payload type: {type(raw).__name__}") if not raw: diff --git a/web/fetch_patches.py b/web/fetch_patches.py index e1e344d..22f86bb 100644 --- a/web/fetch_patches.py +++ b/web/fetch_patches.py @@ -38,7 +38,7 @@ import time import urllib.error from datetime import datetime, timedelta, timezone -from shared.http_utils import http_bytes, http_json, write_json_atomic +from shared.http_utils import download_icons, http_bytes, http_json, write_json_atomic from shared.paths import ABILITY_ICONS, DATA, HERO_PORTRAITS, ITEM_ICONS PATCHES_LIST_URL = "https://www.dota2.com/datafeed/patchnoteslist?language=schinese" @@ -336,8 +336,6 @@ def download_unit_portraits(*, delay: float) -> None: def download_referenced_icons(lookup: dict, *, delay: float) -> None: """Pull referenced item + ability icons into assets/ (skip existing).""" - from http_utils import download_icons - item_keys = {v["key"] for v in (lookup.get("items") or {}).values() if v.get("key")} # Recipe scrolls share one generic icon on Steam CDN. recipe_keys = {k for k in item_keys if k.startswith("recipe_")} diff --git a/web/frontend/app.js b/web/frontend/app.js index 2faa00b..eb71bcd 100644 --- a/web/frontend/app.js +++ b/web/frontend/app.js @@ -1175,18 +1175,24 @@ function wireTalentTrigger(btn, talents) { * badge — many innate keys 404 on Steam CDN, matching dota2.com. * Other abilities load `ability/{key}.png` with a quiet miss state on error. */ +function markAbilityIconMissing(img) { + img.onerror = null; + img.removeAttribute("src"); + img.alt = ""; + img.classList.add("missing"); +} + function setAbilityIcon(img, abilityKey, isInnate) { if (isInnate) { img.src = innateIconSrc(); - img.onerror = null; + img.onerror = () => markAbilityIconMissing(img); return; } img.src = abilityIconSrc(abilityKey); img.onerror = () => { - img.onerror = null; - img.removeAttribute("src"); - img.alt = ""; - img.classList.add("missing"); + // Many innates / renamed keys 404 on CDN — shared badge, then quiet miss. + img.onerror = () => markAbilityIconMissing(img); + img.src = innateIconSrc(); }; } @@ -3668,13 +3674,25 @@ function innateAbilityKeys() { return s; } +/** Inline onerror for patch HTML icons: optional innate fallback, then remove (no empty box). */ +function patchIconOnErrorAttr(tryInnate) { + if (tryInnate) { + const src = innateIconSrc().replace(/\\/g, "\\\\").replace(/'/g, "\\'"); + return ( + `this.onerror=null;this.src='${src}';` + + "this.onerror=function(){this.onerror=null;this.remove()}" + ); + } + return "this.onerror=null;this.remove()"; +} + function patchAbilityBlockHtml(ab, lookup) { const ameta = (lookup.abilities || {})[String(ab.ability_id)]; const akey = ameta ? ameta.key : ""; const aname = ameta ? ameta.name_loc : "# " + ab.ability_id; const innate = akey && innateAbilityKeys().has(akey); const aicon = akey - ? `` + ? `` : ""; return `
${aicon}
${escapeHtml(aname)}${renderNotesList(ab.ability_notes)}
`; } @@ -3684,7 +3702,7 @@ function renderItemEntry(entry, lookup) { const key = meta ? meta.key : ""; const name = meta ? meta.name_loc : "# " + entry.ability_id; const icon = key - ? `` + ? `` : ""; return `
${icon}
${escapeHtml(name)}${renderNotesList(entry.ability_notes)}
`; } @@ -3694,7 +3712,7 @@ function renderHeroEntry(hero, lookup) { const key = meta ? meta.key : ""; const name = meta ? meta.name_loc : "其它单位"; const portrait = key - ? `` + ? `` : ""; let html = `
${portrait}${escapeHtml(name)}
`; html += renderHeroNotes(hero.hero_notes); @@ -3762,6 +3780,112 @@ function ensureDefaultPatch() { } } +function patchSummaryFor(version) { + const pack = state.data?.patch_summaries || {}; + const by = pack.by_version || {}; + return version && by[version] ? by[version] : null; +} + +function patchSummaryEntityLabel(kind, key) { + if (kind === "hero") { + const hero = heroByKey(key); + return (hero && (hero.name_loc || hero.key)) || key; + } + const meta = (state.data.items_meta || {})[key]; + if (meta && meta.name_loc) return meta.name_loc; + const shop = ((state.data.item_shop || {}).items || {})[key]; + if (shop && shop.name_loc) return shop.name_loc; + return key; +} + +function renderPatchSummaryChip(kind, row) { + const key = row && row.key ? String(row.key) : ""; + if (!key) return ""; + const note = row.note ? String(row.note) : ""; + const label = patchSummaryEntityLabel(kind, key); + const img = + kind === "hero" + ? `` + : ``; + const clickable = kind === "hero" && heroByKey(key); + const tag = clickable ? "button" : "div"; + const attrs = clickable + ? ` type="button" class="patch-summary-chip is-clickable" data-hero="${escapeHtml(key)}"` + : ` class="patch-summary-chip"`; + return `<${tag}${attrs}>${img}${escapeHtml(label)}${note ? `${escapeHtml(note)}` : ""}`; +} + +function renderPatchSummarySection(title, html) { + if (!html) return ""; + return `

${escapeHtml(title)}

${html}
`; +} + +function renderPatchAside(version) { + const aside = $("#patches-aside"); + if (!aside) return; + const summary = patchSummaryFor(version); + if (!summary) { + aside.hidden = true; + aside.innerHTML = ""; + return; + } + + const themes = (summary.themes || []) + .filter((t) => typeof t === "string" && t.trim()) + .map((t) => `
  • ${escapeHtml(t)}
  • `) + .join(""); + const buffs = (summary.buffs || []).map((r) => renderPatchSummaryChip("hero", r)).join(""); + const nerfs = (summary.nerfs || []).map((r) => renderPatchSummaryChip("hero", r)).join(""); + const items = (summary.items || []).map((r) => renderPatchSummaryChip("item", r)).join(""); + const takeaways = (summary.takeaways || []) + .filter((t) => typeof t === "string" && t.trim()) + .map((t) => `
  • ${escapeHtml(t)}
  • `) + .join(""); + + let body = ""; + if (summary.headline) { + body += `

    ${escapeHtml(String(summary.headline))}

    `; + } + if (themes) { + body += renderPatchSummarySection("本版方向", `
      ${themes}
    `); + } + if (buffs) { + body += renderPatchSummarySection("明显加强", `
    ${buffs}
    `); + } + if (nerfs) { + body += renderPatchSummarySection("明显削弱", `
    ${nerfs}
    `); + } + if (items) { + body += renderPatchSummarySection("装备要点", `
    ${items}
    `); + } + if (takeaways) { + body += renderPatchSummarySection("选将提示", `
      ${takeaways}
    `); + } + + aside.hidden = false; + aside.innerHTML = ` +
    +
    +

    版本解读

    +

    非官方 · 社区摘要

    +
    + ${body} +
    `; + + aside.querySelectorAll("button.patch-summary-chip[data-hero]").forEach((btn) => { + btn.addEventListener("click", () => { + const key = btn.dataset.hero; + if (!key || !heroByKey(key)) return; + state.page = "heroes"; + state.selectedKey = key; + state.detailTab = "patches"; + state.inspect = null; + syncStateToUrl(); + render(); + }); + }); +} + function renderPatches() { if (!state.data) return; const select = $("#patch-select"); @@ -3774,6 +3898,7 @@ function renderPatches() { if (select) select.innerHTML = ""; if (verEl) verEl.textContent = ""; root.innerHTML = '
    暂无版本数据
    '; + renderPatchAside(null); return; } @@ -3803,6 +3928,8 @@ function renderPatches() { const cur = patches.find((p) => p.version === state.selectedPatch) || patches[0]; if (verEl) verEl.textContent = (cur && cur.version) || ""; + renderPatchAside(state.selectedPatch); + const lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} }; const details = state.data.patch_details || {}; const det = details[state.selectedPatch]; @@ -5721,8 +5848,11 @@ function renderMechanics() { abImg.alt = ""; abImg.loading = "lazy"; abImg.onerror = function () { - this.onerror = null; - this.src = assetUrl("ability/innate.png"); + this.onerror = function () { + this.onerror = null; + this.remove(); + }; + this.src = innateIconSrc(); }; const textWrap = document.createElement("span"); textWrap.className = "mechanics-text"; @@ -6201,6 +6331,7 @@ async function main() { state.data.patches = state.data.patches || []; state.data.patch_lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} }; state.data.patch_details = state.data.patch_details || {}; + state.data.patch_summaries = state.data.patch_summaries || { by_version: {} }; state.data.leaderboards = state.data.leaderboards || { fetched_at: null, source: "valve", diff --git a/web/frontend/config.js b/web/frontend/config.js index 0140075..749870e 100644 --- a/web/frontend/config.js +++ b/web/frontend/config.js @@ -1,5 +1,5 @@ /* Local defaults; production export overwrites via export_relations_site.py. */ -var SITE_VERSION = "0.6.9"; +var SITE_VERSION = "0.6.13"; var SITE_ORIGIN = ""; var ABILITY_VIDEO_BASE = ""; var STATIC_ASSET_BASE = ""; diff --git a/web/frontend/index.html b/web/frontend/index.html index 6a48b29..6de800c 100644 --- a/web/frontend/index.html +++ b/web/frontend/index.html @@ -43,8 +43,8 @@ } - - + +

    DOTA2 上分帝

    @@ -218,14 +218,21 @@
    -
    -

    - 游戏性更新 - -

    - +
    +
    +
    +
    +

    + 游戏性更新 + +

    + +
    +
    +
    + +
    -
    - - - + + + diff --git a/web/frontend/style.css b/web/frontend/style.css index 548baf0..3259b1a 100644 --- a/web/frontend/style.css +++ b/web/frontend/style.css @@ -1675,7 +1675,7 @@ body.detail-drawer-open { display: block; } .skill-icon img.missing { - visibility: hidden; + display: none; } /* Circular triggers: talent tree + innate (official dota2.com order). */ .skill-icon.talent-trigger, @@ -2833,17 +2833,32 @@ body:has(#items-view:not(.hidden)) { } .patches-board { - display: flex; - flex-direction: column; - align-items: center; + overflow-x: hidden; overflow-y: auto; } +/* Center only the changelog column; summary hangs in the right gutter. */ +.patches-cluster { + display: flex; + justify-content: center; + width: 100%; + box-sizing: border-box; + padding: 0 24px; +} +.patches-center-wrap { + position: relative; + width: min(var(--content-read), calc(100% - 48px)); + flex: 0 0 auto; +} +.patches-main { + width: 100%; + min-width: 0; +} .patches-header { display: flex; align-items: center; justify-content: space-between; gap: var(--space-lg); - width: min(var(--content-read), 92%); + width: 100%; padding: var(--space-xl) 0 10px; } .patches-title { @@ -2881,9 +2896,169 @@ body:has(#items-view:not(.hidden)) { cursor: pointer; } .patches-detail { - width: min(var(--content-read), 92%); + width: 100%; padding: 6px 0 64px; } +.patches-aside { + position: absolute; + left: calc(100% + var(--aside-gap)); + top: 0; + bottom: 0; + width: 280px; + pointer-events: none; + box-sizing: border-box; +} +.patches-aside[hidden] { + display: none !important; +} +/* Cap to the visible board (below .topbar), not raw 100vh — otherwise the + sticky box hangs past the viewport and the last lines stay unreachable. */ +.patch-summary { + position: sticky; + top: 20px; + align-self: start; + width: 100%; + height: fit-content; + max-height: calc(100dvh - 9rem); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + pointer-events: auto; + display: flex; + flex-direction: column; + gap: var(--space-lg); + box-sizing: border-box; + padding-bottom: calc(var(--space-lg) + 8px); +} +.patch-summary-head { + display: flex; + flex-direction: column; + gap: 4px; +} +.patch-summary-title { + margin: 0; + font-size: var(--fs-body-lg); + font-weight: var(--fw-black); + color: var(--text); + letter-spacing: 0.02em; + line-height: var(--lh-tight); +} +.patch-summary-disclaimer { + margin: 0; + font-size: var(--fs-label); + font-weight: var(--fw-medium); + color: var(--muted); + line-height: 1.35; +} +.patch-summary-headline { + margin: 0; + font-size: var(--fs-body-sm); + font-weight: var(--fw-medium); + color: var(--text); + line-height: var(--lh-body); +} +.patch-summary-section { + display: flex; + flex-direction: column; + gap: 8px; +} +.patch-summary-section-title { + margin: 0; + font-size: var(--fs-label); + font-weight: var(--fw-semibold); + color: var(--muted); + letter-spacing: 0.08em; + line-height: var(--lh-title); +} +.patch-summary-list { + margin: 0; + padding-left: 1.15em; + display: flex; + flex-direction: column; + gap: 6px; + font-size: var(--fs-body-sm); + color: var(--text); + line-height: 1.45; +} +.patch-summary-chips { + display: flex; + flex-direction: column; + gap: 8px; +} +.patch-summary-chip { + display: flex; + align-items: flex-start; + gap: 8px; + width: 100%; + margin: 0; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; +} +button.patch-summary-chip.is-clickable { + cursor: pointer; + border-radius: var(--radius-sm); +} +button.patch-summary-chip.is-clickable:hover .patch-summary-chip-name { + color: var(--sel); +} +.patch-summary-icon { + flex-shrink: 0; + margin-top: 2px; + background: var(--panel); + border-radius: 3px; +} +.patch-summary-icon--hero { + width: 32px; + height: 18px; + object-fit: cover; +} +.patch-summary-icon--item { + width: 22px; + height: 22px; + object-fit: contain; +} +.patch-summary-chip-body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.patch-summary-chip-name { + font-size: var(--fs-body-sm); + font-weight: var(--fw-semibold); + color: var(--text); + line-height: 1.3; +} +.patch-summary-chip-note { + font-size: var(--fs-label); + font-weight: var(--fw-medium); + color: var(--muted); + line-height: 1.4; +} +/* Need ~820 main + 300 aside gutter on each side of optical center. */ +@media (max-width: 1440px) { + .patches-center-wrap { + display: flex; + flex-direction: column; + width: min(var(--content-read), 100%); + } + .patches-aside { + position: static; + width: 100%; + pointer-events: auto; + order: -1; + padding: 0 0 8px; + } + .patch-summary { + position: static; + max-height: none; + overflow: visible; + } +} .patches-empty, .rankings-empty, .streamers-empty { @@ -3384,6 +3559,9 @@ body:has(#items-view:not(.hidden)) { height: 48px; margin-top: 2px; } +.patch-ability-icon.missing { + display: none; +} .patch-name { display: block; margin-bottom: 4px; diff --git a/web/refresh_web.py b/web/refresh_web.py index 7df9dea..5070926 100644 --- a/web/refresh_web.py +++ b/web/refresh_web.py @@ -32,9 +32,17 @@ ROOT = Path(__file__).resolve().parent REPO_ROOT = ROOT.parent sys.path.insert(0, str(REPO_ROOT)) -from shared.http_utils import write_json_atomic +from shared.http_utils import http_json, write_json_atomic from shared.paths import HEROES_JSON, RELATIONS_JSON, WEB_FRONTEND +OPENDOTA_PROBE_URL = "https://api.opendota.com/api/heroStats" +# Must be non-empty before any Pages deploy — empty shells overwrite production. +CRITICAL_BY_HERO_FILES = ( + ROOT / "data" / "hero_stats.json", + ROOT / "data" / "stratz_hero_meta.json", + ROOT / "data" / "stratz_matchup_tops.json", +) + # Paths whose content change should trigger deploy / OSS. DATA_WATCH = [ ROOT / "data" / "hero_stats.json", @@ -58,6 +66,7 @@ INPUT_WATCH = [ RELATIONS_JSON, HEROES_JSON, ROOT / "data" / "hero_grid_order.json", + ROOT / "data" / "patch_summaries.json", WEB_FRONTEND / "index.html", WEB_FRONTEND / "app.js", WEB_FRONTEND / "style.css", @@ -270,26 +279,82 @@ def run_patch_linked(*, dry_run: bool = False) -> None: ) +def probe_opendota_available() -> bool: + """Single fail-fast GET; False means skip OpenDota daily fetches this run.""" + try: + http_json(OPENDOTA_PROBE_URL, timeout=15, retries=0) + except Exception as exc: + print( + f"OpenDota probe failed ({type(exc).__name__}: {exc}); " + "skipping hero_stats / hero_matches / pro_matches this run", + flush=True, + ) + RUN_RESULTS.append( + { + "step": "opendota_probe", + "ok": False, + "skipped_opendota": True, + "error": f"{type(exc).__name__}: {exc}", + } + ) + return False + print("OpenDota probe ok", flush=True) + RUN_RESULTS.append({"step": "opendota_probe", "ok": True}) + return True + + +def assert_critical_data_ready() -> None: + """Refuse deploy when restored/generated caches would publish empty shells.""" + problems: list[str] = [] + for path in CRITICAL_BY_HERO_FILES: + rel = path.name + if not path.is_file(): + problems.append(f"{rel}: missing") + continue + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + problems.append(f"{rel}: unreadable ({exc})") + continue + by_hero = raw.get("by_hero") if isinstance(raw, dict) else None + if not isinstance(by_hero, dict) or not by_hero: + problems.append(f"{rel}: empty by_hero") + if problems: + raise RuntimeError( + "refusing deploy: critical web data incomplete (" + + "; ".join(problems) + + ")" + ) + + def run_daily(*, dry_run: bool = False) -> bool: """Return True if patch-linked fetch ran.""" - # Soft-fail OpenDota quota burns: keep restored cache and continue the tier. - run_script("fetch_hero_stats.py", dry_run=dry_run, soft_fail=True) + # Probe once: if OpenDota is 429/down, keep restored cache and do not burn quota. + opendota_ok = True if dry_run else probe_opendota_available() + if opendota_ok: + # Soft-fail OpenDota quota burns: keep restored cache and continue the tier. + run_script("fetch_hero_stats.py", dry_run=dry_run, soft_fail=True) + run_script( + "fetch_hero_matches.py", + "--source", + "league", + dry_run=dry_run, + soft_fail=True, + ) + run_script( + "fetch_pro_matches.py", + "--include-pubs", + "--refresh-limit", + "15", + dry_run=dry_run, + soft_fail=True, + ) + else: + print( + "skipped OpenDota daily fetches (probe failed); keeping prior cache", + flush=True, + ) run_script("fetch_leaderboards.py", dry_run=dry_run) - run_script( - "fetch_hero_matches.py", - "--source", - "league", - dry_run=dry_run, - soft_fail=True, - ) - run_script( - "fetch_pro_matches.py", - "--include-pubs", - "--refresh-limit", - "15", - dry_run=dry_run, - soft_fail=True, - ) # Soft-fail Douyin enrichment (script itself exits 0; keep previous values on miss). run_script("fetch_streamers.py", dry_run=dry_run, soft_fail=True) # Soft-fail live probe (exits 0; probe failures clear is_live / live_probed_at). @@ -555,6 +620,7 @@ def main() -> None: should_deploy = args.force_deploy or data_changed or assets_changed if should_deploy and not args.skip_deploy: + assert_critical_data_ready() run_script("deploy_relations.py") summary["deployed"] = True elif should_deploy: diff --git a/web/serve_relations.py b/web/serve_relations.py index f434da7..d4d49e3 100644 --- a/web/serve_relations.py +++ b/web/serve_relations.py @@ -147,6 +147,7 @@ HERO_ABILITIES_PATH = DATA / "hero_abilities.json" ITEM_SHOP_PATH = DATA / "item_shop.json" ITEMS_META_PATH = DATA / "items_meta.json" PATCHES_PATH = DATA / "patches.json" +PATCH_SUMMARIES_PATH = DATA / "patch_summaries.json" LEADERBOARDS_PATH = DATA / "leaderboards.json" PRO_MATCHES_PATH = DATA / "pro_matches.json" STREAMERS_PATH = DATA / "streamers.json" @@ -346,6 +347,32 @@ def load_patches() -> dict: } +def load_patch_summaries() -> dict: + """Hand-authored patch readings (see web/data/patch_summaries.json). + + Returns {by_version: {version: summary}}; empty when missing/invalid. + Only the latest version is expected to have an entry. + """ + empty: dict = {"by_version": {}} + if not PATCH_SUMMARIES_PATH.is_file(): + return empty + try: + raw = json.loads(PATCH_SUMMARIES_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return empty + if not isinstance(raw, dict): + return empty + by_version = raw.get("by_version") + if not isinstance(by_version, dict): + return empty + out: dict[str, dict] = {} + for ver, cell in by_version.items(): + if not isinstance(ver, str) or not isinstance(cell, dict): + continue + out[ver] = cell + return {"by_version": out} + + def load_leaderboards() -> dict: """Valve Immortal division Top 100 (see fetch_leaderboards.py). Preview only.""" empty: dict = { @@ -627,10 +654,14 @@ def ensure_ability_icon(key: str) -> Path | None: try: data = http_bytes(ABILITY_ICON_URL.format(key=key), timeout=30) if not data or len(data) < 32: - return None + raise OSError("empty ability icon") dest.write_bytes(data) return dest except (urllib.error.URLError, TimeoutError, OSError): + # CDN 404s are common for innates; serve the bundled badge when present. + innate = ABILITY_ICONS / f"{INNATE_ICON_NAME}.png" + if innate.is_file() and innate.stat().st_size >= 32: + return innate return None @@ -724,6 +755,7 @@ def build_payload() -> dict: shop = load_item_shop() items_meta = load_items_meta_index() patches_data = load_patches() + patch_summaries = load_patch_summaries() leaderboards = load_leaderboards() pro_matches = load_pro_matches() streamers = load_streamers() @@ -759,6 +791,7 @@ def build_payload() -> dict: "patches": patches_data.get("patches") or [], "patch_lookup": patches_data.get("lookup") or {}, "patch_details": patches_data.get("details") or {}, + "patch_summaries": patch_summaries, "leaderboards": leaderboards, "pro_matches": pro_matches, "streamers": streamers, @@ -801,6 +834,7 @@ def build_payload() -> dict: "shop_items": len(shop.get("items") or {}), "patches": len(patches_data.get("patches") or []), "patch_details": len(patches_data.get("details") or {}), + "patch_summaries": len(patch_summaries.get("by_version") or {}), "leaderboard_regions": len(leaderboards.get("regions") or {}), "pro_match_players": len(pro_matches.get("by_pro") or {}), "pro_match_heroes": len(pro_matches.get("by_hero") or {}), diff --git a/web/tests/test_refresh_reliability.py b/web/tests/test_refresh_reliability.py index 193cf2f..a8746a3 100644 --- a/web/tests/test_refresh_reliability.py +++ b/web/tests/test_refresh_reliability.py @@ -144,6 +144,48 @@ class RefreshDigestTests(unittest.TestCase): skip_deploy=True, ) + def test_opendota_probe_failure_skips_opendota_daily_fetches(self) -> None: + calls: list[str] = [] + + def fake_run_script(script: str, *args: str, dry_run: bool = False, soft_fail: bool = False): + calls.append(script) + return True + + with ( + patch.object(refresh_web, "probe_opendota_available", return_value=False), + patch.object(refresh_web, "run_script", side_effect=fake_run_script), + patch.object( + refresh_web, + "patch_check", + return_value={"has_new": False, "new_versions": []}, + ), + ): + self.assertFalse(refresh_web.run_daily(dry_run=False)) + self.assertNotIn("fetch_hero_stats.py", calls) + self.assertNotIn("fetch_hero_matches.py", calls) + self.assertNotIn("fetch_pro_matches.py", calls) + self.assertIn("fetch_leaderboards.py", calls) + self.assertIn("fetch_streamers.py", calls) + + def test_critical_empty_by_hero_refuses_deploy(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + data = Path(tmp) + paths = [ + data / "hero_stats.json", + data / "stratz_hero_meta.json", + data / "stratz_matchup_tops.json", + ] + write_json_atomic(paths[0], {"by_hero": {"axe": {"pick": 1}}}) + write_json_atomic(paths[1], {"by_hero": {}}) + write_json_atomic(paths[2], {"by_hero": {"axe": {}}}) + with patch.object(refresh_web, "CRITICAL_BY_HERO_FILES", tuple(paths)): + with self.assertRaises(RuntimeError) as ctx: + refresh_web.assert_critical_data_ready() + self.assertIn("stratz_hero_meta.json", str(ctx.exception)) + write_json_atomic(paths[1], {"by_hero": {"axe": {}}}) + with patch.object(refresh_web, "CRITICAL_BY_HERO_FILES", tuple(paths)): + refresh_web.assert_critical_data_ready() + def test_dry_run_skips_cache_restore_and_summary_write(self) -> None: with tempfile.TemporaryDirectory() as tmp: summary_path = Path(tmp) / "summary.json"