"""Post-match player pages: OpenDota → local profile/match JSON (+ optional publish). Triggered from gsi_watch on POST_GAME. Writes: pc/player_pages/{account_id}/profile.json pc/player_pages/{account_id}/matches/{match_id}.json When player_pages.public_share is true, also POSTs to the site ingest so OSS serves the same paths for /players/{account_id}[/{match_id}]. Not used by recommend / item_suggest. """ from __future__ import annotations import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import json import threading import time import urllib.error import urllib.request from datetime import datetime, timezone from typing import Any from shared.grid import hero_table from shared.http_utils import UA, write_json_atomic from shared.paths import PC_PLAYER_PAGES OPENDOTA = "https://api.opendota.com/api" POST_GAME = "DOTA_GAMERULES_STATE_POST_GAME" _lock = threading.Lock() _in_flight: set[str] = set() _done: set[str] = set() _hero_by_id: dict[int, dict] | None = None def _utc_now() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") def _pp_cfg(cfg: dict) -> dict: raw = cfg.get("player_pages") or {} return raw if isinstance(raw, dict) else {} def _hero_lookup() -> dict[int, dict]: global _hero_by_id if _hero_by_id is None: out: dict[int, dict] = {} for h in hero_table(): try: hid = int(h.get("id") or 0) except (TypeError, ValueError): continue if hid > 0: out[hid] = h _hero_by_id = out return _hero_by_id def _int(v: Any, default: int = 0) -> int: try: return int(v) except (TypeError, ValueError): return default def _item_ids(player: dict) -> list[int]: out: list[int] = [] for i in range(6): iid = _int(player.get(f"item_{i}"), 0) if iid > 0: out.append(iid) return out def _kda(kills: int, deaths: int, assists: int) -> float: return round((kills + assists) / max(deaths, 1), 1) def _mvp_score(p: dict) -> float: """Simple weighted score for MVP badge (not Valve's formula).""" k = _int(p.get("kills")) d = _int(p.get("deaths")) a = _int(p.get("assists")) dmg = _int(p.get("hero_damage")) nw = _int(p.get("net_worth")) return (k * 1.5 + a + dmg / 1000.0 + nw / 2000.0) / max(d, 1) def normalize_match( match: dict, *, focus_account_id: int | None = None, ) -> dict | None: """Build Climperor match-detail JSON from an OpenDota /matches/{id} payload.""" players_raw = match.get("players") if not isinstance(players_raw, list) or not players_raw: return None match_id = _int(match.get("match_id"), 0) if match_id <= 0: return None heroes = _hero_lookup() radiant_win = bool(match.get("radiant_win")) duration = _int(match.get("duration")) start_time = match.get("start_time") try: start_time_i = int(start_time) if start_time is not None else None except (TypeError, ValueError): start_time_i = None team_kills = [0, 0] team_nw = [0, 0] team_dmg = [0, 0] slim_players: list[dict] = [] for p in players_raw: if not isinstance(p, dict): continue slot = _int(p.get("player_slot")) is_radiant = slot < 128 side = 0 if is_radiant else 1 kills = _int(p.get("kills")) deaths = _int(p.get("deaths")) assists = _int(p.get("assists")) hero_damage = _int(p.get("hero_damage")) net_worth = _int(p.get("net_worth")) if net_worth <= 0: net_worth = _int(p.get("gold")) + _int(p.get("gold_spent")) team_kills[side] += kills team_nw[side] += net_worth team_dmg[side] += hero_damage hero_id = _int(p.get("hero_id")) hero = heroes.get(hero_id) or {} account_id = p.get("account_id") try: account_id_i = int(account_id) if account_id is not None else None except (TypeError, ValueError): account_id_i = None personaname = p.get("personaname") if isinstance(personaname, str): personaname = personaname.strip() or None else: personaname = None slim_players.append( { "account_id": account_id_i, "personaname": personaname, "hero_id": hero_id, "hero_key": hero.get("key"), "hero_name_loc": hero.get("name_loc") or hero.get("key"), "level": _int(p.get("level")), "kills": kills, "deaths": deaths, "assists": assists, "kda": _kda(kills, deaths, assists), "hero_damage": hero_damage, "net_worth": net_worth, "items": _item_ids(p), "is_radiant": is_radiant, "won": radiant_win if is_radiant else not radiant_win, "_mvp": _mvp_score(p), "_side": side, } ) if len(slim_players) < 2: return None for p in slim_players: side = p.pop("_side") tk = team_kills[side] or 1 td = team_dmg[side] or 1 p["participation"] = round((p["kills"] + p["assists"]) / tk, 3) p["damage_share"] = round(p["hero_damage"] / td, 3) mvp = max(slim_players, key=lambda r: r["_mvp"]) mvp_account = mvp.get("account_id") for p in slim_players: p["is_mvp"] = bool(mvp_account is not None and p.get("account_id") == mvp_account) del p["_mvp"] return { "match_id": match_id, "start_time": start_time_i, "duration": duration, "radiant_win": radiant_win, "radiant": {"kills": team_kills[0], "net_worth": team_nw[0]}, "dire": {"kills": team_kills[1], "net_worth": team_nw[1]}, "mvp_account_id": mvp_account, "players": slim_players, "focus_account_id": focus_account_id, "fetched_at": _utc_now(), "source": "opendota", } def match_summary_for_profile(detail: dict, account_id: int) -> dict | None: """One recent-match row for the player homepage.""" focus = None for p in detail.get("players") or []: if p.get("account_id") == account_id: focus = p break if focus is None: return None return { "match_id": detail["match_id"], "start_time": detail.get("start_time"), "duration": detail.get("duration"), "won": bool(focus.get("won")), "hero_id": focus.get("hero_id"), "hero_key": focus.get("hero_key"), "hero_name_loc": focus.get("hero_name_loc"), "kills": focus.get("kills"), "deaths": focus.get("deaths"), "assists": focus.get("assists"), "kda": focus.get("kda"), } def account_in_match(match: dict, account_id: int) -> bool: for p in match.get("players") or []: if not isinstance(p, dict): continue try: if int(p.get("account_id")) == account_id: return True except (TypeError, ValueError): continue return False def _fetch_opendota_match(match_id: int) -> dict | None: url = f"{OPENDOTA}/matches/{match_id}" req = urllib.request.Request(url, headers={"User-Agent": UA}) try: with urllib.request.urlopen(req, timeout=45) as resp: data = json.loads(resp.read().decode()) except urllib.error.HTTPError as e: if e.code == 404: return None raise except (urllib.error.URLError, TimeoutError, OSError, ValueError): return None if not isinstance(data, dict) or not data.get("players"): return None return data def profile_path(account_id: int) -> Path: return PC_PLAYER_PAGES / str(account_id) / "profile.json" def match_path(account_id: int, match_id: int) -> Path: return PC_PLAYER_PAGES / str(account_id) / "matches" / f"{match_id}.json" def load_profile(account_id: int) -> dict: path = profile_path(account_id) if not path.is_file(): return { "account_id": account_id, "personaname": None, "public_share": False, "updated_at": None, "recent": [], } try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError): data = {} if not isinstance(data, dict): data = {} data.setdefault("account_id", account_id) data.setdefault("recent", []) return data def upsert_profile( account_id: int, *, summary: dict, personaname: str | None, public_share: bool, recent_limit: int, ) -> dict: profile = load_profile(account_id) recent = [r for r in (profile.get("recent") or []) if isinstance(r, dict)] mid = summary["match_id"] recent = [r for r in recent if r.get("match_id") != mid] recent.insert(0, summary) profile["recent"] = recent[: max(1, recent_limit)] if personaname: profile["personaname"] = personaname profile["public_share"] = bool(public_share) profile["updated_at"] = _utc_now() profile["account_id"] = account_id write_json_atomic(profile_path(account_id), profile) return profile def publish_remote( *, account_id: int, match_id: int, publish_url: str, publish_secret: str = "", ) -> tuple[bool, str]: """POST ingest; returns (ok, message).""" url = (publish_url or "").strip() if not url: return False, "publish_url empty" body = json.dumps({"account_id": account_id, "match_id": match_id}).encode("utf-8") headers = { "User-Agent": UA, "Content-Type": "application/json", "Accept": "application/json", } secret = (publish_secret or "").strip() if secret: headers["X-Climperor-Publish-Secret"] = secret req = urllib.request.Request(url, data=body, headers=headers, method="POST") try: with urllib.request.urlopen(req, timeout=60) as resp: raw = resp.read().decode("utf-8", errors="replace") code = getattr(resp, "status", 200) except urllib.error.HTTPError as e: try: raw = e.read().decode("utf-8", errors="replace") except Exception: # noqa: BLE001 raw = str(e) return False, f"HTTP {e.code}: {raw[:200]}" except (urllib.error.URLError, TimeoutError, OSError) as e: return False, str(e) if code in (200, 201, 202): return True, raw[:200] or f"HTTP {code}" return False, f"HTTP {code}: {raw[:200]}" def process_post_game( cfg: dict, *, match_id: str | int, account_id: str | int | None, ) -> None: """Poll OpenDota, write local pages, optionally publish. Runs in a worker thread.""" pp = _pp_cfg(cfg) if not bool(pp.get("enabled", True)): return try: mid = int(match_id) except (TypeError, ValueError): print(f"[player_pages] skip bad match_id={match_id!r}", flush=True) return if mid <= 0: return try: aid = int(account_id) if account_id is not None else 0 except (TypeError, ValueError): aid = 0 if aid <= 0: print(f"[player_pages] skip match {mid}: no accountid (anonymous?)", flush=True) return key = f"{aid}:{mid}" with _lock: if key in _done or key in _in_flight: return _in_flight.add(key) try: attempts = max(1, _int(pp.get("poll_attempts"), 12)) base = max(5, _int(pp.get("poll_base_seconds"), 30)) recent_limit = max(1, _int(pp.get("recent_limit"), 30)) public_share = bool(pp.get("public_share", False)) print( f"[player_pages] fetching match {mid} for account {aid} " f"(up to {attempts} tries)…", flush=True, ) match: dict | None = None for i in range(attempts): if i > 0: delay = min(300, base * (2 ** min(i - 1, 3))) time.sleep(delay) try: match = _fetch_opendota_match(mid) except Exception as e: # noqa: BLE001 print(f"[player_pages] OpenDota error: {e}", flush=True) match = None if match and account_in_match(match, aid): break if match and not account_in_match(match, aid): print( f"[player_pages] match {mid} has no account {aid} — skip", flush=True, ) return match = None print( f"[player_pages] match {mid} not ready ({i + 1}/{attempts})", flush=True, ) if not match: print(f"[player_pages] gave up waiting for match {mid}", flush=True) return detail = normalize_match(match, focus_account_id=aid) if not detail: print(f"[player_pages] normalize failed for {mid}", flush=True) return write_json_atomic(match_path(aid, mid), detail) summary = match_summary_for_profile(detail, aid) if not summary: print(f"[player_pages] focus player missing in {mid}", flush=True) return personaname = None for p in detail["players"]: if p.get("account_id") == aid and p.get("personaname"): personaname = p["personaname"] break profile = upsert_profile( aid, summary=summary, personaname=personaname, public_share=public_share, recent_limit=recent_limit, ) local_origin = str(pp.get("local_web_origin") or "http://127.0.0.1:8765").rstrip( "/" ) print( f"[player_pages] saved {profile_path(aid)} + match {mid}", flush=True, ) print( f"[player_pages] local: {local_origin}/players/{aid}/{mid}", flush=True, ) if not public_share: print( "[player_pages] private (set player_pages.public_share=true to sync to site)", flush=True, ) else: pub_url = str(pp.get("publish_url") or "").strip() ok, msg = publish_remote( account_id=aid, match_id=mid, publish_url=pub_url, publish_secret=str(pp.get("publish_secret") or ""), ) if ok: print(f"[player_pages] published: {msg}", flush=True) else: print(f"[player_pages] publish failed: {msg}", flush=True) with _lock: _done.add(key) finally: with _lock: _in_flight.discard(key) def schedule_post_game( cfg: dict, *, match_id: str | int, account_id: str | int | None, ) -> None: """Fire-and-forget worker; safe to call from the GSI HTTP thread.""" pp = _pp_cfg(cfg) if not bool(pp.get("enabled", True)): return threading.Thread( target=process_post_game, kwargs={"cfg": cfg, "match_id": match_id, "account_id": account_id}, daemon=True, name=f"player_pages-{match_id}", ).start()