v0.5.71: matches tab, streamer viewport video load, matchup cross-check.

Add pro watchlist matches page; load streamer clips by viewport tier with posters; harden STRATZ matchup refresh and OpenDota cross hints.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-29 16:03:49 +08:00
co-authored by Cursor
parent d9c7b4b7b1
commit d2cfcd7461
10 changed files with 1085 additions and 153 deletions
+115 -35
View File
@@ -1,13 +1,14 @@
"""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
- 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)
Usage:
python audit_relations.py
python audit_relations.py --min-games 80 --out data/relations_audit.json
python shared/audit_relations.py
python shared/audit_relations.py --min-games 80 --out shared/data/relations_audit.json
"""
from __future__ import annotations
@@ -22,12 +23,23 @@ import json
from datetime import datetime, timezone
from shared.grid import hero_table
from shared.paths import SHARED_DATA
from shared.matchup_cross import audit_matchup_tops
from shared.paths import DATA, 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"
def _resolve_cache(*candidates: Path) -> Path:
for p in candidates:
if p.is_file():
return p
return candidates[0]
def _load_json_safe(path: Path) -> dict:
@@ -109,12 +121,18 @@ def main() -> None:
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()
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)
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 {}
@@ -123,7 +141,6 @@ def main() -> None:
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:
@@ -154,7 +171,7 @@ def main() -> None:
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)):
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 = []
@@ -182,13 +199,26 @@ def main() -> None:
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"})
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,
"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"})
@@ -199,7 +229,6 @@ def main() -> None:
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]
@@ -210,19 +239,28 @@ def main() -> None:
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
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.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}
@@ -249,20 +287,18 @@ def main() -> None:
],
}
# 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
k for k in all_keys if counters_from[k] + counters_to[k] + syn_count[k] <= 1
]
empty = [
k for k in all_keys
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
@@ -278,31 +314,60 @@ def main() -> None:
}
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"]),
key=lambda r: (
r["counters_out"] + r["counters_in"] + r["synergies"],
-r["cross_source_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
try:
return str(path.relative_to(ROOT)).replace("\\", "/")
except ValueError:
return str(path).replace("\\", "/")
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,
"path": _rel(matchups_path),
"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,
"path": _rel(stratz_path),
"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,
"path": _rel(syn_path),
"source": syn_file.get("source"),
"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,
@@ -338,13 +403,20 @@ def main() -> None:
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"}],
"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,
"web_matchup_tops_cross": web_tops_audit,
}
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")
args.out.write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
s = report["summary"]
print("=== relations audit ===")
@@ -361,6 +433,14 @@ def main() -> None:
f"qualitative gaps: empty={s['heroes_empty_relations']} thin(<={1} edge)={s['heroes_thin_relations']} "
f"/ 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)}")
+281
View File
@@ -0,0 +1,281 @@
"""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],
}
View File
+204
View File
@@ -0,0 +1,204 @@
"""Unit tests for STRATZ matchup ranking + OpenDota cross classification.
Run from repo root:
python -m unittest shared.tests.test_matchup_cross -v
"""
from __future__ import annotations
import sys
import tempfile
import unittest
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,
load_previous_matchups,
rank_matchup_lists,
write_json_atomic,
)
class RankMatchupListsTests(unittest.TestCase):
def test_counters_sorted_by_advantage_not_raw_wr(self) -> None:
# Wraith King: wr < 0.5 but positive relative advantage — still a counter.
vs = [
{
"hero_id": 42,
"games": 3286,
"wins": 1597,
"advantage": 4.444,
"wr": 0.486,
},
{
"hero_id": 94,
"games": 1070,
"wins": 649,
"advantage": 12.994,
"wr": 0.607,
},
{
"hero_id": 34,
"games": 1171,
"wins": 723,
"advantage": 7.318,
"wr": 0.617,
},
]
ranked = rank_matchup_lists(vs, [], take=3)
self.assertEqual(
[e["hero_id"] for e in ranked["counters"]],
[94, 34, 42],
)
wk = ranked["counters"][2]
self.assertLess(wk["wr"], 0.5)
self.assertGreater(wk["advantage"], 0)
def test_countered_mirrors_lowest_advantage(self) -> None:
vs = [
{"hero_id": 1, "games": 100, "wins": 60, "advantage": 5.0, "wr": 0.6},
{"hero_id": 2, "games": 100, "wins": 40, "advantage": -8.0, "wr": 0.4},
{"hero_id": 3, "games": 50, "wins": 20, "advantage": -3.0, "wr": 0.4},
]
ranked = rank_matchup_lists(vs, [], take=2)
fear = ranked["countered"]
self.assertEqual([e["hero_id"] for e in fear], [2, 3])
self.assertAlmostEqual(fear[0]["advantage"], 8.0)
self.assertAlmostEqual(fear[0]["wr"], 0.6)
def test_synergies_sorted_desc(self) -> None:
with_rows = [
{"hero_id": 10, "games": 10, "wins": 5, "synergy": 1.0, "wr": 0.5},
{"hero_id": 11, "games": 10, "wins": 6, "synergy": 4.5, "wr": 0.6},
]
ranked = rank_matchup_lists([], with_rows, take=2)
self.assertEqual([e["hero_id"] for e in ranked["synergies"]], [11, 10])
class MatchupCacheHelpersTests(unittest.TestCase):
def test_annotate_and_payload_metadata(self) -> None:
cell = annotate_matchup_cell(
{"counters": [{"hero_id": 1}], "countered": [], "synergies": []},
fetched_at="2026-07-28T00:00:00+00:00",
stale=True,
)
self.assertTrue(cell["stale"])
self.assertEqual(cell["fetched_at"], "2026-07-28T00:00:00+00:00")
payload = build_matchup_file_payload(
{"antimage": cell},
take=12,
match_limit=50,
started_at="2026-07-28T00:00:00+00:00",
finished_at="2026-07-28T01:00:00+00:00",
stats={"heroes": 1, "ok": 0, "failed": 1, "stale_kept": 1},
)
self.assertEqual(payload["scope"]["kind"], "global_aggregate")
self.assertIn("global", payload["scope"]["kind"])
self.assertTrue(payload["scope"]["label_zh"])
self.assertEqual(payload["stats"]["stale_kept"], 1)
def test_atomic_write_and_resume_load(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "stratz_matchup_tops.json"
payload = build_matchup_file_payload(
{
"antimage": annotate_matchup_cell(
{"counters": [], "countered": [], "synergies": []},
fetched_at="t0",
)
},
take=12,
match_limit=50,
started_at="t0",
finished_at="t0",
)
write_json_atomic(path, payload)
loaded = load_previous_matchups(path)
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()