scoring.py получает движок из docs/rating/rating-system.md: ожидание пары, K 64 → 16 за 20 партий, вес стола G(N), множитель отрыва (темп, цели, миры, clamp [0.5, 2]) и близость по типу победы, включая last_standing. rate_match и replay — чистые функции без БД; replay отдаёт рейтинги без округления, ΔR и результат относительно ожидания по каждой партии. Тесты: 15 примеров раздела 6 с числами документа, совпадение констант и всех ΔR сезона с эталоном simulate.py (полные партии и история без деталей), монотонность и сумма-ноль. League Points пока остаётся в модуле — витрины переводятся отдельным коммитом. #23 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
238 lines
10 KiB
Python
238 lines
10 KiB
Python
"""Метрика рейтинга: многопользовательский 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)
|
||
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.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
|
||
|
||
|
||
# ─── League Points (прежняя система; уходит вместе с SQL-витринами) ──────────
|
||
|
||
PRIOR_GAMES = 10
|
||
PRIOR_MEAN = 0.5
|
||
|
||
SMOOTHED_SCORE_SQL = (
|
||
f"({PRIOR_GAMES} * {PRIOR_MEAN} + SUM(s.points)) / ({PRIOR_GAMES} + COUNT(*)) * 100"
|
||
)
|
||
|
||
MATCH_POINTS_SQL = (
|
||
"CASE WHEN m.player_count > 1 "
|
||
"THEN (m.player_count - mp.place - (t.tie_size - 1) / 2.0) "
|
||
"/ (m.player_count - 1) "
|
||
"ELSE 1.0 END"
|
||
)
|
||
|
||
|
||
def smoothed_score(points_sum: float, games: int) -> float | None:
|
||
if games <= 0:
|
||
return None
|
||
return (PRIOR_GAMES * PRIOR_MEAN + points_sum) / (PRIOR_GAMES + games) * 100
|
||
|
||
|
||
def leaderboard_sort_key(row: dict) -> tuple:
|
||
"""Ключ сортировки топа: счёт ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑."""
|
||
return (
|
||
-(row["score"] or 0.0),
|
||
-(row["win_rate"] or 0.0),
|
||
-(row["games"] or 0),
|
||
(row["avg_place"] or 0.0),
|
||
row["nickname"].lower(),
|
||
)
|