v1.5 — Обновление системы рейтинга, переход на elo с модифицирующими коэффициентами.

This commit is contained in:
2026-09-30 13:51:44 +03:00
93 changed files with 5366 additions and 1361 deletions
+14 -5
View File
@@ -1,13 +1,23 @@
# Шаблоны отсчитываются от корня контекста: без «**/» правило ловит только корень
# (так `data/` пропускал backend/data/dev/ с dev-БД и загрузками в образ, #71).
# Python # Python
**/__pycache__/ **/__pycache__/
**/*.pyc **/*.pyc
**/*.egg-info/
backend/.venv/ backend/.venv/
backend/.pytest_cache/ backend/.pytest_cache/
backend/*.db
backend/*.db-wal
backend/*.db-shm
backend/openapi.json backend/openapi.json
# Данные: dev-БД, загрузки и ачивки дева (backend/data/dev/), любые файлы SQLite и
# локальные копии бэкапов — ни в образ, ни в контекст сборки
backend/data/
data/
backups/
**/*.db
**/*.db-wal
**/*.db-shm
# Node / сборка фронта # Node / сборка фронта
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
@@ -23,8 +33,7 @@ backend/tests/
backend/pyproject.toml backend/pyproject.toml
# Прочее # Прочее
.env **/.env
data/
**/.DS_Store **/.DS_Store
*.md *.md
.claude/ .claude/
+30 -19
View File
@@ -1,13 +1,12 @@
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# Forbidden Stars — единый .env (dev / test / prod) # Forbidden Stars — единый .env (dev / prod)
# Скопируйте в .env, заполните секреты. Реальный .env в git НЕ идёт. # Скопируйте в .env, заполните секреты. Реальный .env в git НЕ идёт.
# Окружение — строкой APP_ENV (ниже); публикация локалки наружу — LOCAL_PUBLIC. # Окружение — строкой APP_ENV (ниже); публикация локалки наружу — LOCAL_PUBLIC.
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ──────────────────────────────────────────────────── # ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ────────────────────────────────────────────────────
# Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать: # Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать:
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, вход TG+ник # development — нативно: uvicorn --reload + vite, БД в ./data/dev/, есть вход по нику (stub)
# test — прод-клон в Docker локально (порт 8080), вход только TG
# production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d). # production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d).
# Прод-контейнер ИГНОРИРУЕТ это значение и всегда production. # Прод-контейнер ИГНОРИРУЕТ это значение и всегда production.
APP_ENV=development APP_ENV=development
@@ -15,21 +14,24 @@ APP_ENV=development
# ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ───────────────────────────────────── # ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ─────────────────────────────────────
# LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost; # LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost;
# vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru. # vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru.
# TEST и PROD выставляют себя сами через туннель-КОНТЕЙНЕР (docker-compose*.yml) — им # Тогда любому посетителю домена открыты dev-инструменты: вход по нику без пароля,
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже они тоже читают. # список/создание игроков, жёсткое удаление аккаунтов, Swagger. Дефолтные SECRET_KEY
# и ADMIN_PASSWORD при vps не дают стартовать — задайте свои (#69).
# PROD выставляет себя сам через туннель-КОНТЕЙНЕР (docker-compose*.yml) — ему
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже он тоже читает.
LOCAL_PUBLIC=local LOCAL_PUBLIC=local
# Параметры VPS: их читают и dev-туннель (run.ps1), и туннель-контейнер test/prod. # Параметры VPS: их читают и dev-туннель (run.ps1), и туннель-контейнер прода.
# Ключ туннеля — в deploy/tunnel/id_tunnel (в git не идёт); pubkey → authorized_keys у tunnel@VPS. # Ключ туннеля — в deploy/tunnel/id_tunnel (в git не идёт); pubkey → authorized_keys у tunnel@VPS.
VPS_TUNNEL_HOST=186.246.51.17 VPS_TUNNEL_HOST=186.246.51.17
VPS_TUNNEL_USER=tunnel VPS_TUNNEL_USER=tunnel
# VPS_TUNNEL_PORT обычно НЕ задают — каждый контур берёт свой слот по умолчанию: # VPS_TUNNEL_PORT обычно НЕ задают — каждый контур берёт свой слот по умолчанию:
# dev (run.ps1) → 9001, прод-контейнер → 9000, test-контейнер → 9001. # dev (run.ps1) → 9001, прод-контейнер → 9000.
# Раскомментируй и переопредели, только если нужен нестандартный слот. # Раскомментируй и переопредели, только если нужен нестандартный слот.
#VPS_TUNNEL_PORT=9001 #VPS_TUNNEL_PORT=9001
# Приватный ключ туннеля в base64 — чтобы на Pi хватило только docker-compose.yml + .env # Приватный ключ туннеля в 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 # Git Bash / Linux: base64 -w0 deploy/tunnel/id_tunnel
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "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. # Pubkey (deploy/tunnel/id_tunnel.pub) добавь в authorized_keys у tunnel@VPS.
@@ -42,30 +44,31 @@ ADMIN_NICKNAME=Администратор
ADMIN_BOOTSTRAP_ENABLED=true ADMIN_BOOTSTRAP_ENABLED=true
# ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ─────────────────────────────────────────────────── # ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ───────────────────────────────────────────────────
# Методы входа задаёт APP_ENV: dev → Telegram + stub (вход по нику), prod → только # Вход везде — логин/пароль и Telegram; в development ещё stub (по нику без пароля).
# Telegram. Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у # Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
# BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod) # BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod)
# и forbidden-stars.ru (dev/test). # и forbidden-stars.ru (dev).
TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_TOKEN=
TELEGRAM_BOT_USERNAME= 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= PUBLIC_BASE_URL=
# ─── БЕЗОПАСНОСТЬ / СЕССИИ ──────────────────────────────────────────────────── # ─── БЕЗОПАСНОСТЬ / СЕССИИ ────────────────────────────────────────────────────
# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))" # Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))"
# ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как # ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как
# подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно). # подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно).
# У dev и prod ключи должны быть РАЗНЫМИ: иначе токен, подписанный на dev, примет прод.
SECRET_KEY=change-me-dev-secret-not-for-production SECRET_KEY=change-me-dev-secret-not-for-production
JWT_ALGORITHM=HS256 JWT_ALGORITHM=HS256
JWT_USER_TTL_MINUTES=10080 JWT_USER_TTL_MINUTES=10080
JWT_ADMIN_TTL_MINUTES=480 JWT_ADMIN_TTL_MINUTES=480
# COOKIE_SECURE задаётся АВТОМАТИЧЕСКИ по окружению (HTTPS-домен ⇒ Secure-cookie): # COOKIE_SECURE задаётся АВТОМАТИЧЕСКИ по окружению (HTTPS-домен ⇒ Secure-cookie):
# dev+localhost → false; dev через VPS, test, prod → true. Вручную задавать НЕ нужно. # dev+localhost → false; dev через VPS, prod → true. Вручную задавать НЕ нужно.
COOKIE_DOMAIN= COOKIE_DOMAIN=
# ─── БАЗА ДАННЫХ (структура общая, файлы РАЗНЫЕ; выбор по APP_ENV) ──────────── # ─── БАЗА ДАННЫХ (структура общая, файлы РАЗНЫЕ; выбор по APP_ENV) ────────────
# dev → DEV_DATABASE_URL (файл в ./data/dev/); test и prod → PROD_DATABASE_URL # dev → DEV_DATABASE_URL (файл в ./data/dev/); prod → PROD_DATABASE_URL
# (том /data; у test и prod это РАЗНЫЕ тома контейнера, см. docker-compose*.yml). # (том /data контейнера, см. docker-compose*.yml).
DEV_DATABASE_URL=sqlite:///./data/dev/forbidden_stars.db DEV_DATABASE_URL=sqlite:///./data/dev/forbidden_stars.db
PROD_DATABASE_URL=sqlite:////data/forbidden_stars.db PROD_DATABASE_URL=sqlite:////data/forbidden_stars.db
@@ -78,7 +81,7 @@ DEV_ACHIEVEMENTS_DIR=./data/dev/achievements
PROD_ACHIEVEMENTS_DIR=/data/achievements PROD_ACHIEVEMENTS_DIR=/data/achievements
# ─── ОБРАЗЫ ПРОДА (реестр для docker compose pull на Pi) ────────────────────── # ─── ОБРАЗЫ ПРОДА (реестр для docker compose pull на Pi) ──────────────────────
# Образы собираются под arm64 на ПК (scripts/build-push.sh) и пушатся в Gitea-реестр, # Образы собираются под arm64 на ПК (scripts/build-push.ps1; в Linux — .sh) и пушатся в Gitea-реестр,
# а Pi их тянет (docker compose pull). Owner в пути — строчными. Тег можно версионировать. # а Pi их тянет (docker compose pull). Owner в пути — строчными. Тег можно версионировать.
# Перед пушем/пуллом: docker login gitea.arseniev.info # Перед пушем/пуллом: docker login gitea.arseniev.info
IMAGE_REGISTRY=gitea.arseniev.info/notbigghost IMAGE_REGISTRY=gitea.arseniev.info/notbigghost
@@ -86,7 +89,8 @@ IMAGE_TAG=latest
# ─── РЕСУРСЫ ПРОД-КОНТЕЙНЕРА (docker-compose.yml) ───────────────────────────── # ─── РЕСУРСЫ ПРОД-КОНТЕЙНЕРА (docker-compose.yml) ─────────────────────────────
# Лимиты под Raspberry Pi. Не заданы → дефолты compose (512m / 1.5 CPU). # Лимиты под Raspberry Pi. Не заданы → дефолты compose (512m / 1.5 CPU).
# Подними, если у Pi больше RAM/ядер. # Подними, если у Pi больше RAM/ядер. Лимит памяти работает, только если на Pi включён
# memory cgroup (cgroup_enable=memory в cmdline.txt) — см. deploy/pi/README.md, раздел 1.
#APP_MEM_LIMIT=512m #APP_MEM_LIMIT=512m
#APP_CPUS=1.5 #APP_CPUS=1.5
@@ -120,13 +124,20 @@ BACKUP_VPS_DIR=/srv/fs-backups/restic
# в deploy/backup/id_backup (в git НЕ идёт), pubkey — в authorized_keys у fsbackup@VPS. # в deploy/backup/id_backup (в git НЕ идёт), pubkey — в authorized_keys у fsbackup@VPS.
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\backup\id_backup"))) # PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\backup\id_backup")))
BACKUP_SSH_KEY_B64= BACKUP_SSH_KEY_B64=
# Отчёты о бэкапах в Telegram и команды /backups, /status (deploy/backup/README.md, раздел 10).
# id своего чата: напишите боту /start → `docker compose exec backup fs-backup telegram chats`. Несколько — через
# запятую. Пусто = отчёты выключены. Токен по умолчанию — TELEGRAM_BOT_TOKEN приложения.
BACKUP_TELEGRAM_CHAT_ID=
#BACKUP_TELEGRAM_BOT_TOKEN=
# Только для ПК (scripts/fs-backup.ps1 / .sh): как зайти на Pi по SSH и где там лежит # Только для ПК (scripts/fs-backup.ps1 / .sh): как зайти на Pi по SSH и где там лежит
# docker-compose.yml прода. # docker-compose.yml прода.
BACKUP_PI_SSH=pi@192.168.1.10 BACKUP_PI_SSH=pi@192.168.1.10
BACKUP_PI_DIR=~/forbidden-stars BACKUP_PI_DIR=~/forbidden-stars
# ─── ПРОЧЕЕ ─────────────────────────────────────────────────────────────────── # ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
# Часовой пояс приложения (фикс. смещение в часах; МСК = 3) # Часовой пояс приложения (фикс. смещение в часах, −12..14; МСК = 3). Один на всех: в нём
# сервер ставит «дату игры», а фронт показывает время и принимает даты объявлений —
# пояс устройства игрока не учитывается.
APP_TZ_OFFSET_HOURS=3 APP_TZ_OFFSET_HOURS=3
# CORS нужен только в dev (фронт и API на разных портах); в prod single-origin # CORS нужен только в dev (фронт и API на разных портах); в prod single-origin
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
-13
View File
@@ -3,16 +3,3 @@
# Скрипты образов (entrypoint.sh, deploy/*/…) контейнер дополнительно чинит sed-ом при сборке. # Скрипты образов (entrypoint.sh, deploy/*/…) контейнер дополнительно чинит sed-ом при сборке.
*.sh text eol=lf *.sh text eol=lf
backend/entrypoint.sh text eol=lf backend/entrypoint.sh text eol=lf
# ── export-ignore: НЕ попадает в `git archive` (чистая выгрузка прод/тест) ─────
# В git эти файлы есть и доступны на всех ветках (нужны для разработки),
# но в архив деплоя (scripts/export-*.sh) не идут. На Docker-сборку НЕ влияет —
# там чистоту образа обеспечивает .dockerignore.
backend/tests/ export-ignore
backend/app/auth/dev_stub.py export-ignore
backend/app/routers/dev_auth.py export-ignore
backend/pyproject.toml export-ignore
README.md export-ignore
.gitignore export-ignore
.gitattributes export-ignore
.dockerignore export-ignore
+1 -1
View File
@@ -15,7 +15,7 @@ venv/
data/ data/
backend/dev.db* backend/dev.db*
# Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/test/prod) # Env / секреты — коммитим ТОЛЬКО шаблон .env.example (единый .env для dev/prod)
.env .env
!.env.example !.env.example
+118 -84
View File
@@ -1,29 +1,37 @@
# Forbidden Stars — учёт партий # Forbidden Stars — учёт партий
Мобильное веб-приложение для учёта партий настольной игры **Forbidden Stars**: Мобильное веб-приложение для учёта партий настольной игры **Forbidden Stars**:
профили игроков (вход по логину и паролю или через Telegram), группы, создание партий профили игроков (вход по логину и паролю или через Telegram), группы с приглашениями,
с рандомом фракций и фильтром по дополнениям группы, статистика и общий топ, админ-панель. создание партий с рандомом фракций и фильтром по дополнениям группы, фото партий,
уведомления, статистика и общий топ, админ-панель.
- **Бэкенд / ядро + API:** Python · FastAPI · SQLModel · SQLite - **Бэкенд / ядро + API:** Python · FastAPI · SQLModel · SQLite
- **Фронтенд:** React · Vite · TypeScript (SPA, общается с ядром только по REST API) - **Фронтенд:** React · Vite · TypeScript (SPA; с ядром общается по REST API, живые
- **Хостинг:** Raspberry Pi 4 (ARM64) в Docker обновления приходят SSE-потоком `/api/events`)
- **Хостинг:** Raspberry Pi 4 (ARM64) в Docker; наружу — через VPS-привратник (см. «Домен и публикация»)
## Структура ## Структура
``` ```
backend/ FastAPI: ядро, REST API, БД, миграции, seed backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты
frontend/ React + Vite SPA frontend/ React + Vite SPA
deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы)
scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК)
Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI) Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI)
docker-compose.yml docker-compose.yml прод на Pi: app + tunnel + backup
.env.example docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel
run.ps1 / run.sh единый лаунчер dev
.env.example шаблон единого .env
``` ```
## Локальная разработка ## Локальная разработка
Всё проверяется в `development` — отдельного тестового контейнера нет.
### Единый лаунчер (`run.ps1` / `run.sh`) ### Единый лаунчер (`run.ps1` / `run.sh`)
После разовой настройки (ниже) dev и test запускаются **одной командой** — что именно, После разовой настройки (ниже) dev запускается **одной командой** (лаунчер читает
решает `APP_ENV` в корневом `.env`: `APP_ENV` в корневом `.env`):
```powershell ```powershell
.\run.ps1 # Windows (Linux / macOS / Git Bash: ./run.sh) .\run.ps1 # Windows (Linux / macOS / Git Bash: ./run.sh)
@@ -31,9 +39,9 @@ docker-compose.yml
| `APP_ENV` в `.env` | что делает лаунчер | | `APP_ENV` в `.env` | что делает лаунчер |
|---|---| |---|---|
| `development` | `uvicorn --reload` (бэк) + `vite` (фронт) нативно, в двух окнах | | `development` | сначала `alembic upgrade head`, затем `uvicorn --reload` (бэк) + `vite` (фронт) нативно: `run.ps1` — в отдельных окнах, `run.sh` — в текущем терминале (Ctrl+C останавливает оба). При `LOCAL_PUBLIC=vps` дополнительно поднимает SSH-туннель на `forbidden-stars.ru` |
| `test` | `docker compose` прод-клон на :8080 (со сборкой образа) | | `production` | не запускает — прод деплоится отдельно (см. «Production» и «Git и деплой») |
| `production` | не запускает — прод деплоится отдельно (см. «Git и деплой») | | другое значение | отказ: допустимы только `development` и `production` (бэкенд тоже не стартует) |
Разовая настройка перед первым запуском — поднять venv бэка и зависимости фронта Разовая настройка перед первым запуском — поднять venv бэка и зависимости фронта
(после неё повседневный цикл — просто `.\run.ps1`). Vite проксирует `/api` на бэкенд. (после неё повседневный цикл — просто `.\run.ps1`). Vite проксирует `/api` на бэкенд.
@@ -47,8 +55,8 @@ python -m venv .venv
.\.venv\Scripts\Activate.ps1 # если ругается на политику: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned .\.venv\Scripts\Activate.ps1 # если ругается на политику: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
pip install -e ".[dev]" # .[dev] — один аргумент (пакет + dev-зависимости) pip install -e ".[dev]" # .[dev] — один аргумент (пакет + dev-зависимости)
Copy-Item ..\.env.example ..\.env # ЕДИНЫЙ .env лежит в КОРНЕ репозитория Copy-Item ..\.env.example ..\.env # ЕДИНЫЙ .env лежит в КОРНЕ репозитория
alembic upgrade head # применит миграции и сидинг alembic upgrade head # применит миграции и сидинг справочников
python -m app.bootstrap # создаст/синхронизирует администратора из .env python -m app.bootstrap # справочники + создаст/синхронизирует администратора из .env (опционально)
uvicorn app.main:app --reload --timeout-graceful-shutdown 2 # http://localhost:8000 (Swagger: /api/docs) uvicorn app.main:app --reload --timeout-graceful-shutdown 2 # http://localhost:8000 (Swagger: /api/docs)
``` ```
@@ -75,6 +83,10 @@ python -m app.bootstrap
uvicorn app.main:app --reload --timeout-graceful-shutdown 2 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-поток > **`--timeout-graceful-shutdown` обязателен.** Открытая вкладка держит SSE-поток
> `/api/events`, и без лимита `--reload` ждёт его закрытия вечно — сайт висит на загрузке, > `/api/events`, и без лимита `--reload` ждёт его закрытия вечно — сайт висит на загрузке,
> а в логе только `Reloading...`. > а в логе только `Reloading...`.
@@ -96,45 +108,42 @@ npm run dev # http://127.0.0.1:5173 или http://localhost:5
## Production (Docker на Pi) ## 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 ```bash
cp .env.example .env # заполните SECRET_KEY, ADMIN_PASSWORD и пр. # Pi, папка с docker-compose.yml и .env
docker compose build # на ARM64 собирается нативно docker compose up -d # pull_policy: always — тянет свежие образы, без сборки
docker compose up -d # приложение на :8000, БД на томе
``` ```
FastAPI отдаёт собранный SPA и API с одного origin. Миграции и бутстрап админа Портов на хост нет — прод доступен только на `https://forbiddenstars.ru` через
выполняются автоматически при старте (`entrypoint.sh`). туннель-контейнер. FastAPI отдаёт собранный SPA и API с одного origin. Миграции, сидинг
справочников и создание админа выполняются автоматически при старте (`entrypoint.sh`).
В production закрыты OpenAPI/Swagger, а с дефолтным или коротким `SECRET_KEY` либо
дефолтным `ADMIN_PASSWORD` приложение не стартует. `ADMIN_PASSWORD` применяется сам только
при первом создании админа; сменить его потом — правкой `.env`, `docker compose up -d` и
`docker compose exec app python -m app.bootstrap --reset-admin-password` (все админские
сессии завершатся; подробно — в `deploy/pi/README.md`).
Пошагово — [`deploy/pi/README.md`](deploy/pi/README.md), бэкапы — [`deploy/backup/README.md`](deploy/backup/README.md).
## Test — локальный прод-клон в контейнере
Тот же образ и поведение, что и прод (FastAPI отдаёт SPA, БД на томе, вход игроков
по логину/паролю или через Telegram), но на своей машине — для проверки прод-сборки до выката на Pi.
Изолированные тома и порт **8080** (не конфликтует с dev-uvicorn на :8000).
Проще всего — через лаунчер: поставить `APP_ENV=test` в `.env` и запустить `.\run.ps1`.
Вручную (тот же эффект):
```bash
docker compose -f docker-compose.test.yml up -d --build
# открыть http://localhost:8080 (Swagger: /api/docs)
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`.
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно), - **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало и docker compose (prod — `$` = подстановка переменной). Чтобы значение совпадало
везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так: везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так:
`python -c "import secrets;print(secrets.token_urlsafe(48))"` (даёт только `[A-Za-z0-9_-]`). `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`): Методы входа зависят от окружения (`APP_ENV`):
| | dev | test / prod | | | dev | prod |
|---|---|---| |---|---|---|
| Логин (= ник) и пароль | ✓ | ✓ (основной) | | Логин (= ник) и пароль | ✓ | ✓ (основной) |
| Telegram Login Widget | ✓ | ✓ | | Telegram Login Widget | ✓ | ✓ |
@@ -142,57 +151,69 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
- **Логин и пароль** (`app/auth/password.py`) — основной вход. Логин — это ник игрока - **Логин и пароль** (`app/auth/password.py`) — основной вход. Логин — это ник игрока
(смена ника меняет логин). Пароль: от 8 символов, не длиннее 72 байт, хранится bcrypt. (смена ника меняет логин). Пароль: от 8 символов, не длиннее 72 байт, хранится bcrypt.
От перебора — окно 15 минут в памяти процесса: 5 неудач на пару «IP + логин» и 20 на IP, От перебора — окно 15 минут в памяти процесса: 5 неудач на пару «IP + логин», 20 на IP и
дальше `429 TOO_MANY_ATTEMPTS`. Игрок без пароля (из Telegram или созданный до паролей) 50 на аккаунт (независимо от IP), дальше `429 TOO_MANY_ATTEMPTS`. Регистраций — не больше
после входа видит обязательное окно «Задайте пароль». В профиле пароль меняется (нужен 10 с одного IP за окно. Игрок без пароля (из Telegram или созданный до паролей) после входа
текущий) и привязывается Telegram (ник не меняется). Забытый пароль задаёт админ видит обязательное окно «Задайте пароль»: закрыть его нельзя, только задать пароль или
на вкладке аккаунтов — почту приложение не хранит. выйти; в dev-сборке есть кнопка «Позже (dev)». В профиле пароль меняется (нужен текущий) и
- **Stub-вход (по нику)** — только для разработки. Его код **физически не попадает в прод:** привязывается Telegram (ник не меняется). Забытый пароль задаёт админ на вкладке
файлы `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`). - **Stub-вход (по нику)** и **жёсткое удаление аккаунтов** в админке — только для разработки.
`GET /api/auth/config` отдаёт доступные методы и `telegram_bot_username` для виджета. Их код **физически не попадает в прод:** файлы `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`).
В проде аккаунт можно только отключить.
- **Telegram:** сервер проверяет подпись виджета (HMAC по `TELEGRAM_BOT_TOKEN`) и свежесть
данных (не старше суток). Первый вход регистрирует игрока под Telegram-тегом; если такой ник
занят или некорректен, фронт просит выбрать другой. `GET /api/auth/config` отдаёт доступные
методы и `telegram_bot_username` для виджета.
**Настройка Telegram (когда будете подключать реальный вход):** **Настройка Telegram (когда будете подключать реальный вход):**
1. Создать бота у [@BotFather](https://t.me/BotFather) → получить **токен** и **username**. 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=...` (без `@`). 3. В `.env`: `TELEGRAM_BOT_TOKEN=...`, `TELEGRAM_BOT_USERNAME=...` (без `@`).
4. Виджету нужен HTTPS-домен (см. «Домен и публикация») — по голому HTTP/localhost он не работает. 4. Виджету нужен HTTPS-домен (см. «Домен и публикация») — по голому HTTP/localhost он не работает.
Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех окружениях. Админ-вход (секретная панель, логин+пароль) — отдельный механизм, доступен во всех
окружениях. Страница — `/admin/login`; из интерфейса туда ведёт удержание кнопки «Меню» 10 секунд.
## Окружения (dev / test / prod) ## Окружения (dev / prod)
Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env` (его читает лаунчер): Один и тот же код; контур задаёт `APP_ENV` в **едином** `.env`. Допустимы только
`development` и `production` — с любым другим значением бэкенд не стартует:
| | dev | test (прод-клон локально) | prod (Pi) | | | dev | prod (Pi) |
|---|---|---|---| |---|---|---|
| Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `.\run.ps1` → `docker compose -f docker-compose.test.yml` | `docker compose up -d` | | Запуск | `.\run.ps1` → `uvicorn --reload` + `vite` | `docker compose up -d` |
| `APP_ENV` | `development` | `test` (форсится в compose) | `production` (форсится в compose) | | `APP_ENV` | `development` | `production` (форсится в compose) |
| Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) | | Env-файл | единый `.env` | единый `.env` (на Pi) |
| Раздача SPA | Vite (HMR), :5173 | FastAPI, :8080 | FastAPI, :8000 | | Раздача SPA | Vite (HMR), `:5173` | FastAPI, только через `https://forbiddenstars.ru` |
| База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) | | База данных | `backend/data/dev/…` | том `db-data` (`/data`) |
| Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram | пароль + Telegram | | Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram |
| Swagger (`/api/docs`) | ✓ | ✗ |
| Fail-fast по дефолтным секретам | ✗ | ✓ |
- **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что - **Один `.env` на машину** в корне (рядом с `.env.example`). Прод-контейнер значение
запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда `APP_ENV` из него **игнорирует** и всегда `production`.
`production`. Отдельного `.env.test` больше нет.
- **Структура БД одна** (общие миграции Alembic), **файлы разные**: dev → `DEV_DATABASE_URL` - **Структура БД одна** (общие миграции Alembic), **файлы разные**: dev → `DEV_DATABASE_URL`
(`backend/data/dev/`), test и prod → `PROD_DATABASE_URL` (том `/data`; у test и prod это (`backend/data/dev/`), prod → `PROD_DATABASE_URL` (том `/data`). Так же раздельно лежат
РАЗНЫЕ тома). Данные дева в образ **не попадают** (`data/` в `.dockerignore`). загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`).
- **В Docker идёт только прод-код:** dev-вход (stub) и тесты физически исключены из образа - **В Docker идёт только прод-код:** dev-вход (stub), dev-удаление аккаунтов и тесты физически
(`.dockerignore`); `test` — тот же образ, что и прод, просто локально и с `APP_ENV=test`. исключены из образа (`.dockerignore`). Данные дева (`backend/data/`), любые файлы SQLite
и локальные бэкапы (`backups/`) в образ тоже не попадают.
## Git и деплой ## Git и деплой
- Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и `test` здесь. - Ветка **`dev`** — рабочая: весь код, лаунчер, тесты. Повседневная разработка и проверка здесь.
- Ветка **`main`** — релиз прода: готовое промоутишь из `dev` через `merge dev→main`. Файлы во - Ветка **`main`** — релиз прода: готовое промоутишь из `dev` через `merge dev→main`. Файлы во
всех ветках одинаковы (окружение задаёт `.env`/compose, а не ветка) → merge безболезненный; всех ветках одинаковы (окружение задаёт `.env`/compose, а не ветка) → merge безболезненный;
чистоту прод-образа обеспечивает `.dockerignore`, а не разные наборы файлов. чистоту прод-образа обеспечивает `.dockerignore`, а не разные наборы файлов.
- **Деплой на Pi:** `git pull` ветки `main` → `scripts/build-push.ps1`. - **Деплой на Pi:** на ПК с ветки `main` — `.\scripts\build-push.ps1` (собирает и пушит
- **Чистая выгрузка в папку без git** (опц.): `scripts/export-prod.sh <dir> main` (через образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна
`git archive` + `export-ignore` — в папку идёт ровно прод-набор, без лаунчера/тестов/dev-входа). именно PS-версия скрипта (`build-push.sh` из PowerShell уходит в WSL).
Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`. Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`.
@@ -200,21 +221,32 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
Публичные адреса отдаёт **VPS-привратник** (Caddy + HTTPS твоими сертификатами), а Публичные адреса отдаёт **VPS-привратник** (Caddy + HTTPS твоими сертификатами), а
приложение само открывает к нему SSH reverse-туннель (дома белого IP нет — CGNAT). приложение само открывает к нему SSH reverse-туннель (дома белого IP нет — CGNAT).
У **test/prod** туннель — **отдельный контейнер** в их `docker-compose`, и портов на хост У **прода** туннель — **отдельный контейнер** в `docker-compose.yml`, и портов на хост он
они не публикуют (доступны только через домен): не публикует (доступен только через домен):
| | домен | как выставляется | слот VPS | | | домен | как выставляется | слот VPS |
|---|---|---|---| |---|---|---|---|
| prod | `forbiddenstars.ru` | туннель-контейнер (постоянно) → `app:8000` | 9000 | | 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 |
| dev | `forbidden-stars.ru` | `run.ps1` при `LOCAL_PUBLIC=vps` → `localhost:5173` | 9001 |
- Прод (9000) и dev/test (9001) на **разных слотах/доменах** → прод и (dev|test) работают - Прод (9000) и dev (9001) на **разных слотах/доменах** → работают одновременно. Временный
одновременно. Dev и test делят слот 9001 → по очереди. прод на ПК (`docker-compose.temp.yml`) занимает слот 9000 — одновременно с продом на Pi не запускать.
- Dev по умолчанию только на localhost; `LOCAL_PUBLIC=vps` + `run.ps1` выставляет его на домен. - Dev по умолчанию только на localhost; `LOCAL_PUBLIC=vps` + лаунчер выставляет его на домен.
В dev при этом открыты stub-вход по нику и Swagger — держите туннель поднятым только на
время проверки (задача #69).
- `COOKIE_SECURE` выводится автоматически (HTTPS-домен ⇒ Secure-cookie; dev+localhost ⇒ нет). - `COOKIE_SECURE` выводится автоматически (HTTPS-домен ⇒ Secure-cookie; dev+localhost ⇒ нет).
- Ключ туннеля: временный прод берёт файл `deploy/tunnel/id_tunnel`, прод на Pi —
`TUNNEL_KEY_B64` (base64) в `.env`, dev-туннель — системный `ssh` с ключом по умолчанию
(`~/.ssh`). Публичные части — в `authorized_keys` пользователя `tunnel` на VPS.
- Пошаговая настройка — в [`deploy/`](deploy/README.md): `vps/` (Caddy, сертификаты, юзер `tunnel`), - Пошаговая настройка — в [`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. С ПК снимки
скачиваются со сверкой sha256 (`scripts/fs-backup.ps1 pull`).
Настройка, восстановление и действия при гибели Pi — [`deploy/backup/README.md`](deploy/backup/README.md).
## Дополнения и фракции ## Дополнения и фракции
@@ -225,4 +257,6 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
| Forsaken Voids | Инквизиция, Сёстры битвы, Друкхари, Адептус Механикус | | Forsaken Voids | Инквизиция, Сёстры битвы, Друкхари, Адептус Механикус |
Группа отмечает имеющиеся дополнения; при создании партии доступны только фракции Группа отмечает имеющиеся дополнения; при создании партии доступны только фракции
этих дополнений (база — всегда). этих дополнений (база — всегда). Справочник сидится из `backend/app/seed/reference_data.py`.
Админ может переименовать фракцию в панели, но сейчас переименование откатывается при
каждом перезапуске приложения (задача #72).
@@ -0,0 +1,133 @@
"""Рейтинг (#23): раунд окончания, цели и миры участников, правило 9 раундов, last_standing.
Идемпотентна: на свежей БД столбцы и новый CHECK создаёт 0001 (create_all из актуальных
моделей) -> меняется только бэкфилл (на пустой БД он ничего не находит); на существующей
БД добавляет столбцы, расширяет CHECK причины победы (если он в БД есть) и проставляет
last_standing.
Столбцы nullable и задним числом не заполняются: NULL — «нет данных», рейтинг
подставляет вместо них типичные значения (docs/rating/rating-system.md, 4.8).
Revision ID: 0014_rating_inputs
Revises: 0013_user_token_version
Create Date: 2026-09-14
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0014_rating_inputs"
down_revision: Union[str, None] = "0013_user_token_version"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
CK_WIN_REASON = "ck_match_win_reason"
OLD_REASONS = "win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')"
NEW_REASONS = (
"win_reason IS NULL OR win_reason IN "
"('objectives','worlds','plastic','resources','last_standing')"
)
# Причина last_standing ⇔ невыбывший участник ровно один (решение владельца по #22).
# Только у партий хотя бы с двумя участниками: партия, из которой dev-удаление аккаунта
# вычеркнуло соперника, тоже имеет «одного невыбывшего», но победой выжившего не была.
BACKFILL_LAST_STANDING = """
UPDATE matches SET win_reason = 'last_standing'
WHERE status = 'finished'
AND (win_reason IS NULL OR win_reason <> 'last_standing')
AND (SELECT COUNT(*) FROM match_participants mp
WHERE mp.match_id = matches.id AND mp.eliminated = 0) = 1
AND (SELECT COUNT(*) FROM match_participants mp WHERE mp.match_id = matches.id) >= 2
"""
def _columns(insp, table: str) -> set[str]:
return {c["name"] for c in insp.get_columns(table)}
def _win_reason_check(insp) -> str | None:
for ck in insp.get_check_constraints("matches"):
if ck.get("name") == CK_WIN_REASON:
return ck["sqltext"]
return None
def _recreate_matches_with_check(bind, drop_existing: bool, sqltext: str) -> None:
"""Замена CHECK в SQLite = пересоздание таблицы matches (batch copy-and-move).
При включённых внешних ключах DROP старой таблицы выполнил бы неявный DELETE, и
ON DELETE CASCADE унёс бы участников, вложения и черновики. Alembic из CLI работает
без PRAGMA foreign_keys (её включает только движок приложения), а внутри транзакции
PRAGMA не переключить — поэтому не рискуем и отказываемся с понятной ошибкой."""
if bind.exec_driver_sql("PRAGMA foreign_keys").scalar():
raise RuntimeError(
"0014: PRAGMA foreign_keys=ON — пересоздание matches удалило бы участников "
"каскадом. Запускайте миграции через `alembic upgrade head` (CLI)."
)
with op.batch_alter_table("matches", recreate="always") as b:
if drop_existing:
b.drop_constraint(CK_WIN_REASON, type_="check")
b.create_check_constraint(CK_WIN_REASON, sqltext)
def upgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
if "nine_rounds_rule" not in _columns(insp, "groups"):
with op.batch_alter_table("groups") as b:
b.add_column(
sa.Column("nine_rounds_rule", sa.Boolean(), nullable=False, server_default="0")
)
match_cols = _columns(insp, "matches")
with op.batch_alter_table("matches") as b:
if "end_round" not in match_cols:
b.add_column(sa.Column("end_round", sa.Integer(), nullable=True))
if "nine_rounds_rule" not in match_cols:
b.add_column(
sa.Column("nine_rounds_rule", sa.Boolean(), nullable=False, server_default="0")
)
participant_cols = _columns(insp, "match_participants")
with op.batch_alter_table("match_participants") as b:
if "objectives" not in participant_cols:
b.add_column(sa.Column("objectives", sa.Integer(), nullable=True))
if "worlds" not in participant_cols:
b.add_column(sa.Column("worlds", sa.Integer(), nullable=True))
# БД, созданные до появления CHECK в моделях (0001 тогда был старше), ограничения
# не имеют вовсе — last_standing им и так разрешён, пересоздавать таблицу незачем.
check = _win_reason_check(inspect(bind))
if check is not None and "last_standing" not in check:
_recreate_matches_with_check(bind, drop_existing=True, sqltext=NEW_REASONS)
op.execute(BACKFILL_LAST_STANDING)
def downgrade() -> None:
bind = op.get_bind()
# Прежний CHECK не знает last_standing: такие партии теряют признак (раньше их
# записывали «по целям»).
op.execute("UPDATE matches SET win_reason = 'objectives' WHERE win_reason = 'last_standing'")
check = _win_reason_check(inspect(bind))
if check is not None and "last_standing" in check:
_recreate_matches_with_check(bind, drop_existing=True, sqltext=OLD_REASONS)
insp = inspect(bind)
participant_cols = _columns(insp, "match_participants")
with op.batch_alter_table("match_participants") as b:
for name in ("worlds", "objectives"):
if name in participant_cols:
b.drop_column(name)
match_cols = _columns(insp, "matches")
with op.batch_alter_table("matches") as b:
for name in ("nine_rounds_rule", "end_round"):
if name in match_cols:
b.drop_column(name)
if "nine_rounds_rule" in _columns(insp, "groups"):
with op.batch_alter_table("groups") as b:
b.drop_column("nine_rounds_rule")
@@ -0,0 +1,25 @@
"""Слияние веток миграций: рейтинг (0014) и объявления (0015).
Объявления (#84) вышли в main раньше рейтинга, поэтому 0015 отходит от 0013, а не от 0014,
и у графа миграций две ветки. Эта миграция сводит их в одну голову и сама ничего не меняет:
прод, стоящий на 0015 (релиз без рейтинга), при переходе на эту версию получит 0014 и её;
база на 0014 (dev) — 0015 и её.
Revision ID: 0016_merge_rating_announcements
Revises: 0014_rating_inputs, 0015_announcements
Create Date: 2026-09-18
"""
from typing import Sequence, Union
revision: str = "0016_merge_rating_announcements"
down_revision: Union[str, Sequence[str], None] = ("0014_rating_inputs", "0015_announcements")
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+3 -2
View File
@@ -2,8 +2,9 @@
Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин» Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин»
и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`). и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`).
Сервис остаётся чистым от инфраструктуры лимитов. Пароль администратора — единственный Сервис остаётся чистым от инфраструктуры лимитов. Лимиты те же, что у игрока (5 на пару,
барьер к полному контролю приложения, поэтому перебор здесь ограничиваем строже игроцкого. 20 на IP, 50 на аккаунт за 15 минут); отличие — при успешном входе снимаются все счётчики,
включая IP.
""" """
from __future__ import annotations from __future__ import annotations
+1 -1
View File
@@ -1,4 +1,4 @@
"""Dev-провайдер: вход без секрета по нику/идентификатору (только не-production).""" """Dev-провайдер: вход без секрета по нику/идентификатору (только development)."""
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
+2 -2
View File
@@ -1,7 +1,7 @@
"""Общий вход: внешняя личность → пользователь → сессия. """Общий вход: внешняя личность → пользователь → сессия.
Прод-безопасный модуль (без импортов dev-провайдера). Используется и Telegram-входом, Прод-безопасный модуль (без импортов dev-провайдера). establish_session зовут вход через
и dev-входом. Telegram, /auth/login, /auth/register и dev-вход.
""" """
from __future__ import annotations from __future__ import annotations
+1 -1
View File
@@ -2,7 +2,7 @@
Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть
auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте). auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте).
Доступен и в dev, и в prod (в prod — единственный метод входа). Доступен во всех окружениях — наряду со входом по логину и паролю.
""" """
from __future__ import annotations from __future__ import annotations
+45 -2
View File
@@ -1,9 +1,12 @@
"""Идемпотентный бутстрап: справочники + учётная запись администратора. """Идемпотентный бутстрап: справочники + учётная запись администратора.
Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций). Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций).
Ротация пароля админа из .env: `python -m app.bootstrap --reset-admin-password` (#73).
""" """
from __future__ import annotations from __future__ import annotations
import argparse
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core.config import settings from app.core.config import settings
@@ -11,6 +14,7 @@ from app.core.security import hash_password, verify_password
from app.db.session import engine from app.db.session import engine
from app.models import User from app.models import User
from app.seed.reference_data import seed_reference_data from app.seed.reference_data import seed_reference_data
from app.services import user_service
def _ensure_admin(session: Session) -> None: def _ensure_admin(session: Session) -> None:
@@ -38,7 +42,9 @@ def _ensure_admin(session: Session) -> None:
# Администратор уже существует. # Администратор уже существует.
if not settings.is_development: if not settings.is_development:
# В test/prod пароль НЕ перезаписываем (мог быть изменён через панель). # В prod обычный старт пароль НЕ перезаписывает: смена ADMIN_PASSWORD в .env сама
# по себе ничего не делает — применить её можно только осознанно, командой
# ротации (reset_admin_password), которая заодно отзывает админские сессии.
return return
# DEV: подтягиваем логин/пароль из .env (env — источник истины в деве). # DEV: подтягиваем логин/пароль из .env (env — источник истины в деве).
@@ -55,11 +61,48 @@ def _ensure_admin(session: Session) -> None:
print(f"[bootstrap] DEV: администратор обновлён из .env ({', '.join(changed)})") print(f"[bootstrap] DEV: администратор обновлён из .env ({', '.join(changed)})")
def reset_admin_password(session: Session) -> User:
"""Записать существующему админу пароль из ADMIN_PASSWORD (#73) — в любом окружении.
Через панель пароль админа не меняется, а обычный старт в prod его из .env не берёт:
это единственный штатный способ, и он требует доступа к серверу. set_password
проверяет пароль и увеличивает token_version — все выданные админские сессии
(в том числе чужая, если пароль утёк) перестают действовать. Логин не трогаем."""
if not settings.admin_bootstrap_enabled:
raise RuntimeError(
"ADMIN_BOOTSTRAP_ENABLED=false — администратором управляют вручную, ротация отключена."
)
password = (settings.admin_password or "").strip()
if not password:
raise RuntimeError("ADMIN_PASSWORD пуст — нечего применять.")
admin = session.exec(select(User).where(User.role == "admin")).first()
if admin is None:
raise RuntimeError("Администратора ещё нет — запустите обычный bootstrap, он его создаст.")
return user_service.set_password(session, admin, password)
def bootstrap() -> None: def bootstrap() -> None:
with Session(engine) as session: with Session(engine) as session:
seed_reference_data(session) # идемпотентно seed_reference_data(session) # идемпотентно
_ensure_admin(session) _ensure_admin(session)
if __name__ == "__main__": def main(argv: list[str] | None = None) -> None:
ap = argparse.ArgumentParser(prog="python -m app.bootstrap", description=__doc__)
ap.add_argument(
"--reset-admin-password",
action="store_true",
help="применить ADMIN_PASSWORD из .env к существующему админу и завершить его сессии",
)
args = ap.parse_args(argv)
if not args.reset_admin_password:
bootstrap() bootstrap()
return
with Session(engine) as session:
admin = reset_admin_password(session)
print(f"[bootstrap] Пароль администратора {admin.nickname} обновлён из .env; "
"все его сессии завершены.")
if __name__ == "__main__":
main()
+60 -28
View File
@@ -4,7 +4,7 @@ from __future__ import annotations
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from pydantic import model_validator from pydantic import field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково # Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
@@ -12,12 +12,15 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
# (исключён из образа) — там настройки приходят переменными от docker compose. # (исключён из образа) — там настройки приходят переменными от docker compose.
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env") _ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
# Небезопасные значения по умолчанию (годятся только для dev). В production приложение # Небезопасные значения по умолчанию (годятся только для dev на localhost). Опубликованное
# с ними не стартует — см. валидатор _forbid_default_secrets_in_prod (#59). # приложение с ними не стартует — см. валидатор _forbid_default_secrets_when_published.
_DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production" _DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production"
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password" _DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
_MIN_SECRET_KEY_LENGTH = 32 _MIN_SECRET_KEY_LENGTH = 32
# Допустимые окружения. Отдельного test-контура нет: всё проверяется в development.
_APP_ENVS = ("development", "production")
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
@@ -27,20 +30,21 @@ class Settings(BaseSettings):
case_sensitive=False, case_sensitive=False,
) )
# ── Главный переключатель окружения: development | test | production ─────── # ── Главный переключатель окружения: development | production ─────────────
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник. # development — нативный dev (uvicorn + vite), БД в ./data/dev/, есть stub-вход по нику.
# test — прод-клон в Docker локально (порт 8080), ведёт себя как прод.
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env. # production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
app_env: str = "development" app_env: str = "development"
log_level: str = "INFO" log_level: str = "INFO"
# Публикация локального окружения (dev/test) наружу через VPS-туннель. # Публикация локального dev-окружения наружу через VPS-туннель.
# Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель # Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель
# на forbidden-stars.ru. Влияет на cookie_secure (vps ⇒ снаружи HTTPS ⇒ Secure-cookie). # на forbidden-stars.ru. Приложению значение говорит, опубликовано ли оно (is_published):
# от этого зависят Secure-cookie и проверка секретов при старте.
local_public: str = "local" local_public: str = "local"
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3). # Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3). Хранение всегда
# Хранение всегда в UTC; смещение применяется к «дате игры» и отображению. # в UTC; смещение применяется к «дате игры» (timeutil.app_today) и к отображению времени
# на фронте — оно приходит туда в GET /api/auth/config (#68).
app_tz_offset_hours: int = 3 app_tz_offset_hours: int = 3
# ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ───── # ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ─────
@@ -64,8 +68,8 @@ class Settings(BaseSettings):
# cookie_secure НЕ задаётся вручную — выводится из окружения (см. property ниже). # cookie_secure НЕ задаётся вручную — выводится из окружения (см. property ниже).
cookie_domain: str | None = None cookie_domain: str | None = None
# Аутентификация. Методы входа определяются окружением (dev: telegram+stub, # Аутентификация. Методы входа определяются окружением (auth/registry.py): везде
# prod: только telegram) — отдельного переключателя провайдера нет. # логин/пароль + Telegram, в development ещё stub — отдельного переключателя нет.
telegram_bot_token: str | None = None telegram_bot_token: str | None = None
telegram_bot_username: str | None = None telegram_bot_username: str | None = None
public_base_url: str | None = None public_base_url: str | None = None
@@ -91,17 +95,13 @@ class Settings(BaseSettings):
стартовый bootstrap в lifespan и синхронизацию админа из .env.""" стартовый bootstrap в lifespan и синхронизацию админа из .env."""
return self.app_env.lower() == "development" return self.app_env.lower() == "development"
@property
def is_test(self) -> bool:
return self.app_env.lower() == "test"
@property @property
def is_production(self) -> bool: def is_production(self) -> bool:
return self.app_env.lower() == "production" return self.app_env.lower() == "production"
@property @property
def database_url(self) -> str: 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 return self.dev_database_url if self.is_development else self.prod_database_url
@property @property
@@ -113,23 +113,50 @@ class Settings(BaseSettings):
return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir
@property @property
def cookie_secure(self) -> bool: def is_published(self) -> bool:
"""Secure-cookie нужен везде, где снаружи HTTPS (домен). Исключение — """Приложение доступно снаружи по домену: production или dev, выставленный через
нативный dev на localhost по HTTP (development + local_public=local).""" VPS-туннель. Не опубликован только нативный dev на localhost
(development + local_public=local)."""
return not (self.is_development and self.local_public.lower() == "local") return not (self.is_development and self.local_public.lower() == "local")
@property
def cookie_secure(self) -> bool:
"""Secure-cookie нужен везде, где снаружи HTTPS (домен), — у опубликованного
приложения. На localhost по HTTP браузер Secure-cookie не вернул бы."""
return self.is_published
@property @property
def cookie_domain_value(self) -> str | None: def cookie_domain_value(self) -> str | None:
return self.cookie_domain or None return self.cookie_domain or None
@model_validator(mode="after") @field_validator("app_env")
def _forbid_default_secrets_in_prod(self) -> "Settings": @classmethod
"""Fail-fast: в production не стартуем с дефолтными/слабыми секретами (#59). 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
Деплой, скопировавший .env.example дословно (или забывший поле), иначе поднялся бы @field_validator("app_tz_offset_hours")
с общеизвестным ключом подписи JWT (подделка любого токена, включая админский) и @classmethod
известным паролем администратора. В dev/test проверка не мешает — там дефолты норма.""" def _known_tz_offset(cls, value: int) -> int:
if self.app_env.lower() != "production": """Реальные пояса — от −12 до +14: опечатка в .env — ошибка старта, а не время,
сдвинутое на сутки."""
if not -12 <= value <= 14:
raise ValueError(f"APP_TZ_OFFSET_HOURS={value} вне диапазона −12..14")
return value
@model_validator(mode="after")
def _forbid_default_secrets_when_published(self) -> "Settings":
"""Fail-fast: опубликованное приложение не стартует с дефолтными/слабыми
секретами (#59, #69) — и прод, и dev, выставленный на домен (LOCAL_PUBLIC=vps).
Иначе снаружи оказались бы общеизвестный ключ подписи JWT (подделка любого токена,
включая админский) и известный пароль администратора. На localhost дефолты — норма."""
if not self.is_published:
return self return self
problems: list[str] = [] problems: list[str] = []
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < _MIN_SECRET_KEY_LENGTH: if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < _MIN_SECRET_KEY_LENGTH:
@@ -141,8 +168,13 @@ class Settings(BaseSettings):
if not password or password == _DEFAULT_ADMIN_PASSWORD: if not password or password == _DEFAULT_ADMIN_PASSWORD:
problems.append("ADMIN_PASSWORD не задан или дефолтный") problems.append("ADMIN_PASSWORD не задан или дефолтный")
if problems: if problems:
where = (
"production"
if self.is_production
else f"dev, опубликованного наружу (LOCAL_PUBLIC={self.local_public})"
)
raise ValueError( raise ValueError(
"Небезопасная конфигурация production — задайте секреты в .env: " f"Небезопасная конфигурация {where} — задайте секреты в .env: "
+ "; ".join(problems) + "; ".join(problems)
) )
return self return self
+1 -1
View File
@@ -6,7 +6,7 @@
* рестарт (в т.ч. деплой) сбрасывает окно — злоумышленник получает новую квоту после * рестарт (в т.ч. деплой) сбрасывает окно — злоумышленник получает новую квоту после
перезапуска, но окно короткое, а рестарты редки; перезапуска, но окно короткое, а рестарты редки;
* при уходе от одного воркера лимит делится между процессами (каждый считает своё) — * при уходе от одного воркера лимит делится между процессами (каждый считает своё) —
тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в CLAUDE.md тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в core/events.py
про SSE-шину), общий для всех воркеров. про SSE-шину), общий для всех воркеров.
Помимо пары «IP + логин» и лимита по IP есть IP-независимый лимит на аккаунт Помимо пары «IP + логин» и лимита по IP есть IP-независимый лимит на аккаунт
(`login-user:*` / `admin-login-user:*`), чтобы ротация X-Forwarded-For / многих адресов (`login-user:*` / `admin-login-user:*`), чтобы ротация X-Forwarded-For / многих адресов
+2 -2
View File
@@ -175,6 +175,6 @@ def is_session_revoked(payload: dict) -> bool:
def client_ip(request: Request) -> str | None: def client_ip(request: Request) -> str | None:
"""IP клиента для журнала аудита. """IP клиента для журнала аудита.
Одна точка на всё приложение: за VPS-привратником адрес придётся брать из Одна точка на всё приложение. Реальный адрес за VPS-привратником уже подставляет
X-Forwarded-For, и менять это в двух десятках роутеров — не вариант.""" uvicorn (--proxy-headers + --forwarded-allow-ips в entrypoint.sh) — отсюда он и берётся."""
return request.client.host if request.client else None return request.client.host if request.client else None
-21
View File
@@ -1,21 +0,0 @@
"""Инициализация схемы и справочников (для тестов и локального быстрого старта).
В production схема создаётся миграциями Alembic; этот модуль удобен для тестов,
где БД поднимается из чистого состояния.
"""
from __future__ import annotations
from sqlmodel import SQLModel
from app.db.session import engine
from app.seed.reference_data import seed_reference_data
from sqlmodel import Session
# Импорт моделей обязателен, чтобы они зарегистрировались в SQLModel.metadata.
import app.models # noqa: F401
def create_db_and_seed() -> None:
SQLModel.metadata.create_all(engine)
with Session(engine) as session:
seed_reference_data(session)
+14 -4
View File
@@ -132,8 +132,18 @@ async def _lifespan(_app: FastAPI):
hub.bind_loop(asyncio.get_running_loop()) hub.bind_loop(asyncio.get_running_loop())
if settings.is_development and settings.is_published:
# Решение владельца (#69): dev-инструменты остаются и на опубликованном dev —
# но о том, что они открыты любому посетителю домена, нужно сказать громко.
logging.getLogger("fs").warning(
"DEV ОПУБЛИКОВАН НАРУЖУ (LOCAL_PUBLIC=%s): любому посетителю домена открыты "
"вход по нику без пароля, список и создание игроков, жёсткое удаление аккаунтов "
"и Swagger. Не держите в dev-базе копию прод-данных.",
settings.local_public,
)
# В DEV приложение само подтягивает справочники и админа из .env при старте # В 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": if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
try: try:
from app.bootstrap import bootstrap from app.bootstrap import bootstrap
@@ -152,7 +162,7 @@ async def _lifespan(_app: FastAPI):
def create_app() -> FastAPI: def create_app() -> FastAPI:
# Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev/test: она нужна для # Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev: она нужна для
# `npm run gen:api` (генерация типов фронта) и удобной отладки. В production закрываем — # `npm run gen:api` (генерация типов фронта) и удобной отладки. В production закрываем —
# незачем облегчать разведку поверхности API анонимам (#61). # незачем облегчать разведку поверхности API анонимам (#61).
docs_enabled = not settings.is_production docs_enabled = not settings.is_production
@@ -166,7 +176,7 @@ def create_app() -> FastAPI:
) )
# CORS нужен только в dev (vite на :5173 и API на :8000 — разные origin). # 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: if settings.is_development and settings.cors_origins_list:
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -206,7 +216,7 @@ def create_app() -> FastAPI:
app.include_router(r, prefix="/api") app.include_router(r, prefix="/api")
# DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development # DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development
# и только если код физически есть (в test/prod-образе dev_*-файлы исключены # и только если код физически есть (в прод-образе dev_*-файлы исключены
# .dockerignore, импорт просто не выполнится). # .dockerignore, импорт просто не выполнится).
if settings.is_development: if settings.is_development:
for mod_name in ("dev_auth", "dev_admin"): for mod_name in ("dev_auth", "dev_admin"):
+20 -1
View File
@@ -195,6 +195,12 @@ class Group(SQLModel, table=True):
sa_column_kwargs={"onupdate": utcnow}, sa_column_kwargs={"onupdate": utcnow},
nullable=False, nullable=False,
) )
# Домашнее правило: при 5–6 игроках играется 9 раундов вместо 8. Партия снимает
# значение при старте (Match.nine_rounds_rule), так что смена галочки историю не трогает.
nine_rounds_rule: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="0"),
)
class GroupMember(SQLModel, table=True): class GroupMember(SQLModel, table=True):
@@ -275,7 +281,8 @@ class Match(SQLModel, table=True):
Index("ix_matches_group_played", "group_id", "played_at"), Index("ix_matches_group_played", "group_id", "played_at"),
CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"), CheckConstraint("status IN ('in_progress','finished')", name="ck_match_status"),
CheckConstraint( CheckConstraint(
"win_reason IS NULL OR win_reason IN ('objectives','worlds','plastic','resources')", "win_reason IS NULL OR win_reason IN "
"('objectives','worlds','plastic','resources','last_standing')",
name="ck_match_win_reason", name="ck_match_win_reason",
), ),
) )
@@ -295,6 +302,14 @@ class Match(SQLModel, table=True):
finished_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True)) finished_at: datetime | None = Field(default=None, sa_column=Column(DateTime, nullable=True))
duration_minutes: int | None = Field(default=None, sa_column=Column(Integer, nullable=True)) duration_minutes: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
win_reason: str | None = Field(default=None, sa_column=Column(String(16), nullable=True)) win_reason: str | None = Field(default=None, sa_column=Column(String(16), nullable=True))
# Раунд, в котором партия закончилась (NULL — не указан). Лимит раундов не хранится:
# он выводится из снимка nine_rounds_rule и числа участников (scoring.max_rounds).
end_round: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
# Снимок Group.nine_rounds_rule на момент старта партии.
nine_rounds_rule: bool = Field(
default=False,
sa_column=Column(Boolean, nullable=False, server_default="0"),
)
player_count: int = Field(sa_column=Column(Integer, nullable=False)) player_count: int = Field(sa_column=Column(Integer, nullable=False))
overall_comment: str | None = Field(sa_column=Column(Text, nullable=True)) overall_comment: str | None = Field(sa_column=Column(Text, nullable=True))
created_by: int = Field( created_by: int = Field(
@@ -340,6 +355,10 @@ class MatchParticipant(SQLModel, table=True):
eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) eliminated: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0")) was_random: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="0"))
comment: str | None = Field(sa_column=Column(Text, nullable=True)) comment: str | None = Field(sa_column=Column(Text, nullable=True))
# Итог партии для рейтинга (NULL — не указан): маркеры целей и дружественные миры
# на конец партии. У выбывшего миров 0.
objectives: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
worlds: int | None = Field(default=None, sa_column=Column(Integer, nullable=True))
created_at: datetime = Field(default_factory=utcnow, nullable=False) created_at: datetime = Field(default_factory=utcnow, nullable=False)
+8 -21
View File
@@ -13,9 +13,8 @@ from app.core.errors import InvalidCredentialsError, NotFoundError
from app.core.timeutil import iso_utc from app.core.timeutil import iso_utc
from app.db.session import get_session from app.db.session import get_session
from app.models import User from app.models import User
from app.routers.matches import attachment_read, build_match_read from app.routers.matches import attachment_read, build_match_read, participant_inputs
from app.schemas import api as s from app.schemas import api as s
from app.services.match_service import ParticipantInput
from app.services import ( from app.services import (
achievement_service, achievement_service,
admin_service, admin_service,
@@ -154,7 +153,7 @@ def set_user_password(
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в # Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
# routers/dev_admin.py (исключён из прод/тест-образа). В проде аккаунт только # routers/dev_admin.py (исключён из прод-образа). В проде аккаунт только
# отключается (PATCH is_active), удалять нельзя. # отключается (PATCH is_active), удалять нельзя.
@@ -244,19 +243,6 @@ def update_match(
admin: User = Depends(get_current_admin), admin: User = Depends(get_current_admin),
) -> s.MatchRead: ) -> s.MatchRead:
match = match_service.get_match(session, match_id) match = match_service.get_match(session, match_id)
participants = None
if body.participants is not None:
participants = [
ParticipantInput(
user_id=p.user_id,
faction_id=p.faction_id,
place=p.place,
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
)
for p in body.participants
]
match = match_service.update_match( match = match_service.update_match(
session, session,
match, match,
@@ -265,7 +251,9 @@ def update_match(
overall_comment_set=("overall_comment" in body.model_fields_set), overall_comment_set=("overall_comment" in body.model_fields_set),
win_reason=body.win_reason, win_reason=body.win_reason,
win_reason_set=("win_reason" in body.model_fields_set), win_reason_set=("win_reason" in body.model_fields_set),
participants=participants, end_round=body.end_round,
end_round_set=("end_round" in body.model_fields_set),
participants=participant_inputs(body),
expected_version=body.expected_version, expected_version=body.expected_version,
) )
audit_service.record( audit_service.record(
@@ -327,16 +315,15 @@ def delete_match(
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(get_current_admin), admin: User = Depends(get_current_admin),
) -> s.OkResponse: ) -> s.OkResponse:
group_id = match_service.get_match(session, match_id).group_id # для уведомления match = match_service.get_match(session, match_id)
# До удаления: каскад унесёт участников вместе с партией. group_id, finished = match.group_id, match.status == "finished" # для уведомления
participant_ids = notify.match_participant_ids(session, match_id)
admin_service.delete_match(session, match_id) admin_service.delete_match(session, match_id)
audit_service.record( audit_service.record(
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id, session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
ip=client_ip(request), ip=client_ip(request),
) )
session.commit() session.commit()
notify.match_removed(session, match_id, group_id, participant_ids) notify.match_removed(session, match_id, group_id, finished=finished)
return s.OkResponse() return s.OkResponse()
+1
View File
@@ -28,6 +28,7 @@ def auth_config() -> s.AuthConfig:
return s.AuthConfig( return s.AuthConfig(
methods=enabled_methods(), methods=enabled_methods(),
telegram_bot_username=settings.telegram_bot_username, telegram_bot_username=settings.telegram_bot_username,
tz_offset_hours=settings.app_tz_offset_hours,
) )
+2 -2
View File
@@ -1,9 +1,9 @@
"""DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока. """DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока.
Этот файл ФИЗИЧЕСКИ исключён из прод/тест-образа (.dockerignore), а роутер Этот файл ФИЗИЧЕСКИ исключён из прод-образа (.dockerignore), а роутер
подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка
удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так
возможность удаления не попадает ни в прод, ни в тест — там аккаунт можно только возможность удаления не попадает в прод — там аккаунт можно только
отключить (PATCH is_active). отключить (PATCH is_active).
Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются — Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются —
+2 -2
View File
@@ -1,8 +1,8 @@
"""DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи. """DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи.
Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore), Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore),
а подключается роутер лишь когда APP_ENV != production (см. app/main.py). Так код а подключается роутер лишь при APP_ENV=development (см. app/main.py). Так вход
входа по логину остаётся только на деве. без пароля остаётся только на деве (по логину и паролю входят везде).
""" """
from __future__ import annotations from __future__ import annotations
+7 -2
View File
@@ -33,6 +33,7 @@ def _detail(session: Session, group_id: int, user_id: int) -> s.GroupDetail:
owner_id=group.owner_id, owner_id=group.owner_id,
my_role=member.role, my_role=member.role,
expansion_ids=group_service.group_expansion_ids(session, group_id), expansion_ids=group_service.group_expansion_ids(session, group_id),
nine_rounds_rule=group.nine_rounds_rule,
) )
@@ -79,15 +80,19 @@ def get_group(
@router.patch("/{group_id}", response_model=s.GroupDetail) @router.patch("/{group_id}", response_model=s.GroupDetail)
def rename_group( def update_group(
group_id: int, group_id: int,
body: s.GroupRename, body: s.GroupUpdate,
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> s.GroupDetail: ) -> s.GroupDetail:
"""Название и домашние правила группы; меняются только переданные поля."""
group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type] group_service.assert_member(session, group_id, user.id) # type: ignore[arg-type]
group = group_service.get_group(session, group_id) group = group_service.get_group(session, group_id)
if body.name is not None:
group_service.rename_group(session, group, body.name) group_service.rename_group(session, group, body.name)
if body.nine_rounds_rule is not None:
group_service.set_nine_rounds_rule(session, group, body.nine_rounds_rule)
return _detail(session, group_id, user.id) # type: ignore[arg-type] return _detail(session, group_id, user.id) # type: ignore[arg-type]
+33 -18
View File
@@ -22,10 +22,30 @@ from app.services import (
user_service, user_service,
) )
from app.services.match_service import FinishInput, ParticipantInput, RosterInput from app.services.match_service import FinishInput, ParticipantInput, RosterInput
from app.services.scoring import max_rounds
router = APIRouter(prefix="/matches", tags=["matches"]) router = APIRouter(prefix="/matches", tags=["matches"])
def participant_inputs(body: s.MatchUpdate) -> list[ParticipantInput] | None:
"""Участники правки — общее для игроцкого и админского PATCH."""
if body.participants is None:
return None
return [
ParticipantInput(
user_id=p.user_id,
faction_id=p.faction_id,
place=p.place,
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
objectives=p.objectives,
worlds=p.worlds,
)
for p in body.participants
]
def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead: def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead:
"""AttachmentRead с URL под нужным префиксом (base = '/api/matches/{id}' или """AttachmentRead с URL под нужным префиксом (base = '/api/matches/{id}' или
@@ -56,6 +76,8 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
eliminated=p.eliminated, eliminated=p.eliminated,
was_random=p.was_random, was_random=p.was_random,
comment=p.comment, comment=p.comment,
objectives=p.objectives,
worlds=p.worlds,
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type] avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
) )
for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type] for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type]
@@ -79,6 +101,9 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False
finished_at=iso_utc(match.finished_at), finished_at=iso_utc(match.finished_at),
duration_minutes=match.duration_minutes, duration_minutes=match.duration_minutes,
win_reason=match.win_reason, # type: ignore[arg-type] win_reason=match.win_reason, # type: ignore[arg-type]
end_round=match.end_round,
nine_rounds_rule=match.nine_rounds_rule,
max_rounds=max_rounds(match.player_count, match.nine_rounds_rule),
player_count=match.player_count, player_count=match.player_count,
overall_comment=match.overall_comment, overall_comment=match.overall_comment,
created_by=match.created_by, created_by=match.created_by,
@@ -157,6 +182,8 @@ def finish_match(
eliminated=p.eliminated, eliminated=p.eliminated,
comment=p.comment, comment=p.comment,
faction_id=p.faction_id, faction_id=p.faction_id,
objectives=p.objectives,
worlds=p.worlds,
) )
for p in body.participants for p in body.participants
] ]
@@ -165,6 +192,7 @@ def finish_match(
match, match,
finish=finish, finish=finish,
win_reason=body.win_reason, win_reason=body.win_reason,
end_round=body.end_round,
overall_comment=body.overall_comment, overall_comment=body.overall_comment,
overall_comment_set=("overall_comment" in body.model_fields_set), overall_comment_set=("overall_comment" in body.model_fields_set),
expected_version=body.expected_version, expected_version=body.expected_version,
@@ -206,19 +234,6 @@ def update_match(
) -> s.MatchRead: ) -> s.MatchRead:
match = match_service.get_match(session, match_id) match = match_service.get_match(session, match_id)
match_service.assert_can_modify(session, match, user) match_service.assert_can_modify(session, match, user)
participants = None
if body.participants is not None:
participants = [
ParticipantInput(
user_id=p.user_id,
faction_id=p.faction_id,
place=p.place,
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
)
for p in body.participants
]
match = match_service.update_match( match = match_service.update_match(
session, session,
match, match,
@@ -227,7 +242,9 @@ def update_match(
overall_comment_set=("overall_comment" in body.model_fields_set), overall_comment_set=("overall_comment" in body.model_fields_set),
win_reason=body.win_reason, win_reason=body.win_reason,
win_reason_set=("win_reason" in body.model_fields_set), win_reason_set=("win_reason" in body.model_fields_set),
participants=participants, end_round=body.end_round,
end_round_set=("end_round" in body.model_fields_set),
participants=participant_inputs(body),
expected_version=body.expected_version, expected_version=body.expected_version,
) )
audit_service.record( audit_service.record(
@@ -335,9 +352,7 @@ def delete_match(
match_service.assert_can_modify(session, match, user) match_service.assert_can_modify(session, match, user)
match_id_val = match.id match_id_val = match.id
group_id_val = match.group_id group_id_val = match.group_id
# Участников читаем до удаления: каскад унесёт их строки вместе с партией, finished = match.status == "finished" # после удаления статус уже не прочитать
# а событию они нужны, чтобы клиент знал, чьи витрины протухли.
participant_ids = notify.match_participant_ids(session, match_id_val) # type: ignore[arg-type]
match_service.delete_match(session, match, expected_version=expected_version) match_service.delete_match(session, match, expected_version=expected_version)
audit_service.record( audit_service.record(
session, session,
@@ -350,5 +365,5 @@ def delete_match(
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
notify.match_removed(session, match_id_val, group_id_val, participant_ids) # type: ignore[arg-type] notify.match_removed(session, match_id_val, group_id_val, finished=finished) # type: ignore[arg-type]
return s.OkResponse() return s.OkResponse()
+48 -6
View File
@@ -2,11 +2,18 @@
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime from datetime import date, datetime
from typing import Literal from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
WinReason = Literal["objectives", "worlds", "plastic", "resources"] # last_standing — все соперники выбыли. Вручную не выбирается: сервер требует её ровно
# тогда, когда невыбывший участник один (match_service._check_last_standing).
WinReason = Literal["objectives", "worlds", "plastic", "resources", "last_standing"]
# Итоги партии для рейтинга. Верхние границы — только отсечка мусора: правила игры
# ограничивают сильнее, но их проверка — дело предупреждений в форме, а не отказа.
EndRound = Annotated[int, Field(ge=1, le=9)]
Count = Annotated[int, Field(ge=0, le=99)]
# ─── Auth ──────────────────────────────────────────────────────────────────── # ─── Auth ────────────────────────────────────────────────────────────────────
@@ -15,6 +22,9 @@ class AuthConfig(BaseModel):
# Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве. # Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве.
methods: list[str] = [] methods: list[str] = []
telegram_bot_username: str | None = None telegram_bot_username: str | None = None
# Пояс приложения (APP_TZ_OFFSET_HOURS): в нём сервер считает «дату игры», а фронт
# показывает время всем игрокам — независимо от пояса устройства (#68).
tz_offset_hours: int
# Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt. # Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt.
@@ -162,8 +172,10 @@ class GroupCreate(BaseModel):
expansion_ids: list[int] = [] expansion_ids: list[int] = []
class GroupRename(BaseModel): class GroupUpdate(BaseModel):
name: str # Частичная правка: переданные поля меняются, остальные остаются как есть.
name: str | None = None
nine_rounds_rule: bool | None = None
class GroupExpansionsUpdate(BaseModel): class GroupExpansionsUpdate(BaseModel):
@@ -176,6 +188,8 @@ class GroupDetail(BaseModel):
owner_id: int owner_id: int
my_role: str my_role: str
expansion_ids: list[int] = [] expansion_ids: list[int] = []
# Домашнее правило: 9 раундов при 5–6 игроках (снимается в партию при старте).
nine_rounds_rule: bool = False
class MemberRead(BaseModel): class MemberRead(BaseModel):
@@ -269,17 +283,20 @@ class MatchFinishParticipant(BaseModel):
eliminated: bool = False # выбыл из партии → авто-проставится последнее место eliminated: bool = False # выбыл из партии → авто-проставится последнее место
comment: str | None = None comment: str | None = None
faction_id: int | None = None # опц. смена фракции при завершении faction_id: int | None = None # опц. смена фракции при завершении
objectives: Count | None = None # маркеры целей на конец партии (необязательно)
worlds: Count | None = None # дружественные миры на конец партии; у выбывшего 0
class MatchFinish(BaseModel): class MatchFinish(BaseModel):
participants: list[MatchFinishParticipant] participants: list[MatchFinishParticipant]
win_reason: WinReason win_reason: WinReason
end_round: EndRound | None = None # раунд, в котором партия закончилась
overall_comment: str | None = None overall_comment: str | None = None
# Оптимистичная блокировка: версия партии, которую видел клиент (см. MatchRead.version). # Оптимистичная блокировка: версия партии, которую видел клиент (см. MatchRead.version).
expected_version: str | None = None expected_version: str | None = None
# Полный участник (правка завершённой партии админом). # Полный участник (правка результатов завершённой партии).
class ParticipantInput(BaseModel): class ParticipantInput(BaseModel):
user_id: int user_id: int
faction_id: int faction_id: int
@@ -287,12 +304,15 @@ class ParticipantInput(BaseModel):
eliminated: bool = False eliminated: bool = False
was_random: bool = False was_random: bool = False
comment: str | None = None comment: str | None = None
objectives: Count | None = None
worlds: Count | None = None
class MatchUpdate(BaseModel): class MatchUpdate(BaseModel):
played_at: date | None = None played_at: date | None = None
overall_comment: str | None = None overall_comment: str | None = None
win_reason: WinReason | None = None win_reason: WinReason | None = None
end_round: EndRound | None = None
participants: list[ParticipantInput] | None = None participants: list[ParticipantInput] | None = None
expected_version: str | None = None # оптимистичная блокировка expected_version: str | None = None # оптимистичная блокировка
@@ -306,6 +326,8 @@ class MatchParticipantRead(BaseModel):
eliminated: bool = False eliminated: bool = False
was_random: bool was_random: bool
comment: str | None = None comment: str | None = None
objectives: int | None = None
worlds: int | None = None
avatar_url: str | None = None avatar_url: str | None = None
@@ -328,6 +350,10 @@ class MatchFinishDraftData(BaseModel):
comments: dict[str, str] = {} comments: dict[str, str] = {}
win_reason: WinReason | None = None win_reason: WinReason | None = None
overall_comment: str | None = None overall_comment: str | None = None
end_round: EndRound | None = None
# Ключ — user_id строкой (как у comments); незаполненные поля в словарь не попадают.
objectives: dict[str, Count] = {}
worlds: dict[str, Count] = {}
class MatchFinishDraftRead(BaseModel): class MatchFinishDraftRead(BaseModel):
@@ -346,6 +372,11 @@ class MatchRead(BaseModel):
finished_at: str | None = None finished_at: str | None = None
duration_minutes: int | None = None duration_minutes: int | None = None
win_reason: WinReason | None = None win_reason: WinReason | None = None
end_round: int | None = None
# Снимок правила 9 раундов и вычисленный из него лимит раундов этой партии:
# фронт берёт лимит отсюда, а не повторяет правило у себя.
nine_rounds_rule: bool = False
max_rounds: int
player_count: int player_count: int
overall_comment: str | None = None overall_comment: str | None = None
created_by: int created_by: int
@@ -364,7 +395,8 @@ class OverallStats(BaseModel):
wins: int wins: int
win_rate: float win_rate: float
avg_place: float | None = None avg_place: float | None = None
score: float | None = None # Рейтинг (Elo, старт 1500) целым числом; None — игрок ещё не сыграл ни одной партии.
score: int | None = None
class LeaderboardEntry(OverallStats): class LeaderboardEntry(OverallStats):
@@ -372,6 +404,9 @@ class LeaderboardEntry(OverallStats):
nickname: str nickname: str
rank: int | None = None rank: int | None = None
avatar_url: str | None = None avatar_url: str | None = None
# Рейтинг подтверждён: MIN_GAMES+ партий во всём приложении. На странице группы games —
# партии в группе, поэтому статус не выводится из них (и из блока, где стоит строка).
rating_confirmed: bool = False
class MatchHistory(BaseModel): class MatchHistory(BaseModel):
@@ -405,6 +440,8 @@ class FactionStat(BaseModel):
wins: int wins: int
win_rate: float win_rate: float
avg_place: float | None = None avg_place: float | None = None
# Средний результат относительно ожидания (S − E) × 100 — метрика лучшей/худшей
# фракции: выше нуля — игрок на ней выступает лучше своих рейтинговых шансов.
score: float | None = None score: float | None = None
@@ -468,6 +505,8 @@ class MatchListParticipant(BaseModel):
eliminated: bool = False eliminated: bool = False
was_random: bool was_random: bool
comment: str | None = None comment: str | None = None
objectives: int | None = None
worlds: int | None = None
class MatchListItem(BaseModel): class MatchListItem(BaseModel):
@@ -482,6 +521,9 @@ class MatchListItem(BaseModel):
overall_comment: str | None = None overall_comment: str | None = None
created_by: int created_by: int
participants: list[MatchListParticipant] = [] participants: list[MatchListParticipant] = []
# Изменение общего рейтинга владельца истории за эту партию (один знак после запятой).
# Заполняется только в истории игрока (GET /users/{id}/matches); в списке группы — None.
rating_delta: float | None = None
class MatchList(BaseModel): class MatchList(BaseModel):
+7 -2
View File
@@ -33,7 +33,13 @@ FACTIONS: list[tuple[str, str, str, int]] = [
def seed_reference_data(session: Session) -> None: def seed_reference_data(session: Session) -> None:
"""Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно.""" """Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно.
Идёт при каждом старте (entrypoint.sh, lifespan dev), поэтому не перетирает то, что
правят руками: имя существующей фракции меняет админка (admin_service.rename_faction),
и после создания записи источник правды для него — БД (#72). Имя из кода получает
только новая фракция; поправить имя существующей — админкой или миграцией.
Служебные поля (дополнение, порядок) и всё у дополнений синхронизируются с кодом."""
code_to_expansion: dict[str, Expansion] = {} code_to_expansion: dict[str, Expansion] = {}
for code, name_ru, is_base, order in EXPANSIONS: for code, name_ru, is_base, order in EXPANSIONS:
@@ -61,7 +67,6 @@ def seed_reference_data(session: Session) -> None:
) )
session.add(fac) session.add(fac)
else: else:
fac.name_ru = name_ru
fac.expansion_id = expansion.id # type: ignore[assignment] fac.expansion_id = expansion.id # type: ignore[assignment]
fac.sort_order = order fac.sort_order = order
+4 -3
View File
@@ -63,8 +63,9 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None,
def set_player_password(session: Session, user_id: int, new_password: str) -> User: def set_player_password(session: Session, user_id: int, new_password: str) -> User:
"""Новый пароль игроку (восстановление забытого). Пароль админа так не меняется — """Новый пароль игроку (восстановление забытого). Пароль админа через панель не
он задаётся ADMIN_PASSWORD в .env.""" меняется: он задаётся ADMIN_PASSWORD в .env и применяется к существующему админу
командой `python -m app.bootstrap --reset-admin-password` (#73)."""
user = session.get(User, user_id) user = session.get(User, user_id)
if user is None: if user is None:
raise NotFoundError("Пользователь не найден.") raise NotFoundError("Пользователь не найден.")
@@ -74,7 +75,7 @@ def set_player_password(session: Session, user_id: int, new_password: str) -> Us
# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно: # Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно:
# логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа). # логика вынесена в routers/dev_admin.py (файл исключён из прод-образа).
# ─── Группы ────────────────────────────────────────────────────────────────── # ─── Группы ──────────────────────────────────────────────────────────────────
+10
View File
@@ -97,6 +97,16 @@ def rename_group(session: Session, group: Group, name: str) -> Group:
return group return group
def set_nine_rounds_rule(session: Session, group: Group, enabled: bool) -> Group:
"""Хоумрул «9 раундов при 5–6 игроках». Действует на партии, начатые после смены:
уже начатые хранят свой снимок (Match.nine_rounds_rule)."""
group.nine_rounds_rule = enabled
session.add(group)
session.commit()
session.refresh(group)
return group
def set_expansions(session: Session, group: Group, expansion_ids: list[int]) -> Group: def set_expansions(session: Session, group: Group, expansion_ids: list[int]) -> Group:
valid = set(_valid_non_base_expansion_ids(session, expansion_ids)) valid = set(_valid_non_base_expansion_ids(session, expansion_ids))
current = session.exec( current = session.exec(
+124 -18
View File
@@ -19,9 +19,12 @@ from app.core.errors import (
from app.core.timeutil import app_today, iso_utc, utcnow from app.core.timeutil import app_today, iso_utc, utcnow
from app.models import Faction, GroupMember, Match, MatchFinishDraft, MatchParticipant, User from app.models import Faction, GroupMember, Match, MatchFinishDraft, MatchParticipant, User
from app.services import group_service from app.services import group_service
from app.services.scoring import EXTENDED_ROUNDS, max_rounds
MAX_MATCH_PLAYERS = 6 MAX_MATCH_PLAYERS = 6
WIN_REASONS = ("objectives", "worlds", "plastic", "resources") LAST_STANDING = "last_standing"
WIN_REASONS = ("objectives", "worlds", "plastic", "resources", LAST_STANDING)
MAX_COUNT = 99 # отсечка мусора в целях/мирах (та же, что в схеме API)
@dataclass @dataclass
@@ -42,11 +45,13 @@ class FinishInput:
eliminated: bool = False eliminated: bool = False
comment: str | None = None comment: str | None = None
faction_id: int | None = None # опц. смена фракции при завершении faction_id: int | None = None # опц. смена фракции при завершении
objectives: int | None = None
worlds: int | None = None
@dataclass @dataclass
class ParticipantInput: class ParticipantInput:
"""Полный участник (для правки завершённой партии админом).""" """Полный участник (для правки результатов завершённой партии)."""
user_id: int user_id: int
faction_id: int faction_id: int
@@ -54,6 +59,8 @@ class ParticipantInput:
eliminated: bool = False eliminated: bool = False
was_random: bool = False was_random: bool = False
comment: str | None = None comment: str | None = None
objectives: int | None = None
worlds: int | None = None
def round_to_30(minutes: float) -> int: def round_to_30(minutes: float) -> int:
@@ -154,6 +161,43 @@ def _resolve_finish_places(rows: list[tuple[int, int | None, bool]]) -> dict[int
return {uid: (elim_place if elim else place) for uid, place, elim in rows} # type: ignore[misc] return {uid: (elim_place if elim else place) for uid, place, elim in rows} # type: ignore[misc]
# ─── Итоги партии для рейтинга ────────────────────────────────────────────────
# Сервер проверяет только диапазоны и явные противоречия. Согласованность итогов между
# собой (тип победы и цели лидеров и т.п.) — предупреждения формы, а не отказ.
def _check_end_round(end_round: int | None, player_count: int, nine_rounds_rule: bool) -> None:
if end_round is None:
return
rmax = max_rounds(player_count, nine_rounds_rule)
if not 1 <= end_round <= rmax:
raise ValidationError(f"Раунд окончания — от 1 до {rmax}.")
def _worlds_of(eliminated: bool, worlds: int | None) -> int | None:
"""У выбывшего миров нет: пустое поле записывается нулём, иное число — противоречие."""
if not eliminated:
return worlds
if worlds:
raise ValidationError("У выбывшего игрока не может быть миров.")
return 0
def _check_last_standing(win_reason: str | None, eliminated: list[bool]) -> None:
"""Причина «последний выживший» ⇔ невыбывший участник ровно один (решение по #22).
Выбрать её вручную нельзя, и забыть поставить тоже: форма проставляет её сама,
сервер лишь не пропускает расхождение."""
alone = sum(1 for e in eliminated if not e) == 1
if alone and win_reason != LAST_STANDING:
raise ValidationError(
"Остался один невыбывший игрок — причина победы «последний выживший»."
)
if not alone and win_reason == LAST_STANDING:
raise ValidationError(
"«Последний выживший» возможен, только когда все, кроме победителя, выбыли."
)
def _group_member_ids(session: Session, group_id: int) -> set[int]: def _group_member_ids(session: Session, group_id: int) -> set[int]:
return { return {
m.user_id m.user_id
@@ -208,12 +252,15 @@ def create_match(
) )
now = utcnow() now = utcnow()
group = group_service.get_group(session, group_id)
match = Match( match = Match(
group_id=group_id, group_id=group_id,
status="in_progress", status="in_progress",
played_at=app_today(), # дата игры — в поясе приложения (+3) played_at=app_today(), # дата игры — в поясе приложения (+3)
started_at=now, started_at=now,
player_count=len(roster), player_count=len(roster),
# Снимок: смена настройки группы потом не переписывает лимит раундов этой партии.
nine_rounds_rule=group.nine_rounds_rule,
created_by=creator.id, # type: ignore[arg-type] created_by=creator.id, # type: ignore[arg-type]
) )
session.add(match) session.add(match)
@@ -242,6 +289,7 @@ def finish_match(
*, *,
finish: list[FinishInput], finish: list[FinishInput],
win_reason: str, win_reason: str,
end_round: int | None = None,
overall_comment: str | None = None, overall_comment: str | None = None,
overall_comment_set: bool = False, overall_comment_set: bool = False,
expected_version: str | None = None, expected_version: str | None = None,
@@ -251,6 +299,7 @@ def finish_match(
raise ConflictError("Партия уже завершена.") raise ConflictError("Партия уже завершена.")
if win_reason not in WIN_REASONS: if win_reason not in WIN_REASONS:
raise ValidationError("Укажите корректную причину победы.") raise ValidationError("Укажите корректную причину победы.")
_check_end_round(end_round, match.player_count, match.nine_rounds_rule)
existing = { existing = {
p.user_id: p p.user_id: p
@@ -273,12 +322,16 @@ def finish_match(
raise FactionNotAvailableError() raise FactionNotAvailableError()
places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) for f in finish]) places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) for f in finish])
_check_last_standing(win_reason, [f.eliminated for f in finish])
worlds = {f.user_id: _worlds_of(f.eliminated, f.worlds) for f in finish}
for f in finish: for f in finish:
p = existing[f.user_id] p = existing[f.user_id]
p.place = places[f.user_id] p.place = places[f.user_id]
p.eliminated = f.eliminated p.eliminated = f.eliminated
p.comment = f.comment or None p.comment = f.comment or None
p.objectives = f.objectives
p.worlds = worlds[f.user_id]
if f.faction_id is not None: if f.faction_id is not None:
p.faction_id = f.faction_id p.faction_id = f.faction_id
session.add(p) session.add(p)
@@ -294,6 +347,7 @@ def finish_match(
match.duration_minutes = round_to_30(elapsed_min) match.duration_minutes = round_to_30(elapsed_min)
match.status = "finished" match.status = "finished"
match.win_reason = win_reason match.win_reason = win_reason
match.end_round = end_round
if overall_comment_set: if overall_comment_set:
match.overall_comment = overall_comment or None match.overall_comment = overall_comment or None
@@ -312,22 +366,44 @@ def get_finish_draft(session: Session, match_id: int) -> MatchFinishDraft | None
return session.get(MatchFinishDraft, match_id) return session.get(MatchFinishDraft, match_id)
def _draft_counts(value: object) -> dict[str, int]:
"""Цели/миры черновика: {user_id строкой: число}. Пустые поля в словарь не попадают."""
if not isinstance(value, dict):
raise ValidationError("Некорректный черновик.")
out: dict[str, int] = {}
for k, v in value.items():
if isinstance(v, bool) or not isinstance(v, int) or not 0 <= v <= MAX_COUNT:
raise ValidationError("Некорректный черновик.")
out[str(k)] = v
return out
def _validate_draft(session: Session, match: Match, data: dict) -> dict: def _validate_draft(session: Session, match: Match, data: dict) -> dict:
"""Черновик — свободная форма, но не мусор: состав обязан совпадать с участниками """Черновик — свободная форма, но не мусор: состав обязан совпадать с участниками
партии, а причина победы быть из известных. Места здесь НЕ валидируются: человек партии, а причина победы быть из известных. Места здесь НЕ валидируются: человек
раскладывает их постепенно, и промежуточное состояние может быть любым.""" раскладывает их постепенно, и промежуточное состояние может быть любым. По той же
причине не проверяется и правило «последнего выжившего»."""
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValidationError("Некорректный черновик.") raise ValidationError("Некорректный черновик.")
blocks = data.get("blocks") or [] blocks = data.get("blocks") or []
eliminated = data.get("eliminated") or [] eliminated = data.get("eliminated") or []
comments = data.get("comments") or {} comments = data.get("comments") or {}
win_reason = data.get("win_reason") win_reason = data.get("win_reason")
end_round = data.get("end_round")
if not isinstance(blocks, list) or not isinstance(eliminated, list): if not isinstance(blocks, list) or not isinstance(eliminated, list):
raise ValidationError("Некорректный черновик.") raise ValidationError("Некорректный черновик.")
if not isinstance(comments, dict): if not isinstance(comments, dict):
raise ValidationError("Некорректный черновик.") raise ValidationError("Некорректный черновик.")
if win_reason is not None and win_reason not in WIN_REASONS: if win_reason is not None and win_reason not in WIN_REASONS:
raise ValidationError("Некорректная причина победы.") raise ValidationError("Некорректная причина победы.")
if end_round is not None and (
isinstance(end_round, bool)
or not isinstance(end_round, int)
or not 1 <= end_round <= EXTENDED_ROUNDS
):
raise ValidationError("Некорректный черновик.")
objectives = _draft_counts(data.get("objectives") or {})
worlds = _draft_counts(data.get("worlds") or {})
participant_ids = { participant_ids = {
p.user_id p.user_id
@@ -354,6 +430,9 @@ def _validate_draft(session: Session, match: Match, data: dict) -> dict:
"comments": {str(k): str(v) for k, v in comments.items()}, "comments": {str(k): str(v) for k, v in comments.items()},
"win_reason": win_reason, "win_reason": win_reason,
"overall_comment": overall, "overall_comment": overall,
"end_round": end_round,
"objectives": objectives,
"worlds": worlds,
} }
@@ -409,35 +488,41 @@ def update_match(
overall_comment_set: bool = False, overall_comment_set: bool = False,
win_reason: str | None = None, win_reason: str | None = None,
win_reason_set: bool = False, win_reason_set: bool = False,
end_round: int | None = None,
end_round_set: bool = False,
participants: list[ParticipantInput] | None = None, participants: list[ParticipantInput] | None = None,
expected_version: str | None = None, expected_version: str | None = None,
) -> Match: ) -> Match:
"""Правка партии: состав с местами, дата, комментарий, причина победы. """Правка партии: состав с местами и итогами, дата, комментарий, причина победы, раунд.
Результаты (места и причина победы) пишутся только в завершённую партию: иначе они Результаты (места, итоги, причина победы, раунд) пишутся только в завершённую партию:
оседали бы в партии со статусом in_progress, которая остаётся в «Незавершённых» и не иначе они оседали бы в партии со статусом in_progress, которая остаётся
попадает ни в одну витрину статистики (SCORED_CTE считает только status='finished'). в «Незавершённых» и не попадает ни в одну витрину статистики (рейтинг проигрывает
Дату и общий комментарий править можно и по ходу партии — двойственного состояния только status='finished'). Дату и общий комментарий править можно и по ходу
они не создают.""" партии — двойственного состояния они не создают."""
results_touched = participants is not None or win_reason_set results_touched = participants is not None or win_reason_set or end_round_set
if results_touched and match.status != "finished": if results_touched and match.status != "finished":
raise ConflictError( raise ConflictError(
"Результаты незавершённой партии нельзя править — сначала завершите её." "Результаты незавершённой партии нельзя править — сначала завершите её."
) )
assert_version(match, expected_version) assert_version(match, expected_version)
if played_at is not None: if win_reason_set and win_reason is not None and win_reason not in WIN_REASONS:
match.played_at = played_at
if overall_comment_set:
match.overall_comment = overall_comment or None
if win_reason_set:
if win_reason is not None and win_reason not in WIN_REASONS:
raise ValidationError("Некорректная причина победы.") raise ValidationError("Некорректная причина победы.")
match.win_reason = win_reason
if participants is not None:
saved = session.exec( saved = session.exec(
select(MatchParticipant).where(MatchParticipant.match_id == match.id) select(MatchParticipant).where(MatchParticipant.match_id == match.id)
).all() ).all()
# Проверки — по состоянию партии ПОСЛЕ правки: частичный запрос сверяется
# с тем, что уже записано.
new_reason = win_reason if win_reason_set else match.win_reason
new_end_round = end_round if end_round_set else match.end_round
new_count = len(participants) if participants is not None else match.player_count
if end_round_set or participants is not None:
_check_end_round(new_end_round, new_count, match.nine_rounds_rule)
places: dict[int, int] = {}
worlds: dict[int, int | None] = {}
if participants is not None:
# Что уже записано в партии, остаётся допустимым: состав группы и набор # Что уже записано в партии, остаётся допустимым: состав группы и набор
# дополнений с тех пор могли поменяться, но историю это чинить не мешает. # дополнений с тех пор могли поменяться, но историю это чинить не мешает.
_validate_roster_basics( _validate_roster_basics(
@@ -451,6 +536,25 @@ def update_match(
places = _resolve_finish_places( places = _resolve_finish_places(
[(p.user_id, p.place, p.eliminated) for p in participants] [(p.user_id, p.place, p.eliminated) for p in participants]
) )
worlds = {p.user_id: _worlds_of(p.eliminated, p.worlds) for p in participants}
if participants is not None or win_reason_set:
flags = (
[p.eliminated for p in participants]
if participants is not None
else [p.eliminated for p in saved]
)
_check_last_standing(new_reason, flags)
if played_at is not None:
match.played_at = played_at
if overall_comment_set:
match.overall_comment = overall_comment or None
if win_reason_set:
match.win_reason = win_reason
if end_round_set:
match.end_round = end_round
if participants is not None:
for old in saved: for old in saved:
session.delete(old) session.delete(old)
session.flush() session.flush()
@@ -464,6 +568,8 @@ def update_match(
eliminated=p.eliminated, eliminated=p.eliminated,
was_random=p.was_random, was_random=p.was_random,
comment=p.comment or None, comment=p.comment or None,
objectives=p.objectives,
worlds=worlds[p.user_id],
) )
) )
match.player_count = len(participants) match.player_count = len(participants)
+26 -37
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core.events import hub from app.core.events import hub
from app.models import GroupMember, Match, MatchParticipant, User from app.models import GroupMember, Match, User
def _group_member_ids(session: Session, group_id: int) -> list[int]: def _group_member_ids(session: Session, group_id: int) -> list[int]:
@@ -17,30 +17,28 @@ def _group_member_ids(session: Session, group_id: int) -> list[int]:
) )
def match_participant_ids(session: Session, match_id: int) -> list[int]: def _ratings_changed(session: Session, notified: list[int]) -> None:
"""Кто играл в партии. Нужен в событии, чтобы клиент понимал, чьи витрины """Рейтинг общий и считается по всей истории (#80): завершённая партия двигает топ,
(история игр, публичный профиль, личная статистика) реально протухли.""" главную, историю и профили всех, кто играл после неё, и страницы других групп.
return list( Игрокам вне группы (notified уже знают) — событие без подробностей о партии."""
session.exec( skip = set(notified)
select(MatchParticipant.user_id).where(MatchParticipant.match_id == match_id) ids = [
uid
for uid in session.exec(
select(User.id).where(User.role == "player", User.is_active.is_(True)) # type: ignore[union-attr]
).all() ).all()
) if uid not in skip
]
hub.publish(ids, {"type": "ratings"})
def match_changed(session: Session, match: Match) -> None: def match_changed(session: Session, match: Match) -> None:
"""Партия изменилась — уведомить всех участников её группы. """Партия изменилась — уведомить всех участников её группы, а если она завершена —
и остальных игроков (_ratings_changed)."""
Адресат — вся группа: списки партий и статистика группы меняются у всех. А вот members = _group_member_ids(session, match.group_id)
история и профили протухают только у игравших, поэтому их id едут в событии.""" hub.publish(members, {"type": "match", "match_id": match.id, "group_id": match.group_id})
hub.publish( if match.status == "finished":
_group_member_ids(session, match.group_id), _ratings_changed(session, members)
{
"type": "match",
"match_id": match.id,
"group_id": match.group_id,
"participant_ids": match_participant_ids(session, match.id), # type: ignore[arg-type]
},
)
def match_draft_changed(session: Session, match: Match, actor_id: int) -> None: def match_draft_changed(session: Session, match: Match, actor_id: int) -> None:
@@ -53,22 +51,13 @@ def match_draft_changed(session: Session, match: Match, actor_id: int) -> None:
hub.publish(ids, {"type": "match_draft", "match_id": match.id, "group_id": match.group_id}) hub.publish(ids, {"type": "match_draft", "match_id": match.id, "group_id": match.group_id})
def match_removed( def match_removed(session: Session, match_id: int, group_id: int, *, finished: bool) -> None:
session: Session, match_id: int, group_id: int, participant_ids: list[int] | None = None """Партия удалена — уведомить участников группы (обновить списки), а если она была
) -> None: завершена — и остальных игроков. Статус передаётся снаружи: партии уже нет."""
"""Партия удалена — уведомить участников группы (обновить списки). members = _group_member_ids(session, group_id)
hub.publish(members, {"type": "match", "match_id": match_id, "group_id": group_id})
participant_ids передаются снаружи: к этому моменту партии уже нет, а её участники if finished:
ушли каскадом, и собрать их из базы невозможно.""" _ratings_changed(session, members)
hub.publish(
_group_member_ids(session, group_id),
{
"type": "match",
"match_id": match_id,
"group_id": group_id,
"participant_ids": participant_ids or [],
},
)
def group_changed(session: Session, group_id: int, extra_user_ids: list[int] | None = None) -> None: def group_changed(session: Session, group_id: int, extra_user_ids: list[int] | None = None) -> None:
+198 -36
View File
@@ -1,58 +1,220 @@
"""Метрика рейтинга. Вынесена отдельно — легко заменить. """Метрика рейтинга: многопользовательский Elo с множителем отрыва (#22, #23).
По умолчанию: League Points — нормированные очки за место с учётом размера стола Полное описание, обоснование коэффициентов и примеры — docs/rating/rating-system.md;
и ничьих (competition ranking). За партию из N игроков: эталонная реализация тех же формул — docs/rating/simulate.py (тесты сверяют с ней).
points = (N - place - (tie_size - 1)/2) / (N - 1)
1-е место = 1.0, последнее = 0.0; равные места делят сумму очков поровну.
Рейтинговый счёт игрока — сглаженное среднее (байесовское, формула IMDB): Партия раскладывается на пары игроков. Для пары a (выше или наравне) и b:
score = (PRIOR_GAMES * PRIOR_MEAN + SUM(points)) / (PRIOR_GAMES + games) * 100 E_ab = 1 / (1 + 10^((R_b − R_a) / D)) ожидание по рейтингам ДО партии
К реальным партиям «дописываются» PRIOR_GAMES виртуальных со средним PRIOR_MEAN: S_ab = 1 / 0.5 / 0 выше / поровну / ниже
на малой выборке рейтинг держится около 50 и лишь с опытом сходится к чистому ΔR_i = K_i · G(N) / (N − 1) · Σ_j M_ij · (S_ij − E_ij)
среднему — короткая удачная серия новичка не обгоняет стабильного ветерана. Пары двух выбывших в сумму не входят (#91), остальные — все. K_i спускается от K_MAX
у новичка до K_MIN за K_GAMES партий, G(N) — вес размера стола, M — множитель отрыва
(темп, цели, миры; близость по типу победы — только у пар с победителем). Недостающий
признак партии подставляется типичным и не влияет на M.
Модуль — только константы и чистые функции без БД: калибровка на реальных данных —
правка констант, пересчёт выполняется сам (рейтинг — функция упорядоченной истории).
""" """
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass, field
from datetime import date, datetime
from itertools import combinations
# Порог числа игр для попадания в ранжированный топ (ниже — «Новички»/provisional). # Порог числа игр для попадания в ранжированный топ (ниже — «Новички»/provisional).
MIN_GAMES = 10 MIN_GAMES = 10
# Порог числа игр на фракцию для расчёта лучшей/худшей фракции. # Порог числа игр на фракцию для расчёта лучшей/худшей фракции.
FACTION_MIN_GAMES = 2 FACTION_MIN_GAMES = 2
# Сглаживание рейтинга: сколько «виртуальных» партий и с каким средним добавляем. # ─── Правила игры ────────────────────────────────────────────────────────────
PRIOR_GAMES = 10
PRIOR_MEAN = 0.5
# SQL-выражение сглаженного рейтинга поверх агрегата по строкам scored (s.points). # Размер поля в тайлах по числу игроков (дуэль — 2×3, шестеро — 4×5).
# При 0 партий SUM = NULL → score = NULL (рейтинга без игр нет). BOARD_TILES = {2: 6, 3: 9, 4: 12, 5: 16, 6: 20}
SMOOTHED_SCORE_SQL = ( WORLDS_PER_TILE = 2.2
f"({PRIOR_GAMES} * {PRIOR_MEAN} + SUM(s.points)) / ({PRIOR_GAMES} + COUNT(*)) * 100" BASE_ROUNDS = 8
) # Домашнее правило группы: при 5–6 игроках играется 9 раундов.
EXTENDED_ROUNDS = 9
EXTENDED_MIN_PLAYERS = 5
# SQL-выражение очков за участие (tie-aware). Использует поля m.player_count, # ─── Коэффициенты (документ, 4.10) ───────────────────────────────────────────
# mp.place и t.tie_size (размер группы игроков с тем же местом в партии).
MATCH_POINTS_SQL = ( R0 = 1500.0 # стартовый рейтинг
"CASE WHEN m.player_count > 1 " D = 400.0 # масштаб: разница 400 пунктов — шансы 10:1
"THEN (m.player_count - mp.place - (t.tie_size - 1) / 2.0) " K_MAX = 64.0 # K новичка (0 партий)
"/ (m.player_count - 1) " K_MIN = 16.0 # K опытного игрока
"ELSE 1.0 END" K_GAMES = 20 # за сколько партий K линейно спускается от K_MAX к K_MIN
) W_TABLE = 0.5 # вес размера стола
W_TEMPO = 1.0 # вес темпа победы
W_OBJ = 0.5 # вес отрыва по целям
W_WORLDS = 0.5 # вес отрыва по мирам
MU_OBJ = 0.5 # типичный отрыв по целям
MU_WORLDS = 0.5 # типичный отрыв по мирам
M_MIN = 0.5 # страховка: одна партия не легче половины обычной…
M_MAX = 2.0 # …и не тяжелее двух
# Близость партии по типу победы — множитель пар с победителем.
CLOSENESS = {
"objectives": 1.0,
"worlds": 0.85,
"plastic": 0.7,
"resources": 0.6,
"last_standing": 1.0,
}
def smoothed_score(points_sum: float, games: int) -> float | None: def max_rounds(player_count: int, nine_rounds_rule: bool) -> int:
"""Тот же сглаженный рейтинг, что и SMOOTHED_SCORE_SQL, но в Python. """Лимит раундов партии: 9 при хоумруле группы и 5+ игроках, иначе 8."""
if nine_rounds_rule and player_count >= EXTENDED_MIN_PLAYERS:
return EXTENDED_ROUNDS
return BASE_ROUNDS
Нужен там, где строки уже вытащены и агрегировать в SQL нечего (профиль игрока).
Держим рядом с SQL-версией и на одних константах: разъехавшиеся реализации одной def fair_worlds(player_count: int) -> float:
формулы — источник расхождений, который потом ловится только глазами.""" """«Честная доля» миров на игрока — масштаб для разницы миров."""
if games <= 0: return BOARD_TILES[player_count] * WORLDS_PER_TILE / player_count
return None # рейтинга без игр нет — как SUM(...) = NULL в SQL
return (PRIOR_GAMES * PRIOR_MEAN + points_sum) / (PRIOR_GAMES + games) * 100
def mu_tempo(rmax: int) -> float:
"""Типичный темп: партия закончилась в предпоследнем раунде."""
return 1.0 / (rmax - 1)
def expected(r_a: float, r_b: float) -> float:
"""Ожидаемый результат a против b (вероятность, что a окажется выше)."""
return 1.0 / (1.0 + 10.0 ** ((r_b - r_a) / D))
def k_factor(games: int) -> float:
left = max(0.0, 1.0 - games / K_GAMES)
return K_MIN + (K_MAX - K_MIN) * left
def table_weight(player_count: int) -> float:
return 1.0 + W_TABLE * (player_count - 2) / 4.0
def _clamp(x: float, lo: float, hi: float) -> float:
return max(lo, min(hi, x))
# ─── Партия как вход расчёта ─────────────────────────────────────────────────
@dataclass(frozen=True)
class RatedSeat:
user_id: int
place: int
faction_id: int = 0
eliminated: bool = False
objectives: int | None = None # маркеры целей на конец партии
worlds: int | None = None # дружественные миры на конец партии
@dataclass(frozen=True)
class RatedMatch:
seats: tuple[RatedSeat, ...]
win_reason: str | None = None
end_round: int | None = None # раунд, в котором партия закончилась
nine_rounds_rule: bool = False # снимок настройки группы на момент партии
id: int = 0
group_id: int = 0
played_at: date | None = None
finished_at: datetime | None = None
def pair_multiplier(m: RatedMatch, a: RatedSeat, b: RatedSeat) -> float:
"""Множитель отрыва пары; a — выше или наравне с b."""
n = len(m.seats)
tie = a.place == b.place
winner_pair = a.place == 1
add = 1.0
def diff(x: int, y: int) -> float:
return abs(x - y) if tie else max(0, x - y)
if winner_pair:
rmax = max_rounds(n, m.nine_rounds_rule)
mu = mu_tempo(rmax)
tempo = mu if m.end_round is None else (rmax - m.end_round) / (rmax - 1)
add += W_TEMPO * (tempo - mu)
if winner_pair and m.win_reason == "last_standing":
obj = 1.0 # все соперники устранены — отрыв максимальный, сколько бы ни было маркеров
elif a.objectives is not None and b.objectives is not None:
obj = _clamp(diff(a.objectives, b.objectives) / n, 0.0, 1.0)
else:
obj = MU_OBJ
add += W_OBJ * (obj - MU_OBJ)
if a.worlds is not None and b.worlds is not None:
wor = _clamp(diff(a.worlds, b.worlds) / fair_worlds(n), 0.0, 1.0)
else:
wor = MU_WORLDS
add += W_WORLDS * (wor - MU_WORLDS)
close = CLOSENESS.get(m.win_reason, 1.0) if winner_pair and m.win_reason else 1.0
return _clamp(add, M_MIN, M_MAX) * close
def rate_match(
ratings: dict[int, float], games: dict[int, int], m: RatedMatch
) -> tuple[dict[int, float], dict[int, float]]:
"""Изменения рейтинга участников и их результат относительно ожидания.
Возвращает (ΔR, perf): perf_i = Σ_j (S_ij − E_ij) / (N − 1) — насколько игрок
выступил выше ожидания, без множителя отрыва и K. Входные словари не мутирует."""
n = len(m.seats)
delta = {s.user_id: 0.0 for s in m.seats}
perf = {s.user_id: 0.0 for s in m.seats}
if n < 2:
return delta, perf
g = table_weight(n)
r = {s.user_id: ratings.get(s.user_id, R0) for s in m.seats}
k = {s.user_id: k_factor(games.get(s.user_id, 0)) for s in m.seats}
for a, b in combinations(m.seats, 2):
if a.eliminated and b.eliminated:
# Выбывшие между собой не сравниваются: в этой партии все они проиграли, а миров
# у них нет (решение владельца, #91). Нормировка на N − 1 остаётся прежней.
continue
if a.place > b.place:
a, b = b, a
s_ab = 0.5 if a.place == b.place else 1.0
e_ab = expected(r[a.user_id], r[b.user_id])
x = pair_multiplier(m, a, b) * (s_ab - e_ab)
delta[a.user_id] += k[a.user_id] * g / (n - 1) * x
delta[b.user_id] -= k[b.user_id] * g / (n - 1) * x
perf[a.user_id] += (s_ab - e_ab) / (n - 1)
perf[b.user_id] -= (s_ab - e_ab) / (n - 1)
return delta, perf
@dataclass
class Replay:
"""Итог проигрывания истории: рейтинги без округления и следы каждой партии."""
ratings: dict[int, float] = field(default_factory=dict)
games: dict[int, int] = field(default_factory=dict)
delta: dict[tuple[int, int], float] = field(default_factory=dict) # (match_id, user_id)
perf: dict[tuple[int, int], float] = field(default_factory=dict) # (match_id, user_id)
def replay(matches: Iterable[RatedMatch]) -> Replay:
"""Проигрывает партии в переданном порядке (хронологию задаёт вызывающий)."""
out = Replay()
for m in matches:
delta, perf = rate_match(out.ratings, out.games, m)
for uid, dv in delta.items():
out.ratings[uid] = out.ratings.get(uid, R0) + dv
out.games[uid] = out.games.get(uid, 0) + 1
out.delta[(m.id, uid)] = dv
out.perf[(m.id, uid)] = perf[uid]
return out
def leaderboard_sort_key(row: dict) -> tuple: def leaderboard_sort_key(row: dict) -> tuple:
"""Ключ сортировки топа: счёт ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑.""" """Ключ сортировки топа: рейтинг ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑.
row["rating"] — рейтинг без округления: два игрока с одинаковым целым в топе
всё равно упорядочены по настоящему значению."""
return ( return (
-(row["score"] or 0.0), -(row["rating"] or 0.0),
-(row["win_rate"] or 0.0), -(row["win_rate"] or 0.0),
-(row["games"] or 0), -(row["games"] or 0),
(row["avg_place"] or 0.0), (row["avg_place"] or 0.0),
+276 -208
View File
@@ -1,105 +1,183 @@
"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен).""" """Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен).
Рейтинг — функция упорядоченной истории (scoring.replay), поэтому витрины не агрегируют
SQL, а проигрывают завершённые партии: одна загрузка истории на запрос, из неё же
считаются игры, победы, среднее место и разбивки. Рейтинг у игрока один — по всем
партиям приложения (#80). Страница группы берёт из него только рейтинг, а игры, победы,
винрейт и среднее место считает по партиям группы."""
from __future__ import annotations from __future__ import annotations
from typing import Any from collections import defaultdict
from sqlalchemy import func, text from sqlalchemy import func
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core.timeutil import iso_utc from app.core.timeutil import iso_utc
from app.models import Faction, Group, GroupMember, Match, MatchParticipant, User from app.models import Expansion, Faction, Group, GroupMember, Match, MatchParticipant, User
from app.services import faction_service, group_service, membership_service, user_service from app.services import faction_service, group_service, membership_service, user_service
from app.services.scoring import ( from app.services.scoring import (
FACTION_MIN_GAMES, FACTION_MIN_GAMES,
MATCH_POINTS_SQL,
MIN_GAMES, MIN_GAMES,
SMOOTHED_SCORE_SQL, RatedMatch,
RatedSeat,
Replay,
leaderboard_sort_key, leaderboard_sort_key,
smoothed_score, replay,
) )
# Базовый блок: одна строка на участие с tie-aware очками.
# Учитываются только ЗАВЕРШЁННЫЕ партии (in_progress без мест в статистику не входят). MIN_PARTICIPANTS = 2 # партия, где осталось меньше участников, партией не считается
SCORED_CTE = f"""
WITH tie AS (
SELECT mp.match_id AS match_id, mp.place AS place, COUNT(*) AS tie_size def _playable_match_ids():
FROM match_participants mp """Подзапрос id партий, в которых не меньше MIN_PARTICIPANTS участников.
JOIN matches m ON m.id = mp.match_id
WHERE m.status = 'finished' AND mp.place IS NOT NULL Партия может «опустеть» в деве: жёсткое удаление аккаунта (routers/dev_admin.py)
GROUP BY mp.match_id, mp.place вычёркивает игрока из партий и не пересчитывает их. Партия с одним участником —
), уже не игра: её нет ни в рейтинге, ни в историях и списках (админка её видит)."""
scored AS ( return (
SELECT mp.user_id AS user_id, select(MatchParticipant.match_id)
mp.faction_id AS faction_id, .group_by(MatchParticipant.match_id)
m.id AS match_id, .having(func.count() >= MIN_PARTICIPANTS)
m.group_id AS group_id,
m.played_at AS played_at,
mp.place AS place,
mp.was_random AS was_random,
m.player_count AS player_count,
({MATCH_POINTS_SQL}) AS points,
CASE WHEN mp.place = 1 THEN 1 ELSE 0 END AS is_win
FROM match_participants mp
JOIN matches m ON m.id = mp.match_id
JOIN tie t ON t.match_id = mp.match_id AND t.place = mp.place
WHERE m.status = 'finished'
) )
"""
def _round(value: Any, ndigits: int) -> float | None: def load_history(session: Session) -> list[RatedMatch]:
return None if value is None else round(float(value), ndigits) """Все завершённые партии в порядке проигрывания: дата игры, момент завершения, id.
Один запрос на партии с участниками. In_progress в рейтинг не входят: мест у них нет,
партии меньше чем с двумя участниками — тоже (_playable_match_ids). Срез группы —
_for_group по этой же истории: рейтинг считается только целиком."""
stmt = (
select(Match, MatchParticipant)
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
.where(Match.status == "finished")
.order_by(Match.played_at, Match.finished_at, Match.id, MatchParticipant.id)
)
history: list[RatedMatch] = []
current: Match | None = None
seats: list[RatedSeat] = []
def flush() -> None:
if current is not None and len(seats) >= MIN_PARTICIPANTS:
history.append(
RatedMatch(
seats=tuple(seats),
win_reason=current.win_reason,
end_round=current.end_round,
nine_rounds_rule=current.nine_rounds_rule,
id=current.id, # type: ignore[arg-type]
group_id=current.group_id,
played_at=current.played_at,
finished_at=current.finished_at,
)
)
for m, p in session.exec(stmt).all():
if current is None or m.id != current.id:
flush()
current, seats = m, []
if p.place is None: # у завершённой партии мест без значения не бывает
continue
seats.append(
RatedSeat(
user_id=p.user_id,
place=p.place,
faction_id=p.faction_id,
eliminated=p.eliminated,
objectives=p.objectives,
worlds=p.worlds,
)
)
flush()
return history
def _normalize(row: dict) -> dict: def _for_group(history: list[RatedMatch], group_id: int) -> list[RatedMatch]:
# avatar_url мирроринг user_service.avatar_url_for: версия = epoch(updated_at) из SQL. return [m for m in history if m.group_id == group_id]
avatar_url = None
if row.get("avatar_path"):
avatar_url = f"/api/users/{row['user_id']}/avatar?v={int(row.get('avatar_version') or 0)}" def _user_seats(history: list[RatedMatch], user_id: int) -> list[tuple[RatedMatch, RatedSeat]]:
return [(m, s) for m in history for s in m.seats if s.user_id == user_id]
def _rating_confirmed(rep: Replay, user_id: int) -> bool:
"""Рейтинг подтверждён, когда за игроком MIN_GAMES партий во всём приложении."""
return rep.games.get(user_id, 0) >= MIN_GAMES
def _summary(seats: list[tuple[RatedMatch, RatedSeat]], rating: float | None) -> dict:
"""Итог игрока: игры, победы, винрейт, среднее место и рейтинг целым числом.
В расчёте рейтинг без округления (иначе ошибка копилась бы по цепочке партий),
показывается — целым."""
games = len(seats)
if games == 0:
return {"games": 0, "wins": 0, "win_rate": 0.0, "avg_place": None, "score": None}
wins = sum(1 for _m, s in seats if s.place == 1)
return { return {
"user_id": row["user_id"], "games": games,
"nickname": row["nickname"], "wins": wins,
"games": int(row["games"] or 0), "win_rate": round(wins / games, 4),
"wins": int(row["wins"] or 0), "avg_place": round(sum(s.place for _m, s in seats) / games, 2),
"win_rate": _round(row["win_rate"] or 0.0, 4), "score": None if rating is None else round(rating),
"avg_place": _round(row["avg_place"], 2),
"score": _round(row["score"], 1),
"avatar_url": avatar_url,
} }
def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]: def leaderboard(
where = "WHERE s.group_id = :gid" if group_id is not None else "" session: Session,
sql = f""" group_id: int | None = None,
{SCORED_CTE} *,
SELECT u.id AS user_id, u.nickname AS nickname, history: list[RatedMatch] | None = None,
u.avatar_path AS avatar_path, rep: Replay | None = None,
CAST(strftime('%s', u.updated_at) AS INTEGER) AS avatar_version, member_ids: set[int] | None = None,
COUNT(*) AS games, ) -> dict:
SUM(s.is_win) AS wins, """Топ: общий или группы. history/rep — вся история и её проигрывание (home и
AVG(CAST(s.is_win AS FLOAT)) AS win_rate, group_stats их переиспользуют).
AVG(s.place) AS avg_place,
{SMOOTHED_SCORE_SQL} AS score
FROM scored s
JOIN users u ON u.id = s.user_id
{where}
GROUP BY u.id, u.nickname, u.avatar_path, u.updated_at
"""
params = {"gid": group_id} if group_id is not None else {}
result = session.execute(text(sql), params).mappings().all()
return [_normalize(dict(r)) for r in result]
Рейтинг и статус «Новичок» — всегда общие: статус описывает надёжность рейтинга, а он
считается по всем партиям. С group_id игры, победы, винрейт и среднее место берутся
только из партий группы (#80). member_ids — показывать только этих игроков (топ
группы — её текущий состав, #76); места нумеруются уже после фильтра."""
if history is None:
history = load_history(session)
if rep is None:
rep = replay(history)
shown = history if group_id is None else _for_group(history, group_id)
def leaderboard(session: Session, group_id: int | None = None) -> dict: by_user: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list)
rows = _leaderboard_rows(session, group_id) for m in shown:
qualified = [r for r in rows if r["games"] >= MIN_GAMES] for s in m.seats:
provisional = [r for r in rows if r["games"] < MIN_GAMES] if member_ids is None or s.user_id in member_ids:
qualified.sort(key=leaderboard_sort_key) by_user[s.user_id].append((m, s))
provisional.sort(key=leaderboard_sort_key) users = (
{u.id: u for u in session.exec(select(User).where(User.id.in_(list(by_user)))).all()}
if by_user
else {}
)
rows = []
for uid, seats in by_user.items():
u = users[uid]
rows.append(
{
"user_id": uid,
"nickname": u.nickname,
**_summary(seats, rep.ratings[uid]),
"rating": rep.ratings[uid], # только для сортировки
"rating_confirmed": _rating_confirmed(rep, uid),
"avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
}
)
qualified = sorted((r for r in rows if r["rating_confirmed"]), key=leaderboard_sort_key)
provisional = sorted((r for r in rows if not r["rating_confirmed"]), key=leaderboard_sort_key)
for i, r in enumerate(qualified, start=1): for i, r in enumerate(qualified, start=1):
r["rank"] = i r["rank"] = i
for r in provisional: for r in provisional:
r["rank"] = None r["rank"] = None
for r in rows:
del r["rating"]
return { return {
"entries": qualified, "entries": qualified,
"provisional": provisional, "provisional": provisional,
@@ -107,72 +185,38 @@ def leaderboard(session: Session, group_id: int | None = None) -> dict:
} }
def _user_scored_rows(session: Session, user_id: int) -> list[dict]: def _faction_breakdown(
"""Строки участия игрока со всеми полями, нужными витринам профиля. session: Session, seats: list[tuple[RatedMatch, RatedSeat]], rep: Replay, user_id: int
) -> list[dict]:
Один проход по SCORED_CTE вместо трёх: общий итог, разбивка по фракциям и форма by_faction: dict[int, list[tuple[RatedMatch, RatedSeat]]] = defaultdict(list)
последних партий считаются из одного и того же набора строк. CTE джойнит участия for m, s in seats:
со всеми партиями приложения, поэтому каждый лишний проход дорожает вместе с by_faction[s.faction_id].append((m, s))
общим числом партий, а не с числом партий игрока.""" if not by_faction:
sql = f""" return []
{SCORED_CTE} meta = {
SELECT s.group_id AS group_id, s.match_id AS match_id, s.played_at AS played_at, f.id: (f, code)
s.place AS place, s.player_count AS player_count, for f, code in session.exec(
s.points AS points, s.is_win AS is_win, select(Faction, Expansion.code)
f.id AS faction_id, f.code AS code, f.name_ru AS name_ru, .join(Expansion, Expansion.id == Faction.expansion_id)
e.code AS expansion_code .where(Faction.id.in_(list(by_faction)))
FROM scored s ).all()
JOIN factions f ON f.id = s.faction_id
JOIN expansions e ON e.id = f.expansion_id
WHERE s.user_id = :uid
"""
rows = session.execute(text(sql), {"uid": user_id}).mappings().all()
return [dict(r) for r in rows]
def _for_group(rows: list[dict], group_id: int | None) -> list[dict]:
return rows if group_id is None else [r for r in rows if r["group_id"] == group_id]
def _overall_from_rows(rows: list[dict]) -> dict:
"""Тот же итог, что раньше считал SQL: COUNT/SUM/AVG плюс сглаженный рейтинг."""
games = len(rows)
if games == 0:
return {"games": 0, "wins": 0, "win_rate": 0.0, "avg_place": None, "score": None}
wins = sum(int(r["is_win"]) for r in rows)
return {
"games": games,
"wins": wins,
"win_rate": _round(wins / games, 4),
"avg_place": _round(sum(r["place"] for r in rows) / games, 2),
"score": _round(smoothed_score(sum(float(r["points"]) for r in rows), games), 1),
} }
def _faction_breakdown_from_rows(rows: list[dict]) -> list[dict]:
by_faction: dict[int, list[dict]] = {}
for r in rows:
by_faction.setdefault(r["faction_id"], []).append(r)
out = [] out = []
for fid, group in by_faction.items(): for fid, group in by_faction.items():
games = len(group) faction, expansion_code = meta[fid]
wins = sum(int(r["is_win"]) for r in group) perf = sum(rep.perf[(m.id, user_id)] for m, _s in group) / len(group)
meta = group[0]
out.append( out.append(
{ {
"faction_id": fid, "faction_id": fid,
"code": meta["code"], "code": faction.code,
"name_ru": meta["name_ru"], "name_ru": faction.name_ru,
"expansion_code": meta["expansion_code"], "expansion_code": expansion_code,
"games": games, **{k: v for k, v in _summary(group, None).items() if k != "score"},
"wins": wins, # Средний результат относительно ожидания (S − E) × 100: насколько игрок
"win_rate": _round(wins / games, 4), # на фракции выступает выше рейтинговых шансов — без привязки к рейтингу.
"avg_place": _round(sum(r["place"] for r in group) / games, 2), "score": round(perf * 100, 1),
# Фракции: чистое среднее (служебная метрика «лучшая/худшая»,
# сглаживание задавило бы её к 50).
"score": _round(sum(float(r["points"]) for r in group) / games * 100, 1),
"name_ru_prepositional": faction_service.prepositional( "name_ru_prepositional": faction_service.prepositional(
meta["code"], meta["name_ru"] faction.code, faction.name_ru
), ),
} }
) )
@@ -180,15 +224,15 @@ def _faction_breakdown_from_rows(rows: list[dict]) -> list[dict]:
return out return out
def _recent_form_from_rows(rows: list[dict], limit: int = 5) -> list[dict]: def _recent_form(seats: list[tuple[RatedMatch, RatedSeat]], limit: int = 5) -> list[dict]:
recent = sorted(rows, key=lambda r: (str(r["played_at"]), r["match_id"]), reverse=True) recent = sorted(seats, key=lambda ms: (str(ms[0].played_at), ms[0].id), reverse=True)
return [ return [
{ {
"place": r["place"], "place": s.place,
"player_count": r["player_count"], "player_count": len(m.seats),
"played_at": str(r["played_at"]), "played_at": str(m.played_at),
} }
for r in recent[:limit] for m, s in recent[:limit]
] ]
@@ -208,78 +252,91 @@ def _favorite_faction(session: Session, user_id: int) -> dict | None:
} }
def _overall(history: list[RatedMatch], rep: Replay, user_id: int) -> dict:
return _summary(_user_seats(history, user_id), rep.ratings.get(user_id))
def profile_stats( def profile_stats(
session: Session, session: Session,
user_id: int, user_id: int,
group_id: int | None = None,
*, *,
rows: list[dict] | None = None, history: list[RatedMatch] | None = None,
rep: Replay | None = None,
) -> dict: ) -> dict:
"""Витрина профиля. rows — уже вытащенные строки игрока (home() их переиспользует).""" """Витрина профиля — общие показатели. history/rep — уже посчитанные (home их
scoped = _for_group(rows if rows is not None else _user_scored_rows(session, user_id), group_id) переиспользует)."""
overall = _overall_from_rows(scoped) if history is None:
factions = _faction_breakdown_from_rows(scoped) history = load_history(session)
if rep is None:
rep = replay(history)
seats = _user_seats(history, user_id)
factions = _faction_breakdown(session, seats, rep, user_id)
qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES] qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES]
best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None best = max(qualified, key=lambda f: f["score"]) if qualified else None
worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None worst = min(qualified, key=lambda f: f["score"]) if qualified else None
# «Чаще всего играет на» — самая игранная фракция по всей истории, включая # «Чаще всего играет на» — самая игранная фракция по всей истории, включая
# рандомные раздачи. # рандомные раздачи.
main = max(factions, key=lambda f: f["games"]) if factions else None main = max(factions, key=lambda f: f["games"]) if factions else None
return { return {
"user_id": user_id, "user_id": user_id,
"overall": overall, "overall": _summary(seats, rep.ratings.get(user_id)),
"factions": factions, "factions": factions,
"best_faction": best, "best_faction": best,
"worst_faction": worst, "worst_faction": worst,
"favorite_faction": _favorite_faction(session, user_id), "favorite_faction": _favorite_faction(session, user_id),
"main_faction": main, "main_faction": main,
"recent_form": _recent_form_from_rows(scoped), "recent_form": _recent_form(seats),
"min_games": MIN_GAMES, "min_games": MIN_GAMES,
} }
def group_stats(session: Session, group_id: int) -> dict: def group_stats(session: Session, group_id: int) -> dict:
board = leaderboard(session, group_id=group_id) history = load_history(session)
# Нужны только счётчик и дата последней партии — тянуть строки целиком незачем. rep = replay(history)
games_count, last_played = session.exec( group_history = _for_group(history, group_id)
select(func.count(), func.max(Match.played_at)).where( members = membership_service.list_members(session, group_id)
Match.group_id == group_id, Match.status == "finished" # Список игроков группы — только её текущий состав: удалённый из группы в нём не висит.
board = leaderboard(
session,
group_id,
history=history,
rep=rep,
member_ids={u.id for _m, u in members}, # type: ignore[misc]
) )
).one() last_played = max((m.played_at for m in group_history), default=None)
last_at = str(last_played) if last_played else None
games: dict[int, int] = defaultdict(int)
wins: dict[int, int] = defaultdict(int)
for m in group_history:
for s in m.seats:
games[s.faction_id] += 1
wins[s.faction_id] += s.place == 1
available_ids = group_service.available_faction_ids(session, group_id) available_ids = group_service.available_faction_ids(session, group_id)
faction_meta = [] factions = sorted(
sql = f""" session.exec(select(Faction)).all(), key=lambda f: (-games[f.id], f.sort_order) # type: ignore[index]
{SCORED_CTE}
SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
COUNT(s.user_id) AS games, SUM(s.is_win) AS wins
FROM factions f
LEFT JOIN scored s ON s.faction_id = f.id AND s.group_id = :gid
GROUP BY f.id, f.code, f.name_ru
ORDER BY games DESC, f.sort_order
"""
rows = session.execute(text(sql), {"gid": group_id}).mappings().all()
for r in rows:
faction_meta.append(
{
"faction_id": r["faction_id"],
"code": r["code"],
"name_ru": r["name_ru"],
"games": int(r["games"] or 0),
"wins": int(r["wins"] or 0),
"available": r["faction_id"] in available_ids,
}
) )
faction_meta = [
{
"faction_id": f.id,
"code": f.code,
"name_ru": f.name_ru,
"games": games[f.id], # type: ignore[index]
"wins": wins[f.id], # type: ignore[index]
"available": f.id in available_ids,
}
for f in factions
]
# Участники без завершённых партий — отдельным блоком (нули, rank=null). # Участники без завершённых партий в группе — отдельным блоком (нули, rank=null).
# Рейтинг у них общий: если игрок играл в других группах, он виден и здесь.
played_ids = {e["user_id"] for e in board["entries"]} | { played_ids = {e["user_id"] for e in board["entries"]} | {
e["user_id"] for e in board["provisional"] e["user_id"] for e in board["provisional"]
} }
inactive = [] inactive = []
for _m, u in membership_service.list_members(session, group_id): for _m, u in members:
if u.id in played_ids: if u.id in played_ids:
continue continue
rating = rep.ratings.get(u.id) # type: ignore[arg-type]
inactive.append( inactive.append(
{ {
"user_id": u.id, "user_id": u.id,
@@ -288,7 +345,8 @@ def group_stats(session: Session, group_id: int) -> dict:
"wins": 0, "wins": 0,
"win_rate": 0.0, "win_rate": 0.0,
"avg_place": None, "avg_place": None,
"score": None, "score": None if rating is None else round(rating),
"rating_confirmed": _rating_confirmed(rep, u.id), # type: ignore[arg-type]
"rank": None, "rank": None,
"avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), "avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at),
} }
@@ -296,8 +354,8 @@ def group_stats(session: Session, group_id: int) -> dict:
return { return {
"group_id": group_id, "group_id": group_id,
"total_matches": games_count, "total_matches": len(group_history),
"last_match_at": last_at, "last_match_at": str(last_played) if last_played else None,
"leaderboard": board["entries"], "leaderboard": board["entries"],
"provisional": board["provisional"], "provisional": board["provisional"],
"inactive": inactive, "inactive": inactive,
@@ -316,6 +374,8 @@ def _participant_row(p: MatchParticipant, u: User, f: Faction) -> dict:
"eliminated": p.eliminated, "eliminated": p.eliminated,
"was_random": p.was_random, "was_random": p.was_random,
"comment": p.comment, "comment": p.comment,
"objectives": p.objectives,
"worlds": p.worlds,
} }
@@ -337,8 +397,12 @@ def _participants_by_match(session: Session, match_ids: list[int]) -> dict[int,
return out return out
def _match_items(session: Session, matches) -> list[dict]: def _match_items(
"""Элементы списка партий (общее для списка группы и истории игрока).""" session: Session, matches, rating_deltas: dict[int, float] | None = None
) -> list[dict]:
"""Элементы списка партий (общее для списка группы и истории игрока).
rating_deltas — {match_id: ΔR} владельца истории; у списка группы их нет."""
by_match = _participants_by_match(session, [m.id for m in matches]) by_match = _participants_by_match(session, [m.id for m in matches])
items = [] items = []
@@ -357,18 +421,18 @@ def _match_items(session: Session, matches) -> list[dict]:
"overall_comment": m.overall_comment, "overall_comment": m.overall_comment,
"created_by": m.created_by, "created_by": m.created_by,
"participants": parts, "participants": parts,
"rating_delta": None if rating_deltas is None else rating_deltas.get(m.id),
} }
) )
return items return items
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict: def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
total = session.exec( where = (Match.group_id == group_id, Match.id.in_(_playable_match_ids()))
select(func.count()).select_from(Match).where(Match.group_id == group_id) total = session.exec(select(func.count()).select_from(Match).where(*where)).one()
).one()
matches = session.exec( matches = session.exec(
select(Match) select(Match)
.where(Match.group_id == group_id) .where(*where)
.order_by(Match.played_at.desc(), Match.id.desc()) .order_by(Match.played_at.desc(), Match.id.desc())
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
@@ -390,27 +454,29 @@ def user_match_list(
) -> dict: ) -> dict:
"""История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху. """История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху.
best_only — одна лучшая партия по League Points (s.points из SCORED_CTE учитывает У каждой партии — изменение общего рейтинга игрока за неё (rating_delta, один знак
место и размер стола); при равных очках берём более свежую.""" после запятой). best_only — одна лучшая партия: наибольший прирост рейтинга
(учитывает и соперников, и ход партии); при равенстве берём более свежую."""
history = load_history(session)
rep = replay(history)
mine = _user_seats(history, user_id)
# + 0.0 превращает −0.0 (мелкий минус, округлённый до нуля) в обычный ноль.
deltas = {m.id: round(rep.delta[(m.id, user_id)], 1) + 0.0 for m, _s in mine}
if best_only: if best_only:
sql = f""" candidates = [(rep.delta[(m.id, user_id)], m.played_at, m.id) for m, _s in mine]
{SCORED_CTE} matches = [session.get(Match, max(candidates)[2])] if candidates else []
SELECT s.match_id AS match_id
FROM scored s
WHERE s.user_id = :uid
ORDER BY s.points DESC, s.played_at DESC, s.match_id DESC
LIMIT 1
"""
row = session.execute(text(sql), {"uid": user_id}).mappings().first()
matches = [session.get(Match, row["match_id"])] if row else []
return { return {
"items": _match_items(session, matches), "items": _match_items(session, matches, deltas),
"total": len(matches), "total": len(matches),
"limit": 1, "limit": 1,
"offset": 0, "offset": 0,
} }
where = (MatchParticipant.user_id == user_id, Match.status == "finished") where = (
MatchParticipant.user_id == user_id,
Match.status == "finished",
Match.id.in_(_playable_match_ids()),
)
total = session.exec( total = session.exec(
select(func.count()) select(func.count())
.select_from(Match) .select_from(Match)
@@ -426,7 +492,7 @@ def user_match_list(
.limit(limit) .limit(limit)
).all() ).all()
return { return {
"items": _match_items(session, matches), "items": _match_items(session, matches, deltas),
"total": total, "total": total,
"limit": limit, "limit": limit,
"offset": offset, "offset": offset,
@@ -464,10 +530,12 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict: def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict:
board = leaderboard(session, group_id=None) # История грузится и проигрывается один раз: из неё и топ, и профиль, и блок активной
# Строки игрока тянем один раз: из них считается и профиль, и итог по активной группе. # группы (там игры и победы по группе, рейтинг — общий).
rows = _user_scored_rows(session, user_id) history = load_history(session)
profile = profile_stats(session, user_id, group_id=None, rows=rows) rep = replay(history)
board = leaderboard(session, history=history, rep=rep)
profile = profile_stats(session, user_id, history=history, rep=rep)
active_group_brief = None active_group_brief = None
if active_group_id is not None: if active_group_id is not None:
group = session.get(Group, active_group_id) group = session.get(Group, active_group_id)
@@ -475,7 +543,7 @@ def home(session: Session, user_id: int, active_group_id: int | None, leaderboar
active_group_brief = { active_group_brief = {
"id": group.id, "id": group.id,
"name": group.name, "name": group.name,
**_overall_from_rows(_for_group(rows, active_group_id)), **_overall(_for_group(history, active_group_id), rep, user_id),
} }
return { return {
"leaderboard": board["entries"][:leaderboard_limit], "leaderboard": board["entries"][:leaderboard_limit],
+1 -1
View File
@@ -380,5 +380,5 @@ def public_profile(session: Session, user_id: int) -> dict:
"nickname": user.nickname, "nickname": user.nickname,
"bio": user.bio, "bio": user.bio,
"avatar_url": avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type] "avatar_url": avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
"stats": stats_service.profile_stats(session, user_id, group_id=None), "stats": stats_service.profile_stats(session, user_id),
} }
+35
View File
@@ -2,7 +2,11 @@
from __future__ import annotations from __future__ import annotations
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy.pool import StaticPool
from sqlmodel import Session, SQLModel, create_engine, select
from app.models import Faction
from app.seed.reference_data import FACTIONS, seed_reference_data
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login, start_match from tests.conftest import add_group_member, create_finished_match, csrf_headers, login, start_match
@@ -205,6 +209,37 @@ def test_admin_rename_faction_system_wide(client: TestClient, make_admin, engine
assert ap["faction_name"] == "Орки WAAAGH" assert ap["faction_name"] == "Орки WAAAGH"
def test_faction_rename_survives_restart_seeding(client: TestClient, make_admin, engine):
"""Сидинг идёт при каждом старте (entrypoint.sh, lifespan) и не должен откатывать
имя, заданное админом (#72)."""
login(client, "Кто-то")
orks = next(f for f in client.get("/api/factions").json() if f["code"] == "orks")
_admin_login(client, make_admin)
r = client.patch(
f"/api/admin/factions/{orks['id']}",
json={"name_ru": "Орки WAAAGH"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
with Session(engine) as s:
seed_reference_data(s) # то же, что делает рестарт
assert s.get(Faction, orks["id"]).name_ru == "Орки WAAAGH"
def test_seeding_is_complete_and_idempotent():
"""Пустая БД: фракции из кода создаются со своими именами, повтор ничего не ломает."""
engine = create_engine(
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
)
SQLModel.metadata.create_all(engine)
with Session(engine) as s:
seed_reference_data(s)
seed_reference_data(s)
names = {f.code: f.name_ru for f in s.exec(select(Faction)).all()}
assert names == {code: name for code, name, _exp, _order in FACTIONS}
def test_admin_delete_group_with_matches_is_conflict(client: TestClient, make_admin, engine): def test_admin_delete_group_with_matches_is_conflict(client: TestClient, make_admin, engine):
"""Группу с партиями удалять нельзя — но ответ должен быть внятным 409. """Группу с партиями удалять нельзя — но ответ должен быть внятным 409.
@@ -0,0 +1,77 @@
"""Ротация пароля администратора из .env (#73): `python -m app.bootstrap --reset-admin-password`."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, select
from app import bootstrap
from app.core.security import verify_password
from app.models import User
from tests.conftest import csrf_headers
OLD, NEW = "secret123", "brand-new-admin-pw"
def _admin(engine) -> User:
with Session(engine) as s:
return s.exec(select(User).where(User.role == "admin")).one()
def _login(client: TestClient, password: str):
return client.post(
"/api/admin/auth/login",
json={"username": "admin", "password": password},
headers=csrf_headers(client),
)
def test_reset_applies_env_password_and_revokes_sessions(
client: TestClient, engine, make_admin, monkeypatch
):
make_admin("admin", OLD)
assert _login(client, OLD).status_code == 200
assert client.get("/api/admin/me").status_code == 200
version = _admin(engine).token_version
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
with Session(engine) as s:
bootstrap.reset_admin_password(s)
admin = _admin(engine)
assert verify_password(NEW, admin.password_hash)
assert admin.token_version == version + 1
# Прежняя админская cookie больше не действует (в т.ч. у того, кто украл пароль).
assert client.get("/api/admin/me").status_code == 401
assert _login(client, OLD).status_code == 401
assert _login(client, NEW).status_code == 200
def test_regular_bootstrap_keeps_admin_password_outside_dev(engine, make_admin, monkeypatch):
"""Обычный старт в prod по-прежнему не берёт пароль из .env — только ротация."""
make_admin("admin", OLD)
monkeypatch.setattr(bootstrap.settings, "app_env", "production")
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
with Session(engine) as s:
bootstrap._ensure_admin(s)
assert verify_password(OLD, _admin(engine).password_hash)
def test_reset_refuses_without_admin_or_password(engine, make_admin, monkeypatch):
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
with Session(engine) as s, pytest.raises(RuntimeError, match="Администратора ещё нет"):
bootstrap.reset_admin_password(s)
make_admin("admin", OLD)
monkeypatch.setattr(bootstrap.settings, "admin_password", " ")
with Session(engine) as s, pytest.raises(RuntimeError, match="ADMIN_PASSWORD пуст"):
bootstrap.reset_admin_password(s)
assert verify_password(OLD, _admin(engine).password_hash)
def test_reset_refuses_when_bootstrap_disabled(engine, make_admin, monkeypatch):
make_admin("admin", OLD)
monkeypatch.setattr(bootstrap.settings, "admin_bootstrap_enabled", False)
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
with Session(engine) as s, pytest.raises(RuntimeError, match="ADMIN_BOOTSTRAP_ENABLED"):
bootstrap.reset_admin_password(s)
+1 -1
View File
@@ -1,4 +1,4 @@
"""Хардненинг API: раскрытие схемы закрыто в production, открыто в dev/test (#61, F6).""" """Хардненинг API: раскрытие схемы закрыто в production, открыто в dev (#61, F6)."""
from __future__ import annotations from __future__ import annotations
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
+1 -5
View File
@@ -23,14 +23,12 @@ def test_enabled_methods_by_env(monkeypatch):
monkeypatch.setattr(settings, "app_env", "development") monkeypatch.setattr(settings, "app_env", "development")
assert set(enabled_methods()) == {"password", "telegram", "stub"} 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") monkeypatch.setattr(settings, "app_env", "production")
assert enabled_methods() == ["password", "telegram"] # prod → без stub assert enabled_methods() == ["password", "telegram"] # prod → без stub
def test_env_flags_and_db_path(monkeypatch): def test_env_flags_and_db_path(monkeypatch):
"""dev → файл дева; test и prod → том /data (общая ветвь is_development).""" """dev → файл дева; prod → том /data."""
from app.core.config import settings from app.core.config import settings
monkeypatch.setattr(settings, "dev_database_url", "sqlite:///dev.db") 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") monkeypatch.setattr(settings, "app_env", "development")
assert settings.is_development and settings.database_url == "sqlite:///dev.db" 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") monkeypatch.setattr(settings, "app_env", "production")
assert settings.is_production and settings.database_url == "sqlite:////data/prod.db" assert settings.is_production and settings.database_url == "sqlite:////data/prod.db"
+93 -1
View File
@@ -1,6 +1,9 @@
"""Fail-fast конфигурации: production не стартует с дефолтными секретами (#59, F4).""" """Fail-fast конфигурации: опубликованное приложение (production и dev на домене) не
стартует с дефолтными секретами (#59, F4, #69)."""
from __future__ import annotations from __future__ import annotations
import logging
import pytest import pytest
from pydantic import ValidationError from pydantic import ValidationError
@@ -61,7 +64,96 @@ def test_production_skips_admin_check_when_bootstrap_disabled():
def test_development_allows_defaults(): def test_development_allows_defaults():
s = config.Settings( s = config.Settings(
app_env="development", app_env="development",
local_public="local",
secret_key=config._DEFAULT_SECRET_KEY, secret_key=config._DEFAULT_SECRET_KEY,
admin_password=config._DEFAULT_ADMIN_PASSWORD, admin_password=config._DEFAULT_ADMIN_PASSWORD,
) )
assert s.is_development assert s.is_development
assert not s.is_published and not s.cookie_secure
# ─── Dev, опубликованный на домен (LOCAL_PUBLIC=vps, #69) ─────────────────────
# Снаружи он так же доступен, как прод: общеизвестный ключ JWT и пароль админа там
# открывают админку и подделку любого токена.
@pytest.mark.parametrize(
"secret_key,admin_password",
[
(config._DEFAULT_SECRET_KEY, _STRONG_ADMIN_PW),
("too-short", _STRONG_ADMIN_PW),
(_STRONG_SECRET, config._DEFAULT_ADMIN_PASSWORD),
],
ids=["default-secret", "short-secret", "default-admin-password"],
)
def test_published_dev_rejects_weak_secrets(secret_key, admin_password):
with pytest.raises(ValidationError, match="LOCAL_PUBLIC=vps"):
config.Settings(
app_env="development",
local_public="vps",
secret_key=secret_key,
admin_password=admin_password,
)
def test_published_dev_accepts_strong_secrets():
s = config.Settings(
app_env="development",
local_public="vps",
secret_key=_STRONG_SECRET,
admin_password=_STRONG_ADMIN_PW,
)
assert s.is_development and s.is_published and s.cookie_secure
def test_published_dev_warns_on_startup(monkeypatch, caplog):
"""Dev-инструменты на опубликованном dev остаются (решение владельца) — но старт
громко перечисляет, что открыто любому посетителю домена."""
from fastapi.testclient import TestClient
from app import main
monkeypatch.setattr(main.settings, "local_public", "vps")
with caplog.at_level(logging.WARNING, logger="fs"), TestClient(main.create_app()):
pass
assert "DEV ОПУБЛИКОВАН НАРУЖУ" in caplog.text
assert "вход по нику без пароля" in caplog.text
def test_local_dev_starts_quietly(caplog):
from fastapi.testclient import TestClient
from app import main
with caplog.at_level(logging.WARNING, logger="fs"), TestClient(main.create_app()):
pass
assert "DEV ОПУБЛИКОВАН НАРУЖУ" not in caplog.text
@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
# ─── Пояс приложения (#68) ────────────────────────────────────────────────────
@pytest.mark.parametrize("offset", [-13, 15])
def test_tz_offset_out_of_range_rejected(offset):
with pytest.raises(ValidationError, match="APP_TZ_OFFSET_HOURS"):
config.Settings(app_env="development", app_tz_offset_hours=offset)
def test_auth_config_exposes_app_tz_offset(client, monkeypatch):
"""Фронт показывает время в поясе приложения — смещение приходит из настроек."""
from app.routers import auth
monkeypatch.setattr(auth.settings, "app_tz_offset_hours", 5)
assert client.get("/api/auth/config").json()["tz_offset_hours"] == 5
+34 -28
View File
@@ -1,4 +1,5 @@
"""Событие партии несёт список участников: по нему клиент решает, чьи витрины протухли.""" """Адресаты событий партии. Рейтинг общий (#80): завершённая партия двигает витрины
всех игроков, поэтому игроки вне группы получают событие ratings (#88)."""
from __future__ import annotations from __future__ import annotations
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -16,20 +17,29 @@ def _capture_events(monkeypatch) -> list[tuple[list[int], dict]]:
return published return published
def _match_events(published: list[tuple[list[int], dict]]) -> list[dict]: def _recipients(published: list[tuple[list[int], dict]], kind: str) -> set[int]:
return [e for _ids, e in published if e.get("type") == "match"] return {uid for ids, e in published if e.get("type") == kind for uid in ids}
def test_match_event_carries_participants(client: TestClient, engine, monkeypatch): def _two_groups(client: TestClient, engine) -> tuple[dict, int, int, int, list[int]]:
"""Хост и Игрок2 в группе партии, Чужой — только в другой группе хоста."""
me = login(client, "Хост") me = login(client, "Хост")
exps = [e["id"] for e in client.get("/api/expansions").json()] exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client) def group(name: str) -> int:
return client.post(
"/api/groups", json={"name": name, "expansion_ids": exps}, headers=csrf_headers(client)
).json()["id"] ).json()["id"]
gid, other_gid = group("Группа"), group("Другая")
p2 = add_group_member(engine, gid, "Игрок2") p2 = add_group_member(engine, gid, "Игрок2")
# Третий в группе, но НЕ в партии: его история от этой партии не меняется. outsider = add_group_member(engine, other_gid, "Чужой")
p3 = add_group_member(engine, gid, "Зритель")
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
return me, gid, p2, outsider, fids
def test_finished_match_reaches_players_outside_group(client: TestClient, engine, monkeypatch):
me, gid, p2, outsider, fids = _two_groups(client, engine)
published = _capture_events(monkeypatch) published = _capture_events(monkeypatch)
started = start_match( started = start_match(
@@ -37,36 +47,31 @@ def test_match_event_carries_participants(client: TestClient, engine, monkeypatc
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}], [{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
) )
assert started.status_code == 200, started.text assert started.status_code == 200, started.text
mid = started.json()["id"] # Незавершённая партия рейтинг не двигает — знать о ней нужно только группе.
assert _recipients(published, "match") == {me["id"], p2}
ev = _match_events(published)[-1] assert _recipients(published, "ratings") == set()
assert sorted(ev["participant_ids"]) == sorted([me["id"], p2])
assert p3 not in ev["participant_ids"]
published.clear() published.clear()
fin = finish_match( fin = finish_match(
client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}] client, started.json()["id"], [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}]
) )
assert fin.status_code == 200, fin.text assert fin.status_code == 200, fin.text
assert sorted(_match_events(published)[-1]["participant_ids"]) == sorted([me["id"], p2]) assert _recipients(published, "match") == {me["id"], p2}
ratings = _recipients(published, "ratings")
assert outsider in ratings
assert not ratings & {me["id"], p2} # группа уже получила подробное событие
def test_delete_event_carries_participants(client: TestClient, engine, monkeypatch): def test_deleting_finished_match_reaches_players_outside_group(
"""Удаление — главный случай: строки участников уже уничтожены каскадом. client: TestClient, engine, monkeypatch
):
Если собирать их после удаления, список всегда окажется пустым, и клиент не """Удаление завершённой партии пересчитывает рейтинг всех, кто играл после неё."""
обновит историю тем, кто в этой партии играл.""" me, gid, p2, outsider, fids = _two_groups(client, engine)
me = login(client, "Хост")
exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
).json()["id"]
p2 = add_group_member(engine, gid, "Игрок2")
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
mid = start_match( mid = start_match(
client, gid, client, gid,
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}], [{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
).json()["id"] ).json()["id"]
finish_match(client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}])
published = _capture_events(monkeypatch) published = _capture_events(monkeypatch)
version = client.get(f"/api/matches/{mid}").json()["version"] version = client.get(f"/api/matches/{mid}").json()["version"]
@@ -74,4 +79,5 @@ def test_delete_event_carries_participants(client: TestClient, engine, monkeypat
f"/api/matches/{mid}", params={"expected_version": version}, headers=csrf_headers(client) f"/api/matches/{mid}", params={"expected_version": version}, headers=csrf_headers(client)
) )
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
assert sorted(_match_events(published)[-1]["participant_ids"]) == sorted([me["id"], p2]) assert _recipients(published, "match") == {me["id"], p2}
assert outsider in _recipients(published, "ratings")
@@ -0,0 +1,85 @@
"""Список игроков группы — только текущий состав (#76).
Удалённый из группы игрок пропадает из рейтинга группы, но его партии остаются в
истории: они уже повлияли на (общий) рейтинг оставшихся."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
def _ids(stats: dict) -> dict[str, set[int]]:
return {
block: {e["user_id"] for e in stats[block]}
for block in ("leaderboard", "provisional", "inactive")
}
def _group(client: TestClient) -> tuple[int, list[int]]:
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
return gid, fids
def _remove(client: TestClient, gid: int, uid: int) -> None:
r = client.delete(f"/api/groups/{gid}/members/{uid}", headers=csrf_headers(client))
assert r.status_code == 200, r.text
def test_removed_member_leaves_group_rating_but_not_overall(client: TestClient, engine):
me = login(client, "Хозяин")
gid, fids = _group(client)
b = add_group_member(engine, gid, "Ушедший")
create_finished_match(
client, gid,
[
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
{"user_id": b, "faction_id": fids[1], "place": 2},
],
)
before = client.get(f"/api/groups/{gid}/stats").json()
my_score = next(e["score"] for e in before["provisional"] if e["user_id"] == me["id"])
_remove(client, gid, b)
stats = client.get(f"/api/groups/{gid}/stats").json()
ids = _ids(stats)
assert all(b not in block for block in ids.values())
assert me["id"] in ids["provisional"]
# Партия с ушедшим учтена: рейтинг оставшегося не изменился, счётчик партий тоже.
assert next(e["score"] for e in stats["provisional"] if e["user_id"] == me["id"]) == my_score
assert stats["total_matches"] == 1
board = client.get("/api/stats/leaderboard").json()
assert b in {e["user_id"] for e in board["entries"] + board["provisional"]}
# Вернули в группу — снова в списке со своей историей.
add_group_member(engine, gid, "Ушедший")
back = client.get(f"/api/groups/{gid}/stats").json()
entry = next(e for e in back["provisional"] if e["user_id"] == b)
assert (entry["games"], entry["score"]) == (1, 1468)
def test_group_ranks_renumbered_after_removal(client: TestClient, engine):
me = login(client, "Первый")
gid, fids = _group(client)
b = add_group_member(engine, gid, "Второй")
c = add_group_member(engine, gid, "Третий")
for _ in range(10):
create_finished_match(
client, gid,
[
{"user_id": me["id"], "faction_id": fids[0], "place": 1},
{"user_id": b, "faction_id": fids[1], "place": 2},
{"user_id": c, "faction_id": fids[2], "place": 3},
],
)
assert [e["rank"] for e in client.get(f"/api/groups/{gid}/stats").json()["leaderboard"]] == [1, 2, 3]
_remove(client, gid, b)
board = client.get(f"/api/groups/{gid}/stats").json()["leaderboard"]
assert [(e["user_id"], e["rank"]) for e in board] == [(me["id"], 1), (c, 2)]
+3 -2
View File
@@ -322,9 +322,10 @@ def test_history_excludes_matches_without_the_player(client: TestClient, engine)
def test_history_best_mode_picks_highest_points(client: TestClient, engine): def test_history_best_mode_picks_highest_points(client: TestClient, engine):
"""Режим best берёт партию с максимальными League Points, а не самую свежую. """Режим best берёт партию с наибольшим приростом рейтинга, а не самую свежую.
Второе место из четырёх даёт (4-2)/3 ≈ 0.67, второе из двух — (2-2)/1 = 0.""" Второе место из четырёх равных приносит рейтинг (обыграны двое), второе место
в дуэли — отнимает."""
me = login(client, "Лучший") me = login(client, "Лучший")
gid, (a, b, c), fids = _group_with(client, engine, "А", "Б", "В") gid, (a, b, c), fids = _group_with(client, engine, "А", "Б", "В")
+217
View File
@@ -0,0 +1,217 @@
"""Движок рейтинга: примеры docs/rating/rating-system.md и сверка с эталоном simulate.py.
Числа примеров — те же, что в документе (раздел 6) и в EXPECTED эталона: разъехаться
документ, эталон и приложение не должны. Сверка с simulate.py дополнительно гоняет
синтетический сезон и требует совпадения каждого изменения рейтинга."""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
import pytest
from app.services import scoring
from app.services.scoring import RatedMatch, RatedSeat, rate_match, replay
VETERAN = 40 # партий у «опытного» игрока: K = K_MIN
A, B, C, D, E, F = 1, 2, 3, 4, 5, 6
def _vets(*ids: int) -> dict[int, int]:
return dict.fromkeys(ids, VETERAN)
def _duel(first: int, second: int, **kw) -> RatedMatch:
return RatedMatch((RatedSeat(first, 1), RatedSeat(second, 2)), **kw)
def _seat(uid: int, place: int, objectives=None, worlds=None, eliminated=False) -> RatedSeat:
return RatedSeat(uid, place, objectives=objectives, worlds=worlds, eliminated=eliminated)
FIVE = tuple(RatedSeat(uid, i + 1) for i, uid in enumerate((A, B, C, D, E)))
SIX = tuple(RatedSeat(uid, i + 1) for i, uid in enumerate((A, B, C, D, E, F)))
# (ключ, рейтинги, сыграно партий, партия, ожидаемые ΔR с точностью до 0.01)
EXAMPLES = [
("1a", {A: 1600, B: 1400}, _vets(A, B), _duel(A, B, win_reason="objectives"),
{A: 3.84, B: -3.84}),
("1b", {A: 1600, B: 1400}, _vets(A, B), _duel(B, A, win_reason="objectives"),
{B: 12.16, A: -12.16}),
("2a", {A: 1500, B: 1500}, _vets(A, B),
RatedMatch((_seat(A, 1, 2, 5), _seat(B, 2, 1, 4)), "objectives", end_round=3),
{A: 11.18, B: -11.18}),
("2b", {A: 1500, B: 1500}, _vets(A, B),
RatedMatch((_seat(A, 1, 2, 5), _seat(B, 2, 1, 4)), "objectives", end_round=8),
{A: 5.46, B: -5.46}),
("3a", {A: 1500, B: 1500}, _vets(A, B), _duel(A, B, win_reason="objectives"),
{A: 8.0, B: -8.0}),
("3b", dict.fromkeys((A, B, C, D, E), 1500), _vets(A, B, C, D, E),
RatedMatch(FIVE, "objectives"),
{A: 11.0, B: 5.5, C: 0.0, D: -5.5, E: -11.0}),
("4a", {A: 1500, B: 1500}, _vets(A, B), _duel(A, B, win_reason="worlds"),
{A: 6.8, B: -6.8}),
("4b", {A: 1500, B: 1500}, _vets(A, B), _duel(A, B, win_reason="plastic"),
{A: 5.6, B: -5.6}),
("4c", {A: 1500, B: 1500}, _vets(A, B), _duel(A, B, win_reason="resources"),
{A: 4.8, B: -4.8}),
("4d", {A: 1500, B: 1500}, _vets(A, B),
RatedMatch((_seat(A, 1, 2, 6), _seat(B, 2, 2, 5)), "worlds", end_round=8),
{A: 3.4, B: -3.4}),
("4e", {A: 1500, B: 1500}, _vets(A, B),
RatedMatch((_seat(A, 1, 2, 8), _seat(B, 2, 0, 2)), "objectives", end_round=3),
{A: 16.0, B: -16.0}),
("5", {A: 1550, B: 1500, C: 1480, D: 1450}, _vets(A, B, C, D),
RatedMatch(
(_seat(A, 1, 4, 8), _seat(B, 2, 3, 7),
_seat(C, 3, 1, 0, eliminated=True), _seat(D, 3, 0, 0, eliminated=True)),
"objectives", end_round=7,
),
{A: 9.27, B: 5.85, C: -7.6, D: -7.53}),
("6a", dict.fromkeys(range(A, F + 1), 1500), _vets(*range(A, F + 1)),
RatedMatch(SIX, "objectives", end_round=8, nine_rounds_rule=True),
{A: 12.0, B: 7.2, C: 2.4, D: -2.4, E: -7.2, F: -12.0}),
("6b", dict.fromkeys(range(A, F + 1), 1500), _vets(*range(A, F + 1)),
RatedMatch(SIX, "objectives", end_round=8, nine_rounds_rule=False),
{A: 10.29, B: 7.54, C: 2.74, D: -2.06, E: -6.86, F: -11.66}),
("7", {A: 1500, B: 1500}, {A: 0, B: VETERAN}, _duel(A, B, win_reason="objectives"),
{A: 32.0, B: -8.0}),
]
@pytest.mark.parametrize(
"ratings,games,match,want", [e[1:] for e in EXAMPLES], ids=[e[0] for e in EXAMPLES]
)
def test_document_examples(ratings, games, match, want):
delta, _perf = rate_match(ratings, games, match)
assert {uid: round(v, 2) for uid, v in delta.items()} == want
def test_examples_cover_whole_section():
assert len(EXAMPLES) == 15
def test_last_standing_counts_full_objective_gap():
"""Победа last_standing: отрыв победителя по целям = 1, сколько бы маркеров ни было."""
seats = (_seat(A, 1, 1, 6), _seat(B, 2, 1, 0, eliminated=True))
ordinary = rate_match({}, _vets(A, B), RatedMatch(seats, "objectives"))[0][A]
standing = rate_match({}, _vets(A, B), RatedMatch(seats, "last_standing"))[0][A]
# Отрыв по целям 1 вместо 0 → множитель больше на W_OBJ·1 = 0.5; ΔR = K·ΔM·(S − E).
assert standing - ordinary == pytest.approx(16 * 0.5 * 0.5)
def test_eliminated_are_not_compared_with_each_other():
"""Выбывшие между собой не сравниваются (#91): слабый выбывший среди сильных не
получает рейтинг, а рейтинги прочих выбывших на его изменение не влияют."""
six = range(A, F + 1)
seats = (_seat(A, 1),) + tuple(_seat(u, 2, eliminated=True) for u in six if u != A)
match = RatedMatch(seats, "last_standing")
strong = {**dict.fromkeys(six, 1800), F: 1200}
delta = rate_match(strong, _vets(*six), match)[0]
assert delta[F] < 0
# Сильные выбывшие → слабые: у F и у победителя ничего не меняется от этого.
weak = {**dict.fromkeys(six, 1200), A: 1800}
again = rate_match(weak, _vets(*six), match)[0]
assert again[F] == pytest.approx(delta[F])
# Победителю сила соперников по-прежнему важна: против слабых он получает меньше.
assert again[A] < delta[A]
# ─── Сверка с эталоном ───────────────────────────────────────────────────────
SIMULATE = Path(__file__).resolve().parents[2] / "docs" / "rating" / "simulate.py"
@pytest.fixture(scope="module")
def sim():
if not SIMULATE.exists():
pytest.skip("docs/rating/simulate.py недоступен")
spec = importlib.util.spec_from_file_location("rating_simulate", SIMULATE)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module # dataclasses ищут модуль по имени
spec.loader.exec_module(module) # type: ignore[union-attr]
return module
def _convert(m, ids: dict[str, int], match_id: int) -> RatedMatch:
return RatedMatch(
tuple(
RatedSeat(
ids[s.player], s.place, eliminated=s.eliminated,
objectives=s.objectives, worlds=s.worlds,
)
for s in m.seats
),
m.win_reason,
end_round=m.round,
nine_rounds_rule=m.nine_rounds,
id=match_id,
)
def test_constants_match_reference(sim):
p = sim.PROPOSED
assert (p.r0, p.d, p.k_max, p.k_min, p.k_games) == (
scoring.R0, scoring.D, scoring.K_MAX, scoring.K_MIN, scoring.K_GAMES
)
assert (p.w_table, p.w_tempo, p.w_obj, p.w_worlds) == (
scoring.W_TABLE, scoring.W_TEMPO, scoring.W_OBJ, scoring.W_WORLDS
)
assert (p.mu_obj, p.mu_worlds, p.m_min, p.m_max) == (
scoring.MU_OBJ, scoring.MU_WORLDS, scoring.M_MIN, scoring.M_MAX
)
assert dict(p.closeness) == scoring.CLOSENESS
assert sim.BOARD_TILES == scoring.BOARD_TILES
assert not p.autocorr
assert p.skip_eliminated_pairs # выбывшие между собой не сравниваются (#91)
@pytest.mark.parametrize("scenario", ["сигнал", "клубы", "рост"])
@pytest.mark.parametrize("stripped", [False, True], ids=["full", "history"])
def test_replay_matches_reference_season(sim, scenario, stripped):
"""Весь сезон: каждое изменение рейтинга совпадает с эталоном до 1e-9."""
cfg = sim.SCENARIOS[scenario]
_skill, matches = sim.generate_season(
cfg["seed"], sim.SEASON_MATCHES, cfg["informative"], cfg["clubs"], cfg["learning"]
)
if stripped:
matches = [sim.strip_details(m) for m in matches]
ids: dict[str, int] = {}
for m in matches:
for s in m.seats:
ids.setdefault(s.player, len(ids) + 1)
reference = sim.Elo(sim.PROPOSED)
ours = replay(_convert(m, ids, i) for i, m in enumerate(matches))
for i, m in enumerate(matches):
for player, dv in reference.update(m).items():
assert ours.delta[(i, ids[player])] == pytest.approx(dv, abs=1e-9)
for player, uid in ids.items():
assert ours.ratings[uid] == pytest.approx(reference.rating(player), abs=1e-6)
def test_monotone_and_zero_sum(sim):
"""Победитель без ничьей не теряет, последний без ничьей и любой выбывший не получают;
при равных K сумма изменений за партию — ноль."""
cfg = sim.SCENARIOS["сигнал"]
_skill, matches = sim.generate_season(cfg["seed"] + 7, 200, True)
ids: dict[str, int] = {}
for m in matches:
for s in m.seats:
ids.setdefault(s.player, len(ids) + 1)
veterans = dict.fromkeys(ids.values(), VETERAN)
for i, m in enumerate(matches):
rm = _convert(m, ids, i)
delta, _ = rate_match({}, veterans, rm)
places = [s.place for s in rm.seats]
for s in rm.seats:
if s.eliminated:
assert delta[s.user_id] < 0
if places.count(s.place) > 1:
continue
if s.place == 1:
assert delta[s.user_id] > 0
if s.place == max(places):
assert delta[s.user_id] < 0
assert sum(delta.values()) == pytest.approx(0.0, abs=1e-9)
+226
View File
@@ -0,0 +1,226 @@
"""Итоги партии для рейтинга (#23): раунд окончания, цели и миры, правило 9 раундов,
причина «последний выживший». Сервер проверяет диапазоны и явные противоречия."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match
def _group(client: TestClient, engine, players: int) -> tuple[int, list[int], list[int]]:
"""Группа со всеми дополнениями и players участниками (первый — вошедший)."""
me = login(client, "Хозяин")
exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
).json()["id"]
uids = [me["id"]] + [add_group_member(engine, gid, f"Игрок{i}") for i in range(2, players + 1)]
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
return gid, uids, fids
def _start(client: TestClient, gid: int, uids: list[int], fids: list[int]) -> dict:
r = start_match(
client, gid, [{"user_id": u, "faction_id": fids[i]} for i, u in enumerate(uids)]
)
assert r.status_code == 200, r.text
return r.json()
def _finish(client: TestClient, mid: int, participants: list[dict], win_reason: str, **extra):
body = {"participants": participants, "win_reason": win_reason, **extra}
return client.post(f"/api/matches/{mid}/finish", json=body, headers=csrf_headers(client))
def _set_rule(client: TestClient, gid: int, enabled: bool) -> dict:
r = client.patch(
f"/api/groups/{gid}", json={"nine_rounds_rule": enabled}, headers=csrf_headers(client)
)
assert r.status_code == 200, r.text
return r.json()
def test_finish_saves_round_objectives_and_worlds(client: TestClient, engine):
gid, (a, b), fids = _group(client, engine, 2)
match = _start(client, gid, [a, b], fids)
assert match["max_rounds"] == 8 and match["end_round"] is None
r = _finish(
client, match["id"],
[
{"user_id": a, "place": 1, "objectives": 2, "worlds": 5},
{"user_id": b, "place": 2, "objectives": 1}, # миры не указаны — так и остаётся
],
"objectives", end_round=6,
)
assert r.status_code == 200, r.text
data = r.json()
assert data["end_round"] == 6
parts = {p["user_id"]: p for p in data["participants"]}
assert (parts[a]["objectives"], parts[a]["worlds"]) == (2, 5)
assert (parts[b]["objectives"], parts[b]["worlds"]) == (1, None)
listed = client.get(f"/api/groups/{gid}/matches").json()["items"][0]["participants"]
assert {p["user_id"]: p["objectives"] for p in listed} == {a: 2, b: 1}
def test_end_round_limited_by_max_rounds(client: TestClient, engine):
gid, (a, b), fids = _group(client, engine, 2)
match = _start(client, gid, [a, b], fids)
rows = [{"user_id": a, "place": 1}, {"user_id": b, "place": 2}]
assert _finish(client, match["id"], rows, "objectives", end_round=9).status_code == 422
assert _finish(client, match["id"], rows, "objectives", end_round=0).status_code == 422
assert _finish(client, match["id"], rows, "objectives", end_round=8).status_code == 200
def test_nine_rounds_rule_is_snapshotted_for_five_players(client: TestClient, engine):
gid, uids, fids = _group(client, engine, 5)
assert _set_rule(client, gid, True)["nine_rounds_rule"] is True
assert client.get(f"/api/groups/{gid}").json()["name"] == "Группа" # имя не тронуто
five = _start(client, gid, uids, fids)
four = _start(client, gid, uids[:4], fids)
assert (five["nine_rounds_rule"], five["max_rounds"]) == (True, 9)
assert (four["nine_rounds_rule"], four["max_rounds"]) == (True, 8) # правило — только с 5
# Смена настройки группы не переписывает уже начатую партию.
_set_rule(client, gid, False)
assert client.get(f"/api/matches/{five['id']}").json()["max_rounds"] == 9
rows = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids)]
assert _finish(client, five["id"], rows, "objectives", end_round=9).status_code == 200
rows4 = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids[:4])]
assert _finish(client, four["id"], rows4, "objectives", end_round=9).status_code == 422
def test_last_standing_required_exactly_when_one_survivor(client: TestClient, engine):
gid, (a, b, c), fids = _group(client, engine, 3)
match = _start(client, gid, [a, b, c], fids)
alone = [
{"user_id": a, "place": 1},
{"user_id": b, "eliminated": True},
{"user_id": c, "eliminated": True},
]
r = _finish(client, match["id"], alone, "objectives")
assert r.status_code == 422 and "последний выживший" in r.json()["error"]["message"]
two = [
{"user_id": a, "place": 1},
{"user_id": b, "place": 2},
{"user_id": c, "eliminated": True},
]
assert _finish(client, match["id"], two, "last_standing").status_code == 422
ok = _finish(client, match["id"], alone, "last_standing")
assert ok.status_code == 200, ok.text
assert ok.json()["win_reason"] == "last_standing"
def test_eliminated_player_has_no_worlds(client: TestClient, engine):
gid, (a, b, c), fids = _group(client, engine, 3)
match = _start(client, gid, [a, b, c], fids)
rows = [
{"user_id": a, "place": 1, "worlds": 7},
{"user_id": b, "place": 2, "worlds": 4},
{"user_id": c, "eliminated": True, "worlds": 2},
]
assert _finish(client, match["id"], rows, "objectives").status_code == 422
rows[2] = {"user_id": c, "eliminated": True, "objectives": 1} # миры не указаны
r = _finish(client, match["id"], rows, "objectives")
assert r.status_code == 200, r.text
parts = {p["user_id"]: p for p in r.json()["participants"]}
assert (parts[c]["worlds"], parts[c]["objectives"]) == (0, 1)
def test_negative_counts_rejected(client: TestClient, engine):
gid, (a, b), fids = _group(client, engine, 2)
match = _start(client, gid, [a, b], fids)
rows = [{"user_id": a, "place": 1, "objectives": -1}, {"user_id": b, "place": 2}]
assert _finish(client, match["id"], rows, "objectives").status_code == 422
def _finished(client: TestClient, gid: int, uids: list[int], fids: list[int]) -> dict:
match = _start(client, gid, uids, fids)
rows = [{"user_id": u, "place": i + 1} for i, u in enumerate(uids)]
r = _finish(client, match["id"], rows, "objectives")
assert r.status_code == 200, r.text
return r.json()
def _edit_rows(match: dict, **by_user) -> list[dict]:
rows = []
for p in match["participants"]:
row = {"user_id": p["user_id"], "faction_id": p["faction_id"], "place": p["place"]}
row.update(by_user.get(str(p["user_id"]), {}))
rows.append(row)
return rows
def test_edit_checks_last_standing_and_round(client: TestClient, engine):
gid, (a, b, c), fids = _group(client, engine, 3)
match = _finished(client, gid, [a, b, c], fids)
mid = match["id"]
def patch(body: dict):
return client.patch(f"/api/matches/{mid}", json=body, headers=csrf_headers(client))
elim = {"place": None, "eliminated": True}
rows = _edit_rows(match, **{str(b): elim, str(c): elim})
assert patch({"participants": rows, "win_reason": "worlds"}).status_code == 422
# Причина не передана — сверяется с записанной («по целям»): тоже противоречие.
assert patch({"participants": rows}).status_code == 422
r = patch({"participants": rows, "win_reason": "last_standing", "end_round": 5})
assert r.status_code == 200, r.text
assert (r.json()["win_reason"], r.json()["end_round"]) == ("last_standing", 5)
# Одна только причина: при одном выжившем вернуть «по целям» нельзя.
assert patch({"win_reason": "objectives"}).status_code == 422
# Один только раунд: в пределах лимита — можно, за лимитом — нет.
assert patch({"end_round": 9}).status_code == 422
assert patch({"end_round": None}).json()["end_round"] is None
def test_admin_edit_saves_counts(client: TestClient, engine, make_admin):
gid, (a, b), fids = _group(client, engine, 2)
match = _finished(client, gid, [a, b], fids)
make_admin("admin", "secret123")
assert client.post(
"/api/admin/auth/login",
json={"username": "admin", "password": "secret123"},
headers=csrf_headers(client),
).status_code == 200
rows = _edit_rows(match, **{str(a): {"objectives": 2, "worlds": 6}})
r = client.patch(
f"/api/admin/matches/{match['id']}",
json={"participants": rows, "win_reason": "objectives", "end_round": 7},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
parts = {p["user_id"]: p for p in r.json()["participants"]}
assert (parts[a]["objectives"], parts[a]["worlds"], r.json()["end_round"]) == (2, 6, 7)
def test_draft_keeps_round_and_counts(client: TestClient, engine):
gid, (a, b), fids = _group(client, engine, 2)
match = _start(client, gid, [a, b], fids)
body = {
"blocks": [[a], [b]],
"win_reason": "last_standing", # черновик — незаконченный ввод, правило не проверяется
"end_round": 4,
"objectives": {str(a): 2},
"worlds": {str(a): 5, str(b): 3},
}
r = client.put(f"/api/matches/{match['id']}/finish-draft", json=body, headers=csrf_headers(client))
assert r.status_code == 200, r.text
data = client.get(f"/api/matches/{match['id']}").json()["finish_draft"]["data"]
assert data["end_round"] == 4
assert data["objectives"] == {str(a): 2}
assert data["worlds"] == {str(a): 5, str(b): 3}
bad = client.put(
f"/api/matches/{match['id']}/finish-draft",
json={"worlds": {str(a): -3}},
headers=csrf_headers(client),
)
assert bad.status_code == 422
+240
View File
@@ -0,0 +1,240 @@
"""Рейтинг в витринах (#23, #80): Elo по упорядоченной истории, один рейтинг на игрока."""
from __future__ import annotations
from fastapi.testclient import TestClient
from app.services.scoring import RatedMatch, RatedSeat, replay
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
def _group(client: TestClient, name: str = "Группа") -> tuple[int, list[int]]:
gid = client.post(
"/api/groups", json={"name": name, "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
return gid, fids
def _duel(client: TestClient, gid: int, fids: list[int], winner: int, loser: int) -> dict:
return create_finished_match(
client, gid,
[
{"user_id": winner, "faction_id": fids[0], "place": 1},
{"user_id": loser, "faction_id": fids[1], "place": 2},
],
)
def _board(client: TestClient, path: str = "/api/stats/leaderboard") -> dict[int, dict]:
data = client.get(path).json()
rows = data["entries"] + data["provisional"] if "entries" in data else (
data["leaderboard"] + data["provisional"]
)
return {e["user_id"]: e for e in rows}
def test_newcomer_duel_moves_rating_by_32(client: TestClient, engine):
"""Первая дуэль новичков на 1500: K = 64, ожидание 0.5, деталей нет → ±32."""
me = login(client, "Хозяин")
gid, fids = _group(client)
b = add_group_member(engine, gid, "Гость")
_duel(client, gid, fids, me["id"], b)
board = client.get("/api/stats/leaderboard").json()
by_id = {e["user_id"]: e for e in board["entries"] + board["provisional"]}
assert by_id[me["id"]]["score"] == 1532
assert by_id[b]["score"] == 1468
# 1 игра < MIN_GAMES=10 → оба пока «Новички», ранжированный топ пуст.
assert board["entries"] == []
assert board["min_games"] == 10
prof = client.get("/api/users/me/stats").json()
assert prof["overall"]["score"] == 1532
# Фракционная метрика — S − E: победа при шансах 0.5 даёт +0.5 → 50.0.
assert prof["factions"][0]["score"] == 50.0
def test_ranked_after_min_games(client: TestClient, engine):
me = login(client, "Чемпион")
gid, fids = _group(client)
b = add_group_member(engine, gid, "Спарринг")
for _ in range(10):
_duel(client, gid, fids, me["id"], b)
board = client.get("/api/stats/leaderboard").json()
ranks = {e["user_id"]: (e["rank"], e["score"]) for e in board["entries"]}
assert ranks[me["id"]][0] == 1 and ranks[b][0] == 2
assert ranks[me["id"]][1] > 1500 > ranks[b][1]
assert isinstance(ranks[me["id"]][1], int)
def test_editing_past_match_recalculates_later_ones(client: TestClient, engine):
"""Рейтинг — функция истории: правка первой партии меняет итог после второй."""
me = login(client, "А")
gid, fids = _group(client)
b = add_group_member(engine, gid, "Б")
first = _duel(client, gid, fids, me["id"], b)
_duel(client, gid, fids, me["id"], b)
def expected(first_winner: int, first_loser: int) -> dict[int, int]:
rep = replay([
RatedMatch((RatedSeat(first_winner, 1), RatedSeat(first_loser, 2)), "objectives", id=1),
RatedMatch((RatedSeat(me["id"], 1), RatedSeat(b, 2)), "objectives", id=2),
])
return {uid: round(r) for uid, r in rep.ratings.items()}
before = expected(me["id"], b)
assert {uid: e["score"] for uid, e in _board(client).items()} == before
rows = [
{"user_id": b, "faction_id": fids[1], "place": 1},
{"user_id": me["id"], "faction_id": fids[0], "place": 2},
]
r = client.patch(
f"/api/matches/{first['id']}",
json={"participants": rows, "win_reason": "objectives"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
after = expected(b, me["id"])
assert after != before
assert {uid: e["score"] for uid, e in _board(client).items()} == after
def test_group_page_shows_overall_rating_with_group_stats(client: TestClient, engine):
"""Рейтинг один на всё приложение (#80): в группе он тот же, что в общем топе,
а игры и победы — только по партиям группы."""
me = login(client, "Путешественник")
g1, f1 = _group(client, "Первая")
g2, f2 = _group(client, "Вторая")
b = add_group_member(engine, g1, "Сосед")
c = add_group_member(engine, g2, "Соседка")
_duel(client, g1, f1, me["id"], b) # в первой группе — победа
_duel(client, g2, f2, c, me["id"]) # во второй — поражение
overall = _board(client)[me["id"]]
assert overall["games"] == 2
assert overall["score"] == round(
replay([
RatedMatch((RatedSeat(me["id"], 1), RatedSeat(b, 2)), "objectives", id=1),
RatedMatch((RatedSeat(c, 1), RatedSeat(me["id"], 2)), "objectives", id=2),
]).ratings[me["id"]]
)
first = _board(client, f"/api/groups/{g1}/stats")[me["id"]]
second = _board(client, f"/api/groups/{g2}/stats")[me["id"]]
assert first["score"] == second["score"] == overall["score"]
assert (first["games"], first["wins"]) == (1, 1)
assert (second["games"], second["wins"]) == (1, 0)
# Главная и профиль — общие показатели; блок активной группы — игры в группе.
client.put("/api/users/me/active-group", json={"group_id": g2}, headers=csrf_headers(client))
home = client.get("/api/home").json()
assert (home["profile"]["overall"]["games"], home["profile"]["overall"]["score"]) == (
2, overall["score"]
)
assert (home["active_group"]["games"], home["active_group"]["score"]) == (1, overall["score"])
def test_veteran_is_not_a_newcomer_in_new_group(client: TestClient, engine):
"""Статус «Новичок» — про надёжность рейтинга, а он общий: 10 партий где угодно
делают игрока ранжированным и в группе, где он сыграл одну."""
me = login(client, "Ветеран")
g1, f1 = _group(client, "Старая")
b = add_group_member(engine, g1, "Спарринг")
for _ in range(10):
_duel(client, g1, f1, me["id"], b)
g2, f2 = _group(client, "Новая")
c = add_group_member(engine, g2, "Новенький")
add_group_member(engine, g2, "Спарринг") # опытный, но в новой группе не играл
d = add_group_member(engine, g2, "Зритель") # не играл нигде
_duel(client, g2, f2, me["id"], c)
stats = client.get(f"/api/groups/{g2}/stats").json()
ranked = {e["user_id"]: e for e in stats["leaderboard"]}
assert ranked[me["id"]]["rank"] == 1
assert (ranked[me["id"]]["games"], ranked[me["id"]]["rating_confirmed"]) == (1, True)
assert ranked[me["id"]]["score"] == _board(client)[me["id"]]["score"]
assert [e["user_id"] for e in stats["provisional"]] == [c]
inactive = {e["user_id"]: e for e in stats["inactive"]}
assert inactive[b]["score"] == _board(client)[b]["score"]
assert (inactive[b]["games"], inactive[b]["rating_confirmed"]) == (0, True)
assert (inactive[d]["score"], inactive[d]["rating_confirmed"]) == (None, False)
def test_best_match_is_biggest_rating_gain(client: TestClient, engine):
"""Лучшая партия — наибольший прирост рейтинга, а не свежая из равных побед.
Обе партии — победы в дуэли. Первая — новичком над равным (+32), вторая — уже
с рейтингом 1532 и меньшим K над новичком (≈ +28): лучше первая."""
me = login(client, "Лучший")
gid, fids = _group(client)
x = add_group_member(engine, gid, "Икс")
y = add_group_member(engine, gid, "Игрек")
first = _duel(client, gid, fids, me["id"], x)
_duel(client, gid, fids, me["id"], y)
client.patch("/api/users/me/profile", json={"history_mode": "best"}, headers=csrf_headers(client))
data = client.get(f"/api/users/{me['id']}/matches").json()
assert data["total"] == 1
assert data["items"][0]["id"] == first["id"]
def test_match_left_with_one_participant_is_not_a_game(client: TestClient, engine, make_admin):
"""Dev-удаление аккаунта вычёркивает игрока из партий, не трогая сами партии. Партия,
где остался один участник, — не игра: её нет в рейтинге, историях и списках группы
(карточка «с одним игроком» раньше висела в профиле)."""
me = login(client, "Выживший")
gid, fids = _group(client)
b = add_group_member(engine, gid, "Удалённый")
c = add_group_member(engine, gid, "Соперник")
orphan = _duel(client, gid, fids, me["id"], b)
kept = _duel(client, gid, fids, c, me["id"])
make_admin("admin", "secret123")
assert client.post(
"/api/admin/auth/login",
json={"username": "admin", "password": "secret123"},
headers=csrf_headers(client),
).status_code == 200
assert client.delete(f"/api/admin/dev/users/{b}", headers=csrf_headers(client)).status_code == 200
# Админка партию по-прежнему видит — удалить её можно оттуда.
assert client.get(f"/api/admin/matches/{orphan['id']}").status_code == 200
history = client.get(f"/api/users/{me['id']}/matches").json()
assert (history["total"], [i["id"] for i in history["items"]]) == (1, [kept["id"]])
group_list = client.get(f"/api/groups/{gid}/matches").json()
assert (group_list["total"], [i["id"] for i in group_list["items"]]) == (1, [kept["id"]])
assert client.get(f"/api/groups/{gid}/stats").json()["total_matches"] == 1
# В рейтинге — только настоящая партия: дуэль новичков, проигрыш −32.
board = _board(client)
assert (board[me["id"]]["games"], board[me["id"]]["score"]) == (1, 1468)
assert board[c]["score"] == 1532
def test_history_shows_rating_delta_of_its_owner(client: TestClient, engine):
"""История игрока несёт изменение его общего рейтинга за каждую партию (#77)."""
me = login(client, "Историк")
gid, fids = _group(client)
b = add_group_member(engine, gid, "Оппонент")
_duel(client, gid, fids, me["id"], b) # новички на 1500: ±32
_duel(client, gid, fids, b, me["id"]) # реванш: 1468 обыгрывает 1532
mine = client.get(f"/api/users/{me['id']}/matches").json()["items"]
theirs = client.get(f"/api/users/{b}/matches").json()["items"]
# Свежие сверху: реванш первым.
assert (mine[1]["rating_delta"], theirs[1]["rating_delta"]) == (32.0, -32.0)
assert mine[0]["rating_delta"] == -theirs[0]["rating_delta"] < 0
# Изменения складываются в рейтинг (с точностью округления до десятых).
score = _board(client)[me["id"]]["score"]
assert abs(1500 + sum(i["rating_delta"] for i in mine) - score) <= 0.6
# В списке партий группы дельты нет — непонятно, чья она была бы.
group_items = client.get(f"/api/groups/{gid}/matches").json()["items"]
assert [i["rating_delta"] for i in group_items] == [None, None]
# Режим «лучшая партия» тоже её отдаёт.
client.patch("/api/users/me/profile", json={"history_mode": "best"}, headers=csrf_headers(client))
best = client.get(f"/api/users/{me['id']}/matches").json()["items"]
assert best[0]["rating_delta"] == 32.0
-39
View File
@@ -1,39 +0,0 @@
"""Сглаживание рейтинга: score = (C·m + сумма очков) / (C + игр) × 100, C=10, m=0.5."""
from __future__ import annotations
from fastapi.testclient import TestClient
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
def test_leaderboard_and_profile_score_are_smoothed(client: TestClient, engine):
me = login(client, "Хозяин")
gid = client.post(
"/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client)
).json()["id"]
b = add_group_member(engine, gid, "Гость")
factions = client.get(f"/api/groups/{gid}/factions").json()
f1, f2 = factions[0]["id"], factions[1]["id"]
create_finished_match(
client,
gid,
[
{"user_id": me["id"], "faction_id": f1, "place": 1},
{"user_id": b, "faction_id": f2, "place": 2},
],
)
board = client.get("/api/stats/leaderboard").json()
by_id = {e["user_id"]: e for e in board["entries"] + board["provisional"]}
# Победитель: 1 очко за партию → (10·0.5 + 1) / (10 + 1) × 100 = 54.5.
# Проигравший: 0 очков → (10·0.5 + 0) / 11 × 100 = 45.5.
assert by_id[me["id"]]["score"] == 54.5
assert by_id[b]["score"] == 45.5
# 1 игра < MIN_GAMES=10 → оба пока «Новички», ранжированный топ пуст.
assert board["entries"] == []
assert board["min_games"] == 10
# Профиль показывает тот же сглаженный рейтинг, что и топ.
prof = client.get("/api/users/me/stats").json()
assert prof["overall"]["score"] == 54.5
+13 -24
View File
@@ -1,9 +1,9 @@
"""Статистика профиля: цифры сходятся с лидербордом, а главная не гоняет CTE лишний раз.""" """Статистика профиля: цифры сходятся с лидербордом, а главная не грузит историю лишний раз."""
from __future__ import annotations from __future__ import annotations
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import event
from app.services import stats_service
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
@@ -31,10 +31,7 @@ def _group_with_matches(client: TestClient, engine, games: int = 3) -> tuple[dic
def test_profile_numbers_match_leaderboard(client: TestClient, engine): def test_profile_numbers_match_leaderboard(client: TestClient, engine):
"""Профиль считает в Python, лидерборд — в SQL: цифры обязаны совпадать. """Профиль и лидерборд собирают итог разными путями: цифры обязаны совпадать."""
Формула сглаженного рейтинга живёт в двух видах (SMOOTHED_SCORE_SQL и
scoring.smoothed_score); этот тест ловит их расхождение."""
me, gid, p2 = _group_with_matches(client, engine, games=4) me, gid, p2 = _group_with_matches(client, engine, games=4)
profile = client.get("/api/users/me/stats").json()["overall"] profile = client.get("/api/users/me/stats").json()["overall"]
@@ -47,33 +44,25 @@ def test_profile_numbers_match_leaderboard(client: TestClient, engine):
assert profile[field] == entry[field], field assert profile[field] == entry[field], field
def test_home_does_not_repeat_scored_cte(client: TestClient, engine): def test_home_loads_history_once(client: TestClient, engine, monkeypatch):
"""Главная делает не больше двух проходов по SCORED_CTE. """Главная грузит историю партий один раз: топ, профиль и итог активной группы
считаются из неё. Без этой проверки лишняя загрузка тихо вернётся при правке витрин."""
Было пять: лидерборд, три запроса профиля и итог по активной группе. Без этой
проверки оптимизация тихо отъедет назад при следующей правке витрин."""
me, gid, p2 = _group_with_matches(client, engine, games=2) me, gid, p2 = _group_with_matches(client, engine, games=2)
client.put( client.put(
"/api/users/me/active-group", json={"group_id": gid}, headers=csrf_headers(client) "/api/users/me/active-group", json={"group_id": gid}, headers=csrf_headers(client)
) )
seen: list[str] = [] calls: list[int] = []
original = stats_service.load_history
def before_execute(conn, cursor, statement, params, context, executemany): def spy(session):
if "WITH tie AS" in statement: calls.append(1)
seen.append(statement) return original(session)
event.listen(engine, "before_cursor_execute", before_execute) monkeypatch.setattr(stats_service, "load_history", spy)
try:
r = client.get("/api/home") r = client.get("/api/home")
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
finally: assert len(calls) == 1
event.remove(engine, "before_cursor_execute", before_execute)
# Сейчас ровно два: лидерборд и один проход по строкам игрока. Нижняя граница не
# для красоты — без неё тест пройдёт и когда счётчик молча перестанет что-либо
# ловить (сменился путь, переименован CTE).
assert 1 <= len(seen) <= 2, f"ожидали 1–2 прохода, получили {len(seen)}"
# Главная всё ещё показывает и профиль, и блок активной группы. # Главная всё ещё показывает и профиль, и блок активной группы.
body = r.json() body = r.json()
assert body["profile"]["overall"]["games"] == 2 assert body["profile"]["overall"]["games"] == 2
+26 -26
View File
@@ -1,6 +1,6 @@
# Публикация: домены, VPS, туннели # Публикация: домены, VPS, туннели
Приложение крутится дома (Pi — прод) и на твоём ПК (dev/test). Дома белого IP нет Приложение крутится дома (Pi — прод) и на твоём ПК (dev). Дома белого IP нет
(CGNAT), поэтому наружу выставляем через **VPS-привратник**: на нём Caddy терминирует (CGNAT), поэтому наружу выставляем через **VPS-привратник**: на нём Caddy терминирует
HTTPS твоими сертификатами и проксирует трафик в SSH reverse-туннели, которые HTTPS твоими сертификатами и проксирует трафик в SSH reverse-туннели, которые
приложение само открывает к VPS. приложение само открывает к VPS.
@@ -11,40 +11,40 @@ HTTPS твоими сертификатами и проксирует трафи
│ ▲ туннель-КОНТЕЙНЕР │ │ ▲ туннель-КОНТЕЙНЕР │
│ └── Pi : app:8000 PROD │ │ └── Pi : app:8000 PROD │
forbidden-stars.ru ──►│ :443 (cert твой) → 127.0.0.1:9001 │ forbidden-stars.ru ──►│ :443 (cert твой) → 127.0.0.1:9001 │
│ ▲ контейнер (test) ИЛИ │ │ ▲ ssh с ПК (по требованию) │
│ ▲ ssh с ПК (dev) │ │ └── ПК dev : vite:5173 DEV │
│ ├── ПК test : app:8000 │
│ └── ПК dev : vite:5173 │
└───────────────────────────────────────────────────┘ └───────────────────────────────────────────────────┘
``` ```
- **PROD** — Pi. `docker compose up -d` поднимает два сервиса: `app` + `tunnel`. У `app` - **PROD** — Pi. `docker compose up -d` поднимает три сервиса: `app` + `tunnel` + `backup`
**портов на хост нет** — наружу его выставляет только туннель-контейнер (образы из Gitea-реестра, собираются на ПК `scripts/build-push.ps1`). У `app` **портов на
(`ssh -R 9000:app:8000` к VPS). Постоянно, Docker сам переподключает. См. [`pi/`](pi/README.md). хост нет** — наружу его выставляет только туннель-контейнер (`ssh -R 9000:app:8000` к VPS).
- **TEST** — ПК. То же самое: `docker compose -f docker-compose.test.yml up` поднимает Работает постоянно: при обрыве `ssh` завершается, и Docker перезапускает контейнер
`app` + `tunnel` (`ssh -R 9001:app:8000`). Портов на хост нет — тест виден только на (`restart: unless-stopped`). См. [`pi/`](pi/README.md).
`forbidden-stars.ru`. Обычно запускается лаунчером при `APP_ENV=test`.
- **DEV** — ПК, нативно (`uvicorn`+`vite`). По умолчанию только на localhost; при - **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`. (`ssh -R 9001:localhost:5173`) → дев виден на `forbidden-stars.ru` (слот **9001**).
- DEV и TEST делят слот **9001** (`forbidden-stars.ru`) → поднимай что-то **одно за раз**. - PROD на отдельном слоте **9000** (`forbiddenstars.ru`) — работает независимо от dev.
PROD на отдельном слоте **9000** (`forbiddenstars.ru`) — работает независимо. Временный прод на ПК (`docker-compose.temp.yml`) тоже занимает **9000** — одновременно с Pi нельзя.
Ключ туннеля — **`deploy/tunnel/id_tunnel`** (приватный, в git не идёт). Его публичную Ключи туннеля (приватные, в git не идут; публичные части — в `authorized_keys` пользователя
часть добавь в `authorized_keys` пользователя `tunnel` на VPS. Один и тот же ключ годится `tunnel` на VPS):
для контейнерного туннеля (Pi/ПК) и для dev-туннеля `run.ps1`. - **Pi** — `TUNNEL_KEY_B64` (base64 приватного ключа) в `.env`; файла ключа на Pi нет.
- **ПК, временный прод** — файл `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`. 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`. 2. **Pi (прод)** — [`pi/README.md`](pi/README.md): ключ туннеля в `TUNNEL_KEY_B64`, `.env`, `docker compose up -d`.
3. **ПК (dev/test)** — тот же ключ в `deploy/tunnel/id_tunnel` (для test-контейнера) и/или 3. **ПК (dev)** — ключ по умолчанию в `~/.ssh` (для dev-туннеля) и, если нужен временный прод,
ключ по умолчанию для dev (`run.ps1`); pubkey — в `authorized_keys` у `tunnel@VPS`. файл `deploy/tunnel/id_tunnel`; pubkey — в `authorized_keys` у `tunnel@VPS`.
4. **Бэкапы** — [`backup/README.md`](backup/README.md): контейнер `backup` (restic) делает 4. **Бэкапы** — [`backup/README.md`](backup/README.md): контейнер `backup` (restic) делает
снимки на Pi и на VPS (`fsbackup@VPS`, только SFTP), скрипты ПК скачивают их и проверяют снимки на Pi и на VPS (`fsbackup@VPS`, только SFTP), скрипты ПК скачивают их на ПК.
восстановление на тест-клоне.
Секреты не в git: сертификаты/ключи (`*.pem`, `*.key`, `id_tunnel*`) живут на VPS/Pi/ПК, Секреты не в git: сертификаты/ключи (`*.pem`, `*.key`, `id_tunnel*`, `id_backup*`) живут на
в репозитории только `Caddyfile`, `deploy/tunnel/` (образ туннеля) и шаблоны. VPS/Pi/ПК, в репозитории только `Caddyfile`, страница-заглушка, образы `deploy/tunnel/` и
`deploy/backup/` и шаблоны.
> Telegram-вход требует HTTPS-домен: у BotFather `/setdomain` укажи оба домена > Telegram-вход требует HTTPS-домен: у BotFather `/setdomain` укажи оба домена
> (`forbiddenstars.ru` и `forbidden-stars.ru`). > (`forbiddenstars.ru` и `forbidden-stars.ru`).
@@ -62,6 +62,6 @@ HTTPS твоими сертификатами и проксирует трафи
внешний брокер (Redis pub/sub), иначе события увидит только тот воркер, что принял мутацию. внешний брокер (Redis pub/sub), иначе события увидит только тот воркер, что принял мутацию.
SSE-поток сам не закрывается, поэтому uvicorn запускается с `--timeout-graceful-shutdown` SSE-поток сам не закрывается, поэтому uvicorn запускается с `--timeout-graceful-shutdown`
(`entrypoint.sh`, `run.*`). Без него остановка ждёт закрытия всех соединений: в dev (10 с в `entrypoint.sh`, 2 с в `run.*`). Без него остановка ждёт закрытия всех соединений: в dev
`--reload` при открытой вкладке висит вечно, в контейнере остановку обрывает только SIGKILL `--reload` при открытой вкладке висит вечно, в контейнере остановку обрывает только SIGKILL
по `stop_grace_period`. по `stop_grace_period`.
+3 -2
View File
@@ -4,8 +4,9 @@
FROM restic/restic:0.19.1 FROM restic/restic:0.19.1
# sqlite — консистентная копия и проверка БД; supercronic — cron для контейнера без root; # sqlite — консистентная копия и проверка БД; supercronic — cron для контейнера без root;
# tini — корректные сигналы. jq, openssh-client, busybox (wget, flock, tar) уже есть в базе. # tini — корректные сигналы; curl — отчёты и бот в Telegram (#83).
RUN apk add --no-cache sqlite supercronic tini # jq, openssh-client, busybox (wget, flock, tar) уже есть в базе.
RUN apk add --no-cache sqlite supercronic tini curl
# Тот же uid, что у appuser в образе приложения (10001): файлы после restore получают # Тот же uid, что у appuser в образе приложения (10001): файлы после restore получают
# правильного владельца, а -wal/-shm SQLite никогда не достаются root. # правильного владельца, а -wal/-shm SQLite никогда не достаются root.
+107 -42
View File
@@ -16,7 +16,7 @@
4. [Сборка и публикация образов](#шаг-4-сборка-и-публикация-образов) — ПК 4. [Сборка и публикация образов](#шаг-4-сборка-и-публикация-образов) — ПК
5. [Pi: включить бэкапы](#шаг-5-pi-включить-бэкапы) — Pi 5. [Pi: включить бэкапы](#шаг-5-pi-включить-бэкапы) — Pi
6. [ПК: доступ к Pi и выгрузка бэкапов](#шаг-6-пк-доступ-к-pi-и-выгрузка-бэкапов) — ПК 6. [ПК: доступ к Pi и выгрузка бэкапов](#шаг-6-пк-доступ-к-pi-и-выгрузка-бэкапов) — ПК
7. [Учебное восстановление на тест-клоне](#шаг-7-учебное-восстановление-на-тест-клоне) — ПК 7. [Проверка скачанного архива](#шаг-7-проверка-скачанного-архива) — ПК
8. [Восстановление прода](#8-восстановление-прода) — Pi 8. [Восстановление прода](#8-восстановление-прода) — Pi
9. [Катастрофа: Pi умер](#9-катастрофа-pi-умер) — новый Pi 9. [Катастрофа: Pi умер](#9-катастрофа-pi-умер) — новый Pi
10. [Повседневные действия](#10-повседневные-действия) 10. [Повседневные действия](#10-повседневные-действия)
@@ -55,8 +55,8 @@
4. удаляет старые снимки по правилам хранения; 4. удаляет старые снимки по правилам хранения;
5. проверяет, что репозитории целы. 5. проверяет, что репозитории целы.
Раз в неделю (воскресенье, 05:30) дополнительно читает часть сохранённых данных и убеждается, Раз в неделю (воскресенье, 05:30) дополнительно читает часть сохранённых данных (10 %) и
что последний снимок действительно восстанавливается. убеждается, что БД из последнего снимка извлекается и проходит проверку целостности.
**Словарь** **Словарь**
@@ -562,43 +562,42 @@
--- ---
## Шаг 7. Учебное восстановление на тест-клоне ## Шаг 7. Проверка скачанного архива
**Где:** ПК с Docker Desktop. **Зачем:** убедиться, что бэкап действительно **Где:** ПК. **Зачем:** убедиться, что БД в скачанном снимке целая и в ней те данные, что
восстанавливается, **до** того как это понадобится по-настоящему. Прод не затрагивается. ожидаются, **до** того как это понадобится по-настоящему. Прод не затрагивается.
> Данные тест-клона на ПК будут заменены данными из архива. Прежние данные тест-клона > Отдельного тестового контейнера для учебного восстановления больше нет. Сам механизм
> сохраняются в его собственный снимок `pre-restore`. > `restore`/`import` отрабатывает только на Pi ([раздел 8](#8-восстановление-прода)); здесь
> проверяется содержимое архива.
1. Восстановите скачанный архив в тест-клон (подставьте имя своего файла): 1. Распакуйте последний скачанный архив во временную папку:
```powershell ```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 ```powershell
docker compose -f docker-compose.test.yml ps Remove-Item -Recurse -Force (Join-Path $env:TEMP "fs-check")
docker compose -f docker-compose.test.yml logs --tail 20 app
``` ```
3. Посмотрите на сайт: в `.env` на ПК временно поставьте `APP_ENV=test` и запустите
`.\run.ps1`. Тест-клон откроется на `https://forbidden-stars.ru`: проверьте топ,
профили, историю партий. Потом верните `APP_ENV=development`.
**Что должно получиться:** **Что должно получиться:**
- в выводе `restore-test`: - `PRAGMA integrity_check` → `ok`;
- `Развёрнутые данные в порядке: игроков N, партий M.` — те же числа, что в `list` на Pi; - `players` и `matches` совпадают со столбцами `Игроков` / `Партий` этого снимка в `list` на Pi;
- `Данные восстановлены.`; - в папке рядом с БД есть `uploads\…` (фото партий) и, если заводились, `achievements\…`.
- `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`.
--- ---
@@ -646,13 +645,14 @@
| `Игроков` / `Партий` | сколько игроков и партий было в БД в этот момент — по ним легко найти «до поломки» | | `Игроков` / `Партий` | сколько игроков и партий было в БД в этот момент — по ним легко найти «до поломки» |
| `Размер` | полный объём данных снимка | | `Размер` | полный объём данных снимка |
| `Прирост` | сколько места снимок реально добавил в репозиторий | | `Прирост` | сколько места снимок реально добавил в репозиторий |
| `Метки` | `scheduled` — по расписанию, `manual` — вручную, `pre-restore` — страховочный, прочие — имя, данное вручную | | `Метки` | `scheduled` — по расписанию, `manual` — вручную, `pre-restore` — страховочный, `keep` + имя — именованный снимок (`run --tag имя`) |
Если локальный репозиторий повреждён или пуст, смотрите копию на VPS: Если локальный репозиторий повреждён или пуст, смотрите копию на VPS:
`docker compose exec backup fs-backup list vps`. `docker compose exec backup fs-backup list vps`.
2. **По желанию, но рекомендуется:** сначала отрепетируйте на ПК: 2. **По желанию, но рекомендуется:** перед восстановлением проверьте выбранный снимок на ПК:
`.\scripts\fs-backup.ps1 pull -Snapshot <ID>`, затем `restore-test` ([шаг 7](#шаг-7-учебное-восстановление-на-тест-клоне)). `.\scripts\fs-backup.ps1 pull -Snapshot <ID>`, затем [шаг 7](#шаг-7-проверка-скачанного-архива).
Заодно у вас останется копия этого снимка вне Pi.
3. Остановите приложение. Сайт покажет страницу «Технические шоколадки»: 3. Остановите приложение. Сайт покажет страницу «Технические шоколадки»:
@@ -737,7 +737,24 @@
``` ```
3. Дождитесь, пока приложение создаст пустую БД (`docker compose ps` → `app` `(healthy)`). 3. Дождитесь, пока приложение создаст пустую БД (`docker compose ps` → `app` `(healthy)`).
В журнале бэкапа это нормально (`docker compose logs backup`): Контейнер `backup` стартует одновременно с `app`, ждёт, пока приложение ответит (до
10 минут), и пробует сделать первый бэкап. В его журнале (`docker compose logs backup`)
нормально увидеть одно из трёх:
- если приложение так и не поднялось и БД нет:
```
ОШИБКА: БД /fs-db/forbidden_stars.db не найдена — приложение ещё ни разу не запускалось?
Первый бэкап не удался — следующая попытка по расписанию.
```
- если БД есть, но без таблиц (миграции ещё не прошли):
```
Не удалось прочитать число игроков и партий: в БД нет таблиц users/matches.
```
- если БД уже была:
``` ```
В БД нет ни игроков, ни партий, а последний снимок в репозитории vps — с данными. В БД нет ни игроков, ни партий, а последний снимок в репозитории vps — с данными.
@@ -746,21 +763,25 @@
Это защита: пустой новый Pi не перезапишет историю на VPS. Это защита: пустой новый Pi не перезапишет историю на VPS.
4. Посмотрите снимки на VPS и выберите нужный (обычно самый свежий): 4. Посмотрите снимки на VPS и выберите нужный (обычно самый свежий **с данными**):
```bash ```bash
docker compose exec backup fs-backup list vps docker compose exec backup fs-backup list vps
``` ```
5. Восстановите: Проверьте, что у выбранного снимка в столбцах `Игроков`/`Партий` числа, а не `0` и не `?`.
Снимки без данных защита больше не создаёт, но снимок с `?` мог остаться от старой версии
бэкапов — такой не выбирайте.
5. Восстановите, подставив ID из `list vps`:
```bash ```bash
docker compose stop app docker compose stop app
docker compose exec backup fs-backup restore latest --repo vps --yes docker compose exec backup fs-backup restore <ID> --repo vps --yes
docker compose start app docker compose start app
``` ```
Вместо `latest` можно указать ID из `list vps`. Если самый свежий снимок в `list vps` — с данными, вместо ID можно написать `latest`.
6. Проверьте сайт. Затем сделайте снимок вручную, чтобы локальная копия появилась сразу: 6. Проверьте сайт. Затем сделайте снимок вручную, чтобы локальная копия появилась сразу:
@@ -792,7 +813,7 @@
- **Перед каждым обновлением прода** — `now -Tag before-update` (метка — латиница, цифры, `.`, `_`, `-`). - **Перед каждым обновлением прода** — `now -Tag before-update` (метка — латиница, цифры, `.`, `_`, `-`).
- **Раз в месяц:** - **Раз в месяц:**
- скачать снимок на ПК (`pull`); - скачать снимок на ПК (`pull`);
- раз в пару месяцев сделать учебное восстановление (`restore-test`); - проверить скачанный архив ([шаг 7](#шаг-7-проверка-скачанного-архива));
- удалить с ПК старые архивы — они не зашифрованы. - удалить с ПК старые архивы — они не зашифрованы.
- **Иногда:** посмотреть `docker compose ps`. Статус `unhealthy` у `backup` означает, что - **Иногда:** посмотреть `docker compose ps`. Статус `unhealthy` у `backup` означает, что
бэкапы перестали проходить (причину покажет `fs-backup status`). бэкапы перестали проходить (причину покажет `fs-backup status`).
@@ -817,6 +838,42 @@ ID одного и того же снимка в `local` и `vps` разные
**Обновить образ бэкапа** (после изменений в `deploy/backup/`). На ПК — **Обновить образ бэкапа** (после изменений в `deploy/backup/`). На ПК —
`.\scripts\build-push.ps1`, на Pi — `docker compose up -d backup`. `.\scripts\build-push.ps1`, на Pi — `docker compose up -d backup`.
### Отчёты в Telegram
Бот приложения (тот же, что для входа через Telegram) может присылать вам отчёты о бэкапах
и отвечать на команды. Пока `BACKUP_TELEGRAM_CHAT_ID` пуст, отчёты выключены.
**Что приходит само:**
- после каждого бэкапа — «✅ Бэкап»: сколько игроков и партий в БД, по каждому репозиторию
снимок, число снимков и размер;
- если бэкап не удался, в том числе не начавшись (нет пароля, занята другая операция, БД
не прошла проверку) — «❌ Бэкап не удался» с причиной и упавшими репозиториями;
- после еженедельной проверки — «🔍 Проверка данных: OK» или «❌».
**Команды боту** (отвечает только чатам из `BACKUP_TELEGRAM_CHAT_ID`):
- `/backups` — хранящиеся снимки: по репозиторию число и размер, 10 последних с временем,
игроками и партиями, 📌 у именованных;
- `/status` — то же, что `fs-backup status`;
- `/help` — список команд.
**Настройка:**
1. В Telegram найдите бота приложения и напишите ему `/start`.
2. Узнайте id своего чата:
```bash
docker compose exec backup fs-backup telegram chats
# 123456789 @you /start
```
Когда бот уже работает (id вписан), он сам забирает сообщения. Тогда id нового чата
ищите в журнале: `docker compose logs backup | grep "чужого чата"`.
3. Впишите id в `.env` — `BACKUP_TELEGRAM_CHAT_ID=123456789`, несколько через запятую —
и пересоздайте контейнер: `docker compose up -d backup`.
4. Проверьте связь: `docker compose exec backup fs-backup telegram test` — в чат придёт
пробное сообщение.
Токен бота по умолчанию берётся из `TELEGRAM_BOT_TOKEN` приложения. Чтобы слать отчёты от
другого бота, задайте `BACKUP_TELEGRAM_BOT_TOKEN`. Если Telegram недоступен, бэкапы работают
как обычно — в журнале будет только строка «Telegram: … не отправлено».
> **Никогда не выполняйте на проде `docker compose down -v`.** Флаг `-v` удаляет тома — > **Никогда не выполняйте на проде `docker compose down -v`.** Флаг `-v` удаляет тома —
> данные приложения **и** локальную копию бэкапов. Обычный `docker compose down` данные > данные приложения **и** локальную копию бэкапов. Обычный `docker compose down` данные
> не трогает. > не трогает.
@@ -838,6 +895,8 @@ docker compose logs --tail 100 backup
| Симптом | Причина | Что сделать | | Симптом | Причина | Что сделать |
|---|---|---| |---|---|---|
| `BACKUP_PASSWORD не задан в .env — бэкапы ОТКЛЮЧЕНЫ` | нет пароля в `.env` | добавить `BACKUP_PASSWORD` (шаг 5), затем `docker compose up -d backup` | | `BACKUP_PASSWORD не задан в .env — бэкапы ОТКЛЮЧЕНЫ` | нет пароля в `.env` | добавить `BACKUP_PASSWORD` (шаг 5), затем `docker compose up -d backup` |
| отчёты в Telegram не приходят, `telegram test` пишет «отправить не удалось» | неверный chat id или токен, боту не писали `/start` | раздел 10, «Отчёты в Telegram»: написать боту, взять id из журнала, `docker compose up -d backup` |
| бот не отвечает на `/backups`, в журнале «сообщение из чужого чата» | ваш id не в `BACKUP_TELEGRAM_CHAT_ID` | вписать id из этой строки журнала, `docker compose up -d backup` |
| `неверный BACKUP_PASSWORD для репозитория …` | пароль в `.env` не тот, с которым создан репозиторий | вернуть правильный пароль из менеджера паролей; `docker compose up -d backup` | | `неверный BACKUP_PASSWORD для репозитория …` | пароль в `.env` не тот, с которым создан репозиторий | вернуть правильный пароль из менеджера паролей; `docker compose up -d backup` |
| `BACKUP_SSH_KEY_B64 не декодируется из base64` / `— не приватный SSH-ключ` | строка ключа обрезана, с пробелами или от `.pub` | заново скопировать base64 **приватного** ключа (шаг 5, пункт 5), одной строкой | | `BACKUP_SSH_KEY_B64 не декодируется из base64` / `— не приватный SSH-ключ` | строка ключа обрезана, с пробелами или от `.pub` | заново скопировать base64 **приватного** ключа (шаг 5, пункт 5), одной строкой |
| `Репозиторий vps недоступен` и выше `Permission denied (publickey)` | на VPS нет публичного ключа или ключ другой | шаг 3, пункты 4 и 9: проверить `authorized_keys` и вход `sftp` с ПК этим ключом | | `Репозиторий vps недоступен` и выше `Permission denied (publickey)` | на VPS нет публичного ключа или ключ другой | шаг 3, пункты 4 и 9: проверить `authorized_keys` и вход `sftp` с ПК этим ключом |
@@ -852,7 +911,7 @@ docker compose logs --tail 100 backup
| `снимок '…' не найден в репозитории` | опечатка в ID или снимок в другом репозитории | `fs-backup list` и `fs-backup list vps` | | `снимок '…' не найден в репозитории` | опечатка в ID или снимок в другом репозитории | `fs-backup list` и `fs-backup list vps` |
| `service "backup" is not running` | контейнер не запущен | `docker compose up -d backup` | | `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 | | `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`.** Команда сама определяет, на каком этапе оборвалось восстановление: **Про `recover`.** Команда сама определяет, на каком этапе оборвалось восстановление:
- если замена успела пройти во всех томах — дочищает промежуточные папки, восстановленные - если замена успела пройти во всех томах — дочищает промежуточные папки, восстановленные
@@ -885,7 +944,7 @@ docker volume rm <имя тома>
| `WARNING: UNPROTECTED PRIVATE KEY FILE!` | у файла ключа слишком открытые права (ключ создан в Git Bash/WSL или скопирован) | `icacls <путь к ключу> /inheritance:r /grant:r "$($env:USERNAME):(R)"` | | `WARNING: UNPROTECTED PRIVATE KEY FILE!` | у файла ключа слишком открытые права (ключ создан в Git Bash/WSL или скопирован) | `icacls <путь к ключу> /inheritance:r /grant:r "$($env:USERNAME):(R)"` |
| `Checksum mismatch … run pull again` | файл повредился при передаче | повторить `pull` (битый файл уже удалён) | | `Checksum mismatch … run pull again` | файл повредился при передаче | повторить `pull` (битый файл уже удалён) |
| `Already downloaded: …` | этот снимок уже скачан | ничего не делать; нужен новый — сначала `now`, потом `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 <ID>`); если повторяется — `fs-backup verify` на Pi |
--- ---
@@ -906,9 +965,15 @@ docker volume rm <имя тома>
| `recover` | разбор прерванного восстановления | | `recover` | разбор прерванного восстановления |
| `export <ID\|latest> [--repo …]` | снимок в tar в stdout: `docker compose exec -T backup fs-backup export latest > fs.tar` | | `export <ID\|latest> [--repo …]` | снимок в tar в stdout: `docker compose exec -T backup fs-backup export latest > fs.tar` |
| `restic <local\|vps> <аргументы>` | любая команда restic с настройками контейнера, например `restic vps stats` | | `restic <local\|vps> <аргументы>` | любая команда restic с настройками контейнера, например `restic vps stats` |
| `telegram chats` | id чатов, писавших боту, — для `BACKUP_TELEGRAM_CHAT_ID` |
| `telegram test` | пробное сообщение в чаты `BACKUP_TELEGRAM_CHAT_ID` |
| `telegram bot` | бот-слушатель команд `/backups`, `/status` (контейнер запускает его сам) |
| `help` | краткая справка | | `help` | краткая справка |
| `init`, `info`, `health`, `has-snapshots` | служебные: создать репозитории, данные снимка для скриптов ПК, healthcheck, проверка «есть ли снимки» при старте |
Без `--yes` команды `restore` и `import` только показывают, что собираются сделать. Без `--yes` команды `restore` и `import` только показывают, что собираются сделать.
Флаг `--no-pre-restore` у `restore`/`import` пропускает страховочный снимок — используйте,
только если текущие данные точно не нужны.
### Скрипт ПК ### Скрипт ПК
@@ -918,11 +983,9 @@ docker volume rm <имя тома>
|---|---| |---|---|
| `status`, `list [-Repo vps]`, `now [-Tag имя]`, `verify` | то же, что на Pi, но с ПК | | `status`, `list [-Repo vps]`, `now [-Tag имя]`, `verify` | то же, что на Pi, но с ПК |
| `pull [-Snapshot ID] [-Repo vps]` | скачать снимок в `backups\` со сверкой sha256 | | `pull [-Snapshot ID] [-Repo vps]` | скачать снимок в `backups\` со сверкой sha256 |
| `restore-test -File <архив>` | учебное восстановление в локальный тест-клон |
| `-Target test` | выполнить `status`/`list`/`now`/`verify`/`pull` на локальном тест-клоне |
В bash-версии те же команды пишутся так: `list vps`, `now --tag имя`, В bash-версии те же команды пишутся так: `list vps`, `now --tag имя`,
`pull <ID> --repo vps`, `restore-test <архив>`, `--test` первым аргументом. `pull <ID> --repo vps`.
### Переменные `.env` ### Переменные `.env`
@@ -940,6 +1003,8 @@ docker volume rm <имя тома>
| `BACKUP_VPS_PORT` | Pi | `22` | SSH-порт VPS | | `BACKUP_VPS_PORT` | Pi | `22` | SSH-порт VPS |
| `BACKUP_VPS_DIR` | Pi | `/srv/fs-backups/restic` | папка репозитория на VPS | | `BACKUP_VPS_DIR` | Pi | `/srv/fs-backups/restic` | папка репозитория на VPS |
| `BACKUP_SSH_KEY_B64` | Pi | — | приватный ключ для VPS, base64 | | `BACKUP_SSH_KEY_B64` | Pi | — | приватный ключ для VPS, base64 |
| `BACKUP_TELEGRAM_CHAT_ID` | Pi | — | id чатов для отчётов и команд, через запятую; пусто = без Telegram |
| `BACKUP_TELEGRAM_BOT_TOKEN` | Pi | `TELEGRAM_BOT_TOKEN` | токен бота, если отчёты должен слать другой бот |
| `BACKUP_MEM_LIMIT` | Pi | `384m` | лимит памяти контейнера | | `BACKUP_MEM_LIMIT` | Pi | `384m` | лимит памяти контейнера |
| `BACKUP_PI_SSH` | ПК | — | как зайти на Pi: `pi@<IP>` | | `BACKUP_PI_SSH` | ПК | — | как зайти на Pi: `pi@<IP>` |
| `BACKUP_PI_DIR` | ПК | `~/forbidden-stars` | папка прода на Pi | | `BACKUP_PI_DIR` | ПК | `~/forbidden-stars` | папка прода на Pi |
@@ -970,7 +1035,7 @@ docker volume rm <имя тома>
- [ ] На Pi первый бэкап прошёл в `local` и `vps`, `status` без ошибок (шаг 5) - [ ] На Pi первый бэкап прошёл в `local` и `vps`, `status` без ошибок (шаг 5)
- [ ] `docker compose ps` показывает `backup` `(healthy)` (шаг 5) - [ ] `docker compose ps` показывает `backup` `(healthy)` (шаг 5)
- [ ] С ПК `status`, `list`, `pull` работают без пароля (шаг 6) - [ ] С ПК `status`, `list`, `pull` работают без пароля (шаг 6)
- [ ] Учебное восстановление на тест-клоне прошло, данные на месте (шаг 7) - [ ] Скачанный архив проверен: БД целая, числа совпадают с `list` (шаг 7)
**Через сутки** **Через сутки**
- [ ] В `list` появился снимок с меткой `scheduled` в 04:00 - [ ] В `list` появился снимок с меткой `scheduled` в 04:00
+23 -1
View File
@@ -5,6 +5,14 @@ set -eu
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; } log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; }
# Telegram-бот (#83): отвечает владельцу на /backups и /status. Стартует раньше проверки
# пароля — при выключенных бэкапах скажет, что они выключены. Упал — перезапуск через минуту.
if [ -n "${BACKUP_TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${BACKUP_TELEGRAM_CHAT_ID:-}" ]; then
( while :; do fs-backup telegram bot || true; sleep 60; done ) &
else
log "Telegram не настроен (BACKUP_TELEGRAM_CHAT_ID/токен бота) — отчёты в Telegram выключены."
fi
# Без пароля бэкапы невозможны. Не падаем (иначе restart-петля и спам в логах) — ждём, # Без пароля бэкапы невозможны. Не падаем (иначе restart-петля и спам в логах) — ждём,
# пока пароль появится в .env; healthcheck при этом показывает unhealthy. # пока пароль появится в .env; healthcheck при этом показывает unhealthy.
if [ -z "${BACKUP_PASSWORD:-}" ]; then if [ -z "${BACKUP_PASSWORD:-}" ]; then
@@ -29,7 +37,21 @@ if [ ! -s "$CRONTAB" ]; then
fi fi
if ! fs-backup has-snapshots; then if ! fs-backup has-snapshots; then
log "Снимков в локальном репозитории ещё нет — делаю первый бэкап сразу." # Новый сервер: app и backup стартуют разом, и БД может быть ещё без таблиц. /api/health
# отвечает только после миграций и bootstrap — ждём его (#74). Не дождались — пробуем всё
# равно: guard_empty не сохранит БД без таблиц.
_app="${BACKUP_APP_HOST:-app}"
_wait="${BACKUP_APP_WAIT_SECONDS:-600}"
log "Снимков в локальном репозитории ещё нет — жду приложение (до ${_wait} с) и делаю первый бэкап."
_waited=0
until wget -q -T 3 -O /dev/null "http://$_app:8000/api/health" 2>/dev/null; do
if [ "$_waited" -ge "$_wait" ]; then
log "Приложение не ответило за ${_wait} с — пробую бэкап без него."
break
fi
sleep 10
_waited=$((_waited + 10))
done
fs-backup run --scheduled || log "Первый бэкап не удался — следующая попытка по расписанию." fs-backup run --scheduled || log "Первый бэкап не удался — следующая попытка по расписанию."
fi fi
+243 -4
View File
@@ -34,11 +34,15 @@ APP_HOST="${BACKUP_APP_HOST:-app}"
export RESTIC_PASSWORD="${BACKUP_PASSWORD:-}" export RESTIC_PASSWORD="${BACKUP_PASSWORD:-}"
export RESTIC_COMPRESSION="${BACKUP_COMPRESSION:-max}" export RESTIC_COMPRESSION="${BACKUP_COMPRESSION:-max}"
export RESTIC_CACHE_DIR="${RESTIC_CACHE_DIR:-/backup/cache}" export RESTIC_CACHE_DIR="${RESTIC_CACHE_DIR:-/backup/cache}"
TG_TOKEN="${BACKUP_TELEGRAM_BOT_TOKEN:-}"
TG_CHATS="${BACKUP_TELEGRAM_CHAT_ID:-}"
TG_API="${BACKUP_TELEGRAM_API:-https://api.telegram.org}"
LAST_ERROR="" # текст последнего die — причина сбоя в отчёте Telegram
# ─── Общие функции ──────────────────────────────────────────────────────────── # ─── Общие функции ────────────────────────────────────────────────────────────
# Весь служебный вывод — в stderr: stdout у export занят tar-потоком. # Весь служебный вывод — в stderr: stdout у export занят tar-потоком.
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; } log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; }
die() { log "ОШИБКА: $*"; exit 1; } die() { LAST_ERROR="$*"; log "ОШИБКА: $*"; exit 1; }
require_password() { require_password() {
[ -n "$RESTIC_PASSWORD" ] || die "BACKUP_PASSWORD не задан в .env (см. deploy/backup/README.md, шаг 1)." [ -n "$RESTIC_PASSWORD" ] || die "BACKUP_PASSWORD не задан в .env (см. deploy/backup/README.md, шаг 1)."
@@ -118,6 +122,99 @@ human() { # байты → «12.3 MB»
fmt_epoch() { date -d "@$1" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$1"; } fmt_epoch() { date -d "@$1" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$1"; }
# ─── Telegram (#83) ───────────────────────────────────────────────────────────
# Отчёты о бэкапах и проверках — в чаты BACKUP_TELEGRAM_CHAT_ID (через запятую) от бота
# BACKUP_TELEGRAM_BOT_TOKEN. Не настроено — молчим. Telegram недоступен — бэкап от этого не
# страдает: неудача отправки только пишется в лог. Токен не попадает ни в лог, ни в текст.
tg_enabled() { [ -n "$TG_TOKEN" ] && [ -n "$TG_CHATS" ]; }
tg_escape() { sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/g'; }
tg_chat_ids() { printf '%s' "$TG_CHATS" | tr ',;' ' '; }
tg_api() { # tg_api <метод> [аргументы curl…] → JSON ответа
_method="$1"
shift
curl -fsS --max-time 70 "$@" "$TG_API/bot$TG_TOKEN/$_method" 2>/dev/null
}
tg_post() { # tg_post <chat_id> <html> — одно сообщение в один чат
tg_api sendMessage -o /dev/null \
--data-urlencode "chat_id=$1" \
--data-urlencode "text=$2" \
--data-urlencode "parse_mode=HTML" \
--data-urlencode "disable_web_page_preview=true"
}
tg_send() { # tg_send <html> — во все чаты владельца
tg_enabled || return 0
for _chat in $(tg_chat_ids); do
tg_post "$_chat" "$1" || log "Telegram: сообщение в чат $_chat не отправлено."
done
}
repo_summary() { # repo_summary <repo> → «снимков 30 · 45.2 MB»
if _st="$(r "$1" stats --mode raw-data --json 2>/dev/null)"; then
printf '%s' "$_st" | jq -r '"\(.snapshots_count)\t\(.total_size)"' | {
IFS="$(printf '\t')" read -r _cnt _size
echo "снимков $_cnt · $(human "$_size")"
}
else
echo "размер недоступен"
fi
}
# Итог run/verify одним сообщением. Списки — «репо:поле:поле» через пробел; их собирают
# cmd_run/cmd_verify, а вызывает обработчик EXIT — так отчёт уходит и при раннем отказе.
notify_run() { # notify_run <код выхода>
tg_enabled || return 0
_when="$(date '+%d.%m %H:%M')"
_db="БД: игроков $_players, партий $_matches"
if [ "$1" -eq 0 ]; then
_text="✅ <b>Бэкап</b> $_when
$_db"
else
_text="❌ <b>Бэкап не удался</b> $_when
$(printf '%s' "${LAST_ERROR:-прервался с кодом $1}" | tg_escape)"
[ "$_players" = "?" ] || _text="$_text
$_db"
fi
for _item in $_ok_repos; do
_text="$_text
• ${_item%%:*}: снимок <code>${_item#*:}</code> · $(repo_summary "${_item%%:*}")"
done
for _repo in $_failed; do
_text="$_text
• $_repo: ошибка"
done
[ "$1" -eq 0 ] || _text="$_text
Подробности: <code>docker compose logs backup</code>"
tg_send "$_text"
}
notify_verify() { # notify_verify <код выхода>
tg_enabled || return 0
_when="$(date '+%d.%m %H:%M')"
if [ "$1" -eq 0 ]; then
_text="🔍 <b>Проверка данных: OK</b> $_when"
else
_text="❌ <b>Проверка данных не прошла</b> $_when
$(printf '%s' "${LAST_ERROR:-прервалась с кодом $1}" | tg_escape)"
fi
for _item in $_verified; do
_rest="${_item#*:}"
_text="$_text
• ${_item%%:*}: данные целы, в последнем снимке игроков ${_rest%%:*}, партий ${_rest#*:}"
done
for _repo in $_failed; do
_text="$_text
• $_repo: ошибка"
done
[ "$1" -eq 0 ] || _text="$_text
Подробности: <code>docker compose logs backup</code>"
tg_send "$_text"
}
# Проверка SQLite без записи рядом с файлом (immutable: ни -wal, ни -shm не создаются). # Проверка SQLite без записи рядом с файлом (immutable: ни -wal, ни -shm не создаются).
db_ok() { db_ok() {
[ -s "$1" ] || return 1 [ -s "$1" ] || return 1
@@ -178,6 +275,17 @@ forget_repo() {
# Защита истории от пустых данных: новый Pi до восстановления или случайно очищенная БД не # Защита истории от пустых данных: новый Pi до восстановления или случайно очищенная БД не
# должны становиться «последним снимком» (restore latest вернул бы пустоту). # должны становиться «последним снимком» (restore latest вернул бы пустоту).
guard_empty() { # guard_empty <игроков> <партий> guard_empty() { # guard_empty <игроков> <партий>
# «?» — счётчики не прочитались: в БД нет таблиц (миграции ещё не прошли). Такой снимок
# стал бы latest и сломал бы restore latest (#74) — отказ, как и для пустой БД.
case "$1$2" in
*'?'*)
for _r in $(repos); do mark "run-$_r" err "в БД нет таблиц users/matches — бэкап не сделан"; done
log "Не удалось прочитать число игроков и партий: в БД нет таблиц users/matches."
log "Похоже, приложение ещё не применило миграции. Бэкап НЕ сделан, чтобы снимок без данных"
log "не стал последним. Дождитесь запуска приложения; если так и задумано:"
die "fs-backup run --allow-empty"
;;
esac
if [ "$1" != 0 ] || [ "$2" != 0 ]; then return 0; fi if [ "$1" != 0 ] || [ "$2" != 0 ]; then return 0; fi
for _repo in $(repos); do for _repo in $(repos); do
_prev="$(r "$_repo" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null | jq -r \ _prev="$(r "$_repo" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null | jq -r \
@@ -236,11 +344,18 @@ cmd_run() {
esac esac
shift shift
done done
_players="?"
_matches="?"
_ok_repos=""
_failed=""
# Отчёт в Telegram уходит при любом исходе, в том числе при раннем отказе (занят lock,
# нет пароля). Копию БД убираем только после take_lock: до него она может быть чужой.
trap '_rc=$?; notify_run "$_rc"' EXIT
require_password require_password
setup_ssh setup_ssh
take_lock take_lock
warn_leftovers warn_leftovers
trap 'rm -f "$SNAP_ROOT/$DB_NAME"' EXIT trap '_rc=$?; rm -f "$SNAP_ROOT/$DB_NAME"; notify_run "$_rc"' EXIT
log "Снимок данных: консистентная копия БД…" log "Снимок данных: консистентная копия БД…"
stage_db stage_db
@@ -252,7 +367,6 @@ cmd_run() {
log "БД в порядке: игроков $_players, партий $_matches." log "БД в порядке: игроков $_players, партий $_matches."
if [ "$_allow_empty" = no ]; then guard_empty "$_players" "$_matches"; fi if [ "$_allow_empty" = no ]; then guard_empty "$_players" "$_matches"; fi
_failed=""
for _repo in $(repos); do for _repo in $(repos); do
log "=== Репозиторий $_repo ($(repo_url "$_repo")) ===" log "=== Репозиторий $_repo ($(repo_url "$_repo")) ==="
if backup_to "$_repo" --tag "$_kind" --tag "players:$_players" --tag "matches:$_matches" ${_tag:+--tag keep --tag "$_tag"} \ if backup_to "$_repo" --tag "$_kind" --tag "players:$_players" --tag "matches:$_matches" ${_tag:+--tag keep --tag "$_tag"} \
@@ -260,6 +374,7 @@ cmd_run() {
&& r "$_repo" check >&2; then && r "$_repo" check >&2; then
_sid="$(latest_short_id "$_repo")" _sid="$(latest_short_id "$_repo")"
mark "run-$_repo" ok "снимок $_sid" mark "run-$_repo" ok "снимок $_sid"
_ok_repos="$_ok_repos $_repo:$_sid"
log "OK: репозиторий $_repo, снимок $_sid." log "OK: репозиторий $_repo, снимок $_sid."
else else
mark "run-$_repo" err "бэкап/очистка/проверка не удались — см. docker compose logs backup" mark "run-$_repo" err "бэкап/очистка/проверка не удались — см. docker compose logs backup"
@@ -365,10 +480,12 @@ cmd_has_snapshots() {
# ─── verify ─────────────────────────────────────────────────────────────────── # ─── verify ───────────────────────────────────────────────────────────────────
cmd_verify() { cmd_verify() {
_verified=""
_failed=""
trap '_rc=$?; notify_verify "$_rc"' EXIT
require_password require_password
setup_ssh setup_ssh
take_lock take_lock
_failed=""
for _repo in $(repos); do for _repo in $(repos); do
log "=== Проверка репозитория $_repo: структура + $VERIFY_SUBSET данных ===" log "=== Проверка репозитория $_repo: структура + $VERIFY_SUBSET данных ==="
_tmp="$RUNTIME_DIR/verify.db" _tmp="$RUNTIME_DIR/verify.db"
@@ -378,6 +495,7 @@ cmd_verify() {
&& db_ok "$_tmp"; then && db_ok "$_tmp"; then
_counts="$(db_counts "$_tmp")" _counts="$(db_counts "$_tmp")"
mark "verify-$_repo" ok "данные целы; последний снимок: игроков ${_counts%%|*}, партий ${_counts##*|}" mark "verify-$_repo" ok "данные целы; последний снимок: игроков ${_counts%%|*}, партий ${_counts##*|}"
_verified="$_verified $_repo:${_counts%%|*}:${_counts##*|}"
log "OK: $_repo — данные целы, БД последнего снимка открывается (игроков ${_counts%%|*}, партий ${_counts##*|})." log "OK: $_repo — данные целы, БД последнего снимка открывается (игроков ${_counts%%|*}, партий ${_counts##*|})."
else else
mark "verify-$_repo" err "проверка не прошла — см. docker compose logs backup" mark "verify-$_repo" err "проверка не прошла — см. docker compose logs backup"
@@ -779,6 +897,121 @@ cmd_init() {
for _repo in $(repos); do ensure_repo "$_repo" && log "Репозиторий $_repo готов."; done for _repo in $(repos); do ensure_repo "$_repo" && log "Репозиторий $_repo готов."; done
} }
# ─── telegram ─────────────────────────────────────────────────────────────────
tg_help_text() {
cat <<'EOF'
<b>Бэкапы Forbidden Stars</b>
/backups — хранящиеся снимки по репозиториям
/status — последние бэкапы и проверки, размеры
Отчёт о каждом бэкапе и проверке приходит сюда сам.
EOF
}
tg_backups_text() { # хранящиеся снимки: по репозиторию итог и 10 последних
[ -n "$RESTIC_PASSWORD" ] || { echo "Бэкапы отключены: BACKUP_PASSWORD не задан."; return 0; }
setup_ssh
for _repo in $(repos); do
echo "<b>$_repo</b> · $(repo_summary "$_repo")"
if _json="$(r "$_repo" snapshots --json 2>/dev/null)"; then
printf '%s' "$_json" | jq -r '
def tagval($p): ([.tags[]? | select(startswith($p)) | ltrimstr($p)] | first) // "?";
def names: [.tags[]? | select(test("^(players:|matches:|scheduled$|manual$|keep$)") | not)];
sort_by(.time) | .[-10:] | reverse | .[] |
"<code>\(.time[5:16] | sub("T"; " "))</code> игроков \(tagval("players:")), партий \(tagval("matches:"))"
+ (if (names | length) > 0 then " · 📌 " + (names | join(", ")) else "" end)'
else
echo "репозиторий недоступен или занят — повторите позже"
fi
echo
done
}
tg_allowed() { # tg_allowed <chat_id> — чат из белого списка владельца
for _allowed in $(tg_chat_ids); do [ "$_allowed" = "$1" ] && return 0; done
return 1
}
tg_reply() { # tg_reply <chat_id> <текст команды>
# Каждая выборка — в подоболочке $(…): die внутри не роняет цикл бота.
case "$2" in
/backups*|/list*) _reply="$(tg_backups_text 2>/dev/null)" ;;
/status*) _reply="<pre>$(cmd_status 2>&1 | head -n 60 | tg_escape)</pre>" ;;
*) _reply="$(tg_help_text)" ;;
esac
[ -n "$_reply" ] || _reply="Не удалось получить данные — см. docker compose logs backup."
tg_post "$1" "$_reply" || log "Telegram: ответ в чат $1 не отправлен."
}
# Бот: long polling getUpdates. Отвечает только чатам из BACKUP_TELEGRAM_CHAT_ID; про чужие
# пишет chat id в лог — так владелец узнаёт свой при настройке. Lock не берёт: чтение
# restic совместимо с идущим бэкапом.
cmd_telegram_bot() {
tg_enabled || die "Telegram не настроен: нужны BACKUP_TELEGRAM_CHAT_ID и токен бота."
_offset_file="$STATE_DIR/telegram.offset"
_updates="$RUNTIME_DIR/telegram-updates"
log "Telegram-бот: слушаю команды /backups, /status, /help."
while :; do
_offset="$(cat "$_offset_file" 2>/dev/null || echo 0)"
if ! _resp="$(tg_api getUpdates --data-urlencode "offset=$_offset" \
--data-urlencode "timeout=50" --data-urlencode 'allowed_updates=["message"]')"; then
sleep 30
continue
fi
# Разделитель — \037 (не пробельный): пустые поля (нет username/текста) не схлопываются.
printf '%s' "$_resp" | jq -r '.result[]? |
[.update_id, (.message.chat.id // ""), (.message.from.username // ""),
((.message.text // "") | gsub("[\n]"; " "))] | map(tostring) | join("")' \
> "$_updates" 2>/dev/null || { sleep 30; continue; }
while IFS="$(printf '\037')" read -r _uid _chat _from _text; do
echo $((_uid + 1)) > "$_offset_file"
[ -n "$_chat" ] || continue
if tg_allowed "$_chat"; then
tg_reply "$_chat" "$_text"
else
log "Telegram: сообщение из чужого чата $_chat (@${_from:-?}) — без ответа. Если это вы, впишите $_chat в BACKUP_TELEGRAM_CHAT_ID."
fi
done < "$_updates"
done
}
# Кто писал боту: id чатов для BACKUP_TELEGRAM_CHAT_ID. Нужен только токен; апдейты не
# подтверждаются (без offset), так что ничего не теряется. Пока бот-слушатель запущен, он
# забирает сообщения сам — тогда id смотрите в журнале («сообщение из чужого чата …»).
cmd_telegram_chats() {
[ -n "$TG_TOKEN" ] || die "нет токена бота: задайте TELEGRAM_BOT_TOKEN или BACKUP_TELEGRAM_BOT_TOKEN."
_resp="$(tg_api getUpdates --data-urlencode "timeout=0")" \
|| die "Telegram не ответил (неверный токен или нет сети)."
_chats="$(printf '%s' "$_resp" | jq -r '[.result[]? | .message | select(. != null)
| "\(.chat.id)\t@\(.from.username // "?")\t\(.text // "")"] | unique | .[]')"
if [ -z "$_chats" ]; then
echo "Сообщений боту нет: напишите ему /start в Telegram и повторите."
return 0
fi
echo "Чаты, писавшие боту (id — в BACKUP_TELEGRAM_CHAT_ID):"
printf '%s\n' "$_chats"
}
cmd_telegram() {
case "${1:-}" in
bot) cmd_telegram_bot ;;
chats) cmd_telegram_chats ;;
test)
tg_enabled || die "Telegram не настроен: нужны BACKUP_TELEGRAM_CHAT_ID и токен бота (BACKUP_TELEGRAM_BOT_TOKEN или TELEGRAM_BOT_TOKEN)."
_sent=0
for _chat in $(tg_chat_ids); do
if tg_post "$_chat" "🔔 Проверка связи: отчёты о бэкапах Forbidden Stars будут приходить сюда."; then
_sent=$((_sent + 1))
log "Telegram: тестовое сообщение в чат $_chat отправлено."
else
log "Telegram: в чат $_chat отправить не удалось (неверный токен/chat id или боту не писали /start)."
fi
done
[ "$_sent" -gt 0 ] || die "ни одно тестовое сообщение не дошло."
;;
*) die "telegram: укажите chats, test или bot" ;;
esac
}
usage() { usage() {
cat <<'EOF' cat <<'EOF'
fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из папки с docker-compose.yml: fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из папки с docker-compose.yml:
@@ -806,6 +1039,11 @@ fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из
export <id|latest> [--repo local|vps] > fs.tar export <id|latest> [--repo local|vps] > fs.tar
снимок в tar без сжатия (нужен exec -T) снимок в tar без сжатия (нужен exec -T)
Telegram (отчёты о каждом бэкапе и проверке, команды /backups и /status):
telegram chats id чатов, писавших боту (для BACKUP_TELEGRAM_CHAT_ID)
telegram test пробное сообщение в чаты BACKUP_TELEGRAM_CHAT_ID
telegram bot бот-слушатель команд (контейнер запускает его сам)
Прочее: Прочее:
init создать репозитории (делается автоматически) init создать репозитории (делается автоматически)
restic <local|vps> <аргументы> произвольная команда restic с настройками контейнера restic <local|vps> <аргументы> произвольная команда restic с настройками контейнера
@@ -833,6 +1071,7 @@ case "$_cmd" in
restic) cmd_restic "$@" ;; restic) cmd_restic "$@" ;;
health) cmd_health "$@" ;; health) cmd_health "$@" ;;
has-snapshots) cmd_has_snapshots "$@" ;; has-snapshots) cmd_has_snapshots "$@" ;;
telegram) cmd_telegram "$@" ;;
help|-h|--help) usage ;; help|-h|--help) usage ;;
*) usage >&2; exit 2 ;; *) usage >&2; exit 2 ;;
esac esac
+57 -6
View File
@@ -2,12 +2,13 @@
На Pi нужны только **ДВА файла**: `docker-compose.yml` и `.env`. Репозиторий, сборка и файл На Pi нужны только **ДВА файла**: `docker-compose.yml` и `.env`. Репозиторий, сборка и файл
ключа туннеля не нужны: ключа туннеля не нужны:
- образы (`app` + `tunnel`) тянутся из Gitea-реестра (`pull_policy: always`); - образы (`app` + `tunnel` + `backup`) тянутся из Gitea-реестра (`pull_policy: always`);
- приватный ключ туннеля лежит в `.env` как `TUNNEL_KEY_B64` (base64). - приватный ключ туннеля лежит в `.env` как `TUNNEL_KEY_B64` (base64).
`docker compose up` поднимает два контейнера: `app` (FastAPI+SPA, портов на хост нет) и `docker compose up` поднимает три контейнера: `app` (FastAPI+SPA, портов на хост нет),
`tunnel` (`ssh -R 9000:app:8000` к VPS). Публичная точка — VPS, домен `forbiddenstars.ru` `tunnel` (`ssh -R 9000:app:8000` к VPS, стартует после `healthy` у `app`) и `backup` (restic,
(Pi за CGNAT — туннель стучится наружу сам). см. раздел «Бэкапы»). Публичная точка — VPS, домен `forbiddenstars.ru` (Pi за CGNAT — туннель
стучится наружу сам).
--- ---
@@ -34,6 +35,32 @@ newgrp docker # применить группу
docker version && docker compose version # проверка docker version && docker compose version # проверка
``` ```
### Лимиты памяти (memory cgroup)
Прошивка Raspberry Pi сама добавляет ядру `cgroup_disable=memory`. Без контроллера `memory`
Docker **молча игнорирует** `mem_limit` из `docker-compose.yml` (app 512m, tunnel 64m, backup
384m) и на каждый контейнер пишет «Your kernel does not support memory limit capabilities or
the cgroup is not mounted. Limitation discarded.». Тогда утечка или тяжёлый бэкап могут
занять всю RAM Pi, и OOM-killer прибьёт что попало.
Проверка — если есть вывод, лимиты не работают:
```bash
docker info 2>&1 | grep -i "no memory limit"
cat /sys/fs/cgroup/cgroup.controllers # в списке должно быть слово memory
```
Включить — дописать параметр **в ту же единственную строку** `cmdline.txt` (перевод строки
в этом файле ломает загрузку) и перезагрузить Pi. Прод на время перезагрузки недоступен,
контейнеры поднимутся сами:
```bash
sudo cp /boot/firmware/cmdline.txt /boot/firmware/cmdline.txt.bak
grep -q "cgroup_enable=memory" /boot/firmware/cmdline.txt || \
sudo sed -i '1 s/$/ cgroup_enable=memory/' /boot/firmware/cmdline.txt
cat /boot/firmware/cmdline.txt # одна строка, в конце cgroup_enable=memory
sudo reboot
```
После перезагрузки `cgroup.controllers` содержит `memory`, а `docker info` не пишет
`No memory limit support`. Если Pi не загрузился, верните бэкап: вставьте карту в ПК и на
разделе `bootfs` замените `cmdline.txt` содержимым `cmdline.txt.bak`.
## 2. SSH-ключ для туннеля ## 2. SSH-ключ для туннеля
**Что это.** Отдельная пара ключей **только для туннеля** — ею контейнер `tunnel` логинится на **Что это.** Отдельная пара ключей **только для туннеля** — ею контейнер `tunnel` логинится на
`tunnel@VPS`, чтобы открыть `ssh -R`. Это не системный ключ Pi, ты создаёшь его сам. Распределение: `tunnel@VPS`, чтобы открыть `ssh -R`. Это не системный ключ Pi, ты создаёшь его сам. Распределение:
@@ -86,6 +113,23 @@ IMAGE_REGISTRY=gitea.arseniev.info/notbigghost # уже значение по
IMAGE_TAG=latest IMAGE_TAG=latest
``` ```
`APP_ENV` (форсится в `production`) и `VPS_TUNNEL_PORT` (прод → 9000) не трогать. `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`, у существующего админа само ничего не изменится.
>
> **Смена пароля админа** (плановая или при утечке) — осознанной командой, через панель
> его не сменить:
> 1. поменять `ADMIN_PASSWORD` в `.env` на Pi;
> 2. `docker compose up -d` — пересоздаст `app` с новым `.env` (контейнер читает `.env`
> только при создании; без этого шага команда ниже увидит старый пароль);
> 3. `docker compose exec app python -m app.bootstrap --reset-admin-password`.
>
> Все админские сессии, в том числе чужие, если пароль утёк, после этого завершаются.
> Логин (`ADMIN_USERNAME`) команда не меняет.
## 4. Запуск ## 4. Запуск
```bash ```bash
@@ -94,7 +138,8 @@ docker compose up -d # pull_policy: always → тянет обр
docker compose ps # app healthy → поднимется tunnel docker compose ps # app healthy → поднимется tunnel
docker compose logs -f tunnel # ждём строку: [tunnel] -R 9000:app:8000 -> tunnel@... docker compose logs -f tunnel # ждём строку: [tunnel] -R 9000:app:8000 -> tunnel@...
``` ```
На старте контейнер сам применит миграции, засидит справочники и создаст админа из `.env`. На старте контейнер сам применит миграции, засидит справочники и создаст админа из `.env`
(если админа ещё нет).
## 5. Проверка ## 5. Проверка
- Открой `https://forbiddenstars.ru` — должно отдать приложение (не заглушку). - Открой `https://forbiddenstars.ru` — должно отдать приложение (не заглушку).
@@ -103,9 +148,12 @@ docker compose logs -f tunnel # ждём строку: [tunnel] -R 9000:a
## Обновление ## Обновление
```bash ```bash
# Pi: docker compose exec backup fs-backup run --tag before-update # по желанию, если бэкапы включены
# ПК: .\scripts\build-push.ps1 # ПК: .\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` Уже обеспечен: `systemctl enable docker` + `restart: unless-stopped`. После `sudo reboot`
@@ -132,3 +180,6 @@ docker compose exec backup fs-backup list # хронология снимк
слот 9000 → на VPS `sudo fuser -k 9000/tcp`, затем `docker compose restart tunnel`). слот 9000 → на VPS `sudo fuser -k 9000/tcp`, затем `docker compose restart tunnel`).
- `pull` не проходит → проверь `docker login gitea.arseniev.info` и что реестр по HTTPS с валидным - `pull` не проходит → проверь `docker login gitea.arseniev.info` и что реестр по HTTPS с валидным
сертификатом (иначе хост в `/etc/docker/daemon.json` → `insecure-registries`, `systemctl restart docker`). сертификатом (иначе хост в `/etc/docker/daemon.json` → `insecure-registries`, `systemctl restart docker`).
- `docker compose up` пишет «Your kernel does not support memory limit capabilities…
Limitation discarded.» → лимиты памяти не работают: включите memory cgroup, раздел 1,
«Лимиты памяти».
+1 -1
View File
@@ -10,7 +10,7 @@ VPS_TUNNEL_USER="${VPS_TUNNEL_USER:-tunnel}"
UPSTREAM="${UPSTREAM:-app:8000}" UPSTREAM="${UPSTREAM:-app:8000}"
# Источник приватного ключа: либо TUNNEL_KEY_B64 (base64 в .env — прод: только compose+env), # Источник приватного ключа: либо TUNNEL_KEY_B64 (base64 в .env — прод: только compose+env),
# либо смонтированный файл /key/id_tunnel (dev/test, где репозиторий есть на хосте). # либо смонтированный файл /key/id_tunnel (временный прод на ПК, где репозиторий есть на хосте).
mkdir -p /root/.ssh mkdir -p /root/.ssh
KEY=/root/.ssh/id_tunnel KEY=/root/.ssh/id_tunnel
if [ -n "${TUNNEL_KEY_B64:-}" ]; then if [ -n "${TUNNEL_KEY_B64:-}" ]; then
+6 -6
View File
@@ -1,8 +1,8 @@
# Caddy на VPS (186.246.51.17) — единственная публичная точка входа. # Caddy на VPS (186.246.51.17) — единственная публичная точка входа.
# Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели: # Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели:
# #
# forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD # forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, ssh + restart) PROD
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST # forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV
# #
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к # Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
# приложению передаёт X-Forwarded-Proto=https / X-Forwarded-For / Host — # приложению передаёт X-Forwarded-Proto=https / X-Forwarded-For / Host —
@@ -38,10 +38,10 @@
-Server -Server
} }
# Content-Security-Policy подготовлена, но ВЫКЛЮЧЕНА до проверки на test-клоне: строгая # Content-Security-Policy подготовлена, но ВЫКЛЮЧЕНА до проверки: строгая политика легко
# политика легко ломает SPA (инлайновые стили Vite), Telegram-виджет входа (скрипт с # ломает SPA (инлайновые стили Vite), Telegram-виджет входа (скрипт с telegram.org + iframe
# telegram.org + iframe oauth.telegram.org) и EventSource (/api/events). Раскомментировать # oauth.telegram.org) и EventSource (/api/events). Раскомментировать после проверки,
# после проверки на forbidden-stars.ru, что вход и реал-тайм работают (#61). # что вход и реал-тайм работают (#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'" # 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) НЕ кэшируем. Иначе браузер отдаёт старый # HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый
+15 -10
View File
@@ -1,11 +1,11 @@
# VPS (186.246.51.17) — реверс-прокси Caddy + точка входа SSH-туннелей # VPS (186.246.51.17) — реверс-прокси Caddy + точка входа SSH-туннелей
Единственная публичная точка. На VPS: Caddy терминирует HTTPS твоими сертификатами Единственная публичная точка. На VPS: Caddy терминирует HTTPS твоими сертификатами
для двух доменов и проксирует трафик в SSH reverse-туннели от Pi (прод) и ПК (dev/test). для двух доменов и проксирует трафик в SSH reverse-туннели от Pi (прод) и ПК (dev).
``` ```
forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, постоянно) PROD
forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST forbidden-stars.ru → 127.0.0.1:9001 ← ПК (ssh из лаунчера, по требованию) DEV
``` ```
> Туннель `ssh -R` по умолчанию слушает на loopback VPS (127.0.0.1) — ровно туда смотрит > Туннель `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 install -m 600 -o tunnel -g tunnel /dev/null /home/tunnel/.ssh/authorized_keys
``` ```
Публичные ключи Pi и ПК добавишь в `/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. Сертификаты ## 5. Сертификаты
@@ -67,11 +69,11 @@ Caddy читает **PEM** (текст с `-----BEGIN CERTIFICATE-----`). Рас
Удобно собрать прямо на VPS — залей свои файлы и склей: Удобно собрать прямо на VPS — залей свои файлы и склей:
```bash ```bash
mkdir -p /etc/caddy/certs/forbidden-stars.ru /root/certs-tmp mkdir -p /etc/caddy/certs/forbidden-stars.ru /etc/caddy/certs/forbiddenstars.ru /root/certs-tmp
# с локальной машины (пример для домена forbidden-stars.ru): # с локальной машины (пример для домена forbidden-stars.ru; имена файлов — свои):
scp forbidden-stars.crt intermediate.crt forbidden-stars.key root@186.246.51.17:/root/certs-tmp/ scp forbidden-stars.crt intermediate.crt forbidden-stars.key root@186.246.51.17:/root/certs-tmp/
# на VPS — собрать fullchain (leaf + промежуточный) и положить ключ: # на 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 > /etc/caddy/certs/forbidden-stars.ru/fullchain.pem
cp /root/certs-tmp/forbidden-stars.key /etc/caddy/certs/forbidden-stars.ru/privkey.pem cp /root/certs-tmp/forbidden-stars.key /etc/caddy/certs/forbidden-stars.ru/privkey.pem
# то же для forbiddenstars.ru (свои crt/intermediate/key), затем права # то же для 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 caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy systemctl reload caddy
``` ```
> Заглушка живёт в сниппете `(offline)` Caddyfile: при ответе апстрима 502/503/504 > Заглушка живёт в сниппете `(edge)` Caddyfile (там же security-заголовки и `no-store` для
> Caddy отдаёт `/etc/caddy/maintenance/maintenance.html` со статусом 503 и `Cache-Control: 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 не нужен, > Правки текста/вида — в `deploy/vps/maintenance.html`, затем повтори `scp` (reload Caddy не нужен,
> файл читается на каждый запрос). > файл читается на каждый запрос).
## 7. Проверка ## 7. Проверка
1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev/test на ПК (`run.ps1` при `LOCAL_PUBLIC=vps`). 1. Подними туннель прода на Pi (см. `deploy/pi/README.md`) и/или dev на ПК
(лаунчер `run.ps1` при `LOCAL_PUBLIC=vps`).
2. Открой `https://forbiddenstars.ru` и `https://forbidden-stars.ru`. 2. Открой `https://forbiddenstars.ru` и `https://forbidden-stars.ru`.
3. Пока соответствующий туннель не поднят — Caddy отдаёт страницу-заглушку «Технические шоколадки» 3. Пока соответствующий туннель не поднят — Caddy отдаёт страницу-заглушку «Технические шоколадки»
(HTTP 503), это ожидаемо. (HTTP 503), это ожидаемо.
+1 -1
View File
@@ -5,7 +5,7 @@
# #
# Отличия от docker-compose.yml (прод на Pi): # Отличия от docker-compose.yml (прод на Pi):
# • локальный образ (сборка x86 на ПК), НЕ из реестра и НЕ пушится; # • локальный образ (сборка x86 на ПК), НЕ из реестра и НЕ пушится;
# • отдельный проект (name) и свои тома — не конфликтует с dev/test на этом ПК. # • отдельный проект (name) и свои тома — не конфликтует с dev на этом ПК.
# Всё остальное — как у прода (APP_ENV=production, туннель на 9000, лимиты, healthcheck). # Всё остальное — как у прода (APP_ENV=production, туннель на 9000, лимиты, healthcheck).
# #
# Запуск: docker compose -f docker-compose.temp.yml up -d --build # Запуск: docker compose -f docker-compose.temp.yml up -d --build
-86
View File
@@ -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:
+12 -6
View File
@@ -21,8 +21,8 @@
services: services:
app: app:
build: . build: .
# Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.sh) и тянется # Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.ps1, в Linux —
# на Pi через `docker compose pull`. build: оставлен как локальный фолбэк (сборка на Pi). # .sh) и тянется на Pi. build: нужен только этой сборке на ПК — на Pi не используется.
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars:${IMAGE_TAG:-latest} image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars:${IMAGE_TAG:-latest}
pull_policy: always # на Pi всегда тянем образ из реестра (без сборки) pull_policy: always # на Pi всегда тянем образ из реестра (без сборки)
restart: unless-stopped restart: unless-stopped
@@ -42,7 +42,9 @@ services:
timeout: 5s timeout: 5s
retries: 3 retries: 3
start_period: 40s start_period: 40s
mem_limit: ${APP_MEM_LIMIT:-512m} # лимиты под Pi; переопределяются в .env # Лимиты под Pi; переопределяются в .env. mem_limit действует, только если на Pi включён
# memory cgroup — иначе Docker его молча игнорирует (deploy/pi/README.md, раздел 1).
mem_limit: ${APP_MEM_LIMIT:-512m}
cpus: ${APP_CPUS:-1.5} cpus: ${APP_CPUS:-1.5}
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
@@ -75,8 +77,8 @@ services:
# Бэкапы (restic): снимки по расписанию в локальный репозиторий (том backup-data) и на VPS # Бэкапы (restic): снимки по расписанию в локальный репозиторий (том backup-data) и на VPS
# (SFTP, если задан BACKUP_VPS_HOST). От app не зависит и app не мешает. Переменные — # (SFTP, если задан BACKUP_VPS_HOST). От app не зависит и app не мешает. Переменные —
# только BACKUP_* (секреты приложения сюда не передаются). Всё про настройку и # только BACKUP_* (секреты приложения сюда не передаются; исключение — токен бота для
# восстановление — deploy/backup/README.md. # отчётов в Telegram). Всё про настройку и восстановление — deploy/backup/README.md.
backup: backup:
build: ./deploy/backup build: ./deploy/backup
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars-backup:${IMAGE_TAG:-latest} image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars-backup:${IMAGE_TAG:-latest}
@@ -84,7 +86,7 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
BACKUP_PASSWORD: ${BACKUP_PASSWORD:-} # пароль шифрования; пусто = бэкапы отключены BACKUP_PASSWORD: ${BACKUP_PASSWORD:-} # пароль шифрования; пусто = бэкапы отключены
BACKUP_HOSTNAME: fs-prod # имя хоста в снимках (у тест-клона — fs-test) BACKUP_HOSTNAME: fs-prod # имя хоста в снимках
BACKUP_SCHEDULE: ${BACKUP_SCHEDULE:-0 4 * * *} BACKUP_SCHEDULE: ${BACKUP_SCHEDULE:-0 4 * * *}
BACKUP_VERIFY_SCHEDULE: ${BACKUP_VERIFY_SCHEDULE:-30 5 * * 0} BACKUP_VERIFY_SCHEDULE: ${BACKUP_VERIFY_SCHEDULE:-30 5 * * 0}
BACKUP_KEEP_DAILY: ${BACKUP_KEEP_DAILY:-14} BACKUP_KEEP_DAILY: ${BACKUP_KEEP_DAILY:-14}
@@ -97,6 +99,10 @@ services:
BACKUP_VPS_PORT: ${BACKUP_VPS_PORT:-22} BACKUP_VPS_PORT: ${BACKUP_VPS_PORT:-22}
BACKUP_VPS_DIR: ${BACKUP_VPS_DIR:-/srv/fs-backups/restic} BACKUP_VPS_DIR: ${BACKUP_VPS_DIR:-/srv/fs-backups/restic}
BACKUP_SSH_KEY_B64: ${BACKUP_SSH_KEY_B64:-} BACKUP_SSH_KEY_B64: ${BACKUP_SSH_KEY_B64:-}
# Отчёты и бот в Telegram (#83). Единственное исключение из «секреты приложения сюда
# не передаются»: по умолчанию — токен бота приложения (тот же ForbidenStarsBot).
BACKUP_TELEGRAM_BOT_TOKEN: ${BACKUP_TELEGRAM_BOT_TOKEN:-${TELEGRAM_BOT_TOKEN:-}}
BACKUP_TELEGRAM_CHAT_ID: ${BACKUP_TELEGRAM_CHAT_ID:-}
TZ: ${BACKUP_TZ:-Europe/Moscow} TZ: ${BACKUP_TZ:-Europe/Moscow}
volumes: volumes:
- db-data:/fs-db # живая БД (снимается консистентно) - db-data:/fs-db # живая БД (снимается консистентно)
+757
View File
@@ -0,0 +1,757 @@
# Новая рейтинговая система
> **Статус:** утверждено (#22), реализовано в #23 (`backend/app/services/scoring.py`).
> Решения владельца по открытым вопросам (2026-09-14) внесены — [раздел 9](#9-решения-владельца).
> **Приложение:** [`simulate.py`](simulate.py) — эталонная реализация формул, все примеры
> этого документа (с `assert`), симуляция и перебор коэффициентов. Только стандартная
> библиотека Python, вывод воспроизводим:
>
> ```bash
> python docs/rating/simulate.py # примеры + сравнение систем (≈1.5 мин)
> python docs/rating/simulate.py --grid # примеры + подбор коэффициентов (≈7 мин)
> ```
## Коротко
Сейчас рейтинг — среднее очков за место. Он не видит ни силы соперников, ни хода партии,
а первое место в дуэли и за столом на пятерых стоит одинаково.
Предлагается **многопользовательский Elo с множителем отрыва**:
- **Разбор на дуэли.** Партия раскладывается на пары игроков. Каждая пара — дуэль, исход
которой сравнивается с ожидаемым по разнице рейтингов: победа над сильным даёт больше,
чем над слабым.
- **Множитель отрыва.** Размер изменения зависит от того, *как* партия сыграна: на каком
раунде закончилась, какой отрыв по целям и мирам, какой тип победы. Размер стола тоже
влияет, но слабее темпа.
- **Шкала — классический Elo.** Старт 1500, разница 400 пунктов — шансы 10:1. Пример из
задачи «60 против 40» в ней — 1600 против 1400.
- **Выбывшие между собой не сравниваются.** Все они проиграли, а миров у них нет: пара двух
выбывших в расчёт не входит. Сила соперников при этом важна, как и прежде (4.4).
- **Монотонность.** Единоличный победитель и выбывшие всегда движутся в свою сторону: первый
не теряет рейтинг, вторые его не получают. Внутри ничьей невыбывших — за 1-е или
последнее место — пара может сдвинуть рейтинг в любую сторону (4.9).
- **Старая история.** Пересчитывается по тем же формулам: у партий без новых полей
признаки берутся нейтральными.
На синтетической лиге система лучше текущей и чистого 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.644 (раздел 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.774. |
## 2. Исходные данные: правила игры
По справочнику (стр. 8, 11, 16) и уточнениям владельца в #22.
**Победа.** Игрок, собравший маркеры целей в количестве, равном числу игроков N, побеждает.
Цели собираются в фазе обновления, поэтому партия заканчивается на границе раунда.
Если к концу последнего раунда никто не набрал N, побеждает тот, у кого больше целей.
**Тай-брейки** (от менее близкой партии к более близкой) — это и есть типы победы
в приложении:
| Тип (`win_reason`) | Когда | Близость партии |
|---|---|---|
| `objectives` — по целям | больше всех целей | обычная |
| `worlds` — по мирам | цели поровну, больше дружественных миров | близкая |
| `plastic` — по пластику | цели и миры поровну, больше отрядов на поле | очень близкая |
| `resources` — по ресурсам | всё выше поровну; **домашнее правило** | самая близкая |
| `last_standing` — последний выживший | все соперники устранены (нет миров); **новая причина** (решение владельца, раздел 9) | разгром |
Если поровну вообще всё, победа общая (в приложении — ничья за 1-е место).
**Лимит раундов `R_max`.** По правилам 8. **Домашнее правило:** за столом на 5–6 игроков
играется 9 раундов. Правило включается галочкой в настройках группы.
**Миры.** В тексте #22 они названы «системами». По справочнику *система* — это целый тайл
из четырёх областей, а область с планетой — *мир*. Дальше используется термин справочника.
Размер поля и «честная доля» миров на игрока:
| N | Поле | Тайлов | Миров на поле (×2.2) | Миров на игрока `W(N)/N` |
|---|---|---|---|---|
| 2 | 2×3 | 6 | 13.2 | 6.60 |
| 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₀ = 1500**, масштаб **D = 400**: разница 400 пунктов означает шансы 10:1.
Это классическая шкала Elo (решение владельца, раздел 9).
С нынешним топом (около 50) она не совпадает. Для сопоставления чисел в этом документе
используется соответствие `R = 10·score + 1000`: 50 ↔ 1500, 60 ↔ 1600. Пример «60 против 40»
из #22 — это 1600 против 1400. Формулы от выбора шкалы не зависят. Если пересчитать рейтинги
по этому соответствию, а `D` и `K` умножить на 10, ожидания, порядок игроков и качество
прогноза не изменятся — в 10 раз вырастут только изменения рейтинга и его разброс.
`simulate.py` это проверяет: на одних и тех же сезонах обе шкалы дают одинаковые точность,
Brier и ρ.
### 4.2. Ожидаемый результат пары
```math
E_{ab} = \frac{1}{1 + 10^{(R_b - R_a)/D}}
```
`E_ab` — вероятность, что `a` окажется выше `b`. При 1600 против 1400 — 0.760, при равных — 0.500.
### 4.3. Фактический результат пары
`S_ab = 1`, если `a` занял место выше `b`; `0.5`, если места равны; `0` — если ниже.
Выбывшие делят последнее место, как и сейчас (`match_service._resolve_finish_places`),
но между собой не сравниваются (4.4).
### 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)`.
**Выбывшие между собой не сравниваются** (решение владельца, #91): если `i` и `j` оба
выбыли, их пара в сумму не входит — ни `S − E`, ни множитель отрыва. В этой партии они
все проиграли, то есть оказались одинаково слабы, а миров у выбывших нет. Пары выбывшего
с невыбывшими, в том числе с победителем, считаются как обычно: сила соперников важна.
Нормировка на `N − 1` не меняется, поэтому при равных рейтингах результат прежний — такая
пара и раньше давала `S − E = 0`. Уходит только перекос, при котором слабый выбывший
получал рейтинг за счёт сильных выбывших.
### 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 = 64`,
к 20-й партии K линейно спускается до `K_min = 16`. Так новичок быстро находит свой
уровень, а рейтинг опытного игрока не скачет от одной партии.
Эта схема заменяет нынешние «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` — значит,
рейтинг растёт. Последнее место без ничьей всегда уменьшает рейтинг. Выбывший — тоже:
его пары с выбывшими не считаются, а каждому невыбывшему он проиграл. Внутри ничьей
невыбывших `S = 0.5`, и знак `S − E` зависит от рейтингов: сильный игрок, поделивший
1-е место со слабым, может потерять.
- **Сумма-ноль.** `M_ab = M_ba`, поэтому при равных K сумма изменений за партию равна нулю
и рейтинг не раздувается. Когда K разные (новичок и ветеран), сумма не нулевая —
это сделано намеренно (пример 7). В симуляции среднее по лиге за 300 партий сдвигается
не больше чем на 0.8 пункта, в сценарии «рост» — на −7 при разбросе силы игроков ±140.
- **Ограниченность.** Изменение за партию не больше `K·G·2.0`: 32 у ветерана в дуэли,
128 у новичка в дуэли, 192 у новичка за столом на шестерых. Это теоретические пределы
для разгромной победы, которой почти никто не ждал (`E ≈ 0`); против равных — вдвое меньше.
- **Детерминизм.** Рейтинг — функция упорядоченной истории партий. Пересчёт с нуля всегда
даёт тот же результат.
### 4.10. Коэффициенты
| Параметр | Значение | Откуда |
|---|---|---|
| `R₀`, `D` | 1500, 400 | шкала (4.1), решение владельца |
| `K_max`, `K_min`, `n_K` | 64, 16, 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-м раунде +11.2, на 8-м +5.5 — **вдвое** (пример 2) |
| 2. Размер стола, но слабее темпа | `G(N)`, вес 0.5 | 1-е место: дуэль +8.0, пятеро +11.0, шестеро +12.0 — **до ×1.5**, меньше, чем ×2 у темпа (примеры 3, 6) |
| 3. Разница рейтингов с соперником | ожидание `E` | 1600 побеждает 1400: +3.8; 1400 побеждает 1600: +12.2 — **втрое** больше (пример 1) |
| 4. Цели и миры на конец партии | признаки `o`, `w` | стол на 4: пары с выбывшими весят 1.25–1.5, пара лидеров — 0.7 (пример 5) |
| 5. Тип победы | близость `c` | без деталей: по целям +8.0 → по мирам +6.8 → по пластику +5.6 → по ресурсам +4.8 (пример 4) |
| Веса параметров (из треб. 2) | единый множитель `M` с весами | весь диапазон по отрыву: от +3.4 (самая близкая партия) до +16.0 (разгром) — **×4.7** (пример 4) |
## 6. Примеры расчётов
Все игроки опытные (40 партий, `K = 16`), если не сказано иное. Числа совпадают
с выводом `simulate.py` — скрипт проверяет их через `assert`. Промежуточные значения
здесь округлены. В приложении рейтинг показывается целым числом, а в расчёте хранится
без округления (раздел 8).
### Пример 1. Дуэль 1600 против 1400 (треб. 3)
Партия из старой истории: только места и тип `objectives`, так что `M = 1`.
- `E(A выше B) = 1 / (1 + 10^(−200/400)) = 1 / (1 + 0.316) = 0.760`.
- **1a. Побеждает сильный A:** `ΔR_A = 16 · 1 · (1 − 0.760) = +3.84`, у B −3.84.
- **1b. Побеждает слабый B:** `ΔR_B = 16 · 1 · (1 − 0.240) = +12.16`, у A −12.16.
Неожиданная победа приносит втрое больше ожидаемой.
### Пример 2. Быстрая и медленная победа (треб. 1)
Равные (1500 и 1500), дуэль на поле 2×3, победа по целям 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/6.6 = 0.152` → `0.5·(0.152 − 0.5)` | −0.174 | −0.174 |
| `A = M` | 1.397 | 0.683 |
| `ΔR_A = 16 · M · (1 − 0.5)` | **+11.18** | **+5.46** |
### Пример 3. Размер стола (треб. 2)
Все по 1500, партии без деталей (`M = 1`).
- **3a. Дуэль:** `ΔR_1 = 16 · 1 · 0.5 = +8.00`.
- **3b. Стол на 5:** `G(5) = 1 + 0.5·3/4 = 1.375`, множитель перед суммой — `16·1.375/4 = 5.5`.
| Место | Σ (S − E) по 4 соперникам | ΔR | Сейчас (League Points) |
|---|---|---|---|
| 1 | 4·0.5 = 2.0 | **+11.00** | 1.00 |
| 2 | −0.5 + 3·0.5 = 1.0 | +5.50 | 0.75 |
| 3 | 0 | 0.00 | 0.50 |
| 4 | −1.0 | −5.50 | 0.25 |
| 5 | −2.0 | −11.00 | 0.00 |
### Пример 4. Тип победы и близость партии (треб. 5)
Равные, дуэль.
| Вариант | τ | o | w | A | c | M | ΔR победителя |
|---|---|---|---|---|---|---|---|
| по целям, без деталей (= 3a) | μ | μ | μ | 1.000 | 1.00 | 1.000 | +8.00 |
| 4a: по мирам, без деталей | μ | μ | μ | 1.000 | 0.85 | 0.850 | +6.80 |
| 4b: по пластику, без деталей | μ | μ | μ | 1.000 | 0.70 | 0.700 | +5.60 |
| 4c: по ресурсам, без деталей | μ | μ | μ | 1.000 | 0.60 | 0.600 | +4.80 |
| 4d: по мирам на 8-м раунде, цели 2:2, миры 6:5 | 0 | 0 | 0.152 | 0.433 → **0.5** | 0.85 | 0.425 | **+3.40** |
| 4e: разгром — 3-й раунд, цели 2:0, миры 8:2 | 0.714 | 1 | 0.909 | 2.026 → **2.0** | 1.00 | 2.000 | **+16.00** |
В 4d и 4e сработала страховка `clamp`.
### Пример 5. Стол на 4 с выбывшими (треб. 4)
Партия закончилась на 7-м раунде по целям. `G(4) = 1.25`, множитель перед суммой —
`16·1.25/3 = 6.67`, честная доля миров — 6.6.
| Игрок | Рейтинг | Место | Цели | Миры |
|---|---|---|---|---|
| A | 1550 | 1 | 4 | 8 |
| B | 1500 | 2 | 3 | 7 |
| C | 1480 | 3 (выбыл) | 1 | 0 |
| D | 1450 | 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 | — | — | — | — | оба выбыли — пара не считается (4.4) | 0 |
- `ΔR_A = 6.67 · (0.300 + 0.551 + 0.540)` = **+9.27**
- `ΔR_B = 6.67 · (−0.300 + 0.589 + 0.589)` = **+5.85**
- `ΔR_C = 6.67 · (−0.551 − 0.589)` = **−7.60**
- `ΔR_D = 6.67 · (−0.540 − 0.589)` = **−7.53**
Итого: A и B близки друг к другу по целям и мирам, поэтому эта пара весит 0.7. Отрыв обоих
от выбывших огромный — эти пары весят 1.25–1.5. C и D между собой не сравниваются: оба
проиграли всем невыбывшим. C теряет чуть больше, потому что от более сильного ждали большего.
### Пример 6. Стол на 6 и хоумрул 9 раундов
Все по 1500, партия закончилась **на 8-м раунде**, других деталей нет.
`G(6) = 1.5`, множитель перед суммой — `16·1.5/5 = 4.8`.
| | 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 | +12.00, +7.20, +2.40, −2.40, −7.20, −12.00 | +10.29, +7.54, +2.74, −2.06, −6.86, −11.66 |
Конец на 8-м раунде при лимите 9 — это «предпоследний раунд», то есть типичная партия.
При лимите 8 — затянутая партия, и победа весит меньше. Поэтому лимит раундов снимается
в партию при её создании (раздел 8).
### Пример 7. Новичок против ветерана
Оба по 1500; A — новичок (0 партий, `K = 64`), B — 40 партий (`K = 16`). A побеждает.
`ΔR_A = 64 · 0.5` = **+32**, `ΔR_B = 16 · (−0.5)` = **−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)`; в шкале рейтинга ≈ ±140 |
| Производительность в партии | `θ + 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+ партиями (волатильность).
У League Points рейтинг в шкале 0–100, его |ΔR| приведён к шкале 1500 умножением на 10
(соответствие из 4.1).
Сравнение идёт на одних и тех же сезонах (парные разности). Коэффициенты подбирались
на других сезонах (7.4), так что это проверка вне выборки подбора.
### 7.3. Результаты: 200 сезонов на сценарий
«Без новых полей» — предложенная система на той же истории, но без раунда, целей и миров:
так будет считаться история, накопленная до #23. Строки предложенной системы пересчитаны
с правилом «выбывшие между собой не сравниваются» (#91). Оно сдвинуло метрики лишь
в третьем знаке, у остальных систем цифры прежние.
**Сигнал** (потолок точности 0.6843)
| Система | Точность | Brier | ρ | ρ@5 | ρ@10 | ρ@20 | RMSE | Наклон | \|ΔR\| |
|---|---|---|---|---|---|---|---|---|---|
| League Points (сейчас) | 0.6683 | — | 0.913 | 0.660 | 0.769 | 0.854 | — | — | 6.91 |
| Elo, чистый | 0.6691 | 0.2089 | 0.911 | 0.656 | 0.770 | 0.852 | 47.4 | 0.81 | 5.24 |
| **Предложенная** | **0.6721** | **0.2077** | **0.929** | **0.712** | **0.807** | **0.881** | **41.9** | **0.95** | 5.95 |
| Предложенная, без новых полей | 0.6701 | 0.2085 | 0.916 | 0.677 | 0.783 | 0.864 | 47.2 | 0.79 | 5.90 |
**Клубы** (потолок 0.6308)
| Система | Точность | Brier | ρ | ρ@5 | ρ@10 | ρ@20 | RMSE | Наклон | \|ΔR\| |
|---|---|---|---|---|---|---|---|---|---|
| League Points (сейчас) | 0.6045 | — | 0.525 | 0.303 | 0.391 | 0.463 | — | — | 8.06 |
| Elo, чистый | 0.6063 | 0.2341 | 0.632 | 0.326 | 0.440 | 0.535 | 102.6 | 0.40 | 5.71 |
| **Предложенная** | **0.6101** | 0.2336 | **0.645** | **0.356** | **0.463** | **0.555** | **100.3** | **0.47** | 6.53 |
| Предложенная, без новых полей | 0.6073 | **0.2332** | 0.633 | 0.336 | 0.440 | 0.535 | 103.3 | 0.38 | 6.41 |
**Рост** (потолок 0.6864)
| Система | Точность | Brier | ρ | ρ@5 | ρ@10 | ρ@20 | RMSE | Наклон | \|ΔR\| |
|---|---|---|---|---|---|---|---|---|---|
| League Points (сейчас) | 0.6688 | — | 0.915 | 0.631 | 0.732 | 0.824 | — | — | 6.91 |
| Elo, чистый | 0.6687 | 0.2086 | 0.916 | 0.632 | 0.733 | 0.825 | 48.0 | 0.81 | 5.25 |
| **Предложенная** | **0.6726** | **0.2076** | **0.929** | **0.685** | **0.775** | **0.850** | **42.7** | **0.95** | 5.96 |
| Предложенная, без новых полей | 0.6697 | 0.2081 | 0.922 | 0.651 | 0.750 | 0.835 | 47.5 | 0.79 | 5.91 |
**Шум** (потолок 0.6894)
| Система | Точность | Brier | ρ | ρ@5 | ρ@10 | ρ@20 | RMSE | Наклон | \|ΔR\| |
|---|---|---|---|---|---|---|---|---|---|
| League Points (сейчас) | 0.6737 | — | 0.916 | 0.656 | 0.761 | 0.848 | — | — | 6.89 |
| Elo, чистый | 0.6739 | **0.2070** | 0.917 | 0.659 | 0.765 | 0.851 | **46.1** | **0.82** | 5.23 |
| Предложенная | 0.6728 | 0.2076 | 0.908 | 0.643 | 0.750 | 0.844 | 48.5 | 0.81 | 5.91 |
| Предложенная, без новых полей | **0.6744** | **0.2070** | **0.919** | **0.667** | **0.776** | **0.856** | 47.3 | 0.78 | 5.88 |
Парные разности (среднее ± стандартная ошибка по 200 сезонам):
| Сценарий | Brier: предложенная − чистый Elo | Точность: предложенная − чистый Elo | Точность: предложенная − сейчас |
|---|---|---|---|
| сигнал | −0.0011 ± 0.0001 | +0.30 ± 0.06 п.п. | +0.38 ± 0.07 п.п. |
| клубы | −0.0005 ± 0.0002 | +0.38 ± 0.08 п.п. | +0.56 ± 0.10 п.п. |
| рост | −0.0010 ± 0.0001 | +0.39 ± 0.06 п.п. | +0.38 ± 0.06 п.п. |
| шум | +0.0006 ± 0.0001 | −0.12 ± 0.06 п.п. | −0.10 ± 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 — примерно граница шума.
Перебор выполнен до правила «выбывшие между собой не сравниваются» (#91) и не
переигрывался: правило сдвигает метрики лишь в третьем знаке (7.3).
**Этап 1. K чистого Elo** (все четыре сценария). Спуск K за 20 партий лучше, чем за 10.
Выгоден высокий K новичка и низкий K ветерана.
| `K_max` \ `K_min` (спуск за 20 партий) | 16 | 24 | 32 |
|---|---|---|---|
| 48 | 0.21697 | 0.21708 | 0.21775 |
| 64 | 0.21649 | 0.21675 | 0.21751 |
| 96 | **0.21639** | 0.21669 | 0.21747 |
| 128 | 0.21682 | 0.21703 | 0.21775 |
| 160 | 0.21748 | 0.21755 | 0.21819 |
Лучший вариант со спуском за 10 партий — 0.21676 (96 / 24). Для «чистого Elo»
в сравнении 7.3 взяты K = 96 / 16 / 20. Чистый Elo не использует миры, поэтому поле 2×3
на этот этап не повлияло.
**Этап 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.21797 | 0.20991 | 0.25 | 0 | 0 | 0.5 | да |
| 0.21801 | 0.20985 | 0.5 | 0 | 0 | 0.5 | да |
| 0.21802 | 0.21009 | 0 | 0 | 0 | 0.5 | да |
| 0.21804 | 0.21005 | 0.25 | 0.5 | 0 | 0.5 | да |
| 0.21804 | 0.20994 | 0.25 | 0 | 0 | 0.5 | нет |
| … | | | | | | |
| 0.21995 | | | | | | худшая комбинация |
Лучшие комбинации выигрывают у отсутствия множителя около 0.0005, худшая проигрывает
0.0015. Оптимум очень пологий: первые десять вариантов умещаются в 0.0001.
Перебор оставляет **один** признак отрыва из трёх. Это ожидаемо: в генераторе раунд, цели
и миры выводятся из одного и того же отрыва производительности, второй признак не добавляет
информации и лишь увеличивает разброс.
Реальная игра так не устроена: в ней ранний конец, счёт целей и контроль миров — разные
стороны партии. Поэтому веса признаков заданы требованиями #22 в пределах безопасной
зоны (этап 4), а не взяты из вершины перебора.
**Этап 3. K для предложенных весов.** Множитель в среднем чуть больше 1, поэтому K нужен
меньше, чем у чистого Elo. Спуск за 20 партий:
| `K_max` | `K_min` | Brier (сигнальные) | Brier «шум» | Brier с поправкой на автокорреляцию |
|---|---|---|---|---|
| 48 | 12 | **0.21759** | 0.21063 | 0.21755 |
| 48 | 16 | 0.21778 | 0.21049 | 0.21773 |
| 64 | 12 | 0.21779 | 0.21026 | 0.21771 |
| **64** | **16** | 0.21797 | **0.21022** | 0.21788 |
| 80 | 16 | 0.21838 | 0.21028 | 0.21826 |
- **Выбор 64 / 16.** Против чистого Elo он даёт −0.00049 на сигнальных сценариях и
+0.00005 на «шуме». Вариант 48 / 12 выигрывает больше (−0.00087), но на «шуме»
проигрывает +0.00046. Выбран вариант, который не теряет, если гипотеза ТЗ о значении
отрыва не подтвердится.
- **Поправка на автокорреляцию** (FiveThirtyEight: фаворит закономерно побеждает с большим
отрывом, и без поправки его рейтинг раздувается) даёт около 0.0001. В формулу она не
включена: лишняя сложность для ручного расчёта при нулевом эффекте.
**Этап 4. Чувствительность.** Меняется один параметр, остальные — как в предложении
(Brier 0.21797, «шум» 0.21022). ρ@10 здесь — среднее по трём сценариям, включая «клубы»,
поэтому оно ниже, чем в 7.3.
| Параметр | Значение | Brier (сигнальные) | ρ@10 | Brier «шум» |
|---|---|---|---|---|
| `w_N` | 0 / 0.25 / **0.5** / 1.0 | 0.21803 / 0.21796 / **0.21797** / 0.21819 | 0.663 / 0.668 / **0.674** / 0.680 | 0.21072 / 0.21041 / **0.21022** / 0.21011 |
| `w_τ` | 0 / 0.25 / 0.5 / **1.0** / 2.0 | 0.21771 / 0.21775 / 0.21781 / **0.21797** / 0.21826 | 0.672 / 0.672 / 0.673 / **0.674** / 0.667 | 0.20988 / 0.20995 / 0.21003 / **0.21022** / 0.21050 |
| `w_o` | 0 / 0.25 / **0.5** / 1.0 / 2.0 | 0.21767 / 0.21778 / **0.21797** / 0.21838 / 0.21892 | 0.666 / 0.668 / **0.674** / 0.674 / 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.21785 / **0.21797** / 0.21827 / 0.21873 | 0.667 / 0.671 / **0.674** / 0.673 / 0.669 | 0.21003 / 0.21011 / **0.21022** / 0.21037 / 0.21054 |
| `c` | нет / **предложенная** / вдвое сильнее | 0.21799 / **0.21797** / 0.21799 | 0.673 / **0.674** / 0.672 | 0.21016 / **0.21022** / 0.21031 |
Итог:
- Веса в диапазоне 0–1 безопасны: изменение вдвое сдвигает Brier не больше чем на 0.00041
(`w_o` 0.5 → 1.0). Вред начинается с 2.0 — поэтому ни один вес не выше 1.
- Вес размера стола полезен: от 0 до 0.5 растёт ρ@10 и падает Brier на «шуме».
- Близость по типу победы в симуляции нейтральна (±0.00002).
### 7.5. Что симуляция доказывает и что нет
- **Доказывает:** формулы корректны и устойчивы, не раздувают рейтинг, быстро сходятся,
правильно «сшивают» группы разной силы. Выбранные веса лежат в пологой области: изменение
любого веса вдвое в любую сторону сдвигает Brier не больше чем на 0.00041. Если признаки
партии окажутся бесполезны, потеря мала.
- **Не доказывает:**
- что в *реальных* партиях 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 | новая причина победы (раздел 9) |
`R_max` в партии не хранится, а вычисляется: `9`, если `nine_rounds_rule` и `N ≥ 5`, иначе `8`.
Число участников может поменяться при правке партии, а снимок правила — нет.
Миграция идемпотентна, как `0003`: ALTER только при отсутствии столбца, `render_as_batch`
для CHECK. Новые столбцы задним числом не заполняются: NULL — это «нет данных», и формулы
его учитывают (4.8).
**Бэкфилл нужен только для `last_standing`.** У завершённых партий, где невыбывший участник
ровно один, миграция ставит `win_reason = last_standing`. Тогда старые партии не нарушают
правило из раздела «Ввод» при правке, а в рейтинге считаются так же, как новые: отрыв
победителя по целям = 1 (4.7). Бэкфилл идемпотентен: повторный запуск ничего не меняет.
### Ввод
- Форма завершения (`MatchDetailPage`, `match_service.finish_match`, черновик
`MatchFinishDraft`) и админская правка (`AdminMatchEdit`): раунд окончания и у каждого
участника цели и миры. Поля необязательные — пропуск лучше выдумки.
- Серверная валидация — только диапазоны и явные противоречия: у выбывшего миры = 0;
раунд ≤ `R_max`. Подсказки о согласованности (тип `worlds` при неравных целях лидеров
и т.п.) лучше показывать предупреждением, а не отказом.
- Настройки группы: галочка рядом с дополнениями (`PUT /groups/{id}/expansions` или
отдельный `PATCH`).
- **Причина `last_standing`** (решение владельца):
- правило — «невыбывший участник ровно один» ⇔ `win_reason = last_standing`;
- в форме завершения и в админской правке причина проставляется автоматически, как только
все участники, кроме одного, отмечены выбывшими. Выбор причины при этом заблокирован;
- вернули второго невыбывшего — причина сбрасывается, её нужно выбрать заново;
- в выпадающем списке причин `last_standing` нет: выбрать её вручную нельзя;
- сервер проверяет то же правило в `finish_match` и при правке результатов: несовпадение —
ошибка валидации. Черновик формы (`PUT /matches/{id}/finish-draft`) правило не проверяет,
он хранит незаконченный ввод.
### Отображение
- Рейтинг показывается **целым числом**; в расчёте значения хранятся без округления, иначе
ошибка округления накапливается по цепочке партий.
- Общий топ (`OverallStatsPage.tsx`): столбец «Поб» и сортировка по победам убираются, чтобы
четырёхзначный рейтинг поместился в строку; «Очки» переименовываются в «Рейтинг».
Для единообразия — «Очки (рейтинг)» в `ProfileStatsCard.tsx` и заголовок «Очки игроков
(рейтинг)» в `HelpPage.tsx`.
- Подпись причины `last_standing` в карточке партии и истории — «последний выживший».
### Расчёт
- Рейтинг — функция упорядоченной истории, поэтому он **пересчитывается проигрыванием**
завершённых партий по порядку (`played_at`, `finished_at`, `id`), а не агрегатом SQL.
Данных мало: сотни партий, микросекунды на пару. Существующий принцип «считается вживую»
сохраняется, кэш можно ввести позже с инвалидацией по уже существующим SSE-событиям.
- **Одна цепочка** (решение владельца 2026-09-15, #80): рейтинг у игрока один — по всем
партиям приложения, K — по всем его партиям. Отдельного группового рейтинга нет:
на странице группы игры, победы, винрейт и среднее место считаются по партиям группы,
а рейтинг и статус «Новичок» — общие. Первая версия реализации (#23) держала две
цепочки, общую и групповую, — это оказалось неинтуитивно (раздел 9).
- Правка или удаление прошлой партии автоматически меняет всё после неё: при пересчёте
с нуля отдельной логики не нужно.
- Эталон — `rate_match` в `simulate.py`. Примеры из раздела 6 стоит перенести в тесты
бэкенда как есть.
- Смежные метрики на старых очках места:
- «лучшая партия» в профиле (`user_match_list(best_only)`) → партия с наибольшим ΔR;
- «лучшая/худшая фракция» → средний `S − E` на фракции: насколько игрок на ней
выступает выше ожидания, без привязки к рейтингу;
- `win_rate`, `avg_place`, «форма» — без изменений.
### Что ломается для пользователей
- Числа рейтинга у всех меняются: шкала другая (около 1500 вместо около 50), и это другая
величина — не «средний процент очков», а сила относительно соперников.
- Порядок в топе может измениться — в первую очередь у тех, кто играл в основном со слабыми
или сильными соперниками.
- Рейтинг новичка после одной партии меняется заметно сильнее, чем раньше: +32 за обычную
победу над равным, до +64 за разгром (теоретический предел — 128).
- В общем топе пропадает столбец побед (win rate остаётся).
- **Предложение:** разовое уведомление всем игрокам и короткое пояснение «как считается
рейтинг» на странице топа.
### Калибровка после внедрения
Когда наберётся ~100 партий с заполненными раундом, целями и мирами:
- типичные значения `μ` заменить средними по реальным партиям;
- повторить перебор весов из `simulate.py` на реальной истории: критерий — Brier прогноза
следующей партии;
- проверить главное допущение: есть ли у ранних побед и большого отрыва связь с последующими
результатами игроков.
Коэффициенты — константы в одном модуле (как сейчас `scoring.py`): калибровка — это правка
констант и пересчёт, без миграций.
### Справка
Формула текущего рейтинга и пороги продублированы текстом на странице справки
(`frontend/src/pages/HelpPage.tsx`). При реализации #23 её нужно переписать под новую
систему: шкала, от чего зависит изменение рейтинга, `MIN_GAMES`, причина `last_standing`.
## 9. Решения владельца
Первая версия документа выносила шесть вопросов на решение. Ответы владельца —
[комментарий к PR #65](https://gitea.arseniev.info/NotBigGhost/ForbiddenStarsApp/pulls/65#issuecomment-3466)
(2026-09-14). Там же и в [#22](https://gitea.arseniev.info/NotBigGhost/ForbiddenStarsApp/issues/22#issuecomment-3380)
уточнено поле дуэли: 2×3, а не 2×2 — исправлено в разделе 2, примерах и симуляции.
| # | Вопрос | Решение | Что изменилось в документе |
|---|---|---|---|
| 1 | Шкала отображения: «Elo/10» (старт 50) или классические 1500 | **1500.** В общем топе убрать столбец побед, «Очки» переименовать в «Рейтинг» | 4.1 и все числа примеров и таблиц; раздел 8, «Отображение» |
| 2 | Коэффициенты близости по типам победы (1 / 0.85 / 0.7 / 0.6 / 1) | **Согласованы** | 4.10 — отмечены как утверждённые |
| 3 | Новая причина победы `last_standing` | **Добавить.** Ставится автоматически, когда невыбывший ровно один, и не меняется, пока невыбывших меньше двух; в списке выбора её нет | раздел 2; раздел 8 — «Данные» (бэкфилл) и «Ввод» |
| 4 | Ввод миров на конец партии | **Оставить** | без изменений: `w_w = 0.5` |
| 5 | Затухание за неактивность | **Не добавлять** | без изменений |
| 6 | Минимум партий для топа | **Оставить 10** | без изменений: `MIN_GAMES = 10` |
| 7 | Групповой рейтинг отдельной цепочкой (после внедрения, #80, 2026-09-15) | **Убрать.** Рейтинг единый; на странице группы — показатели по партиям группы, на главной и в профиле — общие | раздел 8, «Расчёт» |
| 8 | Сравнивать ли выбывших между собой (после ревью, #87 → #91, 2026-09-18) | **Нет.** Все они проиграли и одинаково слабы в этой партии, миров у них нет; при расчёте победителя сила соперников по-прежнему важна | 4.3, 4.4, 4.9, пример 5, итоги 7.3 (подбор 7.4 не переигрывался) |
Открытых вопросов по предложению не осталось. Калибровка коэффициентов на реальных данных —
после внедрения #23 (раздел 8, «Калибровка»).
+868
View File
@@ -0,0 +1,868 @@
#!/usr/bin/env python3
"""Эталонная реализация и симуляция предложенной рейтинговой системы (#22).
Скрипт — приложение к docs/rating/rating-system.md:
1. Формулы документа в коде (раздел «Эталонная реализация»). #23 может сверять
с ними свою реализацию.
2. Пошаговые примеры документа с assert на числа: документ и код не разъедутся.
Плюс проверка, что шкала (1500 или прежние 50) не влияет на качество прогноза.
3. Синтетическая лига: игроки со скрытой «истинной» силой, партии на 2–6 человек.
Детали партии (раунд, цели, миры, тип победы) выводятся из отрыва
по производительности. На одних и тех же партиях сравниваются текущий League
Points, чистый парный Elo и предложенная система.
4. Перебор весов (--grid).
Только стандартная библиотека и фиксированные seed — вывод воспроизводим.
python docs/rating/simulate.py # примеры + сравнение систем (≈1.5 мин)
python docs/rating/simulate.py --grid # примеры + перебор K и весов (≈7 мин)
"""
from __future__ import annotations
import argparse
import math
import random
import statistics
import sys
from dataclasses import dataclass, replace
from itertools import combinations
# ═══ Правила игры ═══════════════════════════════════════════════════════════════
# Размер поля в тайлах по числу игроков (дуэль — 2×3, 6 игроков — 4×5; уточнения владельца в #22).
BOARD_TILES = {2: 6, 3: 9, 4: 12, 5: 16, 6: 20}
WORLDS_PER_TILE = 2.2
BASE_ROUNDS = 8
# Хоумрул группы: при 5–6 игроках играется 9 раундов.
EXTENDED_ROUNDS = 9
EXTENDED_MIN_PLAYERS = 5
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 (решение владельца в PR #65): старт 1500, разница 400 = шансы 10:1.
r0: float = 1500.0 # стартовый рейтинг
d: float = 400.0 # масштаб логистики
k_max: float = 64.0 # K новичка (0 партий)
k_min: float = 16.0 # 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 # поправка на автокорреляцию (см. документ)
# Выбывшие между собой не сравниваются: пара двух выбывших не входит в сумму
# (решение владельца, #91). У систем для сравнения — как было.
skip_eliminated_pairs: 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).
kappa = 2.2 / (0.001 * (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 p.skip_eliminated_pairs and a.eliminated and b.eliminated:
continue
if a.place > b.place:
a, b = b, a
s_ab = 0.5 if a.place == b.place else 1.0
e_ab = expected(r[a.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
# Рейтинг в шкале 0–100; |ΔR| сравнивается с Elo в пересчёте R_Elo = 10·score + 1000.
move_scale = 10.0
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
move_scale = 1.0
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=400 при ΔR ≈ 0.93·Δθ.
SKILL_TO_RATING = 1.702 * 400 / (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]) * system.move_scale)
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=96.0, k_min=16.0) # чистый 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,
skip_eliminated_pairs=True,
)
# ═══ Примеры из документа ═════════════════════════════════════════════════════
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", "Дуэль 1600 против 1400: побеждает сильный",
{"A": 1600, "B": 1400}, _vets("A", "B"), duel("A", "B", win_reason="objectives")),
("1b", "Дуэль 1600 против 1400: побеждает слабый",
{"A": 1600, "B": 1400}, _vets("A", "B"), duel("B", "A", win_reason="objectives")),
("2a", "Быстрая победа: 3-й раунд",
{"A": 1500, "B": 1500}, _vets("A", "B"),
Match([Seat("A", 1, 2, 5), Seat("B", 2, 1, 4)], "objectives", round=3)),
("2b", "Медленная победа: 8-й раунд",
{"A": 1500, "B": 1500}, _vets("A", "B"),
Match([Seat("A", 1, 2, 5), Seat("B", 2, 1, 4)], "objectives", round=8)),
("3a", "Первое место в дуэли",
{"A": 1500, "B": 1500}, _vets("A", "B"), duel("A", "B", win_reason="objectives")),
("3b", "Стол на 5: все места",
dict.fromkeys("ABCDE", 1500), _vets(*"ABCDE"), Match(five, "objectives")),
("4a", "Тип победы без деталей: по мирам",
{"A": 1500, "B": 1500}, _vets("A", "B"), duel("A", "B", win_reason="worlds")),
("4b", "Тип победы без деталей: по пластику",
{"A": 1500, "B": 1500}, _vets("A", "B"), duel("A", "B", win_reason="plastic")),
("4c", "Тип победы без деталей: по ресурсам",
{"A": 1500, "B": 1500}, _vets("A", "B"), duel("A", "B", win_reason="resources")),
("4d", "Самая близкая полная партия: по мирам на 8-м раунде, 2:2 цели, 6:5 миров",
{"A": 1500, "B": 1500}, _vets("A", "B"),
Match([Seat("A", 1, 2, 6), Seat("B", 2, 2, 5)], "worlds", round=8)),
("4e", "Разгром: 3-й раунд, 2:0 цели, 8:2 миров",
{"A": 1500, "B": 1500}, _vets("A", "B"),
Match([Seat("A", 1, 2, 8), Seat("B", 2, 0, 2)], "objectives", round=3)),
("5", "Стол на 4: двое выбывших, раунд 7",
{"A": 1550, "B": 1500, "C": 1480, "D": 1450}, _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", 1500), _vets(*"ABCDEF"),
Match(six, "objectives", round=8, nine_rounds=True)),
("6b", "Стол на 6, конец на 8-м раунде, хоумрул выключен",
dict.fromkeys("ABCDEF", 1500), _vets(*"ABCDEF"),
Match(six, "objectives", round=8, nine_rounds=False)),
("7", "Новичок (0 партий) побеждает ветерана, оба 1500",
{"A": 1500, "B": 1500}, {"A": 0, "B": VETERAN}, duel("A", "B", win_reason="objectives")),
]
# Изменения рейтинга в примерах (округление до 0.01) — те же числа стоят в документе.
EXPECTED: dict[str, dict[str, float]] = {
"1a": {"A": 3.84, "B": -3.84},
"1b": {"B": 12.16, "A": -12.16},
"2a": {"A": 11.18, "B": -11.18},
"2b": {"A": 5.46, "B": -5.46},
"3a": {"A": 8.0, "B": -8.0},
"3b": {"A": 11.0, "B": 5.5, "C": 0.0, "D": -5.5, "E": -11.0},
"4a": {"A": 6.8, "B": -6.8},
"4b": {"A": 5.6, "B": -5.6},
"4c": {"A": 4.8, "B": -4.8},
"4d": {"A": 3.4, "B": -3.4},
"4e": {"A": 16.0, "B": -16.0},
"5": {"A": 9.27, "B": 5.85, "C": -7.6, "D": -7.53},
"6a": {"A": 12.0, "B": 7.2, "C": 2.4, "D": -2.4, "E": -7.2, "F": -12.0},
"6b": {"A": 10.29, "B": 7.54, "C": 2.74, "D": -2.06, "E": -6.86, "F": -11.66},
"7": {"A": 32.0, "B": -8.0},
}
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, 2) 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Все примеры совпадают с документом.")
def check_scale_invariance(seasons: int = 1) -> None:
"""Шкала «50 / 40» (R = 10·score + 1000, D и K ÷10) и шкала 1500 дают одинаковые
точность, Brier и ρ; изменения рейтинга различаются ровно в 10 раз (документ, 4.1)."""
for cfg in SCENARIOS.values():
for s in range(seasons):
skill, matches = generate_season(
cfg["seed"] + s, SEASON_MATCHES, cfg["informative"], cfg["clubs"], cfg["learning"]
)
for p in (PROPOSED, PLAIN):
small = replace(p, r0=(p.r0 - 1000) / 10, d=p.d / 10, k_max=p.k_max / 10, k_min=p.k_min / 10)
big, tiny = evaluate(Elo(p), skill, matches), evaluate(Elo(small), skill, matches)
for key in ("acc", "brier", "rho", *(f"rho@{k}" for k in CHECKPOINTS)):
assert math.isclose(big[key], tiny[key], abs_tol=1e-9), f"шкала: {key}"
assert math.isclose(big["move"], 10 * tiny["move"], rel_tol=1e-9), "шкала: |ΔR|"
print("Шкала 1500 и шкала 50 дают одинаковые точность, Brier и ρ.")
# ═══ Сценарии запуска ═════════════════════════════════════════════════════════
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 (48.0, 64.0, 96.0, 128.0, 160.0):
for k_min in (16.0, 24.0, 32.0):
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 (48.0, 64.0, 80.0):
for k_min2 in (12.0, 16.0, 24.0):
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()
check_scale_invariance()
if args.grid:
grid(args.grid_seasons)
return
for scenario in SCENARIOS:
compare(args.seasons, scenario, PROPOSED)
if __name__ == "__main__":
main()
+18
View File
@@ -1,15 +1,33 @@
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { RouterProvider } from "react-router-dom"; import { RouterProvider } from "react-router-dom";
import { queryClient } from "./app/queryClient"; import { queryClient } from "./app/queryClient";
import { router } from "./app/router"; import { router } from "./app/router";
import { Spinner } from "./components/Spinner";
import { ToastProvider } from "./context/ToastContext"; import { ToastProvider } from "./context/ToastContext";
import { setAppTzOffsetHours } from "./domain/format";
import { useAuthConfig } from "./hooks/auth";
/**
* Пояс приложения (APP_TZ_OFFSET_HOURS) приходит с сервера и нужен до первой отрисовки:
* функции format.ts читают его синхронно (#68). Пока конфиг грузится — спиннер; если он
* недоступен (нет сети), рендерим с запасным поясом, чтобы не запереть приложение.
*/
function AppTimeZone({ children }: { children: ReactNode }) {
const { data, isPending } = useAuthConfig();
if (data) setAppTzOffsetHours(data.tz_offset_hours);
if (isPending) return <Spinner />;
return <>{children}</>;
}
export function App() { export function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ToastProvider> <ToastProvider>
<AppTimeZone>
<RouterProvider router={router} /> <RouterProvider router={router} />
</AppTimeZone>
</ToastProvider> </ToastProvider>
</QueryClientProvider> </QueryClientProvider>
); );
+19 -4
View File
@@ -1,3 +1,5 @@
import type { QueryClient } from "@tanstack/react-query";
export const qk = { export const qk = {
me: ["me"] as const, me: ["me"] as const,
adminMe: ["adminMe"] as const, adminMe: ["adminMe"] as const,
@@ -30,10 +32,11 @@ export const qk = {
}; };
/** /**
* Ключи, которые протухают от любой партии: конкретных участников мы не знаем * Ключи, которые протухают от любой завершённой партии: рейтинг общий и считается по
* (событие приходит на всю группу), поэтому инвалидируем по префиксу. Один * всей истории (#80), так что партия двигает топ, историю и профили всех, кто играл
* список на SSE-обработчик и на завершение партии — иначе переименование ключа * после неё. Поэтому инвалидируем по префиксу. Один список на SSE-обработчик и на
* в этом файле тихо разойдётся с местами, где он написан строкой. * мутации партии — иначе переименование ключа тихо разойдётся с местами, где он
* написан строкой.
*/ */
export const matchAffectedKeys = [ export const matchAffectedKeys = [
qk.home, qk.home,
@@ -42,3 +45,15 @@ export const matchAffectedKeys = [
["userMatches"], ["userMatches"],
["publicProfile"], ["publicProfile"],
] as const; ] as const;
/**
* Все рейтинговые витрины, включая статистику любой группы: рейтинг игроков в ней
* общий, так что его двигает и партия другой группы (#88). Перезапрашиваются только
* открытые на экране запросы, остальные лишь помечаются устаревшими.
*/
export function invalidateRatingViews(qc: QueryClient) {
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
qc.invalidateQueries({
predicate: (q) => q.queryKey[0] === "group" && q.queryKey[2] === "stats",
});
}
+79 -15
View File
@@ -377,8 +377,11 @@ export interface paths {
delete?: never; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
/** Rename Group */ /**
patch: operations["rename_group_api_groups__group_id__patch"]; * Update Group
* @description Название и домашние правила группы; меняются только переданные поля.
*/
patch: operations["update_group_api_groups__group_id__patch"];
trace?: never; trace?: never;
}; };
"/api/groups/{group_id}/expansions": { "/api/groups/{group_id}/expansions": {
@@ -1427,7 +1430,7 @@ export interface components {
/** Duration Minutes */ /** Duration Minutes */
duration_minutes?: number | null; duration_minutes?: number | null;
/** Win Reason */ /** Win Reason */
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
/** Player Count */ /** Player Count */
player_count: number; player_count: number;
/** Created By */ /** Created By */
@@ -1607,6 +1610,8 @@ export interface components {
methods: string[]; methods: string[];
/** Telegram Bot Username */ /** Telegram Bot Username */
telegram_bot_username?: string | null; telegram_bot_username?: string | null;
/** Tz Offset Hours */
tz_offset_hours: number;
}; };
/** Body_add_attachment_api_matches__match_id__attachments_post */ /** Body_add_attachment_api_matches__match_id__attachments_post */
Body_add_attachment_api_matches__match_id__attachments_post: { Body_add_attachment_api_matches__match_id__attachments_post: {
@@ -1766,6 +1771,11 @@ export interface components {
* @default [] * @default []
*/ */
expansion_ids: number[]; expansion_ids: number[];
/**
* Nine Rounds Rule
* @default false
*/
nine_rounds_rule: boolean;
}; };
/** GroupExpansionsUpdate */ /** GroupExpansionsUpdate */
GroupExpansionsUpdate: { GroupExpansionsUpdate: {
@@ -1775,11 +1785,6 @@ export interface components {
*/ */
expansion_ids: number[]; expansion_ids: number[];
}; };
/** GroupRename */
GroupRename: {
/** Name */
name: string;
};
/** GroupStats */ /** GroupStats */
GroupStats: { GroupStats: {
/** Group Id */ /** Group Id */
@@ -1811,6 +1816,13 @@ export interface components {
/** Min Games */ /** Min Games */
min_games: number; min_games: number;
}; };
/** GroupUpdate */
GroupUpdate: {
/** Name */
name?: string | null;
/** Nine Rounds Rule */
nine_rounds_rule?: boolean | null;
};
/** HTTPValidationError */ /** HTTPValidationError */
HTTPValidationError: { HTTPValidationError: {
/** Detail */ /** Detail */
@@ -1904,6 +1916,11 @@ export interface components {
rank?: number | null; rank?: number | null;
/** Avatar Url */ /** Avatar Url */
avatar_url?: string | null; avatar_url?: string | null;
/**
* Rating Confirmed
* @default false
*/
rating_confirmed: boolean;
}; };
/** MatchCreate */ /** MatchCreate */
MatchCreate: { MatchCreate: {
@@ -1920,7 +1937,9 @@ export interface components {
* Win Reason * Win Reason
* @enum {string} * @enum {string}
*/ */
win_reason: "objectives" | "worlds" | "plastic" | "resources"; win_reason: "objectives" | "worlds" | "plastic" | "resources" | "last_standing";
/** End Round */
end_round?: number | null;
/** Overall Comment */ /** Overall Comment */
overall_comment?: string | null; overall_comment?: string | null;
/** Expected Version */ /** Expected Version */
@@ -1951,9 +1970,25 @@ export interface components {
[key: string]: string; [key: string]: string;
}; };
/** Win Reason */ /** Win Reason */
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
/** Overall Comment */ /** Overall Comment */
overall_comment?: string | null; overall_comment?: string | null;
/** End Round */
end_round?: number | null;
/**
* Objectives
* @default {}
*/
objectives: {
[key: string]: number;
};
/**
* Worlds
* @default {}
*/
worlds: {
[key: string]: number;
};
}; };
/** MatchFinishDraftRead */ /** MatchFinishDraftRead */
MatchFinishDraftRead: { MatchFinishDraftRead: {
@@ -1980,6 +2015,10 @@ export interface components {
comment?: string | null; comment?: string | null;
/** Faction Id */ /** Faction Id */
faction_id?: number | null; faction_id?: number | null;
/** Objectives */
objectives?: number | null;
/** Worlds */
worlds?: number | null;
}; };
/** /**
* MatchHistory * MatchHistory
@@ -2034,7 +2073,7 @@ export interface components {
/** Duration Minutes */ /** Duration Minutes */
duration_minutes?: number | null; duration_minutes?: number | null;
/** Win Reason */ /** Win Reason */
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
/** Player Count */ /** Player Count */
player_count: number; player_count: number;
/** Overall Comment */ /** Overall Comment */
@@ -2046,6 +2085,8 @@ export interface components {
* @default [] * @default []
*/ */
participants: components["schemas"]["MatchListParticipant"][]; participants: components["schemas"]["MatchListParticipant"][];
/** Rating Delta */
rating_delta?: number | null;
}; };
/** MatchListParticipant */ /** MatchListParticipant */
MatchListParticipant: { MatchListParticipant: {
@@ -2068,6 +2109,10 @@ export interface components {
was_random: boolean; was_random: boolean;
/** Comment */ /** Comment */
comment?: string | null; comment?: string | null;
/** Objectives */
objectives?: number | null;
/** Worlds */
worlds?: number | null;
}; };
/** MatchParticipantRead */ /** MatchParticipantRead */
MatchParticipantRead: { MatchParticipantRead: {
@@ -2090,6 +2135,10 @@ export interface components {
was_random: boolean; was_random: boolean;
/** Comment */ /** Comment */
comment?: string | null; comment?: string | null;
/** Objectives */
objectives?: number | null;
/** Worlds */
worlds?: number | null;
/** Avatar Url */ /** Avatar Url */
avatar_url?: string | null; avatar_url?: string | null;
}; };
@@ -2113,7 +2162,16 @@ export interface components {
/** Duration Minutes */ /** Duration Minutes */
duration_minutes?: number | null; duration_minutes?: number | null;
/** Win Reason */ /** Win Reason */
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
/** End Round */
end_round?: number | null;
/**
* Nine Rounds Rule
* @default false
*/
nine_rounds_rule: boolean;
/** Max Rounds */
max_rounds: number;
/** Player Count */ /** Player Count */
player_count: number; player_count: number;
/** Overall Comment */ /** Overall Comment */
@@ -2146,7 +2204,9 @@ export interface components {
/** Overall Comment */ /** Overall Comment */
overall_comment?: string | null; overall_comment?: string | null;
/** Win Reason */ /** Win Reason */
win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; win_reason?: ("objectives" | "worlds" | "plastic" | "resources" | "last_standing") | null;
/** End Round */
end_round?: number | null;
/** Participants */ /** Participants */
participants?: components["schemas"]["ParticipantInput"][] | null; participants?: components["schemas"]["ParticipantInput"][] | null;
/** Expected Version */ /** Expected Version */
@@ -2294,6 +2354,10 @@ export interface components {
was_random: boolean; was_random: boolean;
/** Comment */ /** Comment */
comment?: string | null; comment?: string | null;
/** Objectives */
objectives?: number | null;
/** Worlds */
worlds?: number | null;
}; };
/** PasswordChange */ /** PasswordChange */
PasswordChange: { PasswordChange: {
@@ -3177,7 +3241,7 @@ export interface operations {
}; };
}; };
}; };
rename_group_api_groups__group_id__patch: { update_group_api_groups__group_id__patch: {
parameters: { parameters: {
query?: never; query?: never;
header?: never; header?: never;
@@ -3188,7 +3252,7 @@ export interface operations {
}; };
requestBody: { requestBody: {
content: { content: {
"application/json": components["schemas"]["GroupRename"]; "application/json": components["schemas"]["GroupUpdate"];
}; };
}; };
responses: { responses: {
+8 -2
View File
@@ -30,8 +30,14 @@ function Row({
</div> </div>
</div> </div>
{/* Серебристый — у новичков и у прочерка ещё не игравших; золотой — только {/* Серебристый — у новичков и у прочерка ещё не игравших; золотой — только
подтверждённый рейтинг. */} подтверждённый рейтинг. Рейтинг общий, поэтому подтверждённость приходит
<div className={"lb-score" + (provisional || entry.score == null ? " provisional" : "")}> флагом: в группе «Ещё не играли» бывают и новички, и опытные игроки. */}
<div
className={
"lb-score" +
(provisional || entry.score == null || !entry.rating_confirmed ? " provisional" : "")
}
>
{entry.score ?? "—"} {entry.score ?? "—"}
</div> </div>
</> </>
+6 -1
View File
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { formatDate, formatDuration } from "../domain/format"; import { formatDate, formatDuration } from "../domain/format";
import type { MatchListItem } from "../domain/types"; import type { MatchListItem } from "../domain/types";
import { MatchListView } from "./MatchList"; import { MatchListView } from "./MatchList";
import { RatingDelta } from "./RatingDelta";
/** /**
* История партий игрока в профиле. Подробный режим — тот же список, что у группы * История партий игрока в профиле. Подробный режим — тот же список, что у группы
@@ -53,10 +54,11 @@ export function MatchHistory({
return ( return (
<button <button
key={m.id} key={m.id}
className="card row-between" className="card"
style={{ margin: 0, width: "100%", textAlign: "left" }} style={{ margin: 0, width: "100%", textAlign: "left" }}
onClick={() => navigate(`/match/${m.id}`)} onClick={() => navigate(`/match/${m.id}`)}
> >
<div className="row-between">
<div className="row" style={{ gap: 10, minWidth: 0 }}> <div className="row" style={{ gap: 10, minWidth: 0 }}>
<span <span
className={ className={
@@ -76,6 +78,9 @@ export function MatchHistory({
<span className="muted small" style={{ flexShrink: 0 }}> <span className="muted small" style={{ flexShrink: 0 }}>
{formatDate(m.played_at)} · {formatDuration(m.duration_minutes)} {formatDate(m.played_at)} · {formatDuration(m.duration_minutes)}
</span> </span>
</div>
{/* Второй строкой — изменение рейтинга владельца профиля за партию. */}
{m.rating_delta != null && <RatingDelta delta={m.rating_delta} />}
</button> </button>
); );
})} })}
+4 -1
View File
@@ -3,10 +3,12 @@ import { useNavigate } from "react-router-dom";
import { formatDate, formatDuration } from "../domain/format"; import { formatDate, formatDuration } from "../domain/format";
import { winReasonLabel } from "../domain/winReasons"; import { winReasonLabel } from "../domain/winReasons";
import type { MatchListItem } from "../domain/types"; import type { MatchListItem } from "../domain/types";
import { RatingDelta } from "./RatingDelta";
/** Список ЗАВЕРШЁННЫХ партий. Незавершённые сюда не попадают: группа отдаёт их /** Список ЗАВЕРШЁННЫХ партий. Незавершённые сюда не попадают: группа отдаёт их
* отдельным блоком (InProgressMatches), а история профиля приходит с бэкенда уже * отдельным блоком (InProgressMatches), а история профиля приходит с бэкенда уже
* отфильтрованной по status="finished". */ * отфильтрованной по status="finished". В истории профиля у партии есть rating_delta
* владельца — она идёт последней строкой; у списка группы её нет. */
export function MatchListView({ items }: { items: MatchListItem[] }) { export function MatchListView({ items }: { items: MatchListItem[] }) {
const navigate = useNavigate(); const navigate = useNavigate();
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>; if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
@@ -48,6 +50,7 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
</div> </div>
))} ))}
</div> </div>
{m.rating_delta != null && <RatingDelta delta={m.rating_delta} />}
</button> </button>
); );
})} })}
@@ -0,0 +1,76 @@
import { PickerSelect } from "./PickerSelect";
import { LAST_STANDING, WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label }));
const NO_ROUND = 0;
/**
* Итог партии в форме завершения и правки результатов: причина победы, раунд окончания
* и предупреждения о несогласованном вводе. «Последний выживший» не выбирается —
* пока выживший один, причина зафиксирована (её ставит родитель, см. reasonForSurvivors).
*/
export function MatchOutcomeFields({
winReason,
onReason,
endRound,
onEndRound,
maxRounds,
warnings,
}: {
winReason: WinReason | null;
onReason: (reason: WinReason) => void;
endRound: number | null;
onEndRound: (round: number | null) => void;
maxRounds: number;
warnings: string[];
}) {
const roundOptions = [
{ id: NO_ROUND, label: "не указан" },
...Array.from({ length: maxRounds }, (_, i) => ({ id: i + 1, label: `${i + 1}-й` })),
];
return (
<div className="card">
<h3>Итог партии</h3>
<div className="field">
<label className="label">Причина победы</label>
{winReason === LAST_STANDING ? (
<div className="picker-trigger" aria-disabled>
{winReasonLabel(LAST_STANDING)}
<span className="muted small" style={{ marginLeft: "auto" }}>
все соперники выбыли
</span>
</div>
) : (
<PickerSelect
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null}
options={REASON_OPTIONS}
placeholder="— выберите причину —"
renderOption={(o) => o.label}
onPick={(o) => onReason(o.id)}
/>
)}
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="label">Раунд окончания (из {maxRounds})</label>
<PickerSelect
selected={roundOptions.find((o) => o.id === (endRound ?? NO_ROUND)) ?? null}
options={roundOptions}
placeholder="не указан"
renderOption={(o) => o.label}
onPick={(o) => onEndRound(o.id === NO_ROUND ? null : o.id)}
/>
</div>
<p className="muted small" style={{ marginBottom: 0 }}>
Раунд, цели и миры необязательны, но делают рейтинг точнее: быстрая и крупная победа
весит больше.
</p>
{warnings.length > 0 && (
<ul className="warn-list">
{warnings.map((w) => (
<li key={w}>{w}</li>
))}
</ul>
)}
</div>
);
}
+38 -2
View File
@@ -2,6 +2,7 @@ import { Scissors } from "lucide-react";
import { useRef } from "react"; import { useRef } from "react";
import { Avatar } from "./Avatar"; import { Avatar } from "./Avatar";
import { MAX_COUNT, parseCount } from "../domain/matchCounts";
export interface PlacePlayer { export interface PlacePlayer {
user_id: number; user_id: number;
@@ -10,6 +11,10 @@ export interface PlacePlayer {
avatar_url?: string | null; avatar_url?: string | null;
} }
export type CountField = "objectives" | "worlds";
/** Цели и миры на конец партии по user_id; отсутствие ключа или null — не указано. */
export type Counts = Record<number, { objectives: number | null; worlds: number | null }>;
type Target = type Target =
| { type: "merge"; idx: number } | { type: "merge"; idx: number }
| { type: "insert"; idx: number } | { type: "insert"; idx: number }
@@ -28,7 +33,8 @@ interface DragState {
* Редактор мест перетаскиванием (макет A2): вертикальный список блоков игроков, * Редактор мест перетаскиванием (макет A2): вертикальный список блоков игроков,
* верхний — 1-е место. Бросок между блоками — порядок, на середину чужого блока — * верхний — 1-е место. Бросок между блоками — порядок, на середину чужого блока —
* слияние в ничью (общая рамка и место, ✂ выносит обратно), в пунктирную зону — * слияние в ничью (общая рамка и место, ✂ выносит обратно), в пунктирную зону —
* выбывший. В каждом блоке — строка комментария об игроке. * выбывший. В каждом блоке — строка комментария об игроке и (если переданы counts)
* цели и миры на конец партии; у выбывшего миров нет — поле заблокировано нулём.
* *
* Контролируемый: blocks (упорядоченные группы user_id, длина >1 = ничья) и * Контролируемый: blocks (упорядоченные группы user_id, длина >1 = ничья) и
* eliminated живут у родителя. Во время drag DOM двигается напрямую (transform, * eliminated живут у родителя. Во время drag DOM двигается напрямую (transform,
@@ -41,6 +47,8 @@ export function PlaceEditor({
comments, comments,
onChange, onChange,
onComment, onComment,
counts,
onCount,
}: { }: {
players: PlacePlayer[]; players: PlacePlayer[];
blocks: number[][]; blocks: number[][];
@@ -48,6 +56,8 @@ export function PlaceEditor({
comments: Record<number, string>; comments: Record<number, string>;
onChange: (blocks: number[][], eliminated: number[]) => void; onChange: (blocks: number[][], eliminated: number[]) => void;
onComment: (userId: number, text: string) => void; onComment: (userId: number, text: string) => void;
counts?: Counts;
onCount?: (userId: number, field: CountField, value: number | null) => void;
}) { }) {
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const elimRef = useRef<HTMLDivElement>(null); const elimRef = useRef<HTMLDivElement>(null);
@@ -225,7 +235,24 @@ export function PlaceEditor({
); );
}; };
const commentInput = (userId: number) => ( const countInput = (userId: number, field: CountField, label: string, locked = false) => (
<label className="rank-count">
<span>{label}</span>
<input
type="number"
inputMode="numeric"
min={0}
max={MAX_COUNT}
placeholder="—"
disabled={locked}
value={locked ? 0 : (counts?.[userId]?.[field] ?? "")}
onChange={(e) => onCount?.(userId, field, parseCount(e.target.value))}
/>
</label>
);
const commentInput = (userId: number) => {
const comment = (
<input <input
className="rank-comment" className="rank-comment"
placeholder="Комментарий об игроке" placeholder="Комментарий об игроке"
@@ -233,6 +260,15 @@ export function PlaceEditor({
onChange={(e) => onComment(userId, e.target.value)} onChange={(e) => onComment(userId, e.target.value)}
/> />
); );
if (!counts) return comment;
return (
<div className="rank-details">
{comment}
{countInput(userId, "objectives", "цели")}
{countInput(userId, "worlds", "миры", eliminated.includes(userId))}
</div>
);
};
return ( return (
<div> <div>
+1 -1
View File
@@ -35,7 +35,7 @@ export function ProfileStatsCard({
<b>{o.avg_place ?? "—"}</b> <b>{o.avg_place ?? "—"}</b>
</div> </div>
<div className="row-between"> <div className="row-between">
<div>Очки (рейтинг)</div> <div>Рейтинг</div>
<b className={"lb-score" + (provisional ? " provisional" : "")}>{o.score ?? "—"}</b> <b className={"lb-score" + (provisional ? " provisional" : "")}>{o.score ?? "—"}</b>
</div> </div>
{/* Любимая — личный выбор игрока в профиле; ниже — статистика по партиям. */} {/* Любимая — личный выбор игрока в профиле; ниже — статистика по партиям. */}
+13
View File
@@ -0,0 +1,13 @@
import { formatRatingDelta } from "../domain/format";
/** Строка карточки истории: сколько общего рейтинга владелец профиля получил или
* потерял за партию. Рост — зелёным, падение — красным, ноль — без цвета. */
export function RatingDelta({ delta }: { delta: number }) {
const text = formatRatingDelta(delta);
const tone = text.startsWith("+") ? "rating-up" : text.startsWith("−") ? "rating-down" : undefined;
return (
<div className="small rating-delta">
Рейтинг: <b className={tone}>{text}</b>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { LAST_STANDING, type WinReason, winReasonLabel } from "./winReasons";
export interface OutcomeSeat {
userId: number;
nickname: string;
/** Место невыбывшего; у выбывшего не учитывается. */
place: number;
eliminated: boolean;
objectives: number | null;
worlds: number | null;
}
/**
* Подсказки о несогласованных итогах партии. Это предупреждения, а не отказ: сервер
* проверяет только диапазоны и явные противоречия, а здесь — сочетания, которые по
* правилам почти наверняка опечатка. Проверка идёт только по заполненным полям.
*/
export function finishWarnings({
seats,
winReason,
endRound,
maxRounds,
}: {
seats: OutcomeSeat[];
winReason: WinReason | null;
endRound: number | null;
maxRounds: number;
}): string[] {
const out: string[] = [];
const n = seats.length;
const survivors = seats.filter((s) => !s.eliminated).sort((a, b) => a.place - b.place);
const winners = survivors.filter((s) => s.place === 1);
if (winners.length === 0 || winReason === LAST_STANDING) return out;
// Досрочно партия заканчивается, только когда кто-то набрал столько целей, сколько
// игроков за столом.
const winnerObjectives = winners.map((w) => w.objectives).filter((v): v is number => v != null);
if (
endRound != null &&
endRound < maxRounds &&
winnerObjectives.length > 0 &&
Math.max(...winnerObjectives) < n
) {
out.push(
`Партия закончилась в ${endRound}-м раунде из ${maxRounds}, но у победителя меньше ${n} целей — ` +
`досрочно побеждает тот, кто набрал ${n}.`,
);
}
// Остальное сравнивает победителя с соперниками — при ничьей за 1-е место не с кем.
if (winners.length > 1) return out;
const winner = winners[0];
const secondPlace = survivors.find((s) => s.place > 1)?.place;
const runnersUp = survivors.filter((s) => s.place === secondPlace);
const label = winReasonLabel(winReason).toLowerCase();
for (const r of runnersUp) {
const objKnown = winner.objectives != null && r.objectives != null;
const worldsKnown = winner.worlds != null && r.worlds != null;
if (winReason === "objectives" && objKnown && winner.objectives === r.objectives) {
out.push(
`У ${winner.nickname} и ${r.nickname} поровну целей (${winner.objectives}) — ` +
"при равенстве победа определяется по мирам, пластику или ресурсам.",
);
}
if (winReason && winReason !== "objectives" && objKnown && winner.objectives !== r.objectives) {
out.push(
`Победа ${label} предполагает равные цели у лидеров: у ${winner.nickname} — ` +
`${winner.objectives}, у ${r.nickname} — ${r.objectives}.`,
);
}
if (winReason === "worlds" && worldsKnown && winner.worlds! <= r.worlds!) {
out.push(
`Победа по мирам, но у ${winner.nickname} миров не больше, чем у ${r.nickname} ` +
`(${winner.worlds} и ${r.worlds}).`,
);
}
if (
(winReason === "plastic" || winReason === "resources") &&
worldsKnown &&
winner.worlds !== r.worlds
) {
out.push(
`Победа ${label} предполагает равные миры у лидеров: у ${winner.nickname} — ` +
`${winner.worlds}, у ${r.nickname} — ${r.worlds}.`,
);
}
}
if (winner.objectives != null) {
for (const s of seats) {
if (s.userId !== winner.userId && s.objectives != null && s.objectives > winner.objectives) {
out.push(
`У ${s.nickname} целей больше, чем у победителя ${winner.nickname} ` +
`(${s.objectives} против ${winner.objectives}).`,
);
}
}
}
return out;
}
+25 -9
View File
@@ -1,12 +1,19 @@
// Отображаем время в фиксированном поясе +3 (МСК) независимо от пояса браузера. // Время показываем в поясе приложения — одном для всех, независимо от пояса браузера.
const APP_TZ_OFFSET_MIN = 3 * 60; // Смещение задаёт APP_TZ_OFFSET_HOURS на бэкенде (в нём же считается «дата игры»);
// App.tsx получает его из /api/auth/config до первой отрисовки страниц (#68).
// 3 ч (МСК) — запасное значение, если конфиг недоступен.
let appTzOffsetMin = 3 * 60;
export function setAppTzOffsetHours(hours: number): void {
appTzOffsetMin = hours * 60;
}
const p2 = (n: number) => String(n).padStart(2, "0"); const p2 = (n: number) => String(n).padStart(2, "0");
// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в +3 // iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в пояс приложения
// и форматируем по UTC-частям, чтобы получить «настенное» время МСК. // и форматируем по UTC-частям, чтобы получить «настенное» время этого пояса.
function shifted(iso: string): Date { function shifted(iso: string): Date {
return new Date(new Date(iso).getTime() + APP_TZ_OFFSET_MIN * 60_000); return new Date(new Date(iso).getTime() + appTzOffsetMin * 60_000);
} }
// Склонение существительного при числе: plural(3, "игрок", "игрока", "игроков"). // Склонение существительного при числе: plural(3, "игрок", "игрока", "игроков").
@@ -18,6 +25,14 @@ export function plural(n: number, one: string, few: string, many: string): strin
return many; return many;
} }
// Изменение рейтинга за партию: всегда со знаком и одним знаком после запятой
// («+12.3», «−4.1», «0.0»); минус — типографский, как в справке.
export function formatRatingDelta(delta: number): string {
const abs = Math.abs(delta).toFixed(1);
if (abs === "0.0") return "0.0";
return `${delta > 0 ? "+" : "−"}${abs}`;
}
export function formatDuration(minutes: number | null | undefined): string { export function formatDuration(minutes: number | null | undefined): string {
if (minutes == null) return "—"; if (minutes == null) return "—";
const h = Math.floor(minutes / 60); const h = Math.floor(minutes / 60);
@@ -42,18 +57,19 @@ export function formatDate(iso: string | null | undefined): string {
return m ? `${m[3]}.${m[2]}.${m[1]}` : iso; return m ? `${m[3]}.${m[2]}.${m[1]}` : iso;
} }
// Значение <input type="datetime-local"> — «настенное» время в том же поясе +3, что и всё // Значение <input type="datetime-local"> — «настенное» время в том же поясе приложения, что
// отображение: админ вводит время показа объявления по МСК, где бы ни был его браузер. // и всё отображение: админ вводит время показа объявления в нём, где бы ни был браузер.
export function toAppLocalInput(iso: string): string { export function toAppLocalInput(iso: string): string {
const d = shifted(iso); const d = shifted(iso);
return `${d.getUTCFullYear()}-${p2(d.getUTCMonth() + 1)}-${p2(d.getUTCDate())}T${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`; return `${d.getUTCFullYear()}-${p2(d.getUTCMonth() + 1)}-${p2(d.getUTCDate())}T${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
} }
// Обратно: «настенное» время +3 из datetime-local → момент ISO (UTC). null — поле пустое. // Обратно: «настенное» время пояса приложения из datetime-local → момент ISO (UTC).
// null — поле пустое.
export function fromAppLocalInput(value: string): string | null { export function fromAppLocalInput(value: string): string | null {
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value); const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value);
if (!m) return null; if (!m) return null;
const ms = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]) - APP_TZ_OFFSET_MIN * 60_000; const ms = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]) - appTzOffsetMin * 60_000;
return new Date(ms).toISOString(); return new Date(ms).toISOString();
} }
+10
View File
@@ -0,0 +1,10 @@
/** Верхняя граница целей и миров — отсечка мусора, та же, что в схеме API (Count). */
export const MAX_COUNT = 99;
/** Поле «цели»/«миры» формы: пустое — не указано (null), иначе целое 0..MAX_COUNT. */
export function parseCount(raw: string): number | null {
const text = raw.trim();
const num = Number(text);
if (text === "" || !Number.isFinite(num)) return null;
return Math.min(MAX_COUNT, Math.max(0, Math.trunc(num)));
}
+18 -4
View File
@@ -2,7 +2,10 @@ import type { components } from "../api/schema";
export type WinReason = components["schemas"]["MatchFinish"]["win_reason"]; export type WinReason = components["schemas"]["MatchFinish"]["win_reason"];
// Фиксированный порядок (сверху вниз). export const LAST_STANDING: WinReason = "last_standing";
// Причины для ручного выбора, фиксированный порядок (сверху вниз). «Последний выживший»
// сюда не входит: она ставится сама, когда невыбывший игрок остался один.
export const WIN_REASONS: { code: WinReason; label: string }[] = [ export const WIN_REASONS: { code: WinReason; label: string }[] = [
{ code: "objectives", label: "По целям" }, { code: "objectives", label: "По целям" },
{ code: "worlds", label: "По мирам" }, { code: "worlds", label: "По мирам" },
@@ -10,10 +13,21 @@ export const WIN_REASONS: { code: WinReason; label: string }[] = [
{ code: "resources", label: "По ресурсам" }, { code: "resources", label: "По ресурсам" },
]; ];
const LABELS: Record<string, string> = Object.fromEntries( const LABELS: Record<string, string> = {
WIN_REASONS.map((w) => [w.code, w.label]), ...Object.fromEntries(WIN_REASONS.map((w) => [w.code, w.label])),
); [LAST_STANDING]: "Последний выживший",
};
export function winReasonLabel(code: string | null | undefined): string { export function winReasonLabel(code: string | null | undefined): string {
return code ? LABELS[code] ?? code : "—"; return code ? LABELS[code] ?? code : "—";
} }
/**
* Причина победы после изменения состава выбывших. Правило сервера: невыбывший ровно
* один ⇔ «последний выживший». Поэтому при одном выжившем причина ставится сама, а когда
* выживших снова двое — сбрасывается (null): её нужно выбрать заново, а не угадывать.
*/
export function reasonForSurvivors(current: WinReason | null, survivors: number): WinReason | null {
if (survivors === 1) return LAST_STANDING;
return current === LAST_STANDING ? null : current;
}
+6 -3
View File
@@ -75,14 +75,15 @@ export function useCreateGroup() {
}); });
} }
export function useRenameGroup(groupId: number) { export function useUpdateGroup(groupId: number) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (name: string) => // Частичная правка: название и/или домашние правила (например, 9 раундов).
mutationFn: async (body: { name?: string; nine_rounds_rule?: boolean }) =>
unwrap( unwrap(
await api.PATCH("/api/groups/{group_id}", { await api.PATCH("/api/groups/{group_id}", {
params: { path: { group_id: groupId } }, params: { path: { group_id: groupId } },
body: { name }, body,
}), }),
), ),
onSuccess: () => { onSuccess: () => {
@@ -137,6 +138,8 @@ export function useRemoveMember(groupId: number) {
), ),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.groupMembers(groupId) }); qc.invalidateQueries({ queryKey: qk.groupMembers(groupId) });
// Список игроков группы показывает только текущий состав — удалённый из него уходит.
qc.invalidateQueries({ queryKey: qk.groupStats(groupId) });
qc.invalidateQueries({ queryKey: qk.me }); qc.invalidateQueries({ queryKey: qk.me });
}, },
}); });
+4 -6
View File
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, unwrap } from "../api/client"; import { api, unwrap } from "../api/client";
import { matchAffectedKeys, qk } from "../api/queryKeys"; import { invalidateRatingViews, qk } from "../api/queryKeys";
import type { import type {
FactionRead, FactionRead,
MatchCreate, MatchCreate,
@@ -83,8 +83,7 @@ export function useFinishMatch() {
onSuccess: (m) => { onSuccess: (m) => {
qc.invalidateQueries({ queryKey: qk.match(m.id) }); qc.invalidateQueries({ queryKey: qk.match(m.id) });
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) }); qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) }); invalidateRatingViews(qc);
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
}, },
}); });
} }
@@ -103,9 +102,8 @@ export function useUpdateMatch() {
onSuccess: (m) => { onSuccess: (m) => {
qc.setQueryData(qk.match(m.id), m); qc.setQueryData(qk.match(m.id), m);
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) }); qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) }); // Места изменились — значит изменились рейтинги, топ, истории и профили.
// Места изменились — значит изменились лидерборд, история и профили. invalidateRatingViews(qc);
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
}, },
}); });
} }
+16 -30
View File
@@ -1,15 +1,20 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react"; import { useEffect } from "react";
import { matchAffectedKeys, qk } from "../api/queryKeys"; import { invalidateRatingViews, qk } from "../api/queryKeys";
import { useMe } from "./auth";
interface ServerEvent { interface ServerEvent {
type: "match" | "match_draft" | "group" | "invitations" | "notifications" | "announcements"; /** ratings — завершённая партия чужой группы сдвинула общий рейтинг (#88). */
type:
| "match"
| "match_draft"
| "ratings"
| "group"
| "invitations"
| "notifications"
| "announcements";
match_id?: number; match_id?: number;
group_id?: number; group_id?: number;
/** Кто играл в партии: их история и профили протухли, чужие — нет. */
participant_ids?: number[];
} }
/** /**
@@ -19,11 +24,6 @@ interface ServerEvent {
*/ */
export function useServerEvents(enabled: boolean) { export function useServerEvents(enabled: boolean) {
const qc = useQueryClient(); const qc = useQueryClient();
const { data: me } = useMe();
// Свой id — в ref: положив его в зависимости эффекта, мы бы пересоздавали
// SSE-соединение каждый раз, когда профиль перезапрашивается.
const myId = useRef<number | null>(null);
myId.current = me?.id ?? null;
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
const base = import.meta.env.VITE_API_BASE_URL || ""; const base = import.meta.env.VITE_API_BASE_URL || "";
@@ -50,26 +50,12 @@ export function useServerEvents(enabled: boolean) {
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) }); if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
if (ev.group_id != null) { if (ev.group_id != null) {
qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) }); qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) });
qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) });
}
// Общее меняется от любой партии: рейтинг глобальный, и чужая игра двигает топ.
qc.invalidateQueries({ queryKey: qk.home });
qc.invalidateQueries({ queryKey: qk.leaderboard });
if (ev.participant_ids) {
// Личные витрины — только у игравших: иначе каждая партия в группе
// заставляла бы всех остальных перезапрашивать свою историю.
for (const pid of ev.participant_ids) {
qc.invalidateQueries({ queryKey: qk.userMatches(pid) });
qc.invalidateQueries({ queryKey: qk.publicProfile(pid) });
}
if (myId.current != null && ev.participant_ids.includes(myId.current)) {
qc.invalidateQueries({ queryKey: qk.myStats });
}
} else {
// Событие от бэкенда без списка участников (вкладка открыта до обновления
// сервера) — ведём себя как раньше, широко.
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
} }
// Рейтинг общий и считается по всей истории: партия двигает топ, историю
// и профили всех, кто играл после неё, и страницы других групп.
invalidateRatingViews(qc);
} else if (ev.type === "ratings") {
invalidateRatingViews(qc);
} else if (ev.type === "group") { } else if (ev.type === "group") {
if (ev.group_id != null) { if (ev.group_id != null) {
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) }); qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
+29 -4
View File
@@ -13,8 +13,8 @@ import {
useGroup, useGroup,
useGroupMembers, useGroupMembers,
useRemoveMember, useRemoveMember,
useRenameGroup,
useSetGroupExpansions, useSetGroupExpansions,
useUpdateGroup,
} from "../hooks/groups"; } from "../hooks/groups";
import { useExpansions } from "../hooks/reference"; import { useExpansions } from "../hooks/reference";
@@ -25,7 +25,7 @@ export function GroupSettingsPage() {
const { data: expansions } = useExpansions(); const { data: expansions } = useExpansions();
const { data: members } = useGroupMembers(groupId); const { data: members } = useGroupMembers(groupId);
const setExpansions = useSetGroupExpansions(groupId ?? 0); const setExpansions = useSetGroupExpansions(groupId ?? 0);
const renameGroup = useRenameGroup(groupId ?? 0); const updateGroup = useUpdateGroup(groupId ?? 0);
const removeMember = useRemoveMember(groupId ?? 0); const removeMember = useRemoveMember(groupId ?? 0);
const toast = useToast(); const toast = useToast();
@@ -55,7 +55,7 @@ export function GroupSettingsPage() {
const trimmed = nameValue.trim(); const trimmed = nameValue.trim();
if (!trimmed || trimmed === group.name) return; if (!trimmed || trimmed === group.name) return;
try { try {
await renameGroup.mutateAsync(trimmed); await updateGroup.mutateAsync({ name: trimmed });
setName(null); setName(null);
toast.show("Название сохранено"); toast.show("Название сохранено");
} catch (e) { } catch (e) {
@@ -63,6 +63,15 @@ export function GroupSettingsPage() {
} }
}; };
const saveNineRounds = async (enabled: boolean) => {
try {
await updateGroup.mutateAsync({ nine_rounds_rule: enabled });
toast.show(enabled ? "9 раундов при 5–6 игроках включено" : "Правило 9 раундов выключено");
} catch (e) {
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
const saveExpansions = async () => { const saveExpansions = async () => {
try { try {
await setExpansions.mutateAsync([...selected]); await setExpansions.mutateAsync([...selected]);
@@ -95,7 +104,7 @@ export function GroupSettingsPage() {
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={saveName} onClick={saveName}
disabled={renameGroup.isPending || !nameValue.trim() || nameValue.trim() === group.name} disabled={updateGroup.isPending || !nameValue.trim() || nameValue.trim() === group.name}
> >
Сохранить Сохранить
</button> </button>
@@ -127,6 +136,22 @@ export function GroupSettingsPage() {
)} )}
</div> </div>
<div className="card">
<h3>Домашние правила</h3>
<label className="row" style={{ justifyContent: "space-between" }}>
<span>9 раундов при 5–6 игроках</span>
<Switch
checked={group.nine_rounds_rule}
disabled={updateGroup.isPending}
onChange={saveNineRounds}
/>
</label>
<p className="muted small" style={{ marginBottom: 0 }}>
Действует на партии, начатые после смены: лимит раундов уже идущих и сыгранных
партий не меняется.
</p>
</div>
<div className="card"> <div className="card">
{/* Приглашение нового игрока — на странице группы («Список игроков»). */} {/* Приглашение нового игрока — на странице группы («Список игроков»). */}
<h3>Участники ({(members ?? []).length}/{MAX_GROUP_SIZE})</h3> <h3>Участники ({(members ?? []).length}/{MAX_GROUP_SIZE})</h3>
+102 -32
View File
@@ -2,8 +2,9 @@ import { ChevronDown, ChevronUp } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
/** /**
* Справка — разделы, раскрывающиеся по кнопке. Пока раздел один: «Рейтинг» * Справка — разделы, раскрывающиеся по кнопке. Пока раздел один: «Рейтинг».
* (как считаются очки игроков). Текст соответствует backend/app/services/scoring.py. * Текст соответствует backend/app/services/scoring.py и docs/rating/rating-system.md:
* коэффициенты, пороги и примеры менять вместе с ними.
*/ */
export function HelpPage() { export function HelpPage() {
const [showRating, setShowRating] = useState(false); const [showRating, setShowRating] = useState(false);
@@ -32,50 +33,118 @@ export function HelpPage() {
{showRating && ( {showRating && (
<> <>
<div className="card"> <div className="card">
<h3>Очки игроков (рейтинг)</h3> <h3>Рейтинг игроков</h3>
<p className="small"> <p className="small">
Рейтинг считается только по <b>завершённым</b> партиям. Незавершённые и отменённые Рейтинг — оценка силы игрока относительно соперников (система Elo). Каждый начинает
в зачёт не идут. с <b>1500</b>. Разница в <b>400</b> пунктов означает шансы 10 к 1 в пользу более
сильного.
</p> </p>
<p className="small"> <p className="small">
За каждую партию игрок получает «очки за место» — они нормированы по числу игроков Считаются только <b>завершённые</b> партии, по порядку их игры. Правка или удаление
за столом, поэтому победа за большим столом ценится выше, чем за маленьким: старой партии пересчитывает и все последующие.
</p> </p>
<div className="formula"> <p className="small">
очки = (N − место − (размер_ничьей − 1) / 2) / (N − 1) Партия раскладывается на пары игроков. В каждой паре фактический результат (выше —
1, поровну — 0.5, ниже — 0) сравнивается с ожидаемым по рейтингам до партии:
</p>
<div className="formula">ожидание = 1 / (1 + 10^((R соперника − R игрока) / 400))</div>
<div className="formula" style={{ marginTop: 6 }}>
ΔR = K × G / (N − 1) × Σ M × (результат − ожидание)
</div> </div>
<p className="small" style={{ marginTop: 10 }}> <p className="small" style={{ marginTop: 10 }}>
где <b>N</b> — число игроков в партии. Первое место даёт <b>1.0</b>, последнее — <b>0.0</b>. где <b>N</b> — число игроков, сумма — по всем соперникам. Поэтому победа над сильным
</p> приносит больше, чем над слабым, а поражение от слабого отнимает больше. Выбывшие
<ul className="small" style={{ marginTop: 0, paddingLeft: 18 }}> делят последнее место, но между собой не сравниваются: в этой партии все они
<li>Если несколько игроков делят место (ничья) — они делят сумму очков поровну.</li> проиграли, так что пара двух выбывших рейтинг не двигает.
<li>Выбывшие из партии считаются как последнее место.</li>
</ul>
<p className="small">
Итоговый <b>рейтинг</b> игрока — сглаженное среднее: к реальным партиям
«дописываются» 10 виртуальных со средним результатом 0.5 (шкала от 0 до 100):
</p>
<div className="formula">
рейтинг = (10 × 0.5 + сумма очков) / (10 + число партий) × 100
</div>
<p className="small" style={{ marginTop: 10 }}>
Пока партий мало, рейтинг держится около 50 и с опытом сходится к реальному
среднему — стабильные результаты на длинной дистанции ценятся выше короткой
удачной серии.
</p> </p>
</div> </div>
<div className="card"> <div className="card">
<h3>Пример (стол на 4 игроков)</h3> <h3>От чего зависит изменение</h3>
<ul className="small" style={{ margin: 0, paddingLeft: 18 }}>
<li>
<b>K — скорость.</b> У новичка 64, к 20-й партии плавно снижается до 16: новичок
быстро находит свой уровень, а рейтинг опытного игрока не скачет.
</li>
<li>
<b>G — размер стола:</b> 1 + 0.5 × (N − 2) / 4. В дуэли 1.0, вчетвером 1.25,
вшестером 1.5.
</li>
<li>
<b>M — отрыв в паре</b>, от 0.5 до 2. Обычная партия даёт 1. Больше, если:
<ul style={{ paddingLeft: 16 }}>
<li>
партия закончилась раньше — для пар с победителем; обычной считается конец
в предпоследнем раунде;
</li>
<li>разница целей велика относительно числа игроков;</li>
<li>разница миров велика относительно доли миров на игрока.</li>
</ul>
</li>
<li>
<b>Тип победы</b> — для пар с победителем, чем ближе партия, тем меньше:
по целям ×1, по мирам ×0.85, по пластику ×0.7, по ресурсам ×0.6,
последний выживший ×1.
</li>
</ul>
<p className="small" style={{ marginTop: 10 }}>
Раунд окончания, цели и миры вводить <b>необязательно</b>: пропущенное считается
обычным значением и множитель не меняет. «Последний выживший» ставится сам, когда
все соперники выбыли, — отрыв по целям тогда максимальный. Лимит раундов — 8,
с правилом группы «9 раундов» при 5–6 игроках — 9.
</p>
<p className="small" style={{ marginBottom: 0 }}>
Единоличный победитель никогда не теряет рейтинг, а единоличное последнее место
и выбывание никогда его не приносят. Невыбывшие, поделившие место, сравниваются
между собой как в ничьей: слабый может получить рейтинг, сильный — потерять.
</p>
</div>
<div className="card">
<h3>Примеры (опытные игроки)</h3>
<div className="stack" style={{ gap: 6 }}> <div className="stack" style={{ gap: 6 }}>
<div className="row-between small"><span>1-е место</span><b>(4−1−0)/3 = 1.00 → 100</b></div>
<div className="row-between small"><span>2-е место</span><b>(4−2−0)/3 = 0.67 → 67</b></div>
<div className="row-between small"><span>3-е место</span><b>(4−3−0)/3 = 0.33 → 33</b></div>
<div className="row-between small"><span>4-е место</span><b>(4−4−0)/3 = 0.00 → 0</b></div>
<div className="row-between small"> <div className="row-between small">
<span>Ничья за 1-е (двое)</span><b>(4−1−0.5)/3 = 0.83 каждому</b> <span>Дуэль 1600 и 1400: победил сильный</span>
<b>+3.8 / −3.8</b>
</div>
<div className="row-between small">
<span>Дуэль 1600 и 1400: победил слабый</span>
<b>+12.2 / −12.2</b>
</div>
<div className="row-between small">
<span>Равные, победа по целям без деталей</span>
<b>+8.0</b>
</div>
<div className="row-between small">
<span>Равные, победа по мирам без деталей</span>
<b>+6.8</b>
</div>
<div className="row-between small">
<span>Разгром на 3-м раунде (цели 2:0, миры 8:2)</span>
<b>+16.0</b>
</div>
<div className="row-between small">
<span>1-е место за столом на 5 равных</span>
<b>+11.0</b>
</div>
<div className="row-between small">
<span>Новичок обыграл равного опытного</span>
<b>+32 / −8</b>
</div> </div>
</div> </div>
<p className="muted small" style={{ marginBottom: 0 }}>
Рейтинг показывается целым числом, но считается без округления.
</p>
</div>
<div className="card">
<h3>Один рейтинг на всё приложение</h3>
<p className="small" style={{ margin: 0 }}>
Рейтинг у игрока один — по всем его партиям во всех группах, поэтому он одинаковый
в общем топе, в профиле и на странице группы. На странице группы по партиям этой
группы считаются только игры, победы, винрейт и среднее место; рейтинг и статус
«Новичок» там общие.
</p>
</div> </div>
<div className="card"> <div className="card">
@@ -91,6 +160,7 @@ export function HelpPage() {
<ul className="small" style={{ margin: 0, paddingLeft: 18 }}> <ul className="small" style={{ margin: 0, paddingLeft: 18 }}>
<li><b>Винрейт (WR)</b> — доля партий, в которых игрок занял 1-е место.</li> <li><b>Винрейт (WR)</b> — доля партий, в которых игрок занял 1-е место.</li>
<li><b>Среднее место</b> — среднее по завершённым партиям.</li> <li><b>Среднее место</b> — среднее по завершённым партиям.</li>
<li><b>Лучшая партия</b> в профиле — та, что принесла больше всего рейтинга.</li>
<li> <li>
При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту При равенстве рейтинга топ сортируется по: винрейту, числу игр, среднему месту
и затем по нику. и затем по нику.
+192 -86
View File
@@ -4,13 +4,15 @@ import { useNavigate, useParams } from "react-router-dom";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { ConfirmDialog } from "../components/ConfirmDialog"; import { ConfirmDialog } from "../components/ConfirmDialog";
import { MatchMedia } from "../components/MatchMedia"; import { MatchMedia } from "../components/MatchMedia";
import { MatchOutcomeFields } from "../components/MatchOutcomeFields";
import { PickerSelect } from "../components/PickerSelect"; 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 { PlayerLink } from "../components/PlayerLink";
import { Spinner } from "../components/Spinner"; import { Spinner } from "../components/Spinner";
import { finishWarnings, type OutcomeSeat } from "../domain/finishWarnings";
import { formatDate, formatDuration, formatTime } from "../domain/format"; import { formatDate, formatDuration, formatTime } from "../domain/format";
import type { MatchFinishDraftData } from "../domain/types"; import type { MatchFinishDraftData, MatchRead } from "../domain/types";
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons"; import { reasonForSurvivors, type WinReason, winReasonLabel } from "../domain/winReasons";
import { useToast } from "../context/ToastContext"; import { useToast } from "../context/ToastContext";
import { useMe } from "../hooks/auth"; import { useMe } from "../hooks/auth";
import { import {
@@ -24,14 +26,50 @@ import {
} from "../hooks/matches"; } from "../hooks/matches";
import { useGroupFactions } from "../hooks/reference"; 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<string, number> =>
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);
// Места по раскладке: блоки сверху вниз (competition ranking — ничья съедает следующие
// места), затем выбывшие с общим последним местом.
const placeRows = (blocks: number[][], eliminated: number[]) => {
let place = 1;
const survivors = blocks.flatMap((ids) => {
const rows = ids.map((uid) => ({ uid, place, eliminated: false }));
place += ids.length;
return rows;
});
return [...survivors, ...eliminated.map((uid) => ({ uid, place, eliminated: true }))];
};
export function MatchDetailPage() { export function MatchDetailPage() {
const { matchId } = useParams(); const { matchId } = useParams();
// Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN. // Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
const parsed = matchId ? Number(matchId) : NaN; const parsed = matchId ? Number(matchId) : NaN;
const id = Number.isInteger(parsed) ? parsed : null; const id = Number.isInteger(parsed) ? parsed : null;
const { data: match, isLoading, refetch } = useMatch(id); const { data: match, isLoading, error: loadError, refetch } = useMatch(id);
const { data: me } = useMe(); const { data: me } = useMe();
const { data: groupFactions } = useGroupFactions(match?.group_id ?? null); const { data: groupFactions } = useGroupFactions(match?.group_id ?? null);
const finish = useFinishMatch(); const finish = useFinishMatch();
@@ -47,7 +85,10 @@ export function MatchDetailPage() {
const [blocks, setBlocks] = useState<number[][] | null>(null); const [blocks, setBlocks] = useState<number[][] | null>(null);
const [elim, setElim] = useState<number[]>([]); const [elim, setElim] = useState<number[]>([]);
const [comments, setComments] = useState<Record<number, string> | null>(null); const [comments, setComments] = useState<Record<number, string> | null>(null);
const [winReason, setWinReason] = useState<WinReason>("objectives"); const [counts, setCounts] = useState<Counts | null>(null);
// null — причина не выбрана: сброшена после «последнего выжившего» (см. reasonForSurvivors).
const [winReason, setWinReason] = useState<WinReason | null>("objectives");
const [endRound, setEndRound] = useState<number | null>(null);
const [overall, setOverall] = useState(""); const [overall, setOverall] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [confirmRemove, setConfirmRemove] = useState(false); const [confirmRemove, setConfirmRemove] = useState(false);
@@ -86,23 +127,38 @@ export function MatchDetailPage() {
// Чужой черновик применяем, только если человек сейчас ничего не двигает: // Чужой черновик применяем, только если человек сейчас ничего не двигает:
// иначе правка соседа перетёрла бы тайл прямо под рукой. // иначе правка соседа перетёрла бы тайл прямо под рукой.
const incoming = match?.finish_draft; const incoming = match?.finish_draft;
const participants = match?.participants;
useEffect(() => { 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_by === me?.id) return;
if (incoming.updated_at === appliedDraftAt.current) return; if (incoming.updated_at === appliedDraftAt.current) return;
if (Date.now() - lastLocalEdit.current < 1500) return; if (Date.now() - lastLocalEdit.current < 1500) return;
setBlocks(incoming.data.blocks); setBlocks(incoming.data.blocks);
setElim(incoming.data.eliminated); setElim(incoming.data.eliminated);
setComments(incoming.data.comments); 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 ?? ""); setOverall(incoming.data.overall_comment ?? "");
appliedDraftAt.current = incoming.updated_at; appliedDraftAt.current = incoming.updated_at;
}, [incoming, match?.status, me?.id]); }, [incoming, participants, match?.status, me?.id]);
if (isLoading) return <Spinner />; if (isLoading) return <Spinner />;
if (!match) return <div className="muted">Партия не найдена.</div>; if (!match) {
// Из истории чужого профиля можно попасть в партию группы, где зритель не состоит:
// сервер отдаёт 403, и «не найдена» здесь вводила бы в заблуждение.
if (loadError instanceof ApiError && loadError.code === "NOT_GROUP_MEMBER") {
return (
<div className="muted">
Партия сыграна в группе, в которой вы не состоите, — открыть её могут только
участники группы.
</div>
);
}
return <div className="muted">Партия не найдена.</div>;
}
const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (создатель/owner/admin) const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (любой участник группы или админ)
const inProgress = match.status === "in_progress"; const inProgress = match.status === "in_progress";
// Ленивая инициализация раскладки из участников: каждый — отдельным блоком. // Ленивая инициализация раскладки из участников: каждый — отдельным блоком.
@@ -110,6 +166,7 @@ export function MatchDetailPage() {
const finishComments: Record<number, string> = const finishComments: Record<number, string> =
comments ?? comments ??
Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])); 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"; const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE";
@@ -123,44 +180,80 @@ export function MatchDetailPage() {
), ),
win_reason: winReason, win_reason: winReason,
overall_comment: overall.trim() || null, overall_comment: overall.trim() || null,
end_round: endRound,
objectives: countDict(finishCounts, "objectives"),
worlds: countDict(finishCounts, "worlds"),
...patch, ...patch,
}); });
// Раскладка изменилась: при одном выжившем причина становится «последний выживший»,
// при возврате второго — сбрасывается.
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]));
return placeRows(finishBlocks, elim).map(({ uid, place, eliminated }) => ({
userId: uid,
nickname: byId.get(uid)?.nickname ?? "",
place,
eliminated,
objectives: finishCounts[uid]?.objectives ?? null,
worlds: eliminated ? 0 : (finishCounts[uid]?.worlds ?? null),
}));
};
const warnings = () =>
finishWarnings({
seats: outcomeSeats(),
winReason,
endRound,
maxRounds: match.max_rounds,
});
// Строки результатов для API. Место выбывшего и его миры (0) проставит сервер.
const resultRows = () =>
placeRows(finishBlocks, elim).map(({ uid, place, eliminated }) => ({
user_id: uid,
place: eliminated ? null : place,
eliminated,
comment: (finishComments[uid] ?? "").trim() || null,
objectives: finishCounts[uid]?.objectives ?? null,
worlds: eliminated ? null : (finishCounts[uid]?.worlds ?? null),
}));
const submitFinish = async () => { const submitFinish = async () => {
if (!id || !match) return; if (!id || !match) return;
setError(null); setError(null);
if (!winReason) {
setError("Выберите причину победы.");
return;
}
// Дожимаем отложенную запись: иначе последняя правка ушла бы в результаты, // Дожимаем отложенную запись: иначе последняя правка ушла бы в результаты,
// но не в черновик, и второй участник увидел бы не то, что записалось. // но не в черновик, и второй участник увидел бы не то, что записалось.
if (draftTimer.current) { if (draftTimer.current) {
clearTimeout(draftTimer.current); clearTimeout(draftTimer.current);
sendDraft(); sendDraft();
} }
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
let place = 1;
const survivors = finishBlocks.flatMap((ids) => {
const rows = ids.map((uid) => ({
user_id: uid,
place,
eliminated: false,
comment: commentOf(uid),
}));
place += ids.length; // competition ranking: ничья съедает следующие места
return rows;
});
try { try {
await finish.mutateAsync({ await finish.mutateAsync({
matchId: id, matchId: id,
body: { body: {
participants: [ participants: resultRows(),
...survivors,
...elim.map((uid) => ({
user_id: uid,
place: null,
eliminated: true,
comment: commentOf(uid),
})),
],
win_reason: winReason, win_reason: winReason,
end_round: endRound,
overall_comment: overall.trim() || null, overall_comment: overall.trim() || null,
expected_version: match.version, expected_version: match.version,
}, },
@@ -188,7 +281,9 @@ export function MatchDetailPage() {
setComments( setComments(
Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])), 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 ?? ""); setOverall(match.overall_comment ?? "");
setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id]))); setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id])));
setError(null); setError(null);
@@ -199,36 +294,31 @@ export function MatchDetailPage() {
setEditing(false); setEditing(false);
setBlocks(null); setBlocks(null);
setComments(null); setComments(null);
setCounts(null);
setError(null); setError(null);
}; };
const submitEdit = async () => { const submitEdit = async () => {
if (!id || !match) return; if (!id || !match) return;
setError(null); setError(null);
if (!winReason) {
setError("Выберите причину победы.");
return;
}
const wasRandom = Object.fromEntries( const wasRandom = Object.fromEntries(
match.participants.map((p) => [p.user_id, p.was_random]), 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 { try {
await updateMatch.mutateAsync({ await updateMatch.mutateAsync({
matchId: id, matchId: id,
body: { 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, win_reason: winReason,
end_round: endRound,
overall_comment: overall.trim() || null, overall_comment: overall.trim() || null,
expected_version: match.version, expected_version: match.version,
}, },
@@ -276,6 +366,13 @@ export function MatchDetailPage() {
.map((p) => ({ id: p.faction_id, code: "", name_ru: p.faction_name, expansion_id: 0 })), .map((p) => ({ id: p.faction_id, code: "", name_ru: p.faction_name, expansion_id: 0 })),
]; ];
const placeHint = (
<p className="muted small">
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого игрока, чтобы
разделить место (ничья). Цели и миры — на конец партии.
</p>
);
return ( return (
<div> <div>
<div className="card"> <div className="card">
@@ -294,6 +391,9 @@ export function MatchDetailPage() {
{!inProgress && ( {!inProgress && (
<div className="small" style={{ marginTop: 4 }}> <div className="small" style={{ marginTop: 4 }}>
Победа: <b>{winReasonLabel(match.win_reason)}</b> Победа: <b>{winReasonLabel(match.win_reason)}</b>
{match.end_round != null && (
<span className="muted"> · конец в {match.end_round}-м раунде из {match.max_rounds}</span>
)}
</div> </div>
)} )}
{match.overall_comment && <p className="small" style={{ marginTop: 6 }}>{match.overall_comment}</p>} {match.overall_comment && <p className="small" style={{ marginTop: 6 }}>{match.overall_comment}</p>}
@@ -304,20 +404,16 @@ export function MatchDetailPage() {
<> <>
<div className="card"> <div className="card">
<h3>Правка результатов</h3> <h3>Правка результатов</h3>
<p className="muted small"> {placeHint}
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
игрока, чтобы разделить место (ничья).
</p>
<PlaceEditor <PlaceEditor
players={match.participants} players={match.participants}
blocks={finishBlocks} blocks={finishBlocks}
eliminated={elim} eliminated={elim}
comments={finishComments} comments={finishComments}
onChange={(b, e) => { counts={finishCounts}
setBlocks(b); onChange={applyLayout}
setElim(e);
}}
onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })} onComment={(uid, text) => setComments({ ...finishComments, [uid]: text })}
onCount={applyCount}
/> />
</div> </div>
@@ -345,16 +441,14 @@ export function MatchDetailPage() {
</div> </div>
</div> </div>
<div className="card"> <MatchOutcomeFields
<h3>Причина победы</h3> winReason={winReason}
<PickerSelect onReason={setWinReason}
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null} endRound={endRound}
options={REASON_OPTIONS} onEndRound={setEndRound}
placeholder="— причина —" maxRounds={match.max_rounds}
renderOption={(o) => o.label} warnings={warnings()}
onPick={(o) => setWinReason(o.id)}
/> />
</div>
<div className="card"> <div className="card">
<h3>О партии</h3> <h3>О партии</h3>
@@ -413,6 +507,11 @@ export function MatchDetailPage() {
<span className="muted small">· {p.faction_name}</span> <span className="muted small">· {p.faction_name}</span>
{p.was_random && <span className="badge">🎲</span>} {p.was_random && <span className="badge">🎲</span>}
</PlayerLink> </PlayerLink>
{(p.objectives != null || p.worlds != null) && (
<div className="small muted" style={{ marginLeft: 32 }}>
цели {p.objectives ?? "—"} · миры {p.worlds ?? "—"}
</div>
)}
{p.comment && <div className="small muted" style={{ marginLeft: 32 }}>{p.comment}</div>} {p.comment && <div className="small muted" style={{ marginLeft: 32 }}>{p.comment}</div>}
</div> </div>
))} ))}
@@ -448,10 +547,7 @@ export function MatchDetailPage() {
<> <>
<div className="card"> <div className="card">
<h3>Места</h3> <h3>Места</h3>
<p className="muted small"> {placeHint}
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
игрока, чтобы разделить место (ничья).
</p>
{match.finish_draft && match.finish_draft.updated_by !== me?.id && ( {match.finish_draft && match.finish_draft.updated_by !== me?.id && (
<p className="small" style={{ color: "var(--accent-2)" }}> <p className="small" style={{ color: "var(--accent-2)" }}>
Результаты заполняет также {match.finish_draft.updated_by_nickname ?? "другой игрок"} Результаты заполняет также {match.finish_draft.updated_by_nickname ?? "другой игрок"}
@@ -464,10 +560,10 @@ export function MatchDetailPage() {
blocks={finishBlocks} blocks={finishBlocks}
eliminated={elim} eliminated={elim}
comments={finishComments} comments={finishComments}
counts={finishCounts}
onChange={(b, e) => { onChange={(b, e) => {
setBlocks(b); const reason = applyLayout(b, e);
setElim(e); queueDraft(draftOf({ blocks: b, eliminated: e, win_reason: reason }));
queueDraft(draftOf({ blocks: b, eliminated: e }));
}} }}
onComment={(uid, text) => { onComment={(uid, text) => {
const next = { ...finishComments, [uid]: text }; const next = { ...finishComments, [uid]: text };
@@ -480,23 +576,33 @@ export function MatchDetailPage() {
}), }),
); );
}} }}
/> onCount={(uid, field, value) => {
</div> const next = applyCount(uid, field, value);
queueDraft(
<div className="card"> draftOf({
<h3>Причина победы</h3> objectives: countDict(next, "objectives"),
<PickerSelect worlds: countDict(next, "worlds"),
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null} }),
options={REASON_OPTIONS} );
placeholder="— причина —"
renderOption={(o) => o.label}
onPick={(o) => {
setWinReason(o.id);
queueDraft(draftOf({ win_reason: o.id }));
}} }}
/> />
</div> </div>
<MatchOutcomeFields
winReason={winReason}
onReason={(reason) => {
setWinReason(reason);
queueDraft(draftOf({ win_reason: reason }));
}}
endRound={endRound}
onEndRound={(round) => {
setEndRound(round);
queueDraft(draftOf({ end_round: round }));
}}
maxRounds={match.max_rounds}
warnings={warnings()}
/>
<div className="card"> <div className="card">
<h3>О партии</h3> <h3>О партии</h3>
<textarea <textarea
+4 -4
View File
@@ -6,7 +6,9 @@ import { Spinner } from "../components/Spinner";
import type { LeaderboardEntry } from "../domain/types"; import type { LeaderboardEntry } from "../domain/types";
import { useLeaderboard } from "../hooks/stats"; import { useLeaderboard } from "../hooks/stats";
type SortKey = "rank" | "games" | "wins" | "win_rate" | "score"; // Столбца побед нет: четырёхзначный рейтинг иначе не помещается в строку, а доля побед
// уже есть в WR.
type SortKey = "rank" | "games" | "win_rate" | "score";
function pct(v: number | null | undefined): string { function pct(v: number | null | undefined): string {
return v == null ? "—" : `${Math.round(v * 100)}%`; return v == null ? "—" : `${Math.round(v * 100)}%`;
@@ -50,7 +52,6 @@ export function OverallStatsPage() {
<span className="nick">{e.nickname}</span> <span className="nick">{e.nickname}</span>
</span> </span>
<span className="st-num">{e.games}</span> <span className="st-num">{e.games}</span>
<span className="st-num">{e.wins}</span>
<span className="st-num">{pct(e.win_rate)}</span> <span className="st-num">{pct(e.win_rate)}</span>
<span className={"st-num lb-score" + (isProvisional || e.score == null ? " provisional" : "")}> <span className={"st-num lb-score" + (isProvisional || e.score == null ? " provisional" : "")}>
{e.score ?? "—"} {e.score ?? "—"}
@@ -70,9 +71,8 @@ export function OverallStatsPage() {
<button onClick={() => toggleSort("rank")}>#{arrow("rank")}</button> <button onClick={() => toggleSort("rank")}>#{arrow("rank")}</button>
<span>Игрок</span> <span>Игрок</span>
<button onClick={() => toggleSort("games")}>Игр{arrow("games")}</button> <button onClick={() => toggleSort("games")}>Игр{arrow("games")}</button>
<button onClick={() => toggleSort("wins")}>Поб{arrow("wins")}</button>
<button onClick={() => toggleSort("win_rate")}>WR{arrow("win_rate")}</button> <button onClick={() => toggleSort("win_rate")}>WR{arrow("win_rate")}</button>
<button onClick={() => toggleSort("score")}>Очки{arrow("score")}</button> <button onClick={() => toggleSort("score")}>Рейтинг{arrow("score")}</button>
</div> </div>
{entries.length === 0 ? ( {entries.length === 0 ? (
<div className="muted small" style={{ paddingTop: 8 }}> <div className="muted small" style={{ paddingTop: 8 }}>
@@ -6,7 +6,7 @@ import { Spinner } from "../../components/Spinner";
import { useToast } from "../../context/ToastContext"; import { useToast } from "../../context/ToastContext";
import { useAdminSetPassword, useAdminUpdateUser, useAdminUsers } from "../../hooks/admin"; import { useAdminSetPassword, useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
// DEV-ТОЛЬКО: удаление аккаунтов. Импорт используется лишь под import.meta.env.DEV, // DEV-ТОЛЬКО: удаление аккаунтов. Импорт используется лишь под import.meta.env.DEV,
// поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход). // поэтому в прод-сборке вырезается тришейкингом (как и dev-вход).
import { DevDeleteAccountButton } from "./DevDeleteAccountButton"; import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
export function AdminAccountsPage() { export function AdminAccountsPage() {
+107 -6
View File
@@ -5,7 +5,15 @@ import { ApiError } from "../../api/client";
import { MatchMedia } from "../../components/MatchMedia"; import { MatchMedia } from "../../components/MatchMedia";
import { Spinner } from "../../components/Spinner"; import { Spinner } from "../../components/Spinner";
import { Switch } from "../../components/Switch"; import { Switch } from "../../components/Switch";
import { WIN_REASONS, type WinReason } from "../../domain/winReasons"; import { finishWarnings } from "../../domain/finishWarnings";
import { MAX_COUNT, parseCount } from "../../domain/matchCounts";
import {
LAST_STANDING,
reasonForSurvivors,
WIN_REASONS,
type WinReason,
winReasonLabel,
} from "../../domain/winReasons";
import { import {
useAdminDeleteAttachment, useAdminDeleteAttachment,
useAdminFactions, useAdminFactions,
@@ -23,6 +31,8 @@ interface Row {
eliminated: boolean; eliminated: boolean;
was_random: boolean; was_random: boolean;
comment: string; comment: string;
objectives: number | null;
worlds: number | null;
} }
export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) { export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) {
@@ -37,7 +47,8 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
const [rows, setRows] = useState<Row[]>([]); const [rows, setRows] = useState<Row[]>([]);
const [playedAt, setPlayedAt] = useState(""); const [playedAt, setPlayedAt] = useState("");
const [overall, setOverall] = useState(""); const [overall, setOverall] = useState("");
const [winReason, setWinReason] = useState<WinReason>("objectives"); const [winReason, setWinReason] = useState<WinReason | null>("objectives");
const [endRound, setEndRound] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
@@ -51,19 +62,32 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
eliminated: p.eliminated, eliminated: p.eliminated,
was_random: p.was_random, was_random: p.was_random,
comment: p.comment ?? "", comment: p.comment ?? "",
objectives: p.objectives ?? null,
worlds: p.worlds ?? null,
})), })),
); );
setPlayedAt(match.played_at); setPlayedAt(match.played_at);
setOverall(match.overall_comment ?? ""); setOverall(match.overall_comment ?? "");
if (match.win_reason) setWinReason(match.win_reason); setWinReason(match.win_reason ?? null);
setEndRound(match.end_round ?? null);
} }
}, [match]); }, [match]);
const upd = (i: number, patch: Partial<Row>) => const upd = (i: number, patch: Partial<Row>) => {
setRows((rs) => rs.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); const next = rows.map((r, idx) => (idx === i ? { ...r, ...patch } : r));
setRows(next);
// Выбывание меняет число выживших: «последний выживший» ставится и снимается сам.
if ("eliminated" in patch) {
setWinReason(reasonForSurvivors(winReason, next.filter((r) => !r.eliminated).length));
}
};
const save = async () => { const save = async () => {
setError(null); setError(null);
if (!winReason) {
setError("Выберите причину победы.");
return;
}
try { try {
await update.mutateAsync({ await update.mutateAsync({
matchId, matchId,
@@ -71,6 +95,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
played_at: playedAt, played_at: playedAt,
overall_comment: overall.trim() || null, overall_comment: overall.trim() || null,
win_reason: winReason, win_reason: winReason,
end_round: endRound,
participants: rows.map((r) => ({ participants: rows.map((r) => ({
user_id: r.user_id, user_id: r.user_id,
faction_id: r.faction_id, faction_id: r.faction_id,
@@ -78,6 +103,8 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
eliminated: r.eliminated, eliminated: r.eliminated,
was_random: r.was_random, was_random: r.was_random,
comment: r.comment.trim() || null, comment: r.comment.trim() || null,
objectives: r.objectives,
worlds: r.eliminated ? null : r.worlds,
})), })),
}, },
}); });
@@ -87,6 +114,22 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
} }
}; };
const warnings = match
? finishWarnings({
seats: rows.map((r) => ({
userId: r.user_id,
nickname: r.nickname,
place: r.place,
eliminated: r.eliminated,
objectives: r.objectives,
worlds: r.eliminated ? 0 : r.worlds,
})),
winReason,
endRound,
maxRounds: match.max_rounds,
})
: [];
return ( return (
// Полноэкранная страница правки (не нижний «лист»): занимает весь экран. // Полноэкранная страница правки (не нижний «лист»): занимает весь экран.
<div <div
@@ -159,23 +202,81 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
выбыл выбыл
</label> </label>
</div> </div>
<div className="row">
<input <input
style={{ flex: 1 }}
placeholder="Комментарий об игроке" placeholder="Комментарий об игроке"
value={r.comment} value={r.comment}
onChange={(e) => upd(i, { comment: e.target.value })} onChange={(e) => upd(i, { comment: e.target.value })}
/> />
<input
style={{ flex: "0 0 70px" }}
type="number"
min={0}
max={MAX_COUNT}
placeholder="цели"
title="Цели на конец партии"
value={r.objectives ?? ""}
onChange={(e) => upd(i, { objectives: parseCount(e.target.value) })}
/>
<input
style={{ flex: "0 0 70px" }}
type="number"
min={0}
max={MAX_COUNT}
placeholder="миры"
title="Миры на конец партии"
disabled={r.eliminated}
value={r.eliminated ? 0 : (r.worlds ?? "")}
onChange={(e) => upd(i, { worlds: parseCount(e.target.value) })}
/>
</div>
</div> </div>
))} ))}
<div className="field"> <div className="field">
<label className="label">Причина победы</label> <label className="label">Причина победы</label>
<select value={winReason} onChange={(e) => setWinReason(e.target.value as WinReason)}> {winReason === LAST_STANDING ? (
<select value={LAST_STANDING} disabled>
<option value={LAST_STANDING}>{winReasonLabel(LAST_STANDING)}</option>
</select>
) : (
<select
value={winReason ?? ""}
onChange={(e) => setWinReason(e.target.value as WinReason)}
>
<option value="" disabled>
— выберите причину —
</option>
{WIN_REASONS.map((w) => ( {WIN_REASONS.map((w) => (
<option key={w.code} value={w.code}> <option key={w.code} value={w.code}>
{w.label} {w.label}
</option> </option>
))} ))}
</select> </select>
)}
</div>
<div className="field">
<label className="label">Раунд окончания (из {match.max_rounds})</label>
<select
value={endRound ?? ""}
onChange={(e) => setEndRound(e.target.value === "" ? null : Number(e.target.value))}
>
<option value="">не указан</option>
{Array.from({ length: match.max_rounds }, (_, k) => k + 1).map((n) => (
<option key={n} value={n}>
{n}-й
</option>
))}
</select>
{warnings.length > 0 && (
<ul className="warn-list">
{warnings.map((w) => (
<li key={w}>{w}</li>
))}
</ul>
)}
</div> </div>
<div className="field"> <div className="field">
@@ -10,9 +10,9 @@ import { useToast } from "../../context/ToastContext";
* DEV-ТОЛЬКО кнопка жёсткого удаления аккаунта. * DEV-ТОЛЬКО кнопка жёсткого удаления аккаунта.
* *
* Эндпоинт `DELETE /api/admin/dev/users/{id}` существует только в 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, поэтому в прод-сборке * рендерится лишь под `import.meta.env.DEV` в AdminAccountsPage, поэтому в прод-сборке
* он не используется и вырезается тришейкингом — в прод/тест удаление недоступно. * он не используется и вырезается тришейкингом — в проде удаление недоступно.
*/ */
export function DevDeleteAccountButton({ export function DevDeleteAccountButton({
userId, userId,
@@ -39,7 +39,7 @@ export function DevDeleteAccountButton({
<button <button
className="btn small" className="btn small"
// Яркая (сплошная) красная кнопка. Стиль инлайном, а не классом в общем CSS, // Яркая (сплошная) красная кнопка. Стиль инлайном, а не классом в общем CSS,
// чтобы в прод/тест-стили не попало ничего, связанного с удалением. // чтобы в прод-стили не попало ничего, связанного с удалением.
style={{ background: "var(--danger)", borderColor: "var(--danger)", color: "#fff" }} style={{ background: "var(--danger)", borderColor: "var(--danger)", color: "#fff" }}
disabled={del.isPending} disabled={del.isPending}
onClick={() => setConfirmOpen(true)} onClick={() => setConfirmOpen(true)}
+29
View File
@@ -103,6 +103,11 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
.error-text { color: var(--danger); font-size: 13px; } .error-text { color: var(--danger); font-size: 13px; }
/* Изменение рейтинга за партию в истории профиля */
.rating-delta { color: var(--text-muted); margin-top: 6px; }
.rating-up { color: var(--success); }
.rating-down { color: var(--danger); }
/* Формула в справке */ /* Формула в справке */
.formula { .formula {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
@@ -371,6 +376,30 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
padding: 7px 10px; padding: 7px 10px;
border-radius: 8px; border-radius: 8px;
} }
/* Строка под игроком: комментарий + цели и миры на конец партии */
.rank-details { display: flex; gap: 6px; align-items: flex-end; }
.rank-details .rank-comment { flex: 1; min-width: 0; }
.rank-count { flex: 0 0 58px; margin-top: 8px; }
.rank-count span { display: block; font-size: 10px; color: var(--text-muted); text-align: center; }
.rank-count input {
width: 100%;
font-size: 13px;
padding: 7px 4px;
border-radius: 8px;
text-align: center;
}
/* Предупреждения о несогласованном вводе (не блокируют отправку) */
.warn-list {
margin: 10px 0 0;
padding: 8px 10px 8px 26px;
border: 1px solid var(--accent-2);
border-radius: var(--radius-sm);
background: rgba(240, 160, 75, 0.08);
color: var(--accent-2);
font-size: 13px;
}
.warn-list li + li { margin-top: 4px; }
.rank-unlink { .rank-unlink {
flex: none; flex: none;
width: 30px; height: 30px; width: 30px; height: 30px;
+1 -1
View File
@@ -115,7 +115,7 @@
/* Таблица общей статистики (сортируемая, кликабельные строки) */ /* Таблица общей статистики (сортируемая, кликабельные строки) */
.st-row, .st-head { .st-row, .st-head {
display: grid; display: grid;
grid-template-columns: 26px 1fr 40px 40px 48px 52px; grid-template-columns: 26px 1fr 40px 48px 60px;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 8px 2px; padding: 8px 2px;
-29
View File
@@ -1,29 +0,0 @@
commit 65bebf8b85fcbf921cbbc4f2599953a9fa3ca9c6 (HEAD -> dev)
Author: NotBigGhost <ivan@arseniev.info>
Date: Wed Jun 17 04:45:33 2026 +0300
Добавление ssh-ключа к контейнеру tunnel
commit 7e8b748caf5d9f4ab3d2d7f76d6a4b850b9b1366
Author: NotBigGhost <ivan@arseniev.info>
Date: Wed Jun 17 04:27:11 2026 +0300
Правки локального размещения дева, перенос тунеля в контейнер
commit b04fbb2e171a7fe3376f3633dfee9e29e4e4bb90
Author: NotBigGhost <ivan@arseniev.info>
Date: Tue Jun 16 19:15:54 2026 +0300
Завершена настройка домена для дева, теста и прода. Соединение через ssh-туннель
commit 56b5d09a4dc5e6f5d06e32c2635c84a66597e71f (origin/main, main)
Author: NotBigGhost <ivan@arseniev.info>
Date: Tue Jun 16 17:41:06 2026 +0300
v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
commit 6ab74f01aaf1ac042a269c7660b3e564b93350c7
Author: NotBigGhost <ivan@arseniev.info>
Date: Tue Jun 16 16:53:41 2026 +0300
first commit
+8 -33
View File
@@ -1,11 +1,10 @@
#!/usr/bin/env pwsh #!/usr/bin/env pwsh
# Unified launcher for dev / test. APP_ENV in the root .env decides what to run. # Unified launcher for dev. APP_ENV in the root .env decides what to run.
# development -> uvicorn --reload (backend) + vite (frontend), native, two windows # development -> uvicorn --reload (backend) + vite (frontend), native, two windows
# test -> docker compose prod-clone (port 8080)
# production -> NOT started by launcher (prod is separate: docker compose up -d on Pi) # production -> NOT started by launcher (prod is separate: docker compose up -d on Pi)
# #
# LOCAL_PUBLIC=vps (dev only) opens an SSH tunnel to the VPS, exposing dev at # LOCAL_PUBLIC=vps opens an SSH tunnel to the VPS, exposing dev at
# https://forbidden-stars.ru. test/prod expose themselves via an in-container tunnel. # https://forbidden-stars.ru. Prod exposes itself via an in-container tunnel.
# #
# Run: .\run.ps1 # Run: .\run.ps1
# If blocked by policy: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned # If blocked by policy: Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
@@ -129,42 +128,18 @@ switch ($appEnv) {
if ($localPublic -eq "vps") { if ($localPublic -eq "vps") {
Start-VpsTunnel 5173 Start-VpsTunnel 5173
Write-Host " Public: https://forbidden-stars.ru" -ForegroundColor Green Write-Host " Public: https://forbidden-stars.ru" -ForegroundColor Green
Write-Host " WARNING: dev is public. Anyone can log in by nickname without a password," -ForegroundColor Yellow
Write-Host " list/create players, hard-delete accounts and read Swagger." -ForegroundColor Yellow
Write-Host " Do not keep a copy of production data in the dev database." -ForegroundColor Yellow
} }
} }
"test" {
$compose = Join-Path $root "docker-compose.test.yml"
docker info --format '{{.ServerVersion}}' *> $null
if ($LASTEXITCODE -ne 0) {
Write-Host "Docker daemon is not reachable. Start Docker Desktop, wait until it is running, then re-run .\run.ps1" -ForegroundColor Red
exit 1
}
$keyFile = Join-Path $root "deploy\tunnel\id_tunnel"
if (-not (Test-Path $keyFile -PathType Leaf)) {
if (Test-Path $keyFile -PathType Container) {
Write-Host "deploy\tunnel\id_tunnel is a DIRECTORY - Docker auto-created it because the key file was missing." -ForegroundColor Red
Write-Host "Remove it first: Remove-Item -Recurse -Force deploy\tunnel\id_tunnel" -ForegroundColor Yellow
} else {
Write-Host "No SSH key at deploy\tunnel\id_tunnel (the tunnel container needs it)." -ForegroundColor Red
}
Write-Host "Add your VPS-authorized key: Copy-Item `$env:USERPROFILE\.ssh\id_ed25519 deploy\tunnel\id_tunnel" -ForegroundColor Yellow
exit 1
}
Write-Host "Building and starting prod-clone in Docker (app + in-container tunnel)..." -ForegroundColor Green
docker compose -f $compose up --build -d
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Write-Host ""
Write-Host " Public: https://forbidden-stars.ru (Swagger: /api/docs)" -ForegroundColor Green
Write-Host " (no host port - reachable only via the domain; tunnel runs inside compose)"
Write-Host " Logs: docker compose -f docker-compose.test.yml logs -f"
Write-Host " Stop: docker compose -f docker-compose.test.yml down -v"
}
"production" { "production" {
Write-Host "production is not started by the launcher - prod is separate." -ForegroundColor Yellow Write-Host "production is not started by the launcher - prod is separate." -ForegroundColor Yellow
Write-Host "Deploy on Pi (from main branch): docker compose up -d --build" Write-Host "Deploy: PC (main branch) scripts\build-push.ps1, then on Pi: docker compose up -d"
exit 1 exit 1
} }
default { default {
Write-Host "Unknown APP_ENV='$appEnv'. Allowed: development | test | production." -ForegroundColor Red Write-Host "Unknown APP_ENV='$appEnv'. Allowed: development | production." -ForegroundColor Red
exit 1 exit 1
} }
} }
+11 -16
View File
@@ -1,13 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ╔═══════════════════════════════════════════════════════════════════════════╗ # ╔═══════════════════════════════════════════════════════════════════════════╗
# ║ Единый лаунчер dev / test. Что запускать — решает APP_ENV из корневого .env ║ # ║ Единый лаунчер dev. Что запускать — решает APP_ENV из корневого .env ║
# ╚═══════════════════════════════════════════════════════════════════════════╝ # ╚═══════════════════════════════════════════════════════════════════════════╝
# development → uvicorn --reload (бэк) + vite (фронт), нативно (Ctrl+C останавливает оба) # development → uvicorn --reload (бэк) + vite (фронт), нативно (Ctrl+C останавливает оба)
# test → docker compose прод-клон (порт 8080)
# production → лаунчером НЕ запускается (прод обособлен: docker compose up -d на Pi) # production → лаунчером НЕ запускается (прод обособлен: docker compose up -d на Pi)
# #
# LOCAL_PUBLIC=vps (только dev) поднимает SSH-туннель на VPS → https://forbidden-stars.ru # LOCAL_PUBLIC=vps поднимает SSH-туннель на VPS → https://forbidden-stars.ru
# test/prod выставляют себя сами через туннель-контейнер (см. docker-compose*.yml). # Прод выставляет себя сам через туннель-контейнер (см. docker-compose.yml).
# #
# Запуск: ./run.sh (при необходимости: chmod +x run.sh) # Запуск: ./run.sh (при необходимости: chmod +x run.sh)
set -euo pipefail set -euo pipefail
@@ -76,25 +75,21 @@ case "$app_env" in
trap 'kill "$back" 2>/dev/null || true' EXIT INT TERM trap 'kill "$back" 2>/dev/null || true' EXIT INT TERM
echo " Бэк: http://127.0.0.1:8000 (Swagger: /api/docs)" echo " Бэк: http://127.0.0.1:8000 (Swagger: /api/docs)"
echo " Фронт: http://127.0.0.1:5173" echo " Фронт: http://127.0.0.1:5173"
[ "$local_public" = "vps" ] && start_tunnel 5173 if [ "$local_public" = "vps" ]; then
start_tunnel 5173
echo " ВНИМАНИЕ: dev опубликован. Любой посетитель может войти по нику без пароля,"
echo " смотреть и создавать игроков, жёстко удалять аккаунты и читать Swagger."
echo " Не держите в dev-базе копию прод-данных."
fi
( cd "$root/frontend" && npm run dev ) ( cd "$root/frontend" && npm run dev )
;; ;;
test)
docker info >/dev/null 2>&1 || { echo "Docker-демон недоступен — запусти Docker и повтори ./run.sh"; exit 1; }
echo "Сборка и запуск прод-клона в Docker (app + туннель в контейнере)…"
docker compose -f "$root/docker-compose.test.yml" up --build -d
echo " Публично: https://forbidden-stars.ru (Swagger: /api/docs)"
echo " (портов на хост нет — только через домен; туннель живёт внутри compose)"
echo " Логи: docker compose -f docker-compose.test.yml logs -f"
echo " Стоп: docker compose -f docker-compose.test.yml down -v"
;;
production) production)
echo "production лаунчером не запускается — прод обособлен." echo "production лаунчером не запускается — прод обособлен."
echo "Деплой на Pi (из ветки main): docker compose up -d --build" echo "Деплой: на ПК (ветка main) scripts/build-push.sh, затем на Pi: docker compose up -d"
exit 1 exit 1
;; ;;
*) *)
echo "Неизвестный APP_ENV='$app_env'. Допустимо: development | test | production." echo "Неизвестный APP_ENV='$app_env'. Допустимо: development | production."
exit 1 exit 1
;; ;;
esac esac
-20
View File
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
# Выгрузка ПРОДА: в целевую папку попадают только файлы, нужные для запуска
# прод-контейнера (без тестов, dev-входа и тест-специфики).
#
# Использование: scripts/export-prod.sh <целевая-папка> [git-ref]
# git-ref по умолчанию HEAD; для прод-ветки: scripts/export-prod.sh /srv/fs prod
set -euo pipefail
DEST="${1:?Укажите целевую папку: scripts/export-prod.sh <dir> [ref]}"
REF="${2:-HEAD}"
mkdir -p "$DEST"
# git archive уважает export-ignore из .gitattributes (тесты, dev-вход и т.п. отсеяны)
git archive --format=tar "$REF" | tar -x -C "$DEST"
# тест/dev-специфика в проде не нужна (тест-compose и лаунчер)
rm -f "$DEST/docker-compose.test.yml" "$DEST/run.ps1" "$DEST/run.sh"
echo "[export-prod] Прод выгружен в: $DEST"
echo " дальше: cp .env.example .env && docker compose up -d --build"
-18
View File
@@ -1,18 +0,0 @@
#!/usr/bin/env bash
# Выгрузка ТЕСТА (прод-клон для локального контейнера на x86): в целевую папку
# попадают только файлы, нужные для запуска тест-контейнера.
#
# Использование: scripts/export-test.sh <целевая-папка> [git-ref]
set -euo pipefail
DEST="${1:?Укажите целевую папку: scripts/export-test.sh <dir> [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"
+5 -76
View File
@@ -1,6 +1,5 @@
# Forbidden Stars backups from the PC. Talks to the `backup` container of the prod on the Pi # Forbidden Stars backups from the PC. Talks to the `backup` container of the prod on the Pi
# over SSH, or (with -Target test) to the local test clone (docker-compose.test.yml). # over SSH. Step-by-step guide: deploy/backup/README.md
# Step-by-step guide: deploy/backup/README.md
# #
# .\scripts\fs-backup.ps1 status backup state on the Pi # .\scripts\fs-backup.ps1 status backup state on the Pi
# .\scripts\fs-backup.ps1 list [-Repo vps] snapshot history # .\scripts\fs-backup.ps1 list [-Repo vps] snapshot history
@@ -8,9 +7,6 @@
# .\scripts\fs-backup.ps1 verify check data integrity in the repositories # .\scripts\fs-backup.ps1 verify check data integrity in the repositories
# .\scripts\fs-backup.ps1 pull [-Snapshot <id>] [-Repo vps] # .\scripts\fs-backup.ps1 pull [-Snapshot <id>] [-Repo vps]
# download a snapshot to backups\ (sha256 checked) # download a snapshot to backups\ (sha256 checked)
# .\scripts\fs-backup.ps1 restore-test -File backups\fs_....tar
# practice restore into the local test clone
# add -Target test to run status/list/now/verify/pull against the local test clone
# #
# Settings come from the root .env (an environment variable with the same name wins): # Settings come from the root .env (an environment variable with the same name wins):
# BACKUP_PI_SSH how to reach the Pi over SSH, e.g. pi@192.168.1.10 (or a Host alias) # BACKUP_PI_SSH how to reach the Pi over SSH, e.g. pi@192.168.1.10 (or a Host alias)
@@ -19,15 +15,12 @@
# Keep this file ASCII-only: Windows PowerShell 5.1 breaks on non-ASCII without a BOM. # Keep this file ASCII-only: Windows PowerShell 5.1 breaks on non-ASCII without a BOM.
param( param(
[Parameter(Position = 0)] [Parameter(Position = 0)]
[ValidateSet("status", "list", "now", "verify", "pull", "restore-test", "help")] [ValidateSet("status", "list", "now", "verify", "pull", "help")]
[string]$Command = "help", [string]$Command = "help",
[string]$Snapshot = "latest", [string]$Snapshot = "latest",
[ValidateSet("local", "vps")] [ValidateSet("local", "vps")]
[string]$Repo = "local", [string]$Repo = "local",
[string]$Tag = "", [string]$Tag = ""
[string]$File = "",
[ValidateSet("pi", "test")]
[string]$Target = "pi"
) )
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
# Native tools (ssh, scp, docker) write progress and warnings to stderr. Under "Stop" with a # Native tools (ssh, scp, docker) write progress and warnings to stderr. Under "Stop" with a
@@ -35,7 +28,6 @@ $ErrorActionPreference = "Stop"
# run under "Continue" and success is judged by $LASTEXITCODE only. # run under "Continue" and success is judged by $LASTEXITCODE only.
$root = Split-Path -Parent $PSScriptRoot $root = Split-Path -Parent $PSScriptRoot
$envFile = Join-Path $root ".env" $envFile = Join-Path $root ".env"
$testCompose = Join-Path $root "docker-compose.test.yml"
# Read a key: environment variable first, then the root .env (last assignment wins). # Read a key: environment variable first, then the root .env (last assignment wins).
function Get-Setting([string]$name, [string]$default) { function Get-Setting([string]$name, [string]$default) {
@@ -56,7 +48,7 @@ function Fail([string]$msg) {
} }
function Show-Help { function Show-Help {
Get-Content $PSCommandPath -TotalCount 19 | ForEach-Object { $_ -replace '^# ?', '' } Get-Content $PSCommandPath -TotalCount 15 | ForEach-Object { $_ -replace '^# ?', '' }
} }
$piSsh = Get-Setting "BACKUP_PI_SSH" "" $piSsh = Get-Setting "BACKUP_PI_SSH" ""
@@ -77,30 +69,10 @@ function Invoke-Scp([string]$from, [string]$to) {
& scp -o ConnectTimeout=15 $from $to & scp -o ConnectTimeout=15 $from $to
} }
function Invoke-TestCompose([string[]]$composeArgs) { # Run fs-backup with arguments on the Pi; output goes to the console.
$ErrorActionPreference = "Continue"
& docker compose -f $testCompose @composeArgs
}
# Make sure the backup container of the test clone is running (build it if needed).
function Start-TestBackup {
$id = (Invoke-TestCompose @("ps", "-q", "backup")) | Select-Object -First 1
if (-not $id) {
Write-Host "Starting the backup container of the test clone..." -ForegroundColor Cyan
Invoke-TestCompose @("up", "-d", "--build", "backup") | Out-Host
if ($LASTEXITCODE -ne 0) { Fail "Could not start the test clone backup container." }
}
}
# Run fs-backup with arguments on the chosen target; output goes to the console.
function Invoke-FsBackup([string[]]$fsArgs) { function Invoke-FsBackup([string[]]$fsArgs) {
if ($Target -eq "test") {
Start-TestBackup
Invoke-TestCompose (@("exec", "-T", "backup", "fs-backup") + $fsArgs)
} else {
Invoke-Pi ("docker compose exec -T backup fs-backup " + ($fsArgs -join " ")) Invoke-Pi ("docker compose exec -T backup fs-backup " + ($fsArgs -join " "))
} }
}
function Assert-LastExit([string]$what) { function Assert-LastExit([string]$what) {
if ($LASTEXITCODE -ne 0) { Fail "$what failed (exit code $LASTEXITCODE)." } if ($LASTEXITCODE -ne 0) { Fail "$what failed (exit code $LASTEXITCODE)." }
@@ -126,19 +98,6 @@ function Invoke-Pull {
Write-Host "Snapshot $id ($Repo) -> $local" -ForegroundColor Cyan Write-Host "Snapshot $id ($Repo) -> $local" -ForegroundColor Cyan
$partial = "$local.part" $partial = "$local.part"
if ($Target -eq "test") {
Start-TestBackup
$tmp = "/tmp/fs-backup/export-$id.tar"
$hashLine = Invoke-TestCompose @("exec", "-T", "backup", "sh", "-c",
"fs-backup export $id --repo $Repo > $tmp && sha256sum $tmp") | Select-Object -Last 1
Assert-LastExit "Export"
try {
Invoke-TestCompose @("cp", "backup:$tmp", $partial)
Assert-LastExit "Copy from the container"
} finally {
Invoke-TestCompose @("exec", "-T", "backup", "rm", "-f", $tmp) | Out-Null
}
} else {
# Export into a file in the Pi user's home (binary data never passes through # Export into a file in the Pi user's home (binary data never passes through
# PowerShell pipes - they would corrupt it), then scp it and compare sha256. # PowerShell pipes - they would corrupt it), then scp it and compare sha256.
$remote = "fs-export-$id.tar" $remote = "fs-export-$id.tar"
@@ -151,7 +110,6 @@ function Invoke-Pull {
} finally { } finally {
Invoke-Pi "rm -f ~/$remote" Invoke-Pi "rm -f ~/$remote"
} }
}
$expected = ("$hashLine".Trim() -split "\s+")[0].ToLower() $expected = ("$hashLine".Trim() -split "\s+")[0].ToLower()
$actual = (Get-FileHash -Algorithm SHA256 $partial).Hash.ToLower() $actual = (Get-FileHash -Algorithm SHA256 $partial).Hash.ToLower()
@@ -172,34 +130,6 @@ function Invoke-Pull {
Write-Host "OK: $local ($sizeMb MB, $files files, sha256 verified)" -ForegroundColor Green Write-Host "OK: $local ($sizeMb MB, $files files, sha256 verified)" -ForegroundColor Green
} }
# -------------------------------------------------------------------- restore-test
function Invoke-RestoreTest {
if (-not $File) { Fail "Specify the archive: -File backups\fs_....tar" }
if (-not (Test-Path $File -PathType Leaf)) { Fail "File not found: $File" }
$full = (Resolve-Path $File).Path
$inContainer = "/import/restore-test.archive" # tar or tar.gz: fs-backup detects the format itself
Write-Host "Practice restore of $full into the LOCAL TEST CLONE (prod is not touched)." -ForegroundColor Cyan
Start-TestBackup
Invoke-TestCompose @("stop", "app")
Assert-LastExit "Stopping the test app"
Invoke-TestCompose @("cp", $full, "backup:$inContainer")
Assert-LastExit "Copy into the container"
try {
Invoke-TestCompose @("exec", "-T", "backup", "fs-backup", "import", $inContainer, "--yes")
$importExit = $LASTEXITCODE
} finally {
Invoke-TestCompose @("exec", "-T", "backup", "rm", "-f", $inContainer) | Out-Null
}
if ($importExit -ne 0) { Fail "Import failed (exit code $importExit). The test clone data was not changed." }
Invoke-TestCompose @("up", "-d", "app")
Assert-LastExit "Starting the test app"
Write-Host "Done. The test clone now runs on the restored data." -ForegroundColor Green
Write-Host " See it on https://forbidden-stars.ru: docker compose -f docker-compose.test.yml up -d (or .\run.ps1 with APP_ENV=test)"
Write-Host " Logs: docker compose -f docker-compose.test.yml logs -f app"
}
# ---------------------------------------------------------------------------- main # ---------------------------------------------------------------------------- main
$prevEncoding = $null $prevEncoding = $null
try { try {
@@ -218,7 +148,6 @@ try {
} }
"verify" { Invoke-FsBackup @("verify"); Assert-LastExit "verify" } "verify" { Invoke-FsBackup @("verify"); Assert-LastExit "verify" }
"pull" { Invoke-Pull } "pull" { Invoke-Pull }
"restore-test" { Invoke-RestoreTest }
default { Show-Help } default { Show-Help }
} }
} finally { } finally {
+4 -53
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Бэкапы Forbidden Stars с ПК (Linux / macOS / Git Bash). Команды уходят в контейнер backup # Бэкапы Forbidden Stars с ПК (Linux / macOS / Git Bash). Команды уходят в контейнер backup
# прода на Pi по SSH или (с --test) в локальный тест-клон (docker-compose.test.yml). # прода на Pi по SSH. На Windows удобнее scripts/fs-backup.ps1 — поведение то же.
# На Windows удобнее scripts/fs-backup.ps1 — поведение то же. Инструкция: deploy/backup/README.md # Инструкция: deploy/backup/README.md
# #
# scripts/fs-backup.sh status состояние бэкапов на Pi # scripts/fs-backup.sh status состояние бэкапов на Pi
# scripts/fs-backup.sh list [vps] хронология снимков # scripts/fs-backup.sh list [vps] хронология снимков
@@ -9,9 +9,6 @@
# scripts/fs-backup.sh verify проверить целостность данных # scripts/fs-backup.sh verify проверить целостность данных
# scripts/fs-backup.sh pull [<id>|latest] [--repo vps] # scripts/fs-backup.sh pull [<id>|latest] [--repo vps]
# скачать снимок в backups/ (сверка sha256) # скачать снимок в backups/ (сверка sha256)
# scripts/fs-backup.sh restore-test <файл.tar|.tar.gz>
# учебное восстановление в локальный тест-клон
# --test первым аргументом — status/list/now/verify/pull для локального тест-клона
# #
# Настройки — из корневого .env (переменная окружения с тем же именем важнее): # Настройки — из корневого .env (переменная окружения с тем же именем важнее):
# BACKUP_PI_SSH как зайти на Pi по SSH, например pi@192.168.1.10 (или Host из ~/.ssh/config) # BACKUP_PI_SSH как зайти на Pi по SSH, например pi@192.168.1.10 (или Host из ~/.ssh/config)
@@ -19,8 +16,6 @@
set -euo pipefail set -euo pipefail
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
TEST_COMPOSE="$PROJECT_DIR/docker-compose.test.yml"
export MSYS_NO_PATHCONV=1 # Git Bash: не переписывать /import/... в аргументах docker
die() { echo "ОШИБКА: $*" >&2; exit 1; } die() { echo "ОШИБКА: $*" >&2; exit 1; }
@@ -35,10 +30,7 @@ setting() { # setting <ключ> <по умолчанию>: окружение,
PI_SSH="$(setting BACKUP_PI_SSH "")" PI_SSH="$(setting BACKUP_PI_SSH "")"
# shellcheck disable=SC2088 # тильда намеренно не раскрывается здесь — её раскроет shell на Pi # shellcheck disable=SC2088 # тильда намеренно не раскрывается здесь — её раскроет shell на Pi
PI_DIR="$(setting BACKUP_PI_DIR "~/forbidden-stars")" PI_DIR="$(setting BACKUP_PI_DIR "~/forbidden-stars")"
TARGET="pi"
if [ "${1:-}" = "--test" ]; then TARGET="test"; shift; fi
native_path() { if command -v cygpath >/dev/null 2>&1; then cygpath -w "$1"; else printf '%s' "$1"; fi; }
sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi; } sha256() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1; else shasum -a 256 "$1" | cut -d' ' -f1; fi; }
pi() { # pi <shell-команда>: выполнить на Pi в папке прода pi() { # pi <shell-команда>: выполнить на Pi в папке прода
@@ -46,22 +38,8 @@ pi() { # pi <shell-команда>: выполнить на Pi в папке п
ssh -o ConnectTimeout=15 "$PI_SSH" "cd $PI_DIR && $1" ssh -o ConnectTimeout=15 "$PI_SSH" "cd $PI_DIR && $1"
} }
tc() { docker compose -f "$(native_path "$TEST_COMPOSE")" "$@"; } fs() { # fs <аргументы fs-backup…>: выполнить fs-backup в контейнере backup на Pi
start_test_backup() {
if [ -z "$(tc ps -q backup 2>/dev/null)" ]; then
echo "Запускаю контейнер backup тест-клона…"
tc up -d --build backup
fi
}
fs() { # fs <аргументы fs-backup…>
if [ "$TARGET" = test ]; then
start_test_backup
tc exec -T backup fs-backup "$@"
else
pi "docker compose exec -T backup fs-backup $*" pi "docker compose exec -T backup fs-backup $*"
fi
} }
cmd_pull() { cmd_pull() {
@@ -84,18 +62,10 @@ cmd_pull() {
partial="$local_file.part" partial="$local_file.part"
echo "Снимок $id ($repo) -> $local_file" echo "Снимок $id ($repo) -> $local_file"
if [ "$TARGET" = test ]; then
start_test_backup
local tmp="/tmp/fs-backup/export-$id.tar"
expected="$(tc exec -T backup sh -c "fs-backup export $id --repo $repo > $tmp && sha256sum $tmp" | tail -n 1 | cut -d' ' -f1)"
tc cp "backup:$tmp" "$(native_path "$partial")" || { tc exec -T backup rm -f "$tmp"; die "копирование из контейнера не удалось"; }
tc exec -T backup rm -f "$tmp"
else
local remote="fs-export-$id.tar" local remote="fs-export-$id.tar"
expected="$(pi "docker compose exec -T backup fs-backup export $id --repo $repo > ~/$remote && sha256sum ~/$remote" | tail -n 1 | cut -d' ' -f1)" expected="$(pi "docker compose exec -T backup fs-backup export $id --repo $repo > ~/$remote && sha256sum ~/$remote" | tail -n 1 | cut -d' ' -f1)"
scp -o ConnectTimeout=15 "$PI_SSH:$remote" "$partial" || { pi "rm -f ~/$remote"; die "scp не удался"; } scp -o ConnectTimeout=15 "$PI_SSH:$remote" "$partial" || { pi "rm -f ~/$remote"; die "scp не удался"; }
pi "rm -f ~/$remote" pi "rm -f ~/$remote"
fi
actual="$(sha256 "$partial")" actual="$(sha256 "$partial")"
if [ "$expected" != "$actual" ]; then if [ "$expected" != "$actual" ]; then
@@ -107,24 +77,6 @@ cmd_pull() {
echo "OK: $local_file ($(du -h "$local_file" | cut -f1), $(tar -tf "$local_file" | grep -vc '/$') файлов, sha256 сверена)" echo "OK: $local_file ($(du -h "$local_file" | cut -f1), $(tar -tf "$local_file" | grep -vc '/$') файлов, sha256 сверена)"
} }
cmd_restore_test() {
local file="${1:-}"
[ -n "$file" ] || die "укажите архив: scripts/fs-backup.sh restore-test backups/fs_....tar"
[ -f "$file" ] || die "файл не найден: $file"
local in_container=/import/restore-test.archive # tar или tar.gz — формат fs-backup определит сам
echo "Учебное восстановление $file в ЛОКАЛЬНЫЙ ТЕСТ-КЛОН (прод не затрагивается)."
start_test_backup
tc stop app
tc cp "$(native_path "$file")" "backup:$in_container"
local rc=0
tc exec -T backup fs-backup import "$in_container" --yes || rc=$?
tc exec -T backup rm -f "$in_container"
[ "$rc" -eq 0 ] || die "импорт не удался (код $rc) — данные тест-клона не изменены."
tc up -d app
echo "Готово: тест-клон работает на восстановленных данных."
echo " На https://forbidden-stars.ru: docker compose -f docker-compose.test.yml up -d (или ./run.sh при APP_ENV=test)"
}
cmd="${1:-help}" cmd="${1:-help}"
[ $# -eq 0 ] || shift [ $# -eq 0 ] || shift
case "$cmd" in case "$cmd" in
@@ -133,6 +85,5 @@ case "$cmd" in
now) fs run "$@" ;; now) fs run "$@" ;;
verify) fs verify ;; verify) fs verify ;;
pull) cmd_pull "$@" ;; pull) cmd_pull "$@" ;;
restore-test) cmd_restore_test "$@" ;; *) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//' ;;
*) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//' ;;
esac esac