From 640f712037fac6816a9f70fb1ca36d164ea84ee4 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Tue, 15 Sep 2026 00:18:53 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B9=D1=82=D0=B8=D0=BD=D0=B3:=20?= =?UTF-8?q?=D0=B5=D0=B4=D0=B8=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B5=D0=B9=D1=82?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Групповая цепочка убрана: рейтинг у игрока один, по всем партиям приложения (решение владельца). load_history всегда грузит всю историю, одно проигрывание на запрос. Страница группы: игры, победы, винрейт и среднее место — по партиям группы, рейтинг — общий; «Новичок» тоже по общему числу партий (новое поле rating_confirmed), поэтому опытный игрок в новой группе ранжирован. Участники, не игравшие в группе, показывают общий рейтинг. Главная и профиль — общие показатели; в блоке активной группы игры по группе, рейтинг общий. У profile_stats убран неиспользуемый group_id. Фронт: цвет рейтинга в списках берётся из rating_confirmed; справка — «Один рейтинг на всё приложение». Документ рейтинга: раздел 8 и решение 7 в разделе 9. Тесты: рейтинг в группах равен общему при групповых играх и победах, главная и блок активной группы, ветеран в новой группе ранжирован, «Ещё не играли» с общим рейтингом. #80 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd --- backend/app/schemas/api.py | 3 + backend/app/services/stats_service.py | 74 ++++++++++++---------- backend/app/services/user_service.py | 2 +- backend/tests/test_group_rating_members.py | 2 +- backend/tests/test_rating_stats.py | 56 +++++++++++++--- backend/tests/test_stats_passes.py | 10 +-- docs/rating/rating-system.md | 8 ++- frontend/src/api/schema.d.ts | 5 ++ frontend/src/components/Leaderboard.tsx | 10 ++- frontend/src/pages/HelpPage.tsx | 8 ++- 10 files changed, 124 insertions(+), 54 deletions(-) diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index ab63947..44ece85 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -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): diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index 8ab1abb..5214d4e 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -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], diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 8763606..df6b47c 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -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), } diff --git a/backend/tests/test_group_rating_members.py b/backend/tests/test_group_rating_members.py index be37ac7..b08edec 100644 --- a/backend/tests/test_group_rating_members.py +++ b/backend/tests/test_group_rating_members.py @@ -1,7 +1,7 @@ """Список игроков группы — только текущий состав (#76). Удалённый из группы игрок пропадает из рейтинга группы, но его партии остаются в -групповой цепочке: они уже повлияли на рейтинг оставшихся.""" +истории: они уже повлияли на (общий) рейтинг оставшихся.""" from __future__ import annotations from fastapi.testclient import TestClient diff --git a/backend/tests/test_rating_stats.py b/backend/tests/test_rating_stats.py index 9fe572b..780bf29 100644 --- a/backend/tests/test_rating_stats.py +++ b/backend/tests/test_rating_stats.py @@ -1,4 +1,4 @@ -"""Рейтинг в витринах (#23): Elo по упорядоченной истории, общая и групповая цепочки.""" +"""Рейтинг в витринах (#23, #80): Elo по упорядоченной истории, один рейтинг на игрока.""" from __future__ import annotations from fastapi.testclient import TestClient @@ -100,8 +100,9 @@ def test_editing_past_match_recalculates_later_ones(client: TestClient, engine): assert {uid: e["score"] for uid, e in _board(client).items()} == after -def test_group_rating_is_its_own_chain(client: TestClient, engine): - """Групповой рейтинг проигрывает только партии группы, общий — все партии.""" +def test_group_page_shows_overall_rating_with_group_stats(client: TestClient, engine): + """Рейтинг один на всё приложение (#80): в группе он тот же, что в общем топе, + а игры и победы — только по партиям группы.""" me = login(client, "Путешественник") g1, f1 = _group(client, "Первая") g2, f2 = _group(client, "Вторая") @@ -110,16 +111,55 @@ def test_group_rating_is_its_own_chain(client: TestClient, engine): _duel(client, g1, f1, me["id"], b) # в первой группе — победа _duel(client, g2, f2, c, me["id"]) # во второй — поражение - assert _board(client, f"/api/groups/{g1}/stats")[me["id"]]["score"] == 1532 - assert _board(client, f"/api/groups/{g2}/stats")[me["id"]]["score"] == 1468 - overall = _board(client)[me["id"]]["score"] - assert overall not in (1532, 1468) - assert overall == round( + overall = _board(client)[me["id"]] + assert overall["games"] == 2 + assert overall["score"] == round( replay([ RatedMatch((RatedSeat(me["id"], 1), RatedSeat(b, 2)), "objectives", id=1), RatedMatch((RatedSeat(c, 1), RatedSeat(me["id"], 2)), "objectives", id=2), ]).ratings[me["id"]] ) + first = _board(client, f"/api/groups/{g1}/stats")[me["id"]] + second = _board(client, f"/api/groups/{g2}/stats")[me["id"]] + assert first["score"] == second["score"] == overall["score"] + assert (first["games"], first["wins"]) == (1, 1) + assert (second["games"], second["wins"]) == (1, 0) + + # Главная и профиль — общие показатели; блок активной группы — игры в группе. + client.put("/api/users/me/active-group", json={"group_id": g2}, headers=csrf_headers(client)) + home = client.get("/api/home").json() + assert (home["profile"]["overall"]["games"], home["profile"]["overall"]["score"]) == ( + 2, overall["score"] + ) + assert (home["active_group"]["games"], home["active_group"]["score"]) == (1, overall["score"]) + + +def test_veteran_is_not_a_newcomer_in_new_group(client: TestClient, engine): + """Статус «Новичок» — про надёжность рейтинга, а он общий: 10 партий где угодно + делают игрока ранжированным и в группе, где он сыграл одну.""" + me = login(client, "Ветеран") + g1, f1 = _group(client, "Старая") + b = add_group_member(engine, g1, "Спарринг") + for _ in range(10): + _duel(client, g1, f1, me["id"], b) + + g2, f2 = _group(client, "Новая") + c = add_group_member(engine, g2, "Новенький") + add_group_member(engine, g2, "Спарринг") # опытный, но в новой группе не играл + d = add_group_member(engine, g2, "Зритель") # не играл нигде + _duel(client, g2, f2, me["id"], c) + + stats = client.get(f"/api/groups/{g2}/stats").json() + ranked = {e["user_id"]: e for e in stats["leaderboard"]} + assert ranked[me["id"]]["rank"] == 1 + assert (ranked[me["id"]]["games"], ranked[me["id"]]["rating_confirmed"]) == (1, True) + assert ranked[me["id"]]["score"] == _board(client)[me["id"]]["score"] + assert [e["user_id"] for e in stats["provisional"]] == [c] + + inactive = {e["user_id"]: e for e in stats["inactive"]} + assert inactive[b]["score"] == _board(client)[b]["score"] + assert (inactive[b]["games"], inactive[b]["rating_confirmed"]) == (0, True) + assert (inactive[d]["score"], inactive[d]["rating_confirmed"]) == (None, False) def test_best_match_is_biggest_rating_gain(client: TestClient, engine): diff --git a/backend/tests/test_stats_passes.py b/backend/tests/test_stats_passes.py index cd1e822..67cf555 100644 --- a/backend/tests/test_stats_passes.py +++ b/backend/tests/test_stats_passes.py @@ -52,17 +52,17 @@ def test_home_loads_history_once(client: TestClient, engine, monkeypatch): "/api/users/me/active-group", json={"group_id": gid}, headers=csrf_headers(client) ) - calls: list[int | None] = [] + calls: list[int] = [] original = stats_service.load_history - def spy(session, group_id=None): - calls.append(group_id) - return original(session, group_id) + def spy(session): + calls.append(1) + return original(session) monkeypatch.setattr(stats_service, "load_history", spy) r = client.get("/api/home") assert r.status_code == 200, r.text - assert calls == [None] + assert len(calls) == 1 # Главная всё ещё показывает и профиль, и блок активной группы. body = r.json() assert body["profile"]["overall"]["games"] == 2 diff --git a/docs/rating/rating-system.md b/docs/rating/rating-system.md index 175fe5b..d803733 100644 --- a/docs/rating/rating-system.md +++ b/docs/rating/rating-system.md @@ -670,8 +670,11 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab} завершённых партий по порядку (`played_at`, `finished_at`, `id`), а не агрегатом SQL. Данных мало: сотни партий, микросекунды на пару. Существующий принцип «считается вживую» сохраняется, кэш можно ввести позже с инвалидацией по уже существующим SSE-событиям. -- **Две цепочки:** общий рейтинг — по всем партиям приложения, групповой — по партиям - группы. Это разные числа, как и сейчас. +- **Одна цепочка** (решение владельца 2026-09-15, #80): рейтинг у игрока один — по всем + партиям приложения, K — по всем его партиям. Отдельного группового рейтинга нет: + на странице группы игры, победы, винрейт и среднее место считаются по партиям группы, + а рейтинг и статус «Новичок» — общие. Первая версия реализации (#23) держала две + цепочки, общую и групповую, — это оказалось неинтуитивно (раздел 9). - Правка или удаление прошлой партии автоматически меняет всё после неё: при пересчёте с нуля отдельной логики не нужно. - Эталон — `rate_match` в `simulate.py`. Примеры из раздела 6 стоит перенести в тесты @@ -728,6 +731,7 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab} | 4 | Ввод миров на конец партии | **Оставить** | без изменений: `w_w = 0.5` | | 5 | Затухание за неактивность | **Не добавлять** | без изменений | | 6 | Минимум партий для топа | **Оставить 10** | без изменений: `MIN_GAMES = 10` | +| 7 | Групповой рейтинг отдельной цепочкой (после внедрения, #80, 2026-09-15) | **Убрать.** Рейтинг единый; на странице группы — показатели по партиям группы, на главной и в профиле — общие | раздел 8, «Расчёт» | Открытых вопросов по предложению не осталось. Калибровка коэффициентов на реальных данных — после внедрения #23 (раздел 8, «Калибровка»). diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 19f9d8f..7a73d40 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -1724,6 +1724,11 @@ export interface components { rank?: number | null; /** Avatar Url */ avatar_url?: string | null; + /** + * Rating Confirmed + * @default false + */ + rating_confirmed: boolean; }; /** MatchCreate */ MatchCreate: { diff --git a/frontend/src/components/Leaderboard.tsx b/frontend/src/components/Leaderboard.tsx index edc08d7..54f4eff 100644 --- a/frontend/src/components/Leaderboard.tsx +++ b/frontend/src/components/Leaderboard.tsx @@ -30,8 +30,14 @@ function Row({ {/* Серебристый — у новичков и у прочерка ещё не игравших; золотой — только - подтверждённый рейтинг. */} -
+ подтверждённый рейтинг. Рейтинг общий, поэтому подтверждённость приходит + флагом: в группе «Ещё не играли» бывают и новички, и опытные игроки. */} +
{entry.score ?? "—"}
diff --git a/frontend/src/pages/HelpPage.tsx b/frontend/src/pages/HelpPage.tsx index aebfefc..be864ea 100644 --- a/frontend/src/pages/HelpPage.tsx +++ b/frontend/src/pages/HelpPage.tsx @@ -135,10 +135,12 @@ export function HelpPage() {
-

Общий и групповой рейтинг

+

Один рейтинг на всё приложение

- Общий рейтинг считается по всем партиям приложения, групповой — только по партиям - группы. Поэтому числа в общем топе и в топе группы различаются. + Рейтинг у игрока один — по всем его партиям во всех группах, поэтому он одинаковый + в общем топе, в профиле и на странице группы. На странице группы по партиям этой + группы считаются только игры, победы, винрейт и среднее место; рейтинг и статус + «Новичок» там общие.