From e58b4f6614dbd72ae91621776d3f9c98249e6277 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 14 Sep 2026 22:19:24 +0300 Subject: [PATCH 1/5] =?UTF-8?q?=D0=A0=D0=B5=D0=B9=D1=82=D0=B8=D0=BD=D0=B3:?= =?UTF-8?q?=20=D0=B4=D0=B2=D0=B8=D0=B6=D0=BE=D0=BA=20Elo=20=D1=81=20=D0=BC?= =?UTF-8?q?=D0=BD=D0=BE=D0=B6=D0=B8=D1=82=D0=B5=D0=BB=D0=B5=D0=BC=20=D0=BE?= =?UTF-8?q?=D1=82=D1=80=D1=8B=D0=B2=D0=B0=20=D0=B8=20=D1=82=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D1=8B=20=D0=BD=D0=B0=20=D0=BF=D1=80=D0=B8=D0=BC=D0=B5?= =?UTF-8?q?=D1=80=D1=8B=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd --- backend/app/services/scoring.py | 219 +++++++++++++++++++++++--- backend/tests/test_rating_examples.py | 197 +++++++++++++++++++++++ 2 files changed, 395 insertions(+), 21 deletions(-) create mode 100644 backend/tests/test_rating_examples.py diff --git a/backend/app/services/scoring.py b/backend/app/services/scoring.py index 705bf86..e09c670 100644 --- a/backend/app/services/scoring.py +++ b/backend/app/services/scoring.py @@ -1,35 +1,217 @@ -"""Метрика рейтинга. Вынесена отдельно — легко заменить. +"""Метрика рейтинга: многопользовательский Elo с множителем отрыва (#22, #23). -По умолчанию: League Points — нормированные очки за место с учётом размера стола -и ничьих (competition ranking). За партию из N игроков: - points = (N - place - (tie_size - 1)/2) / (N - 1) -1-е место = 1.0, последнее = 0.0; равные места делят сумму очков поровну. +Полное описание, обоснование коэффициентов и примеры — docs/rating/rating-system.md; +эталонная реализация тех же формул — docs/rating/simulate.py (тесты сверяют с ней). -Рейтинговый счёт игрока — сглаженное среднее (байесовское, формула IMDB): - score = (PRIOR_GAMES * PRIOR_MEAN + SUM(points)) / (PRIOR_GAMES + games) * 100 -К реальным партиям «дописываются» PRIOR_GAMES виртуальных со средним PRIOR_MEAN: -на малой выборке рейтинг держится около 50 и лишь с опытом сходится к чистому -среднему — короткая удачная серия новичка не обгоняет стабильного ветерана. +Партия раскладывается на пары игроков. Для пары 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 -# SQL-выражение сглаженного рейтинга поверх агрегата по строкам scored (s.points). -# При 0 партий SUM = NULL → score = NULL (рейтинга без игр нет). SMOOTHED_SCORE_SQL = ( f"({PRIOR_GAMES} * {PRIOR_MEAN} + SUM(s.points)) / ({PRIOR_GAMES} + COUNT(*)) * 100" ) -# SQL-выражение очков за участие (tie-aware). Использует поля m.player_count, -# mp.place и t.tie_size (размер группы игроков с тем же местом в партии). MATCH_POINTS_SQL = ( "CASE WHEN m.player_count > 1 " "THEN (m.player_count - mp.place - (t.tie_size - 1) / 2.0) " @@ -39,13 +221,8 @@ MATCH_POINTS_SQL = ( def smoothed_score(points_sum: float, games: int) -> float | None: - """Тот же сглаженный рейтинг, что и SMOOTHED_SCORE_SQL, но в Python. - - Нужен там, где строки уже вытащены и агрегировать в SQL нечего (профиль игрока). - Держим рядом с SQL-версией и на одних константах: разъехавшиеся реализации одной - формулы — источник расхождений, который потом ловится только глазами.""" if games <= 0: - return None # рейтинга без игр нет — как SUM(...) = NULL в SQL + return None return (PRIOR_GAMES * PRIOR_MEAN + points_sum) / (PRIOR_GAMES + games) * 100 diff --git a/backend/tests/test_rating_examples.py b/backend/tests/test_rating_examples.py new file mode 100644 index 0000000..38e286f --- /dev/null +++ b/backend/tests/test_rating_examples.py @@ -0,0 +1,197 @@ +"""Движок рейтинга: примеры docs/rating/rating-system.md и сверка с эталоном simulate.py. + +Числа примеров — те же, что в документе (раздел 6) и в EXPECTED эталона: разъехаться +документ, эталон и приложение не должны. Сверка с simulate.py дополнительно гоняет +синтетический сезон и требует совпадения каждого изменения рейтинга.""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +from app.services import scoring +from app.services.scoring import RatedMatch, RatedSeat, rate_match, replay + +VETERAN = 40 # партий у «опытного» игрока: K = K_MIN +A, B, C, D, E, F = 1, 2, 3, 4, 5, 6 + + +def _vets(*ids: int) -> dict[int, int]: + return dict.fromkeys(ids, VETERAN) + + +def _duel(first: int, second: int, **kw) -> RatedMatch: + return RatedMatch((RatedSeat(first, 1), RatedSeat(second, 2)), **kw) + + +def _seat(uid: int, place: int, objectives=None, worlds=None, eliminated=False) -> RatedSeat: + return RatedSeat(uid, place, objectives=objectives, worlds=worlds, eliminated=eliminated) + + +FIVE = tuple(RatedSeat(uid, i + 1) for i, uid in enumerate((A, B, C, D, E))) +SIX = tuple(RatedSeat(uid, i + 1) for i, uid in enumerate((A, B, C, D, E, F))) + +# (ключ, рейтинги, сыграно партий, партия, ожидаемые ΔR с точностью до 0.01) +EXAMPLES = [ + ("1a", {A: 1600, B: 1400}, _vets(A, B), _duel(A, B, win_reason="objectives"), + {A: 3.84, B: -3.84}), + ("1b", {A: 1600, B: 1400}, _vets(A, B), _duel(B, A, win_reason="objectives"), + {B: 12.16, A: -12.16}), + ("2a", {A: 1500, B: 1500}, _vets(A, B), + RatedMatch((_seat(A, 1, 2, 5), _seat(B, 2, 1, 4)), "objectives", end_round=3), + {A: 11.18, B: -11.18}), + ("2b", {A: 1500, B: 1500}, _vets(A, B), + RatedMatch((_seat(A, 1, 2, 5), _seat(B, 2, 1, 4)), "objectives", end_round=8), + {A: 5.46, B: -5.46}), + ("3a", {A: 1500, B: 1500}, _vets(A, B), _duel(A, B, win_reason="objectives"), + {A: 8.0, B: -8.0}), + ("3b", dict.fromkeys((A, B, C, D, E), 1500), _vets(A, B, C, D, E), + RatedMatch(FIVE, "objectives"), + {A: 11.0, B: 5.5, C: 0.0, D: -5.5, E: -11.0}), + ("4a", {A: 1500, B: 1500}, _vets(A, B), _duel(A, B, win_reason="worlds"), + {A: 6.8, B: -6.8}), + ("4b", {A: 1500, B: 1500}, _vets(A, B), _duel(A, B, win_reason="plastic"), + {A: 5.6, B: -5.6}), + ("4c", {A: 1500, B: 1500}, _vets(A, B), _duel(A, B, win_reason="resources"), + {A: 4.8, B: -4.8}), + ("4d", {A: 1500, B: 1500}, _vets(A, B), + RatedMatch((_seat(A, 1, 2, 6), _seat(B, 2, 2, 5)), "worlds", end_round=8), + {A: 3.4, B: -3.4}), + ("4e", {A: 1500, B: 1500}, _vets(A, B), + RatedMatch((_seat(A, 1, 2, 8), _seat(B, 2, 0, 2)), "objectives", end_round=3), + {A: 16.0, B: -16.0}), + ("5", {A: 1550, B: 1500, C: 1480, D: 1450}, _vets(A, B, C, D), + RatedMatch( + (_seat(A, 1, 4, 8), _seat(B, 2, 3, 7), + _seat(C, 3, 1, 0, eliminated=True), _seat(D, 3, 0, 0, eliminated=True)), + "objectives", end_round=7, + ), + {A: 9.27, B: 5.85, C: -7.78, D: -7.35}), + ("6a", dict.fromkeys(range(A, F + 1), 1500), _vets(*range(A, F + 1)), + RatedMatch(SIX, "objectives", end_round=8, nine_rounds_rule=True), + {A: 12.0, B: 7.2, C: 2.4, D: -2.4, E: -7.2, F: -12.0}), + ("6b", dict.fromkeys(range(A, F + 1), 1500), _vets(*range(A, F + 1)), + RatedMatch(SIX, "objectives", end_round=8, nine_rounds_rule=False), + {A: 10.29, B: 7.54, C: 2.74, D: -2.06, E: -6.86, F: -11.66}), + ("7", {A: 1500, B: 1500}, {A: 0, B: VETERAN}, _duel(A, B, win_reason="objectives"), + {A: 32.0, B: -8.0}), +] + + +@pytest.mark.parametrize( + "ratings,games,match,want", [e[1:] for e in EXAMPLES], ids=[e[0] for e in EXAMPLES] +) +def test_document_examples(ratings, games, match, want): + delta, _perf = rate_match(ratings, games, match) + assert {uid: round(v, 2) for uid, v in delta.items()} == want + + +def test_examples_cover_whole_section(): + assert len(EXAMPLES) == 15 + + +def test_last_standing_counts_full_objective_gap(): + """Победа last_standing: отрыв победителя по целям = 1, сколько бы маркеров ни было.""" + seats = (_seat(A, 1, 1, 6), _seat(B, 2, 1, 0, eliminated=True)) + ordinary = rate_match({}, _vets(A, B), RatedMatch(seats, "objectives"))[0][A] + standing = rate_match({}, _vets(A, B), RatedMatch(seats, "last_standing"))[0][A] + # Отрыв по целям 1 вместо 0 → множитель больше на W_OBJ·1 = 0.5; ΔR = K·ΔM·(S − E). + assert standing - ordinary == pytest.approx(16 * 0.5 * 0.5) + + +# ─── Сверка с эталоном ─────────────────────────────────────────────────────── + +SIMULATE = Path(__file__).resolve().parents[2] / "docs" / "rating" / "simulate.py" + + +@pytest.fixture(scope="module") +def sim(): + if not SIMULATE.exists(): + pytest.skip("docs/rating/simulate.py недоступен") + spec = importlib.util.spec_from_file_location("rating_simulate", SIMULATE) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module # dataclasses ищут модуль по имени + spec.loader.exec_module(module) # type: ignore[union-attr] + return module + + +def _convert(m, ids: dict[str, int], match_id: int) -> RatedMatch: + return RatedMatch( + tuple( + RatedSeat( + ids[s.player], s.place, eliminated=s.eliminated, + objectives=s.objectives, worlds=s.worlds, + ) + for s in m.seats + ), + m.win_reason, + end_round=m.round, + nine_rounds_rule=m.nine_rounds, + id=match_id, + ) + + +def test_constants_match_reference(sim): + p = sim.PROPOSED + assert (p.r0, p.d, p.k_max, p.k_min, p.k_games) == ( + scoring.R0, scoring.D, scoring.K_MAX, scoring.K_MIN, scoring.K_GAMES + ) + assert (p.w_table, p.w_tempo, p.w_obj, p.w_worlds) == ( + scoring.W_TABLE, scoring.W_TEMPO, scoring.W_OBJ, scoring.W_WORLDS + ) + assert (p.mu_obj, p.mu_worlds, p.m_min, p.m_max) == ( + scoring.MU_OBJ, scoring.MU_WORLDS, scoring.M_MIN, scoring.M_MAX + ) + assert dict(p.closeness) == scoring.CLOSENESS + assert sim.BOARD_TILES == scoring.BOARD_TILES + assert not p.autocorr + + +@pytest.mark.parametrize("scenario", ["сигнал", "клубы", "рост"]) +@pytest.mark.parametrize("stripped", [False, True], ids=["full", "history"]) +def test_replay_matches_reference_season(sim, scenario, stripped): + """Весь сезон: каждое изменение рейтинга совпадает с эталоном до 1e-9.""" + cfg = sim.SCENARIOS[scenario] + _skill, matches = sim.generate_season( + cfg["seed"], sim.SEASON_MATCHES, cfg["informative"], cfg["clubs"], cfg["learning"] + ) + if stripped: + matches = [sim.strip_details(m) for m in matches] + ids: dict[str, int] = {} + for m in matches: + for s in m.seats: + ids.setdefault(s.player, len(ids) + 1) + + reference = sim.Elo(sim.PROPOSED) + ours = replay(_convert(m, ids, i) for i, m in enumerate(matches)) + for i, m in enumerate(matches): + for player, dv in reference.update(m).items(): + assert ours.delta[(i, ids[player])] == pytest.approx(dv, abs=1e-9) + for player, uid in ids.items(): + assert ours.ratings[uid] == pytest.approx(reference.rating(player), abs=1e-6) + + +def test_monotone_and_zero_sum(sim): + """Победитель без ничьей не теряет, последний без ничьей не получает; при равных K + сумма изменений за партию — ноль.""" + cfg = sim.SCENARIOS["сигнал"] + _skill, matches = sim.generate_season(cfg["seed"] + 7, 200, True) + ids: dict[str, int] = {} + for m in matches: + for s in m.seats: + ids.setdefault(s.player, len(ids) + 1) + veterans = dict.fromkeys(ids.values(), VETERAN) + for i, m in enumerate(matches): + rm = _convert(m, ids, i) + delta, _ = rate_match({}, veterans, rm) + places = [s.place for s in rm.seats] + for s in rm.seats: + if places.count(s.place) > 1: + continue + if s.place == 1: + assert delta[s.user_id] > 0 + if s.place == max(places): + assert delta[s.user_id] < 0 + assert sum(delta.values()) == pytest.approx(0.0, abs=1e-9) From 8aa6bb7235ba9c48ca91cc45936e618568f551b8 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 14 Sep 2026 22:29:01 +0300 Subject: [PATCH 2/5] =?UTF-8?q?=D0=A0=D0=B5=D0=B9=D1=82=D0=B8=D0=BD=D0=B3:?= =?UTF-8?q?=20=D1=80=D0=B0=D1=83=D0=BD=D0=B4,=20=D1=86=D0=B5=D0=BB=D0=B8,?= =?UTF-8?q?=20=D0=BC=D0=B8=D1=80=D1=8B,=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=BB=D0=BE=209=20=D1=80=D0=B0=D1=83=D0=BD=D0=B4=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B8=20=D0=BF=D1=80=D0=B8=D1=87=D0=B8=D0=BD=D0=B0=20last?= =?UTF-8?q?=5Fstanding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Модели и миграция 0014: groups.nine_rounds_rule, снимок правила в matches, matches.end_round, match_participants.objectives/worlds. CHECK причины победы расширяется, только если он в БД есть (старые БД без CHECK таблицу не пересоздают); пересоздание matches отказывает при PRAGMA foreign_keys=ON, иначе DROP унёс бы участников каскадом. Бэкфилл: last_standing у завершённых партий с одним невыбывшим. Проверено на копии dev-БД и на схеме origin/dev с CHECK: строки, CHECK, FK и индексы на месте, повтор и downgrade работают. API и валидация: end_round от 1 до лимита раундов партии (9 при хоумруле и 5+ игроках), у выбывшего миров 0, last_standing ровно при одном невыбывшем — в завершении и правке (игрока и админа); черновик хранит раунд, цели и миры без проверки правила. MatchRead отдаёт end_round, снимок правила и max_rounds. PATCH /groups/{id} принимает nine_rounds_rule вместе с названием. #23 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd --- .../alembic/versions/0014_rating_inputs.py | 130 ++++++++++ backend/app/models.py | 21 +- backend/app/routers/admin.py | 20 +- backend/app/routers/groups.py | 11 +- backend/app/routers/matches.py | 45 ++-- backend/app/schemas/api.py | 40 +++- backend/app/services/group_service.py | 10 + backend/app/services/match_service.py | 148 ++++++++++-- backend/app/services/stats_service.py | 2 + backend/tests/test_rating_inputs.py | 226 ++++++++++++++++++ 10 files changed, 593 insertions(+), 60 deletions(-) create mode 100644 backend/alembic/versions/0014_rating_inputs.py create mode 100644 backend/tests/test_rating_inputs.py diff --git a/backend/alembic/versions/0014_rating_inputs.py b/backend/alembic/versions/0014_rating_inputs.py new file mode 100644 index 0000000..fec3289 --- /dev/null +++ b/backend/alembic/versions/0014_rating_inputs.py @@ -0,0 +1,130 @@ +"""Рейтинг (#23): раунд окончания, цели и миры участников, правило 9 раундов, last_standing. + +Идемпотентна: на свежей БД столбцы и новый CHECK создаёт 0001 (create_all из актуальных +моделей) -> меняется только бэкфилл (на пустой БД он ничего не находит); на существующей +БД добавляет столбцы, расширяет CHECK причины победы (если он в БД есть) и проставляет +last_standing. + +Столбцы nullable и задним числом не заполняются: NULL — «нет данных», рейтинг +подставляет вместо них типичные значения (docs/rating/rating-system.md, 4.8). + +Revision ID: 0014_rating_inputs +Revises: 0013_user_token_version +Create Date: 2026-09-14 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy import inspect + +from alembic import op + +revision: str = "0014_rating_inputs" +down_revision: Union[str, None] = "0013_user_token_version" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +CK_WIN_REASON = "ck_match_win_reason" +OLD_REASONS = "win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')" +NEW_REASONS = ( + "win_reason IS NULL OR win_reason IN " + "('objectives','worlds','plastic','resources','last_standing')" +) + +# Причина last_standing ⇔ невыбывший участник ровно один (решение владельца по #22). +BACKFILL_LAST_STANDING = """ +UPDATE matches SET win_reason = 'last_standing' +WHERE status = 'finished' + AND (win_reason IS NULL OR win_reason <> 'last_standing') + AND (SELECT COUNT(*) FROM match_participants mp + WHERE mp.match_id = matches.id AND mp.eliminated = 0) = 1 +""" + + +def _columns(insp, table: str) -> set[str]: + return {c["name"] for c in insp.get_columns(table)} + + +def _win_reason_check(insp) -> str | None: + for ck in insp.get_check_constraints("matches"): + if ck.get("name") == CK_WIN_REASON: + return ck["sqltext"] + return None + + +def _recreate_matches_with_check(bind, drop_existing: bool, sqltext: str) -> None: + """Замена CHECK в SQLite = пересоздание таблицы matches (batch copy-and-move). + + При включённых внешних ключах DROP старой таблицы выполнил бы неявный DELETE, и + ON DELETE CASCADE унёс бы участников, вложения и черновики. Alembic из CLI работает + без PRAGMA foreign_keys (её включает только движок приложения), а внутри транзакции + PRAGMA не переключить — поэтому не рискуем и отказываемся с понятной ошибкой.""" + if bind.exec_driver_sql("PRAGMA foreign_keys").scalar(): + raise RuntimeError( + "0014: PRAGMA foreign_keys=ON — пересоздание matches удалило бы участников " + "каскадом. Запускайте миграции через `alembic upgrade head` (CLI)." + ) + with op.batch_alter_table("matches", recreate="always") as b: + if drop_existing: + b.drop_constraint(CK_WIN_REASON, type_="check") + b.create_check_constraint(CK_WIN_REASON, sqltext) + + +def upgrade() -> None: + bind = op.get_bind() + insp = inspect(bind) + + if "nine_rounds_rule" not in _columns(insp, "groups"): + with op.batch_alter_table("groups") as b: + b.add_column( + sa.Column("nine_rounds_rule", sa.Boolean(), nullable=False, server_default="0") + ) + + match_cols = _columns(insp, "matches") + with op.batch_alter_table("matches") as b: + if "end_round" not in match_cols: + b.add_column(sa.Column("end_round", sa.Integer(), nullable=True)) + if "nine_rounds_rule" not in match_cols: + b.add_column( + sa.Column("nine_rounds_rule", sa.Boolean(), nullable=False, server_default="0") + ) + + participant_cols = _columns(insp, "match_participants") + with op.batch_alter_table("match_participants") as b: + if "objectives" not in participant_cols: + b.add_column(sa.Column("objectives", sa.Integer(), nullable=True)) + if "worlds" not in participant_cols: + b.add_column(sa.Column("worlds", sa.Integer(), nullable=True)) + + # БД, созданные до появления CHECK в моделях (0001 тогда был старше), ограничения + # не имеют вовсе — last_standing им и так разрешён, пересоздавать таблицу незачем. + check = _win_reason_check(inspect(bind)) + if check is not None and "last_standing" not in check: + _recreate_matches_with_check(bind, drop_existing=True, sqltext=NEW_REASONS) + + op.execute(BACKFILL_LAST_STANDING) + + +def downgrade() -> None: + bind = op.get_bind() + # Прежний CHECK не знает last_standing: такие партии теряют признак (раньше их + # записывали «по целям»). + op.execute("UPDATE matches SET win_reason = 'objectives' WHERE win_reason = 'last_standing'") + check = _win_reason_check(inspect(bind)) + if check is not None and "last_standing" in check: + _recreate_matches_with_check(bind, drop_existing=True, sqltext=OLD_REASONS) + + insp = inspect(bind) + participant_cols = _columns(insp, "match_participants") + with op.batch_alter_table("match_participants") as b: + for name in ("worlds", "objectives"): + if name in participant_cols: + b.drop_column(name) + match_cols = _columns(insp, "matches") + with op.batch_alter_table("matches") as b: + for name in ("nine_rounds_rule", "end_round"): + if name in match_cols: + b.drop_column(name) + if "nine_rounds_rule" in _columns(insp, "groups"): + with op.batch_alter_table("groups") as b: + b.drop_column("nine_rounds_rule") diff --git a/backend/app/models.py b/backend/app/models.py index 4c5d45d..f59dc79 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -195,6 +195,12 @@ class Group(SQLModel, table=True): sa_column_kwargs={"onupdate": utcnow}, nullable=False, ) + # Домашнее правило: при 5–6 игроках играется 9 раундов вместо 8. Партия снимает + # значение при старте (Match.nine_rounds_rule), так что смена галочки историю не трогает. + nine_rounds_rule: bool = Field( + default=False, + sa_column=Column(Boolean, nullable=False, server_default="0"), + ) class GroupMember(SQLModel, table=True): @@ -275,7 +281,8 @@ class Match(SQLModel, table=True): Index("ix_matches_group_played", "group_id", "played_at"), CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"), CheckConstraint( - "win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')", + "win_reason IS NULL OR win_reason IN " + "('objectives','worlds','plastic','resources','last_standing')", name="ck_match_win_reason", ), ) @@ -295,6 +302,14 @@ class Match(SQLModel, table=True): finished_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True)) duration_minutes: int | None = Field(default=None, sa_column=Column(Integer, nullable=True)) win_reason: str | None = Field(default=None, sa_column=Column(String(16), nullable=True)) + # Раунд, в котором партия закончилась (NULL — не указан). Лимит раундов не хранится: + # он выводится из снимка nine_rounds_rule и числа участников (scoring.max_rounds). + end_round: int | None = Field(default=None, sa_column=Column(Integer, nullable=True)) + # Снимок Group.nine_rounds_rule на момент старта партии. + nine_rounds_rule: bool = Field( + default=False, + sa_column=Column(Boolean, nullable=False, server_default="0"), + ) player_count: int = Field(sa_column=Column(Integer, nullable=False)) overall_comment: str | None = Field(sa_column=Column(Text, nullable=True)) created_by: int = Field( @@ -340,6 +355,10 @@ class MatchParticipant(SQLModel, table=True): eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) comment: str | None = Field(sa_column=Column(Text, nullable=True)) + # Итог партии для рейтинга (NULL — не указан): маркеры целей и дружественные миры + # на конец партии. У выбывшего миров 0. + objectives: int | None = Field(default=None, sa_column=Column(Integer, nullable=True)) + worlds: int | None = Field(default=None, sa_column=Column(Integer, nullable=True)) created_at: datetime = Field(default_factory=utcnow, nullable=False) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index a07e8da..97164dd 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -13,9 +13,8 @@ from app.core.errors import InvalidCredentialsError, NotFoundError from app.core.timeutil import iso_utc from app.db.session import get_session from app.models import User -from app.routers.matches import attachment_read, build_match_read +from app.routers.matches import attachment_read, build_match_read, participant_inputs from app.schemas import api as s -from app.services.match_service import ParticipantInput from app.services import ( achievement_service, admin_service, @@ -243,19 +242,6 @@ def update_match( admin: User = Depends(get_current_admin), ) -> s.MatchRead: match = match_service.get_match(session, match_id) - participants = None - if body.participants is not None: - participants = [ - ParticipantInput( - user_id=p.user_id, - faction_id=p.faction_id, - place=p.place, - eliminated=p.eliminated, - was_random=p.was_random, - comment=p.comment, - ) - for p in body.participants - ] match = match_service.update_match( session, match, @@ -264,7 +250,9 @@ def update_match( overall_comment_set=("overall_comment" in body.model_fields_set), win_reason=body.win_reason, win_reason_set=("win_reason" in body.model_fields_set), - participants=participants, + end_round=body.end_round, + end_round_set=("end_round" in body.model_fields_set), + participants=participant_inputs(body), expected_version=body.expected_version, ) audit_service.record( diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py index 1a9bc9d..010d43a 100644 --- a/backend/app/routers/groups.py +++ b/backend/app/routers/groups.py @@ -33,6 +33,7 @@ def _detail(session: Session, group_id: int, user_id: int) -> s.GroupDetail: owner_id=group.owner_id, my_role=member.role, expansion_ids=group_service.group_expansion_ids(session, group_id), + nine_rounds_rule=group.nine_rounds_rule, ) @@ -79,15 +80,19 @@ def get_group( @router.patch("/{group_id}", response_model=s.GroupDetail) -def rename_group( +def update_group( group_id: int, - body: s.GroupRename, + body: s.GroupUpdate, session: Session = Depends(get_session), user: User = Depends(get_current_user), ) -> s.GroupDetail: + """Название и домашние правила группы; меняются только переданные поля.""" group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type] group = group_service.get_group(session, group_id) - group_service.rename_group(session, group, body.name) + if body.name is not None: + group_service.rename_group(session, group, body.name) + if body.nine_rounds_rule is not None: + group_service.set_nine_rounds_rule(session, group, body.nine_rounds_rule) return _detail(session, group_id, user.id) # type: ignore[arg-type] diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py index 8778b7c..c49b65e 100644 --- a/backend/app/routers/matches.py +++ b/backend/app/routers/matches.py @@ -22,10 +22,30 @@ from app.services import ( user_service, ) from app.services.match_service import FinishInput, ParticipantInput, RosterInput +from app.services.scoring import max_rounds router = APIRouter(prefix="/matches", tags=["matches"]) +def participant_inputs(body: s.MatchUpdate) -> list[ParticipantInput] | None: + """Участники правки — общее для игроцкого и админского PATCH.""" + if body.participants is None: + return None + return [ + ParticipantInput( + user_id=p.user_id, + faction_id=p.faction_id, + place=p.place, + eliminated=p.eliminated, + was_random=p.was_random, + comment=p.comment, + objectives=p.objectives, + worlds=p.worlds, + ) + for p in body.participants + ] + + def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead: """AttachmentRead с URL под нужным префиксом (base = '/api/matches/{id}' или @@ -56,6 +76,8 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False eliminated=p.eliminated, was_random=p.was_random, comment=p.comment, + objectives=p.objectives, + worlds=p.worlds, avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type] ) for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type] @@ -79,6 +101,9 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False finished_at=iso_utc(match.finished_at), duration_minutes=match.duration_minutes, win_reason=match.win_reason, # type: ignore[arg-type] + end_round=match.end_round, + nine_rounds_rule=match.nine_rounds_rule, + max_rounds=max_rounds(match.player_count, match.nine_rounds_rule), player_count=match.player_count, overall_comment=match.overall_comment, created_by=match.created_by, @@ -157,6 +182,8 @@ def finish_match( eliminated=p.eliminated, comment=p.comment, faction_id=p.faction_id, + objectives=p.objectives, + worlds=p.worlds, ) for p in body.participants ] @@ -165,6 +192,7 @@ def finish_match( match, finish=finish, win_reason=body.win_reason, + end_round=body.end_round, overall_comment=body.overall_comment, overall_comment_set=("overall_comment" in body.model_fields_set), expected_version=body.expected_version, @@ -206,19 +234,6 @@ def update_match( ) -> s.MatchRead: match = match_service.get_match(session, match_id) match_service.assert_can_modify(session, match, user) - participants = None - if body.participants is not None: - participants = [ - ParticipantInput( - user_id=p.user_id, - faction_id=p.faction_id, - place=p.place, - eliminated=p.eliminated, - was_random=p.was_random, - comment=p.comment, - ) - for p in body.participants - ] match = match_service.update_match( session, match, @@ -227,7 +242,9 @@ def update_match( overall_comment_set=("overall_comment" in body.model_fields_set), win_reason=body.win_reason, win_reason_set=("win_reason" in body.model_fields_set), - participants=participants, + end_round=body.end_round, + end_round_set=("end_round" in body.model_fields_set), + participants=participant_inputs(body), expected_version=body.expected_version, ) audit_service.record( diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 60026aa..0910e3c 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -2,11 +2,18 @@ from __future__ import annotations from datetime import date -from typing import Literal +from typing import Annotated, Literal from pydantic import BaseModel, ConfigDict, Field -WinReason = Literal["objectives", "worlds", "plastic", "resources"] +# last_standing — все соперники выбыли. Вручную не выбирается: сервер требует её ровно +# тогда, когда невыбывший участник один (match_service._check_last_standing). +WinReason = Literal["objectives", "worlds", "plastic", "resources", "last_standing"] + +# Итоги партии для рейтинга. Верхние границы — только отсечка мусора: правила игры +# ограничивают сильнее, но их проверка — дело предупреждений в форме, а не отказа. +EndRound = Annotated[int, Field(ge=1, le=9)] +Count = Annotated[int, Field(ge=0, le=99)] # ─── Auth ──────────────────────────────────────────────────────────────────── @@ -162,8 +169,10 @@ class GroupCreate(BaseModel): expansion_ids: list[int] = [] -class GroupRename(BaseModel): - name: str +class GroupUpdate(BaseModel): + # Частичная правка: переданные поля меняются, остальные остаются как есть. + name: str | None = None + nine_rounds_rule: bool | None = None class GroupExpansionsUpdate(BaseModel): @@ -176,6 +185,8 @@ class GroupDetail(BaseModel): owner_id: int my_role: str expansion_ids: list[int] = [] + # Домашнее правило: 9 раундов при 5–6 игроках (снимается в партию при старте). + nine_rounds_rule: bool = False class MemberRead(BaseModel): @@ -252,17 +263,20 @@ class MatchFinishParticipant(BaseModel): eliminated: bool = False # выбыл из партии → авто-проставится последнее место comment: str | None = None faction_id: int | None = None # опц. смена фракции при завершении + objectives: Count | None = None # маркеры целей на конец партии (необязательно) + worlds: Count | None = None # дружественные миры на конец партии; у выбывшего 0 class MatchFinish(BaseModel): participants: list[MatchFinishParticipant] win_reason: WinReason + end_round: EndRound | None = None # раунд, в котором партия закончилась overall_comment: str | None = None # Оптимистичная блокировка: версия партии, которую видел клиент (см. MatchRead.version). expected_version: str | None = None -# Полный участник (правка завершённой партии админом). +# Полный участник (правка результатов завершённой партии). class ParticipantInput(BaseModel): user_id: int faction_id: int @@ -270,12 +284,15 @@ class ParticipantInput(BaseModel): eliminated: bool = False was_random: bool = False comment: str | None = None + objectives: Count | None = None + worlds: Count | None = None class MatchUpdate(BaseModel): played_at: date | None = None overall_comment: str | None = None win_reason: WinReason | None = None + end_round: EndRound | None = None participants: list[ParticipantInput] | None = None expected_version: str | None = None # оптимистичная блокировка @@ -289,6 +306,8 @@ class MatchParticipantRead(BaseModel): eliminated: bool = False was_random: bool comment: str | None = None + objectives: int | None = None + worlds: int | None = None avatar_url: str | None = None @@ -311,6 +330,10 @@ class MatchFinishDraftData(BaseModel): comments: dict[str, str] = {} win_reason: WinReason | None = None overall_comment: str | None = None + end_round: EndRound | None = None + # Ключ — user_id строкой (как у comments); незаполненные поля в словарь не попадают. + objectives: dict[str, Count] = {} + worlds: dict[str, Count] = {} class MatchFinishDraftRead(BaseModel): @@ -329,6 +352,11 @@ class MatchRead(BaseModel): finished_at: str | None = None duration_minutes: int | None = None win_reason: WinReason | None = None + end_round: int | None = None + # Снимок правила 9 раундов и вычисленный из него лимит раундов этой партии: + # фронт берёт лимит отсюда, а не повторяет правило у себя. + nine_rounds_rule: bool = False + max_rounds: int player_count: int overall_comment: str | None = None created_by: int @@ -451,6 +479,8 @@ class MatchListParticipant(BaseModel): eliminated: bool = False was_random: bool comment: str | None = None + objectives: int | None = None + worlds: int | None = None class MatchListItem(BaseModel): diff --git a/backend/app/services/group_service.py b/backend/app/services/group_service.py index 7c9f3df..8cd2151 100644 --- a/backend/app/services/group_service.py +++ b/backend/app/services/group_service.py @@ -97,6 +97,16 @@ def rename_group(session: Session, group: Group, name: str) -> Group: return group +def set_nine_rounds_rule(session: Session, group: Group, enabled: bool) -> Group: + """Хоумрул «9 раундов при 5–6 игроках». Действует на партии, начатые после смены: + уже начатые хранят свой снимок (Match.nine_rounds_rule).""" + group.nine_rounds_rule = enabled + session.add(group) + session.commit() + session.refresh(group) + return group + + def set_expansions(session: Session, group: Group, expansion_ids: list[int]) -> Group: valid = set(_valid_non_base_expansion_ids(session, expansion_ids)) current = session.exec( diff --git a/backend/app/services/match_service.py b/backend/app/services/match_service.py index e016aca..d51953c 100644 --- a/backend/app/services/match_service.py +++ b/backend/app/services/match_service.py @@ -19,9 +19,12 @@ from app.core.errors import ( from app.core.timeutil import app_today, iso_utc, utcnow from app.models import Faction, GroupMember, Match, MatchFinishDraft, MatchParticipant, User from app.services import group_service +from app.services.scoring import EXTENDED_ROUNDS, max_rounds MAX_MATCH_PLAYERS = 6 -WIN_REASONS = ("objectives", "worlds", "plastic", "resources") +LAST_STANDING = "last_standing" +WIN_REASONS = ("objectives", "worlds", "plastic", "resources", LAST_STANDING) +MAX_COUNT = 99 # отсечка мусора в целях/мирах (та же, что в схеме API) @dataclass @@ -42,11 +45,13 @@ class FinishInput: eliminated: bool = False comment: str | None = None faction_id: int | None = None # опц. смена фракции при завершении + objectives: int | None = None + worlds: int | None = None @dataclass class ParticipantInput: - """Полный участник (для правки завершённой партии админом).""" + """Полный участник (для правки результатов завершённой партии).""" user_id: int faction_id: int @@ -54,6 +59,8 @@ class ParticipantInput: eliminated: bool = False was_random: bool = False comment: str | None = None + objectives: int | None = None + worlds: int | None = None def round_to_30(minutes: float) -> int: @@ -154,6 +161,43 @@ def _resolve_finish_places(rows: list[tuple[int, int | None, bool]]) -> dict[int return {uid: (elim_place if elim else place) for uid, place, elim in rows} # type: ignore[misc] +# ─── Итоги партии для рейтинга ──────────────────────────────────────────────── +# Сервер проверяет только диапазоны и явные противоречия. Согласованность итогов между +# собой (тип победы и цели лидеров и т.п.) — предупреждения формы, а не отказ. + +def _check_end_round(end_round: int | None, player_count: int, nine_rounds_rule: bool) -> None: + if end_round is None: + return + rmax = max_rounds(player_count, nine_rounds_rule) + if not 1 <= end_round <= rmax: + raise ValidationError(f"Раунд окончания — от 1 до {rmax}.") + + +def _worlds_of(eliminated: bool, worlds: int | None) -> int | None: + """У выбывшего миров нет: пустое поле записывается нулём, иное число — противоречие.""" + if not eliminated: + return worlds + if worlds: + raise ValidationError("У выбывшего игрока не может быть миров.") + return 0 + + +def _check_last_standing(win_reason: str | None, eliminated: list[bool]) -> None: + """Причина «последний выживший» ⇔ невыбывший участник ровно один (решение по #22). + + Выбрать её вручную нельзя, и забыть поставить тоже: форма проставляет её сама, + сервер лишь не пропускает расхождение.""" + alone = sum(1 for e in eliminated if not e) == 1 + if alone and win_reason != LAST_STANDING: + raise ValidationError( + "Остался один невыбывший игрок — причина победы «последний выживший»." + ) + if not alone and win_reason == LAST_STANDING: + raise ValidationError( + "«Последний выживший» возможен, только когда все, кроме победителя, выбыли." + ) + + def _group_member_ids(session: Session, group_id: int) -> set[int]: return { m.user_id @@ -208,12 +252,15 @@ def create_match( ) now = utcnow() + group = group_service.get_group(session, group_id) match = Match( group_id=group_id, status="in_progress", played_at=app_today(), # дата игры — в поясе приложения (+3) started_at=now, player_count=len(roster), + # Снимок: смена настройки группы потом не переписывает лимит раундов этой партии. + nine_rounds_rule=group.nine_rounds_rule, created_by=creator.id, # type: ignore[arg-type] ) session.add(match) @@ -242,6 +289,7 @@ def finish_match( *, finish: list[FinishInput], win_reason: str, + end_round: int | None = None, overall_comment: str | None = None, overall_comment_set: bool = False, expected_version: str | None = None, @@ -251,6 +299,7 @@ def finish_match( raise ConflictError("Партия уже завершена.") if win_reason not in WIN_REASONS: raise ValidationError("Укажите корректную причину победы.") + _check_end_round(end_round, match.player_count, match.nine_rounds_rule) existing = { p.user_id: p @@ -273,12 +322,16 @@ def finish_match( raise FactionNotAvailableError() places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) for f in finish]) + _check_last_standing(win_reason, [f.eliminated for f in finish]) + worlds = {f.user_id: _worlds_of(f.eliminated, f.worlds) for f in finish} for f in finish: p = existing[f.user_id] p.place = places[f.user_id] p.eliminated = f.eliminated p.comment = f.comment or None + p.objectives = f.objectives + p.worlds = worlds[f.user_id] if f.faction_id is not None: p.faction_id = f.faction_id session.add(p) @@ -294,6 +347,7 @@ def finish_match( match.duration_minutes = round_to_30(elapsed_min) match.status = "finished" match.win_reason = win_reason + match.end_round = end_round if overall_comment_set: match.overall_comment = overall_comment or None @@ -312,22 +366,44 @@ def get_finish_draft(session: Session, match_id: int) -> MatchFinishDraft | None return session.get(MatchFinishDraft, match_id) +def _draft_counts(value: object) -> dict[str, int]: + """Цели/миры черновика: {user_id строкой: число}. Пустые поля в словарь не попадают.""" + if not isinstance(value, dict): + raise ValidationError("Некорректный черновик.") + out: dict[str, int] = {} + for k, v in value.items(): + if isinstance(v, bool) or not isinstance(v, int) or not 0 <= v <= MAX_COUNT: + raise ValidationError("Некорректный черновик.") + out[str(k)] = v + return out + + def _validate_draft(session: Session, match: Match, data: dict) -> dict: """Черновик — свободная форма, но не мусор: состав обязан совпадать с участниками партии, а причина победы быть из известных. Места здесь НЕ валидируются: человек - раскладывает их постепенно, и промежуточное состояние может быть любым.""" + раскладывает их постепенно, и промежуточное состояние может быть любым. По той же + причине не проверяется и правило «последнего выжившего».""" if not isinstance(data, dict): raise ValidationError("Некорректный черновик.") blocks = data.get("blocks") or [] eliminated = data.get("eliminated") or [] comments = data.get("comments") or {} win_reason = data.get("win_reason") + end_round = data.get("end_round") if not isinstance(blocks, list) or not isinstance(eliminated, list): raise ValidationError("Некорректный черновик.") if not isinstance(comments, dict): raise ValidationError("Некорректный черновик.") if win_reason is not None and win_reason not in WIN_REASONS: raise ValidationError("Некорректная причина победы.") + if end_round is not None and ( + isinstance(end_round, bool) + or not isinstance(end_round, int) + or not 1 <= end_round <= EXTENDED_ROUNDS + ): + raise ValidationError("Некорректный черновик.") + objectives = _draft_counts(data.get("objectives") or {}) + worlds = _draft_counts(data.get("worlds") or {}) participant_ids = { p.user_id @@ -354,6 +430,9 @@ def _validate_draft(session: Session, match: Match, data: dict) -> dict: "comments": {str(k): str(v) for k, v in comments.items()}, "win_reason": win_reason, "overall_comment": overall, + "end_round": end_round, + "objectives": objectives, + "worlds": worlds, } @@ -409,35 +488,41 @@ def update_match( overall_comment_set: bool = False, win_reason: str | None = None, win_reason_set: bool = False, + end_round: int | None = None, + end_round_set: bool = False, participants: list[ParticipantInput] | None = None, expected_version: str | None = None, ) -> Match: - """Правка партии: состав с местами, дата, комментарий, причина победы. + """Правка партии: состав с местами и итогами, дата, комментарий, причина победы, раунд. - Результаты (места и причина победы) пишутся только в завершённую партию: иначе они - оседали бы в партии со статусом in_progress, которая остаётся в «Незавершённых» и не - попадает ни в одну витрину статистики (SCORED_CTE считает только status='finished'). - Дату и общий комментарий править можно и по ходу партии — двойственного состояния - они не создают.""" - results_touched = participants is not None or win_reason_set + Результаты (места, итоги, причина победы, раунд) пишутся только в завершённую партию: + иначе они оседали бы в партии со статусом in_progress, которая остаётся + в «Незавершённых» и не попадает ни в одну витрину статистики (рейтинг проигрывает + только status='finished'). Дату и общий комментарий править можно и по ходу + партии — двойственного состояния они не создают.""" + results_touched = participants is not None or win_reason_set or end_round_set if results_touched and match.status != "finished": raise ConflictError( "Результаты незавершённой партии нельзя править — сначала завершите её." ) assert_version(match, expected_version) - if played_at is not None: - match.played_at = played_at - if overall_comment_set: - match.overall_comment = overall_comment or None - if win_reason_set: - if win_reason is not None and win_reason not in WIN_REASONS: - raise ValidationError("Некорректная причина победы.") - match.win_reason = win_reason + if win_reason_set and win_reason is not None and win_reason not in WIN_REASONS: + raise ValidationError("Некорректная причина победы.") + saved = session.exec( + select(MatchParticipant).where(MatchParticipant.match_id == match.id) + ).all() + # Проверки — по состоянию партии ПОСЛЕ правки: частичный запрос сверяется + # с тем, что уже записано. + new_reason = win_reason if win_reason_set else match.win_reason + new_end_round = end_round if end_round_set else match.end_round + new_count = len(participants) if participants is not None else match.player_count + if end_round_set or participants is not None: + _check_end_round(new_end_round, new_count, match.nine_rounds_rule) + + places: dict[int, int] = {} + worlds: dict[int, int | None] = {} if participants is not None: - saved = session.exec( - select(MatchParticipant).where(MatchParticipant.match_id == match.id) - ).all() # Что уже записано в партии, остаётся допустимым: состав группы и набор # дополнений с тех пор могли поменяться, но историю это чинить не мешает. _validate_roster_basics( @@ -451,6 +536,25 @@ def update_match( places = _resolve_finish_places( [(p.user_id, p.place, p.eliminated) for p in participants] ) + worlds = {p.user_id: _worlds_of(p.eliminated, p.worlds) for p in participants} + if participants is not None or win_reason_set: + flags = ( + [p.eliminated for p in participants] + if participants is not None + else [p.eliminated for p in saved] + ) + _check_last_standing(new_reason, flags) + + if played_at is not None: + match.played_at = played_at + if overall_comment_set: + match.overall_comment = overall_comment or None + if win_reason_set: + match.win_reason = win_reason + if end_round_set: + match.end_round = end_round + + if participants is not None: for old in saved: session.delete(old) session.flush() @@ -464,6 +568,8 @@ def update_match( eliminated=p.eliminated, was_random=p.was_random, comment=p.comment or None, + objectives=p.objectives, + worlds=worlds[p.user_id], ) ) match.player_count = len(participants) diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index d77987e..5f57eb1 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -316,6 +316,8 @@ def _participant_row(p: MatchParticipant, u: User, f: Faction) -> dict: "eliminated": p.eliminated, "was_random": p.was_random, "comment": p.comment, + "objectives": p.objectives, + "worlds": p.worlds, } diff --git a/backend/tests/test_rating_inputs.py b/backend/tests/test_rating_inputs.py new file mode 100644 index 0000000..29ea915 --- /dev/null +++ b/backend/tests/test_rating_inputs.py @@ -0,0 +1,226 @@ +"""Итоги партии для рейтинга (#23): раунд окончания, цели и миры, правило 9 раундов, +причина «последний выживший». Сервер проверяет диапазоны и явные противоречия.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match + + +def _group(client: TestClient, engine, players: int) -> tuple[int, list[int], list[int]]: + """Группа со всеми дополнениями и players участниками (первый — вошедший).""" + me = login(client, "Хозяин") + exps = [e["id"] for e in client.get("/api/expansions").json()] + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client) + ).json()["id"] + uids = [me["id"]] + [add_group_member(engine, gid, f"Игрок{i}") for i in range(2, players + 1)] + fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] + return gid, uids, fids + + +def _start(client: TestClient, gid: int, uids: list[int], fids: list[int]) -> dict: + r = start_match( + client, gid, [{"user_id": u, "faction_id": fids[i]} for i, u in enumerate(uids)] + ) + assert r.status_code == 200, r.text + return r.json() + + +def _finish(client: TestClient, mid: int, participants: list[dict], win_reason: str, **extra): + body = {"participants": participants, "win_reason": win_reason, **extra} + return client.post(f"/api/matches/{mid}/finish", json=body, headers=csrf_headers(client)) + + +def _set_rule(client: TestClient, gid: int, enabled: bool) -> dict: + r = client.patch( + f"/api/groups/{gid}", json={"nine_rounds_rule": enabled}, headers=csrf_headers(client) + ) + assert r.status_code == 200, r.text + return r.json() + + +def test_finish_saves_round_objectives_and_worlds(client: TestClient, engine): + gid, (a, b), fids = _group(client, engine, 2) + match = _start(client, gid, [a, b], fids) + assert match["max_rounds"] == 8 and match["end_round"] is None + + r = _finish( + client, match["id"], + [ + {"user_id": a, "place": 1, "objectives": 2, "worlds": 5}, + {"user_id": b, "place": 2, "objectives": 1}, # миры не указаны — так и остаётся + ], + "objectives", end_round=6, + ) + assert r.status_code == 200, r.text + data = r.json() + assert data["end_round"] == 6 + parts = {p["user_id"]: p for p in data["participants"]} + assert (parts[a]["objectives"], parts[a]["worlds"]) == (2, 5) + assert (parts[b]["objectives"], parts[b]["worlds"]) == (1, None) + + listed = client.get(f"/api/groups/{gid}/matches").json()["items"][0]["participants"] + assert {p["user_id"]: p["objectives"] for p in listed} == {a: 2, b: 1} + + +def test_end_round_limited_by_max_rounds(client: TestClient, engine): + gid, (a, b), fids = _group(client, engine, 2) + match = _start(client, gid, [a, b], fids) + rows = [{"user_id": a, "place": 1}, {"user_id": b, "place": 2}] + assert _finish(client, match["id"], rows, "objectives", end_round=9).status_code == 422 + assert _finish(client, match["id"], rows, "objectives", end_round=0).status_code == 422 + assert _finish(client, match["id"], rows, "objectives", end_round=8).status_code == 200 + + +def test_nine_rounds_rule_is_snapshotted_for_five_players(client: TestClient, engine): + gid, uids, fids = _group(client, engine, 5) + assert _set_rule(client, gid, True)["nine_rounds_rule"] is True + assert client.get(f"/api/groups/{gid}").json()["name"] == "Группа" # имя не тронуто + + five = _start(client, gid, uids, fids) + four = _start(client, gid, uids[:4], fids) + assert (five["nine_rounds_rule"], five["max_rounds"]) == (True, 9) + assert (four["nine_rounds_rule"], four["max_rounds"]) == (True, 8) # правило — только с 5 + + # Смена настройки группы не переписывает уже начатую партию. + _set_rule(client, gid, False) + assert client.get(f"/api/matches/{five['id']}").json()["max_rounds"] == 9 + + rows = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids)] + assert _finish(client, five["id"], rows, "objectives", end_round=9).status_code == 200 + rows4 = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids[:4])] + assert _finish(client, four["id"], rows4, "objectives", end_round=9).status_code == 422 + + +def test_last_standing_required_exactly_when_one_survivor(client: TestClient, engine): + gid, (a, b, c), fids = _group(client, engine, 3) + match = _start(client, gid, [a, b, c], fids) + alone = [ + {"user_id": a, "place": 1}, + {"user_id": b, "eliminated": True}, + {"user_id": c, "eliminated": True}, + ] + r = _finish(client, match["id"], alone, "objectives") + assert r.status_code == 422 and "последний выживший" in r.json()["error"]["message"] + + two = [ + {"user_id": a, "place": 1}, + {"user_id": b, "place": 2}, + {"user_id": c, "eliminated": True}, + ] + assert _finish(client, match["id"], two, "last_standing").status_code == 422 + + ok = _finish(client, match["id"], alone, "last_standing") + assert ok.status_code == 200, ok.text + assert ok.json()["win_reason"] == "last_standing" + + +def test_eliminated_player_has_no_worlds(client: TestClient, engine): + gid, (a, b, c), fids = _group(client, engine, 3) + match = _start(client, gid, [a, b, c], fids) + rows = [ + {"user_id": a, "place": 1, "worlds": 7}, + {"user_id": b, "place": 2, "worlds": 4}, + {"user_id": c, "eliminated": True, "worlds": 2}, + ] + assert _finish(client, match["id"], rows, "objectives").status_code == 422 + + rows[2] = {"user_id": c, "eliminated": True, "objectives": 1} # миры не указаны + r = _finish(client, match["id"], rows, "objectives") + assert r.status_code == 200, r.text + parts = {p["user_id"]: p for p in r.json()["participants"]} + assert (parts[c]["worlds"], parts[c]["objectives"]) == (0, 1) + + +def test_negative_counts_rejected(client: TestClient, engine): + gid, (a, b), fids = _group(client, engine, 2) + match = _start(client, gid, [a, b], fids) + rows = [{"user_id": a, "place": 1, "objectives": -1}, {"user_id": b, "place": 2}] + assert _finish(client, match["id"], rows, "objectives").status_code == 422 + + +def _finished(client: TestClient, gid: int, uids: list[int], fids: list[int]) -> dict: + match = _start(client, gid, uids, fids) + rows = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids)] + r = _finish(client, match["id"], rows, "objectives") + assert r.status_code == 200, r.text + return r.json() + + +def _edit_rows(match: dict, **by_user) -> list[dict]: + rows = [] + for p in match["participants"]: + row = {"user_id": p["user_id"], "faction_id": p["faction_id"], "place": p["place"]} + row.update(by_user.get(str(p["user_id"]), {})) + rows.append(row) + return rows + + +def test_edit_checks_last_standing_and_round(client: TestClient, engine): + gid, (a, b, c), fids = _group(client, engine, 3) + match = _finished(client, gid, [a, b, c], fids) + mid = match["id"] + + def patch(body: dict): + return client.patch(f"/api/matches/{mid}", json=body, headers=csrf_headers(client)) + + elim = {"place": None, "eliminated": True} + rows = _edit_rows(match, **{str(b): elim, str(c): elim}) + assert patch({"participants": rows, "win_reason": "worlds"}).status_code == 422 + # Причина не передана — сверяется с записанной («по целям»): тоже противоречие. + assert patch({"participants": rows}).status_code == 422 + r = patch({"participants": rows, "win_reason": "last_standing", "end_round": 5}) + assert r.status_code == 200, r.text + assert (r.json()["win_reason"], r.json()["end_round"]) == ("last_standing", 5) + + # Одна только причина: при одном выжившем вернуть «по целям» нельзя. + assert patch({"win_reason": "objectives"}).status_code == 422 + # Один только раунд: в пределах лимита — можно, за лимитом — нет. + assert patch({"end_round": 9}).status_code == 422 + assert patch({"end_round": None}).json()["end_round"] is None + + +def test_admin_edit_saves_counts(client: TestClient, engine, make_admin): + gid, (a, b), fids = _group(client, engine, 2) + match = _finished(client, gid, [a, b], fids) + make_admin("admin", "secret123") + assert client.post( + "/api/admin/auth/login", + json={"username": "admin", "password": "secret123"}, + headers=csrf_headers(client), + ).status_code == 200 + rows = _edit_rows(match, **{str(a): {"objectives": 2, "worlds": 6}}) + r = client.patch( + f"/api/admin/matches/{match['id']}", + json={"participants": rows, "win_reason": "objectives", "end_round": 7}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + parts = {p["user_id"]: p for p in r.json()["participants"]} + assert (parts[a]["objectives"], parts[a]["worlds"], r.json()["end_round"]) == (2, 6, 7) + + +def test_draft_keeps_round_and_counts(client: TestClient, engine): + gid, (a, b), fids = _group(client, engine, 2) + match = _start(client, gid, [a, b], fids) + body = { + "blocks": [[a], [b]], + "win_reason": "last_standing", # черновик — незаконченный ввод, правило не проверяется + "end_round": 4, + "objectives": {str(a): 2}, + "worlds": {str(a): 5, str(b): 3}, + } + r = client.put(f"/api/matches/{match['id']}/finish-draft", json=body, headers=csrf_headers(client)) + assert r.status_code == 200, r.text + data = client.get(f"/api/matches/{match['id']}").json()["finish_draft"]["data"] + assert data["end_round"] == 4 + assert data["objectives"] == {str(a): 2} + assert data["worlds"] == {str(a): 5, str(b): 3} + + bad = client.put( + f"/api/matches/{match['id']}/finish-draft", + json={"worlds": {str(a): -3}}, + headers=csrf_headers(client), + ) + assert bad.status_code == 422 From 6ea4a676727f33318b7d35dd778b09fb64440ede Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 14 Sep 2026 22:38:49 +0300 Subject: [PATCH 3/5] =?UTF-8?q?=D0=A0=D0=B5=D0=B9=D1=82=D0=B8=D0=BD=D0=B3:?= =?UTF-8?q?=20=D0=B2=D0=B8=D1=82=D1=80=D0=B8=D0=BD=D1=8B=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=D1=81=D1=82=D0=B8=D0=BA=D0=B8=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D0=B8=D0=B3=D1=80=D1=8B=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B8=D1=81=D1=82=D0=BE=D1=80=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQL-агрегат League Points (SCORED_CTE, сглаженное среднее) заменён проигрыванием завершённых партий по порядку played_at, finished_at, id: load_history — один запрос на партии с участниками, scoring.replay считает рейтинг. Цепочки две: общая (все партии) и групповая (только партии группы, K — по партиям внутри группы). Правка или удаление прошлой партии пересчитывает всё после неё без отдельной логики. Топ, профиль, главная и статистика группы берут игры, победы, винрейт и среднее место из той же истории; score — рейтинг целым числом, сортировка по неокруглённому. Главная грузит историю один раз. Лучшая партия — наибольший ΔR в общей цепочке, лучшая/худшая фракция — средний S − E. League Points из scoring.py удалён. Тесты: дуэль новичков ±32, ранжирование после 10 партий, пересчёт после правки прошлой партии, отдельная групповая цепочка, лучшая партия по ΔR, одна загрузка истории на главной. #23 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd --- backend/app/schemas/api.py | 5 +- backend/app/services/scoring.py | 30 +- backend/app/services/stats_service.py | 411 ++++++++++++------------ backend/tests/test_profile.py | 5 +- backend/tests/test_rating_stats.py | 140 ++++++++ backend/tests/test_scoring_smoothing.py | 39 --- backend/tests/test_stats_passes.py | 41 +-- 7 files changed, 380 insertions(+), 291 deletions(-) create mode 100644 backend/tests/test_rating_stats.py delete mode 100644 backend/tests/test_scoring_smoothing.py diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 0910e3c..4e393e1 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -375,7 +375,8 @@ class OverallStats(BaseModel): wins: int win_rate: float avg_place: float | None = None - score: float | None = None + # Рейтинг (Elo, старт 1500) целым числом; None — игрок ещё не сыграл ни одной партии. + score: int | None = None class LeaderboardEntry(OverallStats): @@ -416,6 +417,8 @@ class FactionStat(BaseModel): wins: int win_rate: float avg_place: float | None = None + # Средний результат относительно ожидания (S − E) × 100 — метрика лучшей/худшей + # фракции: выше нуля — игрок на ней выступает лучше своих рейтинговых шансов. score: float | None = None diff --git a/backend/app/services/scoring.py b/backend/app/services/scoring.py index e09c670..f6c23f5 100644 --- a/backend/app/services/scoring.py +++ b/backend/app/services/scoring.py @@ -203,33 +203,13 @@ def replay(matches: Iterable[RatedMatch]) -> Replay: 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 ↓, игры ↓, среднее место ↑, ник ↑.""" + """Ключ сортировки топа: рейтинг ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑. + + row["rating"] — рейтинг без округления: два игрока с одинаковым целым в топе + всё равно упорядочены по настоящему значению.""" return ( - -(row["score"] or 0.0), + -(row["rating"] or 0.0), -(row["win_rate"] or 0.0), -(row["games"] or 0), (row["avg_place"] or 0.0), diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index 5f57eb1..605e190 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -1,105 +1,151 @@ -"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен).""" +"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен). + +Рейтинг — функция упорядоченной истории (scoring.replay), поэтому витрины не агрегируют +SQL, а проигрывают завершённые партии: одна загрузка истории на запрос, из неё же +считаются игры, победы, среднее место и разбивки. Цепочек две: общая — по всем партиям +приложения, групповая — только по партиям группы (это разные числа).""" from __future__ import annotations -from typing import Any +from collections import defaultdict -from sqlalchemy import func, text +from sqlalchemy import func from sqlmodel import Session, select from app.core.timeutil import iso_utc -from app.models import Faction, Group, GroupMember, Match, MatchParticipant, User +from app.models import Expansion, Faction, Group, GroupMember, Match, MatchParticipant, User from app.services import faction_service, group_service, membership_service, user_service from app.services.scoring import ( FACTION_MIN_GAMES, - MATCH_POINTS_SQL, MIN_GAMES, - SMOOTHED_SCORE_SQL, + RatedMatch, + RatedSeat, + Replay, leaderboard_sort_key, - smoothed_score, + replay, ) -# Базовый блок: одна строка на участие с tie-aware очками. -# Учитываются только ЗАВЕРШЁННЫЕ партии (in_progress без мест в статистику не входят). -SCORED_CTE = f""" -WITH tie AS ( - SELECT mp.match_id AS match_id, mp.place AS place, COUNT(*) AS tie_size - FROM match_participants mp - JOIN matches m ON m.id = mp.match_id - WHERE m.status = 'finished' AND mp.place IS NOT NULL - GROUP BY mp.match_id, mp.place -), -scored AS ( - SELECT mp.user_id AS user_id, - mp.faction_id AS faction_id, - m.id AS match_id, - m.group_id AS group_id, - m.played_at AS played_at, - mp.place AS place, - mp.was_random AS was_random, - m.player_count AS player_count, - ({MATCH_POINTS_SQL}) AS points, - CASE WHEN mp.place = 1 THEN 1 ELSE 0 END AS is_win - FROM match_participants mp - JOIN matches m ON m.id = mp.match_id - JOIN tie t ON t.match_id = mp.match_id AND t.place = mp.place - WHERE m.status = 'finished' -) -""" + +def load_history(session: Session, group_id: int | None = None) -> list[RatedMatch]: + """Завершённые партии в порядке проигрывания: дата игры, момент завершения, id. + + Один запрос на партии с участниками. In_progress в рейтинг не входят: мест у них нет.""" + stmt = ( + select(Match, MatchParticipant) + .join(MatchParticipant, MatchParticipant.match_id == Match.id) + .where(Match.status == "finished") + ) + if group_id is not None: + stmt = stmt.where(Match.group_id == group_id) + stmt = stmt.order_by(Match.played_at, Match.finished_at, Match.id, MatchParticipant.id) + + history: list[RatedMatch] = [] + current: Match | None = None + seats: list[RatedSeat] = [] + + def flush() -> None: + if current is not None and seats: + history.append( + RatedMatch( + seats=tuple(seats), + win_reason=current.win_reason, + end_round=current.end_round, + nine_rounds_rule=current.nine_rounds_rule, + id=current.id, # type: ignore[arg-type] + group_id=current.group_id, + played_at=current.played_at, + finished_at=current.finished_at, + ) + ) + + for m, p in session.exec(stmt).all(): + if current is None or m.id != current.id: + flush() + current, seats = m, [] + if p.place is None: # у завершённой партии мест без значения не бывает + continue + seats.append( + RatedSeat( + user_id=p.user_id, + place=p.place, + faction_id=p.faction_id, + eliminated=p.eliminated, + objectives=p.objectives, + worlds=p.worlds, + ) + ) + flush() + return history -def _round(value: Any, ndigits: int) -> float | None: - return None if value is None else round(float(value), ndigits) +def _for_group(history: list[RatedMatch], group_id: int) -> list[RatedMatch]: + return [m for m in history if m.group_id == group_id] -def _normalize(row: dict) -> dict: - # avatar_url мирроринг user_service.avatar_url_for: версия = epoch(updated_at) из SQL. - avatar_url = None - if row.get("avatar_path"): - avatar_url = f"/api/users/{row['user_id']}/avatar?v={int(row.get('avatar_version') or 0)}" +def _user_seats(history: list[RatedMatch], user_id: int) -> list[tuple[RatedMatch, RatedSeat]]: + return [(m, s) for m in history for s in m.seats if s.user_id == user_id] + + +def _summary(seats: list[tuple[RatedMatch, RatedSeat]], rating: float | None) -> dict: + """Итог игрока: игры, победы, винрейт, среднее место и рейтинг целым числом. + + В расчёте рейтинг без округления (иначе ошибка копилась бы по цепочке партий), + показывается — целым.""" + games = len(seats) + if games == 0: + return {"games": 0, "wins": 0, "win_rate": 0.0, "avg_place": None, "score": None} + wins = sum(1 for _m, s in seats if s.place == 1) return { - "user_id": row["user_id"], - "nickname": row["nickname"], - "games": int(row["games"] or 0), - "wins": int(row["wins"] or 0), - "win_rate": _round(row["win_rate"] or 0.0, 4), - "avg_place": _round(row["avg_place"], 2), - "score": _round(row["score"], 1), - "avatar_url": avatar_url, + "games": games, + "wins": wins, + "win_rate": round(wins / games, 4), + "avg_place": round(sum(s.place for _m, s in seats) / games, 2), + "score": None if rating is None else round(rating), } -def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]: - where = "WHERE s.group_id = :gid" if group_id is not None else "" - sql = f""" - {SCORED_CTE} - SELECT u.id AS user_id, u.nickname AS nickname, - u.avatar_path AS avatar_path, - CAST(strftime('%s', u.updated_at) AS INTEGER) AS avatar_version, - COUNT(*) AS games, - SUM(s.is_win) AS wins, - AVG(CAST(s.is_win AS FLOAT)) AS win_rate, - AVG(s.place) AS avg_place, - {SMOOTHED_SCORE_SQL} AS score - FROM scored s - JOIN users u ON u.id = s.user_id - {where} - GROUP BY u.id, u.nickname, u.avatar_path, u.updated_at - """ - params = {"gid": group_id} if group_id is not None else {} - result = session.execute(text(sql), params).mappings().all() - return [_normalize(dict(r)) for r in result] +def leaderboard( + session: Session, + group_id: int | None = None, + *, + history: list[RatedMatch] | None = None, + rep: Replay | None = None, +) -> dict: + """Топ: общий или группы. history/rep — уже посчитанные (home их переиспользует).""" + if history is None: + history = load_history(session, group_id) + if rep is None: + rep = replay(history) + by_user: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list) + for m in history: + for s in m.seats: + by_user[s.user_id].append((m, s)) + users = ( + {u.id: u for u in session.exec(select(User).where(User.id.in_(list(by_user)))).all()} + if by_user + else {} + ) -def leaderboard(session: Session, group_id: int | None = None) -> dict: - rows = _leaderboard_rows(session, group_id) - qualified = [r for r in rows if r["games"] >= MIN_GAMES] - provisional = [r for r in rows if r["games"] < MIN_GAMES] - qualified.sort(key=leaderboard_sort_key) - provisional.sort(key=leaderboard_sort_key) + rows = [] + for uid, seats in by_user.items(): + u = users[uid] + rows.append( + { + "user_id": uid, + "nickname": u.nickname, + **_summary(seats, rep.ratings[uid]), + "rating": rep.ratings[uid], # только для сортировки + "avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type] + } + ) + qualified = sorted((r for r in rows if r["games"] >= MIN_GAMES), key=leaderboard_sort_key) + provisional = sorted((r for r in rows if r["games"] < MIN_GAMES), key=leaderboard_sort_key) for i, r in enumerate(qualified, start=1): r["rank"] = i for r in provisional: r["rank"] = None + for r in rows: + del r["rating"] return { "entries": qualified, "provisional": provisional, @@ -107,72 +153,38 @@ def leaderboard(session: Session, group_id: int | None = None) -> dict: } -def _user_scored_rows(session: Session, user_id: int) -> list[dict]: - """Строки участия игрока со всеми полями, нужными витринам профиля. - - Один проход по SCORED_CTE вместо трёх: общий итог, разбивка по фракциям и форма - последних партий считаются из одного и того же набора строк. CTE джойнит участия - со всеми партиями приложения, поэтому каждый лишний проход дорожает вместе с - общим числом партий, а не с числом партий игрока.""" - sql = f""" - {SCORED_CTE} - SELECT s.group_id AS group_id, s.match_id AS match_id, s.played_at AS played_at, - s.place AS place, s.player_count AS player_count, - s.points AS points, s.is_win AS is_win, - f.id AS faction_id, f.code AS code, f.name_ru AS name_ru, - e.code AS expansion_code - FROM scored s - JOIN factions f ON f.id = s.faction_id - JOIN expansions e ON e.id = f.expansion_id - WHERE s.user_id = :uid - """ - rows = session.execute(text(sql), {"uid": user_id}).mappings().all() - return [dict(r) for r in rows] - - -def _for_group(rows: list[dict], group_id: int | None) -> list[dict]: - return rows if group_id is None else [r for r in rows if r["group_id"] == group_id] - - -def _overall_from_rows(rows: list[dict]) -> dict: - """Тот же итог, что раньше считал SQL: COUNT/SUM/AVG плюс сглаженный рейтинг.""" - games = len(rows) - if games == 0: - return {"games": 0, "wins": 0, "win_rate": 0.0, "avg_place": None, "score": None} - wins = sum(int(r["is_win"]) for r in rows) - return { - "games": games, - "wins": wins, - "win_rate": _round(wins / games, 4), - "avg_place": _round(sum(r["place"] for r in rows) / games, 2), - "score": _round(smoothed_score(sum(float(r["points"]) for r in rows), games), 1), +def _faction_breakdown( + session: Session, seats: list[tuple[RatedMatch, RatedSeat]], rep: Replay, user_id: int +) -> list[dict]: + by_faction: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list) + for m, s in seats: + by_faction[s.faction_id].append((m, s)) + if not by_faction: + return [] + meta = { + f.id: (f, code) + for f, code in session.exec( + select(Faction, Expansion.code) + .join(Expansion, Expansion.id == Faction.expansion_id) + .where(Faction.id.in_(list(by_faction))) + ).all() } - - -def _faction_breakdown_from_rows(rows: list[dict]) -> list[dict]: - by_faction: dict[int, list[dict]] = {} - for r in rows: - by_faction.setdefault(r["faction_id"], []).append(r) out = [] for fid, group in by_faction.items(): - games = len(group) - wins = sum(int(r["is_win"]) for r in group) - meta = group[0] + faction, expansion_code = meta[fid] + perf = sum(rep.perf[(m.id, user_id)] for m, _s in group) / len(group) out.append( { "faction_id": fid, - "code": meta["code"], - "name_ru": meta["name_ru"], - "expansion_code": meta["expansion_code"], - "games": games, - "wins": wins, - "win_rate": _round(wins / games, 4), - "avg_place": _round(sum(r["place"] for r in group) / games, 2), - # Фракции: чистое среднее (служебная метрика «лучшая/худшая», - # сглаживание задавило бы её к 50). - "score": _round(sum(float(r["points"]) for r in group) / games * 100, 1), + "code": faction.code, + "name_ru": faction.name_ru, + "expansion_code": expansion_code, + **{k: v for k, v in _summary(group, None).items() if k != "score"}, + # Средний результат относительно ожидания (S − E) × 100: насколько игрок + # на фракции выступает выше рейтинговых шансов — без привязки к рейтингу. + "score": round(perf * 100, 1), "name_ru_prepositional": faction_service.prepositional( - meta["code"], meta["name_ru"] + faction.code, faction.name_ru ), } ) @@ -180,15 +192,15 @@ def _faction_breakdown_from_rows(rows: list[dict]) -> list[dict]: return out -def _recent_form_from_rows(rows: list[dict], limit: int = 5) -> list[dict]: - recent = sorted(rows, key=lambda r: (str(r["played_at"]), r["match_id"]), reverse=True) +def _recent_form(seats: list[tuple[RatedMatch, RatedSeat]], limit: int = 5) -> list[dict]: + recent = sorted(seats, key=lambda ms: (str(ms[0].played_at), ms[0].id), reverse=True) return [ { - "place": r["place"], - "player_count": r["player_count"], - "played_at": str(r["played_at"]), + "place": s.place, + "player_count": len(m.seats), + "played_at": str(m.played_at), } - for r in recent[:limit] + for m, s in recent[:limit] ] @@ -208,69 +220,73 @@ def _favorite_faction(session: Session, user_id: int) -> dict | None: } +def _overall(history: list[RatedMatch], rep: Replay, user_id: int) -> dict: + return _summary(_user_seats(history, user_id), rep.ratings.get(user_id)) + + def profile_stats( session: Session, user_id: int, group_id: int | None = None, *, - rows: list[dict] | None = None, + history: list[RatedMatch] | None = None, + rep: Replay | None = None, ) -> dict: - """Витрина профиля. rows — уже вытащенные строки игрока (home() их переиспользует).""" - scoped = _for_group(rows if rows is not None else _user_scored_rows(session, user_id), group_id) - overall = _overall_from_rows(scoped) - factions = _faction_breakdown_from_rows(scoped) + """Витрина профиля. history/rep — общая цепочка, уже посчитанная (home их переиспользует); + с group_id считается групповая цепочка.""" + if history is None: + history = load_history(session) + if group_id is not None: + history, rep = _for_group(history, group_id), None + if rep is None: + rep = replay(history) + seats = _user_seats(history, user_id) + factions = _faction_breakdown(session, seats, rep, user_id) qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES] - best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None - worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None + best = max(qualified, key=lambda f: f["score"]) if qualified else None + worst = min(qualified, key=lambda f: f["score"]) if qualified else None # «Чаще всего играет на» — самая игранная фракция по всей истории, включая # рандомные раздачи. main = max(factions, key=lambda f: f["games"]) if factions else None return { "user_id": user_id, - "overall": overall, + "overall": _summary(seats, rep.ratings.get(user_id)), "factions": factions, "best_faction": best, "worst_faction": worst, "favorite_faction": _favorite_faction(session, user_id), "main_faction": main, - "recent_form": _recent_form_from_rows(scoped), + "recent_form": _recent_form(seats), "min_games": MIN_GAMES, } def group_stats(session: Session, group_id: int) -> dict: - board = leaderboard(session, group_id=group_id) - # Нужны только счётчик и дата последней партии — тянуть строки целиком незачем. - games_count, last_played = session.exec( - select(func.count(), func.max(Match.played_at)).where( - Match.group_id == group_id, Match.status == "finished" - ) - ).one() - last_at = str(last_played) if last_played else None + history = load_history(session, group_id) + board = leaderboard(session, group_id, history=history, rep=replay(history)) + last_played = max((m.played_at for m in history), default=None) + games: dict[int, int] = defaultdict(int) + wins: dict[int, int] = defaultdict(int) + for m in history: + for s in m.seats: + games[s.faction_id] += 1 + wins[s.faction_id] += s.place == 1 available_ids = group_service.available_faction_ids(session, group_id) - faction_meta = [] - sql = f""" - {SCORED_CTE} - SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru, - COUNT(s.user_id) AS games, SUM(s.is_win) AS wins - FROM factions f - LEFT JOIN scored s ON s.faction_id = f.id AND s.group_id = :gid - GROUP BY f.id, f.code, f.name_ru - ORDER BY games DESC, f.sort_order - """ - rows = session.execute(text(sql), {"gid": group_id}).mappings().all() - for r in rows: - faction_meta.append( - { - "faction_id": r["faction_id"], - "code": r["code"], - "name_ru": r["name_ru"], - "games": int(r["games"] or 0), - "wins": int(r["wins"] or 0), - "available": r["faction_id"] in available_ids, - } - ) + factions = sorted( + session.exec(select(Faction)).all(), key=lambda f: (-games[f.id], f.sort_order) # type: ignore[index] + ) + faction_meta = [ + { + "faction_id": f.id, + "code": f.code, + "name_ru": f.name_ru, + "games": games[f.id], # type: ignore[index] + "wins": wins[f.id], # type: ignore[index] + "available": f.id in available_ids, + } + for f in factions + ] # Участники без завершённых партий — отдельным блоком (нули, rank=null). played_ids = {e["user_id"] for e in board["entries"]} | { @@ -296,8 +312,8 @@ def group_stats(session: Session, group_id: int) -> dict: return { "group_id": group_id, - "total_matches": games_count, - "last_match_at": last_at, + "total_matches": len(history), + "last_match_at": str(last_played) if last_played else None, "leaderboard": board["entries"], "provisional": board["provisional"], "inactive": inactive, @@ -392,19 +408,15 @@ def user_match_list( ) -> dict: """История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху. - best_only — одна лучшая партия по League Points (s.points из SCORED_CTE учитывает - место и размер стола); при равных очках берём более свежую.""" + best_only — одна лучшая партия: наибольший прирост рейтинга в общей цепочке + (учитывает и соперников, и ход партии); при равенстве берём более свежую.""" if best_only: - sql = f""" - {SCORED_CTE} - SELECT s.match_id AS match_id - FROM scored s - WHERE s.user_id = :uid - ORDER BY s.points DESC, s.played_at DESC, s.match_id DESC - LIMIT 1 - """ - row = session.execute(text(sql), {"uid": user_id}).mappings().first() - matches = [session.get(Match, row["match_id"])] if row else [] + history = load_history(session) + rep = replay(history) + candidates = [ + (rep.delta[(m.id, user_id)], m.played_at, m.id) for m, _s in _user_seats(history, user_id) + ] + matches = [session.get(Match, max(candidates)[2])] if candidates else [] return { "items": _match_items(session, matches), "total": len(matches), @@ -466,18 +478,21 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]: def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict: - board = leaderboard(session, group_id=None) - # Строки игрока тянем один раз: из них считается и профиль, и итог по активной группе. - rows = _user_scored_rows(session, user_id) - profile = profile_stats(session, user_id, group_id=None, rows=rows) + # История грузится и общая цепочка проигрывается один раз: из них и топ, и профиль; + # итог по активной группе — отдельная (групповая) цепочка по срезу той же истории. + history = load_history(session) + rep = replay(history) + board = leaderboard(session, history=history, rep=rep) + profile = profile_stats(session, user_id, history=history, rep=rep) active_group_brief = None if active_group_id is not None: group = session.get(Group, active_group_id) if group is not None: + group_history = _for_group(history, active_group_id) active_group_brief = { "id": group.id, "name": group.name, - **_overall_from_rows(_for_group(rows, active_group_id)), + **_overall(group_history, replay(group_history), user_id), } return { "leaderboard": board["entries"][:leaderboard_limit], diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py index 03b02fb..69015fd 100644 --- a/backend/tests/test_profile.py +++ b/backend/tests/test_profile.py @@ -322,9 +322,10 @@ def test_history_excludes_matches_without_the_player(client: TestClient, engine) def test_history_best_mode_picks_highest_points(client: TestClient, engine): - """Режим best берёт партию с максимальными League Points, а не самую свежую. + """Режим best берёт партию с наибольшим приростом рейтинга, а не самую свежую. - Второе место из четырёх даёт (4-2)/3 ≈ 0.67, второе из двух — (2-2)/1 = 0.""" + Второе место из четырёх равных приносит рейтинг (обыграны двое), второе место + в дуэли — отнимает.""" me = login(client, "Лучший") gid, (a, b, c), fids = _group_with(client, engine, "А", "Б", "В") diff --git a/backend/tests/test_rating_stats.py b/backend/tests/test_rating_stats.py new file mode 100644 index 0000000..050f2a9 --- /dev/null +++ b/backend/tests/test_rating_stats.py @@ -0,0 +1,140 @@ +"""Рейтинг в витринах (#23): Elo по упорядоченной истории, общая и групповая цепочки.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app.services.scoring import RatedMatch, RatedSeat, replay +from tests.conftest import add_group_member, create_finished_match, csrf_headers, login + + +def _group(client: TestClient, name: str = "Группа") -> tuple[int, list[int]]: + gid = client.post( + "/api/groups", json={"name": name, "expansion_ids": []}, headers=csrf_headers(client) + ).json()["id"] + fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] + return gid, fids + + +def _duel(client: TestClient, gid: int, fids: list[int], winner: int, loser: int) -> dict: + return create_finished_match( + client, gid, + [ + {"user_id": winner, "faction_id": fids[0], "place": 1}, + {"user_id": loser, "faction_id": fids[1], "place": 2}, + ], + ) + + +def _board(client: TestClient, path: str = "/api/stats/leaderboard") -> dict[int, dict]: + data = client.get(path).json() + rows = data["entries"] + data["provisional"] if "entries" in data else ( + data["leaderboard"] + data["provisional"] + ) + return {e["user_id"]: e for e in rows} + + +def test_newcomer_duel_moves_rating_by_32(client: TestClient, engine): + """Первая дуэль новичков на 1500: K = 64, ожидание 0.5, деталей нет → ±32.""" + me = login(client, "Хозяин") + gid, fids = _group(client) + b = add_group_member(engine, gid, "Гость") + _duel(client, gid, fids, me["id"], b) + + board = client.get("/api/stats/leaderboard").json() + by_id = {e["user_id"]: e for e in board["entries"] + board["provisional"]} + assert by_id[me["id"]]["score"] == 1532 + assert by_id[b]["score"] == 1468 + # 1 игра < MIN_GAMES=10 → оба пока «Новички», ранжированный топ пуст. + assert board["entries"] == [] + assert board["min_games"] == 10 + + prof = client.get("/api/users/me/stats").json() + assert prof["overall"]["score"] == 1532 + # Фракционная метрика — S − E: победа при шансах 0.5 даёт +0.5 → 50.0. + assert prof["factions"][0]["score"] == 50.0 + + +def test_ranked_after_min_games(client: TestClient, engine): + me = login(client, "Чемпион") + gid, fids = _group(client) + b = add_group_member(engine, gid, "Спарринг") + for _ in range(10): + _duel(client, gid, fids, me["id"], b) + board = client.get("/api/stats/leaderboard").json() + ranks = {e["user_id"]: (e["rank"], e["score"]) for e in board["entries"]} + assert ranks[me["id"]][0] == 1 and ranks[b][0] == 2 + assert ranks[me["id"]][1] > 1500 > ranks[b][1] + assert isinstance(ranks[me["id"]][1], int) + + +def test_editing_past_match_recalculates_later_ones(client: TestClient, engine): + """Рейтинг — функция истории: правка первой партии меняет итог после второй.""" + me = login(client, "А") + gid, fids = _group(client) + b = add_group_member(engine, gid, "Б") + first = _duel(client, gid, fids, me["id"], b) + _duel(client, gid, fids, me["id"], b) + + def expected(first_winner: int, first_loser: int) -> dict[int, int]: + rep = replay([ + RatedMatch((RatedSeat(first_winner, 1), RatedSeat(first_loser, 2)), "objectives", id=1), + RatedMatch((RatedSeat(me["id"], 1), RatedSeat(b, 2)), "objectives", id=2), + ]) + return {uid: round(r) for uid, r in rep.ratings.items()} + + before = expected(me["id"], b) + assert {uid: e["score"] for uid, e in _board(client).items()} == before + + rows = [ + {"user_id": b, "faction_id": fids[1], "place": 1}, + {"user_id": me["id"], "faction_id": fids[0], "place": 2}, + ] + r = client.patch( + f"/api/matches/{first['id']}", + json={"participants": rows, "win_reason": "objectives"}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + after = expected(b, me["id"]) + assert after != before + assert {uid: e["score"] for uid, e in _board(client).items()} == after + + +def test_group_rating_is_its_own_chain(client: TestClient, engine): + """Групповой рейтинг проигрывает только партии группы, общий — все партии.""" + me = login(client, "Путешественник") + g1, f1 = _group(client, "Первая") + g2, f2 = _group(client, "Вторая") + b = add_group_member(engine, g1, "Сосед") + c = add_group_member(engine, g2, "Соседка") + _duel(client, g1, f1, me["id"], b) # в первой группе — победа + _duel(client, g2, f2, c, me["id"]) # во второй — поражение + + assert _board(client, f"/api/groups/{g1}/stats")[me["id"]]["score"] == 1532 + assert _board(client, f"/api/groups/{g2}/stats")[me["id"]]["score"] == 1468 + overall = _board(client)[me["id"]]["score"] + assert overall not in (1532, 1468) + assert overall == round( + replay([ + RatedMatch((RatedSeat(me["id"], 1), RatedSeat(b, 2)), "objectives", id=1), + RatedMatch((RatedSeat(c, 1), RatedSeat(me["id"], 2)), "objectives", id=2), + ]).ratings[me["id"]] + ) + + +def test_best_match_is_biggest_rating_gain(client: TestClient, engine): + """Лучшая партия — наибольший прирост рейтинга, а не свежая из равных побед. + + Обе партии — победы в дуэли. Первая — новичком над равным (+32), вторая — уже + с рейтингом 1532 и меньшим K над новичком (≈ +28): лучше первая.""" + me = login(client, "Лучший") + gid, fids = _group(client) + x = add_group_member(engine, gid, "Икс") + y = add_group_member(engine, gid, "Игрек") + first = _duel(client, gid, fids, me["id"], x) + _duel(client, gid, fids, me["id"], y) + + client.patch("/api/users/me/profile", json={"history_mode": "best"}, headers=csrf_headers(client)) + data = client.get(f"/api/users/{me['id']}/matches").json() + assert data["total"] == 1 + assert data["items"][0]["id"] == first["id"] diff --git a/backend/tests/test_scoring_smoothing.py b/backend/tests/test_scoring_smoothing.py deleted file mode 100644 index af06321..0000000 --- a/backend/tests/test_scoring_smoothing.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Сглаживание рейтинга: score = (C·m + сумма очков) / (C + игр) × 100, C=10, m=0.5.""" -from __future__ import annotations - -from fastapi.testclient import TestClient - -from tests.conftest import add_group_member, create_finished_match, csrf_headers, login - - -def test_leaderboard_and_profile_score_are_smoothed(client: TestClient, engine): - me = login(client, "Хозяин") - gid = client.post( - "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) - ).json()["id"] - b = add_group_member(engine, gid, "Гость") - factions = client.get(f"/api/groups/{gid}/factions").json() - f1, f2 = factions[0]["id"], factions[1]["id"] - - create_finished_match( - client, - gid, - [ - {"user_id": me["id"], "faction_id": f1, "place": 1}, - {"user_id": b, "faction_id": f2, "place": 2}, - ], - ) - - board = client.get("/api/stats/leaderboard").json() - by_id = {e["user_id"]: e for e in board["entries"] + board["provisional"]} - # Победитель: 1 очко за партию → (10·0.5 + 1) / (10 + 1) × 100 = 54.5. - # Проигравший: 0 очков → (10·0.5 + 0) / 11 × 100 = 45.5. - assert by_id[me["id"]]["score"] == 54.5 - assert by_id[b]["score"] == 45.5 - # 1 игра < MIN_GAMES=10 → оба пока «Новички», ранжированный топ пуст. - assert board["entries"] == [] - assert board["min_games"] == 10 - - # Профиль показывает тот же сглаженный рейтинг, что и топ. - prof = client.get("/api/users/me/stats").json() - assert prof["overall"]["score"] == 54.5 diff --git a/backend/tests/test_stats_passes.py b/backend/tests/test_stats_passes.py index 6839358..cd1e822 100644 --- a/backend/tests/test_stats_passes.py +++ b/backend/tests/test_stats_passes.py @@ -1,9 +1,9 @@ -"""Статистика профиля: цифры сходятся с лидербордом, а главная не гоняет CTE лишний раз.""" +"""Статистика профиля: цифры сходятся с лидербордом, а главная не грузит историю лишний раз.""" from __future__ import annotations from fastapi.testclient import TestClient -from sqlalchemy import event +from app.services import stats_service from tests.conftest import add_group_member, create_finished_match, csrf_headers, login @@ -31,10 +31,7 @@ def _group_with_matches(client: TestClient, engine, games: int = 3) -> tuple[dic def test_profile_numbers_match_leaderboard(client: TestClient, engine): - """Профиль считает в Python, лидерборд — в SQL: цифры обязаны совпадать. - - Формула сглаженного рейтинга живёт в двух видах (SMOOTHED_SCORE_SQL и - scoring.smoothed_score); этот тест ловит их расхождение.""" + """Профиль и лидерборд собирают итог разными путями: цифры обязаны совпадать.""" me, gid, p2 = _group_with_matches(client, engine, games=4) profile = client.get("/api/users/me/stats").json()["overall"] @@ -47,33 +44,25 @@ def test_profile_numbers_match_leaderboard(client: TestClient, engine): assert profile[field] == entry[field], field -def test_home_does_not_repeat_scored_cte(client: TestClient, engine): - """Главная делает не больше двух проходов по SCORED_CTE. - - Было пять: лидерборд, три запроса профиля и итог по активной группе. Без этой - проверки оптимизация тихо отъедет назад при следующей правке витрин.""" +def test_home_loads_history_once(client: TestClient, engine, monkeypatch): + """Главная грузит историю партий один раз: топ, профиль и итог активной группы + считаются из неё. Без этой проверки лишняя загрузка тихо вернётся при правке витрин.""" me, gid, p2 = _group_with_matches(client, engine, games=2) client.put( "/api/users/me/active-group", json={"group_id": gid}, headers=csrf_headers(client) ) - seen: list[str] = [] + calls: list[int | None] = [] + original = stats_service.load_history - def before_execute(conn, cursor, statement, params, context, executemany): - if "WITH tie AS" in statement: - seen.append(statement) + def spy(session, group_id=None): + calls.append(group_id) + return original(session, group_id) - event.listen(engine, "before_cursor_execute", before_execute) - try: - r = client.get("/api/home") - assert r.status_code == 200, r.text - finally: - event.remove(engine, "before_cursor_execute", before_execute) - - # Сейчас ровно два: лидерборд и один проход по строкам игрока. Нижняя граница не - # для красоты — без неё тест пройдёт и когда счётчик молча перестанет что-либо - # ловить (сменился путь, переименован CTE). - assert 1 <= len(seen) <= 2, f"ожидали 1–2 прохода, получили {len(seen)}" + monkeypatch.setattr(stats_service, "load_history", spy) + r = client.get("/api/home") + assert r.status_code == 200, r.text + assert calls == [None] # Главная всё ещё показывает и профиль, и блок активной группы. body = r.json() assert body["profile"]["overall"]["games"] == 2 From cc64bb46444dfb653d0b58a2d8af34bc7feabcad Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 14 Sep 2026 22:45:25 +0300 Subject: [PATCH 4/5] =?UTF-8?q?=D0=A0=D0=B5=D0=B9=D1=82=D0=B8=D0=BD=D0=B3:?= =?UTF-8?q?=20=D0=B2=D0=B2=D0=BE=D0=B4=20=D0=B4=D0=B5=D1=82=D0=B0=D0=BB?= =?UTF-8?q?=D0=B5=D0=B9=20=D0=BF=D0=B0=D1=80=D1=82=D0=B8=D0=B8=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80=D0=B5=D0=B6=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Форма завершения и правка результатов (MatchDetailPage): у каждого игрока в PlaceEditor поля «цели» и «миры» на конец партии (у выбывшего миры заблокированы нулём), общий блок «Итог партии» — причина победы и раунд окончания из max_rounds партии. Всё новое уходит в общий черновик. Причина «последний выживший» ставится сама при одном невыбывшем, выбор заблокирован; вернули второго — причина сбрасывается и её нужно выбрать. То же в админской правке партии. Предупреждения (domain/finishWarnings.ts, отправку не блокируют): тип победы и цели/миры лидеров не согласуются, досрочный конец без N целей у победителя, у соперника целей больше, чем у победителя. Настройки группы: переключатель «9 раундов при 5–6 игроках» через PATCH /groups/{id}. schema.d.ts перегенерирован. #23 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd --- frontend/src/api/schema.d.ts | 85 +++++- .../src/components/MatchOutcomeFields.tsx | 76 +++++ frontend/src/components/PlaceEditor.tsx | 59 +++- frontend/src/domain/finishWarnings.ts | 101 +++++++ frontend/src/domain/winReasons.ts | 22 +- frontend/src/hooks/groups.ts | 7 +- frontend/src/pages/GroupSettingsPage.tsx | 33 ++- frontend/src/pages/MatchDetailPage.tsx | 272 ++++++++++++------ frontend/src/pages/admin/AdminMatchEdit.tsx | 134 ++++++++- frontend/src/styles/global.css | 24 ++ 10 files changed, 683 insertions(+), 130 deletions(-) create mode 100644 frontend/src/components/MatchOutcomeFields.tsx create mode 100644 frontend/src/domain/finishWarnings.ts diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index d245781..427a5eb 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -377,8 +377,11 @@ export interface paths { delete?: never; options?: never; head?: never; - /** Rename Group */ - patch: operations["rename_group_api_groups__group_id__patch"]; + /** + * Update Group + * @description Название и домашние правила группы; меняются только переданные поля. + */ + patch: operations["update_group_api_groups__group_id__patch"]; trace?: never; }; "/api/groups/{group_id}/expansions": { @@ -1307,7 +1310,7 @@ export interface components { /** Duration Minutes */ duration_minutes?: number | null; /** Win Reason */ - win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; + win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null; /** Player Count */ player_count: number; /** Created By */ @@ -1576,6 +1579,11 @@ export interface components { * @default [] */ expansion_ids: number[]; + /** + * Nine Rounds Rule + * @default false + */ + nine_rounds_rule: boolean; }; /** GroupExpansionsUpdate */ GroupExpansionsUpdate: { @@ -1585,11 +1593,6 @@ export interface components { */ expansion_ids: number[]; }; - /** GroupRename */ - GroupRename: { - /** Name */ - name: string; - }; /** GroupStats */ GroupStats: { /** Group Id */ @@ -1621,6 +1624,13 @@ export interface components { /** Min Games */ min_games: number; }; + /** GroupUpdate */ + GroupUpdate: { + /** Name */ + name?: string | null; + /** Nine Rounds Rule */ + nine_rounds_rule?: boolean | null; + }; /** HTTPValidationError */ HTTPValidationError: { /** Detail */ @@ -1730,7 +1740,9 @@ export interface components { * Win Reason * @enum {string} */ - win_reason: "objectives" | "worlds" | "plastic" | "resources"; + win_reason: "objectives" | "worlds" | "plastic" | "resources" | "last_standing"; + /** End Round */ + end_round?: number | null; /** Overall Comment */ overall_comment?: string | null; /** Expected Version */ @@ -1761,9 +1773,25 @@ export interface components { [key: string]: string; }; /** Win Reason */ - win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; + win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null; /** Overall Comment */ overall_comment?: string | null; + /** End Round */ + end_round?: number | null; + /** + * Objectives + * @default {} + */ + objectives: { + [key: string]: number; + }; + /** + * Worlds + * @default {} + */ + worlds: { + [key: string]: number; + }; }; /** MatchFinishDraftRead */ MatchFinishDraftRead: { @@ -1790,6 +1818,10 @@ export interface components { comment?: string | null; /** Faction Id */ faction_id?: number | null; + /** Objectives */ + objectives?: number | null; + /** Worlds */ + worlds?: number | null; }; /** * MatchHistory @@ -1844,7 +1876,7 @@ export interface components { /** Duration Minutes */ duration_minutes?: number | null; /** Win Reason */ - win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; + win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null; /** Player Count */ player_count: number; /** Overall Comment */ @@ -1878,6 +1910,10 @@ export interface components { was_random: boolean; /** Comment */ comment?: string | null; + /** Objectives */ + objectives?: number | null; + /** Worlds */ + worlds?: number | null; }; /** MatchParticipantRead */ MatchParticipantRead: { @@ -1900,6 +1936,10 @@ export interface components { was_random: boolean; /** Comment */ comment?: string | null; + /** Objectives */ + objectives?: number | null; + /** Worlds */ + worlds?: number | null; /** Avatar Url */ avatar_url?: string | null; }; @@ -1923,7 +1963,16 @@ export interface components { /** Duration Minutes */ duration_minutes?: number | null; /** Win Reason */ - win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; + win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null; + /** End Round */ + end_round?: number | null; + /** + * Nine Rounds Rule + * @default false + */ + nine_rounds_rule: boolean; + /** Max Rounds */ + max_rounds: number; /** Player Count */ player_count: number; /** Overall Comment */ @@ -1956,7 +2005,9 @@ export interface components { /** Overall Comment */ overall_comment?: string | null; /** Win Reason */ - win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; + win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null; + /** End Round */ + end_round?: number | null; /** Participants */ participants?: components["schemas"]["ParticipantInput"][] | null; /** Expected Version */ @@ -2104,6 +2155,10 @@ export interface components { was_random: boolean; /** Comment */ comment?: string | null; + /** Objectives */ + objectives?: number | null; + /** Worlds */ + worlds?: number | null; }; /** PasswordChange */ PasswordChange: { @@ -2987,7 +3042,7 @@ export interface operations { }; }; }; - rename_group_api_groups__group_id__patch: { + update_group_api_groups__group_id__patch: { parameters: { query?: never; header?: never; @@ -2998,7 +3053,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["GroupRename"]; + "application/json": components["schemas"]["GroupUpdate"]; }; }; responses: { diff --git a/frontend/src/components/MatchOutcomeFields.tsx b/frontend/src/components/MatchOutcomeFields.tsx new file mode 100644 index 0000000..347e31c --- /dev/null +++ b/frontend/src/components/MatchOutcomeFields.tsx @@ -0,0 +1,76 @@ +import { PickerSelect } from "./PickerSelect"; +import { LAST_STANDING, WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons"; + +const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label })); +const NO_ROUND = 0; + +/** + * Итог партии в форме завершения и правки результатов: причина победы, раунд окончания + * и предупреждения о несогласованном вводе. «Последний выживший» не выбирается — + * пока выживший один, причина зафиксирована (её ставит родитель, см. reasonForSurvivors). + */ +export function MatchOutcomeFields({ + winReason, + onReason, + endRound, + onEndRound, + maxRounds, + warnings, +}: { + winReason: WinReason | null; + onReason: (reason: WinReason) => void; + endRound: number | null; + onEndRound: (round: number | null) => void; + maxRounds: number; + warnings: string[]; +}) { + const roundOptions = [ + { id: NO_ROUND, label: "не указан" }, + ...Array.from({ length: maxRounds }, (_, i) => ({ id: i + 1, label: `${i + 1}-й` })), + ]; + return ( +
+

Итог партии

+
+ + {winReason === LAST_STANDING ? ( +
+ {winReasonLabel(LAST_STANDING)} + + все соперники выбыли + +
+ ) : ( + o.id === winReason) ?? null} + options={REASON_OPTIONS} + placeholder="— выберите причину —" + renderOption={(o) => o.label} + onPick={(o) => onReason(o.id)} + /> + )} +
+
+ + o.id === (endRound ?? NO_ROUND)) ?? null} + options={roundOptions} + placeholder="не указан" + renderOption={(o) => o.label} + onPick={(o) => onEndRound(o.id === NO_ROUND ? null : o.id)} + /> +
+

