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
+6
View File
@@ -0,0 +1,6 @@
"""Shared package: hero table, relations, tags, HTTP helpers, path constants.
Used by both subprojects (pc/ and web/). Must never import from pc/ or web/.
Scripts in this package can be run directly: python shared/<script>.py
"""
+378
View File
@@ -0,0 +1,378 @@
"""Audit qualitative relations.json against OpenDota + STRATZ.
Sources:
- data/matchups.json OpenDota vs winrates
- data/stratz_matchups.json STRATZ vs advantage (fetch_stratz.py --mode matchups)
- data/synergies.json STRATZ teammate synergies
Usage:
python audit_relations.py
python audit_relations.py --min-games 80 --out data/relations_audit.json
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.paths import SHARED_DATA
from shared.relations import DEFAULT_RELATIONS, load_relations
MATCHUPS = SHARED_DATA / "matchups.json"
STRATZ_MATCHUPS = SHARED_DATA / "stratz_matchups.json"
SYNERGIES = SHARED_DATA / "synergies.json"
def _load_json_safe(path: Path) -> dict:
"""Read a JSON file, returning {} on missing/corrupt data."""
if not path.is_file():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
def _pair_key(a: str, b: str) -> tuple[str, str]:
return (a, b) if a <= b else (b, a)
def load_id_maps() -> tuple[dict[int, str], dict[str, int], dict[str, str]]:
table = hero_table()
id_to_key = {int(h["id"]): h["key"] for h in table}
key_to_id = {h["key"]: int(h["id"]) for h in table}
names = {h["key"]: h.get("name_loc") or h["key"] for h in table}
return id_to_key, key_to_id, names
def matchup_wr(by_hero: dict, aid: int, bid: int) -> tuple[float | None, int]:
cell = (by_hero.get(str(aid)) or {}).get(str(bid))
if not cell:
return None, 0
games = int(cell.get("games") or 0)
wins = int(cell.get("wins") or 0)
if games <= 0:
return None, 0
return wins / games, games
def build_baseline(by_hero: dict) -> dict[int, float]:
out: dict[int, float] = {}
for hid_s, opps in by_hero.items():
tw = tg = 0
for cell in opps.values():
g = int(cell.get("games") or 0)
w = int(cell.get("wins") or 0)
tw += w
tg += g
if tg > 0:
out[int(hid_s)] = tw / tg
return out
def odota_adv(by_hero: dict, baseline: dict[int, float], aid: int, bid: int) -> tuple[float | None, int]:
wr, games = matchup_wr(by_hero, aid, bid)
if wr is None:
return None, 0
base = baseline.get(aid)
if base is None:
return None, games
return wr - base, games
def stratz_adv(by_hero: dict, aid: int, bid: int) -> tuple[float | None, int]:
cell = (by_hero.get(str(aid)) or {}).get(str(bid))
if not cell:
return None, 0
games = int(cell.get("games") or 0)
if games <= 0:
return None, 0
return float(cell.get("advantage") or 0.0), games
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--min-games", type=int, default=80)
ap.add_argument("--odota-agree", type=float, default=0.015)
ap.add_argument("--odota-disagree", type=float, default=-0.015)
ap.add_argument("--stratz-agree", type=float, default=1.0)
ap.add_argument("--stratz-disagree", type=float, default=-1.0)
ap.add_argument("--syn-agree", type=float, default=1.0)
ap.add_argument("--top", type=int, default=10)
ap.add_argument("--out", type=Path, default=SHARED_DATA / "relations_audit.json")
args = ap.parse_args()
id_to_key, key_to_id, names = load_id_maps()
all_keys = sorted(key_to_id.keys(), key=lambda k: key_to_id[k])
rel = load_relations()
odota = _load_json_safe(MATCHUPS)
stratz = _load_json_safe(STRATZ_MATCHUPS)
syn_file = _load_json_safe(SYNERGIES)
by_o = odota.get("by_hero") or {}
by_s = stratz.get("by_hero") or {}
by_syn = syn_file.get("by_hero") or {}
baseline = build_baseline(by_o)
counters = [(e["a"], e["b"], e.get("reason") or "") for e in rel.get("counters") or []]
syns = [(e["a"], e["b"], e.get("reason") or "") for e in rel.get("synergies") or []]
known_counters = {(a, b) for a, b, _ in counters}
known_syn = {_pair_key(a, b) for a, b, _ in syns}
counter_ok, counter_conflict, counter_weak, counter_no_data = [], [], [], []
for a, b, reason in counters:
aid, bid = key_to_id.get(a), key_to_id.get(b)
if aid is None or bid is None:
counter_no_data.append({"a": a, "b": b, "why": "unknown_hero"})
continue
o_adv, o_games = odota_adv(by_o, baseline, aid, bid)
s_adv, s_games = stratz_adv(by_s, aid, bid)
r_o, r_og = odota_adv(by_o, baseline, bid, aid)
r_s, r_sg = stratz_adv(by_s, bid, aid)
row = {
"a": a,
"a_loc": names.get(a, a),
"b": b,
"b_loc": names.get(b, b),
"reason": reason,
"opendota_adv": None if o_adv is None else round(o_adv, 4),
"opendota_games": o_games,
"stratz_adv": None if s_adv is None else round(s_adv, 3),
"stratz_games": s_games,
"reverse_opendota_adv": None if r_o is None else round(r_o, 4),
"reverse_stratz_adv": None if r_s is None else round(r_s, 3),
}
o_ok = o_adv is not None and o_games >= args.min_games and o_adv >= args.odota_agree
o_bad = o_adv is not None and o_games >= args.min_games and o_adv <= args.odota_disagree
s_ok = s_adv is not None and s_games >= args.min_games and s_adv >= args.stratz_agree
s_bad = s_adv is not None and s_games >= args.min_games and s_adv <= args.stratz_disagree
if o_adv is None and s_adv is None:
counter_no_data.append({**row, "why": "missing_both"})
elif (o_games < args.min_games and (s_games < args.min_games or s_adv is None)):
counter_no_data.append({**row, "why": "low_games"})
elif o_ok or s_ok:
sources = []
if o_ok:
sources.append("opendota")
if s_ok:
sources.append("stratz")
counter_ok.append({**row, "agree_sources": sources})
elif o_bad or s_bad:
tip = "conflict"
if (
(r_o is not None and r_og >= args.min_games and r_o >= args.odota_agree)
or (r_s is not None and r_sg >= args.min_games and r_s >= args.stratz_agree)
):
tip = "maybe_reverse"
counter_conflict.append({**row, "tip": tip})
else:
counter_weak.append(row)
syn_ok, syn_conflict, syn_missing = [], [], []
for a, b, reason in syns:
aid, bid = key_to_id.get(a), key_to_id.get(b)
if aid is None or bid is None:
syn_missing.append({"a": a, "b": b, "why": "unknown_hero"})
continue
cell = (by_syn.get(str(aid)) or {}).get(str(bid)) or (by_syn.get(str(bid)) or {}).get(str(aid))
if not cell:
syn_missing.append({"a": a, "b": b, "a_loc": names.get(a), "b_loc": names.get(b), "why": "no_stratz"})
continue
score = float(cell.get("synergy") or 0)
games = int(cell.get("games") or 0)
row = {
"a": a, "a_loc": names.get(a, a), "b": b, "b_loc": names.get(b, b),
"reason": reason, "stratz_synergy": round(score, 3), "stratz_games": games,
}
if games < args.min_games:
syn_missing.append({**row, "why": "low_games"})
elif score >= args.syn_agree:
syn_ok.append(row)
elif score <= -args.syn_agree:
syn_conflict.append(row)
else:
syn_missing.append({**row, "why": "weak_synergy"})
# Cross-source suggestions: both OpenDota and STRATZ agree A counters B, missing from relations
suggestions: list[dict] = []
for akey in all_keys:
aid = key_to_id[akey]
for bkey in all_keys:
if akey == bkey or (akey, bkey) in known_counters:
continue
bid = key_to_id[bkey]
o_adv, o_games = odota_adv(by_o, baseline, aid, bid)
s_adv, s_games = stratz_adv(by_s, aid, bid)
if (
o_adv is not None and o_games >= args.min_games and o_adv >= 0.03
and s_adv is not None and s_games >= args.min_games and s_adv >= 2.0
):
suggestions.append({
"a": akey, "a_loc": names[akey],
"b": bkey, "b_loc": names[bkey],
"opendota_adv": round(o_adv, 4), "opendota_games": o_games,
"stratz_adv": round(s_adv, 3), "stratz_games": s_games,
})
suggestions.sort(key=lambda r: (-r["stratz_adv"], -r["opendota_adv"]))
suggestions = suggestions[:100]
# Per-hero coverage in qualitative relations
counters_from: dict[str, int] = {k: 0 for k in all_keys}
counters_to: dict[str, int] = {k: 0 for k in all_keys}
syn_count: dict[str, int] = {k: 0 for k in all_keys}
for a, b, _ in counters:
if a in counters_from:
counters_from[a] += 1
if b in counters_to:
counters_to[b] += 1
for a, b, _ in syns:
if a in syn_count:
syn_count[a] += 1
if b in syn_count:
syn_count[b] += 1
missing_cache = {
"opendota_matchups": [
f"{names[k]}({k})" for k in all_keys if str(key_to_id[k]) not in by_o
],
"stratz_matchups": [
f"{names[k]}({k})" for k in all_keys if str(key_to_id[k]) not in by_s
],
"stratz_synergies": [
f"{names[k]}({k})" for k in all_keys if str(key_to_id[k]) not in by_syn
],
}
# Qualitative gaps: no counter-out, no counter-in, no synergy
no_counter_out = [k for k in all_keys if counters_from[k] == 0]
no_counter_in = [k for k in all_keys if counters_to[k] == 0]
no_synergy = [k for k in all_keys if syn_count[k] == 0]
thin = [
k for k in all_keys
if counters_from[k] + counters_to[k] + syn_count[k] <= 1
]
empty = [
k for k in all_keys
if counters_from[k] == 0 and counters_to[k] == 0 and syn_count[k] == 0
]
# Heroes with strong cross-source suggestions but empty/thin qualitative coverage
suggest_by_hero: dict[str, int] = {}
for row in suggestions:
suggest_by_hero[row["a"]] = suggest_by_hero.get(row["a"], 0) + 1
needs_fill = sorted(
[
{
"key": k,
"name_loc": names[k],
"counters_out": counters_from[k],
"counters_in": counters_to[k],
"synergies": syn_count[k],
"cross_source_suggestions": suggest_by_hero.get(k, 0),
}
for k in empty + [x for x in thin if x not in empty]
],
key=lambda r: (r["counters_out"] + r["counters_in"] + r["synergies"], -r["cross_source_suggestions"], r["key"]),
)
report = {
"generated_at": datetime.now(timezone.utc).isoformat(),
"sources": {
"relations": str(DEFAULT_RELATIONS.relative_to(ROOT)).replace("\\", "/"),
"opendota_matchups": {
"path": str(MATCHUPS.relative_to(ROOT)).replace("\\", "/") if MATCHUPS.is_file() else None,
"source": odota.get("source"),
"fetched_at": odota.get("fetched_at"),
"heroes": len(by_o),
},
"stratz_matchups": {
"path": str(STRATZ_MATCHUPS.relative_to(ROOT)).replace("\\", "/") if STRATZ_MATCHUPS.is_file() else None,
"source": stratz.get("source"),
"fetched_at": stratz.get("fetched_at"),
"heroes": len(by_s),
},
"stratz_synergies": {
"path": str(SYNERGIES.relative_to(ROOT)).replace("\\", "/") if SYNERGIES.is_file() else None,
"source": syn_file.get("source"),
"fetched_at": syn_file.get("fetched_at"),
"heroes": len(by_syn),
},
},
"thresholds": {
"min_games": args.min_games,
"odota_agree": args.odota_agree,
"odota_disagree": args.odota_disagree,
"stratz_agree": args.stratz_agree,
"stratz_disagree": args.stratz_disagree,
"syn_agree": args.syn_agree,
},
"summary": {
"relations_counters": len(counters),
"relations_synergies": len(syns),
"counter_agree": len(counter_ok),
"counter_conflict": len(counter_conflict),
"counter_weak": len(counter_weak),
"counter_no_data": len(counter_no_data),
"synergy_agree": len(syn_ok),
"synergy_conflict": len(syn_conflict),
"synergy_weak_or_missing": len(syn_missing),
"heroes_total": len(all_keys),
"heroes_empty_relations": len(empty),
"heroes_thin_relations": len(thin),
},
"missing_cache": missing_cache,
"missing_qualitative": {
"empty": [{"key": k, "name_loc": names[k]} for k in empty],
"no_counters_out": [{"key": k, "name_loc": names[k]} for k in no_counter_out],
"no_counters_in": [{"key": k, "name_loc": names[k]} for k in no_counter_in],
"no_synergy": [{"key": k, "name_loc": names[k]} for k in no_synergy],
"priority_fill": needs_fill,
},
"counter_conflicts": sorted(
counter_conflict,
key=lambda r: ((r.get("stratz_adv") or 0), (r.get("opendota_adv") or 0)),
),
"counter_agree_both": [r for r in counter_ok if set(r.get("agree_sources") or []) == {"opendota", "stratz"}],
"suggest_add_counters_cross_source": suggestions,
"synergy_conflicts": syn_conflict,
}
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
s = report["summary"]
print("=== relations audit ===")
print(
f"counters: agree={s['counter_agree']} (both={len(report['counter_agree_both'])}) "
f"conflict={s['counter_conflict']} weak={s['counter_weak']} no_data={s['counter_no_data']} "
f"/ total={s['relations_counters']}"
)
print(
f"synergies: agree={s['synergy_agree']} conflict={s['synergy_conflict']} "
f"weak/missing={s['synergy_weak_or_missing']} / total={s['relations_synergies']}"
)
print(
f"qualitative gaps: empty={s['heroes_empty_relations']} thin(<={1} edge)={s['heroes_thin_relations']} "
f"/ heroes={s['heroes_total']}"
)
print("cache gaps:")
for k, v in missing_cache.items():
print(f" {k}: {len(v)}")
print("\nPriority heroes with no qualitative edges:")
for row in needs_fill[:40]:
if row["counters_out"] + row["counters_in"] + row["synergies"] == 0:
print(
f" {row['name_loc']}({row['key']}) "
f"suggest={row['cross_source_suggestions']}"
)
print(f"\nwrote {args.out}")
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
+845
View File
@@ -0,0 +1,845 @@
{
"version": 1,
"meta": {
"source": "dota2.xlsx",
"imported_at": "2026-07-26T07:15:47.770566+00:00",
"note": "qualitative counters/synergies; no winrate",
"patched_at": "2026-07-26T10:15:25.180740+00:00",
"patch_source": "data/relations_patch_draft.json",
"patch_note": "added cross-source counters; kept existing edges; no reverses",
"patch_added": 100,
"patch_skipped_dup": 0,
"cleaned_at": "2026-07-26T18:31:42.825338+00:00",
"cleaned_note": "removed numeric-reason counters (opendota_adv/stratz_adv winrate injected by relations_patch_draft.json; violated no-winrate contract)",
"cleaned_removed": 100
},
"counters": [
{
"a": "abaddon",
"b": "axe",
"reason": ""
},
{
"a": "abaddon",
"b": "legion_commander",
"reason": ""
},
{
"a": "abaddon",
"b": "life_stealer",
"reason": ""
},
{
"a": "abaddon",
"b": "slark",
"reason": ""
},
{
"a": "abyssal_underlord",
"b": "luna",
"reason": ""
},
{
"a": "abyssal_underlord",
"b": "riki",
"reason": ""
},
{
"a": "abyssal_underlord",
"b": "spirit_breaker",
"reason": ""
},
{
"a": "antimage",
"b": "lina",
"reason": ""
},
{
"a": "antimage",
"b": "medusa",
"reason": ""
},
{
"a": "antimage",
"b": "queenofpain",
"reason": ""
},
{
"a": "antimage",
"b": "skeleton_king",
"reason": ""
},
{
"a": "antimage",
"b": "sniper",
"reason": ""
},
{
"a": "antimage",
"b": "storm_spirit",
"reason": ""
},
{
"a": "axe",
"b": "antimage",
"reason": ""
},
{
"a": "axe",
"b": "drow_ranger",
"reason": "反击螺旋克敏核"
},
{
"a": "axe",
"b": "juggernaut",
"reason": "反击螺旋克敏核"
},
{
"a": "axe",
"b": "phantom_assassin",
"reason": "反击螺旋克敏核"
},
{
"a": "axe",
"b": "phantom_lancer",
"reason": ""
},
{
"a": "bloodseeker",
"b": "dark_seer",
"reason": ""
},
{
"a": "bloodseeker",
"b": "night_stalker",
"reason": ""
},
{
"a": "bloodseeker",
"b": "riki",
"reason": ""
},
{
"a": "bloodseeker",
"b": "shredder",
"reason": ""
},
{
"a": "bloodseeker",
"b": "slark",
"reason": ""
},
{
"a": "centaur",
"b": "phantom_lancer",
"reason": ""
},
{
"a": "dark_seer",
"b": "batrider",
"reason": ""
},
{
"a": "dark_seer",
"b": "doom_bringer",
"reason": ""
},
{
"a": "dark_seer",
"b": "luna",
"reason": ""
},
{
"a": "dark_seer",
"b": "templar_assassin",
"reason": ""
},
{
"a": "dark_willow",
"b": "spirit_breaker",
"reason": ""
},
{
"a": "dawnbreaker",
"b": "monkey_king",
"reason": ""
},
{
"a": "dawnbreaker",
"b": "ursa",
"reason": ""
},
{
"a": "death_prophet",
"b": "snapfire",
"reason": ""
},
{
"a": "dragon_knight",
"b": "phantom_assassin",
"reason": ""
},
{
"a": "dragon_knight",
"b": "slark",
"reason": ""
},
{
"a": "drow_ranger",
"b": "juggernaut",
"reason": ""
},
{
"a": "drow_ranger",
"b": "zuus",
"reason": ""
},
{
"a": "earth_spirit",
"b": "antimage",
"reason": ""
},
{
"a": "earth_spirit",
"b": "bloodseeker",
"reason": ""
},
{
"a": "earth_spirit",
"b": "faceless_void",
"reason": ""
},
{
"a": "elder_titan",
"b": "drow_ranger",
"reason": ""
},
{
"a": "ember_spirit",
"b": "centaur",
"reason": ""
},
{
"a": "ember_spirit",
"b": "phantom_assassin",
"reason": ""
},
{
"a": "ember_spirit",
"b": "queenofpain",
"reason": ""
},
{
"a": "faceless_void",
"b": "keeper_of_the_light",
"reason": ""
},
{
"a": "faceless_void",
"b": "kez",
"reason": ""
},
{
"a": "faceless_void",
"b": "lina",
"reason": ""
},
{
"a": "furion",
"b": "shredder",
"reason": ""
},
{
"a": "huskar",
"b": "keeper_of_the_light",
"reason": ""
},
{
"a": "huskar",
"b": "riki",
"reason": ""
},
{
"a": "huskar",
"b": "snapfire",
"reason": ""
},
{
"a": "keeper_of_the_light",
"b": "necrolyte",
"reason": ""
},
{
"a": "keeper_of_the_light",
"b": "skeleton_king",
"reason": ""
},
{
"a": "keeper_of_the_light",
"b": "spectre",
"reason": ""
},
{
"a": "keeper_of_the_light",
"b": "spirit_breaker",
"reason": ""
},
{
"a": "kez",
"b": "ancient_apparition",
"reason": ""
},
{
"a": "kunkka",
"b": "luna",
"reason": ""
},
{
"a": "legion_commander",
"b": "antimage",
"reason": ""
},
{
"a": "legion_commander",
"b": "juggernaut",
"reason": ""
},
{
"a": "legion_commander",
"b": "templar_assassin",
"reason": ""
},
{
"a": "leshrac",
"b": "magnataur",
"reason": ""
},
{
"a": "lich",
"b": "dark_seer",
"reason": ""
},
{
"a": "life_stealer",
"b": "axe",
"reason": ""
},
{
"a": "life_stealer",
"b": "pudge",
"reason": ""
},
{
"a": "life_stealer",
"b": "tiny",
"reason": ""
},
{
"a": "lina",
"b": "treant",
"reason": ""
},
{
"a": "luna",
"b": "necrolyte",
"reason": ""
},
{
"a": "magnataur",
"b": "dawnbreaker",
"reason": ""
},
{
"a": "marci",
"b": "lion",
"reason": ""
},
{
"a": "mars",
"b": "drow_ranger",
"reason": ""
},
{
"a": "medusa",
"b": "bloodseeker",
"reason": ""
},
{
"a": "medusa",
"b": "doom_bringer",
"reason": ""
},
{
"a": "medusa",
"b": "marci",
"reason": ""
},
{
"a": "meepo",
"b": "riki",
"reason": ""
},
{
"a": "monkey_king",
"b": "legion_commander",
"reason": ""
},
{
"a": "naga_siren",
"b": "riki",
"reason": ""
},
{
"a": "naga_siren",
"b": "viper",
"reason": ""
},
{
"a": "necrolyte",
"b": "huskar",
"reason": ""
},
{
"a": "necrolyte",
"b": "tiny",
"reason": ""
},
{
"a": "night_stalker",
"b": "antimage",
"reason": ""
},
{
"a": "night_stalker",
"b": "faceless_void",
"reason": ""
},
{
"a": "night_stalker",
"b": "slark",
"reason": ""
},
{
"a": "nyx_assassin",
"b": "bristleback",
"reason": ""
},
{
"a": "nyx_assassin",
"b": "ember_spirit",
"reason": ""
},
{
"a": "nyx_assassin",
"b": "medusa",
"reason": ""
},
{
"a": "nyx_assassin",
"b": "obsidian_destroyer",
"reason": ""
},
{
"a": "nyx_assassin",
"b": "storm_spirit",
"reason": ""
},
{
"a": "nyx_assassin",
"b": "tidehunter",
"reason": ""
},
{
"a": "obsidian_destroyer",
"b": "faceless_void",
"reason": ""
},
{
"a": "obsidian_destroyer",
"b": "necrolyte",
"reason": ""
},
{
"a": "pangolier",
"b": "life_stealer",
"reason": ""
},
{
"a": "phantom_assassin",
"b": "huskar",
"reason": ""
},
{
"a": "phantom_assassin",
"b": "legion_commander",
"reason": ""
},
{
"a": "phantom_assassin",
"b": "ogre_magi",
"reason": ""
},
{
"a": "phantom_assassin",
"b": "sniper",
"reason": ""
},
{
"a": "phantom_lancer",
"b": "obsidian_destroyer",
"reason": ""
},
{
"a": "phantom_lancer",
"b": "viper",
"reason": ""
},
{
"a": "phantom_lancer",
"b": "windrunner",
"reason": ""
},
{
"a": "primal_beast",
"b": "drow_ranger",
"reason": ""
},
{
"a": "primal_beast",
"b": "riki",
"reason": ""
},
{
"a": "pudge",
"b": "drow_ranger",
"reason": ""
},
{
"a": "pudge",
"b": "riki",
"reason": ""
},
{
"a": "queenofpain",
"b": "phantom_assassin",
"reason": ""
},
{
"a": "rattletrap",
"b": "centaur",
"reason": ""
},
{
"a": "razor",
"b": "dragon_knight",
"reason": ""
},
{
"a": "razor",
"b": "life_stealer",
"reason": ""
},
{
"a": "razor",
"b": "troll_warlord",
"reason": ""
},
{
"a": "riki",
"b": "antimage",
"reason": ""
},
{
"a": "skeleton_king",
"b": "bloodseeker",
"reason": ""
},
{
"a": "skeleton_king",
"b": "doom_bringer",
"reason": ""
},
{
"a": "skeleton_king",
"b": "legion_commander",
"reason": ""
},
{
"a": "slardar",
"b": "antimage",
"reason": ""
},
{
"a": "slardar",
"b": "bounty_hunter",
"reason": ""
},
{
"a": "slark",
"b": "bristleback",
"reason": ""
},
{
"a": "slark",
"b": "ember_spirit",
"reason": ""
},
{
"a": "slark",
"b": "juggernaut",
"reason": ""
},
{
"a": "slark",
"b": "lion",
"reason": ""
},
{
"a": "slark",
"b": "rubick",
"reason": ""
},
{
"a": "slark",
"b": "skywrath_mage",
"reason": ""
},
{
"a": "slark",
"b": "tidehunter",
"reason": ""
},
{
"a": "slark",
"b": "tusk",
"reason": ""
},
{
"a": "slark",
"b": "zuus",
"reason": ""
},
{
"a": "snapfire",
"b": "antimage",
"reason": ""
},
{
"a": "snapfire",
"b": "meepo",
"reason": ""
},
{
"a": "snapfire",
"b": "necrolyte",
"reason": ""
},
{
"a": "snapfire",
"b": "phantom_lancer",
"reason": ""
},
{
"a": "sniper",
"b": "drow_ranger",
"reason": ""
},
{
"a": "sniper",
"b": "necrolyte",
"reason": ""
},
{
"a": "spectre",
"b": "snapfire",
"reason": ""
},
{
"a": "spectre",
"b": "sniper",
"reason": ""
},
{
"a": "storm_spirit",
"b": "keeper_of_the_light",
"reason": ""
},
{
"a": "templar_assassin",
"b": "huskar",
"reason": ""
},
{
"a": "templar_assassin",
"b": "legion_commander",
"reason": "折射克决斗斩杀线"
},
{
"a": "templar_assassin",
"b": "skywrath_mage",
"reason": ""
},
{
"a": "terrorblade",
"b": "ancient_apparition",
"reason": ""
},
{
"a": "terrorblade",
"b": "night_stalker",
"reason": ""
},
{
"a": "terrorblade",
"b": "viper",
"reason": ""
},
{
"a": "tidehunter",
"b": "phantom_lancer",
"reason": ""
},
{
"a": "tidehunter",
"b": "puck",
"reason": ""
},
{
"a": "tidehunter",
"b": "queenofpain",
"reason": ""
},
{
"a": "troll_warlord",
"b": "ursa",
"reason": ""
},
{
"a": "undying",
"b": "juggernaut",
"reason": "魔晶墓碑克剑圣大招"
},
{
"a": "ursa",
"b": "axe",
"reason": ""
},
{
"a": "ursa",
"b": "juggernaut",
"reason": ""
},
{
"a": "ursa",
"b": "pudge",
"reason": ""
},
{
"a": "ursa",
"b": "tidehunter",
"reason": ""
},
{
"a": "viper",
"b": "ember_spirit",
"reason": ""
},
{
"a": "viper",
"b": "huskar",
"reason": ""
},
{
"a": "viper",
"b": "night_stalker",
"reason": ""
},
{
"a": "weaver",
"b": "ancient_apparition",
"reason": ""
},
{
"a": "weaver",
"b": "keeper_of_the_light",
"reason": ""
},
{
"a": "weaver",
"b": "pudge",
"reason": ""
},
{
"a": "zuus",
"b": "snapfire",
"reason": ""
},
{
"a": "zuus",
"b": "treant",
"reason": ""
},
{
"a": "zuus",
"b": "ursa",
"reason": ""
}
],
"synergies": [
{
"a": "beastmaster",
"b": "keeper_of_the_light",
"reason": ""
},
{
"a": "centaur",
"b": "dark_seer",
"reason": ""
},
{
"a": "centaur",
"b": "snapfire",
"reason": ""
},
{
"a": "death_prophet",
"b": "mars",
"reason": ""
},
{
"a": "drow_ranger",
"b": "pudge",
"reason": ""
},
{
"a": "faceless_void",
"b": "invoker",
"reason": ""
},
{
"a": "faceless_void",
"b": "skywrath_mage",
"reason": ""
},
{
"a": "kunkka",
"b": "medusa",
"reason": ""
},
{
"a": "legion_commander",
"b": "skywrath_mage",
"reason": ""
},
{
"a": "magnataur",
"b": "snapfire",
"reason": ""
},
{
"a": "mars",
"b": "snapfire",
"reason": ""
}
]
}
+86
View File
@@ -0,0 +1,86 @@
"""Fetch OpenDota hero matchups into data/matchups.json (for audit_relations).
Usage:
python fetch_matchups.py
python fetch_matchups.py --delay 1.0
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import time
import urllib.error
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.http_utils import http_json
from shared.paths import SHARED_DATA
API = "https://api.opendota.com/api/heroes/{hero_id}/matchups"
OUT = SHARED_DATA / "matchups.json"
def fetch_one(hero_id: int, timeout: float = 30.0) -> list[dict]:
return http_json(API.format(hero_id=hero_id), timeout=int(timeout))
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--delay", type=float, default=1.0, help="seconds between requests")
ap.add_argument("--out", type=Path, default=OUT)
args = ap.parse_args()
table = hero_table()
ids = sorted({int(h["id"]) for h in table})
by_hero: dict[str, dict] = {}
if args.out.is_file():
try:
prev = json.loads(args.out.read_text(encoding="utf-8"))
by_hero = dict(prev.get("by_hero") or {})
print(f"resuming with {len(by_hero)} heroes already cached", flush=True)
except (OSError, json.JSONDecodeError):
pass
pending = [i for i in ids if str(i) not in by_hero]
print(f"fetching {len(pending)} / {len(ids)} heroes -> {args.out}", flush=True)
for n, hid in enumerate(pending, start=1):
try:
rows = fetch_one(hid)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as e:
print(f" [{n}/{len(pending)}] hero {hid} failed: {e}", flush=True)
time.sleep(args.delay * 2)
continue
cell: dict[str, dict] = {}
for r in rows:
opp = r.get("hero_id")
games = int(r.get("games_played") or 0)
wins = int(r.get("wins") or 0)
if opp is None or games <= 0:
continue
cell[str(int(opp))] = {"games": games, "wins": wins}
by_hero[str(hid)] = cell
print(f" [{n}/{len(pending)}] hero {hid}: {len(cell)} matchups", flush=True)
args.out.parent.mkdir(parents=True, exist_ok=True)
payload = {
"fetched_at": datetime.now(timezone.utc).isoformat(),
"source": "opendota",
"attribution": "https://opendota.com",
"by_hero": by_hero,
}
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
time.sleep(args.delay)
print(f"done: {len(by_hero)} heroes in {args.out}", flush=True)
if __name__ == "__main__":
main()
+193
View File
@@ -0,0 +1,193 @@
"""Fetch STRATZ hero-vs-hero matchups or teammate synergies.
Requires STRATZ_API_TOKEN in the environment or a local .env file
(gitignored). Attribution: data from https://stratz.com
Usage:
python fetch_stratz.py --mode matchups # -> data/stratz_matchups.json
python fetch_stratz.py --mode synergies # -> data/synergies.json
python fetch_stratz.py --mode matchups --delay 0.45
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
import os
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.paths import ROOT, SHARED_DATA
API = "https://api.stratz.com/graphql"
# GraphQL edge field ("vs" for matchups, "with" for synergies) and the
# score field name used in the output JSON.
MODE_CONFIG = {
"matchups": {
"edge_field": "vs",
"score_field": "advantage",
"label": "matchups",
"default_out": SHARED_DATA / "stratz_matchups.json",
},
"synergies": {
"edge_field": "with",
"score_field": "synergy",
"label": "synergies",
"default_out": SHARED_DATA / "synergies.json",
},
}
def _build_query(edge_field: str) -> str:
return f"""
query($id: Short!) {{
heroStats {{
heroVsHeroMatchup(heroId: $id) {{
advantage {{
heroId
{edge_field} {{
heroId2
matchCount
winCount
synergy
winsAverage
}}
}}
}}
}}
}}
"""
def load_token() -> str:
for key in (
"STRATZ_API_TOKEN",
"KEYZOO_ASSET_API_KEY",
"KEYZOO_ASSET_SECRET_API_KEY",
"KEYZOO_ASSET_TOKEN",
):
env = os.environ.get(key, "").strip()
if env:
return env
path = ROOT / ".env"
if path.is_file():
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
if k.strip() == "STRATZ_API_TOKEN":
return v.strip().strip('"').strip("'")
raise SystemExit(
"STRATZ_API_TOKEN missing. Set env / keyzoo inject or add to .env "
"(see .env.example)."
)
def gql(token: str, query: str, variables: dict | None = None) -> dict:
body = {"query": query}
if variables:
body["variables"] = variables
req = urllib.request.Request(
API,
data=json.dumps(body).encode(),
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"User-Agent": "climperor",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=60) as resp:
payload = json.loads(resp.read().decode())
if payload.get("errors"):
raise RuntimeError(str(payload["errors"][:2]))
return payload["data"]
def fetch_one(
token: str, hero_id: int, *, edge_field: str, score_field: str
) -> dict[str, dict]:
query = _build_query(edge_field)
data = gql(token, query, {"id": hero_id})
block = (((data or {}).get("heroStats") or {}).get("heroVsHeroMatchup") or {})
rows = block.get("advantage") or []
cell: dict[str, dict] = {}
for row in rows:
for pair in row.get(edge_field) or []:
other = pair.get("heroId2")
games = int(pair.get("matchCount") or 0)
wins = int(pair.get("winCount") or 0)
if other is None or games <= 0:
continue
cell[str(int(other))] = {
"games": games,
"wins": wins,
score_field: float(pair.get("synergy") or 0.0),
"wr": float(pair.get("winsAverage") or (wins / games)),
}
return cell
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--mode", choices=list(MODE_CONFIG), required=True)
ap.add_argument("--delay", type=float, default=0.45)
ap.add_argument("--out", type=Path, default=None)
args = ap.parse_args()
mc = MODE_CONFIG[args.mode]
out_path = args.out or mc["default_out"]
edge_field = mc["edge_field"]
score_field = mc["score_field"]
label = mc["label"]
token = load_token()
ids = sorted({int(h["id"]) for h in hero_table()})
by_hero: dict[str, dict] = {}
if out_path.is_file():
try:
prev = json.loads(out_path.read_text(encoding="utf-8"))
by_hero = dict(prev.get("by_hero") or {})
print(f"resuming with {len(by_hero)} heroes already cached", flush=True)
except (OSError, json.JSONDecodeError):
pass
pending = [i for i in ids if str(i) not in by_hero]
print(f"fetching {len(pending)} / {len(ids)} heroes -> {out_path}", flush=True)
for n, hid in enumerate(pending, start=1):
try:
cell = fetch_one(token, hid, edge_field=edge_field, score_field=score_field)
except (urllib.error.URLError, TimeoutError, RuntimeError, json.JSONDecodeError) as e:
print(f" [{n}/{len(pending)}] hero {hid} failed: {e}", flush=True)
time.sleep(args.delay * 2)
continue
by_hero[str(hid)] = cell
print(f" [{n}/{len(pending)}] hero {hid}: {len(cell)} {label}", flush=True)
out_path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"fetched_at": datetime.now(timezone.utc).isoformat(),
"source": "stratz",
"attribution": "https://stratz.com",
"by_hero": by_hero,
}
out_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
time.sleep(args.delay)
print(f"done: {len(by_hero)} heroes in {out_path}", flush=True)
if __name__ == "__main__":
main()
+232
View File
@@ -0,0 +1,232 @@
"""Read the hero-selection grid: which heroes are unavailable.
No template matching is involved, because the grid's layout is fully
determined. Heroes are split into four attribute blocks laid out left to
right (strength, agility, intelligence, universal); inside a block they are
sorted by the in-client localized name and filled row-major, and any leftover
cells sit at the tail of the block.
That was verified against a live ranked draft: the four blocks held exactly
36 / 35 / 34 / 22 cells, matching the roster's attribute counts, every empty
cell was in the bottom row at the end of its block, and all nine bans that
the in-game chat log named landed on cells drawn with the ban slash.
A card that cannot be picked - banned, or already taken - is drawn dimmed
under a diagonal slash, which flattens it. Greyscale contrast is the clean
separator: in that same draft the seventeen unavailable cards measured 8-21
while every live card measured 33 or more.
cv2/numpy are imported lazily inside the vision functions so that web-side
consumers of hero_table()/ATTR_ORDER (serve/export/fetch scripts, and CI)
do not need opencv installed.
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import json
from shared.paths import HEROES_JSON, PC_CONFIG
ATTR_ORDER = ("str", "agi", "int", "all")
def hero_table() -> list[dict]:
table = json.loads(HEROES_JSON.read_text(encoding="utf-8"))
if table and "attr" not in table[0]:
raise SystemExit(f"{HEROES_JSON.name} predates grid support - rerun fetch_cdn_templates.py")
return table
def _runs(flags, min_len: int) -> list[tuple[int, int]]:
out, start = [], None
for i, v in enumerate(flags):
if v and start is None:
start = i
elif not v and start is not None:
if i - start >= min_len:
out.append((start, i))
start = None
if start is not None and len(flags) - start >= min_len:
out.append((start, len(flags)))
return out
def detect_grid(img, cfg: dict | None = None) -> dict | None:
"""Locate the card lattice. Returns column and row spans, or None.
Cards are busy and the gaps between them are flat, so a per-column and
per-row standard deviation profile separates them without any thresholds
that depend on resolution.
"""
import cv2
import numpy as np
g = cfg.get("grid", {}) if cfg else {}
floor = g.get("min_std", 18.0)
ih = img.shape[0]
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY).astype(np.float32)
band = gray[int(ih * 0.20):int(ih * 0.60), :]
cols = _plausible(_runs(band.std(axis=0) > _ink(band.std(axis=0), floor), int(ih * 0.025)))
if len(cols) < 8:
return None
strip = gray[:, cols[0][0]:cols[-1][1]]
prof = strip.std(axis=1)
rows = [r for r in _plausible(_runs(prof > _ink(prof, floor), int(ih * 0.03))) if r[0] > ih * 0.10]
if len(rows) < 2:
return None
return {"cols": cols, "rows": rows}
def _ink(profile, floor: float) -> float:
"""Threshold that follows the frame's own contrast.
Banners and tooltips dim the whole grid for a moment; a fixed cut loses
rows and columns on those frames, which would silently truncate the layout.
"""
import numpy as np
return max(floor * 0.5, 0.35 * float(np.percentile(profile, 75)))
def _plausible(spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
"""Drop side panels and stray runs by keeping spans near the median width."""
import numpy as np
if not spans:
return []
med = float(np.median([b - a for a, b in spans]))
return [s for s in spans if 0.7 * med <= (s[1] - s[0]) <= 1.4 * med]
def block_of_column(cols: list[tuple[int, int]]) -> list[int]:
"""Tag every column with its attribute block index.
Blocks are separated by a visibly wider gutter than the gap between two
cards in the same block.
"""
gaps = [cols[i + 1][0] - cols[i][1] for i in range(len(cols) - 1)]
if not gaps:
return [0] * len(cols)
cut = float(np.median(gaps)) * 1.8
block, out = 0, [0]
for gap in gaps:
if gap > cut:
block += 1
out.append(block)
return out
def build_layout(grid: dict, table: list[dict]) -> dict[tuple[int, int], str] | None:
"""Map every cell to a hero from the roster alone. None if the shape is off."""
cols, rows = grid["cols"], grid["rows"]
blocks = block_of_column(cols)
if len(set(blocks)) != len(ATTR_ORDER):
return None
layout: dict[tuple[int, int], str] = {}
for bi, attr in enumerate(ATTR_ORDER):
cells = [(r, c) for r in range(len(rows)) for c in range(len(cols)) if blocks[c] == bi]
cells.sort()
heroes = sorted((h for h in table if h["attr"] == attr), key=lambda h: h["name_loc"])
if len(heroes) > len(cells):
return None
for cell, hero in zip(cells, heroes):
layout[cell] = hero["key"]
return layout
def cell_contrast(img, grid: dict, r: int, c: int) -> float:
import cv2
x0, x1 = grid["cols"][c]
y0, y1 = grid["rows"][r]
patch = img[y0:y1, x0:x1]
if patch.size == 0:
return 0.0
# trim the level badge and attribute gem the client paints over the art
h, w = patch.shape[:2]
inner = patch[int(h * 0.04):int(h * 0.86), int(w * 0.05):int(w * 0.95)]
return float(cv2.cvtColor(inner, cv2.COLOR_BGR2GRAY).std())
def read_grid(img, cfg: dict | None = None) -> dict:
"""Heroes that cannot be picked right now, read off the selection grid.
"unavailable" covers bans and heroes already taken by either team; the
caller separates them using the picks it already recognized from the top
bar. Returns ok=False when the lattice does not look like a full roster,
so a mis-detected grid never turns into a bogus ban list.
"""
cfg = cfg or {}
table = hero_table()
grid = detect_grid(img, cfg)
if grid is None:
return {"ok": False, "reason": "no grid detected", "unavailable": [], "cells": {}}
layout = build_layout(grid, table)
if layout is None:
return {"ok": False, "reason": "grid shape does not fit the roster", "unavailable": [], "cells": {}}
if len(layout) != len(table):
return {"ok": False,
"reason": f"placed {len(layout)} of {len(table)} heroes",
"unavailable": [], "cells": {}}
cut = cfg.get("grid", {}).get("unavailable_std", 26.0)
scored = {key: cell_contrast(img, grid, r, c) for (r, c), key in layout.items()}
unavailable = sorted((k for k, s in scored.items() if s < cut), key=lambda k: scored[k])
live = [s for s in scored.values() if s >= cut]
cols, rows = grid["cols"], grid["rows"]
cells = {
key: {"x0": cols[c][0], "y0": rows[r][0], "x1": cols[c][1], "y1": rows[r][1]}
for (r, c), key in layout.items()
}
return {
"ok": True,
"unavailable": unavailable,
"cells": cells,
"grid": {"cols": len(cols), "rows": len(rows)},
"margin": round(min(live) - max((scored[k] for k in unavailable), default=0.0), 1) if live and unavailable else None,
}
def bans(grid_result: dict, picked: list[str]) -> list[str]:
"""Unavailable minus whatever the top bar already showed as picked."""
taken = {p for p in picked if p}
return [k for k in grid_result.get("unavailable", []) if k not in taken]
def _main() -> None:
import cv2
if len(sys.argv) < 2:
raise SystemExit("usage: python shared/grid.py <frame.png> [--picked key,key,...]")
img = cv2.imread(sys.argv[1])
if img is None:
raise SystemExit(f"cannot read {sys.argv[1]}")
picked = []
if "--picked" in sys.argv:
picked = [s.strip() for s in sys.argv[sys.argv.index("--picked") + 1].split(",")]
cfg = json.loads(PC_CONFIG.read_text(encoding="utf-8")) if PC_CONFIG.is_file() else {}
res = read_grid(img, cfg)
names = {h["key"]: h["name_loc"] for h in hero_table()}
if not res["ok"]:
print(f"grid not readable: {res['reason']}")
return
print(f"grid {res['grid']['rows']}x{res['grid']['cols']}, "
f"{len(res['unavailable'])} unavailable, contrast margin {res['margin']}")
print("unavailable:", ", ".join(names.get(k, k) for k in res["unavailable"]))
if picked:
b = bans(res, picked)
print(f"bans ({len(b)}):", ", ".join(names.get(k, k) for k in b))
if __name__ == "__main__":
_main()
+52
View File
@@ -0,0 +1,52 @@
"""Chinese draft tags (定位) for Climperor web filter/display.
Mapped from OpenDota English roles + a curated illusion set.
Not used by recommend scoring.
"""
from __future__ import annotations
# Display / filter order on the web heroes page.
TAG_ORDER = [
"核心",
"辅助",
"推进",
"逃生",
"爆发",
"先手",
"控制",
"耐久",
"幻象",
]
ROLE_TO_ZH = {
"Carry": "核心",
"Support": "辅助",
"Pusher": "推进",
"Escape": "逃生",
"Nuker": "爆发",
"Initiator": "先手",
"Disabler": "控制",
"Durable": "耐久",
}
# Heroes commonly called 幻象系 in CN pubs.
ILLUSION_KEYS = frozenset({
"phantom_lancer",
"naga_siren",
"chaos_knight",
"terrorblade",
"shadow_demon",
})
def tags_for_hero(key: str, roles: list[str] | None) -> list[str]:
"""Stable Chinese tags; order follows TAG_ORDER."""
found: set[str] = set()
for role in roles or []:
zh = ROLE_TO_ZH.get(role)
if zh:
found.add(zh)
if key in ILLUSION_KEYS:
found.add("幻象")
return [t for t in TAG_ORDER if t in found]
+168
View File
@@ -0,0 +1,168 @@
"""Shared HTTP helpers: fetch JSON/bytes, download icons, load Valve datafeeds.
Used by all fetch_*.py scripts and serve_relations.py so HTTP logic, User-Agent,
timeout, and retry conventions live in exactly one place.
"""
from __future__ import annotations
import json
import time
import urllib.error
import urllib.request
from pathlib import Path
UA = "climperor"
DEFAULT_TIMEOUT = 60
# OpenDota and similar APIs occasionally 429; back off before failing CI/refresh.
DEFAULT_RETRIES = 4
DEFAULT_RETRY_BACKOFF = 5.0
HEROES_URL = "https://www.dota2.com/datafeed/herolist?language={lang}"
ITEMLIST_URL = "https://www.dota2.com/datafeed/itemlist?language={lang}"
def _http_open(url: str, *, timeout: int, retries: int, backoff: float):
"""urlopen with retries on 429 / 5xx / transient network errors."""
last_err: BaseException | None = None
for attempt in range(retries + 1):
req = urllib.request.Request(url, headers={"User-Agent": UA})
try:
return urllib.request.urlopen(req, timeout=timeout)
except urllib.error.HTTPError as e:
last_err = e
if e.code not in (429, 500, 502, 503, 504) or attempt >= retries:
raise
sleep_s = backoff * (2**attempt)
retry_after = e.headers.get("Retry-After") if e.headers else None
if retry_after:
try:
sleep_s = max(sleep_s, float(retry_after))
except ValueError:
pass
print(
f"HTTP {e.code} {url} — retry {attempt + 1}/{retries} in {sleep_s:.0f}s",
flush=True,
)
time.sleep(sleep_s)
except (urllib.error.URLError, TimeoutError, OSError) as e:
last_err = e
if attempt >= retries:
raise
sleep_s = backoff * (2**attempt)
print(
f"HTTP error {e} {url} — retry {attempt + 1}/{retries} in {sleep_s:.0f}s",
flush=True,
)
time.sleep(sleep_s)
assert last_err is not None
raise last_err
def http_json(
url: str,
*,
timeout: int = DEFAULT_TIMEOUT,
retries: int = DEFAULT_RETRIES,
backoff: float = DEFAULT_RETRY_BACKOFF,
) -> dict | list:
with _http_open(url, timeout=timeout, retries=retries, backoff=backoff) as resp:
return json.loads(resp.read().decode())
def http_bytes(
url: str,
*,
timeout: int = DEFAULT_TIMEOUT,
retries: int = DEFAULT_RETRIES,
backoff: float = DEFAULT_RETRY_BACKOFF,
) -> bytes:
with _http_open(url, timeout=timeout, retries=retries, backoff=backoff) as resp:
return resp.read()
def download_icons(
keys,
url_template: str,
dest_dir: Path,
*,
force: bool = False,
delay: float = 0.0,
min_size: int = 32,
skip_keys: frozenset[str] | set[str] | None = None,
) -> tuple[int, int, int]:
"""Download PNG icons from a CDN. Returns (saved, skipped_existing, fail).
keys — iterable of template substitution values (hero/item/ability keys).
url_template — e.g. "https://cdn.../abilities/{key}.png".
dest_dir — target directory (created if missing).
force — re-download even if the file exists.
delay — seconds to sleep between requests (rate limiting).
min_size — files smaller than this are treated as empty and re-downloaded.
skip_keys — keys to ignore entirely (e.g. bundled icons that 404 on CDN).
"""
dest_dir.mkdir(parents=True, exist_ok=True)
skip = set(skip_keys or ())
saved = skipped = fail = 0
for key in sorted(keys):
if key in skip or "/" in key or "\\" in key or ".." in key:
continue
dest = dest_dir / f"{key}.png"
if dest.is_file() and dest.stat().st_size >= min_size and not force:
skipped += 1
continue
try:
data = http_bytes(url_template.format(key=key))
if not data or len(data) < min_size:
raise ValueError("empty icon")
dest.write_bytes(data)
saved += 1
print(f" icon saved {key}.png ({len(data)} bytes)", flush=True)
except (urllib.error.URLError, TimeoutError, ValueError, OSError) as e:
print(f" icon {key}: {e}", flush=True)
fail += 1
if delay > 0:
time.sleep(delay)
return saved, skipped, fail
def load_itemlist(lang: str = "schinese") -> dict[int, dict[str, str]]:
"""Valve datafeed item list: item id -> {name_loc, name}."""
raw = http_json(ITEMLIST_URL.format(lang=lang))
rows = (((raw or {}).get("result") or {}).get("data") or {}).get("itemabilities") or []
out: dict[int, dict[str, str]] = {}
for row in rows:
if not isinstance(row, dict):
continue
iid = row.get("id")
if iid is None:
continue
out[int(iid)] = {
"name_loc": (row.get("name_loc") or "").strip(),
"name": (row.get("name") or "").strip(),
}
return out
def load_itemlist_zh() -> dict[int, str]:
"""Convenience: item id -> Chinese localized name."""
return {iid: v["name_loc"] for iid, v in load_itemlist().items() if v["name_loc"]}
def fetch_hero_list(lang: str = "schinese") -> list[dict]:
"""Hero list from Valve's datafeed (id, key, name_loc, primary_attr)."""
data = http_json(HEROES_URL.format(lang=lang))
heroes = data.get("result", {}).get("data", {}).get("heroes") or data.get("heroes")
if not heroes:
raise SystemExit("hero list came back empty")
return heroes
def fetch_hero_keys() -> list[str]:
"""Hero keys (e.g. antimage, earthshaker) from the English datafeed."""
heroes = fetch_hero_list(lang="english")
keys = []
for h in heroes:
name = h.get("name") or ""
keys.append(name.removeprefix("npc_dota_hero_"))
return keys
+158
View File
@@ -0,0 +1,158 @@
"""Import qualitative relations from a Chinese draft spreadsheet.
Usage:
python import_relations_xlsx.py
python import_relations_xlsx.py "C:\\Users\\Administrator\\Downloads\\dota2.xlsx"
Writes data/relations.json. Skips category phrases (辅助/幻想系/...).
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import argparse
import json
from datetime import datetime, timezone
from shared.paths import SHARED_DATA
from shared.relations import (
DEFAULT_RELATIONS,
build_name_index,
load_relations,
resolve_name,
save_relations,
set_counter,
set_synergy,
split_names,
)
# Phrases that are roles/tags, not single heroes.
SKIP_TOKENS = {
"辅助",
"推进",
"幻想系",
"幻想",
"所有肉核",
"肉核",
"高爆发",
"奶妈",
"核心",
}
def _should_skip(token: str) -> bool:
t = token.strip()
if not t:
return True
if t in SKIP_TOKENS:
return True
if "" in t and t.endswith(""):
return True
if t.startswith("所有"):
return True
if "魔晶" in t or "a仗" in t.lower():
# item/facet interactions in hero columns — skip for edge seed
return True
return False
def import_xlsx(path: Path) -> tuple[dict, list[str]]:
import openpyxl
wb = openpyxl.load_workbook(path, data_only=True)
index = build_name_index()
data = load_relations()
data["meta"] = {
"source": str(path),
"imported_at": datetime.now(timezone.utc).isoformat(),
"note": "qualitative counters/synergies; no winrate",
}
unresolved: list[str] = []
sheets = ["力量", "敏捷", "智力", "全才"]
def resolve_list(cell, context: str) -> list[str]:
keys = []
for tok in split_names(cell):
if _should_skip(tok):
unresolved.append(f"{context}: skip tag '{tok}'")
continue
key = resolve_name(tok, index)
if key is None:
unresolved.append(f"{context}: unresolved '{tok}'")
continue
keys.append(key)
return keys
for sheet in sheets:
if sheet not in wb.sheetnames:
continue
ws = wb[sheet]
rows = list(ws.iter_rows(values_only=True))
# find header
header_i = None
for i, row in enumerate(rows):
if row and row[0] == "英雄":
header_i = i
break
if header_i is None:
continue
for row in rows[header_i + 1 :]:
if not row or not row[0]:
continue
hero_raw = str(row[0]).strip()
hero = resolve_name(hero_raw, index)
if hero is None:
unresolved.append(f"{sheet}: hero unresolved '{hero_raw}'")
continue
# col1 被克制, col2 克制, col3 搭档
countered_by = resolve_list(row[1] if len(row) > 1 else None, f"{hero_raw}/被克")
counters = resolve_list(row[2] if len(row) > 2 else None, f"{hero_raw}/克制")
partners = resolve_list(row[3] if len(row) > 3 else None, f"{hero_raw}/搭档")
for other in counters:
set_counter(data, hero, other, reason="")
for other in countered_by:
# other counters hero
set_counter(data, other, hero, reason="")
for other in partners:
set_synergy(data, hero, other, reason="")
return data, unresolved
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument(
"xlsx",
nargs="?",
default=str(Path.home() / "Downloads" / "dota2.xlsx"),
help="path to dota2.xlsx",
)
ap.add_argument("--out", default=str(DEFAULT_RELATIONS))
args = ap.parse_args()
path = Path(args.xlsx)
if not path.is_file():
raise SystemExit(f"file not found: {path}")
data, unresolved = import_xlsx(path)
out = save_relations(data, args.out)
print(f"wrote {out}")
print(f"counters={len(data['counters'])} synergies={len(data['synergies'])}")
report = SHARED_DATA / "relations_import_report.json"
report.write_text(
json.dumps({"unresolved": unresolved, "count": len(unresolved)}, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(f"unresolved notes: {len(unresolved)}{report}")
for line in unresolved[:40]:
print(" ", line)
if len(unresolved) > 40:
print(f" ... +{len(unresolved) - 40} more")
if __name__ == "__main__":
main()
+52
View File
@@ -0,0 +1,52 @@
"""Repository-wide path constants; the single source of truth for locations.
No third-party imports so both subprojects (and CI) can use this without
opencv/numpy. Layout:
climperor/
├── pc/ # in-game draft recognition (GSI + screenshot + overlay)
├── web/ # Climperor web site (frontend, pipelines, deploy)
└── shared/ # this package + shared hero/relations data
"""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SHARED_DIR = Path(__file__).resolve().parent
SHARED_DATA = SHARED_DIR / "data"
HEROES_JSON = SHARED_DATA / "heroes.json"
RELATIONS_JSON = SHARED_DATA / "relations.json"
# PC subproject locations (referenced by shared/pc scripts; code lives in pc/).
PC_DIR = ROOT / "pc"
PC_CONFIG = PC_DIR / "config.json"
TEMPLATES_CDN = PC_DIR / "templates" / "cdn"
# Web subproject locations. DATA keeps its historical name: every web-side
# JSON cache lives here (hero_stats, patches, streamers, ...).
WEB_ROOT = ROOT / "web"
WEB_FRONTEND = WEB_ROOT / "frontend"
DATA = WEB_ROOT / "data"
WEB_DIST = WEB_ROOT / "dist"
WEB_ASSETS = WEB_ROOT / "assets"
# Landscape cards from dota2.com/heroes (Steam CDN heroes/{key}.png); UI only.
HERO_PORTRAITS = WEB_ASSETS / "hero_portraits"
# Primary-attribute icons from dota2.com (dota_react/icons/hero_*.png); UI only.
ATTR_ICONS = WEB_ASSETS / "attr_icons"
# Item icons from Steam CDN (dota_react/items/{key}.png); Climperor web site only.
ITEM_ICONS = WEB_ASSETS / "item_icons"
# Shop category header icons from dota2.com.cn/items/images/itemcat_*.png.
ITEM_CAT_ICONS = WEB_ASSETS / "item_cat_icons"
# Ability icons from Steam CDN (dota_react/abilities/{key}.png); Climperor web site only.
ABILITY_ICONS = WEB_ASSETS / "ability_icons"
# Generic UI glyphs from dota2.com.cn (herostatic/icons/*.png); Climperor web site only.
UI_ICONS = WEB_ASSETS / "ui_icons"
# Rank medal icons (OpenDota rank_icon_1..8); Climperor web trends tab only.
RANK_ICONS = WEB_ASSETS / "rank_icons"
# Streamer avatars cached by fetch_streamers.py; Climperor web「主播」only.
STREAMER_AVATARS = WEB_ASSETS / "streamer_avatars"
# Curated Douyin highlight clips for streamer cards; Climperor web「主播」only.
STREAMER_VIDEOS = WEB_ASSETS / "streamer_videos"
# Official ability demo clips from dota2.com (dota_react/abilities/{hero}/{ability}.webm).
ABILITY_VIDEOS = WEB_ASSETS / "ability_videos"
+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