Реализация новой рейтинговой системы (#23) #75
@@ -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, вывод воспроизводим:
|
||||
|
||||
@@ -35,7 +35,7 @@ export function ProfileStatsCard({
|
||||
<b>{o.avg_place ?? "—"}</b>
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<div>Очки (рейтинг)</div>
|
||||
<div>Рейтинг</div>
|
||||
<b className={"lb-score" + (provisional ? " provisional" : "")}>{o.score ?? "—"}</b>
|
||||
</div>
|
||||
{/* Любимая — личный выбор игрока в профиле; ниже — статистика по партиям. */}
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3>Очки игроков (рейтинг)</h3>
|
||||
<h3>Рейтинг игроков</h3>
|
||||
<p className="small">
|
||||
Рейтинг считается только по <b>завершённым</b> партиям. Незавершённые и отменённые
|
||||
в зачёт не идут.
|
||||
Рейтинг — оценка силы игрока относительно соперников (система Elo). Каждый начинает
|
||||
с <b>1500</b>. Разница в <b>400</b> пунктов означает шансы 10 к 1 в пользу более
|
||||
сильного.
|
||||
</p>
|
||||
<p className="small">
|
||||
За каждую партию игрок получает «очки за место» — они нормированы по числу игроков
|
||||
за столом, поэтому победа за большим столом ценится выше, чем за маленьким:
|
||||
Считаются только <b>завершённые</b> партии, по порядку их игры. Правка или удаление
|
||||
старой партии пересчитывает и все последующие.
|
||||
</p>
|
||||
<div className="formula">
|
||||
очки = (N − место − (размер_ничьей − 1) / 2) / (N − 1)
|
||||
<p className="small">
|
||||
Партия раскладывается на пары игроков. В каждой паре фактический результат (выше —
|
||||
1, поровну — 0.5, ниже — 0) сравнивается с ожидаемым по рейтингам до партии:
|
||||
</p>
|
||||
<div className="formula">ожидание = 1 / (1 + 10^((R соперника − R игрока) / 400))</div>
|
||||
<div className="formula" style={{ marginTop: 6 }}>
|
||||
ΔR = K × G / (N − 1) × Σ M × (результат − ожидание)
|
||||
</div>
|
||||
<p className="small" style={{ marginTop: 10 }}>
|
||||
где <b>N</b> — число игроков в партии. Первое место даёт <b>1.0</b>, последнее — <b>0.0</b>.
|
||||
</p>
|
||||
<ul className="small" style={{ marginTop: 0, paddingLeft: 18 }}>
|
||||
<li>Если несколько игроков делят место (ничья) — они делят сумму очков поровну.</li>
|
||||
<li>Выбывшие из партии считаются как последнее место.</li>
|
||||
</ul>
|
||||
<p className="small">
|
||||
Итоговый <b>рейтинг</b> игрока — сглаженное среднее: к реальным партиям
|
||||
«дописываются» 10 виртуальных со средним результатом 0.5 (шкала от 0 до 100):
|
||||
</p>
|
||||
<div className="formula">
|
||||
рейтинг = (10 × 0.5 + сумма очков) / (10 + число партий) × 100
|
||||
</div>
|
||||
<p className="small" style={{ marginTop: 10 }}>
|
||||
Пока партий мало, рейтинг держится около 50 и с опытом сходится к реальному
|
||||
среднему — стабильные результаты на длинной дистанции ценятся выше короткой
|
||||
удачной серии.
|
||||
где <b>N</b> — число игроков, сумма — по всем соперникам. Поэтому победа над сильным
|
||||
приносит больше, чем над слабым, а поражение от слабого отнимает больше. Выбывшие
|
||||
делят последнее место.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Пример (стол на 4 игроков)</h3>
|
||||
<h3>От чего зависит изменение</h3>
|
||||
<ul className="small" style={{ margin: 0, paddingLeft: 18 }}>
|
||||
<li>
|
||||
<b>K — скорость.</b> У новичка 64, к 20-й партии плавно снижается до 16: новичок
|
||||
быстро находит свой уровень, а рейтинг опытного игрока не скачет.
|
||||
</li>
|
||||
<li>
|
||||
<b>G — размер стола:</b> 1 + 0.5 × (N − 2) / 4. В дуэли 1.0, вчетвером 1.25,
|
||||
вшестером 1.5.
|
||||
</li>
|
||||
<li>
|
||||
<b>M — отрыв в паре</b>, от 0.5 до 2. Обычная партия даёт 1. Больше, если:
|
||||
<ul style={{ paddingLeft: 16 }}>
|
||||
<li>
|
||||
партия закончилась раньше — для пар с победителем; обычной считается конец
|
||||
в предпоследнем раунде;
|
||||
</li>
|
||||
<li>разница целей велика относительно числа игроков;</li>
|
||||
<li>разница миров велика относительно доли миров на игрока.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<b>Тип победы</b> — для пар с победителем, чем ближе партия, тем меньше:
|
||||
по целям ×1, по мирам ×0.85, по пластику ×0.7, по ресурсам ×0.6,
|
||||
последний выживший ×1.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="small" style={{ marginTop: 10 }}>
|
||||
Раунд окончания, цели и миры вводить <b>необязательно</b>: пропущенное считается
|
||||
обычным значением и множитель не меняет. «Последний выживший» ставится сам, когда
|
||||
все соперники выбыли, — отрыв по целям тогда максимальный. Лимит раундов — 8,
|
||||
с правилом группы «9 раундов» при 5–6 игроках — 9.
|
||||
</p>
|
||||
<p className="small" style={{ marginBottom: 0 }}>
|
||||
Победитель никогда не теряет рейтинг, а последнее место никогда его не приносит.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Примеры (опытные игроки)</h3>
|
||||
<div className="stack" style={{ gap: 6 }}>
|
||||
<div className="row-between small"><span>1-е место</span><b>(4−1−0)/3 = 1.00 → 100</b></div>
|
||||
<div className="row-between small"><span>2-е место</span><b>(4−2−0)/3 = 0.67 → 67</b></div>
|
||||
<div className="row-between small"><span>3-е место</span><b>(4−3−0)/3 = 0.33 → 33</b></div>
|
||||
<div className="row-between small"><span>4-е место</span><b>(4−4−0)/3 = 0.00 → 0</b></div>
|
||||
<div className="row-between small">
|
||||
<span>Ничья за 1-е (двое)</span><b>(4−1−0.5)/3 = 0.83 каждому</b>
|
||||
<span>Дуэль 1600 и 1400: победил сильный</span>
|
||||
<b>+3.8 / −3.8</b>
|
||||
</div>
|
||||
<div className="row-between small">
|
||||
<span>Дуэль 1600 и 1400: победил слабый</span>
|
||||
<b>+12.2 / −12.2</b>
|
||||
</div>
|
||||
<div className="row-between small">
|
||||
<span>Равные, победа по целям без деталей</span>
|
||||
<b>+8.0</b>
|
||||
</div>
|
||||
<div className="row-between small">
|
||||
<span>Равные, победа по мирам без деталей</span>
|
||||
<b>+6.8</b>
|
||||
</div>
|
||||
<div className="row-between small">
|
||||
<span>Разгром на 3-м раунде (цели 2:0, миры 8:2)</span>
|
||||
<b>+16.0</b>
|
||||
</div>
|
||||
<div className="row-between small">
|
||||
<span>1-е место за столом на 5 равных</span>
|
||||
<b>+11.0</b>
|
||||
</div>
|
||||
<div className="row-between small">
|
||||
<span>Новичок обыграл равного опытного</span>
|
||||
<b>+32 / −8</b>
|
||||
</div>
|
||||
</div>
|
||||
<p className="muted small" style={{ marginBottom: 0 }}>
|
||||
Рейтинг показывается целым числом, но считается без округления.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Общий и групповой рейтинг</h3>
|
||||
<p className="small" style={{ margin: 0 }}>
|
||||
Общий рейтинг считается по всем партиям приложения, групповой — только по партиям
|
||||
группы. Поэтому числа в общем топе и в топе группы различаются.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
@@ -91,6 +155,7 @@ export function HelpPage() {
|
||||
<ul className="small" style={{ margin: 0, paddingLeft: 18 }}>
|
||||
<li><b>Винрейт (WR)</b> — доля партий, в которых игрок занял 1-е место.</li>
|
||||
<li><b>Среднее место</b> — среднее по завершённым партиям.</li>
|
||||
<li><b>Лучшая партия</b> в профиле — та, что принесла больше всего рейтинга.</li>
|
||||
<li>
|
||||
При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту
|
||||
и затем по нику.
|
||||
|
||||
@@ -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() {
|
||||
<span className="nick">{e.nickname}</span>
|
||||
</span>
|
||||
<span className="st-num">{e.games}</span>
|
||||
<span className="st-num">{e.wins}</span>
|
||||
<span className="st-num">{pct(e.win_rate)}</span>
|
||||
<span className={"st-num lb-score" + (isProvisional || e.score == null ? " provisional" : "")}>
|
||||
{e.score ?? "—"}
|
||||
@@ -70,9 +71,8 @@ export function OverallStatsPage() {
|
||||
<button onClick={() => toggleSort("rank")}>#{arrow("rank")}</button>
|
||||
<span>Игрок</span>
|
||||
<button onClick={() => toggleSort("games")}>Игр{arrow("games")}</button>
|
||||
<button onClick={() => toggleSort("wins")}>Поб{arrow("wins")}</button>
|
||||
<button onClick={() => toggleSort("win_rate")}>WR{arrow("win_rate")}</button>
|
||||
<button onClick={() => toggleSort("score")}>Очки{arrow("score")}</button>
|
||||
<button onClick={() => toggleSort("score")}>Рейтинг{arrow("score")}</button>
|
||||
</div>
|
||||
{entries.length === 0 ? (
|
||||
<div className="muted small" style={{ paddingTop: 8 }}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user