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 <cursoragent@cursor.com>
This commit is contained in:
+4
-1
@@ -42,7 +42,10 @@ web/.refresh-cache/
|
|||||||
|
|
||||||
# Large regenerable CDN media (fetch scripts)
|
# Large regenerable CDN media (fetch scripts)
|
||||||
web/assets/ability_videos/
|
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/hero_portraits/
|
||||||
web/assets/item_icons/
|
web/assets/item_icons/
|
||||||
# Curated streamer highlight clips (large; sync via OSS, not git)
|
# Curated streamer highlight clips (large; sync via OSS, not git)
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ climperor/
|
|||||||
| `web/fetch_item_counter_stats.py` | OpenDota Explorer 近场次聚合:对阵英雄时敌方终局装备购买率/胜率,与同窗口该装备全局队伍基线作差 → `web/data/item_counter_stats.json`(可再生成缓存;观测证据,不作因果结论;weekly 软失败) |
|
| `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/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/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_grid_order.json` | Web 站点四列网格顺序 |
|
||||||
| `web/data/hero_items.json` | 核心成品装备缓存(Web + PC 锁定后核心装推荐只读;由 `fetch_hero_items.py` 生成) |
|
| `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` 生成;**不**参与局内推荐) |
|
| `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/rank_icons/` | 天梯勋章图标(OpenDota `rank_icon_1..8` 先锋→冠绝;走势段位选择器) |
|
||||||
| `web/assets/item_icons/` | Steam CDN 装备图标(上分帝 Web常用装备) |
|
| `web/assets/item_icons/` | Steam CDN 装备图标(上分帝 Web常用装备) |
|
||||||
| `web/assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) |
|
| `web/assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) |
|
||||||
| `web/assets/ability_icons/` | Steam CDN 技能图标(上分帝 Web;按需缓存);`innate.png` 先天共用图标;`talent_tree.png` 天赋树触发图标 |
|
| `web/assets/ability_icons/` | Steam CDN 技能图标(上分帝 Web;按需缓存,目录 gitignore);**例外提交**共用徽章 `innate.png` / `talent_tree.png`(先天/天赋树;CDN 无独立图标时前端与本地 serve 回退用) |
|
||||||
| `web/assets/ui_icons/` | dota2.com.cn 通用 UI 图标(`cooldown.png` 等;`icon_damage.png` 等战斗属性图标;平台 logo) |
|
| `web/assets/ui_icons/` | dota2.com.cn 通用 UI 图标(`cooldown.png` 等;`icon_damage.png` 等战斗属性图标;平台 logo) |
|
||||||
| `web/assets/streamer_avatars/` | 主播头像缓存(由 `fetch_streamers.py` 写入;上分帝 Web「主播」) |
|
| `web/assets/streamer_avatars/` | 主播头像缓存(由 `fetch_streamers.py` 写入;上分帝 Web「主播」) |
|
||||||
| `web/assets/streamer_videos/` | 主播精选高光 mp4 + 同名 JPG 封面(手工放入;上分帝 Web「主播」卡片;勿提交,线上走 OSS `streamer-video/`) |
|
| `web/assets/streamer_videos/` | 主播精选高光 mp4 + 同名 JPG 封面(手工放入;上分帝 Web「主播」卡片;勿提交,线上走 OSS `streamer-video/`) |
|
||||||
|
|||||||
@@ -11,11 +11,36 @@
|
|||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- 上分帝 Web 每日刷新明星比赛改为按 `fetched_at` 轮换(默认每次 15 人),遇 OpenDota 连续 429 熔断并保留旧缓存;`hero_stats` / `hero_matches` 限流时 soft-fail 保留缓存,避免整档 daily 被拖死。
|
- 上分帝 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
|
### Removed
|
||||||
|
|
||||||
- PC 选将 overlay 不再在顶栏头像下显示定位图标;仍保留网格「克/搭/补」与阵容分析条。
|
- 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
|
## [0.6.9] - 2026-07-30
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ components:
|
|||||||
| 档位 | 宽度 | 页面 |
|
| 档位 | 宽度 | 页面 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| full | 100% | 英雄网格 |
|
| full | 100% | 英雄网格 |
|
||||||
| read | `min(820px, 92%)` | 版本 |
|
| read | 主列 `min(820px)` 页内居中;宽屏解读贴主列右侧(不参与居中),sticky + 视口内自滚动 | 版本 |
|
||||||
| standard | `min(880px, calc(100% - 48px))` | 排行、主播、机制 |
|
| standard | `min(880px, calc(100% - 48px))` | 排行、主播、机制 |
|
||||||
| wide | `min(1080px, …)` | 走势 |
|
| wide | `min(1080px, …)` | 走势 |
|
||||||
| data | `min(1200px, …)`(居中时两侧预留侧栏) | 比赛 |
|
| data | `min(1200px, …)`(居中时两侧预留侧栏) | 比赛 |
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
@@ -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": [
|
||||||
|
"出门与转场更吃解缠绕:门/传送通道不再能硬顶缠绕读条",
|
||||||
|
"慧核与蓝耗英雄更早感受到慧光系蓝回缩水,出装节奏会前移",
|
||||||
|
"支援装「储藏处挂机充能」被堵,灵龛/灵瓮更依赖当面参团",
|
||||||
|
"电炎绝手、卓尔、宙斯大招本版优先降权;飞机、黑鸟、冰魂大招等可多看一眼"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1559
-6
File diff suppressed because it is too large
Load Diff
@@ -55,7 +55,7 @@ from shared.paths import (
|
|||||||
from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle
|
from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle
|
||||||
from serve_relations import WEB_DIR, build_payload
|
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"
|
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -20,6 +20,7 @@ from pathlib import Path
|
|||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import urllib.error
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from shared.grid import hero_table
|
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}
|
id_to_key = {int(h["id"]): h["key"] for h in heroes}
|
||||||
|
|
||||||
print(f"fetching {OPENDOTA_HERO_STATS} ...", flush=True)
|
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):
|
if not isinstance(raw, list):
|
||||||
raise SystemExit(f"unexpected heroStats payload type: {type(raw).__name__}")
|
raise SystemExit(f"unexpected heroStats payload type: {type(raw).__name__}")
|
||||||
if not raw:
|
if not raw:
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ import time
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
from datetime import datetime, timedelta, timezone
|
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
|
from shared.paths import ABILITY_ICONS, DATA, HERO_PORTRAITS, ITEM_ICONS
|
||||||
|
|
||||||
PATCHES_LIST_URL = "https://www.dota2.com/datafeed/patchnoteslist?language=schinese"
|
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:
|
def download_referenced_icons(lookup: dict, *, delay: float) -> None:
|
||||||
"""Pull referenced item + ability icons into assets/ (skip existing)."""
|
"""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")}
|
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 scrolls share one generic icon on Steam CDN.
|
||||||
recipe_keys = {k for k in item_keys if k.startswith("recipe_")}
|
recipe_keys = {k for k in item_keys if k.startswith("recipe_")}
|
||||||
|
|||||||
+141
-10
@@ -1175,18 +1175,24 @@ function wireTalentTrigger(btn, talents) {
|
|||||||
* badge — many innate keys 404 on Steam CDN, matching dota2.com.
|
* badge — many innate keys 404 on Steam CDN, matching dota2.com.
|
||||||
* Other abilities load `ability/{key}.png` with a quiet miss state on error.
|
* 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) {
|
function setAbilityIcon(img, abilityKey, isInnate) {
|
||||||
if (isInnate) {
|
if (isInnate) {
|
||||||
img.src = innateIconSrc();
|
img.src = innateIconSrc();
|
||||||
img.onerror = null;
|
img.onerror = () => markAbilityIconMissing(img);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
img.src = abilityIconSrc(abilityKey);
|
img.src = abilityIconSrc(abilityKey);
|
||||||
img.onerror = () => {
|
img.onerror = () => {
|
||||||
img.onerror = null;
|
// Many innates / renamed keys 404 on CDN — shared badge, then quiet miss.
|
||||||
img.removeAttribute("src");
|
img.onerror = () => markAbilityIconMissing(img);
|
||||||
img.alt = "";
|
img.src = innateIconSrc();
|
||||||
img.classList.add("missing");
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3668,13 +3674,25 @@ function innateAbilityKeys() {
|
|||||||
return s;
|
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) {
|
function patchAbilityBlockHtml(ab, lookup) {
|
||||||
const ameta = (lookup.abilities || {})[String(ab.ability_id)];
|
const ameta = (lookup.abilities || {})[String(ab.ability_id)];
|
||||||
const akey = ameta ? ameta.key : "";
|
const akey = ameta ? ameta.key : "";
|
||||||
const aname = ameta ? ameta.name_loc : "# " + ab.ability_id;
|
const aname = ameta ? ameta.name_loc : "# " + ab.ability_id;
|
||||||
const innate = akey && innateAbilityKeys().has(akey);
|
const innate = akey && innateAbilityKeys().has(akey);
|
||||||
const aicon = akey
|
const aicon = akey
|
||||||
? `<img class="patch-ability-icon" src="${innate ? innateIconSrc() : abilityIconSrc(akey)}" alt="" onerror="this.style.visibility='hidden'">`
|
? `<img class="patch-ability-icon" src="${innate ? innateIconSrc() : abilityIconSrc(akey)}" alt="" onerror="${patchIconOnErrorAttr(!innate)}">`
|
||||||
: "";
|
: "";
|
||||||
return `<div class="patch-ability">${aicon}<div class="patch-entry-body"><span class="patch-name">${escapeHtml(aname)}</span>${renderNotesList(ab.ability_notes)}</div></div>`;
|
return `<div class="patch-ability">${aicon}<div class="patch-entry-body"><span class="patch-name">${escapeHtml(aname)}</span>${renderNotesList(ab.ability_notes)}</div></div>`;
|
||||||
}
|
}
|
||||||
@@ -3684,7 +3702,7 @@ function renderItemEntry(entry, lookup) {
|
|||||||
const key = meta ? meta.key : "";
|
const key = meta ? meta.key : "";
|
||||||
const name = meta ? meta.name_loc : "# " + entry.ability_id;
|
const name = meta ? meta.name_loc : "# " + entry.ability_id;
|
||||||
const icon = key
|
const icon = key
|
||||||
? `<img class="patch-item-icon" src="${itemIconSrc(key)}" alt="" onerror="this.style.visibility='hidden'">`
|
? `<img class="patch-item-icon" src="${itemIconSrc(key)}" alt="" onerror="${patchIconOnErrorAttr(false)}">`
|
||||||
: "";
|
: "";
|
||||||
return `<div class="patch-entry-row">${icon}<div class="patch-entry-body"><span class="patch-name">${escapeHtml(name)}</span>${renderNotesList(entry.ability_notes)}</div></div>`;
|
return `<div class="patch-entry-row">${icon}<div class="patch-entry-body"><span class="patch-name">${escapeHtml(name)}</span>${renderNotesList(entry.ability_notes)}</div></div>`;
|
||||||
}
|
}
|
||||||
@@ -3694,7 +3712,7 @@ function renderHeroEntry(hero, lookup) {
|
|||||||
const key = meta ? meta.key : "";
|
const key = meta ? meta.key : "";
|
||||||
const name = meta ? meta.name_loc : "其它单位";
|
const name = meta ? meta.name_loc : "其它单位";
|
||||||
const portrait = key
|
const portrait = key
|
||||||
? `<img class="patch-hero-icon" src="${portraitSrc(key)}" alt="" onerror="this.style.visibility='hidden'">`
|
? `<img class="patch-hero-icon" src="${portraitSrc(key)}" alt="" onerror="${patchIconOnErrorAttr(false)}">`
|
||||||
: "";
|
: "";
|
||||||
let html = `<div class="patch-hero"><div class="patch-hero-head">${portrait}<span class="patch-name">${escapeHtml(name)}</span></div>`;
|
let html = `<div class="patch-hero"><div class="patch-hero-head">${portrait}<span class="patch-name">${escapeHtml(name)}</span></div>`;
|
||||||
html += renderHeroNotes(hero.hero_notes);
|
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"
|
||||||
|
? `<img class="patch-summary-icon patch-summary-icon--hero" src="${portraitSrc(key)}" alt="" onerror="this.style.visibility='hidden'">`
|
||||||
|
: `<img class="patch-summary-icon patch-summary-icon--item" src="${itemIconSrc(key)}" alt="" onerror="this.style.visibility='hidden'">`;
|
||||||
|
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}<span class="patch-summary-chip-body"><span class="patch-summary-chip-name">${escapeHtml(label)}</span>${note ? `<span class="patch-summary-chip-note">${escapeHtml(note)}</span>` : ""}</span></${tag}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPatchSummarySection(title, html) {
|
||||||
|
if (!html) return "";
|
||||||
|
return `<section class="patch-summary-section"><h4 class="patch-summary-section-title">${escapeHtml(title)}</h4>${html}</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => `<li>${escapeHtml(t)}</li>`)
|
||||||
|
.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) => `<li>${escapeHtml(t)}</li>`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
let body = "";
|
||||||
|
if (summary.headline) {
|
||||||
|
body += `<p class="patch-summary-headline">${escapeHtml(String(summary.headline))}</p>`;
|
||||||
|
}
|
||||||
|
if (themes) {
|
||||||
|
body += renderPatchSummarySection("本版方向", `<ul class="patch-summary-list">${themes}</ul>`);
|
||||||
|
}
|
||||||
|
if (buffs) {
|
||||||
|
body += renderPatchSummarySection("明显加强", `<div class="patch-summary-chips">${buffs}</div>`);
|
||||||
|
}
|
||||||
|
if (nerfs) {
|
||||||
|
body += renderPatchSummarySection("明显削弱", `<div class="patch-summary-chips">${nerfs}</div>`);
|
||||||
|
}
|
||||||
|
if (items) {
|
||||||
|
body += renderPatchSummarySection("装备要点", `<div class="patch-summary-chips">${items}</div>`);
|
||||||
|
}
|
||||||
|
if (takeaways) {
|
||||||
|
body += renderPatchSummarySection("选将提示", `<ul class="patch-summary-list">${takeaways}</ul>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
aside.hidden = false;
|
||||||
|
aside.innerHTML = `
|
||||||
|
<div class="patch-summary ui-panel">
|
||||||
|
<div class="patch-summary-head">
|
||||||
|
<h3 class="patch-summary-title">版本解读</h3>
|
||||||
|
<p class="patch-summary-disclaimer">非官方 · 社区摘要</p>
|
||||||
|
</div>
|
||||||
|
${body}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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() {
|
function renderPatches() {
|
||||||
if (!state.data) return;
|
if (!state.data) return;
|
||||||
const select = $("#patch-select");
|
const select = $("#patch-select");
|
||||||
@@ -3774,6 +3898,7 @@ function renderPatches() {
|
|||||||
if (select) select.innerHTML = "";
|
if (select) select.innerHTML = "";
|
||||||
if (verEl) verEl.textContent = "";
|
if (verEl) verEl.textContent = "";
|
||||||
root.innerHTML = '<div class="patches-empty">暂无版本数据</div>';
|
root.innerHTML = '<div class="patches-empty">暂无版本数据</div>';
|
||||||
|
renderPatchAside(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3803,6 +3928,8 @@ function renderPatches() {
|
|||||||
const cur = patches.find((p) => p.version === state.selectedPatch) || patches[0];
|
const cur = patches.find((p) => p.version === state.selectedPatch) || patches[0];
|
||||||
if (verEl) verEl.textContent = (cur && cur.version) || "";
|
if (verEl) verEl.textContent = (cur && cur.version) || "";
|
||||||
|
|
||||||
|
renderPatchAside(state.selectedPatch);
|
||||||
|
|
||||||
const lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} };
|
const lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} };
|
||||||
const details = state.data.patch_details || {};
|
const details = state.data.patch_details || {};
|
||||||
const det = details[state.selectedPatch];
|
const det = details[state.selectedPatch];
|
||||||
@@ -5721,8 +5848,11 @@ function renderMechanics() {
|
|||||||
abImg.alt = "";
|
abImg.alt = "";
|
||||||
abImg.loading = "lazy";
|
abImg.loading = "lazy";
|
||||||
abImg.onerror = function () {
|
abImg.onerror = function () {
|
||||||
this.onerror = null;
|
this.onerror = function () {
|
||||||
this.src = assetUrl("ability/innate.png");
|
this.onerror = null;
|
||||||
|
this.remove();
|
||||||
|
};
|
||||||
|
this.src = innateIconSrc();
|
||||||
};
|
};
|
||||||
const textWrap = document.createElement("span");
|
const textWrap = document.createElement("span");
|
||||||
textWrap.className = "mechanics-text";
|
textWrap.className = "mechanics-text";
|
||||||
@@ -6201,6 +6331,7 @@ async function main() {
|
|||||||
state.data.patches = state.data.patches || [];
|
state.data.patches = state.data.patches || [];
|
||||||
state.data.patch_lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} };
|
state.data.patch_lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} };
|
||||||
state.data.patch_details = state.data.patch_details || {};
|
state.data.patch_details = state.data.patch_details || {};
|
||||||
|
state.data.patch_summaries = state.data.patch_summaries || { by_version: {} };
|
||||||
state.data.leaderboards = state.data.leaderboards || {
|
state.data.leaderboards = state.data.leaderboards || {
|
||||||
fetched_at: null,
|
fetched_at: null,
|
||||||
source: "valve",
|
source: "valve",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* Local defaults; production export overwrites via export_relations_site.py. */
|
/* Local defaults; production export overwrites via export_relations_site.py. */
|
||||||
var SITE_VERSION = "0.6.9";
|
var SITE_VERSION = "0.6.13";
|
||||||
var SITE_ORIGIN = "";
|
var SITE_ORIGIN = "";
|
||||||
var ABILITY_VIDEO_BASE = "";
|
var ABILITY_VIDEO_BASE = "";
|
||||||
var STATIC_ASSET_BASE = "";
|
var STATIC_ASSET_BASE = "";
|
||||||
|
|||||||
+19
-12
@@ -43,8 +43,8 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
|
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
|
||||||
<link rel="stylesheet" href="/style.css?v=0.6.9" />
|
<link rel="stylesheet" href="/style.css?v=0.6.13" />
|
||||||
<script src="/mobile-gate.js?v=0.6.9"></script>
|
<script src="/mobile-gate.js?v=0.6.13"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1 class="sr-only">DOTA2 上分帝</h1>
|
<h1 class="sr-only">DOTA2 上分帝</h1>
|
||||||
@@ -218,14 +218,21 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<main id="patches-view" class="board patches-board hidden">
|
<main id="patches-view" class="board patches-board hidden">
|
||||||
<div class="patches-header">
|
<div class="patches-cluster">
|
||||||
<h2 class="patches-title">
|
<div class="patches-center-wrap">
|
||||||
<span class="patches-kicker page-kicker">游戏性更新</span>
|
<div class="patches-main">
|
||||||
<span class="patches-ver" id="patch-ver"></span>
|
<div class="patches-header">
|
||||||
</h2>
|
<h2 class="patches-title">
|
||||||
<select id="patch-select" aria-label="选择版本"></select>
|
<span class="patches-kicker page-kicker">游戏性更新</span>
|
||||||
|
<span class="patches-ver" id="patch-ver"></span>
|
||||||
|
</h2>
|
||||||
|
<select id="patch-select" aria-label="选择版本"></select>
|
||||||
|
</div>
|
||||||
|
<div class="patches-detail" id="patches-detail"></div>
|
||||||
|
</div>
|
||||||
|
<aside class="patches-aside" id="patches-aside" hidden></aside>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="patches-detail" id="patches-detail"></div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div id="detail-drawer" class="detail-drawer is-collapsed hidden" aria-hidden="true">
|
<div id="detail-drawer" class="detail-drawer is-collapsed hidden" aria-hidden="true">
|
||||||
@@ -233,8 +240,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<footer class="heroes-site-foot" id="heroes-site-foot" aria-hidden="true"></footer>
|
<footer class="heroes-site-foot" id="heroes-site-foot" aria-hidden="true"></footer>
|
||||||
|
|
||||||
<script src="/config.js?v=0.6.9"></script>
|
<script src="/config.js?v=0.6.13"></script>
|
||||||
<script src="/router.js?v=0.6.9"></script>
|
<script src="/router.js?v=0.6.13"></script>
|
||||||
<script src="/app.js?v=0.6.9"></script>
|
<script src="/app.js?v=0.6.13"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+184
-6
@@ -1675,7 +1675,7 @@ body.detail-drawer-open {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.skill-icon img.missing {
|
.skill-icon img.missing {
|
||||||
visibility: hidden;
|
display: none;
|
||||||
}
|
}
|
||||||
/* Circular triggers: talent tree + innate (official dota2.com order). */
|
/* Circular triggers: talent tree + innate (official dota2.com order). */
|
||||||
.skill-icon.talent-trigger,
|
.skill-icon.talent-trigger,
|
||||||
@@ -2833,17 +2833,32 @@ body:has(#items-view:not(.hidden)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.patches-board {
|
.patches-board {
|
||||||
display: flex;
|
overflow-x: hidden;
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
overflow-y: auto;
|
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 {
|
.patches-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-lg);
|
gap: var(--space-lg);
|
||||||
width: min(var(--content-read), 92%);
|
width: 100%;
|
||||||
padding: var(--space-xl) 0 10px;
|
padding: var(--space-xl) 0 10px;
|
||||||
}
|
}
|
||||||
.patches-title {
|
.patches-title {
|
||||||
@@ -2881,9 +2896,169 @@ body:has(#items-view:not(.hidden)) {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.patches-detail {
|
.patches-detail {
|
||||||
width: min(var(--content-read), 92%);
|
width: 100%;
|
||||||
padding: 6px 0 64px;
|
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,
|
.patches-empty,
|
||||||
.rankings-empty,
|
.rankings-empty,
|
||||||
.streamers-empty {
|
.streamers-empty {
|
||||||
@@ -3384,6 +3559,9 @@ body:has(#items-view:not(.hidden)) {
|
|||||||
height: 48px;
|
height: 48px;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
.patch-ability-icon.missing {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
.patch-name {
|
.patch-name {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
|
|||||||
+84
-18
@@ -32,9 +32,17 @@ ROOT = Path(__file__).resolve().parent
|
|||||||
REPO_ROOT = ROOT.parent
|
REPO_ROOT = ROOT.parent
|
||||||
sys.path.insert(0, str(REPO_ROOT))
|
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
|
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.
|
# Paths whose content change should trigger deploy / OSS.
|
||||||
DATA_WATCH = [
|
DATA_WATCH = [
|
||||||
ROOT / "data" / "hero_stats.json",
|
ROOT / "data" / "hero_stats.json",
|
||||||
@@ -58,6 +66,7 @@ INPUT_WATCH = [
|
|||||||
RELATIONS_JSON,
|
RELATIONS_JSON,
|
||||||
HEROES_JSON,
|
HEROES_JSON,
|
||||||
ROOT / "data" / "hero_grid_order.json",
|
ROOT / "data" / "hero_grid_order.json",
|
||||||
|
ROOT / "data" / "patch_summaries.json",
|
||||||
WEB_FRONTEND / "index.html",
|
WEB_FRONTEND / "index.html",
|
||||||
WEB_FRONTEND / "app.js",
|
WEB_FRONTEND / "app.js",
|
||||||
WEB_FRONTEND / "style.css",
|
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:
|
def run_daily(*, dry_run: bool = False) -> bool:
|
||||||
"""Return True if patch-linked fetch ran."""
|
"""Return True if patch-linked fetch ran."""
|
||||||
# Soft-fail OpenDota quota burns: keep restored cache and continue the tier.
|
# Probe once: if OpenDota is 429/down, keep restored cache and do not burn quota.
|
||||||
run_script("fetch_hero_stats.py", dry_run=dry_run, soft_fail=True)
|
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_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).
|
# 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)
|
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).
|
# 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
|
should_deploy = args.force_deploy or data_changed or assets_changed
|
||||||
if should_deploy and not args.skip_deploy:
|
if should_deploy and not args.skip_deploy:
|
||||||
|
assert_critical_data_ready()
|
||||||
run_script("deploy_relations.py")
|
run_script("deploy_relations.py")
|
||||||
summary["deployed"] = True
|
summary["deployed"] = True
|
||||||
elif should_deploy:
|
elif should_deploy:
|
||||||
|
|||||||
+35
-1
@@ -147,6 +147,7 @@ HERO_ABILITIES_PATH = DATA / "hero_abilities.json"
|
|||||||
ITEM_SHOP_PATH = DATA / "item_shop.json"
|
ITEM_SHOP_PATH = DATA / "item_shop.json"
|
||||||
ITEMS_META_PATH = DATA / "items_meta.json"
|
ITEMS_META_PATH = DATA / "items_meta.json"
|
||||||
PATCHES_PATH = DATA / "patches.json"
|
PATCHES_PATH = DATA / "patches.json"
|
||||||
|
PATCH_SUMMARIES_PATH = DATA / "patch_summaries.json"
|
||||||
LEADERBOARDS_PATH = DATA / "leaderboards.json"
|
LEADERBOARDS_PATH = DATA / "leaderboards.json"
|
||||||
PRO_MATCHES_PATH = DATA / "pro_matches.json"
|
PRO_MATCHES_PATH = DATA / "pro_matches.json"
|
||||||
STREAMERS_PATH = DATA / "streamers.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:
|
def load_leaderboards() -> dict:
|
||||||
"""Valve Immortal division Top 100 (see fetch_leaderboards.py). Preview only."""
|
"""Valve Immortal division Top 100 (see fetch_leaderboards.py). Preview only."""
|
||||||
empty: dict = {
|
empty: dict = {
|
||||||
@@ -627,10 +654,14 @@ def ensure_ability_icon(key: str) -> Path | None:
|
|||||||
try:
|
try:
|
||||||
data = http_bytes(ABILITY_ICON_URL.format(key=key), timeout=30)
|
data = http_bytes(ABILITY_ICON_URL.format(key=key), timeout=30)
|
||||||
if not data or len(data) < 32:
|
if not data or len(data) < 32:
|
||||||
return None
|
raise OSError("empty ability icon")
|
||||||
dest.write_bytes(data)
|
dest.write_bytes(data)
|
||||||
return dest
|
return dest
|
||||||
except (urllib.error.URLError, TimeoutError, OSError):
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -724,6 +755,7 @@ def build_payload() -> dict:
|
|||||||
shop = load_item_shop()
|
shop = load_item_shop()
|
||||||
items_meta = load_items_meta_index()
|
items_meta = load_items_meta_index()
|
||||||
patches_data = load_patches()
|
patches_data = load_patches()
|
||||||
|
patch_summaries = load_patch_summaries()
|
||||||
leaderboards = load_leaderboards()
|
leaderboards = load_leaderboards()
|
||||||
pro_matches = load_pro_matches()
|
pro_matches = load_pro_matches()
|
||||||
streamers = load_streamers()
|
streamers = load_streamers()
|
||||||
@@ -759,6 +791,7 @@ def build_payload() -> dict:
|
|||||||
"patches": patches_data.get("patches") or [],
|
"patches": patches_data.get("patches") or [],
|
||||||
"patch_lookup": patches_data.get("lookup") or {},
|
"patch_lookup": patches_data.get("lookup") or {},
|
||||||
"patch_details": patches_data.get("details") or {},
|
"patch_details": patches_data.get("details") or {},
|
||||||
|
"patch_summaries": patch_summaries,
|
||||||
"leaderboards": leaderboards,
|
"leaderboards": leaderboards,
|
||||||
"pro_matches": pro_matches,
|
"pro_matches": pro_matches,
|
||||||
"streamers": streamers,
|
"streamers": streamers,
|
||||||
@@ -801,6 +834,7 @@ def build_payload() -> dict:
|
|||||||
"shop_items": len(shop.get("items") or {}),
|
"shop_items": len(shop.get("items") or {}),
|
||||||
"patches": len(patches_data.get("patches") or []),
|
"patches": len(patches_data.get("patches") or []),
|
||||||
"patch_details": len(patches_data.get("details") 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 {}),
|
"leaderboard_regions": len(leaderboards.get("regions") or {}),
|
||||||
"pro_match_players": len(pro_matches.get("by_pro") or {}),
|
"pro_match_players": len(pro_matches.get("by_pro") or {}),
|
||||||
"pro_match_heroes": len(pro_matches.get("by_hero") or {}),
|
"pro_match_heroes": len(pro_matches.get("by_hero") or {}),
|
||||||
|
|||||||
@@ -144,6 +144,48 @@ class RefreshDigestTests(unittest.TestCase):
|
|||||||
skip_deploy=True,
|
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:
|
def test_dry_run_skips_cache_restore_and_summary_write(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
summary_path = Path(tmp) / "summary.json"
|
summary_path = Path(tmp) / "summary.json"
|
||||||
|
|||||||
Reference in New Issue
Block a user