diff --git a/backend/app/services/scoring.py b/backend/app/services/scoring.py index ac705d2..ec7f93c 100644 --- a/backend/app/services/scoring.py +++ b/backend/app/services/scoring.py @@ -4,15 +4,30 @@ и ничьих (competition ranking). За партию из N игроков: points = (N - place - (tie_size - 1)/2) / (N - 1) 1-е место = 1.0, последнее = 0.0; равные места делят сумму очков поровну. -Рейтинговый счёт игрока = AVG(points) * 100. + +Рейтинговый счёт игрока — сглаженное среднее (байесовское, формула IMDB): + score = (PRIOR_GAMES * PRIOR_MEAN + SUM(points)) / (PRIOR_GAMES + games) * 100 +К реальным партиям «дописываются» PRIOR_GAMES виртуальных со средним PRIOR_MEAN: +на малой выборке рейтинг держится около 50 и лишь с опытом сходится к чистому +среднему — короткая удачная серия новичка не обгоняет стабильного ветерана. """ from __future__ import annotations # Порог числа игр для попадания в ранжированный топ (ниже — «Новички»/provisional). -MIN_GAMES = 5 +MIN_GAMES = 10 # Порог числа игр на фракцию для расчёта лучшей/худшей фракции. FACTION_MIN_GAMES = 2 +# Сглаживание рейтинга: сколько «виртуальных» партий и с каким средним добавляем. +PRIOR_GAMES = 10 +PRIOR_MEAN = 0.5 + +# SQL-выражение сглаженного рейтинга поверх агрегата по строкам scored (s.points). +# При 0 партий SUM = NULL → score = NULL (рейтинга без игр нет). +SMOOTHED_SCORE_SQL = ( + f"({PRIOR_GAMES} * {PRIOR_MEAN} + SUM(s.points)) / ({PRIOR_GAMES} + COUNT(*)) * 100" +) + # SQL-выражение очков за участие (tie-aware). Использует поля m.player_count, # mp.place и t.tie_size (размер группы игроков с тем же местом в партии). MATCH_POINTS_SQL = ( diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index a50e03b..ceec012 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -13,6 +13,7 @@ from app.services.scoring import ( FACTION_MIN_GAMES, MATCH_POINTS_SQL, MIN_GAMES, + SMOOTHED_SCORE_SQL, leaderboard_sort_key, ) @@ -76,7 +77,7 @@ def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]: SUM(s.is_win) AS wins, AVG(CAST(s.is_win AS FLOAT)) AS win_rate, AVG(s.place) AS avg_place, - AVG(s.points) * 100 AS score + {SMOOTHED_SCORE_SQL} AS score FROM scored s JOIN users u ON u.id = s.user_id {where} @@ -112,7 +113,7 @@ def _overall_for_user(session: Session, user_id: int, group_id: int | None) -> d SUM(s.is_win) AS wins, AVG(CAST(s.is_win AS FLOAT)) AS win_rate, AVG(s.place) AS avg_place, - AVG(s.points) * 100 AS score + {SMOOTHED_SCORE_SQL} AS score FROM scored s {cond} """ @@ -139,7 +140,8 @@ def _faction_breakdown(session: Session, user_id: int, group_id: int | None) -> SUM(s.is_win) AS wins, AVG(CAST(s.is_win AS FLOAT)) AS win_rate, AVG(s.place) AS avg_place, - AVG(s.points) * 100 AS score + AVG(s.points) * 100 AS score -- фракции: чистое среднее (служебная метрика + -- «лучшая/худшая», сглаживание задавило бы её к 50) FROM scored s JOIN factions f ON f.id = s.faction_id JOIN expansions e ON e.id = f.expansion_id diff --git a/backend/tests/test_scoring_smoothing.py b/backend/tests/test_scoring_smoothing.py new file mode 100644 index 0000000..af06321 --- /dev/null +++ b/backend/tests/test_scoring_smoothing.py @@ -0,0 +1,39 @@ +"""Сглаживание рейтинга: 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/frontend/src/pages/HelpPage.tsx b/frontend/src/pages/HelpPage.tsx index 10fbf77..d3765f5 100644 --- a/frontend/src/pages/HelpPage.tsx +++ b/frontend/src/pages/HelpPage.tsx @@ -34,8 +34,16 @@ export function HelpPage() {
- Итоговый рейтинг игрока = среднее «очков за место» по всем его партиям × 100 - (шкала от 0 до 100). + Итоговый рейтинг игрока — сглаженное среднее: к реальным партиям + «дописываются» 10 виртуальных со средним результатом 0.5 (шкала от 0 до 100): +
++ Пока партий мало, рейтинг держится около 50 и с опытом сходится к реальному + среднему — стабильные результаты на длинной дистанции ценятся выше короткой + удачной серии.
@@ -55,7 +63,7 @@ export function HelpPage() {- Чтобы попасть в ранжированный топ, нужно сыграть минимум 5 партий. До этого + Чтобы попасть в ранжированный топ, нужно сыграть минимум 10 партий. До этого игрок показывается в блоке «Новички» — предварительно, без места в рейтинге.