v0.5.109: hero role toolbar, skill side-by-side layout, taller detail panel.

Move role filters between grid and detail, fix skill video/text layout and detail height jump, unify item dividers, drop OpenDota matchup cross, sync docs and assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-30 00:48:07 +08:00
co-authored by Cursor
parent 09f0a0b4a0
commit d6212169af
38 changed files with 7225 additions and 6924 deletions
+36 -64
View File
@@ -28,7 +28,6 @@ import urllib.error
from shared.grid import ATTR_ORDER, hero_table
from shared.hero_tags import TAG_ORDER, tags_for_hero
from shared.http_utils import http_bytes
from shared.matchup_cross import build_baseline, enrich_hero_matchups_cross
from shared.paths import (
ABILITY_ICONS,
ABILITY_VIDEOS,
@@ -38,8 +37,8 @@ from shared.paths import (
ITEM_CAT_ICONS,
ITEM_ICONS,
RANK_ICONS,
ROLE_ICONS,
ROOT,
SHARED_DATA,
STREAMER_AVATARS,
STREAMER_VIDEOS,
TEMPLATES_CDN,
@@ -91,7 +90,6 @@ PRO_MATCHES_PATH = DATA / "pro_matches.json"
STREAMERS_PATH = DATA / "streamers.json"
STRATZ_HERO_META_PATH = DATA / "stratz_hero_meta.json"
STRATZ_MATCHUP_TOPS_PATH = DATA / "stratz_matchup_tops.json"
OPENDOTA_MATCHUPS_PATH = SHARED_DATA / "matchups.json"
_LEGACY_DATA = ROOT / "data"
@@ -428,23 +426,8 @@ def load_stratz_hero_meta() -> dict:
}
def _load_opendota_matchups() -> dict:
path = _first_existing(OPENDOTA_MATCHUPS_PATH, _LEGACY_DATA / "matchups.json")
if path is None:
return {}
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return raw if isinstance(raw, dict) else {}
def load_stratz_matchup_tops() -> dict:
"""STRATZ vs/with top lists per hero (see fetch_stratz_meta.py). Web only.
When OpenDota matchups.json is present, each counter/countered row gets a
``cross`` quality blob (agree / conflict / weak). Observation only.
"""
"""STRATZ vs/with top lists per hero (see fetch_stratz_meta.py). Web only."""
empty: dict = {
"fetched_at": None,
"started_at": None,
@@ -459,7 +442,6 @@ def load_stratz_matchup_tops() -> dict:
},
"note": None,
"stats": {},
"cross_source": None,
"by_hero": {},
}
path = _first_existing(
@@ -474,9 +456,8 @@ def load_stratz_matchup_tops() -> dict:
if not isinstance(raw, dict):
return empty
by_hero_raw = dict(raw.get("by_hero") or {})
scope = raw.get("scope") if isinstance(raw.get("scope"), dict) else empty["scope"]
out: dict = {
return {
"fetched_at": raw.get("fetched_at"),
"started_at": raw.get("started_at"),
"finished_at": raw.get("finished_at"),
@@ -487,50 +468,9 @@ def load_stratz_matchup_tops() -> dict:
"scope": scope,
"note": raw.get("note"),
"stats": dict(raw.get("stats") or {}),
"cross_source": None,
"by_hero": by_hero_raw,
"by_hero": dict(raw.get("by_hero") or {}),
}
odota = _load_opendota_matchups()
by_odota = odota.get("by_hero") or {}
if not by_odota:
out["cross_source"] = {
"available": False,
"label_zh": "暂无可靠交叉结论",
"reason": "missing_opendota_matchups",
}
return out
key_to_id = {h["key"]: int(h["id"]) for h in hero_table()}
baseline = build_baseline(by_odota)
enriched: dict[str, dict] = {}
for key, cell in by_hero_raw.items():
if not isinstance(cell, dict):
continue
hid = key_to_id.get(key)
if hid is None:
enriched[key] = cell
continue
enriched[key] = enrich_hero_matchups_cross(
cell,
hero_id=hid,
by_odota=by_odota,
baseline=baseline,
)
out["by_hero"] = enriched
out["cross_source"] = {
"available": True,
"source": "opendota",
"attribution": odota.get("attribution") or "https://opendota.com",
"fetched_at": odota.get("fetched_at"),
"label_zh": "OpenDota 独立交叉(基线校正)",
"manual_review_note": (
"Dota2ProTracker7k+ / 职业)仅作人工复核「来源分歧」条目的高分段参考,"
"不进入自动流水线。"
),
}
return out
def load_hero_abilities() -> dict:
"""Slim per-hero abilities / Aghs upgrades / talents for the web hero pane."""
@@ -932,6 +872,27 @@ class Handler(BaseHTTPRequestHandler):
return
self._send(200, fpath.read_bytes(), "image/png")
return
if path.startswith("/role-icon/"):
key = path[len("/role-icon/") :]
allowed_role_icons = {
"Carry.png",
"Support.png",
"Pusher.png",
"Escape.png",
"Nuker.png",
"Initiator.png",
"Disabler.png",
"Durable.png",
}
if key not in allowed_role_icons:
self._json(400, {"error": "bad role icon"})
return
fpath = ROLE_ICONS / key
if not fpath.is_file():
self.send_error(404)
return
self._send(200, fpath.read_bytes(), "image/png")
return
if path.startswith("/ui-icon/"):
key = path[len("/ui-icon/") :]
allowed_ui = {
@@ -939,6 +900,17 @@ class Handler(BaseHTTPRequestHandler):
"dota2_logo.png",
"dota2_logo_wordmark.png",
"platform_douyin.png",
"platform_bilibili.png",
# Hero combat stats (dota2.com.cn/herostatic/stats)
"icon_damage.png",
"icon_attack_time.png",
"icon_attack_range.png",
"icon_projectile_speed.png",
"icon_armor.png",
"icon_magic_resist.png",
"icon_movement_speed.png",
"icon_turn_rate.png",
"icon_vision.png",
}
if key not in allowed_ui:
self._json(400, {"error": "bad ui icon"})