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)
|
||||
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)
|
||||
|
||||
@@ -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/`) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, …)`(居中时两侧预留侧栏) | 比赛 |
|
||||
|
||||
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 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"
|
||||
|
||||
|
||||
|
||||
+10
-1
@@ -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:
|
||||
|
||||
@@ -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_")}
|
||||
|
||||
+141
-10
@@ -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
|
||||
? `<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>`;
|
||||
}
|
||||
@@ -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
|
||||
? `<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>`;
|
||||
}
|
||||
@@ -3694,7 +3712,7 @@ function renderHeroEntry(hero, lookup) {
|
||||
const key = meta ? meta.key : "";
|
||||
const name = meta ? meta.name_loc : "其它单位";
|
||||
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>`;
|
||||
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() {
|
||||
if (!state.data) return;
|
||||
const select = $("#patch-select");
|
||||
@@ -3774,6 +3898,7 @@ function renderPatches() {
|
||||
if (select) select.innerHTML = "";
|
||||
if (verEl) verEl.textContent = "";
|
||||
root.innerHTML = '<div class="patches-empty">暂无版本数据</div>';
|
||||
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",
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
+19
-12
@@ -43,8 +43,8 @@
|
||||
}
|
||||
</script>
|
||||
<link rel="icon" href="/ui-icon/dota2_logo.png" type="image/png" />
|
||||
<link rel="stylesheet" href="/style.css?v=0.6.9" />
|
||||
<script src="/mobile-gate.js?v=0.6.9"></script>
|
||||
<link rel="stylesheet" href="/style.css?v=0.6.13" />
|
||||
<script src="/mobile-gate.js?v=0.6.13"></script>
|
||||
</head>
|
||||
<body>
|
||||
<h1 class="sr-only">DOTA2 上分帝</h1>
|
||||
@@ -218,14 +218,21 @@
|
||||
</main>
|
||||
|
||||
<main id="patches-view" class="board patches-board hidden">
|
||||
<div class="patches-header">
|
||||
<h2 class="patches-title">
|
||||
<span class="patches-kicker page-kicker">游戏性更新</span>
|
||||
<span class="patches-ver" id="patch-ver"></span>
|
||||
</h2>
|
||||
<select id="patch-select" aria-label="选择版本"></select>
|
||||
<div class="patches-cluster">
|
||||
<div class="patches-center-wrap">
|
||||
<div class="patches-main">
|
||||
<div class="patches-header">
|
||||
<h2 class="patches-title">
|
||||
<span class="patches-kicker page-kicker">游戏性更新</span>
|
||||
<span class="patches-ver" id="patch-ver"></span>
|
||||
</h2>
|
||||
<select id="patch-select" aria-label="选择版本"></select>
|
||||
</div>
|
||||
<div class="patches-detail" id="patches-detail"></div>
|
||||
</div>
|
||||
<aside class="patches-aside" id="patches-aside" hidden></aside>
|
||||
</div>
|
||||
</div>
|
||||
<div class="patches-detail" id="patches-detail"></div>
|
||||
</main>
|
||||
|
||||
<div id="detail-drawer" class="detail-drawer is-collapsed hidden" aria-hidden="true">
|
||||
@@ -233,8 +240,8 @@
|
||||
</div>
|
||||
<footer class="heroes-site-foot" id="heroes-site-foot" aria-hidden="true"></footer>
|
||||
|
||||
<script src="/config.js?v=0.6.9"></script>
|
||||
<script src="/router.js?v=0.6.9"></script>
|
||||
<script src="/app.js?v=0.6.9"></script>
|
||||
<script src="/config.js?v=0.6.13"></script>
|
||||
<script src="/router.js?v=0.6.13"></script>
|
||||
<script src="/app.js?v=0.6.13"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+184
-6
@@ -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;
|
||||
|
||||
+84
-18
@@ -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:
|
||||
|
||||
+35
-1
@@ -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 {}),
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user