diff --git a/backend/alembic/versions/0014_rating_inputs.py b/backend/alembic/versions/0014_rating_inputs.py index fec3289..2c2b29c 100644 --- a/backend/alembic/versions/0014_rating_inputs.py +++ b/backend/alembic/versions/0014_rating_inputs.py @@ -32,12 +32,15 @@ NEW_REASONS = ( ) # Причина last_standing ⇔ невыбывший участник ровно один (решение владельца по #22). +# Только у партий хотя бы с двумя участниками: партия, из которой dev-удаление аккаунта +# вычеркнуло соперника, тоже имеет «одного невыбывшего», но победой выжившего не была. BACKFILL_LAST_STANDING = """ UPDATE matches SET win_reason = 'last_standing' WHERE status = 'finished' AND (win_reason IS NULL OR win_reason <> 'last_standing') AND (SELECT COUNT(*) FROM match_participants mp WHERE mp.match_id = matches.id AND mp.eliminated = 0) = 1 + AND (SELECT COUNT(*) FROM match_participants mp WHERE mp.match_id = matches.id) >= 2 """ diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index 5214d4e..8e495c7 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -26,11 +26,28 @@ from app.services.scoring import ( ) +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 в рейтинг не входят: мест у них нет. - Срез группы — _for_group по этой же истории: рейтинг считается только целиком.""" + Один запрос на партии с участниками. In_progress в рейтинг не входят: мест у них нет, + партии меньше чем с двумя участниками — тоже (_playable_match_ids). Срез группы — + _for_group по этой же истории: рейтинг считается только целиком.""" stmt = ( select(Match, MatchParticipant) .join(MatchParticipant, MatchParticipant.match_id == Match.id) @@ -43,7 +60,7 @@ def load_history(session: Session) -> list[RatedMatch]: seats: list[RatedSeat] = [] def flush() -> None: - if current is not None and seats: + if current is not None and len(seats) >= MIN_PARTICIPANTS: history.append( RatedMatch( seats=tuple(seats), @@ -411,12 +428,11 @@ def _match_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() + 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(Match.group_id == group_id) + .where(*where) .order_by(Match.played_at.desc(), Match.id.desc()) .offset(offset) .limit(limit) @@ -456,7 +472,11 @@ def user_match_list( "offset": 0, } - where = (MatchParticipant.user_id == user_id, Match.status == "finished") + where = ( + MatchParticipant.user_id == user_id, + Match.status == "finished", + Match.id.in_(_playable_match_ids()), + ) total = session.exec( select(func.count()) .select_from(Match) diff --git a/backend/tests/test_rating_stats.py b/backend/tests/test_rating_stats.py index 780bf29..5e0f18f 100644 --- a/backend/tests/test_rating_stats.py +++ b/backend/tests/test_rating_stats.py @@ -180,6 +180,39 @@ def test_best_match_is_biggest_rating_gain(client: TestClient, engine): assert data["items"][0]["id"] == first["id"] +def test_match_left_with_one_participant_is_not_a_game(client: TestClient, engine, make_admin): + """Dev-удаление аккаунта вычёркивает игрока из партий, не трогая сами партии. Партия, + где остался один участник, — не игра: её нет в рейтинге, историях и списках группы + (карточка «с одним игроком» раньше висела в профиле).""" + me = login(client, "Выживший") + gid, fids = _group(client) + b = add_group_member(engine, gid, "Удалённый") + c = add_group_member(engine, gid, "Соперник") + orphan = _duel(client, gid, fids, me["id"], b) + kept = _duel(client, gid, fids, c, me["id"]) + + make_admin("admin", "secret123") + assert client.post( + "/api/admin/auth/login", + json={"username": "admin", "password": "secret123"}, + headers=csrf_headers(client), + ).status_code == 200 + assert client.delete(f"/api/admin/dev/users/{b}", headers=csrf_headers(client)).status_code == 200 + # Админка партию по-прежнему видит — удалить её можно оттуда. + assert client.get(f"/api/admin/matches/{orphan['id']}").status_code == 200 + + history = client.get(f"/api/users/{me['id']}/matches").json() + assert (history["total"], [i["id"] for i in history["items"]]) == (1, [kept["id"]]) + group_list = client.get(f"/api/groups/{gid}/matches").json() + assert (group_list["total"], [i["id"] for i in group_list["items"]]) == (1, [kept["id"]]) + assert client.get(f"/api/groups/{gid}/stats").json()["total_matches"] == 1 + + # В рейтинге — только настоящая партия: дуэль новичков, проигрыш −32. + board = _board(client) + assert (board[me["id"]]["games"], board[me["id"]]["score"]) == (1, 1468) + assert board[c]["score"] == 1532 + + def test_history_shows_rating_delta_of_its_owner(client: TestClient, engine): """История игрока несёт изменение его общего рейтинга за каждую партию (#77).""" me = login(client, "Историк") diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx index c2164ae..a9ef368 100644 --- a/frontend/src/pages/MatchDetailPage.tsx +++ b/frontend/src/pages/MatchDetailPage.tsx @@ -57,7 +57,7 @@ export function MatchDetailPage() { // Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN. const parsed = matchId ? Number(matchId) : NaN; const id = Number.isInteger(parsed) ? parsed : null; - const { data: match, isLoading, refetch } = useMatch(id); + const { data: match, isLoading, error: loadError, refetch } = useMatch(id); const { data: me } = useMe(); const { data: groupFactions } = useGroupFactions(match?.group_id ?? null); const finish = useFinishMatch(); @@ -132,7 +132,19 @@ export function MatchDetailPage() { }, [incoming, participants, match?.status, me?.id]); if (isLoading) return ; - if (!match) return
Партия не найдена.
; + if (!match) { + // Из истории чужого профиля можно попасть в партию группы, где зритель не состоит: + // сервер отдаёт 403, и «не найдена» здесь вводила бы в заблуждение. + if (loadError instanceof ApiError && loadError.code === "NOT_GROUP_MEMBER") { + return ( +
+ Партия сыграна в группе, в которой вы не состоите, — открыть её могут только + участники группы. +
+ ); + } + return
Партия не найдена.
; + } const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (любой участник группы или админ) const inProgress = match.status === "in_progress";