"""Download all hero portraits from Steam CDN as fallback templates. Usage: python fetch_cdn_templates.py Writes: 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 from pathlib import Path import cv2 import numpy as np 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 # 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 # the source width so they hold whatever size the CDN serves. CROP_X0, CROP_X1 = 38 / 256, (38 + 182) / 256 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: cfg = load_config() size = cfg["canonical_size"] TEMPLATES_CDN.mkdir(parents=True, exist_ok=True) print("fetching hero list from the Dota 2 data feed...") 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_") 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 = 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") iw = img.shape[1] window = img[:, int(iw * CROP_X0) : int(iw * CROP_X1)] cv2.imwrite(str(out), cv2.resize(window, (size, size), interpolation=cv2.INTER_AREA)) ok += 1 except Exception as e: # noqa: BLE001 print(f" FAILED {key}: {e}") fail += 1 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__": main()