"""Qualitative hero relations: counters / countered-by / synergies. Edges are stable draft knowledge (not patch winrates): counters: a counters b (a 克 b) synergies: unordered partner pairs Source of truth: data/relations.json (seeded from spreadsheet / edited in UI). """ from __future__ import annotations import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import json from shared.grid import hero_table from shared.paths import ROOT, SHARED_DATA DEFAULT_RELATIONS = SHARED_DATA / "relations.json" # Spreadsheet / slang nicknames not covered by data/heroes.json aliases. EXTRA_NAMES: dict[str, str] = { "pa": "phantom_assassin", "ta": "templar_assassin", "nec": "necrolyte", "tb": "terrorblade", "vs": "vengefulspirit", "sf": "nevermore", "od": "obsidian_destroyer", "mk": "monkey_king", "bh": "bounty_hunter", "es": "earthshaker", "ck": "chaos_knight", "am": "antimage", "dp": "death_prophet", "刚被": "bristleback", "钢背": "bristleback", "打屁股": "abyssal_underlord", "大屁股": "abyssal_underlord", "抄袭": "rubick", "鸟人": "skywrath_mage", "破晓星辰": "dawnbreaker", "破晓辰星": "dawnbreaker", "小牛": "centaur", "人马": "centaur", "骷髅": "skeleton_king", "骷髅王": "skeleton_king", "哈斯卡": "huskar", "神灵": "huskar", "圣堂": "templar_assassin", "圣堂/ta": "templar_assassin", "大圣": "monkey_king", "猴哥": "monkey_king", "猴子": "phantom_lancer", # spreadsheet uses 猴子 for PL; MK is 大圣 "黑贤": "dark_seer", "黑暗贤者": "dark_seer", "兽": "primal_beast", "一霸": "primal_beast", "奶绿": "treant", "马尔斯": "mars", "玛尔斯": "mars", "马西": "marci", "玛西": "marci", "小骷髅": "clinkz", "骨弓": "clinkz", } def _norm(s: str) -> str: return "".join(str(s).strip().lower().split()) def build_name_index(table: list[dict] | None = None) -> dict[str, str]: """Map normalized Chinese/English/alias → hero key. Later entries do not win over EXTRA.""" table = table if table is not None else hero_table() idx: dict[str, str] = {} for h in table: key = h["key"] for raw in ( key, h.get("name") or "", h.get("name_loc") or "", *(h.get("aliases") or []), *(h.get("abbr") or []), ): n = _norm(raw) if n and n not in idx: idx[n] = key for name, key in EXTRA_NAMES.items(): idx[_norm(name)] = key return idx def resolve_name(raw: str, index: dict[str, str] | None = None) -> str | None: if raw is None: return None text = str(raw).strip() if not text: return None idx = index or build_name_index() return idx.get(_norm(text)) def split_names(cell: str | None) -> list[str]: if cell is None: return [] text = str(cell).strip() if not text or text.lower() in ("none", "null"): return [] # separators: Chinese/ASCII comma,顿号, slash, whitespace for sep in ("、", ",", ",", "/", "|", ";", ";"): text = text.replace(sep, "|") parts = [] for p in text.split("|"): p = p.strip() if p: parts.append(p) return parts def empty_relations() -> dict: return {"version": 1, "counters": [], "synergies": [], "meta": {}} def load_relations(path: str | Path | None = None) -> dict: p = Path(path) if path else DEFAULT_RELATIONS if not p.is_absolute(): p = ROOT / p if not p.is_file(): return empty_relations() try: data = json.loads(p.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return empty_relations() if not isinstance(data, dict): return empty_relations() counters = data.get("counters") if isinstance(data.get("counters"), list) else [] synergies = data.get("synergies") if isinstance(data.get("synergies"), list) else [] return { "version": int(data.get("version") or 1), "counters": [c for c in counters if isinstance(c, dict) and c.get("a") and c.get("b")], "synergies": [c for c in synergies if isinstance(c, dict) and c.get("a") and c.get("b")], "meta": data.get("meta") if isinstance(data.get("meta"), dict) else {}, } def save_relations(data: dict, path: str | Path | None = None) -> Path: p = Path(path) if path else DEFAULT_RELATIONS if not p.is_absolute(): p = ROOT / p p.parent.mkdir(parents=True, exist_ok=True) counters = [] seen_c: set[tuple[str, str]] = set() for c in data.get("counters") or []: a, b = c.get("a"), c.get("b") if not a or not b or a == b: continue key = (a, b) if key in seen_c: continue seen_c.add(key) counters.append({"a": a, "b": b, "reason": (c.get("reason") or "").strip()}) synergies = [] seen_s: set[tuple[str, str]] = set() for c in data.get("synergies") or []: a, b = c.get("a"), c.get("b") if not a or not b or a == b: continue x, y = sorted((a, b)) if (x, y) in seen_s: continue seen_s.add((x, y)) synergies.append({"a": x, "b": y, "reason": (c.get("reason") or "").strip()}) out = { "version": 1, "meta": data.get("meta") if isinstance(data.get("meta"), dict) else {}, "counters": sorted(counters, key=lambda r: (r["a"], r["b"])), "synergies": sorted(synergies, key=lambda r: (r["a"], r["b"])), } p.write_text(json.dumps(out, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") return p def indexes(data: dict | None = None) -> tuple[dict[str, list[dict]], dict[str, list[dict]], dict[str, list[dict]]]: """Return (counters_of, countered_by, synergies_of) keyed by hero key.""" rel = data if data is not None else load_relations() counters_of: dict[str, list[dict]] = {} countered_by: dict[str, list[dict]] = {} synergies_of: dict[str, list[dict]] = {} for c in rel.get("counters") or []: a, b, reason = c["a"], c["b"], c.get("reason") or "" counters_of.setdefault(a, []).append({"key": b, "reason": reason}) countered_by.setdefault(b, []).append({"key": a, "reason": reason}) for c in rel.get("synergies") or []: a, b, reason = c["a"], c["b"], c.get("reason") or "" synergies_of.setdefault(a, []).append({"key": b, "reason": reason}) synergies_of.setdefault(b, []).append({"key": a, "reason": reason}) return counters_of, countered_by, synergies_of def set_counter(data: dict, a: str, b: str, reason: str = "", *, enabled: bool = True) -> dict: """Add/update or remove directed counter a→b.""" counters = [c for c in (data.get("counters") or []) if not (c.get("a") == a and c.get("b") == b)] if enabled: counters.append({"a": a, "b": b, "reason": reason}) data["counters"] = counters return data def set_synergy(data: dict, a: str, b: str, reason: str = "", *, enabled: bool = True) -> dict: x, y = sorted((a, b)) synergies = [ c for c in (data.get("synergies") or []) if tuple(sorted((c.get("a"), c.get("b")))) != (x, y) ] if enabled: synergies.append({"a": x, "b": y, "reason": reason}) data["synergies"] = synergies return data