"""Build hero → feared items map from items_meta + hero_abilities. Usage: python item_fears.py python item_fears.py --top 8 """ from __future__ import annotations import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import argparse import json from datetime import datetime, timezone from shared.grid import hero_table from shared.hero_tags import ILLUSION_KEYS from shared.http_utils import http_json from shared.paths import DATA ITEMS_META = DATA / "items_meta.json" HERO_ABILITIES = DATA / "hero_abilities.json" HERO_FEAR_OVERRIDES = DATA / "hero_fear_overrides.json" ITEM_COUNTER_STATS = DATA / "item_counter_stats.json" OUT = DATA / "hero_item_fears.json" ABILITIES_URL = ( "https://raw.githubusercontent.com/odota/dotaconstants/master/build/abilities.json" ) # Prefer iconic counter items when scores tie ITEM_PRIORITY = { "lotus_orb": 12, "diffusal_blade": 10, "disperser": 9, "nullifier": 9, "silver_edge": 8, "monkey_king_bar": 8, "bfury": 8, "abyssal_blade": 8, "mjollnir": 7, "maelstrom": 6, "radiance": 7, "gungir": 6, "spirit_vessel": 7, "black_king_bar": 5, "sphere": 5, "butterfly": 6, "ethereal_blade": 6, "ghost": 4, "gem": 4, "dust": 3, "orchid": 5, "bloodthorn": 6, "sheepstick": 6, } def load_json(path: Path) -> dict: return json.loads(path.read_text(encoding="utf-8")) def is_unit_target_enemy(od: dict) -> bool: beh = od.get("behavior") if isinstance(beh, str): unit = "Unit Target" in beh elif isinstance(beh, list): unit = any("Unit Target" in str(x) for x in beh) else: return False if not unit: return False team = od.get("target_team") if isinstance(team, list): team_s = " ".join(str(x) for x in team) else: team_s = str(team or "") return "Enemy" in team_s or team_s == "" def load_odota_unit_target_keys() -> set[str]: """Ability keys that are enemy unit-target (Lotus / Linken relevant).""" try: raw = http_json(ABILITIES_URL, timeout=90) except Exception as e: # noqa: BLE001 print(f"warn: could not load OpenDota abilities.json ({e})", flush=True) return set() if not isinstance(raw, dict): return set() out: set[str] = set() for key, row in raw.items(): if not isinstance(row, dict) or str(key).startswith("special_bonus"): continue if is_unit_target_enemy(row): out.add(str(key)) return out def index_items_by_key(items: dict) -> dict[str, dict]: out: dict[str, dict] = {} for row in items.values(): if not isinstance(row, dict) or not row.get("key"): continue key = row["key"] out[key] = { "key": key, "name_loc": row.get("name_loc") or row.get("dname") or key, "tags": [t for t in (row.get("tags") or []) if isinstance(t, str)], "cost": int(row.get("cost") or 0), } return out def enrich_override_items(items_by_key: dict[str, dict], overrides: dict[str, dict]) -> None: """Ensure override-only item keys have name_loc (from OpenDota items.json).""" needed = set() for ov in overrides.values(): for row in ov.get("add") or []: if isinstance(row, dict) and row.get("item"): needed.add(str(row["item"])) missing = [k for k in needed if k not in items_by_key] if not missing: return try: raw = http_json( "https://raw.githubusercontent.com/odota/dotaconstants/master/build/items.json", timeout=90, ) except Exception as e: # noqa: BLE001 print(f"warn: could not enrich override items ({e})", flush=True) return if not isinstance(raw, dict): return for key in missing: row = raw.get(key) if not isinstance(row, dict): items_by_key[key] = { "key": key, "name_loc": key, "tags": [], "cost": 0, } continue items_by_key[key] = { "key": key, "name_loc": str(row.get("dname") or key), "tags": [], "cost": int(row.get("cost") or 0), } def index_items_by_tag(items_by_key: dict[str, dict]) -> dict[str, list[dict]]: by_tag: dict[str, list[dict]] = {} for entry in items_by_key.values(): for t in entry["tags"]: by_tag.setdefault(t, []).append(entry) return by_tag def hero_summary( hero_key: str, abilities_db: dict, hero_tags: list[str], unit_target_keys: set[str], ) -> dict: cell = (abilities_db.get("by_hero") or {}).get(hero_key) or {} summary = dict(cell.get("summary") or {}) if hero_key in ILLUSION_KEYS or "幻象" in (hero_tags or []): summary["has_illusion"] = True # Unit-target enemy skills → Lotus / Linken has_unit = False for ab in cell.get("abilities") or []: if not isinstance(ab, dict): continue key = ab.get("key") or "" if ab.get("is_innate"): continue if key in unit_target_keys: has_unit = True break summary["has_unit_target"] = has_unit return summary def needed_tags(summary: dict) -> list[tuple[str, str]]: """Return (item_tag, reason) pairs this hero fears.""" out: list[tuple[str, str]] = [] n_disp = int(summary.get("dispellable_buff_count") or 0) if n_disp > 0: out.append(("basic_dispel", "可驱散技能增益")) out.append(("strong_dispel", "可驱散技能增益")) if summary.get("has_strong_only_buff"): out.append(("strong_dispel", "需强驱散才能驱散的增益")) # Few shop items apply strong dispel offensively; still surface Linken/BKB. out.append(("spell_block", "法术格挡关键技能")) out.append(("magic_immune", "魔免削弱技能")) if summary.get("has_illusion"): out.append(("illusion_clear", "克制幻象")) if summary.get("has_evasion"): out.append(("true_strike", "无视闪避")) if summary.get("has_invis"): out.append(("invis_detect", "显影")) out.append(("invis_break", "破隐")) if summary.get("has_passive_breakable"): out.append(("break", "破坏关键被动")) if summary.get("mana_dependent"): out.append(("mana_burn", "烧蓝克制")) if summary.get("has_unit_target"): out.append(("spell_reflect", "反射点目标技能")) out.append(("spell_block", "格挡点目标技能")) if summary.get("disable_heavy"): out.append(("magic_immune", "魔免抵消控制")) out.append(("spell_block", "法术格挡")) elif summary.get("magic_nuke"): out.append(("magic_immune", "魔免削弱魔法输出")) return out def score_item( item: dict, matched_tags: list[str], reasons: list[str], bonus: int = 0, ) -> tuple[int, dict]: pri = ITEM_PRIORITY.get(item["key"], 0) score = len(matched_tags) * 10 + pri + bonus if item["cost"] >= 2000: score += 2 return score, { "item": item["key"], "name_loc": item["name_loc"], "tags": matched_tags, "reason": ";".join(dict.fromkeys(reasons)), "_score": score, } def load_hero_overrides() -> dict[str, dict]: if not HERO_FEAR_OVERRIDES.is_file(): return {} try: raw = load_json(HERO_FEAR_OVERRIDES) except (OSError, json.JSONDecodeError): return {} heroes = raw.get("heroes") or {} return {str(k): v for k, v in heroes.items() if isinstance(v, dict)} def load_counter_stats() -> tuple[dict, dict[str, dict[str, dict]]]: """Load optional adjusted OpenDota evidence indexed by hero and item.""" if not ITEM_COUNTER_STATS.is_file(): return {}, {} try: raw = load_json(ITEM_COUNTER_STATS) except (OSError, json.JSONDecodeError): return {}, {} indexed: dict[str, dict[str, dict]] = {} for hero_key, rows in (raw.get("by_hero") or {}).items(): if not isinstance(rows, list): continue indexed[str(hero_key)] = { str(row["item"]): row for row in rows if isinstance(row, dict) and row.get("item") } return dict(raw.get("meta") or {}), indexed def evidence_adjustment(evidence: dict | None) -> int: """Return a deliberately small corroboration bonus/penalty.""" if not evidence: return 0 games = int(evidence.get("games") or 0) if games < 100: return 0 buy_lift_pp = float(evidence.get("buy_lift") or 0) * 100 win_delta_pp = float(evidence.get("win_delta") or 0) * 100 # Mixed signs are inconclusive. Requiring agreement avoids promoting generic # expensive winner items based on conditional win rate alone. if buy_lift_pp * win_delta_pp <= 0: return 0 raw = 0.6 * buy_lift_pp + 0.4 * win_delta_pp reliability = min(1.0, games / 500) return round(max(-6.0, min(6.0, raw)) * reliability) def fears_for_hero( hero_key: str, summary: dict, by_tag: dict[str, list[dict]], items_by_key: dict[str, dict], overrides: dict[str, dict], counter_stats: dict[str, dict[str, dict]], top_n: int, ) -> list[dict]: need = needed_tags(summary) acc: dict[str, dict] = {} for tag, reason in need: for item in by_tag.get(tag) or []: key = item["key"] if key not in acc: acc[key] = {"item": item, "matched": [], "reasons": [], "bonus": 0} if tag not in acc[key]["matched"]: acc[key]["matched"].append(tag) if reason not in acc[key]["reasons"]: acc[key]["reasons"].append(reason) ov = overrides.get(hero_key) or {} for row in ov.get("add") or []: if not isinstance(row, dict): continue key = row.get("item") if not key or key not in items_by_key: # Allow overrides for items not tagged (butterfly, ghost, …) if not key: continue meta = items_by_key.get(key) if meta is None: # Synthesize from key alone if missing from finished catalog meta = { "key": key, "name_loc": key, "tags": list(row.get("tags") or []), "cost": 0, } items_by_key[key] = meta item = items_by_key[key] if key not in acc: acc[key] = {"item": item, "matched": [], "reasons": [], "bonus": 20} else: acc[key]["bonus"] = max(int(acc[key].get("bonus") or 0), 20) for t in row.get("tags") or []: if isinstance(t, str) and t not in acc[key]["matched"]: acc[key]["matched"].append(t) reason = (row.get("reason") or "").strip() if reason and reason not in acc[key]["reasons"]: acc[key]["reasons"].insert(0, reason) # Refresh name_loc if we only had key if item.get("name_loc") == key and row.get("name_loc"): item["name_loc"] = row["name_loc"] for key in ov.get("remove") or []: if isinstance(key, str): acc.pop(key, None) ranked = [] for row in acc.values(): evidence = (counter_stats.get(hero_key) or {}).get(row["item"]["key"]) sc, entry = score_item( row["item"], row["matched"], row["reasons"], bonus=int(row.get("bonus") or 0) + evidence_adjustment(evidence), ) if evidence: entry["stats"] = { "games": int(evidence.get("games") or 0), "purchase_rate": float(evidence.get("buy_rate") or 0), "win_rate": float(evidence.get("win_rate") or 0), "purchase_lift": float(evidence.get("buy_lift") or 0), "win_delta": float(evidence.get("win_delta") or 0), } ranked.append((sc, entry)) ranked.sort(key=lambda t: (-t[0], t[1]["item"])) out = [] for _, entry in ranked[:top_n]: entry.pop("_score", None) out.append(entry) return out def main() -> None: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--top", type=int, default=8) ap.add_argument("--out", type=Path, default=OUT) args = ap.parse_args() if not ITEMS_META.is_file(): raise SystemExit(f"missing {ITEMS_META}; run: python fetch_items_meta.py") if not HERO_ABILITIES.is_file(): raise SystemExit(f"missing {HERO_ABILITIES}; run: python fetch_hero_abilities.py") items_meta = load_json(ITEMS_META) abilities_db = load_json(HERO_ABILITIES) counter_meta, counter_stats = load_counter_stats() items = items_meta.get("items") or {} items_by_key = index_items_by_key(items) overrides = load_hero_overrides() enrich_override_items(items_by_key, overrides) by_tag = index_items_by_tag(items_by_key) print("loading OpenDota abilities for unit-target detection...", flush=True) unit_target_keys = load_odota_unit_target_keys() print(f" {len(unit_target_keys)} unit-target enemy abilities", flush=True) heroes = hero_table() by_hero: dict[str, list] = {} for h in heroes: key = h["key"] tags = list(h.get("tags") or []) summary = hero_summary(key, abilities_db, tags, unit_target_keys) by_hero[key] = fears_for_hero( key, summary, by_tag, items_by_key, overrides, counter_stats, args.top ) items_out = { k: { "key": v["key"], "name_loc": v["name_loc"], "tags": v["tags"], } for k, v in items_by_key.items() } payload = { "meta": { "source": "rules+valve+opendota", "attribution": "derived from items_meta.json + hero_abilities.json", "fetched_at": datetime.now(timezone.utc).isoformat(), "top_n": args.top, "heroes": len(by_hero), "overrides": str(HERO_FEAR_OVERRIDES.relative_to(DATA.parent)).replace("\\", "/"), "counter_stats": ( { "path": str(ITEM_COUNTER_STATS.relative_to(DATA.parent)).replace("\\", "/"), "source": counter_meta.get("source"), "fetched_at": counter_meta.get("fetched_at"), "method": counter_meta.get("method"), "caveat": counter_meta.get("caveat"), } if counter_meta else None ), }, "by_hero": by_hero, "items": items_out, } args.out.parent.mkdir(parents=True, exist_ok=True) args.out.write_text( json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) nonempty = sum(1 for v in by_hero.values() if v) jugg = [x["item"] for x in by_hero.get("juggernaut") or []] print(f"done: {nonempty}/{len(by_hero)} heroes have feared items -> {args.out}", flush=True) print(f" juggernaut: {jugg}", flush=True) if __name__ == "__main__": main()