From 8f1518b0b3b2e5cac1c148b81071f2a4955e7b5c Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 19:48:34 +0300 Subject: [PATCH 01/33] =?UTF-8?q?=D0=A0=D0=B5=D0=B9=D1=82=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3:=20=D1=8D=D1=82=D0=B0=D0=BB=D0=BE=D0=BD=D0=BD=D0=B0?= =?UTF-8?q?=D1=8F=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=B8=20=D1=81=D0=B8=D0=BC=D1=83=D0=BB=D1=8F=D1=86?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BD=D0=BE=D0=B2=D0=BE=D0=B9=20=D1=81=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/rating/simulate.py — только stdlib, фиксированные seed. Содержит многопользовательский Elo с множителем отрыва (темп, цели, миры, тип победы, размер стола, K новичка) и пошаговые примеры документа с assert. Синтетическая лига в сценариях «сигнал», «шум», «клубы» и «рост» сравнивает текущий League Points, чистый Elo и предложенную систему. Флаг --grid перебирает K и веса на отдельных сезонах. #22 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- docs/rating/simulate.py | 840 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 840 insertions(+) create mode 100644 docs/rating/simulate.py diff --git a/docs/rating/simulate.py b/docs/rating/simulate.py new file mode 100644 index 0000000..3ed973f --- /dev/null +++ b/docs/rating/simulate.py @@ -0,0 +1,840 @@ +#!/usr/bin/env python3 +"""Эталонная реализация и симуляция предложенной рейтинговой системы (#22). + +Скрипт — приложение к docs/rating/rating-system.md: + +1. Формулы документа в коде (раздел «Эталонная реализация»). #23 может сверять + с ними свою реализацию. +2. Пошаговые примеры документа с assert на числа: документ и код не разъедутся. +3. Синтетическая лига: игроки со скрытой «истинной» силой, партии на 2–6 человек. + Детали партии (раунд, цели, миры, тип победы) выводятся из отрыва + по производительности. На одних и тех же партиях сравниваются текущий League + Points, чистый парный Elo и предложенная система. +4. Перебор весов (--grid). + +Только стандартная библиотека и фиксированные seed — вывод воспроизводим. + + python docs/rating/simulate.py # примеры + сравнение систем (≈20 с) + python docs/rating/simulate.py --grid # примеры + перебор K и весов (≈5 мин) +""" +from __future__ import annotations + +import argparse +import math +import random +import statistics +import sys +from dataclasses import dataclass, replace +from itertools import combinations + +# ═══ Правила игры ═══════════════════════════════════════════════════════════════ + +# Размер поля в тайлах по числу игроков (6 игроков — 4×5, ответ владельца в #22). +BOARD_TILES = {2: 4, 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 + + +def worlds_on_board(n: int) -> float: + return BOARD_TILES[n] * WORLDS_PER_TILE + + +def fair_worlds(n: int) -> float: + """«Честная доля» миров на игрока — масштаб для разницы миров.""" + return worlds_on_board(n) / n + + +def max_rounds(n: int, nine_rounds: bool) -> int: + return EXTENDED_ROUNDS if nine_rounds and n >= EXTENDED_MIN_PLAYERS else BASE_ROUNDS + + +# ═══ Эталонная реализация ═════════════════════════════════════════════════════ + + +@dataclass +class Seat: + player: str + place: int + objectives: int | None = None # маркеры целей на конец партии + worlds: int | None = None # дружественные миры на конец партии + eliminated: bool = False + + +@dataclass +class Match: + seats: list[Seat] + win_reason: str | None = None + round: int | None = None # раунд, в котором партия закончилась + nine_rounds: bool = False # снимок настройки группы на момент партии + + +@dataclass(frozen=True) +class Params: + # Шкала «Elo / 10»: старт 50, разница 40 = шансы 10:1. Классический Elo — R·10 + 1000. + r0: float = 50.0 # стартовый рейтинг + d: float = 40.0 # масштаб логистики + k_max: float = 6.4 # K новичка (0 партий) + k_min: float = 1.6 # K опытного игрока + k_games: int = 20 # за сколько партий K линейно спускается от k_max к k_min + w_table: float = 0.0 # вес размера стола (треб. 2) + w_tempo: float = 0.0 # вес темпа победы (треб. 1) + w_obj: float = 0.0 # вес разницы целей (треб. 4) + w_worlds: float = 0.0 # вес разницы миров (треб. 4) + # Близость партии по типу победы (треб. 5) — множитель пар с победителем. + closeness: tuple[tuple[str, float], ...] = () + # «Типичные» значения признаков: партия с ними получает множитель 1, + # отсутствующий признак подставляется типичным (= нейтральным). + mu_obj: float = 0.5 + mu_worlds: float = 0.5 + m_min: float = 0.5 + m_max: float = 2.0 + autocorr: bool = False # поправка на автокорреляцию (см. документ) + + def closeness_for(self, reason: str | None) -> float: + return dict(self.closeness).get(reason, 1.0) if reason else 1.0 + + +def mu_tempo(rmax: int) -> float: + """Типичный темп: партия закончилась в предпоследнем раунде.""" + return 1.0 / (rmax - 1) + + +def expected(r_a: float, r_b: float, d: float) -> float: + """Ожидаемый результат a против b (вероятность, что a окажется выше).""" + return 1.0 / (1.0 + 10.0 ** ((r_b - r_a) / d)) + + +def k_factor(games: int, p: Params) -> float: + left = max(0.0, 1.0 - games / p.k_games) + return p.k_min + (p.k_max - p.k_min) * left + + +def table_weight(n: int, p: Params) -> float: + return 1.0 + p.w_table * (n - 2) / 4.0 + + +def _clamp(x: float, lo: float, hi: float) -> float: + return max(lo, min(hi, x)) + + +def pair_multiplier( + m: Match, a: Seat, b: Seat, r_a: float, r_b: float, p: Params +) -> tuple[float, dict]: + """Множитель отрыва пары; a — выше или наравне с b. Возвращает (M, разбор).""" + n = len(m.seats) + tie = a.place == b.place + winner_pair = a.place == 1 + parts: dict[str, float] = {} + 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) + mu = mu_tempo(rmax) + tempo = mu if m.round is None else (rmax - m.round) / (rmax - 1) + parts["tempo"] = tempo + add += p.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 = p.mu_obj + parts["obj"] = obj + add += p.w_obj * (obj - p.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 = p.mu_worlds + parts["worlds"] = wor + add += p.w_worlds * (wor - p.mu_worlds) + + if p.autocorr and add > 1.0 and not tie: + # Поправка FiveThirtyEight (2.2 / (0.001·ΔElo + 2.2)) в шкале Elo/10. + kappa = 2.2 / (0.01 * (r_a - r_b) + 2.2) + parts["kappa"] = kappa + add = 1.0 + (add - 1.0) * kappa + parts["additive"] = add + mult = _clamp(add, p.m_min, p.m_max) + close = p.closeness_for(m.win_reason) if winner_pair else 1.0 + parts["closeness"] = close + return mult * close, parts + + +def rate_match( + ratings: dict[str, float], + games: dict[str, int], + m: Match, + p: Params, + trace: list | None = None, +) -> dict[str, float]: + """Изменения рейтинга участников партии. Рейтинги/счётчики не мутирует.""" + n = len(m.seats) + g = table_weight(n, p) + r = {s.player: ratings.get(s.player, p.r0) for s in m.seats} + k = {s.player: k_factor(games.get(s.player, 0), p) for s in m.seats} + delta = {s.player: 0.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.player], r[b.player], p.d) + mult, parts = pair_multiplier(m, a, b, r[a.player], r[b.player], p) + x = mult * (s_ab - e_ab) + delta[a.player] += k[a.player] * g / (n - 1) * x + delta[b.player] -= k[b.player] * g / (n - 1) * x + if trace is not None: + trace.append( + {"a": a.player, "b": b.player, "S": s_ab, "E": e_ab, "M": mult, **parts} + ) + return delta + + +# ═══ Системы для сравнения ════════════════════════════════════════════════════ + + +class LeaguePoints: + """Текущая система (backend/app/services/scoring.py): сглаженное среднее очков за место.""" + + probabilistic = False + PRIOR_GAMES = 10 + PRIOR_MEAN = 0.5 + + def __init__(self) -> None: + self.sum: dict[str, float] = {} + self.games: dict[str, int] = {} + + def rating(self, player: str) -> float: + g = self.games.get(player, 0) + return (self.PRIOR_GAMES * self.PRIOR_MEAN + self.sum.get(player, 0.0)) / ( + self.PRIOR_GAMES + g + ) * 100 + + def update(self, m: Match) -> dict[str, float]: + n = len(m.seats) + tie = {} + for s in m.seats: + tie[s.place] = tie.get(s.place, 0) + 1 + before = {s.player: self.rating(s.player) for s in m.seats} + for s in m.seats: + pts = (n - s.place - (tie[s.place] - 1) / 2) / (n - 1) + self.sum[s.player] = self.sum.get(s.player, 0.0) + pts + self.games[s.player] = self.games.get(s.player, 0) + 1 + return {s.player: self.rating(s.player) - before[s.player] for s in m.seats} + + +class Elo: + """Парный многопользовательский Elo; с нулевыми весами — «чистый» Elo.""" + + probabilistic = True + + def __init__(self, p: Params) -> None: + self.p = p + self.r: dict[str, float] = {} + self.games: dict[str, int] = {} + + def rating(self, player: str) -> float: + return self.r.get(player, self.p.r0) + + def update(self, m: Match) -> dict[str, float]: + delta = rate_match(self.r, self.games, m, self.p) + for pl, dv in delta.items(): + self.r[pl] = self.rating(pl) + dv + self.games[pl] = self.games.get(pl, 0) + 1 + return delta + + +# ═══ Генератор синтетической лиги ═════════════════════════════════════════════ + +SIGMA_SKILL = 150.0 # разброс «истинной» силы игроков +SIGMA_PERF = 225.0 # шум производительности в отдельной партии (кубы, карты, ошибки) +# Истинная сила в шкале рейтинга: Φ(Δθ/(σ√2)) ≈ логистика с масштабом d=40 при ΔR ≈ 0.093·Δθ. +SKILL_TO_RATING = 1.702 * 40 / (math.log(10) * SIGMA_PERF * math.sqrt(2)) +TABLE_SIZES = ((2, 0.45), (3, 0.25), (4, 0.20), (5, 0.07), (6, 0.03)) +ELIM_Z = 2.3 # отставание (в σ), при котором игрок может выбыть +ELIM_P = 0.35 # вероятность выбывания при таком отставании + + +def _choice_weighted(rng: random.Random, pairs) -> int: + x = rng.random() * sum(w for _, w in pairs) + for v, w in pairs: + x -= w + if x <= 0: + return v + return pairs[-1][0] + + +def generate_match( + rng: random.Random, + skill: dict[str, float], + players: list[str], + nine_rounds: bool, + informative: bool, +) -> Match: + """Партия: места — по производительности, детали — по отрыву. + + informative=False — сценарий «шум»: места те же, но величина отрыва (а значит + раунд, цели, миры и тип победы) не связана с силой игроков.""" + n = len(players) + perf = {pl: skill[pl] + rng.gauss(0, SIGMA_PERF) for pl in players} + order = sorted(players, key=perf.get, reverse=True) + if informative: + z = {pl: (perf[order[0]] - perf[pl]) / SIGMA_PERF for pl in players} + else: + ghost = sorted((rng.gauss(0, SIGMA_PERF) for _ in players), reverse=True) + z = {pl: (ghost[0] - ghost[i]) / SIGMA_PERF for i, pl in enumerate(order)} + + winner = order[0] + eliminated = {pl for pl in order[1:] if z[pl] > ELIM_Z and rng.random() < ELIM_P} + survivors = [pl for pl in order if pl not in eliminated] + rmax = max_rounds(n, nine_rounds) + + if len(survivors) == 1: + reason = "last_standing" + gap = z[order[1]] + else: + gap = z[survivors[1]] + if gap < 0.02: + reason = "resources" + elif gap < 0.08: + reason = "plastic" + elif gap < 0.25: + reason = "worlds" + else: + reason = "objectives" + rnd = int(_clamp(round(rmax + 0.3 - 1.4 * gap + rng.gauss(0, 0.9)), 3, rmax)) + + need = n + if reason == "last_standing": + o_win = rng.randint(max(0, need - 2), need - 1) + elif rnd < rmax: + o_win = need + else: + o_win = need if rng.random() < 0.5 else need - 1 + objectives = {winner: o_win} + for pl in order[1:]: + o = round(o_win * (1 - 0.45 * z[pl]) + rng.gauss(0, 0.5)) + objectives[pl] = int(_clamp(o, 0, max(0, o_win - 1))) + + total = int(worlds_on_board(n)) + mean_z = statistics.fmean(z[pl] for pl in survivors) + worlds = {} + for pl in order: + if pl in eliminated: + worlds[pl] = 0 + continue + w = round(fair_worlds(n) * (1 + 0.35 * (mean_z - z[pl])) + rng.gauss(0, 0.8)) + worlds[pl] = int(_clamp(w, 1, total)) + + if reason in ("worlds", "plastic", "resources"): + ru = survivors[1] + objectives[ru] = o_win + if reason == "worlds": + if worlds[winner] <= worlds[ru]: + worlds[winner] = worlds[ru] + 1 + else: + worlds[ru] = worlds[winner] + + seats = [] + for i, pl in enumerate(survivors): + seats.append(Seat(pl, i + 1, objectives[pl], worlds[pl])) + last = len(survivors) + 1 + for pl in order: + if pl in eliminated: + seats.append(Seat(pl, last, objectives[pl], 0, eliminated=True)) + return Match(seats, reason, rnd, nine_rounds) + + +def strip_details(m: Match) -> Match: + """Партия «из старой истории»: только места и тип победы.""" + return Match( + [Seat(s.player, s.place, eliminated=s.eliminated) for s in m.seats], m.win_reason + ) + + +CLUB_OFFSETS = (-150.0, 0.0, 150.0) # сценарий «клубы»: средняя сила трёх групп +CLUB_SIGMA = 90.0 # разброс силы внутри клуба +CLUB_MIXED_SHARE = 0.1 # доля партий, где встречаются игроки разных клубов + + +LEARN_DEFICIT = 200.0 # сценарий «рост»: максимальное стартовое отставание новичка +LEARN_GAMES = 15.0 # за столько партий отставание уменьшается в e раз + + +def generate_season( + seed: int, n_matches: int, informative: bool, clubs: bool = False, learning: bool = False +) -> tuple[dict, list[Match]]: + """Сезон: 12 игроков сразу, ещё по двое на 1/3 и 2/3 сезона. + + clubs=True — игроки разбиты на три группы разной силы и почти всегда играют + внутри своей; общий рейтинг должен их правильно «сшить». + learning=True — сила растёт с опытом: θ − deficit·exp(−партии/LEARN_GAMES). + Возвращает силу на КОНЕЦ сезона — её и должен отражать рейтинг.""" + rng = random.Random(seed) + skill = {} + activity = {} + joins = {} + club = {} + deficit = {} + played = {} + for i in range(18 if clubs else 16): + pl = f"p{i:02d}" + if clubs: + club[pl] = i % 3 + skill[pl] = CLUB_OFFSETS[club[pl]] + rng.gauss(0, CLUB_SIGMA) + joins[pl] = 0 if i < 15 else n_matches // 3 + else: + skill[pl] = rng.gauss(0, SIGMA_SKILL) + joins[pl] = 0 if i < 12 else (n_matches // 3 if i < 14 else 2 * n_matches // 3) + activity[pl] = rng.uniform(0.5, 1.5) + deficit[pl] = rng.uniform(0, LEARN_DEFICIT) if learning else 0.0 + played[pl] = 0 + + def current(pl: str) -> float: + return skill[pl] - deficit[pl] * math.exp(-played[pl] / LEARN_GAMES) + + nine_rounds = rng.random() < 0.5 + matches = [] + for t in range(n_matches): + active = [pl for pl in skill if joins[pl] <= t] + if clubs and rng.random() >= CLUB_MIXED_SHARE: + c = rng.randrange(3) + active = [pl for pl in active if club[pl] == c] + n = min(_choice_weighted(rng, TABLE_SIZES), len(active)) + pool = active[:] + chosen = [] + for _ in range(n): + pick = _choice_weighted(rng, [(pl, activity[pl]) for pl in pool]) + pool.remove(pick) + chosen.append(pick) + now = {pl: current(pl) for pl in chosen} + matches.append(generate_match(rng, now, chosen, nine_rounds, informative)) + for pl in chosen: + played[pl] += 1 + return {pl: current(pl) for pl in skill}, matches + + +# ═══ Метрики ══════════════════════════════════════════════════════════════════ + + +def _ranks(xs: list[float]) -> list[float]: + order = sorted(range(len(xs)), key=lambda i: xs[i]) + ranks = [0.0] * len(xs) + i = 0 + while i < len(order): + j = i + while j + 1 < len(order) and xs[order[j + 1]] == xs[order[i]]: + j += 1 + for t in range(i, j + 1): + ranks[order[t]] = (i + j) / 2 + 1 + i = j + 1 + return ranks + + +def spearman(xs: list[float], ys: list[float]) -> float: + if len(xs) < 3: + return float("nan") + rx, ry = _ranks(xs), _ranks(ys) + return statistics.correlation(rx, ry) + + +CHECKPOINTS = (5, 10, 20) + + +def evaluate(system, skill: dict[str, float], matches: list[Match], feed=None) -> dict: + """Прогоняет сезон. feed(m) — какую версию партии видит система (по умолчанию полную).""" + half = len(matches) // 2 + hits = pairs = 0.0 + brier = [] + abs_moves = [] + at_k: dict[int, dict[str, float]] = {k: {} for k in CHECKPOINTS} + games: dict[str, int] = {} + for t, m in enumerate(matches): + seen = feed(m) if feed else m + if t >= half: + for a, b in combinations(m.seats, 2): + if a.place == b.place: + continue + if a.place > b.place: + a, b = b, a + ra, rb = system.rating(a.player), system.rating(b.player) + pairs += 1 + hits += 1.0 if ra > rb else 0.5 if ra == rb else 0.0 + if system.probabilistic: + brier.append((1.0 - expected(ra, rb, system.p.d)) ** 2) + delta = system.update(seen) + for s in m.seats: + games[s.player] = games.get(s.player, 0) + 1 + gp = games[s.player] + if gp > 20: + abs_moves.append(abs(delta[s.player])) + if gp in at_k: + at_k[gp][s.player] = system.rating(s.player) + played = [pl for pl in skill if games.get(pl, 0) >= 10] + out = { + "acc": hits / pairs if pairs else float("nan"), + "rho": spearman([system.rating(pl) for pl in played], [skill[pl] for pl in played]), + "move": statistics.fmean(abs_moves) if abs_moves else float("nan"), + } + for k in CHECKPOINTS: + pls = list(at_k[k]) + out[f"rho@{k}"] = spearman([at_k[k][pl] for pl in pls], [skill[pl] for pl in pls]) + if system.probabilistic: + out["brier"] = statistics.fmean(brier) + rs = [system.rating(pl) for pl in played] + ts = [skill[pl] * SKILL_TO_RATING for pl in played] + mr, mt = statistics.fmean(rs), statistics.fmean(ts) + out["rmse"] = math.sqrt(statistics.fmean(((r - mr) - (t - mt)) ** 2 for r, t in zip(rs, ts))) + everyone = [system.rating(pl) for pl in games] + out["inflation"] = statistics.fmean(everyone) - system.p.r0 + out["slope"] = _slope(ts, rs) + return out + + +def oracle_accuracy(skill: dict[str, float], matches: list[Match]) -> float: + half = len(matches) // 2 + hits = pairs = 0 + for m in matches[half:]: + for a, b in combinations(m.seats, 2): + if a.place == b.place: + continue + if a.place > b.place: + a, b = b, a + pairs += 1 + hits += skill[a.player] > skill[b.player] + return hits / pairs + + +def _slope(xs: list[float], ys: list[float]) -> float: + """Наклон регрессии рейтинга на истинную силу: 1 — масштаб честный, >1 — раздут.""" + mx, my = statistics.fmean(xs), statistics.fmean(ys) + sxx = sum((x - mx) ** 2 for x in xs) + return sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / sxx if sxx else float("nan") + + +def summarize(rows: list[dict]) -> dict[str, tuple[float, float]]: + keys = rows[0].keys() + res = {} + for k in keys: + vals = [r[k] for r in rows if not math.isnan(r[k])] + mean = statistics.fmean(vals) + se = statistics.stdev(vals) / math.sqrt(len(vals)) if len(vals) > 1 else 0.0 + res[k] = (mean, se) + return res + + +# ═══ Коэффициенты ═════════════════════════════════════════════════════════════ + +PLAIN = Params(k_max=9.6, k_min=1.6) # чистый Elo со своими лучшими K (перебор, этап 1) +CLOSENESS = ( + ("objectives", 1.0), + ("worlds", 0.85), + ("plastic", 0.7), + ("resources", 0.6), + ("last_standing", 1.0), +) +# Для анализа чувствительности: отклонения от 1 вдвое больше. +CLOSENESS_STRONG = tuple((r, 1.0 - 2 * (1.0 - c)) for r, c in CLOSENESS) +PROPOSED = Params( + w_table=0.5, + w_tempo=1.0, + w_obj=0.5, + w_worlds=0.5, + closeness=CLOSENESS, +) + + +# ═══ Примеры из документа ═════════════════════════════════════════════════════ + +VETERAN = 40 # партий у «опытного» игрока: K = k_min + + +def _vets(*names: str) -> dict[str, int]: + return {n: VETERAN for n in names} + + +def examples() -> list[tuple[str, str, dict, dict, Match]]: + """(ключ, заголовок, рейтинги, сыграно партий, партия) — в порядке документа.""" + duel = lambda first, second, **kw: Match([Seat(first, 1), Seat(second, 2)], **kw) # noqa: E731 + five = [Seat("A", 1), Seat("B", 2), Seat("C", 3), Seat("D", 4), Seat("E", 5)] + six = [Seat(x, i + 1) for i, x in enumerate("ABCDEF")] + return [ + ("1a", "Дуэль 60 против 40: побеждает сильный", + {"A": 60, "B": 40}, _vets("A", "B"), duel("A", "B", win_reason="objectives")), + ("1b", "Дуэль 60 против 40: побеждает слабый", + {"A": 60, "B": 40}, _vets("A", "B"), duel("B", "A", win_reason="objectives")), + ("2a", "Быстрая победа: 3-й раунд", + {"A": 50, "B": 50}, _vets("A", "B"), + Match([Seat("A", 1, 2, 5), Seat("B", 2, 1, 4)], "objectives", round=3)), + ("2b", "Медленная победа: 8-й раунд", + {"A": 50, "B": 50}, _vets("A", "B"), + Match([Seat("A", 1, 2, 5), Seat("B", 2, 1, 4)], "objectives", round=8)), + ("3a", "Первое место в дуэли", + {"A": 50, "B": 50}, _vets("A", "B"), duel("A", "B", win_reason="objectives")), + ("3b", "Стол на 5: все места", + dict.fromkeys("ABCDE", 50), _vets(*"ABCDE"), Match(five, "objectives")), + ("4a", "Тип победы без деталей: по мирам", + {"A": 50, "B": 50}, _vets("A", "B"), duel("A", "B", win_reason="worlds")), + ("4b", "Тип победы без деталей: по пластику", + {"A": 50, "B": 50}, _vets("A", "B"), duel("A", "B", win_reason="plastic")), + ("4c", "Тип победы без деталей: по ресурсам", + {"A": 50, "B": 50}, _vets("A", "B"), duel("A", "B", win_reason="resources")), + ("4d", "Самая близкая полная партия: по мирам на 8-м раунде, 2:2 цели, 6:5 миров", + {"A": 50, "B": 50}, _vets("A", "B"), + Match([Seat("A", 1, 2, 6), Seat("B", 2, 2, 5)], "worlds", round=8)), + ("4e", "Разгром: 3-й раунд, 2:0 цели, 8:2 миров", + {"A": 50, "B": 50}, _vets("A", "B"), + Match([Seat("A", 1, 2, 8), Seat("B", 2, 0, 2)], "objectives", round=3)), + ("5", "Стол на 4: ничья выбывших, раунд 7", + {"A": 55, "B": 50, "C": 48, "D": 45}, _vets(*"ABCD"), + Match( + [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", round=7, + )), + ("6a", "Стол на 6, конец на 8-м раунде, хоумрул 9 раундов включён", + dict.fromkeys("ABCDEF", 50), _vets(*"ABCDEF"), + Match(six, "objectives", round=8, nine_rounds=True)), + ("6b", "Стол на 6, конец на 8-м раунде, хоумрул выключен", + dict.fromkeys("ABCDEF", 50), _vets(*"ABCDEF"), + Match(six, "objectives", round=8, nine_rounds=False)), + ("7", "Новичок (0 партий) побеждает ветерана, оба 50", + {"A": 50, "B": 50}, {"A": 0, "B": VETERAN}, duel("A", "B", win_reason="objectives")), + ] + + +# Изменения рейтинга в примерах (округление до 0.001) — те же числа стоят в документе. +EXPECTED: dict[str, dict[str, float]] = { + "1a": {"A": 0.384, "B": -0.384}, + "1b": {"B": 1.216, "A": -1.216}, + "2a": {"A": 1.148, "B": -1.148}, + "2b": {"A": 0.577, "B": -0.577}, + "3a": {"A": 0.8, "B": -0.8}, + "3b": {"A": 1.1, "B": 0.55, "C": 0.0, "D": -0.55, "E": -1.1}, + "4a": {"A": 0.68, "B": -0.68}, + "4b": {"A": 0.56, "B": -0.56}, + "4c": {"A": 0.48, "B": -0.48}, + "4d": {"A": 0.34, "B": -0.34}, + "4e": {"A": 1.6, "B": -1.6}, + "5": {"A": 0.927, "B": 0.585, "C": -0.778, "D": -0.735}, + "6a": {"A": 1.2, "B": 0.72, "C": 0.24, "D": -0.24, "E": -0.72, "F": -1.2}, + "6b": {"A": 1.029, "B": 0.754, "C": 0.274, "D": -0.206, "E": -0.686, "F": -1.166}, + "7": {"A": 3.2, "B": -0.8}, +} + + +def run_examples(p: Params = PROPOSED, verbose: bool = True) -> None: + for key, title, ratings, games, m in examples(): + trace: list = [] + delta = rate_match(ratings, games, m, p, trace) + got = {pl: round(v, 3) for pl, v in delta.items()} + if verbose: + n = len(m.seats) + print(f"\n### Пример {key}. {title}\n") + print(f"N={n}, G(N)={table_weight(n, p):.3f}, раунд={m.round}, R_max={max_rounds(n, m.nine_rounds)}, " + f"тип={m.win_reason}, K: " + ", ".join(f"{pl}={k_factor(games[pl], p):.1f}" for pl in ratings)) + print("\n| пара | S | E | темп | цели | миры | сумма | близость | M |") + print("|---|---|---|---|---|---|---|---|---|") + for t in trace: + tempo = f"{t['tempo']:.3f}" if "tempo" in t else "—" + print(f"| {t['a']}–{t['b']} | {t['S']} | {t['E']:.3f} | {tempo} | {t['obj']:.3f} | " + f"{t['worlds']:.3f} | {t['additive']:.3f} | {t['closeness']} | {t['M']:.3f} |") + print("\nΔR: " + ", ".join(f"{pl} {v:+.3f}" for pl, v in got.items())) + if EXPECTED: + assert got == EXPECTED[key], f"пример {key}: {got} ≠ {EXPECTED[key]}" + if EXPECTED and verbose: + print("\nВсе примеры совпадают с документом.") + + +# ═══ Сценарии запуска ═════════════════════════════════════════════════════════ + +SEASON_MATCHES = 300 + + +SCENARIOS = { + "сигнал": {"informative": True, "clubs": False, "learning": False, "seed": 10_000}, + "шум": {"informative": False, "clubs": False, "learning": False, "seed": 30_000}, + "клубы": {"informative": True, "clubs": True, "learning": False, "seed": 40_000}, + "рост": {"informative": True, "clubs": False, "learning": True, "seed": 50_000}, +} + + +def compare(seasons: int, scenario: str, proposed: Params) -> None: + cfg = SCENARIOS[scenario] + print(f"\n## Сравнение систем — сценарий «{scenario}», {seasons} сезонов по {SEASON_MATCHES} партий\n") + variants = [ + ("League Points (сейчас)", lambda: LeaguePoints(), None), + ("Elo, чистый", lambda: Elo(PLAIN), None), + ("Предложенная", lambda: Elo(proposed), None), + ("Предложенная, без новых полей", lambda: Elo(proposed), strip_details), + ] + results = {name: [] for name, _, _ in variants} + oracle = [] + for s in range(seasons): + skill, matches = generate_season( + cfg["seed"] + s, SEASON_MATCHES, cfg["informative"], cfg["clubs"], cfg["learning"] + ) + oracle.append(oracle_accuracy(skill, matches)) + for name, make, feed in variants: + results[name].append(evaluate(make(), skill, matches, feed)) + print(f"Потолок точности (прогноз по истинной силе): {statistics.fmean(oracle):.4f}\n") + cols = ["acc", "brier", "rho", "rho@5", "rho@10", "rho@20", "rmse", "slope", "move", "inflation"] + print("| Система | " + " | ".join(cols) + " |") + print("|---" * (len(cols) + 1) + "|") + for name, _, _ in variants: + sm = summarize(results[name]) + cells = [] + for c in cols: + if c not in sm: + cells.append("—") + else: + mean, se = sm[c] + cells.append(f"{mean:.4f} ±{se:.4f}" if c in ("acc", "brier") else f"{mean:.3f}") + print(f"| {name} | " + " | ".join(cells) + " |") + base = results["Elo, чистый"] + prop = results["Предложенная"] + lp = results["League Points (сейчас)"] + d_brier = [p["brier"] - b["brier"] for p, b in zip(prop, base)] + d_acc_lp = [p["acc"] - b["acc"] for p, b in zip(prop, lp)] + d_acc = [p["acc"] - b["acc"] for p, b in zip(prop, base)] + for title, ds in ( + ("Brier: предложенная − чистый Elo", d_brier), + ("Точность: предложенная − чистый Elo", d_acc), + ("Точность: предложенная − League Points", d_acc_lp), + ): + mean = statistics.fmean(ds) + se = statistics.stdev(ds) / math.sqrt(len(ds)) + print(f"- {title}: {mean:+.4f} ± {se:.4f} (парная разница)") + + +GRID_SEED_SHIFT = 100_000 # перебор идёт на других сезонах, чем итоговое сравнение + + +def grid(seasons: int) -> None: + """Подбор K и весов по Brier (меньше — лучше) на отдельных от сравнения сезонах.""" + data: dict[str, list] = {} + for name, cfg in SCENARIOS.items(): + data[name] = [ + generate_season( + cfg["seed"] + GRID_SEED_SHIFT + s, SEASON_MATCHES, + cfg["informative"], cfg["clubs"], cfg["learning"], + ) + for s in range(seasons) + ] + signal = [n for n, c in SCENARIOS.items() if c["informative"]] + cache: dict = {} + + def run(p: Params, scenario: str) -> tuple[float, float]: + if (p, scenario) not in cache: + rows = [evaluate(Elo(p), sk, ms) for sk, ms in data[scenario]] + cache[(p, scenario)] = ( + statistics.fmean(r["brier"] for r in rows), + statistics.fmean(r["rho@10"] for r in rows), + ) + return cache[(p, scenario)] + + def brier(p: Params, scenarios) -> float: + return statistics.fmean(run(p, sc)[0] for sc in scenarios) + + def rho10(p: Params, scenarios) -> float: + return statistics.fmean(run(p, sc)[1] for sc in scenarios) + + print(f"\n## Перебор: {seasons} сезонов на сценарий, критерий — средний Brier\n") + print("### Этап 1. K чистого Elo (все сценарии)\n") + print("| k_max | k_min | k_games | Brier |") + print("|---|---|---|---|") + k_res = [] + for k_games in (10, 20): + for k_max in (4.8, 6.4, 9.6, 12.8, 16.0): + for k_min in (1.6, 2.4, 3.2): + p = replace(PLAIN, k_max=k_max, k_min=k_min, k_games=k_games) + b = brier(p, SCENARIOS) + k_res.append((b, k_max, k_min, k_games)) + print(f"| {k_max} | {k_min} | {k_games} | {b:.5f} |") + _, k_max, k_min, k_games = min(k_res) + print(f"\nЛучшие K: k_max={k_max}, k_min={k_min}, k_games={k_games}") + + print(f"\n### Этап 2. Веса отрыва (K этапа 1; сценарии {', '.join(signal)}; «шум» — контроль)\n") + res = [] + for w_table in (0.0, 0.25, 0.5): + for w_tempo in (0.0, 0.5, 1.0): + for w_obj in (0.0, 0.5, 1.0): + for w_worlds in (0.0, 0.5, 1.0): + for close in ((), CLOSENESS): + p = Params( + k_max=k_max, k_min=k_min, k_games=k_games, w_table=w_table, + w_tempo=w_tempo, w_obj=w_obj, w_worlds=w_worlds, closeness=close, + ) + res.append((brier(p, signal), p)) + res.sort(key=lambda x: x[0]) + zero = next(b for b, p in res if (p.w_table, p.w_tempo, p.w_obj, p.w_worlds) == (0, 0, 0, 0) and not p.closeness) + plain_best = replace(PLAIN, k_max=k_max, k_min=k_min, k_games=k_games) + print(f"Без множителя (все веса 0, без близости): Brier {zero:.5f}, " + f"«шум» {brier(plain_best, ['шум']):.5f}\n") + print("| Brier | Brier «шум» | w_table | w_tempo | w_obj | w_worlds | близость |") + print("|---|---|---|---|---|---|---|") + for b, p in res[:12]: + noise = brier(p, ["шум"]) + print( + f"| {b:.5f} | {noise:.5f} | {p.w_table} | {p.w_tempo} | {p.w_obj} | {p.w_worlds} | " + f"{'да' if p.closeness else 'нет'} |" + ) + print(f"\nХудшая комбинация: Brier {res[-1][0]:.5f}") + + print("\n### Этап 3. Доводка K и поправка на автокорреляцию для предложенных весов\n") + print("| k_max | k_min | k_games | autocorr | Brier (сигнальные) | Brier «шум» |") + print("|---|---|---|---|---|---|") + for k_games2 in (10, 20): + for k_max2 in (4.8, 6.4, 8.0): + for k_min2 in (1.2, 1.6, 2.4): + for ac in (False, True): + p = replace(PROPOSED, k_max=k_max2, k_min=k_min2, k_games=k_games2, autocorr=ac) + print( + f"| {k_max2} | {k_min2} | {k_games2} | {'да' if ac else 'нет'} | " + f"{brier(p, signal):.5f} | {brier(p, ['шум']):.5f} |" + ) + + print("\n### Этап 4. Чувствительность: один вес меняется, остальные — как в PROPOSED\n") + print(f"PROPOSED: Brier {brier(PROPOSED, signal):.5f}, ρ@10 {rho10(PROPOSED, signal):.3f}, " + f"Brier «шум» {brier(PROPOSED, ['шум']):.5f}\n") + print("| параметр | значение | Brier (сигнальные) | ρ@10 (сигнальные) | Brier «шум» |") + print("|---|---|---|---|---|") + sweeps = [ + ("w_table", (0.0, 0.25, 0.5, 1.0)), + ("w_tempo", (0.0, 0.25, 0.5, 1.0, 2.0)), + ("w_obj", (0.0, 0.25, 0.5, 1.0, 2.0)), + ("w_worlds", (0.0, 0.25, 0.5, 1.0, 2.0)), + ] + for attr, values in sweeps: + for v in values: + p = replace(PROPOSED, **{attr: v}) + print(f"| {attr} | {v} | {brier(p, signal):.5f} | {rho10(p, signal):.3f} | {brier(p, ['шум']):.5f} |") + for label, close in (("без близости", ()), ("близость ×2 сильнее", CLOSENESS_STRONG), ("предложенная", CLOSENESS)): + p = replace(PROPOSED, closeness=close) + print(f"| closeness | {label} | {brier(p, signal):.5f} | {rho10(p, signal):.3f} | {brier(p, ['шум']):.5f} |") + + +def main() -> None: + if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--seasons", type=int, default=200, help="сезонов в сравнении систем") + ap.add_argument("--grid", action="store_true", help="перебор K и весов") + ap.add_argument("--grid-seasons", type=int, default=40) + args = ap.parse_args() + print("# Примеры расчётов") + run_examples() + if args.grid: + grid(args.grid_seasons) + return + for scenario in SCENARIOS: + compare(args.seasons, scenario, PROPOSED) + + +if __name__ == "__main__": + main() From 648c10f2a72431307d4bbe9ee4f49f3e21801960 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 19:48:34 +0300 Subject: [PATCH 02/33] =?UTF-8?q?=D0=A0=D0=B5=D0=B9=D1=82=D0=B8=D0=BD?= =?UTF-8?q?=D0=B3:=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=20?= =?UTF-8?q?=D1=81=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=D0=BC=20=D0=BD=D0=BE=D0=B2=D0=BE=D0=B9=20=D1=81?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/rating/rating-system.md: - проблемы текущего League Points; - правила игры как исходные данные: тай-брейки, хоумрул 9 раундов, миры; - выбор модели среди альтернатив, формулы и коэффициенты; - трассировка требований 1–5 и пошаговые примеры; - результаты симуляции и анализ чувствительности; - последствия для реализации: поля, миграция, пересчёт истории, что ломается; - открытые вопросы. #22 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- docs/rating/rating-system.md | 693 +++++++++++++++++++++++++++++++++++ 1 file changed, 693 insertions(+) create mode 100644 docs/rating/rating-system.md diff --git a/docs/rating/rating-system.md b/docs/rating/rating-system.md new file mode 100644 index 0000000..e55e4e6 --- /dev/null +++ b/docs/rating/rating-system.md @@ -0,0 +1,693 @@ +# Новая рейтинговая система + +> **Статус:** предложение на утверждение (#22). Реализация — #23. +> **Приложение:** [`simulate.py`](simulate.py) — эталонная реализация формул, все примеры +> этого документа (с `assert`), симуляция и перебор коэффициентов. Только стандартная +> библиотека Python, вывод воспроизводим: +> +> ```bash +> python docs/rating/simulate.py # примеры + сравнение систем (≈20 с) +> python docs/rating/simulate.py --grid # примеры + подбор коэффициентов (≈5 мин) +> ``` + +## Коротко + +Сейчас рейтинг — среднее очков за место. Он не видит ни силы соперников, ни хода партии, +а первое место в дуэли и за столом на пятерых стоит одинаково. + +Предлагается **многопользовательский Elo с множителем отрыва**: + +- **Разбор на дуэли.** Партия раскладывается на пары игроков. Каждая пара — дуэль, исход + которой сравнивается с ожидаемым по разнице рейтингов: победа над сильным даёт больше, + чем над слабым. +- **Множитель отрыва.** Размер изменения зависит от того, *как* партия сыграна: на каком + раунде закончилась, какой отрыв по целям и мирам, какой тип победы. Размер стола тоже + влияет, но слабее темпа. +- **Шкала прежняя.** Старт 50, как в нынешнем топе. Пример из задачи «60 против 40» + ложится в неё буквально. +- **Монотонность.** Победитель никогда не теряет рейтинг, последнее место никогда не + приносит. +- **Старая история.** Пересчитывается по тем же формулам: у партий без новых полей + признаки берутся нейтральными. + +На синтетической лиге система лучше текущей и чистого Elo во всех сценариях, где отрыв +действительно связан с силой игроков. Например, после 10 партий она правильнее упорядочивает +игроков: ρ = 0.807 против 0.769 у текущей. Там, где отрыв — чистый шум, она уступает +чистому Elo около 0.1 п.п. точности. Подробности — в [разделе 7](#7-проверка-на-симуляции). + +## Содержание + +1. [Как считается сейчас и что с этим не так](#1-как-считается-сейчас-и-что-с-этим-не-так) +2. [Исходные данные: правила игры](#2-исходные-данные-правила-игры) +3. [Выбор модели](#3-выбор-модели) +4. [Формулы](#4-формулы) +5. [Требования → решения](#5-требования--решения) +6. [Примеры расчётов](#6-примеры-расчётов) +7. [Проверка на симуляции](#7-проверка-на-симуляции) +8. [Что потребуется в реализации (#23)](#8-что-потребуется-в-реализации-23) +9. [Открытые вопросы](#9-открытые-вопросы) + +--- + +## 1. Как считается сейчас и что с этим не так + +`backend/app/services/scoring.py`. За партию на N игроков участник получает League Points: + +```math +\text{points} = \frac{N - \text{place} - (\text{tie}-1)/2}{N-1} +``` + +1-е место — 1.0, последнее — 0.0; равные места делят очки. Рейтинг — сглаженное среднее +с 10 «виртуальными» партиями по 0.5, в топ попадают игроки от 10 партий: + +```math +\text{score} = \frac{10 \cdot 0.5 + \sum \text{points}}{10 + \text{games}} \cdot 100 +``` + +Проблемы на цифрах: + +| # | Проблема | Пример | +|---|---|---| +| 1 | **Сила соперника не учитывается.** | Победа над лидером топа и над новичком — одинаковые 1.0. Кто играет в основном со слабыми, копит рейтинг быстрее. В симуляции с тремя группами разной силы текущая система упорядочивает игроков с ρ = 0.525, предложенная — с 0.645 (раздел 7). | +| 2 | **Размер стола не учитывается.** | 1-е место в дуэли — 1.0, 1-е место за столом на пятерых — тоже 1.0, хотя обыграны четверо. | +| 3 | **Ход партии не учитывается.** | Разгром на 3-м раунде (2:0 по целям, 8:2 по мирам) и победа на тай-брейке по мирам в конце 8-го раунда — одинаковые 1.0. | +| 4 | **Рейтинг помнит всю историю с одним весом.** | Игрок, проигравший первые 10 дуэлей и выигравший следующие 10, имеет (5 + 10) / 30 · 100 = **50** — как середняк, хотя сейчас сильнее всех. В сценарии «рост» симуляции ρ после 10 партий у текущей системы 0.732, у предложенной 0.773. | + +## 2. Исходные данные: правила игры + +По справочнику (стр. 8, 11, 16) и уточнениям владельца в #22. + +**Победа.** Игрок, собравший маркеры целей в количестве, равном числу игроков N, побеждает. +Цели собираются в фазе обновления, поэтому партия заканчивается на границе раунда. +Если к концу последнего раунда никто не набрал N, побеждает тот, у кого больше целей. + +**Тай-брейки** (от менее близкой партии к более близкой) — это и есть типы победы +в приложении: + +| Тип (`win_reason`) | Когда | Близость партии | +|---|---|---| +| `objectives` — по целям | больше всех целей | обычная | +| `worlds` — по мирам | цели поровну, больше дружественных миров | близкая | +| `plastic` — по пластику | цели и миры поровну, больше отрядов на поле | очень близкая | +| `resources` — по ресурсам | всё выше поровну; **домашнее правило** | самая близкая | +| `last_standing` — последний выживший | все соперники устранены (нет миров); **предлагается добавить** | разгром | + +Если поровну вообще всё, победа общая (в приложении — ничья за 1-е место). + +**Лимит раундов `R_max`.** По правилам 8. **Домашнее правило:** за столом на 5–6 игроков +играется 9 раундов. Правило включается галочкой в настройках группы. + +**Миры.** В тексте #22 они названы «системами». По справочнику *система* — это целый тайл +из четырёх областей, а область с планетой — *мир*. Дальше используется термин справочника. +Размер поля и «честная доля» миров на игрока: + +| N | Поле | Тайлов | Миров на поле (×2.2) | Миров на игрока `W(N)/N` | +|---|---|---|---|---| +| 2 | 2×2 | 4 | 8.8 | 4.40 | +| 3 | 3×3 | 9 | 19.8 | 6.60 | +| 4 | 3×4 | 12 | 26.4 | 6.60 | +| 5 | 4×4 | 16 | 35.2 | 7.04 | +| 6 | 4×5 | 20 | 44.0 | 7.33 | + +## 3. Выбор модели + +Что нужно от модели: + +- учитывать силу соперников (треб. 3); +- работать для столов на 2–6 игроков с ничьими и выбывшими; +- принимать дополнительные признаки партии (треб. 1, 2, 4, 5); +- считаться вручную — чтобы игроки могли проверить, почему рейтинг изменился именно так; +- работать на малых данных: десятки игроков и сотни партий; +- обходиться без внешних зависимостей: вся реализация — пара десятков строк. + +| Модель | Сила соперника | 2–6 игроков | Признаки партии | Ручной расчёт | Вывод | +|---|---|---|---|---|---| +| League Points (сейчас) | нет | да | только место | да | не выполняет треб. 1–5 | +| Elo, парный | да | да, через пары | через множитель K | да | **база** | +| Glicko-2 | да, плюс неопределённость | только через пары, как Elo; нужны рейтинговые периоды | нет | тяжело | сложность без выигрыша на наших объёмах | +| TrueSkill / OpenSkill | да, плюс неопределённость | да, нативно | нет — только порядок мест | нет | не принимает отрыв без самодельных надстроек | + +Выбран **парный Elo с множителем отрыва**. Отрыв умножает K, а фактический результат пары +остаётся 1/0.5/0. Можно было бы вшить отрыв в сам результат (например, близкая победа = +0.6), но тогда фаворит, выигравший близко, *терял бы* рейтинг за победу. Множитель K +сохраняет монотонность: победа всегда в плюс, отрыв влияет только на размер. + +## 4. Формулы + +### 4.1. Шкала + +Стартовый рейтинг **R₀ = 50**, масштаб **D = 40**: разница 40 пунктов означает шансы 10:1. +Это классический Elo, делённый на 10 (`R_Elo = 10·R + 1000`): 1500 ↔ 50, 1600 ↔ 60. +Шкала совпадает по порядку величин с нынешним топом. + +### 4.2. Ожидаемый результат пары + +```math +E_{ab} = \frac{1}{1 + 10^{(R_b - R_a)/D}} +``` + +`E_ab` — вероятность, что `a` окажется выше `b`. При 60 против 40 — 0.760, при равных — 0.500. + +### 4.3. Фактический результат пары + +`S_ab = 1`, если `a` занял место выше `b`; `0.5`, если места равны; `0` — если ниже. +Выбывшие делят последнее место, как и сейчас (`match_service._resolve_finish_places`). + +### 4.4. Изменение рейтинга + +```math +\Delta R_i = \frac{K_i \cdot G(N)}{N-1} \sum_{j \ne i} M_{ij} \, (S_{ij} - E_{ij}) +``` + +Все рейтинги в формуле — **до** партии. Деление на `N − 1` приводит сумму по соперникам +к «средней дуэли», размер стола затем добавляется явно через `G(N)`. + +### 4.5. Коэффициент K — скорость изменения + +```math +K_i = K_{\min} + (K_{\max} - K_{\min}) \cdot \max\!\left(0,\; 1 - \frac{n_i}{n_K}\right) +``` + +`n_i` — сколько завершённых партий игрок сыграл до этой. Новичок стартует с `K_max = 6.4`, +к 20-й партии K линейно спускается до `K_min = 1.6`. Так новичок быстро находит свой +уровень, а рейтинг опытного игрока не скачет от одной партии. + +Эта схема заменяет нынешние «10 виртуальных партий». Минимум партий для топа +(`MIN_GAMES = 10`) сохраняется. К 10-й партии предложенная система упорядочивает игроков +лучше текущей: ρ 0.807 против 0.769 в сценарии «сигнал» (раздел 7). + +### 4.6. Вес размера стола (треб. 2) + +```math +G(N) = 1 + w_N \cdot \frac{N-2}{4}, \qquad w_N = 0.5 +``` + +`G` = 1.0 для дуэли, 1.25 для четверых, 1.5 для шестерых. + +### 4.7. Множитель отрыва пары + +Признаки пары (a — выше или наравне с b), каждый нормирован в [0, 1]: + +| Признак | Формула | Для каких пар | Типичное значение μ | +|---|---|---|---| +| темп `τ` (треб. 1) | `(R_max − раунд) / (R_max − 1)` | только пары с победителем | `1/(R_max − 1)` — конец в предпоследнем раунде | +| отрыв по целям `o` (треб. 4) | `max(0, цели_a − цели_b) / N` | все | 0.5 | +| отрыв по мирам `w` (треб. 4) | `min(1, max(0, миры_a − миры_b) / (W(N)/N))` | все | 0.5 | + +Для пар с равными местами разница берётся по модулю. При победе `last_standing` отрыв +победителя по целям считается равным 1: все соперники устранены, сколько бы маркеров +у них ни было. + +```math +A_{ab} = 1 + w_\tau (\tau - \mu_\tau) + w_o (o - 0.5) + w_w (w - 0.5) +``` + +```math +M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab} +``` + +- **Центрирование.** Благодаря вычитанию μ партия с типичными признаками получает `M = 1` — + то есть обычный Elo. Разгром поднимает множитель, близкая партия его снижает. +- **Ограничение.** `clamp` ставит страховку: одна партия не может весить больше чем вдвое + или меньше чем вдвое против обычной. +- **Близость по типу победы `c`** (треб. 5) действует только на пары с победителем: тип + победы описывает борьбу за первое место, а не за второе или третье. + +| `win_reason` | `objectives` | `worlds` | `plastic` | `resources` | `last_standing` | +|---|---|---|---|---|---| +| `c` | 1.00 | 0.85 | 0.70 | 0.60 | 1.00 | + +### 4.8. Партии без новых полей + +У партий из истории (и у любых, где поле не заполнено) нет раунда, целей или миров. +Недостающий признак подставляется **типичным значением μ**: его слагаемое в `A` равно нулю. +Тип победы в истории есть, поэтому близость `c` работает всегда. Для старой партии формула +сводится к чистому Elo с учётом размера стола и типа победы. Заполнять историю задним числом +не обязательно. + +### 4.9. Свойства + +- **Монотонность.** 1-е место без ничьей даёт `S − E > 0` во всех парах, а `M > 0` — значит, + рейтинг растёт. Последнее место без ничьей всегда уменьшает рейтинг. +- **Сумма-ноль.** `M_ab = M_ba`, поэтому при равных K сумма изменений за партию равна нулю + и рейтинг не раздувается. Когда K разные (новичок и ветеран), сумма не нулевая — + это сделано намеренно (пример 7). В симуляции среднее по лиге за 300 партий сдвигается + не больше чем на 0.1 пункта, в сценарии «рост» — на −0.7 при разбросе силы игроков ±14. +- **Ограниченность.** Изменение за партию не больше `K·G·2.0`: 3.2 у ветерана в дуэли, + 12.8 у новичка в дуэли, 19.2 у новичка за столом на шестерых. Это теоретические пределы + для разгромной победы, которой почти никто не ждал (`E ≈ 0`); против равных — вдвое меньше. +- **Детерминизм.** Рейтинг — функция упорядоченной истории партий. Пересчёт с нуля всегда + даёт тот же результат. + +### 4.10. Коэффициенты + +| Параметр | Значение | Откуда | +|---|---|---| +| `R₀`, `D` | 50, 40 | шкала (4.1) | +| `K_max`, `K_min`, `n_K` | 6.4, 1.6, 20 | перебор (7.4): выигрыш на «сигнале» без потерь на «шуме» | +| `w_N` (стол) | 0.5 | требование «слабее темпа»; в переборе 0.25–0.5 равноценны | +| `w_τ` (темп) | 1.0 | требование 1 («значительно ценнее»); в переборе безопасен до 1.0, вред — с 2.0 | +| `w_o` (цели) | 0.5 | требование 4; равен весу миров, пока нет данных, что один из признаков информативнее (7.4) | +| `w_w` (миры) | 0.5 | требование 4; в переборе 0.5 — лучший вес единственного признака отрыва | +| `c` (близость) | 1 / 0.85 / 0.7 / 0.6 / 1 | требование 5, экспертная оценка по порядку тай-брейков; симуляция не подтверждает и не опровергает (7.5) | +| `clamp` | [0.5, 2.0] | страховка от выбросов | + +## 5. Требования → решения + +| Требование из #22 | Механизм | Эффект (ветеран против равного, дуэль) | +|---|---|---| +| 1. Темп: 4 цели за 2 раунда ценнее, чем к концу 8-го | признак `τ`, вес 1.0 — самый большой из весов | победа на 3-м раунде +1.15, на 8-м +0.58 — **вдвое** (пример 2) | +| 2. Размер стола, но слабее темпа | `G(N)`, вес 0.5 | 1-е место: дуэль +0.80, пятеро +1.10, шестеро +1.20 — **до ×1.5**, меньше, чем ×2 у темпа (примеры 3, 6) | +| 3. Разница рейтингов с соперником | ожидание `E` | 60 побеждает 40: +0.38; 40 побеждает 60: +1.22 — **втрое** больше (пример 1) | +| 4. Цели и миры на конец партии | признаки `o`, `w` | стол на 4: пары с выбывшими весят 1.25–1.5, пара лидеров — 0.7 (пример 5) | +| 5. Тип победы | близость `c` | без деталей: по целям +0.80 → по мирам +0.68 → по пластику +0.56 → по ресурсам +0.48 (пример 4) | +| Веса параметров (из треб. 2) | единый множитель `M` с весами | весь диапазон по отрыву: от +0.34 (самая близкая партия) до +1.60 (разгром) — **×4.7** (пример 4) | + +## 6. Примеры расчётов + +Все игроки опытные (40 партий, `K = 1.6`), если не сказано иное. Числа совпадают +с выводом `simulate.py` — скрипт проверяет их через `assert`. Промежуточные значения +здесь округлены. + +### Пример 1. Дуэль 60 против 40 (треб. 3) + +Партия из старой истории: только места и тип `objectives`, так что `M = 1`. + +- `E(A выше B) = 1 / (1 + 10^(−20/40)) = 1 / (1 + 0.316) = 0.760`. +- **1a. Побеждает сильный A:** `ΔR_A = 1.6 · 1 · (1 − 0.760) = +0.384`, у B −0.384. +- **1b. Побеждает слабый B:** `ΔR_B = 1.6 · 1 · (1 − 0.240) = +1.216`, у A −1.216. + +Неожиданная победа приносит втрое больше ожидаемой. + +### Пример 2. Быстрая и медленная победа (треб. 1) + +Равные (50 и 50), дуэль, победа по целям 2:1, миры 5:4. Разница — только раунд. + +| | Раунд 3 (2a) | Раунд 8 (2b) | +|---|---|---| +| темп `τ = (8 − r)/7` | 0.714 | 0.000 | +| `w_τ(τ − 1/7)` | +0.571 | −0.143 | +| цели `o = 1/2` → `0.5·(0.5 − 0.5)` | 0 | 0 | +| миры `w = 1/4.4 = 0.227` → `0.5·(0.227 − 0.5)` | −0.136 | −0.136 | +| `A = M` | 1.435 | 0.721 | +| `ΔR_A = 1.6 · M · (1 − 0.5)` | **+1.148** | **+0.577** | + +### Пример 3. Размер стола (треб. 2) + +Все по 50, партии без деталей (`M = 1`). + +- **3a. Дуэль:** `ΔR_1 = 1.6 · 1 · 0.5 = +0.800`. +- **3b. Стол на 5:** `G(5) = 1 + 0.5·3/4 = 1.375`, множитель перед суммой — `1.6·1.375/4 = 0.55`. + +| Место | Σ (S − E) по 4 соперникам | ΔR | Сейчас (League Points) | +|---|---|---|---| +| 1 | 4·0.5 = 2.0 | **+1.100** | 1.00 | +| 2 | −0.5 + 3·0.5 = 1.0 | +0.550 | 0.75 | +| 3 | 0 | 0.000 | 0.50 | +| 4 | −1.0 | −0.550 | 0.25 | +| 5 | −2.0 | −1.100 | 0.00 | + +### Пример 4. Тип победы и близость партии (треб. 5) + +Равные, дуэль. + +| Вариант | τ | o | w | A | c | M | ΔR победителя | +|---|---|---|---|---|---|---|---| +| по целям, без деталей (= 3a) | μ | μ | μ | 1.000 | 1.00 | 1.000 | +0.800 | +| 4a: по мирам, без деталей | μ | μ | μ | 1.000 | 0.85 | 0.850 | +0.680 | +| 4b: по пластику, без деталей | μ | μ | μ | 1.000 | 0.70 | 0.700 | +0.560 | +| 4c: по ресурсам, без деталей | μ | μ | μ | 1.000 | 0.60 | 0.600 | +0.480 | +| 4d: по мирам на 8-м раунде, цели 2:2, миры 6:5 | 0 | 0 | 0.227 | 0.471 → **0.5** | 0.85 | 0.425 | **+0.340** | +| 4e: разгром — 3-й раунд, цели 2:0, миры 8:2 | 0.714 | 1 | 1 | 2.071 → **2.0** | 1.00 | 2.000 | **+1.600** | + +В 4d и 4e сработала страховка `clamp`. + +### Пример 5. Стол на 4 с выбывшими (треб. 4) + +Партия закончилась на 7-м раунде по целям. `G(4) = 1.25`, множитель перед суммой — +`1.6·1.25/3 = 0.667`, честная доля миров — 6.6. + +| Игрок | Рейтинг | Место | Цели | Миры | +|---|---|---|---|---| +| A | 55 | 1 | 4 | 8 | +| B | 50 | 2 | 3 | 7 | +| C | 48 | 3 (выбыл) | 1 | 0 | +| D | 45 | 3 (выбыл) | 0 | 0 | + +Темп `τ = 1/7` совпадает с типичным, его слагаемое равно 0. + +| Пара | S | E | o | w | A = M | M·(S − E) | +|---|---|---|---|---|---|---| +| A–B | 1 | 0.571 | 1/4 | 1/6.6 = 0.152 | 1 − 0.125 − 0.174 = **0.701** | 0.300 | +| A–C | 1 | 0.599 | 3/4 | 1 | 1 + 0.125 + 0.25 = **1.375** | 0.551 | +| A–D | 1 | 0.640 | 1 | 1 | 1 + 0.25 + 0.25 = **1.500** | 0.540 | +| B–C | 1 | 0.529 | 2/4 | 1 | 1 + 0 + 0.25 = **1.250** | 0.589 | +| B–D | 1 | 0.571 | 3/4 | 1 | **1.375** | 0.589 | +| C–D | 0.5 | 0.543 | 1/4 (по модулю) | 0 | 1 − 0.125 − 0.25 = **0.625** | −0.027 | + +- `ΔR_A = 0.667 · (0.300 + 0.551 + 0.540)` = **+0.927** +- `ΔR_B = 0.667 · (−0.300 + 0.589 + 0.589)` = **+0.585** +- `ΔR_C = 0.667 · (−0.551 − 0.589 − 0.027)` = **−0.778** +- `ΔR_D = 0.667 · (−0.540 − 0.589 + 0.027)` = **−0.735** + +Итого: A и B близки друг к другу по целям и мирам, поэтому эта пара весит 0.7. Отрыв обоих +от выбывших огромный — эти пары весят 1.25–1.5. C и D поделили место, но C сильнее по +рейтингу, поэтому немного уступает D. + +### Пример 6. Стол на 6 и хоумрул 9 раундов + +Все по 50, партия закончилась **на 8-м раунде**, других деталей нет. +`G(6) = 1.5`, множитель перед суммой — `1.6·1.5/5 = 0.48`. + +| | 6a: хоумрул включён, `R_max = 9` | 6b: хоумрул выключен, `R_max = 8` | +|---|---|---| +| темп `τ` | (9 − 8)/8 = 0.125 | (8 − 8)/7 = 0 | +| типичный `μ_τ` | 1/8 = 0.125 | 1/7 = 0.143 | +| `M` пар с победителем | 1.000 — обычная партия | 0.857 — затянутая | +| ΔR по местам 1…6 | +1.200, +0.720, +0.240, −0.240, −0.720, −1.200 | +1.029, +0.754, +0.274, −0.206, −0.686, −1.166 | + +Конец на 8-м раунде при лимите 9 — это «предпоследний раунд», то есть типичная партия. +При лимите 8 — затянутая партия, и победа весит меньше. Поэтому лимит раундов снимается +в партию при её создании (раздел 8). + +### Пример 7. Новичок против ветерана + +Оба по 50; A — новичок (0 партий, `K = 6.4`), B — 40 партий (`K = 1.6`). A побеждает. + +`ΔR_A = 6.4 · 0.5` = **+3.2**, `ΔR_B = 1.6 · (−0.5)` = **−0.8**. + +О силе новичка ещё ничего не известно, поэтому его рейтинг двигается быстро. Ветеран +теряет как за обычное поражение от равного. + +## 7. Проверка на симуляции + +### 7.1. Модель лиги + +У настоящих партий пока нет раундов, целей и миров, поэтому система проверяется на +синтетической лиге, где «истинная» сила игроков известна. + +| Параметр | Значение | +|---|---| +| Игроки | 12 со старта, ещё по 2 на 1/3 и 2/3 сезона; активность у каждого своя (0.5–1.5) | +| Сезон | 300 партий; хоумрул 9 раундов включён в половине сезонов | +| Размер стола | 2 — 45%, 3 — 25%, 4 — 20%, 5 — 7%, 6 — 3% | +| Сила | `θ ~ N(0, 150)`; в шкале рейтинга ≈ ±14 | +| Производительность в партии | `θ + N(0, 225)` — кубы, карты, ошибки; места — по производительности | +| Детали партии | из отрыва производительности: раунд (чем больше отрыв, тем раньше конец), цели, миры, тип победы, выбывание. Получается 81% побед по целям, 10% по мирам, 4% по пластику, 1.4% по ресурсам, 3% последний выживший; чаще всего конец на 7–8 раунде | + +Сценарии: + +| Сценарий | Что проверяет | +|---|---| +| **сигнал** | базовый: отрыв связан с разницей сил | +| **шум** | места те же, но величина отрыва случайна и с силой не связана — сколько система теряет, если признаки ничего не говорят | +| **клубы** | три группы разной силы (−150 / 0 / +150), 90% партий внутри своей — умеет ли общий рейтинг «сшить» группы | +| **рост** | новички стартуют слабее на 0–200 и догоняют с опытом (×1/e за 15 партий) — успевает ли рейтинг за ростом игрока | + +### 7.2. Метрики + +- **Точность** — доля пар без ничьих во второй половине сезона, где *до* партии рейтинг + выше у занявшего место выше. Потолок — тот же прогноз по истинной силе. +- **Brier** — `(1 − E)²` по тем же парам, меньше — лучше. Показывает, насколько честны + сами вероятности; есть только у Elo-систем. +- **ρ** — ранговая корреляция Спирмена рейтинга на конец сезона с истинной силой + (игроки с 10+ партиями). +- **ρ@k** — то же сразу после k-й партии игрока: как быстро рейтинг «находит» игрока. +- **RMSE** — ошибка рейтинга относительно истинной силы в пунктах шкалы. +- **Наклон** — регрессия рейтинга на истинную силу: 1.0 — разброс честный, меньше — + рейтинги сжаты к середине. +- **|ΔR|** — средний модуль изменения за партию у игроков с 20+ партиями (волатильность). + +Сравнение идёт на одних и тех же сезонах (парные разности). Коэффициенты подбирались +на других сезонах (7.4), так что это проверка вне выборки подбора. + +### 7.3. Результаты: 200 сезонов на сценарий + +«Без новых полей» — предложенная система на той же истории, но без раунда, целей и миров: +так будет считаться история, накопленная до #23. + +**Сигнал** (потолок точности 0.6843) + +| Система | Точность | Brier | ρ | ρ@5 | ρ@10 | ρ@20 | RMSE | Наклон | \|ΔR\| | +|---|---|---|---|---|---|---|---|---|---| +| League Points (сейчас) | 0.6683 | — | 0.913 | 0.660 | 0.769 | 0.854 | — | — | 0.69 | +| Elo, чистый | 0.6691 | 0.2089 | 0.911 | 0.656 | 0.770 | 0.852 | 4.74 | 0.81 | 0.52 | +| **Предложенная** | **0.6723** | **0.2077** | **0.928** | **0.711** | **0.807** | **0.881** | **4.19** | **0.95** | 0.60 | +| Предложенная, без новых полей | 0.6701 | 0.2085 | 0.916 | 0.677 | 0.782 | 0.864 | 4.74 | 0.79 | 0.59 | + +**Клубы** (потолок 0.6308) + +| Система | Точность | Brier | ρ | ρ@5 | ρ@10 | ρ@20 | RMSE | Наклон | \|ΔR\| | +|---|---|---|---|---|---|---|---|---|---| +| League Points (сейчас) | 0.6045 | — | 0.525 | 0.303 | 0.391 | 0.463 | — | — | 0.81 | +| Elo, чистый | 0.6063 | 0.2341 | 0.632 | 0.326 | 0.440 | 0.535 | 10.26 | 0.40 | 0.57 | +| **Предложенная** | **0.6104** | 0.2336 | **0.645** | **0.354** | **0.462** | **0.554** | **10.03** | **0.47** | 0.65 | +| Предложенная, без новых полей | 0.6074 | **0.2332** | 0.633 | 0.336 | 0.440 | 0.534 | 10.33 | 0.38 | 0.64 | + +**Рост** (потолок 0.6864) + +| Система | Точность | Brier | ρ | ρ@5 | ρ@10 | ρ@20 | RMSE | Наклон | \|ΔR\| | +|---|---|---|---|---|---|---|---|---|---| +| League Points (сейчас) | 0.6688 | — | 0.915 | 0.631 | 0.732 | 0.824 | — | — | 0.69 | +| Elo, чистый | 0.6687 | 0.2086 | 0.916 | 0.632 | 0.733 | 0.825 | 4.81 | 0.81 | 0.53 | +| **Предложенная** | **0.6725** | **0.2076** | **0.928** | **0.685** | **0.773** | **0.850** | **4.27** | **0.95** | 0.60 | +| Предложенная, без новых полей | 0.6697 | 0.2081 | 0.922 | 0.651 | 0.750 | 0.835 | 4.76 | 0.79 | 0.59 | + +**Шум** (потолок 0.6894) + +| Система | Точность | Brier | ρ | ρ@5 | ρ@10 | ρ@20 | RMSE | Наклон | \|ΔR\| | +|---|---|---|---|---|---|---|---|---|---| +| League Points (сейчас) | 0.6737 | — | 0.916 | 0.656 | 0.761 | 0.848 | — | — | 0.69 | +| Elo, чистый | 0.6739 | **0.2070** | 0.917 | 0.659 | 0.765 | 0.851 | **4.61** | **0.82** | 0.52 | +| Предложенная | 0.6727 | 0.2076 | 0.908 | 0.641 | 0.751 | 0.844 | 4.85 | 0.81 | 0.59 | +| Предложенная, без новых полей | **0.6743** | **0.2070** | **0.919** | **0.667** | **0.775** | **0.856** | 4.74 | 0.78 | 0.59 | + +Парные разности (среднее ± стандартная ошибка по 200 сезонам): + +| Сценарий | Brier: предложенная − чистый Elo | Точность: предложенная − чистый Elo | Точность: предложенная − сейчас | +|---|---|---|---| +| сигнал | −0.0011 ± 0.0001 | +0.33 ± 0.06 п.п. | +0.40 ± 0.07 п.п. | +| клубы | −0.0005 ± 0.0002 | +0.40 ± 0.08 п.п. | +0.59 ± 0.10 п.п. | +| рост | −0.0010 ± 0.0001 | +0.38 ± 0.07 п.п. | +0.37 ± 0.06 п.п. | +| шум | +0.0006 ± 0.0001 | −0.13 ± 0.07 п.п. | −0.11 ± 0.07 п.п. | + +Выводы: + +1. **Если отрыв отражает силу** (а требования #22 исходят именно из этого), предложенная + система лучше обеих альтернатив по всем метрикам качества. Сильнее всего она выигрывает + в скорости: после 5 партий ρ = 0.71 против 0.66 у текущей, после 10 — 0.81 против 0.77. + Рейтинг меньше сжат к середине (наклон 0.95 против 0.81): сильные игроки быстрее + отрываются от середняков. +2. **Сила соперников — главное преимущество Elo над текущей системой.** В «клубах» текущая + система упорядочивает игроков заметно хуже (ρ 0.525 против 0.645): чемпион слабой группы + у неё стоит рядом с чемпионом сильной. +3. **Если отрыв — шум**, предложенная система теряет 0.1 п.п. точности и 0.0006 Brier — + цена лишней волатильности. Это худший из рассмотренных случаев: в остальных сценариях + она чистому Elo не уступает. +4. **История без новых полей** считается как чистый Elo с учётом стола и типа победы. + По точности и Brier она не хуже чистого Elo ни в одном сценарии. +5. **Абсолютные разности точности малы** (доли процента), потому что партия Forbidden Stars + сама по себе сильно случайна: даже знание истинной силы угадывает порядок пары лишь + в 68% случаев. Все системы близки к этому потолку. Качество рейтинга лучше видно + по ρ@k и RMSE, чем по точности. + +### 7.4. Подбор коэффициентов + +`simulate.py --grid` перебирает коэффициенты на **других** 40 сезонах каждого сценария. +Критерий — средний Brier, меньше — лучше. Разница в 0.0001 — примерно граница шума. + +**Этап 1. K чистого Elo** (все четыре сценария). Спуск K за 20 партий лучше, чем за 10. +Выгоден высокий K новичка и низкий K ветерана. + +| `K_max` \ `K_min` (спуск за 20 партий) | 1.6 | 2.4 | 3.2 | +|---|---|---|---| +| 4.8 | 0.21697 | 0.21708 | 0.21775 | +| 6.4 | 0.21649 | 0.21675 | 0.21751 | +| 9.6 | **0.21639** | 0.21669 | 0.21747 | +| 12.8 | 0.21682 | 0.21703 | 0.21775 | +| 16.0 | 0.21748 | 0.21755 | 0.21819 | + +Лучший вариант со спуском за 10 партий — 0.21676 (9.6 / 2.4). Для «чистого Elo» +в сравнении 7.3 взяты K = 9.6 / 1.6 / 20. + +**Этап 2. Веса отрыва** при K этапа 1: 162 комбинации (`w_N` ∈ {0, 0.25, 0.5}; +`w_τ`, `w_o`, `w_w` ∈ {0, 0.5, 1}; близость да/нет); сценарии «сигнал», «клубы», «рост». + +Без множителя (чистый Elo с K этапа 1): Brier **0.21846**, на «шуме» **0.21017**. +Лучшие комбинации: + +| Brier | Brier «шум» | `w_N` | `w_τ` | `w_o` | `w_w` | близость | +|---|---|---|---|---|---|---| +| 0.21801 | 0.20992 | 0.25 | 0 | 0 | 0.5 | да | +| 0.21804 | 0.20986 | 0.5 | 0 | 0 | 0.5 | да | +| 0.21807 | 0.21010 | 0 | 0 | 0 | 0.5 | да | +| 0.21807 | 0.20992 | 0.5 | 0.5 | 0 | 0 | да | +| 0.21807 | 0.21005 | 0.25 | 0.5 | 0 | 0.5 | да | +| … | | | | | | | +| 0.22000 | | | | | | худшая комбинация | + +Лучшие комбинации выигрывают у отсутствия множителя около 0.0005, худшая проигрывает +0.0015. Оптимум очень пологий: первые десять вариантов умещаются в 0.0001. + +Перебор оставляет **один** признак отрыва из трёх. Это ожидаемо: в генераторе раунд, цели +и миры выводятся из одного и того же отрыва производительности, второй признак не добавляет +информации и лишь увеличивает разброс. +Реальная игра так не устроена: в ней ранний конец, счёт целей и контроль миров — разные +стороны партии. Поэтому веса признаков заданы требованиями #22 в пределах безопасной +зоны (этап 4), а не взяты из вершины перебора. + +**Этап 3. K для предложенных весов.** Множитель в среднем чуть больше 1, поэтому K нужен +меньше, чем у чистого Elo. Спуск за 20 партий: + +| `K_max` | `K_min` | Brier (сигнальные) | Brier «шум» | Brier с поправкой на автокорреляцию | +|---|---|---|---|---| +| 4.8 | 1.2 | **0.21760** | 0.21063 | 0.21757 | +| 4.8 | 1.6 | 0.21780 | 0.21049 | 0.21775 | +| 6.4 | 1.2 | 0.21781 | 0.21027 | 0.21773 | +| **6.4** | **1.6** | 0.21799 | **0.21022** | 0.21790 | +| 8.0 | 1.6 | 0.21840 | 0.21028 | 0.21828 | + +- **Выбор 6.4 / 1.6.** Против чистого Elo он даёт −0.00047 на сигнальных сценариях и + +0.00005 на «шуме». Вариант 4.8 / 1.2 выигрывает больше (−0.00086), но на «шуме» + проигрывает +0.00046. Выбран вариант, который не теряет, если гипотеза ТЗ о значении + отрыва не подтвердится. +- **Поправка на автокорреляцию** (FiveThirtyEight: фаворит закономерно побеждает с большим + отрывом, и без поправки его рейтинг раздувается) даёт около 0.0001. В формулу она не + включена: лишняя сложность для ручного расчёта при нулевом эффекте. + +**Этап 4. Чувствительность.** Меняется один параметр, остальные — как в предложении +(Brier 0.21799, «шум» 0.21022). ρ@10 здесь — среднее по трём сценариям, включая «клубы», +поэтому оно ниже, чем в 7.3. + +| Параметр | Значение | Brier (сигнальные) | ρ@10 | Brier «шум» | +|---|---|---|---|---| +| `w_N` | 0 / 0.25 / **0.5** / 1.0 | 0.21805 / 0.21798 / **0.21799** / 0.21821 | 0.663 / 0.668 / **0.673** / 0.679 | 0.21072 / 0.21041 / **0.21022** / 0.21011 | +| `w_τ` | 0 / 0.25 / 0.5 / **1.0** / 2.0 | 0.21774 / 0.21778 / 0.21783 / **0.21799** / 0.21828 | 0.671 / 0.670 / 0.672 / **0.673** / 0.665 | 0.20989 / 0.20995 / 0.21003 / **0.21022** / 0.21050 | +| `w_o` | 0 / 0.25 / **0.5** / 1.0 / 2.0 | 0.21770 / 0.21780 / **0.21799** / 0.21841 / 0.21894 | 0.667 / 0.670 / **0.673** / 0.672 / 0.671 | 0.21003 / 0.21010 / **0.21022** / 0.21042 / 0.21073 | +| `w_w` | 0 / 0.25 / **0.5** / 1.0 / 2.0 | 0.21781 / 0.21786 / **0.21799** / 0.21832 / 0.21883 | 0.667 / 0.670 / **0.673** / 0.672 / 0.668 | 0.21003 / 0.21011 / **0.21022** / 0.21038 / 0.21059 | +| `c` | нет / **предложенная** / вдвое сильнее | 0.21801 / **0.21799** / 0.21801 | 0.674 / **0.673** / 0.671 | 0.21016 / **0.21022** / 0.21031 | + +Итог: + +- Веса в диапазоне 0–1 безопасны: изменение вдвое сдвигает Brier не больше чем на 0.00042 + (`w_o` 0.5 → 1.0). Вред начинается с 2.0 — поэтому ни один вес не выше 1. +- Вес размера стола полезен: от 0 до 0.5 растёт ρ@10 и падает Brier на «шуме». +- Близость по типу победы в симуляции нейтральна (±0.00002). + +### 7.5. Что симуляция доказывает и что нет + +- **Доказывает:** формулы корректны и устойчивы, не раздувают рейтинг, быстро сходятся, + правильно «сшивают» группы разной силы. Выбранные веса лежат в пологой области: изменение + любого веса вдвое в любую сторону сдвигает Brier не больше чем на 0.00042. Если признаки + партии окажутся бесполезны, потеря мала. +- **Не доказывает:** + - что в *реальных* партиях Forbidden Stars ранний конец, отрыв по целям и мирам связаны + с разницей сил так, как заложено в генераторе. Это допущение, на котором стоит и само + ТЗ; + - какой из признаков отрыва (раунд, цели, миры) информативнее: в генераторе все три + выводятся из одного отрыва и дублируют друг друга. + + Проверить это можно только на реальных данных после внедрения #23 (раздел 8, + «Калибровка»). +- **Коэффициенты близости `c`** симуляция не подтверждает и не опровергает: при записанных + целях и мирах тип победы почти не добавляет информации. Значения — экспертная оценка + по порядку тай-брейков. Их главная роль — старые партии, где тип победы — единственный + признак хода игры. + +## 8. Что потребуется в реализации (#23) + +Изменение **ломающее** (`Compat/Breaking`): у всех игроков меняются числа рейтинга и, +вероятно, порядок в топе. + +### Данные (миграция `0014_*`) + +| Где | Поле | Тип | Смысл | +|---|---|---|---| +| `groups` | `nine_rounds_rule` | bool, default false | галочка «9 раундов при 5–6 игроках» | +| `matches` | `nine_rounds_rule` | bool, default false | **снимок** настройки группы при создании партии: смена настройки не должна переписывать историю (пример 6) | +| `matches` | `end_round` | int NULL, `1 ≤ end_round ≤ R_max` | раунд, в котором партия закончилась | +| `match_participants` | `objectives` | int NULL, ≥ 0 | маркеры целей на конец партии | +| `match_participants` | `worlds` | int NULL, ≥ 0 | дружественные миры на конец партии; у выбывшего 0 | +| `matches.win_reason` | + `last_standing` | CHECK | новая причина победы | + +`R_max` в партии не хранится, а вычисляется: `9`, если `nine_rounds_rule` и `N ≥ 5`, иначе `8`. +Число участников может поменяться при правке партии, а снимок правила — нет. + +Миграция идемпотентна, как `0003`: ALTER только при отсутствии столбца, `render_as_batch` +для CHECK. **Бэкфилл не нужен:** NULL — это «нет данных», и формулы его учитывают (4.8). + +### Ввод + +- Форма завершения (`MatchDetailPage`, `match_service.finish_match`, черновик + `MatchFinishDraft`) и админская правка (`AdminMatchEdit`): раунд окончания и у каждого + участника цели и миры. Поля необязательные — пропуск лучше выдумки. +- Серверная валидация — только диапазоны и явные противоречия: у выбывшего миры = 0; + раунд ≤ `R_max`. Подсказки о согласованности (тип `worlds` при неравных целях лидеров + и т.п.) лучше показывать предупреждением, а не отказом. +- Настройки группы: галочка рядом с дополнениями (`PUT /groups/{id}/expansions` или + отдельный `PATCH`). + +### Расчёт + +- Рейтинг — функция упорядоченной истории, поэтому он **пересчитывается проигрыванием** + завершённых партий по порядку (`played_at`, `finished_at`, `id`), а не агрегатом SQL. + Данных мало: сотни партий, микросекунды на пару. Существующий принцип «считается вживую» + сохраняется, кэш можно ввести позже с инвалидацией по уже существующим SSE-событиям. +- **Две цепочки:** общий рейтинг — по всем партиям приложения, групповой — по партиям + группы. Это разные числа, как и сейчас. +- Правка или удаление прошлой партии автоматически меняет всё после неё: при пересчёте + с нуля отдельной логики не нужно. +- Эталон — `rate_match` в `simulate.py`. Примеры из раздела 6 стоит перенести в тесты + бэкенда как есть. +- Смежные метрики на старых очках места: + - «лучшая партия» в профиле (`user_match_list(best_only)`) → партия с наибольшим ΔR; + - «лучшая/худшая фракция» → средний `S − E` на фракции: насколько игрок на ней + выступает выше ожидания, без привязки к рейтингу; + - `win_rate`, `avg_place`, «форма» — без изменений. + +### Что ломается для пользователей + +- Числа рейтинга у всех меняются. Шкала та же (около 50), но это другая величина: не + «средний процент очков», а сила относительно соперников. +- Порядок в топе может измениться — в первую очередь у тех, кто играл в основном со слабыми + или сильными соперниками. +- Рейтинг новичка после одной партии меняется заметно сильнее, чем раньше: +3.2 за обычную + победу над равным, до +6.4 за разгром (теоретический предел — 12.8). +- **Предложение:** разовое уведомление всем игрокам и короткое пояснение «как считается + рейтинг» на странице топа. + +### Калибровка после внедрения + +Когда наберётся ~100 партий с заполненными раундом, целями и мирами: + +- типичные значения `μ` заменить средними по реальным партиям; +- повторить перебор весов из `simulate.py` на реальной истории: критерий — Brier прогноза + следующей партии; +- проверить главное допущение: есть ли у ранних побед и большого отрыва связь с последующими + результатами игроков. + +Коэффициенты — константы в одном модуле (как сейчас `scoring.py`): калибровка — это правка +констант и пересчёт, без миграций. + +### CLAUDE.md + +Раздел «Статистика» уже расходится с кодом: там `MIN_GAMES=3` и формула без сглаживания. +При реализации #23 его нужно переписать под новую систему. + +## 9. Открытые вопросы + +На утверждение владельцу: + +1. **Шкала отображения.** Предлагается «Elo/10»: старт 50, как сейчас. Альтернатива — + классические 1500 (`10·R + 1000`); она привычнее шахматистам, но ломает визуальную + преемственность топа. Формулы от выбора не зависят. +2. **Коэффициенты близости** по типам победы (1 / 0.85 / 0.7 / 0.6 / 1) — экспертная оценка + (7.5). Согласны ли с порядком и шагом? +3. **Новая причина победы `last_standing`** (все соперники устранены) — добавлять? + Сейчас такая партия записывается как `objectives`. +4. **Ввод миров на конец партии** — самое затратное новое поле: миры нужно пересчитать на + поле. Если это неудобно за столом, можно оставить только раунд и цели: вес миров перейдёт + к целям (перебор показывает, что система остаётся работоспособной и без `w_w`). +5. **Затухание за неактивность** (рейтинг долго не игравшего игрока) в предложение не входит. + Нужно ли оно? +6. **Минимум партий для топа.** Сохраняется 10 — нужно ли менять? From 63fd90e0178d3cc09f466b5cbfce2e37a8790d8a Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 14 Sep 2026 20:06:54 +0300 Subject: [PATCH 03/33] =?UTF-8?q?=D0=94=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F:=20=D1=81=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20README=20=D0=B8=20deploy/*.md=20=D1=81=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=B4=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README: test без порта на хосте (только через домен, Secure-cookie), прод из реестра (build-push + docker compose up -d), лимиты перебора и регистраций, dev_admin.py в списке dev-кода, структура репозитория, раздел о бэкапах, отличия test от prod; отмечены известные проблемы (#69, #71, #72, #73). - deploy/README.md: три сервиса (app + tunnel + backup), источники ключа туннеля для Pi, test и dev-туннеля, слот 9000 у временного прода. - deploy/pi/README.md: контейнер backup, fail-fast по секретам, ADMIN_PASSWORD только при первом создании админа (#73), порядок обновления. - deploy/vps/README.md: туннель-контейнер вместо autossh, сниппет (edge), единые имена файлов в примере сборки сертификатов, дописывать authorized_keys через >>. - deploy/backup/README.md: первый бэкап на новом Pi, выбор снимка с данными при восстановлении (#74), метка keep, --no-pre-restore, служебные команды, причины unhealthy. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd --- README.md | 171 +++++++++++++++++++++++++++------------- deploy/README.md | 40 ++++++---- deploy/backup/README.md | 45 +++++++---- deploy/pi/README.md | 25 ++++-- deploy/vps/README.md | 23 +++--- 5 files changed, 206 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index 74c4acd..ba30290 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,28 @@ # Forbidden Stars — учёт партий Мобильное веб-приложение для учёта партий настольной игры **Forbidden Stars**: -профили игроков (вход по логину и паролю или через Telegram), группы, создание партий -с рандомом фракций и фильтром по дополнениям группы, статистика и общий топ, админ-панель. +профили игроков (вход по логину и паролю или через Telegram), группы с приглашениями, +создание партий с рандомом фракций и фильтром по дополнениям группы, фото партий, +уведомления, статистика и общий топ, админ-панель. - **Бэкенд / ядро + API:** Python · FastAPI · SQLModel · SQLite -- **Фронтенд:** React · Vite · TypeScript (SPA, общается с ядром только по REST API) -- **Хостинг:** Raspberry Pi 4 (ARM64) в Docker +- **Фронтенд:** React · Vite · TypeScript (SPA; с ядром общается по REST API, живые + обновления приходят SSE-потоком `/api/events`) +- **Хостинг:** Raspberry Pi 4 (ARM64) в Docker; наружу — через VPS-привратник (см. «Домен и публикация») ## Структура ``` -backend/ FastAPI: ядро, REST API, БД, миграции, seed -frontend/ React + Vite SPA -Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI) -docker-compose.yml -.env.example +backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты +frontend/ React + Vite SPA +deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы) +scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК), export-*.sh +Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI) +docker-compose.yml прод на Pi: app + tunnel + backup +docker-compose.test.yml тест-клон прода на ПК: app + tunnel + backup +docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel +run.ps1 / run.sh единый лаунчер dev/test +.env.example шаблон единого .env ``` ## Локальная разработка @@ -31,9 +38,9 @@ docker-compose.yml | `APP_ENV` в `.env` | что делает лаунчер | |---|---| -| `development` | `uvicorn --reload` (бэк) + `vite` (фронт) нативно, в двух окнах | -| `test` | `docker compose` прод-клон на :8080 (со сборкой образа) | -| `production` | не запускает — прод деплоится отдельно (см. «Git и деплой») | +| `development` | сначала `alembic upgrade head`, затем `uvicorn --reload` (бэк) + `vite` (фронт) нативно: `run.ps1` — в отдельных окнах, `run.sh` — в текущем терминале (Ctrl+C останавливает оба). При `LOCAL_PUBLIC=vps` дополнительно поднимает SSH-туннель на `forbidden-stars.ru` | +| `test` | `docker compose -f docker-compose.test.yml up --build -d` — прод-клон (app + tunnel + backup); портов на хост нет, открывается на `https://forbidden-stars.ru` | +| `production` | не запускает — прод деплоится отдельно (см. «Production» и «Git и деплой») | Разовая настройка перед первым запуском — поднять venv бэка и зависимости фронта (после неё повседневный цикл — просто `.\run.ps1`). Vite проксирует `/api` на бэкенд. @@ -47,8 +54,8 @@ python -m venv .venv .\.venv\Scripts\Activate.ps1 # если ругается на политику: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned pip install -e ".[dev]" # .[dev] — один аргумент (пакет + dev-зависимости) Copy-Item ..\.env.example ..\.env # ЕДИНЫЙ .env лежит в КОРНЕ репозитория -alembic upgrade head # применит миграции и сидинг -python -m app.bootstrap # создаст/синхронизирует администратора из .env +alembic upgrade head # применит миграции и сидинг справочников +python -m app.bootstrap # справочники + создаст/синхронизирует администратора из .env (опционально) uvicorn app.main:app --reload --timeout-graceful-shutdown 2 # http://localhost:8000 (Swagger: /api/docs) ``` @@ -75,6 +82,10 @@ python -m app.bootstrap uvicorn app.main:app --reload --timeout-graceful-shutdown 2 ``` +> В dev тот же bootstrap (справочники + админ из `.env`) выполняется сам при старте uvicorn, +> поэтому `python -m app.bootstrap` вручную обычно не нужен. Dev-БД, загрузки и ачивки +> лежат в `backend/data/dev/` (пути в `.env` относительные — запускайте из `backend/`). + > **`--timeout-graceful-shutdown` обязателен.** Открытая вкладка держит SSE-поток > `/api/events`, и без лимита `--reload` ждёт его закрытия вечно — сайт висит на загрузке, > а в логе только `Reloading...`. @@ -96,35 +107,57 @@ npm run dev # http://127.0.0.1:5173 или http://localhost:5 ## Production (Docker на Pi) +На Pi нужны только **два файла** — `docker-compose.yml` и `.env`: образы `app`, `tunnel` и +`backup` собираются на ПК под arm64 и пушатся в Gitea-реестр, Pi тянет их сам. + +```powershell +# ПК (обычно с ветки main): собрать и опубликовать образы +docker login gitea.arseniev.info +.\scripts\build-push.ps1 +``` ```bash -cp .env.example .env # заполните SECRET_KEY, ADMIN_PASSWORD и пр. -docker compose build # на ARM64 собирается нативно -docker compose up -d # приложение на :8000, БД на томе +# Pi, папка с docker-compose.yml и .env +docker compose up -d # pull_policy: always — тянет свежие образы, без сборки ``` -FastAPI отдаёт собранный SPA и API с одного origin. Миграции и бутстрап админа -выполняются автоматически при старте (`entrypoint.sh`). +Портов на хост нет — прод доступен только на `https://forbiddenstars.ru` через +туннель-контейнер. FastAPI отдаёт собранный SPA и API с одного origin. Миграции, сидинг +справочников и создание админа выполняются автоматически при старте (`entrypoint.sh`). +В production закрыты OpenAPI/Swagger, а с дефолтным или коротким `SECRET_KEY` либо +дефолтным `ADMIN_PASSWORD` приложение не стартует. Учтите: `ADMIN_PASSWORD` применяется только +при первом создании админа, дальше его смена в `.env` ни на что не влияет (задача #73). +Пошагово — [`deploy/pi/README.md`](deploy/pi/README.md), бэкапы — [`deploy/backup/README.md`](deploy/backup/README.md). -## Test — локальный прод-клон в контейнере +## Test — прод-клон в контейнере на ПК -Тот же образ и поведение, что и прод (FastAPI отдаёт SPA, БД на томе, вход игроков -по логину/паролю или через Telegram), но на своей машине — для проверки прод-сборки до выката на Pi. -Изолированные тома и порт **8080** (не конфликтует с dev-uvicorn на :8000). +Тот же `Dockerfile` и поведение, что у прода (FastAPI отдаёт SPA, БД на томе, вход игроков +по логину/паролю или через Telegram), но образ собирается локально — для проверки прод-сборки +до выката на Pi. Портов на хост **нет**: тест-клон виден только на `https://forbidden-stars.ru` +через свой туннель-контейнер (ключ — файл `deploy/tunnel/id_tunnel`). Проще всего — через лаунчер: поставить `APP_ENV=test` в `.env` и запустить `.\run.ps1`. Вручную (тот же эффект): ```bash docker compose -f docker-compose.test.yml up -d --build -# открыть http://localhost:8080 (Swagger: /api/docs) +# открыть https://forbidden-stars.ru (Swagger: /api/docs) +docker compose -f docker-compose.test.yml logs -f app docker compose -f docker-compose.test.yml down -v # остановить и стереть тестовые данные ``` -- Окружение `test` (прод-клон), но `COOKIE_SECURE=false` (локально по HTTP). - Читает **тот же `.env`**, что dev/prod (отдельного `.env.test` больше нет); внутри - контейнера `APP_ENV` форсится в `test` (см. `docker-compose.test.yml`). -- Данные — на отдельных томах `db-data-test` / `uploads-data-test` (не пересекаются с dev и Pi). -- Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; **игроки** — - по логину/паролю сразу, через Telegram — при боте и публичном HTTPS/туннеле на `localhost:8080`. + контейнера `APP_ENV` форсится в `test` (см. `docker-compose.test.yml`). Cookie — Secure + (снаружи HTTPS). +- От прода test отличается тем, что OpenAPI/Swagger открыт и **нет** fail-fast по дефолтным + секретам, хотя контур публичный (задача #69). Не держите в `.env` дефолтные `SECRET_KEY` / + `ADMIN_PASSWORD`, когда поднимаете test, — особенно после `restore-test` с прод-данными. +- Данные — на отдельных томах `db-data-test` / `uploads-data-test` / `achievements-data-test` + (и `backup-data-test` у контейнера бэкапов, он работает без расписания и без VPS); + с dev и Pi не пересекаются. +- Слот VPS 9001 общий с dev-туннелем (`LOCAL_PUBLIC=vps`) — поднимайте что-то одно. +- Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; **игроки** — по + логину/паролю сразу, через Telegram — при настроенном боте (`/setdomain` → `forbidden-stars.ru`). +- Учебное восстановление прод-бэкапа в тест-клон — `.\scripts\fs-backup.ps1 restore-test` + ([`deploy/backup/README.md`, шаг 7](deploy/backup/README.md#шаг-7-учебное-восстановление-на-тест-клоне)). - **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно), и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так: @@ -142,17 +175,25 @@ docker compose -f docker-compose.test.yml down -v # остановить и с - **Логин и пароль** (`app/auth/password.py`) — основной вход. Логин — это ник игрока (смена ника меняет логин). Пароль: от 8 символов, не длиннее 72 байт, хранится bcrypt. - От перебора — окно 15 минут в памяти процесса: 5 неудач на пару «IP + логин» и 20 на IP, - дальше `429 TOO_MANY_ATTEMPTS`. Игрок без пароля (из Telegram или созданный до паролей) - после входа видит обязательное окно «Задайте пароль». В профиле пароль меняется (нужен - текущий) и привязывается Telegram (ник не меняется). Забытый пароль задаёт админ - на вкладке аккаунтов — почту приложение не хранит. -- **Stub-вход (по нику)** — только для разработки. Его код **физически не попадает в прод:** - файлы `backend/app/auth/dev_stub.py` и `backend/app/routers/dev_auth.py` исключены из - Docker-образа (`.dockerignore`), роутер подключается лишь при `APP_ENV=development` - (`app/main.py`), а на фронте dev-блок вырезается из прод-сборки (`import.meta.env.DEV`). -- **Telegram:** сервер проверяет подпись виджета (HMAC по `TELEGRAM_BOT_TOKEN`). - `GET /api/auth/config` отдаёт доступные методы и `telegram_bot_username` для виджета. + От перебора — окно 15 минут в памяти процесса: 5 неудач на пару «IP + логин», 20 на IP и + 50 на аккаунт (независимо от IP), дальше `429 TOO_MANY_ATTEMPTS`. Регистраций — не больше + 10 с одного IP за окно. Игрок без пароля (из Telegram или созданный до паролей) после входа + видит обязательное окно «Задайте пароль»: закрыть его нельзя, только задать пароль или + выйти; в dev-сборке есть кнопка «Позже (dev)». В профиле пароль меняется (нужен текущий) и + привязывается Telegram (ник не меняется). Забытый пароль задаёт админ на вкладке + аккаунтов — почту приложение не хранит. Смена или сброс пароля завершает все прежние + сессии игрока (при смене в профиле текущее устройство остаётся в системе); «Выйти» + отзывает токен этого устройства. +- **Stub-вход (по нику)** и **жёсткое удаление аккаунтов** в админке — только для разработки. + Их код **физически не попадает в прод:** файлы `backend/app/auth/dev_stub.py`, + `backend/app/routers/dev_auth.py` и `backend/app/routers/dev_admin.py` исключены из + Docker-образа (`.dockerignore`), роутеры подключаются лишь при `APP_ENV=development` + (`app/main.py`), а на фронте dev-блоки вырезаются из прод-сборки (`import.meta.env.DEV`). + В test/prod аккаунт можно только отключить. +- **Telegram:** сервер проверяет подпись виджета (HMAC по `TELEGRAM_BOT_TOKEN`) и свежесть + данных (не старше суток). Первый вход регистрирует игрока под Telegram-тегом; если такой ник + занят или некорректен, фронт просит выбрать другой. `GET /api/auth/config` отдаёт доступные + методы и `telegram_bot_username` для виджета. **Настройка Telegram (когда будете подключать реальный вход):** 1. Создать бота у [@BotFather](https://t.me/BotFather) → получить **токен** и **username**. @@ -160,7 +201,8 @@ docker compose -f docker-compose.test.yml down -v # остановить и с 3. В `.env`: `TELEGRAM_BOT_TOKEN=...`, `TELEGRAM_BOT_USERNAME=...` (без `@`). 4. Виджету нужен HTTPS-домен (см. «Домен и публикация») — по голому HTTP/localhost он не работает. -Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех окружениях. +Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех +окружениях. Страница — `/admin/login`; из интерфейса туда ведёт удержание кнопки «Меню» 10 секунд. ## Окружения (dev / test / prod) @@ -171,18 +213,23 @@ docker compose -f docker-compose.test.yml down -v # остановить и с | Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `.\run.ps1` → `docker compose -f docker-compose.test.yml` | `docker compose up -d` | | `APP_ENV` | `development` | `test` (форсится в compose) | `production` (форсится в compose) | | Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) | -| Раздача SPA | Vite (HMR), :5173 | FastAPI, :8080 | FastAPI, :8000 | +| Раздача SPA | Vite (HMR), `:5173` | FastAPI, только через `https://forbidden-stars.ru` | FastAPI, только через `https://forbiddenstars.ru` | | База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) | | Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram | пароль + Telegram | +| Swagger (`/api/docs`) | ✓ | ✓ | ✗ | +| Fail-fast по дефолтным секретам | ✗ | ✗ | ✓ | - **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда `production`. Отдельного `.env.test` больше нет. - **Структура БД одна** (общие миграции Alembic), **файлы разные**: dev → `DEV_DATABASE_URL` (`backend/data/dev/`), test и prod → `PROD_DATABASE_URL` (том `/data`; у test и prod это - РАЗНЫЕ тома). Данные дева в образ **не попадают** (`data/` в `.dockerignore`). -- **В Docker идёт только прод-код:** dev-вход (stub) и тесты физически исключены из образа - (`.dockerignore`); `test` — тот же образ, что и прод, просто локально и с `APP_ENV=test`. + РАЗНЫЕ тома). Так же раздельно лежат загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`). +- **В Docker идёт только прод-код:** dev-вход (stub), dev-удаление аккаунтов и тесты физически + исключены из образа (`.dockerignore`); `test` собирается из того же `Dockerfile`, что и прод, + просто локально и с `APP_ENV=test`. +- **Внимание:** данные дева (`backend/data/dev/`) сейчас **попадают** в образ — правило + `data/` в `.dockerignore` исключает только корневую папку `data/` (задача #71). ## Git и деплой @@ -190,9 +237,14 @@ docker compose -f docker-compose.test.yml down -v # остановить и с - Ветка **`main`** — релиз прода: готовое промоутишь из `dev` через `merge dev→main`. Файлы во всех ветках одинаковы (окружение задаёт `.env`/compose, а не ветка) → merge безболезненный; чистоту прод-образа обеспечивает `.dockerignore`, а не разные наборы файлов. -- **Деплой на Pi:** `git pull` ветки `main` → `scripts/build-push.ps1`. -- **Чистая выгрузка в папку без git** (опц.): `scripts/export-prod.sh main` (через - `git archive` + `export-ignore` — в папку идёт ровно прод-набор, без лаунчера/тестов/dev-входа). +- **Деплой на Pi:** на ПК с ветки `main` — `.\scripts\build-push.ps1` (собирает и пушит + образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна + именно PS-версия скрипта (`build-push.sh` из PowerShell уходит в WSL). +- **Чистая выгрузка в папку без git** (опц., к деплою на Pi не относится): + `scripts/export-prod.sh [ref]` / `scripts/export-test.sh [ref]` — через + `git archive` + `export-ignore` из `.gitattributes` (без тестов, stub-входа, `pyproject.toml`, + README-файлов; у прода ещё без лаунчера и тест-compose, у теста — без прод-compose). `dev_admin.py` в + `export-ignore` пока не внесён (задача #70). Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`. @@ -207,14 +259,25 @@ docker compose -f docker-compose.test.yml down -v # остановить и с |---|---|---|---| | prod | `forbiddenstars.ru` | туннель-контейнер (постоянно) → `app:8000` | 9000 | | test | `forbidden-stars.ru` | туннель-контейнер → `app:8000` | 9001 | -| dev | `forbidden-stars.ru` | `run.ps1` при `LOCAL_PUBLIC=vps` → `localhost:5173` | 9001 | +| dev | `forbidden-stars.ru` | лаунчер (`run.ps1`/`run.sh`) при `LOCAL_PUBLIC=vps` → `localhost:5173` | 9001 | - Прод (9000) и dev/test (9001) на **разных слотах/доменах** → прод и (dev|test) работают - одновременно. Dev и test делят слот 9001 → по очереди. -- Dev по умолчанию только на localhost; `LOCAL_PUBLIC=vps` + `run.ps1` выставляет его на домен. + одновременно. Dev и test делят слот 9001 → по очереди. Временный прод на ПК + (`docker-compose.temp.yml`) занимает слот 9000 — одновременно с продом на Pi не запускать. +- Dev по умолчанию только на localhost; `LOCAL_PUBLIC=vps` + лаунчер выставляет его на домен. - `COOKIE_SECURE` выводится автоматически (HTTPS-домен ⇒ Secure-cookie; dev+localhost ⇒ нет). +- Ключ туннеля: test и временный прод берут файл `deploy/tunnel/id_tunnel`, прод на Pi — + `TUNNEL_KEY_B64` (base64) в `.env`, dev-туннель — системный `ssh` с ключом по умолчанию + (`~/.ssh`). Публичные части — в `authorized_keys` пользователя `tunnel` на VPS. - Пошаговая настройка — в [`deploy/`](deploy/README.md): `vps/` (Caddy, сертификаты, юзер `tunnel`), - `pi/` (app + туннель в Docker), образ туннеля — `deploy/tunnel/`. Ключ — `deploy/tunnel/id_tunnel`. + `pi/` (app + туннель + бэкапы в Docker), образ туннеля — `deploy/tunnel/`. + +## Бэкапы + +Контейнер `backup` (restic) в `docker-compose.yml` каждую ночь делает зашифрованный снимок +БД, `uploads` и `achievements` — на Pi (том `backup-data`) и на VPS по SFTP. С ПК снимки +скачиваются и проверяются учебным восстановлением в тест-клон (`scripts/fs-backup.ps1`). +Настройка, восстановление и действия при гибели Pi — [`deploy/backup/README.md`](deploy/backup/README.md). ## Дополнения и фракции @@ -225,4 +288,6 @@ docker compose -f docker-compose.test.yml down -v # остановить и с | Forsaken Voids | Инквизиция, Сёстры битвы, Друкхари, Адептус Механикус | Группа отмечает имеющиеся дополнения; при создании партии доступны только фракции -этих дополнений (база — всегда). +этих дополнений (база — всегда). Справочник сидится из `backend/app/seed/reference_data.py`. +Админ может переименовать фракцию в панели, но сейчас переименование откатывается при +каждом перезапуске приложения (задача #72). diff --git a/deploy/README.md b/deploy/README.md index 57e1b54..3a74a4d 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -18,33 +18,41 @@ HTTPS твоими сертификатами и проксирует трафи └───────────────────────────────────────────────────┘ ``` -- **PROD** — Pi. `docker compose up -d` поднимает два сервиса: `app` + `tunnel`. У `app` - **портов на хост нет** — наружу его выставляет только туннель-контейнер - (`ssh -R 9000:app:8000` к VPS). Постоянно, Docker сам переподключает. См. [`pi/`](pi/README.md). -- **TEST** — ПК. То же самое: `docker compose -f docker-compose.test.yml up` поднимает - `app` + `tunnel` (`ssh -R 9001:app:8000`). Портов на хост нет — тест виден только на - `forbidden-stars.ru`. Обычно запускается лаунчером при `APP_ENV=test`. +- **PROD** — Pi. `docker compose up -d` поднимает три сервиса: `app` + `tunnel` + `backup` + (образы из Gitea-реестра, собираются на ПК `scripts/build-push.ps1`). У `app` **портов на + хост нет** — наружу его выставляет только туннель-контейнер (`ssh -R 9000:app:8000` к VPS). + Работает постоянно: при обрыве `ssh` завершается, и Docker перезапускает контейнер + (`restart: unless-stopped`). См. [`pi/`](pi/README.md). +- **TEST** — ПК. То же самое: `docker compose -f docker-compose.test.yml up` собирает образы + локально и поднимает `app` + `tunnel` (`ssh -R 9001:app:8000`) + `backup` (без расписания и + без VPS). Портов на хост нет — тест виден только на `forbidden-stars.ru`. Обычно + запускается лаунчером при `APP_ENV=test`. - **DEV** — ПК, нативно (`uvicorn`+`vite`). По умолчанию только на localhost; при - `LOCAL_PUBLIC=vps` лаунчер (`run.ps1`) дополнительно поднимает SSH-туннель с ПК + `LOCAL_PUBLIC=vps` лаунчер (`run.ps1` / `run.sh`) дополнительно поднимает SSH-туннель с ПК (`ssh -R 9001:localhost:5173`) → дев виден на `forbidden-stars.ru`. - DEV и TEST делят слот **9001** (`forbidden-stars.ru`) → поднимай что-то **одно за раз**. PROD на отдельном слоте **9000** (`forbiddenstars.ru`) — работает независимо. + Временный прод на ПК (`docker-compose.temp.yml`) тоже занимает **9000** — одновременно с Pi нельзя. -Ключ туннеля — **`deploy/tunnel/id_tunnel`** (приватный, в git не идёт). Его публичную -часть добавь в `authorized_keys` пользователя `tunnel` на VPS. Один и тот же ключ годится -для контейнерного туннеля (Pi/ПК) и для dev-туннеля `run.ps1`. +Ключи туннеля (приватные, в git не идут; публичные части — в `authorized_keys` пользователя +`tunnel` на VPS): +- **Pi** — `TUNNEL_KEY_B64` (base64 приватного ключа) в `.env`; файла ключа на Pi нет. +- **ПК, test и временный прод** — файл `deploy/tunnel/id_tunnel`, монтируется в туннель-контейнер. +- **ПК, dev** — `run.ps1`/`run.sh` зовут системный `ssh` без `-i`, то есть с ключом по + умолчанию из `~/.ssh`. Он должен быть в `authorized_keys` (можно тем же, что `id_tunnel`). Настройка по шагам: 1. **VPS** — [`vps/README.md`](vps/README.md): Caddy, файрвол, пользователь `tunnel`, сертификаты, `Caddyfile`. -2. **Pi (прод)** — [`pi/README.md`](pi/README.md): ключ в `deploy/tunnel/id_tunnel`, `.env`, `docker compose up -d`. -3. **ПК (dev/test)** — тот же ключ в `deploy/tunnel/id_tunnel` (для test-контейнера) и/или - ключ по умолчанию для dev (`run.ps1`); pubkey — в `authorized_keys` у `tunnel@VPS`. +2. **Pi (прод)** — [`pi/README.md`](pi/README.md): ключ туннеля в `TUNNEL_KEY_B64`, `.env`, `docker compose up -d`. +3. **ПК (dev/test)** — ключ в `deploy/tunnel/id_tunnel` (для test-контейнера) и/или ключ по + умолчанию в `~/.ssh` (для dev-туннеля); pubkey — в `authorized_keys` у `tunnel@VPS`. 4. **Бэкапы** — [`backup/README.md`](backup/README.md): контейнер `backup` (restic) делает снимки на Pi и на VPS (`fsbackup@VPS`, только SFTP), скрипты ПК скачивают их и проверяют восстановление на тест-клоне. -Секреты не в git: сертификаты/ключи (`*.pem`, `*.key`, `id_tunnel*`) живут на VPS/Pi/ПК, -в репозитории только `Caddyfile`, `deploy/tunnel/` (образ туннеля) и шаблоны. +Секреты не в git: сертификаты/ключи (`*.pem`, `*.key`, `id_tunnel*`, `id_backup*`) живут на +VPS/Pi/ПК, в репозитории только `Caddyfile`, страница-заглушка, образы `deploy/tunnel/` и +`deploy/backup/` и шаблоны. > Telegram-вход требует HTTPS-домен: у BotFather `/setdomain` укажи оба домена > (`forbiddenstars.ru` и `forbidden-stars.ru`). @@ -62,6 +70,6 @@ HTTPS твоими сертификатами и проксирует трафи внешний брокер (Redis pub/sub), иначе события увидит только тот воркер, что принял мутацию. SSE-поток сам не закрывается, поэтому uvicorn запускается с `--timeout-graceful-shutdown` -(`entrypoint.sh`, `run.*`). Без него остановка ждёт закрытия всех соединений: в dev +(10 с в `entrypoint.sh`, 2 с в `run.*`). Без него остановка ждёт закрытия всех соединений: в dev `--reload` при открытой вкладке висит вечно, в контейнере остановку обрывает только SIGKILL по `stop_grace_period`. diff --git a/deploy/backup/README.md b/deploy/backup/README.md index 21acf57..949a464 100644 --- a/deploy/backup/README.md +++ b/deploy/backup/README.md @@ -55,8 +55,8 @@ 4. удаляет старые снимки по правилам хранения; 5. проверяет, что репозитории целы. -Раз в неделю (воскресенье, 05:30) дополнительно читает часть сохранённых данных и убеждается, -что последний снимок действительно восстанавливается. +Раз в неделю (воскресенье, 05:30) дополнительно читает часть сохранённых данных (10 %) и +убеждается, что БД из последнего снимка извлекается и проходит проверку целостности. **Словарь** @@ -646,7 +646,7 @@ | `Игроков` / `Партий` | сколько игроков и партий было в БД в этот момент — по ним легко найти «до поломки» | | `Размер` | полный объём данных снимка | | `Прирост` | сколько места снимок реально добавил в репозиторий | - | `Метки` | `scheduled` — по расписанию, `manual` — вручную, `pre-restore` — страховочный, прочие — имя, данное вручную | + | `Метки` | `scheduled` — по расписанию, `manual` — вручную, `pre-restore` — страховочный, `keep` + имя — именованный снимок (`run --tag имя`) | Если локальный репозиторий повреждён или пуст, смотрите копию на VPS: `docker compose exec backup fs-backup list vps`. @@ -737,30 +737,44 @@ ``` 3. Дождитесь, пока приложение создаст пустую БД (`docker compose ps` → `app` `(healthy)`). - В журнале бэкапа это нормально (`docker compose logs backup`): + Контейнер `backup` стартует одновременно с `app` и сразу пробует сделать первый бэкап. В его + журнале (`docker compose logs backup`) нормально увидеть одно из двух: - ``` - В БД нет ни игроков, ни партий, а последний снимок в репозитории vps — с данными. - Похоже на новый или очищенный сервер: бэкап НЕ сделан, чтобы пустые данные не вытеснили историю. - ``` + - если `backup` успел раньше, чем `app` создал БД: - Это защита: пустой новый Pi не перезапишет историю на VPS. + ``` + ОШИБКА: БД /fs-db/forbidden_stars.db не найдена — приложение ещё ни разу не запускалось? + Первый бэкап не удался — следующая попытка по расписанию. + ``` -4. Посмотрите снимки на VPS и выберите нужный (обычно самый свежий): + - если БД уже была: + + ``` + В БД нет ни игроков, ни партий, а последний снимок в репозитории vps — с данными. + Похоже на новый или очищенный сервер: бэкап НЕ сделан, чтобы пустые данные не вытеснили историю. + ``` + + Это защита: пустой новый Pi не перезапишет историю на VPS. + +4. Посмотрите снимки на VPS и выберите нужный (обычно самый свежий **с данными**): ```bash docker compose exec backup fs-backup list vps ``` -5. Восстановите: + Проверьте, что у выбранного снимка в столбцах `Игроков`/`Партий` числа, а не `0` и не `?`. + Защита выше не распознаёт БД без таблиц: если первый бэкап попал ровно в момент создания + БД, в списке может появиться свежий снимок с `?` (задача #74). Такой снимок не выбирайте. + +5. Восстановите, подставив ID из `list vps`: ```bash docker compose stop app - docker compose exec backup fs-backup restore latest --repo vps --yes + docker compose exec backup fs-backup restore --repo vps --yes docker compose start app ``` - Вместо `latest` можно указать ID из `list vps`. + Если самый свежий снимок в `list vps` — с данными, вместо ID можно написать `latest`. 6. Проверьте сайт. Затем сделайте снимок вручную, чтобы локальная копия появилась сразу: @@ -852,7 +866,7 @@ docker compose logs --tail 100 backup | `снимок '…' не найден в репозитории` | опечатка в ID или снимок в другом репозитории | `fs-backup list` и `fs-backup list vps` | | `service "backup" is not running` | контейнер не запущен | `docker compose up -d backup` | | `permission denied while trying to connect to the Docker daemon socket` | пользователь Pi не в группе `docker` | `sudo usermod -aG docker $USER`, перезайти по SSH | -| `docker compose ps` → `backup` `unhealthy` | последний успешный бэкап старше `BACKUP_MAX_AGE_HOURS` | `docker compose exec backup fs-backup health` покажет причину, дальше по таблице | +| `docker compose ps` → `backup` `unhealthy` | последний успешный бэкап старше `BACKUP_MAX_AGE_HOURS`, успешных бэкапов ещё не было или не задан `BACKUP_PASSWORD` | `docker compose exec backup fs-backup health` покажет причину, дальше по таблице | **Про `recover`.** Команда сама определяет, на каком этапе оборвалось восстановление: - если замена успела пройти во всех томах — дочищает промежуточные папки, восстановленные @@ -907,8 +921,11 @@ docker volume rm <имя тома> | `export [--repo …]` | снимок в tar в stdout: `docker compose exec -T backup fs-backup export latest > fs.tar` | | `restic <аргументы>` | любая команда restic с настройками контейнера, например `restic vps stats` | | `help` | краткая справка | +| `init`, `info`, `health`, `has-snapshots` | служебные: создать репозитории, данные снимка для скриптов ПК, healthcheck, проверка «есть ли снимки» при старте | Без `--yes` команды `restore` и `import` только показывают, что собираются сделать. +Флаг `--no-pre-restore` у `restore`/`import` пропускает страховочный снимок — используйте, +только если текущие данные точно не нужны. ### Скрипт ПК diff --git a/deploy/pi/README.md b/deploy/pi/README.md index a0b0ea0..3b97139 100644 --- a/deploy/pi/README.md +++ b/deploy/pi/README.md @@ -2,12 +2,13 @@ На Pi нужны только **ДВА файла**: `docker-compose.yml` и `.env`. Репозиторий, сборка и файл ключа туннеля не нужны: -- образы (`app` + `tunnel`) тянутся из Gitea-реестра (`pull_policy: always`); +- образы (`app` + `tunnel` + `backup`) тянутся из Gitea-реестра (`pull_policy: always`); - приватный ключ туннеля лежит в `.env` как `TUNNEL_KEY_B64` (base64). -`docker compose up` поднимает два контейнера: `app` (FastAPI+SPA, портов на хост нет) и -`tunnel` (`ssh -R 9000:app:8000` к VPS). Публичная точка — VPS, домен `forbiddenstars.ru` -(Pi за CGNAT — туннель стучится наружу сам). +`docker compose up` поднимает три контейнера: `app` (FastAPI+SPA, портов на хост нет), +`tunnel` (`ssh -R 9000:app:8000` к VPS, стартует после `healthy` у `app`) и `backup` (restic, +см. раздел «Бэкапы»). Публичная точка — VPS, домен `forbiddenstars.ru` (Pi за CGNAT — туннель +стучится наружу сам). --- @@ -86,6 +87,14 @@ IMAGE_REGISTRY=gitea.arseniev.info/notbigghost # уже значение по IMAGE_TAG=latest ``` `APP_ENV` (форсится в `production`) и `VPS_TUNNEL_PORT` (прод → 9000) не трогать. +Блок `BACKUP_*` можно оставить пустым: бэкапы включаются позже, по +[`deploy/backup/README.md`](../backup/README.md). + +> В production приложение **не стартует**, если `SECRET_KEY` дефолтный или короче 32 +> символов, а `ADMIN_PASSWORD` пустой или дефолтный (при `ADMIN_BOOTSTRAP_ENABLED=true`). +> `ADMIN_USERNAME`/`ADMIN_PASSWORD` применяются **только при первом создании** админа: если +> потом поменять их в `.env`, у существующего админа ничего не изменится, а штатной смены +> пароля админа на проде пока нет (задача #73). ## 4. Запуск ```bash @@ -94,7 +103,8 @@ docker compose up -d # pull_policy: always → тянет обр docker compose ps # app healthy → поднимется tunnel docker compose logs -f tunnel # ждём строку: [tunnel] -R 9000:app:8000 -> tunnel@... ``` -На старте контейнер сам применит миграции, засидит справочники и создаст админа из `.env`. +На старте контейнер сам применит миграции, засидит справочники и создаст админа из `.env` +(если админа ещё нет). ## 5. Проверка - Открой `https://forbiddenstars.ru` — должно отдать приложение (не заглушку). @@ -103,9 +113,12 @@ docker compose logs -f tunnel # ждём строку: [tunnel] -R 9000:a ## Обновление ```bash +# Pi: docker compose exec backup fs-backup run --tag before-update # по желанию, если бэкапы включены # ПК: .\scripts\build-push.ps1 -# Pi: docker compose up -d # always-pull подтянет свежий образ +# Pi: docker compose up -d # always-pull подтянет свежие образы ``` +Если в новой версии менялся `docker-compose.yml` или `.env.example`, сначала скачайте +свежий `docker-compose.yml` (шаг 3) и допишите новые переменные в `.env`. ## Автозапуск после перезагрузки Уже обеспечен: `systemctl enable docker` + `restart: unless-stopped`. После `sudo reboot` diff --git a/deploy/vps/README.md b/deploy/vps/README.md index aae1a61..d2bb558 100644 --- a/deploy/vps/README.md +++ b/deploy/vps/README.md @@ -4,8 +4,8 @@ для двух доменов и проксирует трафик в SSH reverse-туннели от Pi (прод) и ПК (dev/test). ``` -forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD -forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST +forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, постоянно) PROD +forbidden-stars.ru → 127.0.0.1:9001 ← ПК (контейнер test или ssh dev, по требованию) DEV/TEST ``` > Туннель `ssh -R` по умолчанию слушает на loopback VPS (127.0.0.1) — ровно туда смотрит @@ -51,7 +51,9 @@ install -d -m 700 -o tunnel -g tunnel /home/tunnel/.ssh install -m 600 -o tunnel -g tunnel /dev/null /home/tunnel/.ssh/authorized_keys ``` Публичные ключи Pi и ПК добавишь в `/home/tunnel/.ssh/authorized_keys` на шагах настройки -Pi (`deploy/pi/README.md`) и ПК (через `ssh-copy-id`). +Pi (`deploy/pi/README.md`) и ПК (`deploy/tunnel/id_tunnel.pub` и/или ключ по умолчанию из +`~/.ssh` для dev-туннеля). Ключи **дописывай** (`>>`), а не перезаписывай файл (`>`): +иначе туннель другого хоста перестанет пускать. ## 5. Сертификаты @@ -67,11 +69,11 @@ Caddy читает **PEM** (текст с `-----BEGIN CERTIFICATE-----`). Рас Удобно собрать прямо на VPS — залей свои файлы и склей: ```bash -mkdir -p /etc/caddy/certs/forbidden-stars.ru /root/certs-tmp -# с локальной машины (пример для домена forbidden-stars.ru): +mkdir -p /etc/caddy/certs/forbidden-stars.ru /etc/caddy/certs/forbiddenstars.ru /root/certs-tmp +# с локальной машины (пример для домена forbidden-stars.ru; имена файлов — свои): scp forbidden-stars.crt intermediate.crt forbidden-stars.key root@186.246.51.17:/root/certs-tmp/ # на VPS — собрать fullchain (leaf + промежуточный) и положить ключ: -cat /root/certs-tmp/www_forbidden_stars_ru_2026_12_31.crt /root/certs-tmp/intermediate_pem_globalsign_ssl_dv_free_1.crt \ +cat /root/certs-tmp/forbidden-stars.crt /root/certs-tmp/intermediate.crt \ > /etc/caddy/certs/forbidden-stars.ru/fullchain.pem cp /root/certs-tmp/forbidden-stars.key /etc/caddy/certs/forbidden-stars.ru/privkey.pem # то же для forbiddenstars.ru (свои crt/intermediate/key), затем права @@ -96,13 +98,16 @@ scp deploy/vps/maintenance.html root@186.246.51.17:/etc/caddy/maintenance/mainte caddy validate --config /etc/caddy/Caddyfile systemctl reload caddy ``` -> Заглушка живёт в сниппете `(offline)` Caddyfile: при ответе апстрима 502/503/504 -> Caddy отдаёт `/etc/caddy/maintenance/maintenance.html` со статусом 503 и `Cache-Control: no-store`. +> Заглушка живёт в сниппете `(edge)` Caddyfile (там же security-заголовки и `no-store` для +> HTML): при ответе апстрима 502/503/504 Caddy отдаёт `/etc/caddy/maintenance/maintenance.html` +> со статусом 503 и `Cache-Control: no-store`. Для `/api/events` (SSE) у каждого домена +> отдельный `handle` без `encode` и с `flush_interval -1`. > Правки текста/вида — в `deploy/vps/maintenance.html`, затем повтори `scp` (reload Caddy не нужен, > файл читается на каждый запрос). ## 7. Проверка -1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev/test на ПК (`run.ps1` при `LOCAL_PUBLIC=vps`). +1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev/test на ПК + (лаунчер `run.ps1`: dev — при `LOCAL_PUBLIC=vps`, test — при `APP_ENV=test`). 2. Открой `https://forbiddenstars.ru` и `https://forbidden-stars.ru`. 3. Пока соответствующий туннель не поднят — Caddy отдаёт страницу-заглушку «Технические шоколадки» (HTTP 503), это ожидаемо. From dafbf5bad416a0f22c4971f79a546f9916c94550 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 14 Sep 2026 20:48:30 +0300 Subject: [PATCH 04/33] =?UTF-8?q?=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20test-=D0=BA=D0=BE=D0=BD=D1=82=D1=83=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.test.yml | 86 ----------------------------------------- scripts/export-test.sh | 18 --------- 2 files changed, 104 deletions(-) delete mode 100644 docker-compose.test.yml delete mode 100644 scripts/export-test.sh diff --git a/docker-compose.test.yml b/docker-compose.test.yml deleted file mode 100644 index 2c96d6b..0000000 --- a/docker-compose.test.yml +++ /dev/null @@ -1,86 +0,0 @@ -# Локальный «клон прода» в контейнере — для проверки прод-сборки на своей машине -# (а не на Raspberry Pi). Тот же образ, что и прод, изолированные тома, единый .env. -# Портов на хост НЕТ: тест виден ТОЛЬКО снаружи на https://forbidden-stars.ru через -# сервис tunnel (контейнер ssh -R 9001:app:8000 на VPS). -# -# Запуск (обычно лаунчером при APP_ENV=test): docker compose -f docker-compose.test.yml up --build -d -# Остановить и стереть данные: docker compose -f docker-compose.test.yml down -v -services: - app: - build: . - image: forbidden-stars:test - container_name: forbidden-stars-test - restart: "no" - init: true - env_file: - - .env # единый .env (тот же, что у dev/prod); секреты не в git - environment: - # Окружение test: прод-клон, но отличимый от прода (см. config.is_test). - # Форсим здесь, чтобы значение не зависело от APP_ENV внутри .env. - APP_ENV: test - # Портов на хост НЕТ: тест доступен только изнутри сети compose; наружу — через - # сервис tunnel (ниже) на forbidden-stars.ru. Из LAN/localhost — недоступно. - volumes: - - db-data-test:/data # изолированные тестовые данные - - uploads-data-test:/data/uploads - - achievements-data-test:/data/achievements # определения ачивок (файлы) - healthcheck: - test: - - CMD - - python - - -c - - "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" - interval: 30s - timeout: 5s - retries: 3 - start_period: 40s - mem_limit: 512m - - # SSH reverse-туннель к VPS: forbidden-stars.ru (VPS:9001) -> app:8000 (по сети compose). - # Ключ — ./deploy/tunnel/id_tunnel (gitignore), pubkey в authorized_keys у tunnel@VPS. - # Внимание: слот 9001 общий с dev-туннелем (run.ps1); поднимай что-то одно за раз. - tunnel: - build: ./deploy/tunnel - image: forbidden-stars-tunnel:test - restart: unless-stopped - init: true - depends_on: - app: - condition: service_healthy - environment: - VPS_TUNNEL_HOST: ${VPS_TUNNEL_HOST} - VPS_TUNNEL_USER: ${VPS_TUNNEL_USER:-tunnel} - VPS_TUNNEL_PORT: "9001" # forbidden-stars.ru (dev/test) - UPSTREAM: app:8000 - volumes: - - ./deploy/tunnel/id_tunnel:/key/id_tunnel:ro - mem_limit: 64m - - # Бэкапы тест-клона: тот же образ, что у прода (сборка локально), но ТОЛЬКО локальный - # репозиторий и без расписания. BACKUP_VPS_HOST принудительно пуст — тестовые данные - # никогда не попадут в прод-репозиторий на VPS. Учебное восстановление: - # .\scripts\fs-backup.ps1 restore-test -File backups\fs_....tar - backup: - build: ./deploy/backup - image: forbidden-stars-backup:test - restart: "no" - environment: - BACKUP_PASSWORD: ${BACKUP_PASSWORD:-test-contour-only} - BACKUP_HOSTNAME: fs-test - BACKUP_SCHEDULE: "" - BACKUP_VERIFY_SCHEDULE: "" - BACKUP_VPS_HOST: "" - BACKUP_COMPRESSION: ${BACKUP_COMPRESSION:-max} - TZ: ${BACKUP_TZ:-Europe/Moscow} - volumes: - - db-data-test:/fs-db - - uploads-data-test:/fs/uploads - - achievements-data-test:/fs/achievements - - backup-data-test:/backup - mem_limit: 384m - -volumes: - db-data-test: - uploads-data-test: - achievements-data-test: - backup-data-test: diff --git a/scripts/export-test.sh b/scripts/export-test.sh deleted file mode 100644 index 71a90c0..0000000 --- a/scripts/export-test.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Выгрузка ТЕСТА (прод-клон для локального контейнера на x86): в целевую папку -# попадают только файлы, нужные для запуска тест-контейнера. -# -# Использование: scripts/export-test.sh <целевая-папка> [git-ref] -set -euo pipefail - -DEST="${1:?Укажите целевую папку: scripts/export-test.sh [ref]}" -REF="${2:-HEAD}" - -mkdir -p "$DEST" -git archive --format=tar "$REF" | tar -x -C "$DEST" - -# прод-compose в тест-папке не нужен (тест запускается своим docker-compose.test.yml) -rm -f "$DEST/docker-compose.yml" - -echo "[export-test] Тест выгружен в: $DEST" -echo " дальше: cp .env.example .env (APP_ENV=test) && docker compose -f docker-compose.test.yml up -d --build" From 101c457f7e022353e8ccb73541fbdcf2782f00b8 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 14 Sep 2026 20:50:40 +0300 Subject: [PATCH 05/33] =?UTF-8?q?=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20test-=D0=BA=D0=BE=D0=BD=D1=82=D1=83=D1=80=D0=B0?= =?UTF-8?q?=20(=D0=B4=D0=BE=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 25 ++-- .gitattributes | 4 +- .gitignore | 2 +- README.md | 116 +++++++----------- backend/app/bootstrap.py | 2 +- backend/app/core/config.py | 29 +++-- backend/app/main.py | 8 +- backend/app/routers/admin.py | 2 +- backend/app/routers/dev_admin.py | 4 +- backend/app/services/admin_service.py | 2 +- backend/tests/test_api_hardening.py | 2 +- backend/tests/test_auth.py | 6 +- backend/tests/test_config_security.py | 12 ++ deploy/README.md | 26 ++-- deploy/backup/README.md | 64 +++++----- deploy/tunnel/tunnel.sh | 2 +- deploy/vps/Caddyfile | 10 +- deploy/vps/README.md | 10 +- docker-compose.temp.yml | 2 +- docker-compose.yml | 2 +- .../src/pages/admin/AdminAccountsPage.tsx | 2 +- .../pages/admin/DevDeleteAccountButton.tsx | 6 +- run.ps1 | 36 +----- run.sh | 18 +-- scripts/export-prod.sh | 6 +- scripts/fs-backup.ps1 | 105 +++------------- scripts/fs-backup.sh | 67 ++-------- 27 files changed, 195 insertions(+), 375 deletions(-) diff --git a/.env.example b/.env.example index 50bda92..add725e 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # ═══════════════════════════════════════════════════════════════════════════ -# Forbidden Stars — единый .env (dev / test / prod) +# Forbidden Stars — единый .env (dev / prod) # Скопируйте в .env, заполните секреты. Реальный .env в git НЕ идёт. # Окружение — строкой APP_ENV (ниже); публикация локалки наружу — LOCAL_PUBLIC. # ═══════════════════════════════════════════════════════════════════════════ @@ -7,7 +7,6 @@ # ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ──────────────────────────────────────────────────── # Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать: # development — нативно: uvicorn --reload + vite, БД в ./data/dev/, вход TG+ник -# test — прод-клон в Docker локально (порт 8080), вход только TG # production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d). # Прод-контейнер ИГНОРИРУЕТ это значение и всегда production. APP_ENV=development @@ -15,21 +14,21 @@ APP_ENV=development # ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ───────────────────────────────────── # LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost; # vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru. -# TEST и PROD выставляют себя сами через туннель-КОНТЕЙНЕР (docker-compose*.yml) — им -# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже они тоже читают. +# PROD выставляет себя сам через туннель-КОНТЕЙНЕР (docker-compose*.yml) — ему +# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже он тоже читает. LOCAL_PUBLIC=local -# Параметры VPS: их читают и dev-туннель (run.ps1), и туннель-контейнер test/prod. +# Параметры VPS: их читают и dev-туннель (run.ps1), и туннель-контейнер прода. # Ключ туннеля — в deploy/tunnel/id_tunnel (в git не идёт); pubkey → authorized_keys у tunnel@VPS. VPS_TUNNEL_HOST=186.246.51.17 VPS_TUNNEL_USER=tunnel # VPS_TUNNEL_PORT обычно НЕ задают — каждый контур берёт свой слот по умолчанию: -# dev (run.ps1) → 9001, прод-контейнер → 9000, test-контейнер → 9001. +# dev (run.ps1) → 9001, прод-контейнер → 9000. # Раскомментируй и переопредели, только если нужен нестандартный слот. #VPS_TUNNEL_PORT=9001 # Приватный ключ туннеля в base64 — чтобы на Pi хватило только docker-compose.yml + .env -# (без файла deploy/tunnel/id_tunnel). Нужен ТОЛЬКО для прод-контейнера на Pi; для dev/test -# на ПК ключ берётся из файла. Сгенерируй ключ на ПК и закодируй БЕЗ переносов строк: +# (без файла deploy/tunnel/id_tunnel). Нужен ТОЛЬКО для прод-контейнера на Pi; временный прод +# на ПК берёт ключ из файла. Сгенерируй ключ на ПК и закодируй БЕЗ переносов строк: # Git Bash / Linux: base64 -w0 deploy/tunnel/id_tunnel # PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy/tunnel/id_tunnel"))) # Pubkey (deploy/tunnel/id_tunnel.pub) добавь в authorized_keys у tunnel@VPS. @@ -45,10 +44,10 @@ ADMIN_BOOTSTRAP_ENABLED=true # Методы входа задаёт APP_ENV: dev → Telegram + stub (вход по нику), prod → только # Telegram. Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у # BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod) -# и forbidden-stars.ru (dev/test). +# и forbidden-stars.ru (dev). TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_USERNAME= -# Внешний адрес (зарезервировано): prod https://forbiddenstars.ru; dev/test https://forbidden-stars.ru. +# Внешний адрес (зарезервировано): prod https://forbiddenstars.ru; dev https://forbidden-stars.ru. PUBLIC_BASE_URL= # ─── БЕЗОПАСНОСТЬ / СЕССИИ ──────────────────────────────────────────────────── @@ -60,12 +59,12 @@ JWT_ALGORITHM=HS256 JWT_USER_TTL_MINUTES=10080 JWT_ADMIN_TTL_MINUTES=480 # COOKIE_SECURE задаётся АВТОМАТИЧЕСКИ по окружению (HTTPS-домен ⇒ Secure-cookie): -# dev+localhost → false; dev через VPS, test, prod → true. Вручную задавать НЕ нужно. +# dev+localhost → false; dev через VPS, prod → true. Вручную задавать НЕ нужно. COOKIE_DOMAIN= # ─── БАЗА ДАННЫХ (структура общая, файлы РАЗНЫЕ; выбор по APP_ENV) ──────────── -# dev → DEV_DATABASE_URL (файл в ./data/dev/); test и prod → PROD_DATABASE_URL -# (том /data; у test и prod это РАЗНЫЕ тома контейнера, см. docker-compose*.yml). +# dev → DEV_DATABASE_URL (файл в ./data/dev/); prod → PROD_DATABASE_URL +# (том /data контейнера, см. docker-compose*.yml). DEV_DATABASE_URL=sqlite:///./data/dev/forbidden_stars.db PROD_DATABASE_URL=sqlite:////data/forbidden_stars.db diff --git a/.gitattributes b/.gitattributes index 741036e..f781db5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,9 +4,9 @@ *.sh text eol=lf backend/entrypoint.sh text eol=lf -# ── export-ignore: НЕ попадает в `git archive` (чистая выгрузка прод/тест) ───── +# ── export-ignore: НЕ попадает в `git archive` (чистая выгрузка прода) ───────── # В git эти файлы есть и доступны на всех ветках (нужны для разработки), -# но в архив деплоя (scripts/export-*.sh) не идут. На Docker-сборку НЕ влияет — +# но в архив деплоя (scripts/export-prod.sh) не идут. На Docker-сборку НЕ влияет — # там чистоту образа обеспечивает .dockerignore. backend/tests/ export-ignore backend/app/auth/dev_stub.py export-ignore diff --git a/.gitignore b/.gitignore index a860c84..5620531 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ venv/ data/ backend/dev.db* -# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/test/prod) +# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/prod) .env !.env.example diff --git a/README.md b/README.md index ba30290..cb6d42a 100644 --- a/README.md +++ b/README.md @@ -16,21 +16,22 @@ backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты frontend/ React + Vite SPA deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы) -scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК), export-*.sh +scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК), export-prod.sh Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI) docker-compose.yml прод на Pi: app + tunnel + backup -docker-compose.test.yml тест-клон прода на ПК: app + tunnel + backup docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel -run.ps1 / run.sh единый лаунчер dev/test +run.ps1 / run.sh единый лаунчер dev .env.example шаблон единого .env ``` ## Локальная разработка +Всё проверяется в `development` — отдельного тестового контейнера нет. + ### Единый лаунчер (`run.ps1` / `run.sh`) -После разовой настройки (ниже) dev и test запускаются **одной командой** — что именно, -решает `APP_ENV` в корневом `.env`: +После разовой настройки (ниже) dev запускается **одной командой** (лаунчер читает +`APP_ENV` в корневом `.env`): ```powershell .\run.ps1 # Windows (Linux / macOS / Git Bash: ./run.sh) @@ -39,8 +40,8 @@ run.ps1 / run.sh единый лаунчер dev/test | `APP_ENV` в `.env` | что делает лаунчер | |---|---| | `development` | сначала `alembic upgrade head`, затем `uvicorn --reload` (бэк) + `vite` (фронт) нативно: `run.ps1` — в отдельных окнах, `run.sh` — в текущем терминале (Ctrl+C останавливает оба). При `LOCAL_PUBLIC=vps` дополнительно поднимает SSH-туннель на `forbidden-stars.ru` | -| `test` | `docker compose -f docker-compose.test.yml up --build -d` — прод-клон (app + tunnel + backup); портов на хост нет, открывается на `https://forbidden-stars.ru` | | `production` | не запускает — прод деплоится отдельно (см. «Production» и «Git и деплой») | +| другое значение | отказ: допустимы только `development` и `production` (бэкенд тоже не стартует) | Разовая настройка перед первым запуском — поднять venv бэка и зависимости фронта (после неё повседневный цикл — просто `.\run.ps1`). Vite проксирует `/api` на бэкенд. @@ -128,46 +129,19 @@ docker compose up -d # pull_policy: always — тянет свежие при первом создании админа, дальше его смена в `.env` ни на что не влияет (задача #73). Пошагово — [`deploy/pi/README.md`](deploy/pi/README.md), бэкапы — [`deploy/backup/README.md`](deploy/backup/README.md). -## Test — прод-клон в контейнере на ПК - -Тот же `Dockerfile` и поведение, что у прода (FastAPI отдаёт SPA, БД на томе, вход игроков -по логину/паролю или через Telegram), но образ собирается локально — для проверки прод-сборки -до выката на Pi. Портов на хост **нет**: тест-клон виден только на `https://forbidden-stars.ru` -через свой туннель-контейнер (ключ — файл `deploy/tunnel/id_tunnel`). - -Проще всего — через лаунчер: поставить `APP_ENV=test` в `.env` и запустить `.\run.ps1`. -Вручную (тот же эффект): -```bash -docker compose -f docker-compose.test.yml up -d --build -# открыть https://forbidden-stars.ru (Swagger: /api/docs) -docker compose -f docker-compose.test.yml logs -f app -docker compose -f docker-compose.test.yml down -v # остановить и стереть тестовые данные -``` - -- Читает **тот же `.env`**, что dev/prod (отдельного `.env.test` больше нет); внутри - контейнера `APP_ENV` форсится в `test` (см. `docker-compose.test.yml`). Cookie — Secure - (снаружи HTTPS). -- От прода test отличается тем, что OpenAPI/Swagger открыт и **нет** fail-fast по дефолтным - секретам, хотя контур публичный (задача #69). Не держите в `.env` дефолтные `SECRET_KEY` / - `ADMIN_PASSWORD`, когда поднимаете test, — особенно после `restore-test` с прод-данными. -- Данные — на отдельных томах `db-data-test` / `uploads-data-test` / `achievements-data-test` - (и `backup-data-test` у контейнера бэкапов, он работает без расписания и без VPS); - с dev и Pi не пересекаются. -- Слот VPS 9001 общий с dev-туннелем (`LOCAL_PUBLIC=vps`) — поднимайте что-то одно. -- Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; **игроки** — по - логину/паролю сразу, через Telegram — при настроенном боте (`/setdomain` → `forbidden-stars.ru`). -- Учебное восстановление прод-бэкапа в тест-клон — `.\scripts\fs-backup.ps1 restore-test` - ([`deploy/backup/README.md`, шаг 7](deploy/backup/README.md#шаг-7-учебное-восстановление-на-тест-клоне)). - **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно), - и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало + и docker compose (prod — `$` = подстановка переменной). Чтобы значение совпадало везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так: `python -c "import secrets;print(secrets.token_urlsafe(48))"` (даёт только `[A-Za-z0-9_-]`). +- **Временный прод на ПК** (вместо Pi): `docker compose -f docker-compose.temp.yml up -d --build` — + поведение production, локальная сборка x86, ключ туннеля из файла `deploy/tunnel/id_tunnel`, + тот же слот VPS 9000, что у Pi (одновременно не запускать). ## Аутентификация Методы входа зависят от окружения (`APP_ENV`): -| | dev | test / prod | +| | dev | prod | |---|---|---| | Логин (= ник) и пароль | ✓ | ✓ (основной) | | Telegram Login Widget | ✓ | ✓ | @@ -189,7 +163,7 @@ docker compose -f docker-compose.test.yml down -v # остановить и с `backend/app/routers/dev_auth.py` и `backend/app/routers/dev_admin.py` исключены из Docker-образа (`.dockerignore`), роутеры подключаются лишь при `APP_ENV=development` (`app/main.py`), а на фронте dev-блоки вырезаются из прод-сборки (`import.meta.env.DEV`). - В test/prod аккаунт можно только отключить. + В проде аккаунт можно только отключить. - **Telegram:** сервер проверяет подпись виджета (HMAC по `TELEGRAM_BOT_TOKEN`) и свежесть данных (не старше суток). Первый вход регистрирует игрока под Telegram-тегом; если такой ник занят или некорректен, фронт просит выбрать другой. `GET /api/auth/config` отдаёт доступные @@ -197,43 +171,42 @@ docker compose -f docker-compose.test.yml down -v # остановить и с **Настройка Telegram (когда будете подключать реальный вход):** 1. Создать бота у [@BotFather](https://t.me/BotFather) → получить **токен** и **username**. -2. `/setdomain` у BotFather → оба домена: `forbiddenstars.ru` (prod) и `forbidden-stars.ru` (dev/test). +2. `/setdomain` у BotFather → оба домена: `forbiddenstars.ru` (prod) и `forbidden-stars.ru` (dev). 3. В `.env`: `TELEGRAM_BOT_TOKEN=...`, `TELEGRAM_BOT_USERNAME=...` (без `@`). 4. Виджету нужен HTTPS-домен (см. «Домен и публикация») — по голому HTTP/localhost он не работает. Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех окружениях. Страница — `/admin/login`; из интерфейса туда ведёт удержание кнопки «Меню» 10 секунд. -## Окружения (dev / test / prod) +## Окружения (dev / prod) -Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env` (его читает лаунчер): +Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env`. Допустимы только +`development` и `production` — с любым другим значением бэкенд не стартует: -| | dev | test (прод-клон локально) | prod (Pi) | -|---|---|---|---| -| Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `.\run.ps1` → `docker compose -f docker-compose.test.yml` | `docker compose up -d` | -| `APP_ENV` | `development` | `test` (форсится в compose) | `production` (форсится в compose) | -| Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) | -| Раздача SPA | Vite (HMR), `:5173` | FastAPI, только через `https://forbidden-stars.ru` | FastAPI, только через `https://forbiddenstars.ru` | -| База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) | -| Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram | пароль + Telegram | -| Swagger (`/api/docs`) | ✓ | ✓ | ✗ | -| Fail-fast по дефолтным секретам | ✗ | ✗ | ✓ | +| | dev | prod (Pi) | +|---|---|---| +| Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `docker compose up -d` | +| `APP_ENV` | `development` | `production` (форсится в compose) | +| Env-файл | единый `.env` | единый `.env` (на Pi) | +| Раздача SPA | Vite (HMR), `:5173` | FastAPI, только через `https://forbiddenstars.ru` | +| База данных | `backend/data/dev/…` | том `db-data` (`/data`) | +| Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram | +| Swagger (`/api/docs`) | ✓ | ✗ | +| Fail-fast по дефолтным секретам | ✗ | ✓ | -- **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что - запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда - `production`. Отдельного `.env.test` больше нет. +- **Один `.env` на машину** в корне (рядом с `.env.example`). Прод-контейнер значение + `APP_ENV` из него **игнорирует** и всегда `production`. - **Структура БД одна** (общие миграции Alembic), **файлы разные**: dev → `DEV_DATABASE_URL` - (`backend/data/dev/`), test и prod → `PROD_DATABASE_URL` (том `/data`; у test и prod это - РАЗНЫЕ тома). Так же раздельно лежат загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`). + (`backend/data/dev/`), prod → `PROD_DATABASE_URL` (том `/data`). Так же раздельно лежат + загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`). - **В Docker идёт только прод-код:** dev-вход (stub), dev-удаление аккаунтов и тесты физически - исключены из образа (`.dockerignore`); `test` собирается из того же `Dockerfile`, что и прод, - просто локально и с `APP_ENV=test`. + исключены из образа (`.dockerignore`). - **Внимание:** данные дева (`backend/data/dev/`) сейчас **попадают** в образ — правило `data/` в `.dockerignore` исключает только корневую папку `data/` (задача #71). ## Git и деплой -- Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и `test` здесь. +- Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и проверка здесь. - Ветка **`main`** — релиз прода: готовое промоутишь из `dev` через `merge dev→main`. Файлы во всех ветках одинаковы (окружение задаёт `.env`/compose, а не ветка) → merge безболезненный; чистоту прод-образа обеспечивает `.dockerignore`, а не разные наборы файлов. @@ -241,10 +214,9 @@ docker compose -f docker-compose.test.yml down -v # остановить и с образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна именно PS-версия скрипта (`build-push.sh` из PowerShell уходит в WSL). - **Чистая выгрузка в папку без git** (опц., к деплою на Pi не относится): - `scripts/export-prod.sh [ref]` / `scripts/export-test.sh [ref]` — через - `git archive` + `export-ignore` из `.gitattributes` (без тестов, stub-входа, `pyproject.toml`, - README-файлов; у прода ещё без лаунчера и тест-compose, у теста — без прод-compose). `dev_admin.py` в - `export-ignore` пока не внесён (задача #70). + `scripts/export-prod.sh [ref]` — через `git archive` + `export-ignore` из + `.gitattributes` (без тестов, stub-входа, `pyproject.toml`, README-файлов и лаунчера). + `dev_admin.py` в `export-ignore` пока не внесён (задача #70). Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`. @@ -252,21 +224,21 @@ docker compose -f docker-compose.test.yml down -v # остановить и с Публичные адреса отдаёт **VPS-привратник** (Caddy + HTTPS твоими сертификатами), а приложение само открывает к нему SSH reverse-туннель (дома белого IP нет — CGNAT). -У **test/prod** туннель — **отдельный контейнер** в их `docker-compose`, и портов на хост -они не публикуют (доступны только через домен): +У **прода** туннель — **отдельный контейнер** в `docker-compose.yml`, и портов на хост он +не публикует (доступен только через домен): | | домен | как выставляется | слот VPS | |---|---|---|---| | prod | `forbiddenstars.ru` | туннель-контейнер (постоянно) → `app:8000` | 9000 | -| test | `forbidden-stars.ru` | туннель-контейнер → `app:8000` | 9001 | | dev | `forbidden-stars.ru` | лаунчер (`run.ps1`/`run.sh`) при `LOCAL_PUBLIC=vps` → `localhost:5173` | 9001 | -- Прод (9000) и dev/test (9001) на **разных слотах/доменах** → прод и (dev|test) работают - одновременно. Dev и test делят слот 9001 → по очереди. Временный прод на ПК - (`docker-compose.temp.yml`) занимает слот 9000 — одновременно с продом на Pi не запускать. +- Прод (9000) и dev (9001) на **разных слотах/доменах** → работают одновременно. Временный + прод на ПК (`docker-compose.temp.yml`) занимает слот 9000 — одновременно с продом на Pi не запускать. - Dev по умолчанию только на localhost; `LOCAL_PUBLIC=vps` + лаунчер выставляет его на домен. + В dev при этом открыты stub-вход по нику и Swagger — держите туннель поднятым только на + время проверки (задача #69). - `COOKIE_SECURE` выводится автоматически (HTTPS-домен ⇒ Secure-cookie; dev+localhost ⇒ нет). -- Ключ туннеля: test и временный прод берут файл `deploy/tunnel/id_tunnel`, прод на Pi — +- Ключ туннеля: временный прод берёт файл `deploy/tunnel/id_tunnel`, прод на Pi — `TUNNEL_KEY_B64` (base64) в `.env`, dev-туннель — системный `ssh` с ключом по умолчанию (`~/.ssh`). Публичные части — в `authorized_keys` пользователя `tunnel` на VPS. - Пошаговая настройка — в [`deploy/`](deploy/README.md): `vps/` (Caddy, сертификаты, юзер `tunnel`), @@ -276,7 +248,7 @@ docker compose -f docker-compose.test.yml down -v # остановить и с Контейнер `backup` (restic) в `docker-compose.yml` каждую ночь делает зашифрованный снимок БД, `uploads` и `achievements` — на Pi (том `backup-data`) и на VPS по SFTP. С ПК снимки -скачиваются и проверяются учебным восстановлением в тест-клон (`scripts/fs-backup.ps1`). +скачиваются со сверкой sha256 (`scripts/fs-backup.ps1 pull`). Настройка, восстановление и действия при гибели Pi — [`deploy/backup/README.md`](deploy/backup/README.md). ## Дополнения и фракции diff --git a/backend/app/bootstrap.py b/backend/app/bootstrap.py index 5a771a0..62bf9bd 100644 --- a/backend/app/bootstrap.py +++ b/backend/app/bootstrap.py @@ -38,7 +38,7 @@ def _ensure_admin(session: Session) -> None: # Администратор уже существует. if not settings.is_development: - # В test/prod пароль НЕ перезаписываем (мог быть изменён через панель). + # В prod пароль НЕ перезаписываем (мог быть изменён через панель). return # DEV: подтягиваем логин/пароль из .env (env — источник истины в деве). diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 0085fbe..dfca3b6 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -4,7 +4,7 @@ from __future__ import annotations from functools import lru_cache from pathlib import Path -from pydantic import model_validator +from pydantic import field_validator, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict # Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково @@ -18,6 +18,9 @@ _DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production" _DEFAULT_ADMIN_PASSWORD = "change-me-admin-password" _MIN_SECRET_KEY_LENGTH = 32 +# Допустимые окружения. Отдельного test-контура нет: всё проверяется в development. +_APP_ENVS = ("development", "production") + class Settings(BaseSettings): model_config = SettingsConfigDict( @@ -27,14 +30,13 @@ class Settings(BaseSettings): case_sensitive=False, ) - # ── Главный переключатель окружения: development | test | production ─────── + # ── Главный переключатель окружения: development | production ───────────── # development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник. - # test — прод-клон в Docker локально (порт 8080), ведёт себя как прод. # production — Docker на Pi; контейнер форсит это значение, игнорируя .env. app_env: str = "development" log_level: str = "INFO" - # Публикация локального окружения (dev/test) наружу через VPS-туннель. + # Публикация локального dev-окружения наружу через VPS-туннель. # Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель # на forbidden-stars.ru. Влияет на cookie_secure (vps ⇒ снаружи HTTPS ⇒ Secure-cookie). local_public: str = "local" @@ -91,17 +93,13 @@ class Settings(BaseSettings): стартовый bootstrap в lifespan и синхронизацию админа из .env.""" return self.app_env.lower() == "development" - @property - def is_test(self) -> bool: - return self.app_env.lower() == "test" - @property def is_production(self) -> bool: return self.app_env.lower() == "production" @property def database_url(self) -> str: - """БД: dev — отдельный файл дева; test и prod — том контейнера (/data).""" + """БД: dev — отдельный файл дева; prod — том контейнера (/data).""" return self.dev_database_url if self.is_development else self.prod_database_url @property @@ -122,13 +120,24 @@ class Settings(BaseSettings): def cookie_domain_value(self) -> str | None: return self.cookie_domain or None + @field_validator("app_env") + @classmethod + def _known_app_env(cls, value: str) -> str: + """Неизвестное окружение — ошибка старта, а не молчаливое «почти прод»: любое + значение, кроме development, выбирает прод-пути к данным и выключает dev-вход.""" + if value.lower() not in _APP_ENVS: + raise ValueError( + f"APP_ENV={value!r} не поддерживается — допустимо: {', '.join(_APP_ENVS)}" + ) + return value + @model_validator(mode="after") def _forbid_default_secrets_in_prod(self) -> "Settings": """Fail-fast: в production не стартуем с дефолтными/слабыми секретами (#59). Деплой, скопировавший .env.example дословно (или забывший поле), иначе поднялся бы с общеизвестным ключом подписи JWT (подделка любого токена, включая админский) и - известным паролем администратора. В dev/test проверка не мешает — там дефолты норма.""" + известным паролем администратора. В dev проверка не мешает — там дефолты норма.""" if self.app_env.lower() != "production": return self problems: list[str] = [] diff --git a/backend/app/main.py b/backend/app/main.py index b91cb9c..856034c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -132,7 +132,7 @@ async def _lifespan(_app: FastAPI): hub.bind_loop(asyncio.get_running_loop()) # В DEV приложение само подтягивает справочники и админа из .env при старте - # (в test/prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0). + # (в prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0). if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0": try: from app.bootstrap import bootstrap @@ -151,7 +151,7 @@ async def _lifespan(_app: FastAPI): def create_app() -> FastAPI: - # Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev/test: она нужна для + # Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev: она нужна для # `npm run gen:api` (генерация типов фронта) и удобной отладки. В production закрываем — # незачем облегчать разведку поверхности API анонимам (#61). docs_enabled = not settings.is_production @@ -165,7 +165,7 @@ def create_app() -> FastAPI: ) # CORS нужен только в dev (vite на :5173 и API на :8000 — разные origin). - # В test/prod (и dev через VPS-туннель) всё single-origin → CORS не подключаем. + # В prod (и в dev через VPS-туннель) всё single-origin → CORS не подключаем. if settings.is_development and settings.cors_origins_list: app.add_middleware( CORSMiddleware, @@ -204,7 +204,7 @@ def create_app() -> FastAPI: app.include_router(r, prefix="/api") # DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development - # и только если код физически есть (в test/prod-образе dev_*-файлы исключены + # и только если код физически есть (в прод-образе dev_*-файлы исключены # .dockerignore, импорт просто не выполнится). if settings.is_development: for mod_name in ("dev_auth", "dev_admin"): diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index a3e5314..a07e8da 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -153,7 +153,7 @@ def set_user_password( # Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в -# routers/dev_admin.py (исключён из прод/тест-образа). В проде аккаунт только +# routers/dev_admin.py (исключён из прод-образа). В проде аккаунт только # отключается (PATCH is_active), удалять нельзя. diff --git a/backend/app/routers/dev_admin.py b/backend/app/routers/dev_admin.py index 9e699f3..50e89af 100644 --- a/backend/app/routers/dev_admin.py +++ b/backend/app/routers/dev_admin.py @@ -1,9 +1,9 @@ """DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока. -Этот файл ФИЗИЧЕСКИ исключён из прод/тест-образа (.dockerignore), а роутер +Этот файл ФИЗИЧЕСКИ исключён из прод-образа (.dockerignore), а роутер подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так -возможность удаления не попадает ни в прод, ни в тест — там аккаунт можно только +возможность удаления не попадает в прод — там аккаунт можно только отключить (PATCH is_active). Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются — diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py index d9b2334..c445df2 100644 --- a/backend/app/services/admin_service.py +++ b/backend/app/services/admin_service.py @@ -74,7 +74,7 @@ def set_player_password(session: Session, user_id: int, new_password: str) -> Us # Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно: -# логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа). +# логика вынесена в routers/dev_admin.py (файл исключён из прод-образа). # ─── Группы ────────────────────────────────────────────────────────────────── diff --git a/backend/tests/test_api_hardening.py b/backend/tests/test_api_hardening.py index 4de3185..d27bb3e 100644 --- a/backend/tests/test_api_hardening.py +++ b/backend/tests/test_api_hardening.py @@ -1,4 +1,4 @@ -"""Хардненинг API: раскрытие схемы закрыто в production, открыто в dev/test (#61, F6).""" +"""Хардненинг API: раскрытие схемы закрыто в production, открыто в dev (#61, F6).""" from __future__ import annotations from fastapi.testclient import TestClient diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 7079c1b..af2e252 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -23,14 +23,12 @@ def test_enabled_methods_by_env(monkeypatch): monkeypatch.setattr(settings, "app_env", "development") assert set(enabled_methods()) == {"password", "telegram", "stub"} - monkeypatch.setattr(settings, "app_env", "test") - assert enabled_methods() == ["password", "telegram"] # test (прод-клон) → без stub monkeypatch.setattr(settings, "app_env", "production") assert enabled_methods() == ["password", "telegram"] # prod → без stub def test_env_flags_and_db_path(monkeypatch): - """dev → файл дева; test и prod → том /data (общая ветвь is_development).""" + """dev → файл дева; prod → том /data.""" from app.core.config import settings monkeypatch.setattr(settings, "dev_database_url", "sqlite:///dev.db") @@ -38,8 +36,6 @@ def test_env_flags_and_db_path(monkeypatch): monkeypatch.setattr(settings, "app_env", "development") assert settings.is_development and settings.database_url == "sqlite:///dev.db" - monkeypatch.setattr(settings, "app_env", "test") - assert settings.is_test and settings.database_url == "sqlite:////data/prod.db" monkeypatch.setattr(settings, "app_env", "production") assert settings.is_production and settings.database_url == "sqlite:////data/prod.db" diff --git a/backend/tests/test_config_security.py b/backend/tests/test_config_security.py index 8c73066..965d38e 100644 --- a/backend/tests/test_config_security.py +++ b/backend/tests/test_config_security.py @@ -65,3 +65,15 @@ def test_development_allows_defaults(): admin_password=config._DEFAULT_ADMIN_PASSWORD, ) assert s.is_development + + +@pytest.mark.parametrize("app_env", ["test", "staging", ""]) +def test_unknown_app_env_rejected(app_env): + # Отдельного test-контура больше нет: такое значение не должно молча включать прод-пути. + with pytest.raises(ValidationError): + config.Settings(app_env=app_env) + + +def test_app_env_case_insensitive(): + s = config.Settings(app_env="Development") + assert s.is_development diff --git a/deploy/README.md b/deploy/README.md index 3a74a4d..6388984 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,6 +1,6 @@ # Публикация: домены, VPS, туннели -Приложение крутится дома (Pi — прод) и на твоём ПК (dev/test). Дома белого IP нет +Приложение крутится дома (Pi — прод) и на твоём ПК (dev). Дома белого IP нет (CGNAT), поэтому наружу выставляем через **VPS-привратник**: на нём Caddy терминирует HTTPS твоими сертификатами и проксирует трафик в SSH reverse-туннели, которые приложение само открывает к VPS. @@ -11,10 +11,8 @@ HTTPS твоими сертификатами и проксирует трафи │ ▲ туннель-КОНТЕЙНЕР │ │ └── Pi : app:8000 PROD │ forbidden-stars.ru ──►│ :443 (cert твой) → 127.0.0.1:9001 │ - │ ▲ контейнер (test) ИЛИ │ - │ ▲ ssh с ПК (dev) │ - │ ├── ПК test : app:8000 │ - │ └── ПК dev : vite:5173 │ + │ ▲ ssh с ПК (по требованию) │ + │ └── ПК dev : vite:5173 DEV │ └───────────────────────────────────────────────────┘ ``` @@ -23,32 +21,26 @@ HTTPS твоими сертификатами и проксирует трафи хост нет** — наружу его выставляет только туннель-контейнер (`ssh -R 9000:app:8000` к VPS). Работает постоянно: при обрыве `ssh` завершается, и Docker перезапускает контейнер (`restart: unless-stopped`). См. [`pi/`](pi/README.md). -- **TEST** — ПК. То же самое: `docker compose -f docker-compose.test.yml up` собирает образы - локально и поднимает `app` + `tunnel` (`ssh -R 9001:app:8000`) + `backup` (без расписания и - без VPS). Портов на хост нет — тест виден только на `forbidden-stars.ru`. Обычно - запускается лаунчером при `APP_ENV=test`. - **DEV** — ПК, нативно (`uvicorn`+`vite`). По умолчанию только на localhost; при `LOCAL_PUBLIC=vps` лаунчер (`run.ps1` / `run.sh`) дополнительно поднимает SSH-туннель с ПК - (`ssh -R 9001:localhost:5173`) → дев виден на `forbidden-stars.ru`. -- DEV и TEST делят слот **9001** (`forbidden-stars.ru`) → поднимай что-то **одно за раз**. - PROD на отдельном слоте **9000** (`forbiddenstars.ru`) — работает независимо. + (`ssh -R 9001:localhost:5173`) → дев виден на `forbidden-stars.ru` (слот **9001**). +- PROD на отдельном слоте **9000** (`forbiddenstars.ru`) — работает независимо от dev. Временный прод на ПК (`docker-compose.temp.yml`) тоже занимает **9000** — одновременно с Pi нельзя. Ключи туннеля (приватные, в git не идут; публичные части — в `authorized_keys` пользователя `tunnel` на VPS): - **Pi** — `TUNNEL_KEY_B64` (base64 приватного ключа) в `.env`; файла ключа на Pi нет. -- **ПК, test и временный прод** — файл `deploy/tunnel/id_tunnel`, монтируется в туннель-контейнер. +- **ПК, временный прод** — файл `deploy/tunnel/id_tunnel`, монтируется в туннель-контейнер. - **ПК, dev** — `run.ps1`/`run.sh` зовут системный `ssh` без `-i`, то есть с ключом по умолчанию из `~/.ssh`. Он должен быть в `authorized_keys` (можно тем же, что `id_tunnel`). Настройка по шагам: 1. **VPS** — [`vps/README.md`](vps/README.md): Caddy, файрвол, пользователь `tunnel`, сертификаты, `Caddyfile`. 2. **Pi (прод)** — [`pi/README.md`](pi/README.md): ключ туннеля в `TUNNEL_KEY_B64`, `.env`, `docker compose up -d`. -3. **ПК (dev/test)** — ключ в `deploy/tunnel/id_tunnel` (для test-контейнера) и/или ключ по - умолчанию в `~/.ssh` (для dev-туннеля); pubkey — в `authorized_keys` у `tunnel@VPS`. +3. **ПК (dev)** — ключ по умолчанию в `~/.ssh` (для dev-туннеля) и, если нужен временный прод, + файл `deploy/tunnel/id_tunnel`; pubkey — в `authorized_keys` у `tunnel@VPS`. 4. **Бэкапы** — [`backup/README.md`](backup/README.md): контейнер `backup` (restic) делает - снимки на Pi и на VPS (`fsbackup@VPS`, только SFTP), скрипты ПК скачивают их и проверяют - восстановление на тест-клоне. + снимки на Pi и на VPS (`fsbackup@VPS`, только SFTP), скрипты ПК скачивают их на ПК. Секреты не в git: сертификаты/ключи (`*.pem`, `*.key`, `id_tunnel*`, `id_backup*`) живут на VPS/Pi/ПК, в репозитории только `Caddyfile`, страница-заглушка, образы `deploy/tunnel/` и diff --git a/deploy/backup/README.md b/deploy/backup/README.md index 949a464..8048f06 100644 --- a/deploy/backup/README.md +++ b/deploy/backup/README.md @@ -16,7 +16,7 @@ 4. [Сборка и публикация образов](#шаг-4-сборка-и-публикация-образов) — ПК 5. [Pi: включить бэкапы](#шаг-5-pi-включить-бэкапы) — Pi 6. [ПК: доступ к Pi и выгрузка бэкапов](#шаг-6-пк-доступ-к-pi-и-выгрузка-бэкапов) — ПК -7. [Учебное восстановление на тест-клоне](#шаг-7-учебное-восстановление-на-тест-клоне) — ПК +7. [Проверка скачанного архива](#шаг-7-проверка-скачанного-архива) — ПК 8. [Восстановление прода](#8-восстановление-прода) — Pi 9. [Катастрофа: Pi умер](#9-катастрофа-pi-умер) — новый Pi 10. [Повседневные действия](#10-повседневные-действия) @@ -562,43 +562,42 @@ --- -## Шаг 7. Учебное восстановление на тест-клоне +## Шаг 7. Проверка скачанного архива -**Где:** ПК с Docker Desktop. **Зачем:** убедиться, что бэкап действительно -восстанавливается, **до** того как это понадобится по-настоящему. Прод не затрагивается. +**Где:** ПК. **Зачем:** убедиться, что БД в скачанном снимке целая и в ней те данные, что +ожидаются, **до** того как это понадобится по-настоящему. Прод не затрагивается. -> Данные тест-клона на ПК будут заменены данными из архива. Прежние данные тест-клона -> сохраняются в его собственный снимок `pre-restore`. +> Отдельного тестового контейнера для учебного восстановления больше нет. Сам механизм +> `restore`/`import` отрабатывает только на Pi ([раздел 8](#8-восстановление-прода)); здесь +> проверяется содержимое архива. -1. Восстановите скачанный архив в тест-клон (подставьте имя своего файла): +1. Распакуйте последний скачанный архив во временную папку: ```powershell - .\scripts\fs-backup.ps1 restore-test -File backups\fs_20260914_0400_3f2a9c1d.tar + $f = (Get-ChildItem backups\fs_*.tar | Sort-Object LastWriteTime | Select-Object -Last 1).FullName + $d = Join-Path $env:TEMP "fs-check"; New-Item -ItemType Directory -Force $d | Out-Null + tar -xf $f -C $d ``` - В первый раз Docker соберёт образы тест-клона — это несколько минут. +2. Откройте `%TEMP%\fs-check\forbidden_stars.db` в [DB Browser for SQLite](https://sqlitebrowser.org) + (вкладка «Выполнить SQL») и выполните: -2. Проверьте, что приложение тест-клона поднялось: + ```sql + PRAGMA integrity_check; + SELECT (SELECT count(*) FROM users WHERE role = 'player') AS players, + (SELECT count(*) FROM matches) AS matches; + ``` + +3. Закройте DB Browser и удалите временную папку — данные в ней не зашифрованы: ```powershell - docker compose -f docker-compose.test.yml ps - docker compose -f docker-compose.test.yml logs --tail 20 app + Remove-Item -Recurse -Force (Join-Path $env:TEMP "fs-check") ``` -3. Посмотрите на сайт: в `.env` на ПК временно поставьте `APP_ENV=test` и запустите - `.\run.ps1`. Тест-клон откроется на `https://forbidden-stars.ru`: проверьте топ, - профили, историю партий. Потом верните `APP_ENV=development`. - **Что должно получиться:** -- в выводе `restore-test`: - - `Развёрнутые данные в порядке: игроков N, партий M.` — те же числа, что в `list` на Pi; - - `Данные восстановлены.`; - - `Done. The test clone now runs on the restored data.`; -- `docker compose ... ps` показывает `app` в состоянии `Up … (healthy)`; -- на сайте тест-клона — данные прода на момент снимка. - -> Этим же способом можно восстановить в тест-клон старые архивы `fs_*.tar.gz` прежнего -> `scripts/backup.sh`: `.\scripts\fs-backup.ps1 restore-test -File backups\fs_20260710_140914.tar.gz`. +- `PRAGMA integrity_check` → `ok`; +- `players` и `matches` совпадают со столбцами `Игроков` / `Партий` этого снимка в `list` на Pi; +- в папке рядом с БД есть `uploads\…` (фото партий) и, если заводились, `achievements\…`. --- @@ -651,8 +650,9 @@ Если локальный репозиторий повреждён или пуст, смотрите копию на VPS: `docker compose exec backup fs-backup list vps`. -2. **По желанию, но рекомендуется:** сначала отрепетируйте на ПК: - `.\scripts\fs-backup.ps1 pull -Snapshot `, затем `restore-test` ([шаг 7](#шаг-7-учебное-восстановление-на-тест-клоне)). +2. **По желанию, но рекомендуется:** перед восстановлением проверьте выбранный снимок на ПК: + `.\scripts\fs-backup.ps1 pull -Snapshot `, затем [шаг 7](#шаг-7-проверка-скачанного-архива). + Заодно у вас останется копия этого снимка вне Pi. 3. Остановите приложение. Сайт покажет страницу «Технические шоколадки»: @@ -806,7 +806,7 @@ - **Перед каждым обновлением прода** — `now -Tag before-update` (метка — латиница, цифры, `.`, `_`, `-`). - **Раз в месяц:** - скачать снимок на ПК (`pull`); - - раз в пару месяцев сделать учебное восстановление (`restore-test`); + - проверить скачанный архив ([шаг 7](#шаг-7-проверка-скачанного-архива)); - удалить с ПК старые архивы — они не зашифрованы. - **Иногда:** посмотреть `docker compose ps`. Статус `unhealthy` у `backup` означает, что бэкапы перестали проходить (причину покажет `fs-backup status`). @@ -899,7 +899,7 @@ docker volume rm <имя тома> | `WARNING: UNPROTECTED PRIVATE KEY FILE!` | у файла ключа слишком открытые права (ключ создан в Git Bash/WSL или скопирован) | `icacls <путь к ключу> /inheritance:r /grant:r "$($env:USERNAME):(R)"` | | `Checksum mismatch … run pull again` | файл повредился при передаче | повторить `pull` (битый файл уже удалён) | | `Already downloaded: …` | этот снимок уже скачан | ничего не делать; нужен новый — сначала `now`, потом `pull` | -| `restore-test`: `Import failed … The test clone data was not changed` | архив повреждён или неполный | скачать заново (`pull`); текст ошибки выше в выводе | +| шаг 7: `integrity_check` не `ok` или счётчики не совпадают с `list` | архив повреждён или скачан не тот снимок | удалить файл из `backups\` и скачать заново (`pull -Snapshot `); если повторяется — `fs-backup verify` на Pi | --- @@ -935,11 +935,9 @@ docker volume rm <имя тома> |---|---| | `status`, `list [-Repo vps]`, `now [-Tag имя]`, `verify` | то же, что на Pi, но с ПК | | `pull [-Snapshot ID] [-Repo vps]` | скачать снимок в `backups\` со сверкой sha256 | -| `restore-test -File <архив>` | учебное восстановление в локальный тест-клон | -| `-Target test` | выполнить `status`/`list`/`now`/`verify`/`pull` на локальном тест-клоне | В bash-версии те же команды пишутся так: `list vps`, `now --tag имя`, -`pull --repo vps`, `restore-test <архив>`, `--test` первым аргументом. +`pull --repo vps`. ### Переменные `.env` @@ -987,7 +985,7 @@ docker volume rm <имя тома> - [ ] На Pi первый бэкап прошёл в `local` и `vps`, `status` без ошибок (шаг 5) - [ ] `docker compose ps` показывает `backup` `(healthy)` (шаг 5) - [ ] С ПК `status`, `list`, `pull` работают без пароля (шаг 6) -- [ ] Учебное восстановление на тест-клоне прошло, данные на месте (шаг 7) +- [ ] Скачанный архив проверен: БД целая, числа совпадают с `list` (шаг 7) **Через сутки** - [ ] В `list` появился снимок с меткой `scheduled` в 04:00 diff --git a/deploy/tunnel/tunnel.sh b/deploy/tunnel/tunnel.sh index 78df777..7c99650 100644 --- a/deploy/tunnel/tunnel.sh +++ b/deploy/tunnel/tunnel.sh @@ -10,7 +10,7 @@ VPS_TUNNEL_USER="${VPS_TUNNEL_USER:-tunnel}" UPSTREAM="${UPSTREAM:-app:8000}" # Источник приватного ключа: либо TUNNEL_KEY_B64 (base64 в .env — прод: только compose+env), -# либо смонтированный файл /key/id_tunnel (dev/test, где репозиторий есть на хосте). +# либо смонтированный файл /key/id_tunnel (временный прод на ПК, где репозиторий есть на хосте). mkdir -p /root/.ssh KEY=/root/.ssh/id_tunnel if [ -n "${TUNNEL_KEY_B64:-}" ]; then diff --git a/deploy/vps/Caddyfile b/deploy/vps/Caddyfile index c4f4f7f..08ad849 100644 --- a/deploy/vps/Caddyfile +++ b/deploy/vps/Caddyfile @@ -2,7 +2,7 @@ # Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели: # # forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD -# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST +# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV # # Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к # приложению передаёт X-Forwarded-Proto=https / X-Forwarded-For / Host — @@ -38,10 +38,10 @@ -Server } - # Content-Security-Policy подготовлена, но ВЫКЛЮЧЕНА до проверки на test-клоне: строгая - # политика легко ломает SPA (инлайновые стили Vite), Telegram-виджет входа (скрипт с - # telegram.org + iframe oauth.telegram.org) и EventSource (/api/events). Раскомментировать - # после проверки на forbidden-stars.ru, что вход и реал-тайм работают (#61). + # Content-Security-Policy подготовлена, но ВЫКЛЮЧЕНА до проверки: строгая политика легко + # ломает SPA (инлайновые стили Vite), Telegram-виджет входа (скрипт с telegram.org + iframe + # oauth.telegram.org) и EventSource (/api/events). Раскомментировать после проверки, + # что вход и реал-тайм работают (#61). # header Content-Security-Policy "default-src 'self'; script-src 'self' https://telegram.org https://oauth.telegram.org; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-src https://oauth.telegram.org; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" # HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый diff --git a/deploy/vps/README.md b/deploy/vps/README.md index d2bb558..ecc4a94 100644 --- a/deploy/vps/README.md +++ b/deploy/vps/README.md @@ -1,11 +1,11 @@ # VPS (186.246.51.17) — реверс-прокси Caddy + точка входа SSH-туннелей Единственная публичная точка. На VPS: Caddy терминирует HTTPS твоими сертификатами -для двух доменов и проксирует трафик в SSH reverse-туннели от Pi (прод) и ПК (dev/test). +для двух доменов и проксирует трафик в SSH reverse-туннели от Pi (прод) и ПК (dev). ``` -forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, постоянно) PROD -forbidden-stars.ru → 127.0.0.1:9001 ← ПК (контейнер test или ssh dev, по требованию) DEV/TEST +forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, постоянно) PROD +forbidden-stars.ru → 127.0.0.1:9001 ← ПК (ssh из лаунчера, по требованию) DEV ``` > Туннель `ssh -R` по умолчанию слушает на loopback VPS (127.0.0.1) — ровно туда смотрит @@ -106,8 +106,8 @@ systemctl reload caddy > файл читается на каждый запрос). ## 7. Проверка -1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev/test на ПК - (лаунчер `run.ps1`: dev — при `LOCAL_PUBLIC=vps`, test — при `APP_ENV=test`). +1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev на ПК + (лаунчер `run.ps1` при `LOCAL_PUBLIC=vps`). 2. Открой `https://forbiddenstars.ru` и `https://forbidden-stars.ru`. 3. Пока соответствующий туннель не поднят — Caddy отдаёт страницу-заглушку «Технические шоколадки» (HTTP 503), это ожидаемо. diff --git a/docker-compose.temp.yml b/docker-compose.temp.yml index 3baaf72..f949d8e 100644 --- a/docker-compose.temp.yml +++ b/docker-compose.temp.yml @@ -5,7 +5,7 @@ # # Отличия от docker-compose.yml (прод на Pi): # • локальный образ (сборка x86 на ПК), НЕ из реестра и НЕ пушится; -# • отдельный проект (name) и свои тома — не конфликтует с dev/test на этом ПК. +# • отдельный проект (name) и свои тома — не конфликтует с dev на этом ПК. # Всё остальное — как у прода (APP_ENV=production, туннель на 9000, лимиты, healthcheck). # # Запуск: docker compose -f docker-compose.temp.yml up -d --build diff --git a/docker-compose.yml b/docker-compose.yml index 23697e6..bda1dd3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,7 +84,7 @@ services: restart: unless-stopped environment: BACKUP_PASSWORD: ${BACKUP_PASSWORD:-} # пароль шифрования; пусто = бэкапы отключены - BACKUP_HOSTNAME: fs-prod # имя хоста в снимках (у тест-клона — fs-test) + BACKUP_HOSTNAME: fs-prod # имя хоста в снимках BACKUP_SCHEDULE: ${BACKUP_SCHEDULE:-0 4 * * *} BACKUP_VERIFY_SCHEDULE: ${BACKUP_VERIFY_SCHEDULE:-30 5 * * 0} BACKUP_KEEP_DAILY: ${BACKUP_KEEP_DAILY:-14} diff --git a/frontend/src/pages/admin/AdminAccountsPage.tsx b/frontend/src/pages/admin/AdminAccountsPage.tsx index cc1b46f..29294e4 100644 --- a/frontend/src/pages/admin/AdminAccountsPage.tsx +++ b/frontend/src/pages/admin/AdminAccountsPage.tsx @@ -6,7 +6,7 @@ import { Spinner } from "../../components/Spinner"; import { useToast } from "../../context/ToastContext"; import { useAdminSetPassword, useAdminUpdateUser, useAdminUsers } from "../../hooks/admin"; // DEV-ТОЛЬКО: удаление аккаунтов. Импорт используется лишь под import.meta.env.DEV, -// поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход). +// поэтому в прод-сборке вырезается тришейкингом (как и dev-вход). import { DevDeleteAccountButton } from "./DevDeleteAccountButton"; export function AdminAccountsPage() { diff --git a/frontend/src/pages/admin/DevDeleteAccountButton.tsx b/frontend/src/pages/admin/DevDeleteAccountButton.tsx index 3b0b7fc..e3c75a4 100644 --- a/frontend/src/pages/admin/DevDeleteAccountButton.tsx +++ b/frontend/src/pages/admin/DevDeleteAccountButton.tsx @@ -10,9 +10,9 @@ import { useToast } from "../../context/ToastContext"; * DEV-ТОЛЬКО кнопка жёсткого удаления аккаунта. * * Эндпоинт `DELETE /api/admin/dev/users/{id}` существует только в dev-сборке бэкенда - * (backend/app/routers/dev_admin.py, исключён из прод/тест-образа). Этот модуль + * (backend/app/routers/dev_admin.py, исключён из прод-образа). Этот модуль * рендерится лишь под `import.meta.env.DEV` в AdminAccountsPage, поэтому в прод-сборке - * он не используется и вырезается тришейкингом — в прод/тест удаление недоступно. + * он не используется и вырезается тришейкингом — в проде удаление недоступно. */ export function DevDeleteAccountButton({ userId, @@ -39,7 +39,7 @@ export function DevDeleteAccountButton({ @@ -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()} + /> +

О партии