Рейтинг: единый рейтинг вместо группового

Групповая цепочка убрана: рейтинг у игрока один, по всем партиям приложения
(решение владельца). load_history всегда грузит всю историю, одно
проигрывание на запрос. Страница группы: игры, победы, винрейт и среднее
место — по партиям группы, рейтинг — общий; «Новичок» тоже по общему числу
партий (новое поле rating_confirmed), поэтому опытный игрок в новой группе
ранжирован. Участники, не игравшие в группе, показывают общий рейтинг.
Главная и профиль — общие показатели; в блоке активной группы игры по
группе, рейтинг общий. У profile_stats убран неиспользуемый group_id.

Фронт: цвет рейтинга в списках берётся из rating_confirmed; справка —
«Один рейтинг на всё приложение». Документ рейтинга: раздел 8 и решение 7
в разделе 9.

Тесты: рейтинг в группах равен общему при групповых играх и победах,
главная и блок активной группы, ветеран в новой группе ранжирован,
«Ещё не играли» с общим рейтингом. #80

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
This commit is contained in:
2026-09-15 00:18:53 +03:00
co-authored by Claude Opus 5
parent 1c391448c1
commit 640f712037
10 changed files with 124 additions and 54 deletions
+3
View File
@@ -384,6 +384,9 @@ class LeaderboardEntry(OverallStats):
nickname: str
rank: int | None = None
avatar_url: str | None = None
# Рейтинг подтверждён: MIN_GAMES+ партий во всём приложении. На странице группы games —
# партии в группе, поэтому статус не выводится из них (и из блока, где стоит строка).
rating_confirmed: bool = False
class MatchHistory(BaseModel):
+42 -32
View File
@@ -2,8 +2,9 @@
Рейтинг — функция упорядоченной истории (scoring.replay), поэтому витрины не агрегируют
SQL, а проигрывают завершённые партии: одна загрузка истории на запрос, из неё же
считаются игры, победы, среднее место и разбивки. Цепочек две: общая — по всем партиям
приложения, групповая — только по партиям группы (это разные числа)."""
считаются игры, победы, среднее место и разбивки. Рейтинг у игрока один — по всем
партиям приложения (#80). Страница группы берёт из него только рейтинг, а игры, победы,
винрейт и среднее место считает по партиям группы."""
from __future__ import annotations
from collections import defaultdict
@@ -25,18 +26,17 @@ from app.services.scoring import (
)
def load_history(session: Session, group_id: int | None = None) -> list[RatedMatch]:
"""Завершённые партии в порядке проигрывания: дата игры, момент завершения, id.
def load_history(session: Session) -> list[RatedMatch]:
"""Все завершённые партии в порядке проигрывания: дата игры, момент завершения, id.
Один запрос на партии с участниками. In_progress в рейтинг не входят: мест у них нет."""
Один запрос на партии с участниками. 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)
)
if group_id is not None:
stmt = stmt.where(Match.group_id == group_id)
stmt = stmt.order_by(Match.played_at, Match.finished_at, Match.id, MatchParticipant.id)
history: list[RatedMatch] = []
current: Match | None = None
@@ -85,6 +85,11 @@ def _user_seats(history: list[RatedMatch], user_id: int) -> list[tuple[RatedMatc
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:
"""Итог игрока: игры, победы, винрейт, среднее место и рейтинг целым числом.
@@ -111,18 +116,21 @@ def leaderboard(
rep: Replay | None = None,
member_ids: set[int] | None = None,
) -> dict:
"""Топ: общий или группы. history/rep — уже посчитанные (home их переиспользует).
"""Топ: общий или группы. history/rep — вся история и её проигрывание (home и
group_stats их переиспользуют).
member_ids — показывать только этих игроков (топ группы — её текущий состав, #76).
Фильтруется вывод, а не расчёт: партии с ушедшим игроком остаются в цепочке и влияют
на рейтинг оставшихся. Места нумеруются уже после фильтра."""
Рейтинг и статус «Новичок» — всегда общие: статус описывает надёжность рейтинга, а он
считается по всем партиям. С group_id игры, победы, винрейт и среднее место берутся
только из партий группы (#80). member_ids — показывать только этих игроков (топ
группы — её текущий состав, #76); места нумеруются уже после фильтра."""
if history is None:
history = load_history(session, group_id)
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 history:
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))
@@ -141,11 +149,12 @@ def leaderboard(
"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["games"] >= MIN_GAMES), key=leaderboard_sort_key)
provisional = sorted((r for r in rows if r["games"] < MIN_GAMES), key=leaderboard_sort_key)
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:
@@ -233,17 +242,14 @@ def _overall(history: list[RatedMatch], rep: Replay, user_id: int) -> dict:
def profile_stats(
session: Session,
user_id: int,
group_id: int | None = None,
*,
history: list[RatedMatch] | None = None,
rep: Replay | None = None,
) -> dict:
"""Витрина профиля. history/rep — общая цепочка, уже посчитанная (home их переиспользует);
с group_id считается групповая цепочка."""
"""Витрина профиля — общие показатели. history/rep — уже посчитанные (home их
переиспользует)."""
if history is None:
history = load_history(session)
if group_id is not None:
history, rep = _for_group(history, group_id), None
if rep is None:
rep = replay(history)
seats = _user_seats(history, user_id)
@@ -268,21 +274,23 @@ def profile_stats(
def group_stats(session: Session, group_id: int) -> dict:
history = load_history(session, group_id)
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=replay(history),
rep=rep,
member_ids={u.id for _m, u in members}, # type: ignore[misc]
)
last_played = max((m.played_at for m in history), default=None)
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 history:
for m in group_history:
for s in m.seats:
games[s.faction_id] += 1
wins[s.faction_id] += s.place == 1
@@ -302,7 +310,8 @@ def group_stats(session: Session, group_id: int) -> dict:
for f in factions
]
# Участники без завершённых партий — отдельным блоком (нули, rank=null).
# Участники без завершённых партий в группе — отдельным блоком (нули, rank=null).
# Рейтинг у них общий: если игрок играл в других группах, он виден и здесь.
played_ids = {e["user_id"] for e in board["entries"]} | {
e["user_id"] for e in board["provisional"]
}
@@ -310,6 +319,7 @@ def group_stats(session: Session, group_id: int) -> dict:
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,
@@ -318,7 +328,8 @@ def group_stats(session: Session, group_id: int) -> dict:
"wins": 0,
"win_rate": 0.0,
"avg_place": None,
"score": 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),
}
@@ -326,7 +337,7 @@ def group_stats(session: Session, group_id: int) -> dict:
return {
"group_id": group_id,
"total_matches": len(history),
"total_matches": len(group_history),
"last_match_at": str(last_played) if last_played else None,
"leaderboard": board["entries"],
"provisional": board["provisional"],
@@ -499,8 +510,8 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
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)
@@ -509,11 +520,10 @@ def home(session: Session, user_id: int, active_group_id: int | None, leaderboar
if active_group_id is not None:
group = session.get(Group, active_group_id)
if group is not None:
group_history = _for_group(history, active_group_id)
active_group_brief = {
"id": group.id,
"name": group.name,
**_overall(group_history, replay(group_history), user_id),
**_overall(_for_group(history, active_group_id), rep, user_id),
}
return {
"leaderboard": board["entries"][:leaderboard_limit],
+1 -1
View File
@@ -380,5 +380,5 @@ def public_profile(session: Session, user_id: int) -> dict:
"nickname": user.nickname,
"bio": user.bio,
"avatar_url": avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
"stats": stats_service.profile_stats(session, user_id, group_id=None),
"stats": stats_service.profile_stats(session, user_id),
}