Players get a fast TTL-backed homepage (local profile / Cloudflare D1) with dense UI polish; login unlocks /home without blocking on every OpenDota refresh. Co-authored-by: Cursor <cursoragent@cursor.com>
328 lines
10 KiB
Python
328 lines
10 KiB
Python
"""Player homepage aggregates (career / recent20 / heroes / activity / peers).
|
|
|
|
Pure helpers used by pc/player_pages.py. Not used by recommend / item_suggest.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
|
|
def _int(v: Any, default: int = 0) -> int:
|
|
try:
|
|
return int(v)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def _float(v: Any, default: float = 0.0) -> float:
|
|
try:
|
|
return float(v)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def kda(kills: int, deaths: int, assists: int) -> float:
|
|
return round((kills + assists) / max(deaths, 1), 1)
|
|
|
|
|
|
def winrate(wins: int, losses: int) -> float | None:
|
|
total = wins + losses
|
|
if total <= 0:
|
|
return None
|
|
return round(wins / total * 1000) / 10
|
|
|
|
|
|
def aggregate_from_rows(rows: list[dict], *, limit: int | None = None) -> dict:
|
|
"""Aggregate match summary rows into a stats snapshot."""
|
|
sample = list(rows)
|
|
if limit is not None:
|
|
sample = sample[: max(0, limit)]
|
|
wins = 0
|
|
losses = 0
|
|
kills = deaths = assists = 0
|
|
gpm_sum = xpm_sum = dmg_sum = 0
|
|
gpm_n = xpm_n = dmg_n = 0
|
|
heroes: list[dict] = []
|
|
for r in sample:
|
|
if not isinstance(r, dict):
|
|
continue
|
|
if r.get("won"):
|
|
wins += 1
|
|
else:
|
|
losses += 1
|
|
k = _int(r.get("kills"))
|
|
d = _int(r.get("deaths"))
|
|
a = _int(r.get("assists"))
|
|
kills += k
|
|
deaths += d
|
|
assists += a
|
|
if r.get("gpm") is not None:
|
|
gpm_sum += _int(r.get("gpm"))
|
|
gpm_n += 1
|
|
if r.get("xpm") is not None:
|
|
xpm_sum += _int(r.get("xpm"))
|
|
xpm_n += 1
|
|
if r.get("hero_damage") is not None:
|
|
dmg_sum += _int(r.get("hero_damage"))
|
|
dmg_n += 1
|
|
heroes.append(
|
|
{
|
|
"match_id": _int(r.get("match_id")),
|
|
"hero_id": r.get("hero_id"),
|
|
"hero_key": r.get("hero_key"),
|
|
"hero_name_loc": r.get("hero_name_loc"),
|
|
"won": bool(r.get("won")),
|
|
}
|
|
)
|
|
n = wins + losses
|
|
out = {
|
|
"sample": n,
|
|
"wins": wins,
|
|
"losses": losses,
|
|
"winrate": winrate(wins, losses),
|
|
"kills": kills,
|
|
"deaths": deaths,
|
|
"assists": assists,
|
|
"kda": kda(kills, deaths, assists) if n else None,
|
|
"avg_kills": round(kills / n, 1) if n else None,
|
|
"avg_deaths": round(deaths / n, 1) if n else None,
|
|
"avg_assists": round(assists / n, 1) if n else None,
|
|
"avg_gpm": round(gpm_sum / gpm_n) if gpm_n else None,
|
|
"avg_xpm": round(xpm_sum / xpm_n) if xpm_n else None,
|
|
"avg_hero_damage": round(dmg_sum / dmg_n) if dmg_n else None,
|
|
"heroes": heroes,
|
|
}
|
|
return out
|
|
|
|
|
|
def career_from_opendota(wl: dict | None, totals: list | None) -> dict | None:
|
|
"""Build career block from OpenDota /wl + /totals. None when empty/private."""
|
|
wl = wl if isinstance(wl, dict) else {}
|
|
wins = _int(wl.get("win"))
|
|
losses = _int(wl.get("lose"))
|
|
if wins <= 0 and losses <= 0:
|
|
return None
|
|
by_field: dict[str, dict] = {}
|
|
if isinstance(totals, list):
|
|
for row in totals:
|
|
if isinstance(row, dict) and row.get("field"):
|
|
by_field[str(row["field"])] = row
|
|
def sum_of(field: str) -> int:
|
|
return _int((by_field.get(field) or {}).get("sum"))
|
|
|
|
def n_of(field: str) -> int:
|
|
return _int((by_field.get(field) or {}).get("n"))
|
|
|
|
n = wins + losses
|
|
kills = sum_of("kills")
|
|
deaths = sum_of("deaths")
|
|
assists = sum_of("assists")
|
|
gpm_n = n_of("gold_per_min")
|
|
xpm_n = n_of("xp_per_min")
|
|
dmg_n = n_of("hero_damage")
|
|
return {
|
|
"games": n,
|
|
"wins": wins,
|
|
"losses": losses,
|
|
"winrate": winrate(wins, losses),
|
|
"kills": kills,
|
|
"deaths": deaths,
|
|
"assists": assists,
|
|
"kda": kda(kills, deaths, assists) if n else None,
|
|
"avg_kills": round(kills / n, 1) if n else None,
|
|
"avg_deaths": round(deaths / n, 1) if n else None,
|
|
"avg_assists": round(assists / n, 1) if n else None,
|
|
"avg_gpm": round(sum_of("gold_per_min") / gpm_n) if gpm_n else None,
|
|
"avg_xpm": round(sum_of("xp_per_min") / xpm_n) if xpm_n else None,
|
|
"avg_hero_damage": round(sum_of("hero_damage") / dmg_n) if dmg_n else None,
|
|
"source": "opendota",
|
|
}
|
|
|
|
|
|
def top_heroes_from_opendota(
|
|
rows: list | None,
|
|
*,
|
|
hero_lookup: dict[int, dict],
|
|
limit: int = 5,
|
|
) -> list[dict]:
|
|
if not isinstance(rows, list):
|
|
return []
|
|
scored: list[tuple[int, dict]] = []
|
|
for row in rows:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
games = _int(row.get("games"))
|
|
if games <= 0:
|
|
continue
|
|
hid = _int(row.get("hero_id"))
|
|
hero = hero_lookup.get(hid) or {}
|
|
wins = _int(row.get("win"))
|
|
last = row.get("last_played")
|
|
try:
|
|
last_i = int(last) if last is not None else None
|
|
except (TypeError, ValueError):
|
|
last_i = None
|
|
scored.append(
|
|
(
|
|
games,
|
|
{
|
|
"hero_id": hid or None,
|
|
"hero_key": hero.get("key"),
|
|
"hero_name_loc": hero.get("name_loc") or hero.get("key"),
|
|
"games": games,
|
|
"wins": wins,
|
|
"winrate": winrate(wins, max(0, games - wins)),
|
|
"last_played": last_i,
|
|
},
|
|
)
|
|
)
|
|
scored.sort(key=lambda t: (-t[0], -(_int(t[1].get("last_played")))))
|
|
return [item for _, item in scored[: max(1, limit)]]
|
|
|
|
|
|
def peers_from_opendota(rows: list | None, *, limit: int = 8) -> list[dict]:
|
|
if not isinstance(rows, list):
|
|
return []
|
|
out: list[dict] = []
|
|
for row in rows:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
aid = _int(row.get("account_id"))
|
|
games = _int(row.get("games"))
|
|
if aid <= 0 or games <= 0:
|
|
continue
|
|
wins = _int(row.get("win"))
|
|
name = row.get("personaname")
|
|
if not isinstance(name, str) or not name.strip():
|
|
name = f"玩家 {aid}"
|
|
avatar = row.get("avatarfull") or row.get("avatar")
|
|
if not isinstance(avatar, str):
|
|
avatar = None
|
|
out.append(
|
|
{
|
|
"account_id": aid,
|
|
"personaname": name.strip(),
|
|
"avatar": avatar,
|
|
"games": games,
|
|
"wins": wins,
|
|
"winrate": winrate(wins, max(0, games - wins)),
|
|
}
|
|
)
|
|
if len(out) >= limit:
|
|
break
|
|
return out
|
|
|
|
|
|
def activity_from_matches(rows: list | None, *, days: int = 180) -> dict | None:
|
|
"""Build 180-day activity heatmap + sample highs from OpenDota /matches."""
|
|
if not isinstance(rows, list) or not rows:
|
|
return None
|
|
by_day: dict[str, dict] = {}
|
|
max_kills = max_assists = max_gpm = None
|
|
wins = losses = 0
|
|
for row in rows:
|
|
if not isinstance(row, dict):
|
|
continue
|
|
st = row.get("start_time")
|
|
try:
|
|
st_i = int(st) if st is not None else 0
|
|
except (TypeError, ValueError):
|
|
st_i = 0
|
|
if st_i <= 0:
|
|
continue
|
|
# UTC day key YYYY-MM-DD
|
|
from datetime import datetime, timezone
|
|
|
|
day = datetime.fromtimestamp(st_i, tz=timezone.utc).strftime("%Y-%m-%d")
|
|
cell = by_day.setdefault(day, {"games": 0, "wins": 0})
|
|
cell["games"] += 1
|
|
player_slot = _int(row.get("player_slot"))
|
|
radiant_win = bool(row.get("radiant_win"))
|
|
won = radiant_win if player_slot < 128 else not radiant_win
|
|
if won:
|
|
cell["wins"] += 1
|
|
wins += 1
|
|
else:
|
|
losses += 1
|
|
kills = _int(row.get("kills"))
|
|
assists = _int(row.get("assists"))
|
|
gpm = _int(row.get("gold_per_min"))
|
|
hero_id = _int(row.get("hero_id")) or None
|
|
if max_kills is None or kills > max_kills["value"]:
|
|
max_kills = {"value": kills, "hero_id": hero_id, "match_id": _int(row.get("match_id"))}
|
|
if max_assists is None or assists > max_assists["value"]:
|
|
max_assists = {
|
|
"value": assists,
|
|
"hero_id": hero_id,
|
|
"match_id": _int(row.get("match_id")),
|
|
}
|
|
if gpm > 0 and (max_gpm is None or gpm > max_gpm["value"]):
|
|
max_gpm = {"value": gpm, "hero_id": hero_id, "match_id": _int(row.get("match_id"))}
|
|
days_list = [
|
|
{"date": d, "games": v["games"], "wins": v["wins"]}
|
|
for d, v in sorted(by_day.items())
|
|
]
|
|
return {
|
|
"days": days,
|
|
"sample": wins + losses,
|
|
"wins": wins,
|
|
"losses": losses,
|
|
"winrate": winrate(wins, losses),
|
|
"heatmap": days_list,
|
|
"highs": {
|
|
"kills": max_kills,
|
|
"assists": max_assists,
|
|
"gpm": max_gpm,
|
|
},
|
|
"label": f"最近 {days} 天样本",
|
|
}
|
|
|
|
|
|
def merge_availability(
|
|
*,
|
|
opendota_recent_n: int,
|
|
career: dict | None,
|
|
steam_history_status: int | None,
|
|
fetched_at: str,
|
|
) -> dict:
|
|
"""Describe whether match history is public / syncing / private."""
|
|
od_public = opendota_recent_n > 0 or bool(career and career.get("games"))
|
|
steam_allowed = steam_history_status in (1,) # 1 = success
|
|
steam_denied = steam_history_status == 15
|
|
if od_public:
|
|
status = "public"
|
|
complete = True
|
|
note = None
|
|
elif steam_allowed and not od_public:
|
|
status = "syncing"
|
|
complete = False
|
|
note = "Steam 已公开,OpenDota 同步中"
|
|
elif steam_denied:
|
|
status = "private"
|
|
complete = False
|
|
note = "未公开比赛数据"
|
|
else:
|
|
status = "unknown"
|
|
complete = False
|
|
note = "暂无公开战绩"
|
|
return {
|
|
"status": status,
|
|
"complete": complete,
|
|
"opendota_public": od_public,
|
|
"steam_history_status": steam_history_status,
|
|
"source": "opendota+steam",
|
|
"fetched_at": fetched_at,
|
|
"note": note,
|
|
"stale": False,
|
|
}
|
|
|
|
|
|
def should_keep_old_career(old: dict | None, new: dict | None) -> dict | None:
|
|
"""HTTP 200 empty must not wipe a previously populated career."""
|
|
if new:
|
|
return new
|
|
if old and isinstance(old, dict) and _int(old.get("games")) > 0:
|
|
return old
|
|
return new
|