"""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