"""Метрика рейтинга: многопользовательский 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 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(), )