diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 0910e3c..4e393e1 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -375,7 +375,8 @@ class OverallStats(BaseModel): wins: int win_rate: float avg_place: float | None = None - score: float | None = None + # Рейтинг (Elo, старт 1500) целым числом; None — игрок ещё не сыграл ни одной партии. + score: int | None = None class LeaderboardEntry(OverallStats): @@ -416,6 +417,8 @@ class FactionStat(BaseModel): wins: int win_rate: float avg_place: float | None = None + # Средний результат относительно ожидания (S − E) × 100 — метрика лучшей/худшей + # фракции: выше нуля — игрок на ней выступает лучше своих рейтинговых шансов. score: float | None = None diff --git a/backend/app/services/scoring.py b/backend/app/services/scoring.py index e09c670..f6c23f5 100644 --- a/backend/app/services/scoring.py +++ b/backend/app/services/scoring.py @@ -203,33 +203,13 @@ def replay(matches: Iterable[RatedMatch]) -> Replay: return out -# ─── League Points (прежняя система; уходит вместе с SQL-витринами) ────────── - -PRIOR_GAMES = 10 -PRIOR_MEAN = 0.5 - -SMOOTHED_SCORE_SQL = ( - f"({PRIOR_GAMES} * {PRIOR_MEAN} + SUM(s.points)) / ({PRIOR_GAMES} + COUNT(*)) * 100" -) - -MATCH_POINTS_SQL = ( - "CASE WHEN m.player_count > 1 " - "THEN (m.player_count - mp.place - (t.tie_size - 1) / 2.0) " - "/ (m.player_count - 1) " - "ELSE 1.0 END" -) - - -def smoothed_score(points_sum: float, games: int) -> float | None: - if games <= 0: - return None - return (PRIOR_GAMES * PRIOR_MEAN + points_sum) / (PRIOR_GAMES + games) * 100 - - def leaderboard_sort_key(row: dict) -> tuple: - """Ключ сортировки топа: счёт ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑.""" + """Ключ сортировки топа: рейтинг ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑. + + row["rating"] — рейтинг без округления: два игрока с одинаковым целым в топе + всё равно упорядочены по настоящему значению.""" return ( - -(row["score"] or 0.0), + -(row["rating"] or 0.0), -(row["win_rate"] or 0.0), -(row["games"] or 0), (row["avg_place"] or 0.0), diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index 5f57eb1..605e190 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -1,105 +1,151 @@ -"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен).""" +"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен). + +Рейтинг — функция упорядоченной истории (scoring.replay), поэтому витрины не агрегируют +SQL, а проигрывают завершённые партии: одна загрузка истории на запрос, из неё же +считаются игры, победы, среднее место и разбивки. Цепочек две: общая — по всем партиям +приложения, групповая — только по партиям группы (это разные числа).""" from __future__ import annotations -from typing import Any +from collections import defaultdict -from sqlalchemy import func, text +from sqlalchemy import func from sqlmodel import Session, select from app.core.timeutil import iso_utc -from app.models import Faction, Group, GroupMember, Match, MatchParticipant, User +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, - MATCH_POINTS_SQL, MIN_GAMES, - SMOOTHED_SCORE_SQL, + RatedMatch, + RatedSeat, + Replay, leaderboard_sort_key, - smoothed_score, + replay, ) -# Базовый блок: одна строка на участие с tie-aware очками. -# Учитываются только ЗАВЕРШЁННЫЕ партии (in_progress без мест в статистику не входят). -SCORED_CTE = f""" -WITH tie AS ( - SELECT mp.match_id AS match_id, mp.place AS place, COUNT(*) AS tie_size - FROM match_participants mp - JOIN matches m ON m.id = mp.match_id - WHERE m.status = 'finished' AND mp.place IS NOT NULL - GROUP BY mp.match_id, mp.place -), -scored AS ( - SELECT mp.user_id AS user_id, - mp.faction_id AS faction_id, - m.id AS match_id, - m.group_id AS group_id, - m.played_at AS played_at, - mp.place AS place, - mp.was_random AS was_random, - m.player_count AS player_count, - ({MATCH_POINTS_SQL}) AS points, - CASE WHEN mp.place = 1 THEN 1 ELSE 0 END AS is_win - FROM match_participants mp - JOIN matches m ON m.id = mp.match_id - JOIN tie t ON t.match_id = mp.match_id AND t.place = mp.place - WHERE m.status = 'finished' -) -""" + +def load_history(session: Session, group_id: int | None = None) -> list[RatedMatch]: + """Завершённые партии в порядке проигрывания: дата игры, момент завершения, id. + + Один запрос на партии с участниками. In_progress в рейтинг не входят: мест у них нет.""" + stmt = ( + select(Match, MatchParticipant) + .join(MatchParticipant, MatchParticipant.match_id == Match.id) + .where(Match.status == "finished") + ) + 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 + seats: list[RatedSeat] = [] + + def flush() -> None: + if current is not None and seats: + 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 _round(value: Any, ndigits: int) -> float | None: - return None if value is None else round(float(value), ndigits) +def _for_group(history: list[RatedMatch], group_id: int) -> list[RatedMatch]: + return [m for m in history if m.group_id == group_id] -def _normalize(row: dict) -> dict: - # avatar_url мирроринг user_service.avatar_url_for: версия = epoch(updated_at) из SQL. - avatar_url = None - if row.get("avatar_path"): - avatar_url = f"/api/users/{row['user_id']}/avatar?v={int(row.get('avatar_version') or 0)}" +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 _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 { - "user_id": row["user_id"], - "nickname": row["nickname"], - "games": int(row["games"] or 0), - "wins": int(row["wins"] or 0), - "win_rate": _round(row["win_rate"] or 0.0, 4), - "avg_place": _round(row["avg_place"], 2), - "score": _round(row["score"], 1), - "avatar_url": avatar_url, + "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_rows(session: Session, group_id: int | None) -> list[dict]: - where = "WHERE s.group_id = :gid" if group_id is not None else "" - sql = f""" - {SCORED_CTE} - SELECT u.id AS user_id, u.nickname AS nickname, - u.avatar_path AS avatar_path, - CAST(strftime('%s', u.updated_at) AS INTEGER) AS avatar_version, - COUNT(*) AS games, - SUM(s.is_win) AS wins, - AVG(CAST(s.is_win AS FLOAT)) AS win_rate, - AVG(s.place) AS avg_place, - {SMOOTHED_SCORE_SQL} AS score - FROM scored s - JOIN users u ON u.id = s.user_id - {where} - GROUP BY u.id, u.nickname, u.avatar_path, u.updated_at - """ - params = {"gid": group_id} if group_id is not None else {} - result = session.execute(text(sql), params).mappings().all() - return [_normalize(dict(r)) for r in result] +def leaderboard( + session: Session, + group_id: int | None = None, + *, + history: list[RatedMatch] | None = None, + rep: Replay | None = None, +) -> dict: + """Топ: общий или группы. history/rep — уже посчитанные (home их переиспользует).""" + if history is None: + history = load_history(session, group_id) + if rep is None: + rep = replay(history) + by_user: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list) + for m in history: + for s in m.seats: + 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 {} + ) -def leaderboard(session: Session, group_id: int | None = None) -> dict: - rows = _leaderboard_rows(session, group_id) - qualified = [r for r in rows if r["games"] >= MIN_GAMES] - provisional = [r for r in rows if r["games"] < MIN_GAMES] - qualified.sort(key=leaderboard_sort_key) - provisional.sort(key=leaderboard_sort_key) + 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], # только для сортировки + "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) 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, @@ -107,72 +153,38 @@ def leaderboard(session: Session, group_id: int | None = None) -> dict: } -def _user_scored_rows(session: Session, user_id: int) -> list[dict]: - """Строки участия игрока со всеми полями, нужными витринам профиля. - - Один проход по SCORED_CTE вместо трёх: общий итог, разбивка по фракциям и форма - последних партий считаются из одного и того же набора строк. CTE джойнит участия - со всеми партиями приложения, поэтому каждый лишний проход дорожает вместе с - общим числом партий, а не с числом партий игрока.""" - sql = f""" - {SCORED_CTE} - SELECT s.group_id AS group_id, s.match_id AS match_id, s.played_at AS played_at, - s.place AS place, s.player_count AS player_count, - s.points AS points, s.is_win AS is_win, - f.id AS faction_id, f.code AS code, f.name_ru AS name_ru, - e.code AS expansion_code - FROM scored s - JOIN factions f ON f.id = s.faction_id - JOIN expansions e ON e.id = f.expansion_id - WHERE s.user_id = :uid - """ - rows = session.execute(text(sql), {"uid": user_id}).mappings().all() - return [dict(r) for r in rows] - - -def _for_group(rows: list[dict], group_id: int | None) -> list[dict]: - return rows if group_id is None else [r for r in rows if r["group_id"] == group_id] - - -def _overall_from_rows(rows: list[dict]) -> dict: - """Тот же итог, что раньше считал SQL: COUNT/SUM/AVG плюс сглаженный рейтинг.""" - games = len(rows) - if games == 0: - return {"games": 0, "wins": 0, "win_rate": 0.0, "avg_place": None, "score": None} - wins = sum(int(r["is_win"]) for r in rows) - return { - "games": games, - "wins": wins, - "win_rate": _round(wins / games, 4), - "avg_place": _round(sum(r["place"] for r in rows) / games, 2), - "score": _round(smoothed_score(sum(float(r["points"]) for r in rows), games), 1), +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() } - - -def _faction_breakdown_from_rows(rows: list[dict]) -> list[dict]: - by_faction: dict[int, list[dict]] = {} - for r in rows: - by_faction.setdefault(r["faction_id"], []).append(r) out = [] for fid, group in by_faction.items(): - games = len(group) - wins = sum(int(r["is_win"]) for r in group) - meta = group[0] + 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": meta["code"], - "name_ru": meta["name_ru"], - "expansion_code": meta["expansion_code"], - "games": games, - "wins": wins, - "win_rate": _round(wins / games, 4), - "avg_place": _round(sum(r["place"] for r in group) / games, 2), - # Фракции: чистое среднее (служебная метрика «лучшая/худшая», - # сглаживание задавило бы её к 50). - "score": _round(sum(float(r["points"]) for r in group) / games * 100, 1), + "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( - meta["code"], meta["name_ru"] + faction.code, faction.name_ru ), } ) @@ -180,15 +192,15 @@ def _faction_breakdown_from_rows(rows: list[dict]) -> list[dict]: return out -def _recent_form_from_rows(rows: list[dict], limit: int = 5) -> list[dict]: - recent = sorted(rows, key=lambda r: (str(r["played_at"]), r["match_id"]), reverse=True) +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": r["place"], - "player_count": r["player_count"], - "played_at": str(r["played_at"]), + "place": s.place, + "player_count": len(m.seats), + "played_at": str(m.played_at), } - for r in recent[:limit] + for m, s in recent[:limit] ] @@ -208,69 +220,73 @@ def _favorite_faction(session: Session, user_id: int) -> dict | None: } +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, group_id: int | None = None, *, - rows: list[dict] | None = None, + history: list[RatedMatch] | None = None, + rep: Replay | None = None, ) -> dict: - """Витрина профиля. rows — уже вытащенные строки игрока (home() их переиспользует).""" - scoped = _for_group(rows if rows is not None else _user_scored_rows(session, user_id), group_id) - overall = _overall_from_rows(scoped) - factions = _faction_breakdown_from_rows(scoped) + """Витрина профиля. history/rep — общая цепочка, уже посчитанная (home их переиспользует); + с group_id считается групповая цепочка.""" + 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) + 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"] or 0)) if qualified else None - worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None + 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": overall, + "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_from_rows(scoped), + "recent_form": _recent_form(seats), "min_games": MIN_GAMES, } def group_stats(session: Session, group_id: int) -> dict: - board = leaderboard(session, group_id=group_id) - # Нужны только счётчик и дата последней партии — тянуть строки целиком незачем. - games_count, last_played = session.exec( - select(func.count(), func.max(Match.played_at)).where( - Match.group_id == group_id, Match.status == "finished" - ) - ).one() - last_at = str(last_played) if last_played else None + history = load_history(session, group_id) + board = leaderboard(session, group_id, history=history, rep=replay(history)) + last_played = max((m.played_at for m in history), default=None) + games: dict[int, int] = defaultdict(int) + wins: dict[int, int] = defaultdict(int) + for m in 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) - faction_meta = [] - sql = f""" - {SCORED_CTE} - SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru, - COUNT(s.user_id) AS games, SUM(s.is_win) AS wins - FROM factions f - LEFT JOIN scored s ON s.faction_id = f.id AND s.group_id = :gid - GROUP BY f.id, f.code, f.name_ru - ORDER BY games DESC, f.sort_order - """ - rows = session.execute(text(sql), {"gid": group_id}).mappings().all() - for r in rows: - faction_meta.append( - { - "faction_id": r["faction_id"], - "code": r["code"], - "name_ru": r["name_ru"], - "games": int(r["games"] or 0), - "wins": int(r["wins"] or 0), - "available": r["faction_id"] in available_ids, - } - ) + 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"]} | { @@ -296,8 +312,8 @@ def group_stats(session: Session, group_id: int) -> dict: return { "group_id": group_id, - "total_matches": games_count, - "last_match_at": last_at, + "total_matches": len(history), + "last_match_at": str(last_played) if last_played else None, "leaderboard": board["entries"], "provisional": board["provisional"], "inactive": inactive, @@ -392,19 +408,15 @@ def user_match_list( ) -> dict: """История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху. - best_only — одна лучшая партия по League Points (s.points из SCORED_CTE учитывает - место и размер стола); при равных очках берём более свежую.""" + best_only — одна лучшая партия: наибольший прирост рейтинга в общей цепочке + (учитывает и соперников, и ход партии); при равенстве берём более свежую.""" if best_only: - sql = f""" - {SCORED_CTE} - SELECT s.match_id AS match_id - FROM scored s - WHERE s.user_id = :uid - ORDER BY s.points DESC, s.played_at DESC, s.match_id DESC - LIMIT 1 - """ - row = session.execute(text(sql), {"uid": user_id}).mappings().first() - matches = [session.get(Match, row["match_id"])] if row else [] + history = load_history(session) + rep = replay(history) + candidates = [ + (rep.delta[(m.id, user_id)], m.played_at, m.id) for m, _s in _user_seats(history, user_id) + ] + matches = [session.get(Match, max(candidates)[2])] if candidates else [] return { "items": _match_items(session, matches), "total": len(matches), @@ -466,18 +478,21 @@ 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: - board = leaderboard(session, group_id=None) - # Строки игрока тянем один раз: из них считается и профиль, и итог по активной группе. - rows = _user_scored_rows(session, user_id) - profile = profile_stats(session, user_id, group_id=None, rows=rows) + # История грузится и общая цепочка проигрывается один раз: из них и топ, и профиль; + # итог по активной группе — отдельная (групповая) цепочка по срезу той же истории. + 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: + group_history = _for_group(history, active_group_id) active_group_brief = { "id": group.id, "name": group.name, - **_overall_from_rows(_for_group(rows, active_group_id)), + **_overall(group_history, replay(group_history), user_id), } return { "leaderboard": board["entries"][:leaderboard_limit], diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index 03b02fb..69015fd 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -322,9 +322,10 @@ def test_history_excludes_matches_without_the_player(client: TestClient, engine) def test_history_best_mode_picks_highest_points(client: TestClient, engine): - """Режим best берёт партию с максимальными League Points, а не самую свежую. + """Режим best берёт партию с наибольшим приростом рейтинга, а не самую свежую. - Второе место из четырёх даёт (4-2)/3 ≈ 0.67, второе из двух — (2-2)/1 = 0.""" + Второе место из четырёх равных приносит рейтинг (обыграны двое), второе место + в дуэли — отнимает.""" me = login(client, "Лучший") gid, (a, b, c), fids = _group_with(client, engine, "А", "Б", "В") diff --git a/backend/tests/test_rating_stats.py b/backend/tests/test_rating_stats.py new file mode 100644 index 0000000..050f2a9 --- /dev/null +++ b/backend/tests/test_rating_stats.py @@ -0,0 +1,140 @@ +"""Рейтинг в витринах (#23): Elo по упорядоченной истории, общая и групповая цепочки.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.services.scoring import RatedMatch, RatedSeat, replay +from tests.conftest import add_group_member, create_finished_match, csrf_headers, login + + +def _group(client: TestClient, name: str = "Группа") -> tuple[int, list[int]]: + gid = client.post( + "/api/groups", json={"name": name, "expansion_ids": []}, headers=csrf_headers(client) + ).json()["id"] + fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] + return gid, fids + + +def _duel(client: TestClient, gid: int, fids: list[int], winner: int, loser: int) -> dict: + return create_finished_match( + client, gid, + [ + {"user_id": winner, "faction_id": fids[0], "place": 1}, + {"user_id": loser, "faction_id": fids[1], "place": 2}, + ], + ) + + +def _board(client: TestClient, path: str = "/api/stats/leaderboard") -> dict[int, dict]: + data = client.get(path).json() + rows = data["entries"] + data["provisional"] if "entries" in data else ( + data["leaderboard"] + data["provisional"] + ) + return {e["user_id"]: e for e in rows} + + +def test_newcomer_duel_moves_rating_by_32(client: TestClient, engine): + """Первая дуэль новичков на 1500: K = 64, ожидание 0.5, деталей нет → ±32.""" + me = login(client, "Хозяин") + gid, fids = _group(client) + b = add_group_member(engine, gid, "Гость") + _duel(client, gid, fids, me["id"], b) + + board = client.get("/api/stats/leaderboard").json() + by_id = {e["user_id"]: e for e in board["entries"] + board["provisional"]} + assert by_id[me["id"]]["score"] == 1532 + assert by_id[b]["score"] == 1468 + # 1 игра < MIN_GAMES=10 → оба пока «Новички», ранжированный топ пуст. + assert board["entries"] == [] + assert board["min_games"] == 10 + + prof = client.get("/api/users/me/stats").json() + assert prof["overall"]["score"] == 1532 + # Фракционная метрика — S − E: победа при шансах 0.5 даёт +0.5 → 50.0. + assert prof["factions"][0]["score"] == 50.0 + + +def test_ranked_after_min_games(client: TestClient, engine): + me = login(client, "Чемпион") + gid, fids = _group(client) + b = add_group_member(engine, gid, "Спарринг") + for _ in range(10): + _duel(client, gid, fids, me["id"], b) + board = client.get("/api/stats/leaderboard").json() + ranks = {e["user_id"]: (e["rank"], e["score"]) for e in board["entries"]} + assert ranks[me["id"]][0] == 1 and ranks[b][0] == 2 + assert ranks[me["id"]][1] > 1500 > ranks[b][1] + assert isinstance(ranks[me["id"]][1], int) + + +def test_editing_past_match_recalculates_later_ones(client: TestClient, engine): + """Рейтинг — функция истории: правка первой партии меняет итог после второй.""" + me = login(client, "А") + gid, fids = _group(client) + b = add_group_member(engine, gid, "Б") + first = _duel(client, gid, fids, me["id"], b) + _duel(client, gid, fids, me["id"], b) + + def expected(first_winner: int, first_loser: int) -> dict[int, int]: + rep = replay([ + RatedMatch((RatedSeat(first_winner, 1), RatedSeat(first_loser, 2)), "objectives", id=1), + RatedMatch((RatedSeat(me["id"], 1), RatedSeat(b, 2)), "objectives", id=2), + ]) + return {uid: round(r) for uid, r in rep.ratings.items()} + + before = expected(me["id"], b) + assert {uid: e["score"] for uid, e in _board(client).items()} == before + + rows = [ + {"user_id": b, "faction_id": fids[1], "place": 1}, + {"user_id": me["id"], "faction_id": fids[0], "place": 2}, + ] + r = client.patch( + f"/api/matches/{first['id']}", + json={"participants": rows, "win_reason": "objectives"}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + after = expected(b, me["id"]) + assert after != before + assert {uid: e["score"] for uid, e in _board(client).items()} == after + + +def test_group_rating_is_its_own_chain(client: TestClient, engine): + """Групповой рейтинг проигрывает только партии группы, общий — все партии.""" + me = login(client, "Путешественник") + g1, f1 = _group(client, "Первая") + g2, f2 = _group(client, "Вторая") + b = add_group_member(engine, g1, "Сосед") + c = add_group_member(engine, g2, "Соседка") + _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( + 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"]] + ) + + +def test_best_match_is_biggest_rating_gain(client: TestClient, engine): + """Лучшая партия — наибольший прирост рейтинга, а не свежая из равных побед. + + Обе партии — победы в дуэли. Первая — новичком над равным (+32), вторая — уже + с рейтингом 1532 и меньшим K над новичком (≈ +28): лучше первая.""" + me = login(client, "Лучший") + gid, fids = _group(client) + x = add_group_member(engine, gid, "Икс") + y = add_group_member(engine, gid, "Игрек") + first = _duel(client, gid, fids, me["id"], x) + _duel(client, gid, fids, me["id"], y) + + client.patch("/api/users/me/profile", json={"history_mode": "best"}, headers=csrf_headers(client)) + data = client.get(f"/api/users/{me['id']}/matches").json() + assert data["total"] == 1 + assert data["items"][0]["id"] == first["id"] diff --git a/backend/tests/test_scoring_smoothing.py b/backend/tests/test_scoring_smoothing.py deleted file mode 100644 index af06321..0000000 --- a/backend/tests/test_scoring_smoothing.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Сглаживание рейтинга: score = (C·m + сумма очков) / (C + игр) × 100, C=10, m=0.5.""" -from __future__ import annotations - -from fastapi.testclient import TestClient - -from tests.conftest import add_group_member, create_finished_match, csrf_headers, login - - -def test_leaderboard_and_profile_score_are_smoothed(client: TestClient, engine): - me = login(client, "Хозяин") - gid = client.post( - "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) - ).json()["id"] - b = add_group_member(engine, gid, "Гость") - factions = client.get(f"/api/groups/{gid}/factions").json() - f1, f2 = factions[0]["id"], factions[1]["id"] - - create_finished_match( - client, - gid, - [ - {"user_id": me["id"], "faction_id": f1, "place": 1}, - {"user_id": b, "faction_id": f2, "place": 2}, - ], - ) - - board = client.get("/api/stats/leaderboard").json() - by_id = {e["user_id"]: e for e in board["entries"] + board["provisional"]} - # Победитель: 1 очко за партию → (10·0.5 + 1) / (10 + 1) × 100 = 54.5. - # Проигравший: 0 очков → (10·0.5 + 0) / 11 × 100 = 45.5. - assert by_id[me["id"]]["score"] == 54.5 - assert by_id[b]["score"] == 45.5 - # 1 игра < MIN_GAMES=10 → оба пока «Новички», ранжированный топ пуст. - assert board["entries"] == [] - assert board["min_games"] == 10 - - # Профиль показывает тот же сглаженный рейтинг, что и топ. - prof = client.get("/api/users/me/stats").json() - assert prof["overall"]["score"] == 54.5 diff --git a/backend/tests/test_stats_passes.py b/backend/tests/test_stats_passes.py index 6839358..cd1e822 100644 --- a/backend/tests/test_stats_passes.py +++ b/backend/tests/test_stats_passes.py @@ -1,9 +1,9 @@ -"""Статистика профиля: цифры сходятся с лидербордом, а главная не гоняет CTE лишний раз.""" +"""Статистика профиля: цифры сходятся с лидербордом, а главная не грузит историю лишний раз.""" from __future__ import annotations from fastapi.testclient import TestClient -from sqlalchemy import event +from app.services import stats_service from tests.conftest import add_group_member, create_finished_match, csrf_headers, login @@ -31,10 +31,7 @@ def _group_with_matches(client: TestClient, engine, games: int = 3) -> tuple[dic def test_profile_numbers_match_leaderboard(client: TestClient, engine): - """Профиль считает в Python, лидерборд — в SQL: цифры обязаны совпадать. - - Формула сглаженного рейтинга живёт в двух видах (SMOOTHED_SCORE_SQL и - scoring.smoothed_score); этот тест ловит их расхождение.""" + """Профиль и лидерборд собирают итог разными путями: цифры обязаны совпадать.""" me, gid, p2 = _group_with_matches(client, engine, games=4) profile = client.get("/api/users/me/stats").json()["overall"] @@ -47,33 +44,25 @@ def test_profile_numbers_match_leaderboard(client: TestClient, engine): assert profile[field] == entry[field], field -def test_home_does_not_repeat_scored_cte(client: TestClient, engine): - """Главная делает не больше двух проходов по SCORED_CTE. - - Было пять: лидерборд, три запроса профиля и итог по активной группе. Без этой - проверки оптимизация тихо отъедет назад при следующей правке витрин.""" +def test_home_loads_history_once(client: TestClient, engine, monkeypatch): + """Главная грузит историю партий один раз: топ, профиль и итог активной группы + считаются из неё. Без этой проверки лишняя загрузка тихо вернётся при правке витрин.""" me, gid, p2 = _group_with_matches(client, engine, games=2) client.put( "/api/users/me/active-group", json={"group_id": gid}, headers=csrf_headers(client) ) - seen: list[str] = [] + calls: list[int | None] = [] + original = stats_service.load_history - def before_execute(conn, cursor, statement, params, context, executemany): - if "WITH tie AS" in statement: - seen.append(statement) + def spy(session, group_id=None): + calls.append(group_id) + return original(session, group_id) - event.listen(engine, "before_cursor_execute", before_execute) - try: - r = client.get("/api/home") - assert r.status_code == 200, r.text - finally: - event.remove(engine, "before_cursor_execute", before_execute) - - # Сейчас ровно два: лидерборд и один проход по строкам игрока. Нижняя граница не - # для красоты — без неё тест пройдёт и когда счётчик молча перестанет что-либо - # ловить (сменился путь, переименован CTE). - assert 1 <= len(seen) <= 2, f"ожидали 1–2 прохода, получили {len(seen)}" + monkeypatch.setattr(stats_service, "load_history", spy) + r = client.get("/api/home") + assert r.status_code == 200, r.text + assert calls == [None] # Главная всё ещё показывает и профиль, и блок активной группы. body = r.json() assert body["profile"]["overall"]["games"] == 2