Files
ForbiddenStarsApp/backend/app/services/scoring.py
T
NotBigGhostandClaude Opus 5 63a2cddd5e Рейтинг: выбывшие между собой не сравниваются
Решение владельца: в партии все выбывшие проиграли и одинаково слабы, миров у них
нет, поэтому пара двух выбывших в сумму не входит — ни S − E, ни множитель отрыва.
Пары с невыбывшими считаются как прежде, нормировка на N − 1 тоже: при равных
рейтингах результат не меняется. Слабый выбывший больше не получает рейтинг за счёт
сильных выбывших.

Эталон — флаг skip_eliminated_pairs у предложенной системы; документ: 4.3, 4.4,
4.9, пример 5, итоги 7.3 пересчитаны (сдвиг в третьем знаке), решение 8 в разделе 9;
справка. #91

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-18 23:41:29 +03:00

223 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Метрика рейтинга: многопользовательский Elo с множителем отрыва (#22, #23).
Полное описание, обоснование коэффициентов и примеры — docs/rating/rating-system.md;
эталонная реализация тех же формул — docs/rating/simulate.py (тесты сверяют с ней).
Партия раскладывается на пары игроков. Для пары a (выше или наравне) и b:
E_ab = 1 / (1 + 10^((R_b − R_a) / D)) ожидание по рейтингам ДО партии
S_ab = 1 / 0.5 / 0 выше / поровну / ниже
ΔR_i = K_i · G(N) / (N − 1) · Σ_j M_ij · (S_ij − E_ij)
Пары двух выбывших в сумму не входят (#91), остальные — все. K_i спускается от K_MAX
у новичка до K_MIN за K_GAMES партий, G(N) — вес размера стола, M — множитель отрыва
(темп, цели, миры; близость по типу победы — только у пар с победителем). Недостающий
признак партии подставляется типичным и не влияет на M.
Модуль — только константы и чистые функции без БД: калибровка на реальных данных —
правка констант, пересчёт выполняется сам (рейтинг — функция упорядоченной истории).
"""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass, field
from datetime import date, datetime
from itertools import combinations
# Порог числа игр для попадания в ранжированный топ (ниже — «Новички»/provisional).
MIN_GAMES = 10
# Порог числа игр на фракцию для расчёта лучшей/худшей фракции.
FACTION_MIN_GAMES = 2
# ─── Правила игры ────────────────────────────────────────────────────────────
# Размер поля в тайлах по числу игроков (дуэль — 2×3, шестеро — 4×5).
BOARD_TILES = {2: 6, 3: 9, 4: 12, 5: 16, 6: 20}
WORLDS_PER_TILE = 2.2
BASE_ROUNDS = 8
# Домашнее правило группы: при 5–6 игроках играется 9 раундов.
EXTENDED_ROUNDS = 9
EXTENDED_MIN_PLAYERS = 5
# ─── Коэффициенты (документ, 4.10) ───────────────────────────────────────────
R0 = 1500.0 # стартовый рейтинг
D = 400.0 # масштаб: разница 400 пунктов — шансы 10:1
K_MAX = 64.0 # K новичка (0 партий)
K_MIN = 16.0 # K опытного игрока
K_GAMES = 20 # за сколько партий K линейно спускается от K_MAX к K_MIN
W_TABLE = 0.5 # вес размера стола
W_TEMPO = 1.0 # вес темпа победы
W_OBJ = 0.5 # вес отрыва по целям
W_WORLDS = 0.5 # вес отрыва по мирам
MU_OBJ = 0.5 # типичный отрыв по целям
MU_WORLDS = 0.5 # типичный отрыв по мирам
M_MIN = 0.5 # страховка: одна партия не легче половины обычной…
M_MAX = 2.0 # …и не тяжелее двух
# Близость партии по типу победы — множитель пар с победителем.
CLOSENESS = {
"objectives": 1.0,
"worlds": 0.85,
"plastic": 0.7,
"resources": 0.6,
"last_standing": 1.0,
}
def max_rounds(player_count: int, nine_rounds_rule: bool) -> int:
"""Лимит раундов партии: 9 при хоумруле группы и 5+ игроках, иначе 8."""
if nine_rounds_rule and player_count >= EXTENDED_MIN_PLAYERS:
return EXTENDED_ROUNDS
return BASE_ROUNDS
def fair_worlds(player_count: int) -> float:
"""«Честная доля» миров на игрока — масштаб для разницы миров."""
return BOARD_TILES[player_count] * WORLDS_PER_TILE / player_count
def mu_tempo(rmax: int) -> float:
"""Типичный темп: партия закончилась в предпоследнем раунде."""
return 1.0 / (rmax - 1)
def expected(r_a: float, r_b: float) -> float:
"""Ожидаемый результат a против b (вероятность, что a окажется выше)."""
return 1.0 / (1.0 + 10.0 ** ((r_b - r_a) / D))
def k_factor(games: int) -> float:
left = max(0.0, 1.0 - games / K_GAMES)
return K_MIN + (K_MAX - K_MIN) * left
def table_weight(player_count: int) -> float:
return 1.0 + W_TABLE * (player_count - 2) / 4.0
def _clamp(x: float, lo: float, hi: float) -> float:
return max(lo, min(hi, x))
# ─── Партия как вход расчёта ─────────────────────────────────────────────────
@dataclass(frozen=True)
class RatedSeat:
user_id: int
place: int
faction_id: int = 0
eliminated: bool = False
objectives: int | None = None # маркеры целей на конец партии
worlds: int | None = None # дружественные миры на конец партии
@dataclass(frozen=True)
class RatedMatch:
seats: tuple[RatedSeat, ...]
win_reason: str | None = None
end_round: int | None = None # раунд, в котором партия закончилась
nine_rounds_rule: bool = False # снимок настройки группы на момент партии
id: int = 0
group_id: int = 0
played_at: date | None = None
finished_at: datetime | None = None
def pair_multiplier(m: RatedMatch, a: RatedSeat, b: RatedSeat) -> float:
"""Множитель отрыва пары; a — выше или наравне с b."""
n = len(m.seats)
tie = a.place == b.place
winner_pair = a.place == 1
add = 1.0
def diff(x: int, y: int) -> float:
return abs(x - y) if tie else max(0, x - y)
if winner_pair:
rmax = max_rounds(n, m.nine_rounds_rule)
mu = mu_tempo(rmax)
tempo = mu if m.end_round is None else (rmax - m.end_round) / (rmax - 1)
add += W_TEMPO * (tempo - mu)
if winner_pair and m.win_reason == "last_standing":
obj = 1.0 # все соперники устранены — отрыв максимальный, сколько бы ни было маркеров
elif a.objectives is not None and b.objectives is not None:
obj = _clamp(diff(a.objectives, b.objectives) / n, 0.0, 1.0)
else:
obj = MU_OBJ
add += W_OBJ * (obj - MU_OBJ)
if a.worlds is not None and b.worlds is not None:
wor = _clamp(diff(a.worlds, b.worlds) / fair_worlds(n), 0.0, 1.0)
else:
wor = MU_WORLDS
add += W_WORLDS * (wor - MU_WORLDS)
close = CLOSENESS.get(m.win_reason, 1.0) if winner_pair and m.win_reason else 1.0
return _clamp(add, M_MIN, M_MAX) * close
def rate_match(
ratings: dict[int, float], games: dict[int, int], m: RatedMatch
) -> tuple[dict[int, float], dict[int, float]]:
"""Изменения рейтинга участников и их результат относительно ожидания.
Возвращает (ΔR, perf): perf_i = Σ_j (S_ij − E_ij) / (N − 1) — насколько игрок
выступил выше ожидания, без множителя отрыва и K. Входные словари не мутирует."""
n = len(m.seats)
delta = {s.user_id: 0.0 for s in m.seats}
perf = {s.user_id: 0.0 for s in m.seats}
if n < 2:
return delta, perf
g = table_weight(n)
r = {s.user_id: ratings.get(s.user_id, R0) for s in m.seats}
k = {s.user_id: k_factor(games.get(s.user_id, 0)) for s in m.seats}
for a, b in combinations(m.seats, 2):
if a.eliminated and b.eliminated:
# Выбывшие между собой не сравниваются: в этой партии все они проиграли, а миров
# у них нет (решение владельца, #91). Нормировка на N − 1 остаётся прежней.
continue
if a.place > b.place:
a, b = b, a
s_ab = 0.5 if a.place == b.place else 1.0
e_ab = expected(r[a.user_id], r[b.user_id])
x = pair_multiplier(m, a, b) * (s_ab - e_ab)
delta[a.user_id] += k[a.user_id] * g / (n - 1) * x
delta[b.user_id] -= k[b.user_id] * g / (n - 1) * x
perf[a.user_id] += (s_ab - e_ab) / (n - 1)
perf[b.user_id] -= (s_ab - e_ab) / (n - 1)
return delta, perf
@dataclass
class Replay:
"""Итог проигрывания истории: рейтинги без округления и следы каждой партии."""
ratings: dict[int, float] = field(default_factory=dict)
games: dict[int, int] = field(default_factory=dict)
delta: dict[tuple[int, int], float] = field(default_factory=dict) # (match_id, user_id)
perf: dict[tuple[int, int], float] = field(default_factory=dict) # (match_id, user_id)
def replay(matches: Iterable[RatedMatch]) -> Replay:
"""Проигрывает партии в переданном порядке (хронологию задаёт вызывающий)."""
out = Replay()
for m in matches:
delta, perf = rate_match(out.ratings, out.games, m)
for uid, dv in delta.items():
out.ratings[uid] = out.ratings.get(uid, R0) + dv
out.games[uid] = out.games.get(uid, 0) + 1
out.delta[(m.id, uid)] = dv
out.perf[(m.id, uid)] = perf[uid]
return out
def leaderboard_sort_key(row: dict) -> tuple:
"""Ключ сортировки топа: рейтинг ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑.
row["rating"] — рейтинг без округления: два игрока с одинаковым целым в топе
всё равно упорядочены по настоящему значению."""
return (
-(row["rating"] or 0.0),
-(row["win_rate"] or 0.0),
-(row["games"] or 0),
(row["avg_place"] or 0.0),
row["nickname"].lower(),
)