Add PC post-match player pages with opt-in public OSS sync.

Generate /players/{account_id}[/{match_id}] locally after POST_GAME via OpenDota; publish to OSS only when public_share is enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-07-31 11:01:32 +08:00
co-authored by Cursor
parent d6f7c3f0f5
commit 4a61aeeb26
16 changed files with 1684 additions and 23 deletions
+11
View File
@@ -146,6 +146,17 @@
"target_slots": 10,
"dump_payloads": true
},
"player_pages": {
"comment": "POST_GAME: poll OpenDota → local pc/player_pages/{account_id}/. public_share=true also POSTs /api/players/publish (OSS). Default private.",
"enabled": true,
"public_share": false,
"recent_limit": 30,
"poll_attempts": 12,
"poll_base_seconds": 30,
"publish_url": "https://dota2.refining.dev/api/players/publish",
"publish_secret": "",
"local_web_origin": "http://127.0.0.1:8765"
},
"overlay": {
"comment": "克/搭/补 marks + lineup analysis banner + post-lock item icon bar (no role tags under top-bar).",
"enabled": true,
+19
View File
@@ -37,6 +37,7 @@ from capture import append_gsi_payload, grab_frame, is_dota_foreground, raw_dir_
from common import ROOT, load_config, load_template_library
from draft_session import HERO_SELECTION, DraftSession, describe, gsi_slot, loc
from overlay import DraftOverlay
from player_pages import POST_GAME, schedule_post_game
from recognize import recognize_image
from roles import detect_roles
@@ -81,6 +82,8 @@ class Watcher:
except Exception as e: # noqa: BLE001
print(f"[draft] overlay disabled: {e}", flush=True)
self.overlay = None
# Dedup post-game player-page jobs per match (in addition to module lock).
self._player_page_matches: set[str] = set()
def on_payload(self, payload: dict) -> None:
if not self.connected:
@@ -123,7 +126,11 @@ class Watcher:
print(f"[gsi] map keys: {sorted(m.keys())}", flush=True)
print(f"[gsi] player keys: {sorted(p.keys())}", flush=True)
print(f"[gsi] self: {self.self_info} -> top-bar slot {gsi_slot(self.self_info)}", flush=True)
prev = self.last_state
self.last_state = state
# First transition into POST_GAME → build local /players pages.
if state == POST_GAME and prev != POST_GAME:
self._maybe_schedule_player_page(match_id)
if state not in self.trigger_states:
return
@@ -144,6 +151,18 @@ class Watcher:
except OSError as e:
print(f"[gsi] dump failed: {e}", flush=True)
def _maybe_schedule_player_page(self, match_id: str) -> None:
if not match_id or match_id == "no-match":
return
if match_id in self._player_page_matches:
return
self._player_page_matches.add(match_id)
schedule_post_game(
self.cfg,
match_id=match_id,
account_id=self.self_info.get("accountid"),
)
def track(self, match_id: str, state: str, key: str) -> dict | None:
"""Follow the draft from here to the end, recording every reveal."""
if not self.busy.acquire(blocking=False):
+493
View File
@@ -0,0 +1,493 @@
"""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()