Ship Steam login, D1 player sync, and cached「我」dashboard.
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>
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user