+ Раунд, цели и миры необязательны, но делают рейтинг точнее: быстрая и крупная победа + весит больше. +

+ {warnings.length > 0 && ( +
    + {warnings.map((w) => ( +
  • {w}
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/PlaceEditor.tsx b/frontend/src/components/PlaceEditor.tsx index ebee8ba..4fe74a3 100644 --- a/frontend/src/components/PlaceEditor.tsx +++ b/frontend/src/components/PlaceEditor.tsx @@ -10,6 +10,10 @@ export interface PlacePlayer { avatar_url?: string | null; } +export type CountField = "objectives" | "worlds"; +/** Цели и миры на конец партии по user_id; отсутствие ключа или null — не указано. */ +export type Counts = Record; + type Target = | { type: "merge"; idx: number } | { type: "insert"; idx: number } @@ -28,7 +32,8 @@ interface DragState { * Редактор мест перетаскиванием (макет A2): вертикальный список блоков игроков, * верхний — 1-е место. Бросок между блоками — порядок, на середину чужого блока — * слияние в ничью (общая рамка и место, ✂ выносит обратно), в пунктирную зону — - * выбывший. В каждом блоке — строка комментария об игроке. + * выбывший. В каждом блоке — строка комментария об игроке и (если переданы counts) + * цели и миры на конец партии; у выбывшего миров нет — поле заблокировано нулём. * * Контролируемый: blocks (упорядоченные группы user_id, длина >1 = ничья) и * eliminated живут у родителя. Во время drag DOM двигается напрямую (transform, @@ -41,6 +46,8 @@ export function PlaceEditor({ comments, onChange, onComment, + counts, + onCount, }: { players: PlacePlayer[]; blocks: number[][]; @@ -48,6 +55,8 @@ export function PlaceEditor({ comments: Record; onChange: (blocks: number[][], eliminated: number[]) => void; onComment: (userId: number, text: string) => void; + counts?: Counts; + onCount?: (userId: number, field: CountField, value: number | null) => void; }) { const listRef = useRef(null); const elimRef = useRef(null); @@ -225,15 +234,49 @@ export function PlaceEditor({ ); }; - const commentInput = (userId: number) => ( - onComment(userId, e.target.value)} - /> + const countInput = (userId: number, field: CountField, label: string, locked = false) => ( + ); + const commentInput = (userId: number) => { + const comment = ( + onComment(userId, e.target.value)} + /> + ); + if (!counts) return comment; + return ( +
+ {comment} + {countInput(userId, "objectives", "цели")} + {countInput(userId, "worlds", "миры", eliminated.includes(userId))} +
+ ); + }; + return (
diff --git a/frontend/src/domain/finishWarnings.ts b/frontend/src/domain/finishWarnings.ts new file mode 100644 index 0000000..042941f --- /dev/null +++ b/frontend/src/domain/finishWarnings.ts @@ -0,0 +1,101 @@ +import { LAST_STANDING, type WinReason, winReasonLabel } from "./winReasons"; + +export interface OutcomeSeat { + userId: number; + nickname: string; + /** Место невыбывшего; у выбывшего не учитывается. */ + place: number; + eliminated: boolean; + objectives: number | null; + worlds: number | null; +} + +/** + * Подсказки о несогласованных итогах партии. Это предупреждения, а не отказ: сервер + * проверяет только диапазоны и явные противоречия, а здесь — сочетания, которые по + * правилам почти наверняка опечатка. Проверка идёт только по заполненным полям. + */ +export function finishWarnings({ + seats, + winReason, + endRound, + maxRounds, +}: { + seats: OutcomeSeat[]; + winReason: WinReason | null; + endRound: number | null; + maxRounds: number; +}): string[] { + const out: string[] = []; + const n = seats.length; + const survivors = seats.filter((s) => !s.eliminated).sort((a, b) => a.place - b.place); + const winners = survivors.filter((s) => s.place === 1); + if (winners.length === 0 || winReason === LAST_STANDING) return out; + + // Досрочно партия заканчивается, только когда кто-то набрал столько целей, сколько + // игроков за столом. + const winnerObjectives = winners.map((w) => w.objectives).filter((v): v is number => v != null); + if ( + endRound != null && + endRound < maxRounds && + winnerObjectives.length > 0 && + Math.max(...winnerObjectives) < n + ) { + out.push( + `Партия закончилась в ${endRound}-м раунде из ${maxRounds}, но у победителя меньше ${n} целей — ` + + `досрочно побеждает тот, кто набрал ${n}.`, + ); + } + + // Остальное сравнивает победителя с соперниками — при ничьей за 1-е место не с кем. + if (winners.length > 1) return out; + const winner = winners[0]; + const secondPlace = survivors.find((s) => s.place > 1)?.place; + const runnersUp = survivors.filter((s) => s.place === secondPlace); + const label = winReasonLabel(winReason).toLowerCase(); + + for (const r of runnersUp) { + const objKnown = winner.objectives != null && r.objectives != null; + const worldsKnown = winner.worlds != null && r.worlds != null; + if (winReason === "objectives" && objKnown && winner.objectives === r.objectives) { + out.push( + `У ${winner.nickname} и ${r.nickname} поровну целей (${winner.objectives}) — ` + + "при равенстве победа определяется по мирам, пластику или ресурсам.", + ); + } + if (winReason && winReason !== "objectives" && objKnown && winner.objectives !== r.objectives) { + out.push( + `Победа ${label} предполагает равные цели у лидеров: у ${winner.nickname} — ` + + `${winner.objectives}, у ${r.nickname} — ${r.objectives}.`, + ); + } + if (winReason === "worlds" && worldsKnown && winner.worlds! <= r.worlds!) { + out.push( + `Победа по мирам, но у ${winner.nickname} миров не больше, чем у ${r.nickname} ` + + `(${winner.worlds} и ${r.worlds}).`, + ); + } + if ( + (winReason === "plastic" || winReason === "resources") && + worldsKnown && + winner.worlds !== r.worlds + ) { + out.push( + `Победа ${label} предполагает равные миры у лидеров: у ${winner.nickname} — ` + + `${winner.worlds}, у ${r.nickname} — ${r.worlds}.`, + ); + } + } + + if (winner.objectives != null) { + for (const s of seats) { + if (s.userId !== winner.userId && s.objectives != null && s.objectives > winner.objectives) { + out.push( + `У ${s.nickname} целей больше, чем у победителя ${winner.nickname} ` + + `(${s.objectives} против ${winner.objectives}).`, + ); + } + } + } + return out; +} diff --git a/frontend/src/domain/winReasons.ts b/frontend/src/domain/winReasons.ts index 048e46c..cfa2501 100644 --- a/frontend/src/domain/winReasons.ts +++ b/frontend/src/domain/winReasons.ts @@ -2,7 +2,10 @@ import type { components } from "../api/schema"; export type WinReason = components["schemas"]["MatchFinish"]["win_reason"]; -// Фиксированный порядок (сверху вниз). +export const LAST_STANDING: WinReason = "last_standing"; + +// Причины для ручного выбора, фиксированный порядок (сверху вниз). «Последний выживший» +// сюда не входит: она ставится сама, когда невыбывший игрок остался один. export const WIN_REASONS: { code: WinReason; label: string }[] = [ { code: "objectives", label: "По целям" }, { code: "worlds", label: "По мирам" }, @@ -10,10 +13,21 @@ export const WIN_REASONS: { code: WinReason; label: string }[] = [ { code: "resources", label: "По ресурсам" }, ]; -const LABELS: Record = Object.fromEntries( - WIN_REASONS.map((w) => [w.code, w.label]), -); +const LABELS: Record = { + ...Object.fromEntries(WIN_REASONS.map((w) => [w.code, w.label])), + [LAST_STANDING]: "Последний выживший", +}; export function winReasonLabel(code: string | null | undefined): string { return code ? LABELS[code] ?? code : "—"; } + +/** + * Причина победы после изменения состава выбывших. Правило сервера: невыбывший ровно + * один ⇔ «последний выживший». Поэтому при одном выжившем причина ставится сама, а когда + * выживших снова двое — сбрасывается (null): её нужно выбрать заново, а не угадывать. + */ +export function reasonForSurvivors(current: WinReason | null, survivors: number): WinReason | null { + if (survivors === 1) return LAST_STANDING; + return current === LAST_STANDING ? null : current; +} diff --git a/frontend/src/hooks/groups.ts b/frontend/src/hooks/groups.ts index 27a3132..6779b0e 100644 --- a/frontend/src/hooks/groups.ts +++ b/frontend/src/hooks/groups.ts @@ -75,14 +75,15 @@ export function useCreateGroup() { }); } -export function useRenameGroup(groupId: number) { +export function useUpdateGroup(groupId: number) { const qc = useQueryClient(); return useMutation({ - mutationFn: async (name: string) => + // Частичная правка: название и/или домашние правила (например, 9 раундов). + mutationFn: async (body: { name?: string; nine_rounds_rule?: boolean }) => unwrap( await api.PATCH("/api/groups/{group_id}", { params: { path: { group_id: groupId } }, - body: { name }, + body, }), ), onSuccess: () => { diff --git a/frontend/src/pages/GroupSettingsPage.tsx b/frontend/src/pages/GroupSettingsPage.tsx index 878852a..4e0eb8e 100644 --- a/frontend/src/pages/GroupSettingsPage.tsx +++ b/frontend/src/pages/GroupSettingsPage.tsx @@ -13,8 +13,8 @@ import { useGroup, useGroupMembers, useRemoveMember, - useRenameGroup, useSetGroupExpansions, + useUpdateGroup, } from "../hooks/groups"; import { useExpansions } from "../hooks/reference"; @@ -25,7 +25,7 @@ export function GroupSettingsPage() { const { data: expansions } = useExpansions(); const { data: members } = useGroupMembers(groupId); const setExpansions = useSetGroupExpansions(groupId ?? 0); - const renameGroup = useRenameGroup(groupId ?? 0); + const updateGroup = useUpdateGroup(groupId ?? 0); const removeMember = useRemoveMember(groupId ?? 0); const toast = useToast(); @@ -55,7 +55,7 @@ export function GroupSettingsPage() { const trimmed = nameValue.trim(); if (!trimmed || trimmed === group.name) return; try { - await renameGroup.mutateAsync(trimmed); + await updateGroup.mutateAsync({ name: trimmed }); setName(null); toast.show("Название сохранено"); } catch (e) { @@ -63,6 +63,15 @@ export function GroupSettingsPage() { } }; + const saveNineRounds = async (enabled: boolean) => { + try { + await updateGroup.mutateAsync({ nine_rounds_rule: enabled }); + toast.show(enabled ? "9 раундов при 5–6 игроках включено" : "Правило 9 раундов выключено"); + } catch (e) { + toast.error(e instanceof ApiError ? e.message : "Ошибка"); + } + }; + const saveExpansions = async () => { try { await setExpansions.mutateAsync([...selected]); @@ -95,7 +104,7 @@ export function GroupSettingsPage() { @@ -127,6 +136,22 @@ export function GroupSettingsPage() { )}
+
+

Домашние правила

+ +

+ Действует на партии, начатые после смены: лимит раундов уже идущих и сыгранных + партий не меняется. +

+
+
{/* Приглашение нового игрока — на странице группы («Список игроков»). */}

Участники ({(members ?? []).length}/{MAX_GROUP_SIZE})

diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx index 4a0fc31..c2164ae 100644 --- a/frontend/src/pages/MatchDetailPage.tsx +++ b/frontend/src/pages/MatchDetailPage.tsx @@ -4,13 +4,15 @@ import { useNavigate, useParams } from "react-router-dom"; import { ApiError } from "../api/client"; import { ConfirmDialog } from "../components/ConfirmDialog"; import { MatchMedia } from "../components/MatchMedia"; +import { MatchOutcomeFields } from "../components/MatchOutcomeFields"; import { PickerSelect } from "../components/PickerSelect"; -import { PlaceEditor } from "../components/PlaceEditor"; +import { type CountField, type Counts, PlaceEditor } from "../components/PlaceEditor"; import { PlayerLink } from "../components/PlayerLink"; import { Spinner } from "../components/Spinner"; +import { finishWarnings, type OutcomeSeat } from "../domain/finishWarnings"; import { formatDate, formatDuration, formatTime } from "../domain/format"; -import type { MatchFinishDraftData } from "../domain/types"; -import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons"; +import type { MatchFinishDraftData, MatchRead } from "../domain/types"; +import { reasonForSurvivors, type WinReason, winReasonLabel } from "../domain/winReasons"; import { useToast } from "../context/ToastContext"; import { useMe } from "../hooks/auth"; import { @@ -24,7 +26,31 @@ import { } from "../hooks/matches"; import { useGroupFactions } from "../hooks/reference"; -const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label })); +type Participant = MatchRead["participants"][number]; + +const countsOf = (participants: Participant[]): Counts => + Object.fromEntries( + participants.map((p) => [p.user_id, { objectives: p.objectives ?? null, worlds: p.worlds ?? null }]), + ); + +const countsFromDraft = (participants: Participant[], data: MatchFinishDraftData): Counts => + Object.fromEntries( + participants.map((p) => [ + p.user_id, + { + objectives: data.objectives?.[String(p.user_id)] ?? null, + worlds: data.worlds?.[String(p.user_id)] ?? null, + }, + ]), + ); + +// Черновик хранит только заполненные поля: {user_id строкой: число}. +const countDict = (counts: Counts, field: CountField): Record => + Object.fromEntries( + Object.entries(counts).flatMap(([uid, c]) => (c[field] == null ? [] : [[uid, c[field]]])), + ); + +const survivorsIn = (blocks: number[][]) => blocks.reduce((sum, ids) => sum + ids.length, 0); export function MatchDetailPage() { const { matchId } = useParams(); @@ -47,7 +73,10 @@ export function MatchDetailPage() { const [blocks, setBlocks] = useState(null); const [elim, setElim] = useState([]); const [comments, setComments] = useState | null>(null); - const [winReason, setWinReason] = useState("objectives"); + const [counts, setCounts] = useState(null); + // null — причина не выбрана: сброшена после «последнего выжившего» (см. reasonForSurvivors). + const [winReason, setWinReason] = useState("objectives"); + const [endRound, setEndRound] = useState(null); const [overall, setOverall] = useState(""); const [error, setError] = useState(null); const [confirmRemove, setConfirmRemove] = useState(false); @@ -86,23 +115,26 @@ export function MatchDetailPage() { // Чужой черновик применяем, только если человек сейчас ничего не двигает: // иначе правка соседа перетёрла бы тайл прямо под рукой. const incoming = match?.finish_draft; + const participants = match?.participants; useEffect(() => { - if (!incoming || match?.status !== "in_progress") return; + if (!incoming || !participants || match?.status !== "in_progress") return; if (incoming.updated_by === me?.id) return; if (incoming.updated_at === appliedDraftAt.current) return; if (Date.now() - lastLocalEdit.current < 1500) return; setBlocks(incoming.data.blocks); setElim(incoming.data.eliminated); setComments(incoming.data.comments); - setWinReason((incoming.data.win_reason ?? "objectives") as WinReason); + setCounts(countsFromDraft(participants, incoming.data)); + setWinReason(incoming.data.win_reason ?? null); + setEndRound(incoming.data.end_round ?? null); setOverall(incoming.data.overall_comment ?? ""); appliedDraftAt.current = incoming.updated_at; - }, [incoming, match?.status, me?.id]); + }, [incoming, participants, match?.status, me?.id]); if (isLoading) return ; if (!match) return
Партия не найдена.
; - const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (создатель/owner/admin) + const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (любой участник группы или админ) const inProgress = match.status === "in_progress"; // Ленивая инициализация раскладки из участников: каждый — отдельным блоком. @@ -110,6 +142,7 @@ export function MatchDetailPage() { const finishComments: Record = comments ?? Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])); + const finishCounts: Counts = counts ?? countsOf(match.participants); // Конфликт версий (кто-то изменил партию с другого устройства) → сообщаем и обновляем. const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE"; @@ -123,19 +156,64 @@ export function MatchDetailPage() { ), win_reason: winReason, overall_comment: overall.trim() || null, + end_round: endRound, + objectives: countDict(finishCounts, "objectives"), + worlds: countDict(finishCounts, "worlds"), ...patch, }); - const submitFinish = async () => { - if (!id || !match) return; - setError(null); - // Дожимаем отложенную запись: иначе последняя правка ушла бы в результаты, - // но не в черновик, и второй участник увидел бы не то, что записалось. - if (draftTimer.current) { - clearTimeout(draftTimer.current); - sendDraft(); - } + // Раскладка изменилась: при одном выжившем причина становится «последний выживший», + // при возврате второго — сбрасывается. + const applyLayout = (b: number[][], e: number[]): WinReason | null => { + const reason = reasonForSurvivors(winReason, survivorsIn(b)); + setBlocks(b); + setElim(e); + setWinReason(reason); + return reason; + }; + + const applyCount = (uid: number, field: CountField, value: number | null): Counts => { + const current = finishCounts[uid] ?? { objectives: null, worlds: null }; + const next = { ...finishCounts, [uid]: { ...current, [field]: value } }; + setCounts(next); + return next; + }; + + const outcomeSeats = (): OutcomeSeat[] => { + const byId = new Map(match.participants.map((p) => [p.user_id, p])); + const seat = (uid: number, place: number, eliminated: boolean): OutcomeSeat => ({ + userId: uid, + nickname: byId.get(uid)?.nickname ?? "", + place, + eliminated, + objectives: finishCounts[uid]?.objectives ?? null, + worlds: eliminated ? 0 : (finishCounts[uid]?.worlds ?? null), + }); + let place = 1; + const rows = finishBlocks.flatMap((ids) => { + const out = ids.map((uid) => seat(uid, place, false)); + place += ids.length; + return out; + }); + return [...rows, ...elim.map((uid) => seat(uid, place, true))]; + }; + + const warnings = () => + finishWarnings({ + seats: outcomeSeats(), + winReason, + endRound, + maxRounds: match.max_rounds, + }); + + // Строки результатов для API: места по блокам (competition ranking), затем выбывшие. + const resultRows = () => { const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null; + const countsFor = (uid: number, eliminated: boolean) => ({ + objectives: finishCounts[uid]?.objectives ?? null, + // У выбывшего миров нет — сервер сам запишет 0. + worlds: eliminated ? null : (finishCounts[uid]?.worlds ?? null), + }); let place = 1; const survivors = finishBlocks.flatMap((ids) => { const rows = ids.map((uid) => ({ @@ -143,24 +221,41 @@ export function MatchDetailPage() { place, eliminated: false, comment: commentOf(uid), + ...countsFor(uid, false), })); place += ids.length; // competition ranking: ничья съедает следующие места return rows; }); + const eliminated = elim.map((uid) => ({ + user_id: uid, + place: null, + eliminated: true, + comment: commentOf(uid), + ...countsFor(uid, true), + })); + return [...survivors, ...eliminated]; + }; + + const submitFinish = async () => { + if (!id || !match) return; + setError(null); + if (!winReason) { + setError("Выберите причину победы."); + return; + } + // Дожимаем отложенную запись: иначе последняя правка ушла бы в результаты, + // но не в черновик, и второй участник увидел бы не то, что записалось. + if (draftTimer.current) { + clearTimeout(draftTimer.current); + sendDraft(); + } try { await finish.mutateAsync({ matchId: id, body: { - participants: [ - ...survivors, - ...elim.map((uid) => ({ - user_id: uid, - place: null, - eliminated: true, - comment: commentOf(uid), - })), - ], + participants: resultRows(), win_reason: winReason, + end_round: endRound, overall_comment: overall.trim() || null, expected_version: match.version, }, @@ -188,7 +283,9 @@ export function MatchDetailPage() { setComments( Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])), ); - setWinReason((match.win_reason ?? "objectives") as WinReason); + setCounts(countsOf(match.participants)); + setWinReason((match.win_reason ?? null) as WinReason | null); + setEndRound(match.end_round ?? null); setOverall(match.overall_comment ?? ""); setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id]))); setError(null); @@ -199,36 +296,31 @@ export function MatchDetailPage() { setEditing(false); setBlocks(null); setComments(null); + setCounts(null); setError(null); }; const submitEdit = async () => { if (!id || !match) return; setError(null); + if (!winReason) { + setError("Выберите причину победы."); + return; + } const wasRandom = Object.fromEntries( match.participants.map((p) => [p.user_id, p.was_random]), ); - const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null; - const rowOf = (uid: number, place: number | null, eliminated: boolean) => ({ - user_id: uid, - faction_id: editFactions[uid], - place, - eliminated, - was_random: wasRandom[uid] ?? false, - comment: commentOf(uid), - }); - let place = 1; - const survivors = finishBlocks.flatMap((ids) => { - const rows = ids.map((uid) => rowOf(uid, place, false)); - place += ids.length; // competition ranking: ничья съедает следующие места - return rows; - }); try { await updateMatch.mutateAsync({ matchId: id, body: { - participants: [...survivors, ...elim.map((uid) => rowOf(uid, null, true))], + participants: resultRows().map((r) => ({ + ...r, + faction_id: editFactions[r.user_id], + was_random: wasRandom[r.user_id] ?? false, + })), win_reason: winReason, + end_round: endRound, overall_comment: overall.trim() || null, expected_version: match.version, }, @@ -276,6 +368,13 @@ export function MatchDetailPage() { .map((p) => ({ id: p.faction_id, code: "", name_ru: p.faction_name, expansion_id: 0 })), ]; + const placeHint = ( +

+ Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого игрока, чтобы + разделить место (ничья). Цели и миры — на конец партии. +

+ ); + return (
@@ -294,6 +393,9 @@ export function MatchDetailPage() { {!inProgress && (
Победа: {winReasonLabel(match.win_reason)} + {match.end_round != null && ( + · конец в {match.end_round}-м раунде из {match.max_rounds} + )}
)} {match.overall_comment &&

{match.overall_comment}

} @@ -304,20 +406,16 @@ export function MatchDetailPage() { <>

Правка результатов

-

- Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого - игрока, чтобы разделить место (ничья). -

+ {placeHint} { - setBlocks(b); - setElim(e); - }} + counts={finishCounts} + onChange={applyLayout} onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })} + onCount={applyCount} />
@@ -345,16 +443,14 @@ export function MatchDetailPage() {
-
-

Причина победы

- o.id === winReason) ?? null} - options={REASON_OPTIONS} - placeholder="— причина —" - renderOption={(o) => o.label} - onPick={(o) => setWinReason(o.id)} - /> -
+

О партии

@@ -413,6 +509,11 @@ export function MatchDetailPage() { · {p.faction_name} {p.was_random && 🎲} + {(p.objectives != null || p.worlds != null) && ( +
+ цели {p.objectives ?? "—"} · миры {p.worlds ?? "—"} +
+ )} {p.comment &&
{p.comment}
}
))} @@ -448,10 +549,7 @@ export function MatchDetailPage() { <>

Места

-

- Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого - игрока, чтобы разделить место (ничья). -

+ {placeHint} {match.finish_draft && match.finish_draft.updated_by !== me?.id && (

Результаты заполняет также {match.finish_draft.updated_by_nickname ?? "другой игрок"} @@ -464,10 +562,10 @@ export function MatchDetailPage() { blocks={finishBlocks} eliminated={elim} comments={finishComments} + counts={finishCounts} onChange={(b, e) => { - setBlocks(b); - setElim(e); - queueDraft(draftOf({ blocks: b, eliminated: e })); + const reason = applyLayout(b, e); + queueDraft(draftOf({ blocks: b, eliminated: e, win_reason: reason })); }} onComment={(uid, text) => { const next = { ...finishComments, [uid]: text }; @@ -480,23 +578,33 @@ export function MatchDetailPage() { }), ); }} - /> -

- -
-

Причина победы

- o.id === winReason) ?? null} - options={REASON_OPTIONS} - placeholder="— причина —" - renderOption={(o) => o.label} - onPick={(o) => { - setWinReason(o.id); - queueDraft(draftOf({ win_reason: o.id })); + onCount={(uid, field, value) => { + const next = applyCount(uid, field, value); + queueDraft( + draftOf({ + objectives: countDict(next, "objectives"), + worlds: countDict(next, "worlds"), + }), + ); }} />
+ { + setWinReason(reason); + queueDraft(draftOf({ win_reason: reason })); + }} + endRound={endRound} + onEndRound={(round) => { + setEndRound(round); + queueDraft(draftOf({ end_round: round })); + }} + maxRounds={match.max_rounds} + warnings={warnings()} + /> +

О партии