"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен).""" from __future__ import annotations from typing import Any from sqlalchemy import func, text from sqlmodel import Session, select from app.core.timeutil import iso_utc from app.models import Faction, Group, GroupMember, Match, MatchParticipant, User from app.services import faction_service, 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, smoothed_score, ) # Базовый блок: одна строка на участие с 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 _user_scored_rows(session: Session, user_id: int) -> list[dict]: """Строки участия игрока со всеми полями, нужными витринам профиля. Один проход по SCORED_CTE вместо трёх: общий итог, разбивка по фракциям и форма последних партий считаются из одного и того же набора строк. CTE джойнит участия со всеми партиями приложения, поэтому каждый лишний проход дорожает вместе с общим числом партий, а не с числом партий игрока.""" sql = f""" {SCORED_CTE} SELECT s.group_id AS group_id, s.match_id AS match_id, s.played_at AS played_at, s.place AS place, s.player_count AS player_count, s.points AS points, s.is_win AS is_win, f.id AS faction_id, f.code AS code, f.name_ru AS name_ru, e.code AS expansion_code FROM scored s JOIN factions f ON f.id = s.faction_id JOIN expansions e ON e.id = f.expansion_id WHERE s.user_id = :uid """ rows = session.execute(text(sql), {"uid": user_id}).mappings().all() return [dict(r) for r in rows] def _for_group(rows: list[dict], group_id: int | None) -> list[dict]: return rows if group_id is None else [r for r in rows if r["group_id"] == group_id] def _overall_from_rows(rows: list[dict]) -> dict: """Тот же итог, что раньше считал SQL: COUNT/SUM/AVG плюс сглаженный рейтинг.""" games = len(rows) if games == 0: return {"games": 0, "wins": 0, "win_rate": 0.0, "avg_place": None, "score": None} wins = sum(int(r["is_win"]) for r in rows) return { "games": games, "wins": wins, "win_rate": _round(wins / games, 4), "avg_place": _round(sum(r["place"] for r in rows) / games, 2), "score": _round(smoothed_score(sum(float(r["points"]) for r in rows), games), 1), } def _faction_breakdown_from_rows(rows: list[dict]) -> list[dict]: by_faction: dict[int, list[dict]] = {} for r in rows: by_faction.setdefault(r["faction_id"], []).append(r) out = [] for fid, group in by_faction.items(): games = len(group) wins = sum(int(r["is_win"]) for r in group) meta = group[0] out.append( { "faction_id": fid, "code": meta["code"], "name_ru": meta["name_ru"], "expansion_code": meta["expansion_code"], "games": games, "wins": wins, "win_rate": _round(wins / games, 4), "avg_place": _round(sum(r["place"] for r in group) / games, 2), # Фракции: чистое среднее (служебная метрика «лучшая/худшая», # сглаживание задавило бы её к 50). "score": _round(sum(float(r["points"]) for r in group) / games * 100, 1), "name_ru_prepositional": faction_service.prepositional( meta["code"], meta["name_ru"] ), } ) out.sort(key=lambda f: (-f["games"], -(f["score"] or 0.0))) return out def _recent_form_from_rows(rows: list[dict], limit: int = 5) -> list[dict]: recent = sorted(rows, key=lambda r: (str(r["played_at"]), r["match_id"]), reverse=True) return [ { "place": r["place"], "player_count": r["player_count"], "played_at": str(r["played_at"]), } for r in recent[:limit] ] def _favorite_faction(session: Session, user_id: int) -> dict | None: """Любимая фракция игрока: личный выбор в профиле, а не вывод из статистики.""" user = session.get(User, user_id) if user is None or user.favorite_faction_id is None: return None faction = session.get(Faction, user.favorite_faction_id) if faction is None: return None return { "id": faction.id, "code": faction.code, "name_ru": faction.name_ru, "expansion_id": faction.expansion_id, } def profile_stats( session: Session, user_id: int, group_id: int | None = None, *, rows: list[dict] | None = None, ) -> dict: """Витрина профиля. rows — уже вытащенные строки игрока (home() их переиспользует).""" scoped = _for_group(rows if rows is not None else _user_scored_rows(session, user_id), group_id) overall = _overall_from_rows(scoped) factions = _faction_breakdown_from_rows(scoped) 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 # «Чаще всего играет на» — самая игранная фракция по всей истории, включая # рандомные раздачи. 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, "favorite_faction": _favorite_faction(session, user_id), "main_faction": main, "recent_form": _recent_form_from_rows(scoped), "min_games": MIN_GAMES, } def group_stats(session: Session, group_id: int) -> dict: board = leaderboard(session, group_id=group_id) # Нужны только счётчик и дата последней партии — тянуть строки целиком незачем. games_count, last_played = session.exec( select(func.count(), func.max(Match.played_at)).where( Match.group_id == group_id, Match.status == "finished" ) ).one() last_at = str(last_played) if last_played else None 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": games_count, "last_match_at": last_at, "leaderboard": board["entries"], "provisional": board["provisional"], "inactive": inactive, "faction_meta": faction_meta, "min_games": MIN_GAMES, } def _participant_row(p: MatchParticipant, u: User, f: Faction) -> dict: return { "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, } def _participants_by_match(session: Session, match_ids: list[int]) -> dict[int, list[dict]]: """Участники сразу всей страницы партий: иначе запрос на каждую партию (N+1).""" if not match_ids: return {} rows = session.exec( select(MatchParticipant, User, Faction) .join(User, User.id == MatchParticipant.user_id) .join(Faction, Faction.id == MatchParticipant.faction_id) .where(MatchParticipant.match_id.in_(match_ids)) # place может быть NULL (партия идёт) — NULL уходит в конец сортировки. .order_by(MatchParticipant.place.is_(None), MatchParticipant.place, User.nickname) ).all() out: dict[int, list[dict]] = {} for p, u, f in rows: out.setdefault(p.match_id, []).append(_participant_row(p, u, f)) return out def _match_items(session: Session, matches) -> list[dict]: """Элементы списка партий (общее для списка группы и истории игрока).""" by_match = _participants_by_match(session, [m.id for m in matches]) items = [] for m in matches: parts = by_match.get(m.id, []) 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 def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict: total = session.exec( select(func.count()).select_from(Match).where(Match.group_id == group_id) ).one() 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() return { "items": _match_items(session, matches), "total": total, "limit": limit, "offset": offset, } def user_match_list( session: Session, user_id: int, limit: int = 20, offset: int = 0, best_only: bool = False, ) -> dict: """История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху. best_only — одна лучшая партия по League Points (s.points из SCORED_CTE учитывает место и размер стола); при равных очках берём более свежую.""" if best_only: sql = f""" {SCORED_CTE} SELECT s.match_id AS match_id FROM scored s WHERE s.user_id = :uid ORDER BY s.points DESC, s.played_at DESC, s.match_id DESC LIMIT 1 """ row = session.execute(text(sql), {"uid": user_id}).mappings().first() matches = [session.get(Match, row["match_id"])] if row else [] return { "items": _match_items(session, matches), "total": len(matches), "limit": 1, "offset": 0, } where = (MatchParticipant.user_id == user_id, Match.status == "finished") total = session.exec( select(func.count()) .select_from(Match) .join(MatchParticipant, MatchParticipant.match_id == Match.id) .where(*where) ).one() matches = session.exec( select(Match) .join(MatchParticipant, MatchParticipant.match_id == Match.id) .where(*where) .order_by(Match.played_at.desc(), Match.id.desc()) .offset(offset) .limit(limit) ).all() return { "items": _match_items(session, matches), "total": total, "limit": limit, "offset": offset, } def user_in_progress_matches(session: Session, user_id: int) -> list[dict]: """Незавершённые партии во всех группах, где состоит пользователь (новые сверху).""" 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() by_match = _participants_by_match(session, [m.id for m, _ in rows]) out = [] for m, gname in rows: parts = by_match.get(m.id, []) 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) # Строки игрока тянем один раз: из них считается и профиль, и итог по активной группе. rows = _user_scored_rows(session, user_id) profile = profile_stats(session, user_id, group_id=None, rows=rows) 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_from_rows(_for_group(rows, 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, }