"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен). Рейтинг — функция упорядоченной истории (scoring.replay), поэтому витрины не агрегируют SQL, а проигрывают завершённые партии: одна загрузка истории на запрос, из неё же считаются игры, победы, среднее место и разбивки. Рейтинг у игрока один — по всем партиям приложения (#80). Страница группы берёт из него только рейтинг, а игры, победы, винрейт и среднее место считает по партиям группы.""" from __future__ import annotations from collections import defaultdict from sqlalchemy import func from sqlmodel import Session, select from app.core.timeutil import iso_utc from app.models import Expansion, 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, MIN_GAMES, RatedMatch, RatedSeat, Replay, leaderboard_sort_key, replay, ) def load_history(session: Session) -> list[RatedMatch]: """Все завершённые партии в порядке проигрывания: дата игры, момент завершения, id. Один запрос на партии с участниками. In_progress в рейтинг не входят: мест у них нет. Срез группы — _for_group по этой же истории: рейтинг считается только целиком.""" stmt = ( select(Match, MatchParticipant) .join(MatchParticipant, MatchParticipant.match_id == Match.id) .where(Match.status == "finished") .order_by(Match.played_at, Match.finished_at, Match.id, MatchParticipant.id) ) history: list[RatedMatch] = [] current: Match | None = None seats: list[RatedSeat] = [] def flush() -> None: if current is not None and seats: history.append( RatedMatch( seats=tuple(seats), win_reason=current.win_reason, end_round=current.end_round, nine_rounds_rule=current.nine_rounds_rule, id=current.id, # type: ignore[arg-type] group_id=current.group_id, played_at=current.played_at, finished_at=current.finished_at, ) ) for m, p in session.exec(stmt).all(): if current is None or m.id != current.id: flush() current, seats = m, [] if p.place is None: # у завершённой партии мест без значения не бывает continue seats.append( RatedSeat( user_id=p.user_id, place=p.place, faction_id=p.faction_id, eliminated=p.eliminated, objectives=p.objectives, worlds=p.worlds, ) ) flush() return history def _for_group(history: list[RatedMatch], group_id: int) -> list[RatedMatch]: return [m for m in history if m.group_id == group_id] def _user_seats(history: list[RatedMatch], user_id: int) -> list[tuple[RatedMatch, RatedSeat]]: return [(m, s) for m in history for s in m.seats if s.user_id == user_id] def _rating_confirmed(rep: Replay, user_id: int) -> bool: """Рейтинг подтверждён, когда за игроком MIN_GAMES партий во всём приложении.""" return rep.games.get(user_id, 0) >= MIN_GAMES def _summary(seats: list[tuple[RatedMatch, RatedSeat]], rating: float | None) -> dict: """Итог игрока: игры, победы, винрейт, среднее место и рейтинг целым числом. В расчёте рейтинг без округления (иначе ошибка копилась бы по цепочке партий), показывается — целым.""" games = len(seats) if games == 0: return {"games": 0, "wins": 0, "win_rate": 0.0, "avg_place": None, "score": None} wins = sum(1 for _m, s in seats if s.place == 1) return { "games": games, "wins": wins, "win_rate": round(wins / games, 4), "avg_place": round(sum(s.place for _m, s in seats) / games, 2), "score": None if rating is None else round(rating), } def leaderboard( session: Session, group_id: int | None = None, *, history: list[RatedMatch] | None = None, rep: Replay | None = None, member_ids: set[int] | None = None, ) -> dict: """Топ: общий или группы. history/rep — вся история и её проигрывание (home и group_stats их переиспользуют). Рейтинг и статус «Новичок» — всегда общие: статус описывает надёжность рейтинга, а он считается по всем партиям. С group_id игры, победы, винрейт и среднее место берутся только из партий группы (#80). member_ids — показывать только этих игроков (топ группы — её текущий состав, #76); места нумеруются уже после фильтра.""" if history is None: history = load_history(session) if rep is None: rep = replay(history) shown = history if group_id is None else _for_group(history, group_id) by_user: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list) for m in shown: for s in m.seats: if member_ids is None or s.user_id in member_ids: by_user[s.user_id].append((m, s)) users = ( {u.id: u for u in session.exec(select(User).where(User.id.in_(list(by_user)))).all()} if by_user else {} ) rows = [] for uid, seats in by_user.items(): u = users[uid] rows.append( { "user_id": uid, "nickname": u.nickname, **_summary(seats, rep.ratings[uid]), "rating": rep.ratings[uid], # только для сортировки "rating_confirmed": _rating_confirmed(rep, uid), "avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type] } ) qualified = sorted((r for r in rows if r["rating_confirmed"]), key=leaderboard_sort_key) provisional = sorted((r for r in rows if not r["rating_confirmed"]), key=leaderboard_sort_key) for i, r in enumerate(qualified, start=1): r["rank"] = i for r in provisional: r["rank"] = None for r in rows: del r["rating"] return { "entries": qualified, "provisional": provisional, "min_games": MIN_GAMES, } def _faction_breakdown( session: Session, seats: list[tuple[RatedMatch, RatedSeat]], rep: Replay, user_id: int ) -> list[dict]: by_faction: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list) for m, s in seats: by_faction[s.faction_id].append((m, s)) if not by_faction: return [] meta = { f.id: (f, code) for f, code in session.exec( select(Faction, Expansion.code) .join(Expansion, Expansion.id == Faction.expansion_id) .where(Faction.id.in_(list(by_faction))) ).all() } out = [] for fid, group in by_faction.items(): faction, expansion_code = meta[fid] perf = sum(rep.perf[(m.id, user_id)] for m, _s in group) / len(group) out.append( { "faction_id": fid, "code": faction.code, "name_ru": faction.name_ru, "expansion_code": expansion_code, **{k: v for k, v in _summary(group, None).items() if k != "score"}, # Средний результат относительно ожидания (S − E) × 100: насколько игрок # на фракции выступает выше рейтинговых шансов — без привязки к рейтингу. "score": round(perf * 100, 1), "name_ru_prepositional": faction_service.prepositional( faction.code, faction.name_ru ), } ) out.sort(key=lambda f: (-f["games"], -(f["score"] or 0.0))) return out def _recent_form(seats: list[tuple[RatedMatch, RatedSeat]], limit: int = 5) -> list[dict]: recent = sorted(seats, key=lambda ms: (str(ms[0].played_at), ms[0].id), reverse=True) return [ { "place": s.place, "player_count": len(m.seats), "played_at": str(m.played_at), } for m, s 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 _overall(history: list[RatedMatch], rep: Replay, user_id: int) -> dict: return _summary(_user_seats(history, user_id), rep.ratings.get(user_id)) def profile_stats( session: Session, user_id: int, *, history: list[RatedMatch] | None = None, rep: Replay | None = None, ) -> dict: """Витрина профиля — общие показатели. history/rep — уже посчитанные (home их переиспользует).""" if history is None: history = load_history(session) if rep is None: rep = replay(history) seats = _user_seats(history, user_id) factions = _faction_breakdown(session, seats, rep, user_id) qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES] best = max(qualified, key=lambda f: f["score"]) if qualified else None worst = min(qualified, key=lambda f: f["score"]) if qualified else None # «Чаще всего играет на» — самая игранная фракция по всей истории, включая # рандомные раздачи. main = max(factions, key=lambda f: f["games"]) if factions else None return { "user_id": user_id, "overall": _summary(seats, rep.ratings.get(user_id)), "factions": factions, "best_faction": best, "worst_faction": worst, "favorite_faction": _favorite_faction(session, user_id), "main_faction": main, "recent_form": _recent_form(seats), "min_games": MIN_GAMES, } def group_stats(session: Session, group_id: int) -> dict: history = load_history(session) rep = replay(history) group_history = _for_group(history, group_id) members = membership_service.list_members(session, group_id) # Список игроков группы — только её текущий состав: удалённый из группы в нём не висит. board = leaderboard( session, group_id, history=history, rep=rep, member_ids={u.id for _m, u in members}, # type: ignore[misc] ) last_played = max((m.played_at for m in group_history), default=None) games: dict[int, int] = defaultdict(int) wins: dict[int, int] = defaultdict(int) for m in group_history: for s in m.seats: games[s.faction_id] += 1 wins[s.faction_id] += s.place == 1 available_ids = group_service.available_faction_ids(session, group_id) factions = sorted( session.exec(select(Faction)).all(), key=lambda f: (-games[f.id], f.sort_order) # type: ignore[index] ) faction_meta = [ { "faction_id": f.id, "code": f.code, "name_ru": f.name_ru, "games": games[f.id], # type: ignore[index] "wins": wins[f.id], # type: ignore[index] "available": f.id in available_ids, } for f in factions ] # Участники без завершённых партий в группе — отдельным блоком (нули, 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 members: if u.id in played_ids: continue rating = rep.ratings.get(u.id) # type: ignore[arg-type] inactive.append( { "user_id": u.id, "nickname": u.nickname, "games": 0, "wins": 0, "win_rate": 0.0, "avg_place": None, "score": None if rating is None else round(rating), "rating_confirmed": _rating_confirmed(rep, u.id), # type: ignore[arg-type] "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(group_history), "last_match_at": str(last_played) if last_played else None, "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, "objectives": p.objectives, "worlds": p.worlds, } 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, rating_deltas: dict[int, float] | None = None ) -> list[dict]: """Элементы списка партий (общее для списка группы и истории игрока). rating_deltas — {match_id: ΔR} владельца истории; у списка группы их нет.""" 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, "rating_delta": None if rating_deltas is None else rating_deltas.get(m.id), } ) 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: """История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху. У каждой партии — изменение общего рейтинга игрока за неё (rating_delta, один знак после запятой). best_only — одна лучшая партия: наибольший прирост рейтинга (учитывает и соперников, и ход партии); при равенстве берём более свежую.""" history = load_history(session) rep = replay(history) mine = _user_seats(history, user_id) # + 0.0 превращает −0.0 (мелкий минус, округлённый до нуля) в обычный ноль. deltas = {m.id: round(rep.delta[(m.id, user_id)], 1) + 0.0 for m, _s in mine} if best_only: candidates = [(rep.delta[(m.id, user_id)], m.played_at, m.id) for m, _s in mine] matches = [session.get(Match, max(candidates)[2])] if candidates else [] return { "items": _match_items(session, matches, deltas), "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, deltas), "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: # История грузится и проигрывается один раз: из неё и топ, и профиль, и блок активной # группы (там игры и победы по группе, рейтинг — общий). history = load_history(session) rep = replay(history) board = leaderboard(session, history=history, rep=rep) profile = profile_stats(session, user_id, history=history, rep=rep) 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_group(history, active_group_id), rep, user_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, }