v0.5.59: item counter evidence for fears, catch up Web features to site version.

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>
This commit is contained in:
voson
2026-07-29 02:11:49 +08:00
co-authored by Cursor
parent 37769580f5
commit 3ec8007077
72 changed files with 20669 additions and 2105 deletions
+299
View File
@@ -0,0 +1,299 @@
"""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()