v0.2.0: relations preview, item shop, abilities, overlay recommend, GSI enhancements
- Add relations/item/abilities preview (serve_relations.py + web/relations/) - Add fetch scripts: hero_items, item_shop, items_meta, hero_abilities, ability_videos, patches, stratz, matchups, portraits - Add overlay.py (role tags + Top-3 cyan marks), recommend.py - Add http_utils.py, loc_format.py, hero_tags.py, item_fears.py - GSI: full payload JSONL dump, foreground window detection - Drop real template library; CDN-only matching - Update docs: CHANGELOG 0.2.0, DESIGN config table, AGENTS module table - .gitignore: exclude large regenerable assets (icons/portraits/videos)
This commit is contained in:
+146
-20
@@ -4,8 +4,10 @@ Usage:
|
||||
python fetch_cdn_templates.py
|
||||
|
||||
Writes:
|
||||
heroes.json - hero id / key / English name table (from OpenDota constants)
|
||||
data/heroes.json - hero id / key / English name / roles / aliases / base stats
|
||||
templates/cdn/{key}.png - face-centered square crop resized to canonical size
|
||||
|
||||
Preserves manually curated `aliases` / `abbr` from an existing heroes.json on rewrite.
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -13,17 +15,24 @@ from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import requests
|
||||
|
||||
from common import ROOT, TEMPLATES_CDN, load_config
|
||||
from common import HEROES_JSON, TEMPLATES_CDN, load_config
|
||||
from hero_tags import tags_for_hero
|
||||
from http_utils import fetch_hero_list, http_bytes, http_json
|
||||
|
||||
# Valve's own feed: ids, localized names, primary attribute. The hero-selection
|
||||
# grid groups by that attribute and sorts by that localized name, so taking both
|
||||
# from the same source is what lets grid.py place every cell without matching.
|
||||
HEROES_URL = "https://www.dota2.com/datafeed/herolist?language={lang}"
|
||||
# Steam CDN landscape hero art; cropped to a face window for top-bar matching.
|
||||
IMG_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png"
|
||||
# OpenDota constants: roles + base combat / attribute stats.
|
||||
ROLES_URL = "https://raw.githubusercontent.com/odota/dotaconstants/master/build/heroes.json"
|
||||
ATTRS = {0: "str", 1: "agi", 2: "int", 3: "all"}
|
||||
|
||||
# Level-1 display vitals (match Valve herodata / dota2.com hero strip).
|
||||
HP_PER_STR = 22
|
||||
MANA_PER_INT = 12
|
||||
HP_REGEN_PER_STR = 0.1
|
||||
MANA_REGEN_PER_INT = 0.05
|
||||
ARMOR_PER_AGI = 1.0 / 6.0
|
||||
|
||||
# The top bar shows a fixed window of the landscape hero art, not a centred
|
||||
# square. These bounds were fitted against 15 portraits captured in game:
|
||||
# they lift the mean match score from 0.65 to 0.94. Stored as fractions of
|
||||
@@ -31,12 +40,114 @@ ATTRS = {0: "str", 1: "agi", 2: "int", 3: "all"}
|
||||
CROP_X0, CROP_X1 = 38 / 256, (38 + 182) / 256
|
||||
|
||||
|
||||
def fetch_heroes(lang: str = "schinese") -> list[dict]:
|
||||
data = requests.get(HEROES_URL.format(lang=lang), timeout=30).json()
|
||||
heroes = data.get("result", {}).get("data", {}).get("heroes") or data.get("heroes")
|
||||
if not heroes:
|
||||
raise SystemExit("hero list came back empty")
|
||||
return heroes
|
||||
def _num(raw: dict, key: str, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(raw.get(key, default) or default)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _round1(v: float) -> float:
|
||||
return round(v + 1e-9, 1)
|
||||
|
||||
|
||||
def _round2(v: float) -> float:
|
||||
return round(v + 1e-9, 2)
|
||||
|
||||
|
||||
def stats_from_opendota(raw: dict) -> dict:
|
||||
"""Derive level-1 strip + combat fields from an OpenDota heroes.json row."""
|
||||
base_str = _num(raw, "base_str")
|
||||
base_agi = _num(raw, "base_agi")
|
||||
base_int = _num(raw, "base_int")
|
||||
primary = str(raw.get("primary_attr") or "all")
|
||||
atk_min = _num(raw, "base_attack_min")
|
||||
atk_max = _num(raw, "base_attack_max")
|
||||
if primary == "str":
|
||||
dmg_bonus = base_str
|
||||
elif primary == "agi":
|
||||
dmg_bonus = base_agi
|
||||
elif primary == "int":
|
||||
dmg_bonus = base_int
|
||||
else:
|
||||
dmg_bonus = 0.7 * (base_str + base_agi + base_int)
|
||||
result = {
|
||||
"base_str": int(base_str) if base_str == int(base_str) else base_str,
|
||||
"str_gain": _round1(_num(raw, "str_gain")),
|
||||
"base_agi": int(base_agi) if base_agi == int(base_agi) else base_agi,
|
||||
"agi_gain": _round1(_num(raw, "agi_gain")),
|
||||
"base_int": int(base_int) if base_int == int(base_int) else base_int,
|
||||
"int_gain": _round1(_num(raw, "int_gain")),
|
||||
"health": int(round(_num(raw, "base_health", 120) + base_str * HP_PER_STR)),
|
||||
"mana": int(round(_num(raw, "base_mana", 75) + base_int * MANA_PER_INT)),
|
||||
"health_regen": _round2(
|
||||
_num(raw, "base_health_regen") + base_str * HP_REGEN_PER_STR
|
||||
),
|
||||
"mana_regen": _round2(
|
||||
_num(raw, "base_mana_regen") + base_int * MANA_REGEN_PER_INT
|
||||
),
|
||||
"armor": _round2(_num(raw, "base_armor") + base_agi * ARMOR_PER_AGI),
|
||||
"damage_min": int(round(atk_min + dmg_bonus)),
|
||||
"damage_max": int(round(atk_max + dmg_bonus)),
|
||||
"move_speed": int(_num(raw, "move_speed")),
|
||||
"attack_range": int(_num(raw, "attack_range")),
|
||||
# OpenDota attack_rate == in-game BAT (base attack time).
|
||||
"attack_rate": _round1(_num(raw, "attack_rate")),
|
||||
"projectile_speed": int(_num(raw, "projectile_speed")),
|
||||
"magic_resist": int(_num(raw, "base_mr", 25)),
|
||||
"vision_day": int(_num(raw, "day_vision", 1800)),
|
||||
"vision_night": int(_num(raw, "night_vision", 800)),
|
||||
}
|
||||
# turn_rate is null in OpenDota for heroes using the game default — omit
|
||||
# rather than inventing 0.6 so the UI only shows an explicit value.
|
||||
turn = raw.get("turn_rate")
|
||||
if turn is not None:
|
||||
try:
|
||||
result["turn_rate"] = _round1(float(turn))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
def fetch_opendota_by_id() -> dict[int, dict]:
|
||||
"""Map hero id -> OpenDota row (roles + base stats)."""
|
||||
data = http_json(ROLES_URL, timeout=30)
|
||||
out: dict[int, dict] = {}
|
||||
for raw in data.values():
|
||||
hid = raw.get("id")
|
||||
if hid is None:
|
||||
continue
|
||||
out[int(hid)] = raw
|
||||
return out
|
||||
|
||||
|
||||
def fetch_roles_by_id() -> dict[int, list[str]]:
|
||||
"""Map hero id -> OpenDota role tags (Carry, Nuker, Initiator, ...)."""
|
||||
return {
|
||||
hid: list(raw.get("roles") or [])
|
||||
for hid, raw in fetch_opendota_by_id().items()
|
||||
}
|
||||
|
||||
|
||||
def load_existing_str_lists(field: str) -> dict[str, list[str]]:
|
||||
"""key -> curated string list for `aliases` or `abbr` (empty if file missing)."""
|
||||
if not HEROES_JSON.is_file():
|
||||
return {}
|
||||
try:
|
||||
rows = json.loads(HEROES_JSON.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
out: dict[str, list[str]] = {}
|
||||
for row in rows:
|
||||
key = row.get("key")
|
||||
if not key:
|
||||
continue
|
||||
vals = row.get(field)
|
||||
if isinstance(vals, list):
|
||||
cleaned = [a.strip().lower() if field == "abbr" else a.strip()
|
||||
for a in vals if isinstance(a, str) and a.strip()]
|
||||
out[str(key)] = cleaned
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -45,25 +156,38 @@ def main() -> None:
|
||||
TEMPLATES_CDN.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print("fetching hero list from the Dota 2 data feed...")
|
||||
heroes = fetch_heroes()
|
||||
heroes = fetch_hero_list()
|
||||
print("fetching roles + base stats from dotaconstants...")
|
||||
odota_by_id = fetch_opendota_by_id()
|
||||
aliases_by_key = load_existing_str_lists("aliases")
|
||||
abbr_by_key = load_existing_str_lists("abbr")
|
||||
|
||||
table = []
|
||||
ok, fail = 0, 0
|
||||
for h in heroes:
|
||||
key = h["name"].removeprefix("npc_dota_hero_")
|
||||
table.append({
|
||||
odota = odota_by_id.get(int(h["id"])) or {}
|
||||
roles = list(odota.get("roles") or [])
|
||||
row = {
|
||||
"id": h["id"],
|
||||
"key": key,
|
||||
"name": h["name_english_loc"],
|
||||
"attr": ATTRS.get(h["primary_attr"], "all"),
|
||||
"name_loc": h["name_loc"],
|
||||
})
|
||||
"roles": roles,
|
||||
"tags": tags_for_hero(key, roles),
|
||||
"aliases": aliases_by_key.get(key, []),
|
||||
"abbr": abbr_by_key.get(key, []),
|
||||
}
|
||||
if odota:
|
||||
row.update(stats_from_opendota(odota))
|
||||
table.append(row)
|
||||
out = TEMPLATES_CDN / f"{key}.png"
|
||||
if out.exists():
|
||||
ok += 1
|
||||
continue
|
||||
try:
|
||||
raw = requests.get(IMG_URL.format(key=key), timeout=30).content
|
||||
raw = http_bytes(IMG_URL.format(key=key), timeout=30)
|
||||
img = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
raise ValueError("decode failed")
|
||||
@@ -75,9 +199,11 @@ def main() -> None:
|
||||
print(f" FAILED {key}: {e}")
|
||||
fail += 1
|
||||
|
||||
with open(ROOT / "heroes.json", "w", encoding="utf-8") as f:
|
||||
json.dump(sorted(table, key=lambda t: t["id"]), f, ensure_ascii=False, indent=1)
|
||||
print(f"done: {ok} templates, {fail} failures, {len(table)} heroes in heroes.json")
|
||||
HEROES_JSON.write_text(
|
||||
json.dumps(sorted(table, key=lambda t: t["id"]), ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"done: {ok} templates, {fail} failures, {len(table)} heroes in {HEROES_JSON}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user