- 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)
390 lines
12 KiB
Python
390 lines
12 KiB
Python
"""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 argparse
|
||
import json
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
from common import DATA
|
||
from grid import hero_table
|
||
from hero_tags import ILLUSION_KEYS
|
||
from http_utils import http_json
|
||
|
||
ITEMS_META = DATA / "items_meta.json"
|
||
HERO_ABILITIES = DATA / "hero_abilities.json"
|
||
HERO_FEAR_OVERRIDES = DATA / "hero_fear_overrides.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 fears_for_hero(
|
||
hero_key: str,
|
||
summary: dict,
|
||
by_tag: dict[str, list[dict]],
|
||
items_by_key: dict[str, dict],
|
||
overrides: 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():
|
||
sc, entry = score_item(
|
||
row["item"],
|
||
row["matched"],
|
||
row["reasons"],
|
||
bonus=int(row.get("bonus") 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)
|
||
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, 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("\\", "/"),
|
||
},
|
||
"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()
|