Files
climperor/item_fears.py
T
voson a91789b72f 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)
2026-07-27 11:56:51 +08:00

390 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()