Ship OpenDota counter-stats reordering for feared items, finalize SITE_VERSION/docs for rankings/streamers/trends/matches/mechanics and draft archetypes, and ignore regenerable Web data caches. Co-authored-by: Cursor <cursoragent@cursor.com>
300 lines
9.3 KiB
Python
300 lines
9.3 KiB
Python
"""Fetch adjusted enemy-item evidence for each hero from OpenDota Explorer.
|
|
|
|
For every hero, this compares enemy-team final-inventory item presence against
|
|
the same item's baseline across all teams in the same recent match window:
|
|
|
|
buy_lift = P(item | against hero) - P(item | any team)
|
|
win_delta = P(win | item, against hero) - P(win | item, any team)
|
|
|
|
These are observational signals, not causal counter claims. The output is an
|
|
optional cache consumed by item_fears.py to corroborate and gently rerank its
|
|
mechanism-based candidates.
|
|
|
|
Usage:
|
|
python fetch_item_counter_stats.py
|
|
python fetch_item_counter_stats.py --matches 20000 --min-games 100
|
|
python fetch_item_counter_stats.py --print-sql
|
|
python fetch_item_counter_stats.py --soft-fail
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
from common import DATA
|
|
from grid import hero_table
|
|
|
|
API = "https://api.opendota.com/api/explorer"
|
|
ITEMS_META = DATA / "items_meta.json"
|
|
OUT = DATA / "item_counter_stats.json"
|
|
|
|
|
|
def build_sql(matches: int, min_games: int) -> str:
|
|
"""Build one bounded aggregate query; integer args are caller-validated."""
|
|
return f"""
|
|
WITH recent_ids AS (
|
|
SELECT DISTINCT match_id
|
|
FROM player_matches
|
|
ORDER BY match_id DESC
|
|
LIMIT {matches}
|
|
),
|
|
recent AS (
|
|
SELECT m.match_id, m.radiant_win
|
|
FROM matches m
|
|
JOIN recent_ids r ON r.match_id = m.match_id
|
|
),
|
|
players AS (
|
|
SELECT
|
|
p.match_id,
|
|
p.hero_id,
|
|
(p.player_slot < 128) AS is_radiant,
|
|
p.item_0, p.item_1, p.item_2,
|
|
p.item_3, p.item_4, p.item_5
|
|
FROM player_matches p
|
|
JOIN recent r ON r.match_id = p.match_id
|
|
WHERE p.hero_id IS NOT NULL
|
|
),
|
|
teams AS (
|
|
SELECT DISTINCT match_id, is_radiant
|
|
FROM players
|
|
),
|
|
team_outcomes AS (
|
|
SELECT
|
|
t.match_id,
|
|
t.is_radiant,
|
|
CASE WHEN t.is_radiant THEN r.radiant_win ELSE NOT r.radiant_win END AS won
|
|
FROM teams t
|
|
JOIN recent r ON r.match_id = t.match_id
|
|
),
|
|
team_items AS (
|
|
SELECT DISTINCT
|
|
p.match_id,
|
|
p.is_radiant,
|
|
x.item_id
|
|
FROM players p
|
|
CROSS JOIN LATERAL (
|
|
VALUES (p.item_0), (p.item_1), (p.item_2),
|
|
(p.item_3), (p.item_4), (p.item_5)
|
|
) AS x(item_id)
|
|
WHERE x.item_id IS NOT NULL AND x.item_id > 0
|
|
),
|
|
hero_totals AS (
|
|
SELECT hero_id, COUNT(*) AS target_games
|
|
FROM players
|
|
GROUP BY hero_id
|
|
),
|
|
global_total AS (
|
|
SELECT COUNT(*) AS team_games
|
|
FROM team_outcomes
|
|
),
|
|
global_items AS (
|
|
SELECT
|
|
ti.item_id,
|
|
COUNT(*) AS item_games,
|
|
SUM(CASE WHEN o.won THEN 1 ELSE 0 END) AS item_wins
|
|
FROM team_items ti
|
|
JOIN team_outcomes o
|
|
ON o.match_id = ti.match_id
|
|
AND o.is_radiant = ti.is_radiant
|
|
GROUP BY ti.item_id
|
|
),
|
|
target_items AS (
|
|
SELECT
|
|
p.hero_id,
|
|
ti.item_id,
|
|
COUNT(*) AS item_games,
|
|
SUM(CASE WHEN o.won THEN 1 ELSE 0 END) AS item_wins
|
|
FROM players p
|
|
JOIN team_items ti
|
|
ON ti.match_id = p.match_id
|
|
AND ti.is_radiant <> p.is_radiant
|
|
JOIN team_outcomes o
|
|
ON o.match_id = ti.match_id
|
|
AND o.is_radiant = ti.is_radiant
|
|
GROUP BY p.hero_id, ti.item_id
|
|
HAVING COUNT(*) >= {min_games}
|
|
)
|
|
SELECT
|
|
t.hero_id,
|
|
t.item_id,
|
|
h.target_games,
|
|
t.item_games,
|
|
t.item_wins,
|
|
g.team_games AS global_team_games,
|
|
gi.item_games AS global_item_games,
|
|
gi.item_wins AS global_item_wins
|
|
FROM target_items t
|
|
JOIN hero_totals h ON h.hero_id = t.hero_id
|
|
JOIN global_items gi ON gi.item_id = t.item_id
|
|
CROSS JOIN global_total g
|
|
ORDER BY t.hero_id, t.item_games DESC
|
|
""".strip()
|
|
|
|
|
|
def explorer(sql: str, *, timeout: int = 240) -> dict:
|
|
params = {"sql": sql}
|
|
api_key = os.environ.get("OPENDOTA_API_KEY", "").strip()
|
|
if api_key:
|
|
params["api_key"] = api_key
|
|
url = API + "?" + urllib.parse.urlencode(params)
|
|
req = urllib.request.Request(url, headers={"User-Agent": "climperor"})
|
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
payload = json.loads(response.read().decode())
|
|
if not isinstance(payload, dict):
|
|
raise RuntimeError("OpenDota Explorer returned a non-object payload")
|
|
if payload.get("error") or payload.get("err"):
|
|
raise RuntimeError(str(payload.get("error") or payload.get("err")))
|
|
return payload
|
|
|
|
|
|
def item_catalog() -> dict[int, dict]:
|
|
raw = json.loads(ITEMS_META.read_text(encoding="utf-8"))
|
|
out: dict[int, dict] = {}
|
|
for raw_id, row in (raw.get("items") or {}).items():
|
|
if not isinstance(row, dict) or not row.get("key"):
|
|
continue
|
|
item_id = int(row.get("id") or raw_id)
|
|
out[item_id] = {
|
|
"key": str(row["key"]),
|
|
"name_loc": str(row.get("name_loc") or row.get("dname") or row["key"]),
|
|
"cost": int(row.get("cost") or 0),
|
|
}
|
|
return out
|
|
|
|
|
|
def build_payload(
|
|
rows: list[dict],
|
|
*,
|
|
matches: int,
|
|
min_games: int,
|
|
min_cost: int,
|
|
) -> dict:
|
|
heroes = {int(row["id"]): row["key"] for row in hero_table()}
|
|
items = item_catalog()
|
|
by_hero: dict[str, list[dict]] = {key: [] for key in heroes.values()}
|
|
|
|
for row in rows:
|
|
hero_key = heroes.get(int(row["hero_id"]))
|
|
item = items.get(int(row["item_id"]))
|
|
if not hero_key or not item or item["cost"] < min_cost:
|
|
continue
|
|
|
|
target_games = int(row["target_games"])
|
|
item_games = int(row["item_games"])
|
|
item_wins = int(row["item_wins"])
|
|
global_team_games = int(row["global_team_games"])
|
|
global_item_games = int(row["global_item_games"])
|
|
global_item_wins = int(row["global_item_wins"])
|
|
if not all((target_games, item_games, global_team_games, global_item_games)):
|
|
continue
|
|
|
|
buy_rate = item_games / target_games
|
|
global_buy_rate = global_item_games / global_team_games
|
|
win_rate = item_wins / item_games
|
|
global_win_rate = global_item_wins / global_item_games
|
|
by_hero[hero_key].append(
|
|
{
|
|
"item": item["key"],
|
|
"name_loc": item["name_loc"],
|
|
"games": item_games,
|
|
"wins": item_wins,
|
|
"target_games": target_games,
|
|
"global_team_games": global_team_games,
|
|
"global_item_games": global_item_games,
|
|
"global_item_wins": global_item_wins,
|
|
"buy_rate": round(buy_rate, 6),
|
|
"global_buy_rate": round(global_buy_rate, 6),
|
|
"buy_lift": round(buy_rate - global_buy_rate, 6),
|
|
"win_rate": round(win_rate, 6),
|
|
"global_win_rate": round(global_win_rate, 6),
|
|
"win_delta": round(win_rate - global_win_rate, 6),
|
|
}
|
|
)
|
|
|
|
for entries in by_hero.values():
|
|
entries.sort(
|
|
key=lambda row: (
|
|
-float(row["buy_lift"]),
|
|
-float(row["win_delta"]),
|
|
-int(row["games"]),
|
|
str(row["item"]),
|
|
)
|
|
)
|
|
|
|
return {
|
|
"meta": {
|
|
"source": "opendota_explorer",
|
|
"attribution": "https://www.opendota.com",
|
|
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
|
"window_matches": matches,
|
|
"min_games": min_games,
|
|
"min_cost": min_cost,
|
|
"method": "enemy final inventory vs same-item global team baseline",
|
|
"caveat": "observational; adjusted for item baseline, not duration, rank, role, or economy",
|
|
},
|
|
"by_hero": by_hero,
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--matches", type=int, default=20_000)
|
|
ap.add_argument("--min-games", type=int, default=100)
|
|
ap.add_argument("--min-cost", type=int, default=1_400)
|
|
ap.add_argument("--out", type=Path, default=OUT)
|
|
ap.add_argument("--print-sql", action="store_true")
|
|
ap.add_argument(
|
|
"--soft-fail",
|
|
action="store_true",
|
|
help="keep an existing cache and exit 0 when OpenDota is unavailable",
|
|
)
|
|
args = ap.parse_args()
|
|
|
|
if args.matches < 1 or args.min_games < 1 or args.min_cost < 0:
|
|
raise SystemExit("matches/min-games must be positive and min-cost non-negative")
|
|
sql = build_sql(args.matches, args.min_games)
|
|
if args.print_sql:
|
|
print(sql)
|
|
return
|
|
if not ITEMS_META.is_file():
|
|
raise SystemExit(f"missing {ITEMS_META}; run: python fetch_items_meta.py")
|
|
|
|
try:
|
|
payload = explorer(sql)
|
|
rows = payload.get("rows") or []
|
|
if not isinstance(rows, list) or not rows:
|
|
raise RuntimeError("OpenDota Explorer returned no rows")
|
|
output = build_payload(
|
|
rows,
|
|
matches=args.matches,
|
|
min_games=args.min_games,
|
|
min_cost=args.min_cost,
|
|
)
|
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
args.out.write_text(
|
|
json.dumps(output, ensure_ascii=False, indent=2) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
nonempty = sum(bool(v) for v in output["by_hero"].values())
|
|
print(
|
|
f"done: {len(rows)} rows, {nonempty} heroes -> {args.out}",
|
|
flush=True,
|
|
)
|
|
except (OSError, TimeoutError, urllib.error.URLError, RuntimeError) as exc:
|
|
if args.soft_fail:
|
|
state = "keeping existing cache" if args.out.is_file() else "no cache available"
|
|
print(f"warn: item counter stats unavailable ({exc}); {state}", flush=True)
|
|
return
|
|
raise
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|