# Рейтинг — пакет формул и начисление

Источник: https://docs.novapoker.ru/dev/rating/
Дорожка: разработка
Сверено с кодом: Mon Aug 24
Код: repos/poker-api/packages/rating/src/index.ts, repos/poker-api/apps/server/openapi.json

---

Все рейтинговые формулы живут **только** в пакете
`repos/poker-api/packages/rating` — чистые функции без обращений к базе, сети и
времени. Это не стилистика: по этим числам игроки сравнивают себя друг с другом,
поэтому формулы обязаны быть проверяемы отдельно от всего остального. Пакет
покрыт тестами на 100%, и это условие, а не достижение.

Правило простое: **если формула появилась вне этого пакета — это ошибка**.

## Конфигурация

Одна структура `RatingConfig` со значениями по умолчанию:

| Поле | Значение | Что задаёт |
|---|---|---|
| `pointsBase` | 10 | множитель формулы очков |
| `minPoints` | 1 | минимум очков за участие |
| `xpLevelBase` | 50 | база порога уровня |
| `xpLevelExponent` | 1.5 | показатель порога уровня |
| `skillAlpha` | 0.1 | сглаживание силы |
| `skillInitial` | 0.4444 | стартовая сила, даёт отображаемые 5.0 |
| `strikesToBan` | 2 | страйков до блокировки записи |
| `strikeWindowDays` | 30 | окно подсчёта страйков |
| `banDays` | 14 | длительность блокировки |

Значения меняет админ платформы через `GET`/`PUT /admin/rating-config`.
Обратите внимание: пороги страйков и бана лежат **здесь же**, а не в модуле
записи — это одна настроечная поверхность.

## Формулы

**Очки сезона.** `round(pointsBase * sqrt(N) * (N - place + 1) / N)`, не меньше
`minPoints`. Здесь `N` — число реально игравших, `place` — от 1 до `N`.
При `N <= 0` или месте вне диапазона бросается `RangeError`.

**Перформанс.** `(N - place) / (N - 1)`; при `N === 1` возвращает 1.

**Новая сила.** `prev + skillAlpha * (perf - prev)`, зажатая в `[0, 1]`.
Это экспоненциальное сглаживание: один турнир смещает силу на десятую часть
разрыва между текущим значением и результатом.

**Отображаемая сила.** `1 + 9 * skill`, округление до 0.1. Отсюда стартовые
`0.4444 → 5.0`.

**Порог уровня.** `reachXp(L) = round(xpLevelBase * (L - 1) ^ xpLevelExponent)`,
причём `reachXp(1) === 0`. Уровень по XP — максимальный `L`, для которого
`reachXp(L) <= xp`; цикл ограничен `MAX_LEVEL = 1000` как защита от зацикливания
на аномальном значении.

XP начисляется один в один с очками сезона.

## Эндпоинты

| Метод и путь | Что отдаёт |
|---|---|
| `POST /tournaments/{id}/confirm-results` | подтверждение результатов — момент начисления |
| `GET /tournaments/{id}/results` | итоги турнира |
| `GET /players/{userId}/results` | результаты игрока |
| `GET /leaderboards/season` | сезонный лидерборд |
| `GET /leaderboards/season/summary` | топ-3 и строка игрока |
| `GET /leaderboards/level` | уровневый, топ-100 по XP |
| `GET /seasons/current`, `GET /seasons` | сезоны |
| `GET`/`PUT /admin/rating-config` | настройки платформы |

Пути даны без префикса `/api/v1`.

## Сезоны

Сезон — календарный квартал. `ensureCurrentSeason()` идемпотентен: гонка ловится
по уникальному `startsOn`. Вызывается и джобой `season-rollover`
(`5 0 * * *` UTC), и лидербордами — то есть первый же запрос после полуночи
заведёт квартал, даже если джоба не отработала.

## Ограничения

- **Начисление происходит только при подтверждении результатов** и необратимо для
  клуба. Пересчёта задним числом нет — исправление делает платформа вручную.
- **Нокауты не учитываются.** В рейтинге есть только места и победы.
- **Уровневый лидерборд ограничен топ-100**, сезонный отдаётся целиком.

## Что рядом

- [Рейтинг, уровни и сезоны](https://docs.novapoker.ru/product/rating.md) — те же формулы словами продукта.
- [poker-api](https://docs.novapoker.ru/dev/poker-api.md) — как запустить сервис и прогнать тесты.
