Files
climperor/relations.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

219 lines
7.3 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.
"""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 json
from pathlib import Path
from common import ROOT
from grid import hero_table
DEFAULT_RELATIONS = ROOT / "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