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,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
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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