299 lines
8.1 KiB
Python
299 lines
8.1 KiB
Python
"""Draft item suggestions: hero core builds + qualitative answers vs enemies.
|
|
|
|
Core items come from web/data/hero_items.json (relative popularity).
|
|
Answer items are rule-mapped from enemy tags / push-global archetypes.
|
|
Does not read hero_item_fears stats or STRATZ.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
import json
|
|
from functools import lru_cache
|
|
|
|
from draft_archetypes import detect_archetypes, tag_profile
|
|
from shared.grid import hero_table
|
|
from shared.hero_tags import tags_for_hero
|
|
from shared.paths import DATA, ROOT
|
|
|
|
DEFAULT_HERO_ITEMS = DATA / "hero_items.json"
|
|
DEFAULT_ITEMS_META = DATA / "items_meta.json"
|
|
|
|
# Heroes that commonly pick from fog / invis — qualitative only.
|
|
INVIS_HEROES = frozenset({
|
|
"riki",
|
|
"bounty_hunter",
|
|
"clinkz",
|
|
"weaver",
|
|
"nyx_assassin",
|
|
"templar_assassin",
|
|
"mirana",
|
|
"treant",
|
|
"windrunner",
|
|
"slark",
|
|
"invoker",
|
|
"sand_king",
|
|
})
|
|
|
|
# Soft heal / sustain cores — vessel answers.
|
|
HEAL_HEROES = frozenset({
|
|
"omniknight",
|
|
"winter_wyvern",
|
|
"bane",
|
|
"undying",
|
|
"abaddon",
|
|
"oracle",
|
|
"chen",
|
|
"io",
|
|
"wisp",
|
|
"dazzle",
|
|
"warlock",
|
|
})
|
|
|
|
# Fallback Chinese names when catalogs miss an entry.
|
|
_FALLBACK_NAMES = {
|
|
"black_king_bar": "黑皇杖",
|
|
"bfury": "狂战斧",
|
|
"disperser": "散魂剑",
|
|
"gem": "真视宝石",
|
|
"dust": "显影之尘",
|
|
"pipe": "洞察烟斗",
|
|
"eternal_shroud": "永世护盾",
|
|
"travel_boots": "远行鞋",
|
|
"crimson_guard": "赤红甲",
|
|
"spirit_vessel": "魂之灵瓮",
|
|
}
|
|
|
|
|
|
def _resolve_path(path: str | Path | None, default: Path) -> Path:
|
|
if not path:
|
|
return default
|
|
p = Path(path)
|
|
if not p.is_absolute():
|
|
p = ROOT / p
|
|
return p
|
|
|
|
|
|
@lru_cache(maxsize=4)
|
|
def _load_hero_items(path_str: str) -> dict:
|
|
path = Path(path_str)
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return {}
|
|
|
|
|
|
@lru_cache(maxsize=4)
|
|
def _load_items_meta(path_str: str) -> dict[str, str]:
|
|
path = Path(path_str)
|
|
if not path.exists():
|
|
return {}
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return {}
|
|
items = data.get("items") if isinstance(data, dict) else None
|
|
if not isinstance(items, dict):
|
|
return {}
|
|
out: dict[str, str] = {}
|
|
for row in items.values():
|
|
if not isinstance(row, dict):
|
|
continue
|
|
key = row.get("key")
|
|
if not key:
|
|
continue
|
|
name = row.get("name_loc") or row.get("dname") or key
|
|
out[str(key)] = str(name)
|
|
return out
|
|
|
|
|
|
def _tags_by_key() -> dict[str, list[str]]:
|
|
return {
|
|
h["key"]: list(h.get("tags") or []) or tags_for_hero(h["key"], h.get("roles"))
|
|
for h in hero_table()
|
|
}
|
|
|
|
|
|
def _name_for(
|
|
key: str,
|
|
*,
|
|
catalog: dict[str, dict],
|
|
meta_names: dict[str, str],
|
|
) -> str:
|
|
row = catalog.get(key) or {}
|
|
return (
|
|
str(row.get("name_loc") or "")
|
|
or meta_names.get(key)
|
|
or _FALLBACK_NAMES.get(key)
|
|
or key
|
|
)
|
|
|
|
|
|
def _catalog_from_hero_items(data: dict) -> dict[str, dict]:
|
|
"""id-str -> row and also key -> row for lookups."""
|
|
items = data.get("items") or {}
|
|
by_key: dict[str, dict] = {}
|
|
for row in items.values():
|
|
if isinstance(row, dict) and row.get("key"):
|
|
by_key[str(row["key"])] = row
|
|
return by_key
|
|
|
|
|
|
def core_items_for_hero(
|
|
self_hero: str,
|
|
data: dict,
|
|
*,
|
|
core_n: int = 3,
|
|
catalog: dict[str, dict] | None = None,
|
|
meta_names: dict[str, str] | None = None,
|
|
) -> list[dict]:
|
|
"""Top relative-popularity finished items for one hero."""
|
|
if not self_hero or core_n <= 0:
|
|
return []
|
|
by_hero = data.get("by_hero") or {}
|
|
rows = list(by_hero.get(self_hero) or [])
|
|
items_by_id = data.get("items") or {}
|
|
catalog = catalog if catalog is not None else _catalog_from_hero_items(data)
|
|
meta_names = meta_names or {}
|
|
out: list[dict] = []
|
|
seen: set[str] = set()
|
|
for row in rows:
|
|
if len(out) >= core_n:
|
|
break
|
|
iid = str(row.get("id") if isinstance(row, dict) else row)
|
|
meta = items_by_id.get(iid) or items_by_id.get(int(iid) if iid.isdigit() else iid) or {}
|
|
key = str(meta.get("key") or "")
|
|
if not key or key in seen:
|
|
continue
|
|
seen.add(key)
|
|
out.append({
|
|
"key": key,
|
|
"name_loc": _name_for(key, catalog=catalog, meta_names=meta_names),
|
|
"kind": "core",
|
|
"reason": "常用",
|
|
})
|
|
return out
|
|
|
|
|
|
def answer_items_for_enemies(
|
|
enemies: list[str],
|
|
*,
|
|
answer_n: int = 3,
|
|
catalog: dict[str, dict] | None = None,
|
|
meta_names: dict[str, str] | None = None,
|
|
tags_by_key: dict[str, list[str]] | None = None,
|
|
) -> list[dict]:
|
|
"""Qualitative counter items from enemy tags / archetypes."""
|
|
if answer_n <= 0 or not enemies:
|
|
return []
|
|
tags_by_key = tags_by_key or _tags_by_key()
|
|
catalog = catalog or {}
|
|
meta_names = meta_names or {}
|
|
profile = tag_profile(enemies, tags_by_key)
|
|
arches = detect_archetypes(enemies, tags_by_key)
|
|
|
|
candidates: list[tuple[str, str]] = []
|
|
|
|
control_n = int(profile.get("控制") or 0) + int(profile.get("先手") or 0)
|
|
if control_n >= 2:
|
|
candidates.append(("black_king_bar", "克控制"))
|
|
|
|
if int(profile.get("幻象") or 0) >= 1 or any(
|
|
"幻象" in (tags_by_key.get(e) or []) for e in enemies
|
|
):
|
|
candidates.append(("bfury", "清幻象"))
|
|
candidates.append(("disperser", "打幻象"))
|
|
|
|
if any(e in INVIS_HEROES for e in enemies):
|
|
candidates.append(("gem", "克隐身"))
|
|
candidates.append(("dust", "显影"))
|
|
|
|
if int(profile.get("爆发") or 0) >= 2:
|
|
candidates.append(("pipe", "克魔法"))
|
|
candidates.append(("eternal_shroud", "魔抗"))
|
|
|
|
if any(e in HEAL_HEROES for e in enemies):
|
|
candidates.append(("spirit_vessel", "克回复"))
|
|
|
|
if "push" in arches:
|
|
candidates.append(("crimson_guard", "抗推进"))
|
|
candidates.append(("travel_boots", "对推进"))
|
|
if "global" in arches:
|
|
candidates.append(("travel_boots", "对全球流"))
|
|
|
|
out: list[dict] = []
|
|
seen: set[str] = set()
|
|
for key, reason in candidates:
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
out.append({
|
|
"key": key,
|
|
"name_loc": _name_for(key, catalog=catalog, meta_names=meta_names),
|
|
"kind": "answer",
|
|
"reason": reason,
|
|
})
|
|
if len(out) >= answer_n:
|
|
break
|
|
return out
|
|
|
|
|
|
def suggest_items(
|
|
self_hero: str | None,
|
|
enemies: list[str] | None,
|
|
*,
|
|
core_n: int = 3,
|
|
answer_n: int = 3,
|
|
max_total: int = 6,
|
|
hero_items_path: str | Path | None = None,
|
|
items_meta_path: str | Path | None = None,
|
|
) -> list[dict]:
|
|
"""Merge answer items then core items; dedupe by key; soft-fail to []."""
|
|
if not self_hero or max_total <= 0:
|
|
return []
|
|
hi_path = _resolve_path(hero_items_path, DEFAULT_HERO_ITEMS)
|
|
meta_path = _resolve_path(items_meta_path, DEFAULT_ITEMS_META)
|
|
data = _load_hero_items(str(hi_path))
|
|
if not data:
|
|
return []
|
|
catalog = _catalog_from_hero_items(data)
|
|
meta_names = _load_items_meta(str(meta_path))
|
|
answers = answer_items_for_enemies(
|
|
list(enemies or []),
|
|
answer_n=answer_n,
|
|
catalog=catalog,
|
|
meta_names=meta_names,
|
|
)
|
|
cores = core_items_for_hero(
|
|
self_hero,
|
|
data,
|
|
core_n=core_n,
|
|
catalog=catalog,
|
|
meta_names=meta_names,
|
|
)
|
|
out: list[dict] = []
|
|
seen: set[str] = set()
|
|
for row in answers + cores:
|
|
key = row.get("key")
|
|
if not key or key in seen:
|
|
continue
|
|
seen.add(key)
|
|
out.append(row)
|
|
if len(out) >= max_total:
|
|
break
|
|
return out
|
|
|
|
|
|
__all__ = [
|
|
"DEFAULT_HERO_ITEMS",
|
|
"answer_items_for_enemies",
|
|
"core_items_for_hero",
|
|
"suggest_items",
|
|
]
|