From ebb368686d867561264138604837fb20117c64ab Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Fri, 17 Jul 2026 03:04:48 +0300 Subject: [PATCH 1/3] =?UTF-8?q?=D0=A0=D0=B5=D0=B9=D1=82=D0=B8=D0=BD=D0=B3:?= =?UTF-8?q?=20=D0=B1=D0=B0=D0=B9=D0=B5=D1=81=D0=BE=D0=B2=D1=81=D0=BA=D0=BE?= =?UTF-8?q?=D0=B5=20=D1=81=D0=B3=D0=BB=D0=B0=D0=B6=D0=B8=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=BF=D0=BE=20=D0=BE=D0=BF=D1=8B=D1=82=D1=83;?= =?UTF-8?q?=20=D0=BD=D0=BE=D0=B2=D0=B8=D1=87=D0=BA=D0=B8=20=E2=80=94=20?= =?UTF-8?q?=D0=B4=D0=BE=2010=20=D0=BF=D0=B0=D1=80=D1=82=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема: средний балл не учитывал число игр — новичок с 10 удачными партиями обходил ветерана с сотнями стабильных. Теперь score = (C·m + сумма очков) / (C + игр) × 100 при C=10, m=0.5: к реальным партиям «дописываются» 10 виртуальных со средним 0.5, рейтинг стартует около 50 и с опытом сходится к чистому среднему (формула IMDB). Применено в топах и профиле; разбивка по фракциям осталась на чистом среднем (служебная метрика «лучшая/худшая», сглаживание давило бы её к 50). MIN_GAMES 5 -> 10 («Новички» во всех топах — до 10 партий; подписи берут порог из API). Справка обновлена, добавлен тест (62 passed). Co-Authored-By: Claude Fable 5 --- backend/app/services/scoring.py | 19 ++++++++++-- backend/app/services/stats_service.py | 8 +++-- backend/tests/test_scoring_smoothing.py | 39 +++++++++++++++++++++++++ frontend/src/pages/HelpPage.tsx | 14 +++++++-- 4 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 backend/tests/test_scoring_smoothing.py 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): +

    +
    + рейтинг = (10 × 0.5 + сумма очков) / (10 + число партий) × 100 +
    +

    + Пока партий мало, рейтинг держится около 50 и с опытом сходится к реальному + среднему — стабильные результаты на длинной дистанции ценятся выше короткой + удачной серии.

    @@ -55,7 +63,7 @@ export function HelpPage() {

    Попадание в топ

    - Чтобы попасть в ранжированный топ, нужно сыграть минимум 5 партий. До этого + Чтобы попасть в ранжированный топ, нужно сыграть минимум 10 партий. До этого игрок показывается в блоке «Новички» — предварительно, без места в рейтинге.

    From 45bdadece90ec00b71a4996489f52b3f1e8d8363 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Fri, 17 Jul 2026 03:07:55 +0300 Subject: [PATCH 2/3] =?UTF-8?q?UI:=20=D0=BF=D1=80=D0=BE=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=BA=20=D1=80=D0=B5=D0=B9=D1=82=D0=B8=D0=BD=D0=B3=D0=B0=20?= =?UTF-8?q?=D1=83=20=D0=B5=D1=89=D1=91=20=D0=BD=D0=B5=20=D0=B8=D0=B3=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D1=88=D0=B8=D1=85=20=E2=80=94=20=D1=81=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B1=D1=80=D0=B8=D1=81=D1=82=D1=8B=D0=B9,=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=B7=D0=BE=D0=BB=D0=BE=D1=82=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Класс .provisional (серебро) применяется и при score = null: строки «Ещё не играли» в топах и прочерк «Очки (рейтинг)» в карточке профиля. Золотым остаётся только подтверждённый рейтинг. Co-Authored-By: Claude Fable 5 --- frontend/src/components/Leaderboard.tsx | 6 +++++- frontend/src/components/ProfileStatsCard.tsx | 2 +- frontend/src/pages/OverallStatsPage.tsx | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Leaderboard.tsx b/frontend/src/components/Leaderboard.tsx index 7e513b1..edc08d7 100644 --- a/frontend/src/components/Leaderboard.tsx +++ b/frontend/src/components/Leaderboard.tsx @@ -29,7 +29,11 @@ function Row({ WR: {pct(entry.win_rate)} -
    {entry.score ?? "—"}
    + {/* Серебристый — у новичков и у прочерка ещё не игравших; золотой — только + подтверждённый рейтинг. */} +
    + {entry.score ?? "—"} +
    ); // Строка-ссылка на профиль — только там, где сама карточка никуда не ведёт diff --git a/frontend/src/components/ProfileStatsCard.tsx b/frontend/src/components/ProfileStatsCard.tsx index 8651d9b..c287acc 100644 --- a/frontend/src/components/ProfileStatsCard.tsx +++ b/frontend/src/components/ProfileStatsCard.tsx @@ -33,7 +33,7 @@ export function ProfileStatsCard({
    Очки (рейтинг)
    - {o.score ?? "—"} + {o.score ?? "—"}
    {stats.most_played_faction && (
    diff --git a/frontend/src/pages/OverallStatsPage.tsx b/frontend/src/pages/OverallStatsPage.tsx index 74d42d2..581898b 100644 --- a/frontend/src/pages/OverallStatsPage.tsx +++ b/frontend/src/pages/OverallStatsPage.tsx @@ -52,7 +52,7 @@ export function OverallStatsPage() { {e.games} {e.wins} {pct(e.win_rate)} - + {e.score ?? "—"} From 4a681430af30886bed331bf4d32a89567a41338e Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Fri, 17 Jul 2026 03:10:53 +0300 Subject: [PATCH 3/3] =?UTF-8?q?UI:=20=D1=80=D0=B5=D0=B9=D1=82=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3=20=D0=BD=D0=BE=D0=B2=D0=B8=D1=87=D0=BA=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8F=20=E2=80=94=20=D1=81?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B1=D1=80=D0=B8=D1=81=D1=82=D1=8B=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProfileStats теперь отдаёт min_games (порог «Новичков» из API, без дублирования константы на фронте): пока сыграно меньше min_games партий, «Очки (рейтинг)» в профиле показываются серебристым, как в топах; золотой — только подтверждённый рейтинг. Co-Authored-By: Claude Fable 5 --- backend/app/schemas/api.py | 3 +++ backend/app/services/stats_service.py | 1 + frontend/src/api/schema.d.ts | 5 +++++ frontend/src/components/ProfileStatsCard.tsx | 5 ++++- 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index a7c2152..84dcb6b 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -331,6 +331,9 @@ class ProfileStats(BaseModel): worst_faction: FactionStat | None = None most_played_faction: FactionStat | None = None recent_form: list[RecentFormItem] = [] + # Порог «Новичков» (MIN_GAMES) — чтобы UI единообразно подсвечивал + # неподтверждённый рейтинг, не дублируя константу на фронте. + min_games: int = 0 class PublicProfile(BaseModel): diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index ceec012..66b43d5 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -206,6 +206,7 @@ def profile_stats(session: Session, user_id: int, group_id: int | None = None) - "worst_faction": worst, "most_played_faction": most_played, "recent_form": _recent_form(session, user_id, group_id), + "min_games": MIN_GAMES, } diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 553e44e..b3f0f86 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -1883,6 +1883,11 @@ export interface components { * @default [] */ recent_form: components["schemas"]["RecentFormItem"][]; + /** + * Min Games + * @default 0 + */ + min_games: number; }; /** ProfileUpdate */ ProfileUpdate: { diff --git a/frontend/src/components/ProfileStatsCard.tsx b/frontend/src/components/ProfileStatsCard.tsx index c287acc..009d565 100644 --- a/frontend/src/components/ProfileStatsCard.tsx +++ b/frontend/src/components/ProfileStatsCard.tsx @@ -12,6 +12,9 @@ export function ProfileStatsCard({ title?: string; }) { const o = stats.overall; + // Серебристый рейтинг — пока игрок «новичок» (меньше min_games партий) или + // не играл вовсе; золотой — только подтверждённый (как в топах). + const provisional = o.score == null || o.games < stats.min_games; return (

    {title}

    @@ -33,7 +36,7 @@ export function ProfileStatsCard({
    Очки (рейтинг)
    - {o.score ?? "—"} + {o.score ?? "—"}
    {stats.most_played_faction && (