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:
voson
2026-07-31 10:06:09 +08:00
co-authored by Cursor
parent a257f96d94
commit 30218790ce
18 changed files with 2169 additions and 63 deletions
+4 -1
View File
@@ -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)
+2 -1
View File
@@ -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/` |
+25
View File
@@ -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 dailyOpenDota 先单次探测,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
+1 -1
View File
@@ -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

+59
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
View File
@@ -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:
+1 -3
View File
@@ -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_")}
+143 -12
View File
@@ -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 setAbilityIcon(img, abilityKey, isInnate) { function markAbilityIconMissing(img) {
if (isInnate) {
img.src = innateIconSrc();
img.onerror = null;
return;
}
img.src = abilityIconSrc(abilityKey);
img.onerror = () => {
img.onerror = null; img.onerror = null;
img.removeAttribute("src"); img.removeAttribute("src");
img.alt = ""; img.alt = "";
img.classList.add("missing"); img.classList.add("missing");
}
function setAbilityIcon(img, abilityKey, isInnate) {
if (isInnate) {
img.src = innateIconSrc();
img.onerror = () => markAbilityIconMissing(img);
return;
}
img.src = abilityIconSrc(abilityKey);
img.onerror = () => {
// 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; 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 = function () {
this.onerror = null; this.onerror = null;
this.src = assetUrl("ability/innate.png"); 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 -1
View File
@@ -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 = "";
+12 -5
View File
@@ -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,6 +218,9 @@
</main> </main>
<main id="patches-view" class="board patches-board hidden"> <main id="patches-view" class="board patches-board hidden">
<div class="patches-cluster">
<div class="patches-center-wrap">
<div class="patches-main">
<div class="patches-header"> <div class="patches-header">
<h2 class="patches-title"> <h2 class="patches-title">
<span class="patches-kicker page-kicker">游戏性更新</span> <span class="patches-kicker page-kicker">游戏性更新</span>
@@ -226,6 +229,10 @@
<select id="patch-select" aria-label="选择版本"></select> <select id="patch-select" aria-label="选择版本"></select>
</div> </div>
<div class="patches-detail" id="patches-detail"></div> <div class="patches-detail" id="patches-detail"></div>
</div>
<aside class="patches-aside" id="patches-aside" hidden></aside>
</div>
</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
View File
@@ -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;
+68 -2
View File
@@ -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,11 +279,61 @@ 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."""
# 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. # 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_stats.py", dry_run=dry_run, soft_fail=True)
run_script("fetch_leaderboards.py", dry_run=dry_run)
run_script( run_script(
"fetch_hero_matches.py", "fetch_hero_matches.py",
"--source", "--source",
@@ -290,6 +349,12 @@ def run_daily(*, dry_run: bool = False) -> bool:
dry_run=dry_run, dry_run=dry_run,
soft_fail=True, 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)
# 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
View File
@@ -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 {}),
+42
View File
@@ -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"