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:
voson
2026-07-29 14:29:08 +08:00
co-authored by Cursor
parent 96a9312194
commit 9c5aa5b610
280 changed files with 1451 additions and 450 deletions
+222
View File
@@ -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