- Add relations/item/abilities preview (serve_relations.py + web/relations/) - Add fetch scripts: hero_items, item_shop, items_meta, hero_abilities, ability_videos, patches, stratz, matchups, portraits - Add overlay.py (role tags + Top-3 cyan marks), recommend.py - Add http_utils.py, loc_format.py, hero_tags.py, item_fears.py - GSI: full payload JSONL dump, foreground window detection - Drop real template library; CDN-only matching - Update docs: CHANGELOG 0.2.0, DESIGN config table, AGENTS module table - .gitignore: exclude large regenerable assets (icons/portraits/videos)
375 lines
15 KiB
Python
375 lines
15 KiB
Python
"""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 argparse
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from common import ROOT
|
|
from grid import hero_table
|
|
from relations import DEFAULT_RELATIONS, load_relations
|
|
|
|
MATCHUPS = ROOT / "data" / "matchups.json"
|
|
STRATZ_MATCHUPS = ROOT / "data" / "stratz_matchups.json"
|
|
SYNERGIES = ROOT / "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=ROOT / "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()
|