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>
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user