Files
climperor/shared/relations.py
T
vosonandCursor 9c5aa5b610 Reorganize repository into pc web shared monorepo
Separate the local recognition, web publishing, and shared data paths while preserving direct script execution and existing site content.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 14:29:08 +08:00

223 lines
7.4 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 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