v0.5.109: hero role toolbar, skill side-by-side layout, taller detail panel.
Move role filters between grid and detail, fix skill video/text layout and detail height jump, unify item dividers, drop OpenDota matchup cross, sync docs and assets. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+36
-141
@@ -1,10 +1,12 @@
|
||||
"""Audit qualitative relations.json against OpenDota + STRATZ.
|
||||
"""Audit qualitative relations.json against STRATZ matchup / synergy caches.
|
||||
|
||||
Sources:
|
||||
- shared/data/matchups.json OpenDota vs winrates
|
||||
- shared/data/stratz_matchups.json STRATZ vs advantage (fetch_stratz.py --mode matchups)
|
||||
- shared/data/synergies.json STRATZ teammate synergies
|
||||
- web/data/stratz_matchup_tops.json Web 对位 Top (optional cross-check section)
|
||||
|
||||
Hero matchup numbers for the Web site come only from
|
||||
web/data/stratz_matchup_tops.json (fetch_stratz_meta.py); this audit does not
|
||||
cross-check against OpenDota.
|
||||
|
||||
Usage:
|
||||
python shared/audit_relations.py
|
||||
@@ -23,14 +25,11 @@ import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from shared.grid import hero_table
|
||||
from shared.matchup_cross import audit_matchup_tops
|
||||
from shared.paths import DATA, ROOT, SHARED_DATA
|
||||
from shared.paths import ROOT, 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"
|
||||
STRATZ_MATCHUP_TOPS = DATA / "stratz_matchup_tops.json"
|
||||
# Legacy monorepo-move leftovers (repo-root data/).
|
||||
_LEGACY_DATA = ROOT / "data"
|
||||
|
||||
@@ -52,10 +51,6 @@ def _load_json_safe(path: Path) -> dict:
|
||||
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}
|
||||
@@ -64,41 +59,6 @@ def load_id_maps() -> tuple[dict[int, str], dict[str, int], dict[str, str]]:
|
||||
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:
|
||||
@@ -112,31 +72,23 @@ def stratz_adv(by_hero: dict, aid: int, bid: int) -> tuple[float | None, int]:
|
||||
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("--suggest-min-adv", type=float, default=2.0)
|
||||
ap.add_argument("--out", type=Path, default=SHARED_DATA / "relations_audit.json")
|
||||
args = ap.parse_args()
|
||||
|
||||
matchups_path = _resolve_cache(MATCHUPS, _LEGACY_DATA / "matchups.json")
|
||||
stratz_path = _resolve_cache(STRATZ_MATCHUPS, _LEGACY_DATA / "stratz_matchups.json")
|
||||
syn_path = _resolve_cache(SYNERGIES, _LEGACY_DATA / "synergies.json")
|
||||
tops_path = _resolve_cache(STRATZ_MATCHUP_TOPS, _LEGACY_DATA / "stratz_matchup_tops.json")
|
||||
|
||||
id_to_key, key_to_id, names = load_id_maps()
|
||||
_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_path)
|
||||
stratz = _load_json_safe(stratz_path)
|
||||
syn_file = _load_json_safe(syn_path)
|
||||
tops = _load_json_safe(tops_path)
|
||||
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 []]
|
||||
@@ -148,9 +100,7 @@ def main() -> None:
|
||||
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,
|
||||
@@ -158,34 +108,23 @@ def main() -> None:
|
||||
"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):
|
||||
s_bad = (
|
||||
s_adv is not None and s_games >= args.min_games and s_adv <= args.stratz_disagree
|
||||
)
|
||||
if s_adv is None:
|
||||
counter_no_data.append({**row, "why": "missing_stratz"})
|
||||
elif s_games < args.min_games:
|
||||
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:
|
||||
elif s_ok:
|
||||
counter_ok.append({**row, "agree_sources": ["stratz"]})
|
||||
elif 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)
|
||||
):
|
||||
if 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:
|
||||
@@ -197,7 +136,9 @@ def main() -> None:
|
||||
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))
|
||||
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(
|
||||
{
|
||||
@@ -236,15 +177,11 @@ def main() -> None:
|
||||
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
|
||||
s_adv is not None
|
||||
and s_games >= args.min_games
|
||||
and s_adv >= 2.0
|
||||
and s_adv >= args.suggest_min_adv
|
||||
):
|
||||
suggestions.append(
|
||||
{
|
||||
@@ -252,13 +189,11 @@ def main() -> None:
|
||||
"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.sort(key=lambda r: (-r["stratz_adv"], -r["stratz_games"]))
|
||||
suggestions = suggestions[:100]
|
||||
|
||||
counters_from: dict[str, int] = {k: 0 for k in all_keys}
|
||||
@@ -276,9 +211,6 @@ def main() -> None:
|
||||
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
|
||||
],
|
||||
@@ -310,28 +242,17 @@ def main() -> None:
|
||||
"counters_out": counters_from[k],
|
||||
"counters_in": counters_to[k],
|
||||
"synergies": syn_count[k],
|
||||
"cross_source_suggestions": suggest_by_hero.get(k, 0),
|
||||
"stratz_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["stratz_suggestions"],
|
||||
r["key"],
|
||||
),
|
||||
)
|
||||
|
||||
web_tops_audit = None
|
||||
if tops.get("by_hero"):
|
||||
web_tops_audit = audit_matchup_tops(
|
||||
tops,
|
||||
odota,
|
||||
id_to_key=id_to_key,
|
||||
key_to_id=key_to_id,
|
||||
names=names,
|
||||
min_games=args.min_games,
|
||||
)
|
||||
|
||||
def _rel(path: Path) -> str | None:
|
||||
if not path.is_file():
|
||||
return None
|
||||
@@ -344,12 +265,6 @@ def main() -> None:
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"sources": {
|
||||
"relations": str(DEFAULT_RELATIONS.relative_to(ROOT)).replace("\\", "/"),
|
||||
"opendota_matchups": {
|
||||
"path": _rel(matchups_path),
|
||||
"source": odota.get("source"),
|
||||
"fetched_at": odota.get("fetched_at"),
|
||||
"heroes": len(by_o),
|
||||
},
|
||||
"stratz_matchups": {
|
||||
"path": _rel(stratz_path),
|
||||
"source": stratz.get("source"),
|
||||
@@ -362,20 +277,13 @@ def main() -> None:
|
||||
"fetched_at": syn_file.get("fetched_at"),
|
||||
"heroes": len(by_syn),
|
||||
},
|
||||
"stratz_matchup_tops": {
|
||||
"path": _rel(tops_path),
|
||||
"source": tops.get("source"),
|
||||
"fetched_at": tops.get("fetched_at"),
|
||||
"heroes": len(tops.get("by_hero") or {}),
|
||||
},
|
||||
},
|
||||
"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,
|
||||
"suggest_min_adv": args.suggest_min_adv,
|
||||
},
|
||||
"summary": {
|
||||
"relations_counters": len(counters),
|
||||
@@ -401,16 +309,11 @@ def main() -> None:
|
||||
},
|
||||
"counter_conflicts": sorted(
|
||||
counter_conflict,
|
||||
key=lambda r: ((r.get("stratz_adv") or 0), (r.get("opendota_adv") or 0)),
|
||||
key=lambda r: (r.get("stratz_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,
|
||||
"counter_agree": counter_ok,
|
||||
"suggest_add_counters": suggestions,
|
||||
"synergy_conflicts": syn_conflict,
|
||||
"web_matchup_tops_cross": web_tops_audit,
|
||||
}
|
||||
|
||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -421,26 +324,18 @@ def main() -> None:
|
||||
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']}"
|
||||
f"counters: agree={s['counter_agree']} "
|
||||
f"conflict={s['counter_conflict']} weak={s['counter_weak']} "
|
||||
f"no_data={s['counter_no_data']} / 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']}"
|
||||
f"qualitative gaps: empty={s['heroes_empty_relations']} "
|
||||
f"thin(<={1} edge)={s['heroes_thin_relations']} / heroes={s['heroes_total']}"
|
||||
)
|
||||
if web_tops_audit:
|
||||
ws = web_tops_audit["summary"]
|
||||
print(
|
||||
"web matchup tops × OpenDota: "
|
||||
f"agree={ws['agree']} conflict={ws['conflict']} weak={ws['weak']} "
|
||||
f"/ rows={ws['counter_rows']} heroes={ws['heroes']}"
|
||||
)
|
||||
print(f" note: {web_tops_audit['manual_review_note']}")
|
||||
print("cache gaps:")
|
||||
for k, v in missing_cache.items():
|
||||
print(f" {k}: {len(v)}")
|
||||
@@ -449,7 +344,7 @@ def main() -> None:
|
||||
if row["counters_out"] + row["counters_in"] + row["synergies"] == 0:
|
||||
print(
|
||||
f" {row['name_loc']}({row['key']}) "
|
||||
f"suggest={row['cross_source_suggestions']}"
|
||||
f"suggest={row['stratz_suggestions']}"
|
||||
)
|
||||
print(f"\nwrote {args.out}")
|
||||
|
||||
|
||||
+5689
-5607
File diff suppressed because it is too large
Load Diff
@@ -1,86 +0,0 @@
|
||||
"""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()
|
||||
@@ -1,281 +0,0 @@
|
||||
"""Cross-check STRATZ web matchup tops against OpenDota hero matchups.
|
||||
|
||||
Web-only observation evidence — never merge into relations.json or recommend.
|
||||
|
||||
Statuses:
|
||||
agree — OpenDota baseline-adjusted advantage agrees with STRATZ direction
|
||||
conflict — OpenDota disagrees with STRATZ direction (enough games)
|
||||
weak — OpenDota sample too small or advantage near zero
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Defaults aligned with shared/audit_relations.py thresholds.
|
||||
DEFAULT_MIN_GAMES = 80
|
||||
DEFAULT_ODOTA_AGREE = 0.015
|
||||
DEFAULT_ODOTA_DISAGREE = -0.015
|
||||
|
||||
|
||||
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 classify_cross_source(
|
||||
*,
|
||||
stratz_signed: float,
|
||||
odota_adv_val: float | None,
|
||||
odota_games: int,
|
||||
min_games: int = DEFAULT_MIN_GAMES,
|
||||
odota_agree: float = DEFAULT_ODOTA_AGREE,
|
||||
odota_disagree: float = DEFAULT_ODOTA_DISAGREE,
|
||||
) -> str:
|
||||
"""Classify whether OpenDota agrees with a STRATZ signed advantage.
|
||||
|
||||
``stratz_signed`` > 0 means STRATZ says A is favored vs B (counters).
|
||||
For countered rows the caller should pass the original vs advantage
|
||||
(positive = A still favored), not the negated display value.
|
||||
"""
|
||||
if odota_adv_val is None or odota_games < min_games:
|
||||
return "weak"
|
||||
if stratz_signed >= 0:
|
||||
if odota_adv_val >= odota_agree:
|
||||
return "agree"
|
||||
if odota_adv_val <= odota_disagree:
|
||||
return "conflict"
|
||||
return "weak"
|
||||
if odota_adv_val <= -odota_agree:
|
||||
return "agree"
|
||||
if odota_adv_val >= -odota_disagree:
|
||||
return "conflict"
|
||||
return "weak"
|
||||
|
||||
|
||||
def enrich_entry_cross(
|
||||
entry: dict,
|
||||
*,
|
||||
hero_id: int,
|
||||
kind: str,
|
||||
by_odota: dict,
|
||||
baseline: dict[int, float],
|
||||
min_games: int = DEFAULT_MIN_GAMES,
|
||||
) -> dict:
|
||||
"""Attach ``cross`` quality blob to one counters/countered/synergies row.
|
||||
|
||||
For ``countered`` rows, STRATZ stores negated advantage for display; we
|
||||
restore the original vs-sign for classification (``-advantage``).
|
||||
Synergies skip OpenDota (no teammate WR in matchups.json).
|
||||
"""
|
||||
out = dict(entry)
|
||||
peer = int(entry.get("hero_id") or 0)
|
||||
if kind == "synergies" or peer <= 0 or hero_id <= 0:
|
||||
out["cross"] = {"status": "weak", "reason": "no_odota_synergy"}
|
||||
return out
|
||||
|
||||
o_adv, o_games = odota_adv(by_odota, baseline, hero_id, peer)
|
||||
raw_adv = float(entry.get("advantage") or 0.0)
|
||||
# countered display advantage is already negated; restore original vs sign.
|
||||
stratz_signed = -raw_adv if kind == "countered" else raw_adv
|
||||
status = classify_cross_source(
|
||||
stratz_signed=stratz_signed,
|
||||
odota_adv_val=o_adv,
|
||||
odota_games=o_games,
|
||||
min_games=min_games,
|
||||
)
|
||||
out["cross"] = {
|
||||
"status": status,
|
||||
"opendota_adv": None if o_adv is None else round(o_adv, 4),
|
||||
"opendota_games": o_games,
|
||||
"min_games": min_games,
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def enrich_hero_matchups_cross(
|
||||
cell: dict,
|
||||
*,
|
||||
hero_id: int,
|
||||
by_odota: dict,
|
||||
baseline: dict[int, float],
|
||||
min_games: int = DEFAULT_MIN_GAMES,
|
||||
) -> dict:
|
||||
"""Return a shallow-copied hero matchup cell with per-row ``cross`` fields."""
|
||||
out = {
|
||||
"counters": [
|
||||
enrich_entry_cross(
|
||||
e,
|
||||
hero_id=hero_id,
|
||||
kind="counters",
|
||||
by_odota=by_odota,
|
||||
baseline=baseline,
|
||||
min_games=min_games,
|
||||
)
|
||||
for e in (cell.get("counters") or [])
|
||||
if isinstance(e, dict)
|
||||
],
|
||||
"countered": [
|
||||
enrich_entry_cross(
|
||||
e,
|
||||
hero_id=hero_id,
|
||||
kind="countered",
|
||||
by_odota=by_odota,
|
||||
baseline=baseline,
|
||||
min_games=min_games,
|
||||
)
|
||||
for e in (cell.get("countered") or [])
|
||||
if isinstance(e, dict)
|
||||
],
|
||||
"synergies": [
|
||||
enrich_entry_cross(
|
||||
e,
|
||||
hero_id=hero_id,
|
||||
kind="synergies",
|
||||
by_odota=by_odota,
|
||||
baseline=baseline,
|
||||
min_games=min_games,
|
||||
)
|
||||
for e in (cell.get("synergies") or [])
|
||||
if isinstance(e, dict)
|
||||
],
|
||||
}
|
||||
for k in ("fetched_at", "stale"):
|
||||
if k in cell:
|
||||
out[k] = cell[k]
|
||||
return out
|
||||
|
||||
|
||||
def summarize_cross_rows(rows: list[dict]) -> dict[str, int]:
|
||||
counts = {"agree": 0, "conflict": 0, "weak": 0}
|
||||
for e in rows:
|
||||
status = ((e.get("cross") or {}).get("status")) or "weak"
|
||||
if status not in counts:
|
||||
status = "weak"
|
||||
counts[status] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def audit_matchup_tops(
|
||||
tops: dict,
|
||||
odota: dict,
|
||||
*,
|
||||
id_to_key: dict[int, str],
|
||||
key_to_id: dict[str, int],
|
||||
names: dict[str, str],
|
||||
min_games: int = DEFAULT_MIN_GAMES,
|
||||
) -> dict[str, Any]:
|
||||
"""Audit STRATZ web tops vs OpenDota; return report section (no file IO)."""
|
||||
by_odota = odota.get("by_hero") or {}
|
||||
baseline = build_baseline(by_odota)
|
||||
by_hero = tops.get("by_hero") or {}
|
||||
|
||||
pairs: list[dict] = []
|
||||
summary = {
|
||||
"heroes": 0,
|
||||
"counter_rows": 0,
|
||||
"agree": 0,
|
||||
"conflict": 0,
|
||||
"weak": 0,
|
||||
}
|
||||
|
||||
for key, cell in sorted(by_hero.items()):
|
||||
if not isinstance(cell, dict):
|
||||
continue
|
||||
hid = key_to_id.get(key)
|
||||
if hid is None:
|
||||
# Prefer embedded id when key map lags new heroes.
|
||||
hid = int(cell.get("id") or 0) or None
|
||||
if hid is None:
|
||||
continue
|
||||
summary["heroes"] += 1
|
||||
enriched = enrich_hero_matchups_cross(
|
||||
cell,
|
||||
hero_id=hid,
|
||||
by_odota=by_odota,
|
||||
baseline=baseline,
|
||||
min_games=min_games,
|
||||
)
|
||||
for kind in ("counters", "countered"):
|
||||
for e in enriched.get(kind) or []:
|
||||
summary["counter_rows"] += 1
|
||||
cross = e.get("cross") or {}
|
||||
status = cross.get("status") or "weak"
|
||||
summary[status] = summary.get(status, 0) + 1
|
||||
peer_id = int(e.get("hero_id") or 0)
|
||||
peer_key = id_to_key.get(peer_id, str(peer_id))
|
||||
pairs.append(
|
||||
{
|
||||
"hero": key,
|
||||
"hero_loc": names.get(key, key),
|
||||
"peer": peer_key,
|
||||
"peer_loc": names.get(peer_key, peer_key),
|
||||
"kind": kind,
|
||||
"stratz_advantage": e.get("advantage"),
|
||||
"stratz_wr": e.get("wr"),
|
||||
"stratz_games": e.get("games"),
|
||||
"status": status,
|
||||
"opendota_adv": cross.get("opendota_adv"),
|
||||
"opendota_games": cross.get("opendota_games"),
|
||||
}
|
||||
)
|
||||
|
||||
conflicts = [p for p in pairs if p["status"] == "conflict"]
|
||||
conflicts.sort(
|
||||
key=lambda r: (
|
||||
abs(float(r.get("stratz_advantage") or 0)),
|
||||
-(int(r.get("opendota_games") or 0)),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
agrees = [p for p in pairs if p["status"] == "agree"]
|
||||
agrees.sort(
|
||||
key=lambda r: (
|
||||
abs(float(r.get("stratz_advantage") or 0)),
|
||||
-(int(r.get("opendota_games") or 0)),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"thresholds": {"min_games": min_games},
|
||||
"manual_review_note": (
|
||||
"Dota2ProTracker (7k+ MMR / pro) is a manual high-MMR reference for "
|
||||
"conflict rows; not automated (login-gated)."
|
||||
),
|
||||
"conflicts": conflicts[:100],
|
||||
"agrees_sample": agrees[:40],
|
||||
}
|
||||
@@ -34,6 +34,8 @@ WEB_ASSETS = WEB_ROOT / "assets"
|
||||
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"
|
||||
# Valve hero-selection role filter icons, chroma keyed to transparent PNGs.
|
||||
ROLE_ICONS = WEB_ASSETS / "role_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.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Unit tests for STRATZ matchup ranking + OpenDota cross classification.
|
||||
"""Unit tests for STRATZ matchup ranking + cache helpers.
|
||||
|
||||
Run from repo root:
|
||||
python -m unittest shared.tests.test_matchup_cross -v
|
||||
python -m unittest shared.tests.test_stratz_matchups -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,11 +13,6 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from shared.matchup_cross import ( # noqa: E402
|
||||
classify_cross_source,
|
||||
enrich_entry_cross,
|
||||
enrich_hero_matchups_cross,
|
||||
)
|
||||
from web.fetch_stratz_meta import ( # noqa: E402
|
||||
annotate_matchup_cell,
|
||||
build_matchup_file_payload,
|
||||
@@ -125,80 +120,5 @@ class MatchupCacheHelpersTests(unittest.TestCase):
|
||||
self.assertIn("antimage", loaded)
|
||||
|
||||
|
||||
class CrossClassifyTests(unittest.TestCase):
|
||||
def test_agree_conflict_weak(self) -> None:
|
||||
self.assertEqual(
|
||||
classify_cross_source(
|
||||
stratz_signed=5.0, odota_adv_val=0.04, odota_games=200
|
||||
),
|
||||
"agree",
|
||||
)
|
||||
self.assertEqual(
|
||||
classify_cross_source(
|
||||
stratz_signed=5.0, odota_adv_val=-0.04, odota_games=200
|
||||
),
|
||||
"conflict",
|
||||
)
|
||||
self.assertEqual(
|
||||
classify_cross_source(
|
||||
stratz_signed=5.0, odota_adv_val=0.04, odota_games=10
|
||||
),
|
||||
"weak",
|
||||
)
|
||||
|
||||
def test_countered_uses_restored_sign(self) -> None:
|
||||
# Display advantage for countered is +8 (negated); original vs was -8.
|
||||
by_odota = {
|
||||
"1": {
|
||||
"2": {"games": 200, "wins": 80}, # AM wr 40% vs peer
|
||||
}
|
||||
}
|
||||
# Baseline for hero 1 ≈ 0.4 from only this matchup.
|
||||
from shared.matchup_cross import build_baseline
|
||||
|
||||
baseline = build_baseline(by_odota)
|
||||
entry = enrich_entry_cross(
|
||||
{
|
||||
"hero_id": 2,
|
||||
"games": 100,
|
||||
"wins": 40,
|
||||
"advantage": 8.0,
|
||||
"wr": 0.6,
|
||||
},
|
||||
hero_id=1,
|
||||
kind="countered",
|
||||
by_odota=by_odota,
|
||||
baseline=baseline,
|
||||
min_games=80,
|
||||
)
|
||||
# odota_adv = 0.4 - 0.4 = 0 → weak
|
||||
self.assertEqual(entry["cross"]["status"], "weak")
|
||||
|
||||
def test_enrich_hero_keeps_stale_flag(self) -> None:
|
||||
cell = {
|
||||
"counters": [
|
||||
{
|
||||
"hero_id": 94,
|
||||
"games": 100,
|
||||
"wins": 60,
|
||||
"advantage": 3.0,
|
||||
"wr": 0.6,
|
||||
}
|
||||
],
|
||||
"countered": [],
|
||||
"synergies": [],
|
||||
"fetched_at": "t0",
|
||||
"stale": True,
|
||||
}
|
||||
out = enrich_hero_matchups_cross(
|
||||
cell,
|
||||
hero_id=1,
|
||||
by_odota={},
|
||||
baseline={},
|
||||
)
|
||||
self.assertTrue(out["stale"])
|
||||
self.assertEqual(out["cross"] if False else out["counters"][0]["cross"]["status"], "weak")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user