В профиле Ivan висели две «партии» с одним участником, которые не открывались. Dev-удаление аккаунта (dev_admin) вычёркивает игрока из партий, не трогая сами партии, и 17.06 в dev-БД так опустели партии 2, 3, 6 и 7. Они засчитывались оставшемуся в число игр с ΔR 0.0, показывались в истории и списке группы, а бэкфилл 0014 проставил им last_standing. Партия меньше чем с двумя участниками больше не считается игрой: её нет в рейтинге и счётчиках (load_history), в истории игрока и в списке партий группы; админка её по-прежнему видит. Бэкфилл last_standing в 0014 теперь требует хотя бы двух участников (на уже мигрированных БД такие партии и так скрыты). «Партия не найдена» при переходе из чужого профиля в партию группы, где зритель не состоит, — это 403; страница партии теперь так и пишет. Тест: партия, опустевшая после dev-удаления, не попадает в историю, список и счётчик группы, рейтинг считается только по настоящей партии. #80 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
556 lines
22 KiB
Python
556 lines
22 KiB
Python
"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен).
|
|
|
|
Рейтинг — функция упорядоченной истории (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,
|
|
)
|
|
|
|
|
|
MIN_PARTICIPANTS = 2 # партия, где осталось меньше участников, партией не считается
|
|
|
|
|
|
def _playable_match_ids():
|
|
"""Подзапрос id партий, в которых не меньше MIN_PARTICIPANTS участников.
|
|
|
|
Партия может «опустеть» в деве: жёсткое удаление аккаунта (routers/dev_admin.py)
|
|
вычёркивает игрока из партий и не пересчитывает их. Партия с одним участником —
|
|
уже не игра: её нет ни в рейтинге, ни в историях и списках (админка её видит)."""
|
|
return (
|
|
select(MatchParticipant.match_id)
|
|
.group_by(MatchParticipant.match_id)
|
|
.having(func.count() >= MIN_PARTICIPANTS)
|
|
)
|
|
|
|
|
|
def load_history(session: Session) -> list[RatedMatch]:
|
|
"""Все завершённые партии в порядке проигрывания: дата игры, момент завершения, id.
|
|
|
|
Один запрос на партии с участниками. In_progress в рейтинг не входят: мест у них нет,
|
|
партии меньше чем с двумя участниками — тоже (_playable_match_ids). Срез группы —
|
|
_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 len(seats) >= MIN_PARTICIPANTS:
|
|
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:
|
|
where = (Match.group_id == group_id, Match.id.in_(_playable_match_ids()))
|
|
total = session.exec(select(func.count()).select_from(Match).where(*where)).one()
|
|
matches = session.exec(
|
|
select(Match)
|
|
.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_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",
|
|
Match.id.in_(_playable_match_ids()),
|
|
)
|
|
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,
|
|
}
|