From adc47beb72dbcfa04e119d176bcf8bd41d8a4a78 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 14 Sep 2026 22:47:25 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B9=D1=82=D0=B8=D0=BD=D0=B3:=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BF,=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=20=D0=B8=20=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=20=D0=BD=D0=BE=D0=B2=D1=83=D1=8E=20=D1=88?= =?UTF-8?q?=D0=BA=D0=B0=D0=BB=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Общий топ: столбец «Поб» и сортировка по победам убраны, чтобы четырёхзначный рейтинг помещался в строку; «Очки» → «Рейтинг» (и в карточке профиля). Справка переписана под Elo: шкала 1500/400, ожидание и формула ΔR, K новичка, вес стола, множитель отрыва и близость по типу победы, необязательность раунда, целей и миров, «последний выживший», лимит 8/9 раундов, примеры из документа, общий и групповой рейтинг, лучшая партия по приросту. В документе рейтинга статус — утверждено и реализовано. #23 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd --- docs/rating/rating-system.md | 4 +- frontend/src/components/ProfileStatsCard.tsx | 2 +- frontend/src/pages/HelpPage.tsx | 129 ++++++++++++++----- frontend/src/pages/OverallStatsPage.tsx | 8 +- frontend/src/styles/layout.css | 2 +- 5 files changed, 105 insertions(+), 40 deletions(-) diff --git a/docs/rating/rating-system.md b/docs/rating/rating-system.md index 1d6c78e..175fe5b 100644 --- a/docs/rating/rating-system.md +++ b/docs/rating/rating-system.md @@ -1,7 +1,7 @@ # Новая рейтинговая система -> **Статус:** предложение на утверждение (#22). Решения владельца по открытым вопросам -> (2026-09-14) внесены — [раздел 9](#9-решения-владельца). Реализация — #23. +> **Статус:** утверждено (#22), реализовано в #23 (`backend/app/services/scoring.py`). +> Решения владельца по открытым вопросам (2026-09-14) внесены — [раздел 9](#9-решения-владельца). > **Приложение:** [`simulate.py`](simulate.py) — эталонная реализация формул, все примеры > этого документа (с `assert`), симуляция и перебор коэффициентов. Только стандартная > библиотека Python, вывод воспроизводим: diff --git a/frontend/src/components/ProfileStatsCard.tsx b/frontend/src/components/ProfileStatsCard.tsx index 496d9e2..c2df55b 100644 --- a/frontend/src/components/ProfileStatsCard.tsx +++ b/frontend/src/components/ProfileStatsCard.tsx @@ -35,7 +35,7 @@ export function ProfileStatsCard({ {o.avg_place ?? "—"}
-
Очки (рейтинг)
+
Рейтинг
{o.score ?? "—"}
{/* Любимая — личный выбор игрока в профиле; ниже — статистика по партиям. */} diff --git a/frontend/src/pages/HelpPage.tsx b/frontend/src/pages/HelpPage.tsx index e7fe227..aebfefc 100644 --- a/frontend/src/pages/HelpPage.tsx +++ b/frontend/src/pages/HelpPage.tsx @@ -2,8 +2,9 @@ import { ChevronDown, ChevronUp } from "lucide-react"; import { useState } from "react"; /** - * Справка — разделы, раскрывающиеся по кнопке. Пока раздел один: «Рейтинг» - * (как считаются очки игроков). Текст соответствует backend/app/services/scoring.py. + * Справка — разделы, раскрывающиеся по кнопке. Пока раздел один: «Рейтинг». + * Текст соответствует backend/app/services/scoring.py и docs/rating/rating-system.md: + * коэффициенты, пороги и примеры менять вместе с ними. */ export function HelpPage() { const [showRating, setShowRating] = useState(false); @@ -32,50 +33,113 @@ export function HelpPage() { {showRating && ( <>
-

Очки игроков (рейтинг)

+

Рейтинг игроков

- Рейтинг считается только по завершённым партиям. Незавершённые и отменённые - в зачёт не идут. + Рейтинг — оценка силы игрока относительно соперников (система Elo). Каждый начинает + с 1500. Разница в 400 пунктов означает шансы 10 к 1 в пользу более + сильного.

- За каждую партию игрок получает «очки за место» — они нормированы по числу игроков - за столом, поэтому победа за большим столом ценится выше, чем за маленьким: + Считаются только завершённые партии, по порядку их игры. Правка или удаление + старой партии пересчитывает и все последующие.

-
- очки = (N − место − (размер_ничьей − 1) / 2) / (N − 1) +

+ Партия раскладывается на пары игроков. В каждой паре фактический результат (выше — + 1, поровну — 0.5, ниже — 0) сравнивается с ожидаемым по рейтингам до партии: +

+
ожидание = 1 / (1 + 10^((R соперника − R игрока) / 400))
+
+ ΔR = K × G / (N − 1) × Σ M × (результат − ожидание)

- где N — число игроков в партии. Первое место даёт 1.0, последнее — 0.0. -

-
    -
  • Если несколько игроков делят место (ничья) — они делят сумму очков поровну.
  • -
  • Выбывшие из партии считаются как последнее место.
  • -
-

- Итоговый рейтинг игрока — сглаженное среднее: к реальным партиям - «дописываются» 10 виртуальных со средним результатом 0.5 (шкала от 0 до 100): -

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

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

-

Пример (стол на 4 игроков)

+

От чего зависит изменение

+
    +
  • + K — скорость. У новичка 64, к 20-й партии плавно снижается до 16: новичок + быстро находит свой уровень, а рейтинг опытного игрока не скачет. +
  • +
  • + G — размер стола: 1 + 0.5 × (N − 2) / 4. В дуэли 1.0, вчетвером 1.25, + вшестером 1.5. +
  • +
  • + M — отрыв в паре, от 0.5 до 2. Обычная партия даёт 1. Больше, если: +
      +
    • + партия закончилась раньше — для пар с победителем; обычной считается конец + в предпоследнем раунде; +
    • +
    • разница целей велика относительно числа игроков;
    • +
    • разница миров велика относительно доли миров на игрока.
    • +
    +
  • +
  • + Тип победы — для пар с победителем, чем ближе партия, тем меньше: + по целям ×1, по мирам ×0.85, по пластику ×0.7, по ресурсам ×0.6, + последний выживший ×1. +
  • +
+

+ Раунд окончания, цели и миры вводить необязательно: пропущенное считается + обычным значением и множитель не меняет. «Последний выживший» ставится сам, когда + все соперники выбыли, — отрыв по целям тогда максимальный. Лимит раундов — 8, + с правилом группы «9 раундов» при 5–6 игроках — 9. +

+

+ Победитель никогда не теряет рейтинг, а последнее место никогда его не приносит. +

+
+ +
+

Примеры (опытные игроки)

-
1-е место(4−1−0)/3 = 1.00 → 100
-
2-е место(4−2−0)/3 = 0.67 → 67
-
3-е место(4−3−0)/3 = 0.33 → 33
-
4-е место(4−4−0)/3 = 0.00 → 0
- Ничья за 1-е (двое)(4−1−0.5)/3 = 0.83 каждому + Дуэль 1600 и 1400: победил сильный + +3.8 / −3.8 +
+
+ Дуэль 1600 и 1400: победил слабый + +12.2 / −12.2 +
+
+ Равные, победа по целям без деталей + +8.0 +
+
+ Равные, победа по мирам без деталей + +6.8 +
+
+ Разгром на 3-м раунде (цели 2:0, миры 8:2) + +16.0 +
+
+ 1-е место за столом на 5 равных + +11.0 +
+
+ Новичок обыграл равного опытного + +32 / −8
+

+ Рейтинг показывается целым числом, но считается без округления. +

+
+ +
+

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

+

+ Общий рейтинг считается по всем партиям приложения, групповой — только по партиям + группы. Поэтому числа в общем топе и в топе группы различаются. +

@@ -91,6 +155,7 @@ export function HelpPage() {
  • Винрейт (WR) — доля партий, в которых игрок занял 1-е место.
  • Среднее место — среднее по завершённым партиям.
  • +
  • Лучшая партия в профиле — та, что принесла больше всего рейтинга.
  • При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту и затем по нику. diff --git a/frontend/src/pages/OverallStatsPage.tsx b/frontend/src/pages/OverallStatsPage.tsx index 581898b..3cdf87a 100644 --- a/frontend/src/pages/OverallStatsPage.tsx +++ b/frontend/src/pages/OverallStatsPage.tsx @@ -6,7 +6,9 @@ import { Spinner } from "../components/Spinner"; import type { LeaderboardEntry } from "../domain/types"; import { useLeaderboard } from "../hooks/stats"; -type SortKey = "rank" | "games" | "wins" | "win_rate" | "score"; +// Столбца побед нет: четырёхзначный рейтинг иначе не помещается в строку, а доля побед +// уже есть в WR. +type SortKey = "rank" | "games" | "win_rate" | "score"; function pct(v: number | null | undefined): string { return v == null ? "—" : `${Math.round(v * 100)}%`; @@ -50,7 +52,6 @@ export function OverallStatsPage() { {e.nickname} {e.games} - {e.wins} {pct(e.win_rate)} {e.score ?? "—"} @@ -70,9 +71,8 @@ export function OverallStatsPage() { Игрок - - +
{entries.length === 0 ? (
diff --git a/frontend/src/styles/layout.css b/frontend/src/styles/layout.css index 6854a04..0980e98 100644 --- a/frontend/src/styles/layout.css +++ b/frontend/src/styles/layout.css @@ -115,7 +115,7 @@ /* Таблица общей статистики (сортируемая, кликабельные строки) */ .st-row, .st-head { display: grid; - grid-template-columns: 26px 1fr 40px 40px 48px 52px; + grid-template-columns: 26px 1fr 40px 48px 60px; align-items: center; gap: 8px; padding: 8px 2px;