"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен).""" from __future__ import annotations from typing import Any from sqlalchemy import text from sqlmodel import Session, select from app.core.timeutil import iso_utc from app.models import Group, GroupMember, Match from app.services import group_service, membership_service, user_service from app.services.scoring import ( FACTION_MIN_GAMES, MATCH_POINTS_SQL, MIN_GAMES, SMOOTHED_SCORE_SQL, leaderboard_sort_key, ) # Базовый блок: одна строка на участие с tie-aware очками. # Учитываются только ЗАВЕРШЁННЫЕ партии (in_progress без мест в статистику не входят). SCORED_CTE = f""" WITH tie AS ( SELECT mp.match_id AS match_id, mp.place AS place, COUNT(*) AS tie_size FROM match_participants mp JOIN matches m ON m.id = mp.match_id WHERE m.status = 'finished' AND mp.place IS NOT NULL GROUP BY mp.match_id, mp.place ), scored AS ( SELECT mp.user_id AS user_id, mp.faction_id AS faction_id, m.id AS match_id, m.group_id AS group_id, m.played_at AS played_at, mp.place AS place, mp.was_random AS was_random, m.player_count AS player_count, ({MATCH_POINTS_SQL}) AS points, CASE WHEN mp.place = 1 THEN 1 ELSE 0 END AS is_win FROM match_participants mp JOIN matches m ON m.id = mp.match_id JOIN tie t ON t.match_id = mp.match_id AND t.place = mp.place WHERE m.status = 'finished' ) """ def _round(value: Any, ndigits: int) -> float | None: return None if value is None else round(float(value), ndigits) def _normalize(row: dict) -> dict: # avatar_url мирроринг user_service.avatar_url_for: версия = epoch(updated_at) из SQL. avatar_url = None if row.get("avatar_path"): avatar_url = f"/api/users/{row['user_id']}/avatar?v={int(row.get('avatar_version') or 0)}" return { "user_id": row["user_id"], "nickname": row["nickname"], "games": int(row["games"] or 0), "wins": int(row["wins"] or 0), "win_rate": _round(row["win_rate"] or 0.0, 4), "avg_place": _round(row["avg_place"], 2), "score": _round(row["score"], 1), "avatar_url": avatar_url, } def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]: where = "WHERE s.group_id = :gid" if group_id is not None else "" sql = f""" {SCORED_CTE} SELECT u.id AS user_id, u.nickname AS nickname, u.avatar_path AS avatar_path, CAST(strftime('%s', u.updated_at) AS INTEGER) AS avatar_version, COUNT(*) AS games, SUM(s.is_win) AS wins, AVG(CAST(s.is_win AS FLOAT)) AS win_rate, AVG(s.place) AS avg_place, {SMOOTHED_SCORE_SQL} AS score FROM scored s JOIN users u ON u.id = s.user_id {where} GROUP BY u.id, u.nickname, u.avatar_path, u.updated_at """ params = {"gid": group_id} if group_id is not None else {} result = session.execute(text(sql), params).mappings().all() return [_normalize(dict(r)) for r in result] def leaderboard(session: Session, group_id: int | None = None) -> dict: rows = _leaderboard_rows(session, group_id) qualified = [r for r in rows if r["games"] >= MIN_GAMES] provisional = [r for r in rows if r["games"] < MIN_GAMES] qualified.sort(key=leaderboard_sort_key) provisional.sort(key=leaderboard_sort_key) for i, r in enumerate(qualified, start=1): r["rank"] = i for r in provisional: r["rank"] = None return { "entries": qualified, "provisional": provisional, "min_games": MIN_GAMES, } def _overall_for_user(session: Session, user_id: int, group_id: int | None) -> dict: cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "") sql = f""" {SCORED_CTE} SELECT COUNT(*) AS games, SUM(s.is_win) AS wins, AVG(CAST(s.is_win AS FLOAT)) AS win_rate, AVG(s.place) AS avg_place, {SMOOTHED_SCORE_SQL} AS score FROM scored s {cond} """ params: dict[str, Any] = {"uid": user_id} if group_id is not None: params["gid"] = group_id r = session.execute(text(sql), params).mappings().first() or {} return { "games": int(r.get("games") or 0), "wins": int(r.get("wins") or 0), "win_rate": _round(r.get("win_rate") or 0.0, 4), "avg_place": _round(r.get("avg_place"), 2), "score": _round(r.get("score"), 1), } def _faction_breakdown(session: Session, user_id: int, group_id: int | None) -> list[dict]: cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "") sql = f""" {SCORED_CTE} SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru, e.code AS expansion_code, COUNT(*) AS games, SUM(s.is_win) AS wins, AVG(CAST(s.is_win AS FLOAT)) AS win_rate, AVG(s.place) AS avg_place, AVG(s.points) * 100 AS score, -- фракции: чистое среднее (служебная метрика -- «лучшая/худшая», сглаживание задавило бы её к 50) SUM(CASE WHEN s.was_random THEN 0 ELSE 1 END) AS chosen_games FROM scored s JOIN factions f ON f.id = s.faction_id JOIN expansions e ON e.id = f.expansion_id {cond} GROUP BY f.id, f.code, f.name_ru, e.code ORDER BY games DESC, score DESC """ params: dict[str, Any] = {"uid": user_id} if group_id is not None: params["gid"] = group_id result = session.execute(text(sql), params).mappings().all() out = [] for r in result: out.append( { "faction_id": r["faction_id"], "code": r["code"], "name_ru": r["name_ru"], "expansion_code": r["expansion_code"], "games": int(r["games"] or 0), "wins": int(r["wins"] or 0), "win_rate": _round(r["win_rate"] or 0.0, 4), "avg_place": _round(r["avg_place"], 2), "score": _round(r["score"], 1), # Служебное (в API-схему не входит): партии, где фракция выбрана # вручную, — для «любимой фракции» рандомные раздачи не в счёт. "chosen_games": int(r["chosen_games"] or 0), } ) return out def _recent_form(session: Session, user_id: int, group_id: int | None, limit: int = 5) -> list[dict]: cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "") sql = f""" {SCORED_CTE} SELECT s.place AS place, s.player_count AS player_count, s.played_at AS played_at FROM scored s {cond} ORDER BY s.played_at DESC, s.match_id DESC LIMIT :lim """ params: dict[str, Any] = {"uid": user_id, "lim": limit} if group_id is not None: params["gid"] = group_id result = session.execute(text(sql), params).mappings().all() return [ {"place": r["place"], "player_count": r["player_count"], "played_at": str(r["played_at"])} for r in result ] def profile_stats(session: Session, user_id: int, group_id: int | None = None) -> dict: overall = _overall_for_user(session, user_id, group_id) factions = _faction_breakdown(session, user_id, group_id) qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES] best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None # Любимая — по числу партий, где игрок выбрал фракцию САМ (was_random=false): # рандомная раздача о предпочтениях не говорит. Нет ручных выборов — нет любимой. chosen = [f for f in factions if f["chosen_games"] > 0] most_played = max(chosen, key=lambda f: f["chosen_games"]) if chosen else None # Основная — просто самая игранная, включая рандомные раздачи. main = max(factions, key=lambda f: f["games"]) if factions else None return { "user_id": user_id, "overall": overall, "factions": factions, "best_faction": best, "worst_faction": worst, "most_played_faction": most_played, "main_faction": main, "recent_form": _recent_form(session, user_id, group_id), "min_games": MIN_GAMES, } def group_stats(session: Session, group_id: int) -> dict: board = leaderboard(session, group_id=group_id) total_matches = session.exec( select(Match).where(Match.group_id == group_id, Match.status == "finished") ).all() last_at = None if total_matches: last_at = str(max(m.played_at for m in total_matches)) available_ids = group_service.available_faction_ids(session, group_id) faction_meta = [] sql = f""" {SCORED_CTE} SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru, COUNT(s.user_id) AS games, SUM(s.is_win) AS wins FROM factions f LEFT JOIN scored s ON s.faction_id = f.id AND s.group_id = :gid GROUP BY f.id, f.code, f.name_ru ORDER BY games DESC, f.sort_order """ rows = session.execute(text(sql), {"gid": group_id}).mappings().all() for r in rows: faction_meta.append( { "faction_id": r["faction_id"], "code": r["code"], "name_ru": r["name_ru"], "games": int(r["games"] or 0), "wins": int(r["wins"] or 0), "available": r["faction_id"] in available_ids, } ) # Участники без завершённых партий — отдельным блоком (нули, rank=null). played_ids = {e["user_id"] for e in board["entries"]} | { e["user_id"] for e in board["provisional"] } inactive = [] for _m, u in membership_service.list_members(session, group_id): if u.id in played_ids: continue inactive.append( { "user_id": u.id, "nickname": u.nickname, "games": 0, "wins": 0, "win_rate": 0.0, "avg_place": None, "score": None, "rank": None, "avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), } ) return { "group_id": group_id, "total_matches": len(total_matches), "last_match_at": last_at, "leaderboard": board["entries"], "provisional": board["provisional"], "inactive": inactive, "faction_meta": faction_meta, "min_games": MIN_GAMES, } def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict: from app.services.match_service import participants_detail # избегаем цикла импорта total = len(session.exec(select(Match.id).where(Match.group_id == group_id)).all()) matches = session.exec( select(Match) .where(Match.group_id == group_id) .order_by(Match.played_at.desc(), Match.id.desc()) .offset(offset) .limit(limit) ).all() items = [] for m in matches: parts = [] for p, u, f in participants_detail(session, m.id): # type: ignore[arg-type] parts.append( { "user_id": u.id, "nickname": u.nickname, "faction_id": f.id, "faction_name": f.name_ru, "place": p.place, "eliminated": p.eliminated, "was_random": p.was_random, "comment": p.comment, } ) items.append( { "id": m.id, "status": m.status, "played_at": str(m.played_at), "started_at": iso_utc(m.started_at), "finished_at": iso_utc(m.finished_at), "duration_minutes": m.duration_minutes, "win_reason": m.win_reason, "player_count": m.player_count, "overall_comment": m.overall_comment, "created_by": m.created_by, "participants": parts, } ) return {"items": items, "total": total, "limit": limit, "offset": offset} def user_in_progress_matches(session: Session, user_id: int) -> list[dict]: """Незавершённые партии во всех группах, где состоит пользователь (новые сверху).""" from app.services.match_service import participants_detail # избегаем цикла импорта group_ids = list( session.exec(select(GroupMember.group_id).where(GroupMember.user_id == user_id)).all() ) if not group_ids: return [] rows = session.exec( select(Match, Group.name) .join(Group, Group.id == Match.group_id) .where(Match.status == "in_progress", Match.group_id.in_(group_ids)) .order_by(Match.started_at.desc(), Match.id.desc()) ).all() out = [] for m, gname in rows: parts = [ { "user_id": u.id, "nickname": u.nickname, "faction_id": f.id, "faction_name": f.name_ru, "place": p.place, "eliminated": p.eliminated, "was_random": p.was_random, "comment": p.comment, } for p, u, f in participants_detail(session, m.id) # type: ignore[arg-type] ] out.append( { "id": m.id, "group_id": m.group_id, "group_name": gname, "started_at": iso_utc(m.started_at), "player_count": m.player_count, "participants": parts, } ) return out def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict: board = leaderboard(session, group_id=None) profile = profile_stats(session, user_id, group_id=None) active_group_brief = None if active_group_id is not None: group = session.get(Group, active_group_id) if group is not None: active_group_brief = { "id": group.id, "name": group.name, **_overall_for_user(session, user_id, active_group_id), } return { "leaderboard": board["entries"][:leaderboard_limit], "provisional": board["provisional"][:leaderboard_limit], "profile": profile, "active_group": active_group_brief, "in_progress": user_in_progress_matches(session, user_id), "min_games": MIN_GAMES, }