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 0000000..be4d970 Binary files /dev/null and b/web/assets/ability_icons/innate.png differ diff --git a/web/assets/ability_icons/talent_tree.png b/web/assets/ability_icons/talent_tree.png new file mode 100644 index 0000000..1040e04 Binary files /dev/null and b/web/assets/ability_icons/talent_tree.png differ diff --git a/web/data/patch_summaries.json b/web/data/patch_summaries.json new file mode 100644 index 0000000..b1964c7 --- /dev/null +++ b/web/data/patch_summaries.json @@ -0,0 +1,59 @@ +{ + "meta": { + "note": "Hand-authored patch readings for Climperor Web. Keep only the latest version entry; replace on each new patch.", + "updated_at": "2026-07-31" + }, + "by_version": { + "7.41e": { + "version": "7.41e", + "headline": "小写平衡以收紧蓝续航与储藏处运营为主,并让缠绕真正打断门/传送类持续施法;电炎绝手与多核远程明显降温,飞机/黑鸟/冰魂大招等获小加强。", + "themes": [ + "缠绕可打断双生门与孽主恶魔之扉持续施法,圣堂陷阱传送也会被打断", + "慧光系魔法恢复增强全面下调,纯慧核蓝线更紧", + "灵龛/灵瓮/精之灵器在储藏处不再因附近阵亡获得充能", + "诡计之雾伪装时长固定,不再吃增益持续时间增强", + "多项开关技能不再破隐(宙斯霹雳之手、美杜莎分裂箭、巨魔姿态、琼英神枪等)" + ], + "buffs": [ + { "key": "gyrocopter", "note": "弹幕耗蓝下降,追踪导弹耗蓝与冷却全面改善" }, + { "key": "obsidian_destroyer", "note": "责难改瞬时施法且可边走边放,护盾与冷却上调" }, + { "key": "ancient_apparition", "note": "冰晶爆轰前两级冷却缩短" }, + { "key": "morphling", "note": "变形冷却降低;神杖幻象去掉视野惩罚" }, + { "key": "queenofpain", "note": "暗影突袭耗蓝下降;连续超声冲击波伤害可叠加" }, + { "key": "death_prophet", "note": "敏捷与攻击力成长上调" }, + { "key": "troll_warlord", "note": "基础属性小幅上调;战斗专注附带减速抗性" }, + { "key": "venomancer", "note": "毒蛇撕咬初始伤害提升" }, + { "key": "omniknight", "note": "纯洁之锤治疗更集中、总量略升" }, + { "key": "chaos_knight", "note": "混乱之箭弹道速度提升" } + ], + "nerfs": [ + { "key": "snapfire", "note": "天赋与饼干/绝吻/散射多项下调,本版重点降温对象" }, + { "key": "drow_ranger", "note": "数箭齐发耗蓝升、伤害降,射程公式重做" }, + { "key": "zuus", "note": "雷神之怒伤害下调;霹雳之手攻速削弱" }, + { "key": "axe", "note": "基础敏捷与战斗饥渴伤害下调" }, + { "key": "nevermore", "note": "基础智力、毁灭阴影叠加时长与关键天赋收紧" }, + { "key": "hoodwink", "note": "穿心耗蓝与旋镖冷却上调,林渊转向几率后期变慢" }, + { "key": "spectre", "note": "鬼影重重与幻象天赋攻击力回调" }, + { "key": "necrolyte", "note": "死亡搜寻施法距离缩短,施虐之心高等级恢复变缓" }, + { "key": "invoker", "note": "幽灵漫步时长缩短,魔晶不再加范围" }, + { "key": "centaur", "note": "反伤力量转化与魔晶状态时长下调" } + ], + "items": [ + { "key": "kaya", "note": "慧光及对剑/陨星锤的魔法恢复增强集体下调" }, + { "key": "urn_of_shadows", "note": "灵龛系在储藏处不再蹭人头充能;灵龛自身蓝恢复也降" }, + { "key": "smoke_of_deceit", "note": "伪装时长固定,不再被增益持续时间延长" }, + { "key": "butterfly", "note": "敏捷下调、攻击力上调,属性取向微调" }, + { "key": "abyssal_blade", "note": "力量加成提升" }, + { "key": "rapier", "note": "多把圣剑的技能增强不再叠加" }, + { "key": "satanic", "note": "不洁狂热冷却从 30 秒增至 40 秒" }, + { "key": "mask_of_madness", "note": "远程移速加成下降,减速抗性改为近战更高" } + ], + "takeaways": [ + "出门与转场更吃解缠绕:门/传送通道不再能硬顶缠绕读条", + "慧核与蓝耗英雄更早感受到慧光系蓝回缩水,出装节奏会前移", + "支援装「储藏处挂机充能」被堵,灵龛/灵瓮更依赖当面参团", + "电炎绝手、卓尔、宙斯大招本版优先降权;飞机、黑鸟、冰魂大招等可多看一眼" + ] + } + } +} diff --git a/web/data/patches.json b/web/data/patches.json index b31a559..7568ab8 100644 --- a/web/data/patches.json +++ b/web/data/patches.json @@ -1,18 +1,24 @@ { "meta": { "source": "https://www.dota2.com/datafeed/patchnoteslist?language=schinese", - "fetched_at": "2026-07-27T07:41:08.378536+00:00", + "fetched_at": "2026-07-31T01:59:47.443632+00:00", "window_days": 365, - "since": "2025-07-27", - "count": 10, - "details_count": 10, + "since": "2025-07-31", + "count": 11, + "details_count": 11, "lookup_counts": { - "items": 171, - "abilities": 583, + "items": 175, + "abilities": 596, "heroes": 128 } }, "patches": [ + { + "version": "7.41e", + "name": "7.41e", + "timestamp": 1785394800, + "date": "2026-07-30" + }, { "version": "7.41d", "name": "7.41d", @@ -192,6 +198,10 @@ "key": "blade_mail", "name_loc": "刃甲" }, + "133": { + "key": "rapier", + "name_loc": "圣剑" + }, "135": { "key": "monkey_king_bar", "name_loc": "金箍棒" @@ -200,6 +210,10 @@ "key": "radiance", "name_loc": "辉耀" }, + "139": { + "key": "butterfly", + "name_loc": "蝴蝶" + }, "145": { "key": "bfury", "name_loc": "狂战斧" @@ -212,6 +226,10 @@ "key": "sange_and_yasha", "name_loc": "散夜对剑" }, + "156": { + "key": "satanic", + "name_loc": "撒旦之邪力" + }, "158": { "key": "mjollnir", "name_loc": "雷神之锤" @@ -252,6 +270,10 @@ "key": "arcane_boots", "name_loc": "奥术鞋" }, + "181": { + "key": "orb_of_venom", + "name_loc": "淬毒之珠" + }, "185": { "key": "ancient_janggo", "name_loc": "韧鼓" @@ -822,6 +844,10 @@ "key": "bristleback_hairball", "name_loc": "毛团" }, + "649": { + "key": "magnataur_horn_toss", + "name_loc": "长角抛物" + }, "651": { "key": "shredder_flamethrower", "name_loc": "喷火装置" @@ -850,6 +876,10 @@ "key": "visage_silent_as_the_grave", "name_loc": "静如古墓" }, + "694": { + "key": "sniper_concussive_grenade", + "name_loc": "震荡手雷" + }, "699": { "key": "terrorblade_demon_zeal", "name_loc": "狂魔" @@ -862,6 +892,10 @@ "key": "skywrath_mage_shield_of_the_scion", "name_loc": "天裔之盾" }, + "865": { + "key": "abyssal_underlord_dark_portal", + "name_loc": "恶魔之扉" + }, "873": { "key": "broodmother_sticky_snare", "name_loc": "蛛纱陷阱" @@ -934,6 +968,10 @@ "key": "meepo_megameepo", "name_loc": "超大米波" }, + "1110": { + "key": "zuus_lightning_hands", + "name_loc": "霹雳之手" + }, "1116": { "key": "alchemist_corrosive_weaponry", "name_loc": "腐蚀兵械" @@ -1110,6 +1148,10 @@ "key": "jakiro_double_trouble", "name_loc": "天生一对" }, + "1319": { + "key": "batrider_smoldering_resin", + "name_loc": "闷烧树脂" + }, "1324": { "key": "doom_bringer_lvl_pain", "name_loc": "等级?痛苦" @@ -1166,6 +1208,10 @@ "key": "naga_siren_eelskin", "name_loc": "鳗鱼皮" }, + "1372": { + "key": "keeper_of_the_light_radiant_bind", + "name_loc": "炎阳之缚" + }, "1379": { "key": "centaur_horsepower", "name_loc": "开足马力" @@ -2342,6 +2388,14 @@ "key": "ursa_enrage", "name_loc": "激怒" }, + "5361": { + "key": "gyrocopter_rocket_barrage", + "name_loc": "火箭弹幕" + }, + "5362": { + "key": "gyrocopter_homing_missile", + "name_loc": "追踪导弹" + }, "5363": { "key": "gyrocopter_flak_cannon", "name_loc": "高射火炮" @@ -2458,6 +2512,10 @@ "key": "shadow_demon_demonic_purge", "name_loc": "邪恶净化" }, + "5426": { + "key": "chaos_knight_chaos_bolt", + "name_loc": "混乱之箭" + }, "5427": { "key": "chaos_knight_reality_rift", "name_loc": "实相裂隙" @@ -2606,6 +2664,10 @@ "key": "slark_shadow_dance", "name_loc": "暗影之舞" }, + "5504": { + "key": "medusa_split_shot", + "name_loc": "分裂箭" + }, "5506": { "key": "medusa_mana_shield", "name_loc": "魔法盾" @@ -2626,6 +2688,14 @@ "key": "troll_warlord_battle_trance", "name_loc": "战斗专注" }, + "5515": { + "key": "centaur_double_edge", + "name_loc": "双刃剑" + }, + "5516": { + "key": "centaur_return", + "name_loc": "反伤" + }, "5518": { "key": "magnataur_shockwave", "name_loc": "震荡波" @@ -2970,6 +3040,10 @@ "key": "tiny_toss_tree", "name_loc": "扔树" }, + "7116": { + "key": "visage_stone_form_self_cast", + "name_loc": "Stone Form" + }, "7230": { "key": "beastmaster_summon_razorback", "name_loc": "召唤刀背兽" @@ -3611,6 +3685,1485 @@ } }, "details": { + "7.41e": { + "version": "7.41e", + "name": "7.41e", + "timestamp": 1785394800, + "general_notes": [ + { + "title": "双生之门", + "generic": [ + { + "indent_level": 1, + "note": "对双生之门的持续施法现在可以被缠绕效果打断" + } + ] + }, + { + "title": "痛苦魔方", + "generic": [ + { + "indent_level": 1, + "note": "现在裂谷之间移动可以躲避弹道" + } + ] + } + ], + "items": [ + { + "ability_id": 208, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "力量加成从+26增加至+30" + } + ] + }, + { + "ability_id": 139, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "敏捷加成从+35减少至+30" + }, + { + "indent_level": 1, + "note": "攻击力加成从+25增加至+30" + } + ] + }, + { + "ability_id": 1872, + "ability_notes": [ + { + "indent_level": 1, + "note": "售价从800金增加至900金" + } + ] + }, + { + "ability_id": 119, + "ability_notes": [ + { + "indent_level": 1, + "note": "图纸售价从1350减少至1250。总价不变,还是4500金" + } + ] + }, + { + "ability_id": 1466, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "图纸售价从400减少至300。总价不变,还是4650金" + } + ] + }, + { + "ability_id": 1856, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "鲁姆斯克仪式的移动速度窃取持续时间从1.5秒增加至2秒" + }, + { + "indent_level": 1, + "note": "鲁姆斯克仪式的腐化光环效果从75%增加至90%" + } + ] + }, + { + "ability_id": 133, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "多个圣剑的技能增强不再叠加" + } + ] + }, + { + "ability_id": 160, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "霜冻攻击的攻击速度降低从20%增加至25%" + } + ] + }, + { + "ability_id": 65, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "攻击速度加成从+35增加至+40" + } + ] + }, + { + "ability_id": 210, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "缴械的冷却时间从16秒减少至15秒" + } + ] + }, + { + "ability_id": 263, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "飓风之力的增益持续时间从6秒减少至5秒" + } + ] + }, + { + "ability_id": 259, + "ability_notes": [ + { + "indent_level": 1, + "note": "魔法恢复增强从30%减少至20%" + } + ] + }, + { + "ability_id": 223, + "ability_notes": [ + { + "indent_level": 1, + "note": "魔法恢复增强从35%减少至25%" + } + ] + }, + { + "ability_id": 273, + "ability_notes": [ + { + "indent_level": 1, + "note": "魔法恢复增强从40%减少至30%" + } + ] + }, + { + "ability_id": 277, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "魔法恢复增强从40%减少至30%" + } + ] + }, + { + "ability_id": 172, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "狂热对远程英雄提供的移动速度加成从8%减少至6%" + }, + { + "indent_level": 1, + "note": "狂热现在对远程英雄提供15%减速抗性,对近战英雄提供30%减速抗性,而不是所有英雄都提供30%" + } + ] + }, + { + "ability_id": 1575, + "ability_notes": [ + { + "indent_level": 1, + "note": "冰霜在攻击友军时不再施加" + }, + { + "indent_level": 1, + "note": "冰霜的生命回复减少从13%增加至15%" + } + ] + }, + { + "ability_id": 569, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "腐蚀的生命回复减少从16%增加至18%" + } + ] + }, + { + "ability_id": 181, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "毒性攻击的每秒伤害从10点增加至12点" + } + ] + }, + { + "ability_id": 260, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "不再提供+12 生命恢复、+6 魔法恢复和+20 攻击力" + } + ] + }, + { + "ability_id": 156, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "不洁狂热的冷却时间从30秒增加至40秒" + } + ] + }, + { + "ability_id": 188, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "现在伪装的持续时间固定,不会受到增益持续时间增强的作用" + } + ] + }, + { + "ability_id": 92, + "ability_notes": [ + { + "indent_level": 1, + "note": "魔法恢复加成从+1.25减少至+1" + }, + { + "indent_level": 1, + "note": "在储藏处时,附近英雄阵亡不再获得能量点数" + } + ] + }, + { + "ability_id": 1852, + "ability_notes": [ + { + "indent_level": 1, + "note": "在储藏处时,附近英雄阵亡不再获得能量点数" + } + ] + }, + { + "ability_id": 267, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "在储藏处时,附近英雄阵亡不再获得能量点数" + } + ] + }, + { + "ability_id": 190, + "ability_notes": [ + { + "indent_level": 1, + "note": "法术虚弱的魔法消耗从50点减少至25点" + } + ] + } + ], + "neutral_items": [ + { + "ability_id": -1, + "title": "宝物", + "is_general_note": true + }, + { + "ability_id": 1868, + "postfix_lines": 1, + "ability_notes": [ + { + "indent_level": 1, + "note": "采菌时间从1秒减少至0.75秒" + }, + { + "indent_level": 1, + "hide_dot": true, + "note": "
" + } + ] + }, + { + "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("本版方向", ``); + } + if (buffs) { + body += renderPatchSummarySection("明显加强", `
    ${buffs}
    `); + } + if (nerfs) { + body += renderPatchSummarySection("明显削弱", `
    ${nerfs}
    `); + } + if (items) { + body += renderPatchSummarySection("装备要点", `
    ${items}
    `); + } + if (takeaways) { + body += renderPatchSummarySection("选将提示", ``); + } + + 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"