Files
ForbiddenStarsApp/backend/app/services/stats_service.py
NotBigGhostandClaude Opus 5 1e04f132b8 Рейтинг: партии без соперников не считаются, понятная причина недоступной партии
В профиле 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
2026-09-15 00:31:01 +03:00

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,
}